본문으로 건너뛰기
문을 하나로 모으고, 열쇠를 둘로 나눈다

생성 수정

IBM * 레드햇 AX 과정 · 25강

문을 하나로 모으고, 열쇠를 둘로 나눈다

2026년 7월 20일(월) ~ 7월 24일(금)

이 강의 문제풀이 — 배운 내용을 주관식 문답으로 확인해보세요.풀어보기 →

이 장을 다 읽으면

  1. API·URL·IP·HTTP·SMTP·FTP·SSH·NS·DNS를 식별자 / 규약 / 이름 해석 / 계약 네 범주로 나누어 각각 정의할 수 있다.
  2. 인증(Authentication)과 인가(Authorization)를 구별하고, 401과 403이 왜 다른 코드인지 설명할 수 있다.
  3. OAuth 2.0이 "로그인 프로토콜"이 아니라 위임된 인가 프로토콜임을 알고, 소셜 로그인이 그 위에 얹힌 응용임을 설명할 수 있다.
  4. JWT의 세 토막을 열어 읽고, 서명이 암호화가 아니라는 사실이 왜 중요한지 말할 수 있다.
  5. HS256과 RS256의 차이를 "키가 하나인가 둘인가"로 설명하고, 개인키를 인증 컨테이너에만 두는 이유를 댈 수 있다.
  6. 포워드 프록시와 리버스 프록시를 누구 앞에 서는가로 구별하고, 게이트웨이·로드밸런서·CDN·터널이 전부 리버스 프록시의 변종임을 안다.
  7. docker-compose.yml에서 ports: 한 줄을 지우는 일이 왜 보안 작업인지 설명하고, 컨테이너 이름·localhost·host.docker.internal을 정확히 구분해 쓸 수 있다.
  8. 로컬 Ollama를 에이전트의 추론 프로바이더로 연결하고, 컨텍스트 부족 에러를 스스로 진단해 고칠 수 있다.
  9. AWS에서 VPC → EC2 → RDS → S3 순서로 자원을 만드는 이유를 대고, 사설 IP와 공인 IP의 비용 구조 차이를 설명할 수 있다.

0. 이 장의 판별 축

이번 주는 손으로 하는 일이 유난히 많은 주입니다. 절차가 길어질수록 축을 먼저 잡아야 합니다. 축이 없으면 "화면에서 시킨 대로 눌렀는데 안 된다"에서 멈추고, 축이 있으면 안 되는 지점이 어느 축의 어느 쪽인지 바로 짚입니다.

#축왼쪽 (0)오른쪽 (1)이 축으로 갈리는 것
1신원을 누가 보증하는가내가 직접 (비밀번호를 내가 보관)남에게 위임 (Provider가 보증)자체 로그인 ↔ 소셜 로그인 / OAuth
2서명 키가 하나인가 둘인가대칭 — 한 개 키로 서명·검증 (HS256)비대칭 — 개인키로 서명, 공개키로 검증 (RS256)발급부 ↔ 검증부 / auth 컨테이너 ↔ 백엔드 컨테이너
3무엇 앞에 서는가클라이언트 앞 = 포워드 프록시서버 앞 = 리버스 프록시프록시 ↔ 게이트웨이·로드밸런서·CDN·터널
4주소가 사설인가 공인인가사설 — 무료·무제한, 내부 DNS가 해석공인 — 제한·과금, 공인 DNS가 해석http://backend:8000 ↔ https://api.example.cloud / VPC CIDR ↔ 탄력적 IP
5문이 몇 개인가여러 개 열려 있음 (ports: 매핑)하나로 수렴 (터널 단일 진입)공격 표면(Attack Surface)의 크기

이 중 축 2와 축 5가 이 장의 중심입니다. 이번 주에 한 일을 한 문장으로 줄이면 이렇게 됩니다.

열쇠는 둘로 나누어 개인키를 한 컨테이너에 가두고(축 2), 문은 하나로 모아 바깥에서 닿을 수 있는 입구를 터널 하나로 줄였다(축 5).

축 3과 축 4는 축 5를 실현하는 도구입니다. 축 1은 그 문 앞에 세울 문지기입니다.

앞 장에서 이어지는 축 축 3의 "프록시"는 6주차에서 코드 안의 구조 패턴으로 이미 만났습니다(퍼사드·프록시·어댑터). 이번 주에는 같은 개념이 네트워크 장비의 이름으로 다시 나옵니다. 같은 구조가 크기만 바꿔 반복되는 것입니다. 축 4의 host.docker.internal은 10주차 12.11절에서 한 번 등장했습니다. 이번 주에 그 이름의 가족 관계가 완성됩니다. 축 5의 "장애 격리 / 단일 진입"은 9주차 스타 토폴로지의 허브 개념 그대로입니다.


1. 주소 · 이름 · 규약 — 인터넷의 아홉 단어

1.1 먼저 네 범주로 나눈다

API / URL / IP / HTTP / SMTP / FTP / SSH / NS / DNS.

이 아홉 개를 나란히 외우면 반드시 섞입니다. 나란히 있는 것이 아니기 때문입니다. 먼저 범주부터 나눕니다.

범주이것은 무엇인가여기 속하는 것
식별자 (Identifier)대상을 가리키는 주소·이름IP, URL
규약 (Protocol)주고받는 순서와 형식의 약속HTTP, SMTP, FTP, SSH
이름 해석 체계 (Naming)사람의 이름을 기계의 주소로 바꿔주는 체계DNS, NS
계약 (Contract)프로그램끼리 무엇을 요청하면 무엇을 준다는 약속API

이 표 하나로 절반이 끝납니다. IP와 HTTP는 애초에 비교 대상이 아닙니다. 하나는 주소이고 하나는 대화 방식입니다. "주소"와 "대화 방식"을 비교하려 드니까 헷갈리는 것입니다.

1.2 아홉 단어의 정의

유(類, 상위 범주) + 종차(種差) 형식으로 씁니다.

  • ⭐ IP (Internet Protocol Address) — [네트워크상의 식별자]인데, [장치 하나하나에 배정된 숫자 주소]라는 점에서 URL과 구별된다. 예: 192.168.0.228
  • ⭐ URL (Uniform Resource Locator) — [네트워크상의 식별자]인데, [장치가 아니라 그 안의 자원(resource) 하나를 가리키고, 어떤 규약으로 접근할지까지 포함한다]는 점에서 IP와 구별된다. 예: https://api.example.cloud/v1/users/3
  • ⭐ HTTP (HyperText Transfer Protocol) — [응용 계층 규약]인데, [요청과 응답 한 쌍으로 문서·데이터를 주고받는다]는 점에서 SMTP·FTP·SSH와 구별된다.
  • SMTP (Simple Mail Transfer Protocol) — [응용 계층 규약]인데, [메일을 보내는 쪽 방향만 담당한다]는 점에서 받는 쪽 규약(POP3·IMAP)과 구별된다.
  • FTP (File Transfer Protocol) — [응용 계층 규약]인데, [파일 자체를 통째로 옮기는 것이 목적]이라는 점에서 HTTP와 구별된다.
  • ⭐ SSH (Secure Shell) — [응용 계층 규약]인데, [원격 컴퓨터의 셸(명령 줄)에 암호화된 통로로 로그인해 명령을 실행한다]는 점에서 파일만 옮기는 FTP와 구별된다.
  • ⭐ DNS (Domain Name System) — [이름 해석 체계]인데, [도메인 이름을 IP 주소로 바꿔주는 전 세계 분산 시스템 전체]를 가리킨다는 점에서 NS와 구별된다.
  • NS (Name Server) — [DNS를 구성하는 부품]인데, [특정 도메인의 답을 실제로 들고 있는 서버 한 대]라는 점에서 체계 전체인 DNS와 구별된다.
  • ⭐ API (Application Programming Interface) — [프로그램 간의 계약]인데, [사람이 아니라 프로그램이 호출하는 창구]라는 점에서 사람이 보는 화면(UI)과 구별된다.

1.3 URL을 해부하면 나머지가 다 보인다

URL 한 줄 안에 위 단어 중 세 개가 들어 있습니다.

  https :// api.example.cloud : 443 /v1/users ? page=2 # top
  ─────    ────────────────── ───  ────────── ──────── ────
  ①스킴        ②호스트        ③포트   ④경로     ⑤쿼리   ⑥프래그먼트
#이름무엇을 정하는가이 장의 어느 단어인가
①스킴 (Scheme)어떤 규약으로 말 걸지HTTP / FTP / SSH …
②호스트 (Host)어느 기계인지 (이름)DNS가 IP로 바꿔줌
③포트 (Port)그 기계의 몇 번 창구인지—
④경로 (Path)그 안의 어느 자원인지API의 엔드포인트
⑤쿼리 (Query)그 자원을 어떻게 걸러서 줄지—
⑥프래그먼트문서 안 어느 위치로 갈지서버에 전송되지 않음

