[친구하자] Capacitor Android 앱 X-Frame-Options 에러 해결
in ProjectDiary
Capacitor로 빌드한 Android 앱에서의 웹소켓 오류 중 X-Frame-Options 에러 트러블슈팅을 다룹니다.
- 이번 포스팅에서는 Capacitor로 빌드한 Android 앱에서 WebSocket 통신 중 발생한 X-Frame-Options 에러에 대해 다루었다.
- 해결하는 과정에서 공부한 내용에 대해 정리해보았다.
문제 상황
- 웹에서는 정상적으로 작동하던 매칭 완료 -> WebSocket 메시지 수신 -> 통화 시작 페이지 이동 로직이 Android 앱에서 다음과 같은 에러로 실패했다.

- 🔥 이 문제는 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>
시각적 구조: 
- 실생활 예시 : 유튜브 영상 삽입, 구글 지도 삽입, 페이스북 “좋아요” 버튼 등
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: DENY
왜 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는 하이브리드 앱을 만드는 프레임워크이다.
하이브리드 앱 구조 
- 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의 보안 우선 철학 :

이유
- 보안 우선주의: 안전하지 않은 것보다 안전한 것이 낫다
- Clickjacking 방지: 대부분의 웹 애플리케이션은 iframe에 로드될 필요 없음
- 명시적 허용: 개발자가 필요하면 명시적으로 변경하도록 유도
일반적인 웹사이트는 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로 앱을 빌드한 다음에 웹에서는 발생하지 않는 에러들이 생겨서 찾아보고, 공부하고, 해결하는 재미가 있다.
- 이제 통화연결까지 앱으로 수행할 수 있게 되었다!!