[SpringBoot] 이메일 인증 트러블 슈팅 : UNIQUE, 트랜잭션 분리, LOCK

다은·2025년 11월 6일

SpringBoot

목록 보기
13/14
post-thumbnail

외국인 유학생을 위한 AI 기반 한국어 학습 서비스, LearnMate 개발기입니다!

AppStore 👉 https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353


1. 문제 상황

개발을 마치고, 스토어에 배포해 운영하던 중, 회원가입 로직 중 이메일 인증 단계에서 Exception log가 발생하는 것을 확인했습니다.

1. 기존 로직

기존 메일 인증 로직은 다음과 같습니다.

  1. email을 받아 해당 email 주소를 가지고 있는 entity가 있는지 조회
  2. 없으면 새로 생성, 있으면 해당 entity 사용
  3. code 생성 후 entity 필드값 업데이트 및 저장
  4. confirm 요청이 올 경우, 1번 수행
  5. 해당 entity에 저장된 code와 동일한지 확인 + 유효시간 5분 확인
  6. 코드가 동일하고 유효하다면 인증 성공

관련 코드는 다음과 같습니다.

	@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()
    }

2. 문제점

유저들의 피드백과 DB를 확인해본 결과, 발견된 문제점은 다음과 같습니다.

  1. 유저가 요청을 동시에 여러 번 반복하거나 쓰레드 간 경쟁 상태가 일어날 경우, 앞선 조건을 만족하지 않는 상황이 발생함
    • 즉, 동일한 email에 대해 2개 이상의 entity가 save되는 경우가 생김
    • 1번에서 entity를 조회할 때 2개 이상의 entity가 조회되어 Exception이 발생하며, 인증 실패
  2. 메일 전송 로직이 entity 저장 로직과 같은 트랜잭션 내에 존재
    • response time이 길어짐
    • 쓰레드 점유 시간이 불필요하게 길어짐
    • 정합성 보장이 어려움

1번 상황에 대한 흐름을 도표로 그려보면 다음과 같습니다.



2. 해결방안

위의 문제를 해결하기 위해 아래 세 가지 해결방안을 도입했습니다.

1. Unique 칼럼 지정

동일한 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);

2. 메일 전송 로직 트랜잭션 분리

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
    }
}



3. 비관적 락 (WRITE LOCK) 적용

경쟁상태를 방지하고자 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을 단축시킬 수 있었습니다.

profile
CS 마스터를 향해 ..

0개의 댓글