Skip to content
메모장
Go back

AWS KMS로 OAuth refresh_token 암호화하기 — 왜 카카오는 저장이 필요 없고 애플은 필요한가

개요

진행중인 프로젝트에서 회원탈퇴 시 카카오/애플 계정과의 unlink의 다른 API 스펙 을 다룬 방법과, 선택한 저장소와 암호화에 대해서 다룬다.


1. 카카오 — 어드민 키만 있으면 된다

카카오의 연결 끊기 API(POST /v1/user/unlink)는 두 가지 인증 방식을 지원한다.

어드민 키 방식은 로그인 당시 발급받은 토큰을 저장해둘 필요가 없다. providerId(카카오 회원번호)만 있으면 서버가 언제든 호출할 수 있다. 이 어드민 키는 로그인에 쓰는 REST API 키(Client ID)와는 다른, 앱 관리 콘솔에서 별도로 발급받는 키다.

@Component
@ConditionalOnProperty(name = ["kakao.unlink.admin-key"])
class KakaoUnlinkAdapter(
    private val kakaoWebClient: WebClient
) : SocialUnlinkPort {
    override fun unlink(providerId: String) {
        kakaoWebClient
            .post()
            .uri("/v1/user/unlink")
            .body(BodyInserters.fromFormData("target_id_type", "user_id").with("target_id", providerId))
            .retrieve()
            .toBodilessEntity()
            .block()
    }
}

2. 애플 — revoke에 로그인 당시 토큰이 반드시 필요하다

애플의 revoke 엔드포인트(POST /auth/revoke)는 다르다.

client_secret(동적 서명 JWT)뿐 아니라 로그인 당시 발급받은 refresh_token을 파라미터로 요구한다.

문제는 이 프로젝트가 Apple의 refresh_token을 애초에 저장하고 있지 않았다는 것

토큰 세 종류, 성격이 다 다르다

참고 문서

Apple 토큰 종류

토큰만료실질 용도
id_token~10분 (JWT exp)그 순간의 신원 증명, 즉시 소비되고 버림
access_token3600초(1시간), 공식 문서상 “Reserved for future use”사실상 쓸 데가 없음
refresh_token만료 없음 — revoke 전까지 유효revoke에 필요한 유일한 재료

refresh_token이 시간 기반으로 만료되지 않는다는 게 중요했다

캡처 지점 — OAuth2AuthorizedClientRepository

이 토큰을 손에 쥘 수 있는 유일한 지점이 OAuth2AuthorizedClientRepository.saveAuthorizedClient(...)다.

로그인 성공 직후 Spring Security가 자동으로 호출해주는데, 여기서 authorizedClient.refreshToken과 이미 매핑된 우리 도메인 User(→ provider, providerId)를 동시에 손에 넣을 수 있다.

@Component
@ConditionalOnProperty(name = ["aws.kms.key-id"])
class AppleTokenCapturingAuthorizedClientRepository(
    private val appleCredentialJpaRepository: AppleCredentialJpaRepository,
    private val tokenEncryptor: KmsEncryptor,
) : OAuth2AuthorizedClientRepository {

    override fun saveAuthorizedClient(authorizedClient: OAuth2AuthorizedClient, principal: Authentication, ...) {
        if (authorizedClient.clientRegistration.registrationId != "apple") return
        val refreshToken = authorizedClient.refreshToken?.tokenValue ?: return
        val user = (principal.principal as AuthenticatedUser).user

        // 같은 providerId로 이미 저장된 게 있으면(재로그인) 갱신, 없으면 신규 생성
        // — providerId에 unique 제약이 걸려 있어서 매번 새 id로 save()하면 재로그인 시 제약 위반이 난다
        val entity = appleCredentialJpaRepository.findByProviderId(user.providerId)
            ?.apply { this.encryptedRefreshToken = tokenEncryptor.encrypt(refreshToken) }
            ?: AppleCredentialJpaEntity(
                id = UuidGenerator.generate(),
                providerId = user.providerId,
                encryptedRefreshToken = tokenEncryptor.encrypt(refreshToken)
            )
        appleCredentialJpaRepository.save(entity)
    }

    override fun loadAuthorizedClient(...) = null // STATELESS 앱이라 읽기 경로는 안 씀
}

loadAuthorizedClient가 항상 null을 반환하는 이유는 이 앱이 세션리스(STATELESS) 인증 구조라 원래도 읽기용으로 쓸 일이 없기 때문이다. 이 컴포넌트는 순수하게 캡처(write) 전용이다.

참고 — 인터페이스 시그니처

OAuth2AuthorizedClientRepository(source)는 Spring Security 5.1부터 있던 표준 인터페이스다.

