본문 바로가기

Server Programming/Spring Boot Backend Programming

6장-2. 다대일 연관관계를 이용한 댓글 처리 (+ 인덱스, Querydsl, Optional<T>, orElseThrow(), @RestControllerAdvice)

반응형

요구사항

다대일 연관관계를 이용한 댓글 CRUD

 

순서

  • 다대일 연관관계
  • 댓글 처리 구현

다대일 연관관계

  • PK와 FK를 이용해 연관관계를 표현하는 데이터베이스
  • 연관관계의 방향성을 판단하기 어려운 JPA

JPA에서 연관관계 판단 기준

  • 변화가 많은 쪽 기준
    • 회원-게시물 : 게시물
    • 회원-좋아요 : 좋아요
  • ERD의 FK 기준

참조방식

  • 데이터베이스 : PK를 FK가 참조
  • JPA : A <-> B와 같은 형태로 서로 참조가 가능한 양방향도 지원

양방향과 단반향 참조

  • 양방향 : 구현은 가능하지만, 관리가 어렵고 에러 발생 가능성이 높아서 웬만하면 사용하지 않도록 한다.
  • 단방향 : 조인 처리를 통해 다른 엔티티를 사용하므로, 불편함이 존재하지만 에러 발생 확률이 적아서 권장

댓글 처리 구현

다대일 연관관계를 이용한 댓글처리

요구사항

  • Board에 종속적인 Reply 구현 (BoardService)
    • 특정 게시물에 댓글 작성
    • 게시물 목록에 게시물당 댓글 수
  • Board에 독립적인 Reply 구현 (ReplyService)
    • 댓글 CRUD
    • 특정 게시물의 댓글 목록

구현 순서

  1. DTO
  2. 엔티티
  3. 리포지토리
  4. 서비스
  5. 컨트롤러
  6. 화면 처리

Board에 종속적인 Reply 구현 (BoardService)

1.Reply 엔티티 작성

-@Entity : JPA가 관리하는 클래스라는 의미로, 객체 맵핑을 위한 어노테이션 

-@Id, @GeneratedValue : 기본키 설정을 위한 어노테이션으로, strategy 속성으로 기본키 생성전략을 선택할 수 있다.

-@ManyToOne : 다대일 연관관계를 위한 어노테이션으로, Fetch타입을 선택할 수 있다.

[기본값이 즉시로딩이므로, fetch 속성을 이용해 지연로딩으로 변경한다.]

 

-@ToString : 참조관계에 있는 필드는 exclude 속성을 이용해 제외해야 순환참조 오류를 방지할 수 있다.

-@Getter, @Builder, @NoArgsConstructor, @AllArgsConstructor : VO 어노테이션

package org.zerock.b01.domain;

import com.fasterxml.jackson.databind.ser.Serializers;
import lombok.*;
import org.springframework.web.bind.annotation.GetMapping;

import javax.persistence.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//참조객체를 제외하도록 exclude 속성 이용
@ToString(exclude = "board")
public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;

    //다대일의 경우 기본값이 즉시 로딩으로, 지연 로딩으로 변경
    //:연관관계를 나타낼 때는 항상 LAZY로 지정
    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    private String replyText;

    private String replyer;

}

 

-> board_bno칼럼이 생기며, 자동으로 FK값으로 설정한다.

 

2. 댓글을 처리하는 리포지토리 작성

 

(1) ReplyRepository 작성

-Reply의 CRUD를 담당하는 리포지토리로 JpaRepository<Reply, Long>을 상속하는 인터페이스

-JpaRepository : 엔티티 타입 @Id 타입을 지정

package org.zerock.b01.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.b01.domain.Reply;

public interface ReplyRepository extends JpaRepository<Reply, Long> {
}

 

(2) ReplyRepositoryTests에 댓글 작성하는 메소드 작성

 

-@SpringBootTest : 스프링부트에서 테스트를 위한 어노테이션

-@Autowired : 필드로 의존성 주입

package org.zerock.b01.repository;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.b01.domain.Board;
import org.zerock.b01.domain.Reply;

@SpringBootTest
@Log4j2
public class ReplyRepositoryTests {
    @Autowired
    private ReplyRepository replyRepository;
    
