← Back to list

기능 하나 수정하는데 78개 파일을 여는 사람의 마음을 아시나요 — D’CENT 지갑 React 마이그레이션 분투기 #2

“기능 하나 수정에 파일 78개를 여는 고통을 아시나요?” 4가지 지갑 모드와 mixin 지옥을 벗어나, React의 Strategy Pattern으로 견고한 앱 초기화 흐름을 구축한 이야기. (React 마이그레이션 시리즈 2편)

최승주 in IOTRUST : Team Blog · 2026-04-22 10:36 · 20 claps · 27.1 min read
#react #vuejs #front-end-development #migration #web3
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 🌐 · Web Development

기능 하나 수정하는데 78개 파일을 여는 사람의 마음을 아시나요 — D’CENT 지갑 React 마이그레이션 분투기 #2

들어가며

D’CENT 지갑지문인증형 지갑, 카드 지갑, 소프트웨어 지갑, 간편 지갑(출시예정)까지 4가지 운영 모드를 지원하는 하이브리드 웹뷰 앱이에요.

웹뷰란 스마트폰 앱 안에 웹 화면을 내장한 방식으로, 사용자가 보는 지갑 화면이 실은 앱 내부의 웹페이지인 셈이에요.

Vue 2.7로 시작한 이 프로젝트를 React 19로 마이그레이션하면서, 가장 먼저 부딪힌 문제가 앱 초기화였습니다.

앱이 켜지면 네이티브 브릿지(앱과 스마트폰 OS/하드웨어 기기를 연결해주는 통신 통로)를 통해 디바이스 정보를 받고, 자산을 불러오고, 계정 정보를 불러오고, 설정을 동기화해야 해요. 그리고 이 모든 과정의 미묘한 차이를 결정하는 것이 바로 지갑 운영 모드입니다.

같은 앱이지만 어떤 지갑을 연결하느냐에 따라 초기화 절차가 완전히 달라져요. 지문인증형 지갑 모드에서는 블루투스 연결을 기다려야 하고, 카드 모드에서는 PIN 상태를 체크해야 하며, 소프트 모드에서는 백업 상태를 확인해야 합니다.

Vue 시절에는 이 차이를 지갑 운영 모드별 분기와 mixin으로 처리했어요.

처음엔 나쁘지 않았지만, 기능이 쌓이면서 초기화 코드가 여러 파일에 흩어지고, 모드별 분기가 곳곳에 산재하면서, 아무도 전체 초기화 흐름을 확신할 수 없는 상태가 되었습니다.

이 글은 Vue → React 마이그레이션 시리즈의 2편으로, Vue의 초기화 코드가 어떤 문제를 안고 있었는지, React에서 Provider 계층과 Strategy Pattern으로 이를 어떻게 재설계했는지, 그리고 그 과정에서 겪은 시행착오를 공유합니다.

2. Vue 초기화의 문제점

2.1 모놀리식 초기화 체인: main.js → initializer.js → actions.js

Vue 앱의 초기화는 세 파일에 걸쳐 이루어졌어요.

[main.js] - 진입점
  네이티브 브릿지 초기화
  → "웹뷰 준비됨" 알림
  → 설정 초기화
  → 로그인 정보 복원
  → getDeviceInfo()
      .then → initialize(store, device)
      .catch → 에러 나도 initialize 호출

[initializer.js] - 초기화 오케스트레이터
  initialize(store, device):
    mode = getOperationMode()
    → 토큰 로드
    → 주소 초기화
    → if (CARD || SOFT) → 디바이스 정보 업데이트
    → if (동기화된 디바이스 && 계정 없음) → 계정 목록 로드
    → 로그인 정보 로드
    → Promise.all(위 작업들)
    → finally: 로딩 완료 플래그 설정

[actions.js] - 65KB 단일 파일
  각 액션 내부에서 또 mode 분기:
    updateAccounts(store):
      switch (mode)
        case BIOMETRIC: ...
        case CARD: ...
        case SOFT: ...

문제는 이 세 파일의 실행 순서와 의존 관계가 암시적이라는 점이었어요.

main.js에서 Promise가 resolve된 후 initializer.js를 호출하고, 그 내부에서 여러 작업을 병렬로 날리며,

각 작업 내부에서 또 지갑 운영 모드를 분기합니다.

전체 흐름을 이해하려면 세 파일을 왔다 갔다 하면서 머릿속에서 실행 순서를 재구성해야 했어요.

2.2 지갑 운영 모드 분기 232곳 산재

