ORM 표준 JPA (3)

이정원·2025년 2월 12일
post-thumbnail

1.상속관계

  • 관계형 데이터베이스는 상속관계가 존재하지 않는다.
  • 슈퍼타입,서브타입 관계라는 모델링 기법이 객체의 상속과 유사하다.

다음 3가지 방법은 위의 DB 논리 모델 설계를 물리 모델로 바꾸는 방법이다.

부모 클래스에서 @Inheritance(strategy = InheritanceType.?)를 통해 전략 선택이 가능하다.

1-1.조인 전략(JOINED)


ITEM 테이블의 PK를 외래키로 각각 테이블로 만들어 조인하여 상속과 비슷하게 구현하는 방법이다. 만약 Album을 추가한다면 Name,Price,(DTYPE=자식 테이블 구별)는 ITEM 테이블에 들어가고 ALBUM엔 ARTIST가 추가가 된다. INSERT는 총 2번이 되고 조회는 FK 조인을 통해 가져온다.

Dtype(저장된 자식 테이블)은 @DiscriminatorColumn을 통해 JPA가 저장해준다. 원하는 name을 넣고 싶다면 @DiscriminatorValue를 지정한다.

1-2.단일 테이블 전략(SINGLE_TABLE)-default


논리 모델을 한 테이블에 모두 넣는 전략이다. 엔티티 구별은 DTYPE으로 해결한다. 자식 필드는 모두 null이 허용 되어야하고 쿼리를 여러번 날리지 않아 성능상 이점이 있다.

1-3.구현 클래스마다 테이블 전략(TABLE_PER_CLASS)


ITEM 테이블을 없애고 각각 구체적인 NAME,PRICE를 가진 독립적인 테이블로 만드는 전략이다. item_id만 아는 상황에서 부모 클래스인 ITEM으로 조회시 union을 통해 모든 테이블을 쿼리하기 때문에 실무에서 적절하지 않다.

2.@MappedSuperclass


엔티티가 아니고 DB 테이블과 관계없이 자바 객체에서 상속받는 자식 클래스에 공통된 필드를 포함시킬수 있다.

    private String createdBy;
    private LocalDateTime createdDate;
    private String lastmodifiedBy;
    private LocalDateTime lastaModifiedDate;

만약 테이블에 위와 같은 필드가 전부 필요하다라고 주문이 들어왔다.
그러면 아래와 같이 클래스를 정의하고 필요한 class에 상속받으면 된다.

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastmodifiedBy;
    private LocalDateTime lastaModifiedDate;
    //getter setter 추가
}
    

엔티티가 아니라서 테이블 생성이 안되고,EntityManager에서 조회가 불가능하다.

3.프록시와 연관관계

private static void printMemberAndTeam(Member member) {
        String username=member.getUsername();
        System.out.println("username = " + username);

        Team team = member.getTeam();
        System.out.println("team = " + team.getName());
    }

만약 위와 같은 로직이 있다. Member를 조회하는 시점 DB에서 연관관계를 가진 Team 까지 SQL 쿼리로 가져오면 효율적이다.


private static void printMember(Member member){
        System.out.println("member = " + member.getUsername());
    }

반면 해당 로직은 Team까지 가져오면 비효율적이다. JPA에서 지연 로딩과 프록시로 이런 문제를 해결 한다.

3-1.프록시

JPA에서 em.getReference()라는 메서드를 지원한다. em.find()는 데이터베이스를 통한 쿼리로 실제 엔티티를 조회한다. 반면 reference는 DB 조회를 미루는 가짜(프록시)엔티티 객체를 조회한다.

Member findMember = em.getReference(Member.class, member.getId());
  • getReference() 메서드는 프록시 객체(proxy object) 를 반환한다.
  • 실제 엔티티를 조회하지 않고, 식별자(id)만 가지고 있는 지연 로딩(lazy loading) 객체이다.
  • 실제 DB에 있는 값을 조회할때는 쿼리를 실행한다.

프록시 객체의 초기화

Member member = em.getReference(Member.class, “id1”); 
member.getName();

를 실행하면 다음과 같이 진행된다.

getName()으로 실제 값을 호출하면 Proxy에서 영속성 컨텍스트로 초기화를 요청한다. 그러면 EM은 실제 DB를 조회해서 Member 객체를 반환하고 Proxy의 target에 Member의 주소를 저장한다.