⚠️ ⑥ 프래그먼트(# 뒤)는 서버로 전송되지 않습니다. 브라우저만 씁니다. 이 사실이 뒤에서 한 번 중요해집니다 — OAuth의 일부 방식이 토큰을 # 뒤에 실어 보내는데, 그 경우 서버 로그에 토큰이 남지 않는 대신 서버가 그 값을 읽지 못합니다.

1.4 포트 번호 — 기계 하나에 창구가 여럿

IP가 건물 주소라면 포트는 그 건물 안의 몇 번 창구입니다. 규약마다 관례적으로 쓰는 번호가 정해져 있습니다.

포트규약하는 일이번 주에 나오는 자리
22SSH원격 셸 로그인EC2 접속, 원격 PC 접속
25SMTP메일 발송—
21FTP파일 전송—
53DNS이름 → IP 조회도커 내장 DNS도 같은 역할
80HTTP평문 웹리버스 프록시 앞단
443HTTPS암호화된 웹Cloudflare 엣지
8000—(관례) 개발용 백엔드backend:8000
9000—(관례) 이번 주에 만든 인증 서버auth:9000
11434—Ollama 기본 포트http://localhost:11434/v1

포트 번호는 약속일 뿐 강제가 아닙니다. 8000번에 HTTP를 띄워도 되고 443에 다른 걸 띄워도 됩니다. 다만 관례를 벗어나면 상대가 못 찾습니다. 그래서 규약이 있는 것입니다.

1.5 API는 왜 이 목록에서 혼자 층이 다른가

여덟 개는 인터넷이 원래 가지고 있는 것입니다. 누가 만들지 않아도 거기 있습니다. API는 내가 만드는 것입니다.

API — [계약]인데, [내가 만든 프로그램이 남에게 열어주는 창구의 목록과 규격]이라는 점에서 인터넷 기반 규약(HTTP·SSH 등)과 구별된다.

API는 대개 HTTP 위에 얹힙니다. HTTP는 "어떻게 말 걸까"의 약속이고, API는 "무엇을 물으면 무엇을 준다"의 약속입니다. 2주차에서 스크립트를 서버로 바꿀 때, 4주차에서 REST를 배울 때 이미 이 층 관계를 지나왔습니다.

1.6 DNS와 NS — 체계와 부품

DNSNS (Name Server)
무엇이름→주소 변환 체계 전체그 체계를 구성하는 서버 한 대
범위전 세계 분산특정 도메인 담당
예"DNS가 안 풀린다"aaron.ns.cloudflare.com

10주차에서 도메인 등록기관의 네임서버(NS) 값을 Cloudflare가 알려준 값으로 바꾸는 작업을 했습니다. 그것은 "이 도메인의 답을 이제부터 저 서버가 들고 있다"고 위임을 옮기는 일이었습니다. 그때가 바로 NS를 만진 순간입니다.

⭐ 이번 주에 이 축이 한 번 더 접힙니다. 도커 컨테이너들도 자기들끼리 쓰는 내장 DNS를 가지고 있습니다. http://backend:8000이라고 쓰면 도커의 내장 DNS가 backend라는 컨테이너 이름을 그 순간의 사설 IP로 바꿔줍니다. 이름 해석이라는 같은 메커니즘이 인터넷 규모와 한 대의 컴퓨터 안에서 똑같이 반복됩니다. (6.6절)

1.7 SSH — 이 장에서 두 번 쓰인다

SSH는 이번 주에 두 자리에서 쓰입니다.

  1. 로컬 PC에서 원격 PC로 — 11~12주차에 만든 local-server → remote-server 구성. 포트 2222.
  2. 내 PC에서 AWS EC2로 — 9.8절. ~/.ssh/config에 Host aws로 등록하고 VSCode Remote-SSH로 붙습니다.

둘은 완전히 같은 규약, 완전히 같은 사고방식입니다. 1주차에서 "내 컴퓨터와 남의 컴퓨터"를 갈랐던 축이, 여기서 "내 방 안의 남의 컴퓨터"와 "남의 데이터센터의 남의 컴퓨터" 로 한 칸 더 나뉩니다.

원격 PC (WSL Ubuntu)AWS EC2
물리적 위치내 방AWS 데이터센터
전원내가 켬 (끄면 죽음)AWS가 켬 (24시간)
IP공유기가 주는 사설 IP (재부팅 시 바뀜)AWS가 주는 공인 IP
접속ssh user@192.168.0.228ssh -i key.pem ubuntu@ec2-...
인증비밀번호 또는 키반드시 키 파일(.pem)
비용전기요금시간당 과금

11주차에 "전원을 새로 켜면 IP가 바뀐다"가 과제였던 이유가 여기 있습니다. 사설 IP는 임대이지 소유가 아닙니다. 이번 주 축 4가 그 문제를 정면으로 다룹니다.


2. 인증과 인가 — 같은 문 앞의 두 개의 질문

2.1 두 단어를 먼저 갈라놓는다

한국어로 쓰면 한 글자 차이라 계속 섞입니다. 영어로는 시작 글자부터 다릅니다.

  • ⭐ 인증(Authentication, AuthN) — [출입 절차]인데, ["너는 누구냐"를 확인한다]는 점에서 인가와 구별된다.
  • ⭐ 인가(Authorization, AuthZ) — [출입 절차]인데, ["너는 무엇을 해도 되냐"를 확인한다]는 점에서 인증과 구별된다.

순서가 정해져 있습니다. 인증이 먼저, 인가가 나중입니다. 누구인지 모르면 무엇을 허용할지 정할 수가 없습니다.

인증 (AuthN)인가 (AuthZ)
묻는 것너는 누구냐너는 뭘 해도 되냐
실패 시 HTTP 상태401 Unauthorized403 Forbidden
실패 뜻신분증이 없거나 위조됨신분증은 진짜인데 권한이 모자람
이번 주 담당OAuth 로그인 → JWT 발급JWT의 roles 클레임 → RBAC

⚠️ HTTP 401의 이름이 잘못 지어졌습니다. 401 Unauthorized는 이름은 "인가 실패"인데 실제 뜻은 "인증 실패" 입니다. 진짜 인가 실패는 403 Forbidden입니다. 이 이름은 HTTP 규격이 만들어질 때 굳어진 실수이고 지금은 못 고칩니다. 401 = 너 누군지 모르겠다 / 403 = 누군지는 알겠는데 안 된다. 이렇게 외우세요.

2.2 OAuth 2.0 — 위임된 인가

가장 널리 퍼진 오해부터 걷어냅니다.

❌ "OAuth는 소셜 로그인 프로토콜이다" ⭕ OAuth 2.0은 인가(Authorization) 위임 프로토콜입니다. 로그인은 그 위에 얹힌 응용 중 하나입니다.

  • ⭐ OAuth 2.0 — [인가 프로토콜]인데, [비밀번호를 넘기지 않고, 제3의 앱에게 내 자원에 대한 제한된 접근 권한만 위임한다]는 점에서 아이디·비밀번호를 직접 주고받는 방식과 구별된다.

OAuth가 풀려고 한 원래 문제는 로그인이 아니라 이것입니다.

"이 사진 인화 서비스가 내 구글 포토에서 사진을 가져가게 하고 싶다. 그런데 구글 비밀번호를 그 서비스에 줄 수는 없다."

비밀번호를 주면 그 서비스는 내 메일도 읽고 문서도 지울 수 있습니다. 권한이 전부 아니면 전무가 됩니다. OAuth는 그 사이에 "사진 읽기만, 30일간" 같은 좁은 권한증서를 끼워 넣습니다.

2.3 등장인물 네 명

OAuth 문서가 어려운 이유는 등장인물이 넷인데 이름이 헷갈리게 지어져 있어서입니다.

역할원어누구인가우리 프로젝트에서는
자원 소유자Resource Owner사용자 본인로그인하려는 사람
클라이언트Client권한을 빌리려는 앱우리가 만든 서비스
인가 서버Authorization Server동의를 받고 증서를 발급하는 곳네이버·카카오·구글
자원 서버Resource Server실제 데이터를 들고 있는 곳네이버 프로필 API 등

⚠️ 가장 흔한 혼동: "Client"는 브라우저가 아닙니다. OAuth에서 클라이언트는 권한을 빌려 쓰려는 우리 서비스입니다. 브라우저는 그냥 사용자의 도구입니다. 그래서 CLIENT_ID / CLIENT_SECRET은 우리 서비스의 신분증이지 사용자의 것이 아닙니다.

2.4 인가 코드 흐름 (Authorization Code Flow)

가장 표준적이고 가장 안전한 흐름입니다. 화면이 다섯 번 오갑니다.

 [사용자]        [우리 서비스]            [네이버 인가 서버]        [네이버 자원 서버]
    │                  │                          │                      │
    │ ① 로그인 클릭     │                          │                      │
    ├─────────────────▶│                          │                      │
    │                  │ ② 인가 URL 로 리다이렉트   │                      │
    │◀─────────────────┤   (client_id, redirect_uri, scope, state)       │
    │                  │                          │                      │
    │ ③ 네이버 동의 화면에서 "동의"                  │                      │
    ├───────────────────────────────────────────▶ │                      │
    │                  │                          │                      │
    │◀───────────────────────────────────────────┤ ④ redirect_uri 로     │
    │                  │      code=abc123          │    인가 코드 반환      │
    ├─────────────────▶│                          │                      │
    │                  │ ⑤ code + client_secret 로 │                      │
    │                  ├─────────────────────────▶│  토큰 교환 (서버↔서버) │
    │                  │◀─────────────────────────┤  access_token 반환    │
    │                  │                          │                      │
    │                  │ ⑥ access_token 으로 프로필 조회                   │
    │                  ├────────────────────────────────────────────────▶│
    │                  │◀────────────────────────────────────────────────┤
    │                  │                          │                      │
    │◀─────────────────┤ ⑦ 우리 서비스의 JWT 를 쿠키로 심어줌               │

이 그림에서 반드시 붙잡아야 할 세 가지입니다.

  1. ④에서 오는 것은 토큰이 아니라 "인가 코드" 입니다. 브라우저를 거쳐 오기 때문에 노출될 수 있는 값이고, 그래서 한 번만 쓸 수 있고 수명이 매우 짧습니다.
  2. ⑤는 브라우저를 거치지 않습니다. 우리 서버가 네이버 서버에 직접 요청합니다. 여기서 CLIENT_SECRET이 쓰이므로 절대 프론트엔드에 두면 안 됩니다.
  3. ⑦이 이 장의 다음 절로 이어집니다. 네이버가 준 토큰은 네이버용입니다. 우리 서비스 안에서 쓸 신분증은 우리가 새로 발급합니다. 그것이 JWT입니다.

⭐ state 파라미터 — ②에서 보내고 ④에서 그대로 돌아오는 임의의 문자열입니다. 돌아온 값이 보낸 값과 다르면 내가 시작한 로그인이 아니라는 뜻이므로 거부합니다. CSRF(요청 위조) 방어 장치이고, 빠뜨리기 쉬운 항목입니다.

2.5 소셜 로그인은 OAuth 위에 얹힌 것

OAuth는 "무엇을 해도 되나"만 다룹니다. "너는 누구냐"는 원래 OAuth의 일이 아닙니다. 그래서 OIDC(OpenID Connect) 라는 얇은 층이 OAuth 위에 얹혀 신원 정보(ID Token)를 표준화합니다.

OAuth 2.0OIDC (OpenID Connect)
상위 범주인가 프로토콜OAuth 2.0의 확장
답하는 질문무엇을 해도 되나 (AuthZ)너는 누구냐 (AuthN)
결과물Access TokenID Token (JWT 형식)

실무에서 "소셜 로그인"이라고 부르는 것은 대개 이 둘이 섞인 상태입니다. 구글은 OIDC를 정식 지원하고, 네이버·카카오는 OAuth 2.0으로 토큰을 받아 프로필 조회 API를 한 번 더 호출해 신원을 얻습니다. 그래서 절차가 공급자마다 조금씩 다릅니다.

2.6 프론트는 다섯 개, 백엔드는 하나

우측 상단 로그인 버튼을 누르면 네이버·카카오·구글·애플·인스타그램 다섯 개의 버튼이 뜨는 화면을 만듭니다. 그런데 백엔드는 그중 접근하기 쉬운 하나만 실제로 구현합니다.

이 판단이 왜 합리적인가:

이유설명
검수 비용공급자마다 앱 심사·약관 검토 절차가 따로 있고, 심사 기간이 제각각입니다
흐름은 동일2.4절의 다섯 단계는 공급자가 바뀌어도 같습니다. 바뀌는 것은 URL과 파라미터 이름뿐입니다
어댑터 패턴하나를 제대로 만들면 나머지는 어댑터를 갈아끼우는 일이 됩니다

⭐ 6주차의 어댑터가 여기서 회수됩니다. OAuthProviderPort라는 포트를 하나 정의하고 NaverAdapter·KakaoAdapter·GoogleAdapter를 각각 구현합니다. 유스케이스(로그인 처리)는 어느 공급자인지 모릅니다. 공급자를 하나 더 붙이는 일이 파일 추가로 끝나면 7주차의 OCP를 지킨 것입니다. 프론트에 버튼 다섯 개를 먼저 그려 두는 것은, 포트가 다섯 개의 어댑터를 받을 준비가 되어 있다는 선언이기도 합니다.

2.7 OAuth를 써도 약관 동의는 생략할 수 없다

여기서 자주 나오는 오해를 짚습니다.

❌ "네이버 로그인 화면에서 동의를 받았으니 우리 서비스 약관 동의는 생략해도 된다" ⭕ 아닙니다. 네이버 화면에서 받은 동의는 네이버가 우리에게 정보를 넘기는 것에 대한 동의입니다. 우리 서비스의 이용약관과 개인정보 처리방침에 대한 동의는 별도로 받아야 합니다.

근거는 두 가지입니다.

  1. 개인정보보호법 — 개인정보 수집·이용에는 정보 주체의 동의가 필요하고, 그 동의는 수집 주체(우리 서비스) 가 받아야 합니다.
  2. 공급자 검수 정책 — 네이버 로그인은 검수 단계에서 자사 약관·처리방침의 존재와 동의 절차를 확인합니다. 없으면 승인이 나지 않습니다.

구현 방법은 두 가지이고, 각각 장단점이 갈립니다.

방식 A — 로그인 플러스방식 B — 콜백 후 리다이렉트
방법네이버 로그인 플러스를 신청해 네이버 동의 창 안에 자사 약관 항목을 포함OAuth 콜백 후, 신규 유저면 약관 동의 페이지로 리다이렉트
사용자 경험화면 한 번에 끝남화면이 두 번 뜸 (이탈 발생)
구현 난도신청·검수 필요, 코드는 단순코드에 분기 필요 (신규/기존 판별)
권장⭐ 추천차선
주의공급자별 지원 여부가 다름동의 전에는 서비스 진입을 막아야 함

방식 B를 고를 때의 판별 기준: 콜백 시점에 DB에 그 사용자가 이미 있는가. 없으면 신규이므로 약관 페이지로, 있으면 기존이므로 바로 서비스로 보냅니다. "약관 동의 완료 여부"를 사용자 테이블의 컬럼으로 들고 있어야 이 분기가 성립합니다.

2.8 Redirect URI — 왜 사전 등록을 강제하는가

REDIRECT_URI는 ④단계에서 인가 코드를 받을 우리 쪽 주소입니다. 공급자 콘솔에 미리 등록해 둔 주소로만 코드를 보내줍니다.

이유는 명확합니다. 등록을 강제하지 않으면 공격자가 redirect_uri를 자기 서버로 바꿔서 남의 인가 코드를 가로챌 수 있습니다. 그래서 "글자 하나라도 다르면 거부"가 정상 동작입니다. 끝의 슬래시 하나, http와 https 차이로도 실패합니다.

공급자콘솔 위치등록 예시
네이버Naver Developers → [내 애플리케이션] > [기본 정보]http://localhost:3000/api/auth/callback/naver
카카오Kakao Developers → [앱 설정] > [요약 정보] / [앱 키]http://localhost:3000/api/auth/callback/kakao
구글Google Cloud Console → [API 및 서비스] > [사용자 인증 정보] > [OAuth 2.0 클라이언트 ID]http://localhost:3000/api/auth/callback/google
인스타그램(Meta)Meta for Developers → [앱 설정] > [기본 설정]https://localhost:3000/api/auth/callback/instagram

⚠️ 인스타그램만 https:// 입니다. Meta는 보안 정책상 로컬 테스트에서도 https://를 요구하는 경우가 많습니다. 로컬에서 https를 쓰려면 자체 서명 인증서를 만들거나 터널(6장)로 https 주소를 하나 얻어야 합니다. 이것이 이번 주에 터널을 먼저 정비한 실무적 이유이기도 합니다.

또한 인스타그램은 제품(Product)을 하나 추가해야 합니다 — "Instagram Basic Display" 또는 "Instagram Graph API". 카카오는 [카카오 로그인] > [일반] > 사용 설정을 ON으로 바꾸지 않으면 어떤 요청도 통과하지 않습니다.

2.9 Vercel Deployment Protection을 반드시 끈다

OAuth를 붙이자마자 부딪히는 벽입니다. 배포한 프론트에서 로그인을 눌렀는데 공급자가 우리 콜백 주소에 접근하지 못합니다.

원인은 코드가 아니라 배포 설정입니다.

Vercel Deployment Protection — 배포된 사이트 전체에 Vercel 계정 로그인을 요구하는 보호 기능입니다. 사람이 브라우저로 들어올 때는 Vercel 로그인 화면을 보고 통과하지만, 네이버·카카오 서버가 콜백 주소로 요청을 보낼 때는 그 화면을 통과할 수 없습니다.

끄는 절차:

  1. Vercel 프로젝트 → Settings
  2. Deployment Protection → Vercel Authentication
  3. Require Log In 토글을 끕니다 — 파란색 → 회색
  4. Save

⚠️ 이 증상의 진단법: 브라우저에서는 사이트가 잘 보이는데 OAuth 콜백만 실패한다면, 거의 항상 "사람은 통과하는데 기계는 못 통과하는 관문" 이 중간에 있다는 뜻입니다. Deployment Protection, Basic Auth, IP 화이트리스트, 봇 차단 규칙이 전부 같은 부류입니다.


3. JWT — 서버가 기억하지 않아도 되는 신분증

3.1 왜 토큰이 필요한가 — 무상태의 대가

4주차와 8주차에서 무상태(Stateless) 를 배웠습니다. 서버는 앞 요청을 기억하지 않습니다. 그래서 확장(Scale-out)이 가능해지지만, 대가가 하나 생깁니다. "아까 로그인했잖아요"가 통하지 않습니다.

해법은 두 갈래입니다.

세션 방식토큰(JWT) 방식
신원을 어디에 두는가서버 메모리·Redis에 저장, 클라이언트는 세션 ID만 들고 다님토큰 자체에 담아 클라이언트가 통째로 들고 다님
서버 상태있음 (상태 보유)없음 (무상태)
검증 비용요청마다 저장소 조회서명만 확인 (저장소 조회 없음)
즉시 무효화쉬움 (지우면 끝)어려움 (만료 전까지 유효)
Scale-out세션 공유 장치 필요그대로 가능
  • ⭐ JWT (JSON Web Token) — [인증 토큰]인데, [정보를 JSON으로 담고 서명을 붙여, 서버가 저장소를 조회하지 않고도 위변조 여부를 검증할 수 있게 만든 것]이라는 점에서 세션 ID와 구별된다.

4주차에서 "서버가 안 기억해 주니까 클라이언트가 매번 신분증을 들고 간다"고 적어 둔 그 신분증이 바로 이것입니다.

3.2 세 토막 구조 — jwt.io 화면 읽기

JWT는 점(.)으로 나뉜 세 토막입니다. 디버거(jwt.io) 좌측의 Encoded Token 영역에서 색으로 구분되어 보입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJKb2huIn0 . dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
└──────────── ① Header ────────────┘  └──────────── ② Payload ───────────┘  └───────── ③ Signature ─────────┘
       (주황색)                                    (보라색)                              (초록색)
#토막무엇이 들어가는가jwt.io 우측 Decoded 표시
①Header토큰 타입과 서명 알고리즘{"alg": "HS256", "typ": "JWT"}
②Payload사용자 정보 = 클레임(Claims){"sub": "...", "name": "...", "admin": true, "iat": ...}
③Signature①+②를 키로 서명한 값 = 위변조 확인용별도 표시 없음

화면 하단에는 두 개의 판정이 뜹니다.

  • ✔ Valid JWT — 형식이 올바름 (점 세 토막, Base64URL로 디코딩됨)
  • ✔ Signature Verified — 서명이 맞음 (진짜 우리가 발급한 토큰임)

우측 하단 Signature Verification 칸에 비밀키를 입력하면 ✔ Valid secret 이 뜹니다. 그 키로 (Header + Payload)를 다시 서명한 결과가 좌측의 세 번째 토막과 글자 하나까지 일치할 때 검증이 성공한 것입니다.

⭐ 이 화면이 알려주는 핵심: 검증이란 "다시 계산해서 같은지 비교하는 일" 입니다. 토큰을 "풀어보는" 일이 아닙니다. 그래서 서명 검증에는 원본 데이터가 필요 없고, 키와 토큰만 있으면 됩니다.

3.3 표준 클레임 — 자주 쓰는 일곱 개

Payload에 아무 이름이나 넣어도 되지만, 표준으로 정해진 이름들이 있습니다. 세 글자로 줄여져 있어 처음엔 암호처럼 보입니다.

클레임원말뜻우리 프로젝트에서
subSubject이 토큰이 가리키는 주체 (사용자 ID)사용자 식별자
audAudience이 토큰을 쓸 수 있는 대상 서비스⭐ 3.8절
expExpiration만료 시각액세스 토큰 10분
iatIssued At발급 시각—
jtiJWT ID토큰 고유 번호⭐ 블랙리스트 조회 키
roles(표준 아님)이 사용자의 역할 목록RBAC 판정 재료
kidKey ID(Header에) 어느 키로 서명했는지⭐ 3.6절

⚠️ sub를 "제목"으로 읽지 마세요. Subject는 3주차에서 배운 주체(행위를 하는 쪽) 그대로입니다. Object의 반대말인 그 Subject입니다. "이 토큰의 주인은 누구인가"를 담는 칸입니다.

3.4 ⭐ 서명은 암호화가 아니다

이 장에서 가장 많이 오해받는 지점입니다.

❌ "JWT는 암호화되어 있으니까 안에 뭘 넣어도 안전하다" ⭕ Payload는 그냥 Base64URL 인코딩입니다. 키 없이 누구나 디코딩해서 읽을 수 있습니다.

Base64는 암호가 아니라 표기법입니다. 바이너리를 텍스트로 옮겨 적는 방식일 뿐, 되돌리는 데 어떤 비밀도 필요 없습니다. jwt.io에 토큰을 붙여넣으면 비밀키 없이도 우측 Payload가 즉시 읽히는 것이 그 증거입니다.

성질서명이 보장하는가
무결성 (Integrity) — 내용이 바뀌지 않았는가⭕ 보장
진정성 (Authenticity) — 우리가 발급한 것이 맞는가⭕ 보장
기밀성 (Confidentiality) — 남이 못 읽는가❌ 보장하지 않음

그래서 비밀번호, 주민등록번호, 카드번호, 내부 시크릿을 Payload에 절대 넣지 않습니다. 넣어도 되는 것은 "남이 봐도 되지만 남이 바꾸면 안 되는 것"입니다. 사용자 ID, 역할, 만료 시각이 정확히 그런 값입니다.

3.5 ⭐ HS256과 RS256 — 축 2

이번 주 설계의 심장입니다.

HS256 (대칭)RS256 (비대칭)
정식 이름HMAC-SHA256RSA-SHA256
키 개수한 개 (Secret)두 개 (Private / Public)
서명할 때 쓰는 키Secret개인키(Private Key)
검증할 때 쓰는 키같은 Secret공개키(Public Key)
검증하는 쪽이 발급도 할 수 있는가⭕ 할 수 있다❌ 못 한다
키를 나눠줄 수 있는가❌ 나눠주면 위조 가능⭕ 공개키는 공개해도 안전
  • HS256 — [JWT 서명 알고리즘]인데, [서명과 검증에 같은 하나의 키를 쓴다]는 점에서 RS256과 구별된다.
  • RS256 — [JWT 서명 알고리즘]인데, [개인키로 서명하고 공개키로 검증한다]는 점에서 HS256과 구별된다.

왜 이번 주에 RS256으로 갔는가. 서비스를 두 컨테이너로 나눴기 때문입니다.

  [auth 컨테이너]                       [backend 컨테이너]
   ─ 개인키(Private) 보유                ─ 공개키(Public)만 보유
   ─ 토큰을 발급한다                     ─ 토큰을 검증만 한다
   ─ 뚫려도 여기 하나만 뚫린다             ─ 뚫려도 토큰을 만들지 못한다

HS256을 쓰면 검증하려는 모든 컨테이너가 발급용 키를 갖게 됩니다. 백엔드가 하나 뚫리면 공격자가 "관리자" 토큰을 마음대로 찍어냅니다. RS256이면 백엔드에는 공개키밖에 없으므로 위조 토큰을 만들 수 없습니다.

⭐ 이것이 축 2의 실체입니다. "키를 둘로 나눈다"는 말은 곧 "발급 권한과 검증 권한을 분리한다" 는 말입니다. 6주차에서 배운 관심사 분리가 암호 키의 층위에서 반복된 것입니다.

3.6 kid와 JWKS — 공개키를 어떻게 나눠주는가

공개키는 공개해도 안전하므로, HTTP로 그냥 게시하면 됩니다. 이때 쓰는 표준 경로가 있습니다.

GET https://auth.example.cloud/.well-known/jwks.json
  • JWKS (JSON Web Key Set) — [공개키 배포 형식]인데, [여러 개의 공개키를 JSON 배열로 게시하고 각 키에 kid를 붙여 구별한다]는 점에서 파일로 복사해 넣는 방식과 구별된다.
  • kid (Key ID) — [JWT Header의 필드]인데, [이 토큰이 어느 공개키로 검증되어야 하는지 가리킨다]는 점에서 alg와 구별된다.

kid가 필요한 이유는 키 교체(Key Rotation) 때문입니다. 키를 새로 만들면 옛 키로 발급된 토큰들이 아직 살아 있습니다. 두 키를 잠시 함께 게시하고, kid로 어느 쪽으로 검증할지 고릅니다.

.well-known/ 은 "이 도메인에 대한 표준 메타 정보는 여기 둔다"고 약속된 경로입니다. 규약이 폴더 이름까지 정해두었기 때문에, 처음 만난 서버라도 이 주소를 물으면 답을 얻습니다.

3.7 세 가지 위조 시도와 그 방어

인증 서버를 만들면 반드시 테스트해야 하는 공격 시나리오입니다. 완료 기준(4.9절)에도 그대로 들어갑니다.

공격무슨 짓인가왜 통하나방어
alg: noneHeader의 알고리즘을 none으로 바꾸고 서명 토막을 비움초기 JWT 라이브러리들이 "서명 없음"을 허용했음허용 알고리즘을 리터럴로 고정
HS256 강제 (알고리즘 혼동)RS256용 공개키를 HS256의 secret으로 써서 서명공개키는 누구나 얻을 수 있음 → 그걸 대칭키로 오인하면 위조 성립허용 알고리즘을 리터럴로 고정
만료 무시 / 페이로드 변조exp를 미래로, roles를 관리자로 고침서명을 안 보면 통과서명 검증 + exp 검증

그래서 검증 코드는 이렇게 씁니다.

# core/security.py — 검증부 (공용, 공개키만 사용)
payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],   # ⭐ 리터럴 하드코딩. 환경변수로 빼지 않는다
    audience=aud,
)

