프론트엔드 개발을 하면서 가장 많이 듣는 단어 중 하나가 “디자인 패턴”이지만, 막상 설명하려고 하면 추상적인 개념처럼 느껴질 때가 많았습니다. 실제로 면접을 볼 때 디자인 패턴에 대한 질문을 자주 받았는데, 명확히 답하기 어려운 부분이 있어 정리해보게 되었습니다.
디자인 패턴을 공부하면서 느낀 점은 단순히 이론을 외우는 것이 아니라 “코드를 더 잘 이해하기 위한 언어”를 배우는 과정이라는 것입니다. 처음엔 이름과 구조를 외우는 데 집중했지만, 점점 ‘왜 이런 구조가 필요했을까?’라는 질문을 던지면서 패턴의 본질을 이해해야겠다는 생각을 하게 되었습니다.
앞으로 공부한 개념을 바탕으로 실제 프로젝트를 진행하면서 디자인 패턴을 적용하며 문제를 해결하기 위한 도구로 유연하게 선택할 수 있는 개발자가 되려고 합니다!
디자인 패턴은 복잡한 이론이 아니라 개발하면서 공통적으로 겪는 문제를 해결하기 위해 정립된 답안입니다. 프론트엔드 개발 환경, 특히 컴포넌트 기반 아키텍처에서는 자주 마주치는 문제들이 존재하며 이를 해결하기 위해 반복적으로 사용되는 패턴들이 있습니다.
이 글에서는 코드의 유지보수성, 확장성, 테스트 용이성을 높이기 위해 자주 사용되는 5가지 핵심 디자인 패턴을 정리해보겠습니다.
1. Facade Pattern (퍼사드 패턴)
복잡한 서브시스템에 대한 단일 인터페이스를 제공하는 패턴
문제 상황
소셜 로그인 처리나 상품 주문 프로세스처럼 하나의 기능을 실행하기 위해 여러 API를 순차적으로 호출하거나, 토큰 저장, 상태 갱신 등 복잡한 로직이 얽혀있는 경우가 많습니다. 컴포넌트가 이 모든 세부 과정을 알고 있다면, 해당 컴포넌트는 과도한 책임을 지게 되고 재사용이 불가능해집니다.
적용하기
따라서 이 복잡한 과정을 하나의 함수로 감싸 단순화된 진입점(Facade)을 제공하는 방법입니다. 컴포넌트는 복잡한 내부 로직을 몰라도 되고, loginWithKakao라는 단순한 API만 알면 된다는 '캡슐화'가 가능하고, 로그인 로직 중간에 추가 기능이 생겨도 authFacade.ts 파일만 수정하면 되며 Facade를 사용하는 모든 컴포넌트에서 수정하지 않아도 된다는 유지보수성 측면에서 장점이 있습니다.
// services/authFacade.ts
// 컴포넌트는 이 함수 뒤에서 일어나는 복잡한 일을 알 필요가 없다.
export async function loginWithKakao(code: string): Promise<User> {
// 1. 인가 코드로 백엔드에 토큰 요청
const token = await api.oauth.kakao(code);
// 2. 받은 토큰을 클라이언트에 저장 (e.g., httpOnly 쿠키)
api.setToken(token);
// 3. 사용자 정보 요청
const user = await api.me();
// 4. 전역 상태 업데이트
userStore.setUser(user);
return user;
}
// --- 사용 ---
// LoginPage.tsx
// 컴포넌트는 오직 'loginWithKakao'라는 단일 동작만 호출한다.
const handleLogin = (code) => {
await loginWithKakao(code);
router.push('/main');
}
2. Adapter Pattern (어댑터 패턴)
호환되지 않는 인터페이스를 변환하여 재사용하는 패턴
문제 상황
외부 API(백엔드)에서 받은 데이터 스키마와 프론트엔드 앱에서 사용하는 도메인 모델의 스키마가 일치하지 않습니다. API 응답을 그대로 앱 전체에서 사용하면, 백엔드 스키마가 변경될 때마다 프론트엔드 코드 전체를 수정해야 하는 문제가 발생합니다.
- API 응답: { "user_id": 1, "nickname": "dev", "profile_image": "url" }
- 프론트엔드 모델: { "id": 1, "name": "dev", "avatarUrl": "url" }
적용하기
API 응답(DTO: Data Transfer Object)을 프론트엔드 도메인 모델로 변환하는 '어댑터' 함수를 구현합니다. 해당 패턴을 사용했을 때 백엔드의 데이터 구조와 프론트엔드의 데이터 구조의 관심사를 분리할 수 있고, 백엔드 변수명이 변경되어도 toUser 어댑터 내부만 수정하면 되기 때문에 프론트엔드 비즈니스 로직은 전혀 영향을 받지 않는다는 장점이 있습니다.
// adapters/userAdapter.ts
interface ApiUserResponse {
user_id: number;
nickname: string;
profile_image?: string; // 옵셔널하거나 null일 수 있음
}
interface AppUser {
id: number;
name: string;
avatarUrl: string | null;
}
// API 응답을 App 모델로 변환하는 어댑터
export function toUser(dto: ApiUserResponse): AppUser {
return {
id: dto.user_id,
name: dto.nickname,
avatarUrl: dto.profile_image ?? null, // nullish 병합으로 안전하게 처리
};
}
// --- 사용 ---
// const { data } = useQuery('user', async () => {
// const responseDto = await api.getUser();
// return toUser(responseDto); // 데이터 수신 즉시 변환
// });
// 이제 앱 내에서는 data.id, data.name, data.avatarUrl을 일관되게 사용한다.
3. Strategy Pattern (전략 패턴)
알고리즘(전략)을 캡슐화하고 런타임에 교체하는 패턴
문제 상황
하나의 작업을 수행하는 방식이 여러 조건에 따라 달라질 때 if/else나 switch 문의 길이가 너무 길어진다.
적용하기
각각의 조건부 로직을 '전략'이라는 독립된 함수(or 객체)로 캡슐화하고 객체에 매핑한다. Context는 상황에 맞는 전략을 선택하여 실행만 한다. 아래 코드의 예시에서 새로운 등급이 새겨도 caculatePrice 함수를 수정할 필요 없이, discountStrategies 객체에 새로운 전략 함수만 추가하면 됩니다. 따라서 확장에 열려있고 수정에 닫혀있다는 개방-폐쇄 원칙(OCP)를 적용할 수 있습니다. 또한 if 지옥이 사라지고 각 전략을 개별적으로 테스트하기 용이해 가독성 및 테스트 측면에서도 장점이 있습니다.
// 😫 나쁜 예시
function calculateDiscount(price, userGrade) {
if (userGrade === 'VIP') {
return price * 0.8;
} else if (userGrade === 'GOLD') {
return price * 0.9;
} else if (userGrade === 'NEW') {
return price - 1000;
} else {
return price;
}
}
// 😉 좋은 예시
// 1. 전략 정의하기
const discountStrategies = {
DEFAULT: (price: number) => price,
VIP: (price: number) => price * 0.8,
GOLD: (price: number) => price * 0.9,
NEW: (price: number) => Math.max(0, price - 1000), // 0원 이하 방지
};
type UserGrade = keyof typeof discountStrategies;
// 2. 1번에서 정의한 전략을 실행하는 Context
export function calculatePrice(price: number, grade: UserGrade) {
// 등급에 맞는 전략을 찾아 실행, 없으면 DEFAULT 실행
const strategy = discountStrategies[grade] ?? discountStrategies.DEFAULT;
return strategy(price);
}
// --- 사용 ---
// calculatePrice(10000, 'VIP'); // 8000
// calculatePrice(10000, 'BRONZE'); // 10000 (DEFAULT)
4. Observer Pattern (옵저버 패턴)
객체의 상태 변화를 관찰하는 옵저버들에게 자동으로 통지하는 패턴
문제 상황
직접적인 부모-자식 관계가 아닌, 멀리 떨어진 두 컴포넌트가 상태를 공유하거나 이벤트를 전파해야 하는 상황입니다. (LoginPage에서 로그인 성공 시, Header 컴포넌트가 '로그인' 버튼을 '내 정보'로 변경해야 함)
적용하기
이벤트를 구독하고 발행하는 중앙 이벤트 시스템을 구현합니다. Header와 LoginPage는 서로의 존재를 몰라도 되며, 오직 pubsub 객체와 정의된 이벤트명만 알면 통신이 가능해 결합도를 감소시킬 수 있습니다. 또한 전역 상태 관리 라이브러리(Zustand, Redux)의 핵심적인 원리이며 WebSocket이나 SSE 수신 데이터를 앱 전역에 전파할 때도 유용하게 사용할 수 있는 패턴입니다.
// utils/pubsub.ts (간단한 구현)
type Handler = (data: any) => void;
const events = new Map<string, Set<Handler>>();
export const pubsub = {
// 이벤트 구독
on(event: string, handler: Handler) {
if (!events.has(event)) {
events.set(event, new Set());
}
events.get(event)!.add(handler);
},
// 이벤트 발행 (모든 구독자에게 알림)
emit(event: string, data: any) {
events.get(event)?.forEach((fn) => fn(data));
},
// 구독 취소 (메모리 누수 방지)
off(event: string, handler: Handler) {
events.get(event)?.delete(handler);
}
};
// --- 사용 ---
// Header.tsx
// useEffect(() => {
// const handleLogin = (user) => setUser(user);
// pubsub.on('auth:login', handleLogin); // 'auth:login' 이벤트 구독
// return () => pubsub.off('auth:login', handleLogin); // 컴포넌트 unmount 시 구독 취소
// }, []);
// LoginPage.tsx
// const onLoginSuccess = (user) => {
// // ...로그인 처리
// pubsub.emit('auth:login', user); // 'auth:login' 이벤트 발행
// };
5. Container–Presenter Pattern
로직과 뷰의 관심사를 분리하는 패턴
문제 상황
하나의 컴포넌트 파일 안에 데이터 페칭, 상태 관리, 이벤트 핸들러 등 로직과 JSX/HTML 뷰가 섞여있습니다. 컴포넌트가 수백 줄이 넘어가며 비대해지고, 테스트가 불가능하며, UI 재사용이 어려운 상황입니다.
적용하기
컴포넌트를 두 개의 역할로 명확히 분리합니다.
- Container (컨테이너): 데이터 페칭, 상태 관리, API 호출 등 모든 비즈니스 로직을 담당합니다.
- Presenter (프리젠터): props로 데이터를 전달받아 오직 UI 렌더링만 담당합니다.
아래 코드를 확인하면 PostView는 props만 받기 때문에 스냅샷 테스트에 용이합니다. PostContainer는 렌더링 로직이 없기 때문에 로직 자체에 집중해 테스트할 수 있다는 장점이 있습니다. 또한 PostView는 데이터만 주입하면 앱 내 어디서든 재사용할 수 있으며, 최근 React 코드에서는 커스텀 훅이 Container의 역할을 대체하기 때문에 자주 사용되는 방법입니다.
// 1. Container (로직 담당)
// PostContainer.tsx
export function PostContainer({ postId }: { postId: string }) {
// 데이터 페칭, 상태 관리, 이벤트 핸들러 등 '일'을 한다.
const { data: post, isLoading } = usePost(postId);
const [isBookmarked, setIsBookmarked] = useState(false);
const handleBookmarkClick = () => {
// ...API 호출 로직
setIsBookmarked((prev) => !prev);
};
// 계산된 데이터를 'props'로 내려주며 렌더링을 위임한다.
return (
<PostView
loading={isLoading}
post={post}
isBookmarked={isBookmarked}
onBookmark={handleBookmarkClick}
/>
);
}
// 2. Presenter (뷰 담당)
// PostView.tsx
export function PostView({ loading, post, isBookmarked, onBookmark }) {
// 'props'로 받은 데이터를 그리기만 한다.
if (loading) return <Spinner />;
if (!post) return <div>게시글이 없습니다.</div>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<button onClick={onBookmark}>
{isBookmarked ? '북마크됨' : '북마크하기'}
</button>
</article>
);
}
요약
디자인 패턴들은 코드를 더 깔끔하게 만드는 것을 넘어, 변경에 유연하고(Flexible), 재사용 가능하며(Reusable), 테스트하기 쉬운(Testable) 구조를 만드는 핵심적인 설계 원칙이기 때문에 상황에 따라 선택한다면 코드 개선에 도움이 될 것 같습니다.
| 패턴 | 목적 (Problem) | 핵심 이점 (Solution) |
| Facade | 복잡한 로직/API 호출을 감춤 | 단일 진입점으로 캡슐화, 결합도 감소 |
| Adapter | 외부-내부 스키마 불일치 | 외부 변경으로부터 내부 로직을 보호 (격리) |
| Strategy | if/else 조건 분기 과다 | 로직(전략)을 쉽게 교체 및 확장 (OCP) |
| Observer | 관계없는 컴포넌트 간 통신 | 1:N 이벤트 전파, 컴포넌트 간 결합도 제거 |
| Container–Presenter | 로직과 뷰의 혼재 | 관심사 분리(SoC), 재사용성 및 테스트 용이성 향상 |
'📚 CS > Basic' 카테고리의 다른 글
| 이벤트 버스 패턴(Event Bus Pattern) 알아보기 (0) | 2025.10.31 |
|---|---|
| [CS] CORS(Cross-Origin Resource Sharing)는 왜 필요할까요? (프록시 서버로 우회하기) (0) | 2025.10.11 |
| [CS] API와 아키텍처 (1) | 2025.08.26 |
| [CS] 컴퓨터 네트워크 기초 (2) | 2025.08.25 |