← cd ..
JobRadarSupabaseNextJSTypeScript사이드프로젝트

[JobRadar 6편] 커버레터 완성, UX 개선, 그리고 로그인 붙이기

April 27, 20261 min read

지난 5편에서 URL을 붙여넣으면 JD 스크래핑 → AI 매칭까지 자동으로 돌아가는 파이프라인을 만들었다. 이번엔 그 위에 살을 붙이는 작업이다.

"커버레터를 생성했는데 새로고침하면 사라진다"는 치명적인 문제부터, 지원 상태를 의미 있게 관리하고 싶다는 니즈, 그리고 혼자만 쓰는 앱을 진짜 서비스처럼 만들기 위한 로그인까지. 오늘 하루에 다 만들었다.


만든 것들 한눈에

기능내용
커버레터 영구 저장모달 열면 기존 내용 자동 로드, 편집 후 저장
커버레터 다운로드TXT / DOCX / PDF 클라이언트 사이드 생성
AI 재검토내가 수정한 내용 기반으로 Claude가 표현 개선
JD 직접 입력Glassdoor 등 스크래핑 불가 사이트용
메모공고별 메모 입력/저장
지원 상태 개편미분류 → 관심있음 → 고민중 → 지원완료 → 패스
로그인/회원가입Supabase Auth (이메일 + Google OAuth)
미들웨어 라우트 보호미로그인 시 /login 자동 이동

Step 1 — 커버레터: 새로고침해도 안 사라지게

가장 먼저 고쳐야 했던 것. 커버레터를 열면 항상 빈 화면이고, 생성해도 새로고침하면 사라졌다.

cover_letters 테이블은 이미 있었다. 문제는 모달이 열릴 때 기존 데이터를 불러오지 않는다는 것.

// CoverLetterModal.tsx
useEffect(() => {
  getCoverLetter(jobId).then(res => {
    if (res.content) {
      setContent(res.content)
      setSavedContent(res.content)
    }
    setState('idle')
  })
}, [jobId])

모달이 열리는 순간 DB에서 기존 커버레터를 당겨온다. 있으면 바로 편집 가능 상태로, 없으면 생성 버튼이 나타난다.

편집 후 저장하려면 "저장" 버튼이 필요했다. 근데 항상 보여주면 지저분하니까 — 수정이 있을 때만 보이게 했다.

const isDirty = content !== savedContent

{isDirty && (
  <button onClick={handleSave}>저장</button>
)}

savedContent는 DB에서 불러온 원본, content는 현재 textarea 값. 둘이 다르면 저장 버튼 등장.


Step 2 — 커버레터 다운로드 (TXT / DOCX / PDF)

세 가지 포맷을 모두 클라이언트 사이드에서 생성한다. 서버 API 호출 없이.

npm install docx jspdf

TXT — 가장 간단. Blob으로 만들어서 링크 클릭.

async function downloadTxt(content: string, filename: string) {
  const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `${filename}.txt`
  a.click()
  URL.revokeObjectURL(url)
}

DOCXdocx 패키지로 단락 생성.

async function downloadDocx(content: string, filename: string) {
  const { Document, Packer, Paragraph, TextRun } = await import('docx')
  const paragraphs = content.split('\n').map(line =>
    new Paragraph({ children: [new TextRun(line)] })
  )
  const doc = new Document({ sections: [{ children: paragraphs }] })
  const blob = await Packer.toBlob(doc)
  // ... 다운로드
}

PDFjspdf로 텍스트를 페이지에 맞게 분할.

async function downloadPdf(content: string, filename: string) {
  const { jsPDF } = await import('jspdf')
  const doc = new jsPDF()
  const lines = doc.splitTextToSize(content, 180) // 줄 자동 분할
  doc.setFontSize(11)
  doc.text(lines, 15, 20)
  doc.save(`${filename}.pdf`)
}

세 함수 모두 await import()로 동적 임포트한다. 번들 사이즈를 줄이고 필요할 때만 로드하기 위해서.


Step 3 — AI 재검토 버튼

"재생성"은 처음부터 다시 쓰는 거고, "AI 재검토"는 내가 수정한 내용을 바탕으로 Claude가 표현만 다듬어주는 기능이다.

