값 타입 컬렉션, 어떻게 사용할까?

junto·2024년 7월 20일
0

spring

목록 보기
26/30
post-thumbnail

과거 작성한 코드를 보니, 값 타입 컬렉션을 잘못 사용하고 있었다. 왜 잘못 사용했는지 알아보고, 언제 사용하면 좋을지 알아본다.
전체 코드: https://github.com/ji-jjang/Learning/tree/main/Practice/ValueObjectCollection

값 타입 컬렉션 특징

1. JPA 영속성 컨텍스트에 저장되지 않음

  • 식별자가 없고, 단순한 값들의 모음이다. 즉, 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾을 수 없는 상황이 발생할 수 있다.
  • 기존 DB에 이렇게 있고, "hello2"를 "hello3"으로 변경해보자. "hello2"가 "hello3"으로 변경된다고 생각할 수 있지만 실제로 쿼리를 살펴보면 전부 삭제하고, 전부 추가하는 과정을 거친다.
delete from space_images where space_id=1;
insert into space_images (space_id,image_paths) values (1,'hello');
insert into space_images (space_id,image_paths) values (1,'hello3');

2. 컬럼 제약

  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어 기본 키를 구성한다. 따라서 기본 키 제약조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 존재한다.

3. 명시적 조인 조건 설정 불가

  • Querydsl을 사용할 때 값 컬렉션 테이블에 대해서 QClass가 자동으로 생성되지 않는다.
  • 조인 조건을 세부적으로 설정할 수 없다.

4. 기본 fetch 전략: Lazy

  • 값 타입 컬렉션도 일대다 관계로 볼 수 있고, N+1문제가 발생할 수 있다. fetch join을 할 수 없는데 어떻게 해결할 수 있을까?

1) @BatchSize

  • BatchSize는 JPA가 컬렉션이나 연관된 엔티티를 지연 로딩할 때 한 번에 가져올 엔티티 수를 지정한다.
@BatchSize(size = 10)
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "space_images", joinColumns = @JoinColumn(name = "space_id"))
@Column
private List<String> imagePaths = new ArrayList<>();
select 
	ip1_0.space_id,ip1_0.image_paths 
from 
	space_images ip1_0 
where ip1_0.space_id in (1,2,3,4,5,6,7,8,9,10);
  • BatchSize를 너무 작게 잡으면 (N/batch_size) + 1문제가 발생한다고 볼 수 있으며, 크게 잡으면 메모리 사용량이 급격히 많아지는 단점이 있다. 실제로 테스트해보며 적절한 값을 설정해야 한다.

2) @Fetch (SUBSELECT)

  • 엔티티를 가져올 때 관련된 모든 값 타입 컬렉션을 한 번의 서브쿼리로 가져올 수 있다.
@Fetch(FetchMode.SUBSELECT)
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "space_images", joinColumns = @JoinColumn(name = "space_id"))
@Column
private List<String> imagePaths = new ArrayList<>();
select 
	ip1_0.space_id,ip1_0.image_paths 
from 
	space_images ip1_0 
where 
	ip1_0.space_id in (select s1_0.id from spaces s1_0);

3) @EntityGraph

  • 실행해야 할 쿼리에 명시적으로 함께 로딩할 연관 엔티티나 컬렉션의 경로를 지정할 수 있다.
@EntityGraph(attributePaths = {"imagePaths"})
Page<Space> findAll(Pageable pageable);
select
	s1_0.id,s1_0.closing_time ...
from 
	spaces s1_0 
left join 
	space_images ip1_0 on s1_0.id=ip1_0.space_id;
  • 연관된 컬렉션을 join해서 가져오는 것을 확인할 수 있다.

값 컬렉션이 아닌 엔티티를 만들어 일대다 관계로 설정하고, 영속성 전이(cascade) + 고아 객체 제거(orphan remove) 기능을 적용하면 위의 문제를 해결할 수 있다.

값 타입 컬렉션 사용 방법

1. List

@Entity
@Table(name = "questions")
@NoArgsConstructor
@Data
public class Question {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String text;

  @ElementCollection(fetch = FetchType.EAGER)
  @CollectionTable(
    name = "question_choice",
    joinColumns = @JoinColumn(name = "question_id")
  )
  @OrderColumn(name = "idx")
  @Column(name = "test")
  private List<String> choices;

  public Question(String text, List<String> choices) {
    this.text = text;
    this.choices = choices;
  }
}
  • 간단히 쿼리를 확인해보자.
@Test
@Rollback(value = false)
void saveQuestion() {

  Question question = new Question("내용 1", List.of("보기 1", "보기 2", "보기 3"));
  questionRepository.save(question);
}
// insert into questions (text) values ('내용 1');
// insert into question_choice (question_id,idx,test) values (1,0,'보기 1');
// insert into question_choice (question_id,idx,test) values (1,1,'보기 2');
// insert into question_choice (question_id,idx,test) values (1,2,'보기 3');

