FireDrago
스프링 동적 객체 생성: ObjectProvider의 한계와 팩토리 클래스 본문
스프링환경에서 동적 객체 생성이 필요할때
스프링 프레임워크는 IoC 컨테이너가 객체의 생명주기와 의존성을 대신 관리해 준다.
빈을 통해 객체의 의존성을 프레임워크가 관리하고,
개발자는 비지니스 로직에 집중할 수 있게 된다.
하지만 모든 객체를 싱글턴 빈으로 미리 만들어둘 수는 없다.
프로그램 실행 중에 결정되는 상태값을 필수로 가져야 하는 객체는 실행시점에 빈으로 미리 등록할 수 없다.
아래의 웹소켓 연결 클래스 코드를 보자
public class ChzzkWebsocketClient extends WebSocketClient {
// 사용자가 보고싶은 채널입력에 따라 달라지는 '채널id'
private final String chatChannelId;
// 매번 연결때마다 달라지는 '엑세스 토큰'
private final String accessToken;
// 빈으로 관리되는 상태값 없는 전형적인 '자바 빈'
private final ChzzkMessageHandler messageHandler;
private ScheduledExecutorService pingScheduler;
public ChzzkWebsocketClient(
ChzzkMessageHandler messageHandler,
String chatChannelId,
String accessToken
) throws URISyntaxException {
super(new URI("wss://kr-ss1.chat.naver.com/chat"));
this.messageHandler = messageHandler; // 빈 주입 필요
this.chatChannelId = chatChannelId; // 동적 값
this.accessToken = accessToken; // 동적 값
}
...
}
`ChzzkWebsocketClient`는 `messageHandler`라는 스프링 빈을 의존성으로 가지면서,
동시에 실행 시점에 결정되는 `chatChannelId`와 `accessToken`이라는 고유한 상태값을 동시에 가진다.
이 객체는 스프링의 의존성 주입(DI)을 받아야 하지만,
스프링 컨테이너가 시작되는 시점에 싱글턴으로 생성할 수는 없다.
이런 상황에서 우리는 어떤 선택을 할 수 있을까?
스프링이 제공하는 ObjectProvider를 사용하려고 시도했다가,
팩토리 클래스를 통해 해결했다.
이번 포스팅에서는 스프링에서 빈과 상태값을 동시에 가진 객체를 생성하는 방법을 알아보자
new 호출
웹소켓 연결은 런타임 상태값에 의존하므로,
상태값이 구해졌을때 그냥 new 호출해서 생성하면 되는거 아닌가?
new 를 명시적으로 호출하니까 상태값이 있는 객체라는 의미도 잘 전달되고 큰 문제 없어 보인다.
@Component
@RequiredArgsConstructor
public class ChzzkChatConnectService implements CommandLineRunner {
private final ChannelIdReader channelIdReader;
private final ChzzkApiClient chzzkApiClient;
private final ChzzkMessageHandler chzzkMessageHandler;
@Override
public void run(String... args) throws Exception {
while (!Thread.currentThread().isInterrupted()) {
try {
String channelId = channelIdReader.readChannelId();
String chatChannelId = chzzkApiClient.getChatChannelId(channelId);
String accessToken = chzzkApiClient.getAccessToken(chatChannelId);
// 상태값 구해지면 명시적으로 new 호출해서 웹소켓 연결 생성
ChzzkWebsocketClient socketClient = new ChzzkWebsocketClient(chzzkMessageHandler, channelId, accessToken);
socketClient.connectBlocking();
break;
} ...
}
}
}
테스트 불가능
결론부터 보자면, new로 직접 객체를 생성하는 방식은 좋은 방식이 아니다.
서비스 클래스를 단위테스트 할때, Mock 객체를 주입할 수 없다.
이 코드를 테스트하려면 실제 네이버 서버와 웹소켓 연결을 맺어야 하며,
외부 환경에 의존적인 불안정한 테스트를 만들게 된다.
의존성 누수
서비스 클래스는 웹소켓 객체를 생성하기 위해 직접 제어하지 않는
`ChzzkMessageHandler`를 주입받아야 한다.
서비스 클래스의 책임은 흐름을 제어하는 것인데,
웹소켓 생성 책임을 추가로 가지게 되면서 불필요한 의존성이 늘어난다.
ObjectProvider + prototype 스코프
`ObjectProvider` 는 빈 주입 시점을 스프링 시작시가 아니라 호출시점으로 지연 시킬 수 있다.
이를 `prototype` 스코프와 함께 사용하면 상태값을 지닌 객체를 필요할때마다 생성 할 수 있을거라고 생각했다.
이 두 개념에대한 자세한 설명은 '스프링 빈 스코프'를 참고하자
@Slf4j
@Component // 빈으로 관리
@Scope("prototype") // 스코프는 프로토타입 -> 요청시 마다 다른 객체 생성
public class ChzzkWebsocketClient extends WebSocketClient {}
먼저 `ChzzkWebsocketClient`를 프로토타입 빈으로 등록해준다.
이제 ChzzkWebsocketClient 객체는 Ioc 컨테이너가 관리하되,
요청될때마다 새로운 객체가 생성된다.
@Component
@RequiredArgsConstructor
public class ChzzkChatConnectService implements CommandLineRunner {
private final ChannelIdReader channelIdReader;
private final ChzzkApiClient chzzkApiClient;
// ObjectProvider를 주입받는다 (지연로딩)
private final ObjectProvider<ChzzkWebsocketClient> objectProvider;
@Override
public void run(String... args) throws Exception {
while (!Thread.currentThread().isInterrupted()) {
try {
String channelId = channelIdReader.readChannelId();
String chatChannelId = chzzkApiClient.getChatChannelId(channelId);
String accessToken = chzzkApiClient.getAccessToken(chatChannelId);
// getObject()메서드를 통해 상태값을 파라미터로 주입
ChzzkWebsocketClient socketClient = objectProvider.getObject(channelId, accessToken);
socketClient.connectBlocking();
break;
} catch (ChzzkPipelineException e) {
...
}
}
}
}
이제 `ObjectProvider`를 대신 주입받고,
생성이 필요할때마다 getObject()메서드로 동적 파라미터를 넘긴다.
헤치웠나?
ObjectProvider의 한계 : 생성자 주입 사용 불가

