[ Next.js, React.js, Tanstack-Query, Orval, Biome.js ] 사내 프론트엔드 5종 마이그레이션 feat: React2Shell

2026. 1. 16. 01:49Dev Logs

✏️ 5종 마이그레이션을 하게 된 계기

마이그레이션을 하고자 마음먹게 된 계기는 바로 2025년 12월에 발견된 "React2Shell" RCE(원격 코드 실행) 취약점이 발견되었고, CVSS 점수 9.8 / CRITICAL 레벨로 방치한다면 실제 서비스에서 재산 피해까지 번질 수 있는 취약점을 보완하기 위해서 작업을 시작하게 되었습니다.

 

추가적인 개선 사항으로 이전에 DX를 위해 도입했던 🔗openapi-generator-cli를 활용한 api 자동 생성 시스템이 호환 확장성을 위해 필요 이상의 코드까지 포함하여 방대하게 생성하고 있는 문제가 있어, 빌드시간이 지연되는 사이드 이팩트를 가지고 있었습니다. 이를 개선하고자 Orval 라이브러리를 찾아보게 되었고, 마이그레이션 작업에 추가했습니다.

 

그리고 fetch기반의 캐싱만으로는 확장성이나 기타 DX면에서 꽤나 번거로운 작업이 많았고, 이 문제를 해결하기 위해 Tanstack-Query로 마이그레이션하여 "낙관적 업데이트도 mutation을 활용하면 쉽게 구현이 가능하겠구나" 구상하며 마이그레이션 작업에 추가하게 되었습니다.

 

create명령어를 통해 Next.js의 초기 템플릿을 구성할 때 Biome이라는 선택지가 있어 서칭해보고 린터(eslint)와 포매터(prettier)의 기능을 하나로 통합한 올인원 도구임을 알게되었고,  Biome 역시 마이그레이션 작업에 추가하기로 했습니다.

 

📚 마이그레이션 리스트

  • [ React.js 18 ➔ 19 ]
  • [ Next.js 14 ➔ 16 ]
  • [ API 자동 생성: openapi-generator-cli  Orval ]
  • [ fetch   Tanstack-Query ]
  • [ eslint + prettier  Biome ]

 

💡 React2Shell ?

마지막으로 마이그레이션 작업 이후에 어떤 방식으로 취약점을 악용할 수 있는지 궁금했고, 직접 레포지토리를 만들어 실험해보며 로컬환경에서 직접 공격 가능한 환경을 만들어 두었습니다.

react2shell-lab: 🔗https://github.com/zeriong/react2shell-lab

 

React2Shell 공격 성공 시 내부 터미널에서 발생하는 콘솔

 

✅ 정리

  • RSC(react-server-component)를 인식하는 header 속성이 포함되어있는 경우 인증/인가를 건너뛰고 즉시 서버에 접근
  • constructor 객체로 인식 가능하도록 form을 통해 주입
  • 프로토타입이 Promise.then() 실행이 가능 객체로 변질되어 then 블록에서 원하는 스크립트를 실행

 

 

⚙️ Next.js & React.js 마이그레이션

우선적으로 React2Shell 취약점은 RSC의 취약점으로 React/Next 모두 포함되기 때문에 두 기술 모두 버전업을 할 수 밖에 없었습니다.

 

마이그레이션의 긍정적 효과로는 React-Compiler의 적용으로 auto memoization 되어 DX면에서 극도로 편리하였고, Next.js에서 turbopack을 기본 번들러로 공식 채택하여 빌드 속도가 눈에 띄게 빨라졌습니다.

 

부정적인 효과로는 이전에 리팩터링 하지 못했던 성능 이슈 있는 코드들이 추가된 컴파일링 기능으로 부하 추가되어 초기 컴파일 타임이 1~3초정도 지연되는 문제가 있었습니다.

 

3번째 열이 빌드입니다.

 

결과적으로 turbopack 적용으로 빌드 속도가 평균적으로  648  3분으로 약 2.27배 향상 되었습니다.

 

👨🏻‍🔧[ eslint + prettier ] 에서 Biome으로

