자바와 스프링, 그리고 JPA에서의 프록시 객체 이해하기
1. 프록시의 소개
프록시 객체는 자바 프로그래밍 패러다임 내에서 매우 중요한 개념이다.
여러분이 객체 지향 프로그래밍을 배우면서 '대리인' 또는 '중개자'와 같은 역할을 하는 구조를 만났을 것이다. 프록시 객체는 바로 이 역할을 수행한다. 이 글에서는 프록시 객체가 무엇이고, 자바와 스프링, 그리고 JPA에서 어떻게 사용되는지를 한번 설명하겠다.
2. 자바에서의 프록시 객체
- 자바에서 프록시 객체는 실제 객체를 대신하여 클라이언트의 요청을 처리한다.
- 이는 주로 원격 객체나 비용이 많이 드는 객체 생성을 대신하거나, 보안상의 이유로 직접적인 객체 접근을 제한할 때 사용된다.
- 아래는 예시 코드들이다.
Image 인터페이스
public interface Image {
void display();
}
- Image 인터페이스는 display 메소드를 정의하고 있다. 이 인터페이스를 구현하는 모든 클래스는 display 메소드를 구현해야 하며, 이 메소드는 이미지를 표시하는 데 사용된다.
RealImage 클래스
public class RealImage implements Image {
private String fileName;
public RealImage(String fileName) {
this.fileName = fileName;
loadFromDisk(fileName);
}
private void loadFromDisk(String fileName) {
System.out.println("Loading " + fileName);
}
public void display() {
System.out.println("Displaying " + fileName);
}
}
- RealImage 클래스는 Image 인터페이스를 구현하는 '실제' 이미지를 나타낸다.
- 이 클래스는 파일명을 인자로 받아 객체를 생성할 때 디스크에서 이미지를 로드하는 작업을 수행한다. (loadFromDisk 메소드 참조)
- display 메소드는 이미지를 표시하는 데 사용되며, 여기서는 단순히 이미지 파일명을 콘솔에 출력하고 있다.
ProxyImage 클래스
public class ProxyImage implements Image {
private RealImage realImage;
private String fileName;
public ProxyImage(String fileName) {
this.fileName = fileName;
}
public void display() {
if (realImage == null) {
realImage = new RealImage(fileName);
}
realImage.display();
}
}
- ProxyImage 클래스도 Image 인터페이스를 구현하는데, 이는 '프록시' 객체로 실제 RealImage 객체의 대리인 역할을 한다.
- display 메소드가 호출될 때, ProxyImage는 내부적으로 RealImage 객체가 이미 생성되었는지 확인하고, 생성되지 않았다면 새로 생성한 후 display 메소드를 호출해 실제 이미지를 표시한다.
- 이렇게 함으로써, 실제로 이미지를 로드하고 표시하는 비용이 큰 작업은 필요할 때까지 미루게 되고, 이는 리소스를 절약하는 데 도움을 준다.
ProxyPatternDemo 클래스
public class ProxyPatternDemo {
public static void main(String[] args) {
Image image = new ProxyImage("test_10mb.jpg");
// 이미지를 처음 불러올 때 실제 객체를 생성한다.
image.display();
// 이미지를 다시 불러올 때는 이미 생성된 실제 객체를 사용한다.
image.display();
}
}
- ProxyPatternDemo 클래스에서는 ProxyImage를 사용하여 이미지를 표시하는 예를 보여준다.
- main 메소드에서 ProxyImage 객체를 생성하고, display 메소드를 두 번 호출한다.
- 첫 번째 호출에서는 실제 RealImage 객체가 생성되고 이미지가 로드되며, 두 번째 호출에서는 이미 생성된 RealImage 객체를 재사용해 이미지를 빠르게 표시한다.
이 예제는 프록시 객체가 어떻게 '실제 객체'의 생성을 지연시키고, 필요할 때만 리소스를 사용하여 객체를 로드하고 표시하는지를 보여주는 좋은 예시다. 이 패턴은 시스템의 성능을 최적화하는데 도움을 주는 디자인 패턴 중 하나로, 리소스가 제한적인 상황에서 유용하게 사용될 수 있다.
3. 스프링에서의 프록시 객체
- 스프링 프레임워크에서 프록시 객체는 AOP(Aspect-Oriented Programming)를 위해 주로 사용된다.
- AOP는 특정 메소드나 클래스에 공통적으로 적용되어야 하는 기능을 모듈화 하여 관리하는 프로그래밍 기법이다.
- 예를 들어, 보안 검사, 트랜잭션 관리, 로깅 등이 이에 해당한다.
- 스프링 AOP는 'advice'라고 하는 공통 기능을 'pointcut'이라고 하는 특정 조인 포인트에 적용한다.
3-1. 스프링 AOP의 주요 개념
- Aspect: 여러 객체에 걸쳐 있는 관심사를 모듈화한 것. 트랜잭션 관리나 로깅 같은 기능을 예로 들 수 있다.
- Join Point: 메소드 실행, 예외 처리와 같이 애플리케이션 실행 중에 특정 지점.
- Advice: 특정 join point에서 Aspect에 의해 취해지는 조치. 예를 들어, 메소드 실행 전후로 로깅을 수행하는 것.
- Pointcut: advice가 적용될 join points를 정의. 표현식을 사용해 어떤 메소드가 advice 대상인지 선언.
- Proxy: AOP를 구현하기 위해 만들어지는 객체. 타깃 객체를 감싸 타깃의 메소드 호출에 대해 advice를 적용한다.
- Weaving: Advice를 타깃 객체에 적용하여 프록시 객체를 생성하는 과정.
3-2. 스프링에서의 프록시 객체 예제
- 스프링은 두 가지 방식의 프록시를 지원한다.
- JDK 동적 프록시와 CGLIB 프록시가 그것이다.
- JDK 동적 프록시는 인터페이스를 구현하는 클래스에 대해 사용되고, CGLIB는 클래스를 상속받아 사용한다.
JDK 동적 프록시 예제:
public interface PaymentService {
void pay(Long amount);
}
@Service
public class SimplePaymentService implements PaymentService {
public void pay(Long amount) {
// 결제 로직
System.out.println("Processing payment of " + amount);
}
}
@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
// AspectJ 프록시를 활성화하는 설정
}
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.service.PaymentService.pay(..))")
public void logBeforePayment(JoinPoint joinPoint) {
// 결제가 실행되기 전에 로깅하는 advice
System.out.println("About to make payment");
}
}
- 위 예제에서 SimplePaymentService 클래스는 PaymentService 인터페이스를 구현한다.
- LoggingAspect 클래스는 PaymentService의 pay 메소드가 호출되기 전에 실행될 로깅 로직을 포함하고 있다.
- 스프링은 @EnableAspectJAutoProxy 어노테이션을 통해 AOP 프록시를 자동으로 활성화하고, LoggingAspect에 정의된 로직을 SimplePaymentService의 pay 메소드 실행 전에 적용한다.
- 이는 SimplePaymentService에 대한 프록시를 생성하여 실제 메소드 호출을 가로채고, logBeforePayment 메소드를 실행시킨 후 실제 메소드를 실행하는 방식으로 작동한다.
❗ 위 내용이 잘 이해가 안되는 사람을 위해 더 쉽게 이해할 수 있는 비유를 생각했다.
상상해 보자. 당신은 영화관에 가서 영화 티켓을 산다. 영화 티켓을 살 때, 단순히 영화를 보기 위한 것뿐만 아니라, 티켓에는 몇 가지 추가 서비스가 포함되어 있을 수 있다.
예를 들어, 영화 시작 전에 팝콘을 받을 수 있는 쿠폰이나 주차장 할인권 같은 것들이다. 이런 추가 서비스는 영화 티켓 구매라는 기본 행위에 '부가적인' 것들이며, 영화를 보는 경험을 '향상'시키는 역할을 한다.
이제 스프링 AOP를 생각해 보자. 여기서 '영화 티켓'은 SimplePaymentService의 pay 메소드 같은 비즈니스 로직을 의미한다.
당신은 이 메소드를 호출하여 '결제'라는 기본적인 작업을 수행한다. 그런데, 우리가 영화 티켓에 추가적인 서비스를 받듯이, 스프링 AOP를 통해 이 결제 메소드에 '부가적인' 기능을 추가할 수 있다.
이 부가적인 기능이 바로 로깅이다.
LoggingAspect는 부가적인 로깅 기능을 정의하고 있다. 당신은 pay 메소드를 호출할 때마다, 스프링 AOP는 이 로깅 기능을 '자동적으로' 실행해 준다. 이 과정에서 스프링은 @EnableAspectJAutoProxy라는 설정을 사용하여 '프록시'를 만든다.
프록시는 당신이 호출한 pay 메소드 사이에 '중간자' 역할을 하며, 실제 메소드를 실행하기 전에 로깅을 수행한다.
그래서 프록시는 마치 영화 티켓의 '부가 서비스'를 제공하는 것과 같이, 당신이 원래 하려던 메소드 호출에 추가적인 기능을 제공하는 역할을 한다.
이 모든 과정은 당신으로부터 숨겨져 있으며, 당신은 단지 메소드를 호출하기만 하면 된다. 스프링이 나머지 부가 기능을 자동으로 처리해 준다.
이렇게 스프링 AOP와 프록시 객체를 사용하면, 비즈니스 로직을 깔끔하게 유지하면서도 필요한 추가 기능을 적용할 수 있다. 프록시는 내부적으로 복잡하지만 사용자는 그 복잡성을 신경 쓸 필요 없이 간단한 어노테이션 설정만으로 원하는 기능을 적용할 수 있다는 큰 장점이 있다.
4. JPA에서의 프록시 객체
- JPA에서 프록시 객체는 데이터베이스와의 상호작용을 최적화하는데 사용된다.
- JPA에서 말하는 프록시 객체는 실제 엔티티 클래스의 '가벼운 대리자'다.
- 데이터베이스에서 데이터를 가져올 때, 모든 정보를 즉시 로드하는 대신, 실제로 필요할 때까지 로드를 지연시키는 객체를 만든다.
- 이 객체를 프록시 객체라고 부른다.
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
// Getters and Setters
}
public class UserService {
@PersistenceContext
private EntityManager entityManager;
public void exampleMethod(Long id) {
User user = entityManager.getReference(User.class, id);
// 이 시점에서는 User 객체가 실제로 데이터베이스로부터 로드되지 않았다.
// user.getName() 같은 접근이 있을 때 비로소 데이터베이스에서 실제 User 객체를 로드한다.
}
}
위 코드에서 프록시 객체의 작동 원리
- entityManager.getReference() 메소드를 호출하면, JPA는 해당 엔티티의 실제 인스턴스 대신 프록시 인스턴스를 반환한다.
- 이 프록시 인스턴스는 실제 User 객체처럼 보이지만, 실제로는 데이터베이스에 대한 참조만 갖고 있고 데이터는 포함하고 있지 않다.
- User user = entityManager.getReference(User.class, id); 여기서 user 변수에 저장된 것은 실제 User 객체가 아니라, User 객체를 대신하는 프록시 객체다. 이 객체를 통해 User의 데이터에 접근하려고 할 때, 예를 들어 user.getName()을 호출하면, JPA는 그제서야 실제 데이터베이스로부터 필요한 데이터를 로드한다.
- 이러한 방식으로 프록시 객체는 데이터베이스의 데이터 로드를 필요한 순간까지 지연시켜 성능을 향상시킨다.
프록시 객체의 장점
- 이 접근 방식의 주요 장점은 성능 최적화다.
- 예를 들어, 당신이 하나의 트랜잭션 안에서 많은 수의 엔티티를 참조해야 하지만, 실제로는 그중 일부만 사용한다고 가정해 보자.
- JPA의 프록시 기능 덕분에, 실제로 사용되는 엔티티의 데이터만 데이터베이스로부터 로드하면 된다.
- 이는 메모리 사용량을 줄이고, 데이터베이스로의 불필요한 쿼리 수를 감소시켜 애플리케이션의 반응성을 높이는 데 도움을 준다.
주의할 점
- 프록시 객체를 사용할 때 주의해야 할 점은, 프록시 객체가 실제 엔티티 데이터를 로드하기 전에는 실제 데이터가 없다는 것이다.
- 그래서 프록시 객체가 실제 데이터를 로드하기 전에 접근하려고 하면 LazyInitializationException 같은 예외가 발생할 수 있다.
- 이를 방지하기 위해서는 JPA 세션(보통 트랜잭션) 내에서 프록시 객체를 사용하고, 필요한 데이터가 로드되도록 해야 한다.
프록시 객체는 JPA를 사용하는 애플리케이션에서 성능을 향상시키는 중요한 기능이며, 올바르게 사용한다면 데이터 접근의 효율성을 크게 높일 수 있다.
5. 마무리
프록시 객체는 자바와 스프링, JPA에서 다양한 상황에 유용하게 사용된다.
원격 객체 접근, 비용이 많이 드는 객체 생성 대신하기, 공통 관심사의 모듈화, 지연 로딩 등 프록시 객체의 사용 사례는 매우 다양하다.
이 글을 통해 프록시 객체의 개념을 이해하고, 실제 프로그래밍에 활용할 수 있기를 바란다.
시간이 된다면 이를 시작점으로 디자인 패턴과 AOP, ORM에 대한 더 깊은 이해를 구축하면 좋을 것 같다.
(프록시 개념이 약간 첨가되어 있는 이전글도 읽어보면 좋을 것 같다!)
[Spring Data JPA] ResponseDTO에 기본 생성자 있어야하는 이유
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
AtomicInteger를 활용한 파일 저장 순서 동기화 문제 해결 (0) | 2024.02.04 |
---|---|
[JAVA] 코드 최적화를 위한 매직 상수 사용법 (4) | 2023.12.31 |
[JAVA] 자바 인터페이스에서 public과 private 접근 제한자 활용하기 (3) | 2023.12.24 |
[JAVA] 람다 표현식에서 final 변수나 effectively final 변수를 사용해야 하는 이유 (0) | 2023.12.01 |
[JAVA] 리플렉션(Reflection)의 이해 (1) | 2023.11.08 |