댓글 서비스 구조 리팩토링

최지환·2023년 9월 12일
0

졸업작품-동네줍깅

목록 보기
11/11
post-thumbnail

과거 댓글기능을 넣는 과정에서, 댓글-및-대댓글-구현기 와 같이 구현을 했었다. 이 당시 서로 다른 게시판에서 댓글에 대해 생성, 조회, 삭제 해야했기 때문에 생성 및 조회하는 댓글이 어떤 게시판에 해당하는 댓글인지 확인을 해야했다. 그 해결법으로 무지성 if 문을 통해 각 게시판에 대한 분기를 처리하면서 코드가 지저분해졌다. 그래서 리팩토링 해보았다.

이 내용은

크게 두가지 문제가 있었는데

  1. 댓글을 생성하는 addComment 에서 경우 게시판 종류에 따라서 Comment 정적 팩토리 메서드를 호출해야기 때문에 if 분기가 반복적으로 있었다.
package mokindang.jubging.project_backend.comment.service;

@Service
@RequiredArgsConstructor
public class CommentService {

	  //...
    @Transactional
    public BoardIdResponse addComment(final Long memberId, final BoardType boardType, final Long boardId,
                                      final CommentCreationRequest commentCreationRequest) {
        Member writer = memberService.findByMemberId(memberId);
        LocalDateTime now = LocalDateTime.now();
        **if (boardType == BoardType.RECRUITMENT_BOARD)** {
						//구인 게시글 조회
            RecruitmentBoard board = recruitmentBoardService.findByIdWithOptimisticLock(boardId);
						// 댓글을 생성하고 구인 게시글과 연관관계를 맺는 메서드 호출
            Comment comment = Comment.createOnRecruitmentBoardWith(board, commentCreationRequest.getCommentBody(), writer, now);
            commentRepository.save(comment);
            return new BoardIdResponse(boardId);
        }

        **if (boardType == BoardType.CERTIFICATION_BOARD)** {
						//인증 게시글 조회
            CertificationBoard board = certificationBoardService.findById(boardId);
						//댓글을 생성하고 인증 게시글과 연관관계를 맺는 메서드 호출
            Comment comment = Comment.createOnCertificationBoardWith(board, commentCreationRequest.getCommentBody(), writer, now);
            commentRepository.save(comment);
            return new BoardIdResponse(boardId);
        }
        throw new IllegalArgumentException("존재 하지 않는 게시판에 대한 접근입니다.");
    }
//...
}
  1. 댓글을 조회하는 selectComments 는 게시판 타입에 따라서 해당하는 Comment 리스트를 불러온다.
  • 각 게시판에 따라서 댓글에 접근 가능한지 회원에 대해 검증을 하는 과정이 달랐기 때문에 if 분기 내에서 비슷하지만 중복되는 코드가 존재했다.
    • 구인 게시판 - 조회 회원의 댓글 작성 가능 여부 및 해당 게시글에 참여하기 신청을 했는지 검증 후 댓글 정보 반환
    • 인증 게시판 - 조회 회원의 지역 소속 상관없이 조회 가능
package mokindang.jubging.project_backend.comment.service;

@Service
@RequiredArgsConstructor
public class CommentService {

//...
  @Transactional
    public MultiCommentSelectionResponse selectComments(final Long memberId, final BoardType boardType, final Long boardId) {
        if (boardType == BoardType.RECRUITMENT_BOARD) {
            List<Comment> commentsByRecruitmentBoard = commentRepository.findCommentsByRecruitmentBoardId(boardId);
            Member member = memberService.findByMemberId(memberId);
            RecruitmentBoard board = recruitmentBoardService.findByIdWithOptimisticLock(boardId);
						//회원에 대해 게시판에 있는 댓글에 대한 검증 로직이 이루어짐
            boolean writingCommentPermission = board.isSameRegion(member.getRegion());
            boolean isWriterParticipatedIn = board.isParticipatedIn(memberId);
            return new MultiCommentSelectionResponse(convertToRecruitmentBoardCommentSelectionResponse(memberId, commentsByRecruitmentBoard, board, isWriterParticipatedIn),
                    writingCommentPermission);
        }

        if (boardType == BoardType.CERTIFICATION_BOARD) { //검증 없이 조회 가능
            List<Comment> commentsByCertificationBoard = commentRepository.findCommentsByCertificationBoardId(boardId);
            CertificationBoard board = certificationBoardService.findById(boardId);
            return new MultiCommentSelectionResponse(convertToCertificationBoardCommentSelectionResponse2(memberId, commentsByCertificationBoard, board),
                    true);
        }
        throw new IllegalArgumentException("존재 하지 않는 게시판에 대한 접근입니다.");
    }

