728x90
반응형
SMALL

2024.10.31 업데이트


참고로, 이 포스팅에서 가장 중요한 부분은 거의 마지막에 나온다!

 

 

 

엔티티에 컬렉션으로 관리하는 데이터는 흔히 있을 수 있는 일이지만 DB는 기본적으로는 컬렉션 데이터를 지원하지 않는다. 물론 요즘은 여러 방법으로 컬렉션을 테이블에서 관리할 수 있지만(JSON으로 데이터를 저장한다든지 등) 그러나 정석적인 방법은 컬렉션 데이터를 테이블로 만들어 참조로 관리하는 이다. 

 

SMALL

아래 그림을 보자. 

멤버라는 엔티티가 관리하는 데이터 favoriteFoodsaddressHistory는 컬렉션 값 타입이다. 이런 엔티티를 테이블화 하기 위해서는 각 컬렉션 값 타입을 테이블로 분리해서 1:N 관계로 만드는 것이다. 이게 정석적인 방법이다. 

 

위 그림을 보면 ADDRESSFAVORITE_FOOD도 모든 필드가 하나의 PK인데 이 이유는 컬렉션 '값 타입'이기 때문이다. 값 타입은 하나의 레코드 자체가 고유값이 되어야 하는 것이다. 만약, 여기서 어떤 구분할 수 있는 PK가 따로 있으면 그건 값 타입이 아니라 엔티티라고 불려야한다. 구현하는 방법도 간단하다. 코드를 보자.

 

Address

package cwchoiit.embedded;

import javax.persistence.Embeddable;
import java.util.Objects;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipCode, address.zipCode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipCode);
    }
}

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
            @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
            @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
    })
    private Address officeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FAV_FOOD")
    private Set<String> favoriteFoods = new HashSet<>();

    @ElementCollection
    @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<Address> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<Address> addressHistory) {
        this.addressHistory = addressHistory;
    }
}
  • 여기서 주의깊게 볼 부분은 바로 이 부분이다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FAV_FOOD")
private Set<String> favoriteFoods = new HashSet<>();

@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
  • 멤버 엔티티가 관리하는 컬렉션 두 개가 있다. favoriteFoods, addressHistory.
  • 이런 컬렉션 형태의 필드를 테이블화하기 위해 두 가지의 어노테이션이 필요하다. @ElementCollection, @CollectionTable. @CollectionTable 어노테이션에 name property는 테이블명을 의미한다. joinColumns는 이 테이블이 어떤 테이블과 조인될지를 선정한다. 즉, 외래키를 받는 부분이고 멤버에 속한 favoriteFoods이고 addressHistory니까 MEMBER_ID라는 외래키를 적용한다. 뭐 M_ID, MID라고 해도 상관은 없다.
  • favoriteFoods같은 경우엔 컬렉션에 들어있는 값이 String으로 된 문자열 단일 값이기 때문에 컬럼명을 지정해주기 위해 @Column 어노테이션도 사용했다. Address{city, street, zipCode} 같은 경우는 세 개의 필드가 한 객체로 만들어져 있으니 이런게 불가능하다. 사용하는게 필수는 아닌데 이런 경우에 이렇게 컬럼명도 지정할 수 있단 사실! 이렇게 두 개의 어노테이션만 있으면 자동으로 컬렉션 값 타입은 테이블로써 만들어지게 된다.

 

실행해보자.

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 코드를 보면 멤버 객체를 하나 생성하고 멤버 객체의 favoriteFoodsaddressHistory를 가져와 추가했다. 저 두 개의 엔티티는 멤버라는 부모 엔티티에 의해 생명주기가 관리된다. 왜냐? 값 타입이기 때문이다. 즉, 멤버 객체가 생성되고 소멸되는 주기가 곧 저 두 엔티티의 생명주기이고 멤버에 의해 관리 되기 때문에 영속 컨텍스트에는 멤버만을 추가해도 알아서 새로이 추가된 favoriteFoodsaddressHistoryINSERT문으로 나간다.

결과를 보자.

Hibernate: 
    insert 
    into
        Member
        (city, street, zipcode, name, MEMBER_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        ADDRESS_HISTORY
        (MEMBER_ID, city, street, zipcode) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)

  • Member, AddressHistory, FavoriteFood 모두 INSERT문이 실행됐음을 확인할 수 있다. 컬렉션 타입은 이렇게 테이블화해서 관계 매핑으로 다루어야 한다.

그리고 이렇게 잘 들어갔다면, 한번 멤버를 조회해 보자. 어떻게 나올까?

값 타입 컬렉션 조회하기

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

  • 실행 결과를 보면, 멤버 정보만 가져왔지 favoriteFoods, addressHistory 정보는 가져오지 않았다. 즉, 지연 로딩이라는 얘기다. 이렇게 값 타입 컬렉션은 기본이 지연로딩이다. 아래 사진을 보면 알 수 있다.

 

그래서, 꺼내와서 메서드를 호출해야 실제로 쿼리가 나가서 값을 가져온다.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            List<Address> addressHistory = findMember.getAddressHistory();
            for (Address address : addressHistory) {
                System.out.println("address = " + address.getCity());
            }

            Set<String> favoriteFoods = findMember.getFavoriteFoods();
            for (String favoriteFood : favoriteFoods) {
                System.out.println("favoriteFood = " + favoriteFood);
            }
            
            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

 

 

근데 만약 데이터를 수정해야할 때는 어떻게 동작할까? 아래 수정 코드를 보자.

값 타입 컬렉션 수정하기

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");
            EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());

            List<Address> addressHistory = findMember.getAddressHistory();
            for (Address address : addressHistory) {
                System.out.println("address = " + address.getCity());
            }

            Set<String> favoriteFoods = findMember.getFavoriteFoods();
            for (String favoriteFood : favoriteFoods) {
                System.out.println("favoriteFood = " + favoriteFood);
            }

            // 컬렉션 값 타입의 내용을 수정하는 방법
            favoriteFoods.remove("치킨");
            favoriteFoods.add("소고기");

            // 컬렉션 값 타입의 내용을 수정하는 방법 (remove 하는건 똑같다, 근데 여기는 레퍼런스를 받기 때문에 반드시 equals 를 구현해 놓은 상태여야 한다)
            addressHistory.remove(new Address("city1", "street1", "zipCode1"));
            addressHistory.add(new Address("city3", "street3", "zipCode3"));

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 새 멤버를 만들 때 addressHistory, favoriteFoods를 추가한 후 멤버만을 persist()해도 flush()를 호출하면 다음과 같이 데이터베이스에 Member, addressHistory, favoriteFoods가 모두 추가된다. 그 이유는 값 타입은 어떤것이든 엔티티의 생명주기에 종속적이기 때문이다. 
  • 이 말은, 멤버의 favoriteFoods를 가져와 요소를 날리면? favoriteFoods의 해당 레코드는 삭제가 된다. 반대로 멤버의 favoriteFoods를 가져와 요소를 추가하면? favoriteFoods에 추가한 요소가 레코드로 저장된다. 그리고 favoriteFoods는 컬렉션에 들어가는 타입이 String이라서 remove() 호출 시에 동등 비교를 위해 따로 해줄 일이 없다. 기본으로 equals()가 잘 구현되어 있기 때문이다.
  • 그러나, addressHistory 같은 경우, 컬렉션에 들어가는 타입이 Address라는 우리가 직접 만든 객체이므로, 리스트에서 이 요소를 제거하려면 equals()가 반드시 구현되어 있어야 한다. 그래야 remove() 호출 시 내부에서 equals()를 호출해서 동일한 요소를 찾아내 지울 수 있기 때문이다.
  • 그래서 위에 Address 코드를 보면, equals() hashCode()를 구현한 모습을 볼 수 있다.

 

그럼 실행해보자. 어떤 쿼리가 나갈까? 

  • 아래 파란 박스는 favoriteFoods 관련 쿼리이고, 위에 빨간 박스는 addressHistory 관련 쿼리이다.
  • favoriteFoods는 보다시피, 원하는 값을 하나 삭제하고 원하는 값을 하나 추가했다. 아주 마음에 든다.
  • 그러나 문제는 addressHistory이다. 얘를 보면 지금, 세개의 쿼리가 나갔는데 처음 쿼리가 전체를 삭제하는 쿼리다! 그리고 원래 있던 레코드를 하나 추가하고, 새로 추가하려고 했던 레코드를 추가한 것이다. 
  • 전체를 삭제해버린 후 기존에 남은 하나와 새로 추가한 하나를 나란히 INSERT한다. 이것이 값 타입 컬렉션을 테이블로 관리할 때 가장 큰 문제가 된다. 값 타입 컬렉션 테이블은 식별자가 따로 없기 때문에 JPA가 삭제할 때 어떤 데이터를 가져와 삭제해야 하는지 알지 못한다. 그렇기 때문에 전체를 삭제한 후 종속된 엔티티가 가지고 있는 모든 데이터를 다시 하나씩 추가한다. 
  • 근데 Set은 하나만 딱 잘 골라서 삭제하고 원하는거 추가하는데 List는 왜 안돼요? → SetList의 차이 그대로이기 때문이다. Set은? 중복값이란게 존재할 수가 없는 자료구조이다. 그렇기 때문에 내가 원하는 것을 삭제한다고 하면 JPA도 그 값을 그대로 찾아서 삭제하면 된다. List는? 중복값이란게 존재할 수 있는 자료구조다. 그 말은 내가 삭제하고자 하는 데이터가 한개뿐이 아니라 두개 세개가 있을 수도 있는데 이걸 JPA는 알지 못하기 때문에 다 날리고 새로 넣을 것들을 다시 넣게 되는 것이다.

🟠 결론은, 이 값 타입 컬렉션을 쓰면 안된다. 🟠

 

값 타입 컬렉션을 엔티티로 변경하라

값 타입 컬렉션의 가장 큰 문제는 식별자가 없기에 수정 쿼리를 수행할 때 JPA가 찾아내지 못한다는 것이다. 그리고 반대로 말하면 식별자가 있는 테이블은 엔티티라고 표현해야 한다. 그럼 위 코드의 문제점을 어떻게 수정하면 될까?

 

AddressEntity라는 엔티티를 만들고 기존의 값 타입 컬렉션을 엔티티로 승격시켜 식별자를 가지게 하는것이다.

 

AddressEntity

package cwchoiit.embedded;

import javax.persistence.*;

@Entity
public class AddressEntity {
    @Id
    @GeneratedValue
    private Long id;

    private Address address;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private EmbedMember member;

    public AddressEntity() {
    }

    public AddressEntity(Address address) {
        this.address = address;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    public EmbedMember getMember() {
        return member;
    }

    public void setMember(EmbedMember member) {
        this.member = member;
    }
}

 

 

package cwchoiit.embedded;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
            @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
            @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
    })
    private Address officeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FAV_FOOD")
    private Set<String> favoriteFoods = new HashSet<>();

    @OneToMany(mappedBy = "member")
    private List<AddressEntity> addressHistory = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<AddressEntity> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<AddressEntity> addressHistory) {
        this.addressHistory = addressHistory;
    }
}
  • 여기서는 다대일 양방향으로 매핑했다. 여기서 누군가는 "그냥 Member가 외래키 관리(일대다 방식)하고 CRUD 편리하게 리스트로 하면 안되나요? 일대다로 하면 안되나요?" 그렇게 해도 된다! 이 경우에는 AddressEntity가 멤버에 종속된 느낌이 강하게 들기 때문에 그렇게 만들어도 될 것 같다.
  • 그러나, 그렇게 만들면 이제 알아두어야 할 점은 결국 데이터베이스 테이블 관점에서는 Member에 외래키가 있는게 아니고 AddressEntity에 외래키가 있다는 점은 인지해야 하고, 그렇기 때문에, MemberAddressHistory를 가져와서 값을 추가하고 빼고 해도 나가는 쿼리는 AddressEntity의 업데이트 쿼리라는 것을 인지하고 있으면 된다.

 

이제 실행해 보자. 실행 코드는 다음과 같다.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.AddressEntity;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            EmbedMember member = new EmbedMember();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city", "street", "zipcode"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

            AddressEntity addressEntity = new AddressEntity();
            addressEntity.setAddress(new Address("city1", "street1", "zipCode1"));
            addressEntity.setMember(member);

            AddressEntity addressEntity2 = new AddressEntity();
            addressEntity2.setAddress(new Address("city2", "street2", "zipCode2"));
            addressEntity2.setMember(member);

            entityManager.persist(addressEntity);
            entityManager.persist(addressEntity2);
            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("================================================================");

            // 값 타입 컬렉션을 엔티티로 승격시키고, 수정하는 방법
            AddressEntity ae = entityManager.find(AddressEntity.class, 1L);
            ae.setAddress(new Address("newCity", "newStreet", "newZipCode"));

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

실행 결과

  • 더 이상 전체 Address 정보를 삭제하지 않고, 삭제하고 추가하고자 하는 하나의 AddressEntity 레코드만을 업데이트하는 모습을 확인할 수 있다. 

 

결론 

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 즉, 식별자가 필요하지 않고 엔티티에 종속된 생명 주기를 가져야 하는 경우에만 사용해야 하고 식별자가 필요하고 본인만의 생명 주기가 필요한 경우 엔티티로 승격시키자! 

 

그리고 사실 실무에서는 값 타입이 그렇게 많이 사용되지도 않는다. Position{x, y} 이렇게 x좌표, y좌표를 한번에 묶어서 가지는 값같은 정말 값 타입이면 되는 그런 경우 말고는 많이 사용되지 않는다.

728x90
반응형
LIST
728x90
반응형
SMALL

2024.10.30 업데이트


임베디드 값 타입은 꽤나 사용성을 높여준다. 임베디드 값 타입은 무엇이냐면 특정 엔티티에 필요한 필드를 클래스타입으로 받는 경우이다.

아래 예를 보자.

좌측 테이블은 기본타입으로만 설정된 테이블이다. 물론 이게 잘못된 건 아니다. 근데 기본 타입이 아니고 클래스로 설계된 타입을 사용할 때 얻는 이점이 매우 많기 때문에 임베디드 값 타입을 사용하는 것을 고려해볼 수 있다. 그래서 위와 같이 만들면 아래 사진 처럼 된다. 

객체 입장에서는, Period, Address 라는 클래스가 Member 엔티티에 들어있게 되지만, DB에서는 이전과 다를 것 없이 그냥 각 필드들이 컬럼으로 만들어진다. 그럼 어차피 DB 테이블에선 이전과 다를 것 없이 동일하게 필드로 표시되는데  임베디드 값 타입을 사용시 어떤 이점이 있을까?

  • 재사용성
  • 높은 응집도
  • 응집도가 높다는 것은, 의미 있는 메서드를 만들어 사용할 수 있고 그에 따라 객체 지향형 설계가 가능해짐 예를 들어, Period.isWork()와 같은 메서드를 만들어서 해당 객체에서만 사용되는 메서드를 Period 클래스에서 구현 가능
  • 임베디드 값 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함

 

그럼 임베디드 값 타입을 사용하는 방법을 알아보자. 우선 임베디드 값 타입을 사용하는 방법은 다음과 같다.

  • @Embeddable → 값 타입을 정의하는 곳에 표시
  • @Embedded → 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수 (값 타입을 정의하는 클래스에서)
SMALL

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.time.LocalDateTime;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    private LocalDateTime startDate;
    private LocalDateTime endDate;

    private String city;
    private String street;
    private String zipCode;
}
  • 이렇게 있는 엔티티에서 하고 싶은 건 (startDate, endDate) 이 두 필드를 하나의 클래스로 관리하고, (city, street, zipCode) 이 세 필드를 하나의 클래스로 관리하고 싶은 것이다.

 

우선, 그래서 Period 클래스를 하나 만들어보자.

Period

package cwchoiit.embedded;

import javax.persistence.Embeddable;
import java.time.LocalDateTime;

@Embeddable
public class Period {
    
    private final LocalDateTime startDate;
    private final LocalDateTime endDate;

    public Period() {
        this.startDate = LocalDateTime.now();
        this.endDate = LocalDateTime.now();
    }

    public Period(LocalDateTime startDate, LocalDateTime endDate) {
        this.startDate = startDate;
        this.endDate = endDate;
    }

    public boolean isWork() {
        return !endDate.isAfter(LocalDateTime.now());
    }

    public LocalDateTime getStartDate() {
        return startDate;
    }

    public LocalDateTime getEndDate() {
        return endDate;
    }
}

 

