Pingu
영차영차! Backend

팩토리 패턴으로 알림 채널 확장하기: Notification Service 실전 사례

2026년 1월 26일
7개 태그
팩토리 패턴
factory pattern
전략 패턴
strategy pattern
알림 시스템
Kotlin
Spring Boot

팩토리 패턴으로 알림 채널 확장하기: Notification Service 실전 사례

Notification Channels

Notification Channels

서론

s-class 프로젝트의 Notification Service는 여러 알림 채널을 지원합니다. 이메일로 결제 완료나 주문 확인을 보내고, 카카오톡 알림톡으로 중요한 결제 알림을 전송하며, Discord로 개발팀 내부 알림을 보냅니다.

처음에는 각 채널별로 별도의 메서드를 만들어서 사용했습니다. 그런데 채널이 하나씩 늘어날 때마다 코드가 복잡해지고 중복이 생기더라구요. 결국 팩토리 패턴을 적용해서 채널별 구현을 분리하고, Factory가 적절한 Provider를 선택하도록 바꿨습니다.

문제 상황

기존 구조의 한계

처음에는 각 채널별로 별도의 메서드를 만들었습니다.

@Service
class NotificationService {
    fun sendEmail(request: NotificationRequest) { ... }
    fun sendKakao(request: NotificationRequest) { ... }
    fun sendDiscord(request: NotificationRequest) { ... }
    
    fun process(request: NotificationRequest) {
        when (request.channel) {
            NotificationChannel.EMAIL -> sendEmail(request)
            NotificationChannel.KAKAO -> sendKakao(request)
            NotificationChannel.DISCORD -> sendDiscord(request)
        }
    }
}

이 구조의 문제점은 명확했습니다. 새 채널을 추가할 때마다 when 문에 케이스를 추가해야 했고, 하나의 서비스가 모든 채널을 처리해서 책임이 분산되지 않았습니다. 각 채널을 독립적으로 테스트하기도 어려웠고, 공통 로직이 각 메서드에 중복되어 있었습니다.

팩토리 패턴 적용

채널별 구현을 분리하고, Factory가 적절한 Provider를 선택하도록 구조를 바꿨습니다.

1. Provider 인터페이스 정의

모든 채널 Provider가 구현해야 할 인터페이스를 정의합니다.

// domain/port/outbound/NotificationChannelProvider.kt
interface NotificationChannelProvider {
    fun supports(channel: NotificationChannel): Boolean
    fun send(request: NotificationRequest): Boolean
}
  • supports(): 해당 Provider가 특정 채널을 지원하는지 확인
  • send(): 실제 알림 전송 로직

2. 채널별 Provider 구현

각 채널별로 Provider를 구현합니다.

Email Provider

@Component
class EmailNotificationProvider(
    private val javaMailSender: JavaMailSender,
    private val emailTemplateRepository: EmailTemplateRepository,
) : NotificationChannelProvider {
    
    override fun supports(channel: NotificationChannel): Boolean {
        return channel == NotificationChannel.EMAIL
    }
    
    override fun send(request: NotificationRequest): Boolean {
        return try {
            val recipient = request.recipient as? NotificationRecipient.EmailRecipient
                ?: throw IllegalArgumentException("Invalid recipient type for Email")
            
            val (subject, htmlBody, textBody) = when (val content = request.content) {
                is NotificationContent.Direct -> {
                    Triple(content.title ?: "알림", content.body, content.body)
                }
                is NotificationContent.Template -> {
                    val template = emailTemplateRepository.findByTemplateId(content.templateId)
                        ?: throw IllegalArgumentException("Email template not found")
                    
                    val subject = replaceParameters(template.subject, content.parameters)
                    val htmlBody = replaceParameters(template.htmlContent, content.parameters)
                    Triple(subject, htmlBody, htmlBody.replace(Regex("<[^>]+>"), ""))
                }
            }
            
            sendEmail(recipient.email, recipient.emailName, subject, htmlBody, textBody)
            true
        } catch (e: Exception) {
            logger.error("이메일 전송 실패", e)
            false
        }
    }
    
    private fun sendEmail(to: String, toName: String?, subject: String, htmlBody: String, textBody: String) {
        val message = javaMailSender.createMimeMessage()
        val helper = MimeMessageHelper(message, true, "UTF-8")
        helper.setTo(InternetAddress(to, toName ?: "", "UTF-8"))
        helper.setSubject(subject)
        helper.setText(htmlBody, true)
        javaMailSender.send(message)
    }
}

Kakao Provider