// actions.ts
export async function reviewCoverLetter(jobId: string, content: string) {
  const message = await anthropic.messages.create({
    model: 'claude-haiku-4-5-20251001',
    max_tokens: 1000,
    messages: [{
      role: 'user',
      content: `아래 커버레터의 내용과 구조는 유지하면서,
어색한 표현이나 반복, 어법 오류를 다듬어주세요.
개선된 버전만 출력해주세요.

## 현재 커버레터
${content}`,
    }],
  })
  // ...
}

프롬프트 핵심은 "내용 유지, 표현만 개선". 이렇게 하면 내가 공들여 수정한 내용이 날아가지 않는다.


Step 4 — JD 직접 입력 (Glassdoor 대응)

Glassdoor는 Cloudflare가 막아서 JD를 못 긁어온다. URL 슬러그에서 직함/회사명만 가져올 수 있고, 실제 JD는 없다.

그래서 "JD 입력" 버튼을 만들었다. Glassdoor이거나 description이 200자 미만인 공고에 주황색 버튼이 뜬다.

{(job.source === 'glassdoor' || !job.description || job.description.length < 200) && (
  <button onClick={() => setShowJdInput(true)}
    className="text-xs border border-orange-200 text-orange-600 ...">
    JD 입력
  </button>
)}

버튼을 누르면 모달이 뜨고, 공고 페이지에서 JD를 복사해 붙여넣은 다음 저장하면 자동으로 AI 매칭이 실행된다.

// JdInputModal.tsx
async function handleSubmit() {
  await updateJobDescription(jobId, description) // 1. JD 저장
  const res = await matchSingleJob(jobId)        // 2. AI 매칭 자동 실행
  onMatched(res.score)
}

Step 5 — 메모 + 지원 상태 개편

메모

공고별로 메모를 남기려면 jobs 테이블에 컬럼이 필요하다.

ALTER TABLE jobs ADD COLUMN IF NOT EXISTS memo text;

카드에서 "메모" 버튼을 누르면 textarea가 토글된다. 저장하면 DB에 반영.

메모가 있으면 버튼이 노란색으로 강조 표시된다 — 내용이 있다는 시각적 신호.

className={`... ${memo
  ? 'border-yellow-300 text-yellow-700 bg-yellow-50'
  : 'border-zinc-200'}`}

지원 상태 개편

기존 상태(new / bookmarked / applied / pass)가 너무 의미가 없었다. 실제 지원 흐름에 맞게 바꿨다.

기존변경
new미분류
bookmarked⭐ 관심있음
(없음)🤔 고민중
applied✓ 지원완료
pass✕ 패스

DB는 text 컬럼이라 마이그레이션 없이 새 값을 바로 쓸 수 있었다.

버그: 재매칭하면 상태가 'new'로 초기화

점수를 다시 매기면 matches 테이블을 upsert하는데, 코드에 status: 'new'가 하드코딩돼 있었다.

// 수정 전 — 항상 'new'로 덮어씀
await supabaseAdmin.from('matches').upsert({
  ...result,
  status: 'new', // 💀
})

// 수정 후 — 기존 status 보존
const { data: existing } = await supabaseAdmin
  .from('matches').select('status')
  .eq('user_id', profile.id).eq('job_id', job.id).single()

await supabaseAdmin.from('matches').upsert({
  ...result,
  status: existing?.status ?? 'new', // ✅ 기존 상태 유지
})

"지원완료"로 표시해뒀던 공고를 재매칭하면 "미분류"로 돌아오는 황당한 일이 있었는데, 이걸로 해결됐다.


Step 6 — Supabase Auth 로그인/회원가입

MVP 단계에서 이메일을 하드코딩해두고 혼자만 썼는데, 이제 진짜 로그인을 붙일 때가 됐다.

@supabase/ssr 설치

npm install @supabase/ssr

App Router에서 쿠키 기반 세션을 다루려면 @supabase/supabase-js 대신 @supabase/ssr이 필요하다. 서버 컴포넌트에서 세션을 읽을 수 있게 해준다.

클라이언트 두 개

// supabase-server.ts — 서버 컴포넌트/Server Action용
export async function createSupabaseServerClient() {
  const cookieStore = await cookies()
  return createServerClient(url, key, {
    cookies: {
      getAll() { return cookieStore.getAll() },
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value, options }) =>
          cookieStore.set(name, value, options))
      },
    },
  })
}

// supabase-browser.ts — 클라이언트 컴포넌트용
export function createSupabaseBrowserClient() {
  return createBrowserClient(url, key)
}

미들웨어로 라우트 보호

// middleware.ts
export async function middleware(request: NextRequest) {
  const { data: { user } } = await supabase.auth.getUser()

  if (!user && !pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api|auth/callback).*)'],
}

auth/callback을 matcher에서 제외하는 게 중요하다. Google OAuth 콜백이 미들웨어에 막히면 세션 교환이 안 된다. 처음에 이걸 빠뜨려서 로그인하면 다시 로그인 페이지로 튕기는 버그가 생겼었다.

Google OAuth + 콜백 처리

// LoginForm.tsx
async function handleGoogleLogin() {
  await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: { redirectTo: `${window.location.origin}/auth/callback` },
  })
}

// app/auth/callback/route.ts
export async function GET(request: Request) {
  const code = new URL(request.url).searchParams.get('code')
  if (code) {
    const supabase = await createSupabaseServerClient()
    await supabase.auth.exchangeCodeForSession(code)
  }
  return NextResponse.redirect(new URL('/', request.url))
}

하드코딩 이메일 제거

actions.ts, matching.ts, profile/actions.ts 곳곳에 박혀있던 이메일 하드코딩을 전부 교체했다.

// auth-helpers.ts
export async function getAuthUserEmail() {
  const supabase = await createSupabaseServerClient()
  const { data: { user } } = await supabase.auth.getUser()
  return user?.email ?? null
}

export async function getOrCreateProfile(email: string) {
  const { data: existing } = await supabaseAdmin
    .from('profiles').select('*').eq('email', email).single()

  if (existing) return existing

  // 첫 로그인 시 빈 프로파일 자동 생성
  const { data: created } = await supabaseAdmin
    .from('profiles').insert({ email, name: '' }).select().single()

  return created
}

모든 Server Action의 시작에 이걸 넣어줬다.

const email = await getAuthUserEmail()
if (!email) return { error: '로그인이 필요합니다.' }
const profile = await getOrCreateProfile(email)

트러블슈팅

Google OAuth 후 localhost로 리다이렉트

Supabase Dashboard의 Site URL이 기본값 localhost:3000으로 설정돼 있어서 생긴 문제.

해결: Authentication → URL Configuration → Site URL을 Vercel 배포 URL로 변경.

Google 로그인 후 다시 로그인 페이지로 튕김

미들웨어 matcher에서 auth/callback을 제외 안 했을 때 발생. OAuth 콜백 URL에 미들웨어가 먼저 실행되면서 세션 쿠키가 없다고 판단해 /login으로 보내버린다.

해결: matcher 패턴에 auth/callback 제외 추가.

matcher: ['/((?!_next/static|_next/image|favicon.ico|api|auth/callback).*)'],

redirect_uri_mismatch (Google OAuth)

Google Cloud Console의 승인된 리디렉션 URI에 Supabase 콜백 URL이 빠져있을 때.

해결: https://<project-id>.supabase.co/auth/v1/callback 추가.


정리 — 핵심 흐름 한눈에

로그인 (/login)
  ├── 이메일/비밀번호
  └── Google OAuth → /auth/callback → 세션 교환 → /

미들웨어: 미로그인 → /login 리다이렉트 (auth/callback 제외)

대시보드 (/)
  └── 잡 카드
        ├── JD 입력 버튼 (description 없을 때)
        ├── 메모 버튼 → textarea 토글 → 저장
        ├── 지원 상태 버튼 (클릭으로 순환)
        └── 커버레터 버튼
              ├── 기존 내용 자동 로드
              ├── 편집 → 저장
              ├── AI 재검토 (현재 내용 기반 표현 개선)
              ├── 재생성 (처음부터 새로 작성)
              └── 다운로드 (TXT / DOCX / PDF)

오늘 작업의 핵심은 두 가지였다. 첫째, 사용자 경험 디테일 — 새로고침하면 사라지는 커버레터, 의미없는 상태 레이블, 재매칭하면 초기화되는 상태값. 이런 것들이 쌓이면 실제로 쓰고 싶은 툴이 안 된다. 둘째, 인증 구조 — Supabase Auth + @supabase/ssr 조합은 Next.js App Router와 잘 맞는다. 미들웨어 하나로 라우트 보호가 되고, 서버 컴포넌트에서도 세션을 읽을 수 있다.

다음 편에서는 OAuth 로그인 직후 터지는 "Database error saving new user" 에러 삽질기를 다룬다.


JobRadar 개발기 시리즈

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.

[JobRadar 6편] 커버레터 완성, UX 개선, 그리고 로그인 붙이기 | backtodev