SpringBootProject

SpringBoot : 프록시 패턴

dding-shark 2025. 7. 15. 18:17
728x90

프록시 패턴 (Proxy Pattern)

실무에서 스프링 프레임워크를 사용하다 보면 다양한 방법으로 빈(Bean)을 등록하게 됩니다. 이 과정에서 프록시(Proxy) 개념은 매우 중요하게 다뤄집니다. 스프링의 핵심 기능인 AOP(관점 지향 프로그래밍)가 프록시를 기반으로 동작하기 때문입니다.

일반적으로 스프링 빈을 등록하는 방식은 다음과 같이 나눌 수 있습니다.

  1. 인터페이스와 구현 클래스 분리: 인터페이스를 정의하고, 구현체를 만들어 @Configuration@Bean을 통해 수동으로 스프링 빈에 등록합니다.
  2. 구체 클래스만 사용: 인터페이스 없이 구체 클래스를 직접 만들어 수동으로 스프링 빈에 등록합니다.
  3. 컴포넌트 스캔 활용: @Component, @Service, @Repository 등의 애너테이션을 사용하여 스프링이 자동으로 빈을 찾아 등록하게 합니다.

어떤 방식을 사용하든, 부가 기능(로깅, 트랜잭션 등)을 적용하기 위해 프록시가 사용될 수 있으므로 프록시 패턴에 대한 이해는 필수적입니다.

 

 


 

 

프록시 패턴이란?

프록시(Proxy) 는 '대리자'라는 뜻으로, 특정 객체에 대한 접근을 제어하기 위해 대리 객체를 두는 디자인 패턴입니다. 클라이언트는 실제 객체(RealSubject)가 아닌 프록시 객체(Proxy)를 통해 간접적으로 실제 객체와 상호작용합니다.

이를 통해 프록시는 실제 객체의 기능이 필요해지기 전까지 객체의 생성을 미루거나(Lazy Loading), 접근 권한을 확인하고, 캐싱(Caching) 또는 로깅(Logging)과 같은 부가 기능을 추가하는 등 다양한 역할을 수행할 수 있습니다.


 

 

프록시 패턴의 구조

프록시 패턴의 구조를 클래스 다이어그램과 런타임 시퀀스 다이어그램으로 표현하면 다음과 같습니다.

프록시 패턴 클래스 다이어그램

프록시 패턴 런타임 시퀀스 다이어그램
  • Subject: ProxyRealSubject가 모두 구현해야 하는 공통 인터페이스입니다. 클라이언트는 이 인터페이스를 통해 객체를 사용하므로, ProxyRealSubject를 구분하지 않고 동일한 방식으로 다룰 수 있습니다.
  • RealSubject: 프록시가 제어하려는 실제 객체입니다. 핵심 비즈니스 로직을 담고 있습니다.
  • Proxy: RealSubject와 동일한 인터페이스를 구현하며, 내부에 RealSubject에 대한 참조를 가집니다. 클라이언트의 요청을 중간에서 가로채 특정 로직을 수행한 후, RealSubject에 요청을 전달하거나 직접 처리합니다.
  • Client: Subject 인터페이스를 통해 Proxy를 사용합니다.

데코레이터 패턴과의 비교

프록시 패턴은 구조적으로 데코레이터 패턴(Decorator Pattern)과 매우 유사하여 혼동하기 쉽습니다. 두 패턴을 구분하는 핵심 기준은 적용 의도(Intent)에 있습니다.

  • 프록시 패턴: 접근 제어가 주된 목적입니다.
    • 예시: 캐시 적용, 지연 로딩(Lazy Loading), 인증 및 권한 검사 등 원래 객체의 기능에 직접 관여하기보다는 접근 방식을 관리합니다.
  • 데코레이터 패턴: 기능의 동적 추가가 주된 목적입니다.
    • 예시: 반환값의 구조 변경, 실행 전후 로그 추가, 데이터 암호화/압축 등 원래 객체의 핵심 기능에 새로운 책임을 동적으로 덧붙입니다.

두 패턴은 구조가 비슷하더라도, 이처럼 사용하려는 의도에 따라 구분하여 적용하는 것이 중요합니다.

 

 


 

 

프록시 패턴의 실행 흐름

프록시 패턴 실행 흐름 요약
  1. 클라이언트는 Subject 인터페이스를 통해 프록시 객체의 메서드를 호출합니다.
  2. 프록시는 요청을 받아 접근 제어 로직(checkAccess())을 먼저 수행합니다.
  3. 접근 제어 로직을 통과하면, 프록시는 자신이 참조하고 있는 실제 서비스 객체(RealSubject)의 메서드(operation())를 호출합니다.
  4. 실제 서비스의 작업이 수행되고, 그 결과는 다시 프록시를 거쳐 클라이언트에게 반환됩니다.

 

 

 


 

 

인터페이스 기반 프록시 vs. 클래스 기반 프록시

프록시를 생성하는 방법은 크게 두 가지로 나뉩니다. 바로 인터페이스(Interface) 를 기반으로 만드는 방법과 클래스(Class) 를 상속받아 만드는 방법입니다.

클래스 기반 프록시

