JPA 프록시

최지환·2023년 5월 23일
0

졸업작품-동네줍깅

목록 보기
6/11
post-thumbnail

프록시

JPA 의 프록시에 대해서 정리해보자.

예전에 강의를 듣고 프록시 내용을 안다고 생각했다. 하지만 졸업작품 프로젝트를 하면서 프록시와 관련하여 문제가 발생하였고, 이를 제대로 해결하지도, 해결하고도 정확히 이해를 하지도 못했다.

시간에 쫓긴다 생각하고, “나중에 시간 내서 따로 공부하자” 라고 생각하고 정리를 제대로 하지 않았다.

(아마 시간이 지나도 안했을 것이다.)

이후 코드살롱 모임 시간에 발생했던 문제와 해결 방법을 말씀드렸지만, 이와 관련된 질문에 제대로 답변을 드리지 못했다. 그래서 늦게라도 정리하기로 마음 먹었고 정리를 해보았다.


문제가 대두 되었던 상황

다음과 같이 Board 엔티티와 Member 엔티티가 있었다.

Board 와 Member 는 n:1 관계로 서로 연관이 되어있었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id", nullable = false)
    private Long id;

    @Column(nullable = false)
    private LocalDateTime creatingDateTime;

    **@ManyToOne(fetch = FetchType.LAZY) -> 이 부분 추가 후 에러 발생**
    @JoinColumn(name = "member_id")
    private Member writer;

 
    @Embedded
    private Title title;

    @Embedded
    private Content content;
		//...
}

@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Member {

    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

   
    @Column(nullable = false)
    private String alias;

		//... 
   }

내가 Board 쪽에서 @ManyToOne(fetch = FetchType.LAZY) 라고 LAZY로딩으로 패치 타입을 추가하면서 문제가 발생하였다.

발생한 에러는 다음과 같다.

HHH000143: Bytecode enhancement failed because no public, protected or package-private default constructor was found for entity: mokindang.jubging.project_backend.domain.member.Member. Private constructors don't work with runtime proxies!; nested exception is org.hibernate.HibernateException: HHH000143: Bytecode enhancement failed because no public, protected or package-private default constructor was found for entity: mokindang.jubging.project_backend.domain.member.Member. Private constructors don't work with runtime proxies!

간단하게 말해서 Member 의 기본생성자가 Private 타입이라 프록시 객체를 생성 할 수 없다는 문제였다.

따라서 에러문에서 알려준 해결 책 대로 protected 타입으로 기본 생성자를 열어주었고, 문제를 해결 하였다.

하지만 왜 이렇게 문제를 해결 할 수 있는지 명확하게 설명할 수 없었다.


프록시 개념이 나오게 된 이유

객체는 객체 그래프로 연관된 객체들을 탐색을 한다. 이때 탐색 대상인 객체가 데이터베이스에 저장되어 있으면 자유롭게 객체 그래프를 탐색을 할 수가 없다. JPA 에서는 이런 문제를 해결하기 위해서 프록시라는 기술을 사용한다. 즉 특정 객체에서 연관된 객체를 탐색 하기 위해 관련된 모든 객체들을 데이터베이스에서 조회하는 것이아니라, 실제 사용하는 시점에 데이터 베이스에서 조회를 한다. 이렇게 실제 사용 시점에서 데이터베이스에 관련 값들을 조회를 할 수 있도록 해주는 것이 프록시라는 기술이다.

JPA의 프록시(Proxy)

프록시는 말 그대로 대신하다라는 의미를 가진다. 즉 프록시 객체는 실제 객체를 대신 해주는 역할을 하고있다.

하이버네이트에서는 지연 로딩을 사용하는 연관관계에서는 프록시 객체를 주입하여 실제 객체가 있는 것처럼 보이게한다. JPA에서 지연로딩을 지연 로딩이 제대로 동작하려면 실제로 연관된 엔티티가 사용 될때 까지 조회를 미루다가 실제 데이터를 사용 할 때 조회를 해야한다. 그렇다면 연관된 엔티티를 조회하기 전까지는 연관된 엔티티를 null 로 갖고 있는가? 라는 생각을 할 수 있다. 하지만 그렇지는 않다. 프록시 객체를 갖고 있는다. 따라서 연관된 엔티티를 조회하기 전까지는 프록시 객체를 갖고 있고, 이를 통해 실제 객체가 있는 것 처럼 동작을 한다.

정리하자면 JPA에서 지연 로딩을 하기 위해서 프록시라는 객체로, 연관된 엔티티 역할을 할 수있도록 했다. 이때 실제 객체에 접근을 하는 상황에서 데이터베이스 조회가 일어난다.

