[생각] 코드 예쁘게 짜기

들어가며

태초에 기계어가 있었다. CPU는 일렬로 나열된 기계어를 읽고 적절한 동작을 수행하는 기계이다.

사람이 이를 편하게 다루기 위해 어셈블리가 생겨났다. 어셈블리는 무려 사람이 읽을 수 있다. 어셈블리는 기계어와 1:1 대응된다. 즉 추상화 수준은 기계어나 다름없다. 벨 연구소의 멋쟁이들은 어셈블리로 Unix를 짰지만, 쉽지 않은 일이다.

그래서 C언어가 나왔다. 무려 사람이 편하게 읽을 수 있는데 이식성도 좋다! 여기서부터 ‘컴퓨터 프로그램’이 ‘단순 명령의 집합’을 넘어서 ‘구조를 가진 하나의 시스템’으로 인식되기 시작했다.

구체화

현실의 문제는 추상적이다. 사람이 아무리 구체적으로 생각한다 한들, 기계 수준만큼 자세할 수는 없다. 반면 코드는 매우 구체적이어야 한다. 가령 ‘아침식사를 한다’라는 작업을 코드 레벨로 표현하려면, 아침은 몇 시인가?, 무엇을 준비해야 하나?, 어떻게 준비해야 하나?, 식사 시간은 얼마나 되나?, 무엇을 먹는가?, 먹는 순서는 어떻게 되나?, 어떤 집기를 사용하는가?, 어디에서 먹는가?, 앉아서 먹는가 일어서서 먹는가?는 물론, 더 자세한 물음들에 대답해야 한다.

만약 위의 물음에 모두 대답했다고 치자. 아침식사를 하려면 현지시각 오전 8시에, 빵과 우유를 준비하는데, 냉장고에서 꺼내어 그릇에 담아 식탁에 올려둔 후, 30분간, 빵과 우유를, 5초 간격으로 번갈아가면서, 손으로, 식탁에, 앉아서 먹어야 한다. 이를 코드로 표현해보자.

function have_a_breakfast() {
  assertTime(now(), '8:00 AM');

  prepare('', '우유', function() {
    const bread = refrig.get('');
    const milk = refrig.get('우유');

    table.place(bread, milk);
  });

  sit(table);

  repeatFor(function() {
    const bread = table.hold('');
    const milk = table.hold('우유');

    takeByHand(bread);
    table.release(bread);

    sleep('5s');

    takeByHand(milk);
    table.release(milk);

    sleep('5s');
  }, '30m');
}

의사 코드 수준으로 추상화시켜서 표현하였지만, 각 함수 내에는 또 상세한 구현이 잔뜩 들어가 있을 것이다. 코드 수준에서는 하나의 작업을 매우 상세하게 쪼개어 한 치의 오차도 없이 정확하게 표현해야 한다. 그렇게 해야만 코드가 기계 수준으로 번역되어 원하는 동작을 얻을 수 있다.

프로그래밍의 어려움

위 코드는 특정 상황에서의 특정 동작을 위해 작성되었다. 즉, 다른 경우는 고려되지 않았다는 것이다. 만약 식사 시간이 오전 9시로 바뀌거나, 메뉴가 바뀐다면, 코드를 다시 작성해야 할 것이다. 아침식사 뿐만 아니라 점심, 저녁식사까지 추가하고 싶다면 저 전체 코드를 복제한 후 조금씩만 수정해야 할 것이다.

이렇게 비슷한 코드가 곳곳에 퍼져있는 상태에서, 냉장고(refrig)의 사용법이 바뀐다면 어떻게 될까? 해당 코드가 존재하는 모든 곳에 가서 직접 한 줄씩 손으로 코드를 수정해야 할 것이다. 만약 수정 과정에서 누락된 코드가 있다면 이는 즉각적으로, 또는 아무도 모르게 서서히 애플리케이션의 안정성을 나락으로 떨어뜨릴 것이다.

이런 상황을 반복적으로 겪다 보면, 프로그래밍이라는 행위 자체가 원래 이리 어렵고 어지러운, 불쾌한 과정이라는 인식을 가지게 된다. 그럴 수 밖에 없다. 코드가 2천줄만 넘어가 한 눈에 볼 수 없게 된다고 해보자. 그 속에 들어있는 누더기 코드를 헤치고 지나가는 데에 개발 시간의 대부분을 사용하게 될 것이다. 다시 하고 싶지는 않을 것이다.

