[스프링 부트/ Spring Boot] 스프링 게시판 만들기 - 부트로 쉽게 구현한 Spring 게시판

| 스프링 게시판 만들기 - 부트로 쉽게 구현한 Spring 게시판


예제 git repository는 여기를 클릭하시면 됩니다.


스프링 게시판은 스프링 MVC로 스프링 부트에서 밀고있는 툴인 Thymeleaf를 사용하여 쉽게 만들 수 있습니다. REST API + SPA( React, Vue 등 )으로 만들 수 있지만 간단한 커뮤니티 사이트 구현을 위해서는 조금 과한 기술스택을 사용하는 것이 아닌 지 생각해 봐야 합니다.


스프링 MVC를 사용했을 때의 데이터 흐름은 아래의 링크를 참고하여 보시면 될 것 같습니다.


[Spring/Spring 입문 - 개념 및 핵심] - [Spring] 스프링(Spring) MVC 아키텍처/설계 구조


위에서 JSP를 Thymeleaf라고 생각하고 읽으시면 스프링 부트 MVC에서의 데이터가 어떻게 흘러가는 지 알 수 있습니다.


요구 사항


인텔리제이 (Intellij) ( 이클립스로도 무방 )

Gradle 4 버전

Java 1.8 이상


| 스프링 게시판 프로젝트 


프로젝트 구조

\---src
+---main
| +---java
| | \---com
| | \---tutorial
| | \---springboard
| | | AppRunner.java
| | | SpringBoardApplication.java
| | |
| | +---config
| | +---controller
| | | BoardController.java
| | |
| | +---domain
| | | | Board.java
| | | | User.java
| | | |
| | | \---enums
| | | BoardType.java
| | |
| | +---repository
| | | BoardRepository.java
| | | UserRepository.java
| | |
| | \---service
| | BoardService.java
| |
| \---resources
| | application.yml
| |
| +---static
| | +---css
| | +---images
| | \---js
| |
| \---templates
| \---board
| list.html


의존성 관리


Gradle

plugins {
id 'org.springframework.boot' version '2.1.5.RELEASE'
id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.tutorial'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}


소스 코드

import com.tutorial.springboard.domain.enums.BoardType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table
@Builder
public class Board {

@Id
@Column
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long idx;

@Column
private String title;

@Column
private String subTitle;

@Column
private String content;

@Column
@Enumerated(EnumType.STRING)
private BoardType boardType;

@Column
private LocalDateTime createdDate;

@Column
private LocalDateTime updatedDate;

@OneToOne(fetch = FetchType.LAZY)
private User user;

}

package com.tutorial.springboard.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table
@Builder
public class User implements Serializable {

@Id
@Column
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long idx;

@Column
public String name;

@Column
public String password;

@Column
public String email;

@Column
public LocalDateTime createdDate;

@Column
public LocalDateTime updatedDate;

}
  • 유저정보를 나타내는 User 클래스와 게시글을 나타내는 Board 클래스입니다.
  • @GeneratedValue는 자동적으로 idx의 값을 할당해주는 어노테이션입니다. 여기서는 전체 DB 범위로 아이디의 값을 관리하는 GenerationType.IDENTITY 옵션을 썼습니다.


import com.tutorial.springboard.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
}

import com.tutorial.springboard.domain.Board;
import com.tutorial.springboard.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<Board, Long> {
Board findByUser(User user);
}