Address

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }
}
  • 이렇게 두 개의 클래스가 있고 이 두개의 클래스에 어노테이션으로 @Embeddable을 사용해준다. 그니까 직관적으로 이 클래스는 다른 클래스에 임베디드가 될 수 있다는 의미다.
  • 그리고 이렇게 따로 클래스로 뽑아버리니 좋은점은 각 필드간 응집도가 높아져서 이 클래스에서 유용하게 사용할 수 있는 메서드를 만들수도 있고 만든 메서드는 재활용 할수도 있어진다는 점이다. (예: Period.isWork())
  • 그리고 위에서 말한 기본 생성자 필수라는 게 이 @Embeddable 애노테이션이 붙은 클래스들에게 적용되는 말이다.
  • 그리고! 가장 중요한 것은, 이 두 임베디드 값 타입은 모두 불변 객체라는 것이다. 왜 불변객체로 만드는 걸까? 이후에 설명하겠다.

 

EmbedMember

package cwchoiit.embedded;

import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }
}
  • 위처럼 Period, Address 클래스를 타입으로 설정한 필드에 어노테이션으로 @Embedded를 붙여준다. 즉 이것도 마찬가지로 이 필드는 임베디드 되었다는 의미다.
  • 이렇게 멤버 클래스를 만들면 DB에 생성되는 테이블은 임베디드 값 타입을 사용하나 사용하지 않나 완전히 똑같다. 즉, 테이블에는 그냥 아래 모습 그대로 들어간다.

그럼 왜 사용할까? 다시 말하지만 객체 지향적으로 코드를 작성하기에 훨씬 유리하기 때문이다. 사용성도 높아지고. 각 클래스가 가지고 있는 메서드를 사용하는 이점이 생각보다 엄청 크다.

 

 

그래서 이렇게 임베디드 값 타입을 사용한 엔티티를 사용하는 방법은 뭐 별거 없다.

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;
import cwchoiit.embedded.Period;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.time.LocalDateTime;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(new Address("city", "street", "zipCode"));
            embedMember.setWorkPeriod(new Period(LocalDateTime.now(), LocalDateTime.now()));

            entityManager.persist(embedMember);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 이전과 동일하게 사용하면 된다.

 

 

 

임베디드 값 타입과 연관관계

 

임베디드 값 타입으로 만든 클래스는 내부에 임베디드 값 타입으로 만든 클래스도 당연히 가질 수 있다. 위 그림에서 AddressZipcode를 가지고 있는 모습을 볼 수 있다. 근데, 재밌는게, 임베디드 값 타입으로 만든 클래스가 엔티티도 가질 수 있다. 위 그림에서 PhoneNumberPhoneEntity를 가지고 있음을 볼 수 있다.

 

그러니까 아래 코드처럼 말이다.

package cwchoiit.embedded;

import cwchoiit.fetch.Team;

import javax.persistence.Embeddable;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;

@Embeddable
public class Period {
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    ...
    
}
  • Period라는 임베디드 값 타입 클래스는 아래와 같이 Team 이라는 엔티티도 가질 수 있다. (물론, 그렇게 자주 사용되는 방식은 아니다)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;

 

 

@AttributeOverrides @AttributeOverride

또 한가지는 같은 임베디드 값 타입의 클래스를 여러개 사용하고 싶을수가 있다. 예를 들면 아래 코드를 보자.

EmbedMember

package cwchoiit.embedded;

import javax.persistence.*;

@Entity
public class EmbedMember {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

    @Embedded
    private Address officeAddress;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Period getWorkPeriod() {
        return workPeriod;
    }

    public void setWorkPeriod(Period workPeriod) {
        this.workPeriod = workPeriod;
    }

    public Address getHomeAddress() {
        return homeAddress;
    }

    public void setHomeAddress(Address homeAddress) {
        this.homeAddress = homeAddress;
    }

    public Address getOfficeAddress() {
        return officeAddress;
    }

    public void setOfficeAddress(Address officeAddress) {
        this.officeAddress = officeAddress;
    }
}
  • 이 코드에서 이 부분을 자세히 보자.
@Embedded
private Address homeAddress;

@Embedded
private Address officeAddress;
  • 충분히 의미 있을 만한 필드들이다. 그런데 둘 다 같은 임베디드 값 타입이다. 이럴때 이렇게만 두고 실행하면 에러가 난다. 그래서 아래처럼 바꿔줘야 한다.
@Embedded
private Address homeAddress;

@Embedded
@AttributeOverrides({
        @AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
        @AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
        @AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
})
private Address officeAddress;
  • @AttributeOverrides@AttributeOverride를 사용해서, 이 애노테이션이 달린 임베디드 값 타입의 클래스의 어떤 필드를 어떤 컬럼명으로 매핑할건지를 지정해줘야 동일한 컬럼명을 사용하지 않게 되어 에러가 발생하지 않는다. 

 

 

임베디드 값 타입과 같은 값 타입은 왜 불변객체여야 할까?

굉장히 중요한 부분이다. 만약, 예를 들어 회원1회원2 모두 같은 Address 참조값을 가지고 있다고 가정해보자. 개발자가 그렇게 만든것이다. 최초의 의도는 두 회원이 같은 주소를 가지기 때문에 Address 객체를 하나 만들어서 두 회원에 모두 세팅한 것이다. 

 

여기까지는 그럴수 있다. 그리고 그걸 막을 방법도 없다. 근데 만약, 두 회원 중 하나를 선택해서 주소를 변경해 버리면 어떻게 될까? 다음과 같은 일이 일어난다. 

 

 

나의 의도는 회원1의 주소만 바꾸려는 의도였는데 회원2의 주소까지 변경되어 버린다. 이거는 막을 방법도 없고 이런 버그는 거의 잡는게 불가능하다. 코드로 이 내용을 구현해보자. 

 

우선 불변객체가 아니어야 하므로 Address를 불변객체에서 그렇지 않도록 변경해보자.

 

Address - 불변객체가 아님

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipCode;

    public Address() {
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }
}

 

Main

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            Address address = new Address("city", "street", "zipCode");

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(address);

            EmbedMember embedMember2 = new EmbedMember();
            embedMember2.setUsername("embed2");
            embedMember2.setHomeAddress(address);

            entityManager.persist(embedMember);
            entityManager.persist(embedMember2);

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 지금 코드를 보면, 하나의 Address 객체를 만들고 두개의 EmbedMemberHomeAddress에 적용시켰다. 이러면 두 회원은 같은 값 타입을 참조하고 있게 된다.
  • 그리고, 이렇게 코드를 작성하면 컴파일러 단계에서 이를 방지할 수 있는 방법이 있나? 없다. 자바는 그냥 HomeAddress를 세팅하기 위한 세터에 적절한 타입의 Address를 받았다고 생각하기 때문에 이 시점에 컴파일러는 아무런 문제를 인지하지 못한다. 이게 핵심이다. 그리고 실제로 지금까지는 아무런 문제도 없다.

실행 결과

 

두 회원이 모두 같은 주소를 가지고 있다. 여기까지는 아무런 문제가 없다. 그런데 내가 여기서 둘 중 하나의 주소를 변경하는 시점부터 문제가 발생한다. 저 코드에서 딱 한줄을 추가해보자.

package cwchoiit;

import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {
            Address address = new Address("city", "street", "zipCode");

            EmbedMember embedMember = new EmbedMember();
            embedMember.setUsername("embed");
            embedMember.setHomeAddress(address);

            EmbedMember embedMember2 = new EmbedMember();
            embedMember2.setUsername("embed2");
            embedMember2.setHomeAddress(address);

            entityManager.persist(embedMember);
            entityManager.persist(embedMember2);
            
            embedMember.getHomeAddress().setCity("newCity");

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 바로 이 부분이다.
embedMember.getHomeAddress().setCity("newCity");
  • 난 딱 하나의 회원의 주소를 변경했을 뿐인데, 실행을 한다면 이런 결과를 얻게 된다.

  • 보면 알겠지만 업데이트 쿼리가 두번 나간다. 그리고 실제 결과도 이렇다. 같이 변경됐다.

 

 

이런 부작용이 발생할 수가 있다. 그러니까, 애시당초에 부작용을 발생시키지 않으려면 값의 변경을 할 수 없게 막아야 한다. 어떻게? 불변객체로 만들어서! 그래서 불변객체로 만들어야 한다는 것이다. 그래서 이렇게 강제하는 것이다. "한번 세팅된 값을 바꾸고 싶어? 그러면 새로 객체 만들어!" 

 

그래서 불변 객체로 만든 Address는 이렇게 만들 수 있다. 

package cwchoiit.embedded;

import javax.persistence.Embeddable;

@Embeddable
public class Address {
    private final String city;
    private final String street;
    private final String zipCode;

    public Address() {
        this.city = null;
        this.street = null;
        this.zipCode = null;
    }

    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }
}
  • 아까 위에서 본 그대로이다. 세터를 제거하고 필드를 전부 `final`로 변경했다. 그리고 오로지 생성자로만 값을 세팅할 수 있게 했다. 
  • 여기서 좀 더 친절한 코드가 되고 싶으면 새로운 불변객체를 만들어내는 with(...), from(...) 등의 메서드를 만들어서 변경하고자 하는 값을 받고 그 값을 세팅한 새로운 객체를 뱉어내는 메서드들도 만들 수 있겠다.
728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25
[JPA] Part 12. CASCADE, 고아 객체  (2) 2023.10.23
[JPA] Part 11. 지연로딩과 즉시로딩  (2) 2023.10.23
[JPA] Part 10. 프록시  (0) 2023.10.22
728x90
반응형
SMALL
SMALL

2024.10.30 업데이트


 

CASCADE는 영속성 전이를 어떤식으로 동작시킬 거냐를 지정하는 옵션이다.

일단 가장 먼저 알고 넘어갈 건 이 CASCADE는 연관관계나 지연로딩 즉시로딩과 전혀 아무런 상관이 없다.

 

그저 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티를 어떻게 할건지를 지정한다. 예를 들면 부모를 저장할 때 부모에 연관된 자식도 같이 영속하는 경우를 말한다.

 

말로는 잘 이해가 안되고 코드로 보면 바로 이해가 된다. 

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "parent")
    private List<Child> childList = new ArrayList<>();
    
    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

Child

package org.example.entity.cascade;

import javax.persistence.*;

@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
}

이렇게 두 엔티티가 있다고 가정했을 때, Parent가 2개의 Child를 가지는 코드를 작성한다고 해보자.

 

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("childA");

            Child childB = new Child();
            childB.setName("childB");
            
            parent.addChild(child);
            parent.addChild(childB);
            
            em.persist(parent);
            em.persist(child);
            em.persist(childB);
            
            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 그럼 이렇게 코드가 작성될거다. 근데 사실 이게 좀 귀찮은 작업이다. parentchild, childB가 속해지는데 parent, child, childB 모두 영속시키는 코드를 작성해줘야 하니까. child, childB가 어차피 parent에 속한다면 그냥 parent 하나를 영속시켜서 한번에 모두 다 영속시키면 더 편리하지 않을까? 그게 가능하다. 다음 코드를 보자.
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();

Parent가 가지고 있는 childList 필드에 @OneToMany로 연관관계를 걸어놓은 상태에서 CascadeType.ALL 속성을 CASCADE에 부여하면 @OneToMany 애노테이션이 달린 필드의 타입인 Child들도 전이를 시키겠다는 것이다. 그러니까 CASCADE는 딱 이렇게 생각하면 좋다. cascade 옵션을 설정한 필드 타입은 (여기서는 List<Child>이니까 Child를 말함) 이 Parentpersist()로 영속시키거나 remove()로 삭제할 때 이 Parent가 가지고 있는 Child들도 다 영속되거나 다 삭제된다고 생각하면 좋다.

 

이렇게 변경하고 실행 코드에서 child들을 persist() 메소드로 호출하는 라인을 지워보자.

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("childA");

            Child childB = new Child();
            childB.setName("childB");
            
            parent.addChild(child);
            parent.addChild(childB);
            
            em.persist(parent);
            //em.persist(child);
            //em.persist(childB);
            
            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 실행했을 때 INSERT문으로 parent, child, childB가 모두 들어간다. CASCADE는 이게 끝이다. 연관관계나 프록시와는 아무런 상관이 없다. 

 

CASCADE 종류

CASCADE의 종류는 다음과 같다. 보통은 ALL, PERSIST, REMOVE 정도가 주로 사용된다. 부모를 영속시킬 때 자식들도 한번에 다 영속시키거나, 부모를 삭제하면 그 하위 자식들도 다 같이 삭제되는 그런 경우가 거의 일반적이니까.

  • ALL: 모두 적용
  • PERSIST: 영속
  • REMOVE: 삭제
  • MERGE: 병합
  • REFRESH: REFRESH
  • DETACH: 준영속 

CASCADE는 자주 사용되는 편리한 옵션이지만, 주의할 사항이 있다. 위 예시처럼 Parent - Child 딱 하나로만 이루어진 연관 엔티티가 있는 경우는 CASCADE를 적용해도 무방하다. 근데 만약, ParentChild를 가지고 있고, 다른 어떤 엔티티(예를 들면.. 유치원 선생님 엔티티?)도 Child를 가지고 있다면 CASCADE 옵션을 사용하면 안된다. 위험하다. 예를 들어 A, B 엔티티가 있고 C라는 엔티티가 있을 때, A와 B 모두 C에 대한 부모가 되는 경우에 A에 CASCADE 옵션으로 REMOVE 또는 ALL을 적용해버리면 A를 삭제하면 C도 삭제가 될 것이다. 근데 B는 이를 모르고 있는 상태가 된다. 영문도 모른채 본인의 자식 엔티티 객체가 사라져버린다. 이런 이유때문에 조심해야한다. 다음 코드가 그 예시다.

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

ParentTwo

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class ParentTwo {

    @Id @GeneratedValue
    @Column(name = "PARENTTWO_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parentTwo", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.childList.add(child);
        child.setParentTwo(this);
    }
}

 

Child

package org.example.entity.cascade;

import javax.persistence.*;

@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;

    @ManyToOne
    @JoinColumn(name = "PARENTTWO_ID")
    private ParentTwo parentTwo;

    public ParentTwo getParentTwo() {
        return parentTwo;
    }

    public void setParentTwo(ParentTwo parentTwo) {
        this.parentTwo = parentTwo;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Parent getParent() {
        return parent;
    }

    public void setParent(Parent parent) {
        this.parent = parent;
    }
}
  • 주의 깊게 볼 내용은 Parent, ParentTwo 클래스 모두 Child와 관계를 가지고 있고, ParentParentTwo 둘 모두 CASCADE옵션을 ALL로 지정했다는 것이다. 여기서 만약, Parent 또는 ParentTwo 둘 모두에게 연결된 Child가 있는 Parent 또는 ParentTwo 레코드가 있고 그 레코드를 삭제하면 어떻게 될까? Child도 삭제가 된다. 왜냐하면 CASCADE옵션을 ALL로 주었기 때문에 PERSIST, REMOVE, DETACH등 모든 옵션이 적용된다. 그럼 다른 부모 입장에서 갑자기 자식이 사라진 셈이다. 망한거다.
package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.cascade.ParentTwo;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            ParentTwo parentTwo = new ParentTwo();
            parentTwo.setName("parentTwo");

            Child child = new Child();
            child.setName("childA");

            parent.addChild(child);
            parentTwo.addChild(child);

            em.persist(parent);
            em.persist(parentTwo);

            em.flush();
            em.clear();

            ParentTwo parentTwo1 = em.find(ParentTwo.class, parentTwo.getId());
            em.remove(parentTwo1);

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 주의 깊게 볼 부분은 flush가 된 후 다시 찾아낸 ParentTwo 객체를 지우는 이 부분이다.
ParentTwo parentTwo1 = em.find(ParentTwo.class, parentTwo.getId());
em.remove(parentTwo1);

 

이러면 Child가 날아가버린다. 확인해보자.

Hibernate: 
    delete 
    from
        Child 
    where
        CHILD_ID=?
Hibernate: 
    delete 
    from
        ParentTwo 
    where
        PARENTTWO_ID=?

이러한 상황을 조심해야 하기 때문에 딱 관계가 Parent - Child처럼 다른 참조 객체가 없는 상황에서는 상관이 없지만 그게 아닌 경우 CASCADE 옵션은 사용을 하면 안된다.

 

 

고아 객체 (orphan)

