Files
project-gitops/docs/archive/INTERN_GUIDE-legacy.md
T

3041 lines
180 KiB
Markdown

# 인턴 가이드: 개념, 아키텍처, 코드로 이해하는 Project Auth GitOps
## 1. 이 문서의 목표
이 문서의 목적은 **개념**, **현재 프로젝트 아키텍처**, **실제 코드**를 하나의 흐름으로 이해하게 만드는 것입니다.
즉, 이 문서는 단순한 코드 해설서가 아니라 아래 3층 구조를 목표로 합니다.
1. 개념 레이어
이 프로젝트가 왜 Kubernetes, GitOps, Vault, Terraform, Keycloak 구조를 쓰는지 이해합니다.
2. 아키텍처 레이어
현재 dev 환경에서 각 구성요소가 어떤 책임을 가지며 어떻게 연결되는지 이해합니다.
3. 코드 레이어
실제로 `apps/`, `infra/`, `scripts/`, `terraform/`, `runbooks/` 안의 파일을 읽고 수정할 수 있게 합니다.
이 문서를 다 읽고 나면 최소한 아래 질문에 스스로 답할 수 있어야 합니다.
- 이 저장소는 왜 존재하는가?
- `apps/`만 보면 왜 절반밖에 이해하지 못하는가?
- `auth-server`는 왜 `Deployment``Job`을 둘 다 가지는가?
- 비밀값은 어디에서 생성되고, 어디를 지나서, 어떤 방식으로 Pod 안으로 들어가는가?
-`vault-transit``vault`를 둘 다 두었는가?
- 어떤 값은 `ConfigMap`에 두고, 어떤 값은 Vault에 두는가?
- 무엇을 `base`에 두고 무엇을 `overlay`에 두는가?
- 어떤 변경이 다른 파일들까지 연쇄적으로 수정하게 만드는가?
이 문서는 **현재 dev 환경 기준**으로 설명합니다.
## 2. 가장 먼저 알아야 하는 사실
이 저장소는 애플리케이션 소스 저장소가 아니라 **GitOps 저장소**입니다.
- `Project-Auth-Server`, `Project-Api-Server` 같은 앱 저장소는 코드와 CI를 담당합니다.
- 이 저장소는 Kubernetes에 반영할 선언과 CD를 담당합니다.
- 그래서 `apps/` 안의 YAML만 읽으면 "앱이 어떻게 배포되는지"는 보이지만, "누가 이걸 적용하는지", "비밀값은 누가 준비하는지", "왜 이런 구조인지"는 보이지 않습니다.
즉, 이 저장소를 제대로 이해하려면 아래를 **한 묶음**으로 봐야 합니다.
- `apps/`: 앱 매니페스트
- `infra/`: 공용 인프라 매니페스트
- `argocd/`: Argo CD가 어떤 폴더를 감시할지 정의
- `scripts/`: 실제 운영 절차를 자동화하는 Bash
- `terraform/`: Vault 내부 상태를 선언적으로 맞추는 코드
- `runbooks/`: 사람이 최초 bootstrap할 때 따르는 문서와 Vault 정책
## 3. 파일 확장자부터 정확히 알고 가기
| 확장자 | 뜻 | 이 저장소에서 하는 일 |
| ------- | -------------------------------- | -------------------------------------------------------------------- |
| `.yaml` | 선언형 데이터 포맷 | Kubernetes 리소스, Argo CD Application, GitHub Actions 워크플로 정의 |
| `.sh` | Bash 스크립트 | 운영 절차를 순서대로 자동 실행 |
| `.hcl` | HashiCorp Configuration Language | Vault 정책, Vault 서버 설정 |
| `.tf` | Terraform 설정 파일 | Vault 내부 리소스를 선언적으로 생성/동기화 |
중요한 점은 `*.tf`도 내부적으로 HCL 문법을 사용하지만, **의미가 다르다**는 것입니다.
- `runbooks/vault/dev/policies/*.hcl`: "누가 어떤 경로를 읽거나 쓸 수 있는가"를 적는 **Vault 정책**
- `infra/vault/base/files/vault/vault.hcl`: Vault 서버 자체가 어떻게 동작할지 적는 **Vault 서버 설정**
- `terraform/**/*.tf`: Terraform이 어떤 Vault 리소스를 만들어야 하는지 적는 **IaC 코드**
## 4. 용어 사전
### 4-1. Kubernetes 용어
| 용어 | 뜻 | 이 저장소에서 왜 중요한가 |
| -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Manifest | Kubernetes에 적용할 선언 파일 | `apps/`, `infra/`, `argocd/` 대부분이 manifest다 |
| Namespace | 리소스를 논리적으로 나누는 공간 | `auth-dev`, `api-dev`, `platform`, `vault`, `vault-transit`가 서로 다른 책임을 가진다 |
| Pod | 실제 컨테이너가 뜨는 가장 작은 실행 단위 | Deployment나 Job이 결국 Pod를 만든다 |
| Deployment | stateless 앱을 원하는 개수만큼 유지하는 리소스 | `auth-server`, `api-server`, `keycloak`, `vault`가 여기에 해당한다 |
| StatefulSet | 이름, 저장소, 순서가 중요한 워크로드용 리소스 | `postgres`는 데이터가 있으므로 `Deployment`가 아니라 `StatefulSet`을 쓴다 |
| Job | 한 번 실행하고 끝나는 작업 | DB migration, Keycloak client sync에 사용된다 |
| Service | Pod 앞에 놓는 고정된 네트워크 진입점 | Pod IP가 바뀌어도 `auth-server`, `api-server`, `postgres`에 접속할 수 있다 |
| Ingress | 클러스터 바깥 또는 north-south HTTP 진입 규칙 | Traefik을 통해 public host를 연결한다 |
| NetworkPolicy | Pod 간 통신 허용/차단 규칙 | 기본 차단 후 필요한 통신만 허용하는 구조를 만든다 |
| ConfigMap | 민감하지 않은 설정값 저장소 | 포트, 호스트, issuer URI 같은 값을 둔다 |
| Secret | 민감한 값 저장소 | 이 저장소는 runtime secret을 가급적 Vault로 옮기고 image pull secret만 예외로 남긴다 |
| SealedSecret | Git에 올려도 되는 암호화된 Secret 형태 | `ghcr-regcred`처럼 예외적으로 Git에 남겨야 하는 secret에 쓴다 |
| ServiceAccount | Pod가 Kubernetes API 세계에서 갖는 신분 | Vault Kubernetes auth가 이 신분을 이용해 Pod를 검증한다 |
| ExternalName Service | 다른 DNS 이름으로 트래픽을 넘기는 Service | `auth-public`, `api-public`처럼 Traefik 이름을 우회해 내부에서도 같은 public host를 쓰게 한다 |
| Probe | 컨테이너 준비 상태/생존 상태 확인 | 준비 전 트래픽 차단, 비정상 재시작 판단에 쓰인다 |
### 4-2. GitOps / Kustomize / Argo CD 용어
| 용어 | 뜻 | 이 저장소에서 왜 중요한가 |
| ------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
| GitOps | Git에 있는 선언을 실제 클러스터 상태의 기준으로 삼는 방식 | 사람이 직접 `kubectl edit` 하지 않고 Git을 수정한다 |
| Source of truth | 최종 기준이 되는 원본 | 앱 배포 선언의 원본은 이 저장소이고, 일부 seed secret의 원본은 provider Vault다 |
| Kustomize | YAML을 base + overlay 구조로 합성하는 도구 | 공통 뼈대와 환경별 차이를 분리한다 |
| Base | 환경과 무관한 공통 정의 | 공통 Deployment, Service, ServiceAccount 등이 들어간다 |
| Overlay | 특정 환경에만 적용되는 차이 | dev용 namespace, host, image tag, Vault patch가 들어간다 |
| Patch | 기존 리소스 일부만 덮어쓰는 변경 조각 | `deployment.vault-patch.yaml`이 대표적이다 |
| Argo CD Application | "이 경로를 이 namespace로 동기화하라"는 선언 | `argocd/applications/dev/**`가 담당한다 |
| Sync wave | Argo CD 적용 순서를 정하는 숫자 | Vault, platform, apps 순서를 안정적으로 맞춘다 |
| Hook / PreSync | 일반 리소스 적용 전/후에 특별하게 실행되는 리소스 | DB migration Job은 앱 배포보다 먼저 돈다 |
### 4-3. Vault / Terraform 용어
| 용어 | 뜻 | 이 저장소에서 왜 중요한가 |
| --------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- |
| Vault | 비밀값과 암호 기능을 제공하는 시스템 | 이 프로젝트의 runtime secret 관리 중심이다 |
| Provider Vault | 다른 Vault를 돕는 상위 Vault | 여기서는 `vault-transit`이 workload Vault를 돕는다 |
| Workload Vault | 앱이 직접 사용하는 Vault | `vault`가 여기에 해당한다 |
| Transit engine | 데이터를 직접 저장하지 않고 암호 연산만 해주는 Vault 엔진 | JWT 서명, auto-unseal에 사용된다 |
| Auto-unseal | Vault가 재기동 시 자동으로 봉인을 풀 수 있게 하는 방식 | workload Vault는 provider Vault의 transit key로 자동 unseal된다 |
| Mount | Vault 안의 기능이 걸린 경로 | `kv`, `database`, `transit`, `auth/kubernetes`, `auth/approle`이 대표적이다 |
| Policy | Vault 경로별 권한 규칙 | `auth-server-dev.hcl` 같은 파일이 여기에 해당한다 |
| Kubernetes auth | Pod의 ServiceAccount JWT로 Vault에 로그인하는 방식 | 앱/플랫폼 Pod가 이 방식으로 secret을 받는다 |
| AppRole | 기계 대 기계 로그인 방식 | CI와 bootstrap 자동화가 사용한다 |
| TTL | 토큰이나 동적 계정의 수명 | migration용 DB 계정 같은 것을 오래 살지 않게 만든다 |
| Terraform state | Terraform이 "내가 무엇을 만들었는지" 기억하는 파일 | bootstrap 루트와 reconcile 루트가 분리되어 있다 |
| Idempotent | 여러 번 실행해도 결과가 같게 유지되는 성질 | reconcile 스크립트와 Terraform 설계의 핵심이다 |
## 5. 이 저장소를 이해하기 위한 핵심 개념
이 섹션은 "왜 이런 구조가 필요한가"를 설명합니다.
이 섹션을 먼저 이해해야 뒤에서 나오는 YAML, Bash, HCL, TF가 단순 문법이 아니라 **설계의 결과물**로 보입니다.
### 5-1. 인증, 인가, OAuth2, OIDC, JWT, Keycloak
이 프로젝트를 이해하려면 먼저 아래 개념을 구분해야 합니다.
| 용어 | 뜻 | 이 프로젝트에서의 의미 |
| -------------------- | ---------------------------------------- | -------------------------------------------------------- |
| 인증(Authentication) | "너 누구냐?"를 확인하는 것 | 로그인 성공 여부, 토큰 발급 대상 식별 |
| 인가(Authorization) | "너 여기 접근해도 되냐?"를 확인하는 것 | API 접근 권한, 역할(Role) 확인 |
| OAuth2 | 권한 위임 프레임워크 | 소셜 로그인, 외부 로그인 연동의 큰 틀 |
| OIDC(OpenID Connect) | OAuth2 위에 사용자 신원 확인을 얹은 표준 | 로그인 결과를 표준화된 토큰 형태로 다룸 |
| JWT | 서명된 토큰 포맷 | auth-server가 발급하고 api-server가 검증 |
| Issuer | 토큰을 발급한 주체 | `auth-server`, `Keycloak`, `Vault transit` 설정과 연결됨 |
| Client | 인증 서버를 이용하는 애플리케이션 | Keycloak 안의 `project-auth-server` client |
| Redirect URI | 로그인 후 다시 돌아올 주소 | Keycloak client sync Job이 코드로 맞춘다 |
이 프로젝트에서는 인증 관련 역할이 셋으로 나뉩니다.
- `Keycloak`
외부 로그인 제공자와 연결되고, 로그인 브로커 역할을 한다
- `auth-server`
우리 서비스 관점의 인증 서버 역할을 하며 JWT를 발급한다
- `api-server`
auth-server가 발급한 JWT를 검증하는 리소스 서버 역할을 한다
이 셋이 실제로 어떻게 맞물려 동작하는지 하나의 흐름으로 보면 이렇습니다.
```mermaid
sequenceDiagram
participant User as 사용자 브라우저
participant KC as Keycloak<br/>(로그인 브로커)
participant Google as 구글/GitHub 등
participant Auth as auth-server<br/>(JWT 발급자)
participant API as api-server<br/>(리소스 서버)
User->>Auth: "소셜 로그인할래요"
Auth->>KC: Keycloak 로그인 페이지로 리다이렉트
KC->>Google: 소셜 제공자와 OAuth2 통신
Google-->>KC: 사용자 정보 반환
KC-->>Auth: OIDC 표준 토큰으로 변환해서 콜백
Auth->>Auth: 사용자 DB 조회/가입 처리 + 우리 JWT 발급
Auth-->>User: 우리 서비스 JWT 토큰 반환
User->>API: JWT를 헤더에 담아 API 호출
API->>API: JWT 서명 검증 (Vault Transit 공개키로)
API-->>User: API 응답
```
이 그림에서 핵심은 **각자가 맡은 범위가 다르다**는 것입니다. Keycloak은 외부 제공자와의 복잡한 통신만 처리하고, auth-server는 우리 서비스의 JWT만 발급하고, api-server는 그 JWT를 검증만 합니다. 하나의 서비스가 모든 것을 하지 않기 때문에, 각 부분을 독립적으로 교체하거나 수정할 수 있습니다.
왜 이렇게 나누는가?
- 소셜 로그인 제공자별 차이를 Keycloak이 흡수하게 하기 위해
- 우리 서비스의 토큰 정책과 외부 로그인 흐름을 분리하기 위해
- API 서버가 로그인 로직과 토큰 발급 책임까지 모두 떠안지 않게 하기 위해
> 💡 이 흐름의 각 단계가 **구체적으로 어떤 메커니즘**으로 동작하는지는 24장에서 심층적으로 다룹니다.
이 개념을 이해해야 아래 파일들이 왜 존재하는지 자연스럽게 연결됩니다.
- `infra/platform/base/keycloak-deployment.yaml`
- `infra/platform/base/keycloak-client-sync-job.yaml`
- `apps/auth-server/overlays/dev/configmap.yaml`
- `apps/api-server/overlays/dev/configmap.yaml`
### 5-2. 리눅스, 컨테이너, 프로세스, 파일
이 저장소의 YAML을 읽을 때 사실상 리눅스 프로세스 개념을 알아야 합니다.
특히 Vault patch를 읽을 때 이 이해가 없으면 `command`, `args`, `. /vault/secrets/runtime-env`, `exec java -jar ...` 같은 부분이 전부 주문처럼 보입니다.
꼭 이해해야 하는 개념은 아래와 같습니다.
| 개념 | 뜻 | 이 프로젝트에서 왜 중요한가 |
| ------------------- | --------------------------------------------- | ----------------------------------------------------------------------- |
| Process | 실행 중인 프로그램 | 컨테이너 안에서 결국 Java, Postgres, Keycloak도 모두 프로세스다 |
| PID 1 | 컨테이너 안의 첫 번째 프로세스 | 신호 처리와 종료 동작에 영향이 크다 |
| `command` / `args` | 컨테이너가 실제로 어떤 명령으로 시작할지 정의 | Vault secret을 읽고 나서 원래 앱을 띄우기 위해 자주 재정의한다 |
| `source` (`. file`) | 파일 안의 셸 명령을 현재 셸에 적용 | Vault Agent가 만든 `export ...` 파일을 환경변수로 불러온다 |
| `exec` | 현재 셸 프로세스를 실제 앱 프로세스로 교체 | PID 1을 셸이 아니라 Java/Postgres/Keycloak로 만들기 위해 중요하다 |
| Volume mount | 파일이나 디렉터리를 컨테이너에 붙이는 것 | Vault secret file, init script, config file이 모두 이 방식으로 들어온다 |
예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 핵심은 이 순서입니다.
1. Vault Agent가 `/vault/secrets/runtime-env` 파일 생성
2. `/bin/sh -ec` 셸 시작
3. `. /vault/secrets/runtime-env`로 환경변수 로드
4. `exec java -jar /app/application.jar`로 실제 앱 프로세스 시작
이것을 프로세스 관점에서 그림으로 보면 이렇습니다.
```mermaid
flowchart TD
subgraph Container["auth-server 컨테이너 내부"]
direction TB
A["/bin/sh -ec 시작<br/>PID 1 = 셸 프로세스"] --> B[". /vault/secrets/runtime-env<br/>export 명령들이 현재 셸에 적용<br/>→ 환경변수가 셸 메모리에 올라감"]
B --> C["exec java -jar /app/application.jar<br/>셸 프로세스가 Java 프로세스로 교체<br/>→ PID 1 = Java (셸은 사라짐)"]
end
subgraph 만약_exec_없이["만약 exec를 안 쓰면?"]
direction TB
D["PID 1 = 셸 (계속 살아있음)"] --> E["PID 2 = Java (자식 프로세스)"]
E --> F["K8s가 SIGTERM → 셸이 받음<br/>셸은 자식에게 전달 안 할 수 있음<br/>→ Java가 graceful shutdown 못 함"]
end
```
`exec`가 왜 중요한지 이 그림이 보여줍니다. K8s가 Pod를 종료할 때 **PID 1에게** SIGTERM 신호를 보냅니다. `exec` 없이 셸이 PID 1이면, 셸은 이 신호를 Java에게 전달하지 않을 수 있습니다. 결과적으로 Java가 연결을 정리하지 못한 채 강제 종료(SIGKILL)됩니다. `exec`를 쓰면 Java가 PID 1이 되어 직접 SIGTERM을 받고, 연결을 정리한 뒤 깔끔하게 종료합니다.
즉, 여기서 중요한 것은 "Vault가 비밀값을 준다"는 사실만이 아닙니다.
**비밀값을 파일로 렌더링하고, 셸이 그 파일을 읽고, 마지막에 앱 프로세스로 넘어간다**는 실행 모델 전체를 이해해야 합니다.
이 개념이 없으면 아래 같은 질문에 답하기 어렵습니다.
- 왜 secret을 환경변수 자체로 바로 안 넣고 파일로 렌더링하나?
-`exec`를 쓰나?
- 왜 base Deployment에서는 `command`가 없는데 overlay patch에서는 생기나?
> 💡 PID 1와 시그널 처리, 컨테이너 내부 프로세스 모델에 대한 더 깊은 이해는 23장에서 다룹니다.
### 5-3. Kubernetes는 "컨테이너 실행기"가 아니라 "원하는 상태를 유지하는 시스템"이다
초보자는 Kubernetes를 "도커를 원격으로 띄우는 도구"처럼 이해하기 쉽습니다. 하지만 더 정확히는 **원하는 상태(desired state)를 유지하는 시스템**입니다.
이 프로젝트에서 꼭 알아야 하는 핵심 리소스는 아래입니다.
| 리소스 | 무엇을 위한 것인가 | 이 프로젝트의 예시 |
| -------------- | ------------------------------------------- | --------------------------------------------------- |
| Deployment | 계속 살아 있어야 하는 stateless 앱 | `auth-server`, `api-server`, `keycloak`, `vault` |
| StatefulSet | 저장소와 정체성이 중요한 워크로드 | `postgres` |
| Job | 한 번 실행하고 끝나야 하는 작업 | `auth-db-migration`, `keycloak-client-sync` |
| Service | Pod 앞의 고정 네트워크 이름 | `auth-server`, `api-server`, `postgres`, `keycloak` |
| Ingress | HTTP 요청의 진입 규칙 | public host와 Traefik 연결 |
| NetworkPolicy | Pod 간 허용할 통신만 남기는 네트워크 방화벽 | 각 namespace의 default deny 구조 |
| ServiceAccount | Pod의 신분 | Vault Kubernetes auth에서 핵심 |
| ConfigMap | 비민감 설정 | 포트, URL, issuer, host |
| Secret / Vault | 민감 설정 | DB 비밀번호, client secret, token |
이 리소스들을 왜 구분해서 써야 할까요?
- 앱은 계속 살아야 하므로 `Deployment`
- DB는 디스크와 이름이 안정적이어야 하므로 `StatefulSet`
- migration은 한 번만 돌고 끝나야 하므로 `Job`
이 판단을 잘못하면 부작용이 큽니다.
- DB를 `Deployment`로 만들면 저장소와 이름 안정성이 약해진다
- migration을 `Deployment`로 만들면 계속 재시작될 수 있다
- 앱을 `Job`로 만들면 정상 서비스가 유지되지 않는다
K8s가 이 리소스들의 상태를 어떻게 유지하는지 핵심 루프를 그림으로 보면 이렇습니다.
```mermaid
flowchart LR
A["개발자가 선언<br/>replicas: 3"] --> B["API Server에<br/>Desired State 저장"]
B --> C{"Controller Manager<br/>현재 vs 원하는 상태 비교"}
C -->|"Pod 2개 살아있음<br/>1개 부족"| D["Pod 1개 추가 생성"]
C -->|"Pod 4개 살아있음<br/>1개 초과"| E["Pod 1개 삭제"]
C -->|"Pod 3개 살아있음<br/>일치 ✅"| F["아무것도 안 함"]
D --> C
E --> C
F -->|"계속 감시<br/>(Reconciliation Loop)"| C
```
이 루프가 **끊임없이** 도는 것이 K8s의 핵심입니다. 사용자가 "3개 돌려라"라고 선언하면, K8s는 현재 상태를 계속 확인하면서 차이를 조정합니다. Pod가 죽어도 자동으로 새로 만듭니다. 이것이 "컨테이너 실행기"가 아니라 "상태 유지 시스템"인 이유입니다.
> 💡 이 Reconciliation Loop, Watch 메커니즘, Control Plane 각 컴포넌트의 역할은 25장에서 프로세스 수준으로 상세히 다룹니다.
### 5-4. GitOps, Kustomize, Argo CD
이 프로젝트는 "좋은 YAML을 써놨다"에서 끝나지 않습니다.
이 YAML을 **누가**, **어떤 기준으로**, **반복적으로** 적용하느냐가 중요합니다.
#### GitOps
GitOps는 "실제 클러스터 상태의 기준을 Git에 둔다"는 운영 방식입니다.
장점:
- 누가 무엇을 바꿨는지 Git 기록으로 남는다
- 수동 클릭보다 재현 가능하다
- 문제가 생기면 선언 기준으로 되돌리기 쉽다
주의할 점:
- 클러스터에서 직접 수정하면 Git과 드리프트가 생긴다
- Git에 민감값을 넣으면 GitOps의 편의가 보안 리스크로 바뀐다
#### Kustomize
Kustomize는 공통(base)과 환경별 차이(overlay)를 분리합니다.
- `base`: 환경과 무관한 공통 뼈대
- `overlay`: dev/prod별 차이
이 프로젝트에서 이 구조가 중요한 이유:
- `auth-server`의 기본 보안 설정, 포트, 프로브는 공통이지만
- Vault 경로, ingress host, image tag, namespace는 환경별로 다를 수 있기 때문입니다
#### Argo CD
Argo CD는 Git에 있는 선언을 실제 클러스터와 맞추는 실행 주체입니다.
이 프로젝트에서 Argo CD가 하는 일:
- `argocd/applications/dev/**`에 정의된 경로를 감시
- 해당 경로의 manifest를 dev 클러스터에 동기화
- 드리프트가 생기면 다시 선언 상태로 되돌리려 함
즉, `apps/auth-server/overlays/dev`를 수정한다는 것은 단순히 파일을 고치는 것이 아니라
**Argo CD가 나중에 실제 클러스터 상태를 바꾸게 될 선언을 수정하는 것**입니다.
이 세 가지(GitOps, Kustomize, Argo CD)가 맞물리는 전체 흐름을 그림으로 보면 이렇습니다.
```mermaid
flowchart LR
subgraph Developer["개발자"]
A["base/deployment.yaml 수정<br/>또는 overlay/configmap.yaml 수정"]
end
subgraph Git["Git 저장소 (Source of Truth)"]
B["base/ + overlay/<br/>= 최종 선언"]
end
subgraph ArgoCD["Argo CD"]
C["Git 감시<br/>변경 감지"] --> D["Kustomize로<br/>base + overlay 합성"]
D --> E["합성 결과와<br/>현재 클러스터 비교"]
end
subgraph Cluster["K8s 클러스터"]
F["실제 리소스<br/>Deployment, Service 등"]
end
A -->|"git push"| B
B -->|"Watch"| C
E -->|"차이 있으면<br/>kubectl apply"| F
F -->|"드리프트 발생 시<br/>다시 선언으로 복원"| E
```
개발자는 Git만 수정합니다. 클러스터를 직접 건드리지 않습니다. Argo CD가 Git의 선언과 클러스터의 실제 상태를 계속 비교하고, 차이가 있으면 선언 쪽으로 맞춥니다. 만약 누군가 `kubectl edit`으로 클러스터를 직접 수정하면, Argo CD가 그것을 "드리프트"로 감지하고 Git 기준으로 되돌립니다.
### 5-5. Secret 관리: Kubernetes Secret, SealedSecret, Vault
이 프로젝트의 핵심 설계 중 하나는 "무엇을 어디에 저장할 것인가"입니다.
#### Kubernetes Secret
Kubernetes Secret은 Kubernetes 안에서 secret을 다루기 위한 기본 기능입니다.
하지만 이 프로젝트에서는 runtime secret의 최종 해답으로 보지 않습니다.
이유:
- 클러스터 안에 secret 복사본이 많이 생기기 쉽다
- GitOps 저장소에 그대로 두기 어렵다
- 장기 자격증명을 쉽게 만들 수 있다
#### SealedSecret
SealedSecret은 "Git에 올릴 수 있게 암호화된 Secret"입니다.
이 프로젝트에서 SealedSecret이 남아 있는 이유:
- `ghcr-regcred` 같은 image pull secret은 **Pod가 뜨기 전**에 필요하다
- Vault Agent는 Pod 생성 이후에 동작한다
- 즉, 이미지 pull credential은 Vault injection만으로 해결할 수 없다
그래서 이 프로젝트는 아래처럼 분리합니다.
- image pull secret: SealedSecret
- runtime secret: Vault
#### Vault
Vault는 "비밀값을 저장하는 곳"이면서 동시에 "권한을 기준으로 필요한 순간에만 비밀을 주는 곳"입니다.
이 프로젝트에서 Vault를 쓰는 이유:
- 앱마다 필요한 secret만 읽게 하기 위해
- 장기 비밀번호를 Git에서 제거하기 위해
- dynamic DB credential을 발급하기 위해
- JWT 서명을 key file 없이 transit으로 처리하기 위해
#### Secret Zero Problem
"Vault에 로그인하려면 처음에 무엇으로 인증하나?"라는 질문이 바로 Secret Zero Problem입니다.
이 프로젝트의 해법은 두 가지입니다.
- 앱/플랫폼 Pod: Kubernetes auth 사용
- CI / bootstrap automation: AppRole 사용
즉,
- Pod는 자기 ServiceAccount JWT로 신분을 증명하고
- CI는 별도의 AppRole credential로 로그인합니다
이 두 경로를 그림으로 보면 이렇습니다.
```mermaid
flowchart TB
subgraph Pod_경로["경로 1: Pod가 Vault에 접근할 때"]
direction LR
P1["Pod 내부의<br/>ServiceAccount JWT"] -->|"자동 마운트됨"| P2["Vault Agent가<br/>JWT를 들고 인증"]
P2 --> P3["Vault가 K8s API에<br/>'이 JWT 진짜야?' 확인"]
P3 --> P4["Secret 발급"]
end
subgraph CI_경로["경로 2: CI가 Vault에 접근할 때"]
direction LR
C1["GitHub Actions<br/>Secrets에 저장된<br/>Role ID + Secret ID"] --> C2["AppRole 로그인"]
C2 --> C3["Vault 토큰 발급"]
C3 --> C4["Terraform 실행"]
end
```
Pod 경로에서는 **K8s가 이미 부여한 신분(ServiceAccount)**을 재활용합니다. 별도의 비밀번호가 필요 없습니다. CI 경로에서는 **GitHub Actions의 Secrets 기능**이 Secret Zero를 담당합니다. 완벽하지는 않지만, 비밀번호를 코드에 직접 쓰는 것보다 훨씬 안전합니다.
> 💡 각 인증 방식의 핸드셰이크 상세는 바로 아래 5-6에서, 그리고 프로세스 수준의 동작은 26장에서 다룹니다.
### 5-6. Kubernetes auth, AppRole, Transit, Dynamic Secret
이 네 개념은 이 저장소를 이해할 때 반드시 구분해야 합니다.
| 개념 | 무엇인가 | 누가 쓰는가 | 이 프로젝트의 예시 |
| --------------- | ------------------------------------------- | ----------------------------- | -------------------------------------- |
| Kubernetes auth | Pod의 ServiceAccount로 Vault 로그인 | 앱/플랫폼 Pod | `auth-server`, `postgres`, `keycloak` |
| AppRole | 기계용 Vault 로그인 방식 | CI, bootstrap, 운영 자동화 | `vault-dev-reconcile` workflow |
| Transit | 키를 직접 밖으로 꺼내지 않고 암호 연산 제공 | auth-server, auto-unseal 구조 | JWT signing, workload Vault unseal |
| Dynamic secret | 일정 시간이 지나면 만료되는 자격증명 | migration Job, operator | `database/creds/auth-db-migration-dev` |
이 네 가지를 섞어 생각하면 안 됩니다.
- Kubernetes auth는 "Pod용 로그인 방식"
- AppRole은 "비Pod 자동화용 로그인 방식"
- Transit은 "암호 기능"
- Dynamic secret은 "짧게 사는 계정"
이 네 가지가 이 프로젝트의 어디에서 쓰이는지 한 눈에 보면 이렇습니다.
```mermaid
flowchart TB
subgraph Vault["Workload Vault"]
KV["KV Engine<br/>(고정 secret 저장)"]
DB["Database Engine<br/>(동적 계정 발급)"]
TR["Transit Engine<br/>(암호 연산)"]
KA["K8s Auth<br/>(Pod 로그인)"]
AR["AppRole Auth<br/>(CI 로그인)"]
end
AUTH["auth-server Pod"] -->|"K8s Auth로 로그인"| KA
AUTH -->|"DB 비밀번호 읽기"| KV
AUTH -->|"JWT 서명 요청"| TR
MIG["migration Job"] -->|"K8s Auth로 로그인"| KA
MIG -->|"임시 DB 계정 발급"| DB
CI["GitHub Actions"] -->|"AppRole로 로그인"| AR
CI -->|"Terraform으로<br/>policy/role/secret 설정"| KV
```
auth-server는 K8s Auth로 로그인해서 KV(고정 비밀번호)와 Transit(JWT 서명)을 사용합니다. migration Job은 K8s Auth로 로그인해서 Database Engine(임시 계정)을 사용합니다. CI는 AppRole로 로그인해서 Terraform으로 설정을 관리합니다. **같은 Vault지만 로그인 방식과 사용하는 엔진이 다릅니다.**
> 💡 각 엔진과 인증 방식의 내부 동작은 26장에서 상세히 다룹니다.
### 5-7. Terraform, State, 멱등성, Bootstrap vs Reconcile
Terraform은 단순히 "리소스를 만드는 도구"가 아닙니다.
핵심은 **현재 상태와 원하는 상태의 차이를 계산한다**는 점입니다.
#### State
Terraform state는 Terraform이 "내가 무엇을 만들었는지" 기억하는 파일입니다.
이 프로젝트에서 state가 중요한 이유:
- Vault 안에는 정책, auth backend, AppRole, database role 등 많은 리소스가 있다
- 이것을 사람이 매번 수동으로 비교하는 것은 어렵다
- Terraform이 state를 바탕으로 diff를 계산해야 반복 적용이 안전해진다
#### 멱등성(Idempotency)
멱등성이란 "같은 작업을 여러 번 해도 결과가 같게 유지되는 성질"입니다.
이 프로젝트에서 왜 중요한가?
- GitHub Actions workflow는 반복 실행될 수 있다
- Vault reconcile도 여러 번 돌아야 한다
- 같은 apply가 다시 실행되더라도 리소스가 중복 생성되면 안 된다
#### Bootstrap vs Reconcile
이 프로젝트는 Terraform 루트를 일부러 나눴습니다.
- bootstrap 루트
최초 1회, 강한 권한으로 구조를 세우는 용도
- reconcile 루트
이미 세워진 구조를 반복적으로 안전하게 맞추는 용도
왜 이렇게 나눌까?
- CI에 root 수준 권한을 오래 주지 않기 위해
- 최초 생성과 일상 동기화의 책임을 분리하기 위해
- 사람이 승인해야 할 작업과 자동화가 해도 되는 작업을 구분하기 위해
이 개념을 먼저 이해해야 뒤에서 나오는:
- `terraform/vault/dev`
- `terraform/vault/reconcile`
- `terraform/vault-transit/dev`
- `terraform/vault-transit/reconcile`
이 네 디렉터리의 의미가 선명해집니다.
이 분리를 권한 경계 관점에서 그림으로 보면 이렇습니다.
```mermaid
flowchart LR
subgraph Bootstrap["Bootstrap (최초 1회, 사람이 실행)"]
direction TB
B1["vault/dev<br/>mount 생성, auth backend 활성화<br/>root 수준 권한 필요"]
B2["vault-transit/dev<br/>transit key 생성, AppRole 생성<br/>root 수준 권한 필요"]
end
subgraph Reconcile["Reconcile (반복, CI가 실행)"]
direction TB
R1["vault/reconcile<br/>policy 업데이트, role 업데이트<br/>secret 복사, DB role 설정<br/>제한된 권한으로 충분"]
R2["vault-transit/reconcile<br/>policy 업데이트, role 업데이트<br/>제한된 권한으로 충분"]
end
Bootstrap -->|"구조가 세워진 뒤<br/>이후는 reconcile만"| Reconcile
```
핵심은 **CI(GitHub Actions)가 root 토큰을 갖지 않는다**는 것입니다. Bootstrap은 운영자가 직접, 한 번만 실행합니다. 이후 CI는 reconcile 루트만 반복 실행하며, 최소한의 권한으로 기존 구조를 유지보수합니다.
> 💡 Terraform의 State 관리, Plan/Apply 사이클, Provider 이중 설정 등 내부 메커니즘은 27장에서 상세히 다룹니다.
## 6. 이 저장소의 큰 흐름
```mermaid
flowchart TD
A[App repo CI] --> B[GitOps repo image tag update]
C[main push or infra change] --> D[.github/workflows/vault-dev-reconcile.yaml]
D --> E[scripts/ci/reconcile-vault-dev.sh]
E --> F[terraform/vault-transit/reconcile]
E --> G[terraform/vault/reconcile]
E --> H[argocd/applications/dev]
H --> I[Argo CD sync]
I --> J[apps/auth-server]
I --> K[apps/api-server]
I --> L[infra/platform]
I --> M[infra/vault]
I --> N[infra/vault-transit]
N --> M
M --> J
M --> L
J --> K
```
이 그림을 문장으로 풀면 아래와 같습니다.
1. 앱 저장소 CI가 새 이미지를 만들면 이 저장소의 overlay 이미지 태그를 바꿉니다.
2. 이 저장소의 `main`에 변경이 들어오면 `vault-dev-reconcile` 워크플로가 실행됩니다.
3. 워크플로는 Bash 스크립트를 통해 먼저 Vault 관련 상태를 맞춥니다.
4. 그 다음 Argo CD Application 정의를 적용합니다.
5. Argo CD가 실제 `apps/``infra/` 폴더를 읽어 클러스터 상태를 맞춥니다.
6. 앱 Pod는 최종적으로 workload Vault에서 secret을 받아 기동합니다.
## 7. 폴더 지도
| 경로 | 역할 | 여기서 반드시 이해해야 하는 것 |
| ----------------------------------- | --------------------------------------- | --------------------------------------------------- |
| `apps/auth-server` | 인증 서버 배포 정의 | DB migration, Vault injection, OAuth/Keycloak 연동 |
| `apps/api-server` | API 서버 배포 정의 | auth-server가 발급한 JWT를 검증하는 구조 |
| `infra/platform` | Postgres, Keycloak, Keycloak sync 정의 | 앱이 의존하는 플랫폼 계층 |
| `infra/vault` | workload Vault 배포 정의 | 앱이 직접 접근하는 Vault |
| `infra/vault-transit` | provider Vault 배포 정의 | workload Vault auto-unseal 지원 |
| `argocd/applications/dev` | Argo CD가 어떤 경로를 적용할지 정의 | sync 순서와 대상 namespace |
| `scripts/ci` | GitHub Actions가 호출하는 자동화 진입점 | reconcile 순서와 안전장치 |
| `scripts/vault/dev` | workload Vault bootstrap/reconcile 보조 | provider Vault에서 bootstrap 정보를 읽는 방식 |
| `scripts/vault-transit/dev` | provider Vault bootstrap 보조 | seed secret 입력, seal token 준비 |
| `terraform/vault/dev` | workload Vault 최초 bootstrap용 루트 | mount, auth backend, policy, role까지 만든다 |
| `terraform/vault/reconcile` | workload Vault routine reconcile용 루트 | CI가 반복 적용하는 루트 |
| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap용 루트 | transit key, AppRole, seal Secret 생성 |
| `terraform/vault-transit/reconcile` | provider Vault routine reconcile용 루트 | CI가 반복 적용하는 provider 쪽 루트 |
| `runbooks/vault/**` | 사람이 직접 bootstrap할 때 보는 문서 | 왜 bootstrap과 reconcile이 분리됐는지 이해해야 한다 |
## 8. 파일을 읽을 때 항상 던져야 하는 5가지 질문
이 저장소의 어떤 파일이든 아래 5가지 질문으로 읽으면 이해가 훨씬 빨라집니다.
1. 이 파일은 **누가 적용하는가**?
2. 이 파일은 **누가 소비하는가**?
3. 이 파일의 값은 **민감한 값인가 아닌가**?
4. 이 파일은 **최초 1회 bootstrap용인가**, 아니면 **반복 실행되는 reconcile용인가**?
5. 이 파일을 잘못 바꾸면 **어디가 먼저 깨지는가**?
예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`을 볼 때는 이렇게 읽어야 합니다.
- 누가 적용하는가: Argo CD
- 누가 소비하는가: Vault Agent Injector와 최종적으로 auth-server 컨테이너
- 민감한 값인가: 파일 자체는 secret을 담지 않지만 secret 경로를 지정한다
- bootstrap용인가 reconcile용인가: reconcile 이후 실제 앱 배포에 쓰이는 runtime 정의다
- 잘못 바꾸면 어디가 깨지는가: Vault secret render 실패, Pod 기동 실패, 로그인 기능 장애
## 9. 대표 시나리오 1: auth-server가 DB 비밀번호를 받는 과정
이 시나리오를 이해하면 이 프로젝트의 핵심을 절반 이상 이해한 것입니다.
### 9-1. 실제 흐름
1. 운영자 또는 초기 bootstrap 절차가 `scripts/vault-transit/dev/populate-workload-seeds.sh`를 실행합니다.
2. 이 스크립트는 provider Vault의 `kv/dev/workload/platform/postgres/auth-server` 경로에 값을 넣습니다.
3. `terraform/vault/reconcile/main.tf`는 provider Vault에서 이 값을 읽습니다.
4. 같은 Terraform이 workload Vault의 `kv/data/dev/platform/postgres/auth-server` 경로로 값을 복사합니다.
5. `runbooks/vault/dev/policies/auth-server-dev.hcl`는 auth-server가 그 경로를 읽을 수 있게 허용합니다.
6. `terraform/vault/reconcile/main.tf``auth-dev` namespace의 `auth-server` ServiceAccount에 이 정책을 연결하는 Kubernetes auth role도 만듭니다.
7. `apps/auth-server/overlays/dev/namespace.yaml`에는 `vault-injection: enabled` 라벨이 있어서 injector webhook 대상이 됩니다.
8. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`는 Vault에게 어떤 secret을 어디서 읽을지, 어떤 role로 로그인할지 알려줍니다.
9. Pod가 뜰 때 Vault Agent가 `/vault/secrets/runtime-env` 파일을 만들어 줍니다.
10. auth-server 컨테이너는 그 파일을 `source`한 뒤 `java -jar /app/application.jar`로 기동합니다.
이 10단계를 하나의 그림으로 보면 이렇습니다.
```mermaid
sequenceDiagram
participant OP as 운영자/Bootstrap
participant PV as Provider Vault<br/>(vault-transit)
participant TF as Terraform
participant WV as Workload Vault
participant API as K8s API Server
participant INJ as Vault Agent Injector
participant POD as auth-server Pod
Note over OP,PV: 1~2단계: Seed 입력
OP->>PV: populate-workload-seeds.sh로<br/>DB 비밀번호 입력
Note over TF,WV: 3~6단계: Terraform Reconcile
TF->>PV: provider Vault에서 seed 읽기
TF->>WV: workload Vault에 secret 복사
TF->>WV: policy 생성 + K8s auth role 생성
Note over API,POD: 7~10단계: Pod 기동
API->>INJ: "vault 어노테이션 있는 Pod 정의 와슸"
INJ->>INJ: Pod에 Vault Agent 사이드카 추가
INJ-->>API: 수정된 Pod 정의 반환
API->>POD: Pod 생성 (원래 컨테이너 + Vault Agent)
POD->>WV: K8s SA JWT로 인증 → secret 요청
WV-->>POD: /vault/secrets/runtime-env 파일 생성
POD->>POD: source runtime-env → exec java
```
이 그림에서 왼쪽(1~2단계)은 **사람이 최초 1회 하는 일**, 가운데(3~6단계)는 **CI가 반복하는 일**, 오른쪽(7~10단계)는 **매 배포마다 자동으로 일어나는 일**입니다.
> 💡 각 단계의 내부 동작(Mutating Webhook, K8s Auth Handshake, Go 템플릿 렌더링 등)은 26장에서 상세히 다룹니다.
### 9-2. 여기서 꼭 이해해야 하는 파일
| 파일 | 역할 | 왜 중요한가 |
| ----------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- |
| `scripts/vault-transit/dev/populate-workload-seeds.sh` | 최초 seed 입력 | secret의 진짜 출발점이다 |
| `terraform/vault/reconcile/main.tf` | provider Vault -> workload Vault 동기화 | secret이 어디로 복사되는지 결정한다 |
| `runbooks/vault/dev/policies/auth-server-dev.hcl` | 읽기 권한 제한 | auth-server가 무엇을 읽을 수 있는지 최소 권한으로 제한한다 |
| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Pod 기동 시 secret 주입 설정 | secret이 파일로 렌더되고 실행 시점에 읽힌다 |
### 9-3. 왜 이렇게 복잡하게 하나
단순히 Kubernetes Secret에 DB 비밀번호를 넣어도 앱은 뜹니다. 그런데 이 프로젝트는 그렇게 하지 않습니다.
- secret 원본을 Git에 두지 않기 위해
- 사람이 직접 장기 토큰을 여기저기 복붙하지 않기 위해
- Pod마다 필요한 secret만 읽게 하기 위해
- 이후 동적 계정, transit 서명, 최소 권한 같은 보안 정책을 일관되게 적용하기 위해
### 9-4. 잘못 건드렸을 때 생기는 부작용
| 잘못된 변경 | 바로 생기는 문제 | 더 큰 문제 |
| ------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------- |
| policy 경로를 너무 좁게 적음 | auth-server가 secret을 못 읽고 기동 실패 | 배포는 성공한 것처럼 보여도 앱은 죽는다 |
| policy 경로를 너무 넓게 적음 | 당장은 잘 동작 | auth-server 침해 시 읽을 수 있는 비밀 범위가 커진다 |
| patch에서 `automountServiceAccountToken: true`를 빼먹음 | Vault Kubernetes auth 실패 | injector가 secret을 렌더링하지 못한다 |
| secret을 `ConfigMap`으로 옮김 | 당장은 쉬워 보임 | 민감한 값이 GitOps 정의 안으로 들어온다 |
## 10. 대표 시나리오 2: 왜 auth-db-migration은 Job인가
`auth-server`는 앱이 하나만 있는 것이 아니라, **DB migration Job + 앱 Deployment** 두 가지로 구성됩니다.
### 10-1. 관련 파일
| 파일 | 역할 |
| ----------------------------------------------------------------- | -------------------------------------------- |
| `apps/auth-server/base/db-migration-job.yaml` | 실제 migration Job 정의 |
| `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` | migration Job에 Vault 기반 동적 DB 계정 주입 |
| `apps/auth-server/base/deployment.yaml` | 실제 API 서버 프로세스 |
### 10-2. 왜 Job인가
DB schema 변경은 "앱이 떠 있는 동안 계속 돌고 있어야 하는 프로세스"가 아닙니다.
- 한 번 실행해서 끝나야 합니다.
- 앱보다 먼저 끝나야 합니다.
- 실패하면 앱이 뜨기 전에 문제를 알리는 것이 더 안전합니다.
그래서 `db-migration-job.yaml`에는 아래 Argo CD 어노테이션이 들어 있습니다.
- `argocd.argoproj.io/hook: PreSync`
- `argocd.argoproj.io/sync-wave: "-1"`
의미는 이렇습니다.
- 일반 리소스보다 먼저 돈다
- 성공하면 지워도 된다
- 성공 전에는 뒤에 있는 앱 배포가 진행되지 않도록 앞단에서 멈춘다
이 타이밍을 그림으로 보면 이렇습니다.
```mermaid
flowchart LR
subgraph PreSync["① PreSync 단계"]
M["db-migration Job<br/>sync-wave: -1"] --> MC{"Migration<br/>결과"}
MC -->|"성공 ✅"| NEXT["다음 단계로"]
MC -->|"실패 ❌"| STOP["전체 sync 중단<br/>앱 배포 안 함"]
end
subgraph Sync["② Sync 단계"]
D["auth-server Deployment<br/>앱 배포"]
end
NEXT --> D
```
Migration Job이 성공해야만 auth-server Deployment가 배포됩니다. Migration이 실패하면 앱 배포가 아예 진행되지 않아서, schema가 안 맞는 상태로 앱이 뜨는 위험을 방지합니다.
> 💡 Flyway의 lock 메커니즘과 Job 분리 전략의 상세는 24장에서 다룹니다.
### 10-3. 왜 동적 계정을 쓰는가
`apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml`를 보면 migration Job은 `database/creds/auth-db-migration-dev`를 읽습니다.
이것은 KV에 저장된 고정 비밀번호가 아니라, **Vault database engine이 짧은 TTL을 가진 계정을 그때그때 발급**한다는 뜻입니다.
이 구조를 택한 이유는 다음과 같습니다.
- migration은 고권한 작업일 수 있으므로 장기 계정을 남기고 싶지 않다
- Job은 짧게 실행되므로 동적 계정과 잘 맞는다
- 누가 언제 어떤 계정을 발급받았는지 추적하기 쉽다
### 10-4. 이 설계에서 고민해야 할 점
| 고민 | 왜 해야 하는가 | 이 저장소의 답 | 잘못 선택했을 때 |
| ------------------------------------------ | ---------------------------------------------------------- | ------------------ | ------------------------------- |
| migration을 앱 시작 로직에 넣을까? | 서버 여러 대가 동시에 뜨면 schema 변경 경쟁이 생길 수 있다 | 별도 Job으로 분리 | 동시 실행, 락 충돌, 배포 불안정 |
| migration 계정을 고정할까 동적으로 만들까? | 장기 계정이 유출되면 피해가 길어진다 | 동적 계정 사용 | 유출 시 회수와 추적이 어려움 |
| 실패 시 앱도 뜨게 할까? | schema가 안 맞는데 앱이 뜨면 더 큰 장애를 만든다 | PreSync에서 막는다 | 런타임 예외, 데이터 손상 가능성 |
## 11. 대표 시나리오 3: auth-server가 JWT를 서명하는 과정
이 부분은 처음 보면 특히 헷갈립니다. auth-server는 단순히 DB secret만 읽는 것이 아닙니다. **Vault Transit을 이용해 JWT 서명도 수행**합니다.
### 11-1. 관련 파일
| 파일 | 역할 |
| ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| `apps/auth-server/overlays/dev/configmap.yaml` | auth-server가 Vault transit으로 JWT를 다룬다는 설정 제공 |
| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Vault token file을 컨테이너로 전달 |
| `runbooks/vault/dev/policies/auth-server-dev.hcl` | `transit/keys/project-auth-jwt`, `transit/sign/project-auth-jwt` 접근 허용 |
| `terraform/vault/dev/main.tf` | `project-auth-jwt` transit key 생성 |
### 11-2. 핵심 이해 포인트
- auth-server는 로컬에서 개인키를 직접 생성하지 않습니다.
- Vault transit engine에 "이 key로 서명해 달라"고 요청합니다.
- 그래서 애플리케이션 컨테이너 안에 장기 서명 키 파일이 존재하지 않습니다.
이 과정을 그림으로 보면 이렇습니다.
```mermaid
sequenceDiagram
participant User as 사용자
participant Auth as auth-server
participant Vault as Workload Vault<br/>(Transit Engine)
participant API as api-server
User->>Auth: "로그인 해줘"
Auth->>Auth: 사용자 확인, JWT 페이로드 준비
Auth->>Vault: "transit/sign/project-auth-jwt로<br/>이 페이로드에 서명해줘"
Note over Vault: 내부의 RSA 개인키로 서명<br/>개인키는 Vault 밖으로 절대 안 나감
Vault-->>Auth: 서명된 JWT 반환
Auth-->>User: JWT 토큰 전달
User->>API: JWT를 담아 API 호출
API->>Vault: "transit/keys/project-auth-jwt로<br/>공개키 읽기"
Vault-->>API: RSA 공개키 반환
API->>API: 공개키로 JWT 서명 검증
API-->>User: API 응답
```
핵심은 **auth-server가 개인키를 한 번도 본 적이 없다**는 것입니다. 서명이 필요할 때 Vault API를 호출하고, Vault가 내부에서 서명한 결과만 돌려줍니다. api-server는 공개키만 가져와서 검증합니다.
이 방식은 운영 난이도는 조금 올라가지만, **키 보관을 중앙화**하고 **키 유출 위험을 줄이는 장점**이 있습니다.
> 💡 Transit Engine의 키 Rotation, 이중 역할(JWT 서명 + Auto-Unseal), 성능 트레이드오프는 26장에서 상세히 다룹니다.
## 12. auth-server 파일을 어떻게 읽어야 하는가
### 12-1. `apps/auth-server/base/kustomization.yaml`
이 파일은 auth-server를 구성하는 공통 리소스 목록입니다.
- `serviceaccount.yaml`
- `auth-db-migration-serviceaccount.yaml`
- `service.yaml`
- `db-migration-job.yaml`
- `deployment.yaml`
이 단계에서 꼭 봐야 하는 것은 "auth-server는 서비스 하나가 아니라 **서비스 + migration job**으로 구성된다"는 사실입니다.
### 12-2. `apps/auth-server/base/deployment.yaml`
이 파일은 secret 주입이 없는 기본 뼈대입니다. 여기서 눈여겨볼 지점은 다음과 같습니다.
| 항목 | 의미 | 왜 이렇게 했는가 |
| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------- |
| `serviceAccountName: auth-server` | 이 Pod의 Kubernetes 신분 | 나중에 Vault Kubernetes auth와 연결된다 |
| `automountServiceAccountToken: false` | 기본적으로 SA 토큰을 Pod에 넣지 않음 | 필요한 경우에만 토큰을 노출하려는 보안 기본값 |
| `runAsNonRoot`, `seccompProfile` | 보안 기본 설정 | root 실행과 과도한 시스템 호출을 줄인다 |
| `envFrom``configMapRef``secretRef` | 기본 설계상 설정/비밀을 받는 자리 | dev에서는 overlay patch로 secret 부분이 Vault 방식으로 대체된다 |
| `readinessProbe`, `livenessProbe`, `startupProbe` | 준비/생존/초기 부팅 상태 확인 | 느린 기동과 장애를 구분하기 위함 |
중요한 포인트는 **base의 값이 최종값이 아닐 수 있다**는 것입니다. overlay patch가 들어오면 일부 항목은 바뀝니다.
### 12-3. `apps/auth-server/overlays/dev/kustomization.yaml`
이 파일은 dev 환경에서 auth-server가 실제로 어떤 모습으로 배포되는지 정합니다.
주요 포인트:
- namespace는 `auth-dev`
- dev 전용 `configmap`, `ingress`, `networkpolicy`, `sealedsecret`를 추가
- `deployment.vault-patch.yaml`, `db-migration-job.vault-patch.yaml`로 base를 덮어씀
- 이미지 태그는 여기서 관리됨
즉, 실제 dev 배포를 이해하려면 **base만 보면 안 되고 overlay까지 합쳐서 봐야** 합니다.
이 합성 과정을 그림으로 보면 이렇습니다.
```mermaid
flowchart TD
subgraph Base["base/ (공통 뼈대)"]
B1["deployment.yaml<br/>automountServiceAccountToken: false<br/>command/args: 없음"]
B2["service.yaml"]
B3["serviceaccount.yaml"]
end
subgraph Overlay["overlays/dev/ (환경별 차이)"]
O1["deployment.vault-patch.yaml<br/>automountServiceAccountToken: true<br/>command/args: 추가"]
O2["configmap.yaml (새로 추가)"]
O3["networkpolicy.yaml (새로 추가)"]
O4["namespace: auth-dev"]
end
subgraph Result["최종 배포 결과 (Kustomize 합성)"]
R1["deployment.yaml<br/>automountServiceAccountToken: true ← patch로 변경<br/>command/args: Vault 시작 명령 추가"]
end
B1 --> R1
O1 -->|"패치 적용<br/>(strategic merge)"| R1
```
base에서 `automountServiceAccountToken: false`이지만, overlay patch가 이것을 `true`로 덮어씁니다. **즉, base만 보고 "토큰이 안 들어가네"라고 판단하면 틀립니다.** overlay까지 합쳐야 실제 동작을 알 수 있습니다.
### 12-4. `apps/auth-server/overlays/dev/configmap.yaml`
이 파일은 민감하지 않은 값만 둡니다.
대표 항목:
- `APP_DATASOURCE_URL`
- `APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI`
- `APP_SECURITY_JWT_ISSUER`
- `APP_SECURITY_JWT_VAULT_ENABLED`
- `APP_SECURITY_JWT_VAULT_ADDRESS`
- `APP_SECURITY_JWT_VAULT_MOUNT_PATH`
여기서 중요한 기준은 간단합니다.
- 값이 공개되어도 치명적이지 않으면 `ConfigMap`
- 비밀번호, client secret, token처럼 노출되면 안 되면 Vault
### 12-5. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`
이 파일은 dev auth-server의 핵심입니다.
반드시 이해해야 하는 항목:
| 항목 | 의미 | 놓치면 안 되는 이유 |
| ------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| `vault.hashicorp.com/agent-inject: "true"` | injector가 이 Pod를 가로채 secret 파일을 만든다 | 이 값이 없으면 Vault 주입이 시작되지 않는다 |
| `agent-inject-secret-runtime-env` | 어떤 Vault 경로에서 값을 읽을지 | secret 출처를 정확히 가리킨다 |
| `agent-inject-template-runtime-env` | secret을 쉘 `export` 형식으로 렌더링 | 앱이 `. /vault/secrets/runtime-env` 로 바로 읽을 수 있다 |
| `vault.hashicorp.com/role: auth-server-dev` | Vault 로그인 시 사용할 역할 이름 | policy와 Kubernetes auth role 이름이 연결된다 |
| `automountServiceAccountToken: true` | 이 Pod만 예외적으로 SA 토큰 허용 | Vault가 Pod 신분을 검증하려면 필요하다 |
| `command`, `args` 재정의 | Java 실행 전에 secret 파일과 token file을 읽음 | Vault 주입 결과를 프로세스 환경에 연결한다 |
이 파일을 읽을 때 꼭 이해해야 하는 역설이 하나 있습니다.
- base에서는 `automountServiceAccountToken: false`
- overlay patch에서는 `automountServiceAccountToken: true`
왜 이런 모순처럼 보이는 구조를 쓰는가?
- 기본값은 "토큰을 넣지 않는다"가 더 안전하기 때문
- 하지만 Vault Kubernetes auth를 쓰는 특정 Pod는 로그인에 토큰이 필요하기 때문
- 그래서 **필요한 워크로드에서만 예외를 허용**한다
이게 바로 "보안 기본값은 닫고, 필요한 곳만 연다"는 설계입니다.
### 12-6. `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml`
이 파일은 더 중요합니다. migration Job은 고정 secret이 아니라 동적 DB 계정을 받기 때문입니다.
핵심 포인트:
- `database/creds/auth-db-migration-dev` 사용
- `agent-pre-populate-only: "true"` 사용
- 컨테이너 시작 전에 secret 파일을 만들어두고, 사이드카를 계속 띄우지 않음
`pre-populate-only`가 어울리는가?
- Job은 짧게 실행되고 끝난다
- secret을 한 번 받아 실행하면 충분하다
- 장시간 sidecar를 유지할 필요가 없다
### 12-7. `apps/auth-server/overlays/dev/networkpolicy.yaml`
이 파일은 "막아두고 필요한 것만 연다"는 철학을 가장 잘 보여줍니다.
구조는 다음과 같습니다.
- 기본적으로 ingress, egress 모두 차단
- DNS만 허용
- Postgres와 Vault로 가는 egress 허용
- Traefik에서 들어오는 ingress 허용
이 파일을 수정할 때는 항상 이 질문을 해야 합니다.
- 새로 필요한 네트워크 경로가 정말 있는가?
- 그 경로는 어느 namespace, 어느 label, 어느 port인가?
- DNS는 이미 열려 있는가?
가장 흔한 실수는 "앱이 안 뜬다"는 문제를 보고 Deployment만 수정하는 것입니다. 실제 원인은 NetworkPolicy일 수 있습니다.
### 12-8. `namespace.yaml`, `public-access.yaml`, `ghcr-regcred.sealedsecret.yaml`
이 세 파일은 초보자가 자주 지나치지만, 실제로는 구조 이해에 매우 중요합니다.
`apps/auth-server/overlays/dev/namespace.yaml`
- `auth-dev` namespace를 만든다
- `vault-injection: enabled` 라벨을 준다
- Pod Security 관련 라벨도 같이 준다
여기서 중요한 것은 `vault-injection: enabled`입니다.
`argocd/applications/dev/infra/vault-agent-injector.yaml`를 보면 injector webhook은 **이 라벨이 있는 namespace에만** 동작합니다.
즉, auth-server가 Vault injection을 받는 이유는 단순히 Deployment patch 때문만이 아니라, **namespace도 injector 대상 조건을 만족**하기 때문입니다.
반대로 `apps/api-server/overlays/dev/namespace.yaml`에는 이 라벨이 없습니다. api-server는 Vault injection을 쓰지 않기 때문입니다.
`apps/auth-server/overlays/dev/public-access.yaml`
- `auth-public`이라는 `ExternalName` Service를 만든다
- 실제로는 `traefik.kube-system.svc.cluster.local`을 가리킨다
이 구조를 두는 이유는 "클러스터 내부에서도 public host 기준으로 접근하게 만들기 위해서"입니다.
예를 들어 issuer URI나 callback URL은 public host 기준으로 맞추는 편이 일관성이 좋습니다. 그때 내부 Pod가 그 host를 해석했을 때도 Traefik으로 가도록 `ExternalName`을 둡니다.
`apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml`
- GHCR 이미지 pull secret을 암호화된 형태로 Git에 저장한다
왜 runtime secret은 Vault로 옮기면서 이건 SealedSecret으로 남겼는가?
- 이미지 pull secret은 **Pod가 뜨기 전에** 필요하다
- Vault Agent는 Pod 생성 후에 동작한다
- 즉, 이미지를 받기도 전에 필요한 자격증명은 Vault injection으로 해결할 수 없다
이 차이를 이해해야 "왜 어떤 secret은 Vault고 어떤 secret은 SealedSecret이지?"라는 질문이 풀립니다.
## 13. api-server는 auth-server와 무엇이 다른가
`api-server`는 구조가 더 단순합니다. DB migration도 없고 Vault injection도 없습니다.
### 13-1. 관련 파일
| 파일 | 역할 |
| ------------------------------------------------- | ----------------------------- |
| `apps/api-server/base/deployment.yaml` | API 서버 기본 Deployment |
| `apps/api-server/overlays/dev/configmap.yaml` | JWT issuer URI 등 비민감 설정 |
| `apps/api-server/overlays/dev/networkpolicy.yaml` | Traefik 및 DNS 관련 통신 제어 |
| `argocd/applications/dev/apps/api-server.yaml` | Argo CD 적용 대상 선언 |
### 13-2. 꼭 봐야 할 포인트
- `APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI``auth-public.auth-dev.svc.cluster.local`을 가리킨다
- 즉, api-server는 auth-server가 발급한 토큰을 **검증하는 소비자**다
- auth-server가 죽거나 issuer 주소가 틀리면 api-server의 인증 기능도 깨질 수 있다
이 저장소를 읽을 때 흔히 하는 실수는 `api-server`를 완전히 독립된 서비스로 보는 것입니다. 하지만 인증 관점에서는 **auth-server에 의존**합니다.
이 의존 관계를 그림으로 보면 이렇습니다.
```mermaid
flowchart LR
USER["브라우저"] --> API["api-server<br/>JWT 검증만 수행"]
USER --> AUTH["auth-server<br/>JWT 발급"]
AUTH -->|"OIDC"| KC["Keycloak"]
AUTH -->|"JWT 서명"| VAULT["Vault Transit"]
AUTH -->|"DB R/W"| PG["Postgres"]
API -->|"공개키 읽기<br/>(issuer URI 경유)"| AUTH
style API fill:#e8f5e9
style AUTH fill:#fff3e0
```
api-server는 auth-server의 issuer URI를 통해 공개키를 가져와 JWT를 검증합니다. auth-server가 죽거나 issuer 주소가 바뀌면, api-server도 인증을 못 합니다.
## 14. platform 계층을 같이 봐야 하는 이유
`auth-server`만 보면 "DB URL이 왜 저기지?", "Keycloak client secret은 어디서 쓰이지?"가 남습니다. 그래서 `infra/platform`도 같이 봐야 합니다.
### 14-1. Postgres
`infra/platform/base/postgres-statefulset.yaml``infra/platform/overlays/dev/postgres.vault-patch.yaml`를 같이 봐야 합니다.
이 조합에서 확인해야 할 것:
-`StatefulSet`인가
- `AUTH_DB_PASSWORD`, `KEYCLOAK_DB_PASSWORD`도 Vault에서 주입받는가
- 초기 DB 생성 스크립트는 어떻게 들어가는가
여기서 배울 점은 "앱만 Vault를 쓰는 게 아니라 **플랫폼 컴포넌트도 Vault를 쓴다**"는 것입니다.
### 14-2. Keycloak
`infra/platform/base/keycloak-deployment.yaml``infra/platform/overlays/dev/keycloak.vault-patch.yaml`를 보면 Keycloak도 DB 비밀번호와 bootstrap admin 비밀번호를 Vault에서 받습니다.
이 구조를 이해해야 `auth-server`의 OAuth 설정이 왜 Keycloak과 맞물리는지 보입니다.
### 14-3. Keycloak client sync Job
`infra/platform/base/keycloak-client-sync-job.yaml``infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`는 "Keycloak client 설정도 코드로 맞춘다"는 것을 보여줍니다.
이 Job이 하는 일:
- Keycloak admin 계정으로 로그인
- `project-auth-server` client를 찾음
- client secret, base URL, redirect URI, web origins를 업데이트
즉, 사람 손으로 Keycloak 콘솔을 클릭하지 않고 **코드와 Job으로 클라이언트 설정을 맞추는 구조**입니다.
이 platform 계층의 의존 관계를 그림으로 정리하면 이렇습니다.
```mermaid
flowchart TB
VAULT["Workload Vault<br/>(secret 제공)"] --> PG["Postgres<br/>(StatefulSet)"]
VAULT --> KC["Keycloak<br/>(Deployment)"]
PG --> KC_SYNC["Keycloak Client<br/>Sync Job"]
KC --> KC_SYNC
PG --> AUTH["auth-server"]
KC --> AUTH
VAULT --> AUTH
KC_SYNC -.->|"client secret 설정<br/>redirect URI 설정"| AUTH
AUTH -->|"JWT issuer"| API["api-server"]
```
Vault가 모든 컴포넌트에 secret을 제공하고, Postgres와 Keycloak이 auth-server의 기반이 되며, Keycloak client sync Job이 auth-server와 Keycloak 사이의 설정을 자동으로 맞춥니다. **앱(auth-server)만 보면 이 기반이 보이지 않습니다.**
## 15. Argo CD는 정확히 무엇을 하는가
앱과 인프라 YAML을 실제로 클러스터에 반영하는 주체는 Argo CD입니다.
### 15-1. 꼭 읽어야 할 파일
| 파일 | 의미 |
| --------------------------------------------------------- | ----------------------------- |
| `argocd/applications/dev/infra/vault-transit.yaml` | provider Vault 배포 선언 |
| `argocd/applications/dev/infra/vault.yaml` | workload Vault 배포 선언 |
| `argocd/applications/dev/infra/platform.yaml` | Postgres, Keycloak 배포 선언 |
| `argocd/applications/dev/apps/auth-server.yaml` | auth-server dev 배포 선언 |
| `argocd/applications/dev/apps/api-server.yaml` | api-server dev 배포 선언 |
| `argocd/applications/dev/infra/vault-agent-injector.yaml` | injector Helm chart 배포 선언 |
### 15-2. sync wave를 꼭 이해해야 하는 이유
현재 dev 기준 순서는 대략 아래입니다.
- Vault, Vault Transit, Vault Agent Injector: `10`
- Platform: `20`
- auth-server: `30`
- api-server: `40`
왜 이런 순서가 필요한가?
- Vault와 injector가 먼저 있어야 secret 주입이 가능하다
- platform이 먼저 있어야 auth-server가 붙을 Postgres와 Keycloak이 준비된다
- auth-server가 먼저 있어야 api-server가 issuer를 안정적으로 참조할 수 있다
순서를 잘못 잡으면 "코드는 맞는데 배포만 실패하는" 문제가 생깁니다.
이 순서를 타임라인으로 보면 이렇습니다.
```mermaid
flowchart LR
W10["wave 10<br/>Vault Transit<br/>Vault<br/>Agent Injector"] --> W20["wave 20<br/>Platform<br/>(Postgres, Keycloak)"]
W20 --> W30["wave 30<br/>auth-server"]
W30 --> W40["wave 40<br/>api-server"]
W10 -.->|"이것 없이 다음 단계로 가면<br/>secret 주입 실패"| W20
W20 -.->|"이것 없이 다음 단계로 가면<br/>DB 연결 실패"| W30
W30 -.->|"이것 없이 다음 단계로 가면<br/>JWT 검증 실패"| W40
```
각 wave는 **이전 wave가 완료된 후에** 적용됩니다. wave 10이 완료되어야 Vault가 준비되고, wave 20이 완료되어야 Postgres가 준비되고, 그래야 auth-server가 DB와 Vault에 연결할 수 있습니다.
## 16. GitHub Actions와 Bash 스크립트는 무엇을 하는가
이 저장소에서 YAML만큼 중요한 것이 `scripts/``.github/workflows/`입니다.
### 16-1. `.github/workflows/vault-dev-reconcile.yaml`
이 워크플로는 dev 환경의 routine reconcile 진입점입니다.
주요 단계:
1. 저장소 checkout
2. 필수 도구 확인
3. kubeconfig 설정
4. Argo CD infra 정의 적용
5. provider Vault reconcile
6. workload Vault reconcile
7. Argo CD app 정의 적용
즉, 이 워크플로는 "앱 배포만" 하는 것이 아니라 **Vault 상태를 먼저 맞춘 뒤 앱을 반영**합니다.
### 16-2. `scripts/ci/reconcile-vault-dev.sh`
이 스크립트는 실전 운영 로직의 핵심입니다.
반드시 읽어야 할 이유:
- `require_cmd`, `require_env`로 선행조건을 강제한다
- `start_port_forward``trap`으로 백그라운드 프로세스를 정리한다
- `ensure_transit_state_resource`, `ensure_workload_state_resource`로 state import를 자동 보조한다
- `prepare-infra`, `reconcile-transit`, `reconcile-workload`, `apply-apps`를 분리해 순서를 명확히 한다
이 파일을 이해하지 못하면 "왜 Terraform이 두 번 돌지?", "왜 Argo CD 적용이 나중이지?"가 계속 헷갈립니다.
이 워크플로 전체 흐름을 그림으로 보면 이렇습니다.
```mermaid
sequenceDiagram
participant GH as GitHub Actions
participant SH as reconcile-vault-dev.sh
participant TF1 as Terraform<br/>(vault-transit/reconcile)
participant TF2 as Terraform<br/>(vault/reconcile)
participant ARGO as Argo CD
participant K8S as K8s 클러스터
GH->>SH: 스크립트 실행
SH->>SH: require_cmd/require_env<br/>사전조건 검증
SH->>K8S: Argo CD infra 정의 적용<br/>(Vault, Platform 등)
SH->>K8S: kubectl wait<br/>Vault Pod 준비 대기
SH->>TF1: Provider Vault reconcile<br/>(policy, role 업데이트)
SH->>TF2: Workload Vault reconcile<br/>(secret 복사, auth role 설정)
SH->>K8S: Argo CD app 정의 적용<br/>(auth-server, api-server)
ARGO->>K8S: 앱 매니페스트 sync
```
핵심은 **Vault 상태가 먼저 준비되고, 그 다음에 앱이 배포된다**는 것입니다. 순서가 바뀌면 앱이 secret을 못 받고 기동 실패합니다.
> 💡 이 스크립트의 방어적 프로그래밍(set -euo pipefail, trap, require_cmd)은 27장에서 한 줄씩 해부합니다.
## 17. Terraform은 왜 bootstrap 루트와 reconcile 루트가 분리되어 있는가
이 질문은 꼭 깊게 고민해야 합니다.
> 💡 이 장의 내용은 5-7절에서 개념적으로 다뤄으며, 27장에서 Terraform State, import, Provider 이중 설정 등 내부 메커니즘을 상세히 해부합니다.
### 17-1. 관련 디렉터리
| 경로 | 목적 |
| ----------------------------------- | -------------------------------- |
| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap |
| `terraform/vault-transit/reconcile` | provider Vault routine reconcile |
| `terraform/vault/dev` | workload Vault 최초 bootstrap |
| `terraform/vault/reconcile` | workload Vault routine reconcile |
### 17-2. 왜 분리하는가
bootstrap과 reconcile은 필요한 권한이 다릅니다.
- bootstrap은 auth backend 생성, mount 생성, 초기 root 수준 작업이 들어간다
- reconcile은 이미 만들어진 구조를 반복적으로 맞추는 데 집중한다
이 둘을 섞어버리면 어떤 문제가 생길까?
- CI가 너무 강한 권한을 가져야 한다
- 실수로 초기화 수준 작업을 routine workflow가 건드릴 수 있다
- 운영자가 의도한 수동 승인 절차가 사라진다
즉, 이 분리는 단순한 취향이 아니라 **권한 경계와 사고 범위를 줄이기 위한 설계**입니다.
### 17-3. `terraform/vault/reconcile/main.tf`에서 꼭 봐야 하는 것
이 파일은 크게 다섯 가지를 합니다.
1. Vault policy 생성
2. Kubernetes auth role 생성
3. AppRole 생성
4. provider Vault seed 값을 workload Vault KV로 복사
5. database engine connection과 dynamic role 생성
이 파일을 읽을 때는 리소스를 한 줄씩 보는 것보다, 아래 묶음으로 보는 것이 좋습니다.
- policy 묶음
- Kubernetes auth role 묶음
- provider seed -> workload KV 복사 묶음
- database backend 묶음
### 17-4. `runbooks/vault/dev/policies/*.hcl`는 왜 따로 뒀는가
정책을 Terraform 파일 안에 큰 문자열로 넣을 수도 있습니다. 그런데 이 저장소는 정책을 별도 `*.hcl` 파일로 분리했습니다.
이렇게 한 이유:
- 역할별 권한을 눈으로 검토하기 쉽다
- PR 리뷰에서 "이 서비스가 읽는 경로가 넓어졌는가"를 바로 볼 수 있다
- 정책만 따로 읽어도 서비스 권한 모델을 이해할 수 있다
## 18. 왜 `vault-transit`과 `vault`를 둘 다 두는가
이것도 반드시 고민해야 하는 포인트입니다.
### 18-1. 현재 구조
- `vault-transit`: provider Vault
- `vault`: workload Vault
provider Vault는 주로 아래를 담당합니다.
- workload Vault auto-unseal 지원
- workload용 seed secret의 source of truth 제공
- workflow AppRole 정보 제공
workload Vault는 주로 아래를 담당합니다.
- 앱/플랫폼 Pod가 실제로 읽는 runtime secret 제공
- Kubernetes auth 제공
- JWT signing transit 제공
- dynamic DB credential 발급
### 18-2. 왜 하나로 합치지 않았는가
하나로 합치면 구조는 단순해집니다. 하지만 이 저장소는 분리를 선택했습니다.
이유:
- auto-unseal trust boundary를 분리할 수 있다
- runtime secret 저장소와 unseal provider 역할을 분리할 수 있다
- CI가 직접 workload Vault root 수준 정보를 오래 들고 있지 않게 만들 수 있다
대신 비용도 있습니다.
- Vault가 2개라 운영 난이도가 올라간다
- bootstrap 절차가 길어진다
- 학습 난이도도 높아진다
즉, 이 구조는 "무조건 더 좋다"가 아니라 **보안 경계를 얻는 대신 운영 복잡도를 감수한 선택**입니다.
이 2-Vault 구조의 신뢰 관계를 그림으로 보면 이렇습니다.
```mermaid
flowchart TB
subgraph Provider["Provider Vault (vault-transit)"]
T_UNSEAL["Transit Key<br/>workload-vault-dev-unseal<br/>(Unseal용)"]
T_JWT["Transit Key<br/>project-auth-jwt<br/>(JWT 서명용)"]
SEED["Seed Secrets<br/>(원본 비밀번호)"]
end
subgraph Workload["Workload Vault"]
KV["KV Engine<br/>(복사된 runtime secret)"]
DB_ENG["Database Engine<br/>(동적 계정 발급)"]
K8S_AUTH["K8s Auth<br/>(Pod 인증)"]
end
T_UNSEAL -->|"Auto-Unseal<br/>마스터키 복호화"| Workload
SEED -->|"Terraform이<br/>seed를 복사"| KV
AUTH_POD["auth-server"] --> K8S_AUTH
AUTH_POD --> KV
AUTH_POD -->|"JWT 서명 요청"| T_JWT
MIG_POD["migration Job"] --> K8S_AUTH
MIG_POD --> DB_ENG
```
Provider Vault는 **Workload Vault의 생명(unseal)**과 **원본 secret(seed)**를 관리합니다. Workload Vault는 **앱이 실제로 사용하는 인터페이스**를 제공합니다. auth-server의 JWT 서명은 Provider Vault의 Transit을 직접 사용하지만, DB 비밀번호 등 runtime secret은 Workload Vault의 KV를 사용합니다.
> 💡 이 구조의 Seal/Unseal 메커니즘, Transit 이중 역할, Agent Injector 동작은 26장에서 상세히 다룹니다.
## 19. 이 프로젝트에서 꼭 고민해야 하는 설계 포인트
아래 표는 인턴이 실제로 생각해야 하는 지점을 일부러 늘려 정리한 것입니다.
| 상황 | 왜 고민해야 하는가 | 이 저장소의 현재 선택 | 그렇게 해야 하는 이유 | 잘못 선택했을 때 부작용 |
| --------------------------------------- | ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
| 새 설정값 추가 | 설정마다 민감도가 다르다 | 비민감 값은 `ConfigMap`, 민감 값은 Vault | Git과 manifest에 secret을 남기지 않기 위해 | secret 유출 또는 값 추적 어려움 |
| 새 secret 추가 | source of truth가 어디인지 정해야 한다 | provider Vault seed -> workload Vault 복사 | bootstrap과 runtime을 분리하기 위해 | 경로 불일치, CI와 runtime 동기화 실패 |
| 앱이 DB schema를 건드림 | 앱과 schema 변경 타이밍이 충돌할 수 있다 | 별도 PreSync Job 사용 | 앱보다 먼저, 한 번만 실행되게 하려는 목적 | 락 경쟁, 앱 부팅 실패, 반쪽 배포 |
| 새 워크로드가 Vault를 써야 함 | 기본 보안값과 예외를 정해야 한다 | base는 SA token off, 필요한 overlay만 on | 불필요한 토큰 노출을 막기 위해 | Vault 로그인 실패 또는 토큰 과노출 |
| 새 권한 추가 | 권한 범위를 얼마나 넓힐지 결정해야 한다 | policy 경로를 최소화 | 침해 시 피해 범위를 줄이기 위해 | 과권한 부여 |
| 새 네트워크 경로 추가 | 통신이 되게 하면서도 너무 열지 말아야 한다 | default deny 후 allowlist | 우연한 통신 의존성을 막기 위해 | 앱 기동 실패 또는 불필요한 개방 |
| base와 overlay 어디에 둘지 | 환경 독립성과 환경 특수성을 구분해야 한다 | 공통 구조는 base, dev host/path/tag는 overlay | prod/dev 드리프트를 줄이기 위해 | 다른 환경까지 의도치 않게 바뀜 |
| bootstrap과 reconcile 중 어디를 바꿀지 | 권한 수준과 실행 주체가 다르다 | 최초 생성은 bootstrap, 반복 동기화는 reconcile | CI 권한 최소화와 절차 분리를 위해 | routine workflow가 과도한 권한 요구 |
| Job와 Deployment 중 무엇을 쓸지 | 실행 수명과 재시작 특성이 다르다 | 일회성은 Job, 지속 서비스는 Deployment | 워크로드의 본질에 맞추기 위해 | 완료돼야 할 작업이 계속 재시작되거나, 계속 살아야 할 앱이 종료됨 |
| Deployment와 StatefulSet 중 무엇을 쓸지 | 저장소와 정체성 보장이 필요한지 다르다 | Postgres만 StatefulSet | 데이터 볼륨과 안정적 네트워크 식별자가 필요해서 | 데이터 유실, 스토리지 재연결 문제 |
## 20. 실전에서 가장 많이 하는 변경과 수정 위치
### 20-1. auth-server에 비민감 설정값 1개 추가
수정 순서:
1. `apps/auth-server/overlays/dev/configmap.yaml`에 값 추가
2. 앱이 그 값을 실제로 읽는지 확인
3. 필요하면 prod overlay에도 대응
이 경우 Vault, Terraform, policy 수정은 보통 필요 없습니다.
### 20-2. auth-server에 민감한 값 1개 추가
수정 순서:
1. `scripts/vault-transit/dev/populate-workload-seeds.sh`에 seed 입력 항목 추가
2. `terraform/vault/dev/main.tf` 또는 `terraform/vault/reconcile/main.tf`에서 provider -> workload 복사 로직 추가
3. `runbooks/vault/dev/policies/auth-server-dev.hcl`에 읽기 권한 추가
4. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 template에 `export` 추가
5. 앱 코드가 그 환경변수를 읽는지 확인
이 과정 중 하나라도 빠지면 Pod는 떠도 값이 비어 있거나, Vault 권한 오류가 납니다.
### 20-3. auth-server가 새 DB 권한을 요구함
확인 순서:
1. 단순 런타임 읽기인지
2. migration처럼 고권한 일회성 작업인지
3. 사람 운영용 접근인지
현재 저장소의 기준:
- 런타임 앱: KV 기반 고정 secret
- migration / operator: database engine 기반 동적 계정
- 사람 운영: 짧은 TTL 운영자 토큰
### 20-4. 외부 접근 host를 바꾸고 싶음
수정 후보:
- `apps/auth-server/overlays/dev/ingress.yaml`
- `apps/auth-server/overlays/dev/public-access.yaml`
- `apps/api-server/overlays/dev/ingress.yaml`
- `infra/platform/overlays/dev/keycloak-ingress.yaml`
- 관련 `ConfigMap`의 issuer/base URL
- 필요 시 Keycloak client sync 관련 값
host 변경은 단순 Ingress 한 파일 수정으로 끝나지 않는 경우가 많습니다.
## 21. 이 문서를 읽은 뒤 실제로 해봐야 할 연습 문제
아래 연습은 "이해했다"고 착각하지 않게 해줍니다.
1. `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`가 어디서 시작해서 어느 파일을 거쳐 auth-server Pod까지 들어오는지 경로를 종이에 적어보세요.
2. `auth-db-migration`이 왜 `Deployment`가 아니라 `Job`인지, 그리고 왜 `PreSync`인지 설명해보세요.
3. workload Vault가 재시작되었을 때 누가 어떻게 unseal을 돕는지 설명해보세요.
4. `api-server`가 왜 DB secret이 필요 없는지, 대신 어떤 서비스에 의존하는지 설명해보세요.
5. auth-server에 새 secret을 추가해야 한다고 가정하고, 수정해야 할 파일을 빠짐없이 나열해보세요.
이 다섯 개를 막힘 없이 말할 수 있으면, 이 저장소를 "눈으로 본 수준"이 아니라 "실제로 수정 가능한 수준"으로 이해한 것입니다.
## 22. 마지막으로: 이 저장소를 배울 때 절대 잊지 말아야 할 관점
이 저장소는 단순히 YAML 모음이 아닙니다. 각 파일은 아래 네 가지 중 하나의 책임을 갖습니다.
- 클러스터에 무엇을 띄울지 정하는 파일
- Vault 안에 어떤 권한과 비밀 경로를 만들지 정하는 파일
- 그 선언을 어떤 순서로 적용할지 정하는 파일
- 사람이 최초 1회 어떤 절차를 밟아야 하는지 정리한 파일
초보자가 가장 많이 하는 실수는 **한 파일만 보고 이해하려는 것**입니다.
이 저장소는 반드시 연결해서 봐야 합니다.
- 앱 manifest는 Argo CD와 연결해서
- Vault patch는 policy와 Terraform과 연결해서
- ConfigMap은 실제 의존 서비스와 연결해서
- workflow는 bootstrap/runbook과 연결해서
이 관점으로 보면, `apps/`의 YAML, `scripts/`의 Bash, `runbooks/`의 HCL, `terraform/`의 TF가 서로 따로 있는 것이 아니라 **한 배포 시스템의 서로 다른 층**이라는 것이 보이기 시작합니다.
---
# Part II: 심층 해부 — 왜 이 기술을 쓰고, 내부에서 무슨 일이 벌어지는가
Part I(1~22장)은 "이 저장소의 파일을 어떻게 읽고 수정하는가"에 집중했습니다.
Part II는 한 걸음 더 들어가서 **"왜 이런 기술을 선택했고, 그 기술이 내부에서 어떤 원리로 돌아가는가"**를 설명합니다.
이 파트를 읽고 나면 Part I에서 표면적으로만 이해했던 개념들이 입체적으로 연결되기 시작합니다.
## 23. 운영체제와 서버 인프라의 근간
### 23-1. Linux vs Windows: 왜 서버는 리눅스인가
#### 서버에 윈도우를 안 쓰는 진짜 이유
우리가 일상에서 쓰는 윈도우 PC를 떠올려 보세요. 부팅하면 바탕화면이 뜨고, 마우스 커서가 나타나고, 시작 메뉴가 보입니다. 이 모든 것이 실행 중인 **프로세스**입니다.
바탕화면을 렌더링하는 프로세스, 마우스 커서를 따라 그리는 프로세스, 알림 영역을 관리하는 프로세스 등이 부팅 직후 수십 개가 올라옵니다. 이것들이 소비하는 메모리만 해도 최소 2~4GB에 달합니다.
서버는 다릅니다. **모니터에 무언가를 보여줄 일이 없습니다.** 서버의 유일한 목적은 "네트워크로 들어온 요청을 처리하고 결과를 돌려주는 것"입니다. 그런데 이걸 하겠다고 바탕화면 렌더링에 몇 GB를 쓰는 것은 자원 낭비입니다.
리눅스 서버는 **텍스트 터미널만** 있습니다. GUI가 없으니 그래픽 프로세스가 전혀 올라오지 않습니다. 같은 하드웨어에서 운영체제가 차지하는 메모리가 수백 MB 수준이라, 나머지 리소스를 전부 실제 서비스(Java, Postgres, Vault 등)에 쓸 수 있습니다.
하지만 이것만이 리눅스를 서버로 쓰는 이유는 아닙니다. 더 근본적인 이유가 있습니다.
#### "모든 것이 파일이다" — 리눅스의 설계 철학
리눅스에는 **"Everything is a file"**이라는 설계 원칙이 있습니다.
이게 무슨 뜻인지 구체적으로 보겠습니다.
| 대상 | 윈도우에서는 | 리눅스에서는 |
|---|---|---|
| 하드디스크 | "디스크 관리" 프로그램으로 관리 | `/dev/sda` 라는 파일로 접근 |
| USB 장치 | 장치 관리자에서 확인 | `/dev/usb/...` 파일로 접근 |
| 네트워크 설정 | 제어판 → 네트워크 설정 GUI | `/proc/net/...` 파일을 읽으면 됨 |
| 실행 중인 프로세스 정보 | 작업 관리자 GUI | `/proc/[PID]/...` 파일을 읽으면 됨 |
| 시스템 로그 | 이벤트 뷰어 GUI | `/var/log/...` 텍스트 파일을 읽으면 됨 |
하드디스크든, 네트워크 포트든, 실행 중인 프로세스의 정보든, 리눅스에서는 **전부 파일처럼 읽고 쓸 수 있습니다.** 이 통일성이 왜 중요할까요?
Kubernetes는 컨테이너 안의 리소스 사용량을 모니터링하고, 네트워크를 설정하고, 볼륨을 마운트합니다. 이 모든 작업을 리눅스에서는 "파일을 읽고 쓰는 것"으로 통일해서 처리할 수 있습니다. 만약 하드디스크 접근은 A 방식, 네트워크 접근은 B 방식, 프로세스 접근은 C 방식이라면 Kubernetes 같은 오케스트레이터를 만드는 것 자체가 훨씬 어려워집니다.
#### 그렇다면 우리 윈도우 개발 PC에서 Docker는 어떻게 돌아가는가?
여기서 자연스러운 의문이 생깁니다. "서버는 리눅스인데, 내 개발 PC는 윈도우잖아? 그런데 Docker로 리눅스 컨테이너를 돌리고 있잖아? 이게 어떻게 가능하지?"
답은 **WSL2(Windows Subsystem for Linux 2)**입니다. WSL2는 단순한 에뮬레이터가 아닙니다. 마이크로소프트가 **진짜 리눅스 커널**을 윈도우 안에서 돌리는 구조를 만든 것입니다.
```mermaid
flowchart TB
subgraph 우리_PC["우리 개발 PC (Windows)"]
direction TB
WIN[Windows NT 커널<br/>바탕화면, 마우스, VS Code 등 실행]
subgraph HYPERV["Hyper-V 가상화 층"]
direction TB
LINUX[진짜 Linux 커널<br/>WSL2가 제공하는 경량 가상머신]
end
subgraph DOCKER["Docker Desktop"]
direction TB
ENGINE[Docker Engine 데몬<br/>Linux 커널 위에서 실행됨]
ENGINE --> C1[auth-server 컨테이너]
ENGINE --> C2[postgres 컨테이너]
ENGINE --> C3[vault 컨테이너]
end
end
WIN -->|"Hyper-V를 통해<br/>리눅스 커널 호스팅"| LINUX
LINUX -->|"커널 기능 제공<br/>(cgroups, namespaces)"| ENGINE
```
이 그림을 단계별로 설명하면 이렇습니다.
1. 우리 윈도우 PC의 `Windows NT 커널`이 먼저 부팅됩니다. 바탕화면, VS Code 등이 여기서 돕니다.
2. Windows에는 `Hyper-V`라는 가상화 기능이 내장되어 있습니다. 이것이 아주 가벼운 가상머신을 하나 만듭니다.
3. 그 가상머신 안에 **진짜 리눅스 커널**이 올라갑니다. 에뮬레이션이 아니라 실제 리눅스 커널 바이너리입니다.
4. Docker Desktop은 이 리눅스 커널 위에서 Docker Engine을 실행합니다.
5. 우리가 `docker run`으로 띄우는 Postgres, Vault, auth-server 컨테이너들은 모두 이 리눅스 커널의 기능(cgroups, namespaces)을 사용합니다.
그래서 우리 윈도우 PC에서 리눅스 컨테이너가 아무 문제 없이 돌아가는 것입니다. 컨테이너 입장에서는 자기가 리눅스 위에서 돌고 있다고 "느끼는" 것이고, 실제로도 그렇습니다.
#### 리눅스 계열의 차이: Ubuntu, CentOS, Arch는 뭐가 다른가
"리눅스"라고 하면 하나의 운영체제처럼 들리지만, 실제로는 **수십 가지 배포판(Distribution)**이 있습니다. 커널(심장)은 같지만, 그 위에 올라가는 패키지 관리 방식, 기본 설치 범위, 업데이트 정책이 다릅니다.
비유하자면, 같은 엔진을 쓰는 자동차라도 세단, SUV, 트럭이 다르듯이, 같은 리눅스 커널 위에 어떤 옷을 입혔느냐가 배포판의 차이입니다.
| 계열 | 대표 배포판 | 패키지 관리 도구 | 특징 | 서버 적합도 |
|---|---|---|---|---|
| Debian 계열 | **Ubuntu**, Debian | `apt` (deb 패키지) | 초보자 친화적, 커뮤니티 크고 자료 많음, 2년마다 LTS(장기 지원) 버전 출시 | ⭐⭐⭐⭐⭐ 가장 많이 사용 |
| RHEL 계열 | CentOS, Rocky Linux, AlmaLinux | `yum` / `dnf` (rpm 패키지) | 기업 환경에서 검증된 안정성, Red Hat의 상업 지원 가능, 보안 인증(FIPS 등) | ⭐⭐⭐⭐⭐ 엔터프라이즈 표준 |
| Arch 계열 | **Arch Linux**, Manjaro | `pacman` | 아무것도 기본 설치하지 않음, 사용자가 직접 모든 것을 구성, 롤링 릴리스(항상 최신) | ⭐⭐ 학습용에 가까움 |
| Alpine | Alpine Linux | `apk` | 극도로 가벼움(5MB급 베이스 이미지), 보안 중심 설계 | ⭐⭐⭐⭐ Docker 이미지 베이스로 인기 |
이 프로젝트의 K3s 노드는 **Ubuntu** 위에서 돌고 있습니다. Ubuntu를 선택한 이유는 K3s 공식 문서의 1순위 지원 대상이고, 자료가 가장 많고, `apt`로 패키지 설치가 간편하기 때문입니다.
PPT에서 언급한 **Arch Linux**는 "아무것도 안 해주니까 직접 다 하면서 원리를 배우기 좋다"는 학습 목적으로 시도한 것입니다. 실제 서버 운영에는 Ubuntu나 RHEL 계열을 쓰는 것이 일반적입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- 리눅스의 "모든 것이 파일" 원칙이 왜 컨테이너 기술(Docker, K8s)과 궁합이 좋은지, `/proc``/sys` 파일시스템의 역할을 조사해보세요.
- Alpine Linux가 Docker 이미지 베이스로 인기 있는 이유는 무엇일까요? 반대로, Alpine을 쓰면 생기는 단점(glibc vs musl 차이)은 무엇일까요?
- 우리 프로젝트의 `hashicorp/vault:1.18` 이미지는 어떤 리눅스 배포판을 베이스로 사용하고 있을까요? `docker inspect`로 확인해보세요.
- WSL2 없이 윈도우에서 리눅스 컨테이너를 돌릴 수 있는 다른 방법이 있을까요? (힌트: VirtualBox, VMware, Multipass)
### 23-2. 부팅과 커널의 비밀: Dual Boot와 GRUB 부트로더
#### 컴퓨터 전원을 누르면 실제로 무슨 일이 일어나는가
우리는 매일 전원 버튼을 누르지만, 전원 버튼을 누른 순간부터 로그인 화면이 뜰 때까지 컴퓨터 내부에서는 **5단계의 정밀한 체인**이 순서대로 실행됩니다.
이 체인 중 하나라도 실패하면 화면에 아무것도 안 뜹니다. 또는 에러 메시지만 나오고 멈춥니다.
```mermaid
flowchart TD
A["1단계: 전원 ON<br/>전기가 메인보드에 공급됨"] --> B["2단계: BIOS / UEFI 실행<br/>메인보드 칩에 박혀있는 초소형 프로그램<br/>하드웨어 점검(POST) 수행"]
B --> C["3단계: 부트로더 실행<br/>BIOS가 하드디스크의 약속된 위치에서<br/>부트로더 프로그램을 찾아 실행"]
C --> D{"4단계: OS 선택<br/>부트로더가 설치된 OS 목록을 보여줌<br/>(Dual Boot인 경우)"}
D -->|"Ubuntu 선택"| E["5a단계: Linux 커널 적재<br/>vmlinuz(커널 바이너리)를<br/>RAM에 통째로 올림"]
D -->|"Windows 선택"| F["5b단계: Windows Boot Manager<br/>Windows 커널을 RAM에 올림"]
E --> G["6단계: init / systemd 실행<br/>커널이 PID 1번 프로세스를 생성<br/>이것이 모든 프로세스의 조상"]
G --> H["7단계: 로그인 화면<br/>또는 텍스트 프롬프트"]
```
각 단계를 하나씩 풀어보겠습니다.
**1~2단계: BIOS / UEFI**
전원을 누르면 가장 먼저 실행되는 것은 윈도우도 리눅스도 아닙니다. **메인보드 칩에 박혀 있는 아주 작은 프로그램**이 먼저 뜹니다. 이것이 BIOS(오래된 방식) 또는 UEFI(최신 방식)입니다.
이 프로그램이 하는 일은 "하드웨어가 정상인지 확인"하는 것입니다. RAM이 있는지, 하드디스크가 연결되어 있는지, 그래픽카드가 있는지 등을 빠르게 체크합니다(이것을 POST, Power-On Self-Test라고 부릅니다).
**3단계: 부트로더**
하드웨어 점검이 끝나면 BIOS/UEFI는 "이제 운영체제를 찾아야 한다"고 판단합니다. 하지만 BIOS 자체는 운영체제를 이해하지 못합니다. 그래서 하드디스크의 **약속된 위치(EFI System Partition)**에서 **부트로더**라는 중간 프로그램을 찾아서 실행합니다.
부트로더는 쉽게 말하면 **"어떤 운영체제를 켤지 고르는 메뉴판 프로그램"**입니다.
**4단계: OS 선택 (Dual Boot)**
만약 하드디스크에 Ubuntu와 Windows가 둘 다 설치되어 있다면, 부트로더가 두 선택지를 보여줍니다. 사용자가 Ubuntu를 선택하면 리눅스 커널을 RAM에 올리고, Windows를 선택하면 Windows Boot Manager에게 제어를 넘깁니다.
**이것이 듀얼 부팅이 가능한 원리입니다.** 하드디스크에 두 OS가 나란히 깔려 있고, 부트로더가 "어느 쪽 커널을 메모리에 올릴지" 선택하는 것입니다.
**5~6단계: 커널 → PID 1**
리눅스 커널(`vmlinuz`)이 RAM에 올라오면, 커널은 시스템의 모든 하드웨어를 초기화하고, 그 다음 **딱 하나의 프로세스**를 생성합니다. 이것이 `systemd` (또는 구형 시스템에서는 `init`)이고, **PID 번호 1번**을 받습니다.
PID 1번은 **모든 프로세스의 조상**입니다. 이후에 뜨는 SSH 서버, 네트워크 데몬, Docker Engine, K3s kubelet 등은 전부 이 PID 1번의 자손입니다.
Part I의 5-2에서 "왜 `exec`를 써서 PID 1을 Java 프로세스로 교체하는가"를 설명했습니다. 이제 부팅 과정을 알았으니 그 의미가 더 선명해집니다. 컨테이너 안에서도 PID 1은 특별하고, 운영체제에서의 PID 1이 곧 컨테이너의 PID 1과 같은 개념입니다.
#### GRUB 부트로더와 다른 부트로더의 차이
현재 이 프로젝트의 개발 환경은 **GRUB(GRand Unified Bootloader)**을 사용하고 있습니다. GRUB은 리눅스에서 가장 널리 쓰이는 부트로더입니다. 하지만 세상에는 다른 부트로더도 있습니다.
| 부트로더 | 특징 | 커스터마이징 | Dual Boot 지원 | 적합한 상황 |
|---|---|---|---|---|
| **GRUB** | 가장 유명, 거의 모든 리눅스 배포판의 기본 부트로더 | 매우 유연(테마, 스크립트, 커널 파라미터 조정 가능) | ⭐⭐⭐⭐⭐ `os-prober`로 다른 OS 감지 | 일반 서버/데스크탑 대부분 |
| **systemd-boot** | systemd 프로젝트의 일부, 설정이 단순 | 설정 파일이 간결하지만 유연성은 낮음 | ⭐⭐⭐ 수동 설정 필요 | UEFI 전용, 단일 OS 서버 |
| **rEFInd** | GUI가 예쁘고, EFI 엔트리를 직접 스캔 | 아이콘/테마 커스터마이징 편리 | ⭐⭐⭐⭐⭐ EFI 파티션 직접 탐색 | 맥 + 리눅스 듀얼부팅 |
| **Syslinux/ISOLINUX** | 극도로 가벼움, 설치 미디어(USB/CD)용 | 제한적 | ⭐⭐ 단일 OS 전용 | 부팅 USB, 임베디드 |
#### GRUB은 어떻게 Windows를 "자동으로" 감지하는가
위 표에서 GRUB이 "다른 OS를 감지한다"고 적었는데, **이것은 마법이 아니라 구체적인 메커니즘**이 있습니다. 이것을 이해해야 "왜 가끔 GRUB이 감지를 못 하는 상황이 생기는지"까지 알 수 있습니다.
Ubuntu에서 `sudo update-grub` 명령을 실행하면 내부적으로 이런 일이 벌어집니다.
```mermaid
flowchart TD
A["사용자가 sudo update-grub 실행"] --> B["grub-mkconfig 프로그램 시작<br/>GRUB 메뉴 설정 파일을 자동 생성하는 도구"]
B --> C["1단계: /boot 디렉터리 스캔<br/>리눅스 커널 파일(vmlinuz)을 찾음"]
B --> D["2단계: os-prober 실행<br/>다른 OS가 있는지 디스크를 뒤짐"]
D --> E["os-prober가 하는 일:<br/>① 모든 디스크 파티션을 하나씩 마운트<br/>② 각 파티션에서 OS 흔적을 찾음"]
E --> F{"Windows 흔적 발견?<br/>예: /EFI/Microsoft/Boot/bootmgfw.efi<br/>또는 NTFS 파티션의 bootmgr"}
F -->|"흔적 발견"| G["GRUB 메뉴에 'Windows Boot Manager' 항목 추가"]
F -->|"흔적 없음"| H["GRUB 메뉴에 리눅스만 표시"]
G --> I["결과: /boot/grub/grub.cfg 파일 생성<br/>이 파일이 부팅 시 메뉴로 표시됨"]
H --> I
```
핵심은 **`os-prober`**라는 프로그램입니다. 이 프로그램이 하는 일을 쉽게 설명하면 이렇습니다.
1. 컴퓨터에 연결된 **모든 디스크의 모든 파티션**을 하나씩 열어봅니다.
2. 각 파티션을 임시로 마운트해놓고, 그 안에 **운영체제의 흔적**이 있는지 찾습니다.
- Windows의 흔적: EFI 파티션 안의 `/EFI/Microsoft/Boot/bootmgfw.efi` 파일, 또는 NTFS 파티션의 `bootmgr` 파일
- 다른 리눅스의 흔적: `/boot/vmlinuz` 커널 파일
- macOS의 흔적: HFS+ 파티션의 특정 구조
3. 흔적을 찾으면 "이 파티션에 이런 OS가 있다"고 보고합니다.
4. `grub-mkconfig`가 이 보고를 받아서 GRUB 메뉴 설정 파일(`/boot/grub/grub.cfg`)에 해당 OS 항목을 추가합니다.
그래서 GRUB의 "자동 감지"란 사실 **디스크의 모든 파티션을 뒤져서 알려진 OS의 파일 패턴을 찾는 것**입니다. 이것을 알면 아래 상황이 왜 생기는지도 이해됩니다.
- **Windows를 나중에 깔면 GRUB이 사라지는 이유**: Windows 설치 프로그램이 EFI System Partition의 기본 부트 엔트리를 자기 것(Windows Boot Manager)으로 **덮어씁니다**. GRUB 파일이 지워지는 것은 아니지만, UEFI가 "기본으로 실행할 부트로더"를 Windows 것으로 바꿔버리니까 GRUB 메뉴가 안 뜨는 것입니다. 이 경우 Ubuntu 설치 USB로 부팅한 뒤 `sudo grub-install`로 GRUB을 기본 부트 엔트리로 다시 등록하면 복구됩니다.
- **`os-prober`가 비활성화된 최신 Ubuntu에서 Dual Boot 메뉴가 안 뜨는 이유**: Ubuntu 21.10부터 보안상의 이유로 `os-prober`가 기본 비활성화되었습니다. `/etc/default/grub``GRUB_DISABLE_OS_PROBER=false`를 추가하고 `sudo update-grub`을 다시 실행해야 Windows가 메뉴에 나타납니다.
반면 **rEFInd**는 `os-prober` 같은 별도 프로그램 없이, **EFI System Partition 안의 `.efi` 파일들을 직접 스캔**해서 부팅 가능한 OS 목록을 만듭니다. EFI 표준 자체가 "이 디렉터리 구조에 부팅 파일을 놓아라"는 규칙을 정해놨기 때문에, 그 규칙대로 있는 파일들만 찾으면 되는 것입니다.
**systemd-boot**는 이런 자동 탐색을 아예 안 합니다. 관리자가 설정 파일에 "Windows는 이 파티션에 있고, 이 loader를 써라"고 직접 적어줘야 합니다. 그래서 Dual Boot 지원이 약한 것입니다.
GRUB이 압도적으로 많이 쓰이는 이유는 이런 **범용성** 때문입니다. 디스크를 스캔해서 OS를 알아서 찾아주고, 커널 부팅 파라미터를 세밀하게 조정할 수 있고, 복구 모드 진입도 GRUB 메뉴에서 직접 가능합니다.
서버 운영에서 커널 파라미터 튜닝은 특히 중요합니다. 예를 들어 K8s 노드에서는 `net.bridge.bridge-nf-call-iptables=1` 같은 커널 파라미터를 켜야 CNI 네트워크가 정상 동작합니다. GRUB은 부팅 시 이 파라미터를 커널에 전달하는 역할도 합니다(`/etc/default/grub``GRUB_CMDLINE_LINUX` 항목).
#### 이 부팅 지식이 K8s 운영에서 왜 중요한가
"부팅 과정까지 알아야 하나?" 싶을 수 있습니다. 하지만 서버를 운영하다 보면 이런 상황이 생깁니다.
- K8s 워커 노드가 갑자기 응답을 멈춤 → **커널 패닉**인지, **systemd 데몬 장애**인지, **kubelet 프로세스 죽음**인지 구분해야 합니다.
- CNI 플러그인이 동작하지 않음 → 원인이 **커널 파라미터**(net.bridge 설정)인지, CNI 바이너리(Flannel/Calico)인지 진단해야 합니다.
- 보안 업데이트 후 재부팅했는데 안 켜짐 → **GRUB 설정이 깨졌는지**, 새 커널에 문제가 있는지 확인해야 합니다.
이 모든 상황에서 "전원 → BIOS → GRUB → 커널 → systemd → kubelet → containerd"라는 체인을 머릿속에 그릴 수 있어야 어느 층에서 문제가 생겼는지 빠르게 좁혀갈 수 있습니다.
```mermaid
flowchart LR
A[전원] --> B[BIOS/UEFI] --> C[GRUB] --> D[Linux 커널] --> E[systemd<br/>PID 1] --> F[kubelet<br/>K8s 워커 에이전트] --> G[containerd<br/>컨테이너 런타임] --> H[Pod 안의<br/>Java/Vault/Postgres]
```
위 체인에서 왼쪽으로 갈수록 "사무실 건물의 전기"에 가깝고, 오른쪽으로 갈수록 "사무실 안의 사원"에 가깝습니다. 장애 진단은 항상 "어느 층에서 끊겼는가"를 찾는 것입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- BIOS와 UEFI의 차이는 무엇인가요? 왜 최신 컴퓨터는 UEFI를 쓰는 걸까요? (힌트: 2TB 이상 디스크 지원, 보안 부팅)
- Dual Boot 환경에서 Ubuntu를 먼저 설치하고 Windows를 나중에 깔면 GRUB이 깨졌다는 말을 자주 듣습니다. 왜 그런 걸까요? (힌트: Windows가 EFI Partition의 부트로더를 자기 것으로 덮어쓰기)
- `systemd`가 PID 1인 이유와, 컨테이너 안에서 PID 1이 `sh`(셸)이면 왜 시그널 처리에 문제가 생기는지 조사해보세요. (Part I 5-2의 `exec` 사용 이유와 연결됩니다)
- 커널 파라미터 `net.bridge.bridge-nf-call-iptables=1`이 왜 K8s CNI에 필수인지 공식 문서에서 찾아보세요.
- K8s 노드가 재부팅될 때, 그 노드 위에 있던 Pod들은 어떻게 되나요? 다른 노드로 옮겨지나요, 아니면 그냥 사라지나요?
## 24. Spring Security, OAuth2, Keycloak 심층 해부
### 24-1. Spring Security 무용론? — 클린 아키텍처와의 조화
#### 일반적인 Spring Security 로그인은 어떻게 동작하는가
Spring Security를 처음 배우면 이런 흐름을 따릅니다.
```mermaid
sequenceDiagram
participant User as 사용자 브라우저
participant Filter as Spring Security Filter Chain<br/>(웹 앞단, 인프라 계층)
participant DAO as DaoAuthenticationProvider
participant UDS as UserDetailsService
participant DB as 데이터베이스
User->>Filter: 로그인 요청 (이메일 + 비밀번호)
Filter->>DAO: 인증 시도
DAO->>UDS: "이 이메일의 사용자 정보 가져와"
UDS->>DB: SELECT * FROM users WHERE email = ?
DB-->>UDS: 사용자 정보 (해시된 비밀번호 포함)
UDS-->>DAO: UserDetails 객체 반환
DAO->>DAO: 비밀번호 비교 (BCrypt)
DAO-->>Filter: 인증 성공 → Authentication 객체 생성
Filter-->>User: 로그인 성공, 세션 또는 토큰 발급
```
이 흐름에서 중요한 것은 **Security Filter 안에서 DB 조회가 일어난다는 점**입니다. `UserDetailsService`가 DB에서 사용자를 찾고, `DaoAuthenticationProvider`가 비밀번호를 검증합니다. 이 모든 것이 웹 필터 체인, 즉 **인프라 계층** 안에서 발생합니다.
작은 프로젝트에서는 이게 간편합니다. Spring이 다 해주니까요. 하지만 이 구조에는 **아키텍처적 문제**가 있습니다.
#### 왜 우리 프로젝트는 이 방식을 안 쓰는가 — 클린 아키텍처 위반
클린 아키텍처(Clean Architecture)의 핵심 규칙은 하나입니다.
> **의존성은 무조건 바깥에서 안쪽으로만 향해야 한다.**
이것을 원으로 그리면 이렇습니다.
```mermaid
flowchart TB
subgraph 가장_바깥["가장 바깥: 인프라/프레젠테이션"]
direction TB
A["Spring Security Filter<br/>Spring MVC Controller<br/>JPA Repository<br/>DB 드라이버"]
end
subgraph 중간["중간: Application / UseCase"]
direction TB
B["로그인 UseCase<br/>회원가입 UseCase<br/>토큰 발급 UseCase"]
end
subgraph 가장_안쪽["가장 안쪽: Domain"]
direction TB
C["User 엔티티<br/>비즈니스 규칙<br/>순수 Java 코드"]
end
A -->|"의존 가능 ✅"| B
B -->|"의존 가능 ✅"| C
C -.-x|"의존 불가 ❌<br/>Domain은 Security를<br/>몰라야 함"| A
```
**바깥 원(Security Filter, Controller, DB)**은 안쪽 원(UseCase, Domain)을 호출할 수 있습니다. 하지만 안쪽 원은 바깥 원의 존재를 **전혀 몰라야** 합니다.
그런데 기본 Spring Security 흐름에서는 **Filter(바깥 원) 안에서 DB 조회(비즈니스 로직)가 직접 실행**됩니다. 이것은 바깥 원이 안쪽 원을 관통해서 직접 DB를 건드리는 것이라, 클린 아키텍처 원칙에 **위배**됩니다.
Spring 공식 문서도 이 점을 인정합니다. 공식 Web Integration 문서에서는 "웹 계층은 여러 계층 중 하나일 뿐이며, 서비스 계층에 정의된 서비스 객체에 비즈니스 관련 사용 사례를 처리하도록 위임하라"고 권고합니다.
#### 우리 프로젝트는 어떻게 했는가
이 프로젝트의 `auth-server`에서는 Security의 역할을 **문지기로 축소**했습니다.
```mermaid
flowchart LR
subgraph Security_역할["Spring Security가 하는 일 (문지기)"]
direction TB
S1["CORS 설정<br/>다른 출처의 요청 차단/허용"]
S2["JWT 서명 검증<br/>토큰이 위조되었는지 확인"]
S3["RBAC 권한 체크<br/>일반 유저인지, 관리자인지<br/>API 접근 허용/차단"]
end
subgraph Service_역할["비즈니스 로직이 하는 일 (UseCase)"]
direction TB
U1["로그인 검증<br/>DB에서 사용자 조회"]
U2["회원 가입 처리<br/>DB에 사용자 저장"]
U3["토큰 발급<br/>Vault Transit으로 서명"]
end
Security_역할 -->|"Argument Resolver로<br/>토큰 정보만 넘겨줌"| Service_역할
```
구체적으로:
| 역할 | 일반적인 Spring Security | 이 프로젝트 |
|---|---|---|
| 비밀번호 검증 | Filter 안의 `DaoAuthenticationProvider`가 직접 수행 | UseCase(Service 계층)에서 수행 |
| DB 조회 | `UserDetailsService`가 Filter 안에서 DB 접근 | Controller가 Argument Resolver로 토큰 정보만 파싱한 뒤, **Service 계층에서** DB 접근 |
| 비밀번호 암호화 | Security의 `BCryptPasswordEncoder` 직접 사용 | 도메인이 "비밀번호 검증해줘"라고 **포트(인터페이스)**를 통해 요청 → 바깥의 Security가 BCrypt로 처리 |
| OAuth2 로그인 | Security의 OAuth2LoginFilter가 session에 유저 정보를 밀어넣음 | 토큰 교환까지만 Security가 처리 → 이후 Argument Resolver → Service 계층에서 DB 조회 및 가입 처리 |
이렇게 하면 **도메인 코드는 Spring Security의 존재를 전혀 모릅니다.** 내일 갑자기 Security를 걷어내고 다른 보안 프레임워크로 바꿔도, 도메인과 UseCase 코드는 수정할 필요가 없습니다.
#### ArchUnit: 이 규칙을 어떻게 강제하는가
사람이 코드를 짜다 보면 실수로 규칙을 어길 수 있습니다. "급하니까 Domain에서 직접 Security 클래스를 import하자"라고 하는 순간 아키텍처가 무너집니다.
이 프로젝트에서는 **ArchUnit**이라는 라이브러리로 이 규칙을 **테스트 코드로 강제**합니다.
ArchUnit은 JUnit 테스트처럼 동작합니다. 개발자가 코드를 커밋하고 CI가 돌 때, ArchUnit 테스트가 함께 실행됩니다. 만약 `domain` 패키지의 클래스가 `infrastructure` 패키지의 클래스를 import하고 있으면, **테스트가 실패**합니다.
즉, ArchUnit은 런타임에 동작하는 것이 아니라 **테스트 시점(CI 빌드 시점)**에 동작합니다. 실제 서비스가 돌고 있을 때 성능에 영향을 주는 것이 아니라, 코드를 올릴 때 "이 코드는 아키텍처 규칙을 어겼으므로 빌드 실패"라고 알려주는 **정적 분석 파수꾼**입니다.
이 프로젝트의 ConfigMap인 `apps/auth-server/overlays/dev/configmap.yaml`을 보면 Security가 남긴 흔적을 확인할 수 있습니다.
```yaml
# Vault Transit으로 JWT를 다룬다는 설정 — Security는 이 주소를 알고 서명을 요청할 뿐
APP_SECURITY_JWT_VAULT_ENABLED: "true"
APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200
APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt
# Keycloak과의 OIDC 연동 — Security는 토큰 교환까지만 담당
APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth
APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server
```
여기서 `APP_SECURITY_JWT_VAULT_ENABLED: "true"`는 "JWT 서명을 로컬에서 하지 않고 Vault Transit에 위임하겠다"는 뜻입니다. Security가 서명의 **실행자**가 아니라 서명을 **중계하는 문지기** 역할만 한다는 것을 설정값으로도 확인할 수 있습니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- ArchUnit이 검사하는 시점은 언제인가? 런타임인가, CI 빌드 시점인가? (답: 테스트 시점, 즉 JUnit이 돌 때)
- Keycloak이 완전히 다운되면 기존에 발급된 JWT 토큰은 여전히 유효한가? (힌트: JWT는 자기 완결적 — 서명 검증에 외부 호출이 필요 없다면 유효하다)
- `BcryptPasswordEncoder`를 도메인 코드에서 직접 쓰지 않고 포트(인터페이스)로 뺀 이유는 무엇인가? 포트를 안 쓰고 직접 BCrypt를 호출하면 어떤 문제가 생기는가?
- 이 프로젝트에서 `APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false"`로 설정한 이유는 무엇인가? (힌트: 키 생성도 Vault Transit에 맡겼기 때문)
- MVC 패턴과 클린 아키텍처의 가장 큰 차이는 무엇인가? MVC에서 Controller가 직접 DB를 호출하는 것이 왜 큰 프로젝트에서는 문제가 되는가?
### 24-2. 현업의 OAuth2: Keycloak과 OIDC 내부 동작
#### OAuth2의 핵심 아이디어: "비밀번호를 우리가 받지 않는다"
사용자가 구글 로그인을 누르는 상황을 생각해보세요. 우리 서비스가 사용자의 구글 비밀번호를 직접 받아서 구글에 대신 로그인해주는 것은 **매우 위험**합니다. 사용자 입장에서도 "내 구글 비밀번호를 니네 서비스에 왜 줘야 하지?" 싶습니다.
OAuth2는 이 문제를 해결합니다. **비밀번호를 우리한테 주는 게 아니라, 구글 로그인 페이지에서 직접 로그인하고, 구글이 "이 사람은 진짜야"라는 증거(토큰)만 우리한테 건네주는 구조**입니다.
이 흐름을 **인가 코드 부여 방식(Authorization Code Grant)**이라고 부릅니다.
```mermaid
sequenceDiagram
participant User as 사용자 브라우저
participant Spring as auth-server<br/>(Spring Security)
participant Google as 구글 로그인 서버
User->>Spring: "구글로 로그인할래요"
Spring->>User: 구글 로그인 페이지로 리다이렉트<br/>(우리 서버가 비밀번호를 받지 않음!)
User->>Google: 구글 로그인 페이지에서 직접 로그인
Google->>User: 로그인 성공! 인가 코드를 들고<br/>우리 서버의 콜백 URL로 돌아가거라
User->>Spring: 인가 코드(임시 교환권)를 들고 콜백 URL로 돌아옴
Note over Spring,Google: 여기서부터는 서버 대 서버 통신 (사용자 브라우저를 거치지 않음)
Spring->>Google: "이 인가 코드, 진짜 너네가 준 거 맞지?<br/>Access Token으로 바꿔줘"
Google-->>Spring: Access Token + ID Token 반환
Spring->>Spring: ID Token에서 사용자 이메일, 이름 등 추출
Spring-->>User: 우리 서비스의 JWT 토큰 발급
```
핵심 포인트는 **인가 코드(Authorization Code)**입니다. 이것은 "일회용 교환권"같은 것입니다.
1. 구글이 사용자에게 인가 코드를 줍니다.
2. 사용자가 그 코드를 우리 서버에 전달합니다.
3. 우리 서버가 구글에 "이 코드 진짜야? 토큰으로 바꿔줘"라고 서버 대 서버 통신을 합니다.
왜 처음부터 토큰을 안 주고 코드를 먼저 줄까요? 코드는 **사용자의 브라우저를 경유**합니다. 브라우저는 해킹당할 수 있습니다. 그래서 코드는 일회용이고 짧은 시간만 유효합니다. 실제 토큰 교환은 **서버 대 서버 통신**으로 이루어지니 브라우저가 탈취당해도 토큰은 안전합니다.
#### Keycloak이 없으면 어떤 끔찍한 일이 생기는가
위 흐름을 구글, 카카오, 네이버, GitHub 각각에 대해 구현한다고 생각해보세요.
```mermaid
flowchart TB
subgraph 문제["Keycloak 없이 직접 구현한 경우"]
direction TB
APP[auth-server]
APP --> G[구글 API<br/>응답 형식: A]
APP --> K[카카오 API<br/>응답 형식: B]
APP --> N[네이버 API<br/>응답 형식: C]
APP --> GH[GitHub API<br/>응답 형식: D]
end
```
각 소셜 로그인 제공자마다:
- 콜백 URL 형식이 다릅니다.
- 사용자 정보 응답 형식(JSON 구조)이 다릅니다. 구글은 `email` 필드, 카카오는 `kakao_account.email` 필드입니다.
- 토큰 갱신 방식이 다릅니다.
제공자가 4개면 4가지 파싱 코드를 짜야 합니다. 10개가 되면 10가지입니다. 코드가 폭발합니다.
#### Keycloak은 이 문제를 어떻게 해결하는가
**Keycloak**은 **사설 통합 인증 센터(Identity Provider Broker)**입니다. 쉽게 말하면 "외부 로그인 제공자와의 복잡한 대화를 대신 해주고, 우리한테는 항상 같은 형식으로 결과를 알려주는 중간 통역사"입니다.
```mermaid
flowchart TB
subgraph 해결["Keycloak을 둔 경우"]
direction TB
APP2[auth-server<br/>Keycloak 하나만 상대하면 됨]
KC[Keycloak<br/>통합 인증 센터]
APP2 -->|"항상 OIDC 표준 형식<br/>하나의 콜백, 하나의 응답 규격"| KC
KC --> G2[구글]
KC --> K2[카카오]
KC --> N2[네이버]
KC --> GH2[GitHub]
end
```
이 구조에서 `auth-server`의 Spring Security가 하는 일은 극적으로 줄어듭니다.
1. 사용자가 "구글로 로그인" 클릭 → Security가 사용자를 **Keycloak**으로 리다이렉트 (구글로 직접이 아님!)
2. Keycloak이 구글과 알아서 통신 → 구글의 응답을 **OIDC 표준 형식(JSON)**으로 변환
3. Keycloak이 auth-server에 콜백 → Security가 OIDC 표준 토큰을 수신
4. 여기까지가 Security의 역할 끝 → 이후 Argument Resolver가 토큰에서 사용자 정보 추출 → Service 계층에서 DB 조회
**OIDC(OpenID Connect)**란 OAuth2 위에 "사용자 신원 확인" 규격을 얹은 표준입니다. OAuth2만으로는 "이 사람이 어떤 리소스에 접근 가능하다"만 알 수 있지만, OIDC를 쓰면 "이 사람의 이메일은 뭐고 이름은 뭐다"까지 표준화된 형식(ID Token)으로 받을 수 있습니다.
#### 이 구조가 실제 코드에 어떻게 반영되어 있는가
이 저장소의 실제 파일들을 보면 위 아키텍처가 선언으로 드러납니다.
**1. Keycloak이 auth-server의 인증 상대방이라는 것을 알려주는 설정**
`apps/auth-server/overlays/dev/configmap.yaml`:
```yaml
# auth-server가 Keycloak을 통해 OIDC 인증을 받겠다는 선언
APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth
APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server
# 구글/GitHub 로그인도 Keycloak을 경유한다는 뜻
# registration ID가 "keycloak-google", "keycloak-github"다
APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google
APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github
```
`APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google`이 중요합니다. 이름이 **"keycloak-google"**인 것은 **구글 로그인이지만 Keycloak을 거쳐서 간다**는 뜻입니다. auth-server는 구글 API를 직접 호출하지 않고, Keycloak에게 "구글 쪽으로 브로커링 해줘"라고 힌트(`idp_hint: google`)만 보냅니다.
**2. Keycloak의 client 설정을 코드로 맞추는 Job**
`infra/platform/base/keycloak-client-sync-job.yaml`을 보면, 이 Job이 Keycloak Admin API를 호출해서:
- `project-auth-server` 클라이언트의 `secret`을 업데이트하고
- `baseUrl`을 맞추고
- `redirectUris`를 코드 기반으로 등록합니다
```yaml
# keycloak-client-sync-job.yaml 중 핵심 부분
/opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \
-r project-auth \
-s "secret=$KEYCLOAK_CLIENT_SECRET" \
-s "baseUrl=$AUTH_SERVER_BASE_URL" \
-s 'redirectUris=[".../login/oauth2/code/keycloak-google",".../login/oauth2/code/keycloak-github"]'
```
사람이 Keycloak 관리 콘솔에서 마우스로 클릭하는 것이 아니라, **K8s Job이 코드로 자동 설정**합니다. 이것이 GitOps 관점에서 중요합니다. 설정이 코드에 있으니 변경 추적이 되고, 재현 가능합니다.
**3. OAuth2 client secret은 Vault에서 온다**
Keycloak과 auth-server가 통신하려면 **client secret**(일종의 비밀 비밀번호)이 필요합니다. 이 값은 `deployment.vault-patch.yaml`에서 Vault를 통해 주입됩니다.
```yaml
# deployment.vault-patch.yaml의 일부
{{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }}
export APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET }}
{{ end }}
```
즉, client secret은 Git에 없고, Vault에만 있고, Pod가 뜰 때 파일로 주입됩니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- OAuth2의 "인가 코드"를 사용자 브라우저가 중간에 가로챌 수 있는가? 가로채도 왜 안전한가? (힌트: 코드를 토큰으로 교환할 때 client secret이 필요하고, 이것은 서버만 알고 있다)
- OIDC의 ID Token과 OAuth2의 Access Token은 어떤 차이가 있는가? 둘 다 JWT인가?
- Keycloak이 완전히 다운되면 어떤 일이 생기는가? 이미 로그인한 사용자는 괜찮은가? 새로 로그인하려는 사용자는?
- `redirectUris`가 잘못 설정되면 어떤 보안 문제가 생기는가? (힌트: Open Redirect 공격)
- `keycloak-client-sync-job.yaml`에서 `until ... do sleep 5; done` 루프가 있는 이유는 무엇인가? (힌트: Keycloak이 아직 기동 중일 수 있다)
### 24-3. DB 버전 관리(Flyway) 분리 배포 작전
#### 왜 DB 스키마 변경은 특별하게 다뤄야 하는가
일반적인 코드 변경(버그 수정, 기능 추가)은 서버를 새로 배포하면 반영됩니다. 이전 코드가 새 코드로 바뀌는 것뿐이니, 잘못되면 이전 버전으로 롤백하면 됩니다.
하지만 **DB 스키마 변경**은 다릅니다. 새 컬럼을 추가하거나, 테이블 이름을 바꾸거나, 인덱스를 거는 것은 **데이터 구조 자체를 바꾸는 것**입니다. 한번 바꾸면 되돌리기가 훨씬 어렵습니다. 새 컬럼에 데이터가 이미 쌓였다면 단순 롤백으로는 원상복구가 안 됩니다.
**Flyway**는 DB 스키마 변경을 **버전 관리**하는 도구입니다. 코드 변경을 Git으로 관리하듯이, DB 변경을 `V1__create_user.sql`, `V2__add_email_column.sql` 같은 번호 붙은 마이그레이션 파일로 관리합니다.
#### 기존 방식의 문제: Spring Boot 기동 시 Flyway 자동 실행
처음에는 Flyway를 Spring Boot 앱 안에 넣었습니다. 서버가 기동될 때 Flyway가 자동으로 실행되어 DB 스키마를 최신으로 맞추는 방식입니다.
작은 서비스에서는 이것도 괜찮습니다. 하지만 **트래픽이 몰려서 서버를 10대로 늘려야 하는 상황**을 생각해보세요.
```mermaid
flowchart TB
subgraph 문제_상황["서버 10대가 동시에 기동되는 상황"]
direction TB
S1[auth-server Pod 1] -->|"Flyway: ALTER TABLE users..."| DB[(PostgreSQL)]
S2[auth-server Pod 2] -->|"Flyway: ALTER TABLE users..."| DB
S3[auth-server Pod 3] -->|"Flyway: ALTER TABLE users..."| DB
S4["... Pod 4~10도 동시에"] -->|"Flyway: ALTER TABLE users..."| DB
end
DB -->|"💥 Lock 경합!<br/>누가 먼저야?<br/>DDL Lock 충돌!"| DEAD[배포 데드락<br/>일부 Pod는 마이그레이션 성공<br/>일부 Pod는 Lock 대기 중 타임아웃]
```
Flyway는 내부적으로 DB Lock을 사용해서 중복 실행을 방지하려고 합니다. 하지만 10대의 서버가 **동시에** 일어나면서 모두 "나 먼저 마이그레이션 할게!"라고 달려들면, Lock 경합이 발생합니다. 일부는 성공하고 일부는 타임아웃으로 실패합니다. 실패한 Pod는 기동에 실패합니다.
#### 이 프로젝트의 해결책: 완전 분리된 K8s Job
이 프로젝트에서는 Flyway를 **앱 서버에서 완전히 떼어내서 별도의 K8s Job으로 분리**했습니다.
```mermaid
flowchart TD
subgraph 해결_구조["이 프로젝트의 배포 흐름"]
direction TB
subgraph PreSync["1단계: PreSync (앱 배포 전)"]
JOB[auth-db-migration Job<br/>Flyway 실행<br/>딱 1개만 실행됨] -->|"스키마 변경 완료"| DB2[(PostgreSQL)]
end
subgraph MainSync["2단계: Main Sync (스키마 준비 완료 후)"]
S1b[auth-server Pod 1<br/>Flyway 안 함] --> DB2
S2b[auth-server Pod 2<br/>Flyway 안 함] --> DB2
S3b[auth-server Pod 3<br/>Flyway 안 함] --> DB2
end
PreSync -->|"Job 성공해야<br/>다음 단계 진행"| MainSync
end
```
이 구조의 핵심은 Argo CD의 **PreSync Hook**입니다.
`apps/auth-server/base/db-migration-job.yaml`을 보면:
```yaml
annotations:
argocd.argoproj.io/hook: PreSync # 일반 리소스보다 먼저 실행
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded # 성공하면 정리
argocd.argoproj.io/sync-wave: "-1" # 가장 먼저
```
그리고:
```yaml
spec:
backoffLimit: 1 # 실패하면 1번만 재시도
template:
spec:
restartPolicy: Never # 완료 후 재시작하지 않음
```
이 어노테이션과 설정을 합치면 이런 동작이 됩니다.
1. Argo CD가 sync를 시작하면 **PreSync 리소스를 먼저** 실행합니다.
2. migration Job이 **딱 1개** 뜹니다. (Deployment가 아니라 Job이니까 복제본 없음)
3. Flyway가 DB 스키마를 변경합니다.
4. Job이 성공하면(exit code 0) Argo CD가 다음 단계(Deployment 등)를 진행합니다.
5. Job이 실패하면 **배포 전체가 멈춥니다.** 스키마가 반쪽만 적용된 상태로 앱이 뜨는 것을 방지합니다.
auth-server의 ConfigMap에서 `APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false"`인 이유도 이것 때문입니다. 앱 서버 자체는 Flyway를 실행하지 않습니다. 마이그레이션은 오직 Job만 합니다.
#### 분리에 따른 의존성 문제와 해결
PPT에서 언급했듯이, 처음에 Flyway를 분리하려 했을 때 문제가 있었습니다. Flyway가 Spring Boot 앱 안에 있다 보니, 관련 설정 Bean들이 앱 기동에 필요했습니다. 단순히 Flyway 실행만 떼어내는 것으로는 의존성이 끊기지 않았습니다.
최종 해결책은 **Flyway를 아예 별도의 라이브러리 모듈로 분리**하고, Docker 이미지 빌드 시 `migration.jar`라는 독립적인 JAR 파일을 만든 것입니다.
`db-migration-job.yaml`에서 이것이 드러납니다:
```yaml
containers:
- name: auth-db-migration
image: ghcr.io/donghyeonka/project-auth-server # 같은 이미지지만
command:
- java
- -jar
- /app/migration.jar # migration.jar를 따로 실행
env:
- name: SPRING_MAIN_WEB_APPLICATION_TYPE
value: none # 웹 서버를 띄우지 않음
- name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP
value: "true" # 이 Job에서만 마이그레이션 실행
```
`SPRING_MAIN_WEB_APPLICATION_TYPE: none`은 "Spring의 웹 서버(Tomcat 등)를 켜지 마라"는 뜻입니다. migration에는 HTTP 서버가 필요 없으니까요. 이렇게 하면 순수하게 Flyway만 돌리고 종료됩니다.
#### 동적 DB 계정: migration Job은 왜 고정 비밀번호를 안 쓰는가
dev overlay의 `db-migration-job.vault-patch.yaml`을 보면, migration Job은 고정 비밀번호가 아니라 **Vault가 그 순간에 만들어주는 임시 DB 계정**을 사용합니다.
```yaml
vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev
```
경로가 `kv/...`(고정 값 저장소)가 아니라 `database/creds/...`(동적 발급 엔진)인 것이 핵심입니다.
이 경로로 요청하면 Vault가 **그 순간에** PostgreSQL에 접속해서 임시 사용자를 만들고, 짧은 TTL(수명)이 지나면 자동으로 삭제합니다.
왜 이렇게 할까요?
- migration은 **고권한 작업**입니다. 테이블 생성, 컬럼 변경 같은 DDL을 수행합니다.
- 이런 강력한 권한을 가진 계정이 **영구적으로 존재**하면, 유출 시 피해가 큽니다.
- 동적 계정은 Job 실행 후 자동 만료되니, 유출되어봐야 이미 삭제된 계정입니다.
그리고 `agent-pre-populate-only: "true"` 어노테이션의 의미도 여기서 명확해집니다. Deployment에 붙는 Vault Agent는 sidecar로 계속 살아있으면서 secret을 갱신할 수 있지만, Job은 **짧게 실행되고 끝나는 일회성 작업**이니 사이드카가 계속 떠 있을 필요가 없습니다. 한 번 secret 파일을 만들어두고 바로 사라지는 것입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- Flyway 마이그레이션 Job이 중간에 실패해서 테이블은 만들었는데 인덱스는 못 만든 상태라면, 다음 Job 실행 시 어떻게 되는가? Flyway는 이것을 어떻게 감지하는가? (힌트: `flyway_schema_history` 테이블)
- DDL(테이블 구조 변경)과 DML(데이터 변경) 마이그레이션의 위험도 차이는 무엇인가? 왜 DDL 마이그레이션이 더 위험한가?
- `backoffLimit: 1`의 의미는 무엇인가? 왜 migration Job은 횟수를 제한하는가?
- 만약 Flyway가 성공했는데 앱 배포가 실패해서 롤백한다면, DB 스키마는 이전으로 돌아가는가? (답: 아니다. DB 스키마 롤백은 별도 마이그레이션이 필요하다)
- `database/creds/auth-db-migration-dev``kv/data/dev/platform/postgres/auth-server`의 차이는 무엇인가? 왜 runtime 앱은 KV를 쓰고 migration Job은 database engine을 쓰는가?
## 25. Kubernetes(K3s) 심층 해부와 네트워크 구조
### 25-1. K8s vs K3s: 왜 가볍고 왜 선택했는가
#### Kubernetes의 기본 구조: 두 종류의 노드
Kubernetes를 쉽게 비유하면, **회사의 본사(Control Plane)**와 **공장(Worker Node)**의 관계입니다.
본사는 "무엇을 어디에 얼마나 만들지" 결정하고, 공장은 실제로 물건(컨테이너)을 생산합니다. 본사가 망하면 새 지시가 안 내려가지만, 이미 돌고 있는 공장은 당장은 계속 돌아갑니다.
```mermaid
flowchart TB
subgraph CP["Control Plane (본사)"]
direction TB
API["API Server<br/>모든 요청의 창구<br/>kubectl 명령이 여기로 들어옴"]
ETCD["etcd / SQLite<br/>클러스터의 모든 상태를<br/>저장하는 데이터베이스"]
SCHED["Scheduler<br/>새 Pod를 어느 노드에<br/>배치할지 결정"]
CM["Controller Manager<br/>선언된 상태와 현재 상태의<br/>차이를 감지하고 조정"]
API <--> ETCD
SCHED --> API
CM --> API
end
subgraph W1["Worker Node 1 (공장)"]
direction TB
KL1["kubelet<br/>API Server의 지시를 받아<br/>컨테이너를 생성/삭제"]
KP1["kube-proxy<br/>네트워크 규칙을 관리<br/>(Service → Pod 라우팅)"]
CR1["containerd<br/>실제 컨테이너를 실행하는<br/>런타임 엔진"]
KL1 --> CR1
end
subgraph W2["Worker Node 2 (공장)"]
direction TB
KL2["kubelet"]
KP2["kube-proxy"]
CR2["containerd"]
KL2 --> CR2
end
API -->|"Watch 스트림으로<br/>이벤트 전달"| KL1
API -->|"Watch 스트림으로<br/>이벤트 전달"| KL2
```
각 컴포넌트가 하는 일을 구체적으로 설명합니다.
| 컴포넌트 | 어디에 있나 | 무슨 일을 하나 | 없으면 어떻게 되나 |
|---|---|---|---|
| **API Server** | Control Plane | 모든 통신의 중심. `kubectl` 명령, kubelet 보고, Scheduler 요청이 전부 여기를 거침 | 클러스터 전체가 통신 불가. 신규 배포, 스케일링, 조회 모두 불가능 |
| **etcd** (K8s) / **SQLite** (K3s) | Control Plane | "이 클러스터에 무엇이 있고, 어떤 상태인지"를 영구 저장. Deployment 몇 개, Pod 몇 개, Service 설정 등 | 상태 정보가 날아감. 클러스터를 처음부터 다시 만들어야 함 |
| **Scheduler** | Control Plane | 새 Pod가 생겼을 때 "어느 Worker에 배치할지" 결정 (CPU, 메모리, affinity 조건 등 고려) | Pod가 Pending 상태에서 영원히 멈춤 |
| **Controller Manager** | Control Plane | "선언된 상태(Deployment에 replica: 3)"와 "현재 상태(Pod 2개 살아있음)"의 차이를 발견하고 Pod를 1개 더 만듦 | Pod가 죽어도 자동 복구 안 됨, 스케일링 안 됨 |
| **kubelet** | 각 Worker | API Server에게 "제 노드에서 이 Pod를 실행하겠습니다"라는 지시를 받고, containerd에게 실제 실행을 시킴 | 해당 노드에서 컨테이너 생성, 삭제, 모니터링 불가 |
| **kube-proxy** | 각 Worker | Service의 IP로 들어온 트래픽을 실제 Pod의 IP로 전달하는 네트워크 규칙을 관리 | Service를 통한 통신 불가 (Pod IP 직접 지정하면 가능) |
| **containerd** | 각 Worker | 실제 컨테이너 이미지를 다운로드하고, 프로세스를 격리해서 실행 | 컨테이너 실행 자체가 불가 |
#### kubelet은 API Server에게 어떻게 지시를 받는가 — Watch 메커니즘
여기서 중요한 의문이 생깁니다. kubelet은 API Server에게 명령을 어떻게 받을까요?
두 가지 방식이 가능합니다.
1. **Polling(폴링)**: kubelet이 1초마다 API Server에 "나한테 새 일 있어?" 하고 물어보는 방식
2. **Watch(감시)**: kubelet이 API Server에 "내 노드에 변경 생기면 바로 알려줘"라고 한 번 등록해놓고, 변경이 있을 때만 알림을 받는 방식
Kubernetes는 **Watch 방식**을 씁니다. 왜냐하면 Worker Node가 100대, 1000대가 되면 모두가 1초마다 물어보는 건 API Server에게 엄청난 부하가 됩니다. Watch는 **변경이 있을 때만** 이벤트를 푸시하니까 훨씬 효율적입니다.
```mermaid
sequenceDiagram
participant KL as kubelet (Worker Node)
participant API as API Server (Control Plane)
KL->>API: "내 노드에 관련된 변경사항을<br/>Watch 스트림으로 구독합니다"
Note over KL,API: HTTP Long-Poll 연결이 유지됨
API-->>KL: (아무 일 없으면 조용)
Note over API: 사용자가 kubectl apply로<br/>새 Deployment 생성
API->>API: Scheduler가 "Worker Node 1에 배치" 결정
API-->>KL: "새 Pod를 실행하세요" 이벤트 푸시
KL->>KL: containerd에게 컨테이너 생성 요청
KL-->>API: "Pod 실행 중(Running)" 상태 보고
```
이 구조를 **선언적 상태 관리(Declarative State Management)**라고 합니다.
사용자는 "auth-server를 3개 돌려라"라고 **원하는 상태를 선언**합니다. Controller Manager가 현재 상태와 원하는 상태를 지속적으로 비교합니다. Pod가 1개 죽으면 "2개인데 3개여야 하니까 1개 더 만들어야지"라고 판단하고, 이것을 **Reconciliation Loop(조정 루프)**라고 부릅니다. 이 루프는 **끊임없이** 돕니다.
#### K3s는 이 구조를 어떻게 경량화했는가
K3s는 위의 Kubernetes와 동일한 개념이지만, 일부를 **다이어트**했습니다.
```mermaid
flowchart LR
subgraph K8S["K8s (풀 사이즈)"]
direction TB
E1["etcd<br/>분산 합의 알고리즘 (Raft)<br/>별도 클러스터 3~5대 필요<br/>수백 MB 메모리"]
C1["Cloud Controller Manager<br/>AWS, GCP 연동 코드"]
S1["Storage Driver<br/>다양한 CSI 드라이버 포함"]
end
subgraph K3S["K3s (경량)"]
direction TB
E2["SQLite<br/>단일 파일 DB<br/>단일 바이너리 내장<br/>수 MB 메모리"]
C2["없음<br/>클라우드 연동 코드 제거"]
S2["Local Path Provisioner<br/>기본 탑재, 간단한 로컬 볼륨"]
end
K8S -->|"이것이 100MB 바이너리 하나로<br/>압축된 것이 K3s"| K3S
```
| 차이점 | K8s | K3s |
|---|---|---|
| 상태 저장소 | **etcd** — 분산 합의 알고리즘(Raft)을 자체 구현한 별도 프로세스. HA를 위해 최소 3대 클러스터 필요 | **SQLite** — 파일 하나(`/var/lib/rancher/k3s/server/db/state.db`)로 상태를 저장. 프로세스 추가 없음 |
| 바이너리 크기 | 여러 바이너리 합계 수백 MB | **단일 바이너리 약 100MB** (API Server, Scheduler, Controller Manager, kubelet, kube-proxy 전부 포함) |
| 네트워크 플러그인(CNI) | 별도 설치 필요 | **Flannel 기본 탑재** (설치 없이 바로 Pod 간 통신 가능) |
| 인그레스 컨트롤러 | 별도 설치 (NGINX, Traefik 등) | **Traefik 기본 탑재** |
| 자원 요구량 | Control Plane 최소 2GB RAM | **최소 512MB RAM** |
PPT에서 "K3s가 etcd 대신 SQLite를 쓰기 때문에 가볍다"고 설명했습니다. 좀 더 깊이 들어가면, etcd는 **여러 노드 간의 데이터 일관성을 보장**하기 위해 Raft 합의 알고리즘을 돌립니다. 3대의 etcd 노드가 "이 데이터를 저장할게"라고 합의하는 과정 자체가 CPU와 메모리를 많이 씁니다.
K3s의 SQLite는 이런 분산 합의가 **없습니다**. 파일 하나에 기록할 뿐입니다. 대신 트레이드오프가 있습니다. **하나의 Control Plane이 죽으면 상태 저장소가 함께 날아갑니다.** 이것이 K3s가 "실험/개발 환경, IoT 엣지 디바이스"에 적합하고, "대규모 운영 인프라"에는 풀 K8s를 쓰는 이유입니다.
(다만 K3s도 HA를 원하면 SQLite 대신 외부 DB(MySQL, PostgreSQL 등)를 상태 저장소로 쓸 수 있습니다.)
#### 이 주제에서 스스로 던져봐야 할 질문들
- etcd의 Raft 합의 알고리즘은 왜 노드가 최소 3대 필요한가? 2대면 안 되는 이유는? (힌트: 과반수 투표)
- K3s에서 SQLite를 쓰면 HA 구성이 불가능한가? 가능하다면 어떤 방식인가?
- Reconciliation Loop는 얼마나 자주 도는가? 1초마다? 이벤트가 있을 때만?
- 우리 프로젝트의 `vault-deployment.yaml``strategy: Recreate`로 설정한 이유는 무엇인가? RollingUpdate와 무엇이 다른가? (힌트: 볼륨 동시 마운트 문제)
- API Server가 다운되면 이미 돌고 있는 Pod들은 어떻게 되는가? 바로 죽는가?
### 25-2. 복잡한 K8s 네트워크의 마법 (CNI와 DNS)
#### "Service"란 정확히 무엇인가 — 추상적 설명을 넘어서
Part I에서 Service를 "Pod 앞에 놓는 고정된 네트워크 진입점"이라고 설명했습니다. 하지만 이것만으로는 **Service가 별도의 프로세스인지, 가상의 설정인지, 물리적으로 어디에 존재하는지** 감이 안 옵니다.
답부터 말하면: **Service는 독립적인 프로세스나 컨테이너가 아닙니다.** Service는 **kube-proxy가 각 노드의 iptables(또는 IPVS)에 기록해놓은 네트워크 규칙(라우팅 테이블)**입니다.
이것을 실제 동작으로 풀어보겠습니다.
```mermaid
sequenceDiagram
participant APP as auth-server Pod<br/>(다른 Pod에서 vault를 호출)
participant IPTABLES as iptables 규칙<br/>(kube-proxy가 관리)
participant POD as vault Pod<br/>(실제 컨테이너)
Note over APP: configmap에 적힌 주소:<br/>vault.vault.svc.cluster.local:8200
APP->>APP: DNS 조회: vault.vault.svc.cluster.local<br/>→ CoreDNS가 10.43.x.x (Service의 ClusterIP) 반환
APP->>IPTABLES: 10.43.x.x:8200으로 패킷 전송
Note over IPTABLES: kube-proxy가 미리 심어놓은 규칙:<br/>"10.43.x.x:8200 → 실제 Pod IP 10.42.y.y:8200"
IPTABLES->>POD: 실제 Pod의 IP(10.42.y.y:8200)로 전달
POD-->>APP: 응답 반환
```
이 그림을 단계별로 설명하면 이렇습니다.
**1단계: Service 생성 시**`vault-service.yaml``kubectl apply`하면 API Server가 이것을 etcd(K3s는 SQLite)에 기록합니다. 이 순간 Kubernetes가 Service에 **ClusterIP**(예: 10.43.x.x)라는 가상 IP를 할당합니다. 이 IP는 **어떤 노드에도 실제로 바인딩되어 있지 않은 가상 주소**입니다.
**2단계: kube-proxy가 규칙 설정** — 각 노드의 kube-proxy가 Watch 스트림으로 "새 Service가 생겼다"는 이벤트를 받습니다. kube-proxy는 자기 노드의 **iptables에 규칙을 추가**합니다. "10.43.x.x:8200으로 가는 패킷은 → 실제 Pod IP 10.42.y.y:8200으로 보내라."
**3단계: Pod가 Service를 호출할 때** — auth-server Pod가 `vault.vault.svc.cluster.local:8200`으로 요청을 보내면, DNS가 이것을 ClusterIP(10.43.x.x)로 해석합니다. 패킷이 노드의 네트워크 스택을 통과할 때, **iptables 규칙에 의해** 실제 Pod IP로 변환됩니다.
그래서 "Service가 Pod 앞에 놓여있다"는 비유적 표현이고, 실제로는 **각 노드의 iptables 규칙이 트래픽을 중계하는 것**입니다. Service라는 별도 프로세스가 떠서 트래픽을 통과시키는 것이 아닙니다.
이 프로젝트의 `infra/vault/base/vault-service.yaml`을 보면:
```yaml
apiVersion: v1
kind: Service
metadata:
name: vault # 이 이름이 DNS 이름의 일부가 됨
spec:
selector:
app: vault # label이 "app: vault"인 Pod로 트래픽을 연결
ports:
- name: http
port: 8200 # Service가 받는 포트
targetPort: 8200 # Pod가 실제로 리스닝하는 포트
type: ClusterIP # 클러스터 내부에서만 접근 가능
```
`selector: app: vault`가 핵심입니다. kube-proxy는 `app: vault` 라벨이 달린 **모든 Pod의 IP**를 수집해서 iptables 규칙에 등록합니다. Pod가 죽으면 규칙에서 제거하고, 새 Pod가 뜨면 규칙에 추가합니다. 이것이 "고정된 진입점"이라고 불리는 이유입니다. **Pod는 죽고 태어나면 IP가 바뀌지만, Service의 ClusterIP와 DNS 이름은 바뀌지 않습니다.**
#### CNI: Pod에 IP를 어떻게 부여하는가
각 Pod에는 고유한 IP 주소가 있습니다. 하지만 Pod는 가상의 컨테이너입니다. 물리 서버처럼 NIC(네트워크 카드)가 있는 것이 아닙니다. 그러면 IP는 어떻게 생기는 걸까요?
이것을 담당하는 것이 **CNI(Container Network Interface)** 플러그인입니다.
K3s는 기본으로 **Flannel**이라는 CNI를 탑재하고 있습니다. Flannel은 **VXLAN 오버레이 네트워크**를 만듭니다.
쉽게 비유하면, 물리적으로 다른 건물(노드)에 있는 사무실(Pod)들을 마치 같은 내부 네트워크에 있는 것처럼 **가상 터널**로 연결하는 것입니다.
```mermaid
flowchart TB
subgraph Node1["Worker Node 1 (물리 IP: 192.168.1.10)"]
direction TB
P1["auth-server Pod<br/>Pod IP: 10.42.0.5"]
P2["migration Job Pod<br/>Pod IP: 10.42.0.6"]
F1["Flannel<br/>VXLAN 터널 엔드포인트"]
end
subgraph Node2["Worker Node 2 (물리 IP: 192.168.1.11)"]
direction TB
P3["vault Pod<br/>Pod IP: 10.42.1.3"]
P4["postgres Pod<br/>Pod IP: 10.42.1.4"]
F2["Flannel<br/>VXLAN 터널 엔드포인트"]
end
F1 <-->|"VXLAN 터널<br/>Pod 패킷을 캡슐화해서<br/>물리 네트워크 위로 전달"| F2
P1 -.-|"10.42.0.5 → 10.42.1.3<br/>다른 노드지만<br/>직접 통신 가능"| P3
```
auth-server Pod(10.42.0.5)가 vault Pod(10.42.1.3)로 패킷을 보내면:
1. 패킷이 Flannel의 VXLAN 인터페이스에 도착합니다.
2. Flannel이 이 패킷을 **UDP로 캡슐화**(원래 패킷을 외부 패킷 안에 넣음)합니다.
3. 물리 네트워크(192.168.1.10 → 192.168.1.11)를 통해 상대 노드로 전달합니다.
4. 상대 노드의 Flannel이 캡슐을 벗기고 vault Pod에게 전달합니다.
이것이 **다른 물리 서버에 있는 Pod들이 마치 같은 네트워크에 있는 것처럼** 통신할 수 있는 원리입니다.
#### CoreDNS: vault.vault.svc.cluster.local은 누가 해석하는가
이 프로젝트에서는 `vault.vault.svc.cluster.local`이라는 DNS 이름이 여기저기 등장합니다.
이 이름의 구조를 분해하면:
| 부분 | 의미 |
|---|---|
| `vault` | Service의 이름 (`metadata.name: vault`) |
| `vault` | Service가 속한 Namespace |
| `svc` | "이것은 Service의 DNS다"라는 고정 접미사 |
| `cluster.local` | 클러스터의 기본 도메인 |
이 이름을 IP 주소로 변환하는 것은 **CoreDNS**라는 Pod입니다. CoreDNS는 K3s(그리고 K8s)가 기본으로 띄우는 **클러스터 내부 DNS 서버**입니다.
```mermaid
flowchart LR
A["auth-server Pod가<br/>vault.vault.svc.cluster.local<br/>을 호출"] --> B["Pod의 /etc/resolv.conf에<br/>CoreDNS IP가 적혀있음"]
B --> C["CoreDNS Pod가<br/>K8s API로부터<br/>Service 목록을 Watch"]
C --> D["vault Service의<br/>ClusterIP: 10.43.x.x<br/>를 응답"]
D --> E["iptables가<br/>10.43.x.x를<br/>실제 Pod IP로 변환"]
```
모든 Pod 안에는 `/etc/resolv.conf` 파일이 자동으로 생성되고, 여기에 CoreDNS의 IP가 적혀 있습니다. 그래서 Pod 안에서 `vault.vault.svc.cluster.local`을 호출하면 자동으로 CoreDNS에게 물어보게 됩니다.
`vault.hcl` 설정 파일에서 이 DNS 의존성이 직접 드러납니다:
```hcl
api_addr = "http://vault.vault.svc.cluster.local:8200"
cluster_addr = "http://vault.vault.svc.cluster.local:8201"
```
만약 CoreDNS가 죽으면? DNS 이름 해석이 안 되니, 이 주소로의 통신이 전부 실패합니다. 이미 해석된 IP가 캐시에 있으면 잠시 동안은 되겠지만, 캐시 TTL이 만료되면 끝입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- `type: ClusterIP``type: NodePort`, `type: LoadBalancer`의 차이는 무엇인가? 이 프로젝트에서 왜 `ClusterIP`만 쓰는가?
- Flannel(VXLAN)과 Calico(BGP/eBPF)의 핵심 차이는 무엇인가? 왜 K3s는 Flannel을 기본으로 택했는가?
- CoreDNS의 캐시 TTL이 30초인데, Pod가 20초 만에 죽고 새 Pod가 새 IP로 뜨면 DNS 캐시 때문에 오래된 IP로 가지 않는가?
- `vault-service.yaml`에서 `port: 8200``targetPort: 8200`이 같은데, 이 둘을 다르게 설정하면 어떤 일이 생기는가? 언제 다르게 쓰는가?
- NetworkPolicy를 설정하면 iptables 규칙이 바뀌는가, 아니면 CNI 수준에서 별도 방화벽이 생기는가? (힌트: CNI마다 다르다)
### 25-3. 깡통 서버(Stateless)와 볼륨 클레임(PVC)
#### Stateless: 컨테이너가 죽으면 내부 데이터는 전부 사라진다
K8s에서 컨테이너는 **깡통**입니다. 안에 뭘 저장하든, 컨테이너가 삭제되면 전부 사라집니다.
이것이 왜 **장점**인지 직관적이지 않을 수 있습니다. "데이터가 사라지면 안 되지 않나?"
장점은 **교체가 자유롭다**는 것입니다. auth-server Pod가 죽으면 K8s가 새 Pod를 만들어서 교체합니다. 이전 Pod의 "더러운" 상태(메모리 누수, 잘못된 임시 파일 등)가 깨끗이 청소되고, 새 Pod는 완전히 깨끗한 상태에서 시작합니다. 서버를 "수리"하는 것이 아니라 "교체"하는 것입니다. 이것을 **Cattle, not Pets(가축이지 애완동물이 아니다)** 패턴이라고 부릅니다.
하지만 **반드시 데이터를 유지해야 하는 워크로드**가 있습니다:
- **PostgreSQL**: DB 데이터가 사라지면 끝장입니다.
- **Vault**: Raft 저장소에 seal/unseal 상태와 secret이 저장되어 있습니다.
이런 워크로드를 위해 **PVC(PersistentVolumeClaim)**가 존재합니다.
#### PVC → PV → StorageClass: 볼륨이 생기는 메커니즘
PVC를 이해하려면 세 가지 개념 사이의 관계를 알아야 합니다.
```mermaid
flowchart LR
subgraph 사용자_요청["개발자가 선언하는 것"]
PVC["PVC<br/>(PersistentVolumeClaim)<br/>'5GB 볼륨 하나 주세요'"]
end
subgraph 중간_매개["K8s가 처리하는 것"]
SC["StorageClass<br/>'볼륨을 어떤 방식으로<br/>만들지 정의한 템플릿'"]
end
subgraph 실제_저장소["실제로 생성되는 것"]
PV["PV<br/>(PersistentVolume)<br/>'실제 5GB 디스크 공간'"]
DISK["노드의 로컬 디스크<br/>또는 NFS/클라우드 EBS"]
end
PVC -->|"① '5GB 주세요'<br/>StorageClass 참조"| SC
SC -->|"② Provisioner가<br/>실제 볼륨 생성"| PV
PV -->|"③ 바인딩 완료"| PVC
PV -->|"실제 데이터 저장"| DISK
```
비유하자면:
- **PVC**는 "5GB짜리 USB 하나 주세요"라는 **요청서**
- **StorageClass**는 "USB는 삼성 제품으로, SSD로 만들겠다"는 **제조 사양서**
- **PV**는 실제로 만들어진 **USB 그 자체**
이 프로젝트의 `infra/vault/base/vault-pvc.yaml`을 보면:
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vault-data # 이 이름으로 Deployment에서 참조
spec:
accessModes:
- ReadWriteOnce # 한 번에 하나의 노드만 읽기/쓰기 가능
resources:
requests:
storage: 5Gi # 5GB 요청
```
`ReadWriteOnce`는 **이 볼륨을 동시에 두 노드에서 마운트할 수 없다**는 뜻입니다. 하나의 노드에서만 읽고 쓸 수 있습니다.
이것이 `vault-deployment.yaml`에서 `strategy: Recreate`을 쓰는 이유와 직결됩니다.
```yaml
# vault-deployment.yaml
spec:
strategy:
type: Recreate # 기존 Pod를 먼저 죽이고 새 Pod를 띄움
```
만약 `RollingUpdate`(기본값)를 쓰면, 새 Pod가 먼저 뜨고 나서 기존 Pod를 죽입니다. 그런데 `ReadWriteOnce` 볼륨은 동시에 두 Pod가 마운트할 수 없으니, 새 Pod는 볼륨을 마운트하지 못하고 **Pending 상태에서 영원히 멈춥니다.** 기존 Pod는 "새 Pod가 Ready가 될 때까지 죽지 마라"고 기다리니, 양쪽 다 데드락에 빠집니다.
`Recreate`은 이 문제를 막습니다. 기존 Pod를 **먼저 완전히 종료**시키고, 볼륨 마운트가 해제된 다음에야 새 Pod를 띄웁니다. 대신 업데이트 동안 **잠깐 서비스가 중단**됩니다. Vault는 이 짧은 다운타임을 감수할 수 있으니 이 전략을 택한 것입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- `ReadWriteOnce``ReadWriteMany`, `ReadOnlyMany`의 차이는 무엇인가? PostgreSQL은 왜 `ReadWriteOnce`를 쓰는가?
- PVC를 실수로 `kubectl delete pvc`로 삭제하면 데이터가 바로 날아가는가? (힌트: `reclaimPolicy`에 따라 다르다 — `Retain` vs `Delete`)
- K3s의 기본 StorageClass인 `local-path-provisioner`는 어떻게 동작하는가? 노드의 어느 디렉터리에 데이터가 저장되는가?
- StatefulSet과 Deployment의 차이는 무엇인가? PostgreSQL은 왜 StatefulSet을 쓰는가? (힌트: Pod 이름 안정성, 볼륨 1:1 대응)
- `strategy: Recreate` 동안 Vault가 잠깐 죽는데, 이 시간 동안 auth-server의 JWT 서명은 어떻게 되는가?
## 26. HashiCorp Vault Secret 주입 딥다이브
### 26-1. Vault 아키텍처: Seal/Unseal과 Storage Backend
#### Vault는 왜 "봉인(Sealed)" 상태로 시작하는가
Vault를 처음 접하면 혼란스러운 개념이 **Sealed/Unsealed** 상태입니다.
보통의 서비스는 프로세스가 뜨면 바로 사용 가능합니다. 하지만 Vault는 **프로세스가 떠도 "봉인" 상태**이면 아무 요청도 처리하지 못합니다. 모든 읽기/쓰기가 거부됩니다.
왜 이렇게 설계했을까요?
Vault가 저장하는 것은 DB 비밀번호, API 키, 암호화 키 등 **최고 기밀 정보**입니다. 이 정보들은 디스크에 **암호화되어** 저장됩니다. Vault가 기동될 때, 이 암호화된 데이터를 복호화하려면 **마스터 키**가 필요합니다.
Sealed 상태란 **이 마스터 키가 메모리에 없는 상태**입니다. 디스크에 암호화된 데이터가 있지만, 열쇠가 없어서 읽을 수 없습니다.
```mermaid
flowchart LR
subgraph SEALED["Sealed 상태 (기동 직후)"]
direction TB
S1["디스크: 암호화된 secret들<br/>🔒 잠겨있음"]
S2["메모리: 마스터 키 없음<br/>❌ 복호화 불가"]
S3["모든 API 요청 → 503 거부"]
end
subgraph UNSEAL["Unseal 과정"]
direction TB
U1["마스터 키를<br/>메모리에 올림"]
end
subgraph UNSEALED["Unsealed 상태"]
direction TB
US1["디스크: 여전히 암호화 상태"]
US2["메모리: 마스터 키 보유<br/>✅ 요청 시 복호화 가능"]
US3["모든 API 요청 → 정상 처리"]
end
SEALED -->|"Unseal 과정<br/>(키 제공)"| UNSEAL
UNSEAL --> UNSEALED
```
중요한 것은 **디스크의 데이터는 Unsealed 후에도 암호화 상태**라는 점입니다. 마스터 키는 오직 메모리에만 존재합니다. Vault 프로세스가 재시작되면 메모리가 초기화되니, 다시 Sealed 상태로 돌아갑니다.
#### 이 프로젝트의 Auto-Unseal: Transit 방식
사람이 매번 수동으로 unseal 하는 것은 실무에서 불가능합니다. Vault가 Pod 재시작될 때마다 24시간 대기하고 있을 수 없으니까요.
이 프로젝트에서는 **Transit Auto-Unseal**을 사용합니다. 또 다른 Vault(Provider Vault, 이 프로젝트에서는 vault-transit)가 마스터 키의 암호화/복호화를 대행하는 패턴입니다.
```mermaid
sequenceDiagram
participant WV as Workload Vault<br/>(우리가 쓰는 Vault)
participant PV as Provider Vault<br/>(vault-transit)
Note over WV: Pod 기동됨 → Sealed 상태
WV->>WV: 디스크에서 암호화된<br/>마스터 키를 읽음
WV->>PV: "이 암호화된 마스터 키를<br/>Transit 엔진으로 복호화해줘"
Note over PV: Transit 키 "workload-vault-dev-unseal"로<br/>복호화 수행
PV-->>WV: 복호화된 마스터 키 반환
WV->>WV: 마스터 키를 메모리에 올림<br/>→ Unsealed 상태 전환!
Note over WV: 이제 모든 secret 읽기/쓰기 가능
```
이 구조가 `vault.hcl`에 선언되어 있습니다:
```hcl
seal "transit" {
address = "http://vault-transit.vault-transit.svc.cluster.local:8200"
disable_renewal = "false"
key_name = "workload-vault-dev-unseal"
mount_path = "transit/"
tls_skip_verify = "true"
}
```
- `address`: Provider Vault의 주소입니다. K8s DNS 이름으로 접근합니다.
- `key_name: "workload-vault-dev-unseal"`: Provider Vault의 Transit 엔진에 있는 암호화 키 이름입니다. 이 키가 Workload Vault의 마스터 키를 암호화/복호화합니다.
여기서 의문이 생깁니다. Workload Vault가 Provider Vault에게 요청을 보내려면 **인증 토큰**이 필요합니다. 이 토큰은 어디서 올까요?
`vault-deployment.yaml`에서 확인할 수 있습니다:
```yaml
env:
- name: VAULT_TOKEN
valueFrom:
secretKeyRef:
name: vault-transit-seal # K8s Secret에 저장된
key: VAULT_TRANSIT_SEAL_TOKEN # Provider Vault의 제한된 토큰
```
이 토큰은 **Provider Vault의 Transit 엔진 사용 권한만** 가진 제한적 토큰입니다. 탈취되어도 할 수 있는 건 "unseal 키 복호화"뿐이며, Vault 안의 secret을 직접 읽는 것은 불가능합니다. 이것이 PPT에서 "토큰의 권한을 최소한으로"라고 언급한 부분입니다.
#### Raft Storage: Vault의 내부 저장소
`vault.hcl`의 다른 섹션을 보면:
```hcl
storage "raft" {
path = "/vault/data"
node_id = "vault-dev-0"
}
```
**Raft**는 분산 합의 알고리즘입니다 (25장의 etcd에서 나온 그것과 같은 알고리즘). Vault는 별도의 외부 DB를 쓰지 않고, **자체적으로 Raft를 돌려서 데이터를 저장**합니다.
현재 단일 노드(`node_id: vault-dev-0`)로 운영하고 있으니 합의 과정은 사실상 "혼자 결정"이지만, 노드를 추가하면 HA 구성이 가능합니다.
`/vault/data` 경로는 PVC(`vault-data`)에 마운트되어 있어서, Pod가 재시작되어도 데이터가 유지됩니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- Provider Vault(vault-transit)도 재시작되면 누가 unseal하는가? 무한 재귀 아닌가? (힌트: Provider Vault는 수동 unseal 또는 별도의 auto-unseal 메커니즘 사용)
- Raft 단일 노드에서 장애가 나면 복구 방법은? (힌트: PVC에 저장된 Raft 스냅샷)
- `vault-transit-seal` K8s Secret이 바로 K8s Secret에 저장된다는 게 딜레마라고 PPT에서 언급했는데, 현업에서는 이걸 어떻게 해결하는가? (힌트: 클라우드의 KMS나 HSM)
- `tls_skip_verify: "true"`는 왜 설정했는가? 운영 환경에서도 이렇게 하면 안 되는 이유는?
- Vault의 Sealed 상태에서 readinessProbe가 fail하면 K8s는 어떻게 반응하는가?
### 26-2. Vault Agent Injector의 Secret 주입 전체 과정
#### Pod에 Secret이 들어가는 메커니즘: Mutating Webhook
`deployment.vault-patch.yaml``vault.hashicorp.com/agent-inject: "true"` 어노테이션을 달면 마법처럼 secret이 Pod에 들어갑니다. 하지만 이것은 마법이 아닙니다. **K8s의 Mutating Admission Webhook** 메커니즘이 동작하는 것입니다.
이것이 무엇인지 단계별로 풀어보겠습니다.
```mermaid
sequenceDiagram
participant USER as kubectl apply<br/>(또는 Argo CD)
participant API as K8s API Server
participant INJECTOR as Vault Agent Injector<br/>(Webhook 서버)
participant KUBELET as kubelet
participant AGENT as Vault Agent<br/>(사이드카 컨테이너)
participant VAULT as Vault Server
USER->>API: "auth-server Pod를 만들어줘"
API->>API: 어노테이션 확인:<br/>vault.hashicorp.com/agent-inject: "true"
API->>INJECTOR: "이 Pod 정의를 보내는데,<br/>수정할 게 있으면 수정해줘"
Note over INJECTOR: Pod 정의를 분석<br/>vault 관련 어노테이션 발견
INJECTOR->>INJECTOR: Pod 정의에 사이드카 컨테이너<br/>(Vault Agent) 추가
INJECTOR->>INJECTOR: 공유 볼륨<br/>(/vault/secrets) 추가
INJECTOR-->>API: 수정된 Pod 정의 반환
API->>KUBELET: 수정된 Pod를 생성하라
KUBELET->>KUBELET: 원래 컨테이너 + Vault Agent 사이드카 함께 실행
AGENT->>VAULT: K8s ServiceAccount JWT로 인증 요청
VAULT->>VAULT: K8s API에 "이 JWT 진짜야?" 확인
VAULT-->>AGENT: Vault 토큰 발급
AGENT->>VAULT: 토큰으로 secret 요청<br/>"kv/data/dev/platform/postgres/auth-server"
VAULT-->>AGENT: secret 데이터 반환
AGENT->>AGENT: Go 템플릿으로 렌더링<br/>→ /vault/secrets/runtime-env 파일 생성
Note over KUBELET: auth-server 컨테이너가<br/>/vault/secrets/runtime-env 파일을<br/>읽어서 환경변수로 로드
```
핵심 메커니즘:
**1. Mutating Webhook** — K8s API Server는 Pod가 생성되기 전에 등록된 Webhook 서버에게 "이 Pod 정의를 수정할 기회를 줄게"라고 보냅니다. Vault Agent Injector가 바로 이 Webhook 서버입니다. Injector는 vault 어노테이션이 있는 Pod 정의에 **사이드카 컨테이너(Vault Agent)**와 **공유 볼륨(/vault/secrets)**을 자동으로 추가합니다.
**2. K8s Auth Handshake** — Vault Agent는 Pod 안에 자동으로 마운트된 **K8s ServiceAccount의 JWT 토큰**을 들고 Vault에 인증합니다. Vault는 이 JWT가 진짜인지 **K8s API Server에 직접 물어서** 확인합니다. 진짜라면 해당 ServiceAccount에 매핑된 policy에 따라 Vault 토큰을 발급합니다.
`terraform/vault/reconcile/main.tf`에서 이 매핑이 선언되어 있습니다:
```hcl
resource "vault_kubernetes_auth_backend_role" "auth_server" {
bound_service_account_names = ["auth-server"] # 이 SA만
bound_service_account_namespaces = ["auth-dev"] # 이 네임스페이스에서만
role_name = "auth-server-dev"
token_policies = ["auth-server-dev"] # 이 policy의 권한만 부여
}
```
이것은 "auth-dev 네임스페이스의 auth-server ServiceAccount를 가진 Pod만 `auth-server-dev` 정책으로 Vault를 사용할 수 있다"는 뜻입니다. 다른 네임스페이스의 Pod가 같은 이름의 ServiceAccount를 가져도 **접근 불가**입니다.
**3. Template Rendering**`deployment.vault-patch.yaml`의 이 부분:
```yaml
vault.hashicorp.com/agent-inject-template-runtime-env: |
{{ with secret "kv/data/dev/platform/postgres/auth-server" }}
export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.data.APP_DATASOURCE_USERNAME }}
export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.data.APP_DATASOURCE_PASSWORD }}
{{ end }}
```
이것은 **Go 템플릿** 문법입니다. Vault Agent가 secret 데이터를 받아서, 이 템플릿에 맞춰 `/vault/secrets/runtime-env` 파일을 생성합니다. `printf "%q"`는 값을 따옴표로 감싸서 셸에서 안전하게 쓸 수 있도록 이스케이프하는 함수입니다.
결과 파일은 이런 형태가 됩니다:
```bash
export APP_DATASOURCE_USERNAME="auth_user"
export APP_DATASOURCE_PASSWORD="s3cret!p@ss"
```
auth-server 컨테이너의 시작 명령이 `. /vault/secrets/runtime-env`로 이 파일을 source하면, 환경변수로 로드됩니다.
#### Sidecar vs Pre-Populate-Only: Deployment용과 Job용의 차이
| | Deployment (auth-server) | Job (db-migration) |
|---|---|---|
| 어노테이션 | `agent-inject: "true"` (기본값) | `agent-pre-populate-only: "true"` |
| Vault Agent 수명 | **sidecar로 계속 살아있음** | **init container로 한 번 실행 후 종료** |
| Secret 갱신 | 주기적으로 Vault에 재요청하여 파일 갱신 가능 | 불가 (한 번 생성하고 끝) |
| 적합한 워크로드 | 오래 실행되는 서비스. Secret rotation 시 파일이 자동 갱신됨 | 짧게 실행되고 끝나는 작업. sidecar가 계속 떠 있으면 Job이 "완료"로 전환 안 됨 |
Job에서 `agent-pre-populate-only: "true"`를 안 쓰면 어떻게 될까요? Vault Agent sidecar가 계속 살아있으니까, 메인 컨테이너(migration)가 끝나도 **Pod가 Completed 상태로 전환되지 않습니다.** Job은 Pod가 완료되어야 성공으로 간주하는데, sidecar가 죽지 않으니 Job이 영원히 Running 상태에 머물러서 배포가 멈춥니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- `vault.hashicorp.com/agent-inject: "true"` 어노테이션이 필요한 것 외에, 네임스페이스에 어떤 라벨이 있어야 Injector가 동작하는가? (힌트: `vault-injection: enabled`)
- Vault Agent가 secret rotation을 감지하면 파일은 갱신되지만, 앱 프로세스는 파일을 다시 읽지 않으면 반영이 안 된다. 이것을 어떻게 해결하는가?
- `printf "%q"`를 안 쓰고 그냥 `.Data.data.PASSWORD`를 출력하면 어떤 문제가 생기는가? (힌트: 비밀번호에 특수문자가 있으면 셸 해석 문제)
- `automountServiceAccountToken: true`가 왜 Vault Agent가 있는 Pod에서 필수인가?
- 이 프로젝트에서 `vault.hashicorp.com/agent-inject-token: "true"` 어노테이션은 왜 auth-server에만 있고 migration Job에는 없는가?
### 26-3. Transit Engine: JWT 서명과 Auto-Unseal의 이중 역할
#### Transit Engine이란: "키를 꺼내주지 않고 대신 연산해주는 서비스"
일반적인 암호화에서는 키를 가져와서 내 코드에서 직접 암호화/복호화를 합니다. 하지만 이 방식은 키가 애플리케이션 메모리에 올라오니, 메모리 덤프 등으로 키가 유출될 위험이 있습니다.
Vault의 **Transit Engine**은 다릅니다. **키가 Vault 밖으로 절대 나가지 않습니다.** 대신 "이 데이터를 암호화해줘", "이 데이터에 서명해줘"라고 **API로 요청**하면, Vault가 내부에서 키를 써서 결과만 돌려줍니다.
```mermaid
flowchart LR
subgraph 위험한_방식["일반적인 방식 (키 유출 위험)"]
A1["앱이 키를 다운로드"] --> A2["앱 메모리에 키 올림"] --> A3["앱이 직접 서명"]
end
subgraph 안전한_방식["Transit Engine 방식"]
B1["앱이 Vault API 호출<br/>'이 데이터에 서명해줘'"] --> B2["Vault가 내부에서<br/>키로 서명 수행"] --> B3["서명 결과만 반환<br/>키는 Vault 밖으로 안 나감"]
end
```
#### 이 프로젝트에서 Transit의 두 가지 역할
Transit Engine은 이 프로젝트에서 **두 가지 완전히 다른 목적**으로 사용됩니다.
**역할 1: auth-server의 JWT 서명**
auth-server가 사용자에게 JWT 토큰을 발급할 때, RSA 개인키로 서명해야 합니다. 이 키를 auth-server의 메모리에 올리는 대신, **Vault Transit에게 서명을 위임**합니다.
`runbooks/vault/dev/policies/auth-server-dev.hcl`:
```hcl
path "transit/keys/project-auth-jwt" {
capabilities = ["read"] # 공개키 읽기 (JWT 검증용)
}
path "transit/sign/project-auth-jwt" {
capabilities = ["update"] # 서명 요청 (JWT 발급용)
}
```
auth-server는 `transit/sign/project-auth-jwt`로 "이 JWT 페이로드에 서명해줘"라고 요청합니다. Vault가 `project-auth-jwt`라는 RSA 키로 서명한 결과를 돌려줍니다. auth-server는 개인키를 **한 번도 본 적이 없습니다.**
**역할 2: Workload Vault의 Auto-Unseal**
26-1에서 설명한 것처럼, Provider Vault(vault-transit)의 Transit Engine이 Workload Vault의 마스터 키를 암호화/복호화합니다. Transit 키 이름은 `workload-vault-dev-unseal`입니다.
두 역할을 그림으로 보면:
```mermaid
flowchart TB
subgraph TRANSIT["Provider Vault의 Transit Engine"]
direction TB
K1["키: project-auth-jwt<br/>(RSA 키, JWT 서명용)"]
K2["키: workload-vault-dev-unseal<br/>(AES 키, Unseal용)"]
end
AUTH["auth-server Pod"] -->|"'이 JWT에 서명해줘'<br/>transit/sign/project-auth-jwt"| K1
VAULT["Workload Vault"] -->|"'이 마스터키 복호화해줘'<br/>transit/decrypt/workload-vault-dev-unseal"| K2
```
같은 Transit Engine이지만, **다른 키를 사용해서 완전히 다른 목적**으로 쓰이고 있습니다. `terraform/vault-transit/reconcile/main.tf`(또는 `dev/main.tf`)에서 이 키들이 Terraform으로 선언되어 있습니다.
#### Transit 키 Rotation: 기존 JWT는 검증 실패하는가?
Transit 키를 rotation(교체)하면 새 버전의 키가 생깁니다. Vault Transit은 **키 버전 관리**를 합니다.
- **서명**: 항상 **최신 버전**의 키로 서명합니다.
- **검증**: 서명에 포함된 키 버전 정보를 보고, **해당 버전**의 키로 검증합니다.
즉, 키를 rotation해도 **이전 버전의 키가 삭제되지 않으면** 기존 JWT 검증은 계속 성공합니다. 이전 버전을 명시적으로 "min_decryption_version"으로 제한하기 전까지는 안전합니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- Transit Engine으로 JWT 서명을 하면 auth-server가 Vault에 의존하게 됩니다. Vault가 다운되면 JWT 발급이 불가한데, 이 단일 장애점(SPOF)을 어떻게 완화할 수 있는가?
- `transit/keys/project-auth-jwt``read` 권한을 준 이유는? read로 무엇을 가져오는가? (힌트: 공개키를 가져와서 JWT 검증에 사용)
- Transit 키의 `min_encryption_version``min_decryption_version`을 올리면 되돌릴 수 없다. 왜 위험한가?
- auth-server가 Vault API를 호출하는 것과 직접 메모리에서 키로 서명하는 것의 **성능 차이**는 어느 정도인가? 이 트레이드오프는 어떤 상황에서 정당화되는가?
- Provider Vault와 Workload Vault를 왜 분리했는가? 하나의 Vault에서 모든 것을 하면 안 되는 이유는?
## 27. Terraform IaC와 Bash 자동화 심층 해부
### 27-1. Terraform의 State 관리와 Plan/Apply 사이클
#### Terraform은 대체 무엇을 하는 도구인가
Kubernetes에서 `kubectl apply -f deployment.yaml`을 하면, K8s API Server가 "이 Deployment를 만들어라"라는 지시를 받고 처리합니다. Terraform도 **비슷한 역할**을 하지만, 대상이 K8s가 아닙니다.
Terraform은 **인프라를 코드로 선언하고, 그 코드의 상태를 추적하면서, 변경이 필요한 부분만 자동으로 적용**하는 도구입니다.
이 프로젝트에서 Terraform이 관리하는 것들은:
- Vault의 **policy** (누가 어떤 secret을 읽을 수 있는지)
- Vault의 **K8s auth backend role** (어떤 ServiceAccount가 어떤 policy를 받는지)
- Vault의 **KV secret** (Provider Vault에서 Workload Vault로 secret 복사)
- Vault의 **database secret engine** (동적 DB 자격 증명 설정)
이것들을 사람이 하나씩 `vault` CLI로 수동 설정할 수도 있습니다. 하지만 그러면 **"지금 어디까지 설정했지?"를 기억하는 것이 불가능**합니다. 10개의 policy, 5개의 role, 8개의 secret을 수동으로 관리하면 누락이 생기고, 재현이 안 됩니다.
#### State 파일: "내가 무엇을 만들었는지" 기억하는 메모장
Terraform의 핵심은 **State 파일**입니다. 이것은 "Terraform이 지금까지 무엇을 만들었는지"를 기록한 JSON 파일입니다.
```mermaid
flowchart TD
subgraph 선언["개발자가 작성한 것 (main.tf)"]
TF["'auth-server-dev policy를 만들어라'<br/>'auth-server K8s auth role을 만들어라'<br/>'postgres secret을 복사해라'"]
end
subgraph state["State 파일 (.tfstate)"]
ST["'auth-server-dev policy: 만들었음 ✅'<br/>'auth-server K8s auth role: 만들었음 ✅'<br/>'postgres secret: 만들었음 ✅'"]
end
subgraph 실제["실제 인프라 (Vault)"]
REAL["auth-server-dev policy 존재<br/>auth-server K8s auth role 존재<br/>postgres secret 존재"]
end
TF -->|"terraform plan<br/>선언 vs State 비교"| ST
ST -->|"terraform apply<br/>차이만 실제에 적용"| REAL
REAL -->|"적용 결과를<br/>State에 기록"| ST
```
이 흐름을 구체적으로 설명하면:
**1. `terraform plan`** — "내가 원하는 상태(main.tf)"와 "지금까지 만든 것(State 파일)"을 비교합니다. 차이가 있으면 "이것을 추가하겠다", "이것을 수정하겠다", "이것을 삭제하겠다"는 계획을 보여줍니다. **아직 아무것도 실행하지 않습니다.** 계획만 보여주는 단계입니다.
**2. `terraform apply`** — plan에서 나온 차이를 **실제로 적용**합니다. Vault API를 호출해서 policy를 만들고, role을 설정하고, secret을 복사합니다.
**3. State 업데이트** — apply가 끝나면 "이것을 만들었다"는 기록을 State 파일에 저장합니다.
다음에 같은 코드로 `terraform apply`를 다시 실행하면, State 파일을 보고 "이미 다 만들어져 있네, 할 일 없음"이라고 판단합니다. **멱등성(idempotency)** — 같은 코드를 여러 번 실행해도 결과가 동일합니다.
#### State가 손상되거나 사라지면?
State 파일이 손상되면 Terraform은 **자기가 무엇을 만들었는지 모르는 상태**가 됩니다. 이 상태에서 `terraform apply`를 하면, 이미 존재하는 리소스를 또 만들려고 시도해서 에러가 발생합니다.
이런 상황을 복구하는 것이 `terraform import`입니다. "이 리소스는 이미 실제로 존재하는데, State에 기록이 없으니 기록해줘"라는 명령입니다.
이 프로젝트의 `scripts/ci/reconcile-vault-dev.sh`에 이 패턴이 함수로 정의되어 있습니다:
```bash
ensure_transit_state_resource() {
local address="$1"
local import_id="$2"
# State에 이 리소스가 있는지 확인
if ! terraform ... state show "$address" >/dev/null 2>&1; then
# 없으면 import로 기존 리소스를 State에 등록
log "Importing missing vault-transit state for ${address}"
terraform ... import "$address" "$import_id"
fi
}
```
이 함수가 하는 일을 풀어보면:
1. `terraform state show "$address"` — State 파일에서 이 리소스가 기록되어 있는지 확인합니다.
2. 기록이 없으면(`if !`) — `terraform import`로 실제 Vault에 존재하는 리소스를 State에 등록합니다.
3. 기록이 있으면 — 아무것도 안 합니다.
이것은 **CI 파이프라인이 State 손실에 강해지도록** 만드는 방어적 패턴입니다. CI 환경은 매번 깨끗한 러너에서 실행될 수 있으니, State가 없을 수도 있습니다. 그래도 에러 없이 이어서 할 수 있도록 보장합니다.
#### Provider 이중 설정: 왜 Vault Provider가 두 개인가
`terraform/vault/reconcile/main.tf`의 상단을 보면:
```hcl
provider "vault" {
address = var.workload_vault_addr # Workload Vault (우리가 쓰는 것)
token = var.workload_vault_token
}
provider "vault" {
alias = "transit"
address = var.transit_vault_addr # Provider Vault (Transit용)
token = var.transit_vault_token
}
```
Terraform의 **하나의 provider 블록은 하나의 서버**에 연결됩니다. 이 프로젝트에는 Vault가 **두 대**(Workload + Transit)이니, provider도 두 개가 필요합니다.
`alias = "transit"`이 붙은 provider는 Transit Vault에 연결됩니다. 코드에서 `provider = vault.transit`를 지정하면 Transit Vault에 요청을 보내고, 지정하지 않으면 기본 provider(Workload Vault)에 요청을 보냅니다.
```hcl
# Provider Vault에서 secret 읽기 (transit alias 사용)
data "vault_kv_secret_v2" "provider_postgres_superuser" {
provider = vault.transit # ← Transit Vault에서 읽겠다
mount = var.seed_kv_mount_path
name = "dev/workload/platform/postgres/superuser"
}
# Workload Vault에 secret 쓰기 (기본 provider)
resource "vault_kv_secret_v2" "platform_postgres_superuser" {
# provider 지정 없음 → Workload Vault에 쓴다
mount = var.kv_mount_path
name = "dev/platform/postgres/superuser"
data_json = jsonencode({
POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"]
})
}
```
이 두 블록을 합치면: **Transit Vault에서 secret을 읽어서 → Workload Vault에 복사**하는 것입니다. 이것이 Terraform이 하는 "Secret 브릿징" 역할입니다.
#### Data Source vs Resource: 읽기 전용과 쓰기의 차이
| 키워드 | 의미 | 이 프로젝트 예시 |
|---|---|---|
| `data` | **읽기 전용**. 이미 존재하는 것을 참조만 함. Terraform이 관리하지 않음 | `data "vault_kv_secret_v2"` — Transit Vault의 secret을 **읽기만** |
| `resource` | **생성/수정/삭제**. Terraform이 생명주기를 관리함. State에 기록됨 | `resource "vault_kv_secret_v2"` — Workload Vault에 secret을 **생성** |
| `resource` | | `resource "vault_policy"` — Vault policy를 **생성/업데이트** |
| `resource` | | `resource "vault_kubernetes_auth_backend_role"` — K8s Auth role **생성** |
`data`로 읽은 값을 `resource`에서 사용하는 것이 **참조 패턴**입니다. Transit Vault에서 `data`로 비밀번호를 읽고, Workload Vault에 `resource`로 복사하는 것이 이 패턴의 전형입니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- `terraform destroy`를 CI에서 실수로 실행하면 어떻게 되는가? 모든 policy, role, secret이 삭제되면 서비스에 어떤 영향이 있는가?
- State 파일(`.tfstate`)에는 민감한 정보(secret 값 등)가 포함되는가? 그렇다면 이 파일을 어떻게 보호해야 하는가?
- `terraform plan`에서 "변경 없음"이 나왔는데 `terraform apply`를 하면 실제로 변경되는 경우가 있는가? (힌트: provider의 API가 plan 시점과 apply 시점 사이에 바뀌면)
- 이 프로젝트에서 `backend "local"`을 쓰고 있는데, 팀으로 협업할 때는 왜 remote backend(S3, GCS 등)를 써야 하는가?
- `skip_child_token = true`가 provider 설정에 있는 이유는 무엇인가? 이것을 안 쓰면 어떤 문제가 생기는가?
### 27-2. Bash 방어적 프로그래밍: trap, set, wait
#### `set -euo pipefail` — 셸 스크립트의 안전벨트 3종 세트
이 프로젝트의 모든 Bash 스크립트는 첫 줄 근처에 이것이 있습니다:
```bash
set -euo pipefail
```
이것은 **세 가지 안전장치**를 동시에 거는 것입니다. 각각이 무엇인지, **없으면 어떤 끔찍한 일이 생기는지** 구체적으로 보겠습니다.
**`-e` (errexit): 에러 발생 시 즉시 종료**
```bash
# -e 없이 실행하면:
kubectl apply -f wrong-file.yaml # ← 에러 발생! 파일이 없음
echo "배포 성공!" # ← 이 줄이 실행됨!!! 에러가 무시됨
vault write secret/data ... # ← 잘못된 상태에서 계속 진행
# -e 있으면:
kubectl apply -f wrong-file.yaml # ← 에러 발생!
# 스크립트 즉시 종료. 아래 줄은 실행 안 됨
```
`-e` 없이 스크립트를 짜면, 중간에 에러가 나도 멈추지 않고 **다음 줄로 넘어갑니다.** CI에서 이것은 치명적입니다. 앞 단계가 실패했는데 뒷 단계가 계속 실행되면, 반쪽짜리 인프라가 구성됩니다.
**`-u` (nounset): 정의되지 않은 변수 사용 시 에러**
```bash
# -u 없이:
echo "Vault 주소: ${VAULT_ADR}" # ← 오타! VAULT_ADDR가 맞는데
# → "Vault 주소: " 빈 문자열 출력, 에러 없이 넘어감
vault login -address="" # ← 빈 주소로 로그인 시도...
# -u 있으면:
echo "Vault 주소: ${VAULT_ADR}" # ← 에러! "VAULT_ADR: unbound variable"
# 스크립트 즉시 종료. 변수 오타를 바로 잡을 수 있음
```
변수 이름 오타는 누구나 합니다. `-u`가 없으면 오타된 변수가 빈 문자열로 조용히 치환되어, 디버깅하기 극도로 어려운 버그가 됩니다.
**`-o pipefail`: 파이프라인에서 중간 명령 에러 전파**
```bash
# pipefail 없이:
vault read secret/data | jq '.data'
# 만약 vault read가 실패해도, jq가 성공(빈 입력에 에러 없이 종료)하면
# 전체 파이프라인은 "성공"으로 간주됨!
# pipefail 있으면:
vault read secret/data | jq '.data'
# vault read가 실패하면 → 파이프라인 전체가 실패로 간주
```
`A | B`에서 `-o pipefail` 없이는 **B의 종료 코드만** 확인합니다. A가 실패해도 B가 성공이면 전체가 성공입니다. `pipefail`을 켜면 A, B 중 **하나라도** 실패하면 전체가 실패합니다.
#### `trap ... EXIT` — 뒷정리 보장 메커니즘
`scripts/ci/reconcile-vault-dev.sh``start_port_forward` 함수를 보면:
```bash
start_port_forward() {
local namespace="$1"
local service="$2"
local local_port="$3"
local remote_port="$4"
local log_file="$5"
# port-forward를 백그라운드에서 실행
kubectl -n "$namespace" port-forward "svc/${service}" \
"${local_port}:${remote_port}" >"$log_file" 2>&1 &
local pf_pid=$! # 방금 백그라운드로 보낸 프로세스의 PID를 저장
# EXIT 트랩: 스크립트가 어떤 이유로든 종료될 때 이 프로세스를 죽임
trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT
printf '%s\n' "$pf_pid"
}
```
이 코드를 한 줄씩 해부합니다.
**`... &` (백그라운드 실행)** — `kubectl port-forward`는 끝나지 않는 프로세스입니다. 포트 포워딩을 유지하려고 계속 대기합니다. 이것을 `&`로 백그라운드로 보내야 스크립트의 다음 줄이 실행됩니다. `&` 없이 실행하면 스크립트가 여기서 영원히 멈춥니다.
**`$!` (마지막 백그라운드 PID)** — 방금 `&`로 보낸 프로세스의 PID(프로세스 번호)를 캡처합니다. 나중에 이 프로세스를 죽이려면 PID를 알아야 합니다.
**`trap 'kill "$pf_pid" ...' EXIT`** — 이것이 핵심입니다. `trap`은 "특정 신호를 받으면 이 명령을 실행하라"는 뜻입니다. `EXIT`는 "스크립트가 종료될 때"입니다.
```mermaid
flowchart TD
A["스크립트 시작"] --> B["port-forward 프로세스 시작<br/>PID 저장"]
B --> C["trap 등록:<br/>'종료 시 PID를 kill해라'"]
C --> D["Terraform apply 등<br/>메인 작업 실행"]
D --> E{"결과는?"}
E -->|"성공"| F["스크립트 정상 종료"]
E -->|"에러 발생 (-e로 즉시 종료)"| G["스크립트 에러 종료"]
F --> H["trap 발동:<br/>port-forward 프로세스 kill"]
G --> H
H --> I["깔끔하게 종료됨<br/>좀비 프로세스 없음"]
```
trap이 없으면 어떻게 될까요?
스크립트가 에러로 중단되면 `kill` 명령이 실행되지 않고, **port-forward 프로세스가 좀비처럼 살아남습니다.** CI 러너에서 이런 좀비 프로세스가 쌓이면 포트 충돌이 발생하고, 다음 CI 실행이 실패합니다.
`|| true`는 "kill이 실패해도(이미 프로세스가 죽어있어도) 에러로 취급하지 마라"는 뜻입니다. `-e`가 켜져 있으니, `kill` 실패가 스크립트 종료를 유발하는 것을 방지합니다.
#### `require_cmd` / `require_env` — 사전 조건 검증 패턴
```bash
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "$cmd is required" >&2
exit 1
fi
}
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "$name must be set" >&2
exit 1
fi
}
```
이 함수들은 스크립트의 **맨 처음**에 호출됩니다:
```bash
reconcile_transit() {
require_cmd kubectl
require_cmd vault
require_cmd terraform
require_cmd curl
require_cmd jq
require_env TF_STATE_DIR
require_env TRANSIT_VAULT_ADDR
# ... 여기서 필수 도구와 환경변수가 있는지 먼저 확인
```
이 패턴의 목적: **10분 동안 실행한 뒤에 "jq가 없습니다"로 실패하는 것을 방지**합니다. 필수 조건을 스크립트 시작 시점에 전부 확인하고, 하나라도 빠지면 즉시 종료합니다. 디버깅 시간을 극적으로 줄여줍니다.
`${!name:-}`의 의미: `${!name}`는 **변수 간접 참조**입니다. `name` 변수에 "VAULT_ADDR"가 들어있으면, `${!name}``$VAULT_ADDR`의 값을 가져옵니다. `:-`는 "변수가 없으면 빈 문자열을 반환"하는 기본값 구문으로, `-u` 옵션에 의한 에러를 방지합니다.
#### `kubectl wait` vs `sleep` — 조건 기반 대기의 중요성
```bash
# 이 프로젝트의 방식 (올바른 방법)
kubectl -n vault-transit wait --for=condition=available \
deployment/vault-transit --timeout=300s
# 나쁜 방법
sleep 60 # 60초면 되겠지...?
```
| | `kubectl wait` | `sleep` |
|---|---|---|
| 대기 방식 | **조건을 지속 확인**. 조건 만족 즉시 통과 | 고정 시간만큼 무조건 대기 |
| 리소스가 30초에 준비되면 | 30초에 즉시 다음 단계 진행 | 60초 다 기다림 (30초 낭비) |
| 리소스가 90초에 준비되면 | 90초에 즉시 다음 단계 진행 | 60초에 성공으로 간주... **실제로는 아직 안 됨!** |
| timeout 처리 | `--timeout=300s` 초과 시 에러 코드 반환 → `-e`로 스크립트 종료 | timeout 개념 자체가 없음 |
`sleep`**"이 정도면 되겠지"라는 추측**에 기반합니다. CI 환경의 부하 상태에 따라 리소스 준비 시간이 달라지니, 어떤 때는 되고 어떤 때는 안 되는 **불안정한(flaky) 파이프라인**이 됩니다.
`kubectl wait`는 **실제 상태를 확인**합니다. Deployment가 Available 조건을 만족하는 순간 통과하고, timeout 안에 안 되면 명확하게 실패합니다.
#### 이 주제에서 스스로 던져봐야 할 질문들
- `trap`이 여러 번 호출되면 어떤 것이 실행되는가? 마지막에 등록된 것만? 전부? (힌트: 같은 신호에 대한 trap은 덮어쓰기됨)
- `-e`가 켜져 있을 때 `if ! command ...` 구문에서 command가 실패하면 스크립트가 종료되는가? (힌트: `if`문 안에서는 `-e`가 일시 중단됨)
- `2>&1`의 의미는 무엇인가? `>&2`와는 무엇이 다른가? (힌트: 표준 에러 리다이렉션 방향의 차이)
- `terraform apply -auto-approve`는 plan 확인 없이 바로 적용한다. CI에서는 왜 이것을 쓰는가? 사람이 직접 실행할 때는 왜 위험한가?
- `reconcile-vault-dev.sh`에서 `transit_tf_token="$(transit_login)"`을 왜 두 번 호출하는가? 한 번이면 안 되는가? (힌트: policy 업데이트 후 새 토큰이 필요)