junyeokk
Blog
React Ecosystem·2025. 10. 24

@fillout/react

SaaS 제품을 만들다 보면 "문의하기" 폼이 필요한 순간이 온다. 영업 문의, 버그 리포트, 피드백 수집 같은 것들이다. 직접 만들면 폼 UI, 유효성 검사, 제출 처리, 알림 연동, 스팸 방지까지 챙겨야 할 게 한두 가지가 아니다. 핵심 기능도 아닌 문의 폼에 이 모든 걸 쏟는 건 비효율적이다.

그래서 Typeform, Google Forms, Tally 같은 외부 폼 서비스를 쓴다. 문제는 이걸 자기 앱 안에 자연스럽게 녹이는 방법이다. 단순히 링크를 걸어서 외부 페이지로 보내면 사용자 경험이 끊기고, iframe을 직접 넣자니 크기 조절이나 스타일링이 까다롭다.

Fillout은 이 문제를 해결하는 폼 빌더 서비스다. 노코드로 폼을 만들고, React 앱에 컴포넌트 하나로 임베드할 수 있다. @fillout/react는 Fillout의 공식 React SDK로, 폼을 팝업/슬라이더/풀스크린/인라인 등 다양한 형태로 앱 안에 삽입할 수 있게 해준다.

대안들과 비교

외부 폼을 앱에 넣는 방법은 크게 세 가지다.

1. 직접 구현

React Hook Form이나 Formik으로 폼을 만들고, 제출 시 자체 API로 보내는 방식이다.

tsx
const ContactForm = () => {
  const { register, handleSubmit } = useForm();
  
  const onSubmit = async (data) => {
    await fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) });
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      <textarea {...register('message')} />
      <button type="submit">보내기</button>
    </form>
  );
};

완전한 제어권이 있지만, 이메일 알림 연동, 스팸 방지, 응답 대시보드 같은 부가 기능을 전부 직접 만들어야 한다. 문의 폼 하나에 백엔드 API까지 만드는 건 과하다.

2. iframe 직접 삽입

tsx
<iframe 
  src="https://forms.fillout.com/t/abc123" 
  width="100%" 
  height="500px"
  style={{ border: 'none' }}
/>

간단하지만 높이 자동 조절이 안 되고, 폼 제출 완료 같은 이벤트를 감지할 수 없다. 팝업 형태로 보여주려면 모달 로직까지 직접 만들어야 한다.

3. SDK 사용 (@fillout/react)

tsx
import { FilloutPopupEmbed } from '@fillout/react';

<FilloutPopupEmbed filloutId="abc123" />

iframe을 내부적으로 관리하면서, 팝업/슬라이더 등 다양한 임베드 형태를 컴포넌트 하나로 제공한다. 폼 제출 이벤트 콜백도 지원하고, 크기 조절도 자동이다.

설치와 기본 사용법

bash
npm install @fillout/react

Fillout에서 폼을 만들면 filloutId가 부여된다. 이 ID만 있으면 어떤 임베드 형태든 사용할 수 있다.

임베드 타입

@fillout/react는 네 가지 임베드 컴포넌트를 제공한다. 각각 용도와 사용자 경험이 다르다.

FilloutStandardEmbed — 인라인 임베드

페이지 안에 폼을 직접 삽입한다. 별도의 페이지 전환이나 오버레이 없이 콘텐츠 흐름 안에 폼이 들어간다.

tsx
import { FilloutStandardEmbed } from '@fillout/react';

const FeedbackSection = () => {
  return (
    <div style={{ width: '100%', height: '500px' }}>
      <FilloutStandardEmbed filloutId="abc123" />
    </div>
  );
};

부모 컨테이너의 크기에 맞게 폼이 렌더링된다. 높이를 지정하지 않으면 폼 내용에 따라 자동으로 조절된다. 폼이 페이지의 일부로 항상 보여야 할 때 적합하다.

FilloutPopupEmbed — 팝업 오버레이

화면 전체를 어둡게 덮는 모달 형태로 폼을 표시한다. 사용자의 주의를 폼에 집중시킬 수 있어서 문의 폼이나 설문에 적합하다.

tsx
import { FilloutPopupEmbed } from '@fillout/react';

const ContactPage = () => {
  return (
    <FilloutPopupEmbed
      filloutId="abc123"
      inheritParameters
      onClose={() => console.log('팝업 닫힘')}
      onSubmit={() => console.log('폼 제출 완료')}
    />
  );
};

inheritParameters를 설정하면 현재 페이지의 URL 파라미터를 폼에 전달할 수 있다. UTM 파라미터 추적이나 사용자 식별에 유용하다.

FilloutPopupEmbedButton — 버튼 트리거 팝업

버튼을 클릭하면 팝업이 열리는 형태다. 폼을 처음부터 보여주지 않고 사용자가 원할 때만 열 수 있다.

