JPA 기초 6 - 프록시

JPA 기초 6 - 프록시

지연로딩의 핵심인 프록시에 대해 학습합니다

 

📕 프록시(Proxy)


프록시는 한글로 대리자, 대리인이라는 뜻을 갖는다.

프로그래밍에는 인디렉션(Indirection)이라는 개념이 있다.

 

📜 Dennis DeBruler

컴퓨터 과학은 인디렉션 계층을 한 단계 더 만들면 모든 문제를 풀 수 있다고 믿는 학문이다.

 

위키백과등에서 인디렉션에 대한 내용을 참고해보면 두 계층사이에 어떤 문제가 발생했을 때 두 계층 사이에 별도의 계층을 하나 추가하면 해당 문제가 깔끔하게 해결되는 경우가 많다는 것이다.

 

이를 프로그래밍 용어로 간접참조 혹은 추상화로 요약할 수 있을 것 같다.

 

프록시는 이 인디렉션과 궤를 같이하며 프록시 패턴은 SOLID중 개방폐쇄원칙(OCP)의존역전원칙(DIP)을 충실히 따른다.

 

그렇다면 프록시가 뭘까?

 

프록시는 특정 개체의 사이에 위치한 진짜인 척을 하는 가짜 개체다.

여기서 프록시가 목표로 하는 것은 본연의 로직에는 전혀 영향을 주지 않으면서 흐름을 제어 하는 것이다.

그래서 두 개체는 사이에 프록시라는 가짜 개체가 있는지 전혀 모르며 서로를 진짜라고 신뢰하고 통신을 진행하게 된다.

코드를 통해 보자.

 

image

 

통상적인 프록시 패턴의 클래스 다이어그램이다.

이를 코드로 풀어내면 다음과 같다.

 

// file: 'Client.java'
public class Client {
    Interface anInterface;

    public Client(Interface anInterface) {
        this.anInterface = anInterface;
    }

    public void callOperation(){
        anInterface.operation();
    }
}

 

// file: 'Interface.java'
public interface Interface {
    void operation();
}

 

// file: 'Real.java'
public class Real implements Interface{
    @Override public void operation() {
        System.out.println("Real Operation");
    }
}

 

// file: 'Proxy.java'
public class Proxy implements Interface {
    Interface real = new Real();

    @Override public void operation() {
        System.out.println("Proxy Operation");
        real.operation();
    }
}

 

// file: 'ClientTest.java'
class ClientTest {
    @Test
    void proxy() {
        Client client = new Client(new Proxy());

        client.callOperation();
    }
}

/*------------출력------------
        Proxy Operation
        Real Operation
 ----------------------------*/

 

중간에 Proxy의 로직이 추가될 수 있으면서

실제 결과는 Real의 연산결과가 나옴을 볼 수 있다.

 

🤔 프록시를 사용하는 이유 ?


프록시가 대충 뭔지 알았다면 프록시를 사용하는 이유에 대한 납득이 필요하다.

프록시는 대표적으로 다음과 같은 역할들을 수행할 수 있다.

 

  1. 흐름제어
  2. 캐싱
  3. 지연연산

 

💡 흐름제어


서문의 예시코드와 같다.

예시코드에서는 단순히

 

System.out.println("Proxy Operation");

 

한줄만을 추가했지만, 이곳에 개발자가 임의의 코드를 추가할 수도 있다.

보통 조건문을 사용하여 흐름을 제어하게 된다.

이를 극대화시켜서 사용하는 예로 포워드 프록시, 리버스 프록시 등이 있다.

스프링에서는 이를 활용해 AOP 기술을 구현하고 있으며 AOP 기술을 통해 구현되는 대표적인 기술로 @Transactional이 있다.

 

💡 캐싱


📜 시나리오

특정한 텍스트를 읽어 복호화하여 반환해주는 기능이 있다.

매번 새로운 텍스트를 반환하니 리소스의 낭비가 심해 이를 캐시하고자 한다.

 