    @Test
    public void testInsert(){
        //DB에 존재하는 게시물 번호
        Long bno = 100L;

        Board board = Board.builder()
                .bno(bno)
                .build();

        Reply reply=Reply.builder()
                .board(board)
                .replyText("댓글...")
                .replyer("replyer1")
                .build();
        
        replyRepository.save(reply);
    }
    
    
}

3. 인덱스를 이용한 특정 게시물의 댓글 조회

-게시물 번호를 이용해 댓글 객체를 참조하는 경우가 많다. (게시물 댓글 수, 특정 게시물의 댓글 목록)

-자주 사용하는 쿼리 조건의 칼럼을 인덱스로 지정

 

(1) 댓글 칼럼을 인덱스로 지정한 인덱스 테이블 생성

Reply 엔티티에 @Table 어노테이션으로 인덱스 테이블 생성

-@Table : 테이블을 생성하는 어노테이션

-indexes : 인덱스 설정을 위한 속성

-@Index : 인덱스의 속성을 결정하는 어노테이션으로, name, columnList 속성을 이용해 인덱스를 지정한다.

@Table(name = "Reply", indexes = {@Index(name = "idx_reply_board_bno", columnList = "board_bno")})

 

(2) JPQL을 이용해 게시물의 댓글 페이징 처리

-ReplyRepository에 listOfBoard() 메서드 작성

: 특정 게시물 번호와, Pageable 객체를 파라미터로 전달해 JPQL로 작성한 쿼리를 수행해 페이징 처리된 게시물의 댓글 목록을 리턴

  • 사용하는 쿼리 : JPQL
  • 메서드 명 : listOfBoard()
  • 파라미터 : bno, pageable
  • 리턴값 : Page<Reply>
@Query("select r from Reply r where r.board.bno=:bno")
Page<Reply> listOfBoard(Long bno, Pageable pageable);

 

(3) 테스트 코드 작성

-JPA는 필요한 엔티티를 최소한의 자원으로 사용하므로, @ToString에서 exclude로 참조하는 객체를 제외하지 않으면 에러 발생.

-강제로 실행하기 위해서는 선언적 트랜잭션 처리를 수행하는 어노테이션인 @Transactional를 사용한다.

-FetchType.LAZY : 필요한 순간까지 데이터베이스 접근을 늦추는 속성으로 성능을 위해 가장 최소화하는 것이 좋다.

@Test
public void testBoardReplies(){
    Long bno = 100L;

    Pageable pageable= PageRequest.of(0, 10, Sort.by("rno").descending());

    Page<Reply> result = replyRepository.listOfBoard(bno, pageable);
    
    result.getContent().forEach(reply -> {
        log.info(reply);
    });
    
}

 

    select
        reply0_.rno as rno1_1_,
        reply0_.moddate as moddate2_1_,
        reply0_.regdate as regdate3_1_,
        reply0_.board_bno as board_bn6_1_,
        reply0_.reply_text as reply_te4_1_,
        reply0_.replyer as replyer5_1_ 
    from
        reply reply0_ 
    where
        reply0_.board_bno=? 
    order by
        reply0_.rno desc limit ?

4. 게시물 목록에 댓글 적용

-게시물 당 댓글 수를 게시물 목록에 반영한다.

기존 목록 댓글 적용한 목록
Board 객체를 BoardDTO로 변환해 내용 출력 화면에 필요한 BoardListReplyCountDTO을 생성해 출력
   

 

(1) BoardListReplyCountDTO 작성

@Data
//생성자를 이용해 객체 생성을 할 필요가 없는 화면 출력만을 위한 DTO
public class BoardListReplyDTO {
    private Long bno;
    private String title;
    private String writer;
    private LocalDateTime regDate;
    
    private Long replyCount;
    
}

 

(2) 검색으로 게시물을 찾기 때문에 Querydsl을 이용한 동적쿼리로 BoardSearch에 searchWithReplyCount() 메서드 작성

  • 메소드 명 : searchWithReplyCount
  • 파라미터 : String[] types, String keyword, Pageable pageable
  • 리턴 타입 : Page<BoardListReplyCountDTO>
  • 참조 방식 : 단방향
  • 쿼리 종류 : JPQL을 이용한 left outer join / inner join
    (댓글이 없는 게시물이 존재할 수 있으므로 외부조인을 사용하는것이 바람직하다)
