이 연재글은 SpringBoot2로 Rest api 만들기의 14번째 글입니다.

이번 장에서는 지금까지 구축한 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 서버를 이용하여 웹사이트를 만들어보고 싶으시면 아래 포스팅을 참고해 주세요.

연재글 이동[이전글] SpringBoot2로 Rest api 만들기(13) – Jenkins 배포(Deploy) + Git Tag Rollback
[다음글] SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리