TypeScript 풀스택 개발에서 피해야 할 것들
직접 겪은 실수와 교훈들 - 데이터베이스 뷰, 컴포넌트 폭발 등
TypeScript 기반 풀스택 개발을 하면서 겪은 실수들을 정리합니다. 당시에는 합리적인 선택이라 생각했지만, 결과적으로 유지보수를 어렵게 만든 결정들입니다.
1. 데이터베이스 뷰를 사용하지 마라
Drizzle 같은 ORM을 사용할 때 데이터베이스 뷰(View)를 활용하고 싶은 유혹이 있습니다. 복잡한 조인이나 집계를 뷰로 만들어두면 쿼리가 간단해지니까요.
하지만 이 접근 방식에는 심각한 문제가 있습니다.
왜 문제인가
핵심 문제
뷰는 단일 책임 원칙(SRP)을 위반하게 만듭니다. 스키마를 수정하면 뷰와 애플리케이션을 둘 다 수정해야 합니다.
문제 1: 단일 책임 원칙 위반
뷰를 사용하는 순간, 데이터 조회 로직의 책임이 두 곳으로 분산됩니다:
스키마 변경 발생
→ 뷰 정의 수정 필요
→ 애플리케이션 코드도 수정 필요
→ 변경 지점이 2배로 증가
→ 하나라도 놓치면 런타임 에러
이것이 가장 치명적인 문제입니다. 테이블 컬럼 하나 바꾸는데 뷰도 확인하고, 애플리케이션도 확인해야 합니다. 뷰가 여러 개면 더 심각해집니다.
문제 2: 성능에 도움이 안 됨
흔한 오해가 "뷰를 쓰면 성능이 좋아진다"는 것입니다. 틀렸습니다.
일반 뷰는 매번 쿼리를 실행합니다. 캐싱도 없고, 최적화도 없습니다. 그냥 SQL을 숨겨놓은 것일 뿐입니다.
Materialized View는 결과를 저장하므로 성능에 도움이 됩니다. 하지만 Materialized View가 필요한 상황이라면, 대부분 설계 자체를 재검토해야 할 신호입니다. 정말 필요한 게 맞는지 먼저 고민해야 합니다.
문제 3: ORM과의 불협화음
Drizzle이나 Prisma 같은 ORM은 테이블 스키마를 기준으로 타입을 생성합니다. 뷰를 사용하면:
- 타입 추론이 제대로 되지 않거나 수동 정의가 필요
- 마이그레이션 관리가 복잡해짐
- ORM의 relation 기능을 활용하기 어려움
문제 4: 로직의 은닉
애플리케이션 코드에서 비즈니스 로직 일부 처리
→ 나머지는 DB 뷰에서 처리
→ 디버깅 시 양쪽을 모두 확인해야 함
→ 전체 로직 파악이 어려움
대안: 애플리케이션에 책임을 위임하라
// Bad: 뷰에 의존
const orderSummary = await db.select().from(orderSummaryView);
// Good: 애플리케이션에서 명시적으로 처리
const orders = await db
.select({
orderId: ordersTable.id,
customerName: customersTable.name,
totalAmount: sql<number>`sum(${orderItemsTable.price})`,
})
.from(ordersTable)
.innerJoin(customersTable, eq(ordersTable.customerId, customersTable.id))
.innerJoin(orderItemsTable, eq(ordersTable.id, orderItemsTable.orderId))
.groupBy(ordersTable.id, customersTable.name);
쿼리가 길어지더라도 모든 로직이 한 곳에 있으면:
- 코드 리뷰가 쉬워짐
- 타입 안전성 확보
- 테스트 작성이 용이
- 성능 튜닝 포인트가 명확
예외 상황
레거시 시스템 연동이나 리포팅 전용 뷰처럼, 읽기 전용이고 변경 가능성이 낮은 경우에는 뷰가 적절할 수 있습니다.
2. 컴포넌트 폭발을 경계하라
React 컴포넌트를 "작고 재사용 가능하게" 만들라는 원칙을 과하게 적용하면 컴포넌트 폭발(Component Explosion)이 발생합니다.
컴포넌트 폭발이란
하나의 기능을 위해 과도하게 많은 컴포넌트 파일이 생기는 현상입니다.
components/
UserProfile/
UserProfile.tsx
UserProfileContainer.tsx
UserProfileHeader.tsx
UserProfileHeaderTitle.tsx
UserProfileHeaderSubtitle.tsx
UserProfileBody.tsx
UserProfileAvatar.tsx
UserProfileAvatarWrapper.tsx
UserProfileStats.tsx
UserProfileStatItem.tsx
...
왜 문제인가
실제로 겪은 문제
컴포넌트가 100개가 넘어가면서 간단한 UI 수정에도 5~6개 파일을 열어봐야 했습니다. props drilling도 심해지고, 어떤 컴포넌트가 어디서 쓰이는지 파악하기 어려워졌습니다.
문제 1: 인지 부하 증가
- 파일 간 이동이 잦아짐
- 데이터 흐름 추적이 어려움
- 새로운 팀원의 온보딩 시간 증가
문제 2: 재사용의 환상
"나중에 재사용하겠지"라고 만든 컴포넌트 중 실제로 재사용되는 비율은 극히 낮습니다. 오히려 재사용을 위해 추가한 props들이 컴포넌트를 복잡하게 만듭니다.
문제 3: 추상화 비용
컴포넌트 분리는 공짜가 아닙니다:
- 파일 생성 및 관리 비용
- import/export 관리
- props 인터페이스 정의
- 테스트 파일 증가
대안: 적절한 크기를 유지하라
// Bad: 과도한 분리
<UserProfileContainer>
<UserProfileHeader>
<UserProfileHeaderTitle>{user.name}</UserProfileHeaderTitle>
</UserProfileHeader>
</UserProfileContainer>
// Good: 적절한 응집도
<UserProfile user={user} />
// UserProfile 내부에서 필요한 만큼만 분리
function UserProfile({ user }: { user: User }) {
return (
<div className="profile">
<header>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</header>
<UserStats stats={user.stats} />
</div>
);
}
컴포넌트 분리 기준:
- 실제로 재사용되는가? - 2곳 이상에서 사용될 때 분리
- 독립적인 상태를 가지는가? - 자체 상태 관리가 필요하면 분리
- 테스트가 필요한가? - 복잡한 로직이 있으면 분리
- 가독성이 나아지는가? - 100줄이 넘어가면 분리 고려
실용적인 원칙
"컴포넌트를 분리해야 할 이유가 명확할 때만 분리하라." 분리하지 않을 이유를 찾지 말고, 분리해야 할 이유를 찾아라.
3. 메뉴는 처음부터 동적으로 관리하라
정말 작은 프로젝트가 아니라면, 메뉴를 하드코딩하지 마세요. 결국 나중에 다 바꾸게 됩니다.
반드시 나오는 요구사항들
프로젝트가 조금만 커지면 이런 요청이 들어옵니다:
- "메뉴를 직접 수정하고 싶어요" (관리자 페이지에서)
- "페이지 제목을 메뉴 이름이랑 맞춰주세요"
- "SEO를 위해 메뉴별로 metadata를 다르게 설정해주세요"
- "다국어 지원할 때 메뉴도 언어별로 바뀌어야 해요"
- "메뉴 순서를 드래그로 바꾸고 싶어요"
핵심 문제
메뉴를 하드코딩하면, 위 요구사항 하나하나가 대규모 리팩토링을 요구합니다. 모든 페이지를 돌아다니며 수정해야 합니다.
흔한 실수: 페이지마다 개별 처리
// pages/dashboard.tsx
export const metadata = {
title: '대시보드',
description: '대시보드 페이지입니다',
};
// pages/users.tsx
export const metadata = {
title: '사용자 관리',
description: '사용자를 관리합니다',
};
// pages/settings.tsx
export const metadata = {
title: '설정',
description: '설정 페이지입니다',
};
// ... 페이지가 50개면 50군데 수정
이렇게 하면:
- 메뉴 이름 바꾸려면 해당 페이지 파일 직접 수정 필요
- 다국어 지원하려면 모든 페이지에 i18n 로직 추가
- 관리자가 메뉴를 수정하려면 배포가 필요
- 메뉴 구조와 페이지 구조가 분리되어 불일치 발생
대안: DB + Layout에서 일괄 처리
// DB에 메뉴 정보 저장
// menus 테이블: id, path, title, description, order, parent_id, locale
// layout.tsx 또는 template.tsx에서 일괄 처리
async function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const locale = useLocale();
// 현재 경로에 해당하는 메뉴 정보 조회
const menuInfo = await getMenuByPath(pathname, locale);
return (
<html>
<head>
<title>{menuInfo?.title}</title>
<meta name="description" content={menuInfo?.description} />
</head>
<body>
<Sidebar menus={await getMenuTree(locale)} />
{children}
</body>
</html>
);
}
이렇게 하면:
- 메뉴 수정은 DB 업데이트만으로 해결
- 다국어는 locale 컬럼으로 자연스럽게 지원
- 관리자 페이지에서 CRUD 가능
- 메뉴와 페이지 metadata가 항상 일치
보너스: 권한 관리가 쉬워진다
메뉴를 DB로 관리하면, 권한 관리 요구사항이 들어왔을 때 자연스럽게 확장할 수 있습니다.
-- 역할-메뉴 매핑 테이블만 추가하면 됨
CREATE TABLE role_menus (
role_id INT REFERENCES roles(id),
menu_id INT REFERENCES menus(id),
PRIMARY KEY (role_id, menu_id)
);
// layout.tsx에서 권한 체크 추가
async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getUser();
const menus = await getMenuTreeByRole(user.roleId, locale);
// 권한 없는 메뉴는 애초에 조회되지 않음
return (
<Sidebar menus={menus} />
);
}
메뉴를 하드코딩했다면? 모든 페이지에 권한 체크 로직을 추가하고, 사이드바 컴포넌트에서도 조건부 렌더링을 넣고, 관리자 페이지에서 역할별 메뉴를 설정하는 UI도 따로 만들어야 합니다.
DB로 관리했다면? 테이블 하나 추가하고 쿼리만 수정하면 끝입니다.
구현 포인트
1. 메뉴 테이블 설계
CREATE TABLE menus (
id SERIAL PRIMARY KEY,
path VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(50),
order_index INT DEFAULT 0,
parent_id INT REFERENCES menus(id),
locale VARCHAR(10) DEFAULT 'ko',
is_visible BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
2. generateMetadata에서 메뉴 정보 활용 (Next.js)
// app/[...slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const menu = await getMenuByPath(`/${params.slug.join('/')}`);
return {
title: menu?.title ?? 'Default Title',
description: menu?.description,
};
}
3. 메뉴 캐싱
메뉴는 자주 변경되지 않으므로 적극적으로 캐싱하세요:
const getMenuTree = cache(async (locale: string) => {
return db.select().from(menus).where(eq(menus.locale, locale));
});
실용적인 원칙
"나중에 동적으로 바꾸면 되지"라고 생각하면, 그 '나중'이 오면 모든 페이지를 수정해야 합니다. 처음부터 DB로 관리하세요.
4. 비즈니스 로직과 레거시를 먼저 분석하라
어찌 보면 당연한 말입니다. 하지만 일정에 쫓기다 보면 "일단 만들고 맞추자"는 유혹에 빠지기 쉽습니다.
분석 없이 구현하면 생기는 일
실제로 겪은 일
요구사항 문서만 보고 "대충 이런 거겠지" 하며 구현했습니다. 결과물을 보여주니 "이게 아닌데요"라는 말을 들었습니다. 절반 이상을 다시 만들었습니다.
요구사항 문서만 보고 구현 시작
→ 실제 업무 프로세스와 맞지 않음
→ 현업 피드백: "이게 아닌데요"
→ 대규모 수정 또는 재구현
→ 일정 지연 + 신뢰 하락
비즈니스 로직을 제대로 이해하지 않으면, 기술적으로 아무리 잘 만들어도 쓸모없는 프로그램이 됩니다.
요구사항 문서를 믿지 마라
현실적인 조언입니다. 요구사항 문서 자체가 형편없는 경우가 많습니다.
현실
고객사의 "IT 담당자"라는 사람들도 대부분은 코드 짤 줄 모릅니다. 그들의 역할은 프로젝트 계약과 일정 관리입니다. 실제 업무를 아는 사람은 현업 담당자입니다.
요구사항 문서는 현업 → IT 담당자 → 문서화 과정을 거치면서 왜곡됩니다. 중간에 정보가 빠지고, 잘못 해석되고, 추상화됩니다.
그래서 해야 할 일:
- 기존 시스템을 직접 봐라 - 현재 쓰고 있는 엑셀, 레거시 시스템, 수기 문서 등을 직접 확인
- 현업 담당자와 직접 대화하라 - IT 담당자를 통하지 말고 실제 업무를 하는 사람과 이야기
- 업무를 따라가 봐라 - 가능하다면 하루 정도 옆에서 실제 업무 프로세스를 관찰
요구사항 문서: "주문 정보를 관리한다"
↓
현업과 대화 후: "주문은 5가지 상태가 있고,
취소는 배송 전에만 가능하며,
부분 취소 시 금액 재계산이 필요하고,
환불은 별도 승인 프로세스를 거친다"
문서에 한 줄로 적힌 기능이 실제로는 수십 가지 규칙을 포함하고 있습니다. 이걸 모르고 구현하면 "이게 아닌데요"를 듣게 됩니다.
DB 설계: 레거시와 동일하게 가져가라
시간이 충분하면 이상적인 스키마를 설계할 수 있습니다. 하지만 현실은 대부분 시간이 부족합니다.
흔한 실수
"레거시 구조가 마음에 안 드니까 새로 설계하고 매핑하면 되지"라는 생각은 하수의 판단입니다.
"매핑하면 되지"가 잡아먹는 비용:
- 인지적 비용: 두 가지 스키마를 동시에 머릿속에 담고 있어야 함
- 매핑 로직 구현: 양방향 변환 코드 작성 및 유지보수
- 디버깅 복잡도: 문제 발생 시 어느 쪽 스키마 기준인지 확인 필요
- 엣지 케이스: 레거시에만 있는 예외 상황을 새 스키마에서 표현 못 함
- 테스트 부담: 매핑 정합성 테스트까지 추가
// Bad: "우리 스키마"로 만들고 매핑
const legacyOrder = await fetchFromLegacy(orderId);
const ourOrder = mapLegacyToOur(legacyOrder); // 여기서 문제 발생
const result = processOrder(ourOrder);
const legacyResult = mapOurToLegacy(result); // 여기서도 문제 발생
await sendToLegacy(legacyResult);
// Good: 레거시와 동일한 구조 사용
const order = await fetchFromLegacy(orderId);
const result = processOrder(order); // 같은 구조니까 바로 처리
await sendToLegacy(result);
언제 새로 설계해도 되는가
- 레거시 시스템과 인터페이스가 없는 경우
- 충분한 분석 및 설계 시간이 확보된 경우
- 레거시 구조가 명백히 잘못되어 마이그레이션이 필수인 경우
이 조건을 만족하지 않으면, 레거시 테이블 구조를 그대로 가져가는 게 현명합니다. 테이블명, 컬럼명까지 동일하게 맞추세요. "나중에 정리하지 뭐"라고 생각하면 그 '나중'은 오지 않습니다.
실용적인 원칙
시간이 부족하면 이상적인 설계를 포기하세요. 레거시와 100% 동일하게 맞추는 게 훨씬 빠르고 안전합니다.
5. 에러 핸들링을 한 곳에서 처리하라
API 호출마다 try-catch를 개별로 작성하고 있다면, 나중에 고통받을 준비를 하고 있는 겁니다.
흔한 실수: 에러 처리 코드의 복붙
// 페이지 A
try {
const data = await fetchUsers();
} catch (error) {
toast.error('사용자 정보를 불러오는데 실패했습니다.');
console.error(error);
}
// 페이지 B
try {
const data = await fetchOrders();
} catch (error) {
toast.error('주문 정보를 불러오는데 실패했습니다.');
console.error(error);
}
// ... 50개 페이지에 비슷한 코드
그러다 이런 요청이 들어옵니다:
- "에러 메시지 포맷을 통일해주세요"
- "401 에러면 로그인 페이지로 보내주세요"
- "에러 발생 시 Sentry로 리포팅해주세요"
50군데를 돌아다니며 수정해야 합니다.
대안: 글로벌 에러 핸들러
Next.js의 경우:
// lib/api.ts - fetch 래퍼
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json();
// 401이면 로그인으로
if (response.status === 401) {
redirect('/login');
}
// 에러 리포팅
Sentry.captureException(error);
// 통일된 에러 throw
throw new ApiError(error.message, response.status);
}
return response.json();
}
// error.tsx - 글로벌 에러 UI
export default function Error({ error, reset }: ErrorProps) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
NestJS의 경우:
// filters/http-exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// 에러 타입에 따른 처리
const status = exception instanceof HttpException
? exception.getStatus()
: 500;
// 로깅
this.logger.error(exception);
// Sentry 리포팅
Sentry.captureException(exception);
// 통일된 응답 포맷
response.status(status).json({
success: false,
message: this.getMessage(exception),
code: this.getErrorCode(exception),
});
}
}
// main.ts
app.useGlobalFilters(new GlobalExceptionFilter());
에러 처리 정책이 바뀌면? 한 파일만 수정하면 됩니다.
실용적인 원칙
에러 처리는 "어디서 발생하든 어떻게 처리할지"를 한 곳에서 정의하세요. 개별 컴포넌트는 에러를 throw만 하면 됩니다.
6. 타입을 Backend/Frontend 간에 공유하라
TypeScript 풀스택 개발의 가장 큰 장점은 타입 공유입니다. 그런데 이걸 안 하는 프로젝트가 의외로 많습니다.
흔한 실수: 타입을 양쪽에서 따로 정의
// Backend: user.dto.ts
export class UserDto {
id: number;
name: string;
email: string;
createdAt: Date;
}
// Frontend: types/user.ts
export interface User {
id: number;
name: string;
email: string;
created_at: string; // 오타? 의도? 누가 알겠어
}
시간이 지나면:
- Backend에서 필드 추가 → Frontend 타입 업데이트 깜빡
- 필드명 불일치 (camelCase vs snake_case)
- 타입 불일치 (Date vs string)
- 런타임에서야 에러 발견
실제로 겪은 일
Backend에서 응답 필드를 optional로 바꿨는데 Frontend 타입은 그대로였습니다. 프로덕션에서 "Cannot read property of undefined" 에러가 터졌습니다.
대안: tRPC를 쓰세요
진심입니다. TypeScript 풀스택이면 tRPC를 쓰지 않을 이유가 없습니다.
// server/routers/user.ts
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user; // 이 타입이 자동으로 클라이언트에 전파됨
}),
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.user.update({ where: { id: input.id }, data: input });
}),
});
// Frontend에서 사용
function UserProfile({ userId }: { userId: number }) {
// user 타입이 자동 추론됨. 별도 타입 정의 불필요.
const { data: user } = trpc.user.getById.useQuery({ id: userId });
// input 타입도 자동 검증. 잘못된 필드 넣으면 컴파일 에러.
const updateMutation = trpc.user.update.useMutation();
return <div>{user?.name}</div>;
}
tRPC의 장점:
- 타입 자동 공유: Backend 응답 타입이 Frontend에 자동 전파
- 입력 검증: Zod 스키마로 런타임 검증까지 해결
- API 문서 불필요: 타입이 곧 문서
- 리팩토링 안전성: 필드명 바꾸면 양쪽에서 컴파일 에러
tRPC를 못 쓰는 환경이라면, 최소한 Zod 스키마를 공유하세요:
// shared/schemas/user.ts
export const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof userSchema>;
// Backend와 Frontend 모두 이 타입을 import해서 사용
실용적인 원칙
TypeScript 풀스택의 최대 장점은 End-to-End 타입 안전성입니다. 이걸 포기하면 TypeScript를 쓰는 의미가 반감됩니다. tRPC를 쓰세요.
7. 데이터베이스 초기 세팅을 먼저 하라
타임존과 collation 설정은 데이터가 들어오기 전에 해야 합니다. 나중에 바꾸려면 정말 고생합니다.
타임존: 서버는 UTC로, 변환은 애플리케이션에서
실제로 겪은 일
서버 타임존을 KST로 설정해뒀다가, 글로벌 서비스 확장할 때 모든 날짜 데이터를 마이그레이션해야 했습니다. 기존 데이터가 KST인지 UTC인지 확신할 수 없어서 하나하나 검증해야 했습니다.
왜 UTC여야 하는가:
- 글로벌 서비스 대응: 사용자마다 다른 타임존을 지원하려면 기준점이 필요
- DST(서머타임) 문제 회피: 일부 국가는 서머타임이 있어서 로컬 시간으로 저장하면 1시간씩 어긋남
- 데이터 정합성: 모든 시간 데이터가 동일한 기준으로 저장됨
// Bad: 서버 타임존에 의존
const createdAt = new Date(); // 서버가 KST면 KST, UTC면 UTC
// Good: 명시적으로 UTC 사용
const createdAt = new Date().toISOString(); // 항상 UTC
date-fns + date-fns-tz로 타임존 처리:
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
import { parseISO } from 'date-fns';
// DB에서 가져온 UTC 시간을 사용자 타임존으로 표시
function formatForUser(utcDateString: string, userTimezone: string) {
const date = parseISO(utcDateString);
return formatInTimeZone(date, userTimezone, 'yyyy-MM-dd HH:mm:ss');
}
// 사용
const dbTime = '2024-01-15T09:00:00.000Z'; // UTC로 저장된 시간
formatForUser(dbTime, 'Asia/Seoul'); // "2024-01-15 18:00:00"
formatForUser(dbTime, 'America/New_York'); // "2024-01-15 04:00:00"
서버/DB 타임존 설정:
-- PostgreSQL
SET timezone = 'UTC';
-- 또는 postgresql.conf에서
-- timezone = 'UTC'
-- MySQL
SET GLOBAL time_zone = '+00:00';
# 서버 환경변수
TZ=UTC node server.js
핵심 원칙
저장은 UTC로, 표시는 사용자 타임존으로. 이 원칙만 지키면 글로벌 서비스도 문제없습니다.
Collation: 데이터 들어오기 전에 설정하라
Collation은 문자열 정렬과 비교 규칙을 정의합니다. 한글 정렬이 이상하게 되거나, 대소문자 구분이 안 되는 문제의 원인이 대부분 collation입니다.
실제로 겪은 일
데이터가 100만 건 쌓인 후에 "가나다 순으로 정렬이 안 돼요"라는 버그 리포트를 받았습니다. collation을 바꾸려면 테이블을 새로 만들고 데이터를 마이그레이션해야 했습니다.
왜 미리 설정해야 하는가:
- 데이터가 있으면 변경 불가: 대부분의 DB에서 기존 데이터가 있는 컬럼의 collation 변경은 테이블 재생성 필요
- 인덱스 재생성 필요: collation이 바뀌면 관련 인덱스도 모두 재생성해야 함
- 다운타임 발생: 대용량 테이블이면 마이그레이션에 수 시간 소요
PostgreSQL 설정:
-- 데이터베이스 생성 시 설정 (가장 확실)
CREATE DATABASE myapp
WITH ENCODING = 'UTF8'
LC_COLLATE = 'ko_KR.UTF-8'
LC_CTYPE = 'ko_KR.UTF-8'
TEMPLATE = template0;
-- 또는 ICU collation 사용 (PostgreSQL 10+, 더 정확한 정렬)
CREATE DATABASE myapp
WITH ENCODING = 'UTF8'
LOCALE_PROVIDER = icu
ICU_LOCALE = 'ko-KR'
TEMPLATE = template0;
MySQL 설정:
-- 데이터베이스 생성 시
CREATE DATABASE myapp
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 한글 정렬이 중요하면
COLLATE utf8mb4_korean_ci;
Collation 선택 가이드:
| 요구사항 | PostgreSQL | MySQL |
|---|---|---|
| 한글 가나다순 정렬 | ko_KR.UTF-8 또는 ICU ko-KR | utf8mb4_korean_ci |
| 대소문자 구분 | 기본값 (case-sensitive) | utf8mb4_bin |
| 대소문자 무시 | CITEXT 타입 사용 | utf8mb4_unicode_ci |
| 이모지 지원 | UTF8 (기본 지원) | utf8mb4 필수 |
// Drizzle ORM에서 테이블 정의 시 collation 지정
import { pgTable, text, varchar } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
// 특정 컬럼에만 다른 collation 적용 가능
name: text('name').notNull(), // DB 기본 collation 사용
email: varchar('email', { length: 255 })
.notNull()
.$type<string>(), // 타입 힌트
});
실용적인 원칙
프로젝트 시작할 때 5분만 투자해서 타임존과 collation을 설정하세요. 나중에 바꾸려면 5시간이 아니라 5일이 걸릴 수 있습니다.
8. 중요한 API에는 멱등성을 보장하라
결제, 포인트 차감, 주문 생성 같은 API에서 "같은 요청이 두 번 처리되면 안 된다"는 요구사항은 필수입니다. 이걸 보장하는 개념이 멱등성(Idempotency)입니다.
멱등성이란
멱등성은 같은 연산을 여러 번 수행해도 결과가 동일한 성질입니다.
f(x) = f(f(x)) = f(f(f(x))) = ...
일상적인 예시로 이해하면 쉽습니다:
- 멱등한 것: 엘리베이터 버튼 누르기 - 한 번 누르든 열 번 누르든 엘리베이터는 한 번만 옴
- 멱등하지 않은 것: 커피 주문 - 버튼 누를 때마다 커피가 추가됨
HTTP 메서드로 보면:
| 메서드 | 멱등성 | 설명 |
|---|---|---|
| GET | O | 같은 리소스 조회를 여러 번 해도 결과 동일 |
| PUT | O | 같은 데이터로 여러 번 업데이트해도 최종 상태 동일 |
| DELETE | O | 이미 삭제된 리소스를 또 삭제해도 결과는 "없음"으로 동일 |
| POST | X | 호출할 때마다 새 리소스가 생성될 수 있음 |
문제는 POST가 멱등하지 않다는 점입니다. 결제, 주문, 포인트 차감은 대부분 POST인데, 이게 두 번 실행되면 큰일납니다.
왜 두 번 실행되는가
"API를 두 번 호출할 일이 있나?"라고 생각할 수 있지만, 실제로는 자주 발생합니다.
실제로 겪은 일
결제 API 응답이 5초 이상 걸리자 사용자가 "결제" 버튼을 다시 눌렀습니다. 결제가 두 번 승인되었고, 환불 처리와 CS 대응에 반나절을 썼습니다.
두 번 실행되는 시나리오들:
1. 사용자의 중복 클릭
[결제 버튼 클릭] → 응답 느림 → [다시 클릭] → 결제 2번
2. 네트워크 타임아웃 + 자동 재시도
[요청 전송] → 서버 처리 완료 → 응답 중 타임아웃 → [클라이언트 재시도] → 2번 처리
3. 프론트엔드 라이브러리의 자동 재시도
React Query, SWR 등이 실패로 간주하고 자동 재시도 → 2번 처리
4. 로드밸런서/프록시의 재시도
upstream 타임아웃 → 다른 서버로 재시도 → 2번 처리
특히 2번 시나리오가 위험합니다. 서버는 처리를 완료했는데 클라이언트는 실패로 인식하는 경우입니다. 이때 재시도하면 중복 처리가 발생합니다.
멱등성 키(Idempotency Key)로 해결하기
해결 방법은 간단합니다. 클라이언트가 고유한 키를 보내고, 서버는 이 키로 중복 요청을 식별합니다.
// 클라이언트: 요청 시 고유 키 생성
const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // 고유 키 전송
},
body: JSON.stringify({ amount: 10000, orderId: 'order-123' }),
});
// 서버: 키 기반으로 중복 체크
async function handlePayment(req: Request) {
const idempotencyKey = req.headers.get('Idempotency-Key');
if (!idempotencyKey) {
throw new Error('Idempotency-Key header is required');
}
// 1. 이미 처리된 요청인지 확인
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) {
// 이미 처리됨 → 저장된 결과 반환
return JSON.parse(existing);
}
// 2. 처리 중 상태로 마킹 (다른 요청이 동시에 처리하는 것 방지)
const locked = await redis.set(
`idempotency:${idempotencyKey}`,
JSON.stringify({ status: 'processing' }),
'NX', // 키가 없을 때만 설정
'EX', 60 // 60초 후 만료 (처리 중 타임아웃)
);
if (!locked) {
// 다른 요청이 처리 중
throw new Error('Request is being processed');
}
// 3. 실제 결제 처리
const result = await processPayment(req.body);
// 4. 결과 저장 (24시간 보관)
await redis.set(
`idempotency:${idempotencyKey}`,
JSON.stringify(result),
'EX', 86400
);
return result;
}
핵심 포인트:
- 키는 클라이언트가 생성: 서버가 생성하면 "이 요청이 재시도인지" 알 수 없음
- 처리 중 상태 관리: 동시에 같은 키로 요청이 오면 하나만 처리
- 결과 저장: 재시도 시 같은 결과를 반환해야 진정한 멱등성
- TTL 설정: 영원히 저장하면 스토리지 낭비, 적절한 만료 시간 설정
DB 기반 멱등성 (Redis 없이)
Redis가 없는 환경이라면 DB로도 구현할 수 있습니다.
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_path VARCHAR(255) NOT NULL,
request_body JSONB,
response_body JSONB,
status VARCHAR(20) DEFAULT 'processing', -- processing, completed, failed
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
-- 오래된 키 자동 삭제를 위한 인덱스
CREATE INDEX idx_idempotency_created_at ON idempotency_keys(created_at);
async function withIdempotency<T>(
key: string,
fn: () => Promise<T>
): Promise<T> {
// 1. 키 삽입 시도 (이미 있으면 실패)
try {
await db.insert(idempotencyKeys).values({
key,
status: 'processing',
});
} catch (e) {
// 이미 존재하는 키
const existing = await db.query.idempotencyKeys.findFirst({
where: eq(idempotencyKeys.key, key),
});
if (existing?.status === 'completed') {
return existing.responseBody as T;
}
if (existing?.status === 'processing') {
throw new Error('Request is being processed');
}
}
// 2. 실제 로직 실행
try {
const result = await fn();
// 3. 성공 결과 저장
await db.update(idempotencyKeys)
.set({
status: 'completed',
responseBody: result,
completedAt: new Date(),
})
.where(eq(idempotencyKeys.key, key));
return result;
} catch (e) {
// 4. 실패 시 키 삭제 (재시도 허용)
await db.delete(idempotencyKeys)
.where(eq(idempotencyKeys.key, key));
throw e;
}
}
// 사용
app.post('/api/payments', async (req) => {
const idempotencyKey = req.headers['idempotency-key'];
return withIdempotency(idempotencyKey, async () => {
return processPayment(req.body);
});
});
프론트엔드에서의 처리
멱등성 키를 제대로 활용하려면 프론트엔드도 협력해야 합니다.
// hooks/useIdempotentMutation.ts
function useIdempotentMutation<T>(
mutationFn: (data: T, key: string) => Promise<Response>
) {
const [idempotencyKey, setIdempotencyKey] = useState(() => crypto.randomUUID());
const mutation = useMutation({
mutationFn: (data: T) => mutationFn(data, idempotencyKey),
onSuccess: () => {
// 성공 시 새 키 생성 (다음 요청을 위해)
setIdempotencyKey(crypto.randomUUID());
},
// 실패 시 같은 키로 재시도 (자동으로 됨)
});
return mutation;
}
// 사용
function PaymentButton({ orderId, amount }: Props) {
const mutation = useIdempotentMutation(async (data, key) => {
return fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify(data),
});
});
return (
<button
onClick={() => mutation.mutate({ orderId, amount })}
disabled={mutation.isPending}
>
{mutation.isPending ? '처리 중...' : '결제하기'}
</button>
);
}
실용적인 원칙
결제, 포인트, 재고처럼 "두 번 실행되면 안 되는" 모든 API에 멱등성 키를 적용하세요. 구현 비용은 하루, 사고 수습 비용은 일주일입니다.
9. 감사 로그(Audit Log)를 제대로 설계하라
"누가 이 데이터를 바꿨어요?"라는 질문에 답하지 못하면 곤란합니다. 감사 로그는 요구사항에 포함되는 경우가 많지만, 설계를 잘못하면 나중에 쓸모없는 로그가 됩니다.
감사 로그가 필요한 순간들
실제로 겪은 일
고객사에서 "3일 전에 수정된 데이터를 원래대로 복구해달라"고 요청했습니다. 변경 이력은 있었지만 "변경 전 값"을 저장하지 않아서 복구할 수 없었습니다.
감사 로그가 빛을 발하는 순간들:
- "이 주문 금액이 원래 이게 맞았나요?" → 변경 이력 확인
- "누가 이 사용자를 삭제했어요?" → 삭제자 추적
- "언제부터 이 설정이 바뀌었죠?" → 변경 시점 확인
- 내부 감사/외부 감사 대응 → 모든 변경 이력 제출
- 버그 원인 분석 → "이 시점에 뭐가 바뀌었지?"
흔한 실수: 쓸모없는 감사 로그
// Bad: 변경 사실만 기록
await auditLog.create({
action: 'UPDATE',
entity: 'users',
entityId: userId,
userId: currentUser.id,
timestamp: new Date(),
});
// "user 123이 수정됨"만 알 수 있음. 뭐가 어떻게 바뀌었는지는 모름.
// Bad: 변경된 필드만 기록
await auditLog.create({
action: 'UPDATE',
entity: 'users',
entityId: userId,
changes: { email: 'new@email.com' }, // 변경 후 값만 있음
});
// 이전 이메일이 뭐였는지 알 수 없음
// Bad: JSON으로 대충 저장
await auditLog.create({
data: JSON.stringify({
action: 'update',
table: 'users',
// ... 구조가 일관되지 않음
}),
});
// 나중에 쿼리/분석 불가능
감사 로그 테이블 설계
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
-- 언제
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
-- 누가
actor_id VARCHAR(255), -- 사용자 ID (시스템이면 NULL)
actor_type VARCHAR(50) NOT NULL, -- 'user', 'admin', 'system', 'api_key'
actor_ip INET, -- IP 주소
actor_user_agent TEXT, -- User Agent
-- 무엇을
entity_type VARCHAR(100) NOT NULL, -- 'users', 'orders', 'products'
entity_id VARCHAR(255) NOT NULL, -- 대상 레코드 ID
-- 어떻게
action VARCHAR(50) NOT NULL, -- 'create', 'update', 'delete', 'restore'
-- 변경 내용 (핵심!)
before_state JSONB, -- 변경 전 전체 스냅샷
after_state JSONB, -- 변경 후 전체 스냅샷
changed_fields TEXT[], -- 변경된 필드 목록 (빠른 필터링용)
-- 부가 정보
reason TEXT, -- 변경 사유 (선택)
request_id VARCHAR(255), -- 요청 추적 ID
-- 인덱스를 위한 컬럼
created_date DATE GENERATED ALWAYS AS (created_at::date) STORED
);
-- 필수 인덱스
CREATE INDEX idx_audit_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_audit_actor ON audit_logs(actor_id, created_at);
CREATE INDEX idx_audit_created_date ON audit_logs(created_date);
CREATE INDEX idx_audit_changed_fields ON audit_logs USING GIN(changed_fields);
설계 포인트:
- before/after 전체 저장: 변경된 필드만 저장하면 복구 시 전체 상태를 알 수 없음
- changed_fields 배열: "이메일이 변경된 이력"만 빠르게 조회 가능
- actor_type 구분: 사용자, 관리자, 시스템, API 키 등 구분
- 날짜 파티셔닝 대비: created_date 컬럼으로 파티셔닝 가능
Drizzle ORM에서 구현
// schema/audit-logs.ts
import { pgTable, bigserial, timestamp, varchar, jsonb, text, inet } from 'drizzle-orm/pg-core';
export const auditLogs = pgTable('audit_logs', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
actorId: varchar('actor_id', { length: 255 }),
actorType: varchar('actor_type', { length: 50 }).notNull(),
actorIp: inet('actor_ip'),
actorUserAgent: text('actor_user_agent'),
entityType: varchar('entity_type', { length: 100 }).notNull(),
entityId: varchar('entity_id', { length: 255 }).notNull(),
action: varchar('action', { length: 50 }).notNull(),
beforeState: jsonb('before_state'),
afterState: jsonb('after_state'),
changedFields: text('changed_fields').array(),
reason: text('reason'),
requestId: varchar('request_id', { length: 255 }),
});
// lib/audit.ts
type AuditAction = 'create' | 'update' | 'delete' | 'restore';
interface AuditContext {
actorId?: string;
actorType: 'user' | 'admin' | 'system' | 'api_key';
actorIp?: string;
actorUserAgent?: string;
requestId?: string;
reason?: string;
}
export async function createAuditLog<T extends Record<string, unknown>>(
entityType: string,
entityId: string,
action: AuditAction,
context: AuditContext,
before?: T,
after?: T,
) {
// 변경된 필드 계산
const changedFields = before && after
? Object.keys(after).filter(key =>
JSON.stringify(before[key]) !== JSON.stringify(after[key])
)
: undefined;
await db.insert(auditLogs).values({
entityType,
entityId: String(entityId),
action,
actorId: context.actorId,
actorType: context.actorType,
actorIp: context.actorIp,
actorUserAgent: context.actorUserAgent,
requestId: context.requestId,
reason: context.reason,
beforeState: before,
afterState: after,
changedFields,
});
}
// 사용 예시: 사용자 정보 수정
async function updateUser(
userId: string,
data: UpdateUserInput,
context: AuditContext
) {
// 1. 현재 상태 조회 (before)
const before = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!before) throw new Error('User not found');
// 2. 업데이트 실행
const [after] = await db
.update(users)
.set(data)
.where(eq(users.id, userId))
.returning();
// 3. 감사 로그 기록
await createAuditLog(
'users',
userId,
'update',
context,
before,
after
);
return after;
}
트리거 vs 애플리케이션: 트레이드오프
감사 로그를 DB 트리거로 구현할지, 애플리케이션에서 구현할지는 트레이드오프가 있습니다.
DB 트리거 방식:
-- PostgreSQL 트리거 예시
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
entity_type, entity_id, action,
before_state, after_state, actor_type
) VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id)::text,
TG_OP,
CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN row_to_json(OLD) END,
CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN row_to_json(NEW) END,
'system' -- 트리거에서는 사용자 정보를 알기 어려움
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_users_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
| 방식 | 장점 | 단점 |
|---|---|---|
| DB 트리거 | 누락 불가능, 직접 SQL 수정도 기록됨 | 사용자 정보 전달 어려움, 디버깅 어려움, DB 부하 |
| 애플리케이션 | 풍부한 컨텍스트(사용자, IP 등), 유연한 로직 | 개발자가 빼먹을 수 있음, 직접 SQL은 기록 안 됨 |
추천 방식
애플리케이션 레벨을 기본으로 하되, 중요한 테이블에는 트리거를 백업으로 추가하세요. 트리거는 "최소한의 기록"만 하고, 상세 정보는 애플리케이션에서 기록합니다.
감사 로그 조회 API
// 특정 엔티티의 변경 이력 조회
app.get('/api/audit/:entityType/:entityId', async (req) => {
const { entityType, entityId } = req.params;
const { limit = 50, offset = 0 } = req.query;
const logs = await db.query.auditLogs.findMany({
where: and(
eq(auditLogs.entityType, entityType),
eq(auditLogs.entityId, entityId)
),
orderBy: desc(auditLogs.createdAt),
limit: Number(limit),
offset: Number(offset),
});
return logs;
});
// 특정 필드가 변경된 이력만 조회
app.get('/api/audit/:entityType', async (req) => {
const { entityType } = req.params;
const { field, from, to } = req.query;
const logs = await db.query.auditLogs.findMany({
where: and(
eq(auditLogs.entityType, entityType),
field ? sql`${field} = ANY(${auditLogs.changedFields})` : undefined,
from ? gte(auditLogs.createdAt, new Date(from as string)) : undefined,
to ? lte(auditLogs.createdAt, new Date(to as string)) : undefined,
),
orderBy: desc(auditLogs.createdAt),
limit: 100,
});
return logs;
});
데이터 복구 유틸리티
감사 로그의 진가는 복구할 때 발휘됩니다.
// 특정 시점으로 데이터 복구
async function restoreEntity(
entityType: string,
entityId: string,
targetTimestamp: Date,
context: AuditContext
) {
// 1. 해당 시점 직전의 상태 찾기
const log = await db.query.auditLogs.findFirst({
where: and(
eq(auditLogs.entityType, entityType),
eq(auditLogs.entityId, entityId),
lte(auditLogs.createdAt, targetTimestamp)
),
orderBy: desc(auditLogs.createdAt),
});
if (!log?.afterState) {
throw new Error('No state found for the given timestamp');
}
// 2. 현재 상태 조회
const currentState = await db.query[entityType].findFirst({
where: eq(schema[entityType].id, entityId),
});
// 3. 복구 실행
await db.update(schema[entityType])
.set(log.afterState)
.where(eq(schema[entityType].id, entityId));
// 4. 복구 이력도 감사 로그에 기록
await createAuditLog(
entityType,
entityId,
'restore',
{ ...context, reason: `Restored to state at ${targetTimestamp.toISOString()}` },
currentState,
log.afterState
);
}
실용적인 원칙
감사 로그는 "있다"만으로는 부족합니다. before/after 전체 상태를 저장하고, 복구까지 가능하게 설계하세요. 로그가 있어도 복구를 못 하면 반쪽짜리입니다.
마치며: 코드에서 나는 악취를 맡아라
이 글에서 다룬 실수들의 공통점은 나도 모르게 SOLID 원칙을 어기게 된다는 것입니다.
- 뷰는 단일 책임 원칙(SRP)을 위반 - 변경 지점이 분산됨
- 과도한 컴포넌트 분리는 인터페이스 분리 원칙(ISP)을 위반 - 불필요한 props 의존성 증가
- 메뉴 하드코딩은 개방-폐쇄 원칙(OCP)을 위반 - 새 요구사항마다 기존 코드 수정 필요
- 분석 없는 구현과 자의적 매핑은 KISS 원칙을 위반 - 불필요한 복잡성 추가
- 에러 핸들링 분산은 DRY 원칙을 위반 - 동일한 처리 로직이 여러 곳에 복붙됨
- 타입 분리 정의는 신뢰성을 위반 - 런타임에서야 불일치 발견
- DB 초기 세팅 누락은 "나중에 바꾸면 되지" 사고방식의 전형 - 데이터 쌓인 후에는 변경 불가
- 멱등성 미적용은 "두 번 호출될 리 없다"는 낙관의 결과 - 네트워크는 믿을 수 없음
- 감사 로그 설계 미흡은 "기록만 하면 되지"의 결과 - 복구 못 하면 무용지물
문제는 이런 위반이 코드를 작성하는 순간에는 잘 보이지 않는다는 점입니다. "이게 더 깔끔해 보이는데?"라고 생각하며 작성하지만, 시간이 지나고 유지보수할 때 비용이 드러납니다.
경험이 필요한 이유
SOLID 원칙 위반은 코드에서 나는 악취(Code Smell)와 같습니다. 악취를 맡으려면 경험이 필요합니다. 한 번 겪어봐야 "아, 이게 그 냄새구나" 알게 됩니다.
이 글에서 다룬 것들도 직접 겪어보니 알게 된 것들입니다:
- 스키마 바꿨는데 뷰 수정을 깜빡해서 장애 나봐야 알게 됨
- 컴포넌트 100개 넘어가니까 간단한 수정에 30분 걸리는 걸 겪어봐야 알게 됨
- "메뉴 이름 좀 바꿔주세요"에 50개 파일 수정하면서 후회해봐야 알게 됨
- "이게 아닌데요" 듣고 절반 다시 만들어봐야 알게 됨
- "에러 메시지 포맷 통일해주세요"에 모든 페이지 돌아다녀봐야 알게 됨
- 프로덕션에서 "Cannot read property of undefined" 터져봐야 알게 됨
- 데이터 100만 건 쌓인 후에 "한글 정렬이 안 돼요" 듣고 테이블 재생성해봐야 알게 됨
- 결제가 두 번 되어서 환불 처리하고 CS 대응해봐야 알게 됨
- "3일 전 데이터로 복구해주세요"에 "변경 전 값이 없어서요..." 라고 답변해봐야 알게 됨
"필요해지면 그때 추상화하자"가 "미리 추상화해두자"보다 대부분의 경우 나은 선택이었습니다. 추상화는 복잡성을 숨기는 좋은 도구이지만, 숨겨진 복잡성은 여전히 존재합니다.
앞으로도 겪은 실수들을 이 글에 추가해나갈 예정입니다.