예측가능한 코드, 상태관리와 밀접한 개념인 부수효과(Side Effect)에 대해 알아보자.
부수효과는 함수가 실행될 때 함수 외부의 상태를 변경하거나, 외부 상태에 의존하는 모든 행위를 말한다.
부수효과에서 언급하는 '외부'는 함수의 스코프 밖에 있는 모든 것을 의미한다. 하.. 스코프는 또 뭐지? => 클릭
// 🚨 부수효과가 있는 함수들
// 1. 외부 변수 변경
let total = 0;
const addToTotal = (num: number) => {
total += num; // 외부 변수 total을 변경
};
// 2. 콘솔 출력
const logUser = (user: User) => {
console.log(user); // 외부 환경(콘솔)에 출력
};
// 3. API 호출
const saveUser = async (user: User) => {
await fetch('/api/users', { // 외부 서버와 통신
method: 'POST',
body: JSON.stringify(user)
});
};
// 4. DOM 조작
const updateUI = (element: HTMLElement) => {
element.textContent = 'Updated!'; // DOM 상태 변경
};
// ✅ 부수효과가 없는 순수 함수들
// 1. 입력값에 따른 출력값만 반환
const double = (num: number): number => {
return num * 2;
};
// 2. 객체 변환
const formatUser = (user: User): FormattedUser => {
return {
fullName: `${user.firstName} ${user.lastName}`,
age: calculateAge(user.birthDate)
};
};
// 3. 배열 변환
const filterActiveUsers = (users: User[]): User[] => {
return users.filter(user => user.isActive);
};
- 예측 불가능성
- 외부 상태에 의존하므로 결과를 예측하기 어려움
- 테스트가 복잡해짐
// 🚨 예측 불가능성 예시
let globalCount = 0;
const increment = () => {
globalCount++;
return globalCount;
};
console.log(increment()); // 1
console.log(increment()); // 2
// 다른 개발자가 globalCount를 100으로 변경했다면?
console.log(increment()); // 101 (예측 불가능!)
// ✅ 예측 가능한 코드
const increment = (count: number) => {
return count + 1;
};
console.log(increment(0)); // 1
console.log(increment(1)); // 2
console.log(increment(2)); // 3 (항상 예측 가능!)
- 디버깅의 어려움
- 문제 발생 시 원인 파악이 어려움
- 상태 변경의 흐름을 추적하기 어려움
// 🚨 디버깅이 어려운 코드예시
// 1. 첫 번째 코드 (부수효과 있음)
let user = { name: '김지성' };
const updateUser = () => {
user.name = '박지성'; // 🚨 외부 변수 직접 수정
console.log('User updated');
};
updateUser();
console.log(user); // { name: '박지성' }
// 2. 두 번째 코드 (순수 함수)
const user = { name: '김지성' };
const updateUser = (user: User) => {
return { ...user, name: '박지성' }; // ✅ 새로운 객체 반환
};
const newUser = updateUser(user);
console.log(user); // { name: '김지성' } (원본 유지)
console.log(newUser); // { name: '박지성' } (새로운 객체)
주요 차이점:
1. 원본 데이터 변경 여부
- 첫 번째: 원본 `user` 객체를 직접 수정
- 두 번째: 원본은 그대로 두고 새로운 객체를 반환
2. 함수의 예측 가능성
- 첫 번째: 함수 호출 시 외부 변수 `user`의 상태에 따라 결과가 달라짐
- 두 번째: 입력값이 같으면 항상 같은 결과를 반환
3. 디버깅 용이성
- 첫 번째: `user.name`이 어디서 변경되었는지 추적이 어려움
- 두 번째: 상태 변경이 명확하게 함수의 반환값으로 표현됨
4. 사용 예시
// 첫 번째 방식
let user = { name: '김지성' };
updateUser();
// user가 변경되었는지 확인하려면 user를 직접 확인해야 함
// 두 번째 방식
const user = { name: '박지성' };
const updatedUser = updateUser(user);
// 변경된 내용이 명확하게 updatedUser에 담겨있음
두번째 방식 특히 React의 불변성(Immutability) 원칙에 맞다.
불변성(Immutability): 데이터가 한번 생성되면 그 값을 변경할 수 없고, 대신 새로운 데이터를 생성하여 사용해야 한다는 원칙.
React에서 불변성을 지키면 상태 변경의 추적이 용이해짐.
예측 가능한 애플리케이션을 만들 수 있다.
- 코드 재사용성 저하
- 외부 상태에 의존하므로 다른 환경에서 재사용이 어려움
// 🚨 부수효과가 많은 코드
class UserManager {
private users: User[] = [];
addUser(user: User) {
this.users.push(user);
console.log(`User added: ${user.name}`);
this.saveToDatabase(user);
this.updateUI();
}
}
// ✅ 부수효과를 분리한 코드
class UserManager {
private users: User[] = [];
// 순수 함수: 데이터 변환만 담당
addUser(user: User): User[] {
return [...this.users, user];
}
// 부수효과를 명시적으로 분리
async handleUserAddition(user: User) {
const newUsers = this.addUser(user);
await this.saveToDatabase(user);
this.notifyUserAdded(user);
this.updateUI(newUsers);
}
}
const UserProfile: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
// 이 함수는 컴포넌트가 렌더링될 때마다 새로 생성됨
const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data); // setUser 호출 → 상태 변경 → 컴포넌트 재렌더링
};
return <div>{user?.name}</div>;
};
문제점:
fetchUser
함수가 컴포넌트 내부에 있어서, 컴포넌트가 렌더링될 때마다 새로운 함수가 생성됨- 이 함수가
setUser
를 호출하면 상태가 변경되어 컴포넌트가 다시 렌더링됨- 다시 렌더링되면
fetchUser
가 다시 생성되고... 이렇게 무한 루프가 발생할 수 있음
const UserProfile: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
};
// fetchUser를 어디서 호출해야 할지 명확하지 않음
return <div>{user?.name}</div>;
};
문제점:
fetchUser
함수는 정의만 되어있고, 어디서 호출해야 할지가 명확하지 않음- 컴포넌트가 처음 마운트될 때 자동으로 데이터를 가져오지 않음
- 사용자가 직접 호출해야 하는지, 자동으로 호출되어야 하는지 불명확
const UserProfile: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
// 데이터 가져오기 (부수효과)
const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
};
// UI 그리기 (렌더링 로직)
return <div>{user?.name}</div>;
};
문제점:
- 데이터를 가져오는 로직(
fetchUser
)과 UI를 그리는 로직(return
)이 한 컴포넌트에 섞여있음- 이렇게 되면 코드가 복잡해지고 유지보수가 어려워짐
const UserProfile: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
// useEffect로 데이터 가져오기 로직을 분리
useEffect(() => {
const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
};
fetchUser(); // 컴포넌트 마운트 시 한 번만 실행
}, []); // 빈 배열: 의존성이 없으므로 마운트 시 한 번만 실행
// UI 그리기 로직만 남음
return <div>{user?.name}</div>;
};
장점:
1.useEffect
를 사용해 데이터 가져오기 로직을 분리
2. 컴포넌트 마운트 시 한 번만 실행되도록 명확하게 지정
3. UI 그리기 로직만 남아서 코드가 깔끔해짐
이렇게 하면 코드가 더 예측 가능하고 유지보수하기 쉬워짐
이 글을 여기까지 훑고 부수효과가 무엇이고, 프로그램에 어떤 영향을 미칠 수 있는지 정도 이해했다면 성공적이라고 생각한다.
부수효과는 코드의 예측 가능성과 유지보수성을 저하시키는 주요 원인이다. 하지만 현실적인 프로그래밍에서 부수효과를 완전히 제거하기는 불가능에 가깝다.
따라서 이 글의 핵심은 부수효과의 제거가 아닌 '이 함수를 호출하면 외부에 어떤 영향을 줄지 코드만 봐도 알 수 있게 하자'이다.
// ❌ 숨겨진 부수효과
const addToCart = (item) => {
const newItem = { ...item, id: Date.now() };
cart.push(newItem); // 🚨 전역 배열 변경 (숨겨진 부수효과)
return newItem;
};
// ✅ 명시적 부수효과
const createCartItem = (item) => {
return { ...item, id: Date.now() }; // 순수 함수
};
const addToCart = (item) => {
const newItem = createCartItem(item);
cart.push(newItem); // 명시적으로 전역 상태 변경
return newItem;
};