2025년 11월 15일
최근 면접을 위한 CS 전공 지식 노트를 읽으면서 디자인 패턴을 다시 정리하게 되었습니다. 사실 예전에는 "디자인 패턴? 백엔드에서나 쓰는 거 아냐?"라고 생각했었는데, React로 실무를 하다 보니 생각보다 많은 곳에서 패턴이 사용되고 있더라구요. 특히 상태 관리나 컴포넌트 설계에서 패턴을 이해하고 있으면 코드 리뷰나 설계 논의가 훨씬 수월해집니다.
이 글에서는 디자인 패턴에 대해 정리해보며, 프론트엔드에서도 볼 수 있는 디자인 패턴을 실제 코드와 함께 정리해보려고 합니다. 직접 맞닿았던 코드들과 예시를 통해 이 참에 자세히 배워보려고 합니다.
디자인 패턴이란 반복되는 설계 문제에 대한 검증된 해결책이라고 할 수 있습니다. "이런 상황에서는 보통 이렇게 해결한다"는 일종의 레시피 같은 것이라고 보면 됩니다.
최근, AI 챗봇 서버를 구현 할 때 클라이언트 초기화, 데이터 통신 등에서 디자인 패턴을 직접적으로 사용하면서 더욱 더 깊이 이해할 수 있었습니다. 특히 다른 개발자와의 소통에서 패턴을 이해하고 있으면 더욱 효과적인 소통이 가능할 뿐 아니라 가독성 등 유지보수성도 향상 시킬 수 있었습니다.
다만 주의할 점은, 패턴은 "도구"이지 "목적"이 아니라는 것이라고 할 수 있습니다. 무조건 패턴을 적용하려다 보면 오히려 코드가 복잡해질 수 있습니다. 결론적으로는 필요한 곳에 적절히 사용하는 게 중요하다고 할 수 있습니다.
대표적인 디자인 패턴들에 대해 간단하게 살펴보겠습니다.
싱글톤 패턴이란 특정 클래스의 인스턴스가 오직 하나만 생성되도록 보장하는 디자인 패턴입니다. 주로 DB 연결, API 클라이언트, 로거, 설정 관리자 등에 사용됩니다.
API 클라이언트, 로거, 설정 관리자처럼 앱 전체에서 딱 하나만 존재하면 되는 객체가 있다. 매번 새로 만들 필요도 없고, 여러 개가 존재하면 오히려 문제가 될 수 있는 경우다.
예를 들어, API 클라이언트는 앱 전체에서 딱 하나만 존재하면 됩니다. 매번 새로 만들 필요도 없고, 여러 개가 존재하면 오히려 문제가 될 수 있습니다.
이럴 때 싱글톤 패턴을 사용하면 됩니다.
// lib/apiClient.ts
class ApiClient {
private static instance: ApiClient | null = null
private baseUrl: string
private constructor() {
this.baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''
}
static getInstance(): ApiClient {
if (!ApiClient.instance) {
ApiClient.instance = new ApiClient()
}
return ApiClient.instance
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
return response.json()
}
async post<T>(
endpoint: string,
data: unknown,
options?: RequestInit,
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
return response.json()
}
}
export const apiClient = ApiClient.getInstance()
// hooks/usePosts.ts
import { useEffect, useState } from 'react'
import { apiClient } from '@/lib/apiClient'
interface Post {
id: number
title: string
content: string
}
export function usePosts() {
const [posts, setPosts] = useState<Post[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
apiClient
.get<Post[]>('/posts')
.then(setPosts)
.catch(setError)
.finally(() => setIsLoading(false))
}, [])
return { posts, isLoading, error }
}
팩토리 패턴은 객체 생성 로직을 별도의 팩토리(공장) 클래스에서 담당하게 하여, 객체 생성과 사용을 분리하는 패턴입니다. 하위 클래스가 실제로 어떤 객체를 생성할지 결정하도록 위임합니다. 즉, 상위 클래스는 뼈대만, 하위 클래스는 구체적인 생성 로직을 담당합니다.
디자인 시스템을 만들다 보면 비슷한데 조금씩 다른 컴포넌트를 여러 개 만들게 됩니다. 예를 들어 Primary Button, Secondary Button, Ghost Button... 이런 식으로 말이죠. 각각을 별도 컴포넌트로 만들면 중복 코드가 많아지고, 하나의 거대한 컴포넌트에 조건문으로 처리하면 읽기 어려워집니다.
팩토리 패턴을 쓰면 "생성 방식"을 분리해서 깔끔하게 관리할 수 있습니다. 상위 buttonFactory 컴포넌트는 어떻게 버튼을 생성할 지 정의하고, 실제로 아래 하위 버튼 컴포넌트에서 어떤 버튼을 생성할 지 결정하도록 합니다.
이렇게 하면 새로운 버튼 컴포넌트를 추가할 때 기존 코드를 거의 건드리지 않고 새로운 버튼 컴포넌트를 추가할 수 있습니다. 이는 유지보수성을 향상시키는 데 큰 도움이 됩니다.
// components/Button/buttonFactory.tsx
import { ButtonHTMLAttributes, ReactNode } from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonConfig {
variant: ButtonVariant
size?: ButtonSize
}
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800',
secondary:
'bg-gray-100 text-gray-900 hover:bg-gray-200 border border-gray-300',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
const baseStyles =
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
export function createButton({ variant, size = 'md' }: ButtonConfig) {
return function Button({
children,
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactNode }) {
const classes = [
baseStyles,
variantStyles[variant],
sizeStyles[size],
className,
].join(' ')
return (
<button className={classes} {...props}>
{children}
</button>
)
}
}
// components/Button/index.tsx
import { createButton } from './buttonFactory'
export const PrimaryButton = createButton({ variant: 'primary' })
export const SecondaryButton = createButton({ variant: 'secondary' })
export const GhostButton = createButton({ variant: 'ghost' })
export const DangerButton = createButton({ variant: 'danger' })
// 사이즈 변형도 가능
export const SmallPrimaryButton = createButton({
variant: 'primary',
size: 'sm',
})
export const LargeDangerButton = createButton({ variant: 'danger', size: 'lg' })
// app/components/CTASection.tsx
import { PrimaryButton, GhostButton } from '@/components/Button'
export function CTASection() {
const handleSignup = () => {
// 회원가입 로직
}
const handleLearnMore = () => {
// 자세히 보기 로직
}
return (
<section className="flex flex-col items-center gap-4 py-12">
<h2 className="text-3xl font-bold">지금 시작하세요</h2>
<div className="flex gap-3">
<PrimaryButton onClick={handleSignup}>무료로 시작하기</PrimaryButton>
<GhostButton onClick={handleLearnMore}>자세히 보기</GhostButton>
</div>
</section>
)
}
이 패턴을 Storybook과 함께 쓰면 효과가 배가 됩니다. 모든 버튼 변형을 자동으로 문서화할 수 있기 때문이죠.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import {
PrimaryButton,
SecondaryButton,
GhostButton,
DangerButton,
} from './index'
const meta: Meta<typeof PrimaryButton> = {
title: 'Components/Button',
component: PrimaryButton,
}
export default meta
export const Primary: StoryObj<typeof PrimaryButton> = {
args: {
children: 'Primary Button',
},
}
export const Secondary: StoryObj<typeof SecondaryButton> = {
render: () => <SecondaryButton>Secondary Button</SecondaryButton>,
}
전략 패턴(Strategy Pattern)은 알고리즘을 각각 별도의 클래스로 캡슐화하여, 실행 중에도 알고리즘을 자유롭게 바꿀 수 있도록 하는 객체지향 디자인 패턴입니다
전략 패턴의 핵심 개념은 다음과 같습니다.
폼 입력 포맷팅을 예로 들어보자. 전화번호 입력 필드에서는 "010-1234-5678" 형태로, 가격 입력 필드에서는 "1,000,000원" 형태로 표시하고 싶습니다. 각 필드마다 다른 컴포넌트를 만들 수도 있지만, 포맷팅 로직만 교체 가능하도록 만들면 훨씬 유연합니다. 각 필드마다 다른 컴포넌트를 만들면 중복 코드가 많아지고, 하나의 거대한 컴포넌트에 조건문으로 처리하면 읽기 어려워집니다. 포맷팅 로직만 교체 가능하도록 만들면 훨씬 유연합니다.
아래처럼 각 타입별로 포맷팅 로직을 정의하고, 각 경우의 수에 따라 다양한 동작을 유연하게 처리할 수 있도록 할 수 있습니다.
// lib/formatStrategies.ts
export type FormatStrategy = (value: string) => string
export const phoneFormatter: FormatStrategy = (value) => {
const numbers = value.replace(/[^0-9]/g, '')
if (numbers.length <= 3) return numbers
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`
}
export const priceFormatter: FormatStrategy = (value) => {
const numbers = value.replace(/[^0-9]/g, '')
if (!numbers) return ''
return new Intl.NumberFormat('ko-KR').format(Number(numbers))
}
export const cardNumberFormatter: FormatStrategy = (value) => {
const numbers = value.replace(/[^0-9]/g, '')
const chunks = numbers.match(/.{1,4}/g) || []
return chunks.join('-').slice(0, 19) // 16자리 + 하이픈 3개
}
export const businessNumberFormatter: FormatStrategy = (value) => {
const numbers = value.replace(/[^0-9]/g, '')
if (numbers.length <= 3) return numbers
if (numbers.length <= 5) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`
return `${numbers.slice(0, 3)}-${numbers.slice(3, 5)}-${numbers.slice(5, 10)}`
}
type StrategyType = 'phone' | 'price' | 'card' | 'business'
const strategies: Record<StrategyType, FormatStrategy> = {
phone: phoneFormatter,
price: priceFormatter,
card: cardNumberFormatter,
business: businessNumberFormatter,
}
export function getFormatStrategy(type: StrategyType): FormatStrategy {
return strategies[type]
}
// hooks/useFormattedInput.ts
import { useState, ChangeEvent } from 'react'
import { getFormatStrategy } from '@/lib/formatStrategies'
type FormatType = 'phone' | 'price' | 'card' | 'business'
export function useFormattedInput(type: FormatType, initialValue = '') {
const [value, setValue] = useState(initialValue)
const [rawValue, setRawValue] = useState(initialValue.replace(/[^0-9]/g, ''))
const formatter = getFormatStrategy(type)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const input = e.target.value
const formatted = formatter(input)
const raw = input.replace(/[^0-9]/g, '')
setValue(formatted)
setRawValue(raw)
}
const reset = () => {
setValue('')
setRawValue('')
}
return {
value,
rawValue,
handleChange,
reset,
}
}
// components/FormField.tsx
import { useFormattedInput } from '@/hooks/useFormattedInput'
export function PhoneField() {
const { value, rawValue, handleChange } = useFormattedInput('phone')
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">전화번호</label>
<input
type="text"
value={value}
onChange={handleChange}
placeholder="010-1234-5678"
className="rounded-md border border-gray-300 px-3 py-2"
/>
<p className="text-xs text-gray-500">실제 전송될 값: {rawValue}</p>
</div>
)
}
export function PriceField() {
const { value, rawValue, handleChange } = useFormattedInput('price')
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">가격</label>
<div className="relative">
<input
type="text"
value={value}
onChange={handleChange}
placeholder="1,000,000"
className="w-full rounded-md border border-gray-300 px-3 py-2 pr-8"
/>
<span className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-500">
원
</span>
</div>
<p className="text-xs text-gray-500">숫자 값: {rawValue}</p>
</div>
)
}
옵저버 패턴은 한 객체의 상태 변화가 있을 때, 이를 관찰하는 여러 객체(옵저버)에게 자동으로 통지(알림)하는 패턴입니다. 일대다(One to Many) 관계에서 주로 사용되며, 대표적으로 이벤트 시스템, GUI의 데이터 바인딩 등에 활용됩니다.
MVC 패턴 또한 옵저버 패턴을 기반으로 합니다.
옵저버 패턴은 사실 React를 쓰는 사람이라면 이미 매일 사용하고 있는 패턴이라고 할 수 있습니다. useState, useEffect, 상태 관리 라이브러리들이 모두 옵저버 패턴을 기반으로 한다고 할 수 있습니다.
옵저버 패턴 구성 요소는 크게 옵저버(Observer)와 관찰 대상(Subject)로 나뉩니다.
이를 슈도 코드로 표현하면 아래와 같이 표현할 수 있습니다.
class Subject {
private observers: Observer[] = []
attach(o: Observer) {
this.observers.push(o)
}
detach(o: Observer) {
/* ...제거... */
}
notify() {
this.observers.forEach((o) => o.update(this))
}
setState(newState) {
this.state = newState
this.notify()
}
}
interface Observer {
update(subject: Subject): void
}
프록시 패턴은 원본 객체에 대한 접근을 제어하고, 추가 기능을 제공하는 디자인 패턴입니다. 즉, 객체에 직접 접근하지 않고, 대리 객체(Proxy)를 통해 제어·보호·지연·추가 기능을 넣는 패턴입니다.
API 요청마다 인증 토큰을 자동으로 추가하거나, 로깅을 하거나, 캐싱을 하거나... 원본 객체에 직접 접근하기 전에 뭔가 전처리가 필요할 때 프록시 패턴이 유용합니다.
JavaScript의 Proxy 객체를 사용하면 이를 쉽게 구현할 수 있습니다.
// lib/apiClientWithProxy.ts
interface ApiRequest {
endpoint: string
options?: RequestInit
}
interface ApiResponse<T> {
data: T
status: number
timestamp: number
}
class BaseApiClient {
constructor(private baseUrl: string) {}
async request<T>(
endpoint: string,
options?: RequestInit,
): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, options)
return {
data: await response.json(),
status: response.status,
timestamp: Date.now(),
}
}
}
// 프록시로 감싸서 자동으로 토큰 추가, 로깅, 에러 처리
export function createApiClientProxy(baseUrl: string) {
const client = new BaseApiClient(baseUrl)
return new Proxy(client, {
get(target, prop) {
if (prop === 'request') {
return async function <T>(endpoint: string, options?: RequestInit) {
// 요청 전 처리
const token = localStorage.getItem('auth_token')
const enhancedOptions: RequestInit = {
...options,
headers: {
...options?.headers,
...(token && { Authorization: `Bearer ${token}` }),
},
}
console.log(`[API] ${options?.method || 'GET'} ${endpoint}`)
const startTime = Date.now()
try {
const result = await target.request<T>(endpoint, enhancedOptions)
// 응답 후 처리
const duration = Date.now() - startTime
console.log(`[API] ✓ ${endpoint} (${duration}ms)`)
return result
} catch (error) {
// 에러 처리
const duration = Date.now() - startTime
console.error(`[API] ✗ ${endpoint} (${duration}ms)`, error)
// 401 에러시 자동 리다이렉트
if (error instanceof Response && error.status === 401) {
window.location.href = '/login'
}
throw error
}
}
}
return Reflect.get(target, prop)
},
})
}
export const apiClient = createApiClientProxy(
process.env.NEXT_PUBLIC_API_BASE_URL ?? '',
)
개발 중에 실수로 상태를 직접 수정하는 걸 방지하기 위해 프록시를 사용할 수도 있다.
// lib/immutableProxy.ts
export function createImmutableProxy<T extends object>(
obj: T,
name = 'state',
): T {
return new Proxy(obj, {
set() {
throw new Error(
`${name}는 직접 수정할 수 없습니다. setState를 사용하세요.`,
)
},
get(target, prop) {
const value = Reflect.get(target, prop)
// 중첩된 객체도 프록시로 감싸기
if (value && typeof value === 'object') {
return createImmutableProxy(value, `${name}.${String(prop)}`)
}
return value
},
})
}
// 사용 예시
const state = createImmutableProxy({ user: { name: '홍길동', age: 30 } })
console.log(state.user.name) // "홍길동"
state.user.age = 31 // Error: state.user는 직접 수정할 수 없습니다.
이 외 이 패턴 기반으로 nginx 등 프록시 서버 개념도 구현할 수 있습니다. 프록시 서버란 클라이언트와 서버 사이에서 중간 매체를 하는 서버를 의미합니다.
패턴은 "문제를 해결하기 위한 도구" 이자 효율적인 의사소통을 위해 필요하다고 볼 수 있습니다. 하지만 이에 너무 매몰되면 오히려 코드가 복잡해지거나, 개발 속도가 떨어질 수도 있단 것을 배웠습니다. 예를 들어, 간단한 컴포넌트임에도 추후 유지보수 및 확장 가능성을 생각해 패턴을 적용하면 오히려 코드가 복잡해지고, 개발 속도가 떨어질 수 있습니다.
디자인 패턴을 도입할 때는 반드시 팀과 논의해야 합니다. 특히 공용 컴포넌트나 핵심 라이브러리에 패턴을 적용할 때는 더욱 그렇습니다. 오히려 의사소통을 위한 도구가 불필요한 소통이 늘어나는 결과를 초래할 수 있습니다. 모두가 이해할 수 있는 팀 마다 정형화된 패턴을 도입하는 것이 중요하다고 생각합니다.
그럼에도 불구하고, 디자인 패턴을 모르고 개발 하는 것과 알고 개발 하는 것의 차이는 분명하다는 것을 배웠습니다. 실제로 부끄럽지만 이렇게 직접 찾아보고 책을 읽어보기 전까지는 구현에 집중 했지, 디자인 패턴에 대해선 깊게 고민하지 못했습니다. 그저 자격증 및 학교 시험 공부를 위해 공부 했던 경험 밖에 없어 실제로 디자인 패턴을 적용해본 경험 또한 없었습니다.
하지만 현재 회사에서 AI 챗봇 서버부터 프론트엔드 까지 폭넓게 작업을 하면서 다양한 기술 스택 사용 및 기능을 구현하면서 느낀 점은 프론트엔드부터 백엔드까지 용도와 방법은 약간씩 차이는 있더라도 공통된 패턴이 있다면 더욱 효율적인 개발이 가능하다는 것을 배웠습니다.
실제로 챗봇 서버 개발 시 구글 클라우드 인프라 연결 클라이언트, 임베딩 및 RAG 클라이언트 타임아웃 처리 등으로 성능 상 고민을 할 때 싱글톤 패턴 등을 도입 하였던 경험이 있었으며 프론트엔드 또한 스토리북을 통해 디자인 시스템을 구축할 때 불필요한 컴포넌트 사용 대신 팩토리 패턴을 통해 효과적으로 간결하게 컴포넌트를 관리할 수 있었습니다.
지금도 그렇지만 앞으로도 AI가 더 코드를 잘 짜줄 것이라 생각하고 개발자의 역할도 많이 달라질 것이라 생각합니다. 그럼에도 이제 막 커리어를 시작하는 주니어 입장에서는 늘 AI가 짜주는 코드를 그대로 받아들이는 것에 매몰되지 않도록 경계해야 한다고 생각합니다. 즉, 내가 이해하고 통제할 수 있어야 한다는 점입니다. 이는 개발뿐 아니라 커뮤니케이션에서도 중요할 것임을 느끼고 있습니다.
앞으로도 이러한 점들을 깊게 생각해 클린 코드를 작성하는 법과, CS 지식같은 기본기를 꾸준히 공부해야 함을 절실히 느꼈습니다. 특히 AI 백엔드를 담당하면서, 기본기가 부족하다는 생각을 많이 했습니다. 메모리 관리부터 인프라 및 네트워크 등 배워야 할 것들이 너무 많다는 생각이 들었습니다.
그렇지만 한 편으로는 여러 기술 스택 및 분야를 접하면서 스스로 성장하고 있다는 생각도 들어 앞으로 다양한 공부를 할 생각에 설레기도 합니다. 앞으로 어떤 개발자가 될 지는 모르겠지만 적어도 지금 이렇게 글을 남기고 공부하는 것이 든든한 주춧돌이 될 것이란 확신으로 꾸준히 공부하고 성장해나갈 예정입니다.