본문 바로가기
Spring Boot

스프링 부트(Spring Boot) JPA 게시판 - 게시글 등록/수정 구현하기 (With. MySQL)

by 도뎡 2021. 9. 15.
반응형

본 JPA 게시판 프로젝트는 단계별(step by step)로 진행됩니다.


이전 글에서는 애플리케이션 전역에서 예외를 핸들링하는 방법을 알아보았습니다.

이제부터 각 기능별로 서비스, 컨트롤러, 화면까지 전체적으로 구현해보는 시간을 가져볼 건데요.

이번 글에서는 비즈니스 로직을 담당하는 Service Layer와

API 호출을 담당하는 Rest Controller를 처리해 보도록 할게요.

그럼, 바로 시작해보죠 :)

 

1. 서비스 레이어(Service Layer)에서 사용할 클래스(Classes) 생성하기

Service Layer에서 API를 처리하기 위해 필요한 클래스는 다음과 같습니다.

자세한 내용은 각 클래스를 생성하는 과정에서 설명해 드리도록 할게요.


1. 게시글의 생성과 수정을 처리할 요청(Request) DTO 클래스

2. 게시글 정보를 리턴할 응답(Response) DTO 클래스

3. 트랜잭션(Transaction)을 처리할 Service 클래스


 

1-1. 요청(Request) DTO 클래스 생성하기

package com.study.board.dto;

import com.study.board.entity.Board;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardRequestDto {

    private String title; // 제목
    private String content; // 내용
    private String writer; // 작성자
    private char deleteYn; // 삭제 여부

    public Board toEntity() {
        return Board.builder()
                .title(title)
                .content(content)
                .writer(writer)
                .hits(0)
                .deleteYn(deleteYn)
                .build();
    }

}

 

설명에 앞서, 이전 글에서 생성한 Board Entity 클래스의 구조를 한번 보도록 하겠습니다.

Board Entity 클래스 구조

눈으로 보시는 것처럼, Entity 클래스와 요청 DTO 클래스는 유사한 구조인데요.

Entity 클래스는 테이블(Table) 또는 레코드(Record) 역할을 하는 데이터베이스 그 자체로 생각할 수 있고,

절대로 요청(Request)이나 응답(Response)에 사용되어서는 안 되기 때문에

반드시 Request, Response 클래스를 따로 생성(구분)해 주어야 합니다.

 

toEntity( ) 메서드

다음의 코드는 이전 글에서 작성한 BoardTests 클래스의 save( ) 메서드의 일부입니다.

    @Test
    void save() {

        // 1. 게시글 파라미터 생성
        Board params = Board.builder()
                .title("1번 게시글 제목")
                .content("1번 게시글 내용")
                .writer("도뎡이")
                .hits(0)
                .deleteYn('N')
                .build();

        // 2. 게시글 저장
        boardRepository.save(params);
    }

 

보시는 것처럼, Entity 객체를 인자로 전달해서 게시글을 생성하는데요.

Entity 클래스는 절대로 요청(Request)에 사용되어서는 안 된다는 말씀을 드렸고,

이러한 이유로 BoardRequestDto로 전달받은 데이터(파라미터)를 기준으로 Entity 객체를 생성합니다.

이해가 쉽지 않으실 수 있을 텐데, toEntity( ) 메서드의 자세한 사용법은 잠시 후에 알아볼게요!

 

1-2. 응답(Response) DTO 클래스 생성하기

package com.study.board.dto;

import java.time.LocalDateTime;

import com.study.board.entity.Board;

import lombok.Getter;

@Getter
public class BoardResponseDto {

    private Long id; // PK
    private String title; // 제목
    private String content; // 내용
    private String writer; // 작성자
    private int hits; // 조회 수
    private char deleteYn; // 삭제 여부
    private LocalDateTime createdDate; // 생성일
    private LocalDateTime modifiedDate; // 수정일

    public BoardResponseDto(Board entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.writer = entity.getWriter();
        this.hits = entity.getHits();
        this.deleteYn = entity.getDeleteYn();
        this.createdDate = entity.getCreatedDate();
        this.modifiedDate = entity.getModifiedDate();
    }

}

 