grep으로 세어보니 지금 어떤 모드인지 확인하고 분기하는 코드78개 파일에 걸쳐 총 232곳에 있었어요.

초기화 코드뿐 아니라 UI 컴포넌트, 라우팅, 동기화 로직 곳곳에 스며들어 있었습니다.

// 헤더 컴포넌트에서
if (mode === "지문지갑 모드") {
  // 블루투스 연결 상태 표시
} else if (mode === "카드지갑 모드") {
  // NFC 상태 표시
} else if (mode === "소프트웨어 모드") {
  // 소프트 지갑 아이콘
}

// 계정 추가 페이지에서
if (mode === "카드지갑 모드") {
  if ("카드지갑 전용 지원 네트워크") { ... } else { ... }
}

// 전송 페이지에서
if (mode === "지문지갑 모드") {
  // 디바이스 연결 확인 후 서명
} else if (mode === "카드지갑 모드") {
  // NFC 탭 요청
}

새로운 운영 모드(예: 간편 지갑)를 추가하려면 이 232곳을 전부 훑으면서 필요한 곳에 분기를 추가해야 했어요.

빠뜨린 곳이 하나라도 있으면? 런타임에서야 발견됐습니다.

2.3 Vue mixin의 한계

Vue에서는 모드별 UI/동작 차이를 mixin으로 처리했어요.

mixin이란 여러 컴포넌트에서 공통으로 쓸 코드를 한 곳에 모아놓고, 원하는 컴포넌트에 복사해 붙여넣듯 끼워 넣는 Vue의 코드 재사용 방식이에요.

src/utils/main-ui/mixins/
├── biometric.js   // 7곳의 모드 분기
├── card.js        // 3곳
└── soft.js        // 3곳

문제는 다음과 같았습니다.

  1. 이름 충돌: data, methods가 컴포넌트와 이름이 겹칠 수 있어요. 어떤 mixin이 어떤 속성을 주입하는지 파일을 열어봐야 알 수 있습니다.
  2. 타입 안전성 없음: mixin으로 주입된 this.someMethod()가 실제로 존재하는지 타입 체커가 보장하지 못해요.
  3. 암묵적 의존성: mixin A가 mixin B의 데이터를 참조하는 경우가 있는데, 적용 순서에 따라 동작이 달라질 수 있습니다.
  4. 모드 추가의 어려움: 새로운 모드를 추가하면 새 mixin 파일을 만들고, 적용하는 컴포넌트마다 mixins: […] 배열에 추가해야 해요.

2.4 에러 핸들링 불확실성

Vue 초기화 코드의 에러 핸들링은 “실패해도 일단 진행”이라는 전략이었어요.

getDeviceInfo()
  .then(async (device) => {
    initialize(store, device)
  })
  .catch((error) => {
    reportError('Failed getDeviceInfo', ...)
    // 에러가 나도 initialize 호출
    initialize(store, undefined)
  })

이 접근은 실용적이었지만, 어떤 에러가 발생했을 때 앱이 어떤 상태에 있는지 예측하기 어려웠어요. 디바이스 정보 로드가 실패한 상태에서 초기화가 호출되면, 내부에서 지갑 운영 모드를 제대로 읽을 수 있는지, device가 undefined일 때 업데이트는 안전하게 스킵되는지 — 이런 질문들에 대한 답을 코드를 실행해봐야만 알 수 있었습니다.

2.5 테스트 어려움

Vue 초기화 코드는 사실상 테스트가 불가능했어요:

  • main.js는 import하는 순간 사이드 이펙트가 실행됩니다 (Vue 인스턴스 생성, 네이티브 브릿지 초기화 등).
  • initializer.js는 Vuex store에 강하게 결합되어 있어서, store 전체를 모킹하지 않으면 테스트할 수 없었어요.
  • actions.js는 65KB짜리 단일 파일로, 개별 액션을 격리해서 테스트하기 어려웠습니다.

특정 모드에서 특정 초기화 단계가 올바르게 동작하는지 검증하려면, 실제 네이티브 앱 환경에서 수동으로 확인하는 수밖에 없었어요.

정리하자면,

Vue 시절에는 초기화 로직과 모드 분기가 세 파일(main/initializer/actions)에 흩어져 있고, 78개 파일 232곳에 스며든 지갑 운영 모드 분기를 사람 손으로 관리해야 했어요.

이 복잡도가 새 모드 추가와 테스트를 사실상 막고 있었습니다.

3. React 재설계: 핵심 아이디어 3가지

3.1 Provider 계층 = 초기화 순서

React에서는 Provider의 중첩 순서가 곧 초기화의 의존 관계를 표현합니다.

