- 코딩테스트
- 정보처리기사
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- PY4E
- SQL
- boostcourse
- python
- boostcoures
- DB
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- 인공지능기초다지기
- 네이버부스트캠프
- AI 플랫폼을 활용한 데이터 분석
- 프로그래머스
- 코딩테스트 python
- Ai
- 기초다지기
- 오라클
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- unsub
- 파이썬
- 데이터베이스
- 빅데이터분석기사
- Oracle
- r
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- Django
- 부스트코스
- 이기적
- 빅분기
- Today
- Total
매일공부
"빈 DB에만 터지는" Django 마이그레이션 버그 — 트리거 SQL 파일을 잘못 읽고 있었다 본문
요약
운영서버에 새 기능(발주서 관리)을 배포하는데 migrate가 이런 에러를 내며 실패했다.
django.db.utils.OperationalError: (1054, "Unknown column 'status' in 'NEW'")
원인은 한 문장으로 요약된다.
마이그레이션 파일이 "그 시점의 스키마"를 기록해야 하는데, 트리거 생성 SQL을 매번 파일에서 읽어오는 방식이라 나중에 그 파일을 고치면 과거 마이그레이션까지 조용히 동작이 바뀌어 버린다.
이 글은 이 버그를 잡은 과정, 왜 지금까지 개발 환경에서는 한 번도 안 터졌는지, 그리고 같은 유형의 버그가 프로젝트에 더 있는지 전수조사한 결과와 재발 방지책을 정리한 기록이다.
1. 발생 상황
사내 B2B 영업관리 시스템(Django 기반, 이하 "이 프로젝트")에 발주서(Purchase Order) 관리 기능을 새로 추가하고 운영서버에 배포했다. 운영서버는 완전히 빈 DB에 처음부터 마이그레이션을 전부 적용하는 상황이었다.
/app# python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, belong, brands, common, contenttypes, ...
Running migrations:
Applying products.0021_productprice_price_type... OK
Applying purchase_orders.0001_initial...Traceback (most recent call last):
...
django.db.utils.OperationalError: (1054, "Unknown column 'status' in 'NEW'")
purchase_orders.0001_initial이 트리거를 생성하는 도중, NEW.status라는 아직 존재하지도 않는 컬럼을 참조하면서 실패했다.
2. 근본 원인: read_sql()은 "지금 이 순간"의 파일을 읽는다
이 프로젝트는 테이블마다 INSERT/UPDATE/DELETE 이력을 남기는 트리거를 두고 있고, 그 트리거 SQL을 sql/triggers/<table>.sql 파일로 별도 관리한다. 마이그레이션에서는 아래 패턴으로 그 파일을 읽어 RunSQL로 실행한다.
def read_sql(filename):
path = settings.BASE_DIR / "sql" / "triggers" / filename
with open(path, encoding="utf-8") as f:
return f.read()
migrations.RunSQL(sql=read_sql("purchase_order.sql"), ...)
문제는 read_sql()이 마이그레이션 파일이 작성된 시점이 아니라 마이그레이션이 실제로 "실행"되는 시점의 파일 내용을 읽는다는 것이다.
시간 순서로 보면:
- purchase_orders/0001_initial.py에서 purchase_order 테이블을 만들고, 그 직후 read_sql("purchase_order.sql")로 트리거를 생성한다. 이때 purchase_order.sql은 아직 컬럼 3개(status, excel_path, excel_generated_at)를 모르는 "원래" 버전이었다.
- 이후 기능 추가 작업(Phase 9)에서 PurchaseOrder에 status/excel_path/excel_generated_at 컬럼을 추가하면서, 트리거가 이 컬럼들을 로그에 남기도록 같은 파일 purchase_order.sql을 덮어썼다. 이 변경은 0002_purchase_order_documents.py가 담당한다.
- 그런데 0001_initial.py는 여전히 read_sql("purchase_order.sql")을 그대로 참조하고 있다. 이 마이그레이션 코드 자체는 안 바뀌었지만, 그 함수가 읽는 파일의 "현재" 내용이 바뀌어버린 것이다.
- 운영서버는 빈 DB라 0001과 0002가 한 번의 migrate에서 연달아 실행된다. 이때 0001이 (이미 최신 버전으로 바뀐) purchase_order.sql을 읽으면서, 아직 테이블에 없는 status 컬럼을 참조하는 트리거를 만들려다 실패한다.
마이그레이션 파일은 "그 시점에 무엇을 했는가"를 영구히 기록하는 역사 기록이어야 하는데, 그 기록의 일부(트리거 SQL)를 외부의 "살아있는" 파일에 위임해버려서, 나중에 그 파일을 고치면 과거 기록까지 조용히 다시 쓰여버린 셈이다.
3. 왜 개발 환경에서는 지금까지 한 번도 안 터졌나
이게 핵심이다. 이 버그는 개발 환경에서는 구조적으로 재현이 안 된다.
개발 컨테이너에서는 마이그레이션을 "새로 하나 만들 때마다 그때그때" 적용해왔다.
- 0001을 만들고 바로 migrate → 이 시점엔 purchase_order.sql이 아직 원래 버전이라 문제없이 성공 → django_migrations 테이블에 0001이 "적용됨"으로 기록된다.
- 그다음 0002를 추가하고 트리거 파일을 최신 컬럼 기준으로 고쳐도, 0001은 이미 적용된 걸로 기록돼 있어서 Django가 다시 실행하지 않는다. 그래서 문제가 절대 드러나지 않는다.
반면 운영서버는 완전히 빈 DB였기 때문에, 0001과 0002가 처음부터 순서대로 한 번에 실행됐고, 그 순간 0001이 이미 바뀌어버린 파일을 읽으면서 터진 것이다.
즉 이 버그는 "새 환경에 처음부터 배포"할 때만 재현되고, 개발 컨테이너처럼 이미 마이그레이션이 하나씩 순차 적용돼 온 환경에서는 절대 드러나지 않는다. 배포 전 검증 없이는 개발 단계에서 잡을 방법이 사실상 없었던 이유다.
4. 1차 수정
0001_initial.py의 트리거 RunSQL을 "그 시점 스키마"(신규 컬럼 3개가 없는 원래 버전)로 고정된 인라인 SQL 문자열로 되돌렸다. 이제:
- 0001은 원래 컬럼 기준 트리거를 만들고,
- 0002가 컬럼 추가 후 최신 purchase_order.sql을 읽어 CREATE OR REPLACE TRIGGER로 최종본으로 교체한다.
운영서버는 이미 0001 도중 실패한 상태였다. MariaDB는 DDL을 트랜잭션으로 묶지 못하기 때문에, purchase_order/purchase_order_detail/purchase_order_history 테이블은 이미 생성됐는데 django_migrations에는 0001이 기록 안 된 애매한 상태였다. 이 세 테이블을 수동으로 DROP한 뒤 migrate를 재시도해서 정상적으로 마무리했다.
5. 개발 단계에서 잡을 수 있는 방법은?
이런 버그는 이번이 처음이 아니었다. 근본적인 해결 방법이 궁금했다. 구체적으로는 두 가지를 확인하고 싶었다.
- 이 문제를 배포 전에 미리 잡을 방법이 있었는가?
- 같은 유형의 버그가 이 프로젝트 다른 곳에도 있는가?
5-1. 배포 전에 잡을 수 있었는가 — 그렇다
개발 컨테이너에서 진짜 빈 DB(스키마 없는 새 DB)에 migrate를 한 번 돌려보면 정확히 같은 에러가 재현된다. "새 환경에 처음 배포하는 상황"을 그대로 시뮬레이션하는 것이기 때문이다. 이건 운영 배포 직전에 반드시 해볼 만한 검증이다.
# 컨테이너 안에서
mysql -u<user> -p -e "CREATE DATABASE db_migration_check;"
# DATABASE 설정을 임시로 db_migration_check로 바꿔서
python manage.py migrate
mysql -u<user> -p -e "DROP DATABASE db_migration_check;"
5-2. 같은 유형의 버그, 프로젝트 전체 전수조사
이 프로젝트는 sql/triggers/ 아래 18개 SQL 파일을 두고 있고, 여러 마이그레이션이 read_sql()로 이 파일들을 참조한다. AI 에이전트에게 아래 절차로 조사를 맡겼다.
- 전체 저장소에서 read_sql() 호출을 모두 찾아 (app, migration 파일, 참조 SQL 파일) 목록화
- 같은 SQL 파일을 참조하는 마이그레이션이 2개 이상인 경우를 위험 후보로 추림
- 각 후보에 대해, 현재 트리거 SQL이 참조하는 컬럼들과 가장 이른 마이그레이션 시점에 실제로 존재했을 컬럼 목록을 비교
- 현재 파일이 참조하는 컬럼 중 그 이른 마이그레이션 시점엔 없었고 더 나중에 추가된 게 있으면 → 재현 가능한 버그로 보고
결과: 같은 유형의 버그가 3곳 더 있었다
# 트리거 파일 문제 마이그레이션(가장 이른 것) 그 시점엔 없던 컬럼 실제 추가 시점
| 1 | sw_brand.sql | brands/0002_baseline_triggers.py | contact_org, contact_tel, contact_email, contact_display_mode, pdf_footer_notes | 0003, 0004 |
| 2 | quotation.sql | quotations/0009_baseline_triggers.py | needs_state_sync, is_ea_contract | 0016, 0026 |
| 3 | quo_details.sql | quotations/0009_baseline_triggers.py | list_price, discount_rule_id, other_discount_note | 0023 |
| 4 | product.sql | products/0003_baseline_triggers.py | is_pre_discounted, group_id, is_module_based | 0005, 0017, 0020 |
(payment.sql / delivery_schedule.sql / belong.sql 등 나머지 트리거 파일들은 전부 이상 없었다 — 참조하는 마이그레이션이 1개뿐이거나, 컬럼이 그 시점에 이미 다 있었다.)
흥미로운 건 quotation.sql 쪽은 이 버그를 팀이 이미 두 번 인지하고 우회한 이력이 있었다는 점이다. 0016_add_needs_state_sync.py와 0021_reapply_quotation_trigger.py의 코드 주석에 "지금 트리거를 재적용하면 아직 없는 컬럼 때문에 실패한다"는 내용이 남아 있었다. 하지만 그 우회는 증상만 피했을 뿐, 진짜 근원인 0009(최초 트리거 생성)는 건드리지 않아서 회피가 불완전했다. products/0017_product_group.py의 주석에서도 비슷한 흔적("운영에서 트리거 단계에서 멈춘 뒤 재실행될 수 있어…")이 발견됐다 — 이 버그가 이번이 처음이 아니었다는 뜻이다.
5-3. 4건 모두 같은 방식으로 수정
각 "가장 이른" 마이그레이션의 트리거 SQL을 git 이력에서 그 시점의 원본 버전을 찾아 인라인 SQL로 고정하고, 실제로 해당 컬럼을 추가하는 가장 나중의 마이그레이션만 최신 트리거 파일을 읽도록 정리했다. purchase_orders/0001에 적용한 것과 동일한 패턴이다.
이 수정은 이미 적용된 운영/개발 DB에는 아무 영향이 없다 — Django는 django_migrations에 기록된 마이그레이션을 재실행하지 않기 때문이다. 다음번 완전히 새로운 환경(재해복구, 신규 스테이징 등)에 처음부터 배포할 때만 의미가 있는 예방 조치다.
6. 덤으로 발견한 두 번째 유형의 버그: managed=False 모델에 평범한 AddField
전수조사 중 products/0005_discount_stackable_removal_product_pre_discounted.py를 보다가 다른 종류의 문제를 하나 더 발견했다.
이 프로젝트는 원래 Django 도입 전부터 운영해오던 레거시 DB를 그대로 가져와 쓰고 있어서, 핵심 테이블(Product, Quotation, SWBrand 등)은 Django 모델에서 managed = False로 선언돼 있다. Django는 managed=False 모델에 대해서는 스키마 오퍼레이션(AddField, RemoveField, CreateModel 등)을 스키마 편집기에서 자동으로 건너뛴다 — DDL을 전혀 실행하지 않고, 에러도 안 낸다.
그런데 0005는 Product.is_pre_discounted 필드를 평범한 migrations.AddField(...)로만 추가했다. 이 경우 마이그레이션은 Django 내부 상태(필드 인식)만 바꾸고, 실제 DB 컬럼은 절대 생성하지 않는다.
이 프로젝트의 올바른 패턴은 아래처럼 상태 변경과 실제 DDL을 분리하는 것이다 (같은 앱의 0017, 0020이 이미 이 방식을 쓰고 있었다).
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddField(model_name="product", name="is_pre_discounted", ...),
],
database_operations=[
migrations.RunSQL(
sql="ALTER TABLE product ADD COLUMN IF NOT EXISTS is_pre_discounted TINYINT(1) NOT NULL DEFAULT 0;",
reverse_sql="ALTER TABLE product DROP COLUMN IF EXISTS is_pre_discounted;",
),
],
)
이것도 전체 managed=False 모델(Belong, SWBrand, Customer, FolderMapping, License, OrderCode, Product, Quotation, QuotationDetail)을 대상으로 다시 전수조사를 돌렸고, 이 한 건 외에는 같은 유형이 더 없었다.
실제로 운영 DB에는 is_pre_discounted 컬럼이 이미 존재했다 — Django로 완전히 전환하기 전, "레거시 테이블은 손으로 DB를 고치고 마이그레이션은 상태만 맞춰둔다"는 예전 방식을 쓰던 시기에 수동으로 추가해둔 것이었다. 그 이후 "컬럼 추가·트리거 갱신도 전부 마이그레이션 자동화로 하자"는 방침으로 바꿨는데, 0005 하나만 그 전환 이전에 만들어져서 빠뜨려진 케이스였다. IF NOT EXISTS로 고쳐뒀기 때문에 컬럼이 이미 있는 환경에서 다시 실행돼도 안전하고, 앞으로 진짜 빈 DB부터 배포하는 상황에서만 의미를 갖는다.
7. 재발 방지책
7-1. 배포 전 "빈 DB 재현" 검증을 루틴에 추가
트리거 SQL 파일을 수정하는 마이그레이션을 만들 때마다, 최소한 운영 배포 직전에는 한 번 완전히 새 DB에 migrate --database=default를 처음부터 돌려본다. 이게 이번 버그를 배포 전에 잡을 수 있는 가장 확실한 방법이었다.
7-2. 프로젝트 내부 체크리스트(CLAUDE.md)에 규칙 명문화
"모델 필드 추가·변경 시 의무 체크리스트"에 아래 두 항목을 추가했다.
- 이미 적용됐을 수 있는(과거) 마이그레이션이 참조하는 sql/triggers/*.sql을 다시 고칠 때는, 그 과거 마이그레이션의 RunSQL을 "그 시점 스키마"에 맞는 내용으로 인라인 고정하고, 최신 파일을 읽는 것은 항상 실제로 컬럼을 추가하는 가장 나중의 마이그레이션 하나만 담당하게 한다.
- managed=False 모델의 필드를 추가·변경·삭제할 때는 반드시 SeparateDatabaseAndState로 감싸서 state_operations(Django 상태)와 database_operations(실제 RunSQL DDL)을 분리한다. 평범한 AddField/RemoveField만 쓰면 Django가 스키마 오퍼레이션을 조용히 건너뛰어서 아무 일도 안 일어난다.
7-3. 근본 교훈
마이그레이션 파일은 "그 시점에 실행된 것"을 영구히 고정하는 역사 기록이어야 한다. 외부의 "계속 바뀌는 살아있는 파일"(이번 경우엔 sql/triggers/*.sql)을 실행 시점에 읽어오는 구조는, 그 파일이 나중에 바뀌는 순간 과거 기록까지 조용히 재작성해버리는 근본적인 위험을 안고 있다. 이런 참조를 쓸 수밖에 없다면, 적어도 "이 파일을 최종적으로 읽어 들이는 건 이 마이그레이션 하나뿐"이라는 게 항상 성립하도록 관리해야 한다.
그리고 이번 사례처럼, 레거시 DB를 마이그레이션 체계로 편입시키는 프로젝트에서는 "관행이 바뀌는 경계 시점"에 만들어진 마이그레이션을 특히 의심해봐야 한다. 수동 DDL 시대에서 완전 자동화 시대로 전환하는 그 사이에 만들어진 마이그레이션 하나가 새 방침을 놓쳤을 가능성이 높기 때문이다.
이 글은 Django + MariaDB 기반 사내 B2B 영업관리 시스템을 개발하면서 겪은 실제 배포 장애를 정리한 기록입니다.
'IT > 작업기록' 카테고리의 다른 글
| 이메일 캠페인에 참조(CC) 기능 추가하기 — 그리고 놓치기 쉬운 함정 하나 (0) | 2026.09.07 |
|---|---|
| [메일 발송] 700명한테 보내는데 갑자기 느려진 이유 — 진단부터 자동화까지 (0) | 2026.09.07 |
| 포커스 도둑 — 첫 클릭만 씹히던 이상한 버그 (0) | 2026.09.02 |
| [Django/pytest] 테스트 작성할 때 다시 볼 정리 — mock의 함정과 뭘 테스트해야 하나 (0) | 2026.09.01 |
| Django(Python)와 Node.js/NestJS, 뭐가 다를까 — 스택 전환을 고민하다 미룬 이유 (0) | 2026.09.01 |