Beauty is mute deception. - Theophrastos

기원전, 테오프라스토스라는 사람은 '아름다움은 말 없는 속임수'라는 말을 했다. 언뜻보면 아름다움이란 사람들마다 비슷하게 생각하는 것 같으면서도 각자 생각하는 기준이 다르다. 아름다움은 보편적이면서도 개인적이기 때문에 명확한 기준을 내릴 수도 없고 모두를 만족시킬 수도 없다. 그래서 테오프라스토스는 실체를 알 수 없는 아름다움에 대해 속임수라는 박한 평가를 내린 것이다. 애초에 아름다움이란 추상적인 개념인데 기준을 정할 수 있을까?

필자는 개발자 모임을 가거나 강의 중 아름다운, 이쁜, 좋은 등 앞에 수식어가 붙는 코드에 대한 질문을 여러 차례 받았다. 그럴때마다 '가독성이 좋은', '유지보수가 쉬운', '재사용성이 높은' 등의 답변을 했지만 스스로도 만족스럽지 못한 답변이라 느낀적이 많았다. 앞서 테오프라스토스가 사기라 평가한 아름다움과 같이 코드 또한 무엇이 좋은가에 대한 명확한 기준을 정할 수 없다. 그러다보니 가볍게 은총알은 없다는 말로 넘어가곤 한다.

은총알은 없다라는 말이 은총알이다

은총알은 없다라는 말은 업계 전반적으로 굉장히 많이 쓰이는 표현이다. 맞는 말이지만 필자는 개인적으로 이 말은 굉장히 조심스럽게 사용해야 한다고 생각한다. 정말 좋은 해결 방법을 찾는 노력 대신 이 표현을 통해 넘어가는 경우가 많기 때문이다. 앞서 질문에 대해 '가독성이 좋은', '유지보수가 쉬운', '재사용성이 좋은'이라는 답변을 했지만 깊게 들어가면 결국 '상황에 따라 다르다'와 같은 표현으로 이어진다. 이는 결국 은총알은 없다는 말과 같은 말이다.

대체 아름다운 코드란 대체 무엇인가. 진지하게 개발자를 업으로 삼는 사람이라면 한 번쯤 고민해 본 적이 있을 것이다. 그렇지만 그 고민은 대게 일이 바빠 다음으로 넘기거나 정답은 없다는 결론에 이르게 된다. 필자 또한 그렇다. 그렇지만 이번 기회에 은총알은 없다와 같은 말로 회피하지 않고 진지하게 생각해 보기로 생각했다. 이때 고민하며 나온 생각을 글로 정리해 보고자 한다.

아름다움이란?

필자는 아름다운 코드를 정의하려면 먼저 아름다움이라는 것이 무엇인지 이해해야 한다고 생각했다. 아름다움은 사람이 느끼는 가치다. 이는 사람마다 다르게 느낄 수 있으며 시대에 따라 변할 수 있다. 그리고 대상에 따라서도 기준이 다르다. 다음 이미지를 참고해 보자.

좌측부터 고흐의 별이 빛나는 밤, 몬드리안의 컴포지션, 자연 풍경 사진과 오일러 등식이다. 이 네 가지는 큰 연관이 없어 보이지만 많은 사람들이 아름다운 것으로 생각한다는 공통점이 있다. 그렇다면 이 네 가지가 모두 아름다운 것이라면 사람이 아름답다 느끼는 기준이 무엇일까? 필자는 여러 자료를 조사하며 나오는 감정에 대한 단어를 카테고리화 했더니 놀라움 / 새로움 / 안정성 / 편안함 / 단순성으로 정리가 됐다.

앞서 말한 다섯 가지는 아름다움에 대한 단어로 자주 나오는 것들을 한 단어로 묶어 일반화한 것이다. 이를 다시 둘로 나눠보면 놀라운 아름다움자연적 아름다움으로 나눌 수 있다. 위 이미지에 나온 네 가지를 분류하면 다음과 같다.

몬드리안의 컴포지션은 새로운 패러다임을 제시했기에 놀라운 아름다움으로 분류했다. 그리고 자연 풍경은 말 그대로 자연적 아름다움으로 분류했다. 고흐의 별이 빛나는 밤은 자연 풍경을 묘사했지만 현실 세계와는 다른 표현 방식을 사용했기에 놀라운 아름다움과 자연적 아름다움 중간으로 분류했다. 마지막으로 오일러 등식은 많은 수학자들이 경이롭게 바라보기 때문에 놀라운 아름다움으로 분류했다.

사실 필자는 몬드리안의 컴포지션과 오일러 등식을 보며 아름다움을 크게 느끼지는 못했다. 그저 다른 이들이 느꼈다는 것을 받아들였을 뿐이다. 왜 필자는 아름다움을 느끼지 못했을까? 그 이유는 깨달음이 없었기 때문이다. 놀라운 아름다움을 알기 위해서는 사물에 대한 이해가 필요하며 그 말은 즉, 이해할 수 있는 기본적인 지식이 필요함을 뜻한다. 여러분은 e+1 = 0라는 식을 봤을 때 어떤 감정을 느꼈는가? 누군가는 아름답다 느꼈을 것이고 누군가는 관심이 없거나 두려움을 느꼈을 것이다. 이는 많은 이들이 수학을 두려워하는 이유기도 하다. 정리하면 이해할 수 없는 사물에 대해선 아름다움 대신 두려움을 느낀다는 것이다.

