Inbox 패턴 기반 레거시 연동 비동기 전환 사례

Inbox 패턴 기반 레거시 연동 비동기 전환 사례

-레거시 대량 발송정보 수신 구간을 동기 처리에서 저장-후-종료 구조로 전환한 경험-

1. 들어가며 — 어떤 문제를 마주했는가

제가 담당한 어댑터 서비스는 병원의 레거시 의료정보 시스템(이하 레거시)이 보내는 설문 발송정보를 수신해, 내부 마이크로서비스(계정 서비스, 설문 서비스)에 등록하는 관문 역할을 합니다. 겉으로 보면 전문 하나를 받아 등록하는 단순한 연동이지만, 운영에 들어가면서 세 가지 문제가 동시에 드러났습니다.

  • 처리 시간이 곧 레거시의 트랜잭션 점유 시간이었습니다. 수신 1건을 처리하기 위해 어댑터가 레거시 조회 2회, 계정 서비스 4~5회, 설문 서비스 10여 회, 총 약 20회의 REST 왕복을 동기로 수행합니다. 레거시는 대량 건을 자기 트랜잭션 안에서 연속 호출하기 때문에, 어댑터의 처리 시간 전체를 레거시가 물고 기다리는 구조였습니다. 트레이스를 떠 보니 건당 HTTP 응답이 1.4~2.2초였고, 수백 건이 몰리는 시간대에는 레거시 측 타임아웃이 상시 위험이었습니다.

  • 부분 실패가 고아 리소스를 남겼습니다. 어댑터는 자체 DB를 갖지 않는(DB-less) 설계라, 진입점에 붙어 있던 @Transactional이 사실상 아무 일도 하지 않았습니다. 중간 REST 호출이 실패하면 앞 단계에서 이미 만들어진 게스트 계정이나 수신자 정보가 롤백 없이 그대로 남았습니다.

  • 동기 응답 하나에 너무 많은 책임이 실려 있었습니다. 전문 파싱 결과부터 업무 검증(서식 존재 여부, 그룹 존재 여부, 서식 배포 여부)까지 전부 한 응답의 성공/실패 헤더로 회신하다 보니, 어느 단계에서 왜 실패했는지가 응답에 담기지 않았고 재처리 수단도 없었습니다.

이 글은 이 수신 구간을 "수신 즉시 저장하고 종료하는(Inbox 패턴)" 구조로 바꾸고, 실제 등록 로직을 Kafka 이벤트 기반 비동기 처리로 분리한 과정을 정리한 것입니다. 설계 판단의 근거와 중간에 뒤집은 결정, 실측으로 확인한 결과까지 함께 남깁니다.

2. 기술 선택 배경 — 대안을 지운 과정

2.1 비동기화 방식 선택

"응답을 먼저 주고 나중에 처리한다"는 방향은 금방 정해졌지만, 그 방법에는 후보가 여럿 있었습니다. 각각을 지운 이유는 다음과 같습니다.

  • 어댑터 내부 스레드풀 비동기 — 코드 변경은 가장 적지만, 어댑터가 무DB라 접수한 건을 어디에도 기록할 수 없습니다. 파드가 재기동되면 접수했다고 응답한 건이 흔적 없이 사라집니다. 레거시는 성공 응답을 받았으므로 재전송하지 않기 때문에, 이는 곧 발송 유실입니다.

  • 어댑터에 DB를 추가 — 접수 기록은 남길 수 있으나, "어댑터는 데이터를 소유하지 않는다"는 서비스 정체성을 깨뜨립니다. 데이터를 갖는 순간 원본 서비스와의 동기화 이슈가 새로 생깁니다.

  • 메시지 브로커에 원문을 직접 적재 — 유실은 막지만, 개별 건의 상태(재시도 횟수, 실패 사유, 종결 여부)를 조회하거나 운영자가 눈으로 확인할 수단이 없습니다. 대량 실패 상황에서 "지금 몇 건이 밀려 있는가"에 답하지 못하는 구조는 운영에 쓰기 어렵다고 판단했습니다.