고아 객체라는 건 부모 - 자식 관계를 가지고 있는 엔티티 구조에서 부모 엔티티와 연결이 끊어진 자식 객체를 말한다. 

그리고 JPA에서는 이 고아 객체를 제거해버리는 옵션이 주어지는데 이 옵션이 orphanRemoval 옵션이다.

 

바로 확인해보자.

 

Parent

package org.example.entity.cascade;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Child> getChildList() {
        return childList;
    }

    public void setChildList(List<Child> childList) {
        this.childList = childList;
    }

    public void addChild(Child child) {
        this.getChildList().add(child);
        child.setParent(this);
    }
}

 

이 부모 클래스에서 볼 부분은 다음 코드다.

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList = new ArrayList<>();

보면 orphanRemoval = true 라는 옵션을 주었다. 이 옵션을 주면 이 부모와 연결이 끊어진 자식 객체는 삭제가 된다.

 

Main

package org.example;

import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.cascade.ParentTwo;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Parent parent = new Parent();
            parent.setName("parent");

            Child child = new Child();
            child.setName("child");

            parent.addChild(child);

            em.persist(parent);
            em.flush();
            em.clear();

            Parent findParent = em.find(Parent.class, parent.getId());
            findParent.getChildList().remove(0);

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드에서 자식을 생성하고 부모에 연결한 후 부모를 영속시킨 후 플러시를 실행했다. 여기까지 진행되면 Parent, Child 둘 모두 DB에 추가된다. 그리고 나서 이 코드를 보자.
Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);
  • 엔티티 매니저로 부모 객체를 찾아서 부모 객체가 가지고 있는 자식을 remove() 했다. 그럼, 0번에 해당하는 Child 객체는 이제 더이상 부모인 Parent 객체의 리스트에서 관리되지 않는다. (여기서 혼동이 많은데, 지금 remove()를 호출한다고 한들, 당연히 DB에는 아무런 영향이 없다! 즉, 0ChildPARENT_ID 외래키가 없어지는게 아니다! 그저 객체 관점에서 0Child가 더 이상 부모 참조가 없어진 것 뿐이다. 그러나! `orphanRemoval = true` 활성화를 하면 객체가 고아가 되면 DB에도 영향을 준다는 것이다. DB에서도 삭제가 되니 말이다.) 
Hibernate: 
    insert 
    into
        Parent
        (name, PARENT_ID) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        Child
        (name, PARENT_ID, PARENTTWO_ID, CHILD_ID) 
    values
        (?, ?, ?, ?)
Hibernate: 
    select
        parent0_.PARENT_ID as parent_i1_2_0_,
        parent0_.name as name2_2_0_ 
    from
        Parent parent0_ 
    where
        parent0_.PARENT_ID=?
Hibernate: 
    select
        childlist0_.PARENT_ID as parent_i3_0_0_,
        childlist0_.CHILD_ID as child_id1_0_0_,
        childlist0_.CHILD_ID as child_id1_0_1_,
        childlist0_.name as name2_0_1_,
        childlist0_.PARENT_ID as parent_i3_0_1_,
        childlist0_.PARENTTWO_ID as parenttw4_0_1_,
        parenttwo1_.PARENTTWO_ID as parenttw1_3_2_,
        parenttwo1_.name as name2_3_2_ 
    from
        Child childlist0_ 
    left outer join
        ParentTwo parenttwo1_ 
            on childlist0_.PARENTTWO_ID=parenttwo1_.PARENTTWO_ID 
    where
        childlist0_.PARENT_ID=?
Hibernate: 
    delete 
    from
        Child 
    where
        CHILD_ID=?

위에서부터 차례로 부모와 자식이 INSERT된 후 부모를 조회하는데 부모를 조회한 후 자식을 호출하는 과정에서 자식을 찾아내는 조인쿼리가 실행된다. (왜냐? OneToMany는 기본이 지연로딩(LAZY)이고 아무것도 설정하지 않았기 때문에 초기화하는 시점에 자식을 찾아온다) 그리고 나서 실행된 DELETE문이 보인다. 이게 고아 객체를 제거하는 쿼리다.

 

그리고 이 경우에도 주의할 점은! CASCADE와 마찬가지 이유로, 반드시 참조하는 곳이 하나일 때 사용해야 한다! 즉, 특정 엔티티가 개인 소유하는 경우에만 사용해야 한다. (예: 게시글 - 첨부파일 관계) 

  

참고로, 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 `orphanRemoval = true` 활성화하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CASCADEREMOVE처럼 동작한다.

 

영속성 전이 + 고아 객체

만약, CascadeType.ALL + orphanRemoval = true 이 두가지 옵션을 모두 설정을 하면, 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있게 된다. 영속성 전이를 통해 영속을 시키든, 삭제를 시키든 그 하위 엔티티도 동일하게 동작할 것이고, 부모에서 자식을 떼버리면 고아 객체가 된 객체는 DB에서 삭제되기 때문에 완전히 부모를 통해 자식의 생명 주기를 관리할 수 있게 된다.

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.30 업데이트


 

지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다. 

 

우선 지연로딩즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다. 

 

다음 상황을 가정해보자.

팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?

코드로 이를 직접 비교해보자.

 

지연로딩 (Lazy Fetch)

Member

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}
  • 멤버는 팀을 참조하고, LAZY(지연 로딩)를 Fetch 전략으로 가지고 있다.

Team

package org.example.entity;

import org.example.entity.fetch.Member;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Member> getMembers() {
        return members;
    }

    public void setMembers(List<Member> members) {
        this.members = members;
    }
}
  • 팀은 별다른 내용은 없고 멤버와 양방향으로 참조한다. 이런 두 엔티티가 있을 때 멤버를 조회해보자. 

Main

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());
            System.out.println("findMember: " + findMember.getUsername());
            System.out.println("findMember Team Class: " + findMember.getTeam().getClass());

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 지연 로딩으로 설정하면 참조 객체에 대해 바로 엔티티로 찾아오는게 아니라 전 시간에 공부했던 프록시로 가져온다. 즉, 위 코드로 실행하면 멤버의 팀은 프록시로 가져와야 한다. 실행 결과는 다음과 같다.

찾은 멤버의 팀은 현재 Proxy로 가져온 상태임을 확인할 수 있다. 이게 지연로딩이고 실제 SQL문을 살펴봐도 팀과 조인한 SQL문이 실행되지 않았다. 이렇게 되면 만약 멤버를 조회했을 때 팀을 알 필요가 전혀 없는 상태라면 이런 SELECT문이 훨씬 이점을 가져갈 것이다.

 

 

이번에는 즉시로딩으로 실행해보자. 참고로 @ManyToOne, @OneToOne은 기본이 즉시로딩(EAGER)이다.

즉시로딩 (Eager Fetch)

위에서 작성한 코드에서 수정할 내용은 LAZYEAGER로 변경만 하면 된다.

package org.example.entity.fetch;

import org.example.entity.Team;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}

EAGER로 변경 후 실행해보자. SQL문을 확인해보면 다음과 같이 출력된다.

멤버를 조회할 때 바로 조인을 해서 TEAM 정보도 가져오는 SELECT문을 확인할 수 있다. 그리고 가져온 TEAM의 클래스를 찍어보면 이번엔 Proxy가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.

 

 

결론

결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.

 

왜 그러냐면 다음과 같은 이유들이 있다. 

  • 즉시 로딩의 문제1 - 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.

이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다. 

Member findMember = em.find(Member.class, member.getId());

물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 거기다가, 지금이야 Team 하나만 즉시로딩으로 설정했으니 망정이지, 즉시로딩이 만약 3개, 4개 넘어가면 조인도 그만큼 많이 하게 된다.

 

  • 즉시 로딩의 문제2 - 즉시 로딩은 JPQL 사용을 할 때 N+1 문제가 생긴다.

이게 진짜 큰 문제인데 JPQL은 종종 사용된다. 복잡한 쿼리를 작성할 일이 많기 때문에 그래서 예시를 위해 다음 JPQL 코드를 살펴보자.

package org.example;

import org.example.entity.Team;
import org.example.entity.fetch.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setTeam(team);
            member.setUsername("memberA");
            em.persist(member);

            em.flush();
            em.clear();

            List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드부분에서 여기 부분이 JPQL을 사용하는 부분이다.

List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();

 

이 코드를 수행해보면 다음과 같은 SQL문이 출력된다.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

SELECT를 두번한다. 왜 두번하냐 ? 우선 위에 작성한 JPQL은 멤버를 조회한다. 멤버를 조회했을 때 멤버가 1개면 1개의 레코드, 2개면 2개의 레코드가 출력되는데 이 때 즉시로딩은 멤버가 가지고 있는 팀도 같이 가져와야하기 때문에 각 멤버별 팀까지 가져와야 하는 SELECT문이 나간다. 그럼 이게 멤버가 10명이 있을때, 각 멤버가 서로 다 다른 팀에 속한 경우, 팀을 가져오는 SELECT문 10번이 같이 나간다는 소리다. (물론, 멤버 10명이 다 같은 팀에 속해 있다면, 당연히 팀을 가져오는 쿼리는 한번만 나간다. 두번째부터는 이미 가져온 팀이니까)

 

그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 최악의 경우, 멤버가 10명이고 각 멤버가 속한 팀이 서로 다 다르다면 한번에 10 + 1 쿼리가 나가게된다.

 

심지어! 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다. 그래서 즉시로딩은 사용하면 안된다.

 

그럼 지연로딩으로 설정만 하면 이런 N + 1 문제가 아예 없어지나요!?

→ 안타깝게도 아니다. 그러나, 최소한 지연로딩으로 설정하면 손 쓸 수도 없게 문제를 바라만봐야 하는 상황은 막을 수 있다.

 

왜 근데 아닐까? 지연로딩으로 설정하고 아래와 같이 팀 2개, 유저 2개를 만들어서 DB에 넣고 영속성 컨텍스트를 깔끔하게 정리한 후 다시 조회해보자.

package cwchoiit;

import cwchoiit.fetch.Team;
import cwchoiit.fetch.Users;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");

            Team team2 = new Team();
            team2.setName("TEAM2");

            Users users = new Users();
            users.setUsername("choi");
            users.setTeam(team);

            Users users2 = new Users();
            users2.setUsername("kim");
            users2.setTeam(team2);

            entityManager.persist(team);
            entityManager.persist(team2);

            entityManager.persist(users);
            entityManager.persist(users2);

            entityManager.flush();
            entityManager.clear();


            List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

 

그리고 아래 코드처럼 JPQL로 모든 유저들을 가져오는 쿼리를 날리고 그 결과를 보자.

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

 

실행 결과

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
            
...

보다시피, 당연히 지연로딩이기 때문에 팀에 대한 정보는 아직 조회를 하지 않는다. 그러나, 만약 이 리스트를 순회해서 팀을 가져온다면?

그땐, 당연히 N + 1 문제가 결국엔 나타나게 된다. 

...

List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();

for (Users findUser : findUsers) {
    System.out.println(findUser.getTeam().getName());
}

...

 

실행 결과

...

Hibernate: 
    /* SELECT
        u 
    FROM
        Users u */ select
            users0_.USER_ID as user_id1_12_,
            users0_.TEAM_ID as team_id3_12_,
            users0_.username as username2_12_ 
        from
            Users users0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM1
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.name as name2_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
TEAM2

...

실행 결과를 보면 역시 마찬가지로 각 멤버가 속한 팀에 대한 쿼리를 하나씩 날리고 있다. 즉, 지연로딩으로 해도 N + 1 문제는 여전히 발생할 수 있다는 소리다. 그러나 차이점은 명확하다. 즉시로딩은 JPQL 실행 시, 개발자가 손 쓸 수도 없게 바로 N + 1 문제가 발생하지만, 지연로딩은 JPQL 실행 시 바로 N + 1 문제는 발생하지 않는다. 그래서 만약, 순회를 하더라도 팀을 조회하는 코드가 없다면 N + 1 문제는 지연로딩만으로도 해결할 수 있다. (물론, 반쪽짜리 해결이고 사실 해결한 것이라 볼 수 없지만.)

 

그럼 이 N + 1 문제를 완전히 해결하는 방법은 뭔데요?

→ 이후에 배우겠지만 "지연 로딩 + 페치 조인"이다.

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 13. 임베디드 값 타입  (0) 2023.10.23
[JPA] Part 12. CASCADE, 고아 객체  (2) 2023.10.23
[JPA] Part 10. 프록시  (0) 2023.10.22
[JPA] Part 9. @MappedSuperclass  (0) 2023.10.22
[JPA] Part 8. 상속관계 매핑  (2) 2023.10.22
728x90
반응형
SMALL
SMALL

2024.10.30 업데이트


 

JPA에서 중요한 개념 중 하나인 프록시라는 게 있다. 중요한 개념이라기보단 중요하게 사용되는 지연로딩과 즉시로딩을 사용하려면 반드시 깊은 이해가 필요하다고 개인적으로 생각한다. 왜 그러냐면 회사에서 프로젝트를 진행 중에 이해도가 깊지 않은 상태에서 지연로딩을 마구잡이로 썼다가 프록시 초기화 오류를 진짜 무진장 만났던 기억이 아직도 생생하다. 

 

 

우선 서사는 이렇다.

멤버라는 테이블과 팀이라는 테이블이 있을 때를 가정해보자. 멤버는 팀에 속하고 아래 그림과 같다.

이런 구조를 가진 두 테이블이 있을 때 멤버를 조회한다고 가정해보자. 특정 멤버를 조회할 때 팀도 같이 조회해야 할까?

예를 들어 이런 메서드가 있다고 생각해보자.

private static void printMember(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member = " + member.getName());
}
    
private static void printMemberAndTeam(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member name = " + member.getName());
    System.out.println("member's team = " + member.getTeam());
}
  • printMemberAndTeam(EntityManager em)은 멤버와 팀을 모두 출력하는 메서드이다.
  • printMember(EntityManager em)은 멤버만 출력하는 메서드이다.
  • printMemberAndTeam()을 호출한다면, 멤버를 조회할 때 팀까지 같이 조회가 되면 훨씬 효율적이겠지만, printMember()를 호출한다면, 멤버를 조회할 때 팀까지 조회되는게 비효율적일 것이다. (팀을 가져오기 위해 JOIN을 할테니)

이런 상황을 해결하기 위해 '프록시'가 있다.

 

Proxy

우선 프록시를 깊이 있게 알기 전에 엔티티 매니저는 두 가지 핵심 메서드가 있다. (핵심 메서드라고 말은 했지만, 사실 순수 JPA를 사용할 일은 거의 없고 Spring Boot + Spring Data JPA를 같이 사용하는 게 거의 99%이고, 이 경우에는 entityManager.find(), entityManager.getReference() 같은 메서드를 사용하지 않아서 이 메서드를 막 외우고 그럴 필요는 없다. 다만 이런것으로부터 프록시와 초기화에 대해 이해하는게 정말 중요하기 때문에 이 내용을 알아야 한다.)

  • em.find()
  • em.getReference()

우선 find() 메서드는 이미 여러번 사용했기 때문에 잘 알고 있다. 영속성 컨텍스트 또는 데이터베이스로부터 특정 객체(레코드)를 반환하는 메서드이다. 그럼 getReference()는 무엇일까? 데이터베이스로 조회를 지연시키는 가짜 엔티티(프록시) 객체를 반환한다. 

 

이해하기 위해 코드를 실행해보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

실행 결과

Hibernate: 
    insert 
    into
        Member
        (createdAt, description, email, lastModifiedAt, name, roleType, TEAM_ID, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
  • 실제 데이터베이스로부터 멤버를 조회하기 위한 SELECT문이 수행된다. 근데 getReference()를 사용해보면 어떻게 될까?

 

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.getReference(Member.class, member.getId());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

실행 결과

Oct 22, 2023 7:06:06 PM org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/h2/test]

Process finished with exit code 0
  • SELECT문이 출력되지 않는다. 즉 데이터베이스에 직접 조회를 하지 않았단 소리다. 이게 프록시다. 우선 객체를 찾아내는 조회 과정을 미루는것이다. 그럼 언제 실제로 조회할까? 실제로 사용할 때! 조회를 그때가서야 한다. 그리고 이 조회를 하는 순간을 "프록시를 초기화 한다" 라고 말한다.

  • 이렇게 getReference()를 호출하면, Proxy 객체를 반환하는데 이 상태에서는 DB로부터 조회하는 단계는 없다.

 