문제는 ObjectProvider.getObject(args)의 동작 방식에 있다.
getObject에 파라미터를 넘기는 순간,
스프링은 생성자 자동 주입(Autowiring) 메커니즘을 중단한다.
그리고 우리가 넘긴 파라미터 타입과 정확히 일치하는 생성자만을 찾는다.
결국 이 방식을 억지로 성공시키려면 생성자 주입을 포기해야 한다.
빈(MessageHandler)은 @Autowired를 통한 필드 주입 혹은 메서드 주입으로 돌리고,
생성자에는 상태값만 남겨야 한다.
하지만 이는 객체의 불변성(final)을 포기하게 만들고,
테스트 코드 작성 시 의존성을 주입하기 까다롭게 만드는 안티 패턴으로 이어진다.
팩토리 클래스 구현
결국 팩토리 패턴을 선택하여 동적 객체를 생성하도록 했다.
스프링의 빈 관리 전략을 사용하지 않고,
객체 조립을 전담하는 새로운 빈을 직접 만들었다.
@Component
@RequiredArgsConstructor
public class ChzzkWebsocketClientFactory {
private final ChzzkMessageHandler chzzkMessageHandler;
public ChzzkWebsocketClient create(String chatChannelId, String accessToken) throws URISyntaxException {
return new ChzzkWebsocketClient(chzzkMessageHandler, chatChannelId, accessToken);
}
}
`ChzzkWebsocketClientFactory` 클래스는 생성하려는 객체에 필요한
`ChzzkMessageHandler`의존성을 먼저 주입 받는다.
create(channerId, token) 메서드를 통해 동적 파라미터를 받는다.
팩토리 내부에서 new를 호출하여 완전한 객체를 조립해 반환한다.
@Service
@RequiredArgsConstructor
public class ChzzkChatConnectService {
private final ChannelIdReader channelIdReader;
private final ChzzkApiClient chzzkApiClient;
// ObjectProvider 대신 Factory를 주입받는다.
private final ChzzkClientFactory clientFactory;
public void run() {
// ... (생략) ...
String channelId = ...;
String accessToken = ...;
// Factory에게 데이터만 넘겨주면 된다.
// 서비스는 MessageHandler의 존재를 몰라도 된다! (의존성 누수 해결)
ChzzkWebsocketClient client = clientFactory.create(channelId, accessToken);
client.connect();
}
}
무엇이 좋아졌을까?
1. 생성자 주입과 불변성 유지 : `ObjectProvider`를 썼을 때처럼 생성자 주입을 타협할 이유가 없다.
`ChzzkWebsocketClient`의 모든 필드를 final 로 선언할 수 있으며, 객체가 생성되는 시점에
모든 상태가 완벽하게 초기화됨을 보장한다.
2. 의존성 누수 방지 : 서비스 클래스는 더이 상 웹소켓 클라이언트 내부에 어떤 빈이 필요한지
알 필요가 없다. 서비스는 오직 비지니스 데이터만 팩토리에 넘기면 된다. 책임분리가 명확해진다.
3. 테스트 용이성 : 테스트 코드 작성이 매우 쉬워진다. 서비스 클래스를 테스트 할 때,
`ChzzkClientFactory`만 Mocking 하면 된다.
스프링이 만능은 아니다.
스프링 프레임워크는 강력한 편의 기능들을 제공하지만,
때로는 그 기능이 객체지향의 기본 원칙과 충돌할 때가 있다.
`ObjectProvider`는 빈 조회 시점을 지연시키는 유용한 도구이지만,
생성자 주입과 동적 파라미터가 혼재된 상황에서는 불변성을 해치는 원인이 되기도 한다.
이런 상황에서는 직접 팩토리 클래스를 구현하여 객체지향적으로 동적 객체를 생성해보자
'프로그래밍 > Spring' 카테고리의 다른 글
| [Spring] JDBC Template 사용하기 (0) | 2025.04.21 |
|---|---|
| [Spring] @ModelAttribute 바인딩 null 문제 (0) | 2024.05.26 |
| [Spring] 스프링 AOP - 실무 주의사항 (0) | 2024.05.22 |
| [Spring] 스프링 AOP - 포인트컷 2 (0) | 2024.05.20 |
| [Spring] 스프링 AOP - 포인트컷 1 (0) | 2024.05.20 |
