Files
frontend/docs/as/04.curriculum-advanced.md
T

8.9 KiB

AS 프로젝트 교육 자료: 고급 과정 (Advanced Course)

대상: 대규모 컴포넌트 설계와 복잡한 클라이언트 인터랙션을 처리해야 하는 시니어급 개발자 목표: FSD 아키텍처 의존성 린팅 툴(Steiger)을 이해하고, @dnd-kit 기반의 결재선 드래그 앤 드롭을 구현하며, tus-js-client를 통한 대용량 이어올리기 및 OpenTelemetry 분산 트레이싱을 연동할 수 있다.


1. Steiger & FSD 아키텍처 아키텍처 규칙 검증

Steiger는 FSD 아키텍처 규범을 실시간으로 감시하는 파일 구조 정적 분석 린터입니다.

① FSD 핵심 위반 검출 및 조치법

  • 비정상 의존 관계 (Violation of Layer Hierarchy):
    • 상황: entities/user 내부 컴포넌트에서 features/auth-login 컴포넌트를 import하려는 시도.
    • 해결: 하위 레이어는 상위 레이어에 대해 절대 알 수 없어야 합니다. 해당 컴포넌트의 책임을 상위 레이어(pages 혹은 widgets)로 격상시키거나 하위 레이어의 성격에 맞게 분리하세요.
  • Public API 규칙 위반 (Slicing Boundaries Bypass):
    • 상황: import { UserProfile } from "@/entities/user/ui/profile" 형태로 직접 파일 경로로 접근하는 경우.
    • 해결: entities/user/index.ts에서 export { UserProfile } from "./ui/profile"을 선언하고, 사용하는 곳에서는 import { UserProfile } from "@/entities/user" 로 Public API만 접근하도록 변경해야 합니다.

2. @dnd-kit을 활용한 결재선 관리 화면 및 DnD 구현

결재선 관리(Approval Line Management) 화면은 드래그 앤 드롭으로 결재자 순서를 변경하며, 그리드 상에서 인라인 폼 편집을 처리하는 매우 복잡한 뷰입니다.

① 핵심 설계 요구사항

  1. 단일 행 편집 잠금: 신규 결재선 추가 혹은 특정 행 수정 시, 해당 행을 제외한 다른 행의 입력 및 드래그 인터랙션은 모두 비활성화되어야 합니다 (editingRowKey 상태 관리).
  2. 드래그 정렬: @dnd-kit을 사용하여 결재자 순서 변경을 UI에 즉시 반영한 뒤, [결재선 저장] 버튼을 통해 전체 순서를 일괄 저장합니다.

② 결재선 DnD 구현 예제 (ui/components/sortable-apln-table.tsx)

"use client";

import { useState } from "react";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableAplnRow } from "./sortable-apln-row";

export interface IAplnMember {
  aplnId: string;
  atrzSeqNo: number;
  atrzSeComCd: string; // 검토자, 승인자 구분
  aprvrUuid: string;
  aprvrNm: string;
}

export function SortableAplnTable() {
  const [items, setItems] = useState<IAplnMember[]>([
    { aplnId: "1", atrzSeqNo: 1, atrzSeComCd: "RVW", aprvrUuid: "user-01", aprvrNm: "김검토" },
    { aplnId: "2", atrzSeqNo: 2, atrzSeComCd: "APR", aprvrUuid: "user-02", aprvrNm: "이승인" },
  ]);
  
  // 편집 중인 행 키 (null이 아니면 드래그 및 타 행 편집 금지)
  const [editingRowKey, setEditingRowKey] = useState<string | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8, // 클릭 클릭 중 미세한 움직임으로 드래그 활성화 방지
      },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const handleDragEnd = (event: DragEndEvent) => {
    // 편집 중일 때는 드래그를 차단합니다.
    if (editingRowKey) return;

    const { active, over } = event;
    if (over && active.id !== over.id) {
      setItems((prevItems) => {
        const oldIndex = prevItems.findIndex((item) => item.aplnId === active.id);
        const newIndex = prevItems.findIndex((item) => item.aplnId === over.id);
        const reordered = arrayMove(prevItems, oldIndex, newIndex);
        
        // 정렬 후 시퀀스 넘버(atrzSeqNo)를 다시 인덱스 기준으로 갱신합니다.
        return reordered.map((item, index) => ({
          ...item,
          atrzSeqNo: index + 1,
        }));
      });
    }
  };

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <table className="min-w-full divide-y divide-gray-200">
        <thead>
          <tr>
            <th className="w-10">정렬</th>
            <th>순서</th>
            <th>구분</th>
            <th>결재자</th>
            <th>관리</th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          <SortableContext
            items={items.map((item) => item.aplnId)}
            strategy={verticalListSortingStrategy}
          >
            {items.map((item) => (
              <SortableAplnRow
                key={item.aplnId}
                item={item}
                isEditing={editingRowKey === item.aplnId}
                isDisableDrag={!!editingRowKey} // 편집 모드가 켜져 있으면 전체 드래그 비활성화
                onEditStart={() => setEditingRowKey(item.aplnId)}
                onEditCancel={() => setEditingRowKey(null)}
                onEditSave={(updated) => {
                  setItems((prev) =>
                    prev.map((p) => (p.aplnId === item.aplnId ? { ...p, ...updated } : p))
                  );
                  setEditingRowKey(null);
                }}
              />
            ))}
          </SortableContext>
        </tbody>
      </table>
    </DndContext>
  );
}

