002. (Clean Code) 2. 의미있는 이름 - Meaningful Names

2. 의미있는 이름

소프트웨어에서 이름은 어디에나 쓰이고 있다.

변수를 선언할때도, 함수를 선언할때도, 클래스를 작성할때도 이름을 붙여준다.

개발자의 고충

출처 : IT world - 프로그래머가 해야 하는 가장 어려운 9가지 일

위의 설문조사 결과처럼 실제로 개발자들은 네이밍을 정하는 데 많은 고민을 하게 된다.

본 포스팅에서는 이름을 잘 짓는 간단한 규칙을 소개한다.

2.1. 의도를 분명히 밝혀라

의도를 알 수 있는 이름을 지어라.

말은 쉽다.

문제는 의도가 분명한 이름을 짓는 것이 어렵다는 것일 뿐이다.

변수, 함수, 클래스 등은 특히 더 어렵다.

변수, 함수, 클래스의 존재 이유, 수행 기능, 사용 방법을 명확히 표현할 수 있어야 하며, 별도의 주석이 존재한다면 잘못된 작명으로 볼 수 있다.

1
va d: Int // 경과 시간 (단위: 날짜)

위 예시를 보자. 변수명 d는 아무런 의미도 가지고 있지않다.

즉, 경과 시간이나 날짜를 표현한다는 의도를 느낄 수 없는 것이다.

따라서 아래와 같이 좀 더 명확하게 변수명을 작성해야 한다.

1
2
3
4
var elapsedTimeInDays: Int
var daysSinceCreation: Int
var daysSinceModification: Int
var fileAgeInDays: Int

이처럼 의도가 드러나는 이름을 가지면 코드의 이해와 변경을 쉬워진다.

이해는 알겠는데 변경은 무엇일까?

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
fun getThem(): List<IntArray> {
val list1 = ArrayList<IntArray>()
theList.forEach { x ->
if (x[0] == 4) {
list1.add(x)
}
}
return list1
}

위 함수는 도대체 어떤 동작을 하는 것일까?

쓰인 변수도 몇 개 없고, 로직도 단순하다.

단순한 코드니까 좋은 걸까?

아니다. 이 코드의 문제는 단순성이 아니라 함축성이다.

즉 이 코드가 의미하는 맥락이 명시적으로 드러나지않고 있다.

좀 더 명세해보자면 위의 코드는 개발자에게 아래 정보를 기존에 알고 있도록 강요하다.

  1. theList에 무엇이 들어있는지 알고 있다.
  2. theList의 0번 인덱스 값이 왜 중요한지 알고 있다.
  3. 비교값으로 쓰인 4가 어떤 의미를 가지고 있는지 알고 있다.
  4. getThem() 함수가 반환하는 list1을 어떻게 사용할지 알고 있다.

예제의 스니펫만 보았을 때 위 4가지 정보를 유추할 수 있는가?

이제 이 스니펫에 적당한 이름을 부여해보도록 하자.

1
2
3
4
5
6
7
8
9
fun getFlaggedCells(): List<IntArray> {
val flaggedCells = ArrayList<IntArray>()
gameBoard.forEach { cell ->
if (cell[STATUS_VALUE] == FLAGGED) {
flaggedCells.add(cell)
}
}
return flaggedCells
}

코드의 단순성을 그대로 둔채로, 변수와 함수, 그리고 상수들에 대해 이름을 부여했다.

이 함수가 지뢰찾기 게임을 만드는 데 쓰인다고 가정해보면 맥락을 파악할 수 있다.

gameBoard는 현재 지뢰찾기 게임에 노출된 보드의 상태일 것이고, cell 은 해당 보드의 한 칸을, 0이라는 인덱스는 해당 칸의 상태를 의미하는 STATUS_VALUE

상수 4는 FLAGGED 를 의미하며, 결과적으로 이 함수는 flagged 상태인 칸들을 종합해서 반환하는 함수임을 유추할 수 있게 되었다.

