Skip to main content
네이티브
입문네이티브React Native 플랫폼 · 25

React Native JavaScript stack 복원: 어느 source map을 써야 하는가

배포 stack을 현재 main의 map으로 복원해 그럴듯하지만 틀린 파일을 얻는 실패를 재현하고, 실행된 bundle과 함께 만든 platform별 최종 source map을 고른 뒤 원본 파일·줄·함수로 되돌린다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

이 글에서 build는 원본 코드를 실행·배포 가능한 결과로 만드는 작업과 그 한 번의 결과를 함께 가리킨다. JavaScript engine은 JavaScript를 실행하는 도구이며, stack frame은 stack에 기록된 호출 한 항목이다. commit은 소스 변경 기록이고, 해시값은 파일 내용이 같은지 비교하는 짧은 지문 값이다.

릴리스는 앱 버전을 사용자가 이용할 수 있게 공개하는 일이다. React Native 도구가 쓰는 release는 사용자 공개 행위가 아니라 최적화된 배포용 build configuration 이름이므로 코드·명령에서는 backtick으로 구분한다. 사용자에게 공개된 특정 결과는 공개된 build 417, 그 파일 묶음은 build 417 산출물이라고 쓴다.

React Native release 빌드 앱의 JavaScript stack은 p@1:132161처럼 축소된 함수 이름과 생성 코드 위치만 보여 줄 수 있다. source map은 이 위치를 처음 작성한 TypeScript·TSX 파일의 줄·열에 연결하고, metro-symbolicate는 stack과 map을 읽어 사람이 조사할 위치로 바꾸는 명령 도구다.

핵심은 map 파일이 “있다”가 아니라 “실행된 bundle과 함께 만들어진 정확한 map이다”라는 점이다.

오류 원본       platform + app build + JavaScript update + engine + raw stack
build 산출물    실행 bundle + 최종 source map + commit + 두 파일 해시값
선택 조건       오류 원본과 산출물 기록의 모든 값 일치
복원           metro-symbolicate에 raw stack과 해당 map 입력
검증           오류 메시지·호출 순서·공개 build 소스와 복원 위치 대조

JavaScript update는 앱 설치 뒤 별도 JavaScript 배포 체계를 쓸 때 어느 update가 실행됐는지 구분하는 값이며, 그런 체계가 없으면 기록하지 않는다.

반복해서 기억할 문장은 이것이다.

stack의 build를 먼저 식별하고, 그 build와 함께 만든 최종 map으로만 원본 위치를 복원한다.

이 글을 관통하는 상황: 결제 오류가 ProfileScreen으로 복원됐다

운영 Android 앱 2.4.1(417)에서 다음 raw JavaScript stack이 수집됐다. raw는 도구로 복원하기 전의 원본 입력이라는 뜻이다.

Error: receipt total must be positive
    at h (index.android.bundle:1:132161)
    at p (index.android.bundle:1:131854)

팀은 공개된 build 417의 map을 찾지 못해 현재 main에서 build 418 map을 새로 만들었다. 명령은 성공했고 첫 프레임이 src/profile/ProfileScreen.tsx:88로 나왔다. 파일 이름이 그럴듯해 그 화면 코드를 수정했지만 오류는 사라지지 않았다.

실제 build 417 map을 찾은 뒤 같은 raw stack은 src/checkout/checkout-total.ts:17requirePositiveTotal로 복원됐다.

이 글의 질문은 하나다.

읽기 어려운 release 빌드 JavaScript stack을 정확한 원본 파일·함수·줄로 어떻게 되돌리는가?

release 빌드 stack은 생성 코드 위치를 기록한다

Hermes는 React Native에 기본 포함된 JavaScript 실행 엔진이다. release build에서는 JavaScript를 Hermes가 실행하기 좋은 bytecode로 미리 바꿀 수 있다. bytecode는 엔진이 실행할 명령 형식이고, 그 위치 1:132161은 작성한 TSX의 132,161번째 줄이라는 뜻이 아니다.

