3 minute read

요구사항을 듣다가 멈칫한 부분이 있었다. “기존 동호회에서 승인을 안 해주면 어떻게 되나요?” 답은 “그러면 못 옮긴다”였다. 그게 기능 명세가 아니라 현재 상황이었다.

배경

배드민턴협회 회원관리 시스템을 만들고 있다. 핵심 기능은 이적동의서 전자결재다.

회원이 동호회를 옮기려면 기존 동호회의 동의가 필요하다. 지금까지는 이렇게 처리됐다.

  1. 새로 갈 동호회가 기존 동호회 회장에게 종이 이적동의서를 받아온다
  2. 새 동호회 임원이 그 서류를 사진 찍거나 스캔한다
  3. 협회 카페(다음·네이버)에 게시한다

문제가 두 층으로 있었다.

기록 문제 — 동호회별로 회원을 각자 관리하니 협회 차원의 통합 이력이 없다. 누가 언제 어디서 옮겨왔는지 추적이 안 된다. 종이는 분실되고, 사진은 카페 게시물 사이에 묻힌다.

절차 문제 — 이게 더 컸다. 기존 동호회가 승인을 거부하거나 그냥 응답하지 않으면 절차 자체가 멈춘다. 회원은 기다릴 수밖에 없고, 중재할 방법이 없다. 지역 언론에 보도된 적도 있는 반복되는 갈등 유형이었다.

전산화만으로는 안 되는 이유

처음엔 “서류를 디지털로 옮기면 되겠다”고 생각했다. 그런데 그러면 기록 문제만 풀린다. 승인을 안 해주면 멈추는 건 그대로다. 종이가 화면으로 바뀔 뿐이다.

이게 기술 문제가 아니라 권한 설계 문제라는 걸 받아들이는 데 시간이 좀 걸렸다. 협회에는 분쟁을 중재할 권한이 실제로 있다. 그 권한이 지금은 코드 밖에, 사람의 전화 통화 안에 있었다.

선택: 자체 구축

세 가지를 비교했다.

서류 업로드 게시판 — 가장 싸다. 카페 업로드를 사이트 안으로 옮기는 수준이다. 이력 추적은 조금 나아지지만 교착은 그대로 남는다. 탈락.

범용 전자결재 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 하나, 조건 메서드 하나, 컬럼 두 개다. 기술적으로 어려운 건 없었다.

어려웠던 건 무엇을 규칙으로 만들어야 하는지 알아내는 것이었다. 그건 코드를 보고는 알 수 없고, 그 일을 하는 사람에게 물어봐야 알 수 있었다.