최종 선택은 Inbox 패턴 + 이벤트 트리거였습니다. 원문을 이미 DB를 소유한 설문 서비스에 저장하되, 설문 서비스는 그것을 해석하지 않는 원문(불투명 페이로드, opaque payload)과 처리 메타데이터로만 취급합니다. 전문의 의미를 아는 것은 여전히 어댑터뿐이라는 경계를 유지하면서, 저장·상태 관리라는 인프라의 책임만 DB 소유 서비스에 위임한 셈입니다.

2.2 처리 트리거 — Feign 역호출 대신 이벤트

저장 이후 실제 처리를 누가 깨울 것인가도 쟁점이었습니다. 설문 서비스가 어댑터를 직접 호출(Feign)하면 배선은 단순하지만, 지금까지 단방향이던 어댑터→설문 의존에 역방향 동기 의존이 새로 생깁니다. 통신 매트릭스가 순환하는 순간 장애 전파 경로도 함께 생깁니다.

그래서 설문 서비스는 저장 커밋 이후 도메인 이벤트만 발행하고, 어댑터가 컨슈머로 이를 받아 처리하도록 했습니다. 결과적으로 서비스 간 계약에는 "어댑터가 설문 이벤트를 구독한다" 한 줄만 추가되었고, 역방향 동기 호출은 만들지 않았습니다. 파티션 키를 설문 작성 번호로 지정해, 같은 대상의 이벤트가 같은 파티션에서 발행 순서대로 소비되도록 한 것도 이 선택 덕분에 무료로 얻은 이점입니다.

2.3 적용 기술 스택

  • Apache Kafka — 저장 완료 이벤트 발행과 구독. 파티션 키로 순서 보장의 인프라 근거를 확보했습니다.

  • PostgreSQL & JPA — Inbox 영속화. 원문은 JSON 텍스트로, 상태·재시도 횟수·실패 사유는 정규 컬럼으로 관리합니다.

  • ShedLock — fallback 배치가 다중 인스턴스에서 중복 실행되지 않도록 분산 락을 적용했습니다.

  • Redis — 외부 시스템 조회 캐시(§4.4)와 발송 메타데이터 보존 계층으로 사용했습니다.

  • Spring Cloud OpenFeign — Inbox 등록·점유·상태 갱신 API 호출. 계약은 별도 client 모듈로 분리해 사내 저장소에 배포했습니다.

3. 적용 과정

3.1 수신 구간 — 무엇을 남기고 무엇을 뺐는가

전환의 첫 단계는 수신 시점에 하는 일을 최소로 줄이는 것이었습니다. 다만 검증을 전부 없애지는 않았습니다. 전문 자체가 깨진 건은 저장해도 영구 실패이므로 저장하지 않고 즉시 동기 실패로 회신하고, 그 외 업무 검증은 모두 비동기 경로로 넘겼습니다.

// 전환 전: 진입점이 등록 전체를 동기로 수행 (@Transactional은 DB가 없어 무효)
@Transactional
public void registerSurveySendInfo(SurveySendInfo info) {
    // 레거시 조회 2회 + 계정 4~5회 + 설문 10여 회 ... 총 20여 회 REST 왕복
    // 중간 실패 시 앞 단계 산출물(게스트, 수신자정보 등)이 롤백 없이 잔존
}

// 전환 후: 저장-후-종료
public void acceptSurveySendInfo(SrvySendInfoDto ipd) {
    validateRequired(ipd); // srvyDrawupNo, patno, formCd 존재만 확인
    smsMsgInfoAction.saveMsgInfo(ipd);  // 발송 메타데이터 보존(Redis)
    surveySendInfoInboxProxy.register( // 원문 그대로 저장 + 커밋 후 이벤트 발행
            SurveySendInfoInboxCdo.from(ipd));
}

[코드 1] 수신 진입점의 전환 전후. 레거시 대면 계약(URL·전문 규격·응답 헤더)은 바꾸지 않았습니다.

