JPA 엔티티의 복합 UNIQUE 가 운영 DB 엔 없었습니다
프랜차이즈 ERP 의 현장 담당자용 매니저 웹에서 본사 문의가 하나 들어왔습니다. 매장 품질점검을 하다가 임시저장을 했는데 이어서 쓸 방법이 없다는 내용이었습니다. 요청 범위는 딱 하나, 이어쓰기를 붙여 달라는 것이었습니다.
붙이고 나서 요청 범위 밖에서 결함 셋이 드러났습니다. 그중 하나는 코드가 아니라 운영 DB 스키마에 있었습니다. 중복을 막는 제약이 엔티티 선언에만 있고 실물에는 없었습니다. 그 상태로 몇 달을 돌았는데, 중복을 만들 경로가 아직 없어서 아무도 모르고 있었습니다.
신고 문구와 실제 문제가 달랐습니다
처음 받은 신고는 클릭했는데 안 열린다는 문장이었습니다. 눌러 보니 상세 화면은 다 열렸습니다. 진짜 뜻은 이어서 쓸 방법이 없다는 것이었고, 그 밑에는 구조 문제가 있었습니다. 저장 API 가 어느 점검 건을 고칠지 지목할 수단이 없었습니다.
그래서 이어쓰기를 붙였습니다. 다만 임시저장 상태인 건에만 붙였습니다. 이 한 줄이 뒤에 나오는 모든 것의 출발점입니다.
완료된 점검을 눌렀더니 새 점검이 시작됐습니다
임시저장 쪽이 되는 것을 확인하고, 그 옆의 오늘 완료 카드를 눌러 봤습니다. 새 점검이 시작됐습니다. 이미 제출을 마친 건인데도 백지 양식으로 떨어졌습니다. 이어쓰기 분기를 임시저장에만 붙였으니 나머지 상태는 모두 예전 경로, 즉 새로 시작하는 길로 간 것입니다.
거기서 멈추지 않고 제출까지 밀어 봤습니다. 같은 조합의 두 번째 행이 그대로 들어갔습니다. 점검 템플릿과 매장, 점검일, 점검자가 모두 같은 행이 둘이 됐습니다. 그때 나온 질문이 하나였습니다. 제약이 왜 안 막는가.
두 번째 행이 생기면 그다음이 더 나쁩니다. 단건 반환을 기대하는 조회가 실패하고, 템플릿 목록 API 가 500 을 냅니다. 그런데 화면은 에러를 보여주지 않습니다. '진행 중인 점검 양식이 없습니다' 라는 빈 상태를 그립니다. 사용자 입장에서는 장애가 난 화면과 할 일이 없는 화면이 똑같이 생겼습니다.

엔티티에는 있었고 실물 스키마에는 없었습니다
엔티티를 먼저 열었습니다. 점검 템플릿과 매장, 점검일, 점검자를 묶은 복합 UNIQUE 제약이 선언돼 있었습니다. 선언은 분명히 있었습니다.
운영 DB 의 실물 스키마를 봤습니다. 없었습니다.
이유는 설정 한 줄이었습니다. 이 프로젝트는 운영 데이터를 지키려고 스키마 자동 생성을 `ddl-auto: none`(애플리케이션이 뜰 때 테이블·제약을 만들거나 바꾸지 않는 설정)으로 꺼 두고 있었습니다. 운영 DB 를 코드가 멋대로 고치지 못하게 막은 것이고, 그 판단 자체는 옳습니다. 다만 그 설정은 엔티티에 적은 제약이 실물에 반영될 경로까지 같이 없앱니다.
그래서 엔티티의 스키마 선언은 그날부터 문서가 됩니다. 실행되지 않는 문서라, 실물과 갈라져도 아무 소리가 안 납니다. 컴파일도 통과하고, 테스트도 통과합니다. 리뷰에서도 선언만 보이니 그대로 지나갑니다. 테스트 DB 는 자동 생성으로 뜨니까 거기서는 제약이 실제로 있었습니다.
테스트가 통과하는 것이 실물을 봤다는 뜻은 아닙니다. 테스트 668건이 전부 통과하는 동안 마이그레이션 파일은 한 줄도 읽히지 않고 있던 기록이 있습니다.

