지난 포스팅에서 MySQL의 최신 표준 콜레이션(utf8mb4_0900_ai_ci, utf8mb4_unicode_ci)이 유니코드 가중치 알고리즘(UCA)에 의해 특수문자 ª를 a로, ²를 2로 동일시하여 데이터 이관 시 중복 오류나 데이터 왜곡을 일으키는 현상을 살펴보았습니다.

이 비교표를 보신 많은 엔지니어분들이 다음과 같은 질문을 던지십니다.

"그렇다면 'ª' = 'a'를 False로 처리하는 utf8mb4_general_ci로 설정하면 데이터 변질이나 충돌을 완벽히 막을 수 있지 않을까요?"

결론부터 말씀드리면 "반은 맞고, 반은 매우 위험한 착각"입니다. 당장 ª와 ²의 충돌은 피할 수 있지만, utf8mb4_general_ci는 또 다른 형태의 심각한 문자 변질(유실 및 왜곡)을 유발하는 레거시(Legacy) 콜레이션이기 때문입니다.

 

이번 글에서는 왜 utf8mb4_general_ci가 근본적인 해결책이 될 수 없는지 내부 아키텍처와 실행 계획을 통해 낱낱이 파헤쳐 보겠습니다.

 


1. utf8mb4_general_ci의 내부 아키텍처와 치명적 한계

utf8mb4_general_ci는 과거 CPU 연산 성능이 부족하던 시절, 정렬과 비교 속도를 극대화하기 위해 유니코드 표준 알고리즘(UCA)을 무시하고 만든 단순 1:1 매핑(Simple Mapping) 방식의 콜레이션입니다.

① 왜 ª와 ²는 변질되지 않았을까?

general_ci는 복잡한 호환성 분해(NFKD)를 수행하지 않습니다. 내부 매핑 테이블에 정의된 일부 라틴 문자만 대문자로 치환할 뿐, 매핑 테이블에 없는 ª(U+00AA)나 ²(U+00B2) 같은 문자는 변환을 포기하고 유니코드 코드포인트(바이트 값) 그대로 가중치에 반영합니다. 그 결과 우연히 ª와 a가 구분된 것일 뿐, 문자의 원형을 보호하기 위해 설계된 것이 아닙니다.

② general_ci가 일으키는 또 다른 문자 변질 참사

그렇다면 매핑 테이블에 존재하는 다른 특수문자나 다국어 문자가 들어오면 어떻게 될까요? general_ci 역시 가차 없이 문자를 변질시킵니다.

-- utf8mb4_general_ci 환경에서의 문자 비교 및 내부 가중치(WEIGHT_STRING) 확인
SELECT 
    'ä' = 'a' COLLATE utf8mb4_general_ci AS umlaut_to_a,
    'é' = 'e' COLLATE utf8mb4_general_ci AS acute_to_e,
    'ß' = 's' COLLATE utf8mb4_general_ci AS eszett_to_s,
    HEX(WEIGHT_STRING('a' COLLATE utf8mb4_general_ci)) AS weight_a,
    HEX(WEIGHT_STRING('ä' COLLATE utf8mb4_general_ci)) AS weight_umlaut,
    HEX(WEIGHT_STRING('ß' COLLATE utf8mb4_general_ci)) AS weight_eszett,
    HEX(WEIGHT_STRING('s' COLLATE utf8mb4_general_ci)) AS weight_s;
umlaut_to_a acute_to_e eszett_to_s weight_a weight_umlaut weight_eszett weight_s
1 (True) 1 (True) 1 (True) 0041 0041 0053 0053

위 결과에서 볼 수 있듯, general_ci는 다음과 같은 심각한 정합성 문제를 야기합니다.

  • 악센트 강제 제거: 유럽어권이나 외래어 표기에 흔히 쓰이는 ä, à, á, é, ñ 등의 문자를 모두 일반 알파벳 a(0x0041), e(0x0045), n(0x004E)으로 강제 치환합니다.
  • 잘못된 언어 규칙 적용: 독일어 에스체트(ß)는 원래 'ss'(2글자)와 매핑되어야 맞지만, general_ci는 글자 확장을 지원하지 못해 임의로 단일 문자 's'(0x0053)로 뭉개버립니다.
  • 보충 문자(4바이트 이모지 및 특수 한자) 처리 버그: BMP(Basic Multilingual Plane) 영역을 벗어나는 4바이트 문자들을 일괄적으로 동일한 가중치(0xFFFD, Replacement Character)로 처리하거나 잘못 정렬하는 역사적 결함을 안고 있습니다.

2. 한눈에 보는 Collation 문자 변질(동등 처리) 매트릭스

데이터 이관 관점에서 각 콜레이션이 어떤 문자를 변질(동일시)시키는지 비교한 아키텍처 매트릭스입니다.

비교 대상 문자 쌍 utf8mb4_0900_ai_ci(MySQL 8.0 기본) utf8mb4_general_ci(레거시 단순 매핑) utf8mb4_0900_as_cs(엄격한 유니코드 구분) utf8mb4_bin(바이너리 바이트 비교)
대소문자 ('A' = 'a') O (동일 취급) O (동일 취급) X (구분함) X (구분함)
악센트 문자 ('ä' = 'a', 'é' = 'e') O (동일 취급) O (동일 취급 - 변질!) X (구분함) X (구분함)
위첨자/서수 ('ª' = 'a', '²' = '2') O (동일 취급) X (우연히 구분함) X (구분함) X (구분함)
전각/반각 문자 ('A' = 'A') O (동일 취급) X (우연히 구분함) X (구분함) X (구분함)
이모지 구분 ('😀' = '😁') X (정확히 구분) 위험 (과거 버전 버그 이력) X (구분함) X (구분함)
데이터 원형 보존성 낮음 (검색 특화) 불완전 (예측 불가) 매우 높음 완벽 (100% 바이트 일치)

