Redis로 완성한 일회용 코드 SSO

Redis로 완성한 일회용 코드 SSO

- 토큰 직접 노출을 줄이기 위한 임시 코드 발급, 소비, 중복 요청 제어 경험 -

1. 적용 배경

이번 작업은 내부 업무 시스템에서 별도의 수동 로그인 없이 설문 서비스 화면으로 진입할 수 있도록 SSO 흐름을 구성하는 과정에서 시작되었습니다. 사용자는 내부 업무 시스템에서 특정 메뉴나 대상 화면을 선택하고, 서비스 화면으로 자연스럽게 이동해야 했습니다. 처음에는 인증 서버에서 발급받은 accessToken과 refreshToken을 그대로 응답으로 내려주면 간단하게 해결될 것처럼 보였습니다.

그러나 token은 실제 인증 자격증명에 해당합니다. 이 값이 외부 응답, URL query, 브라우저 히스토리, 서버 로그, 캡처 화면, 메신저 공유 등에 남으면 보안상 위험합니다. 특히 화면 이동을 위해 브라우저가 열리는 구조에서는 URL이나 프론트 로그에 값이 남을 가능성을 함께 고려해야 했습니다. 따라서 token을 직접 전달하지 않고, 짧은 시간만 유효한 일회용 loginCode를 전달한 뒤 프론트가 별도 인증 API를 통해 token을 교환받는 구조를 선택했습니다.

이 글에서는 실제 업무에서 적용했던 구조를 공개 블로그에 맞게 일반화하여 설명합니다. 실제 프로젝트의 패키지명, 내부 도메인, 서버 주소, 병원명, 환자 식별값 등은 노출하지 않고, 기술 선택 배경과 문제 해결 과정 중심으로 정리했습니다.

2. 전체 구조

구현 구조는 크게 네 영역으로 나누었습니다. 첫 번째는 내부 업무 시스템의 요청을 받아 로그인 코드를 발급하는 adapter 역할의 서버입니다. 두 번째는 loginCode와 tokenJson을 임시 저장하는 Redis입니다. 세 번째는 프론트가 전달한 loginCode를 소비하여 token을 반환하는 account 역할의 인증 서버입니다. 마지막으로 프론트는 URI의 query parameter에서 loginCode를 읽고 consume(소비) API를 호출한 뒤 token을 저장합니다.

전체 흐름은 다음과 같습니다.

업무 시스템
  -> adapter: 진입 URI 발급 요청
  -> adapter: 인증 서버에서 직원 token 발급
  -> Redis: tokenJson, loginCode, userKey 저장
  -> adapter: loginCode가 포함된 rsltUri 반환
  -> front: rsltUri로 진입 후 loginCode 추출
  -> account: loginCode consume 요청
  -> Redis: tokenJson 반환 및 key 삭제
  -> front: token 저장 후 목표 화면 표시

이 구조의 핵심은 token 자체를 화면 이동 경로에 싣지 않는 것입니다. 업무 시스템과 프론트 사이에는 token이 아닌 일회용 loginCode만 전달합니다. account 서버는 loginCode를 받은 뒤 Redis에서 tokenJson을 꺼내고, 동시에 해당 code를 폐기합니다.

3. token 대신 loginCode를 전달한 이유

accessToken과 refreshToken은 인증된 사용자를 증명하는 값입니다. 따라서 token을 직접 URL에 붙이는 방식은 피해야 했습니다. token 값이 직접 노출되면 유효 시간 동안 해당 사용자의 권한으로 API를 호출할 수 있기 때문입니다.

loginCode도 token을 꺼낼 수 있는 값이므로 민감하지 않다고 볼 수는 없습니다. 다만 token 자체와 달리 짧은 TTL을 가지며, 한 번 사용되면 즉시 삭제되도록 만들 수 있습니다. 즉 loginCode는 token을 직접 대체하는 값이 아니라, Redis에 임시 저장된 token을 한 번만 꺼내기 위한 교환권입니다.

accessToken / refreshToken
  - 실제 인증 자격증명입니다.
  - URL이나 외부 응답에 직접 노출하지 않는 것이 안전합니다.
