---
title: "JobSecretary : AI 기반 취업 준비 통합 관리 서비스"
date: "2025-11-01"
lastModified: "2026-07-21"
category: "AI / Web Service (개인)"
projectCategory: "AI 서비스"
platforms: ["web"]
role: "fullstack"
tags: ["Next.js","TypeScript"]
cover: "/images/projects/jobsecretary.png"
coverAlt: "JobSecretary AI 취업 준비 관리 플랫폼의 지원 현황 화면"
githubUrl: "https://github.com/kimjunha1231/JobSecretary"
description: "채용 공고, 지원 일정, 자기소개서, 면접 준비가 흩어지는 문제를 하나의 흐름으로 연결한 구직자용 AI 취업 준비 관리 서비스입니다."
---

## 프로젝트 개요

- **구분**: 개인 프로젝트 (1인 개발)
- **기간**: 2025.11 ~ 2025.12
- **담당 범위**: 기획·설계·프론트엔드 개발·테스트·배포
- **기여도**: 100%
- **기술 스택**:
  - 프론트엔드: Next.js, TypeScript
  - 폼 상태 관리: React Hook Form, Zod
  - 서버 상태 관리: TanStack Query
  - 전역 상태 관리: Zustand
  - 스타일링: Tailwind CSS, shadcn/ui
  - 테스트·모니터링: Jest, Playwright, Sentry
  - 백엔드·데이터: Next.js API Routes, Supabase
  - AI·배포: Google Gemini API, Vercel
- **주요 성과**:
  - React Developer Tools Profiler의 동일한 드래그 시나리오에서 전체 커밋 **271회 → 98회** (**63.8% 감소**), 비활성 컴포넌트 리렌더링 **268회 → 0회** (**100% 감소**)
  - Lighthouse 접근성 점수 **80점대 → 95점대** (**15점 이상 향상**)

JobSecretary는 채용 공고와 지원 현황, 자기소개서, 면접 준비까지의 과정을 하나로 연결한 AI 기반 취업 준비 관리 서비스입니다. 칸반 보드와 태그 검색으로 지원 정보와 자기소개서를 관리하고, Gemini로 초안·문장 교정·면접 질문 생성을 지원합니다.

## 담당 기능

- **채용 공고·지원 정보 관리**: 회사명, 직무, 마감일과 지원 단계 등 채용 공고별 정보를 등록하고 관리할 수 있도록 구성했습니다.
- **지원 현황 칸반 보드**: 지원 상태를 단계별로 확인하고, 카드를 드래그 앤 드롭해 진행 상태를 변경할 수 있도록 구현했습니다.
- **자기소개서 작성·관리**: 여러 문항으로 구성된 자기소개서를 작성·수정하고, 태그를 활용해 문서를 저장·검색하며 이전 문서를 참고할 수 있도록 구성했습니다.
- **문서 내보내기**: 작성한 자기소개서를 PDF로 변환해 다운로드할 수 있도록 구현했습니다.
- **AI 기반 취업 준비 보조**: Gemini를 활용해 자기소개서 초안 작성, 문장 교정, 면접 질문 생성을 지원하되 최종 내용과 답변은 사용자가 직접 검토하고 결정하도록 구성했습니다.

## 주요 기술 개선

### 1. 칸반 보드 렌더링 경계 분리

칸반 보드를 만들고 나서 카드를 드래그해 보니, 움직이지 않은 카드까지 함께 다시 그려지는 현상이 눈에 들어왔습니다. 처음에는 메모이제이션만 적용하면 충분히 해결할 수 있다고 생각했습니다. 하지만 `React.memo`를 적용해도 리렌더링은 줄어들지 않았습니다. 메모이제이션만으로는 설명되지 않는 원인이 있다고 생각해, 카드가 `dnd-kit`의 어떤 상태를 구독하는지 라이브러리 동작을 살펴보기 시작했습니다.

- **문제 상황**: React Developer Tools Profiler에서 같은 카드 목록을 드래그해 다른 위치에 놓는 동작을 기록했습니다. 한 번의 드래그에서 전체 커밋이 **271회** 발생했고, 실제로 움직이지 않은 카드도 **268회** 렌더링되고 있었습니다.

