🔥 이벤트 알림: S3 다음 Lambda·SQS 연결
강의 목차
처음 이 부분을 다룰 때 머릿속에 들었던 질문은 단순했다. S3 안에서 객체가 바뀌는 순간, 그 사실을 다른 시스템이 알게 하려면 어떻게 잇지? 가장 멍청한 답은 "1분마다 ListObjectsV2로 폴링한다"인데, 이건 버킷과 키: 플랫 네임스페이스의 의미에서 본 prefix당 5,500 GET 한도와 비용 산수를 떠올리는 순간 답이 아닌 게 분명하다. 객체가 천만 개인 버킷이라면 한 번 LIST하는 데 ContinuationToken을 1만 번 굴려야 하고, 어차피 새 객체만 골라내려면 created_time을 어디 다른 곳에 따로 관리해야 한다.
그래서 S3는 다른 길을 마련해 뒀다. 객체에 무슨 일이 일어나면 S3가 먼저 누구에게 한 줄 적은 메시지를 보내준다는 길이다. 이게 S3 Event Notification이다. 2014-11-13에 Lambda·SNS·SQS 세 곳을 동시에 destination으로 두고 출시했고, 2021-11-18에 EventBridge가 네 번째 destination으로 합류했다. 추가 요금은 없다. 메시지를 만드는 비용은 S3가 흡수하고, 받는 destination 쪽 비용(Lambda 호출, SQS·SNS 메시지)만 지불한다.

다만 이 길에는 한 가지 약속이 빠져 있다. 정확히 한 번 전달도 아니고 순서 보장도 아니다. 메시지가 두 번 도착할 수도 있고, PUT이 DELETE보다 늦게 도착할 수도 있다. AWS 공식 문서에 그렇게 적혀 있고, 운영자가 이걸 모르고 시작하면 며칠 안에 데이터가 한 번 더 처리되거나 한 번 누락되는 상황을 맞이한다. 이 글에서는 그 길의 구조와, 그 약속이 빠진 부분에 어떻게 idempotency를 채워 넣는지를 같이 본다.
NotificationConfiguration이라는 한 묶음
S3 Event Notification은 버킷 단위로 켠다. 버킷 하나마다 NotificationConfiguration이라는 JSON 한 묶음이 붙어 있고, 그 안에 destination별 entry가 들어간다. Lambda로 보낼 거면 LambdaFunctionConfiguration, SQS로 보낼 거면 QueueConfiguration, SNS로 보낼 거면 TopicConfiguration, EventBridge로 보낼 거면 EventBridgeConfiguration. 이름은 다르지만 구조는 같다. 이 이벤트 타입이 일어나면, 이 destination ARN에 보내라라는 한 줄 약속이다.
{
"LambdaFunctionConfigurations": [
{
"Id": "thumbnail-generator",
"LambdaFunctionArn": "arn:aws:lambda:ap-northeast-2:1234:function:make-thumb",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{ "Name": "prefix", "Value": "uploads/" },
{ "Name": "suffix", "Value": ".jpg" }
]
}
}
}
],
"QueueConfigurations": [ ],
"TopicConfigurations": [ ]
}{
"LambdaFunctionConfigurations": [
{
"Id": "thumbnail-generator",
"LambdaFunctionArn": "arn:aws:lambda:ap-northeast-2:1234:function:make-thumb",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{ "Name": "prefix", "Value": "uploads/" },
{ "Name": "suffix", "Value": ".jpg" }
]
}
}
}
],
"QueueConfigurations": [ ],
"TopicConfigurations": [ ]
}내가 처음 이 구조를 봤을 때 헷갈리는 부분이 두 군데 있었다. 하나는 "그래서 이 묶음을 어떻게 갱신하느냐"였고, 다른 하나는 "몇 개까지 둘 수 있느냐"였다.
첫 번째 답은 운영적으로 좀 까다롭다. PutBucketNotificationConfiguration API 한 번 호출이 전체 묶음을 통째로 덮어쓴다. 부분 추가 API가 없다. 그래서 한 버킷에 두 팀이 각자의 destination을 추가하려고 하면, 두 번째 호출이 첫 번째 팀의 entry를 그대로 지워 버리는 사고가 생긴다. CDK·Terraform 같은 IaC가 없는 곳에서 콘솔로 직접 관리할 때 가장 흔한 함정이다. 답은 한 줄. 항상 GET 한 번 → 수정 → PUT 한 번 패턴으로 가거나, IaC가 그 묶음을 단일 소유자로 잡게 두거나.
두 번째 답은 명확한 숫자다. 한 버킷 당 100개 entry가 한도다. 이 한도는 늘릴 수 없다. 100개라고 들으면 충분해 보이는데, 큰 멀티테넌트 SaaS에서 테넌트마다 prefix 다르게 entry를 만드는 식으로 가면 금세 차오른다. 거기서부턴 EventBridge 한 entry로 합쳐서 EventBridge Rule 쪽으로 넘기는 게 답이다. 왜 그런지는 잠시 뒤에.

