더 이상 JS 메서드 체이닝만 고집하지 않는 이유
자바스크립트의 메서드 체이닝은 처음에는 깔끔해 보이지만, 단계가 많아질수록 가독성이 크게 떨어지고 디버깅과 유지보수가 어려워집니다. 또한 불필요하게 전체 배열을 순회하게 만들어 성능 저하를 유발할 수 있습니다. 따라서 3~4단계 이상의 체이닝은 코드를 분리해 작성하는 것이 장기적인 코드 품질과 명확성에 훨씬 유리합니다.
예전에는 자바스크립트 코드를 이렇게 많이 작성하곤 했습니다:
const result = users .filter(user => user.active) .map(user => user.name) .sort() .slice(0, 5);
여기에는 아무런 문제가 없습니다. 저도 항상 이런 식으로 코드를 작성했습니다. 하지만 처음에는 괜찮아 보이지만, 시간이 지날수록 다루기가 점점 더 어려워지는 코드가 바로 이런 코드입니다.
체이닝은 대단하죠... 더 이상 그렇지 않을 때까지는
문제는 .map()이나 .filter()가 아닙니다. 그것들을 계속 쌓아올릴 때 생기는 일입니다. 단계별로 작성하는 것을 멈추고 파이프라인을 작성하기 시작하는 것이죠. 파이프라인은 깔끔해 보이지만, 여전히 머릿속에서 과정을 따라가야 합니다: 필터 → 맵 → 정렬 → 자르기. 한두 번이야 괜찮습니다. 하지만 파일 곳곳에 이런 코드가 있으면 점점 지치게 됩니다.
다음 코드와 비교해 보세요:
const activeUsers = users.filter(user => user.active); const names = activeUsers.map(user => user.name); names.sort(); return names.slice(0, 5);
맞습니다. 줄 수는 더 많습니다. 하지만 각 단계가 그냥 명확하게 거기에 있습니다. 해독할 필요가 없죠.
같은 문제를 푸는 세 가지 방식
의도가 같은 코드를 세 가지 다른 방식으로 작성해 보겠습니다.
체이닝을 사용한다면: users.filter(u => u.active).map(u => u.name)[0]
깔끔해 보입니다. 예전에는 이런 방식을 정말 많이 사용했습니다. 하지만 결과가 하나만 필요한데도 전체를 처리하게 됩니다.
단계별로 나누어 작성한다면: const user = users.find(u => u.active); const name = user?.name;
보통 제가 선택하는 방식입니다. 조건을 만족하는 항목을 찾는 즉시 멈추고, 뭔가 이상하다 싶으면 각 부분을 쉽게 확인할 수 있습니다.
완전한 제어가 필요하다면: for (const u of users) { if (u.active) return u.name; }
가장 명시적이며, 솔직히 말해 코드가 어떻게 동작하는지 정말 신경 쓰일 때는 가장 명확한 방식입니다.
상황이 조금 지저분해지는 지점
이러한 문제는 디버깅을 하려고 할 때 가장 빨리 나타납니다. 뭔가 이상하다고 느껴서 필터링된 결과를 확인하고 싶다고 가정해 봅시다.
체이닝을 사용하면 결국 이렇게 하게 됩니다:
const result = users .filter(user => { console.log(user); return user.active; }) .map(user => user.name);
이제 비즈니스 로직과 디버깅 코드가 섞여 버렸습니다. 아니면 포기하고 체인을 억지로 분리하게 됩니다.
필요한 것보다 더 많은 작업을 하게 될 수 있습니다
체이닝은 의도와 상관없이 '모든 것을 처리하도록' 유도합니다.
const firstActiveUser = users .filter(user => user.active) .map(user => user.name)[0];
이 코드는 전체 배열을 필터링하고 결과를 매핑한 다음 단 하나의 항목을 가져옵니다.
반면 실제로 원했던 것은 이것일 수 있습니다:
const user = users.find(user => user.active); const name = user?.name;
또는:
for (const user of users) { if (user.active) { return user.name; } }
아프기 시작하는 지점
이건 단순히 가독성의 문제가 아닙니다. 배열의 크기가 크거나 코드가 빈번하게 실행되는 핫 패스(Hot Path)에서는 그 불필요한 작업이 성능 저하로 이어집니다. 그리고 긴 체인은 프로덕션 환경에서 디버깅할 때 예상외로 성가실 수 있습니다. 예전에 꽤 복잡한 체인을 작성한 적이 있는데, 나중에 다시 그 코드를 보니... 겸손해지더군요.
유창한(Fluent) 코드가 항상 명확한 것은 아닙니다
체이닝이 인기 있는 데는 이유가 있습니다. 처음에는 읽기 좋으니까요.
data .transform() .normalize() .validate() .save();
하지만 이제 각 단계가 무엇을 반환하는지, 브레이크포인트를 어디에 찍어야 할지, 이 코드의 일부를 재사용할 수 있는지 궁금해집니다. 코드를 단계별로 나누면 이 질문들에 바로 답을 얻을 수 있습니다.
비동기 체인도 같은 문제를 겪습니다
프로미스(Promise) 체이닝은 세련되게 보일 수 있습니다:
const data = await fetchUsers() .then(res => res.json()) .then(users => users.filter(u => u.active)) .then(users => users.map(u => u.name));
하지만 지금 하나의 체인 안에 비동기 제어 흐름(가져오기, 파싱)과 데이터 변환이 섞여 있습니다.
이를 분리하면 일반적으로 이해하기가 훨씬 쉽습니다:
const res = await fetchUsers(); const users = await res.json(); const activeNames = users.filter(u => u.active).map(u => u.name);
제가 따르는 대략적인 규칙
- 1단계: 완전히 괜찮음 (예:
users.map(u => u.name)) - 2단계: 보통 괜찮음 (예:
users.filter(u => u.active).map(u => u.name)) - 3~4단계: 잠시 멈추고, 분리하는 것을 고려할 것 (예:
users.filter(...).map(...).sort(...).slice(...)) - 5단계 이상: 무조건 단계별로 분리할 것 (예: 복잡한 변환 또는 비동기 체인)
체이닝을 절대 쓰지 말라는 게 아닙니다
짧은 체인은 괜찮습니다. 저도 여전히 사용합니다. 3~4단계에 도달하면 잠시 멈추는 것뿐입니다.
지금은 이렇게 생각합니다
코드를 빠르게 작성할 때 체이닝은 정말 좋습니다...