개발일지

JPA에서의 일급 컬렉션 적용

gilssang97 2022. 1. 2. 16:58

엔티티를 다시 값 타입 컬렉션으로 전환

바로 이전 포스트에서 값 타입 컬렉션을 엔티티로 승격시킨바 있다.

그 이유는 이전 포스트에도 설명했지만 추후 수정이나 추적의 여지가 있기에 해당 부분에 대한 변경을 진행해보았다.

물론, 태그라는 특성 자체가 수정이나 추적의 여지가 큰 건 아니지만 한 번 진행해본 부분이였다.

해당 부분을 수정한 뒤, 협업하는 팀원과 함께 논의를 거쳐 요구사항을 더 명확히 하기로 하였다.

그 결과, 태그 자체를 수정하는 로직은 존재하지 않으며 수정을 거치기 위해서는 단순 삭제 후 새로운 것을 추가하는 방향이며 태그를 추후 업데이트하고 싶다면 업데이트된 태그들을 담은 리스트로 기존 리스트를 대체하는 것이였다.

이 요구사항을 정리해보자.

  • 수정은 불필요하다.
  • 태그 요소들의 변경은 리스트 자체의 대체이다.

이러한 두 부분을 살펴보면 태그에 대한 추적이 전혀 필요하지 않고 단순 저장을 필요로 할 뿐이다.

그에 따라 이전에 언급한 값 타입 컬렉션의 한계(수정으로 인한 사이드 이펙트(전체 삭제 후 나머지 다시 삽입)는 이번 부분에서 안전하다고 생각했고 태그는 엔티티보단 값 타입 컬렉션의 성격에 가깝다고 생각했다.

그에 따라, 값 타입 컬렉션으로 다시 전환하는 것을 추진하였다.

하지만 기존에 구현했던 방식과는 조금 다르게 List이 아니라 Tag라는 임베디드 타입(값 타입)을 만들어 이를 값 타입 컬렉션(List)으로 만들기로 하였다.

그리고 다시 전환함에 있어 그전에 새로 추가된 요구사항들은 유지하기로 하였다.

  • 태그는 NULL이 될 수 없다.
  • 빈 문자열로 이루어진 태그는 금지한다.
  • 중복되는 태그는 금지한다.

먼저, Tag 임베디드 타입을 구성해보자.

만들어진 Tag 임베디드 타입은 다음과 같다.

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "name")
public class Tag {
    private String name;

    public Tag(String name) {
        validTagName(name);
        this.name = name;
    }

    private void validTagName(String name) {
        if (name.isBlank())
            throw new InCorrectTagNameException();
    }
}

validTagName은 이전 요구사항에 존재했던 검증사항이다.

엔티티와 다르게 ID는 존재하지 않았다.

그리고 주의해야할 점은 lombok을 활용해 @EqualsAndHashCode를 선언했다는 점이다.

이전 포스트에서도 설명했지만 태그의 이름을 통해 동일성, 동등성을 체크할 것이기에 이 부분을 필수적으로 구현했다.

그리고 임베디드 타입이라고 했으므로 @Embeddable을 잊지 말자.

다음은 실제로 값 타입 컬렉션으로 구성해보자.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public class Study extends EntityDate {

    ...

    @ElementCollection
    @CollectionTable(name = "study_tag", joinColumns = @JoinColumn(name = "study_id"))
    @Column(name = "tag_name")
    private List<Tag> tags;

    ...

}

@ElementCollection 어노테이션을 통해 임베디드 타입인 Tag를 인자로 가지는 리스트를 구성하여 위와 같은 컬렉션을 만들었다.

실제로 값 타입 컬렉션을 구성하는 부분은 이전 포스트에서 다루었기에 이 부분에 대해 잘 모르겠다면 이 포스트를 참고하자.(@ElementCollections 사용기 (tistory.com))

이제 Dto에서 태그가 중복되었는지에 대한 요구사항을 맞춰보자.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(value = "스터디 생성 요청")
public class StudyCreateRequestDto {

    ...

    public Study toEntity(String profileImg) {
        List<Tag> tagList = tags.stream() // 1
                .map(tag -> new Tag(tag))
                .collect(Collectors.toList());

        validateDuplicate(tagList); // 2

        Study study = Study.builder()
                .title(title)
                .tags(tagList)
                .content(content)
                .schedule(new Schedule(startDate, endDate))
                .studyOptions(new StudyOptions(studyState, recruitState, studyMethod))
                .member(new Member(memberId))
                .studyJoins(new ArrayList<>())
                .studyBoards(new ArrayList<>())
                .profileImgUrl(profileImg)
                .headCount(headCount)
                .build();

        initStudyJoins(study);
        initStudyBoards(study);

        return study;
    }

    private void validateDuplicate(List<Tag> tags) {
        Set<Tag> nonDuplicateTags = new HashSet<>(tags);
        if (nonDuplicateTags.size() != tags.size())
            throw new DuplicateTagsException();
    }

    ...

}