왜 우리는 이해할 수 없는 것들에 대해 두려움을 느끼게 됐을까? 시간이 괜찮다면 다음 영상을 시청해보자.

쿠자르게작트

영상 중 앞 부분 내용을 요약하자면 인류는 안전하고 도움이 되는 것에 아름다움을 느꼈기에 아름다움은 생존을 위한 것이며 이에 대한 사고가 지금까지 발전했다고 한다. 따라서 우리가 모르는 것이나 이해할 수 없는 것에 대해선 생존을 위협하기 때문에 불쾌감과 더불어 두려움까지 느끼는 것이라 할 수 있다. 이는 코드에도 그대로 반영이 된다. 우리가 이해할 수 없는 코드에 대해 불안함을 느끼고 그것이 아름답지 못한, 더러운 코드라고 느끼게 된다.

여기서 한 가지 주의할 점으로 본인이 지식이 부족해서 더럽다고 느끼는지, 정말 이해하기 어렵게 작성되어서 더럽다고 느끼는지 곰곰히 생각할 필요는 있다.

아름다운 코드

코드는 왜 아름다워야 할까? 그냥 일단 돌아가도록 만드는게 더 좋다라고 생각할 수 있다. 물론 필자도 제일 중요한 것은 동작하는 코드라고 생각한다. 그렇지만 그에 못지않게 코드를 아름답게 만드는 것도 중요하다. 왜냐하면 코드는 혼자 보는 것이 아니기 때문이다. 코드는 언제든 다른 사람과 함께 볼 수 있고 코드 소유권이 나에게서 다른 사람으로 이관될 수도 있다. 따라서 코드를 아름답게 만드는 행위는 배려이자 팀을 위한 활동이라 할 수 있다. 그리고 만에 하나 혼자 일을 하더라도 코드를 아름답게 만드는 것은 중요하다. 이는 나 자신의 성장을 위한 드라이브의 원인이 될 수 있고 미래에 코드를 다시 들여다보는 나를 위함이기도 하다. 미래의 나는 사실상 타인이라고 봐도 무방하다.

아름다운 코드에 대한 정의

이에 대한 정의는 사람마다 다를 수 있지만 필자가 정의내린 궁극적으로 아름다운 코드는 읽으며 걸리는 부분이 단 하나도 없는 코드를 말한다.

필자는 코드 또한 앞서 이야기했던 놀라운 아름다움과 자연적 아름다움으로 나눌 수 있다고 생각한다. 필자는 그 비율이 2:98로 극명하게 나뉜다고 생각한다. 여기서 놀라운 아름다움의 비율이 2%로 코드에서의 놀라운 아름다움이라는 것은 우리가 생각하지 못했던 방식으로 깔끔하게 문제를 해결하는 경우에만 느낄 수 있다고 생각하기 때문이다. 따라서 주니어 시기에는 비교적 자주 느낄 수도 있겠지만 점점 경력이 쌓일 수록 느끼기 힘들어진다. 그렇기 때문에 협업을 중시하는 실제 업무에선 놀라운 아름다움을 생각하기 보다는 대체로 공학적으로 자연스러운 아름다움을 추구하는 것이 좋다고 생각기에 이 글에선 자연스러운 코드에 대해서 다룰 것이다.

필자는 자연스럽게 아름다운 코드가 될 수 있는 조건을 다음과 같이 정의내렸다.

  • 사회적
  • 신뢰적
  • 선언적
  • 선형적

이 네 가지 조건은 다음과 같이 코드의 두 가지 요소를 구성한다.

사회적이고 신뢰적인 코드는 안정성을 보장한다. 그리고 선언적이고 선형적인 코드는 코드의 심미성을 보장한다. 어떻게 보면 너무 당연한 이야기일 수 있다. 하지만 우리가 자연 풍경을 볼때 편안한 마음을 느끼듯 코드를 볼 때 위화감을 못느끼고 편안한 감정을 느꼈다면 그것이 아름다운 코드라 할 수 있다.

사회적인 코드

사회적인 코드란 것은 본인뿐만 아닌 주변 상황을 모두 고려한 코드를 의미한다. 이는 언어의 사회성1과 유사하다. 보통 코딩을 한다는 것은 수학적인 사고와 엮이는 경우가 많지만 필자는 언어적인 사고 또한 코딩 능력에 중요한 부분이라 생각한다.

관습이나 규칙, 해야 하는 일인 미션을 따르면 사회적인 코드가 될 수 있다. 먼저, 관습은 패러다임이나 언어나 프레임워크의 공식 문서 스타일 가이드, 커뮤니티 표준이나 언어의 특성을 고려하는 것을 말한다. 대표적으로 Pythonic이라는 말이 있다. 이는 얼마나 Python스럽게 코드를 작성했는지를 말하는데, 이런 것이 관습을 따랐는지를 의미한다.

다음으로 규칙을 따랐는지를 따질 수 있다. 사내 네이밍 규칙이나 설계 규칙, 사내에서만 사용되는 스타일 가이드 등을 말한다. 보통 스타일 가이드는 관습이나 커뮤니티 표준을 따르는 경우가 많지만, 가끔 회사 차원에서 재정의할 때도 있다. 예를 들어, Python은 보통 snake_case를 사용하지만 camelCase를 쓰겠다고 규칙을 정하면 그것을 따를 수 밖에 없다.

