0. 개요
- 이번 포스팅에서는 값 타입을 사용한 collection에 대해서 알아보려 한다.
- 결론부터 말하면 값 타입 collection은 사용하지 않는다.
- 값 타입 collection이 무엇인지, 왜 사용하지 않는지에 대해서 알아보자.
1. 값 타입과 collection 사용법
- 이전 포스팅에서 일대다(1:N) 관계를 표현할 때, Entity를 collection에 담아서 사용해본 적이 있다.
- 값 타입 또한 collection에 담아서 사용할 수 있는데, 그 방법에 대해서 알아보자.
a) 예시 케이스
- 예를 들어, 위의 사진과 같은 축구 선수 Entity를 구성하려고 한다.
→ Player는 Entity이다.
→ Game은 출전한 경기를 기록하는 값 타입이다.
→ preferPos는 선수가 선호하는 포지션을 기록하는 일반적인 collection 객체다.
→ gameHistory는 값 타입을 사용한 collection 객체다.
b) DB 구현
- 위의 collection을 DB로 구현한다고 생각해보자.
- RDB는 collection 데이터를 저장할 수 없다.
- 그러므로 각 collection을 추가적인 테이블로 구성해야 한다.
- 다음 ERD를 살펴보자.
- 위의 collection을 DB 테이블로 변환해보면 다음과 같다.
- collection을 테이블로 구성할 때, 슈퍼 키를 기본 키로써 사용한다.
→ 기본 키를 슈퍼 키로 사용하는 이유는 값 타입을 Entity와 구분하기 위함이다.
→ player_id를 PK, FK로 사용하면 Entity와의 구조적 차이점이 없다.
c) 코드 구현 - Game 값 타입
- 이제 위에서 설명한 예시를 직접 코드로 구현해보자.
@Embeddable
public class Game {
private String opponent;
private LocalDate matchDay;
private boolean participation;
public Game() {}
public Game(String opponent, LocalDate matchDay, boolean participation) {
this.opponent = opponent;
this.matchDay = matchDay;
this.participation = participation;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Game game = (Game) o;
return participation == game.participation &&
Objects.equals(opponent, game.opponent) &&
Objects.equals(matchDay, game.matchDay);
}
@Override
public int hashCode() {return Objects.hash(opponent, matchDay, participation);}
// Getter & Setter 생략
}
d) 코드 구현 - Player Entity
@Entity
public class Player {
@Id @GeneratedValue
@Column(name = "PLAYER_ID")
private Long id;
@Column(name = "PLAYER_NAME")
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
@Embedded
private Game game;
// @ElementCollection - 컬렉션이라는 것을 명시
// @CollectionTable - 컬렉션을 테이블로 선언, 테이블 이름 설정, FK 설정
@ElementCollection
@CollectionTable(name = "PREFER_POS", joinColumns = @JoinColumn(name = "PLAYER_ID"))
@Column(name = "PREFER_POS")
private Set<String> preferPos = new HashSet<>();
@ElementCollection
@CollectionTable(name = "GAME_HISTORY", joinColumns = @JoinColumn(name = "PLAYER_ID"))
private List<Game> gameHistory = new ArrayList<>();
// Getter & Setter 생략
}
e) 실행 결과
- 위에서 작성한 코드를 실행해보면 다음과 같은 쿼리문이 발생한다.
- DB는 다음과 같은 결과를 만들어낸다.
2. 값 타입과 컬렉션 설명
a) 값 타입과 collection은 언제 함께 사용할까?
- 값 타입 객체를 하나 이상 저장해야 할 때 사용한다.
b) @ElementCollection
- 해당 필드가 collection 객체라는 것을 JPA에게 알려주는 어노테이션이다.
c) @CollectionTable
- collection 필드를 하나의 테이블로 구성한다는 것을 명시하는 어노테이션이다.
- name 옵션을 사용하여 테이블 명을 설정한다.
- joinColumns 옵션을 사용하여 조인을 수행할 PK를 설정한다.
→ joinColumns 옵션의 값을 설정할 때 @JoinColumns 어노테이션을 사용하여 PK 칼럼명을 명시한다.
3. 활용 예제 - 저장
a) 예시 코드
- 위에서 작성한 코드를 이용하여 실제로 데이터를 저장해보자.
- 다음과 같이 코드를 작성한다.
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
// 트랜잭션 생성
EntityTransaction tx = em.getTransaction();
// 트랜잭션 시작
tx.begin();
try {
Player player = new Player();
player.setName("JI SUNG PARK");
player.setGame(new Game("RealMadrid", LocalDate.now(), true));
// Set 컬렉션에 데이터 삽입
player.getPreferPos().add("CDM");
player.getPreferPos().add("LM");
player.getPreferPos().add("RM");
player.getPreferPos().add("RW");
player.getPreferPos().add("LW");
// List 컬렉션에 데이터 삽입
player.getGameHistory().add(new Game("Chelsea",
LocalDate.of(2022, 01, 01), false));
player.getGameHistory().add(new Game("AC_Millan",
LocalDate.of(2021, 10, 10), true));
player.getGameHistory().add(new Game("TottenHam",
LocalDate.of(2019, 03, 03), true));
em.persist(player);
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
}
b) 실행 결과
- 위의 코드를 실행하면 다음과 같은 결과를 DB에서 확인할 수 있다.
c) 결과 분석
- 위의 코드에서 player 객체 하나만을 persist() 한 것을 확인할 수 있다.
- 이를 강조하는 이유는 collection 객체들의 라이프 사이클이 player 객체와 묶여있다는 것을 알 수 있기 때문이다.
- 이전 포스팅에서 값 타입은 Entity 객체의 라이프 사이클과 함께하게 동작한다고 언급하였다.
- 이 개념은 값 타입을 사용하는 collection에도 동일하게 적용된다는 것을 확인할 수 있다.
- 위의 실행 결과로 깨달을 수 있는 것이 몇 가지 있다.
1. 값 타입을 사용하는 collection은 영속성 전이(Cascade)를 사용한다.
→ player 객체만을 persist() 하였는데 독립적으로 존재하는 collection 테이블에도 적용된다.
2. 값 타입을 사용하는 collection은 고아 객체를 제거하는 기능을 가진다.
→ 값 타입을 사용하는 collection은 Entity와 라이프 사이클을 동일하게 가져간다.
4. 활용 예제 - 조회
a) 예시 코드
- 저장된 값 타입 collection을 조회해보자.
- 다음과 같이 간단하게 player 객체의 id 값을 조회하는 코드를 작성해보자.
- 위의 코드를 실행하면 다음과 같은 쿼리문이 발생한다.
- 신기하게도 값 타입으로 선언된 필드를 제외한 collection은 따로 호출되지 않았다.
- 그 이유는 값 타입 collection에서는 지연 로딩을 사용하기 때문이다.
b) collection은 지연 로딩을 사용한다.
- 지연 로딩을 사용하고 있기 때문에 직접 호출을 해야만 값을 불러온다.
- 그렇다면 다음 코드를 실행해보자.
- 필요한 데이터를 직접 호출하니 위의 사진과 같은 쿼리문이 발생한 것을 확인할 수 있다.
- 이처럼 collection은 지연 로딩을 사용한다.
c) 지연 로딩의 출처 - @ElementCollection
- 그렇다면 collection의 지연 로딩 기능은 어디에 명시되어 있을까?
- 이는 @ElementCollection 어노테이션에 들어있다.
- 위의 사진은 @ElementCollection의 원본 코드다.
- 내부 코드에서 확인할 수 있듯이 FetchType.LAZY가 명시되어 있다.
- 이처럼, collection은 지연 로딩을 default로 사용한다.
5. 활용 예제 - 수정
a) 값 타입 수정
- 이전 포스팅에서 배운 내용과 동일한 부분으로, 값 타입은 immutable(= 변경 불가) 해야 한다.
- 즉, 특정 필드의 Setter를 사용할 수도 접근할 수도 없도록 설계되어야 한다.
- 그러므로 다음과 같이 통째로 새로운 객체를 생성하여 수정해야 한다.
b) 컬렉션 수정
- 다음으로 collection 객체인 preferPost를 수정해보자.
- 예시로 기존에 LM이라고 저장되어있는 포지션을 LCDM으로 변경하려고 한다.
- 다음 코드를 살펴보자.
- 위의 코드처럼, LM이라는 객체 자체를 삭제한 다음 새로운 객체(LCDM)를 추가하는 방식을 사용한다.
- 왜냐면 String 자체가 값 타입이므로 이를 변경(update)할 수 있는 방법이 없기 때문이다.
- 그러므로 교체하고 싶은 객체를 삭제하고 통째로 새로운 객체를 삽입하는 방식을 사용한다.
- DB를 확인해보면 기존의 LM은 삭제되고 LCDM이 삽입된 것을 확인할 수 있다.
c) 값 타입 컬렉션 수정
- 다음으로 값 타입을 사용하는 collection 객체인 gameHistory를 수정해보자.
- 예시로 기존에 Chelsea와의 경기 기록의 상대를 Dortmund로 변경하려고 한다.
- 다음 코드를 살펴보자.
- 값 타입을 사용하는 collection 객체의 내용을 변경하기 위해서는 동일하게 해당 객체를 삭제하고 새로운 객체를 추가한다.
- 특이한 점은 삭제 시 변경하려는 객체와 동일한 내용을 가진 객체를 생성하여 전달한다는 것이다.
- 이 과정에서 JPA는 전달받은 값 타입 객체와 동일한 값을 가진 값 타입 객체를 찾는다.
- 그러므로 반드시 값 타입 내부에는 equals()와 hashCode() 메서드가 재정의 되어 있어야 한다.
- 이 과정에서 반드시 알고 넘어가야 되는 것이 한 가지 있다.
- 위의 코드를 실행했을 때 발생하는 쿼리문을 살펴보자.
- 위의 쿼리문을 보고 무엇인가 이상하다는 것을 발견하였는가?
- 쿼리문을 해석해보면 GAME_HISTORY 테이블에서 해당 PLAYER_ID를 가진 모든 데이터를 삭제한다.
- 그리고 다음 insert 쿼리가 발생한다.
- 여기서도 이상한 점은 insert 쿼리가 두 번 발생한다는 것이다.
- 분명 특정 객체만을 삭제했는데, 왜 모든 데이터를 삭제하는 것일까?
- 그리고 insert 쿼리는 왜 두 번 발생하는 것일까?
- 위의 쿼리문은 다음과 같은 과정에 의해 발생한다.
→ GAME_HISTORY 테이블에서 "Chelsea"를 가진 객체를 포함한 모든 데이터를 삭제한다. (delete 쿼리)
→ GAME_HISTORY 테이블에 "Dortmund"를 가진 객체를 저장한다. (insert 쿼리)
→ GAME_HISTORY 테이블에서 지워진 "Chelsea" 외 나머지 데이터를 저장한다. (insert 쿼리)
6. 값 타입 컬렉션의 제약 - 식별자
a) 값 타입은 식별자가 없다.
- 값 타입은 식별자가 따로 존재하지 않는다.
- 식별자가 존재하지 않으므로 다음과 같은 특징을 갖는다.
→ 식별자가 없으므로 값 타입은 변경된 과거 값에 대한 추적이 불가능하다.
- 즉, 식별자가 없으므로 데이터 삭제 시, 주인 Entity(= Player 객체)와 연관된 모든 데이터를 삭제한다.
- 그리고 값 타입 컬렉션에 저장되어있는 현재 값을 모두 다시 저장한다.
- 이와 같은 이유로 위에서 delete 쿼리 한번, insert쿼리 2번이 발생된 것이다.
7. 결론과 대안
a) 결론
- 위의 설명을 이해하면 값 타입 collection의 데이터를 삭제하는 방식이 굉장히 위험하다고 느낄 것이다.
- 값 타입 collection의 제약을 다시 정리하자면 다음과 같다.
→ 식별자가 없으므로 변경된 값의 추적이 불가능하다.
→ 식별자가 없으므로 데이터를 특정할 수 없으며, 그로 인해 데이터 삭제 시 모든 데이터를 삭제한다.
- 이처럼 복잡한 방식과 리스크를 안고 값 타입을 collection으로 사용할 이유가 없다.
- 다시 말하자면, Entity와 유사한 기능을 필요로 함에도 값 타입 collection을 사용해야 할 장점이 없다.
- 그러므로 값 타입은 값 타입으로써의 역할이 확실할 때에만 사용하는 것을 권장하며,
- 그 외의 경우, 값 타입 collection은 사용하지 않는 것을 권장한다.
b) 대안
- 값 타입 collection을 사용하지 않는다면 무엇을 이를 대체해야 할까?
- Entity로 정의하여 일대다(1:N) 관계를 사용하면 된다.
'Back-end > JPA 개념' 카테고리의 다른 글
23. JPQL - 기본 기능(파라미터 바인딩, 결과 조회, 쿼리의 종류) (0) | 2022.04.21 |
---|---|
22. JPQL - JPQL에 대하여 (0) | 2022.04.20 |
20. 값 타입(3) - 객체 비교와 equals() 재정의 (0) | 2022.04.18 |
19. 값 타입(2) - 문제점과 불변 객체 (0) | 2022.04.15 |
18. 값 타입(1) - 개념 (0) | 2022.04.14 |
댓글