프록시 특징
1. 프록시 객체는 처음 사용할때 한번만 초기화한다.
2. 프록시 객체는 초기화 (영속성 컨텍스트의 DB 조회와 target에 실제 객체 주소 저장)를 통해 실제 엔티티에 접근 가능하다.
3. 프록시 객체는 원본 엔티티를 상속받는다. 따라서 ==(메모리 비교) 비교대신 instance of를 사용한다.
4. 영속성 컨텍스트에 찾는 엔티티가 있으면 em.getReference()를 호출해도 실제 엔티티를 반환한다.(<-> 프록시를 먼저 조회하면 find에서도 프록시가 반환한다.)
5. 영속성 컨텍스트의 도움을 받을수 없는 준영속 상태일때,프록시를 초기화하면 문제가 발생한다.

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

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

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

            em.detach(refMember);
            System.out.println(refMember.getUsername());

위와 같이 영속성 컨텍스트에서 refMember를 분리하고 DB 값을 가져올때 Could not initialize proxy라는 exception이 발생한다.

프록시 확인

Member refMember=em.getReference(Member.class,member1.getId());
System.out.println("refMember.getClass() = " + refMember.getClass());
Hibernate.initialize(refMember);
System.out.println("isLoaded: "+emf.getPersistenceUnitUtil().isLoaded(refMember));

3-2.지연 로딩(Lazy Loading)

Member를 조회하는 로직을 수행할때 불필요한 연관관계를 가진 Team까지 조회를 하지 않으려면 다음과 같은 프록시 기반의 지연 로딩(Lazy Loading)을 통해 해결이 가능하다.

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

Team 데이터 관련 조회 발생시 실제 Team 테이블까지 쿼리를 진행한다.

3-3.즉시 로딩(Eager Loading)

반대로 비지니스 로직상 Member와 Team을 같이 쓰는 경우가 많다면 다음과 같은 즉시 로딩을 사용한다.

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

즉시 로딩을 적용하면 예상하지 못한 SQL이 발생하고 JPQL에서 N+1 문제가 발생한다.

List<Member> members = em.createQuery("select m from Member m", Member.class)
                    .getResultList();
//SQL: select * from member;

위의 JPQL을 실행할 때, team 필드가 즉시 로딩(FetchType.EAGER)이면
JPA는 첫 번째 쿼리(SELECT * FROM Member로 모든 회원을 조회한 후),
각 Member 객체의 team 필드를 채우기 위해 추가적으로 N개의 SELECT 쿼리를 실행한다.

@ManyToOne,@OneToOne은 기본이 즉시 로딩(EAGER)이다.

따라서 실무에서 가급적 지연 로딩만 사용한다.

4.영속성 전이(CASCADE)

영속성 전이란 특정 엔티티를 영속 상태로 만들때 연관된 엔티티도 함께 영속성 컨텍스트에 추가하는 기술이다.

@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "parent",cascade = CascadeType.ALL)
    private List<Child> childList=new ArrayList<>();
 }

위와 같이 Collection에 child 엔티티가 추가된다면 child 개별로 persist을 하지 않더라도 parent만 persist 한다면 반영이된다.

연관된 엔티티를 영속화 하는것일뿐 그이상 그이하도 아니다.
만약 List 인스턴스들이 다른 엔티티와도 연관되어 있다면 cascade는 사용하면 안된다.

CASCADE 종류

  1. ALL: 모두 적용
    child 전부 자동 persist, remove(parent) 호출 시 전체 child 삭제

  2. PERSIST: 영속
    remove(parent)를 호출해도 child1은 자동 삭제되지 않음

  3. REMOVE: 삭제
    persist (parent)를 호출해도 child1이 자동 persist되지 않음,parent를 삭제하면 child들도 삭제

  4. MERGE: 병합
    JPA가 DB에서 기존 parent를 다시 조회하고, 변경된 데이터를 복사하여 업데이트, child 자동 persist 된것 아님

  5. REFRESH
    부모 엔티티를 refresh하면 자식 엔티티도 자동으로 DB에서 다시 불러옴

  6. DETACH
    영속성 컨텍스트에서 제거됨

