React Native는 JavaScript 또는 TypeScript를 사용해 Android와 iOS 애플리케이션을 개발할 수 있는 프레임워크다. React의 컴포넌트 모델과 Hooks를 기반으로 하며, HTTP API와 WebSocket을 통해 백엔드 서버와 통신할 수 있다.
백엔드와 연동하는 클라이언트는 단순히 API를 호출하는 것만으로 충분하지 않다. 인증 토큰을 안전하게 관리하고, 네트워크 단절 이후 연결을 복구하며, 서버와 클라이언트의 상태를 일치시켜야 한다.
특히 실시간 게임 플랫폼에서는 다음과 같은 요소가 중요하다.
- 컴포넌트와 Hooks를 활용한 상태 관리
- HTTP API를 통한 데이터 조회 및 변경
- JWT와 Refresh Token을 이용한 인증 유지
- WebSocket 기반 실시간 통신
- 네트워크 오류와 타임아웃 처리
- 연결 복구 및 서버 상태 동기화
이 글에서는 React Native 클라이언트를 백엔드 서버에 안정적으로 연결하기 위해 필요한 개념과 설계 방법을 정리한다.
React 기본
컴포넌트 구조
React는 UI를 독립적인 컴포넌트로 분리해 구성한다. 각 컴포넌트는 화면의 특정 부분을 담당하며, 필요한 데이터와 이벤트를 전달받아 UI를 구성한다.
React Native에서는 웹의 HTML 요소 대신 View, Text, TextInput, Pressable 등의 네이티브 UI 컴포넌트를 사용한다.
import { View, Text, Pressable } from 'react-native';
type ProfileProps = {
nickname: string;
onLogout: () => void;
};
export function ProfileScreen({
nickname,
onLogout,
}: ProfileProps) {
return (
<View>
<Text>{nickname}</Text>
<Pressable onPress={onLogout}>
<Text>로그아웃</Text>
</Pressable>
</View>
);
}
컴포넌트를 설계할 때는 화면 표현과 비즈니스 로직을 분리하는 것이 좋다.
- 화면 컴포넌트: UI 렌더링과 사용자 입력 처리
- 커스텀 Hook: 인증 상태, 연결 상태 등의 재사용 가능한 로직
- API 모듈: HTTP 요청과 응답 처리
- 서비스 모듈: 인증 및 WebSocket 연결 관리
- 상태 저장소: 여러 화면에서 공유하는 애플리케이션 상태 관리
예를 들어 프로필 화면이 직접 토큰 갱신이나 WebSocket 재연결을 처리하면 화면 코드와 통신 로직이 강하게 결합된다. 이러한 로직을 별도 모듈로 분리하면 테스트와 유지보수가 쉬워진다.
Hooks
Hooks는 함수형 컴포넌트에서 상태와 생명주기 관련 동작을 관리하는 기능이다.
주요 Hooks는 다음과 같다.
useState: 컴포넌트 내부 상태 관리useEffect: 외부 시스템과의 동기화 및 부수 효과 처리useCallback: 함수 참조를 메모이제이션useMemo: 계산 결과를 메모이제이션useRef: 렌더링 사이에 유지되는 값을 저장
Hooks는 컴포넌트 또는 커스텀 Hook의 최상위에서 호출해야 한다. 조건문이나 반복문 안에서 호출하면 Hooks의 호출 순서가 달라질 수 있으므로 허용되지 않는다.
useState와 useEffect
useState는 UI에 영향을 주는 상태를 관리한다. 상태가 변경되면 React는 필요한 화면을 다시 렌더링한다.
import { useState } from 'react';
import { Text, Pressable } from 'react-native';
export function Counter() {
const [count, setCount] = useState(0);
return (
<Pressable onPress={() => setCount(value => value + 1)}>
<Text>Count: {count}</Text>
</Pressable>
);
}
useEffect는 컴포넌트와 외부 시스템을 동기화할 때 사용한다. 대표적인 예로 이벤트 구독, 타이머, 네트워크 연결, 앱 상태 변경 감지 등이 있다.
import { useEffect } from 'react';
useEffect(() => {
const subscription = subscribeToMessages(message => {
console.log(message);
});
return () => {
subscription.unsubscribe();
};
}, []);
Effect가 실행될 때 메시지 구독을 시작하고, 정리 단계에서 구독을 해제한다.
실제 WebSocket 연결을 구현할 때는 컴포넌트가 다시 렌더링될 때마다 불필요하게 연결을 생성하지 않도록 주의해야 한다. 연결 생성과 종료는 Effect의 생명주기에 맞추고, 연결 자체를 관리하는 책임은 별도의 서비스로 분리하는 편이 적절하다.
비동기 작업을 수행할 때는 컴포넌트가 사라진 뒤에도 작업이 진행되는 상황을 고려해야 한다. AbortController나 구독 해제 등의 방법으로 불필요한 작업을 취소할 수 있다.
useCallback과 useMemo
useCallback은 함수 참조를 유지하고, useMemo는 계산 결과를 재사용한다.
const handleRefresh = useCallback(() => {
refreshProfile();
}, [refreshProfile]);
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
이 기능들은 모든 함수와 계산에 무조건 적용해야 하는 최적화 도구가 아니다.
불필요하게 사용하면 코드 복잡도와 의존성 관리 비용이 증가한다. 실제로 자식 컴포넌트의 불필요한 렌더링이 발생하거나 계산 비용이 큰 경우에 적용하는 것이 좋다.
백엔드 통신
HTTP API 호출
React Native 클라이언트는 HTTP API를 통해 로그인, 프로필 조회, 게임 기록 저장, 매칭 요청 등의 작업을 수행할 수 있다.
fetch를 사용하면 별도의 HTTP 클라이언트 라이브러리 없이 요청을 보낼 수 있다.
async function getProfile(accessToken: string) {
const response = await fetch(
'https://api.example.com/profile',
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
HTTP 응답이 404나 500 등의 상태 코드를 반환하더라도 fetch가 자동으로 예외를 발생시키는 것은 아니다. 따라서 response.ok 또는 response.status를 직접 검사해야 한다.
실제 서비스에서는 다음과 같은 사항도 처리해야 한다.
- 네트워크 연결 실패
- 요청 타임아웃
- 인증 만료
- 서버 오류
- 응답 데이터 검증
- 요청 취소
- 중복 요청 방지
특히 결제, 포인트 정산, 경기 결과 저장처럼 중복 실행 시 문제가 발생하는 작업은 클라이언트의 재시도만으로 해결할 수 없다. 서버에서 멱등성 키나 트랜잭션 등의 방법으로 중복 처리를 방지해야 한다.
JWT 전달 및 갱신
JWT는 서버가 발급하는 토큰 형식으로, 클라이언트가 인증된 요청을 보낼 때 사용할 수 있다.
일반적인 인증 흐름은 다음과 같다.
- 사용자가 로그인 정보를 전송한다.
- 서버가 Access Token과 Refresh Token을 발급한다.
- 클라이언트가 Access Token을 API 요청에 포함한다.
- Access Token이 만료되면 Refresh Token으로 갱신을 요청한다.
- 서버가 토큰을 검증하고 새로운 토큰을 발급한다.
Access Token은 일반적으로 짧은 만료 시간을 설정하고, Refresh Token은 새로운 Access Token을 발급받는 용도로 사용한다.
토큰 갱신 과정에서는 여러 API 요청이 동시에 인증 만료 오류를 반환하는 상황을 고려해야 한다. 각 요청이 개별적으로 토큰 갱신을 시작하면 Refresh Token이 중복 사용되거나 갱신 요청이 충돌할 수 있다.
이를 방지하려면 토큰 갱신 작업을 하나로 통합할 수 있다.
let refreshPromise: Promise<string> | null = null;
async function getRefreshedToken(): Promise<string> {
if (!refreshPromise) {
refreshPromise = refreshAccessToken()
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
여러 요청이 동시에 토큰 갱신을 요구하더라도 같은 Promise를 기다리게 할 수 있다.
실제 구현에서는 갱신된 토큰을 안전하게 저장하고, 실패한 요청을 제한적으로 재시도하며, Refresh Token까지 만료되거나 폐기된 경우 로그아웃 상태로 전환해야 한다.
또한 모든 401 Unauthorized 응답이 Access Token 만료를 의미하지는 않는다. 서버가 정의한 오류 코드와 인증 정책을 기준으로 갱신 여부를 판단해야 한다.
Refresh Token 안전한 저장
React Native에서는 웹 브라우저의 localStorage를 네이티브 보안 저장소처럼 사용할 수 있다고 가정해서는 안 된다.
Refresh Token은 공격자가 획득하면 계정 탈취로 이어질 수 있는 민감한 정보다. 따라서 일반적인 애플리케이션 설정값과 다르게 취급해야 한다.
권장되는 접근 방식은 다음과 같다.
- iOS: Keychain 기반 보안 저장소 사용
- Android: Android Keystore를 활용하는 보안 저장소 사용
- 일반 설정값: 민감하지 않은 데이터에 한해 AsyncStorage 사용
- 로그: Access Token과 Refresh Token을 기록하지 않음
- 로그아웃: 저장된 토큰 삭제 및 서버 측 토큰 폐기 정책 적용
React Native에서는 Expo SecureStore나 적절한 네이티브 보안 저장소 라이브러리를 사용할 수 있다. 실제 보안 수준은 라이브러리의 구현, 운영체제 설정, 백업 정책 및 기기 보안 상태에 영향을 받는다.
Access Token도 민감한 인증 정보이므로 불필요하게 노출하지 않는 것이 좋다. 앱이 실행 중일 때 메모리에 보관하는 방식은 토큰을 평문으로 장기간 저장하는 방식보다 적절할 수 있지만, 메모리 사용만으로 토큰 탈취 위험이 완전히 사라지는 것은 아니다.
Refresh Token Rotation을 적용하면 갱신할 때마다 새로운 Refresh Token을 발급하고 이전 토큰을 폐기할 수 있다. 이 경우 클라이언트는 새 토큰을 안전하게 저장해야 하며, 갱신 도중 앱이 종료되거나 요청이 중복 실행되는 상황도 고려해야 한다.
WebSocket 연결 및 재연결
WebSocket은 클라이언트와 서버가 지속적인 연결을 유지하면서 양방향으로 메시지를 주고받는 통신 방식이다.
실시간 게임 서버에서는 매칭 상태, 방 참가, 게임 이벤트, 채팅, 플레이어 상태 변경 등의 기능에 활용할 수 있다.
const socket = new WebSocket(
'wss://api.example.com/realtime',
);
socket.onopen = () => {
console.log('WebSocket connected');
};
socket.onmessage = event => {
const message = JSON.parse(event.data);
console.log(message);
};
socket.onerror = error => {
console.error('WebSocket error', error);
};
socket.onclose = () => {
console.log('WebSocket disconnected');
};
이 코드는 기본적인 이벤트 처리를 보여주는 예제다. 운영 환경에서는 연결 생성뿐 아니라 인증, 메시지 검증, 재연결, 연결 종료, 타임아웃 등을 함께 설계해야 한다.
WebSocket 연결이 끊어지는 원인은 다양하다.
- 사용자의 네트워크 전환
- Wi-Fi 또는 모바일 데이터 연결 단절
- 서버 배포 및 재시작
- 로드 밸런서의 유휴 연결 제한
- 인증 만료
- 모바일 앱의 백그라운드 전환
재연결에는 지수 백오프와 무작위 지연을 적용할 수 있다. 연결이 반복해서 실패할수록 대기 시간을 늘리고, 무작위 지연을 추가해 다수의 클라이언트가 동시에 서버에 재접속하는 현상을 완화한다.
function getReconnectDelay(attempt: number) {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = Math.min(
maxDelay,
baseDelay * 2 ** attempt,
);
return Math.random() * exponentialDelay;
}
실제 연결 관리 코드에서는 최대 재시도 횟수, 연결 성공 시 시도 횟수 초기화, 앱의 활성 상태, 인증 갱신 여부, 사용자 로그아웃 여부를 함께 고려해야 한다.
또한 연결이 끊겼다고 해서 서버의 세션이나 게임 상태가 자동으로 복구되는 것은 아니다. 재연결은 전송 경로를 복구하는 과정이며, 애플리케이션 상태의 복구는 별도로 처리해야 한다.
서버 메시지 처리
WebSocket 메시지는 문자열이나 바이너리 데이터로 전달될 수 있다. JSON을 사용하는 경우 메시지 종류를 구분하는 프로토콜을 정의하면 클라이언트와 서버 사이의 통신을 명확하게 관리할 수 있다.
{
"type": "MATCH_FOUND",
"payload": {
"roomId": "room-example"
}
}
클라이언트는 type에 따라 적절한 처리 로직을 실행한다.
type ServerMessage =
| {
type: 'MATCH_FOUND';
payload: { roomId: string };
}
| {
type: 'MATCH_CANCELLED';
payload: { reason: string };
};
function handleServerMessage(message: ServerMessage) {
switch (message.type) {
case 'MATCH_FOUND':
console.log(message.payload.roomId);
break;
case 'MATCH_CANCELLED':
console.log(message.payload.reason);
break;
}
}
위 코드는 TypeScript의 정적 타입을 활용한 예제다. 네트워크에서 전달된 JSON이 실제로 이 타입을 만족한다고 보장되지는 않는다. 따라서 메시지를 파싱한 뒤 런타임 스키마 검증을 수행해야 한다.
메시지 프로토콜에는 다음 요소를 포함하는 것이 유용하다.
type: 메시지 종류payload: 실제 데이터requestId: 요청과 응답의 연결sequence: 순서가 중요한 이벤트의 순번timestamp: 이벤트 발생 시각version: 프로토콜 버전
모든 메시지에 이 필드가 필요한 것은 아니다. 메시지의 성격에 따라 필요한 정보를 선택해야 한다.
특히 실시간 게임에서는 메시지 순서가 뒤바뀌거나 중복 전달될 가능성을 고려해야 한다. 순서가 중요한 상태 변경에는 시퀀스 번호를 사용하고, 재접속 이후에는 서버가 제공하는 최신 상태를 기준으로 클라이언트 상태를 다시 구성하는 방식이 효과적이다.
네트워크 오류 및 연결 복구
네트워크 오류 및 타임아웃
모바일 환경에서는 네트워크 연결이 항상 안정적이지 않다. Wi-Fi에서 모바일 데이터로 전환되거나 신호가 약한 장소에 진입하면 요청이 지연되거나 실패할 수 있다.
클라이언트는 모든 요청이 성공한다는 가정을 버리고 오류 유형에 따라 대응해야 한다.
- 연결 실패: 네트워크 상태를 확인하고 재시도 여부를 판단한다.
- 타임아웃: 일정 시간 안에 응답이 없으면 요청을 중단한다.
- 인증 오류: 토큰 갱신 가능 여부를 판단한다.
- 서버 오류: 재시도 가능성과 사용자 안내를 구분한다.
- 잘못된 요청: 동일한 요청을 반복하지 않고 입력을 수정한다.
fetch에는 기본적인 요청 타임아웃 옵션이 없으므로 AbortController를 이용해 요청을 취소할 수 있다.
async function fetchWithTimeout(
url: string,
timeoutMs: number,
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
return await fetch(url, {
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}
이 함수는 요청이 지정된 시간 안에 완료되지 않으면 중단 신호를 전달한다. 실제 서비스에서는 타임아웃으로 발생한 AbortError와 다른 네트워크 오류를 구분해 처리하는 것이 좋다.
주의할 점은 클라이언트가 요청을 취소해도 서버가 이미 작업을 수행했을 수 있다는 것이다. 예를 들어 포인트 지급 요청의 응답을 받지 못했다고 해서 서버에서 포인트가 지급되지 않았다고 단정할 수 없다.
따라서 중요한 변경 작업은 멱등성 키, 작업 상태 조회 API, 트랜잭션 등의 서버 측 설계와 함께 사용해야 한다.
연결 복구와 상태 동기화
네트워크가 복구되어도 클라이언트와 서버의 상태가 자동으로 일치하는 것은 아니다.
예를 들어 클라이언트의 WebSocket 연결이 끊어진 동안 서버에서는 매칭이 완료되거나 방 상태가 변경될 수 있다. 연결이 복구된 뒤 이전 화면 상태를 그대로 유지하면 실제 서버 상태와 차이가 발생한다.
이를 해결하려면 연결 복구와 상태 동기화를 별개의 과정으로 설계해야 한다.
일반적인 복구 흐름은 다음과 같다.
- 네트워크 연결 상태를 감지한다.
- 기존 연결의 유효성을 확인한다.
- 필요한 경우 인증 토큰을 갱신한다.
- WebSocket 연결을 재수립한다.
- 서버에 세션 복구 또는 상태 조회를 요청한다.
- 서버의 최신 상태를 기준으로 클라이언트 상태를 갱신한다.
- 이후 실시간 이벤트 수신을 재개한다.
서버는 재접속한 클라이언트가 현재 상태를 복구할 수 있도록 세션 식별자, 재접속 토큰, 마지막으로 처리한 이벤트 순번 등의 정보를 활용할 수 있다.
단, 클라이언트가 전달한 세션 정보나 재접속 정보를 무조건 신뢰해서는 안 된다. 서버는 사용자의 인증 및 권한을 검증하고 해당 세션에 접근할 수 있는지 확인해야 한다.
이벤트 순번을 활용하면 마지막으로 처리한 이벤트 이후의 변경 사항을 전달하는 방식도 구현할 수 있다. 다만 서버가 해당 이벤트 기록을 보관하고 있어야 하며, 기록이 만료되었거나 복구할 수 없는 경우에는 전체 상태를 다시 조회해야 한다.
실시간 게임 플랫폼에서는 게임 상태를 무조건 클라이언트에서 복원하기보다 서버를 기준 상태로 삼는 것이 중요하다. 클라이언트는 화면 표시와 사용자 입력을 담당하고, 게임 결과나 매칭 상태처럼 신뢰성이 중요한 정보는 서버가 검증하고 관리해야 한다.
실무 적용 시 고려할 사항
인증과 연결 관리 분리
인증 서비스, HTTP API 클라이언트, WebSocket 연결 관리자를 분리한다. 이렇게 하면 토큰 갱신 정책이 변경되더라도 화면 코드와 실시간 통신 로직을 크게 수정하지 않아도 된다.
상태의 기준을 명확하게 설정
UI 상태, 인증 상태, 네트워크 상태, 서버 상태를 구분한다. 특히 클라이언트가 임시로 보관하는 데이터와 서버가 확정한 데이터를 구분해야 한다.
재시도 정책을 작업별로 구분
조회 요청과 상태 변경 요청은 같은 방식으로 재시도하면 안 된다. 조회는 비교적 안전하게 재시도할 수 있지만, 포인트 지급이나 결제 같은 작업은 중복 실행 방지 설계가 필요하다.
보안 정보의 노출 방지
토큰과 민감한 사용자 정보가 로그에 기록되지 않도록 한다. 서버와 클라이언트 모두 인증과 권한 검증을 수행하고, 필요한 데이터만 전송한다.
모바일 생명주기 고려
앱이 활성화되거나 백그라운드로 전환될 때 WebSocket과 타이머 등의 동작을 적절하게 조정한다. 앱이 다시 활성화되었다면 기존 연결이 유효한지 확인하고 필요한 경우 상태를 동기화한다.
장애 상황을 테스트
정상적인 네트워크 환경뿐 아니라 연결 단절, 토큰 만료, 서버 재시작, 앱 백그라운드 전환, 중복 요청 등의 상황을 테스트한다.