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

설정 개요

인증 통합

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

파일 업로드

FileService 통합useTypeForm 자동 연결

다국어 지원

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

전역 상태

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

기본 설정

1. 설정 파일 생성

프로젝트의 src/config/sonamu-provider.config.ts 파일을 생성합니다.
// src/config/sonamu-provider.config.ts
import type { SonamuAuth, SonamuContextValue, SonamuFile } from "@sonamu-kit/react-components";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import type { DictKey, MergedDictionary } from "@/i18n/sd.generated";
import { SD } from "@/i18n/sd.generated";
import { FileService, UserService } from "@/services/services.generated";
import type { UserSubsetSS } from "@/services/sonamu.generated";
import type { UserLoginParams } from "@/services/user/user.types";

export function createSonamuConfig(): SonamuContextValue<MergedDictionary> {
  // Auth 설정
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  const { data: user, isLoading, refetch } = UserService.useMe();
  const loginMutation = UserService.useLoginMutation();
  const logoutMutation = UserService.useLogoutMutation();

  const auth_config: SonamuAuth<UserSubsetSS, UserLoginParams> = {
    user: user ?? null,
    loading: isLoading || loginMutation.isPending || logoutMutation.isPending,
    login: (loginParams: UserLoginParams) => {
      loginMutation.mutate(
        { params: loginParams },
        {
          onSuccess: async ({ user: _user }) => {
            await queryClient.invalidateQueries({ queryKey: ["User", "me"] });
            await queryClient.refetchQueries({ queryKey: ["User", "me"] });
            navigate({ to: "/admin", replace: true });
          },
          onError: (error) => {
            console.error("Login failed:", error);
            alert(SD("user.login.failed"));
          },
        },
      );
    },
    logout: () => {
      logoutMutation.mutate(undefined, {
        onSuccess: async () => {
          await queryClient.invalidateQueries({ queryKey: ["User", "me"] });
          await queryClient.refetchQueries({ queryKey: ["User", "me"] });
        },
        onError: (error) => {
          console.error("Logout failed:", error);
          alert(SD("user.logout.failed"));
        },
      });
    },
    refetch,
  };

  // Uploader 설정
  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;
  };

  // SD 설정
  const sd_config = <K extends DictKey>(key: K): ReturnType<typeof SD<K>> => SD(key);

  return { auth: auth_config, uploader: uploader_config, SD: sd_config };
}

2. App에 적용

__root.tsx에서 SonamuProvider를 설정합니다.
// src/routes/__root.tsx
import { SonamuProvider } from "@sonamu-kit/react-components";
import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { createSonamuConfig } from "@/config/sonamu-provider.config";
import type { MergedDictionary } from "@/i18n/sd.generated";

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

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

  return (
    <QueryClientProvider client={queryClient}>
      <SonamuProviderWrapper>
        <Outlet />
      </SonamuProviderWrapper>
    </QueryClientProvider>
  );
}

function SonamuProviderWrapper({ children }: { children: React.ReactNode }) {
  const sonamuConfig = createSonamuConfig();
  return <SonamuProvider<MergedDictionary> {...sonamuConfig}>{children}</SonamuProvider>;
}
왜 SonamuProviderWrapper를 별도로 만드나요?createSonamuConfig는 React 훅(useQueryClient, useNavigate 등)을 사용하므로 컴포넌트 안에서 호출되어야 합니다. 따라서 별도 컴포넌트로 분리하여 QueryClientProvider 아래에 배치합니다.

인증 설정

Auth 인터페이스

export type SonamuAuth<TUser = any, TLoginParams = any> = {
  user: TUser | null;
  loading: boolean;
  login: (params: TLoginParams) => void;
  logout: () => void;
  refetch: () => void;
};

User 타입 지정

자동 생성된 User Subset을 사용합니다.
import type { UserSubsetSS } from "@/services/sonamu.generated";

const auth_config: SonamuAuth<UserSubsetSS, UserLoginParams> = {
  // UserSubsetSS: Session Summary (세션용 최소 정보)
  // 예: { id, username, email, role }
  user: user ?? null,
  // ...
};
Subset “SS”란?Session Summary의 약자로, 세션 관리에 필요한 최소한의 사용자 정보만 포함합니다. 전체 사용자 정보(Subset “A”)를 로드하는 것보다 가볍고 빠릅니다.

