1. 기본값 타입

1)  JPA의 타입 분류

 

JPA에서는 타입을 크게 2가지로 분류하는데

@Entity로 정의 하는 객체인 엔티티 타입

하나는 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본타입이나 객체인 값 타입이다.

 

값 타입은 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 나뉜다.

 

기본값 타입자바 기본타입, 래퍼클래스, String을 포함 하는 값 타입이고

임베디드 타입은 복합 값 타입 이라고 하며, 사용자가 직접 정의하는 값 타입이며

컬렉션 값 타입기본값, 임베디드 타입을 컬렉션으로 묶은것이 컬렉션 값 타입이다.

 

2) 값 타입 분류

 

1 - 기본값 타입

  • 예 : String name, int age
  • 생명주기를 엔티티에 의존
    • 예 : 회원을 삭제하면 이름, 나이 필드도 제거됨
  • 값 타입은 공유하면 x
    • 예: 회원 이름 변경 시 다른 회원의 이름도 함께 변경되면 안됨
  • 참고 : 자바의 기본 타입은 절대 공유X
    • - int double 같은 기본 타입은 절대 공유 X
    • - 기본 타입은 항상 값을 복사함
    • - Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능하능한 객체이지만 변경 X

int, double같은 기본 타입은 절대 공유되지 않는다. 그렇기에 값타입으로 썼을 때 안전하다.

@Test  
void valueTest() {  
int a = 10;  
int b = a;  
  
a = 20;  
  
System.out.println("a = " + a);  
System.out.println("b = " + b);  
}

------------------------------------
a = 20
b = 10

a와 b는 저장공간을 따로 가지고 있어서

b의 값은 a가 10일때 초기화되고
a는 20으로 할당되어

a = 20 , b = 10이 된다.

이렇듯 기본형은 값이 공유되지 않는다.

그래서 부수효과가 일어나지 않는다.

래퍼 클래스같은 경우,  참조값을 공유하지만, 변경이 불가능하기때문에 안전하다.

 

 

2 - 임베디드 타입

  •  새로운 값 타입을 직접 정의할 수 있음
  • JPA는 임베디드 타입(embedded type) 이라고 함
  • 주로 기본 값 타입을 모아 만들어서 복합 값 타입이라고도 함
  • int, String과 같은 값 타입
  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수

2-1 임베디드 타입의 장점

  • 재사용
  • 높은 응집도
  • Period.isWork() 처럼 해당 값 타입만 사용하는 의미 있는 메서드를 만들 수 있음
  • 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함 (값 타입이기 때문에)

임베디드 타입은 객체이기 때문에 데이터 뿐만 아니라 메서드까지 가지고 있기 때문에 거기서 오는 이득이 많다.

임베디드 타입 자체에는 @Embeddable을, 임베디드 타입을 사용할 엔티티의 속성에는 @Embedded를 붙인다.

@Entity  
@Getter  
@Setter  
public class Member {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
@Column(name = "username")  
private String username;  
@Embedded  
private Period workPeriod;  
@Embedded  
private Address homeAddress;


@Embeddable  
@Getter  
@Setter  
@NoArgsConstructor  
public class Address {  
private String city;  
private String street;  
private String zipcode;  
  
public Address(String city, String street, String zipcode) {  
this.city = city;  
this.street = street;  
this.zipcode = zipcode;  
}  
}


@Embeddable  
@Setter  
@Getter  
@NoArgsConstructor  
public class Period {  
private LocalDateTime startDate;  
private LocalDateTime endDate;  
  
public Period(LocalDateTime startDate, LocalDateTime endDate) {  
this.startDate = startDate;  
this.endDate = endDate;  
}  
}

이러한 값 타입은 불변객체여야 하기 때문에 Setter대신 생성자를 통해 새로운 값을 만들어야 한다.

 

int 같은 기본형 타입은 애초에 참조가 존재하지 않아서 늘 새로운 값이 저장되므로 중간에 어떠한 값이 바뀌더라도 그것을 이용한 다른 값들이 변경되지 않지만,

 

임베디드 타입은 객체이므로 참조를 통해 값을 가져오기 때문에 중간에 값이 변경되면 그것과 연관되는 다른값들도 부수효과가 일어나기 때문에, 이러한 값타입들은 불변객체여야 한다.

Member member =new Member();  
member.setHomeAddress(new Address("city","street","1000"));  
member.setWorkPeriod(new Period(LocalDateTime.now(),LocalDateTime.now()));  
em.persist(member);

 

이처럼 따로 테이블이 생성되지 않고 member의 컬럼으로써 동작한다.

Hibernate: 
    create table member (
        end_date datetime(6),
        member_id bigint not null,
        start_date datetime(6),
        team_id bigint,
        city varchar(255),
        street varchar(255),
        username varchar(255),
        zipcode varchar(255),
        primary key (member_id)
    ) engine=InnoDB
Hibernate: 
    /* insert for
        com.example.jpa.domain.Member */insert 
    into
        member (city,street,zipcode,username,end_date,start_date,member_id) 
    values
        (?,?,?,?,?,?,?)

 

2-2 임베디드 타입과 테이블 매핑

  • 임베디드 타입은 엔티티의 값일 뿐이다.
  • 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
  • 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능
  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음

2-3 @AttributeOverride

@AttributeOverride임베디드타입의 속성을 재정의하며, 엔티티 내에서 같은 임베디드 타입을 쓸 때 컬럼이 중복되지 않게 컬럼명을 변경할 수 있다.

@Entity  
@Getter  
@Setter  
public class Member {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
@Column(name = "username")  
private String username;  
@Embedded  
private Period workPeriod;  
@Embedded  
private Address homeAddress;  
@Embedded  
@AttributeOverrides({  
@AttributeOverride(name="city",  
column=@Column(name = "work_city")),  
@AttributeOverride(name="street",  
column=@Column(name = "work_street")),  
@AttributeOverride(name="zipcode",  
column=@Column(name = "work_zipcode"))  
})  
private Address workAddress;  
  
}

이렇게 임베디드 타입인 homeAddress의 컬럼과, workAddress의 컬럼이 구분되어 쿼리가 작성된다.

Hibernate: 
    /* insert for
        com.example.jpa.domain.Member */insert 
    into
        member (city,street,zipc5de,username,work_city,work_street,work_zipcode,end_date,start_date,member_id) 
    values
        (?,?,?,?,?,?,?,?,?,?)

참고 : 임베디드 타입의 값이 null 이면, 매핑한 컬럼 값은 null이 된다.

 

3) 값 타입과 불변 객체

1 - 값 타입 공유 참조

 

임베디드 타입은 여러 엔티티에서 동시에 같은 인스턴스를 참조할 수 있다. 

Address address = new Address("city", "street", "1000");  
  
Member member1 =new Member();  
member1.setUsername("member1");  
member1.setHomeAddress(address);  
em.persist(member1);  
  
Member member2 =new Member();  
member2.setUsername("member2");  
member2.setHomeAddress(address);  
em.persist(member2);  
  
member1.getHomeAddress().setCity("newCity");

member1의 Address의 값을 변경했으나, address라는 참조를 member2도 같이 사용하므로 값이 공유된다.

결론적으로 같은 임베디드 타입 값을 공유하였기 때문에 사이드 이팩트가 난 것이다.

Hibernate: 
    /* update
        for com.example.jpa.domain.Member */update member 
    set
        city=?,
        street=?,
        zipc5de=?,
        username=?,
        end_date=?,
        start_date=? 
    where
        member_id=?
Hibernate: 
    /* update
        for com.example.jpa.domain.Member */update member 
    set
        city=?,
        street=?,
        zipc5de=?,
        username=?,
        end_date=?,
        start_date=? 
    where
        member_id=?

그렇기에 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 굉장히 위험하다.
만약 이렇게 2개가 동시에 수정되는것을 의도하고자 한다면 임베디드 타입이 아닌 엔티티로 승격시켜야 한다.

 

2 - 값 타입 복사

 

 위처럼 같은 실제 인스턴스를 공유하는것은 위험하기 때문에

같은 값을 넣으려면 새로운 인스턴스를 만들어서 기존의 값을 복사해야 한다.

 