⚠️ algorithms를 환경변수로 빼면 안 되는 이유. 환경변수는 운영 중에 바뀔 수 있는 값입니다. 실수로 HS256이 섞여 들어가는 순간 위 두 번째 공격이 열립니다. 이 값은 "설정"이 아니라 "보안 불변식" 입니다. 바뀌면 안 되는 것은 바뀔 수 있는 곳에 두지 않습니다. 이번 주 하네스 문서에 이 항목이 절대 규칙 3번으로 못 박힌 이유입니다. (4.3절)

3.8 aud — 토큰이 어디까지 통하는가

같은 사람이 로그인해도, 그 토큰이 모든 서비스에 통하면 안 됩니다.

  • aud: "api" — 비즈니스 API에서만 유효
  • aud: "admin" — 어드민에서만 유효

검증 함수는 반드시 자기 서비스의 aud를 인자로 받아 확인합니다.

def verify_token(token: str, aud: str) -> dict:
    return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"], audience=aud)

aud가 다른 토큰을 들고 오면 403을 돌려줍니다. 서명은 진짜지만 여기서 쓸 수 있는 신분증이 아니기 때문입니다. 2.1절의 "401은 누군지 모름 / 403은 알겠는데 안 됨"이 여기서 정확히 적용됩니다.

⭐ 이것이 최소 권한 원칙(Principle of Least Privilege) 의 토큰 버전입니다. 하나의 신분증이 모든 문을 열면, 그 하나가 새는 순간 전부가 새기 때문입니다.