그럼, 이 프록시 객체를 초기화하는 순간을 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member findMember = em.getReference(Member.class, member.getId());
            System.out.println("No SELECT QUERY Until this line");

            // Execute SELECT Query
            System.out.println("findMember = " + findMember.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 프록시 객체가 가지고 있는 메서드를 호출할 때 비로소 프록시는 실제 데이터를 DB로부터 조회해온다. 실행 결과는 다음과 같다. "No SELECT QUERY Until this line"이 출력된 후 SELECT문이 실행됨을 알 수 있다. 
No SELECT QUERY Until this line
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
findMember = member1

 

그래서 이 과정을 그림으로 보면 다음과 같다.

  • 프록시 객체는 실제 대상 객체인 target을 가지고 있다. 이 target은 초기화되기 전까지는 값이 없는 상태다.
  • ClientgetName()과 같은 객체를 통해 가져와야 하는 값을 호출하는 무언가를 호출했을때 프록시는 초기화 요청을 진행하낟.
  • 그럼 DB로부터 실제 데이터를 조회한다.
  • 조회한 데이터에 대한 엔티티를 만들고 그 엔티티를 프록시 객체가 가지고 있는 target에 집어넣는다.
  • target이 가진 getName()을 호출하고 그 값을 Client에게 반환한다.

 

 

그래서 프록시는 이러한 특징을 가지고 있다.

  • 실제 클래스를 상속 받아서 만들어진다.
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.
  • 프록시 객체는 실제 객체의 참조(Target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출
  • 프록시 객체는 처음 사용할 때 한 번만 초기화
  • 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.
  • 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의 요망 (`==`를 사용하면 안되고, `instance of`를 사용해야 한다)이게 무슨 말이냐면, 아래 코드를 보자.
Member member = new Member();
member.setName("member1");
em.persist(member);

Member member2 = new Member();
member2.setName("member2");
em.persist(member2);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member2.getId());

System.out.println("m1 = " + m1.getClass()); //m1 = class org.example.entity.Member
System.out.println("m2 = " + m2.getClass()); //m2 = class org.example.entity.Member$HibernateProxy$H8YFX6fR 

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); //false
  • m1find() 메서드를, m2getReference() 메서드를 사용했다. 그러면 가져오는 클래스는 MemberMemberProxy가 된다. 이 때 이 두 객체 모두 타입을 Member로 받아왔지만 (프록시가 원본 엔티티를 상속받기 때문에 프록시 객체여도 Member 타입으로 받아올 수 있음) 두 객체의 클래스를 비교하면 false를 리턴한다는 뜻이다. 
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환이라고 되어 있으나, 좀 더 정확히는 같은 트랜잭션 내에서 JPA는 무결성과 일관성을 유지해주는 속성 때문에 getReference()가 아니라 find() 메서드를 사용하더라도 이미 영속성 컨텍스트에 프록시가 있으면 find() 메서드로 호출해도 프록시로 반환한다. 반대로 이미 영속성 컨텍스트에 찾은 동일한 멤버 객체가 실제로 있으면 getReference()를 호출해도 실제 엔티티를 반환한다는 의미다. 아래 코드로 예시를 살펴보자.
Member member = new Member();
member.setName("member1");
em.persist(member);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member.getId());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
  • 위 코드를 보면, 영속성 컨텍스트를 깔끔히 정리한 후에 find() 메서드를 먼저 호출하고 getReference() 메서드를 호출한다. 이 때 find()메서드를 통해 실제 엔티티를 가져와 영속성 컨텍스트에 넣었기 때문에 getReference()를 호출해서 가져와도 실제 엔티티를 가져온다. 그 결과는 다음과 같다.
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member
m2 = class org.example.entity.Member
m1 == m2: true

반대도 마찬가지다. 만약 아래와 같은 코드가 있을 때를 보자.

Member member = new Member();
member.setName("member1");
em.persist(member);

em.flush();
em.clear();

Member m2 = em.getReference(Member.class, member.getId());
Member m1 = em.find(Member.class, member.getId());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
  • 이번엔 getReference()를 먼저 호출해서 멤버를 조회하면 이 때 m2는 프록시로 가져온다. 그럼 m1은 어떻게 가져올까? 그렇다. 프록시로 가져온다. 왜냐? JPA는 같은 트랜잭션 내 동일한 레코드에 대해서 동일성(`==`) 보장이라는 매커니즘이 있기 때문이다.

이 코드의 결과는 다음과 같다.

Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m2 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m1 == m2: true
  • find() 메서드로 인해 SELECT문으로 실제 데이터베이스로부터 조회해도 가져오는 값은 Proxy이다. 근데 이제 복잡하게 생각말고, 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 엔티티를 가져온다. 라고 한 줄로 요약하는 것이다.

 

또, 아주 중요한 내용이 하나 있다. 바로 다음 한 줄이다.

영속성 컨텍스트의 도움을 받을 수 없는 준영속(Detach)상태 일 때, 프록시를 초기화하면 에러 발생

이게 가장 중요한 부분이다. 준영속 상태가 되는 상황은 크게 세가지 정도가 있다.

  • 엔티티 매니저의 detach(해당 객체) 
  • 엔티티 매니저의 clear()
  • 엔티티 매니저의 close()

어떤 경우가 됐든 준영속 상태가 되었다면 프록시를 초기화할 때 LazyInitializationException이 발생한다. 영속성 컨텍스트의 도움을 받을 수 없다라는 말은 영속성 컨텍스트는 결국 하나의 엔티티 매니저당 하나의 영속성 컨텍스트를 가진다. 그럼 엔티티 매니저가 끝나면 영속성 컨텍스트도 끝나는 것이다. 이게 이제 스프링 부트를 같이 사용하면 @Transactional 이라는 애노테이션을 사용해서 간편하게 엔티티 매니저를 만들고 영속성 컨텍스트를 만들어 주는데, 이 @Transactional이 적용된 메서드가 끝난 상태에서 초기화를 시도하면 영속성 컨텍스트는 없기 때문에 동일하게 LazyInitializationException 에러가 발생한다.

 

 

코드로 보면 좀 더 이해가 빠르게 되더라. 코드를 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

            em.flush();
            em.clear();

            Member m2 = em.getReference(Member.class, member.getId());

            em.detach(m2); //준영속 상태

            System.out.println("m2 Name: " + m2.getName());

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드처럼 프록시를 가져온 후 가져온 detach() 하고나서, 프록시를 초기화하려고 시도하면 다음과 같은 에러를 마주할 수 있다. 
org.hibernate.LazyInitializationException: could not initialize proxy [org.example.entity.Member#1] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:176)
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:322)
	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)
	at org.example.entity.Member$HibernateProxy$AFKAFeFy.getName(Unknown Source)
	at org.example.Main.main(Main.java:37)

이 준영속 상태는 다시 말해 영속성 컨텍스트의 도움을 못받는 경우를 말하고, 영속성 컨텍스트는 하나의 트랜잭션이 곧 하나의 엔티티 매니저와 1:1로 매핑된다고 아주아주 이전에 말한 것 처럼 엔티티 매니저가 닫히거나(하나의 트랜잭션이 끝남), 비워지거나 등 어떤 식으로든 영속성 컨텍스트의 도움을 받지 못하는 경우 위 에러를 마주한다. 이런 상황을 이해해야 지연 로딩과 즉시 로딩에 대해 깊은 이해와 사용성을 높일 수 있다. 나도 직접 이런 경우를 실제 회사 프로젝트에서 많이 많이 겪다보니까 정말 힘들었는데.. 알고 나니 어려운 게 하나도 아니더라. 

 

 

프록시 유틸 메소드

프록시 관련해서 쓸만한 메서드들을 소개하자면, 프록시가 초기화 된 상태인지 아닌지 확인 가능한 isLoaded()가 있다.

emf.getPersistenceUnitUtil().isLoaded(Object entity);
// emf.getPersistenceUnitUtil()은 PersistenceUnitUtil를 반환

 

또는 강제 초기화를 할 수 있는 initialize() 메서드도 있다.

Hibernate.initialize(Object entity);
  • 근데 JPA 표준에서는 강제 초기화가 없기 때문에 그냥 해당 프록시가 상속 받는 클래스가 가진 메서드가 실제로 호출되는 순간이 곧 초기화 되는 순간이다. (예시: member.getName() 호출)
728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.30 업데이트


 

이건 Part 8에서 알아본 상속 관계 매핑이 아니다! (느낌은 비슷하다, 왜냐하면 클래스에서는 extends로 사용되기 때문에 헷갈리긴 하지만 상속관계 매핑은 @Inheritance 애노테이션을 사용할때가 상속 관계 매핑이다.) 어떤 거냐면 테이블들이 공통으로 사용하는 필드들을 편하게 가져다 쓰기 위해 한 클래스에서 그 필드들을 선언하고 필요한 엔티티가 가져다가 사용하는 것을 말한다. 

 

자주 사용되며 아주 대표적인 예시가 createdDate, createdBy, lastModifiedBy, lastModifiedDate같은 필드를 다룰 때이다. 바로 코드를 보자. Part 8에서 사용했던 Item, Movie, Album, Book 테이블을 활용해서 적용해보자. 

 

BaseEntity

package org.example.entity.inheritance;

import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifiedBy;
    private LocalDateTime lastModifiedDate;

    public String getCreatedBy() {
        return createdBy;
    }

    public void setCreatedBy(String createdBy) {
        this.createdBy = createdBy;
    }

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public void setCreatedDate(LocalDateTime createdDate) {
        this.createdDate = createdDate;
    }

    public String getLastModifiedBy() {
        return lastModifiedBy;
    }

    public void setLastModifiedBy(String lastModifiedBy) {
        this.lastModifiedBy = lastModifiedBy;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }

    public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
    }
}
  • BaseEntity라는 추상클래스를 만들고 공통적으로 사용될 데이터를 모두 작성한 후 이 클래스에 @MappedSuperclass 어노테이션을 추가해준다. 그럼 JPA는 아 이 클래스가 테이블로 만들어지는 클래스가 아니고 메타데이타성 클래스구나를 인지한다.
  • 직접 객체를 만들어 사용할 일도 없고, 엔티티도 아니기 때문에 추상 클래스로 만들기를 권장한다.

 

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private int price;

    public Item() {}

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • 이제 Album, Book, Movie가 상속받는 Item 클래스는 BaseEntity를 부모로 선언한다. 그렇게 되면 나머지 Album, Book, Movie도 모두 가져다가 사용할 수 있게 된다.

 

Main

package org.example;

import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        
        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            movie.setCreatedBy("반지의제왕 감독");
            em.persist(movie);

            em.flush();
            em.clear();

            Item item = em.find(Item.class, movie.getId());
            
            System.out.println("Movie item = " + item.getName());
            System.out.println("Movie item createdBy = " + item.getCreatedBy());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
  • 위 코드처럼 Movie 객체 하나를 추가해서 createdBy 값을 추가하고 DB에 저장한 뒤 DB로부터 데이터를 받아와보자.

실행 결과

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (createdBy, createdDate, lastModifiedBy, lastModifiedDate, name, price, DTYPE, ITEM_ID) 
    values
        (?, ?, ?, ?, ?, ?, 'Movie', ?)
Hibernate: 
    insert 
    into
        Movie
        (actor, director, ITEM_ID) 
    values
        (?, ?, ?)
Hibernate: 
    select
        item0_.ITEM_ID as item_id2_3_0_,
        item0_.createdBy as createdb3_3_0_,
        item0_.createdDate as createdd4_3_0_,
        item0_.lastModifiedBy as lastmodi5_3_0_,
        item0_.lastModifiedDate as lastmodi6_3_0_,
        item0_.name as name7_3_0_,
        item0_.price as price8_3_0_,
        item0_1_.author as author1_1_0_,
        item0_1_.isbn as isbn2_1_0_,
        item0_2_.artist as artist1_0_0_,
        item0_3_.actor as actor1_6_0_,
        item0_3_.director as director2_6_0_,
        item0_.DTYPE as dtype1_3_0_ 
    from
        Item item0_ 
    left outer join
        Book item0_1_ 
            on item0_.ITEM_ID=item0_1_.ITEM_ID 
    left outer join
        Album item0_2_ 
            on item0_.ITEM_ID=item0_2_.ITEM_ID 
    left outer join
        Movie item0_3_ 
            on item0_.ITEM_ID=item0_3_.ITEM_ID 
    where
        item0_.ITEM_ID=?
Movie item = 반지의 제왕
Movie item createdBy = 반지의제왕 감독

 

참고로, 이후에 Spring Data JPA를 같이 사용할땐 이렇게 직접 값을 넣어주지 않아도 알아서 넣어주는 방법이 있다. 지금은 순수 JPA만 사용중이니까 이렇게 직접 createdBy 값을 넣어줬지만.

 

실전 예제

그럼 이제, 상속관계 매핑도 배웠고 @MappedSuperclass도 배웠으니 이걸 사용해서 테이블을 엔티티로 매핑해보는 예제를 다뤄보자.

 

도메인 모델

 

상세

 

이렇게 되어 있을 때, 테이블을 엔티티로 설계해보자.

 

BaseEntity

package cwchoiit.relationship;

import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
public abstract class BaseEntity {

    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;

    private String createdBy;
    private String lastModifiedBy;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private Integer price;
    private Integer stockQuantity;
}
  • 상속관계 매핑 전략을 JOINED로 설정했다. SINGLE_TABLE로 해도 무방하다.

Album

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Album extends Item {

    private String artist;
    private String etc;
}

 

Book

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Book extends Item {

    private String author;
    private String isbn;
}

 

Movie

package cwchoiit.relationship;

import javax.persistence.Entity;

@Entity
public class Movie extends Item {

    private String director;
    private String actor;
}

 

Member

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;
    private String city;
    private String street;
    private String zipCode;

    @OneToMany(mappedBy = "member")
    private List<Orders> orders = new ArrayList<>();
}

 

Orders

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Orders extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    private LocalDateTime orderDate;
    private Status status;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToOne
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}

 

Delivery

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Delivery extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "DELIVERY_ID")
    private Long id;

    private String city;
    private String street;
    private String zipCode;
    private Status status;
}

 

OrderItem

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
public class OrderItem extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "ORDERITEM_ID")
    private Long id;

    private Integer orderPrice;
    private Integer count;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Orders orders;
}

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "CATEGORY_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();
}

 

CategoryItem

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class CategoryItem extends BaseEntity {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "CATEGORY_ID")
    private Category category;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private LocalDateTime created;
}

 

이렇게 구성하면 된다!

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.30 업데이트


 

이제 상속관계를 매핑하는 법도 알아보자. 왜냐하면 객체는 상속을 받을 수 있으니까. 

우선은 관계형 데이터베이스는 상속 관계가 없다. 대신 슈퍼타입 서브타입 관계라는 모델링 기법이 있고 그 기법이 객체 상속과 유사하다.

그래서, 상속관계 매핑을 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑해 보는 것이 주제다.

위 그림에서 ITEM이라는 테이블이 있고 그 테이블이 가지는 컬럼을 모두 동일하게 가지는 3개의 테이블 Album, Movie, Book이 있다고 가정해 보자. 이 경우 데이터베이스에서는 3가지 전략으로 테이블을 구성할 수 있다.

  • 조인 전략
  • 단일 테이블 전략
  • 구현 클래스마다 각 테이블 전략

 

 

조인 전략

조인 전략은 상위 테이블인 ITEM의 기본키를 각 테이블이 기본키이자 외래키로 가지는 방법이다. 다음 그림이 이를 설명한다.

ITEM 테이블에서 공통으로 사용되는 NAME, PRICE를 가지고 있으며 각 테이블마다 개인적으로 필요한 데이터는 각 테이블이 관리하는 방법이다. 그리고 이 때, ITEM 테이블에서는 어떤 테이블과 연결된 데이터인지를 구분 짓기 위해 DTYPE이라는 필드가 추가된다. 데이터를 가장 정교화된 방식으로 모델링을 깔끔하게 한 모델이다.

 

객체로 이를 구현해보면 다음과 같다.

 

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private int price;

    public Item() {}

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

가장 최상위인 아이템 클래스는 살펴볼 점이 두 가지가 있다. 

  • 첫 번째, @Inheritance는 이 클래스를 상속받을 클래스들과 어떤식으로 테이블이 구성될지를 알려주는 어노테이션이다. 이 전략의 기본값은 SINGLE_TABLE인데 이는 위에서 말한 단일 테이블 전략을 말한다. 그래서 JOINED로 변경했다.
  • 두 번째, @DiscriminatorColumn은 위에서 표기한 DTYPE을 칼럼으로 추가한다는 어노테이션이다. 기본 칼럼명은 DTYPE이다.

 

