ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

들어가며

사내 BPM 플랫폼의 자동화 엔진을 운영한 지 1년이 넘으면서, 코드에 기술 부채가 쌓이기 시작했다. 이벤트 핸들러 안에 타입 체크 로직이 중복되고, 포탈 상태 업데이트가 여러 곳에 산재해 있었고, Gson과 Jackson이 혼용되고 있었다. "이대로 두면 다음 기능 추가할 때 터진다"는 판단 하에, 하루 만에 5단계 리팩토링을 진행했다. 그 과정을 정리한다.

환경

  • BPMN 엔진: Camunda 7 (Spring Boot 기반)
  • 언어: Java 17
  • 비동기 처리: Project Reactor (Mono, Schedulers)
  • 직렬화: Jackson (Gson에서 전환)
  • 테스트: JUnit 5 + Mockito
  • 서비스 타입: REST API, OData, RPA, OCR, Email, LLM(Claude/OpenAI/Gemini) 등 12종

문제 / 증상

리팩토링 전 코드에서 발견한 주요 문제들:

1. 이벤트 타입 중복 체크
디스패처인 handleEvent()에서 이벤트 타입을 분기한 뒤, 개별 핸들러에서 또다시 같은 타입 체크를 하고 있었다.

// 디스패처에서 이미 INCIDENT_CREATE로 분기했는데...
private void handleIncidentCreateEvent(HistoryEvent historyEvent) {
    if (historyEvent.isEventOfType(HistoryEventTypes.INCIDENT_CREATE)) { // ← 또 체크
        // 실제 로직
    }
}

2. 포탈 상태 업데이트 책임 분산
인시던트 발생 시 포탈에 ERROR 상태를 보내는 코드가 HistoryEventHandlerIncidentReporter 양쪽에 있었다.

// IncidentReporter.reportIncident()
public Incident reportIncident(Incident incident, ...) {
    boxwoodPortalRestApiService.updateProcessStatusPublisher(
        processInstanceId, ERROR)  // ← 여기서도 업데이트
        .subscribeOn(Schedulers.single())
        .subscribe();
    return incident;
}

// HistoryEventHandler.handleIncidentCreateEvent()도 동일한 호출...

같은 상태를 두 곳에서 보내니, 어느 쪽이 먼저 실행될지 보장할 수 없고, 한쪽만 수정하면 버그가 됐다.

3. 기타 문제들

  • AbstractStartListener가 ScriptObject 변환 + null 정규화 + 메타데이터 조회를 전부 담당 (SRP 위반)
  • ApplicationContextProvider.getBean() 호출이 코드 전체에 산재
  • Gson과 Jackson이 9개 파일에서 혼용
  • FailedJobHandlerFailedExternalTaskHandler가 95% 동일한 코드

원인 분석

근본 원인은 기능 추가 시 "가장 가까운 곳에 넣기" 패턴이었다. 인시던트 발생 시 포탈 알림이 필요하니까 IncidentReporter에 넣고, 나중에 히스토리 이벤트 핸들러에도 같은 로직이 필요하니까 거기에도 넣었다. 각각의 시점에서는 합리적이었지만, 결과적으로 책임이 분산됐다.

해결 — 5단계 리팩토링

Phase 1+2: 기반 다지기

안전장치부터 깔았다. 에러 핸들러 누락, unsafe 캐스팅, null 방어 같은 즉시 위험한 항목을 먼저 처리했다.

// Phase 1: Reactive subscribe()에 에러 핸들러 추가 (7곳)
boxwoodPortalRestApiService.updateProcessStatusPublisher(...)
    .subscribeOn(Schedulers.single())
    .doOnError(e -> log.error("포탈 상태 업데이트 실패: {}", processInstanceId, e))
    .onErrorResume(e -> Mono.empty())  // ← 이게 없으면 에러 시 구독 해제됨
    .subscribe();

// Phase 2: NullSafeELResolver — Map에서 없는 키 접근 시 null 반환
public class NullSafeELResolver extends MapELResolver {
    @Override
    public Object getValue(ELContext context, Object base, Object property) {
        if (base instanceof Map && !((Map<?,?>) base).containsKey(property)) {
            context.setPropertyResolved(true);
            return null;  // ← NPE 대신 null 반환
        }
        return super.getValue(context, base, property);
    }
}

총 11개 파일, +258줄, -26줄. 기존 동작을 바꾸지 않으면서 안전망을 씌웠다.

Phase 3: 이벤트 디스패치 정리 + 포탈 상태 업데이트 역할 분리

이번 리팩토링의 핵심이다.

원칙: 이벤트 타입 판별은 디스패처의 책임, 핸들러는 타입이 보장된 상태에서 실행.

// AFTER: handleEvent() — 중앙 디스패처가 타입별로 분기
@Override
public void handleEvent(HistoryEvent historyEvent) {
    if (historyEvent.isEventOfType(HistoryEventTypes.PROCESS_INSTANCE_START)) {
        handleProcessInstanceStart(historyEvent);
    } else if (historyEvent instanceof HistoricIncidentEventEntity) {
        // 인시던트 이벤트는 여기서 한 번만 분기
        if (historyEvent.isEventOfType(HistoryEventTypes.INCIDENT_CREATE)) {
            handleIncidentCreateEvent(historyEvent);
        } else if (historyEvent.isEventOfType(HistoryEventTypes.INCIDENT_RESOLVE)) {
            handleIncidentResolveEvent(historyEvent);
        }
    } else if (historyEvent.isEventOfType(HistoryEventTypes.PROCESS_INSTANCE_UPDATE)) {
        handleProcessSuspendEvent(historyEvent);
    }
    // ...
}

