데이터 쓰기 방식: 전체 교체·파티션 교체·append·upsert¶
한 줄 요약: 표에 결과를 쓸 때는 "통째로 바꿀지, 일부 칸만 바꿀지, 뒤에 붙일지, 있으면 고치고 없으면 넣을지"를 고른다. 무엇을 고를지는 그 표가 이력을 지키는 원천인지, 다시 계산할 수 있는 결과인지에 달려 있다.
📍 만난 상황¶
- 2026-11-11 — 2번 물음 → 그날 일지
- 10:32 H3 시공간 정규화 파이프라인 두 개를 하나로 합치는 중이었다. 실행 뒤 출력 20개가 모두 다시 써졌는지 확인하겠다는 설명을 들었다. 코드에 쓰기 방식이 "전체 교체"와 "파티션 교체"로 나뉘어 있어서 파티션 교체가 뭐냐고 물었다.
- 10:35 파티션 교체 설명을 들은 직후, "누적되는 정보가 있을 텐데 upsert로 저장할 방법은 없나"를 물었다.
- 결론: 플랫폼(hub-dev)은 데이터셋에 upsert를 아직 지원하지 않는다. 그리고 누적은 원천이 맡을 일이라 정규화 결과에는 upsert가 필요 없다.
🧩 쉽게 말하면¶
반 학생들의 출석부 바인더를 떠올리면 된다. 바인더에는 반마다 칸막이가 있다.
- 전체 교체: 바인더를 통째로 버리고 새 바인더로 바꾼다.
- 파티션 교체: 3반 칸막이 안의 종이만 새 것으로 바꾼다. 1반·2반은 그대로다.
- append: 맨 뒤에 새 종이를 끼운다.
- upsert: 학생 이름으로 찾아서, 있으면 그 줄을 고치고 없으면 새 줄을 쓴다.
이 구분이 없으면 "3반만 고치고 싶었는데 1반·2반 기록까지 날아가는" 사고가 난다.
📖 제대로 알기¶
파티션이란¶
파티션(partition: 표를 특정 열 값에 따라 나눠 둔 칸)은 데이터를 폴더별로 쪼개 저장하는 방식이다. Delta Lake(Parquet 파일 위에 버전 기록을 얹은 표 형식)나 Hive 계열 표에서 흔히 쓴다.
norm_obs/
dataset_id=rainfall_10m/ part-000.parquet
dataset_id=traffic_volume/ part-000.parquet
dataset_id가 파티션 키(나누는 기준 열)다. 날짜(obs_date, ymd)를 키로 쓰는 경우가 가장 흔하다.
네 가지 쓰기 방식¶
| 방식 | 하는 일 | 기존 데이터 |
|---|---|---|
전체 교체 (overwrite) |
표 전체를 새 결과로 바꿈 | 모두 사라짐 |
파티션 교체 (overwrite_partitions) |
새 결과에 들어 있는 파티션만 바꿈 | 다른 파티션은 그대로 |
붙이기 (append) |
새 행을 뒤에 추가 | 그대로. 중복 가능 |
upsert (merge) |
키로 찾아 있으면 고치고 없으면 넣음 | 키가 겹치는 행만 바뀜 |
upsert는 update + insert를 합친 말이다. SQL에서는 MERGE 문이나 PostgreSQL의 INSERT … ON CONFLICT로 쓴다.
upsert에는 반드시 키(행을 하나로 알아보는 열. 예: 관측소+시각)가 있어야 한다.
원천과 파생 결과¶
- 원천(source: 바깥에서 수집해 온 데이터): 지나간 시점의 값은 다시 가져올 수 없는 경우가 많다. 그래서 이력을 지키는 책임이 여기 있다.
- 파생 결과(derived: 원천을 계산해 만든 데이터): 원천만 있으면 언제든 다시 만들 수 있다.
이 구분이 쓰기 방식을 고르는 첫 번째 기준이다.
⚙️ 어떻게 동작하나¶
- 전체 교체: 새 파일을 다 쓴 뒤 "이제부터 표는 이 파일들"이라고 기록을 바꾼다. Delta는 이 기록 교체가 한 번에 일어나서(원자적), 읽는 쪽이 반쯤 바뀐 표를 보지 않는다.
- 파티션 교체: 새 결과에 어떤 파티션 값이 들어 있는지 본다. 그 파티션의 옛 파일만 빼고 새 파일로 채운다. 나머지 폴더는 손대지 않는다.
- append: 새 파일을 추가만 한다. 같은 날을 두 번 돌리면 같은 행이 두 번 들어간다.
- upsert: 새 행마다 키로 기존 행을 찾는다. 찾으면 고치고, 없으면 넣는다. 키 비교 때문에 다른 방식보다 무겁다.
오늘 데이터 흐름은 이렇게 생겼다.
flowchart LR
A["수집 파이프라인"] -->|"날짜별로 쌓기<br/>파티션 교체·append"| B[("원천<br/>11-05, 11-06, 11-07")]
B -->|"전부 읽기"| C["정규화 코드"]
C -->|"전체 교체"| D[("정규화 결과<br/>11-05, 11-06, 11-07")]
원천이 날짜별로 쌓이면, 원천 전체를 읽어 통째로 다시 쓰는 정규화 결과에도 모든 날짜가 그대로 들어간다. 누적은 왼쪽(원천)이 하고, 오른쪽(결과)은 매번 새로 계산할 뿐이다.
🧪 예시¶
- 예시 1 · 일상에 빗대어: 바인더에 1반 30장, 2반 28장, 3반 25장이 있다. 3반 새 출석부 26장을 쓴다.
- 전체 교체 → 바인더에 3반 26장만 남는다. 1·2반 58장이 사라진다.
- 파티션 교체 → 3반만 26장으로 바뀐다. 전체 84장.
- append → 3반이 25+26=51장이 된다. 같은 날 기록이 겹친다.
-
upsert(학생 번호 기준) → 3반 기존 25명은 고쳐지고, 새로 온 1명만 추가된다. 3반 26장.
-
예시 2 · 실제로는 (Spark + Delta)
# ① 전체 교체
df.write.format("delta").mode("overwrite").save(path)
# ② 파티션 교체: df에 든 파티션만 바꿈
(df.write.format("delta").mode("overwrite")
.option("partitionOverwriteMode", "dynamic").save(path))
# ③ 뒤에 붙이기
df.write.format("delta").mode("append").save(path)
①은 path 표 전체를 df로 바꾼다. 강우량만 담긴 df면 표에는 강우량만 남는다.
②는 df에 든 파티션 값(예: dataset_id=rainfall_10m)의 폴더만 바꾼다.
③은 기존 행을 두고 df를 더한다. 재실행하면 중복된다.
upsert는 보통 SQL MERGE로 쓴다.
MERGE INTO norm_obs AS t
USING new_rows AS s
ON t.station_id = s.station_id AND t.obs_time = s.obs_time
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
① ON 줄이 키다. 관측소와 시각이 같으면 같은 행으로 본다.
② 찾으면(MATCHED) 값을 고친다.
③ 못 찾으면 새 행으로 넣는다.
오늘 hub-dev에서 실제로 쓰이던 방식은 이랬다.
| 원천 | 쓰기 방식 | 결과 |
|---|---|---|
| 강우량·교통량·생활인구 | 날짜 파티션 교체 | 돌릴수록 날짜가 쌓임 |
| 돌발정보 | append | 쌓임 |
| 핫스팟 인구·환경 센서 | 전체 교체 | 돌릴 때마다 이전 값이 사라짐 |
| 대피시설 등 월간 정적 데이터 | 전체 교체 | "현재 목록"이라 맞는 방식 |
⚖️ 비교 · 흔한 오해¶
헷갈린 점: "누적되는 데이터면 결과 표에 upsert가 필요하다" 그렇게 생각하기 쉬운 이유가 있다. 결과 표를 "계속 자라는 장부"로 보면, 새 날짜를 끼워 넣는 upsert가 자연스러워 보인다.
하지만 정규화 결과는 장부가 아니라 원천의 계산 결과다. 원천이 날짜별로 쌓여 있으면 결과는 전체 교체만 해도 모든 날짜를 담는다. 오히려 전체 교체가 낫다. 정규화 규칙을 고치면 과거 날짜까지 새 규칙으로 다시 계산되기 때문이다. upsert로 쌓았다면 옛 규칙으로 만든 행이 섞여 남는다.
진짜 위험은 결과 쪽이 아니라 원천이 전체 교체로 수집되는 경우다. 실시간 도시데이터처럼 돌릴 때마다 덮어쓰면, 지나간 시점 값은 영영 사라진다. 이력이 필요하면 원천 수집을 append나 날짜 파티션 교체로 바꿔야 한다.
다른 흔한 오해 - "파티션 교체 = 일부만 지운다" → 정확히는 새 결과에 들어 있는 파티션만 지운다. 새 결과가 비어 있으면 아무것도 안 바뀐다. - "append면 안전하다" → 재실행하면 중복이 생긴다. 실패 후 재시도가 잦은 작업에선 위험하다. - "전체 교체면 옛 데이터를 영영 잃는다" → Delta는 옛 버전을 일정 기간 기록에 남겨 되돌릴 수 있다(time travel). 다만 현재 표에서는 사라진다.
🛠️ 실무에서는¶
- 먼저 물을 것: 이 표는 원천인가, 다시 계산할 수 있는 결과인가?
- 원천 + 시계열 → 날짜 파티션 교체(재실행해도 그 날짜만 다시 써져서 안전)
- 원천 + "현재 목록" → 전체 교체
- 파생 결과 → 전체 교체가 기본. 데이터가 너무 커져 매번 다시 계산하기 무거울 때만 파티션 교체나 upsert를 검토
- upsert를 쓰려면 키가 정말 유일한지 먼저 확인한다. 키가 겹치면
MERGE가 실패하거나 엉뚱한 행을 고친다. - Spark 함정: 파티션 덮어쓰기 모드가
static(기본값)이면overwrite가 모든 파티션을 지운다. 파티션만 바꾸려면dynamic을 명시한다. - 문제가 생기면 먼저 볼 것: 실행 뒤 행 수, 파티션 목록, 갱신 시각 열(예:
normalized_at)이 기대대로 바뀌었는지.
❓ 자주 묻는 질문¶
- Q. 정규화 결과에도 이력이 정말 다 들어가나? A. 원천이 이력을 갖고 있다면 그렇다. 정규화가 원천 전체를 읽어 쓰기 때문이다. 원천 수집에 일정이 없으면 이력 자체가 안 생기니, 그쪽부터 챙긴다.
- Q. 파티션 교체와 upsert는 언제 갈리나? A. 바꿀 범위가 "날짜 하나 통째"면 파티션 교체, "그 안의 몇 행"이면 upsert다. 파티션 교체가 훨씬 단순하고 빠르다.
- Q. 플랫폼에서 upsert가 지원되면 바로 바꿔야 하나? A. 아니다. 파생 결과는 전체 교체로 충분하다. 결과가 커져 재계산 시간이 문제 될 때 다시 본다.
🔭 더 깊이 보기¶
- 원천 수집을 정해진 시각에 자동으로 돌리려면 무엇이 필요한가? — 이력이 쌓이지 않는 진짜 원인이 "일정 없음"이었다.
- 재실행해도 결과가 같은 작업(멱등성)은 어떻게 설계하나? — 쓰기 방식 선택의 바탕이 되는 개념이다.
- Delta의 time travel과 보관 기간은 어떻게 정해지나? — 전체 교체의 안전망이 어디까지인지 알 수 있다.
- 관련 주제: 데이터 파이프라인 실행과 갱신 · H3 격자 체계와 해상도 · OLTP와 OLAP의 차이
📚 참고 자료¶
- Table batch reads and writes (Delta Lake 공식 문서, 영문) — overwrite·append·파티션 덮어쓰기 옵션 · 링크
- Table deletes, updates, and merges (Delta Lake 공식 문서, 영문) —
MERGE로 upsert 하는 법 · 링크 - INSERT — ON CONFLICT 절 (PostgreSQL 공식 문서, 영문) — 일반 DB의 upsert 문법 · 링크
🗓️ 이력¶
- 2026-11-11: 파티션 교체가 무엇인지, 누적 데이터에 upsert가 필요한지 물음 → 일지