JPA 기초 6 - 프록시
지연로딩의 핵심인 프록시
에 대해 학습합니다
📕 프록시(Proxy)
프록시
는 한글로 대리자, 대리인이라는 뜻을 갖는다.
프로그래밍에는 인디렉션(Indirection)
이라는 개념이 있다.
📜 Dennis DeBruler
컴퓨터 과학은 인디렉션 계층을 한 단계 더 만들면 모든 문제를 풀 수 있다고 믿는 학문이다.
위키백과등에서 인디렉션에 대한 내용을 참고해보면 두 계층사이에 어떤 문제가 발생했을 때 두 계층 사이에 별도의 계층을 하나 추가하면 해당 문제가 깔끔하게 해결되는 경우가 많다는 것이다.
이를 프로그래밍 용어로 간접참조
혹은 추상화
로 요약할 수 있을 것 같다.
프록시
는 이 인디렉션과 궤를 같이하며 프록시 패턴은 SOLID중 개방폐쇄원칙(OCP)
과 의존역전원칙(DIP)
을 충실히 따른다.
그렇다면 프록시가 뭘까?
프록시는 특정 개체의 사이에 위치한 진짜인 척을 하는 가짜 개체다.
여기서 프록시가 목표로 하는 것은 본연의 로직에는 전혀 영향을 주지 않으면서 흐름을 제어 하는 것
이다.
그래서 두 개체는 사이에 프록시라는 가짜 개체가 있는지 전혀 모르며 서로를 진짜라고 신뢰하고 통신을 진행하게 된다.
코드를 통해 보자.
통상적인 프록시 패턴의 클래스 다이어그램이다.
이를 코드로 풀어내면 다음과 같다.
// 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
의 연산결과가 나옴을 볼 수 있다.
🤔 프록시를 사용하는 이유 ?
프록시가 대충 뭔지 알았다면 프록시를 사용하는 이유에 대한 납득이 필요하다.
프록시는 대표적으로 다음과 같은 역할들을 수행할 수 있다.
- 흐름제어
- 캐싱
- 지연연산
💡 흐름제어
서문의 예시코드와 같다.
예시코드에서는 단순히
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=?
그리고 Member
와 Team
의 데이터를 모두 출력(사용)
하고 있으므로 이 경우에는 별다른 문제가 없다.
하지만 다음과 같은 경우엔 어떨까?
public void printMemberAndTeam(String memberId){
Member member = memberRepository.findById(memberId);
System.out.println("회원 이름 : " + member.getName());
}
Member
와 Team
의 데이터를 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 문제
를 바로 이 지연로딩을 통해 회피한다.
그렇다면 항상 지연로딩이 옳은것일까?
그건 또 아니다.
바로 위의 예시에서 보다시피,Member
와 Team
을 모두 사용하기 위한 목적으로 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): 외부조인