반응형

이번 시간에는 계층형 댓글을 이어서 구현해보겠습니다.

 

 

service.comment.CommentService를 작성해주겠습니다.

package kukekyakya.kukemarket.service.comment;

import ...

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentService {
    private final CommentRepository commentRepository;
    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    public List<CommentDto> readAll(CommentReadCondition cond) { // 1
        return CommentDto.toDtoList(
                commentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc(cond.getPostId())
        );
    }

    @Transactional
    public void create(CommentCreateRequest req) { // 2
        commentRepository.save(CommentCreateRequest.toEntity(req, memberRepository, postRepository, commentRepository));
    }

    @Transactional
    public void delete(Long id) { // 3
        Comment comment = commentRepository.findById(id).orElseThrow(CommentNotFoundException::new);
        comment.findDeletableComment().ifPresentOrElse(commentRepository::delete, comment::delete);
    }
}

각각의 항목에 대해서 자세히 살펴보도록 하겠습니다.

 

먼저, 댓글 목록 조회 메소드인 1번을 살펴보겠습니다.

CommentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc의 조회 결과를, CommentDto.toDtoList를 이용하여 CommentDto로 변환해주었습니다.

 

dto.comment.CommentDto는 다음과 같습니다.

package kukekyakya.kukemarket.dto.comment;

import ...

@JsonInclude(JsonInclude.Include.NON_NULL)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommentDto {
    private Long id;
    private String content;
    private MemberDto member;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdAt;
    private List<CommentDto> children;

    public static List<CommentDto> toDtoList(List<Comment> comments) {
        NestedConvertHelper helper = NestedConvertHelper.newInstance(
                comments,
                c -> new CommentDto(c.getId(), c.isDeleted() ? null : c.getContent(), c.isDeleted() ? null : MemberDto.toDto(c.getMember()), c.getCreatedAt(), new ArrayList<>()),
                c -> c.getParent(),
                c -> c.getId(),
                d -> d.getChildren());
        return helper.convert();
    }
}

플랫한 구조의 댓글 목록을, 계층형 구조로 어디서 변환 하나 했더니,

CommentDto.toDtoList에서 comments를 인자로 받아다가 계층형 구조로 변환하여 반환해주고 있었습니다.

NestedConvertHelper라는 헬퍼 클래스를 통해 댓글 목록을 중첩 구조로 변환하고 있던 것입니다.

 

 

그렇다면 이제 플랫한 구조의 댓글 목록을, 계층형으로 변환하기 위한 실질적인 작업을 수행하는 helper.NestedConvertHelper를 살펴보도록 하겠습니다.

* 이에 대한 내용은 계층형 카테고리를 구현하면서 설명했던 내용이지만, 편의를 위해 동일한 내용을 한번 더 설명하겠습니다.

2021.12.08 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (15) - 게시판 - 계층형 카테고리 - 1

위 기능을 구현하면서 작성했던 NestedConvertHelper를 그대로 적용하여, 계층형 구조로 손쉽게 변환할 수 있는 것입니다.

 

package kukekyakya.kukemarket.helper;

import kukekyakya.kukemarket.exception.CannotConvertNestedStructureException;

import java.util.*;
import java.util.function.Function;

public class NestedConvertHelper<K, E, D> {

    private List<E> entities;
    private Function<E, D> toDto;
    private Function<E, E> getParent;
    private Function<E, K> getKey;
    private Function<D, List<D>> getChildren;

    public static <K, E, D> NestedConvertHelper newInstance(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
        return new NestedConvertHelper<K, E, D>(entities, toDto, getParent, getKey, getChildren);
    }

    private NestedConvertHelper(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
        this.entities = entities;
        this.toDto = toDto;
        this.getParent = getParent;
        this.getKey = getKey;
        this.getChildren = getChildren;
    }

    public List<D> convert() {
        try {
            return convertInternal();
        } catch (NullPointerException e) {
            throw new CannotConvertNestedStructureException(e.getMessage());
        }
    }

    private List<D> convertInternal() {
        Map<K, D> map = new HashMap<>();
        List<D> roots = new ArrayList<>();

        for (E e : entities) {
            D dto = toDto(e);
            map.put(getKey(e), dto);
            if (hasParent(e)) {
                E parent = getParent(e);
                K parentKey = getKey(parent);
                D parentDto = map.get(parentKey);
                getChildren(parentDto).add(dto);
            } else {
                roots.add(dto);
            }
        }
        return roots;
    }

