기술

취소 로직 공통화를 검토했더니, 바꾼 코드는 0줄이었습니다

2026.09.268분 읽기

급식서비스를 운영하면서 취소 때문에 손이 간 적이 두 번 있습니다. 한쪽에서는 회원이 예약을 취소했는데 포인트가 돌아가지 않은 일이 있었습니다. 화면에는 취소가 정상 처리됐다고 찍혀 있었고, 환불만 빠져 있었습니다. 다른 쪽에서는 취소 알림과 푸시를 뒤늦게 보강했습니다.

코드를 열어보니 취소 로직이 두 도메인에 따로 있었습니다. 하나는 포인트로 결제하는 유료 예약이고, 다른 하나는 무료로 잡는 공간 예약입니다. 흐름은 닮았는데 파일이 둘이라, 한쪽을 고쳐도 다른 쪽에 같은 구멍이 남을 수 있습니다.

그래서 공통 패턴으로 묶을 가치가 있는지 정식으로 검토했습니다. 결론부터 적으면 코드는 한 줄도 바꾸지 않았습니다.


취소는 상태 하나 바꾸는 일이 아니었습니다

처음 설계할 때는 취소를 상태값 하나 바꾸는 일로 봤습니다. 운영을 겪고 나니 취소 한 건에 붙는 일이 여덟 가지였습니다.

권한 검증 — 본인 예약인지, 매장 관리자인지

마감 검증 — 취소 가능 시각을 넘겼는지

중복 검증 — 이미 취소된 건을 또 취소하는지(멱등성)

상태 변경

환불 — 돌려줄지, 얼마를 돌려줄지

인앱 알림 행 생성

푸시 발송

취소 사유 저장

이 여덟 가지가 두 도메인에 각각 흩어져 있었습니다. 환불 누락도 알림 보강도 결국 이 목록 중 하나가 한쪽 코드에서 빠져 있던 것입니다. 그러니 한 곳에 모아두면 다음 누락을 막을 수 있겠다는 기대가 생겼습니다.


공통 흐름은 실제로 있었습니다

두 도메인의 취소 코드를 나란히 놓고 순서를 뽑았습니다. 권한 검증, 마감과 중복 검증, 상태 변경, 환불, 알림. 이 다섯 단계는 순서까지 같았습니다. 여기까지만 보면 공통 템플릿 하나에 도메인별 훅을 꽂는 그림이 자연스럽게 나옵니다.

취소 흐름 5단계의 공통 순서와 환불 단계에서 유료 예약·무료 공간 예약이 갈리는 지점

그런데 각 단계 안을 들여다보니 갈리는 지점이 파라미터 수준이 아니었습니다. 단계 자체가 한쪽에만 있었습니다.


갈리는 지점이 본질이었습니다

항목

유료 예약

무료 공간 예약

결제

포인트 결제

없음

환불

있음. 주문·라인 단위

없음. 누락이 아니라 원래 없음

부분 취소

가능

불가. 1건 단위

취소 사유

저장 안 함

저장함

상태값

취소 하나

회원 취소와 매장 회수를 다른 값으로 둠

환불이 가장 큰 갈림이었습니다. 유료 예약은 주문 하나에 라인이 여럿이고, 라인 하나만 취소하면 그 금액만 돌려줍니다. 무료 공간 예약은 돌려줄 것이 없습니다. 같은 환불 단계가 한쪽에서는 가장 복잡한 부분이고, 다른 쪽에서는 아예 없는 단계입니다.

상태값도 달랐습니다. 공간 예약은 회원이 스스로 취소한 것과 매장이 거둬들인 것을 다른 값으로 둡니다. 운영자가 목록을 볼 때 누가 취소했는지가 값 하나로 보입니다. 유료 예약에는 그 구분이 없고 취소 하나뿐입니다.

마감 정책은 양쪽 다 이미 같은 방식이었습니다. 정책 계산 서비스가 마감 시각을 동적으로 구하고, 취소 서비스는 그 결과만 받습니다. 여기는 더 묶을 것이 없었습니다.

공간 예약 쪽 마감 정책을 어떻게 세웠는지는 앞서 따로 정리했습니다.


공통 추상화를 그려봤더니 분기투성이였습니다

그래도 한 번은 그려봤습니다. 취소 정책 객체 하나와 취소 커맨드·핸들러로 흐름을 한 곳에 모으는 안입니다. 그려놓고 보니 세 가지가 걸렸습니다.

첫째, 템플릿이 분기투성이가 됩니다. 환불 단계는 '환불이 있는 도메인이면'으로 감싸야 하고, 그 안에서 다시 '부분 취소면'으로 갈립니다. 사유 저장도 '사유를 받는 도메인이면'입니다.

