Stan 기술블로그

리버스 프록시로 Keycloak 렐름별 접근제어 실전

지난 글에서는 Nginx Proxy Manager(NPM)로 리버스 프록시의 기초 — 도메인 라우팅, IP 화이트리스트, 경로 단위 접근제어의 개념 — 을 whoami 거울 백엔드로 익혔다. 이번엔 그 원리를 진짜 Keycloak 앞단에 적용하면서 만난 함정들, 그리고 서버 접근 권한 없이 실제 운영 서버의 노출 구조를 역추론한 이야기이다.

(사내 인프라 특성상 실제 도메인/IP/렐름 이름/설정값은 일반화한 예시로 대체했다.)


이번 글의 목표

지난 글 마지막에서 이렇게 예고했다.

“다음 글에서는 이 원리를 실제 Keycloak 앞단에 적용해, 렐름별로 내부/외부 노출을 관리하는 구성을 다뤄본다.”

목표를 한 문장으로 정리하면:

하나의 Keycloak(auth.example.com)에서, 렐름마다 내부/외부 노출을 다르게 — 리버스 프록시만으로 — 통제할 수 있는가?

Keycloak은 렐름을 URL 경로로 노출한다(/realms/<렐름>/...). 렐름이 URL에 보인다는 건 프록시가 경로를 보고 렐름별로 막고 열 수 있다는 뜻이다. 이론은 지난 글에서 whoami로 증명했다. 그런데 진짜 Keycloak을 붙이는 순간, whoami로는 절대 못 봤던 함정들이 쏟아졌다.


왜 whoami로는 부족했나

whoami는 어떤 경로로 요청하든 200으로 자기 정보를 뱉는 백엔드이다. 학습용 거울로는 최고지만, 바로 그 성질 때문에 경로에 민감한 실제 앱의 문제를 하나도 못 보여준다. /realms/foo를 치든 /aaa/bbb를 치든 whoami는 똑같이 200이니까.

그래서 테스트 환경에 일회용 Keycloak을 진짜로 띄웠다. (운영과 격리된 테스트 NPM과 같은 docker-compose 네트워크에 올려, 호스트 포트 노출 없이 컨테이너 이름으로 프록시)

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: start-dev          # 개발 모드 (내장 H2 DB)
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin

NPM에서 auth.test.local → keycloak:8080으로 프록시. 그리고 첫 삽질이 곧바로 터졌다.


함정 ① — “Custom Location”이 Keycloak을 통째로 죽였다

지난 글에서 경로 단위 제어는 NPM의 Custom Locations로 한다고 배웠다. 그대로 /realms/master에 IP 제한을 걸어봤다.

결과: 로그인 페이지가 404, 그리고 호스트 전체가 죽었다. 다른 경로까지 전부 404. 프록시 설정 파일(nginx conf)이 아예 생성되지 않았다.

원인을 파보니 이랬다.

NPM의 Custom Location은 단순히 “이 경로에 규칙 추가”가 아니라, 그 경로를 백엔드로 다시-프록시하는 별도 블록을 만든다. 경로가 재작성되면서, 경로에 민감한 실제 앱(Keycloak)은 엉뚱한 경로를 받아 깨지고 nginx 문법 검사도 실패한다.

whoami는 아무 경로나 200이라 이 재작성 부작용이 안 보였던 것이다. 실제 앱을 붙이고 나서야 드러난 맹점이었다.

복구: 그 Custom Location을 삭제하니 conf가 다시 생성되고 200으로 돌아왔다. 그리고 여기서 중요한 디버깅 습관 하나:

NPM UI에서 뭘 눌렀든, 진짜 결과물은 컨테이너 안의 nginx 설정 파일이다. 원인 확정은 항상 실제 생성된 conf를 직접 열어본다.

# NPM이 실제로 만든 nginx 설정을 직접 확인
docker exec <npm컨테이너> sh -c \
  'grep -rnE "location|allow|deny|proxy_pass" /data/nginx/proxy_host/'

올바른 방법 — Advanced 탭에 raw nginx, “필터만 얹기”

정답은 호스트 레벨 Advanced 탭(per-location 톱니가 아니라, 호스트 전체에 raw nginx를 넣는 곳)이었다. 핵심 발상의 전환:

