부록 B. 셀프 체크 정답과 해설
1장
1-1. NEXT_PUBLIC_이 붙지 않은 환경 변수는 서버 전용으로 격리되어 서버 런타임(Route Handler, Server Action 등)에서만 읽을 수 있고 브라우저로 전달되지 않습니다. 반면 NEXT_PUBLIC_ 접두사가 붙은 변수는 빌드 시점에 실제 문자열 값이 클라이언트 자바스크립트 번들 파일 안에 평문으로 인라이닝되어 전 세계 누구나 브라우저 개발자 도구에서 볼 수 있습니다. 빌드 시점에 정적 파일로 구워지므로 대시보드에서 값을 수정한 뒤에는 반드시 재배포(Redeploy, 새 빌드)를 실행해야 브라우저에 반영됩니다.
1-2. 클라이언트와 함수 사이의 왕복은 요청 시 한 번뿐이지만, 서버리스 함수가 요청 하나를 처리할 때 데이터베이스와는 연결 수립(TCP+TLS+인증 핸드셰이크) 및 여러 번의 순차적 쿼리(인증 확인, 데이터 조회, 기록 저장 등)를 주고받으며 다중 왕복(Multiple RTT)을 수행합니다. 함수(iad1, 미국 동부)와 DB(서울) 사이의 1회 왕복 지연이 약 180ms라면 쿼리 몇 번과 연결 수립만으로도 1초 이상의 누적 네트워크 지연이 발생하므로, 함수 리전을 데이터베이스와 같은 서울(icn1)로 맞추어야 합니다.
1-3. 트랜잭션 모드(포트 6543)는 개별 트랜잭션(BEGIN~COMMIT)이나 단일 쿼리가 실행되는 짧은 순간에만 백엔드 Postgres 연결을 빌려 쓰고 즉시 풀에 반납하므로, 짧은 수명의 인스턴스가 급격히 늘어나는 서버리스 앱의 런타임 쿼리에 사용해야 연결 고갈을 막을 수 있습니다. 반면 세션 모드(포트 5432)나 직접 연결은 클라이언트가 연결을 끊을 때까지 백엔드 연결을 1:1로 점유하고 세션 수준 기능(Prepared Statement, SET, Advisory Lock 등)을 유지하므로 데이터베이스 스키마 마이그레이션(migrate) 작업에 분리해 사용합니다.
1-4. USING 절은 테이블에 이미 존재하는 기존 행 가운데 어느 행을 읽거나(SELECT), 수정 대상으로 삼거나(UPDATE), 지울 수 있는지(DELETE) 거를 때 평가됩니다. 반면 WITH CHECK 절은 INSERT나 UPDATE의 결과로 테이블에 새로 들어가거나 변경된 뒤의 행 상태가 정책을 만족하는지 검증할 때 평가됩니다. 만약 UPDATE 정책에 USING만 본인 소유로 걸려 있고 WITH CHECK 조건이 느슨하면(예: true), 사용자가 자기 행을 골라 수정하면서 소유자 ID(owner_id)나 소속 조직(org_id), 승인 상태를 타인의 것이나 권한 밖의 값으로 덮어써 버릴 수 있습니다.
1-5. 첫째는 프로젝트에 발급된 자동화 우회 시크릿을 담는 x-vercel-protection-bypass: <SECRET> 헤더이고, 둘째는 첫 진입 후 이어지는 페이지·리소스 요청들도 우회 상태를 유지하도록 브라우저에 우회 쿠키를 구워 주는 x-vercel-set-bypass-cookie: true(교차 사이트 테스트 시 samesitenone) 헤더입니다.
1-6. (1) 버킷 설정: 계약서는 민감한 개인 문서이므로 URL 유출 시 무단 열람을 막기 위해 반드시 비공개 버킷(Private Bucket, public = false)으로 생성합니다. (2) 업로드 흐름: Vercel 서버리스 함수의 요청 본문 크기 제한을 피하기 위해, 서버 API에서 먼저 사용자의 로그인 및 권한을 검증한 뒤 짧은 만료 시간을 가진 서명된 업로드 URL(createSignedUploadUrl)을 발급하고, 브라우저가 이 URL로 Supabase Storage에 파일을 직접 업로드하게 합니다. (3) 열람 흐름: 열람 요청 시 서버(또는 RLS)가 계약서 소유자인지 확인한 뒤 짧은 유효 시간(예: 60초)이 설정된 서명된 다운로드 URL(createSignedUrl)을 발급해 본인의 브라우저에서만 일시적으로 열람하게 합니다.
2장
2-1. 상태 잠금 없이 두 명이 동시에 terraform apply를 실행하면 두 프로세스가 동시에 상태 파일을 읽고 각자의 결과를 덮어쓰는 경쟁 상태(Race Condition)가 발생해, 한쪽이 생성한 자원이 상태 파일에서 유실되거나 JSON 구조가 파손되고 중복 생성·충돌 오류가 납니다. AWS S3 백엔드에서는 동시 실행을 막기 위해 상태 잠금(use_lockfile = true 또는 dynamodb_table)을 켜야 하고, 실수로 상태 파일이 잘못 덮어써지거나 손상되었을 때 이전 상태로 되돌리기 위해 S3 버킷 버전 관리(Bucket Versioning, 및 서버 측 암호화 encrypt = true)를 켜야 합니다.
2-2. ~(Update in-place)는 기존 클라우드 자원을 삭제하지 않고 유지한 채 설정 속성만 API로 제자리 수정한다는 뜻이고, -/+(Replace)는 불변 속성 변경 등으로 인해 기존 자원을 완전히 삭제(-)한 뒤 새 자원을 생성(+)한다는 뜻입니다. 운영 데이터베이스에서 -/+가 실행되면 DB 인스턴스와 내부 데이터가 삭제되므로, 리소스 블록 안에 lifecycle { prevent_destroy = true }를 설정해 삭제나 교체가 계획되면 plan 단계에서 즉시 에러로 차단되게 해야 합니다.
2-3. 코드에서 리소스 식별자만 aws_instance.app에서 aws_instance.api_server로 바꾸고 apply하면, Terraform은 이름이 바뀐 것을 알지 못해 기존 aws_instance.app 인스턴스를 완전히 삭제(-)하고 aws_instance.api_server를 새로 생성(+)해 서비스 중단과 데이터 초기화가 발생합니다. 중단 없이 상태 파일의 주소 매핑만 옮기려면 코드에 moved { from = aws_instance.app, to = aws_instance.api_server } 블록을 추가해야 합니다.
2-4. sensitive = true는 terraform plan과 apply를 실행할 때 터미널 화면(표준 출력 로그)에 값이 보이지 않게 (sensitive value)로 가려 줄 뿐이며, Terraform이 변경 여부를 비교하기 위해 저장하는 terraform.tfstate JSON 파일 안에는 여전히 비밀값이 평문(Plaintext)으로 기록되기 때문입니다. 이를 막으려면 (1) .gitignore로 *.tfstate의 Git 커밋을 원천 차단하고, (2) KMS 암호화와 최소 IAM 권한이 적용된 원격 백엔드(S3 등)에만 상태를 보관하며, (3) 상태 파일에 값이 남지 않는 Ephemeral 변수·쓰기 전용 속성(password_wo)이나 외부 Secrets Manager 참조를 사용해야 합니다.
2-5. 콘솔에서 수동으로 추가한 규칙은 드리프트(Drift) 상태이므로 그냥 두고 다음 배포(apply)를 돌리면 Terraform이 코드에 없는 규칙이라며 삭제해 버립니다. 따라서 (1) 먼저 .tf 코드의 보안 그룹 정의에 새벽에 수동으로 추가했던 포트 규칙을 똑같이 작성해 반영하고(신규 생성된 별도 자원이라면 import 블록 사용), (2) terraform plan을 실행해 클라우드 실제 상태와 코드 사이에 더 이상 변경 사항이 없음(No changes. Your infrastructure matches the configuration.)을 확인한 뒤 Git에 커밋·병합합니다.
2-6. PR이 열렸을 때는 아직 코드 리뷰와 승인을 거치지 않은 상태이므로, 만약 PR 워크플로에 프로덕션 쓰기(apply) 권한이 있는 역할을 주면 악의적이거나 실수가 담긴 PR 코드가 리뷰 전에 실제 인프라를 변경하거나 비밀을 탈취할 수 있습니다. 따라서 PR 워크플로에는 읽기 전용(plan) 역할만 부여하고, 실제 변경 권한이 있는 apply 역할은 코드 리뷰 후 main 브랜치에 병합되었을 때만 작동하도록 분리해야 합니다. apply 역할의 OIDC 신뢰 정책 Condition에서는 대상 서비스인 aud(sts.amazonaws.com)와 함께 정확한 저장소 및 브랜치/환경을 가리키는 sub(예: repo:조직/저장소:ref:refs/heads/main 또는 보호된 environment:production) 클레임을 반드시 검증해야 합니다.
3장
3-1. DNS 표준(RFC 1034/2181)상 어떤 도메인 이름에 CNAME 레코드가 있으면 그 이름에는 다른 어떤 종류의 레코드도 함께 존재할 수 없습니다. 그런데 루트 도메인(Zone Apex, example.com)에는 도메인 영역을 성립시키는 필수 레코드인 SOA와 NS 레코드가 반드시 존재해야 하므로 일반 CNAME을 넣을 수 없습니다. Route 53에서는 루트 도메인에 Alias 레코드를 설정해 내부적으로 ALB의 현재 IP(A/AAAA)를 찾아 표준 응답으로 내려주는 방식으로 해결합니다(Cloudflare에서는 CNAME Flattening 사용).
3-2. 전 세계 통신사 및 공용 재귀 DNS 리졸버들은 이전 질의 때 받아 간 구 서버 IP를 그 당시 설정되어 있던 TTL(86,400초 = 24시간) 동안 자신의 캐시에 보관하며, 그 시간이 만료되기 전까지는 권한 있는 네임서버에 다시 물어보지 않기 때문입니다. 이전 당일에 네임서버에서 TTL을 60초로 바꾸고 IP를 고쳐도, 이미 24시간 캐시를 쥐고 있는 리졸버들은 최대 24시간 동안 계속 구 서버 IP로 트래픽을 보냅니다. 따라서 최소 기존 TTL(24시간)보다 훨씬 이전에 미리 TTL만 60~300초로 낮추고 기존 캐시가 모두 만료되기를 기다린 뒤에 IP를 전환해야 합니다.
3-3. 사용자 브라우저에서는 ERR_TOO_MANY_REDIRECTS(무한 리다이렉트 루프) 오류가 발생해 사이트가 열리지 않습니다. 사용자는 Cloudflare에 https://로 접속하지만, Flexible 모드는 Cloudflare에서 원본 서버로 갈 때 평문 http://(포트 80)로 바꾸어 요청을 보내고, 원본 서버는 평문 HTTP 요청이 들어왔으니 다시 https://로 접속하라며 301/308 리다이렉트를 돌려보내는 과정이 끝없이 반복되기 때문입니다. 또한 엣지~원본 구간이 평문으로 노출되므로, 반드시 원본 서버에 유효한 인증서(공인 CA 또는 Cloudflare Origin CA)를 설치하고 Full (Strict) 모드를 사용해야 합니다.
3-4. 주황 구름(Proxied)은 DNS 질의에 대해서만 Cloudflare 엣지 IP를 돌려줄 뿐입니다. 만약 원본 서버의 방화벽(보안 그룹)이 전 세계(0.0.0.0/0)에 열려 있다면, 공격자가 과거 DNS 이력이나 스캔으로 알아낸 원본 서버의 실제 IP 주소로 직접 Host: 도메인 헤더를 붙여 HTTP 요청을 보낼 때 Cloudflare 엣지와 WAF를 아예 거치지 않고 완전히 우회할 수 있습니다. 이를 막으려면 원본 서버側에서 (1) 인바운드 방화벽을 Cloudflare 공개 IP 대역으로만 제한하거나, (2) **Authenticated Origin Pulls(mTLS 클라이언트 인증서 검증)**로 우리 Cloudflare 영역이 보낸 요청만 수락하거나, (3) 인바운드 포트를 모두 닫고 **Cloudflare Tunnel(cloudflared)**로만 연결해야 합니다.
3-5. 개인화 API(/api/profile)를 공유 CDN에 캐싱하면 첫 번째로 호출한 사용자의 개인정보나 Set-Cookie 세션 헤더가 엣지에 저장되어 뒤이어 접속한 다른 사용자들에게 그대로 노출되는 캐시 오염(개인정보 유출) 사고가 발생합니다. 또한 LLM 스트리밍(/api/chat/stream, text/event-stream)을 캐싱하거나 응답 버퍼링을 켜면 토큰이 실시간으로 한 글자씩 흘러가지 않고 스트림이 끝날 때까지 엣지에 묶여 있다가 한꺼번에 내려가거나 타임아웃이 발생합니다. 따라서 두 경로 모두 CDN 캐시 규칙에서 **Bypass Cache(캐시 우회)**로 설정하고, 개인화 API에는 Cache-Control: private, no-store를, SSE 스트리밍에는 Cache-Control: no-cache, no-transform(및 버퍼링 비활성화)을 지정해야 합니다.
3-6. 첫째, 자동 재시도 및 멀티 프로바이더 폴백(Universal Endpoint / Fallbacks) 기능을 구성해 주 모델(OpenAI)에서 503이나 타임아웃이 발생하면 코드 배포 없이 게이트웨이가 즉시 예비 프로바이더(예: Anthropic Claude 또는 Google Gemini)로 요청을 자동 우회시켜 서비스 중단을 막습니다. 둘째, **게이트웨이 속도 제한(Rate Limiting)**을 설정해 에이전트 루프 폭주로 시간당/분당 임계치를 넘는 과도한 호출이 발생하면 외부 LLM에 도달해 요금을 발생시키기 전에 게이트웨이에서 차단합니다. 셋째, 통합 관측성(Analytics/Logging) 및 결정적 프롬프트 캐싱을 켜서 모델별 지연 시간·오류율·비용을 한눈에 감시하고 반복되는 동일 질의는 캐시에서 즉시 응답해 비용과 부하를 줄입니다.
4장
4-1. 전통적인 경계형 VPN은 인증을 통과한 기기를 사내 네트워크(서브넷) 안으로 편입시켜 기본적으로 같은 망 안의 여러 서버와 포트에 접근할 수 있는 넓은 네트워크 수준 신뢰를 부여합니다. 반면 NIST SP 800-207 기반 ZTNA는 네트워크 위치로 신뢰를 주지 않고, 매 세션마다 신원과 기기 상태(Device Posture)를 검증해 그 역할에 허가된 개별 애플리케이션·리소스와 특정 포트에만 최소 권한 접근을 열어 줍니다. 따라서 단말 한 대가 악성코드에 감염되었을 때 전통 VPN에서는 내부망을 타고 인접 서버와 DB로 번지는 횡적 이동(Lateral Movement) 피해가 발생합니다.
4-2. 일반 웹 서버는 외부 클라이언트가 안쪽으로 들어오는 인바운드 연결을 기다려야 하므로 방화벽 인바운드 포트를 열어야 합니다. 반면 cloudflared(Cloudflare Tunnel)는 사내 프라이빗 서브넷 안의 데몬이 먼저 바깥쪽 Cloudflare 엣지 서버를 향해 아웃바운드(Outbound) 암호화 연결을 맺어 두고, 외부 직원이 Cloudflare Access 인증을 통과하면 이미 열려 있는 그 아웃바운드 터널을 통해 요청을 안쪽으로 전달받기 때문에 사내 방화벽의 인바운드 포트는 단 하나도 열 필요가 없습니다.
4-3. 192.168.1.0/24(및 192.168.0.0/24)는 대다수 가정용 와이파이 공유기와 카페 공유기가 기본값으로 사용하는 RFC1918 사설 IP 대역입니다. 사내 VPC도 똑같이 192.168.1.0/24를 쓰면 재택근무자의 로컬 공유기 네트워크와 사내 VPN 라우팅 경로가 정면으로 충돌(Overlapping Subnet Collision)해 패킷이 엉뚱한 곳으로 가거나 로컬 프린터·게이트웨이 통신이 끊깁니다. 해법은 사내 클라우드 VPC 대역을 가정용 공유기와 겹치지 않는 대역(예: 10.240.0.0/16 또는 172.28.0.0/16의 정밀한 서브넷)으로 설계하고, 분할 터널링(Include 모드)에는 실제로 필요한 사내 CIDR이나 CGNAT(100.64.0.0/10)·도메인만 등록하는 것입니다.
4-4. 사설 IP 자체로는 외부에서 직접 연결할 수 없더라도, 외부 공인 DNS에 사내 서버 이름(db.dev, payroll 등)과 내부 사설 IP 대역을 등록해 두면 공격자가 DNS 조회를 통해 사내에 어떤 핵심 시스템이 있고 네트워크 대역이 어떻게 구성되어 있는지 내부 토폴로지 정보를 손쉽게 수집할 수 있습니다. 이를 막기 위해 사내 도메인은 외부 공인 DNS에서 지우고 사내 프라이빗 DNS에만 등록한 뒤, ZTNA/VPN 클라이언트가 해당 도메인 접미사(*.internal.corp) 질의만 터널 내부 DNS 리졸버로 보내도록 **분할 시야 DNS(Split-Horizon DNS / 조건부 포워딩)**를 구성합니다.
4-5. NODE_TLS_REJECT_UNAUTHORIZED=0은 사내 CA만 허용하는 것이 아니라 TLS 인증서 검증 자체를 완전히 꺼 버리므로, 외부망에서 해커가 위조 인증서로 수행하는 중간자 공격(MITM)에 무방비로 노출됩니다. 올바른 방법은 TLS 검증을 유지한 채 사내 루트 CA 인증서를 신뢰 목록에 등록하는 것입니다. Node.js(및 Node 기반 코딩 에이전트)에는 기존 내장 CA 목록에 사내 CA를 추가해 주는 NODE_EXTRA_CA_CERTS="/경로/사내-root-ca.pem"을 설정하고, 기본 번들을 교체해 버리는 Python·OpenSSL 계열 도구에는 기존 공인 루트 CA 번들과 사내 루트 CA를 하나로 합친 통합 PEM 파일을 만들어 SSL_CERT_FILE 및 REQUESTS_CA_BUNDLE에 지정해야 합니다.
4-6. (1) RDS 연결 구성 요소: AWS RDS에는 VPN/ZTNA 에이전트를 직접 설치할 수 없으므로, 같은 VPC 프라이빗 서브넷에 서브넷 라우터(Subnet Router) 또는 cloudflared 프라이빗 네트워크 커넥터를 이중화(2대 이상)하여 배치합니다. (2) 포트·대상 제한 수단: 외주 개발자 그룹(group:contractors)을 출발지로 하고 스테이징 RDS의 IP와 5432 포트만 목적지로 허용하는 **최소 권한 ACL(기본 거부)**을 적용해 SSH(22)나 다른 사내 서버로의 횡적 이동을 차단합니다. (3) 퇴사 시 자동 회수: 사내 IdP와 ZTNA/VPN을 SCIM 프로토콜로 연동해 계약 종료 시 계정이 즉시 자동 비활성화되고 활성 터널 세션과 토큰이 강제 종료되게 합니다.
5장
5-1. 이중 키(Dual-Key) 무중단 회전 4단계는 (1) 새 키(V2)를 생성해 서버 검증 코드가 [V2, V1] 두 키를 모두 허용하도록 먼저 배포, (2) 새 토큰 서명 및 웹훅 발신 설정을 V2로 전환, (3) 기존 토큰 유효 기간(TTL) 동안 대기하며 로그에서 V1 사용량이 0으로 줄어드는지 확인, (4) 검증 목록에서 V1을 제거하고 폐기하는 순서입니다. 반면 외부 API 키가 공개 저장소에 유출된 경우에는 공격자가 유예 기간 동안 그 키를 악용해 데이터를 탈취하거나 막대한 과금을 유발할 수 있으므로, 새 키를 배포하는 즉시 유예 기간 없이 노출된 구 키를 영구 폐기(Revoke)해야 합니다.
5-2. RPO(복구 목표 시점)는 장애 시 감내할 수 있는 최대 과거 데이터 유실 시간 폭이고, RTO(복구 목표 시간)는 장애 발생부터 서비스 정상 재개까지 허용되는 최대 복구 소요 시간입니다. 매일 새벽 4시에만 스냅샷을 찍는 구조에서는 새벽 4시 이후부터 오후 2시 사고 시점까지 10시간 동안 쌓인 변경 이력이 스냅샷에 없으므로 복원 시 10시간 치 데이터가 모두 유실됩니다. 이를 해결하려면 기초 백업 위에 모든 변경 로그(WAL, Write-Ahead Log)를 지속 아카이빙해 오후 2시 사고 쿼리 실행 직전(초 단위)으로 되돌릴 수 있는 **시점 복구(PITR, Point-in-Time Recovery)**를 사용해야 합니다.
5-3. 롤링 배포 중에는 아직 종료되지 않은 구버전 애플리케이션 인스턴스와 새로 뜬 신버전 인스턴스가 동시에 같은 데이터베이스에 쿼리를 보냅니다. 이때 RENAME COLUMN이나 DROP COLUMN을 바로 실행하면 구버전 인스턴스가 기존 컬럼을 찾지 못해 즉시 500 오류를 내며, 신버전에 문제가 생겨도 구버전 코드로 롤백할 수 없게 됩니다. Expand-Contract 패턴은 (1) Expand(확장): 기존 컬럼을 둔 채 새 컬럼을 Nullable로 추가, (2) Dual-Write & Backfill: 앱이 신·구 컬럼 양쪽에 모두 쓰게 배포하고 과거 데이터를 백필, (3) Cutover(전환): 앱이 새 컬럼만 읽고 쓰도록 배포, (4) Contract(수축): 구버전 인스턴스가 완전히 사라진 뒤 구 컬럼을 삭제하는 순서로 진행합니다.
5-4. 대다수의 빠른 정적·캐시 응답(예: 10ms)이 섞여 있으면 소수 사용자가 겪는 심각한 병목(예: 20초 지연)이 평균값(Average)에 희석되어 정상처럼 보이기 때문에, 가장 느린 하위 5%·1% 요청의 실제 체감 지연을 보여 주는 **p95·p99(테일 레이턴시)**를 봐야 합니다. 로그와 트레이스를 수집할 때는 (1) Authorization 헤더와 세션 쿠키(Cookie/Set-Cookie), (2) API 비밀키와 데이터베이스 연결 문자열 비밀번호, (3) 사용자 비밀번호·신용카드 번호·주민등록번호 등 민감 개인정보를 반드시 스크러빙(마스킹)해야 합니다.
5-5. 과부하에 빠진 하위 시스템에 모든 서버 인스턴스가 대기 없이 동시에 재시도를 폭격하면, 원래 트래픽에 재시도 트래픽까지 몇 배로 겹쳐 하위 시스템이 아예 복구 불능 상태로 완전히 무너지는 **재시도 폭풍(Retry Storm / Thundering Herd)**과 연쇄 장애가 발생합니다. 이를 막으려면 재시도 간격을 점점 늘리면서 무작위 시간 편차를 섞는 **지수 백오프와 지터(Exponential Backoff with Jitter)**를 적용하고, 연속 실패가 임계치를 넘으면 하위 호출을 즉시 차단해 폴백을 돌려주는 **서킷 브레이커(Circuit Breaker)**를 두어야 합니다.
5-6. 장애가 진행 중인 상태에서 원인을 분석하고 새 코드를 짜서 테스트·배포하느라 수십 분을 허비하면 그 시간 동안 모든 결제 고객이 피해를 입기 때문입니다("완화 우선, 분석은 나중" 원칙 위반). 처음 10분 안에는 디버깅 대신 **즉시 직전 정상 배포본으로 롤백(Instant Rollback)**하거나 문제의 신규 기능을 기능 플래그(Kill Switch)로 꺼서 오류율을 먼저 0%로 가라앉혀야 합니다. 장애가 끝난 뒤에는 특정 개인을 비난하지 않고 "왜 테스트나 카나리 단계에서 이 오류가 걸러지지 않았는가"라는 시스템적 원인과 구체적인 재발 방지 액션 아이템을 기록하는 **비난 없는 사후 회고(Blameless Postmortem)**를 작성합니다.