- Published on
django-simple-history로 변경 이력 추적하고 직전 상태로 복원하기
- Authors

- Name
- hongreat
- ✉️hongreat95@gmail.com
운영을 하다보면 반드시 이런 문의가 옵니다.
"이 쿠폰 금액이 왜 이렇게 되어있어요? 누가 바꾼거예요?"
바뀐 경로는 다양합니다. 어드민에서 사람이 바꿨을 수도, 배치가 바꿨을 수도, API가 바꿨을 수도 있습니다. 이력이 없으면 이 질문에 답할 방법이 로그 뒤지기밖에 없습니다.
돈이나 권한이 걸린 모델들에 django-simple-history를 붙여서 운영해왔고, 최근에는 잘못된 변경을 직전 상태로 되돌리는 복구 작업에도 활용했습니다.
이 글에서는 기본 활용법과 함께, 직접 확인한 함정들을 기록합니다.
- 1. 설치와 적용
- 2. 이력 조회: history_type, prev_record, diff_against
- 3. 직전 상태로 복원하기
- 4. 이력 테이블에 커스텀 필드 추가하기
- 5. 제일 중요한 함정: update()는 이력에 남지 않는다
- 참고 자료
1. 설치와 적용
pip install django-simple-history
# settings.py
INSTALLED_APPS = [
...
"simple_history",
]
MIDDLEWARE = [
...
"simple_history.middleware.HistoryRequestMiddleware", # history_user 자동 기록
]
모델에는 HistoricalRecords 한 줄만 추가하면 됩니다.
from simple_history.models import HistoricalRecords
class Coupon(models.Model):
name = models.CharField(max_length=50)
amount = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
history = HistoricalRecords()
마이그레이션을 돌리면 historicalcoupon 이라는 이력 테이블이 생기고, 이후 save/delete가 일어날때마다 그 시점의 전체 스냅샷 row가 쌓입니다.
어디에 붙일지는 기준을 정해두는게 좋습니다. 모든 모델에 붙이면 테이블 수가 두배가 되고 쓰기마다 insert가 하나씩 추가됩니다. 저는 돈·권한·정책이 걸린 모델(쿠폰, 멤버십, 결제 관련, 운영 정책)에만 붙이는 것을 원칙으로 하고 있습니다.
2. 이력 조회: history_type, prev_record, diff_against
기본 조회부터 보겠습니다.
coupon = Coupon.objects.create(name="웰컴쿠폰", amount=5000)
coupon.amount = 3000
coupon.is_active = False
coupon.save()
[(h.history_type, h.amount, h.is_active) for h in coupon.history.all()]
# [('~', 3000, False), ('+', 5000, True)]
history_type은 +(생성), ~(수정), -(삭제) 입니다.
무엇이 바뀌었는지는 diff_against로 바로 뽑을 수 있습니다. CS 대응할때 가장 많이 쓰는 기능입니다.
latest = coupon.history.first()
prev = latest.prev_record
delta = latest.diff_against(prev)
[(change.field, change.old, change.new) for change in delta.changes]
# [('amount', 5000, 3000), ('is_active', True, False)]
각 이력 row에는 history_date(변경 시각), history_user(변경자)도 함께 남기 때문에, "누가 언제 무엇을"의 세 질문에 코드 몇줄로 답할 수 있게 됩니다.
한가지 알아둘 점은 history_user는 위에서 등록한 미들웨어가 request에서 뽑아 기록한다는 것 입니다. shell이나 배치에서의 변경은 request가 없으므로 history_user가 None으로 남습니다. 배치 변경까지 추적하고 싶다면 아래 4번처럼 커스텀 필드를 활용하는 방법이 있습니다.
3. 직전 상태로 복원하기
이력의 진짜 가치는 복원이 필요할때 나옵니다.
이력 row는 .instance로 당시 상태의 모델 인스턴스로 바꿀 수 있고, 그대로 save하면 복원이 됩니다.
prev = coupon.history.first().prev_record
prev.instance.save() # 직전 상태로 복원
coupon.refresh_from_db()
coupon.amount, coupon.is_active
# (5000, True)
복원 자체도 새로운 ~ 이력으로 남기 때문에 감사 추적이 끊기지 않습니다. "복원했다는 사실"까지 이력에 남는 것이 이 방식의 좋은 점 입니다.
특정 시점의 상태가 필요하면 as_of를 사용합니다.
Coupon.history.as_of(datetime(2026, 6, 1, tzinfo=timezone.utc))
# 해당 시점 기준의 인스턴스들
실무에서는 이 복원 패턴을 되돌림성 작업(잘못된 일괄 변경 복구, 환불 철회처럼 처리 자체를 무르는 작업)에 활용했습니다.
다만 복원을 실무에 쓸때는 두가지를 함께 챙겨야 합니다.
- 복원 대상 row가 마지막 변경 이후 다른 경로로 또 바뀌지 않았는지 확인해야 합니다. 무조건 직전 이력으로 덮으면 그 사이의 정상 변경까지 날립니다.
- 이력은 그 모델의 필드만 복원합니다. 변경에 딸려있던 부수효과(연결된 포인트, 쿠폰 상태 등)는 이력이 모르는 영역이라, 부수효과가 감지되면 중단하고 사람이 확인하는 흐름 + dry-run 기본값으로 만드는 것이 안전했습니다.
4. 이력 테이블에 커스텀 필드 추가하기
이력 테이블에 우리만의 필드를 추가할 수 있습니다. bases 옵션에 abstract 모델을 넘기는 방식입니다.
class HistoryAddOnModel(models.Model):
"""이력 테이블에 공통으로 추가할 필드들"""
ip_address = models.GenericIPAddressField(default="0.0.0.0", null=True)
changed_text = models.TextField(default="")
class Meta:
abstract = True
class Coupon(models.Model):
...
history = HistoricalRecords(bases=[HistoryAddOnModel])
이렇게 하면 historicalcoupon 테이블에 ip_address, changed_text 컬럼이 같이 생깁니다. 요청 IP나 "관리자 일괄 조정" 같은 변경 사유를 이력에 함께 남기는 용도입니다.
변경 사유만 필요하다면 라이브러리가 제공하는 history_change_reason과 update_change_reason 유틸을 쓰는 것도 방법입니다.
5. 제일 중요한 함정: update()는 이력에 남지 않는다
simple-history는 post_save/post_delete 시그널 기반입니다. 그래서 시그널이 발생하지 않는 변경은 이력에 남지 않습니다.
Coupon.objects.filter(id=coupon.id).update(amount=99999)
coupon.refresh_from_db()
coupon.amount # 99999 <- DB는 바뀌었는데
coupon.history.first().amount # 5000 <- 이력은 모른다
직접 확인해보면 더 무서운 부분이 있는데, 이 상태에서 as_of로 현재 시점을 조회하면 5000이라고 답합니다. 이력과 현실이 어긋난 순간부터, 이력 기반의 조회·복원은 조용히 거짓말을 하게 됩니다.
queryset의 update(), bulk_update(), bulk_create()가 전부 여기에 해당합니다.
대응 방법은 상황에 따라 골랐습니다.
- 대량 생성은 라이브러리가 제공하는
bulk_create_with_history유틸 사용 - 이력이 꼭 필요한 모델은 애초에 update() 대신 인스턴스 save 경로만 쓰도록 팀 규칙화
- 마이그레이션 커맨드처럼 의도적으로 대량 변경하는 경우는 "이 변경은 이력에 안 남는다"를 인지하고 커맨드 자체의 로그를 남김
그리고 이력 테이블은 계속 자랍니다. 변경이 잦은 모델이라면 clean_duplicate_history(중복 이력 정리), clean_old_history --days N(오래된 이력 삭제) 관리 커맨드를 주기 배치로 걸어두는 것을 권장합니다.
마지막으로 어드민에서는 SimpleHistoryAdmin을 상속하면 각 객체의 History 버튼으로 이력 조회·복원 UI가 생깁니다. unfold를 쓰는 경우에는 unfold.contrib.simple_history를 INSTALLED_APPS에 추가해야 이력 화면의 스타일이 유지됩니다.
예약 시스템 설계 글에서 정책 값은 스냅샷으로 저장한다고 했는데, 이력은 그 반대편의 도구입니다.
스냅샷이 "그때의 값으로 계산을 고정"하는 것이라면, 이력은 "그때로 돌아갈 수 있게 기록"하는 것 입니다. 운영 데이터를 다루는 모델이라면 둘 중 하나는 반드시 있어야 하고, 돈이 걸려있다면 둘 다 있는 것이 마음이 편합니다.