또 다른 어려움도 있다. 작성한 코드가 제대로 작동한다는 보장이 없다는 것이다. 어느 소스 파일에 한 줄의 코드를 추가하면, 그 코드가 잘 작동하는지 테스트하기 위해 새로운 main 함수를 작성하고 의존 모듈들을 가져와야 한다. 아니면 프로그램 전체를 실행하여 제대로 돌아가는 지 확인해야 한다. 짧은 프로그램이라면 할 만 하다. 하지만 한 줄의 코드를 시험하기 위해 수천 줄의 코드를 다시 컴파일하고 실행하는 과정은 생각만 해도 힘이 빠진다. 더군다나 DB나 네트워크를 사용하는 프로그램이라면 이 모든 요소가 준비되어 있어야 한 줄의 코드를 테스트할 수 있다.

프로그래밍은 그런게 아니야!

코딩의 불확실성과 불쾌함, 끊이지 않는 버그는 필연적인 것일까?

그렇다. 이는 필연적으로 발생한다. 하지만 이를 줄일 수 있다.

소프트웨어 위기 라는 용어가 있다. 소프트웨어 공학이 미성숙하던 1960년대 중후반에 처음 등장하였다.

소프트웨어 위기의 주요한 위기는 컴퓨터 성능이 몇수십배나 더 강력해졌기 때문입니다! 심하게 말하면: 컴퓨터가 없었을 때는 프로그래밍에는 전혀 문제가 없었습니다; 느린 컴퓨터 몇 개 뿐이었을 때는 프로그래밍이 조금 문제가 되었고, 이제는 거대한 컴퓨터에 프로그래밍도 비슷하게 거대한 문제가 되었습니다.
— 에츠허르 데이크스트라, 겸손한 프로그래머 (EWD340), Communications of the ACM

소프트웨어의 설계와 개발 방법론에 대한 이해가 부족하던 시절, 대규모 시스템을 작성하다 보니 수많은 문제가 나타난 것이다. 예산이 초과되고, 일정이 지연되고, 버그가 난무하였다.

이를 해결하기 위한 수많은 시도가 있었다. 그 중 품질 좋은 소프트웨어를 적은 비용으로 단기간에 만들어내는 단 하나의 방법은 존재하지 않았다. 하지만 소프트웨어 개발에 큰 도움을 줄 소프트웨어 공학 수단이 다수 개발되었고, 사용되어 왔으며, 검증되었다. 대표적으로 이런 것들이 있다.

설계 기법

  • 객체 지향 프로그래밍
  • 디자인 패턴
  • 컴포넌트 개발

개발 프로세스

  • 고속 응용 프로그램 개발
  • 테스트 주도 개발
  • 애자일 개발 프로세스

소프트웨어 외적인 것들

  • 이슈 트래킹
  • 버전 관리

현대에 접어들며, 소프트웨어 개발에 전문적으로 접근할 수 있는 공학적 토대가 보다 견고해졌다. 이를 잘 활용하면 주먹구구식 프로그래밍에서 벗어날 수 있다.

인식의 전환

코드를 일렬로 주욱 늘어놓는 것은 1960s 스타일이다 (절차지향).
프로시저를 분리하여 설계하는 것은 1970s 스타일이다 (구조적).
프로그램을 명령의 집합이 아니라 객체의 집합으로 보는 것은 1990s 이후 스타일이다 (객체지향).
2000년대 초반에는 SOLID(객체 지향 설계 5원칙)이 소개되었다.

상태가 없어(stateless) 부작용(side effect)이 제거된 함수지향, 프로그램을 데이터의 흐름으로 보는 반응형 등 여러 패러다임이 등장하였고 새로운 시도가 진행되고 있다.

최근에는 테스트 주도 개발이 완전히 정착하여 코드마다 상응하는 테스트 케이스를 작성하는 것이 당연시되고 있다. 대부분의 IDE가 코드 단위 테스트를 위한 기능을 지원한다.

자 그럼 어쩌자는건가!

