개발일지

값 타입 컬렉션 ➡ 엔티티

gilssang97 2022. 1. 2. 13:06

값 타입 컬렉션을 엔티티로

그전에 이를 적용하게 된 계기부터 살펴보자.

이전 포스트에서 태그에 대한 부분에 대해 @ElementCollection 어노테이션을 통해 태그들을 값 타입 컬렉션으로 관리해주었다.

나는 태그에 관한 부분에 대해 값 타입 컬렉션에서 엔티티로 승격하기로 한 두 가지 이유가 존재했다.

첫 번째는 태그에 대한 요구사항의 변화에 유연하게 대처하고 싶었다.

태그 자체를 값 타입인 String 타입으로 저장하다보니 실제로 변경되거나 삭제되면 더이상 추적할 수 없고 그대로 다른 String으로 변하거나 삭제되어버린다.

추후 태그 자체의 수정을 가능하게하고 추적이 가능하게 할 수 있기 때문에 이에 대한 부분이 생긴다면 더 이상 값 타입으로 유지할 수 없고 엔티티로 승격해야하기에 미리 이 부분에 대해 고려하여 엔티티로 승격하기로 하였다.

두 번째는 값 타입 컬렉션의 한계점 부분 때문이다.

값 타입 컬렉션은 요소가 변경되면 전체 삭제 후 다시 입력된다. (그에 따라 INSERT 쿼리만 발생할 것이라고 예상하는 것과 달리 삭제 쿼리가 함께 나가고 추가한 요소만큼의 INSERT 쿼리보다 많은 쿼리가 발생할 것이다.)

값 타입 컬렉션을 구현한 JPA 구현체들은 보통 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력하지만 사용하는 컬렉션이나 여러 조건에 따라 기본 키를 식별할 수도 있고 식별하지 못할 수도 있다.

물론, 하이버네이트 구현체의 경우에는 Set 자체가 유일성을 보장하기에 모든 PK를 잡으면 최적화가 가능하지만 List의 경우 내부에 순서가 존재하기에 최적화가 이루어질 수 없어 이 부분에 대해 Set과 같은 최적화를 이루어내지 못한다.

물론 @OrderColumn 어노테이션을 통해 순서 컬럼을 PK 자체로 추가하여 ID+순서라는 PK로 진행한다면 최적화가 가능하지만 좋지 않은 방향이다.

그렇다면 값 타입 컬렉션을 채택하고 Set만 사용하면 되는 것이 아니냐라는 의문을 가질 수 있었으나 그에 대한 부분도 명백한 한계가 존재했다.

Set을 사용할 경우 데이터의 추가에서 문제가 생기는 모습을 보여줬다.

Set의 자료구조는 잘 알고있듯이, 중복 데이터를 저장하지 않는다.

그 뜻은 기존 데이터 중에 중복이 있는지에 대한 여부를 확인해야한다.

바로 위에서 언급한 중복에 대한 확인이 문제가 된다.

우리는 흔히 N+1 문제가 발생하지 않기 위해 Fetch 전략을 디폴트로 LAZY로 걸어둔다고 언급했었다. ([JPA] N+1 문제 (tistory.com))

LAZY로 걸어둔다는 뜻은 해당 엔티티 변수에 대해 프록시로 초기화해두어 사용될 때 로드하여 사용하는 것을 의미하는데 우리는 중복을 확인해야하기 위해 데이터를 필수적으로 확인해야하므로 프록시를 강제로 초기화해야한다.

그렇기에 우리가 예상치 못한 부분에서 쿼리가 발생할 수 있는 문제가 있다.

이러한 부분들에 대한 대안으로 값 타입 컬렉션을 엔티티로 변경하는 것을 생각해보았다.

이제 실제로 변경하는 것을 살펴보자.

다음 코드는 기존에 @ElementCollection을 활용하던 코드이다.

@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<String> tags = new ArrayList<>();

    ...

}

이를 String이 아닌 Tag라는 엔티티를 담는 컬렉션으로 승격해보자.

먼저, 태그는 간단하게 이름을 구성할 수 있게 만들었다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public class Tag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "study_id")
    private Study study;

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

그리고 기존 어노테이션에서 @OneToMany 어노테이션으로 변경해 1:N 연관관계 매핑을 진행해준다.

