πŸ“• Spring Framework/Spring Project

OutBox Pattern 을 ν™œμš©ν•œ 메일 전솑 μ„œλΉ„μŠ€ 개발 [At Least Once]

GroovyArea 2023. 4. 20. 23:54
μŠ€ν”„λ§μ—μ„œ 메일 전솑은 정말 κ°„λ‹¨ν•˜κ²Œ κ΅¬ν˜„ν•  수 μžˆλ‹€. 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 문을 톡해 λ¬΄ν•œ 루프λ₯Ό 돌게 ν•˜λŠ” 방식은 잘λͺ» 된 방식이라고 λ³Έλ‹€.
잘λͺ»λœ 데이터가 κ³„μ†μ μœΌλ‘œ λ“€μ–΄μžˆμ„ 수 있기 λ•Œλ¬Έμ΄λ‹€.
 
일뢀 횟수λ₯Ό μ •ν•œ λ’€, κ·Έ 이상을 λ²—μ–΄λ‚  경우, 일단 μŠ€μΌ€μ₯΄λŸ¬λŠ” μ’…λ£Œν•˜κ³ ,
μŠ¬λž™ μ•Œλ¦Όμ΄λ‚˜, μ—λŸ¬ 둜그λ₯Ό μž‘μ„±ν•˜λŠ” 방식이 μ„œλ²„ λ¦¬μ†ŒμŠ€ μ°¨μ›μ—μ„œ 쒋은 μˆ˜μ • λ°©μ•ˆμΈ 것 κ°™λ‹€.

λ°˜μ‘ν˜•