@Test
@Transactional
void findQuestionById() {

  Question question = questionRepository.findById(1L).get();

  for (var choice : question.getChoices()) {
    System.out.println("choice = " + choice);
  }
}
// Lazy
// select q1_0.id,q1_0.text from questions q1_0 where q1_0.id=1;
// select c1_0.question_id,c1_0.idx,c1_0.test from question_choice c1_0 where c1_0.question_id=1;

// Eager
// select q1_0.id,c1_0.question_id,c1_0.idx,c1_0.test,q1_0.text from questions q1_0 left join question_choice c1_0 on q1_0.id=c1_0.question_id where q1_0.id=1;

@Test
@Rollback(value = false)
@Transactional
void addAndRemoveChoice() {

  Question question = questionRepository.findById(1L).get();

  question.getChoices().add("보기 4");
  question.getChoices().remove("보기 1");
}
// add 하고, remove을 별도로 실행했을 경우
// insert into question_choice (question_id,idx,test) values (1,3,'보기 4');
// delete from question_choice where question_id=1 and idx=3;
// update question_choice set test='보기 4' where question_id=1 and idx=2;
// update question_choice set test='보기 3' where question_id=1 and idx=1;
// update question_choice set test='보기 2' where question_id=1 and idx=0;

// add와 remove를 동시에 실행했을 경우
// update question_choice set test='보기 4' where question_id=1 and idx=2;
// update question_choice set test='보기 3' where question_id=1 and idx=1;
// update question_choice set test='보기 2' where question_id=1 and idx=0;

2. Set

@Entity
@Table(name = "roles")
@NoArgsConstructor
@Getter
@Setter
public class Role {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;

  @ElementCollection(fetch = FetchType.EAGER)
  @CollectionTable(name = "role_perm", joinColumns = @JoinColumn(name = "role_id")) // 롤에 있는 id 컬럼을 참조, 조인할 때 사용할 컬럼 지정
  @Column(name = "perm") // 실제 값을 가지고 있는 컬럼
  private Set<String> permissions = new HashSet<>();

  public Role(String name, Set<String> permissions) {
    this.name = name;
    this.permissions = permissions;
  }
}
  @Test
  @Rollback(value = false)
  void saveRole() {
    Role role = new Role("관리자", Set.of("권한 1", "권한 2", "권한 3"));

    roleRepository.save(role);

    //    insert into roles (name,id) values ('관리자',default);
    //    insert into role_perm (role_id,perm) values (1,'권한 3');
    //    insert into role_perm (role_id,perm) values (1,'권한 2');
    //    insert into role_perm (role_id,perm) values (1,'권한 1');
  }

  @Test
  @Transactional
  void findRoleById() {

    Role role = roleRepository.findById(1L).get();

    for (var perm : role.getPermissions()) {
      System.out.println("perm = " + perm);
    }
  }
  // JPA는 기본적으로 @ManyToOne과 @OneToOne에서는 즉시 로딩, @OneToMany과 @ManyToMany에서는 지연로딩 방식으로 동작한다.
  // 값 컬렉션도 @OneToMany로 볼 수 있으므로 기본 전략은 지연 로딩이다.
  // select r1_0.id,r1_0.name from roles r1_0 where r1_0.id=1;
  // select p1_0.role_id,p1_0.perm from role_perm p1_0 where p1_0.role_id=1;

  // @ElementCollection(fetch = FetchType.EAGER)
  // Eager 방식: select r1_0.id,r1_0.name,p1_0.role_id,p1_0.perm from roles r1_0 left join role_perm
  // p1_0 on r1_0.id=p1_0.role_id where r1_0.id=1;

  @Test
  @Rollback(value = false)
  @Transactional
  void addAndRemovePermission() {

    Role role = roleRepository.findById(1L).get();

    role.getPermissions().add("권한 4");
    role.getPermissions().remove("권한 1");
  }
  // delete from role_perm where role_id=1 and perm='권한 1';
  // insert into role_perm (role_id,perm) values (1,'권한 4');

3. Map

@Entity
@Table(name = "docs")
@NoArgsConstructor
@Data
public class Document {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String title;
  private String content;

  @ElementCollection
  @CollectionTable(
    name = "doc_prop",
    joinColumns = @JoinColumn(name = "doc_id"))
  @MapKeyColumn(name = "name")
  @Column(name = "value")
  private Map<String, String> props = new HashMap<>();