3.9 액세스 토큰과 리프레시 토큰 — 그리고 로테이션

3.1절 표의 마지막 줄이 문제였습니다. JWT는 즉시 무효화가 어렵습니다. 만료 전까지는 유효하니까요. 해법은 수명을 아주 짧게 하고, 갱신용 토큰을 따로 두는 것입니다.

액세스 토큰 (Access Token)리프레시 토큰 (Refresh Token)
용도매 요청의 신분증새 액세스 토큰을 받아오는 교환권
수명10분 정도며칠~몇 주
어디에 저장클라이언트(쿠키)클라이언트 + 서버 Redis
서버가 기억하는가❌ 무상태⭕ Redis에 저장

리프레시 토큰만 서버가 기억하는 이유: 이것 하나만 통제하면 세션 전체를 끊을 수 있기 때문입니다. 액세스 토큰은 10분 뒤 어차피 죽습니다.

로테이션(Rotation)과 재사용 감지가 이 설계의 핵심입니다.

정상 흐름:
  RT1 제출 → AT2 + RT2 발급, RT1 즉시 폐기
  RT2 제출 → AT3 + RT3 발급, RT2 즉시 폐기

이상 흐름:
  RT1 제출 → 이미 폐기된 토큰이 다시 왔다
           → ⭐ 누군가 훔쳐서 쓰고 있다는 뜻
           → 해당 사용자의 세션 전체를 폐기

