땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 25. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (4) 로그인/회원가입 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 25. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (4) 로그인/회원가입

ttasjwi 2024. 12. 2. 17:11

이전 글에서, 사용자의 소셜서비스 사용자 정보를 획득하는 것까지 수행했습니다.

이제 이 정보를 기반으로 우리 서비스에 실제로 로그인 시키고 회원가입까지 해보겠습니다.


1. 유즈케이스 계약

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

import java.time.ZonedDateTime

interface SocialLoginUseCase {

    /**
     * 소셜 연동 정보를 기반으로 액세스토큰, 리프레시 토큰을 얻어옵니다.
     * 만약 소셜 연동에 해당하는 회원이 없으면 회원을 생성합니다.
     */
    fun socialLogin(request: SocialLoginRequest): SocialLoginResult
}

data class SocialLoginRequest(
    val socialServiceName: String,
    val socialServiceUserId: String,
    val email: String,
)

data class SocialLoginResult(
    val accessToken: String,
    val accessTokenExpiresAt: ZonedDateTime,
    val refreshToken: String,
    val refreshTokenExpiresAt: ZonedDateTime,
    val memberCreated: Boolean,
    val createdMember: CreatedMember?,
) {

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

 

다시 이전 글에서의 소셜로그인 유즈케이스 계약을 보겠습니다.

 

소셜 서비스 이름, 소셜서비스 사용자아이디, 이메일 이 3가지 정보를 통해

우리 서비스에서의 어떤 사용자인지 식별하거나, 회원가입 시켜야합니다. 그리고 액세스토큰/리프레시토큰을 통해 이후 사용자가 토큰을 통해 서비스를 이용할 수 있게 해야합니다.


2. 소셜 로그인 요구사항

소셜 서비스 구현을 어떻게 할 지 정책을 설계해봤습니다.

 

 

1. 소셜서비스 연동 정보가 있는 회원은 그대로 로그인된다.

소셜 서비스 이름, 소셜 서비스 회원 아이디에 대응되는 회원이 있으면 그대로 로그인 시키도록 할 예정입니다.

이를 위해서는 소셜서비스 이름/소셜 서비스 아이디를 통해 식별할 수 있는 소셜 연동 정보가 필요하고 이 소셜연동 정보를 통해 사용자가 어떤 회원인지 알 수 있어야합니다.


2. 소셜 연동 정보가 없지만, 이메일이 우리 서비스에 가입된 회원의 것이면 소셜 연동을 추가하고 로그인처리 된다.

 

소셜서비스 이름, 소셜서비스 회원 아이디에 해당하는 소셜연동정보가 없다 하더라도

이메일 방식으로 가입했을 때와, 구글 소셜로그인 방식으로 가입했을 때의 이메일이 같으면 같은 회원으로 취급하고

소셜 연동만 추가합니다.

 

회원 자체는 식별됐으니 로그인처리 합니다.

 

3. 서비스에 가입하지 않은 사용자는 소셜로그인을 통해 회원가입이 되고 로그인처리가 한 번에 된다.

소셜 서비스 이름, 소셜서비스 회원아이디로 소셜 연동을 식별할 수 없고

이메일에 대응하는 회원도 조회할 수 없으면 이 사람은 신규회원이 됩니다.

신규회원 생성 후 로그인처리 시켜야합니다.

 

4. 사용자는 여러 소셜서비스를 통해 같은 회원으로 로그인할 수 있다.

가장 많이 연동가능할 수 있는 경우는 3가지 모든 서비스에 대해 로그인이 되도록 하는 것입니다.

대신 한 사용자는 카카오 계정을 최대 한 개, 구글 계정을 최대 한 개, 네이버 계정을 최대 한 개 연동할 수 있습니다.


3. 필요 도메인 서비스 기능 식별

1. 소셜 연동

소셜 연동 개념이 하나 필요합니다.

이 개념은 소셜서비스 이름, 소셜서비스 회원아이디, 우리 서비스 회원 아이디 3가지 정보를 담고 있어야하며 이를 통해 어떤 회원인 지 식별할 수 있어야 합니다. 저는 이걸 SocialConnection 이라 부르기로 했어요.

 

2. username / nickname / 패스워드 랜덤 생성 기능

 

우리 서비스는 사용자 생성시 기본적으로 email, username, nickname, password 네가지가 정보가 필요한데요.

소셜로그인을 통해 가입하는 회원은 이런 정보가 없습니다.

 

email 은 소셜 서비스에서 제공해주는 값을 쓰면 되고

username, nickname, password 는 랜덤으로 생성해주고 나중에 사용자가 변경할 수 있게 할거에요. 이 랜덤으로 생성하는 도메인 기능이 필요합니다.

 

3. 소셜 연동 생성 기능, 저장소 기능

소셜 연동을 생성하고, 저장/수정/등록/삭제 할 수 있어야하는데 이를 위한 기능도 추가해야합니다.

 

그 외에는 기존에 만들어둔 기능들로 충분하므로 위의 기능을 중심으로 기능을 구현해보겠습니다.


4. 도메인 개념

 

 

소셜 연동 개념을 만들기 위해서 4가지 도메인(개념) 객체를 추가했습니다.

 

4.1 소셜연동 아이디

class SocialConnectionId
internal constructor(
    value: Long,
) : DomainId<Long>(value) {

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

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

    override fun toString(): String {
        return "SocialConnectionId(value=$value)"
    }

    companion object {
        fun restore(value: Long): SocialConnectionId {
            return SocialConnectionId(value)
        }
    }
}

 

소셜 연동의 고유식별자를 담당하는 SocialConnectionId 입니다.

 

+

저는 프로젝트 초반 진행을 할 때는 식별자들도 별도의 개념으로 정의해야지 생각했는데

막상 진행하다보니 오버 엔지니어링 느낌이 들긴하더라구요.

나중에 그냥 Long 타입으로 처리하도록 바꿀까 생각 중입니다.

 

4.2 소셜서비스

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

enum class SocialService {
    GOOGLE, KAKAO, NAVER;

    companion object {
        fun restore(name: String): SocialService {
            return SocialService.valueOf(name.uppercase())
        }
    }
}

 

우리 서비스에서 지원하는 소셜서비스들입니다.

name 을 전달받아서 SocialService enum 을 생성하도록 했어요.

 

4.3 소셜 서비스 사용자

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

class SocialServiceUser
internal constructor(
    val service: SocialService,
    val userId: String,
) {

    companion object {
        fun restore(serviceName: String, serviceUserId: String): SocialServiceUser {
            return SocialServiceUser(
                service = SocialService.restore(serviceName),
                userId = serviceUserId
            )
        }
    }

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

        if (service != other.service) return false
        if (userId != other.userId) return false

        return true
    }

    override fun hashCode(): Int {
        var result = service.hashCode()
        result = 31 * result + userId.hashCode()
        return result
    }

    override fun toString(): String {
        return "SocialServiceUser(service=$service, userId='$userId')"
    }
}

 

