본문 바로가기
📕 Spring Framework/Spring Project

OutBox Pattern 을 활용한 메일 전송 서비스 개발 [At Least Once]

by GroovyArea 2023. 4. 20.
스프링에서 메일 전송은 정말 간단하게 구현할 수 있다. JavaMailSender 로 말이야.
단순히, 메일만 전송하는 함수만 구현하면 끝일까?
물론 상황에 따라 간단한 구현이나, 복잡한 구현이 나눠져야 한다.

실 서비스에서는?
메일로 전송해야 하는 데이터 중요도에 따라 다르겠지만, 
아무래도 메일을 수신하는 클라이언트 입장에서는 서버 장애 때문에, 메일 수신이 안 될 경우 매우 당황스러울 것이다.

즉, 적어도 한번 전송 (At least once) 를 만족하는 Eventually Consistency 를 구현해야 하는 것은 메일 전송 서비스에서 기본적으로 다뤄져야 할 사항이다.

일례로, 분산 서버 환경에서는, 알림 서비스만을 다루는 애플리케이션이 존재하는데, 이때 outbox 패턴이라는 것을 사용하여 구현한다.

내가 개발하는 서버는 모노리스 환경이므로, out box 패턴까지는 사용할 이유가 없지만, 외부 API 를 통해 데이터를 제공받는 로직이 담겨 있기에, out box 패턴과 비스무리하게 구현해봤다.

 

At Least Once 가 뭔데?

일반적으로, 데이터 전송 방식은 총 3가지가 있다.
 

At Least Once

- 적어도 한번 데이터 전송을 보장
- 중복 데이터 전송 발생 이슈가 있다. (상대적으로 가벼운 메시지에 대해서 적용하면 좋을 것 같다.)
 

At Most Once 

- 한번의 데이터 전송을 보장
- 데이터의 수신자가 데이터를 수신한 여부는 중요하지 않다.
- 데이터의 유실이 발생할 수 있다.
 

Exactly Once

- 정확히 한 번 데이터 전송
- Message Filtering 을 하는 미들웨어가 보장되어야 정확히 한 번 데이터 전송 가능 
- 구현하기 참 어렵다.
 

OutBox 패턴이란?

MSA 환경에서 주로 사용하는 패턴
Message Queue 의 개념과 유사하다.
분산 애플리케이션 간의 데이터 전송 시 데이터의 일관성을 유지할 수 있다.
 
데이터의 수정 사항이 생길 경우, 이벤트를 발생 시켜 이벤트의 내용을 OutBox 에 저장한다.
OutBox 를 구독하는 Message Relay 가 저장된 이벤트를 처리하는 방식
 

나는 어떤 상황이었을까?

일단 외부 API 로부터 데이터를 Fetching 해와야 한다.
그 데이터를 바로 보낼 수 있지만, 언제 어디서든 고려해야 하는 예외 상황!
 
그래서 나는 데이터를 가져오는 것과, 그 데이터를 가지고 메일 전송하는 로직을 나눠 개발해야했다.
그래서 데이터를 임시로 저장해 놓고 있어야 한다.
 
데이터를 fetching 해, out box 를 조금 각색한 mongodb document 에 저장하는 스케쥴러,
mongodb 에 저장된 데이터를 가공해 적어도 한번 전송을 보장하는 메일 전송 스케쥴러
 
내가 개발할 목록이다.
 

구현 과정

1. 데이터를 API 로부터 페칭해, out box (mail box) document 에 저장

나는 Google Analytics4 의 데이터를 페칭해와야 했다.
그 데이터를 페칭해 저장할 document 인 mail box

@Document(collection = "mail_box")
class MailBox(

    @Id
    @Field(name = "_id", targetType = FieldType.OBJECT_ID)
    val id: ObjectId = ObjectId.get(),

    var event: Event,

    var payload: Payload,

)

data class Event(

    @Field(name = "event_type")
    var eventType: String,

    @Field(name = "period")
    var period: String,
)

enum class ExtractPeriod {

    WEEK,
    MONTH,
}

data class Payload(
    val payload: String,
)

실제로 out box 모델은 이와 다르지만, 나는 우리 서비스에 필요한 필드만으로 각색하여 구성하였다.
 
주와 월 단위로 전송을 확인하기 위해, enum 타입을 정의하였고,
이벤트 타입으로 어떠한 데이터 전송 타입인지 명시하였고,
실질적으로 전송할 데이터의 상세 사항을 payload 에 json 형식으로 변환하여 저장한다.
 

