메인 콘텐츠로 건너뛰기
Sonamu 프론트엔드 통합의 시작점인 SonamuProvider 설정 방법을 알아봅니다. 인증, 파일 업로드, 다국어 지원을 한 곳에서 구성할 수 있습니다.

설정 개요

인증 통합

UserService.useMe 연결로그인/로그아웃 흐름

파일 업로드

FileService 통합useTypeForm 자동 연결

다국어 지원

SD 함수 제공타입 안전한 번역

전역 상태

Context API 기반모든 컴포넌트에서 접근

기본 설정

1. SonamuProvider 설정 파일 생성

프로젝트의 src/contexts/sonamu-provider.tsx 파일을 생성합니다.
// src/contexts/sonamu-provider.tsx
import {
  SonamuProvider as BaseSonamuProvider,
  useSonamuBaseContext,
} from "@sonamu-kit/react-components";
import { type SonamuFile } from "@sonamu-kit/react-components";
import { type BetterAuthClientOptions } from "better-auth/client";
import { inferAdditionalFields } from "better-auth/client/plugins";
import { type ReactNode } from "react";

import { type MergedDictionary } from "@/i18n/sd.generated";
import { SD } from "@/i18n/sd.generated";
import { FileService } from "@/services/services.generated";

// better-auth 클라이언트 옵션 정의
const authOptions = {
  plugins: [
    inferAdditionalFields({
      user: {
        role: { type: "string" },
      },
    }),
  ],
} satisfies BetterAuthClientOptions;

// 타입이 지정된 useSonamuContext
export function useSonamuContext() {
  return useSonamuBaseContext<MergedDictionary, typeof authOptions>();
}

export function SonamuProvider({ children }: { children: ReactNode }) {
  const uploadMutation = FileService.useUploadMutation();

  const uploader = async (files: File[]): Promise<SonamuFile[]> => {
    if (files.length === 0) {
      return [];
    }

    const result = await uploadMutation.mutateAsync({ files });
    return result.files;
  };

  return (
    <BaseSonamuProvider<MergedDictionary> authOptions={authOptions} uploader={uploader} SD={SD}>
      {children}
    </BaseSonamuProvider>
  );
}

2. App에 적용

__root.tsx에서 SonamuProvider를 설정합니다.
// src/routes/__root.tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { SonamuProvider } from "@/contexts/sonamu-provider";

export const Route = createRootRouteWithContext()({
  component: RootComponent,
});

function RootComponent() {
  const { queryClient } = Route.useRouteContext();

  return (
    <QueryClientProvider client={queryClient}>
      <SonamuProvider>
        <Outlet />
      </SonamuProvider>
    </QueryClientProvider>
  );
}
왜 SonamuProvider를 별도 파일로 만드나요?SonamuProviderFileService.useUploadMutation() 같은 React 훅을 사용하므로 QueryClientProvider 아래에 배치해야 합니다. 별도 파일로 분리하면 authOptions, useSonamuContext 등의 설정과 타입을 함께 관리할 수 있습니다.

인증 설정

Sonamu는 better-auth를 기반으로 인증을 처리합니다. SonamuProviderauthOptions를 전달하면 내부적으로 better-auth 클라이언트가 생성됩니다.

authOptions 정의

better-auth 클라이언트 옵션을 정의합니다. 플러그인을 통해 사용자 필드를 확장할 수 있습니다.
import { type BetterAuthClientOptions } from "better-auth/client";
import { inferAdditionalFields } from "better-auth/client/plugins";

const authOptions = {
  plugins: [
    inferAdditionalFields({
      user: {
        role: { type: "string" },
        // 필요한 추가 필드 정의
      },
    }),
  ],
} satisfies BetterAuthClientOptions;
inferAdditionalFields란?better-auth의 기본 User 타입(id, name, email 등) 외에 프로젝트에서 추가한 필드(role 등)를 클라이언트 타입에 반영합니다. 서버에서 정의한 사용자 스키마와 일치시켜야 합니다.

로그인 흐름