Page<BoardListReplyDTO> searchWithReplyCount(String [] types, String keyword, Pageable pageable);

 

(3) BoardSearchImpl에 searchWithReplyCount() 메서드 구현

  1. Querydsl을 이용한 조인
  2. 쿼리 결과를 프로젝션을 이용해 한번에 DTO로 처리
    (프로젝션 : JPQL의 결과를 즉시 DTO로 처리하는 기능)
  3. 페이징 처리
  4. 테스트 코드 작성

(3-1) Querydsl을 이용한 조인 처리 구현

-게시물 기준 외부 조인 (댓글없는 게시물도 null로 출력)하고, 게시물당 처리

더보기

Querydsl 기본 문법

QMember qMember = new QMember("m");//별칭 지정
QMember qMember = QMember.member;//기본 인스턴스 사용

 

동일성 체크

- eq(), ne(), not() // == , != ,!= (not은 마지막에 붙여준다.)

- isNotNull() // Null이 아니면 true

 

원소, 범위 체크

- in(1,2,3,4), notIn(1,3,5), between(10,20) // 원소에 있는경우, 원소에 없는경우, 10 ~ 20 사이 

- x.goe(y); (x >= y)

- x.gt(y); (x > y)

- x.loe(y); (x <= y)

- x.lt(y); (x < y)

그 외

- like("str%"); (like 검색)

- contains("str"); (like %str%)

- startsWith("str"); (like str%)

 

결과 조회

- fetch(); 리스트 조회 없으면 빈 리스트 반환

- fetchOne(); 단일 객체 반환, 없으면 null, 둘 이상이면 NonUniqueResultException

- fetchFirst(); 가장 먼저 찾는걸 반환

- fetchResults(); 페이징 정보 포함, total 쿼리 추가 실행 / 페이징이 아니라면 지양

- fetchCount(); count쿼리로 갯수 조회 (long형)

 

정렬

- desc(), asc()

- nullsLast(), nullsFirst() // null 데이터 순서 부여

 

@Override
public Page<BoardListReplyDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {
    QBoard board=QBoard.board;
    QReply reply=QReply.reply;

    JPQLQuery<Board> query=from(board);
    
    query.leftJoin(reply).on(reply.board.eq(board));
    
    query.groupBy(board);
    
    //from board left join reply on reply.board=board
    //:게시물 기준 외부 조인 (댓글없는 게시물도 null로 출력)
    
    //group by board
    //:조인 처리 후, 게시물당 처리를 위해
    
    return null;
}

 

(3-2) BoardListReplyDTO에 검색 조건 추가

//검색 조건 추가
if((types!=null&&types.length>0) && keyword!= null){
    //or 조건 묶기 위한 괄호 생성
    BooleanBuilder booleanBuilder = new BooleanBuilder();

    for(String type : types){
        switch (type) {
            case "t":
                booleanBuilder.or(board.title.contains(keyword));
                break;
            case "c":
                booleanBuilder.or(board.content.contains(keyword));
                break;
            case "w":
                booleanBuilder.or(board.writer.contains(keyword));
                break;
        }
    }
    //where (('t') or ('c') or ('w'))
    query.where(booleanBuilder);
}
//bno>0
query.where(board.bno.gt(0L));

 

(3-3) 목록 화면에 필요한 쿼리 결과를 한번에 DTO로 처리

-프로젝션 :  JPQL 결과를 즉시 DTO로 처리하는 기능

-Projections.bean() : 목록 화면에 필요한 쿼리 결과를 한번에 DTO로 처리

-select() :JPQL 쿼리를 위한 Query 인터페이스인 JPQLQuery 객체의 메서드

JPQLQuery<BoardListReplyDTO> dtoQuery = query.select(Projections.bean(BoardListReplyDTO.class,
        board.bno, board.title, board.writer, board.regDate,reply.count().as("replyCount")));

 

(3-4) BoardListReplyDTO에 페이징 처리

더보기

Querydsl로 검색 조건과 목록 처리