참고로, 현재 시점(2024.11.01)에서는 JOIN 전략일 때, @DiscriminatiorColumn 애노테이션을 사용하지 않아도 된다. 아니 오히려 사용하면 WARNING 로깅이 찍힌다. 예전 방식이라고. 사실 JOIN 전략일땐 이 DTYPE이 필요가 없다. 어차피 조인해서 값을 가져올 수 있기 때문에. 그런데, SINGLE_TABLE에서는 반드시 필요하다. 당연한게 모든 필드를 하나의 테이블에 넣어서 관리하면 각 레코드가 어떤 데이터인지 DTYPE 말고는 구분할 방법이 없기에.

 

 

Album

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Album")
public class Album extends Item {

    private String artist;

    public Album(String name, int price, String artist) {
        super(name, price);
        this.artist = artist;
    }

    public Album() {
        super();
    }
}
  • Item 클래스를 상속받을 Album이다. 이 클래스는 @ID가 필요없다. 왜냐하면 ItemIdPK이자 FK로 설정할 것.
  • @DiscriminatorValue("Album")ItemDTYPE값으로 들어갈 Value를 지정하는 것. 기본값은 엔티티명이 된다. 즉, 위 예시에서 굳이 값을 입력할 필요는 없었지만 저렇게 변경할 수 있다는 것을 기억하기 위해 "Album"을 입력했다.

 

Book

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Book")
public class Book extends Item {
    private String author;
    private String isbn;

    public Book() {
        super();
    }

    public Book(String name, int price, String author, String isbn) {
        super(name, price);
        this.author = author;
        this.isbn = isbn;
    }
}

 

 

Movie

package org.example.entity.inheritance;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("Movie")
public class Movie extends Item {
    private String director;
    private String actor;

    public Movie() {
        super();
    }

    public Movie(String name, int price, String director, String actor) {
        super(name, price);
        this.director = director;
        this.actor = actor;
    }
}
  • BookMovieAlbum과 맥락이 동일하다. 각 테이블에서 필요한 필드값만 다를뿐이다.

 

Main

package org.example;

import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 결과

 

JOINED 전략에서 조회

Movie 데이터를 DB로부터 가져올 때 JPA는 당연하게도 조인을 사용한다. 이 방식 자체가 조인 전략이니까. 

package org.example;

import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            em.flush();
            em.clear();

            Movie findMovie = em.find(Movie.class, movie.getId());
            System.out.println("findMovie = " + findMovie.getDirector());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 로그 및 SQL문:

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (name, price, DTYPE, ITEM_ID) 
    values
        (?, ?, 'Movie', ?)
Hibernate: 
    insert 
    into
        Movie
        (actor, director, ITEM_ID) 
    values
        (?, ?, ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id2_3_0_,
        movie0_1_.name as name3_3_0_,
        movie0_1_.price as price4_3_0_,
        movie0_.actor as actor1_6_0_,
        movie0_.director as director2_6_0_ 
    from
        Movie movie0_ 
    inner join
        Item movie0_1_ 
            on movie0_.ITEM_ID=movie0_1_.ITEM_ID 
    where
        movie0_.ITEM_ID=?
findMovie = 감독

보면 알겠지만, INSERT문이 두 번 들어가고 조회할 땐 조인을 해서 가져오기 때문에 아무래도 단일 테이블 전략에 비해 성능이 저하될 수 있다. 근데 사실 뭐 INSERT문 두번 한다고 해서 성능에 크게 영향을 주거나 그러지는 않는다.

 

 

단일 테이블 전략

단일 테이블 전략은 하나의 테이블에서 모든 데이터를 다 집어넣으면 된다. 다른 게 없다. 성능적인 이점을 가져갈 순 있다. 조인도 필요 없고 INSERT문도 한 번만 하면 되니까. 그 대신 NULL값이 지저분할 순 있겠지.

 

우선 그림으로 살펴보면 다음과 같다.

정말 한 테이블에 다 넣은 것. 얘는 조인 테이블과 달리 반드시 DTYPE 칼럼이 들어가야 한다. 조인 테이블은 DTYPE 컬럼이 없어도 가능하지만 이 테이블은 그렇게 되면 안 된다. 그래서, JPA가 @DiscriminatorColumn 어노테이션을 붙이지 않더라도 알아서 SINGLE_TABLE인 경우에 DTYPE을 추가해 준다. 반드시 필요한 데이터니까.

 

이를 코드상으로 구현해 보면 딱 하나만 변경해 주면 된다.

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 이 부분
@DiscriminatorColumn
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private int price;

    public Item() {}

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • @Inheritance의 전략을 SINGLE_TABLE로 변경하면 된다.

실행 결과

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    insert 
    into
        Item
        (name, price, actor, director, DTYPE, ITEM_ID) 
    values
        (?, ?, ?, ?, 'Movie', ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id2_1_0_,
        movie0_.name as name3_1_0_,
        movie0_.price as price4_1_0_,
        movie0_.actor as actor8_1_0_,
        movie0_.director as director9_1_0_ 
    from
        Item movie0_ 
    where
        movie0_.ITEM_ID=? 
        and movie0_.DTYPE='Movie'
findMovie = 감독

INSERT문 한 번에 조회 시에도 조인이 필요 없어진다.

 

 

구현 클래스마다 테이블 전략

아이템 테이블을 아예 없애고, 아이템 테이블이 가지고 있는 필드들을 각 테이블 (Movie, Book, Album)이 모두 가지는 경우를 말한다.

그림으로 보면 다음과 같다.

각 테이블이 모두 NAME, PRICE라는 칼럼을 동일하게 가지고 있고, PKITEM_ID로 가지고 있는 그림이다.

이 경우도 마찬가지로 딱 한 부분만 변경하면 된다.

Item

package org.example.entity.inheritance;

import javax.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private int price;

    public Item() {}

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
  • @Inheritance 어노테이션의 전략을 TABLE_PER_CLASS로 전략을 변경하고 실행해 보자. ITEM 테이블은 만들어지지 않는다. 

실행 결과

Hibernate: 
    
    create table Album (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        artist varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    
    create table Book (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        author varchar(255),
        isbn varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    
    create table Movie (
       ITEM_ID bigint not null,
        name varchar(255),
        price integer not null,
        actor varchar(255),
        director varchar(255),
        primary key (ITEM_ID)
    )
Hibernate: 
    insert 
    into
        Movie
        (name, price, actor, director, ITEM_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    select
        movie0_.ITEM_ID as item_id1_3_0_,
        movie0_.name as name2_3_0_,
        movie0_.price as price3_3_0_,
        movie0_.actor as actor1_6_0_,
        movie0_.director as director2_6_0_ 
    from
        Movie movie0_ 
    where
        movie0_.ITEM_ID=?
findMovie = 감독

근데, 결론은 이 구현 클래스마다 테이블 전략은 사용하지 말자. 왜냐하면 비효율적이다. 우선 같은 칼럼이 다 테이블마다 들어가는 것도 그렇지만 가장 큰 문제는 ITEM으로 조회했을 때 일어나는 현상 때문이다.

 

다음 코드를 보자. Item이 부모인데 당연히 Item으로 조회가 가능할 거고, 그때 일어나는 현상을 Hibernate가 찍어주는 로그로 확인해 보자.

package org.example;

import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();
        try {
            Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
            em.persist(movie);

            em.flush();
            em.clear();

            Item item = em.find(Item.class, movie.getId());
            System.out.println("Movie item = " + item.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

 

실행 결과

Hibernate: 
    insert 
    into
        Movie
        (name, price, actor, director, ITEM_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    select
        item0_.ITEM_ID as item_id1_3_0_,
        item0_.name as name2_3_0_,
        item0_.price as price3_3_0_,
        item0_.author as author1_1_0_,
        item0_.isbn as isbn2_1_0_,
        item0_.artist as artist1_0_0_,
        item0_.actor as actor1_6_0_,
        item0_.director as director2_6_0_,
        item0_.clazz_ as clazz_0_ 
    from
        ( select
            ITEM_ID,
            name,
            price,
            author,
            isbn,
            null as artist,
            null as actor,
            null as director,
            1 as clazz_ 
        from
            Book 
        union
        all select
            ITEM_ID,
            name,
            price,
            null as author,
            null as isbn,
            artist,
            null as actor,
            null as director,
            2 as clazz_ 
        from
            Album 
        union
        all select
            ITEM_ID,
            name,
            price,
            null as author,
            null as isbn,
            null as artist,
            actor,
            director,
            3 as clazz_ 
        from
            Movie 
    ) item0_ 
where
    item0_.ITEM_ID=?
Movie item = 반지의 제왕

조회 시 Album, Book, Movie 이 세 가지를 다 UNION으로 묶어서 조회하는 끔찍한 일이 일어난다. 그러니까 결론은 쓰지 말자. 

 

마무리

그럼 조인 전략단일 테이블 전략 둘 중 사용하면 되는데, 각 장단점이 있다. 그때그때 필요한 더 적합한 방식을 사용하면 되는데 우선 조인 전략이 정규화된, 정석적인 방법이란 걸 알아 두자. 그러니까 기본은 조인 전략을 사용한다는 것을 전제하에 두고 시작하면 된다.

 

조인 전략

장점

  • 테이블 정규화
  • 정석적인 방식
  • 객체와 상호보완적
  • 무결성 보존의 장점 (단일 테이블과 비교해서 NULL값이 들어가지 않는다)

단점

  • 조회 시 조인을 사용하니까 단일 테이블 전략에 비해 성능 저하 가능성
  • 생성 시 INSERT문이 두 번 사용되니까 단일 테이블 전략에 비해 성능 저하 가능성

 

 

단일 테이블 전략

장점

  • 간단함
  • 조회 및 생성 시 조인을 사용하지 않고 INSERT문이 한 번으로 끝난다.

단점

  • 불필요한 칼럼들에 대한 NULL값이 생긴다. (Movie 관련 데이터를 추가하면 Album, Book 관련 데이터는 모두 NULL)
728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.29 업데이트


 

이 전 포스팅까지는 단방향, 양방향에 대해서 알아보았는데 결론은 테이블 관점은 방향이란 게 없고 자식 테이블에 외래키가 존재하며 그 외래키를 통해 부모 테이블과 조인하여 원하는 데이터를 얻어내거나 반대로 부모 테이블은 본인의 PK를 이용해서 자식 테이블의 외래키와 조인하여 원하는 데이터를 얻을 수 있고 객체 관점은 단방향 또는 양방향 연관관계라는 게 존재하며 양방향 연관관계는 사실 단방향 두 개를 의미하며 설계할 때 양방향이 단 하나도 없어도 애플리케이션에 전혀 문제가 없다는 것. 단방향으로 설계를 끝내고 필요하면 양방향 연관관계를 추가할 것이 결론이었다.

 

이제는 연관관계를 매핑할 때 ManyToOne, OneToMany, OneToOne, ManyToMany 관계에 대해서 알아보려고 한다. 

여기서, ManyToOne(다대일), OneToMany(일대다), OneToOne(일대일), ManyToMany(다대다)는, 앞부분에서 외래키를 관리하는것으로 생각하면 된다. 그래서 다대일이면 '다'쪽에 외래키가 있고, 일대다면 '일'쪽에 외래키가 있게 객체에서 설계한다는 단어이다. DB 설계와는 완전히 상관없는 얘기고 자바와 JPA, 즉, 객체 관점에서 외래키 관리를 앞쪽에서 한다고 생각하면 된다.

 

⭐️ ManyToOne

ManyToOne은 다대일로 다음과 같은 관계를 말한다.

유저 테이블과 게시물 테이블 두 개가 있을 때, 하나의 유저는 여러 개의 포스팅을 할 수 있다. 그럼 포스트 입장에서는 다대일이 되는 것이다. 그리고 외래키를 관리하는 쪽은 포스트 쪽이 된다. 이 ⭐️테이블의 설계를 그대로 객체 관점에서도 적용할 수 있는 게 다대일이다.⭐️

 

다음 코드를 보자. 멤버와 주문의 관계는 다대일 연관관계로 적용할 수 있다. 이때 자식 테이블은 주문이 될 것이고 주문 테이블에서 부모 테이블의 기본키를 외래키로 참조한다. 그 설계를 그대로 객체에 적용한다. 

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;
    private String city;
    private String street;
    private String zipcode;

}
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

}

주문 테이블에 멤버 타입의 멤버를 참조한다. 이 @JoinColumn 어노테이션은 멤버와 조인할 때, 어떤것을 가지고 조인할 생각이냐?란 질문에 대한 답변이라고 보면 된다. 당연히 멤버의 ID로 하면 된다. 그리고 하나 더 @ManyToOne 어노테이션이 들어있다. 즉, 주문 입장에서는 다대일 관계가 된다. 그리고 위와 같은 설계를 '다대일 단방향 연관관계'라고 한다. 다대일이니까 '다'쪽에 외래키를 가지고 있고, 한쪽에서만 참조하고 있는 형태.

 

다대일 양방향 연관관계를 만드려면 ?! 다음과 같은 코드로 만들면 된다. 

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

    private String name;
    private String city;
    private String street;
    private String zipcode;

}
  • 참고로, 꽤 중요한 내용인데, 컬렉션은 위 코드(private List<Order> orders = new ArrayList<>();)처럼 필드에서 초기화 하는게 가장 좋다. 우선 여러 가지 이유가 있다.
    • null Safety
    • 하이버네이트가 엔티티를 영속화할 때, 컬렉션을 한번 자기들이 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경을 해버린다. 그래서 컬렉션 내부의 값이 변경이 됐는지 아닌지 이런 체크도 자기들 소스에서 유용하게 처리하고 어떤 정상적인 흐름을 수행할 수 있다. 그런데 만약, 영속시킨 이후에 내가 갑자기 setOrders() 같은 메서드를 호출해서 컬렉션을 초기화하거나 값을 바꾼다면? 그럼 하이버네이트가 컬렉션에 대해 지원하는 기능을 사용 못하게 된다. 이게 진짜 문제다. 
  • 그래서, 결론적으로 필드레벨에서 컬렉션을 초기화하는게 가장 깔끔하고 안전하다.
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

}

복습하자면, 다대일 양방향 연관관계는 없어도 그만인데 조회의 편리함을 위해서 작성해도 괜찮다고 했다. 테이블 관점에서는 외래키는 주문 테이블만 가지고 있다. 양방향은 연관관계의 주인을 설정해야 하고 주인은 테이블 관점에서 외래키를 가지고 있는 쪽(자식테이블, 위 예제에서는 주문 테이블)이 연관관계의 주인이 되면 된다. 주인이 아닌 쪽은 mappedBy 옵션으로 "나는 연관관계의 주인이 아니고, 주인 객체(Order)의 필드 중 `member` 라는 필드와 연관된 필드입니다." 라고 알려주면 된다.

 

 

결론을 미리 여기서 말하면, 다대일 단방향 또는 다대일 양방향으로 설계하는 것이 가장 좋고 일대다, 다대다는 그냥 사용을 하면 안 된다고 생각해라.

 

 

OneToMany

일대다는 테이블 관점에서는 존재할 수 없는 연관관계다. 일대다이므로, '일' 쪽에 외래키를 가지고 있는, 즉, 부모 테이블이 외래키를 가지고 있는 경우인데 테이블 관점에서는 존재할 수 없다. 그렇기 때문에 일대다 연관관계는 사용하지 않는 것이 좋다는 결론을 바로 위에 작성했다. 

 

우선 그림을 살펴보면 다음과 같다.

객체 관점에서는 이런 경우가 흔하다고 볼 수 있다. 팀에서만 멤버에 관심이 있고 멤버 입장에서는 팀에 관심이 없을 수 있다. 이게 객체 관점에서는 전혀 문제 될 게 없는데 테이블 관점에서는 있을 수 없다. 1:N 관계에서 언제나 N 쪽에 외래키가 포함되기 때문이다. 아래 사진이 테이블 관점에서의 ERD이다.

 

그래서 객체 관점에서는 가능한 부분이기 때문에 다음과 같이 작성할 수 있다.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}
  • @JoinColumn 부터 애매해진다. 외래키를 가지고 있는데 조인하는 필드도 본인의 ID라니! 그렇지만, 이렇게 해야 테이블 관점에서는 동작하기 때문에 어쩔 수 없다. 
package org.example.entity.mapping;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;
}

이렇게 작성하면 실제로 동작은 하는데 굉장히 부자연스럽다. 왜냐하면 테이블 관점에서는 팀에서 외래키가 존재하려야 할 수 없는데 지금 객체 관점에서는 존재하게 돼버리니 말이다. 각설하고 위처럼 일대다 단방향으로 설계를 했고 실제로 코드를 돌려보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.mapping.Member;
import org.example.entity.mapping.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Member member = new Member();
            member.setUsername("member1");
            em.persist(member);

            Team team = new Team();
            team.setName("team1");
            team.getMembers().add(member);
            
            em.persist(team);

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

이렇게 코드를 작성하고 돌려보면 문제 없이 테이블이 만들어지고 데이터가 들어갈 거다. 문제없이 잘 들어가는데 다음 SQL문을 보자.

이상하다. 왜 이상하냐면 멤버와 팀을 새로 만들고 persist() 호출을 했으니 INSERT문이 실행되는 건 맞는데 그다음이다. 난 분명 다음 코드처럼 팀의 멤버를 가져와 멤버를 추가했는데 SQL문에서 멤버의 UPDATE 쿼리가 실행된다. 

team.getMembers().add(member);

왜냐하면 테이블 관점에서는 절대 외래키를 부모가 관리하지 않기 때문에 결국 멤버를 추가해서 팀과 결합시켰으면 멤버가 속한 팀 FK를 업데이트해줘야 하는 것이다. 이게 이제 문제가 발생할 여지가 크다. 어떤 문제냐면 코드를 보기에는 팀을 가져와 작업을 했는데 쿼리문에서 멤버가 업데이트되니까 이해하기가 힘든 것이다. (설령 JPA를 아주 잘 다루더라도 헷갈릴 소지가 있다 코드가 커지고 시간이 지나면 지날수록)

 

그러니까 결론은 그냥 다대일 단방향, 양방향을 사용하자... 

일대다 양방향도 있는데 그냥 넘어가겠다.. 그걸 알 필요가 없다. 다시 한 번 말하지만 다대일 단방향, 양방향을 사용하자.

 

OneToOne

이 일대일 관계는 주 테이블이나 대상 테이블 중에 외래키를 선택해서 넣을 수 있는 관계이다. 여기서 주 테이블, 대상 테이블은 약속된 단어가 아니라 자주 사용되는 테이블을 주 테이블이라고 칭하고 그보다 덜 사용되는 테이블을 대상 테이블이라고 말한 것이다. 

다만, 외래키에 데이터베이스 유니크 제약조건을 추가해야 한다. (꼭 추가해야 하는 건 아니지만 추가를 하는 게 관리하기 훨씬 유리하다)

 

일대일은 어떤쪽으로 외래키를 넣어도 상관없다. 위 ERD처럼 MemberLocker의 일대일 관계에서 멤버에서 외래키를 관리하게 설정하면 된다. 객체 관계에서도 마찬가지로 다음 그림처럼 만들면 된다.

 

나는 주 테이블을 Member 테이블로 선정하고 (주 테이블은 더 자주 사용되는 테이블이라고 생각하면 된다) 대상 테이블을 Locker 테이블이라고 했을 때 주 테이블에 외래키를 가지는 방식으로 설정해 일대일 단방향 관계를 객체로 구성하면 다음과 같다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

 

일대일 양방향 관계는 다음처럼 현시점은 연관관계의 주인(외래키를 가지고 있는 쪽)이 Member다. 그러니까 Locker에 다음과 같이 코드를 추가한다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member
}

이러면 일대일 양방향 관계는 끝이 난다. 당연히 양방향이니까 연관관계의 주인을 선택해야 하고 주인이 아닌 Locker 쪽은 mappedBy로 알려주어야 한다.

 

 

이제 단방향으로 외래키가 대상 테이블에 존재하는 경우를 살펴보면 다음 그림처럼 생겼다.

이것 또한 가능한데, 한 가지 주의할 점은! 만약 객체 관점에서 주 테이블에 외래키를 가지고 있는데 테이블 관점에서 대상 테이블에 외래키가 존재하는 경우는 JPA는 지원하지 않는다. 즉, 객체 관점에서 Member 객체가 Locker를 참조하고 있는데 테이블 관점에서는 LockerMEMBER_ID를 가지고 있는 경우를 말한다. 이런 경우는 JPA에서 지원하지 않으니까 조심해야 한다.

 

그래서, 혹시라도 테이블 관점에서 대상 테이블에 외래키가 존재할 때, 객체 관점에서 주 객체(테이블)에 대상 객체(테이블)를 참조하고 싶으면 무조건 일대일 양방향으로 설계해야 한다. 이 방법뿐이 없다. 

 

양방향은 위 주 테이블이 Member일 때 했던 것처럼 반대로 해주면 된다.

 

일대일 단방향, 양방향은 그냥 주 테이블 또는 대상 테이블 둘 중 하나에 외래키가 들어가면 끝나는 건데 여기서 좀 짚고 넘어갈 부분이 있다. 

 

⭐️ 일대일 양방향 연관관계에서 외래키가 대상 테이블 일 때는 지연 로딩처리가 불가능하다.

 

이를 이해하기 위해 주 테이블에 외래키가 있을 때를 살펴보자.   

주 테이블에 외래키를 보관하는 설계를 했을 때(양방향, 단방향 상관없음) 멤버를 조회하면 LOCKER값은 NULL 또는 특정 값이 존재할 거다. 멤버 테이블에 LOKCER_ID가 있고 멤버가 LOCKER가 할당된 상태라면 NULL값이 아니겠고 할당되지 않은 상태라면 NULL일 테니까. 그럼 할당된 상태일 때 지연 로딩이면 그대로 Lazy Fetch를 이행하면 되고 NULL이면 값이 없으니까 그냥 NULL을 반환하면 된다. 

 

근데 대상 테이블에 외래키가 있는 일대일 양방향 연관관계는 그게 불가능하다. 무슨 소리냐면 만약 아래 그림처럼 대상 테이블에 외래키가 있을 때를 살펴보자.

 

만약 위처럼 대상 테이블에 외래키가 있을 때 멤버를 조회했다고 가정해 보자. 멤버만 조회했을 때 바로 LOCKER와 연결된 상태인지 알 수 있을까? 없다. 멤버 테이블을 봐라. 멤버 테이블엔 LOCKER 관련 외래키가 없다. 그래서 JPA는 결국 LOCKER 테이블을 조회해서 현재 조회한 멤버의 PK와 동일한 멤버 ID가 있는지 확인을 해야 한다. 즉, 어차피 LOCKER를 조회하기 때문에 지연 로딩을 해봤자 아무런 이점을 가질 수 없단 소리다. 그래서 지연 로딩이 의미가 없고 지연 로딩으로 적용을 해도 바로 데이터를 받아온다. 

 

그래서 만약 일대일 관계를 사용하는 경우엔 주 테이블에 외래키를 보관하는 게 데이터베이스 관점 말고 개발자 관점에서 좀 더 유리할 수 있다. 근데 이건 객체 관점이고, 데이터베이스 관점에서는 조금 다르다. 예를 들어, 멤버 테이블에 외래키를 가지도록 테이블 관점에서 설계를 최초에는 했는데, 이후에 갑자기 일대일이 아니라 "멤버가 여러 라커를 가질 수 있다"라는 비즈니스 규칙이 변경되면, 그땐 데이터베이스의 테이블을 수정해야 하는데 그게 만만치가 않기 때문에 (왜냐하면 테이블 관점에서는 1:N에서 N쪽에 외래키가 반드시 있게 되는데 최초 설계는 멤버에 외래키가 있는 상태이니까) DBA는 이렇게 멤버에 외래키가 있는게 싫을 수도 있다. 그래서 이건 서로간의 충분한 협의가 필요하다. 근데 그냥 멤버에 외래키를 제안하고 DBA가 싫다고 하면 그냥 싸우지말고 말 들어주자. 객체입장에서 일대일 양방향으로 만들면 되는데 데 싸우는 시간이 아깝다.

 

ManyToMany

이건 쓰면 안 된다. 그냥 이게 결론이다. 근데 왜 쓰면 안 되는지를 알아보자. 

우선 데이터베이스에서 다대다 관계는 없다. 즉, 다대다 관계를 두 개의 테이블이 하나의 새로운 테이블(그 두 테이블을 연결해 주는)과 1:N 관계로 만들어야 한다. 아래 그림처럼 말이다.

근데 이게 왜 안되냐면, JPA에서 중간 테이블에 필요한 데이터가 들어가질 않는다. 예를 들어 위 상황에서 주문 수량이나 주문 일자 같은 데이터가 필요할 수 있는 가능성이 농후한데 그 데이터를 연결 테이블이 가질 수 없다. 아무것도 넣지 못한다. 각 테이블의 외래키 말고는.

 

그래서 다대다를 해결하는 방법은 연결 테이블을 새로운 엔티티로 승격시켜 사용하는 것. 다음 그림처럼 말이다.

그래서 위처럼 연결 테이블(MemberProduct)을 엔티티(ORDER)로 승격시켜 다대다를 일대다, 다대일로 풀어라.

그리고, 데이터베이스에서는 이렇게 풀 때, 중간 테이블의 PK를 양 옆 두 테이블의 FK를 하나의 PK로 만드는 경우가 종종 있는데 그러지 말고 그냥 이 테이블만의 고유 PK(ORDER_ID)를 만드는게 운영면에서 훨씬 유리하다. 

 

 

그럼 예시를 들어보자. 자주 사용되는 Follower, Following 같은 경우는 어떻게 해야 할까? 느낌은 ManyToMany인데 그렇게 사용하지 않기로 했다. 이 역시 마찬가지로 엔티티 하나를 승격시켜서 사용하면 된다. 다만, 여기서는 OneToMany, ManyToOne으로 풀 게 아니다. 왜냐하면 테이블이 두 개가 아니라 같은 멤버 테이블 하나일 테니까. 그래서 OneToMany, OneToMany로 풀면 된다.

 

이런 형태로 만들 수 있겠다. 코드를 작성해 보자.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToMany(mappedBy = "from")
    private List<Followers> following = new ArrayList<>();

    @OneToMany(mappedBy = "to")
    private List<Followers> followers = new ArrayList<>();
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Followers {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "FOLLOW_ID")
    private Member from;

    @ManyToOne
    @JoinColumn(name = "FOLLOWER_ID")
    private Member to;
}

 

