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

지난번 프로젝트에서 대용량 데이터를 다루며 브라우저 렌더링 성능의 한계를 체감했다. DOM 노드가 몇천 개만 넘어가도 스크롤이 버벅이고 메모리 점유율이 치솟는 현상을 겪었다. 데이터가 많아질수록 <table> 태그나 단순 리스트 렌더링으로는 사용자 경험을 지키기 어렵다는 것을 뼈저리게 느꼈다.

그런데 문득, 우리 팀의 메인 라이브러리인 ag-grid는 수만 건의 데이터를 페이징 없이 한 화면에 보여주는데도 스크롤이 매끄럽다는 점이 눈에 들어왔다. 데이터가 10만 개여도 60FPS를 유지한다. 단순히 "좋은 라이브러리라서 빠르다"라고 넘기기엔 원리가 궁금했다.

그래서 오늘은 대용량 그리드 처리의 핵심 기술인 Row Virtualization(행 가상화)이 내부적으로 어떻게 동작하는지 분석해 본 내용을 정리한다.


1. 무식하게 다 그리면 벌어지는 일 (DOM Bottleneck)

가상화가 왜 필요한지 이해하려면 브라우저가 DOM을 그리는 비용을 알아야 한다. 만약 10만 줄의 데이터를 그대로 <table>로 렌더링한다고 가정해보자.

  1. DOM 생성: <tr> 10만 개 + (<td> × 컬럼 수) 생성.
  2. Layout (Reflow): 브라우저가 10만 개 요소의 위치와 크기를 계산.
  3. Paint: 픽셀을 그림.

이 과정에서 가장 비싼 비용은 DOM 조작 그 자체다. 실제로 div 1만 개 정도만 렌더링해봐도 크롬 성능 탭에서 프레임 드랍이 발생하는 것을 확인할 수 있다. 백엔드에서 데이터를 아무리 빨리 줘도, 프론트엔드에서 그리는 과정이 병목이 되는 것이다.


2. Row Virtualization: 필요한 것만 그린다

Row Virtualization의 핵심 아이디어는 사용자가 지금 보고 있는 화면(Viewport)에 해당하는 행만 DOM으로 유지하자는 것이다.

데이터가 10만 개라도, 내 모니터 높이(예: 1000px)에 들어오는 데이터가 20개라면 딱 20개(+버퍼)의 DOM만 그린다. 스크롤을 내리면 화면 밖으로 나간 DOM은 제거하거나 재활용하고, 새로 들어오는 데이터를 그린다. 이를 구현하기 위해서는 크게 두 가지 기술적 트릭이 필요하다는 것을 알게 되었다.

1) 가짜 스크롤 바 (Phantom Height)

실제 DOM이 20개밖에 없다면 스크롤바는 꽉 찬 상태로 움직이지 않을 것이다. 스크롤바는 컨텐츠의 전체 높이에 비례해야 하기 때문이다. 그래서 보이지 않는 거대한 div를 만들어 브라우저가 스크롤바를 그리도록 속여야 한다.

// 전체 데이터 높이 계산
const totalHeight = totalCount * rowHeight; 
// 예: 100,000건 * 30px = 3,000,000px

이렇게 높이를 강제로 잡아주면 브라우저는 컨텐츠가 실제로 존재하는 것처럼 인식하고 스크롤바를 정상적으로 렌더링한다.

2) 위치 계산 (Offset Calculation)

사용자가 스크롤을 내릴 때마다(onScroll), 현재 스크롤 위치(scrollTop)를 기반으로 지금 보여줘야 할 데이터의 인덱스를 계산한다. 로직을 간단히 추상화하면 다음과 같다.

const onScroll = (e) => {
    const scrollTop = e.target.scrollTop; // 현재 스크롤 위치
    
    // 1. 시작 인덱스 계산 (현재 위치 / 행 높이)
    // 예: 스크롤이 300px 지점이고 행 높이가 30px이면 10번째 데이터부터 보여야 함
    const startIndex = Math.floor(scrollTop / rowHeight);
    
    // 2. 끝 인덱스 계산 (시작점 + 화면에 보이는 개수)
    const endIndex = startIndex + visibleCount;
    
    // 3. 해당 범위의 데이터만 잘라서 렌더링
    renderRows(data.slice(startIndex, endIndex));
}

3. ag-grid 구현 뜯어보기

이론적인 내용을 바탕으로, 실제 운영 중인 서비스의 ag-grid를 개발자 도구로 확인해 봤다. 스크롤을 빠르게 이동시켜 보니 흥미로운 점들이 발견되었다.

  • 일정한 DOM 개수: 데이터가 10만 건이어도 div.ag-body-viewport 안의 div.ag-row 개수는 약 30~40개로 항상 일정하게 유지되었다.
  • Absolute Positioning: 각 Row는 transform: translateY(Y값) 또는 top 속성을 통해 절대적인 위치에 배치되고 있었다.

즉, 스크롤을 내린다고 DOM을 무작정 삭제하고 다시 만드는 unmount -> mount 방식이 아니라, 기존 DOM의 위치 값(Y축)과 내부 텍스트만 바꿔치기(Recycling)하는 최적화가 들어가 있는 것으로 보였다. 이는 리액트의 Reconciliation(재조정) 비용을 최소화하기 위한 선택으로 추측된다.


4. 튜닝 포인트: rowBuffer