그러면 지연 로딩 상황에서 실제 데이터를 조회하기 전까지 들고 있는 프록시 객체는 무엇이고, 어떤 정보를 갖고 있을까? 라는 생각이 든다. - 이는 밑에서 설명

실제 객체 타입을 상속한 프록시 객체

프록시는 실제 객체를 상속한 타입을 가지고 있다. 프록시 객체의 메서드를 호출 했을 때 실제 객체의 메서드를 호출한다. 따라서 실제 객체 타입위치에 프록시가 들어가도 자연스럽게 사용할 수 있다.
이런 특징 때문에 “엔티티의 기본 생성자는 protected or public 의 접근 제한자를 가져야한다”는 규칙과 “엔티티 클래스는 final 로 정의할 수 없다는 규칙”이 생기게 되었다.

→ 만약 생성자의 접근 제한자가 private 라면 프록시 생성 시 super 를 호출 할 수 없어 생성이 불가하다!


코드로 보면서 프록시를 이해해보자

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id", nullable = false)
    private Long id;

     **@ManyToOne(fetch = FetchType.LAZY)**
    @JoinColumn(name = "member_id")
    private Member writer;

 ...
 }

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

   
    @Column(nullable = false)
    private String alias;

		... 
   }

BoardMember 의 연관 관계는 지연 로딩으로 설정 되어 있기 때문에 Board 를 통해 Member 를 조회하면 Member 에는 프록시 객체가 들어가게 된다.

Board board = em.find(Board.class, 1L);
Member writer = board.getWriter(); //writer 는 프록시 객체임
System.out.println(writer.getAlias()); // writer 내부에 getAlias 호출 시 실제 데이터를 불러오기 위한 쿼리가 나감

위와 같이 조회한 경우를 살펴보자. 로그를 쉽게 보기 위해 sout 을 추가하였다.

실행 코드

				Board board = em.find(Board.class, 1L);
        System.out.println("board.getWriter() 실행");
        Member writer = board.getWriter(); //writer 는 프록시 객체임
        System.out.println("board.getWriter() 실행 종료");
        System.out.println("writer.getAlias() 실행");
        System.out.println(writer.getAlias()); // writer 내부에 getAlias 호출 시 실제 데이터를 불러오기 위한 쿼리가 나감
        System.out.println("writer.getAlias() 실행 종료");

쿼리 로그

과정을 살펴보자.

처음 Board board = em.find(Board.class, 1L); 를 통해 select 문이 나가는 겄을 확인할 수 있다.

이후 writer.getAlias() 실행 시점에 select 문을 통해서 실제 writer(member 객체) 에 대한 데이터를 조회하는 select 가 나간다.

즉 실질적으로 writer 에 접근하는 시점 전까지는 Boardwriter를 프록시 객체 상태로 갖고 있다가. 실질적으로 writer에 접근(getAlias())시점에 writer에 대한 정보를 DB로부터 가져온다.

이것만 가지고는 프록시 객체라는 것이 잘 와닿지 않기 때문에 이번에는 디버깅을 해보았다.

브레이크 포인트를 하단과 같이 찍어 두고 실행 해보자

해당 시점에서 board 를 살펴보자.

board 내부에 writer 를 보면 idaliasnull 로 되어있는 것을 볼 수 있다. 또한 Member 타입이 아닌 Member$HibernateProxy$54mLqwY4라는 타입을 갖고있다.

이것이 프록시 객체이다. 프록시 객체 writer 를 한단계 더 살펴보면 내부에 $$_hibernate_interceptor 라는 정보를 갖고 있다. 살펴보자

여기서 눈에띄는 값들은 id와, entityName 이다.

프록시 writer에서 실제 writer에 해당하는 데이터에 가져오기 위해, id을 이용한다고 추측할 수 있다.

또한 id 라는 값은 1로 지정이 되어있는데, 이는 실제 writer 객체의 id 값이다. 그런데 이기서 이상한 점이 한가지 있다.