better-auth 클라이언트의 signIn 메서드를 사용합니다.
import { useSonamuContext } from "@/contexts/sonamu-provider";

export function LoginPage() {
  const { auth } = useSonamuContext();
  const [email, setEmail] = React.useState("");
  const [password, setPassword] = React.useState("");

  const handleSubmit = async () => {
    const result = await auth.signIn.email({ email, password });

    if (result.error) {
      alert(result.error.message);
      return;
    }
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit">로그인</button>
    </form>
  );
}

로그아웃 흐름

const handleLogout = () => {
  auth.signOut();
};

세션 사용하기

컴포넌트에서 useSonamuContext로 auth 클라이언트에 접근하고, auth.useSession()으로 현재 세션을 조회합니다.
import { useSonamuContext } from "@/contexts/sonamu-provider";

export function Header() {
  const { auth } = useSonamuContext();
  const session = auth.useSession();
  const user = session.data?.user ?? null;

  if (session.isPending) {
    return <div>로딩...</div>;
  }

  if (!user) {
    return <a href="/login">로그인</a>;
  }

  return (
    <div>
      <span>환영합니다, {user.name}</span>
      <button onClick={() => auth.signOut()}>로그아웃</button>
    </div>
  );
}

파일 업로드 설정

Uploader 인터페이스

type Uploader = (files: File[]) => Promise<SonamuFile[]>;

type SonamuFile = {
  name: string;
  url: string;
  mime_type: string;
  size: number;
};

FileService 통합

const uploadMutation = FileService.useUploadMutation();

const uploader_config = async (files: File[]): Promise<SonamuFile[]> => {
  if (files.length === 0) {
    return [];
  }

  const result = await uploadMutation.mutateAsync({ files });
  return result.files;
};
동작:
  1. mutateAsync로 파일을 백엔드에 업로드
  2. 업로드된 파일 정보 (SonamuFile[]) 반환
  3. useTypeForm이 자동으로 이 uploader를 사용

useTypeForm 자동 통합

uploader를 설정하면 useTypeFormsubmit이 자동으로 파일을 업로드합니다.
import { useTypeForm } from "@sonamu-kit/react-components";
import { FileInput } from "@sonamu-kit/react-components/components";

export function UserForm() {
  const { form, setForm, register, submit } = useTypeForm(UserSaveParams, {
    username: "",
    avatar: null,  // SonamuFile | null
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit(async (formData) => {
          // formData.avatar는 이미 업로드되어 SonamuFile 객체
          await UserService.save({ params: formData });
        });
      }}
    >
      <Input {...register("username")} />

      {/* FileInput: File → SonamuFile 변환은 submit이 자동 처리 */}
      <FileInput {...register("avatar")} />

      <button type="submit">저장</button>
    </form>
  );
}
자동 업로드 메커니즘submit 함수는 내부적으로 traverseAndUploadFiles를 호출하여 모든 File 객체를 찾아 업로드합니다. 중첩된 객체나 배열 안의 파일도 자동으로 처리됩니다.

커스텀 업로드 로직

다른 업로드 서비스를 사용하려면 직접 구현할 수 있습니다.
// AWS S3 직접 업로드 예시
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3Client = new S3Client({ region: "ap-northeast-2" });

const uploader_config = async (files: File[]): Promise<SonamuFile[]> => {
  const uploadPromises = files.map(async (file) => {
    const key = `uploads/${Date.now()}-${file.name}`;

    await s3Client.send(
      new PutObjectCommand({
        Bucket: "my-bucket",
        Key: key,
        Body: file,
        ContentType: file.type,
      }),
    );

    return {
      name: file.name,
      url: `https://my-bucket.s3.amazonaws.com/${key}`,
      mime_type: file.type,
      size: file.size,
    };
  });

  return Promise.all(uploadPromises);
};

다국어 설정 (SD)

SD 함수

SD (Sonamu Dictionary) 함수는 타입 안전한 다국어 번역을 제공합니다.
const sd_config = <K extends DictKey>(key: K): ReturnType<typeof SD<K>> => SD(key);

