IT

레거시 EAI 걷어내기: DB 연동에서 API/MQ로 넘어갈 때 백엔드 개발자가 살아남는 법

어느 개발자의 블로그 2026. 3. 25. 16:19
반응형

"다음 달 차세대 오픈 때 기존 EAI 다 걷어내고 RabbitMQ랑 API 게이트웨이로 간다는데, 우리 쪽 코드는 얼마나 수정해야 하죠?"

요즘 레거시 전환 프로젝트를 하다 보면 파트원들에게 가장 많이 듣는 질문입니다. 솔직히 말하면, 과거 EAI(Enterprise Application Integration) 시절에는 애플리케이션 개발자가 참 편했습니다. 인프라나 DBA 파트에서 출발지 DB와 도착지 DB 테이블만 매핑해두면, EAI 데몬이 백그라운드에서 알아서 데이터를 퍼가고 넣어줬으니까요. 우리는 그저 약속된 인터페이스 테이블에 INSERT 쿼리 한 줄 날리면 그만이었습니다.

하지만 MSA나 클라우드 네이티브 같은 모던 아키텍처로 넘어오면서 이 평화는 끝났습니다. 이제 데이터 연동의 책임은 인프라가 아니라 오롯이 애플리케이션으로 넘어왔습니다. 개발자가 직접 데이터를 추출하고, JSON으로 말아서, API를 쏘거나 MQ에 메시지를 던져야 합니다.

오늘은 제가 여러 번의 레거시 전환 프로젝트를 겪으며 얻어맞고 깨달았던, EAI에서 API/MQ 환경으로 넘어갈 때 애플리케이션 단에서 반드시 방어해야 할 실무 패턴들을 정리해 보려고 합니다.

1. 환상 버리기: 100% 아름다운 REST API 통신은 없다

시스템을 전환한다고 하면 다들 최신 트렌드에 맞춰 모든 통신을 REST API나 gRPC로 맞출 수 있을 거라 기대합니다. 하지만 엔터프라이즈의 현실은 녹록지 않습니다.

메인 코어 시스템은 철저한 보안과 마이크로서비스 원칙에 따라 API 방식을 강제하지만, 당장 내일모레 멈추면 큰일 나는 외부 가공장이나 창고의 낡은 MES 시스템들은 API 서버를 띄울 인프라도, 개발자도 없는 경우가 태반입니다.

결국 실무에서는 비대칭 하이브리드 연동을 하게 됩니다. 코어 시스템인 우리는 중간에 있는 API 게이트웨이(webMethods 등)를 API로 호출하지만, 게이트웨이는 낡은 레거시 시스템 DB에 JDBC로 직접 붙어서 데이터를 꽂아주거나 MFT(Managed File Transfer)로 파일을 떨궈주는 식입니다.

따라서 타겟 시스템의 상태를 우리가 굳이 알 필요 없이, 우리는 게이트웨이나 메시지 브로커(RabbitMQ)와 맺은 약속(Contract)에만 집중해서 메시지를 던지면 됩니다. 결합도가 끊어지는 대신, 전송에 대한 책임은 우리가 져야 합니다.

2. 가장 흔하고 치명적인 실수: 트랜잭션 물고 외부 API 쏘기

주니어 개발자분들이 연동 코드를 짤 때 가장 많이 하는 실수이자, 오픈 첫날 시스템이 뻗어버리는 가장 큰 원인입니다.

보통 주문 데이터를 DB에 저장하고 곧바로 외부 시스템에 연동해야 할 때, 아무 생각 없이 @Transactional이 붙은 비즈니스 로직 안에서 RestTemplate이나 WebClient를 호출해 버립니다.