Biome 마이그레이션은 정말 선택지였지만, UX/DX 지향 개발자로서... 린팅/포매팅의 속도와 관리 포인트도 최적화하고 싶은 욕심이 있어, Rust 엔진 기반의 Biome.js을 채택하여 린팅/포매팅의 속도가 공식적인 수치로는 약 35배까지 향상 될 수 있는 환경으로 마이그레이션 했습니다.

 

Biome.js 사이트 https://biomejs.dev/의 랜딩페이지

 

긍정적인 효과로, 단연 린팅/포매팅이 빠르다는 것이 가장 큰 장점이었습니다. 유지보수면에서는 하나의 파일로 관리할 수 있어 관리 비용이 줄어들었습니다.

 

부정적인 효과로는 확장성과 유연함이 줄었고, 관리 포인트에서 특정 규칙 책임을 분리하기 어렵다는 점이었습니다.

 

다만, 진행하고 있는 프로젝트의 특징을 감안했을 때 크게 불편함을 체감하진 못했고, 장점을 더 많이 가져갈 수 있어 "마이그레이션하길 잘했다!" 생각했습니다.

 

온보딩 시 기존 prettier와 eslint에 익숙할 수 있는 동료들을 위해 빠르게 인지할 수 있도록 주석설명과 함께 작성 가능한 jsonc 확장자로 작성했습니다.

 

// biome.jsonc
{
  "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
  // ======================
  // VCS(버전 관리 시스템) 설정
  // ======================
  "vcs": {
    // VCS 통합 기능 활성화 - Git과 통합하여 변경된 파일만 검사
    "enabled": true,
    // VCS 클라이언트 종류 - "git" 또는 "hg"(Mercurial) 중 선택
    "clientKind": "git",
    // .gitignore 파일 사용 여부 - Git ignore 규칙을 Biome에도 적용
    "useIgnoreFile": true,
    // Git에서 무시할 파일 목록 (useIgnoreFile이 true일 때는 .gitignore가 우선)
    "defaultBranch": "main"
  },
  // ======================
  // 파일 처리 설정
  // ======================
  "files": {
    // 알 수 없는 파일 무시 여부 - Biome이 인식하지 못하는 파일 형식 무시
    "ignoreUnknown": true,
    // 포함할 파일 패턴 - Biome이 처리할 파일 확장자 지정
    // include → includes (복수형)
    "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
    // "ignore" 키는 files 섹션에서 제거됨
    // Git ignore를 사용하려면 vcs.useIgnoreFile: true로 충분
    // 또는 CLI에서 --ignore 플래그 사용
  },
  // ======================
  // 포매터(코드 자동 정렬) 설정
  // ======================
  "formatter": {
    // 포매터 기능 활성화 - 코드 자동 포맷팅 기능 사용 여부
    "enabled": true,
    // 에러가 있는 코드 포맷팅 여부 - 문법 오류가 있어도 포맷팅 시도
    "formatWithErrors": false,
    // 들여쓰기 스타일 - "space"(스페이스) 또는 "tab"(탭) 중 선택
    "indentStyle": "space",
    // 들여쓰기 너비 - 몇 개의 공백 문자를 사용할지 지정
    "indentWidth": 2,
    // 줄 끝 문자 유형 - "lf"(Unix), "crlf"(Windows), "cr"(Mac) 중 선택
    "lineEnding": "lf",
    // 한 줄 최대 문자 수 - 줄 바꿈의 기준이 되는 문자 수
    "lineWidth": 80
  },
  
  // ...
}

 

👀내가 본 Biome

Biome에 대한 견해로 Next.js에서 Biome을 공식 채택을 할 것으로 눈여겨 보았지만, React19 업데이트에서 공식적으로 'react-compiler를 정상 호환하기 위해서는 eslint에서 설정하라'는 언급이 있어 아마 미뤄지거나 공식적으로는 eslint로 유지될 가능성이 높아보였습니다.

 

🔎 About React19 & Next16 ...

React19와 Next16에서는 '수동 최적화의 시대'를 끝내고 '자동 최적화의 시대'를 열었습니다.

