[Nyummy] 이메일 인증 구현기
최근 출시된 앱들을 보면 소셜 로그인만 제공하는 서비스가 많이 보인다.
처음에는 사용자가 편하게 가입할 수 있어서 그런가 보다 싶었다. 그런데 Nyummy에서 직접 이메일 회원가입을 구현하다 보니, 왜 소셜 로그인을 선택하는지 개발하는 입장에서도 조금씩 느끼게 됐다.
그중 이메일 인증은 코드를 보내고 일치하는지만 확인하면 될 것 같았다. 하지만 재발송과 입력 횟수 제한, 인증 결과를 다음 요청으로 전달하는 방법까지 생각할 게 꽤 많았다.
이번 글에서는 이메일 인증을 처음 설계하고 수정하면서 고민했던 것들을 가볍게 공유해 보려고 한다.
1. 처음 설계한 이메일 인증 흐름
처음 그려 본 흐름
아래는 처음 이메일 인증을 설계하면서 그린 시퀀스 다이어그램이다. 인증 메일을 보내고, 사용자가 입력한 코드를 확인하는 과정으로 나눴다.
여기에 빠진 것은?
처음에는 인증 코드를 보내고 확인하는 흐름부터 잡았다. 이 과정에서 서비스의 역할을 일관되게 나누지 못했고, 사용자를 언제 저장할지와 인증 결과를 회원가입까지 어떻게 이어갈지도 정리해야 했다.
1. 코드 발급과 검증을 서로 다른 서비스에서 처리하면?
MailService는 메일 발송을, MailCodeService는 인증 코드 조회와 저장을 맡도록 나눴다. 코드 저장에 트랜잭션을 걸고, 메일 발송은 그 밖에서 처리하는 구조였다.
하지만 코드 확인에서는 MailService가 직접 MailRepository에 접근하도록 했다. 같은 인증 코드인데 발급과 재발송 제한은 MailCodeService에, 검증과 인증 완료 처리는 MailService에 나뉘어 있었다.
메일을 보내는 일과 인증 코드를 관리하는 일을 분리하려면, 코드의 발급과 검증도 같은 기준으로 묶었어야 했다. 트랜잭션을 분리하는 것에 더해 각 서비스가 어디까지 담당할지도 정리할 필요가 있었다.
2. 클라이언트가 보낸 이메일을 그대로 믿어도 될까?
인증 코드 확인은 클라이언트에게 이메일과 코드를 받도록 설계했다.
1
emailVerificationConfirm(email, code)
여기서 이메일은 클라이언트가 자유롭게 바꿀 수 있는 값이다. 화면에서 수정을 막아도 요청까지 막을 수는 없다. 따라서 전달받은 이메일을 그 사용자의 이메일이라고 바로 믿어서는 안 된다.
서버는 해당 이메일로 발급한 기록을 찾아 코드가 일치하는지 확인해야 한다. 유효 시간과 시도 횟수도 함께 검사해야 한다. 이 검증을 통과해야 그 이메일로 보낸 코드를 알고 있다는 근거가 생긴다.
이메일과 코드를 함께 받는 방식 자체가 문제는 아니었다. 중요한 것은 클라이언트가 보낸 이메일과 서버가 확인한 이메일을 구분하는 것이었다.
3. 이메일 인증할 때 user를 미리 만들면?
이메일 인증을 진행할 때 user 테이블에 사용자를 먼저 만들어 둘지도 고민했다. 사용자를 미리 만들면 그 사용자에 이메일 인증 여부를 기록하고, 회원가입할 때 이어서 사용할 수 있었다.
하지만 이메일 인증만 하고 회원가입을 끝내지 않으면 가입이 완료되지 않은 사용자가 남는다. 그러면 가입 중인 사용자와 실제 회원을 구분하고, 중간에 그만둔 데이터를 언제 정리할지도 관리해야 한다.
그래서 user에는 회원가입까지 완료한 사용자만 두기로 했다. 이메일 인증 단계에서는 사용자를 만들지 않고, 실제 회원가입을 처리할 때 저장하도록 미뤘다.
그 대신 인증이 끝났다는 사실을 다음 요청에서 확인할 방법이 필요해졌다. 아직 사용자가 없으니 user의 인증 상태를 조회할 수는 없었다.
처음 다이어그램에서는 코드가 맞으면 인증 코드 기록에 isVerified = true와 verifiedAt을 저장하고 204 No Content를 반환했다. 하지만 회원가입 요청에서 이메일만 받아 이 상태를 조회하는 것으로 끝내면, 다른 요청자도 같은 이메일로 인증된 상태를 이용할 수 있다.
그래서 인증에 성공한 요청자에게 이메일 인증 완료 토큰을 발급하고, 회원가입할 때 그 토큰을 받기로 했다. 사용자를 미리 저장하는 대신 토큰으로 인증 결과를 이어가는 방식이다.
2. CodeService 분리와 토큰 기반 인증 흐름
인증 코드의 발급과 검증은 CodeService로 모았다. 사용자는 회원가입이 끝날 때 저장하기로 했으므로, 그 전까지의 인증 흐름은 서버가 발급한 토큰으로 연결했다. 인증 코드를 확인하는 단계와 인증을 마치고 가입하는 단계에는 각각 다른 토큰을 사용했다.
아래 코드는 현재 프로젝트에서 관련 부분을 발췌했다. 다이어그램의
MailService와TokenService는 현재 코드에서 각각EmailService와JwtService에 해당한다.EmailVerifiedToken은 코드에서verifiedToken이라는 이름으로 사용한다.
CodeService는 어디까지 담당하면 될까?
기존에는 인증 코드 처리가 MailService와 MailCodeService에 나뉘어 있었다. 이를 CodeService로 모아 코드 생성과 저장, 발송 횟수와 입력 횟수 관리, 코드 대조와 삭제를 담당하도록 했다.
분리한 이유는 흩어진 코드를 모으는 것뿐만 아니라 계층별 역할을 명확하게 하기 위해서였다. AuthService는 이메일 인증이라는 전체 흐름을 조합하고, CodeService는 코드 자체를 생성하고 관리하는 역할을 맡도록 했다. 메일을 보내는 일은 MailService로 분리했다.
이메일 인증 외에도 코드 생성이 필요한 기능이 생길 수 있다는 점도 고려했다. 코드 생성과 관리가 메일 발송에 묶여 있으면 다른 기능에서도 메일 관련 서비스를 거쳐야 한다. 그래서 이름도 MailCodeService에서 CodeService로 바꾸고, 이메일 인증 흐름과 코드 관리의 역할을 나눴다. 이후 다른 용도로 사용할 때는 그 용도에 맞는 검증 규칙을 추가할 수 있도록 하려는 의도였다.
AuthService는 이 작업들을 순서대로 호출한다. CodeService에 코드 생성을 요청하고, 반환받은 코드를 MailService로 전달해 메일을 보낸다. 토큰 발급은 TokenService에 맡긴다.
이렇게 역할을 나누면서 코드 확인도 CodeService를 거치도록 바꿨다. 메일 발송을 담당하는 서비스가 인증 코드 저장소에 직접 접근할 필요가 없어졌다. 코드 저장 트랜잭션과 외부 메일 발송도 분리했다.
코드로 보면 AuthService가 코드 생성, 메일 발송, 토큰 발급을 순서대로 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun sendAuthCode(request: SendAuthCodeRequest): SendAuthCodeResponse {
val email = request.email
val purpose = request.purpose
validateEmailForPurpose(purpose, email)
val authCode = codeService.createCodeByEmail(email, purpose)
emailService.sendAuthCode(email, authCode)
val emailChallengeToken = jwtService.createEmailChallengeToken(email, purpose)
return SendAuthCodeResponse(emailChallengeToken)
}
코드 검증은 CodeService 안에서 처리한다. 다른 용도의 코드는 거부하고, 오답 횟수를 확인한 뒤 코드가 맞으면 삭제한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Transactional(noRollbackFor = [BusinessException::class])
fun validateAuthCodeByEmail(challengeCode: String, email: String, type: AuthPurpose) {
val existingCode = codeRepository.findByEmail(email)
?: throw BusinessException(AuthErrorCode.INCORRECT_EMAIL)
if (existingCode.type != type) {
throw BusinessException(AuthErrorCode.INCORRECT_EMAIL)
}
val attemptCount = existingCode.attemptCount
if (attemptCount >= 5) {
throw BusinessException(AuthErrorCode.EMAIL_CODE_ATTEMPT_EXCEEDED)
}
if (existingCode.code != challengeCode) {
existingCode.increaseAttemptCount()
throw BusinessException(AuthErrorCode.EMAIL_CODE_MISMATCH)
}
codeRepository.delete(existingCode)
}
오답 횟수를 올린 뒤 예외를 던져도 그 기록은 남아야 한다. 그래서 이 메서드에서는 BusinessException으로 인한 롤백을 막았다.
이메일 대신 서버가 발급한 토큰을 받으면?
메일 발송 후에는 204 No Content로 끝내지 않고, 해당 이메일을 담은 EmailChallengeToken을 반환하도록 했다. 클라이언트는 인증 코드를 확인할 때 입력한 코드와 이 토큰을 함께 보낸다.
1
2
이메일 입력 → 인증 메일 발송 → EmailChallengeToken 반환
인증 코드 + EmailChallengeToken → 토큰 검증 → 토큰의 이메일로 코드 대조
서버는 토큰의 위변조 여부와 만료, 인증 코드 확인에 사용할 수 있는 종류인지를 검증한 뒤 이메일을 꺼내야 한다. CodeService에는 이렇게 확인한 이메일과 사용자가 입력한 코드를 전달한다. 다이어그램에는 이 토큰 검증과 이메일 추출 단계가 생략되어 있지만, 실제 처리에서는 필요한 부분이다.
클라이언트가 보내는 값이라는 점은 토큰도 같다. 다만 이메일 문자열을 그대로 사용하는 대신, 서버가 발급했고 유효한 토큰인지 확인한 뒤 그 안의 이메일을 사용한다.
EmailChallengeToken을 가지고 있다는 것만으로 이메일 인증이 끝나는 것은 아니다. 이 토큰은 어떤 이메일의 인증을 진행 중인지 연결하는 용도다. 실제로 그 메일을 받을 수 있는지는 인증 코드를 대조해서 확인한다.
실제 코드에서도 이메일은 요청 본문에서 다시 받지 않고, 검증한 토큰에서 꺼낸다. 코드 확인까지 성공한 뒤에만 인증 완료 토큰을 발급한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun validateAuthCode(request: ValidateAuthCodeRequest): ValidateAuthCodeResponse {
val challengeToken = request.emailChallengeToken
val challengeCode = request.authCode
val (email, purpose) = jwtService.getEmailChallengeClaims(challengeToken)
codeService.validateAuthCodeByEmail(challengeCode, email, purpose)
val verifiedToken = jwtService.createVerifiedToken(
VerifiedClaims(provider = AuthProvider.EMAIL, email = email, purpose = purpose),
)
return ValidateAuthCodeResponse(verifiedToken)
}
user가 없는데 인증 완료 여부는 어떻게 확인할까?
인증 코드가 맞으면 저장된 코드를 삭제하고 EmailVerifiedToken을 반환하도록 했다. 이때도 user에는 사용자를 저장하지 않는다. 이메일 인증은 끝났지만, 아직 회원가입을 완료한 것은 아니기 때문이다.
클라이언트는 회원가입에 필요한 정보와 함께 이 토큰을 보낸다. 서버는 토큰의 유효성과 만료, 가입에 사용할 수 있는 종류와 목적인지를 확인하고, 토큰에 담긴 이메일을 가입할 이메일로 사용한다. 이 검증과 나머지 가입 조건을 통과한 뒤에 사용자를 저장한다.
1
2
3
인증 메일 발송 → EmailChallengeToken 반환
코드 확인 성공 → EmailVerifiedToken 반환 (user 저장 안 함)
회원가입 요청 → EmailVerifiedToken 검증 → 가입 정보 검증 → user 저장
회원가입 메서드의 시작 부분에서도 먼저 토큰과 목적을 확인한다. 아래는 signUp()의 일부다.
1
2
3
4
5
val claims = jwtService.getVerifiedClaims(request.verifiedToken)
if (claims.purpose != AuthPurpose.SIGNUP) {
throw BusinessException(AuthErrorCode.INVALID_VERIFIED_TOKEN)
}
이후 중복 가입과 비밀번호 등 나머지 조건을 확인하고, claims.email을 사용해 사용자를 저장한다.
이렇게 하면 이메일 인증 후 가입을 그만두더라도 미완성 사용자 데이터는 남지 않는다. 대신 인증 결과를 토큰으로 전달하고 검증해야 하며, 가입 전에 인증 완료 토큰이 만료됐다면 이메일 인증을 다시 진행해야 한다.
EmailChallengeToken은 인증 중인 이메일을 연결하고, EmailVerifiedToken은 인증이 완료됐다는 사실을 회원가입 요청에 전달한다. 사용자 저장 시점을 늦추면서 생긴 인증 상태 확인 문제를 이 두 단계로 나눠 처리했다.
토큰으로 JWT를 선택한 이유는?
토큰에는 JWT를 사용했다. 클라이언트가 전달한 값이 서버에서 발급한 그대로인지 확인할 수 있어야 했고, 서명된 JWT는 서버에서 서명을 검증해 내용의 위변조를 확인할 수 있기 때문이다.
다만 서명은 내용을 숨겨 주지는 않는다. 이메일을 페이로드에 담으면서 이 부분도 함께 고려하게 됐다.
JWT에 이메일을 그대로 넣어도 될까?
토큰을 사용하기로 한 뒤에는 페이로드에 무엇을 담을지도 고민했다. 다음 요청에서 인증 중인 이메일을 알아야 하므로 이메일을 넣고, 토큰의 종류와 인증 목적, 발급 시각과 만료 시각도 함께 담았다.
그런데 이메일을 그대로 담으면 토큰을 얻은 사람이 그 안의 이메일을 읽을 수 있다는 문제가 있었다. 서명한 JWT는 내용을 바꾸었는지 확인할 수 있지만, 페이로드를 숨겨 주지는 않는다. 인코딩된 내용을 풀면 이메일도 확인할 수 있다.
그래서 이메일이 들어가는 토큰은 페이로드를 암호화해서 발급했다. 현재 구현은 JWE를 사용하고, AES-256-GCM으로 암호화한다. 이메일 필드만 따로 암호화하는 것이 아니라 이메일을 포함한 페이로드 전체를 암호화하는 방식이다.
챌린지 토큰의 이메일은 subject에, 인증 목적은 purpose에 담는다. 공통 생성 함수에서 종류와 유효 시간을 넣고 페이로드를 암호화한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun createEmailChallengeToken(email: String, purpose: AuthPurpose): String =
createEncryptedToken(email, TokenType.EMAIL_CHALLENGE, mapOf("purpose" to purpose.name))
private fun createEncryptedToken(subject: String?, type: TokenType, claims: Map<String, String>): String {
val now = clock.instant()
val timeToLive = getTimeToLive(type)
return Jwts.builder()
.subject(subject)
.claim("type", type.value)
.claims(claims)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(timeToLive)))
.encryptWith(encryptionKey, Jwts.ENC.A256GCM)
.compact()
}
정확히는 서명된 JWT인 JWS와 암호화된 JWT인 JWE를 구분해야 한다. 현재 이메일 인증 토큰은 JWE이므로, 별도의 서명을 검증하는 대신 AES-GCM의 인증 태그를 검증해 암호문의 변조를 확인한다. JWT를 선택할 때 생각한 위변조 검증에 더해, 이메일이 드러나지 않도록 암호화까지 적용한 것이다.
클라이언트는 내용을 읽을 필요 없이 받은 토큰을 그대로 돌려주면 된다. 서버가 이를 복호화하고 유효성을 검증한 뒤 이메일을 꺼내 코드 확인에 사용한다.
이렇게 하면 토큰이 노출되더라도 그 안의 이메일을 바로 읽을 수는 없다. 다만 암호화가 토큰 탈취나 재사용까지 막아 주는 것은 아니다. 토큰의 내용을 숨기는 것과, 그 토큰으로 어떤 요청을 허용할지는 따로 처리해야 한다.

