
원작은 “사기꾼이지만 괜찮아” 입니다.
리액트를 개발하다보면 선언적 프로그래밍에 대해 많이 듣게 됩니다.
“선언적이라서 가독성이 좋은 걸까요?”
“선언적이면 더 나은 추상화라고 보면 될까요?”
“절차적인 코드는 나쁜 코드일까요?”
물론 이런 글을 쓴다는 것은 위 질문들은 모두 정답이 아니라는 것입니다.
1. 선언적 vs 절차적 프로그래밍
프로그래밍 스타일을 크게 나누면 두 가지로 이야기할 수 있습니다.
- 절차적(Procedural): 원하는 결과를 얻기 위해 어떻게(how) 동작할지 구체적으로 작성하는 방식.
- 선언적(Declarative): 원하는 결과가 무엇(what) 인지만 선언하고, 내부 동작은 추상화에 맡기는 방식.
특징 비교
절차적 프로그래밍의 특징
- 명시적 제어: 코드의 실행 흐름을 개발자가 직접 제어할 수 있어 디버깅이 용이합니다.
- 성능 최적화: 필요한 부분에 정확한 최적화를 적용할 수 있습니다.
- 예외 처리: 복잡한 에러 상황을 세밀하게 처리할 수 있습니다.
- 단점: 코드가 길어지고, 비즈니스 로직과 구현 세부사항이 섞여 가독성이 떨어질 수 있습니다.
선언적 프로그래밍의 특징
- 간결성: 코드가 짧고 의도가 명확하게 드러납니다.
- 재사용성: 추상화된 함수들을 조합하여 다양한 로직을 만들 수 있습니다.
- 테스트 용이성: 작은 단위의 함수들을 독립적으로 테스트하기 쉽습니다.
- 단점: 내부 동작이 숨겨져 있어 성능 이슈나 예외 상황 처리가 어려울 수 있습니다.
추상화
선언적 프로그래밍은 결국 추상화(abstraction)의 힘을 빌립니다. 내부적으로는 여전히 절차적인 코드가 동작하지만, 그 과정을 숨겨 더 높은 수준의 표현력을 제공합니다.
예를 들어, React의 useState는 내부적으로 복잡한 상태 관리 로직을 추상화하여 개발자가 “상태를 선언한다”는 의도만 표현할 수 있게 해줍니다. 이는 단순히 코드를 숨기는 것이 아니라, 도메인 개념을 코드로 표현하는 것입니다.
2. 예제 - 사용자 목록에서 관리자만 필터링하기
실무에서 자주 마주치는 상황으로, 사용자 목록에서 관리자 권한을 가진 사용자만 화면에 표시하는 기능을 구현해봅시다.
절차적 방식
// /components/ProceduralUserList.tsx
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
role: 'admin' | 'user';
email: string;
}
const users: User[] = [
{ id: 1, name: '김철수', role: 'admin', email: 'kim@example.com' },
{ id: 2, name: '이영희', role: 'user', email: 'lee@example.com' },
{ id: 3, name: '박민수', role: 'admin', email: 'park@example.com' },
];
export default function ProceduralUserList() {
const [adminUsers, setAdminUsers] = useState<User[]>([]);
useEffect(() => {
const result: User[] = [];
// 1단계: 모든 사용자를 순회
for (let i = 0; i < users.length; i++) {
const user = users[i];
// 2단계: 관리자인지 확인
if (user.role === 'admin') {
result.push(user);
}
}
// 3단계: 결과를 상태에 저장
setAdminUsers(result);
}, []);
return (
<div>
<h2>관리자 목록</h2>
<ul>
{adminUsers.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
}
- 어떻게(How):
for루프로 순회 →if조건으로 필터링 →push로 결과 추가 →setState로 저장하는 단계별 과정을 명시적으로 작성했습니다.
선언적 방식
// /components/DeclarativeUserList.tsx
import { useState } from 'react';
interface User {
id: number;
name: string;
role: 'admin' | 'user';
email: string;
}
const users: User[] = [
{ id: 1, name: '탄지로', role: 'admin', email: 'tan@example.com' },
{ id: 2, name: '네즈코', role: 'user', email: 'nae@example.com' },
{ id: 3, name: '젠이츠', role: 'admin', email: 'zen@example.com' },
];
export default function DeclarativeUserList() {
const [showAdminsOnly, setShowAdminsOnly] = useState(true);
return (
<div>
<h2>사용자 목록</h2>
<label>
<input
type="checkbox"
checked={showAdminsOnly}
onChange={(e) => setShowAdminsOnly(e.target.checked)}
/>
관리자만 보기
</label>
<ul>
{users
// 1. 필터링: "관리자만 보기"가 체크되어 있으면 관리자만, 아니면 모든 사용자
.filter(user => !showAdminsOnly || user.role === 'admin')
// 2. 변환: 각 사용자 객체를 JSX 요소로 변환
.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))
}
</ul>
</div>
);
}
- 무엇(What): “관리자만 필터링해서 보여줘”라는 의도만 선언했습니다.
filter와map이 내부적으로 어떻게 동작하는지는 신경 쓰지 않습니다. - 체이닝: 함수들을 연결해서 데이터 변환 파이프라인을 만듭니다.
- 선언적 표현: “어떻게”가 아닌 “무엇을” 하고 싶은지만 코드에 드러냅니다.
3. 추상화(Abstraction) — 선언적 코드의 핵심
선언적 프로그래밍에서 가장 중요한 개념 중 하나는 추상화입니다. 추상화는 세부 구현(어떻게)을 숨기고, 도메인 관점에서 의미 있는 이름과 경계를 통해 무엇(관계/규칙) 만 드러내는 작업입니다. 선언적 코드는 단순히 map/filter 같은 함수 사용을 의미하는 것이 아니라, 그 함수들이 어떠한 추상화(비즈니스 의미)를 제공하는지가 핵심입니다.
추상화가 하는 일
- 의미 전달: 함수 이름만으로도 무엇을 하는지 읽을 수 있게 한다. (
calculateDiscountvsnum * (1 - d)) - 복잡성 은닉: 내부 절차(예외 처리, 최적화 등)를 숨겨 호출자는 관계에만 집중한다.
- 재사용성·테스트 용이성: 작은 단위의 추상화는 독립적으로 검증하고 재조합할 수 있다.
추상화 예시
아래는 위의 리스트 필터링 예제를 추상화 관점에서 다시 정리한 코드입니다. 각 함수는 무엇을 하는지를 이름으로 나타내고, 합성으로 전체 동작을 구성합니다.
// /lib/listPipeline.ts
export const filterByKeyword = (keyword: string) => (items: string[]) =>
items.filter(item => item.includes(keyword));
export const normalize = (items: string[]) =>
items.map(item => item.trim().toLowerCase());
export const toViewModel = (items: string[]) =>
items.map(item => ({ id: item, label: item }));
// 파이프 합성 (간단한 compose 구현)
export const compose = (...fns: Function[]) => (arg: any) =>
fns.reduceRight((v, f) => f(v), arg);
export const buildPipeline = (keyword: string) =>
compose(toViewModel, normalize, filterByKeyword(keyword));
// 사용
const pipeline = buildPipeline('app');
const result = pipeline(['apple', 'banana', 'pineapple']);
// result => [{id: 'apple', label: 'apple'}, {id: 'pineapple', label: 'pineapple'}]
위 코드에서 중요한 포인트는 filterByKeyword, normalize, toViewModel 같은 이름이 곧 비즈니스 관계를 설명한다는 점입니다. 호출부는 이 함수들을 어떻게 구현했는지 알 필요 없이 keyword -> viewModel이라는 관계만 이해하면 됩니다.
실무에서의 팁
- 추상화 이름에 도메인 용어를 사용하기. (예:
applyPromotion,isEligibleUser) - 한 함수는 한 가지 의미만 가지게 하가(단일 책임 원칙). 복합적 로직이면 더 작은 단위로 분해하기.
- 항상 추상화가 필요한 것은 아닙니다. 성능·디버깅·예외 처리가 중요한 곳에서는 절차적 구현이 더 적합할 수 있습니다.
3. 절차적이지만 괜찮아
실무에서는 선언적이 더 “깔끔해 보인다”라는 인식이 강합니다. 하지만 절차적인 코드가 나쁜 건 아닙니다.
절차적이 더 나은 상황들
1. 복잡한 예외 처리
// 선언적 방식
const processPayment = (amount: number, paymentMethod: string) => {
return paymentMethods
.find(method => method.type === paymentMethod)
?.process(amount)
.catch(error => {
// 에러 처리가 애매함 - 어떤 단계에서 실패했는지 모름
console.error('결제 실패');
throw error;
});
};
// 절차적 방식
const processPayment = async (amount: number, paymentMethod: string) => {
try {
// 1단계: 결제 수단 검증
const method = paymentMethods.find(m => m.type === paymentMethod);
if (!method) {
throw new Error('지원하지 않는 결제 수단입니다');
}
// 2단계: 금액 검증
if (amount <= 0) {
throw new Error('결제 금액이 올바르지 않습니다');
}
// 3단계: 결제 처리
const result = await method.process(amount);
// 4단계: 로그 기록
console.log(`결제 완료: ${amount}원, ${paymentMethod}`);
return result;
} catch (error) {
// 5단계: 에러 처리 및 복구
console.error('결제 실패:', error.message);
// 실패한 결제에 대한 롤백 처리
await rollbackPayment(amount, paymentMethod);
throw error;
}
};
2. 성능이 중요한 상황
// 선언적 방식
const ExpensiveComponent = ({ items }) => {
return (
<div>
{items
.filter(item => item.isActive) // 모든 아이템 순회
.map(item => item.name) // 다시 모든 아이템 순회
.filter(name => name.length > 3) // 또 다시 모든 아이템 순회
.map(name => <div key={name}>{name}</div>)
}
</div>
);
};
// 절차적 방식
const OptimizedComponent = ({ items }) => {
const processedItems = useMemo(() => {
const result = [];
// 한 번의 순회로 모든 작업 처리
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.isActive && item.name.length > 3) {
result.push(
<div key={item.id}>{item.name}</div>
);
}
}
return result;
}, [items]);
return <div>{processedItems}</div>;
};
복잡한 예외 처리를 해야 할 때는 오히려 선언적보다 절차적인 접근이 명확합니다
협업 시 팀원 모두가 한눈에 이해하기 쉬운 건 언제나 선언적 코드만은 아닙니다.
즉, 절차적이냐 선언적이냐는 상황에 맞게 선택하면 될 문제입니다.