@Component
class KakaoNotificationProvider(
    @Qualifier("kakaoWebServiceTemplate") private val webServiceTemplate: WebServiceTemplate,
    @Value("\${app.notification.kakao.cert-key:}") private val certKey: String,
    @Value("\${app.notification.kakao.corp-num:}") private val corpNum: String,
    @Value("\${app.notification.kakao.sender-id:}") private val senderId: String,
    @Value("\${app.notification.kakao.yellow-id:}") private val yellowId: String,
) : NotificationChannelProvider {
    
    override fun supports(channel: NotificationChannel): Boolean {
        return channel == NotificationChannel.KAKAO
    }
    
    override fun send(request: NotificationRequest): Boolean {
        return try {
            val recipient = request.recipient as? NotificationRecipient.KakaoRecipient
                ?: throw IllegalArgumentException("Invalid recipient type for Kakao")
            
            when (val content = request.content) {
                is NotificationContent.Template -> {
                    sendTemplateMessage(recipient.phoneNumber, content.templateId, content.parameters)
                }
                is NotificationContent.Direct -> {
                    logger.warn("Kakao는 Direct 메시지를 지원하지 않습니다.")
                    false
                }
            }
        } catch (e: Exception) {
            logger.error("Kakao 알림톡 전송 실패", e)
            false
        }
    }
    
    private fun sendTemplateMessage(
        phoneNumber: String,
        templateName: String,
        parameters: Map<String, String>
    ): Boolean {
        val cleanPhoneNumber = phoneNumber.replace("-", "").replace(" ", "")
        
        val soapRequest = SendATKakaotalkEx(
            certkey = certKey,
            corpNum = corpNum,
            senderID = senderId,
            yellowId = yellowId,
            templateName = templateName,
            kakaotalkMessage = KakaotalkMessage(
                receiverNum = cleanPhoneNumber,
                message = formatTemplateMessage(parameters),
                buttons = Buttons()
            )
        )
        
        val response = webServiceTemplate.marshalSendAndReceive(
            soapRequest,
            SoapActionCallback("http://ws.baroservice.com/SendATKakaotalkEx")
        ) as? SendATKakaotalkExResponse
        
        val result = response?.sendATKakaotalkExResult ?: ""
        return !result.startsWith("-") && result.isNotBlank()
    }
}

Discord Provider

@Component
class DiscordNotificationProvider(
    private val webClient: WebClient,
    @Value("\${app.notification.discord.webhook-url:}") private val webhookUrl: String,
) : NotificationChannelProvider {
    
    override fun supports(channel: NotificationChannel): Boolean {
        return channel == NotificationChannel.DISCORD
    }
    
    override fun send(request: NotificationRequest): Boolean {
        return try {
            val recipient = request.recipient as? NotificationRecipient.DiscordRecipient
                ?: throw IllegalArgumentException("Invalid recipient type for Discord")
            
            val content = when (val notificationContent = request.content) {
                is NotificationContent.Direct -> {
                    buildDiscordMessage(notificationContent.title, notificationContent.body)
                }
                is NotificationContent.Template -> {
                    buildTemplateMessage(notificationContent.templateId, notificationContent.parameters)
                }
            }
            
            val payload = mapOf("content" to content)
            
            webClient.post()
                .uri(webhookUrl)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(payload)
                .retrieve()
                .bodyToMono(String::class.java)
                .block()
            
            true
        } catch (e: Exception) {
            logger.error("Discord 메시지 전송 실패", e)
            false
        }
    }
}

3. Factory 구현

Spring의 의존성 주입을 활용해서 모든 Provider를 자동으로 수집하고, 채널에 맞는 Provider를 반환하는 Factory를 만듭니다.

// application/service/NotificationChannelFactory.kt
@Component
class NotificationChannelFactory(
    private val providers: List<NotificationChannelProvider>
) {
    fun getProvider(channel: NotificationChannel): NotificationChannelProvider {
        return providers.firstOrNull { it.supports(channel) }
            ?: throw IllegalArgumentException("지원하지 않는 채널입니다.: ${channel.name}")
    }
}

Spring이 @Component로 등록된 모든 NotificationChannelProvider 구현체를 리스트로 주입해줍니다. supports() 메서드로 적절한 Provider를 찾고, 새 Provider를 추가해도 Factory 코드는 변경할 필요가 없습니다.

4. UseCase에서 Factory 사용

// application/usecase/ProcessNotificationUseCaseImpl.kt
@Service
class ProcessNotificationUseCaseImpl(
    private val channelFactory: NotificationChannelFactory
) : ProcessNotificationUseCase {
    
    override fun process(request: NotificationRequest) {
        logger.info("알림 처리 시작: notificationId=${request.notificationId}, channel=${request.channel}")
        
        val provider = channelFactory.getProvider(request.channel)
        val success = provider.send(request)
        
        if (success) {
            logger.info("알림 전송 성공: notificationId=${request.notificationId}")
        } else {
            logger.warn("알림 처리 중 오류 발생: notificationId=${request.notificationId}")
        }
    }
}

아키텍처 다이어그램

다이어그램 로딩 중...

팩토리 패턴의 장점

1. 확장성

새로운 채널을 추가할 때 Factory 코드를 수정할 필요가 없습니다.

