Published on

Keycloak OAuth2 로그인 루프(무한 302) 해결 가이드

Authors

서버 로그에는 302만 가득하고, 브라우저는 로그인 화면으로 되돌아가며, 애플리케이션은 끝없이 Authorization Code 플로우를 다시 시작하는 현상. Keycloak 연동에서 흔히 말하는 로그인 루프(무한 302) 는 대부분 “인증은 됐는데 세션이 유지되지 않거나, 콜백이 유효하지 않거나, 토큰 검증 기준(issuer/audience)이 어긋나서 다시 인증을 시도하는” 케이스로 압축됩니다.

이 글은 원인을 증상별로 빠르게 좁히는 체크리스트와, Keycloak/리버스 프록시/애플리케이션 설정을 어디부터 어떻게 고쳐야 하는지에 집중합니다. (Kubernetes/Ingress 환경에서 특히 빈번합니다. 장애 트러블슈팅 루틴은 Kubernetes CrashLoopBackOff 원인별 10분 진단 글의 접근법과 유사하게 “관측 → 가설 → 최소 변경 검증” 순서로 진행하면 시간을 크게 줄일 수 있습니다.)

1) 무한 302의 전형적인 흐름 이해

OIDC Authorization Code Flow 기준으로 정상 흐름은 대략 이렇습니다.

  1. 앱이 보호된 리소스 접근 감지 → Keycloak /auth로 302
  2. 로그인 성공 → Keycloak가 앱의 redirect_uri(콜백 URL)로 code를 붙여 302
  3. 앱이 code로 토큰 교환(/token) → 세션/쿠키 저장
  4. 이후 요청은 세션/쿠키로 인증 상태 유지

무한 302는 보통 3~4 단계에서 문제가 생깁니다.

  • 세션 쿠키가 브라우저에 저장되지 않음 → 매 요청이 “미인증”으로 판단되어 다시 1로
  • 콜백 URL/redirect_uri 불일치 → Keycloak가 다시 로그인 또는 에러 후 재시도
  • issuer/realm URL이 외부 URL과 불일치 → 토큰 검증 실패로 앱이 재인증
  • 프록시가 X-Forwarded-*를 잘못 전달 → Keycloak가 잘못된 URL로 리다이렉트

2) 10분 내 원인 좁히기: 관측 포인트 4가지

2.1 브라우저 Network 탭에서 확인할 것

  • 302 체인이 app → keycloak → app/callback → app → keycloak ... 형태인지
  • Set-Cookie가 내려오는지, 내려와도 차단(회색/경고) 되는지
  • 콜백 요청에 code, state, session_state가 포함되는지
  • 리다이렉트 URL이 http/https, host, path가 의도한 값인지

2.2 Keycloak 이벤트/로그에서 확인할 것

Keycloak Admin Console에서:

  • Events 활성화 후 LOGIN, CODE_TO_TOKEN 관련 이벤트 확인
  • 실패 이벤트에 invalid_redirect_uri, cookie_not_found, session_not_active 같은 힌트가 있는지

컨테이너 로그 레벨을 올려 더 자세히 보려면(Quarkus 기반 Keycloak 17+):

# 예: Docker/K8s 환경 변수
KC_LOG_LEVEL=DEBUG
KC_LOG_CONSOLE_OUTPUT=json

2.3 애플리케이션 로그에서 확인할 것

Spring Security(OAuth2 Client)라면 다음 키워드가 단서가 됩니다.

  • Invalid state parameter
  • Authorization request not found in session
  • Jwt issuer mismatch
  • Failed to validate the token

2.4 프록시/Ingress 레벨에서 확인할 것

  • X-Forwarded-Proto, X-Forwarded-Host가 올바른지
  • TLS 종료 지점(ELB/Ingress/Envoy/Nginx)과 앱/Keycloak이 인지하는 스킴이 일치하는지
  • (특히 Envoy) 리다이렉트/헤더 조작 여부

EKS에서 Envoy를 쓰는 경우 503 이슈처럼 “프록시가 실제 원인”인 경우가 많습니다. 프록시 관측 루틴은 EKS에서 Envoy 503 UF·URX 원인과 해결 10분 도 함께 참고하면 좋습니다.

3) 원인 1: Keycloak이 외부 URL을 모르고 잘못 리다이렉트

가장 흔한 케이스입니다. Keycloak이 자신이 http://keycloak:8080 같은 내부 주소로 서비스된다고 생각하면, 로그인 후 리다이렉트가 내부 주소/스킴으로 생성되어 브라우저가 쿠키를 못 붙이거나(도메인 불일치), Mixed Content/차단으로 이어져 루프가 발생합니다.

3.1 Keycloak 17+ (Quarkus)에서 프록시 인지 설정

Keycloak이 리버스 프록시 뒤에 있다면 아래를 우선 점검합니다.

