들어가며
보안을 어디까지 챙겨야 할까? 과하면 업무 효율을 떨어뜨리는 과잉 규제가 되고, 미흡하면 기본적인 관리 소홀로 이어지기 때문이다. 실무에서 같은 인프라를 짠다면 어디서 멈출지를 계속 떠올리면서 선을 잡아갔다.
먼저 시스템에 고정된 비밀번호나 인증 키를 남기지 않는 것을 목표로 잡았다. AWS Access Key, SSH 키, kubeconfig 같은 중요한 인증 정보를 설정에 고정으로 심어두지 않는 방식이다. 실제 운영 환경에서 이런 고정 키가 유출돼서 일어나는 경우가 많을테니 유출될 만한 키 자체를 애초에 만들지 않는 게 가장 확실한 보안이라고 생각했다.
이것을 출발점으로 잡고 Edge → Network → IAM → App 4계층으로 나눠서 보안 설계를 했다.
1. Edge — WAF + CloudFront OAC + ALB TLS 종단
WAF에 AWS Managed Rule을 깐 이유
WAF 룰을 직접 짤 자신은 없었다. SQL Injection 패턴이나 XSS 우회 변형을 내가 일일이 정규식으로 잡는 건 현실적으로 효율성이 떨어진다고 판단했다. 그래서 AWS가 운영하는 Managed Rule 6종을 골라서 깔았다.
AWSManagedRulesCommonRuleSet— 공통 OWASP Top 10 계열AWSManagedRulesKnownBadInputsRuleSet— 알려진 악성 페이로드AWSManagedRulesAmazonIpReputationList— AWS가 추적하는 평판 나쁜 IPAWSManagedRulesSQLiRuleSet— SQL Injection 전용AWSManagedRulesLinuxRuleSet,AWSManagedRulesUnixRuleSet— OS 명령 주입
WAF는 CloudFront 앞단에 붙였다. ALB에 직접 붙이는 선택지도 있었는데, 이미 CloudFront로 정적/동적 트래픽을 다 받고 있어서 굳이 두 군데 붙일 이유가 없었다. 다만 CloudFront용 WAF는 반드시 us-east-1에 만들어야 한다는 제약이 있어서, dev/prod가 다른 리전이어도 WAF만큼은 버지니아에 따로 둬야 했다.
WAF는 CloudFront 앞단에 붙였다. ALB에 붙일 수 있었지만 WAF를 CloudFront와 ALB 양쪽에 다 붙이면 비용도 이중으로 들고, 트래픽이 WAF를 한 단계 더 거치면서 네트워크 지연(Latency)이 발생할 수 있지 않을까 생각했기 때문이다. 이 부분에 대해서는 아직 검증해보지 않아서 추측이다.
CloudFront용 WAF는 반드시 us-east-1(버지니아 북부) 리전에 만들어야 한다는 제약이 있다. 지금은 버지니아 리전에 인프라를 설정했기 때문에 상관 없지만 dev/prod 메인 인프라가 다른 리전에 있게 된다면 WAF는 버지니아에 따로 생성해 관리해야 한다.
CloudFront OAC — 이 CloudFront만 S3 읽을 수 있게
프론트엔드는 React 빌드 결과물이라 그냥 S3에 올리면 끝나는데 그 S3를 어떻게 보호할지가 고민이었다. 처음엔 S3 정적 웹사이트 호스팅을 켤까 했는데 그러면 S3가 공개되어 버린다. URL만 알면 누구나 직접 접근 가능하게 되어서 그건 싫었다.
그래서 Public Access Block 4종을 다 켜고, CloudFront만 OAC(Origin Access Control)로 S3를 읽을 수 있게 했다. 여기서 추가로 S3 버킷 정책에 AWS:SourceArn 조건을 걸어서 이 CloudFront 배포 한 개만 GetObject를 할 수 있게 했다.
# modules/cloudfront/main.tf
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.this.arn
}
}
이 조건 한 줄이 없으면 같은 계정의 다른 CloudFront도 이 S3를 읽을 수 있게 된다. 별 거 아닌 것 같지만 멀티 프로젝트 환경이라면 의미가 크다고 봤다. 운영 환경에서 한 팀이 만든 정적 자산을 다른 팀이 실수로 자기네 CDN으로 쏘는 사고는 꽤 흔할 것 같았다.
TLS는 어디서 끊을까
CloudFront ↔ ALB ↔ EKS 구간에서 TLS를 어디서 종단할지 정해야 했다. 효율성과 보안의 타협점을 고민한 끝에 내린 결론은 다음과 같다.
- 사용자 → CloudFront: 가장 바깥쪽 영역인 만큼 안전한 최신 암호화 규격(TLSv1.2_2021)을 적용했다. 80포트로 들어오는 평문 요청은 HTTP 301로 강제 리다이렉트시켜서 최초 진입부터 진입 자체가 불가능하도록 차단했다.
- CloudFront → ALB: CloudFront에서 ALB로 갈 때 AWS 백본망을 타긴 하지만 기본적으로 퍼블릭 인터넷 영역을 거치기 때문에 스니핑(도청)이나 중간자 공격을 막기 위해 HTTPS 통신을 강제했다.
- ALB → EKS(Istio Ingress): 여기부터는 외부인이 들여다볼 수 없는 완벽한 VPC 내부망이다. 패킷을 주고받을 때마다 암·복호화를 반복하면 서버 CPU 부하가 커지므로 성능 최적화와 패킷 전송 속도를 위해 HTTP로 풀었다.
인증서 발급과 도메인 관리
ACM 인증서는 와일드카드(*.shoong.cloud)로 받아서 DNS 자동 검증으로 발급했고, Route53 레코드까지 Terraform이 같이 만든다. Hosted Zone 자체는 콘솔에서 만든 걸 data 소스로 참조만 하게 했다.
다만 Hosted Zone 자체는 콘솔에서 만든 걸 data 소스로 참조만 하게 했다. 호스팅 영역을 Terraform이 직접 관리하게 하면 destroy했을 때 가비아에 연동해 둔 네임서버 설정까지 통째로 날아가 버리기 때문이다. 네임서버 주소가 새로 생성되면 가비아에 다시 등록하러 가야 하는 번거로움이 있어 변경이 잦은 레코드만 분리하여 IaC로 관리하기로 했다.
2. Network — 3-tier + SG 체이닝 + VPC Endpoint
서브넷을 3단으로 나눈 이유
VPC를 짤 때 Public / Private / DB 3-tier로 나눴다. AZ 3개를 쓰니까 서브넷이 총 9개가 됐다. 한 AZ만 쓰면 서브넷 3개로 끝나서 깔끔하지만 AZ 장애 시 통째로 죽는다. 비용이 좀 들어도 3개로 가는 게 맞다고 판단했다.
AZ를 2개만 써서 서브넷 6개로 타협할 수도 있었다. 하지만 특정 가용 구역에 장애가 발생했을 때 남은 인프라가 트래픽 부하를 안정적으로 나눠 가질 수 있는 완충 효과(N+1 구조)를 고려했다. 또한 EKS와 Multi-AZ 데이터베이스 환경에서 고가용성을 확보하려면 최소 3개의 AZ를 구축하는 것이 AWS의 권장 규격이라 비용이 조금 더 들더라도 추후 발생할 장애 리스크를 최소화하기 위해 AWS 가이드를 충실히 따르는 게 맞다고 판단했다.
- Public — ALB와 NAT Gateway(AZ별 각 1개). 외부에서 들어오는 진입점은 ALB. NAT은 Private subnet의 outbound용
- Private — EKS 워커 노드, Bastion EC2. 인터넷에서 직접 안 보임
- DB — RDS 전용. 자체 라우팅 테이블에 인터넷 경로 자체가 없음
RDS는 publicly_accessible = false에 더해서 아예 인터넷 경로가 없는 서브넷에 두는 이중 안전장치를 걸었다. 그리고 storage_encrypted = true로 EBS 볼륨 레벨 암호화도 켰다. RDS 암호화는 인스턴스 만들 때 안 켜면 나중에 못 켠다(스냅샷 복사로 우회해야 함)
Security Group은 CIDR 대신 SG 참조로
SecurityGroup(SG)를 짤 때 신경 쓴 부분은 CIDR을 쓰지 않고 보안 그룹 간의 참조로만 묶는 방식이었다.
10.0.0.0/16이나 10.0.11.0/24 같은 CIDR 대역을 인바운드 허용 조건으로 넣으면 당장은 편하겠지만 그 대역이나 서브넷 안에 새로 생성되는 모든 리소스까지 데이터베이스 접근 권한을 가지게 된다. 반면에 보안 그룹 자체를 참조하도록 설정하면 딱 그 보안 그룹이 부여된 특정 리소스만(ex: EKS 워커노드) 통과시킬 수 있다.
예를 들어 RDS SG의 인바운드 룰은 이런 식으로 짰다.
# modules/security_group/main.tf
resource "aws_security_group_rule" "rds_from_eks_node" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.rds.id
source_security_group_id = aws_security_group.eks_node.id # ← CIDR 아님
description = "PostgreSQL from EKS Node SG"
}
실제 운영 환경에서는 새로운 인프라가 추가되면서 IP 대역이 겹치거나 원치 않는 접근이 허용되는 보안 구멍이 생기기 쉬운데 이렇게 그룹 간 참조로 묶으면 최소 권한 원칙을 확실하게 지킬 수 있어서 이 방식을 최우선으로 뒀다.
다만 dev 환경에서만 Bastion을 통해 RDS 5432 포트로 직접 접근할 수 있도록 예외를 열어뒀다. allow_ssm_db_access라는 조건문 변수로 dev/prod 분기를 처리했는데, prod 환경에서는 이 변수가 false라 룰 자체가 생성이 안 된다. 프로덕션 환경에서 누군가 직접 DB에 psql로 붙는 리스크는 원천 차단해야 한다는 판단이었다.
참고로 RDS SG를 구축할 때는 inline ingress 룰을 쓰지 않고 전부 별도의 aws_security_group_rule 리소스로 분리해서 선언했다. 인라인 방식과 별도 리소스 방식을 섞어 쓰거나 혼선이 생기면 Terraform이 plan/apply를 할 때마다 리소스 상태의 충돌(Drift)을 감지해 인바운드 규칙이 무한으로 수정, 삭제되는 루프 이슈가 있었기 때문이다.
VPC Endpoint
Private Subnet에 있는 워커 노드라 해도 ECR에서 이미지 받거나 SSM으로 명령 받으려면 어쨌든 AWS API를 호출해야 한다. 그게 NAT Gateway를 거쳐 인터넷으로 나가면 결국 그 트래픽은 인터넷 망을 한 번 타게 된다.
보안성을 극대화하기 위해 AWS 공용 서비스들과 직접 사설 연결을 맺어주는 VPC Endpoint 7종을 도입했다.
- S3 Gateway (무료) — ECR 이미지 layer가 실제로 저장된 S3 접근용
- ECR API / ECR DKR (Interface) — 이미지 pull
- SSM / SSMMessages / EC2Messages (Interface) — Bastion 호스트 및 세션 매니저 접속용
- EKS (Interface) — 워커 노드가 EKS 컨트롤 플레인 API를 호출하기 위한 용도(kubectl)
이렇게 구성하면 워커 노드와 AWS API 간의 모든 트래픽이 외부로 나가지 않고 VPC 내부망 안에서만 완전히 격리되어 처리된다. 보안 효과(인터넷 노출 원천 차단)와 비용 효과(NAT 트래픽 감소)를 동시에 잡는 셈이다. VPC Endpoint SG는 VPC CIDR에서만 443을 허용해서 외부에서 접근 시도 자체를 차단한다.
다만 사용하는 모든 서비스에 엔드포인트를 붙인 건 아니다. 상대적으로 보안 리스크가 적거나 VPC Endpoint가 굳이 필요 없는 일부 서비스(CloudWatch Logs, STS 등) 호출은 여전히 NAT Gateway를 경유하도록 두어 인프라 비용의 균형을 맞췄다.
3. IAM — 장기 자격증명 0개, OIDC 4겹
정적 자격증명(AWS Access Key / SSH Key / kubeconfig 등)을 코드, 환경변수, 로컬 파일 어디에도 두지 않는다는 게 목표였다. 4가지 경로 모두 OIDC 기반 임시 토큰으로 대체했다.
1) GitHub Actions → AWS
GitHub Actions가 AWS에 인증할 때 보통 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 secret에 넣는다. GitHub OIDC로 바꾸면 키 자체가 없다.
# modules/iam_oidc/main.tf
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = [
for repo in var.github_repos : "repo:${var.github_org}/${repo}:*"
]
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
GitHub이 발급한 OIDC 토큰의 sub 클레임은 repo:<org>/<repo>:<context> 형태인데, 여기에 repo:shoong-delivery/shoong-order-api:*처럼 이 org의 이 repo가 보낸 토큰만 AssumeRole을 허용한다. aud 조건으로 sts.amazonaws.com도 강제했다.
이게 없으면 OIDC 자체는 깔려있어도 GitHub의 다른 어떤 repo든 이 Role을 가져갈 수 있게 된다. OIDC 도입했다고 안심하면 안 되는 부분이 여기다. 처음엔 그냥 sts.amazonaws.com aud 조건만 걸고 끝낼 뻔했다.
ECR 권한은 GetAuthorizationToken은 AWS 제약상 어쩔 수 없이 *로 줬지만 push/pull은 ECR repo ARN으로 좁혔다. 프론트엔드 S3 + CloudFront invalidation 권한도 분리해서 한 정책에 다 몰아넣지 않았다.
2) Pod → AWS API (IRSA 3종)
K8s Pod이 AWS API를 호출할 일이 생각보다 많다. AWS Load Balancer Controller는 ALB를 생성해야 하고, EBS CSI Driver는 EBS 볼륨을 프로비저닝해야 하며, External Secrets Operator(ESO)는 Secrets Manager나 Parameter Store를 읽어와야 한다. 만약 이 모든 권한을 워커 노드의 인스턴스 프로파일(Instance Profile)에 몰아넣으면 그 노드 위에서 도는 모든 Pod가 노드 권한을 공유하게 되기 때문에 보안상 문제가 생긴다.
이 문제를 해결하기 위해 찾아보니 Pod 단위로 AWS IAM 권한을 쪼개서 부여할 수 있는 IRSA(IAM Roles for Service Accounts)가 있어 추가했다.
Condition = {
StringEquals = {
"${local.oidc_host}:sub" = "system:serviceaccount:external-secrets:external-secrets"
"${local.oidc_host}:aud" = "sts.amazonaws.com"
}
}
테라폼으로 신뢰 관계(Trust Relationship)를 정의할 때 system:serviceaccount:: 형식을 지정하여 정확히 매핑된 특정 서비스 어카운트(SA)만 해당 Role을 AssumeRole 할 수 있도록 제한했다. 이렇게 하면 ESO용 Role을 권한이 없는 다른 Load Balancer Controller Pod가 가로채서 쓰는 등의 권한 탈취 리스크를 원천 차단할 수 있다.
3) 사람 → Bastion EC2
Bastion은 public IP를 안 줬고(associate_public_ip_address = false), 22번 포트도 안 열었다. 대신 SSM Session Manager로만 붙도록 했다. SSM은 IAM 권한으로 접속을 인증하기 때문에 따로 SSH 키를 관리할 필요가 없다.
# modules/bastion/main.tf
resource "aws_instance" "this" {
associate_public_ip_address = false
iam_instance_profile = aws_iam_instance_profile.this.name
# SSH 키 없음, 보안 그룹에 22번 인바운드 없음
}
이렇게 하면 SSH 키 분실/유출 자체가 시나리오에서 사라진다. 키가 없으니까. SSM은 권한이 끊기면 즉시 접속 차단이 되기 때문에 실무에서 담당자가 퇴사하게 된다면 IAM에서 권한만 빼면 끝이다.
4) 사람 → EKS API
클러스터 인증 정보가 담긴 kubeconfig 파일을 어디서 관리하느냐도 중요한 보안 이슈인 것 같다. 만약 이 파일을 로컬 PC에 평문으로 저장해 두고 다닌다면 노트북 분실이나 해킹이 곧 클러스터 통째로 노출되는 대형 사고로 이어질 수도 있기 때문이다.
이 문제를 해결하기 위해 EKS Access Entry를 도입했다. 로컬 PC의 접근을 아예 차단하고 SSM으로만 접속할 수 있는 Bastion EC2의 IAM Role만 EKS Access Entry에 등록해서 클러스터 관리자 권한을 부여했다.
# modules/eks/main.tf
resource "aws_eks_access_entry" "ssm_ec2" {
cluster_name = aws_eks_cluster.this.name
principal_arn = var.ssm_ec2_role_arn
type = "STANDARD"
}
이제 클러스터를 조작하려면 먼저 SSM으로 Bastion에 보안 접속한 뒤, aws eks update-kubeconfig 명령어를 통해 인증 정보를 일시적으로 생성해야 한다. 로컬에 영구 보관하는 파일이 아니라 통제된 원격 서버 안에서 세션이 유지되는 동안만 사용하는 방식이다. 세션을 끊으면 사라지게 된다.
이 흐름이 처음에는 좀 낯설었지만 익숙해지고 나니 오히려 과거에 kubeconfig 마스터 키를 로컬 PC에 들고 다녔던 것이 위험한 행동이었구나를 깨닫게 되었다.
4. App — 시크릿이 Git에도 환경변수에도 안 남게
매니페스트에 평문 시크릿이 들어가면 Git에 영원히 박힌다. 한번 push되면 force push로 지워도 fork된 곳, 로그, 캐시 어디든 남는다고 한다. 특히 GitHub에 올라오는 실시간 커밋을 그대로 긁어가는 자동 크롤러 봇들이 엄청나게 많다고 한다. 해커들이 AWS 키나 비밀번호 같은 시크릿만 전문적으로 스캔하는 봇을 24시간 돌린다고 한다...
그래서 K8s 매니페스트와 Helm values 어디에도 실제 시크릿 값을 적히지 않도록 노력했다.
값의 민감도에 따른 시크릿 관리
AWS에 시크릿을 저장할 때 Parameter Store와 Secrets Manager 둘 다 쓸 수 있다.
어떤 것인가에 따라 Parameter Store 또는 Secrets Manager를 사용할지 기준을 정했다.
- Parameter Store —
NODE_ENV,ORDER_API_URL같은 일반 환경변수 및 설정값 (Standard tier 기준 무료, String type) - Secrets Manager — DB 패스워드, API 마스터 키 등 자격증명 (KMS 암호화 기본 적용, 시크릿당 월 $0.40)
두 서비스를 조합한 이유는 값의 민감도가 엄연히 다른데 모든 값을 굳이 유료 서비스에 밀어 넣을 이유가 없다고 판단했기 때문이다. 서비스 URL이나 환경 이름처럼 유출되어도 치명적이지 않은 설정값들은 Parameter Store로 비용 없이 관리하고, DB 자격증명 같이 개발자조차 몰라야 하는 최고 등급의 민감 정보들은 돈을 내더라도 안전한 Secrets Manager에 격리해 두었다.
ESO로 K8s Secret 동기화
External Secrets Operator(ESO)가 백그라운드에서 12시간마다 자동으로 AWS에서 값을 읽어와 쿠버네티스 고유의 내장 객체인 K8s Secret으로 만들어준다. 그리고 실제 애플리케이션인 Deployment 매니페스트는 생성된 K8s Secret을 envFrom으로 마운트해 평범한 환경변수처럼 읽기만 하면 된다. 이 과정 덕분에 Git 리포지토리에 저장되는 매니페스트에는 실제 비밀번호가 아닌, AWS의 ARN이나 키 경로만 기록된다.
# shoong-gitops/eso/dev/external-secret-db.yaml
spec:
data:
- secretKey: password
remoteRef:
key: /shoong/dev/db-credentials
property: password
이제 이 YAML 파일이 퍼블릭 Git에 완전히 노출되어도 아무런 문제가 없다. 유출되면 위험한 실제 password 값은 오직 AWS Secrets Manager/Parameter Store에만 존재하기 때문이다.
게다가 이 값을 긁어오는 ESO Pod 자체도 앞서 언급한 IRSA를 통해 AWS API를 호출하므로 클러스터 내부에 AWS Access Key 같은 정적 인증 키를 저장할 필요가 없다.
(미해결)RDS 마스터 비번이 tfstate에 평문으로 남는 문제
여기까지 듣고 깔끔해 보이지만 사실 한 군데 구멍이 있다. RDS 마스터 비밀번호다.
현재 흐름은 사람이 콘솔에서 Secrets Manager에 비번을 미리 만들어두고, Terraform이 그걸 data 소스로 읽어서 RDS에 평문으로 전달한다. 이 과정에서 비번이 tfstate 파일에 평문으로 저장된다. tfstate는 S3 백엔드 + KMS 암호화 + 버전 관리로 보호하긴 하지만, 이상적이지는 않다.
개선안은 RDS의 manage_master_user_password = true 옵션을 쓰는 것이다. AWS가 자동으로 강력한 랜덤 비번을 생성하고 Secrets Manager 시크릿도 자동으로 만들어준다. Terraform이 비번 자체를 모르니까 tfstate에도 안 남는다. 추가로 aws_secretsmanager_secret_rotation으로 자동 회전까지 붙일 수 있다.
이건 아직 적용 안 한 상태다. 옵션 한 줄만 켜면 끝일 것 같지만 막상 흐름을 따라가 보면 여러 군데를 연쇄적으로 건드려야 해서이다..
지금 구조는 부트스트랩 스크립트(init.sh)가 환경변수로 받은 DB 비번을 Secrets Manager에 직접 넣고, ESO가 그걸 동기화해서 K8s Secret으로 만들고, Bastion에서 psql로 DB 초기화 SQL을 실행할 때도 그 환경변수를 그대로 쓴다. 사람이 비번을 안다는 전제로 전체 흐름이 짜여 있다.
manage_master_user_password = true로 바꾸고 init.sh 리팩토링과 ESO 매니페스트 수정을 같이해야해서 다음 고도화 작업에 적용할 예정이다.
5. CI 가드레일 — Trivy로 머지 차단
여기까지가 런타임 보안이고, 빌드 시점에서도 한 번 더 거르는 안전장치를 마련했다. CI 파이프라인에서 Trivy를 활용해 컨테이너 이미지의 CVE 취약점을 스캔하고, CRITICAL 또는 HIGH 등급의 취약점이 발견되면 ECR Push를 차단하도록 구성했다.
이 스캔 단계를 PR(Pull Request) 단계에 넣을지, Main 브랜치 Merge 후에 실행할지를 두고 고민이 많이 됐었다. PR 단계에서 스캔하면 취약한 코드가 머지되는 것을 원천 차단할 수 있지만 매번 빌드와 스캔이 중복 발생하여 개발 피드백 루프가 느려진다는 단점이 있었다. 반면 Merge 후에 하면 PR 속도는 빠르지만 이미 취약점이 포함된 코드가 저장소에 합쳐지게 된다.
결국 Merge 후 Trivy 스캔을 수행하는 방식을 선택했다. 어차피 최종 ECR Push 직전에 파이프라인이 차단되므로 운영 환경에 배포되는 이미지는 무조건 취약점 검증을 통과한 안전한 상태임이 보장되기 때문이다. 향후 애플리케이션 규모가 커질수록 CI 빌드 타임이 늘어날 텐데 PR 단계에서 무거운 빌드와 스캔 과정을 두 번씩 반복하며 파이프라인 리소스를 낭비하는 것보다 Merge 후 최종 단계에서 한 번 확실하게 검증하는 것이 효율적이라고 판단했다.
'Project: Shoong-Delivery' 카테고리의 다른 글
| [테스트] Load Test와 Spike Test 찍먹해보기 (0) | 2026.05.28 |
|---|---|
| [Observability] EKS MSA 옵저버빌리티 통합 — Prometheus + Loki + Tempo + Grafana (0) | 2026.05.22 |
| [AWS] Shoong Delivery 네트워크 설계 정리 (0) | 2026.05.20 |
| [자동화] terrafom apply 후 수동 작업 대신 자동화 스크립트 (0) | 2026.05.20 |
| [Terraform] shoong-delivery 테라폼 구조 및 회고 (0) | 2026.05.20 |