8 가지 이벤트 타입
S3가 알림으로 보내는 상태 변화는 한 묶음으로 8 카테고리다. 처음 한꺼번에 보면 많아 보이는데, 객체에 일어나는 일이 사실 그 정도라는 거다.
그림 1. S3 객체에 일어나는 8 가지 상태 변화. 한 NotificationConfiguration 안에서 와일드카드는 카테고리 안쪽으로만 작동한다.
가장 자주 쓰는 케이스는 두 군데다. ObjectCreated는 업로드 직후 처리(썸네일 생성, OCR, 검증)를 자동으로 잇는 길이고, ObjectRemoved는 삭제 시 외부 시스템의 메타데이터·캐시를 같이 비우는 길이다. 나머지 6개는 운영적 신호 (Replication 모니터링, lifecycle 자동 삭제 감사 흔적, IT 자동 이동 추적) 에 가깝다. 글로 다룰 때도 ObjectCreated·ObjectRemoved 둘이 거의 모든 시간을 차지한다.
여기서 한 가지 단서. 와일드카드는 카테고리 안쪽으로만 작동한다. s3:ObjectCreated:*는 Put·Post·Copy·CompleteMultipartUpload 네 개를 한 번에 잡지만, s3:Object* 같이 카테고리를 가로지르는 패턴은 없다. ObjectCreated와 ObjectRemoved를 한 destination으로 모두 받고 싶으면 entry 두 개를 따로 만들거나, EventBridge로 받아서 Rule 쪽에서 합치는 게 운영적으로 깨끗하다.
두 토큰 짜리 필터
같은 버킷 안에서 모든 객체에 알림을 거는 경우는 흔하지 않다. 보통은 특정 prefix 아래의 특정 suffix 객체만 골라낸다. uploads/users/로 시작하고 .jpg로 끝나는 객체에만 썸네일 Lambda를 발화시키고 싶다면 그렇게 적는다.
"Filter": {
"Key": {
"FilterRules": [
{ "Name": "prefix", "Value": "uploads/users/" },
{ "Name": "suffix", "Value": ".jpg" }
]
}
}"Filter": {
"Key": {
"FilterRules": [
{ "Name": "prefix", "Value": "uploads/users/" },
{ "Name": "suffix", "Value": ".jpg" }
]
}
}여기서 운영적으로 한 번 더 멈칫할 부분이 세 가지다.
첫째, prefix와 suffix는 entry당 각각 한 개씩만. 정규표현식이 아니다. \.(jpg|png)$ 같은 OR 조합이 안 된다. JPG·PNG 둘을 잡으려면 entry 두 개를 만들고 destination ARN을 같게 두는 식으로 가야 한다.
둘째, 최대 길이는 prefix·suffix 각각 1,024자. 키 길이 자체의 한도(1,024 bytes)와 같은 숫자라서 사실상 키 전체를 prefix로 그대로 넣어도 된다. 다만 prefix 1,024자를 가득 채우는 경우는 흔하지 않다. 그 정도면 보통 키 한 줄로 매칭하는 게 더 자연스럽다.
셋째 (내가 처음 봤을 때 이건 의외였는데) 같은 버킷 안에서 prefix·suffix가 겹치면 등록 자체를 거부한다. 이미 uploads/로 시작하는 entry가 있는 버킷에 uploads/users/ entry를 추가하려고 하면 Configuration is ambiguously defined에 가까운 에러를 돌려준다. 한 객체가 두 entry에 동시에 매칭되면 어느 destination을 우선할지 결정할 수 없기 때문이다. 답은 두 길로 나뉜다. entry를 하나로 합치고 destination 쪽에서 분기하거나, prefix를 서로 안 겹치도록 배치하거나.
내가 이 세 번째 제약 때문에 헤맸던 일이 있다. 한 팀이 data/raw/ 아래로 들어오는 모든 객체를 ETL Lambda에 보내고 있었고, 다른 팀이 data/raw/audit/만 별도 SQS로 받고 싶어했다. NotificationConfiguration에 entry 두 개를 같이 넣을 수 없다는 게 그제야 분명했다. 결국 ETL Lambda가 받는 entry를 EventBridge로 옮기고, 거기서 Rule 두 개로 나눠 받는 식으로 정리했다. 그게 EventBridge 통합이 들어온 이유 중 하나다.
destination 4종의 차이
같은 메시지를 어느 destination에 보내느냐는 그 다음 단계의 처리 형식을 결정한다. 네 곳의 성격이 다른데, 한 줄씩 짚어 두면 헷갈리지 않는다.
Lambda는 메시지가 곧 함수 호출이다. S3가 비동기 invoke를 걸고, Handler와 실행 모델: 이벤트가 들어오면에서 본 envelope 형식 (Records[] 배열에 s3.bucket.name·s3.object.key·eventName이 들어 있는 그 형식) 으로 함수가 받는다. 이 호출의 재시도 위치와 한도는 트리거 종류: API Gateway·SQS·EventBridge·S3에서 다뤘다. Lambda 6h 비동기 큐, 자동 retries 2회 (1·2분 간격). 즉시 처리가 핵심인 워크로드(업로드 직후 썸네일·검증·OCR)에 적합하다.
SQS는 메시지를 큐에 한 줄씩 누적한다. 받는 쪽이 자기 속도로 폴링해서 처리할 수 있다. 처리량이 spike 치는 워크로드, 또는 처리 시간이 길어 Lambda 15분 한도를 넘는 경우(EC2·ECS task가 받는 식)에 적합하다. 한 가지 운영 함정. S3는 SQS Standard만 destination으로 받는다. SQS FIFO는 직접 destination이 안 된다. 정확히 한 번 + 순서 보장이 필요하면 EventBridge를 거쳐 FIFO로 라우팅하는 길로 가야 한다.
SNS는 메시지를 fan-out한다. 한 객체 생성 이벤트를 N개의 구독자(다른 Lambda·SQS·HTTP·이메일)에 동시에 보내고 싶을 때다. S3 Event Notification 자체는 한 entry에 destination 하나만 둘 수 있어서, "이 이벤트를 N개 시스템에 동시에 보내고 싶다"는 상황에서 SNS 하나만 entry로 두고 SNS 구독자로 N개를 매다는 식으로 푼다. 한 가지 맞바꿈은 envelope 한 겹이 더 생긴다는 것. 받는 쪽이 SNS message 안의 Message 필드에서 다시 S3 envelope을 꺼내야 한다.
EventBridge가 2021-11-18에 네 번째로 합류한 길이다. 앞의 세 destination은 S3 entry 다음 한 destination으로 1:1이지만, EventBridge는 한 entry에서 default bus로 보내고 그 위에 Rule 여러 개가 한 번 더 분기하는 형식이다. 위에서 봤던 data/raw/와 data/raw/audit/ 케이스가 이 형식으로 답이 나온다. entry는 EventBridge 하나, Rule이 둘.
그림 2. Direct는 entry당 destination 하나, EventBridge는 entry 하나로 default bus에 보내고 Rule이 그 위에서 분기한다.
EventBridge로 빠지면 직접 통합에서 못 닿던 곳 (Step Functions, Kinesis Firehose, SQS FIFO, API Destinations(외부 HTTP), SaaS partner) 이 한 번에 추가로 닿는다. 24h 내장 retry도 따라온다 (Events: EventBridge의 이전 이름과 현재에서 본 그 24h). 다만 EventBridge envelope 한 겹이 더 끼는 만큼 데이터 구조가 한 줄 더 깊다. AWS 측에서 자기들 docs에 advanced filtering and routing(고급 필터링과 라우팅)이 필요한 경우를 EventBridge로 가는 권장 길로 적어 놓은 것도 그 관점을 잡아 준 결과다.
빠진 약속 두 가지
여기까지가 길의 구조이고, 이제 그 길에 빠진 약속 두 가지를 짚어야 한다. 글머리에서 한 번 적었던 부분이다.
at-least-once 전달. S3가 한 번의 객체 생성 이벤트를 만들어 destination에 보내는데, 내부 retry 메커니즘이 같은 이벤트를 두 번 만들어 보낼 수 있다. AWS 공식 문서에 다음과 같이 적혀 있다.
"Amazon S3 Event Notifications is designed to deliver notifications at least once."
"Amazon S3 이벤트 알림은 최소 한 번 이상의 전달을 보장하도록 설계되어 있다."
한 운영자 블로그가 이걸 probably once(아마 한 번)라고 부르기도 한다. 흔한 경우는 아니지만 (천 만 건에 한 두 건 정도라고 운영자들 사이에서는 도는데 AWS는 정확한 숫자를 안 준다) 드물게 일어나는 게 0회는 아니라는 게 운영적으로 중요하다.
운영 의미는 한 가지뿐이다. 수신자 쪽이 idempotency를 강제해야 한다. 같은 이벤트가 두 번 도착해도 결과가 같아야 한다. 썸네일 생성이라면 같은 키에 같은 결과 객체가 덮어쓰이는 식이라 안전하고, 잔액 차감 같은 건 답이 아니다. 어차피 그런 워크로드를 S3 이벤트에 직접 거는 설계가 잘못된 거지만.
순서 보장 없음. 같은 객체에 PUT을 했다가 1초 뒤에 DELETE를 했다고 하면, destination에 도착하는 순서가 PUT 다음 DELETE라는 보장이 없다. DELETE가 먼저 도착하고 PUT이 그 뒤에 도착하면, 수신자는 "없는 객체를 지웠고 그 다음에 객체가 생겼다"고 잘못 해석할 수 있다. 동일 객체에 빠른 인터리빙이 없는 워크로드(업로드 한 번 다음 처리 한 번)는 거의 부딪히지 않지만, 동일 키에 자주 덮어쓰기를 하는 워크로드(예: hot-key 패턴)에서는 무조건 만난다.
이 문제를 푸는 첫 도구가 메시지에 들어 있는 sequencer 한 줄짜리 토큰이다. AWS가 같은 객체의 이벤트들 사이에 어느 게 더 나중에 일어났는지 비교할 수 있도록 넣어 둔 lexicographic 비교 가능한 hex 문자열이다. 같은 bucket + key + versionId로 두 이벤트가 도착했을 때, sequencer가 더 큰 쪽이 더 나중 사건이다. 수신자가 자기 처리 상태(예: DynamoDB의 last_seen_sequencer)와 비교해서 낡은 이벤트는 버리는 식으로 dedupe + 순서 정정을 같이 풀 수 있다.
const seen = await ddb.get({ Key: { id: `${bucket}/${key}` } });
if (seen?.sequencer && eventSequencer <= seen.sequencer) {
return;
}
await processEvent(event);
await ddb.put({ Item: { id: `${bucket}/${key}`, sequencer: eventSequencer } });const seen = await ddb.get({ Key: { id: `${bucket}/${key}` } });
if (seen?.sequencer && eventSequencer <= seen.sequencer) {
return;
}
await processEvent(event);
await ddb.put({ Item: { id: `${bucket}/${key}`, sequencer: eventSequencer } });이 패턴이 정답인 건 아니다. DynamoDB 한 항목에 read·write가 직렬화되어야 해서 hot-key 패턴에서는 그쪽이 새 병목이 된다. 다만 왜 sequencer가 거기 들어 있는지가 이 패턴으로 분명하다.
그림 3. at-least-once의 운영 형태. 한 사건의 두 메시지가 같은 sequencer를 들고 도착해서, 수신자가 그 값을 dedupe key로 쓴다.