다시-프록시하지 말고, 프록시는 원래대로 흘려보내되 그 위에 IP 필터만 얹는다.

location /realms/internal-realm {
    allow 10.0.0.0/8;   # 사내 대역(예시)
    deny all;
    proxy_pass http://keycloak:8080;   # ← 끝에 슬래시 없음 = 경로 보존
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

proxy_pass에 경로(끝 슬래시)를 붙이지 않는 것이 포인트이다. 이러면 nginx가 경로를 자르거나 재작성하지 않고 그대로 백엔드에 넘겨, Keycloak이 안 깨진다. (이 “끝 슬래시” 얘기는 뒤에서 훨씬 무서운 형태로 다시 나온다.)

여기서 배운 운영 팁:

NPM Advanced의 nginx 문법 오류는 컨테이너 런타임 로그(docker logs)에 안 뜬다. 저장 직후 깨졌는지 확인하려면 docker exec <npm> nginx -t로 직접 검사해야 한다. 그리고 Advanced는 append가 아니라 전체 replace — 같은 location을 중복 정의하면 nginx가 거부하고 호스트가 죽는다.


렐름별 차등 접근제어 달성

Keycloak 관리 콘솔에서 렐름 두 개를 만들었다 — public-realm(외부 공개용), internal-realm(내부 전용). 그리고 Advanced 탭에 렐름별 location 블록을 넣고 정책을 다르게 걸었다.

  • public-realm → 규칙 없음(기본 location으로 흘려 열림)
  • internal-realm, master → 사내 IP만 allow, 나머지 deny

응답 코드로 검증:

# (응답 코드만 관찰)
public-realm   : 200   ← 열림
internal-realm : 403   ← 프록시가 차단
master         : 403   ← 프록시가 차단

같은 도메인(auth.test.local), 렐름 경로별로 다른 노출 정책. 정책이 바뀌면 해당 렐름 location의 allow 목록만 갈아끼우면 내부↔외부가 토글된다. 목표였던 “리버스 프록시만으로 렐름별 내/외부 통제”를 실물로 증명한 순간이었다.

참고로 응답 코드 판독이 이 실습의 언어였다: 403 = 프록시가 막음 / 200·302·404 = Keycloak까지 도달(안 막힘). 그리고 로그인 화면 정적자원(/resources/...)은 /realms 밖에 있어서, 렐름 경로만 막고 공용 경로는 열어둬야 허용된 사용자의 로그인 화면이 안 깨진다.


함정 ② — “어제 만든 렐름이 사라졌다”

다음 날 서버를 다시 켜니 렐름이 전부 없어져 있었다. NPM 설정(프록시 규칙)은 멀쩡한데 Keycloak 렐름만 증발.

원인:

Keycloak을 start-dev로 띄우면 내장 H2 데이터베이스가 컨테이너 내부(/opt/keycloak/data)에 저장된다. 컨테이너를 삭제/재생성하면 그 데이터가 날아간다. NPM 설정은 호스트 바인드 마운트(./data)라 살아남지만, Keycloak엔 볼륨을 안 잡아둔 게 화근이었다.

수정은 간단했다. 그 데이터 경로에 named volume을 하나 붙이는 것 (메인 compose는 안 건드리고 override 파일에만 추가):

# docker-compose.override.yml
services:
  keycloak:
    volumes:
      - kc-data:/opt/keycloak/data
volumes:
  kc-data:

그리고 정말 영속되는지 증명했다. 예전에 렐름을 날렸던 바로 그 동작 — 컨테이너 완전 삭제 후 재생성:

docker compose rm -sf keycloak     # 컨테이너 파괴
docker compose up -d keycloak      # 재생성 (볼륨 다시 마운트)
# → 렐름 재조회: 살아있음. 볼륨 붙이기 전이었다면 여기서 사라졌을 것.

교훈:

“재부팅하면 사라진다”가 아니라 “컨테이너를 삭제/재생성하면 사라진다” 가 정확한 표현. 단순 재시작(restart)은 컨테이너를 유지하므로 데이터가 남는다. 개발 모드라도 데이터 경로에 볼륨만 잡으면 영속된다.


함정 ③ (하이라이트) — 슬래시 하나가 접근제어를 뚫었다

영속화를 끝내고 마지막 검증을 돌리는데, 이상한 게 나왔다.

internal-realm  (슬래시 있음)  /realms/internal-realm/   → 403   ✅ 막힘
internal-realm  (슬래시 없음)  /realms/internal-realm    → 301   ❌ ???

슬래시를 뺀 요청이 403(차단)이 아니라 301(리다이렉트)로 빠져나갔다. 301이 나왔다는 건 요청이 차단되지 않고 백엔드까지 도달했다는 뜻 — 즉 접근제어가 새고 있었다.

301이 어디로 가는지 봤더니 .../realms/internal-realm/ — 끝에 슬래시를 붙이라는 리다이렉트였다. 범인은 nginx의 auto_redirect였고, 이걸 이해하려면 nginx가 요청을 처리하는 순서를 알아야 한다.

① location 매칭  →  ②rewrite  →  ③ access(allow/deny)  →  ④ content(proxy_pass)
                                       ↑
                              우리 deny 규칙은 여기서 평가된다

문제의 블록은 이렇게 끝에 슬래시가 붙어 있었다.

location /realms/internal-realm/ {   # ← 슬래시로 끝남
    allow 10.0.0.0/8; deny all;
    proxy_pass http://keycloak:8080; # ← 경로 없음
}

이 “슬래시로 끝나는 location + 경로 없는 proxy_pass” 조합은 nginx의 auto_redirect를 켠다. 그러면 슬래시 없는 /realms/internal-realm 요청이 들어올 때, nginx는 ①매칭 단계에서 곧바로 301(슬래시 붙여 다시 오라)을 쏘고 끝낸다. ③access 단계(우리 deny)까지 가지도 않다.

  • /realms/internal-realm/ (슬래시 O) → 블록에 정확히 매칭 → ③access → deny → 403 ✅
  • /realms/internal-realm (슬래시 X) → ①에서 301 자동 리다이렉트 → deny 평가 자체가 안 일어남 ❌

nginx가 왜 이런 “친절”을 베푸냐면 — location /app/처럼 하위 경로로 앱을 서빙할 때 사용자가 /app으로 오면 백엔드의 상대경로 링크가 깨지니까, 슬래시를 붙여주려는 편의 기능이다. 그런데 이 좋은 의도가 하필 접근제어를 건너뛰는 부작용을 냈다.

수정은 딱 한 글자 — location에서 끝 슬래시 제거:

location /realms/internal-realm {    # ← 슬래시 없음 = auto_redirect 조건 탈락
    allow 10.0.0.0/8; deny all;
    ...
}

이제 슬래시가 없으니 301이 안 뜨고, 슬래시 없는 요청도 그냥 prefix 매칭되어 ③access까지 도달 → 403. 슬래시 유무와 무관하게 견고해졌다.

internal-realm  (슬래시 없음)  → 403   ✅ 고쳐짐
internal-realm  (슬래시 있음)  → 403   ✅
public-realm                  → 200   ✅ 그대로 열림

한 줄 요약: 슬래시로 끝나는 proxy location은 “슬래시 붙이라”는 301을 access(deny) 단계 전에 쏴서, 슬래시 없는 URL이 접근제어를 통째로 우회한다. 렐름 ACL을 걸 땐 location에 끝 슬래시를 붙이지 마라.

이게 이번 여정에서 제일 값진 삽질이었다. whoami(아무 경로나 200)로는 죽었다 깨어나도 못 봤을 버그니까.


실전 확인 — 서버 권한 없이 “진짜 서버”는 어떻게 돼있나 정찰하기

여기까지는 격리된 테스트 환경에서 “된다”를 증명한 것이었다. 그럼 실제 운영 Keycloak(auth.example.com)은 지금 어떻게 노출돼 있을까

문제는 내게 그 서버나 운영 프록시의 설정을 열어볼 권한이 없다는 것이었다. 가진 건 브라우저 접근과 관리 콘솔 계정뿐. 그래서 블랙박스 정찰 — 밖에서 HTTP 응답 코드만 관찰해 노출 구조를 역추론했다.

접근제어가 IP 기반이라면, “어디서 요청하느냐”가 곧 정책 판정기이다. 그래서 같은 요청을 사내망과 외부망(휴대폰 LTE) 두 곳에서 쏴서 비교했다.

for r in master internal-realm partner-realm; do
  echo -n "$r: "; curl -s -o /dev/null -w "%{http_code}\n" \
    https://auth.example.com/realms/$r
done
경로 내부(사내망) 외부(LTE)
/realms/master 200 403
/realms/internal-realm 200 403
/realms/partner-realm 200 403
/ (루트) 403 403

외부에서 전부 403 = 실제 auth.example.com은 렐름 구분 없이 통째로 내부 전용이었다. 어떤 렐름도 외부엔 열려있지 않았다. 즉 우리가 테스트로 만든 “렐름별 차등”은 현행 운영엔 아직 없는, “가능성”으로서의 미래 기능이었던 것이다.

첫 결론 — “경로 단위 ACL로 추정”

내부에서도 루트가 403인데?

표를 보면 이상한 게 있다. 내부에서 렐름은 200인데 루트 /는 내부에서도 403이다. 내부는 열린 거 아니었나?

여기서 대조군을 하나 놓는 게 결정적이었다. 우리 테스트 Keycloak의 루트를 프록시 우회로 직접 찔러봤다.

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8480/   # → 302

Keycloak의 루트는 원래 302(리다이렉트)를 준다. 그런데 실제 서버 루트는 403. 즉 그 403은 Keycloak이 준 게 아니라 프록시가 루트를 따로 막은 값이라는 뜻이다. (루트 /는 어차피 Keycloak이 서빙하는 실경로가 아니라, host/path 구분용으론 애초에 애매한 프로브였다는 점도 배웠다.)

여기서 조심스러운 역추론:

같은 내부 IP인데 /realms/*는 200이고 /는 403 → 운영 프록시는 경로를 구분해서 처리하고 있다(경로 단위 ACL로 추정). 다만 “호스트 전체를 막고 특정 경로만 여는 구조”인지, “운영 Keycloak 버전이 루트에서 자체적으로 403을 주는 구조”인지까지는 설정 원문 없이 100% 확정 불가.

중요한 건, 어느 쪽이든 결론은 안 바뀐다는 점이다: 외부에선 전부 닫힘, 내부 전용. 정찰의 목적은 달성했다.

이 결론으로 사수에게 보고서 초안을 냈다. 그리고 6일 뒤에 뒤집혔다.


6일 뒤 재검증 — 결론이 뒤집혔다

보고서 최종본을 쓰기 전에 같은 명령을 다시 돌렸다. 표가 달라졌다.

경로 1차 정찰 (내부) 재검증 (내부)
/ (루트) 403 302 (→ /admin/)

내부 루트가 403이 아니라 302였다 — 대조군에서 확인한 “Keycloak 본연의 응답” 그대로. 재현이 안 된 것이다.

그러면 1차의 403은 무엇이었나. 두 가지 가능성이 있다.

  • (A) 1차 측정 자체가 오류였다 (다른 네트워크, 세션 상태 등)
  • (B) 그 사이 실제 운영 설정이 바뀌었다

설정 원문과 변경 이력을 볼 권한이 없으니 (A)/(B) 확정은 불가능하다. 그러나 지금 재현되는 값은 302다. 그리고 302라면 루트도 “프록시가 따로 막은 것”이 아니라 그냥 Keycloak이 응답한 것이므로, 경로 간 차등이 없다.

새 데이터로 표를 다시 그리면 내부에선 루트(302)·모든 렐름(200)·관리콘솔(200)·관리 REST(401=인증필요)까지 전부 도달 가능하고, 외부에서만 전 경로 403이다. 즉 이 데이터가 가리키는 구조는 “경로 단위 ACL”이 아니라 호스트 단위 하나 — 통째로 내부만 허용이다.

블랙박스 정찰 데이터는 재현될 때까지 결론이 아니다. 1차의 추정은 논리적으론 그럴듯했지만 근거 데이터 한 줄이 재현되지 않으면서 통째로 뒤집혔다. 설정 원문을 못 볼 때는 같은 명령을 다른 시점·다른 위치에서 반복 실행해 재현성을 확인하는 것이 유일한 검증 도구다.

그리고 정정하는 방식도 배웠다. 틀린 항목을 소리 없이 지우지 않고 “1차엔 X였는데 재검증에서 Y로 나왔고, 권한 없이는 측정 오류인지 설정 변경인지 확정 불가”까지 같이 남겼다. 정정의 근거가 있어야 다음 결론도 신뢰받는다.

덤 — 과녁이 틀린 요청

표를 다시 그리다 하나 더 드러났다. 지시 예시에 “master 렐름을 외부로 열자”는 항목이 있었는데, 이 요청 자체가 과녁이 어긋나 있었다.

  • /realms/master는 master 렐름의 OIDC 엔드포인트(메타데이터·공개키·로그인)다. 관리 권한이 아니라 인증 표면이고, 오히려 이 경로를 막으면 관리콘솔 로그인 자체가 깨진다(콘솔이 이 경로로 인증을 걸기 때문).
  • 실제 관리자 권한 표면은 /admin/* — 콘솔 UI(/admin/master/console/)와 관리 REST(/admin/realms/*).

그래서 되물었다 — “master” 관련 요구가 실제로 관리자 접근(/admin)을 말하는 건지, master 렐름을 쓰는 일반 로그인을 말하는 건지. 이 되물음이 없었다면 잘못된 과녁에 정확한 화살을 쏘는 상황이 됐을 것이다.

이름이 비슷한 두 경로가 완전히 다른 역할일 수 있다. 정책 요청을 받을 때 “무엇을 통제하려는가”를 표면 단위로 되물어야 한다.


두 개의 시각: 콘솔(논리) vs 프록시(경로)

이 정찰에서 개념적으로 제일 크게 남은 건 두 계층을 분리해서 보는 습관이었다.

관리 콘솔(논리 계층) 리버스 프록시(경로 계층)
렐름·클라이언트·유저 목록 URL 경로별 노출/차단
“어떤 렐름이 존재하는가” “그 렐름 경로가 밖에서 보이는가”
Keycloak admin이 봄 curl 응답 코드가 봄

관리자 콘솔에 로그인할 수 있어도, “이 렐름이 외부에 열렸나”는 콘솔에 안 나온다. 그건 프록시 계층의 일이니까. 반대로 프록시는 ‘렐름’이라는 개념 자체를 모르고 URL 경로만 본다. 그래서 실제 노출 지도를 그리려면 콘솔(존재 목록) + 내부/외부 블랙박스(노출 여부) 를 겹쳐 봐야 했다.


마치며

whoami 거울로 개념을 익히고, 진짜 Keycloak을 붙이자마자 세 번 넘어졌다.

  1. Custom Location이 경로 민감 백엔드를 죽인다 → Advanced 탭에 raw nginx로 “필터만 얹기”. proxy_pass는 경로 없이.
  2. 개발 모드 데이터는 볼륨 없으면 컨테이너와 함께 사라진다 → 데이터 경로에 볼륨. “재시작”과 “컨테이너 재생성”은 다르다.
  3. 슬래시로 끝나는 proxy location은 접근제어를 우회시킨다 → 끝 슬래시 제거. nginx auto_redirect 301이 deny보다 먼저 터진다.

셋 다 whoami로는 죽었다 깨어나도 못 봤을 버그다. 학습용 거울은 아무 경로나 200이라, 경로에 민감한 실제 앱의 문제를 하나도 보여주지 않는다.

그리고 서버 권한 하나 없이도 밖에서 응답 코드만 비교해 운영 노출 구조를 역추론할 수 있다는 걸 배웠다 — 다만 그 결론은 재현될 때까지 결론이 아니었다. 6일 뒤 재검증에서 뒤집힌 게 이 글에서 가장 값진 부분이다.


참고

  • 대상 도구: Nginx Proxy Manager, Keycloak 26 (개발 모드, 내장 H2)
  • 배포 환경: 격리된 테스트 docker-compose (호스트 포트 노출 없이 컨테이너 이름으로 프록시)
  • 미확인 항목(서버 원문 열람 권한 확보 시 확정): 운영 프록시의 Access List 원문, 접근제어가 호스트 단위인지 경로 단위인지 최종 확정, 1차↔재검증 내부 루트 응답 불일치(403→302)의 원인

리버스 프록시·Keycloak 카테고리의 글