Querydsl로 검색 조건을 구현하고 JPQL 생성

'제목(t), 내용(c), 작성자(w)'의 조합 + 페이징 처리

 

Querydsl로 검색 조건 구현

  • BooleanBuilder : JPQL에 '()'를 작성하기 위한 객체
  • 검색을 위한 메소드 선언과 테스트
    • types [] : 여러 가지 검색 조건
    • keyword : 키워드
  • PageImpl를 이용한 Page<T> 반환
    • PageImpl : 페이징 처리시 Page<T> 타입을 반환하는 불편함을 해소하기 위해 제공하는 클래스로
      PageImpl를 이용하면 3개의 파라미터를 전달해 Page<T>를 생성한다.
      • List<T> : 실제 목록 데이터
      • Pageable : 페이지 관련 정보 객체
      • long : 전체 개수

 

//페이징 처리
this.getQuerydsl().applyPagination(pageable, dtoQuery);

List<BoardListReplyDTO> dtoList=dtoQuery.fetch();

long count=dtoQuery.fetchCount();

//PageImpl를 이용한 Page<T> 반환
//:3개의 파라미터를 전달해 Page<T>를 생성
return new PageImpl<>(dtoList, pageable, count);

 

(4) 테스트 코드 작성

@Test
public void testSearchReplyCount(){
    String[] types={"t", "c", "w"};
    
    String keyword= "1";
    
    Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
    
    Page<BoardListReplyDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);
    
    log.info(result.getTotalPages());
    log.info(result.getSize());
    log.info(result.getNumber());
    log.info(result.hasPrevious() +" :" +result.hasNext());
    
    result.getContent().forEach(board -> log.info(board));
}

5. 서비스 구현

-BoardListReplyCountDTO : BoardRepository + BoardSearch(Impl)

-BoardDTO -> BoardListReplyCountDTO로 변경하기 위한 메서드 작성

 

(1) BoardService에 listWithReplyCount()메서드 작성, BoardServiceImpl에 listWithReplyCount() 메서드 구현

  • 메소드 명 : listWithReplyCount()
  • 파라미터 : PageRequestDTO pageRequestDTO
  • 리턴 타입 : PageResponseDTO<BoardListReplyCountDTO>

-페이징처리까지 고려

PageResponseDTO<BoardListReplyDTO> listWithReplyCount(PageRequestDTO pageRequestDTO);

 

@Override
public PageResponseDTO<BoardListReplyDTO> listWithReplyCount(PageRequestDTO pageRequestDTO) {
    //1. 리포지토리 호출
    String[] types = pageRequestDTO.getTypes();
    String keyword = pageRequestDTO.getKeyword();
    Pageable pageable = pageRequestDTO.getPageable("bno");


    Page<BoardListReplyDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);
 

    //2. PageResponseDTO<BoardListReplyDTO>를 생성자를 이용해 생성
    //: 생성자를 호출하기위해 PageRequestDTO , dtoList, total가 필요
    return PageResponseDTO.<BoardListReplyDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(result.getContent())
            .total((int)result.getTotalElements())
            .build();
}

 

6. 컨트롤러 구현

-호출하는 메서드 변경

-BoardService.list() -> BoardService.listWithReplyCount()로 변경

 

변경 전, 서비스에서 list() 메서드 호출

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model){
    PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);

    log.info(responseDTO);

    model.addAttribute("responseDTO", responseDTO);
}

 

변경 후, 서비스에서 listWithReplyCount() 메서드 호출

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model){
    PageResponseDTO<BoardListReplyDTO> responseDTO = boardService.listWithReplyCount(pageRequestDTO);

    log.info(responseDTO);

    model.addAttribute("responseDTO", responseDTO);
}

 

7. 게시물 목록 화면 처리

 

변경 전, 게시물에 댓글 정보가 존재하지 않는 화면

<!--th:with을 이용해 재사용해서 게시물의 링크처리 -->

