
외국인 유학생을 위한 AI 기반 한국어 학습 서비스, LearnMate 개발기입니다!
AppStore 👉 https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353
개발을 마치고, 스토어에 배포해 운영하던 중, 회원가입 로직 중 이메일 인증 단계에서 Exception log가 발생하는 것을 확인했습니다.

기존 메일 인증 로직은 다음과 같습니다.
관련 코드는 다음과 같습니다.
@Transactional
override fun sendVerificationCodeToEmail(email: String) {
val code = createVerificationCode()
val verification = emailVerificationRepository.findByEmail(email)
?: EmailVerification(email, code)
verification.updateCode(code)
emailVerificationRepository.save(verification)
emailSendService.sendEmail(email, code)
}
@Transactional
override fun confirmEmailVerificationCode(email: String, code: String) {
val verification = getEmailVerificationByEmail(email)
if (verification.isExpired()) {
throw GeneralException(ErrorStatus.EXPIRED_EMAIL_VERIFICATION_CODE)
}
if (verification.code != code) {
throw GeneralException(ErrorStatus.INVALID_EMAIL_VERIFICATION_CODE)
}
verification.verify()
}
유저들의 피드백과 DB를 확인해본 결과, 발견된 문제점은 다음과 같습니다.
1번 상황에 대한 흐름을 도표로 그려보면 다음과 같습니다.

위의 문제를 해결하기 위해 아래 세 가지 해결방안을 도입했습니다.
동일한 email 주소에 대해 여러 개의 레코드가 생기는 문제를 방지하고자, EmailVerification entity의 email 칼럼을 unique로 지정했습니다.
@Column(nullable = false, unique = true)
val email: String,
혹은 PostgreSQL에 아래 쿼리를 이용해 직접 설정을 할 수도 있습니다.
ALTER TABLE email_verification
ADD CONSTRAINT uk_email_verification_email UNIQUE (email);
Event, Listener을 이용해 저장 로직과 메일 전송 로직 분리했습니다.
@Transactional
override fun sendVerificationCodeToEmail(email: String) {
...
verification.updateCode(code)
emailVerificationRepository.save(verification)
// entity 저장 및 업데이트 이후 메일 전송 이벤트 발행
applicationEventPublisher.publishEvent(EmailSendEvent(email, code))
}
리스너가 이벤트를 받아 메일 전송 로직을 호출하도록 위임하여 로직을 분리했습니다.
TransactionPhase.AFTER_COMMIT을 이용해 entity 저장, 즉 커밋이 완료된 후 메일 전송되도록 보장합니다.
data class EmailSendEvent(
val email: String,
val code: String
)
@Component
class EmailSendListener(
val emailSendService: EmailSendService
) {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleEmailSendEvent(event: EmailSendEvent) {
// 이메일 전송 로직 호출
emailSendService.sendEmail(event.email, event.code)
}
}
리스너에 @Async 이용해 메일 전송 쓰레드를 별도로 분리하여 응답시간 단축했습니다.
해당 기능을 사용하기 위해서는 Config 파일을 추가로 작성해야 합니다.
@Configuration
@EnableAsync
class AsyncConfig: AsyncConfigurer {
override fun getAsyncExecutor(): Executor {
val executor = ThreadPoolTaskExecutor()
val coreCount = Runtime.getRuntime().availableProcessors()
executor.corePoolSize = coreCount // 기본 스레드 수
executor.maxPoolSize = coreCount * 2 // 최대 스레드 수
executor.queueCapacity = 10
executor.setThreadNamePrefix("EmailAsync-")
executor.initialize()
return executor
}
}
경쟁상태를 방지하고자 Repository에서 email 주소를 이용해 entity를 찾는 함수에 @Lock(LockModeType.PESSIMISTIC_WRITE) 추가했습니다.
interface EmailVerificationRepository: JpaRepository<EmailVerification, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByEmail(email: String): EmailVerification?
}
락을 적용함에 따라, 이메일 인증 entity를 조회하고 save하는 과정은 아래 흐름과 같이 개선되었습니다.

위의 세 가지 방법들을 적용한 이후, Race Condition을 해결하고, 메일 전송에 대한 Response Time을 단축시킬 수 있었습니다.