인터페이스가 없더라도 구체 클래스를 상속받아 프록시를 생성할 수 있습니다. 예를 들어, 라는 클래스가 있다면, 이 클래스를 상속받는 OrderServiceV2Proxy를 만들어 부가 기능을 적용할 수 있습니다. OrderServiceV2
하지만 클래스 기반 프록시는 다음과 같은 제약이 따릅니다.

  • 생성자 호출 제약: 자식 클래스(프록시)는 반드시 부모 클래스의 생성자를 호출해야 합니다. 만약 부모 클래스에 기본 생성자가 없다면, 프록시 클래스에서 super()를 통해 명시적으로 부모 생성자를 호출해야 합니다.
  • final 키워드 제약:
    • 클래스에 final이 선언된 경우, 해당 클래스는 상속할 수 없으므로 프록시를 만들 수 없습니다.
    • 메서드에 final이 선언된 경우, 해당 메서드는 오버라이딩(Overriding)이 불가능하므로 부가 기능을 추가할 수 없습니다.

 

 

인터페이스 기반 프록시

인터페이스 기반 프록시는 Subject라는 인터페이스를 Proxy와 가 함께 구현하는 방식입니다. 이러한 방식은 상속이라는 제약에서 자유롭다는 큰 장점이 있습니다. 또한, 역할(인터페이스)과 구현(클래스)을 명확하게 분리하는 객체 지향 설계 원칙(SOLID)에도 부합하여 더 유연하고 확장성 있는 코드를 작성하게 해줍니다. RealSubject
하지만 인터페이스 기반 프록시의 단점은 이름 그대로 인터페이스가 반드시 필요하다는 점입니다. 적용하려는 대상에 인터페이스가 없다면 이 방식의 프록시는 생성할 수 없습니다.

 

 

인터페이스 기반 프록시의 단점과 실용적 관점

이론적으로 모든 객체에 인터페이스를 도입하여 역할과 구현을 분리하는 것은 객체 지향 설계의 이상적인 목표입니다. 이렇게 하면 구현체를 손쉽게 교체할 수 있어 시스템의 유연성이 극대화됩니다. 하지만 실제 개발 현장에서는 모든 상황에 인터페이스를 적용하는 것이 항상 최선은 아닙니다.

 

1. 타입 캐스팅(Casting)의 제약

인터페이스 기반 프록시의 가장 큰 기술적 단점 중 하나는 구체 클래스로의 타입 캐스팅이 불가능하다는 점입니다.
예를 들어, 인터페이스를 프록시가 구현하고 있다고 가정해 보겠습니다. OrderServiceV1

// 프록시를 생성하고 의존성을 주입받음
OrderServiceV1 proxy = new OrderControllerInterfaceProxy(new OrderServiceV1Impl(), logTrace);

// 구체 클래스로 캐스팅 시도 -> ClassCastException 발생!
OrderServiceV1Impl castedTarget = (OrderServiceV1Impl) proxy; // 런타임 오류

 

 

클라이언트는 인터페이스() 타입으로 프록시를 주입받습니다. 이때 클라이언트가 프록시 객체를 실제 구현 클래스인 로 타입 캐스팅하려고 하면 ClassCastException이 발생합니다. 왜냐하면 프록시 객체는 인터페이스를 구현했을 뿐, 클래스와는 상속 관계가 아니기 때문입니다. 
이러한 제약은 프록시를 사용하는 클라이언트가 구체 클래스의 타입에 의존하지 않도록 강제하는 효과도 있지만, 기존 코드가 구체 클래스 타입에 의존하고 있는 경우 프록시를 적용하기 어렵게 만드는 요인이 되기도 합니다.

 

 

 

2. 실용적인 관점에서의 고민

인터페이스 도입은 구현체를 변경할 가능성이 있을 때 가장 큰 효과를 발휘합니다. 예를 들어, 데이터 저장소를 JdbcTemplate 구현체에서 JPA 구현체로 변경하거나, 테스트 환경에서 실제 객체 대신 목(Mock) 객체를 사용해야 하는 경우가 그렇습니다.
하지만 실제 프로젝트에서는 구현이 변경될 가능성이 거의 없는 단순한 클래스도 많습니다. 이러한 클래스에까지 인터페이스를 의무적으로 도입하는 것은 다음과 같은 단점을 낳을 수 있습니다.

  • 코드의 양 증가: 간단한 기능임에도 불구하고 인터페이스와 구현 클래스를 모두 만들어야 하므로 코드의 양이 불필요하게 늘어납니다.
  • 탐색의 번거로움: 클래스 구조가 복잡해져 특정 구현 코드를 찾기 위해 인터페이스와 구현체를 번갈아 확인해야 하는 번거로움이 생길 수 있습니다.

따라서 실용적인 관점에서는 인터페이스가 제공하는 유연성의 가치와 추가되는 복잡성의 비용을 저울질하는 지혜가 필요합니다. 구현 변경의 가능성이 낮고 클래스의 역할이 명확하다면, 인터페이스 없이 구체 클래스를 직접 사용하는 것이 더 효율적이고 실용적인 선택일 수 있습니다. 핵심은 인터페이스가 항상 정답은 아니라는 것을 이해하고 상황에 맞게 최적의 설계 결정을 내리는 것입니다.

728x90