⭐️팔로워라는 다대다 테이블을 엔티티로 승격시킨 후, from, to 유저를 @ManyToOne으로 선언한다. 이제 이 외래키를 관리하는 쪽이 연관관계의 주인이 된다. 그럼 반대쪽 멤버 테이블에서는 @OneToManyfollowing(팔로우 하는 사람), followers(나를 팔로우하는 사람) 읽기 전용(mappedBy)으로 선언하면 된다. mappedBy는 반드시 이 애노테이션이 달린 필드로 선언된 엔티티의 실제 필드 이름을 가져야 하고, @JoinColumnname은 의미있는 이름을 직접 만들어주어도 괜찮다(FK를 다른 이름으로 만들어도 상관없으니까).⭐️

 

 

실전 예제

그러면, 지금까지 배운 모든 내용을 토대로 한번 엔티티 설계를 해보자. ERD는 다음과 같다.

 

논리적 설계는 이렇게 생겼다.

 

엔티티의 상세 내용은 다음과 같다.

 

이 모양을 토대로, 엔티티를 JPA를 활용해서 만들어보자. 참고로, 저기서 다대다도 있다. 다대다는 당연히 사용하면 안된다! 그런데, JPA 스펙에서 지원은 하는 내용이니까 이번 실전 예제에서만 한번만 다뤄보기로 하자.

 

Member

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;
    private String city;
    private String street;
    private String zipCode;
    
    @OneToMany(mappedBy = "member")
    private List<Orders> orders = new ArrayList<>();
}

 

Orders

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Orders {

    @Id
    @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    private LocalDateTime orderDate;
    private Status status;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToOne
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}

 

Delivery

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Delivery {

    @Id
    @GeneratedValue
    @Column(name = "DELIVERY_ID")
    private Long id;

    private String city;
    private String street;
    private String zipCode;
    private Status status;
}

 

OrderItem

package cwchoiit.relationship;

import javax.persistence.*;

@Entity
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "ORDERITEM_ID")
    private Long id;

    private Integer orderPrice;
    private Integer count;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Orders orders;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Item {

    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private Integer price;
    private Integer stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category {

    @Id
    @GeneratedValue
    @Column(name = "CATEGORY_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();

    @ManyToMany
    @JoinTable(name = "CATEGORY_ITEM",
            joinColumns = @JoinColumn(name = "CATEGORY_ID"), inverseJoinColumns = @JoinColumn(name = "ITEM_ID"))
    private List<Item> items = new ArrayList<>();
}

 

 

이렇게 만들면 된다. 재밌는 부분은 Category의 경우, 카테고리는 상위 카테고리 라는게 존재하는게 일반적이다. 그래서 parentchild가 모두 자기 자신의 타입으로 되어 있고, 각자 카테고리가 부모와 자식 카테고리를 가질테니 parent, child가 있다. 

 

그리고, @ManyToMany는 사용하면 안된다. 그러나 한번만 해보기위해서 이렇게 사용했다. 다대다는 @JoinTable 애노테이션을 사용해서 중간 테이블이 하나 생성된다고 했다. 그리고 그 테이블은 다른 필드 하나 없이 양 옆 테이블의 PK만을 가질 수 있다. 그래서 joinColumns, inverseJoinColumns의 값으로 CategoryItem 두 PK를 선언한다.

 

 

이렇게 해보면 되는데 역시 다대다는 사용하면 안된다고 했으니 이를 중간 테이블을 엔티티로 승격시켜서 다대일, 일대다로 풀어보자.

 

Category

package cwchoiit.relationship;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Category {

    @Id
    @GeneratedValue
    @Column(name = "CATEGORY_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();
}

 

CategoryItem

package cwchoiit.relationship;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class CategoryItem {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "CATEGORY_ID")
    private Category category;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private LocalDateTime created;
}

 

Item

package cwchoiit.relationship;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Item {

    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private Integer price;
    private Integer stockQuantity;
}

 

이렇듯, ItemCategory 사이에 있는 테이블을 엔티티로 승격시킨 CategoryItem이 존재하게 되고, 이 엔티티가 ItemCategory와 다대일, 일대다 관계를 가지도록 변경하면 된다. 원한다면 양방향으로 만들어도 상관없다. 

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.25 업데이트


 

데이터베이스와 자바의 객체를 매핑하는 것을 이전 시간에 공부했는데 데이터베이스와 자바의 객체는 한 가지 이질적인 부분이 있다.

데이터베이스는 한 테이블에서 다른 테이블을 참조할 때 외래키를 이용하여 어떤 레코드를 참조하는지만 표현하는 반면, 객체 지향적이란 건 객체가 다른 객체를 참조할 수 있어야 객체 지향적이라고 할 수 있다.

 

객체 지향적인 설계가 복잡하고 어려운 내용인것 같다. 공부하고 공부해도 모자란 부분이 이 부분인 것 같지만 한 가지 확실한 건 다음과 같은 설계가 객체 지향적이라고 볼 순 없을 것 같다.

 

Member

package org.example.domain;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
    private String city;
    private String street;
    private String zipcode;

}

 

Order

package org.example.domain;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    @Column(name = "MEMBER_ID")
    private Long memberId;
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

}

이렇게 두 개의 엔티티가 있고 쇼핑몰에 대한 데이터베이스를 설계할 때 멤버(유저)는 주문을 할 수 있을 것이고 주문 정보에는 어떤 유저가 주문을 했는지에 대한 정보가 있을 것이다. 그래서 위와 같이 Order Entity에 유저의 PK유저 ID를 필드로 설정했다고 하면 이는 데이터 중심 설계를 했다고 봐야 한다. 데이터베이스가 추구하는 다른 테이블을 참조하는 방식인 외래키를 사용하는 방법.

 

그러나, 이 코드를 객체 관점으로 보게되면 주문 객체에서 유저 객체를 참조하지 않고 유저 객체를 참조할 수 있는 ID만을 가지고 있는 상태이기 때문에 객체 지향적 설계와 거리가 있다고 보는 게 더 합리적이다.

 

