[코드로 배우는 스프링부트 웹 프로젝트] - 연관관계(4) - 목록 화면 JPQL 만들기 + 서비스 계층 생성

Jongwon·2023년 1월 8일
1

목록 화면에서는 각 게시물이 있고, 옆에는 댓글 수와 작성자가 표시되어야 합니다.

BoardRepository에 이에 해당하는 쿼리를 추가하겠습니다.

BoardRepository

@Query(value="select b, w, count(r) from Board b left join b.writer w 
	left join Reply r on r.board = b group by b", countQuery="select count(b) from Board b")
Page<Object[]> getBoardWithReplyCount(Pageable pageable);

테스트코드를 아래와 같이 작성합니다.

BoardRepositoryTests

    @Test
    public void testWithReplyCount() {
        Pageable pageable = PageRequest.of(4, 10, Sort.by("bno").descending());

        Page<Object[]> result = boardRepository.getBoardWithReplyCount(pageable);

        result.get().forEach(row -> {
            Object[] arr = (Object[]) row;

            System.out.println(Arrays.toString(arr));
        });
    }

가장 오른쪽의 Reply수까지 정상적으로 출력된 것을 확인할 수 있습니다.

계속해서 테스트를 진행하다가 이상한 느낌이 들어 DB를 확인해보니 어떤 경우에는 데이터 삽입이 2번 발생하여 bno값이 200까지 늘어간 것을 보았습니다. 이런 일이 발생하지 않도록 @AfterEach를 사용하는 것이 바람직하긴 하지만, 당분간은 DB를 초기화하고 재생성하는 방향으로만 진행하겠습니다.
혹시라도 다른 결과를 얻으셨다면 초기화 후 다시 진행하는 것을 추천드립니다.

bno값에 따라 조회하는 쿼리도 생성하겠습니다.

BoardRepository

    @Query("select b, w, count(r) from Board b Left Join b.writer w Left Join Reply r On r.board = b Where b.bno = :bno")
    Object getBoardByBno(@Param("bno") Long bno);

BoardRepositoryTests

	@Test
    public void testRead3() {
        Object result = boardRepository.getBoardByBno(100L);

        Object[] arr = (Object[]) result;

        System.out.println(Arrays.toString(arr));
    }



다음으로는 DTO와 서비스 계층을 만들겠습니다.

BoardDTO

package org.zerock.board.dto;

import lombok.*;

import java.time.LocalDateTime;

@Data
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {

    private Long bno;

    private String title;

    private String content;

    private String writerEmail;

    private String writerName;

    private LocalDateTime regDate;

    private LocalDateTime modDate;

    private int replyCount;
}

BoardService

package org.zerock.board.service;

import org.zerock.board.dto.BoardDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Member;

public interface BoardService {

    Long register(BoardDTO dto);

    default Board dtoToEntity(BoardDTO dto) {
        Member member = Member.builder()
                .email(dto.getWriterEmail()).build();

        Board board = Board.builder()
                .bno(dto.getBno())
                .title(dto.getTitle())
                .content(dto.getContent())
                .writer(member)
                .build();

        return board;
    }
}

BoardServiceImpl

package org.zerock.board.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.zerock.board.dto.BoardDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.repository.BoardRepository;

@Service
@RequiredArgsConstructor
@Log4j2
public class BoardServiceImpl implements BoardService {

    @Autowired
    private final BoardRepository repository;

    @Override
    public Long register(BoardDTO dto) {
        log.info(dto);

        Board board = dtoToEntity(dto);

        repository.save(board);

        return board.getBno();
    }
}

테스트도 생성해보겠습니다.
테스트 아래에 ServiceTest를 생성 후 아래 코드를 추가합니다.

BoardServiceTests

package org.zerock.board.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.board.dto.BoardDTO;

@SpringBootTest
public class BoardServiceTests {

    @Autowired
    private BoardService boardService;

    @Test
    public void testRegister() {

        BoardDTO dto = BoardDTO.builder()
                .title("Test.")
                .content("Test...")
                .writerEmail("user55@aaa.com")
                .build();

        Long bno = boardService.register(dto);
    }
}

DB에 레코드가 추가된 것을 확인할 수 있습니다.




다음으로는 이전 guestbook 프로젝트에서 사용했던 Page관련 DTO 2개 PageRequestDTO, PageResultDTO를 추가합니다.

그리고 BoardService에서 리스트를 불러오는 메서드를 설계합니다. 이전 guestbook 프로젝트와 동일하게 PageResult의 생성자에 함수를 넣어줄 때 entity를 DTO로 변환해야하므로 이부분도 설계해야 합니다.

BoardService

...
    PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO);

