- SpringBoot2로 Rest api 만들기(1) – Intellij Community 프로젝트생성
- SpringBoot2로 Rest api 만들기(2) – HelloWorld
- SpringBoot2로 Rest api 만들기(3) – H2 Database 연동
- SpringBoot2로 Rest api 만들기(4) – Swagger API 문서 자동화
- SpringBoot2로 Rest api 만들기(5) – API 인터페이스 및 결과 데이터 구조 설계
- SpringBoot2로 Rest api 만들기(6) – ControllerAdvice를 이용한 Exception처리
- SpringBoot2로 Rest api 만들기(7) – MessageSource를 이용한 Exception 처리
- SpringBoot2로 Rest api 만들기(8) – SpringSecurity 를 이용한 인증 및 권한부여
- SpringBoot2로 Rest api 만들기(9) – Spring Starter Unit Test
- SpringBoot2로 Rest api 만들기(10) – Social Login kakao
- SpringBoot2로 Rest api 만들기(11) – profile을 이용한 환경별 설정 분리
- SpringBoot2로 Rest api 만들기(12) – Deploy & Nginx 연동 & 무중단 배포 하기
- SpringBoot2로 Rest api 만들기(13) – Jenkins 배포(Deploy) + Git Tag Rollback
- SpringBoot2로 Rest api 만들기(14) – 간단한 JPA 게시판(board) 만들기
- SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리
- SpringBoot2로 Rest api 만들기(16) – AOP와 Custom Annotation을 이용한 금칙어(Forbidden Word) 처리
이번 장에서는 지금까지 구축한 SpringBoot + Security 환경에 간단한 JPA 게시판을 추가해 보도록 하겠습니다. 관계 다이어그램은 다음과 같습니다. 하나의 게시판에는 여러 개의 게시물이 작성될 수 있으므로 BOARD와 POST는 1:N의 관계를 같습니다. 회원은 여러 개의 게시물을 작성할 수 있으므로 USER와 POST 역시 1:N의 관계를 같습니다.
Entity 작성
Entity 공통으로 필요한 날짜정보를 담는 CommonDateEntity를 생성하여 Board, Post, User Entity가 상속 받도록 처리합니다.
@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class CommonDateEntity { // 날짜 필드 상속 처리 @CreatedDate // Entity 생성시 자동으로 날짜세팅 private LocalDateTime createdAt; @LastModifiedDate // Entity 수정시 자동으로 날짜세팅 private LocalDateTime modifiedAt; }
@Entity @Getter @NoArgsConstructor public class Board extends CommonDateEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long boardId; @Column(nullable = false, length = 100) private String name; }
@Entity @Getter @NoArgsConstructor public class Post extends CommonDateEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long postId; @Column(nullable = false, length = 50) private String author; @Column(nullable = false, length = 100) private String title; @Column(length = 500) private String content; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "board_id") private Board board; // 게시글 - 게시판의 관계 - N:1 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "msrl") private User user; // 게시글 - 회원의 관계 - N:1 // Join 테이블이 Json결과에 표시되지 않도록 처리. protected Board getBoard() { return board; } // 생성자 public Post(User user, Board board, String author, String title, String content) { this.user = user; this.board = board; this.author = author; this.title = title; this.content = content; } // 수정시 데이터 처리 public Post setUpdate(String author, String title, String content) { this.author = author; this.title = title; this.content = content; return this; } }
// Post Entity에서 User와의 관계를 Json으로 변환시 오류 방지를 위한 코드 @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class User extends CommonDateEntity implements UserDetails { // 날짜 필드 상속 처리 //내용 생략 }
SpringRestApiApplication에 CommonDateEntity의 Auditing 활성화를 위해 @EnableJpaAuditing을 추가합니다.
@EnableJpaAuditing @SpringBootApplication public class SpringRestApiApplication { public static void main(String[] args) { SpringApplication.run(SpringRestApiApplication.class, args); } // 내용 생략 }
JPA Repository 추가
Post의 Repository를 추가합니다. Board 이름으로 조회하기 위해 findByName 메서드를 추가합니다.
public interface BoardJpaRepo extends JpaRepository<Board, Long> { Board findByName(String name); }
Post의 Repository를 추가합니다. 게시판의 모든 게시글을 조회하기 위한 메서드를 추가합니다. 여기서 주의해야 할점은 Join 테이블의 조건을 줄때는 컬럼명이 아니라 객체 자체를 인자로 주입해야 한다는것입니다.
public interface PostJpaRepo extends JpaRepository<Post, Long> { List<Post> findByBoard(Board board); }
ParamPost 추가
게시물 등록/수정시 입력 파라미터를 받기 위한 DTO를 아래와 같이 작성합니다. Swagger 처리를 위해 @ApiModelProperty를 작성합니다.
@Getter @Setter @NoArgsConstructor public class ParamsPost { @NotEmpty @Size(min=2, max=50) @ApiModelProperty(value = "작성자명", required = true) private String author; @NotEmpty @Size(min=2, max=100) @ApiModelProperty(value = "제목", required = true) private String title; @Size(min=2, max=500) @ApiModelProperty(value = "내용", required = true) private String content; }
BoardService 추가
package com.rest.api.service.board; import 생략 @Service @Transactional @RequiredArgsConstructor public class BoardService { private final BoardJpaRepo boardJpaRepo; private final PostJpaRepo postJpaRepo; private final UserJpaRepo userJpaRepo; // 게시판 이름으로 게시판을 조회. 없을경우 CResourceNotExistException 처리 public Board findBoard(String boardName) { return Optional.ofNullable(boardJpaRepo.findByName(boardName)).orElseThrow(CResourceNotExistException::new); } // 게시판 이름으로 게시물 리스트 조회. public List<Post> findPosts(String boardName) { return postJpaRepo.findByBoard(findBoard(boardName)); } // 게시물ID로 게시물 단건 조회. 없을경우 CResourceNotExistException 처리 public Post getPost(long postId) { return postJpaRepo.findById(postId).orElseThrow(CResourceNotExistException::new); } // 게시물을 등록합니다. 게시물의 회원UID가 조회되지 않으면 CUserNotFoundException 처리합니다. public Post writePost(String uid, String boardName, ParamsPost paramsPost) { Board board = findBoard(boardName); Post post = new Post(userJpaRepo.findByUid(uid).orElseThrow(CUserNotFoundException::new), board, paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent()); return postJpaRepo.save(post); } // 게시물을 수정합니다. 게시물 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. public Post updatePost(long postId, String uid, ParamsPost paramsPost) { Post post = getPost(postId); User user = post.getUser(); if (!uid.equals(user.getUid())) throw new CNotOwnerException(); # 영속성 컨텍스트의 변경감지(dirty checking) 기능에 의해 조회한 Post내용을 변경만 해도 Update쿼리가 실행됩니다. post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent()); return post; } // 게시물을 삭제합니다. 게시물 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. public boolean deletePost(long postId, String uid) { Post post = getPost(postId); User user = post.getUser(); if (!uid.equals(user.getUid())) throw new CNotOwnerException(); postJpaRepo.delete(post); return true; } }
Controller 작성
위에서 생성한 Service를 이용하여 Controller를 작성합니다.
글 작성, 수정, 삭제는 회원 인증이 필요하므로 토큰을 필수로 받도록 처리합니다.
@Api(tags = {"3. Board"}) @RequiredArgsConstructor @RestController @RequestMapping(value = "/v1/board") public class BoardController { private final BoardService boardService; private final ResponseService responseService; @ApiOperation(value = "게시판 정보 조회", notes = "게시판 정보를 조회한다.") @GetMapping(value = "/{boardName}") public SingleResult<Board> boardInfo(@PathVariable String boardName) { return responseService.getSingleResult(boardService.findBoard(boardName)); } @ApiOperation(value = "게시판 글 리스트", notes = "게시판 게시글 리스트를 조회한다.") @GetMapping(value = "/{boardName}/posts") public ListResult<Post> posts(@PathVariable String boardName) { return responseService.getListResult(boardService.findPosts(boardName)); } @ApiImplicitParams({ @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 access_token", required = true, dataType = "String", paramType = "header") }) @ApiOperation(value = "게시판 글 작성", notes = "게시판에 글을 작성한다.") @PostMapping(value = "/{boardName}") public SingleResult<Post> post(@PathVariable String boardName, @Valid @ModelAttribute ParamsPost post) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String uid = authentication.getName(); return responseService.getSingleResult(boardService.writePost(uid, boardName, post)); } @ApiOperation(value = "게시판 글 상세", notes = "게시판 글 상세정보를 조회한다.") @GetMapping(value = "/post/{postId}") public SingleResult<Post> post(@PathVariable long postId) { return responseService.getSingleResult(boardService.getPost(postId)); } @ApiImplicitParams({ @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 access_token", required = true, dataType = "String", paramType = "header") }) @ApiOperation(value = "게시판 글 수정", notes = "게시판의 글을 수정한다.") @PutMapping(value = "/post/{postId}") public SingleResult<Post> post(@PathVariable long postId, @Valid @ModelAttribute ParamsPost post) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String uid = authentication.getName(); return responseService.getSingleResult(boardService.updatePost(postId, uid, post)); } @ApiImplicitParams({ @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 access_token", required = true, dataType = "String", paramType = "header") }) @ApiOperation(value = "게시판 글 삭제", notes = "게시판의 글을 삭제한다.") @DeleteMapping(value = "/post/{postId}") public CommonResult deletePost(@PathVariable long postId) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String uid = authentication.getName(); boardService.deletePost(postId, uid); return responseService.getSuccessResult(); } }
Exception 추가
다음과 같이 2개의 Exception을 추가합니다.
리소스의 소유자가 아닌 경우 CNotOwnerException
리소스가 존재하지 않는경우 CResourceNotExistException
public class CNotOwnerException extends RuntimeException { private static final long serialVersionUID = 2241549550934267615L; public CNotOwnerException(String msg, Throwable t) { super(msg, t); } public CNotOwnerException(String msg) { super(msg); } public CNotOwnerException() { super(); } }
public class CResourceNotExistException extends RuntimeException { public CResourceNotExistException(String msg, Throwable t) { super(msg, t); } public CResourceNotExistException(String msg) { super(msg); } public CResourceNotExistException() { super(); } }
다국어 메시지 처리
# exception_ko.yml notOwner: code: "-1006" msg: "해당 자원의 소유자가 아닙니다." resourceNotExist: code: "-1007" msg: "요청한 자원이 존재 하지 않습니다."
# exception_en.yml notOwner: code: "-1006" msg: "You are not the owner of this resource." resourceNotExist: code: "-1007" msg: "This resource does not exist."
ExceptionAdvice 추가
@ExceptionHandler(CNotOwnerException.class) @ResponseStatus(HttpStatus.NON_AUTHORITATIVE_INFORMATION) public CommonResult notOwnerException(HttpServletRequest request, CNotOwnerException e) { return responseService.getFailResult(Integer.valueOf(getMessage("notOwner.code")), getMessage("notOwner.msg")); } @ExceptionHandler(CResourceNotExistException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public CommonResult resourceNotExistException(HttpServletRequest request, CResourceNotExistException e) { return responseService.getFailResult(Integer.valueOf(getMessage("resourceNotExist.code")), getMessage("resourceNotExist.msg")); }
Swagger Test
H2 console에서 게시판을 하나 추가합니다.
insert into board(name) values('free');
회원 가입 / 로그인
회원 가입 후 로그인을 하여 토큰정보를 얻습니다.
게시글 작성
{ "success": true, "code": 0, "msg": "성공하였습니다.", "data": { "createdAt": "2019-05-10T01:25:35.088", "modifiedAt": "2019-05-10T01:25:35.088", "postId": 2, "author": "아빠", "title": "안내 드립니다.", "content": "제품을 구입해 주셔서 감사합니다.", "user": { "createdAt": "2019-05-10T01:10:27.403", "modifiedAt": "2019-05-10T01:10:27.403", "msrl": 194, "uid": "daddy@gmail.com", "name": "daddy", "provider": null, "roles": [ "ROLE_USER" ], "authorities": [ { "authority": "ROLE_USER" } ] } } }
게시글 수정
다른 사람이 작성한 게시글 수정 요청시 예외처리
{ "success": false, "code": -1006, "msg": "해당 자원의 소유자가 아닙니다." }
수정 성공시
{ "success": true, "code": 0, "msg": "성공하였습니다.", "data": { "createdAt": "2019-05-10T01:25:35.088", "modifiedAt": "2019-05-10T01:45:04.505", "postId": 2, "author": "화난아빠", "title": "강클레임", "content": "미세먼지를 하나도 못 처리합니다", "user": { "createdAt": "2019-05-10T01:10:27.403", "modifiedAt": "2019-05-10T01:10:27.403", "msrl": 194, "uid": "daddy@gmail.com", "name": "daddy", "provider": null, "roles": [ "ROLE_USER" ], "authorities": [ { "authority": "ROLE_USER" } ] } } }
게시글 상세
게시글 상세는 인증이 필요없으며 게시글 ID로 조회가능합니다.
{ "success": true, "code": 0, "msg": "성공하였습니다.", "data": { "createdAt": "2019-05-10T01:25:35.088", "modifiedAt": "2019-05-10T01:45:04.505", "postId": 2, "author": "화난아빠", "title": "강클레임", "content": "미세먼지를 하나도 못 처리합니다", "user": { "createdAt": "2019-05-10T01:10:27.403", "modifiedAt": "2019-05-10T01:10:27.403", "msrl": 194, "uid": "daddy@gmail.com", "name": "daddy", "provider": null, "roles": [ "ROLE_USER" ], "authorities": [ { "authority": "ROLE_USER" } ] } } }
게시글 리스트
게시글 리스트는 인증이 필요없으며 게시판 이름으로 조회가능합니다.
{ "success": true, "code": 0, "msg": "성공하였습니다.", "list": [ { "createdAt": "2019-05-09T23:36:06.136", "modifiedAt": "2019-05-09T23:36:06.136", "postId": 1, "author": "author", "title": "title", "content": "content", "user": { "createdAt": null, "modifiedAt": null, "msrl": 193, "uid": "happydaddy@naver.com", "name": "happydaddy", "provider": null, "roles": [ "ROLE_USER" ], "authorities": [ { "authority": "ROLE_USER" } ] } }, { "createdAt": "2019-05-10T01:25:35.088", "modifiedAt": "2019-05-10T01:45:04.505", "postId": 2, "author": "화난아빠", "title": "강클레임", "content": "미세먼지를 하나도 못 처리합니다", "user": { "createdAt": "2019-05-10T01:10:27.403", "modifiedAt": "2019-05-10T01:10:27.403", "msrl": 194, "uid": "daddy@gmail.com", "name": "daddy", "provider": null, "roles": [ "ROLE_USER" ], "authorities": [ { "authority": "ROLE_USER" } ] } } ] }
게시글 삭제
다른 사람이 작성한 게시글을 삭제할려고 시도할시 예외처리
{ "success": false, "code": -1006, "msg": "해당 자원의 소유자가 아닙니다." }
삭제 성공
간단하게 JPA로 게시판을 만들어 보았습니다. 계층형 게시판이 아닌것은 아쉽지만 이를 통해 JPA를 가볍게 훓어볼수 있는 좋은 기회가 된것 같습니다. 좀더 실력을 쌓은 후에 계층형 게시판에 대한 내용도 포스팅 하도록 하겠습니다.
최신 소스는 GitHub 사이트를 참고해 주세요.
https://github.com/codej99/SpringRestApi/tree/feature/board
GitHub Repository를 import하여 Intellij 프로젝트를 구성하는 방법은 다음 포스팅을 참고해주세요.
Docker로 개발 환경을 빠르게 구축하는 것도 가능합니다. 다음 블로그 내용을 읽어보세요!
스프링 api 서버를 이용하여 웹사이트를 만들어보고 싶으시면 아래 포스팅을 참고해 주세요.