Spring IoC & DI 완전 정복: Chapter 8. Proxy와 Spring AOP

2026. 7. 22. 15:42·🍃SpringBoot

들어가며: 완성된 Bean을 두고 왜 또 다른 객체를 만드는가?

Spring Container는 BeanDefinition을 기반으로 Bean을 생성하고, 의존성을 해결하여 객체 그래프를 완성한다. 나아가 BeanPostProcessor를 통해 생성이 완료된 Bean을 후처리한다. 여기서 한 가지 의문이 발생한다. 이미 완성된 Bean이 있는데, Spring은 왜 굳이 또 다른 객체를 만들어 등록하는가?

  • Spring의 이례적인 동작 방식: 원본 Bean을 그대로 사용하지 않고, 전혀 다른 객체를 생성하여 Bean 대신 등록한다.
    애플리케이션은 원본 객체가 아닌 이 새로운 객체를 사용하게 된다.
  • 성능상의 손해 감수: 객체를 추가로 생성하는 것은 메모리 사용량 증가와 메서드 호출 단계 추가를 의미한다.
  • 핵심 기능의 기반: 성능상 불리함에도 불구하고 Spring은 핵심 기능 대부분을 이 구조 위에서 구현한다.
    • `@Transactional`, `@Cacheable`, `@Async`, `@Validated`, Spring AOP, Spring Security 등

이 기술들의 중심에는 Proxy(프록시)가 존재한다.

Proxy에 대한 오해와 본질

흔히 Proxy를 "대리 객체", "원본 객체 대신 요청을 처리하는 객체"로 정의한다. 그러나 이러한 단순 정의만으로는 Spring이 왜 Proxy를 선택했는지 설명할 수 없다. 기술은 문제를 해결하기 위해 존재하므로, Proxy를 이해하려면 먼저 Spring이 해결하고자 했던 문제를 파악해야 한다.

8.1 객체지향은 모든 문제를 해결했는가?

객체지향 프로그래밍(OOP)은 상태와 행위를 객체 단위로 묶고, 인터페이스와 다형성, SOLID 원칙을 통해 견고한 설계를 가능하게 했다. Spring 역시 이러한 객체지향 철학을 바탕으로 만들어진 프레임워크다. 그러나 실제 프로젝트에서는 객체 간의 의존성이 아닌 기능의 중복 및 반복이라는 새로운 문제가 등장한다.

잘 설계된 서비스 객체의 예시

@Service
public class OrderService {
    public void order(OrderRequest request) {
        // 주문 처리
    }
}

@Service
public class PaymentService {
    public void pay(PaymentRequest request) {
        // 결제 처리
    }
}

@Service
public class MemberService {
    public void signup(MemberRequest request) {
        // 회원 가입
    }
}

위 클래스들은 단일 책임 원칙(SRP)을 잘 준수하며 역할과 책임이 명확히 분리되어 있다.

현실적인 비즈니스 메서드의 문제점

실제 서비스 환경에서는 비즈니스 로직 실행 전후로 수많은 부가 작업이 요구된다.

  • 권한 검사, 트랜잭션 시작/종료, 실행 시간 측정, 로깅, 캐시 확인, 예외 공통 처리 등
public void order(OrderRequest request) {
    checkAuthorization();
    startTransaction();
    long start = System.currentTimeMillis();

    try {
        log.info("주문 시작");
        
        // 실제 비즈니스 로직 (주문 처리)
        
        commit();
    } catch (Exception e) {
        rollback();
        throw e;
    } finally {
        long end = System.currentTimeMillis();
        log.info("실행 시간 = {}", end - start);
    }
}

문제의 본질

  1. 주객전도: 정작 중요한 비즈니스 로직은 몇 줄 되지 않고, 부가 코드가 대부분을 차지한다.
  2. 코드 복사·붙여넣기: 동일한 형태의 부가 코드가 PaymentService, MemberService, ProductService 등 모든 서비스에 중복되어 작성된다.
  3. 객체지향의 한계: 객체지향은 객체 간의 협력은 훌륭히 해결했으나, 여러 객체에 공통으로 필요한 부가 기능을 효율적으로 관리하는 방법은 제시하지 못한다.

8.2 객체에 존재하는 두 가지 관심사

