Programming/JPA ⁄Spring

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

📝 작성 : 2021.08.08  ⏱ 수정 : 
반응형

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

 

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

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

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

 


 

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

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

💻 Windows

https://shanepark.tistory.com/188

 

Windows) Docker 설치하기. + 도커 가상환경에 PostgreSQL 설치하기, WSL 2 installation is incomplete. 오류 해결

Windows) Docker 설치하기. + 도커 가상환경에 PostgreSQL 설치하기,  WSL 2 installation is incomplete. 오류 해결 Windows용 Docker는 아래 링크에서 다운 받을 수 있습니다. https://www.docke..

shanepark.tistory.com

💻 MacOS

https://shanepark.tistory.com/186

 

MacOS PostgreSQL 설치 하고 테이블 생성, 조회하기

MacOS PostgreSQL 설치 하고 테이블 생성, 조회하기 ​ PostgreSQL PostgreSQL은 확장 가능성 및 표준 준수를 강조하는 객체-관계형 데이터베이스 관리 시스템의 하나 입니다. 오픈소스 RDBMS로서 사용율은 Or

shanepark.tistory.com

https://shanepark.tistory.com/194

 

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

MacOS ) m1 맥북 docker 설치하기 + 가상환경에 postgreSQL 띄워 보기 이번엔 Windows에 Docker를 설치 해 보았으니, Mac에서도 Docker를 설치 해 보겠습니다. 예전에는 MySQL 이건 postgreSQL이건 무슨..

shanepark.tistory.com

 

각자 본인의 운영체제에 맞게 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를 생성하겠습니다.

 

 

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

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

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

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

 

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

 

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

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

 

전체 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 구조는 아래와 같이 만들었습니다.

 

 게시판 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 메서드만 테스트 해 보려면 크롬에 그냥 해당 주소를 넣어도 테스트 할 수 있습니다.

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

 

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

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

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

 

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

수정도 잘 되네요.

 

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

 

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

 

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

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

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

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/ 으로 접속하면 아래의 페이지가 보여집니다.

 

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

 

수정하기를 클릭하면

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

 

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

 

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

 

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

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

 

GitHub - Shane-Park/markdownBlog: This repository is to keep all my blog posting written in MarkDown online

This repository is to keep all my blog posting written in MarkDown online - GitHub - Shane-Park/markdownBlog: This repository is to keep all my blog posting written in MarkDown online

github.com

 

반응형