객체 생성의 역전 - 팩토리 패턴

토비의 스프링에 나오고 있는 내용에서 나오는 패턴 중 팩토리 패턴이라는 개념은 객체의 생성의 책임을 팩토리라는 클래스에 위임함으로 전략 패턴과 템플릿 메소드 패턴 등으로 분산되는 객체를 한 곳에서 관리할 수 있게 해줍니다. 왜 이전에 역활과 책임에 따라 분리해두었던 객체를 다시 한 곳에 모아두는 이유가 무엇일까요?
팩토리 패턴의 예시
이전에 설명했던 알림 모듈을 예시로 들자면 유저에게 알림을 발송할 때 알림톡으로 알림을 보낼 때와 앱 푸시로 알림을 보낼 때 그리고 이메일로 알림을 보낼 때에는 주입받아야 하는 객체가 전부 다릅니다. 아래의 코드를 보면 쉽게 이해할 수 있을 것입니다.
// 전략 인터페이스
public interface NotificationSender {
void send(String target, String message);
}
// 구현체들
public class KakaoSender implements NotificationSender {
@Override
public void send(String target, String message) {
new KakaoClient("api-key-123").sendAlimtalk(target, message);
}
}
public class PushSender implements NotificationSender {
@Override
public void send(String target, String message) {
new FirebaseClient("fcm-key-456").sendPush(target, message);
}
}
public class EmailSender implements NotificationSender {
@Override
public void send(String target, String message) {
new SmtpClient("smtp.example.com").sendMail(target, "알림", message);
}
}
// 팩토리 없이 직접 생성하는 경우
public class NotificationController {
public void notify(String type, String userId, String message) {
NotificationSender sender;
UserFinder userFinder;
// 매번 생성 로직이 흩어짐
if (type.equals("KAKAO")) {
sender = new KakaoSender();
userFinder = new PhoneUserFinder(userRepository);
} else if (type.equals("PUSH")) {
sender = new PushSender();
userFinder = new TokenUserFinder(deviceRepository);
} else {
sender = new EmailSender();
userFinder = new EmailUserFinder(emailRepository);
}
NotificationService service = new NotificationService(userFinder, sender);
service.send(userId, message);
}
}
그리고 다른 모듈이 추가 될 때 마다 호출하는 쪽에서 if문을 통해 다른 처리를 해야하고 이런 구조는 해당 알림 모듈을 사용하는 곳 모두에게 영향을 줄 수 있는 결합도를 가지게 됩니다. 그래서 이런 문제를 해결하기 위해 팩토리 클래스를 만들고 객체의 생성에 대한 책임을 팩토리에게 위임함으로써 알림 모듈의 변경 가능성은 팩토리안에 갇히게 됩니다.
// 팩토리로 생성 책임 위임
public class NotificationFactory {
public NotificationService create(String type) {
if (type.equals("KAKAO")) {
return new NotificationService(
new PhoneUserFinder(userRepository),
new KakaoSender()
);
} else if (type.equals("PUSH")) {
return new NotificationService(
new TokenUserFinder(deviceRepository),
new PushSender()
);
} else {
return new NotificationService(
new EmailUserFinder(emailRepository),
new EmailSender()
);
}
}
}
// 사용하는 쪽은 깔끔해짐
public class NotificationController {
private final NotificationFactory factory;
public void notify(String type, String userId, String message) {
NotificationService service = factory.create(type);
service.send(userId, message);
}
}
카카오톡의 알림 모듈이 변경되도 앱푸시의 알림 모듈이 변경되더라도 하나에 모듈 안에서 변경되기 때문에 높은 응집도를 보이게 되고 이는 알림모듈을 사용하는 쪽의 코드를 단순하게 유지할 수 있게 만들어줍니다.
Inversion of Control, 제어관계의 역전
이전에는 저희가 직접 제어관계를 제어하고 의존성을 주입하였습니다. 하지만 이제는 그 책임이 팩토리 클래스에 위임하게 되면서 저희는 의존성 주입의 역활을 팩토리 클래스에 전가하게 되었습니다. 이처럼 제어의 역전은 메인 로직에 좀 더 집중할 수 있게 해주는 장점으로 인해 스프링의 핵심 요소로 자리잡게 되었습니다.
스프링을 사용하면서 많이 들어본 Ioc 컨테이너(빈 팩토리)가 이런 역활을 합니다. 보통 빈 팩토리보다는 어플리케이션 팩토리를 많이 활용하지만 두개 다 스프링을 기반으로 어플리케이션을 개발하는데 필요한 핵심 개념이니 차차 설명드리도록 하겠습니다.
BeanFactory & Application Context
- BeanFaactory: 가장 기본적인 컨테이너로써 의존성 주입(Dependency Injection)과 빈의 생명주기를 관리하는데 사용하고 실제 빈이 사용될 때 까지 Bean의 주입을 지연시키는 특징을 가지고 있습니다.
- Application Context: Bean Context 보다 넓은 범위를 괸리하며 빈 팩토리보다 다소 무겁지만 강력한 기능들을 제공합니다. 빈을 즉시로딩하고 각기 여러 언어로 처리할 수 있는 국제화 기능과 빈들 간 이벤트 통신 그리고 빈 팩토리보다 편리한 AOP 기능을 제공합니다.
| 특징 | BeanFactory | ApplicationContext |
| 기본 빈 로딩 시점 | 지연 로딩 (Lazy) | 즉시 로딩 (Eager) |
| 부가 기능 | 거의 없음 | 국제화, 이벤트, AOP 등 다양함 |
| 사용 환경 | 자원이 극히 제한된 환경 | 대부분의 Spring 애플리케이션 |
| 복잡성 | 단순함 | 복잡하고 강력함 |
Bean Factory가 더 가볍고 빠르지만 Application Context를 더 많이 사용하는 이유는 현대에 들어와서 빈 생성에 리소스가 크지않고 빈을 미리 생성하여 런타임에 발생할 수 있는 오류를 컴파일 시점에 잡을 수 있는 방향이 백엔드 어플리케이션을 개발하고 운영하는 관점에서 이점이 훨씬 크기 때문입니다.
Application Context 동작 원리
// Spring Security 설정 - 빈을 미리 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider());
}
@Bean
public JwtTokenProvider jwtTokenProvider() {
return new JwtTokenProvider(secretKey, expireTime);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
어플리케이션 컨텍스트를 활용하는 방법은 위의 코드처럼 Configuration 어노테이션을 활용하여 어플리케이션 컨텍스트에서 사용될 설정 정보라는 것을 명시한 뒤 주입해야되는 빈에 대해 위와같이 정의하면됩니다. 이렇게 간단하게 설정하면 어플리케이션 컨텍스트에서 해당 빈을 인식하여 빈 목록에 추가를 합니다.

위의 그림처럼 컴파일 시점에 어플리케이션 컨텍스트가 Config파일을 읽어 빈을 등록하고 생성합니다. 클라이언트에서 빈을 호출하는 시점에 getBean()을 통해 빈 목록에서 관련된 빈을 응답하는 형태로 빈이 사용되는 것입니다.
스프링 Ioc에서 필수적으로 알아야하는 용어
Bean
스프링에서 가장 기본적으로 사용되는 오브젝트 단위입니다. 이전에 자바의 로고를 보면 커피 모양의 로고를 사용하는 것을 알 수 있는데 커피를 만드는데 사용되는 건 커피 콩이기 때문에 "커피(자바 프로그램)을 만드는데 가장 기본적으로 사용되는 것인 빈이라고 하자"라고 생각하면서 빈이라고 불리게 되었습니다.
Bean Factory
빈을 관리하는 핵심 컨테이너 입니다. 빈을 등록하고 생성하고 돌려주는 등이 역활을 담당하며 보통은 빈 팩토리를 확장한 팩토리를 활용하여 Ioc 컨테이너를 제공합니다.
Application Context
빈 팩토리의 기능을 제공하지만 좀 더 확장된 개념으로 제공합니다 예시로 어플리케이션 개발에 필요한 데이터소스, 설정, 국제화, 빈간 통신 등의 기능들을 제공하여 좀 더 쉽게 어플리케이션을 개발할 수 있도록 도와줍니다.
설정 정보/설정 메타 정보
스프링에서는 Configuration이라고 하는데 실제 설정된 빈들의 정보를 의미합니다. 예시로 Security나 Jpa를 설정할 때 커스텀한 모듈을 주입하거나 데이터 소스를 주입한 빈을 적용하는데 이런 정보들을 설정 메타 정보라고 합니다.
Ioc 컨테이너
IoC 방식으로 빈을 관리한다는 의미에서 애플리케이션 컨텍스트나 빈 팩토리를 컨테이너 또는 IoC 컨테이너라고 합니다. 쉽게 말해 빈의 의존성 주입에 대한 제어를 대신 해주는 컨테이너라고 보시면 됩니다.
빈 그리고 싱글톤
스프링의 빈은 싱글톤으로 되어있습니다. 싱글톤 패턴의 의미는 하나의 어플리케이션 안에서는 하나의 인스턴시만 존재하도록 하는 패턴입니다. 싱글톤으로 만약 하지 않았을 때에는 아래와 같은 문제점이 발생합니다.
// 싱글톤이 아니라면?
@Service
public class OrderService {
private final NotificationService notificationService;
}
// 요청마다 새 인스턴스 생성
// 1000명 동시 접속 = 1000개의 OrderService + 1000개의 NotificationService
// → 메모리 낭비, GC 부담 증가
그리고 빈들은 상태를 가지고 있지 않기 때문에 자원을 공유하지 않아 싱글톤 패턴으로 빈을 사용할 수 있는 것입니다. 이로인해 성능적인 이점을 크게 가져갈 수 있지만 몇가지의 단점 또한 존재합니다.
- private 생성자를 갖고 있기 때문에 상속할 수 없다.
public class DatabaseConnection {
private static DatabaseConnection instance;
private DatabaseConnection() { } // 상속 불가
public static DatabaseConnection getInstance() {
if (instance == null) {
instance = new DatabaseConnection();
}
return instance;
}
}
// ❌ 컴파일 에러
public class MySqlConnection extends DatabaseConnection {
// Cannot inherit from final 'DatabaseConnection'
}
- 싱글톤은 테스트하기가 힘들다
public class OrderService {
public void save(Order order) {
// getInstance()로 직접 가져옴 - Mock 교체 불가
DatabaseConnection conn = DatabaseConnection.getInstance();
conn.save(order);
}
}
// 테스트 시 실제 DB 연결이 필요
@Test
void saveTest() {
OrderService service = new OrderService();
service.save(new Order()); // 실제 DB에 저장됨 - 단위 테스트 불가
}
- 서버 환경에서는 싱글톤이 하나만 만들어지는 것을 보장하지 못한다
// 클래스 로더가 여러 개인 환경 (예: 톰캣의 여러 WAR 배포)
// 각 클래스 로더마다 별도의 싱글톤 인스턴스 생성
// ClassLoader A → DatabaseConnection 인스턴스 1
// ClassLoader B → DatabaseConnection 인스턴스 2
// JVM 내에서 여러 개의 "싱글톤"이 존재하게 됨
- 싱글톤의 사용은 전역 상태를 만들기 때문에 바람직하지 못하다
public class OrderService {
public void process(Order order) {
// 메서드 시그니처만 봐서는 의존성을 알 수 없음
DatabaseConnection.getInstance().save(order);
EmailSender.getInstance().send(order.getUserEmail());
Logger.getInstance().log("Order processed");
}
}
// 어떤 의존성이 있는지 코드를 열어봐야 알 수 있음
// vs 생성자 주입
public class OrderService {
public OrderService(DatabaseConnection conn,
EmailSender sender,
Logger logger) { // 의존성이 명확히 드러남
...
}
}
싱글톤 레지스트리
이와같이 자바의 싱글톤 방식은 문제가 있어, 스프링은 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 기능을 제공하는데 이게 바로 싱글톤 레지스트리(singleton registry)입니다. 싱글톤 레지스트리의 장점은 자바의 static이나 private 같은 클래스가 아니라 평범한 클래스도 싱글톤으로 활용하게 해줍니다.
싱글톤 레지스트리에서 관리되는 빈은 여러 스레드에서 동시에 접근하기 때문에 상태를 유지(Stateful)하게 설계하면 절대 안 됩니다. 이런 문제를 방지하기 위해 보통 final을 활용하여 Stateless하고 불변한 객체를 주입합니다.
현대식 스프링 DI: 설계의 명확성과 안전성
현대적인 스프링 DI는 프레임워크의 기능을 넘어, 자바 언어의 특성을 활용해 런타임 안정성을 확보하는 방향으로 변화하였습니다.
1. "간결한 DI": 어노테이션의 최소화
과거에는 빈 주입을 위해 과거에는 XML 설정이라는 '마법'에 의존하거나 @Autowired를 남발했다면, 현대에는 스프링의 암시적 주입 규칙을 활용합니다.
- 최소 어노테이션: 생성자가 하나만 존재할 경우 @Autowired를 생략할 수 있는 스프링의 기능을 활용합니다.
- 보일러플레이트 제거: Lombok의 @RequiredArgsConstructor는 선택 사항이지만, 반복적인 생성자 코드를 줄여주는 도구로 널리 쓰입니다. 중요한 것은 Lombok 없이도 순수 자바 생성자만으로 완벽한 DI가 가능하다는 점입니다.
2. 컴파일 타임에 검증되는 의존 관계
현대적 DI의 가장 큰 핵심은 개발 과정에서 오류의 발견 해야된다라는 철학을 기반으로 만들어졌다는 것 입니다.
- 불변성(Immutability): private final 필드를 통해 한 번 주입된 의존성이 애플리케이션 생명주기 동안 변하지 않음을 보장합니다.
- 누락 방지: 생성자 주입을 사용하면 의존성 주입 없이 객체를 생성하는 것이 불가능하므로, 실수로 빈을 누락했을 때 컴파일 단계나 애플리케이션 구동 시점에 바로 알 수 있습니다.
3. 순환 참조(Circular Dependency)의 조기 발견
주입 방식에 따라 순환 참조를 발견하는 시점이 달라지며 이는 시스템 안정성에 직결됩니다.
| 주입 방식 | 발견 시점 | 특징 |
| 필드 주입 / 수정자 주입 | 런타임 (Runtime) | 실제 메서드가 호출되는 시점에 에러가 발생하여 서비스 장애로 이어질 수 있음 |
| 생성자 주입 | 애플리케이션 시작 시 (Startup) | 컨텍스트 로딩 단계에서 BeanCurrentlyInCreationException을 발생시켜 배포 전 차단 |
왜 생성자 주입이 유리한가요?
객체를 생성하려면 먼저 의존하는 객체가 생성되어 있어야 하는데, 서로를 참조하면 객체 생성 자체가 불가능해집니다. 이를 통해 설계상의 결함(과도한 결합도)을 서비스 운영 전에 강제로 교정하게 만듭니다.
4. 진화의 흐름: Spring 3.x에서 현재까지
역사적으로 보면 Spring 3.x 후반부터 이미 생성자 주입의 이점이 강조되기 시작했습니다.
- 과거의 관행: 편의성을 위해 필드 주입을 여러 방식으로 무분별하게 사용하던 시기.
- 현대의 표준: 객체 지향 원칙(SOLID)을 지키기 위해 생성자 주입과 불변성을 기본으로 채택하는 시기.
최종 요약
| 구분 | 과거의 DI (Spring 2.x ~ 3.x) | 현대의 DI (Spring Boot 2.x ~ 3.x) |
| 설정 방식 | XML 또는 @Autowired 필드 주입 | 생성자 주입 (Constructor Injection) |
| 객체 상태 | 가변적(Mutable)일 위험이 큼 | 불변성(Immutable) - final 키워드 |
| Lombok 활용 | 거의 없음 | @RequiredArgsConstructor 필수 활용 |
| 테스트 | SpringRunner 등 컨테이너 로드 필수 | 순수 자바(JUnit/Mockito)로 빠른 단위 테스트 |
| 철학 | "스프링이 다 해줄 거야" (마법) | "객체 지향적으로 잘 짜면 스프링이 도와줄 거야" |
'spring' 카테고리의 다른 글
| 토비의 스프링 정복하기 3장 - 테스트의 필요성 (0) | 2026.01.14 |
|---|---|
| 토비의 스프링 정복하기 1편 - 관심사의 분리 (0) | 2026.01.07 |