// AFTER: 핸들러는 타입 체크 없이 바로 비즈니스 로직 실행
private void handleIncidentCreateEvent(HistoryEvent historyEvent) {
    // if (isEventOfType...) 제거됨 — 디스패처가 이미 보장
    HistoricIncidentEventEntity incident = (HistoricIncidentEventEntity) historyEvent;
    boxwoodPortalRestApiService.updateProcessStatusPublisher(
        incident.getProcessInstanceId(), ERROR)   // ← 유일한 상태 업데이트 지점
        .subscribeOn(Schedulers.single())
        .subscribe();
}

그리고 IncidentReporter.reportIncident()에서 포탈 상태 업데이트 코드를 제거했다. 포탈 상태 업데이트의 오너는 HistoryEventHandler 하나뿐이다.

2개 파일, +49줄, -63줄. 코드는 줄었는데 책임은 명확해졌다.

Phase 4: 서비스 로케이터 통합 + 책임 추출

AbstractStartListener가 너무 많은 일을 하고 있었다.

// BEFORE: AbstractStartListener.notifyInternal() 안에 전부 다 있었음
// ScriptObjectMirror 변환 로직 50줄 + null 정규화 로직 40줄 + 메타데이터 조회

// AFTER: 각각 독립 클래스로 추출
public class ScriptObjectConverter {
    public static void convertAll(DelegateExecution execution) {
        execution.getVariables().forEach((key, value) -> {
            if (isScriptObjectMirror(value)) {
                execution.setVariable(key, convertScriptObject(value));
            }
        });
    }
}

public class VariableNormalizer {
    public static void normalizeNulls(DelegateExecution execution) {
        // boxwood:typeCode 기반으로 null → 타입별 기본값
        // JSON_OBJECT → {}, JSON_ARRAY → [], INTEGER → 0, ...
    }
}

// AbstractStartListener는 조합만 담당
public void notifyInternal(BoxwoodContext ctx, DelegateExecution execution) {
    ScriptObjectConverter.convertAll(execution);    // 위임
    VariableNormalizer.normalizeNulls(execution);   // 위임
    setCurrentServiceTaskMetadata(execution);
    preprocess(execution);  // 12개 서비스 타입별 구현
}

BoxwoodContext.from() 정적 팩토리로 서비스 주입도 단일 지점으로 통합했다.

// BEFORE: 리스너마다 ApplicationContextProvider.getBean() 직접 호출
ExecutionMetadataService service = ApplicationContextProvider.getBean(ExecutionMetadataService.class);

// AFTER: BoxwoodContext가 중앙 서비스 로케이터
public static BoxwoodContext from(DelegateExecution execution) {
    return new BoxwoodContext(
        execution,
        ApplicationContextProvider.getBean(ExecutionMetadataService.class),
        ApplicationContextProvider.getBean(ProcessExecutionMetadataService.class)
    );
}

15개 단위 테스트 추가. 추출한 클래스들이 독립적으로 테스트 가능해졌다.

Phase 5: 인프라 정리

마지막으로 기술 부채를 정리했다.

// BEFORE: Gson TypeToken 사용
Type type = new TypeToken<Map<String, Object>>(){}.getType();
Map<String, Object> result = new Gson().fromJson(json, type);

// AFTER: Jackson TypeReference로 통일
TypeReference<Map<String, Object>> typeRef = new TypeReference<>(){};
Map<String, Object> result = objectMapper.readValue(json, typeRef);

FailedJobHandlerFailedExternalTaskHandler의 95% 중복 코드를 AbstractBoxwoodIncidentHandler로 추출했다.

// BEFORE: 두 클래스가 거의 동일한 handleIncident() 구현
// AFTER: 공통 로직을 추상 클래스로 올림
public abstract class AbstractBoxwoodIncidentHandler extends DefaultIncidentHandler {
    @Override
    public Incident handleIncident(IncidentContext context, String message) {
        final Incident incident = super.handleIncident(context, message);
        boxwoodPortalRestApiService.sendErrorReportEmail(...);
        return reportIncident(incident, context, processEngineSupplier.get());
    }
}

13개 파일, Gson 의존성 완전 제거.

결과

항목 Before After
포탈 상태 업데이트 지점 2곳 (HistoryEventHandler + IncidentReporter) 1곳 (HistoryEventHandler)
이벤트 타입 체크 디스패처 + 핸들러 중복 디스패처 1회만
직렬화 라이브러리 Gson + Jackson 혼용 Jackson 단일
AbstractStartListener 책임 4가지 (변환+정규화+메타데이터+전처리) 1가지 (조합+위임)
인시던트 핸들러 중복 95% 0% (공통 추상 클래스)
총 변경 34 files +845, -516
테스트 추가 15개

배운 점

  1. "가장 가까운 곳에 넣기"는 기술 부채의 씨앗이다. 기능을 추가할 때 기존 구조에서 책임의 경계를 먼저 확인해야 한다. 인시던트 발생 시 포탈 업데이트가 필요하다고 IncidentReporter에 넣은 순간, 이미 책임이 분산된 것이다.
  2. 리팩토링은 "안전망 먼저, 구조 변경 나중에". Phase 1+2에서 에러 핸들러와 null 방어를 먼저 깔았기 때문에, Phase 3에서 이벤트 디스패치 구조를 바꿀 때 자신 있게 진행할 수 있었다.
  3. 추출의 기준은 "독립적으로 테스트 가능한가"다. ScriptObjectConverterVariableNormalizer를 추출한 후 각각 4개, 6개의 단위 테스트를 바로 작성할 수 있었다. 추출 전에는 테스트가 불가능했다.
profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.