<blockquote>측정 시나리오: 같은 칸반 카드 목록에서 카드를 드래그해 다른 위치에 놓는 과정</blockquote>

<div className="grid grid-cols-1 gap-5 md:grid-cols-2 not-prose">
  <figure className="min-w-0">
    <div className="overflow-hidden rounded-2xl border border-card-border bg-black/5">
      <video
        muted
        loop
        playsInline
        controls
        preload="metadata"
        aria-label="JobSecretary 칸반 보드 개선 전 드래그 동작"
        className="aspect-video w-full object-contain"
      >
        <source src="/videos/projects/jobsecretary-before.mp4" type="video/mp4" />
      </video>
    </div>
    <figcaption className="mt-2 text-xs text-foreground/60">개선 전 · 카드 드래그 동작</figcaption>
  </figure>

  <figure className="min-w-0">
    <div className="overflow-hidden rounded-2xl border border-card-border bg-black/5">
      <video
        muted
        loop
        playsInline
        controls
        preload="metadata"
        aria-label="JobSecretary 칸반 보드 개선 후 드래그 동작"
        className="aspect-video w-full object-contain"
      >
        <source src="/videos/projects/jobsecretary-after.mp4" type="video/mp4" />
      </video>
    </div>
    <figcaption className="mt-2 text-xs text-foreground/60">개선 후 · 카드 드래그 동작</figcaption>
  </figure>
</div>

- **원인**: 초기 구현에서는 각 카드 컴포넌트가 `useSortable`을 직접 호출했습니다. `useSortable`은 `useContext`로 드래그 상태를 읽기 때문에, 포인터가 움직일 때마다 Context 값이 바뀌고 이 훅을 사용하는 카드들이 다시 실행되었습니다. `React.memo`를 적용해도 Context 변경으로 인한 리렌더링을 피할 수 없었습니다.

문제는 드래그 위치를 계산하는 로직과 회사명·직무·마감일을 그리는 UI가 같은 컴포넌트에 있었던 점입니다. 그래서 드래그 중 바뀌지 않는 카드 콘텐츠도 같은 렌더링 범위에 포함되었습니다.

- **해결 과정**: 먼저 드래그 라이브러리만 바꾸는 것으로는 원인이 사라지지 않는다고 판단했습니다. `@hello-pangea/dnd`로 칸반 구현을 옮기되, 라이브러리가 전달하는 드래그 정보와 카드 콘텐츠를 분리하는 데 초점을 맞췄습니다.

Render Props를 사용한 것 자체가 리렌더링을 줄여준 것은 아닙니다. 다만 `Draggable`에서 `provided`와 `snapshot`을 바로 받을 수 있었고, 드래그 처리는 `KanbanCard`가 맡고 카드 정보만 `KanbanCardContent`에 넘기는 구조를 만들 수 있었습니다. 그 결과 드래그 중 계속 바뀌는 값과 그대로 있어야 하는 카드 콘텐츠를 코드상에서 나눠볼 수 있었습니다.

`Draggable`의 Render Props에서 전달하는 `provided`와 `snapshot`은 드래그 연결과 현재 드래그 여부를 처리하는 `KanbanCard`에서만 사용하도록 했습니다. `KanbanCard`는 회사명·직무·마감일을 그리는 하위 컴포넌트 `KanbanCardContent`를 렌더링하고, 이 컴포넌트에는 카드 정보만 전달했습니다. 부모인 `KanbanCard`가 다시 실행되더라도 `KanbanCardContent`의 Props가 같으면 `React.memo`가 하위 컴포넌트의 실행을 건너뛰도록 한 구조입니다.

구조를 단순화하면 다음과 같습니다.

```tsx
<Draggable draggableId={application.id} index={index}>
  {(provided, snapshot) => (
    <KanbanCard
      application={application}
      onDelete={onDelete}
      provided={provided}
      isDragging={snapshot.isDragging}
    />
  )}
</Draggable>
```

`React.memo`는 위 `Draggable` 사용부가 아니라 실제 `KanbanCardContent` 선언부에 적용되어 있습니다. 선언부의 핵심만 발췌하면 다음과 같습니다. `application`과 `onDelete`만 받고 `provided`와 `snapshot`은 받지 않는 부분을 확인할 수 있습니다.