안쪽 Provider는 바깥 Provider의 Context에 접근할 수 있으므로, 중첩 구조 자체가 “누가 먼저 준비되어야 하는가”를 명시해요.

이것이 Vue의 명령형 초기화 체인(main.js → initializer.js → actions.js)을 대체하는 가장 큰 아이디어였습니다.

export const AppInitProvider = ({ children }) => {
  return (
    <NativeProvider>                {/* 1. Native bridge + 지갑 운영 모드 */}
      <StrategyProvider>            {/* 2. 모드별 Strategy */}
        <I18nProvider>              {/* 3. 다국어 */}
          <InitializationGuard>     {/* 4. 로딩/에러 UI */}
            {children}
          </InitializationGuard>
        </I18nProvider>
      </StrategyProvider>
    </NativeProvider>
  )
}

다만, Provider가 렌더링되었다고 해서 내부 비동기 초기화까지 완료된 것은 아니에요.

그래서 각 Provider 내부에서 이전 의존성의 완료 상태를 명시적으로 체크합니다.

이 부분은 시행착오 3에서 자세히 다룰게요.

이 구조의 장점은 다음과 같아요

  1. 순서가 명시적입니다: JSX 중첩 구조를 보면 의존 관계를 한눈에 파악할 수 있어요. 코드 주석이 아니라 구조 자체가 문서예요.
  2. 의존성이 강제됩니다: StrategyProvider는 NativeProvider 내부에 있으므로, useNative() 훅으로 네이티브 브릿지에 접근할 수 있어요. 순서를 바꾸면 런타임 에러가 즉시 발생합니다.
  3. 개별 교체가 가능합니다: 테스트 시 NativeProvider만 모킹 버전으로 교체하면 나머지 Provider들은 그대로 동작해요.

3.2 Strategy Pattern으로 모드별 캡슐화

232곳에 흩어진 지갑 운영 모드 분기를 한 곳으로 모으자는 게 핵심이에요.

흩어진 분기는 새 모드를 추가할 때 빠뜨리는 곳이 생기기 쉽고, 버그가 어디서 나왔는지 추적하기도 힘들어요.

한 곳에 모으면 새 모드 추가 시 그 파일 하나만 수정하면 됩니다.

소프트웨어 설계의 고전인 GoF(Gang of Four) 디자인 패턴에서 소개한 Strategy Pattern이 정확히 이 용도예요.

핵심 발상은 이렇습니다:

  • 모드별 차이가 나는 로직을 인터페이스로 정의해요.
  • 각 모드(device/card/soft/social)가 이 인터페이스를 구현합니다.
  • 사용하는 쪽에서는 모드를 알 필요 없이, 인터페이스의 메서드만 호출하면 돼요.
Before (Vue):
  컴포넌트 A → if (지문인증 모드면) ... else if (카드 모드면) ...
  컴포넌트 B → switch (모드) { case 지문인증: ... }
  컴포넌트 C → if (소프트 모드면) ...

After (React):
  컴포넌트 A → strategy.canSendCoin()
  컴포넌트 B → strategy.renderHeader()
  컴포넌트 C → strategy.needsBackup()

분기가 Strategy 클래스 내부에 캡슐화되므로, 새 모드를 추가할 때 기존 컴포넌트는 건드리지 않아도 됩니다.

3.3 Phase 기반 초기화 훅

initializer.js의 절차적 흐름을 React의 useEffect 의존성 체인으로 변환했어요. 각 Phase는 이전 Phase의 완료 상태를 의존성으로 가집니다.

Phase 0: Device Info + Custom Tokens (앱 실행에 필요한 기본 정보)
  ↓ 
Phase 1: Settings 업데이트 (설정값 업데이트)
  ↓ 
Phase 2: Native Settings + 알림 초기화 (Native 통신 세팅)
  ↓ 
Phase 3: Accounts(계정) 로드
  ↓ 
Phase 4: Balances(자산) 로드

Vue에서는 await과 Promise.all의 조합으로 이 순서를 제어했는데,

중간에 하나가 실패하면 뒤의 것들이 모두 막히거나, 반대로 에러를 무시하고 진행하는 문제가 있었어요.

React의 useEffect는 각 Phase가 독립적인 effect이므로, Phase 1이 실패해도 Phase 2가 자체 조건만 충족되면 진행할 수 있습니다.

정리하자면,

React에서는 Provider 계층으로 의존 관계, Strategy Pattern으로 모드별 차이, Phase 기반 훅으로 데이터 흐름을 각각 분리해 다룰 수 있게 된 것이 핵심이에요.