스크롤을 아주 빠르게 내리면 순간적으로 하얀 빈 화면이 보일 때가 있다. 자바스크립트가 스크롤 이벤트를 감지하고 계산해서 렌더링하는 속도가 스크롤 속도를 따라가지 못하기 때문이다.

ag-grid는 이를 방지하기 위해 Buffer 개념을 사용한다. 화면에 20개가 보인다면, 위아래로 10개씩 미리 더 그려놓는 것이다.

ag-grid는 스크롤을 빠르게 내릴 때 빈 화면이 보이는 것을 방지하기 위해 버퍼(Buffer)를 둔다. 화면에 20개가 보인다면, 위로 10개, 아래로 10개 정도를 미리 그려두는 것이다.

<AgGridReact
    rowData={largeData} 
    rowBuffer={10}  // Default: 10
/>

이 옵션을 조정할 때는 Trade-off를 고려해야 한다.

  • Buffer가 크면: 스크롤 시 빈 화면은 줄어들지만, 한 번에 그려야 할 DOM이 많아져 초기 로딩이나 리렌더링이 무거워질 수 있다.
  • Buffer가 작으면: DOM은 가볍지만, 급격한 스크롤 시 컨텐츠가 늦게 뜰 수 있다.

엔터프라이즈 환경에서는 PC 성능이 다양하기 때문에, 무조건 버퍼를 늘리기보다는 기본값을 유지하되 렌더러 컴포넌트를 가볍게 가져가는 것이 더 중요하다는 결론을 내렸다.


5. Column Virtualization (가로 스크롤)

분석 과정에서 ag-grid가 수평 스크롤(Column Virtualization)도 지원한다는 점을 확인했다. 관리자 페이지 특성상 컬럼이 50개, 100개가 넘어가는 경우가 많은데, 행(Row)만 줄인다고 성능이 해결되는 게 아니다. 가로 스크롤 시에도 화면 밖의 컬럼은 렌더링하지 않는 2D Virtualization이 적용되어야 비로소 대용량 데이터를 버티는 그리드가 완성된다.


Outro

라이브러리를 단순히 가져다 쓰는 것과, 내부 원리를 이해하고 쓰는 것은 확실히 다르다는 걸 느꼈다.
결국 핵심은 '보이는 것만 그린다'는 것이었다. 이번 분석 덕분에 앞으로 대용량 데이터를 다룰 때 어떤 지점을 신경 써야 할지 감을 더 잡을 수 있었다. 다음에는 이 원리를 응용해 간단한 가상 스크롤을 직접 구현해 보고 싶다.

Intro

최근 엔터프라이즈급 웹 서비스를 개발하면서 가장 많이 들었던 요구사항은 이것이었다. "기존에 쓰던 엑셀 파일, 워드 문서 양식을 웹에서 그대로 쓰고 싶어요. 기능은 똑같이, 속도는 더 빠르게."

B2B 어드민이나 데이터 플랫폼을 개발하다 보면 단순히 데이터를 조회하는 것을 넘어, 웹상에서 엑셀 시트를 직접 편집(SpreadJS)하거나 문서를 공동 작성(OnlyOffice)해야 하는 고난도 요구사항과 마주하게 된다.

DOM 엘리먼트가 수천 개씩 쏟아지는 무거운 라이브러리들을 React 환경에 녹여내면서 겪었던 렌더링 성능 이슈와 메모리 누수(Memory Leak)를 해결한 과정을 기록해본다.


1. SpreadJS: 렌더링 제어권 가져오기 (suspendPaint)

엑셀의 기능을 웹으로 옮겨놓은 SpreadJS는 기능이 강력한 만큼 무겁다. 특히 우측 설정 패널(Binding Path Panel)을 열고 닫을 때마다 캔버스 전체가 다시 그려지면서 화면이 버벅이는 현상이 발생했다.

⛔️ 문제 상황

데이터 바인딩을 변경하거나 시트 설정을 바꿀 때마다 SpreadJS는 즉시 화면을 갱신(Repaint)하려고 한다. 수천 개의 셀이 있는 상태에서 패널을 조작할 때마다 리렌더링이 일어나니 UX가 뚝뚝 끊길 수밖에 없었다.

✅ 해결: 그리기, 잠시만 멈춰!

브라우저의 리플로우(Reflow)를 줄이는 원리와 비슷하게, SpreadJS도 "작업 끝날 때까지 그리지 마"라고 명령할 수 있다.

  • workbook.suspendPaint(): 렌더링 일시 중지. (변경 사항을 메모리에만 반영)
  • workbook.resumePaint(): 렌더링 재개. (변경된 내용을 한 번에 그리기)

이 패턴을 적용하여 데이터 세팅 로직을 감싸주니, 패널 조작 시 발생하던 딜레이가 사라졌다. 대량의 DOM 조작이나 Canvas 드로잉 작업에서는 '배치(Batch) 처리'가 성능의 핵심임을 다시 한번 깨달았다.

/* SpreadJS 성능 최적화: 커맨드 실행 제어 */

// 바인딩 패널 데이터 주입 및 실행
const updateBindingLayout = (bindingPath, isPanelClosed) => {
    // 1. 데이터 세팅
    designer.setData(TREE_NODE_FROM_JSON, JSON.stringify(bindingPath));
    
    // 2. 커맨드 실행 함수 가져오기 (방어 코드)
    const executeCommand = getExecuteFnc();
    
    // 3. 패널이 닫혀있지 않을 때만 실행하여 불필요한 연산 방지
    if (!isPanelClosed) {
        executeCommand(designer);
    }
}

