본문 바로가기

일기

2023-02-09

SSE 구현을 위해 씨름을 하다가 알게된 OSIV 와 JPA 의 프록시 및 Lazy, Eager 를 더 자세히 알기 위해서 시작...

 

 

사실상 아래의 글을 그대로 배껴 쓴것이나 다름 없습니다.

글쓴이 분께 죄송하지만 그대로 배껴 쓰지 않기에는 글의 내용이 뺄것이 없고 설명이 너무 잘 되어 있기에 이를 머리에 넣기 위해서 글을 그대로 타자로 치면서 공부했습니다.

감사합니다 글쓴이님...

 

JPA Hibernate 프록시 제대로 알고 쓰기

JPA…

tecoble.techcourse.co.kr

 

 

JPA Proxy

JPA 를 사용할 때의 장점중 하나는 객체 그래프를 통해 연관관계를 탐색할 수 있다.
하지만 엔티티들은 DB 에 저장되어 있기 때문에 한 객체 조회시 연관되어 있는 엔티티들을 모두 조회하는 것 보다는 필요한 연관관계만 조회해 오는 것이 효과적이다.
이런 상황을 위해 JPA 는 지연로딩 이라는 방식을 지원하는데, 그 중에서도 일반적으로 가장 많이 사용하는 JPA 구현체인 하이버네이트(Hibernate)는 프록시 객체를 통해 지연로딩을 구현하고 있다.
JPA 프록시는 지연로딩이 가능하게 해주는 고마운 기술이지만  잘못 사용하면 예상치 못한 예외에 직면할 수 있다.

 

 

그러면 Proxy 란???

JPA 프록시 이전에 프록시란 무엇인가...
프록시는 '대신한다' 는 의미를 가지고 있는 단어인데, 이는 동작을 대신해주는 가짜 객체의 개념이라고 생각하면 된다.
프록시는 JPA 하이버네이트에만 존재하는 개념이 아니라 스프링에서 초기화 지연, 트랜잭션을 적용하거나 하는 부가 기능을 추가할 때에도 프록시 기술을 사용한다.

 

 

다시 돌아와서...

즉, 하이버네이트는 지연로딩을 구현하기 위해 프록시를 사용한다.
JPA 명세에는 지연로딩의 구현방법이 나와있지 않다. 즉, 구현체에 지연로딩의 방법을 위임하기 때문에 이 뒤의 모든 설명은 하이버네이트 기준이다.
지연로딩을 위해서는 연관된 엔티티의 실제 데이터가 필요할 때 까지 조회를 미뤄야 하는데, 그렇다고 해당 엔티티를 연관관계로 가지고 있는 엔티티의 필드값에 null 값을 넣어 둘 수는 없다.
따라서, 하이버네이트는 지연로딩을 사용하는 연관관계 자리에 프록시 객체르 주입하여 실제 객체가 들어있는 것처럼 동작하도록 해준다.
덕분에 사용자는 연관관계 자리에 프록시 객체가 들어있든 실제 객체가 들어있든 신경쓰지 않고 사용이 가능하다.
+ 프록시 객체는 지연로딩 외에도 em.getReference 를 호출하여 프록시를 호출할 수도 있다.

 

 

어떻게 가능한가???

프록시가 어떻게 실제 객체처럼 동작이 가능한가??
이는 프록시가 실제 객체를 상속한 타입을 가지고 있기 때문이다. 또, 프록시 객체는 실제 객체에 대한 참조를 보관하여 프록시 객체의 메서드를 호출했을 때 실제 객체의 메서드를 호출한다.
따라서, 실제 객체 타입 자리에 들어가도 문제없이 사용이 가능하다.
이 때문에 프록시 객체가 실제 객체의 상속본인 것은 JPA 엔티티 생성의 중요한 규칙도 만들어내게 되었는데,
'기본 생성자는 최소 protected 접근 제한자를 가져야 한다.' 는 규칙과 '엔티티 클래스는 final 로 정의할 수 없다' 이다.
만약 기본생성자가 private 이면 프록시 생성 시 super 를 호출할 수 없을 것이고, 엔티티를 final 로 선언하면 상속 또한 불가능해진다.

 

 

프록시의 초기화

그런데 최초 지연로딩 시점에는 당연히 참조값이 없는 상태이다.
떄문에 실제 객체의 메서드를 호출할 필요가 있을 때 DB 를 조회해서 참조값을 채우게 되는데, 이를 프록시 객체 초기화 라고 한다.
실젝 객체의 메서드를 호출할 필요가 있을 때 select 쿼리를 실행하여 실제 객체를 DB 에서 조회하고 참조 값을 저장하게 된다.

 

 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;

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

    public Member(String name, Team team) {
        this.name = name;
        this.team = team;
    }
    ...
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;

    public Team(String name) {
        this.name = name;
    }
    ...
}

 

  1. Member 의 연관관계 Team 은 지연 로딩으로 설정되어 있다.
  2. Member 를 조회해오게 되면 team 필드 자리에는 프록시가 들어가 있게 된다.
  3. 이 때, 프록시 Team 의 getName 메서드를 호출하게 되면 select 쿼리가 실행되고 프록시가 초기화 된다.

 