    private boolean hasParent(E e) {
        return getParent(e) != null;
    }

    private E getParent(E e) {
        return getParent.apply(e);
    }

    private D toDto(E e) {
        return toDto.apply(e);
    }

    private K getKey(E e) {
        return getKey.apply(e);
    }

    private List<D> getChildren(D d) {
        return getChildren.apply(d);
    }
}

복잡해보이지만, 실상은 그렇게 복잡하지 않습니다.

 

세부적으로 살펴보겠습니다.

 

public class NestedConvertHelper<K, E, D> {
    ...

NestedConvertHelper는 세 개의 제너릭 파라미터를 받습니다.

K : 엔티티의 key 타입

E : 엔티티의 타입

D : 엔티티가 변환된 DTO의 타입.

 

public class NestedConvertHelper<K, E, D> {

    private List<E> entities;
    private Function<E, D> toDto;
    private Function<E, E> getParent;
    private Function<E, K> getKey;
    private Function<D, List<D>> getChildren;

NestedConvertHelper는 다음과 같은 네 개의 필드를 가지고 있습니다.

entities : 플랫한 구조의 엔티티 목록입니다. 우리가 약속했던 방법(CommentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc)으로 정렬된 엔티티 목록을 전달받으면, 각 엔티티가 자식 엔티티를 나타내는 계층형 구조의 DTO 목록으로 변환시켜줄 것입니다.

toDto : 엔티티를 DTO로 변환해주는 Function입니다.

getParent : 엔티티의 부모 엔티티를 반환해주는 Function입니다.

getKey : 엔티티의 Key(우리는 id)를 반환해주는 Function입니다.

getChildren: DTO의 children 리스트를 반환해주는 Function입니다.

 

우리는 기존에 작성된 엔티티와 DTO는 전혀 건드리지 않을 것입니다.

엔티티가 변환되어야 할 DTO 타입은, 이미 엔티티에 대해서 알고 있습니다.

엔티티의 수정 없이 entities를 계층형 DTO 구조로 변환하기 위해, 필요한 함수들을 DTO를 통해서 주입받아서 사용할 것입니다.

 

 

public static <K, E, D> NestedConvertHelper newInstance(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
    return new NestedConvertHelper<K, E, D>(entities, toDto, getParent, getKey, getChildren);
}

private NestedConvertHelper(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
    this.entities = entities;
    this.toDto = toDto;
    this.getParent = getParent;
    this.getKey = getKey;
    this.getChildren = getChildren;
}

NestedConvertHelper의 인스턴스를 생성하는 스태틱 팩토리 메소드 newInstance입니다.

스태틱 메소드이기 때문에 메소드 레벨에 제너릭 타입 파라미터를 지정하였습니다.

전달받는 함수를 이용한 타입 추론에 의해 각각의 제너릭 타입이 추론될 것입니다.

내부적으로 private 생성자를 이용하여 인스턴스를 생성하고 초기화합니다.

 

 

public List<D> convert() {
    try {
        return convertInternal();
    } catch (NullPointerException e) {
        throw new CannotConvertNestedStructureException(e.getMessage());
    }
}

인스턴스를 생성했다면, 퍼블릭 메소드 convert를 통해 계층형 변환 작업을 수행할 수 있습니다.

단, 우리는 입력받은 entities에 대해 사전 조건이 필요합니다.

부모 댓글의 id를 오름차순으로 정렬되어있어야하기 때문에,

entities를 순차적으로 탐색하면서, 어떤 댓글의 부모 댓글 id는 반드시 해당 댓글보다 앞서서 탐색된 상황이어야합니다.

만약 그렇지 않다면, NullPointerExceptino이 발생할 것이고, CannotConvertNestedStructureException 예외가 발생하여 우리는 entities를 계층형 구조로 변환할 수 없습니다.

 

 

private List<D> convertInternal() {
    Map<K, D> map = new HashMap<>();
    List<D> roots = new ArrayList<>();

    for (E e : entities) { // 1
        D dto = toDto(e);
        map.put(getKey(e), dto); // 2
        if (hasParent(e)) { // 3
            E parent = getParent(e);
            K parentKey = getKey(parent);
            D parentDto = map.get(parentKey);
            getChildren(parentDto).add(dto);
        } else {
            roots.add(dto); // 4
        }
    }
    return roots;
}

실질적인 변환 작업을 수행합니다.

roots에는 자식 엔티티가 없는 루트 엔티티가 담기게 되고, 최종적인 반환 값은 roots가 될 것입니다.

그리고 roots에 담긴 DTO의 children들은, 자신의 자식들을 담고 있습니다.

이를 구현하기 위해 Map을 이용하였습니다.

 

1. 우리의 사전 조건에 의해 정렬된 entities를 순차적으로 탐색합니다.

2. 탐색된 엔티티를 DTO로 변환하여 map에 넣어줍니다. 이미 탐색된 부모 엔티티의 DTO는, 어떤 자식 엔티티를 탐색할 때 반드시 Map에 담겨있어야합니다. 그렇지 않다면 NullPointerException 예외가 발생하여 변환 작업이 실패하는 것입니다.

3. 부모가 있다면, Map에서 부모의 DTO를 찾아줍니다. entities는 우리의 의도에 맞게 정렬된 상태일 것이기 때문에, 반드시 부모 엔티티의 DTO는 Map에 이미 있을 것입니다. 부모 DTO의 children으로, 지금 탐색하는 엔티티의 DTO를 넣어줍니다.

4. 부모가 없다면, 루트 엔티티입니다. 해당 DTO를 roots에 넣어줍니다.

 

위 알고리즘에 의해 NestedConvertHelper가 전달받은 entites는 계층형 구조로 변환되는 것입니다.

 

private boolean hasParent(E e) {
    return getParent(e) != null;
}

private E getParent(E e) {
    return getParent.apply(e);
}

private D toDto(E e) {
    return toDto.apply(e);
}

private K getKey(E e) {
    return getKey.apply(e);
}

private List<D> getChildren(D d) {
    return getChildren.apply(d);
}

주입받은 Function을 통해 다양한 메소드를 작성할 수 있습니다.

 

 

그렇다면 이제 다시 CommentDto.toDtoList로 돌아가보도록 하겠습니다.

public static List<CommentDto> toDtoList(List<Comment> comments) {
    NestedConvertHelper helper = NestedConvertHelper.newInstance(
            comments,
            c -> new CommentDto(c.getId(), c.isDeleted() ? null : c.getContent(), c.isDeleted() ? null : MemberDto.toDto(c.getMember()), c.getCreatedAt(), new ArrayList<>()),
            c -> c.getParent(),
            c -> c.getId(),
            d -> d.getChildren());
    return helper.convert();
}

이제 어떤 코드인지 알게 되었습니다.

NestedConvertHelper의 인스턴스를 생성하기 위해,

첫번째 인자로 계층형 구조로 변환할 엔티티 목록을,

두번째 인자로 엔티티를 DTO로 변환하는 함수를,

세번째 인자로 엔티티의 부모를 반환하는 함수를,

네번째 인자로 엔티티의 ID를 반환하는 함수를,

다섯번째 인자로 DTO의 자식 목록을 반환하는 함수를 지정해준 것입니다.

이를 이용하여 NestedConvertHelper.convert는 계층형 구조로 변환 작업을 수행하고, toDtoList는 계층형 구조의 CommentDto 리스트를 반환하는 것입니다.

응답된 리스트에는 루트 DTO들이 있을 것이고, 각 루트 DTO는 children에 하위 DTO를 재귀적으로 소유하고 있을 것입니다.

* 삭제된 댓글이라면, 댓글 본문과 작성자 정보는 생략하도록 하였습니다.

 

우리는 요구 사항에 의해, 카테고리 뿐만 아니라 대댓글도 계층형으로 만들어야했는데, 카테고리를 구현하면서 작성했던 클래스를 그대로 재사용하여, 손쉽게 계층형 구조로 변환할 수 있던 것입니다.

 

NestedConvertHelper의 테스트는 계층형 카테고리 1편에 작성되어 있으므로 생략하겠습니다.

 

이제 toDtoList에 대한 이해가 끝났으니, 다시 CommentService.readAll로 돌아가서,

파라미터로 전달받았던 CommentReadCondition를 확인해보겠습니다.

package kukekyakya.kukemarket.dto.comment;

import ...

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommentReadCondition {
    @NotNull(message = "게시글 번호를 입력해주세요.")
    @PositiveOrZero(message = "올바른 게시글 번호를 입력해주세요. (0 이상)")
    private Long postId;
}

단순히 댓글이 속한 게시글의 번호만 가지고 있습니다.

 

제약 조건도 즉시 테스트해주겠습니다.

test 디렉토리에서 CommentReadConditionTest를 작성해줍니다.

package kukekyakya.kukemarket.dto.comment;

import ...

class CommentReadConditionTest {
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validateTest() {
        // given
        CommentReadCondition cond = createCommentReadCondition();

        // when
        Set<ConstraintViolation<CommentReadCondition>> validate = validator.validate(cond);

        // then
        assertThat(validate).isEmpty();
    }

