땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 15. 이메일 인증 API 구현 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 15. 이메일 인증 API 구현

ttasjwi 2024. 11. 13. 09:47

 

 

지난 인증 이메일 발송 기능 구현 글에서 이어집니다.

인증 코드 발송 기능은 구현했고, 이어서 이메일 인증을 실제로 해볼거에요.


1. 표현계층(api 모듈)

@RestController
class EmailVerificationController(
    private val useCase: EmailVerificationUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) {

    @PostMapping("/api/v1/members/email-verification")
    fun emailVerification(@RequestBody request: EmailVerificationRequest): ResponseEntity<SuccessResponse<EmailVerificationResponse>> {
        // 애플리케이션 서비스에 요청 처리를 위임
        val result = useCase.emailVerification(request)

        // 처리 결과로부터 응답 메시지 가공
        val response = makeResponse(result)

        // 200 상태코드와 함께 HTTP 응답
        return ResponseEntity.ok(response)
    }

    private fun makeResponse(result: EmailVerificationResult): SuccessResponse<EmailVerificationResponse> {
        val code = "EmailVerification.Complete"
        val locale = localeManager.getCurrentLocale()
        return SuccessResponse(
            code = code,
            message = messageResolver.resolve("$code.message", locale),
            description = messageResolver.resolve("$code.description", locale),
            data = EmailVerificationResponse(
                verificationResult = EmailVerificationResponse.VerificationResult(
                    email = result.email,
                    verificationExpiresAt = result.verificationExpiresAt,
                )
            )
        )
    }
}

data class EmailVerificationResponse(
    val verificationResult: VerificationResult
) {

    data class VerificationResult(
        val email: String,
        val verificationExpiresAt: ZonedDateTime,
    )
}

 

컨트롤러 코드는 기존 컨트롤러 코드와 큰 차이가 없습니다.

요청을 받아서, 유즈케이스에 위임하고, 결과를 받아서 응답 메시지 작성 처리에 사용하는 기조를 그대로 유지합니다.

 

POST 메서드므로, body로 전달한 json을 바인딩할 수 있게 @RequestBody를 달아야합니다.

 


2. 애플리케이션 계층

2.1 유즈케이스 계약

package com.ttasjwi.board.system.member.application.usecase

import java.time.ZonedDateTime

interface EmailVerificationUseCase {

    /**
     * 회원 가입을 위한 이메일 인증을 처리합니다.
     */
    fun emailVerification(request: EmailVerificationRequest): EmailVerificationResult
}

data class EmailVerificationRequest(
    val email: String?,
    val code: String?,
)

data class EmailVerificationResult(
    val email: String,
    val verificationExpiresAt: ZonedDateTime,
)

 

유즈케이스의 계약입니다.

요청(Request)으로 "email", "code" 를 받고 응답으로는 이메일 인증 결과(Result) 를 반환합니다.

2.2 애플리케이션 서비스

@ApplicationService
internal class EmailVerificationApplicationService(
    private val commandMapper: EmailVerificationCommandMapper,
    private val processor: EmailVerificationProcessor,
    private val transactionRunner: TransactionRunner,
) : EmailVerificationUseCase{

    override fun emailVerification(request: EmailVerificationRequest): EmailVerificationResult {
        val command = commandMapper.mapToCommand(request)

        val event = transactionRunner.run {
            processor.verify(command)
        }

        return mapToResult(event)
    }

    private fun mapToResult(event: EmailVerifiedEvent): EmailVerificationResult {
        return EmailVerificationResult(
            email = event.data.email,
            verificationExpiresAt = event.data.verificationExpiresAt,
        )
    }
}

 

이메일 인증 유즈케이스의 구현체인 애플리케이션 서비스입니다.

기존에 작성해온 애플리케이션 서비스와 구조는 거의 같습니다.

 

요청을 명령으로 매핑하고(CommandMapper), 명령을 트랜잭션에서(TransactionRunner) 처리하고(Processor)

결과를 가공해서 반환합니다.

 