4. Strategy Pattern 상세

아이디어는 간단해요. 각 지갑모드가 같은 인터페이스에 맞는 플러그만 끼우면 됩니다. 코드로 옮기면 이렇습니다.

4.1 인터페이스 설계

모든 Strategy가 구현해야 하는 계약을 인터페이스로 정의했어요:

interface OpmodeStrategy {
  readonly mode: 'device' | 'card' | 'soft' | 'social'
  initialize(): Promise<void>
  renderHeader(): ReactNode
  ...
}

설계에서 가장 고민했던 부분은 renderHeader()처럼 UI를 직접 반환하는 메서드가 Strategy에 속하는 게 맞느냐였어요.

순수한 Strategy Pattern이라면 로직만 반환하고 UI는 별도 컴포넌트에서 처리하는 게 교과서적이죠.

하지만 실제로 각 모드의 헤더 UI는 완전히 다른 구조였습니다.

  • 지문 인증형 지갑 모드는 블루투스 연결 상태 바
  • 카드 지갑 모드는 NFC 아이콘
  • 소프트웨어 지갑 모드는 백업 경고

이걸 하나의 Header 컴포넌트에서 조건부 렌더링하면

결국 모드 분기 덩어리가 다시 생겨요.

그래서 각 Strategy가 자신의 UI를 책임지게 했습니다.

4.2 BaseStrategy (Template Method)

공통 로직은 추상 클래스에 모으고

모드별 차이는 하위 클래스가 채우는 구조예요

abstract class BaseStrategy<TState> implements OpmodeStrategy {
  async initialize(): Promise<void> {
    this.setConnectionState()
    await this.initializeModeSpecific()
  }
  protected abstract initializeModeSpecific(): Promise<void>
}

각 하위 클래스는 자기 모드에 특화된 부분만 채우면 됩니다:

  • DeviceStrategy: 지문인증 연결 관리, 펌웨어 업데이트 체크
  • CardStrategy: PIN 상태 체크, 백업 카운트 관리
  • SoftStrategy: 백업 상태 체크, iOS 에러 계정 관리
  • SocialStrategy: OAuth 인증, Wepin 코인 리스트 관리

4.3 StrategyProvider (Factory + Context)

Provider가 지갑 운영 모드에 따라 Strategy를 생성하고

Context로 하위 트리에 공유해요.

지갑 운영 모드는 앱 시작 시 한 번 결정되므로 Context 값은 이후 변하지 않습니다.

사용하는 쪽에서는 지갑 운영 모드를 몰라도 됩니다:

const { strategy, canSendCoin, isConnected } = useStrategy()
strategy.renderHeader()     // 현재 지갑 운영 모드에 맞는 헤더
strategy.canAddAccount()    // 현재 지갑 운영 모드에서 계정 추가 가능 여부

4.4 React 상태와 클래스의 브릿지 (ref 주입)

가장 까다로웠던 부분이에요.

Strategy는 클래스 인스턴스인데, React의 상태는 함수형 훅으로 관리됩니다. Strategy 내부에서 최신 상태를 참조해야 하는데, 함수가 만들어질 때의 값을 그대로 기억하는 자바스크립트의 특성(클로저) 때문에 이전 값을 바라보는 문제가 생겼어요.

해결책은 React의 useState를 Strategy에 주입하는 것이었습니다.

상태를 읽을 때ref.current로 접근하고(클로저 문제 회피)

변경할 때setState를 넘기는 형태로 진행했어요.

const [deviceState, setDeviceState] = useState({ isConnected: false })
const deviceStateRef = useRef(deviceState)

deviceStateRef.current = deviceState

instance.setStateManager(
  () => deviceStateRef.current,
  setDeviceState
)
React State (useState)
  ↕ (렌더마다 동기화)
Ref (useRef)
  ← (getter) Strategy가 읽음
  → (setter = setState) Strategy가 업데이트하면 리렌더링 발생

ref는 값 자체를 담는 게 아니라 값이 있는 상자를 가리키기 때문에, 상자 안의 내용이 바뀌어도 Strategy는 항상 그 상자를 들여다보기만 하면 최신 값을 얻을 수 있어요.

정리하자면,

Strategy 클래스가 인터페이스로 모드별 차이를 명시하고

BaseStrategy가 공통 골격을 제공하며

StrategyProvider가 모드에 맞는 인스턴스를 생성해 Context로 공유해요.

클래스와 React 상태의 간극은 ref 주입 패턴으로 메웠습니다.

5. 초기화 Phase 설계

7개 Phase 흐름

