[친구하자] Capacitor Android 앱 X-Frame-Options 에러 해결

Capacitor로 빌드한 Android 앱에서의 웹소켓 오류 중 X-Frame-Options 에러 트러블슈팅을 다룹니다.

  • 이번 포스팅에서는 Capacitor로 빌드한 Android 앱에서 WebSocket 통신 중 발생한 X-Frame-Options 에러에 대해 다루었다.
  • 해결하는 과정에서 공부한 내용에 대해 정리해보았다.

문제 상황

  • 웹에서는 정상적으로 작동하던 매칭 완료 -> WebSocket 메시지 수신 -> 통화 시작 페이지 이동 로직이 Android 앱에서 다음과 같은 에러로 실패했다.

에러 이미지1

  • 🔥 이 문제는 Capacitor의 WebView 환경과 Spring Security의 기본 보안 설정이 충돌하면서 발생! 🔥
  • WebSocket 라이브러리인 SockJS가 fallback 메커니즘으로 iframe을 사용하려 했지만, 서버가 보안상의 이유로 이를 차단했기 때문이다.
  • 이 글에서는 X-Frame-Options가 무엇인지, iframe이 무엇인지, 그리고 왜 이런 에러가 발생햇는지를 자세히 알아보겠습니다.

1. iframe 이란?

  • iframe (inline frame)은 HTML 문서 안에서 다른 HTML 문서를 삽입하는 태그이다.
<!-- 예시: 유튜브 영상 삽입 -->
<iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    width="560"
    height="315">
</iframe>

시각적 구조: 설명 이미지1

  • 실생활 예시 : 유튜브 영상 삽입, 구글 지도 삽입, 페이스북 “좋아요” 버튼 등

2. X-Frame-Options 이란?

  • X-Frame-Options는 HTTP 응답 헤더로, 내 웹사이트가 다른 사이트의 iframe안에 표시되는 것을 제어하는 보안 메커니즘

  • 헤더 값 3가지:

    • X-Frame-Options: DENY
      • 어떤 사이트든 iframe으로 내 페이지를 로드할 수 없음
    • X-Frame-Options: SAMEORIGIN
      • 같은 도메인에서만 iframe으로 로드 가능
    • X-Frame-Options: ALLOW-FROM https://example.com
      • 특정 도메인에서만 iframe으로 로드 가능 (deprecated)

왜 X-Frame-Options가 필요한가?

  • Clickjacking 공격 방지
    • 악의적인 사이트가 투명한 iframe을 통해 사용자를 속여 원하지 않는 행동을 하게 만드는 공격

공격 시나리오:

1. 공격자가 악의적인 사이트 만들기 (evil.com)

2. 투명한 iframe으로 은행 사이트를 숨김
   ┌─────────────────────────────────┐
   │ evil.com                        │
   │                                 │
   │ [무료 iPhone 받기!] 버튼           │
   │        ↑                        │
   │        실제로는 투명한 iframe       │
   │        은행 송금 버튼이 위치         │
   └─────────────────────────────────┘

3. 사용자가 "무료 iPhone 받기!" 클릭
   → 실제로는 은행의 "송금 승인" 버튼 클릭
   → 돈이 공격자에게 전송됨 🥲

X-Frame-Options: DENY가 막는 방법:

┌─────────────────────────────────┐
│ evil.com                        │
│                                 │
│ <iframe src="bank.com">         │
│   ❌ 브라우저가 로드 거부!           │
│   "X-Frame-Options: DENY"       │
│ </iframe>                       │
└─────────────────────────────────┘

‼️ Capacitor로 빌드한 앱에서 이 문제가 발생한 이유

  • Capacitor는 하이브리드 앱을 만드는 프레임워크이다.

하이브리드 앱 구조 설명 이미지2

  • WebView는 본질적으로 iframe과 유사하다!
