Claude Code로 안전한 사용자 프로필 기능 구현하기
DB 설계, 폼, 검증, 인가, 아바타 업로드, 감사 로그까지 사용자 프로필을 안전하게 구현합니다.
사용자 프로필 기능은 표시 이름과 이미지를 저장하는 단순한 화면처럼 보입니다. 하지만 실제 서비스에서는 본인만 수정할 수 있는지, 공개해도 되는 필드만 노출하는지, 이미지 업로드에 제한이 있는지, 변경 이력을 추적할 수 있는지가 모두 중요합니다. Claude Code에 단순히 “프로필 페이지를 만들어줘”라고 요청하면 화면은 빠르게 나오지만, 이런 경계가 빠질 수 있습니다.
이 글은 Next.js App Router, Prisma, Zod, React, Sharp를 기준으로 Claude Code로 사용자 프로필 기능을 구현하는 절차를 설명합니다. 핵심은 AI가 코드를 빨리 쓰게 하는 것이 아니라, 먼저 소유권과 금지 필드, 검증 기준을 명확히 한 뒤 Claude Code가 그 안에서 작업하게 만드는 것입니다.
인증 흐름은 Claude Code 인증 구현, 입력 검증은 Claude Code 폼 검증과 함께 보면 좋습니다. 공식 자료는 OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers, Prisma relations를 확인하세요.
구현 범위와 보안 경계
이번 기능은 로그인한 사용자가 자신의 프로필을 수정하고, 필요하면 공개 프로필로 보여주는 기능입니다. 수정 가능한 필드는 사용자명, 표시 이름, 소개, 위치, 웹사이트, 소셜 링크, 공개 여부, 아바타 URL입니다. 이메일, 요금제, 역할, 팀 권한, 이메일 인증 상태는 프로필 기능 밖에 둡니다.
인증은 사용자가 누구인지 확인하는 절차이고, 인가는 그 사용자가 어떤 작업을 해도 되는지 판단하는 절차입니다. 프로필 화면은 사용자를 어떻게 보여줄지 다루는 곳이지, 관리자인지 또는 결제를 했는지 결정하는 곳이 아닙니다.
| 항목 | 규칙 | 이유 |
|---|---|---|
| 소유자 | session.user.id만 업데이트 대상으로 사용 | 다른 사용자 프로필 수정을 방지 |
| 입력 | Zod 스키마가 허용한 필드만 수락 | mass assignment 방지 |
| 응답 | 공개해도 되는 필드만 select | 이메일과 내부 ID 노출 방지 |
| 아바타 | MIME, 크기, 픽셀을 검사하고 재인코딩 | 위험한 업로드와 대용량 이미지 방지 |
| 감사 로그 | 변경 필드명과 최소 메타데이터만 저장 | 추적성과 개인정보 보호 균형 |
flowchart TD
A["Profile form"] --> B["Zod validation"]
B --> C["Authorization check"]
C --> D["Prisma transaction"]
D --> E["Profile table"]
D --> F["ProfileAuditLog"]
A --> G["Avatar upload"]
G --> H["MIME / size / pixel checks"]
H --> I["Sharp resize to WebP"]
I --> E
데이터베이스 설계
User와 Profile은 1:1 관계로 나눕니다. User는 로그인, 이메일, 계정 상태를 갖고, Profile은 공개 화면과 설정 화면에서 다루는 표시 정보만 갖습니다. 변경 이력은 ProfileAuditLog에 별도로 저장합니다.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
profile Profile?
profileAuditLogs ProfileAuditLog[] @relation("ProfileAuditActor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Profile {
id String @id @default(cuid())
userId String @unique
username String @unique
displayName String
bio String @default("")
location String @default("")
websiteUrl String @default("")
avatarUrl String?
socialLinks Json @default("{}")
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isPublic, updatedAt])
}
model ProfileAuditLog {
id String @id @default(cuid())
userId String
actorUserId String
action String
changedFields Json
metadata Json?
createdAt DateTime @default(now())
actor User @relation("ProfileAuditActor", fields: [actorUserId], references: [id])
@@index([userId, createdAt])
}
Profile에 role, plan, isAdmin 같은 필드를 넣지 않는 것이 중요합니다. 나중에 관리자 대리 편집이 필요하면 별도 관리자 API를 만들고, 감사 로그에서 actorUserId에는 관리자, userId에는 대상 사용자를 저장합니다.
Zod로 입력 제한하기
Zod는 신뢰할 수 없는 요청 본문이 데이터베이스로 들어가기 전의 방어선입니다. 여기서는 .strict()를 사용해 알 수 없는 키를 거부합니다. 공격자가 role, emailVerified, userId를 보내도 저장 로직으로 넘어가지 않습니다.
// src/lib/profile-schema.ts
import { z } from "zod";
const usernamePattern = /^[a-z0-9][a-z0-9_-]{2,29}$/;
function isHttpUrl(value: string) {
if (value === "") return true;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
const optionalHttpUrl = z
.string()
.trim()
.max(200)
.refine(isHttpUrl, "Use http or https URLs only");
export const profileInputSchema = z
.object({
username: z
.string()
.trim()
.regex(usernamePattern, "Use 3-30 lowercase letters, numbers, _ or -"),
displayName: z.string().trim().min(1).max(40),
bio: z.string().trim().max(280).default(""),
location: z.string().trim().max(80).default(""),
websiteUrl: optionalHttpUrl.default(""),
socialLinks: z
.object({
github: optionalHttpUrl.default(""),
x: optionalHttpUrl.default(""),
linkedin: optionalHttpUrl.default(""),
})
.default({}),
isPublic: z.boolean().default(false),
})
.strict();
export type ProfileInput = z.infer<typeof profileInputSchema>;
사용자명은 공개 URL에 들어가기 때문에 처음부터 좁게 제한합니다. 표시 이름은 HTML이 아니라 텍스트로 렌더링합니다. 소셜 링크는 비어 있을 수 있지만, 값이 있으면 http 또는 https URL이어야 합니다.
업데이트 API는 본인 프로필만 수정한다
Route Handler에서는 서버에서 세션을 읽고, 수정 대상은 항상 session.user.id로 고정합니다. 요청 본문에 들어온 userId는 믿지 않습니다. 프로필 저장과 감사 로그 생성을 하나의 트랜잭션에 넣으면, 데이터와 로그가 어긋나는 일을 줄일 수 있습니다.
// src/app/api/profile/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { profileInputSchema } from "@/lib/profile-schema";
const publicProfileSelect = {
username: true,
displayName: true,
bio: true,
location: true,
websiteUrl: true,
avatarUrl: true,
socialLinks: true,
isPublic: true,
updatedAt: true,
} as const;
function changedKeys(before: Record<string, unknown> | null, after: Record<string, unknown>) {
if (!before) return Object.keys(after);
return Object.keys(after).filter((key) => {
return JSON.stringify(before[key]) !== JSON.stringify(after[key]);
});
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const profile = await prisma.profile.findUnique({
where: { userId: session.user.id },
select: publicProfileSelect,
});
return NextResponse.json({ profile });
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const json = await request.json().catch(() => null);
const parsed = profileInputSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid profile input", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const userId = session.user.id;
const input = parsed.data;
const before = await prisma.profile.findUnique({ where: { userId } });
const fields = changedKeys(before, input);
const profile = await prisma.$transaction(async (tx) => {
const saved = await tx.profile.upsert({
where: { userId },
update: input,
create: { userId, ...input },
select: publicProfileSelect,
});
if (fields.length > 0) {
await tx.profileAuditLog.create({
data: {
userId,
actorUserId: userId,
action: before ? "profile.update" : "profile.create",
changedFields: fields,
metadata: {
source: "profile-settings",
beforeDisplayName: before?.displayName ?? null,
},
},
});
}
return saved;
});
return NextResponse.json({ profile });
}
응답은 공개해도 되는 필드만 반환합니다. 이메일, 내부 ID, 계정 상태, 감사 로그는 포함하지 않습니다. 감사 로그에는 변경 필드명을 남기되, 이전 소개문 전체나 이전 소셜 링크 전체를 복사하지 않습니다.
아바타 업로드 제한과 재인코딩
이미지 업로드는 프로필 기능에서 가장 사고가 나기 쉬운 부분입니다. 브라우저의 accept 속성은 사용성을 위한 힌트일 뿐입니다. 서버에서 MIME, 크기, 픽셀 수를 확인하고 Sharp로 WebP로 다시 인코딩합니다.
// src/app/api/profile/avatar/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 4096 * 4096;
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("avatar");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Avatar file is required" }, { status: 400 });
}
if (!allowedTypes.has(file.type)) {
return NextResponse.json({ error: "Use JPEG, PNG, or WebP" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Avatar must be 2MB or smaller" }, { status: 400 });
}
const input = Buffer.from(await file.arrayBuffer());
const image = sharp(input, { limitInputPixels: MAX_PIXELS });
const metadata = await image.metadata();
if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64) {
return NextResponse.json({ error: "Image must be at least 64x64 pixels" }, { status: 400 });
}
const output = await sharp(input, { limitInputPixels: MAX_PIXELS })
.rotate()
.resize(256, 256, { fit: "cover" })
.webp({ quality: 82 })
.toBuffer();
const fileName = `${session.user.id}-${randomUUID()}.webp`;
const uploadDir = path.join(process.cwd(), "public", "uploads", "avatars");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, fileName), output);
const avatarUrl = `/uploads/avatars/${fileName}`;
await prisma.$transaction(async (tx) => {
await tx.profile.update({
where: { userId: session.user.id },
data: { avatarUrl },
});
await tx.profileAuditLog.create({
data: {
userId: session.user.id,
actorUserId: session.user.id,
action: "profile.avatar.update",
changedFields: ["avatarUrl"],
metadata: { contentType: "image/webp", bytes: output.byteLength },
},
});
});
return NextResponse.json({ avatarUrl });
}
운영 환경에서는 S3, R2, GCS 같은 객체 스토리지로 교체하고, 오래된 아바타 삭제, 서명 URL, 악성 파일 스캔, CDN 캐시 정책까지 정해야 합니다.
React 프로필 폼
프로필 JSON 저장과 아바타 업로드를 분리하면 실패 상황이 명확해집니다. 이미지 업로드가 실패해도 텍스트 입력은 유지되고, 텍스트 검증이 실패해도 이미지를 다시 업로드할 필요가 없습니다. 저장 성공 후에는 서버 응답으로 상태를 갱신해 낙관적 업데이트의 불일치를 줄입니다.
// src/components/ProfileForm.tsx
"use client";
import { useState, useTransition } from "react";
import type { ProfileInput } from "@/lib/profile-schema";
type ProfileFormProps = {
initialProfile: ProfileInput & { avatarUrl?: string | null };
};
export function ProfileForm({ initialProfile }: ProfileFormProps) {
const [form, setForm] = useState<ProfileInput>(initialProfile);
const [avatarUrl, setAvatarUrl] = useState(initialProfile.avatarUrl ?? "");
const [message, setMessage] = useState("");
const [isPending, startTransition] = useTransition();
function updateField<K extends keyof ProfileInput>(key: K, value: ProfileInput[K]) {
setForm((current) => ({ ...current, [key]: value }));
}
async function uploadAvatar(file: File) {
setMessage("");
if (file.size > 2 * 1024 * 1024) {
setMessage("Avatar must be 2MB or smaller.");
return;
}
const body = new FormData();
body.append("avatar", file);
const response = await fetch("/api/profile/avatar", {
method: "POST",
body,
});
const result = await response.json();
if (!response.ok) {
setMessage(result.error ?? "Avatar upload failed.");
return;
}
setAvatarUrl(result.avatarUrl);
setMessage("Avatar updated.");
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage("");
startTransition(async () => {
const response = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (!response.ok) {
setMessage(result.error ?? "Profile save failed.");
return;
}
setForm(result.profile);
setMessage("Profile saved.");
});
}
return (
<form onSubmit={submit} className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center gap-4">
<img
src={avatarUrl || "/images/default-avatar.png"}
alt=""
className="h-20 w-20 rounded-full object-cover"
/>
<label className="block">
<span className="text-sm font-medium">Avatar image</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadAvatar(file);
}}
className="mt-2 block text-sm"
/>
</label>
</div>
<label className="block">
<span className="text-sm font-medium">Username</span>
<input
value={form.username}
onChange={(event) => updateField("username", event.target.value as ProfileInput["username"])}
className="mt-1 w-full rounded border px-3 py-2"
autoComplete="off"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Display name</span>
<input
value={form.displayName}
onChange={(event) => updateField("displayName", event.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
maxLength={40}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Bio</span>
<textarea
value={form.bio}
onChange={(event) => updateField("bio", event.target.value)}
className="mt-1 h-28 w-full rounded border px-3 py-2"
maxLength={280}
/>
<span className="text-xs text-slate-500">{form.bio.length}/280</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.isPublic}
onChange={(event) => updateField("isPublic", event.target.checked)}
/>
<span>Show this profile publicly</span>
</label>
<button
type="submit"
disabled={isPending}
className="rounded bg-slate-900 px-4 py-2 font-medium text-white disabled:bg-slate-400"
>
{isPending ? "Saving..." : "Save profile"}
</button>
{message && <p className="text-sm text-slate-700">{message}</p>}
</form>
);
}
실제 제품에서는 필드별 오류 메시지, 중복 사용자명 안내, 업로드 전 미리보기, 키보드 포커스, 비활성 버튼 상태 테스트도 추가합니다. 하지만 그 모든 개선은 서버 검증과 인가 위에 올라가야 합니다.
Claude Code 요청문과 보안 리뷰
Claude Code에는 구현 요청과 리뷰 요청을 분리해 전달하는 것이 좋습니다. 구현 요청에는 편집 범위와 금지 필드를 쓰고, 리뷰 요청에는 보안 관점에서 비판적으로 확인할 항목을 줍니다.
Implement a user profile feature in this Next.js App Router project.
Scope:
- Edit only the files needed for profile schema, API routes, avatar upload, and ProfileForm.
- Use Prisma for Profile and ProfileAuditLog.
- Use Zod for request validation.
- Never accept userId, email, role, plan, or emailVerified from the request body.
- Use session.user.id as the only profile owner.
- Return only public-safe profile fields from API responses.
- Limit avatars to JPEG, PNG, or WebP, 2MB max, then resize to 256x256 WebP.
- Create an audit log row for profile and avatar changes.
After implementation, review your own diff for:
- mass assignment
- broken authorization
- unsafe file upload
- PII in logs
- optimistic UI inconsistency
- missing tests or manual verification steps
이 요청문의 핵심은 “무엇을 만들지”뿐 아니라 “무엇을 절대 하지 않을지”를 알려주는 것입니다. 특히 userId, role, emailVerified를 요청 본문에서 받지 않는다는 조건은 리뷰 단계에서도 다시 확인해야 합니다.
주요 유스케이스
첫 번째는 SaaS 계정 프로필입니다. 사용자는 표시 이름, 부서, 아바타, 소개를 수정합니다. 요금제와 관리자 역할은 별도 설정에서 다루며, 프로필 API로 변경할 수 없어야 합니다.
두 번째는 팀 관리입니다. 관리자가 멤버 목록을 보더라도, 역할 변경과 프로필 수정은 API를 분리해야 합니다. 관리자가 대신 프로필을 수정할 때는 감사 로그에 행위자와 대상자를 분리해 남깁니다.
세 번째는 온보딩입니다. 첫 로그인 후 사용자명과 표시 이름을 받으면 초대 화면이나 공개 페이지에서 사용할 수 있습니다. 다만 이메일 인증, 약관 동의, 마케팅 동의는 별도 상태로 관리합니다.
네 번째는 공개 프로필입니다. isPublic이 true인 경우에만 /users/[username] 같은 페이지에서 보여줍니다. 공개 페이지는 이메일, 내부 ID, 팀 정보, 감사 로그를 반환하지 않아야 합니다.
실패 사례와 함정
함정 1은 요청에서 대상 사용자 ID를 받는 것입니다. 본인 수정 API에서는 대상자를 항상 세션에서 결정해야 합니다.
함정 2는 ...body를 Prisma에 그대로 넘기는 것입니다. 지금은 민감한 필드가 없어도 나중에 마이그레이션으로 생길 수 있습니다.
함정 3은 이미지 업로드 제한을 프런트엔드에만 두는 것입니다. 브라우저 제한은 우회할 수 있으므로 서버에서 다시 검사해야 합니다.
함정 4는 개인정보를 로그에 너무 많이 남기는 것입니다. 이전 소개문, 위치, 소셜 링크 전체는 감사 로그에 기본 저장하지 않는 편이 안전합니다.
함정 5는 낙관적 업데이트 불일치입니다. 공개 여부, 사용자명, 아바타 URL은 서버가 받아들인 결과로 화면을 갱신해야 합니다.
수익화와 운영
프로필 기능은 수익과도 연결됩니다. SaaS에서는 팀 초대의 신뢰감, 커뮤니티에서는 작성자 신뢰, 교육 서비스에서는 강사와 수강자 정보가 상담 전환에 영향을 줍니다. 설정 화면처럼 보여도 실제로는 온보딩과 전환율에 닿아 있습니다.
ClaudeCodeLab은 Claude Code로 인증, 프로필, 관리자 화면, 감사 로그 같은 작지만 위험한 기능을 구현하고 리뷰하는 과정을 지원합니다. 기존 제품에 안전한 프로필 기능을 넣거나 팀에 Claude Code 리뷰 습관을 정착시키고 싶다면 Claude Code 교육 및 상담을 확인하세요.
운영 지표로는 프로필 완성률, 아바타 업로드 실패율, 사용자명 중복 오류, 공개 프로필 조회수, CTA 클릭률을 봅니다. 로그 분석을 하더라도 이벤트명과 로그 본문에 개인정보를 직접 넣지 않는 것이 좋습니다.
실제로 테스트한 결과
Masa가 작은 Next.js 검증 앱에서 이 흐름을 시험했을 때, 먼저 DB 경계와 금지 필드를 정하고 Claude Code에 요청한 버전은 리뷰가 훨씬 쉬웠습니다. 반대로 “보기 좋은 프로필 페이지”만 요청한 시안에서는 userId가 폼 상태에 들어가고, 이미지 제한이 브라우저에만 있으며, 감사 로그가 이전 소개문 전체를 저장하는 문제가 나왔습니다.
배포 전에는 다른 userId를 본문에 넣어도 대상이 바뀌지 않는지, role과 emailVerified가 400으로 거부되는지, 2MB 초과 이미지가 막히는지, javascript: 링크가 저장되지 않는지, 실패한 저장 후 UI가 성공 상태로 남지 않는지 확인하세요. Claude Code는 속도를 높여주지만, 프로필 품질은 소유권, 검증, 로그, 공개 표시를 끝까지 확인하는 데서 결정됩니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.