EventBridge로 가는 길에는 이 형식 위에 24h 자체 retry가 더 붙는다. EventBridge가 target에 보내려다 실패하면 이벤트를 24시간 동안 자기 큐에서 보관하면서 재시도한다. 운영적으로 destination 쪽 일시 장애를 더 잘 흡수하는 형식인데, 그만큼 같은 사건이 더 늦게 도착할 수도 있다는 맞바꿈이 같이 들어온다.
권한, 누가 누구를 부를 자격이 있는가
마지막으로, 운영적으로 한 번은 헤매는 부분이 권한이다. S3가 destination을 호출하려면 S3 자신의 자격이 필요한 게 아니라 destination 쪽이 S3의 호출을 받아주는 자세가 필요하다. AWS resource-based policy의 표준 형식인데, 내가 처음 봤을 때는 방향이 한 번 어려웠다.
Lambda destination은 함수의 resource policy(SDK는 AddPermission)에 한 줄 적는다. s3.amazonaws.com Principal이 lambda:InvokeFunction을 부를 수 있고, source ARN은 그 버킷이다. CDK·Terraform·SAM이 NotificationConfiguration과 함께 이 한 줄을 자동으로 넣어 주는데, 콘솔로 직접 만지면 빠뜨려서 "S3가 Lambda를 못 부른다"는 에러로 만난다.
SQS·SNS destination은 큐/토픽의 access policy에 같은 형식으로 적는다. Principal은 s3.amazonaws.com, Action은 sqs:SendMessage 또는 sns:Publish, Condition으로 aws:SourceArn은 그 버킷, aws:SourceAccount는 자기 계정. SourceAccount 조건이 빠지면 confused deputy(혼동된 대리자) 형식의 보안 함정이 따라온다. 다른 계정의 누군가가 같은 ARN 형식으로 호출하면 큐가 그대로 메시지를 받는 위험.
EventBridge destination은 별도 권한이 필요 없다. S3가 default bus에 PutEvents 하는 통로는 AWS 내부에서 매여 있다. 다만 EventBridge Rule의 target이 다른 서비스인 경우, Rule이 그 target을 부를 수 있는 IAM Role이 필요하다. 권한 형식이 한 단계 미뤄져 있다는 게 EventBridge 형식의 또 다른 특징.
언제 이 길이 답이 아닌가
S3 Event Notification이 답이 아닌 경우가 분명히 있다. keyThread "언제 이 서비스를 쓰지 말아야 하는가"에 한 번 짚어 둔다.
첫째, 정확히 한 번 + 순서 보장이 필요한 워크로드. 회계·재고 차감처럼 한 사건의 중복 처리 또는 순서 뒤바뀜이 비즈니스적으로 맞을 수 없는 워크로드는 SQS FIFO를 EventBridge로 우회해서 사용하거나, 애초에 객체 업로드와 별도 트랜잭션 신호를 분리해서 설계한다. S3 이벤트로 직접 회계를 거는 건 probably once의 그 probably가 어느 날 청구서로 돌아오는 길이다.
둘째, destination이 N개 이상으로 fan-out 되어야 하는데 N이 자주 바뀌는 경우. 직접 통합으로 Lambda·SQS·SNS 셋을 동시에 entry로 두는 건 가능하지만, 6개월 뒤 N+1 destination이 추가될 때마다 NotificationConfiguration을 PUT으로 통째로 다시 적어야 한다는 게 운영 부담이다. EventBridge entry 하나로 가면 그 위 Rule만 추가하면 된다.
셋째, 버킷 안의 prefix·suffix 분기가 entry 100개를 넘기는 식. 큰 SaaS 멀티테넌트에서 자주 만나는데, 한도 자체를 늘릴 수 없다. EventBridge 한 entry + Rule 패턴 (prefix별 Rule)으로 옮기면 entry 한 칸만 차지하고 Rule 한도(이벤트 버스당 default 300개, 하드 한도 2,000개)로 갈아탄다.
넷째, 객체 자체의 형식·크기 같은 내용에 따라 분기해야 하는 경우. NotificationConfiguration의 필터는 키 이름의 prefix·suffix만 본다. 객체의 size·content-type·메타데이터를 보고 분기하려면 일단 다 받은 다음에 Lambda·EventBridge Rule 안에서 분기해야 한다. "분기를 어디서 하느냐"의 운영 비용 차이.
마무리하고 다음으로
S3 Event Notification은 추가 요금 없이 객체 상태 변화를 외부로 보내 주는 가벼운 길이다. NotificationConfiguration 한 묶음 안에 entry 100개를 두고, 8 카테고리 이벤트 타입과 prefix·suffix 두 토큰 짜리 필터로 좁히고, Lambda·SQS·SNS·EventBridge 네 곳으로 보낸다. 다만 정확히 한 번도 순서도 약속하지 않는다. 그 약속을 받으려면 sequencer로 idempotency를 짜거나, EventBridge + SQS FIFO로 한 번 더 추상화한 길로 가야 한다.
내가 이 글을 쓰고 나서 다르게 보게 된 부분은 한 군데다. S3가 보내는 메시지 한 줄을 그대로 처리하는 코드를 짜면 안 된다. 그 한 줄은 두 번 도착할 수도 있고 늦게 도착할 수도 있는, 운영적으로 암시에 가까운 신호다. 처리 코드는 항상 내가 이미 본 사건인지를 묻고 늦게 도착했나를 묻는 두 줄을 자기 안에 가지고 있어야 한다. 그 두 줄을 안 둔 채로 다음 단계로 가면, 어느 날 결제 시스템에서 같은 영수증을 두 번 처리하는 사고가 그대로 따라온다.