2.3 커맨드 / 커맨드 매퍼(CommandMapper)

package com.ttasjwi.board.system.member.application.dto

import com.ttasjwi.board.system.member.domain.model.Email
import java.time.ZonedDateTime

internal data class EmailVerificationCommand(
    val email: Email,
    val code: String,
    val currentTime: ZonedDateTime,
)
package com.ttasjwi.board.system.member.application.mapper

import com.ttasjwi.board.system.core.annotation.component.ApplicationCommandMapper
import com.ttasjwi.board.system.core.exception.NullArgumentException
import com.ttasjwi.board.system.core.exception.ValidationExceptionCollector
import com.ttasjwi.board.system.core.time.TimeManager
import com.ttasjwi.board.system.logging.getLogger
import com.ttasjwi.board.system.member.application.dto.EmailVerificationCommand
import com.ttasjwi.board.system.member.application.usecase.EmailVerificationRequest
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.service.EmailCreator

@ApplicationCommandMapper
internal class EmailVerificationCommandMapper(
    private val emailCreator: EmailCreator,
    private val timeManager: TimeManager,
) {

    companion object {
        private val log = getLogger(EmailVerificationCommandMapper::class.java)
    }

    fun mapToCommand(request: EmailVerificationRequest): EmailVerificationCommand {
        log.info { "요청 입력값이 유효한지 확인합니다." }

        val exceptionCollector = ValidationExceptionCollector()

        val email = getEmail(request.email, exceptionCollector)
        val code = getCode(request.code, exceptionCollector)

        exceptionCollector.throwIfNotEmpty()

        log.info{ "요청 입력값이 유효합니다. (email=$email)"}

        return EmailVerificationCommand(
            email = email!!,
            code = code!!,
            currentTime = timeManager.now()
        )
    }

    private fun getEmail(email: String?, exceptionCollector: ValidationExceptionCollector): Email? {
        if (email == null) {
            log.warn { "이메일이 누락됐습니다." }
            exceptionCollector.addCustomExceptionOrThrow(NullArgumentException("email"))
            return null
        }
        return emailCreator.create(email)
            .getOrElse {
                log.warn { "이메일 포맷이 유효하지 않습니다. (email=${email})" }
                exceptionCollector.addCustomExceptionOrThrow(it)
                return null
            }
    }

    private fun getCode(code: String?, exceptionCollector: ValidationExceptionCollector): String? {
        if (code == null) {
            log.warn { "이메일 인증 코드가 누락됐습니다." }
            exceptionCollector.addCustomExceptionOrThrow(NullArgumentException("code"))
            return null
        }
        return code
    }
}

 

커맨드 매퍼에서는 사용자 요청을 분석하여 유효성 검증을 합니다.

이메일이 누락됐는지, 이메일 형식이 이상한지, 코드가 누락됐는지를 검증합니다.

검증 과정에서 발생한 예외들은 ValidationExceptionCollector를 통해 수집하고 한번에 터트립니다.

 

이 과정을 거쳐서 Email 도메인이 생성되고, code는 null 아 아님이 확정되며, 현재 시간 정보도 명령에 담기게 됩니다.

 

2.5 프로세서

package com.ttasjwi.board.system.member.application.processor

import com.ttasjwi.board.system.core.annotation.component.ApplicationProcessor
import com.ttasjwi.board.system.logging.getLogger
import com.ttasjwi.board.system.member.application.dto.EmailVerificationCommand
import com.ttasjwi.board.system.member.application.exception.EmailVerificationNotFoundException
import com.ttasjwi.board.system.member.domain.event.EmailVerifiedEvent
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import com.ttasjwi.board.system.member.domain.service.EmailVerificationAppender
import com.ttasjwi.board.system.member.domain.service.EmailVerificationEventCreator
import com.ttasjwi.board.system.member.domain.service.EmailVerificationFinder
import com.ttasjwi.board.system.member.domain.service.EmailVerificationHandler

