한 줄 요약: prod ALB에 AWS WAF(L7 방어)를 세우고, 그 과정에서 드러난 콘솔 드리프트를 전수 조사해 Terraform으로 흡수했으며, prod 평문 시크릿을 Secrets Manager로 무중단 이전했다. 인프라 보안 3건(방화벽·IaC 정합·시크릿)을 하루에 닫은 날.
modules/waf 신설, apply·검증terraform import — agentcore SG·S3 templates 흡수, relay(bastion) 정리, 최종 plan "No changes"farmily/prod/app, task def secrets[]화, 무중단 배포 성공(prod-app:16)prod ALB 로그에 ThinkPHP/PHP RCE 스캐너 probe(/index.php?s=..., /xmlrpc.php)가 꾸준히 들어오고 있었다. 우리 백엔드는 Spring(Java)이라 404로 무해하지만, ① 요청이 ECS까지 도달해 컴퓨트를 쓰고 ② 정찰 신호라 brute-force·flood로 번질 수 있다. ALB 앞단에 차단 계층이 0이라는 게 진짜 문제였다.
설계의 첫 단추는 위협을 계층으로 나누는 것이었다.
aws shield get-subscription-state = INACTIVE = Advanced 미가입 = Standard로 동작 확인)Shield Advanced는 월 $3,000인데, 그게 주는 L7 방어는 WAF rate-limit으로 직접 커버되고 비용 보호도 우리 규모엔 효용이 없어 Standard 유지. 이 트레이드오프 자체가 면접 단골 질문이다.
룰 설계는 임의로 하지 않고 AWS Security Blog "The three most important AWS WAF rate-based rules"(Shield Response Team)를 근거로 잡았다. SRT가 꼽은 3대 룰 = ① Blanket(전역) ② URI-specific(로그인) ③ IP reputation. 우리 모듈이 정확히 이 셋을 포함한다(+ KnownBadInputs·Common 보너스). Blanket 한도 2,000/5분도 AWS 예시값 그대로.
p5 rate-limit-auth /api/v1/auth/* 5분 100회 (scope_down) Block
p10 rate-limit-global 전역 IP 5분 2000회 Block
p20 AmazonIpReputation 악성/스캐너 IP Block
p30 KnownBadInputs RCE/exploit 페이로드 Block
p40 CommonRuleSet SQLi/XSS Count(관측)→Block 승격
CommonRuleSet만 Count로 시작한 게 핵심 설계다 — managed 룰은 정상 요청을 공격으로 오인(false positive)할 위험이 있어, 며칠 관측 후 오탐이 없으면 common_rule_action="none"(변수 토글)으로 차단 승격한다. AWS도 "로그로 임계값 정하고 위험한 룰은 Count 카나리 후 Block"을 권장한다.
apply에서 진짜 함정을 만났다. web ACL description에 괄호 ()를 넣었더니 ValidationException — WAFv2 description은 정규식으로 허용 문자가 제한(괄호 불가)된다. 괄호를 빼니 통과. log group 이름도 반드시 aws-waf-logs- 프리픽스여야 로깅이 붙는다.
검증은 aws wafv2 get-web-acl-for-resource로 ALB ARN에 prod-alb-waf(룰 5개)가 연결됐는지 확인했다.
WAF를 apply하려다, 팀이 AgentCore 준비하며 콘솔로 만든 SG·S3가 Terraform 밖에 있다는 걸 알았다. 이게 위험한 이유는 우리 SG가 inline ingress 방식이기 때문이다 — TF가 그 SG의 규칙 전체를 소유하므로, 콘솔로 추가한 규칙은 다음 apply가 삭제한다. 무관한 작업(WAF) 한 번에 엉뚱한 규칙이 날아갈 수 있었다.
추측 대신 terraform plan을 권위 도구로 전수 조사했다(prod·dev). 내가 추가한 코드 변경을 "예상"으로 분리하니, 진짜 드리프트는 둘뿐이었다.
farmily-relay-sg)이 RDS(5432)·Redis(6379) SG에 콘솔로 부착돼 있었다. 이 bastion은 launch-wizard로 만든 EC2인데 SSH·5432가 0.0.0.0/0(전 세계) 오픈 — 별도 보안 위험. (IaC 정리가 보안 점검으로 이어진 사례)source_code_hash 불일치. 알고 보니 드리프트가 아니라 6/11 커밋했지만 배포 안 된 코드였다(archive_file은 해시가 내용 결정적이라 git으로 추적 가능).콘솔 리소스는 코드를 실제와 1:1로 쓴 뒤 terraform import로 흡수했다. 여기서 또 함정 — SG의 name은 변경 불가(ForceNew) 속성이라, 모듈 컨벤션(prod-)을 따르려 이름을 바꾸면 import 후 plan이 "destroy+create"로 재생성을 시도한다. 그래서 콘솔 생성명 farmily-agentcore-sg를 그대로 코드에 박아 깨끗하게 import했다. S3 버킷 import는 객체(파일)를 건드리지 않아 템플릿 HTML은 그대로 보존됐다.
import 중 팀원(민석)이 prod state 락을 잡고 있어 한 번 막혔다. Lock Info에 누가·언제 잡았는지 나오는데, active 락은 절대 force-unlock하지 않았다(state 손상 위험). 해제 후 fresh plan으로 재검증하고 재개. 최종 terraform plan = "No changes" = 드리프트 0 달성. 모든 변경은 commit → PR #25 → 머지로 기록.
dev는 6/12에 끝냈고 오늘 prod 차례. 가장 중요한 원칙부터 — 시크릿 값은 Terraform에 두면 안 된다. TF로 시크릿을 만들면 값이 state(S3)에 평문 저장돼 문제를 키운다. 그래서 값은 콘솔(IaC 밖), 구조·IAM만 코드. 이건 속도 타협이 아니라 보안상 올바른 선택이다.
prod extra_environment를 키별로 분류해 시크릿 9개(DB_PASSWORD·JWT·KAKAO×2·KMA·PORTONE×3·FCM)를 가렸다. 비밀 아닌 것(KAKAO_CLIENT_ID·PORTONE_IMP_CODE·*_TEST_MODE 등)은 env에 남겼다. task def 새 리비전에서 9개를 environment → secrets[](valueFrom ARN)로 옮겼다.
valueFrom 문법: arn:...:secret:farmily/prod/app-<suffix>:KEY:: — 키 뒤 콜론 2개(version 자리 비움), 키 대소문자 정확.
배포에서 dev와 똑같은 함정이 재현됐다.
ResourceInitializationError: retrieved secret from Secrets Manager
did not contain json key DB_PASSWORD
시크릿에 DB_PASSWORD 키가 빠져 있었다. 하지만 prod는 한순간도 안 끊겼다 — 구 task(prod-app:15)가 그대로 서빙했다. Circuit Breaker + minHealthy 100%가 "새 task가 헬스 통과해야 구 task가 빠진다"를 보장하기 때문. 키를 채우고 --force-new-deployment로 재시도하니 running 1 / failed 0으로 정상 기동, 롤아웃 COMPLETED. prod-app:16로 전환 완료.
ㅁㅁ
get-subscription-state=INACTIVE로 Standard 동작 확인.AWSManagedRulesAmazonIpReputationList는 AWS가 자사 위협 인텔로 상시 갱신하는 관리형 룰이고 우리는 이름만 참조($0). rate-limit(우리 트래픽 행동) vs IP reputation(AWS의 전역 평판) — 신호가 다른 두 층.ingress {}는 TF가 규칙 전체를 소유 → 콘솔 추가분은 apply가 삭제. terraform plan이 최고의 드리프트 탐지기(단, TF가 아는 리소스만).extra_environment에서 빼야 state에서도 빠진다(DB_PASSWORD는 RDS가 필요로 해 예외 → RDS-managed password가 후속 해법).FCM_SERVICE_ACCOUNT_JSON)의 값에 통째로. private_key의 \n은 literal 유지.source_code_hash 불일치 = 진짜 코드 변경(phantom 아님) → git으로 미배포 커밋임을 규명.terraform plan을 진단 도구로 먼저 돌린 게 사고를 막았다.DB_PASSWORD 키 누락으로 실패. dev에서 한 번 겪고도 prod에서 재현 — 체크리스트("배포 전 9개 키 전부 확인")를 절차로 박아야 한다. 단, Circuit Breaker 덕에 무중단이었던 게 설계의 승리.