Spring Bean Scope와 생명주기 완벽 가이드: Singleton, Prototype, Request Scope 핵심 정리

Spring Bean Scope와 생명주기 완벽 가이드: Singleton, Prototype, Request Scope 핵심 정리

“Spring Bean의 기본 Scope가 뭔가요?”, “싱글톤 빈이 상태를 가지면 어떤 문제가 생기나요?” — Spring 면접에서 빈의 생명주기와 Scope는 단골 주제다. 이 글은 Bean이 생성되고 소멸되는 과정부터 Scope별 동작 차이, 그리고 실무에서 자주 발생하는 함정까지 다룬다.


1. Spring Bean이 생성되고 소멸되는 과정

1.1 Bean 생명주기 전체 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
┌─────────────────────────────────────────────────────────────────────┐
│                    Spring Bean 생명주기                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ① 스프링 컨테이너 생성                                             │
│       │                                                             │
│  ② Bean 인스턴스 생성 (생성자 호출)                                 │
│       │                                                             │
│  ③ 의존관계 주입 (DI)                                               │
│       │  @Autowired, 생성자 주입 등                                 │
│       │                                                             │
│  ④ 초기화 콜백                                                      │
│       │  @PostConstruct                                             │
│       │  InitializingBean.afterPropertiesSet()                     │
│       │  @Bean(initMethod = "init")                                │
│       │                                                             │
│  ⑤ 사용 (애플리케이션 실행 중)                                      │
│       │                                                             │
│  ⑥ 소멸 전 콜백                                                    │
│       │  @PreDestroy                                                │
│       │  DisposableBean.destroy()                                  │
│       │  @Bean(destroyMethod = "close")                            │
│       │                                                             │
│  ⑦ 스프링 컨테이너 종료                                             │
│                                                                     │
│  핵심: 생성자 호출 ≠ 초기화                                         │
│  생성자: 객체 생성 + 필수 값 설정                                    │
│  초기화: 외부 연결, 무거운 작업 (DB 커넥션, 파일 열기 등)           │
│  → 분리하는 것이 단일 책임 원칙에 부합                              │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

1.2 코드로 보는 생명주기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Slf4j
@Component
public class MyBean {

    private final DataSource dataSource;

    // ② 생성자 호출 + ③ 의존관계 주입
    public MyBean(DataSource dataSource) {
        this.dataSource = dataSource;
        log.info("생성자 호출 - 의존관계 주입 완료");
    }

    // ④ 초기화 콜백 — DI 완료 후 실행
    @PostConstruct
    public void init() {
        log.info("초기화 - DB 연결 확인, 캐시 로딩 등");
    }

    // ⑥ 소멸 전 콜백 — 컨테이너 종료 시 실행
    @PreDestroy
    public void close() {
        log.info("소멸 - 리소스 정리, 연결 해제");
    }
}

출력:

1
2
3
4
생성자 호출 - 의존관계 주입 완료
초기화 - DB 연결 확인, 캐시 로딩 등
... (애플리케이션 실행) ...
소멸 - 리소스 정리, 연결 해제

2. Bean 초기화와 소멸 콜백

2.1 세 가지 방법 비교

1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────────────────────────────────────┐
│               초기화/소멸 콜백 3가지 방법                             │
├─────────────────────┬──────────────────┬────────────────────────────┤
│ 방법                │ 초기화            │ 소멸                       │
├─────────────────────┼──────────────────┼────────────────────────────┤
│ ① 어노테이션 (권장) │ @PostConstruct   │ @PreDestroy               │
│ ② 인터페이스        │ afterPropertiesSet│ destroy()                 │
│ ③ @Bean 속성       │ initMethod       │ destroyMethod             │
└─────────────────────┴──────────────────┴────────────────────────────┘