metadata는 파일에 덧붙인 설명 정보다.

위치 변환은 다음처럼 읽는다.

generated position     index.android.bundle:1:132161
source map lookup      이 생성 위치에 대응하는 원본 위치 검색
original position      src/checkout/checkout-total.ts:17:9
original name          requirePositiveTotal

generated position은 빌드가 만든 코드의 위치, original position은 처음 작성한 파일의 위치다. map encoding은 위치 대응을 파일에 저장한 형식이다. metro-symbolicate가 이 형식과 줄·열 기준 차이를 처리하므로 사람이 숫자에 1을 더하거나 빼서 맞추지 않는다.

현재 main map이 그럴듯한 오답을 만든다

먼저 map 선택 실패를 같은 화면에서 재현한다. 2편에서 이어 쓴 React Native 0.86 앱의 App.tsx를 다음 완결 코드로 교체한다. 이 fixture는 실제 Metro source map을 해석하지 않는다. 같은 generated position도 서로 다른 build map에서 다른 원본 위치로 연결된다는 선택 규칙만 반복 검증한다.

ENFORCE_ARTIFACT_MATCH = false가 잘못된 기준선이다.

import React, { useState } from 'react'
import { Button, SafeAreaView, StyleSheet, Text, View } from 'react-native'
 
const ENFORCE_ARTIFACT_MATCH = false
 
type Platform = 'android' | 'ios'
type Engine = 'hermes'
 
type RawStack = {
  appBuild: string
  platform: Platform
  engine: Engine
  bundleHash: string
  frames: string[]
}
 
type MapArtifact = {
  id: string
  appBuild: string
  platform: Platform
  engine: Engine
  bundleHash: string
  mappings: Record<string, string>
}
 
type LookupResult = {
  mapId: string
  artifactMatch: boolean
  locations: string[]
  outcome: 'PASS' | 'FAIL'
}
 
const RAW_STACK: RawStack = {
  appBuild: '2.4.1(417)',
  platform: 'android',
  engine: 'hermes',
  bundleHash: 'sha256:4170417041704170417041704170417041704170417041704170417041704170',
  frames: ['1:132161', '1:131854'],
}
 
const MAPS: MapArtifact[] = [
  {
    id: 'current-main-build-418',
    appBuild: '2.4.2(418)',
    platform: 'android',
    engine: 'hermes',
    bundleHash: 'sha256:4180418041804180418041804180418041804180418041804180418041804180',
    mappings: {
      '1:132161': 'src/profile/ProfileScreen.tsx:88:12',
      '1:131854': 'src/profile/useProfile.ts:41:7',
    },
  },
  {
    id: 'published-build-417',
    appBuild: '2.4.1(417)',
    platform: 'android',
    engine: 'hermes',
    bundleHash: 'sha256:4170417041704170417041704170417041704170417041704170417041704170',
    mappings: {
      '1:132161': 'src/checkout/checkout-total.ts:17:9',
      '1:131854': 'src/checkout/submit-receipt.ts:52:5',
    },
  },
]
 
function matchesStack(stack: RawStack, map: MapArtifact) {
  return (
    stack.appBuild === map.appBuild &&
    stack.platform === map.platform &&
    stack.engine === map.engine &&
    stack.bundleHash === map.bundleHash
  )
}
 
function runLookup(): LookupResult {
  const selectedMap = ENFORCE_ARTIFACT_MATCH
    ? MAPS.find(map => matchesStack(RAW_STACK, map))
    : MAPS[0]
 
  if (!selectedMap) {
    return {
      mapId: '없음',
      artifactMatch: false,
      locations: [],
      outcome: 'FAIL',
    }
  }
 
  const artifactMatch = matchesStack(RAW_STACK, selectedMap)
 
  return {
    mapId: selectedMap.id,
    artifactMatch,
    locations: RAW_STACK.frames.map(
      frame => selectedMap.mappings[frame] ?? `미복원:${frame}`,
    ),
    outcome: artifactMatch ? 'PASS' : 'FAIL',
  }
}
 