<tbody th:with="link =${pageRequestDTO.getLink()}">
<tr th:each="dto:${responseDTO.dtoList}">
    <th scope="row">
        [[${dto.bno}]]
    </th>
    <td>
        <a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${dto.title}]] </a>

    </td>
    <td>
        [[${dto.writer}]]
    </td>
    <td>
        <!--#temporals 유틸리티 객체를 이용해 날짜 포맷팅 -->
        [[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]
    </td>

</tr>
</tbody>

 

변경 후, 게시물에 댓글 정보가 추가된 화면

 

https://getbootstrap.kr/docs/5.2/components/badge/#%EC%9C%84%EC%B9%98

 

배지

배지에 대한 개요와 사용법 예시입니다.

getbootstrap.kr

 

<table class="table">
    <thead>
    <tr>
        <th scope="col">Bno</th>
        <th scope="col">Title</th>
        <th scope="col">Writer</th>
        <th scope="col">RegDate</th>
    </tr>
    </thead>
    <!--th:with을 이용해 재사용해서 게시물의 링크처리 -->

    <tbody th:with="link =${pageRequestDTO.getLink()}">
    <tr th:each="dto:${responseDTO.dtoList}">
        <th scope="row">
            [[${dto.bno}]]
        </th>
        <td>
            <a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${dto.title}]] </a>
            
            <!-- 게시물당 댓글 정보 추가-->
            <span class="badge progress-bar-success" style="background-color: #0a53be">
                [[${dto.replyCount}]]
            </span>

        </td>
        <td>
            [[${dto.writer}]]
        </td>
        <td>
            <!--#temporals 유틸리티 객체를 이용해 날짜 포맷팅 -->
            [[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]
        </td>

    </tr>
    </tbody>

Board에 독립 Reply 구현 (BoardService)

  • 댓글 CRUD
    • 등록
    • 조회/수정/삭제/목록

1. 댓글 등록 처리

(1) ReplyService 인터페이스에 register() 선언

package org.zerock.b01.service;

import org.zerock.b01.dto.ReplyDTO;

public interface ReplyService {
    Long register(ReplyDTO replyDTO);
}

 

 

(2) ReplyServiceImpl에 register() 구현

-@Service : 서비스를 담당하는 클래스를 나타내고, 빈 등록을 위한 어노테이션

-@RequiredArgsConstructor : ReplyRepository, ModelMapper 주입

-ModelMapper : DTO <-> Entity 변환을 담당하는 역할

 

  • 메서드 명 : register()
  • 파라미터 : ReplyDTO replyDTO
  • 리턴 타입 : Long
  • 기능 : 댓글 등록 후, 등록한 댓글 번호를 리턴한다.
  • 특징 : DTO를 엔티티로 변환 후, 변환한 엔티티를 영속 컨텍스트에 저장한다.
@Override
public Long register(ReplyDTO replyDTO) {
    Reply reply =modelMapper.map(replyDTO, Reply.class);

    Long rno=replyRepository.save(reply).getRno();

    return rno;
}

 

(3) 테스트 코드 작성

package org.zerock.b01.service;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.dto.ReplyDTO;

@SpringBootTest
@Log4j2
public class ReplyServiceTests {
    @Autowired
    private ReplyService replyService;

    @Test
    public void testRegister(){
        ReplyDTO replyDTO = ReplyDTO.builder()
                .replyText("ReplyDTO Text")
                .replyer("replyer")
                .bno(100L)
                .build();
                
        //댓글 번호 출력
        log.info(replyService.register(replyDTO));
    }
}

 

2. 댓글 조회/수정/삭제/목록 처리

(1) Reply 엔티티에 changeText() 메서드 추가

- replyText만 수정할 수 있도록, replyText Setter 메서드 작성

package org.zerock.b01.domain;

import com.fasterxml.jackson.databind.ser.Serializers;
import lombok.*;
import org.springframework.web.bind.annotation.GetMapping;

import javax.persistence.*;


@Entity
@Table(name = "Reply", indexes = {@Index(name = "idx_reply_board_bno", columnList = "board_bno")})
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//참조객체를 제외하도록 exclude 속성 이용
@ToString(exclude = "board")
public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;

    //다대일의 경우 기본값이 즉시 로딩으로, 지연 로딩으로 변경
    //:연관관계를 나타낼 때는 항상 LAZY로 지정
    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    private String replyText;

    private String replyer;

    public void changeText(String replyText){
        this.replyText=replyText;
    }


}

 

