땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 17. 로그인 API 구현 - (1) 설계 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 17. 로그인 API 구현 - (1) 설계

ttasjwi 2024. 11. 13. 15:42

 

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


1. 로그인 개요 - 세션과 토큰

HTTP는 기본적으로 무상태 프로토콜입니다. 기존 사용자가 누구인지 HTTP 자체 사양만으로는 기억할 수 없어요.

 

그래서 존재하는 기능이 로그인입니다.

사용자가 아이디(또는 이메일), 패스워드를 전달하고, 이후에 서버에 사용자가 다시 요청을 보낼 때는 사용자가 누구인지를 기억하도록 하기 위한 작업입니다.

 

그런데 서버 입장에서는 사용자가 누구인지 어떻게 기억하고 관리할까요?

 

1.1 세션방식

 

첫번째로 세션방식이 있습니다. 가장 많이 사용되는 방식인데요.

한번 로그인 하면 서버는 사용자에게 sessionId 에 해당하는 쿠키를 발급하여, 사용자가 계속 요청을 할 때마다 쿠키를 보내게 합니다.

 

쿠키에 기입된 sessionId 를 확인하여 우리 서버의 세션(서버내 메모리 또는 Redis와 같은 외부 인메모리 DB)에서 사용자를 찾아다 식별하는 방식이죠.

 

이 방식은 사용자 정보를 서버가 기억해서 관리하는 점에서 장점이 있고 사용자의 최신화된 정보를 실시간으로 사용할 수 있고, 매 요청마다 세션에 접근하는 부하가 존재한다는 단점이 존재합니다.

 

1.2 토큰 방식

 

두번째로 토큰 방식이 있습니다. 이 역시 많이 사용되는 방식입니다.

한번 로그인을 하고 서버는 일정 시간동안 유효한 액세스토큰과 리프레시토큰을 사용자에게 내려줍니다.

 

액세스토큰은 주로 JWT 형태로 발급하는데, 사용자 정보와 토큰 정보를 암호화된 토큰 형태로 발급하여 사용자에게 내리는 방식입니다. 사용자는 매 요청마다 헤더에 액세스 토큰을 담아 우리 서버에게 보내게 됩니다.

 

서버 입장에서는 JWT 가 우리 서버에서 발급했다는 것을 확인할 수 있는 수단이 있기 때문에 해당 토큰이 제대로 된 토큰인 지 판단할 수 있습니다.

 

 

토큰 방식은 세션 방식과 다르게 사용자가 매 요청마다 자신이 누군지에 해당하는 정보를 토큰을 통해 전달하기 때문에 매 요청마다 사용자 정보를 Session 과 같은 곳에서 찾을 필요가 없다는 장점이 있습니다.

 

인증 서버를 따로 두고, 그 외의 서버에서는 토큰을 통해서만 사용자 정보를 받아 관리하면 인증 서버를 제외한 다른 서버에서는 세션에 접근하지 않고 사용자를 식별할 수 있습니다.

 

이렇게 분산형으로 서비스를 관리하게 되는 경우, 매 요청마다 사용자를 좀 더 적은 부하로 빠르게 식별할 수 있어서(물론 HTTP 요청에 사용자가 보내야하는 데이터의 양이 크긴 하지만) 세션방식과 다른 강점을 가집니다.

 

다만 한번 액세스토큰을 발급하고 나면, 그 토큰의 유효기간 내에서는 토큰의 사용자 정보가 실시간으로 유효한 지 통제할 수 없습니다. 액세스토큰을 탈취당하면 탈취한 사람이 계속 토큰을 악용할 수 있겠죠. 그래서 보통 액세스토큰의 유효시간을 짧게 잡고 리프레시토큰이라는 좀 더 수명이 긴 토큰을 통해 액세스토큰을 재갱신하는 방식으로 토큰을 관리합니다. 그리고 리프레시 토큰의 유효생명주기를 어느 정도 통제할 수 있도록 리프레시 토큰 저장소를 별도로 관리합니다.

 

이번 프로젝트에서 저는 JWT 기술에 익숙해지고 싶어서 토큰 방식을 사용해보기로 했습니다.


2. 모듈

rootProject.name = "board-system"

include(

    // 설정 최종 구성 및 실행
    "board-system-container",

    // 공용 모듈
    "board-system-core",
    "board-system-logging",

    // api
    "board-system-api:api-core",
    "board-system-api:api-member",
    "board-system-api:api-auth",
    "board-system-api:api-deploy",

    // application
    "board-system-application:application-core",
    "board-system-application:application-member",
    "board-system-application:application-auth",

    // domain
    "board-system-domain:domain-core",
    "board-system-domain:domain-member",
    "board-system-domain:domain-auth",

    // event
    "board-system-event:event-publisher-member",
    "board-system-event:event-consumer-member",

    // external : 외부 기술 의존성
    "board-system-external:external-message",
    "board-system-external:external-db",
    "board-system-external:external-redis",
    "board-system-external:external-security",
    "board-system-external:external-exception-handle",
    "board-system-external:external-email-sender",
    "board-system-external:external-email-format-checker",
)

 