export default function App() {
  const [result, setResult] = useState<LookupResult | null>(null)
 
  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.title}>JavaScript stack map 선택 fixture</Text>
      <Text>artifact match 검사: {ENFORCE_ARTIFACT_MATCH ? 'on' : 'off'}</Text>
      <Button title="raw stack 복원" onPress={() => setResult(runLookup())} />
 
      {result ? (
        <View style={styles.result}>
          <Text>선택 map: {result.mapId}</Text>
          <Text>산출물 일치: {String(result.artifactMatch)}</Text>
          {result.locations.map((location, index) => (
            <Text key={`${index}:${location}`}>frame {index + 1}: {location}</Text>
          ))}
          <Text>결과: {result.outcome}</Text>
        </View>
      ) : null}
    </SafeAreaView>
  )
}
 
const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 12 },
  title: { fontSize: 22, fontWeight: '700' },
  result: { borderWidth: 1, borderColor: '#8A8A8A', padding: 10, gap: 4 },
})

실행 전에 예측한다.

  1. raw stack의 build와 선택 map build가 다른데도 원본 파일 이름이 나올까?
  2. 도구가 파일 위치를 출력했다는 사실만으로 map 일치를 증명할 수 있을까?
  3. platform과 engine이 같아도 bundle hash가 다르면 같은 map을 써도 될까?

개발자 메뉴에서 Reload한 뒤 raw stack 복원을 누른다.

선택 map: current-main-build-418
산출물 일치: false
frame 1: src/profile/ProfileScreen.tsx:88:12
frame 2: src/profile/useProfile.ts:41:7
결과: FAIL

파일과 줄이 나왔지만 실패다. fixture가 잘못된 map에도 해당 generated position의 가상 대응값을 넣었기 때문이다. 실제 도구도 map이 stack과 같은 build인지 대신 증명하지 않는다. 잘못된 map은 실패하거나, 미복원 위치를 남기거나, 더 위험하게는 그럴듯한 다른 위치를 낼 수 있다.

key는 함께 찾아야 하는 값을 묶은 식별 조건이다. commit은 소스 변경 기록이고 offset은 파일·bytecode 안에서 특정 위치까지의 거리다.

먼저 오류 원본과 map 산출물의 key를 맞춘다

build configuration은 debug·release처럼 build 동작을 고르는 설정 묶음이다. 다음 최소 기록을 공개 build 산출물과 함께 보관한다.

app version / build identifier
platform / build configuration
React Native / JavaScript engine
commit / JavaScript update ID(사용할 때만)
bundle path / SHA-256 hash
final source map path / SHA-256 hash
source map generation status

SHA-256은 파일 내용을 고정 길이 해시값으로 만드는 표준 방식이다. 해시값은 파일이 같은지 확인하지만 누가 만들었는지나 안전한지까지 증명하지는 않는다. 공개 build 목록에는 map이 빠진 상태도 missing으로 명시해 나중에 현재 소스에서 임의 생성하지 않게 한다.

상수를 바꾼다.

const ENFORCE_ARTIFACT_MATCH = true

Reload 뒤 같은 버튼을 누른다.

선택 map: published-build-417
산출물 일치: true
frame 1: src/checkout/checkout-total.ts:17:9
frame 2: src/checkout/submit-receipt.ts:52:5
결과: PASS

platform별 최종 map을 공개 build와 함께 보관한다

flag는 명령 동작을 고르는 설정값이고 build phase는 Xcode가 build 중 정해진 script를 실행하는 단계다. build log는 build 명령이 남긴 실행 단계와 산출물 경로 기록이다.

두 platform 모두 실제 release build log에 찍힌 bundle과 source map 출력 위치를 기록한다. 이 절의 명령은 앞의 가상 1:132161을 실제 requirePositiveTotal로 복원하는 결정적 fixture가 아니다. 공개 앱에서 수집한 raw stack과 그 build가 실제로 만든 산출물을 연결하는 절차 템플릿이다.