⭐ "이미 쓴 표가 다시 들어왔다"는 사실 자체가 유출의 증거입니다. 정상 사용자는 이미 RT2를 가지고 있으므로 RT1을 다시 낼 이유가 없습니다. 그래서 재사용이 감지되면 누가 진짜인지 가리려 하지 않고, 둘 다 끊습니다. 안전한 쪽으로 실패하는 것(Fail-safe)입니다.

여기에 액세스 토큰용 블랙리스트를 더합니다. 로그아웃하면 그 토큰의 jti를 Redis에 넣고, 검증할 때마다 조회합니다. 남은 수명(최대 10분)만 보관하면 되므로 저장 비용이 거의 들지 않습니다.

3.10 쿠키 옵션 네 개

토큰을 브라우저에 어떻게 심어 둘지도 설계입니다.

COOKIE_KWARGS = dict(
    domain=".example.cloud",   # 서브도메인 전체에서 공유
    secure=True,               # HTTPS 에서만 전송
    httponly=True,             # JavaScript 가 읽을 수 없음
    samesite="lax",            # 외부 사이트발 요청에는 안 붙음 (CSRF 완화)
)
옵션무엇을 막는가
httponly=TrueXSS — 스크립트가 주입돼도 document.cookie로 토큰을 못 훔침
secure=True평문 노출 — http로는 아예 전송되지 않음
samesite="lax"CSRF — 남의 사이트에서 우리 API로 쏘는 요청에 쿠키가 안 붙음
domain=".example.cloud"auth.에서 심은 쿠키가 api.에서도 읽힘 (⭐ 이번 주 구조의 전제)

⚠️ domain 앞의 점(.)이 핵심입니다. .example.cloud로 써야 모든 서브도메인이 공유합니다. 이 한 글자 때문에 "로그인은 되는데 API 호출만 401"이 생깁니다. 그리고 secure=True이므로 로컬 http에서는 쿠키가 아예 안 심어집니다. 로컬 테스트가 이유 없이 실패하면 이 줄을 먼저 보세요.


4. 게이트웨이를 따로 세운다 — auth 서브도메인 분리

4.1 지금까지의 구조와 바꾸려는 구조

지금까지는 main.py 하나가 api.example.cloud에 배포되어 모든 일을 했습니다. 이번 주의 목표는 같은 코드베이스를 유지한 채 엔트리포인트만 둘로 쪼개는 것입니다.

  [변경 전]                              [변경 후]
                                          auth.example.cloud
   api.example.cloud                         └── auth 컨테이너 (auth_main.py, 개인키 보유)
      └── main.py 하나                     api.example.cloud
          (인증 + 비즈니스)                    └── backend 컨테이너 (main.py, 공개키만)

전제 조건 세 가지:

  1. 네트워크는 도커 공유 네트워크 하나
  2. 바깥에서의 진입은 cloudflared 터널만
  3. 호스트 포트를 노출하지 않는다 (ports: 없음)

4.2 엔트리포인트 분리는 코드베이스 분리가 아니다

3주차에서 배운 두 축을 다시 꺼냅니다.

축이번 주의 선택
저장소 축 (모노레포 ↔ 멀티레포)모노레포 유지 — 저장소는 하나
배포 축 (모놀리식 ↔ MSA)컨테이너 두 개로 분리

즉, 모듈러 모놀리식에서 반 발짝만 MSA 쪽으로 옮긴 상태입니다. 코드는 한 저장소에 있고, apps/auth/는 그 안의 한 모듈이며, 다만 기동되는 프로세스가 둘입니다.

이 선택의 이점:

  • 공통 코드(core/)를 복사하지 않아도 됩니다
  • 그러면서도 개인키는 물리적으로 한 컨테이너 안에만 존재합니다
  • 나중에 진짜로 떼어내려면 폴더 하나를 들어내면 됩니다

⭐ 9주차의 스타 토폴로지로 보면, auth가 새 스포크 하나로 붙은 것입니다. 다만 이 스포크는 다른 스포크들과 달리 모두가 참조하는 자격 발급소라서, 뚫리면 전부가 뚫립니다. 그래서 규칙이 유난히 빡빡합니다.

4.3 ⭐ 절대 규칙 여섯 가지

이 작업은 하네스 문서(auth/_docs/auth-gateway-harness.md)로 먼저 씁니다. AI에게 시키기 전에 "하면 안 되는 것"을 먼저 못 박는 것이 하네스 엔지니어링입니다.

위반 시 작업을 중단하고 보고할 것.

  1. 기존 앱 코드는 한 줄도 수정 금지.
  2. 어떤 서비스에도 docker-compose.yml에 ports: 매핑 추가 금지.
  3. JWT 검증부의 허용 알고리즘은 algorithms=["RS256"] 리터럴 하드코딩 (환경변수로 빼지 않음).
  4. JWT_PRIVATE_KEY를 읽는 코드는 발급 함수에만 존재할 것. 검증 경로에서 개인키 참조가 발견되면 즉시 수정.
  5. 비밀키·개인키 커밋 금지. .env.*는 .gitignore에 추가.
  6. 비즈니스 앱이 apps.auth를 import 금지. 백엔드가 쓸 수 있는 것은 core.dependencies 뿐.

각 규칙이 어떤 사고를 막는지 붙여 봅니다.

규칙막는 사고이 책의 어느 개념인가
1인증을 붙이다가 멀쩡한 기능을 깨뜨림7주차 OCP — 확장에는 열고 수정에는 닫는다
2터널을 우회하는 뒷문이 생김축 5
3alg 혼동 공격 (3.7절)보안 불변식은 설정이 아니다
4백엔드에 개인키가 흘러들어가 위조 가능축 2
5깃 히스토리에 키가 영구 박제됨3주차 API 키 원칙
6인증 모듈이 비즈니스에 끌려 들어감5주차 의존 방향

⚠️ 규칙 5의 진짜 무서움: 커밋된 키는 나중에 지워도 깃 히스토리에 남습니다. 저장소를 공개로 바꾸는 순간 과거 전체가 공개됩니다. 그래서 "지우면 되지"가 통하지 않습니다. 실수로 커밋했다면 파일을 지우는 것이 아니라 키 자체를 폐기하고 새로 발급해야 합니다.

4.4 파일 배치

apps/auth/ (신규)

파일역할주요 내용
router.py인증 엔드포인트POST /login, POST /logout, POST /refresh, GET /callback/{provider}, GET /.well-known/jwks.json
services.pyOAuth Provider 연동2.4절의 ⑤⑥단계 (공급자별 어댑터)
schemas.py요청·응답 스키마7주차의 인바운드 스키마
rbac.py역할·권한 정의Role(str, Enum), Permission 매핑

라우터의 다섯 엔드포인트가 각각 무엇에 대응하는지가 중요합니다.

  • GET /callback/{provider} — 2.4절 ④단계의 착지점. {provider} 자리에 naver/kakao/google이 들어갑니다. 하나의 경로가 여러 공급자를 받습니다 (7주차 OCP).
  • POST /refresh — 3.9절의 로테이션이 일어나는 자리.
  • GET /.well-known/jwks.json — 3.6절. 공개키를 게시합니다.

core/security.py — 발급부와 검증부를 한 파일에, 그러나 갈라서

# ── 발급부 (auth 컨테이너 전용) ──────────────────────────
def create_access_token(sub: str, roles: list[str], aud: str, expires_min: int = 10) -> str:
    private_key = os.environ["JWT_PRIVATE_KEY"]   # ⭐ 호출 시점에 읽는다
    ...