Phase를 나눈 기준은 하나예요

다음 단계가 이전 단계의 완료를 필요로 하는가.

그 의존성을 그려보면 7개의 구간이 자연스럽게 생겼습니다.

초기화 순서가 꼬이면 앱이 하얀 화면에서 멈추거나, 계정 정보 없이 메인 페이지가 렌더링되는 등 심각한 문제가 발생했어요.

디바이스 정보 + 커스텀 토큰 로드
  ↓
설정 업데이트 + Analytics 초기화
  ↓
네이티브 설정 로드 + 푸시 알림 동기화
  ↓
사용자 정보 복원
  ↓
계정 목록 로드
  ↓
잔액 로드
  ↓
✅ 초기화 완료

각 단계는 이전 단계가 완료되었는지를 조건으로 확인한 뒤에 실행돼요.

모든 단계가 완료되어야 앱이 메인 화면을 보여줍니다.

useEffect 의존성 체인

각 Phase는 하나의 useEffect로 구현되고

이전 Phase의 완료 상태를 의존성 배열에 포함해요

useEffect(() => { /* Phase 0 */ }, [nativeInitialized])
useEffect(() => { /* Phase 1 */ }, [isDeviceInfoInitialized])
useEffect(() => { /* Phase 1.5 */ }, [isDeviceInfoInitialized])

// TanStack Query(데이터 요청 관리 라이브러리)의 enabled로 선언적 의존 관계 표현
const accountsQuery = useAccounts({ enabled: isNativeSettingsInitialized })

이 패턴에서 주목할 점은

useAccounts({ enabled: isNativeSettingsInitialized })예요.

enabled 옵션을 써서

이전 Phase가 완료되기 전에는 쿼리 자체가 실행되지 않습니다.

Vue에서는 await으로 순서를 보장했지만, React에서는 선언적으로 의존 관계를 표현해요.

module-level 플래그

한 가지 재미있는 패턴을 소개할게요.

Phase 2(User Info 초기화)에서 module-level 변수를 사용합니다:

let userInfoInitialized = false

export const useStoreInitialization = () => {
  useEffect(() => {
    if (userInfoInitialized) return
    // ...
    userInfoInitialized = true
  }, [...])
}

왜 useRef 대신 module-level 변수를 쓸까요?

useStoreInitialization은 App.tsx와 MainLayout.tsx 두 곳에서 호출돼요.

useRef는 인스턴스별로 독립적이라, MainLayout이 재마운트되면 ref가 초기화되어 Phase 2가 다시 실행됩니다.

module-level 변수는 모든 인스턴스가 공유하므로 전체 앱 라이프사이클에서 딱 한 번만 실행돼요.

이 패턴은 의도적인 싱글톤이에요.

이 코드가 웹뷰 전용 SPA이고 테스트에서 모듈을 격리한다는 전제 하에 선택했습니다.

단점(HMR에서 리셋되는 등)을 인지하면서도, 프로덕션에서의 정확한 1회 실행이 더 중요했어요.

정리하자면,

초기화를 7개 Phase로 나누고, 각 Phase가 이전 Phase의 완료 상태를 useEffect 의존성이나 TanStack Query의 enabled로 선언적으로 참조해요.

모두 끝나야 다음 단계가 아니라,

내가 필요한 것만 준비되면 시작하는 구조입니다.

6. Before & After 비교표

숫자로 보면 깔끔해 보이지만,

이 구조에 도달하기까지 적지 않은 시행착오가 있었어요.

그 이야기는 8장에서 다루겠습니다.

7. 다른 영역에서의 Strategy 활용

사실 이 패턴을 초기화에 적용하고 나서 같은 문제가 다른 곳에서도 보이기 시작했어요. 모드에 따라 동작이 달라지는 곳이 초기화만 있는 게 아니었습니다.

몇 가지 잠재적 확장 영역을 정리해 봤어요.

DApp Sign (탈중앙화 앱 서명 요청)

DApp이란 카카오나 구글 같은 중앙 서버 없이 블록체인 위에서 직접 실행되는 앱이에요. 쉽게 말해 운영사 없이 돌아가는 앱입니다.

이런 DApp에서 거래를 승인할 때,

사용자의 지갑으로 전자 서명을 요청해요. 이 서명 과정이 지갑 운영 모드별로 완전히 다릅니다

  • 지문 인증형 지갑 모드: 블루투스로 연결된 실물 지갑에 서명 요청 → 물리 버튼으로 확인
  • 카드 지갑 모드: NFC 탭 요청 → PIN 입력 → NFC로 서명
  • 소프트웨어 지갑 모드: 앱 내에서 즉시 소프트웨어 서명

