현재 모바일 반응형은 준비중에 있습니다.

데스크탑에서 확인해주세요.

화면 넓이는 768px 이상이어야 합니다.


Scroll Down Or Click

Project

Haedeup

Period

2024.09 ~ 2024.10

Role

Front-End(100%)

Team

FE - 1

BE - 1

Designer - 1

PM - 1

PO - 1

Description

(주)우당 네트웍의 헬스 케어 서비스입니다. [ 상세 구현 사항 ] [프론트엔드] 엔드 포인트 관리와 ui 재사용성을 위해 모노레포를 도입했습니다. tailwind기반의 빠른 스타일링, 디자인 토큰 생성 및 커스터마이징을 위해 shadcn/ui를 도입했습니다. useFunnel훅을 이용해 추후 개발 예정이었던 웹뷰 형식에서 앱과 비슷한 동작을 할 수 있도록 구현했습니다. 백엔드와 원활한 소통 및 API문서 작업 공수를 줄이기 위해 GraphQL 도입, code generater를 통한 API, 타입, react query 작성을 자동화 했습니다. 해듭의 피부 분석 리포트 PDF 자동화 백 오피스 툴을 구현했습니다. Next.js puppeteer, API Router, Prisma, SQLite를 통해 간단한 백엔드를 구현했습니다. [백엔드] Nest.js 기반 GraphQL을 도입했습니다.

Skill

Next.js @14.2.3

SSR, SEO를 위한 메타태그 설정, 이미지 최적화 등 next.js의 기능을 활용하기 위해 도입했습니다. 리액트 쿼리를 이용한 서버패치도 사용했습니다.

tailwindcss @3.4.10

tailwindCSS를 통해 디자인 시스템을 구성하고, css를 파일 대신 클래스 네임으로 사용하도록 도입했습니다.

shadcn/UI @2.1.0

shadcn/UI를 통해 빠르게 디자인 시스템을 구성하고, 컴포넌트를 재사용하기 위해 도입했습니다.

@tanstack/react-query @5.37.1

서버로부터 받아오는 데이터의 전역 상태, 캐싱, loading 상태 등을 관리하기 위해 도입했습니다. 또한 ApolloClient 대신 GraphQL의 상태관리를 위해 사용합니다.

zustand @4.5.2

컴포넌트의 상태와 같이 클라이언트 측에서 사용할 전역 상태(ex. 모달 등)을 관리하기 위해 도입했습니다.

GraphQL

클라이언트 측에서 원하는 데이터를 조회하기 위해 도입했습니다. API 문서 작성, 반복되는 API 중복을 줄이기 위해 사용했습니다.

@graphql-codegen/cli @5.0.2

GraphQL 코드 생성을 통해 쿼리문을 작성하면 리액트 쿼리를 통해 API 함수를 자동으로 작성해줍니다. 코드 작성 시간을 줄이기 위해 사용했습니다.

turbo @2.1.0

모노레포를 통해 일관된 lint, config를 유지하고, 공통 컴포넌트를 package화 해서 재활용하기 위해 도입했습니다.

Problem Solving

백오피스 PDF 툴 구현

문제 상황

프로젝트 오너(PO)로부터 PDF를 일일이 피그마로 작성하여 고객에게 전달하기에는 인원이 부족하다는 요청을 받았습니다.
따라서 프론트엔드에서 HTML을 PDF로 변환할 수 있는지, 그리고 백엔드의 추가 작업 없이 JSON 또는 CSV 형식의 파일을 불러와 사용자 정보에 따라 PDF 내용을 동적으로 변경할 수 있는지에 대한 요구가 있었습니다.
또한, PDF에서 링크 첨부와 텍스트 드래그가 가능한지도 요구사항에 포함되었습니다.

해결 과정

이전 프로젝트에서 사용한 Puppeteer를 활용하여 페이지를 PDF로 추출하기로 결정했습니다. Puppeteer는 HTML 기반으로 브라우저에서 넓이와 높이를 설정하여 PDF를 생성할 수 있어, 이미지 캡처 방식과 달리 텍스트 복사와 링크 기능을 적용할 수 있었습니다.
Next.js를 사용하고 있었기 때문에, API Router를 통해 백엔드 기능을 충분히 활용할 수 있었습니다. 사용자 정보를 JSON으로 불러오는 경우, 1차적인 유효성 검증이 필요했습니다. 이를 위해 간단한 백엔드를 App Router로 구현하기로 했습니다. 데이터베이스는 파일로 관리할 수 있는 SQLite3를 사용하고, ORM은 Prisma를 선택했습니다.
간단한 POST API를 통해 루트 폴더의 JSON 파일을 읽어와, Prisma를 통해 유효성 검증을 거쳐 데이터베이스에 저장했습니다.

export async function POST(req: Request) {
  try {
    // upload.json 파일 읽기
    const jsonPath = path.join(`${process.cwd()}`, "upload.json");
    const jsonData = fs.readFileSync(jsonPath, "utf-8");
    const dataList = JSON.parse(jsonData);

    if (!Array.isArray(dataList)) {
      console.error("입력 데이터는 배열 형태여야 합니다.");
      return;
    }

    if (!Array.isArray(dataList)) {
      return NextResponse.json(
        { error: "입력 데이터는 배열 형태여야 합니다." },
        { status: 400 }
      );
    }

    const createdReports = await prisma.$transaction(
      dataList.map((data) =>
        prisma.reportJSON.create({
          data: {
            name: data.name,
            ...
          },
          include: {
            worry: true,
            ...
          },
        })
      )
    );

    return NextResponse.json(createdReports, { status: 201 });
  } catch (error) {
    console.error("리포트 생성 중 오류 발생:", error);
    return NextResponse.json(
      { error: "리포트 생성 중 오류가 발생했습니다." },
      { status: 500 }
    );
  }
}