이미 존재하는 기법들을 잘 활용해서 코드를 공예하듯이 다루자는 것이다.

한 번 짜놓고 내팽개쳐두지 말고, 짤 때 제대로 설계해서 짠다. 그리고 리팩토링을 멈추지 않는다. 리팩토링은 개발의 일부이다. 처음 짠 코드는 글쓰기로 따지면 초고이다. 초고를 그대로 퍼블리싱하는 사람은 없을 것이다. 코드 또한 마찬가지이다. 돌아간다고 다가 아니다.

코드를 일회성으로 사용하지 않는다. 코드를 짜는 데 사용하는 시간보다 읽는 데 사용하는 시간이 훨씬 더 길다. 코드를 새로 작성할 때에는 그 주변 코드를 반드시 참고하게 된다. 즉, 코드를 보기 좋게 작성하고 정리해서 읽는 데에 걸리는 시간을 단축하는 것이 개발 시간을 단축시키는 유일한 방법이다.

이제부터는 리팩토링에 적용할 수 있는 코드 레벨 소프트웨어 공학 기법들을 알아볼 것이다.

실전

이름 잘 짓기

제일 중요한 것 중 하나이다.

많은 개발자들이 변수나 함수의 이름을 짓는 데에 큰 공을 들이지 않는다. 코드가 타이핑되는 시점에서 상세한 구현은 이미 개발자의 머리에 들어 있다. 따라서 구현을 키보드에 털어넣는 것이 좋은 함수/변수 이름을 짓는 것보다 우선시되는 경우가 많다. 하지만 다른 사람이 그 코드를 본다면, 아니면 미래의 작성자가 그 코드를 다시 본다면, 그 때의 발상을 바로 이해할 수 있을까?

어떤 문제에 대한 기가막힌 해결 방법을 생각해 내어 handle이나 process, solve와 같은 함수를 만들어 구현을 채워넣었다고 해보자. 코드를 작성한 당시에는 타당한 선택이라고 생각될 것이다. 함수를 이미 알고 있어 이름과 코드를 보고 해석하는 절차가 필요 없기 때문에 그 이름이 얼마나 부적절한지 알지 못하기 때문이다.

handle이나 process, solve와 같은 중립적이고 범용적인 단어는 하나의 기능에 특화된 객체에 메소드로 넣는 경우 이외에는 사용하지 않는 것이 좋다.

시간이 흐르면 과거의 내가 현재의 나의 적이 된다. 과거의 나는 암호화된 함수 이름으로 현재의 나를 괴롭힐 것이다. handle과 같은 추상적인 함수 이름만 보고 그 복잡한 구현의 의도와 동작을 알아낼 수 있을까?

아래는 함수지향 언어의 map 또는 forEach와 비슷한 동작을 하는 코드의 예시이다.

function handle(a, f) {
  for (const b of a) {
    if (b && (b.a !== undefined ||
      b.a !== null ||
      b.b !== undefined ||
      b.b !== null)) {
      f(b);
    }
  }
}

바로 이해하기 힘들다.

function forEachElement(collection, action) {
  for (const element of collection) {
    if (element && (element.a !== undefined ||
      element.a !== null ||
      element.b !== undefined ||
      element.b !== null)) {
      action(element);
    }
  }
}

이름만 바꿔도 훨씬 낫다.

function forEachElement(collection, action) {
  for (const element of collection) {
    if (isValid(element)) {
      action(element);
    }
  }
}

지저분한 코드를 함수로 분리하면 더욱 깔끔하다. 복잡한 식(expression)보다는 isValid라는 이름이 훨씬 낫다.

테스트 주도 개발

리팩토링은 꺼려진다. 그 이유는, 일단 현재 코드가 잘 돌아가기 때문이고, 다른 하나는 혹시나 코드에 손을 댔을 때에 코드가 망가지지 않을까 하는 두려움 때문이다. 하나씩 살펴보자.

코드가 잘 돌아간다고 그대로 방치해두면 언젠가 프로그램 자체가 거대한 쓰레기가 되어 손 쓰지도 못하게 될 가능성이 매우 매우 크다. 코드를 처음 작성할 때에 미래 수정에 대비해 작성했는가? 재사용을 고려해 작성했는가? 읽기 쉽고 충분히 납득 가능한가? 초안부터 이를 모두 만족하는 코드를 짜는 것은 불가능하다. 따라서 작성한 후 방치한 코드는 높은 확률로 애물단지가 된다. 따라서 리팩토링은 필수이다.

