Files
genome2025/backend/doc/프론트엔드_API_연동_가이드.md
2025-12-09 17:02:27 +09:00

17 KiB

프론트엔드 API 연동 가이드

작성일: 2025-10-26 버전: 1.0 대상: 한우 유전체 분석 시스템 프론트엔드 개발자

📋 목차

  1. 개요
  2. 인증 흐름
  3. 사용자-농장-개체 관계
  4. API 연동 방법
  5. 주요 구현 사례
  6. 문제 해결
  7. 추가 구현 권장사항

1. 개요

1.1 시스템 구조

사용자 (User) 1:N 농장 (Farm) 1:N 개체 (Cow)
  • User: 로그인한 사용자 (농가, 컨설턴트, 기관담당자)
  • Farm: 사용자가 소유한 농장 (한 사용자가 여러 농장 소유 가능)
  • Cow: 농장에 속한 개체 (한우)

1.2 주요 기술 스택

백엔드:

  • NestJS 10.x
  • TypeORM
  • JWT 인증
  • PostgreSQL

프론트엔드:

  • Next.js 15.5.3 (App Router)
  • TypeScript
  • Zustand (상태관리)
  • Axios (HTTP 클라이언트)

1.3 API Base URL

개발: http://localhost:4000
운영: 환경변수 NEXT_PUBLIC_API_URL 사용

2. 인증 흐름

2.1 JWT 기반 인증

모든 API 요청은 JWT 토큰을 필요로 합니다 (일부 Public 엔드포인트 제외).

전역 Guard 적용:

// backend/src/main.ts
app.useGlobalGuards(new JwtAuthGuard(reflector));

2.2 로그인 프로세스

// 1. 사용자 로그인
const response = await authApi.login({
  userId: 'user123',
  userPassword: 'password123'
});

// 2. 응답 구조
{
  accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
  refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
  user: {
    userNo: 1,
    userName: '홍길동',
    userEmail: 'hong@example.com',
    // ...
  }
}

// 3. 토큰 자동 저장 (auth-store.ts에서 처리)
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('refreshToken', response.refreshToken);

2.3 자동 토큰 주입

apiClient가 모든 요청에 자동으로 토큰을 추가합니다:

// frontend/src/lib/api-client.ts (자동 처리)
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

개발자는 별도로 토큰을 관리할 필요 없음


3. 사용자-농장-개체 관계

3.1 데이터 모델

// User Entity
{
  pkUserNo: number;           // 사용자 번호
  userId: string;             // 로그인 ID
  userName: string;           // 이름
  userSe: 'FARM' | 'CNSLT' | 'ORGAN';  // 사용자 구분
  farms: FarmModel[];         // 소유 농장 목록
}

// Farm Entity
{
  pkFarmNo: number;           // 농장 번호
  fkUserNo: number;           // 사용자 번호 (FK)
  farmCode: string;           // 농장 코드 (F000001)
  farmName: string;           // 농장명
  farmAddress: string;        // 농장 주소
  cows: CowModel[];           // 농장의 개체 목록
}

// Cow Entity
{
  pkCowNo: string;            // 개체번호 (12자리)
  fkFarmNo: number;           // 농장 번호 (FK)
  cowSex: 'M' | 'F';          // 성별
  cowBirthDt: Date;           // 생년월일
  cowStatus: string;          // 개체 상태
  delYn: 'Y' | 'N';           // 삭제 여부
}

3.2 관계 API 호출 순서

// 올바른 순서:
// 1. 로그인한 사용자의 농장 목록 조회
const farms = await farmApi.findAll();  // GET /farm

// 2. 특정 농장의 개체 조회
const cows = await cowApi.findByFarmNo(farms[0].pkFarmNo);  // GET /cow/farm/:farmNo

잘못된 방법: farmNo를 하드코딩

const cows = await cowApi.findByFarmNo(1);  // ❌ 다른 사용자의 데이터 접근 불가

4. API 연동 방법

4.1 API 모듈 구조

frontend/src/lib/api/
├── api-client.ts          # Axios 인스턴스 + 인터셉터
├── index.ts               # 모든 API export
├── auth.api.ts            # 인증 API
├── farm.api.ts            # 농장 API
├── cow.api.ts             # 개체 API
├── kpn.api.ts             # KPN API
└── dashboard.api.ts       # 대시보드 API

4.2 Import 방법

import { cowApi, farmApi, authApi } from '@/lib/api';

4.3 주요 Farm API

// 1. 현재 사용자의 농장 목록 조회
const farms = await farmApi.findAll();
// GET /farm
// 응답: FarmDto[]

// 2. 농장 상세 조회
const farm = await farmApi.findOne(farmNo);
// GET /farm/:id
// 응답: FarmDto

// 3. 농장 생성
const newFarm = await farmApi.create({
  userNo: 1,
  farmName: '행복농장',
  farmAddress: '충청북도 보은군...',
  farmBizNo: '123-45-67890'
});
// POST /farm

4.4 주요 Cow API

// 1. 특정 농장의 개체 목록 조회
const cows = await cowApi.findByFarmNo(farmNo);
// GET /cow/farm/:farmNo
// 응답: CowDto[]

// 2. 개체 상세 조회
const cow = await cowApi.findOne(cowNo);
// GET /cow/:cowNo
// 응답: CowDto

// 3. 개체 검색
const results = await cowApi.search('KOR001', farmNo, 20);
// GET /cow/search?keyword=KOR001&farmNo=1&limit=20
// 응답: CowDto[]

// 4. 개체 랭킹 조회 (필터 + 정렬)
const ranking = await cowApi.getRanking({
  filterOptions: {
    filters: [/* 필터 조건 */]
  },
  rankingOptions: {
    criteria: 'GENE',
    order: 'DESC'
  }
});
// POST /cow/ranking
// 응답: RankingResult<CowDto>

5. 주요 구현 사례

5.1 개체 목록 페이지 (/cow/page.tsx)

완전한 구현 예시 (하드코딩 없음):

'use client'

import { useState, useEffect } from 'react'
import { cowApi, farmApi } from '@/lib/api'
import type { Cow } from '@/types/cow.types'

export default function CowListPage() {
  const [cows, setCows] = useState<Cow[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const fetchCows = async () => {
      try {
        setLoading(true)
        setError(null)

        // 1단계: 사용자의 농장 목록 조회
        const farms = await farmApi.findAll()

        if (!farms || farms.length === 0) {
          setError('등록된 농장이 없습니다. 농장을 먼저 등록해주세요.')
          setLoading(false)
          return
        }

        // 2단계: 첫 번째 농장의 개체 조회
        // TODO: 여러 농장이 있을 경우 사용자가 선택할 수 있도록 UI 추가
        const farmNo = farms[0].pkFarmNo

        const data = await cowApi.findByFarmNo(farmNo)
        setCows(data)
      } catch (err) {
        console.error('개체 데이터 조회 실패:', err)
        setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다')
      } finally {
        setLoading(false)
      }
    }

    fetchCows()
  }, [])

  if (loading) return <div>로딩 ...</div>
  if (error) return <div>에러: {error}</div>

  return (
    <div>
      <h1>개체 목록</h1>
      {cows.map(cow => (
        <div key={cow.pkCowNo}>
          <p>개체번호: {cow.pkCowNo}</p>
          <p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
        </div>
      ))}
    </div>
  )
}

5.2 개체 상세 페이지 (/cow/[cowNo]/page.tsx)

'use client'

import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { cowApi } from '@/lib/api'
import type { Cow } from '@/types/cow.types'

export default function CowDetailPage() {
  const params = useParams()
  const cowNo = params.cowNo as string
  const [cow, setCow] = useState<Cow | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const fetchCow = async () => {
      try {
        const data = await cowApi.findOne(cowNo)
        setCow(data)
      } catch (err) {
        console.error('개체 상세 조회 실패:', err)
      } finally {
        setLoading(false)
      }
    }

    fetchCow()
  }, [cowNo])

  if (loading) return <div>로딩 ...</div>
  if (!cow) return <div>개체를 찾을  없습니다</div>

  return (
    <div>
      <h1>개체 상세: {cow.pkCowNo}</h1>
      <p>농장번호: {cow.fkFarmNo}</p>
      <p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
      <p>생년월일: {cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString() : '-'}</p>
    </div>
  )
}

