004. (Clean Code) 4. 주석 - Comments

4. 주석 - Comments

나쁜 코드에 주석을 달지 마라. 새로 짜라
브라이언 윌슨 커니핸(Brian Wilson Kernighan) - The C Programming Language의 저자인 K&R 중 한 명
P.J. 플라우거(Phillip James Plauger) - The Elements of Programming Style의 저자

잘 작성된 주석은 어떤 정보보다 유용하지만, 근거없는 주석은 오히려 코드를 이해하기 어렵게 만든다.

제일 좋은 것은 주석이 필요없을 정도로 의도가 선명한 코드를 작성하는 것이다.

하지만 프로그래밍 언어 자체의 표현력이 부족한 경우, 이를 만회하기 위해 주석은 필요하다.

즉, 주석은 필요악이다.

코드는 게속해서 여기 저기 나뉘고 갈라지면서 변화하고 진화한다.

이때 모든 주석이 각 코드를 추종해서 쫓아가지 못하는 경우가 너무 흔한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
var request: MockRequest? = null
private val HTTP_DATE_REGEXP: String =
"[SMTWF][a-z]{2}\\,\\s[0-9]{2}\\s[JFMASOND][a-z]{2}\\s" +
"[0-9]{4}\\s[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}\\sGMT"
private var response: Response? = null // HERE
private var context: FitNesseContext? = null // HERE
private var responder: FileResponder? = null // HERE
private var saveLocale: Locale? = null // HERE
// Example: "Tue, 02 Apr 2003 22:18:49 GMT"

위 코드를 보았을 때, HTTP_DATE_REGEXP와 주석 사이에 다른 인스턴스 변수를 추가했을 가능성이 높다.

이처럼 주석은 엄격하게 관리해야하기때문에 애초에 주석이 필요없는 방향으로 코드를 작성하는 것이 최상책이라고 볼 수 있다.

4.1. 주석은 나쁜 코드를 보완하지 못한다

코드에 주석을 추가하는 일반적인 이유는 코드의 품질이 나쁘기 때문이다.

표현력이 풍부하고 깔끔하며 주석이 거의 없는 코드가, 복잡하고 어순선하며 주석이 많이 달린 코드보다 훨씬 좋다.

4.2. 코드로 의도를 표현하라

물론 코드만으로 의도를 설명하기 어려운 경우도 존재한다.

그렇다고 이를 그대로 둘 수는 없다.

아래 두 개의 예제를 살펴보자.

1
2
// 직원에게 복지 혜택을 받을 자격이 있는지 검사한다.
if ((employee.flags and HOURLY_FLAG) && (employee.age > 65))
1
if (employee.isEligibleForFullBenefits())

많은 경우 아래 예제처럼 주석으로 표현하고자 하는 내용을 함수로 만들어서 표현해도 충분하다.

4.3. 좋은 주석

그렇다면 모든 주석은 쓸모가 없는 것일까?

주석이 필요한 경우의 예시를 알아보자.

때로는 회사내 사규에 의해 법적인 이유로 특정 주석을 넣도록 되어있는 경우도 있다.

예를 들어, 각 소스 파일의 최상단에 아래와 같은 저작권 정보 및 소유권 정보를 명시하는 경우이다.

1
2
// Copyright (C) 2003,2004,2005 by Object Mentor, Inc. All rights reserved.
// GNU General Public License 버전 2 이상을 따르는 조건으로 배포한다.

때로는 기본적인 정보를 주석으로 제공하는 경우도 있다.

아래 주석은 추상 메서드가 반환할 값을 설명한다.

1
2
// 테스트 중인 Responder 인스턴스를 반환한다.
abstract fun responderInstance(): Responder

물론 위의 주석이 유용하다고 하더라도, 아래와같이 함수 이름에 정보는 담는 것이 더 좋다.

1
abstract fun responderBeingTested(): Responder

상대적으로 한 눈에 알아보기 힘든 정규식은 주석을 남기는 것도 좋다.

1
2
// kk:mm:ss EEE, MMM dd, yyyy 형식이다.
val timeMatcher = Pattern.compile("\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*")

다만 이 경우에도, 시각과 날짜를 변환하는 클래스를 만들어 코드를 옮겨주면 더 좋을 것이다.

떄때로 주석은 구현의 이해를 도와주는 것을 넘어, 해당 코드를 작성하게 된 의도까지 설명한다.

아래 예제는 두 객체를 비교할 때, 다른 어떤 객체보다 자기 객체에 높은 우선 순위를 주는 코드이다.

1
2
3
4
5
6
7
8
9
fun compareTo(o: Any): Int {
if (o is WikiPagePath) {
val p: WikiPagePath = o as WikiPagePath
val compressedName: String = StringUtil.join(names, "")
val compressedArgumentName: String = StringUtil.join(p.names, "")
return compressedName.compareTo(compressedArgumentName)
}
return 1 // 오른쪽 유형이므로 정렬 순위가 더 높다.
}

또 다른 의도를 드러내는 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Throws(Exception::class)
fun testConcurrentAddWidgets() {
val widgetBuilder = WidgetBuilder(arrayOf<Class<*>>(BoldWidget::class.java))
val text = "'''bold text'''"
val parent: ParentWidget = BoldWidget(MockWidgetRoot(), "'''bold text'''")
val failFlag = AtomicBoolean()
failFlag.set(false)

// 스레드를 대량 생성하는 방법으로 어떻게든 경쟁 조건을 만들려 시도한다.
(0 until 25_000).forEach {
val widgetBuilderThread = WidgetBuilderThread(widgetBuilder, text, parent, failFlag)
val thread: Thread = Thread(widgetBuilderThread)
thread.start()
}
assertEquals(false, failFlag.get())
}

문제 해결 방식이 납득되지 않더라도, 주석을 통해 이렇게 작성한 의도를 분명히 드러낸 것을 알 수 있다.

또 다른 주석의 사용 방법으로 의미를 명료하게 밝히는 경우가 있다.

특정 인수나 반환값이 표준 라이브러리 등의 외부 의존성이라 변경이 불가한 경우, 주석을 통해 의도를 명확히 밝히는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Throws(Exception::class)
fun testCompareTo() {
val a: WikiPagePath = PathParser.parse("PageA")
val ab: WikiPagePath = PathParser.parse("PageA.PageB")
val b: WikiPagePath = PathParser.parse("PageB")
val aa: WikiPagePath = PathParser.parse("PageA.PageA")
val bb: WikiPagePath = PathParser.parse("PageA.PageB")
val ba: WikiPagePath = PathParser.parse("PageB.PageA")

assertTrue(a.compareTo(a) == 0) // a == a
assertTrue(a.compareTo(b) != 0) // a != b
assertTrue(ab.compareTo(ab) == 0) // ab == ab
assertTrue(a.compareTo(b) == -1) // a < b
assertTrue(aa.compareTo(ab) == -1) // aa < ab
assertTrue(ba.compareTo(bb) == -1) // ba < bb
assertTrue(b.compareTo(a) == 1) // b > a
assertTrue(ab.compareTo(aa) == 1) // ab > aa
assertTrue(bb.compareTo(ba) == 1) // bb > ba
}

다만 주석은 검증하기가 매우 어렵기때문에, 위와 같은 용도로 쓰다가 잘못 작성했을 때 실수를 알아차리기가 어렵다.

때로는 다른 개발자에게 경고를 목적으로 주석을 작성하는 케이스도 있다.

1
2
3
4
5
6
7
8
9
10
// 여유 시간이 충분하지 않다면 실행하지 마십시오.
fun _testWithReallyBigFile() {
writeLinesToFile(10000000)
response.body = testFile
response.readyToSend(this)
val responseString: String = output.toString()

assertSubString("Content-Length: 1000000000", responseString)
assertTrue(bytesSent > 1000000000)
}

이 경우 @Ignore 어노테이션을 이용해 테스트에서 제외하는 방법도 사용할 수 있다.

_ 접두사를 가진 것도 예쩐 규칙이므로 아래와 같이 개선할 수 있다.

1
2
3
4
5
6
7
8
9
10
@Ignore("여유 시간이 충분하지 않다면 실행하지 마십시오.")
fun testWithReallyBigFile() {
writeLinesToFile(10000000)
response.body = testFile
response.readyToSend(this)
val responseString: String = output.toString()

assertSubString("Content-Length: 1000000000", responseString)
assertTrue(bytesSent > 1000000000)
}

참고 JUnit - Annotation Type Ignore

위의 예시가 다소 억지스러울 수 있으니 다른 예제를 살펴보자.

1
2
3
4
5
6
7
8
@JvmStatic
fun makeStandardHttpDateFormat(): SimpleDateFormat {
// SimpleDateFormat은 스레드에 안전하지 못하다.
// 따라서 각 인스턴스를 독립적으로 생성해야 한다.
val df = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z")
df.timeZone = TimeZone.getTimeZone("GMT")
return df
}

위 예제가 개발자에게 경고로 쓰이는 주석의 적절한 예시라고 볼 수 있을 것이다.

경고뿐만 아니라 해야할 일도 주석으로 남겨둘 수 있다. 바로 TODO 주석이다.

아래 예제를 보자.

1
2
3
4
5
6
// TODO-MdM 현재 필요하지 않다.
// 체크아웃 모델을 도입하면 함수가 필요 없다.
@Throws(Exception::class)
fun makeVersion(): VersionInfo? {
return null
}

TODO 주석은 개발자가 필요하다 여기지만 당장 구현하기 어려운 업무를 표현하기위해 작성한다.

다만, 이또한 불필요한 주석임은 맞다.

다행히 최근 IDE들은 TODO 주석만 모아서 보여주는 기능을 제공하므로 주기적으로 점검 후 소거하는 방향이 바람직하다.

코드가 표현하기 어려운 중요성을 강조하기 위한 주석도 있다.

1
2
3
4
5
val listItemContent = match.group(3).trim()
// 여기서 trim은 정말 중요하다. trim 함수는 문자열에서 시작 공백을 제거한다.
// 문자열에 시작 공백이 있으면 다른 문자열로 인식되기 때문이다.
ListItemWidget(this, listItemContent, this.level + 1)
return buildList(text.substring(match.end()))

4.4. 나쁜 주석

여태까지 좋은 주석들의 예시를 살펴보았다.

이제 반대로 나쁜 주석들의 예시를 살펴보자.

안타깝게도 대부분의 주석이 나쁜 주석의 범주에 속한다.

일반적으로 대다수의 주석은 허술한 코드를 지탱하거나, 엉성한 코드를 변경하거나, 미숙한 결정을 합리화하는 등의 개발자의 독백에서 벗어나지 못하기 때문이다.

따라서 주석을 작성하기로 했다면 충분한 시간을 들여 최대한 좋은 주석을 작성하도록 해야 한다.

아래 예제는 제대로된 주석이 아니어서 그저 개발자의 독백으로 남은 주석의 예시이다.

1
2
3
4
5
6
7
8
9
fun loadProperties() {
try {
val propertiesPath: String = "$propertiesLocation/$PROPERTIES_FILE"
val propertiesStream: FileInputStream = FileInputStream(propertiesPath)
loadedProperties.load(propertiesStream)
} catch(e: Excpetion) {
// 속성 파일이 없다면 기본값을 모두 메모리로 읽어들였다는 의미다.
}
}

위 코드의 주석은 무슨 의미가 있을까?

IOException이 발생하는 경우, 해당 위치에 속성 파일이 없다는 뜻이다.

그럼 정말로 모든 기본값을 메모리에 적재한 것이 맞는건가?

이 추측이 맞는지 검증하기 위해 다른 코드들을 추가 검증해야하는 것이 불가피하다.

즉, 위 주석을 쓸모없는 나쁜 주석이다.

같은 이야기를 중복하는 주석

코드를 보면 이해할 수 있는 내용을 굳이 주석으로 작성하여 의미를 중복시킨 경우도 있다.

1
2
3
4
5
6
7
8
9
10
// this.closed가 true일 때 반환되는 유틸리티 메서드다.
// 타임아웃에 도달하면 예외를 던진다.
@Synchronized
@Throws(Exception::class)
fun waitForClose(timeoutMillis: Long) {
if (!closed) {
wait(timeoutMillis)
if (!closed) throw Exception("MockResponseSender could not be closed")
}
}

위와 같은 주석은 코드보다 더 많은 정보를 제공하지도 않고, 코드의 의도를 설명하지도 않는 중복일뿐이다.

오해할 여지가 있는 주석

의도만 좋은 주석이 존재하는 경우도 있다.

위의 예제를 다시 살펴보자.

1
2
3
4
5
6
7
8
9
10
// this.closed가 true일 때 반환되는 유틸리티 메서드다.
// 타임아웃에 도달하면 예외를 던진다.
@Synchronized
@Throws(Exception::class)
fun waitForClose(timeoutMillis: Long) {
if (!closed) {
wait(timeoutMillis)
if (!closed) throw Exception("MockResponseSender could not be closed")
}
}

이 코드는 closed가 true일 경우에만 무언가가 반환된다.

