자바 ORM 표준 JPA 프로그래밍 스터디 - 4주차

큰모래·2023년 5월 28일
0

8장. 프록시와 연관관계 정리


프록시

  • 회원 엔티티만 필요한 상황에서 회원 엔티티를 조회할 때 연관된 팀 엔티티까지 같이 db에서 조회된다.
public String printUser(String memberId) {
		Member member = em.find(Member.class, memberId);
		Team team = member.getTeam(); //연관된 team도 조회 가능
}

JPA는 이러한 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 db 조회를 지연하는 지연 로딩을 지원한다.

따라서 team.getName() 처럼 실제 팀 엔티티의 값을 사용하는 시점에 db에서 조회할 수 있도록 한다.

이때, 실제 엔티티 객체 대신 db 조회를 지연할 수 있도록 만드는 가짜 객체를 프록시 객체라고 한다.

프록시 기초

EntityManager.getReference() 메서드를 사용하면 JPA는 db를 조회하지 않고 프록시 객체를 반환한다.

프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.

따라서, 사용하는 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.

프록시 객체는 실제 객체를 참조하고 있다.

따라서, 프록시 객체의 메서드를 호출하면 프록시 객체는 실제 객체의 메서드를 호출하여 반환한다.

  1. 프록시 객체에 getName() 메서드 호출
  2. 초기화 요청 : 실제 엔티티가 생성되어 있지 않아 영속성 컨텍스트에 실제 엔티티를 요청하는 작업
  3. 영속성 컨텍스트에서 db를 조회해서 실제 엔티티 객체를 생성
  4. 프록시 객체는 실제 엔티티 객체의 참조를 Member target 멤버 변수에 보관
  5. 실제 엔티티 객체의 target.getName()을 호출해서 결과를 반환한다.

프록시 특징

  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것이 아닌, 프록시 객체를 통해 실제 엔티티에 접근할 수 있는 것이다.
  • 영속성 컨텍스트에 찾는 엔티티가 있으면 em.getReference()를 해도 프록시가 아닌 실제 객체 반환
  • 초기화 작업은 영속성 컨텍스트의 도움이 필요하므로 준영속 상태의 프록시를 초기화하면 문제가 발생한다.
    Member member = em.getReference(Member.class, "id1");
    transaction.commit();
    em.close();
    
    member.getName(); //org.hibernate.LazyInitializationException 예외 발생

프록시와 식별자

  • 엔티티를 프록시로 조회할 때 식별자 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.
  • 프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 team.getId()를 호출해도 프록시를 초기화하지 않는다.
Team team = em.getReference(Team.class, "team1");
team.getId(); // 초기화 X
  • 단, 엔티티 접근 방식이 프로퍼티 설정일 경우에만 초기화하지 않고, 필드 접근 방식인 경우 프록시 객체를
    초기화한다.

프록시 확인

  • 프록시 객체의 초기화 여부를 확인할 수 있다.
boolean isLoad = em.getEntityManagerFactory()
										.getPersistenceUnitUtil().isLoaded(entity);

즉시 로딩과 지연 로딩

  • 즉시로딩
    • 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
    • @ManyToOne(fetch = FetchType.EAGER)
  • 지연로딩
    • 연관된 엔티티를 실제 사용할 때 조회한다.
    • member.getTeam().getName()
    • @ManyToOne(fetch = FetchType.LAZY)

즉시로딩

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

즉시로딩이기 떄문에 회원과 팀을 조회하는 2개의 쿼리가 날라갈 것 같지만, JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 통해 한번에 처리한다.

null 제약조건과 jpa 조인 전략

  • jpa는 선택적 관계면 외부 조인을 사용하고, 필수 관계면 내부 조인을 사용한다.
//LEFT OUTER JOIN (기본값)
@JoinColumn(name = "TEAM_ID", nullable = true)

//INNER JOIN
@JoinColumn(name = "TEAM_ID", nullable = false)

지연 로딩

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