5.3 Dashboard 통계 페이지

'use client'

import { useState, useEffect } from 'react'
import { farmApi, cowApi } from '@/lib/api'

export default function DashboardPage() {
  const [stats, setStats] = useState({
    totalFarms: 0,
    totalCows: 0,
    farms: []
  })

  useEffect(() => {
    const fetchStats = async () => {
      try {
        // 1. 사용자의 농장 목록 조회
        const farms = await farmApi.findAll()

        // 2. 각 농장의 개체 수 집계
        let totalCows = 0
        const farmsWithCows = await Promise.all(
          farms.map(async (farm) => {
            const cows = await cowApi.findByFarmNo(farm.pkFarmNo)
            totalCows += cows.length
            return {
              ...farm,
              cowCount: cows.length
            }
          })
        )

        setStats({
          totalFarms: farms.length,
          totalCows,
          farms: farmsWithCows
        })
      } catch (err) {
        console.error('통계 조회 실패:', err)
      }
    }

    fetchStats()
  }, [])

  return (
    <div>
      <h1>대시보드</h1>
      <p> 농장 : {stats.totalFarms}</p>
      <p> 개체 : {stats.totalCows}마리</p>
      {stats.farms.map(farm => (
        <div key={farm.pkFarmNo}>
          <p>{farm.farmName}: {farm.cowCount}마리</p>
        </div>
      ))}
    </div>
  )
}

6. 문제 해결

6.1 인증 에러 (401 Unauthorized)

증상:

{"statusCode":401,"message":["인증이 필요합니다. 로그인 후 이용해주세요."]}

원인:

  • localStorage에 토큰이 없음
  • 토큰 만료

해결 방법:

// 1. 로그인 상태 확인
const { isAuthenticated } = useAuthStore()

if (!isAuthenticated) {
  router.push('/login')
  return
}

// 2. 토큰 갱신 (필요 시)
await authApi.refreshToken(refreshToken)

6.2 농장이 없는 경우

증상:

등록된 농장이 없습니다.

해결 방법:

if (!farms || farms.length === 0) {
  return (
    <div>
      <p>등록된 농장이 없습니다.</p>
      <button onClick={() => router.push('/farm/create')}>
        농장 등록하기
      </button>
    </div>
  )
}

6.3 CORS 에러

증상:

Access to XMLHttpRequest has been blocked by CORS policy

해결 방법:

백엔드에서 CORS 설정 확인:

// backend/src/main.ts
app.enableCors({
  origin: 'http://localhost:3000',
  credentials: true,
});

7. 추가 구현 권장사항

7.1 여러 농장 선택 UI

현재는 첫 번째 농장만 사용하지만, 사용자가 여러 농장을 소유한 경우 선택할 수 있어야 합니다.

구현 예시:

'use client'

import { useState, useEffect } from 'react'
import { farmApi, cowApi } from '@/lib/api'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