만약 타겟 시스템이 일시적인 장애를 겪거나 네트워크가 느려져서 API 응답이 10초씩 걸린다면 어떻게 될까요? 우리 서버의 스레드는 그 10초 동안 DB 커넥션을 쥔 채로 대기하게 됩니다. 사용자가 조금만 몰려도 HikariCP 커넥션 풀이 순식간에 말라버리고, 연동과 전혀 상관없는 조회 화면들까지 전부 터져버리는 장애가 발생합니다.

해결책은 로컬 DB 트랜잭션과 외부 통신 트랜잭션을 철저하게 분리하는 것입니다. 주문 데이터가 로컬 DB에 완벽하게 커밋된 이후에만 API를 쏘도록, 스프링의 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)과 @Async를 조합해서 별도의 스레드에서 통신이 일어나도록 격리해야 합니다.

3. 분산 환경의 진리: 트랜잭셔널 아웃박스 (Transactional Outbox) 패턴

트랜잭션을 분리하고 비동기로 API를 쏘기로 했다고 끝이 아닙니다. 이벤트를 비동기로 처리하다가 우리 서버가 갑자기 재시작되거나, RabbitMQ 브로커가 찰나의 순간 죽어있다면 어떻게 될까요? 로컬 DB에는 주문이 들어갔는데 타 시스템으로는 데이터가 영영 넘어가지 않는 유실(Data Loss)이 발생합니다.

이걸 막기 위해 실무에서는 트랜잭셔널 아웃박스 패턴을 필수적으로 도입합니다.

로컬 DB에 주문 데이터를 INSERT 할 때, 같은 트랜잭션으로 묶어서 Outbox(송신 이력) 테이블에도 보낼 데이터를 JSON 형태로 만들어서 상태를 '대기(WAIT)'로 같이 INSERT 하는 겁니다.

그리고 비동기로 API를 쏘거나 MQ에 메시지를 던진 뒤, 정상 응답(HTTP 200 혹은 MQ의 Publisher Confirm ACK)을 받으면 Outbox 테이블의 상태를 '성공(SUCCESS)'으로 업데이트합니다.

이렇게 해두면 네트워크 단절로 전송에 실패하더라도, 5분마다 도는 배치 스케줄러가 Outbox 테이블에서 '대기'나 '실패' 상태인 건들만 싹 긁어와서 다시 전송(Retry)해주기 때문에 데이터 유실을 원천 차단할 수 있습니다.

4. 멱등성 (Idempotency): 두 번 받아도 결과는 같아야 한다

앞서 말한 아웃박스 재처리 로직 때문에 수신 측 시스템은 필연적으로 끔찍한 상황을 마주하게 됩니다. 바로 똑같은 데이터가 두 번 이상 날아오는 상황이죠.

송신 측에서 API를 쐈는데 타임아웃이 났습니다. 수신 측은 이미 받아서 DB에 잘 넣었는데 송신 측만 응답을 못 받은 겁니다. 송신 측 배치는 전송 실패인 줄 알고 5분 뒤에 똑같은 데이터를 또 보냅니다.

수신 측 로직이 이걸 그대로 또 INSERT 해버리면 데이터 정합성이 박살 납니다. 이런 At-least-once (적어도 한 번) 전달 환경에서는 수신 측이 반드시 멱등성을 보장하도록 방어 코드를 짜야 합니다.

가장 깔끔한 방법은 송신 측에서 API 헤더나 메시지 페이로드에 고유한 Message-ID(UUID 등)를 박아서 보내는 겁니다. 수신 측은 데이터를 처리하기 전에 이 Message-ID가 이미 처리된 이력이 있는지 확인하고, 이미 처리했다면 HTTP 200만 내려주고 스킵하거나, RDB의 MERGE 문법(혹은 UPSERT)을 사용해 최종 데이터로 덮어씌워서 정합성을 맞춰야 합니다.

5. 수백 개의 연동, 객체지향으로 공통화하기

EAI 시절의 수백 개 인터페이스 테이블이 이제 수백 개의 API 엔드포인트로 바뀝니다. 이걸 일일이 RestTemplate 팩토리를 만들고 try-catch로 감싸고 있으면 유지보수 지옥이 열립니다.

