GitHub - devlasbe/react-native-otel-example: React Native 애플리케이션 OpenTelemetry 적용 템플릿
React Native 애플리케이션 OpenTelemetry 적용 템플릿. Contribute to devlasbe/react-native-otel-example development by creating an account on GitHub.
github.com
📒 React Native에서 OpenTelemetry 사용하기, OTEL Web SDK로 구현해보자
React Native 앱의 옵저빌리티(Observability)를 높이고 싶어서 OpenTelemetry(OTEL)를 찾아봤습니다.
문서도 읽고, SDK 목록도 살펴봤는데, 없습니다. RN 전용 SDK가.
Web SDK는 있고, Node SDK는 있고, iOS/Android 네이티브 SDK는 있는데, React Native를 위한 JavaScript SDK는 공식적으로 존재하지 않습니다.
그래서 Web SDK를 RN 위에서 돌리기로 했습니다.
생각보다 잘 됩니다. 단, metro 번들러가 협조하기 전까지는요.
환경 정보
- React Native 0.80.2
- @opentelemetry/sdk-trace-web 2.0.0
- @opentelemetry/instrumentation-fetch 0.200.0
- @opentelemetry/exporter-trace-otlp-http 0.200.0
- @opentelemetry/resources 2.0.1
📌 왜 옵저빌리티인가, 왜 OTEL인가
앱이 죽으면 압니다. Firebase Crashlytics가 알려주니까요.
그런데 "죽기 전에 뭘 하고 있었는지"는 모릅니다.
API 응답이 느린 건지, 어떤 화면에서 사용자가 이탈했는지, 특정 기능이 실제로 얼마나 자주 호출되는지, 이런 것들은 크래시 로그로 잡히지 않습니다.
옵저빌리티가 필요했던 이유입니다.
단순한 에러 추적을 넘어, 앱 내부에서 무슨 일이 벌어지는지 "보고" 싶었습니다.
OpenTelemetry를 선택한 이유는 간단합니다.
벤더 중립적인 표준이라, 시장의 주요 모니터링 서비스들이 OTEL을 지원합니다.
덕분에 어떤 서비스를 쓰더라도 SDK 코드를 그대로 유지하면서 모니터링 툴만 설정으로 교체할 수 있습니다.
문제는 RN 공식 SDK가 없다는 점이었습니다.
CNCF 프로젝트 페이지에 JavaScript SDK는 있지만, 이건 브라우저와 Node 환경용입니다.
RN은 둘 다 아닙니다. 정확히는 둘 다의 중간 어딘가죠.
📌 OTEL 핵심 개념
SDK를 직접 만들기 전에 핵심 개념 세 가지를 짚고 넘어가겠습니다.
🔎 Trace와 Span (생명주기)
Trace는 하나의 요청이 시스템을 통과하는 전체 여정입니다.
Span은 그 여정의 각 단계입니다.
API 호출 하나, 함수 실행 하나가 각각 하나의 Span입니다.
Span은 시작(startSpan)과 종료(span.end())가 명시적으로 있습니다.
그 사이에 attributes를 붙이거나, 에러를 기록하거나, 상태를 설정합니다.
종료되지 않은 Span은 OTLP 엔드포인트로 전송되지 않으니, end()를 빠뜨리지 않는 게 중요합니다.
🔎 Resource: 내 앱이 누구인지 알려주는 메타데이터
Resource는 "이 Trace를 보낸 게 어떤 서비스냐"를 나타내는 메타데이터입니다.
service.name, service.version, os.name 같은 속성들로 구성됩니다.
모니터링 툴에서 필터링할 때 이 Resource 정보가 기준이 됩니다.
제대로 설정해두지 않으면 어느 서비스에서 온 Trace인지 구분이 안 됩니다.
🔎 SpanProcessor와 Exporter의 관계
Exporter는 Span 데이터를 어디로 보낼지를 담당합니다.
콘솔에 출력하거나(ConsoleSpanExporter), HTTP로 OTLP 엔드포인트에 전송합니다(OTLPTraceExporter).
SpanProcessor는 Exporter를 감싸는 래퍼입니다.
BatchSpanProcessor는 Span을 모아서 일괄 전송하고, SimpleSpanProcessor는 즉시 전송합니다.
📌 Web SDK로 RN SDK 만들기
🔎 패키지 구성
pnpm add @opentelemetry/sdk-trace-web@2.0.0 \
@opentelemetry/resources@2.0.1 \
@opentelemetry/exporter-trace-otlp-http@0.200.0 \
@opentelemetry/instrumentation@0.203.0 \
@opentelemetry/instrumentation-fetch@0.200.0 \
@opentelemetry/otlp-exporter-base@0.200.0 \
@opentelemetry/api
sdk-trace-web을 RN에서 쓴다는 게 핵심입니다.
RN의 JS 런타임은 Hermes 또는 JSC인데, 브라우저처럼 fetch나 XMLHttpRequest가 글로벌로 있어서 Web SDK가 동작합니다.
단, RN의 fetch는 진짜 브라우저 fetch가 아니라 whatwg-fetch polyfill → XHR → Native(NSURLSession/OkHttp) 로 이어지는 구조라, Web SDK 입장에서는 브라우저 환경처럼 보이지만 실제 네트워크는 각 OS의 네트워크 스택이 처리합니다.
웹이랑 완전히 동일하지 않아 완벽하진 않지만, 대부분의 기능은 별도 설정없이 작동합니다.
🔎 OptlConfig 인터페이스
설정 인터페이스부터 정의합니다.
interface OptlConfig {
url: string; // OTLP 엔드포인트 URL
accessToken: string; // 인증 토큰
serviceName: string; // 모니터링 툴에서 식별할 서비스명
serviceVersion: string;
}
🔎 SpanProcessor 이중 설정 (Console + OTLP)
개발 중에는 콘솔에서 바로 확인하고, 프로덕션에서는 OTLP 엔드포인트로 전송해야 합니다.
spanProcessors 배열에 둘 다 등록하면 됩니다.
const logSpanProcessor = new BatchSpanProcessor(new ConsoleSpanExporter());
const otlpSpanProcessor = new BatchSpanProcessor(
new OTLPTraceExporter({
url: config.url,
headers: {
"Content-Type": "application/json",
Authorization: config.accessToken,
},
}),
);
tracerProvider = new WebTracerProvider({
resource,
spanProcessors: [logSpanProcessor, otlpSpanProcessor],
});
배열에 여러 SpanProcessor를 넣으면 Span이 끝날 때 등록된 순서대로 모두 실행됩니다.
🔎 FetchInstrumentation으로 자동 추적
fetch 호출을 일일이 Span으로 감싸는 건 현실적이지 않습니다.
FetchInstrumentation을 등록하면 모든 fetch 호출이 자동으로 Span으로 추적됩니다.
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: /.*/, // 모든 도메인에 tracecontext 헤더 전파
clearTimingResources: false,
applyCustomAttributesOnSpan: (span, request, result) => {
if (!(result as Response).ok) {
span.setStatus({
code: api.SpanStatusCode.ERROR,
message: `${(result as Response).status} ${(result as Response).url}`,
});
}
},
}),
],
});
applyCustomAttributesOnSpan 콜백에서 HTTP 에러를 잡아 Span에 에러 상태를 기록합니다.
이렇게 하면 4xx/5xx 응답도 Trace에 남습니다.
🔎 Resource에 OS 정보 담기
import { Platform } from "react-native";
import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
const resource = defaultResource().merge(
resourceFromAttributes({
"service.name": config.serviceName,
"service.version": config.serviceVersion,
"deployment.environment.name": process.env.NODE_ENV,
"os.name": Platform.OS, // 'ios' | 'android'
"os.version": Platform.Version, // iOS: '17.0', Android: 33
}),
);
Platform.OS와 Platform.Version으로 iOS/Android를 구분합니다.
모니터링 툴에서 플랫폼별로 필터링할 때 이 값이 기준이 됩니다.
defaultResource()는 SDK가 자동으로 채워주는 기본 속성들(SDK 버전 등)이 담겨 있습니다.
.merge()로 커스텀 속성을 덧붙이는 방식입니다.
📌 가장 고통스러운 부분: metro.config.js
SDK 코드 자체는 어렵지 않았습니다. 진짜 문제는 번들링이었습니다.
🔎 왜 번들링이 깨지는가
metro를 기본 설정으로 두고 앱을 실행하면 두 가지 에러가 순서대로 납니다.
첫 번째
Unable to resolve module '@opentelemetry/otlp-exporter-base/browser-http'
None of these files exist:
* node_modules/@opentelemetry/otlp-exporter-base/browser-http(.ios.js|.native.js|.js|...)
@opentelemetry/otlp-exporter-base 패키지는 package.json의 exports 필드로 서브패스를 정의합니다.
Metro는 기본적으로 exports 필드를 무시하고 파일 시스템에서 직접 경로를 찾으려 합니다. 당연히 실패하죠.
두 번째
Unable to resolve module './semconvStability' from
'node_modules/@opentelemetry/instrumentation/build/src/...'
@opentelemetry/instrumentation 패키지 내부에서 상대 경로로 ./semconvStability를 import합니다.
Metro가 이 상대 경로를 패키지 내부 컨텍스트에서 올바르게 해석하지 못하는 버그입니다.
🔎 해결책: unstable_enablePackageExports + alias + resolveRequest
// metro.config.js
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const config = {
resolver: {
// 문제 2 해결: 상대 경로를 절대 경로로 강제 매핑
alias: {
"./semconvStability": require.resolve("@opentelemetry/instrumentation/build/src/semconvStability.js"),
},
// 문제 1 해결: browser-http 모듈만 exports 필드를 활성화해 해석
resolveRequest: (context, moduleImport, platform) => {
if (moduleImport === "@opentelemetry/otlp-exporter-base/browser-http") {
return context.resolveRequest({ ...context, unstable_enablePackageExports: true }, moduleImport, platform);
}
return context.resolveRequest(context, moduleImport, platform);
},
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
핵심 포인트는 두 가지입니다.
첫째, unstable_enablePackageExports를 전역으로 켜지 않고 해당 모듈에만 적용합니다.
전역으로 켜면 다른 RN 내부 패키지들이 깨질 수 있습니다.
둘째, alias는 Metro의 resolver 레벨에서 동작하기 때문에 패키지 내부의 상대 경로도 잡아낼 수 있습니다.
📌 실제 사용 예시
🔎 initialize() 호출 시점
App.tsx 최상단에서 모듈 스코프로 호출합니다.
React lifecycle 밖에서 실행되어야 FetchInstrumentation이 앱 시작부터 동작합니다.
// App.tsx
import optl from "./optl";
optl.initialize({
url: "YOUR_OTLP_ENDPOINT",
accessToken: "YOUR_ACCESS_TOKEN",
serviceName: "my-rn-app",
serviceVersion: "1.0.0",
});
🔎 startSpan / endSpan / recordError 사용법
// 수동 Span: 특정 로직의 실행 시간 추적
const handlePayment = async () => {
const span = optl.startSpan("payment.process", {
"payment.method": "card",
"cart.total": 15000,
});
try {
await processPayment();
optl.endSpan(span); // OK 상태로 종료
} catch (error) {
optl.endSpan(span, error); // ERROR 상태로 종료 + 예외 기록
}
};
// 에러 단독 기록
const handleError = (error: Error) => {
optl.recordError(error);
};
🔎 FetchInstrumentation 자동 추적
별도 코드 없이 fetch를 그냥 쓰면 됩니다.
// 이 호출이 자동으로 Span으로 추적됩니다
const response = await fetch("https://api.example.com/users");
모니터링 툴에서는 이렇게 찍힙니다.
GET https://api.example.com/users
duration: 234ms
http.status_code: 200
http.method: GET
service.name: my-rn-app
os.name: ios
404나 500 응답은 applyCustomAttributesOnSpan 콜백에서 ERROR 상태로 기록되니 에러 Trace도 따로 추적됩니다.
🔎 전역 JS 에러 자동 포착
HTTP 요청 외에 try/catch로 잡히지 않은 전역 JS 예외도 Trace로 남겨야 합니다.
RN은 ErrorUtils.setGlobalHandler()로 이를 처리할 수 있습니다.
const previousHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
this.recordError(error, { 'exception.is_fatal': String(isFatal) });
previousHandler?.(error, isFatal);
});
isFatal 플래그를 attribute로 함께 기록하면 "앱이 강제 종료된 에러"와 "그냥 넘어간 에러"를 구분할 수 있습니다.
기존 핸들러(previousHandler)를 체이닝하는 것도 중요한데, Sentry 같은 다른 에러 리포팅 도구가 이미 핸들러를 등록해뒀을 수 있거든요.
덮어쓰면 둘 중 하나가 에러를 못 받습니다.
🔎 ErrorBoundary로 React 렌더 에러 포착
문제는 ErrorUtils가 전부가 아니라는 점입니다.
React 컴포넌트 렌더 중 발생한 에러는 React가 내부적으로 먼저 처리하기 때문에 ErrorUtils 핸들러까지 도달하지 않습니다.
더 나쁜 건, ErrorBoundary 없이 렌더 에러가 나면 컴포넌트 트리 전체가 언마운트되고 JS 스레드가 중단됩니다.
이후 ErrorUtils가 호출되더라도 이미 복구 불가 상태인 경우가 생기죠.
componentDidCatch()에서 info.componentStack을 attribute로 기록하면 어느 컴포넌트에서 에러가 터졌는지 Trace로 추적할 수 있습니다.
import React from 'react';
import {Text, View} from 'react-native';
import optl from './optl';
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends React.Component<Props, State> {
state: State = {hasError: false};
componentDidCatch(error: Error, info: React.ErrorInfo) {
optl.recordError(error, {
'exception.type': 'ReactRenderError',
'react.component_stack': info.componentStack ?? '',
});
}
static getDerivedStateFromError(): State {
return {hasError: true};
}
render() {
if (this.state.hasError) {
return (
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Text>Something went wrong.</Text>
</View>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
JS 런타임 에러는 ErrorUtils가, React 렌더 에러는 ErrorBoundary가 각각 담당합니다.
두 레이어를 모두 커버해야 앱 에러를 빠짐없이 OTEL로 보낼 수 있습니다.
처음엔 "Web SDK를 RN에서 그냥 돌려도 되나?" 반신반의하며 시작했습니다.
그런데 모니터링 툴 화면에 Trace가 찍히는 순간, 처음으로 앱이 "보이는" 느낌을 받았습니다.
API 응답 시간이 숫자로 나오고, 어떤 화면에서 어떤 요청이 얼마나 걸렸는지가 한눈에 들어오는 경험이었습니다.
도입한지 몇 개월이 지난 지금, 이슈가 발생했을 때 어느 구간에서 문제가 생겼는지 빠르게 파악할 수 있게 되었고, 에러 패턴을 미리 감지해 선제적으로 대응할 수 있는 환경이 갖춰졌습니다.
툴에서 직접 제공하는 SDK를 사용해도 좋지만 직접 구현한다면 비용적인 면에서 메리트가 있으니 조심히 추천 드려봅니다.
'React-Native' 카테고리의 다른 글
| React Native, Web 크로스 플랫폼 디자인 시스템 Storybook 구축하기 (0) | 2026.04.11 |
|---|---|
| React Native Lazy Loading으로 앱 시작 속도 개선하기 (0) | 2025.05.19 |
| react-native-performance로 앱 시작 시간 측정하기 (0) | 2025.05.17 |
| react-native 버전 업그레이드 가이드, v0.69 to v0.78 (2) | 2025.04.19 |
| 2025년 React Native 현황과 CLI vs Expo 비교분석 (2) | 2025.01.15 |
오픈 채팅
댓글