ADR‐009: 웹뷰 이미지 업로드는 브리지를 경유하지 않는다 - Moadong/moadong GitHub Wiki

작성일: 2026-09-21 | 상태: Accepted
결정일자: 2026-09-21

관련: ADR‐007: 이미지 업로드 방식 결정

왜 이 결정이 필요했나요?

앱이 웹뷰다 보니 이런 질문이 반복됩니다.

"앱에서 활동사진을 올리면 브리지를 한 번 타고 가는 거 아닌가요? 그럼 넘기는 포맷 때문에 느려질 수도 있는 거 아닌가요?"

그럴듯한 질문이에요. 앱 안에서 일어나는 일이니 RN이 중간에 끼어 있을 것 같거든요. 그런데 답을 확인하려면 웹 3개 파일(webviewBridge.ts, image.ts, useClubImages.ts), 앱 2개 파일(webview-message.types.ts, home-webview-screen.tsx), 백엔드 1개 파일(CloudflareImageService.java)을 다 읽어야 했습니다. 한 번 읽어서 답이 나왔으면 적어두는 게 낫겠다 싶었어요.

답이 중요한 이유는 업로드가 느릴 때 어디를 고칠지가 갈리기 때문입니다. 브리지가 경로에 있으면 메시지 프로토콜을 손봐야 하고, 없으면 아예 다른 데를 봐야 하니까요.

그리고 앱에서 관리자 화면에 실제로 닿습니다. 웹뷰에서도 하단 네비게이션이 뜨고(AppLayout.tsx), /menu 화면의 버튼이 /admin/login으로 갑니다(MenuPage.tsx). React Router 내부 이동이라 앱의 onShouldStartLoadWithRequest조차 타지 않아요. 가정이 아니라 실제로 열려 있는 경로입니다.


어떤 결정을 내렸나요?

이미지 바이트는 RN 브리지를 통과시키지 않습니다. 브리지에는 제어 신호(작은 JSON)만 흐릅니다.

업로드 한 번을 구간별로 끊어보면 이렇습니다.

구간 누가 하나 브리지 경유
파일 선택 (<input type="file">) Android: RNCWebChromeClient.onShowFileChooser (네이티브 Java)
iOS: WKWebView 자체
❌
presigned URL 발급 요청 웹뷰 안의 웹앱 → 우리 서버 ❌
R2로 PUT (실제 바이트) 웹뷰 안의 fetch → R2 ❌
저장 반영 (complete, 게시글 저장) 웹뷰 안의 웹앱 → 우리 서버 ❌
WebP 변환 R2 쪽 변환 → 웹훅으로 DB URL 치환 ❌

경유가 전부 ❌입니다. 앱은 이 흐름을 알지도 못합니다.

왜 이렇게 되나요

WebView는 RN의 일부가 아니거든요. OS가 주는 완제품 브라우저 엔진이고, 자체 JS 엔진·네트워크 스택·렌더러·스토리지를 다 갖고 있습니다. RN이 하는 일은 그 컴포넌트를 화면에 붙이고 URL 하나 넘기는 것까지예요. 그 다음부터 웹은 RN을 건너뛰고 OS와 직접 대화합니다.

그래서 브리지 비용은 웹뷰 앱이라 자동으로 붙는 통행료가 아니라, 브라우저 샌드박스에 없는 기능을 쓸 때만 내는 값입니다. fetch로 R2에 PUT하는 건 브라우저가 원래 할 줄 아는 일이라 RN에 부탁할 이유가 없어요.

지금 브리지에 올라가 있는 메시지를 보면 기준이 선명합니다.

메시지 왜 브리지를 타나
NOTIFICATION_SUBSCRIBE / SUBSCRIBE_TOGGLE FCM 토큰과 알림 권한이 네이티브에만 있음
SHARE WKWebView에는 navigator.share가 없음 (그래서 useShare.ts가 웹뷰면 브리지, 아니면 navigator.share로 분기합니다)
OPEN_APP_SETTINGS OS 설정 앱을 여는 웹 API가 없음
NAVIGATE_BACK / NAVIGATE_WEBVIEW 웹은 자기 history만 알지 RN 네비게이션 스택은 모름
OPEN_EXTERNAL_URL 외부 링크를 인앱 브라우저로 띄우려면 네이티브가 해야 함

전부 "웹에 없어서" 넘어간 것들이에요. 이미지 업로드는 이 목록에 낄 이유가 없습니다.

브리지의 진짜 비용은 런타임이 아닙니다

"비용"이라고 하면 보통 CPU를 떠올리는데 그쪽은 거의 0이에요. 작은 JSON 문자열 직렬화 한 번에 비동기 홉 하나고, 앱이 newArchEnabled: true(JSI 기반)라 더 작습니다. 측정할 가치가 없는 수준입니다.