2.2 @PostConstruct, @PreDestroy (권장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class CacheManager {

    private Map<String, Object> cache;

    @PostConstruct  // DI 완료 후 자동 실행
    public void init() {
        cache = new ConcurrentHashMap<>();
        // DB에서 초기 데이터 로딩
        loadInitialData();
    }

    @PreDestroy     // 컨테이너 종료 시 자동 실행
    public void clear() {
        cache.clear();
    }
}

이 방법이 권장되는 이유:

  • 코드가 가장 간결
  • Java 표준 어노테이션 (jakarta.annotation 패키지)
  • Spring에 의존하지 않음
  • 컴포넌트 스캔과 자연스럽게 동작

2.3 인터페이스 방식 (비권장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Spring 인터페이스에 의존 → 코드가 Spring에 종속됨
@Component
public class MyBean implements InitializingBean, DisposableBean {

    @Override
    public void afterPropertiesSet() throws Exception {
        // 초기화
    }

    @Override
    public void destroy() throws Exception {
        // 소멸
    }
}

2.4 @Bean 속성 (외부 라이브러리에 사용)

1
2
3
4
5
6
7
8
9
10
@Configuration
public class AppConfig {

    @Bean(initMethod = "init", destroyMethod = "close")
    public ExternalLibraryClient externalClient() {
        return new ExternalLibraryClient();
        // ExternalLibraryClient는 외부 라이브러리라 @PostConstruct를 붙일 수 없음
        // → @Bean의 initMethod/destroyMethod로 지정
    }
}

3. 싱글톤 빈이 기본인 이유

3.1 Singleton Scope

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────────────────────┐
│                    Singleton Scope                                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Spring Container                                                   │
│  ┌──────────────────────────────────────────────────────────┐      │
│  │                                                          │      │
│  │  userService (싱글톤 빈) ──── 인스턴스 딱 1개            │      │
│  │       ▲          ▲                                      │      │
│  │       │          │                                      │      │
│  │  controllerA  controllerB  ← 같은 인스턴스를 공유       │      │
│  │                                                          │      │
│  └──────────────────────────────────────────────────────────┘      │
│                                                                     │
│  ● 컨테이너 시작 시 딱 1번 생성, 컨테이너 종료 시 소멸              │
│  ● 모든 요청에서 같은 인스턴스 사용                                  │
│  ● 스프링의 기본 Scope                                               │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

3.2 왜 싱글톤이 기본인가?

1
2
3
4
5
6
7
8
9
10
11
12
웹 애플리케이션은 동시에 수많은 요청을 처리한다.
요청마다 새 객체를 생성하면:

● UserService 요청 1 → new UserService() + new UserRepository()
● UserService 요청 2 → new UserService() + new UserRepository()
● UserService 요청 3 → new UserService() + new UserRepository()
...
→ 초당 수천 개 객체 생성·소멸 → GC 부담, 메모리 낭비

싱글톤이면:
● UserService → 인스턴스 1개를 모든 요청에서 공유
→ 메모리 절약, GC 부담 최소화, 성능 향상

4. Singleton Scope — 상세

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component  // 기본이 싱글톤 → @Scope 생략 가능
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}
1
2
3
4
// 같은 빈인지 확인
UserService service1 = context.getBean(UserService.class);
UserService service2 = context.getBean(UserService.class);
System.out.println(service1 == service2); // true — 같은 인스턴스!

5. 싱글톤 빈이 상태를 가지면 왜 위험한가

5.1 Thread-Safety 문제

1
2
3
4
5
6
7
8
9
10
// ❌ 위험한 코드: 싱글톤 빈에 공유 상태
@Service
public class CountService {

    private int count = 0;  // 모든 스레드가 공유하는 필드!

    public int getNextCount() {
        return ++count;  // Race Condition 발생!
    }
}
1
2
3
Thread A: count 읽기 (0) → +1 → 저장 (1)
Thread B: count 읽기 (0) → +1 → 저장 (1)  ← 같은 값을 읽음!
→ 기대값: 2, 실제값: 1 → 데이터 정합성 깨짐

5.2 해결 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ✅ 방법 1: 상태를 가지지 않는 설계 (가장 권장)
@Service
public class UserService {
    // 멤버 변수에 상태 저장하지 않음
    // 모든 데이터는 파라미터와 지역 변수로 처리

    public UserResponse findById(Long id) {
        User user = userRepository.findById(id).orElseThrow(); // 지역 변수
        return UserResponse.from(user); // 지역 변수
    }
}

// ✅ 방법 2: ThreadLocal 사용 (스레드별 독립 저장소)
@Component
public class UserContext {
    private static final ThreadLocal<Long> currentUserId = new ThreadLocal<>();

    public void setUserId(Long userId) {
        currentUserId.set(userId);
    }

    public Long getUserId() {
        return currentUserId.get();
    }

    public void clear() {
        currentUserId.remove(); // 반드시 정리! (스레드 풀 재사용 때문)
    }
}

// ✅ 방법 3: 동기화 (성능 저하 가능)
@Service
public class SequenceService {
    private final AtomicLong sequence = new AtomicLong(0);

    public long getNext() {
        return sequence.incrementAndGet(); // 원자적 연산
    }
}

6. Prototype Scope

6.1 동작 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────────────────────┐
│                    Prototype Scope                                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Spring Container                                                   │
│  ┌──────────────────────────────────────────────────────────┐      │
│  │                                                          │      │
│  │  요청 1 → new PrototypeBean() → 반환 (컨테이너가 관리X) │      │
│  │  요청 2 → new PrototypeBean() → 반환 (컨테이너가 관리X) │      │
│  │  요청 3 → new PrototypeBean() → 반환 (컨테이너가 관리X) │      │
│  │                                                          │      │
│  └──────────────────────────────────────────────────────────┘      │
│                                                                     │
│  ● 요청할 때마다 새 인스턴스 생성                                    │
│  ● 생성 + 의존관계 주입 + 초기화(@PostConstruct)까지만 관리          │
│  ● 이후 컨테이너가 관리하지 않음                                     │
│  ● @PreDestroy 호출 안 됨! ★                                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@Scope("prototype")
public class PrototypeBean {

    @PostConstruct
    public void init() {
        System.out.println("초기화 - " + this); // 매번 다른 인스턴스
    }

    @PreDestroy
    public void destroy() {
        System.out.println("소멸");  // ★ 호출 안 됨!
    }
}
1
2
3
PrototypeBean bean1 = context.getBean(PrototypeBean.class);
PrototypeBean bean2 = context.getBean(PrototypeBean.class);
System.out.println(bean1 == bean2); // false — 다른 인스턴스!

7. 싱글톤 빈이 프로토타입 빈을 주입받을 때 문제

7.1 문제 상황

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Scope("prototype")
public class PrototypeBean {
    private int count = 0;
    public int addCount() { return ++count; }
}

@Component
public class SingletonBean {

    private final PrototypeBean prototypeBean; // ❌ 주입 시점에 1번만 생성!

    public SingletonBean(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
    }

    public int logic() {
        return prototypeBean.addCount();
    }
}
1
2
3
4
5
6
싱글톤 빈은 생성 시 1번만 DI를 받으므로,
prototypeBean도 1번만 생성되어 계속 같은 인스턴스를 사용!

clientA.logic() → count: 1
clientB.logic() → count: 2  ← 같은 PrototypeBean!
→ Prototype의 의미가 없어짐

7.2 해결: ObjectProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class SingletonBean {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public SingletonBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        // getObject()할 때마다 새 PrototypeBean 생성!
        return prototypeBean.addCount();
    }
}
1
2
clientA.logic() → 새 PrototypeBean → count: 1
clientB.logic() → 새 PrototypeBean → count: 1  ← 매번 새 인스턴스!