여기서 실제로 부딪힌 것이 필수 필드 목록의 확정이었습니다. 처음에는 발송일시도 필수로 잡았는데, 스테이징 실측에서 발송일시가 빈 전문이 운영에 실존한다는 사실을 확인했습니다. 기존 로직이 이미 파싱 실패 시 수신 시각으로 폴백하고 있었으므로, 검증 대상에서 제외하고 빈 값 그대로 저장한 뒤 처리 시점 폴백에 맡기는 것으로 정정했습니다. 검증을 강화하려다 멀쩡히 처리되던 트래픽을 막을 뻔한 사례였습니다.

3.2 상태 기계와 단일 재시도 원장

저장된 건은 다섯 개의 상태를 오갑니다. 수신(RECEIVED) → 처리중(PROCESSING) → 완료(COMPLETED), 실패 시 FAILED로 기록되고 재시도 상한을 넘기면 DEAD로 전이되어 수동 개입 대상이 됩니다.

설계에서 가장 신경 쓴 원칙은 재시도 원장을 하나로 유지한다는 것이었습니다. 메시징 계층의 재시도(기본 10회 후 DLT)와 애플리케이션 재시도가 겹치면 실제 시도 횟수를 아무도 설명할 수 없게 됩니다. 그래서 컨슈머는 예외를 밖으로 던지지 않고 전부 잡아 Inbox에 기록만 하며, 재시도 판단은 Inbox의 시도 횟수와 fallback 배치가 전담합니다.

3.3 원자적 점유(claim) — 중복 처리를 막는 유일한 관문

비동기 전환에서 가장 위험한 시나리오는 이벤트 중복 소비와 배치 재발행이 경합해 같은 건이 두 번 등록되는 것입니다. 이를 조건부 단일 UPDATE 한 문장으로 막았습니다. 전이에 성공한 인스턴스만 원문을 받아 처리를 진행하고, 나머지는 조용히 종료합니다.

@Modifying
@Query("UPDATE SurveySendInfoInboxJpo i " +
       " SET i.status = 'PROCESSING', i.modifiedOn = :now " +
       " WHERE i.id = :id AND i.status IN ('RECEIVED', 'FAILED')")
int claim(@Param("id") Long id, @Param("now") LocalDateTime now);

// 갱신 행 수가 1이면 점유 성공 → 원문 반환, 0이면 이미 점유/종결된 건 → null
public SurveySendInfoInboxRdo claimAndLoad(Long id) {
    if (repository.claim(id, LocalDateTime.now()) == 0) {
        return null;
    }
    return SurveySendInfoInboxRdo.from(repository.getById(id));
}

[코드 2] 점유는 조회 후 갱신이 아니라 조건부 단일 UPDATE여야 합니다. 조회와 갱신을 나누면 그 사이가 경합 구간이 됩니다.

설계 초기에는 점유 API와 원문 조회 API를 따로 두었는데, 구현하면서 점유 성공 시 원문까지 함께 반환하도록 합쳤습니다. 두 호출로 나누면 호출 횟수가 늘 뿐 아니라, 점유에 성공하고 조회에 실패하는 어중간한 상태가 생기기 때문입니다.

3.4 컨슈머 — 예외를 밖으로 던지지 않는다

@KafkaListener(topics = SurveyTopic.SURVEY_SEND_INFO_RECEIVED, groupId = "...")
public void handle(SurveySendInfoReceivedDomainEvent event) {
    SurveySendInfoInboxRdo rdo = inboxProxy.claim(event.getInboxId());
    if (rdo == null) {
        return;   // 이미 점유·종결됐거나 선행 미종결 건이 있어 보류된 경우
    }
    try {
        surveyFlow.registerSurveySendInfo(rdo.toSurveySendInfo());
        inboxProxy.complete(rdo.getId());
    } catch (PromsBizException e) { // 업무 예외 → 메시지를 그대로 사유로
        handleFailure(rdo, e.getMessage());
    } catch (Exception e) { // 시스템 예외 → 고정 문구
        log.error("inbox 처리 실패. id={}", rdo.getId(), e);
        handleFailure(rdo, "시스템 오류");
    }
}

