@@ -39,6 +39,7 @@ class MailService(
3939 companion object {
4040 private val log = LoggerFactory .getLogger(MailService ::class .java)
4141 private const val MAX_RETRY_HOURS = 24L
42+ private const val STUCK_SENDING_MINUTES = 15L
4243 }
4344
4445 fun sendMail (command : MailSendCommand ) {
@@ -166,6 +167,13 @@ class MailService(
166167
167168 fun sendReservedMails () {
168169 val now = Instant .now()
170+ val resetCount =
171+ mailReservationWriter.resetStuckSendingReservations(
172+ now.minus(STUCK_SENDING_MINUTES , ChronoUnit .MINUTES ),
173+ )
174+ if (resetCount > 0 ) {
175+ log.warn(" SENDING 고착 복구: {}건을 PENDING_SEND로 되돌렸습니다." , resetCount)
176+ }
169177 val reservations = mailReservationReader.readAllPendingBefore(now)
170178 log.info(" 예약 메일 처리 시작: 기준시각={}, 발송대상건수={}" , now, reservations.size)
171179 for (reservation in reservations) {
@@ -178,10 +186,15 @@ class MailService(
178186 now,
179187 delaySeconds,
180188 )
181- val sent = trySendReservation(reservation)
182- if (! sent && reservation.reservationTime.plus(MAX_RETRY_HOURS , ChronoUnit .HOURS ).isBefore(now)) {
183- log.error(" 최대 재시도 기간({}시간) 초과로 예약 삭제: reservationId={}, mailId={}" , MAX_RETRY_HOURS , reservation.id, reservation.mailId)
184- mailReservationWriter.delete(reservation)
189+ val claimed = mailReservationWriter.claimForSendingOrNull(reservation.id!! , now)
190+ if (claimed == null ) {
191+ log.debug(" 예약 claim 실패(다른 인스턴스 처리 중 또는 상태 변경): reservationId={}" , reservation.id)
192+ continue
193+ }
194+ val sent = trySendClaimedReservation(claimed)
195+ if (! sent && claimed.reservationTime.plus(MAX_RETRY_HOURS , ChronoUnit .HOURS ).isBefore(now)) {
196+ log.error(" 최대 재시도 기간({}시간) 초과로 예약 삭제: reservationId={}, mailId={}" , MAX_RETRY_HOURS , claimed.id, claimed.mailId)
197+ mailReservationWriter.delete(claimed)
185198 }
186199 }
187200 }
@@ -222,33 +235,64 @@ class MailService(
222235 )
223236 }
224237
225- val sent = trySendReservation(reservation)
238+ val claimed =
239+ mailReservationWriter.claimForSendingOrNull(reservation.id!! , now)
240+ ? : run {
241+ val current =
242+ mailReservationReader.readById(reservationId)
243+ ? : throw MailReservationNotFoundException (" 예약을 찾을 수 없습니다. reservationId=$reservationId " )
244+ when (current.status) {
245+ MailReservationStatus .SENT ->
246+ throw MailReservationAlreadyProcessedException (
247+ " 이미 발송된 메일은 재전송할 수 없습니다. reservationId=$reservationId " ,
248+ )
249+ MailReservationStatus .SENDING ->
250+ throw MailFailedException (
251+ " 다른 작업에서 발송 처리 중입니다. 잠시 후 다시 시도해 주세요. reservationId=$reservationId " ,
252+ )
253+ else ->
254+ throw MailFailedException (
255+ " 예약 메일을 처리할 수 없습니다. reservationId=$reservationId " ,
256+ )
257+ }
258+ }
259+
260+ val sent = trySendClaimedReservation(claimed)
226261 if (! sent) {
227262 throw MailFailedException (
228263 " 메일 발송에 실패했습니다. OAuth 토큰 갱신 또는 네트워크 상태를 확인해 주세요. reservationId=$reservationId " ,
229264 )
230265 }
231266 }
232267
233- private fun trySendReservation (
234- reservation : MailReservation ,
235- ): Boolean {
268+ /* *
269+ * [MailReservationStatus.SENDING] 상태(claim 직후)인 예약만 발송한다.
270+ */
271+ private fun trySendClaimedReservation (reservation : MailReservation ): Boolean {
236272 if (reservation.status == MailReservationStatus .SENT ) {
237273 log.warn(" 이미 발송된 예약에 대한 발송 시도 무시: reservationId={}" , reservation.id)
238274 return false
239275 }
276+ if (reservation.status != MailReservationStatus .SENDING ) {
277+ log.warn(
278+ " SENDING이 아닌 예약에 대한 발송 시도 무시: reservationId={}, status={}" ,
279+ reservation.id,
280+ reservation.status,
281+ )
282+ return false
283+ }
240284 return try {
241285 val mail = mailRepository.findById(reservation.mailId)
242286 if (mail == null ) {
243287 log.warn(" 예약 메일의 원본을 찾을 수 없어 삭제합니다: reservationId={}, mailId={}" , reservation.id, reservation.mailId)
244288 mailReservationWriter.delete(reservation)
245- return @trySendReservation false
289+ return @trySendClaimedReservation false
246290 }
247291 val user = userReader.findByEmail(mail.senderEmailAddress)
248292 if (user == null ) {
249293 log.warn(" 발신자를 찾을 수 없어 예약메일을 삭제합니다: reservationId={}, mailId={}" , reservation.id, reservation.mailId)
250294 mailReservationWriter.delete(reservation)
251- return @trySendReservation false
295+ return @trySendClaimedReservation false
252296 }
253297 log.info(
254298 " 예약 메일 발송 직전 제목 상태: reservationId={}, mailId={}, subject=[{}]" ,
@@ -260,7 +304,7 @@ class MailService(
260304 val refreshedUser = oauth2Service.refreshOAuth2TokenBeforeExpiry(user.id!! , OAuth2Type .GOOGLE , 10L )
261305 val accessToken = refreshedUser.getBearerAccessToken()
262306 mailSender.send(MailData .from(mail), accessToken)
263- mailReservationRepository.save (reservation.copy(status = MailReservationStatus . SENT ) )
307+ mailReservationWriter.markAsSent (reservation)
264308 log.info(" 예약 메일 발송 완료: reservationId={}, mailId={}" , reservation.id, reservation.mailId)
265309 true
266310 } catch (e: Exception ) {
@@ -271,9 +315,7 @@ class MailService(
271315 e.javaClass.simpleName,
272316 e,
273317 )
274- if (reservation.status == MailReservationStatus .SCHEDULED ) {
275- mailReservationRepository.save(reservation.copy(status = MailReservationStatus .PENDING_SEND ))
276- }
318+ mailReservationWriter.markAsPendingSend(reservation)
277319 false
278320 }
279321 }
@@ -346,6 +388,11 @@ class MailService(
346388 " 이미 발송된 메일은 수정할 수 없습니다. reservationId=$reservationId " ,
347389 )
348390 }
391+ if (existingReservation.status == MailReservationStatus .SENDING ) {
392+ throw MailReservationAlreadyProcessedException (
393+ " 발송 처리 중인 예약은 수정할 수 없습니다. reservationId=$reservationId " ,
394+ )
395+ }
349396 val now = Instant .now()
350397 if (! now.isBefore(existingReservation.reservationTime)) {
351398 throw MailReservationAlreadyProcessedException (
@@ -400,6 +447,11 @@ class MailService(
400447 " 이미 발송된 메일은 취소할 수 없습니다. reservationId=$reservationId " ,
401448 )
402449 }
450+ if (reservation.status == MailReservationStatus .SENDING ) {
451+ throw MailReservationAlreadyProcessedException (
452+ " 발송 처리 중인 예약은 취소할 수 없습니다. reservationId=$reservationId " ,
453+ )
454+ }
403455 val now = Instant .now()
404456 if (! now.isBefore(reservation.reservationTime)) {
405457 throw MailReservationAlreadyProcessedException (
0 commit comments