진짜 비용은 웹과 앱이 따로 배포된다는 것입니다. 웹은 머지하면 바로 나가지만 앱은 스토어 심사와 사용자 업데이트를 기다려야 해요. 브리지 메시지를 하나 늘린다는 건 원자적으로 바꿀 수 없는 계약을 하나 늘리는 것이고, 구버전 앱은 모르는 메시지를 받으면 조용히 아무것도 안 합니다.

이미 어긋나 있기도 해요. 앱의 홈 웹뷰는 REQUEST_APP_VERSION을 처리하는데 웹의 WebViewMessage 타입에는 그 항목이 없습니다. 타입으로 못 잡는 종류의 불일치라, 앱에 reportUnknownBridgeMessage를 넣어서 "호스트가 못 알아들은 메시지"를 Mixpanel로 관측하고 있습니다.

그러니 웹에서 되는 건 웹에서 하는 게 맞습니다. 브리지로 보낼 수 있다는 이유로 보내면 런타임 이득은 0인데 호환 부채만 늘어요.

다른 대안들:

  • ❌ 네이티브 이미지 피커 + 네이티브 업로드: 얻는 건 백그라운드 업로드, 네이티브 리사이즈, 재시도 관리입니다. 다만 브리지 계약이 늘고(선택·진행률·완료·실패 최소 4종), iOS/Android 업로드 코드가 새로 생기고, 웹 전용 사용자와 앱 사용자의 업로드 경로가 갈라집니다. 지금 이 기능들이 필요하다는 근거가 없어요.
  • ❌ base64/dataURL로 브리지 통과: 최악의 조합입니다. base64가 바이트를 33% 부풀리고, 웹→앱은 postMessage(string) 한 방향이라 문자열 복사가 강제되고, 앱→웹은 injectJavaScript로 문자열을 eval시키는 구조예요. 제어 신호엔 적절하지만 MB급 문자열에는 파행적입니다. 무엇보다 아무것도 사지 못합니다. 웹이 이미 할 수 있는 일을 비싸게 다시 하는 거예요.

어떤 효과가 있나요?

장점:

  • 앱 업로드와 웹 업로드가 같은 코드 한 벌입니다. 관리자 기능을 만들 때 앱을 따로 고려하지 않아도 돼요.
  • 업로드 기능을 바꿔도 앱 배포가 필요 없습니다. 웹만 배포하면 앱 사용자에게도 즉시 반영돼요.
  • 브리지 계약이 안 늘어납니다. 구버전 앱 호환을 걱정할 표면이 그대로예요.
  • ADR‐007의 presigned 구조가 앱에서도 그대로 성립합니다. 서버가 이미지 바이트를 안 만진다는 성질이 웹뷰라고 달라지지 않아요.

단점/트레이드오프:

  • 앱이 업로드 상태를 모릅니다. 사진 15장을 올리는 중에 사용자가 앱을 나가면 웹뷰의 fetch가 끊기고, 앱은 그걸 이어받을 수단이 없어요. 네이티브 업로드였다면 백그라운드 세션으로 살릴 수 있습니다.
  • 웹뷰의 제약을 그대로 받습니다. 파일 선택 UI, 권한 프롬프트, 메모리 한도가 전부 WebView 것입니다. 앱에서 손댈 수 있는 여지가 없어요.
  • 업로드가 실패했을 때 앱 쪽 로그에 아무것도 안 남습니다. 경계 너머라 앱에서는 보이지 않아요.

그럼 업로드가 느릴 때는 어디를 봐야 하나요?

브리지가 아닌 건 확인됐으니, 남는 후보를 적어둡니다.

1. 원본 크기 — 지배적인 비용입니다. 클라이언트 리사이즈가 전혀 없어요. 최신 폰 사진 한 장이 3~8MB인데 제한이 10MB이고 활동사진은 최대 15장입니다. 모바일 네트워크로 원본을 그대로 올리는 이 구간이 브리지 비용과는 자릿수가 다릅니다.

2. 변환이 비동기라는 것. 업로드 직후 화면에는 원본 URL이 걸려 있고, WebP 이득은 웹훅이 돌고 나서 생깁니다. 올린 사람 본인은 그 창에서 이득을 못 봐요.

열린 질문: 리사이즈를 클라이언트로 옮길까

포맷 변환(WebP)을 서버가 한다는 결정과 픽셀 축소는 별개입니다. 클라이언트에서 긴 변을 줄여 JPEG로 내보내고 WebP 변환은 지금대로 서버에 두면, 두 결정이 충돌하지 않으면서 업로드 바이트만 크게 줄어요. 캔버스 작업은 WebView 자체 JS 엔진에서 도니 이 ADR과도 무관합니다.

그리고 이건 새 기능을 붙이는 게 아니라 잃어버린 기능을 되찾는 쪽에 가깝습니다. 예전에는 서버가 리사이즈를 했거든요.

// ClubImageUtil.resizeImage — 커밋 a41465e7f (2025-05-19)
Thumbnails.of(file.getInputStream()).size(2000, 2000).outputQuality(0.9)

