Sonamu 프론트엔드 통합의 시작점인 SonamuProvider 설정 방법을 알아봅니다. 인증, 파일 업로드, 다국어 지원을 한 곳에서 구성할 수 있습니다.
설정 개요
인증 통합 UserService.useMe 연결 로그인/로그아웃 흐름
파일 업로드 FileService 통합 useTypeForm 자동 연결
전역 상태 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를 별도 파일로 만드나요? SonamuProvider는 FileService.useUploadMutation() 같은 React 훅을 사용하므로 QueryClientProvider 아래에 배치해야 합니다.
별도 파일로 분리하면 authOptions, useSonamuContext 등의 설정과 타입을 함께 관리할 수 있습니다.
인증 설정
Sonamu는 better-auth 를 기반으로 인증을 처리합니다. SonamuProvider에 authOptions를 전달하면 내부적으로 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 ;
};
동작 :
mutateAsync로 파일을 백엔드에 업로드
업로드된 파일 정보 (SonamuFile[]) 반환
useTypeForm이 자동으로 이 uploader를 사용
uploader를 설정하면 useTypeForm의 submit이 자동으로 파일을 업로드합니다.
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 });
}
};
문제 해결
Error: [SonamuProvider] uploader is not configured.
Please provide uploader configuration to SonamuProvider.
해결 : SonamuProvider에서 uploader를 전달하세요.
Error: [SonamuProvider] auth is not configured.
Please provide auth configuration to SonamuProvider.
해결 : SonamuProvider에 authOptions를 전달하세요.
QueryClient를 찾을 수 없음
Error: useQueryClient must be used within a QueryClientProvider
해결 : SonamuProvider를 QueryClientProvider 안에 배치하세요.
< QueryClientProvider client = { queryClient } >
< SonamuProvider { ... config } >
{ children }
</ SonamuProvider >
</ QueryClientProvider >
다음 단계