[Spring IoC] Singleton

📌 스프링이 내부적으로 싱글톤을 어떻게 구현하는가?

Spring의 싱글톤 패턴은 ApplicationContext(IoC 컨테이너)가 관리하는 빈을 하나만 생성하여 재사용하는 방식으로 구현된다.
내부적으로는 BeanDefinitionMap이라는 저장소에 한 번 생성한 객체를 저장하고, 이후 요청 시 같은 객체를 반환한다.
싱글톤 보장은 DefaultSingletonBeanRegistry에서 관리되며, 이를 통해 이미 생성된 빈을 재사용한다.


1. 싱글톤 패턴의 기본 원리

일반적으로 싱글톤 패턴(Singleton Pattern)객체의 인스턴스를 단 하나만 유지하는 디자인 패턴이다.

🛠 기본적인 싱글톤 패턴 구현 (Spring 없이)

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

클래스가 처음 로딩될 때 한 번만 객체가 생성된다.
이후 getInstance()를 호출하면 같은 객체를 반환한다.


2. 스프링의 싱글톤 관리 방식

Spring은 위와 같은 방식이 아니라, IoC 컨테이너에서 싱글톤을 관리하는 방식을 사용한다.
이를 위해 Spring 내부에서는 DefaultSingletonBeanRegistry가 모든 싱글톤 빈을 관리한다.


3. 싱글톤이 DefaultSingletonBeanRegistry에서 어떻게 관리되는가?

Spring은 빈을 생성한 후, 내부적으로 DefaultSingletonBeanRegistry에 등록한다.
그 후 같은 빈이 요청되면, 새로 생성하지 않고 기존 객체를 반환한다.

📌 DefaultSingletonBeanRegistry 내부 구현

public class DefaultSingletonBeanRegistry {

    /** 싱글톤 빈 저장소 */
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>();

    /** 빈 등록 메서드 */
    public void registerSingleton(String beanName, Object singletonObject) {
        singletonObjects.put(beanName, singletonObject);
    }

    /** 빈 조회 메서드 */
    public Object getSingleton(String beanName) {
        return singletonObjects.get(beanName);
    }
}

singletonObjects에 빈을 한 번만 등록하고, 이후 같은 빈이 요청되면 기존 객체를 반환한다.
즉, ApplicationContext.getBean()을 호출하면 내부적으로 DefaultSingletonBeanRegistry에서 빈을 조회한다.


4. 스프링 컨테이너의 싱글톤 동작 과정

  1. 빈을 요청하면(getBean()) ApplicationContext가 내부 BeanFactory에 빈이 있는지 확인한다.
  2. 빈이 없으면 BeanFactoryDefaultSingletonBeanRegistry에 빈을 생성하여 저장한다.
  3. 다음 번에 같은 빈을 요청하면 DefaultSingletonBeanRegistry에서 기존 객체를 반환한다.

📌 실제 동작 과정

  ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
  MyService service1 = context.getBean(MyService.class);
  MyService service2 = context.getBean(MyService.class);
  
  System.out.println(service1 == service2); // true (같은 객체)

getBean()을 호출할 때마다 같은 객체가 반환된다.


5. @Configuration이 싱글톤을 보장하는 이유

@Configuration
public class AppConfig {
    
    @Bean
    public MyService myService() {
        return new MyService();
    }
}

🚨 @Configuration이 없으면, @Bean 메서드가 여러 번 호출될 때마다 새로운 객체가 생성될 수 있다.
🚀 Spring은 @Configuration이 적용되면 CGLIB 프록시를 활용하여 싱글톤을 보장한다.

@Configuration
public class AppConfig {

    @Bean
    public MyService myService() {
        System.out.println("MyService 생성됨");
        return new MyService();
    }
}

📌 @Configuration이 있는 경우

ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MyService service1 = context.getBean(MyService.class);
MyService service2 = context.getBean(MyService.class);

출력:

MyService 생성됨

한 번만 실행되며, 같은 객체가 재사용된다.

📌 @Configuration이 없는 경우

public class AppConfig {

    @Bean
    public MyService myService() {
        System.out.println("MyService 생성됨");
        return new MyService();
    }
}
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MyService service1 = context.getBean(MyService.class);
MyService service2 = context.getBean(MyService.class);

출력:

MyService 생성됨
MyService 생성됨

🚨 두 번 실행되며, 서로 다른 객체가 생성된다.
이 문제를 방지하려면 @Configuration을 반드시 사용해야 한다.