@ApplicationProcessor
internal class EmailVerificationProcessor(
    private val emailVerificationFinder: EmailVerificationFinder,
    private val emailVerificationHandler: EmailVerificationHandler,
    private val emailVerificationAppender: EmailVerificationAppender,
    private val emailVerificationEventCreator: EmailVerificationEventCreator,
) {

    companion object {
        private val log = getLogger(EmailVerificationProcessor::class.java)
    }


    fun verify(command: EmailVerificationCommand): EmailVerifiedEvent {
        // 이메일에 대응하는 이메일 인증 조회
        val emailVerification = getEmailVerification(command)

        // 이메일 인증 처리
        val verifiedEmailVerification = emailVerificationHandler.codeVerify(emailVerification, command.code, command.currentTime)

        // 이메일 인증 저장
        emailVerificationAppender.append(verifiedEmailVerification, verifiedEmailVerification.verificationExpiresAt!!)

        // 이메일 인증됨 이벤트 생성, 반환
        return emailVerificationEventCreator.onVerified(verifiedEmailVerification)
    }

    private fun getEmailVerification(command: EmailVerificationCommand): EmailVerification {
        log.info{ "이메일(email=${command.email.value})에 대응하는 이메일 인증을 조회합니다."}
        val emailVerification = emailVerificationFinder.findByEmailOrNull(command.email)

        if (emailVerification == null) {
            log.warn { "이메일(email=${command.email.value})에 대응하는 이메일 인증이 없습니다. 없거나, 만료됐습니다." }
            throw EmailVerificationNotFoundException(command.email.value)
        }
        log.info{ "이메일(email=${command.email.value})에 대응하는 이메일 인증을 찾았습니다. (codeCreatedAt=${emailVerification.codeCreatedAt},codeExpiresAt=${emailVerification.codeExpiresAt})"}
        return emailVerification
    }
}

 

프로세서에서는 실제 앞서 만들어진 명령을 처리합니다.

 

1. 이메일 인증 조회 : 이메일 인증을 조회하고(EmailVerificationFinder), 없으면 예외를 발생시킵니다.

 

2. 이메일 인증 처리 : 이메일 인증 처리기(EmailVerificationHandler) 에게 인증 처리를 위임합니다. 이때 이메일 인증에 관한 핵심 처리는 EmailVerificationHandler 에서 이루어지므로 이메일 인증의 실질적 처리 부분(코드가 잘못됐느니, 만료됐느니...)은 테스트할 필요는 없습니다.

 

3. 이메일 인증 저장 : 처리된 이메일 인증을 EmailVerificationAppender 를 통해 저장합니다. 이때 만료시각은 이메일 인증의 VerificationExpiresAt 을 따릅니다.

 

4. 이메일 인증됨 이벤트 생성 및 반환 : EmailVerificationEventCreator 를 통해 이메일 인증됨 이벤트를 생성하고 반환합니다.

 

package com.ttasjwi.board.system.member.application.exception

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus

class EmailVerificationNotFoundException(
    email: String,
) : CustomException(
    status = ErrorStatus.BAD_REQUEST,
    code = "Error.EmailVerificationNotFound",
    args = listOf(email),
    source = "emailVerification",
    debugMessage = "이메일 인증이 만료됐거나 인증이 존재하지 않습니다. 다시 인증 절차를 시작해주세요. (email=${email})"
)

 

참고로 여기서 발생하는 이메일 인증 조회 실패 예외는 이메일 인증이 없을 때 발생하는데,

 

정말 사용자가 인증을 안해서 발생했을 수도 있고, 인증을 했는데 만료되서 사라져서 발생했을 수도 있으므로 그런 의미를 모두 내포한 예외입니다.

 


3. 도메인 기능

3.1 EmailVerificationFinder/EmailVerificationAppender

package com.ttasjwi.board.system.member.domain.service

import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.EmailVerification

interface EmailVerificationFinder {
    fun findByEmailOrNull(email: Email): EmailVerification?
}
package com.ttasjwi.board.system.member.domain.service