5.고아 객체

부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제

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

부모 객체를 삭제하면 자식 객체는 모두 삭제되어 CASCADE.REMOVE 처럼 동작한다.
마찬가지로 참조하는곳이 하나일때만 사용한다.

CASCADE.ALL + orphanRemoval true를 함께 사용하면 부모 객체를 통해 자식 엔티티의 생명 주기를 관리한다.
ex.findParent.getChildList().remove(0)를 하면 orphanRemoval true로 인해 같이 삭제된다. == 부모 메서드로 인한 자식 엔티티 생명주기 관리

6.값 타입

6-1.JPA의 데이터 타입

  • 엔티티 타입: @Entity로 정의하는 객체 (데이터 변경시 식별자로 지속적인 추적 가능)
  • 값 타입: integer,int,String처럼 단순히 값으로 사용하는 자바 기본타입이나 객체(데이터 변경시 추적 불가능)

값 타입 분류

기본 값 타입: 자바 기본 타입(int,double)
래퍼 클래스(Integer,Long),String -> 불변(Immutable)

임베디드 타입: 여러 개의 필드를 하나의 객체로 묶어서 재사용할 수 있도록 하는 기능( -> 값 타입)

컬렉션 값 타입: 기본 값,임베디드 타입을 컬렉션에 넣을수 있음

6-2.임베디드 타입

  • int, String과 같은 값 타입,기본 생성자가 필수
  • 엔티티의 일부로 저장되지만, 별도의 테이블이 생성되지 않음
  • 값 타입이므로 엔티티와 다르게 독립적인 식별자(ID)가 없기 때문에 변경과 추적이 불가능
  • 임베디드 타입을 사용하기 전과 후의 DB 매핑 테이블은 같다.
  • 임베디드 클래스의 메서드 활용이 가능하여 의미에 맞는 비즈니스 로직을 내부에 정의할수 있다.
  • @AttributeOverides를 통한 중복 임베디드 클래스 사용 가능
@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;

여러 엔티티에서 임베디드 타입(값 타입)을 공유하게 되면 다음과 같이 부작용이 발생한다.

Address address=new Address("city","street","10000");

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과 member2의 city column이 동시에 바뀐다.

해결 방법은 복사해서 사용하는것이다.

Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
member2.setHomeAddress(copyAddress);

여기서 문제는 임베디트 타입과 같은 값 타입은 자바에서 Primitive 타입이 아닌 객체이다. 따라서 객체의 공유 참조는 피할 수 없다. -> 불변 객체(Immutable object)로 설정하면 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 된다.

또한 값 타입을 비교할때 equals를 재정의한다.

6-3.값 타입 컬렉션

엔티티의 컬렉션은 DB 테이블에서 담을 구조가 없다.(value로 값만 넣을수 있다.) OneToMany이기 때문에 컬렉션을 저장하기 위해 별도의 테이블로 구성해야한다.

위와 같이 따로 식별자 ID를 두면 엔티티가 되기 때문에 임베디드 타입 규칙에 어긋난다. 따라서 모든 값타입을 PK로 지정한다.


    @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>addressHistory=new ArrayList<>();

Member 엔티티에 위와 같이 작성하고 컬렉션에 값을 추가하는 코드를 실행하면 다른 테이블인데도 불구하고 값이 정상적으로 persist 된것을 확인할수 있다. 이유는 값 타입 컬렉션은 생명주기를 해당 엔티티에 소속되기 때문이다. 즉 cascade ALL과 고아객체 기능을 기본으로 가지고 있다 볼수 있다.

1.조회

임베디드 타입는 엔티티에 소속된 값 타입이기 때문에 같이 불러지고 컬레션은 기본적으로 지연 로딩(LAZY)이다.

2.삭제
equals와 hashcode가 구현이 제대로 되어있다면 다음 코드에서 정상 동작한다.

findMember.getAddressHistory().remove(new Address("old1","street","20000"));

파라미터의 객체와 컬렉션을 순차적으로 equals()를 통해 비교한후 동일한 객체를 삭제한다.

3.컬렉션 수정

findMember.getAddressHistory().remove(new Address("old1","street","20000"));
findMember.getAddressHistory().add(new Address("newCity","street","30000"));

위와 같이 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된
모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티

0개의 댓글