[개발지식] 특별한 DI주입 대상 - Proxy객체에 대하여(*cf. CGLIB/JDK Dynamic Proxy/Bean)

Hyo Kyun Lee·2025년 12월 25일

개발지식

목록 보기
124/131

1. 개요

Spring Batch의 JobScope, StepScope 인터페이스를 살펴보면 특별한 의존성 주입 대상과 방법을 살펴볼 수 있다.

@Scope(value = "job", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface JobScope {

}
@Scope(value = "step", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StepScope {

}

기본적으로 spring boot에서의 @Scope는 “이 빈 인스턴스가 언제 생성되고, 얼마 동안 살아 있고, 누구를 공유하는가”를 정의하는 메타데이터다.

즉, @Scope는 생명주기(Lifecycle)와 공유 범위(Sharing Boundary)를 Spring 컨테이너에게 선언하는 역할을 한다.

이 내부를 구체적으로 살펴본다면,

public interface Scope {
    Object get(String name, ObjectFactory<?> objectFactory);
    Object remove(String name);
    void registerDestructionCallback(String name, Runnable callback);
    Object resolveContextualObject(String key);
    String getConversationId();
}

Bean을 어떻게 추출(get)하고, 제거(remove)할지 세부적인 전략에 대해 기술하고 있음을 확인할 수 있다.

기본적으로 제공하는 core scope는 singleton이고, sping application context에 등록하여 전역적으로 bean을 등록할 수 있도록 한다.

Scope 이름의미생명주기
singleton (기본)컨테이너당 1개ApplicationContext 전체
prototype요청마다 새 객체DI 시점마다
requestHTTP 요청당 1개HTTP Request
sessionHTTP 세션당 1개HTTP Session
applicationServletContext당 1개웹 애플리케이션
websocketWebSocket 세션당WebSocket

위와 같은 scope 범위를 제공하며, singleton이기에 빈등록 시에는 반환타입을 살펴보아야 하며, 반환타입이 여러개라면 컴포넌트명도 같이 살펴보아야 한다.

자 이제, 본론으로 넘어오면 Spring batch에서 특별한 주입 대상은 이러한 scope범위를 proxy 객체로 확장하였다는 점과 함께 살펴볼 필요가 있다.

예시제공 주체
stepSpring Batch
jobSpring Batch
refreshSpring Cloud
viewJSF
사용자 정의 Scope개발자

Spring 자체가 scope 범위의 확장을 허용하는데, spring batch에서 이를 적용하고 있다.

@Scope(value = "job", proxyMode = ScopedProxyMode.TARGET_CLASS)

위 기술한대로 살펴보자면,

  • value : 이 어노테이션의 적용 범위, job을 실행하는 동안 혹은 step을 실행하는 동안?
  • proxyMode : 나는 이 어노테이션을 통해 빈객체가 아닌 프록시객체를 주입하겠다. 그렇다면 어떠한 방법으로 프록시 객체를 등록할 것인가?
의미
NO프록시 사용 안 함
INTERFACESJDK 동적 프록시
TARGET_CLASSCGLIB 클래스 프록시
DEFAULTScope가 결정

JDK Dynamic 방식을 통해 해당 인터페이스 혹은 메서드 내용만 등록할 것인지, CGLIB를 통해 프록시 원본객체 및 부모 클래스까지 파고들어서 해당 클래스의 내용까지 메타데이터로 등록, 프록시 객체를 등록시키도록 구성할 수 있다.

spring batch가 어떻게 보면 pattern, 위임의 연속인데 이러한 부분까지 유사한 원리로 작동하니 꽤나 신기하게 다가왔다.

뿐만 아니라 spring batch 프레임워크 자체적으로, 이러한 객체 생성 및 주입방법을 주체적으로 지정하여 사용한다는 점이 의미있게 느껴지기도 하였다.

이러한 배경지식을 바탕으로, Spring batch에서 진행하는 의존성 주입의 특별한 방법들인 CGLIB와 JDK Dynamic 주입방식의 차이점에 대해 알아보았다.

2. CGLIB / JDK Dynamic Proxy에 대하여 - Proxy 객체 "생성방식"에 대한 명세

CGLIB, JDK Dynamic Proxy 생성방식을 명시한다는 것은 곧, "이 객체를 bean객체가 아닌 proxy 객체로 주입할 것이다"라는 것을 의미한다.

그리고 이 전제를 기반으로, Proxy 객체, 즉 원본객체가 아닌 대리객체를 만들어 최종적으로 주입을(Injection) 받게된다.

구분무엇에 대한 것인가
DI (의존성 주입)객체 참조 전달 방식
Proxy대리 객체 패턴(설계 개념)
JDK Dynamic Proxy프록시를 만드는 기술 (JDK 표준 API)
CGLIB프록시를 만드는 바이트코드 조작 라이브러리

그렇다면 두 생성방식은 어떠한 차이점이 있을까?

쉽게 말하면, Java 표준 라이브러리 기반의 생성방식인가, 외부 라이브러리 기반의 생성방식인지에 대한 차이이다.

  • CGLIB

Spring이 외부 라이브러리를 기반으로 프록시 객체를 생성하며, 바이트 코드(Code Generation)를 조작하여 만든다.

클래스 상속 기반의 프록시 객체이기에, Spring batch의 상속/구현체가 많은 프레임워크 특성상 필수적으로 사용해야 하는 프록시 객체 생성 방안이기도 하다.

  • JDK Dynamic Proxy

Spring 내부의, 표준 라이브러리를 통해, 보통 인터페이스에 적용하여 InvocationHandler에게 적절한 프록시 객체 생성을 하도록 위임한다.

그리고 최종적으로 이런 과정을 통해 생성한 프록시 객체를 "주입"한다.

  • BeanDefinition
  • BeanFactory
  • BeanPostProcessor
  • AOP ProxyFactory

어노테이션에 명세된 것은 bean definition을 통해 메타데이터로 등록되며, 이후 reflection 과정에서 프록시 혹은 빈객체 생성 및 주입이 발생한다. 이 과정은 동일하다.

차이는 주입하는 객체가 Bean인가, Proxy인가에 대한 차이일 뿐이다.

그리고, proxy 객체를 주입하는 것이라면 그것은 원본객체가 아닌 대리객체일 뿐이다. 주입 시점이 원본객체를 생성하는 시점과 다르거나 특성상 lazy injection이 필요하다면, 그때 proxy 객체 생성 및 주입방법을 활용해볼 수 있는 것이다.

3. stepScope/jobScope가 proxy 객체를 생성하는 기준

이처럼 Spring batch에서 사용하는 프록시 객체 생성 및 주입 방식은 batch 특유의 프레임워크 생명주기에 부합하기 위한 적절한 전략이라 할 수 있겠다.

그렇다면 step scope, job scope가 무조건적으로 프록시 객체, CGLIB 기반의 프록시 객체를 생성할까?

이것을 이해하는 것이 중요한데, 사실 기본적인 인터페이스의 프록시 객체 생성 방법은 JDK Dynamic Proxy이다.

조건프록시 방식
인터페이스 기반 빈JDK Dynamic Proxy (기본)
인터페이스 없음CGLIB (강제)
proxyTargetClass = true무조건 CGLIB

StepScope, JobScope가 내부적으로 proxyMode = TargetClass로 지정해주었기에, 해당 프록시 객체를 생성할때 CGLIB 방식을 사용하는 것이다.

참고로, Customized Class를 주입대상으로 지정하였다면 해당 클래스는 CGLIB 방식의 프록시 객체를 생성할 것이다.

@Bean
@StepScope
public ItemReader<MyItem> reader() { ... }

만약 일반적인 인터페이스, itemStream 인터페이스를 구현하지 않은 대상을 stepScope로 지정해주었다면 해당 인터페이스는 CGLIB가 아닌, JDK Dynamic Proxy 생성 대상이기에 해당 인터페이스만을 생성 대상으로 삼는다.

itemStream을 구현하지 않은 일반적인 인터페이스가 proxy 객체 생성 대상으로 지정되었기에, itemStream과 같은 상속/구현체를 전혀 신경쓰지 않는다.

이 상태에서 Spring batch의 생명주기가 그대로 동작한다면? itemStream을 구현하지 않은 상태이기 때문에 open(), close()와 같은 자원관리를 동작하지 않게 된다.

위 예외상황이 발생할 경우 Spring batch가 자원관리에 신경쓰지 않거나, 실행 시 Exception을 발생할 수 있는 치명적인 오류를 남기게 된다.

CGLIB, JDK Dynmaic Proxy는 위와 같은 과정으로 만들어지고 주입이 이루어진다.

4. CGLIB와 JDK Dynamic Proxy의 동작 차이

이 두가지 proxy 객체 생성방식은 다소 차이를 보인다.

JDK Dynamic Proxy(이하 JDK)의 경우,

Interface (ItemReader)
     ↑
JDK Proxy (implements Interface)
     ↓
InvocationHandler
     ↓
Target Object

위와 같이 흔히 알고 있는 프록시 객체 생성 방법, 즉 인터페이스 기반과 런타임에 java.lang.reflect.Proxy로 생성한다는 점, 메서드 호출 시 InvocationHandler.invoke()로 위임하여 원본객체를 참조한다는 점이 특징이다.

실제로,

proxy.read() 
  → InvocationHandler.invoke()
     → target.read()

인터페이스 기반이지만 그만큼 표준적이고 강력하다. 다만, 인터페이스에만 적용 가능하며 클래스 메서드 레벨에서는 적용이 불가능하다는 지엽적인 단점이 존재한다.

CGLIB의 경우,

TargetClass
    ↑ (상속)
CGLIB Proxy (subclass)
    ↓
MethodInterceptor
    ↓
super.method()

이처럼 proxy 뿐만 아니라, 해당 부모/상속체까지 탐색하여 모든 정보를 참고하게 된다.

이러한 특성을 이유로 클래스 상속 기반의 프록시 객체 생성 방법을 제공하며, final 클래스/메서드에 적용을 할 수 없다는 점만 제외하면 유연하고 확장적인 프록시 객체 생성 방법을 제안한다.

참고로, @Autowired도 프록시 객체를 생성하는 것이고, 이 경우에는 CGLIB를 사용하는 것이며, final의 경우 생성자 주입을 통해 직접 해당 객체 혹은 빈객체를 주입받기에 CGLIB의 위 적용제약은 그리 문제가 되지는 않는다.

5. vs Bean

Bean 객체와 프록시 객체는 헷갈릴 수 있는데, 본질적으로 다르다. 애초에 주입대상이 bean(원본) 객체인가, 프록시(대리) 객체인가로 나누어 바라보아야 하기 때문이다.

일반적인 Bean 등록 과정을 살펴보자.

BeanDefinition 생성
BeanFactory에 등록
인스턴스 생성 (new 또는 Factory Method)
Reflection 기반 필드/생성자/Setter 주입
초기화 콜백

결국 Bean Definition을 통해 등록한 메타데이터, bean factory에 등록하여 reflection 시점에 실제 객체를 생성하고 주입받는 것은 모두 실제 객체 그 자체이다.

반면, 프록시 객체 주입 방식은 Bean Post Processor가 개입하여 실제 객체 생성 후, 프록시 객체를 생성하여 원본이 아닌 대리 객체를 일단 먼저 등록한다는 차이점이 존재한다.

BeanFactory
 └── proxy (등록된 빈)
        └── target (내부 실제 객체)

따라서, 원본객체가 매개변수 및 기타 다른 이유로 아직 완성되지 않은 시점이라면, 프록시 객체를 사용하며 대리 객체를 미리 등록시켜놓는 것이다.

비단 Spring batch의 stepScope, jobScope뿐만 아니라 job parameter, N+1 problem 등 실행 컨텍스트의 논리적 지연과 이로 인한 정당성을 확보하기 위해 상당히 많은 곳에서 이 방식을 필요로 한다.

참고로 등록은 프록시 객체이지만, 당연히 실제 로직을 진행한다면 내부 원본 객체를 참조한다.

구분일반 Bean프록시 Bean
컨테이너가 관리하는 객체실제 객체프록시
실제 객체의 위치Bean 자체프록시 내부
호출 흐름caller → targetcaller → proxy → target
목적비즈니스 로직제어/위임
AOP/Scope

결론적으로 일반 bean 객체를 주입해야 한다면 로직을, 프록시 객체를 주입해야 한다면 제어 및 위임에 그 의미를 두어야 한다.

그렇게 닳고 닳도록 봐왔던 MVC에서도 프록시를 사용한다.

@Service
@Transactional
public class OrderService { ... }

이 orderService는 Controller 측에서 프록시 객체로 참조가 된다.

OrderServiceControllerOrderServiceProxyOrderServiceTarget

만약 생성자 주입이라면? 컨테이너가 가진 Bean을 참조 주입하게 된다.

@Transactional이 가지는 의미는? 단순히 해당 객체를 프록시 객체로 다루겠다, 트랜잭션 컨텍스트를 일정하게 진행하겠다는 의미가 아니라, "대리", "위임"의 사상을 넣어 공통관심사(AOP) 및 interceptor가 필요한 시점이 존재하고 이에 대한 확장성을 제공하겠다는 의미로도 볼 수 있다.

6. 결론

컨트롤러나 서비스에서 의존성을 주입할 때는 생성자 주입을 사용하는 것이 바람직하며, AOP나 스코프 제어가 필요 없는 경우에는 실제 객체가 Bean으로 등록되고 그 Bean을 참조/주입한다.

다만 트랜잭션 등 런타임 제어가 필요한 경우에는 프록시 Bean이 등록되며, 이는 메모리 낭비가 아니라 기능을 위한 설계 선택이다.

Spring batch에서 시작한 흥미와 의문이, Transaction 어노테이션을 사용했을때 공통관심사를 적용할 수 있는 이유(특히 동시성 제어 등), autowired를 남용할 필요가 없는 이유(위임의 의미가 없다면 final화하여 생성자 주입을 하는 것이 훨씬 바람직) 등까지 이어져 내부 동작과정을 이해하는데 큰 도움이 되었다.

참고로, “트랜잭션은 스레드에 묶인다” 개념처럼 컨트롤러 혹은 서비스를 @Transactional로 설정하여 이를 프록시 객체로 진행한다 하더라도, 내부적인 요소들은 원본 로직을 호출이 가능하기에 굳이 @Autowired와 같은 이중 프록시화를 해줄 필요가 없다.

스레드에 묶이기 때문에, 프록시 객체로 묶지 않아도 동일 트랜잭션으로 전파되기 때문이다.

Thread-1
  ├─ TransactionContext (ThreadLocal)
  ├─ Controller Proxy
  └─ Service (프록시든 아니든 상관 없음)

주입 대상이 프록시 객체이냐, bean(원본) 객체이냐의 차이이지만 굳이 대리객체를 지정할 필요없이 바로 원본을 바라볼 수 있도록 해주는 것이 구조적으로, 성능적으로 미약하게나마 도움이 될 것이다.

0개의 댓글