loginCode
  - Redis에 저장된 tokenJson을 꺼내기 위한 일회용 교환권입니다.
  - TTL과 GETDEL 성격의 소비 로직으로 사용 범위를 제한합니다.

4. Redis key 설계

Redis에는 여러 종류의 key를 저장했습니다. 처음에는 단순히 loginCode를 key로 tokenJson만 저장하면 된다고 생각했지만, 실제 적용 과정에서는 중복 발급, 동시 요청, 소비 후 정리까지 함께 고려해야 했습니다.

codeKey
app:sso:login-code:{loginCode}
-> tokenJson
userKey
app:sso:login-code:user:{userHash}
-> loginCode
codeUserKey
app:sso:login-code:code-user:{loginCode}
-> userKey
lockKey
app:sso:login-code:lock:{userHash}
-> LOCKED

codeKey는 loginCode로 tokenJson을 찾기 위한 key입니다. userKey는 같은 진입 단위에서 이미 발급된 미사용 loginCode가 있는지 확인하기 위한 key입니다. codeUserKey는 account가 loginCode만 가지고도 userKey를 찾아 삭제할 수 있도록 만든 역방향 key입니다. lockKey는 동시에 여러 issue 요청이 들어왔을 때 하나의 요청만 실제 발급을 수행하도록 제어하기 위한 key입니다.

5. URI 분기와 진입 대상 구분

업무 시스템에서 사용자가 서비스 화면을 여는 방식은 두 가지였습니다. 하나는 특정 대상 화면으로 바로 진입하는 경우이고, 다른 하나는 개인 대시보드로 진입하는 경우입니다. 따라서 adapter는 단순 loginCode만 반환하지 않고, 프론트가 열어야 할 rsltUri를 만들어 반환하도록 변경했습니다.

대상 식별값이 있는 경우:
/service/staff/survey/response?loginCode={code}&targetId={targetId}
대상 식별값이 없는 경우:
/service/staff/dashboard/personal?loginCode={code}

공개 글에서는 실제 도메인, 실제 path, 실제 식별자 이름은 일반화했습니다. 중요한 점은 loginCode가 화면 이동 URI에 포함되고, 프론트가 해당 값을 읽어 account consume API를 호출한다는 구조입니다.

private String buildLoginUri(String loginCode, String targetId) {
    if (hasText(targetId)) {
        return baseUrl + "/staff/survey/response"
                + "?loginCode=" + loginCode
                + "&targetId=" + targetId;
    }
    return baseUrl + "/staff/dashboard/personal"
            + "?loginCode=" + loginCode;
}

6. userHash 기준을 조정한 이유

구현 과정에서 가장 오래 고민했던 부분은 userHash에 무엇을 포함할지였습니다. 처음에는 userHash를 직원 정보 기준으로만 만들면 된다고 생각했습니다. 실제 token의 주체는 직원이고, 대상 식별값은 화면 이동에만 쓰이는 값이라고 보았기 때문입니다.

하지만 URI 분기 요구사항을 다시 보면, 대상 식별값이 있는 경우와 없는 경우는 서로 다른 진입 목적입니다. 같은 직원이 짧은 시간 안에 대상 A 화면과 대상 B 화면을 연속으로 열 수 있고, 두 화면이 동시에 브라우저 탭으로 열릴 수도 있습니다. 이때 userHash를 직원 기준으로만 만들면 서로 다른 화면 진입 요청이 같은 userKey로 묶일 수 있습니다.

직원 기준으로만 userHash를 만들면:
직원 A + 대상 111 -> userHash H1 -> loginCode X
직원 A + 대상 222 -> userHash H1 -> loginCode X
결과:
두 화면이 같은 loginCode를 공유할 수 있습니다.
loginCode는 일회용이므로 한 화면이 먼저 consume하면 다른 화면은 실패할 수 있습니다.

그래서 최종적으로 userHash는 직원 정보와 진입 대상을 함께 고려하도록 조정했습니다. 이것은 token의 주체를 바꾸는 것이 아닙니다. token의 사용자는 여전히 직원입니다. 다만 Redis의 중복 발급 기준을 “직원” 단위가 아니라 “직원 + 진입 목적” 단위로 나눈 것입니다.

