SpringBoot + PostgreSQL + Hibernate ) 간단한 게시판 만들기

작성: 2021.08.08

수정: 2021.08.08

읽는시간: 00 분

Programming/JPA ⁄ Spring

반응형

Intro

앞으로 맡을 프로젝트에서 SpringBoot 와 PostgreSQL 그리고 Hibernate를 기술스택으로 사용하게 될 것 같습니다.

여태 배웠던 Spring Legacy Project + Oracle Database + Mybatis 환경에서 조금씩은 달라 지겠지만, 크게 다를 것은 없기 때문에 금방 적응 할 수 있을 것이라고 믿습니다.

그래도 조금이라도 빨리 해당 기술들에 대한 막연함을 해소하고, 업무에 대한 준비를 갖추고 싶어 틈틈이 시간나는 대로 가장 기본적인 게시판을 만들어 보았습니다. 데이터 검증 과정이 없고 화면 구성이 투박하지만, 그 만큼 쉬운 코드로 간단하게 작성 해 보았습니다.

Body

제일 먼저 할일은 데이터 베이스 구축입니다.

PostgreSQL 이 준비 되어있다면 그대로 사용 하시면 되고, 혹시 이번기회에 Docker에 설치 해 볼 생각이시거나 아직 PostgreSQL이 준비가 되어 있지 않으시면

💻 Windows

https://shanepark.tistory.com/188

💻 MacOS

https://shanepark.tistory.com/186

https://shanepark.tistory.com/194

MacOS ) m1 맥북 docker 설치하기 + 가상환경에 postgreSQL 띄워 보기

각자 본인의 운영체제에 맞게 postgreSQL 서버를 먼저 구축 해 주세요.

데이터베이스 서버를 구축 한 후에는 board 테이블을 아래와 같이 생성해줍니다. serial 타입은 자동으로 1씩 증가하는 PK 값을 만들도록 해줍니다. 테이블 만들고 테스트용으로 데이터도 몇개 넣어주세요.

CREATE TABLE public.board (
    boardno serial NOT NULL,
    title varchar NULL,
    "content" varchar NULL,
    writer varchar NULL,
    CONSTRAINT board_pk PRIMARY KEY (boardno)
);

제 게시판의 데이터인데 딱히 필요는 없겠지만 기본 데이터로 사용하고 싶으면 아래 쿼리를 실행 해서 데이터 추가 하셔도 됩니다.

INSERT INTO public.board (title,"content",writer) VALUES
     ('2번째 글 제목','2번째 내용','2번작성자'),
     ('새글','새글 써봅니다.','새글맨'),
     ('글 수정','수정도 잘됩니다.','수정맨'),
     ('1번째 글 제목','1번째 내용,내용','1번작성자');

데이터베이스가 준비 되었으면 이제 프로젝트를 생성 해 보겠습니다.

Spring Starter Project를 생성하겠습니다.

img

원하는 Name, Group, Artifact, 바자 버전 등을 입력 합니다. Maven, Gradle 무관 합니다.

저는 Maven 은 써봤는데 Gradle을 거의 안써봐서 Gradle 로 프로젝트를 만들어 보았는데요, Maven으로 생성해도 무관합니다.

img

Spring 버전과 사용할 기능들을 선택 합니다.

Web Project 기 때문에 Spring Web, 그리고 postgreSQL 을 사용하기 때문에 PostgreSQL Driver를 추가하고 Hibernate 사용을 위해 Spring Data JPA, 그리고 Lombok을 한번 추가 해 보았습니다. 사진상에는 없지만 Thymeleaf도 추가해주세요.

img

이제 Finish 버튼을 누르면 프로젝트가 생성됩니다.

img

Template Engine은 Thymeleaf를 사용합니다. 저는 아까 까먹고 안넣어서 dependencies에 추가했습니다.

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'Copy

img

전체 build.gradle 은 다음과 같습니다.

