뒤로가기
복붙 주도 개발의 함정

November 3, 2020

frontendessay

개발자라면 누구나 복사-붙여넣기를 한다. 이걸 부정하는 사람은 거짓말을 하거나, 기억력이 나쁘거나, 둘 중 하나다.

Stack Overflow에서, 블로그에서, GitHub 이슈 코멘트에서, 심지어 같은 프로젝트의 다른 파일에서. 우리는 매일 코드를 복사하고, 붙여넣고, 동작하면 넘어간다. 나도 그렇다. 솔직히 내 코드의 상당 부분이 어딘가에서 시작된 코드다.

복사 자체가 문제는 아니다. 문제는 그 다음이다.

동작하면 이해한 거 아니야?#

주니어 시절 나의 작업 흐름은 이랬다. 기능을 구현하다가 막힌다. 에러 메시지를 구글에 붙여넣는다. Stack Overflow에서 답변을 찾는다. 코드를 복사한다. 에러가 사라진다. 다음 기능으로 넘어간다.

이 과정에서 빠진 게 있다. "왜 이 코드가 문제를 해결하는지" 이해하는 단계. 에러가 사라졌으니까, 동작하니까, 이해한 거라고 착각했다. 동작과 이해 사이에는 거대한 간극이 있는데, 당시에는 그 간극이 보이지 않았다.

한동안은 별 문제 없이 지나갔다. 복사한 코드가 대부분 간단한 것들이었으니까. 배열 메서드 사용법, CSS 레이아웃 트릭, 날짜 포맷팅 유틸. 이런 것들은 복사해서 쓰다 보면 자연스럽게 익혀지기도 한다.

문제는, 복사하는 코드의 복잡도가 올라가기 시작하면서 터졌다.

useEffect와 클로저, 그리고 메모리 누수#

그 사건은 실시간 알림 기능을 만들 때 일어났다. WebSocket으로 서버에서 알림을 받아서 화면에 표시하는 기능이었다. useEffect 안에서 WebSocket 연결을 맺고, 메시지를 수신하면 상태를 업데이트하는 구조.

WebSocket을 useEffect에서 다루는 패턴을 블로그에서 찾았다. 코드는 대충 이런 모양이었다.

javascript
useEffect(() => {
  const ws = new WebSocket(url);

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    setNotifications(prev => [...prev, data]);
  };

  return () => {
    ws.close();
  };
}, [url]);

깔끔해 보였다. 복사해서 붙여넣고, 변수명만 프로젝트에 맞게 바꿨다. 로컬에서 테스트해보니 잘 동작했다. PR을 올리고 머지했다.

일주일 후, QA에서 리포트가 왔다. "알림 페이지에 오래 있으면 브라우저가 느려져요." 개발자 도구를 열어봤다. 메모리 사용량이 시간이 지날수록 계속 올라가고 있었다. 전형적인 메모리 누수 패턴이었다.

원인을 찾는 데 반나절이 걸렸다. 문제는 내가 복사한 코드에 있었지만, 코드 자체의 문제는 아니었다. 내가 이해하지 못한 채 붙여넣은 맥락의 문제였다.

실제 프로젝트에서는 단순히 url만 의존성 배열에 넣으면 안 되는 상황이었다. 유저의 인증 토큰이 갱신될 때마다 WebSocket을 재연결해야 했는데, 토큰이 바뀌어도 url은 같았기 때문에 이전 연결이 닫히지 않고 새 연결이 추가로 열렸다. 클린업 함수가 실행되지 않으니 이전 WebSocket 인스턴스가 계속 살아 있었고, 각각의 onmessage 핸들러가 상태를 업데이트하고 있었다.

클로저의 동작 방식을 이해했다면 이 문제를 미리 잡을 수 있었다. 의존성 배열의 의미를 제대로 알았다면 url만으로는 부족하다는 걸 알았을 거다. 하지만 나는 복사한 코드가 "이런 상황에서 동작하는 코드"라는 걸 모른 채, 내 상황에 그대로 적용한 거다.