Address address1 = new Address("city", "street", "1000");  
  
Member member1 =new Member();  
member1.setUsername("member1");  
member1.setHomeAddress(address1);  
em.persist(member1);  
  
Address address2 = new Address(address1.getCity(), address1.getStreet(), address1.getZipcode());  
Member member2 =new Member();  
member2.setUsername("member2");  
member2.setHomeAddress(address2);  
em.persist(member2);  
  
member1.getHomeAddress().setCity("newCity");

 

3 - 객체 타입의 한계

  • 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
  • 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.
  • 자바 기본 타입에 값을 대입하면 값을 복사한다.
  • 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.
  • 객체의 공유 참조는 피할 수 없다.

기본 타입

int a = 10;
int b= a; // 기본 타입은 값을 복사
b = 4;

기본타입은 = 을 하면 값이 복사가 된다.
그래서 b의 값을 4로 변경하여도 a의 값은 유지된다.

 

객체 타입

Address a = new Address("old");
Address b = a; // 객체 타입은 참조를 전달
b. setCity("New")

여기서는 a,b 모두 같은 인스턴스를 가르키기 때문에 값이 변경되는 순간 a의 값도 변경된다.

 

4 - 불변 객체

  • 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단
  • 값 타입은 불변 객체(immutable object)로 설계해야 함
  • 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
  • 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됨
  • 참고 : Integer, String은 자바가 제공하는 대표적인 불변 객체

값을 변경하려면

address 자체를 통으로 새로 만들고, homeAddress 자체를 newAddress로 변경한다.

ddress address1 = new Address("city", "street", "1000");  
  
Member member1 =new Member();  
member1.setUsername("member1");  
member1.setHomeAddress(address1);  
em.persist(member1);  
  
Address newAddress = new Address("newCity", address1.getStreet(), address1.getZipcode());  
member1.setHomeAddress(newAddress);

 

4) 값 타입의 비교

 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.

  • 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 사용
  • 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
  • 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야함
  • 값 타입의 equals() 메서드를 적절하게 재정의(주로 모든 필드 사용)

address1.equals(address2)는 true가 나올까? false가 나온다.

Address address1 = new Address("city", "street", "1000");  
Address address2 = new Address("city", "street", "1000");  
  
System.out.println("(address1 == address2) = " + (address1 == address2));  
System.out.println("(address1 eq address2) = " + (address1.equals(address2)));

 

왜냐하면 equals의 디폴트 옵션이 == 비교이기 때문이다.

(address1 == address2) = false
(address1 eq address2) = false

우리는 지금까지 String을 사용했을 때 equals로 비교하면 비교가 잘 되었는데 어째서 false가 나온것일까?

이유는 String 객체 내부에는 이미 equals 가 오버라이드 되어 있지만, 우리가 커스텀한 임베디드 타입은

오버라이드 되어있지 않기 때문이다. 

 

그래서 String내부의 equals ()를 보면

아래처럼 == 비교로 구성된 것을 알 수 있다.

== 비교는 기본형을 비교 할 때에는 값을 비교하기 때문에 가능하다.

public static boolean equals(byte[] value, byte[] other) {
        if (value.length == other.length) {
            for (int i = 0; i < value.length; i++) {
                if (value[i] != other[i]) {
                    return false;
                }
            }
            return true;
        }
        return false;
    }

그러므로 Address 임베디드 타입을 비교하고자 한다면 equals를 오버라이딩 해야한다.

또한 hashCode도 같이 오버라이딩을 해줘야 한다.

 

A와 B의 값이 동등하다면, 이 둘의 해쉬코드도 같다고 봐야 하기 때문이다.

또한, HashMap, HashSet 같은 컬렉션들은 해당 타입의 해쉬코드 값을 기준으로 객체를 저장하기 때문이다.

 

참고로 A와 B가 서로 상관없는 타입이어도 해쉬코드는 같을 수 있다.

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

 

5) 값 타입 컬렉션

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요함
  • 참고 : 값 타입 컬렉션은 영속성 전이(CASCADE) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

관계형데이터베이스는 기본적으로 컬렉션을 담을수 있는 구조가 없다.


그래서 이러한 컬렉션 구조를 표현하려면
개별적으로 테이블을 생성해야 한다

값타입 테이블에는 식별자가 따로 존재하면 엔티티가 되어버리기 때문에 따로 두지 않는다.

@ElementCollection과 @CollectionTable을 이용한 엔티티구현

@Entity  
@Getter  
@Setter  
public class Member {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
  
@Column(name = "username")  
private String username;  
  
@Embedded  
private Address homeAddress;  
  
@ElementCollection  
@CollectionTable(name = "favorite_food", joinColumns =  
@JoinColumn(name = "member_id")  
)  
@Column(name = "food_name")  
private Set<String> favoriteFoods = new HashSet<>();  
  
@ElementCollection  
@CollectionTable(name = "address", joinColumns =  
@JoinColumn(name = "member_id"))  
private List<Address> addressesHistory = new ArrayList<>();  
  
}

실행시켜보면 다음처럼 쿼리가 나온다


member

Hibernate: 
    create table member (
        member_id bigint not null,
        team_id bigint,
        city varchar(255),
        street varchar(255),
        username varchar(255),
        zipcode varchar(255),
        primary key (member_id)
    ) engine=InnoDB

 

favoriteFoods

Hibernate: 
    create table favorite_food (
        member_id bigint not null,
        food_name varchar(255)
    ) engine=InnoDB

 

address

Hibernate: 
    create table address (
        member_id bigint not null,
        city varchar(255),
        street varchar(255),
        zipcode varchar(255)
    ) engine=InnoDB

다음과 같이 테스트 코드를 작성한다.

값 타입 저장

Member member = new Member();  
member.setUsername("member1");  
member.setHomeAddress(new Address("homeCity","street","1000"));  
  
member.getFavoriteFoods().add("치킨");  
member.getFavoriteFoods().add("족발");  
member.getFavoriteFoods().add("피자");  
  
member.getAddressesHistory().add(new Address("oldCity1","street","1000"));  
member.getAddressesHistory().add(new Address("oldCity2","street","1000"));  
  
em.persist(member);

 

흥미로운 점은 값 타입 컬렉션을 따로 영속하지 않아도 member만 영속하니까 다른 테이블들도 저장이 되었다. 즉 라이프사이클이 다른 테이블임에도 불구하고 member와 같이 돌아가고 있는것이다.


값 타입 컬렉션도 본인 스스로 라이프 사이클이 없다.
값 타입 컬렉션도 결국은 값 타입이기 때문에 라이프 사이클을 엔티티에 의존한다.

 

또한 이런 값 타입 컬렉션은 기본이 지연로딩이라서 실제 데이터를 요청하기 전 까지는 데이터를 부르지 않는다.

 

참고로  컬렉션들은 대부분 대상을 찾을 때 equals 를 사용한다.  그래서 완전히 똑같은 equals 대상을 넣어주면 해당 값을 지울 수 있다.
그렇기 때문에 값 타입은 equals와 hashcode를 구현해 놓아야 한다.

 

값 타입 리스트 수정

Member member = new Member();  
member.setUsername("member1");  
  
member.getAddressesHistory().add(new Address("oldCity1","street","1000"));  
member.getAddressesHistory().add(new Address("oldCity2","street","1000"));  
  
em.persist(member);  
  
  
em.flush();  
em.clear();  
System.out.println("========================================");  
Member findMember = em.find(Member.class, member.getId());  
  
findMember.getAddressesHistory().remove(new Address("oldCity1","street", "1000"));

-------------------------------------------------------------------------------------
HashSet(FavoriteFoods) 경우 수정 코드 :
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

 쿼리를 보면  해당 멤버에 소속된 addressHistory의 값 전체를 다 지웠다. 그리고 이상하게도 insert문이 2번 나왔다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.member_id=?
Hibernate: 
    select
        a1_0.member_id,
        a1_0.city,
        a1_0.street,
        a1_0.zipcode 
    from
        address a1_0 
    where
        a1_0.member_id=?
Hibernate: 
    /* one-shot delete for com.example.jpa.domain.Member.addressesHistory */delete 
    from
        address 
    where
        member_id=?