Team team = member.getTeam();
System.out.println(team.getName()); // 이 시점에 프록시 초기화

 

프록시가 실제 객체를 참조하게 되는 것이지 프록시가 실제 객체로 바뀌지는 않는다.

또, 프록시 초기화는 영속성 컨텍스트의 도움을 받는데, 영속성 컨텍스트의 관리를 받지 못하는 즉, 준영속 상태의 프록시를 초기화 한다거나 OSIV 옵션이 꺼져 있는 상황에(기본값은 켜져있다) 트랜잭션 밖에서 프록시를 초기화 하려는 경우 LazyInitializationException 에러를 만나게 된다.

때문에 프록시를 초기화 할 때는 반드시 프록시가 영속상태 여야만 한다.

 

 

그런데 ID 를 조회하면...

그런데 getName 이 아닌 getId 를 호출할 때에는 id 를 그대로 출력하고 select 쿼리는 발생하지 않는다.
이는 식별자를 조회할 때는 프록시를 초기화하지 않는다 는 의미가 된다.
프록시의 초기화 과정은 프록시 객체 내부의 ByteBuddyInterceptor 라는 클래스가, 정확히는 상위의 추상 클래스인 AbstractLazyInitializer 가 담당하하게 된다.
이제 AbstractLazyInitializer 의 초기화 로직을 확인해 보자.

 

초기화가 되지 않는다

 

@Override
public final Serializable getIdentifier() {
    if (isUninitialized() && isInitializeProxyWhenAccessingIdentifier() ) {
        initialize();
    }
    return id;
}

 

엔티티의 식별자를 조회하는 메서드를 호출하게 되면 최종적으로는 AbstractLazyInitializer 의 getIdentigier 메서드를 호출하는데, 이 때 if 문 바깥쪽을 보면 그대로 id 를 반환하는 것을 알 수 있다.

이는 AbstractLazyInitializer가 id 값을 가지고 있기 때문에 가능하다.

if 문 안쪽은 초기화되지 않았을 것과, 식별자 접근시 프록시를 초기화하는 옵션을 켰을 때 true가 되는 조건문이다.

hibernate.jpa.compliance.proxy 라는 설정값을 true 로 주었을 때 해당 옵션이 켜진다. 즉, 기본은 false 이기 때문에 일반적으로는 id 의 getId 를 호출하면 프록시가 초기화 되지 않는다.

 

다만, getId 는 자바 빈 규약에 맞는 get + 필드명 이기 때문에 getIdentifier 메서드가 호출된 것이다.

만약 findId 와 같이 getter 에 대한 자바 빈 규약을 만족시키지 못하거나 getTeamId 와 같이 식별자 이름과 매칭되지 않을 경우 getIdentifier 메서드를 호출하지 못하고 프록시가 초기화 되게 된다.

 

또 하나 주의할 점은, 프록시 객체가 가진 필드값들은 모두 null 이라는 것이다.

일반적으로는 필드로 꺼내 쓰기 때문에 상관 없지만, 만약 필드가 public 이어서 바로 접근한다면 null 에 접근하게 된다.

 

 

프록시와 영속성 컨텍스트

- 프록시가 생성되면 영속성 컨텍스트는 프록시를 반환한다.
프록시로 만들어진 엔티티에 대한 조회가 들어오면, 영속성 컨텍스트는 실제 엔티티와 프록시 객체 중 어떤 객체를 반환할까??
영속성 컨텍스트의 특징으로 동일성 보장이 있다. 그런데 위에서 한 번 프록시로 만들어진 객체는 프록시 초기화를 하더라도 실제 엔티티로 변환되지 않고 참조값만 가지게 된다.
때문에 동일성을 보장해주기 위해서 한 트랜잭션 내에서 최초 생성이 프록시로 된 엔티티는 이후 초기화 여부에 상관없이 영속성 컨텍스트가 무조건 같은 프록시 객체를 반환해주게 된다.

반대로 영속성 컨텍스트에 최초로 저장될 때 실제 엔티티로 저장될 경우, 이후로는 프록시가 아닌 실제 엔티티로 반환이 된다.
이는 지연로딩으로 인해 프록시가 들어갈 자리에도 마찬가지로, 이미 영속성 컨텍스트에 엔티티가 저장된 상태에서 해당 엔티티를 FetchType.LAZY 로 가지고 있는 엔티티를 조회하는 경우라면, 프록시 객체를 반환하는 대신 실제 엔티티를 반환해주게 된다.

'일기' 카테고리의 다른 글

2023-02-13  (0) 2023.02.13
2023-02-10  (0) 2023.02.10
2023-02-08  (0) 2023.02.09
2023-02-07  (0) 2023.02.07
2023-02-06  (0) 2023.02.06