    private List<CommentSelectionResponse> convertToRecruitmentBoardCommentSelectionResponse(final Long memberId, final List<Comment> commentsByRecruitmentBoard, final RecruitmentBoard board, final boolean isWriterParticipatedIn) {
        return commentsByRecruitmentBoard.stream()
                .map(comment -> new CommentSelectionResponse(comment, memberId, board.isSameWriterId(comment.getWriter().getId()), isWriterParticipatedIn))
                .collect(Collectors.toUnmodifiableList());
    }

    private List<CommentSelectionResponse> convertToCertificationBoardCommentSelectionResponse2(final Long memberId, final List<Comment> commentsByRecruitmentBoard, final CertificationBoard board) {
        return commentsByRecruitmentBoard.stream()
                .map(comment -> new CommentSelectionResponse(comment, memberId, board.isSameWriterId(comment.getWriter().getId()), false))
                .collect(Collectors.toUnmodifiableList());
    }
//...
}

1번 문제 해결 과정

addComment( )를 보면 컨트롤러에서 받은 boardType 에 따라 해당하는 게시글에 댓글을 추가한다.

해결 해야 하는 문제

  1. 댓글을 생성 및 삭제 할 때 각 게시판에 해당하는 로직을 서비스에서 if 분기로 처리를 해야함.
    • 각 분기에서는 어떤 게시판의 댓글인지 확인하는 작업을 함.
    • 이후 각 게시판과의 연관관계를 이어주기 위해 각 게시판에 맞는 댓글의 생성 메서드를 호출.
  2. if 분기 후 각 if 분 마다 패턴이 비슷한 중복되는 코드를 발생 시킴

댓글을 생성 할 때 마다 게시판과의 연관관계를 이어주게 하기 위해 각 게시판마다 댓글 생성이 가능한 정적 팩토리메서드를 호출함 하는 과정에서 서비스 레이어 에서의 코드가 복잡하게 되었음.

이런 문제는 결국 jpa 를 사용하면서 발생하는 연관관계를 이어주는 과정에서 발생한 문제라고 생각함. 따라서 Comment 엔티티 구조 자체가 문제가 아닐까? 라고 생각함.

Comment 엔티티의 문제점 파악

  • 현재 서비스에는 recruitmentBoardcertificationBoard에서 댓글을 사용함.
  • 연관관계를 매핑하기 위해서 각 게시판 별로 매핑을 해주는 정적 팩토리 메서드가 존재함 → 이는 결과적으로 서비스 레이어의 코드를 복잡하게 함.
  • recruitmentBoardcertificationBoard 외에 댓글 기능을 사용하는 새로운 요구사항이 추가될 때 Comment 에 필드를 추가하는 것은 유연하지 않다고 생각함.
  • 개념적으로 Comment 는 여러 게시판에 한번에 달리는것이 아닌 하나의 게시판에만 달림. 따라서 각 게시판과의 연관관계를 맺기 위해, 존재하는 모든 게시판을 Comment 필드에 두는 것이 이상함.
  • 현재 구조는 DB에 comment 데이터가 적재가 되면 아래와 같이 해당하지 않는 게시판의 칼럼을 null 로 두는 상태임. → 이는 새로운 게시판이나 댓글 기능을 넣을 때마다 Comment 테이블에 칼럼을 추가해줘야하는 문제가 발생

comment table