직원 + 진입 대상 기준으로 userHash를 만들면:
직원 A + 대상 111 -> userHash H111 -> loginCode X
직원 A + 대상 222 -> userHash H222 -> loginCode Y
직원 A + 대시보드 -> userHash HD -> loginCode Z

7. 동시 요청을 막기 위한 lockKey

userKey만으로는 동시성 문제를 완전히 막을 수 없습니다. 예를 들어 같은 사용자가 같은 화면을 여는 요청이 거의 동시에 여러 번 들어오면, 여러 요청이 모두 userKey가 아직 없다고 판단할 수 있습니다. 그러면 loginCode가 여러 개 발급될 수 있습니다.

이를 막기 위해 Redis의 setIfAbsent 성격을 이용한 lockKey를 사용했습니다. lock을 획득한 요청만 실제 token 발급과 Redis 저장을 수행하고, lock을 얻지 못한 요청은 짧게 대기한 뒤 userKey에 저장된 기존 loginCode를 반환합니다.

locked = setIfAbsent(lockKey, "LOCKED", lockTtl)
if (!locked) {
    existingCode = waitAndGet(userKey)
    return buildLoginUri(existingCode, targetId)
}
try {
    token = loginOrJoin(user)
    code = createLoginCode()
    saveCodeKeys(code, token, userKey)
    return buildLoginUri(code, targetId)
} finally {
    delete(lockKey)
}

이 방식으로 같은 진입 단위에 대해 동시에 여러 issue 요청이 들어와도 하나의 미사용 loginCode만 유지할 수 있었습니다. 반면 대상 식별값이 다른 화면은 userHash가 다르게 계산되므로 서로 다른 loginCode를 가질 수 있습니다.

8. 저장 순서와 codeUserKey의 역할

adapter에서 Redis에 저장할 때는 codeKey, codeUserKey, userKey 순서로 저장했습니다. userKey는 adapter가 “이미 발급된 code가 있다”고 판단하는 기준입니다. 따라서 실제 tokenJson이 저장되기 전에 userKey가 먼저 저장되면, 다른 요청이 아직 준비되지 않은 code를 받아갈 수 있습니다.

set(codeKey, tokenJson, ttl)
set(codeUserKey, userKey, ttl)
set(userKey, loginCode, ttl)

codeUserKey는 consume 성공 후 userKey까지 정리하기 위해 필요합니다. account 서버는 프론트로부터 loginCode만 전달받습니다. 따라서 account는 userHash를 직접 계산할 수 없습니다. adapter가 발급 시점에 codeUserKey를 만들어두면 account는 consume 시점에 codeUserKey를 통해 userKey를 찾아 삭제할 수 있습니다.

9. account의 consume 처리와 GETDEL

account 서버에서는 프론트가 전달한 loginCode를 받아 Redis에서 tokenJson을 꺼내고, 동시에 해당 key를 삭제합니다. 이때 단순 GET이 아니라 GETDEL 성격의 getAndDelete를 사용했습니다.

tokenJson = getAndDelete(codeKey)

if (tokenJson is blank) {
    throw expiredOrInvalid
}

userKey = getAndDelete(codeUserKey)
if (userKey exists) {
    delete(userKey)
}

return parseToken(tokenJson)

이 API는 DB를 수정하지 않고 Redis key를 소비하는 역할만 하므로 별도의 DB 트랜잭션을 사용하지 않았습니다. 또한 로그인 전 호출되어야 하므로 인증이 필요한 일반 API와 달리 permitAll 대상으로 설정했습니다.

10. 왜 TTL만으로 충분하지 않은가

TTL과 GETDEL은 서로 다른 문제를 해결합니다. TTL은 “언제까지 사용할 수 있는가”를 제한합니다. 반면 GETDEL은 “몇 번 사용할 수 있는가”를 제한합니다.

TTL만 사용하면 loginCode는 일정 시간이 지난 뒤 자동 삭제됩니다. 하지만 TTL이 끝나기 전까지는 같은 loginCode로 여러 번 token을 받을 수 있습니다. 그러면 일회용 로그인 코드가 아니라, 제한 시간 동안 재사용 가능한 로그인 코드가 됩니다.

