본문 바로가기
Back-end/JPA 개념

21. 값 타입(4) - 값 타입과 컬렉션

by devraphy 2022. 4. 19.

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) 관계를 사용하면 된다. 

댓글