문제
엑셀 파일로 장비 수천 행을 조직에 일괄 배정하는 기능이다. 사용자는 시트에 장비번호와 조직명을 적어 올린다. 서버는 조직명으로 조직을 찾아 매핑한다.
여기서 조직명이 유일하지 않다. 조직 트리에서 서로 다른 부모 아래에 같은 이름의 하위 조직이 있을 수 있다. 사용자가 "1지점"이라고 적었는데 DB에 1지점이 셋 있다.
수천 행짜리 업로드에서 이런 행이 몇 십 개 섞여 있다.
나쁜 선택지 둘
이 상황에서 흔히 하는 처리가 둘 있고, 둘 다 나쁘다.
전부 실패시키기. “조직명 ‘1지점’이 모호합니다”라며 업로드를 거부한다. 안전하긴 하다. 그런데 사용자 입장에서는 3,000행을 올렸다가 40행 때문에 전부 거부당한 것이다. 엑셀로 돌아가 조직명을 유일하게 만들 방법도 마땅찮다 — 사용자가 조직 트리 구조를 알아야 하고, 조직 ID를 직접 적게 하는 건 엑셀 업로드의 취지에 어긋난다. 실무에서는 결국 이 기능을 안 쓰게 된다.
조용히 하나 고르기. 후보 중 첫 번째나 ID가 작은 걸 자동 선택한다. 업로드는 성공하고 화면에는 초록불이 들어온다. 그리고 40개 장비가 틀린 조직에 들어간다. 아무도 모른다. 조직 배정은 권한 범위와 연결되므로, 이건 몇 달 뒤에 “왜 이 장비가 여기 보이지”로 돌아온다.
전자는 쓸 수 없는 기능을 만들고, 후자는 조용히 틀린 데이터를 만든다.
셋째 선택지: 되묻기
커밋 6e9d8e0에서 만든 건 파이프라인이다.
파싱 → 검증 → (모호하면 해소 모달) → 재검증 → 실행
각 단계가 하는 일은 이렇다.
파싱 — 엑셀을 읽어 행 DTO 목록으로 만든다. 여기서는 아직 DB를 안 본다.
검증 — 각 행의 조직명을 DB와 대조한다. 이 단계는 아무것도 쓰지 않는다. 결과만 만든다.
해소 모달 — 모호한 조직명이 있으면 화면에 띄운다. findAllOrgCandidatesByNames로 그 이름들의 후보를 한 번에 모아 오고, 사용자에게 이름별로 어느 조직인지 고르게 한다. 후보 조회에 상위 경로를 함께 주는 게 중요하다. 1지점 셋 중에 고르라고만 하면 구분할 수가 없다.
재검증 — 사용자의 선택을 받아 처음부터 다시 검증한다.
실행 — 최종 확인 후 쓴다.
사용자의 선택은 Map<String, Long> 형태의 orgIdResolutions로 전달된다. 조직명 → 사용자가 고른 조직 ID다. 행마다가 아니라 이름마다 한 번씩만 물으므로, 3,000행에 모호한 이름이 5종류면 질문은 5번이다.
// orgIdResolutions: 중복 조직명 → 사용자가 선택한 org_id. 비어 있으면 기존 동작과 동일.
주석의 마지막 문장이 설계 포인트다. orgIdResolutions가 비어 있으면 기존 경로와 완전히 동일하게 동작한다. 모호한 이름이 없는 대부분의 업로드는 모달을 아예 만나지 않고, 코드 경로도 예전 그대로다. 새 기능이 기존 흐름에 얹히는 게 아니라 옵션으로 붙는다.
재검증을 왜 별도 단계로 두나
“이미 검증했는데 선택만 받으면 되지 않나”라고 생각하기 쉽다. 그렇지 않다.
첫 번째 이유는 신뢰 경계다. orgIdResolutions는 브라우저에서 온 값이다. 사용자가 후보 중 하나를 골랐다는 보장은 클라이언트 코드가 하는 것이고, 그건 보장이 아니다. 서버는 그 org_id가 실제로 그 이름의 유효한 후보였는지 다시 확인해야 한다. 실제 구현에서 해소값을 후보와 대조하는 코드가 들어가는 이유다.
두 번째는 다른 검증이 되살아나기 때문이다. 조직이 정해져야 비로소 판단할 수 있는 규칙들이 있다. 그 조직에 그 장비를 넣을 권한이 있는지, 이미 다른 조직에 배정돼 있지는 않은지 같은 것들. 1차 검증 때는 조직이 미정이라 이 규칙들을 건너뛴다. 조직이 확정된 뒤 다시 돌려야 한다.
세 번째는 시간이 흘렀기 때문이다. 사용자가 모달에서 고민하는 동안 다른 관리자가 조직을 지웠을 수도 있다. 흔한 일은 아니지만, 재검증이 있으면 공짜로 막힌다.
검증 로직을 한 번만 쓰고 재활용 가능하게 만들어두면 재검증은 같은 함수를 다시 호출하는 것뿐이다. 비용이 거의 없다.
검증 결과를 3단계로
결과가 OK/실패 두 값이면 표현력이 부족하다. 세 단계로 나눴다.
| 상태 | 의미 | 처리 |
|---|---|---|
| OK | 문제 없음 | 그대로 실행 |
| WARNING | 실행되지만 사용자가 알아야 함 | 표시하고 진행 |
| SKIP | 이 행은 처리하지 않음 | 건너뛰고 나머지 진행 |
핵심은 SKIP이 전체 실패가 아니라는 것이다. 처리할 수 없는 행 몇 개가 나머지 2,960행을 막지 않는다. 사용자는 결과 화면에서 어느 행이 왜 빠졌는지 보고, 그것만 고쳐 다시 올리면 된다.
WARNING이 따로 있는 이유는 “되긴 되는데 의도한 게 맞나 싶은” 경우가 실제로 많기 때문이다. 이걸 OK로 뭉개면 사용자가 못 보고, SKIP으로 뭉개면 처리돼야 할 게 안 된다.
매칭 키 폴백
관련해서 커밋 5e350ac에는 매칭 키를 단계적으로 낮추는 인덱싱이 있다. 3키 복합 → 2키 복합 → 단독 순으로 시도한다.
가장 구체적인 조합으로 먼저 찾고, 없으면 키를 하나 떼고 다시 찾고, 그래도 없으면 단일 키로 찾는다. 사용자가 엑셀에 정보를 얼마나 채워 넣었든 최선을 다해 매칭하되, 더 구체적인 정보가 있으면 그쪽을 우선한다.
이것도 같은 철학이다. 입력이 불완전하다고 바로 포기하지 않는다. 다만 포기하지 않는 것과 아무거나 고르는 것은 다르다. 폴백은 후보가 유일해지는 지점까지만 내려가고, 그래도 여럿이면 그때 사용자에게 묻는다.
남는 교훈
“전부 실패”와 “조용히 추측” 사이에 되묻기가 있다. 그리고 되묻기는 대체로 구현이 더 비싸다. 상태를 들고 왕복해야 하고, 검증을 두 번 돌려야 하고, 모달 UI가 필요하다. 그 비용을 안 내려고 양 극단 중 하나를 고르게 되는데, 데이터 정합성이 걸린 기능에서는 낼 만한 비용이다.
질문은 행 단위가 아니라 원인 단위로 묶어야 한다. 3,000행에 대해 3,000번 물으면 아무도 안 쓴다. 모호한 이름 5개에 대해 5번 물으면 30초면 끝난다. 같은 정보를 요구하는데 사용자 경험은 완전히 다르다.
모호성 해소는 반드시 서버에서 다시 검증해야 한다. 사용자에게 선택지를 준다는 건 클라이언트가 값을 만들어 보낸다는 뜻이고, 그 값은 신뢰할 수 없다. 되묻기를 구현하면서 재검증 단계를 빼먹으면 UX는 좋아지고 보안은 나빠진다.