tsx
import { FilloutPopupEmbedButton } from '@fillout/react';

const Header = () => {
  return (
    <FilloutPopupEmbedButton
      filloutId="abc123"
      onSubmit={() => alert('감사합니다!')}
      color="#4F46E5"
      size="medium"
      text="문의하기"
    />
  );
};

SDK가 기본 버튼 UI를 제공하지만, 이 버튼의 스타일을 세밀하게 커스터마이징하는 데는 한계가 있다. 자체 디자인 시스템의 버튼을 쓰고 싶다면 약간의 우회가 필요하다.

FilloutSliderEmbed — 슬라이더

화면 오른쪽에서 슬라이드 인 되는 패널 형태다. 현재 페이지 콘텐츠를 완전히 가리지 않으면서 폼을 보여줄 수 있다.

tsx
import { FilloutSliderEmbed } from '@fillout/react';

<FilloutSliderEmbed filloutId="abc123" />

FilloutFullScreenEmbed — 풀스크린

페이지 전체를 폼으로 채운다. 전용 폼 페이지를 만들 때 사용한다.

tsx
import { FilloutFullScreenEmbed } from '@fillout/react';

<FilloutFullScreenEmbed filloutId="abc123" inheritParameters />

커스텀 버튼으로 팝업 제어하기

FilloutPopupEmbedButton이 제공하는 기본 버튼 대신 자체 버튼을 쓰고 싶은 경우가 많다. 디자인 시스템에 맞춰야 하니까. 문제는 SDK가 커스텀 트리거를 직접 지원하지 않는다는 점이다.

이 경우 SDK 버튼을 숨기고, 커스텀 버튼 클릭 시 숨겨진 SDK 버튼을 프로그래밍적으로 클릭하는 패턴을 사용할 수 있다.

tsx
'use client';

import { ReactNode, useEffect, useRef, useState } from 'react';
import { FilloutPopupEmbedButton } from '@fillout/react';

interface CustomFilloutPopupProps {
  filloutId: string;
  onSubmit?: () => void;
  children?: ReactNode;
}