응답(Response)도 마찬가지로 Entity 클래스가 사용되어서는 안 되기 때문에 클래스를 분리해야 합니다.

응답(Response) 객체 생성은 필수적으로 Entity 클래스를 필요로 하며,

BoardRequestDto와 마찬가지로 Service 클래스를 처리하는 과정에서 알아보도록 할게요.

 

1-3. Entity 클래스에 게시글 수정 기능 추가하기

package com.study.board.entity;

import java.time.LocalDateTime;

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

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // PK

    private String title; // 제목

    private String content; // 내용

    private String writer; // 작성자

    private int hits; // 조회 수

    private char deleteYn; // 삭제 여부

    private LocalDateTime createdDate = LocalDateTime.now(); // 생성일

    private LocalDateTime modifiedDate; // 수정일

    @Builder
    public Board(String title, String content, String writer, int hits, char deleteYn) {
        this.title = title;
        this.content = content;
        this.writer = writer;
        this.hits = hits;
        this.deleteYn = deleteYn;
    }

    public void update(String title, String content, String writer) {
        this.title = title;
        this.content = content;
        this.writer = writer;
        this.modifiedDate = LocalDateTime.now();
    }

}

 

기존 Board 클래스에 update( ) 메서드가 추가되었습니다.

마찬가지로 디테일한 내용은 Service 클래스를 처리하는 과정에서 알아볼게요.

 

1-4. 서비스(Service) 클래스 생성하기

package com.study.board.model;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.study.board.dto.BoardRequestDto;
import com.study.board.dto.BoardResponseDto;
import com.study.board.entity.Board;
import com.study.board.entity.BoardRepository;
import com.study.exception.CustomException;
import com.study.exception.ErrorCode;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class BoardService {

    private final BoardRepository boardRepository;

    /**
     * 게시글 생성
     */
    @Transactional
    public Long save(final BoardRequestDto params) {

        Board entity = boardRepository.save(params.toEntity());
        return entity.getId();
    }

    /**
     * 게시글 리스트 조회
     */
    public List<BoardResponseDto> findAll() {

        Sort sort = Sort.by(Direction.DESC, "id", "createdDate");
        List<Board> list = boardRepository.findAll(sort);
        return list.stream().map(BoardResponseDto::new).collect(Collectors.toList());
    }

    /**
     * 게시글 수정
     */
    @Transactional
    public Long update(final Long id, final BoardRequestDto params) {

        Board entity = boardRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.POSTS_NOT_FOUND));
        entity.update(params.getTitle(), params.getContent(), params.getWriter());
        return id;
    }

}

 

boardRepository

JPA Repository 인터페이스입니다.

보통 @Autowired로 빈(Bean)을 주입받는 방식을 사용했는데요,

스프링은 생성자로 빈을 주입하는 방식을 권장한다고 합니다.

클래스 레벨에 선언된 @RequiredArgsConstructor는 롬복에서 제공해주는 어노테이션으로,

클래스 내에 final로 선언된 모든 멤버에 대한 생성자를 만들어 줍니다.

예를 들어, 이런 식으로 말이죠.

public BoardService(BoardRepository boardRepository) {
    this.boardRepository = boardRepository;
}

 

@Transactional

JPA를 사용한다면, 서비스(Service) 클래스에서 필수적으로 사용되어야 하는 어노테이션입니다.

일반적으로 메서드 레벨에 선언하게 되며, 메서드의 실행, 종료, 예외를 기준으로

각각 실행(begin), 종료(commit), 예외(rollback)를 자동으로 처리해 줍니다.

 

save( ) 메서드

@Transactional
public Long save(final BoardRequestDto params) {
    Board entity = boardRepository.save(params.toEntity());
    return entity.getId();
}

 

boardRepository의 save( ) 메서드가 실행되면 새로운 게시글이 생성됩니다.

앞에서 말씀드렸듯이 Entity 클래스는 절대로 요청(Request)에 사용되어서는 안 되기 때문에

BoardRequestDto의 toEntity( ) 메서드를 이용해서 boardRepository의 save( ) 메서드를 실행합니다.

save( ) 메서드가 실행된 후 entity 객체에는 생성된 게시글 정보가 담기며,

메서드가 종료되면 생성된 게시글의 id(PK)를 리턴합니다.

 