Vue에서는 서명 관련 컴포넌트마다 모드 분기가 반복되었어요.

Strategy Pattern으로 SignStrategy 인터페이스를 정의하면

서명 흐름 컴포넌트가 모드를 알 필요 없이 signStrategy.requestSign(tx) 한 줄로 처리할 수 있습니다.

정리하자면,

서명, 교환, 동기화 등 모드별로 다르게 동작하는 모든 영역에 같은 패턴을 적용할 수 있어요. Strategy 인터페이스를 정의하고, 각 모드가 구현하면, 사용하는 쪽에서는 모드를 몰라도 됩니다.

8. 실제 겪은 시행착오와 교훈

시행착오 1: 클래스의 상태 변경을 React가 모른다

처음에 Strategy 클래스에 isConnected: boolean 같은 필드를 직접 두고, 클래스 내부에서 this.isConnected = true로 변경했어요.

이때 UI가 업데이트되지 않았습니다.

React는 자신이 관리하는 state만 감지해요.

클래스 인스턴스의 필드 변경은 React 세계 밖에서 일어나는 일입니다.

교훈: 클래스와 React를 결합할 때는 반드시 React의 상태 메커니즘(useState/useReducer)을 통해 상태를 관리해야 해요.

4.4절의 ref 주입 패턴이 이 교훈에서 나왔습니다.

시행착오 2: useEffect 클로저에 갇힌 state

Strategy 인스턴스가 useEffect 안에서 생성되면,

생성 시점의 state를 클로저로 캡처해요. 이후 state가 변해도 Strategy는 예전 값만 봅니다.

// ❌ 문제: state가 클로저에 갇힘
useEffect(() => {
  const instance = new DeviceStrategy(config)
  instance.setStateManager(
    () => deviceState,     // 생성 시점의 deviceState만 캡처
    setDeviceState
  )
}, [])

해결: getter를 ref를 통해 간접 참조하게 했어요.

// ✅ 해결: ref는 항상 최신값
const deviceStateRef = useRef(deviceState)
deviceStateRef.current = deviceState

instance.setStateManager(
  () => deviceStateRef.current,
  setDeviceState
)

교훈: useEffect 내부에서 외부 값을 참조할 때, 해당 값이 변할 수 있다면 ref를 경유하거나 useEffectEvent를 활용해 최신 값을 안전하게 읽어야 해요.

우리는 Strategy 클래스에 getter를 주입하는 구조였기 때문에 ref 방식을 선택했습니다.

시행착오 3: 초기화 순서 보장에 대한 과신

처음에는 “Provider 중첩 순서로 충분하겠지”라고 생각했어요.

하지만 실제로는 Provider 내부의 비동기 로직이 문제였습니다. NativeProvider가 렌더링되었다고 해서 네이티브 브릿지 초기화가 완료된 것이 아니에요.

// ❌ Provider가 렌더링됨 ≠ 초기화 완료
<NativeProvider>     {/* 렌더는 즉시, 초기화는 비동기 */}
  <StrategyProvider>  {/* 네이티브 미준비 상태에서 실행될 수 있음 */}

해결: 각 Provider 내부에서 이전 의존성의 완료 상태를 체크했어요.

// ✅ StrategyProvider 내부
useEffect(() => {
  if (!nativeCall || !nativeReady) return
  // ... strategy 생성
}, [nativeReady, nativeCall])

교훈: Provider 계층화는

구조적 의존성(누가 누구의 Context에 접근할 수 있는지)을 표현하지만,

시간적 의존성(누가 먼저 완료되어야 하는지)은 내부에서 별도로 처리해야 해요. 선언적 구조와 명령형 가드의 조합이 필요합니다.

시행착오 4: 간편 지갑(소셜로그인 지갑) 모드의 뒤늦은 추가

처음 Strategy 인터페이스를 설계할 때는 device/card/soft 세 모드만 고려했어요. 간편 지갑이 추가되면서 인터페이스를 수정해야 했습니다. updateDeviceInfo가 optional 메서드(?)인 이유도 간편 지갑 모드에서는 이 단계가 없기 때문이에요.

interface OpmodeStrategy {
  // 간편 지갑 모드에서는 불필요
  updateDeviceInfo?(device: LastConnectedDevice): Promise<void>
}

교훈: 인터페이스 설계 시 “현재의 3가지 모드”가 아닌 모드별로 다를 수 있는 축을 기준으로 생각해야 해요.