복사한 코드의 숨은 가정#

이 사건 이후로 복사한 코드를 다르게 보기 시작했다. 모든 코드에는 가정이 들어 있다. 원본 코드의 작성자는 특정 상황을 전제로 그 코드를 썼다. 그 가정이 내 상황과 맞는지 확인하지 않으면, 코드는 동작하는 것처럼 보이다가 예상치 못한 순간에 깨진다.

Stack Overflow 답변을 다시 읽어보면, 종종 "이건 X 상황을 가정한 예제입니다"라는 주석이 달려 있다. 예전에는 그런 주석을 무시했다. 코드만 복사하면 되니까. 하지만 그 주석이 말하는 "X 상황"이 내 상황과 다르면, 그 코드는 내 프로젝트에서 시한폭탄이다.

비슷한 일이 또 있었다. 무한 스크롤을 구현할 때, Intersection Observer를 사용하는 코드를 블로그에서 가져왔다. 잘 동작했다. 그런데 리스트 아이템 안에 이미지가 있으면 스크롤이 버벅거렸다. 원본 코드는 텍스트 리스트를 전제로 작성된 거였고, 이미지 로딩에 따른 높이 변화를 고려하지 않았다. rootMargin을 조정하고 이미지 placeholder를 넣어야 했는데, 원본 코드에서는 그런 처리가 필요 없었으니 당연히 없었다.

또 한 번은 debounce 유틸 함수를 복사해서 검색 입력에 적용했다. 동작은 잘 됐는데, 컴포넌트가 언마운트될 때 타이머가 정리되지 않아서 "Can't perform a React state update on an unmounted component" 경고가 떴다. 원본 코드는 React 바깥에서 쓰이는 순수 JavaScript debounce였고, 컴포넌트 라이프사이클은 고려 대상이 아니었다.

매번 같은 패턴이었다. 코드 자체는 맞다. 하지만 내 맥락에서는 틀렸다.

세 단계: 복사, 이해, 적응#

이제 내가 따르는 원칙이 있다. 거창한 건 아니고, 복사-붙여넣기를 할 때 세 단계를 밟는 거다.

1단계: 복사. 이건 그대로다. 좋은 코드를 찾으면 복사한다. 바퀴를 재발명하는 데 시간을 쓸 이유는 없다.

2단계: 이해. 복사한 코드를 한 줄씩 읽는다. 각 줄이 왜 있는지 설명할 수 있는지 자문한다. 설명하지 못하는 줄이 있으면, 그 줄을 검색하거나 지워보거나 바꿔본다. 예를 들어 return () => { ws.close(); }에서 return이 하는 역할을 설명할 수 없으면, useEffect의 클린업 함수가 뭔지부터 찾아본다.

이 단계에서 시간이 꽤 걸린다. 원래 5초면 끝나던 복사-붙여넣기가 10분, 20분이 되기도 한다. 하지만 이 10분이 나중에 디버깅에 쓸 반나절을 아껴준다.

3단계: 적응. 원본 코드의 가정을 내 상황에 맞게 수정한다. 의존성 배열에 빠진 값은 없는지, 에러 처리가 되어 있는지, 클린업이 필요한지, 내 프로젝트의 컨벤션에 맞는지. 이 단계를 거치면 "복사한 코드"가 "내 코드"가 된다. 문제가 생겨도 디버깅할 수 있다. 왜 이렇게 작성했는지 설명할 수 있다.

복사 코드가 쌓여서 만들어진 것#

사실 이 이야기의 핵심은 복사 자체가 아니다. 이해하지 않고 넘어가는 습관이 쌓이면 어떻게 되는가에 대한 거다.

복사한 코드가 한두 개일 때는 괜찮다. 그런데 프로젝트가 커지면서 이해하지 못한 코드가 여기저기 쌓이기 시작하면, 어느 순간 코드베이스 전체가 블랙박스가 된다. 내가 작성한 코드인데 내가 설명하지 못하는 부분이 점점 늘어난다.