// file: 'TextFileReader.java'
public interface TextFileReader {
    SecretText read();
}

 

// file: 'RealTextFileReader.java'
public class RealTextFileReader implements TextFileReader {
    private String plainText;

    public RealTextFileReader(String plainText) {
        this.plainText = SecretUtil.decode(plainText);
    }

    @Override
    public SecretText read() {
        System.out.println("RealTextFileReader reading text from : " + plainText);
        return new SecretText(plainText);
    }
}

 

// file: 'TextFileReaderTest.java'
class TextFileReaderTest {
    @Test
    void noCache() {
        TextFileReader reader = new RealTextFileReader("text");
        reader.read();
        reader.read();
        reader.read();
        reader.read();
        reader.read();
    }
}

 

출력은 다음과 같다.

 

RealTextFileReader reading text from : text
RealTextFileReader reading text from : text
RealTextFileReader reading text from : text
RealTextFileReader reading text from : text
RealTextFileReader reading text from : text

 

이를 프록시 패턴을 활용해 캐시해보자

 

// file: 'ProxyTextFileReader.java'
public class ProxyTextFileReader implements TextFileReader {
    private String plainText;
    private SecretText secretText;

    public ProxyTextFileReader(String plainText) {
        this.plainText = SecretUtil.decode(plainText);
    }

    @Override
    public SecretText read() {
        // 가지고 있는 파일이 없거나, 가지고 있는 파일과 요청받은 파일이 다른 경우 새로운 파일을 생성하여 캐시
        if(secretText == null || !secretText.getPlainText().equals(plainText)) {
            System.out.println("RealTextFileReader reading text from : " + plainText);
            this.secretText = new SecretText(plainText);
            return this.secretText;
        }

        System.out.println("RealTextFileReader use cache");
        return new SecretText(plainText);
    }
}

 

생성자를 통해 ProxyTextFileReader가 초기화되면 내부에 복호화 한 파일을 캐시해두고 이후 호출되면 캐시해둔 파일을 즉시 리턴하는 로직이다.

 

// file: 'TextFileReaderTest.java'
class TextFileReaderTest {
    @Test
    void useCache() {
        TextFileReader reader = new ProxyTextFileReader("text");
        reader.read();
        reader.read();
        reader.read();
        reader.read();
        reader.read();
    }
}

 

RealTextFileReader reading text from : text
RealTextFileReader use cache
RealTextFileReader use cache
RealTextFileReader use cache
RealTextFileReader use cache

 

이처럼 프록시를 사용하여 기존의 아키텍처에 영향을 주지 않는 선에서 캐시 기능을 간단하게 추가할 수 있다.

 

💡 지연연산


지연연산이라는 것은 어떤 연산이 정말로 실행되어야 하기 전까지 해당 연산의 실행을 유예하는 것이다.

이렇게 함으로써 필요하지 않은 연산을 최소화하여 성능을 극대화시킬 수 있다.

 

구현은 간단하다.

진짜 객체를 프록시로 한번 래핑하면 된다.

 

// file: 'LazyTextFileReader.java'
public class LazyTextFileReader implements TextFileReader{
    private String plainText;
    private TextFileReader reader;

    public LazyTextFileReader(String plainText) {
        this.plainText = plainText;
    }

    @Override
    public SecretText read() {
        if(reader == null){
            reader = new RealTextFileReader(plainText);
        }
        System.out.println("lazy initialisation");
        return reader.read();
    }
}

 

// file: 'TextFileReaderTest.java'
class TextFileReaderTest {
    @Test
    void lazy() {
        TextFileReader reader = new LazyTextFileReader("text");
        reader.read();
        reader.read();
        reader.read();
        reader.read();
        reader.read();
    }
}

 

lazy initialisation
RealTextFileReader reading text from : text
lazy initialisation
RealTextFileReader reading text from : text
lazy initialisation
RealTextFileReader reading text from : text
lazy initialisation
RealTextFileReader reading text from : text
lazy initialisation
RealTextFileReader reading text from : text

 

