🔥 버킷 정책과 ACL: 공개와 비공개의 경계
강의 목차

처음 S3 버킷에 파일 하나를 올리고 콘솔이 보여 준 Object URL을 그대로 외부 브라우저에 붙였더니 AccessDenied가 돌아왔다. 나는 그 순간에 의아했다. 콘솔에서는 내 계정으로 객체를 올렸고, IAM은 통과했고, URL도 올바른데 왜 외부 브라우저에서는 안 보일까. 한참 docs를 따라간 끝에 한 줄이 답이었다. S3는 기본값이 비공개다. 외부에서 보이게 하려면 별도로 권한을 열어 줘야 한다. 그런데 그 별도가 단순하지 않았다. 버킷 정책, 객체 ACL, 그리고 그 위에 또 한 겹 차단막인 Block Public Access가 있었다.
처음에는 왜 권한 모델이 이렇게 여러 겹인가가 가장 어려웠다. 한 군데서 허용하면 끝일 것 같은데, 실제로는 네 가지 검사를 모두 통과해야 외부에서 객체에 닿는다. 이 글은 그 네 겹이 왜 따로 있는지, 어디서 어떻게 평가하는지를 짚는다. 그리고 가장 자주 사고가 나는 공개로 노출된 버킷이 어떤 형태인지 한 번 묶어 본다.
S3란 무엇인가: 오브젝트 스토리지의 구조에서 S3가 객체 스토리지로 어떻게 동작하는지 봤고, 버킷과 키: 플랫 네임스페이스의 의미에서 키가 디렉토리가 아니라 평면 문자열임을 봤다. 정책 평가 흐름: Allow와 Deny의 교차에서 IAM의 평가 규칙도 봤다. 이번 글은 그 위에 S3가 자기 단계로 더하는 정책 모델 두 가지, bucket policy와 ACL, 그리고 그 둘 위에 한 겹 더 있는 차단막인 Block Public Access를 같이 본다.
본 글은 2026년 4월 시점 스냅샷이다. 서울 리전(ap-northeast-2) 기준으로 작성했다.
권한 모델이 네 겹인 이유
S3에서 어떤 요청을 허용할지 결정하는 검사는 네 가지다. 순서대로 적으면 다음과 같다.
- 호출자의 IAM identity-based 정책. 이 사용자/역할이 이 객체를 GET 할 수 있나
- 버킷의 bucket policy. 이 버킷이 이 호출자에게 GET을 허용하나
- 객체의 ACL. 이 객체 자체에 grantee가 명시되어 있나
- Block Public Access. 공개로 평가하는 권한을 일괄 차단하나
이 네 가지가 다른 이유는 각각이 다른 시기에, 다른 문제를 풀려고 들어왔기 때문이다. ACL은 S3가 처음 출시된 2006년 그때부터 있었다. 그때는 IAM도 bucket policy도 없었다. ACL이 유일한 권한 모델이었다. IAM이 2010년에 나오고 bucket policy가 그 무렵 함께 들어오면서 권한 표현을 JSON 정책으로 옮겨 갔다. 하지만 ACL은 사라지지 않았다. 이미 수많은 버킷이 ACL로 권한을 잡고 있었기 때문에 호환을 위해 그대로 살아남았다.
그러다 2017–2018년 사이에 S3 버킷이 통째로 공개되어 데이터를 잃었다는 사고가 언론에 연이어 등장했다. AWS는 한 줄짜리 차단막이 필요했다. 그래서 2018년 11월에 Block Public Access (BPA)를 출시했다. 위 세 가지 검사 결과가 어떻든, BPA가 켜져 있으면 공개로 평가하는 모든 권한을 무력화한다. 정책을 잘못 적었거나 ACL을 깜빡 켰을 때 마지막으로 막아 주는 안전장치다.
그래서 권한 모델이 네 겹으로 보이는 건 S3의 역사가 한 번에 설계된 적이 없기 때문이다. 새 모델을 도입할 때 옛 모델을 지우지 않고 그 위에 쌓아 올린 결과다.
Bucket Policy, 버킷 단위 JSON 정책
Bucket policy는 버킷에 직접 붙이는 resource-based JSON 정책이다. IAM identity-based 정책과 형식은 비슷한데, 한 가지가 다르다. Principal 필드가 필수다.
IAM identity-based 정책은 사용자/역할에 붙으니 누가 이걸 쓰는지가 이미 정해져 있다. 그래서 정책 안에 Principal을 적지 않는다. Bucket policy는 반대다. 버킷에 붙어 있으니 누구에게 허용할 것인가를 정책 안에 명시해야 한다. 다른 AWS 계정에게 허용하는 cross-account 액세스도 이 곳에서 표현한다.
가장 단순한 형식은 다음과 같다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAnalyticsTeamRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/AnalyticsTeam"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-data/*"
}
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAnalyticsTeamRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/AnalyticsTeam"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-data/*"
}
]
}여기서 한 가지를 짚고 가야 한다. Resource가 arn:aws:s3:::my-data/*로 끝나는 형식. 객체 ARN은 버킷 ARN 뒤에 슬래시와 키가 붙는다. arn:aws:s3:::my-data는 버킷 그 자체이고, arn:aws:s3:::my-data/*는 그 버킷 안의 모든 객체다. 둘은 다른 자원이라서 권한도 다르다. s3:GetObject는 객체 ARN에 매칭하고, s3:ListBucket은 버킷 ARN에 매칭한다.
이 차이를 깜빡하면 권한은 줬는데 안 보이는 상황이 생긴다. 예를 들어 s3:GetObject만 객체 ARN에 허용하고 s3:ListBucket을 버킷 ARN에 안 줬다면, 호출자는 키를 정확히 알면 GET 할 수 있지만 버킷 안에 무엇이 있는지 목록을 못 본다. 둘이 다른 권한이기 때문이다. 버킷과 키: 플랫 네임스페이스의 의미에서 봤듯 S3에서 키는 평면 문자열이라 디렉토리 단위 권한이라는 개념이 없고, 버킷 단위와 객체 단위 두 가지로만 권한을 잡는다.
Principal에 *을 적으면 익명 호출자에게도 허용한다는 뜻이다. 이게 흔한 사고의 출발점이다. 정적 웹사이트를 호스팅하려고 GET을 누구에게나 열어 두면 (정확히 그 객체만 공개하려던 의도였는데) Resource: "arn:aws:s3:::my-bucket/*"로 적어 놓으면 버킷 안 모든 객체에 익명 액세스가 통과한다. 다음 절에서 다시 짚는다.

Block Public Access, 마지막 차단막
Bucket policy가 잘못 적힐 수 있고, ACL이 옛날 버킷에서는 켜져 있을 수 있고, IAM identity-based 정책이 너무 넓을 수도 있다. 공개는 그 결과로 나오는 평가 결과다. Block Public Access는 그 평가 결과를 마지막에 한 번 더 잘라낸다.
옵션은 4개다.
BlockPublicAcls.public-read같은 ACL을 생성하는 요청을 거부한다.IgnorePublicAcls. 이미 있는 public ACL을 평가에서 제외한다.BlockPublicPolicy. S3가 공개로 평가하는 bucket policy를 PutBucketPolicy 시점에 거부한다 (대표적으로Principal: "*"이 있는 정책).RestrictPublicBuckets. 정책이 어쩌다 public으로 평가되더라도, 익명 또는 다른 AWS 계정의 호출은 차단한다.
이 4개는 AWS docs의 표현으로 independent하고 어떤 조합으로든 켤 수 있다. 4개 중 하나가 켜져 있고 그 옵션이 막아야 한다고 판정하면 그 항목을 막는다. 결과적으로 사용자에게 닿는 효과는 OR로 결합된 것과 같다. 4개 모두 켜져 있는 게 default다.
같은 4개가 계정 단위에도 있다. AWS Organizations에서 모든 자식 계정에 일괄 적용할 수도 있고, 한 계정 안에서 켜 두면 그 계정의 모든 버킷에 적용한다. 그래서 BPA의 평가 결과는 두 단계로 굳어 있다.
요청이 공개로 막히는 조건:
버킷 BPA의 한 옵션이라도 켜져 있고 그 옵션이 막아야 한다고 평가
OR 계정 BPA의 한 옵션이라도 켜져 있고 그 옵션이 막아야 한다고 평가요청이 공개로 막히는 조건:
버킷 BPA의 한 옵션이라도 켜져 있고 그 옵션이 막아야 한다고 평가
OR 계정 BPA의 한 옵션이라도 켜져 있고 그 옵션이 막아야 한다고 평가두 단계가 OR로 결합한다. 한 단계라도 막으면 그 요청은 막는다. 그래서 계정 단위에서 4개를 다 켜 두면, 그 계정 안에서는 어떤 버킷도 실수로 공개가 안 된다. 이게 운영적으로 가장 안전한 default 셋업이다.
2023년 4월 28일에 한 가지가 바뀌었다. 그 이후 새로 생성하는 모든 버킷은 BPA 4개가 모두 켜진 상태로 시작한다. 그 전에는 4개 중 일부 또는 전부가 꺼진 상태로 시작하는 default가 길게 살아 있었다. 이 변경 덕에 새로 만든 버킷에서 기본적으로 공개 사고가 안 난다. 공개로 열려면 사용자가 직접 BPA를 끄고, 그 위에 정책이나 ACL로 권한을 열어야 한다. 두 단계가 의도되지 않으면 공개될 일이 없다는 구조다.

ACL의 역사적 흔적, 오늘 새 버킷에는 의미가 없다
S3가 2006년에 출시될 당시, 권한을 표현하는 유일한 방법이 ACL이었다. ACL은 객체나 버킷에 grantee와 permission의 쌍을 매다는 모델이다. grantee는 다른 AWS 계정의 canonical user ID이거나 미리 정의한 그룹 (대표적으로 AllUsers (익명을 포함한 임의 호출자), AuthenticatedUsers (AWS 어떤 계정이든 사인-인된 호출자), 로그 전송용 LogDelivery) 중 하나다. permission은 READ, WRITE, READ_ACP, WRITE_ACP, FULL_CONTROL 5가지다.
오랫동안 ACL과 bucket policy는 공존했다. 같은 버킷에 둘 다 적용되어 있고, 평가는 합집합이었다. 어느 한 쪽이라도 허용하면 허용. 이게 흔한 사고의 또 다른 출처다. 정책으로는 비공개로 잡았는데, 콘솔에서 무심코 Make public 버튼을 눌러 ACL이 AllUsers: READ로 바뀐 객체가 외부에 노출되는 식이다.
2020년 10월에 AWS가 Object Ownership이라는 기능을 출시했다. 처음에는 BucketOwnerPreferred 모드 한 가지였고 (cross-account PutObject 객체의 소유자를 자동으로 버킷 소유자로 바꿔 주는 기능) 2021년 11월 30일에 AWS가 ACL 자체를 무력화하는 Bucket owner enforced 모드를 추가로 발표했다. Bucket owner enforced를 켜면 모든 객체의 소유자를 버킷 소유자로 통일하고, ACL을 명시적으로 적용해도 무시한다. 그래서 권한은 오로지 IAM 정책과 bucket policy로만 결정한다.
2023년 4월부터는 Bucket owner enforced가 신규 버킷의 default다. 그래서 오늘 새로 만든 버킷에서는 ACL이 의미가 없다. 기존 버킷에서도 콘솔에서 한 번 토글하면 ACL을 비활성화한다. AWS 자신도 ACL을 legacy access control mechanism이라고 docs에서 표기한다.
그럼 ACL은 언제 만나는가. 세 곳이 있다.
첫째, 2023-04 이전에 만들어진 옛 버킷. Object Ownership을 명시적으로 토글하지 않은 상태로 살아 있다면 ACL을 여전히 평가한다. 일종의 archaeology, 운영 중인 옛 시스템을 만지면 마주칠 수 있다.
둘째, cross-account s3:PutObject 흐름. 다른 계정의 호출자가 내 버킷에 객체를 올렸을 때, 그 객체의 소유자는 기본적으로 호출자 계정이다. 내 계정은 그 객체를 GET 할 수 없다. 이 단계를 풀려고 옛날에는 PutObject 요청에 ACL을 bucket-owner-full-control로 함께 보내라고 docs가 가르쳤다. Bucket owner enforced를 켜면 AWS가 이 흐름 자체를 통째로 걷어낸다. 버킷 소유자가 자동으로 객체 소유자가 되니까.
셋째, CloudFront Origin Access Identity (OAI)의 잔재. 옛날 CloudFront가 비공개 S3 버킷에 닿으려면 OAI라는 사용자를 만들고 그 사용자에게 권한을 줬다 (정확히는 OAI도 bucket policy를 쓸 수 있었지만 ACL로 READ를 주는 형태가 흔했다). 지금은 Origin Access Control (OAC)이 권장 방식이고, bucket policy 기반으로 동일한 흐름을 더 단순하게 만든다. 콘솔에서 새 distribution을 붙일 때도 OAC가 권장 default로 떠 있다. 옛 OAI 셋업이 살아 있는 사이트에서만 ACL이 한 번 더 등장한다.
요약하면, 오늘 새 버킷을 만들고 새 distribution을 붙이는 흐름에서는 ACL을 신경 쓸 일이 없다. 다만 옛 자산을 만지거나 정확한 docs 검색을 위해 그게 무엇이었는지는 알아 두면 도움이 된다.
흔한 공개 노출 사고 패턴
내가 직접 본 적이 있거나 docs와 사고 보고서에서 자주 보는 케이스 4가지를 묶어 둔다. 어디서 한 번이라도 꺾여 있으면 사고가 안 난다는 관점에서 본다.
첫째, 옛 버킷 ACL public-read 잔존. 2023년 4월 이전에 만들어진 버킷에 객체를 올리면서 ACL 옵션을 public-read로 적어 둔 객체가 살아 있는 케이스. Bucket policy는 비공개인데 ACL이 공개라서 합집합으로 공개가 된다. 막는 방법은 Bucket owner enforced를 켜거나, BPA의 IgnorePublicAcls를 켜는 것이다.
둘째, bucket policy Principal: "*" + Resource 와일드카드. 정적 자산 한 디렉토리만 공개하려고 했는데 Resource를 arn:aws:s3:::bucket/*로 적은 경우. 의도는 bucket/public/*이었는데 와일드카드 위치가 모든 객체 공개가 됐다. 막는 방법은 BPA의 BlockPublicPolicy다. 켜져 있으면 PutBucketPolicy 시점에 거부한다. 그래서 정책 자체를 적용하지 않는다.
셋째, static website hosting + BPA 비활성. 정적 웹 호스팅을 켜려면 s3:GetObject를 익명에게 열어야 하는데, 이걸 위해 BPA를 꺼야 한다. BPA를 끈 상태에서 정책을 와일드카드로 잘못 적으면 호스팅 의도가 아닌 객체까지 외부에 노출한다. 호스팅용 버킷과 데이터용 버킷을 분리하는 게 이 사고를 미리 막는 흔한 패턴이다. 또는 CloudFront + OAC를 앞에 두고 S3 자체는 비공개로 유지하는 방법도 있다.
넷째, presigned URL 만료가 너무 김. Presigned URL 자체는 공개 권한이 아니지만, 만료를 길게 잡아 외부에 공유한 URL은 그 시간 동안 유효한 익명 GET과 같은 효과를 낸다. 만료 상한은 sign할 때 쓴 자격증명 종류에 따라 다르다. IAM 장기 자격증명으로 sign하면 최대 7일, 콘솔 sign은 최대 12시간, STS/IAM Role의 임시 자격증명은 그 자격증명 자체의 세션 만료가 상한이다. 막는 방법은 만료 시간을 짧게 (1시간 이하) 잡고, 정말 필요한 경우만 길게 잡는 운영 룰이다. Presigned URL은 뒤이어 자세히 본다.
차단 단계의 한 가지 공통 패턴이 있다. BPA가 항상 한 칸을 차지한다. 정책을 잘못 적었어도, ACL을 깜빡 켰어도, BPA가 OR로 마지막에 막아 준다. 그래서 운영 표준은 단순하다. 계정 단위 BPA 4개를 모두 켜 두고, 공개가 정말 필요한 케이스만 그 버킷의 BPA를 부분적으로 풀고 정책으로 명시한다.
평가 결과를 한 줄로, 4겹의 합산
네 검사가 모두 따로 있다고 했지만, 실제 요청이 들어왔을 때 평가 순서는 다음과 같다.
핵심은 Deny가 어디서든 한 번 나오면 끝이라는 것이다. 정책 평가 흐름: Allow와 Deny가 만나면에서 본 IAM의 일반 규칙을 그대로 적용한다. 그리고 BPA는 그 위에 공개 차단 한정 추가 검사다. 공개가 아닌 액세스 (예: 같은 계정의 IAM Role이 bucket policy로 GET) 는 BPA의 영향을 받지 않는다.
VPC Endpoint를 통해 들어오는 요청에 대해서는 한 겹이 더 끼어든다. VPC Endpoint: S3·DynamoDB에 나가지 않고 접근하기에서 본 것처럼, endpoint policy가 endpoint를 통과하는 요청만 추가로 검사한다. 이 글에서는 그 부분은 별도로 다루지 않는다.
사고가 났는지 어떻게 보는가, Access Analyzer
권한을 올바르게 잡았다고 생각해도, 실제로 외부에서 보이는지를 검증해 주는 도구가 따로 있다. IAM Access Analyzer: 과권한을 탐지하는 법에서 본 도구다. Access Analyzer는 bucket policy, bucket ACL, BPA, access point/MRAP policy를 합산한 정적 설정 분석으로 이 버킷의 객체가 외부 principal에게 닿을 수 있는지를 보고 external access finding으로 보고한다.
다만 Access Analyzer가 닿지 못하는 곳도 있다. 같은 글에서 짚었듯, data-plane 권한 (즉 객체 단위의 ACL이나 시간 제한된 presigned URL) 은 control-plane 분석 범위 밖이다. presigned URL 검출은 별도 모니터링이 필요하다.
운영 표준은 다음과 같이 정해 둔다. BPA 4개를 계정 단위로 켜고, Access Analyzer로 control-plane 공개를 자동 모니터링하고, presigned URL 사용 정책을 따로 관리한다. 이 세 가지가 다 있어야 공개 사고가 안 난다.
마무리, 관리형이 권한 모델은 호출자 몫으로 둔다
지금까지 잡은 keyThread 중 하나가 '관리형'이라는 단어가 감추는 비용이다. S3는 가장 관리형에 가까운 서비스다. 인프라는 AWS가 다 본다. 11개 9의 영구성과 자동 분산까지 default다. 그런데도 한 가지는 그대로 호출자에게 호출자 몫으로 둔다. 권한 모델이다. 그것도 한 모델이 아니라 네 겹이 누적된 모델이다.
왜 이렇게 됐냐고 물으면 답은 단순하다. 역사다. ACL은 2006년의 답이었고, IAM과 bucket policy는 그 위에 쌓였고, BPA와 Object Ownership은 사고가 난 뒤에 AWS가 추가로 도입했다. 처음부터 한 번에 설계되지 않았다. AWS docs도 ACL을 legacy로 표기하고, 신규 버킷의 default도 ACL이 무력화된 상태다. 시간이 지나면 한 모델로 수렴할 수도 있다.
다만 지금 만지는 모델이 네 겹이라는 사실은 그대로다. 운영하는 입장에서 가장 안전한 셋업은 한 줄로 단순하다. 계정 단위 BPA 4개 모두 ON, 신규 버킷은 Bucket owner enforced default 그대로, 공개가 정말 필요한 케이스만 별도 명시. 이 셋업이면 정책을 한 줄 잘못 적어도 외부에 노출될 위험이 충분히 작아진다. 이어서 그 공개가 필요한 길 중 하나인 Presigned URL (임시 접근 링크의 원리) 을 본다.