로그인 흐름

login: (loginParams: UserLoginParams) => {
  loginMutation.mutate(
    { params: loginParams },
    {
      onSuccess: async ({ user: _user }) => {
        // 1. 캐시 무효화
        await queryClient.invalidateQueries({ queryKey: ["User", "me"] });

        // 2. 최신 사용자 정보 리패칭
        await queryClient.refetchQueries({ queryKey: ["User", "me"] });

        // 3. 관리자 페이지로 이동
        navigate({ to: "/admin", replace: true });
      },
      onError: (error) => {
        console.error("Login failed:", error);
        alert(SD("user.login.failed"));
      },
    },
  );
},
핵심 단계:
  1. loginMutation.mutate: 백엔드 로그인 API 호출
  2. invalidateQueries: 기존 사용자 정보 캐시 무효화
  3. refetchQueries: 새로운 사용자 정보 가져오기
  4. navigate: 성공 시 페이지 이동

로그아웃 흐름

logout: () => {
  logoutMutation.mutate(undefined, {
    onSuccess: async () => {
      // 사용자 정보 리패칭 (null로 바뀜)
      await queryClient.invalidateQueries({ queryKey: ["User", "me"] });
      await queryClient.refetchQueries({ queryKey: ["User", "me"] });
    },
    onError: (error) => {
      console.error("Logout failed:", error);
      alert(SD("user.logout.failed"));
    },
  });
},

Auth 사용하기

컴포넌트에서 useSonamuContext로 auth에 접근합니다.
import { useSonamuContext } from "@sonamu-kit/react-components";

export function Header() {
  const { auth } = useSonamuContext();

  if (auth.loading) {
    return <div>로딩...</div>;
  }

  if (!auth.user) {
    return (
      <button onClick={() => auth.login({ email: "[email protected]", password: "password" })}>
        로그인
      </button>
    );
  }

  return (
    <div>
      <span>환영합니다, {auth.user.username}</span>
      <button onClick={() => auth.logout()}>로그아웃</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 로딩을 쉽게 추가할 수 있습니다.

타입 안전성

제네릭 타입 지정

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

<SonamuProvider<MergedDictionary> {...sonamuConfig}>
  {children}
</SonamuProvider>

자동 완성

const { SD } = useSonamuContext<MergedDictionary>();

SD("

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

고급 설정

최소 설정 (Auth만)

파일 업로드가 필요 없다면 uploader를 생략할 수 있습니다.
export function createSonamuConfig(): SonamuContextValue<MergedDictionary> {
  const auth_config = { /* ... */ };
  const sd_config = SD;

  return { auth: auth_config, SD: sd_config };
  // uploader 생략 (fallback 함수가 자동으로 설정됨)
}
uploader를 생략하면 FileInput 사용 시 에러가 발생합니다. 파일 업로드 기능이 필요하다면 반드시 설정하세요.

Auth 없이 사용

인증이 필요 없는 프로젝트라면 auth도 생략 가능합니다.
export function createSonamuConfig(): SonamuContextValue<MergedDictionary> {
  const sd_config = SD;

  return { SD: sd_config };
}

로딩 상태 표시

export function App({ children }: { children: React.ReactNode }) {
  const { auth } = useSonamuContext();

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

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

  // 인증 완료
  return children;
}

Redirect 커스터마이징

로그인 성공 후 이동 경로를 동적으로 결정할 수 있습니다.
login: (loginParams: UserLoginParams) => {
  loginMutation.mutate(
    { params: loginParams },
    {
      onSuccess: async ({ user: _user }) => {
        await queryClient.invalidateQueries({ queryKey: ["User", "me"] });
        await queryClient.refetchQueries({ queryKey: ["User", "me"] });

        // 역할에 따라 다른 페이지로 이동
        if (_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.
해결: createSonamuConfig에서 uploader_config를 반환하세요.

”auth is not configured” 에러

Error: [SonamuProvider] auth is not configured.
Please provide auth configuration to SonamuProvider.
해결: createSonamuConfig에서 auth_config를 반환하세요.

QueryClient를 찾을 수 없음

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

다음 단계