findAll( ) 메서드

public List<BoardResponseDto> findAll() {
    Sort sort = Sort.by(Direction.DESC, "id", "createdDate");
    List<Board> list = boardRepository.findAll(sort);
    return list.stream().map(BoardResponseDto::new).collect(Collectors.toList());
}

 

boardRepository의 findAll( ) 메서드의 인자로 sort 객체를 전달해서 전체 게시글을 조회합니다.

sort 객체는 ORDER BY id DESC, created_date DESC을 의미합니다.

마지막 return 문을 보시면, Java의 Stream API를 이용하는 걸 보실 수 있는데요.

쉽게 말씀드리자면, list 변수에는 게시글 Entity가 담겨 있고,

각각의 Entity를 BoardResponseDto 타입으로 변경(생성)해서 리턴해 준다고 생각해 주시면 되겠습니다.

 

만약, findAll( ) 메서드를 Stream API 없이 풀어서 작성하면 다음과 같습니다.

(겁나게 길어지네요..)

public List<BoardResponseDto> findAll() {

    Sort sort = Sort.by(Direction.DESC, "id", "createdDate");
    List<Board> list = boardRepository.findAll(sort);
    
    /* Stream API를 사용하지 않은 경우 */
    List<BoardResponseDto> boardList = new ArrayList<>();
    
    for (Board entity : list) {
        boardList.add(new BoardResponseDto(entity));
    }
    
    return boardList;
}

 

update( ) 메서드

@Transactional
public Long update(final Long id, final BoardRequestDto params) {
    Board entity = boardRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.POSTS_NOT_FOUND));
    entity.update(params.getTitle(), params.getContent(), params.getWriter());
    return id;
}

 

겁나게 중요한 내용입니다, 꼭꼭꼭! 기억해 주셔야 돼요.

조금 전에 Board Entity 클래스에 update( ) 메서드를 추가했는데요,

해당 메서드에는 update 쿼리를 실행하는 로직이 없습니다.

하지만, 해당 메서드의 실행이 종료(commit)되면 update 쿼리가 자동으로 실행됩니다.

JPA에는 영속성 컨텍스트라는 개념이 있는데요.

제가 이해하고 있는 영속성 컨텍스트에 대해 쉽게 말씀드리도록 하겠습니다.

 

영속성 컨텍스트란 Entity를 영구히 저장하는 환경이라는 뜻이며,

애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 영역 정도로 생각할 수 있습니다.

JPA의 엔티티 매니저(Entity Manager)라는 녀석은 Entity가 생성되거나, Entity를 조회하는 시점에

영속성 컨텍스트에 Entity를 보관 및 관리합니다.

결론을 말씀드리자면, Entity를 조회하면 해당 Entity는 영속성 컨텍스트에 보관(포함)될 테고,

영속성 컨텍스트에 포함된 Entity 객체의 값이 변경되면,

트랜잭션(Transaction)이 종료(commit)되는 시점에 update 쿼리를 실행합니다.

이렇게 자동으로 쿼리가 실행되는 개념을 더티 체킹(Dirty Checking)이라고 하는데요,

"영속성 컨텍스트에 의해 더티 체킹이 가능해진다" 정도로 이해해 주시면 될 것 같습니다.

메서드 실행 결과는 잠시 후에 직접 확인해 볼게요 :)

 

2. BoardApiController 클래스 수정하기

package com.study.board.controller;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.study.board.dto.BoardRequestDto;
import com.study.board.dto.BoardResponseDto;
import com.study.board.model.BoardService;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class BoardApiController {

    private final BoardService boardService;

    /**
     * 게시글 생성
     */
    @PostMapping("/boards")
    public Long save(@RequestBody final BoardRequestDto params) {
        return boardService.save(params);
    }

    /**
     * 게시글 리스트 조회
     */
    @GetMapping("/boards")
    public List<BoardResponseDto> findAll() {
        return boardService.findAll();
    }

    /**
     * 게시글 수정
     */
    @PatchMapping("/boards/{id}")
    public Long save(@PathVariable final Long id, @RequestBody final BoardRequestDto params) {
        return boardService.update(id, params);
    }

}

 

3. API 테스트해보기