리팩토링을 통해 좀 더 개선해보면 아래와 같이 표현할 수 있겠다.

1
2
3
4
5
6
7
8
9
fun getFlaggedCells(): List<Cell> {
val flaggedCells = ArrayList<Cell>()
gameBoard.forEach { cell ->
if (cell.isFalgged()) {
flaggedCells.add(cell)
}
}
return flaggedCells
}

결론적으로 같은 로직이라도 의도를 포함한 이름을 가진다면 더욱 쉬운 이해와 변경이 가능하다.

2.2. 그릇된 정보를 피하라

개발자는 코드에 잘못된 정보를 남겨서는 안된다. 잘못된 정보는 코드의 이해도를 저하시키기 때문이다.

대중적으로 통용되는 단어 사용 금지

hp, aix, sco 등 널리 쓰이는 의미가 있는 단어를 다른 의미를 가진 변수명으로 사용해선 안된다.

개발자에게 특수한 의미를 가진 단어 사용 금지

자료구조의 List에 더 익숙한 개발자 특성상 실제로 List가 아니라면 Group등을 사용하는 방법이 좋다.

흡사한 이름 사용 금지

서로 흡사한 이름을 사용하지않도록 주의해야 한다.

예를 들어 XYZControllerForEfficientHandlingOfStrings라는 이름과 XYZControllerForEfficientStorageOfStrings라는 이름은 다른 곳에서 쓰이더라도

혼란을 야기할 가능성이 크다.

소문자 L과 대문자 O

1
2
3
4
5
6
var a: Int = 1
if (O == l) {
a = Ol
} else {
l = 01
}

위 예제 코드로 설명을 대신한다.

2.3. 의미 있게 구분하라

단순히 컴파일 오류 없이 빌드 테스트를 통과하는 정도의 코드 작성은 많은 버그를 야기할 수 있다.

컴파일이 되는 것과는 별개로, 연속된 숫자를 덧붙이거나 사용되지않는 단어를 추가하는 방식을 적절하지 않다.

의미에 맞게 이름을 붙여야하는 것처럼, 이름이 달라지면 의미도 달라져야하기 때문이다.

아래 코드를 보자.

1
2
3
4
5
fun copyChars(a1: CharArray, a2: CharArray) {
for (i in a1.indices) {
a2[i] = a1[i]
}
}

a1a2는 아무런 의미를 제공하지않는다.

a1a2에 복사하는 맥락을 보건대, 아래와 같이 sourcedestination을 사용하면 이해가 더욱 쉬워질 것이다.

1
2
3
4
5
fun copyChars(source: CharArray, destination: CharArray) {
for (i in source.indices) {
destination[i] = source[i]
}
}

또 다른 예시를 생각해보자.

어떤 클래스가 Product라는 이름으로 존재한다고 가정한다.

그런데 다른 클래스로 ProductInfo 혹은 ProductData가 같이 존재하는 경우, 각 클래스가 가진 의미가 모호해진다.

InfoDataa, an, the 처럼 의미가 불분명한 불용어이기 때문이다.

또한 중복 의미하는 이름도 지양해야 한다.

Name으로 끝낼 수 있는 것을 NameString으로 작명하거나,

Customer로 충분한 것을 CustomerObject라고 작명하는 경우가 그 예시이다.

비단 클래스명만 아니라 함수명도 마찬가지이다.

1
2
3
getActiveAccount()
getActiveAccounts()
getActiveAccountInfo()

위의 함수명만 보고 정확히 원하는 반환값을 돌려주는 함수를 추측하기란 어렵기때문이다.

2.4. 발음하기 쉬운 이름을 사용하라

혼자서 개발하는 경우도 있겠지만, 대부분의 개발은 협업을 통해 진행된다.