const getExecuteFnc = () => {
    // 템플릿 디자인 모드 커맨드 객체 조회
    const command = GC_Designer.Spread.Sheets.Designer.getCommand(TEMPLATE_DESIGN_MODE);
    
    // 커맨드가 없거나 실행 함수가 없으면 빈 함수 반환 (Safe Navigation)
    if (!command || !command.execute) return () => {};
    
    return command.execute;
}

// Tip: 대량 데이터 로드 시 패턴
const loadHeavyData = (workbook) => {
    workbook.suspendPaint(); // 🎨 렌더링 중지 (핵심!)
    try {
        workbook.clearSheets(); 
        // ... 수만 건의 데이터 바인딩 로직 ...
    } finally {
        workbook.resumePaint(); // 🎨 한 번에 그리기
    }
}

2. Data Handling: 복잡한 객체 다루기

그리드(ag-grid)나 시트(SpreadJS)에 데이터를 바인딩하려면, 백엔드에서 받은 JSON 데이터를 라이브러리가 좋아하는 형태로 가공해야 한다. 엔터프라이즈 데이터는 깊이(depth)가 깊거나 구조가 복잡한 경우가 많다.

단순 for문 대신, ES6+ 메서드를 적극 활용해 데이터 무결성을 체크하고 구조를 변환했다.

  • Object.entries & reduce: 객체를 순회하며 특정 조건에 맞는 데이터만 필터링하거나 새로운 포맷으로 집계할 때 사용.
  • some: 대량의 데이터 중 'Error' 상태나 '필수 값 누락'이 하나라도 있는지 빠르게 검증할 때 사용 (조건 만족 시 즉시 종료되므로 성능상 이점).
// 예: 복잡한 응답 데이터에서 특정 플래그(flag)가 true인 항목만 추출하여 그리드 Row로 변환
const gridRows = Object.entries(rawData).reduce((acc, [key, value]) => {
    if (value.isValid) {
        acc.push({ id: key, ...value.content });
    }
    return acc;
}, []);

// 예: 데이터 유효성 검사 (하나라도 빈 값이 있으면 저장 불가)
const isInvalid = Object.values(formData).some(val => val === null || val === '');

3. OnlyOffice: 외부 라이브러리와 React 생명주기

웹에서 워드 문서를 편집하는 OnlyOffice를 React 컴포넌트로 래핑(Wrapping)해서 사용하는 과정에서 문제가 생겼다. 페이지를 이동했다가 돌아오면 에디터가 중복 생성되거나, 다운로드 이벤트가 중첩되어 실행되는 현상이었다.

React의 Virtual DOM 바깥에서 동작하는 외부 라이브러리(3rd-party)는 React가 알아서 치워주지 않는다. 개발자가 직접 청소(Cleanup)해야 한다.

✅ 해결: useEffect의 Cleanup 함수 활용

에디터 인스턴스를 ref에 담아 관리하고, 컴포넌트가 언마운트(Unmount)될 때 반드시 destroyEditor()를 호출하여 메모리를 해제했다.

import { useRef, useEffect } from 'react';

const DocumentEditorContainer = ({ id, url, config, userToken }) => {
    // 에디터 인스턴스를 저장할 ref
    const docEditorRef = useRef(null);

    // 에디터 로딩 완료 핸들러
    const onAppReady = () => {
        // 전역 객체(window.DocEditor)에서 현재 인스턴스를 찾아 연결
        // Optional Chaining(?.)으로 안전하게 접근
        docEditorRef.current = window.DocEditor?.instances?.[id];
    };

    const onDownloadAs = () => {
        // ref를 통해 에디터 API 직접 호출
        docEditorRef.current?.downloadAs('docx'); 
    };

    useEffect(() => {
        // Mount 시점: OnlyOffice 로드 (Component 내부 로직에 의해 자동 실행됨)
        
        // Unmount 시점: Cleanup (중요!)
        return () => {
            if (docEditorRef.current) {
                docEditorRef.current.destroyEditor(); // 인스턴스 파괴
                docEditorRef.current = null; // 참조 해제
            }
        };
    }, []);

    return (
        <DocumentEditor
            id={id}
            documentServerUrl={url}
            config={{
                ...config,
                events: {
                    onAppReady,
                    onDownloadAs,
                }
            }}
        />
    );
};

Outro

이번 개발 경험을 통해 "라이브러리를 가져다 쓰는 것"과 "라이브러리를 제어하는 것"의 차이를 확실히 느꼈다.

  1. 성능 최적화: 무거운 라이브러리일수록 렌더링 사이클(suspendPaint)을 직접 제어해야 사용자 경험(UX)을 지킬 수 있다.
  2. 데이터 핸들링: 복잡한 비즈니스 로직을 UI에 녹여내기 위해서는 자유자재로 객체와 배열을 다루는 JS 기본기가 필수다.
  3. 메모리 관리: React 생명주기에 맞춰 외부 인스턴스를 생성하고 파괴하는 습관이 안정적인 애플리케이션을 만든다.

다음 포스팅에서는 ag-grid의 Row Virtualization 원리를 분석해보고, 대용량 데이터 스크롤 성능을 극대화하는 방법에 대해 정리해봐야겠다. 끝!

1. 개요

이번 프로젝트의 핵심 과제는 3개로 분산되어 있던 문서 관리 시스템(DMS)을 하나로 통합하는 것이었다. 물리적으로 나눠져 있던 시스템이 합쳐지면서 데이터의 볼륨은 자연스럽게 커졌고, 문서 하나의 복잡도 또한 급격히 증가했다.