private void handleFailure(SurveySendInfoInboxRdo rdo, String reason) {
    String status = InboxProxy.fail(rdo.getId(), reason);  // FAILED 또는 DEAD 반환
    if ("DEAD".equals(status)) {
        amcFlowProxy.sendResult(rdo.getSrvyDrawupNo(), "N", reason); // 레거시 회신
    }
}

[코드 3] 실패 기록 API가 DEAD 전이 여부를 판정해 돌려주고, 회신 트리거는 어댑터가 쥡니다.

업무 예외와 시스템 예외를 재시도 정책에서는 구분하지 않기로 했습니다. 서식 미등록 같은 업무 오류도 담당자가 서식을 등록하면 해소되는 성격이고, 재시도 간격이 1분이라 비용이 거의 없기 때문입니다. 둘의 차이는 레거시에 돌려주는 사유 문구에서만 드러납니다.

3.5 Fallback 배치 — 직접 처리하지 않고 이벤트만 재발행

이벤트 유실이나 컨슈머 중단을 보정하는 배치를 두되, 배치가 등록 로직을 직접 호출하지는 않도록 했습니다. 이벤트만 다시 흘려보내면 정상 경로와 복구 경로가 완전히 동일한 코드로 수렴하기 때문입니다. 복구 전용 코드 경로는 평소에 실행되지 않아 정작 필요한 순간에 동작을 신뢰하기 어렵다는 점도 고려했습니다.

@Scheduled(cron = "0 * * * * *")                       // 1분 주기
@SchedulerLock(name = "surveySendInfoInboxRedeliver")  // 다중 인스턴스 중복 방지
public void redeliver() {
    // ① 이벤트 유실 의심: RECEIVED 이면서 생성 후 2분 경과
    // ② 재시도 대상: FAILED 이면서 retryCount < 5
    // ③ 처리중 고착: PROCESSING 이면서 15분간 갱신 없음 → RECEIVED 복귀 후 재발행
    for (SurveySendInfoInbox target : InboxStore.findRedeliverTargets()) {
        try {
            eventPublisher.publish(SurveySendInfoReceivedDomainEvent.of(target));
        } catch (Exception e) { // 한 건 실패가 라운드 전체를 막지 않도록
            log.warn("재발행 실패. id={}", target.getId(), e);
        }
    }
}

[코드 4] 세 종류의 정체 상황을 한 배치가 흡수합니다. 건별 try-catch는 배치 작성 시 습관처럼 붙이는 편이 안전합니다.

4. 문제 해결 경험

4.1 재시도 안전성 — 가드를 흩뿌리는 대신 원자화

비동기 재시도를 도입하려면 등록 로직이 여러 번 실행돼도 안전해야 합니다. 단계별로 점검해 보니 수신자 정보·스케줄·배정·작업 등록 네 단계가 무조건 신규 생성이라 중복 위험이 있었습니다. 특히 스케줄과 배정이 중복 생성되면, 작업 없는 활성 발송 건이 남아 후속 배치가 영구히 실패하는 사고 패턴이 이미 실제로 발생한 전례가 있었습니다.

처음에는 어댑터에 "기존 건 존재 여부를 역방향으로 조회하는 API"를 추가해 단계마다 가드를 붙이는 방향을 검토했습니다. 그러나 이 방식은 가드가 네 곳으로 흩어지고, 새 단계가 추가될 때마다 같은 실수를 반복할 여지를 남깁니다.

대신 설문 서비스 측에 네 단계를 하나의 트랜잭션으로 묶는 번들 API를 만들어 호출하도록 정리했습니다. 번들이 실패하면 설문 측 데이터가 아예 남지 않으므로 재시도가 자동으로 안전해집니다. 진입부에서 이미 완주 건을 재발송 분기로 걸러내고 있었기 때문에, 이 원자화 하나로 어댑터 측 멱등 가드는 전부 불필요해졌습니다. 원자성의 책임을 데이터를 소유한 서비스로 옮긴 것이 핵심이었습니다.

4.2 순서 보장과 잔여 리스크