마지막으로 미션은 해결해야 하는 문제를 의미한다. 문제에 따라 가독성보다는 성능이 우선일 수 있고 복잡한 도메인에 맞춰 코드 또한 복잡해질 수 있다. 너무 당연한 말이지만 미션이 마음에 안든다고 미션 자체를 바꿔버리거나 미션을 무시하고 코드를 작성하면 안된다.

따라야할 우선 순위

대표적으로 나타나는 사례 중 하나로 문법 설탕(Syntactic Sugar) 사용이 있다. 다음 문법 설탕 예시를 살펴보자.

# Elixir
def foo do
  " Hello, World!   " |> String.trim() |> String.upcase()
end
# Python
def foo():
  return [i ** 2 for i in range(10) if i % 2 == 0]

def bar(string):
  return string[::-1]
// Kotlin
infix fun Int.`**`(value: Int): Int = this.toDouble().pow(value).toInt()

fun main() {
  print(2 `**` 4)
}

위 코드는 각 언어에서 제공하는 설탕 문법이다. Kotlin에선 infix 표기법과 확장 함수를 이용하여 간단한 DSL을 정의했다. 문법 설탕은 이름처럼 여러 복잡한 것들을 언어적으로 추상화시켜 표현하는 것이니 잘 사용하면 생산성이 올라간다. 하지만 반대로 합의되지 않은 문법 설탕은 독이 될 수 있다. 언어에서 기본적으로 제공하는 문법이라면 그나마 많은 사람이 알고있을 확률이 높지만 DSL(Domain Specific Language)을 지원하는 언어에서 이를 사용하여 문법 설탕을 만든다면 혼란이 생길 수 있다.

스스로 위 예제 코드에서 나온 표현을 다 이해하고 있는지 확인해보자. 그리고 일하다가 처음 마주쳤다고 상상해보자. 어쩌면 심미적으론 보기 좋았을지도 모르지만 당혹스러움을 느꼈을 것이다. 그렇기 때문에 사회적으로 합의되지 않은 코드는 결코 아름다워질 수 없다.

설탕 너무 좋아하면 피본다

앞서 언어의 사회성과 유사하다 한 것처럼 보편적인 규칙을 무시하고 혼자 엇나가면 그것이 옳은 방향이라 하더라도 다른 이들에게 불편을 줄 수 있다는 것을 고려하자. 급진적인 것보다 서서히 바꿔나가는 방향이 좋다. 어떻게 보면 이런 것이 건전한 정치라고 할 수 있다.

신뢰적인 코드

신뢰적인 코드란 말은 너무 당연해 보인다. 누군가 만든 코드를 신뢰할 수 없다면 들어가서 확인해야 하는 코드가 되고 불편함을 느끼게 된다. 불편한 마음을 느끼게 된다면 필자가 세운 기준에선 아름답지 못한 코드라 할 수 있다. 신뢰성에 대한 측정은 여러 방법이 있지만 보통 사이드 이펙트가 존재하는지, 이것이 알려져 있는지, 예외가 있다는 것을 알 수 있는지, 결함이 있는지, 순수함수인지 멱등성2이 있는지 등을 통해 알 수 있다. 참고로 앞서 말한 것과 별개로 당연히 버그나 결함은 치명적이기 때문에 없어야 한다. 다음 예시를 살펴보자.

// 🔴 - 실행 시점에 따라 함수 결과가 달라진다
var global = 0
fun sum(a: Int, b: Int): Int {
  global += 1
  return a + b + global
}

// 🟢 - 같은 입력에 대해 같은 출력을 보장한다
fun sum(a: Int, b: Int): Int {
  return a + b
}

첫 번째 코드를 자세히 살펴보면 실행 시점에 따라 결과가 달라진다는 것을 알 수 있기 때문에 신뢰할 수 없다. 반면 두 번째 코드는 순수 함수라 어떤 입력이 들어오더라도 같은 결과를 보장한다. 따라서 신뢰할 수 있다.

fun divide(a: Int, b: Int): Int {
  return a / b
}

그리고 코드엔 사이드 이펙트가 존재할 수 있다. 예를 들어 위 함수에서 b에 0 값이 들어와 에러가 발생한다면 사이드 이펙트가 있다고 볼 수 있다. 그렇지만 요즘에 와선 사이드 이펙트가 전혀 없는 프로그램을 만드는 것은 불가능하기 때문에 이를 최대한 신뢰성 있게 제공하기 위해 문서화를 작성하거나 예외가 있음을 알리는 방법을 사용할 수 있다.

선형적인 코드

선형적인 코드는 가독성에 좋다. 이를 더 풀어서 설명하자면 코드를 읽을 때 위에서 아래로 한 번만 읽어도 되는 것을 의미한다. 뇌과학적으로 우리가 사고할 때 사용하는 영역인 작업 기억 공간이 처리하기 쉬워진다. 다음 예시를 살펴보자.

// 🔴 - 루프를 머리에서 연산해야 한다
fun binaryToDecimal(input: String): Int {
  var decimal = 0
  var binary = input
  var power = 0

  while (binary.isNotEmpty()) {
    val lastChar = binary.last()
    binary = binary.dropLast(1)
    if (lastChar == '1') {
      decimal += 2.0.pow(power).toInt()
    }
    power += 1
  }
  
  return decimal
}

// 🟢 - 위에서 아래로 한 번만 읽으면 된다
fun binaryToDecimal(input: String): Int {
  return input
    .reversed()
    .mapIndexed { index, char ->
      if (char == '1') {
        2.0.pow(index).toInt()
      } else {
        0
      }
    }
    .sum()
}

첫 번째 코드는 루프를 돌리기 때문에 위아래로 왔다 갔다 해야 한다. 반면 두 번째 코드는 어떤 행위를 하는지 읽기만 하면 된다. 이는 후술할 선언적인 코드 개념과 함께 사용되면 효과가 더 좋다.

선언적인 코드

선언적인 코드는 가독성에 좋다. 선언적인 코드라는 말이 조금 헷갈리게 느껴질 수 있다. 간략하게 표현하자면 코드에 무엇을 하는지 정확히 알리는 것으로 생각하면 편하다. 이를 위해서는 함수, 클래스, 변수명 등에 적절한 이름을 지정해 주는 것이 좋다.

왜 선언적인 코드가 가독성에 도움이 될까? 우선 사람의 뇌는 너무나도 휘발적이다. 얼마나 휘발적인지 실험을 하나 해보자. 다음 문장을 5초간 본 후 어떤 문장이었는지 기억해내면 된다.

abk mrtpi gbar

5초면 생각보다 짧기 때문에 다 외우지 못한 사람이 꽤 많을 것이다. 그럼 이번엔 다음 문장을 5초간 본 후 어떤 문장이었는지 기억해보자.

cat loves cake

이번엔 아주 쉬웠을 것이다. 아마 5초가 너무 길었을지도 모른다. 이 또한 뇌과학적인 부분이 존재한다. 우리의 뇌에는 단기 기억 공간(STM)이라는 부분이 존재한다. 이 부분은 정보를 일시적으로 저장하는 공간이다. 말 그대로 정보를 일시적으로 저장하기 때문에 다른 정보를 찾는 과정을 거치거나 시간이 지나면 잊게 된다. 앞 abk mrtpi gbar는 3개의 단어와 9개의 서로 다른 문자로 이루어져있다. 이는 STM의 용량을 훌쩍 넘어서는 크기기 때문에 외우기 어렵다. 반면, cat loves cake는 우리가 이미 알고있는 단어들로 이루어져있어 기억해야 할 항목이 3개 밖에 없다. STM 한도 이내기 때문에 외우기 쉽다.

따라서 선언적인 코드는 코드를 읽는데에 도움이 되고 이해하는 것이 쉬워진다. 따라서 마음이 편해진다고 볼 수 있다. 다음 예시도 살펴보자.

// 🟡 - 내용을 한 번 더 읽어야 한다.
fun main() {
  print(
    generateSequence(1) { it + 1 }
      .take(10)
      .filter { it % 2 == 1 }
      .fold(0) { a, b -> a + b }
  )
}

// 🟢 - 함수명에서 의도를 바로 파악할 수 있다.
fun main() {
  print(
    generateSequentialNumber(10)
      .filterOdd()
      .sum()
  )
}

첫 번째 코드도 충분히 선언적이지만 아래 코드와 비교하면 아래 코드가 더 명확하다는 것을 알 수 있다. 5초 안에 파악하느냐 1초 안에 파악하느냐 정도의 차이라 느낄 수도 있지만 이러한 차이가 모여서 큰 차이를 이룬다고 생각한다.

마음가짐

지금까지 아름다운 코드는 무엇으로 구성되는지를 살펴보았다. 그렇지만 이 모든 내용을 다 지키면서 코드를 만들 수 있을까? 아마 힘들 것이다. 따라서 오히려 아름다운 코드를 대하는 마음가짐이 더 중요하다고 생각한다. 필자가 생각하기로 두 가지를 생각해야 한다.

첫째는 아름다운 코드라는 것은 한 번에 뚝딱 나오는 게 아니라는 점이다. 현실엔 다양한 문제가 있으며 여러 명과 일하다 보면 정말 생각지도 못한 이슈가 발생한다. 그러다 보면 알면서도 보기 좋지 않은 코드를 작성할 때가 많다. 중첩된 if, 적절하지 못한 네이밍, 통일성 없는 규칙 등이 있을 수 있다. 그리고 다른 관점에서도 아름다운 코드가 아닐 수 있다. 바로 오버 엔지니어링이다. 무조건적인 디자인 패턴 적용이나 너무 과도한 추상화 등을 예로 들 수 있다.

두 번째는 완벽하게 아름다운 코드라는 것은 흔하지 않다는 점이다. 앞서 설명한 사회적, 신뢰적, 선형적, 선언적 네 가지를 모두 지킨 코드는 생각보다 많지 않다. 사이드 이펙트는 어쩔 수 없이 필요하며 크기가 적당한 함수, 객체만 작성한다는 것은 이상에 가깝습니다. 또한, 성능이 중요하다면 선형적이거나 선언적이지 않을 수도 있습니다. 이는 소규모 조직이며 빠르게 발전하는 소프트웨어일수록 더 그렇다.

따라서 한 번에 만드는 것보다 두 가지를 더 중요하게 생각해야 한다. 바로 점진적인 코드 개선코드 수식이다. 참고로 여기서 말하는 수식이란 꾸며주는 것을 말한다.

점진적 개선

점진적 개선은 한 번에 완벽하게 코드를 작성하지 말고 괜찮은 품질을 계속 유지시키는 것을 말한다. 코드 개선이라하면 결국 리팩터링이라 할 수 있는데, 주변 코드는 계속 바뀌기 마련이고 그에 따라 리팩터링은 계속 필요하다. 완벽한 코드도 언젠가 훼손되기 때문에 너무 많은 시간을 들이지 않고 점검과 개선을 반복하며 적당한 품질을 유지시키는 것이 좋다. 앞서 설명한 네 가지 요소 중 하나가 조금 부족해도 괜찮다는 의미라고 할 수 있다. 필자가 생각하기로 100%가 가장 아름다운 코드라면 대략 70%~80% 정도의 품질을 유지하는 것이 중요하다.

점검과 개선의 무한 반복

마치 사용하지 않는 기계가 금새 녹슬 듯, 코드 품질 또한 시간이 지날 수록 낮아진다. 따라서 품질을 유지하기 위해선 반복적인 코드에 대한 점검과 개선이 필요하다. 언제 점검과 개선을 해야할까?

코드 오너십이 흐려질때

작성된 코드의 주인은 누구일까? 이에 대한 표현을 코드 오너십이라 부르는데 이 코드 오너십은 다양한 형태로 존재한다. 마틴 파울러가 정리한 코드 오너십 아티클을 참조하면 코드 오너십의 형태는 세 가지로 분류된다.

먼저 Strong code ownership은 코드 베이스를 모듈 단위로 나눈 후 각 모듈에 대한 담당자를 지정하는 것이다. 이 경우 개발자는 자신이 배정받은 모듈만 수정할 수 있다. 만약 다른 모듈에 수정이 필요한 경우 모듈 소유자가 수정을 마칠 때까지 기다려야 한다. 두 번째로 Weak code ownership은 각 모듈에 대한 담당자가 존재하지만 다른 개발자가 수정하는 것이 허락된다. 마지막으로 Collective code ownership은 코드 베이스 전체는 개인이 아닌 팀에 속한다는 개념이다. 따라서 팀 내 누구라도 수정하는 것이 가능하다.

대부분의 회사가 어떠한 형태로든 애자일 기반의 프로세스를 따르는 요즘은 이러한 개념을 모르더라도 자연스럽게 Collective code ownership을 따르는 경우가 많다. 코드가 개인이 아닌 팀에 속한다는 말은 이상적이지만 그럼에도 불구하고 점점 코드 오너십이 흐려지는 영역이 생길 수 밖에 없다. 다음과 같은 상황이 있을 수 있다.

  • 작성한 코드가 오랫동안 방치되어 누구도 기억 못하는 경우
  • 코드를 작성한 사람이 회사를 떠난 경우
  • 아무나 접근해서 수정하는 공통 모듈인 경우

세 가지 모두 흔하게 발생하는 일이다. 첫 번째 사례처럼 작성한 코드가 오래 방치 되는 경우는 이미 안정적으로 구축된 코드거나 이미 업계 전반적으로 패턴화되어 수정할 일이 별로 없는 사례일 가능성이 높다. 사실 이런 경우엔 앞으로도 큰 로직 자체를 수정할 일이 없을 수 있지만 의존성 버전 업그레이드나 인터페이스 변경, 보안적인 문제 등 부가적인 부분에서 수정이 발생할 가능성이 있으니 혹시 모를 상황을 대비하여 문서화를 해두면 금방 코드 오너십을 회복할 수 있다.

두 번째 사례는 한 사람에게 크게 의존하고 있는 경우에 흔하게 발생한다. 난이도가 높은 작업을 잘하는 개발자 한 사람에게만 맡기는 경우가 대표적인 사례라 할 수 있다. 특히 작은 조직에서 이러한 일들이 많이 발생한다. 이런 경우는 조금씩이라도 다른 개발자에게 코드 오너십을 부여할 수 있도록 장치를 마련해야 한다. 따로 분석할 시간을 가지거나 코드 리뷰를 통해 코드를 읽을 수 있도록 하는 것이 좋다. 혹은 페어 프로그래밍을 이용하는 것도 괜찮은 방법이다. 이를 통해 갑작스럽게 코드 오너십이 사라지는 것을 방지할 수 있다.

떠나기 전에 대비해두자

마지막 사례는 역설적으로 교통 정리를 하는 책임자가 없어서 발생하는 사례다. 예를 들어, 회사에서 필요할 때마다 공통적인 유틸 로직을 추가하는 공통 모듈이 있다고 가정해보자. 이때 여러 개발자가 제품을 개발하며 필요한 것이 생길 경우에 공통 모듈에 무언가를 추가할 경우 자칫 잘못하면 중복 코드가 발생하거나 불필요하게 거대해지는 등 쓰레기통 같은 모듈이 될 수 있다. 이런 문제가 발생하는 이유는 각 개발자가 자신이 담당하는 업무에만 관심을 두고 공통 모듈에 큰 관심을 안두기 때문이다. 따라서 나중에 왜 공통 모듈이 엉망이 됐는지 아무도 모르는 경우가 생긴다. 이런 경우엔 오히려 Strong code ownership처럼 확실하게 구분을 짓는 것이 좋다. 이를 통해 공통 모듈을 관리하는 팀이 생기고 해당 팀이 책임을 지는 것이다. 이를 통해 코드 오너십을 확실하게 지킬 수 있다.

악취를 느낄 때

코드를 보다보면 불편함을 느낄 때가 있을 것이다. 마틴 파울러의 리팩터링에선 이러한 현상을 코드에서 악취가 난다고 표현한다. 앞서 설명한 코드 오너십이 흐려지는 것이 대체로 사회적인 부분을 건드렸다면 악취가 나는 것은 가독성이나 신뢰적인 부분을 건드렸다고 볼 수 있다. 냄새가 조금 난다고해서 무조건 리팩터링을 할 필요는 없다. 하지만 개선할 점이 보인다면 주석을 통해 메모하거나 이슈를 생성해두는 것이 좋다. 간단하게 수정할 수 있다면 겸사겸사 바로 수정하는 것도 좋다. 이를 통해 코드 품질을 점진적으로 개선할 수 있다.

불편함을 느끼는 부분으로 다음과 같은 경우가 있을 수 있다.

  • 코드의 목적을 알 수 없는 경우
  • 코드를 읽기 힘든 경우
  • 너무 많은 클래스, 함수를 참조해야 하는 경우
  • 잘 돌아가는 코드인지 의심이 되는 경우

정리하면 앞서 소개한 아름다운 코드의 네 가지 요소 중 사회적 부분을 제외한 나머지가 부족하다면 악취가 난다고 할 수 있다. 이를 틈틈히 확인해보자.

코드 수식

코드 수식은 코드를 아름답게 보이도록 꾸며주는 것을 말한다. 여러가지 방법이 있지만 주로 쓰이는 것은 테스트, 코드 리뷰, 문서화, 주석이 사용된다.

테스트

테스트는 코드를 더 신뢰할 수 있게 만들어준다. 테스트가 작성된 부분에 대해서는 작동을 보장하고 테스트 자체가 문서가 될 수 있다. 다음과 같이 회원가입 로직이 있다고 가정해보자.

fun signUp(email: String, password: String): User {
  if (email.isEmpty() || password.isEmpty()) {
    throw IllegalArgumentException("이메일과 비밀번호는 필수입니다.")
  }

  if (!email.contains("@")) {
    throw IllegalArgumentException("올바른 이메일이 아닙니다.")
  }

  if (password.length < 8) {
    throw IllegalArgumentException("비밀번호는 8자 이상이어야 합니다.")
  }

  if (User.findByEmail(email) != null) {
    throw IllegalArgumentException("이미 가입된 이메일입니다.")
  }

  return User.save(email, password)
}

만약 signUp 함수를 직접 들여다본 것이 아니라면 위와 같은 예외가 있다는 것을 알 수 없다. 하지만 테스트가 있다면 다음과 같이 알 수 있다.

@Test
fun `이메일과_비밀번호는_필수`() {
  assertThrows<IllegalArgumentException> {
    signUp("", "")
  }
}

@Test
fun `올바른_이메일_아님`() {
  assertThrows<IllegalArgumentException> {
    signUp("kciter@naver.com", "1234567")
  }
}

@Test
fun `이미_가입된_유저`() {
  assertThrows<IllegalArgumentException> {
    signUp("kciter@naver.com", "12345678")
    signUp("kciter@naver.com", "12345678")
  }
}

@Test
fun `회원가입_성공`() {
  val user = signUp("kciter@naver.com", "12345678")
  assertNotNull(user)
}

테스트 코드 또한 들여다봐야 한다고 할 수 있지만 함수를 직접 들여다보는 것보다 피로도는 더 낮다. 따라서 테스트는 코드를 수식하여 더 아름답게 만들어준다.

코드 리뷰

코드 리뷰는 여러 사람의 검증을 통해 코드의 신뢰를 높일 수 있고 자연스럽게 코드 오너십 전파를 하기 때문에 코드의 사회성도 높일 수 있다. 주의할 점으로 무조건적인 코드 리뷰는 개발 진행에 병목이 될 가능성도 높다. 팀 내 문화에 따라 다르겠지만 필자는 적절한 사례에만 코드 리뷰를 하는 것이 좋다고 생각한다. 예를 들어, 다음과 같은 경우가 있다.

  • 협업 등의 이유로 상황 공유가 필요할 때
  • 정말 중요한 로직이라 신뢰성을 높여야 할 때
  • 교육이 필요할 때
  • 마음의 안정감이 필요할 때

이 외에도 팀 내 사정에 따라 다양한 경우가 있을 수 있다. 만약 코드 리뷰가 불편하게 느껴진다면 생산성이 저해되고 있다는 조짐일 수 있다. 그런 경우엔 언제 코드 리뷰를 해야하는지 다시 생각해보자.

문서화

문서화는 코드를 더 이해할 수 있게 만들어준다. 문서화를 통해 코드의 의도를 파악할 수 있고 코드 오너십이 흐려지는 것을 방지할 수 있다. 문서화를 해야하는 시점은 다른 개발자가 업무에 투입되어 코드 오너십을 가져야 할 때 해당 코드에 대한 맥락과 설계, 규칙을 알아야 할 때다. 문서화를 할 때는 단순히 글로 설명할 수도 있지만 UML과 같은 도구를 이용하여 도식화를 하면 더 이해하기 좋다.

UML을 통해 설계나 코드를 표현할 수 있다

이때, 문서를 너무 정교하게 만드는 것은 그다지 좋은 생각은 아니다. 도식화를 하더라도 너무 과하지 않게 적정 수준을 찾아 설명하는 것이 오히려 더 이해하기에 좋다.

주석

주석은 문서의 간략한 버전이라 볼 수 있다. 주석을 통해 코드의 의도를 파악하는 것도 가능하다. 문서의 하위 호환이라 생각할 수도 있지만 어쩔 수 없이 생기는 복잡한 코드 영역은 주석으로 설명하는 것이 더 좋다. 코드를 작성하다보면 잘 작성하고 싶어도 변수명, 함수명 등이 애매할 수 있다. 그리고 당장 리팩터링할 시간이 부족한 경우에도 유용하다. 즉, 주석은 코드를 더 잘 이해할 수 있도록 돕는다.

주석을 사용하기 적절한 상황은 다음과 같다.

  • 예외 상황을 알릴 때
  • 복잡한 도메인을 설명할 때
  • 확장하기 힘든 기능을 억지로 구현할 때
    • 예를 들어, 외부 라이브러리를 사용하다보면 내가 원하는 기능을 제공하지 않아서 여러 트릭을 이용해 구현하는 경우가 있다. 이럴 때는 주석을 통해 왜 이런 구현을 했는지 설명하는 것이 좋다.
  • 시간이 부족하여 하드 코딩된 부분이 있을 때
    • 이상하게 들릴 수 있지만 흔한 사례다. 오즈의 마법사 MVP3가 이런 사례에 해당할 수 있다.
  • 애니메이션 표현을 위해 매직 넘버를 사용할 때
    • 화려하고 섬세한 애니메이션을 위해 규칙적이지 않은 수를 사용할 때가 많다.

리팩터링 기법

마지막으로 사용할 수 있는 몇 가지 리팩터링 기법을 알아보자. 이 글에서는 아주 간단한 것 몇 가지를 소개한다.

규칙 정하기

일은 혼자하는 것이 아니다. 코드를 작성하는 것도 마찬가지다. 따라서 코드를 작성할 때 팀 내에서 합의된 규칙을 지키는 것이 중요하다. 따라서 리팩터링을 진행할 때는 사회적인 규칙을 지켰는지 확인할 필요가 있다.

유명 웹툰 미생의 한 장면

그럼에도 불구하고 더 좋은 방향이 있을 수 있다. 역설적으로 규칙을 지키기만 한다면 발전 없이 고여서 썩은 물이 될 수 밖에 없다. 만약 더 좋은 방향을 알고 있다면 스며들게끔 만들자. 다음과 같은 방법을 사용할 수 있다.

  • 팀 내 발표
  • 팀 내 스터디
  • 회고 및 리뷰

위 세 가지 방법의 공통점은 모두가 듣는 시간이라는 것이다. 규칙을 바꾸기 위해선 모두가 알아야하며 합의가 되어야 한다. 이 과정에서 스트레스를 받을 수 있지만 더 좋은 방향을 찾을 수 있다면 그만한 가치가 있다.

단계 분리하기

주로 프로그래밍 로직은 대체로 단계로 나눠서 작성하는 것이 가능하다. 예를 들면 전처리, 계산, 후처리와 같은 형태다. 이렇게 단계를 분리해서 코딩하며 이해하기 쉽다. 예를 들어 다음 예제를 살펴보자.

// 🔴 - 전처리, 계산, 후처리가 섞여있다
fun fibonacciSum(n: Int): Int {
  var sum = 0
  var a = 0
  var b = 1
  var c = 0
  for (i in 0 until n) {
    // 데이터 생성과 동시에 계산
    if (i <= 1) {
      sum += i
    } else {
      c = a + b
      sum += c
      a = b
      b = c
    }
  }
  return sum
}

fun main() {
  print(fibonacciSum(10)) // 출력
}

fibonacciSum 함수는 입력받은 수 만큼의 피보나치 수열의 합을 구하는 함수다. 이 함수는 데이터를 생성하고 계산하는 것이 섞여있어 코드를 읽을 때 단계를 구분지어 생각하기 어렵다. 그리고 계산 로직이 목적에 완전히 의존되어 있어 재사용이 불가능하다. 그럼 다음 코드를 살펴보자.

// 🟢 - 전처리, 계산, 후처리가 분리되어 있다
fun makeFibonacciList(n: Int): List<Int> {
  val list = mutableListOf(0, 1)
  for (i in 2 until n) {
    list.add(list[i - 2] + list[i - 1])
  }
  return list
}

fun listSum(list: List<Int>): Int {
  return list.sum()
}

fun main() {
  val list = makeFibonacciList(10) // 데이터 생성
  val sum = list.sum() // 로직
  print(sum) // 출력
}

위 코드는 데이터 생성과 로직이 분리되어 있다. 이처럼 단계를 분리하면 코드를 읽기 쉽고 이해하기 쉬우며 코드를 재사용하기 쉽다. 예를 들어, makeFibonacciList 함수는 피보나치 수열을 생성하는 함수이기 때문에 다른 곳에서도 사용할 수 있다. 또한, makeFibonacciList 함수를 통해 생성된 데이터를 가지고 다른 로직을 만들 수도 있다. 위 예제 외에도 함수 로직이 길고 복잡하다면 로직을 보조 함수로 분리하는 것이 좋다.

반복문 정리하기

반복문은 코드를 읽기 어렵게 만든다. 반복문을 사용할 때는 반복문을 함수로 추출하는 것이 좋다. 예를 들어 다음 코드를 살펴보자.

fun main() {
  val list = mutableListOf(1, 2, 3, 4, 5)
  val newList = mutableListOf<Int>()
  for (i in 0 until list.size) {
    val item = list[i]
    if (item % 2 == 0) {
      newList.add(item)
    }
  }
  print(newList)
}

위 코드는 리스트에서 짝수만 추출하는 코드다. 여기서 반복문 부분을 함수로 추출하면 다음과 같다.

fun filterEven(list: List<Int>): List<Int> {
  val newList = mutableListOf<Int>()
  for (i in 0 until list.size) {
    val item = list[i]
    if (item % 2 == 0) {
      newList.add(item)
    }
  }
  return newList
}

fun main() {
  val list = mutableListOf(1, 2, 3, 4, 5)
  val newList = filterEven(list)
  print(newList)
}

위 코드는 반복문을 함수로 추출했기 때문에 핵심 코드가 더 읽기 쉬워졌다. 또한, 추출한 함수를 재사용할 수 있다. 고차함수를 지원하는 언어라면 기본적으로 제공되는 함수를 이용할 수도 있다.

// 🟢 - 고차함수를 이용한 코드
fun main() {
  val list = mutableListOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 0 }
  print(list)
}

조건문 정리하기

조건이 너무 복잡하다면 코드를 읽는 것이 힘들다. 만약 조건이 너무 복잡하다면 조건문을 정리하는 것이 좋다. 다음 간단한 예제를 살펴보자.

fun authorize(email: String, password: String): Boolean {
  // 로그인 로직
  val user = User.signIn(email, password) ?: return false

  // 유저가 어드민이 아니면서 블락되었거나 결제가 만료된 경우
  if (user.role != Role.ADMIN && (user.isBlocked || Date.now() - user.paymentAt > 60 * 60 * 24 * 30)) {
    return false
  }

  return true
}

위 로직을 보면 조건이 복잡하다. 이런 경우 함수로 추출하는 것이 가능하다.

fun isValidUser(user: User): Boolean {
  if (user.role != Role.ADMIN && (user.isBlocked || Date.now() - user.paymentAt > 60 * 60 * 24 * 30)) {
    return false
  }

  return true
}

fun authorize(email: String, password: String): Boolean {
  // 로그인 로직
  val user = User.signIn(email, password) ?: return false
  return isValidUser(user)
}

위 코드를 보면 isValidUser 함수를 통해 조건을 정리했다. 이를 통해 핵심 코드를 읽기 쉽게 만들 수 있다. isValidUser 함수도 Guard Clause 패턴을 이용하여 가독성 좋게 만들 수 있다.

// Guard Clause 패턴을 이용하여 조건문을 단순화했다
fun isValidUser(user: User): Boolean {
  if (user.role == Role.ADMIN) return true
  if (user.isBlocked) return false
  if (Date.now() - user.paymentAt > 60 * 60 * 24 * 30) return false

  return true
}

Guard Clause 패턴의 핵심은 로직 상단에 방어 조건이 있는 것이며 중첩된 if를 피하는 것이다. 이를 통해 함수의 가독성을 높일 수 있다.

try-catch 정리

try-catch 문법은 선형적이지 않다. 사실상 goto 문과 같은 역할을 하기 때문에 코드가 길어질 수록 읽기 어려워진다. 따라서 try-catch의 내부 로직을 함수로 추출하거나 모나드를 사용하는 것이 좋다. 다음 예제를 살펴보자.

fun sum(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int = a / b

fun main() {
  try {
    var result = 5
    result = sum(result + 10)
    result = divide(result, 0)
    print(result)
  } catch (e: Exception) {
    println(e.message)
  }
}

위 코드를 보면 선형적이지 않으며 어디서 예외가 발생하는지 한 번에 알기 어렵다. 이를 해결하는 방법으로 모나드를 이용할 수 있다. 해당 방법에 대한 소개는 이전에 작성한 Railway-Oriented Programming 글과 중복되므로 여기서는 생략한다.

코드를 넘어서

코드 품질은 중요하지만 아름다운 코드가 꼭 성공을 보장하지는 않는다. 개발적인 부분에선 설계나 업무 프로세스 등을 더 고려해야할 수 있으며 코드 품질이 제품 품질을 보장하는 것은 아니다. 간혹 코드 품질 때문에 화가 난다면 스스로 너무 지엽적인 것에 갇힌 것이 아닌지 생각해보자. 그래야 진정 아름다운 코드를 만들 수 있다.

마치며

아름다운 코드에 대해선 아직도 고민할 것이 많다. 옛 말로 아름답다는 나 답다라는 뜻이라고 한다. 어쩌면 나의 경험과 고민이 축적된 코드가 나 다운 코드며 가장 아름다운게 아닐까 싶다.

참고로 이 글이 정답은 아니며 단지 필자 개인의 생각을 정리했을 뿐이다. 특히 아름다움에 대해 필자가 정의한 것은 뇌피셜이며 뇌과학적으로 진실이 밝혀진다면 헛소리로 치부될 가능성이 높다. 그럼에도 불구하고 아름다운 코드를 만들기 위한 개발자 개인의 치열한 고민의 흔적이라 생각해 준다면 감사할 것이다. 만약 아름다운 코드에 대한 의견이 있다면 언제든지 댓글로 남겨주길 바란다.

Footnotes

  1. 의사소통을 위해 사람들 간에 만들어진 사회적인 약속. 이를 무시하면 의사소통이 어려워진다.

  2. 연산을 여러 번 반복하더라도 한 번만 수행한 것과 결과가 달라지지 않는 성질을 말한다. 함수가 순수하지 않더라도 항상 같은 입력에 같은 결과임을 보장할 수 있다면 멱등성이 있다고 표현한다.

  3. 고객에게 완전히 작동하는 제품으로 보이지만 실제론 사람이 수동으로 작동시키는 제품을 말한다.