Intro
지난번 <JSONObject를 활용한 Diff 로직 포스팅>에서는 대량의 데이터를 효율적으로 비교하고 처리하는 백엔드 가공에 대해 알아봤다. 그런데 이번에는 아키텍처 관점에서 더 큰 숙제를 마주하게 됐다. 바로 "독립적으로 운영되던 여러 서비스를 하나의 앱으로 통합하면서, 서비스 간 데이터를 어떻게 주고받을 것인가"에 대한 고민이었다.
기존에는 '기계 관리 서비스'와 '장치 관리 서비스'가 각자의 영역에서 따로 놀고 있었다. 하지만 이 둘을 아우르는 '중앙 관리 서비스'가 신규 생성되면서 상황이 달라졌다. 하나의 화면에서 기계와 장치 데이터를 동시에 조회하거나 수정해야 하는 요구사항이 쏟아졌다. 처음엔 그저 API를 호출하면 끝일 줄 알았는데, 코드를 뜯어보니 이미 Shared 모듈과 Kafka라는 두 가지 거대한 흐름이 잡혀 있었다. "이게 왜 이렇게 돌아가지?"라는 질문을 시작으로, MSA 환경에서 데이터가 흐르는 지도를 그려본 과정을 정리해 본다.
1. Shared 모듈을 통한 API Proxy 연동
통합 작업을 위해 프로젝트를 열었을 때 가장 먼저 의아했던 점은, 내가 직접 만들지 않은 DTO나 API 호출 메서드들이 이미 라이브러리 형태로 준비되어 있었다는 것이다.
- Shared 프로젝트의 존재: 우리 팀은 서비스 간 공통으로 사용하는 DTO와 통신용 Proxy 객체를 Shared라는 별도 프로젝트에서 관리하고 있었다.
- 유연한 의존성: Shared 모듈에 특정 서비스 호출 로직을 정의하고 배포하면, 이를 참조하는 모든 프로젝트에서 gradle reload 한 번으로 타 서비스의 기능을 내 것처럼 쓸 수 있었다.
실제 통신부 코드를 보니 Spring WebClient를 사용하고 있었는데, 특이하게도 마지막에 .block()을 걸어 동기 방식으로 처리하고 있었다.
// Shared 모듈 내 구현된 Proxy 예시
public QueryResponse fetchData(String id) {
return this.webClient.get()
.uri(targetUri)
.retrieve()
.bodyToMono(QueryResponse.class)
.block(); // 응답이 올 때까지 대기하여 정합성 확보
}
처음엔 비동기 라이브러리를 왜 동기로 쓰는지 궁금했지만, 로직을 보니 납득이 갔다. 기계 정보를 가져오지 못하면 장치 정보를 수정할 수 없는 것처럼, 데이터의 강한 정합성이 필요한 상황에서는 '전화'를 걸어 즉답을 듣는 이 방식이 가장 안전했기 때문이다. 프론트엔드 입장에서도 API 응답이 올 때까지 loading 상태를 유지하다가 확정된 데이터를 보여주면 되니 예외 처리가 명확해지는 장점이 있었다.
2. Kafka를 활용한 비동기 이벤트 구독
Shared 프록시를 사용하는 방식에 익숙해질 무렵, 또 다른 난관에 부딪혔다. 특정 페이지의 eventInfo를 개발하던 중이었는데, 내 코드 어디에도 외부 서비스를 호출하는 Proxy 로직이 없는데 데이터가 실시간으로 들어오고 있었다. "호출이 없는데 어떻게 데이터가 들어오지?"라는 의문을 품고 코드를 추적하다 보니, 그 배후에 Kafka(카프카)가 있었다.
외부 서비스에서 데이터가 변경되면 카프카로 이벤트를 발행(Pub)하고, 우리 서비스는 아래와 같은 리스너(Listener)를 통해 이를 상시 감시하며 데이터를 수신하고 있었다.
// 이벤트를 구독하는 Consumer 측 코드
@KafkaListener(topics = "${edwp.event.topic}")
public void onEventWithData(@Payload CloudEvent payload) {
// 1. 메시지가 도착하면 자동으로 실행
// 2. 받은 payload(데이터)를 공통 로직으로 전달
eventSubscribeCommonLogic.subscribe(payload, topicName, messages);
}
이 방식은 '전화'보다는 '라디오 방송'에 가까웠다. 발행자는 수신자가 데이터를 잘 받았는지 일일이 확인하지 않고 자기 할 일을 한다. 우리 서비스는 리스너를 통해 받은 데이터를 eventInfo 테이블에 일단 저장해둔다. 이후 관리자가 화면에서 '접수(Receipt)' 버튼을 누를 때 메인 데이터로 업데이트하는 단계를 거쳤다.
프론트엔드 관점에서 보면, 이 방식은 데이터 업데이트가 비동기적으로 일어나기 때문에 사용자가 목록을 새로고침하거나 별도의 알림을 받아야 변경 사항을 인지할 수 있다는 특징이 있었다.
3. 비즈니스 요구사항에 따른 통신 방식 전환
이번 프로젝트의 하이라이트는 기존 Kafka 방식으로 돌아가던 일부 기능을 API Proxy(동기) 방식으로 전환한 결정이었다. 처음엔 "최신 트렌드인 카프카가 무조건 좋은 게 아닌가?"라는 생각을 했지만, 그 배경에는 비즈니스 목적이 있음을 깨달았다.
- 통합 환경의 즉시성: 두 서비스가 하나의 앱으로 통합되면서, 유저는 버튼을 누르자마자 결과가 즉각 화면에 반영되길 원했다. 카프카는 아무리 빨라도 찰나의 지연(Latency)이 발생할 수 있는데, 통합 관리 서비스에서는 이 미세한 시차조차 UX를 해치는 요소가 될 수 있었다.
- 트랜잭션 관리: API 호출 성공 여부를 메인 로직의 성공/실패와 하나로 묶음으로써, 유저에게 "데이터가 반영 중이니 잠시만 기다려주세요" 같은 모호한 메시지 대신 명확한 성공/실패 피드백을 즉시 줄 수 있게 되었다.
결국 기술의 우열이 아니라, "통합된 환경에서 유저에게 얼마나 신뢰성 있는 결과를 즉시 보여줄 것인가"라는 비즈니스 요구사항이 우선순위를 바꾼 것이다.
4. 사내 Nexus 저장소와 라이브러리 배포 프로세스
Shared 모듈의 수정 사항이 각 서비스에 전달되는 과정은 사내 Nexus(원격 저장소)를 통해 관리되고 있었다.
- Snapshot 버전: 개발 중인 코드는 SNAPSHOT 접미사를 붙여 Nexus에 올리고, 각 서비스는 빌드 시 이 저장소에서 최신 코드를 실시간으로 땡겨온다.
- Release 버전: 운영 배포 시에는 고정된 버전을 사용하여 의도치 않은 코드 변경으로 인한 장애를 차단한다.
Outro
이번 통합 작업을 거치며 MSA 환경에서 데이터를 주고받는 방식은 정답이 정해진 것이 아님을 배웠다. 프록시를 통해 즉각적인 응답을 얻을지, 혹은 이벤트를 통해 비동기적으로 흐르게 할지는 결국 서비스가 처한 비즈니스 상황이 결정하는 것이었다.
주니어 개발자로서 단순히 API를 연동하는 법을 넘어, 아키텍처가 UX와 데이터 정합성이라는 요구사항을 어떻게 따라가는지 훑어본 값진 경험이었다. 다음에는 신규 투입된 멀티테넌트 플랫폼 프로젝트에서 새롭게 마주한 'Proxy와 Client의 차이'를 다루며, 플랫폼 관점에서의 통신 방식에 대해 정리해 봐야겠다. 끝!
'Backend' 카테고리의 다른 글
| 서로 다른 구조의 대량 데이터, JSONObject로 Diff 로직 & Schema Mapping 해결하기 (0) | 2026.01.14 |
|---|---|
| AWS S3 Pre-signed URL로 보안과 성능 다 잡기 (feat. 동영상 스트리밍 아키텍처) (1) | 2025.11.18 |
| MSA 환경에서 대용량 분할 저장 시 발생한 서버 종료 문제, 폴링 기반 헬스 체크 & 복구(Recovery) 구현 (0) | 2025.05.21 |