def create_refresh_token(sub: str) -> str:
    ...

# ── 검증부 (공용, 공개키만) ─────────────────────────────
def verify_token(token: str, aud: str) -> dict:
    return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"], audience=aud)

⭐ "모듈 로드 시점이 아니라 호출 시점에 JWT_PRIVATE_KEY를 읽을 것." 이 한 줄이 왜 규칙이 되었는가: PRIVATE_KEY = os.environ["JWT_PRIVATE_KEY"] 를 파일 맨 위에 쓰면, 그 모듈을 import 하는 순간 키를 읽습니다. 백엔드 컨테이너에는 그 환경변수가 없으므로 import 자체가 터집니다. 같은 파일을 두 컨테이너가 공유하려면, 개인키는 실제로 쓸 때만 읽어야 합니다. 8주차 8.3절에서 "모듈을 통째로 가져와 런타임에 속성으로 접근"했던 것과 정확히 같은 해법입니다.

액세스 토큰의 클레임은 sub, roles, aud, exp, iat, jti 여섯 개 + Header에 kid입니다.

core/dependencies.py — 백엔드가 유일하게 import 해도 되는 것

async def get_current_user(request: Request) -> User:
    # 1) 쿠키 또는 Authorization 헤더에서 토큰 추출
    # 2) verify_token(token, aud=settings.SERVICE_AUD)
    # 3) Redis 블랙리스트에서 jti 조회 → 있으면 거부
    ...

class RoleChecker:
    def __init__(self, *allowed: Role):
        self.allowed = allowed
    def __call__(self, user: User = Depends(get_current_user)):
        if not set(user.roles) & set(self.allowed):
            raise HTTPException(403)      # ⭐ 401 이 아니라 403
        return user

8주차에서 배운 Depends는 안쪽부터 풀린다가 여기서 그대로 쓰입니다. RoleChecker가 get_current_user에 의존하므로, 인증이 먼저 끝나고 그 결과를 받아 인가를 판정합니다. 2.1절의 순서가 코드 구조로 강제됩니다.

auth_main.py — 별도 엔트리포인트

app = FastAPI(
    title="Example Auth",
    docs_url=None,      # ⭐ Swagger 비공개
    redoc_url=None,
    openapi_url=None,
)
app.include_router(auth_router, prefix="/auth")

@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

⚠️ 인증 서버의 API 문서는 끕니다. docs_url=None. 비즈니스 API의 Swagger는 개발 편의가 보안보다 크지만, 인증 서버의 엔드포인트 목록과 스키마는 공격자에게 지도를 주는 것입니다. /healthz만 열어 두는 이유는 컨테이너 상태 점검 때문입니다.

4.5 RBAC — 인가를 무엇을 기준으로 판정할 것인가

  • ⭐ RBAC (Role-Based Access Control) — [접근 제어 방식]인데, [사용자에게 권한을 직접 주지 않고 역할(Role)에 권한을 묶은 뒤 사용자에게 역할을 준다]는 점에서 사용자별로 권한을 나열하는 방식(ACL)과 구별된다.
[사용자] ──▶ [역할(Role)] ──▶ [권한(Permission)] ──▶ [자원]
  홍길동        admin           user:delete           /users/3

한 단계를 끼워 넣는 이유: 사용자는 계속 늘지만 역할은 잘 늘지 않기 때문입니다. "관리자가 할 수 있는 일"을 한 번 바꾸면 관리자 전원에게 반영됩니다. 사용자마다 권한을 적어 두면 그럴 수 없습니다.

방식권한을 누구에게 붙이나사용자 1만 명일 때
ACL사용자 각각1만 줄을 고쳐야 함
RBAC역할역할 정의 한 줄
ABAC속성 조건식유연하지만 복잡

파이썬에서는 Role(str, Enum) 으로 정의합니다. str을 함께 상속하면 JSON 직렬화가 그냥 됩니다 — JWT 클레임에 실을 값이므로 이 선택이 필요합니다.

4.6 docker-compose.yml — 두 서비스, 포트 없음

services:
  auth:
    build: .
    command: uvicorn auth_main:app --host 0.0.0.0 --port 9000
    env_file: .env.auth          # JWT_PRIVATE_KEY, OAuth secrets
    networks: [dreamscape]
    restart: unless-stopped
    # ⭐ ports: 없음

  backend:
    build: .
    command: uvicorn main:app --host 0.0.0.0 --port 8000
    env_file: .env.backend       # JWT_PUBLIC_KEY 만
    networks: [dreamscape]
    restart: unless-stopped
    # ⭐ ports: 없음
파일들어가는 것누가 읽는가
.env.authJWT_PRIVATE_KEY, OAuth CLIENT_SECRET 들auth 컨테이너만
.env.backendJWT_PUBLIC_KEYbackend 컨테이너

⭐ --host 0.0.0.0은 여기서 위험하지 않습니다. 컨테이너 안에서의 0.0.0.0은 그 컨테이너의 모든 인터페이스를 뜻하고, 컨테이너는 공유 네트워크 안에만 있습니다. 위험한 것은 호스트에서 0.0.0.0에 바인딩하는 ports: 입니다. 둘을 혼동하지 마세요. (6.2절)

4.7 RS256 키 만들기

scripts/generate_jwt_keys.sh:

# 개인키 생성 (2048비트 RSA)
openssl genrsa -out jwt_private.pem 2048

# 개인키에서 공개키 추출
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem

두 줄의 관계가 축 2 그 자체입니다. 공개키는 개인키에서 뽑아낼 수 있지만, 반대는 불가능합니다. 그래서 공개키를 잃어버려도 다시 만들 수 있고, 개인키를 잃어버리면 끝입니다.

# 생성 확인
openssl rsa -in jwt_private.pem -check -noout
cat jwt_public.pem

PEM 파일은 여러 줄입니다. 환경변수는 보통 한 줄이므로 그대로 넣으면 깨집니다. 해결은 base64입니다.

# 넣을 때 — 한 줄로 인코딩
base64 -w 0 jwt_private.pem > jwt_private.b64
# 쓸 때 — 디코드
import base64, os
PRIVATE_KEY = base64.b64decode(os.environ["JWT_PRIVATE_KEY_B64"]).decode()

.gitignore에 반드시 추가합니다.

*.pem
*.b64
.env
.env.*

4.8 cloudflared ingress — 두 서브도메인을 각각 어디로

ingress:
  - hostname: auth.example.cloud
    service: http://auth:9000
  - hostname: api.example.cloud
    service: http://backend:8000
  - service: http_status:404          # ⭐ 마지막 catch-all
# DNS 라우트 등록
cloudflared tunnel route dns <터널이름> auth.example.cloud

⚠️ 마지막 - service: http_status:404 를 빠뜨리지 마세요. ingress 규칙은 위에서부터 순서대로 매칭되고, 마지막 항목은 hostname 없이 전부 받는 규칙이어야 합니다. 없으면 설정 검증에서 실패합니다. "어디에도 해당하지 않으면 404"라고 명시적으로 적어 주는 것입니다.

4.9 규칙을 사람이 아니라 도구가 강제하게 — import-linter

절대 규칙 6번("비즈니스 앱이 apps.auth를 import 금지")은 사람이 지키기로 약속해서는 지켜지지 않습니다. 9주차에서 배운 방식 그대로, 도구가 검사하게 만듭니다.

.importlinter:

[importlinter]
root_package = fastapi

[importlinter:contract:auth-isolation]
name = 비즈니스 앱은 apps.auth 를 import 할 수 없다
type = forbidden
source_modules =
    apps.star_craft
    apps.silicon_valley
forbidden_modules =
    apps.auth
lint-imports

⭐ 9주차 8절의 원리가 그대로 반복됩니다. "규칙을 문서에 적는 것"과 "규칙을 CI가 검사하는 것"은 다른 일입니다. 앞의 것은 지침이고 뒤의 것은 불변식입니다. 하네스 엔지니어링은 지침을 불변식으로 바꾸는 작업입니다.

4.10 완료 기준 여덟 개

작업이 끝났는지를 느낌이 아니라 검사로 판정합니다.

#완료 기준무엇을 확인하는가
1auth_main 단독 기동 + /healthz 200엔트리포인트 분리 성공
2main.py가 개인키 없이 정상 동작규칙 4
3공개키만으로 검증 통과축 2
4aud가 다른 토큰은 4033.8절
5만료 토큰 거부3.7절
6서명 변조 토큰 거부 / alg=none 거부 / HS256 강제 토큰 거부3.7절
7리프레시 재사용 시 세션 전체 폐기3.9절
8lint-imports 통과 + pytest 전체 통과규칙 6

⭐ 6번이 이 목록의 심장입니다. 나머지는 "동작하는가"를 묻지만, 6번은 "공격당했을 때 막는가" 를 묻습니다. 정상 동작 테스트만 있는 인증 서버는 테스트가 없는 것과 같습니다.


5. 프록시와 리버스 프록시 — 축 3

5.1 프록시는 무엇의 한 종류인가

6주차에서 프록시(Proxy) 를 구조 패턴의 하나로 배웠습니다. "원본과 똑같은 얼굴로 앞에 서서 접근을 통제하는 대리자." 네트워크에서도 정확히 같은 정의가 쓰입니다. 다만 대리하는 대상이 객체가 아니라 서버일 뿐입니다.

  • ⭐ 프록시 서버(Proxy Server) — [중계 서버]인데, [클라이언트와 서버 사이에 서서 요청을 대신 전달한다]는 점에서 최종 목적지 서버와 구별된다.