잘 돌아가는 코드에 손을 대는 것이 부담스럽다면, 그 이유는 작은 수정마다 작동 여부를 점검하기 부담스럽기 때문일 것이다. 이는 테스트 자동화를 통해 해결할 수 있다. 만약 어떤 코드가 어떤 임무를 가지고 있다면, 그 코드가 임무를 잘 수행하는지 시험해주는 코드를 작성하면 된다. 이 테스트가 충분히 가볍고 잘 작동한다면 1초에도 3번씩 테스트를 수행해서, 단어 하나 단위로 내가 무슨 잘못을 했는지 즉각적으로 알아낼 수 있을 것이다.

이런 식인 것이다.

D라는 모듈을 사용하는 A 모듈이 있다. A가 사용하는 D 모듈을 D2 모듈로 교체하려고 한다. 만약 A의 기능을 시험하는 단위 테스트가 존재한다면, A의 구현에 변화를 주기 전에 먼저 현재 코드가 잘 돌아가는지 확인할 수 있다. 구현에서 한 줄을 추가하거나 수정한 다음 다시 테스트를 실행해서 방금 바꾼 부분이 잘 돌아가는지 바로 확인할 수 있다.

기능의 분리

하나의 객체, 또는 함수는 하나의 일만 해야 한다. [객체 지향 설계 원칙](https://ko.wikipedia.org/wiki/SOLID_(객체_지향_설계) 중 단일 책임 원칙 에 해당한다.

하나의 일만 하지 않는다면, 즉 이름값을 하지 못한다면 문제가 생길 수 있다. 예를 들어 getMenu()라는 함수가 있다면, 이는 Menu라 부를 수 있는 자료 구조 타입의 데이터를 가져와 반환할 것이 기대된다. 또한 몇 번이고 호출해도 같은 결과를 낼 것으로 기대된다. 그런데 사실 getMenu() 함수가 내부적으로 한 번 가져온 객체는 삭제하는 동작을 한다면, 두 번째 호출부터는 아무것도 반환하지 않을 것이다. 만약 같은 프로젝트에 참여하는 다른 프로그래머가 그런 사실을 모른 채 이 함수를 가져다 쓴다면 반환값이 비어있을 것임을 기대하지 못한 어느 곳에서 문제가 발생할 것이다.

하나의 객체가 하나의 일만 해야 테스트를 작성하기도 수월해진다. 사용자의 요청을 파싱하는 객체는 파싱만 해야지, 사용자에게 요청을 보내는 일까지 해서는 안된다. 객체의 기능이 많아진다면 객체(클래스)를 수정할 일이 많아진다. 단일 책임 원칙 위반이다. 또한 객체를 수정할 때마다 모든 기능을 시험하는 복잡하고 큰 테스트를 진행해야 하며, 이는 개발 속도를 크게 떨어뜨린다.

기능이 잘 분리되었는지는 함수의 부피를 통해 유추할 수 있다. 하나의 메소드 또는 함수의 길이를 10줄 미만으로 유지하는 것이 좋다. 만약 인자에 따라 branch가 나뉘어진다면, 각 경우를 처리하는 함수를 만들어 이를 호출하는 것으로 대체한다.

객체의 경우 멤버 변수가 10개를 넘어간다면 클래스를 분리하는 것을 고려해 보아야 한다. 아무리 큰 프로젝트라도 하나의 작은 클래스를 충분히 유지할 수 있다.

마치며

좋은 코드는 읽기 쉽고 테스트하기 용이한 코드이다. 두 조건을 만족하기 위해서는 위에 언급한 모든 기법들이 필요하다. 좋은 코드를 쓰는 것은 마치 좋은 글을 쓰는 것과 같다. 끊임없이 고민하고 계속 퇴고해야 한다. 돌아만 가는 코드는 완성된 코드가 아니다. 코드를 테스트하고 리팩토링을 통해 품질을 향상시키는 것 또한 개발 과정의 일부이다.

Reference

댓글