레거시는 배치와 단건을 구분해 알려주지 않으므로, 도착 순서를 곧 업무 순서로 간주한다는 것을 명시적 결정으로 남겼습니다. 파티션 키가 이를 인프라 차원에서 뒷받침하지만, 재시도가 끼어들면 전제가 흔들리는 구간이 하나 남습니다. 최초 발송 전문이 실패해 재시도를 기다리는 동안 같은 대상의 제출 전문이 먼저 도착하면 순서가 역전됩니다.

이 구간은 점유 직전에 같은 대상의 더 이른 미종결 건이 있으면 현재 건을 처리하지 않고 종료하는 가드로 막았습니다. 보류된 건은 배치가 순서대로 재발행하므로 결국 도착순으로 처리됩니다. 인덱스 하나로 해결되는 저비용 가드였고, 나중에 "동일 전문 재수신을 재발송 요청으로 해석한다"는 정책을 추가했을 때도 이 가드가 그대로 안전장치가 되어 주었습니다.

4.3 동기 계약 축소의 대가 — 실패를 알릴 경로 만들기

응답의 의미가 "등록 완료"에서 "접수 완료"로 바뀌면서, 업무 검증 실패를 레거시에 알릴 방법이 사라졌습니다. 이 공백을 메우기 위해 마침 별도로 설계 중이던 단방향 결과 통지 인터페이스를 실패 회신 채널로 함께 사용하도록 확장했습니다.

  • 최종 실패(DEAD) 건 — 실패 사유를 담아 발송이 성립하지 않았음을 통지합니다.

  • 발송이 생략된 건 — 문진만 생성하고 알림톡은 보내지 않는 요청임을 사유와 함께 통지합니다.

  • 이미 제출되어 재발송이 불가한 건 — 재발송 불가 사유를 통지합니다.

판정은 데이터를 소유한 설문 서비스가 수행해 응답으로 알려주고, 회신 자체는 레거시 전문을 아는 어댑터가 담당하도록 책임을 나눴습니다. 다만 회신 실패 시 재회신 수단이 없다는 점은 현재도 남아 있는 과제입니다.

4.4 병목은 추측하지 말고 계측한다 — 외부 조회 캐싱

전환 후에도 처리량이 기대만큼 오르지 않아, 모니터링 도구로 트레이스를 여러 건 교차 확인했습니다. 그 결과 등록 한 건당 발생하는 레거시 조회 두 건 중 동의서 조회 하나가 일관되게 760~984밀리초를 차지하는 반면, 다른 조회는 50~150밀리초에 그친다는 것을 확인했습니다. 두 호출은 코드상 형태가 거의 같아 보였기 때문에, 계측하지 않았다면 균등하게 느릴 것이라 짐작하고 엉뚱한 곳을 손봤을 가능성이 큽니다.

레거시 측 개선이 불가함을 확인한 뒤, 어댑터에서 짧은 TTL의 캐시로 흡수했습니다. 같은 설문을 대상으로 하는 다수의 전문이 함께 도착하는 업무 특성상, 총 조회 횟수가 "전문 수"에서 "서로 다른 설문 수"로 줄어듭니다.

public List<AmcSurveyConsent> findAmcSurveyConsent(String srvyNo) {
    String key = "proms:amc:survey-consents:" + srvyNo;
    try {
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return objectMapper.readValue(cached, new TypeReference<>() {});
        }
    } catch (Exception e) {
        log.warn("캐시 조회 실패 — 원 호출로 폴백. srvyNo={}", srvyNo, e);
    }

    List<AmcSurveyConsent> result = amcProxy.findSurveyConsent(srvyNo);
    // 동의서는 빈 목록도 캐시한다 — 동의서 없는 설문이 일반적이라 여기가 절감의 핵심
    cachePut(key, result, Duration.ofMinutes(5));
    return result;
}

[코드 5] 캐시 장애가 기능 장애가 되지 않도록, 캐시 계층의 예외는 경고 로그 후 원 호출로 폴백합니다.