지금 이 메서드는 아무 데서도 호출되지 않습니다. presigned로 전환하면서 서버가 이미지 바이트를 만지지 않게 됐고(ADR‐007), 그때 리사이즈도 같이 호출부를 잃었어요. 원본이 그대로 R2에 올라가는 현재 상태는 그렇게 하기로 정한 게 아니라 전환의 부산물입니다.

다만 아직 결정하지 않았습니다. 근거가 없거든요. 필요한 건 두 가지예요.

  • 관리자 업로드 파일 크기 분포 (실측)
  • iOS 실기기에서의 캔버스 거동 — WebKit은 캔버스 총 픽셀 수에 상한이 있어서 고화소 사진은 에러 없이 빈 캔버스가 나옵니다. createImageBitmap(file, { resizeWidth })로 디코드 단계에서 줄이면 피할 수 있는데, Safari가 이 옵션을 지원하는지는 확인하지 못했습니다.

측정하고 나면 별도 ADR로 씁니다.


이 결정을 언제 뒤집나요?

백그라운드 업로드가 요구사항이 될 때입니다. "앱을 나가도 업로드가 이어져야 한다"가 나오면 웹뷰 fetch로는 방법이 없어요. 그때는 브리지 계약을 늘리는 대가를 치르고 네이티브로 옮기는 게 맞습니다.

그 외의 이유 — 느리다, 앱이니까, 네이티브가 더 그럴듯하다 — 로는 뒤집지 않습니다. 위의 "어디를 봐야 하나요"를 먼저 재보고 오는 게 순서예요.


범위 밖에서 같이 발견한 것들

이 결정을 정리하다 나온 것들인데, 결정이 아니라 결함 후보라 여기서 다루지 않습니다.

  • 활동사진 presign의 Content-Type 비대칭 (재현 미확인): 허용 목록에 없는 타입이면 presign은 image/jpeg로 폴백하는데(useClubImages.ts) PUT은 file.type을 그대로 보냅니다(image.ts). Content-Type은 서명에 묶이니(ADR‐007) 어긋나면 R2가 403을 냅니다. 로고·커버는 양쪽 다 file.type이라 해당 없고, 홍보는 서버가 준 requiredHeaders를 써서 안전합니다.

    발생 창은 좁습니다. 백엔드가 presign 진입점에서 validateFileName으로 파일명 확장자도 같은 목록에 대조하거든요(CloudflareImageService). IMG.heic처럼 확장자부터 목록 밖이면 presign 단계에서 막혀 R2까지 못 갑니다. 403이 나려면 확장자는 허용 목록인데 MIME만 목록 밖이어야 해요 — photo.jpg인데 file.type이 application/octet-stream인 경우 같은 것(일부 파일 관리자 앱에서 나옵니다). 비대칭 자체는 실재하지만 우선순위는 낮습니다.

  • iOS 카메라 권한 누락 (2026-09-21 확인 → 해당 없음): 로고·커버·활동사진이 accept='image/*'라 iOS 선택 시트에 "사진 찍기"가 뜰 테고, 앱 app.json에 NSCameraUsageDescription이 없으니 누르면 앱이 종료될 거라고 봤습니다. iOS 실기기에서 눌러보니 시트가 아예 안 뜨고 바로 앨범이 열립니다. 활동사진은 multiple이라 카메라가 처음부터 후보가 아니고, 로고·커버는 WebKit이 usage description 없는 카메라를 옵션에서 빼는 것으로 보여요(메커니즘은 추정입니다). 남는 선택지가 사진 보관함 하나뿐이라 시트를 건너뛰고 바로 열립니다. 앱에서 닿을 수 있는 업로드 입력은 이 셋이 전부입니다 — 데스크톱 전용인 ClubLogoEditor·ClubCoverEditor는 앱 웹뷰 폭에서 렌더되지 않고, 모바일에서는 MobileBannerSection이 로고·커버를 함께 담당하거든요. 고칠 것이 없습니다.


참고 자료

  • 브리지 유틸 도입: 커밋 f279d51fb (2026-01-23)
  • 웹뷰 내부 라우팅 통일: #1662 (커밋 8dbdc0950, 2026-06-13)
  • 처리 못 한 브리지 메시지 관측: 앱 레포 커밋 430b177 (2026-09-17)
  • 웹 브리지 정의: frontend/src/utils/webviewBridge.ts
  • 앱 브리지 정의·핸들러: types/webview-message.types.ts, ui/home/home-webview-screen.tsx (Moadong/moadong-react-native)
  • 업로드 경로: frontend/src/apis/image.ts, frontend/src/hooks/Queries/useClubImages.ts
  • Android 파일 선택이 네이티브에서 끝나는 근거: react-native-webview의 RNCWebChromeClient.onShowFileChooser (13.15.0)
⚠️ **GitHub.com Fallback** ⚠️