즉, utf8mb4_general_ci를 선택하는 것은 ª라는 지뢰 하나를 피하기 위해 ä, é, ß라는 수많은 지뢰밭으로 걸어 들어가는 것과 같습니다.

3. 실무에서 general_ci 도입 시 발생하는 실행 계획(Execution Plan) 참사

MySQL 8.0 환경에서 특정 테이블만 변질을 막겠다며 utf8mb4_general_ci로 설정하면, 기본 콜레이션(utf8mb4_0900_ai_ci)을 사용하는 다른 테이블과 JOIN할 때 심각한 성능 저하와 에러가 발생합니다.

① Collation 충돌 에러 (Illegal mix of collations)

두 테이블의 컬럼 콜레이션 강도(Coercibility)가 동일하기 때문에, 조인 시 MySQL 옵티마이저는 어느 쪽 기준으로 비교할지 결정하지 못하고 즉시 에러를 뱉어냅니다.

-- table_a (utf8mb4_0900_ai_ci) 와 table_b (utf8mb4_general_ci) 조인 시도
SELECT * FROM table_a a JOIN table_b b ON a.code = b.code;
-- ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_0900_ai_ci,IMPLICIT) and (utf8mb4_general_ci,IMPLICIT) for operation '='

② 암시적 형변환으로 인한 인덱스 무력화 (Full Table Scan)

에러를 피하기 위해 쿼리에 COLLATE 절을 명시하거나, 스토어드 프로시저의 세션 콜레이션과 테이블 콜레이션이 어긋날 경우 인덱스 컬럼이 내부적으로 형변환(CONVERT())되어 B-Tree 인덱스를 전혀 타지 못합니다.

-- 실행 계획 확인: b.code의 인덱스가 깨지고 Full Table Scan(ALL) 발생
EXPLAIN SELECT * FROM table_a a 
JOIN table_b b ON a.code = b.code COLLATE utf8mb4_0900_ai_ci;
id select_type table type possible_keys key rows Extra
1 SIMPLE a ALL NULL NULL 10000 NULL
1 SIMPLE b ALL NULL NULL 5000000 Using where; Using join buffer (hash join)

4. 시니어 아키텍트의 최종 처방: 변질을 완벽히 막는 올바른 설계

데이터 이관 시 특수문자, 악센트, 위첨자 등 모든 문자의 논리적 변질과 중복 충돌을 원천 차단하려면, 어중간한 utf8mb4_general_ci가 아니라 다음 두 가지 표준 전략을 사용해야 합니다.

처방 1: 데이터 원형 100% 보존이 필요한 경우 → utf8mb4_bin (가장 권장)

문자열의 언어적 의미를 완전히 배제하고 오직 UTF-8 바이트 시퀀스(Hex 값) 자체로만 비교합니다.

  • 'a'(0x61), 'A'(0x41), 'ª'(0xC2AA), 'ä'(0xC3A4)를 모두 완전히 다른 독립된 데이터로 취급합니다.

복잡한 유니코드 가중치 계산을 생략하고 메모리 바이트만 비교하므로, 비교 및 인덱스 정렬 속도가 모든 콜레이션 중 가장 빠릅니다.

