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의 차이'를 다루며, 플랫폼 관점에서의 통신 방식에 대해 정리해 봐야겠다. 끝!

Intro

지난번 [SpreadJS 최적화 포스팅]에서는 대량의 데이터를 화면에 그리는 프론트엔드 성능 문제로 골머리를 앓았었다. 그런데 이번에는 백엔드 로직에서 또 다른 벽을 만났다. 바로 "데이터의 변경 이력(History)을 관리하고, 서로 다른 페이지 간에 데이터를 이동시키는" 작업이었다.

개인 프로젝트나 간단한 서비스에서는 보통 데이터를 INSERT 하고 SELECT 하면 끝이다. 하지만 실무에서의 요구사항은 훨씬 구체적이고 까다로웠다.

  1. 변경 이력(Revision) 시각화: "버전별 데이터를 비교해서, 삭제된 건 빨간색, 추가된 건 파란색, 수치가 바뀐 건 초록색으로 보여주세요."
  2. 데이터 이관(Import) 시 스키마 매핑: "A 페이지(설계)에서 만든 A 데이터를 B 페이지(구매)로 가져오고 싶은데, 두 페이지의 컬럼명이 달라도 알아서 매핑해서 넣어주세요."

단순히 데이터를 넣고 빼는 수준을 넘어서, 데이터의 흐름과 상태를 추적해야 하는 문제였다. 처음 요구사항을 들었을 때는 막막했지만, 기존 시스템을 분석하면서 해답을 찾았다. 기존 코드는 정적인 DTO 대신 JSON 객체를 적극적으로 활용하고 있었는데, 처음엔 의아했지만 "왜 이렇게 짰을까?"를 역추적하며 그 유연함을 이해하게 되었다. 이 구조를 기반으로 복잡한 Diff 로직과 매핑 기능을 풀어낸 과정을 정리해 본다.


1. JsonNode vs JSONObject & JSONArray

구현 초기에는 Spring Boot의 표준 라이브러리인 Jackson의 JsonNode를 사용하려 했다. 프로젝트 내 다른 로직에서도 JsonNode를 사용하고 있었고, 최신 스프링 생태계에 더 적합하다고 생각했기 때문이다.

하지만 설계를 검토해주시던 수석님께서 뜻밖의 조언을 주셨다. "데이터 구조를 계속 변경해야 하는 이 로직에서는 JsonNode보다 JSONObject가 관리하기 훨씬 편할 거야. 기존에 작성된 JSONObject 활용 코드를 한번 분석해 봐."

처음엔 의아했다. '굳이 옛날 라이브러리(org.json)를?' 하는 생각이 들었지만, 두 라이브러리를 직접 비교해 보니 이유를 알 수 있었다.

  • JsonNode (Jackson): 기본적으로 불변(Immutable)에 가깝다. 데이터를 수정하거나 필드를 추가하려면 ObjectNode로 캐스팅해야 하고, 구조를 변경하는 코드가 장황해진다. (읽기 전용에 유리)
  • JSONObject & JSONArray (org.json): Map 그리고 List와 거의 동일하게 동작한다.
    • JSONObject: HashMap처럼 Key-Value를 자유롭게 put(), remove() 할 수 있다. (행 데이터 역할)
    • JSONArray: ArrayList처럼 인덱스로 접근하고, 순회(Iteration)가 편하다. (전체 그리드 데이터 역할)

특히 이번 작업처럼 엑셀 데이터 1만 건이 넘어오는 경우, 굳이 List<ItemDto> 같은 래퍼(Wrapper) 클래스를 만들 필요 없이 JSONArray로 받아서 바로 JSONObject로 꺼내 쓰는 방식이 훨씬 직관적이었다.

// 기존 방식: DTO 리스트 (클래스 정의 필요)
List<ItemDto> items = ...; 

// 변경 방식: JSONArray (클래스 없이 바로 사용)
JSONArray jsonArray = new JSONArray(jsonString); // 프론트에서 받은 배열 문자열
for (int i = 0; i < jsonArray.length(); i++) {
    JSONObject row = jsonArray.getJSONObject(i); // i번째 행을 바로 꺼냄
    // ... 로직 수행
}