애플리케이션 내의 코드는 성격에 따라 두 가지 관심사로 나뉜다.

핵심 관심사 (Core Concern)

  • 각 객체가 자신의 역할에 따라 수행하는 고유의 핵심 업무다.
  • 예: 주문 처리, 결제 수행, 회원 관리 등

공통 관심사 (Cross-Cutting Concern)

  • 특정 서비스에 국한되지 않고 애플리케이션 전반에 걸쳐 공통·반복적으로 필요한 기능이다.
  • 예: 트랜잭션, 로깅, 권한 검사, 캐시, 감사(Audit) 등

두 관심사가 결합될 때 발생하는 폐해

  • 책임의 모호화: 부가 기능이 늘어날수록 클래스가 본래의 목적을 잃고 코드가 비대해진다.
  • SRP(단일 책임 원칙) 위반: OrderService가 주문 로직뿐만 아니라 트랜잭션, 로깅, 보안 정책 변경 시에도 수정되어야 하므로 변경의 이유가 다수 존재하게 된다.
  • 강한 결합: 순수한 비즈니스 로직이 하위 인프라 기술(트랜잭션, 보안 등)과 강하게 결합된다.

요약 및 결론

객체지향 설계는 책임을 분리하는 데 성공했으나, 핵심 관심사와 공통 관심사를 깔끔하게 분리하는 문제에는 한계를 보였다. Spring은 바로 이 문제, 즉 "기존 객체의 코드를 수정하지 않고 공통 관심사를 어떻게 분리·확장할 것인가?"에 대한 해답으로 Proxy를 선택하게 된다.

8.3 가장 먼저 떠오르는 해결 방법: 메서드 추출의 한계

공통 관심사를 분리하기 위해 개발자가 가장 먼저 고려하는 방식은 공통 메서드/클래스로의 추출이다.

Utility 클래스를 활용한 예시

public class PerformanceUtil {
    public static void start() { ... }
    public static void end() { ... }
}

public void order(OrderRequest request) {
    PerformanceUtil.start(); // 공통 기능 호출
    
    // 비즈니스 로직 (주문 처리)
    
    PerformanceUtil.end();   // 공통 기능 호출
}

해결되지 않는 문제: 공통 기능 제어의 잔재

  • 구현은 분리되었으나 제어권은 유지됨: 로직 자체는 PerformanceUtil로 모였지만, 어느 시점에 호출할 것인지는 여전히 OrderService가 결정한다.
  • 책임 미분리: 비즈니스 객체는 비즈니스 로직 외에도 로깅, 트랜잭션, 성능 측정의 실행 타이밍과 존재 유무를 여전히 인지해야 한다.

8.4 공통 클래스/상속 구조로 분리해도 해결되지 않는 이유

구현체마다 공통 메서드를 일일이 호출하는 불편함을 줄이고자,
부모 클래스를 두어 상속받는 구조(AbstractService)를 도입하기도 한다.

그러나 상속(Inheritance) 방식은 다음과 같은 치명적인 한계를 내포한다.

1) 강한 결합과 단일 상속의 제약

  • 기술적 종속: 비즈니스 객체가 자신의 본래 역할 외에 특정 기술 클래스(AbstractService)에 강하게 결합된다.
  • 자바의 한계: 자바는 다중 상속을 지원하지 않으므로, 이미 다른 부모 클래스를 상속 중인 객체에는 이 방식을 적용할 수 없다.

2) 변경 전파 (OCP 위반)

  • 부모 클래스의 공통 로직(예: 보안 정책 추가, 로깅 방식 변경)이 수정되면, 이를 상속하는 모든 하위 서비스 클래스가 동시에 영향을 받는다.
  • 기능을 확장하거나 변경할 때 기존 코드를 계속 수정해야 하므로 개방-폐쇄 원칙(OCP)에 위배된다.

3) 공통 클래스의 거대화 (God Class화)

  • 시간이 흐름에 따라 트랜잭션, 캐시, 감사(Audit), 예외 처리, 입력 검증 등 온갖 기능이 부모 클래스에 집중된다.
  • 결과적으로 단순 비즈니스 객체가 자신과 무관한 수많은 인프라 기능까지 비대하게 상속받는 기형적 구조가 형성된다.

8.5 왜 기존 방식은 한계를 가질 수밖에 없는가