-------------------------------------
Member member = em.find(Member.class, "member1');
Team team = member.getTeam(); 
team.getName(); // 팀 객체 실제 사용

실제 객체의 데이터가 필요한 순간에 db를 조회해서 프록시 객체를 초기화한다.


지연 로딩 활용

  • 회원은 팀 하나에만 소속할 수 있다. (N:1)
  • 회원은 여러 주문 내역을 가진다. (1:N)
  • 주문 내역은 상품 정보를 가진다. (N:1)
  • Member와 연관된 Team은 자주 함께 사용되므로 Member와 Team은 즉시 로딩으로 설정한다.
  • Member와 연관된 Order는 가끔 사용되므로 Member와 Order는 지연 로딩으로 설정한다.
  • Order와 연관된 Product는 자주 함께 사용되므로 Order와 product는 즉시 로딩으로 설정한다.
@Entity
public class Member {

    @Id
    private String id;
    
    private String username;

    private Integer age;

    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;

    @OneToMany(mappedBy = "member, fetch = FetchType.LAZY)
    private List<Order> orders;

}

프록시와 컬렉션 래퍼

//주문내역 조회
Member member = em.find(Member.class, "member1");
List<Order> orders = member.getOrders();
System.out.println("orders = " + orders.getClass().getName());
// 출력 결과: orders = org.hibernate.collections.internal.PersistenBag
  • 엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만, 주문 내역 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해 준다.
  • 다만, 위 코드에서 orders 컬렉션은 아직 초기화되지 않는다. member.getOrders().get(0) 처럼 컬렉션에서 실제 데이터를 조회할 때 초기화된다.
  • 주문내역과 상품은 FetchType.EAGER로 설정되어있으므로 주문내역을 조회할 때 연관된 상품도 조회된다.

JPA 기본 페치 전략

  • @ManyToOne, @OneToOne: 즉시 로딩
  • @OneToMany, @ManyToMany: 지연 로딩
  • 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용함
    • 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문에
  • 추천 방법은 모든 연관관계에 지연 로딩을 사용하는 것

컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
    • 컬렉션 조인 → 일대다 조인 → 결가 데이터가 다 쪽에 있는 수만큼 증가
    • 만약 서로 다른 컬렉션을 2개 이상 조인 → N * M 만큼 결과 데이터 증가
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
    • 팀 테이블 → 회원 테이블 일대다 조인 경우 내부 조인을 사용하면 팀에는 회원이 무조건 존재해야한다.
    • 팀에 회원이 한명도 없을 경우 내부 조인하면 회원뿐만 아니라 팀까지 조회되지 않는 문제가 발생한다.

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용한다. JPA는 CASCADE 옵션으로 영속성 전이를 제공한다.****

영속성 전이: 저장

  • 부모를 영속화할 때 연관된 자식들도 함께 영속화한다.
@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "parent", cascade = CasecadeType.PERSIST)
    private List<Child> children = new ArrayList<>();
}

@Entity
public class Child {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private Parent parent;
}
Child child1 = new Child();
Child child2 = new Child();

Parent parent = new Parent();
child1.setParent(parent);
child2.setParent(parent);
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent); //부모 저장, 연관된 자식들 저장

영속성 전이는 연관관계 매핑과 아무 관련이 없다.

단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이다.

영속성 전이: 삭제

@OneToMany(mappedBy = "parent", cascade = CasecadeType.REMOVE)
private List<Child> children = new ArrayList<>();

영속성 전이는 엔티티를 삭제할 때도 사용할 수 있다. cascade = CasecadeType.REMOVE 로 설정한다.

부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제된다.

Parent findParent = em.find(Parent.class, lL);
em.remove(parent);

만약, 영속성 전이를 설정하지 않고 위의 코드를 실행하면, db에서 부모 로우를 삭제하는 순간 자식 테이블에 걸려있는 외래 키 제약조건으로 인해, 데이터베이스의 외래키 무결성 예외가 발생한다.

CASCADE 종류

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

PERSIST와 REMOVE는 플러시를 호출할 때 전이가 발생한다.


고아 객체

고아 객체 제거 : 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능

이 기능을 사용하면 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.

@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
---------------------------------------------------------------------
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);

즉, 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 판단하고 삭제하는 기능이다.

따라서 이 기능은 참조하는 곳이 하나일 때만 사용해야 한다.

또한, 부모를 제거하면 자식도 제거된다.


영속성 전이 + 고아 객체, 생명주기

일반적으로 엔티티는 em.persist() 를 통해 영속화되고 em.remove() 를 통해 제거된다. 이것은 엔티티 스스로 생명 주기를 관리한다는 의미이다. 그런데 두 옵션(CASCADE + orphanRemoval)을 모두 활성화하면
부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.

//자식을 저장하려면 부모에 등록만 하면 된다(CASCADE).
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);

------------------------------------------------------------------------------

//자식을 삭제하려면 부모에서 제거하면 된다(orphanRemoval)
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(child1);

9장. 값 타입

기본값 타입

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private int age;
}

String, int : 기본값 타입

기본값 타입은 공유하면 안된다.


임베디드 타입(복합 값 타입)

  • @Embedded
    • 값 타입 사용
  • @Embeddable
    • 값 타입 정의
  • 회원이 상세한 데이터를 그대로 가지고 있기 보다는 근무 기간, 주소같은 타입을 따로 지정해두면 코드가 훨씬 명확해지고 객체지향적이며 응집력이 강해진다.
  • Period.isWork() 처럼 해당 값 타입만 사용하는 메서드를 만들 수 있다.
  • 임베디드 타입은 기본 생성자가 필수이다.
@Entity
public class Member {

    @Id
    @GeneratedValue
    priate Long id;

    private String name;

    @Embedded
    private Period workPeriod; //근무 기간

    @Embedded
    private Address homeAddress; //집 주소
}

@Embeddable
public class Period {

    @Temporal(TemporalType.DATE)
    private Date startDate;

    @Temporal(TemporalType.DATE)
    private Date endDate;

		public boolean isWork(Date date){
			...
		}
}

@Embeddable
public class Address {