이번 프로젝트의 핵심은 정해지지 않은 엑셀 컬럼을 받아 DB 컬럼으로 매핑(수정)하고, 없는 필드를 추가(Add)하는 것이었다. 결국 수석님의 말씀대로, 정적 타입의 안전성보다는 데이터 가공의 유연성(Flexibility)이 압도적으로 중요한 상황이었다. 나는 이 의도를 파악한 후, JSONArray와 JSONObject 조합을 채택하여 로직을 설계했다.


2. 성능 최적화: Diff 로직을 O(N)으로 만들기

데이터 비교(Diff) 기능의 핵심은 속도다. 처음에는 단순하게 "리스트 두 개니까 이중 for문 돌려서 찾으면 되겠지?"라고 생각했다. 하지만 테스트 데이터 1만 건을 넣자마자 서버 응답이 눈에 띄게 느려졌다. 이는 시간 복잡도가 O(N^2)이 되기 때문이다.

그래서 DB에 저장된 데이터(From)와 엑셀로 업로드된 데이터(To)를 모두 Unique Key 기반의 Map으로 변환하는 작업을 선행했다.

// 리스트를 Map으로 변환하여 검색 속도 확보 -> O(N)
public Map<String, JSONObject> getToItemMap(String projectNo, List<ItemDto> dtos) {
    return dtos.stream()
        .filter(item -> /* 필요시 조건 필터링 */)
        .collect(Collectors.toMap(
            ItemDto::getUniqueKey, // Key: 유니크한 ID (ItemNo 등)
            value -> {
                JSONObject json = new JSONObject();
                json.put("reqNo", value.getReqNo());
                /* ... 필요한 데이터 Put ... */
                return json;
            }
        ));
}

이렇게 데이터를 Map<String, JSONObject> 형태로 미리 가공해두니, 비교 로직에서 map.get(key)로 바로 접근할 수 있어 데이터 양이 늘어나도 O(N)에 가까운 속도를 유지할 수 있었다.


3. Revision 로직: 변화를 시각화하기

이제 두 개의 Map(fromMap, toMap)을 순회하며 데이터의 상태를 결정해야 했다. 현업에서 요청한 "빨강, 파랑, 초록"을 구현하기 위해, 백엔드에서 명확한 상태 값(CUD Type)을 내려주기로 했다.

public ItemCompareResult createCompareResult(String key, JSONObject fromObj, JSONObject toObj) {
    
    ItemCompareResult result = new ItemCompareResult();
    result.setCompareKey(key);

    // 1. Delete (빨강): DB엔 있었는데 엑셀에서 사라짐
    if (!fromObj.isEmpty() && toObj.isEmpty()) {
        result.setCudType(CudType.DELETED);
    } 
    // 2. Add (파랑): DB엔 없었는데 엑셀에 새로 생김
    else if (fromObj.isEmpty() && !toObj.isEmpty()) {
        result.setCudType(CudType.ADDED);
    } 
    // 3. Revised (초록): 둘 다 있는데 값이 다름
    else {
        result.setCudType(CudType.REVISED);
        // 필드 단위로 상세 비교 (값이 바뀐 것만 체크)
        result.setDetailMap(createDetailDiff(keys, fromObj, toObj));
    }
    
    return result;
}

단순히 행(Row)의 존재 여부만 보는 게 아니라, createDetailDiff 메서드 안에서 셀(Cell) 단위로 값을 비교하고 isChanged 플래그를 달아줬다. 덕분에 프론트엔드에서는 복잡한 로직 없이 이 플래그만 보고 바로 CSS 클래스를 적용할 수 있었다.


4. 난관: 페이지마다 컬럼명이 다르다 (Schema Mapping)

Diff 기능을 끝내고 나니 또 다른 요구사항이 들어왔다. "설계(Engineering) 페이지의 데이터를 구매(Purchasing) 페이지로 불러오고 싶은데, 데이터가 안 들어와요."

확인해보니 두 페이지가 다루는 데이터의 실체(프로젝트 번호, 자재 코드 등)는 같은데, DB 컬럼명(Key)이 제각각이었다.

  • 설계 페이지: project_no, material_code
  • 구매 페이지: pjt_id, item_cd

이걸 코드에 하드코딩(if page == 'purchase')으로 박아넣기 시작하면, 나중에 페이지가 늘어날 때마다 감당이 안 될 것 같았다. 그래서 스키마 매핑 정보를 별도의 JSON 설정이나 DB로 분리해서 관리하기로 했다.