@Service
class ExampleGA4InfoBoxService(
    private val exampleBlangcoGA4Service: GA4Service,
    private val exampleMailBoxBuilder: MailBoxBuilder<ExampleGA4Data>
) : GA4InfoBoxService {

    override fun receiveDataPerWeekOfBox(): MailBox {
        val today = DateTimeUtils.returnToday()
        val sevenDaysAgo = today.minus(7, ChronoUnit.DAYS)

        val startDate = getStartDate(sevenDaysAgo)
        val endDate = getEndDate(today)

        val exampleGA4Data = receiveData(startDate, endDate, exampleBlangcoGA4Service)

        return exampleMailBoxBuilder.createMailBox(
            ga4Data = exampleGA4Data,
            period = ExtractPeriod.WEEK,
        )
    }

    override fun receiveDataPerMonthOfBox(): MailBox {
        val today = DateTimeUtils.returnToday()
        val thirtyDaysAgo = today.minus(30, ChronoUnit.DAYS)

        val startDate = getStartDate(thirtyDaysAgo)
        val endDate = getEndDate(today)

        val exampleGA4Data = receiveData(startDate, endDate, exampleBlangcoGA4Service)

        return exampleMailBoxBuilder.createMailBox(
            ga4Data = exampleGA4Data,
            period = ExtractPeriod.MONTH,
        )
    }

페칭해온 json 형식으로 변환하는 과정은 내부적으로 createMailBox 함수로서 처리하였다.
스케쥴러를 통해 지정한 주와 월마다 데이터를 저장한다.
 

2. 적어도 한번 메일 전송

이제 저장한 데이터를 가지고, 조회하여 적어도 한번 메일 전송을 진행해야 한다.
전체 코드는 너무 많으므로, 일부 추상화된 핵심 로직만 작성하겠다.
 

	@Scheduled(cron = "0 0 9 ? * MON", zone = "Asia/Seoul")
    fun sendGA4DataPerMonth() {
        while (true) {
            if (sendMailToGato(ExtractPeriod.MONTH).isEmpty()) {
                break
            }
        }
    }

    private fun sendMailToClient(extractPeriod: ExtractPeriod): List<MailBox> {
            val mailBoxes = getMailBoxes(extractPeriod)

            if (mailBoxes.isNotEmpty()) {
                val completedMailBoxIds = LinkedList<ObjectId>()

                mailBoxes.forEach {
                    try {
                        exampleSendMailService.sendMail(
                            mailBox = it,
                            extractPeriod = extractPeriod
                        )
                        completedMailBoxIds.add(it.id)
                    } catch (e: Exception) {
                        logger.error(
                            """Error occurred in ${Thread.currentThread().stackTrace[1].className}
                               at method ${Thread.currentThread().stackTrace[1].methodName}
                               line ${Thread.currentThread().stackTrace[1].lineNumber}: $ERROR_MESSAGE""",
                            e
                        )
                        mailBoxRepository.save(it)
                    }
                }

                if (completedMailBoxIds.isNotEmpty()) {
                    mailBoxRepository.deleteAllByIdIn(completedMailBoxIds)
                }
            }

        return mailBoxes
    }

전송하고자 하는 모든 목록을 mongo 에서 쿼리해오고, 
반복문을 통해 메일을 일단 전송한다.
성공한 목록들은 list collection에 objectId 만 담는다.
 
실패할 경우, 실패한 이벤트에 한해서 다시 저장한다.
마지막으로, 성공 이벤트는 document 에서 삭제한다.
 
이 로직은 상위 스케쥴러 함수에서 while 문을 통해 boxes 가 empty 할 때까지 진행한다.
 

결론

데이터를 가지고 적어도 한번 메일 전송을 진행할 수 있기에, At least once 를 구현했다고 볼 수 있다.
하지만, while 문을 통해 무한 루프를 돌게 하는 방식은 잘못 된 방식이라고 본다.
잘못된 데이터가 계속적으로 들어있을 수 있기 때문이다.
 
일부 횟수를 정한 뒤, 그 이상을 벗어날 경우, 일단 스케쥴러는 종료하고,
슬랙 알림이나, 에러 로그를 작성하는 방식이 서버 리소스 차원에서 좋은 수정 방안인 것 같다.

반응형