...

	default BoardDTO entityToDTO(Board board, Member member, Long replyCount) {
        BoardDTO boardDTO = BoardDTO.builder()
                .bno(board.getBno())
                .title(board.getTitle())
                .content(board.getContent())
                .regDate(board.getRegDate())
                .modTime(board.getModDate())
                .writerEmail(member.getEmail())
                .writerName(member.getName())
                .replyCount(replyCount.intValue())
                .build();

        return boardDTO;
    }

BoardServiceImpl

	@Override
    public PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);

        Function<Object[], BoardDTO> fn = (en-> entityToDTO((Board) en[0], (Member) en[1], (Long) en[2]));

        Page<Object[]> result = repository.getBoardWithReplyCount(pageRequestDTO.getPageable(Sort.by("bno").descending()));
        
        return new PageResultDTO<>(result,fn);
    }

앞에 Repository때 Object타입을 Object[]으로 캐스팅한 이유를 여기서 알 수 있습니다.
Object result = boardRepository.getBoardWithBno(100L);
Object[] arr = (Object[]) result;
여기서 result의 원래 타입인 Object는 테이블의 여러 레코드 Object[] 중 하나를 고른 것을 뜻합니다.
그 아래에 캐스팅을 하는 이유는 레코드는 여러 애트리뷰트값을 가지고있고, 이를 분리하려면 Object[] 타입으로 변환해야 접근 가능하기 때문입니다.



테스트코드도 이전 예제때와 비슷하게 작성할 수 있습니다.

BoardServiceTests

	@Test
    public void testList() {

        PageRequestDTO pageRequestDTO = new PageRequestDTO();

        PageResultDTO<BoardDTO, Object[]> result = boardService.getList(pageRequestDTO);

        for(BoardDTO boardDTO : result.getDtoList()) {
            System.out.println(boardDTO);
        }
    }




다음으론 게시물 한개를 조회하는 기능을 만들겠습니다.

BoardService

    BoardDTO get(Long bno);

BoardServiceImpl

	@Override
    public BoardDTO get(Long bno) {
        Object result = repository.getBoardByBno(bno);
        
        Object[] arr = (Object[]) result;
        
        return entityToDTO((Board) arr[0], (Member) arr[1], (Long) arr[2]);
    }

BoardServiceTests

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

        BoardDTO boardDTO = boardService.get(bno);

        System.out.println(boardDTO);
    }




세번째로는 삭제 처리입니다. 게시물을 삭제하는 경우 외래키로 댓글을 참조하고 있기 때문에 먼저 댓글을 모두 지운 후, 게시물을 지워야합니다.

삭제 쿼리문부터 레포지토리에 생성하겠습니다.

ReplyRepository

    @Modifying
    @Query("delete from Reply r where r.board.bno = :bno")
    void deleteByBno(@Param("bno") Long bno);

쿼리문이 수정이나 삭제와 연관이 있다면 @Modifying 어노테이션을 추가해야합니다.

다음으로는 BoardService에서 Reply와 Board의 삭제요청을 Repository로 보내야합니다. 이를 위해서는 ReplyRepository의 의존성이 주입되어야 합니다.

BoardService

...
    void removeWithReplies(Long bno);
...