Android의 android/app/build.gradle에서 source map flag를 확인한다.

react {
    hermesFlags = ["-O", "-output-source-map"]
}

앱 루트에서 release configuration으로 build한다.

npm run android -- --mode="release"

React Native 공식 예제의 최종 Android map 경로와 bundle 경로를 기록하고 해시값을 계산한다.

ANDROID_BUNDLE=android/app/build/generated/assets/react/release/index.android.bundle
ANDROID_MAP=android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
shasum -a 256 "$ANDROID_BUNDLE" "$ANDROID_MAP"

build log에는 intermediates/...packager.map처럼 중간 map도 보일 수 있다. Hermes release 빌드 stack에는 공식 metro-symbolicate 예제가 가리키는 generated/sourcemaps/...bundle.map 최종 map을 쓴다. 실제 경로가 다르면 현재 build log와 사용 중인 React Native 버전 문서에서 최종 산출물 경로를 다시 확인한다.

iOS에서 target은 한 앱을 어떤 설정으로 build할지 정한 대상이고, Archive는 배포용 build 결과를 모은 묶음이다. Xcode에서 앱 target을 고른 뒤 Build PhasesBundle React Native code and images를 열고, 다른 export보다 위에 다음 줄을 둔다. 이 줄은 터미널에서 실행하는 명령이 아니라 Xcode build phase에 저장하는 설정이다.

export SOURCEMAP_FILE="$(pwd)/../main.jsbundle.map"

Xcode에서 release configuration의 Archive를 만들고 build log의 bundle·map 위치를 보관한다. 위 예제 설정을 그대로 썼다면 앱 루트의 main.jsbundle.map이 생성됐는지 확인하고 Archive의 build identifier·commit과 함께 제한된 공개 build 산출물 저장소로 옮긴다. Android map을 iOS stack에 쓰거나 Simulator map을 실기기 Archive stack에 쓰지 않는다.

raw stack과 정확한 map을 metro-symbolicate에 입력한다

shell은 터미널 명령을 해석해 실행하는 프로그램이다. shell 명령의 <는 파일 내용을 명령 입력으로 전달하고, pipe 기호 |는 앞 명령의 출력을 뒤 명령의 입력으로 전달한다.

Android stack을 stacktrace.txt에 저장하고 앱 루트에서 실행한다.

ANDROID_MAP=android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
npx metro-symbolicate "$ANDROID_MAP" < stacktrace.txt

Android Debug Bridge(ADB)는 연결된 Android 기기·에뮬레이터에 명령을 보내는 도구이고, logcat은 그 기기의 로그를 읽는 명령이다. Android 개발 재현의 전체 adb logcat 출력을 직접 pipe로 넘기는 공식 방식도 있다.

adb logcat -d | npx metro-symbolicate "$ANDROID_MAP"

metro-symbolicate가 즉시 성공 종료하면서 출력이 없다면 터미널에서 stack을 직접 타이핑하지 말고 공식 안내처럼 pipe나 파일 입력을 사용했는지 확인한다.

iOS에서 보관한 stack도 같은 명령과 해당 Archive의 map을 사용한다. 아래 예시는 stacktrace.txtmain.jsbundle.map을 앱 루트에 보관한 뒤 앱 루트에서 실행한다.

IOS_MAP=main.jsbundle.map
npx metro-symbolicate "$IOS_MAP" < stacktrace.txt

Android 로그의 com.facebook.react.common.JavascriptException은 React Native가 JavaScript 오류를 Android 예외로 담아 표시한 범주다. native address는 Android·iOS 기계 코드 안의 위치다.