이럴 때 템플릿 메서드 패턴을 쓰면 코드가 아주 우아해집니다. 공통적인 관심사(로깅, 타임아웃, Message-ID 헤더 세팅, 재처리 로직)는 부모 추상 클래스에 몰아넣고, 개별 연동 규격(URL, HTTP 메서드, 요청/응답 DTO 타입)만 자식 클래스에서 정의하는 방식입니다.

대략적인 수도코드 느낌은 이렇습니다.

// 부모 클래스: 통신 뼈대와 방어 로직을 책임짐
public abstract class AbstractApiCaller<REQ, RES> {
    
    private final RestTemplate restTemplate;

    protected abstract String getApiUri();
    protected abstract HttpMethod getHttpMethod();
    protected abstract Class<RES> getResponseType();
    
    // Spring Retry나 Resilience4j로 일시적 네트워크 오류 시 3회 재시도
    @Retryable(value = RestClientException.class, maxAttempts = 3, backoff = @Backoff(delay = 2000))
    public RES execute(REQ requestData) {
        String uri = getApiUri();
        String messageId = UUID.randomUUID().toString(); // 멱등성 키 발급
        
        log.info("[API-REQ] URI: {}, MessageId: {}", uri, messageId);
        
        try {
            HttpEntity<REQ> entity = new HttpEntity<>(requestData, createHeaders(messageId));
            ResponseEntity<RES> response = restTemplate.exchange(
                    uri, getHttpMethod(), entity, getResponseType()
            );
            
            log.info("[API-RES] Status: {}", response.getStatusCode());
            return response.getBody();
            
        } catch (RestClientException e) {
            log.error("[API-ERROR] URI: {}, Msg: {}", uri, e.getMessage());
            throw new ExternalApiException("외부 연동 실패", e);
        }
    }

    private HttpHeaders createHeaders(String messageId) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("X-Message-Id", messageId);
        return headers;
    }
}

이제 새로운 시스템 연동이 추가되면, 개발자는 비즈니스 로직에만 집중해서 아래처럼 자식 클래스 하나만 뚝딱 만들면 됩니다.

@Component
public class OrderSyncApiCaller extends AbstractApiCaller<OrderDto, ApiResultDto> {

    @Override
    protected String getApiUri() { return "[http://api-gateway.internal/v1/orders/sync](http://api-gateway.internal/v1/orders/sync)"; }

    @Override
    protected HttpMethod getHttpMethod() { return HttpMethod.POST; }

    @Override
    protected Class<ApiResultDto> getResponseType() { return ApiResultDto.class; }
}

나중에 전사 API 타임아웃 정책이 바뀌거나 로깅 포맷을 변경해야 할 때, 자식 클래스 수백 개를 건드릴 필요 없이 부모 클래스 한 곳만 수정하면 되니 야근을 피할 수 있습니다.

마치며

새로운 아키텍처나 인프라 솔루션이 도입된다고 해서 분산 시스템의 근본적인 문제인 네트워크 불확실성이 사라지는 것은 아닙니다. EAI가 주던 달콤함은 사실 시스템 간의 강결합이라는 기술 부채를 담보로 한 것이었죠.

단순히 DB 폴링에서 API 호출로 바뀌었다고 생각하면 곤란합니다. 책임이 넘어온 만큼, 애플리케이션 코드는 그 어느 때보다 방어적이어야 합니다.

지금 당장 여러분이 짠 연동 코드를 열어보세요. 외부 API를 부르는 코드가 트랜잭션 안에 갇혀 있지는 않은지, 타임아웃이 났을 때 재처리할 방안은 있는지 확인해보시길 바랍니다. 지루하고 고된 작업이지만, 결국 이 방어 코드들이 오픈 날 여러분의 수면 시간을 지켜줄 겁니다.

반응형