본문 바로가기
게시판 만들기(Spring Boot, JPA)

게시판 만들기 02 - 게시글 CRUD

by burpee 2022. 12. 2.

게시글 CRUD를 구현하기 전에...

REST API

예전에 학원에서 프로젝트를 할 때 스프링 부트와 타임리프를 같이 사용했다. 이때는 컨트롤러에서 HTML을 반환하는 방식을 사용했고, 시간이 지나고 페이지를 반환하는 게 아닌 데이터만 반환할 수 있다는 것도 알게 됐다(REST API). 어쨌든 이번에 게시판 프로젝트에서는 스프링 부트를 사용한 REST API를 만들어볼 계획이다.

 

REST API 참고자료

https://meetup.toast.com/posts/92

Spring Boot에서 REST API

기존에 @Controller를 붙인 컨트롤러들은 HTML을 리턴했다고 했는데, 데이터만 반환하는건 어떻게 할까? 컨트롤러에서 REST 하게 만들고 싶은 메서드에 @ResponseBody라는 어노테이션을 붙여주면 가능하다. 그리고 @ResponseBody를 사용하지 않고 컨트롤러에 @RestController 어노테이션을 붙이면 해당 컨트롤러의 모든 메서드는 데이터를 반환할 수 있다.

 

@Controller에서는 요청을 처리하고 viewName을 반환하면 ViewResolver라는 녀석이 해당하는 View를 찾아 반환하는데, @RestController는 요청을 처리하고 객체를 반환한다. 이 객체를 MessageConverter라는 녀석이 Json형식으로 바꿔주면서 데이터를 반환할 수 있게 해 준다.

 

나는 이번에 게시판을 만들면서 @RestController를 사용할 것이고, 이때 반환은 ResponseEntity로 할 것이다. 왜냐하면 REST API를 만들 때 URI만 잘 설계하고 요청을 처리하는 것도 중요하지만 요청에 대한 응답과 상태 코드를 정확히 내려주는 것도 중요하다고 생각했기 때문이다. ResponseEntity를 사용하면 응답 상태코드, 헤더, 바디 부분에 필요한 정보를 담아서 내려줄 수 있기 때문에 ResponseEntity를 사용해서 응답을 내려줄 생각이다.

 

자 이제 게시글 CRUD를 만들어보자!

 

아 그전에 패키지 구조는 아래 사진처럼 잡았다.

패키지 구조

config: 설정파일(swagger, cors 등등)

controller: 컨트롤러 파일

domain: 엔티티 파일

dto: Dto 파일

repository: 리포지토리 파일

service: 서비스 파일

게시글 등록

먼저 JpaRepository를 상속받는 PostRepository 인터페이스를 만들어보자. JpaRepository를 상속받으면 기본적인 CRUD 메서드를 바로 사용할 수 있다.

 

PostRepository

public interface PostRepository extends JpaRepository<Post, Long> {
}

 

다음으로 Service 계층을 만들어보자. 

 

PostService

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
}

 

PostRepository를 의존성 주입 해준다. 그리고 클라이언트에서 게시글을 작성하고 보내주는 데이터를 담을 Dto를 만들어준다.

 

RequestRegisterPostDto

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RequestRegisterPostDto {

    private String title;

    private String content;
}

 

제목(title), 내용(content)이 담겨있다. Dto를 만들고 난 후에 PostService에서 해당 Dto를 사용해서 데이터를 저장하는 메소드를 만든다.

 

public ResponseSavedIdDto write(RequestRegisterPostDto requestDto) {
    Post post = Post.builder()
            .title(requestDto.getTitle())
            .content(requestDto.getContent())
            .createdDate(LocalDateTime.now())
            .build();

    Post savedPost = postRepository.save(post);

    return ResponseSavedIdDto.builder()
            .savedId(savedPost.getPostId())
            .build();
}

 

메소드 내부 코드를 보면 requestDto의 데이터를 가지고 Post Entity를 만들고 postRepository를 사용해서 저장하는 것을 볼 수 있다. 그리고 저장된 Entity의 Id 값을 담고 있는 ResponseSavedIdDto를 반환하는 모습을 볼 수 있다. 저 Dto를 반환하는 이유는 Controller에서 새로 생긴 리소스의 Id 값을 가지고 해당 리소스에 접근할 수 있는 URI를 Header에 담거나, Body에 해당 Id를 담아서 반환하려고 하기 때문이다.

 

ResponseSavedIdDto

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ResponseSavedIdDto {

    private Long savedId;
}

 

Service 계층이 마무리되었다면 이제 PostService에 있는 write 메서드를 호출할 Controller를 만들어보자.

 

PostController

@RestController
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {

    private final PostService postService;
}

 