프록시는 어느 쪽 앞에 서느냐로 두 종류로 갈립니다. 이것이 축 3입니다.

 [포워드 프록시]  클라이언트들 ──▶ [프록시] ──▶ 인터넷
                  (프록시가 클라이언트를 대신한다 · 서버는 프록시를 클라이언트로 안다)

 [리버스 프록시]  인터넷 ──▶ [프록시] ──▶ 서버들
                  (프록시가 서버를 대신한다 · 클라이언트는 프록시를 서버로 안다)
포워드 프록시 (Forward Proxy)리버스 프록시 (Reverse Proxy)
누구 앞에 서나클라이언트 앞서버 앞
누구를 숨기나클라이언트의 정체서버의 정체와 개수
누가 설치하나클라이언트 쪽 조직 (회사·학교)서비스 운영자
상대는 무엇으로 아는가서버는 프록시를 클라이언트로 인식클라이언트는 프록시를 서버로 인식
전형적 예사내 인터넷 필터, VPN, 광고 차단nginx, 로드밸런서, CDN, API 게이트웨이, Cloudflare

⚠️ "리버스"를 "거꾸로 간다"로 읽으면 틀립니다. 트래픽 방향은 똑같습니다. 뒤집힌 것은 누구를 대신하느냐입니다. 포워드는 요청하는 쪽을, 리버스는 응답하는 쪽을 대리합니다.

반례로 확인하기

상황무엇인가
회사에서 특정 사이트를 막으려고 모든 직원 트래픽을 거쳐 가게 함포워드 프록시
백엔드 서버 3대 앞에 한 대를 세워 요청을 나눠 줌리버스 프록시 (로드밸런서)
이미지 파일을 전 세계 캐시 서버에서 대신 내려 줌리버스 프록시 (CDN)
브라우저가 페이지를 캐시에 저장해 둠❌ 둘 다 아님 — 중계 서버가 아니라 클라이언트 자신의 저장소

5.2 리버스 프록시가 실제로 하는 일 여섯 가지

우리가 이번 주에 세운 구조에서 Cloudflare가 하는 일을 그대로 나열한 것입니다.

#하는 일이번 주 어디서
1TLS 종료 — https 암호를 여기서 풀고 뒤로는 http로 보냄백엔드에 인증서를 안 넣어도 되는 이유
2라우팅 — hostname·경로별로 뒤쪽 서비스를 고름auth. → auth:9000, api. → backend:8000
3부하 분산 — 뒤쪽 서버 여러 대에 나눠 줌Scale-out의 전제
4캐싱 — 같은 응답을 대신 돌려줌CDN
5보호 — WAF, 레이트 리밋, 봇 차단⭐ 6.2절의 핵심
6은닉 — 뒤쪽 서버의 실제 IP를 감춤공인 IP 없이 서비스

⭐ 6주차의 게이트웨이(퍼사드)와 이 리버스 프록시는 같은 아이디어의 두 층위입니다. 게이트웨이는 코드 안에서 여러 라우터의 창구를 하나로 모았습니다. 리버스 프록시는 네트워크에서 여러 서버의 창구를 하나로 모읍니다. 3주차에서 말한 프랙탈입니다. 같은 구조가 크기만 바꿔 반복됩니다.

5.3 게이트웨이 · 로드밸런서 · CDN · 터널 — 전부 리버스 프록시의 변종

이름이 넷이지만 상위 범주는 하나입니다. 무엇을 주된 목적으로 삼느냐가 종차입니다.

이름상위 범주종차 (주 목적)
API 게이트웨이리버스 프록시인증·라우팅·속도 제한 같은 횡단 관심사를 앞단에서 처리
로드밸런서리버스 프록시여러 서버에 부하를 나누는 것
CDN리버스 프록시정적 자원을 가까운 곳에 캐시해 지연을 줄이는 것
터널 커넥터리버스 프록시공인 IP 없이 내부 서비스를 외부에 연결하는 것
nginx(구현체)위 넷을 전부 할 수 있는 소프트웨어

💡 nginx가 헷갈리는 이유는 그것이 범주가 아니라 제품이기 때문입니다. "nginx는 리버스 프록시인가 웹서버인가"라는 질문은 "이 사람은 요리사인가 아버지인가"와 같은 형태의 질문입니다. 둘 다입니다.

5.4 코드의 프록시와 네트워크의 프록시 — 대응표

8주차에서 스미스 선장을 헥사고날 입구에 세운 것이 프록시였습니다. 그 구조를 네트워크로 확대하면 이번 주가 됩니다.

6·8주차 (코드 안)13주차 (네트워크)
퍼사드gateway — 라우터를 한 창구로Cloudflare가 서브도메인들을 한 엣지로
프록시스미스 — 같은 얼굴로 접근 통제터널 — 같은 주소로 진입 통제
어댑터PgRepository / MongoRepositoryOAuthProviderAdapter (네이버/카카오/구글)
책임 연쇄정책을 순서대로 통과엣지 WAF → 레이트 리밋 → 인증 → 비즈니스

6. 터널 — 문을 하나로 모으기

6.1 Cloudflare Tunnel은 무엇의 한 종류인가

  • ⭐ 터널(Tunnel) — [리버스 프록시 구성]인데, [바깥에서 안으로 연결을 여는 것이 아니라, 안쪽 커넥터가 바깥으로 먼저 연결을 열어 그 통로를 역방향으로 쓴다]는 점에서 일반적인 포트 개방 방식과 구별된다.

이 한 줄에 모든 것이 들어 있습니다. 방향이 뒤집혀 있습니다.

 [일반적인 서버 공개]
   인터넷 ──(들어오는 연결)──▶ 공유기 포트포워딩 ──▶ 내 서버
   · 공인 IP 필요 · 방화벽에 구멍 필요 · 그 구멍은 누구에게나 열려 있음

 [터널]
   내 서버의 cloudflared ──(나가는 연결)──▶ Cloudflare 엣지
   인터넷 ──▶ Cloudflare 엣지 ──(이미 열려 있는 통로를 역방향으로)──▶ 내 서버
   · 공인 IP 불필요 · 방화벽에 들어오는 구멍 0개

⭐ 핵심은 "들어오는 포트를 하나도 열지 않는다" 는 점입니다. 대부분의 방화벽은 나가는 연결(outbound)은 기본 허용하고 들어오는 연결만 막습니다. 터널은 그 비대칭을 이용합니다. 안에서 밖으로 손을 뻗어 악수하고, 그 잡은 손을 통로로 씁니다.

10주차에서 이미 터널을 한 번 세웠습니다. 이번 주에는 그 터널을 제대로 만듭니다.

6.2 ⭐ ports: 한 줄이 왜 보안 구멍인가

10주차의 설정은 이랬습니다. cloudflared가 호스트에서 직접 돌고, 컨테이너는 ports:로 호스트 포트를 열었습니다.

# 지난주까지의 구성 — 동작은 하지만 위험하다
backend:
  ports:
    - "8000:8000"     # ⚠️ 이 한 줄

동작은 합니다. cloudflared가 호스트에 있으니 localhost:8000으로 백엔드에 닿습니다. 문제는 그 포트가 cloudflared에게만 열린 것이 아니라는 점입니다.

#문제무슨 일이 벌어지는가
10.0.0.0 바인딩ports: 8000:8000은 기본적으로 모든 네트워크 인터페이스에 바인딩됩니다. 같은 공유기에 붙은 모든 기기가 192.168.x.x:8000 으로 직접 접근할 수 있습니다
2보호 장치 우회그 경로로 들어오면 Cloudflare의 WAF·레이트 리밋·봇 차단을 전부 건너뜁니다. 5.2절의 6번 항목이 통째로 무력화됩니다
3도커가 방화벽을 무시도커는 iptables를 직접 조작합니다. ufw deny 8000 을 걸어 두어도 도커가 만든 규칙이 그보다 앞에 놓여 UFW 규칙이 적용되지 않습니다
4LAN IP는 고정이 아님그렇다고 192.168.x.x를 설정에 하드코딩하면, DHCP가 IP를 재할당하는 순간 서비스가 죽습니다

⚠️ 3번은 특히 자주 당합니다. "방화벽 켜 뒀으니 안전하다"가 도커 환경에서는 사실이 아닙니다. sudo iptables -L DOCKER -n 으로 확인해 보면 도커가 삽입한 ACCEPT 규칙이 보입니다. 도커에서 포트를 막는 올바른 방법은 방화벽이 아니라 애초에 ports:를 쓰지 않는 것입니다.

⚠️ 4번은 11주차의 그 문제입니다. "원격 PC를 껐다 켜면 IP가 바뀐다." 그때는 매일 아침 ipconfig로 확인해서 넘겼지만, 이번 주에 그 문제를 이름 해석으로 근본 해결합니다. (6.5절)

이 네 문제의 공통 원인은 하나입니다. 문이 두 개이기 때문입니다. 터널이라는 정문과 ports:라는 뒷문. 축 5의 왼쪽에 서 있는 상태입니다.

6.3 1단계 — cloudflared를 컨테이너로 편입

전제부터 바뀝니다. cloudflared 자체를 도커 공유 네트워크 안에 넣습니다.

cloudflared:
  image: cloudflare/cloudflared:latest
  command: tunnel run --token ${TUNNEL_TOKEN}
  networks: [dreamscape]
  restart: unless-stopped