Comment.java

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id", nullable = false)
    Long id;

	  //...

    **@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "recruitment_board_id")
    private RecruitmentBoard recruitmentBoard;**

    **@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "certification_board_id")
    private CertificationBoard certificationBoard;**

		//...

    public static Comment **createOnRecruitmentBoardWith**(final RecruitmentBoard board, final String commentBody, final Member writer, final LocalDateTime now) {
        return new Comment(board, commentBody, writer, now);
    }

    private Comment(final RecruitmentBoard board, final String commentBody, final Member writer, final LocalDateTime now) {
        this.commentBody = new CommentBody(commentBody);
        this.writer = writer;
        this.createdDateTime = now;
        this.lastModifiedDateTime = createdDateTime;
        setRecruitmentBoard(board);
    }

    private void setRecruitmentBoard(final RecruitmentBoard recruitmentBoard) {
        this.recruitmentBoard = recruitmentBoard;
        recruitmentBoard.addComment(this);
    }

    public static Comment **createOnCertificationBoardWith**(final CertificationBoard board, final String commentBody, final Member writer, final LocalDateTime now) {
        return new Comment(board, commentBody, writer, now);
    }

    private Comment(final CertificationBoard board, final String commentBody, final Member writer, final LocalDateTime now) {
        this.commentBody = new CommentBody(commentBody);
        this.writer = writer;
        this.createdDateTime = now;
        this.lastModifiedDateTime = createdDateTime;
        setCertificationBoard(board);
    }

    private void setCertificationBoard(final CertificationBoard certificationBoard) {
        this.certificationBoard = certificationBoard;
        certificationBoard.addComment(this);
    }
	//...
}

해결 과정

  • BoardType을 나타내는 Enum 으로 처리하도록 함.
  • 연관관계를 위해 게시판 별로 두는 필드들을 제거

리팩토링 후 Comment

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id", nullable = false)
    private Long id;

    @Column
    private Long boardId;

    @Enumerated(EnumType.STRING)
    private BoardType boardType;

    public Comment(final Long boardId, final BoardType boardType, final String commentBody, final Member writer, final LocalDateTime now) {
        this.boardId = boardId;
        this.boardType = boardType;
        this.commentBody = new CommentBody(commentBody);
        this.writer = writer;
        this.createdDateTime = now;
        this.lastModifiedDateTime = createdDateTime;
    }

  //...
}

Comment 리팩토링 후 CommentService 의 addComment 메서드

@Transactional
    public BoardIdResponse addComment(final Long memberId, final BoardType boardType, final Long boardId,
                                       final CommentCreationRequest commentCreationRequest) {
        Member writer = memberService.findByMemberId(memberId);
        LocalDateTime now = LocalDateTime.now();
        Comment comment = new Comment(boardId, boardType, commentCreationRequest.getCommentBody(), writer, now);
        commentRepository.save(comment);
        return new BoardIdResponse(comment.getBoardId());
   }

 
}

2번 문제 해결 과정

해결 해야하는 문제

  • selectComments 메서드는 입력받은 BoardType 와 boardId 에 해당하는 댓글 리스트를 반환한다.
  • 이때 각 게시판에 따라서 댓글을 반환하는 과정에서 비즈니스적으로 검증을 해야하는 과정이 추가되면서 결국 if 분기를 통해 작업을 해야한다.

각 게시판 마다 따라 댓글 반환하는 전략이 다르다 때문에 전략패턴으로 해결을 해보려고한다.

따라서 이를 처리하는 전략들을 만들어두고, BoardType 에 따라서 그에 맞는 댓글 반환 전략을 통해 댓글을 반환하면 될 것 같다.

package mokindang.jubging.project_backend.comment.service;

