To Do List
- DI (Dependency Injection, 의존성 주입)
- IoC (Inversion of Control, 제어의 역전)
- Spring Bean (빈 스코프 & 생명주기)
- 왜 Spring Bean은 기본적으로 싱글톤으로 설계했을까?
- 요청이 들어왔을 때, 디스패처 서블릿이 컨트롤러를 어떻게 찾아주는가
Part 1 : DI (Dependency Injection, 의존성 주입)
의존성은 무엇일까?
코드에서 A가 B를 사용한다면, "A는 B에 의존한다" 라고 말한다.
🕶️ 코드 예시
public class OrderService {
private PaymentService paymentService = new PaymentService(); // 직접 생성
public void order() {
paymentService.pay();
}
}
OrderService는 PaymentService에 의존하고 있다.
코드에서 OrderService가 PaymentService를 직접 new로 생성했는데 왜 문제일까?

"직접 만들지 말고, 외부에서 받아라"
// DI 적용 전 — 내가 직접 생성
public class OrderService {
private PaymentService paymentService = new PaymentService();
}
// DI 적용 후 — 외부에서 주입받음
public class OrderService {
private PaymentService paymentService; // 선언만
// 생성자를 통해 외부에서 주입
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
OrderService는 PaymentService가 어떻게 만들어졌는지 몰라도 된다. 그냥 주어진 걸 사용하기만 하면 된다.
그래서 나중에 PaymentService를 MoonPayService로 바꿔도 OrderService 코드는 전혀 수정할 필요가 없다.
✔️ DI를 해주는 3가지 방법
// 방법 1 : 생성자 주입 (Spring에서 가장 권장하는 방법)
@Service
public class OrderService {
private final PaymentService paymentService;
@Autowired // 생략 가능
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
// 방법 2 : Setter 주입
@Service
public class OrderService {
private PaymentService paymentService;
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
// 방법 3 : 필드 주입 (간단하지만 권장하지 않음)
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
}
생성자 주입을 사용하는 이유는 final로 선언할 수 있어서 값이 바뀌지 않게 만들 수 있고,
테스트할 때도 직접 값을 넣어주기 쉽기 때문이다.
Part 2 : IoC (Inversion of Control, 제어의 역전)
IoC는 “제어권이 뒤집혔다”는 의미다.
원래는 내가 직접 객체를 만들고 흐름을 제어하지만, IoC에서는 이 제어를 스프링이 대신 관리해준다.
// 내가 제어 (IoC 없음)
PaymentService pay = new PaymentService();
OrderService order = new OrderService(pay);
order.order();
IoC가 적용되면 객체 생성과 흐름을 제어하는 권한이 나에게서 프레임워크(Spring)로 넘어간다.
// Spring이 제어 (IoC 있음)
// 개발자는 그냥 이것만 씀
@Autowired
private OrderService orderService; // Spring이 알아서 만들고 주입해줌
개발자는 객체를 어떻게 만들지 직접 구현하는 게 아니라, 어떤 것이 필요한지만 선언하면 된다.

🍬 DI와 IoC의 관계 정리
IoC는 개념이고, DI는 그걸 구현하는 방법이다.
- IoC : 제어권을 프레임워크(Spring)에게 넘긴다는 설계 개념
- DI : 필요한 객체를 직접 생성하지 않고 외부에서 주입받는 방식
즉, Spring은 IoC를 실현하기 위해 DI를 사용한다고 보면 된다.
정리하면 DI는 “내가 직접 new 하지 않고 받는 것”, IoC는 “그 제어권 자체가 Spring으로 넘어간 것”이다.
Part 3 : Spring Bean — 빈 스코프 & 생명주기
✔️ Bean이 무엇일까?
Spring이 IoC 컨테이너에서 직접 관리하는 객체를 Bean(빈) 이라고 한다.
그냥 new OrderService()로 직접 만든 객체는 빈이 아니다.
Spring 컨테이너에 등록되어서, Spring이 생성하고 주입하고 소멸까지 관리해주는 객체만 빈이라고 한다.
🍬 빈 스코프 (Bean Scope)
스코프 = "이 빈을 얼마나 넓은 범위에서 공유하냐"
자주 쓰는 스코프 3가지

🍬 싱글톤 : 컨테이너에 하나만 만들어두고 모두가 함께 사용한다. Spring의 기본값이다.
🍬 프로토타입 : 요청할 때마다 새로운 객체를 만들어준다. 각각 독립적인 상태가 필요할 때 사용한다.
🍬 리퀘스트 : 웹 요청이 들어올 때 생성되고, 요청이 끝나면 사라진다. 같은 요청 안에서는 하나를 공유한다.
빈 생명주기 (Lifecycle)
빈이 태어나서 죽기까지의 전체 흐름
Spring 컨테이너 시작
↓
1. 빈 생성 (객체 new)
↓
2. 의존관계 주입 (DI)
↓
3. 초기화 콜백 (@PostConstruct) "다 준비됐으면 이 메서드 실행해줘"
↓
4. 사용 (비즈니스 로직 실행)
↓
5. 소멸 전 콜백 (@PreDestroy) "죽기 전에 이 메서드 실행해줘"
↓
Spring 컨테이너 종료
🕶️ 코드 예시
@Component
public class DatabaseConnector {
@PostConstruct
public void init() {
// 빈이 완전히 준비된 직후 실행
// DB 연결 풀 초기화, 캐시 로딩 등
System.out.println("DB 연결 초기화!");
}
@PreDestroy
public void cleanup() {
// 빈이 소멸되기 직전 실행
// DB 연결 종료, 리소스 반납 등
System.out.println("DB 연결 종료!");
}
}
@PostConstruct는 의존성 주입이 모두 끝난 후 실행된다.
생성자에서는 아직 주입이 안 끝났을 수 있기 때문에, 초기화 로직은 여기서 처리하는 것이 좋다.
Part 4 : 왜 Spring Bean은 기본적으로 싱글톤으로 설계했을까?
이건 단순히 “그렇게 만들어진 것”이 아니라, 분명한 이유가 있다. 웹 서버의 특성을 먼저 이해해야 한다.
✔️ 웹 서버 환경

실제 서비스에서는 수천 명이 동시에 요청을 보낸다.
이때 요청마다 OrderService, UserService, PaymentService를 매번 new로 생성하면 메모리가 금방 부족해진다.
그래서 Spring은 기본적으로 싱글톤을 사용한다.
✔️ 첫째, 메모리 효율
OrderService 같은 서비스 객체는 상태가 없고 로직만 있기 때문에 하나만 만들어 여러 요청이 함께 사용해도 문제없다.
✔️ 둘째, 성능
객체를 생성하는 데는 메모리 할당과 초기화 비용이 든다. 요청마다 반복하면 낭비가 크지만,
싱글톤은 한 번만 만들어 재사용하므로 훨씬 효율적이다.
✔️ 셋째, Spring의 DI 구조와 잘 맞는다.
Spring은 객체를 한 번 생성해두고 필요한 곳에 주입하는 방식이기 때문에, 싱글톤 구조가 자연스럽게 어울린다.
🍬 싱글톤의 주의점은?
싱글톤은 상태(인스턴스 변수)를 가지면 위험하다.
// 위험한 코드!
@Service
public class OrderService {
private int orderCount = 0; // 인스턴스 변수 (상태)
public void order() {
orderCount++; // 여러 사용자가 동시에 이걸 바꾸면?
}
}
싱글톤 객체는 모든 사용자가 함께 쓰기 때문에, 사용자 A가 바꾼 값을 사용자 B도 보게 될 수 있다. 이게 동시성 문제다.
그래서 Spring 빈은 상태를 가지지 않는 방식으로 설계하는 것이 중요하다.
메서드 안에서만 사용하는 지역변수는 괜찮지만, 여러 요청이 함께 쓰는 인스턴스 변수는 문제가 될 수 있다.
// 올바른 코드
@Service
public class OrderService {
// 인스턴스 변수 없음. 상태 없음.
public void order(int count) {
int localCount = count; // 지역변수는 각 스레드마다 독립적이라 안전
// 비즈니스 로직...
}
}
Spring 빈이 기본적으로 싱글톤인 이유는 웹 환경에서 많은 요청을 처리할 때 메모리와 성능을 효율적으로 사용할 수 있고, 서비스 객체는 보통 상태가 없어서 여러 요청이 함께 사용해도 안전하기 때문이다.
Part 5 : 요청이 들어왔을 때, 디스패처 서블릿이 컨트롤러를 어떻게 찾아주는가
이걸 이해하려면 3단계로 나눠서 보면 된다.
✔️ 1단계 : 전체 흐름을 먼저 큰 그림으로 이해하기
✔️ 2단계 : 핵심 구성 요소가 무엇인지 파악하기
✔️ 3단계 : 각 단계를 하나씩 자세히 살펴보기
1단계 : 큰 그림

DispatcherServlet = 프론트 컨트롤러란
모든 클라이언트 요청을 가장 먼저 받는 입구 역할, 요청을 직접 처리하지 않고, 어디로 보낼지 “교통정리”만 함
흐름을 말로 푼다면
- 클라이언트 요청이 들어온다
- DispatcherServlet이 받는다 (입구)
- 어떤 컨트롤러가 처리할지 찾는다 (Handler Mapping)
- 실제 컨트롤러 실행 (Handler Adapter 통해서)
- 결과(ModelAndView) 받음
- View 찾아서 렌더링
- 최종 응답 반환
2단계 : 핵심 부품들
🍬 Spring MVC 핵심 구성요소 🍬

Spring MVC는 DispatcherServlet을 중심으로 "누가 처리할지 찾고 → 실행하고 → 결과를 화면으로 바꿔주는 구조"이다.
🍬 DispatcherServlet
- 모든 요청의 시작점 (입구)
- 클라이언트 요청 = 무조건 여기로 들어옴
- 전체 흐름을 컨트롤하는 "총괄 관리자"
🍬 HandlerMapping
- "이 요청 누가 처리함?" 담당
- 서버 시작 시 : @RequestMapping, @GetMapping 등 전부 스캔, URL ↔ Controller 매핑 테이블 생성
🍬 @RequestMapping
- 정보를 쭉 스캔해서 URL과 컨트롤러를 매핑해 저장
🍬 HandlerAdapter
- 실제로 컨트롤러를 실행시켜주는 어댑터
- 왜 필요할까? : 컨트롤러가 여러 종류일 수 있다. (@Controller, 옛날 방식 등) 어댑터가 중간에서 형식 맞춰서 실행
🍬 Controller
- 개발자가 작성한 비즈니스 로직 결과로 ModelAndView 또는 @ResponseBody (JSON) 를 반환
🍬 ViewResolver
- 컨트롤러가 "home"이라는 뷰 이름을 반환하면, 실제 파일 경로 (/templates/home.html)로 변환해주는 역할
한 줄 정리
DispatcherServlet → HandlerMapping → HandlerAdapter → Controller → ViewResolver
3단계: 단계별 초세세 흐름
① HandlerMapping 조회 — "이 URL 담당이 누구야?"
서버가 처음 뜰 때, Spring이 @Controller 달린 클래스들을 다 스캔해서 이런 표를 만든다.
GET /users → UserController.getUsers()
POST /orders → OrderController.createOrder()
GET /products/{id} → ProductController.getProduct()
요청이 들어오면 디스패처 서블릿이 이 표에서 URL을 찾아서 담당 컨트롤러 메서드를 꺼낸다.
이게 HandlerMapping의 역할
② HandlerAdapter — "컨트롤러를 표준 방식으로 실행"
컨트롤러를 찾았다고 바로 실행할 수 있는 건 아니다.
디스패처 서블릿이 컨트롤러를 직접 호출하면, 컨트롤러 방식이 바뀔 때마다 디스패처 서블릿 코드도 함께 수정해야 한다.
그래서 다양한 컨트롤러를 일관된 방식으로 실행하기 위해 중간에 HandlerAdapter를 두었다.
DispatcherServlet → HandlerAdapter.handle() → 실제 컨트롤러 메서드 호출
어댑터가 파라미터 바인딩(@PathVariable, @RequestParam, @RequestBody 파싱)도 여기서 다 처리해준다.
③ Controller 실행 → ModelAndView 반환
컨트롤러가 실행되고, 두 가지 방식으로 결과를 돌려준다.
// 방식 1 : 뷰 이름 반환 (서버사이드 렌더링)
@GetMapping("/home")
public String home(Model model) {
model.addAttribute("username", "home");
return "home"; // → ViewResolver가 home.html 찾아줌
}
// 방식 2 : JSON 반환 (REST API)
@GetMapping("/api/users")
@ResponseBody // 또는 클래스에 @RestController
public List<User> getUsers() {
return userList; // → 뷰 없이 바로 JSON으로 직렬화
}
@ResponseBody가 붙으면 ViewResolver를 아예 안 거치고 바로 HTTP 응답 바디에 JSON을 쓴다.
④ ViewResolver — "뷰 이름을 실제 파일로"
"home" → /templates/home.html
"error" → /templates/error.html
application.properties에 이렇게 설정해두면
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
"home" 이라는 이름만 받아도 알아서 /templates/home.html 파일을 찾아서 렌더링해준다.
전체 흐름
1. 브라우저가 GET /users 요청 전송
2. DispatcherServlet이 요청 수신
(모든 요청의 단일 진입점)
3. HandlerMapping에게 물어봄
"GET /users 담당 컨트롤러가 누구야?"
→ UserController.getUsers()반환
4. HandlerAdapter에게 실행 요청
"이 컨트롤러 메서드 실행해줘"
→ 파라미터 바인딩, 실행
5. UserController.getUsers()실행
→ "users" 뷰 이름 + User 데이터 반환
6. ViewResolver에게 뷰 이름 전달
"users"라는 이름의 실제 파일 찾아줘
→ /templates/users.html 반환
7. users.html에 데이터 채워서 HTML 완성
→ 클라이언트에게 응답
🕶️ 디스패처 서블릿은 직접 하지 않고,
✔️ "누가 할지"는 HandlerMapping한테 물어보고
✔️ "실행"은 HandlerAdapter한테 시킨다.
정리 끝⎝⍢⎠