땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 16. 회원가입 API 구현 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 16. 회원가입 API 구현

ttasjwi 2024. 11. 13. 11:16

이메일 인증 기능을 구현했으므로, 이어서 회원가입 기능을 구현해보겠습니다.


1. 표현 계층(Api 모듈)

@RestController
class RegisterMemberController(
    private val useCase: RegisterMemberUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) {

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

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

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

    private fun makeResponse(result: RegisterMemberResult): SuccessResponse<RegisterMemberResponse> {
        val code = "RegisterMember.Complete"
        val locale = localeManager.getCurrentLocale()
        return SuccessResponse(
            code = code,
            message = messageResolver.resolve("$code.message", locale),
            description = messageResolver.resolve("$code.description", locale),
            data = RegisterMemberResponse(
                registeredMember = RegisterMemberResponse.RegisteredMember(
                    memberId = result.memberId,
                    email = result.email,
                    username = result.username,
                    nickname = result.nickname,
                    role = result.role,
                    registeredAt = result.registeredAt
                )
            )
        )
    }
}

data class RegisterMemberResponse(
    val registeredMember: RegisteredMember
) {

    data class RegisteredMember(
        val memberId: Long,
        val email: String,
        val username: String,
        val nickname: String,
        val role: String,
        val registeredAt: ZonedDateTime,
    )
}

 

기존 방식과 큰 차이는 없습니다.

유즈케이스에 위임하여 요청을 처리하고, 결과를 받아서 응답 메시지를 가공해 반환합니다.

 

(+다시 보니 상태코드 200보다는 201 Created가 맞을 것 같은데, 이 부분은 따로 수정할게요.)


2. 애플리케이션 계층

2.1 유즈케이스

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

import java.time.ZonedDateTime

interface RegisterMemberUseCase {

    /**
     * 회원 가입을 수행합니다.
     */
    fun register(request: RegisterMemberRequest): RegisterMemberResult
}

data class RegisterMemberRequest(
    val email: String?,
    val password: String?,
    val username: String?,
    val nickname: String?,
)

data class RegisterMemberResult(
    val memberId: Long,
    val email: String,
    val username: String,
    val nickname: String,
    val role: String,
    val registeredAt: ZonedDateTime,
)

 

요청을 받아서 결과를 가공해 반환하는 유즈케이스입니다.

 

2.2 애플리케이션 서비스

@ApplicationService
internal class RegisterMemberApplicationService(
    private val commandMapper: RegisterMemberCommandMapper,
    private val processor: RegisterMemberProcessor,
    private val transactionRunner: TransactionRunner
) : RegisterMemberUseCase {

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


    override fun register(request: RegisterMemberRequest): RegisterMemberResult {
        log.info { "회원 가입을 시작합니다." }

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

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

        log.info { "회원가입 됨(id=${event.data.memberId},email = ${event.data.email})" }

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

    private fun makeResult(event: MemberRegisteredEvent): RegisterMemberResult {
        return RegisterMemberResult(
            memberId = event.data.memberId,
            email = event.data.email,
            username = event.data.username,
            nickname = event.data.nickname,
            role = event.data.roleName,
            registeredAt = event.data.registeredAt,
        )
    }

}

 

유즈케이스의 구현체, 애플리케이션 서비스 역시 기존 흐름과 같습니다.

 

커맨드 매퍼를 통해 명령을 만들고, Processor 를 통해 명령을 처리하고, 처리 결과 만들어진 이벤트를 가공하여 반환하는 구조입니다.

 

2.3 커맨드 / 커맨드 매퍼

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

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

internal data class RegisterMemberCommand(
    val email: Email,
    val rawPassword: RawPassword,
    val username: Username,
    val nickname: Nickname,
    val currentTime: ZonedDateTime,
)
@ApplicationCommandMapper
internal class RegisterMemberCommandMapper(
    private val emailCreator: EmailCreator,
    private val passwordManager: PasswordManager,
    private val usernameCreator: UsernameCreator,
    private val nicknameCreator: NicknameCreator,
    private val timeManager: TimeManager,
) {
    companion object {
        private val log = getLogger(RegisterMemberCommandMapper::class.java)
    }

    fun mapToCommand(request: RegisterMemberRequest): RegisterMemberCommand {
        log.info { "요청 입력값이 유효한 지 확인합니다." }
        val exceptionCollector = ValidationExceptionCollector()

        val email = getEmail(request.email, exceptionCollector)
        val rawPassword = getRawPassword(request.password, exceptionCollector)
        val username = getUsername(request.username, exceptionCollector)
        val nickname = getNickname(request.nickname, exceptionCollector)

        exceptionCollector.throwIfNotEmpty()

        log.info { "요청 입력값들은 유효합니다." }

        return RegisterMemberCommand(
            email = email!!,
            rawPassword = rawPassword!!,
            username = username!!,
            nickname = nickname!!,
            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(it)
                exceptionCollector.addCustomExceptionOrThrow(it)
                return null
            }
    }
    
    // 생략
}

 

커맨드 매퍼에서는

요청 입력값의 누락 여부를 확인하고 요청값들을 email, password, username, nickname 도메인으로 변환합니다.

 

2.4 프로세서

@ApplicationProcessor
internal class RegisterMemberProcessor(
    private val memberFinder: MemberFinder,
    private val emailVerificationFinder: EmailVerificationFinder,
    private val emailVerificationHandler: EmailVerificationHandler,
    private val emailVerificationAppender: EmailVerificationAppender,
    private val memberCreator: MemberCreator,
    private val memberAppender: MemberAppender,
    private val memberEventCreator: MemberEventCreator,
) {

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

    fun register(command: RegisterMemberCommand): MemberRegisteredEvent {
        checkDuplicate(command)
        checkEmailVerificationAndRemove(command)

        val member = createMember(command)

        val savedMember = memberAppender.save(member)
        log.info { "회원 생성 및 저장됨 (memberId=${savedMember.id})" }

        val event = memberEventCreator.onMemberRegistered(savedMember)
        return event
    }

    private fun checkDuplicate(command: RegisterMemberCommand) {
        log.info { "중복되는 회원이 있는 지 확인합니다. " }
        val ex: CustomException
        if (memberFinder.existsByEmail(command.email)) {
            ex = DuplicateMemberEmailException(command.email.value)
            log.warn(ex)
            throw ex
        }
        if (memberFinder.existsByUsername(command.username)) {
            ex = DuplicateMemberUsernameException(command.username.value)
            log.warn(ex)
            throw ex
        }
        if (memberFinder.existsByNickname(command.nickname)) {
            ex = DuplicateMemberNicknameException(command.nickname.value)
            log.warn(ex)
            throw ex
        }
        log.info { "중복되는 회원이 없습니다." }
    }

    private fun checkEmailVerificationAndRemove(command: RegisterMemberCommand) {
        log.info { "이메일 인증을 조회합니다. (email=${command.email})" }
        val emailVerification = emailVerificationFinder.findByEmailOrNull(command.email)

        if (emailVerification == null) {
            val ex = EmailVerificationNotFoundException(command.email.value)
            log.warn(ex)
            throw ex
        }
        log.info { "이메일 인증이 존재합니다." }

        // 이메일이 인증됐는지, 그리고 인증이 현재 유효한 지 확인
        emailVerificationHandler.checkVerifiedAndCurrentlyValid(emailVerification, command.currentTime)

        // 더 이상 이메일 인증이 필요 없으므로 말소
        emailVerificationAppender.removeByEmail(emailVerification.email)
    }

    private fun createMember(command: RegisterMemberCommand): Member {
        return memberCreator.create(
            email = command.email,
            password = command.rawPassword,
            username = command.username,
            nickname = command.nickname,
            currentTime = command.currentTime,
        )
    }
}

 

회원 가입 프로세서입니다.

 

1. 중복체크

MemberFinder 를 통해 중복 회원 존재성 여부를 확인하고 중복되면 예외를 발생시킵니다.

 

2. 이메일 인증 존재 여부 확인, 현재 유효성 확인, 말소

EmailVerificationFinder 를 통해 이메일 인증을 조회하고(없으면 예외 발생)

EmailVerificationHandler 를 통해 이메일 인증이 인증된 상태인지, 현재 유효한지 검증 처리를 위임합니다.

그 후 이메일 인증은 더 이상 필요 없으니  EmailVerificationAppender 를 통해 말소시킵니다.

 

3. 회원 생성

MemberCreator를 통해 회원을 생성합니다.

 

4. 회원 저장

MemberAppender 를 통해 만들어진 회원을 저장합니다.

 

5. 회원가입됨 이벤트 생성 및 반환

이렇게 만들고, 저장된 회원을 기반으로 MemberEventCreator를 통해 회원가입됨 이벤트를 생성하고 반환합니다.


3. 도메인 기능 구현

이어서 도메인 기능을 구현해볼게요.

3.1 PasswordManager

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

import com.ttasjwi.board.system.member.domain.model.EncodedPassword
import com.ttasjwi.board.system.member.domain.model.RawPassword

interface PasswordManager {

    fun createRawPassword(value: String): Result<RawPassword>
    fun encode(rawPassword: RawPassword): EncodedPassword
    fun matches(rawPassword: RawPassword, encodedPassword: EncodedPassword): Boolean
}
@DomainService
internal class PasswordManagerImpl(
    private val externalPasswordHandler: ExternalPasswordHandler
) : PasswordManager {

    override fun createRawPassword(value: String): Result<RawPassword> {
        return kotlin.runCatching { RawPassword.create(value) }
    }

    override fun encode(rawPassword: RawPassword): EncodedPassword {
        return externalPasswordHandler.encode(rawPassword)
    }

    override fun matches(rawPassword: RawPassword, encodedPassword: EncodedPassword): Boolean {
        return externalPasswordHandler.matches(rawPassword, encodedPassword)
    }
}

 

 

PasswordManager는 원본 패스워드 인스턴스 생성, 패스워드 인코딩, 패스워드 매칭의 역할을 담당합니다.

 

1.CreateRawPassword

RawPassword 의 정적 팩터리 메서드를 통해 생성하도록 위임합니다.

 

생성 결과를 Result로 감싼 이유는 처리하는 측이 주로 애플리케이션 계층의 입력값 유효성 검증을 담당하는 CommandMapper, QueryMapper 측인 점을 고려하여 별도의 예외처리를 유연하게 할 수 있도록 Result로 감쌌습니다.

 

 

2. encode

RawPassword 의 값을 인코딩하고 EncodedPassword 로 만들어야하는데

패스워드 인코딩에 관한 기술은 외부 라이브러리를 써야하기 때문에 ExternalPasswordHandler라는 별도의 인터페이스를 통해 위임했습니다.

 

3. matches

인코딩된 패스워드의 매칭 역시 외부 기술을 필요로 하기 때문에 ExternalPasswordHandler 를 통해 매칭을 위임했습니다.

 

class RawPassword
internal constructor(
    val value: String
) {
    companion object {
        private const val RAW_PASSWORD_TO_STRING = "RawPassword(value=[SECRET])"
        internal const val MIN_LENGTH = 4
        internal const val MAX_LENGTH = 32

        internal fun create(value: String): RawPassword {
            if (value.length < MIN_LENGTH || value.length > MAX_LENGTH) {
                throw InvalidPasswordFormatException()
            }
            return RawPassword(value)
        }
    }

    override fun toString(): String {
        return RAW_PASSWORD_TO_STRING
    }

}

 

RawPassword 생성 로직은 4자이장 32자 이하의 문자열만 가능하게 설정했고

 

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

import com.ttasjwi.board.system.member.domain.model.EncodedPassword
import com.ttasjwi.board.system.member.domain.model.RawPassword

interface ExternalPasswordHandler {

    fun encode(rawPassword: RawPassword): EncodedPassword
    fun matches(rawPassword: RawPassword, encodedPassword: EncodedPassword): Boolean
}

 

ExternalPasswordHandler 는 외부모듈 연동을 위해 인터페이스를 도메인 모듈 내에 두었습니다.

실제 ExternalPasswordHandler 의 구현은 아래에서 다룰게요.

 

3.2 MemberFinder/MemberAppender

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.Member
import com.ttasjwi.board.system.member.domain.model.MemberId
import com.ttasjwi.board.system.member.domain.model.Nickname
import com.ttasjwi.board.system.member.domain.model.Username

interface MemberFinder {

    fun findByIdOrNull(id: MemberId): Member?
    fun existsById(id: MemberId): Boolean

    fun findByEmailOrNull(email: Email): Member?
    fun existsByEmail(email: Email): Boolean

    fun findByUsernameOrNull(username: Username): Member?
    fun existsByUsername(username: Username): Boolean

    fun findByNicknameOrNull(nickname: Nickname): Member?
    fun existsByNickname(nickname: Nickname): Boolean
}
package com.ttasjwi.board.system.member.domain.service

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

interface MemberAppender {

    /**
     * 회원을 저장합니다. 저장된 회원은 유지되어야 하며, 저장된 회원에는 id가 발급됩니다.
     */
    fun save(member: Member): Member
}

 

회원을 조회하고, 저장/수정/삭제하는 역할인데요.

이전 글들에서 데이터베이스와 연동하여 구현했기때문에 구현사항은 생략합니다.

 

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

interface EmailVerificationAppender {

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

 

이메일 인증 조회/삽입,수정,삭제 를 담당하는 역할인데 이것 역시 앞서 구현했기 때문에 생략합니다.

 

3.4 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

    /**
     * 이메일 인증이 현재 검증된 상태이면서 유효한지 확인합니다.
     */
    fun checkVerifiedAndCurrentlyValid(
        emailVerification: EmailVerification,
        currentTime: ZonedDateTime
    )
}
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)
    }

    override fun checkVerifiedAndCurrentlyValid(emailVerification: EmailVerification, currentTime: ZonedDateTime) {
        emailVerification.checkVerifiedAndCurrentlyValid(currentTime)
    }
}

 

기존 EmailVerificationHandler 인터페이스에 새로 메서드를 하나 추가했습니다.

 

checkVerifiedAndCurrentlyValid : 이메일 인증이 현재 검증된 상태이면서 유효한지 확인합니다.

    internal fun checkVerifiedAndCurrentlyValid(currentTime: ZonedDateTime) {
        log.info{ "이메일 인증이 현재 유효한 지 확인합니다." }

        // 인증이 안 됨 -> 인증 해라
        if (this.verifiedAt == null) {
            log.warn{ "해당 이메일은 인증이 되지 않았음. (email=${this.email.value})" }
            throw EmailNotVerifiedException(email.value)
        }
        // 인증은 했는데, 인증이 만료된 경우 -> 처음부터 다시 인증해라
        if (currentTime >= this.verificationExpiresAt!!) {
            log.warn{ "이메일 인증이 만료됐음. 다시 인증해야합니다. (email=${email.value},expiredAt=${this.verificationExpiresAt!!},currentTime=${currentTime})"}
            throw EmailVerificationExpiredException(email.value, verificationExpiresAt!!, currentTime)
        }
        // 그 외: 인증이 됐고, 인증이 만료되지 않은 경우(유효함)
        log.info{ "이메일 인증이 현재 유효합니다." }
    }

 

실제 이메일 인증 유효성 확인 기능은 EmailVerification 자기 자신에게 위임되어 처리됩니다.

 

여기서는 이메일이 인증되지 않았다면 인증되지 않았다는 예외를 터트리고

인증은 했는데 만료된 경우, 인증 만료됐다는 예외를 터트립니다.

 

그 외는 정상 케이스이므로 아무 것도 하지 않습니다.

 

3.5 MemberCreator

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.Member
import com.ttasjwi.board.system.member.domain.model.Nickname
import com.ttasjwi.board.system.member.domain.model.RawPassword
import com.ttasjwi.board.system.member.domain.model.Username
import java.time.ZonedDateTime

interface MemberCreator {

    fun create(
        email: Email,
        password: RawPassword,
        username: Username,
        nickname: Nickname,
        currentTime: ZonedDateTime
    ): Member
}
@DomainService
class MemberCreatorImpl(
    private val passwordManager: PasswordManager,
) : MemberCreator {

    override fun create(
        email: Email,
        password: RawPassword,
        username: Username,
        nickname: Nickname,
        currentTime: ZonedDateTime
    ): Member {
        return Member.create(
            email = email,
            password = passwordManager.encode(password),
            username = username,
            nickname = nickname,
            registeredAt = currentTime,
        )
    }
}

 

MemberCreator는 회원 생성을 담당합니다. 구현체인 MemberCreatorImpl 에서는 PasswordManager를 의존하고 있으며,

사용자에게 받은 파라미터를 기반으로 하여 PasswordManager 를 통해 패스워드 인코딩을 하고

 

Member.create 를 통해 회원을 생성해요.

 

class Member
internal constructor(
    id: MemberId? = null,
    email: Email,
    password: EncodedPassword,
    username: Username,
    nickname: Nickname,
    role: Role,
    val registeredAt: ZonedDateTime,
) : DomainEntity<MemberId>(id) {

    companion object {

        /**
         * 가입 회원 생성
         */
        internal fun create(
            email: Email,
            password: EncodedPassword,
            username: Username,
            nickname: Nickname,
            registeredAt: ZonedDateTime,
        ): Member {
            return Member(
                email = email,
                password = password,
                username = username,
                nickname = nickname,
                role = Role.USER,
                registeredAt = registeredAt,
            )
        }

 

Member.create 에서는 내부적으로 회원을 생성하는 기능이 구현되어 있습니다. 이때 회원에게는 기본 역할인 Role.USER 를 부여합니다.

 

3.6 MemberEventCreator

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

import com.ttasjwi.board.system.member.domain.event.MemberRegisteredEvent
import com.ttasjwi.board.system.member.domain.model.Member

interface MemberEventCreator {
    fun onMemberRegistered(member: Member): MemberRegisteredEvent
}
@DomainService
internal class MemberEventCreatorImpl : MemberEventCreator {

    override fun onMemberRegistered(member: Member): MemberRegisteredEvent {
        return MemberRegisteredEvent.create(member)
    }
}

 

회원 가입됨 이벤트를 생성합니다.

단순히 회원값에서 특정 프로퍼티만 꺼내서 값을 매핑하는 방식으로 구현되어있기 때문에 구현 코드는 생략할게요.

 

 

여기까지 하면 도메인 계층 기능 구현은 마무리 됐습니다.


4. 패스워드 처리

문제는 패스워드 처리 부분인데요. 앞서 선언해둔 ExternalPasswordHandler 를 구현해야합니다.

 

4.1 모듈 선언 및 의존성 설정

 

저는 여기서 external-security 모듈을 선언하고

 

    SPRING_SECURITY_CRYPTO(groupId = "org.springframework.security", artifactId = "spring-security-crypto"),
dependencies {
    implementation(Dependencies.SPRING_BOOT_STARTER.fullName)
    implementation(Dependencies.SPRING_SECURITY_CRYPTO.fullName)
    implementation(project(":board-system-domain:domain-core"))
    implementation(project(":board-system-domain:domain-member"))
    testImplementation(testFixtures(project(":board-system-domain:domain-core")))
    testImplementation(testFixtures(project(":board-system-domain:domain-member")))
}

 

spring-boot-starter 의존성 및 spring-security-crypto 의존성을 추가하였습니다.

 

spring-boot-starter 는 스프링부트 실행을 위한 설정이고

spring-security-crypto 는 스프링 시큐리티가 제공하는 PasswordEncoder 의존성 등록을 위한 설정입니다.

 

spring-boot-security-starter 를 추가해도 되긴 하는데 이렇게 하면 스프링 시큐리티 관련 설정(필터체인 ..)이 자동구성되어서 모든 엔드포인트에 인증을 필요로하게 됩니다. 이런 것들을 커스텀하게 설정해야하는데 이 부분은 지금 다루는 주제가 아니여서 추가하지 않았습니다.

4.2 PasswordEncoder 설정

package org.springframework.security.crypto.password;

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

 

스프링 시큐리티가 제공하는 PasswordEncoder 인터페이스는

문자열을 기반으로 인코딩된 문자열을 만들고, 원본 문자열/인코딩 문자열을 매칭하는 역할을 담당합니다.

 

이에 대한 설정을 해야하는데요.

 

package com.ttasjwi.board.system.core.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder

@Configuration
class PasswordConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
}

 

public final class PasswordEncoderFactories {
    private PasswordEncoderFactories() {
    }

    public static PasswordEncoder createDelegatingPasswordEncoder() {
        String encodingId = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap();
        encoders.put(encodingId, new BCryptPasswordEncoder());
        encoders.put("ldap", new LdapShaPasswordEncoder());
        encoders.put("MD4", new Md4PasswordEncoder());
        encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
        encoders.put("noop", NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
        encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
        encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
        encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
        encoders.put("sha256", new StandardPasswordEncoder());
        encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
        encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        return new DelegatingPasswordEncoder(encodingId, encoders);
    }
}

 

spring-boot-security-starter 등록을 하면 자동구성이 되지만 저는 spring-security-crypto 를 사용하므로 수동 설정을 했습니다.

 

PasswordEncoderFactories.createDelegatingPasswordEncoder() 를 호출하면 DelegatingPasswordEncoder 가 생성되어 반환되는데 여기에는 여러가지 알고리즘의 PasswordEncoder 가 내부에 포함되어 있습니다.

 

가장 기본으로 사용되어지는 PasswordEncoder는 BCryptPasswordEncoder 입니다.

 

	@Override
	public String encode(CharSequence rawPassword) {
		return this.idPrefix + this.idForEncode + this.idSuffix + this.passwordEncoderForEncode.encode(rawPassword);
	}

 

DelegatingPasswordEncocder 에 의해 인코딩되면 인코딩 문자열이 {bcrypt}실제인코딩된패스워드 형식으로 인코딩이 됩니다. 이후 기본 인코딩 방식을 bcrypt 가 아닌 다른 방식으로 인코딩하도록 변경할 때 대응하기 쉽도록 설계됐습니다.

 

4.3 ExternalPasswordHandlerImpl

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

import com.ttasjwi.board.system.member.domain.model.EncodedPassword
import com.ttasjwi.board.system.member.domain.model.RawPassword
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component

@Component
class ExternalPasswordHandlerImpl(
    private val passwordEncoder: PasswordEncoder,
) : ExternalPasswordHandler {

    override fun encode(rawPassword: RawPassword): EncodedPassword {
        val encodedPasswordValue = passwordEncoder.encode(rawPassword.value)
        return EncodedPassword.restore(encodedPasswordValue)
    }

    override fun matches(rawPassword: RawPassword, encodedPassword: EncodedPassword): Boolean {
        return passwordEncoder.matches(rawPassword.value, encodedPassword.value)
    }
}

 

저희가 실제로 구현할 ExternalPasswordHandlerImpl 입니다.

스프링 시큐리티의 PasswordEncoder 를 통해 인코딩/매칭 처리를 위임하도록 했습니다.

 

@SpringBootTest
@Profile("test")
@DisplayName("ExternalPasswordHandlerImpl 테스트")
class ExternalPasswordHandlerImplTest @Autowired constructor(
    private val externalPasswordHandler: ExternalPasswordHandler
) {

    @Nested
    @DisplayName("encode: 패스워드를 인코딩한다")
    inner class Encode {

        @Test
        fun test() {
            val rawPassword = rawPasswordFixture("1234")
            val encodedPassword = externalPasswordHandler.encode(rawPassword)
            assertThat(encodedPassword).isNotNull()
        }
    }

    @Nested
    @DisplayName("matches: 원본 패스워드와 인코딩 된 패스워드를 비교하여 일치하는 지 여부를 반환한다.")
    inner class Matches {

        @Test
        @DisplayName("원본 패스워드가 같으면, true를 반환한다.")
        fun testSamePassword() {
            // given
            val rawPassword = rawPasswordFixture("1234")
            val encodedPassword = externalPasswordHandler.encode(rawPassword)

            // when
            val matches = externalPasswordHandler.matches(rawPassword, encodedPassword)

            // then
            assertThat(matches).isTrue()
        }

        @Test
        @DisplayName("원본 패스워드가 다르면, false를 반환한다.")
        fun testDifferentPassword() {
            // given
            val rawPassword = rawPasswordFixture("1234")
            val encodedPassword = externalPasswordHandler.encode(rawPassword)

            // when
            val matches = externalPasswordHandler.matches(rawPasswordFixture("1235"), encodedPassword)

            // then
            assertThat(matches).isFalse()
        }
    }
}

 

 

실제 패스워드 인코딩/매칭 처리를 테스트해보면 잘 됩니다

 


5. 메시지 처리

RegisterMember:
  Complete:
    message: "회원가입 완료됨"
    description: "회원가입이 완료됐습니다."
Error:
  EmailVerificationNotFound:
    message: "이메일인증이 없거나 만료됨"
    description: "이메일에 대응하는 이메일 인증을 찾지 못 했습니다. 없거나 만료됐습니다. 인증처리를 해주세요.(email={0})"
  EmailVerificationExpired:
    message: "이메일인증 만료됨"
    description: "이메일인증이 만료됐습니다. 인증처리를 해주세요.(email={0},만료시각={1},현재시각={2})"
  InvalidEmailVerificationCode:
    message: "이메일인증 코드가 유효하지 않음"
    description: "이메일인증 코드가 유효하지 않습니다. 올바른 인증 코드를 입력해주세요."
  EmailNotVerified:
    message: "인증되지 않은 이메일"
    description: "이 이메일은 인증되지 않았습니다. 인증을 먼저 수행해주세요.(email={0})"

  DuplicateMemberEmail:
    message: "이메일 중복"
    description: "중복되는 이메일의 회원이 존재합니다.(email={0})"
  DuplicateMemberUsername:
    message: "사용자 아이디(username) 중복"
    description: "중복되는 사용자 아이디(username)의 회원이 존재합니다.(username={0})"
  DuplicateMemberNickname:
    message: "닉네임 중복"
    description: "중복되는 닉네임의 회원이 존재합니다.(nickname={0})"

 

그 외에도, 사용자에게 보여줄 메시지 처리도 작성했습니다.

 

(+ 이번 글 이후로는 이런 메시지 처리 내용은 생략하겠습니다)

 


6. 실행

 

실제로 이메일 인증을 수행하고

 

회원가입을 수행하면 성공합니다.

 

RegisterMemberApplicationService : 회원 가입을 시작합니다.
RegisterMemberCommandMapper : 요청 입력값이 유효한 지 확인합니다.
RegisterMemberCommandMapper : 요청 입력값들은 유효합니다.
RegisterMemberProcessor    : 중복되는 회원이 있는 지 확인합니다. 

# 쿼리 생략

RegisterMemberProcessor    : 중복되는 회원이 없습니다.
RegisterMemberProcessor    : 이메일 인증을 조회합니다. (email=Email(value=ttasjwi920@gmail.com))
RegisterMemberProcessor    : 이메일 인증이 존재합니다.
EmailVerification      : 이메일 인증이 현재 유효한 지 확인합니다.
EmailVerification      : 이메일 인증이 현재 유효합니다.
[board-system] [nio-8080-exec-6] p6spy                                    : 
Execute DML : 

    insert 
    into
        member
        (email, nickname, password, registered_at, role, username) 
    values
        ('ttasjwi920@gmail.com', '땃쥐', '{bcrypt}$2a$10$ZqWQSIlHFXLAtRLwjgGf0.nfSUQo6zsmjwBeFayyw09jN3U0pfd0S', '2024-11-13T11:09:09.409+0900', 'USER', 'ttasjwi')

Execution Time: 13 ms
----------------------------------------------------------------------------------------------------
RegisterMemberProcessor    : 회원 생성 및 저장됨 (memberId=MemberId(value=1))
[board-system] [nio-8080-exec-6] p6spy                                    : 
Execute Command : 

	commit

----------------------------------------------------------------------------------------------------
2024-11-13T11:09:09.943+09:00  INFO 21084 --- [board-system] [nio-8080-exec-6] s.m.a.s.RegisterMemberApplicationService : 회원가입 됨(id=1,email = ttasjwi920@gmail.com)

 

로그를 확인해보면 사용자 저장 과정에서 Password 로 인코딩된 패스워드가 저장되어지는 것을 볼 수 있어요.

 


 

이렇게 회원가입 기능 구현까지 마쳤습니다.

이제 회원을 가입시켰으니  그 다음은 회원을 로그인 시켜야겠죠?

이어서 로그인 기능을 구현해볼게요.

 


리포지토리

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/42

 

Feature: (BRD-45) 회원가입 API 구현 by ttasjwi · Pull Request #42 · ttasjwi/board-system

JIRA 티켓 BRD-45 내부 흐름 1. api-member 모듈 > RegisterMemberController 요청을 받고 유즈케이스(RegisterMemberUseCase)에 요청 처리를 위임한다 유즈케이스의 구현체는 RegisterMemberApplicationService 로서, application-m

github.com

 

Comments