이렇게 하면 read()가 정말로 실행되야 할 순간이 오면 그제야 진짜 객체를 호출하여 연산을 시작한다.

 

🤔 JPA에서의 프록시 ?


JPA에는 연관관계 매핑 혹은 엔티티 매핑이라고 부르는 기법이 있다.

데이터베이스의 테이블과 자바의 클래스를 매핑하여 쿼리 작성의 부담을 줄이기 위한 목적을 갖는다.

즉, 이 방법을 사용하게 되면 데이터베이스의 외래키를 자바 코드로 구현할 때 자바의 객체 그래프 형식으로 변환된다. (참조, 참조, 참조)

 

// file: 'Member.java'
@Entity
public class Member {
    @Id
    private Long id;
    
    private String name;
    
    @ManyToOne
    private Team team;
    
    public Team getTeam(){
        return this.team;
    }
    
    public String getName(){
        return this.name;
    }
}

 

// file: 'Team.java'
@Entity
public class Team {
    @Id
    private Long id;
    
    private String name;
    
    public String getName(){
        return this.name;
    }
}

 

public void printMemberAndTeam(String memberId){
    Member member = memberRepository.findById(memberId); // left outer join 발생
    System.out.println("회원 이름 : " + member.getName());
    System.out.println("소속 팀 이름 : " + member.getTeam().getName());
}

 

이런 코드가 있다.

데이터베이스에서 Member를 가져오면 @ManyToOne으로 인해 Team도 같이 가져와진다.

이때 발생하는 쿼리는 다음과 같다.

 

select
        member0_.id as id1_1_0_,
        member0_.name as name2_1_0_,
        member0_.team_id as team_id3_1_0_,
        team1_.id as id1_2_1_,
        team1_.name as name2_2_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.team_id=team1_.id 
    where
        member0_.id=?

 

그리고 MemberTeam의 데이터를 모두 출력(사용)하고 있으므로 이 경우에는 별다른 문제가 없다.

 

하지만 다음과 같은 경우엔 어떨까?

 

public void printMemberAndTeam(String memberId){
    Member member = memberRepository.findById(memberId);
    System.out.println("회원 이름 : " + member.getName());
}

 

MemberTeam의 데이터를 join하여 모두 가져온게 분명하지만 실제로는 Member의 데이터만을 사용하고 있다.

이럴 경우에는 Team을 굳이 가져올 필요가 없으며, 오히려 가져오는 것이 리소스의 낭비이다.

이럴 때 프록시를 활용한 지연로딩을 사용하게 된다.

 

// file: 'Member.java'
@ManyToOne(fetch = FetchType.LAZY)
private Team team;

 

이렇게 연관관계 매핑에 지연로딩(LAZY)를 사용하겠다고 선언하면 실제 쿼리의 발생순서는 다음과 같다.

 

public void printMemberAndTeam(String memberId){
    Member member = memberRepository.findById(memberId); // Select Member 쿼리 발생, 다만 Member를 가져오되 Team은 프록시로 가져옴
    System.out.println("회원 이름 : " + member.getName()); // 쿼리 발생하지 않음
    System.out.println("소속 팀 이름 : " + member.getTeam().getName()); // Team이 실제로 사용되므로 Select Team 쿼리가 발생
}

 

위의 예시가 바로 지연로딩(fetch = LAZY)의 전부다.

이 지연로딩을 사용하는 이유는 위 프록시의 지연연산 예시와 같이

꼭 필요한 연산만 행해서 성능의 극대화를 꾀한다.

라고 볼 수 있다.

 

그리고 JPA를 더 공부하면 알게될 N+1 문제를 바로 이 지연로딩을 통해 회피한다.

 

그렇다면 항상 지연로딩이 옳은것일까?

 

그건 또 아니다.

 