plugins {
    id 'org.springframework.boot' version '2.5.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

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

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.postgresql:postgresql'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

이제 application.properties에 DB 접속 관련 정보를 등록해줍니다. PUT, DELETE 메서드를 사용 하기 위해 hiddenmethod filter도 넣어줬습니다.

spring.datasource.url=jdbc:postgresql://localhost:5432/study
spring.datasource.username=testuser
spring.datasource.password=test
spring.mvc.hiddenmethod.filter.enabled=true

일단 RESTful API 방식으로 백엔드를 만들어 보겠습니다. Package 구조는 아래와 같이 만들었습니다.

img

게시판 Model은 Board 라는 이름으로 만들어 뒀습니다.

PK를 AutoIncrement 로 했을 경우 문제가 발생해 @GeneratedValue(strategy 를 변경 해 주었는데요, 여기에 대한 자세한 내용은 이동욱님의 블로그 https://jojoldu.tistory.com/295 에 잘 설명이 되어 있습니다.

아주 기본적인 테스트 형식이라 어노테이션이 적게 달려 있지만 실제로는 데이터 검증이나 Not Null 등의 옵션이 모두 있어야 합니다.

package com.shanep.model;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Entity
@Table(name="board")
@Getter
@Setter
@ToString
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer boardno;

    private String title;
    private String content;
    private String writer;

}

그 외에 응답 데이터를 보내기 위해 Result class와 ErrorResponse class 도 만들어 주었습니다.

package com.shanep.model;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
public class Result {
    private ErrorResponse error;
    private Object payload;
}
package com.shanep.model;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
public class ErrorResponse {
    private Integer code;
    private String message;

    public ErrorResponse(String message) {
        this.message = message;
    }

}

Repository 는 간단하게 전체 조회하는 메서드만 추가 해 주었습니다.

package com.shanep.repositories;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.shanep.model.Board;

@Repository
public interface BoardRepository extends JpaRepository<Board, Integer> {
    public List<Board> findAllByOrderByBoardnoDesc();

}

Hibernate 는 위의 메서드 명을 바탕으로 select * from board order by boardno desc; 쿼리를 알아서 만들어줍니다.

서비스 인터페이스를 만들어주고

package com.shanep.service;

import com.shanep.model.Board;
import com.shanep.model.Result;

public interface BoardService {
    public Result createBoard(Board board);
    public Result retrieveBoardList();
    public Result retrieveBoard(int boardno);
    public Result updateBoard(Board board);
    public Result deleteBoard(int boardno);
}

아주 기본적인 C/R/U/D restapi를 만들어 보았습니다.

package com.shanep.controller;

import org.apache.logging.log4j.LogManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.shanep.model.Board;
import com.shanep.model.Result;
import com.shanep.repositories.BoardRepository;
import com.shanep.service.BoardService;

@RestController
@RequestMapping(value="restapi/board")
public class BoardRestController {

    private static final org.apache.logging.log4j.Logger logger = LogManager.getLogger(BoardRestController.class);

    @Autowired
    BoardRepository repository;

    @Autowired
    BoardService boardService;

    @GetMapping
    public Result retrieveBoardList() {
        Result result = boardService.retrieveBoardList();
        return result;
    }

    @GetMapping("/{boardno}")
    public Result retrieveBoard(@PathVariable Integer boardno) {
        Result result = boardService.retrieveBoard(boardno);
        return result;
    }

    @PostMapping
    public Result createBoard(@ModelAttribute Board board) {
        Result result = boardService.createBoard(board);
        return result;
    }

    @PutMapping
    public Result updateBoard(@ModelAttribute Board board) {
        Result result = boardService.updateBoard(board);
        return result;
    }

    @DeleteMapping
    public Result deleteBoard(@RequestParam int boardno) {
        Result result = boardService.deleteBoard(boardno);
        return result;
    }

}

서비스 구현체도 간단하게 만듭니다.

package com.shanep.service;

import java.util.List;
import java.util.Optional;

import org.apache.logging.log4j.LogManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.shanep.enumpkg.ServiceResult;
import com.shanep.model.Board;
import com.shanep.model.ErrorResponse;
import com.shanep.model.Result;
import com.shanep.repositories.BoardRepository;

@Service
public class BoardServiceImpl implements BoardService{

    private static final org.apache.logging.log4j.Logger logger = LogManager.getLogger(BoardServiceImpl.class);

    @Autowired
    BoardRepository repository;

    public Result updateBoard(Board board) {
        Optional<Board> search = repository.findById(board.getBoardno());
        Result result = new Result();
        if(search.isPresent()) {
            board = repository.save(board);
            result.setPayload(board);
        }else {
            result.setError(new ErrorResponse(ServiceResult.NOTEXIST.toString()));
        }
        return result;
    }

    public Result deleteBoard(int boardno) {
        Result result = new Result();
        boolean isPresent = repository.findById(boardno).isPresent();
        if(!isPresent) {
            result.setError(new ErrorResponse(ServiceResult.NOTEXIST.toString()));
        }else {
            repository.deleteById(boardno);
        }
        return result;
    }

    @Override
    public Result createBoard(Board board) {
        board = repository.save(board);
        Result result = new Result();
        result.setPayload(board);
        return result;
    }

    @Override
    public Result retrieveBoardList() {
        List<Board> list = repository.findAllByOrderByBoardnoDesc();
        Result result = new Result();
        result.setPayload(list);
        return result;
    }

    @Override
    public Result retrieveBoard(int boardno) {
        Optional<Board> optionalBoard = repository.findById(boardno);
        Result result = new Result();
        if(optionalBoard.isPresent()) {
            result.setPayload(optionalBoard.get());
        }else {
            result.setError(new ErrorResponse(ServiceResult.NOTEXIST.toString()));
        }
        return result;
    }

}

이걸로 백엔드는 모두 준비되었습니다. 서버를 실행해서 테스트 해봅니다.

API test는 POSTMAN 을 이용했습니다. 사실 GET 메서드만 테스트 해 보려면 크롬에 그냥 해당 주소를 넣어도 테스트 할 수 있습니다.

img

해당 주소로 테스트 했을 때 저장된 모든 목록을 받아왔습니다.

이번엔 POST 요청으로 글을 등록해봅니다.

코드 작성한 대로, 해당 데이터대로 insert 하고 해당 board 객체를 그대로 return 합니다.

img

error 가 없기 때문에 error 는 null 입니다.

PUT 메서드로 수정 해 보겠습니다.

img

수정도 잘 되네요.

마지막으로 DELETE 메서드로 삭제 해 보겠습니다.

img

삭제 성공에 대한 응답을 따로 안만들어서 불편하네요. error 가 없으면 성공한걸로 간주할 수도 있고, 성공에 대한 응답을 따로 만드는 것도 좋을 것 같습니다. DB를 확인하니 잘 삭제 되었습니다.

위의 API 들을 바탕으로 게시판 페이지도 간략하게 만들어 보았는데요, 사실 API 서버와 WAS 서버가 같이 있기 때문에 요청을 실제 RESTAPI로 보내는 건 게시판 목록 보여줄때 fetch로 받아올때 딱 한번 사용했습니다. 그 외에는 모두 서비스에서 데이터 호출해 만들어 보았습니다.

img

뷰는 이렇게 구성했습니다.

모든 게시판에 대한 컨트롤러는 아래 코드 하나로 다 처리했습니다.

package com.shanep.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import com.shanep.model.Board;
import com.shanep.service.BoardService;

@Controller
public class UrlController {

    @Autowired
    BoardService boardService;

    @GetMapping(value={"/","board"})
    public ModelAndView boardList(ModelAndView mav) {
        mav.setViewName("page/board");
        return mav;
    }

    @GetMapping("board/{boardno}")
    public ModelAndView board(
            ModelAndView mav
            , @PathVariable int boardno) {
        mav.setViewName("page/view");
        Board board = (Board) boardService.retrieveBoard(boardno).getPayload();
        mav.addObject(board);
        return mav;
    }
    @GetMapping("board/{boardno}/delete")
    public ModelAndView delete(
            ModelAndView mav
            , @PathVariable int boardno) {
        mav.setViewName("redirect:/");
        boardService.deleteBoard(boardno);
        return mav;
    }

    @GetMapping("board/write")
    public ModelAndView write(ModelAndView mav) {
        mav.setViewName("page/write");
        return mav;
    }
    @PostMapping("board/write")
    public ModelAndView writeView(
            ModelAndView mav
            ,@ModelAttribute Board board) {
        mav.setViewName("redirect:/");
        boardService.createBoard(board);
        return mav;
    }

    @GetMapping("board/{boardno}/edit")
    public ModelAndView editView(
            ModelAndView mav
            , @PathVariable int boardno) {
        mav.setViewName("page/edit");
        Board board = (Board) boardService.retrieveBoard(boardno).getPayload();
        mav.addObject(board);
        return mav;
    }
    @PostMapping("board/{boardno}/edit")
    public ModelAndView edit(
            ModelAndView mav
            , @PathVariable int boardno
            , @ModelAttribute Board board) {
        mav.setViewName(String.format("redirect:/board/%d", boardno));
        boardService.updateBoard(board);
        return mav;
    }
}

그 외 각각 board.html은 게시판 목록 . write.html은 게시판 글 쓰기, edit.html은 수정. 그리고 view.html 은 한개의 게시판 글 확인을 할 수 있도록 구성 해 보았습니다. Thymeleaf를 이용해 작성했습니다.

board.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorator="layout/layout">

<head>
<meta charset="UTF-8">
<style>
    table{
        border:1px solid black;
        font-size : 1.5em;
    }
    tbody tr:nth-child(1){
        width : 50px;
    }
    tbody tr:nth-child(2){
        width : 100px;
    }
    tbody tr:nth-child(3){
        width : 50px;
    }
    button{
        margin-top : 10px;
        margin-left : 400px;
    }
</style>
<title>board</title>
</head>
<script>
    fetch('/restapi/board', {
        method:'GET'
    }).then(function (response) {
        return response.json();
    }).then(function (result){
            if(!result.error){
                let data = result.payload;
                let table = document.getElementById('board');
                for(let i in data){
                    board = data[i];
                    let row = table.insertRow(parseInt(i)+1);
                    let cell0 = row.insertCell(0);
                    let cell1 = row.insertCell(1);
                    let cell2 = row.insertCell(2);
                    let url = '/board/' + board.boardno;
                    cell0.innerHTML = board.boardno;
                    cell1.innerHTML = '<a href="'+url+'">'+board.title + '</a>';
                    cell2.innerHTML = board.writer;
                }
            }
    }).catch(function (err){
        console.warn(err);
    })
</script>
<body>
    <table id="board">
        <thead>
            <tr>
                <th>글번호</th>
                <th>제목</th>
                <th>작성자</th>
            </tr>
        </thead>
        <tbody>
        </tbody>
        <tfoot>
            <tr>
                <td colspan="3">
                    <button onclick="location.href = '/board/write';">글 작성</button>
                </td>
            </tr>
        </tfoot>
    </table>
</body>
</html>

write.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
    form div{
        margin : 5px;
    }
    label{
        width : 50px;
        display : inline-block;
    }
    .btns{
        margin-left : 100px;
    }
</style>
<title>Insert title here</title>
</head>
<body>
    <h1>게시판 글 작성</h1>
    <form id="writeForm" action="" method="post">
        <div>
            <label for="title">제목</label>
            <input type="text" id="title" name="title">
        </div>
        <div>
            <label for="writer">작성자</label>
            <input type="text" id="writer" name="writer">
        </div>
        <div>
            <label for="content">내용</label>
            <textarea id="content" name="content"></textarea>
        </div>
    </form>
    <div class="btns">
        <button type="button" onclick="location.href = '/board';">취소</button>
        <button type="submit" form="writeForm">저장</button>
    </div>
</body>
</html>

edit.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<style>
    form div{
        margin : 5px;
    }
    label{
        width : 50px;
        display : inline-block;
    }
    .btns{
        margin-left : 100px;
    }
</style>
<title>board</title>
</head>
<body>
    <h1>게시판 글 수정</h1>
    <form id="editForm" method="post">
        <input th:value="${board.boardno}" hidden="hidden">
        <div>
            <label for="title">제목</label>
            <input type="text" id="title" name="title" th:value="${board.title}">
        </div>
        <div>
            <label for="writer">작성자</label>
            <input type="text" id="writer" name="writer" th:value="${board.writer}">
        </div>
        <div>
            <label for="content">내용</label>
            <textarea id="content" name="content" th:text="${board.content}">
            </textarea>
        </div>
    </form>
    <div class="btns">
        <button type="button" onclick="window.history.go(-1); return false;">취소</button>
        <button type="submit" form="editForm">저장</button>
    </div>
</body>
</html>

view.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
    <h1 th:text="${board.boardno}+'. '+${board.title}"></h1>
    <h3 th:text="'작성자 : ' + ${board.writer}"></h3>
    <p th:text="${board.content}"></p>
    <button onclick="location.href='/board'">목록으로</button>
    <button onclick="location.href=window.location.href+'/edit'">수정하기</button>
    <button onclick="location.href=window.location.href+'/delete'">삭제하기</button>
</body>
</html>

이제 결과물 입니다. 꽤나 투박합니다.

http://localhost:8080/ 으로 접속하면 아래의 페이지가 보여집니다.

img

img

그 중 한 글을 클릭해 들어가면 이처럼 글 제목, 작성자, 글 내용을 보여줍니다. 목록으로 돌아갈 수도 있으며 글 수정, 삭제가 가능합니다.

수정하기를 클릭하면

img

이처럼 간단하게 게시판을 수정 할 수 있습니다. hidden 타입으로 form 태그 안에 게시글 번호가 숨겨져 있도록 해 두었습니다.

게시글 작성은 아래 처럼 작성 해 두었습니다.

img

정말 간단한 게시판을 만들어 보았는데요, 데이터 검증이나 예외 처리, 그리고 UX 개선등의 필요한 사항들이 남아있지만 간단하게 Spring Boot, PostgreSQL, Hibernate를 체험 해 보기 위해 만든 프로젝트기 때문에 이정도로 일단 포스팅을 마치겠습니다.

해당 프로젝트의 코드는 아래 링크에서 자세히 확인 하거나 다운 받으실 수 있습니다. 수고하셨습니다.

https://github.com/Shane-Park/markdownBlog/tree/master/projects/postgresql

반응형