선언적 프로그래밍에 대한 착각과 오해
문법이 아닌 사고방식의 전환이 만드는 진정한 선언적 코드

필자는 평소 기술 인터뷰를 진행하며 지원자 분들이 과제를 작성하면서 내렸던 의사결정에 대한 근거를 물어보는 경우가 잦다.
이때 이에 대한 근거로 “이런 방식이 보다 선언적이기 때문이다”라는 답변을 많이 해주시는데, 정작 그 방식이 왜 선언적인 것인지, 선언적인 코드란 무엇인지 여쭤보면 시원한 답변을 해주시는 경우는 많지 않았던 것 같다.
그래서 이번 포스팅에서는 필자가 생각하는 선언적이라는 것이 무엇인지, 그리고 선언적인 코드란 무엇인지에 대해서 한번 간략하게 이야기해보려고 한다.
많은 개발자들이 스스로 선언적인 코드를 작성한다고 생각하지만 실제로는 본질을 놓치고 있는 경우가 종종 있으며, 단지 특정한 도구를 사용하거나 문법을 사용하는 것이 선언적인 것이라고 착각하고는 한다.
하지만 필자가 생각하는 선언적 프로그래밍은 도구의 문제가 아니라 사고방식의 근본적 전환이다.
선언적 프로그래밍에 대한 가장 흔한 착각
많은 개발자들이 빠지는 첫 번째 함정은 “절차적인 동작을 함수로 추상화하면 선언적”이라는 착각이다. 하지만 함수를 사용한다고 해서 무조건 선언적인 프로그래밍을 구사하는 것은 아니다.
가장 간단한 예부터 살펴보자. 사용자 정보를 가져오는 함수를 절차적으로 작성하면 이렇게 된다.
function getUserInfo(userId) {
const connection = connectDB();
const userRow = connection.query('SELECT * FROM users WHERE id = ?', userId);
const permissionRows = connection.query('SELECT * FROM permissions WHERE user_id = ?', userId);
const user = {
id: userRow.id,
name: userRow.name,
email: userRow.email,
permissions: permissionRows.map(row => row.permission_name)
};
if (user.name) {
user.displayName = user.name.toUpperCase();
}
return user;
}
위 코드는 함수를 사용하고는 있지만 “먼저 DB에서 사용자를 가져오고, 그 다음 권한을 추가하고, 마지막에 포맷팅한다”는 시간적 순서에 집중하고 있다. 즉, 함수를 사용했다고 해서 절차적 사고에서 벗어난 것이 아니다.
선언적인 코드는 동작의 시간적 순서에서 벗어나 동작 간의 관계를 기술하는 것에 집중해야한다.
// 선언적 접근 - 데이터 변환 관계에 집중
const getUserInfo = (userId) =>
pipe(
fetchUserFromDB,
addUserPermissions,
formatUserData
)(userId);
// 또는 더 명확하게
const getUserInfo = (userId) =>
formatUserData(
addUserPermissions(
fetchUserFromDB(userId)
)
);
이 코드는 “사용자 ID → 포맷된 사용자 정보”라는 관계를 선언한다. 실행 순서가 아니라 데이터 변환의 관계에 집중하고 있다.
여기서 핵심 구분 기준은 명확하다. 절차적 코드는 “어떻게(How) 단계별로 실행할 것인가”에 집중하고, 선언적 코드는 “무엇을(What) 원하는 관계인가”에 집중한다.
이런 착각은 특히 배열 메소드를 사용할 때 자주 발생한다. map
, filter
, reduce
같은 메소드를 쓰면 자동으로 선언적 코드가 된다고 생각하는 경우가 많다.
하지만 마찬가지로 배열 메소드를 사용하더라도 얼마든지 절차적인 사고가 가능하다.
// 배열 메소드를 사용했지만 절차적 사고
function processItems(items) {
return items
.map(item => {
let price = item.basePrice;
if (item.discount) {
price = price * (1 - item.discount);
}
price = price * 1.1;
return { ...item, finalPrice: Math.round(price * 100) / 100 };
})
.filter(item => item.finalPrice > 0);
}
반면 선언적인 접근은 각각의 변환이 나타내는 비즈니스 관계에만 집중하여 기술한다는 차이점이 있다.
// 진정한 선언적 접근
const processItems = (items) =>
items
.map(applyDiscount)
.map(addTax)
.map(formatPrice)
.filter(hasValidPrice);
const applyDiscount = (item) => ({
...item,
price: item.basePrice * (1 - (item.discount || 0))
});
const addTax = (item) => ({
...item,
price: item.price * 1.1
});
const formatPrice = (item) => ({
...item,
finalPrice: Math.round(item.price * 100) / 100
});
const hasValidPrice = (item) => item.finalPrice > 0;
첫 번째 코드는 배열 메소드를 사용했지만 여전히 단계별 처리 과정에 집중하고 있지만, 두 번째 코드는 각각의 변환이 나타내는 비즈니스 관계를 명확히 선언하고 있다.
중요한 것은 그 함수가 무엇을 하는지를 명확하게 표현하는 올바른 추상화까지 곁들여야 한다는 것이다.
결국 함수 자체는 중립적인 도구다. 절차적 사고를 함수로 포장할 수도 있고, 관계적 사고를 함수로 표현할 수도 있다. 선언적 프로그래밍의 핵심은 함수 사용 여부가 아니라, 그 함수가 어떤 추상화를 제공하느냐에 따라 달라진다.
대략적인 예시만으로는 이해가 쉽지 않을 수 있으니 이제 본질적인 부분을 살펴보도록 하자.
수학적 관점에서 본 선언적 프로그래밍
선언적 프로그래밍을 제대로 이해하려면 먼저 “선언”이라는 개념 자체를 이해해야 한다. 이를 가장 쉽게 이해할 수 있는 방법은 일상의 예시부터 시작하는 것이다.
우리가 요리 레시피를 생각해보자. 절차적 접근은 레시피와 같다. “물 2컵을 끓인다, 면을 넣고 3분간 끓인다, 스프를 넣고 1분간 더 끓인다, 그릇에 담는다”라는 시간의 흐름에 따른 행동 지침이다.
반면 선언적 접근은 관계를 표현한다. “라면 = 삶은 면 + 스프 + 뜨거운 물의 조합”이라는 재료들 사이의 본질적 관계를 나타낸다.
레시피는 시간의 흐름에 따른 행동 지침이고, 관계적 선언은 재료들 사이의 본질적 관계를 나타낸다. 이 차이가 바로 절차적 사고와 선언적 사고의 본질적 차이다.
수학에서 더 명확한 예를 살펴보자. 우리가 중학교 때 배운 일차함수 을 생각해보자. 이 수식은 컴퓨터에게 ”에 2를 곱하고 1을 더하라”는 명령이 아니다. 이는 ”와 사이에는 이런 관계가 있다”는 사실을 선언한 것이다.
이 선언은 관계에 대한 기술이기 때문에 시간과 무관한 영원한 진리다. 언제 어디서나 가 3이라면 는 7이라는 것이다. 여기서 계산 과정이나 실행 순서는 중요하지 않다. 중요한 것은 와 의 관계 그 자체다.
수학자들이 함수를 정의할 때 주목하는 것은 바로 이런 불변적 관계다. 함수는 계산 알고리즘이 아니라 구조 간의 대응 관계를 나타내는 것이다. 우리가 삼각함수 를 정의할 때, 우리는 단위원에서 각도와 좌표 사이의 관계를 선언하는 것이지, 어떻게 계산할지를 지시하는 것이 아닌 것과 마찬가지이다.