이제, 게시글 등록/수정, 리스트 조회 관련 API를 테스트해 보도록 할 텐데요,

저는 크롬(Chrome)에서 제공해주는 Advanced REST Client를 이용하도록 하겠습니다.

 

3-1. 게시글 생성(Create) 테스트

REST API 호출 설정

 

Body content type을 보시면, application/json으로 선택되어 있습니다.

우리는 일반적인 Form Submit 방식이 아닌, REST API 기반의 게시판 구현이 목적이기에

서버(Back-End)로 전송하는 데이터는 당연히 JSON Format이어야 합니다.

여기까지 따라오셨다면, SEND를 클릭해서 API를 호출해 보도록 하겠습니다.

 

 

다음은 BoardService의 save( ) 메서드의 디버깅 모드입니다.

파라미터로 선언된 params로 정상적으로 데이터가 넘어왔음을 확인할 수 있습니다.

Request Parameters

 

F6을 누르고, boardRepository의 save( ) 메서드가 실행되면 INSERT 쿼리가 실행됩니다.

boardRepository - save( ) 메서드 실행 결과

 

쿼리가 실행된 후에 board 테이블을 SELECT 해보니, 게시글(Entity)이 생성되지 않았습니다. (띠용)

board 테이블 SELECT 결과

 

다시 이클립스(STS)로 돌아와서 F8을 눌러 로직 실행을 완료해 주시고,

REST Client 프로그램을 확인해보니, "1"을 Response로 내려주었습니다.

Advanced REST Client - save( ) 메서드 실행 결과

 

자, 다시 board 테이블을 SELECT 해보니 1번 게시글이 생성되어 있습니다.

board 테이블 SELECT 결과

 

"아니.. INSERT 쿼리는 분명 실행되었는데, 어째서 SELECT 했을 때 데이터가 없었던 거지?"

라고 생각하시는 분들이 계실 수도 있을 것 같은데요, 이는 @Transactional 때문입니다.

@Transactional이 선언된 메서드는, 메서드의 로직이 정상적으로 종료되었을 때

실행된 SQL 쿼리에 대한 COMMIT을 수행하고, 해당 메서드에 대한 트랜잭션을 종료합니다.

 

 

이번에는 다른 제목, 내용, 작성자로 다시 게시글을 생성해 보도록 하겠습니다.

Second Request

 

이번에는 "2"를 Response로 내려주었습니다.

BoardService의 save( ) 메서드의 결괏값이 의미하는 게 어떤 건지 감이 잡히시지요?

네, 생성된 id 컬럼의 값, 즉 생성된 게시글 번호(PK)를 의미합니다.

Advanced REST Client - save( ) 메서드 실행 결과

 

3-2. 게시글 리스트(Read) 테스트

findAll( ) 메서드의 경우, Request Method가 GET으로 매핑되어 있으며,

GET 방식의 API는 굳이 Content-Type을 설정하지 않아도 됩니다.

Method를 변경하고, SEND를 클릭해서 API를 호출해 보도록 할게요.

REST API 호출 설정

 

다음은 이클립스(STS)의 콘솔 창입니다.

앞에서 등록한 게시글 리스트가 정상적으로 SELECT 되고 있네요 :)

boardRepository - findAll( ) 메서드 실행 결과

 

REST Client 프로그램에서는 게시글 리스트를 JSON Format으로 내려주는 것을 확인하실 수 있습니다.

Advanced REST Client - findAll( ) 메서드 실행 결과

 

3-3. 게시글 수정(Update) 테스트

REST API 호출 설정

 

API 호출 설정은 게시글 생성(Create)과 유사합니다.

Method가 PATCH로 변경되었고, Request URL 뒤에 "/1"이 추가되었습니다.

추가된 URL Path는 수정할 게시글(Entity)의 PK, 즉 id 컬럼을 의미합니다.

@PatchMapping("/boards/{id}")
public Long save(@PathVariable final Long id, @RequestBody final BoardRequestDto params) {
    return boardService.update(id, params);
}

 

다시 SEND를 클릭해서 API를 호출해 보도록 할 텐데요, 저는 1번 게시글의 정보를 수정해 볼게요.

 

 

