1. 서론
마이크로서비스 아키텍처(MSA) 환경에서 각 도메인 서비스는 독립된 비즈니스 책임과 데이터베이스를 가집니다. 본 시스템의 근태 및 휴가 도메인(Timecard/Leave 서비스) 역시 독자적인 라이프사이클을 수행하지만, 사용자의 휴가 신청(LeavePlan)이나 특수 근무 신청과 같은 핵심 업무 프로세스는 전사 공통 도메인인 '외부 전자결재 서비스(Approval Service)'와의 유기적인 연계가 필수적입니다. 기존 프로세스는 내부 서비스 내에서 자체적으로 승인 상태를 처리하는 구조였으나, 전사 차원의 전자결재 표준화에 따라 외부 결재 서비스와의 연동 파이프라인을 구축해야 했습니다. 그러나 MSA 환경에서 외부 결재 시스템과의 통신을 단순 동기(Synchronous) 방식으로 처리하면, 결재 서버의 응답 지연이나 장애가 내부 서비스의 트랜잭션 블로킹으로 전파되는 문제가 발생합니다. 또한 사용자의 기안 상신 후 결재선 승인(Approved), 반려(Rejected), 또는 회수(Withdrawn) 시점이 비동기적이므로, 두 서비스 간의 데이터 최종 일관성(Eventual Consistency)을 보장하는 아키텍처가 요구되었습니다. 본 글에서는 MSA 환경에서 외부 결재 서비스를 연동하는 과정에서, timecard-facade의 인바운드 계층에 ApprovalStatusHandler를 구축하고 SubmitLeaveRequestTask, LeavePlanTask와 결합하여 비동기 이벤트 기반의 결재 상태 동기화 파이프라인을 완성한 실무 구현 사례를 공유합니다.
2. 기술 선택 배경
외부 결재 서비스와의 연동 아키텍처 설계 시 해결해야 할 핵심 과제는 다음과 같습니다.
-
장애 격리(Fault Isolation) 및 비동기 분리: 외부 결재 시스템의 트래픽 폭증이나 점검이 근태 서비스의 가용성을 해치지 않도록, 결재 완료 시점의 결과를 비동기 이벤트 메시지로 수신하는 구조를 채택했습니다.
-
외부 식별자(approvalId) 기반 도메인 매핑: 외부 결재 서비스에서 생성된 결재 문서 식별자(approvalId)를 내부 휴가 신청 엔티티(LeavePlan)에 외래 키(Reference Key) 형태로 바인딩하여, 향후 비동기 상태 변경 이벤트 수신 시 대상 도메인을 정확히 조회할 수 있도록 설계했습니다.
-
이벤트 멱등성 및 상태 전이 검증: 네트워크 재전송으로 인해 동일한 결재 완료 이벤트가 중복 유입되더라도 내부 데이터가 오염되지 않도록 엔티티 레벨의 멱등성 가드(State-based Guard)를 설계했습니다.
따라서 시스템 간 결합도를 최소화하면서도 외부 서비스의 상태 변화를 안전하게 추적하고 분산 환경에서의 데이터 무결성을 확보할 수 있는 솔루션으로 인바운드 이벤트 핸들러 기반의 상태 동기화 패턴을 도입하게 되었습니다.
3. 실제 적용 과정 및 트러블슈팅
본 프로젝트의 실제 결재 연동 파이프라인 설계 사상을 바탕으로, 외부 결재 서비스와 내부 도메인을 안전하게 연계한 과정을 구체적인 코드 블록과 함께 설명합니다.
3.1 결재 서비스 기안 요청 및 식별자 매핑 (SubmitLeaveRequestTask)
사용자가 휴가를 상신하면 SubmitLeaveRequestTask는 전달받은 외부 결재 문서 식별자(approvalId)의 유무를 검증하여 초기 상태를 InApproval(결재 진행 중)로 설정합니다. 유효성 검증(잔여 일수 확인, 중복 기간 체크)을 거친 후 approvalId를 LeavePlan 엔티티 내에 영속화함으로써, 향후 비동기로 도달할 결재 결과 이벤트를 추적할 수 있는 단방향 매핑 구조를 완성합니다.
@Service
@RequiredArgsConstructor
public class SubmitLeaveRequestTask {
private final LeavePlanLogic leavePlanLogic;
private final LeavePermitLogic leavePermitLogic;
public String submitLeaveRequest(String citizenId, LocalDate startDate, LocalDate endDate,
LeaveType leaveType, double leaveDays, String approvalId) {
LeavePermit leavePermit = getLeavePermit(citizenId, startDate.getYear());
leavePermit.planLeave(leaveDays);
leavePermitLogic.modifyLeavePermit(leavePermit);
LeavePlanCdo leavePlanCdo = LeavePlanCdo.builder()
.citizenId(citizenId)
.leaveType(leaveType)
.planStatus(StringUtils.hasText(approvalId) ? PlanStatus.InApproval : PlanStatus.Planned)
.approvalId(approvalId)
.build();
return leavePlanLogic.registerLeavePlan(leavePlanCdo);
}
}
3.2 결재 상태 이벤트 수신 및 라우팅 (ApprovalStatusHandler)
외부 전자결재 시스템에서 결재선상의 최종 승인, 반려, 또는 기안 회수 등의 상태 변경이 발생하면 인바운드 컨슈머 채널을 통해 이벤트 페이로드가 유입됩니다. ApprovalStatusHandler는 외부 결재 서비스의 이벤트 스펙이 내부 도메인 코어로 직접 침투하지 않도록 격리하는 어댑터 역할을 수행하며, 수신된 결재 상태(status)에 따라 적절한 도메인 Task 메서드로 처리를 라우팅합니다.
@Component
@RequiredArgsConstructor
public class ApprovalStatusHandler {
private final LeavePlanTask leavePlanTask;
public void handleApprovalStatusChanged(ApprovalStatusEventPayload payload) {
String approvalId = payload.getDocumentId();
ApprovalStatus status = payload.getStatus();
switch (status) {
case Approved -> leavePlanTask.approveLeavePlanByApprovalId(approvalId, payload.getApproverId());
case Rejected -> leavePlanTask.rejectLeavePlanByApprovalId(approvalId, payload.getRejectReason());
case Cancel -> leavePlanTask.cancelLeavePlanByApprovalId(approvalId);
default -> {
log.info("Approval transient state received: {}", status);
}
}
}
}
3.3 도메인 멱등성 검증 및 상태 갱신 (LeavePlanTask)
LeavePlanTask에서는 외부 결재 식별자(approvalId)를 기준으로 내부 도메인 엔티티를 조회합니다. 네트워크 재시도나 메시지 중복 발송으로 인해 이미 완료된 승인 이벤트가 재유입되더라도 시스템이 오작동하지 않도록 엔티티의 현재 상태(PlanStatus.Approved)를 사전에 확인하는 멱등성 가드(State-based Guard)를 수행합니다. 유효한 전이 건에 대해서만 상태 변경과 승인 메타데이터를 영속화하고, 최종 승인 완료 도메인 이벤트를 발행하여 후속 프로세스를 안전하게 트리거합니다.
@Component
@RequiredArgsConstructor
public class LeavePlanTask {
private final LeavePlanStore leavePlanStore;
private final EventPublisher eventPublisher;
@Transactional
public void approveLeavePlanByApprovalId(String approvalId, String approverId) {
LeavePlan leavePlan = leavePlanStore.findByApprovalId(approvalId)
.orElseThrow(() -> new NoSuchElementException("연동된 휴가 건이 없습니다: " + approvalId));
if (leavePlan.getPlanStatus() == PlanStatus.Approved) {
return;
}
leavePlan.setPlanStatus(PlanStatus.Approved);
leavePlan.setApproverId(approverId);
leavePlan.setApprovedTime(System.currentTimeMillis());
leavePlanStore.update(leavePlan);
eventPublisher.publish(new LeavePlanEvent(leavePlan.getId(), leavePlan.getRequesterId(), PlanStatus.Approved));
}
}
3.4 트러블슈팅: 결재 상태 변화와 DB Check 제약 조건 불일치 해결
결재 서비스 연동 초기, 사용자가 결재 상신 후 내부 도메인 상태를 전이하는 과정에서 DB 레벨의 CHECK (plan_status IN (...)) 제약 조건 위반 에러가 발생했습니다. 기존 내부 상태 머신 스키마에는 자체 결재 기준의 상태 값만 정의되어 있어, 외부 결재 서비스와의 연동으로 추가된 세부 라이프사이클 상태(InApproval)가 누락되어 있었기 때문입니다. 이를 해결하기 위해 Flyway 마이그레이션 스크립트를 적용하여 DB 제약 조건을 최신 결재 라이프사이클에 맞게 동기화하고, LeavePlanTask에 예외 상태 롤백 가드를 추가하여 런타임 데이터 정합성을 확보했습니다.
4. 적용 결과 및 성과
사내 주요 근태 및 휴가 도메인에 비동기 결재 연동 이벤트 핸들러 아키텍처를 적용한 결과, 다음과 같은 기술적 성과를 달성하였습니다.
-
MSA 시스템 간 결합도 완화 및 장애 격리: 외부 전자결재 서비스의 트래픽 급증이나 일시적 장애가 근태 서비스의 기본 기능으로 전파되지 않는 완벽한 장애 격리성을 확보했습니다.
-
분산 데이터 정합성 보장: 외부 결재 식별자(approvalId) 기반의 멱등 핸들러 설계를 통해, 네트워크 재시도로 인한 이벤트 중복 수신이나 처리 누락 없이 결재 문서와 내부 도메인 데이터 간의 일관성을 안정적으로 유지했습니다.
-
표준 연동 아키텍처 확립: ApprovalStatusHandler를 계층화·모듈화하여, 향후 특수 근무, 출장 등 결재 서비스 연동이 필요한 신규 도메인 확장 시 일관되게 재사용할 수 있는 사내 표준 연동 패턴을 구축했습니다.
5. 결론
이번 결재 연동 이벤트 핸들러 구현은 MSA 환경에서 외부 서비스와 통신할 때 도메인 간의 결합도를 낮추고 데이터 무결성을 안정적으로 지켜내는 백엔드 아키텍처 설계의 중요성을 체감하는 계기였습니다.
단순한 동기식 API 호출을 지양하고, 비동기 이벤트 핸들러(ApprovalStatusHandler)와 엔티티 레벨의 멱등 상태 갱신 설계를 도입함으로써 시스템 간 장애 격리와 최종 일관성(Eventual Consistency)을 모두 달성할 수 있었습니다. 이번 프로젝트를 통해 정립한 외부 서비스 연동 패턴과 상태 동기화 노하우를 사내 표준 가이드라인으로 자산화하여, 전사 분산 시스템의 아키텍처 안정성을 한 단계 격상시키는 데 기여하고자 합니다.
* Reference
-
Enterprise Integration Patterns - Event-Driven Consumer & Message Dispatcher
-
Chris Richardson - Microservices Patterns (Saga and Asynchronous Messaging)
-
Spring Framework Official Documentation - Integration and Event Listeners
D.Hyeok