기술

순서만 바꿨는데 등록일시 5건이 같은 시각으로 덮어써졌습니다

2026.09.128분 읽기

목록의 순서만 바꿨습니다. 드래그로 항목 다섯 개를 위아래로 옮기고 저장한 것이 전부입니다. 잠시 뒤 목록을 다시 열었을 때 그 다섯 항목의 등록일시가 전부 같은 값으로 바뀌어 있었습니다. 방금 저장을 누른 그 시각입니다.

작성자 필드도 함께 다시 찍혔습니다. 순서를 바꾼 사람이 원래 작성자를 밀어내고 들어간 상태였습니다. 등록일시는 목록 정렬과 기간별 집계의 기준이라, 다섯 건이 밀린 순간 그 항목들의 이력이 통째로 어긋났습니다.


같은 시각이 여러 줄이면 사람이 한 일이 아닙니다

처음에는 정렬 기능이 만든 새 버그라고 봤습니다. 그날 붙인 기능이 그것 하나였기 때문입니다. 방향을 바꾼 것은 다섯 건의 등록일시가 초 단위까지 완전히 같다는 점이었습니다. 사람이 하나씩 등록했다면 초가 겹칠 수 없습니다.

동일한 타임스탬프가 여러 행에 찍혀 있으면 한 번의 저장이 여러 건을 건드린 흔적입니다. 정렬 기능은 순서를 다시 계산해 전 항목을 한 번에 저장합니다. 정렬이 원인을 새로 만든 것이 아니라, 원래 있던 것을 다섯 배로 키워 보여준 셈이었습니다.

143-1.jpg


원인은 세 겹이 겹쳐 있었습니다

파보니 단일 원인이 아니었습니다. 세 조건이 동시에 성립할 때만 터지는 구조였습니다.

저장 대상이 신규인지 기존인지 프레임워크가 판정할 근거가 없었습니다. 이 도구는 노드에 사람이 읽을 수 있는 식별자를 코드에서 직접 부여하고, 버전 필드를 두지 않았습니다.

판정 근거가 없으면 프레임워크는 들어온 저장 요청을 전부 신규로 간주합니다. 그리고 기반 엔티티에는 생성일시와 생성자를 자동으로 채우는 감사 애노테이션이 붙어 있었습니다.

정렬 기능이 전 항목을 한 번에 저장하면서 다섯 건의 감사 필드가 동시에 다시 찍혔습니다.

자동 감사 필드는 「이 저장이 신규인가」를 프레임워크가 스스로 판정할 수 있을 때만 안전합니다. 보통은 식별자를 데이터베이스가 발급하니 「식별자가 비어 있으면 신규」라는 규칙이 성립합니다. 식별자를 사람이 읽는 형태로 애플리케이션이 직접 부여하는 순간 그 규칙이 무너집니다.

창구에서 접수증에 날짜 도장을 찍는 상황에 가깝습니다. 창구 직원은 접수증에 이미 날짜가 찍혀 있는지 보지 않고, 자기 앞에 온 서류는 전부 새 접수로 보고 오늘 도장을 찍습니다. 서류를 한 장씩 들고 가면 한 장의 날짜만 바뀝니다. 다섯 장을 한꺼번에 내면 다섯 장이 같은 날짜가 됩니다.

143-2.jpg


한 건씩 저장하는 동안에는 보이지 않았습니다

이 구조는 처음부터 그랬습니다. 정렬 기능이 만든 것이 아니라 도구를 만든 첫날부터 있던 함정입니다. 다만 그때까지의 저장 경로는 전부 한 건짜리였습니다. 항목 하나를 수정하면 그 항목의 등록일시 하나가 조용히 오늘로 밀립니다.

한 줄만 밀리면 사람은 그것을 오류로 읽지 않습니다. 방금 그 항목을 만졌으니 날짜가 오늘인 것이 자연스러워 보입니다. 다섯 줄이 같은 초를 가리킬 때에야 패턴이 됩니다. 일괄 저장 경로가 처음 생기면서 부채가 보이는 형태로 바뀐 것입니다.


세 가지 선택지가 있었습니다

원인이 세 겹이라 손댈 수 있는 지점도 세 곳이었습니다.

선택지

하는 일

남는 문제

호출부만 수정

문제가 난 세 경로가 엔티티 전체 대신 속성만 갱신하게 변경

같은 부채를 안은 나머지 30여 곳은 그대로

버전 필드 도입

프레임워크가 신규를 판정할 수 있도록 정석대로 필드 추가

전 노드 마이그레이션과 기존 데이터 초기값 문제

판정을 없앤다

감사 애노테이션을 떼고 값이 비어 있을 때만 채움

생성자 자동 기록을 포기

버전 필드를 넣는 쪽이 교과서에 가깝습니다. 낙관적 잠금까지 덤으로 얻습니다. 다만 이미 쌓인 노드 전부에 필드를 추가하고 초기값을 정해야 했습니다. 사고 당일에 돌리기에는 범위가 컸습니다.