CGLIB 프록시란?

  • ✅ CGLIB(Code Generation Library) 프록시는 런타임에 클래스의 바이트코드를 조작하여 동적으로 프록시 객체를 생성하는 기술이다.
  • ✅ Spring에서는 @Configuration, AOP(Aspect-Oriented Programming), 트랜잭션 관리(@Transactional) 등의 기능을 제공하기 위해 CGLIB 프록시를 활용한다.
  • ✅ CGLIB는 상속을 이용하여 원본 클래스의 기능을 확장하는 방식으로 프록시 객체를 생성한다.

CGLIB 프록시는 어떻게 동작할까?

Spring은 @Configuration이 붙은 클래스를 CGLIB을 이용해 동적으로 확장된 프록시 객체로 변환한다.

  1. 📌 CGLIB 프록시 클래스를 생성하는 과정
  2. @Configuration이 붙은 클래스를 감지한다.
  3. CGLIB을 이용해 AppConfig 클래스를 상속한 새로운 프록시 클래스를 생성한다.
  4. @Bean 메서드를 오버라이드하여 싱글톤을 보장한다.
  5. getBean()이 호출될 때, 원래의 @Bean 메서드를 실행하지 않고 프록시 객체가 저장된 싱글톤 빈을 반환한다.
public class AppConfig$$EnhancerByCGLIB extends AppConfig {
    private final MyService myServiceInstance = new MyService();

    @Override
    public MyService myService() {
        return myServiceInstance;
    }
}

이제 getBean(MyService.class)을 호출하면 myServiceInstance가 반환되므로 싱글톤이 유지된다.

프록시 종류

Spring은 인터페이스를 구현한 클래스에 활용할 수 있는 JDK 동적 프록시와 인터페이스를 구현하지 않은 클래스를 위한 CGLIB 프록시가 있다. (final 클래스는 프록시 적용 불가)
JDK 동적 프록시는 인터페이스 기반이라 안전하고 가볍지만, 인터페이스가 없으면 사용할 수 없다. CGLIB은 인터페이스가 없는 경우에도 사용할 수 있지만, 바이트코드 조작이 필요하고 final 클래스에서는 동작하지 않는다.

순수 자바 코드로 AOP 와 @Transactional을 직접 구현해보자.

Java의 InvocationHandler(JDK 동적 프록시)를 사용하여 직접 구현해보자.

AOP 적용 대상 서비스 클래스

public interface PaymentService {
    void processPayment();
}

public class CreditCardPaymentService implements PaymentService {
    @Override
    public void processPayment() {
        System.out.println("결제 처리 중...");
    }
}

JDK 동적 프록시를 사용하여 AOP 기능 추가

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class LoggingProxyHandler implements InvocationHandler {
    private final Object target;

    public LoggingProxyHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("Method 실행 전: " + method.getName());
        Object result = method.invoke(target, args); // 원래 메서드 실행
        System.out.println("Method 실행 후: " + method.getName());
        return result;
    }

    public static Object createProxy(Object target) {
        return Proxy.newProxyInstance(
                target.getClass().getClassLoader(),
                target.getClass().getInterfaces(), // 인터페이스 기반 프록시
                new LoggingProxyHandler(target)
        );
    }
}

실행

public class Main {
    public static void main(String[] args) {
        PaymentService paymentService = new CreditCardPaymentService();

        // 프록시 적용
        PaymentService proxy = (PaymentService) LoggingProxyHandler.createProxy(paymentService);
        proxy.processPayment();
    }
}

@Transactional을 순수 자바 코드로 구현

트랜잭션 적용 대상 클래스

public class OrderService {
    public void placeOrder() {
        System.out.println("주문 처리 시작...");
        processPayment();
        System.out.println("주문 완료!");
    }

    private void processPayment() {
        System.out.println("결제 처리 중...");
        throw new RuntimeException("결제 실패!");
    }
}

트랜잭션 프록시 구현 (CGLIB 방식)

import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import net.sf.cglib.proxy.Enhancer;

import java.lang.reflect.Method;

public class TransactionProxyHandler implements MethodInterceptor {
    private final Object target;

    public TransactionProxyHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("트랜잭션 시작...");
        try {
            Object result = proxy.invoke(target, args); // 실제 메서드 실행
            System.out.println("트랜잭션 커밋");
            return result;
        } catch (Exception e) {
            System.out.println("트랜잭션 롤백: " + e.getMessage());
            throw e;
        }
    }

    public static Object createProxy(Object target) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(target.getClass()); // 클래스 상속 지정
        enhancer.setCallback(new TransactionProxyHandler(target)); // 메서드를 가로챌 핸들러(인터셉터)를 등록한다.
        return enhancer.create(); // 프록시 객체를 생성하여 반환한다.
    }
}

