diff --git a/src/main/kotlin/com/wafflestudio/spring2025/SeminarApplication.kt b/src/main/kotlin/com/wafflestudio/spring2025/SeminarApplication.kt index 2c2c47ce..f3d9a12b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/SeminarApplication.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/SeminarApplication.kt @@ -3,9 +3,11 @@ package com.wafflestudio.spring2025 import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling class SeminarApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/event/EmailOutboxCreatedEvent.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/event/EmailOutboxCreatedEvent.kt new file mode 100644 index 00000000..8627c0bd --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/event/EmailOutboxCreatedEvent.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.spring2025.common.email.outbox.event + +data class EmailOutboxCreatedEvent( + val outboxId: Long, +) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutbox.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutbox.kt new file mode 100644 index 00000000..06099b70 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutbox.kt @@ -0,0 +1,26 @@ +package com.wafflestudio.spring2025.common.email.outbox.model + +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant + +@Table("email_outbox") +class EmailOutbox( + @Id var id: Long? = null, + var messageKey: String, + var eventType: EmailOutboxEventType, + var recipientEmail: String, + @Column("payload_json") + var payloadJson: String, + var status: EmailOutboxStatus = EmailOutboxStatus.PENDING, + var retryCount: Int = 0, + var maxRetryCount: Int = 5, + var nextRetryAt: Instant = Instant.now(), + var lastError: String? = null, + var sentAt: Instant? = null, + @CreatedDate + var createdAt: Instant? = null, + var updatedAt: Instant? = null, +) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxEventType.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxEventType.kt new file mode 100644 index 00000000..ab967395 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxEventType.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.spring2025.common.email.outbox.model + +enum class EmailOutboxEventType { + REGISTRATION_STATUS, + REGISTRATION_DELETE, + WAITLIST_PROMOTION, + REGISTRATION_DEMOTION, + EVENT_CANCELLATION, +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxStatus.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxStatus.kt new file mode 100644 index 00000000..6eee4b2b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/model/EmailOutboxStatus.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.spring2025.common.email.outbox.model + +enum class EmailOutboxStatus { + PENDING, + PROCESSING, + SENT, + FAILED, +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxCommandRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxCommandRepository.kt new file mode 100644 index 00000000..657f0476 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxCommandRepository.kt @@ -0,0 +1,105 @@ +package com.wafflestudio.spring2025.common.email.outbox.repository + +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.Timestamp +import java.time.Instant + +@Repository +class EmailOutboxCommandRepository( + private val jdbcTemplate: NamedParameterJdbcTemplate, +) { + fun findProcessableIds( + limit: Int, + now: Instant = Instant.now(), + ): List { + val params = + MapSqlParameterSource() + .addValue("limit", limit) + .addValue("now", Timestamp.from(now)) + return jdbcTemplate.query( + """ + SELECT id + FROM email_outbox + WHERE status IN ('PENDING', 'FAILED') + AND retry_count < max_retry_count + AND next_retry_at <= :now + ORDER BY id ASC + LIMIT :limit + """.trimIndent(), + params, + ) { rs, _ -> rs.getLong("id") } + } + + fun claimForProcessing( + id: Long, + now: Instant = Instant.now(), + ): Boolean { + val params = + MapSqlParameterSource() + .addValue("id", id) + .addValue("processing", EmailOutboxStatus.PROCESSING.name) + .addValue("now", Timestamp.from(now)) + val updated = + jdbcTemplate.update( + """ + UPDATE email_outbox + SET status = :processing, + updated_at = CURRENT_TIMESTAMP(6) + WHERE id = :id + AND status IN ('PENDING', 'FAILED') + AND retry_count < max_retry_count + AND next_retry_at <= :now + """.trimIndent(), + params, + ) + return updated == 1 + } + + fun markSent(id: Long) { + val params = + MapSqlParameterSource() + .addValue("id", id) + .addValue("sent", EmailOutboxStatus.SENT.name) + jdbcTemplate.update( + """ + UPDATE email_outbox + SET status = :sent, + sent_at = CURRENT_TIMESTAMP(6), + last_error = NULL, + updated_at = CURRENT_TIMESTAMP(6) + WHERE id = :id + """.trimIndent(), + params, + ) + } + + fun markFailed( + id: Long, + retryCount: Int, + nextRetryAt: Instant, + lastError: String, + ) { + val params = + MapSqlParameterSource() + .addValue("id", id) + .addValue("failed", EmailOutboxStatus.FAILED.name) + .addValue("retryCount", retryCount) + .addValue("nextRetryAt", Timestamp.from(nextRetryAt)) + .addValue("lastError", lastError) + jdbcTemplate.update( + """ + UPDATE email_outbox + SET status = :failed, + retry_count = :retryCount, + next_retry_at = :nextRetryAt, + last_error = :lastError, + updated_at = CURRENT_TIMESTAMP(6) + WHERE id = :id + """.trimIndent(), + params, + ) + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxRepository.kt new file mode 100644 index 00000000..b46d9a62 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/repository/EmailOutboxRepository.kt @@ -0,0 +1,6 @@ +package com.wafflestudio.spring2025.common.email.outbox.repository + +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutbox +import org.springframework.data.repository.ListCrudRepository + +interface EmailOutboxRepository : ListCrudRepository diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxProducer.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxProducer.kt new file mode 100644 index 00000000..4004a1f5 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxProducer.kt @@ -0,0 +1,130 @@ +package com.wafflestudio.spring2025.common.email.outbox.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.outbox.event.EmailOutboxCreatedEvent +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutbox +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxEventType +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxRepository +import com.wafflestudio.spring2025.common.email.service.EmailService +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.dao.DuplicateKeyException +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.sql.SQLIntegrityConstraintViolationException +import java.time.Instant + +@Component +class EmailOutboxProducer( + private val emailOutboxRepository: EmailOutboxRepository, + private val objectMapper: ObjectMapper, + private val eventPublisher: ApplicationEventPublisher, + @Value("\${email.outbox.max-retry:5}") + private val maxRetryCount: Int, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + fun enqueueRegistrationStatus(data: EmailService.RegistrationStatusEmailData) { + val registrationPublicId = data.registrationPublicId ?: return + saveOutbox( + eventType = EmailOutboxEventType.REGISTRATION_STATUS, + messageKey = "$registrationPublicId:${data.status.name}", + recipientEmail = data.toEmail, + payload = data, + ) + } + + fun enqueueRegistrationDelete(data: EmailService.RegistrationDeleteEmailData) { + saveOutbox( + eventType = EmailOutboxEventType.REGISTRATION_DELETE, + messageKey = "${data.registrationPublicId}:DELETE", + recipientEmail = data.toEmail, + payload = data, + ) + } + + fun enqueueWaitlistPromotion(data: EmailService.WaitlistPromotionEmailData) { + saveOutbox( + eventType = EmailOutboxEventType.WAITLIST_PROMOTION, + messageKey = "${data.registrationPublicId}:PROMOTION", + recipientEmail = data.toEmail, + payload = data, + ) + } + + fun enqueueRegistrationDemotion(data: EmailService.DemotionEmailData) { + saveOutbox( + eventType = EmailOutboxEventType.REGISTRATION_DEMOTION, + messageKey = "${data.registrationPublicId}:DEMOTION", + recipientEmail = data.toEmail, + payload = data, + ) + } + + fun enqueueEventCancellation(data: EmailService.EventCancellationEmailData) { + saveOutbox( + eventType = EmailOutboxEventType.EVENT_CANCELLATION, + messageKey = "${data.eventPublicId}:${data.toEmail}:EVENT_CANCELLATION", + recipientEmail = data.toEmail, + payload = data, + ) + } + + private fun saveOutbox( + eventType: EmailOutboxEventType, + messageKey: String, + recipientEmail: String, + payload: Any, + ) { + val payloadJson = objectMapper.writeValueAsString(payload) + val normalizedMessageKey = "${eventType.name}:${sha256(messageKey)}" + val now = Instant.now() + val outbox = + EmailOutbox( + messageKey = normalizedMessageKey, + eventType = eventType, + recipientEmail = recipientEmail, + payloadJson = payloadJson, + maxRetryCount = maxRetryCount, + nextRetryAt = now, + createdAt = now, + updatedAt = now, + ) + + try { + val saved = emailOutboxRepository.save(outbox) + saved.id?.let { id -> + eventPublisher.publishEvent(EmailOutboxCreatedEvent(id)) + } + } catch (ex: Exception) { + if (isDuplicateOutboxException(ex)) { + logger.info("중복 outbox 메시지 스킵: {}", normalizedMessageKey) + } else { + throw ex + } + } + } + + private fun isDuplicateOutboxException(ex: Throwable): Boolean { + var current: Throwable? = ex + while (current != null) { + if (current is DuplicateKeyException || + current is DataIntegrityViolationException || + current is SQLIntegrityConstraintViolationException + ) { + return true + } + current = current.cause + } + return false + } + + private fun sha256(source: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(source.toByteArray(StandardCharsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxWorker.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxWorker.kt new file mode 100644 index 00000000..d949999b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/outbox/service/EmailOutboxWorker.kt @@ -0,0 +1,139 @@ +package com.wafflestudio.spring2025.common.email.outbox.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.outbox.event.EmailOutboxCreatedEvent +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutbox +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxEventType +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxCommandRepository +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxRepository +import com.wafflestudio.spring2025.common.email.service.EmailService +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import java.time.Instant + +@Component +@ConditionalOnProperty(prefix = "email.outbox", name = ["enabled"], havingValue = "true", matchIfMissing = true) +class EmailOutboxWorker( + private val emailOutboxRepository: EmailOutboxRepository, + private val emailOutboxCommandRepository: EmailOutboxCommandRepository, + private val emailService: EmailService, + private val objectMapper: ObjectMapper, + @Value("\${email.outbox.batch-size:50}") + private val batchSize: Int, + @Value("\${email.outbox.backoff-seconds:30}") + private val backoffSeconds: Long, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onEmailOutboxCreated(event: EmailOutboxCreatedEvent) { + processEmail(event.outboxId) + } + + @Scheduled(fixedDelayString = "\${email.outbox.poll-interval-ms:1000}") + fun processPendingEmails() { + val now = Instant.now() + val targetIds = emailOutboxCommandRepository.findProcessableIds(batchSize.coerceAtLeast(1), now) + targetIds.forEach { id -> + processEmail(id) + } + } + + private fun processEmail(id: Long) { + val now = Instant.now() + if (!emailOutboxCommandRepository.claimForProcessing(id, now)) return + val message = emailOutboxRepository.findById(id).orElse(null) ?: return + runCatching { dispatch(message) } + .onSuccess { + emailOutboxCommandRepository.markSent(id) + }.onFailure { throwable -> + handleFailure(message, throwable) + } + } + + private fun dispatch(message: EmailOutbox) { + when (message.eventType) { + EmailOutboxEventType.REGISTRATION_STATUS -> { + val payload = + objectMapper.readValue( + message.payloadJson, + EmailService.RegistrationStatusEmailData::class.java, + ) + emailService.sendRegistrationStatusEmail(payload) + } + + EmailOutboxEventType.REGISTRATION_DELETE -> { + val payload = + objectMapper.readValue( + message.payloadJson, + EmailService.RegistrationDeleteEmailData::class.java, + ) + emailService.sendRegistrationDeleteEmail(payload) + } + + EmailOutboxEventType.WAITLIST_PROMOTION -> { + val payload = + objectMapper.readValue( + message.payloadJson, + EmailService.WaitlistPromotionEmailData::class.java, + ) + emailService.sendWaitlistPromotionEmail(payload) + } + + EmailOutboxEventType.REGISTRATION_DEMOTION -> { + val payload = + objectMapper.readValue( + message.payloadJson, + EmailService.DemotionEmailData::class.java, + ) + emailService.sendDemotionEmail(payload) + } + + EmailOutboxEventType.EVENT_CANCELLATION -> { + val payload = + objectMapper.readValue( + message.payloadJson, + EmailService.EventCancellationEmailData::class.java, + ) + emailService.sendEventCancellationEmail(payload) + } + } + } + + private fun handleFailure( + message: EmailOutbox, + throwable: Throwable, + ) { + val id = message.id ?: return + val nextRetryCount = message.retryCount + 1 + val exponent = (nextRetryCount - 1).coerceAtMost(10) + val nextRetryAt = Instant.now().plusSeconds(backoffSeconds * (1L shl exponent)) + val reason = truncateError(throwable.message ?: throwable.javaClass.simpleName) + emailOutboxCommandRepository.markFailed( + id = id, + retryCount = nextRetryCount, + nextRetryAt = nextRetryAt, + lastError = reason, + ) + logger.warn( + "이메일 outbox 처리 실패 id={}, eventType={}, retryCount={}/{} reason={}", + id, + message.eventType, + nextRetryCount, + message.maxRetryCount, + reason, + ) + } + + private fun truncateError(message: String): String = + if (message.length <= 1000) { + message + } else { + message.substring(0, 1000) + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index e6ce6809..33285bce 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -89,6 +89,7 @@ class EmailService( data class RegistrationDeleteEmailData( val toEmail: String, + val registrationPublicId: String, val name: String, val eventTitle: String?, val startsAt: Instant?, @@ -103,6 +104,7 @@ class EmailService( data class EventCancellationEmailData( val toEmail: String, + val eventPublicId: String, val name: String, val eventTitle: String?, val startsAt: Instant?, @@ -128,6 +130,23 @@ class EmailService( val waitingNum: Int?, ) + data class WaitlistPromotionEmailData( + val toEmail: String, + val eventTitle: String?, + val name: String, + val waitingNum: Int?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val totalCount: Int?, + val capacity: Int?, + val registrationStartsAt: Instant?, + val registrationEndsAt: Instant?, + val description: String?, + val eventPublicId: String, + val registrationPublicId: String, + ) + fun sendRegistrationStatusEmail(data: RegistrationStatusEmailData) { when (data.status) { RegistrationStatus.CONFIRMED -> { @@ -278,6 +297,25 @@ class EmailService( logger.info("정원 축소 대기 변경 알림이 ${data.toEmail} 로 전달되었습니다.") } + fun sendWaitlistPromotionEmail(data: WaitlistPromotionEmailData) { + sendWaitlistPromotionEmail( + toEmail = data.toEmail, + eventTitle = data.eventTitle, + name = data.name, + waitingNum = data.waitingNum, + startsAt = data.startsAt, + endsAt = data.endsAt, + location = data.location, + totalCount = data.totalCount, + capacity = data.capacity, + registrationStartsAt = data.registrationStartsAt, + registrationEndsAt = data.registrationEndsAt, + description = data.description, + eventPublicId = data.eventPublicId, + registrationPublicId = data.registrationPublicId, + ) + } + fun sendWaitlistPromotionEmail( toEmail: String, eventTitle: String?, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 7d41b3e9..66ac85f3 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.spring2025.domain.event.service +import com.wafflestudio.spring2025.common.email.outbox.service.EmailOutboxProducer import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.event.dto.response.CapabilitiesInfo @@ -27,8 +28,6 @@ import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionSynchronization -import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Instant import java.util.UUID @@ -40,7 +39,7 @@ class EventService( private val waitlistReconciliationService: WaitlistReconciliationService, private val userRepository: UserRepository, private val imageService: ImageService, - private val emailService: EmailService, + private val emailOutboxProducer: EmailOutboxProducer, ) { /** * 일정 생성 @@ -402,6 +401,7 @@ class EventService( if (toEmail.isNullOrBlank()) return@mapNotNull null EmailService.EventCancellationEmailData( toEmail = toEmail, + eventPublicId = event.publicId, name = user?.name ?: reg.guestName ?: "참여자", eventTitle = event.title, startsAt = event.startsAt, @@ -416,10 +416,8 @@ class EventService( registrationRepository.deleteByEventId(eventId) eventRepository.deleteById(eventId) - afterCommit { - emailDataList.forEach { data -> - emailService.sendEventCancellationEmail(data) - } + emailDataList.forEach { data -> + emailOutboxProducer.enqueueEventCancellation(data) } } @@ -499,20 +497,6 @@ class EventService( newCapacity: Int?, ): Boolean = previousCapacity != null && newCapacity != null && newCapacity < previousCapacity - private fun afterCommit(action: () -> Unit) { - if (!TransactionSynchronizationManager.isActualTransactionActive()) { - action() - return - } - TransactionSynchronizationManager.registerSynchronization( - object : TransactionSynchronization { - override fun afterCommit() { - action() - } - }, - ) - } - private fun buildCapabilities( viewerStatus: ViewerStatus, capacity: Int?, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 46ea8c70..b6073a28 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.service +import com.wafflestudio.spring2025.common.email.outbox.service.EmailOutboxProducer import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.event.exception.EventFullException @@ -31,8 +32,6 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionSynchronization -import org.springframework.transaction.support.TransactionSynchronizationManager import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.time.Duration @@ -46,7 +45,7 @@ RegistrationService( private val eventRepository: EventRepository, private val eventLockRepository: EventLockRepository, private val userRepository: UserRepository, - private val emailService: EmailService, + private val emailOutboxProducer: EmailOutboxProducer, private val imageService: ImageService, ) : WaitlistReconciliationService { private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") @@ -182,9 +181,7 @@ RegistrationService( waitingNum = waitlistedNumber, ) - afterCommit { - emailService.sendRegistrationStatusEmail(emailData) - } + emailOutboxProducer.enqueueRegistrationStatus(emailData) } else { throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) } @@ -263,6 +260,7 @@ RegistrationService( val emailData = EmailService.RegistrationDeleteEmailData( toEmail = recipientEmail, + registrationPublicId = registration.registrationPublicId, name = registration.guestName ?: registrationUser?.name ?: "참여자", eventTitle = event.title, startsAt = event.startsAt, @@ -275,9 +273,7 @@ RegistrationService( description = event.description, ) - afterCommit { - emailService.sendRegistrationDeleteEmail(emailData) - } + emailOutboxProducer.enqueueRegistrationDelete(emailData) } } @@ -646,9 +642,7 @@ RegistrationService( registrationPublicId = registration.registrationPublicId, ) - afterCommit { - emailService.sendRegistrationStatusEmail(emailData) - } + emailOutboxProducer.enqueueRegistrationStatus(emailData) } @Transactional @@ -707,9 +701,7 @@ RegistrationService( ) } - afterCommit { - emailDataList.forEach { emailService.sendDemotionEmail(it) } - } + emailDataList.forEach { emailOutboxProducer.enqueueRegistrationDemotion(it) } } @Transactional @@ -761,7 +753,7 @@ RegistrationService( if (recipientEmail.isNullOrBlank()) { null } else { - WaitlistPromotionEmailData( + EmailService.WaitlistPromotionEmailData( toEmail = recipientEmail, eventTitle = event.title, name = recipientName, @@ -780,57 +772,8 @@ RegistrationService( } } - afterCommit { - emailDataList.forEach { data -> - emailService.sendWaitlistPromotionEmail( - toEmail = data.toEmail, - eventTitle = data.eventTitle, - name = data.name, - waitingNum = data.waitingNum, - startsAt = data.startsAt, - endsAt = data.endsAt, - location = data.location, - totalCount = data.totalCount, - capacity = data.capacity, - registrationStartsAt = data.registrationStartsAt, - registrationEndsAt = data.registrationEndsAt, - description = data.description, - eventPublicId = data.eventPublicId, - registrationPublicId = data.registrationPublicId, - ) - } - } - } - - private data class WaitlistPromotionEmailData( - val toEmail: String, - val eventTitle: String, - val name: String, - val waitingNum: Int?, - val startsAt: Instant?, - val endsAt: Instant?, - val location: String?, - val totalCount: Int?, - val capacity: Int?, - val registrationStartsAt: Instant?, - val registrationEndsAt: Instant?, - val description: String?, - val eventPublicId: String, - val registrationPublicId: String, - ) - - private fun afterCommit(action: () -> Unit) { - if (!TransactionSynchronizationManager.isActualTransactionActive()) { - action() - return + emailDataList.forEach { data -> + emailOutboxProducer.enqueueWaitlistPromotion(data) } - - TransactionSynchronizationManager.registerSynchronization( - object : TransactionSynchronization { - override fun afterCommit() { - action() - } - }, - ) } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ee7ef521..a76d11d7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -44,6 +44,12 @@ jwt: email: from-email: moiming@wafflestudio.com from-name: 모이밍 + outbox: + enabled: true + poll-interval-ms: 300000 + batch-size: 50 + max-retry: 5 + backoff-seconds: 30 oauth: providers: diff --git a/src/main/resources/db/migration/V15__create_email_outbox.sql b/src/main/resources/db/migration/V15__create_email_outbox.sql new file mode 100644 index 00000000..5ae80d35 --- /dev/null +++ b/src/main/resources/db/migration/V15__create_email_outbox.sql @@ -0,0 +1,29 @@ +CREATE TABLE email_outbox ( + id BIGINT NOT NULL AUTO_INCREMENT, + message_key VARCHAR(191) NOT NULL, + event_type ENUM( + 'REGISTRATION_STATUS', + 'REGISTRATION_DELETE', + 'WAITLIST_PROMOTION', + 'REGISTRATION_DEMOTION', + 'EVENT_CANCELLATION' + ) NOT NULL, + recipient_email VARCHAR(255) NOT NULL, + payload_json JSON NOT NULL, + status ENUM('PENDING', 'PROCESSING', 'SENT', 'FAILED') NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retry_count INT NOT NULL DEFAULT 5, + next_retry_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_error VARCHAR(1000) NULL, + sent_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + CONSTRAINT uq_email_outbox_message_key UNIQUE (message_key) +); + +CREATE INDEX idx_email_outbox_status_next_retry_at_id + ON email_outbox (status, next_retry_at, id); + +CREATE INDEX idx_email_outbox_created_at + ON email_outbox (created_at); diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EmailOutboxIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EmailOutboxIntegrationTest.kt new file mode 100644 index 00000000..6595bfd6 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/EmailOutboxIntegrationTest.kt @@ -0,0 +1,570 @@ +package com.wafflestudio.spring2025 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.TestContainerConfig +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutbox +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxEventType +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxStatus +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxCommandRepository +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxRepository +import com.wafflestudio.spring2025.common.email.outbox.service.EmailOutboxProducer +import com.wafflestudio.spring2025.common.email.outbox.service.EmailOutboxWorker +import com.wafflestudio.spring2025.common.email.service.EmailService +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@Import(TestContainerConfig::class) +class EmailOutboxIntegrationTest + @Autowired + constructor( + private val producer: EmailOutboxProducer, + private val worker: EmailOutboxWorker, + private val emailOutboxRepository: EmailOutboxRepository, + private val emailOutboxCommandRepository: EmailOutboxCommandRepository, + private val mapper: ObjectMapper, + ) { + @MockitoBean + private lateinit var emailService: EmailService + + @AfterEach + fun cleanup() { + emailOutboxRepository.deleteAll() + } + + @Test + fun `등록 상태는 멱등한 키로 아웃박스에 적재된다`() { + val data = + EmailService.RegistrationStatusEmailData( + toEmail = "user@example.com", + status = RegistrationStatus.WAITLISTED, + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-1", + ) + + producer.enqueueRegistrationStatus(data) + producer.enqueueRegistrationStatus(data) + + val rows = emailOutboxRepository.findAll().toList() + assertThat(rows).hasSize(1) + assertThat(rows[0].messageKey).startsWith("REGISTRATION_STATUS:") + assertThat(rows[0].messageKey).hasSizeGreaterThan("REGISTRATION_STATUS:".length) + } + + @Test + fun `이벤트 타입별 메시지 키는 이벤트 타입으로 시작한다`() { + val now = Instant.parse("2026-01-01T00:00:00Z") + + val registrationStatusData = + EmailService.RegistrationStatusEmailData( + toEmail = "user-status@example.com", + status = RegistrationStatus.WAITLISTED, + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-status", + ) + val registrationDeleteData = + EmailService.RegistrationDeleteEmailData( + toEmail = "user-delete@example.com", + registrationPublicId = "reg-delete", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + ) + val promotionData = + EmailService.WaitlistPromotionEmailData( + toEmail = "user-promotion@example.com", + eventTitle = "행사", + name = "참여자", + waitingNum = 1, + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + eventPublicId = "event-1", + registrationPublicId = "reg-promotion", + ) + val demotionData = + EmailService.DemotionEmailData( + toEmail = "user-demotion@example.com", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + newCapacity = 2, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-demotion", + waitingNum = 2, + ) + val cancellationData = + EmailService.EventCancellationEmailData( + toEmail = "user-cancel@example.com", + eventPublicId = "event-1", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + description = "소개", + hostEmail = "host@example.com", + ) + + producer.enqueueRegistrationStatus(registrationStatusData) + producer.enqueueRegistrationDelete(registrationDeleteData) + producer.enqueueWaitlistPromotion(promotionData) + producer.enqueueRegistrationDemotion(demotionData) + producer.enqueueEventCancellation(cancellationData) + + val savedByType = + emailOutboxRepository + .findAll() + .associateBy { it.eventType } + + assertThat(savedByType[EmailOutboxEventType.REGISTRATION_STATUS]!!.messageKey).startsWith("REGISTRATION_STATUS:") + assertThat(savedByType[EmailOutboxEventType.REGISTRATION_DELETE]!!.messageKey).startsWith("REGISTRATION_DELETE:") + assertThat(savedByType[EmailOutboxEventType.WAITLIST_PROMOTION]!!.messageKey).startsWith("WAITLIST_PROMOTION:") + assertThat(savedByType[EmailOutboxEventType.REGISTRATION_DEMOTION]!!.messageKey).startsWith("REGISTRATION_DEMOTION:") + assertThat(savedByType[EmailOutboxEventType.EVENT_CANCELLATION]!!.messageKey).startsWith("EVENT_CANCELLATION:") + } + + @Test + fun `등록 상태 메시지 키는 상태값이 바뀌면 변경된다`() { + val base = + EmailService.RegistrationStatusEmailData( + toEmail = "user@example.com", + status = RegistrationStatus.CONFIRMED, + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-1", + ) + + producer.enqueueRegistrationStatus(base) + producer.enqueueRegistrationStatus(base.copy(status = RegistrationStatus.WAITLISTED)) + + val keys = emailOutboxRepository.findAll().map { it.messageKey } + assertThat(keys).hasSize(2) + assertThat(keys[0]).isNotEqualTo(keys[1]) + } + + @Test + fun `중복 키는 예외가 아닌 스킵 처리된다`() { + val data = + EmailService.RegistrationDeleteEmailData( + toEmail = "user@example.com", + registrationPublicId = "reg-dup", + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + ) + + producer.enqueueRegistrationDelete(data) + producer.enqueueRegistrationDelete(data) + + assertThat(emailOutboxRepository.findAll()).hasSize(1) + } + + @Test + fun `registrationPublicId가 없으면 등록 상태 아웃박스는 적재하지 않는다`() { + val data = + EmailService.RegistrationStatusEmailData( + toEmail = "user@example.com", + status = RegistrationStatus.CONFIRMED, + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + publicId = "event-1", + registrationPublicId = null, + ) + + producer.enqueueRegistrationStatus(data) + + assertThat(emailOutboxRepository.findAll()).isEmpty() + } + + @Test + fun `처리 가능한 아웃박스는 이벤트 타입별로 적절히 디스패치되고 SENT 처리된다`() { + val now = Instant.parse("2026-01-01T00:00:00Z") + val registrationStatusData = + EmailService.RegistrationStatusEmailData( + toEmail = "user-1@example.com", + status = RegistrationStatus.CONFIRMED, + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-1", + ) + val registrationDeleteData = + EmailService.RegistrationDeleteEmailData( + toEmail = "user-2@example.com", + registrationPublicId = "reg-2", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + ) + val promotionData = + EmailService.WaitlistPromotionEmailData( + toEmail = "user-3@example.com", + eventTitle = "행사", + name = "참여자", + waitingNum = 1, + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + eventPublicId = "event-1", + registrationPublicId = "reg-3", + ) + val demotionData = + EmailService.DemotionEmailData( + toEmail = "user-4@example.com", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + newCapacity = 2, + registrationStartsAt = now.minusSeconds(3600), + registrationEndsAt = now.minusSeconds(3000), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-4", + waitingNum = 2, + ) + val cancellationData = + EmailService.EventCancellationEmailData( + toEmail = "user-5@example.com", + eventPublicId = "event-1", + name = "참여자", + eventTitle = "행사", + startsAt = now, + endsAt = now.plusSeconds(7200), + location = "회의실", + description = "소개", + hostEmail = "host@example.com", + ) + + val ids = + listOf( + emailOutboxRepository + .save( + outbox(EmailOutboxEventType.REGISTRATION_STATUS, registrationStatusData, "m-${UUID.randomUUID()}"), + ).id!!, + emailOutboxRepository + .save( + outbox(EmailOutboxEventType.REGISTRATION_DELETE, registrationDeleteData, "m-${UUID.randomUUID()}"), + ).id!!, + emailOutboxRepository + .save( + outbox(EmailOutboxEventType.WAITLIST_PROMOTION, promotionData, "m-${UUID.randomUUID()}"), + ).id!!, + emailOutboxRepository + .save( + outbox(EmailOutboxEventType.REGISTRATION_DEMOTION, demotionData, "m-${UUID.randomUUID()}"), + ).id!!, + emailOutboxRepository + .save( + outbox(EmailOutboxEventType.EVENT_CANCELLATION, cancellationData, "m-${UUID.randomUUID()}"), + ).id!!, + ) + worker.processPendingEmails() + + verify(emailService).sendRegistrationStatusEmail(registrationStatusData) + verify(emailService).sendRegistrationDeleteEmail(registrationDeleteData) + verify(emailService).sendWaitlistPromotionEmail(promotionData) + verify(emailService).sendDemotionEmail(demotionData) + verify(emailService).sendEventCancellationEmail(cancellationData) + + ids.forEach { id -> + val outbox = emailOutboxRepository.findById(id).orElseThrow() + assertThat(outbox.status).isEqualTo(EmailOutboxStatus.SENT) + } + } + + @Test + fun `잘못된 payload는 실패 상태와 retry 정보로 저장된다`() { + val row = + emailOutboxRepository.save( + outbox(EmailOutboxEventType.REGISTRATION_STATUS, "{}", "broken-${UUID.randomUUID()}"), + ) + worker.processPendingEmails() + + val after = emailOutboxRepository.findById(row.id!!).orElseThrow() + assertThat(after.status).isEqualTo(EmailOutboxStatus.FAILED) + assertThat(after.retryCount).isEqualTo(1) + assertThat(after.nextRetryAt).isAfter(Instant.now().minusSeconds(1)) + assertThat(after.nextRetryAt).isBefore(Instant.now().plusSeconds(120)) + assertThat(after.lastError).isNotBlank() + } + + @Test + fun `이메일 전송 예외는 실패로 처리되고 에러가 기록된다`() { + val data = + EmailService.RegistrationStatusEmailData( + toEmail = "user@example.com", + status = RegistrationStatus.CONFIRMED, + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-1", + ) + val row = + emailOutboxRepository.save( + outbox(EmailOutboxEventType.REGISTRATION_STATUS, data, "smtp-${UUID.randomUUID()}"), + ) + whenever(emailService.sendRegistrationStatusEmail(any())).thenThrow(RuntimeException("smtp down")) + worker.processPendingEmails() + + val after = emailOutboxRepository.findById(row.id!!).orElseThrow() + assertThat(after.status).isEqualTo(EmailOutboxStatus.FAILED) + assertThat(after.retryCount).isEqualTo(1) + assertThat(after.lastError).isEqualTo("smtp down") + verify(emailService).sendRegistrationStatusEmail(data) + } + + @Test + fun `실패 사유는 1000자로 잘린다`() { + val data = + EmailService.RegistrationStatusEmailData( + toEmail = "user@example.com", + status = RegistrationStatus.CONFIRMED, + name = "참여자", + eventTitle = "행사", + startsAt = Instant.parse("2026-01-01T00:00:00Z"), + endsAt = Instant.parse("2026-01-01T02:00:00Z"), + location = "회의실", + totalCount = 1, + capacity = 10, + registrationStartsAt = Instant.parse("2025-12-31T23:00:00Z"), + registrationEndsAt = Instant.parse("2025-12-31T23:50:00Z"), + description = "소개", + publicId = "event-1", + registrationPublicId = "reg-1", + ) + val row = + emailOutboxRepository.save(outbox(EmailOutboxEventType.REGISTRATION_STATUS, data, "long-${UUID.randomUUID()}")) + + whenever(emailService.sendRegistrationStatusEmail(any())).thenThrow(RuntimeException("E".repeat(1205))) + worker.processPendingEmails() + + val after = emailOutboxRepository.findById(row.id!!).orElseThrow() + assertThat(after.status).isEqualTo(EmailOutboxStatus.FAILED) + assertThat(after.lastError).hasSize(1000) + assertThat(after.lastError).isEqualTo("E".repeat(1000)) + } + + @Test + fun `findProcessableIds는 처리 가능한 상태만 조회한다`() { + val now = Instant.now() + val pendingNow = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user-1@example.com\"}", + "q-${UUID.randomUUID()}", + status = EmailOutboxStatus.PENDING, + nextRetryAt = now.minusSeconds(60), + retryCount = 0, + maxRetryCount = 5, + ), + ) + val futurePending = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user-2@example.com\"}", + "q-${UUID.randomUUID()}", + status = EmailOutboxStatus.PENDING, + nextRetryAt = now.plusSeconds(60), + retryCount = 0, + maxRetryCount = 5, + ), + ) + val failedReady = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user-3@example.com\"}", + "q-${UUID.randomUUID()}", + status = EmailOutboxStatus.FAILED, + nextRetryAt = now.minusSeconds(60), + retryCount = 1, + maxRetryCount = 5, + ), + ) + val failedOverRetry = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user-4@example.com\"}", + "q-${UUID.randomUUID()}", + status = EmailOutboxStatus.FAILED, + nextRetryAt = now.minusSeconds(60), + retryCount = 5, + maxRetryCount = 5, + ), + ) + val processing = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user-5@example.com\"}", + "q-${UUID.randomUUID()}", + status = EmailOutboxStatus.PROCESSING, + nextRetryAt = now.minusSeconds(60), + ), + ) + + val ids = emailOutboxCommandRepository.findProcessableIds(20) + + assertThat(ids).hasSize(2) + assertThat(ids).containsExactlyInAnyOrder(pendingNow.id, failedReady.id) + assertThat(ids).doesNotContain(futurePending.id, failedOverRetry.id, processing.id) + } + + @Test + fun `처리 대상이 아닌 아웃박스는 worker 처리에서 건너뛴다`() { + val processing = + emailOutboxRepository.save( + outbox( + EmailOutboxEventType.REGISTRATION_STATUS, + "{\"toEmail\":\"user@example.com\"}", + "skip-${UUID.randomUUID()}", + status = EmailOutboxStatus.PROCESSING, + nextRetryAt = Instant.now().minusSeconds(10), + ), + ) + + worker.processPendingEmails() + + assertThat(emailOutboxRepository.findById(processing.id!!).orElseThrow().status) + .isEqualTo(EmailOutboxStatus.PROCESSING) + verifyNoInteractions(emailService) + verify(emailService, never()).sendRegistrationStatusEmail(any()) + } + + private fun outbox( + eventType: EmailOutboxEventType, + payload: Any, + messageKey: String, + status: EmailOutboxStatus = EmailOutboxStatus.PENDING, + nextRetryAt: Instant = Instant.now().minusSeconds(60), + retryCount: Int = 0, + maxRetryCount: Int = 5, + recipientEmail: String = "user-${UUID.randomUUID()}@example.com", + ): EmailOutbox { + val now = Instant.now() + return EmailOutbox( + messageKey = messageKey, + eventType = eventType, + recipientEmail = recipientEmail, + payloadJson = if (payload is String) payload else mapper.writeValueAsString(payload), + status = status, + retryCount = retryCount, + maxRetryCount = maxRetryCount, + nextRetryAt = nextRetryAt, + createdAt = now, + updatedAt = now, + ) + } + } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index aa96cba1..515b4697 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -1,6 +1,8 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.outbox.model.EmailOutboxEventType +import com.wafflestudio.spring2025.common.email.outbox.repository.EmailOutboxRepository import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.dto.request.CreateEventRequest import com.wafflestudio.spring2025.domain.event.dto.request.UpdateEventRequest @@ -11,17 +13,12 @@ import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -46,10 +43,8 @@ class EventIntegrationTest private val dataGenerator: DataGenerator, private val eventRepository: EventRepository, private val registrationRepository: RegistrationRepository, + private val emailOutboxRepository: EmailOutboxRepository, ) { - @MockitoBean - private lateinit var emailService: EmailService - // ================================================================= // Helpers // ================================================================= @@ -1081,7 +1076,14 @@ class EventIntegrationTest .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) - verify(emailService, times(1)).sendDemotionEmail(any()) + val demotionOutboxesForEvent = + emailOutboxRepository + .findAll() + .filter { it.eventType == EmailOutboxEventType.REGISTRATION_DEMOTION } + .map { mapper.readValue(it.payloadJson, EmailService.DemotionEmailData::class.java) } + .filter { it.publicId == event.publicId } + + assertThat(demotionOutboxesForEvent).hasSize(1) } @Test @@ -1282,7 +1284,17 @@ class EventIntegrationTest .header("Authorization", "Bearer $token"), ).andExpect(status().isNoContent) - verify(emailService, times(2)).sendEventCancellationEmail(any()) + val cancellationOutboxesForEvent = + emailOutboxRepository + .findAll() + .filter { it.eventType == EmailOutboxEventType.EVENT_CANCELLATION } + .map { mapper.readValue(it.payloadJson, EmailService.EventCancellationEmailData::class.java) } + .filter { it.eventPublicId == event.publicId } + + assertThat(cancellationOutboxesForEvent).hasSize(2) + assertThat(cancellationOutboxesForEvent.map { it.toEmail }) + .containsExactlyInAnyOrder(confirmed.email, waitlisted.email) + .doesNotContain(banned.email) } @Test @@ -1315,6 +1327,13 @@ class EventIntegrationTest .header("Authorization", "Bearer $token"), ).andExpect(status().isNoContent) - verify(emailService, never()).sendEventCancellationEmail(any()) + val cancellationOutboxesForEvent = + emailOutboxRepository + .findAll() + .filter { it.eventType == EmailOutboxEventType.EVENT_CANCELLATION } + .map { mapper.readValue(it.payloadJson, EmailService.EventCancellationEmailData::class.java) } + .filter { it.eventPublicId == event.publicId } + + assertThat(cancellationOutboxesForEvent).isEmpty() } }