  public Document(String title, String content, Map<String, String> props) {
    this.title = title;
    this.content = content;
    this.props = props;
  }
}
@Test
@Rollback(value = false)
public void saveDoc() {

  Map<String, String> props = new HashMap<>();
  props.put("props1", "value1");
  props.put("props2", "value2");
  Document doc = new Document("제목 1", "내용 1", props);
  documentRepository.save(doc);
}
// insert into docs (content,title) values ('내용 1','제목 1');
// insert into doc_prop (doc_id,name,value) values (1,'props1','value1');
// insert into doc_prop (doc_id,name,value) values (1,'props2','value2');

@Test
@Transactional
void findQuestionById() {

  Document document = documentRepository.findById(1L).get();

  for (var prop : document.getProps().entrySet()) {
    System.out.println(prop.getKey() + ": " + prop.getValue());
  }
}
// select d1_0.id,d1_0.content,d1_0.title from docs d1_0 where d1_0.id=1;
// select p1_0.doc_id,p1_0.name,p1_0.value from doc_prop p1_0 where p1_0.doc_id=1;

@Test
@Rollback(value = false)
@Transactional
void addAndRemoveProp() {

  Document document = documentRepository.findById(1L).get();

  document.getProps().put("props3", "value3");
  document.getProps().remove("props1");
}
// fetch
// select d1_0.id,d1_0.content,d1_0.title from docs d1_0 where d1_0.id=1;
// select p1_0.doc_id,p1_0.name,p1_0.value from doc_prop p1_0 where p1_0.doc_id=1;
// delete from doc_prop where doc_id=1 and name='props1';
// insert into doc_prop (doc_id,name,value) values (1,'props3','value3');

// eager
// select d1_0.id,d1_0.content,p1_0.doc_id,p1_0.name,p1_0.value,d1_0.title from docs d1_0 left join doc_prop p1_0 on d1_0.id=p1_0.doc_id where d1_0.id=1;
// delete from doc_prop where doc_id=1 and name='props1';
// insert into doc_prop (doc_id,name,value) values (1,'props3','value3');
  • 패치 전략에 따라 연관된 엔티티를 가져오는 방법이 결졍되며, 데이터가 변경되거나 수정되면 해당 엔티티를 변경하거나 삭제하고 새로 삽입하는 방식으로 동작한다.

잘못 사용한 사례

1. 공간 세부 타입을 값 컬렉션으로

  • 초기 JPA에 익숙하지 않을 때, 공간 세부 타입을 값 컬렉션으로 처리했다. 다른 테이블과 조인할 필요가 없어 단순해지지만, 이는 크게 두 가지 문제가 발생한다.

1) 데이터 중복 문제

  • 같은 타입의 공간도 각자 타입을 저장하고 있으므로 중복된 데이터가 저장된다.

2) 변경에 취약

  • 세부 타입이 수정된다면 모든 공간에 대해 업데이트 쿼리가 실행되어야 한다. 만약 Enumerated로 Enum값을 사용한다면 세부 타입 이름을 변경했을 때 프로덕션 코드를 직접 수정하고, 배포해야 한다.

2. 공간과 옵션을 값 컬렉션으로

  • 가장 큰 문제는 중복과 수정 문제이다. 공간마다 옵션을 별도로 저장하고 있으면 데이터 중복이 크다. 또한, 옵션의 이름이 수정된 경우 해당 옵션을 가지고 있는 모든 공간에 대해 업데이트 쿼리가 발생한다. 특정 옵션을 식별할 수 없으므로, 모든 공간에 대해 옵션을 지우고 새로 만드는 불필요한 쿼리가 발생할 가능성이 있다.

값 컬렉션을 사용하기 좋은 상황

1. 저장할 데이터가 적다.

  • 매핑된 테이블이 적어 기본 키를 식별하지 못했을 때(변경이지만 모든 데이터 삭제 및 추가 가능성 존재)도 오버헤드가 적어야 한다.

2. 자주 변경되지 않아야 한다.

  • 값 컬렉션은 식별자가 없기 때문에 변경이 일어나면 모두 다 삭제하고, 모두 다 저장하는 비효율적으로 동작할 수 있기 때문에 변경이 자주 일어나면 안된다.

3. 값이 고유성을 가져야 한다.

  • 사용자들이 공통된 정보를 저장한다면 엔티티로 만들어, 데이터 중복을 없애는 편이 좋다.
  • 예를 들어, 사용자 주소를 여러 개 저장하거나 사용자가 새로운 위치에 로그인했을 경우 해당 IP를 추적하는 로직을 작성할 때 값 컬렉션을 사용할 수 있다고 생각한다.

자료 구조가 아주 간단하고, 자주 쓰이지 않으면 사용할 수 있지만 대부분의 경우 값 컬렉션 보단 별도의 엔티티를 만들어 사용하는 게 좋다고 생각한다.

참고자료

  • 자바 ORM 표준 JPA 프로그래밍
  • 최범균 JPA 기초
profile
꾸준하게

0개의 댓글