import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import java.time.ZonedDateTime

interface EmailVerificationAppender {

    fun append(emailVerification: EmailVerification, expiresAt: ZonedDateTime)
    fun removeByEmail(email: Email)
}

 

이메일 인증 조회 / 수정,삭제,저장 을 담당하는 인터페이스입니다.

그런데 이 부분은 앞선 글에서 메모리 DB로 간단하게 구현해뒀죠. 그래서 구현 내용은 생략합니다.

3.2 EmailVerificationHandler

package com.ttasjwi.board.system.member.domain.service

import com.ttasjwi.board.system.member.domain.model.EmailVerification
import java.time.ZonedDateTime

interface EmailVerificationHandler {

    /**
     * code 를 통해 유효한 이메일 인증인 지 확인하고 유효하다면 이메일을 '인증처리'합니다.
     */
    fun codeVerify(
        emailVerification: EmailVerification,
        code: String,
        currentTime: ZonedDateTime
    ): EmailVerification

}

 

이메일인증 처리기입니다. 

EmailVerification 을 조작하거나, 검증하는 역할을 담당합니다.

 

codeVerify 메서드를 통해 code 검증, 현재 code가 유효한지 등을 종합적으로 검증하도록 할거에요.

 

package com.ttasjwi.board.system.member.domain.service.impl

import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import com.ttasjwi.board.system.member.domain.service.EmailVerificationHandler
import java.time.ZonedDateTime

@DomainService
internal class EmailVerificationHandlerImpl : EmailVerificationHandler {

    override fun codeVerify(emailVerification: EmailVerification, code: String, currentTime: ZonedDateTime): EmailVerification {
        return emailVerification.codeVerify(code, currentTime)
    }

}

 

구현체에서는 내부적으로 emailVerification.codeVerify 를 통해 이메일 인증 처리를 EmailVerification 자신에게 위임합니다.

class EmailVerification
internal constructor(
    val email: Email,
    val code: String,
    val codeCreatedAt: ZonedDateTime,
    val codeExpiresAt: ZonedDateTime,
    verifiedAt: ZonedDateTime? = null,
    verificationExpiresAt: ZonedDateTime? = null
) {

    var verifiedAt: ZonedDateTime? = verifiedAt
        private set

    var verificationExpiresAt: ZonedDateTime? = verificationExpiresAt
        private set


    companion object {

        private val log = getLogger(EmailVerification::class.java)

        internal const val VERIFICATION_VALIDITY_MINUTE = 30L
        
        // 생략
    }

    internal fun codeVerify(code: String, currentTime: ZonedDateTime): EmailVerification {
        if (currentTime >= this.codeExpiresAt) {
            log.warn { "이메일 인증이 만료됐습니다. (email=${email.value},expiredAt=${codeExpiresAt},currentTime=${currentTime}" }
            throw EmailVerificationExpiredException(email.value, codeExpiresAt, currentTime)
        }
        if (this.code != code) {
            log.warn { "잘못된 code 입니다." }
            throw InvalidEmailVerificationCodeException()
        }
        this.verifiedAt = currentTime
        this.verificationExpiresAt = currentTime.plusMinutes(VERIFICATION_VALIDITY_MINUTE)

        log.info { "이메일 인증 성공 (email=${email.value}" }
        return this
    }

}

 

codeVerify 메서드는 code가 만료됐는지, code 가 잘못됐는지 확인하고 verifiedAt / verificationExpiresAt 속성을 추가합니다. 이 속성이 추가되면 이메일 인증이 됐다는 뜻입니다. (이 과정에서 잘못된 부분이 있으면 예외를 발생시킵니다.)

 

verifiedAt 은 현재 시각(인증 시각), 그리고 verificationExpiresAt 은 인증 만료시각으로서 인증 시각으로부터 30분 뒤까지 유효하게 했습니다.

 

이 과정을 거친뒤 자기 자신을 반환하게 했습니다.

 

3.3 EmailVerificationEventCreator