    @Test
    void invalidateByNegativePostIdTest() {
        // given
        Long invalidValue = -1L;
        CommentReadCondition cond = createCommentReadCondition(invalidValue);

        // when
        Set<ConstraintViolation<CommentReadCondition>> validate = validator.validate(cond);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }
}

자세한 설명은 생략하겠습니다.

 

사용된 CommentReadConditionFactory는 다음과 같습니다.

package kukekyakya.kukemarket.factory.dto;

import ...

public class CommentReadConditionFactory {
    public static CommentReadCondition createCommentReadCondition() {
        return new CommentReadCondition(1L);
    }

    public static CommentReadCondition createCommentReadCondition(Long postId) {
        return new CommentReadCondition(postId);
    }
}

 

 

이제 CommentService에서 댓글을 생성하는 2번 항목을 살펴보겠습니다.

// CommentService.java
@Transactional
public void create(CommentCreateRequest req) {
    commentRepository.save(CommentCreateRequest.toEntity(req, memberRepository, postRepository, commentRepository));
}

CommentCreateRequest를 파라미터로 전달받아서, Comment 엔티티로 변환하여 저장해줍니다.

 

 

dto.comment.CommentCreateRequest는 다음과 같습니다.

package kukekyakya.kukemarket.dto.comment;

import ...

@ApiModel(value = "댓글 생성 요청")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommentCreateRequest {

    @ApiModelProperty(value = "댓글", notes = "댓글을 입력해주세요", required = true, example = "my comment")
    @NotBlank(message = "댓글을 입력해주세요.")
    private String content;

    @ApiModelProperty(value = "게시글 아이디", notes = "게시글 아이디를 입력해주세요", example = "7")
    @NotNull(message = "게시글 아이디를 입력해주세요.")
    @Positive(message = "올바른 게시글 아이디를 입력해주세요.")
    private Long postId;

    @ApiModelProperty(hidden = true)
    @Null
    private Long memberId;

    @ApiModelProperty(value = "부모 댓글 아이디", notes = "부모 댓글 아이디를 입력해주세요", example = "7")
    private Long parentId;

    public static Comment toEntity(CommentCreateRequest req, MemberRepository memberRepository, PostRepository postRepository, CommentRepository commentRepository) {
        return new Comment(
                req.content,
                memberRepository.findById(req.memberId).orElseThrow(MemberNotFoundException::new),
                postRepository.findById(req.postId).orElseThrow(PostNotFoundException::new),
                Optional.ofNullable(req.parentId)
                        .map(id -> commentRepository.findById(id).orElseThrow(CommentNotFoundException::new))
                        .orElse(null)
        );
    }
}

댓글의 내용과 게시글 id, 작성자 id, 부모 댓글 id를 가지고 있습니다.

memberId는 클라이언트에서 직접 입력받는 것이 아니라, 서버에서 주입해줄 것이기 때문에 @Null 제약 조건을 달아주었습니다.

 

이외의 API 문서 작성과 제약 조건을 위한 어노테이션들의 설명은 생략하겠습니다.

우리는 스태틱 메소드 toEntity를 집중적으로 살펴볼 것입니다.

public static Comment toEntity(CommentCreateRequest req, MemberRepository memberRepository, PostRepository postRepository, CommentRepository commentRepository) {
    return new Comment(
            req.content,
            memberRepository.findById(req.memberId).orElseThrow(MemberNotFoundException::new),
            postRepository.findById(req.postId).orElseThrow(PostNotFoundException::new),
            Optional.ofNullable(req.parentId)
                    .map(id -> commentRepository.findById(id).orElseThrow(CommentNotFoundException::new))
                    .orElse(null)
    );
}

댓글의 부모는 없을 수도 있습니다. 루트 댓글인 경우가 그렇습니다.

즉, parentId는 null일 수도 있습니다.

parentId가 null이라면, Comment 생성자의 네번째 인자(부모 Comment)도 null이어야 합니다.

parentId가 null이 아니라면, Comment 생성자의 네번째 인자로 부모 Comment를 지정해주어야합니다.

하지만 parentId에 해당하는 Comment가 없다면, CommentNotFoundException 예외가 발생해야합니다.

이러한 로직을 간단히 구현하기 위해, Optional을 이용하였습니다.

 

ofNullable에 주어진 값이 null이라면, map은 수행되지 않습니다. orElse의 null이 반환됩니다.

ofNullable에 주어진 값이 null이 아니지만, 없는 댓글 id라면, CommentNotFoundException 예외가 던져집니다.

ofNullable에 주어진 값이 null이 아니지만, 있는 댓글 id라면, 해당하는 Comment가 반환되어 부모로 지정할 수 있습니다.

 

 

다음으로 CommentService에서 3번 항목인 delete 메소드를 살펴보겠습니다.

// CommentService.java
@Transactional
public void delete(Long id) {
    Comment comment = commentRepository.findById(id).orElseThrow(CommentNotFoundException::new);
    comment.findDeletableComment().ifPresentOrElse(commentRepository::delete, comment::delete);
}

댓글 삭제 방식에 대해서는 지난 시간에 논의하였으므로, 자세한 설명은 생략하겠습니다.

Comment.findDeletableComment로 조회된 결과가 있다면, 그 결과를 데이터베이스에서 실제로 제거할 것이고,

그렇지 않다면, Comment.delete를 호출하여 삭제 표시만 해주겠습니다.

 

 

이제 test 디렉토리에서 CommentServiceTest를 작성하여 테스트해주겠습니다.

package kukekyakya.kukemarket.service.comment;

import ...

@ExtendWith(MockitoExtension.class)
class CommentServiceTest {
    @InjectMocks CommentService commentService;
    @Mock CommentRepository commentRepository;
    @Mock MemberRepository memberRepository;
    @Mock PostRepository postRepository;