이 협업이란 것은 필수적으로 의사소통을 동반해야하는데, 발음을 어려운 이름을 가진 경우 토론이 어려워진다.

동일한 역할을 하는 아래 클래스를 보고, 어떤 게 의사소통이 쉬울지 생각해보면 답이 나올 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// BAD
class DtaRcrd102 {
private var genymahms: Date
private var modymahms: Date
private val pszqint: String = "102";

/* ... */
}

// GOOD
class Customer {
private var generationTimestamp: Date
private var modificationTimestamp: Date
private val recordId: String = "102";

/* ... */
}

2.5. 검색하기 쉬운 이름을 사용하라

문자 하나를 사용하는 이름과 상수는 코드에서 쉽게 눈에 들어오지 않는 문제점이 있다.

예를 들어 MAX_CLASSES_PER_STUDENT는 grep만 걸면 대부분 찾을 수 있겠지만, 숫자 7은 온갖 영역에서 다 검색될 것이다.

알파벳 e도 영어에서 가장 많이 쓰이는 문자이기때문에 검색하기 어렵다.

2.6. 인코딩을 피하라

이름에 유형이나 범위까지 포함하면 의미를 이해하기 어려워진다.

따라서 불필요한 인코딩을 피하고 꼭 필요한 것만 인코딩하는 것이 좋다.

헝가리안 표기법

이름에 길이제한이 존재하던 시절 포트란은 첫 글자로 유형을 표현하였다.

접두어 데이터 타입
b byte, boolean
n int, short
i int, short (주로 인덱스로 사용)
c int, short (주로 크기로 사용)
l long
f float
d, db double
ld long double
w word
dw double word
qw quad word
ch char
sz NULL로 끝나는 문자열
str C++ 문자열
arr 배열 (문자열 제외): 다른 접두어와 조합 가능
p 포인터 (16bit, 32bit): 다른 접두어와 조합 가능
lp 포인터 (64bit): 다른 접두어와 조합 가능
psz NULL로 끝나는 문자열을 가리키는 포인터 (16비트, 32비트)
lpsz NULL로 끝나는 문자열을 가리키는 포인터 (32비트[2], 64비트)
fn 함수 타입
pfn 함수 포인터 (16bit, 32bit)
lpfn 함수 포인터 (64bit)

출처 IT위키 - 헝가리안 표기법

모든 변수명 앞에 prefix로 위의 문자들이 붙는다고 생각하면 변수의 의도를 이해하는 데에 더욱 많은 노력이 필요할 것이다.

다만 이는 옛날의 불가피한 선택이었고, 현재 쓰이는 프로그래밍 언어들은 컴파일러가 타입을 감지해주고 있다.

따라서 현재 헝가리안표기법은 의미없는 인코딩이 되었다.

멤버 접두어

예전엔 m_ 이라는 접두어를 변수명에 붙여서 멤버 변수임을 표기하기도 했다.

1
2
3
4
5
6
public class Part {
private String m_dsc; // 설명 문자열
void setName(String name) {
m_dsc = name;
}
}

이제 m_을 붙이지 않고 작성하는 것이 더욱 의도가 명확해진다.

1
2
3
4
5
6
7
// java
public class Part {
private String description;
void setDescription(String description) {
this.description = description;
}
}
1
2
3
4
// kotlin
class Part {
var description: String? = null
}

인터페이스 클래스와 구현 클래스

때로는 오히려 인코딩이 필요한 경우가 있다.

예를 들어 도형을 생성하는 추상 팩토리를 구현한다고 가정하자.

이 팩토리는 인터페이스 클래스(interface class) 이며 구현은 구체 클래스(concrete class) 에서 진행한다.

인터페이스 클래스와 구체 클래스의 이름은 어떻게 지어야할까?

가장 전통적인 방법으로는 인터페이스를 뜻하는 I를 접두어로 사용하여 IShapeFactoryShapeFactory를 떠올릴 수 있다.

하지만 I는 주의를 흐트리거나 (인터페이스라는) 과도한 정보를 표현한다.

즉, 인터페이스임을 알 필요가 없음에도 알아버리게 되는 불편함이 존재한다.

외부에서는 그저 ShapeFactory로만 노출하고, 구체 클래스를 인코딩하는 것이 좋다고 볼 수 있다.

IShapeFactory보다는 CShapeFactoryShapeFactoryImp이 더 낫다.

참고 위 내용은 저자인 로버트 C. 마틴의 의견으로 필자도 이에 동의한다.

2.7. 기억력을 믿지마라

코드를 읽으면서 변수명을 자신이 아는 이름으로 변경해야하는 경우가 있다면,

일반적으로 문제 영역이나 해법 영역에서 사용하지않는 이름을 사용했기 때문에 발생하는 문제이다.

루프에서 자주 쓰이는 i,j,k 정도는 범위가 작고 다른 이름과 충돌하지않는 선에서는 사용해도 되지만

해당 케이스 외에는 부적합한다.

개인의 기억력이 좋고, 똑똑한 것과는 상관없이 개발자로서의 미덕은 명료한 코드 이다.

어떤 변수 p의 의미가 특정 URI에서 프로토콜과 호스트 영역을 제외한 파라미터 영역을 의미한다는 것을 “혼자” 끝까지 기억할 수 있다고 가정하자.

이러한 작명은 협업 관계에 있는 누군가에게 반복적으로 설명해주어야하며, 코드의 이해도도 저하시킨다.

즉, 별도의 설명이 필요없는 명료한 코드가 제일 좋은 코드이다.

2.8. 클래스와 메서드 이름

클래스와 객체의 이름은 명사나 명사구가 적합하다.

Customer, WikiPage, Account, AddressParse 등이 좋은 예시이다.

반대로 Manager, Processor, Data, Info 등은 피하고 동사는 사용하지않아야 한다.

반대로 메서드의 이름은 동사나 동사구가 적합하다.

postPayment(), deletePage(), save() 등이 좋은 예시이다.

접근자(Accessor), 변경자(Mutator), 조건자(Predicate)는 javabean 표준에 따라 get, set, is를 붙인다.

아래는 예시 코드이다.

1
2
3
4
5
6
// java
String name = employee.getName();
customer.setName("mike");
if (paycheck.isPosted()) {
// ...
}
1
2
3
4
5
6
// kotlin
val name: String = employee.getName()
customer.name = "mike"
if (paycheck.isPosted) {
// ...
}

만약 생성자를 중복 정의하는 경우엔 정적 팩토리 메서드를 사용한다.

예를 들어

1
val fulcrumPoint = Complex(23.0)

보다는

1
val fulcrumPoint = Complex.fromRealNumber(23.0)

이 더 좋다.

참고
정적 팩토리 메서드의 경우 생성자 사용을 제한하기 위해 생성자를 private으로 선언하는 것도 좋다.
관련 포스트 : (Effective Java 2/E) 101. Item 1 - Consider static factory methods instead of constructors

2.9. 한 개념에 한 단어를 사용하라

추상적인 개념 하나에 단어 하나를 선택해야 한다.

예를 들어 동일한 행위를 하는 메서드를 각 클래스마다 fetch(), retrieve(), get() 등으로 각기 네이밍하면 혼란을 야기할 수 있다.

비단 메서드뿐만이 아니라 클래스의 이름도 마찬가지이다.

동일한 코드 내에서 controller, manager, driver등이 혼재되어있어도 혼란이 야기된다.

예를 들어 DeviceManagerProtocolController는 근본적으로 동일한 녀석이기 때문이다.

이름이 다르면 다른 클래스로 착각할 여지가 존재하기 때문이다.

따라서 이름은 독자적이고 일관적이어야 한다.

2.10. 솔루션에서 이름을 차용하라.