8. Request, Session, Application Scope

8.1 웹 스코프 종류

1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────────────────────────────────────┐
│                    웹 스코프                                         │
├──────────────┬──────────────────────────────────────────────────────┤
│ Scope        │ 생명주기                                             │
├──────────────┼──────────────────────────────────────────────────────┤
│ request      │ HTTP 요청 하나가 들어오고 나갈 때까지                │
│ session      │ HTTP 세션이 생성되고 만료될 때까지                   │
│ application  │ ServletContext와 동일한 생명주기                     │
└──────────────┴──────────────────────────────────────────────────────┘

8.2 Request Scope 활용: 요청별 로그 추적

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Slf4j
public class RequestLog {

    private String uuid;
    private String requestUrl;

    @PostConstruct
    public void init() {
        uuid = UUID.randomUUID().toString().substring(0, 8);
        log.info("[{}] request scope bean created", uuid);
    }

    public void setRequestUrl(String requestUrl) {
        this.requestUrl = requestUrl;
    }

    public void log(String message) {
        log.info("[{}] [{}] {}", uuid, requestUrl, message);
    }

    @PreDestroy
    public void close() {
        log.info("[{}] request scope bean destroyed", uuid);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequiredArgsConstructor
public class UserController {

    private final RequestLog requestLog;  // 프록시 객체가 주입됨
    private final UserService userService;

    @GetMapping("/api/users/{id}")
    public UserResponse getUser(@PathVariable Long id, HttpServletRequest request) {
        requestLog.setRequestUrl(request.getRequestURI());
        requestLog.log("controller 시작");
        UserResponse response = userService.findById(id);
        requestLog.log("controller 끝");
        return response;
    }
}
1
2
3
4
5
6
7
8
[a1b2c3d4] [/api/users/1] controller 시작
[a1b2c3d4] [/api/users/1] service 로직
[a1b2c3d4] [/api/users/1] controller 끝
[a1b2c3d4] request scope bean destroyed

다음 요청:
[e5f6g7h8] [/api/users/2] controller 시작  ← 다른 UUID!
...

8.3 proxyMode가 필요한 이유

1
2
3
4
5
6
7
8
9
10
싱글톤 빈(Controller)이 Request Scope 빈(RequestLog)을 주입받으면,
Controller 생성 시점에는 아직 HTTP 요청이 없어서 RequestLog를 만들 수 없음!

proxyMode = TARGET_CLASS:
→ 가짜 프록시 객체를 먼저 주입
→ 실제 요청이 오면 프록시가 진짜 RequestLog로 위임
→ 요청마다 다른 RequestLog 인스턴스 사용

Provider로도 해결 가능:
ObjectProvider<RequestLog> → getObject() 시점에 생성

9. Scope별 비교 정리

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────────────────┐
│                    Bean Scope 비교                                    │
├──────────────┬──────────┬──────────┬────────────┬──────────────────┤
│              │Singleton │Prototype │ Request    │ Session          │
├──────────────┼──────────┼──────────┼────────────┼──────────────────┤
│ 인스턴스 수  │ 1개      │ 요청마다 │ HTTP 요청마다│ HTTP 세션마다   │
│ 생성 시점    │ 컨테이너 │ 조회할 때│ HTTP 요청 시│ 세션 생성 시     │
│              │ 시작 시  │          │             │                  │
│ 소멸 시점    │ 컨테이너 │ 관리 안함│ 요청 끝    │ 세션 만료        │
│              │ 종료 시  │          │             │                  │
│ @PreDestroy  │ ✅ 호출 │ ❌ 안 됨│ ✅ 호출    │ ✅ 호출         │
│ 사용 비율    │ 99%      │ 거의 안씀│ 간혹 사용  │ 거의 안씀       │
└──────────────┴──────────┴──────────┴────────────┴──────────────────┘

면접 예상 질문 & 답변

Q1. Spring Bean의 기본 Scope는 무엇이고, 왜 기본인가요?

기본 Scope는 Singleton입니다. 스프링 컨테이너가 시작될 때 빈 인스턴스를 하나만 생성하고, 모든 요청에서 같은 인스턴스를 공유합니다.

웹 애플리케이션은 동시에 수많은 요청을 처리하는데, 요청마다 Service, Repository 객체를 새로 만들면 메모리 낭비와 GC 부담이 심해집니다. 싱글톤으로 하나의 인스턴스를 공유하면 메모리 효율과 성능이 크게 향상됩니다.

Q2. 싱글톤 빈이 상태를 가지면 어떤 문제가 생기나요?

싱글톤 빈은 모든 스레드가 같은 인스턴스를 공유합니다. 멤버 변수에 상태를 저장하면 여러 스레드가 동시에 읽고 쓰면서 Race Condition이 발생합니다.

예를 들어 private int count를 두고 count++를 하면, 두 스레드가 같은 값을 읽어 증가시켜 데이터가 꼬입니다. 따라서 싱글톤 빈은 상태를 가지지 않도록(stateless) 설계해야 하고, 모든 데이터는 파라미터, 지역 변수, ThreadLocal로 처리해야 합니다.

Q3. @PostConstruct는 언제 실행되나요?

@PostConstruct의존관계 주입(DI)이 완료된 직후 실행됩니다. 생성자에서 DI가 끝나고, 빈이 사용 가능한 상태가 된 시점에 호출됩니다.

초기화 작업(DB 연결 확인, 캐시 로딩 등)은 생성자가 아니라 @PostConstruct에서 수행해야 합니다. 생성자 시점에는 아직 다른 빈의 주입이 완료되지 않았을 수 있기 때문입니다.

Q4. Prototype Scope와 Singleton Scope의 차이는?

Singleton은 컨테이너에 인스턴스가 하나만 존재하고 모든 요청에서 공유합니다. 컨테이너가 생성부터 소멸까지 전체 생명주기를 관리합니다.

Prototype은 요청할 때마다 새 인스턴스를 생성합니다. 컨테이너는 생성 + DI + 초기화까지만 관리하고, 이후에는 관리하지 않습니다. 따라서 @PreDestroy가 호출되지 않습니다.

Q5. 싱글톤 빈이 프로토타입 빈을 주입받으면 어떤 문제가 생기나요?

싱글톤 빈은 생성 시 한 번만 DI를 받으므로, 프로토타입 빈도 한 번만 생성되어 계속 같은 인스턴스를 사용합니다. 프로토타입의 “매번 새 인스턴스” 의도가 무의미해집니다.

해결 방법은 ObjectProvider<PrototypeBean>을 주입받고, getObject() 호출 시마다 새 인스턴스를 생성하는 것입니다. 이를 Provider를 통한 DL(Dependency Lookup) 이라고 합니다.

Q6. Request Scope는 언제 사용하나요?

Request Scope는 HTTP 요청 하나에 대해 빈 인스턴스가 생성되고, 요청이 끝나면 소멸합니다. 같은 요청 내에서는 같은 인스턴스를 공유합니다.

주로 요청별 로그 추적에 사용합니다. 요청마다 고유 UUID를 생성하고, Controller → Service → Repository를 거치면서 같은 UUID로 로그를 남겨 하나의 요청을 추적할 수 있습니다.

다만 싱글톤 빈에서 Request Scope 빈을 주입받으려면 proxyMode = TARGET_CLASS 또는 ObjectProvider가 필요합니다.

Q7. Bean 초기화와 소멸 콜백의 세 가지 방법과 권장 방식은?

첫째, @PostConstruct/@PreDestroy 어노테이션 — 가장 간결하고 Spring 권장 방법입니다. Java 표준 어노테이션이라 Spring에 종속되지 않습니다.

둘째, InitializingBean/DisposableBean 인터페이스 — Spring 인터페이스에 코드가 종속되므로 비권장입니다.

셋째, @Bean(initMethod, destroyMethod) — 외부 라이브러리처럼 코드를 수정할 수 없는 경우에 사용합니다.

실무에서는 @PostConstruct/@PreDestroy를 기본으로 사용하고, 외부 라이브러리에만 @Bean 속성을 사용합니다.


마무리

Spring Bean의 Scope와 생명주기를 정리하면:

  • Singleton이 기본: 하나의 인스턴스를 공유하여 메모리 효율 극대화
  • 상태를 가지면 위험: Thread-Safety 문제, stateless 설계 원칙
  • @PostConstruct/@PreDestroy: 초기화/소멸 콜백의 표준
  • Prototype: 매번 새 인스턴스, 컨테이너가 소멸 관리 안 함
  • 싱글톤 + 프로토타입 함정: ObjectProvider로 해결
  • Request Scope: 요청별 로그 추적에 활용, proxyMode 필요

Bean Scope를 이해하면 “왜 Controller에 멤버 변수를 두면 안 되는지”, “왜 요청마다 다른 결과가 나오는지” 같은 실무 문제를 명확히 진단할 수 있다.