// 스키마 매핑 정의 (Schema Definition)
{
  "engineering": { 
      "std_key_1": "project_no", 
      "std_key_2": "material_code" 
  },
  "purchasing": { 
      "std_key_1": "pjt_id", 
      "std_key_2": "item_cd" 
  }
}

그리고 데이터를 이관할 때 이 '지도'를 보고 변환해주는 범용 메서드를 만들었다.

public JSONObject convertDataSchema(JSONObject sourceData, String sourcePage, String targetPage) {
    JSONObject targetData = new JSONObject();
    
    // 각 페이지의 매핑 정보 로딩
    Map<String, String> sourceSchema = schemaMap.get(sourcePage);
    Map<String, String> targetSchema = schemaMap.get(targetPage);
    
    // 표준 키(std_key)를 매개체로 데이터 변환
    // 설계의 'project_no' 값을 꺼내서 -> 구매의 'pjt_id' 키로 넣음
    for (String stdKey : sourceSchema.keySet()) {
        String sourceCol = sourceSchema.get(stdKey);
        String targetCol = targetSchema.get(stdKey);
        
        if (sourceData.has(sourceCol)) {
            targetData.put(targetCol, sourceData.get(sourceCol));
        }
    }
    return targetData;
}

이렇게 추상화해두니 새로운 페이지가 추가되어도 매핑 정보만 등록하면 코드를 수정할 필요가 없었다. 유지보수 측면에서 정말 잘한 선택이었다고 생각한다.


Outro

이번 작업을 하면서 느낀 건, 백엔드 로직을 어느 정도 다룰 줄 알면 프론트엔드 코드가 훨씬 깔끔해진다는 점이다. 예전 같았으면 프론트에서 억지로 끼워 맞췄을 로직들을, 백엔드 단계에서 미리 정제해서 내려주니 화면에서는 그리기에만 집중할 수 있었다.

물론 아직 쿼리 최적화나 복잡한 트랜잭션 처리는 낯설고 어렵다. 하지만 이렇게 데이터의 흐름을 처음부터 끝까지 한번 훑어본 경험은, 앞으로 프론트엔드 개발을 할 때 API 구조를 제안하거나 백엔드 개발자와 소통하는 데 큰 자산이 될 것 같다. 다음에는 이 데이터를 받아 리액트(React) 상태로 관리할 때 고민했던 부분들을 정리해봐야겠다. 끝!

Intro

프로젝트 막바지를 달려가던 중 꽤 까다로운 추가 요구사항이 들어왔다. 기존에는 PDF로 관리하던 시스템 매뉴얼을 각 페이지별 동영상 가이드로 변경해달라는 것.

사용자 입장에서는 텍스트보다 동영상이 훨씬 직관적이겠지만, 개발자 입장에서는 고민해야 할 포인트가 한두 가지가 아니다. 단순히 영상을 public 버킷에 올리고 링크를 걸면 끝날 일 같지만, 우리 시스템은 보안이 생명인 폐쇄형 시스템이다.

  1. 동영상이 외부(인터넷)에 노출되면 안 된다. (Private S3)
  2. 동시 접속자가 몰릴 때 WAS(Web Application Server)가 뻗으면 안 된다.
  3. 클라이언트(React)에서 끊김 없이 재생되어야 한다.

이 문제를 해결하기 위해 S3 Pre-signed URL을 도입하며 겪었던 삽질과 아키텍처 설계를 기록해본다.


1. 아키텍처 설계: 스트리밍을 누가 담당할 것인가?

가장 먼저 고민한 것은 데이터의 흐름이었다. 크게 두 가지 방법이 있다.

1-1) Proxy 방식 (백엔드 경유)

클라이언트가 백엔드에 요청하면, 백엔드가 S3에서 스트림을 받아 다시 클라이언트에 전달하는 방식.

  • 장점: 보안성이 극강이다. 클라이언트는 S3의 존재조차 모른다.
  • 단점: 서버 부하가 엄청나다. 동영상 트래픽이 WAS를 거쳐 가므로, I/O 처리에 스레드가 잠식되어 정작 중요한 비즈니스 로직 처리가 지연될 수 있다.

1-2) Direct Access 방식 (Pre-signed URL) ✅