# 대표적인 권장 조합(환경에 맞게 조정)
KC_PROXY=edge
KC_HTTP_ENABLED=true
KC_HOSTNAME=auth.example.com
KC_HOSTNAME_STRICT=true
KC_HOSTNAME_STRICT_HTTPS=true
  • KC_PROXY=edge: 외부에서 TLS 종료(HTTPS)하고 내부는 HTTP로 들어오는 전형적인 Ingress/ALB 패턴에 적합
  • KC_HOSTNAME*: Keycloak이 생성하는 모든 URL(issuer, redirect 등)의 기준을 외부 도메인으로 고정

3.2 Ingress/Nginx에서 X-Forwarded-* 전달

Nginx Ingress를 쓰면 보통 자동이지만, 커스텀 프록시/Envoy/ALB 조합에서는 누락되기도 합니다.

예: Nginx 리버스 프록시 설정

location / {
  proxy_pass http://keycloak:8080;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

검증 포인트: 로그인 후 리다이렉트 URL이 반드시 https://auth.example.com/... 형태로 유지되는지 확인하세요.

4) 원인 2: 쿠키가 저장/전송되지 않아 세션이 유지되지 않음

무한 302의 두 번째 왕좌는 쿠키입니다. OIDC 로그인 과정에서 Keycloak과 앱은 여러 쿠키를 사용합니다.

  • Keycloak 도메인 쿠키(SSO 세션)
  • 앱 도메인 쿠키(세션/STATE 저장)

4.1 SameSite/secure 정책으로 쿠키가 차단되는 경우

최근 브라우저는 기본적으로 쿠키 정책이 엄격합니다.

  • HTTPS가 아닌데 Secure 쿠키를 쓰면 저장되지 않음
  • 크로스사이트 리다이렉트에서 SameSite=Lax/Strict 때문에 쿠키가 안 붙는 경우

Keycloak은 보통 올바르게 설정되지만, “외부는 HTTPS인데 내부에서 HTTP로 인식”하는 순간 Secure 판단이 꼬여 쿠키가 기대대로 동작하지 않습니다. 결국 3)에서 말한 프록시/hostname 설정이 쿠키 문제로도 이어집니다.

4.2 Spring Boot/Spring Security에서 state 저장 세션이 날아가는 경우

Spring Security OAuth2 Client는 기본적으로 state를 세션에 저장합니다. 그런데 다음 상황이면 콜백에서 state를 찾지 못해 인증이 실패하고 재시도 루프가 납니다.

  • 로드밸런서 뒤에서 세션 스티키가 없고, 서버가 여러 대인데 세션이 메모리 기반
  • JSESSIONID 쿠키가 차단/미전송
  • 콜백 경로가 프록시에서 다른 호스트/스킴으로 바뀌어 세션 쿠키 스코프가 달라짐

해결 방향:

  • 세션을 Redis(Spring Session)로 공유하거나
  • 최소한 로그인 플로우 동안은 스티키 세션을 켜거나
  • 쿠키 도메인/경로/secure 설정을 외부 기준으로 정렬

Spring Session Redis 예시:

dependencies {
  implementation 'org.springframework.session:spring-session-data-redis'
  implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
spring:
  session:
    store-type: redis
  data:
    redis:
      host: redis
      port: 6379

5) 원인 3: redirect_uri 불일치 (특히 경로/슬래시/포트)

Keycloak은 redirect_uri를 엄격하게 검증합니다. 앱이 요청한 redirect_uri와 Keycloak Client 설정의 Valid Redirect URIs가 맞지 않으면 로그인 후 콜백이 거부되거나, 애매한 재시도로 루프처럼 보일 수 있습니다.

5.1 Keycloak Client 설정 체크

Keycloak Admin Console → Client → Settings:

  • Valid Redirect URIs: 예) https://app.example.com/login/oauth2/code/keycloak
  • Valid Post Logout Redirect URIs: 로그아웃 루프가 있다면 여기도 점검
  • Web Origins: CORS 문제가 섞이면 로그인 후 API 호출에서 다시 리다이렉트가 날 수 있음

와일드카드 *는 편하지만 보안상 지양하고, 최소 범위로 설정하세요.

5.2 Spring Boot 설정 예시

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-client
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            scope: openid,profile,email
        provider:
          keycloak:
            issuer-uri: https://auth.example.com/realms/myrealm

여기서 issuer-uri가 외부 URL과 반드시 일치해야 합니다(다음 섹션).

6) 원인 4: issuer(발급자) 불일치로 토큰 검증 실패 → 재인증

앱이 토큰을 받긴 받았는데, 검증 단계에서 실패하면 “인증 안 됨”으로 처리되어 다시 OAuth2 로그인으로 튕기면서 302 루프가 발생합니다.

