| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 프론트엔드
- 인증
- contextBridge
- 마이그레이션
- 온디바이스AI
- 도메인설계
- python
- Kotlin
- 상태관리
- JWT
- Vite
- playwright
- typescript
- 배포
- 크롤링
- php
- REST API
- 소셜로그인
- 아키텍처
- React
- electron
- sqlite
- 메일자동화
- javascript
- next.js
- 보안
- 백엔드
- FastAPI
- github actions
- Android
- Today
- Total
HM소프트
웹·앱 공용 풀스택 시스템 개발기 (3) — React Native 직원 앱과 운영 연동 본문

React Native와 Expo로 만든 직원 셀프서비스 앱이 공용 백엔드에 붙는 과정을 다룬다. 데모 모드를 교체 가능한 구현으로 만들어 화면 코드 변경 없이 운영 연동하고, 기기 바인딩 식별자 생성, SecureStore 토큰 보안 저장, axios 인터셉터의 .code vs .error 봉투 버그까지 정리한다.
3편: 같은 백엔드를 보는 직원 앱
시리즈 마지막이다. 1편에서 웹·앱 공용 백엔드를, 2편에서 관리자 웹의 알림 시스템을 다뤘다. 이번엔 현장 직원이 쓰는 모바일 앱이다. 관리자는 웹을, 직원은 이 앱을 쓰지만 둘 다 같은 백엔드 API(1편)를 본다. 앱은 출근·퇴근, 휴가 신청, 근태 조회, 내 정보·교육 이수 현황 같은 직원 셀프서비스에 집중한다.
핵심 메시지는 1편과 같다. 업무 규칙은 백엔드에 있으니, 앱은 화면만 만든다. 다만 모바일에는 모바일만의 숙제가 있었다 — 기기 바인딩, 토큰 보안 저장, 불안정한 현장 네트워크. 그리고 운영 백엔드 없이 화면을 먼저 완성하기 위한 데모 모드다. 이 글은 그 결정들을 순서대로 다룬다.
기술 스택 — React Native + Expo
직원 앱은 React Native + Expo로 만들었다. iOS·안드로이드를 하나의 TypeScript 코드베이스로 다루고, Expo로 빌드·배포·OTA 업데이트를 단순화했다. 1편 백엔드가 Next.js/TypeScript라, 앱도 TypeScript로 가면서 도메인 타입과 응답 봉투(envelope) 형태를 자연스럽게 공유할 수 있었다.

화면은 api 레이어만 호출하고, 그 아래에서 데모/운영이 갈린다. 화면은 자기가 가짜 데이터를 받는지 진짜 백엔드를 받는지 모른다 — 이 구조가 이번 편의 출발점이다.
데모 모드로 먼저 만들기 — 같은 인터페이스, 가짜 구현 → 진짜 구현
앱을 처음부터 운영 백엔드에 붙여 개발하면 느리고 위험하다. 실데이터를 건드리고, 인증·기기 바인딩까지 통과해야 화면 하나를 볼 수 있다. 백엔드가 아직 일부만 준비됐다면 더 막막하다. 그래서 데모 모드로 먼저 만들었다. 환경변수 EXPO_PUBLIC_DEMO로 켜지며, 가짜 데이터로 모든 화면과 흐름을 완성한 뒤 백엔드에 연결한다.
중요한 건 데모와 운영이 같은 함수 시그니처를 가진다는 점이다. 각 API 함수는 첫 줄에서 데모 여부만 보고 갈라진다.

데모 구현은 단순한 고정 응답이 아니라 런타임 상태를 가진 인메모리 모형이다. 출근을 누르면 현재 시각으로 체크인이 찍히고, 9시를 넘으면 지각 처리되고, 퇴근은 18시 이후면 18:00으로 자동 조정된다. 휴가를 신청하면 목록에 PENDING으로 추가되고, 승인된 휴가를 취소하면 잔여일이 복구된다. 즉 데모만으로도 업무 규칙의 동작을 그대로 체험할 수 있다.