이 운영 절차에서 성공은 출력의 각 JavaScript frame이 실제 오류가 난 공개 build commit의 원본 파일·줄·열과 가능한 함수 이름으로 바뀌고, 오류 message와 호출 순서가 그 소스와 맞는 것이다. 줄 번호는 제품 build마다 달라지므로 앞의 가상 checkout-total.ts:17을 고정 기대값으로 사용하지 않는다. 수집한 stack의 JavaScript frame만 이 map으로 복원한다. Android Java·Kotlin·C++ frame은 26편, iOS native address는 27편의 platform 기호 산출물과 도구가 맡는다.

복원된 위치를 오류 문맥과 다시 대조한다

symbolication 성공은 조사 가능한 위치를 만든 것이지 원인을 확정한 것이 아니다. 다음을 함께 확인한다. cluster는 같은 원인 후보의 오류 보고서를 묶은 단위다.

오류 message          requirePositiveTotal의 입력 조건과 맞는가
호출 순서             submitReceipt → requirePositiveTotal 흐름과 맞는가
공개 build source      build 417 commit의 실제 17행인가
제품 단계             비민감 마지막 단계가 receipt-submit인가
재현                   같은 공개 build 입력에서 같은 오류 범주가 생기는가
교정 검증              새 공개 build에서 같은 raw 오류 cluster가 사라지는가

message는 오류가 담은 설명 문자열이다. source map에는 오류 당시 변수 값, server 응답, 사용자 행동과 운영체제가 각 실행 흐름을 언제 실행하도록 정했는지가 들어 있지 않다. token, 결제 정보와 사용자 입력을 stack 주변 log에 추가하지 않는다.

복원된 줄이 빈 줄이거나 전혀 관계없는 함수면 코드를 추측해 고치기 전에 map key, final map 여부와 raw stack 형식을 다시 확인한다.

source map을 제한된 진단 산출물로 다룬다

따라서 map을 앱 사용자에게 내려받게 하거나 누구나 읽는 URL에 두는 것을 기본값으로 삼지 않는다.

접근        공개 build 진단 담당자와 자동 symbolication 작업으로 제한
보존        지원 중인 공개 버전과 오류 보존 기간에 맞춤
무결성      build 기록의 map hash와 다운로드한 파일 hash 비교
삭제        공개 버전 지원 종료와 법적·보안 정책에 따라 수행
민감 정보   source에 token·secret을 넣지 않고 sourcesContent 포함 여부 검사

source map 접근 제한은 앱에 secret을 하드코딩해도 된다는 뜻이 아니다. 배포 bundle에 들어간 비밀값은 map이 없어도 추출될 수 있다.

pull request에서 달라져야 할 행동

공개 build의 JavaScript 오류를 다루는 pull request에는 다음 표가 있어야 한다.

오류 원본       platform·app build·engine·update ID·raw stack을 보존했는가
bundle          실행된 파일 위치와 hash를 기록했는가
source map      같은 build의 final map 위치와 hash를 기록했는가
생성 설정       Android hermesFlags와 iOS SOURCEMAP_FILE을 확인했는가
선택 검증       stack key와 map key가 모두 맞지 않으면 복원을 차단하는가
도구 결과       metro-symbolicate 출력과 오류 message·호출 순서를 대조했는가
보안            map 접근·보존·삭제와 sourcesContent 정책이 있는가
platform 경계   JavaScript frame과 Android·iOS native frame을 나눴는가

검토자는 다음을 확인한다.

  • 현재 main에서 새로 만든 map을 과거 공개 build stack에 쓰지 않는가?
  • Android map과 iOS map, debug map과 release map을 섞지 않는가?
  • 중간 packager map을 Hermes 최종 map으로 오인하지 않는가?
  • 파일 이름이 출력됐다는 이유만으로 artifact match를 생략하지 않는가?
  • build identifier만 적고 bundle·map hash와 commit을 잃지 않는가?
  • symbolication 결과를 오류 원인 자체로 단정하지 않는가?
  • source map을 공개 저장소나 무제한 URL에 두지 않는가?
  • JavaScript map으로 native frame까지 복원하려 하지 않는가?

한 장으로 다시 보기

