- 발행일
승인될 때까지 아무것도 만들지 않기 — 신청서 작성분을 초안으로 두고 승인 때 재생하기
승인될 때까지 아무것도 만들지 않기
표준데이터 신청서에서 엘리먼트·프레임·메시지를 직접 작성할 수 있게 했습니다. 그전에는 이미 등록된 것 중에서 고르기만 할 수 있었어요.
작성 기능을 붙이는 순간 물음이 하나 생깁니다. 신청서를 낸 시점에 이것들을 만들 것인가.
만들면 반려된 신청의 찌꺼기가 표준데이터 목록에 남습니다. 심사에 며칠씩 걸리니까 그 사이 검색에도 잡히고, 이 시스템은 인스턴스가 저장될 때 OID를 자동으로 부여하는 구조라 심사도 안 끝난 것에 식별자가 먼저 나갑니다. 표준 식별자가 새는 건 되돌리기 어려워요.
만들어 두고 지울 것인가, 안 만들고 나중에 만들 것인가
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
① 신청 즉시 생성 + 초안 상태 플래그 | 화면이 실제 데이터를 그대로 씀 | 모든 조회에 조건이 하나씩 붙고, 한 곳만 빠뜨리면 샌다. OID도 그 시점에 부여됨 | 기각 |
| ② 엘리먼트·프레임·메시지 초안 테이블 3종을 따로 둠 | 구조가 명확 | 스키마 이중화. 클래스 구성 속성이 바뀌면 두 곳을 고쳐야 함 | 기각 |
| ③ JSON 초안 한 칸 + 승인 시 재생 | 승인 전에는 DB에 아무것도 없다. 조회에 조건이 안 붙음 | 초안에 스키마가 없어 재생 시점에 안 맞을 수 있음 | 채택 |
| ④ 작성 기능을 안 넣음 | 지금 그대로 | 신청자가 없는 엘리먼트를 못 씀 — 애초 요구사항 | 불가 |
①이 가장 흔한 모양이고, 그래서 함정도 흔합니다. "평소에는 안 보이는 데이터"를 만들면 안 보이게 하는 책임이 조회하는 쪽 전부에 흩어집니다. 목록·검색·관계도·통계·다운로드가 각각 조건을 알아야 하고, 새 화면을 만드는 사람도 알아야 해요. 이 블로그에 같은 표준데이터를 두 화면이 198건과 134건으로 세고 있었다를 쓴 적이 있는데, 조회 조건이 화면마다 갈리는 게 어떻게 끝나는지 이미 한 번 봤습니다.
③은 그 책임 자체를 없앱니다. 승인 전에는 인스턴스가 없으니까 아무도 걸러낼 필요가 없어요.
쓰는 방향이 곧 작성 순서다
작성 화면의 단계 순서를 뒤집었습니다. 원래는 사용자가 생각하는 순서(메시지부터)였는데, 만들어지는 순서로 바꿨어요.
엘리먼트 → 프레임(중첩은 의존 순서) → 메시지 → 데이터셋
이유는 단순합니다. 링크가 pk 참조라 자식이 DB에 먼저 있어야 부모가 가리킬 수 있습니다. 화면에서 메시지를 먼저 쓰게 해두면 그 안의 프레임은 아직 존재하지 않는 것을 가리켜야 하고, 그럼 화면이 임시 식별자를 관리해야 해요.
그래서 초안 안에서는 두 종류의 참조가 섞입니다.
- `links` 값은 `ref`(정수) 와 `tmp`(문자열) 가 섞인다. 재생 때 `tmp → 새 pk` 로 치환한다.
이미 등록된 것을 가리키면 ref(pk), 이 신청서에서 같이 쓴 것을 가리키면 tmp. 재생할 때 자식을 먼저 만들면서 tmp → 새 pk 표를 채우고, 부모를 만들 때 그 표로 치환합니다.
프레임은 프레임을 담을 수 있어서 중첩이 생기는데, 이건 위상정렬로 순서를 냅니다. 순환(f1 → f2 → f1)은 작성 화면에서 막고, 재생에서도 위상정렬이 못 끝내면 승인을 거부합니다. 화면이 막는 것과 서버가 막는 것을 둘 다 뒀어요.
재생은 원자적이어야 한다
재생 전체를 transaction.atomic으로 묶었습니다. 하나라도 실패하면 승인 자체가 안 일어나야 하거든요.
중간에 실패하면 엘리먼트 셋만 만들어지고 메시지는 없는 상태로 남는데, 그건 신청서에도 없고 표준데이터에도 없는 유령이 됩니다. 승인 버튼을 다시 눌러도 이미 만들어진 것들과 부딪혀요.
부수 효과로 얻은 것도 있습니다. OID 자동 부여가 이 트랜잭션 안에서 함께 일어납니다. 인스턴스 저장에 붙어 있는 동작이라, 생성 시점을 승인으로 미룬 것만으로 식별자 누출이 같이 막혔어요.
반려는 아무 일도 안 한다
- **반려·취소는 아무것도 하지 않는다.** 초안 JSON 만 남고 DB 인스턴스는 애초에 없다.
이 한 줄이 ③을 고른 값어치입니다. ①이었다면 반려 처리에 "만들어 둔 것들을 지운다"가 붙고, 그 삭제가 어디까지 연쇄되는지(이미 다른 데서 참조했다면?)를 또 정해야 했을 겁니다.
되돌리는 코드를 안 쓰는 제일 확실한 방법은 되돌릴 게 안 생기게 하는 것이더군요.
초안이 인덱스를 터뜨렸다
여기서 예상 못 한 게 나왔습니다. 초안을 저장하는 순간 INSERT가 실패했어요.
RequestData는 신청서의 값을 key/value로 담는 테이블이고, (key, value) 복합 색인이 있었습니다. 그리고 value를 TextField로 바꿨습니다.
# 길이 제한 없음 — 255자였을 때 긴 심사 의견과 초안 JSON 이 DB 에서 터졌다
value = models.TextField()
CharField(max_length=255)로는 수 KB짜리 초안 JSON이 안 들어가니 당연한 변경인데, 그러자 색인이 걸렸습니다.
indexes = [
models.Index(fields=['request', 'key']),
# ⚠️ 초안 JSON 은 수 KB 라 PostgreSQL btree 상한(2704바이트)을 넘겨 INSERT 자체가
# 터진다. key+value 조회는 pk 문자열을 찾는 용도뿐이라 초안 행만 색인에서 뺀다
models.Index(
fields=['key', 'value'],
name='workflow_reqdata_key_value',
condition=~Q(key='metadata_draft'),
),
]
PostgreSQL btree 색인의 항목 하나는 2704바이트를 넘을 수 없습니다. 넘으면 경고가 아니라 INSERT 자체가 실패해요. 색인이 있는 컬럼에 긴 값을 넣는 순간 저장이 거부됩니다.
부분 색인으로 초안 행만 뺐습니다. (key, value) 조회는 원래 pk 문자열을 찾는 용도뿐이라 초안은 거기 낄 일이 없거든요. 색인을 지우는 대신 색인이 필요한 행만 남긴 셈입니다.
개발은 SQLite라 이걸 안 잡습니다. 운영에서 터졌을 자리를 미리 만난 건 운이 좋았어요 — 이 시스템은 SQLite에선 통과하고 PostgreSQL에서 터진 적이 이미 한 번 있습니다.
심사자가 못 본 채 결재하지 않게
승인하면 무언가가 만들어지는데 심사 화면에 그게 안 보이면, 결재는 서명일 뿐입니다.
그래서 신청 상세·심사 상세가 초안을 기존 메시지와 같은 트리 위젯으로 그립니다. 심사자 입장에서 "지금 있는 것"과 "승인하면 생길 것"이 같은 모양으로 보여요.
한 가지 덫이 있었습니다. 처리 이력 타임라인이 신청서의 새 key를 그대로 찍기 때문에, 예약어 목록에 metadata_draft를 안 넣으면 JSON 덩어리가 이력에 한 줄로 찍힙니다. 저장하는 칸을 늘릴 때 그 칸을 읽는 다른 화면이 있는지 같이 봐야 했어요.
검증
- 파싱 계약·쓰기 순서·중첩 위상정렬을
workflow/test_metadata_draft.py203줄로 잠금. - 프레임 순환(
f1 → f2 → f1)을 만들어 승인이 거부되는 것 확인. - 재생 중간을 실패시켜 엘리먼트도 안 남는 것 확인.
atomic이 없으면 남습니다. - 반려·취소 후 표준데이터 목록·검색에 아무것도 안 나오는 것 확인.
- PostgreSQL에서 수 KB 초안을 저장해 INSERT가 통과하는 것 확인. 부분 색인 조건을 빼고 같은 저장을 시도해 실패를 먼저 재현했습니다.
- 처리 이력에 JSON이 안 찍히는 것 확인.
- 저장 전
validate()가 이름 원천이 빈 카드를 거르는 것 확인 — 이걸 안 하면 신청서를 낸 지 한참 뒤 승인 시점에 재생이 통째로 실패합니다. 그때는 신청자도 심사자도 이유를 모릅니다.
남은 것 · 한계
- 초안 JSON에는 스키마가 없습니다. 신청부터 승인까지 사이에 클래스 구성 속성이 바뀌면 옛 초안이 안 맞을 수 있어요.
validate()는 저장 시점에 도는데 재생은 승인 시점이라, 두 시점 사이의 변화는 아무도 안 봅니다. 심사가 길어질수록 위험이 커지는 구조인데 기간 제한도 재검증도 없습니다. - 반려된 신청의 초안 JSON은 계속 쌓입니다. 인스턴스는 안 생기지만
RequestData행은 남아요. 정리 정책을 안 정했습니다. tmp식별자가 문자열 규약입니다.ref는 정수,tmp는 문자열이라는 타입 차이로 구분하는데, 이건 규약이지 제약이 아니에요. 숫자로 시작하는tmp가 들어오면 조용히 잘못 치환될 수 있습니다.- 부분 색인이라 초안 값으로는 조회할 수 없습니다. 지금은 필요 없지만, "이 엘리먼트 이름으로 낸 신청이 있나"를 묻고 싶어지면 다른 길이 필요합니다.
- 개발 DB(SQLite)는 이 색인 문제를 재현하지 않습니다. 운영에서만 나는 종류라, 다음에 긴 값을 색인 걸린 컬럼에 넣는 사람은 같은 자리에서 또 넘어집니다. 모델 주석 말고는 막는 장치가 없습니다.
- 작성 단계가 늘어난 만큼 신청자의 부담도 늘었습니다. 선택 단계는 건너뛸 수 있게 했지만, 실제로 얼마나 걸리는지는 안 재봤습니다.
관련 글: 7단계를 박아둔 템플릿이 5단계 환경에서 거짓말을 했다 · SQLite에선 통과하고 PostgreSQL에서 터진 CharField(512)