Hibernate: 
    /* insert for
        com.example.jpa.domain.Member.addressesHistory */insert 
    into
        address (member_id,city,street,zipcode) 
    values
        (?,?,?,?)
Hibernate: 
    /* insert for
        com.example.jpa.domain.Member.addressesHistory */insert 
    into
        address (member_id,city,street,zipcode) 
    values
        (?,?,?,?)

1 -  값 타입 컬렉션의 제약사항

  • 값 타입은 엔티티와 다르게 식별자 개념이 없다.
  • 값은 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 데이터를 모두 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 함: null 입력 x, 중복 저장 x

값 타입 컬렉션에서 수정을 하려면 결국 해당 id에 대한 값을 모두 지우고 다시 리스트에 있는 값을 저장하기 때문에 효율적이지 않다.

 

결론적으로 정말 단순한 것 외에는 실무에서 그대로 사용하기는 어렵다

 

2 - 값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대 다 관계를 고려
  • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
  • 영속성 전이(CASCADE) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용

임베디드 타입 Address를 Entity로 승격

@Entity
@Getter
@Setter
@Table(name = "address")
@NoArgsConstructor
public class AddressEntity {
    @Id @GeneratedValue
    private Long id;

    private Address address;

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

    public AddressEntity(String city, String street, String number) {
        this.address = new Address(city,street,number);
    }
}

addressHistory에 @CollectionTable 대신 @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) 설정

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

    @Column(name = "username")
    private String username;

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(name = "favorite_food", joinColumns =
        @JoinColumn(name = "member_id"))
    @Column(name = "food_name")
    private Set<String> favoriteFoods = new HashSet<>();

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "member_id")
    private List<AddressEntity> addressesHistory = new ArrayList<>();

}

 

'Java > JPA' 카테고리의 다른 글

JPA - 즉시로딩과 지연로딩, CASCADE와 고아객체  (1) 2023.10.10
JPA - 프록시  (0) 2023.10.10
JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23

1. 지연로딩

JPA에서 지연 로딩(Lazy Loading)은 연관된 엔티티나 컬렉션을 즉시 로딩하지 않는 전략을 의미한다. 대신, 실제로 해당 데이터에 접근이 필요할 때 (예: 프로퍼티의 값을 가져오는 경우) 데이터베이스에서 해당 데이터를 로드한다.

 

연관관계에 FetchType.LAZY  속성을 추가

package com.example.jpa.domain;  
  
import jakarta.persistence.*;  
import lombok.Getter;  
import lombok.Setter;  
import lombok.extern.java.Log;  
  
import java.util.ArrayList;  
import java.util.List;  
  
@Entity  
@Getter  
@Setter  
public class Member extends BaseEntity {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
@Column(name = "username")  
private String username;  
@ManyToOne(fetch = FetchType.LAZY)  
@JoinColumn(name = "team_id")  
private Team team;  
}

테스트 코드로 Team의 클래스명과, 쿼리를 확인해 보면

Team team = new Team();  
team.setName("teamA");  
em.persist(team);  
  
Member member1 = new Member();  
member1.setUsername("member1");  
member1.setTeam(team);  
em.persist(member1);  
  
em.flush();  
em.clear();  
  
Member member = em.find(Member.class, member1.getId());  
  
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());

System.out.println("============================================================");  
member.getTeam().getName();  
System.out.println("============================================================");

Member 엔티티는 Team을 Join 하지 않고 조회했다가,

실제 Team의 데이터를 조회할 때 조회 쿼리가 나가는것을 알 수 있다.

클래스도 확인해보면 실제 객체가 아닌 프록시 객체를 반환한다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_by,
        m1_0.created_date,
        m1_0.last_modified_by,
        m1_0.last_modified_date,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.member_id=?
member.getTeam().getClass() = class com.example.jpa.domain.Team$HibernateProxy$k43PADH3
============================================================
Hibernate: 
    select
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
============================================================

비즈니스 로직 상 대부분 메인 엔티티만 사용하고 참조 엔티티는 많이 쓰지 않을때 쓰는게 좋다. 라고 하지만

실무에서는 대부분  FetchType.LAZY 만 쓰인다고 한다.

 

정리

  • 모든 연관관계에 지연 로딩을 사용하라
  • 실무에서 즉시 로딩을 사용하지 마라
  • JPQL fetch 조인이나, 엔티티 그래프 기능을 사용하라
  • 즉시 로딩은 상상하지 못한 쿼리가 나간다.

 

2. 즉시로딩

지연로딩은 조회 시 가짜 프록시 객체를 만들어 조회하지만

즉시 로딩은 모든것에 Join을 걸고 조회한다

 

이전 엔티티 코드에서 fetchType.LAZY -> fetchType.EAGER 로 변경해 준 후 동일한 테스트코드를 실행하면

지연로딩과는 다르게 최초 조회시 모든 데이터를 불러오고,

구분선(===...)사이에는 아무것도 조회하지 않는것을 알 수 있다.

데이터를 모두 가지고 오기 때문에 프록시 객체가 아닌 실제 객체를 반환한다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_by,
        m1_0.created_date,
        m1_0.last_modified_by,
        m1_0.last_modified_date,
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        m1_0.member_id=?
member.getTeam().getClass() = class com.example.jpa.domain.Team
============================================================
============================================================

엔티티와 참조 엔티티가 대부분 같이 사용된다고 한다면 즉시 로딩을 사용한다고 한다.

 

주의점

데이터베이스 입장에서 한두개 정도는  조인이라고 해서 크게 느리진 않다. 
하지만 만약에 수십개라면 완전 다른 차원의 이야기다.

다 즉시로딩으로 10개씩 되어있다고 생각해보자 
뭐 하나 할때마다 전부 조인되서 나간다.

그래서 실무에서 테이블이 복잡하게 얽혀있을 때에는 전부 지연 로딩 으로 설정해야 한다.

 

예를들어 JPQL로 테스트 코드를 작성하면

Team team = new Team();  
team.setName("teamA");  
em.persist(team);  
  
Member member1 = new Member();  
member1.setUsername("member1");  
member1.setTeam(team);  
em.persist(member1);  
  
em.flush();  
em.clear();  
  
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

 

처음에는 엔티티 객체에 대한 쿼리가 나오고, 그 후에 참조 객체에 대해서 한번 더 조회 쿼리가 나온다.
그렇기에, 조회하려는 엔티티 객체가 10개 라면, 엔티티 쿼리 1개 + 참조 엔티티 쿼리 10개 의 쿼리가 출력된다. 

em.find같은 것들은 JPA가 내부적으로 최적화를 해 주지만 JPQL은 쿼리 그대로 나가기 때문이다.

 

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            m1_0.member_id,
            m1_0.created_by,
            m1_0.created_date,
            m1_0.last_modified_by,
            m1_0.last_modified_date,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0
Hibernate: 
    select
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?

Team과 Member를 하나씩 더 추가해보자

Team teamA = new Team();  
teamA.setName("teamA");  
em.persist(teamA);  
  
Team teamB = new Team();  
teamB.setName("teamB");  
em.persist(teamB);  
  
Member member1 = new Member();  
member1.setUsername("member1");  
member1.setTeam(teamA);  
em.persist(member1);  
  
Member member2 = new Member();  
member2.setUsername("member2");  
member2.setTeam(teamB);  
em.persist(member2);  
  
em.flush();  
em.clear();  
  
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

쿼리를 보면 엔티티에 대한 쿼리 1개와 참조 엔티티에 대한 2개의 쿼리가 나가는것을 볼 수 있다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            m1_0.member_id,
            m1_0.created_by,
            m1_0.created_date,
            m1_0.last_modified_by,
            m1_0.last_modified_date,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0
Hibernate: 
    select
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
Hibernate: 
    select
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?

하지만 지연로딩으로 설정한다면 아래와 같이 하나의 쿼리로 끝이 난다.

 /* select
        m 
    from
        Member m */ select
            m1_0.member_id,
            m1_0.created_by,
            m1_0.created_date,
            m1_0.last_modified_by,
            m1_0.last_modified_date,
            m1_0.team_id,
            m1_0.username 
        from
            member m1_0

 

