본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 12. · 14 Views
Delta Lake 스키마 진화와 스키마 강제 완벽 가이드
데이터 파이프라인에서 스키마 변경을 안전하게 관리하는 방법을 배웁니다. Delta Lake의 스키마 강제와 스키마 진화 기능을 실무 중심으로 이해하고, 컬럼 추가부터 타입 변경, Nested 구조 관리까지 완벽하게 마스터합니다.
목차
- 스키마 강제(Schema Enforcement) 이해
- 스키마 진화(Schema Evolution) 활성화
- 컬럼 추가와 타입 변경
- Nested 스키마 관리
- 스키마 충돌 해결 전략
- 스키마 버전 관리 베스트 프랙티스
1. 스키마 강제(Schema Enforcement) 이해
데이터 엔지니어 김개발 씨는 오늘도 열심히 데이터 파이프라인을 운영하고 있습니다. 그런데 갑자기 에러 알림이 울립니다.
"Schema mismatch detected!" 분명 어제까지 잘 돌아가던 파이프라인인데, 무슨 일이 생긴 걸까요?
스키마 강제는 Delta Lake가 테이블에 잘못된 데이터가 들어오는 것을 막아주는 안전장치입니다. 마치 공항 보안 검색대가 위험물을 걸러내듯이, 스키마 강제는 정의된 스키마와 맞지 않는 데이터를 자동으로 차단합니다.
이를 통해 데이터 품질을 보장하고 예기치 않은 오류를 사전에 방지할 수 있습니다.
다음 코드를 살펴봅시다.
from pyspark.sql import SparkSession
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
# Delta 테이블 생성
spark = SparkSession.builder.appName("schema_enforcement").getOrCreate()
# 초기 스키마 정의
schema = StructType([
StructField("user_id", IntegerType(), False),
StructField("name", StringType(), False),
StructField("age", IntegerType(), True)
])
# 스키마에 맞는 데이터 삽입 성공
df_correct = spark.createDataFrame([(1, "김철수", 25)], schema)
df_correct.write.format("delta").mode("append").save("/tmp/users")
# 스키마와 맞지 않는 데이터 삽입 시도 - 에러 발생!
# df_wrong = spark.createDataFrame([(1, "김철수", "25세")], schema) # age가 문자열
김개발 씨는 입사 6개월 차 데이터 엔지니어입니다. 오늘도 열심히 사용자 데이터를 처리하는 파이프라인을 모니터링하고 있었습니다.
그런데 갑자기 Slack에 에러 알림이 떴습니다. "Schema mismatch detected in users table!" 선배 데이터 엔지니어 박시니어 씨가 다가와 화면을 들여다봅니다.
"아, 이거 스키마 강제 때문에 막힌 거네요. 누가 age 필드에 문자열을 넣으려고 했나 봐요." 그렇다면 스키마 강제란 정확히 무엇일까요?
쉽게 비유하자면, 스키마 강제는 마치 레고 블록의 결합 구조와 같습니다. 레고는 정해진 모양끼리만 딱 맞게 결합되도록 설계되어 있습니다.
엉뚱한 모양의 블록을 억지로 끼우려고 하면 결합이 되지 않죠. 이처럼 스키마 강제도 테이블에 정의된 구조와 타입에 맞지 않는 데이터가 들어오는 것을 원천적으로 차단합니다.
스키마 강제가 없던 시절에는 어땠을까요? 개발자들은 데이터 타입 검증 로직을 직접 작성해야 했습니다.
매번 데이터를 삽입하기 전에 "이 필드가 정수형인가?", "이 컬럼이 NULL을 허용하는가?" 같은 검증 코드를 작성했죠. 실수로 검증 로직을 빼먹으면 잘못된 데이터가 그대로 들어갔습니다.
더 큰 문제는 잘못된 데이터가 쌓이고 나서야 문제를 발견한다는 점이었습니다. 데이터 분석가가 "왜 이 필드에 문자열이 들어있나요?"라고 물어볼 때쯤이면 이미 수백만 건의 잘못된 데이터가 쌓여있었습니다.
바로 이런 문제를 해결하기 위해 스키마 강제가 등장했습니다. 스키마 강제를 사용하면 데이터 품질을 자동으로 보장할 수 있습니다.
또한 런타임 에러를 사전에 방지할 수도 있습니다. 무엇보다 개발자가 검증 로직을 따로 작성할 필요가 없다는 큰 이점이 있습니다.
Delta Lake가 알아서 스키마를 체크하고 문제가 있으면 즉시 에러를 발생시키니까요. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 4번째 줄부터 보면 StructType으로 스키마를 명시적으로 정의하고 있습니다. user_id는 정수형이고 NULL을 허용하지 않습니다.
name은 문자열이고 역시 필수 필드입니다. age는 정수형이지만 NULL을 허용합니다.
이 스키마가 바로 우리의 "안전 규칙"이 됩니다. 12번째 줄에서는 스키마에 맞는 데이터를 삽입합니다.
(1, "김철수", 25)는 모든 타입이 정확히 일치하므로 문제없이 저장됩니다. Delta Lake는 이 데이터를 받아들입니다.
그런데 주석 처리된 15번째 줄을 보세요. age 필드에 "25세"라는 문자열을 넣으려고 시도합니다.
이 코드의 주석을 풀고 실행하면 어떻게 될까요? Delta Lake는 즉시 에러를 발생시킵니다.
"age는 IntegerType인데 StringType을 넣으려고 하네요? 안 됩니다!" 하고 말이죠.
실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 서비스를 개발한다고 가정해봅시다.
주문 테이블에는 order_amount(주문 금액) 필드가 있고, 이는 반드시 정수형이어야 합니다. 만약 어떤 개발자가 실수로 "10,000원"이라는 문자열을 넣으려고 하면 어떻게 될까요?
스키마 강제가 즉시 이를 차단합니다. 덕분에 잘못된 데이터가 들어가서 나중에 매출 집계가 꼬이는 상황을 미리 방지할 수 있습니다.
하지만 주의할 점도 있습니다. 초보 데이터 엔지니어들이 흔히 하는 실수 중 하나는 스키마 강제를 너무 엄격하게 설정하는 것입니다.
모든 필드를 NOT NULL로 설정하면 나중에 선택적 필드를 추가할 때 곤란해집니다. 이렇게 하면 기존 데이터와 충돌이 발생할 수 있습니다.
따라서 필수 필드와 선택 필드를 신중하게 구분하여 스키마를 설계해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 에러가 났군요!
누군가 잘못된 타입의 데이터를 보냈네요." 로그를 확인해보니 외부 API에서 받은 데이터의 age 필드가 문자열로 들어오고 있었습니다. 김개발 씨는 데이터를 받는 부분에 타입 변환 로직을 추가했고, 파이프라인은 다시 정상 작동했습니다.
스키마 강제를 제대로 이해하면 데이터 품질 문제를 사전에 차단하고 안정적인 파이프라인을 구축할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 선택적 필드는 nullable=True로 설정하여 유연성을 확보하세요
- 스키마 정의 시 주석을 달아 각 필드의 의미를 명확히 하세요
- 외부 데이터 소스를 사용할 때는 타입 변환 로직을 미리 추가하세요
2. 스키마 진화(Schema Evolution) 활성화
김개발 씨의 팀은 새로운 요구사항을 받았습니다. "사용자 테이블에 email 필드를 추가해주세요." 그런데 기존 테이블에는 이미 수백만 건의 데이터가 있습니다.
어떻게 해야 기존 데이터를 건드리지 않고 새 필드를 추가할 수 있을까요?
스키마 진화는 기존 데이터를 유지하면서 테이블 스키마를 안전하게 변경할 수 있는 기능입니다. 마치 건물을 사람들이 사용하면서도 증축할 수 있는 것처럼, 스키마 진화를 사용하면 서비스를 중단하지 않고도 새로운 컬럼을 추가하거나 구조를 변경할 수 있습니다.
mergeSchema 옵션을 활성화하는 것만으로 이 강력한 기능을 사용할 수 있습니다.
다음 코드를 살펴봅시다.
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
# 기존 데이터 (user_id, name, age만 있음)
df_old = spark.createDataFrame([
(1, "김철수", 25),
(2, "이영희", 30)
], ["user_id", "name", "age"])
# 새로운 스키마로 데이터 추가 (email 필드 포함)
df_new = spark.createDataFrame([
(3, "박민수", 28, "park@example.com")
], ["user_id", "name", "age", "email"])
# mergeSchema 옵션으로 스키마 진화 활성화
df_new.write.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.save("/tmp/users")
김개발 씨는 월요일 아침 회의에서 새로운 미션을 받았습니다. "이번 주 안으로 사용자 테이블에 email 필드를 추가해주세요.
마케팅팀에서 이메일 캠페인을 시작하려고 합니다." 김개발 씨는 고민에 빠졌습니다. 사용자 테이블에는 이미 300만 건의 데이터가 있습니다.
테이블을 새로 만들자니 기존 데이터를 모두 마이그레이션해야 하고, 그 과정에서 서비스가 중단될 수도 있습니다. "어떻게 하지?" 박시니어 씨가 지나가다가 김개발 씨의 고민을 듣고는 빙그레 웃습니다.
"아, 그거? 스키마 진화 쓰면 5분이면 끝나요." 그렇다면 스키마 진화란 정확히 무엇일까요?
쉽게 비유하자면, 스키마 진화는 마치 아파트 베란다 확장 공사와 같습니다. 사람들이 살고 있는 상태에서도 조심스럽게 공간을 넓힐 수 있죠.
기존 방들은 그대로 두고 새로운 공간만 추가하는 것입니다. 이처럼 스키마 진화도 기존 데이터는 그대로 유지하면서 새로운 컬럼이나 구조를 추가할 수 있게 해줍니다.
스키마 진화가 없던 시절에는 어땠을까요? 테이블 구조를 바꾸려면 엄청난 작업이 필요했습니다.
먼저 새로운 스키마로 빈 테이블을 만들고, 기존 데이터를 전부 읽어서 변환한 다음, 새 테이블에 쓰는 과정을 거쳐야 했습니다. 데이터가 많으면 이 작업에 몇 시간씩 걸렸고, 그 동안 서비스를 중단해야 했습니다.
더 큰 문제는 실시간으로 들어오는 새 데이터를 어떻게 처리할지 고민해야 한다는 점이었습니다. 바로 이런 문제를 해결하기 위해 스키마 진화가 등장했습니다.
스키마 진화를 사용하면 서비스 중단 없이 스키마를 변경할 수 있습니다. 또한 기존 데이터의 무결성을 유지할 수도 있습니다.
무엇보다 복잡한 마이그레이션 스크립트를 작성할 필요가 없다는 큰 이점이 있습니다. 단지 옵션 하나만 추가하면 되니까요.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 3번째 줄부터 보면 기존 스키마의 데이터를 생성합니다.
user_id, name, age 세 개의 컬럼만 있습니다. 이미 프로덕션에서 운영 중인 테이블이라고 가정해봅시다.
10번째 줄에서는 새로운 스키마의 데이터를 생성합니다. 여기에는 email이라는 새로운 컬럼이 추가되었습니다.
기존 세 개 컬럼에 email이 더해져 총 네 개의 컬럼을 가지고 있죠. 핵심은 15번째 줄입니다.
**option("mergeSchema", "true")**를 설정하면 Delta Lake가 자동으로 스키마를 병합합니다. 기존 테이블에 없던 email 컬럼을 발견하면, "아, 새로운 컬럼이 추가되었구나" 하고 스키마를 업데이트합니다.
기존 데이터의 email 컬럼은 자동으로 NULL 값을 가지게 됩니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 로그 수집 시스템을 운영한다고 가정해봅시다. 처음에는 단순히 timestamp, user_id, action만 수집했습니다.
그런데 몇 달 후 "사용자의 브라우저 정보도 수집해주세요"라는 요구사항이 들어왔습니다. 스키마 진화를 사용하면 간단합니다.
새로운 데이터를 쓸 때 browser 필드를 추가하고 mergeSchema 옵션을 켜면 됩니다. 기존 수십억 건의 로그 데이터는 그대로 유지되면서 새로운 필드만 추가됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 mergeSchema를 무분별하게 사용하는 것입니다.
모든 쓰기 작업에 mergeSchema를 켜두면 의도하지 않은 컬럼이 추가될 수 있습니다. 예를 들어 오타로 "emial"이라고 쓰면 email 대신 emial이라는 새 컬럼이 생깁니다.
이렇게 하면 스키마가 점점 지저분해집니다. 따라서 스키마를 변경할 때만 명시적으로 mergeSchema를 활성화하고, 평소에는 꺼두는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 김개발 씨는 mergeSchema 옵션을 사용해 email 컬럼을 추가했습니다.
단 5분 만에 작업이 끝났습니다. 김개발 씨는 감탄했습니다.
"와, 정말 간단하네요! 서비스 중단도 없고, 기존 데이터도 그대로 있어요." 스키마 진화를 제대로 이해하면 유연하고 확장 가능한 데이터 파이프라인을 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 스키마 변경 시에만 mergeSchema를 true로 설정하고 평소에는 false로 유지하세요
- 새로운 컬럼은 가능한 nullable로 추가하여 기존 데이터와의 호환성을 유지하세요
- 스키마 변경 전 테스트 환경에서 충분히 검증하세요
3. 컬럼 추가와 타입 변경
김개발 씨는 email 필드를 성공적으로 추가한 후 자신감이 붙었습니다. 이번에는 age 필드의 타입을 IntegerType에서 LongType으로 바꾸고 싶습니다.
그런데 박시니어 씨가 말립니다. "잠깐, 타입 변경은 조심해야 해요!"
컬럼 추가는 스키마 진화에서 가장 안전한 작업이지만, 타입 변경은 신중하게 접근해야 합니다. Delta Lake는 호환 가능한 타입 변경만 허용하며, 데이터 손실이 발생할 수 있는 변경은 차단합니다.
예를 들어 IntegerType을 LongType으로 바꾸는 것은 가능하지만, StringType을 IntegerType으로 바꾸는 것은 불가능합니다.
다음 코드를 살펴봅시다.
from delta.tables import DeltaTable
# 컬럼 추가는 간단합니다
df_with_phone = spark.createDataFrame([
(4, "최지우", 32, "choi@example.com", "010-1234-5678")
], ["user_id", "name", "age", "email", "phone"])
df_with_phone.write.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.save("/tmp/users")
# 타입 변경은 ALTER TABLE 사용
delta_table = DeltaTable.forPath(spark, "/tmp/users")
spark.sql("ALTER TABLE delta.`/tmp/users` CHANGE COLUMN age age BIGINT")
# 호환되지 않는 타입 변경은 에러 발생
# spark.sql("ALTER TABLE delta.`/tmp/users` CHANGE COLUMN name name INT") # 실패!
김개발 씨는 email 필드 추가에 성공한 후 기분이 좋았습니다. "이거 생각보다 쉬운데?
이번에는 age 필드를 좀 더 큰 타입으로 바꿔볼까?" 김개발 씨는 나이가 200살을 넘을 일은 없지만, 혹시 모를 미래를 대비해 IntegerType을 LongType으로 바꾸기로 했습니다. mergeSchema만 켜면 되겠지 하고 코드를 작성하려는데, 박시니어 씨가 말립니다.
"잠깐만요! 타입 변경은 컬럼 추가랑 다릅니다.
잘못하면 큰일 나요." 그렇다면 컬럼 추가와 타입 변경은 어떻게 다를까요? 쉽게 비유하자면, 컬럼 추가는 집에 새로운 방을 짓는 것과 같습니다.
기존 방들은 전혀 건드리지 않고 새 방만 추가하면 되니까 안전하죠. 반면 타입 변경은 기존 방의 구조를 바꾸는 것과 같습니다.
침실을 거실로 바꾸려면 벽을 허물고 재배치해야 합니다. 잘못 건드리면 구조적 문제가 생길 수 있습니다.
이처럼 타입 변경은 기존 데이터의 저장 방식과 해석 방법을 바꾸기 때문에 훨씬 신중해야 합니다. 타입 변경에 제약이 없던 시절에는 어땠을까요?
개발자가 실수로 StringType을 IntegerType으로 바꾸면 어떤 일이 벌어질까요? "김철수"라는 문자열을 정수로 읽으려고 하면 에러가 발생하거나 쓰레기 값이 나옵니다.
데이터가 손상되고, 이미 처리된 데이터를 복구하기도 어렵습니다. 더 큰 문제는 이런 문제를 즉시 발견하지 못하고, 며칠 후 데이터 분석 결과가 이상할 때야 알아차린다는 점이었습니다.
바로 이런 문제를 해결하기 위해 Delta Lake는 타입 변경에 엄격한 규칙을 적용합니다. Delta Lake의 타입 변경 규칙을 사용하면 호환 가능한 변경만 허용됩니다.
또한 데이터 손실 위험을 사전에 차단할 수도 있습니다. 무엇보다 ALTER TABLE 명령으로 명시적인 변경을 강제하므로 실수를 방지할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 2번째 줄부터 보면 새로운 컬럼 phone을 추가하는 예제입니다.
기존 네 개 컬럼에 phone을 더해 총 다섯 개가 되었습니다. 8번째 줄에서 mergeSchema를 true로 설정하면 간단히 추가됩니다.
이 부분은 위험하지 않습니다. 그런데 13번째 줄을 보세요.
타입 변경은 ALTER TABLE 명령을 사용합니다. age 컬럼을 INT에서 BIGINT로 바꾸는 작업입니다.
이것은 호환 가능한 변경입니다. INT의 모든 값은 BIGINT로 안전하게 변환될 수 있으니까요.
주석 처리된 16번째 줄은 어떨까요? name 컬럼을 STRING에서 INT로 바꾸려고 시도합니다.
이 코드를 실행하면 Delta Lake는 즉시 에러를 발생시킵니다. "문자열을 정수로 바꿀 수 없습니다!" 데이터 손실을 방지하기 위한 안전장치입니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 주문 시스템을 운영한다고 가정해봅시다.
처음에는 order_count를 IntegerType으로 설계했습니다. 주문 건수가 21억을 넘을 일은 없다고 생각했거든요.
그런데 회사가 성장하면서 대량 주문이 들어오기 시작했습니다. 안전하게 LongType으로 바꿔야겠다고 판단했습니다.
ALTER TABLE을 사용하면 기존 데이터는 그대로 유지하면서 타입만 안전하게 변경할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 타입 변경을 너무 자주 하는 것입니다. "혹시 몰라서"라는 이유로 모든 INT를 BIGINT로, 모든 FLOAT를 DOUBLE로 바꾸면 스토리지 비용이 증가합니다.
이렇게 하면 쿼리 성능도 약간 떨어질 수 있습니다. 따라서 실제 필요성을 검토한 후에만 타입을 변경해야 합니다.
또 다른 실수는 비호환 타입 변경을 강제로 시도하는 것입니다. "STRING을 INT로 바꾸고 싶은데 안 되네?
그럼 새 컬럼 만들고 데이터 변환해서 옮겨야지." 맞습니다. 비호환 타입 변경은 이렇게 해야 합니다.
직접 변경할 수는 없습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 신중하게 생각했습니다. "age를 BIGINT로 바꿀 필요가 정말 있을까?
지금 INT로도 충분한데..." 결국 김개발 씨는 타입 변경을 하지 않기로 했습니다. 대신 정말 필요한 phone 컬럼만 추가했습니다.
불필요한 변경을 피한 것이죠. 컬럼 추가와 타입 변경의 차이를 제대로 이해하면 안전하고 효율적인 스키마 관리를 할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 타입 변경은 정말 필요한 경우에만 수행하세요
- 호환 가능한 타입 변경만 가능합니다 (INT→BIGINT는 가능, STRING→INT는 불가능)
- 비호환 타입 변경이 필요하면 새 컬럼을 만들고 데이터를 변환한 후 옮기세요
4. Nested 스키마 관리
김개발 씨는 이번에 더 복잡한 요구사항을 받았습니다. "사용자의 주소 정보를 저장해주세요.
시, 도, 우편번호를 모두 포함해야 합니다." 단순히 address라는 String 컬럼 하나를 추가할까요? 아니면 더 나은 방법이 있을까요?
Nested 스키마는 JSON처럼 계층 구조를 가진 복잡한 데이터를 효율적으로 저장할 수 있는 방법입니다. 마치 폴더 안에 폴더가 있듯이, StructType 안에 또 다른 StructType을 중첩할 수 있습니다.
이를 통해 관련된 필드들을 논리적으로 그룹화하고, 스키마 진화 시에도 개별 필드를 독립적으로 관리할 수 있습니다.
다음 코드를 살펴봅시다.
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
# Nested 스키마 정의 - address는 구조체
address_schema = StructType([
StructField("city", StringType(), True),
StructField("state", StringType(), True),
StructField("zip_code", StringType(), True)
])
user_schema = StructType([
StructField("user_id", IntegerType(), False),
StructField("name", StringType(), False),
StructField("address", address_schema, True) # Nested 구조!
])
# Nested 데이터 삽입
from pyspark.sql import Row
df_nested = spark.createDataFrame([
Row(user_id=1, name="김철수", address=Row(city="서울", state="서울특별시", zip_code="06234"))
])
df_nested.write.format("delta").mode("append").save("/tmp/users_nested")
# Nested 필드에 새로운 서브필드 추가 (country 추가)
df_with_country = spark.createDataFrame([
Row(user_id=2, name="이영희", address=Row(city="부산", state="부산광역시", zip_code="48000", country="대한민국"))
])
df_with_country.write.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.save("/tmp/users_nested")
김개발 씨는 새로운 요구사항을 받고 고민에 빠졌습니다. "사용자 주소를 저장해야 하는데, 시, 도, 우편번호를 모두 따로 관리해야 한다고?" 처음에는 간단하게 생각했습니다.
address_city, address_state, address_zip_code 이렇게 세 개의 컬럼을 추가하면 되겠지. 그런데 뭔가 깔끔하지 않았습니다.
나중에 건물명, 상세주소도 추가해달라고 하면? 컬럼이 계속 늘어날 텐데...
박시니어 씨가 지나가다가 김개발 씨의 고민을 듣고는 조언합니다. "그런 경우에는 Nested 스키마를 쓰면 됩니다.
주소 관련 필드를 하나의 구조체로 묶는 거죠." 그렇다면 Nested 스키마란 정확히 무엇일까요? 쉽게 비유하자면, Nested 스키마는 마치 서랍장과 같습니다.
큰 서랍장 안에 작은 칸막이들이 있어서 양말, 속옷, 손수건을 각각 정리할 수 있죠. 모두 옷이라는 큰 카테고리에 속하지만 세부적으로 구분됩니다.
이처럼 Nested 스키마도 관련된 필드들을 하나의 구조체로 묶어서 논리적으로 그룹화할 수 있습니다. Nested 스키마가 없던 시절에는 어땠을까요?
모든 필드를 평면적으로 나열해야 했습니다. user_address_city, user_address_state, user_address_zip_code, user_phone_mobile, user_phone_home...
이렇게 필드 이름이 점점 길어지고 복잡해졌습니다. 어떤 필드가 어떤 그룹에 속하는지 이름으로만 파악해야 했죠.
더 큰 문제는 쿼리할 때였습니다. "주소 관련 필드를 모두 가져오고 싶은데..." 하나하나 SELECT 절에 나열해야 했습니다.
바로 이런 문제를 해결하기 위해 Nested 스키마가 등장했습니다. Nested 스키마를 사용하면 관련 필드를 논리적으로 그룹화할 수 있습니다.
또한 스키마가 더 읽기 쉽고 유지보수하기 쉬워집니다. 무엇보다 쿼리할 때 구조체 전체를 한 번에 다룰 수 있다는 큰 이점이 있습니다.
SELECT address라고만 쓰면 city, state, zip_code가 모두 딸려 나옵니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 3번째 줄부터 보면 address_schema라는 중첩된 구조체를 정의합니다. 이 구조체는 city, state, zip_code 세 개의 필드를 가집니다.
주소와 관련된 모든 정보를 하나로 묶은 것이죠. 10번째 줄에서 이 address_schema를 user_schema의 한 필드로 사용합니다.
address 필드의 타입이 단순한 String이 아니라 StructType입니다. 이것이 Nested 스키마의 핵심입니다.
17번째 줄을 보면 실제 데이터를 삽입할 때 Row 안에 또 다른 Row를 중첩합니다. address=Row(city="서울", state="서울특별시", zip_code="06234") 이런 식으로 말이죠.
데이터 구조가 스키마와 정확히 일치합니다. 흥미로운 부분은 23번째 줄부터입니다.
Nested 구조 안에 새로운 필드를 추가하는 예제입니다. address에 country라는 필드를 추가했습니다.
mergeSchema를 true로 설정하면 Delta Lake가 자동으로 address 구조체 안에 country 필드를 추가합니다. 기존 데이터의 address.country는 NULL이 됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 주문 시스템을 운영한다고 가정해봅시다.
주문 정보에는 배송 주소, 결제 정보, 상품 목록 등이 포함됩니다. 이것들을 모두 평면적으로 나열하면 수십 개의 컬럼이 생깁니다.
대신 shipping_address, payment_info, items처럼 Nested 구조로 만들면 훨씬 깔끔합니다. 나중에 배송 주소에 "배송 메모" 필드를 추가하고 싶으면 shipping_address 구조체 안에만 추가하면 됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 깊게 중첩하는 것입니다.
구조체 안에 구조체, 그 안에 또 구조체... 5단계, 6단계까지 중첩하면 쿼리하기도 어렵고 성능도 떨어집니다.
이렇게 하면 address.detail.building.floor.room 같은 복잡한 경로가 생깁니다. 따라서 중첩은 2-3단계까지만 사용하는 것이 좋습니다.
또 다른 실수는 관련 없는 필드를 억지로 묶는 것입니다. "다 사용자 정보니까 하나의 구조체로 묶자!" 이렇게 하면 오히려 혼란스럽습니다.
주소와 전화번호는 논리적으로 관련이 없으니 별도로 관리하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 조언대로 김개발 씨는 address를 Nested 구조체로 설계했습니다. 코드를 작성하고 나니 훨씬 깔끔했습니다.
며칠 후 기획팀에서 "상세 주소도 추가해주세요"라고 요청했을 때, 김개발 씨는 여유롭게 웃으며 address 구조체에 detail_address 필드만 추가했습니다. 기존 코드는 전혀 건드리지 않아도 되었습니다.
Nested 스키마 관리를 제대로 이해하면 복잡한 데이터를 체계적으로 관리하고 확장 가능한 스키마를 설계할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 관련된 필드들을 논리적으로 그룹화하여 Nested 구조로 만드세요
- 중첩 깊이는 2-3단계까지만 유지하세요
- Nested 필드에도 mergeSchema를 사용하여 서브필드를 추가할 수 있습니다
5. 스키마 충돌 해결 전략
김개발 씨의 파이프라인이 또 에러를 냈습니다. "Schema conflict detected!" 로그를 보니 같은 이름의 컬럼인데 타입이 다릅니다.
한쪽에서는 age를 Integer로 보내고, 다른 쪽에서는 String으로 보내고 있었습니다. 어떻게 해결해야 할까요?
스키마 충돌은 동일한 컬럼에 대해 서로 다른 타입이나 제약조건이 충돌할 때 발생합니다. 마치 두 사람이 같은 서랍에 서로 다른 물건을 넣으려고 할 때 생기는 혼란과 같습니다.
Delta Lake는 이런 충돌을 자동으로 감지하고 차단하지만, 개발자는 데이터 정규화, 타입 변환, 스키마 검증 등의 전략으로 사전에 충돌을 방지해야 합니다.
다음 코드를 살펴봅시다.
from pyspark.sql import functions as F
from pyspark.sql.types import IntegerType
# 문제 상황: 두 개의 다른 소스에서 age 타입이 다름
df_source1 = spark.createDataFrame([
(1, "김철수", 25) # age는 Integer
], ["user_id", "name", "age"])
df_source2 = spark.createDataFrame([
(2, "이영희", "30") # age는 String
], ["user_id", "name", "age"])
# 해결 전략 1: 타입 변환으로 통일
df_source2_fixed = df_source2.withColumn("age", F.col("age").cast(IntegerType()))
# 해결 전략 2: 스키마 검증 함수
def validate_and_cast_schema(df, target_schema):
for field in target_schema.fields:
if field.name in df.columns:
df = df.withColumn(field.name, F.col(field.name).cast(field.dataType))
return df
# 적용
df_validated = validate_and_cast_schema(df_source2, df_source1.schema)
# 이제 안전하게 병합 가능
df_source1.union(df_validated).write.format("delta").mode("append").save("/tmp/users")
김개발 씨는 월요일 아침부터 골치가 아팠습니다. 주말 동안 돌아간 파이프라인이 에러로 멈춰있었습니다.
"Schema conflict detected: Column 'age' has conflicting types: IntegerType vs StringType" 알고 보니 두 개의 서로 다른 데이터 소스에서 사용자 정보를 수집하고 있었습니다. 한쪽은 나이를 숫자로 보내고, 다른 쪽은 문자열로 보내고 있었습니다.
"왜 이렇게 통일이 안 되어 있지?" 박시니어 씨가 화면을 보더니 한숨을 쉽니다. "외부 시스템과 연동할 때 흔히 생기는 문제예요.
스키마 충돌을 해결해야 합니다." 그렇다면 스키마 충돌이란 정확히 무엇일까요? 쉽게 비유하자면, 스키마 충돌은 마치 두 사람이 같은 서랍장을 쓰는데 정리 방식이 다른 것과 같습니다.
한 사람은 양말을 돌돌 말아서 넣고, 다른 사람은 펼쳐서 넣습니다. 한 사람은 색깔별로 정리하고, 다른 사람은 용도별로 정리합니다.
결국 서랍이 엉망이 되죠. 이처럼 스키마 충돌도 같은 데이터를 서로 다른 방식으로 저장하려고 할 때 발생합니다.
스키마 충돌을 제대로 관리하지 않던 시절에는 어땠을까요? 데이터가 섞여서 들어오면 어떤 레코드는 정수형 age를 가지고, 어떤 레코드는 문자열 age를 가졌습니다.
쿼리할 때 타입 에러가 발생했고, 집계 함수가 제대로 작동하지 않았습니다. "평균 나이를 계산해주세요"라는 간단한 요청도 처리할 수 없었습니다.
더 큰 문제는 어디서부터 잘못되었는지 찾기도 어렵다는 점이었습니다. 바로 이런 문제를 해결하기 위해 스키마 충돌 해결 전략이 필요합니다.
스키마 충돌 해결 전략을 사용하면 데이터 일관성을 보장할 수 있습니다. 또한 런타임 에러를 사전에 방지할 수도 있습니다.
무엇보다 여러 소스의 데이터를 안전하게 통합할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 4번째 줄부터 보면 두 개의 서로 다른 데이터 소스를 시뮬레이션합니다. df_source1은 age가 정수형이고, df_source2는 age가 문자열입니다.
이 두 개를 그대로 합치려고 하면 충돌이 발생합니다. 14번째 줄이 첫 번째 해결 전략입니다.
cast() 함수로 타입을 통일합니다. 문자열 "30"을 정수 30으로 변환하는 것이죠.
이제 두 데이터프레임의 스키마가 일치합니다. 17번째 줄부터는 더 범용적인 해결책입니다.
validate_and_cast_schema 함수를 만들어서 타겟 스키마에 맞게 자동으로 타입을 변환합니다. 여러 컬럼에 충돌이 있어도 한 번에 처리할 수 있습니다.
이 함수는 타겟 스키마의 모든 필드를 순회하면서 타입을 맞춰줍니다. 24번째 줄에서 검증 함수를 적용하고, 28번째 줄에서 안전하게 union으로 병합합니다.
더 이상 충돌이 발생하지 않습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 여러 나라의 지사에서 데이터를 수집하는 글로벌 서비스를 운영한다고 가정해봅시다. 미국 지사는 날짜를 "MM/DD/YYYY" 형식으로 보내고, 한국 지사는 "YYYY-MM-DD" 형식으로 보냅니다.
이런 경우 중앙 파이프라인에서 스키마 검증 레이어를 두어 모든 데이터를 통일된 형식으로 변환한 후 저장합니다. 타입 변환 로직을 한 곳에 모아두면 유지보수도 쉽습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 타입 변환 시 예외 처리를 하지 않는 것입니다.
"25세"는 정수 25로 변환할 수 있지만, "스물다섯"은 변환할 수 없습니다. 이렇게 하면 런타임 에러가 발생합니다.
따라서 try-except나 조건문으로 변환 실패를 처리해야 합니다. 변환할 수 없는 값은 NULL로 처리하거나 별도 로그를 남기는 것이 좋습니다.
또 다른 실수는 스키마 충돌을 너무 늦게 발견하는 것입니다. 데이터가 Delta 테이블에 쓰일 때 에러가 나면 이미 많은 처리가 끝난 후입니다.
ETL 파이프라인 초반에 스키마 검증 단계를 두어 빨리 발견하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 조언대로 김개발 씨는 파이프라인 앞단에 스키마 검증 로직을 추가했습니다. 모든 데이터 소스에서 들어오는 데이터를 표준 스키마로 변환한 후에야 Delta 테이블에 쓰도록 만들었습니다.
일주일 후, 새로운 데이터 소스가 추가되었을 때도 문제가 없었습니다. 검증 로직이 자동으로 타입을 맞춰주었기 때문입니다.
김개발 씨는 뿌듯했습니다. 스키마 충돌 해결 전략을 제대로 이해하면 여러 소스의 데이터를 안전하게 통합하고 안정적인 파이프라인을 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - ETL 파이프라인 초반에 스키마 검증 레이어를 두세요
- 타입 변환 시 예외 처리를 반드시 추가하세요
- 변환할 수 없는 값은 NULL 처리하고 별도 로그를 남기세요
6. 스키마 버전 관리 베스트 프랙티스
김개발 씨의 팀은 이제 스키마를 자주 변경합니다. 새로운 필드를 추가하고, 타입을 바꾸고, 구조를 재설계합니다.
그런데 "3개월 전 스키마로 데이터를 읽어야 하는데 어떻게 하죠?"라는 질문을 받았습니다. 스키마 변경 이력을 어떻게 관리해야 할까요?
스키마 버전 관리는 스키마 변경 이력을 체계적으로 추적하고 관리하는 방법입니다. 마치 Git이 코드 변경 이력을 관리하듯이, 스키마도 버전을 매겨 관리해야 합니다.
Delta Lake는 트랜잭션 로그를 통해 모든 스키마 변경을 자동으로 기록하며, DESCRIBE HISTORY 명령으로 언제든지 과거 스키마를 조회할 수 있습니다.
다음 코드를 살펴봅시다.
from delta.tables import DeltaTable
# 스키마 변경 이력 조회
delta_table = DeltaTable.forPath(spark, "/tmp/users")
history = delta_table.history()
history.select("version", "timestamp", "operation", "operationMetrics").show(truncate=False)
# 특정 버전의 스키마 확인
version_2_schema = spark.read.format("delta").option("versionAsOf", 2).load("/tmp/users").schema
print("Version 2 스키마:", version_2_schema)
# 타임스탬프 기준으로 과거 데이터 읽기
from datetime import datetime, timedelta
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
df_yesterday = spark.read.format("delta").option("timestampAsOf", yesterday).load("/tmp/users")
# 현재 스키마와 비교
current_schema = spark.read.format("delta").load("/tmp/users").schema
print("현재 스키마:", current_schema)
print("어제 스키마:", df_yesterday.schema)
# 스키마 변경 시 문서화 (메타데이터에 설명 추가)
spark.sql("""
ALTER TABLE delta.`/tmp/users`
SET TBLPROPERTIES (
'schema.version' = 'v3',
'schema.change.description' = 'Added email and phone fields for marketing campaign'
)
""")
김개발 씨의 팀은 이제 스키마 진화를 자유자재로 사용합니다. 지난 3개월 동안 사용자 테이블에 email을 추가했고, phone을 추가했고, address를 Nested 구조로 바꿨습니다.
매끄럽게 잘 진행되었죠. 그런데 어느 날 데이터 분석가 정분석 씨가 찾아왔습니다.
"3개월 전 데이터를 분석하려는데, 그때는 email 필드가 없었잖아요. 그때 스키마로 데이터를 읽을 수 있나요?" 김개발 씨는 당황했습니다.
"음... 스키마를 여러 번 바꿨는데 언제 뭘 바꿨는지 기억이 안 나는데요?" 박시니어 씨가 웃으며 말합니다.
"걱정 마세요. Delta Lake가 다 기록하고 있어요.
스키마 버전 관리를 해보죠." 그렇다면 스키마 버전 관리란 정확히 무엇일까요? 쉽게 비유하자면, 스키마 버전 관리는 마치 건물의 증축 이력을 기록하는 것과 같습니다.
"2020년에 2층을 증축했고, 2021년에 베란다를 확장했고, 2022년에 지하실을 리모델링했다" 이런 이력이 남아있으면 언제든지 과거로 돌아가 볼 수 있습니다. 이처럼 스키마 버전 관리도 모든 변경 이력을 추적하여 필요할 때 과거 시점의 스키마를 확인하거나 복원할 수 있게 해줍니다.
스키마 버전 관리가 없던 시절에는 어땠을까요? 개발자들은 스키마를 바꿀 때마다 수동으로 메모를 남겼습니다.
"2023-05-15: email 필드 추가", "2023-07-20: age 타입을 BIGINT로 변경" 같은 식으로요. 문제는 이런 메모를 빼먹거나 잘못 기록하는 경우가 많았다는 점입니다.
또한 정확히 어느 시점에 어떤 스키마였는지 재현하기도 어려웠습니다. 과거 데이터를 분석하려고 할 때 스키마 불일치 에러가 자주 발생했습니다.
바로 이런 문제를 해결하기 위해 Delta Lake는 자동 스키마 버전 관리 기능을 제공합니다. Delta Lake의 스키마 버전 관리를 사용하면 모든 변경이 자동으로 기록됩니다.
또한 언제든지 과거 시점으로 돌아가 스키마를 확인할 수 있습니다. 무엇보다 타임 트래블 기능으로 과거 데이터를 정확한 스키마로 읽을 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 3번째 줄부터 보면 DESCRIBE HISTORY로 변경 이력을 조회합니다.
이 명령은 테이블에 대한 모든 트랜잭션을 시간 순서대로 보여줍니다. 언제, 어떤 작업이, 누가, 어떤 결과로 수행되었는지 모두 기록되어 있습니다.
8번째 줄에서는 특정 버전의 스키마를 확인합니다. versionAsOf 옵션을 사용하면 버전 2 시점의 스키마를 가져올 수 있습니다.
이때 실제 데이터를 읽지 않고 스키마만 확인하는 것도 가능합니다. 13번째 줄부터는 타임스탬프 기준으로 과거 데이터를 읽는 예제입니다.
timestampAsOf 옵션으로 "어제"의 데이터를 읽습니다. 이때 자동으로 어제 시점의 스키마가 적용됩니다.
22번째 줄부터는 메타데이터에 스키마 변경 설명을 추가하는 부분입니다. 단순히 스키마만 바꾸는 것이 아니라, "왜 바꿨는지"도 함께 기록합니다.
schema.version과 schema.change.description을 테이블 속성에 추가하면 나중에 다른 개발자가 보고 이해하기 쉽습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 금융 서비스를 운영한다고 가정해봅시다. 규제 때문에 과거 3년간의 거래 데이터를 정확히 보관해야 합니다.
그런데 스키마는 계속 진화합니다. 새로운 거래 유형이 추가되고, 필드가 바뀝니다.
감사 시점에 "2년 전 1월의 데이터를 정확히 보여주세요"라는 요청이 들어오면 어떻게 할까요? Delta Lake의 타임 트래블 기능으로 해당 시점의 정확한 스키마와 데이터를 복원할 수 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 스키마를 바꾸면서 문서화를 하지 않는 것입니다.
Delta Lake가 자동으로 기록하긴 하지만, "왜 바꿨는지"는 자동으로 알 수 없습니다. 이렇게 하면 6개월 후 다른 개발자가 이력을 보고 "왜 이렇게 바꿨지?" 하고 고민하게 됩니다.
따라서 스키마 변경 시 반드시 이유와 맥락을 메타데이터나 커밋 메시지로 남겨야 합니다. 또 다른 실수는 너무 자주 스키마를 바꾸는 것입니다.
일주일에 한 번씩 스키마를 바꾸면 버전이 너무 많아져서 관리가 어렵습니다. 스키마 변경은 신중하게, 필요할 때만 수행해야 합니다.
여러 작은 변경을 모아서 한 번에 하는 것도 방법입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 도움으로 김개발 씨는 3개월 전 스키마를 찾아냈습니다. DESCRIBE HISTORY로 이력을 조회하고, versionAsOf로 해당 버전의 데이터를 읽어서 정분석 씨에게 전달했습니다.
정분석 씨는 감탄했습니다. "와, 정확히 그때 스키마네요!
어떻게 이렇게 빨리 찾으셨어요?" 김개발 씨는 자신 있게 대답했습니다. "Delta Lake가 다 기록하고 있어서요.
타임 트래블 기능 덕분입니다." 이제 김개발 씨는 스키마를 바꿀 때마다 테이블 속성에 변경 이유를 기록합니다. 나중에 자신도, 다른 개발자도 쉽게 이해할 수 있도록 말이죠.
스키마 버전 관리 베스트 프랙티스를 제대로 이해하면 스키마 변경 이력을 체계적으로 추적하고, 언제든지 과거로 돌아갈 수 있는 안전한 데이터 플랫폼을 구축할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 스키마 변경 시 반드시 메타데이터에 변경 이유와 맥락을 기록하세요
- 정기적으로 DESCRIBE HISTORY를 확인하여 변경 이력을 리뷰하세요
- 중요한 스키마 변경은 팀원들과 공유하고 문서화하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
보안 아키텍처 구성 완벽 가이드
프로젝트의 보안을 처음부터 설계하는 방법을 배웁니다. AWS 환경에서 VPC부터 WAF, 암호화, 접근 제어까지 실무에서 바로 적용할 수 있는 보안 아키텍처를 단계별로 구성해봅니다.
AWS Organizations 완벽 가이드
여러 AWS 계정을 체계적으로 관리하고 통합 결제와 보안 정책을 적용하는 방법을 실무 스토리로 쉽게 배워봅니다. 초보 개발자도 바로 이해할 수 있는 친절한 설명과 실전 예제를 제공합니다.
AWS KMS 암호화 완벽 가이드
AWS KMS(Key Management Service)를 활용한 클라우드 데이터 암호화 방법을 초급 개발자를 위해 쉽게 설명합니다. CMK 생성부터 S3, EBS 암호화, 봉투 암호화까지 실무에 필요한 모든 내용을 담았습니다.
AWS Secrets Manager 완벽 가이드
AWS에서 데이터베이스 비밀번호, API 키 등 민감한 정보를 안전하게 관리하는 Secrets Manager의 핵심 개념과 실무 활용법을 배워봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실전 예제와 함께 설명합니다.