(2) 조회/수정/삭제/목록 서비스 구현

메서드 명 register() read() modify() remove() getListOfBoard()
파라미터 ReplyDTO replyDTO Long rno ReplyDTO replyDTO Long rno Long bno
PageRequestDTO
pageRequestDTO
리턴 타입 Long ReplyDTO void void PageResponseDTO<ReplyDTO>
Repository getRno() findById() findById() deleteById() listOfBoard()
Repository
리턴타입
Long rno Optional
<Reply>
Optional<Reply> Optional
<Reply>
Page<Reply>
페이징
처리 여부
X X X X O
검색 조건
처리 여부
X X X X O
ModelMapper ReplyDTO -> Reply Reply
->
ReplyDTO
X X Page<Reply>의 Reply
-> List<ReplyDTO>

 

해당 객체가 존재하지 않는 예외처리를 위해 Optional<T>로 리포지토리에서 받은 후, orElseThrow() 메서드 호출하는 과정을 거친다.

 

조회 메서드 : read()

@Override
public ReplyDTO read(Long rno){
    //Reply reply = replyRepository.findById(rno);
    Optional<Reply> replyOptional = replyRepository.findById(rno);
    Reply reply = replyOptional.orElseThrow();
    return modelMapper.map(reply, ReplyDTO.class);
}

 

수정 메서드 : modify()

@Override
public void modify(ReplyDTO replyDTO){
    //Reply reply = replyRepository.findById(rno);
    //Long rno = replyDTO.getRno();
    Optional<Reply>replyOptional=replyRepository.findById(replyDTO.getRno());
    Reply reply=replyOptional.orElseThrow();
    
    reply.changeText(replyDTO.getReplyText());
    replyRepository.save(reply);
}

 

삭제 메서드 : remove()

@Override
public void remove(Long rno){
    replyRepository.deleteById(rno);
}

 

목록 메서드 : getListOfBoard()

-Pageable 객체 -> Page<Reply> 객체 -> List<ReplyDTO> 객체

-리턴타입 : PageResponseDTO

-PageResponseDTO를 생성자로 생성하기 위해 필요한 파라미터 : PageRequestDTO, dtoList, total

@Override
public PageResponseDTO<ReplyDTO> getListOfBoard(Long bno, PageRequestDTO pageRequestDTO) {
    Pageable pageable = PageRequest.of(pageRequestDTO.getPage() <=0?0: pageRequestDTO.getPage() -1
    ,pageRequestDTO.getSize(), Sort.by("rno").ascending());

    Page<Reply> result = replyRepository.listOfBoard(bno, pageable);

    //페이징 처리된 Reply인 Page<Reply>의 Reply를 스트림을 이용해, Reply -> ReplyDTO로 변환
    List<ReplyDTO> dtoList=result.getContent().stream().map(reply -> modelMapper.map(reply, ReplyDTO.class))
            .collect(Collectors.toList());

    //생성자를 이용해 PageResponseDTO 생성
    //파라미터 : pageRequestDTO, dtoList, count

    return PageResponseDTO.<ReplyDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(dtoList)
            .total((int)result.getTotalElements())
            .build();
}

 

 

(3) ReplyConroller 구현

-ReplyService 의존성 주입

//Ajax와 JSON을 이용한 비동기처리를 위한 RestController
//: 메소드의 리턴 값이 JSON/XML으로 처리된다.
@RestController
@Log4j2
//JSON -> DTO로 변환하는 어노테이션
@RequestMapping("/replies")
@RequiredArgsConstructor
public class ReplyController {
    
    private final ReplyService replyService;
    
}

ReplyController의 댓글 등록/조회/수정/삭제/목록 메서드 매핑

