귀찮은개발자6 min

위젯 하나로 6개 플랫폼 전부 지원하게 만들었다

2026년 4월 · 귀찮은개발자 EP.08

2026년 4월 · 귀찮은개발자 EP.08

FeedMission이 웹에서만 돌아가면 의미가 반쪽이다. iOS 앱에서도, Android 앱에서도 피드백을 받을 수 있어야 한다. Notion이나 Webflow에 iframe으로 넣을 수도 있어야 하고, Google Tag Manager로 코드 수정 없이 설치할 수도 있어야 한다.

결국 위젯 하나를 6개 플랫폼에서 돌아가게 만들었다. Script 태그, React npm 패키지, iOS SwiftUI 네이티브, Android Kotlin WebView, iframe embed, GTM 커스텀 태그. 편의점 하나인데 배달앱 6개에 전부 입점한 느낌이다. 이 글은 그 과정의 기록이다.

빠르게 보기

– Script 태그: data 속성 9개로 커스텀. 52px 플로팅 버튼 + 패널 (데스크톱 380px, 모바일 풀스크린)
– React: npm 패키지로 배포. useEffect로 script 동적 로드 + 중복 방지
– iOS: SwiftUI 네이티브 — 플로팅 버튼 + Sheet + API 직접 호출 + Haptic
– Android: WebView로 embed URL 로드 + Jetpack Compose ModalBottomSheet
– iframe: CSP frame-ancestors *로 모든 도메인 허용 + 다크모드
– GTM: 코드 수정 없이 커스텀 HTML 태그로 설치
– 모든 플랫폼에서 한도 초과 시 위젯 자동 숨김
FeedMission 위젯 설치 페이지 — 6탭 코드 생성기와 미리보기
위젯 설치 페이지 — Script 탭 코드 생성기와 미리보기 / GoCodeLab

기본: Script 태그 한 줄

가장 기본이 되는 건 script 태그다. 모든 웹사이트에서 동작한다. HTML에 한 줄만 넣으면 된다.

<!– 이게 전부다 –>
<script src=“https://feedmission.com/widget.js”
  data-widget-key=“내_키”
  data-theme-color=“#6366f1”
  data-lang=“ko”
  defer></script>

이 스크립트가 로드되면 페이지 우측 하단에 52px 원형 버튼이 나타난다. 탭하면 피드백 폼 패널이 열린다. 데스크톱에서는 380px 폭 패널, 모바일(640px 이하)에서는 풀스크린으로 올라온다.

data 속성으로 9가지를 커스텀할 수 있다. 테마 색상, 위치(좌/우), 버튼 텍스트, 언어(ko/en), 이름/이메일 필드 숨기기, 제출 후 새로고침 여부. 위젯이 로드될 때 /api/feedback/status를 호출해서 한도를 확인한다. EP.07에서 만든 그 체크다. 한도 초과면 버튼 자체가 안 나타난다.

피드백을 제출하면 POST /api/feedback을 호출한다. widgetKey로 프로젝트를 식별하고, CORS 헤더로 어떤 도메인에서든 요청을 허용한다. 429가 오면 “이번 달 한도를 초과했습니다” 메시지를 표시한다.

React: npm 패키지로 배포

React 프로젝트에서는 npm 패키지로 설치하는 게 자연스럽다. feedmission-react 패키지를 만들어서 npm에 배포했다.

// npm install feedmission-react
import { FeedMissionWidget } from ‘feedmission-react’

<FeedMissionWidget
  widgetKey=“내_키”
  lang=“ko”
  themeColor=“#6366f1”
/>

내부적으로는 useEffect에서 widget.js 스크립트를 동적으로 로드한다. 중복 로드를 방지하기 위해 data-feedmission 속성이 이미 있는 스크립트가 있는지 먼저 확인한다. 컴포넌트가 언마운트되면 스크립트와 DOM 요소를 정리한다. Props가 data 속성으로 매핑되는 구조라 Script 태그 버전과 동일한 기능을 제공한다.

iOS: 네이티브 SwiftUI로 만든 이유

iOS는 WebView로 만들 수도 있었다. Android에서 그렇게 했으니까. 근데 iOS에서 WebView는 사용자 경험이 다르다. 로딩 시간이 느끼고, Sheet 애니메이션이 어색하다. SwiftUI 네이티브로 만들기로 했다.

// SwiftUI — modifier 하나로 설치
ContentView()
  .feedMission(config: .init(
    widgetKey: “내_키”,
    lang: .ko,
    themeColor: .indigo
  ))

.feedMission(config:) modifier를 붙이면 화면 우측 하단에 52px 플로팅 버튼이 나타난다. 탭하면 Haptic 피드백이 오고, Sheet가 올라온다. presentationDetents([.medium, .large])로 반쯤 열리거나 전체로 열 수 있다.

피드백 폼은 네이티브 SwiftUI다. TextField, TextEditor, 타입 선택 버튼(Feature/Bug/Other). 이메일 필드는 keyboardType(.emailAddress)로 @ 키보드가 바로 올라온다. 제출하면 POST /api/feedback을 직접 호출한다. WebView를 안 거치니까 로딩 없이 즉시 반응한다.

한도 체크도 넣었다. 뷰가 나타날 때 .task { available = await FeedMissionAPI.checkStatus(...) }로 한도를 확인한다. available이 false면 플로팅 버튼이 안 나타난다. 429가 오면 에러 Haptic과 함께 “현재 피드백을 받을 수 없습니다” 메시지를 보여준다.