public interface OAuth2AuthorizedClientRepository {
    <T extends OAuth2AuthorizedClient> @Nullable T loadAuthorizedClient(
        String clientRegistrationId, Authentication principal, HttpServletRequest request);

    void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal,
        HttpServletRequest request, HttpServletResponse response);

    void removeAuthorizedClient(String clientRegistrationId, Authentication principal,
        HttpServletRequest request, HttpServletResponse response);
}

이 타입의 Bean을 커스텀 등록하면, oauth2Login {} DSL이 기본 구현(HttpSessionOAuth2AuthorizedClientRepository) 대신 자동으로 이걸 사용한다


3. 어디에 저장할 것인가 redis vs rds

이 값을 어디 저장할지는 Redis vs RDB 판단 기준 글의 기준을 그대로 적용해봤는데, 둘 다 걸렸다.

-> 결론은 “RDB + 암호화”였고, 암호화는 AWS KMS로 갔다.


4. 대칭키를 쓴 이유

@Component
@ConditionalOnProperty(name = ["aws.kms.key-id"])
class KmsEncryptor(
    private val kmsClient: KmsClient,
    private val kmsProperties: KmsProperties,
) {
    fun encrypt(plainText: String): String {
        val response = kmsClient.encrypt(
            EncryptRequest.builder().keyId(kmsProperties.keyId).plaintext(SdkBytes.fromUtf8String(plainText)).build()
        )
        return Base64.encode(response.ciphertextBlob().asByteArray())
    }

    fun decrypt(cipherText: String): String {
        val response = kmsClient.decrypt(
            DecryptRequest.builder().ciphertextBlob(SdkBytes.fromByteArray(Base64.decode(cipherText))).build()
        )
        return response.plaintext().asUtf8String()
    }
}

앱은 키 material을 절대 보지 않는다 -> KMS API(Encrypt/Decrypt) 두 개만 IAM 권한으로 호출한다.

마스터 키는 AWS HSM 안에만 존재하고, DB가 통째로 유출돼도 KMS 접근 권한까지 같이 뚫리지 않는 한 복호화가 불가능하다.

비용은 커스텀 관리 키 하나에 월 $1, API 호출은 월 20,000건까지 무료라 이 정도 저빈도 호출(유저당 로그인 1회 + 탈퇴 1회)엔 사실상 무시할 수준이다.

decrypt 요청엔 키 ID를 안 넘겨도 된다(ciphertext 안에 어떤 키로 암호화됐는지 메타데이터가 이미 포함돼 있다.)


5. 카카오·애플 둘 다 없는 로컬 환경 대비

두 어댑터 모두 필요한 설정값이 없으면 아예 Bean으로 등록되지 않도록 @ConditionalOnProperty를 걸었다.

@Configuration
@ConditionalOnProperty(name = ["aws.kms.key-id"])
@EnableConfigurationProperties(KmsProperties::class)
class KmsConfig { @Bean fun kmsClient(): KmsClient = ... }

여기서 중요한 건 같은 조건을 여러 곳에 반복해서 걸어야 한다는 점이다.

@ConfigurationProperties 바인딩은 그 값을 실제로 쓰는 Bean이 있든 없든 @EnableConfigurationProperties가 등록되는 순간 무조건 시도되기 때문에, Config 클래스 자체에도 조건을 걸지 않으면 프로퍼티가 없는 로컬 환경에서 앱이 아예 안 뜬다.

KmsConfig, KmsEncryptor, AppleUnlinkAdapter, AppleTokenCapturingAuthorizedClientRepository 전부 같은 aws.kms.key-id 조건을 달아야, 이 중 뭐 하나를 놓쳐도 애플 로그인 자체가 깨지는 걸 막을 수 있다.


요약

항목선택이유
카카오 unlink어드민 키 + providerId저장된 토큰 불필요, 로그인용 키와는 별개 키
애플 unlinkKMS로 암호화된 refresh_token 저장revoke API가 원본 refresh_token을 요구, providerId만으론 불가
refresh_token 캡처 지점OAuth2AuthorizedClientRepository.saveAuthorizedClientApple registrationId만 필터링, User 매핑까지 끝난 시점
저장소RDB + KMS 암호화Redis는 장기 보존 전제에 안 맞고, RDB 평문은 너무 민감
KMS 키 종류대칭키(AES-256)암복호화 주체가 동일(같은 백엔드) — 비대칭키 장점이 적용 안 됨
설정 부재 대비@ConditionalOnProperty를 관련 Bean 전부에 반복하나라도 빠지면 프로퍼티 부재 시 로그인 자체가 깨짐

Share this post:

Previous Post
@TransactionalEventListener(AFTER_COMMIT) 제대로 쓰기 — 캐시 무효화부터 예외를 삼키는 함정까지
Next Post
Redis vs RDB