@Service
@RequiredArgsConstructor
public class CommentService {

//...
  @Transactional
    public MultiCommentSelectionResponse selectComments(final Long memberId, final BoardType boardType, final Long boardId) {
        if (boardType == BoardType.RECRUITMENT_BOARD) {
            List<Comment> commentsByRecruitmentBoard = commentRepository.findCommentsByRecruitmentBoardId(boardId);
            Member member = memberService.findByMemberId(memberId);
            RecruitmentBoard board = recruitmentBoardService.findByIdWithOptimisticLock(boardId);
						//회원에 대해 게시판에 있는 댓글에 대한 검증 로직이 이루어짐
            boolean writingCommentPermission = board.isSameRegion(member.getRegion());
            boolean isWriterParticipatedIn = board.isParticipatedIn(memberId);
            return new MultiCommentSelectionResponse(convertToRecruitmentBoardCommentSelectionResponse(memberId, commentsByRecruitmentBoard, board, isWriterParticipatedIn),
                    writingCommentPermission);
        }

        if (boardType == BoardType.CERTIFICATION_BOARD) { //검증 없이 조회 가능
            List<Comment> commentsByCertificationBoard = commentRepository.findCommentsByCertificationBoardId(boardId);
            CertificationBoard board = certificationBoardService.findById(boardId);
            return new MultiCommentSelectionResponse(convertToCertificationBoardCommentSelectionResponse2(memberId, commentsByCertificationBoard, board),
                    true);
        }
        throw new IllegalArgumentException("존재 하지 않는 게시판에 대한 접근입니다.");
    }

    private List<CommentSelectionResponse> convertToRecruitmentBoardCommentSelectionResponse(final Long memberId, final List<Comment> commentsByRecruitmentBoard, final RecruitmentBoard board, final boolean isWriterParticipatedIn) {
        return commentsByRecruitmentBoard.stream()
                .map(comment -> new CommentSelectionResponse(comment, memberId, board.isSameWriterId(comment.getWriter().getId()), isWriterParticipatedIn))
                .collect(Collectors.toUnmodifiableList());
    }

    private List<CommentSelectionResponse> convertToCertificationBoardCommentSelectionResponse2(final Long memberId, final List<Comment> commentsByRecruitmentBoard, final CertificationBoard board) {
        return commentsByRecruitmentBoard.stream()
                .map(comment -> new CommentSelectionResponse(comment, memberId, board.isSameWriterId(comment.getWriter().getId()), false))
                .collect(Collectors.toUnmodifiableList());
    }
//...
}

해결 과정

  • 구인 게시판과 인증 게시판에서 댓글을 반환하는 전략(CommentsSelectionStrategy) 생성
  • BoardType 에 따라 그에 맞는 CommentsSelectionStrategy 를 반환하는 Finder 를 통해 CommentService 에서 CommentsSelectionStrategy 를 반환 하도록함.

1. 전략 인터페이스 구성

CommentsSelectionStrategy

public interface CommentsSelectionStrategy {

    MultiCommentSelectionResponse selectComments(final Long boardId, final Long memberId);

    BoardType getBoardType();
}
  • selectComments () - 게시판 id , 요청 회원에 맞는 게시글 리스트 반환
  • getBoardType() - StrategyFinder 에서 그에 맞는 전략 구현체를 찾기 위해 어떤 BoardType 인지 반환

2. 각 게시판에 맞는 전략 구현체 생성

RecruitmentBoardCommentsSelectionStrategy - 구인 게시글

@Component
@RequiredArgsConstructor
public class RecruitmentBoardCommentsSelectionStrategy implements CommentsSelectionStrategy {

    private static final BoardType BOARD_TYPE = BoardType.RECRUITMENT_BOARD;
    private final CommentRepository commentRepository;
    private final MemberService memberService;
    private final RecruitmentBoardService recruitmentBoardService;

    @Override
    public MultiCommentSelectionResponse selectComments(Long boardId, Long memberId) {
        List<Comment> commentsByRecruitmentBoard = commentRepository.findCommentByBoardTypeAndBoardId(BOARD_TYPE, boardId);
        Member member = memberService.findByMemberId(memberId);
        RecruitmentBoard board = recruitmentBoardService.findByIdWithOptimisticLock(boardId);
        boolean writingCommentPermission = board.isSameRegion(member.getRegion());
        boolean isWriterParticipatedIn = board.isParticipatedIn(memberId);
        return new MultiCommentSelectionResponse(convertToRecruitmentBoardCommentSelectionResponse(memberId, commentsByRecruitmentBoard, board, isWriterParticipatedIn), writingCommentPermission);
    }