위에서 설명했듯이 @RestController 어노테이션을 붙여주고 PostService 의존성 주입을 해주면 된다. 그리고 @RequestMapping("/posts")를 사용해서 PostController로 들어오는 요청을 /post로 시작하게 통일해 준다. 이제 게시글 등록 요청을 받아줄 메서드를 만들어보자.

 

@PostMapping
public ResponseEntity<ResponseSavedIdDto> registerPost(@RequestBody RequestRegisterPostDto requestDto) {
    ResponseSavedIdDto responseSavedIdDto = postService.write(requestDto);

    URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{savedId}")
            .buildAndExpand(responseSavedIdDto.getSavedId())
            .toUri();

    return ResponseEntity.created(location).body(responseSavedIdDto);
}

 

먼저 새로운 리소스를 생성하는 요청이기 때문에 @PostMapping을 사용해서 POST 요청을 받아준다. 그리고 @RequestBody 어노테이션을 사용해서 POST 요청의 Body 데이터를 RequestRegisterPostDto 객체로 받아준다. 그 객체를 postService의 write 함수를 호출할 때 파라미터로 넣어주면 아까 완성한 PostService 내부에 write 메서드가 실행되면서 게시글을 저장해 주게 된다. 저장을 한 후에는 반환받은 responseSavedIdDto를 사용해서 새로 생긴 리소스에 접근할 수 있는 URI를 만들어 준다(ex "/posts/1"). 마지막으로 ResponseEntity에 HTTP 응답 상태코드 CREATED 201, URI, responseSavedIdDto를 담아서 클라이언트로 반환해 주면 된다.

 

현재까지 게시글 등록 API 구현을 해봤는데 앞으로 만들 다른 API들도 이와 비슷한 흐름으로 만들게 된다. CRUD 특성에 따라 Controller와 Service에서 하는 일은 조금씩 달라지겠지만 이 흐름만 기억하면 어렵지 않다. 이제 등록한 게시글을 조회할 수 있는 API를 만들어보자.

게시글 조회

다시 PostService로 돌아와서 DB에서 게시글을 꺼내오는 get 메서드를 만들어보자.

 

public ResponsePostDto get(Long postId) {
    Post post = postRepository.findById(postId)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 게시글입니다."));

    return ResponsePostDto.builder()
            .postId(post.getPostId())
            .title(post.getTitle())
            .content(post.getContent())
            .createdDate(post.getCreatedDate())
            .build();
}

 

어떤 게시글을 가져올지 식별을 해야 하기 때문에 가져오고 싶은 postId를 받는다. 그 후에 postRepository의 findById라는 메서드를 사용해서 게시글(Post)을 가져온다.

 

그런데 이 JPA Repository에서 반환받은 Post는 처음엔 Optional이라는 클래스로 감싸져 있다. Optional로 감싸진 객체들은 null 체크를 효율적으로 할 수 있다. Optional을 사용할 수 있는 방법이 다양한데 언제 사용하는지, 어떻게 효율적으로 잘 사용하는지에 대한 방법들은 따로 공부를 한 후에 포스팅을 다시 해야겠다. 어쨌든 나는 Optional로 감싸진 Post Entity를 orElseThrow 메서드를 통해서 해당 postId에 대한 Post가 없으면 RuntimeException을 뱉도록 코드를 작성했다. 그리고 받아온 Post 데이터는 ResponsePostDto에 담아서 Controller로 반환한다.

 

ResponsePostDto

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ResponsePostDto {

    private Long postId;
    private String title;
    private String content;
    private LocalDateTime createdDate;
}

 

이제 PostController로 돌아와서 게시글 조회 요청에 응답하는 메소드를 만들어보자.

 

@GetMapping("/{postId}")
public ResponseEntity<ResponsePostDto> getPost(@PathVariable Long postId) {
    ResponsePostDto responsePostDto = postService.get(postId);

    return ResponseEntity.ok(responsePostDto);
}

 

먼저 데이터를 조회하는 요청이기 때문에 @GetMapping을 사용해서 GET 요청을 받아준다. 그리고 @PathVariable을 사용해서 postId를 받아준다. 그 후에 postService의 get 메서드를 호출해주면 해당 게시글의 제목(title), 내용(content), 작성일(createdDate) 등을 담은 Dto를 받을 수 있다. 마지막으로 ResponseEntity에 200 OK 상태와 Dto를 반환하면 끝이다.

 

이제 게시글을 수정하는 API를 만들어보자.

 

게시글 수정

다시 PostService로 돌아와서 게시글을 수정하는 edit 메소드를 작성하자.

 

@Transactional
public void edit(Long postId, RequestUpdatePostDto requestDto) {
    Post post = postRepository.findById(postId)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 게시글입니다."));

    post.update(requestDto);
}

 