```tsx
const KanbanCardContent = memo(function KanbanCardContent({
  application,
  onDelete,
}: {
  application: Document;
  onDelete: (id: string) => void;
}) {
```

드래그 중인 카드는 React Portal을 사용해 `document.body` 아래에 표시했습니다. 칸반 보드의 스크롤 컨테이너 안에서 그대로 움직이면 부모의 `overflow`에 의해 카드 일부가 잘리거나, 다른 stacking context의 `z-index`에 가려질 수 있습니다. DOM 위치를 컨테이너 밖으로 옮겨 드래그 중인 카드가 화면 위에 안정적으로 보이도록 했습니다.

- **결과**: 같은 드래그 시나리오를 다시 측정했을 때 전체 커밋은 **271회에서 98회**로 줄었고, 비활성 컴포넌트 렌더링은 **268회에서 0회**가 되었습니다.

| 항목 | 변경 전 | 변경 후 | 변화 |
| --- | ---: | ---: | ---: |
| 전체 커밋 횟수 | 271회 | 98회 | **63.8% 감소** |
| 비활성 컴포넌트 렌더링 | 268회 | 0회 | **100% 감소** |

### 2. 자기소개서 편집 상태 관리

자기소개서 편집 화면에서는 입력 성능과 데이터 유실 방지를 함께 해결해야 했습니다. 문항이 여러 개이고 한 문항의 글도 길어질 수 있어서, 입력할 때마다 전체 폼이 다시 렌더링되거나 `localStorage`에 접근하는 구조는 피하고 싶었습니다.

- **처음 구현**: `Zustand(persist)`에 입력값을 저장하고 타이핑할 때마다 `localStorage`에 기록했습니다. 새로고침해도 작성 중인 내용을 복구할 수 있다는 장점은 있었지만, 제어 컴포넌트 방식으로 입력값을 관리하면서 글자가 입력될 때마다 상태 업데이트와 리렌더링이 발생했습니다. 여기에 저장소 접근까지 겹치면 자기소개서가 길어질수록 입력 비용이 커질 수 있었습니다.
- **대안 검토**: `Zustand(persist)`와 `React Hook Form`을 함께 사용하는 방법도 검토했습니다. 하지만 폼의 변경 내용을 다시 Zustand가 읽고 `localStorage`에 저장하면 리렌더링과 저장 과정이 그대로 남고, 상태 관리 계층만 하나 더 늘어나는 구조가 되었습니다. 타이핑이 끝난 뒤 저장하도록 Debounce를 적용하는 방법도 생각했지만, 자기소개서는 작성 중 수정이 자주 일어나기 때문에 저장 시점을 늦추는 것만으로는 입력 과정의 비용을 충분히 줄이기 어렵다고 판단했습니다.
- **최종 선택**: 비제어 컴포넌트 기반의 `React Hook Form`을 사용해 입력할 때 발생하는 불필요한 리렌더링을 줄이고, 저장 버튼을 눌렀을 때만 서버에 저장하도록 했습니다. 입력 중에는 변경 상태를 확인해 새로고침·뒤로가기·목록 이동을 시도할 때 이탈 경고 Dialog를 보여주도록 했습니다. 입력 성능은 폼 관리 방식으로 개선하고, 데이터 유실은 이탈 방지 UX로 보완했습니다.

### 3. 기능 재사용과 확장을 고려한 FSD 구조

처음부터 FSD를 완성된 형태로 적용한 것은 아니었습니다. 초기에는 파일 하나에 많은 코드가 모여 있었고 폴더 구조도 기능별 책임이 분명하지 않았습니다. 이후 면접 복기, 이력서, 포트폴리오 관리처럼 서비스가 더 커질 가능성을 생각하면서, 기능을 독립적으로 나누고 변경 범위를 좁힐 수 있는 구조가 필요하다고 판단했습니다.