백엔드는 S3 접근 권한이 담긴 '임시 URL'만 발급해주고, 실제 영상 데이터는 클라이언트가 S3와 직접 통신하는 방식.

  • 장점: 서버 부하가 거의 없다(URL 텍스트 생성 비용뿐). 대용량 트래픽 처리에 유리하다.
  • 단점: 임시 URL이 탈취될 경우 유효시간 동안은 외부 접근이 가능하다. (하지만 유효시간을 짧게 두면 해결 가능)

결론: 우리 시스템은 대시보드 조회 등 데이터 처리가 많으므로, 동영상 트래픽까지 WAS가 감당하는 건 비효율적이라 판단했다. 2번 방식(Pre-signed URL)을 채택했다.


2. 구현 과정: Pre-signed URL 발급과 보안 처리

2-1) S3 경로 은닉화 (UUID 매핑)

Pre-signed URL을 쓰더라도 URL 자체에 S3의 실제 파일 경로(bucket/folder/file.mp4)가 노출되는 것은 찜찜하다. 이를 방지하기 위해 UUID 매핑 전략을 사용했다.

 
  1. 동영상 업로드 시 UUID를 생성하여 DB에 저장한다.
  2. 프론트엔드는 UUID만 서버로 보낸다.
  3. 서버는 내부적으로 UUID ↔ 실제 S3 Key를 매핑하여 URL을 생성한다.

이렇게 하면 클라이언트는 실제 S3의 폴더 구조를 전혀 알 수 없다.

 

2-2) MongoDB 스키마 설계

우리 시스템은 MongoDB를 사용 중이다. 매뉴얼 데이터는 독립적으로 존재하기보다, 각 메뉴 구조에 종속적이므로 Embedding(임베딩) 방식을 택했다. RDBMS였다면 정규화를 했겠지만, 조회 패턴이 "메뉴 클릭 -> 해당 매뉴얼 조회"로 단순하기 때문이다.

// MongoDB Document 예시
{
  "_id": ObjectId("..."),
  "category": "대시보드",
  "menuList": [
    {
      "menuName": "사용자 관리",
      "userManuals": [
        {
          "uuid": "550e8400-e29b...", // 프론트 노출용 ID
          "s3Key": "secure_folder/2024/manual_v1.mp4", // 내부 관리용 Key
          "description": "사용자 추가 방법"
        }
      ]
    }
  ]
}

2-3) Spring Boot 코드 (S3 URL 생성)

AWS SDK를 사용하여 URL을 생성한다. 이때 유효 시간(Expiration) 설정이 핵심이다. 너무 길면 보안 구멍이 되고, 너무 짧으면 영상 보다가 끊긴다. 영상 길이를 고려해 적절한 값(여기선 10분)을 설정했다.

import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;

public String getVideoUrl(String s3Key) {
    // 유효시간 설정 (10분)
    Date expiration = new Date();
    long expTimeMillis = expiration.getTime();
    expTimeMillis += 1000 * 60 * 10; 
    expiration.setTime(expTimeMillis);

    // Pre-signed URL 요청 생성
    GeneratePresignedUrlRequest generatePresignedUrlRequest = 
            new GeneratePresignedUrlRequest(bucketName, s3Key)
            .withMethod(HttpMethod.GET)
            .withExpiration(expiration);

    // URL 생성 및 반환
    return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString();
}

 


3. 트러블슈팅: "The specified key does not exist"

분명 콘솔에 파일이 있는데 NoSuchKey 에러가 발생했다. 원인은 사소하지만 치명적인 인코딩과 공백 문제였다.

  • 폴더 경로에 //가 들어가거나, 파일명 앞뒤 공백이 있을 경우 S3는 엄격하게 다른 파일로 인식한다.
  • 특히 한글 파일명이나 특수문자가 포함된 경우 URL 인코딩 과정에서 불일치가 발생하기 쉽다.

해결: S3 Key를 DB에 저장할 때 반드시 정규식을 통해 경로를 정제(replaceAll("/+", "/"))하고 트림(trim) 처리하는 방어 코드를 추가했다.


마치며

백엔드 로직은 이제 견고해졌다. 서버는 가볍게 URL 티켓만 발권해주고, 무거운 동영상 데이터는 AWS S3가 전담한다. 보안도 UUID와 만료 시간으로 챙겼다.

