땃쥐네

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

Project

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

ttasjwi 2024. 11. 13. 08:34

 


1. 개요

이메일 인증은 크게 두 가지 과정으로 구성됩니다.

 

  • 사용자가 이메일 주소를 우리 서버에게 보내면, 우리서버가 해당 이메일 주소로 인증울 위한 코드를 포함한 이메일을 발송하는 과정
  • 사용자가 이메일을 통해 받은 코드를 우리 서버에 전달하여, 이메일 인증을 마무리 하는 과정

이번 글에서는 1번째 기능을 구현해볼거에요.

 


2. 이메일 인증 시작 컨트롤러

package com.ttasjwi.board.system.member.api

import com.ttasjwi.board.system.core.api.SuccessResponse
import com.ttasjwi.board.system.core.locale.LocaleManager
import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.member.application.usecase.EmailVerificationStartRequest
import com.ttasjwi.board.system.member.application.usecase.EmailVerificationStartResult
import com.ttasjwi.board.system.member.application.usecase.EmailVerificationStartUseCase
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import java.time.ZonedDateTime

@RestController
class EmailVerificationStartController(
    private val useCase: EmailVerificationStartUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) {

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

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

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

    private fun makeResponse(result: EmailVerificationStartResult): SuccessResponse<EmailVerificationStartResponse> {
        val code = "EmailVerificationStart.Complete"
        val locale = localeManager.getCurrentLocale()
        return SuccessResponse(
            code = code,
            message = messageResolver.resolve("$code.message", locale),
            description = messageResolver.resolve("$code.description", locale),
            data = EmailVerificationStartResponse(
                EmailVerificationStartResponse.EmailVerificationStartResult(
                    email = result.email,
                    codeExpiresAt = result.codeExpiresAt
                )
            )
        )
    }
}

data class EmailVerificationStartResponse(
    val emailVerificationStartResult: EmailVerificationStartResult
) {

    data class EmailVerificationStartResult(
        val email: String,
        val codeExpiresAt: ZonedDateTime,
    )
}

 

현재 구현된 이메일 인증 시작 API 입니다.

 

1. 유즈케이스에 요청을 위임하고

2. 1의 처리결과를 가공하여 사용자에게 응답해줍니다.

 

이전에 구현한 글과 거의 동일합니다. 다만 여기서 MessageResolver에게 전부 맡겼던 메시지/국제화 처리를 LocaleManager로 나눈 부분은 기존 코드에서 조금 제가 수정을 했습니다.

 

@Component
internal class MessageResolverImpl(
    @Qualifier("generalMessageSource")
    private val generalMessageSource: MessageSource,

    @Qualifier("errorMessageSource")
    private val errorMessageSource: MessageSource,
) : MessageResolver {

    companion object {
        private const val ERROR_MESSAGE_PREFIX = "Error."
    }

    override fun resolveMessage(code: String): String {
        val messageSource = selectMessageSource(code)
        return messageSource.getMessage("$code.message", null, LocaleContextHolder.getLocale())
    }

    override fun resolveDescription(code: String, args: List<Any?>): String {
        val messageSource = selectMessageSource(code)
        return messageSource.getMessage("$code.description", args.toTypedArray(), LocaleContextHolder.getLocale())
    }

    private fun selectMessageSource(code: String): MessageSource {
        return if (code.startsWith(ERROR_MESSAGE_PREFIX)) {
            errorMessageSource
        } else {
            generalMessageSource
        }
    }
}

 

기존 MessageResolverImpl 은 Message, Description 을 획득하는 메서드 두개를 제공하고 사용자의 로케일을 LocaleContextHolder를 통해서만 획득하고 있었습니다.

 

 

이 방식은 서블릿 기반의 스프링 웹 애플리케이션에서는 유효한 방식입니다만, 스프링 MVC를 거치지 않는 경우 로케일을 제대로 획득할 수 없는 문제가 존재합니다.

 

그리고 메시지 획득을 code.message, code.description 만 획득할 수 있어서 한계가 있었어요.

 

@Component
internal class MessageResolverImpl(
    @Qualifier("generalMessageSource")
    private val generalMessageSource: MessageSource,

    @Qualifier("errorMessageSource")
    private val errorMessageSource: MessageSource,
) : MessageResolver {

    companion object {
        private const val ERROR_MESSAGE_PREFIX = "Error."
    }

    override fun resolve(code: String, locale: Locale, args: List<Any?>): String {
        val messageSource = selectMessageSource(code)
        return messageSource.getMessage(code, args.toTypedArray(), locale)
    }

    private fun selectMessageSource(code: String): MessageSource {
        return if (code.startsWith(ERROR_MESSAGE_PREFIX)) {
            errorMessageSource
        } else {
            generalMessageSource
        }
    }
}

 

수정한 MessageResolver 에서는 resolve 메서드 하나만 열어두고, 이 인터페이스를 사용하는 사용자가 구체적인 코드와 로케일을 직접 전달해야합니다.

 

이렇게 하면 사용하는 측에서는 구체코드를 좀 더 자세하게 지정해야하긴 하지만, 좀 더 다양한 메시지코드를 관리할 수 있게 됐고, 번거롭긴하지만 로케일을 결정하는 방법도 사용자가 재량껏 결정할 수 있는 자유가 생겨요.

 

package com.ttasjwi.board.system.core.locale

import java.util.*

interface LocaleManager {
    fun getCurrentLocale(): Locale
}
package com.ttasjwi.board.system.core.locale

import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.stereotype.Component
import java.util.*

@Component
class LocaleManagerImpl : LocaleManager {

    override fun getCurrentLocale(): Locale {
        return LocaleContextHolder.getLocale()
    }
}

 

그리고 로케일 획득처리를 위한 LocaleManager 인터페이스를 또 하나 두었습니다. 다만 이 LocaleManager 는 LocaleContextHolder를 통해 로케일을 얻어오는 것에서 알 수 있듯, Spring MVC 환경에서만 쓸 수 있습니다.

 

이 LocaleManager 와 MessageResolver 를 통해 로케일/메시지 처리를 하도록 변경한 커밋 내역은 PR 링크를 드릴테니 해당 내용을 살펴보시면 될 듯 합니다.

 

이 인터페이스를 도입하게 된 이유는 이후 설명할 이벤트 구독자측의 로케일 처리 문제부분에서 다시 이야기할게요.


3. 애플리케이션 계층 구현

3.1 유즈케이스

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

import java.time.ZonedDateTime

interface EmailVerificationStartUseCase {

    /**
     * 이메일 검증 절차를 시작합니다.
     */
    fun startEmailVerification(request: EmailVerificationStartRequest): EmailVerificationStartResult
}

data class EmailVerificationStartRequest(
    val email: String?,
)

data class EmailVerificationStartResult(
    val email: String,
    val codeExpiresAt: ZonedDateTime,
)

 

애플리케이션 계층 진입지점인 유즈케이스입니다. 컨트롤러에서는 이 유즈케이스를 호출하되, 실제 구현체는 모릅니다.

(실제 컨트롤러 테스트 코드도, 유즈케이스를 간단하게 Fixture로 구현해서 테스트했어요.)

 

3.2 애플리케이션 서비스

@ApplicationService
internal class EmailVerificationStartApplicationService(
    private val commandMapper: EmailVerificationStartCommandMapper,
    private val processor: EmailVerificationStartProcessor,
    private val transactionRunner: TransactionRunner,
    private val eventPublisher: EmailVerificationStartedEventPublisher
) : EmailVerificationStartUseCase {

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

    override fun startEmailVerification(request: EmailVerificationStartRequest): EmailVerificationStartResult {
        log.info{ "이메일 인증 시작 요청을 받았습니다."}

        // 유효성 검사를 거쳐서 명령으로 변환
        val command = commandMapper.mapToCommand(request)

        // 프로세서에 위임
        val event = transactionRunner.run {
            processor.startEmailVerification(command)
        }

        // 이벤트 발행
        eventPublisher.publishEvent(event)

        log.info{ "이메일 인증 시작됨 (email = ${event.data.email}"}

        // 처리 결과로 가공, 반환
        return makeResult(event)
    }

    private fun makeResult(event: EmailVerificationStartedEvent): EmailVerificationStartResult {
        return EmailVerificationStartResult(
            email = event.data.email,
            codeExpiresAt = event.data.codeExpiresAt,
        )
    }
}

 

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

 

1. CommandMapper 를 통해 요청을 애플리케이션 명령으로 변환합니다. (여기서 유효성 검사가 함께 일어납니다.)

2. Processor를 통해 명령 처리를 위임하고 그 결과인 Event를 받습니다.(TransactionRunner를 통해 Processor 실행 콜백을 전달해, 트랜잭션에서 실행되도록 합니다.)

3. EvenPublisher 를 통해 Event를 발행합니다.

4. makeResult를 통해 Event를 Result로 가공합니다.

5. Result를 반환합니다.

 

3.3 Command / CommandMapper

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

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

internal data class EmailVerificationStartCommand(
    val email: Email,
    val currenTime: ZonedDateTime,
    val locale: Locale,
)

 

이메일 인증 시작 애플리케이션 명령입니다.

Request 의 Email 은 단순한 원시값이지만 Email 은 우리 도메인 생성 규칙을 거쳐서 생성된 도메인 값객체라는 차이가 있습니다. 

 

그 외에도 현재 시각 정보, 사용자 로케일 정보를 함께 담도록 정의 했어요.

@ApplicationCommandMapper
internal class EmailVerificationStartCommandMapper(
    private val emailCreator: EmailCreator,
    private val localeManager: LocaleManager,
    private val timeManager: TimeManager,
) {

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

    fun mapToCommand(request: EmailVerificationStartRequest): EmailVerificationStartCommand {
        log.info { "요청이 유효한지 확인합니다." }

        if (request.email == null) {
            val e = NullArgumentException("email")
            log.warn(e)
            throw e
        }
        val email = emailCreator.create(request.email).getOrElse {
            log.warn(it)
            throw it
        }
        log.info { "입력값이 유효합니다. (email = ${request.email})" }

        return EmailVerificationStartCommand(
            email = email,
            currenTime = timeManager.now(),
            locale = localeManager.getCurrentLocale(),
        )
    }

}

 

CommandMapper 에서는 요청의 유효성 검사를 수행합니다.

필수값이 null 인지, EmailCreator를 통해 이메일 도메인 객체를 생성 시도하고 생성에 실패했다면 생성 실패 예외를 터트립니다. (여기서 EmailCreator는 이메일 유효성 검증 API 구현 과정에서 구현됐고 픽스쳐도 구현됐기 때문에 추가적으로 별도의 코드를  작성하지 않고 재사용했습니다.)

 

Command로 만들어졌다면 Command 안의 값은 일단 입력값의 유효성이 보장됐다는 것을 의미합니다.

 

3.4 Processor

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.EmailVerificationStartCommand
import com.ttasjwi.board.system.member.domain.event.EmailVerificationStartedEvent
import com.ttasjwi.board.system.member.domain.service.EmailVerificationAppender
import com.ttasjwi.board.system.member.domain.service.EmailVerificationCreator
import com.ttasjwi.board.system.member.domain.service.EmailVerificationEventCreator

@ApplicationProcessor
internal class EmailVerificationStartProcessor(
    private val emailVerificationCreator: EmailVerificationCreator,
    private val emailVerificationAppender: EmailVerificationAppender,
    private val emailVerificationEventCreator: EmailVerificationEventCreator,
) {

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

    fun startEmailVerification(command: EmailVerificationStartCommand): EmailVerificationStartedEvent {
        log.info{ "이메일 인증을 시작합니다" }

        val emailVerification = emailVerificationCreator.create(command.email, command.currenTime)

        emailVerificationAppender.append(
            emailVerification = emailVerification,
            expiresAt = emailVerification.codeExpiresAt
        )

        val event = emailVerificationEventCreator.onVerificationStarted(emailVerification, command.locale)

        log.info{ "'이메일 인증됨' 이벤트 생성됨.(email=${event.data.email}"}

        return event
    }
}

 

Processor 는 Command 를 받아서 실제 이메일 인증 시작 명령을 처리합니다.

입력값 유효성 검사는 Command가 되면서 마무리됐다는 전제하에 기능을 구현해요.

 

1. EmailVerificationCreator(이메일인증 생성기) : 이메일 인증을 생성합니다.

2. EmailVerificationAppender(이메일인증 저장/변경/삭제기): 이메일 인증을 지정시간(코드 만료시간)까지 저장합니다.

3. EmailVerificationEventCreator(이메일 인증 이벤트 생성기): 이메일 인증 시작됨 이벤트를 생성합니다.

4. '이메일 인증 시작됨'이벤트를 그대로 반환합니다.


4. 도메인 기능들

4.1 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 CODE_LENGTH = 6
        internal const val CODE_VALIDITY_MINUTE = 5L
        internal const val VERIFICATION_VALIDITY_MINUTE = 30L

        fun restore(
            email: String,
            code: String,
            codeCreatedAt: ZonedDateTime,
            codeExpiresAt: ZonedDateTime,
            verifiedAt: ZonedDateTime?,
            verificationExpiresAt: ZonedDateTime?
        ): EmailVerification {
            return EmailVerification(
                email = Email.restore(email),
                code = code,
                codeCreatedAt = codeCreatedAt,
                codeExpiresAt = codeExpiresAt,
                verifiedAt = verifiedAt,
                verificationExpiresAt = verificationExpiresAt
            )
        }

        internal fun create(email: Email, currentTime: ZonedDateTime): EmailVerification {
            return EmailVerification(
                email = email,
                code = UUID.randomUUID().toString().substring(startIndex = 0, endIndex = CODE_LENGTH),
                codeCreatedAt = currentTime,
                codeExpiresAt = currentTime.plusMinutes(CODE_VALIDITY_MINUTE)
            )
        }
    }


}

 

이메일 인증 도메인입니다.

내부적으로 email, code 정보를 가지고 있으면서

codeCreatedAt(code 생성 시점), codeExpiresAt(code 만료시점), verifiedAt(인증시점), verificationExpiresAt(인증만료시점) 4개의 값을 가집니다.

 

최초 생성시점에는 codeCreatedAt, codeExpiresAt 만 가지고 있다가

인증 시점에는 verifiedAt, verificationExpiresAt 정보를 추가해서 가지게 할 예정이에요.

 

최초 이메일 인증 생성은 create 를 통해 할 수 있고 이것은 도메인 서비스 EmailVerificationCreator를 통해서만 호출되도록 하기 위해 internal 접근제어자를 붙였습니다. 여기서는 이메일 인증의 코드를 UUID를 통해 랜덤한 6글자를 사용하게했고, 코드 만료시간은 현재시간 기준 5분 뒤로 잡았습니다.

 

저장소에서 저장된 데이터를 이메일 인증 객체로 복원하기 위한 restore 메서드도 준비했습니다.

 

4.2 EmailVerificationCreator

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 EmailVerificationCreator {

    fun create(
        email: Email,
        currentTime: ZonedDateTime,
    ): EmailVerification
}
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.Email
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import com.ttasjwi.board.system.member.domain.service.EmailVerificationCreator
import java.time.ZonedDateTime

@DomainService
internal class EmailVerificationCreatorImpl : EmailVerificationCreator {

    override fun create(email: Email, currentTime: ZonedDateTime): EmailVerification {
        return EmailVerification.create(email, currentTime)
    }
}

 

이메일 인증 생성기입니다. EmailVerification.create 를 통해, 도메인 객체 생성을 도메인 모델에게 위임해요.

 

4.3 EmailVerificationAppender/EmailVerificationFinder

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

 

이메일 인증 저장, 조회를 담당하는 Appender, Finder 입니다.

 

이메일 인증이라는 정보는 영구적으로 저장되는 것이 아닌, 일정시간동안만 유효해야하는 정보다보니 TTL(Time To Live) 기능이 지원되는 저장소가 필요한데요.

이를 위해 저는 Redis 를 쓰기로 했습니다.

 

 

External-Redis 모듈을 만들어두고 여기에 구현을 해야하는데

Redis 접근 설정은 이번 글에서 다루지 않고 이후 글에서 다루도록 하겠습니다.

 

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

import com.ttasjwi.board.system.core.annotation.component.AppComponent
import com.ttasjwi.board.system.member.domain.model.Email
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.EmailVerificationFinder
import java.time.ZonedDateTime
import java.util.concurrent.ConcurrentHashMap

@AppComponent
internal class EmailVerificationStorage : EmailVerificationAppender, EmailVerificationFinder {

    private val store: MutableMap<Email, EmailVerification> = ConcurrentHashMap()

    override fun append(emailVerification: EmailVerification, expiresAt: ZonedDateTime) {
        store[emailVerification.email] = emailVerification
    }

    override fun removeByEmail(email: Email) {
        store.remove(email)
    }

    override fun findByEmailOrNull(email: Email): EmailVerification? {
        return store[email]
    }
}

 

TTL 기능은 지원하지는 않지만 일단 저장/조회/삭제 기능은 작동하는 메모리 저장소를 구현체로 작성해뒀습니다.

 

4.4 EmailVerificationEventCreator

@DomainService
internal class EmailVerificationEventCreatorImpl : EmailVerificationEventCreator {

    override fun onVerificationStarted(
        emailVerification: EmailVerification,
        locale: Locale
    ): EmailVerificationStartedEvent {
        return EmailVerificationStartedEvent.create(emailVerification, locale)
    }
    
    // 생략
}
class EmailVerificationStartedEvent
internal constructor(
    email: String,
    code: String,
    codeCreatedAt: ZonedDateTime,
    codeExpiresAt: ZonedDateTime,
    locale: Locale,
) : DomainEvent<EmailVerificationStartedEvent.Data>(
    occurredAt = codeCreatedAt,
    data = Data(email, code, codeCreatedAt, codeExpiresAt, locale)
) {

    class Data(
        val email: String,
        val code: String,
        val codeCreatedAt: ZonedDateTime,
        val codeExpiresAt: ZonedDateTime,
        val locale: Locale
    )

    companion object {

        internal fun create(emailVerification: EmailVerification, locale: Locale): EmailVerificationStartedEvent {
            return EmailVerificationStartedEvent(
                email = emailVerification.email.value,
                code = emailVerification.code,
                codeCreatedAt = emailVerification.codeCreatedAt,
                codeExpiresAt = emailVerification.codeExpiresAt,
                locale = locale
            )
        }
    }
}

 

EmailVerificationCreator 에서는 내부적으로 EmailVerificationEvent.create 를 호출해 이메일 인증 시작됨 이벤트를 생성합니다.

 

4.5 EmailVerificationEventStartedEventPublisher

package com.ttasjwi.board.system.core.domain.event

interface DomainEventPublisher<T: DomainEvent<*>> {
    fun publishEvent(event: T)
}
package com.ttasjwi.board.system.member.domain.service

import com.ttasjwi.board.system.core.domain.event.DomainEventPublisher
import com.ttasjwi.board.system.member.domain.event.EmailVerificationStartedEvent

interface EmailVerificationStartedEventPublisher : DomainEventPublisher<EmailVerificationStartedEvent>

 

EventPublisher 는 이메일을 발행하는 역할입니다.

 

 

이벤트 발행 처리를 위해 event-publisher 모듈을 두고

dependencies {
    implementation(Dependencies.SPRING_BOOT_STARTER.fullName)
    implementation(project(":board-system-domain:domain-member"))
    implementation(project(":board-system-domain:domain-core"))

    testImplementation(testFixtures(project(":board-system-domain:domain-core")))
    testImplementation(testFixtures(project(":board-system-domain:domain-member")))
}
@Component
class EmailVerificationStartedEventPublisherImpl(
    private val applicationEventPublisher: ApplicationEventPublisher
) : EmailVerificationStartedEventPublisher {

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

    override fun publishEvent(event: EmailVerificationStartedEvent) {
        applicationEventPublisher.publishEvent(event)
        log.info{ "이메일 인증 시작됨 내부이벤트 발행" }
    }
}

 

구현은 Spring 의 ApplicationEventPublisher를 통해 애플리케이션 내부 이벤트 발행 형식으로 처리했어요.

다만 지금 방식은 애플리케이션 내부에서만 유효한 이벤트를 발행하는 방식이고,

 

서비스가 분산된 서비스 형태로 관리되거나, 자주 새로운 서비스 배포가 일어나는 환경이라면 이 방식은 좋지 못 합니다.

같은 서비스가 3대로 복제되어 배포되고 있었는데, 이벤트 발행만 한 상태로 애플리케이션이 죽어버리면 이벤트 처리가 되지 못 하는 문제가 생기거든요.

 

이런 방식의 이벤트 처리에 있어서는 Kafka 와 같은 별도의 외부 메시지 저장시스템을 사용하면 좋을 듯 한데, 지금은 제가 학습 곡선이 필요해서 애플리케이션 내부 이벤트 발행 형식으로 처리했어요.

 


5. EmailVerificationStaratedEventCosumer(이벤트 구독자, 소비자)

 

event-consumer-member모듈은 회원 관련 이벤트 구독을 담당하는 모듈입니다.

dependencies {
    implementation(Dependencies.SPRING_BOOT_STARTER.fullName)
    implementation(project(":board-system-application:application-member"))
    implementation(project(":board-system-domain:domain-member"))
    implementation(project(":board-system-domain:domain-core"))

    testImplementation(testFixtures(project(":board-system-application:application-member")))
    testImplementation(testFixtures(project(":board-system-domain:domain-member")))
}
package com.ttasjwi.board.system.member.event.consumer

import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.member.application.usecase.EmailSendUseCase
import com.ttasjwi.board.system.member.domain.event.EmailVerificationStartedEvent
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component

@Component
class EmailVerificationStartedEventConsumer(
    private val emailSendUseCase: EmailSendUseCase,
    private val messageResolver: MessageResolver,
) {

    @Async
    @EventListener(EmailVerificationStartedEvent::class)
    fun handleEmailVerificationStartedEvent(event: EmailVerificationStartedEvent) {
        emailSendUseCase.sendEmail(
            address = event.data.email,
            subject = messageResolver.resolve("EmailVerification.EmailSubject", event.data.locale),
            content = messageResolver.resolve("EmailVerification.EmailContent", event.data.locale, listOf(event.data.code)),
        )
    }
}

 

이메일 인증 시작됨 이벤트를 구독하고, 이 이벤트를 비동기적으로 처리하는 것을 담당해요.

 

EmailSendUseCase 를 통해 이메일 전송 처리를 위임합니다.

 

실제 이벤트 구독 시점은 발행시점 이후이긴 한데, 비동기적으로 진행되기 때문에 이벤트를 발행한 측인 Spring MVC 쪽에서는 이벤트 발행만 마무리 짓고 바로 응답을 작성하게 돼요. 이렇게 하면 이메일이 실제로 발송되기까지 기다리지 않고 MVC측 요청은 끝낼 수 있는 장점이 있죠. 별도의 스레드에서 이메일 발송 처리를 비동기적으로 하면 되니까요.

 

여기서 messageResolver를 통해 회원가입 이메일의 제목,  본문에 대한 메시지 처리를 수행하는데요.

로케일 정보를 Event 에서 꺼내와서 지정했습니다.

이벤트 구독자 스레드 입장에서는 Spring MVC와 다른 스레드이므로, 로케일 관련 정보가 없다보니 이벤트를 통해 전달받은 로케일을 전달하는 방식으로 로케일을 처리해야합니다.

 

 

@EnableAsync
@ConfigurationPropertiesScan
@ComponentScan(
    includeFilters = [ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = [
            AppComponent::class,
        ]
    )]
)
@SpringBootApplication
class Main

fun main(args: Array<String>) {
    runApplication<Main>(*args)
}

 

그리고 여기서 @Async 를 활성화하기 위해서, Main 클래스 위에 @EnableAsync 를 걸었습니다.


6. 이메일 발송 기능

6.1 이메일 발송 유즈케이스

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

interface EmailSendUseCase {
    fun sendEmail(address: String, subject: String, content: String)
}

 

이메일 발송 기능의 진입점은 다시 application-member 모듈의 EmailSendUseCase에 있습니다.

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

import com.ttasjwi.board.system.core.annotation.component.ApplicationService
import com.ttasjwi.board.system.member.application.usecase.EmailSendUseCase
import com.ttasjwi.board.system.member.domain.service.EmailCreator
import com.ttasjwi.board.system.member.domain.service.EmailSender

@ApplicationService
internal class EmailSendApplicationService(
    private val emailCreator: EmailCreator,
    private val emailSender: EmailSender,
) : EmailSendUseCase {

    override fun sendEmail(address: String, subject: String, content: String) {
        val email = emailCreator.create(address).getOrThrow()
        emailSender.send(email, subject, content)
    }
}

 

여기서는 EmailCreator를 통해 다시 email 을 도메인 객체로 변환하고, EmailSender를 통해 이메일 발송 처리를 위임합니다.

 

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

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

interface EmailSender {

    fun send(
        address: Email,
        subject: String,
        content: String
    )
}

 

EmailSender는 도메인 서비스에 위치한 인터페이스입니다.

 

6.2 EmailSender 구현

 