React Compiler를 통해 개발자는 복잡한 성능 관리 대신 비즈니스 로직에만 집중할 수 있게 되었고, 이는 곧 개발 생산성(DX)과 어플리케이션의 안정성(UX)을 동시에 잡은 핵심적인 릴리즈였다고 생각합니다.

 

🛠️ Orval + Tanstack-Query 마이그레이션

Orval & Tanstack-Query의 마이그레이션하기 가장 어렵고, 많은 작업이 필요했던 요소였습니다.

기존에 이미 수동 캐싱 + fetch 캐싱을 활용하고 있었고, 특히나 가장 문제였던 것은 이미 API를 사용하고 있는 모든 부분의 import문을 수정해야 하는 문제가 있었습니다.

 

최근 참여했던 🔗TEOConf에서 알게된 'codemod'를 도입해볼까 고민도 했었지만, 결과적으로 구조도 개선을 하고자 했기 때문에 우선 수동 작업을 하는 방향으로 절차적 변경을 진행하기로 했습니다.

 

해당 프로젝트는 비즈니스 로직을 기준으로 서버를 나누어 관리하고 있습니다.

인증/인가 로직을 담당하는 서버는 "AuthServer"로 구분하고, 나머지 비즈니스 로직은 "APIServer"로 구분하고 있었습니다.

때문에 자동 생성 구축 시에도 동일하게 도메인을 분리하듯 관심사를 분리하여 생성되도록 orval.config.ts 를 작성하였고, 기존에 fetch기반 interceptor는 유지하기 위해 orval-instance.ts에 반영하여 해당 통신 interceptor instance기반의 Tanstack-Query custom hook을 자동 생성하도록 구현했습니다.

 

// orval.config.ts

import { defineConfig } from "orval";

/**
 * Orval 설정 파일
 * OpenAPI 스펙으로부터 TypeScript 타입과 React Query hooks 자동 생성
 */
export default defineConfig({
  // API 서버 설정
  api: {
    input: {
      target: process.env.SWAGGER_API_URL || "",
      validation: false, // Swagger validation 경고 무시
    },
    output: {
      // 생성 모드: 태그별로 파일 분리
      mode: "tags-split",

      override: {
        mutator: {
          path: "src/libs/orval/orval-instance.ts",
          name: "customApiInstance",
        },
      },
      // 출력 경로
      target: "./src/libs/orval/api-generated",
      // React Query 클라이언트 사용
      client: "react-query",
      // 생성 파일 옵션
      schemas: "./src/libs/orval/api-generated/models",
      // 기본 옵션
      baseUrl: process.env.NEXT_PUBLIC_API_URL_JAVA || "",
    },
  },

  // Auth 서버 설정
  auth: {
    input: {
      target: process.env.SWAGGER_AUTH_URL || "",
      validation: false, // Swagger validation 경고 무시
    },
    output: {
      mode: "single",

      override: {
        mutator: {
          path: "src/libs/orval/orval-instance.ts",
          name: "customAuthInstance",
        },
      },
      target: "./src/libs/orval/auth-generated/auth.ts",
      client: "react-query",
      schemas: "./src/libs/orval/auth-generated/models",
      baseUrl: process.env.NEXT_PUBLIC_API_URL_AUTH || "",
    },
  },
});

 

빌드와 api 갱신 시 편리하게 활용하기 위해 orval --config orval.config.ts 명령어를 package.json의 scripts에 작성하여 api 자동 생성 시스템을 구축했습니다.

 

결과적으로 JVM기반 생성에서 Node기반 생성으로 변경되어 발생하는 오버헤드가 최적화되었고,

generate 속도가 15.5s   6.3s 약 2.46배 가량 향상됐습니다.

 

파워쉘의 Measure-Command 를 활용한 측정

 

 

또한, 불필요한 확장성 코드를 제외하고 생성하는 Orval의 특징으로 생성된 파일 사이즈가

2,059KB  1,304KB  36.67% 감소하는 효과도 얻을 수 있었습니다.

 

openapi-generator-cli vs orval

 

UX향상을 위한 낙관적 업데이트를 Orval로 생성된 Tanstack-Query hook을 활용해서 서비스의 매장 즐겨찾기 기능에 낙관적 업데이트를 구현했습니다.

 

