메뉴
HN
Hacker News 40일 전

더 이상 JS 메서드 체이닝만 고집하지 않는 이유

IMP
7/10
핵심 요약

자바스크립트의 메서드 체이닝은 처음에는 깔끔해 보이지만, 단계가 많아질수록 가독성이 크게 떨어지고 디버깅과 유지보수가 어려워집니다. 또한 불필요하게 전체 배열을 순회하게 만들어 성능 저하를 유발할 수 있습니다. 따라서 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단계에 도달하면 잠시 멈추는 것뿐입니다.

지금은 이렇게 생각합니다

코드를 빠르게 작성할 때 체이닝은 정말 좋습니다...

원문 보기
원문 보기 (영어)
I used to write a lot of JavaScript like this: const result = users .filter(user => user.active) .map(user => user.name) .sort() .slice(0, 5); Nothing here is wrong. I wrote code like this all the time. But this is exactly the kind of thing that feels fine at first, then slowly gets harder to work with. Chaining is great…until it isn’t The issue isn’t .map() or .filter() . It’s what happens when you stack them. You stop writing steps and start writing pipelines. Pipelines look clean, but you still have to walk through them in your head: filter → map → sort → slice. That’s fine once or twice. Do it all over a file and it starts to wear on you. Compare that to this: const activeUsers = users.filter(user => user.active); const names = activeUsers.map(user => user.name); names.sort(); return names.slice(0, 5); Yeah, it’s more lines. But each step is just sitting there. No decoding required. Same problem, three ways to write it Here’s the same intent written three different ways. If I’m chaining: users.filter(u => u.active).map(u => u.name)[0] It looks neat. I used to reach for this a lot. But it processes everything, even though I only need one result. If I’m writing it in steps: const user = users.find(u => u.active); const name = user?.name; This is usually where I land. It stops early, and if something feels off I can check each piece. If I want full control: for (const u of users) { if (u.active) return u.name; } This is the most explicit and, honestly, sometimes the clearest when I really care about what’s happening. Where things get a little messy This shows up fastest when you try to debug. Say something feels off and you want to check the filtered results. With a chain, you end up doing this: const result = users .filter(user => { console.log(user); return user.active; }) .map(user => user.name); Now your logic is mixed with debugging code. Or you give up and break the chain apart anyway. You can end up doing more work than you need Chaining nudges you toward “process everything,” even when that’s not what you meant to do. const firstActiveUser = users .filter(user => user.active) .map(user => user.name)[0]; This filters the entire array, maps the result, and then grabs one item. When what you actually wanted was: const user = users.find(user => user.active); const name = user?.name; Or: for (const user of users) { if (user.active) { return user.name; } } Where this starts to hurt This isn’t just about readability. That extra work adds up with large arrays or hot paths. And long chains can be surprisingly annoying to debug in production. I’ve written some pretty gnarly chains before. Coming back to them later is…humbling. Fluent doesn’t always mean clear There’s a reason chaining is popular: it reads nicely at first. data .transform() .normalize() .validate() .save(); But now you’re wondering what each step returns, where you’d even put a breakpoint, or whether any of it is reusable. Breaking it into steps answers those questions right away. Async chains have the same problem Chaining promises can look sleek: const data = await fetchUsers() .then(res => res.json()) .then(users => users.filter(u => u.active)) .then(users => users.map(u => u.name)); But now you’re mixing async control flow (fetching, parsing) with data transformation in one chain. Splitting it up is usually easier to follow: const res = await fetchUsers(); const users = await res.json(); const activeNames = users.filter(u => u.active).map(u => u.name); A rough rule I follow Chain length Recommendation Example 1 step Perfectly fine users.map(u => u.name) 2 steps Usually fine users.filter(u => u.active).map(u => u.name) 3–4 steps Pause, consider breaking up users.filter(...).map(...).sort(...).slice(...) 5+ steps Definitely break into steps Complex transformations or async chains I’m not saying never chain Short chains are fine. I still write them. Once I hit three or four steps, I pause. How I think about this now Chaining is great when you’re writing code quickly. Breaking things into steps is better when that code has to be read later. Those aren’t the same thing. How I usually untangle these Step What to do Example 1 Name intermediate values const activeUsers = users.filter(u => u.active) 2 Separate transformations logically const names = activeUsers.map(u => u.name) 3 Only chain what’s clear names.sort() This has saved me from a lot of headaches. JavaScript gives you a lot of tools, but you don’t need to use all of them at once. JavaScript