    @Test
    void readAllTest() {
        // given
        given(commentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc(anyLong()))
                .willReturn(
                        List.of(createComment(null),
                                createComment(null)
                        )
                );

        // when
        List<CommentDto> result = commentService.readAll(createCommentReadCondition());

        // then
        assertThat(result.size()).isEqualTo(2);
    }

    @Test
    void readAllDeletedCommentTest() {
        // given
        given(commentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc(anyLong()))
                .willReturn(
                        List.of(createDeletedComment(null),
                                createDeletedComment(null)
                        )
                );

        // when
        List<CommentDto> result = commentService.readAll(createCommentReadCondition());

        // then
        assertThat(result.size()).isEqualTo(2);
        assertThat(result.get(0).getContent()).isNull();
        assertThat(result.get(0).getMember()).isNull();
    }

    @Test
    void createTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(postRepository.findById(anyLong())).willReturn(Optional.of(createPost()));

        // when
        commentService.create(createCommentCreateRequest());

        // then
        verify(commentRepository).save(any());
    }

    @Test
    void createExceptionByMemberNotFoundTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> commentService.create(createCommentCreateRequest()))
                .isInstanceOf(MemberNotFoundException.class);
    }

    @Test
    void createExceptionByPostNotFoundTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(postRepository.findById(anyLong())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> commentService.create(createCommentCreateRequest()))
                .isInstanceOf(PostNotFoundException.class);
    }

    @Test
    void createExceptionByCommentNotFoundTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(postRepository.findById(anyLong())).willReturn(Optional.of(createPost()));
        given(commentRepository.findById(anyLong())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> commentService.create(createCommentCreateRequestWithParentId(1L)))
                .isInstanceOf(CommentNotFoundException.class);
    }

}

 자세한 설명은 생략하겠습니다.

 

 

이번 시간에는 댓글을 조회, 삭제, 수정하는 서비스 로직을 작성해보았습니다.

다음 시간에는 이를 수행하는 컨트롤러 계층을 작성해보겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts