Allra Fintech
Convention

아키텍처

이 가이드에서 '아키텍처'는 프로젝트 요구사항과 팀이 성장함에 따라 확장할 수 있도록 앱을 구조화하고, 조직화하고, 설계하는 방법을 의미합니다.

컨벤션

레이어 구조

프로젝트는 DATA LayerUI Layer로 분리됩니다.

DATA Layer

도메인(백엔드 API 기반)별로 구조화

src/data/domain/user/
└── api/
    └── get-me/
        ├── index.ts          # API 함수 (handleApi 사용)
        ├── schema.ts         # Zod Schema, 데이터 검증
        └── fixtures.ts       # Mock 데이터 (스토리북, playwright)

특징:

  • 백엔드 API 계약에 의존하는 순수 API 호출 로직만 포함
  • React Query 및 클라이언트 로직 제외 (DATA Layer는 독립적)
  • Fixtures는 정상/빈 리스트/에러 케이스를 모두 포함하여 테스트 커버리지 확보

UI Layer

사용자 인터페이스 및 클라이언트 로직

구조

src/views/{view-name}/
├── index.tsx                    # 뷰 컴포넌트
├── index.stories.tsx           # Storybook 파일
├── components/                 # 뷰 전용 컴포넌트
│   └── {component-name}.tsx
└── use-{view-name}.ts        # 비즈니스 로직 (선택사항)

특징

  • 라우터와 뷰 분리 (src/app/ vs src/views/): 테스트 용이성과 관심사 분리
  • 플랫한 뷰 구조: 각 뷰를 독립 디렉토리로 관리하여 스토리북 관리 용이
  • 컴포넌트와 로직 분리: 뷰는 렌더링만, 비즈니스 로직은 커스텀 훅으로 처리

Hook 구분

  • Domain Hook (src/data/domain/{domain}/hooks/): React Query로 서버 상태 관리
    • DATA Layer API와 매핑, 캐싱, 에러 처리
    • 예: useCurrentUser, useNotifications, useApplyOverview
  • UI Hook (src/views/{view}/use-{view-name}.ts): 비즈니스 로직 처리
    • 여러 Domain Hook 조합, 내비게이션, 상태 관리
    • 예: useSignInViewModel, useApplyConfirm, useNotificationSetting

Layer 간 데이터 흐름:

Architecture

컴포넌트 계층

  • Shared Components (src/shared/components/): 프로젝트 전반에서 재사용되는 범용 컴포넌트
    • Button, Input, Text, Container, Modal 등
  • View Components (src/views/{view}/components/): 특정 뷰에서만 사용되는 컴포넌트
    • 각 뷰의 고유한 UI 요소들

스토어 (Zustand)

  • 전역 상태 관리는 src/stores/에서 관리
  • 세션, 임시 폼 데이터, UI 상태 등 클라이언트 상태 관리
  • 서버 상태 관리는 React Query로 관리 (DATA Layer - Domain Hook)

실제 코드 예시

DATA Layer 예시

// src/data/domain/user/api/get-me/schema.ts
import { z } from 'zod'

export const getMeResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
  company: z.object({
    id: z.number(),
    name: z.string(),
  }).nullish(),
})

export type GetMeResponse = z.infer<typeof getMeResponseSchema>
// src/data/domain/user/api/get-me/index.ts
import { API_ENDPOINT } from '@/data/constants/api-endpoint'
import { handleApi } from '@/data/utils/handle-api'
import { getMeResponseSchema } from './schema'

export const getMe = async () => {
  return handleApi({
    requestConfig: {
      endpoint: API_ENDPOINT.USER.ME,
    },
    responseConfig: {
      dataSchema: getMeResponseSchema,
      errorCode: ['USER_NOT_FOUND'] as const,
    },
  })
}

DATA Layer 예시 - Domain Hook

// src/data/domain/user/hooks/use-current-user.ts
export const useCurrentUser = () => {
  const { session } = useSessionStore()
  const { data, isLoading, error, isError, refetch } = useQuery({
    queryKey: QUERY_KEY.USER.ME,
    queryFn: async () => {
      const response = await getMe()
      if (response.result === 'ERROR') {
        if (response.data.code === 공통에러_코드.AUTH_REQUIRED) {
          throw new AuthorizationError()
        }
        throw new RequestFailedError()
      }
      return response.data
    },
    throwOnError: true,
    staleTime: 5 * 60 * 1000,
  })

  const status: 'authenticated' | 'unauthenticated' | 'loading' | undefined = useMemo(() => {
    if (session === null) return 'unauthenticated'
    if (isLoading) return 'loading'
    if (error) return 'unauthenticated'
    if (data) return 'authenticated'
    return 'unauthenticated'
  }, [isLoading, error, data, session])

  return { data, isLoading, error, isError, status, refetch }
}

참고: Domain hook은 데이터 레이어에 속하고 api와 맵핑되어 UI layer에서 사용하기 편하도록 데이터를 변환하는 역할을 맡습니다.

UI Layer 예시 - UI Hook

// src/views/home/components/status-info/use-status-info.ts
export const useStatusInfo = () => {
  const { data: user } = useCurrentUser()
  const { data: eligibilityInfo } = useApplyOverview()
  
  const status = useMemo(() => {
    if (!user || !eligibilityInfo) return '로딩'
    if (eligibilityInfo.repaymentInProgressAmount) return '납부중'
    return '이용가능'
  }, [user, eligibilityInfo])
  
  return { user, eligibilityInfo, status }
}
// src/views/home/components/status-info/index.tsx
export const StatusInfo = () => {
  const { status, eligibilityInfo, error } = useStatusInfo()

  if (status === '에러') {
    throw error
  }

  if (status === '로딩') {
    return <Skeleton width="100%" height={130} borderRadius="xl" />
  }

  if (status === '납부중') {
    return <RepayProgressStatus />
  }

  return null
}
// src/views/home/index.tsx
export const HomeScreen = () => {
  const { theme } = useTheme()
  const { refreshing, onRefresh } = useRefreshQuery()

  return (
    <Container edges={['top']} paddingBottom={0}>
      <HomeHeaderNavigation />
      <ScrollView
        style={{ flex: 1, marginHorizontal: -20 }}
        contentContainerStyle={{ gap: theme.spacing[7], paddingBottom: theme.spacing[10] }}
        refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
        <Column
          gap={7}
          backgroundColor="componentAlternative"
          style={{
            paddingVertical: theme.spacing.container,
            paddingHorizontal: theme.spacing.container,
          }}>
          <ContractStatusBanner />
          <StatusInfo />
          <PaymentPendingList />
        </Column>
        <OhwalGuides />
      </ScrollView>
    </Container>
  )
}

참고: src/views/home/index.tsx는 실제 화면과 1:1 매핑되는 컴포넌트로 컴포넌트 사이의 간격을 조정합니다.