들어가며
사내 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 상태를 보내는 코드가 HistoryEventHandler와 IncidentReporter 양쪽에 있었다.
// 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개 파일에서 혼용
FailedJobHandler와FailedExternalTaskHandler가 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);
FailedJobHandler와 FailedExternalTaskHandler의 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개 |
배운 점
- "가장 가까운 곳에 넣기"는 기술 부채의 씨앗이다. 기능을 추가할 때 기존 구조에서 책임의 경계를 먼저 확인해야 한다. 인시던트 발생 시 포탈 업데이트가 필요하다고
IncidentReporter에 넣은 순간, 이미 책임이 분산된 것이다. - 리팩토링은 "안전망 먼저, 구조 변경 나중에". Phase 1+2에서 에러 핸들러와 null 방어를 먼저 깔았기 때문에, Phase 3에서 이벤트 디스패치 구조를 바꿀 때 자신 있게 진행할 수 있었다.
- 추출의 기준은 "독립적으로 테스트 가능한가"다.
ScriptObjectConverter와VariableNormalizer를 추출한 후 각각 4개, 6개의 단위 테스트를 바로 작성할 수 있었다. 추출 전에는 테스트가 불가능했다.
'백엔드 트러블슈팅' 카테고리의 다른 글
| Kafka 동기화 삽질기 4편: 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제 (0) | 2026.03.29 |
|---|---|
| Kafka 동기화 삽질기 3편: Reconciliation — "없는 것"을 처리하는 기술 (0) | 2026.03.29 |
| Kafka 동기화 삽질기 2편: batchInsert Lock Wait Timeout과 청크 전략 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 1편: 트랜잭션 중첩이 만든 유령 롤백 분석 (0) | 2026.03.28 |
| Windows 환경 Blue-Green 무중단 배포 50커밋 삽질기 (0) | 2026.03.27 |