updateDeviceInfo는 “하드웨어 디바이스가 있는 모드”에서만 의미가 있으므로, 처음부터 optional이었어야 했습니다.

시행착오 5: 개발 중 핫 리로드(HMR) 시 module-level 플래그 초기화 문제

let userInfoInitialized = false를 모듈 레벨에 둔 것이 개발 환경에서 문제가 됐어요.

HMR을 쓸 때, Vite가 모듈을 다시 평가하면서 이 변수가 false로 리셋되어

Phase 2가 재실행됐습니다. 프로덕션에서는 문제없지만,

개발 중 혼란을 줄 수 있었어요.

교훈: module-level 싱글톤은 의도를 주석으로 명확히 남기고, HMR 영향을 문서화해야 합니다. 코드에 이런 주석을 남겼어요:

/**
 * 제약사항:
 * - HMR(개발 환경)에서 모듈이 재평가되면 false로 리셋되어
 *   Phase 2가 재실행될 수 있으나, 프로덕션에는 영향 없습니다.
 */
let userInfoInitialized = false

정리 하자면,

대부분의 시행착오는 클래스 기반 OOPReact 함수형 패러다임의 간극에서 발생했어요.

클래스의 상태를 React가 인식하게 하는 것, 클로저에 갇힌 값을 최신으로 유지하는 것, 선언적 구조와 비동기 타이밍의 불일치

이 세 가지가 핵심 도전이었습니다.

9. 마치며

이번 마이그레이션에서 앱 초기화 설계는 가장 먼저 손을 댄 부분이자 가장 오래 고민한 부분이었어요.

Vue의 절차적 초기화가 틀린 것은 아니었습니다. 오랫동안 프로덕션에서 잘 동작했거든요.

문제는 복잡성이 선형적으로 증가하지 않았다는 점이에요.

모드가 하나 추가될 때마다, 기능이 하나 추가될 때마다, 수정해야 할 곳이 기하급수적으로 늘어났습니다.

Strategy Pattern과 Provider 계층화로

이 문제를 해결한 핵심은 결국 관심사의 분리였어요

  1. 무엇을 초기화하는가 → useStoreInitialization의 Phase들
  2. 어떤 순서로 초기화하는가 → AppInitProvider의 Provider 중첩
  3. 모드에 따라 어떻게 다른가 → Strategy 클래스들

이 세 가지 질문에 대한 답이 각각 다른 파일에 명확하게 존재합니다.

그리고 새로운 모드가 추가되면, 새 Strategy 클래스를 추가하고 StrategyProvider에 한 줄의 분기를 추가하면 돼요.

(1)과 (2)는 건드릴 필요가 없습니다.

만약 여러분의 프로젝트에서도 비슷한 모드별 분기 지옥을 겪고 있다면, 이런 질문을 던져보세요

switch/if 분기가 몇 곳에 있는가? — grep으로 세어보세요. 숫자가 두 자리를 넘기면, 그건 설계 문제입니다.

초기화 순서를 한 곳에서 파악할 수 있는가? — N개 파일을 왔다 갔다 해야 한다면, Provider 계층화를 고려해보세요. 구조 자체가 문서가 되어야 합니다.

각 Phase가 독립적으로 실패하고 복구할 수 있는가? — “하나 실패하면 전부 멈춤” 구조는 프로덕션에서 앱이 하얀 화면에 멈추는 최악의 시나리오로 이어져요.

이 질문들에 대한 우리의 시행착오와 해결 과정이,

이 글에서 공유한 패턴들 속에 담겨 있습니다.

남은 보완사항: 클래스에서 함수형으로

솔직하게 남겨둘 부분이 있어요.

현재 Strategy는 클래스 기반으로 구현되어 있습니다.

Vue의 mixin을 Strategy Pattern으로 전환하면서 OOP 스타일을 그대로 가져왔는데

이유가 있었어요.

232개의 switch문을 한번에 제거하는 것이 최우선 목표였고

팀이 Vue의 클래스 기반 구조에 익숙했기 때문에 전환 비용을 줄이려는 실용적 판단이었습니다.

하지만 이 선택이 React와 잘 어울리는지 돌아보면

어색한 지점들이 분명히 있어요.

1) 클래스 안에서 Hook을 쓸 수 없어요

renderHeader()가 JSX를 반환하는데,

클래스 메서드 안에서는 useState나 useEffect를 호출할 수 없습니다.

그래서 Hook이 필요한 UI는 별도 컴포넌트로 우회해야 했어요:

renderSecurityAlerts(): ReactNode {
  return <CardPinUpdateAlerts needPinUpdate={this.state.needCardPinUpdate} />
}