기존 태그에서와 같이 태그의 생명주기가 Study 엔티티(부모 엔티티)에 의존할 수 있도록 Cascade, OrphanRemoval, OnDelete를 걸어주어 이를 처리해줄 수 있게 하였다. (OnDelete를 사용한 이유는 Cascade vs @Delete (tistory.com)에서 확인할 수 있다.)

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

    ...

    @OneToMany(mappedBy = "study", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<Tag> tags = new ArrayList<>();

    ...

}

모든 변경이 진행된 후 값 타입 컬렉션에서 엔티티로 승격이 완료되었다.

그런데 아직 남아있는 과제가 존재한다.

우리가 클라이언트(프론트)로부터 받는 데이터(DTO)를 보자.

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

    ...

    @ApiModelProperty(value = "스터디 주제", notes = "스터디 주제를 입력해주세요.", required = true, example = "백엔드")
    private List<String> tags;

    ...
}

우리는 클라이언트(프론트)로부터 String의 데이터를 입력받아 Tag로 변경 후 진행해야한다.

우리는 각각의 태그 이름에 대해 몇몇 요구사항을 추가하고싶다.

  • 태그는 NULL이 될 수 없다.

  • 빈 문자열로 이루어진 태그는 금지한다.

  • 중복되는 태그는 금지한다.

더 많은 요구사항이 존재할 수 있지만 위와 같은 요구사항들에 대해서만 살펴보자.

태그가 NULL이 될 수 없다라는 요구사항 자체는 List자체가 Null이 아니면 되지 않을까라는 생각이 든다.

그래서 우리가 흔히 검증에 사용하는 Bean Validation을 통해 처리해줄 수 있지 않을까싶다.

하지만 List 내부 요소들 각각에 대해 그러한 태그가 존재하는지 확인하고 맞지 않는 상황이 있다면 Exception을 발생시켜야한다.

그러면 추가할 때 이를 확인해주자. Tag로 돌아가 해당 로직을 만들어보자. (Tag가 검증에 대한 로직을 처리하는 이유는 객체지향적인 설계를 살리기 위함이다.)

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "name")
public class Tag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "study_id")
    private Study study;

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

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

생성자에 대해 검증을 처리하는 로직을 추가하고 생성자에서 생성하면서 검증을 진행하면 다음과 같이 해결할 수 있다.

마지막으로 중복되는 태그를 금지하는 것을 확인해보자.

이 부분은 Tag 리스트 전체를 검증하는 부분임으로 DTO에서 해당 부분에 대한 메소드로 검증해보자.

그전에 @EqualsAndHashCode에 주목하자.

속성을 name 단일로 지정해주었는데 이렇게 설정한 이유는 우리는 단순히 태그의 동일성과 동등성 검증을 태그의 이름으로 처리할 것이기 때문에 이렇게 처리했다.

@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();
    }

    ...

}

위의 메소드는 간단하다.

1에서 Tag 엔티티로 각각을 변환하고 저장한다.

2에서 Tag 엔티티에 대한 이름의 중복을 검증한다. (중복을 검증하는데 있어 Set으로 변경해 개수가 같은지 검증하는 것을 통해 중복된 요소가 있는지 확인한다. 여기서 @EqualsAndHashCode가 진가를 발한다.)

위의 요구사항을 모두 테스트해보자.

class StudyCreateRequestDtoTest {
    @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().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"));
    }
}

테스트 코드는 위와 같다.

나머지 파라미터에는 신경쓰지말고 태그 파라미터를 보자.

첫 번째 테스트는 공백 문자열로 이루어진 태그를 가진 요청이기에 InCorrectTagNameException을 반환할 것이다.

두 번째 테스트는 "백엔드", "JPA"라는 태그를 가진 요청이기에 중복되는 부분이 존재하지 않아 정상적으로 통과될 것이다.

세 번째 테스트는 "백엔드"라는 태그를 중복으로 가진 요청이기에 DuplicateTagsException을 반환할 것이다.

실제로 테스트의 결과는 다음과 같다.

위와 같이 테스트가 잘 통과하는 모습을 확인할 수 있었다.

물론 아직 List라는 상태와 검증을 진행하는 validateDuplicate라는 행위가 다른 곳에서 관리되어지고 있어 다른 개발자로 하여금 혼란을 줄 수 있다. (물론, 지금은 바로 아래에 위치하기에 큰 문제 요소가 없어보이지만 추후 멀리 떨어져 있다면 문제가 될 가능성이 있다.)

그래서 다음에는 이러한 부분에 대해 처리해보려고 한다. (일급 컬렉션)