기능 구현에 앞서 인증 기능을 담당하는 모듈들을 새로 선언했습니다

 

1. api-auth : 인증 api (의존성: api-core, application-auth)

2. application-auth: 인증 애플리케이션 (의존성 : application-core, domain-auth, domain-member, domain-core)

3. domain-auth: 인증 도메인 (의존성: domain-member, domain-core)


3. 표현계층(api-auth) - 컨트롤러

package com.ttasjwi.board.system.auth.api

@RestController
class LoginController(
    private val useCase: LoginUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) {

    companion object {
        internal const val TOKEN_TYPE = "Bearer"
    }

    @PostMapping("/api/v1/auth/login")
    fun login(@RequestBody request: LoginRequest): ResponseEntity<SuccessResponse<LoginResponse>> {
        // 유즈케이스에 요청 처리를 위임
        val result = useCase.login(request)

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

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

data class LoginResponse(
    val loginResult: LoginResult,
) {

    data class LoginResult(
        val accessToken: String,
        val accessTokenExpiresAt: ZonedDateTime,
        val tokenType: String = TOKEN_TYPE,
        val refreshToken: String,
        val refreshTokenExpiresAt: ZonedDateTime,
    )
}

 

LoginController 입니다. 

기존에 작성한 컨트롤러 코드와 흐름은 같습니다.


4. 애플리케이션 계층

4.1 유즈케이스

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

import java.time.ZonedDateTime

interface LoginUseCase {

    fun login(request: LoginRequest): LoginResult
}

data class LoginRequest(
    val email: String?,
    val password: String?,
)

data class LoginResult(
    val accessToken: String,
    val accessTokenExpiresAt: ZonedDateTime,
    val refreshToken: String,
    val refreshTokenExpiresAt: ZonedDateTime,
)

 

유즈케이스입니다. 이메일과 패스워드를 요청 파라미터로 받아, 로그인 처리하도록 계약을 정의 했어요.

 

 

4.2 ApplicationService

@ApplicationService
internal class LoginApplicationService(
    private val commandMapper: LoginCommandMapper,
    private val processor: LoginProcessor,
    private val transactionRunner: TransactionRunner,
) : LoginUseCase {

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

    override fun login(request: LoginRequest): LoginResult {
        log.info { "로그인 요청을 받았습니다." }

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

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

        log.info { "로그인 됨" }

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

    private fun makeResult(event: LoggedInEvent): LoginResult {
        return LoginResult(
            accessToken = event.data.accessToken,
            accessTokenExpiresAt = event.data.accessTokenExpiresAt,
            refreshToken = event.data.refreshToken,
            refreshTokenExpiresAt = event.data.refreshTokenExpiresAt,
        )
    }
}

 

유즈케이스의 구현체인 ApplicationService 입니다.

 

기존 작성한 애플리케이션 서비스들 흐름과 같습니다.

 

요청을 받아 CommandMapper 를 통해 요청 매핑 후, Processor를 통해 처리를 위임한 뒤, 처리 후 반환받은 Event를 기반으로 결과를 만들어 반환합니다. 

4.3 Command / CommandMapper

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

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

class LoginCommand(
    val email: Email,
    val rawPassword: RawPassword,
    val currentTime: ZonedDateTime,
)
@ApplicationCommandMapper
internal class LoginCommandMapper(
    private val emailCreator: EmailCreator,
    private val passwordManager: PasswordManager,
    private val timeManager: TimeManager,
) {

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


    fun mapToCommand(request: LoginRequest): LoginCommand {
        log.info{ "로그인 요청 필드의 유효성을 확인합니다." }
        checkNullField(request)

        val email = getEmail(request)
        val password = getPassword(request)

        log.info{ "로그인 요청 필드는 유효합니다." }

        return LoginCommand(
            email = email,
            rawPassword = password,
            currentTime = timeManager.now()
        )
    }

    private fun checkNullField(request: LoginRequest) {
        val exceptionCollector = ValidationExceptionCollector()
        if (request.email == null) {
            NullArgumentException("email")
                .let {
                    log.warn(it)
                    exceptionCollector.addCustomExceptionOrThrow(it)
                }
        }
        if (request.password == null) {
            NullArgumentException("password")
                .let {
                    log.warn(it)
                    exceptionCollector.addCustomExceptionOrThrow(it)
                }
        }
        exceptionCollector.throwIfNotEmpty()
    }

    private fun getEmail(request: LoginRequest): Email {
        return emailCreator.create(request.email!!)
            .getOrElse {
                val ex = LoginFailureException("로그인 실패 - 이메일 포맷이 유효하지 않음")
                log.warn(ex)
                throw ex
            }
    }

    private fun getPassword(request: LoginRequest): RawPassword {
        return passwordManager.createRawPassword(request.password!!)
            .getOrElse {
                val ex = LoginFailureException("로그인 실패 - 패스워드 포맷이 유효하지 않음")
                log.warn { ex }
                throw ex
            }
    }

}

 

CommandMapper 에서는 email, password 를 도메인 객체로 변환하고 현재 시간 정보를 추가하여 Command 를 생성합니다.

 

이 과정에서 email, password 가 null 인 경우는 파라미터 누락 예외로 응답시키고

이메일, 패스워드 포맷이 유효하지 않은 경우 발생하는 예외들은 그대로 예외를 터트리지 않고 LoginFailureException(아래에서 후술합니다.) 으로 뭉뚱그려서 터트립니다.

 

4.4 LoginFailureException

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

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

/**
 * 로그인 예외는 사용자에게 구체적인 사유를 밝히는 것은 보안상 위험성이 있습니다.
 * 따라서 서버 내부에서 확인할 수 있는 수준에서 디버깅 메시지만 예외에 담아 생성하세요.
 */
class LoginFailureException(
    debugMessage: String,
) : CustomException(
    status = ErrorStatus.UNAUTHENTICATED,
    code = "Error.LoginFailure",
    args = emptyList(),
    source = "credentials",
    debugMessage = debugMessage
)

 

LoginFailureException 은 말 그대로 로그인 실패 예외입니다.

 

그런데 잘 생각해보면 로그인이라는 행위는 보안상 민감한 행위입니다. 아이디가 잘못됐는지 패스워드가 잘못됐는지 구체적으로 예외를 터트려서 사용자에게 내보내는 것은 보안상 좋지 않다고 판단해서, LoginFailureException 으로 퉁치기로 했어요.

 

대신 애플리케이션 디버깅하는 입장에서 원인을 간단하게 설명할 수 있도록 debugMessage는 파라미터로 받도록 했습니다.

 

  LoginFailure:
    message: "로그인 실패"
    description: "로그인에 실패했습니다. 이메일 또는 패스워드가 잘못됐습니다."

 

사용자에게 응답을 내려줄 때는 로그인에 실패했고, 이메일 또는 패스워드가 잘못됐다고 뭉뚱그려서 응답하도록 했습니다.

 

4.5 Processor

@ApplicationProcessor
internal class LoginProcessor(
    private val memberFinder: MemberFinder,
    private val passwordManager: PasswordManager,
    private val authMemberCreator: AuthMemberCreator,
    private val accessTokenManager: AccessTokenManager,
    private val refreshTokenManager: RefreshTokenManager,
    private val refreshTokenHolderFinder: RefreshTokenHolderFinder,
    private val refreshTokenHolderManager: RefreshTokenHolderManager,
    private val refreshTokenHolderAppender: RefreshTokenHolderAppender,
    private val authEventCreator: AuthEventCreator
) {

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

    fun login(command: LoginCommand): LoggedInEvent {
        log.info { "로그인 처리를 시작합니다. (email=${command.email.value})" }
        // 회원 조회
        val member = findMember(command)

        // 패스워드 매칭
        matchesPassword(command.rawPassword, member.password)

        // 인증 성공
        val authMember = authMemberCreator.create(member)

        // 토큰 발급
        val (accessToken, refreshToken) = createTokens(authMember, command.currentTime)

        // 리프레시 토큰 홀더 업데이트
        upsertRefreshTokenHolder(authMember, refreshToken, command.currentTime)

        val loggedInEvent = authEventCreator.onLoginSuccess(accessToken, refreshToken)

        log.info { "로그인 처리를 성공했습니다. (email=${command.email.value})" }

        return loggedInEvent
    }

    /**
     * 로그인을 할 회원 조회
     */
    private fun findMember(command: LoginCommand): Member {
        log.info { "로그인 처리 - 회원을 조회합니다. (email=${command.email.value})" }
        val member = memberFinder.findByEmailOrNull(command.email)

        if (member == null) {
            val ex = LoginFailureException("로그인 실패 - 일치하는 이메일(email=${command.email.value})의 회원을 찾지 못 함")
            log.warn(ex)
            throw ex
        }
        log.info { "로그인 처리 - 회원이 조회됐습니다. (memberId=${member.id!!.value},email=${command.email.value})" }
        return member
    }

    /**
     * 패스워드 비교
     */
    private fun matchesPassword(
        rawPassword: RawPassword,
        encodedPassword: EncodedPassword,
    ) {
        log.info { "로그인 처리 - 패스워드 일치 여부를 확인합니다." }

        if (!passwordManager.matches(rawPassword, encodedPassword)) {
            val ex = LoginFailureException("로그인 처리 실패 - 패스워드 불일치")
            log.warn(ex)
            throw ex
        }
        log.info { "패스워드가 일치합니다." }
    }

    /**
     * 액세스 토큰, 리프레시 토큰 생성
     */
    private fun createTokens(authMember: AuthMember, currentTime: ZonedDateTime): Pair<AccessToken, RefreshToken> {
        val accessToken = accessTokenManager.generate(authMember, currentTime)
        val refreshToken = refreshTokenManager.generate(authMember.memberId, currentTime)
        return Pair(accessToken, refreshToken)
    }

    /**
     * 리프레시 토큰 홀더 조회 또는 생성 -> 리프레시 토큰 홀더 업데이트 -> 리프레시 토큰 저장
     */
    private fun upsertRefreshTokenHolder(
        authMember: AuthMember,
        refreshToken: RefreshToken,
        currentTime: ZonedDateTime
    ) {
        val refreshTokenHolder = refreshTokenHolderFinder.findByMemberIdOrNull(authMember.memberId)
            ?: refreshTokenHolderManager.createRefreshTokenHolder(authMember)

        val addedRefreshTokenHolder = refreshTokenHolderManager.addNewRefreshToken(refreshTokenHolder, refreshToken)

        refreshTokenHolderAppender.append(authMember.memberId, addedRefreshTokenHolder, currentTime)
    }
}

 

로그인  처리를 실질적으로 담당하는 Processor 입니다. 여기서는 다음 과정을 거쳐 로그인을 진행해요.

 

1. 회원 조회

email 에 해당하는 회원을 조회합니다. (MemberFinder)

사용자가 우리 서비스에 가입되어야겠죠? 조회하는데 실패했다면 LoginFailureException 으로 예외를 터트립니다.

 

2. 패스워드 매칭

사용자가 보내준 패스워드와, 실제 회원 객체가 가지고 있는 인코딩된 패스워드가 맞는지 확인합니다. 이 과정에는 PasswordManager 를 사용합니다. 서로 매칭했을 때 맞지 않다면 LoginFailureException 을 발생시킵니다.

 

3. 인증 회원 생성

인증됐으므로, 인증된 회원에 해당하는 AuthMember를 생성합니다. 이 작업은 AuthMemberCreator 가 담당합니다.

 

4. 토큰 발급

저는 인증 정보를 액세스토큰과 리프레시 토큰 형태로 2개를 발급할 예정입니다.

AccessTokenManager, RefreshTokenManager 를 통해 액세스 토큰, 리프레시 토큰 발급을 위임합니다.

 

5. 리프레시토큰 홀더 업데이트

액세스 토큰/리프레시 토큰 방식에서는 주로 서버에서 리프레시 토큰을 통제할 수 있도록 리프레시 토큰을 관리해야하는데요. 후술하겠지만 저는 사용자 하나가 동시에 유지할 수 있는 리프레시 토큰을 5개정도로 잡고 통합적으로 통제하여 관리할 수 있도록 RefreshTokenHolder 라는 개념으로 사용자마다 리프레시토큰 홀더 하나를 두고, 이를 통해 리프레시토큰을 관리합니다.

 

리프레시 토큰 홀더를 저장소(RefreshTokenHolderFinder)에서 조회해오고, 없다면 새로 생성합니다. (RefreshTokenHolderManager)

 

이렇게 얻어온 리프레시 토큰 홀더에 리프레시토큰을 새로 추가하고

리프레시 토큰 홀더를 저장소(RefreshTokenHolderAppender) 에 저장합니다.

 

6. 로그인됨 이벤트 생성 및 반환

최종적으로 AccessToken, RefreshToken 을 기반으로 로그인됨 이벤트를 생성(AuthEventCreator)하고 반환합니다.


5. 도메인 개념

5.1 AuthMember

abstract class AuthMember(
    val memberId: MemberId,
    val email: Email,
    val username: Username,
    val nickname: Nickname,
    val role: Role,
) {

    companion object {
        fun restore(
            memberId: Long,
            email: String,
            username: String,
            nickname: String,
            roleName: String
        ): AuthMember {
            return RestoredAuthMember(
                memberId = MemberId.restore(memberId),
                email = Email.restore(email),
                username = Username.restore(username),
                nickname = Nickname.restore(nickname),
                role = Role.restore(roleName)
            )
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is AuthMember) return false

        if (memberId != other.memberId) return false
        if (email != other.email) return false
        if (username != other.username) return false
        if (nickname != other.nickname) return false
        if (role != other.role) return false

        return true
    }

    override fun hashCode(): Int {
        var result = memberId.hashCode()
        result = 31 * result + email.hashCode()
        result = 31 * result + username.hashCode()
        result = 31 * result + nickname.hashCode()
        result = 31 * result + role.hashCode()
        return result
    }

    final override fun toString(): String {
        return "AuthMember(memberId=$memberId, email=$email, username=$username, nickname=$nickname, role=$role)"
    }

    private class RestoredAuthMember(
        memberId: MemberId,
        email: Email,
        username: Username,
        nickname: Nickname,
        role: Role,
    ) : AuthMember(
        memberId = memberId,
        email = email,
        username = username,
        nickname = nickname,
        role = role,
    )
}

 

인증된 회원을 의미하는 AuthMember 클래스입니다.

 

현재 요청을 보낸 사용자에 대한 정보를 여러 모듈에서 사용할 일이 많기 때문에 domain-core 모듈에 선언했어요. 

 

현재 로그인한 회원의 기본적인 신상정보를 담습니다.

보통 고유 식별자(id), 역할(role) 정보만 담아도 되긴 하는데 저는 memberId, email, username, nickname, role 정보를 담도록 했어요.

 

그리고 저는 여기서 이 클래스를 추상 클래스로 선언했는데요. 실제 AuthMember 를 생성하는 쪽은 domain-auth 쪽에 있는데 선언은 domain-core 에 있기 때문에 외부 모듈에서 기존 추상클래스를 확장한 구체 클래스를 만드는 형식으로 하도록 이렇게 설계했습니다.

5.2 AccessToken

class AccessToken
internal constructor(
    val authMember: AuthMember,
    val tokenValue: String,
    val issuedAt: ZonedDateTime,
    val expiresAt: ZonedDateTime,
) {

    companion object {

        internal const val VALIDITY_MINUTE = 30L

        fun restore(
            memberId: Long,
            email: String,
            username: String,
            nickname: String,
            roleName: String,
            tokenValue: String,
            issuedAt: ZonedDateTime,
            expiresAt: ZonedDateTime,
        ): AccessToken {
            return AccessToken(
                authMember = AuthMember.restore(
                    memberId = memberId,
                    email = email,
                    username = username,
                    nickname = nickname,
                    roleName = roleName,
                ),
                tokenValue = tokenValue,
                issuedAt = issuedAt,
                expiresAt = expiresAt
            )
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is AccessToken) return false
        if (authMember != other.authMember) return false
        if (tokenValue != other.tokenValue) return false
        if (issuedAt != other.issuedAt) return false
        if (expiresAt != other.expiresAt) return false
        return true
    }

    override fun hashCode(): Int {
        var result = authMember.hashCode()
        result = 31 * result + tokenValue.hashCode()
        result = 31 * result + issuedAt.hashCode()
        result = 31 * result + expiresAt.hashCode()
        return result
    }

}

 

액세스토큰입니다. 현재 로그인 사용자의 정보에 해당하는 AuthMember 필드, 그리고 토큰값에 해당하는 tokenValue 필드, 발행시각에 해당하는 issuedAt 필드, 만료시각에 해당하는 expiresAt 필드를 가집니다.

 

5.3 RefreshTokenId

class RefreshTokenId
internal constructor(
    val value: String
) {
    companion object {

        internal const val REFRESH_TOKEN_ID_LENGTH = 6

        internal fun create(): RefreshTokenId {
            return RefreshTokenId(
                UUID.randomUUID()
                    .toString()
                    .replace("-", "")
                    .substring(0, REFRESH_TOKEN_ID_LENGTH)
            )
        }

        fun restore(value: String): RefreshTokenId {
            return RefreshTokenId(value)
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is RefreshTokenId) return false
        if (value != other.value) return false
        return true
    }

    override fun hashCode(): Int {
        return value.hashCode()
    }
}

 

리프레시 토큰 홀더 내에서 리프레시 토큰을 구분할 수 있도록, 고유식별자 RefreshTokenId를 정의했습니다.

 

5.4 RefreshToken

class RefreshToken
internal constructor(
    val memberId: MemberId,
    val refreshTokenId: RefreshTokenId,
    val tokenValue: String,
    val issuedAt: ZonedDateTime,
    val expiresAt: ZonedDateTime,
) {

    companion object {

        internal const val VALIDITY_HOURS = 24L

        fun restore(
            memberId: Long,
            refreshTokenId: String,
            tokenValue: String,
            issuedAt: ZonedDateTime,
            expiresAt: ZonedDateTime
        ): RefreshToken {
            return RefreshToken(
                memberId = MemberId.restore(memberId),
                refreshTokenId = RefreshTokenId.restore(refreshTokenId),
                tokenValue = tokenValue,
                issuedAt = issuedAt,
                expiresAt = expiresAt
            )
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is RefreshToken) return false
        if (memberId != other.memberId) return false
        if (refreshTokenId != other.refreshTokenId) return false
        if (tokenValue != other.tokenValue) return false
        if (issuedAt != other.issuedAt) return false
        if (expiresAt != other.expiresAt) return false
        return true
    }

    override fun hashCode(): Int {
        var result = memberId.hashCode()
        result = 31 * result + refreshTokenId.hashCode()
        result = 31 * result + tokenValue.hashCode()
        result = 31 * result + issuedAt.hashCode()
        result = 31 * result + expiresAt.hashCode()
        return result
    }
}

 

리프레시토큰은 액세스토큰을 재갱신하는 목적을 가진 토큰인 만큼 MemberId(회원 고유식별자), RefreshTokenId(리프레시토큰 식별자) 수준으로만 정보를 담고

 

여기에 TokenValue(토큰원본값), IssuedAt(발행시점), expiresAt(만료시점) 정보를 담습니다.

 

5.5 RefreshTokenHolder

/**
 * 회원 및 회원이 가진 리프레시 토큰 목록을 관리하는 객체입니다.
 */
class RefreshTokenHolder
internal constructor(
    val authMember: AuthMember,
    tokens: MutableMap<RefreshTokenId, RefreshToken>
) {

    private val _tokens: MutableMap<RefreshTokenId, RefreshToken> = tokens

    companion object {

        internal const val MAX_TOKEN_COUNT = 5

        fun restore(
            memberId: Long,
            email: String,
            username: String,
            nickname: String,
            roleName: String,
            tokens: MutableMap<RefreshTokenId, RefreshToken>
        ): RefreshTokenHolder {
            return RefreshTokenHolder(
                authMember = AuthMember.restore(
                    memberId = memberId,
                    email = email,
                    username = username,
                    nickname = nickname,
                    roleName = roleName,
                ),
                tokens = tokens,
            )
        }
    }

    fun getTokens(): Map<RefreshTokenId, RefreshToken> {
        return _tokens.toMap()
    }

}

 

사용자가 가진 리프레시 토큰을 통제하여 관리할 수 있도록 정의한 RefreshTokenHolder 입니다.

내부적으로 누구인지에 대한 정보를 가진 AuthMember, 그리고 사용자가 가진 리프레시 토큰들을 내부적으로 관리할 수 있도록 토큰 해시테이블  자료구조(key: refreshTokenId, value: RefreshToken)를 가지고 있습니다.


6. 도메인 서비스

앞서 구현한 인터페이스 등은 설명을 생략하고 새로 만든 도메인 서비스 위주로 서술하겠습니다.

 

6.1 AuthMemberCreator

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

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

interface AuthMemberCreator {
    fun create(member: Member): AuthMember
}

 

AuthMemberCreator 는 인증회원을 생성하는 역할입니다.

 

@DomainService
internal class AuthMemberCreatorImpl : AuthMemberCreator {

    override fun create(member: Member): AuthMember {
        return CreatedAuthMember(member)
    }

    private class CreatedAuthMember(
        member: Member
    ) : AuthMember(
        memberId = member.id!!,
        email = member.email,
        username = member.username,
        nickname = member.nickname,
        role = member.role,
    )
}

 

여기서는 인증회원 생성을 위해 AuthMember를 상속한 CreatedAuthMember 인스턴스를 만들어서, 반환합니다.

 

이렇게 만든 이유는 인증회원 생성은, 인증 회원이 선언된 domain-core 모듈과, 생성도메인 서비스가 위치한 domain-auth 모듈이 달라서, 별도의 하위 클래스 형태로 제공하게 된 것입니다.

 

6.2 AccessTokenManager / RefreshTokenManager

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

import com.ttasjwi.board.system.auth.domain.model.AccessToken
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import java.time.ZonedDateTime

interface AccessTokenManager {

    fun generate(authMember: AuthMember, issuedAt: ZonedDateTime): AccessToken
    fun parse(tokenValue: String): AccessToken
}
@DomainService
internal class AccessTokenManagerImpl(
    private val externalAccessTokenManager: ExternalAccessTokenManager
) : AccessTokenManager {

    override fun generate(authMember: AuthMember, issuedAt: ZonedDateTime): AccessToken {
        val expiresAt = issuedAt.plusMinutes(AccessToken.VALIDITY_MINUTE)
        return externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
    }

    override fun parse(tokenValue: String): AccessToken {
        return externalAccessTokenManager.parse(tokenValue)
    }
}

 

AccessTokenManager는 액세스토큰 생성, 파싱을 담당합니다. 다만 실제 액세스토큰 값을 생성하는건 Jwt 기술이 필요하므로 여기서는 액세스토큰 유효시간에 대한 도메인 규칙(액세스토큰은 30분동안 유효하다) 정도만을 도메인 규칙에 따라 정해두고, ExternalAccessTokenManager 에게 대부분을 위임합니다.

 

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

import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.member.domain.model.MemberId
import java.time.ZonedDateTime

interface RefreshTokenManager {

    fun generate(memberId: MemberId, issuedAt: ZonedDateTime): RefreshToken
    fun parse(tokenValue: String): RefreshToken
}
@DomainService
internal class RefreshTokenManagerImpl(
    private val externalRefreshTokenManager: ExternalRefreshTokenManager,
) : RefreshTokenManager {

    override fun generate(memberId: MemberId, issuedAt: ZonedDateTime): RefreshToken {
        val refreshTokenId = RefreshTokenId.create()
        val expiresAt = issuedAt.plusHours(RefreshToken.VALIDITY_HOURS)
        return externalRefreshTokenManager.generate(memberId, refreshTokenId, issuedAt, expiresAt)
    }

    override fun parse(tokenValue: String): RefreshToken {
        return externalRefreshTokenManager.parse(tokenValue)
    }
}

 

리프레시 토큰 매니저 역시 비슷합니다.

리프레시 토큰의 유효시간은 24시간으로 정의하고, ExternalRefreshTokenManager 에게 리프레시 토큰 발급, 파싱 처리를 위임합니다.

 

6.3 RefreshTokenHolderFinder / RefreshTokenHolderAppender

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

import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder
import com.ttasjwi.board.system.member.domain.model.MemberId

interface RefreshTokenHolderFinder {
    fun findByMemberIdOrNull(memberId: MemberId): RefreshTokenHolder?
}
package com.ttasjwi.board.system.auth.domain.service

import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder
import com.ttasjwi.board.system.member.domain.model.MemberId
import java.time.ZonedDateTime

interface RefreshTokenHolderAppender {
    fun append(memberId: MemberId, refreshTokenHolder: RefreshTokenHolder, currentTime: ZonedDateTime)
}

 

RefreshTokenHolderFinder는 RefreshTokenHolder 를 조회하는 역할이고

RefreshTokenHolderAppender 는 RefreshTokenHolder 를 수정/등록/삭제하는 역할입니다.

 

@DomainService
internal class RefreshTokenHolderStorageImpl(
    private val externalRefreshTokenHolderAppender: ExternalRefreshTokenHolderAppender,
    private val externalRefreshTokenHolderFinder: ExternalRefreshTokenHolderFinder,
) : RefreshTokenHolderAppender, RefreshTokenHolderFinder {

    override fun append(memberId: MemberId, refreshTokenHolder: RefreshTokenHolder, currentTime: ZonedDateTime) {
        val expiresAt = refreshTokenHolder.expiresAt(currentTime)
        externalRefreshTokenHolderAppender.append(memberId, refreshTokenHolder, expiresAt)
    }

    override fun findByMemberIdOrNull(memberId: MemberId): RefreshTokenHolder? {
        return externalRefreshTokenHolderFinder.findByMemberIdOrNull(memberId)
    }
}

 

실제 이 두 인터페이스는 RefreshTokenHolderStorageImpl 에서 구현했는데, 이 역시도 리프레시토큰 홀더의 만료시간 정도만 정의하고, 실제 처리는 별도의 외부 기술을 위한 ExternalRefreshTokenHolderAppender, ExternalRefreshTokenHolderFinder 인터페이스를 선언해서 사용했습니다. 실제 저장은 Redis에 할 예정입니다.

 

1. append: 만료시간을 지정하여 ExternalRefreshTokenHolderAppender 에 저장합니다.

2. findByMemberIdOrNull : 회원 Id를 통해 리프레시토큰 홀더를 조회하고, 없다면 Null 을 반환합니다.

 


internal fun expiresAt(currentTime: ZonedDateTime): ZonedDateTime {
    // 토큰이 없으면 지금이 만료시점
    if (_tokens.isEmpty()) {
        return currentTime
    }
    // 만료일이 가장 늦은 것을 기준으로 만료시킴
    // 가장 만료시간이 마지막인 토큰을 기준으로 만료시간을 잡음
    var maxExpireTime = ZonedDateTime.of(LocalDateTime.MIN, ZoneId.of("Asia/Seoul"))
    for (token in _tokens.values) {
        if (token.expiresAt > maxExpireTime) {
            maxExpireTime = token.expiresAt
        }
    }
    return maxExpireTime
}

 

여기서 리프레시토큰 홀더의 만료시간을 설정하는 로직은 RefreshTokenHolder 의 expiresAt 을 통해 얻는데요.

 

리프레시토큰 홀더가 가진 토큰이 아무것도 없으면 현재 시간을 만료시각으로 잡고

리프레시토큰 홀더가 가진 토큰이 1개라도 존재할 경우, 가장 만료시간이 마지막인 토큰을 기준으로 홀더의 만료시각을 잡습니다.

6.4 RefreshTokenHolderManager

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

import com.ttasjwi.board.system.auth.domain.model.AuthMember
import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.model.RefreshTokenHolder

interface RefreshTokenHolderManager {

    fun createRefreshTokenHolder(authMember: AuthMember): RefreshTokenHolder
    fun addNewRefreshToken(refreshTokenHolder: RefreshTokenHolder, refreshToken: RefreshToken): RefreshTokenHolder
}

 

RefreshTokenHolderManager 는 리프레시 토큰 홀더를 생성하고, 새로운 리프레시 토큰 추가 등 리프레시 토큰홀더에 관한 처리를 담당하는 역할입니다.

 

@DomainService
internal class RefreshTokenHolderManagerImpl : RefreshTokenHolderManager {

    override fun createRefreshTokenHolder(authMember: AuthMember): RefreshTokenHolder {
        return RefreshTokenHolder.create(authMember)
    }

    override fun addNewRefreshToken(
        refreshTokenHolder: RefreshTokenHolder,
        refreshToken: RefreshToken
    ): RefreshTokenHolder {
        return refreshTokenHolder.addNewRefreshToken(refreshToken)
    }
}

 

실제 기능 구현은 RefreshTokenHolder 자기 자신이 처리하도록 위임합니다.

 

class RefreshTokenHolder
internal constructor(
    val authMember: AuthMember,
    tokens: MutableMap<RefreshTokenId, RefreshToken>
) {

    private val _tokens: MutableMap<RefreshTokenId, RefreshToken> = tokens

    companion object {

        internal const val MAX_TOKEN_COUNT = 5

        internal fun create(authMember: AuthMember): RefreshTokenHolder {
            return RefreshTokenHolder(
                authMember = authMember,
                tokens = hashMapOf()
            )
        }
    }
    
    internal fun addNewRefreshToken(refreshToken: RefreshToken): RefreshTokenHolder {
        // 리프레시토큰의 발행시점을 현재 시각으로 삼아, 오래된 토큰들을 만료시킴
        val currentTime = refreshToken.issuedAt
        removeExpiredTokens(currentTime)

        if (_tokens.size == MAX_TOKEN_COUNT) {
            // 보유할 수 있는 토큰의 갯수가 제한을 초과하면 가장 발행이 먼저된 것을 만료시킴
            val oldestToken = _tokens.values.minBy { it.issuedAt }
            _tokens.remove(oldestToken.refreshTokenId)
        }
        _tokens[refreshToken.refreshTokenId] = refreshToken
        return this
    }

    private fun removeExpiredTokens(currentTime: ZonedDateTime) {
        _tokens.entries.removeIf { currentTime >= it.value.expiresAt }
    }

 

create

리프레시토큰 홀더를 생성합니다. 최초에는 토큰이 없습니다.

 

addNewRefreshToken

새로운 리프레시토큰을 추가합니다.

리프레시 토큰을 현재시각으로 잡아서, 현재 시각 기준 만료된 토큰들은 모두 제거합니다.

그리고, 새로 추가하는 토큰이 5개쨰(MAX_TOKEN_COUNT) 이면 가장 발급시각이 오래된 토큰을 만료시키고 추가합니다.

6.5 AuthEventCreator

interface AuthEventCreator {

    fun onLoginSuccess(accessToken: AccessToken, refreshToken: RefreshToken): LoggedInEvent
}
package com.ttasjwi.board.system.auth.domain.service.impl

import com.ttasjwi.board.system.auth.domain.event.LoggedInEvent
import com.ttasjwi.board.system.auth.domain.model.AccessToken
import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.service.AuthEventCreator
import com.ttasjwi.board.system.core.annotation.component.DomainService

@DomainService
internal class AuthEventCreatorImpl : AuthEventCreator {

    override fun onLoginSuccess(accessToken: AccessToken, refreshToken: RefreshToken): LoggedInEvent {
        return LoggedInEvent.create(accessToken, refreshToken)
    }
}
        internal fun create(accessToken: AccessToken, refreshToken: RefreshToken): LoggedInEvent {
            return LoggedInEvent(
                accessToken = accessToken.tokenValue,
                accessTokenExpiresAt = accessToken.expiresAt,
                refreshToken = refreshToken.tokenValue,
                refreshTokenExpiresAt = refreshToken.expiresAt,
                loggedInAt = accessToken.issuedAt
            )
        }

 

AuthEventCreator는 '로그인됨 이벤트'를 생성하는 역할입니다.

액세스토큰, 리프레시토큰을 전달받아서 로그인됨 이벤트를 생성합니다.


7. 외부 기술

이제, 이렇게 놓고보면 컨트롤러측 기능, 애플리케이션 기능, 도메인 기능은 모두 작성됐지만

실제 기술에 사용하는 외부 기술 문제가 해결되지 않았습니다.

 

1. JWT 기술 : JWT 액세스토큰, 리프레시토큰을 실제 생성하고, 파싱할 수 있어야합니다.

2. RefreshTokenHolder 저장문제 : 레디스에 저장해야합니다.

 

여기서 1은 이번 글에서 다루기엔 글이 너무 길어질 것 같아서, 바로 뒤의 글에서 다루고 2번 부분은 이번 글 기준으로는 간단하게 메모리 DB로 구현하겠습니다.

레디스를 사용하는 부분은 앞서 처리하지 못 한 이메일 인증 레디스 저장문제 해결과 함께 해결하려고 합니다.

 

 

@AppComponent
class ExternalRefreshTokenHolderStorageImpl : ExternalRefreshTokenHolderAppender, ExternalRefreshTokenHolderFinder {

    private val store: MutableMap<MemberId, RefreshTokenHolder> = ConcurrentHashMap()

    override fun append(memberId: MemberId, refreshTokenHolder: RefreshTokenHolder, expiresAt: ZonedDateTime) {
        store[memberId] = refreshTokenHolder
    }

    override fun findByMemberIdOrNull(memberId: MemberId): RefreshTokenHolder? {
        return store[memberId]
    }
}

 

레디스를 바로 쓰지 않고, 일단 당장 기능이 돌아가도록 ExternalRefreshTokenHolderAppender, ExternalRefreshToeknHolderFinder 구현체를 external-redis 모듈에 정의해뒀습니다.

 

이렇게 하면 메모리에 RefreshTokenHolder를 저장하여 관리할 수 있습니다.

 

바로 뒤에 이어지는 글에서는 실제 JWT를 이용하여 액세스토큰 생성, 리프레시 토큰 생성을 해보겠습니다.


리포지토리

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

 

Feature: (BRD-7) 로그인 API 구현 by ttasjwi · Pull Request #47 · ttasjwi/board-system

JIRA 티켓 BRD-7

github.com

 

Comments