import com.tutorial.springboard.domain.Board;
import com.tutorial.springboard.service.BoardService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("/board")
public class BoardController {

@Autowired
BoardService boardService;

@GetMapping({"", "/"})
public String board(@RequestParam(value="idx", defaultValue = "0") Long idx,
Model model) {
model.addAttribute("board", boardService.findBoardByIdx(idx));
return "/board/form";
}

@GetMapping("/list")
public String list(@PageableDefault Pageable pageable, Model model) {
Page<Board> boardList = boardService.findBoardList(pageable);
boardList.stream().forEach(e -> e.getContent());
model.addAttribute("boardList", boardList);

return "/board/list";
}

}
  • BoardController는 /board URL을 매핑하며 /board/list 을 관리합니다. 
  • /board/list 는 게시글의 데이터를 FETCH하는 데 쓰이며 서비스 계층인 BoardService에서 해당 페이징 처리를 하게 됩니다.
  • Pageable은 페이지 요청을 받을 수 있는 인터페이스입니다. 보통 Pageable의 구현체인 PageRequest의 인스턴스를 받아 이 안에 있는 데이터를 가지고 페이징 관련 처리를 하게 됩니다.
  • Model은 View 층에서 JSP나 Thymeleaf와 같은 템플릿 엔진이 동적으로 HTML 페이지를 만드는 데 필요한 데이터를 제공해줍니다. 위와 같은 경우 boardList 속성 혹은 board 속성에 대한 데이터를 model에 추가하는 것을 볼 수 있습니다. 이 데이터는 뒤에서 보실 Thymeleaf 코드에서 게시글을 만들 때 쓰여집니다.

import com.tutorial.springboard.domain.Board;
import com.tutorial.springboard.repository.BoardRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
public class BoardService {

@Autowired
private BoardRepository boardRepository;

public Page<Board> findBoardList(Pageable pageable) {
pageable = PageRequest.of(
pageable.getPageNumber() <= 0 ? 0 : pageable.getPageNumber()-1,
pageable.getPageSize());
return boardRepository.findAll(pageable);
}

public Board findBoardByIdx(Long idx) {
return boardRepository.findById(idx).orElse(new Board());
}
}
  • 게시글 리스트뿐만 아니라 게시글 하나에 대한 요청도 처리할 수 있게 BoardService 클래스를 작성했습니다.
  • Spring에서는 Page 클래스로 페이징 요청을 관리합니다. 이 Page 클래스는 페이징에 관련된 여러 요청을 손쉽게 처리할 수 있도록 만들어진 클래스입니다.
  • 보통 Pageable의 구현체인 PageRequest의 인스턴스를 받아 이 안에 있는 데이터를 가지고 페이징 관련 처리를 하게 됩니다. 이 정보를 JpaRepository의 구현체에게 넘겨주면 스프링에서 자동적으로 메서드 인터페이스를 해석하여 페이징 데이터를 DB에서 FETCH하여 넘겨주게 됩니다.

import com.tutorial.springboard.domain.Board;
import com.tutorial.springboard.domain.User;
import com.tutorial.springboard.domain.enums.BoardType;
import com.tutorial.springboard.repository.BoardRepository;
import com.tutorial.springboard.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.stream.IntStream;

@Component
public class AppRunner implements ApplicationRunner {

@Autowired
UserRepository userRepository;

@Autowired
BoardRepository boardRepository;

@Override
public void run(ApplicationArguments args) throws Exception {
User user = userRepository.save(User.builder()
.name("saelobi")
.password("saelobi")
.email("saelobi@gmail.com")
.createdDate(LocalDateTime.now())
.build());

IntStream.rangeClosed(1, 200).forEach(index ->
boardRepository.save(Board.builder()
.title("Content " + index)
.subTitle("Order " + index)
.content("Content Example " + index)
.boardType(BoardType.free)
.createdDate(LocalDateTime.now())
.updatedDate(LocalDateTime.now())
.user(user).build()));
}

}
  • 위는 ApplicationRunner를 구성해서 애플리케이션이 켜질  어떻게 작동할 지를 정하는 run 메서드를 오버라이드 받아 씁니다.
  • 개발시에 테스트 데이터를 받아서 개발하는 경우가 많지만 그것이 여의치 않았을 때 위와 같이 테스트 DB에 데이터를 추가하여 개발 수도 있습니다.