tip. 프록시 클래스 내부 구조(CGLIB은 OrderService를 상속하여 동적으로 OrderService$$EnhancerByCGLIB 클래스를 생성한다.)

public class OrderService$$EnhancerByCGLIB extends OrderService {
    private MethodInterceptor interceptor; // 메서드 가로채기 핸들러

    public void placeOrder() {
        Method method = OrderService.class.getDeclaredMethod("placeOrder");
        interceptor.intercept(this, method, new Object[]{}, new MethodProxy(method));
    }
}

프록시를 적용하여 트랜잭션 관리 테스트

public class Main {
    public static void main(String[] args) {
        OrderService orderService = new OrderService();

        // CGLIB 프록시 적용
        OrderService proxy = (OrderService) TransactionProxyHandler.createProxy(orderService);

        try {
            proxy.placeOrder();
        } catch (Exception e) {
            System.out.println("예외 발생: " + e.getMessage());
        }
    }
}

6. @Scope("prototype")을 사용하면 싱글톤이 깨진다

기본적으로 Spring의 모든 빈은 싱글톤(singleton)이다.
그러나 @Scope("prototype")을 사용하면 매번 새로운 객체가 생성된다.

@Configuration
public class AppConfig {

    @Bean
    @Scope("prototype")
    public MyService myService() {
        return new MyService();
    }
}
  ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
  MyService service1 = context.getBean(MyService.class);
  MyService service2 = context.getBean(MyService.class);
  
  System.out.println(service1 == service2); // false (다른 객체)

프로토타입 빈은 매번 새로운 객체를 생성하므로, 싱글톤 보장이 깨진다.


📌 @Scope 옵션별 장단점 및 실무 활용 예시

✅ Spring에서는 여러 가지 스코프(singleton, prototype, request, session, application)를 제공하며,
✅ 각 스코프는 빈의 생성 주기와 사용 범위를 결정한다.
✅ 적절한 스코프를 선택하면 성능을 최적화하고, 유지보수성을 향상시킬 수 있다.


1. singleton (기본값)

💡 Spring 컨테이너 내에서 단 하나의 인스턴스를 생성하여 공유하는 방식
💡 모든 요청에서 동일한 객체를 사용 (Spring의 기본값)

🛠 설정 방법

@Service
@Scope("singleton") // 기본값이므로 생략 가능
public class SingletonService {
}
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
SingletonService service1 = context.getBean(SingletonService.class);
SingletonService service2 = context.getBean(SingletonService.class);

System.out.println(service1 == service2); // true (같은 객체)

✅ 장점

메모리 절약 → 한 번만 생성되므로 메모리 사용량이 적다.
관리 용이 → 같은 인스턴스를 공유하므로 관리가 편하다.
애플리케이션 성능 최적화 → 객체를 반복적으로 생성하는 비용이 줄어든다.

❌ 단점

상태(state)를 가지면 동시성 문제 발생 → 멀티스레드 환경에서는 공유 필드가 변경될 위험이 있다.
사용자별/요청별로 다른 객체를 생성해야 할 경우 적합하지 않음

🏢 실무 활용 예시

서비스 객체(@Service) → 비즈니스 로직을 처리하는 대부분의 서비스 클래스
DAO/Repository 객체(@Repository) → 데이터베이스 연결 관리
Spring MVC 컨트롤러(@Controller) → 요청 처리


2. prototype

💡 요청할 때마다 새로운 객체를 생성
💡 상태를 가지는 객체나 요청마다 다른 객체가 필요한 경우 사용

🛠 설정 방법

@Component
@Scope("prototype")
public class PrototypeService {
}
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
PrototypeService service1 = context.getBean(PrototypeService.class);
PrototypeService service2 = context.getBean(PrototypeService.class);

System.out.println(service1 == service2); // false (새로운 객체 생성)

✅ 장점

객체 상태를 유지할 수 있음 → 새로운 객체가 생성되므로, 내부 상태를 저장해도 문제가 없다.
병렬 처리에 유리 → 여러 요청이 동시에 발생해도, 객체가 공유되지 않으므로 동시성 문제가 없다.

❌ 단점