2) Zustand를 억지로 호출하는 패턴

클래스 내부에서 this.accountStore.getState(), usePopupStore.getState() 같은 호출이 나와요.

함수 컴포넌트였다면 useAccountStore()로 자연스럽게 구독했을 부분을

클래스에서는 수동으로 최신 state를 꺼내와야 합니다.

3) ref 주입 브릿지 자체가 보일러플레이트

4.4절에서 설명한 setStateManager , getter/setter를 ref로 연결하는 패턴이

클래스와 React 상태를 잇기 위해 존재하는 코드예요.

함수형이었다면 애초에 필요 없었을 겁니다.

함수형이었다면 이런 모습이었을 거예요:

function useDeviceStrategy(): OpmodeStrategy {
  const [isConnected, setIsConnected] = useState(false)
  const deviceStore = useDeviceStore()
  const { openPopup } = usePopupStore()

  const initialize = async () => {
    setIsConnected(checkDevConnection())
  }
  return {
    mode: 'device',
    canSendCoin: () => isConnected,
    renderHeader: () => <BluetoothButton connected={isConnected} />,
    initialize,
  }
}

ref 주입도 없고, getState() 수동 호출도 없고, Hook을 자연스럽게 쓸 수 있어요.

공통 로직은 Template Method 상속 대신 공통 Hook을 합성하는 방식으로 처리하면 됩니다.

현재 클래스 기반 구현이 동작에 문제가 있는 것은 아니에요.

하지만 React의 함수형 패러다임에 최적화된 설계라고 하기는 어렵습니다.

이 부분은 향후 리팩토링 대상으로 남겨둡니다.

좋은 아키텍처는 한 번에 완성되는 것이 아니라, 이런 지점들을 인식하고 점진적으로 개선해가는 과정이라고 생각해요.

다음 글에서는 이 Strategy Pattern을 DApp 서명 영역에 적용한 이야기를 다뤄요.

Vue에서 8개 파일 6,900줄에 걸쳐 복붙되어 있던 DApp 서명 화면을, 3계층 아키텍처(UI + Strategy 훅 + 구체 전략)로 재구조화한 과정입니다.

이 글에서 소개한 지갑 운영 모드 축의 캡슐화와 같은 원리를

이번에는 플랫폼(Engine/WalletConnect/MetamaskQR)이라는 축에 적용한 사례예요.

2편에서 다루기엔 분량이 길어져서 별도로 분리했는데, 연결해서 읽으시면

Strategy Pattern이 실제로 어떻게 확장되는지 더 구체적으로 이해하실 수 있을 거예요!

이 글은 D’CENT 지갑 React 마이그레이션 분투기 시리즈의 2편입니다.

  1. 왜 우리는 Vue에서 React로 옮겼을까
  2. 기능 하나 수정하는데 78개 파일을 여는 사람의 마음을 아시나요 (현재글)
  3. 쌍둥이인 줄 알았는데 세쌍둥이였다 — 중복 코드 다이어트기
  4. Vue에서 React로: 프레임워크 차이가 만든 실전 버그들
  5. 개선에 따라온 여러 호환성 문제들에 관하여
  6. 우리는 더 빠르고, 더 가벼워졌습니다

메타데이터
post_id
494a87dea6eb
slug
기능-하나-수정하는데-78개-파일을-여는-사람의-마음을-아시나요-494a87dea6eb
url
https://medium.com/iotrustlab/%EA%B8%B0%EB%8A%A5-%ED%95%98%EB%82%98-%EC%88%98%EC%A0%95%ED%95%98%EB%8A%94%EB%8D%B0-78%EA%B0%9C-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%97%AC%EB%8A%94-%EC%82%AC%EB%9E%8C%EC%9D%98-%EB%A7%88%EC%9D%8C%EC%9D%84-%EC%95%84%EC%8B%9C%EB%82%98%EC%9A%94-494a87dea6eb
canonical_url
https://medium.com/iotrustlab/%EA%B8%B0%EB%8A%A5-%ED%95%98%EB%82%98-%EC%88%98%EC%A0%95%ED%95%98%EB%8A%94%EB%8D%B0-78%EA%B0%9C-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%97%AC%EB%8A%94-%EC%82%AC%EB%9E%8C%EC%9D%98-%EB%A7%88%EC%9D%8C%EC%9D%84-%EC%95%84%EC%8B%9C%EB%82%98%EC%9A%94-494a87dea6eb
author_url
https://medium.com/@choi_tory
status
ok
fetched_at
2026-06-10 21:21:38