정리

  • 가급적 지연 로딩만 사용(실무에서)
  • 즉시 로딩을 적용하면 예상치 못한 SQL이 발생
  • 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩 -> LAZY로 설정
  • @OneToMany, @ManyToMany 는 기본이 지연로딩

3. CASCADE(영속성 전이)

CASCADE특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만드는 기능이다.

 

Parent 와 Child라는 엔티티가 있을 때 연관관계에 cascade 속성을 추가한다.

package com.example.jpa.domain;  
  
import jakarta.persistence.*;  
import lombok.Getter;  
import lombok.Setter;  
  
import java.util.ArrayList;  
import java.util.List;  
  
@Entity  
@Getter  
@Setter  
public class Parent {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
private String name;  
@OneToMany(mappedBy = "parent",cascade = CascadeType.ALL)  
private List<Child> childList = new ArrayList<>();  
  
public void addChild(Child child){  
childList.add(child);  
child.setParent(this);  
}  
}



package com.example.jpa.domain;  
  
import jakarta.persistence.*;  
import lombok.Getter;  
import lombok.Setter;  
  
@Entity  
@Getter  
@Setter  
public class Child {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
private String name;  
  
@ManyToOne(fetch = FetchType.LAZY)  
@JoinColumn(name = "parent_id")  
private Parent parent;  
}

Child 엔티티를 영속시키지 않고 Parent 엔티티만 영속시킨다.

 
Child child1 = new Child();  
child1.setName("child1");  
Child child2 = new Child();  
child2.setName("child2");  
  
Parent parent = new Parent();  
parent.addChild(child1);  
parent.addChild(child2);  
  
em.persist(parent);

실제 쿼리를 보면 Parent 엔티티만 영속시켰지만 Child에 대한 쓰기 쿼리가 출력된다.

Hibernate: 
    /* insert for
        com.example.jpa.domain.Parent */insert 
    into
        parent (name,member_id) 
    values
        (?,?)
Hibernate: 
    /* insert for
        com.example.jpa.domain.Child */insert 
    into
        child (name,parent_id,member_id) 
    values
        (?,?,?)
Hibernate: 
    /* insert for
        com.example.jpa.domain.Child */insert 
    into
        child (name,parent_id,member_id) 
    values
        (?,?,?)

이처럼 부모 엔티티를 저장할때 부모 엔티티에 저장되어있는 자식 (정확하게는 부모 엔티티의 컬렉션 안에 있는)엔티티도 전부 저장하는 기능이다.

주의

  • 영속성 전이는 연관관계를 매핑하는 것과 아무 관련 없음
  • 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐이다.

 

4. 고아객체

고아 객체부모 엔티티와 연관관계가 끊어진 자식 엔티티를 의미하는데 

연관관계 속성 중 orphanRemoval라는 옵션을 true로 하면, 고아 객체들을 자동 삭제해준다.

package com.example.jpa.domain;  
  
import jakarta.persistence.*;  
import lombok.Getter;  
import lombok.Setter;  
  
import java.util.ArrayList;  
import java.util.List;  
  
@Entity  
@Getter  
@Setter  
public class Parent {  
@Id  
@GeneratedValue  
@Column(name = "member_id")  
private Long id;  
private String name;  
@OneToMany(mappedBy = "parent",cascade = CascadeType.ALL, orphanRemoval = true)  
private List<Child> childList = new ArrayList<>();  
  
public void addChild(Child child){  
childList.add(child);  
child.setParent(this);  
}  
}

childList에서 첫번째 child를 제거하는 테스트코드를 작성

Child child1 = new Child();  
child1.setName("child1");  
Child child2 = new Child();  
child2.setName("child2");  
  
Parent parent = new Parent();  
parent.addChild(child1);  
parent.addChild(child2);  
  
em.persist(parent);  
  
em.flush();  
em.clear();  
  
Parent findparent = em.find(Parent.class, parent.getId());  
findparent.getChildList().remove(0);

쿼리를 확인해보면 delete 쿼리가 나가고 있다.

Hibernate: 
    select
        p1_0.member_id,
        p1_0.name 
    from
        parent p1_0 
    where
        p1_0.member_id=?
Hibernate: 
    select
        c1_0.parent_id,
        c1_0.member_id,
        c1_0.name 
    from
        child c1_0 
    where
        c1_0.parent_id=?
Hibernate: 
    /* delete for com.example.jpa.domain.Child */delete 
    from
        child 
    where
        member_id=?

실제 DB

 

parent

child

id가 1번인 데이터가 삭제된것을 볼 수 있다.

 

주의

  • 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능
  • 참조하는 곳이 하나일 때사용해야함!
  • 특정 엔티티를 개별 소유할 때 사용

영속성 전이 + 고아 객체

  • CascadeType.ALL + orphanRemovel=true
  • 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거
  • 두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있음
  • 도메인 주도 설계(DDD)의 AggreGrate Root 개념을 구현할 때 유용하다고 한다.

 

CascadeType.ALL 혹은 REMOVE와 orphanRemovel이 기능적으로 비슷해보이지만

orphanRemoval은 연관관계가 끊어진 엔티티를 삭제하는 반면, CascadeType.REMOVE는 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 함께 삭제한다.

'Java > JPA' 카테고리의 다른 글

JPA - 값 타입  (0) 2023.10.12
JPA - 프록시  (0) 2023.10.10
JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23

1. EntityManager.find() vs EntityManager.getReference()

이하 EntityManager = em

 

JPA에는 em.find 말고도 em.getReference라는 이름 그대로 참조를 가져오는 메서드가 제공된다.

em.find는 데이터베이스를 통해서 실제 엔티티 객체를 바로 조회하고

em.getReference는 데이터베이스 조회를 미루는 가짜 엔티티 객체를 조회한다.

 

결론적으로 DB의 쿼리가 안나가는데 객체가 조회가 된다.

 

1) em.find()

Member member = new Member();  
member.setUsername("hello");  
  
em.persist(member);  
  
em.flush();  
em.clear();  
  
Member findMember = em.find(Member.class, member.getId());  
System.out.println("findMember = " + findMember.getId());  
System.out.println("findMember = " + findMember.getUsername());

em.find() 시점에서 실제 데이터를 불러오는 쿼리를 날린다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_by,
        m1_0.created_date,
        m1_0.last_modified_by,
        m1_0.last_modified_date,
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        m1_0.member_id=?
findMember = 1
findMember = hello

2) em.getReference()

위 코드에서 em.find() 메서드만 em. getReference() 로 변경했다.

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

getId() 부분까지는 이미 엔티티를 조회하는 시점에서 getId() 값을 가지고 있기 때문에 조회하지 않다가,

실제 데이터의 값이 필요하게 되는 getUserName() 시점에 조회쿼리를 날려 값을 조회한다.

findMember.getId() = 1
Hibernate: 
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipc5de,
        m1_0.username,
        m1_0.work_city,
        m1_0.work_street,
        m1_0.work_zipcode,
        m1_0.end_date,
        m1_0.start_date 
    from
        member m1_0 
    where
        m1_0.member_id=?
findMember.getUsername() = hello

그렇다면 getReference()의 Member 객체는 뭘까?

 

아래 쿼리를 추가하여 실행하면,

System.out.println("findMember = " + findMember.getClass());

진짜 Member객체가 아닌, 프록시 객체라는것을 알 수 있다.

findMember = class com.example.jpa.domain.Member$HibernateProxy$gPJPsc5E

이 프록시 객체의 내부에는 target이라는 것이 있는데, 이것이 진짜 엔티티를 가르킨다.

초기에는 껍데기만 있고, ID값만 들고있는 가짜가 반환된다.

2. 프록시 특징

엔티티의 프록시 객체는 어떻게 생겼냐 하면, 프록시 객체는 실제 객체의 taget이라는 참조를 보관한다.

예를들어, Member엔티티 프록시 객체에서 getName을 호출하면, 

프록시 객체는 실제 target에 있는 엔티티의 getName을 대신 호출한다.

그런데 처음에는 타겟이 없다.
왜냐하면 이걸 실제 DB에서 조회한 적이 없기 때문이다.

 