위 코드에서 볼 수 있듯이 이전 엔티티에서 구현한 부분과 크게 다르지 않다.

그렇기에 이전 포스트에서 언급한 문제가 여기서도 보인다. 바로 상태와 행위가 한 곳에서 관리되지 않고 있다.

기능적으로는 문제가 되지 않지만 추후 여러 개발자들이 투입되고 이러한 코드들이 붙어있지 않고 상당히 떨어져있다면 validateDuplicate가 tag의 검증을 위한 코드인지를 해당 메소드를 확인하고나서야 알 것이다.

이러한 부분뿐 아니라 다양한 장점을 제공해주는 해결방안이 존재한다.

바로 일급 컬렉션이다.

일급 컬렉션으로

나는 자바에 대해 공부를 하면서 일급 컬렉션에 대해 학습한 적이 있다.

일급 컬렉션의 다양한 장점은 다음과 같다.

1.불변이다.

불변이라는 것의 장점은 이전 포스트에서 언급했었지만, 복사에 안전하고 동시성 문제에서 안전하게 사용할 수 있다는 점 등 다양한 장점이 존재한다.

2.상태와 행위를 한곳에서 관리한다.

보통 컬렉션을 선언한 부분과 컬렉션을 활용하는 로직의 경우 따로 떨어져서 존재하지만 일급 컬렉션으로 선언할 경우 해당 부분을 묶어 한곳에서 관리할 수 있다.

3.이름을 지정할 수 있다.

기본적으로 컬렉션은 List과 같이 단순 타입으로 나타나 있지만 타입 자체를 의미 있는 이름으로 설정하여 사용할 수 있다. 이 부분이 큰 장점처럼 보이지 않을 수 있지만 List의 타입이 여러개 존재할 경우 구분하기 쉽지 않을 것이라는 것을 고려해보면 큰 장점이 될 수 있다.

이러한 장점들을 가진 일급 컬렉션을 실제로 엔티티에 적용해보려고하는데 어떻게 적용할 수 있을까?

바로 이전에 사용한 @Embeddable을 활용하면 가능하다. (@Embeddable 활용기 (tistory.com))

@Embeddable을 활용해 Tag 컬렉션을 래핑하여 이를 제공하는 메소드를 이 래핑 클래스에 구현하면 상태와 행위를 한 곳에서 관리할 수 있게 되는 것이다.

이를 직접 적용해보자.

기존에는 Tag 컬렉션을 다음과 같이 구성하였다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public class Study extends EntityDate {

    ...

    @ElementCollection
    @CollectionTable(name = "study_tag", joinColumns = @JoinColumn(name = "study_id"))
    @Column(name = "tag_name")
    private List<Tag> tags;

    ...

}

이를 래핑하는 클래스를 하나 구성하자.

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tags {
    @ElementCollection
    @CollectionTable(name = "study_tag", joinColumns = @JoinColumn(name = "study_id"))
    @Column(name = "tag_name")
    private List<Tag> tags;

    public Tags(List<Tag> tags) {
        validDuplicate(tags);
        this.tags = tags;
    }

    public List<String> getTagNames() {
        return tags.stream()
                .map(tag -> tag.getName())
                .collect(Collectors.toList());
    }

    private void validDuplicate(List<Tag> tags) {
        Set<Tag> nonDuplicateTags = new HashSet<>(tags);
        if (nonDuplicateTags.size() != tags.size())
            throw new DuplicateTagsException();
    }
}

위에서 설명한 불변이라는 의미와 다르지 않는가?

Tag 컬렉션이 final로 선언되지도 않았는데 어떻게 불변이라고 할 수 있는가라고 하겠지만 JPA는 기본 생성자를 활용하여 객체를 생성하고 리플랙션을 사용하여 값을 매핑하도록 내부적으로 구현되어있기 때문에 final로 선언이 불가능하다.

그에 따라, 최대한 불변성을 제공하기 위해 Setter를 제공하지 않고 생성자를 통해서만 이용이 가능하게 하고 Tag 컬렉션을 Getter로 제공하지 않고 단순 Tag의 이름 컬렉션만을 제공하고 있다.

(최대한 개인적인 견해로 불변성을 유지하려고 했습니다. 틀린 부분이나 다른 의견이 있으시면 댓글 부탁드리겠습니다!)

다른 장점들은 명백히 보인다.

상태와 행위를 내부에서 직접 관리하여 중복을 확인해주는 모습을 확인해주고 있으며 List로 타입을 표시하던 것을 Tags라는 이름으로 표시할 수 있게 되어 가독성을 증진시켜주었다.

이제 다시 Study 클래스로 돌아가 이를 적용하면 다음과 같다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public class Study extends EntityDate {

    ...

    @Embedded
    private Tags tags = new Tags();

    ...

}

위에서 쭉 살펴보았듯이, 임베디드 타입으로 설정된 Tags는 List를 래핑한 클래스로 일급 컬렉션과 같은 효과를 누릴 수 있다.