-- 마이그레이션 타겟 테이블 설계 시 완벽한 원형 보존 설정
CREATE TABLE migration_target (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    raw_code VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
    description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
    UNIQUE KEY uk_raw_code (raw_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

처방 2: 대소문자만 무시하고 모든 특수/악센트 문자는 구분하고 싶은 경우

만약 비즈니스 요구사항상 'a'와 'A'는 같아야 하지만, 'ª', 'ä', '²' 같은 특수·악센트 문자는 절대 변질되면 안 된다면 어떻게 해야 할까요? 안타깝게도 MySQL은 as_ci (Accent-Sensitive, Case-Insensitive) 콜레이션을 기본 제공하지 않습니다. 이 경우 가상 생성 컬럼(Generated Column)과 함수 기반 인덱스를 조합하는 아키텍처 패턴으로 우아하게 해결할 수 있습니다.

-- 원본은 utf8mb4_bin으로 100% 보존하되, 검색/유니크 제약은 소문자 변환 후 바이트 비교
ALTER TABLE users 
ADD COLUMN username_lower VARCHAR(100) GENERATED ALWAYS AS (LOWER(username)) VIRTUAL,
ADD UNIQUE INDEX idx_username_as_ci (username_lower COLLATE utf8mb4_bin);
  • 작동 원리: LOWER() 함수는 순수 알파벳 대문자('A')만 소문자('a')로 바꿀 뿐, 'ª'나 'ä'는 건드리지 않습니다. 그 결과를 utf8mb4_bin으로 인덱싱하면 대소문자는 무시하면서 모든 특수문자의 변질은 완벽히 차단하는 커스텀 콜레이션 효과를 얻을 수 있습니다.

5. 아키텍트 요약 (Takeaway)

  1. utf8mb4_general_ci는 절대 대안이 아닙니다. ª와 ²는 우연히 피해 가지만, ä, é, ß 등 수많은 다국어 및 특수기호를 여전히 일반 알파벳으로 변질시키는 구형 비표준 콜레이션입니다.
  2. MySQL 8.0 이상에서 general_ci는 공식적으로 Deprecated(사용 중지 권고) 수순을 밟고 있는 레거시 기술이며, 타 테이블과 조인 시 심각한 콜레이션 충돌을 일으킵니다.
  3. 데이터 이관 시 문자 변질과 중복 키 오류를 막기 위한 유일무이한 정석은 utf8mb4_bin 또는 utf8mb4_0900_as_cs를 적용하는 것입니다.

 

Apache Airflow 클러스터를 설계할 때 많은 엔지니어들이 가장 먼저 부딪히는 아키텍처적 고민은 "우리 조직의 워크로드에 어떤 Executor를 선택해야 하는가?"입니다. 하루 수백만 건의 가벼운 SQL 쿼리를 트리거하는 파이프라인과, 수십 GB의 데이터를 메모리에 올려 처리하거나 무거운 파이썬 머신러닝 패키지를 구동해야 하는 파이프라인은 요구하는 리소스 특성이 완전히 다르기 때문입니다.

 

과거에는 클러스터 전체에 단 하나의 Executor만 강제되었기 때문에 '빠른 실행 속도(Celery)'와 '완벽한 환경 격리(Kubernetes)' 사이에서 고통스러운 타협을 해야 했습니다. 하지만 최신 Airflow 아키텍처(Airflow 2.7~2.10 및 최신 3.x)에서는 다중 실행기(Hybrid / Multi-Executor)의 표준화, Edge Executor의 등장, 그리고 워커의 DB 직접 접근 차단(Task Execution API)을 통해 Executor의 개념과 작동 방식이 완전히 새롭게 진화했습니다.

 

이번 포스팅에서는 Airflow Executor의 내부 작동 원리(Lifecycle)를 해부하고, 현존하는 핵심 Executor들의 아키텍처 비교 및 실무 환경에서의 트러블슈팅 노하우를 깊이 있게 다루어 보겠습니다.

 


1. Executor의 본질과 내부 작동 원리 (Architecture)

Airflow에서 흔히 오해하는 것 중 하나가 "스케줄러(Scheduler)나 Executor가 태스크(Task) 코드를 직접 실행한다"는 것입니다. 정확히 말하면 Executor는 스케줄러 프로세스 내부에서 동작하는 플러그인 형태의 '작업 할당 및 위임 메커니즘(Mechanism)'이며, 실제 코드는 워커(Worker)에서 실행됩니다.

스케줄링 루프와 Executor의 상호작용 생명주기

스케줄러가 메타데이터 DB(MySQL/PostgreSQL)를 스캔하여 실행할 태스크를 찾고 워커에 전달하기까지의 내부 흐름은 다음과 같습니다.

  1. 태스크 선점 (SCHEDULED → QUEUED): 스케줄러가 메타데이터 DB의 task_instance 테이블에서 실행 조건이 충족된 태스크를 찾아 상태를 QUEUED로 변경합니다(내부적으로 SELECT ... FOR UPDATE SKIP LOCKED 활용).
  2. Executor 큐잉 (queue_command): 스케줄러는 설정된 Executor 인스턴스의 큐에 실행할 태스크의 식별자(TaskInstanceKey)와 실행 명령어를 밀어 넣습니다.
  3. 하트비트 및 작업 위임 (heartbeat → trigger_tasks): 스케줄러 루프가 돌 때마다 Executor의 heartbeat() 메서드가 호출됩니다. 이때 가용한 워커 슬롯(parallelism, pool 등)을 계산한 뒤, 외부 브로커(Redis/RabbitMQ)나 컨테이너 오케스트레이터(Kubernetes API Server)로 태스크 실행 요청을 발송합니다.
  4. 워커 실행 및 상태 동기화 (RUNNING → SUCCESS/FAILED):
    • Airflow 2.x 방식: 워커 프로세스가 깨어나면 직접 메타데이터 DB에 SQLAlchemy 커넥션을 맺고 자신의 상태를 RUNNING으로 업데이트한 뒤 작업을 수행합니다.
    • Airflow 3.x 방식 (가장 큰 아키텍처 변화): 워커의 DB 직접 접근이 전면 차단되었습니다(AIP-44). Executor가 태스크를 워커에 전달할 때 단기(Short-lived) JWT 실행 토큰을 함께 주입하며, 워커는 이 토큰을 사용해 api-server (Task Execution API)와 HTTP 통신을 수행하며 상태를 보고하고 XCom을 주고받습니다.
  5. 이벤트 버퍼 수거 (sync): Executor는 주기적으로 sync() 메서드를 호출하여 완료되거나 비정상 종료(OOM, Pod Eviction 등)된 워커의 상태를 확인하고 스케줄러에 최종 보고합니다.

 

2. 핵심 Executor 종류별 아키텍처 및 실무 비교

Airflow의 Executor는 크게 로컬 실행형(Local)과 원격 분산 실행형(Remote/Distributed)으로 나뉩니다. 실무에서 고려되는 주요 Executor들의 아키텍처적 특징은 다음과 같습니다.

Executor 종류 실행 위치 및 메커니즘 장점 (Pros) 단점 및 한계 (Cons) 권장 실무 워크로드
LocalExecutor 스케줄러와 동일한 서버(노드) 내에서 서브프로세스(multiprocessing)를 생성하여 병렬 실행 • 외부 브로커(Redis 등) 불필요

• 네트워크 오버헤드가 없어 태스크 시작 지연(Latency)이 가장 짧음
• 스케줄러 노드의 CPU/Memory를 공유하므로 무거운 작업 시 스케줄러 장애 유발

• 수평 확장(Scale-out) 불가
중소규모 단일 서버 환경, DB 쿼리 위임(SQLExecuteQueryOperator) 위주의 가벼운 파이프라인
CeleryExecutor Redis/RabbitMQ 브로커에 태스크를 큐잉하고, 상시 대기 중인 다수의 Celery 워커 노드가 가져가서 실행 • 워커가 항상 떠 있어(Warm Start) 태스크 실행 지연 시간이 매우 짧음 (1~2초 내외)

• 대규모 동시성 처리에 탁월
• 모든 워커가 동일한 Python 환경을 공유(패키지 충돌 위험)

• 특정 태스크의 OOM이 같은 워커의 다른 태스크에 영향을 줌

• 유휴 시간에도 워커 리소스 비용 발생
실행 시간이 짧고 빈도가 매우 높은(High-throughput) 수천 개의 정형 배치 파이프라인
KubernetesExecutor 브로커 없이 스케줄러가 K8s API를 직접 호출해 매 태스크마다 독립된 Pod를 동적 생성 및 종료 • 태스크별 완벽한 리소스(CPU/RAM) 및 패키지(Docker Image) 격리

• 유휴 시 워커 Pod가 0개로 축소(Scale-to-Zero)되어 비용 절감
• 매번 Pod를 띄우는 데 걸리는 시간(Cold Start Latency, 10~60초+) 발생

• 짧은 태스크가 수천 개 몰리면 K8s API 서버 및 etcd 부하 급증
리소스 소모가 큰 대용량 처리, 팀별로 상이한 파이썬 패키지 의존성을 가진 환경
CeleryKubernetesExecutor

(레거시 하이브리드)
기본은 Celery로 돌리고, 특정 queue 이름으로 지정된 태스크만 K8s Executor로 라우팅 (Airflow 2.0+) • Celery의 속도와 K8s의 격리성을 동시에 확보 가능 • queue 파라미터를 라우팅 용도로 편법 사용하여 직관성이 떨어짐 (최신 버전에서는 Multi-Executor로 대체됨) Airflow 2.6 이하 버전에서의 하이브리드 구성
EdgeExecutor

(Airflow 2.10+ / 3.x)
원격지(On-premise, 망분리 DMZ 등)의 경량 에이전트가 중앙 api-server를 HTTPS로 폴링(Pull)하여 실행 • 방화벽 인바운드 오픈이나 브로커 노출 없이 원격지/하이브리드 클라우드 워커 제어 가능 • 중앙 클러스터와 원격 워커 간의 HTTP 통신 지연 고려 필요 금융/대기업 등 망분리 환경의 레거시 DB(Oracle, Tibero 등) 데이터 이관 및 원격 제어
LocalKubernetesExecutor LocalExecutor와 K8s Executor의 결합 • 브로커 인프라 없이 빠른 로컬 실행과 K8s Pod 격리를 동시에 구현 • 로컬 태스크가 많아지면 스케줄러 파드 리소스 경합 발생 K8s 환경에서 Redis 관리 포인트를 없애고 싶은 중소규모 조직

(참고: 과거에 존재하던 SequentialExecutor는 SQLite 테스트용을 제외하면 사실상 폐기되었으며, DaskExecutor나 MesosExecutor 등은 생태계 변화에 따라 코어에서 제거되거나 별도 Provider로 분리되어 현재 실무에서는 거의 사용되지 않습니다.)

3. 버전 진화에 따른 Executor의 3대 혁신 (Airflow 2.x 후반 ~ 3.x)

"버전이 올라가면서 Executor도 바뀌었는가?"에 대한 대답은 "단순한 기능 추가를 넘어 아키텍처 레벨의 대격변이 일어났다"입니다.

① 멀티 실행기: Hybrid / Multi-Executor (Airflow 2.7 ~ 3.x)

과거에는 CeleryKubernetesExecutor처럼 특정 조합만 하드코딩된 클래스를 써야 했고, 라우팅을 위해 queue='kubernetes' 같은 꼼수를 써야 했습니다.

Airflow 2.7(AIP-51)부터 도입되어 Airflow 3.x에서 완전히 표준화된 Multi-Executor 아키텍처에서는 core.executor 설정에 콤마(,)로 여러 개의 Executor를 동시에 등록하고, 태스크나 DAG 레벨에서 executor 파라미터를 명시적으로 지정할 수 있습니다.

  • 아키텍처 활용 시나리오:
    • 전체 파이프라인의 90%를 차지하는 가벼운 MySQL/Oracle 쿼리 호출 및 센서 대기 작업은 기본값인 CeleryExecutor로 1초 만에 빠르게 처리합니다.
    • 나머지 10%에 해당하는 대용량 데이터 검증 스크립트나 무거운 라이브러리가 필요한 태스크만 executor="KubernetesExecutor"를 부여하여 독립된 고사양 Pod에서 안전하게 실행합니다.

② 망분리 및 원격지 제어를 위한 EdgeExecutor (AIP-69)

대기업이나 금융권에서 데이터 마이그레이션(예: 온프레미스 Oracle/Tibero에서 클라우드 MySQL/Aurora로 이관)을 수행할 때 가장 큰 장벽은 네트워크 방화벽입니다. 클라우드에 구축된 Airflow에서 온프레미스 DB망으로 직접 붙으려면 복잡한 VPN이나 방화벽 인바운드 정책을 뚫어야 하고, 원격지에 Celery 워커를 두자니 Redis 브로커와 메타데이터 DB 포트를 외부로 열어주어야 하는 보안 문제가 발생했습니다.

이를 해결하기 위해 등장한 것이 EdgeExecutor (apache-airflow-providers-edge)입니다.

  • 작동 원리: 온프레미스나 폐쇄망 내부에 경량 airflow edge worker 프로세스를 띄웁니다. 이 워커는 인바운드 포트를 전혀 열지 않고, 아웃바운드로 중앙 Airflow의 웹/API 서버(포트 443 HTTPS)로 주기적인 Polling 요청을 보내 할당된 태스크를 가져와 실행한 뒤 결과와 로그를 다시 HTTPS로 전송합니다.
  • 실무적 가치: Airflow 3.x의 Zero DB Access 철학과 완벽히 결합되어, 원격지 데이터 센터의 데이터를 추출하거나 이관 검증 스크립트를 돌릴 때 보안팀과의 마찰 없이 하이브리드 파이프라인을 구축할 수 있습니다.

③ Executor와 메타데이터 DB의 결별 (Airflow 3.x)

앞서 언급했듯 Airflow 2.x까지는 KubernetesExecutor로 500개의 파드를 동시에 띄우면, 500개의 파드가 뜨자마자 일제히 메타데이터 DB(MySQL)에 커넥션을 맺어 DB의 max_connections 고갈 장애를 유발했습니다.

Airflow 3.x부터는 어떤 Executor를 사용하든 워커는 DB의 존재조차 알지 못하며, 오직 api-server와만 통신하므로 KubernetesExecutor나 CeleryExecutor의 대규모 스케일 아웃 시 발생하던 메타데이터 DB 병목이 근본적으로 해결되었습니다.

 

4. 실전 설정(airflow.cfg) 및 Multi-Executor DAG 구현 코드

최신 Airflow 환경에서 CeleryExecutor, KubernetesExecutor, 그리고 EdgeExecutor를 동시에 구성하고 하나의 DAG 안에서 태스크 특성에 맞게 골라 쓰는 실무 예제입니다.

① airflow.cfg (또는 환경변수) 멀티 실행기 설정

별칭(Alias) 기능을 활용하면 코드 가독성을 크게 높일 수 있습니다.

[core]
# 기본 실행기는 Celery로 설정하고, K8s와 Edge 실행기를 별칭(Alias)과 함께 동시에 등록
executor = CeleryExecutor, k8s_exec:KubernetesExecutor, onprem_edge:airflow.providers.edge.executors.edge_executor.EdgeExecutor
parallelism = 128
max_active_tasks_per_dag = 32

[celery]
worker_concurrency = 16
broker_url = redis://redis-cluster.internal:6379/0

[kubernetes_executor]
namespace = airflow-workers
worker_container_repository = my-registry.io/data-platform/airflow-worker
worker_container_tag = 3.0.2-prod
delete_worker_pods = True
delete_worker_pods_on_failure = False # 장애 분석을 위해 실패한 파드는 즉시 삭제하지 않음

② 태스크별로 최적의 Executor를 라우팅하는 DAG 코드

from datetime import datetime, timedelta
from airflow.sdk import dag, task
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from kubernetes.client import models as k8s

default_args = {
    "owner": "data_arch_team",
    "retries": 1,
    "retry_delay": timedelta(minutes=3),
}

@dag(
    dag_id="hybrid_executor_data_migration_validation",
    default_args=default_args,
    schedule="0 3 * * *",
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["migration", "multi-executor", "mysql", "tibero"],
)
def migration_validation_pipeline():

    # 1. [EdgeExecutor 활용] 온프레미스 망에 있는 레거시 DB(Tibero/Oracle)의 건수 추출
    # onprem_edge 워커가 아웃바운드로 이 태스크를 가져가 내부망에서 안전하게 실행함
    @task(executor="onprem_edge", queue="dmz_migration_zone")
    def get_legacy_db_row_count(table_name: str, target_date: str) -> dict:
        # 온프레미스 내부망에서만 접근 가능한 DB 조회 로직 수행
        print(f"Connecting to On-Premise Legacy DB for {table_name}...")
        legacy_cnt = 15420300 # 예시 결과값
        return {"table": table_name, "legacy_count": legacy_cnt}

    # 2. [ 기본 CeleryExecutor 활용] 클라우드 타겟 DB(MySQL)의 통계 테이블 사전 정리
    # 가벼운 쿼리 호출이므로 Warm 상태인 Celery 워커에서 지연 없이 1초 만에 실행됨 (executor 생략 시 기본값 사용)
    init_target_audit_table = SQLExecuteQueryOperator(
        task_id="init_target_audit_table",
        conn_id="mysql_cloud_prod",
        sql="""
            DELETE FROM migration_audit.daily_row_check 
            WHERE check_date = '{{ ds }}' AND table_name = 'TB_ORDER_DTL';
        """,
    )

    # 3. [KubernetesExecutor 활용] 대용량 해시(Checksum) 비교 및 데이터 정합성 검증
    # 메모리를 많이 소모하므로 독립된 고사양 K8s Pod를 동적으로 생성하여 격리 실행
    @task(
        executor="k8s_exec",
        executor_config={
            "pod_override": k8s.V1Pod(
                spec=k8s.V1PodSpec(
                    containers=[
                        k8s.V1Container(
                            name="base",
                            resources=k8s.V1ResourceRequirements(
                                requests={"cpu": "2000m", "memory": "8Gi"},
                                limits={"cpu": "4000m", "memory": "16Gi"},
                            ),
                        )
                    ]
                )
            )
        },
    )
    def heavy_checksum_validation(legacy_meta: dict, target_date: str):
        print(f"Running heavy memory validation on dedicated K8s Pod for {legacy_meta['table']}...")
        # 16GB 메모리가 보장된 독립 Pod에서 OOM 걱정 없이 대용량 청크 검증 수행
        return "VALIDATION_SUCCESS"

    # 워크플로우 실행 흐름 구성
    legacy_info = get_legacy_db_row_count(table_name="TB_ORDER_DTL", target_date="{{ ds }}")
    validation_result = heavy_checksum_validation(legacy_meta=legacy_info, target_date="{{ ds }}")
    
    init_target_audit_table >> validation_result

migration_validation_pipeline()

5. 실무 트러블슈팅 & 운영 팁

Executor를 프로덕션 환경에서 운영할 때 반드시 겪게 되는 대표적인 장애 케이스와 아키텍처 튜닝 포인트입니다.

① CeleryExecutor의 '유령 태스크(Zombie Task)'와 visibility_timeout 함정

  • 현상: 실행 시간이 2시간 걸리는 데이터 이관 배치를 CeleryExecutor로 돌렸는데, 정확히 1시간 뒤에 동일한 태스크가 다른 워커에서 중복으로 또 실행되면서 DB Lock 경합이 발생하는 장애.
  • 원인 분석: Celery가 브로커로 사용하는 Redis의 기본 visibility_timeout 값이 3600초(1시간)로 설정되어 있기 때문입니다. 워커가 태스크를 가져간 뒤 1시간 동안 브로커에 완료(ACK) 응답을 주지 않으면, Redis는 해당 워커가 죽었다고 판단하고 동일한 태스크 메시지를 다른 워커에게 재할당해 버립니다.
  • 해결책: airflow.cfg의 [celery_broker_transport_options] 섹션에 있는 visibility_timeout 값을 클러스터 내에서 가장 오래 걸리는 태스크의 최대 실행 시간보다 크게(예: 21600 = 6시간) 설정해야 합니다.
[celery_broker_transport_options]
visibility_timeout = 21600

② KubernetesPodOperator vs KubernetesExecutor의 명확한 구분

실무에서 가장 많이 혼동하는 개념입니다.

  • KubernetesExecutor: Airflow가 태스크를 스케줄링하는 방식입니다. 생성되는 모든 파드는 반드시 Airflow 런타임과 DAG 코드가 포함된 'Airflow 전용 베이스 이미지'로 떠야 합니다.
  • KubernetesPodOperator (KPO): 특정 태스크의 종류(Operator)입니다. 클러스터의 Executor가 LocalExecutor든 CeleryExecutor든 상관없이, 이 오퍼레이터가 실행되면 K8s API를 호출해 Airflow가 전혀 설치되지 않은 순수 외부 도커 이미지(예: Golang, Rust, Java, 사내 전용 마이그레이션 툴 이미지)를 독립된 파드로 띄우고 결과만 모니터링합니다.
  • 실무 팁:もし KubernetesExecutor 환경에서 KubernetesPodOperator를 그냥 실행하면, "KPO를 실행하기 위한 Airflow 워커 파드 1개 + 실제 작업을 수행할 타겟 파드 1개"가 동시에 떠서 리소스가 2배로 낭비되는 안티패턴이 발생합니다. 파이썬 기반의 Airflow 태스크 격리가 목적이라면 KPO가 아니라 Multi-Executor 환경에서 @task(executor="KubernetesExecutor")와 pod_override를 사용하는 것이 정석입니다.

③ 워커 OOM Kill과 Zombie Task 탐지 주기 최적화

워커 컨테이너가 리소스 제한(cgroups memory limit)을 초과하여 커널의 OOM Killer에 의해 강제 종료(SIGKILL - Exit Code 137)되면, 워커 프로세스는 자신이 죽었다는 사실을 api-server(또는 DB)에 보고할 기회조차 얻지 못합니다.

이때 Airflow는 일정 시간 동안 하트비트가 끊긴 태스크를 좀비(Zombie) 태스크로 간주하고 실패 처리합니다.

  • 만약 K8s 클러스터의 Spot Instance 회수나 OOM이 잦은 환경이라면, [scheduler] 섹션의 scheduler_zombie_task_threshold (기본 300초) 값을 워크로드 특성에 맞게 조절하고, CeleryExecutor 사용 시 한 워커 프로세스가 메모리를 무한정 점유하지 않도록 worker_max_tasks_per_child = 100 (100개의 태스크를 처리한 워커 자식 프로세스는 자동으로 재시작하여 메모리 누수 반환) 옵션을 설정해 주는 것이 안정적인 운영의 핵심입니다.

 

대규모 데이터베이스를 마이그레이션하거나 외부 시스템의 데이터를 이관(ETL)할 때, 엔지니어들이 흔히 겪는 기이한 현상이 있습니다. 원본 데이터에는 분명히 특수문자 ª(Feminine Ordinal Indicator, U+00AA)나 위첨자 숫자 ²(Superscript Two, U+00B2)로 존재했는데, 타겟 DB로 이관하는 과정에서 일반 알파벳 a나 숫자 2로 변질되거나, 전혀 다른 문자임에도 Duplicate entry (중복 키 오류)를 뱉어내며 이관이 실패하는 현상입니다.

단순한 인코딩 깨짐(? 표시)과는 결이 다른 이 문제는 유니코드 정규화(Unicode Normalization)와 MySQL의 콜레이션 가중치 알고리즘(UCA, Unicode Collation Algorithm)에 대한 깊은 이해가 필요합니다. 이번 글에서는 이 현상의 내부 작동 원리와 실무 해결책을 상세히 파헤쳐 보겠습니다.

 


1. 문제의 핵심: 문자셋(Character Set) 변환 vs 콜레이션(Collation) 동등성

데이터 이관 시 ª가 a로 바뀌거나 ²가 2로 처리되는 현상은 크게 두 가지 레이어에서 발생합니다.

① ETL 도구 및 드라이버의 문자셋 대체 (Transliteration / Fallback)

타겟 테이블이나 커넥션 인코딩이 ª를 표현할 수 없는 문자셋(예: 일부 구형 캐릭터셋 또는 잘못 설정된 latin1/ascii 파이프라인)으로 설정된 경우, 이관 도구나 DB 드라이버는 데이터 유실(에러)을 막기 위해 유니코드 호환성 분해(NFKD/NFKC) 규칙을 적용합니다. 이 과정에서 ª는 가장 유사한 기본 라틴 문자 a로, ²는 숫자 2로 실제 바이트 값 자체가 치환(Transliteration)되어 저장됩니다.

② MySQL Collation에 의한 논리적 동일시 (Weight Matching)

더 무서운 것은 바이트 값은 그대로인데 MySQL이 두 문자를 같은 글자로 취급하는 경우입니다. MySQL 8.0의 기본 콜레이션인 utf8mb4_0900_ai_ci나 구버전의 utf8mb4_unicode_ci는 악센트 무시(Accent-Insensitive, ai) 및 대소문자 무시(Case-Insensitive, ci) 속성을 가집니다. 이로 인해 SELECT, JOIN, GROUP BY, UNIQUE INDEX 검사 시 MySQL 옵티마이저와 스토리지 엔진은 ª를 a로, ²를 2로 변환하여 비교합니다.

-- MySQL 8.0 (utf8mb4_0900_ai_ci 환경) 테스트 쿼리
SELECT 'ª' = 'a' COLLATE utf8mb4_0900_ai_ci AS is_equal_a,
       '²' = '2' COLLATE utf8mb4_0900_ai_ci AS is_equal_2;
is_equal_a is_equal_2
1 (True) 1 (True)

2. 내부 작동 원리: UCA(유니코드 콜레이션 알고리즘)와 가중치(Weight)

MySQL이 왜 ª를 a와 같다고 판단하는지 이해하려면, 내부적으로 문자를 어떻게 정렬하고 비교하는지 WEIGHT_STRING() 함수를 통해 들여다보아야 합니다.

UCA(Unicode Collation Algorithm)는 각 문자에 4단계의 가중치(Level 1 ~ Level 4)를 부여합니다.

  • Level 1 (Primary Weight): 기본 문자 자체의 차이 (예: a vs b)
  • Level 2 (Secondary Weight): 악센트나 변형 기호 차이 (예: a vs ä vs ª)
  • Level 3 (Tertiary Weight): 대소문자나 위첨자/아래첨자 형태 차이 (예: a vs A, 2 vs ²)

일반적인 _ai_ci (Accent-Insensitive, Case-Insensitive) 콜레이션은 비교 연산 시 Level 1 가중치만 사용하고 Level 2, 3를 무시합니다. 직접 MySQL 내부 가중치 해시값을 확인해 보겠습니다.

-- 문자별 내부 Collation 가중치(Weight) 바이트 확인
SELECT 
    HEX(WEIGHT_STRING('a' COLLATE utf8mb4_0900_ai_ci)) AS weight_a,
    HEX(WEIGHT_STRING('ª' COLLATE utf8mb4_0900_ai_ci)) AS weight_fem_a,
    HEX(WEIGHT_STRING('2' COLLATE utf8mb4_0900_ai_ci)) AS weight_2,
    HEX(WEIGHT_STRING('²' COLLATE utf8mb4_0900_ai_ci)) AS weight_sup_2;
weight_a weight_fem_a weight_2 weight_sup_2
1C47 1C47 1C3B 1C3B

결과를 보면 'a'와 'ª'는 내부적으로 완전히 동일한 0x1C47 가중치를, '2'와 '²'는 동일한 0x1C3B 가중치를 생성합니다.

이관 시 발생하는 치명적 문제들:

  1. Unique Key 제약조건 위반: 원본 DB(Oracle, PostgreSQL 등 바이너리 기반 비교 환경)에서는 'user_a'와 'user_ª'가 별개의 Row로 존재했지만, MySQL로 이관하는 순간 B-Tree 인덱스 상에서 동일한 키 가중치(1C47)를 가지므로 ERROR 1062 (23000): Duplicate entry가 발생합니다.
  2. GROUP BY / JOIN 데이터 덮어쓰기: 이관 중 임시 테이블에서 GROUP BY나 INSERT ... ON DUPLICATE KEY UPDATE를 수행할 때, 먼저 들어간 'a' 값에 'ª' 데이터가 합산되거나 덮어씌워져 데이터 정합성이 깨집니다.

3. 콜레이션 종류별 특수문자 처리 비교 분석

MySQL에서 제공하는 주요 utf8mb4 콜레이션들이 특수문자와 숫자를 어떻게 취급하는지 비교한 아키텍처 표입니다.

Collation 명칭 UCA 버전 비교 방식 'ª' = 'a' '²' = '2' 특징 및 권장 사용처
utf8mb4_general_ci 비표준 (Legacy) 1:1 단순 매핑 False (0) False (0) 속도는 빠르나 유니코드 표준 미준수. ª와 a를 다른 문자로 취급
utf8mb4_unicode_ci UCA 4.0.0 확장 매핑 True (1) True (1) 다국어 정렬 정확도 높음. 호환성 문자를 기본 문자와 동일시함
utf8mb4_0900_ai_ci UCA 9.0.0 악센트·대소문자 무시 True (1) True (1) MySQL 8.0 기본값. 특수 변형 문자를 일반 문자/숫자와 강하게 동등 처리
utf8mb4_0900_as_cs UCA 9.0.0 악센트·대소문자 구분 False (0) False (0) 대소문자 및 특수문자 변형을 엄격하게 구분
utf8mb4_bin Binary 코드포인트(Byte) 직접 비교 False (0) False (0) 문자의 의미 무시, 오직 바이트 값(0xC2AA vs 0x61)으로만 비교

4. 실무 트러블슈팅 및 아키텍처 해결 가이드

데이터 이관 시 특수문자가 일반 문자/숫자로 변질되거나 충돌하는 문제를 해결하기 위한 3가지 실무 전략입니다.

전략 1: 식별자(ID, 코드, 이메일) 컬럼에는 utf8mb4_bin 또는 _as_cs 적용

이관 대상 컬럼이 단순 검색용 텍스트가 아니라 상품 코드, 사용자 ID, 해시값 등 문자 하나하나의 원형이 보존되고 구분되어야 하는 컬럼이라면 콜레이션을 반드시 변경해야 합니다.

-- 특정 컬럼의 Collation을 엄격한 구분(Accent-Sensitive, Case-Sensitive)으로 변경
ALTER TABLE users 
MODIFY COLUMN username VARCHAR(100) 
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL;

-- 또는 완벽한 바이트 단위 일치를 보장하는 Binary Collation 적용 (MySQL 5.7 호환)
ALTER TABLE products 
MODIFY COLUMN serial_code VARCHAR(64) 
CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;

전략 2: 이관 파이프라인(Connection & Dump)에서 강제 형변환 방지

만약 DB에 적재된 데이터 자체가 ª에서 a로 바뀌어 저장되었다면, 이는 콜레이션 비교 문제가 아니라 클라이언트(ETL 툴, JDBC, Python 스크립트 등)와 MySQL 서버 간의 Connection Character Set 협상 실패로 인한 Fallback 현상입니다.

my.cnf 설정과 클라이언트 연결 문자열을 점검하여 전 구간 utf8mb4 무손실 파이프라인을 구축해야 합니다.

# my.cnf (MySQL Server 설정)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_0900_as_cs # 필요시 기본 콜레이션을 보수적으로 설정
skip-character-set-client-handshake # 클라이언트의 임의 인코딩 설정을 무시하고 서버 설정 강제

전략 3: 실행 계획(Execution Plan)을 고려한 쿼리 레벨 우회

스키마 변경(ALTER TABLE)이 불가능한 운영 환경에서 이관 검증 쿼리를 작성할 때, 쿼리 내에 무작정 COLLATE utf8mb4_bin을 걸면 인덱스를 타지 못하고 Full Table Scan이 발생하는 성능 참사가 일어날 수 있습니다.

-- [나쁜 예] 좌변(컬럼)에 COLLATE 함수를 씌우면 인덱스 변형이 일어나 Full Scan 발생
EXPLAIN SELECT * FROM legacy_data WHERE code COLLATE utf8mb4_bin = 'ª';

-- [좋은 예] 인덱스 스캔(Index Range Scan)으로 1차 필터링 후, 바이트 검증을 추가 수행
EXPLAIN SELECT * FROM legacy_data 
WHERE code = 'ª' 
  AND BINARY code = 'ª';
  • 튜닝 포인트: WHERE code = 'ª' 조건으로 기존의 _ai_ci 인덱스를 태워 'a', 'A', 'ª' 후보군을 빠르게 좁힌(Index Lookup) 뒤, AND BINARY code = 'ª' 조건으로 메모리 상에서 정확한 특수문자만 필터링(Filter)하면 인덱스를 100% 활용하면서도 정확한 데이터 검증이 가능합니다.

 

5. 마무리

  1. ª가 a로, ²가 2로 취급되는 현상은 버그가 아니라 유니코드 정규화(NFKC) 및 UCA 기반 콜레이션(_unicode_ci, _0900_ai_ci)의 가중치 설계 의도입니다.
  2. 실제 저장된 값 자체가 변했다면 이관 툴/드라이버의 캐릭터셋 손실(Transliteration)을 의심해야 하며, 저장된 값은 그대로인데 중복 오류나 조인 왜곡이 발생한다면 Collation 설정이 원인입니다.
  3. 데이터 마이그레이션 설계 시, 이관 전 반드시 타겟 테이블의 PK/Unique Key 컬럼을 utf8mb4_bin 또는 utf8mb4_0900_as_cs로 설계하여 데이터 왜곡을 원천 차단하시기 바랍니다.

+ Recent posts