실제로 이런 설계를 가진 애플리케이션이라면 애플리케이션에서 주문 정보로부터 유저 정보를 알아내려면 주문 정보를 가져온 후 주문 정보에 담겨있는 유저의 PK를 찾아내서 다시 한 번 유저를 가져와야 한다. 다음 코드를 보자.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Order findOrder = em.find(Order.class, 1L);
            
            Long findOrderMemberId = findOrder.getMemberId();
            
            Member findMember = em.find(Member.class, findOrderMemberId);
            System.out.println("findMember = " + findMember.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드에서 엔티티 매니저로부터 주문 정보를 가져온 후 주문 정보로부터 다음과 같은 코드는 사용할 수가 없다.

Member orderMember = findOrder.getMember();

왜냐하면 주문 정보는 유저를 참조하지 않기 때문이다. 이는 객체 지향적인 설계가 아닌 데이터 지향적 설계가 됐기 때문이다.

객체 지향적 설계를 하기 위해서는 이를 변경해야 한다. 그리고 그 시점에서 우린 단방향 연관관계를 배울 수 있다.

 

단방향 연관관계

단방향 연관관계란, 한쪽에서만 다른 쪽을 참조한 상태를 말한다. 예제에서는 주문 정보에서만 유저를 참조하면 된다고 가정해보고 단방향 연관관계를 설정해 보면 다음과 같이 주문 엔티티를 변경할 수 있다.

 

Order - 변경

package org.example.domain;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    /*@Column(name = "MEMBER_ID")
    private Long memberId;*/
    
    // 변경한 부분
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
}

멤버와 주문 정보의 관계는 '일대다'이다. 즉, 멤버 한명이 많은 주문을 할 수 있다는 얘기다. 그러면 주문 엔티티에서는 멤버를 설정할 때 @ManyToOne 어노테이션을 붙여준다. 그리고 한가지 더 애노테이션이 있는데 @JoinColumn이다. ⭐️@JoinColumn 애노테이션이 달려있으면 "Order에서 Member를 가져오기 위해서 조인을 할건데 어떤 값(키)을 조인해야해?"를 작성해주면 된다. 그러니까 쉽게 말해 외래키를 추가하는 애노테이션이라고 보면 된다. 그리고! 통상적으로는 Member의 기본키 컬럼명이 MEMBER_ID라면 Order의 외래키 컬럼명도 MEMBER_ID라고 해주면 가장 아름답지만, 반드시 그 이름이 동일할 필요는 없다. 그러니까, 내가 여기서 @JoinColumn으로 외래키 명을 MID 또는 M_ID라고 해도 아무런 문제도 발생하지 않는다는 의미이다. 결국 조인할 때 Member의 기본키와 Order의 외래키를 가지고 조인하는건 달라지지 않으니까.⭐️

 

그럼 JPA로부터 Order 객체를 가져오면 그 안에는 연관된 멤버 객체가 딸려 오게 된다. (물론 이것도, 뒤에 배울 지연 로딩을 배우면 또 약간 달라지지만 일단은 이렇게 생각해두자)

 

이렇게 변경한다면 주문 정보를 찾아내면 그 주문을 한 유저도 객체 입장에선 바로 참조가 가능하다.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Order findOrder = em.find(Order.class, 1L);
            
            // 개선된 코드
            Member findMember = findOrder.getMember();
            System.out.println("findMember = " + findMember.getName());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

저 위에서 주문을 찾고 주문에 담긴 유저의 아이디를 가져와 다시 한 번 유저를 찾는 번거로움 없이 주문을 찾아내면 그 주문을 행한 유저도 바로 가져올 수 있게 코드가 개선되었다. 이렇게 설계가 되어야 객체 지향적 설계에 더 가깝다고 볼 수 있을 것 같다.

 

 

 

⭐️ 양방향 연관관계와 연관관계 주인

이 부분이 너무너무 중요하다. 객체 지향적으로도 중요하고 애플리케이션 관점에서도, 데이터베이스 관리 차원에서도 너무 중요하다.

우선, 위에서 Member, Order 엔티티를 살펴보면 Order에서만 Member를 참조할 수 있고 Member에서는 Order를 참조할 수 없게 되어 있는 '단방향 연관관계'이다. 

 

단방향 연관관계

MemberOrder 대신 좀 더 직관적으로 이해하기 좋게 MemberTeam으로 변경해 보자. 

위 그림은 단방향 연관관계를 표현한 그림이다. TeamMember가 객체로 보이는 부분이 위에 부분인데 TeamMember에 대한 참조가 없고 MemberTeam에 대한 참조가 있다. MemberOrder를 생각해 보면 동일하다.

 

테이블 관점으로 보면 테이블은 사실 방향이란 게 없는 것이다. 무슨 소리냐면 이 상태에서도 멤버는 TEAM_ID라는 외래키를 통해 본인의 팀을 조인으로 알아낼 수 있고 TEAM 입장에서도 본인의 PKMEMBER의 외래키 TEAM_ID를 조인해서 MEMBER를 가져올 수 있다. 즉, 한쪽만 참조 또는 양쪽 둘 다 참조라는 개념이 사실 없는 거다.

 

그러나 객체는 Member라는 객체가 Team을 참조하지만 Team에서는 그 어떤 방법으로도 자기한테 속한 Member를 가져올 방법이 없다. 이런 경우를 단방향 연관관계라고 했고 이제 양방향은 두 객체 모두 서로를 참조하는 경우를 말한다.

 

양방향 연관관계

양방향 연관관계는 이런 상태를 의미한다.

테이블은 그 전과 전혀 다른 게 없다. 정말 동일한 상태이고 객체 간 관계가 이렇게 변한다. 멤버에서도 팀을 참조하고 팀에서도 본인한테 속한 멤버들을 참조한다. 

 

그럼 기존 멤버 엔티티는 이렇게 생겼었다.

package org.example.entity;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" })
})
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Member {
    @Id
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "MEMBER_SEQ_GENERATOR")
    private Long id;

    @Column(nullable = false)
    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

멤버 입장에서는 팀과 다대일 관계다. 즉, 하나의 팀에 여러 멤버가 속할 수 있다는 뜻이고 그것을 객체로 표현하는 방법이 위와 같다.

@ManyToOne, @JoinColumn(name = "TEAM_ID") 어노테이션으로 Team을 참조한다.

 

그럼, Team 엔티티에서는 어떻게 멤버들을 참조할 수 있을까?

package org.example.entity;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

Team에 속한 멤버들을 저장할 ArrayList를 필드로 선언하고 그 필드에 @OneToMany 어노테이션을 추가했다. 팀과 멤버는 팀 입장에서 일대다 관계이기 때문에 OneToMany는 이해하기 어려운 부분은 아니다. 그러나 여기 mappedBy가 문제다.
우선 mappedBy = "team"이라는 건 이 애노테이션이 달린 필드 타입으로 선언한 Member 클래스에서 어떤 필드와 매핑될것이야?를 선언한 것이다. Member 클래스에서 team이라는 필드로 매핑할 것이니까 mappedBy = "team" 이렇게 작성했다. 근데 왜 여기에만 mappedBy 옵션이 있는지 이해하기 힘들다. 이때 '연관관계의 주인' 개념을 알아야 한다.

 

양방향 연관관계에서 연관관계 주인

우선 연관관계의 주인이라는 건 양방향 연관관계일때만 고려하면 된다. 자, 이제 주인이 누구인지를 따져보기 전에 양방향 연관관계에서 '객체 연관관계'와 '테이블 연관관계'를 좀 더 깊게 이해해 보자. 양방향 연관관계에서 객체 연관관계는 2개다.

  • 멤버 > 팀 연관관계 1개 (단방향)
  • 멤버 < 팀 연관관계 1개 (단방향)

즉, 양방향 연관관계에서 객체 연관관계는 그저 단방향 두 개가 존재하는 것이다.

 

그러나 테이블 연관관계는 위에서도 얘기했지만 방향이란 게 사실 없다. 그냥 서로가 서로를 참조할 수 있다. 실제로 팀에서는 테이블에서 멤버 관련 정보는 하나도 없지만 PK와 멤버의 외래키를 가지고 조인해서 속한 멤버를 구할 수 있지 않나. 

  • 멤버 <> 팀 연관관계 1개 (양방향)

그래서 그림으로 살펴보면 다음과 같다. 

그럼 이런 상태에서 만약 멤버가 속한 팀을 변경하고 싶다면? 객체 관점에서는 어떤 객체를 변경해야 할까?

멤버의 팀을 변경해야 하나? 팀의 멤버를 변경해야하나?

뭔가 이상하다. 테이블의 관점에서는 부모 테이블은 아예 자식 테이블에 관련된 정보 자체가 없기 때문에 멤버가 속한 팀을 변경하려면 당연히 그냥 멤버의 특정 레코드의 팀 외래키를 변경하면 된다. 그럼 객체 관점에서는 어떤 걸 변경해야 할까? 테이블의 관점을 따라가면 된다. 

 

즉, 객체 관점에서 두 객체가 양방향으로 연관관계를 가질 때 연관관계의 주인을 지정하면 되는데 그 주인은 테이블의 관점에서 외래키가 있는 곳을 주인으로 정하면 된다. 그럴 때 이 예제에서 연관관계의 주인은 누가 되면 될까? 그렇다. 멤버가 되면 된다. 왜냐? 테이블의 관점에서 멤버가 외래키를 가지고 있기 때문.

 

그리고 테이블 관점에서 멤버가 속한 팀을 변경하려면 멤버의 외래키를 변경하면 된 것처럼 연관관계의 주인만이 값의 변경을 할 수 있다. 그리고 주인이 아닌 쪽은 읽기만 가능하다. 그래서 mappedBy가 달린 필드는 변경이 불가능하고 읽기만 가능하며, 주인은 mappedBy 속성을 사용하지 않는다. 반대로 주인이 아니면 mappedBy 속성으로 주인을 지정해줘야 한다. 

 

이제 다시 엔티티 클래스를 보면 이해가 된다.

package org.example.entity;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

테이블 관점에서 팀은 멤버 관련 필드는 아예 없다. 그 말은 외래키를 멤버가 가지고 있단 의미이고 외래키를 가지고 있는 쪽이 객체 관점에서는 연관 관계의 주인이 되면 되니까 주인이 아닌 이 Team 엔티티에서 mappedBy 속성으로 연관관계의 주인이 아니라고 알려주고, mappedBy = "team" 이라고 작성해서 이 애노테이션이 달린 필드의 타입 List<Member>Member 객체에 있는 team 필드에 의해 매핑된 데이터이다. 라고 알려주는 것이다.

 

그래서, 객체 관점에서 데이터를 수정 및 변경 작업을 할 때 연관관계의 주인 쪽에서만 변경이 가능하기 때문에 멤버가 속한 팀을 변경하려면 멤버 엔티티에서 team 필드값을 변경하면 되는 것이다. 아무리 팀 엔티티에서 멤버 리스트에 멤버를 추가해 봐야 DB에 추가되지 않는다. 

 

이 점을 이해해야만 왜 팀 엔티티의 멤버 리스트 필드에 멤버를 추가해도 DB에는 추가된 멤버가 반영되지 않는지를 알 수 있고 그렇게 알게 되면 멤버를 만들어서 멤버의 팀 필드에 팀을 추가해야만 DB에 추가된 멤버가 반영된다는 것을 알 수 있다.

 

그래서 아래 코드는 잘못된 코드인데 어디가 잘못된 코드인지 한번 확인해 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            em.persist(member);
            
            team.getMembers().add(member);

            em.flush();
            em.clear();

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

보면, Team 엔티티의 객체 team을 만들었고 team.getMembers().add(member); 라는 코드가 한 줄 보인다.

이 부분이 잘못된 코드이다. 왜냐하면 위에서 말한 이 teammembers라는 리스트는 mappedBy로 읽기 전용 필드이다. 즉, JPA가 데이터베이스에 반영할 때 전혀 관심사항이 없는 데이터라는 의미이다. 실제로 위 코드로 실행해보면 데이터베이스에 해당 멤버의 팀 값은 없다. 아래는 그 결과물이다. 

 

 

따라서, DB에 새로운 멤버를 팀에 추가하고 싶다면 다음과 같은 코드로 변경되어야 한다.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            em.flush();
            em.clear();

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

연관관계의 주인인 memberteam에 적용할 팀을 세팅해줘야 한다. 그래야 JPA는 데이터베이스에 변경사항을 반영할 때 적용해 준다.

 

그럼 여기서 한 가지 의문이 생긴다. 

연관관계의 주인쪽에만 데이터를 넣으면 JPA가 데이터베이스에 쓰기 지연 SQL문을 날려서 실제 데이터베이스에 적용되기 전까진 team.getMembers()를 가져오면 데이터는 없는 것 아닌가?

맞다! 정확하다! 코드로 그것을 증명해보면,

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            Team findTeam = em.find(Team.class, team.getId());
            System.out.println("findTeam.getMembers(): START");
            for (Member teamMember : findTeam.getMembers()) {
                System.out.println("teamMember = " + teamMember.getName());
            }
            System.out.println("findTeam.getMembers(): END");

            em.flush();
            em.clear();

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드는 연관관계의 주인인 memberteam에만 team값을 세팅한 후, flush()가 발생하기 전 또는 commit()이 이루어지기 전에 엔티티매니저로부터 team을 가져와서 (이 때 가져오는 건 1차 캐시로부터 가져온다. 왜냐하면 persist()commit()이 일어나지 않았기 때문에) 팀이 가지고 있는 모든 멤버를 출력하는 코드이다.

 

결과는 다음과 같다. 아무것도 출력되지 않는다.

 

그러니까 주의할 점은, 연관관계의 주인인 Member 클래스에 팀을 설정하고 DB에 값을 반영하기 전에(다른 말로 같은 트랜잭션 단위가 끝나기 전에, 또 다른 말로 의도적으로 flush()를 호출하기 전에) Teammembers에는 해당 멤버는 없다. 그래서 이 개념을 아주 확실히 알고 있는 상태면 상관이 없지만 그럴거라는 전제하에 개발을 하기보다는 그러지 않을것이라는 전제하에 개발을 진행해서 Member 객체에 팀을 넣어줄 때 Teammembers에도 의도적으로 같이 넣어주는 게 이후 예측하지 못한 버그를 마주하는 것에 있어 안전할 수 있다. 어차피 주인이 아닌쪽에 데이터를 수정해봐야 데이터베이스에는 아무런 영향도 끼치지 않기 때문에 데이터베이스에 문제가 생길 걱정 자체를 할 필요가 없다. 아예 JPA는 그 데이터를 쳐다도 보지 않을테니까.

 

가장 좋은 방식은, 편의 메서드를 만드는 것이다. 단순히 setTeam(team)을 호출하고 반대로 team.getMembers().add(member) 이렇게 복잡하게 두번을 하는게 아니라, changeTeam(team) 이런 메서드를 하나 만들어서 깔끔하게 처리하는 방식으로 말이다.

public void changeTeam(Team team) {
    this.team = team;
    this.team.getMembers().add(this);
}

마무리

진짜 결론은 양방향 연관관계는 없어도 무방하다는 것. 가장 중요한 건 단방향 연관관계만으로 애플리케이션을 충분히 만들 수 있고 단방향 연관관계만 있을 때 가장 깔끔하다. 양방향은 사실 조회를 위해 편의상 만드는 거지 그 외 더 이상 기능이 없다. 그래서, 가장 중요한 건 처음엔 단방향 연관관계로만 잘 설계를 하고 필요하면 (조회를 편하게 하고싶다면) 그 때 양방향을 걸어도 전혀 문제가 없다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

2024.10.25 업데이트


이제 JPA에서 역시 제일 중요한 부분 중 하나인 객체와 테이블 매핑에 대해서 알아보자. 

 

Member

