프록시란?
프록시는 대리 객체라고도 불리며, 클라이언트로부터 실제 오브젝트인 타겟을 대신해서 요청을 받는 대리 객체이다. 즉, 실제 오브젝트인 타겟은 프록시를 통해 최종적으로 요청을 받아 처리하면서 추가적인 기능(예: 로깅, 트랜잭션 관리, 보안 검사 등)을 수행할 수 있다.
이를 통해 최종적으로 실제 오브젝트인 타겟은 자신의 기능에만 집중하고 부가기능은 프록시에게 위임하게 된다. 여기서 부가기능이라고 하면 클라이언트, 타겟 객체를 직접적으로 수정하지 않고 기능을 타겟 객체의 기능을 추가하는 것을 의미한다.

왜 프록시를 사용하는가?
- 클라이언트가 타겟에 접근하는 방법을 제어할 때 (JPA Lazy Loading)
- 타겟에 부가적인 기능을 부여해주기 위해 (@Transactional 어노테이션, AOP)
프록시를 구현하는 방법
- 프록시 패턴을 통해 프록시를 구현
- JDK Dynamic Proxy를 가져와 사용
- CGLIB 사용
기본적인 프록시 패턴 구현 (순수 Java)
- Service → 원본 서비스의 동작을 정의하는 인터페이스
- RealService → 실제 서비스 로직을 수행하는 클래스
- ServiceProxy → 실제 서비스 실행 전후로 추가 로직(로깅)을 수행하는 프록시
- main()에서 ServiceProxy를 통해 RealService를 호출
// 1. 원본 인터페이스
interface Service {
void operation();
}
// 2. 실제 객체 (RealSubject)
class RealService implements Service {
@Override
public void operation() {
System.out.println("실제 서비스 실행 중...");
}
}
// 3. 프록시 객체 (Proxy)
class ServiceProxy implements Service {
private final Service realService;
public ServiceProxy(Service realService) {
this.realService = realService;
}
@Override
public void operation() {
System.out.println("[프록시] 실행 전 로깅...");
realService.operation(); // 실제 서비스 호출
System.out.println("[프록시] 실행 후 로깅...");
}
}
// 4. 실행 테스트
public class ProxyPatternExample {
public static void main(String[] args) {
Service service = new ServiceProxy(new RealService());
service.operation();
}
}
간단한 캐싱 프록시 구현 (순수 Java)
1. 기능 설명
- ExpensiveService라는 클래스를 만들고, 비싼 연산을 수행하는 메서드(getData(int key)) 를 제공한다.
- 하지만, 같은 key에 대해 여러 번 요청하면 불필요한 연산을 줄이고 캐싱된 결과를 반환하는 프록시를 만들어야 한다.
- 프록시는 처음 요청 시만 실제 연산을 수행하고, 이후에는 캐시된 값을 반환해야 한다.
2. 클래스 설계
- ExpensiveService (실제 서비스)
- getData(int key): key를 받아 오래 걸리는 연산을 수행하고 결과를 반환.
- ExpensiveServiceProxy (프록시)
- getData(int key): 이미 요청된 key가 있으면 캐싱된 결과를 반환, 없으면 실제 서비스 호출 후 캐싱.
- Main
- ExpensiveServiceProxy를 사용하여 여러 번 같은 데이터를 요청하는 코드를 작성
/**
* ExpensiveService라는 클래스를 만들고, 비싼 연산을 수행하는 메서드(getData(int key)) 를 제공한다.
*/
public interface ExpensiveService {
String getData(int key);
}
public class TargetExpensiveService implements ExpensiveService {
@Override
public String getData(int key) {
try {
Thread.sleep(1000);
} catch (Exception e) {
throw new RuntimeException(e);
}
return "오래 걸리는 연산 수행 중..." ;
}
}
public class ProxyExpensiveService implements ExpensiveService {
private final ExpensiveService expensiveService;
private final Map<Integer, String> cache = new HashMap<>();
public ProxyExpensiveService(ExpensiveService expensiveService) {
this.expensiveService = expensiveService;
}
@Override
public String getData(int key) {
if (!cache.containsKey(key)) {
// 실제 연산 실행 후 결과를 저장
String result = expensiveService.getData(key);
cache.put(key, result);
return result;
}
return cache.get(key) + "(캐시에서 가져옴)"; // 캐시에서 데이터 반환
}
}
public class Main {
public static void main(String[] args) {
ExpensiveService service = new ProxyExpensiveService(new TargetExpensiveService());
System.out.println(service.getData(1));
System.out.println(service.getData(2));
System.out.println(service.getData(1));
System.out.println(service.getData(2));
}
}
오래 걸리는 연산 수행 중...
오래 걸리는 연산 수행 중...
오래 걸리는 연산 수행 중...(캐시에서 가져옴)
오래 걸리는 연산 수행 중...(캐시에서 가져옴)
장점
- 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있다 (OCP)
- 기존 코드가 해야 하는 일만 유지할 수 있음 (SRP)
단점
- 프록시를 적용하기 전과 적용한 후 비교했을 때 코드의 복잡도가 증가하게 된다.
- 인터페이스 구현, 프록시 객체 생성
- 부가기능 구현시 중복 코드가 발생할 수 있다.
정리
특정 객체에 대한 접근을 제어하거나 수정하지 않고 새로운 기능을 추가할 수 있는 기술(?)이라서 굉장히 흥미롭다. 스프링 부트에서 ‘프록시’라는 단어가 정말 많이 들렸지만 정작 제대로 이해하진 않았다. 이번 기회로 프록시 객체에 대해서 조금이라도 이해할 수 있어서 좋은 시간은 보낸 것 같다. 지금은 기본적인 부분을 봤지만 더 나아가서 이를 활용한 간단한 프로그램도 개발해보고 싶다.