public enum BoardType {
notice("공지사항"),
free("자유게시판");

private String value;

BoardType(String value) {
this.value = value;
}

public String getValue() {
return this.value;
}

}
  • BoardType에 대한 소스 코드입니다.


<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Board Form</title>
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
crossorigin="anonymous">
<style>
/*html{position:relative;min-height:100%;}*/
body{
margin-bottom:60px;
}

body > .container{
padding:60px 15px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1>게시글 목록</h1>
</div>
<div class="pull-right" style="width:100px;margin:10px 0;">
<a href="/board" class="btn btn-primary btn-block">등록</a>
</div>
<br/><br/><br/>

<div id="mainHide">
<table class="table table-hover">
<thead>
<tr>
<th class="col-md-1">#</th>
<th class="col-md-2">서비스분류</th>
<th class="col-md-5">제목</th>
<th class="col-md-2">작성날짜</th>
<th class="col-md-2">수정날짜</th>
</tr>
</thead>
<tbody>
<tr th:each="board : ${boardList}">
<td th:text="${board.idx}"></td>
<td th:text="${board.boardType.value}"></td>
<td><a th:href="'/board?idx='+${board.idx}" th:text="${board.title}"></a></td>
<td th:text="${board.createdDate} ?
${#temporals.format(board.createdDate,'yyyy-MM-dd HH:mm')} : ${board.createdDate}"></td>
<td th:text="${board.updatedDate} ?
${#temporals.format(board.updatedDate,'yyyy-MM-dd HH:mm')} : ${board.updatedDate}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<br/>

<nav aria-label="Page navigation" style="text-align:center;">
<ul class="pagination" th:with="startNumber=${T(Math).floor(boardList.number/10)}*10+1, endNumber=(${boardList.totalPages} > ${startNumber}+9) ? ${startNumber}+9 : ${boardList.totalPages}">
<li><a aria-label="Previous" href="/board/list?page=1">&laquo;</a></li>
<li th:style="${boardList.first} ? 'display:none'">
<a th:href="@{/board/list(page=${boardList.number})}">&lsaquo;</a>
</li>

<li th:each="page :${#numbers.sequence(startNumber, endNumber)}" th:class="(${page} == ${boardList.number}+1) ? 'active'">
<a th:href="@{/board/list(page=${page})}" th:text="${page}"><span class="sr-only"></span></a>
</li>

<li th:style="${boardList.last} ? 'display:none'">
<a th:href="@{/board/list(page=${boardList.number}+2)}">&rsaquo;</a>
</li>
<li><a aria-label="Next" th:href="@{/board/list(page=${boardList.totalPages})}">&raquo;</a></li>
</ul>
</nav>
</body>
</html>
  • 위는 게시글을 동적으로 생성하는 Thymeleaf 문법으로 만든 파일입니다. 컨트롤러에서 보내는Model 객체에서 boardList 에 대한 정보를 꺼내와 페이지를 동적으로 만든 것입니다.
  • nav 부분을 보면 페이징의 startNumber와 endNumber에 대한 것을 정하고 HTML 페이지에 반영하는 것을 볼 수 있습니다. 

결과 화면




참고자료 : http://www.yes24.com/Product/Goods/64584833?scode=032&OzSrank=1



이 글을 공유하기

댓글(17)

  • 바로 위에 비밀로 질문했던 사람입니다
    2019.11.14 10:53

    제가 바로 위에 댓글을 달았었는데 로그인을 안한 채로 달았더니 답글을 볼 수가 없네요ㅠㅠ
    boardList.stream().forEach(e -> e.getContent());
    를 다시 설명해주신다면 감사하겠습니다 !

    • 2019.11.15 11:02 신고

      안녕하세요! 댓글 달아주셔서 감사합니다~

      boardList는 Page의 리스트들을 담아놓은 객체를 가르키는 변수입니다. (Page의 타입은 Board)

      stream은 자바8에서 제공하는 데이터 스트림 기능입니다. 리스트와 같은 객체를 순차적으로 쉽게 처리하기 위해 사용하죠. 이 stream 객체를 이용하여 Page 리스트들을 쉽게 for loop 를 사용하지 않고 바로 처리할 수있습니다~ ( 공장 컨베이어 벨트에서 날라지는 물품들을 생각하시면 됩니다)

      이 스트림 데이터들에 forEach 구문을 써서 각각의 객체들을 getContent라는 메서드를 이용하여 컨텐츠를 뽑아내는 것입니다. 여기서 e는 페이지 객체입니다~

      지금 회사라서 눈치보여서 저 코드를 바로 테스트는 못할 것 같아요 ㅠㅠ 집에서 테스트 돌려보고 또 댓글 달아드릴게요! 감사합니다!

  • chul
    2019.11.14 19:56

    이대로만 하면 appRunner랑 controller 부분에서 오류가 납니당..

    • 2019.11.15 10:52 신고

      댓글 감사합니다! 어떤 에러인지 설명 해주실 수 있을까요?

    • 2019.12.05 22:46 신고

      혹시 아래 댓글과 같은 에러 때문이라면 아래에 달아놓은 댓글 참고하셔서 문제 처리하시면 될 것 같아요!

  • 2019.12.03 23:18 신고

    안녕하세요~ 저도 윗 분처럼 에러가 나는데 현재 에러 로그 전달 드립니다!

    /Users/odongjin/elasticboard/src/main/java/com/tutorial/springboardelastic/example/controller/BoardController.java
    Error:(31, 42) java: cannot find symbol
    symbol: method getContent()
    location: variable e of type com.tutorial.springboardelastic.example.domain.Board
    /Users/odongjin/elasticboard/src/main/java/com/tutorial/springboardelastic/example/AppRunner.java
    Error:(27, 45) java: cannot find symbol
    symbol: method builder()
    location: class com.tutorial.springboardelastic.example.domain.User
    Error:(35, 43) java: cannot find symbol
    symbol: method builder()
    location: class com.tutorial.springboardelastic.example.domain.Board

    • 아조씨
      2019.12.05 12:03

      저도 똑같은 오류가 나네요..

    • 2019.12.05 14:06 신고

      댓글 감사합니다! 그러셨군요;; 오늘 저녁에 집에가서 테스팅한 후 git에 코드 올리도록 하겠습니다~!

    • 2019.12.05 22:30 신고

      아 이건 lombok의 annotation을 제대로 인식하지 못해서 lombok에서 자바 코드를 생성하지 못해서 발생하는 문제입니다.

      intellij 기준 File -> Settings -> Annotation Processors 에 가셔서
      Enable annotation processing 을 체크하시면 됩니다!

      이클립스를 사용하시는 분들 께서는 아래 링크를 참조하시면 될 것 같아요~
      https://ojava.tistory.com/131

    • 2019.12.05 22:32 신고

      헐... 한 번 경험해본 이슈였는데 생각을 못했네요.. ㅜㅜ 말씀해주셔서 너무 감사합니다!!

    • 2019.12.05 22:45 신고

      도움이 되셨다니 감사합니다!

      git에 예제 코드도 업로드하였으니 참고 부탁드려요~

      https://github.com/engkimbs/springboard

    • 2019.12.05 22:46 신고

      넵~!! ㅎㅎ

  • 지나가는...
    2019.12.17 11:44

    게시글과 댓글 간의 부모 자식 관계를 annotation 줄 때 어떤 식으로 하는지 알 수 있을까요?
    게시글에 @OneToMany이고 댓글에 @ManyToOne인가요?

    • 2019.12.19 00:48 신고

      네 맞습니다. 댓글에 해당하는 class에 게시글을 나타내는 변수 post가 있으면 거기에 @ManyToOne을 붙이면 됩니다.

      게시글에 해당하는 class에서는 @OneToMany가 되겠죠.

  • 2019.12.23 10:27 신고

    안녕하세요 oracleman님 갑자기 댓글이 삭제됬다고 해서 대댓글 남길려는데 안되네요 ㅎㅎ; 여기라도 남길게요

    1. 네 그렇게 생각하시면 됩니다!
    2. react, angular, vue는 프론트엔드에서 쓰이는 자바스크립트 웹 프레임워크입니다. ( react는 엄밀히 말하면 library인데 거의 뭐 프레임워크같이 쓰입니다)
    어떤 용도로 사용하냐면은 SPA( Single Page Application )에서 프론트 부분을 만들 때 쓰입니다.

    여기서 SPA라는 개념을 헷갈리실 수 있는데 예전에 서버에서 요청때마다 화면을 그려서 HTML 형태로 전송했던 방식이었습니다. 하지만 SPA는 처음 한 번 사용자에게 한 번의 페이지가 전송되면 거기에서부터 거의 모든 화면처리는 사용자측( 클라이언트 단)에서 하게 되는 개념입니다.

    쉽게말하면, 요청때마다 서버에서 화면을 그려서 전송해주는 것이 MVC, 서버에다가 일일히 요청안하고 한 번 사용자에게 화면이 로딩되면 거기서 모든 화면이나 자잘한 로직을 처리하는 것이 SPA라고 생각하면 편합니다! (이 역시 하나하나 디테일하게 따지고 들면 짚고 넘어가야할 부분이 많지만 퉁쳐서 이렇게 생각하면 개념 이해하기 훨씬 쉽습니다)

    그렇다면 SPA에서 서버에서는 아무것도 안 하는 걸까요? 클라이언트 단에서 화면 다 그려주는데..

    아닙니다 클라이언트에서 화면을 그리는 로직은 자바스크립트에서 행하지만 거기서 필요한 이미지나, 사용자 정보, 물품 정보 등은 서버에서 제공합니다. 즉 SPA에서 서버는 데이터만 제공해주는 역할을 하는 것으로 생각하면 됩니다! 이때 데이터를 제공해주는 것을 보통 REST 형태로 제공해주고 이 데이터를 주고받는 인터페이스를 REST API라고 합니다!

    3. 위에 내용 참고하시면 됩니다. REST가 무엇인지는 제 블로그 내용이나
    https://engkimbs.tistory.com/855?category=789178
    혹은 다른 문서 참고하셔서 감잡으시면 될 것 같아요~

    혹시 더 궁금하신 게 있으시면 010-3354-5349 전화번호로 문자나 카카오톡으로 문의주시면 답변해드릴 수 있어요 ㅎㅎ 부담가지지 마시고 물어봐주세요~~. 프로그래밍이나 취업 꿀팁같은 것도 알려드릴게요 ㅎㅎ

  • 어드민
    2020.02.12 14:11

    안녕하세요

    C:\BootProject\Spring_Boot-Community-Web\src\test\java\com\web\JpaMappingTest.java:34: error: cannot find symbol
    User user = userRepository.save(User.builder()
    ^
    symbol: method builder()
    location: class User

    오류가 발생하네요ㅠㅠㅠㅠㅠ

    Enable annotation processing 체크했음에도 계속 발생하는데ㅠㅠ

    • 2020.02.12 17:11 신고

      lombok 및 gradle 버전 충돌일 가능성이 크네요~

      링크 => https://github.com/rzwitserloot/lombok/issues/1716

      해결 방법은 버전을 최신 버전을 명시해주고 compile 해보시면 될 것 같습니다.

      링크 => https://projectlombok.org/setup/gradle

      repositories {
      mavenCentral()
      }

      dependencies {
      compileOnly 'org.projectlombok:lombok:1.18.12'
      annotationProcessor 'org.projectlombok:lombok:1.18.12'

      testCompileOnly 'org.projectlombok:lombok:1.18.12'
      testAnnotationProcessor 'org.projectlombok:lombok:1.18.12'
      }

Designed by JB FACTORY