이전 글에서 @Component를 붙이면 Spring이 그 클래스를 new해서 보관한다고 했다.
그 보관된 객체를 Bean이라고 부른다고도 했다.
이번 글에서는 Bean이 정확히 무엇인지, Container가 Bean을 어떻게 관리하는지,
그리고 Bean이 언제 태어나고 언제 사라지는지를 살펴본다.
아래 두 클래스가 모두 EmailSender를 필요로 한다고 해보자.
@Component
public class OrderService {
private final EmailSender emailSender;
public OrderService(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
@Component
public class NotificationService {
private final EmailSender emailSender;
public NotificationService(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
Spring은 EmailSender를 몇 개 만들까?
결론부터 말하면 딱 하나다.
EmailSender a; // OrderService에 주입된 것
EmailSender b; // NotificationService에 주입된 것
a == b // true. 같은 객체다.
이것을 싱글톤(Singleton) 이라고 한다.
앱 전체에서 해당 Bean의 인스턴스가 딱 하나만 존재하는 것
싱글톤이 안전하게 동작하려면 조건이 하나 있다.
Bean 내부에 상태(state)가 없어야 한다.
// 상태 없음 - 싱글톤 안전
@Component
public class EmailSender {
public void send(String message) {
System.out.println("이메일 전송: " + message);
// 실행 후 EmailSender 내부에 아무것도 저장되지 않는다
}
}
// 상태 있음 - 싱글톤 위험
@Component
public class ShoppingCart {
private List<Item> items = new ArrayList<>(); // 사용자마다 달라지는 상태
public void addItem(Item item) {
items.add(item); // 실행 후 내부가 바뀐다
}
}
ShoppingCart를 싱글톤으로 공유하면 어떻게 될까?
사용자 A의 장바구니에 사용자 B의 상품이 섞이는 최악의 상황이 발생한다.
싱글톤 안전 여부를 판단하는 기준은 간단하다.
클래스 안에 필드가 있나?
↓
없다 → 싱글톤 안전
↓
있다 → 실행 중에 그 필드가 바뀌나?
↓
안 바뀐다 (final, 주입받은 의존성) → 싱글톤 안전
↓
바뀐다 (사용자마다 다른 값) → 싱글톤 위험
OrderService와 NotificationService는 둘 다 EmailSender를 final로 주입받을 뿐,
내부 상태가 없다. 그래서 같은 EmailSender Bean을 공유해도 전혀 문제없다.
Spring의 대부분의 클래스(Service, Repository 등)는 상태가 없도록 설계된다.
그래서 싱글톤이 기본값인 것이다.
상태가 없는 클래스는 하나만 만들어도 충분하니까, 미리 만들어두고 여러 곳에서 재사용한다.
모든 Bean이 싱글톤이면 곤란한 경우도 있다.
Spring은 이런 상황을 위해 Bean Scope(범위) 를 제공한다.
// 기본값: 앱 전체에서 하나
@Component
public class EmailSender { ... }
// 요청할 때마다 새로 생성
@Component
@Scope("prototype")
public class ShoppingCart { ... }
| Scope | 의미 | 주로 사용하는 경우 |
|---|---|---|
singleton | 앱 전체에서 딱 하나 (기본값) | 상태 없는 Service, Repository |
prototype | 요청할 때마다 새 객체 | 상태 있는 객체 |
request | HTTP 요청마다 새 객체 | 웹 요청별 데이터 |
session | 사용자 세션마다 새 객체 | 로그인 사용자별 데이터 |
실무에서는 @Scope("prototype")을 쓰는 경우가 드물다.
ShoppingCart 같은 데이터는 보통 DB나 세션에 저장하고,
상태 없는 ShoppingCartService가 처리하는 방식을 사용한다.
Spring은 앱이 시작될 때 Bean을 전부 미리 만들어둔다.
@SpringBootApplication
public class Application {
public static void main(String[] args) {
System.out.println("앱 시작 전");
SpringApplication.run(Application.class, args); // 여기서 Bean 전부 생성
System.out.println("앱 시작 후");
}
}
콘솔 출력 순서:
앱 시작 전
EmailSender 생성됨! ← SpringApplication.run() 내부에서 발생
OrderService 생성됨!
앱 시작 후
만약 Bean을 처음 사용될 때 만든다면 두 가지 문제가 생긴다.
첫째, 성능 문제다.
첫 번째 요청이 들어오는 순간 Bean을 생성하고, 그 Bean의 의존성도 생성하고,
그 의존성의 의존성도 생성해야 한다. 사용자 입장에서는 첫 요청이 느리게 느껴진다.
둘째, 오류 발견이 늦어진다.
개발자가 실수로 @Component를 빠뜨렸다고 해보자.
// @Component 없음!
public class EmailSender { ... }
처음 사용될 때 만드는 방식이라면, 이 오류는 실제로 이메일을 보내려는 순간에야 발견된다.
결제는 완료됐는데 이메일 발송에서 오류가 터지는 최악의 상황이 생길 수 있다.
Spring이 앱 시작 시점에 Bean을 전부 만드는 이유가 여기 있다.
@Component가 빠진 클래스가 있으면 앱 시작 자체가 실패한다.
사용자가 이 오류를 만나기 전에 개발자가 먼저 발견하게 된다.
이것을 Fail Fast 전략이라고 한다.
오류는 최대한 일찍 발견하자. 사용자에게 가기 전에, 앱 시작 시점에.
Bean이 생성되거나 소멸될 때 특별히 처리해야 할 일이 있을 수 있다.
@Component
public class DatabaseConnection {
private Connection conn;
@PostConstruct // Bean 생성 직후 자동 호출
public void init() {
conn = DriverManager.getConnection("...");
System.out.println("DB 연결 완료!");
}
@PreDestroy // Bean 소멸 직전 자동 호출
public void cleanup() {
conn.close();
System.out.println("DB 연결 종료!");
}
}
이름 그대로다.
| 어노테이션 | 호출 시점 | 주로 하는 일 |
|---|---|---|
@PostConstruct | Bean 생성 직후 | 초기화 (DB 연결, 캐시 로딩 등) |
@PreDestroy | Bean 소멸 직전 | 정리 (연결 종료, 리소스 해제 등) |
전체 Bean 생명주기를 흐름으로 보면 이렇다.
앱 시작
↓
Bean 생성 (new)
↓
의존성 주입 (@Autowired)
↓
@PostConstruct 호출 ← 초기화 커스텀 작업
↓
앱 실행 중 (Bean 사용)
↓
@PreDestroy 호출 ← 정리 커스텀 작업
↓
Bean 소멸
↓
앱 종료
실제 Spring 코드를 보면 @Component 외에 다른 어노테이션도 보인다.
@Service
public class OrderService { ... }
@Repository
public class OrderRepository { ... }
@RestController
public class OrderController { ... }
이 어노테이션들의 실제 정의를 보면:
// Spring 소스코드 내부
@Component // @Service 안에 @Component가 들어있다
public @interface Service { }
@Service, @Repository, @Controller는 모두 내부에 @Component를 포함하고 있다.
Bean 등록 기능 면에서는 @Component와 완전히 동일하다.
// 아래 두 코드는 Bean 등록 기능이 같다
@Component
public class OrderService { ... }
@Service
public class OrderService { ... }
그렇다면 왜 나눠놨을까?
"이 클래스가 어떤 역할인지" 를 코드만 봐도 알 수 있게 하기 위해서다.
@Controller // 아, 웹 요청 받는 클래스구나
@Service // 아, 비즈니스 로직 담당이구나
@Repository // 아, DB 접근하는 클래스구나
@Component // 그 외 나머지
개발자끼리의 약속이자 의사소통 수단이다.
@Repository는 하나의 추가 기능이 있다.
DB 관련 예외를 Spring 표준 예외로 자동 변환해준다.
MySQL 예외 → DataAccessException
Oracle 예외 → DataAccessException
MongoDB 예외 → DataAccessException
DB를 MySQL에서 Oracle로 바꾸더라도 예외 처리 코드를 수정하지 않아도 된다.
| 개념 | 한 줄 정의 |
|---|---|
| Bean | Spring Container가 생성하고 관리하는 객체 |
| 싱글톤 | 앱 전체에서 Bean 인스턴스가 딱 하나 (기본값) |
| Bean Scope | Bean의 생존 범위 (singleton, prototype, request, session) |
| Fail Fast | 오류를 앱 시작 시점에 미리 발견하는 전략 |
| @PostConstruct | Bean 생성 직후 초기화 작업 |
| @PreDestroy | Bean 소멸 직전 정리 작업 |
| @Service | @Component + "비즈니스 로직 담당" 의미 부여 |
| @Repository | @Component + "DB 접근 담당" + 예외 자동 변환 |
| @Controller | @Component + "웹 요청 처리 담당" 의미 부여 |
Bean은 단순히 "Spring이 관리하는 객체"가 아니다.
싱글톤으로 관리되고, 앱 시작 시점에 생성되며, 생명주기 전반에 걸쳐 Spring이 책임진다.
이 구조 덕분에 개발자는 객체 생성과 관리에 신경 쓰지 않고 비즈니스 로직에만 집중할 수 있다.
@Service, @Repository, @Controller로 역할을 명확하게 표시