BoardServiceImpl

...
    @Autowired
    private final ReplyRepository replyRepository;

...

	@Transactional
    @Override
    public void removeWithReplies(Long bno) {
        replyRepository.deleteByBno(bno);

        repository.deleteById(bno);
    }

BoardServiceTests

	@Test
    public void testRemove() {
        Long bno = 1L;

        boardService.removeWithReplies(bno);
    }

1번 게시물을 삭제하려고 테스트를 진행해 보겠습니다.

1번 게시물에는 3개의 댓글이 달려있는데 삭제를 진행해보면

먼저 댓글을 삭제한 후,

게시물에 대해서는 조회 후 삭제처리를 하고 있습니다.

왜 댓글은 바로 삭제 쿼리를 진행하는데 게시물은 조회 후 삭제를 진행할까요?
ReplyRepository에서
@Modifying
@Query("delete from Reply r where r.board.bno = :bno")
void deleteByBno(@Param("bno") Long bno);
을 통해 직접 삭제 쿼리를 지정해주었기 때문입니다. JPA에 내장되어 있는 삭제연산은 하나하나를 조회한 후 삭제하는 연산을 채택하고 있기 때문에 한번에 여러개를 삭제하고자 한다면 직접 쿼리를 작성해주어야 성능이 향상됩니다.




서비스 계층의 마지막 CRUD, 수정을 처리하겠습니다. 수정은 제목과 내용에 한해 수정가능합니다.

먼저 그전에 간단한 구현을 위해 Board 엔티티에 수정 기능을 추가하겠습니다.

Board

...
	public void changeTitle(String title) {
        this.title = title;
    }
    
    public void changeContent(String content) {
        this.content = content;
    }

다음으로는 서비스 계층을 구현하겠습니다.

BoardService

...
    void modify(BoardDTO boardDTO);
...

BoardServiceImpl

	@Override
    public void modify(BoardDTO boardDTO) {
        Board board = repository.getReferenceById(boardDTO.getBno());
        
        if(board != null) {
            board.changeTitle(boardDTO.getTitle());
            
            board.changeContent(boardDTO.getContent());
            
            repository.save(board);
        }
    } 

getReferenceById는 findById와 다르게 실제로 엔티티를 참조하기 직전까지 DB 접근을 하지 않고 프록시만을 이용하여 메서드를 진행하기 때문에 성능상의 이점이 있습니다.

BoardServiceTests

...
    @Test
    public void testModify() {
        BoardDTO boardDTO = BoardDTO.builder()
                .bno(2L)
                .title("제목 변경합니다")
                .content("내용 변경합니다")
                .build();
        
        boardService.modify(boardDTO);
    }
...

하지만 테스트코드를 실행하면
could not initialize proxy [org.zerock.board.entity.Board#2] - no Session 에러가 발생합니다.

위에서 언급했던 getReferenceById 방식을 잘 이해해보면 프록시 객체를 생성하는데, 이것은 영속성 컨텍스트에 저장됩니다. 하지만 서비스 구현코드를 실행하는 도중 트랜잭션이 넘어감으로써 프록시 객체는 준영속 상태로 변환되고, 따라서 변경감지가 불가능해집니다.

이를 해결하기 위해서는 서비스 메서드 전체가 하나의 트랜잭션임을 명시해주어야 하고, 이를 위해 @Transactional 어노테이션을 추가합니다.

BoardServiceImpl

	@Transactional
	@Override
    public void modify(BoardDTO boardDTO) {
        Board board = repository.getReferenceById(boardDTO.getBno());
        
        if(board != null) {
            board.changeTitle(boardDTO.getTitle());
            
            board.changeContent(boardDTO.getContent());
            
            repository.save(board);
        }
    } 

이제는 테스트가 정상적으로 동작하는데, 실행하면 아래와 같았던 레코드가

제목과 내용이 변경된 것을 확인할 수 있습니다.

profile
Backend Engineer

0개의 댓글