개발자라면 전산 용어, 알고리즘과 패턴의 이름, 수학 용어등에 친숙할 것이다.

가능한 경우 이처럼 솔루션 영역에서 이름을 차용하는 것이 의도를 내포하기 좋은 경우가 있다.

예를 들어 계정 정보 목록이 있다면 AccountGroup보다는 AccountList가 더욱 와닿을 것이다.

머신 러닝 측면에서 보면 data_generator는 반복자가 적용된 데이터 생성기라는 것을 바로 유추할 수 있을 것이다.

즉, 모든 이름을 문제 도메인 영역에서만 가져오는 것은 현명하지 못하다.

2.11. 문제에서 이름을 차용하라.

만약 솔루션 내에 적절한 이름이 없다면 문제 영역에서 이름을 차용하는 것이 좋다.

문제 영역에서 이름을 차용하는 경우, 개발자가 해당 문제의 전문가를 통해 맥락이나 의도를 파악하는 것이 수월해지기 때문이다.

2.11. 의미있는 맥락을 추가하라.

대부분의 이름은 스스로 의미를 가지고 있다.

하지만 복합적인 개념의 경우 맥락을 부여해서 이해를 해야 한다.

예를 들어 firstName, lastName, street, houseNumber, city, state, zipcode 라는 변수들이 있다고 가정해보자.

이 변수들이 합쳐서 표현하는 것은 어떠한 주소라는 것을 쉽게 떠올릴 수 있다.

하지만 만약 state하나만 딸랑 있는 경우엔 주소를 쉽게 떠올릴 수 있을까?

이처럼 전체적인 맥락은 의도를 파악하는 데 매우 중요한 요소이다.

위 변수들을 추가하는 addFirstName(), addListName(), addState() 등의 메서드를 가진 Address 클래스를 정의하면 매우 명확해 질 것이다.

이번엔 예제를 통해 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void printGuessStatistics(char candidate, int count) {
String number;
String verb;
String pluralModifier;

if (count == 0) {
number = "no";
verb = "are";
pluralModifer = "s";
} else if (count == 1) {
number = "1";
verb = "is";
pluralModifer = "";
} else {
number = Integer.toString(count);
verb = "are";
pluralModifer = "s";
}

String guessMessage = String.format("There %s %s %s%s", verb, number, candidate, pluralModifier);
print(guessMessage);
}

위 메서드의 최상단에 정의된 number, verb, pluralModifer 변수는 메서드의 끝까지 가서야 통계 결과에 쓰이는 값임을 알 수 있다.

printGuessStatistics() 메서드에 맥락을 부여하기 위해서 아래와 같이 클래스로 치환해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private class GuessStatisticsMessage {
String number;
String verb;
String pluralModifier;

public String make(char candidate, int count) {
return String.format("There %s %s %s%s", verb, number, candidate, pluralModifier);
}

private void createPluralDependentMessageParts(int count) {
if (count == 0) {
thereAreNoLetters();
} else if (count == 1) {
thereIsOneLetter();
} else {
thereAreManyLetters(count);
}
}

private void thereAreManyLetters(int count) {
number = Integer.toString(count);
verb = "are";
pluralModifer = "s";
}

private void thereIsOneLetter() {
number = "1";
verb = "is";
pluralModifer = "";
}

private void thereAreNoLetters() {
number = "no";
verb = "are";
pluralModifer = "s";
}
}

위와 같은 맥락의 부여를 통해 number, verb, pluralModifer 변수와 출력 결과에 대한 알고리즘이 명확해졌다.

참고 이 코드는 일부러 kotlin으로 작성하지않았다. kotlin으로 하면 더 간결하고 클래스의 전환도 필요없어지긴 하겠지만
이는 java로도 충분히 간소화할 수 있으며, 위 예제는 맥락 부여의 중요성을 납득시키기 위한 의도적인 저품질 코드로 판단되기때문이다.