하지만 진짜 난관은 프론트엔드에 있었다. 영상을 보면서 작업을 하고 싶으니 새 창(PIP)으로 띄워달라는 요구사항과 브라우저의 보안 정책이 충돌하기 시작했으니... 다음 편에서는 React와 Window Portal을 활용한 프론트엔드 구현기를 다뤄보겠다.

Intro

대용량 데이터를 다루며 생긴 문제로 페이징, SSE 기법 등에 대해 이전 글들에서 소개한 적이 있다.
오늘 글에서는 그 외에 프로젝트 진행 중 겪었던, 꽤 치명적이었던 문제를 다뤄보고자 한다.
프로젝트를 성공적으로 마무리하기 위해 꼭 해결해야 했던 문제가 있었는데,
데이터를 분할해서 저장하는 과정 중 서버가 종료되면 저장 도중 데이터가 유실되는 현상이었다.
내가 직접 처리한 부분은 아니지만, 팀 문서와 설명을 정리해보니 언젠가 내가 마주칠 수 있을 문제여서 기록해두려 한다.

 

1. 대량 데이터 분할 저장 방식

우리 프로젝트에서는 아래와 같은 API 호출 구조를 통해 대량 데이터를 분할 저장했다.

MassDataFlow.requestExecuteMassData("headerId", ........)
try {
    // SSE 체크 & 시작 (작업 상태 생성 및 등록)
    massDataSseTask.start(key);
    
    // 비동기 @Async로 실제 분할 데이터 저장 작업 수행
    massDataProcessAction.processMassData("세부 작업명", ............);
    
    return "작업요청 정상 종료 메시지 코드 (62)";
} catch (Exception e) {
    return "다른 작업 실행 중 메시지 코드 (63)";
}
  • start(key) 호출로 동시 작업 방지를 위해 작업 가능 여부를 판단
  • 정상 작업 허가 시 62 반환, 작업 수행 시작
  • 작업 도중 분할 저장이 진행될 때마다 SSE 이벤트를 프론트로 보내 프로그래스바를 표시

하지만 다음 두 가지 문제가 발생했다.

  1. 서버 종료 문제
    • 개발서버에 코드 푸시 시, CI/CD(자동 배포)로 기존 서버가 종료됨
    • 이때 데이터 전송 중 서버가 종료되면 작업이 중단되고 데이터 유실 발생
    • MSA 기반이라 전통적인 DB 트랜잭션 처리도 어렵다
  2. 사용자 페이지 이탈 문제
    • 작업 도중 사용자가 페이지를 떠나면 저장 작업이 중단됨

이 문제들은 일부만 변경사항이 반영되는 치명적인 문제로,
마감이 한 달 남은 시점에 이 문제를 발견해서, 우리팀은 이 기간동안 거의 회사에 살았다...

 

2. 폴링 기반 헬스 체크

이 문제 해결의 핵심은 서버 상태 모니터링이었다.

백엔드에서는 사실 매우 단순했다.
GET /health 요청에 대해 정상 상태면 200 OK만 응답하면 되었다.

하지만 프론트는 다음과 같은 이유로 조금 복잡했다.

  • SSE 연결이 끊겨도 클라이언트가 인지하지 못하는 경우가 많음
  • 그래서 SSE 연결 상태와 별개로 주기적인 폴링으로 서버 상태를 체크해야 함
  • 폴링 실패 시 즉시 오류 메시지를 사용자에게 띄워 서버 종료 사실을 알림
  • 이후 서버가 정상화되면 자동으로 저장 작업 재개 시도

React 폴링 헬스 체크 구현 예시

