승인을 안 해주면 절차가 멈춘다 - 분쟁 해결 규칙을 코드에 넣기
요구사항을 듣다가 멈칫한 부분이 있었다. “기존 동호회에서 승인을 안 해주면 어떻게 되나요?” 답은 “그러면 못 옮긴다”였다. 그게 기능 명세가 아니라 현재 상황이었다.
배경
배드민턴협회 회원관리 시스템을 만들고 있다. 핵심 기능은 이적동의서 전자결재다.
회원이 동호회를 옮기려면 기존 동호회의 동의가 필요하다. 지금까지는 이렇게 처리됐다.
- 새로 갈 동호회가 기존 동호회 회장에게 종이 이적동의서를 받아온다
- 새 동호회 임원이 그 서류를 사진 찍거나 스캔한다
- 협회 카페(다음·네이버)에 게시한다
문제가 두 층으로 있었다.
기록 문제 — 동호회별로 회원을 각자 관리하니 협회 차원의 통합 이력이 없다. 누가 언제 어디서 옮겨왔는지 추적이 안 된다. 종이는 분실되고, 사진은 카페 게시물 사이에 묻힌다.
절차 문제 — 이게 더 컸다. 기존 동호회가 승인을 거부하거나 그냥 응답하지 않으면 절차 자체가 멈춘다. 회원은 기다릴 수밖에 없고, 중재할 방법이 없다. 지역 언론에 보도된 적도 있는 반복되는 갈등 유형이었다.
전산화만으로는 안 되는 이유
처음엔 “서류를 디지털로 옮기면 되겠다”고 생각했다. 그런데 그러면 기록 문제만 풀린다. 승인을 안 해주면 멈추는 건 그대로다. 종이가 화면으로 바뀔 뿐이다.
이게 기술 문제가 아니라 권한 설계 문제라는 걸 받아들이는 데 시간이 좀 걸렸다. 협회에는 분쟁을 중재할 권한이 실제로 있다. 그 권한이 지금은 코드 밖에, 사람의 전화 통화 안에 있었다.
선택: 자체 구축
세 가지를 비교했다.
서류 업로드 게시판 — 가장 싸다. 카페 업로드를 사이트 안으로 옮기는 수준이다. 이력 추적은 조금 나아지지만 교착은 그대로 남는다. 탈락.
범용 전자결재 SaaS — 결재선, 승인/반려, 알림이 이미 다 있다. 그런데 “7일 미응답 시 협회가 직권으로 처리한다” 같은 도메인 규칙을 넣을 수 없다. 결재선 우회는 보통 관리자 권한으로 ‘대결’ 처리하는 식인데, 그건 사유가 남지 않는 우회다. 우리가 필요한 건 우회가 아니라 규칙에 따른 개입이었다. 탈락.
자체 구축 — 분쟁 해결 권한을 워크플로우 안에 넣을 수 있다. 채택.
상태를 먼저 정했다
코드를 쓰기 전에 상태와 전이를 적었다. 승인 흐름은 네 단계다.
신규요청 ──▶ 기존동호회 승인 ──▶ 협회 승인 ──▶ 확정
│ │ │
│ ├─ 거부 ──────────▶ (사유 필수)
│ │
│ └─ 7일 미응답 ────▶ 협회 직권처리 가능
└─ 취소
public enum TransferStatus {
REQUESTED, // 신규 동호회가 요청
PREV_CLUB_APPROVED, // 기존 동호회 승인
PREV_CLUB_REJECTED, // 기존 동호회 거부 (사유 필수)
ASSOCIATION_APPROVED,
CONFIRMED,
CANCELED,
}
상태를 enum으로 고정하니 “지금 어디까지 왔는지”를 당사자가 화면에서 직접 본다. 전에는 서류가 어디 있는지 물어봐야 알 수 있었다.
교착을 깨는 규칙
핵심은 협회 직권처리를 버튼이 아니라 조건으로 만든 것이다. 아무 때나 누를 수 있으면 그건 그냥 관리자 우회다.
public boolean canOverride(TransferRequest req, LocalDateTime now) {
// 기존 동호회가 거부했거나
if (req.getStatus() == TransferStatus.PREV_CLUB_REJECTED) {
return true;
}
// 요청 접수 후 7일이 지나도록 응답이 없을 때만
if (req.getStatus() == TransferStatus.REQUESTED
&& req.getRequestedAt().plusDays(OVERRIDE_WAIT_DAYS).isBefore(now)) {
return true;
}
return false;
}
조건을 만족하지 않으면 협회 관리자 화면에도 직권처리 버튼이 나타나지 않는다. 7일이라는 숫자는 기본값으로 두고 협회와 조율해 조정할 수 있게 했다.
사유를 필수로 만든 것이 실제로 중요했다
거부와 직권처리 두 경우 모두 사유 입력 없이는 진행되지 않게 했다.
public void reject(Long handlerId, String reason) {
if (reason == null || reason.isBlank()) {
throw new ReasonRequiredException("거부 사유를 입력해야 합니다");
}
this.status = TransferStatus.PREV_CLUB_REJECTED;
this.handledBy = handlerId;
this.handledAt = LocalDateTime.now();
this.reason = reason;
}
사유를 DB 컬럼 하나 추가하는 걸로 끝나는 일인데, 효과는 그 이상이었다.
- 기록이 남는다. 나중에 같은 분쟁이 생겼을 때 전에 어떻게 처리했는지 볼 수 있다
- 처리자가 특정된다.
handledBy가 있으니 누가 결정했는지 분명하다 - 거부가 가벼워지지 않는다. 이유를 적어야 하면 감정적인 거부가 줄어든다
마지막 효과는 기대하지 않았던 부분이다. 입력 폼을 하나 늘린 게 행동을 바꿨다.
이력을 끊지 않기
이적이 확정될 때 소속 변경 이력을 함께 기록한다. 현재 소속만 업데이트하면 “지금 어디 소속인지”는 알지만 “어디서 왔는지”는 또 모르게 된다. 종이 시절과 같은 상태로 돌아가는 것이다.
@Transactional
public void confirm(TransferRequest req) {
Member member = memberRepository.findById(req.getMemberId()).orElseThrow();
clubHistoryRepository.save(ClubHistory.of(
member.getId(),
req.getPrevClubId(),
req.getNewClubId(),
req.getId() // 어떤 이적 건으로 바뀌었는지 연결
));
member.changeClub(req.getNewClubId());
req.markConfirmed();
}
req.getId()를 같이 넣어두는 게 핵심이다. 이력에서 이적 건으로, 이적 건에서 승인 사유로 거슬러 갈 수 있다.
배운 것
1. 업무 전산화는 업무를 베끼는 게 아니다. 종이 양식을 화면으로 옮기는 것까지는 쉽다. 그런데 종이 시절에 “안 되던 것”을 그대로 가져오면 불편만 디지털로 옮겨진다. 멈추던 지점을 찾아 거기에 규칙을 넣는 게 실제 작업이었다.
2. 사람의 재량을 조건으로 바꾸면 신뢰가 생긴다. “협회가 알아서 처리한다”와 “7일 미응답 시 협회가 처리하고 사유를 남긴다”는 같은 일인데 받아들여지는 정도가 다르다. 전자는 자의적이고 후자는 예측 가능하다.
3. 요구사항을 들을 때 “그러면 어떻게 되나요”를 끝까지 물어야 한다. 이 기능의 핵심은 요구사항 문서에 없었다. “승인을 안 해주면요?”라고 물었을 때 나온 답에 있었다.
마무리
코드량으로 보면 상태 enum 하나, 조건 메서드 하나, 컬럼 두 개다. 기술적으로 어려운 건 없었다.
어려웠던 건 무엇을 규칙으로 만들어야 하는지 알아내는 것이었다. 그건 코드를 보고는 알 수 없고, 그 일을 하는 사람에게 물어봐야 알 수 있었다.