이후 서버 컴포넌트에서 Prisma 클라이언트를 사용하여 서버 패치를 통해 HTML을 즉시 생성함으로써 지연 시간을 최소화했습니다.

export default async function ReportPage({ params }: { params: { id: string } }) {
  const reportData = await prisma.reportJSON.findUnique({
    where: { id: params.id },
	  ...
  });

  if (!reportData) {
    return <div>No data</div>;
  }

  const samplePage = reportData.samplePage ? reportData.samplePage.split("|") : [];
  console.log(reportData.samplePage)

  return (
    <div className="w-full h-full">
      <DownloadButton reportList={[reportData]} />
      <TitlePage reportData={reportData} />
      ...
      <LastLayoutPage />
    </div>
  );
}

마지막으로, Puppeteer를 이용한 PDF 추출 API를 작성하여 PDF를 한 번에 또는 개별적으로 다운로드할 수 있도록 설계하고 구현을 완료했습니다.

export async function GET(request: NextRequest) {
  try {
    const searchParams = request.nextUrl.searchParams;
    const id = searchParams.get("id");
    // Puppeteer 인스턴스 실행
    const browser = await puppeteer.launch({
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
      headless: true,
    });

    const page = await browser.newPage();
    await page.setViewport({ width: 595, height: 842 });
    const targetUrl = `http://localhost:3000/haedeup/${id}`;

    // 페이지 접속
    await page.goto(targetUrl, {
      waitUntil: "networkidle0",
      timeout: 0,
    });

    // 이미지 로딩 대기
    await page.evaluate(async () => {
      const selectors = Array.from(document.querySelectorAll("img"));
      document.body.scrollIntoView(false);
      await Promise.all(
        selectors.map((img) => {
          if (img.complete) return;
          return new Promise((resolve, reject) => {
            img.addEventListener("load", resolve);
            img.addEventListener("error", reject);
          });
        })
      );
    });

    // PDF 옵션 설정
    const options: PDFOptions = {
      width: 595,
      height: 842,
      printBackground: true,
    };

    // PDF 생성 및 파일로 저장
    const pdf = await page.pdf(options);
    await browser.close();

    return new Response(pdf, {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": 'attachment; filename="document.pdf"',
      },
    });
  } catch (error) {
    console.error("PDF 생성 중 오류 발생:", error);
    return NextResponse.json(
      { error: "PDF 생성 중 오류가 발생했습니다." },
      { status: 500 }
    );
  }
}

또한 해당 프로젝트를 마무리하면서 PO에게 리드미 파일을 전달할 때, 비개발자가 이 프로젝트를 쉽게 사용할 수 있도록 하는 방법을 더욱 고민해보게 되었습니다.

결과

200여명의 피부 분석 리포트를 간단한 버튼 클릭만으로 생성하여 고객에게 성공적으로 전달했습니다. 이 시스템을 통해 앞으로도 다른 고객의 리포트를 백오피스 툴을 사용하여 효율적으로 생성하고 전달할 수 있게 되었습니다.


GraphQL, CodeGen 도입

라이브러리 선정 배경

이전 프로젝트에서는 중복되는 API를 많이 생성해야 했습니다.
사이드 프로젝트에서 GraphQL을 사용해본 경험이 있었고, 이를 통해 중복된 API 문제를 프론트엔드에서 쿼리문을 직접 작성하여 해결할 수 있었습니다. 또한, CodeGen을 통해 빠르게 API 함수를 작성할 수 있다는 장점을 경험했습니다.
이러한 이유로 백엔드와 논의 후 GraphQL을 도입하기로 결정했습니다.

주요 기능 구현

아래의 CodeGen 설정 파일을 작성한 후, 명령어를 입력하면 자동으로 API 함수들이 generated.ts 파일로 생성됩니다.

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  overwrite: true,
  // GraphQL 스키마를 불러 올 주소 설정
  schema: 'http://localhost:3030/graphql',
  // GraphQL 쿼리문을 불러올 경로 설정
  documents: 'src/graphql/*.gql',
  hooks: {
    afterOneFileWrite: ['prettier --write'],
  },
  generates: {
	  // API 함수 생성 파일 위치 설정
    'src/graphql/helpers/generated.ts': {
      documents: 'string',
      config: {
	      // 리액트 쿼리 사용 설정
        reactQueryVersion: 5,
        ...,
        // fetch 함수 커스터마이즈
        fetcher: {
          func: './fetcher#fetcher',
        },
        ...
      },
      plugins: [
        'typescript',
        '@graphql-codegen/typescript-operations',
	      // react-query 플러그인 추가
        '@graphql-codegen/typescript-react-query',
        {
          add: {
	          // generate.ts 파일 최상단 커스텀 에러 import 설정
            content: "import { ApiErrorInstance } from './apiError';",
          },
        },
      ],
    },
  },
};

export default config;

결과

GraphQL과 CodeGen을 도입함으로써, 백엔드의 Swagger 문서 작성, 타입 지정, 리액트 쿼리 API 함수 생성 등 다양한 부분에서 작업량을 줄일 수 있었고, 개발자 경험(DX)을 크게 향상시킬 수 있었습니다.