const HealthChecker = () => {
  const intervalRef = useRef<number>();

  useEffect(() => {
    const checkHealth = async () => {
      try {
        await axios.get('/api/health');
        healthStore.setHealthy(true);
      } catch {
        healthStore.setHealthy(false);
      }
    };

    checkHealth(); // 초기 체크

    intervalRef.current = window.setInterval(checkHealth, 5000);

    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);

  return null;
};

| 폴링 간격, 타임아웃, 백오프 전략 등은 서버 부하와 감지 지연 사이 균형을 맞추는 데 중요한 요소다.

폴링 기반 헬스 체크 개선사항 및 최적화 전략

폴링 기반 헬스 체크는 단순하고 효과적이지만, 적절한 설계와 튜닝 없이는 오히려 시스템에 부담을 주거나 감지 지연을 유발할 수 있다. 따라서 아래의 주요 개선점과 최적화 방법을 반드시 고려해야 한다.

1. 폴링 주기 최적화

  • 권장 주기: 일반적으로 5~10초 간격이 적절하다.
  • 너무 짧으면 서버에 불필요한 요청이 폭증해 과부하 유발
  • 너무 길면 장애 발생 시 감지 및 대응이 지연되어 사용자 불편 초래

2. 백오프(Backoff) 전략 적용

  • 연속 실패 시 폴링 주기를 점진적으로 늘려가며 재시도
  • 실패 횟수 누적 시 경고를 띄우거나 폴링 중단 후 수동 재시작 유도 가능
  • 네트워크 장애나 서버 다운 시 무한 재시도를 방지하여 자원 낭비 최소화
let failCount = 0;
let interval = 5000;

async function pollHealth() {
  try {
    await axios.get('/api/health');
    failCount = 0;
    interval = 5000; // 정상 시 기본 주기로 복귀
  } catch {
    failCount++;
    interval = Math.min(interval * 2, 60000); // 최대 1분까지 증가
  }
  setTimeout(pollHealth, interval);
}

pollHealth();

3. 타임아웃 설정과 에러 처리 강화

  • Axios 등 HTTP 클라이언트에서 타임아웃을 반드시 설정하여, 응답 지연 시 빠르게 실패 처리
  • 타임아웃이 없으면 요청이 무한 대기 상태가 되어 폴링 주기 간격 유지 실패
  • 에러 발생 시 예외처리 루틴을 통해 사용자 알림, 로그 기록 등 추가 작업 수행
axios.defaults.timeout = 3000; // 3초 이상 응답 없으면 실패 처리

4. 폴링 중지 및 재개 조건 관리

  • 사용자가 페이지를 벗어나거나 탭이 비활성화되면 폴링을 자동으로 중지하여 불필요한 트래픽 감소
  • 다시 페이지로 복귀 시 폴링 재개
  • React에서는 visibilitychange 이벤트나 useEffect를 활용 가능
useEffect(() => {
  function handleVisibilityChange() {
    if (document.hidden) {
      clearInterval(pollingInterval);
    } else {
      startPolling();
    }
  }

  document.addEventListener('visibilitychange', handleVisibilityChange);

  return () => {
    document.removeEventListener('visibilitychange', handleVisibilityChange);
  };
}, []);

5. SSE 이벤트와 폴링 헬스 체크의 연동

  • SSE 연결이 정상 작동할 때는 폴링 주기를 느리게 하거나 폴링을 중지
  • SSE 오류(onerror) 발생 시 폴링을 빠르게 활성화하여 빠른 장애 감지 및 복구 유도
  • 이렇게 두 방식을 병행하면 성능과 안정성 모두 잡을 수 있음

6. 서버 부하 및 네트워크 최적화

  • 헬스 체크 API는 반드시 가볍고 빠른 응답이 가능하도록 최소한의 로직만 수행
  • 가능하면 별도의 헬스 체크 전용 엔드포인트 분리 권장
  • 클라이언트는 실패 시 과도한 재요청을 막기 위해 적절한 재시도 횟수 제한 설정

 

3. Recovery 작업

  • 대용량 데이터 핵심 식별자(projectNo, itemNo 등)를 담는 헤더 데이터에 recovery 필드를 추가
  • 저장 작업 전 헤더 데이터를 조회하여 recovery가 false일 때만 작업 수행
  • true일 경우 작업이 비정상 종료된 것으로 간주하고 복구 작업 수행
  • 저장 작업 시작 시 recovery를 true로 변경, 정상 완료 시 false로 업데이트

이를 통해 사용자가 작업 도중 페이지를 이탈해도,
복귀 시 복구 메시지를 띄우고 작업 전 상태로 되돌릴 수 있게 했다.

 

마치며

MSA 환경에서 대용량 데이터를 다루며 작업 중단 및 데이터 유실 문제를 해결하는 것은 쉽지 않은 도전이었다..
폴링 기반 헬스 체크와 복구 시스템을 도입하며 한 단계 성장한 듯 (우리팀이)
언젠가 이 경험이 도움이 되길 바라며, 오늘 포스팅 끝!

+ Recent posts