🔥 Logs: 구조화 로그와 인덱싱 관점
강의 목차

"로그 한 줄을 CloudWatch에 어떻게 보내나"라고 검색했다가 PutLogEvents API 페이지에서 한 박자 멈췄다. 한 호출이 받는 한 이벤트는 {timestamp, message} 두 필드뿐이다. timestamp는 epoch milliseconds, message는 자유 텍스트. 이게 끝이다.
처음 봤을 때 나는 이게 너무 단출해서 오히려 의심이 들었다. 로그 시스템이라면 보통 레벨이나 카테고리, 적어도 source 같은 메타데이터 한두 개는 있어야 자연스러운데, AWS는 그걸 다 빼고 두 필드만 남겼다. CloudWatch란 무엇인가: AWS 모니터링의 중심 도구에서 로그가 "log group 아래 log stream에 timestamp와 자유 텍스트로 기록한다"는 한 줄로 정의해 두었는데, 그 구조를 한 단계 더 짚으면 왜 그렇게 단출한지가 드러난다. 두 필드짜리 자유 텍스트라는 형태가 검색 형식, 메트릭과의 관계, 청구서가 자라는 메커니즘을 다 결정한다.
Log Group, Stream, Event: 두 필드 위의 3단계 계층
이벤트 한 줄이 어디에 쌓이는지부터 짚는다. CloudWatch Logs는 세 단계 구조다. 가장 아래에 LogEvent ({timestamp, message} 그 자체), 그 위에 LogStream, 가장 위에 LogGroup이 있다. 이름이 비슷해서 처음엔 헷갈리는데, 역할이 분명히 다르다.
LogStream은 시간순으로 한 줄씩 진행하는 append-only 시퀀스다. 같은 함수 인스턴스, 같은 컨테이너, 같은 프로세스가 만든 이벤트들을 한 stream에 모은다. LogGroup은 그 stream들을 묶는 컨테이너이고, 운영 결심이 머무는 곳이다. 보존 기간(retentionInDays), 암호화 키(kmsKeyId), 로그 클래스(Standard vs Infrequent Access). 이 세 가지를 LogGroup 단위로 묶고, stream에는 그런 결심이 없다. 그래서 같은 함수의 모든 인스턴스가 만든 stream들은 한 group으로 모이고, 운영 정책은 그 group 한 군데에서 통제한다.
![16:9 가로 계층 다이어그램. 흰 배경, 둥근 모서리, 절제된 그림자. 위쪽에 'Log Group / Log Stream / Log Event: 3-tier hierarchy' 제목. 세 개의 layer를 세로로 중첩한다. 최상위 layer는 큰 슬레이트 그레이 #1e293b 둥근 사각형 'LogGroup: retention, encryption, log class' 안에 retentionInDays, kmsKeyId, logGroupClass 세 개의 작은 속성 알약을 둔다. 중간 layer는 LogGroup 안에 슬레이트 블루 #475569 둥근 사각형 세 개를 가로로 두고 'LogStream (append-only)' 라벨과 함께 stream 이름(2026/04/27/[$LATEST]a1b2c3 등)을 적는다. 최하위 layer는 가장 왼쪽 stream 안에 가로 알약 캡슐 네 개를 세로로 쌓아 두고, 각각 작은 시계 아이콘 'timestamp'와 { message } 중괄호 박스로 나눈다. 하단 캡션에 Event = timestamp + message 라는 문구를 슬레이트 그레이로 두고, append-only 라벨에 에메랄드 그린 #10b981 강조, LogGroup 외곽선에 AWS 오렌지 #ff9900 강조를 적용한 프로페셔널 IT 다이어그램](https://static.codingmax.net/images/courses/acb3c74540f3c017db92f4d3a060ae15.avif)
이 구조가 손에 잡히면, PutLogEvents 한 호출이 받을 수 있는 양에도 한도가 있다는 게 당연하다. AWS 문서에는 한도가 네 개 줄줄이 적혀 있어서 내가 처음 봤을 때는 외울 게 많아 보였는데, 한 호출의 무게와 시점의 일관성 두 축으로 묶고 나서야 외울 게 사실상 두 축뿐이라는 걸 알았다.
무게 쪽부터 본다. 한 호출 페이로드는 1 MB를 넘지 못한다. 이때 1 MB는 단순히 message 바이트의 합이 아니라, 메시지 UTF-8 바이트에 이벤트당 26 바이트가 더해진 값이다. 이 26 바이트는 timestamp와 메타데이터를 위해 AWS가 고정으로 잡아 둔 머리값이다. 만 개의 짧은 이벤트(평균 50 바이트짜리)를 한 번에 보낸다고 직접 계산하면 메시지 합은 50만 바이트(약 488 KB)지만, 메타가 더해져 76만 바이트, 약 743 KB가 된다. 76 KB가 아니다. 나도 처음엔 "그럼 짧은 이벤트는 만 개도 여유롭겠네" 싶었는데, 계산해 보니 1 MB 한도가 의외로 가까웠다. 이벤트 수 10,000개 한도와 1 MB 한도 두 개 중 먼저 닿는 쪽이 한도다.
시점 쪽이 의외로 까다롭다. 한 배치 안의 timestamp들은 24시간 범위를 넘지 못한다. 그리고 14일 이상 오래된 timestamp는 아예 거부하고, 2시간 이상 미래의 timestamp도 거부한다. 이 두 숫자가 백필 작업의 형태 자체를 결정한다. "Lambda가 죽었던 한 시간 동안의 로그를 한 번에 다시 쏘자"는 시도는 가능하지만, "지난 달 데이터를 지금 다시 쏘자"는 시도는 14일 벽에 부딪힌다. 14일이 지난 데이터는 별도 시스템(S3 archive 등)으로 가야 한다는 신호로 나는 읽었다. CloudWatch Logs는 지금부터 최근 2주를 위한 도구라는 자기 정의가 이 두 숫자에 들어 있는 셈이다.

API 시그니처 자체가 한 번 더 변했다는 점도 같이 짚는다. 예전에는 SequenceToken이라는 필드가 있어서 stream에 쓰기를 직렬화했다. 한 stream에 동시에 두 호출이 들어오면 충돌이 났고, 그래서 SDK가 InvalidSequenceTokenException을 처리하는 분기를 갖고 있어야 했다. 이제 AWS는 그 필드를 더 이상 받지 않는다. 시퀀스 토큰을 무시하고, 같은 stream에 병렬 PutLogEvents를 허용한다. 옛 글이나 코드에서 그 예외 처리 분기를 보면, 그건 이제 들어오지 않는 죽은 코드다.
이벤트 한 줄의 크기 자체도 2025년 4월에 256 KB에서 1 MB로 4배 늘었다 (AWS 발표). 한 줄짜리 큰 stack trace나 큰 JSON 페이로드를 자르지 않고 그대로 보낼 수 있는 폭이 그만큼 더 크다. 한 이벤트가 1 MB이면 한 호출은 사실상 그 한 이벤트만으로 채워지지만, 잘려서 두 줄에 흩어진 stack trace를 디버깅하던 사람에게는 1년이 지난 지금도 큰 변화다.
자유 텍스트는 인덱스가 아니다: 구조화 JSON이 들어오는 이유
콘솔 화면을 잠깐 떠올려 본다. 나도 처음 CloudWatch Logs 검색창에 단어를 던졌을 때 답이 생각보다 늦게 돌아와서 의외였는데, 그 이유가 바로 페이로드 형식이다. message가 자유 텍스트라는 한 줄이 다음 결심들을 다 강제한다. CloudWatch Logs는 검색 인덱스 데이터베이스가 아니다. 메시지의 어떤 토큰이 어디에 있는지를 미리 색인해 두지 않는다는 뜻이다.
CloudWatch란 무엇인가: AWS 모니터링의 중심 도구에서 "풀텍스트 검색은 OpenSearch와 Elasticsearch가 맡는다"고 짚었던 그 구분이 이 문맥에서 의미를 갖는다. Logs Insights가 쿼리를 받기는 하지만, 그 쿼리는 스캔이지 인덱스 lookup이 아니다. 즉 매 쿼리가 해당 시간 범위의 모든 이벤트를 처음부터 끝까지 한 줄씩 읽어 내려가며 매칭한다. 그래서 AWS는 과금 모델을 스캔한 GB를 단위로 잡는다. 1주일 치 100 GB 로그를 검색하면 매번 100 GB 스캔 비용이 발생하는 구조다.
이 형태 위에서 구조화 JSON이 인덱스의 역할을 대신한다. 메시지를 그냥 "GET /api/users 200 35ms user=alice" 같은 평문으로 두지 않고 다음처럼 JSON으로 쓴다.
{"ts": 1761537600000, "method": "GET", "path": "/api/users", "status": 200, "latency_ms": 35, "user": "alice"}{"ts": 1761537600000, "method": "GET", "path": "/api/users", "status": 200, "latency_ms": 35, "user": "alice"}
문자열로 보면 전자가 짧고 사람 눈에는 읽기 더 편하다. 그런데 같은 데이터의 운영 가능성이 한쪽에서만 살아난다. Logs Insights의 쿼리에서 parse @message 없이 바로 filter status >= 500, stats count() by path, stats avg(latency_ms)로 들어갈 수 있는 건 후자뿐이기 때문이다. 전자는 매 쿼리마다 문자열을 직접 자르는 정규식을 한 줄씩 써야 한다.
이게 왜 비용이 되는지 한 번 계산한다. 같은 100 GB 로그에 매일 10번씩 쿼리를 발행한다고 가정한다. JSON 형태라면 필드 단위 비교만 하므로 가장 적은 데이터만 읽어도 답이 나온다. 평문 형태라면 매 쿼리마다 모든 줄을 정규식으로 파싱해야 하므로 같은 100 GB를 매번 풀 스캔한다. 한 달이면 그 차이가 ingestion과 storage 청구서 옆에 추가 쿼리 비용이라는 줄로 따로 자란다. 처음엔 작아 보이지만 운영 6개월 차에 보면 무시 못 할 항목이다.
자유 텍스트라는 페이로드 형태는 그래서 글을 읽는 도구가 아니라 글을 쓰는 쪽에서 결심한다. 운영을 시작하는 시점에 로그 포맷을 JSON으로 쓰겠다는 결심 한 줄이, 6개월 뒤 디버깅 비용을 결정한다. 그 결심을 한 단계 더 밀면 메트릭까지 한 호출로 같이 처리할 수 있는 옵션, Embedded Metric Format이 들어선다.
Embedded Metric Format: 한 호출에 메트릭과 로그를
Embedded Metric Format, 줄여서 EMF는 한 줄로 설명할 수 있다. 로그 한 줄에 메트릭을 끼워서 PutLogEvents 한 호출에 둘 다 처리하는 사양이다. 로그를 쓰는 단계에서 자동으로 메트릭을 추출하고, 사용자는 PutMetricData를 따로 부를 필요가 없다. 페이로드 형태는 이렇다.
{
"_aws": {
"Timestamp": 1761537600000,
"CloudWatchMetrics": [{
"Namespace": "MyApp",
"Dimensions": [["Service", "Region"]],
"Metrics": [
{"Name": "Latency", "Unit": "Milliseconds"},
{"Name": "RequestCount", "Unit": "Count"}
]
}]
},
"Service": "users-api",
"Region": "ap-northeast-2",
"Latency": 35,
"RequestCount": 1,
"user": "alice",
"path": "/api/users"
}{
"_aws": {
"Timestamp": 1761537600000,
"CloudWatchMetrics": [{
"Namespace": "MyApp",
"Dimensions": [["Service", "Region"]],
"Metrics": [
{"Name": "Latency", "Unit": "Milliseconds"},
{"Name": "RequestCount", "Unit": "Count"}
]
}]
},
"Service": "users-api",
"Region": "ap-northeast-2",
"Latency": 35,
"RequestCount": 1,
"user": "alice",
"path": "/api/users"
}
페이로드 안의 _aws.CloudWatchMetrics 배열이 메트릭의 지시문 역할을 한다. Namespace, Dimensions, Metrics 세 키를 그 안에 두고, CloudWatch는 이 한 줄을 받아 두 가지를 동시에 처리한다. (a) Latency, RequestCount 두 메트릭을 추출해 Metric: 숫자 하나가 찍히는 과정에서 본 그 좌표(namespace × MetricName × dimensions)에 찍는다. (b) 같은 JSON을 그대로 Log에도 쌓는다. 그래서 user나 path 같은 일반 필드는 메트릭으로는 가지 않지만 로그로는 살아 있어, 한 줄로 두 검색 경로를 동시에 만든다.
한도는 한 EMF 줄당 메트릭 100개와 dimension 30개다. 그리고 1 MB 이벤트 한도 안에서. 이 한도를 넘으면 메트릭 추출이 그냥 안 된다. 로그는 들어가는데 메트릭이 만들어지지 않는 조용한 실패다. 그래서 한 줄에 너무 많은 메트릭을 넣지 않고, 한 요청 단위로 잘라 쓰는 패턴을 흔히 쓴다. 한 HTTP 요청 한 줄에 그 요청의 latency, status, count만 담는 식이 가장 흔하고 무난한 형태다.
EMF가 PutMetricData와 다른 부분은 비용 구조다. PutMetricData는 US East 기준 API 호출 1,000건당 $0.01로 단가를 잡는다(서울 등 다른 region은 따로 본다). 같은 데이터를 EMF로 보내면 PutMetricData 호출이 0이 되는 대신, PutLogEvents 한 호출이 그 역할을 맡는다. 메트릭 호출 비용이 사라지는 만큼 로그 ingestion 비용이 자란다.
내가 직접 계산한 한 시나리오. 초당 1건의 요청을 30일간 기록한다고 하자. 한 달이면 약 2.59M개의 데이터 포인트가 된다. 두 길의 비용을 같은 기준에서 비교하면 이렇다.
- PutMetricData 길: 요청 한 번에 호출 한 번, 즉 2.59M 호출. 비용 = 2.59M / 1000 × $0.01 = 약 $25.9.
- EMF 길: 요청 한 번에 PutLogEvents 한 줄. 평균 300바이트짜리 JSON이면 한 달 약 777 MB(약 0.78 GB) ingestion이고, $0.50/GB로 약 $0.39다.
같은 워크로드에 60배가 넘는 격차가 난다. 작은 페이로드로 메트릭을 자주 찍는 패턴에서 EMF가 압도적이다. 다만 페이로드 크기에 따라 우열이 거꾸로 간다. 같은 빈도에 한 번에 9 KB짜리 큰 JSON을 보내면 ingestion이 약 30배(0.78 GB → 약 23 GB)로 자라 약 $11.7이 되고, 메트릭 수가 적은 워크로드라면 PutMetricData 쪽이 더 작다. 결국 EMF는 PutMetricData 비용을 0으로 만드는 게 아니라 비용을 다른 통장으로 보낸다. 어느 통장이 더 작은지는 메시지 크기 × 메트릭 빈도가 결정한다.
다만 EMF는 Standard log class에서만 동작한다. 뒤에서 다시 짚을 Infrequent Access 클래스는 메트릭 추출 자체를 지원하지 않아서, IA 그룹에 EMF 페이로드를 보내봐야 CloudWatch는 메트릭을 그냥 무시한다. EMF를 쓸 그룹은 Standard에 두는 결심이 자동으로 따라온다.
청구서가 자라는 메커니즘: Retention, IA class, Subscription Filter
데이터의 형태까지 살펴봤으니, 이제 그 데이터에 청구서가 붙는 결심을 본다. 셋이 있고, 셋 다 CloudWatch란 무엇인가: AWS 모니터링의 중심 도구에서 짚은 '관리형이 감추는 비용'의 구체 사례다.
Retention 기본값이 Never expire. 새 Log Group을 만들 때 retention 정책을 따로 정하지 않으면 로그는 영원히 남는다. 이게 왜 함정이냐면, 자기가 명시적으로 Log Group을 만들 때만 결심하는 게 아니기 때문이다. Lambda를 처음 띄우면 그 함수 이름의 Log Group이 자동으로 생기는데, 그것도 기본값이 Never다. 한 달에 100 GB가 들어오는 함수가 1년 동안 그대로 쌓이면 1.2 TB의 로그가 storage에 살아 있다. CloudWatch Logs storage는 GB와 월당 별도 단가를 매기므로(CloudWatch Logs 가격), US East 기준 $0.03/GB와 월로 계산하면 1.2 TB는 매월 $36.9를 storage에서 따로 청구한다. 함수가 더 이상 호출되지 않아도 청구서는 계속 자란다. "내가 안 쓰는 함수의 옛 로그가 매월 돈을 먹고 있다"는 상황이 생긴다.
PutRetentionPolicy가 받는 retentionInDays는 정해진 enum 값만 받는다. 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653. 총 22개 값이다. 0 같은 sentinel은 없다. 무한 보관으로 되돌리고 싶으면 별도 DeleteRetentionPolicy API를 부른다. 결국 retention 정책을 한 번도 정한 적 없는 그룹이 기본 Never이고, 그 상태가 청구서를 조용히 자라게 만드는 메커니즘이다. 운영 입장에서는 새 Log Group이 생기는 시점마다 retention을 동시에 정해 두는 자동화 한 줄(IaC, 또는 account-level default)이 거의 필수에 가깝다고 나는 본다.

Infrequent Access 로그 클래스. 2023년 11월에 GA된 IA 클래스는 ingestion이 50% 싸다. US East 기준 Standard $0.50/GB가 IA에서는 $0.25/GB다. 100 GB짜리 사후 분석용 로그를 IA로 옮기면 한 달 ingestion 비용이 $50에서 $25로 내려간다. 같은 데이터에 단가가 절반이 붙는다.
대신 못 쓰는 기능이 명확하다. Live Tail, metric filters(메트릭 추출과 그 위에 거는 알람), Embedded Metric Format, Subscription filters. 이 넷은 IA에서 동작하지 않는다. 반대로 Logs Insights 쿼리는 IA에서도 같은 가격으로 그대로 들어가고(AWS는 일부 명령에 제약을 둔다), storage 비용도 같으며, 민감정보 마스킹(sensitive data protection)도 동일하게 동작한다. 이걸 한 줄 표로 묶으면 결심이 쉽다. 운영 알람이 걸려야 하거나 다음 hop으로 보내야 하는 로그는 Standard, 사후 분석에만 보는 로그는 IA다. 두 가지로 분류해 두면 같은 데이터 양에 청구서가 절반 가까이 줄어든다. 한 가지 단서. 한 번 만든 Log Group의 클래스는 변경할 수 없다. 새로 만들 때 결심해야 하고, 잘못 정했으면 새 Log Group을 만들어 옮기는 수밖에 없다.
Subscription Filter 한도. 한 Log Group에 subscription filter는 2개까지만 붙일 수 있다. 이 한도는 firm이라 늘릴 수 없다. 그래서 Lambda에 한 개, Kinesis Firehose에 한 개 걸어 두면 그 Log Group은 끝이다. 세 번째 다음 hop이 필요한 시나리오, 예를 들어 Datadog으로도 보내고 S3로도 archive하고 Slack 알람도 띄우고 싶을 때는, 흔히 Kinesis 한 개로 모은 다음 거기서 다시 fan-out하는 패턴으로 우회한다.
2024년 1월에 AWS는 **account-level subscription filter**를 추가했다. region당 하나, 모든 Log Group을 한 묶음으로 다음 hop에 보낸다. multi-account 관측 토폴로지를 짤 때 큰 도움이 되는 옵션인데, 본격적인 이야기는 따로 다룬다.
Retention을 정하는 한 줄
새 Log Group을 만들 때 retentionInDays를 같이 정한다. 그게 운영 결심의 첫 줄이다. 다음 줄은 로그 메시지를 JSON으로 쓰겠다는 결심. 그 다음 줄은 이 로그가 운영 알람에 쓰일지 사후 분석에만 쓰일지를 구분해 Standard와 IA로 나누는 결심이다. 셋 다 코드 한 줄, IaC 한 줄이지만 셋 중 하나라도 빠지면 청구서가 다른 형태로 자란다. 셋이 같이 있어야 예상한 형태가 된다.
CloudWatch Logs는 결국 {timestamp, message} 두 필드짜리 그릇이고, 그 그릇을 결심으로 채우는 게 운영의 시작이다. 두 필드를 그대로 두면 자유 텍스트와 무한 보관, Standard 클래스라는 가장 비싼 기본값에 머무른다. 그 채워진 그릇에 어떻게 쿼리를 발행하는지, Logs Insights는 뒤에서 짚는다.
참고 자료
- PutLogEvents API Reference: 1 MB / 10,000 events / 14d back / 2h forward 한도와 SequenceToken 무시 변경
- CloudWatch Logs Log Classes (Standard / Infrequent Access): IA 클래스가 못 쓰는 기능과 가격 차이
- Embedded Metric Format Specification:
_aws.CloudWatchMetrics페이로드 사양과 한도 - PutRetentionPolicy API Reference:
retentionInDays허용값 enum - SubscriptionFilters User Guide: 로그 그룹당 2개 firm 한도
- Amazon CloudWatch Logs increases log event size to 1 MB: 2025-04 256 KB에서 1 MB 변경