이런 코드베이스에서 버그가 터지면 공포스럽다. 어디가 문제인지 감도 안 잡힌다. 이 함수가 정확히 뭘 하는지 모르니까, 여기가 원인인지 저기가 원인인지 판단할 수 없다. 결국 전부 콘솔 로그를 찍어가면서 하나씩 확인해야 한다.

반면에, 모든 코드를 이해하고 있는 코드베이스는 다르다. 버그 리포트가 들어오면 "아, 아마 저기일 거야"라는 직감이 생긴다. 그 직감이 맞는 경우가 많다. 코드를 이해하고 있기 때문에 가능한 거다.

같은 팀 안에서의 복사#

외부에서 가져오는 코드만 문제가 아니다. 같은 프로젝트 안에서의 복사도 함정이 있다.

새로운 페이지를 만들 때, 기존의 비슷한 페이지를 복사해서 시작하는 경우가 많다. 빠르고 효율적인 방법이다. 문제는, 기존 페이지에 있던 코드 중 새 페이지에는 필요 없는 부분을 그대로 두는 거다. 안 쓰는 상태 변수, 필요 없는 API 호출, 관련 없는 이벤트 핸들러. "나중에 정리하지" 하고 넘어가는데, 그 "나중"은 오지 않는다. 그렇게 프로젝트 전체에 죽은 코드가 쌓인다.

더 나쁜 건, 기존 페이지에 있던 버그까지 복사되는 거다. A 페이지에서 발견 못한 버그가 B, C, D 페이지로 퍼진다. 나중에 A 페이지의 버그를 고쳐도, B, C, D는 여전히 같은 버그를 안고 있다. 복사의 원본을 추적하기 어렵기 때문에, 고쳐야 할 곳을 전부 찾아내는 것 자체가 일이 된다.

복사를 부끄러워하지 않기#

한 가지 더. 복사-붙여넣기를 부끄러워할 필요는 없다고 생각한다. 모든 코드를 처음부터 직접 작성해야 한다는 강박은 비효율적이다. 이미 좋은 코드가 있는데 왜 다시 쓰나.

다만, 복사한 코드에 대한 책임은 복사한 사람에게 있다. Stack Overflow에서 가져온 코드가 프로덕션에서 문제를 일으키면, 그건 원본 답변자의 잘못이 아니라 내 잘못이다. "Stack Overflow에서 이렇게 하라고 했는데요"는 변명이 안 된다.

동료와 코드 리뷰를 할 때도 이 기준을 적용한다. "이 코드 어디서 가져왔어요?"라고 물어볼 때, 출처를 물어보는 게 아니라 이해도를 확인하는 거다. "이 부분은 왜 이렇게 되어 있어요?"에 대답할 수 있으면, 복사든 직접 작성이든 상관없다.

가끔 후배 개발자들이 "검색 안 하고 코드 쓸 수 있으려면 얼마나 걸려요?"라고 물어본다. 답은 "영원히 안 된다"이다. 경력이 쌓여도 검색한다. 라이브러리 API를 매번 외우지 못해서, 가끔 만나는 엣지 케이스의 해법을 기억하지 못해서, 이전에 해본 적 없는 새로운 작업이어서. 달라지는 건, 검색한 결과를 이해하는 속도와 깊이다.

복사하되, 이해하고, 내 것으로 만들자. 그게 복붙 주도 개발의 함정에서 빠져나오는 유일한 방법이다. 거창한 방법이 아니라서 미안한데, 실제로 이게 전부다.

그리고 한 가지만 더. 이해하는 과정이 느리다고 조급해하지 않았으면 한다. 처음 보는 패턴을 완전히 이해하는 데 30분이 걸려도 괜찮다. 그 30분은 낭비가 아니라 투자다. 다음에 비슷한 패턴을 만나면 5분 만에 이해할 수 있다. 그리고 그 다음에는 직접 쓸 수 있게 된다. 복사에서 이해로, 이해에서 체득으로. 이 과정이 성장이다.