		 //...
    @Override
    public BoardType getBoardType() {
       return BOARD_TYPE;
    }
}

CertificationBoardCommentsSelectionStrategy - 인증 게시글

@Component
@RequiredArgsConstructor
public class CertificationBoardCommentsSelectionStrategy implements CommentsSelectionStrategy {

    private static final BoardType BOARD_TYPE = BoardType.CERTIFICATION_BOARD;
    private final CommentRepository commentRepository;
    private final CertificationBoardService certificationBoardService;

    @Override
    public MultiCommentSelectionResponse selectComments(Long boardId, Long memberId) {
        List<Comment> commentsByCertificationBoard = commentRepository.findCommentByBoardTypeAndBoardId(BOARD_TYPE, boardId);
        CertificationBoard board = certificationBoardService.findById(boardId);
        return new MultiCommentSelectionResponse(convertToCertificationBoardCommentSelectionResponse2(memberId, commentsByCertificationBoard, board),
                true);
    }

	  //...

    @Override
    public BoardType getBoardType() {
        return BOARD_TYPE;
    }
}

3. 전략 Finder 생성

CommentSelectionStrategyFinder

@Component
@RequiredArgsConstructor
public class CommentsSelectionStrategyFinder {

    private final Set<CommentsSelectionStrategy> commentsSelectionStrategies;

    public CommentsSelectionStrategy getCommentSelectionStrategy(final BoardType boardType) {
        return commentsSelectionStrategies.stream()
                .filter(strategy -> strategy.getBoardType() == boardType)
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 boardType 입니다."));
    }
}

적용 후

@Service
@RequiredArgsConstructor
public class CommentService {

  //...
  private final CommentsSelectionStrategyFinder commentsSelectionStrategyFinder;

    @Transactional
    public MultiCommentSelectionResponse selectComments(final Long memberId, final BoardType boardType, final Long boardId){
        CommentsSelectionStrategy commentSelectionStrategy = commentsSelectionStrategyFinder.getCommentSelectionStrategy(boardType);
        return commentSelectionStrategy.selectComments(boardId, memberId);
    }

  //...
}

해결해야될 문제

  • 게시글이 삭제 될 경우, 데이터 정합성이 맞지 않는 문제 발생.
    • 기존에는 연관관계를 통한 CascadeType 설정으로 관리 했음.
    • 현재는 게시글 삭제 시 댓글 삭제가 안됨.

알게된 점 및 결과

1번 문제 - Comment 및 addComment() 메서드 리팩토링

  • 처음부터 엔티티 설계를 잘못했기 때문에 이런 문제가 발생하였기 때문에 앞으로는 DB 설계 시점에서 좀 더 신중하게 설계를 해야할 것.
    • 과거 Comment 엔티티 설계 시,구인 게시판과 인증 게시판과의 연관관계를 꼭 넣어야된다고 생각했던 점이 이런 문제를 불러일으켰음. 너무 객체간의 연관관계를 생각하는 점이 오히려 안좋게 작용 할 수도 있다고 생각함.

2번 문제 - 전략패턴을 통한 selectCommnets() 리팩토링

  • 이전에 자바로 전략 패턴을 사용 해봤을 때는 static 을 이용해서 Set 자료구조에 전략들을 넣어두고 원하는 전략을 뽑았다. 스프링에서는 빈으로 등록하여 사용하기 때문에 그런 불편한 작업이 없음.
  • 댓글 조회를 인터페이스로 추상화하여 각 Strategy에서 처리하면서 로직을 캡슐화 할 수 있었다. 결과적으로 CommentService 내부 코드의 복잡도를 낮출 수 있었다.
  • 추후에 다른 곳에 댓글 기능을 넣을 땐 전략만 생성해서 추가해주면 된다. 따라서 코드에 유연성이 올라감

해당 내용 작업 PR

https://github.com/mokInDang/project_backend/pull/412

끝.

BoardType 네이밍 CommentType

0개의 댓글

관련 채용 정보