delay()로 일부러 약간의 지연을 줘 실제 네트워크처럼 로딩 스피너가 도는 것까지 재현했다. 데모와 운영의 응답 모양이 정확히 같으니, 연동할 때 화면 코드는 한 줄도 안 바뀐다. 데모는 "백엔드의 흉내"가 아니라 "백엔드와 교체 가능한 구현"이라는 게 핵심이다. (이 발상은 2편 웹의 데이터 소스 seam과 똑같다 — 시리즈 전체를 관통하는 패턴이다.)
데모 모드는 개발 외에도 쓸모가 있었다. 로그인 화면이 데모일 때 사번·비밀번호를 미리 채워, 백엔드 없이도 앱을 열어 흐름을 보여줄 수 있다 — 시연이나 디자인 리뷰에서 유용했다.
운영 연동 — axios 인터셉터에 공통 처리를 모으다
화면이 데모로 완성된 뒤, EXPO_PUBLIC_API_URL을 운영 주소로 두고 DEMO_MODE를 끄면 진짜 백엔드에 붙는다. 여기서 1편의 "공용 백엔드"가 빛난다. 앱은 출근·휴가·근태 규칙을 다시 구현하지 않는다. 웹이 쓰는 것과 같은 규칙이 백엔드에 있으니, 앱은 그저 호출만 한다. "웹엔 있는데 앱엔 없는" 불일치가 구조적으로 생기지 않는다.
모바일에서 공통으로 처리해야 할 것(토큰 부착, 에러 표시, 인증 만료)은 axios 인터셉터 한 곳에 모았다. 화면마다 try/catch로 같은 토스트를 띄우는 중복을 없앤다.

인터셉터에서 잡은 실제 버그가 있다. 백엔드 봉투의 에러 코드 필드명은 error인데, 앱이 처음엔 .code로 읽었다. 그래서 DEVICE_MISMATCH 같은 코드가 전부 undefined가 되고, 로그인 에러가 죄다 "로그인 실패"로 뭉개졌다. 사용자는 "다른 기기에 등록된 계정"인지 "비밀번호 오류"인지 알 수 없었다. error를 우선 읽고 .code는 방어적 폴백으로 둬 고쳤다. 1편에서 응답 봉투를 통일한 이유가 여기서 증명된다 — 필드명 하나가 어긋나도 클라이언트 전체의 에러 표시가 무너진다.
401을 받으면 인터셉터가 저장된 인증을 지우고 onUnauthorized 콜백을 부른다. 이 콜백은 인증 컨텍스트에 연결돼 있어, 토큰이 만료되면 앱이 자동으로 로그인 화면으로 돌아간다.
기기 바인딩 — 1편의 절반이 앱에서 완성된다
1편에서 앱 토큰은 시그니처만이 아니라 기기 바인딩까지 검증한다고 했다. 한 직원 계정은 한 기기에만 묶인다. 그 절반(검증)은 백엔드에 있고, 나머지 절반(기기 식별자 생성·전송)이 앱에 있다.

안드로이드는 Android ID를, iOS는 vendor ID를 쓰되, 못 얻는 환경(에뮬레이터 등)을 위해 무작위 ID 폴백을 둔다. 한 번 정해진 식별자는 로컬에 캐시해 재설치 전까지 유지된다. 로그인할 때 이 deviceId와 플랫폼을 함께 보내면, 백엔드가 1편에서 본 바인딩 규칙을 적용한다 — 미바인딩이면 신규 생성, 같은 기기면 통과, 다른 기기면 DEVICE_MISMATCH(409), 관리자가 초기화(resetAt)했으면 재바인딩.

백엔드 에러 코드를 그대로 사용자 문구로 매핑한다. 앞의 .code 버그를 고친 뒤에야 이 매핑이 제대로 작동했다. "다른 기기에서 등록된 계정"이라는 정확한 안내는, 분실·기기 변경 시 관리자에게 무엇을 요청해야 하는지(기기 초기화)를 사용자가 알게 해 준다.
토큰을 안전하게 — SecureStore와 만료 자동 정리
모바일에서 토큰은 웹의 localStorage보다 보수적으로 다뤄야 한다. 그래서 토큰 본체는 SecureStore(OS 키체인/키스토어 암호화 저장)에, 만료 시각과 직원 정보 같은 비밀이 아닌 값은 AsyncStorage에 나눠 저장했다.