특히 웹 엑셀(SpreadJS)을 통합하는 과정에서 기존에 사용하던 ssjson 포맷은 한계에 달했다. 시트 개수가 늘어나고 데이터 바인딩 패스(Binding Path)가 복잡해짐에 따라, 단순 텍스트 기반인 ssjson은 파싱과 렌더링, 전송 모든 구간에서 병목을 유발했다.

이에 포맷을 경량화된 sjs로 전환하게 되었고, 이와 더불어 클라이언트와 서버 간의 전송 효율을 극대화하기 위해 Gzip 압축병렬 전송 아키텍처를 수립했다. 오늘 포스팅에서는 이 과정에 대해 자세히 다뤄보겠다.


2. 문제 정의 및 해결 전략

2-1. 포맷의 한계: ssjson vs sjs

기존 시스템은 ssjson 포맷을 사용했다. 이는 JSON 텍스트 기반이라 가독성은 좋지만, 통합 시스템의 방대한 데이터(스타일, 수식, 수많은 바인딩 정보)를 담기에는 용량이 비대했다. 100MB가 넘는 JSON 문자열을 메모리에 올리고 전송하는 것은 *브라우저 크래시를 유발할 위험이 컸다.

우리는 이를 sjs (SpreadJS Zip 포맷) 형태로 변경하여 1차적인 용량 최적화를 수행했다. 하지만 sjs 역시 바이너리 데이터이므로, 네트워크 구간에서의 전송 효율을 높이기 위한 추가적인 전략이 필요했다.

💡 *브라우저 크래시란?
쉽게 말해: 크롬이나 엣지 같은 브라우저가 "아, 못 해먹겠다!" 하고 뻗어버리는 현상
왜 발생하나요? 브라우저가 한 탭에서 사용할 수 있는 메모리(RAM)는 한정되어 있다. 그런데 100MB가 넘는 거대한 텍스트(ssjson 같은 문자열)를 변수에 담거나 복사하려고 하면, 메모리가 꽉 차서 화면이 멈추거나(Freezing) "앗, 이런!" 하면서 탭이 강제 종료됨.

2-2. 전송 방식 선정: Chunking vs Compression

대용량 파일 전송 시 통상적으로 고려하는 Multipart Upload (Chunking) 방식은 구현 복잡도가 높고, HTTP 요청 오버헤드가 발생한다.

우리는 데이터의 특성이 압축 효율이 높은 문서 데이터라는 점에 착안하여, 복잡한 쪼개기 방식 대신 클라이언트 사이드 압축(Compression)단일/병렬 전송하는 방식을 채택했다.


3. Frontend 구현

최적화의 핵심은 서버 부하를 클라이언트(브라우저)로 분산시키는 것이다.

💡 용어 정리
Uint8Array (알맹이)
컴퓨터가 이해하는 가장 기초적인 언어인 0부터 255까지의 숫자(1바이트)로 이루어진 배열 → 일반 자바스크립트 배열보다 메모리를 훨씬 적게 차지해서, 이미지나 압축 파일 같은 바이너리 데이터의 실체를 다룰 때 사용
Blob (포장지)
Binary Large Object의 약자 → Uint8Array 같은 알맹이 데이터를 모아서 "이건 PDF야", "이건 엑셀이야"라고 이름표(MIME Type)를 붙여 포장한 불변의 객체. 브라우저가 파일을 다운로드하거나 서버로 전송할 때 사용하는 '택배 상자'라고 이해하기!

3-1. Gzip 압축 및 Blob 변환 (pako)

pako 라이브러리를 사용하여 데이터를 Gzip으로 압축한다. ssjson을 sjs로 변환하는 과정이나, 혹은 전송 직전 데이터를 바이너리(Uint8Array)로 직렬화할 때 메모리 스파이크를 방지하기 위해 TypedArray를 적극 활용했다.

// CompressUtil.ts
import { gzip } from "pako";

const compress64 = (data: string | object): string => {
  // 1. 데이터 직렬화
  const jsonString = typeof data === 'string' ? data : JSON.stringify(data);
  
  // 2. Gzip 압축 (Uint8Array 반환)
  // 텍스트 기반 데이터(ssjson)의 경우 용량이 획기적으로 감소함
  const zipped: Uint8Array = gzip(jsonString);

  // 3. 전송을 위한 Base64 변환
  let result = "";
  for (let i = 0; i < zipped.byteLength; i++) {
     result += String.fromCharCode(zipped[i]);
  }

  return btoa(result);
};

3-2. Promise.all을 이용한 병렬 전송

통합 시스템 특성상 사용자는 다수의 문서를 한 번에 업로드하는 경우가 빈번하다. 순차 처리(Sequential) 대신 Promise.all을 사용하여 네트워크 대역폭을 최대한 활용하도록 구현했다.

// FileUploadModalView.tsx
const onClickAttach = async (): Promise<void> => {
  if (filesFromUpload.length > 0) {
    setIsAttaching(true);

    // 1. 각 파일별 업로드 요청을 Promise 배열로 생성
    const promises = filesFromUpload.map(async (file: File) => {
       return await uploadAttachment({ file });
    });

    // 2. 병렬 실행 (Parallel Execution)
    const newAttachments = await Promise.all(promises);
    
    onAddAttachment(newAttachments);
    setIsAttaching(false);
  }
};