네이티브 앱 (Android/iOS)
    └── WebView ≈ iframe
         └── React 앱 (https://localhost)
              └── WebSocket 연결 시도 → 백엔드 (https://backend.domain)

SockJS의 iframe Transport

SockJS는 WebSocket이 안 될 때를 대비한 여러 fallback 방법을 제공한다.

SockJS Transport 순서:

1. WebSocket (가장 빠르고 효율적)
   ↓ 실패
2. xhr-streaming (HTTP 스트리밍)
   ↓ 실패
3. xhr-polling (HTTP 폴링)
   ↓ 실패
4. iframe-based transports (레거시 브라우저용)
   ↓ 실패
5. jsonp-polling (최후의 수단)
  • iframe-based transport가 하는 일:
<!-- SockJS가 내부적으로 생성하는 숨겨진 iframe -->
<iframe src="https://backend.domain/ws/iframe.html" style="display: none;">
</iframe>

문제 발생 시나리오:

1. Capacitor 앱 (WebView) 시작
   Origin: capacitor://localhost

2. WebSocket 연결 시도 (친구하자 프로젝트의 경우, 통화 매칭시 사용 중)
   → https://backend.domain/ws

3. SockJS가 iframe transport 시도
   ┌─────────────────────────────────┐
   │ Capacitor WebView               │
   │ (capacitor://localhost)         │
   │                                 │
   │ <iframe src="backend.domain/ws"> │
   │   ❌ 차단됨!                     │
   │   "X-Frame-Options: DENY"       │
   │ </iframe>                       │
   └─────────────────────────────────┘

4. 에러 메시지:
   "Refused to display 'https://backend.domain/'
    in a frame because it set 'X-Frame-Options' to 'deny'"

왜 Spring Security가 기본적으로 Deny를 설정할까?

  • Spring Security의 보안 우선 철학 : 설명 이미지3

이유

  1. 보안 우선주의: 안전하지 않은 것보다 안전한 것이 낫다
  2. Clickjacking 방지: 대부분의 웹 애플리케이션은 iframe에 로드될 필요 없음
  3. 명시적 허용: 개발자가 필요하면 명시적으로 변경하도록 유도

일반적인 웹사이트는 DENY가 맞다:

은행, 쇼핑몰, 관리자 페이지 등
→ iframe에 로드될 이유가 없음
→ DENY로 보안 강화

하지만 우리의 경우:

Capacitor 하이브리드 앱
→ WebView는 iframe과 유사한 환경
→ DENY면 앱 자체가 작동 안 함
→ SAMEORIGIN 또는 특정 origin 허용 필요

해결 방법

방법 1: frameOptions().sameOrigin() (운영 환경 권장)

java.headers(headers -> headers
    .frameOptions(frameOptions -> frameOptions.sameOrigin())
)

의미:

  • 같은 도메인에서만 iframe 허용
  • https://backend.domain에서 https://backend.domain를 iframe으로 로드 가능
  • Capacitor WebView는 capacitor://localhost이지만, WebView 자체가 특수 처리됨

왜 이게 Capacitor에서 작동하나? WebView는 브라우저 엔진의 특수 모드 → origin 검사가 일반 웹과 다르게 처리됨 → SAMEORIGIN이어도 WebView 내부 컨텍스트에서는 허용

방법 2: Content-Security-Policy 추가 (최고 보안)

java.headers(headers -> headers
    .frameOptions(frameOptions -> frameOptions.sameOrigin())
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("frame-ancestors 'self' capacitor://localhost http://localhost https://localhost")
    )
)

의미:

  • 현대적인 보안 헤더 (X-Frame-Options의 후속)
  • 더 세밀한 제어 가능
  • 특정 origin만 명시적으로 허용

결론

  • Capacitor로 앱을 빌드한 다음에 웹에서는 발생하지 않는 에러들이 생겨서 찾아보고, 공부하고, 해결하는 재미가 있다.
  • 이제 통화연결까지 앱으로 수행할 수 있게 되었다!!