메서드 명 register() read() modify() remove() getListOfBoard()
HTTP 메서드 POST GET PUT DELETE GET
URLPatterns "/" "/{rno}" "/{rno}" "/{rno}" "/list/{bno}"
consumes
: JSON 처리
O X O X X
파라미터 @Valid
@RequestBody
replyDTO
bindingResult
rno replyDTO rno bno
pageRequestDTO
예외처리 throws BindException
handleBindException
handleSuchException   handleFKException  
리턴 타입 Map<String, Long> ReplyDTO void void PageResponseDTO<ReplyDTO>

 

  • REST 방식 : URL이 '원하는 대상'을 의미, PUT/DELETE가 '행위나 작업'을 의미한다
  • 쿼리 스트링을 이용하지 않고, 직접 주소의 일부로 사용해 
    하나의 자원을 하나의 주소로 유일무이 표현함으로써, 하나의 URL이 하나의 자원을 식별하는 고유값이 된다.
  • URI이라는 자원을 이용해 '원하는 행위/작업'을 나타내는 GET/POST/PUT/DELETE를 수행하는 방식
  • 따라서, 같은 URL에 대해서 메서드에 따라 이루어지는 행위/작업이 달라진다.

  • 특정한 댓글 번호에 대한 URL인 "/{rno}"에 대해서, 메서드에 따라 조회/수정/삭제 기능을 수행한다.

 

1. 등록 메서드 매핑

(1) register() 변경

-@RequestBody : JSON 문자열 전송하기 위한 어노테이션

-리턴타입 :  ResponseEntity<Map<String,Long>> -> Map<String, Long>로 변경

-파라미터 : ReplyDTO 검증하기 위한 @Valid 어노테이션 추가, 검증 처리를 위한 BindingResult 추가

 

변경 전, register()

//Swagger UI에서 출력하는 메시지
@ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글등록")
//consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
//댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
public ResponseEntity<Map<String, Long>> register(@RequestBody ReplyDTO replyDTO){

    log.info(replyDTO);
    Map<String,Long> resultMap=new HashMap<>();

    return ResponseEntity.ok(resultMap);
}

 

변경 후, register()

  //Swagger UI에서 출력하는 메시지
    @ApiOperation(value = "Replies POST", notes = "POST 방식으로 댓글등록")
    //consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
    @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
    //댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
    public Map<String, Long> register(@Valid @RequestBody ReplyDTO replyDTO, BindingResult bindingResult) 
    throws BindException{

        log.info(replyDTO);
        if(bindingResult.hasErrors()){
            throw new BindException(bindingResult);
        }
        Map<String,Long> resultMap=new HashMap<>();
        Long rno = replyService.register(replyDTO);
        resultMap.put("rno",rno);

        return resultMap;
    }

 

(2) CustomRestAdvice에 DataIntegrityViolationException 예외 처리를 위해 handleFKException() 메서드 생성

메서드 명 handleBindException handleFKException handleSuchException
예외처리 BindException DataIntegrityViolationExcetpion NoSuchElementException
파라미터 BindException e Exception e Exception e
리턴타입 ResponseEntity
<Map<String, String>>
ResponseEntity
<Map<String, String>>
ResponseEntity
<Map<String, String>>
특징 JSON과 DTO 불일치 참조하는 FK값이 존재하지 않음 원하는 요소가 존재하지 않음
메시지 에러 발생 시간, 에러 메시지 에러 발생 시간, 에러 메시지 에러 발생 시간, 에러 메시지
리턴값 ResponseEntity.badRequest()
.body(errorMap);
ResponseEntity.badRequest()
.body(errorMap);
ResponseEntity.badRequest()
.body(errorMap);

- DataIntegrityViolationException으로 데이터에 대한 검증오류에도 불구하고 상태코드 500 오류라고 반환

-> 상태코드 500은 서버 내부의 오류로, 서버 문제라고 Ajax는 인식한다.

 

-정확하게 데이터 문제라는 점을 전송하기 위해, CustomRestAdvice에 DataIntegrityViolationException 생성

-CustomRestAdvice 클래스는 @RestControllerAdvice 어노테이션을 사용한 클래스

@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleFKException(Exception e) {

    log.error(e);

    Map<String, String> errorMap = new HashMap<>();

    errorMap.put("time", ""+System.currentTimeMillis());
    //DataIntegrityViolationException 발생시 메시지 출력
    errorMap.put("msg", "constraint fails");

    return ResponseEntity.badRequest().body(errorMap);
}

-> 400오류 리턴

2. 조회 메서드 매핑

(1) 컨트롤러 추가