4. Backend 구현 (Spring Boot)

프론트엔드에서 압축된 형태로 전송하므로, 백엔드는 복잡한 조각 모음(Merge) 로직 없이 표준적인 파일 처리 방식만으로 대응 가능하다.

4-1. Controller 및 파일 저장

Spring의 MultipartFile 인터페이스를 통해 파일을 수신하고, 물리 스토리지에 저장한다. 데이터가 ssjson이든 sjs든, 혹은 Gzip으로 압축되었든 백엔드 입장에서는 동일한 바이너리 파일일 뿐이다.

// Controller Layer
@PostMapping(value = "/upload-command", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public CommandResponse uploadFiles(
    @RequestPart("command") Command command, 
    @RequestPart("file") List<MultipartFile> multipartFiles
) {
    // 표준 Multipart 방식으로 수신 후 서비스 로직 이관
    return service.upload(command, multipartFiles);
}
// FileUtil.java
public File convertMultipartToFile(MultipartFile multipartFile) {
    // 임시 경로에 파일 저장
    File tempFile = new File(
        String.join(File.separator, FileUtils.getTempDirectoryPath(), multipartFile.getOriginalFilename())
    );

    try {
        multipartFile.transferTo(tempFile);
        return tempFile;
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

5. 결론

3개의 시스템을 통합하며 마주친 데이터 복잡도 증가는 단순한 용량 증가 이상의 기술적 과제였다.

관성적으로 Multipart Upload를 도입하는 대신, 데이터 포맷의 전환(ssjson → sjs)전송 구간의 압축(Gzip)이라는 기본 원리에 집중하여 문제를 해결했다. 결과적으로 프론트엔드는 더 빠른 업로드 속도를 확보했고, 백엔드는 불필요한 복잡도를 제거하여 유지보수성을 높일 수 있었다. 무엇보다 라이브러리 뒤에 숨겨져 있던 Blob, Uint8Array 같은 바이너리 데이터 처리의 기초를 탄탄히 다지며, 파일을 다루는 근본적인 원리에 대해 깊이 배울 수 있었다.

Intro

지난 포스트에서 백엔드 설계를 마쳤다. 이제 프론트엔드 차례다. 요구사항은 명확했다.
"사용자가 매뉴얼 영상을 보면서 시스템 조작을 동시에 할 수 있게 해주세요."

단순히 모달(Modal)을 띄우는 건 쉽지만, 모달은 작업을 가린다. 결국 PIP(Picture-in-Picture) 모드나 새 창(Pop-up) 기능이 필수적이다. React 환경에서 Pre-signed URL을 받아 영상을 재생하고, Window 팝업을 제어하며 겪었던 이슈들을 정리한다.


1. React-Player 연동 및 기본 재생

react-player 라이브러리는 훌륭하다. S3에서 발급받은 URL을 url props에 넣기만 하면 된다.

import ReactPlayer from 'react-player';

// API로 받아온 presignedUrl을 바로 주입
const ManualPlayer = ({ videoUrl }) => {
  return (
    <div className="player-wrapper">
      <ReactPlayer
        url={videoUrl} // https://s3... (Pre-signed URL)
        controls={true}
        width="100%"
        height="auto"
        playing={true}
        // S3 CORS 설정이 되어 있어야 정상 동작함
        config={{ file: { attributes: { controlsList: 'nodownload' } } }}
      />
    </div>
  );
};
💡 Check Point: S3 버킷의 CORS 설정에서 프론트엔드 도메인을 허용하고, GET 메서드를 열어둬야 재생이 가능하다.

2. 난관: PIP(새 창) 구현과 Window Lifecycle

사용자가 새 창으로 보기 버튼을 클릭했을 때, 브라우저의 새 윈도우를 열고 그 안에 React 컴포넌트를 렌더링해야 했다.
여기서 두 가지 문제가 발생했다.

2-1) 라우트 이동 시 팝업이 닫히는 문제

일반적인 컴포넌트는 페이지가 이동(Unmount)되면 사라진다.
하지만 매뉴얼 팝업은 사용자가 다른 페이지로 이동해도 계속 떠 있어야 한다.

2-2) 해결책: React Portal + Singleton Window

ReactDOM.createPortal을 사용하면 리액트 트리는 유지하되, 렌더링되는 실제 DOM 위치만 '새 창의 body'로 옮길 수 있다.
그리고 전역 변수(또는 Context)로 윈도우 객체를 관리하여 창을 재사용하는 패턴을 적용했다.

import ReactDOM from 'react-dom';
import { useEffect, useRef, useState } from 'react';

// 윈도우 객체를 모듈 스코프(싱글톤처럼) 관리
let externalWindow: Window | null = null;

const NewWindowPortal = ({ children, onClose }) => {
  const [container, setContainer] = useState<HTMLElement | null>(null);

  useEffect(() => {
    // 1. 이미 창이 열려있다면 재사용, 없으면 새로 오픈
    if (!externalWindow || externalWindow.closed) {
      externalWindow = window.open('', '', 'width=600,height=400,left=200,top=200');
    }

    // 2. 새 창의 body에 div 컨테이너 생성
    if (externalWindow) {
      const newContainer = externalWindow.document.createElement('div');
      externalWindow.document.body.appendChild(newContainer);
      setContainer(newContainer);
      
      // 스타일 시트(CSS) 복사 로직 필요 (생략)
      
      // 3. 창 닫힘 감지
      externalWindow.onbeforeunload = () => {
        externalWindow = null;
        onClose();
      };
    }

    // 4. Cleanup: 컴포넌트가 언마운트되어도 창은 닫지 않음 (사용자가 닫을 때까지 유지)
    return () => {
      if (externalWindow && newContainer) {
        // 여기서 close()를 호출하지 않는 것이 핵심!
        // 대신 React Portal 연결만 끊어줌
      }
    };
  }, []);

  // 5. Portal을 통해 새 창의 container에 children(ReactPlayer) 렌더링
  return container ? ReactDOM.createPortal(children, container) : null;
};

이 방식을 통해 사용자가 메인 창에서 페이지를 이동하더라도, 팝업 창의 영상은 끊기지 않고 재생되도록 구현했다.


3. 트러블슈팅: 외부 아이콘 클릭 이벤트 증발

UI 디자인상 외부 라이브러리 아이콘을 사용했는데, 이 아이콘 위에 커스텀 오버레이를 씌우니 클릭 이벤트가 먹히지 않거나, ref를 걸 수 없는 문제가 있었다.

문제 상황

  • 라이브러리 아이콘: ref 지원 안 함, 내부 onClick 로직 있음.
  • 내 요구사항: 내 아이콘(MyIcon)을 보여주되, 클릭 시 라이브러리 아이콘의 동작이 실행되어야 함.

해결: 투명 오버레이 & CSS Trick

복잡한 이벤트 캡처링/버블링을 따지는 대신, 시각적인 트릭을 썼다.
보이는 건 내 아이콘이지만, 실제 클릭되는 건 투명해진 원본 아이콘이 되도록 겹쳐 배치했다.

.wrapper { position: relative; width: 32px; height: 32px; }

/* 실제 클릭 타겟 (투명) */
.original-icon {
  position: absolute; inset: 0;
  opacity: 0; 
  pointer-events: auto; /* 클릭 받음 */
  z-index: 2;
}

/* 보여지는 아이콘 */
.my-custom-icon {
  pointer-events: none; /* 클릭 통과 */
  z-index: 1;
}

다소 원시적(?)일 수 있지만, 라이브러리 내부 로직을 건드리지 않고 가장 확실하게 동작을 보장하는 방법이었다. 현업에서는 우아한 코드보다 확실한 동작이 우선될 때가 있다.


마치며

이번 기능을 개발하며 Pre-signed URL을 통한 보안 데이터 처리부터 React Portal을 활용한 윈도우 제어까지 풀스택으로 깊게 파고들 수 있었다.

결과적으로 사용자는 보안 걱정 없이, 업무 흐름을 끊지 않으면서 동영상 매뉴얼을 참고할 수 있게 되었다. 단순한 동영상 재생 기능 하나에도 백엔드의 보안 정책과 프론트엔드의 UX 디테일이 촘촘하게 얽혀있음을 다시 한번 느낀다.

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

Intro

대용량 데이터 처리를 하며 긴 대기시간으로 사용자 경험을 개선하고자 프로그래스바를 도입하게 되었다.
실시간 진행 상황을 확인하려면 서버→클라이언트 통신이 필수였고, 그 중 하나인 SSE(Server-Sent Events)를 선택했다.
그런데 SSE를 다루기 위한 객체가 두 가지나 있네? 왜.. 두개지? 뭘 써야하지?
EventSourceEventSourcePolyfill이라는 객체인데, 주로 브라우저 호환성 때문에 사용하는 것 같은데..
생각보다 알아야 할 내용이 많으니 블로그에 기록해봐야겠다

그럼 두 객체의 개념과 차이점, 조건부 폴리필 적용 방법과 함께
SSE 통신으로 MUI LinearProgress 컴포넌트 연동해서 실제 프로그래스바 구현하는 과정까지 알아보자.

1. EventSource란?

1-1) 특징

  • 단방향 통신: 서버 → 클라이언트
  • 자동 재연결: 연결 끊김 시 자동 재시도
  • 이벤트 기반: message, open, error 등 처리 가능
  • 텍스트 전송 중심: 주로 JSON·텍스트 데이터 전송에 사용