const CustomFilloutPopup = ({ filloutId, onSubmit, children }: CustomFilloutPopupProps) => {
  const triggerRef = useRef<HTMLDivElement>(null);
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // SDK가 DOM에 버튼을 렌더링할 때까지 감시
    const observer = new MutationObserver(() => {
      const buttons = document.querySelectorAll('button');
      const filloutButton = Array.from(buttons).find(
        (btn) => btn.textContent === 'Open form'
      );

      if (filloutButton && triggerRef.current) {
        // 커스텀 버튼 클릭 → 숨겨진 SDK 버튼 클릭으로 연결
        triggerRef.current.addEventListener('click', () => {
          filloutButton.click();
        });
        setIsReady(true);
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
    return () => observer.disconnect();
  }, []);

  return (
    <>
      {/* SDK 버튼을 숨김 */}
      <div className="hidden">
        <FilloutPopupEmbedButton filloutId={filloutId} onSubmit={onSubmit} />
      </div>

      {/* 커스텀 트리거 */}
      <div
        ref={triggerRef}
        style={{ 
          cursor: isReady ? 'pointer' : 'not-allowed',
          opacity: isReady ? 1 : 0.6 
        }}
      >
        {children}
      </div>
    </>
  );
};

이 패턴의 핵심은 MutationObserver다. SDK가 내부적으로 DOM에 버튼을 추가하는 시점을 알 수 없으니, DOM 변화를 감시해서 SDK 버튼이 나타나면 커스텀 버튼과 연결한다. isReady 상태로 SDK 로딩 전에 클릭하는 걸 방지한다.

이 방식은 동작하지만 꽤 hacky하다. SDK의 내부 구현(버튼 텍스트가 "Open form"이라는 점)에 의존하기 때문에 SDK 업데이트 시 깨질 수 있다. DOM 쿼리로 버튼을 찾는 것도 불안정하다. 하지만 SDK가 ref나 imperative API를 제공하지 않는 이상, 이게 현실적인 방법이다.

z-index 문제 해결

모달이나 드롭다운 위에 Fillout 팝업을 띄울 때 z-index 충돌이 발생할 수 있다. Fillout 팝업의 z-index가 앱의 다른 오버레이보다 낮으면 폼이 뒤에 가려진다.

tsx
useEffect(() => {
  const observer = new MutationObserver(() => {
    const popupContainer = document.querySelector(
      '.fillout-embed-popup-container'
    ) as HTMLElement;
    const popupMain = document.querySelector(
      '.fillout-embed-popup-main'
    ) as HTMLElement;

    if (popupContainer) {
      popupContainer.style.zIndex = '9999';
      popupContainer.style.pointerEvents = 'auto';
    }
    if (popupMain) {
      popupMain.style.zIndex = '9999';
      popupMain.style.pointerEvents = 'auto';
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });
  return () => observer.disconnect();
}, []);

Fillout SDK는 자체 클래스명(fillout-embed-popup-container, fillout-embed-popup-main)을 사용한다. 이 클래스를 타겟으로 스타일을 강제 오버라이드한다. pointerEvents: 'auto'도 같이 설정하는 이유는 일부 상황에서 팝업 위에 투명한 레이어가 클릭을 가로채는 문제가 있기 때문이다.

SSR 주의사항

Next.js에서 사용할 때 @fillout/react는 브라우저 API(DOM, window)에 의존하므로 서버 사이드에서 렌더링하면 에러가 발생한다. 두 가지 방법으로 해결할 수 있다.

방법 1: 'use client' 지시어

tsx
'use client';

import { FilloutPopupEmbed } from '@fillout/react';

컴포넌트 파일 최상단에 'use client'를 추가하면 해당 컴포넌트가 클라이언트에서만 렌더링된다.

방법 2: 클라이언트 체크

tsx
const [isClient, setIsClient] = useState(false);

useEffect(() => {
  setIsClient(true);
}, []);

return isClient ? <FilloutPopupEmbed filloutId="abc123" /> : null;

hydration mismatch를 확실히 피하려면 useEffect로 클라이언트 환경을 확인한 후 렌더링한다.

방법 3: next/dynamic

tsx
import dynamic from 'next/dynamic';

const FilloutPopup = dynamic(
  () => import('@fillout/react').then((mod) => mod.FilloutPopupEmbed),
  { ssr: false }
);

ssr: false로 서버 렌더링을 완전히 건너뛴다. 번들 분할 효과도 있어서 초기 로드가 가벼워진다.

외부 스크립트 로딩

SDK 컴포넌트만으로 동작하지 않고, Fillout의 외부 스크립트를 추가로 로드해야 하는 경우도 있다. 특히 커스텀 버튼 패턴을 사용할 때 스크립트가 필요하다.

tsx
useEffect(() => {
  const script = document.createElement('script');
  script.src = 'https://server.fillout.com/embed/v1/';
  script.async = true;
  document.body.appendChild(script);

  return () => {
    if (document.body.contains(script)) {
      document.body.removeChild(script);
    }
  };
}, []);

async로 로드해서 페이지 렌더링을 블로킹하지 않는다. cleanup에서 스크립트를 제거하는 건 React의 Strict Mode에서 이중 마운트될 때 스크립트가 중복 추가되는 걸 방지하기 위해서다.

Props 정리

모든 임베드 컴포넌트가 공유하는 공통 props:

Prop타입설명
filloutIdstringFillout 폼 ID (필수)
inheritParametersbooleanURL 파라미터를 폼에 전달
parametersRecord<string, string>폼에 전달할 커스텀 파라미터
onSubmit() => void폼 제출 완료 콜백

FilloutPopupEmbed 추가 props:

Prop타입설명
onClose() => void팝업 닫힘 콜백

FilloutPopupEmbedButton 추가 props:

Prop타입설명
textstring버튼 텍스트
colorstring버튼 배경색
sizestring버튼 크기 (small, medium, large)

Typeform과의 비교

Typeform도 @typeform/embed-react라는 React SDK를 제공한다.

tsx
// Typeform
import { PopupButton } from '@typeform/embed-react';

<PopupButton id="abc123">문의하기</PopupButton>

// Fillout
import { FilloutPopupEmbedButton } from '@fillout/react';

<FilloutPopupEmbedButton filloutId="abc123" text="문의하기" />

API 구조가 비슷하다. 둘 다 폼 ID와 임베드 타입(popup, slider, inline 등)을 지정하는 방식이다. Typeform은 children으로 커스텀 트리거를 바로 넣을 수 있어서 커스텀 버튼 구현이 더 간단하다. Fillout은 가격이 더 저렴하고 Notion 스타일의 폼 빌더 UX를 제공하는 게 차별점이다.

정리

@fillout/react는 외부 폼 서비스를 React 앱에 깔끔하게 통합하는 도구다. 핵심 가치는 "폼 인프라를 직접 만들지 않아도 된다"는 것이다. 문의 폼, 피드백 수집, 설문 같은 비핵심 기능에 개발 시간을 쏟는 대신, 컴포넌트 하나로 해결할 수 있다.

다만 커스터마이징에 한계가 있다. 기본 제공 버튼의 스타일을 바꾸거나 팝업의 동작을 세밀하게 제어하려면 DOM 조작에 의존하는 우회 방법을 써야 한다. 이건 SDK의 imperative API가 부족하기 때문이고, 향후 버전에서 개선될 여지가 있다.

관련 문서