프록시의 특징은 실제 객체 엔티티를 상속받아서 만들어진다.
그래서 실제 클래스와 겉모양이 같다.
이는 하이버네이트가 내부적으로 여러 프록시 라이브러리를 이용해 만든다.

 

정리

  • 실제 클래스를 상속 받아서 만들어짐
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨(이론상)
  • 프록시 객체는 실제 객체의 참조(target)을 보관
  • 프록시 객체를 호출하면 객체는 실제 객체의 메소드 호출

3. 프록시 객체의 초기화

1) 프록시 객체가 초기화되는 과정

프록시 객체를 getReference로 가져왔을 때, member.GetName을 호출하면, 
target에는 Member의 값이 처음에는 없었다가, JPA가 영속성 컨텍스트에 Member 값을 요청한다.

그러면 영속성 컨텍스트가 DB를 조회해서 실제 엔티티 객체를 생성한다. 그리고 프록시 객체의 target에 연결을 시켜준다.
즉, 프록시 내부에는  Member target 이라는 진짜 객체 변수가 있다고 보면 된다.

그래서 프록시 객체에서 getName을 했을 때, target의  getName을 통해 실제 객체에 있는 getName이 반환된다.

 

2) 한번 초기화된 프록시는 다시 DB를 조회할 필요가 없다.


영속성 컨텍스트에서 초기화를 요청하는 부분이 중요하다.
프록시에 값이 없을 때 진짜 값을 달라고 요청하는 것이다. 
DB를 통해서 진짜 값을 가지고 와서 진짜 엔티티를 만드는 과정을 의미한다. 
그리고 이것이 초기화를 의미한다.

그리고 한번 초기화 되면 target에 객체 값이 걸리기 때문에 다시 DB를 조회할 일은 없다.
그래서 getName을 2번 요청하더라도 쿼리는 한번만 나간다.

 

em.getReference()로 userName 조회 시 쿼리

Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_by,
        m1_0.created_date,
        m1_0.last_modified_by,
        m1_0.last_modified_date,
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        m1_0.member_id=?
findMember.getUsername() = hello
findMember.getUsername() = hello

3) 실제 객체와 프록시 객체의 타입 비교 

 

또한 타입비교를 할 때 == 비교를 사용하지 말고 instance of를 사용해야한다.

왜냐하면 같은 객체끼리의 비교는 괜찮지만, em.find()와, em.getReference()를 == 비교한다면 false가 나올 수 있기 때문이다.

 Member member1 = new Member();
        member1.setUsername("hello1");
        em.persist(member1);

        Member member2 = new Member();
        member2.setUsername("hello2");
        em.persist(member2);

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

        Member findMember1 = em.find(Member.class, member1.getId());
        Member findMember2 = em.getReference(Member.class, member2.getId());

        System.out.println("findMember1.getClass() = " + findMember1.getClass());
        System.out.println("findMember2.getClass() = " + findMember2.getClass());
        System.out.println("findMember1 == findMember2 : " + (findMember1.getClass() == findMember2.getClass()));

이 경우엔 실제 객체와 프록시 객체가 비교되므로 false가 나온다.

== 연산자는 객체의 참조를 비교한다. 즉, 두 객체가 메모리 상에서 동일한 위치에 있는지를 확인하는 것이다.

 

그렇기 때문에, 실제 엔티티 객체와 그 엔티티의 프록시 객체가 서로 다른 메모리 위치에 있다면

== 연산자로 비교했을 때 결과는 false가 된다.

findMember1.getClass() = class com.example.jpa.domain.Member
findMember2.getClass() = class com.example.jpa.domain.Member$HibernateProxy$pNqeG26x
findMember1 == findMember2 : false

 

4) 영속성 컨텍스트에 엔티티가 이미 있을 때 em.getReference()

 

같은 id를 참조하는 경우,  이미 find() 하여 1차 캐시에 엔티티가 저장되어있는 상태라면, getReference() 하더라도 프록시가 아닌, 실제 객체를 반환한다.

Member member1 = new Member();  
member1.setUsername("hello1");  
em.persist(member1);  
  
  
em.flush();  
em.clear();  
  
Member findMember1 = em.find(Member.class, member1.getId());  
System.out.println("findMember1.getClass() = " + findMember1.getClass());  
  
Member findMember1Refer = em.getReference(Member.class, member1.getId());  
System.out.println("findMember1Refer.getClass() = " + findMember1Refer.getClass());


첫번째 이유는 1차캐시에 이미 실제 엔티티가 존재하기 때문이고, 
두번째 이유는 JPA는 같은 엔티티의 같은 id라면 라면 항상 == 비교를 true를 보장하기 때문이다.

findMember1.getClass() = class com.example.jpa.domain.Member
findMember1Refer.getClass() = class com.example.jpa.domain.Member

 

JPA는 같은 엔티티와 id라면 항상 == 비교가 참이 되도록 보장해주기 때문에, getReference()후 find()를 하게 되면 

둘 다 프록시 객체로 조회된다.

Member member1 = new Member();  
member1.setUsername("hello1");  
em.persist(member1);  
  
  
em.flush();  
em.clear();  
  
Member refMember = em.getReference(Member.class, member1.getId());  
System.out.println("refMember.getClass() = " + refMember.getClass());  
  
Member findMember = em.find(Member.class, member1.getId());  
System.out.println("findMember.getClass() = " + findMember.getClass());  
  
System.out.println("refMember == findMember : " + (refMember == findMember));

그렇기에 재미있게도 refMember.getClass()가 호출되기 직전에 조회쿼리를 호출하지만, 프록시 객체로 조회되는것을 볼 수 있다.

refMember.getClass() = class com.example.jpa.domain.Member$HibernateProxy$a1lJoMHB
Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_by,
        m1_0.created_date,
        m1_0.last_modified_by,
        m1_0.last_modified_date,
        t1_0.team_id,
        t1_0.created_by,
        t1_0.created_date,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        m1_0.member_id=?
findMember.getClass() = class com.example.jpa.domain.Member$HibernateProxy$a1lJoMHB
refMember == findMember : true

5) hibernate.LazyInitializationException - 영속성 컨텍스트에 값이 없거나 컨텍스트 자체가 닫힌 경우

 

프록시 객체에서 username을 호출하면 프록시 객체에서 영속성 컨텍스트에게 실제객체를 호출해달라고 요청하는데,

 영속성 컨텍스트에게 호출하기 전에 준영속을 시켜버리면 영속성 컨텍스트에는 캐시가 없기 때문에

 JPA에서는 실제 엔티티를 불러올수 없게 된다. close(), clear() 모두 동일하다.

 

Member refMember = em.getReference(Member.class, member1.getId());  
System.out.println("refMember.getClass() = " + refMember.getClass());  
  
em.detach(refMember);  
  
refMember.getUsername();

 

Hibernate: 
    /* insert for
        com.example.jpa.domain.Member */insert 
    into
        member (created_by,created_date,last_modified_by,last_modified_date,username,member_id) 
    values
        (?,?,?,?,?,?)
refMember.getClass() = class com.example.jpa.domain.Member$HibernateProxy$PJu5GIAI