1-2) 사용 예시

const eventSource = new EventSource('https://server.com/sse');

eventSource.onmessage = (event) => {
  console.log('New message:', event.data);
};

eventSource.onerror = (error) => {
  console.error('SSE error:', error);
};

 

2. EventSourcePolyfill이란?

2-1) 특징

브라우저 호환성 제공: IE, React Native 등 지원

커스텀 재연결 로직, 헤더 삽입 등 추가 기능

기본 EventSource API 에뮬레이션

2-2) 사용 예시

import { EventSourcePolyfill } from 'event-source-polyfill';

const eventSource = new EventSourcePolyfill('https://your-server.com/sse', {
  headers: { Authorization: 'Bearer your-token' },
  heartbeatTimeout: 45_000,
});

eventSource.onmessage = (event) => {
  console.log('New message:', event.data);
};

eventSource.onerror = (error) => {
  console.error('SSE error:', error);
};

 

3. EventSource vs Polyfill 비교

특징 EventSource EventSourcePolyfill
지원 브라우저 최신 브라우저 내장 API IE·React Native 등 구형·특정 환경 지원
설치 필요 여부 불필요 npm install event-source-polyfill 필요
재연결 로직 기본 제공 커스텀 설정 가능
커스텀 헤더 불가능 가능
확장성 제한적 다양한 옵션·기능 제공

 