유틸리티 클래스, 헬퍼 클래스, 상속 구조 등 기존 접근법은 한 가지 공통된 허점을 지닌다.

기존 방식의 근본적 문제 비즈니스 객체가 공통 기능의 존재와 실행 시점을 알고 있다는 점이다.

 

문제의 재정의

  • 잘못된 접근: "공통 기능을 어디에 둘 것인가?" (단순 코드 위치 이동에 집중)
  • 올바른 접근: "공통 기능을 누가 실행해야 하는가?" (제어 주체 분리에 집중)

Spring이 내린 결론

  1. 단순 분리를 넘어, 비즈니스 객체가 공통 기능의 존재 자체를 몰라야 한다.
  2. 트랜잭션이나 로깅의 유무와 상관없이 비즈니스 로직은 순수하게 유지되어야 한다.
  3. 따라서 공통 기능은 객체의 내부가 아닌 '객체의 바깥(외부)'에서 적용되어야 한다.

Spring은 기존의 한계를 극복하기 위해 객체 내부의 코드를 건드리는 방식을 버리고, 객체 외부에서 감싸서 제어하는 Proxy Pattern(프록시 패턴)을 해법으로 선택한다.

8.6 객체의 바깥에서 기능을 추가할 수는 없을까?

비즈니스 객체(서비스)가 스스로 로깅, 트랜잭션, 캐시, 보안의 호출 타이밍을 결정하도록 두면 책임이 오염된다.

그렇다면 "공통 기능을 객체 내부가 아닌 객체의 바깥(외부)에서 적용할 수는 없을까?"라는 질문으로 발상을 전환할 수 있다.

요청 중계 구조의 도입

  • 클라이언트가 OrderService를 직접 호출하지 않고 중간의 중개자를 거친다.
  • 중개자가 요청을 먼저 받아 부가 작업(트랜잭션, 로깅, 권한 검사 등)을 처리한 후, 원본 객체에게 요청을 전달한다.
  • OrderService는 부가 기능의 존재를 전혀 몰라도 되며, 오직 순수한 비즈니스 로직(주문 처리)만 수행하게 된다.
  • 이것이 바로 Proxy Pattern(프록시 패턴)의 출발점이다.

8.7 Proxy Pattern: 대리인의 역할

Proxy는 사전적 의미로 '대리인'을 뜻한다. 부동산 계약 시 집주인과 임차인 사이에서 계약 전후 서류 확인 및 절차를 대신 처리해 주는 공인중개사와 같은 역할을 수행한다.

[ Client ]  ──>  [ Proxy (대리인) ]  ──>  [ Target (원본 객체) ]
  • Target: 실제 비즈니스 로직을 수행하는 원본 객체다.
  • Proxy: Target을 대신하여 요청을 수신하는 객체다.
  • 역할 분담: Proxy는 비즈니스 로직 자체를 직접 실행하지 않고 Target에게 위임하며, 자신은 요청의 제어 및 공통 부가 기능 수행에만 집중한다.

8.8 Proxy는 무엇을 대신하는가?

Proxy의 본질은 단순 '대호출'에 그치지 않고, 호출의 흐름(Control Flow)을 제어한다는 데 있다.

Proxy를 통한 전체 호출 흐름

 

Target은 제어 흐름 내에 적용된 기술(트랜잭션, 보안, 캐시 등)을 알 필요가 없다.
공통 기능은 모두 외부의 Proxy가 담당함으로써 완벽한 책임 분리가 완성된다.

8.9 Proxy는 객체지향을 무너뜨리는가, 지켜주는가?

중간에 객체를 하나 더 두는 구조가 객체지향을 복잡하게 만든다는 오해가 존재한다. 그러나 실제로는 정반대다.

Proxy는 객체지향을 지켜주는 기술이다.

  • 책임의 순수성 보장: 객체지향의 핵심은 객체가 자신의 고유 책임에만 집중하는 것이다.
  • 기술적 관심사 분리: 공통 기능이 클래스 내부로 침투하면 책임이 오염되지만, Proxy가 이 기술적 관심사를 객체 밖으로 밀어내 준다.
  • 결론: Proxy는 객체를 대체하기 위해서가 아니라, 객체가 자신의 책임을 끝까지 지킬 수 있도록 보호하기 위해 존재한다.