// 새 채널 추가: Slack
@Component
class SlackNotificationProvider(
    private val webClient: WebClient,
) : NotificationChannelProvider {
    
    override fun supports(channel: NotificationChannel): Boolean {
        return channel == NotificationChannel.SLACK
    }
    
    override fun send(request: NotificationRequest): Boolean {
        // Slack 전송 로직
    }
}

NotificationChannel enum에 SLACK만 추가하면 끝입니다. Factory는 자동으로 새 Provider를 인식합니다.

2. 단일 책임 원칙

각 Provider는 하나의 채널만 담당합니다. EmailNotificationProvider는 이메일 전송만, KakaoNotificationProvider는 카카오톡 알림톡 전송만, DiscordNotificationProvider는 Discord 메시지 전송만 담당합니다.

3. 테스트 용이성

각 Provider를 독립적으로 테스트할 수 있습니다.

class EmailNotificationProviderTest {
    @Test
    fun `이메일 전송 성공`() {
        val provider = EmailNotificationProvider(mockMailSender, mockRepository)
        val request = createEmailRequest()
        
        val result = provider.send(request)
        
        assertTrue(result)
        verify(mockMailSender).send(any())
    }
}

4. 느슨한 결합

UseCase는 구체적인 Provider 구현을 모르고, 인터페이스만 의존합니다.

// UseCase는 Factory만 알고 있음
class ProcessNotificationUseCaseImpl(
    private val channelFactory: NotificationChannelFactory
)

실제 사용 예시

Pub/Sub에서 메시지 수신

abstract class BaseNotificationSubscriber(
    protected val processNotificationUseCase: ProcessNotificationUseCase
) {
    protected fun createMessageHandler(): MessageHandler {
        return MessageHandler { message ->
            try {
                val request = objectMapper.readValue(
                    payloadString, 
                    NotificationRequest::class.java
                )
                
                // Factory를 통해 적절한 Provider 선택 및 전송
                processNotificationUseCase.process(request)
                
                ackMessage?.ack()
            } catch (e: Exception) {
                logger.error("알림 처리 실패", e)
                ackMessage?.nack()
            }
        }
    }
}

다른 서비스에서 알림 발행

// payment-service에서 결제 완료 알림 발행
val request = NotificationRequest(
    notificationId = UUID.randomUUID().toString(),
    channel = NotificationChannel.EMAIL,
    recipient = NotificationRecipient.EmailRecipient(
        email = user.email,
        emailName = user.name
    ),
    content = NotificationContent.Template(
        templateId = "payment-completed",
        parameters = mapOf(
            "userName" to user.name,
            "amount" to payment.amount.toString(),
            "orderId" to order.id
        )
    )
)

notificationPublisher.publish(request)

팩토리 패턴 vs 전략 패턴

이 구현은 팩토리 패턴과 전략 패턴을 결합한 것입니다. 팩토리 패턴으로 적절한 Provider 객체를 생성하고 선택하고, 전략 패턴으로 각 Provider가 send() 전략을 구현합니다.

두 패턴을 함께 사용하면 객체 생성 로직을 Factory에 캡슐화할 수 있고, 런타임에 전략을 동적으로 선택할 수 있습니다. 결과적으로 확장하기 쉽고 유지보수하기 좋은 구조가 됩니다.

주의사항

1. Provider 중복 등록 방지

같은 채널을 지원하는 Provider가 여러 개 등록되면 예상치 못한 동작이 발생할 수 있습니다.

@Component
class NotificationChannelFactory(
    private val providers: List<NotificationChannelProvider>
) {
    fun getProvider(channel: NotificationChannel): NotificationChannelProvider {
        // firstOrNull 대신 first()를 사용하면 예외 발생
        // 또는 여러 개 발견 시 예외를 던지도록 검증 추가
        val matchedProviders = providers.filter { it.supports(channel) }
        require(matchedProviders.size == 1) {
            "채널 $channel에 대한 Provider가 ${matchedProviders.size}개 발견되었습니다."
        }
        return matchedProviders.first()
    }
}

2. Provider 초기화 순서

Spring의 의존성 주입 순서에 의존하지 않도록, Factory에서 명시적으로 검증하는 것이 좋습니다.

@PostConstruct
fun validateProviders() {
    val channels = NotificationChannel.values()
    channels.forEach { channel ->
        val providers = providers.filter { it.supports(channel) }
        require(providers.size == 1) {
            "채널 $channel에 대한 Provider가 ${providers.size}개 등록되었습니다."
        }
    }
}

개선 가능한 부분

현재 구조에서 추가로 개선할 수 있는 부분들이 있습니다. 각 Provider마다 다른 재시도 정책을 적용하거나, 알림 전송을 비동기로 처리해서 응답 시간을 줄일 수 있습니다. 각 채널별 전송 성공률과 실패율을 모니터링하는 메트릭 수집도 필요합니다.

여러 채널을 순차적으로 시도하는 폴백 전략이나, Thymeleaf, Mustache 같은 템플릿 엔진 통합도 고려해볼 만합니다. Factory에서 Provider를 캐싱해서 조회 성능을 높이는 것도 방법입니다.

댓글

?