website: 소프트 딜리트 엔티티에 unique 제약 하나로 두 규칙을 표현하려다 생긴 버그
멤버 관리 기능(#145) PR #155 리뷰에서 지적받은 문제를 고치고, 그 과정에서 배운 패턴을 학습 기록에 남겼다.
멤버 엔티티에 studentId(학번) 컬럼에 단일 unique 제약을 걸어놨었다. 오프보딩은 실제로 row를 삭제하는 게 아니라 로그인만 막는 소프트 딜리트 방식이라, 오프보딩된 멤버의 row도 DB에 계속 남아있다. 리뷰에서 이 조합이 문제가 된다는 지적이 나왔다 — 휴학 후 재입부처럼 실제로 있을 법한 케이스에서, 예전에 썼던 학번이 이미 "등록된 학번"으로 잡혀서 재입부 자체가 영구히 막히는 상황이었다.
원인을 정리해보니 이랬다. unique 제약을 학번 하나에만 걸어놓은 건, 사실 두 가지 서로 다른 규칙을 하나의 DB 제약으로 억지로 표현하려 한 것이었다. 하나는 "학번은 한 사람을 가리키는 자연키니까 유일해야 한다"는 규칙이고, 다른 하나는 "같은 사람이 동시에 두 개의 활동 상태로 존재할 순 없다"는 규칙이다. 전자는 시간이 지나도 변하지 않는 정적인 유일성이고, 후자는 "현재 활동 중"이라는 상태에 걸린 유일성인데, 이 둘을 컬럼 하나의 unique 제약으로 뭉뚱그려 놓으니 소프트 딜리트된 row까지 첫 번째 규칙에 걸려버린 것이다.
해법은 두 규칙을 분리하는 것이었다. 유니크 키를 학번 하나가 아니라 "학번+기수" 조합으로 넓혀서 "한 사람을 사람으로 정의하는 조합"이 유일하도록 바꾸고, "동시에 두 개 활동 중일 순 없다"는 진짜 지켜야 할 불변식은 DB 제약이 아니라 애플리케이션 레벨에서 별도로 검증하기로 했다 — 오프보딩 안 된(활성 상태인) 레코드가 이미 존재하는지 체크하는 방식으로.
이 케이스를 기록해두면서 일반화해본 원칙은, 소프트 딜리트를 쓰는 엔티티에 unique 제약을 설계할 때는 "이 컬럼 값이 시간이 지나면서 재사용될 수 있는가"를 먼저 물어봐야 한다는 것이다. 재사용 가능성이 있는 값이라면, DB 제약 하나에 자연키 유일성과 활성 상태 유일성을 동시에 태우면 안 되고 처음부터 분리해서 설계해야 문제가 재발하지 않는다.
이번 커밋 자체는 pm/docs/learnings.md에 한 줄 추가한 게 전부지만, 같은 패턴의 버그를 다음에 또 만들지 않으려고 남긴 기록이다.