아니면 타임아웃을 기다렸다가 true가 아니어야 예외를 던진다.

타임아웃만 명시된 주석으로 인해 오히려 혼동을 야기한다.

의무적으로 다는 주석

모든 함수에 javadocs나 모든 변수에 주석을 달아야한다는 규칙은 나쁜 규칙이다.

오히려 코드를 복잡하게 만들고, 무질서를 초래하기때문이다.

아래 예제 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @param title CD 제목
* @param author CD 저자
* @param tracks CD 트랙 숫자
* @param durationInMinutes CD 길이(단위 : 분)
*/
fun addCD(
title: String,
author: String,
tracks: Int,
durationInMinutes: Int,
) {
val cd = CD()
cd.title = title
cd.author = author
cd.tracks = tracks
cd.durationInMinutes = durationInMinutes
cdList.add(cd)
}

위 예제는 모든 함수에 javadocs를 넣으라는 규칙이 낳은 코드이다.

오히려 코드를 헷갈리게하고, 잘못된 정보를 제공할 여지만 만든다.

있으나 마나 한 주석

너무 당연한 사실을 언급하며, 새로운 정보를 제공하지 않는 주석도 있다.

1
2
3
4
5
6
7
8
9
// 기본 생성자
class AnnualDateRul(
// ...
) {
// ...
}

// 월 중 일자
private var dayOfMonth

이러한 주석은 상술한 중복에도 해당된다.

함수나 변수로 표현할 수 있따면 주석을 달지 마라

아래 코드를 살펴보자.

1
2
// 전역 목록 smodule에 속하는 모듈이 우리가 속한 하위 시스템에 의존하는가?
if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem()))

위 예제의 주석을 없애고 다시 코드로 표현해보자.

1
2
3
val modulDependees: ArrayList = smodule.getDependSubsystems()
val ourSubSystem = subSysMod.getSubSystem()
if (modulDependees.contains(ourSubSystem))

위와 같아 코드를 통한 표현력을 늘리면 주석이 필요없어진다.

주석으로 처리한 코드

주석으로 처리해둔 코드만큼 나쁜 관행도 드물다.

아래와 같은 방식의 코드는 작성하지않도록 해야 한다.

1
2
3
4
5
val response = InputStreamResponse()
response.setBody(formatter.getResultStream(), formatter.getByteCount())
// val resultsStream = formatter.getResultStream()
// val reader = StreamReader(resultsStream)
// response.setContent(reader.read(formatter.getByteCount()))

굳이 주석으로 코드를 가려놓는 경우엔, 이를 발견하는 다른 사람이 소거하기가 꺼려지는 부작용도 발생한다.

전역 정보

주석을 달아야하는 경우은 근처에 있는 코드에 대해서만 작성해야 한다.

일부 코드에 주석을 추가하면서 시스템의 전반적인 정보를 기술할 필요는 없다.

아래 코드를 보자.

1
2
3
4
5
6
7
8
/**
* 적합성 테스트가 동작하는 포트: 기본값은 <b>8082</b>
*
* @param fitnessePort
*/
fun setFitnessePoirt(fitnessePort: Int) {
this.fitnessePort = fitnessePort
}

일단 코드와 주석의 의미가 중복되었음을 알 수 있다.

그 외에도 주석은 기본 포트정보를 적어놓았지만, 정작 함수는 포트 번호에 대한 검증 기능 자체가 없어서 기본 포트값을 통제하지 못한다.

즉 이 주석은 setFitnessePoirt() 함수가 아닌 시스템 어딘가에 있는 기본 포트를 설정하는 기능을 설명하는 주석이 되어버린다.

모호한 관계

주석과 주석이 설명하는 코드는 둘 사이의 관계가 명백해야 한다.

충분한 시간을 들여 주석을 달았다면, 이 주석과 코들르 통해 이해를 증진시켜야함을 명심하자.

아래 예제를 보자.

1
2
3
4
5
/**
* 모든 픽셀을 담을 만큼 충분한 배열로 시작한다(여기에 필터 바이트를 더한다).
* 그리고 헤더 정보를 위해 200바이트를 더한다.
*/
this.pngBytes = ByteArray((this.width + 1) * (this.height * 3) + 200)

위 코드에서 필터 바이트는 도대체 무엇일까?

width에 더한 1일까? 아니면 height에 더한 3일까? 혹은 둘 다 일까?

결국 의도가 명확하지않은 모호한 주석이 되어버린다.