메모리 사용량 증가 → 매번 새로운 객체를 생성하므로 메모리를 더 많이 사용한다.
객체 생성 비용 증가 → 매번 객체를 생성하면 성능에 영향을 줄 수 있다.

🏢 실무 활용 예시

사용자별 상태 정보를 유지해야 하는 경우 → 계산기, PDF 생성, 이메일 전송 등의 클래스
멀티스레드 환경에서 동시성을 고려해야 하는 경우 → Thread-Safe한 객체를 만들어야 할 때
특정 요청에만 필요한 임시 객체 → 일정한 생명 주기를 가진 객체


3. request (Spring MVC)

💡 HTTP 요청마다 새로운 객체를 생성
💡 웹 애플리케이션에서 요청별로 다른 객체가 필요할 때 사용

🛠 설정 방법

@Component
@Scope("request")
public class RequestScopedBean {
}
@Controller
public class MyController {
    @Autowired
    private RequestScopedBean requestBean;

    @GetMapping("/request")
    public String handleRequest() {
        System.out.println(requestBean);
        return "requestHandled";
    }
}

✅ 장점

사용자별/요청별로 독립된 객체 관리 가능
세션과 관계없이 새로운 객체를 사용할 수 있음
HTTP 요청마다 필요한 데이터를 유지할 수 있음

❌ 단점

Spring 컨테이너에서 직접 관리되지 않음 → 일반적인 빈보다 사용이 제한됨
Spring MVC 환경에서만 사용 가능

🏢 실무 활용 예시

로그인한 사용자 정보 저장 (HttpServletRequest 대체)
파일 업로드 같은 요청별로 상태를 유지해야 하는 경우
폼 데이터를 요청 범위에서 유지해야 하는 경우


4. session (Spring MVC)

💡 사용자 세션 범위 내에서 같은 객체를 공유
💡 사용자별로 상태를 유지해야 할 때 사용

🛠 설정 방법

@Component
@Scope("session")
public class SessionScopedBean {
}
@Controller
public class MyController {
    @Autowired
    private SessionScopedBean sessionBean;

    @GetMapping("/session")
    public String handleSession() {
        System.out.println(sessionBean);
        return "sessionHandled";
    }
}

✅ 장점

사용자별 상태 유지 가능 → 로그인 상태, 장바구니 등 사용자 세션 내에서 객체 유지
여러 요청 간 데이터 공유 가능

❌ 단점

메모리 사용량 증가 → 세션이 많아지면 서버 메모리 사용량이 증가할 수 있음
세션 만료 시 빈이 삭제됨

🏢 실무 활용 예시

로그인된 사용자 정보 저장 (UserSession, UserPreferences)
웹 애플리케이션의 장바구니 기능
사용자 맞춤 설정을 유지하는 기능


5. application (Spring MVC)

💡 애플리케이션이 실행되는 동안 하나의 객체를 유지
💡 애플리케이션 전역에서 동일한 객체를 공유

🛠 설정 방법

@Component
@Scope("application")
public class ApplicationScopedBean {
}
@Controller
public class MyController {
    @Autowired
    private ApplicationScopedBean appBean;

    @GetMapping("/app")
    public String handleApp() {
        System.out.println(appBean);
        return "appHandled";
    }
}

✅ 장점

애플리케이션 전역에서 공통된 데이터를 관리할 수 있음
서버가 종료될 때까지 객체가 유지됨

❌ 단점

변경이 필요하면 동기화 문제 발생 가능
사용자별 데이터 저장이 불가능

🏢 실무 활용 예시

애플리케이션 공통 설정 정보 유지
애플리케이션에서 사용되는 캐시 데이터
모든 요청에서 동일한 데이터가 필요한 경우


📌 스코프별 정리

스코프설명장점단점실무 활용
singleton기본값, 한 번 생성 후 공유메모리 절약, 관리 용이상태 저장 시 동시성 문제대부분의 서비스, DAO, 컨트롤러
prototype요청할 때마다 새 객체상태 유지 가능, 동시성 문제 없음메모리 사용 증가상태가 있는 객체, 멀티스레드 환경
requestHTTP 요청마다 새 객체요청별 데이터 유지 가능Spring MVC에서만 사용로그인 정보, 폼 데이터 유지
session사용자 세션마다 새 객체사용자별 상태 유지 가능세션이 많아지면 메모리 증가장바구니, 사용자 설정
application애플리케이션 전역에서 하나만 생성전역 데이터 유지데이터 변경 시 동기화 필요공통 설정, 캐시

© 2023 Lee. All rights reserved.