전형적인 메시지:

  • The Issuer "http://keycloak:8080/realms/..." did not match "https://auth.example.com/realms/..."

6.1 해결: Keycloak의 외부 hostname 고정 + 앱의 issuer-uri 통일

  • Keycloak이 발급하는 iss 클레임이 외부 URL이 되도록 KC_HOSTNAME/프록시 설정 정리
  • 앱의 issuer-uri도 동일한 외부 URL로 설정

검증 방법:

# OIDC discovery 문서가 외부 URL 기준으로 내려오는지 확인
curl -s https://auth.example.com/realms/myrealm/.well-known/openid-configuration | jq .issuer

출력이 https://auth.example.com/realms/myrealm 여야 합니다.

7) 원인 5: Ingress/ALB에서 헤더 또는 경로 재작성으로 콜백이 변형됨

예를 들어 /auth로 들어온 요청을 내부에서 /로 rewrite하거나, X-Forwarded-Prefix를 잘못 주면 Keycloak이 콜백 경로를 다르게 계산할 수 있습니다. 특히 “Keycloak을 /keycloak 같은 서브패스로 제공”할 때 자주 터집니다.

7.1 서브패스 제공 시 권장: 가능하면 서브도메인 사용

  • 비권장: https://example.com/keycloak (서브패스)
  • 권장: https://auth.example.com (서브도메인)

서브패스는 프록시 rewrite/쿠키 path/issuer URL까지 모두 복잡해져 루프 가능성이 급증합니다.

8) 실전 트러블슈팅 절차(재현 → 최소 변경 → 검증)

아래 순서대로 하면 대부분의 무한 302는 빠르게 끝납니다.

8.1 1단계: 리다이렉트 체인 캡처

# -L로 따라가되, 쿠키/헤더를 보고 싶으면 -v 사용
curl -vkL https://app.example.com/protected \
  -o /dev/null
  • 중간에 Location:http://로 떨어지거나 내부 호스트로 바뀌는지 확인

8.2 2단계: OIDC discovery의 issuer 확인

curl -s https://auth.example.com/realms/myrealm/.well-known/openid-configuration | jq -r .issuer
  • 내부 주소가 나오면 Keycloak hostname/proxy 설정부터 수정

8.3 3단계: 브라우저에서 쿠키 차단 여부 확인

  • Application 탭 → Cookies
  • Secure, SameSite, Domain/Path 확인
  • 콜백 직후 앱 세션 쿠키가 생기는지 확인

8.4 4단계: 멀티 인스턴스면 세션 공유/스티키 검토

  • 로그인 요청과 콜백 요청이 서로 다른 파드로 가면 state가 사라질 수 있음
  • Redis 세션 또는 스티키로 먼저 안정화 후, 근본적으로 stateless(JWT) 구조로 개선

9) 자주 쓰는 “정답 조합” 예시 (K8s + Ingress + Keycloak + Spring)

9.1 Keycloak (환경 변수)

env:
  - name: KC_PROXY
    value: edge
  - name: KC_HOSTNAME
    value: auth.example.com
  - name: KC_HOSTNAME_STRICT
    value: "true"
  - name: KC_HOSTNAME_STRICT_HTTPS
    value: "true"
  - name: KC_HTTP_ENABLED
    value: "true"

9.2 Spring Boot (issuer-uri 외부 통일)

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: https://auth.example.com/realms/myrealm
        registration:
          keycloak:
            client-id: my-client
            scope: openid,profile,email

9.3 Ingress/Nginx (TLS 종료 + forwarded headers)

환경마다 다르지만 핵심은 X-Forwarded-Proto: https가 보장되는 것입니다.

10) 마무리: 무한 302는 “URL 기준점”과 “쿠키” 문제다

Keycloak OAuth2/OIDC 로그인 루프는 겉으로는 복잡해 보여도, 대부분 아래 두 축 중 하나로 정리됩니다.

  1. 외부 URL 기준점이 불일치: Keycloak이 만드는 URL(issuer/redirect)이 외부와 다름
  2. 세션/쿠키가 유지되지 않음: SameSite/Secure, 도메인/경로, 멀티 인스턴스 세션 문제

가장 먼저 /.well-known/openid-configurationissuer가 외부 URL인지 확인하고, 다음으로 브라우저에서 쿠키가 정상 저장/전송되는지 확인하면 “무한 302”의 80%는 10분 안에 끝납니다.

운영 환경이 EKS/Ingress/Envoy처럼 프록시 레이어가 두꺼울수록 원인이 애플리케이션이 아니라 라우팅/헤더/스킴 인지에서 출발하는 경우가 많습니다. 이때는 프록시 관측을 먼저 강화하고(요청 헤더/리다이렉트/쿠키), 최소 변경으로 한 가지씩 고쳐 검증하는 방식이 가장 빠릅니다.