사용 예시

import { useSonamuContext } from "@sonamu-kit/react-components";

export function WelcomeMessage() {
  const { SD } = useSonamuContext();

  return (
    <div>
      <h1>{SD("common.welcome")}</h1>
      <p>{SD("user.login.prompt")}</p>
    </div>
  );
}
왜 Context를 통해 SD를 제공하나요?직접 import해서 사용해도 되지만, Context를 통하면 미래에 런타임 locale 전환이나 동적 dictionary 로딩을 쉽게 추가할 수 있습니다.

타입 안전성

제네릭 타입 지정

BaseSonamuProvider에 Dictionary 타입을 지정하면 SD 함수가 타입 안전해집니다.
import type { MergedDictionary } from "@/i18n/sd.generated";

<BaseSonamuProvider<MergedDictionary> authOptions={authOptions} uploader={uploader} SD={SD}>
  {children}
</BaseSonamuProvider>

자동 완성

// useSonamuBaseContext에 Dictionary와 authOptions 타입을 지정합니다.
const { SD } = useSonamuBaseContext<MergedDictionary, typeof authOptions>();

SD("

    common.welcome
    common.save
    common.cancel
    user.login.failed
    user.logout.failed
    entity.User.email
    ...

고급 설정

최소 설정 (Auth만)

파일 업로드가 필요 없다면 uploader를 생략할 수 있습니다.
export function SonamuProvider({ children }: { children: ReactNode }) {
  return (
    <BaseSonamuProvider<MergedDictionary> authOptions={authOptions} SD={SD}>
      {children}
    </BaseSonamuProvider>
  );
  // uploader 생략 (fallback 함수가 자동으로 설정됨)
}
uploader를 생략하면 FileInput 사용 시 에러가 발생합니다. 파일 업로드 기능이 필요하다면 반드시 설정하세요.

Auth 없이 사용

인증이 필요 없는 프로젝트라면 authOptions를 생략할 수 있습니다.
export function SonamuProvider({ children }: { children: ReactNode }) {
  return (
    <BaseSonamuProvider<MergedDictionary> SD={SD}>
      {children}
    </BaseSonamuProvider>
  );
}

로딩 상태 표시

import { useSonamuContext } from "@/contexts/sonamu-provider";

export function App({ children }: { children: React.ReactNode }) {
  const { auth } = useSonamuContext();
  const session = auth.useSession();
  const user = session.data?.user ?? null;

  // 초기 로딩 중
  if (session.isPending) {
    return (
      <div className="flex items-center justify-center h-screen">
        <Spinner />
      </div>
    );
  }

  // 인증 필요
  if (!user) {
    return <LoginPage />;
  }

  // 인증 완료
  return children;
}

Redirect 커스터마이징

로그인 성공 후 이동 경로를 동적으로 결정할 수 있습니다.
const handleLogin = async (email: string, password: string) => {
  const result = await auth.signIn.email({ email, password });

  if (result.error) {
    alert(result.error.message);
    return;
  }

  // 역할에 따라 다른 페이지로 이동
  const session = await auth.getSession();
  if (session.data?.user.role === "admin") {
    navigate({ to: "/admin", replace: true });
  } else {
    navigate({ to: "/dashboard", replace: true });
  }
};

문제 해결

”uploader is not configured” 에러

Error: [SonamuProvider] uploader is not configured.
Please provide uploader configuration to SonamuProvider.
해결: SonamuProvider에서 uploader를 전달하세요.

”auth is not configured” 에러

Error: [SonamuProvider] auth is not configured.
Please provide auth configuration to SonamuProvider.
해결: SonamuProviderauthOptions를 전달하세요.

QueryClient를 찾을 수 없음

Error: useQueryClient must be used within a QueryClientProvider
해결: SonamuProviderQueryClientProvider 안에 배치하세요.
<QueryClientProvider client={queryClient}>
  <SonamuProvider {...config}>
    {children}
  </SonamuProvider>
</QueryClientProvider>

다음 단계