// 매장 즐겨찾기 mutation
const toggleFavoriteMutation = useToggleFavoriteShop({
  mutation: {
    // 낙관적 업데이트 - 실행 전
    onMutate: async ({ data: requestData }) => {
      const shopNo = requestData.shopNo;

      // 생성된 get key 유틸
      const queryKey = getGetMyShopsQueryKey(MY_SHOP_QUERY_KEY);

      // 진행 중인 refetch 취소
      await queryClient.cancelQueries({ queryKey });

      // 업데이트 전 상태
      const previousData =
        queryClient.getQueryData<PageListShopUserWithMasterResponse>(
          queryKey,
        );

      // 낙관적으로 캐시 우선 업데이트
      if (previousData?.data) {
        queryClient.setQueryData<PageListShopUserWithMasterResponse>(
          queryKey,
          (old) => {
            if (!old?.data) return old;

            // 데이터 배열 업데이트
            const updatedData = old.data.map((shop) => ({
              ...shop,
              // 선택된 샵은 true, 나머지는 false로 설정
              favoriteShop: shop.id === shopNo ? true : false,
            }));

            // favoriteShop이 true인 것을 최상단으로 정렬 (useMyPageShopData의 select 로직과 동일)
            const sortedData = [...updatedData].sort((a, b) => {
              if (a.favoriteShop && !b.favoriteShop) return -1;
              if (!a.favoriteShop && b.favoriteShop) return 1;
              return 0;
            });

            return {
              ...old,
              data: sortedData,
            };
          },
        );
      }

      // 롤백을 위한 context 반환
      return { previousData };
    },

    // mutation 실패 시 롤백
    onError: (err, variables, context) => {
      console.log("즐겨찾기 실패:", err);

        // 이전 상태로 롤백
        if (context?.previousData) {
          // 올바른 쿼리 키 사용
          const queryKey = getGetMyShopsQueryKey(MY_SHOP_QUERY_KEY);
          queryClient.setQueryData(queryKey, context.previousData);
        }

        toast.error("즐겨찾기 등록에 실패했습니다.");
    },

    // mutation 성공 시
    onSuccess: (responseData, variables) => {
      console.log("[toggleFavoriteShop] 응답:", responseData);
    },

    // mutation 완료 후 (성공/실패 무관)
    onSettled: () => {
      // 올바른 쿼리 키 사용 + refetchType 옵션
      const queryKey = getGetMyShopsQueryKey({ size: 50 });
      queryClient.invalidateQueries({
        queryKey,
        refetchType: "none", // 즉시 refetch하지 않고 다음 마운트 시 refetch
      });
    },
  },
});

 

아래는 위에서 작성한 mutaion 함수를 활용한 '매장 즐겨찾기' 함수입니다.

// 즐겨찾기 등록/해제 함수
async function handleChangeFavoriteShop(e: React.MouseEvent<HTMLElement>) {
  e.stopPropagation();

  if (isPlanNotRegistered) {
    return;
  }

  if (!data?.id) {
    toast.error("존재하지 않는 매장입니다.");
    return;
  }

  if (data.favoriteShop) {
    return toast.error("즐겨찾기 매장은 필수입니다.");
  }
    
  const confirmMessage = `"${data.name}" 매장을 즐겨찾기를 등록 하시겠습니까?\n즐겨찾기는 1개 매장만 가능합니다.`;

  if (await openConfirm({ confirmMessage })) {
    toggleFavoriteMutation.mutate({
      data: { shopNo: data.id },
    });
  }
}

 

🤔 배포 OS의 Node버전을 쉽게 핸들링 할 순 없을까?

사내 배포 환경은 모두 IaaS로 구성되어있습니다. 현재는 iwinv 업체에서 클라우드를 대여해서 Ubuntu OS에서 서비스를 배포하고 있습니다. 전체적으로 최신버전으로 마이그레이션을 했기 때문에 배포 OS에서도 Node 버전을 높여야할 필요가 있었습니다.

 

이전에 배포 환경에서는 Node 버전은 20.x로 React19에서 요구하는 Node 22+ 버전에 미치지 못하고, LTS인 24.x에 비하면 메이저 버전으로 4레벨이나 낮기 때문에 반드시 Ubuntu 환경도 동일하게 Node 버전업을 강행해야 했습니다.

 