getToken()이 만료를 함께 검사해, 만료된 토큰은 읽는 시점에 스스로 정리된다. 그래서 인터셉터가 매번 굳이 만료를 확인하지 않아도, 만료된 토큰으로 요청이 나가는 일이 줄어든다. 앱 시작 시 인증 컨텍스트는 이 getToken()으로 세션 복원을 시도하고, 토큰이 살아 있으면 저장된 직원 정보로 곧장 홈 화면을 띄운다.
// src/hooks/useAuth.tsx — 시작 시 세션 복원, 401 시 자동 로그아웃
useEffect(() => {
(async () => {
const token = await storage.getToken(); // 만료면 내부에서 정리되어 null
if (token) setEmployee(await storage.getEmployee());
setReady(true);
})();
setUnauthorizedHandler(() => setEmployee(null)); // 인터셉터의 401 → 여기로 연결
}, []);
현장 네트워크와 모바일 UX
이 앱의 사용자는 책상이 아니라 현장에서 앱을 연다. 네트워크가 늘 좋은 건 아니다. 그래서 신경 쓴 것들:
- 네트워크 실패의 명확한 구분: 인터셉터에서
status가 없으면(요청 자체가 못 나감) "인터넷 연결을 확인하세요"를, 5xx면 "서버 오류"를 띄운다. 사용자가 "내 문제인지 서버 문제인지"를 알 수 있게 했다. - 시간 자동 조정의 투명성: 퇴근이 18:00으로 자동 조정되면, 원래 찍힌 시각(
originalCheckOut)을 함께 보관하고 화면에 "자동 조정됨"을 명시한다. 시스템이 값을 바꿨다는 사실을 숨기지 않는다. - 핵심 화면 압축: 직원이 자주 쓰는 것만 다섯 화면(홈/근태/휴가/내정보/로그인)으로 압축했다. 관리자 웹의 수십 개 메뉴를 앱에 욱여넣지 않았다. 앱과 웹은 같은 백엔드를 보지만 역할이 다르다 — 관리자는 전체를 운영하고, 직원은 자기 것을 본다.
홈 화면은 오늘 출퇴근 상태, 휴가 잔여일, 곧 만료되는 교육 이수 항목을 한 번에 보여준다. 만료 임박 교육은 남은 일수에 따라 경고(WARNING)/위험(DANGER)으로 색을 달리해, 직원이 스스로 재이수 시점을 챙기게 했다. 관리자가 일일이 독촉하지 않아도 되는 구조다.
정리하며

- React Native + Expo로 iOS·안드로이드 직원 앱을 하나의 TypeScript 코드베이스로 만들었다.
- 데모 모드 → 운영 연동: 데모를 "백엔드의 흉내"가 아니라 "교체 가능한 같은 구현"으로 만들어, 환경변수만 끄면 화면 코드 변경 없이 운영에 붙는다(2편 웹의 seam과 동일한 패턴).
- 공용 백엔드 덕에 앱은 화면만 만든다 — 출근·휴가·근태 규칙을 다시 구현하지 않으니 웹과의 불일치가 없다.
- 기기 바인딩은 앱(식별자 생성)과 백엔드(검증)가 절반씩 책임진다. 1편의 인증 설계가 앱에서 비로소 완성된다.
- 응답 봉투 통일의 가치가
.codevs.error버그로 증명됐다. 필드명 하나가 클라이언트 전체의 에러 표시를 좌우한다. - 토큰은 SecureStore에 암호화 저장 + 만료 자동 정리, 공통 처리(토큰 부착·에러·401)는 인터셉터 한 곳에 모았다.
시리즈를 마치며
세 편에 걸쳐 웹·앱 공용 풀스택 시스템(v2.0)을 정리했다.
- 1편: 공용 백엔드 API 분리 — 화면 없는 Next.js + Prisma + PostgreSQL + Redis, 그리고 웹/앱 두 인증 경로를 한 백엔드에.
- 2편: 알림 시스템 — 플러그인 detector 엔진, 그리고 클라이언트 필터의 한계를 넘은 서버사이드 필터·상태 이력·일괄 확인.
- 3편: React Native 직원 앱 — 데모 모드 우선 개발, 운영 연동, 기기 바인딩과 토큰 보안.
세 편을 관통하는 교훈은 하나다. 업무 규칙을 공용 백엔드 한 곳에 두면, 클라이언트(웹·앱)는 화면만 만들면 된다. 새 클라이언트를 붙여도 규칙을 다시 구현하지 않으니, 웹과 앱의 불일치가 구조적으로 사라진다. 그리고 "같은 인터페이스, 가짜 구현 → 진짜 구현"이라는 seam 패턴이 백엔드(테스트)·웹(mock 데이터 소스)·앱(데모 모드) 모두에서 반복됐다 — 경계를 인터페이스로 끊어 두면, 양쪽을 독립적으로 개발하고 나중에 합칠 수 있다.
'외주 개발일지' 카테고리의 다른 글
| 웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계 (1) | 2026.06.21 |
|---|---|
| 웹·앱 공용 풀스택 시스템 개발기 (1) — 공용 백엔드 API 재설계 (0) | 2026.06.21 |
| 커피머신 컨트롤 패널 데모 개발기 (2) — 정적 빌드 전환과 라우트 감사 스크립트 (0) | 2026.06.21 |
| 커피머신 컨트롤 패널 데모 개발기 (1) — 5구 추출 화면과 핸드드립 애니메이션 (0) | 2026.06.21 |
| 커피머신 내장 웹 서버 개발기 (1) — 기기 안에서 도는 UI 서버와 Yocto 레시피 (1) | 2026.06.21 |