함께 쓰는 이유

브라우저 호환성 보장: 구형 브라우저나 React Native에서도 SSE 동작

인증·헤더 삽입: 토큰 전달, Heartbeat 타임아웃 등 커스텀 기능

일관된 코드베이스 유지: 환경별 분기 없이 동일 API 사용

 

조건부 폴리필 적용법

import { EventSourcePolyfill } from 'event-source-polyfill';

const EventSourceClass = window.EventSource || EventSourcePolyfill;

const eventSource = new EventSourceClass('/api/progress-sse', {
  headers: { Authorization: 'Bearer token' },
  heartbeatTimeout: 45_000,
});

eventSource.onmessage = (e) => { /* … */ };
eventSource.onerror   = (err) => { /* … */ };

 

4. MUI 프로그래스바 연동

4-1) 준비

@mui/material 설치

React 상태 관리 훅 useState, useEffect 준비

4-2) 구현 코드

import React, { useState, useEffect } from 'react';
import LinearProgress from '@mui/material/LinearProgress';
import { EventSourcePolyfill } from 'event-source-polyfill';

export default function ProgressSSE() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const EventSourceClass = window.EventSource || EventSourcePolyfill;
    const es = new EventSourceClass('/api/progress-sse');

    es.onmessage = (e) => {
      const { progress: p } = JSON.parse(e.data);
      setProgress(p);
    };

    es.onerror = () => {
      es.close();
    };

    return () => es.close();
  }, []);

  return (
    <LinearProgress variant="determinate" value={progress} />
  );
}

 

레퍼런스

MDN EventSource

Event-Source-Polyfill 모든 브라우저에서 SSE 구현하기

MUI Linear Progress

 

마치며

대기가 필요한 서비스들에서 프로그래스 바가 있으면 진행 상황을 확인할 수 있어 좋았는데, 그 서비스를 내가 직접 만들다니 넘 뿌듯하다.
야근 겁나 많이해서 맨날 좀비처럼 출퇴근했지만 결과물을 보고 나니 배운게 많은 듯
다음 글에서는 SSE 연결 상태를 모니터링하는 폴링 기반의 헬스 체크에 대해 다뤄봐야겠다.
2주 안에 다시 돌아오길 바라며.. 끝

Intro

대용량 데이터를 처리하며 저장 작업을 수행할 때 긴 대기시간이 발생하게 되었다.
사용자 입장에서는 Confirm 버튼을 클릭한 뒤, 화면에 추가적인 이벤트나 피드백이 전혀 없으니 멈춘 것으로 오해할 수도 있겠다는 생각이 들었다. 이를 해결하기 위해 단순히 Skeleton UI로 가려둘 수도 있겠지만, 작업이 오래 걸리는 경우에는 현재 어느 정도 처리되었는지 진행 상황을 실시간으로 확인하면 좋겠다는 생각이 들었다.
Material UI LinearProgress 컴포넌트를 사용하면 진행 상황을 시각적으로 보여줄 있을 같은데,
어떻게 서버 상태를 확인하지? 에서 출발한 오늘의 포스팅..
글이 길어질것 같으니 2개로 나누어서 작성해야지. 일단 오늘은 서버와 실시간으로 통신할 있는 방법들에 대해 알아보자.

1. 서버와 실시간으로 통신할 수 있는 방법

서버와 실시간으로 통신할 수 있는 방법에는 여러가지가 있지만,
내가 알아본 바로는 4가지의 방법이 있었다.

  • Polling (Short Polling)
  • Long Polling
  • WebSocket
  • SSE(Server-Sent Events)

아래에서 각각의 방법에 대해 자세하게 풀어보자.

 

2. Polling (Short Polling)

2-1) 특징

  • 가장 단순한 방식으로, 정해진 간격(interval)으로 주기적인 HTTP 요청을 보내어 서버 데이터를 가져오는 방법
  • 예를 들어, 5초에 한 번씩 GET /api/data 요청을 보내어 새로운 데이터가 있나 계속 확인!
  • 특별한 프로토콜이나 서버 설정이 필요 없고, 기존의 HTTP GET/POST 방식만으로도 충분히 구현이 가능

2-2) 동작 방식

  1. 클라이언트가 일정 시간 간격(ex: 5초)으로 서버에 요청을 보냄
  2. 서버는 즉시(혹은 빠르게) 현재 상태/데이터를 응답으로 내려줌
  3. 클라이언트는 받은 데이터를 화면에 업데이트하고, 다시 다음 주기 때 요청을 반복

2-3) 장단점

  • 장점
    • 구현이 매우 간단 (ex: setInterval()에 AJAX 요청)
    • 추가 프로토콜(WebSocket)이나 특별한 서버 설정이 없어도, 기존 HTTP만으로 구현 가능
    • 구형 브라우저 환경에서도 무난히 사용 가능
  • 단점
    • 데이터가 변경되지 않았는데도 주기적으로 요청을 보내므로 불필요한 트래픽이 발생할 수 있음.
    • 요청 간격 사이에 발생한 이벤트는 즉시 감지하기 어렵다. (ex: 5초 주기로 요청하면, 최대 5초 늦게 알게 됨)

3. Long Polling

3-1) 특징

  • Polling(Short Polling)과 마찬가지로 HTTP 요청을 사용하지만, 서버가 응답을 지연시키는 기법
  • 즉, 서버 입장에서 “새로운 데이터가 생길 때까지 기다렸다가 응답”을 보내는 형태
  • 클라이언트는 응답을 받은 즉시 다시 요청을 보내어 연결을 이어가는 방식으로, 마치 실시간처럼 데이터 받을 수 있음

3-2) 동작 방식

  1. 클라이언트가 서버에 요청을 보냄
  2. 서버는 새로운 데이터(혹은 이벤트)가 발생할 때까지 연결을 유보(hold)함
  3. 데이터가 준비되면 서버가 즉시 응답을 전송
  4. 클라이언트는 응답을 받으면, 다시 새 요청을 보내서 같은 과정을 반복

3-3) 장단점

  • 장점
    • Polling보다 “실시간성”이 상대적으로 높다. (서버 이벤트가 생기면 즉시 응답)
    • 여전히 HTTP 기반이며, 별도의 WebSocket 설정이 필요 없음.
  • 단점
    • 서버 자원 점유: 응답을 보류하고 있기 때문에, 많은 사용자가 동시에 접속하면 서버 연결이 많이 소모될 수 있음.
    • 연결이 끊기고, 재연결이 반복되는 구조이므로, 클라이언트와 서버 모두 추가 로직이 필요할 수 있음.

3. WebSocket

4-1) 특징

  • 양방향(Full-duplex) 통신을 제공하는 독립된 프로토콜(ws:// 또는 wss://).
  • 초기에는 HTTP 핸드셰이크로 연결을 설정한 뒤, 성공 시 WebSocket 프로토콜로 업그레이드하여 지속적인 채널을 형성
  • 이후에는 서버와 클라이언트가 자유롭게 이벤트를 주고받을 수 있으므로, 실시간 채팅, 협업, 게임 등에 적합

4-2) 동작 방식

  1. 클라이언트가 HTTP 요청을 보내며 “WebSocket 업그레이드”를 요청
  2. 서버가 이를 수락하면, WebSocket 프로토콜로 전환
  3. 연결이 수립된 후에는 양쪽이 필요할 때마다 메시지를 발행하고, 서로 수신할 수 있음

4-3) 장단점

  • 장점
    • 양방향 통신이 가능해, 클라이언트 -> 서버, 서버 -> 클라이언트 모두 실시간으로 주고받을 수 있음
    • 채팅, 게임, 실시간 협업 등 짧은 지연(latency)을 요구하는 서비스에 탁월
  • 단점
    • 별도의 WebSocket 서버, 라이브러리 또는 설정이 필요하며, 로드밸런서나 프록시를 쓸 때도 추가 설정이 필요할 수 있음
    • 단순히 서버 -> 클라이언트 단방향 이벤트만 필요한 경우에는 오히려 과도한 스펙이 될 수 있음

4. SSE(Server Sent Event)

5-1) 특징

  • HTML5 표준으로, 브라우저가 기본 지원하는 EventSource API를 사용해 서버 -> 클라이언트 단방향 데이터를 스트리밍할 수 있음
  • HTTP 기반으로, “text/event-stream” 형태로 계속 이벤트를 흘려보낸다
  • 서버가 데이터를 Push하는 시점에 클라이언트에서 onmessage 등을 통해 실시간으로 수신 가능

5-2) 동작 방식

  1. 클라이언트(브라우저)에서 new EventSource(url)로 SSE endpoint에 연결
  2. 서버는 HTTP 연결을 유지하면서, 필요한 이벤트가 있을 때마다 일방적으로 푸시(Push)함
  3. 클라이언트는 연결이 끊길 경우 자동 재연결을 시도(브라우저의 EventSource 기본 기능)

5-3) 장단점

  • 장점
    • 자동 재연결: 연결 끊김 시 브라우저가 재접속 시도
    • 설정이 간단하며, 단방향 실시간 알림/로그/이벤트에 가장 적합
    • 기존 HTTP 기반이므로 방화벽/프록시 문제 발생이 비교적 적음
  • 단점
    • 양방향 통신이 안 되어, 클라이언트 -> 서버로 실시간 전송이 필요하면 별도 채널(예: AJAX) 필요
    • 바이너리 전송에는 적합하지 않고, 대부분 텍스트/JSON 형태로 사용

5. 정리

 

  • Short Polling: 쉽고 간단하지만, 주기적 요청으로 불필요한 트래픽이 발생할 수 있음.
  • Long Polling: 실시간에 좀 더 가깝지만, 서버가 응답을 홀드해야 하므로 서버 자원 부담이 큼.
  • WebSocket: 양방향 소통이 뛰어나고 실시간성이 가장 좋지만, 서버/네트워크 설정이 복잡할 수 있음.
  • SSE: 단방향 푸시에 특화되어 있고, 자동 재연결 + 구현 간단이라는 강점이 있음.

 

래퍼런스

클라이언트와 서버간 실시간 통신을 하는 방법

웹소켓을 알아봅시다

SSE (Server Sent Events) - 서버가 그대에게 보낸다

 

마치며

이전에 진행했던 프로젝트에서 웹소켓을 사용해보았었는데, supabase에서 제공하는 간단한 api만 사용했다보니 관련 지식에 대해서 단순히 양방향 소통이 가능한 방법이구나 정도만 알았었다. 이번 경험을 통해 서버와 소통할 수 있는 다양한 방법에 대해 알고, 어떤것이 필요한지 판별하는 과정에서 공부가 많이 되었던것 같다. 다음 편에서는 내가 프로젝트에 적용하게 된 기법에 대해 작성해보겠다!

+ Recent posts