- **시행착오**: 처음에는 `App`, `Features`, `Entities`, `Shared`만 두고 `Widgets` 계층은 생략했습니다. 1인 프로젝트에서 계층을 더 늘리는 것이 과하다고 생각했고, `Widgets`와 `Features`의 역할도 명확하게 구분하지 못했습니다.
- **문제 상황**: `Widgets`가 없으니 `Features` 안에 `View` 폴더를 만들어 페이지 조립과 화면 단위 UI까지 맡기기 시작했습니다. 그러다 한 Feature에서 다른 Feature의 slice를 직접 가져오는 의존성이 생겼고, 기능 간 결합도도 높아졌습니다. 사이드바도 단순한 공통 UI로 보기에는 자체 상태와 동작이 많아 `Shared`에 두기 애매했습니다.
- **구조 변경**: 페이지를 조립하는 `Widgets` 계층을 추가하고 `App → Widgets → Features → Entities → Shared`의 역할을 다시 정리했습니다. `Widgets`에는 사이드바와 칸반 보드처럼 여러 기능을 조합하는 화면 단위 UI를 배치하고, `Features`에는 칸반 드래그 앤 드롭·문서 편집·AI 보조처럼 사용자의 동작을 완성하는 기능을 배치했습니다. 공통 데이터 모델은 `Entities`, 공통 UI와 유틸리티는 `Shared`에서 관리하도록 분리했습니다.

이 과정을 거치면서 폴더를 나누는 것보다 중요한 것은 의존성의 방향과 각 계층의 책임을 정하는 일이라는 점을 배웠습니다. 페이지 조립과 기능 로직이 한곳에 섞이지 않게 되었고, 사이드바처럼 규모가 큰 공통 UI도 적절한 위치를 찾을 수 있었습니다. 새로운 기능을 추가할 때도 어느 계층에 코드를 둘지 판단하기 쉬워져, 수정 범위를 예측하기가 이전보다 수월해졌습니다.

### 4. PDF 기능 분리를 통한 초기 번들 최적화

PDF 변환은 사용자가 다운로드를 누를 때만 필요한 기능인데, 처음에는 관련 라이브러리를 페이지 진입 시점에 불러오고 있었습니다. PDF를 사용하지 않는 방문자도 같은 코드를 내려받는 구조였습니다.

`@react-pdf/renderer`를 다운로드 시점에 Dynamic Import하도록 바꾸고, 로딩 중에는 Skeleton UI를 보여주었습니다. PDF 기능을 실제로 사용할 때만 관련 코드를 불러오도록 초기 번들의 부담을 줄였습니다.

### 5. 접근성 보완

키보드와 스크린 리더를 사용하는 경우에도 주요 흐름을 따라갈 수 있어야 했습니다. 화면이 눈에 보이는 것만 확인해서는 버튼의 의미가 전달되지 않거나 입력 요소와 label이 연결되지 않은 문제를 놓치기 쉬웠습니다.

상호작용 요소에는 `aria-label`과 `label`을 연결하고, 화면에는 보이지 않지만 스크린 리더에 필요한 안내는 `sr-only`로 제공했습니다. 그 결과 Lighthouse 접근성 점수를 **80점대에서 95점대**로 높일 수 있었습니다.

### 6. 테스트와 배포 후 모니터링

1인 개발에서는 모든 기능을 배포 직전에 수동으로 다시 확인하기 어렵고, 제가 예상하지 못한 입력이나 화면 전환에서 문제가 생길 가능성도 있었습니다. 그래서 주요 로직은 Jest로 단위 테스트를 작성하고, 실제 사용자 흐름은 Playwright로 반복해서 확인할 수 있도록 구성했습니다.

테스트만으로 배포 이후의 모든 상황을 알 수는 없기 때문에 Sentry도 연결했습니다. 문제가 발생한 위치와 흐름을 배포 후에도 확인할 수 있게 하면서, 오류를 사용자의 제보에만 의존하지 않고 직접 추적할 수 있도록 했습니다.

### 7. 일관된 UI/UX와 공통 컴포넌트

디자이너나 별도의 디자인 시스템 없이 진행한 프로젝트라 화면마다 스타일과 동작이 달라지지 않도록 기준을 정할 필요가 있었습니다. 자주 사용하는 UI와 기능은 공통 컴포넌트로 만들어 여러 화면에서 재사용하고, 화면별로 달라지는 부분은 필요한 로직만 조합했습니다.

반복되는 로직은 Hook으로 분리하고 UI 컴포넌트는 화면을 그리는 역할에 집중하도록 했습니다. 덕분에 자기소개서 작성·수정처럼 비슷한 흐름을 가진 화면에서도 같은 동작과 스타일을 재사용할 수 있었습니다.
