💡 코드를 나눴는데도 복잡한 이유는 뭘까?
코드를 작성하다 보면 점점 길어지고 복잡해지는 순간이 옵니다.
이때 "함수를 작게 나누면 코드가 더 깔끔해지겠지?"라고 생각하며 분리하지만,
막상 나누고 나서도 복잡함이 해소되지 않거나 오히려 가독성이 떨어지는 경우가 많습니다.
이유는 무엇일까요?
단순히 함수를 쪼갰다고 해서 코드가 더 나아지는 것은 아니기 때문입니다.
이 글에서는 함수 분리의 흔한 함정과 더 나은 함수 분리 방법에 대해 알아보겠습니다.
함수 분리를 했는데도 복잡한 이유
여러 개의 역할(관심사)이 섞여 있다.
아래 fetchData
함수는 "데이터 가져오기"라는 역할을 한다고 보이지만,
사실 내부적으로 3가지 이상의 일을 수행하고 있습니다.
- 서버에서 데이터 호출
- 받은 데이터를 React 상태로 저장 (setData)
- 결과에 따라 페이지 이동 (navigate)
async function fetchData(setData, navigate) {
const response = await fetch('https://some-api.com/data'); // 데이터 호출
const result = await response.json(); // 데이터 처리
setData(result); // 상태 업데이트
if (result.needsRedirect) {
navigate('/redirect-page'); // 페이지 이동
}
}
문제점
함수 이름은 fetchData인
데, 상태 업데이트와 페이지 이동까지 포함되어 있습니다. → 관심사(역할)가 분리 X
또한 React 상태 setData
와 navigate
를 의존하기 때문에 독립적인 단위 테스트가 어렵습니다.
이 함수는 setState
를 사용하기 때문에 React 상태가 필요하며,react-router-dom
의 navigate
메서드를 사용하고 있어 react-router-dom
라이브러리도 필요합니다.
즉, 이 함수를 바깥으로 분리하더라도 React 상태 관리와 라우팅 기능에 강하게 결합되어 있어, 테스트가 어렵고 재사용성이 낮아집니다.
결과적으로 코드의 유지보수성이 떨어지고, 관심사가 제대로 분리되지 않았기 때문에 좋은 코드라고 할 수 없습니다.
1. 올바른 함수 분리법 – 역할을 명확하게 나누기
이 문제를 해결하려면 "하나의 함수는 하나의 역할만 수행하도록" 분리해야 합니다.
위의 함수는 아래처럼 3가지 역할로 분리할 수 있습니다.
// 순수하게 데이터를 가져오는 역할
async function fetchData() {
const response = await fetch('https://some-api.com/data');
return response.json();
}
// 데이터를 받아 상태를 업데이트하고, 필요하면 페이지 이동
function updateState(result, setData, navigate) {
setData(result);
if (result.needsRedirect) {
navigate('/redirect-page');
}
}
// 두 가지 함수를 조합하여 실행
async function handleFetch(setData, navigate) {
const result = await fetchData();
updateState(result, setData, navigate);
}
순수 함수(Pure Function)와 부수 효과(Side Effect) 구분하기
순수 함수 (예측 가능, 테스트 쉬움)
- 같은 입력값이면 항상 같은 결과가 나옴
- 외부 상태를 변경하지 않음 (즉, 부수 효과가 없음)
const add = (a, b) => a + b;
부수 효과가 있는 함수 (예측 어려움, 테스트 어려움)
- 네트워크 요청을 포함하고 있어서 외부 환경에 따라 결과가 달라질 수 있음
- 외부 상태를 변경하거나 네트워크 요청, UI 업데이트 등을 수행
const fetchData = async () => {
return fetch('/api/data');
}
왜 순수 함수와 부수 효과를 분리해야 할까?
비즈니스 로직은 단순한 계산만으로 이루어지지 않습니다.
예를 들어, 다음과 같은 동작이 필요할 수 있습니다.
- 서버에 요청을 보내기
- 사용자 입력에 따라 데이터를 변경하기
- API 내부에서 상태를 변경하기
이처럼 외부 세계와의 상호작용이 필요한 동작을 "부수 효과(Side Effect)"라고 합니다.
부수 효과는 항상 같은 입력을 받아도 실행 시점이나 환경에 따라 결과가 달라질 수 있기 때문에,
이렇게 예측이 가능한 순수 함수(데이터 처리)와 예측이 어려운 부수 효과(네트워크 요청)를 분리하면 코드의 예측 가능성이 높아지고 테스트가 용이해집니다.
2. 올바른 함수 분리법 – 데이터, 계산, 액션을 분리하기
코드를 함수로 분리했는데도 여전히 복잡하게 느껴지는 경우, 대부분의 원인은 "액션" 로직이 함수에 과도하게 섞여 있기 때문입니다.
함수를 구성하는 요소는 아래처럼 데이터, 계산, 액션으로 구분할 수 있습니다.
- 데이터 : 단순한 값 (API 응답, 사용자 입력)
- 계산 : 입력값을 변환하는 순수 함수
- 액션 : 외부 세계와 상호작용 (네트워크 요청, UI 업데이트)
이때, 가능한 한 액션을 분리하고, 계산을 적극적으로 활용하며, 액션 내부에 포함된 계산 로직은 따로 분리하는 것이 바람직합니다.
계산은 외부 상태에 영향을 주지 않으므로:
- 테스트가 쉽고
- 여러 번 실행해도 동일한 결과를 반환합니다.
반면, 액션은:
- 실행 시점과 횟수에 따라 결과가 달라질 수 있으며
- 외부 시스템과 상호작용하기 때문에 테스트와 유지보수가 어렵습니다.
- 예를 들어, 네트워크 요청을 포함하는 함수를 테스트하려면 외부 API를 모의(Mock) 처리해야 하는 추가 작업이 필요합니다.
즉, 액션은 우리가 원하는 결과를 얻기 위해 반드시 필요하지만, 외부 세계와의 연결점을 최소화하는 것이 중요합니다.
액션의 영향을 줄이고 테스트 가능성을 높이기 위해, 가능한 한 액션을 최소화하고, 데이터를 가공하는 계산 로직을 따로 분리하는 것이 좋은 설계 방식입니다.
이제 아래 코드를 예시로 살펴보겠습니다.
이메일 유효성 검사 & 전송 코드 예제 (수정 전)
먼저, 아래의 validateAndSendEmail
함수는 이메일 유효성 검사와 이메일 전송 로직이 하나로 묶여 있습니다.
type Email = string;
const validateAndSendEmail = async (email: Email, content: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(email)) {
// 이메일 유효성 검사 통과 후, 이메일 전송 로직
console.log(`Sending email to ${email}: ${content}`);
// 예시를 위한 가상의 비동기 처리
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
console.error("Invalid email address");
}
}
이 코드는 유효성 검사와 이메일 전송이라는 두 가지 역할을 동시에 수행하고 있습니다.
하지만 이처럼 여러 관심사가 섞여 있으면, 유효성 검사만을 단독으로 테스트하기가 어려워지고, 이메일 검증 로직을 다른 곳에서 재사용하기도 힘들어집니다.
이메일 유효성 검사 & 전송 코드 예제 (수정 후)
// 순수 함수 (계산)
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// 부수 효과가 있는 함수 (액션)
async function sendEmail(email: string, content: string): Promise<void> {
console.log(`Sending email to ${email}: ${content}`);
// 예시를 위한 가상의 비동기 처리
await new Promise(resolve => setTimeout(resolve, 1000));
}
// 액션과 계산을 조합하는 함수
async function validateAndSendEmail(email: string, content: string) {
if (isValidEmail(email)) {
await sendEmail(email, content);
} else {
console.error("Invalid email address");
}
}
이제 isValidEmail
이라는 계산 로직과 sendEmail
이라는 액션이 각기 분리되고, validateAndSendEmail
함수에서 이를 조합하여 사용하고 있습니다.
이렇게 수정하면 유효성 검사는 순수 함수가 되어 단독으로 테스트할 수 있으며, 이메일 검증 로직을 다른 곳에서도 재사용할 수 있습니다.
만약 이메일 기능이 정상적으로 동작하지 않는다는 고객 리포트를 받았다면, 기존 코드에서는 이메일 전송까지 포함해서 검토해야 하지만,
이제는 isValidEmail
함수만 별도로 테스트하여 유효성 검사 로직이 문제인지 쉽게 확인할 수 있습니다.
이처럼 계산과 액션을 분리하면 유지보수성이 높아지고, 테스트가 쉬워지며, 코드의 가독성도 향상됩니다.
앞에서 이메일 유효성 검사 예제를 통해 순수 함수(계산)와 부수 효과(액션)를 분리하는 방법을 살펴보았습니다.
이번에는 실제 투두리스트 앱 코드를 예시로, 좀 더 복잡한 구조에서도 계산과 액션을 어떻게 나눌 수 있는지 알아보겠습니다.
투두리스트 앱 예제 (수정 전)
먼저, 기존의 투두리스트 코드입니다.
import React, { useState } from 'react';
const TodoList = () => {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const [filter, setFilter] = useState('all'); // all, completed, incomplete;
const [searchQuery, setSearchQuery] = useState('');
const addTodo = () => {
const newTodo = { text: input, completed: false };
setTodos([...todos, newTodo]);
setInput('');
};
const toggleComplete = (index) => {
const newTodos = [...todos];
newTodos[index].completed = !newTodos[index].completed;
setTodos(newTodos);
};
const filterTodos = () => {
if (filter === 'completed') {
return todos.filter((todo) => todo.completed);
}
if (filter === 'incomplete') {
return todos.filter((todo) => !todo.completed);
}
return todos;
};
const searchTodos = () => {
return filterTodos().filter((todo) => todo.text.includes(searchQuery));
};
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<button onClick={() => setFilter('all')}>Show All</button>
<button onClick={() => setFilter('completed')}>Show Completed</button>
<button onClick={() => setFilter('incomplete')}>Show Incomplete</button>
<input
placeholder='Search...'
onChange={(e) => setSearchQuery(e.target.value)}
/>
<ul>
{searchTodos().map((todo, index) => (
<li key={index}>
{todo.text}{' '}
<button onClick={() => toggleComplete(index)}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</li>
))}
</ul>
</>
);
};
export default TodoList;
위 코드에서 정의된 함수들을 먼저 계산과 액션으로 나누어보면, 아래와 같습니다.
- 계산 (순수 함수)
filterTodos
: 이 함수는 현재 상태(todos
와filter
)를 기반으로 새로운 배열을 계산합니다. 외부 상태를
변경하지 않으며, 동일한 상태에 대해 항상 동일한 결과를 반환합니다.searchTodos
: 이 함수도filterTodos
의 결과와searchQuery
를 기반으로 필터링된 결과를 반환합니다. 외
부 상태를 변경하지 않고, 동일한 입력에 대해 동일한 출력을 제공합니다.
- 액션 (부수 효과를 가진 함수)
addTodo
: 이 함수는 새로운 할 일을todos
배열에 추가합니다. 이는 외부 상태(todos
)를 변경하는 부수 효
과를 가집니다.toggleComplete
: 특정 할 일의 완료 상태를 토글합니다. 이것도todos
배열의 상태를 변경하는 부수 효과
를 가집니다.setInput
,setFilter
,setSearchQuery
: 이들은 React의useState
를 사용해 컴포넌트의 상태를 변경합니다. 모두 컴포넌트 외부 상태를 변경하는 액션입니다.
위 두 카테고리 중 적어도 순수 함수에 속하는 ‘계산’ 함수들은 컴포넌트 바깥으로 분리해도 무리가 없습니다.
반면, ‘액션’에 속하는 함수들은
- 부수 효과를 일으키며
- 컴포넌트 바깥으로 분리하기 어렵고
- 만약 다른 ‘계산’ 로직들과 합쳐진다면 그들 또한 액션으로 만들어 버립니다.
투두리스트 앱 예제 (수정 후)
먼저, 계산 로직을 TodosController
라는 헬퍼 함수로 따로 분리하고,
컴포넌트에서는 이 함수를 활용하여 액션을 처리하도록 변경합니다.
import React, { useState } from 'react';
const FilterType = {
ALL: 'all',
COMPLETED: 'completed',
INCOMPLETE: 'incomplete',
};
const TodoList = () => {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const [filter, setFilter] = useState(FilterType.ALL);
const [searchQuery, setSearchQuery] = useState('');
// 액션
const addTodo = () => {
// 계산
const nextTodos = TodosController(todos)
.add({ text, completed: false, id: Math.random() })
.get();
// 액션 (re-render)
setTodos(nextTodos);
setInput('');
};
// 액션
const toggleComplete = (targetTodo) => {
// 계산
const nextTodos = TodosController(todos).toggleComplete(targetTodo).ge;
// 액션 (re-render)
setTodos(nextTodos);
};
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<button onClick={() => setFilter('all')}>Show All</button>
<button onClick={() => setFilter('completed')}>Show Completed</button>
<button onClick={() => setFilter('incomplete')}>Show Incomplete</button>
<input
placeholder='Search...'
onChange={(e) => setSearchQuery(e.target.value)}
/>
<ul>
{/* 계산 */}
{TodosController(todos)
.filter(filter)
.search(searchQuery)
.get()
.map((todo) => (
<li key={todo.id}>
{todo.text}{' '}
<button onClick={() => toggleComplete(todo)}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</li>
))}
</ul>
</>
);
};
export default TodoList;
// ViewModel 역할 + 체이닝 스타일 적용을 위한 헬퍼 함수
const TodosController = (todos) => ({
add: (todo) => {
return TodosController([...todos, todo]);
},
search: (keyword) => {
const searchedTodos = todos.filter((todo) =>
todo.text.includes(keyword.toLowerCase()),
);
return TodosController(searchedTodos);
},
filter: (filter) => {
let filteredTodos = todos;
if (filter === FilterType.COMPLETED) {
filteredTodos = todos.filter((todo) => todo.completed);
} else if (filter === FilterType.INCOMPLETE) {
filteredTodos = todos.filter((todo) => !todo.completed);
}
return TodosController(filteredTodos);
},
toggleComplete: (targetTodo) => {
const nextTodos = todos.map((todo) => {
if (todo.id === targetTodo.id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
return TodosController(nextTodos);
},
get: () => todos, // 최종 결과를 반환하는 메서드
});
이제 TodosController
를 만들어 계산 로직을 컴포넌트에서 분리하고,
컴포넌트에서는 TodosController
를 호출하여 필요한 계산을 수행한 후, 액션만 담당하도록 변경되었습니다.
이렇게 변경함으로써,
- 계산 로직은 순수 함수로 유지되어 독립적인 테스트가 가능해졌고,
- 액션(상태 변경, UI 업데이트)과 분리되어 코드의 역할이 명확해졌으며,
- 컴포넌트에서 상태 관리가 더욱 직관적으로 이루어질 수 있게 되었습니다.
결국, 함수 분리를 단순히 "코드를 나눈다"는 개념이 아니라, "각 함수의 역할을 명확히 구분하는 과정"입니다.
이러한 접근 방식을 적용하면, 코드의 유지보수성이 향상되고, 테스트가 쉬워지며, 기능 확장이 보다 유연해집니다.
앞으로 함수 분리를 할 때 단순히 코드를 쪼개는 것이 아니라, 계산과 액션을 분리하여 설계하는 것을 고려해보면 좋겠습니다.
결론: 좋은 함수 분리의 기준
- "함수 하나가 하나의 역할만 담당하도록" 설계할 것
- 순수 함수(계산)와 부수 효과(액션)를 분리하여 유지보수성을 높일 것
'Frontend Architecture' 카테고리의 다른 글
프론트엔드 개발에서 관심사의 분리란? (0) | 2025.03.10 |
---|