간단하게 직접 SSH를 통해 접근해서 nvm을 통해 버전을 변경하면 되지만...

"과연 이게 최선일까? 언제까지 이렇게 할 순 없지 않을까?" 하는 생각이 들었습니다.

 

사내에 인프라, DevOps 개발자가 없기 때문에 직접 아이디어를 떠올려야 했습니다.

 

어차피 직접 관리해야 한다면, 프로젝트의 레포지토리에서 핸들링 가능하도록 작성하고,

코드가 병합 됐을 때 CI/CD를 통해 반영될 수 있는 구조를 떠올렸습니다.

 

현재 Jenkins를 통해서 Ubuntu에 직접 실행에 필요한 파일 들을 pipeline 명령어를 통해 transfer(전송)하고 있습니다.

 

제가 떠올린 것은 관리 비용을 줄이고자 transfer 대상으로 소스코드 root경로에 .deploy 디렉터리를 생성하고 내부에 Next.js를 관리하는 pm2의 환경파일인 ecosystem.config.json 배포 환경에서 node의 버전관리가 가능하고, pm2를 헬스체크해서 reload / start를 결정할 수 있는 deploy.sh파일을 추가하여 흩어져 있던 관리 포인트를 하나로 집중시키는 방법이었습니다.

 

기존 관리 포인트

pm2 - relaod / start Jenkins - pipeline
ecosystem.config.json SSH - 프로젝트 경로
Ubuntu - Node, 패키지 관리 SSH

 

하나로 집중된 관리 포인트

pm2 - relaod / start Git 소스코드 - deploy.sh
ecosystem.config.json Git 소스코드 - ecosystem.config.json
Ubuntu - Node, 패키지 관리 Git 소스코드 - deploy.sh

 

 

이처럼 관리 포인트를 하나로 집중시켜 유지보수성을 높여 DX를 향상시키고, 실제로 deploy.sh파일에 위 로직을 모두 작성하여 아래 이미지와 같이 Jenkins에서 로그를 통해 디버깅에 유리하도록 구성하여 개선했습니다.

Jenkins console output

 

📌UX/DX 개선 요약

  • React-Compiler 도입으로 놓칠 수 있는 최적화 요소들이 Auto Memoiztion이 되어 UX/DX 향상
  • Orval 도입으로 생성 속도 2.27배 향상, 생성되는 파일 사이즈 36.67% 감소
  • Tanstack-Query 도입으로 일관성 있는 통신 데이터 캐싱 / 낙관적 업데이트 로직으로 관리 비용 절감
  • Turbopack 도입으로 빌드 시간  2.27배 향상
  • Biome 도입으로 공식적 수치 기준 약 35배까지 빠른 린팅/포매팅이 가능한 환경 구성
  • 흩어져 있던 배포 환경 관리 포인트를 하나로 집중시켜 DevOps 관리 비용 절감

 

✍🏻마치며...

2년간 개발자로 일하면서 하나의 프로젝트에서 이렇게 많은 마이그레이션을 해본 것은 처음입니다.

성장할 수 있는 정말 좋은 기회였고, 단순히 최신 기술을 도입하는 것보다 저는 "보안, 성능, 생산성을 고려했을 때 정말로 유의미한 output으로 이어질 수 있을까?"에 집중하고, 많이 고민하고 검토했습니다.

 

보안 취약점 대응을 시작으로 빌드 속도 단축, Orval과 TanStack Query를 조합하여 비효율적인 구조 개선 등을 다양한 마이그레이션과 리팩터링을 진행하는 과정에서 설계의 중요성을 다시금 체감하게 되었습니다.

 

또한 인프라/옵스 관리 포인트를 코드 베이스로 통합하며, 개발자가 비즈니스 로직에 더 집중할 수 있는 환경이 곧 서비스 안정성으로 직결될 수 있다는 믿음이 생겼습니다.

 

이번 경험을 발판 삼아, 변화에 유연하고 효율적으로 대응할 수 있는 미드레벨 개발자로 거듭나고 싶습니다!