이번에는 BoardService의 update( ) 메서드의 디버깅 모드이며,

52번 라인의 boardRepository의 findById( ) 메서드가 실행된 상태입니다.

콘솔을 보시면 1번 게시글의 SELECT 쿼리가 실행되었음을 확인하실 수 있습니다.

boardRepository - findById( ) 메서드 실행 결과

 

우리는 앞에서 Board Entity 클래스에 update( ) 메서드를 추가했습니다.

53번 라인의 코드가 실행되면 findById( )로 조회한 게시글(entity) 객체의 값이 변경됩니다.

다음의 이미지에서 파란색 네모 박스 안의 객체(this)는 기존의 1번 게시글 정보를 의미하고,

빨간색 네모 박스 안의 title, content, writer는 update( ) 메서드의 파라미터로 전달받은 데이터로,

수정할 게시글 정보를 의미합니다.

기존 & 수정할 게시글(entity) 정보

 

메서드가 실행된 후, 게시글(entity) 객체의 값이 변경되었음을 확인할 수 있습니다.

수정된 게시글(entity) 정보

 

save( ) 메서드와 마찬가지로 메서드가 정상적으로 종료되면 UPDATE 쿼리가 실행되고,

COMMIT을 수행한 후에 트랜잭션을 종료합니다.

BoardService - update( ) 메서드 실행 결과

 

Client 프로그램도 save( ) 메서드와 마찬가지로 수정된 게시글의 번호를 리턴합니다.

Advanced REST Client - update( ) 메서드 실행 결과

 

다시, 게시글 리스트 조회 API를 호출한 결과입니다.

1번 게시글의 정보가 정상적으로 변경되었네요.

Advanced REST Client - findAll( ) 메서드 실행 결과

 

이제, 마지막 테스트입니다.

데이터베이스에는 존재하지 않는 999번 게시글을 Path로 지정하고 API를 호출해보면

이전 글에서 ErrorCode에 정의한 POSTS_NOT_FOUND Exception을 throw 합니다.

데이터베이스에 없는 Entity 조회 결과

 

그런데, BoardService의 update( ) 메서드에서 entity를 조회하는 코드를 보면

findById(id).orElseThrow(( ) -> ...) <<< 익숙하지 않은 문법이 있는데요.

@Transactional
public Long update(final Long id, final BoardRequestDto params) {
    Board entity = boardRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.POSTS_NOT_FOUND));
    entity.update(params.getTitle(), params.getContent(), params.getWriter());
    return id;
}

 

JPA Repository의 findById( )는 Java 8에서 도입된 Optional 클래스를 리턴합니다.

Optional에 대해 쉽게 말씀드리자면, 반복적인 NULL 처리를 피하기 위해 사용되는 클래스입니다.

orElseThrow( )는 Optional 클래스에 포함된 메서드로,

Entity 조회와 예외 처리를 단 한 줄로 처리할 수 있는 너무나도 매력적인 녀석입니다.

 

참고로 해당 코드를 풀어서 작성하면 다음과 같습니다.

(가독성 측면에서는 괜찮지만, 코드 라인을 기준으로 본다면 좋지만은 않네요 ^^..)

@Transactional
public Long update(final Long id, final BoardRequestDto params) {

    Board entity = boardRepository.findById(id).orElse(null);

    if (entity == null) {
        throw new CustomException(ErrorCode.POSTS_NOT_FOUND);
    }

    entity.update(params.getTitle(), params.getContent(), params.getWriter());
    return id;
}

 

마무리

여기까지, 게시글 등록/수정, 리스트 조회에 대한 백엔드(Back-End) 영역의 API 구현이 끝이 났습니다.

다음 글부터는 UI 요소를 담당하는 프론트엔드(Front-End) 영역을 처리합니다.

자세한 내용은 다음 글에서 설명드리도록 할게요!

오늘도 고생 많으셨고, 방문해 주셔서 감사합니다 :)

 


진행에 어려움을 겪으시는 분들이 계실 수 있으니, 프로젝트를 첨부해 드리도록 하겠습니다.

application.properties의 데이터베이스 정보만 내 PC 환경과 일치하도록 변경해서 사용해 주세요 :)


Board.zip
0.09MB

반응형

댓글