"프로젝트는 완성했는데, 면접관이 '이건 튜토리얼 따라한 거 아닌가요?'라고 물어보더라고요."
최근 주니어 개발자 커뮤니티에서 가장 많이 들리는 하소연입니다. 수많은 온라인 강의를 듣고, TodoList와 날씨 앱을 만들어봤지만, 정작 포트폴리오를 만들 때가 되면 막막해지죠. 어떤 프로젝트를 해야 차별화될 수 있을까요?
2025년 현재 신입 채용 시장은 좁습니다. 원티드나 로켓펀치에 신입 공고 하나가 올라오면 지원자가 몰리고, 대부분은 서류에서 걸러집니다. 여기서 살아남으려면 '코딩을 할 줄 아는' 수준으로는 부족하고, '실무에 바로 투입할 수 있는' 쪽을 보여줘야 합니다.
이 글에서는 실제로 면접관들이 높이 평가하는 포트폴리오 프로젝트 7가지를 소개합니다. 단순한 아이디어가 아니라, 각 프로젝트에서 어떤 기술적 고민을 보여줘야 하는지, 어떤 문제 해결 과정을 강조해야 하는지까지 구체적으로 다룹니다.
1. 실시간 협업 툴: 기술 스택의 깊이를 보여주는 프로젝트
왜 이 프로젝트인가?
실시간 협업 툴(노션 클론, 피그마 간이버전, 공동 문서 편집기)은 2025년 포트폴리오 소재로 자주 추천됩니다. WebSocket, CRDT(Conflict-free Replicated Data Type), 상태 관리의 복잡성을 한 프로젝트 안에서 전부 만나게 되기 때문입니다.
핵심 구현 포인트
1. WebSocket 실시간 통신
// Socket.IO를 활용한 실시간 동기화
import { Server } from "socket.io";
import { createServer } from "http";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_URL },
});
io.on("connection", (socket) => {
socket.on("document:update", async (data) => {
// Operational Transformation 적용
const transformedOps = transformOperations(data.operations);
// Redis에 변경사항 캐싱
await redis.set(`doc:${data.docId}`, JSON.stringify(transformedOps));
// 같은 문서를 보는 다른 사용자에게 브로드캐스트
socket.to(data.docId).emit("document:updated", transformedOps);
});
});2. CRDT 구현으로 충돌 해결
// Yjs를 활용한 CRDT 구현
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
"ws://localhost:1234",
"document-room",
ydoc
);
const ytext = ydoc.getText("content");
// 충돌 없는 동시 편집 가능
ytext.observe((event) => {
console.log("Document changed:", event.changes);
});면접에서 강조할 포인트
- 동시성 제어: "5명이 동시에 같은 문단을 수정할 때 어떻게 처리했나요?"
- 성능 최적화: "실시간 업데이트 시 렌더링 최적화는 어떻게 했나요?" → Virtual Scrolling, debounce 전략 설명
- 오프라인 지원: "네트워크가 끊겼다 다시 연결되면?" → Conflict Resolution 로직 설명
기술 스택 추천
- Frontend: Next.js 14 (App Router), Zustand, Yjs, Lexical Editor
- Backend: Node.js, Socket.IO, Redis, PostgreSQL
- Infra: Vercel, Railway, Redis Cloud
2. 개인화 추천 시스템: 데이터 처리 역량 증명
프로젝트 개요
넷플릭스처럼 사용자 행동 기반 콘텐츠 추천 시스템입니다. ML 없이도 협업 필터링(Collaborative Filtering)으로 충분히 의미 있는 결과를 만들 수 있습니다.
핵심 구현: 협업 필터링 알고리즘
import pandas as pd
from sklearn.metrics.pairwise import cosine_similarity
class RecommendationEngine:
def __init__(self, interactions_df):사용자-아이템 매트릭스 생성
self.user_item_matrix = interactions_df.pivot_table( index='user_id', columns='item_id', values='rating', fill_value=0 )
def get_similar_users(self, user_id, top_n=10):
코사인 유사도 계산
user_similarities = cosine_similarity(self.user_item_matrix) user_sim_df = pd.DataFrame( user_similarities, index=self.user_item_matrix.index, columns=self.user_item_matrix.index )
return user_sim_df[user_id].nlargest(top_n + 1)[1:]
def recommend_items(self, user_id, top_n=5): similar_users = self.get_similar_users(user_id)
유사 사용자들이 좋아한 아이템 찾기
weighted_sum = self.user_item_matrix.T.dot(similar_users) recommendations = weighted_sum.nlargest(top_n)
return recommendations.index.tolist()
실시간 추천 API 구현
// Next.js API Route에서 추천 제공
import { NextRequest, NextResponse } from "next/server";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function GET(request: NextRequest) {
const userId = request.nextUrl.searchParams.get("userId");
// 캐시 확인
const cached = await redis.get(`recommendations:${userId}`);
if (cached) {
return NextResponse.json(cached);
}
// Python 추천 서비스 호출
const response = await fetch(`${process.env.ML_SERVICE_URL}/recommend`, {
method: "POST",
body: JSON.stringify({ userId }),
});
const recommendations = await response.json();
// 5분간 캐싱
await redis.set(`recommendations:${userId}`, recommendations, { ex: 300 });
return NextResponse.json(recommendations);
}차별화 포인트
- A/B 테스트 구현: 추천 알고리즘 A vs B의 클릭률 비교
- Cold Start 문제 해결: 신규 유저에게는 인기도 기반 추천
- 실시간 피드백 반영: 클릭/스킵 데이터를 즉시 추천에 반영
데이터셋 추천
- MovieLens Dataset (영화 평점 데이터)
- Spotify Million Playlist Dataset
- Amazon Product Review Data
3. 성능 모니터링 대시보드: DevOps 역량 어필
왜 효과적인가?
실제 서비스를 운영하며 겪는 문제를 해결하는 프로젝트입니다. "내가 만든 서비스가 어떻게 동작하는지 모니터링했다"는 것 자체가 강력한 메시지입니다.
핵심 구현: 커스텀 성능 트래커
// 클라이언트 사이드 성능 측정
export class PerformanceTracker {
private metrics: Map<string, number[]> = new Map();
// Core Web Vitals 측정
measureWebVitals() {
// Largest Contentful Paint
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.sendMetric("LCP", lastEntry.renderTime || lastEntry.loadTime);
}).observe({ entryTypes: ["largest-contentful-paint"] });
// First Input Delay
new PerformanceObserver((list) => {
const firstInput = list.getEntries()[0];
const fid = firstInput.processingStart - firstInput.startTime;
this.sendMetric("FID", fid);
}).observe({ entryTypes: ["first-input"] });
// Cumulative Layout Shift
let clsScore = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
}
}
this.sendMetric("CLS", clsScore);
}).observe({ entryTypes: ["layout-shift"] });
}
private async sendMetric(name: string, value: number) {
// 배치 처리로 네트워크 요청 최소화
if (!this.metrics.has(name)) {
this.metrics.set(name, []);
}
this.metrics.get(name)!.push(value);
if (this.metrics.get(name)!.length >= 10) {
await this.flush(name);
}
}
private async flush(name: string) {
const values = this.metrics.get(name)!;
await fetch("/api/metrics", {
method: "POST",
body: JSON.stringify({
metric: name,
values,
timestamp: Date.now(),
userAgent: navigator.userAgent,
}),
});
this.metrics.set(name, []);
}
}
서버 사이드: 메트릭 수집 및 시각화
// Prometheus + Grafana 스타일 메트릭 저장
import { TimescaleDB } from "timescaledb";
export async function POST(request: Request) {
const { metric, values, timestamp, userAgent } = await request.json();
// TimescaleDB에 시계열 데이터 저장
await db.query(
`
INSERT INTO performance_metrics (
metric_name,
value,
timestamp,
user_agent,
percentile_95
) VALUES (
$1,
$2,
to_timestamp($3 / 1000.0),
$4,
percentile_cont(0.95) WITHIN GROUP (ORDER BY $2)
)
`,
[metric, values[0], timestamp, userAgent]
);
// 실시간 알림: LCP > 2.5초일 때
if (metric === "LCP" && values[0] > 2500) {
await sendSlackAlert({
message: `🚨 LCP 성능 저하 감지: ${values[0]}ms`,
url: request.headers.get("referer"),
});
}
return new Response("OK", { status: 200 });
}시각화 대시보드
// Recharts로 실시간 그래프 구현
"use client";
import { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts";
import { useEffect, useState } from "react";
export default function MetricsDashboard() {
const [data, setData] = useState([]);
useEffect(() => {
// Server-Sent Events로 실시간 업데이트
const eventSource = new EventSource("/api/metrics/stream");
eventSource.onmessage = (event) => {
const newMetric = JSON.parse(event.data);
setData((prev) => [...prev, newMetric].slice(-50)); // 최근 50개만
};
return () => eventSource.close();
}, []);
return (
<div className="grid grid-cols-3 gap-4">
<MetricCard
title="LCP"
data={data.filter((d)=> d.name= "LCP")}
threshold={2500}
/>
<MetricCard
title="FID"
data={data.filter((d)=> d.name= "FID")}
threshold={100}
/>
<MetricCard
title="CLS"
data={data.filter((d)=> d.name= "CLS")}
threshold={0.1}
/>
</div>
);
}면접 어필 포인트
- "실제 사용자 경험을 정량화했습니다"
- "성능 저하 시 자동 알림으로 빠른 대응이 가능합니다"
- "P95 레이턴시 기준으로 SLA를 관리했습니다"
4. 오픈소스 기여형 프로젝트: 협업 능력 증명
전략적 접근법
기존 인기 오픈소스에 기여하되, 단순 오타 수정이 아닌 의미 있는 기능 추가를 목표로 합니다.
추천 오픈소스 프로젝트 (2025년 기준)
-
shadcn/ui (GitHub Stars: 78k)
- 새로운 컴포넌트 추가 (예: Advanced Data Table, Color Picker)
- 접근성(A11y) 개선
-
tRPC (GitHub Stars: 32k)
- 새로운 어댑터 개발 (예: Fastify, Hono 지원)
- 에러 핸들링 개선
-
Zod (GitHub Stars: 29k)
- 커스텀 스키마 타입 추가
- 에러 메시지 국제화
기여 프로세스 예시
// shadcn/ui에 Advanced Multi-Select 컴포넌트 추가
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
interface MultiSelectProps {
options: { label: string; value: string }[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
maxDisplay?: number;
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = "Select items...",
maxDisplay = 3,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (value: string) => {
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
onChange(newSelected);
};
const displayedLabels = selected
.slice(0, maxDisplay)
.map((value) => options.find((opt) => opt.value === value)?.label);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex gap-1 flex-wrap">
{selected.length === 0 ? (
<span className="text-muted-foreground">{placeholder}</span>
) : (
<>
{displayedLabels.map((label) => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))}
{selected.length > maxDisplay && (
<Badge variant="secondary">
+{selected.length - maxDisplay}
</Badge>
)}
</>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={()=> handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selected.includes(option.value)
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}PR 작성 시 핵심
## Description
이 PR은 여러 항목을 선택할 수 있는 Multi-Select 컴포넌트를 추가합니다.
## Motivation
기존 Select 컴포넌트는 단일 선택만 지원합니다.
사용자들이 태그 선택, 필터 설정 등에서 다중 선택이 필요하다는
요청이 많았습니다. (Issue #1234 참조)
## Changes
- 새로운 `MultiSelect` 컴포넌트 추가
- 키보드 네비게이션 지원 (Accessibility)
- 최대 표시 개수 제한 옵션 (`maxDisplay`)
- 선택된 항목을 Badge로 표시
## Testing
- [ ] Unit tests 추가
- [ ] Storybook stories 추가
- [ ] 접근성 테스트 (NVDA, VoiceOver)
## Screenshots
[Before/After 스크린샷 첨부]5. 마이크로서비스 아키텍처 실습: 백엔드 역량 강화
프로젝트 구조
간단한 이커머스를 마이크로서비스로 구현합니다.
ecommerce-microservices/ ├── services/ │ ├── auth-service/ # 인증/인가 │ ├── product-service/ # 상품 관리 │ ├── order-service/ # 주문 처리 │ ├── payment-service/ # 결제 (Stripe 연동) │ └── notification-service/ # 이메일/SMS ├── api-gateway/ # Kong or Nginx ├── shared/ │ ├── proto/ # gRPC Protocol Buffers │ └── events/ # 이벤트 스키마 └── infrastructure/ ├── docker-compose.yml └── kubernetes/
gRPC 서비스 간 통신
// shared/proto/order.proto
syntax = "proto3";
package order;
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (Order);
rpc GetOrder (GetOrderRequest) returns (Order);
rpc UpdateOrderStatus (UpdateOrderStatusRequest) returns (Order);
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
string shipping_address = 3;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double price = 3;
}
message Order {
string id = 1;
string user_id = 2;
repeated OrderItem items = 3;
string status = 4;
double total_amount = 5;
int64 created_at = 6;
}
// order-service/src/server.ts
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { EventEmitter } from "events";
const packageDefinition = protoLoader.loadSync(
"../../shared/proto/order.proto"
);
const orderProto = grpc.loadPackageDefinition(packageDefinition).order;
class OrderServiceImpl {
async CreateOrder(call: any, callback: any) {
const { user_id, items, shipping_address } = call.request;
// 1. 재고 확인 (product-service 호출)
const stockCheck = await this.checkStock(items);
if (!stockCheck.available) {
return callback({
code: grpc.status.FAILED_PRECONDITION,
message: "Out of stock",
});
}
// 2. 주문 생성
const order = await db.orders.create({
user_id,
items,
shipping_address,
status: "pending",
total_amount: this.calculateTotal(items),
});
// 3. 이벤트 발행 (Kafka/RabbitMQ)
await eventBus.publish("order.created", {
orderId: order.id,
userId: user_id,
amount: order.total_amount,
});
callback(null, order);
}
private async checkStock(items: any[]) {
// product-service에 gRPC 호출
const client = new productProto.ProductService(
"product-service:50051",
grpc.credentials.createInsecure()
);
return new Promise((resolve, reject) => {
client.CheckStock({ items }, (err: any, response: any) => {
if (err) reject(err);
else resolve(response);
});
});
}
}
const server = new grpc.Server();
server.addService(orderProto.OrderService.service, new OrderServiceImpl());
server.bindAsync(
"0.0.0.0:50051",
grpc.ServerCredentials.createInsecure(),
() => {
console.log("Order Service running on port 50051");
}
);이벤트 기반 통신 (Saga Pattern)
// 분산 트랜잭션: 주문 생성 → 결제 → 재고 차감 → 배송 시작
import { Kafka } from "kafkajs";
const kafka = new Kafka({
clientId: "order-service",
brokers: ["kafka:9092"],
});
const producer = kafka.producer();
const consumer = kafka.consumer({ groupId: "order-saga" });
class OrderSaga {
async execute(orderId: string) {
const order = await db.orders.findById(orderId);
try {
// Step 1: 결제 요청
await producer.send({
topic: "payment.requested",
messages: [
{
key: orderId,
value: JSON.stringify({ orderId, amount: order.total_amount }),
},
],
});
// Step 2: 결제 완료 대기
await this.waitForEvent("payment.completed", orderId);
// Step 3: 재고 차감 요청
await producer.send({
topic: "inventory.reserved",
messages: [
{
key: orderId,
value: JSON.stringify({ orderId, items: order.items }),
},
],
});
// Step 4: 배송 시작
await producer.send({
topic: "shipping.started",
messages: [{ key: orderId, value: JSON.stringify(order) }],
});
} catch (error) {
// 보상 트랜잭션 (Compensation)
await this.compensate(orderId, error);
}
}
private async compensate(orderId: string, error: any) {
console.error(`Order ${orderId} failed:`, error);
// 결제 취소
await producer.send({
topic: "payment.cancelled",
messages: [
{
key: orderId,
value: JSON.stringify({ orderId, reason: error.message }),
},
],
});
// 주문 상태 업데이트
await db.orders.update(orderId, { status: "failed" });
}
private waitForEvent(eventType: string, orderId: string): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timeout")), 30000);
consumer.subscribe({ topic: eventType });
consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.orderId === orderId) {
clearTimeout(timeout);
resolve();
}
},
});
});
}
}
Docker Compose로 로컬 환경 구성
version: "3.8"
services:
auth-service:
build: ./services/auth-service
ports:
- "50050:50051"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/auth
- JWT_SECRET=your-secret-here
depends_on:
- postgres
product-service:
build: ./services/product-service
ports:
- "50052:50051"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/products
depends_on:
- postgres
order-service:
build: ./services/order-service
ports:
- "50053:50051"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/orders
- KAFKA_BROKERS=kafka:9092
depends_on:
- postgres
- kafka
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
- postgres_data:/var/lib/postgresql/data
kafka:
image: confluentinc/cp-kafka:7.5.0
environment:
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
depends_on:
- zookeeper
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
- ZOOKEEPER_CLIENT_PORT=2181
volumes:
postgres_data:6. AI 기반 개인 비서 앱: 트렌드 기술 활용
2025년 주목받는 이유
ChatGPT, Claude API를 활용한 프로젝트는 최신 기술 트렌드를 따라가는 개발자임을 보여줍니다.
핵심 기능 구현
// OpenAI Function Calling으로 실제 액션 수행
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const functions = [
{
name: 'create_calendar_event',
description: '새로운 일정을 구글 캘린더에 추가합니다',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
start_time: { type: 'string', format: 'date-time' },
end_time: { type: 'string', format: 'date-time' },
location: { type: 'string' }
},
required: ['title', 'start_time', 'end_time']
}
},
{
name: 'send_email',
description: '이메일을 전송합니다',
parameters: {
type: 'object',
properties: {
to: { type: 'string', format: 'email' },
subject: { type: 'string' },
body: { type: 'string' }
},
required: ['to', 'subject', 'body']
}
}
];
export async function POST(req: Request) {
const { message, conversationHistory } = await req.json();
const messages = [
{