앱만 고칠까, DB 까지 건드릴까
원인이 보인 뒤에 갈림길이 나왔습니다. 고칠 층이 둘이었고, 비용이 서로 달랐습니다.
선택 | 막는 범위 | 비용과 위험 |
|---|---|---|
앱만 고친다 — 완료 카드 분기 수정 | 이번에 발견한 경로 하나 | 코드 수정뿐. 다음에 새로 생기는 경로가 같은 자리를 또 밟는다 |
DB 까지 고친다 — 복합 UNIQUE 적용 | 같은 조합의 두 번째 행 전부 | 운영 DDL 이라 사전 확인이 필요하다. 기존 중복이 하나라도 있으면 적용이 실패한다 |
앱만 고치는 쪽이 싸고 빠릅니다. 그런데 이번 경로가 생긴 이유를 보면 선택이 달라집니다. 이 결함은 누가 설계를 잘못해서 생긴 것이 아니고, 이어쓰기를 붙이면서 상태 분기 하나를 빠뜨려서 생겼습니다. 같은 종류의 누락은 다음 기능에서도 같은 확률로 생깁니다.
두 겹으로 막았습니다
둘 다 했습니다. 앱 층에서는 완료된 건을 눌렀을 때 새 점검으로 보내지 않고 이력으로 보냈습니다. 그러면서 백지로 새로 시작하는 길 자체를 없앴습니다. 새로 쓰려면 기존 건을 먼저 버려야 합니다. 그 결과 중복 행을 만들 입구가 구조적으로 사라졌습니다.
그 위에 DB 제약을 얹었습니다. 운영 DDL 이라 순서를 지켰습니다.
적용 전에 기존 중복을 먼저 셌습니다. 같은 조합이 둘 이상인 묶음은 0건이었습니다
0건을 확인한 뒤에 UNIQUE 제약을 적용했습니다
완료된 카드의 이동 경로를 이력 화면으로 바꿨습니다
같은 작업에서 발견한 다른 결함 둘을 함께 고쳤습니다 — 활성 항목만 세는 화면과 전체를 세는 카운트의 기준이 갈려 있었고, 점수 컬럼이 비는 문항 유형을 그대로 더해서 카테고리 점수가 항상 0 이었습니다
백엔드 테스트 809건이 통과한 뒤 배포했습니다
중복이 0건이라 바로 걸 수 있었습니다. 0 이 아니었으면 제약 적용이 실패했을 것이고, 그때는 데이터 정리가 먼저였을 겁니다. 정리는 어느 행을 남기고 어느 행을 지울지 고르는 일이라 도메인 판단이고, 개발자 혼자 끝낼 수 없습니다. 작업 전체는 5시간이 걸렸습니다.
안 만드는 것과 안 보는 것은 다른 선택입니다
이 사건의 핵심은 설정 하나가 두 가지를 같이 껐다는 점입니다. 스키마를 자동으로 만들지 않겠다는 선택과, 선언과 실물이 같은지 보지 않겠다는 선택은 서로 다른 결정입니다. 그런데 한 설정값에 묶여 있어서 같이 꺼집니다.
같은 조합을 가리키는 기준이 코드와 DB 에서 갈리는 일은 제약 유무만의 문제도 아닙니다. 제외 조건은 이름으로 세고 유니크 제약은 코드로 세고 있던 기록이 있습니다.
끄더라도 대조는 켤 수 있습니다. 부팅할 때 엔티티와 실물 스키마를 맞춰 보는 `validate` 모드가 있고, 마이그레이션 도구로 스키마 변경을 코드와 같은 저장소에 두는 방법도 있습니다. 둘 중 어느 쪽도 운영 데이터를 바꾸지 않습니다.
다만 대조도 만능은 아닙니다. DB 종류와 드라이버에 따라 무엇까지 비교하는지가 다르고, 인덱스나 제약 이름 차이로 잘못된 경고가 쌓이면 팀이 대조 자체를 꺼 버리는 쪽으로 흐릅니다. 잘못된 경고가 많은 검사는 사람이 끄게 되고, 끄고 나면 다음 사고의 신호까지 같이 묻힙니다. 남은 패턴은 세 가지입니다.
단건 반환 쿼리는 제약이 지켜 준다는 가정 위에 서 있습니다. 이 조합은 하나뿐이라는 문장이 코드에는 반환 타입으로, DB 에는 제약으로 적혀 있어야 하는데, 한쪽만 있으면 다른 쪽이 런타임 예외로 갚습니다
예외가 화면에서 빈 상태로 번역되는 층을 의심합니다. 목록 API 가 실패했는데 화면이 없습니다를 그리면, 사용자도 운영자도 장애를 못 봅니다. 이 경우엔 에러 화면이 오히려 친절합니다
제약이 늘 정답은 아닙니다. 지운 행을 남기거나 이력을 같은 테이블에 쌓는 모델에서는 UNIQUE 가 같은 조합의 재생성을 막습니다. 그런 곳에서는 조건을 붙인 인덱스가 먼저입니다
같은 자리를 점검하는 네 가지
운영 중인 시스템에서 같은 상태를 찾으려면 이 순서로 봅니다.
스키마 자동 생성을 끈 프로젝트에서, 엔티티에 선언된 제약과 인덱스가 실물에 있는지 한 번이라도 대조한 기록이 있는지 확인합니다
단건 반환을 기대하는 조회를 모아서, 그 조합이 DB 제약으로도 보장되는지 짝을 맞춰 봅니다
목록 조회가 실패할 때 화면이 에러를 보여주는지, 빈 상태로 그리는지 확인합니다
제약을 새로 걸기 전에 기존 중복을 먼저 셉니다. 0 이 아니면 데이터 정리와 남길 행 선정이 먼저이고, 그건 도메인 담당자와 같이 정합니다

요청받은 것보다 요청 안 받은 것이 위험했습니다
들어온 요청은 이어쓰기 하나였고, 그건 불편의 문제였습니다. 작업하다 발견한 제약 없음은 아직 발생하지 않은 500 이었습니다. 데이터 한 행이 들어오는 순간에 장애가 되고, 그 장애는 화면에서 빈 상태로 보입니다.
요청 범위를 붙이다가 그 옆이 드러나는 일은 흔합니다. 이번에는 한 요청에서 셋이 나왔습니다. 요청 밖이니 다음에 보자고 미루면, 미뤄 둔 목록이 그대로 다음 장애 목록이 됩니다. 적어도 무엇을 미뤘는지는 기록으로 남겨 두는 편이 낫습니다.