TTL만 있는 경우:
1번째 consume ABC -> token 반환
2번째 consume ABC -> token 반환
3번째 consume ABC -> token 반환

GETDEL을 사용하는 경우:
1번째 consume ABC -> token 반환 + ABC 삭제
2번째 consume ABC -> expired or invalid

또한 GET 후 DELETE를 분리하면 동시 요청에서 두 요청이 모두 GET에 성공할 수 있습니다. Redis의 GETDEL 또는 Spring Data Redis의 getAndDelete는 조회와 삭제를 원자적으로 처리하기 때문에, 같은 loginCode로 동시에 여러 consume 요청이 들어와도 하나만 성공할 수 있습니다. 따라서 TTL은 방치된 code를 정리하기 위한 장치이고, GETDEL은 code를 진짜 일회용으로 만들기 위한 장치라고 정리할 수 있습니다.

11. 테스트 결과

로컬과 개발계에서 다음 항목을 확인했습니다. adapter가 rsltUri를 정상 반환하는지 확인했고, 대상 식별값이 있을 때와 없을 때 URI가 올바르게 분기되는지 확인했습니다. account consume API를 호출하면 token이 정상 반환되었고, 같은 loginCode를 두 번째로 consume하면 expired or invalid가 반환되는 것도 확인했습니다.

또한 consume 성공 후 Redis의 codeKey, codeUserKey, userKey가 삭제되는 것을 확인했습니다. userHash 기준을 직원 + 진입 대상 기준으로 조정한 뒤에는 서로 다른 대상 화면 진입 요청이 같은 loginCode를 공유하지 않는 것도 확인했습니다.

확인한 항목
- URI 발급 성공
- targetId의 유무에 따른 화면 분기 성공
- loginCode consume 성공
- 동일 loginCode 재사용 실패
- consume 후 Redis key 정리 확인
- 서로 다른 진입 대상의 loginCode 분리 확인

12. 적용하면서 배운 점

이번 작업을 통해 SSO 연동은 단순히 token을 받아서 전달하는 작업이 아니라는 점을 배웠습니다. token을 어디까지 노출할 것인지, 임시 code를 몇 초 동안 유지할 것인지, 한 번 사용한 code를 어떻게 폐기할 것인지, 중복 요청을 어떤 단위로 판단할 것인지 등 여러 기준을 명확히 정해야 했습니다.

특히 userHash에 대상 식별값을 포함할지 여부는 단순한 구현 문제가 아니라 정책 판단이었습니다. 처음에는 사용자가 직원이므로 직원 정보만으로 충분하다고 생각했습니다. 하지만 화면 진입 단위가 대상별로 달라지고, 여러 화면을 동시에 열 수 있다는 점을 고려하면서 중복 발급 기준을 더 세분화해야 한다는 결론에 도달했습니다.

GETDEL을 사용하는 이유도 처음에는 단순히 key를 빨리 지우기 위한 것으로 생각하기 쉬웠습니다. 그러나 실제로는 loginCode를 진짜 일회용으로 만들기 위한 핵심 정책이었습니다. TTL은 시간 제한이고, GETDEL은 사용 횟수 제한입니다. 이 둘은 서로 대체 관계가 아니라 함께 사용해야 하는 보완 관계입니다.

13. 마무리

최종적으로 이번 작업은 내부 업무 시스템에서 서비스 화면으로 진입하는 과정에서 token을 직접 노출하지 않고, Redis 기반 일회용 loginCode를 통해 안전하게 token을 교환하는 구조를 적용한 사례입니다. adapter는 사용자 정보와 진입 대상을 기반으로 URI를 생성하고, account는 loginCode를 원자적으로 소비하여 token을 반환합니다.

구현 과정에서 단순히 API를 하나 추가하는 수준을 넘어 Redis key 설계, 동시성 제어, GETDEL 기반 1회성 소비, consume 후 key 정리, 화면 분기, 프론트 연동까지 함께 고려했습니다. 이 경험을 통해 인증 흐름에서는 정상 동작뿐 아니라 노출 가능성, 중복 호출, 실패 시나리오까지 함께 설계해야 한다는 점을 체감했습니다.

Wade

Site footer