이번에는 Comment API에 관한 내용을 정리할 것이다.
우선 기본적으로 Board아래에 comment가 존재한다.
하지만 확장성을 고려하여 chat이나 다른 여러 comment도 생길 수 있다고 생각하여 만들게 되었다.
이로 인해 새로운 동적테스트도 적용해 보았고, 확장성에 대비하여 코드를 작성하였다.
Comment
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@Table(name = "COMMENT")
@DiscriminatorColumn
public abstract class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
protected Comment() {
}
protected Comment(final Long id, final String content, final Member member) {
validation(content);
this.id = id;
this.content = content;
this.member = member;
}
public Long getId() {
return id;
}
public String getContent() {
return content;
}
public Member getMember() {
return member;
}
private void validation(final String content) {
if (isEmpty(content)) {
throw new CommentContentBlankException(content);
}
}
private boolean isEmpty(final String content) {
return content == null || content.isBlank();
}
public void isWriter(final Member member) {
if (!this.member.equals(member)) {
throw new MemberNotEqualsException();
}
}
public void update(final String content) {
validation(content);
this.content = content;
}
}
우선 전체적으로 사용될 comment 구조이다.
현재는 게시판에 대한 댓글만 존재하기에 아래와 같이 확장성 있게 사용할 수 있다.
BoardComment
@Entity
@Table(name = "BOARD_COMMENT")
public class BoardComment extends Comment {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
private BoardComment(final Long id, final String content, final Member member, final Board board) {
super(id, content, member);
this.board = board;
}
protected BoardComment() {
}
public static BoardComment of(final String content, final Member member, final Board board) {
return new BoardComment(null, content, member, board);
}
public static BoardComment of(final Long id, final String content, final Member member, final Board board) {
return new BoardComment(id, content, member, board);
}
}
Board와 다대일로 매핑해 주었다.
이를 데이터베이스 테이블로 보면 쉽게 이해할 수 있다.
일반적인 id의 경우 둘 다 같은 값을 가진다.
이를 활용해 나중에 리포지토리에서 JPQL로 쉽게 데이터를 읽어올 수 있다.
이 부분은 아래에서 좀 더 다시 다룰 예정이다.
CommonController
@RestController
@RequestMapping("/api")
public class CommentController {
private final CommonCommentService commonCommentService;
public CommentController(final CommonCommentService commonCommentService) {
this.commonCommentService = commonCommentService;
}
@PatchMapping("/comments/{commentId}")
public ResponseEntity<CommentResponse> updateComment(@PathVariable("commentId") final Long commentId,
@JwtLogin final Member member,
@RequestBody @Valid final CommentRequest commentRequest) {
return ResponseEntity.status(HttpStatus.OK)
.body(commonCommentService.update(commentId, member, commentRequest));
}
@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable("commentId") final Long commentId,
@JwtLogin final Member member) {
commonCommentService.delete(commentId, member);
return ResponseEntity.status(HttpStatus.NO_CONTENT)
.build();
}
}
우선 commentController에는 삭제와 업데이트뿐인데, 이는 어떤 댓글이더라도 위의 url로 타기 때문에 공통 로직이므로 따로 두었다.
반대로 작성 및 조회는 해당 댓글이 어디의 댓글인지 정확히 알아야 하므로 따로 빼두었다.
BoardCommentController
@RequestMapping("/api")
@RestController
public class BoardCommentController {
private final BoardCommentService boardCommentService;
public BoardCommentController(final BoardCommentService boardCommentService) {
this.boardCommentService = boardCommentService;
}
@PostMapping("/boards/{boardId}/comments")
public ResponseEntity<CommentResponse> createBoardComment(@PathVariable("boardId") final Long boardId,
@JwtLogin final Member member,
@RequestBody @Valid final CommentRequest commentRequest) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(boardCommentService.create(boardId, member, commentRequest));
}
@GetMapping("/boards/{boardId}/comments")
public ResponseEntity<CommentAllResponse> findCommentsByBoard(@PathVariable("boardId") final Long boardId,
@PageableDefault final Pageable pageable) {
return ResponseEntity.status(HttpStatus.OK)
.body(boardCommentService.findComments(boardId, pageable));
}
}
위에서 말했듯이 특정 댓글의 경우 url이 바뀌기 때문에 특정하도록 지정해 주었다.
이러한 로직이 서비스에서도 나타나게 된다.
CommonCommentService
@Service
public class CommonCommentService {
private final CommentRepository commentRepository;
public CommonCommentService(final CommentRepository commentRepository) {
this.commentRepository = commentRepository;
}
@Transactional
public CommentResponse update(final Long commentId, final Member member, final CommentRequest commentRequest) {
Comment comment = findComment(commentId);
comment.isWriter(member);
comment.update(commentRequest.getContent());
return CommentResponse.from(comment);
}
@Transactional
public void delete(final Long commentId, final Member member) {
Comment comment = findComment(commentId);
comment.isWriter(member);
commentRepository.delete(comment);
}
private Comment findComment(final Long commentId) {
return commentRepository.findById(commentId)
.orElseThrow(() -> new CommentNotFoundException(commentId));
}
}
서비스도 공통로직이 존재하므로 공통부분은 묶어주었다.
하지만 반대로 특정 댓글에 대한 로직이 필요한 부분은 인터페이스를 따로 두어서 만들어주었다.
CommentService
public interface CommentService {
CommentResponse create(Long boardId, Member member, CommentRequest commentRequest);
CommentAllResponse findComments(Long boardId, Pageable pageable);
}
BoardCommentService
@Service
public class BoardCommentService implements CommentService {
private final BoardRepository boardRepository;
private final CommentRepository commentRepository;
public BoardCommentService(final CommentRepository commentRepository, final BoardRepository boardRepository) {
this.boardRepository = boardRepository;
this.commentRepository = commentRepository;
}
@Override
@Transactional
public CommentResponse create(final Long boardId, final Member member, final CommentRequest commentRequest) {
Board board = findBoard(boardId);
BoardComment boardComment = BoardComment.of(commentRequest.getContent(), member, board);
commentRepository.save(boardComment);
return CommentResponse.from(boardComment);
}
@Override
@Transactional(readOnly = true)
public CommentAllResponse findComments(final Long boardId, final Pageable pageable) {
findBoard(boardId);
Page<Comment> commentPageInfo = commentRepository.findAllByBoardId(pageable, boardId);
CommentPageInfo pageInfo = CommentPageInfo.from(commentPageInfo);
List<CommentResponse> comments = commentPageInfo.stream()
.map(CommentResponse::from)
.collect(collectingAndThen(toList(), Collections::unmodifiableList));
return CommentAllResponse.from(comments, pageInfo);
}
private Board findBoard(final Long boardId) {
return boardRepository.findById(boardId)
.orElseThrow(() -> new BoardNotFoundException(boardId));
}
}
조회의 경우, findBoard()를 호출하고 리턴값을 받지 않는 이유는 아래 로직에서 사용하지 않기 때문이다.
단순히 게시판이 존재하는지 검증하는 용도로 사용한다.
Test
이번 테스트에서는 동적테스트를 적용해 보았다.
앞서 말했듯이 댓글이 어느 곳의 댓글인지에 따라 달라지기에 이를 한 번에 테스트하기 위함이다.
우선 중요한 부분부터 설명하겠다.
우리는 특정 댓글이 정확히 어떤 댓글인지 알고 이를 한 번에 테스트하고 싶다.(구현 클래스)
따라서 아래의 코드가 추가되었다.
ApplicationContextProvider
@Component
public class ApplicationContextProvider implements ApplicationContextAware {
private static ApplicationContext applicationContext;
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
@Override
public void setApplicationContext(final ApplicationContext applicationContext) throws BeansException {
ApplicationContextProvider.applicationContext = applicationContext;
}
}
ApplicationContext를 여러 곳에서 인스턴스로 사용하면 문제가 발생할 수 있다.
따라서 ApplicationContext를 관리하며 제공해 주는 ApplicationContextProvider라는 임의의 클래스를 생성하였다.
BeanUtils
public class BeanUtils {
private static final ApplicationContext applicationContext = ApplicationContextProvider.getApplicationContext();
public static List<CommentService> getBeansOfCommentServiceType() {
Map<String, CommentService> beansOfType = new TreeMap<>(applicationContext.getBeansOfType(CommentService.class));
return new ArrayList<>(beansOfType.values());
}
}
이제 정확히 어떤 댓글에 대한 클래스인지 applicationContext에서 꺼내올 수 있다.
현재는 게시판에 대한 댓글만 존재하기에 게시판 댓글만 가져오게 된다.
이를 활용하여 동적 테스트를 진행할 수 있다.
테스트가 매우 많기 때문에 최소한의 테스트만 가지고 설명하겠다.
CommonCommentControllerIntegrationTest
@DisplayName("게시판의 댓글을 작성한다.")
@TestFactory
List<DynamicTest> create_comment_by_id() {
List<CommentService> commentServices = BeanUtils.getBeansOfCommentServiceType();
List<DynamicTest> dynamicTestList = new ArrayList<>();
IntStream.range(0, commentServices.size()).forEach(index -> {
CommentService commentService = commentServices.get(index);
dynamicTestList.add(DynamicTest.dynamicTest(getClassName(commentService), () -> {
// given
CommentRequest commentRequest = new CommentRequest("comment");
// when
Response response = RestAssured.given()
.contentType(ContentType.JSON)
.auth().preemptive().oauth2(token)
.body(commentRequest)
.when()
.post(uriProvider().get(index));
// then
response.then()
.statusCode(HttpStatus.CREATED.value());
}));
});
return dynamicTestList;
}
private List<String> uriProvider() {
return List.of("/api/boards/1/comments");
}
private String getClassName(final CommentService commentService) {
return commentService.getClass().getSimpleName().split("\\$\\$")[0];
}
private void truncateAllTables() {
String truncateQuery = "TRUNCATE TABLE Comment";
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
jdbcTemplate.execute(truncateQuery);
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
}
우선 getBeansOfCommentServiceType()을 통해 댓글의 서비스들을 가져온다.
현재는 게시판 댓글 서비스뿐이다.
이제 동적으로 테스트들을 돌릴 것인데, 이를 DynamicTest클래스로 한 번에 담아서 반환할 것이다.
getClassName의 경우 프록시 객체일 경우 $CGLIB가 붙어있기에 이를 제거하기 위해 사용되었다.
이제 각각의 댓글 서비스 테스트 넣어주면서 테스트를 진행한다.
DynamicTest.dynamicTest의 첫 매개변수는 표시될 이름을 넣어주었고, 그 뒤의 매개변수는 실행될 테스트를 작성해 주었다.
이후 각 테스트마다 truncateAllTables를 실행하여 데이터베이스를 비워주었다.
이를 실행하면 아까 표시하기로 한 이름도 잘 나오고, 테스트도 잘 작동한다.
BoardCommentServiceUnitTest
게시판 댓글 단위 테스트도 최소한의 코드로만 간단하게 설명하겠다.
@DisplayName("댓글 생성시 내용이 비어있으면 예외가 발생한다.")
@ParameterizedTest(name = "{index} : {0}")
@MethodSource("commentRequestProvider")
void throws_exception_when_create_comment_with_blank(final String text, final CommentRequest commentRequest) {
//given
Long boardId = 2L;
given(boardRepository.findById(boardId)).willReturn(Optional.of(board));
//when & then
assertThatThrownBy(() -> boardCommentService.create(boardId, member, commentRequest))
.isInstanceOf(CommentContentBlankException.class);
}
private static Stream<Arguments> commentRequestProvider() {
return Stream.of(
Arguments.of("내용이 null인 경우", new CommentRequest(null)),
Arguments.of("내용이 공백인 경우", new CommentRequest("")),
Arguments.of("내용이 빈 칸인 경우", new CommentRequest(" "))
);
}
위 테스트의 경우에는 각 케이스마다 테스트를 돌릴 수 있다.
공통로직이 많아질 경우 테스트를 늘리는 것이 아닌 위의 방식을 사용하면 쉽게 해결할 수 있다.
첫 매개변수의 경우{0}에 들어가게 된다. 즉 테스트의 이름을 부여하기 위해 사용되었다.
이제 바뀌는 부분은 두 번째 매개변수에 값을 넣어주어 사용하였다.
이를 실행하면 아래와 같이 잘 작동하게 된다.
테스트가 많기에 이를 모두 설명하기에는 길어질 것이라 생각하여 중요한 부분만 설명하였다.
나머지 테스트의 경우에도 모두 잘 작동하는 것을 볼 수 있다.
시행착오
댓글을 조회할 때 리포지토리에서 JPQL을 작성할 때 새로운 사실을 알게 되었다.
CommentRepository
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Query("select c from Comment c join BoardComment bc on c.id = bc.id where bc.board.id = :boardId")
Page<Comment> findAllByBoardId(Pageable pageable, @Param("boardId") Long boardId);
}
위의 코드를 작동하면 당연하게도 N+1문제가 발생할 것이라고 생각하였다.
이유는 bc.board.id는 BoardComment안에 있는 Board를 가지고 id를 비교해야 하기 때문이다.
현재 코드에서 Board는 다대일로 매핑되어 있기에 프록시 객체를 들고 있다.
따라서 비교 로직에서 실제 객체를 가져오면서 N+1이 발생하는 것이 당연하다고 생각하고, 이를 리펙토링을 하려 하였다.
그전에 실제 쿼리가 얼마나 나오는지 확인해 보았다.
실제 쿼리는 2번이 발생하였다.
첫 번째 게시판 조회의 경우 프로덕션 코드에서 검증을 위해 생성되었고,
이후 쿼리가 실제 조회의 쿼리이다.(페이징 쿼리는 사이즈를 초과하지 않아서 발생하지 않음)
즉 N+1이 발생하지 않았다.
이유는 무엇일까?
- JPA에서 @ManyToOne 어노테이션이 붙은 필드는 기본적으로 지연 로딩(LAZY loading) 전략을 사용하여 연관된 엔티티의 식별자(ID)만을 가져와서 프록시(proxy) 객체로 처리하는 것이 기본 동작이다.
- 지연 로딩(LAZY loading)은 연관된 엔티티를 실제로 사용할 때까지 데이터베이스에 접근하지 않고 대신 프록시 객체를 사용하여 대리 조회하는 방식이다.
- 프록시 객체는 실제 데이터를 조회하는 시점에서 데이터베이스에 접근하여 필요한 정보를 가져온다.
- 따라서 @ManyToOne 어노테이션이 붙은 필드는 해당 엔티티의 ID만을 가져와서 프록시 객체로 처리되며, 실제 데이터는 실제로 필요한 시점에서 데이터베이스에 접근하여 로딩된다.
즉 프록시 객체라도 id는 들고 있기에 실제 객체를 가져오는 작업을 수행하지 않았다는 것이다.
이외에 다른 자세한 코드는 아래 깃허브를 통해 볼 수 있습니다.
https://github.com/kimtaesoo99/ChatUniv
'프로젝트 > ChatUniv' 카테고리의 다른 글
[프로젝트] ChatUniv 페이징 최적화 (2) | 2023.09.02 |
---|---|
[프로젝트] ChatUniv GPT 적용 (0) | 2023.08.26 |
[프로젝트] ChatUniv Board API #3 (0) | 2023.07.16 |
[프로젝트] ChatUniv Member API #2 (0) | 2023.07.16 |
[프로젝트] ChatUniv 전반적인 설계 및 Auth구조 #1 (0) | 2023.07.04 |