		@Column(name="city")
    private String city;
    private String street;
    private String zipcode;
}

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

임베디드 타입은 엔티티의 값일 뿐이므로, 값이 속한 엔티티의 테이블에 매핑된다.

잘 설계한 ORM 어플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다!

임베디드 타입과 연관관계

  • 값 타입인 Address가 값 타입인 Zipcode를 포함
  • 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조
@Entity
public class Member {

    @Embedded
    private Address address;

    @Embedded
    private PhoneNumber phoneNumber;
}

@Embeddable
public class Address {

    private String street;
    private String city;
    private String state;

    @Embedded
    private Zipcode zipcode; 
}

@Embeddable
public class Zipcode {

    private String zip;
    private String plusFour;
}

@Embeddable
public class PhoneNumber {
 
    private String areaCode;
    private String localNumber;

    @ManyToOne
    PhoneServiceprovider provider;
}

@Entity
public class PhoneServiceProvider {

    @Id
    private String name;
}

@AttributeOverride: 속성 재정의

  • 매핑 컬럼명이 중복되는 상황
  • @AttributeOverride를 사용해서 매핑정보를 재정의
  • 다만, 코드가 지저분해진다.
@Entity
public class Member {

    @Id
    @GeneratedValue
    priate Long id;

    private String name;

    @Embedded
    private Address homeAddress;

    //@Embedded
    //private Address companyAddress;
		
		@Embedded
		@AttributeOverrides({
		    @AttributeOverride(name = "city", column = @Column(name = "COMPANY_CITY")),
		    @AttributeOverride(name = "street", column = @Column(name = "COMPANY_STREET")),
		    @AttributeOverride(name = "zipcode", column = @Column(name = "COMPANY_ZIPCODE"))
		})
		private Address companyAddress;
}

값 타입과 불변 객체

값 타입 공유 참조

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

address.setCity("NewCity"); // 회원 1의 address 값을 공유해서 사용
member2.setHomeAddress(address);

회원1과 회원2가 같은 address를 참조하고 있기 때문에 영속성 컨텍스트는 둘 다 city 속성이 바뀐걸로 인지

값 타입 복사

member1.setHomeAddress(new Address("OldCity");
Address address = member1.getHomeAddress();

Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

clone 메서드는 자신을 복사해서 반환하도록 구현 (임의로 구현한 메서드인듯)****

회원1의 주소 인스턴스를 복사해서 사용한다. 이 코드를 실행하면 의도한 대로 회원2의 주소만 NewCity로 변경된다.

//기본 타입은 값을 복사해서 준다.
int a = 10;
int b = a;
b = 4;

//객체는 참조 값을 준다.
Address a = new Address("Old");
Address b = a;
b.setCity("New");  //a도 같이 바뀜

객체의 공유참조는 피할 수 없다. 따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 수정자 메서드를 모두 제거하는 것이다.

불변 객체

한번 만들면 절대 변경할 수 없는 객체

객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다.

  • 생성자로만 값을 설정하고 수정자(setter)를 만들지 않는다.
@Embeddable
public class Address {

    private String city;

    protected Address() {} // JPA에서 기본 생성자는 필수다.

		//생성자로 초기 값을 설정한다.
    public Address(String city) {
        this.city = city;
    }

		public String getCity() {
				return city;
		}
}
Address address = member1.getHomeAddress();

Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

값 타입의 비교

  • 동일성 비교 : 인스턴스의 참조 값을 비교 (== 연산)
  • 동등성 비교 : 인스턴스의 값을 비교 (equals() 연산)
Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");

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

따라서 값 타입을 비교할때는 a.equals(b)를 사용해서 동등성 비교를 해야 한다.

물론 equals() 메서드에 대한 재정의가 필요하다.

재정의할때는 보통 모든 필드의 값을 비교하도록 구현한다.


값 타입 컬렉션

  • 값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable
    어노테이션을 사용하면 된다.
  • db 컬럼에는 컬렉션 저장이 불가능하므로 별도의 테이블을 추가하고
    추가한 테이블을 @CollectionTable 을 통해 매핑한다.
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS",
        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> addressHistory = new ArrayList<>();
}

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;
}

값 타입 컬렉션 사용

조회 ( @ElementCollection(fetch = FetchType.LAZY) )

//회원만 조회, 임베디드 타입인 homeAddress도 함께 조회 (select 쿼리 한번)
Member member = em.find(Member.class, 1L);

//위에서 같이 조회됨.
Address homeAddress = member.getHomeAddress();

//실제 컬렉션을 사용할 때 select 쿼리 호출
Set<String> favoriteFoods = member.getFavoriteFoods();

//select 쿼리 호출
for (String favoriteFood : favoriteFoods) {
		System.out.println("favoriteFood = " + favoriteFood);
}

값 타입 컬렉션의 제약사항

실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려하자!

또한, 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.

따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없다.

결론 : 값 타입 컬렉션보다는 일대다 관계를 사용하자

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address homeAddress;

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

}

@Entity
public class AddressEntity {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

}
profile
큰모래

0개의 댓글