external-email-sender 모듈에서 이메일 발송을 구현했습니다.

    SPRING_BOOT_MAIL(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-mail"),
dependencies {
    implementation(Dependencies.SPRING_BOOT_MAIL.fullName)
    implementation(project(":board-system-domain:domain-member"))
    implementation(project(":board-system-domain:domain-core"))

    testImplementation(testFixtures(project(":board-system-domain:domain-member")))
    testImplementation(testFixtures(project(":board-system-domain:domain-core")))
}

 

의존성으로 spring-boot-starter-email 을 사용할거에요.

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

import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.service.EmailSender
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.stereotype.Component

@Component
class EmailSenderImpl(
    private val javaMailSender: JavaMailSender
) : EmailSender {

    override fun send(address: Email, subject: String, content: String) {
        val message = SimpleMailMessage()
        message.subject = subject
        message.setTo(address.value)
        message.text = content

        javaMailSender.send(message)
    }
}

 

JavaMailSender 는 spring-boot-starter-email 을 의존성 등록하면 스프링부트가 자동구성을 통해 등록해주는데요.

이를 통해 이메일 발송 처리를 하면 돼요.

 

그런데 이렇게까지 하면 안 되고, 우리의 이메일 발송 명령을 받아서 대신 이메일을 발송해주고, 사용자가 읽고 삭제할 때까지 이메일 데이터를 관리해주는 역할을 하는 무언가가 필요해요. 여기서는 google의 서비스를 사용할거에요.

 

 

그리고 여기서 계정에 들어가서

 

 

보안에 들어가시고

 

2단계 인증을 활성하고 > 페이지로 들어갑니다.

 

 

앱 비밀번호를 활성화시켜야합니다.


앱을 선언하고(이름은 상관없습니다.) 만들어서, 앱 비밀번호를 발급받습니다.

 

gmail 페이지에 들어가서 우측 상단에 보면 톱니바퀴가 있습니다. 여기서 모든 설정 보기를 클릭하고 "전달 및 POP/IMAP" 을 선택합니다.

 

 

IMAP 사용을 클릭하고, 변경사항 저장을 클릭합니다.

이렇게 해서 향후 사용자는, 우리가 발송한 메일을 구글 서버에서 접근할 수 있어요.

 

 

혹시 이런 인증이 필요하다고 뜨면, 인증처리도 마저 합니다.

spring:
  config:
    activate:
      on-profile: productionSecret
  mail:
    host: smtp.gmail.com
    port: 587
    username: 이메일주소
    password: [smtp password]
    properties:
      mail:
        smtp:
          auth: true
          timeout: 5000
          starttls:
            enable: true

 

설정파일입니다.

 

spring.mail.username 에는 이메일 주소를 기입하고

password 에는 아까 얻은 앱 비밀번호를 기입합니다.

이렇게 하면 이메일 발송 처리를 위한 준비는 끝.

 

당연하지만 이 파일은 .gitignore 처리 해서 공개적으로 올라가게 해선 안 됩니다.


7. 메시지 처리

 

메시지/국제화 파일에 메시지를 새로 추가해야합니다.

EmailVerificationStart:
  Complete:
    message: "이메일인증 시작됨"
    description: "이메일 인증이 시작됐습니다. 귀하의 이메일로 보내진 코드를 참고하세요."

EmailVerification:
  Complete:
    message: "이메일인증 완료됨"
    description: "이메일 인증을 완료했습니다."
  EmailSubject: "이메일인증 코드입니다"
  EmailContent: "이메일인증 코드: {0}"

 

일반 메시지 파일에는, EmailVerification.Complete 관련 코드와, 사용자에게 발송할 이메일 제목/내용에 대응하는 코드를 추가했습니다. (영어 메시지파일은 생략합니다.)


8. 실행

EmailVerificationStartProcessor : 이메일 인증을 시작합니다
EmailVerificationStartProcessor : '이메일 인증됨' 이벤트 생성됨.(email=ttasjwi920@gmail.com
p6spy                                    : 
Execute Command : 

	commit

----------------------------------------------------------------------------------------------------
ailVerificationStartedEventPublisherImpl : 이메일 인증 시작됨 내부이벤트 발행
EmailVerificationStartApplicationService : 이메일 인증 시작됨 (email = ttasjwi920@gmail.com

 

실제 실행해보면 성공 응답이 오고, 로그를 확인해보면 이벤트가 발행되고 이메일 인증이 시작된 것을 볼 수 있어요.

(이메일 발송부에서 로그를 찍어놨어야했는데 안 했네요... 이후 추가해야겠습니다.)

 

이메일코드도 옵니다!


 

 

이렇게 해서 이메일 인증 시작 기능(이메일 발송기능)을 구현했습니다.

다만 레디스를 사용해서 이메일 인증을 저장하는 부분은 나중의 글에서 서술하겠습니다.

 

이어지는 글에서는 이메일 인증을 실제로 구현해보겠습니다.

 

읽어주셔서 감사합니다.

 


리포지토리

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

 

메시지/로케일 획득 인터페이스 분리

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

 

Refactor: (BRD-70) 메시지/로케일 획득 인터페이스 분리 by ttasjwi · Pull Request #38 · ttasjwi/board-system

JIRA 티켓 BRD-70 배경 package com.ttasjwi.board.system.core.message interface MessageResolver { /** * code 에 대응하는 메시지를 찾습니다. */ fun resolveMessage(code: String): String /** ...

github.com

 

메시지 획득방법 일원화

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

 

Refactor: (BRD-71) 메시지 획득 방법 일원화 by ttasjwi · Pull Request #40 · ttasjwi/board-system

JIRA 티켓 BRD-71 작업 내역 package com.ttasjwi.board.system.core.message import java.util.Locale interface MessageResolver { /** * code 에 대응하는 메시지를 찾습니다. */ fun resolveMessage(code...

github.com

 

이메일 인증 시작 API 구현

 

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

 

Feature: (BRD-43) 이메일 인증 시작 API 구현 by ttasjwi · Pull Request #39 · ttasjwi/board-system

JIRA 티켓 BRD-43 구현 기능 이메일 인증 시작 API, 애플리케이션 서비스, 도메인 기능구현(단, 이메일 인증은 메모리에 저장하는 방식으로 구현) 이후 Redis 연동방식으로 이메일 인증 저장기능 구현

github.com

 

Comments