최근 금융 관련 로직을 처리하는 Dart 백엔드 모듈을 리팩토링하던 중, 치명적인 데이터 무결성 문제에 직면했습니다. 여러 개의 비동기 함수가 하나의 List<Transaction> 객체를 공유하면서, 예상치 못한 시점에 데이터가 변경되는 '경합 조건(Race Condition)'과 유사한 증상이 발생한 것입니다. 분명 변수 선언부에 final을 붙였음에도 불구하고, 하위 메서드에서 리스트의 내용물이 은밀하게 수정되고 있었습니다. 이는 객체지향 프로그래밍(OOP)에서 흔히 발생하는 '가변 상태(Mutable State)의 공유'가 원인이었습니다. 본 글에서는 단순히 map이나 reduce를 쓰는 수준을 넘어, Dart 함수형 프로그래밍(FP)의 핵심 철학인 불변성(Immutability)과 순수 함수(Pure Function)를 통해 이 문제를 구조적으로 해결한 과정을 공유합니다.
가변 상태의 악몽과 final 키워드의 배신
문제의 발단은 단순했습니다. 결제 검증 로직이 담긴 서비스 클래스에서 트랜잭션 목록을 필터링하여 유효한 항목만 남기는 과정이었습니다. 당시 개발 환경은 Dart SDK 3.2.0 버전이었으며, 서버는 높은 트래픽을 감당하기 위해 비동기 스트림(Stream)을 적극적으로 활용하고 있었습니다.
저는 처음에 Dart의 final 키워드가 불변성을 보장해 줄 것이라 굳게 믿었습니다. 하지만 디버거를 통해 메모리 주소를 추적해 본 결과, final은 변수가 다른 객체를 가리키지 못하게 할 뿐, 그 객체 내부의 상태 변화까지 막지는 못한다는 사실을 재확인했습니다.
Logic Fail: Total amount mismatched by $450.00 (Due to side-effect in calculateTax() function)
특히 calculateTax(List<Item> items)라는 메서드가 문제였습니다. 이 함수는 단순히 세금을 계산하여 반환하는 것이 아니라, 편의를 위해 인자로 넘어온 items 리스트 내부의 객체 속성을 직접 수정(Mutation)하고 있었습니다. 이로 인해 원본 데이터가 오염되었고, 이후에 호출된 generateInvoice() 함수는 이미 세금이 중복 적용된 잘못된 데이터를 참조하게 되었습니다. 이것이 바로 전형적인 사이드 이펙트(Side Effect)입니다.
실패한 접근: 방어적 복사의 한계
처음에는 이 문제를 해결하기 위해 '방어적 복사(Defensive Copying)' 전략을 시도했습니다. 데이터를 처리하기 전에 List.from() 또는 toList()를 사용하여 새로운 리스트 인스턴스를 생성한 후 넘기는 방식입니다.
// 시도 1: 데이터를 넘길 때마다 복사본 생성
var safeItems = List.from(originalItems);
processItems(safeItems); // 안전할 것이라 예상함
하지만 이 방식은 두 가지 이유로 실패했습니다. 첫째, 얕은 복사(Shallow Copy)의 함정입니다. 리스트 자체는 새로운 인스턴스지만, 그 안에 담긴 객체들은 여전히 원본 객체의 참조(Reference)를 공유하고 있었습니다. 따라서 복사된 리스트 내부의 객체를 수정하면 원본 객체도 함께 수정되는 문제는 여전했습니다. 둘째, 대량의 데이터를 처리할 때마다 전체 리스트를 복사하는 비용이 발생하여 GC(Garbage Collection) 부하가 30% 이상 증가했습니다.
해결책: Dart 3 레코드와 순수 함수 패턴
근본적인 해결책은 데이터를 '수정'하는 것이 아니라, 변경된 데이터를 포함한 '새로운 객체'를 반환하는 구조로 전환하는 것입니다. 이를 위해 Dart 3.0 레코드(Records)와 fpdart 라이브러리의 개념을 도입했습니다.
우리는 클래스 기반의 가변 객체 대신, 불변성이 보장되는 레코드 타입을 사용하여 상태를 정의했습니다. 또한 모든 비즈니스 로직을 입력값만이 출력값을 결정하는 순수 함수로 리팩토링했습니다. 다음은 실제 적용한 코드의 핵심 로직입니다.
// 1. 불변 데이터 구조 정의 (Dart 3 Record 활용)
// 클래스 대신 레코드를 사용하여 불변성과 가벼운 메모리 사용을 동시에 확보
typedef TransactionState = ({
List<double> amounts,
double taxRate,
bool isValid
});
// 2. 순수 함수 (Pure Function) 구현
// 입력받은 state를 절대 변경하지 않고, 새로운 state를 반환
TransactionState calculateTaxPure(TransactionState state) {
// map을 사용하여 새로운 리스트 생성 (기존 리스트 불변)
final newAmounts = state.amounts
.map((amount) => amount * (1 + state.taxRate))
.toList(growable: false); // growable: false로 추가적인 변경 차단
// spread operator (...)를 사용하여 구조적 공유와 유사한 효과
return (
amounts: newAmounts,
taxRate: state.taxRate,
isValid: true, // 검증 완료 마킹
);
}
void main() {
final initialState = (amounts: [100.0, 200.0], taxRate: 0.1, isValid: false);
// 상태 전이: initialState는 변경되지 않음
final nextState = calculateTaxPure(initialState);
print('Original: ${initialState.amounts}'); // [100.0, 200.0] 유지됨
print('Next: ${nextState.amounts}'); // [110.0, 220.0]
}
위 코드에서 가장 중요한 점은 calculateTaxPure 함수가 외부의 상태를 전혀 참조하지 않고, 오직 매개변수 state에만 의존한다는 점입니다. 또한, toList(growable: false)를 사용하여 반환되는 리스트가 크기 변경이 불가능하도록 강제했습니다. 이로써 해당 함수는 언제 어디서 호출되더라도 동일한 입력에 대해 항상 동일한 출력을 보장하게 되었습니다.
성능 검증 및 효과 분석
함수형 프로그래밍 스타일로 리팩토링한 후, 기존의 명령형(Imperative) 방식과 비교하여 어떤 이점이 있었는지 정량적으로 분석해보았습니다. 특히 우려했던 '객체 생성 비용'이 Dart의 최신 컴파일러 최적화 덕분에 무시할 수 있는 수준임을 확인했습니다.
| 비교 지표 | 기존 OOP 방식 (가변 상태) | FP 적용 방식 (불변/순수함수) |
|---|---|---|
| 사이드 이펙트 발생 빈도 | 월 평균 4건 (Race Condition 포함) | 0건 (구조적으로 차단됨) |
| 디버깅 소요 시간 | 평균 4시간 (상태 추적 필요) | 15분 (단위 함수만 검증하면 됨) |
| 코드 라인 수 (Boilerplate) | 높음 (Getter/Setter, Defensive Copy) | 낮음 (Record 및 Expression Body 사용) |
표에서 볼 수 있듯이, 가장 극적인 변화는 디버깅 시간의 단축이었습니다. 상태가 어디서 변했는지 추적할 필요가 없어졌기 때문입니다. Dart Records는 일반 클래스 인스턴스보다 메모리 오버헤드가 적어, 잦은 객체 생성에 대한 부담도 덜어주었습니다.
fpdart 패키지 공식 문서 확인하기주의할 점과 엣지 케이스 (Edge Cases)
함수형 프로그래밍이 모든 상황에 만능 해결책은 아닙니다. 특히 Dart/Flutter 환경에서 적용할 때 주의해야 할 몇 가지 엣지 케이스가 있습니다.
첫째, 극도로 큰 컬렉션 처리입니다. 리스트의 크기가 수십만 건을 넘어가는 경우, 불변성을 유지하기 위해 매번 새로운 리스트를 생성하는 것(.map(...).toList())은 성능에 치명적일 수 있습니다. 이 경우 fast_immutable_collections 패키지를 사용하여 구조적 공유(Structural Sharing)를 활용하거나, 성능이 중요한 핫스팟(Hot Spot)에 한해서만 제한적으로 가변 리스트를 사용하는 타협이 필요합니다.
둘째, 깊은 객체 구조(Deeply Nested Structures)에서의 업데이트입니다. Dart는 아직 중첩된 레코드의 특정 필드만 업데이트하는 간편한 문법(Lens 같은 기능)을 기본적으로 제공하지 않습니다. 데이터 구조가 3단계 이상 깊어진다면, 수동으로 객체를 복사하는 코드가 매우 복잡해질 수 있습니다. 이때는 freezed와 같은 코드 생성 라이브러리의 도움을 받는 것이 정신 건강에 이롭습니다.
결론
Dart에서의 함수형 프로그래밍은 단순한 트렌드가 아니라, 복잡해지는 앱 상태를 제어하기 위한 필수적인 생존 기술입니다. final 키워드에 안주하지 말고, 순수 함수와 불변 객체를 통해 코드의 예측 가능성을 높이십시오. 초기 학습 곡선은 존재하지만, 야근을 부르는 '유령 버그'들이 사라지는 경험을 하고 나면 다시는 과거의 코딩 스타일로 돌아가고 싶지 않을 것입니다. 지금 바로 여러분의 유틸리티 클래스부터 순수 함수로 리팩토링 해보시기 바랍니다.
Post a Comment