Android: WebView로 충분했다

Android는 iOS와 다른 판단을 했다. Android WebView는 성능이 괜찮고, 네이티브 UI와 자연스럽게 섞인다. 네이티브 폼을 따로 만드는 것보다 /embed/[slug] URL을 WebView로 로드하는 게 유지보수가 쉬웠다. 웹에서 위젯 UI를 수정하면 Android에도 자동으로 반영되니까.

// Jetpack Compose
FeedMissionWidget(
  slug = “my-app”,
  themeColor = Color(0xFF6366F1),
  lang = “ko”
)

52dp 원형 FAB(Floating Action Button)을 탭하면 ModalBottomSheet이 올라온다. 높이는 화면의 85%. 안에 WebView가 들어있고 feedmission.com/embed/[slug]을 로드한다. JavaScript와 DOM Storage가 활성화되어 있어서 위젯의 모든 기능이 동작한다.

Kotlin View 기반 버전도 만들었다. Compose를 안 쓰는 프로젝트를 위해. Dialog 안에 WebView를 넣고, Gravity.BOTTOM으로 아래에서 올라오게 했다.

iframe: Notion이든 Webflow이든 어디서든

코드를 수정할 수 없는 환경에서 피드백을 받으려면 iframe이 필요하다. /embed/[slug] 경로로 공개 피드백 보드를 iframe에 넣을 수 있게 만들었다.

기본적으로 Next.js는 iframe 임베딩을 차단한다. X-Frame-Options 헤더 때문이다. /embed 경로에만 이 제한을 풀어야 한다.

// next.config.ts — /embed 경로만 iframe 허용
{
  source: “/embed/:path*”,
  headers: [
    { key: “X-Frame-Options”, value: “ALLOWALL” },
    { key: “Content-Security-Policy”,
      value: “frame-ancestors *” },
  ]
}

frame-ancestors *는 어떤 도메인에서든 이 페이지를 iframe에 넣을 수 있게 허용한다. 다른 경로에는 적용 안 된다. embed 페이지에서는 쿼리 파라미터로 다크모드(?theme=dark)와 헤더 숨기기(?hideHeader=true)를 지원한다.

GTM: 코드 수정 없이 설치

개발팀 없이 마케터가 위젯을 설치해야 하는 경우가 있다. Google Tag Manager를 쓰면 코드를 건드리지 않고 커스텀 HTML 태그로 script를 넣을 수 있다. 위젯 설치 페이지에서 GTM 탭을 선택하면 복사할 수 있는 코드가 생성된다.

위젯 설치 페이지 — 6탭 코드 생성기

대시보드의 Widget 페이지에서 설치 방법을 선택하면 해당 플랫폼에 맞는 코드가 자동 생성된다. 커스텀 옵션(언어, 위치, 버튼 텍스트, 필드 숨기기)을 설정하면 코드가 실시간으로 바뀐다.

미리보기도 넣었다. 웹 탭에서는 브라우저 모형 안에 플로팅 버튼이 보이고, iOS/Android 탭에서는 폰 프레임(노치, 홈 인디케이터 포함) 안에 위젯이 표시된다.

플랫폼별 판단이 달랐다

6개 플랫폼을 전부 같은 방식으로 만들지 않았다. 플랫폼 특성에 따라 판단이 달랐다.

Script기본. 어디서든 동작. 스타일까지 내장 (CSS-in-JS)ReactScript 위의 래퍼. useEffect로 동적 로드 + 중복 방지iOS네이티브 SwiftUI. API 직접 호출. WebView보다 빠르고 자연스러움AndroidWebView. 네이티브만큼 자연스럽고 유지보수가 쉬움iframe코드 수정 불가 환경용. Notion, Webflow 등GTM개발자 없이 마케터가 설치

iOS는 네이티브, Android는 WebView로 간 이유. iOS WebView(WKWebView)는 Sheet 안에서 쓸 때 로딩이 체감되고, 스크롤이 어색하다. 반면 Android WebView는 BottomSheet에서 꽤 자연스럽다. 네이티브 폼을 따로 만드는 비용 대비 UX 차이가 iOS에서만 유의미했다.

WordPress 플러그인도 만들었는데, 심사 승인 대기 중이라 대시보드에서는 탭을 임시로 숨겨뒀다. 내부적으로는 wp_footer 훅에 script 태그를 주입하는 방식이다. 설정은 관리자 페이지에서 Widget Key만 입력하면 된다.

자주 묻는 질문

위젯 설치에 코딩이 필요한가요?
Script 태그 한 줄 복사-붙여넣기면 된다. GTM을 쓰면 코드 수정 없이도 가능하다. iframe은 URL만 넣으면 된다.
iOS에서 왜 WebView 대신 네이티브로 만들었나요?
iOS WebView는 Sheet 안에서 로딩 체감이 있고 스크롤이 어색하다. 네이티브 SwiftUI로 만들면 Haptic, 키보드 타입, presentationDetents 같은 시스템 기능을 자연스럽게 쓸 수 있다.
iframe을 쓰면 어떤 사이트에서든 동작하나요?
/embed 경로에 CSP frame-ancestors * 헤더를 설정해서 모든 도메인에서 임베드 가능하다. Notion, Webflow, WordPress 어디서든 된다.
위젯 디자인을 커스텀할 수 있나요?
테마 색상, 위치(좌/우), 버튼 텍스트, 언어, 이름/이메일 필드 숨기기, 제출 후 새로고침 여부를 설정할 수 있다. 총 9가지 옵션이다.

관련 글