세 번째를 골랐습니다. 신규인지 맞히는 정확도를 올리는 대신, 맞힐 필요가 없게 만드는 방향입니다.


판정을 정교하게 만드는 대신 판정을 지웠습니다

급한 불부터 껐습니다. 정렬과 수정, 기한 설정 세 경로를 엔티티 전체 저장이 아니라 속성만 갱신하도록 바꿨습니다. 이건 그 세 곳만 막는 처방입니다.

그다음 기반을 고쳤습니다. 생성일시와 생성자의 감사 애노테이션을 떼고, 저장 직전 콜백으로 대체했습니다.

저장 직전 콜백
  생성일시가 비어 있으면  -> 지금 시각을 채운다
  이미 값이 있으면        -> 손대지 않는다

신규 여부를 묻지 않으니 판정이 틀릴 일도 없습니다. 기존 노드에 몇 번을 저장해도 등록일시는 처음 값 그대로 남습니다. 이 수정 하나로 같은 부채를 안고 있던 30여 곳이 함께 정리됐습니다. 아직 터지지 않았을 뿐 언제든 같은 증상을 낼 자리들이었습니다.

143-3.jpg


데이터는 대부분 돌아오지 않았습니다

다섯 건 중 되살린 것은 한 건입니다. 그 항목만 작업 로그에 원래 시각이 남아 있어 손으로 되돌렸습니다. 나머지 네 건은 원본을 알 방법이 없어 영구 유실로 처리했습니다.

더 곤란한 것은 과거분이었습니다. 이 도구는 작업을 하고 나서 기록하는 방식이라 생성 시각과 완료 시각이 원래 거의 붙어 있습니다. 그래서 정상적으로 등록된 항목과 저장할 때 밀려서 덮어써진 항목의 흔적이 구분되지 않습니다. 과거에 몇 건이 밀렸는지 세는 것 자체가 불가능합니다.

피해 범위를 모른다는 사실을 그대로 기록에 남겼습니다. 숫자를 추정해서 적으면 나중에 그 추정치가 사실처럼 인용됩니다.


감수한 것과 막아둔 것

생성일시를 코드에서 직접 넣어 주던 경로 40여 곳은 이제 생성자 필드가 빈 채로 저장됩니다. 확인해 보니 전체 노드 5천 개 중 3천 개 이상이 이미 생성자가 비어 있었습니다. 실질 영향이 없다고 판단하고 그대로 수용했습니다.

대신 되살아나기 쉬운 수정이라 방어를 붙였습니다. 애노테이션 두 줄을 지운 수정은 「감사가 왜 없지」라며 다시 붙이기 아주 쉽습니다. 프로젝트 규칙 문서에 두 애노테이션을 복구하면 같은 사고가 재발한다고 경고로 적고, 회귀 테스트 세 건을 추가했습니다.

회귀 테스트 세 건이 고정하는 것은 이번에 확인한 경로뿐입니다. 새로운 저장 경로가 생기면 같은 부류를 다시 밟을 여지가 남아 있습니다.


일괄 처리를 새로 붙이기 전에 확인하는 것들

이번 건에서 실제로 쓸모 있었던 점검 항목만 남깁니다.

새로 붙이는 기능이 기존에 한 건씩 하던 일을 한 번에 하는 것인지 먼저 구분합니다. 그렇다면 단건 경로의 부작용이 그대로 N배가 됩니다.

프레임워크가 신규와 기존을 무엇을 근거로 구별하는지 확인합니다. 식별자를 애플리케이션에서 직접 부여하고 있다면 그 근거가 없을 가능성이 큽니다.

감사 필드가 자동으로 채워지는 엔티티에서, 기존 데이터를 다시 저장했을 때 값이 유지되는지 테스트 한 건으로 고정합니다.

조건을 정교하게 만들기 전에 조건 자체를 없앨 수 있는지 봅니다. 비어 있을 때만 채우는 방식은 판정을 요구하지 않습니다.

호출부 수정과 기반 수정을 구분해 둘 다 합니다. 순서는 급한 불인 호출부가 먼저, 재발을 막는 기반이 나중입니다.


같은 시각이 여러 줄이면 먼저 의심합니다

데이터에서 동일한 타임스탬프가 여러 행에 찍혀 있으면 일괄 저장의 흔적입니다. 이번 사고에서 원인까지 가는 데 가장 빨랐던 단서가 그것이었습니다. 사람은 초 단위로 같은 시각에 다섯 건을 만들지 못합니다.

그리고 이 함정은 정렬 기능이 만든 것이 아니었습니다. 새 기능을 붙인 날 사고가 나면, 그 기능이 원인인지 아니면 원래 있던 것을 드러낸 것인지 한 번 갈라 보는 편이 낫습니다. 후자라면 고쳐야 할 자리는 그 기능이 아니라 기반입니다.

#감사필드#ORM함정#등록일시#일괄저장#데이터손상#회귀테스트