기존에 중복 검증 로직을 수행해주던 DTO는 어떻게 변했을까?

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(value = "스터디 생성 요청")
public class StudyCreateRequestDto {

    ...

    public Study toEntity(String profileImg) {
        Study study = Study.builder()
                .title(title)
                .content(content)
                .tags(new Tags(tags.stream().map(tag -> new Tag(tag)).collect(Collectors.toList())))
                .schedule(new Schedule(startDate, endDate))
                .studyOptions(new StudyOptions(studyState, recruitState, studyMethod))
                .member(new Member(memberId))
                .studyJoins(new ArrayList<>())
                .studyBoards(new ArrayList<>())
                .profileImgUrl(profileImg)
                .headCount(headCount)
                .build();

        initStudyJoins(study);
        initStudyBoards(study);

        return study;
    }

    ...

}

위와 같이 단순히 String의 태그 리스트를 Tag 엔티티의 리스트로 변경시켜 Tags를 생성하여 넣어주기만 하면 된다.

기존에 존재하던 validateDuplicate 메소드와 해당 메소드를 적용하는 로직이 사라져 코드가 상당히 깔끔해진 모습을 확인할 수 있다.

이렇게 상태와 행위를 한곳에서 관리함으로써 코드가 클린하게 변하고 가독성이 올라갔다.

그리고 불변 객체로 바꿈으로써 변경으로 인한 사이드 이펙트가 줄어들었고 이름을 직접적으로 선언함으로써 가독성이 올라갔다.

이제 실제로 이를 테스트해 기능이 정상적으로 동작하는지 확인해보자.

이전에 진행했던 테스트를 동일하게 가져오자. (다만, Tag 리스트를 Tags로 래핑했기에 두 번째 테스트의 assertEquals 구문에서 getTags를 한번더 호출했다.)

class TagsTest {
    @Test
    @DisplayName("공백의 태그를 가진 Tag 컬렉션은 예외를 반환한다.")
    public void blank() throws Exception {
        //given
        FileInputStream fileInputStream = new FileInputStream("C:\\Users\\Family\\Pictures\\Screenshots\\git.png");
        MockMultipartFile profileImg = new MockMultipartFile("Img", "myImg.png", MediaType.IMAGE_PNG_VALUE, fileInputStream);

        StudyCreateRequestDto requestDto = new StudyCreateRequestDto(1L, "백엔드 스터디", List.of(" "),
                "백엔드 스터디입니다.", "2021-12-01", "2021-01-01", 2L, profileImg,
                StudyMethod.FACE, StudyState.STUDYING, RecruitState.PROCEED);

        Assertions.assertThrows(InCorrectTagNameException.class, () -> requestDto.toEntity("convertedProfileImg"));

    }

    @Test
    @DisplayName("중복된 태그를 가지지 않은 Tag 컬렉션은 성공적으로 저장된다.")
    public void success() throws Exception {
        //given
        FileInputStream fileInputStream = new FileInputStream("C:\\Users\\Family\\Pictures\\Screenshots\\git.png");
        MockMultipartFile profileImg = new MockMultipartFile("Img", "myImg.png", MediaType.IMAGE_PNG_VALUE, fileInputStream);

        StudyCreateRequestDto requestDto = new StudyCreateRequestDto(1L, "백엔드 스터디", List.of("백엔드", "JPA"),
                "백엔드 스터디입니다.", "2021-12-01", "2021-01-01", 2L, profileImg,
                StudyMethod.FACE, StudyState.STUDYING, RecruitState.PROCEED);

        Study study = requestDto.toEntity("convertedProfileImg");

        Assertions.assertEquals(2, study.getTags().getTags().size());
    }

    @Test
    @DisplayName("중복된 태그를 가진 Tag 컬렉션은 예외를 반환한다.")
    public void duplicateTest() throws Exception {
        FileInputStream fileInputStream = new FileInputStream("C:\\Users\\Family\\Pictures\\Screenshots\\git.png");
        MockMultipartFile profileImg = new MockMultipartFile("Img", "myImg.png", MediaType.IMAGE_PNG_VALUE, fileInputStream);

        StudyCreateRequestDto requestDto = new StudyCreateRequestDto(1L, "백엔드 스터디", List.of("백엔드", "백엔드"),
                "백엔드 스터디입니다.", "2021-12-01", "2021-01-01", 2L, profileImg,
                StudyMethod.FACE, StudyState.STUDYING, RecruitState.PROCEED);

        Assertions.assertThrows(DuplicateTagsException.class, () -> requestDto.toEntity("convertedProfileImg"));
    }
}

테스트 결과를 살펴보면 다음과 같다.

모두 통과되었다.


JPA에서 일급 컬렉션으로 전환하면서 다양한 이점을 얻었다.

그리고 테스트를 통해 변경으로부터 기능의 안전성을 검증받았다.

이를 통해 일급 컬렉션의 장점과 테스트의 중요성을 다시 한번 더 알 수 있게 되었다.