캐싱 정책은 데이터 성격에 따라 차등을 뒀습니다. 설정 미존재 결과는 캐시하지 않습니다. 담당자가 설정을 정정한 직후에도 최대 5분간 옛 결과가 유지되면, 정정했는데 왜 안 되냐는 문의로 이어지기 때문입니다. 반대로 동의서 빈 목록은 캐시합니다. 동의서 없는 설문이 오히려 일반적이라 이 케이스가 절감의 대부분을 차지합니다. 트레이드오프로 레거시 설정 변경이 최대 5분 지연 반영된다는 점은 운영에 공지했습니다.

4.5 예외와 트랜잭션 경계 — noRollbackFor를 쓰지 않기로 한 이유

"처리는 실패했지만 실패 기록은 남아야 한다"는 요구가 생겼을 때, 가장 손쉬운 해법은 롤백 제외 설정을 붙이는 것입니다. 그러나 이 방식은 예외의 종류가 늘어날 때마다 목록을 관리해야 하고, 어떤 상태가 남고 어떤 상태가 사라지는지가 코드를 다 읽어야 보입니다.

그래서 트랜잭션 경계 분리로만 해결한다는 원칙을 세웠습니다. 점유·완료·실패 기록이 각각 독립된 REST 호출이자 독립 트랜잭션이라는 점이 이 원칙의 구현체입니다. 본 처리가 실패해도 실패 기록은 별도 트랜잭션이므로 롤백되지 않고, 시도 횟수와 사유가 온전히 남습니다.

실패 기록마저 실패하는 최악의 경우는 롤백이 아니라 시간에 의한 복구에 맡깁니다. 상태가 처리중으로 잔류하면 15분 뒤 배치가 되돌리고, 점유의 원자성이 이중 처리를 차단하므로 안전합니다. 다만 이 경로에서는 시도 횟수가 증가하지 않는다는 점은 문서에 명시해 두었습니다.

전수 검증 과정에서 공통 라이브러리의 공백도 하나 발견했습니다. 사내 업무 예외 클래스가 컨슈머의 재시도 제외 목록에 빠져 있어, 즉시 DLT로 가야 할 업무 예외가 10회 재시도 후에야 DLT로 가고 있었습니다. Inbox 경로는 컨슈머가 예외를 던지지 않으므로 영향이 없었지만, 다른 서비스의 컨슈머에는 그대로 영향을 주는 사안이라 공통 라이브러리 개선 항목으로 등록했습니다.

4.6 되돌리는 방법까지 설계에 포함하기

배포 후 문제가 생겼을 때 되돌리는 절차를 미리 정리하면서, 이 전환에는 일반적인 이미지 롤백이 통하지 않는 제약이 있다는 것을 알게 됐습니다. 레거시는 이미 접수 응답을 받았으므로 재전송하지 않는데, 구버전 어댑터에는 컨슈머가 없어 남아 있는 미종결 건이 영구 미처리로 남기 때문입니다. 즉 미종결 건 소진(drain) 확인이 어떤 롤백 경로에서든 선행되어야 합니다.

그래서 1차 수단을 이미지 롤백이 아니라 설정 토글로 잡았습니다. 수신 경로만 구 동기 방식으로 되돌리되 컨슈머와 배치는 살려 두면, 신규 유입은 동기로 처리되면서 잔여 건은 계속 소화되어 소진이 자연히 완료됩니다.

// 롤백 토글: false면 Inbox를 거치지 않고 구 동기 경로로 즉시 처리한다.
// off 상태에서도 컨슈머·fallback은 유지되어 잔여 inbox 건은 계속 소화된다.
@Value("${proms.survey-send-info.inbox-enabled:true}")
private boolean inboxEnabled;

if (inboxEnabled) {
    surveySendInfoInboxFlow.acceptSurveySendInfo(ipd); // 저장-후-종료
} else {
    surveyFlow.registerSurveySendInfo(new SurveySendInfo(ipd)); // 구 동기 경로
}

[코드 6] 이미지 재배포 없이 설정 변경과 재기동만으로 수신 경로를 되돌릴 수 있게 했습니다.