대시보드 관리형(Remotely-managed) 터널이므로 설정 파일 없이 토큰 하나만 있으면 됩니다.

토큰 얻는 절차:

  1. Cloudflare Zero Trust → Networks → Tunnels
  2. 해당 터널 선택 → 토큰 복사
  3. .env에 TUNNEL_TOKEN=... 으로 저장

⚠️ 이 토큰은 터널 전체를 조종할 수 있는 자격증명입니다. 3주차의 API 키 원칙 그대로, .env에 넣고 절대 커밋하지 않습니다.

6.4 2단계 — Service를 컨테이너 이름으로 바꾼다

cloudflared가 이제 컨테이너 안에 있으므로, 그가 보는 세상이 달라졌습니다. localhost는 이제 호스트가 아니라 cloudflared 자기 자신입니다.

Cloudflare 대시보드의 Published application routes에서 Service 값을 바꿉니다.

라우트변경 전변경 후
api.도메인Alocalhost:8000http://backend:8000
api.도메인Blocalhost:8081http://second-backend:8081
api.도메인C192.168.x.x:8082http://third-backend:8082
auth.도메인A (신규)—http://auth:9000

왜 이름으로 써도 되는가. 도커에는 내장 DNS가 있습니다. 같은 사용자 정의 네트워크에 속한 컨테이너들은 서로의 컨테이너 이름(또는 서비스 이름)으로 통신할 수 있고, 도커가 그 이름을 그 순간의 사설 IP로 해석해 줍니다.

⭐ 이것이 6.2절 4번 문제의 근본 해결입니다. 컨테이너가 재시작되어 내부 IP가 바뀌어도, 이름은 그대로이므로 설정을 고칠 일이 없습니다. 1.6절에서 말한 "이름 해석이 인터넷 규모와 한 대의 컴퓨터 안에서 똑같이 반복된다"가 이것입니다. 도메인 이름을 IP로 바꿔주는 일을 인터넷에서는 DNS가, 도커 네트워크 안에서는 도커 내장 DNS가 합니다.

6.5 ⭐ localhost · 컨테이너 이름 · host.docker.internal

이번 주에 반드시 손에 익어야 하는 삼분법입니다. 주소를 쓰는 쪽이 어디에 있느냐로 갈립니다.

쓰는 주소무엇을 가리키는가언제 쓰는가
localhost / 127.0.0.1그 코드가 돌고 있는 자기 자신호스트에서 호스트의 서비스를 부를 때
http://backend:8000같은 도커 네트워크의 다른 컨테이너⭐ 컨테이너 → 컨테이너
http://host.docker.internal:80컨테이너에서 본 호스트 머신컨테이너 → 호스트에 직접 설치된 서비스
        ┌─────────────── 호스트 머신 ────────────────┐
        │                                            │
        │   ┌──────── 도커 네트워크 (dreamscape) ────┐ │
        │   │                                       │ │
        │   │  [cloudflared] ──"backend"──▶ [backend]│ │   ← 컨테이너 이름
        │   │       │                                │ │
        │   └───────┼────────────────────────────────┘ │
        │           │                                  │
        │           └──"host.docker.internal"──▶ [호스트에 직접 설치된 nginx]
        │                                            │
        └────────────────────────────────────────────┘

⚠️ "터널은 녹색인데 502가 뜬다"의 대부분이 이 삼분법을 틀린 것입니다. 10주차에서 커넥터가 컨테이너였을 때 host.docker.internal을 썼고, 이번 주에 백엔드도 컨테이너로 들어왔으니 컨테이너 이름으로 바뀐 것입니다. 같은 시스템인데 답이 바뀐 이유는, cloudflared가 서 있는 위치가 바뀌었기 때문입니다.

예외 처리 — 컨테이너가 아니라 호스트에 직접 설치된 서비스로 보내야 한다면:

cloudflared:
  image: cloudflare/cloudflared:latest
  command: tunnel run --token ${TUNNEL_TOKEN}
  networks: [dreamscape]
  extra_hosts:
    - "host.docker.internal:host-gateway"    # ⭐ 리눅스에서는 이 줄이 필요

그리고 Service를 http://host.docker.internal:80 으로 설정합니다.

💡 host.docker.internal은 Docker Desktop(윈도우·맥)에서는 기본 제공되지만 리눅스 엔진에서는 기본값이 아닙니다. extra_hosts에 host-gateway를 매핑해 줘야 이름이 해석됩니다.

6.6 3단계 — 모든 백엔드에서 ports: 제거

이제 뒷문을 닫습니다.

backend:
  build: .
  command: uvicorn main:app --host 0.0.0.0 --port 8000
  networks: [dreamscape]
  # ports:              ← 통째로 삭제
  #   - "8000:8000"

제거 후 무엇이 달라지는가:

제거 전제거 후
인터넷에서Cloudflare 경유Cloudflare 경유
같은 공유기의 다른 기기에서⚠️ 192.168.x.x:8000 직접 접근 가능❌ 닿을 수 없음
호스트 자신에서localhost:8000 가능❌ 불가 (docker exec로만)
진입 경로 개수2개 이상1개

⭐ 축 5가 왼쪽에서 오른쪽으로 넘어가는 순간입니다. "진입 경로가 Cloudflare 엣지 하나로 수렴한다" — 이 문장이 이번 주 인프라 작업의 결론입니다. 문이 하나면 그 문 앞에만 문지기를 세우면 됩니다. 문이 여럿이면 문지기도 여럿이어야 하고, 하나만 빠뜨려도 전체가 무너집니다.

💡 디버깅이 불편해지지 않나요? 됩니다. 대신 이렇게 합니다. docker compose exec backend curl localhost:8000/healthz — 컨테이너 안에서 확인하거나, docker compose logs -f backend 로 로그를 봅니다. 임시로 확인하려고 ports:를 다시 여는 습관이 가장 위험합니다.

6.7 ⭐ 무중단 전환 순서 다섯 단계

이 순서를 지키지 않으면 전환 도중 서비스가 끊깁니다. 순서 자체가 설계입니다.

① cloudflared 컨테이너를 먼저 기동한다        (새 통로를 만든다)
② 대시보드에서 Service 를 컨테이너 이름으로 하나씩 바꾼다   (트래픽을 새 통로로 옮긴다)
③ 전부 정상 동작하는지 확인한다               (돌아갈 수 있는 마지막 지점)
④ 호스트에서 돌던 기존 cloudflared 를 내린다   (옛 통로를 닫는다)
⑤ 마지막에 ports: 를 제거한다                (뒷문을 닫는다)

원칙은 하나입니다. 새 길을 먼저 열고, 옮기고, 확인하고, 그 다음에 옛 길을 닫는다. 반대로 하면(먼저 ports:를 지우면) 새 경로가 잘못 설정돼 있을 때 복구할 통로가 남지 않습니다.

단계이 시점에 롤백하려면서비스 영향
① 후컨테이너만 내리면 됨없음 (기존 경로 살아 있음)
② 진행 중라우트를 되돌리면 됨없음
③ 실패 시②를 되돌림없음
④ 후호스트 cloudflared 재기동없음
⑤ 후ports: 재추가 + 재기동재기동 순간만

⭐ 이것은 배포에서 블루-그린(Blue-Green) 이라 부르는 방식의 축소판입니다. "바꾸기"가 아니라 "새것을 세우고 → 옮기고 → 옛것을 지운다" 는 3박자로 생각하는 습관이 인프라 작업의 기본기입니다.

6.8 이 구조에서 .env가 어떻게 바뀌는가

기존에 로컬 주소로 직접 지정했던 REDIRECT는 주석 처리하고, 콜백 주소를 auth 서브도메인으로 바꿉니다.

# .env.auth

# ── 기존 (로컬 개발용) — 주석 처리 ────────────
# NAVER_REDIRECT_URI=http://localhost:3000/api/auth/callback/naver

# ── 변경 후 (터널 경유) ───────────────────────
NAVER_REDIRECT_URI=https://auth.example.cloud/auth/callback/naver

# JWT
JWT_PRIVATE_KEY_B64=...
# OAuth
NAVER_CLIENT_ID=...
NAVER_CLIENT_SECRET=...
# 리프레시 토큰 저장소
REDIS_URL=redis://redis:6379/0

⚠️ 네이버 콘솔의 등록 주소도 함께 바꿔야 합니다. (2.8절) .env만 바꾸고 콘솔을 안 바꾸면 공급자가 거부합니다. 두 곳이 항상 짝입니다.

⚠️ REDIS_URL의 호스트가 redis인 것에 주목하세요. localhost가 아닙니다. 6.5절 삼분법이 여기서도 적용됩니다. auth 컨테이너에서 본 Redis는 같은 네트워크의 다른 컨테이너입니다.

6.9 여기까지 하고 나면 남는 일

인증 서브도메인과 소셜 로그인이 붙었다는 것은 껍데기가 완성되었다는 뜻입니다. 그 다음은 안을 채우는 일입니다.

내부 에이전트는 각자 자기 도메인(콘텐츠)을 토대로 학습합니다. 그래서 인증 구조가 서면, 남은 기간은 콘텐츠를 채우는 기간이 됩니다.

9주차에서 만든 스타 토폴로지의 스포크들 — 콘텐츠·커뮤니티·어드민 — 이 이제 각자 다른 권한 등급을 가진 사용자를 맞이할 준비가 된 것입니다. RBAC의 Role이 실제로 의미를 갖는 것은 채워 넣을 콘텐츠가 있을 때부터입니다.