8.10 Spring은 왜 Proxy를 선택했는가?

Spring은 앞서 다룬 BeanPostProcessor(빈 후처리기) 확장 지점을 활용하여 Proxy 구조를 완성한다.

  1. Spring Container가 원본 객체를 생성하면, BeanPostProcessor가 이를 감싸는 Proxy를 생성한다.
  2. Container에는 원본 객체 대신 Proxy가 Bean으로 등록된다.
  3. 애플리케이션(Client)이 DI(의존성 주입)를 받을 때 전달되는 객체는 원본이 아닌 Proxy다.

이러한 메커니즘을 통해 Spring은 비즈니스 코드를 단 한 줄도 수정하지 않고 @Transactional, @Cacheable, @Async, @Validated, Spring Security 등 수많은 고성능 프레임워크 기능을 유연하게 적용한다.

중간 핵심 정리

  • 객체지향의 한계: 객체 간 협력은 잘 처리하나, 여러 객체에 걸친 공통 관심사(Cross-Cutting Concern)를 관리하는 데 한계가 존재했다.
  • 기존 접근법의 실패: Utility 클래스, Helper, 상속 구조 등은 코드를 분리했을 뿐, 비즈니스 객체가 공통 기능의 존재와 호출 시점을 알고 있어야 한다는 한계를 극복하지 못했다.
  • Proxy의 해법: 공통 기능을 객체 바깥에서 제어함으로써 비즈니스 객체가 자신의 핵심 책임에만 전념할 수 있도록 만든다.
  • Spring과의 결합: Spring은 BeanPostProcessor를 통해 런타임에 Proxy를 주입하는 방식을 채택하여 framework의 유연한 확장성을 확보했다.

8.11 Spring 이전에는 어떻게 해결했을까?

트랜잭션, 보안, 로깅과 같은 공통 관심사는 Spring이 새로 발견한 문제가 아니다. 엔터프라이즈 애플리케이션이 시작된 순간부터 존재했던 고질적인 문제였다. Spring 이전에는 EJB(Enterprise JavaBeans)가 그 대표적인 해답이었다. EJB 역시 트랜잭션 관리, 보안, 분산 환경 등 다양한 엔터프라이즈 기능을 제공했으나, 결정적인 한계를 지니고 있었다.

EJB의 근본적인 한계: POJO의 파괴

  • 침투적인 프레임워크(Intrusive Framework): 개발자가 작성하는 객체가 순수한 객체(POJO)로 남아있지 못하고, EJB가 요구하는 규약과 인터페이스를 구현하며 EJB API에 직접 의존해야 했다.
  • 객체지향 설계의 주객전도: 프레임워크가 객체의 구조와 생명주기를 강제 결정함으로써, 비즈니스 로직이 프레임워크와 강력하게 결합되었다.
  • 테스트의 어려움: EJB 컨테이너가 없으면 객체를 단독으로 실행할 수 없어, 순수한 단위 테스트(Unit Test)가 불가능에 가까웠다.

EJB 환경에서의 객체: 스스로 독립하지 못하고, 오직 프레임워크 내부에서만 생존할 수 있는 기술 종속적인 컴포넌트로 전락했다.

8.12 Rod Johnson의 질문

Spring Framework의 탄생은 화려한 기술적 발명이 아닌, 객체지향에 대한 하나의 본질적인 질문에서 시작되었다.

"정말 비즈니스 객체가 Framework를 알아야 하는가?"
— Rod Johnson

Rod Johnson은 객체지향 설계의 본질을 '객체가 자신의 책임에만 집중하는 것'으로 보았다.

  • 비즈니스 객체의 책임: 주문, 결제, 회원 관리
  • 프레임워크의 책임: 트랜잭션, 보안, 로깅, 예외 처리

비즈니스 객체를 프레임워크 안으로 강제 침투시키는 것이 아니라, 프레임워크가 순수한 비즈니스 객체를 외부에서 감싸는 구조가 훨씬 더 객체지향적이라는 결론에 도달했다. Spring은 비즈니스 객체에게 프레임워크 상속이나 특정 인터페이스 구현을 일절 강요하지 않는다. 객체의 바깥에서 필요한 기능을 부가하는 이 전략의 중심에 바로 Proxy가 존재한다.