토글을 설계하면서 놓칠 뻔한 지점도 있었습니다. 발송 메타데이터 조회를 Inbox 우선으로 바꿔 둔 상태였는데, 토글을 끄면 재수신된 건은 Redis만 갱신되므로 옛 Inbox 행의 값으로 회신하는 역전이 발생합니다. 조회 지점에도 같은 토글을 공유해 꺼진 상태에서는 Redis만 보도록 보완했습니다. 토글은 하나를 넣으면 그것을 참조하는 모든 지점을 함께 점검해야 한다는 것을 배웠습니다.

5. 검증 결과

개발 환경 4회, 스테이징 1회의 실 트래픽 검증을 수행했고, 로그·트레이스 도구로 수치를 실측했습니다.

  • 수신 응답 시간 — 기존 1.4~2.2초에서 중앙값 33밀리초로, 약 50배 단축되었습니다. 레거시 트랜잭션 점유 문제가 근본적으로 해소되었습니다.

  • 전량 완주 — 111건 기준 38초, 273건 기준 94초에 실패 0건·fallback 개입 0건으로 완주했습니다. 처리율은 초당 약 3건이었으며, 증설이 필요하면 파티션과 컨슈머 동시성이 레버라는 것도 함께 확인했습니다.

  • 외부 조회 절감 — 캐시 도입 후 레거시 조회가 전문 수 대비 약 98% 감소했습니다(111건 처리 시 캐시 미스 조회 약 5회).

  • 실패 통지 경로 실증 — 서식 미등록 29건이 5회 재시도 후 DEAD로 전이되어 레거시에 사유와 함께 회신되는 전 과정을 실제 트래픽으로 확인했습니다.

  • 장애 자동 회복 실증 — 스테이징 검증이 공교롭게 레거시 DB 순단과 시점이 겹쳤습니다. 273건이 전량 정상 접수된 뒤 1라운드에서 전 건이 실패했고, DB 복구 후 배치 재발행을 통해 3.5분 내 273건 전량 자동 회복(최종 실패 0건)되었습니다. 의도한 설계가 계획된 시나리오가 아닌 실제 장애에서 동작한 것이라, 개인적으로 이번 전환에서 가장 의미 있는 결과였습니다.

6. 회고 — 다음에도 가져갈 것

  • 응답의 의미가 바뀌면 그 파장을 끝까지 추적해야 합니다. "등록 완료"가 "접수 완료"로 바뀐 것은 코드 몇 줄의 변경이지만, 실패 통지 경로 신설과 롤백 시 소진 선행이라는 두 개의 큰 요구가 여기서 파생됐습니다. 계약의 문구가 아니라 의미가 바뀌었는지를 먼저 묻는 습관이 생겼습니다.

  • 멱등성은 가드를 늘려서가 아니라 경계를 옮겨서 얻는 편이 낫습니다. 네 곳에 가드를 붙이는 대신 데이터 소유 서비스에서 원자화하니, 가드가 전부 사라지고 새 단계가 추가돼도 안전이 유지되는 구조가 됐습니다.

  • 복구 경로는 정상 경로와 같은 코드로 수렴시켜야 신뢰할 수 있습니다. 배치가 직접 처리하지 않고 이벤트만 재발행하게 한 결정 덕분에, 실제 장애에서 복구 경로가 처음 실행됐을 때도 별도 검증 없이 동작했습니다.

  • 병목은 계측한 뒤에 손봐야 합니다. 형태가 같아 보이는 두 조회 중 하나만 15배 느리다는 사실은 트레이스를 보지 않았다면 알 수 없었습니다.

  • 되돌리는 절차를 설계에 포함하면 설계의 허점이 드러납니다. 롤백 절차를 쓰다가 "접수 응답을 이미 준 건은 구버전에서 처리 수단이 없다"는 제약을 발견했고, 그 덕분에 토글이라는 더 나은 1차 수단을 마련할 수 있었습니다.

남은 과제도 있습니다. 회신 실패 시 재회신 수단이 없고, 원문에 평문 개인정보가 포함되므로 종결된 건의 보존 기간과 정리 배치 주기를 확정해야 합니다. 두 항목 모두 후속 작업으로 관리하고 있습니다.

conley

Site footer