같은 문장과 같은 답변은 다르다
대화의 답변을 노트로 저장한 뒤 새로고침하면, 버튼이 그 답변의 저장 여부를 다시 알아야 한다. 클릭 직후의 화면 상태만 남기면 이 관계가 사라진다. 노트 본문을 비교해도 충분하지 않다. 서로 다른 질문에 나온 답변이 같은 문장을 가질 수 있고, 저장한 노트의 본문은 나중에 수정할 수 있기 때문이다.
Kaya Toast에서는 노트에 source_conversation_message_id를 추가했다. 저장 여부는 텍스트 일치가 아니라 해당 assistant 메시지를 출처로 가진 노트가 있는지로 판단한다. 출처가 없는 일반 노트도 계속 만들 수 있도록 컬럼은 nullable이고, 유일 인덱스는 출처 ID가 있는 행에만 적용한다.
CREATE UNIQUE INDEX note_source_conversation_message_id
ON note(source_conversation_message_id)
WHERE source_conversation_message_id IS NOT NULL; API는 출처 메시지의 존재, assistant 역할, 요청한 글과 대화의 연결을 확인한다. 이미 저장된 출처라면 기존 노트를 담아 409로 응답한다. 클라이언트는 그 노트를 목록에 합치고 포커스를 옮긴다. “저장됨” 버튼도 저장된 노트들의 출처 ID에서 계산하므로 다시 읽은 데이터로 상태를 복원할 수 있다.
대화를 지워도 노트는 남긴다
출처 관계에는 ON DELETE SET NULL을 사용한다. 원래 대화나 메시지를 지울 때 사용자가 보관한 노트까지 지우지 않기 위한 선택이다. 대신 노트가 어느 답변에서 왔는지에 대한 연결은 사라진다. 이 연결을 통한 저장 여부 판정도 더는 할 수 없다.
| 삭제 대상 | 남는 데이터 | 출처 ID의 역할 |
|---|---|---|
| 저장한 노트 | 원래 답변은 유지 | 유일 인덱스의 자리가 비어 같은 답변을 다시 저장 가능 |
| 원래 대화·메시지 | 노트 본문은 유지 | 외래 키가 null이 되어 출처 관계 해제 |
본문 해시는 다른 답변을 잘못 합칠 수 있고, 화면의 클릭 플래그는 다른 창의 저장을 막지 못한다. 출처 외래 키와 부분 유일 인덱스는 이 두 문제에 더 잘 맞는다. 그만큼 스키마 변경과 삭제 정책을 함께 관리해야 한다. 이 구현은 SQLite 연결에서 외래 키 검사도 활성화한다.
행은 하나만 남아도 모든 중복 요청이 409가 되지는 않는다
기존 노트를 찾는 조회와 새 노트를 넣는 호출은 별도 단계다. 두 요청이 모두 “없음”을 읽어도 유일 인덱스는 중복 행을 막는다. 하지만 API가 삽입 단계의 유일 제약 오류를 잡아 409로 바꾸지는 않는다. 조회에서 발견한 중복은 기존 노트로 안내하고, 동시 삽입 경합은 다른 오류 경로로 남는 차이가 있다.
저장소 테스트에는 출처 ID로 조회하기, 두 번째 직접 삽입의 오류, 노트 삭제 뒤 새 ID로 다시 저장하기가 있다. 이 테스트의 범위는 저장소의 중복 제약과 재저장이다. HTTP 409·출처 검증·출처 삭제 시 null 전환을 직접 검사하지는 않는다. 이 사례의 핵심은 답변과 노트의 지속적인 관계를 만들고, 그 관계가 삭제와 경합에서 어디까지 유지되는지 분명히 한 데 있다.