//Swagger UI에서 출력하는 메시지
@ApiOperation(value = "Read Reply", notes = "GET 방식으로 댓글 조회")
//consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
@GetMapping(value = "/")
//댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
public ReplyDTO read(@PathVariable("rno") Long rno){

    log.info("rno "+rno+" read");

    //서비스에서 수정 메서드 호출
    ReplyDTO replyDTO=replyService.read(rno);

    Map<String, Long> resultMap = new HashMap<>();

    return replyDTO;
    //특정 댓글의 번호의 데이터가 존재하지 않는 경우
    //500 에러 발생: CustomRestAdvice에서 NoSuchElementException 추가
}

(2) NoSuchElementException 예외 처리를 위해 CustomRestAdvice에 handleSuchElement 메서드 추가

//댓글 조회시 없는 댓글번호
@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleSuchElement(Exception e) {

    log.error(e);

    Map<String, String> errorMap = new HashMap<>();

    errorMap.put("time", ""+System.currentTimeMillis());
    //DataIntegrityViolationException 발생시 메시지 출력
    errorMap.put("msg", "No Such Element fails");

    return ResponseEntity.badRequest().body(errorMap);
}

3. 수정 메서드 매핑

컨트롤러 추가

-@RequestBody : JSON 문자열 전송하기 위한 어노테이션

//Swagger UI에서 출력하는 메시지
@ApiOperation(value = "Replies Update", notes = "PUT 방식으로 댓글 수정")
//consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
@PutMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
//댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
public Map<String,Long> update(@PathVariable("rno") Long rno, @RequestBody ReplyDTO replyDTO){

    log.info("rno "+rno+" update");

    //파라미터로 받은 DTO에 수정할 댓글번호를 대입
    replyDTO.setRno(rno);

    //서비스에서 수정 메서드 호출
    replyService.modify(replyDTO);

    Map<String, Long> resultMap = new HashMap<>();

    resultMap.put("rno",rno);

    return resultMap;
}

 

4. 삭제 메서드 매핑

(1) 컨트롤러 추가

//Swagger UI에서 출력하는 메시지
@ApiOperation(value = "Replies Delete", notes = "DELETE 방식으로 댓글 삭제")
//consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
@DeleteMapping(value = "/")
//댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
public Map<String,Long> remove(@PathVariable("rno") Long rno){

    log.info("rno "+rno+" remove");
    //서비스에서 삭제 메서드 호출
    replyService.remove(rno);

    Map<String, Long> resultMap = new HashMap<>();

    resultMap.put("rno",rno);

    return resultMap;
}

(2) CustomRestAdvice에 NoSuchElementException 예외 처리를 위해 handleSuchException() 메서드 생성

//댓글 조회시 없는 댓글번호
@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleSuchElement(Exception e) {

    log.error(e);

    Map<String, String> errorMap = new HashMap<>();

    errorMap.put("time", ""+System.currentTimeMillis());
    //DataIntegrityViolationException 발생시 메시지 출력
    errorMap.put("msg", "No Such Element fails");

    return ResponseEntity.badRequest().body(errorMap);
}

 

5. 목록 메서드 매핑

-@PathVariable : 경로에 있는 값을 사용하기 위한 어노테이션으로 나머지 경로(페이지 관련 정보)는 일반 쿼리 스트링 이용

(동적 경로는 일반 쿼리 스트링, 고정인 값은 URL로 고정)

//Swagger UI에서 출력하는 메시지
@ApiOperation(value = "Replies of Board", notes = "GET 방식으로 댓글 목록")
//consumes : 메서드를 받아서 사용하는 데이터가 어떤 데이터인지 명시하는 속성
@DeleteMapping(value = "/")
//댓글 등록 메서드로, ReplyDTO를 전달받아 키,값 객체인 ResponseEntity로 반환한다.
public PageResponseDTO<ReplyDTO> getList(@PathVariable("bno") Long bno, PageRequestDTO pageRequestDTO){

    log.info("bno "+bno+" replies list");
    PageResponseDTO<ReplyDTO> responseDTO = replyService.getListOfBoard(bno, pageRequestDTO);

    return responseDTO;
}

-> PageResponseDTO가 JSON으로 처리된다.

 

 

반응형