cancel(요청):
  권한 검증
  마감 검증
  중복 검증
  상태 변경              // 취소? 회수? 도메인마다 값이 다름
  if (환불 있는 도메인)    // 유료 예약만
    if (부분 취소 허용)    // 유료 예약만
      라인 단위 환불
    else
      전체 환불
  if (사유 받는 도메인)    // 공간 예약만
    사유 저장
  알림 + 푸시

공통 코드라고 부르는 것이 실제로는 두 도메인의 조건문을 한 파일에 모아둔 것에 가깝습니다. 읽는 사람은 결국 어느 도메인의 취소인지를 머릿속에서 다시 갈라야 합니다.

둘째, 상태값을 강제로 통일하면 파급이 큽니다. 매장 회수 상태를 없애고 취소 하나로 합치면 응답 DTO, 관리자 웹, 회원 앱의 표시 로직, 기존 데이터 마이그레이션까지 손이 갑니다.

그렇게 해서 얻는 것은 템플릿의 분기 하나를 지우는 것뿐입니다. 게다가 회원 취소와 매장 회수를 나눠 둔 지금 쪽이 오히려 읽기 쉽습니다. 누가 취소했는지가 값 하나로 보이기 때문입니다.

셋째, 핸들러 층이 추적을 어렵게 합니다. 도메인이 둘뿐인데 커맨드를 만들고 핸들러로 넘기고 정책 객체를 조회하는 층이 생기면, 취소 한 건이 왜 이렇게 처리됐는지 따라가는 경로가 두 배로 늘어납니다.

환불 누락 같은 문제는 단건을 끝까지 따라가야 잡힙니다. 그 추적이 어려워지는 구조를 도메인 둘 때문에 들이는 것은 손해였습니다.

취소 로직 공통 추상화 도입안과 도메인별 독립 유지안의 좌우 비교


코드는 한 줄도 바꾸지 않았습니다

결론은 독립 유지였습니다. 두 도메인의 취소 서비스를 그대로 두고 코드 변경은 0줄입니다. 대신 두 가지를 남겼습니다.

하나는 '왜 공통화하지 않았나'를 적은 의사결정 기록입니다. 이 중복은 눈에 잘 띄어서, 다음에 코드를 보는 사람이 같은 제안을 다시 할 가능성이 높습니다. 그때 검토를 처음부터 반복하지 않도록 근거를 남겼습니다.

다른 하나는 재검토 트리거입니다. 취소가 필요한 세 번째 도메인이 생기면 다시 본다고 적어뒀습니다. 둘일 때는 무엇이 진짜 공통이고 무엇이 우연히 닮은 것인지 가르기 어렵습니다. 셋째가 나오면 그 경계가 드러납니다.


중복은 공통화 신호가 아니라 검토 신호였습니다

이번 검토에서 남은 기준은 세 가지입니다.

같은 모양의 흐름이라도 핵심 분기가 본질적으로 다르면 묶지 않습니다. 환불 유무처럼 단계가 있고 없고가 갈리면 파라미터로 흡수되지 않습니다

표본이 둘이면 추상화를 미룹니다(Rule of Three). 둘에서 뽑은 공통 모델은 셋째가 나왔을 때 틀리는 경우가 많습니다

추상화의 숨은 비용은 간접 참조입니다. 층이 하나 늘 때마다 단건 추적 경로가 늘고, 소규모에서는 이 비용이 이득을 넘습니다

반대 경우도 적어둡니다. 취소 도메인이 셋 이상이거나 차이가 파라미터 수준이면 공통화가 맞습니다. 규제나 감사 때문에 취소 흐름을 한 곳에서 강제하려면 간접 참조 비용을 감수하고도 중앙화가 낫습니다.

그리고 트리거를 실제로 추적하지 않으면 '지금 안 한다'가 '영원히 안 한다'로 굳습니다. 세 번째 도메인이 생겼을 때 그 기록을 다시 열 사람이 있어야 이 결정이 유효합니다.

중복 코드를 발견했을 때 공통화 여부를 가르는 판단 순서도


공통화 제안이 올라왔을 때 보는 것

반복되는 도메인이 몇 개인가. 둘이면 일단 미룬다

갈리는 지점이 파라미터 수준인가, 단계의 유무 수준인가

통일하려는 값이 화면·DTO·기존 데이터까지 파급되는가

추상화 뒤에 단건 추적 경로가 얼마나 늘어나는가

안 하기로 했다면 근거와 재검토 조건을 어디에 남기는가

검토에 들인 시간의 결과물은 코드가 아니라 기록이었습니다. 중복을 발견하는 것과 공통화하는 것 사이에는 '갈리는 지점이 무엇인가'를 묻는 단계가 하나 있고, 이번에는 그 단계에서 멈추는 것이 답이었습니다.

다음에 같은 제안이 올라오면 다시 검토하는 대신 그 기록을 읽고, 세 번째 도메인이 생겼는지만 확인하면 됩니다.

#공통화#추상화#RuleOfThree#취소처리#환불#멱등성#YAGNI#의사결정#백엔드설계#운영의기술