📕 목차
1. 개요
2. 실패: Mock 객체 생성
3. 해결: 인터페이스 분리
1. 개요
📌 아키텍처
푸시 알림 기능을 구현하기 위해, FcmConfig에서 파이어 베이스 인증을 시도해주고 있었다.
여기서 문제는 통합 테스트 환경에서 FcmConfig를 생성하는 과정에서 실제 인증을 위한 json을 필요로 한다는 점이었다.
public class FcmConfig implements PennywayInfraConfig {
private final ClassPathResource firebaseResource;
private final String projectId;
public FcmConfig(@Value("${app.firebase.config.file}") String firebaseConfigPath,
@Value("${app.firebase.project.id}") String projectId) {
this.firebaseResource = new ClassPathResource(firebaseConfigPath);
this.projectId = projectId;
}
@PostConstruct // 생성자 호출 후
public void init() throws IOException {
FirebaseOptions option = FirebaseOptions.builder() // 파이어베이스 인증을 시도
.setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream()))
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(option);
log.info("FirebaseApp is initialized");
}
}
@Bean
FirebaseMessaging firebaseMessaging() {
return FirebaseMessaging.getInstance(FirebaseApp.getInstance());
}
}
당연히 테스트를 위해 푸시 알림을 보낼 생각이 없었고, Fcm 연결은 Mock으로 처리해둘 생각이었기에
인증을 위한 json 경로는 아무 값이나 삽입해두었었다.
app:
firebase:
config:
file: ${FIREBASE_CONFIG_FILE:firebase-adminsdk.json}
project:
id: ${FIREBASE_PROJECT_ID:pennyway-12345}
아니나 다를까, firebase-adminsdk.json 경로의 파일을 읽을 수 없어서 모든 통합 테스트가 실패하는 결과를 맞이했다.
2. 실패: Mock 객체 생성
📌 가장 단순한 해결책?
@SpringBootTest
public class FooTest {
@MockBean
private FcmConfig fcmConfig;
...
}
FcmConfig를 MockBean으로 등록하면 해결될 것 같겠지만, 앞으로 작성하는 모든 통합 테스트에 FcmConfig Mock을 생성해주어야 한다.
당연히 말이 안 되는 해결책이므로 기각.
📌 test 모듈에서 TestFcmConfig를 생성한다면?
FcmConfig가 문제가 된다면, 테스트 목적의 Config 파일을 만들어주면 되지 않을까?
예를 들어, 기존의 FcmConfig에는 @Profile을 사용하여 test 환경에서 빈이 등록되는 것을 제외한다.
@Slf4j
@Profile({"local", "dev", "prod"})
public class FcmConfig implements PennywayInfraConfig {
...
}
그리고 external-api 모듈의 테스트 패키지에서 다음과 같은 가짜 FcmConfig를 만들어주는 것이다.
@TestConfiguration
public class TestFcmConfig {
@Bean
FirebaseMessaging firebaseMessaging() {
return FirebaseMessaging.getInstance(FirebaseApp.getInstance());
}
}
그리고 모든 테스트에서 @Import(TestFcmConfig.class)를 선언해주면 문제가 해결될 수도 있을 것이다.
별도의 어노테이션을 만들어주는 방법도 있다.
하지만 이 또한 2가지 이유로 인해 사용할 수 없었다.
- 인증을 하지 않고 FirebaseMessaging 빈을 생성할 방법을 찾질 못 했다. (진짜 못 찾겠어서 코드까지 훑어봤는데 모르겠다..)
- 멀티 모듈 환경에서 FcmConfig는 infra 모듈 내에 위치하고 있으며, firebase 라이브러리는 하위 모듈로 의존성을 전파하지 않고 있다. 따라서 external-api 모듈에선 FirebaseMessaging의 존재 자체를 알 수 없다.
📌 FcmConfig를 아예 만들지 않으면 되는 거 아닐까?
@Slf4j
@Profile({"local", "dev", "prod"})
public class FcmConfig implements PennywayInfraConfig {
...
}
이전과 똑같이 FcmConfig를 test 환경에서 스캔되지 않도록 처리하면 어떻게 될까?
@Slf4j
@Component
@RequiredArgsConstructor
public class FcmManager {
private final FirebaseMessaging firebaseMessaging;
...
}
애석하게도 FcmManager에서 FirebaseMessaging 빈 주입에 실패해서 에러가 발생한다.
물론 여기서 모든 테스트에서 FcmManager를 @MockBean으로 만들면 어떨까에 대해 생각해볼 수 있겠지만, 첫 번째 문제로 회귀한다.
하지만 여기서 중요한 아이디어를 얻었는데, infra 모듈의 빈을 굳이 통합 테스트 환경에서 알 필요가 있을까?
3. 해결: 인터페이스 분리
📌 아키텍처 다시 살펴보기
현재 사용 중인 아키텍처는 아니지만, 푸시 알림 플로우를 처리하기 위해 구상했던 설계 중 하나다.
여기선 SQS로 메시지를 전달하지만 외부 Actor가 누구인지는 중요하지 않다.
통합 테스트로 얻고자 하는 것이 무엇인가? 어디까지 테스트를 해야하는가?
테스트를 할 때마다 실제 외부 Actor를 호출한다면, 특히 호출하기 위한 비용이 발생한다면 더더욱 가짜 빈이 등록되어야 한다.
그리고 애초에 테스트의 타겟은 현재 컴포넌트가 되어야지, 외부 컴포넌트에 종속되어선 안 된다.
물론 별도로 테스트를 해보긴 해야 하겠지만 지금은 아니다.
그 말은 즉, presentation 계층을 테스트함에 있어 Infra 모듈의 빈을 실제 빈을 사용할 일은 없다고 봐도 무방하다.
📌 Handler 인터페이스
FcmManager의 인터페이스를 정의해도 무방하겠지만, 굳이 Handler의 인터페이스를 정의한 이유는 이렇다.
지금은 Firebase로 메시지를 전달하지만, 컴포넌트 간의 의존도를 떨어트리고 Microservice 지향적으로 아키텍처를 개선하고자 한다면 추후 AWS SQS를 사용하게 될 수도 있을 것이다.
아키텍처 관점을 제외하고 봐도 메시지 전달 실패의 경우를 고려해서라도 나쁘지 않은 전략이다.
그렇다면 다형성이 필요한 것은 FcmManager보다는 NotificationEventHandler가 되어야 한다고 판단했다.
/**
* 푸시 알림을 처리하는 핸들러 인터페이스
* <p>
* 푸시 알림을 포함한 기능을 테스트할 때는 해당 인터페이스를 구현한 Mock 객체를 사용한다.
*
* @author YANG JAESEO
* @since 2024-07-09
*/
public interface NotificationEventHandler {
void handleEvent(NotificationEvent event);
}
대충 이렇게 생긴 인터페이스를 작성해주고, 구현체를 작성해준다.
/**
* FCM 푸시 알림을 처리하는 핸들러
*/
@Slf4j
@RequiredArgsConstructor
public class FcmEventHandlerImpl implements NotificationEventHandler {
private final FcmManager fcmManager;
@Async
@Override
@TransactionalEventListener
public void handleEvent(NotificationEvent event) {
log.debug("handleEvent: {}", event);
ApiFuture<?> response = fcmManager.sendMessage(event);
if (response == null) {
return;
}
response.addListener(() -> {
try {
log.info("Successfully sent message: " + response.get());
} catch (Exception e) {
log.error("Failed to send message: " + e.getMessage());
}
}, Executors.newCachedThreadPool()); // FIXME: 알림이 매우 많은 경우 out of memory 발생 가능성 있음 (Thread pool size 제한 필요)
}
}
여기서 중요한 것은 @Component를 붙여서는 안 된다는 점이다.
통합 테스트 환경에선 해당 빈이 스캔되지 않도록 하고 싶으므로, FcmConfig에서 직접 빈을 등록하는 방식을 취했다.
@Slf4j
@Profile({"local", "dev", "prod"})
public class FcmConfig implements PennywayInfraConfig {
private final ClassPathResource firebaseResource;
private final String projectId;
public FcmConfig(@Value("${app.firebase.config.file}") String firebaseConfigPath,
@Value("${app.firebase.project.id}") String projectId) {
this.firebaseResource = new ClassPathResource(firebaseConfigPath);
this.projectId = projectId;
}
@PostConstruct
public void init() throws IOException {
FirebaseOptions option = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream()))
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(option);
log.info("FirebaseApp is initialized");
}
}
@Bean
FirebaseMessaging firebaseMessaging() {
return FirebaseMessaging.getInstance(FirebaseApp.getInstance());
}
@Bean
FcmManager fcmManager(FirebaseMessaging firebaseMessaging) {
return new FcmManager(firebaseMessaging);
}
@Bean
NotificationEventHandler notificationEventHandler(FcmManager fcmManager) {
return new FcmNotificationEventHandler(fcmManager);
}
}
Handler를 FcmConfig 빈에서 등록하는 게 맞나... 싶긴하지만 뭐 허허...
Sqs로 이벤트 핸들러를 처리할 땐 SqsConfig에 빈 등록해주면 되겄지 😏
애초에 지금 거기까지 고려하는 건 YAGNI 원칙에 어긋나는 듯 하므로 관두기로 했다.
📌 결과
너무 잘 된다. ㅎㅎ 삽질을 생각보다 너무 오래해서 그렇긴 하지만, 깔끔하게 해결할 수 있어서 좋았다.
만약 푸시 알림 이벤트가 실제로 발생하는 기능을 테스트 할 때는 NotificationEventHandler 인터페이스를 정의한 클래스를 테스트 패키지 내에서 정의하고 빈으로 등록해주면 된다.