export default function CowListPage() {
  const [farms, setFarms] = useState([])
  const [selectedFarmNo, setSelectedFarmNo] = useState<number | null>(null)
  const [cows, setCows] = useState([])

  useEffect(() => {
    const fetchFarms = async () => {
      const data = await farmApi.findAll()
      setFarms(data)

      // 첫 번째 농장 자동 선택
      if (data.length > 0) {
        setSelectedFarmNo(data[0].pkFarmNo)
      }
    }

    fetchFarms()
  }, [])

  useEffect(() => {
    if (!selectedFarmNo) return

    const fetchCows = async () => {
      const data = await cowApi.findByFarmNo(selectedFarmNo)
      setCows(data)
    }

    fetchCows()
  }, [selectedFarmNo])

  return (
    <div>
      <Select value={String(selectedFarmNo)} onValueChange={(val) => setSelectedFarmNo(Number(val))}>
        <SelectTrigger>
          <SelectValue placeholder="농장 선택" />
        </SelectTrigger>
        <SelectContent>
          {farms.map(farm => (
            <SelectItem key={farm.pkFarmNo} value={String(farm.pkFarmNo)}>
              {farm.farmName} ({farm.farmCode})
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <div>
        {cows.map(cow => (
          <div key={cow.pkCowNo}>{cow.pkCowNo}</div>
        ))}
      </div>
    </div>
  )
}

7.2 유전자 정보 포함 API 사용

현재 GET /cow/farm/:farmNo는 기본 정보만 반환합니다. 유전자 정보가 필요한 경우 POST /cow/ranking API를 사용하세요.

구현 예시:

// 유전자 정보를 포함한 개체 조회
const ranking = await cowApi.getRanking({
  filterOptions: {
    filters: [
      {
        field: 'cow.fkFarmNo',
        operator: 'equals',
        value: farmNo
      }
    ]
  },
  rankingOptions: {
    criteria: 'GENE',
    order: 'DESC'
  }
})

// ranking.items에 SNP, Trait 등 모든 관계 데이터 포함됨
const cowsWithGenes = ranking.items

백엔드 참조:

  • backend/src/cow/cow.service.ts:122-140 - createRankingQueryBuilder()
  • SNP, Trait, Repro, MPT 데이터를 모두 leftJoin으로 포함

7.3 에러 바운더리 추가

// components/ErrorBoundary.tsx
'use client'

import { useEffect } from 'react'

export default function ErrorBoundary({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error('에러 발생:', error)
  }, [error])

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2 className="text-2xl font-bold mb-4">문제가 발생했습니다</h2>
      <p className="mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        다시 시도
      </button>
    </div>
  )
}

7.4 로딩 스켈레톤 UI

// components/CowListSkeleton.tsx
export default function CowListSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[1, 2, 3, 4, 5, 6].map((i) => (
        <div key={i} className="border rounded p-4 animate-pulse">
          <div className="h-6 bg-gray-200 rounded mb-2"></div>
          <div className="h-4 bg-gray-200 rounded"></div>
        </div>
      ))}
    </div>
  )
}

// 사용
if (loading) return <CowListSkeleton />

8. 참고 자료

8.1 백엔드 API 문서

  • Cow API: backend/src/cow/cow.controller.ts
  • Farm API: backend/src/farm/farm.controller.ts
  • Auth API: backend/src/auth/auth.controller.ts

8.2 프론트엔드 코드 위치

frontend/src/
├── app/
│   ├── cow/
│   │   ├── page.tsx                    # 개체 목록 (완성)
│   │   └── [cowNo]/page.tsx           # 개체 상세
│   └── dashboard/page.tsx              # 대시보드
├── lib/api/
│   ├── cow.api.ts                      # Cow API
│   ├── farm.api.ts                     # Farm API (신규 추가)
│   └── index.ts                        # API export
├── types/
│   ├── cow.types.ts                    # Cow 타입
│   └── auth.types.ts                   # Auth 타입
└── store/
    └── auth-store.ts                   # 인증 상태 관리

8.3 타입 정의 참조

백엔드 Entity → 프론트엔드 Types 매핑:

백엔드 Entity 프론트엔드 Types 설명
UsersModel UserDto 사용자
FarmModel FarmDto 농장
CowModel CowDto 개체
KpnModel KpnDto KPN

필드명 주의사항:

  • 백엔드: pkCowNo, fkFarmNo, cowBirthDt, cowSex
  • 프론트엔드도 동일하게 사용 (DTO 변환 없음)

9. 체크리스트

개발 시 확인사항:

  • JWT 토큰이 localStorage에 저장되는가?
  • API 호출 시 Authorization 헤더가 자동으로 추가되는가?
  • farmNo를 하드코딩하지 않고 farmApi.findAll()로 조회하는가?
  • 농장이 없는 경우를 처리했는가?
  • 에러 발생 시 사용자에게 적절한 메시지를 보여주는가?
  • 로딩 상태를 표시하는가?
  • 여러 농장이 있는 경우를 고려했는가?

10. 문의

질문이나 문제가 있는 경우:

  1. 백엔드 API 문서 확인: backend/doc/기능요구사항전체정리.md
  2. PRD 문서 확인: E:/repo5/prd/
  3. 코드 참조:
    • 완성된 /cow/page.tsx 구현 참조
    • lib/api-client.ts 인터셉터 참조
    • store/auth-store.ts 인증 흐름 참조

문서 작성: Claude Code 최종 수정일: 2025-10-26