8.13 Spring은 객체를 감싼다

Spring Container 내부에서 일어나는 일의 본질은 "생성된 Bean을 프레임워크가 감싸는 과정"이다.

객체를 감쌈으로써 얻는 이점

  • 비침투성 확장: 비즈니스 코드를 단 한 줄도 수정하지 않고 트랜잭션, 캐시, 보안, 로깅 등의 기능을 자유롭게 추가·제거한다.
  • 완벽한 무지(Ignorance): 비즈니스 객체는 자신에게 트랜잭션이나 보안이 적용되어 있다는 사실조차 모른 채 순수한 역할만 수행한다.

8.14 Proxy는 Spring Framework의 철학이다

Proxy는 GoF 디자인 패턴 중 하나로 널리 알려져 있으나, Spring에 있어 Proxy는 단순한 패턴 그 이상의 가치를 가진다.

Proxy = Spring의 설계 철학을 현실로 구현한 핵심 수단

  • 비즈니스 객체를 수정하지 않는다.
  • 비즈니스 객체를 프레임워크에 종속시키지 않는다.
  • 비즈니스 객체의 순수한 책임을 침범하지 않는다.
  • 대신, 프레임워크가 객체의 바깥에서 모든 기술적 관심사를 처리한다.

Spring이 수많은 엔터프라이즈 기능을 일관되게 확장할 수 있는 이유는 이 강력한 원칙과 Proxy라는 도구가 결합되어 있기 때문이다.

Chapter 8 마무리

  • 핵심 명제: Spring은 비즈니스 객체를 프레임워크 안으로 끌어들이지 않는다. 대신 프레임워크가 비즈니스 객체를 감싼다.
  • Proxy의 본질: 단순한 디자인 패턴을 넘어, 객체의 순수성을 보존하려는 Spring Framework의 철학 그 자체다.
  • 향후 과제: 다음 장에서는 런타임에 이 Proxy를 동적으로 생성하는 기술인 JDK Dynamic Proxy, CGLIB, 그리고 이를 통일된 인터페이스로 추상화한 ProxyFactory의 메커니즘을 살펴본다.

'🍃SpringBoot' 카테고리의 다른 글

Spring: Part 2. IoC 컨테이너와 Bean  (0) 2026.07.29
Spring: Part 1. Spring Framework의 이해  (0) 2026.07.28
Spring IoC & DI 완전 정복: Chapter 7. BeanPostProcessor와 Spring의 확장 포인트  (0) 2026.07.22
Spring IoC & DI 완전 정복: Chapter 6. Spring은 의존성을 어떻게 해결할까?  (0) 2026.07.22
Spring IoC & DI 완전 정복: Chapter 5. BeanDefinition과 Bean 생성 과정  (0) 2026.07.22
'🍃SpringBoot' 카테고리의 다른 글
  • Spring: Part 2. IoC 컨테이너와 Bean
  • Spring: Part 1. Spring Framework의 이해
  • Spring IoC & DI 완전 정복: Chapter 7. BeanPostProcessor와 Spring의 확장 포인트
  • Spring IoC & DI 완전 정복: Chapter 6. Spring은 의존성을 어떻게 해결할까?
limdaeil
limdaeil
limdaeil 님의 블로그 입니다.
  • limdaeil
    limdaeil
    limdaeil
  • 전체
    오늘
    어제
    • 분류 전체보기 (82) N
      • 💭Retrospective (21)
      • 🥕FrontEnd (0)
      • 🐬MySQL (1)
      • 🐍Python (5)
      • 🍃SpringBoot (33) N
      • ☕Java (1)
      • ♾️Devops (1)
      • 🌎Network (2)
      • 📚Read & 👨‍🏫Course (10)
      • Programmers (1)
      • 🧪Test (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    한빛미디어
    IoC
    spring boot
    회고
    Mockito
    redis
    MySQL
    한빛아카데미
    Python
    jwt
    DI
    junit
    Concurrency
    나는리뷰어다
    Spring
    distributed lock
    책
    서평단
    gradle
    optimistic rock
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
limdaeil
Spring IoC & DI 완전 정복: Chapter 8. Proxy와 Spring AOP
상단으로

티스토리툴바