could not initialize proxy [com.example.jpa.domain.Member#1] - no Session
org.hibernate.LazyInitializationException: could not initialize proxy [com.example.jpa.domain.Member#1] - no Session

 

정리

  •  프록시 객체는 처음 사용할 때 한 번만 초기화
  • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아님. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능
  • 프록시 객체는 원본 엔티티를 상속받음. 따라서 타입 체크 시 주의 필요 (== 비교 대신, instance of 사용)
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference() 를 호출해도 실제 엔티티를 반환
  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시 초기화하면 문제 발생(하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림)

'Java > JPA' 카테고리의 다른 글

JPA - 값 타입  (0) 2023.10.12
JPA - 즉시로딩과 지연로딩, CASCADE와 고아객체  (1) 2023.10.10
JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23

상속관계 매핑

 

객체에는 상속관계가 존재하지만, DB에는 상속이 존재하지 않는다.

하지만 DB에는 슈퍼- 서브타입 이라는 모델링 기법이 존재하는데, 이것이 객체의 상속관계와 유사하다.

 

상속관계 매핑이란, 객체의 상속, 구조와 DB의 슈퍼타입, 서브타입을 매핑하는것이다.

 

슈퍼-서브타입 모델링으로 공통된 속성들을 하나로 묶거나, 하나로 합칠 수 있다.

이렇게 슈퍼타입, 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법들이 JPA에서 세 가지가 존재한다.

 

1. 데이터를 정규화시켜서 각각의 테이블로 변환하는 조인 전략

 

비즈니스적으로 중요하고 복잡할 때 쓴다.

기본적으로 세 전략 중 이 전략을 많이 사용한다.

슈퍼타입으로 사용할 상위 엔티티 생성 후 @Inheritance(strategy = InheritanceType.JOINED) 를 클레스에 추가한다.

또한, @DiscriminatorColumn 라는 어노테이션을 추가하여 테이블상에 DTYPE 이라는것을 추가하는데,

조인된 서브타입 테이블의 이름을 저장해 데이터를 구분하는데 도움을 준다.

(name속성을 변경하여 컬럼명을 DTYPE 외에도 다양하게 사용할 수 있다. )

상속받은 엔티티 클레스에 @DiscriminatorValue(value = "M") 를 추가해주면,(@DiscriminatorValue("M") 도 가능)

엔티티 명 대신 설정한 값인 M이 들어간다.

 

이후 아래처럼 하위 엔티티에서 상위 엔티티를 상속받으면 된다.

결론적으로 아래 코드에서 변경점 하나 없이 @ Inheritance의 stategy 속성 변경만으로 테이블 구조가 달라진다.

package com.example.jpa.domain;  
  
import jakarta.persistence.*;  
import lombok.Getter;  
import lombok.Setter;  
  
import java.util.ArrayList;  
import java.util.List;  
  
@Entity  
@Getter  
@Setter  
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public class Item {  
  
@Id @GeneratedValue  
@Column(name = "item_id")  
private Long id;  
  
private String name;  
private int price;  
private int stockQuantity;  
@ManyToMany(mappedBy = "items")  
private List<Category> categories = new ArrayList<>();  
}

package com.example.jpa.domain;  
  
import jakarta.persistence.Entity;  
  
@Entity  
public class Book extends Item{  
private String author;  
private String isbn;  
}

package com.example.jpa.domain;  
  
import jakarta.persistence.Entity;  
@Entity  
public class Album extends Item{  
private String artist;  
}

package com.example.jpa.domain;  
  
import jakarta.persistence.Entity;  
  
@Entity  
public class Movie extends Item{  
private String director;  
private String actor;  
}

상위 테이블과 하위 테이블 모두를 접근해야하므로 쿼리가 2번 나간다는 특징이 있다.

 

  • 장점
    • 테이블 정규화
    • 외래 키 참조 무결성 제약조건 활용 가능
    • 저장공간 효율화
  • 단점
    • 조회 시 조인을 많이 사용, 성능 저하
    • 조회 쿼리가 복잡함
    • 데이터 저장 시 INSERT SQL 2번 호출

가장 정석적인 전략이다.

 

2. 여러 테이블을 하나라 통합하는 단일 테이블 전략

 

논리 모델을 한 테이블로 구성하는 전략이다.

데이터도 얼마 안되고 너무 단순하고 확장할 일도 없을때 쓰면 좋다고 한다.

 

위에 상술한 @Inheritance 속성을 InheritanceType.SINGLE_TYPE으로 변경하면 된다.

Hibernate: 
    create table item (
        price integer not null,
        stock_quantity integer not null,
        item_id bigint not null,
        dtype varchar(31) not null,
        actor varchar(255),
        artist varchar(255),
        author varchar(255),
        director varchar(255),
        isbn varchar(255),
        name varchar(255),
        primary key (item_id)
    ) engine=InnoDB

위 코드들을 보면, 각자의 세부 엔티티들은 상위 엔티티인 item을 확장했고, 

그 item 테이블 쿼리를 보면 테이블에 세부 엔티티가 전부 들어간것을 볼수 있다.

  • 장점
    • 조인이 필요없으므로 일반적인 조회 성능이 빠름
    • 조회 쿼리가 단순함
  • 단점
    • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
    • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있는 상황에 따라서 조회 성능이 느려질 수 있다.

3. 슈퍼타입으로 모으지 않고 서브타입에 각각 속성들을 포함시키는 구현 클래스마다 전략

 

@Inheritance 속성을 InheritanceType.TABLE_PER_CLASS 로 변경하면 된다.

상위 테이블을 없애고 각각의 하부 테이블에 상위테이블 값들을 넣는 방식이다.

참고로, 이 전략에서는 @DiscriminatorColumn 이 적용되지 않는다.

상위 테이블인 ITEM이 사라졌기 때문에, 구분할 필요가 없기 때문이다.

 

이 전략은 단순하게 값을 넣고 뺄 때는 딱 좋은데,  상위타입으로 엔티티를 find하게되면 문제가 생긴다.

타입을 구분할 수 없기 때문이다.

Item findItem = em.find(Item.class, movie.getId());  
log.info("findItem={}",findItem);

 

조회시 쿼리가 너무 복잡해진다.

Hibernate: 
    select
        i1_0.id,
        i1_0.clazz_,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director 
    from
        (select
            price,
            stock_quantity,
            id,
            artist,
            name,
            null as author,
            null as isbn,
            null as actor,
            null as director,
            1 as clazz_ 
        from
            album 
        union
        all select
            price,
            stock_quantity,
            id,
            null as artist,
            name,
            author,
            isbn,
            null as actor,
            null as director,
            2 as clazz_ 
        from
            book 
        union
        all select
            price,
            stock_quantity,
            id,
            null as artist,
            name,
            null as author,
            null as isbn,
            actor,
            director,
            3 as clazz_ 
        from
            movie
    ) i1_0 
where
    i1_0.id=?

 

  • 장점
    • 서브 타입을 명확하게 구분해서 처리할 때 효과적
    • not null 제약조건 사용 가능
  • 단점
    • 여러 자식 테이블을 함께 조회 할 때 성능이 느림(UNION SQL)
    • 자식 테이블을 통합해서 쿼리하기 어려움

@MappedSuperclass

테이블과는 관계 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할을 한다.

주로 등록일, 수정일, 등록자, 수정자 같이 전체 엔티티에서 공통적으로 사용하는 정보를 모을 때 사용한다.

 

package com.example.jpa.domain;  
  
import jakarta.persistence.MappedSuperclass;  
import lombok.Getter;  
import lombok.Setter;  
  
import java.time.LocalDateTime;  
@Getter  
@Setter  
@MappedSuperclass  
public abstract class BaseEntity {  
private String createdBy;  
private LocalDateTime createdDate;  
private String lastModifiedBy;  
private LocalDateTime lastModifiedDate;  
}
------------------------------------
public class Team extends BaseEntity {...}

 참고 : @Entity 클래스는 엔티티나 @MappedSuperClass로 지정한 클래스만 상속가능하다.

 

  • 특징 
    • 상속관계 매핑이 아님
    • 엔티티 X, 테이블과 매핑 X
    • 부모 클래스를 상속받는 자식 클래스에 매핑 정보만 제공한다.
    • 조회, 검색 불가(em.find(BaseEntity) 불가)
    • 직접 생성해서 사용할 일이 없으므로 추상 클래스 권장

'Java > JPA' 카테고리의 다른 글

JPA - 즉시로딩과 지연로딩, CASCADE와 고아객체  (1) 2023.10.10
JPA - 프록시  (0) 2023.10.10
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23
JPA - 플러시, 준영속 상태  (0) 2023.09.20

 연관관계 매핑이란, JPA에서 객체 간의 관계를 데이터베이스 테이블 간의 관계와 매핑하는 것을 말한다.

 

연관관계 매핑에 대해서는 고려해야할점이 크게 3가지가 존재한다.

1. 단방향, 양방향

테이블

  • 외래키 하나로 조인 가능
  • 방향이라는 개념이 없음

객체

  • 참조용 필드가 있는 쪽으로만 참조 가능
  • 한쪽만 참조하면 단방향
  • 양쪽이 서로 참조하면 양방향

2. 연관관계 주인

  • 외래키를 관리하는 참조를 의미
  • 주인의 반대편은 단순 조회만 가능
  • 테이블은 외래키 하나로 두 테이블이 연관관계를 맺음
  • 객체의 양방향 관계는 A->B, B->A처럼 참조가 2군데

3. 다중성

엔터티 간의 관계의 다중성을 나타낸다 . A 엔터티에서 B 엔터티로의 참조가 여러 개가 가능한지 , 혹은 하나만 참조가 가능한지, 아니면 서로가 서로 여러개를 참조 할 수 있는지 등을 설정한다.

N:1

단방향

N 쪽인 엔터티가 외래키를 가지며, 이를 연관관계의 주인으로 설정한다.

@JoinColumn 어노테이션을 사용하여 연관관계의 외래키 컬럼명을 지정할 수 있다.

양방향

양방향인 경우에는 참조된 엔티티 변수에 @ManyToOne(mappedBy = "엔티티변수명") 을 추가한다. 이 때, mappedBy 속성에는 연관관계의 주인이 아닌 반대편 엔터티의 필드명을 지정한다.

1:N

1 쪽인 엔티티가 연관관계의 주인이 된다.

DB의 1:N 관계에는 언제나 N쪽에 외래키가 존재하지만, 엔티티에서는 1쪽에 외래키가 존재할 수 있기 때문에 가능한 방법이다.

하지만 이런 DB와 객체의 관계차이 때문에 설계, 운영에 어려움이 많아, 일반적으로 권장되지 않는 방법이다.

또한, 연관관계 관리를 위해 추가적으로 UPDATE SQL이 실행된다.

단방향

N:1과는 반대로 @OneToMany가 붙은 변수에 @JoinColumn을 추가하고, 외래키 컬럼명을 지정한다. 또한 @JoinColumn을 추가하지 않으면 조인 테이블 방식(중간 테이블 생성)을 사용한다.

양방향

1:N 양방향은 JPA 스펙상 존재하지는 않지만 읽기전용 필드를 사용해서 양방향처럼 구현이 가능하다.

N쪽에도 @JoinColumn을 추가한다. 추가로 N쪽에는 연관관계 주인이 되는것을 막기 위해 속성에 insertable, updatable을 모두 false로 설정하여 읽기전용으로 만든다.

1:1

주 테이블과 대상 테이블 어느쪽에도 외래키를 선택할 수 있다.

외래키에 유니크 제약조건을 걸어야 1:1이 된다.

주 테이블에 외래키 단방향

@OneToOne, @JoinColumn을 이용하여 연결한다.

주 테이블에 외래키 양방향

1:1 관계에서 양방향 관계는 참조된 엔티티 변수쪽에 마찬가지로 @OneToOne을 추가하고, mappedBy설정을 해주면 된다.

대상 테이블에 외래키 단방향

지원하지 않는다.

 

 

대상 테이블에 외래키 양방향

연관관계의 주인을 대상 테이블쪽 엔티티로 잡아서 구현한다.

개발자 입장에서는 주 테이블에 외래키가 있는것이 성능적으로 좋다. 주 테이블의 값을 조회할 때 대상테이블을 확인하는데 있어서 주 테이블만 확인하면 되기 때문이다.

반대로 대상 테이블에 외래키가 존재한다면 주테이블 엔티티만으로는 확인 할 수 없으므로 무조건적으로 대상테이블을 조회해야 한다. 그러므로 지연로딩으로 설정해도 항상 즉시로딩된다.

 

N:N

실무에서는 권장하지 않는다

 

데이터베이스에서의 N:N

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다.

연결 테이블을 추가해서 1:N, N:1 관계로 풀어내야 한다.

객체에서의 N:N

객체에서는 컬렉션을 사용하여 N:N 관계를 표현 할 수 있다.

단방향

@ManyToMany 어노테이션과, 중간 테이블 생성을 위한 @JoinTable을 추가한다.

양방향

@ManyToMany.(mappedBy = "xxx")를 추가한다.

 

@ManyToMany의 한계

쿼리도 복잡하고 중간 테이블에 무엇인가 추가 정보를 넣기가 불가능하기 때문에 실무에서는 사용하기 어렵다.

 

@ManyToMany의 한계 극복

@ManyToMant, @JoinTable로 중간테이블을 생성하는 대신,

중간 테이블을 엔티티로 승격시키고, @ManyToMany를 @ManyToOne, @OneToMany로 변경한다.

중간 테이블쪽을 @ManyToOne으로 잡고 연결이 필요한 테이블들을 @OneToMany로 잡는다.

 

'Java > JPA' 카테고리의 다른 글

JPA - 프록시  (0) 2023.10.10
JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23
JPA - 플러시, 준영속 상태  (0) 2023.09.20
JPA - 영속성컨텍스트(2)  (0) 2023.09.20

JPA는 기본적으로 1차 캐시에 @Id로 키를 가지고 @Entity로 값을 가진다. 

 

그렇기에 1차 캐시에 엔티티를 저장하려면 @Id의 값이 필수적인데, @GeneratedValue는 자동으로 이 id값을 생성해준다.

 

@GeneratedValue

GenerationType.IDENTITY:

  • id값(기본 키)을 DB에서 생성한다.
  • JPA는 기본적으로 트랜잭션이 끝난 직후 데이터가 DB에 반영되지만, IDENTITY인 경우에는 엔티티를 영속시킬 때 쿼리가 DB에 반영된다.

GenerationType.IDENTITY은 왜 엔티티가 영속될때 쿼리가 DB에 반영되는가?

  • IDENTITY는 DB에서 id값을 넘겨받는다. 즉, DB에 넘어가기 전 까지는 id값이 존재하지 않는다.
  • JPA의 1차 캐시는 id값을 가지고 있어야 저장할수 있으므로, DB에 id값을 받아지 않는 한 저장할 수 없다.
  • 그렇기 때문에 JPA에서 IDENTITY 설정을 사용 할 경우에 영속화를 하는 시점에 미리 쿼리를 날려서 id를 조회하여 1차 캐시에 엔티티를 저장한다. 

GenerationType.SEQUENCE:

  • DB에서 id값을 가져오지만 IDENTITY와 다르게 다량으로 id값을 가져올 수 있다.
  • 차이점은 IDENTITY와 다르게 영속을 하더라도 트랜잭션이 끝난 시점에 DB에 반영된다.
  • @SequenceGenerator 로 시퀀스를 따로 설정할 수 있다.(따로 설정하지 않을 시 hibernate_sequence의 값을 사용한다)

GenerationType.SEQUENCE는 어떻게 IDENTITY와 다른가?

  •  @SequenceGenerator의 속성 중, allocationSize 라는 속성은 DB에서 sequence값을 한번에 몇개씩 가져올지를 정한다.
  • 이 allocationSize의 기본값은 50인데,  1부터 51까지의 시퀀스값을 db에서 생성하여 미리 JPA에 가져와서 어플리케이션이 종료될 때 까지 가져온다는 의미다.
  • 이렇게 가져온 시퀀스 값을 엔티티를 영속할 때 마다 하나씩 부여하여 1차 캐시에 저장할 수 있다. 
  • 동시성 이슈없이 다양한 문제들을 해결 할 수 있다고 한다.

GenerationType.TABLE

  • DB의 시퀀스를 흉내내는 키 전용 생성 테이블을 생성한다.
  • 실제 운영에서 사용하기에는 기존의 DB 생성 규칙과 다를 수 있으므로 쓰기가 어렵다.
  • 모든 DB에 적용할 수 있다는 장점이 있지만, 보통은 테이블 전략을 잘 사용 하지 않는다고 한다.

결론

전략은 SEQUENCE를 사용하자

'Java > JPA' 카테고리의 다른 글

JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 플러시, 준영속 상태  (0) 2023.09.20
JPA - 영속성컨텍스트(2)  (0) 2023.09.20
JPA - 영속성 컨텍스트(1)  (0) 2023.09.18

1. 플러시

플러시란 영속성 컨텍스트의 변경내용을 데이터베이스에 반영하는것을 의미한다.

 

플러시가 일어나면 어떤일이 일어나는가?

1. 변경감지가 일어난다. 그리고

2. 수정된 엔티티의 쿼리를 지연 SQL 저장소에 등록한다. 그리고

3. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.

4. 플러시를 보낸다고 해서 커밋이 되는것은 아니다.

 

영속성 컨텍스트를 플러시하는 방법

em.flush() - EntityManager의 flush를 직접 호출하는 방법.

member200이라는 맴버객체를 생성하고, em.persist를 한다고 하면,

현재 member 객체는 영속성 컨텍스트, 즉 1차 캐시에 저장된 상태이다.

또 동시에 지연 SQL 저장소에 객체를 생성하는 쿼리가 저장된다.

이후 em.flush()를 하게 되면 

트랜잭션이 커밋되기 전에 member 생성 쿼리가 데이터베이스에 호출된다.

 

트랜잭션 커밋 - 트랜직션을 커밋하면 자동으로 플러시가 호출.

위 코드처럼 따로 flush를 호출하지 않는다면 실제 플러시가 실행되는 시점은 커밋이 실행되는 시점이다.


JPQL 쿼리 실행 - 플러시 자동 호출

JPA 기본설정이 JPQL을 실행하면 무조건 플러시가 호출되게 되어있다.

플러시의 모드옵션을 설정해서 변경할 수 있지만, 크게 도움이 되지 않는다고 한다.

 

정리

1. 플러시는 영속성 컨텍스트를 비우지 않는다.

2. 플러시는 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화 한다.
3. 트랜잭션이라는 작업 단위가 중요하기 때문에 커밋 직전에만 동기화 하면 된다.

 

2. 준영속 상태

영속 -> 준영속

영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detatched)되는 것
영속성 컨텍스트가 제공하는 기능을 사용 못한다.

준영속 상태로 만드는 방법

em.detach(entity); 

특정 엔티티만 준영속 상태로 전환한다.

150번 객체를 em.find로 찾았다. 이렇게되면 영속성컨텍스트에 150번 맴버객체가 등록된다.

이후 맴버의 이름을 "AAAA"로 변경했다. 

본래대로라면 커밋이 되었을 때 JPA에서 변경감지가 일어나 업데이트 쿼리가 생성되어야 정상이지만

현재는 em.detach를 사용하여 맴버 엔티티를 준영속 상태로 전환했다. 즉 영속성 컨택스트에서

맴버 엔티티를 빼내었다는 이야기이므로 em.detach(member)가 일어난 시점 이후로에는

영속성 컨텍스트에 맴버 객체가 없으므로 변경감지가 일어나지 않고

결론적으로 조회 쿼리만 남게 되는 것이다.

 

em.close();

영속성 컨텍스트를 완전히 초기화시킨다.

em.detach(entity)와 같은 결과를 반환한다.

 

em.close();

영속성 컨텍스트를 종료시킨다.

위와 동일.

'Java > JPA' 카테고리의 다른 글

JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23
JPA - 영속성컨텍스트(2)  (0) 2023.09.20
JPA - 영속성 컨텍스트(1)  (0) 2023.09.18

1. 1차캐시

1차 캐시는 영속성 컨텍스트의 핵심 구성 요소 중 하나다. 이는 @Id (Entity의 식별자)를 키로, 해당 Entity 객체를 값으로 가지는 내부 맵 구조를 가지고 있다. 이러한 구조 덕분에, 특정 Entity를 조회할 때 데이터베이스를 직접 조회하기 전에 1차 캐시에서 해당 Entity를 찾을 수 있어 성능 이점을 가져오게 된다. 그러나, 이 1차 캐시의 생명 주기는 트랜잭션과 연결되어 있어 그 이점은 트랜잭션 범위에서만 유효하다.

2. 동일성 보장

JPA는 동일한 트랜잭션 내에서 같은 식별자를 가진 Entity에 대해 동일성(==)을 보장하게 한다. 이는 1차 캐시 덕분에 가능하다.

 

3. 트랜잭션을 지원하는 쓰기 지연

JPA는 Entity를 영속화(em.persist())할 때 즉시 데이터베이스에 쿼리를 전송하지 않는다. 대신, 해당 쿼리는 "쓰기 지연 SQL 저장소"에 임시로 저장되며, 트랜잭션이 커밋되는 시점에 데이터베이스로 전송된다.

4. 변경 감지 (더티 체킹)

JPA는 1차 캐시 내의 Entity 객체의 상태 변화를 감지할 수 있다. 이를 "더티 체킹"이라고 하며, 이 기능 덕분에 개발자는 직접 업데이트 쿼리를 작성할 필요 없이 객체의 상태만 변경하면 JPA가 이를 감지하고 적절한 쿼리를 생성해 데이터베이스에 반영한다.

'Java > JPA' 카테고리의 다른 글

JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23
JPA - 플러시, 준영속 상태  (0) 2023.09.20
JPA - 영속성 컨텍스트(1)  (0) 2023.09.18

1. 영속성 컨텍스트란?

영속성 컨텍스트는 JPA의 핵심 기능 중 하나로, 엔티티 인스턴스를 영구적으로 저장하고 관리하는 환경이다.

2. 엔티티 매니저

엔티티 매니저는 JPA에서 제공하는 API로, 이를 통해 영속성 컨텍스트에 접근하고 엔티티에 대한 CRUD 연산을 할 수 있다.

3. 엔티티의 생명주기

엔티티는 크게 4단계의 생명주기를 가진다.

비영속(new / transient)

  • 영속성 컨텍스트와 전혀 관계없는 새로운 상태

영속 (managed)

  • 영속성 컨텍스트에 관리되는 상태

준영속 (detached)

  • 영속성 컨텍스트에 저장되었다가 분리된 상태

삭제 (deleted)

4. 영속성 컨텍스트의 이점

영속성 컨텍스트에는 다음과 같은 이점이 있다.

  1. 1차 캐시
    • 영속성 컨텍스트 내부에는 엔티티를 저장하기 위한 메모리 공간인 1차 캐시가 존재한다.
    • 엔티티 매니저를 통해 엔티티를 조회하면, 먼저 1차 캐시에서 해당 엔티티를 찾아본다. 1차 캐시에 존재한다면 데이터베이스에 접근하지 않고 캐시된 값을 반환한다. 이로 인해 성능이 향상된다.
  2. 동일성 보장
    • 같은 트랜잭션 안에서 같은 엔티티를 조회하면 항상 동일한 인스턴스를 반환한다.
    • 이러한 동일성은 객체 지향 프로그래밍의 중요한 특징을 지원한다. 데이터베이스의 같은 레코드를 기반으로 생성된 엔티티가 메모리에서도 동일한 것을 보장하기 때문에 데이터의 일관성을 유지할 수 있다.
  3. 트랜잭션을 지원하는 쓰기 지연
    • 영속성 컨텍스트는 트랜잭션 내에서의 변경사항을 모아두고, 트랜잭션 커밋 시점에 한꺼번에 데이터베이스에 반영한다.
    • 이렇게 변경사항을 한 번에 데이터베이스에 반영하면 네트워크 사용량이 줄어들고, 데이터베이스 액세스 횟수도 감소하여 성능이 향상된다.
    • 또한, 중간에 오류가 발생하면 트랜잭션 롤백을 통해 이전 상태로 쉽게 되돌릴 수 있다.
  4. 지연로딩
    • 연관된 엔티티나 컬렉션을 실제로 사용하는 시점에 데이터베이스에서 로딩하는 기능이다.
    • 초기 로딩 시 필요하지 않은 데이터를 로딩하지 않기 때문에 성능을 최적화할 수 있다.
    • 애플리케이션의 반응 시간을 향상시킬 수 있으며, 불필요한 데이터베이스 접근을 줄일 수 있다.

'Java > JPA' 카테고리의 다른 글

JPA - 상속관계 매핑, @MappedSuperclass  (0) 2023.10.04
JPA - 연관관계  (0) 2023.10.01
JPA - 기본 키 매핑 : @GeneratedValue  (0) 2023.09.23
JPA - 플러시, 준영속 상태  (0) 2023.09.20
JPA - 영속성컨텍스트(2)  (0) 2023.09.20

+ Recent posts