프로그래밍에서도 마찬가지다. 선언적 코드는 컴퓨터에게 어떤 일을 할지 주문하는 것이 아니라 “이 문제의 본질은 이런 관계에 있다”는 것을 표현한다. 이런 관점에서 보면, 프로그래밍은 계산 과정을 지시하는 것이 아니라 문제 도메인의 수학적 구조를 발견하고 표현하는 행위가 된다.
절차적 vs 선언적: 사고방식의 차이
이제 같은 기능을 두 가지 사고방식으로 접근한 구체적 예시를 통해 차이점을 명확히 해보자. 절차적 사고는 시간의 흐름 속에서 일어나는 변화에 집중한다. “먼저 이것을 하고, 그 다음에 저것을 하고…”라는 순차적 실행 모델이다.
// 절차적 프로그래밍 - 어떻게(How) 할 것인가에 집중
function calculateTotalPrice(items) {
let total = 0;
// 단계 1: 각 아이템을 순회
for (let i = 0; i < items.length; i++) {
const item = items[i];
// 단계 2: 유효성 검사
if (item.quantity > 0) {
// 단계 3: 기본 가격 계산
let itemPrice = item.price * item.quantity;
// 단계 4: 할인 적용
if (item.discount) {
itemPrice = itemPrice - (itemPrice * item.discount);
}
// 단계 5: 총합에 누적
total = total + itemPrice;
}
}
return total;
}
이 코드는 시간적 순서와 상태 변화에 집중한다. 각 단계에서 무엇이 일어나는지, 변수가 어떻게 변하는지를 추적해야 한다.
반면 선언적 사고는 시간을 초월한 논리적 관계에 집중한다. “총 가격 = 유효한 아이템들의 할인된 가격들의 합”이라는 관계를 선언한다.
// 선언적 프로그래밍 - 무엇을(What) 원하는가에 집중
const calculateTotalPrice = (items) =>
items
.filter(hasValidQuantity)
.map(calculateItemPrice)
.reduce(sum, 0);
const hasValidQuantity = (item) => item.quantity > 0;
const calculateItemPrice = (item) =>
item.price * item.quantity * (1 - (item.discount || 0));
const sum = (a, b) => a + b;
이 코드는 데이터 변환의 관계를 선언한다. 각 함수는 특정한 변환 관계를 나타내고, 이들의 합성으로 전체 문제를 해결한다.
프로그래밍에서 이 차이는 코드가 표현하는 추상화의 종류로 드러난다. 절차적 코드는 컴퓨터의 실행 과정을 추상화한다. 메모리 할당, 루프 실행, 조건 분기 같은 기계적 연산을 변수와 제어 구조로 포장한다. 선언적 코드는 문제 도메인의 논리적 구조를 추상화한다. 비즈니스 규칙, 데이터 관계, 상태 변환 같은 개념적 관계를 함수와 타입으로 표현한다.
그렇다면 왜 이런 관계적 사고가 중요할까? 근본적인 이유는 인간의 인지적 한계 때문이다. 심리학자 조지 밀러(George Miller)의 연구에 따르면, 인간의 단기 기억은 동시에 7±2개의 정보 단위만 처리할 수 있다고 한다.
그러나 절차적 사고에서는 시간의 흐름에 따른 상태 변화를 모두 추적해야 하며 프로그램이 복잡해질수록 이런 상태들의 조합이 기하급수적으로 증가한다. 10개의 변수가 있다면 가지의 가능한 상태가 있고, 각 단계에서 이 모든 경우의 수를 고려해야 한다는 의미이다. 인간의 기억력으로 이 모든 변화를 추적하는 것은 불가능에 가깝다.
반면 관계적 사고에서는 불변적 관계의 합성으로 복잡성을 관리한다. 수학에서 복잡한 함수를 간단한 함수들의 합성으로 표현하는 것과 같은 원리다. 에서 를 이해하기 위해 와 의 내부 구현을 모두 알 필요가 없고 각각의 입출력 관계만 이해하면 되는 것처럼 말이다.
이러한 관계적 사고로 인해 복잡한 상태들을 몇 개의 청크로 나누어 기억할 수 있게 되고, 이는 우리가 복잡한 코드를 이해하는 데 큰 도움을 준다.
JSX는 왜 선언적일까?
이제 평소에 우리가 자주 접하는 대표적인 선언적 도구인 JSX를 예시로 한번 살펴보자. 앞서 제기한 “왜 특정 코드가 선언적인가?”라는 질문에 대해 JSX를 통해 한번 알아보려고 한다.
필자가 인터뷰에서 자주 보는 광경 중 하나는 바로 “선언적인 코드란 무엇인가?”라는 질문에 “JSX나 React 같은 도구를 사용하는 것”이라고 답하는 것이다. 이때 “그럼 JSX는 왜 선언적인가요?”라고 물어보면 “JSX를 사용하면 선언적인 코드를 작성할 수 있어서요”라고 답변하는, 전형적인 순환논법에 빠지는 경우가 많았다.
JSX가 선언적인 진짜 이유는 구조적 관계를 표현하는 방식에 있다. 두 가지 접근법을 비교해보자.
function createUserProfile(user) {
const container = document.createElement('div');
container.className = 'user-profile';
const nameElement = document.createElement('h2');
nameElement.textContent = user.name;
container.appendChild(nameElement);
const emailElement = document.createElement('p');
emailElement.textContent = user.email;
container.appendChild(emailElement);
if (user.avatar) {
const avatarElement = document.createElement('img');
avatarElement.src = user.avatar;
avatarElement.alt = `${user.name}의 아바타`;
container.appendChild(avatarElement);
}
return container;
}
function UserProfile({ user }) {
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
{user.avatar && (
<img src={user.avatar} alt={`${user.name}의 아바타`} />
)}
</div>
);
}
첫 번째 코드는 DOM 요소를 생성하고 조작하는 순서에 집중한다. “먼저 컨테이너를 만들고, 그 다음 이름 요소를 만들어서 추가하고, 이메일 요소를 만들어서 추가하고…” 라는 시간적 순서가 중요하다. 심지어 각 appendChild
호출의 순서를 바꾸면 결과도 달라진다.
반면 JSX로 작성된 두 번째 코드는 “UserProfile은 이름, 이메일, 그리고 선택적으로 아바타로 구성된 구조다”라는 관계를 선언하는 것에만 집중한다. 여기서는 요소들 사이의 포함 관계와 계층 구조가 중요하며 시간적 순서는 전혀 중요하지 않다.
데이터와 UI의 대응 관계
JSX의 진짜 힘은 데이터 구조와 UI 구조 사이의 대응 관계를 직관적으로 표현할 수 있다는 점에 있다.
const TodoList = ({ todos }) => (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
text={todo.text}
completed={todo.completed}
/>
))}
</ul>
);
이 코드를 보면 todos
배열의 각 항목이 TodoItem
컴포넌트와 대응된다는 관계가 즉시 읽힌다. 데이터의 구조가 곧 UI의 구조가 되는 것이다.
같은 동작을 명령형으로 작성하면 이렇게 된다.
function createTodoList(todos) {
const ul = document.createElement('ul');
for (let i = 0; i < todos.length; i++) {
const li = document.createElement('li');
li.textContent = todos[i].text;
if (todos[i].completed) {
li.classList.add('completed');
}
ul.appendChild(li);
}
return ul;
}
명령형 코드에서는 데이터 순회, 요소 생성, 속성 설정, DOM 추가 등의 절차적 단계들이 섞여 있다. 데이터의 구조와 최종 UI의 구조 사이의 관계를 파악하려면 전체 코드를 읽고 머릿속으로 실행해봐야 한다.
구조적 관계의 표현
JSX는 수학에서 집합의 관계를 표현하는 것과 비슷하다. 수학에서 라고 쓸 때, 우리는 집합 가 원소 로 구성된다는 관계를 선언한다. 이 관계는 원소들을 어떤 순서로 집합에 넣었는지와는 무관하다.
JSX도 마찬가지다. 아래와 같은 코드를 쓸 때, 우리는 ”UserCard
는 Avatar
와 UserInfo
로 구성된다”는 구조적 관계를 선언한다. 이 관계는 컴포넌트들이 어떤 순서로 렌더링되는지와는 개념적으로 분리되어 있다.
// 구조적 관계의 선언
const UserCard = ({ user }) => (
<div className="user-card">
<Avatar src={user.avatar} />
<UserInfo name={user.name} email={user.email} />
</div>
);
즉, “사용자 카드는 아바타, 사용자 정보의 조합이다”라는 관계를 선언하는 것에만 집중하고 각 컴포넌트가 어떻게 구현되는지, 어떤 순서로 DOM에 추가되는지는 이 관계 선언과는 별개의 관심사다.
이런 관점에서 보면, JSX가 선언적인 이유는 복잡한 과정을 숨겨주는 것 뿐 아니라, 데이터와 UI 사이의 본질적 관계를 직접적으로 표현할 수 있게 해주기 때문이다.
JSX가 선언적인 이유를 이해했다면, 이제 한 가지 의문이 들 수 있다.
“그런데 JSX도 결국
createElement
로 변환되고, React의 reconciliation도 절차적 코드 아닌가? 그럼 이게 정말 선언적인 건가?”
선언적과 절차적은 상대적이다
이는 추상화나 선언적인 프로그래밍을 이해할 때 매우 핵심적인 부분인데, 이 개념들을 절대적인 무언가로 이해하면 치명적인 오개념이 생길 수 있다.
실제로는 같은 코드라도 관찰하는 관점과 추상화 레벨에 따라 선언적일 수도, 절차적일 수도 있다. “선언적”과 “절차적”은 절대적인 구분이 아니라 상대적인 개념이다.
React Query를 사용한 예시로 이런 상대성을 살펴보자.
// 애플리케이션 레벨 - 선언적
function useUserData(userId) {
return useQuery(['user', userId], () => fetchUser(userId));
}
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useUserData(userId);
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러가 발생했습니다</div>;
return <div>{user.name}</div>;
}
애플리케이션 개발자의 관점에서 보면 이 코드는 완전히 선언적이다. ”userId
에 해당하는 사용자 데이터를 가져오는 쿼리”라는 관계를 선언하고 있다. 캐싱, 재시도, 에러 처리 같은 복잡한 로직은 모두 추상화되어 있다.
하지만 useQuery
의 핵심 로직인 QueryClient를 구현하는 코드는 절차적이다. 캐시 확인, 네트워크 요청, 상태 업데이트, 재시도 로직 등 복잡한 절차적 과정들이 모두 드러나 있다.
이런 상대성은 도메인 경계에서 특히 명확하게 드러난다. 비즈니스 로직 레벨에서 주문을 처리하는 함수를 보면 이렇다.
// 비즈니스 로직 레벨 - 선언적
async function processOrder(order) {
const validatedOrder = await validateOrder(order);
const payment = await processPayment(validatedOrder);
const shipment = await scheduleShipment(validatedOrder);
return createOrderConfirmation(validatedOrder, payment, shipment);
}
이 함수는 비즈니스 로직 관점에서는 선언적이다. “주문을 검증하고, 결제를 처리하고, 배송을 스케줄링한다”는 관계를 명확히 표현한다. 하지만 그 아래 결제 처리 같은 인프라 레벨 함수들은 절차적이다.
// 인프라 레벨 - 절차적
async function processPayment(order) {
const paymentGateway = new PaymentGateway(process.env.PAYMENT_API_KEY);
try {
const paymentRequest = {
amount: order.total,
currency: order.currency,
source: order.paymentMethod
};
const response = await paymentGateway.charge(paymentRequest);
if (response.status === 'succeeded') {
await database.payments.create({
orderId: order.id,
paymentId: response.id,
amount: response.amount,
status: 'completed'
});
return { success: true, paymentId: response.id };
} else {
throw new PaymentError(response.error);
}
} catch (error) {
await logger.error('Payment processing failed', { orderId: order.id, error });
throw error;
}
}
이처럼 선언적이라는 개념은 어떤 계층에서 바라보냐에 따라 상대적일 수 밖에 없다. 결국 선언적인 코드의 포인트는 절차적으로 작성된 부분을 얼마나 덜 확인할 수 있도록 추상화된 부분을 잘 표현해주는 것이냐에 따라 달려있다.
또한 “절차적 코드는 나쁘고 무조건 피해야 할 것”이라는 편견도 흔히 볼 수 있는데, 이 역시 잘못된 생각이다. 가장 우아한 함수형 코드도 결국 CPU의 절차적 명령어로 실행되기 때문이다.
중요한 것은 적절한 레벨에서 적절한 추상화를 제공하는 것이다.
이런 상대성을 이해하면 각 레벨에서 적절한 추상화를 찾는 것이 중요하다는 것을 알 수 있다. 모든 것을 선언적으로 만들려고 할 필요는 없다. 비즈니스 로직 레벨에서는 선언적으로 접근하는 것이 좋다. 도메인의 본질적 관계를 표현하는 데 집중해야 하기 때문이다. 반면 인프라 레벨에서는 절차적이어도 괜찮다. 효율적이고 안전한 구현이 더 중요하기 때문이다.
핵심은 경계를 명확히 하고 추상화의 레벨을 맞춰주는 것이다. 어느 레벨에서 어떤 관심사를 다루는지 분명히 구분해야 한다. 좋은 예를 보면, 비즈니스 레벨에서는 선언적으로 쇼핑카트의 구조를 표현하고, 인프라 레벨에서는 절차적인 계산 로직을 사용해도 문제없다.
// 좋은 예: 명확한 레벨 분리
// 비즈니스 레벨 - 선언적
const ShoppingCart = ({ items, onCheckout }) => (
<div className="shopping-cart">
<ItemList items={items} />
<TotalPrice items={items} />
<CheckoutButton onClick={() => onCheckout(items)} />
</div>
);
// 프레젠테이션 레벨 - 선언적
const ItemList = ({ items }) => (
<ul className="item-list">
{items.map(item => (
<ItemCard key={item.id} item={item} />
))}
</ul>
);
// 인프라 레벨 - 절차적이어도 OK
function calculateTotalWithTax(items, taxRate) {
let subtotal = 0;
for (const item of items) {
subtotal += item.price * item.quantity;
}
const tax = subtotal * taxRate;
return subtotal + tax;
}
이런 관점에서 보면, 선언적 프로그래밍은 “모든 코드를 선언적으로 만드는 것”이 아니라 “적절한 추상화 레벨에서 적절한 표현력을 갖는 것”이라고 할 수 있다.
선언적인 코드라고 착각하는 코드 vs 진짜 선언적인 코드
이제 앞서 제기한 핵심 질문인 “왜 특정 코드가 선언적인가?”에 구체적으로 답해보자. 선언적 코드의 핵심은 “가능한 상태들 간의 관계”를 명확히 정의하는 것이다.
수학에서 함수의 정의역을 명확히 하는 것처럼, 프로그램에서도 “어떤 상태 조합이 논리적으로 가능한가”를 먼저 선언해야 한다.
비동기 데이터 요청을 생각해보면, 실제로는 아직 시작하지 않음(idle), 요청 중(loading), 성공(데이터 있음), 실패(에러 있음) 같은 상태들만 논리적으로 가능하다. “로딩 중이면서 동시에 데이터도 있고 에러도 있는” 상태는 현실에서는 일어날 수 없는 모순된 조합이다.
React + TypeScript 환경에서 비동기 데이터를 다루는 상황을 통해 이런 선언적 사고의 본질을 구체적으로 살펴보자.
착각하는 코드 - 상태 변화 과정에 집중
절차적 접근으로 작성된 비동기 데이터 훅을 보면 상태가 어떻게 변하는지에 집중하고 있다.
// 절차적 접근 - 상태가 어떻게 변하는지에 집중
function useAsyncData<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 1단계: 로딩 상태로 전환
setLoading(true);
setError(null);
setData(null);
fetcher()
.then(result => {
// 2단계: 성공 상태로 전환
setData(result);
setLoading(false);
})
.catch(err => {
// 3단계: 에러 상태로 전환
setError(err.message);
setLoading(false);
setData(null);
});
}, [fetcher]);
return { data, loading, error };
}
선언적인 코드 - 상태 관계에 집중
선언적 접근으로 작성하면 가능한 상태가 무엇인지에 집중한다.
// 선언적 접근 - 가능한 상태가 무엇인지에 집중
type AsyncDataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function useAsyncData<T>(fetcher: () => Promise<T>): AsyncDataState<T> {
const [state, setState] = useState<AsyncDataState<T>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
fetcher()
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error: error.message }));
}, [fetcher]);
return state;
}
왜 첫 번째 코드가 절차적인가?
첫 번째 코드는 상태 변화의 순서와 과정에 집중한다. “먼저 로딩을 true
로 설정하고, 에러를 null
로 초기화하고, 데이터를 null
로 리셋하고…” 같은 절차적 명령의 나열이다.
더 중요한 문제는 불가능한 상태 조합이 타입 수준에서 허용된다는 점이다. TypeScript가 { data: someUserData, loading: true, error: "Network Error" }
라는 말이 안 되는 상태를 막아주지 못한다. 데이터가 있으면서 동시에 로딩 중이고 에러도 있다는 것은 논리적으로 모순이다. 하지만 첫 번째 방식에서는 개발자가 모든 상태 조합의 유효성을 수동으로 보장해야 한다.
또한 { data: null, loading: false, error: null }
나 { data: someUserData, loading: false, error: "Server Error" }
같은 논리적으로 불가능한 상태 조합들이 타입 시스템에서 허용된다. 이런 상태들은 현실에서 일어날 수 없는 조합이지만, 타입 시스템이 이를 막아주지 못한다.
각 setState
호출마다 다른 상태들을 올바르게 초기화했는지 일일이 확인해야 하고, 이는 휴먼 에러로 이어지기 쉽다. 예를 들어 성공 시에 setLoading(false)
를 깜빡하거나, 에러 시에 setData(null)
을 빼먹을 수 있다.
두 번째 코드는 왜 선언적인가?
두 번째 코드는 “비동기 데이터 요청”이라는 도메인의 본질적 상태 관계를 선언한다. Union Type을 통해 “이 네 가지 상태 중 정확히 하나만 존재할 수 있다”는 불변적 관계를 타입 수준에서 표현한다.
여기서 핵심은 불가능한 상태를 원천적으로 차단한다는 점이다. TypeScript 컴파일러가 로딩 중이면서 동시에 성공 상태 같은 모순된 조합을 컴파일 타임에 방지해준다. 이는 수학에서 집합의 정의역을 명확히 하는 것과 같은 원리다. 에서 는 함수 의 정의역이고, 에 속하지 않는 값에 대해서는 가 정의되지 않는다.
Union Type이 제공하는 안전장치를 더 구체적으로 살펴보면, TypeScript가 컴파일 타임에 강제하는 안전성을 확인할 수 있다.
// TypeScript가 컴파일 타임에 강제하는 안전성
function handleAsyncState<T>(state: AsyncDataState<T>) {
switch (state.status) {
case 'idle':
return "아직 시작하지 않음";
case 'loading':
return "로딩 중...";
// 여기서 state.data나 state.error 접근 시 컴파일 에러
case 'success':
return `데이터: ${state.data}`;
// 여기서는 state.data가 확실히 존재
// state.error 접근 시 컴파일 에러
case 'error':
return `에러: ${state.error}`;
// 여기서는 state.error가 확실히 존재
// state.data 접근 시 컴파일 에러
default:
// TypeScript가 모든 case를 다뤘는지 검증
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
각 case에서는 해당 상태에서만 유효한 속성에만 접근할 수 있고, 다른 속성에 접근하려고 하면 컴파일 에러가 발생한다. 또한 default
케이스의 never
타입을 통해 모든 경우를 다뤘는지 컴파일러가 검증해준다.
컴포넌트에서 사용할 때의 차이
첫 번째 방식을 사용하면 개발자가 모든 조합을 수동으로 처리해야 한다.
// 첫 번째 방식 - 모든 조합을 수동으로 처리
function DataDisplay<T>({ fetcher, render }: {
fetcher: () => Promise<T>;
render: (data: T) => React.ReactNode;
}) {
const { data, loading, error } = useAsyncData(fetcher);
// 개발자가 모든 경우의 수를 수동으로 고려해야 함
if (loading && !error && !data) return <LoadingSpinner />;
if (error && !loading) return <ErrorMessage error={error} />;
if (data && !loading && !error) return render(data);
// 예상치 못한 상태 조합들은 어떻게 처리할까?
// { loading: true, data: someData, error: null } 같은 경우는?
return <div>알 수 없는 상태</div>;
}
반면 두 번째 방식을 사용하면 타입이 모든 경우를 보장한다.
// 두 번째 방식 - 타입이 모든 경우를 보장
function DataDisplay<T>({ fetcher, render }: {
fetcher: () => Promise<T>;
render: (data: T) => React.ReactNode;
}) {
const state = useAsyncData(fetcher);
// TypeScript가 모든 case를 처리했는지 검증
switch (state.status) {
case 'idle':
return <div>준비 중...</div>;
case 'loading':
return <LoadingSpinner />;
case 'success':
// state.data가 확실히 T 타입으로 존재
return render(state.data);
case 'error':
// state.error가 확실히 존재
return <ErrorMessage error={state.error} />;
// TypeScript가 모든 case를 다뤘는지 컴파일 타임에 검증
// 새로운 상태가 추가되면 컴파일 에러로 알려줌
}
}
TypeScript가 모든 case를 처리했는지 검증하고, 각 case에서 해당 속성이 확실히 존재함을 보장한다. 새로운 상태가 추가되면 컴파일 에러를 통해 놓친 부분을 알려준다.
확장성 측면에서의 차이
새로운 상태를 추가해야 하는 상황을 생각해보자. 첫 번째 방식에서는 여러 변수를 모두 수정해야 한다.
// 첫 번째 방식 - 여러 변수를 모두 수정해야 함
const [retrying, setRetrying] = useState(false);
const [stale, setStale] = useState(false);
// 각 상태 변화마다 다른 모든 상태들과의 조합을 고려해야 함
// 16가지 boolean 조합 중 논리적으로 말이 되는 것은 몇 개일까?
반면 두 번째 방식에서는 타입 정의만 확장하면 된다.
// 두 번째 방식 - 타입 정의만 확장
type AsyncDataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'retrying'; previousError: string; attempt: number }
| { status: 'success'; data: T; stale?: boolean }
| { status: 'error'; error: string };
// TypeScript가 모든 사용처에서 새로운 case 처리를 강제
// 컴파일 에러를 통해 놓친 부분을 알려줌
새로운 상태를 추가하면 TypeScript가 모든 사용처에서 새로운 case를 처리하도록 강제한다. 컴파일 에러를 통해 놓친 부분을 확실히 알려주기 때문에 안전하게 확장할 수 있다.
이 예시에서 보는 선언적 사고의 핵심은 “무엇이 가능한가”를 먼저 정의하는 것이다. 수학에서 함수의 정의역과 치역을 명확히 하는 것처럼, 비동기 요청의 가능한 상태들을 명시적으로 선언한다.
절차적 접근은 “어떻게 상태를 변경할 것인가”에 집중하지만, 선언적 접근은 “이 문제 도메인에서 어떤 상태들이 논리적으로 가능한가”를 먼저 선언한다. 이런 관점 전환이 바로 선언적 프로그래밍의 본질이다.
실무 적용 가이드라인
이제 앞서 살펴본 이론을 실무에 적용할 수 있는 구체적인 기준을 정리해보자. 언제 절차적 접근을 택하고 언제 선언적 접근을 택할 것인가에 대한 실용적 가이드라인이다.
언제 선언적 접근을 해야 하는가?
비즈니스 로직과 상태 관리에서는 선언적 접근이 유리하다.
앞서 다룬 비동기 데이터 상태 예시처럼, 복잡한 상태 조합이 가능한 경우에는 타입 수준에서 불가능한 상태를 차단하는 것이 중요하다:
// 앞서 다룬 AsyncDataState 예시 재활용
type AsyncDataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
UI 구조 표현에서도 선언적 접근이 자연스럽다. JSX 예시에서 봤듯이 데이터와 UI 사이의 구조적 관계를 직접 표현할 수 있다:
// JSX 예시 재활용 - 구조적 관계를 명확히 선언
const UserProfile = ({ user }) => (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
{user.avatar && <img src={user.avatar} alt={`${user.name}의 아바타`} />}
</div>
);
데이터 변환 파이프라인에서도 각 단계가 독립적으로 정의되고 테스트 가능할 때 선언적 접근이 효과적이다:
// 변환 관계를 명확히 선언
const processUserData = (rawUsers: RawUser[]) =>
rawUsers
.filter(isActiveUser)
.map(normalizeUserData)
.map(addComputedFields)
.sort(byLastLoginDate);
언제 절차적 접근을 해도 되는가?
인프라 레벨과 성능 최적화에서는 절차적 접근이 적절하다.
앞서 상대성 섹션에서 다룬 계산 로직처럼, 효율성과 성능이 중요한 경우에는 절차적 구현이 자연스럽다:
// 앞서 다룬 계산 로직 예시 재활용
function calculateTotalWithTax(items, taxRate) {
let subtotal = 0;
for (const item of items) {
subtotal += item.price * item.quantity;
}
const tax = subtotal * taxRate;
return subtotal + tax;
}
React Query 내부 구현처럼 복잡한 상태 관리와 최적화가 필요한 라이브러리 코드에서도 절차적 접근이 필요하다. 캐시 확인, 네트워크 요청, 상태 업데이트, 재시도 로직 등은 절차적으로 구현하는 것이 효율적이다.
에러 처리가 복잡한 시스템 레벨 코드에서도 절차적 접근이 적합하다:
// 복잡한 에러 처리와 재시도 로직
async function retryWithBackoff<T>(
operation: () => Promise<T>,
maxAttempts: number = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw lastError;
}
const delay = Math.pow(2, attempt - 1) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError!;
}
마치며
선언적 프로그래밍은 단순히 특정 문법이나 도구를 사용하는 것이 아니다. 그것은 “어떻게”에서 “무엇”으로, 절차에서 관계로 사고하는 방식의 전환이다.
이번 포스팅에서 다룬 핵심 내용을 정리해보면, 선언적 프로그래밍의 본질은 관계적 사고에 있다. 시간적 순서가 아닌 논리적 관계에 집중하는 것이다. 또한 상태 모델링을 통해 가능한 상태를 먼저 선언하고 불가능한 상태를 원천 차단하는 것이 중요하다. 적절한 추상화를 통해 각 레벨에서 적합한 표현력을 제공해야 하며, 이는 절대적 구분이 아닌 관점과 레벨에 따른 상대적 개념이라는 점을 이해해야 한다.
진정한 선언적 코드의 특징을 보면, 비즈니스 의도가 코드 구조에서 직접 읽힌다. 기술적 복잡성은 적절한 추상화 뒤로 숨겨지고, 각 부분이 독립적으로 이해 가능하면서도 안전하게 합성된다. 그리고 불가능한 상태가 타입 수준에서 방지된다.
선언적 프로그래밍은 하루아침에 습득할 수 있는 기술이 아니다. 하지만 꾸준히 연습하고 적절한 추상화 레벨을 찾아가는 과정에서, 더 읽기 쉽고 유지보수하기 쉬운 코드를 작성할 수 있게 될 것이다.
코드는 컴퓨터를 위한 것이 아니라 사람을 위한 것이다. 선언적 프로그래밍은 바로 그 사람들이 복잡한 문제를 더 쉽게 이해하고 다룰 수 있게 도와주는 강력한 도구다.
선언적 프로그래밍은 도구가 아니라 사고방식이며, 문법이 아니라 철학이다. 이 철학을 이해하고 실무에 적용할 때, 우리는 더 나은 코드를 작성할 수 있게 된다.