3월에 통계 리포트용 배치에 “앱 접속 현황” 집계를 하나 추가했다. 커밋에 들어간 파일은 둘이었다. 187줄짜리 SQL 파일 하나와 Terraform 변수 파일 4줄. 배치를 실제로 돌리는 파이썬 스크립트는 한 줄도 건드리지 않았다.
이 배치는 동료가 설계하고 만든 것이고, 나는 그 위에 집계 하나를 얹었다. 하나의 서비스를 고객사별 SaaS로 바꾸던 프로젝트 옆에서 돌던 데이터 배치다. 9월에 그 동료가 프로젝트를 떠나면서 운영사 운영팀에 넘길 문서를 내가 쓰게 됐고, 구조를 처음부터 다시 읽었다. 3월의 그 커밋이 왜 그렇게 작을 수 있었는지 설명할 수 있게 된 건 그때다.
서비스 DB는 L0에서 하루 한 번만 읽는다
운영 쪽은 고객사별, 사이트별로 회원 수, 성별·연령대 분포, 로그인 수 같은 통계를 리포트로 받아야 했다. 데이터는 전부 서비스 DB(Aurora MySQL)에 있다. 그런데 거기에 집계 쿼리를 바로 날리면 사용자가 쓰는 DB에 부하가 가고, “지난달 같은 날의 회원 수” 같은 과거 시점 값은 서비스 DB에 남아 있지도 않다. 그래서 매일 복사해 두고 복사본에서 집계한다.
실행 엔진은 AWS Glue(Spark), 저장 포맷은 S3 위의 Apache Iceberg다. git 기록을 보면 초기에 S3 Table Buckets로 옮겼다가 다음 날 일반 S3 버킷 위의 Iceberg로 돌아온 흔적이 있다.
flowchart LR
SRC[("서비스 DB<br/>3개 스키마")] -->|"그대로 복사"| L0["L0<br/>원본 36개 테이블"]
L0 -->|"조인, 정제"| L1["L1<br/>중간 5개 테이블"]
L1 -->|"집계"| L2["L2<br/>리포트 7개 테이블"]
L2 --> OUT["Athena, 대시보드"]L0는 원본을 복사하고 기준일(batch_date)을 붙인다. L1은 L0만, L2는 L1만 읽는다. 이 규칙 덕분에 L1 SQL을 고치면 L1과 그 위의 L2만 다시 돌리면 된다. 서비스 DB 접속 정보가 필요한 잡도 L0 하나뿐이다.
스크립트는 테이블 이름을 하나도 모른다
L0, L1, L2 잡은 모두 같은 파이썬 스크립트(run_sql_to_iceberg.py)를 실행한다. 잡마다 다른 것은 S3에 올라간 작업 목록 파일 하나다.
{
"layer": "l1",
"tasks": [
{
"task_key": "member_summary",
"sql_s3_uri": "s3://scripts-bucket/sql/l1_member_summary.sql",
"target_table": "l1_member_summary"
}
]
}
스크립트는 작업 목록을 읽어 작업마다 SQL을 실행하고 결과를 대상 테이블에 넣는 일만 한다. 작업 목록도 사람이 직접 쓰지 않는다. Terraform이 환경별 변수 파일(terraform.tfvars)의 l0_jobs, l1_jobs, l2_jobs를 읽어 JSON으로 만들어 S3에 올린다. 3월에 내가 추가한 4줄이 이 변수 파일의 한 항목이었다.
l1_jobs = {
# ... 기존 작업들
tb_member_access_stats = {
sql_file = "glue/sql/l1_tb_member_access_stats.sql"
target_table = "tb_member_access_stats"
}
}
여기에 숨은 규칙이 하나 있다. 작업 목록은 SQL 위치를 sql/{계층}_{task_key}.sql로 적고, CodePipeline은 glue/sql/ 폴더를 파일 이름 그대로 sql/에 동기화한다. SQL 파일 이름이 계층 접두어와 task_key를 이어 붙인 이름과 다르면, push한 SQL은 S3에 올라가도 아무 잡도 읽지 않는다. 인수인계 문서의 “테이블 추가” 절차에 이 규칙을 따로 적었다.
대상 테이블에는 그날 날짜의 행을 지우고 다시 넣는다.
def insert_from_temp_view(spark, table_identifier, view_name, target_date, write_mode="snapshot"):
if write_mode == "snapshot":
# 대상일 파티션을 먼저 비운다. 그래서 같은 날짜를 몇 번 돌려도 결과가 같다
spark.sql(f"DELETE FROM {table_identifier} WHERE batch_date = DATE '{target_date}'")
# append 모드는 DELETE 없이 INSERT만 한다
target_columns = spark.table(table_identifier).columns
column_projection = ", ".join(quote_identifier(c) for c in target_columns)
spark.sql(f"INSERT INTO {table_identifier} SELECT {column_projection} FROM {view_name}")
테이블이 batch_date 일 단위로 파티션돼 있어서 이 DELETE는 그날 파티션만 비운다. 중간에 실패한 날은 나중에 백필 스크립트(run_workflows.py)로 다시 돌리는데, 몇 번을 돌려도 중복이 생기지 않는다. 원본에 컬럼이 생기면 대상 테이블에 자동으로 추가하고, 있던 컬럼이 빠지거나 타입이 바뀌면 잡을 실패시킨다.
7월에 내가 붙인 암호문 복호화도 이 구조를 그대로 탔다. 변수 파일의 해당 L0 작업에 pii_columns 목록을 붙이는 방식이라 작업 목록 구조 안에서 끝났다. 과정은 Java 라이브러리 대신 kms:Decrypt 권한 하나로 푼 암호문에 따로 적었다.
마스터 테이블을 매일 통째로 복사하는 이유
L0의 복사 방식은 테이블마다 다르다. 마스터 테이블 28개는 매일 전체를 복사하고, 로그 테이블 8개는 그날 발생분만 가져온다. 연락처나 주소 같은 민감 컬럼은 빈 문자열로 바꿔 저장한다. 이 차이도 전부 SQL 파일 안에 있다.
마스터 테이블을 매일 통째로 복사하는 게 낭비처럼 보이지만, 과거 시점 비교는 이 전체 복사로 한다. batch_date가 3개월 전인 행을 읽으면 그날 회원 테이블이 어떤 상태였는지가 나온다. 그래서 L1 SQL은 마스터 테이블은 대상일 하루를, 로그 테이블은 필요한 기간을 골라 읽는다.
-- l1 집계 SQL의 일부
tb_member AS (
SELECT * FROM l0_catalog.`{{target_database}}`.`l0_tb_member`
WHERE batch_date = DATE '{{target_date}}' -- 마스터: 그날의 스냅샷
),
tb_login_log AS (
SELECT * FROM l0_catalog.`{{target_database}}`.`l0_tb_login_log`
WHERE batch_date >= trunc(DATE '{{target_date}}', 'MM')
AND batch_date <= DATE '{{target_date}}' -- 로그: 월초부터 그날까지
)
같은 이유로 조회할 때 함정이 생긴다. 날짜마다 전체 스냅샷이 쌓이니 batch_date 조건 없이 세면 쌓인 날짜 수만큼 부풀려진다.
-- ❌ 날짜 조건 없이 세면 쌓인 스냅샷 수만큼 부풀려진다
SELECT COUNT(*) FROM l0_tb_member;
-- ✓ 조회에는 항상 batch_date 조건을 넣는다
SELECT COUNT(*) FROM l0_tb_member WHERE batch_date = DATE '2026-03-01';
정리 문서의 체크리스트 마지막 줄을 “조회 시 날짜 조건이 필수임을 사용자에게 알렸는가”로 둔 이유다.
선언과 다르게 도는 write_mode
7월 3일, 복호화 기능을 붙이려고 Terraform 코드를 읽다가 이상한 걸 발견했다. 변수 파일에서 로그 테이블 8개는 write_mode = "append"로 선언돼 있다. dev, stg, prd 세 환경 모두 그렇다. 그런데 실제로는 이 8개도 매번 지우고 다시 넣고 있었다.
# 00_main.tf: 작업 정의에는 write_mode가 있다
layer_job_manifests = {
for layer, jobs in local.layer_task_sets : layer => {
tasks = [
for task_key in sort(keys(jobs)) : merge({
task_key = task_key
# ...
write_mode = try(jobs[task_key].write_mode, "snapshot")
}, ...)
]
}
}
# 03_s3.tf: S3에 올리는 작업 목록 JSON에는 write_mode가 빠져 있다
content = jsonencode({
layer = each.value.layer
tasks = [
for task in each.value.tasks : merge({
task_key = task.task_key
sql_s3_uri = "s3://${local.script_bucket_name}/sql/${each.key}_${task.task_key}.sql"
target_table = task.target_table
source_type = task.source_type
# write_mode = task.write_mode ← 이 줄이 없다
}, ...)
]
})
스크립트는 작업 목록에 write_mode가 없으면 기본값 snapshot을 쓴다. 선언은 Terraform 안에서 만들어졌다가 JSON으로 나가는 길목에서 사라진다. 변수 파일만 읽은 사람은 로그 테이블이 append로 돈다고 믿게 된다.
고치는 건 03_s3.tf에 한 줄이다. 그래도 고치지 않았다. 고치는 순간 로그 테이블 8개의 동작이 바뀌기 때문이다. append는 DELETE를 건너뛰니, --skip false로 강제 재처리하거나 실패한 날을 다시 돌리면 같은 행이 두 번 들어간다. 지금까지 백필이 안전했던 건 이 버그 덕분이기도 했다. 로그 SQL은 어차피 대상일 하루치만 읽으니 snapshot으로 돌아도 결과는 맞다. 선언이 틀렸을 뿐 데이터가 틀린 건 아니었다.
환경별 설정을 확인한 뒤 적용하자며 보류했고, 결국 끝까지 손대지 않았다. 정리 문서의 이슈 표에 증상과 원인, 한 줄짜리 수정 위치를 적어 두었다. 이 배치를 넘겨받는 사람은 변수 파일의 append를 믿으면 안 된다. 실제 동작은 S3에 올라간 작업 목록 JSON이 정한다.