3. 대용량 파일 분할 업로드 (Tus Protocol)

일반적인 HTTP POST 업로드는 대용량 파일 전송 중 대역폭 변화나 일시적인 네트워크 끊김이 발생할 경우 처음부터 다시 업로드해야 하는 심각한 취약점이 있습니다. AS 프로젝트는 이어올리기가 가능한 Tus Protocol 규격의 파일 업로더인 tus-js-client를 활용합니다.

import * as tus from "tus-js-client";

export function uploadFileWithTus(file: File, onProgress: (pct: number) => void) {
  return new Promise<string>((resolve, reject) => {
    const upload = new tus.Upload(file, {
      endpoint: `${process.env.NEXT_PUBLIC_API_URL}/files/upload`,
      retryDelays: [0, 1000, 3000, 5000], // 연결 실패 시 재시도 간격
      metadata: {
        filename: file.name,
        filetype: file.type,
      },
      onError: (error) => {
        console.error("Tus upload failed:", error);
        reject(error);
      },
      onProgress: (bytesSent, bytesTotal) => {
        const percentage = Math.round((bytesSent / bytesTotal) * 100);
        onProgress(percentage);
      },
      onSuccess: () => {
        // 서버에서 부여받은 최종 파일 접근 경로 또는 고유 ID 획득
        console.log("Upload complete:", upload.url);
        resolve(upload.url || "");
      },
    });

    // 업로드 개시
    upload.start();
  });
}

4. OpenTelemetry를 활용한 분산 트레이싱 (APM)

AS 프로젝트에는 애플리케이션의 엔드 투 엔드(End-to-End) 요청 성능 분석과 응답 속도 지연 병목 지점 감시를 위해 **OpenTelemetry(OTel)**가 연동되어 있습니다.

  • instrumentation.ts : Next.js 서버 사이드 API 요청 및 서버 렌더링에 대한 Span을 생성하고 익스포터로 전송합니다.
  • instrumentation-client.ts : 브라우저 클라이언트 사이드 환경에서 API 호출(fetch, xhr) 및 주요 사용자 Interaction에 대해 브라우저 환경 성능 Span을 계측합니다.

OTel 동작 핵심 컨셉

  1. Context Propagation: 클라이언트에서 서버로 HTTP 요청을 보낼 때 Trace Context Header(traceparent)를 실어 보냄으로써, 브라우저에서의 동작과 백엔드 API 서비스에서의 병목 지점 간의 연관 관계를 하나로 통합해서 관찰할 수 있게 합니다.

5. 고급 실습 과제

과제 내용

  • ui/components/sortable-apln-table.tsx를 확장하여, 새로운 결재선을 리스트 맨 밑에 추가하는 [행 추가] 기능을 구현하세요.
    • 행 추가 시 새로 생성된 행은 즉시 편집 상태(editingRowKey = 'new-temp')여야 하며, 사용자가 저장을 누르면 REST API(POST /api/v1/elapr-apln)를 호출하고 임시 키 대신 서버에서 생성된 진짜 ID로 대체되어야 합니다.
    • 사용자가 입력을 다 채우지 않고 취소를 누르면, 해당 추가되었던 행이 목록에서 사라져야 합니다.
  • 제약 조건: 모든 API 통신은 TanStack Query의 Mutation(useMutation) 및 캐시 무효화(invalidateQueries)를 통해 완전한 동기화 흐름을 가지도록 개발하세요.