문제       build 417 JavaScript stack이 읽기 어려움
오판       현재 main의 build 418 map으로 복원해 ProfileScreen을 원인으로 선택
실패       파일·줄은 나오지만 bundle hash와 build가 달라 artifact match=false
교정       platform·build·engine·bundle hash가 모두 맞는 final map 선택
결과       checkout-total.ts:17 requirePositiveTotal로 복원
생성       Android 기본 map 확인, iOS SOURCEMAP_FILE 명시
도구       raw stack + exact map → metro-symbolicate
검증       공개 build source·오류 message·호출 순서·재현과 대조
보안       sourcesContent 가능성을 보고 map 접근·보존 제한
경계       Android·iOS native stack 복원은 26·27편으로 분리

JavaScript stack 복원은 raw 숫자를 읽는 요령이 아니다. 오류가 난 공개 build 산출물을 먼저 식별하고, 그 산출물과 함께 만들어 보관한 final source map을 선택하는 작업이다. 정확한 위치를 얻은 뒤에야 오류 문맥과 재현을 대조해 수정할 코드를 고른다.

스스로 확인할 질문

  1. 현재 main source map이 과거 공개 build stack에 그럴듯한 오답을 낼 수 있는 이유는 무엇인가?
  2. stack과 map 사이에 platform·build·engine·bundle hash를 왜 모두 맞춰야 하는가?
  3. Android와 iOS에서 release build source map 생성을 어떻게 다르게 확인하는가?
  4. 중간 packager map과 Hermes 최종 map을 왜 구분해야 하는가?
  5. symbolication 뒤에도 오류 message·호출 순서·공개 build source를 다시 보는 이유는 무엇인가?
  6. source map을 제한된 진단 산출물로 다뤄야 하는 이유는 무엇인가?

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01운영 stack의 앱 버전과 raw 위치가 보이면 현재 main branch에서 새 source map을 만들어 복원해도 될까?

    정답

    아니다. 실제로 실행된 platform·build·bundle과 함께 생성한 최종 source map을 사용해야 한다. 작은 코드 변경도 생성 위치를 크게 바꿀 수 있다.

    왜 그런가

    source map은 모든 코드에 공통인 사전이 아니라 특정 생성 코드 위치와 원본 위치의 대응표다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02Hermes `release` 빌드 stack에 Metro가 만든 중간 packager map 하나만 있으면 항상 원본 TSX까지 복원할 수 있을까?

    정답

    아니다. 빌드가 여러 변환 map을 만들면 실제 실행된 최종 bundle·bytecode 위치까지 연결해 만든 platform 최종 map을 사용해야 한다.

    왜 그런가

    React Native 문서도 여러 map이 생성될 수 있으므로 공식 예제 위치의 최종 map을 쓰라고 경고한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03원본 파일·함수·줄이 복원되면 오류 당시 변수 값과 사용자 행동도 source map에서 알 수 있을까?

    정답

    아니다. source map은 생성 위치를 원본 위치에 연결한다. 당시 값과 행동은 별도 비민감 사건 기록·재현 증거가 필요하다.

    왜 그런가

    위치 복원과 원인 설명은 서로 다른 단계다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04source map은 앱에서 실행하지 않는 파일이므로 공개 저장소나 누구나 읽는 URL에 올려도 안전할까?

    정답

    자동으로 안전하지 않다. map에는 원본 파일 이름과 선택적으로 원본 코드 전체가 들어갈 수 있으므로 접근·보존·삭제 정책을 둔다.

    왜 그런가

    진단 산출물도 공개 범위를 별도로 결정해야 한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. Debugging Release BuildsReact Native · 공식 문서 · 확인 2026-08-09
  2. Using HermesReact Native · 공식 문서 · 확인 2026-08-09
  3. Source Map FormatMetro · 공식 문서 · 확인 2026-08-09
  4. Metro CLI OptionsMetro · 공식 문서 · 확인 2026-08-09
  5. ECMA-426 — Source map format specificationEcma International · 표준 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.