소셜서비스 사용자는 소셜서비스의 어떤 사용자인지를 정의한 개념입니다.

어떤 소셜서비스의 어떤 회원인가에 대한 정보가 이 클래스로 정의됩니다.

 

4.4 소셜 연동

/**
 * 소셜 연동
 */
class SocialConnection
internal constructor(
    id: SocialConnectionId? = null,
    val memberId: MemberId,
    val socialServiceUser: SocialServiceUser,
    val linkedAt: ZonedDateTime,
) : DomainEntity<SocialConnectionId>(id) {

    companion object {

        internal fun create(
            memberId: MemberId,
            socialServiceUser: SocialServiceUser,
            currentTime: ZonedDateTime
        ): SocialConnection {
            return SocialConnection(
                memberId = memberId,
                socialServiceUser = socialServiceUser,
                linkedAt = currentTime,
            )
        }

        fun restore(
            id: Long,
            memberId: Long,
            socialServiceName: String,
            socialServiceUserId: String,
            linkedAt: ZonedDateTime
        ): SocialConnection {
            return SocialConnection(
                id = SocialConnectionId.restore(id),
                memberId = MemberId.restore(memberId),
                socialServiceUser = SocialServiceUser.restore(socialServiceName, socialServiceUserId),
                linkedAt = linkedAt
            )
        }
    }
}

 

소셜 연동 개념입니다.

고유 식별자를 가지고, 회원 아이디 / 소셜서비스 사용자 / 연동 시점에 관한 정보를 가지고 있습니다.