package org.example.entity;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
        @UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // ! strategy는 크게 4가지가 있다.
    // ! IDENTITY = DB에게 기본키 생성을 위임
    // ! SEQUENCE = DB에 Sequence 오브젝트를 만들어내서 그 오브젝트에서 다음 값, 또 다음 값을 꺼내서 할당
    // ! TABLE = 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략
    // ! AUTO = 위 3개중에 아무거나 하나로 자동 지정
    private Long id;

    //! 컬럼 매핑
    @Column(nullable = false)
    private String email;

    //! 컬럼 매핑
    @Column(nullable = false)
    private String name;

    //! 날짜 타입 매핑
    private LocalDateTime createdAt;

    //! 날짜 타입 매핑
    private LocalDateTime lastModifiedAt;

    //! Enum Type (일반적으로 DB엔 ENUM이 없음)
    //! 기본이 EnumType이 ORDINAL인데 이건 데이터베이스에 ENUM의 순서를 저장하기 때문에 좋지 않음 STRING으로 쓸 것
    @Enumerated(EnumType.STRING)
    private RoleType roleType;

    //! VARCHAR 보다 더 큰 값이 들어가야 할 때
    @Lob
    private String description;

    //! 특정 필드를 컬럼으로 생성하는게 아니라 그냥 객체 안에서 메모리에서만 관리하고자 하는 경우
    @Transient
    private String temp;
}

 

 

  • @Entity 애노테이션이 붙은 클래스는 JPA가 관리하게 된다. 그 JPA가 관리하는 클래스를 엔티티라고 보통 칭한다.
  • JPA를 사용해서 테이블과 매핑할 클래스에는 이렇게 반드시 @Entity 애노테이션이 붙어야 한다. (JPA가 애플리케이션을 띄울 때, 이 애노테이션이 달린 클래스들을 관리하고 테이블로 만들고 등등의 리플렉션 작업이 일어나기 때문에)
  • 기본 생성자가 필수이다. 접근 제어자는 public 또는 protected 생성자로 가능하다. (이 역시도 JPA가 애플리케이션을 띄울 때, 동적으로 객체를 만들어야 하고 그때 리플렉션을 활용해서 기본 생성자로부터 객체를 만들기 때문에)
  • final 클래스, enum, interface, inner 클래스로는 엔티티를 만드는 것이 불가능하다.
  • 데이터베이스에 저장할 필드에 final 사용은 할 수 없다.

 

어 그럼 이 테이블을 어떻게 만드나요? 

직접 데이터베이스에서 만들면 그게 가장 좋다. 근데, 개발이나 테스트 환경에서는 굳이 그럴 필요 없이 빨리 진행하는 게 더 효율적일 수가 있는데, 그때 사용하는 옵션이 바로 `hibernate.hbm2ddl.auto`이다. 

 

먼저 이 옵션의 속성값들은 뭐가 있는지부터 살펴보자.

옵션 설명
create 기존 테이블 삭제 후 다시 생성 (DROP + CREATE)
create-drop create이랑 비슷한데, 종료 시점에 테이블을 DROP
update 변경분만 반영
validate 엔티티와 테이블이 정상 매핑되었는지만 확인,  정상적이지 않다면 에러를 발생시키고 애플리케이션 종료
none 사용하지 않음

 

이렇게 5가지 옵션을 사용할 수 있고, 개발상에서는 그냥 CREATE, UPDATE, CREATE-DROP을 사용해서 빨리 빨리 테이블을 만들면 더 효율적으로 사용할 수 있다.

 

위 사진과 같이 옵션을 주면 실행할 때, 우선 테이블을 싹 다 삭제한 후, 엔티티 클래스를 테이블로 새로 만들어 준다.

그럼 이 기능을 사용해서 개발할땐 빨리 빨리 진행이 가능해 지겠지. 테이블 만드는 스크립트까지 굳이 작성 안해도 되니까. 

 

그런데, 주의할점은 운영에서는 절대로 그 어떤 옵션도 사용하지말자.

굳이 굳이 사용할려면 가능한게 validate 정도이고, 나머지 옵션은 생각도 하면 안된다. 데이터가 어떻게 보면 회사에서 가장 중요한 정보이자 자산인데, 시스템이 자동화한 작업에 데이터를 믿고 맡길 수 있겠는가? 어떤 변수가 일어날지 알고 말이다.

 

@Column

가장 간단한 @Column 어노테이션부터 살펴보면, 이 어노테이션이 붙으면 필드를 컬럼과 매핑하겠다는 의미이다.

컬럼안에 옵션으로 nullable, unique, length 등 여러 옵션을 줄 수 있고, 이 옵션은 자바와는 상관없이 JPA에만 영향을 주는 것들이다.

이 애노테이션을 확인하는 부분이 JPA에서 리플렉션을 활용할 때이다. 그래서 어떤 제약조건이 있는지 체크해서 데이터베이스에 테이블을 만들거나 검증할 때 사용하는 거라서 자바 객체와는 연관은 없다! 

 

만약, 속성을 추가할 필요가 없으면 이 @Column은 굳이 사용하지 않아도 알아서 데이터베이스에 컬럼으로 잘 추가된다.

 

@Column 속성들

속성 설명 기본값
name 필드와 매핑할 테이블의 컬럼 이름 객체의 필드 이름
insertable 등록 가능 여부  true
updatable 변경 가능 여부 true
nullable (DDL) null 값의 허용 여부를 설정한다. false로 설정하면 DDL 생성시에 not null 제약 조건이 붙는다. true
unique (DDL) @TableuniqueConstraints와 같지만, 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용한다. false
columnDefinition (DDL) 데이터베이스 컬럼 정보를 직접 줄 수 있다.
예) varchar(100) default 'EMPTY'
 
length (DDL) 문자 길이 제약 조건, String 타입에만 허용 255
precision, scale (DDL) BigDecimal 타입에서 사용한다(BigInteger도 가능). precision은 소수점을 포함한 전체 자릿수를, scale은 소수의 자릿수다. 참고로 double, float 타입에는 적용되지 않는다. 아주 큰 숫자나 정밀한 소수를 다루어야 할때만 사용한다. precision = 19, scale=2 
(이렇게 설정하면 전체 자리수가 19자리의 숫자이며 소수점은 2자리까지 표시하겠다는 의미가 된다)

 

Timestamp

데이터베이스에 시간 관련 필드를 만들고자 하면 자바에서는 그냥 LocalDateTime 타입을 사용하면 된다.

 

@Enumerated

자바에서 ENUM 클래스를 사용해서 필드를 만들고 싶을 때가 더러 있다. 그러나 DB는 일반적으로 ENUM 타입은 존재하지 않고 그럴 때 이렇게 @Enumerated 라는 어노테이션을 붙이면 ENUM 클래스를 필드로 매핑할 수 있다.

 

여기서 중요한 건 EnumType.STRING 옵션이다. 왜 이게 중요하냐면 기본적으로 EnumTypeORDINAL이 기본값인데 이것을 사용하면 안된다. 이 값은 데이터베이스에 ENUM의 순서를 값으로 사용한다는 뜻인데 이건 큰 문제가 하나 있다. 

 

다음 코드를 보자. 

package org.example.entity;

public enum RoleType {
    USER, ADMIN
}

나는 두개의 ENUM Data가 있다. 이것을 데이터베이스에 필드로 선언하고 유저를 생성하는 코드를 짜보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            Member member = new Member();
            member.setName("helloC");
            member.setRoleType(RoleType.USER);
            em.persist(member);

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

RoleType.USER로 생성할 멤버의 롤 타입을 지정해서 유저를 생성했다. 데이터베이스에는 어떻게 들어갈까?

보다시피 0이라는 값으로 들어간다. 이번엔 ADMIN으로 생성해보자.

ADMIN1로 생성이 된다. 이 01ENUM의 순서를 의미한다. 근데 만약, 비즈니스 요구사항이 변경된다면 ?

"GUEST라는 롤도 추가해주세요"

 

이런 요구사항이 들어와서 이렇게 코드를 수정했다. 근데 하필 GUEST를 맨 앞에 추가했다!

package org.example.entity;

public enum RoleType {
    GUEST, USER, ADMIN
}

이제 GUEST를 추가하면 어떤 일이 일어날까?

그렇다. 0으로 추가된다. 왜냐하면 GUEST를 맨 앞에 두었으니까. 이게 ORDINAL의 가장 큰 문제다. 이를 방지하기 위해서 STRING 타입으로 꼭 변경해야 한다.

 

 

@Lob

VARCHAR(255)보다 큰 매우 큰 문자열이 필요한 경우 @Lob을 사용한다. 대부분은 이 경우에서 다 끝나는데 이 Lob은 두가지 타입이 있다. BLOB, CLOB.

 

매핑하는 필드 타입이 문자면 CLOB 매핑, 나머지는 BLOB 매핑이 된다.

  • BLOB → String, char[], java.sql.CLOB
  • CLOB → byte[], java.sql.BLOB

 

@Transient

데이터베이스에 컬럼으로 추가하고 싶진 않지만 객체 메모리 상에서 관리할 데이터가 있을 때 이 어노테이션을 사용하면 데이터베이스에는 컬럼으로 추가되지 않는다.

 

 

@UniqueConstraint

이건 뭐냐면 일반적으로 필드에 unique 속성을 추가하고 싶다면 그냥 @Column(unique = true)라고 추가하면 되는데 이게 주는 문제는 제약의 이름이 마구잡이로 생성된다. 한번 예시를 보자.

@Column(nullable = false, unique = true)
private String name;

이렇게 하고 데이터베이스 테이블을 만들어보면 다음과 같은 SQL문이 출력된다.

보다시피 알 수 없는 이름으로 제약조건이 생성된다. 난 이게 싫다. 로그를 보고 알아 볼 수 있어야 문제가 생겼을 때도 수월하게 해결이 가능하니까. 이를 해결하기 위해서 @UniqueConstraint 어노테이션으로 제약 조건의 이름을 직접 명시한다. 바로 아래처럼.

@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
        @UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})

저기서 name이 바로 제약조건의 이름이 된다. columnNames는 컬럼명을 의미한다.

이렇게 하고 다시 테이블을 만들게 해보면 다음과 같은 로그가 찍힌다.

 

 

@Id, @GeneratedValue

가장 중요한 부분인 기본키 생성과 전략이다. 우선 모든 테이블은 PK가 필요한데 그 PK를 생성하기 위해 @Id 라는 어노테이션을 붙인다. 그럼 JPA는 아 이 필드가 기본키가 될 녀석이구나라고 인식한다. 그럼 @GeneratedValue는 무엇일까? 기본키를 어떻게 생성할지에 대한 정보이다. 

 

@GeneratedValue 어노테이션은 기본키 생성 전략을 가지는데 그 전략은 크게 4가지가 있다.

 

- IDENTITY:  데이터베이스에게 기본키 생성을 위임 (MySQL의 auto_Increment 같은)

- SEQUENCE: 데이터베이스 시퀀스를 생성하고 그 시퀀스의 값을 기본키로 지정

- TABLE: 기본키를 위한 테이블을 하나 만들고 데이터베이스 시퀀스를 흉내내는 전략

- AUTO: DB 방언(Dialect)에 따라 자동 지정 (기본값)

 

여기서 중요한건 IDENTITYSEQUENCE다.

 

IDENTITY는 데이터베이스에게 완전 위임을 하는 것이기 때문에 데이터베이스에 데이터가 추가가 되기 전까지 INSERT 대상의 객체 기본키를 알 수 없다. 그럼 여기서 의문이 생긴다. 

분명히 엔티티 매니저가 persist()를 호출하면 영속 컨텍스트에 영속시키고 그 영속시킬 때 1차 캐시에 기본키를 저장하는데 INSERT가 되기 전까지 기본키를 알 수 없는 IDENTITY 전략은 어떻게 하지?

그래서!IDENTITY 전략은 persist()를 할 때 영속시키기 전 데이터베이스에 추가한다. 로그로 바로 확인해볼 수 있다. 

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            Member member = new Member();
            member.setName("helloCCC");
            member.setId(1L);
            member.setRoleType(RoleType.GUEST);
            member.setEmail("test33");
            
            System.out.println("BEFORE");
            em.persist(member);
            System.out.println("AFTER");
            
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드를 실행해보자. 지금 멤버에 엔티티에 @GeneratedValue 애노테이션을 아예 사용하지 않은 상태다. 즉, 내가 직접 지정한다는 의미이다. 이 상태에서는 절대 절대 트랜잭션 커밋이 일어나기 전까지 SQL문이 보이지 않을 것이다.

로그는 BEFORE, AFTER가 찍히고 난 후 INSERT문이 실행됐다. 당연하다. persist()는 영속 컨텍스트에 영속시킬 뿐 데이터베이스에 뭔가를 작업하는 단계가 아니니까. 

 

근데 여기서 전략을 IDENTITY로 해보면 어떨까? persist()를 호출할 때 INSERT문이 보일것이다.

보다시피 `BEFORE``AFTER` 사이에 INSERT문이 실행됐다. 즉, 영속 컨텍스트에 객체를 담기 위해선 기본키가 필요하고 IDENTITY 전략은 데이터베이스에 추가되기 전까지 기본키 값을 알 수 없기 때문에 내부 메커니즘이 IDENTITY일 경우 persist()를 호출하면 데이터베이스에 항상 SQL문을 날리는 것이다. 이 말은 INSERT 문에 한하여 쓰기 지연 SQL문은 IDENTITY에서 없다는 뜻이다. 

 

 

반면, SEQUENCE 데이터베이스의 시퀀스를 만들고 그 시퀀스를 가지고 기본키를 적용하는 전략이다. 그럼 생각해보자. 역시나 영속 컨텍스트에 객체를 보관하기 위해 기본키가 필요하고 전략이 SEQUENCE라면 persist()가 호출될 때 데이터베이스의 시퀀스를 조회하지 않을까? 맞다. (참고로 TABLESEQUENCE랑 동일하게 작동한다 이 부분에서)

 

아래 사진은 전략을 SEQUENCE로 변경 후 새로운 멤버를 추가했을 때 로그 출력 결과다.

persist()가 호출될 때 시퀀스로부터 다음값을 먼저 불러온다. 왜냐하면 그래야 persist(member)가 호출될 때 이 멤버의 기본키를 알 수 있기 때문이다.

 

그럼 이 SEQUENCE 전략에선 이런 생각을 할 수 있다.

그럼 시퀀스를 가져오는 것 한 번, 데이터베이스에 INSERT 쿼리를 날리는 것 한 번해서 쓸데없이 두번 왔다 갔다 해야 하잖아? 성능에 좋지 않겠다.

어느 정도 합리적이다. 그러나 이에 대한 성능 최적화를 위해 allocationSize라는 속성이 있다.

@Entity
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
}

이렇게 시퀀스를 생성할 때 이름도 지정하고 기본값들을 지정할 수 있는 @SequenceGenerator 어노테이션이 있는데 여기서 allocationSize100으로 지정하면 메모리가 미리 시퀀스 100개를 가지게 되는 것과 동일하다.

 

그러면 영속 컨텍스트에 객체를 보관하기 위해 시퀀스 값을 가져오는 횟수는 객체를 저장한 개수가 100개가 될 때까지 딱 2번이다.

두번이냐면, 우선 시퀀스는, 내가 allocationSize를 100으로 설정하면 시퀀스값을 호출할 때 데이터베이스 시퀀스가 100개씩 증가한다. 호출을 아예 한번도 하지 않으면 1인 상태이다. 그러면 애플리케이션에서 최초 호출을 했을 때 1을 받는다. 근데 allocationSize를 100으로 할당했는데 1이 나왔으니, "아 지금 최초 호출도 하지 않은 상태이구나!?"로 애플리케이션이 판단하고 한번 더 호출한다. 그럼 그때 데이터베이스 시퀀스가 100으로 증가하면서 메모리에 1부터 100까지를 미리 올려놓는다. 이후부터는 같은 객체에 대해 시퀀스값을 가져오는건 데이터베이스를 거치지 않고 메모리에 올라와있는 값을 사용하면 되는것이다. 언제까지? 100을 사용할때까지.

 

그래서 멤버가 100명이 될 때까지 시퀀스를 땡겨오지 않는다. 이것도 역시 로그로 직접 확인해보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            System.out.println("BEFORE");
            Member member = new Member();
            member.setName("helloCCC");
            member.setRoleType(RoleType.GUEST);
            member.setEmail("test33");

            Member member2 = new Member();
            member2.setName("helloBBB");
            member2.setRoleType(RoleType.GUEST);
            member2.setEmail("test22");

            Member member1 = new Member();
            member1.setName("helloAAA");
            member1.setRoleType(RoleType.GUEST);
            member1.setEmail("test11");

            em.persist(member);
            em.persist(member2);
            em.persist(member1);

            System.out.println("AFTER");

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

자, 위 사진에서 볼 수 있듯 시퀀스의 값은 두 번만 호출했다. 이제 100개의 시퀀스가 채워질 때 까지 더 이상 시퀀스를 데이터베이스로부터 가져오지 않는다. 이렇게 성능 최적화도 가능하다.

 

728x90
반응형
LIST

+ Recent posts