package com.ttasjwi.board.system.member.domain.service

import com.ttasjwi.board.system.member.domain.event.EmailVerificationStartedEvent
import com.ttasjwi.board.system.member.domain.event.EmailVerifiedEvent
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import java.util.*

interface EmailVerificationEventCreator {

    fun onVerificationStarted(emailVerification: EmailVerification, locale: Locale): EmailVerificationStartedEvent
    fun onVerified(emailVerification: EmailVerification): EmailVerifiedEvent
}
@DomainService
internal class EmailVerificationEventCreatorImpl : EmailVerificationEventCreator {

    override fun onVerified(emailVerification: EmailVerification): EmailVerifiedEvent {
        return EmailVerifiedEvent.create(emailVerification)
    }
}
class EmailVerifiedEvent
internal constructor(
    email: String,
    verifiedAt: ZonedDateTime,
    verificationExpiresAt: ZonedDateTime,
) : DomainEvent<EmailVerifiedEvent.Data>(
    occurredAt = verifiedAt,
    data = Data(email, verifiedAt, verificationExpiresAt)
) {

    class Data(
        val email: String,
        val verifiedAt: ZonedDateTime,
        val verificationExpiresAt: ZonedDateTime,
    )

    companion object {

        internal fun create(emailVerification: EmailVerification): EmailVerifiedEvent {
            return EmailVerifiedEvent(
                email = emailVerification.email.value,
                verifiedAt = emailVerification.verifiedAt!!,
                verificationExpiresAt = emailVerification.verificationExpiresAt!!
            )
        }
    }
}

 

이메일 인증 이벤트 생성기입니다.

onVerificationStarted는 앞서 구현한 기능이고, onVerified 는 지금 새로 구현하는 기능입니다.

이메일 인증됨 이벤트를 생성합니다. 이메일값, 인증된 시각, 인증만료시각 정보를 담습니다.

 


4. 실행

 

실제 실행하여, 이메일 인증 시작 과정을 거치고(앞서 구현)

 

 

 

발급받은 코드를 기억해둡니다.

 

잘못된 이메일주소를 기입했을 경우 저장되어 있는 이메일 인증이 없으면 예외가 발생하고

 

잘못된 코드를 기입하면 처리에 실패하고

제대로 된 코드를 포함해서 요청을 보내면 인증에 성공합니다.


 

이렇게 이메일 인증 기능을 간단하게 구현해봤습니다.

 

레디스에 이메일 인증을 저장해야하는 기능은 아직 구현이 덜 됐고, 아직 우리가 모르는 문제 상황이 있을 수 있긴합니다.

다만 이 부분에 대해서는 커버리지 100%가 되도록 테스트코드를 충분히 작성해뒀기 때문에 추가적인 대응은 어렵지 않을 듯 해요. 

 

이어지는 글에서는 회원가입 기능을 구현해보겠습니다.


리포지토리

https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

관련 PR

이메일 인증 API 구현

https://github.com/ttasjwi/board-system/pull/41

 

Feature: (BRD-44) 이메일 인증 API 구현 by ttasjwi · Pull Request #41 · ttasjwi/board-system

JIRA 티켓 BRD-44 작업 내역 이메일 인증 기능 구현

github.com

 

이메일 인증 API - 요청 매핑 안 되는 문제 수정(요청 객체에 @RequestBody 안 걸어서 문제 생긴 부분 수정)

https://github.com/ttasjwi/board-system/pull/43

 

Fix: (BRD-72) 이메일 인증 API - 요청 매핑 안 되는 문제 수정 by ttasjwi · Pull Request #43 · ttasjwi/board-syst

JIRA 티켓 BRD-72 작업 내역 요청 Body 가 요청 객체에 매핑되지 않는 문제가 있음 컨트롤러 요청 객체 앞에 @RequestBody 를 안 붙여서 생긴 문제여서, 어노테이션을 새로 붙임 동일한 문제 방지 추후 이

github.com

 

Comments