5. 도메인 서비스 구현

5.1 username / nickname / password 랜덤 생성 기능

 

@DomainService
internal class NicknameCreatorImpl : NicknameCreator {

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

    override fun createRandom(): Nickname {
        return Nickname.createRandom()
    }
}

 

NicknameCreator, UsernameCreator, PasswordManager 쪽에 랜덤 생성 메서드를 추가했습니다.

구현은 정적 팩터리 메서드 형태로 구현할 것입니다.

 

class Nickname
internal constructor(
    val value: String
) {

    companion object {

        internal const val MIN_LENGTH = 1
        internal const val MAX_LENGTH = 15
        internal const val RANDOM_NICKNAME_LENGTH = MAX_LENGTH
        
        // 생략

        internal fun createRandom(): Nickname {
            val value = UUID.randomUUID().toString().replace("-", "")
                .substring(0, RANDOM_NICKNAME_LENGTH)
            return Nickname(value)
        }
    }
package com.ttasjwi.board.system.member.domain.model

import com.ttasjwi.board.system.member.domain.exception.InvalidUsernameFormatException
import java.util.*

/**
 * 사용자 관점 아이디
 */
class Username
internal constructor(
    val value: String
) {

    companion object {
    
    	// 생략

        internal const val MIN_LENGTH = 4
        internal const val MAX_LENGTH = 15
        internal const val RANDOM_USERNAME_LENGTH = MAX_LENGTH
        
        // 생략

        internal fun createRandom(): Username {
            val value = UUID.randomUUID().toString()
                .replace("-", "")
                .substring(0, RANDOM_USERNAME_LENGTH)
            return Username(value)
        }
    }
    
    // 생략

}
package com.ttasjwi.board.system.member.domain.model

import com.ttasjwi.board.system.member.domain.exception.InvalidPasswordFormatException
import java.util.*

class RawPassword
internal constructor(
    val value: String
) {
    companion object {
    	// 생략
        
        internal const val RANDOM_PASSWORD_LENGTH = 16

        fun randomCreate(): RawPassword {
            val randomValue = UUID.randomUUID().toString().replace("-", "")
                .substring(0, RANDOM_PASSWORD_LENGTH)
            return RawPassword(randomValue)
        }
    }

}

 

실질적인 구현은 도메인 클래스 내부에서 구현했습니다.

 

5.2 소셜 연동 생성

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

import com.ttasjwi.board.system.member.domain.model.MemberId
import com.ttasjwi.board.system.member.domain.model.SocialConnection
import com.ttasjwi.board.system.member.domain.model.SocialServiceUser
import java.time.ZonedDateTime

interface SocialConnectionCreator {

    fun create(
        memberId: MemberId,
        socialServiceUser: SocialServiceUser,
        currentTime: ZonedDateTime
    ): SocialConnection
}
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.MemberId
import com.ttasjwi.board.system.member.domain.model.SocialConnection
import com.ttasjwi.board.system.member.domain.model.SocialServiceUser
import com.ttasjwi.board.system.member.domain.service.SocialConnectionCreator
import java.time.ZonedDateTime

@DomainService
class SocialConnectionCreatorImpl : SocialConnectionCreator {

    override fun create(
        memberId: MemberId,
        socialServiceUser: SocialServiceUser,
        currentTime: ZonedDateTime
    ): SocialConnection {
        return SocialConnection.create(
            memberId = memberId,
            socialServiceUser = socialServiceUser,
            currentTime = currentTime
        )
    }
}

 

소셜 연동 생성 기능은 SocialConnectionCreator 를 통해 하도록 합니다. 이때 내부 구현은 SocialConnection 이 합니다.

 

5.3 소셜 연동 저장/조회

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

import com.ttasjwi.board.system.member.domain.model.SocialConnection
import com.ttasjwi.board.system.member.domain.model.SocialServiceUser

interface SocialConnectionStorage {

    fun save(socialConnection: SocialConnection): SocialConnection
    fun findBySocialServiceUserOrNull(socialServiceUser: SocialServiceUser): SocialConnection?
}

 

소셜연동 저장 및 조회는 SocialConnectionStorage 를 통해 하도록 했습니다.

구현은 데이터베이스에서 담당하도록 하면 되는데,

 

external-db 쪽에 SocialConnectionStorageImpl 을 만들어두고

 

@Entity
@Table(name = "social_connections")
class JpaSocialConnection(

    @Id
    @Column(name = "social_connection_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Column(name = "member_id")
    val memberId: Long,

    @Column(name = "social_service")
    val socialService: String,

    @Column(name = "social_service_user_id")
    val socialServiceUserId: String,

    @Column(name = "linked_at")
    val linkedAt: ZonedDateTime
) {


    companion object {

        fun from(socialConnection: SocialConnection): JpaSocialConnection {
            return JpaSocialConnection(
                id = socialConnection.id?.value,
                memberId = socialConnection.memberId.value,
                socialService = socialConnection.socialServiceUser.service.name,
                socialServiceUserId = socialConnection.socialServiceUser.userId,
                linkedAt = socialConnection.linkedAt
            )
        }
    }

    fun toDomainEntity(): SocialConnection {
        return SocialConnection.restore(
            id = this.id!!,
            memberId = this.memberId,
            socialServiceName = this.socialService,
            socialServiceUserId = this.socialServiceUserId,
            linkedAt = this.linkedAt.withZoneSameInstant(TimeRule.ZONE_ID)
        )
    }
}

 

JPA 전용 모델을 만들고

(고유키, 인덱스 설계 등 DB 최적화는 아직 진행하지 않았습니다.)

 

interface JpaSocialConnectionRepository : JpaRepository<JpaSocialConnection, Long> {

    @Query(
        """
        SELECT sc FROM JpaSocialConnection as sc
        WHERE sc.socialService = :socialService AND sc.socialServiceUserId = :socialServiceUserId
    """
    )
    fun findBySocialServiceAndSocialServiceUserIdOrNull(
        @Param("socialService")socialService: String,
        @Param("socialServiceUserId")socialServiceUserId: String): JpaSocialConnection?
}

 

Jpa 리포지토리를 만든뒤

 

@Component
class SocialConnectionStorageImpl(
    private val jpaSocialConnectionRepository: JpaSocialConnectionRepository
) : SocialConnectionStorage {

    override fun save(socialConnection: SocialConnection): SocialConnection {
        val jpaModel = JpaSocialConnection.from(socialConnection)
        val savedJpaModel = jpaSocialConnectionRepository.save(jpaModel)
        socialConnection.initId(SocialConnectionId.restore(savedJpaModel.id!!))
        return socialConnection
    }

    override fun findBySocialServiceUserOrNull(
        socialServiceUser: SocialServiceUser,
    ): SocialConnection? {
        return jpaSocialConnectionRepository.findBySocialServiceAndSocialServiceUserIdOrNull(
            socialService = socialServiceUser.service.name,
            socialServiceUserId = socialServiceUser.userId
        )?.toDomainEntity()
    }
}

 

저장소에서는 JPA 기능을 사용하여 저장, 조회 할 수 있게 했습니다.

 

여기까지 하면 소셜 로그인에 관한 도메인 기능 구현은 끝!


6. 애플리케이션 서비스 구현

6.1 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.SocialServiceUser
import java.time.ZonedDateTime

internal class SocialLoginCommand(
    val socialServiceUser: SocialServiceUser,
    val email: Email,
    val currentTime: ZonedDateTime,
)
@ApplicationCommandMapper
internal class SocialLoginCommandMapper(
    private val emailCreator: EmailCreator,
    private val timeManager: TimeManager,
) {

    fun mapToCommand(request: SocialLoginRequest): SocialLoginCommand {
        val socialServiceUser = SocialServiceUser.restore(request.socialServiceName, request.socialServiceUserId)
        val email = emailCreator.create(request.email).getOrThrow()

        return SocialLoginCommand(
            socialServiceUser = socialServiceUser,
            email = email,
            currentTime = timeManager.now()
        )
    }
}

 

소셜로그인 명령 및 명령 매퍼입니다.

입력된 소셜서비스 이름, 소셜서비스 사용자 아이디를 SocialServiceUser 개념 객체로 만들고

입력된 이메일 값 역시 EmailCreator 를 통해 생성합니다.

 

그리고 현재 시간 정보를 더하여 SocialLoginCommandMapper 로 만들어 줍니다.

6.2 Processor

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

@ApplicationProcessor
internal class SocialLoginProcessor(
    private val memberFinder: MemberFinder,
    private val socialConnectionCreator: SocialConnectionCreator,
    private val socialConnectionStorage: SocialConnectionStorage,
    private val passwordManager: PasswordManager,
    private val usernameCreator: UsernameCreator,
    private val nicknameCreator: NicknameCreator,
    private val memberCreator: MemberCreator,
    private val memberAppender: MemberAppender,
    private val authMemberCreator: AuthMemberCreator,
    private val accessTokenManager: AccessTokenManager,
    private val refreshTokenManager: RefreshTokenManager,
    private val refreshTokenHolderFinder: RefreshTokenHolderFinder,
    private val refreshTokenHolderManager: RefreshTokenHolderManager,
    private val refreshTokenHolderAppender: RefreshTokenHolderAppender,
) {

    fun socialLogin(command: SocialLoginCommand): SocialLoginResult {
        // 회원 획득 (없다면 생성)
        val (memberCreated, member) = getMemberOrCreate(command)

        // 인증회원 구성
        val authMember = authMemberCreator.create(member)

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

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

        // 소셜 로그인 결과 생성
        return makeResult(accessToken, refreshToken, memberCreated, member)
    }

}

 

소셜 로그인 로직입니다.

 

1. getOrMemberOrCreate 를 통해 회원을 가져오거나 생성하고

2. 인증 회원을 구성한 뒤

3. 인증회원을 가져다가, 토큰(액세스토큰, 리프레시토큰)을 발급

4. 리프레시 토큰 홀더를 갱신시키고

5. 소셜로그인 결과를 생성하는 방식입니다.

 

기존 로그인 프로세서와 거의 코드는 유사한데 회원을 획득하는 부분의 차이가 있습니다.

여기서 getMemberOrCreate 부분만 보겠습니다.

 

    /**
     * 소셜 연동된 회원을 얻어옵니다.
     * 소셜 연동이 없을 경우, 이메일에 해당하는 회원을 얻어옵니다.
     * 소셜 연동도 없고, 이메일에 해당하는 회원이 없을 경우 회원을 생성합니다.
     */
    private fun getMemberOrCreate(command: SocialLoginCommand): Pair<Boolean, Member> {
        // 소셜 연동에 해당하는 회원을 식별하는데 성공하면, 회원을 그대로 반환
        val socialConnection = socialConnectionStorage.findBySocialServiceUserOrNull(command.socialServiceUser)
        if (socialConnection != null) {
            return Pair(false, memberFinder.findByIdOrNull(socialConnection.memberId)!!)
        }

        // 소셜 연동은 없지만 이메일에 해당하는 회원이 있으면, 소셜 연동 시키고 회원을 그대로 반환
        val member = memberFinder.findByEmailOrNull(command.email)
        if (member != null) {
            createSocialConnectionAndSave(member.id!!, command)
            return Pair(false, member)
        }

        // 회원도 없고, 소셜 연동도 찾지 못 했으면 회원 생성 및 소셜 연동 생성 후 회원 반환
        return createNewMember(command)
    }

    /**
     * 신규회원을 생성합니다.
     */
    private fun createNewMember(
        command: SocialLoginCommand
    ): Pair<Boolean, Member> {
        // 회원 생성
        val member = memberCreator.create(
            email = command.email,
            password = passwordManager.createRandomRawPassword(),
            username = usernameCreator.createRandom(),
            nickname = nicknameCreator.createRandom(),
            currentTime = command.currentTime,
        )
        // 회원 저장
        val savedMember = memberAppender.save(member)
        createSocialConnectionAndSave(savedMember.id!!, command)
        return Pair(true, member)
    }

    /**
     * 소셜 연동을 생성하고, 저장합니다.
     */
    private fun createSocialConnectionAndSave(
        memberId: MemberId,
        command: SocialLoginCommand
    ) {
        val socialConnection = socialConnectionCreator.create(
            memberId,
            command.socialServiceUser,
            command.currentTime
        )
        socialConnectionStorage.save(socialConnection)
    }

 

getMemberOrCreate 에서는 회원을 조회하거나, 회원을 생성합니다.

 

 

먼저 소셜연동 저장소에서 SocialServiceUser 를 통해 소셜연동을 조회하고, 소셜 연동을 조회해옵니다.

 

1. 소셜 연동이 있을 때

여기서 찾아진 소셜연동이 있다면, 소셜 연동이 가지고 있는 MemberId를 통해 회원을 조회하고 그 회원을 반환합니다.

 

2. 소셜 연동은 없지만 이메일에 대응되는 회원이 있을 때

찾아진 소셜연동이 없으면, 이메일에 대응되는 회원으로 회원을 찾는데 회원이 찾아졌다면

회원의 소셜연동을 새로 생성하여 저장한 뒤, 회원을 반환합니다.

 

3. 소셜 연동도 없고 이메일에 대응되는 회원도 없을 때

찾아진 소셜연동이 없고 이메일에 대응되는 회원도 없으면

새로 회원을 만들고 저장한 뒤, 반환합니다.

 

6.3 ApplicationService

@ApplicationService
internal class SocialLoginApplicationService(
    private val commandMapper: SocialLoginCommandMapper,
    private val processor: SocialLoginProcessor,
    private val transactionRunner: TransactionRunner,
) : SocialLoginUseCase {

    override fun socialLogin(request: SocialLoginRequest): SocialLoginResult {

        val command = commandMapper.mapToCommand(request)

        return transactionRunner.run {
            processor.socialLogin(command)
        }
    }
}

 

유즈케이스의 구현체, 애플리케이션 서비스는

commandMapper 를 통해 요청을 애플리케이션 명령으로 변환하고

애플리케이션 명령을 프로세서를 통해 처리하도록 합니다.

 

여기까지 하면 소셜로그인 기능 전체 구현이 완료됩니다.


7. 실행

7.1 실행

 

애플리케이션 구동 시점에 DDL 자동 실행 기능이 작동하여, social_connections 테이블이 생성됩니다.

7.2 신규가입

 

우선 인가 엔드포인트에 요청해서 리다이렉트된 승인페이지에서 승인과정을 거친다음,

 

리다이렉트 된 페이지의 쿼리파라미터들을 전부 복사하고

 

최초 우리 서버의 소셜 로그인 엔드포인트에 그대로 쿼리파라미터들을 전부 전달하여 소셜로그인을 시도해봅니다.

응답을 보면 제대로 소셜 로그인에 성공했습니다.

 

회원가입을 안 해둔 상태기 때문에, 소셜연동 정보도 없고, 이메일에 해당하는 회원도 없습니다.

그래서 신규회원이 생성됐습니다.

 

7.3 이메일 가입 시도

이 상태로 이메일 방식 회원가입을 시도하면 예외가 발생합니다. 

이메일에 해당하는 회원이 생성됐고, 중복 예외가 발생했기 때문이죠.

 

7.4 카카오 회원가입 시도

저의 카카오 계정은 회원정보가 google 과 동일한 이메일 주소로 설정되어있는데요.

이 상태로 한번 카카오 로그인을 시도해보겠습니다.

 

소셜 로그인에 성공했는데, 회원이 생성되지 않았습니다.

 

발급된 액세스토큰을 보면 아까 생성된 회원을 가리키는 액세스 토큰이 만들어진 것을 볼 수 있습니다.

sub(회원아이디) 필드가 1로 되어있어요.

 

mysql> select * from member;
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
| member_id | registered_at              | email                | nickname        | password                                                             | role | username        |
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
|         1 | 2024-12-02 07:38:31.455963 | ttasjwi920@gmail.com | 15854c3ffaf744b | {bcrypt}$2a$10$fUP5CBTi2zN6tlqJT1Y./O1itwmfPmbNMqxbak0az8SU2LbG9rTVS | USER | f358538da888437 |
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
1 row in set (0.00 sec)

 

DB를 조회해보면 회원은 1개만 존재하고

 

mysql> select * from social_connections;
+----------------------------+-----------+----------------------+----------------+------------------------+
| linked_at                  | member_id | social_connection_id | social_service | social_service_user_id |
+----------------------------+-----------+----------------------+----------------+------------------------+
| 2024-12-02 07:38:31.455963 |         1 |                    1 | GOOGLE         | xxxxxxxxxxxxxxxxxxxxx  |
| 2024-12-02 07:47:55.384806 |         1 |                    2 | KAKAO          | yyyyyyyyyy             |
+----------------------------+-----------+----------------------+----------------+------------------------+
2 rows in set (0.00 sec)

 

소셜연동 테이블에는 소셜 연동이 2개가 생성되어있는 것을 볼 수 있습니다.

하나는 member_id 가 1인 회원의 Google 소셜연동, kakao  소셜연동이 각각 존재합니다.

 

7.5 네이버 회원가입 시도

이번에는 네이버 회원가입을 시도해보겠습니다. 제 네이버 회원 id 는 기존의 google, kakao 와 다르게 이메일 주소가 다르게 설정되어 있습니다.

이번에는 회원이 새로 생성됐습니다. 기존과 이메일이 다르기 때문입니다.

mysql> select * from member;
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
| member_id | registered_at              | email                | nickname        | password                                                             | role | username        |
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
|         1 | 2024-12-02 07:38:31.455963 | ttasjwi920@gmail.com | 15854c3ffaf744b | {bcrypt}$2a$10$fUP5CBTi2zN6tlqJT1Y./O1itwmfPmbNMqxbak0az8SU2LbG9rTVS | USER | f358538da888437 |
|         2 | 2024-12-02 07:56:04.116906 | xxxxxx@naver.com     | 132a3c589ace4eb | {bcrypt}$2a$10$Ih6/LiT1cfLW8oLG1cySveldKVBbQa.xntQLzUz/17GxtvxuG60f. | USER | 3003ac4eac1f4dd |
+-----------+----------------------------+----------------------+-----------------+----------------------------------------------------------------------+------+-----------------+
2 rows in set (0.00 sec)

 

회원 테이블에는 회원이 새로 생성되어 있고

mysql> select * from social_connections;
+----------------------------+-----------+----------------------+----------------+---------------------------------------------+
| linked_at                  | member_id | social_connection_id | social_service | social_service_user_id                      |
+----------------------------+-----------+----------------------+----------------+---------------------------------------------+
| 2024-12-02 07:38:31.455963 |         1 |                    1 | GOOGLE         | xxxxxxxxxxxxxxxxxxxxx                       |
| 2024-12-02 07:47:55.384806 |         1 |                    2 | KAKAO          | yyyyyyyyyy                                  |
| 2024-12-02 07:56:04.116906 |         2 |                    3 | NAVER          | zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz |
+----------------------------+-----------+----------------------+----------------+---------------------------------------------+
3 rows in set (0.00 sec)

 

소셜 연동 테이블에서도 새로운 소셜 연동이 추가됐습니다.

대신, 소셜연동이 가리키는 member_id 가 새로 추가된 member_id = 2 인 회원입니다.

 

7.6 다시 구글 로그인

 

이번에는 다시 구글 로그인을 시도해봤습니다. 이번에는 회원이 생성되지 않고, 기존의 정보를 사용해서 회원이 생성됐습니다.


8. 추가적으로 처리해야할 일

 

일단 이렇게 소셜로그인 기능은 잘 작동합니다.

문제는 이 모든 기능이 신뢰할만큼 잘 작동하는 지 테스트 코드를 작성하는 부분이 될텐데요.

 

저는 일단 스프링시큐리티 부분에 대한 테스트 통제는 모든 부분을 하기 힘들어서 모두 작성해두지 않았고,

소셜로그인 유즈케이스, 도메인서비스 측의 부분에 대해서 테스트를 충분히 작성해뒀습니다.

 

문제가 터지더라도 스프링 시큐리티쪽에서 터질텐데 이 부분은 그때그때 문제를 확인하고 테스트를 추가적으로 보강해서 작성하면서 문제를 해결해나가면 되지 않을까 싶네요.


 

저희 서비스는 이제 이메일 방식 또는 소셜 로그인 방식으로 회원을 가입시키거나 로그인시킬 수 있게 됐습니다.

회원을 가입시켰으니, 여기에 기반하여 간단한 서비스를 구현해보면 될 것 같은데 이 부분은 뒤에 이어지는 글들에서 이어서 작성해보겠습니다.

 

(다만 제가 취준 상황이라서 기능 개발을 추가적으로 할 시간이 충분할 지는 모르겠습니다. ㅠㅠ)


GitHub 리포지토리

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

 

GitHub - ttasjwi/board-system

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

github.com

 

Comments