위 이미지 최하단을 보면 프록시 writeridnull 이다. 그럼 프록시 상태에서 getId를 하면 어떤 id r가 반환될까?

  • getId() 를 하면?

    직접 확인 해보자.

    위처럼 getId() 를 찍어보았다.

    위와 같이 프록시 writer 상태에서 id 값이 1이 조회되는 것을 확인 할 수 있다.

    정리하자면 프록시 상태에서 getId를 호출 하면 실제 객체의 id 값이 반환된다!

    → 연관 관계의 주인이 board이기 때문에 이미 member의 id 를 알고 있기 때문에 프록시 생성 시점에 id를 넣어줄 수 있다.

    다만 id의 접근제어자가 public 인경우 writer.id 형식으로 접근하면 null 이 반환된다.

    즉 메서드 호출 방식으로 접근 해야한다.


    좀더 들어가서 아까 디버깅을 하면서 보았던 ByteBuddyInterceptor 가 상속한 AbstractLazyInitializer 로 가보자.

    엔티티의 getId를 호출하고 흐름을 따라가다 보면

    getIdentifier 이라는 메서드를 호출하게 된다. 이때 프록시 객체가 초기화되고(isUninitalized), 식별자에 대한 접근이 초기화되지 않은 경우(isInitializeProxyWhenAccessingIdentifier) 프록시 객체의 초기화를 진행하고, 그렇지 않는다면 id를 반환한다.

    hibernate.jpa.compliance.proxy 라는 설정을 true 로 해준경우 해당 옵션이 켜진다. default 는 false이다.

    현재 상황에서는 isUninitalized 가 true 이지만 isInitializeProxyWhenAccessingIdentifier 가 항상 false 이기 때문에 getId 호출 시 id 가 반환이 된다.

    getIdentifier 가 호출 되기 위해서는 자바 빈 규약에 맞는 메서드 네이밍이 되어야한다. getId 가 아니라 getMemberId 형태로 메서드 호출을 하면 getIdentifier가 호출되지 않기 때문에 초기화가 일어난다.

    여기서 알수 있는 점은 getId() 호출 시는 초기화가 일어나지 않는다는 것이다!
    즉 id 값을 메서드를 통해 접근하면 추가적인 쿼리가 나가지 않게된다.

프록시 객체를 갖고 있는 상태에서 실제 데이터를 받아오면 실제 객체로 바뀌나?

지연 로딩 사용시 연관된 엔티티를 프록시 객체로 갖고 있는다. 이후 실제 데이터베이스를 통해서 실제 객체의 데이터를 받오면 프록시 객체가 실제 객체로 변경이 되는 것인가? 라는 생각이 들 수 있다.

확인 해보자.

하단 이미지는 writer.getAlias 호출 후 writer 의 상태이다

위에서 확인한 대로라면 DB로부터 실제 객체에 대한 정보를 받아왔지만, 여전히 프록시 객체를 갖고 있는것으로 확인 된다.

그러면 자연스럽게 “실제 데이터베이스에서 writer 의 정보를 가지고 왔는데 왜 null로 갖고 있지?그리고 어떻게 실제 데이터에 접근을 하지?” 라는 의문이 든다.

프록시 객체가 실제 데이터에 어떻게 접근을 할까?

실제 데이터 접근 방법

writer.$$_hibernate_interceptor 을 살펴보자

target 이라는 이름으로 실제 객체를 대상를 가르키고 있는 것을 알 수 있다.

즉 실제 데이터를 조회한 후, 실제 엔티티로 연관된 프록시 엔티티가 바뀌는 것이 아니라

프록시 엔티티가 실제 엔티티를 가르키는 것을 알 수 있다.

위 상황에서는 board → 프록시 writer → 실제 writer 의 형태이다.

따라서 writer 에서 getAlias 를 조회하면 프록시의 alias 가 반환되는 것이 아니라 프록시 writer실제(target)getAlias 를 호출하여 abc 라는 alias 를 얻는다.


정리

  • Lazy 로딩시 프록시 객체를 통해서 Lazy 로딩을 가능하게 한다.
    • 실제 사용하지 않는 연관 연티티를 프록시 객체로 들고 있다.
    • id 조회를 제외한 데이터 접근이 필요한 메서드 접근 시 실제 데이터를 호출한다.
  • 프록시 객체는 실제 객체를 상속받은 객체이다
    • 따라서 실제 객체를 상속 받기 위해 엔티티 클래스는 final 로 로 정의하면 안되고, 기본 생성자는 protected 이상의 접근 제어자를 가져야 한다.
  • 실제 데이터 조회 후 엔티티에 있던 프록시 객체가 실제 객체로 변환되는게 아니라, 프록시 객체는 실제 객체를 target 으로 가르키고 있다.
    • 이후 메서드 조회시 프록시 객체는 실제 객체를 호출한다.
  • 프록시 객체는 실제 객체의 id 값을 갖고 있다. 이는 id 값 접근을 위해서 실제 데이터를 불러오지 않아도 된다는 장점이 있다.
    • 이런 이점을 갖기 위해서는 id에 자바 빈 규약에 맞는 getter 로 접근을 해야한다.

0개의 댓글

관련 채용 정보