바로 위의 예시에서 보다시피,MemberTeam을 모두 사용하기 위한 목적으로 Member를 조회해왔음에도 지연로딩으로 인해 select 쿼리가 두번 발생했다.

애초에 이를 join을 사용해 select 쿼리했다면 한번의 select 쿼리로 해결할 수 있었을 것이다.

 

이를 즉시로딩(fetch = EAGER)이라고 하며 이처럼 즉시로딩이 더 효율적인 경우도 매우 많다.

JPA를 더 공부하게 되면 나중에 fetch join이라는 것을 배우게 될 것인데, 이것이 바로 즉시로딩을 활용한 예라고 볼 수 있다.

 

🤔 로딩 전략은 어떻게 ?


fetch의 기본 설정은 다음과 같다.

 

💡 @~ToOne (@OneToOne, @OneToMany) - 즉시로딩(FetchType.EAGER)

💡 @~ToMany (@ManyToOne, @ManyToMany) - 지연로딩(FetchType.LAZY)

 

JPA의 기본 로딩 전략은 연관된 엔티티가 한개(~ToOne)면 즉시 로딩을,컬렉션(~ToMany)이면 지연 로딩을 사용한다.

컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 메모리에 퍼올릴 수 있기 때문이다.

예를 들어 특정 회원이 연관된 컬렉션에 데이터를 수천만건 등록했는데 이를 즉시 로딩으로 설정해둔 경우 해당 회원을 로딩하는 순간 메모리에 수천만건의 데이터도 함께 퍼올려진다.

만약에 멀티스레드 환경에서 이런 일이 발생한다면 애플리케이션은 그 즉시 OutOfMemory를 띄우며 뻗어버릴것이다.

 

권장하는 방법은 모든 상황에 대해 지연 로딩을 사용하고, 상황을 보면서 필요한 부분에 즉시로딩(fetch join)을 사용하여 최적화하는 것이다.

 

💥 컬렉션 로딩 전략 주의점


컬렉션에 FetchType.EAGER를 사용할 경우 주의점은 다음과 같다.

 

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다

컬렉션과 조인한다는 것(~ToMany)은 데이터베이스 테이블로 보면 일대다 조인이다.

일대다 조인은 결과 데이터가 다쪽에 있는 수만큼 증가하게 된다.

문제는 서로 다른 컬렉션을 두개이상 조인할 때 발생하는데, 이를 SQL용어로 카티션 곱이라고 한다.

 

여러개의 테이블을 조인했을 때 발생 가능한 모든 경우의 수가 출력되는 상황을 말하며,

N개의 행을 가진 테이블M개의 행을 가진 테이블을 조인했을 경우 NM의 결과가 출력된다.

 

이 문제가 발생하게 되면 시스템에 막대한 부하를 발생시키므로 반드시 피해야 하는 문제이다.

 


 

  • 컬렉션 즉시 로딩은 항상 외부조인(OUTER JOIN)을 사용한다

예를 들어 다대일 관계인 회원 테이블팀 테이블을 조인할 때 회원 테이블의 외래키에

not null 제약조건을 걸어두면 모든 회원은 반드시 어떤 팀에 소속되야 하므로

이 경우 내부조인(INNER JOIN)을 사용해도 올바른 데이터가 출력된다.

 

반대로 양방향 매핑을 걸어 팀 테이블에서 회원 테이블로 일대다 조인을 시도할 때

회원이 한명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 상황이 발생한다.

데이터베이스 시스템으로는 이런 상황을 미연에 방지할 수 없으므로

애초에 양방향 매핑을 사용하지 않거나, 반드시 외부조인(OUTER JOIN)을 사용해야 한다.

 

FetchType.EAGER 설정과 조인 전략은 다음과 같다.

 

  • @ManyToOne, @OneToOne
    • (optional = false): 내부조인
    • (optional = true): 외부조인

 

  • @OneToMany, @ManyToMany
    • (optional = false): 외부조인
    • (optional = true): 외부조인

 


© 2022. All rights reserved.