postId, RequestUpdatePostDto를 파라미터로 받아서 postId에 해당하는 Post를 RequestUpdatePostDto에 있는 데이터들로 수정해 주면 된다. 그런데 여기서 postRepository를 사용하지 않고 Post Entity에 있는 update 메서드를 호출하고 끝나는 것을 볼 수 있다. 저 update 메서드는 Post Entity 클래스에 Setter를 사용하지 않고 객체 내부에서 데이터를 수정하도록 만든 메서드이다.

 

public void update(RequestUpdatePostDto requestDto) {
    this.title = requestDto.getTitle();
    this.content = requestDto.getContent();
}

 

어쨌든 이렇게 post의 값만 수정해 주고 postRepository를 이용해서 저장하는 로직이 필요 없는 이유는 JPA의 Dirty Checking(변경 감지) 때문이다. 

 

변경감지

postId로 조회한 post는 영속성 콘텍스트에 의해 관리된다. 이때 영속성 콘텍스트는 post를 조회했을 때 최초의 상태를 스냅숏으로 남겨둔다. 그리고 영속성 콘텍스트가 flush 되는 시점에 스냅숏과 현재 post의 상태를 비교하고 변경된 데이터를 찾아 쓰기 지연 SQL 저장소에 쿼리를 쌓아둔다. 그리고 트랜잭션 커밋을 하게되면 쓰기지연 SQL 저장소에 있던 쿼리들을 날려서 update를 진행한다.

변경감지는 트랜잭션이 커밋되는 시점에 일어나기 때문에 edit 메서드를 @Transactional 어노테이션으로 묶어주어야 한다.

 

마지막으로 RequestUpdatePostDto를 살펴보자.

 

ResponseUpdatePostDto

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RequestUpdatePostDto {

    private String title;
    private String content;
}

 

이 클래스는 처음에 게시글 등록 때 사용했던 RequestRegisterPostDto 클래스와 마찬가지로 제목(title), 내용(content)을 담고 있다. 그럼 같은 데이터를 사용하는 클래스인데 게시글을 수정할 때도 같은 Dto를 써도 되지 않나?라고 생각 할 수도 있다. 사실 지금은 간단한 게시판을 만드는 프로젝트라서 크게 상관이 없을 수 있지만 나중에는 등록 Dto와 수정 Dto가 담고 있는 데이터가 달라질 수도 있다. 그래서 처음부터 다른 용도로 사용하는 Dto들은 분리를 해야겠다고 생각했다. 만약 같은 Dto를 사용한다면 나중에 Dto에서 필요한 데이터가 바뀌었을 때 같은 Dto를 사용했던 다른 곳에서 예상치 못한 에러가 날 수 있기 때문이다.

 

이제 PostController에서 PostService의 edit 메서드를 호출해 주고 응답을 내려주면 된다.

 

@PutMapping("/{postId}")
public ResponseEntity<?> updatePost(@PathVariable Long postId, @RequestBody RequestUpdatePostDto requestUpdatePostDto) {
    postService.edit(postId, requestUpdatePostDto);

    return ResponseEntity.ok().build();
}

 

게시글 삭제

여기까지 했으면 게시글 삭제는 정말 간단하기 때문에 코드만 올려둬야겠다.

PostService에 delete 메서드 작성

 

public void delete(Long id) {
    Post post = postRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 게시글입니다."));

    postRepository.delete(post);
}

 

PostController에서 delete 메소드 호출하고 응답 내려주기

 

@DeleteMapping("/{postId}")
public ResponseEntity<?> deletePost(@PathVariable Long postId) {
    postService.delete(postId);

    return ResponseEntity.noContent().build();
}

 

여기서 NO CONTENT 204 응답으로 내려준다.

 

이렇게 게시글 CRUD를 간단하게 해 봤다. 다음 포스팅엔 게시글 조회인데 이제 페이징을 곁들인.... 그런 내용을 써봐야겠다. 그리고 지금 작성한 코드들은 또 천천히 수정이 될 수도 있을 것 같다. 암튼 이번 내용은 여기까지 해야겠다.

 

2022.11.25 - [게시판 만들기(Spring Boot, JPA)] - 게시판 만들기 하는 이유

 

게시판 만들기 하는 이유

나는 국비 학원에서 Java와 Spring을 배웠다. 학원 수업으로도 배웠지만 인프런에 있는 유명한 강의도 들으면서 Java, Spring, JPA 등을 조금씩 알게됐다. 그리고 운좋게 그때 배웠던 기술스택을 거의

burpeekim.tistory.com

2022.11.30 - [게시판 만들기(Spring Boot, JPA)] - 게시판 만들기 01 - 게시글 Entity 만들기

댓글