매직 넘버에 대한 고찰

베루스·2022년 3월 1일
6

우테코에서 로또 미션을 진행하면서 "어디까지가 매직 넘버인가?"라는 궁금증이 생겼다.

private static boolean isFirstPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 6;
}

private static boolean isSecondPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 5 && bonusMatch;
}

private static boolean isThirdPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 5 && !bonusMatch;
}

private static boolean isFourthPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 4;
}

private static boolean isFifthPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 3;
}

private static boolean isNothingPrize(int matchCount, boolean bonusMatch) {
    return 0 <= matchCount && matchCount < 3;
}

위와 같이 당첨 로또와 발행된 로또간의 일치하는 숫자 개수(matchCount)와 보너스 번호 일치 여부(bonusMatch)를 확인해 로또 순위를 확인하는 메서드들을 만들었는데, 과연 여기서 일치하는 숫자 개수(6, 5, 4, 3 등)가 매직 넘버인가라는 궁금증이 생겨 매직 넘버에 대한 고찰을 해보고자 이 글을 작성하게 되었다.

📌
개인적인 생각을 담은 글이므로, 100% 옳다라고 생각은 하지 않으며 반대되는 생각이 있다면 댓글로 의견을 공유했으면 좋겠습니다 ㅎㅎ (저에 대한 비난은 말고, 의견에 대해 비판만 해주세요~)

매직 넘버란?

먼저 매직 넘버가 무엇인지 알아보려 한다. 개인적으로 매직 넘버는 코드 상에서 의미를 알 수 없는 숫자라고 생각한다. 코드에서 의미를 단번에 유추해낼 수 없기 때문에, 타인이 코드를 읽을 때 어떤 의미를 가진 숫자인지 고민하게 만드는 요소라고 생각한다.

Code Complete 2/E을 살펴보면 매직 넘버에 대한 얘기가 나온다.

Magic numbers are literal numbers, such as 100 or 47524,
that appear in the middle of a program without explanation.

위의 짧은 매직 넘버에 대한 정의를 해석해보면, 매직 넘버는 프로그램 중간에 설명없이 나타나는 리터럴 숫자라고 해석할 수 있다. 간단히 예시를 만들어보면, 주문 취소 로직이 있을 때 주문 상태(배송중, 배송 준비중)에 따라 주문을 취소할 수도, 취소를 아예 못할 수도 있다고 가정하자. (제가 사용해본 주문 시스템은 모두 이랬습니다. ㅎㅎ) 이를 코드로 나타내면 아래와 같이 만들어 볼 수 있을 것이다. (아래의 코드를 보고 마음이 불편한 생각을 할 수 있지만, 일단 매직 넘버에 대한 고찰을 위해 저렇게 만든다고 생각해주세요~)

public void cancleOrder() {
	if (this.state == 0) {
		...
    } else if (this.state == 1) {
    	...
    }
}

위의 코드에서는 숫자 리터럴 0, 1의 의미가 분명하지 않는다. 이렇게 코드 중간에 의미를 알 수 없고 설명없이 나타나는 숫자값을 매직 넘버라고 Code Complete 2/E 에서는 얘기하고 있고, 해결 방안으로 이름을 가지는 상수를 사용할 것을 제안하고 있다.

private static final int PREPARE_ORDER = 0;
private static final int SHIPPING = 1;

public void cancleOrder() {
	if (this.state == PREPARE_ORDER) {
		...
    } else if (this.state == SHIPPING) {
    	...
    }
}

위와 같이 이름을 가지는 상수를 사용하면, 타인이 주문 취소 로직을 해석할 때 매직 넘버가 있을 때 보다 더욱 명확하게 의미를 파악할 수 있을 것이다.

매직 넘버의 이점

Code Complete 2/E에서는 매직 넘버의 이름을 가지는 상수화에 대해 3가지 이점이 있다고 알려준다.

1. Changes can be made more reliably.
2. Changes can be made more easily.
3. Your code is more readable.

1. 신뢰성을 가지고 변경할 수 있다.

동일한 의미를 가진 매직 넘버의 값을 변경해야할 때 여러 군데에서 사용되고 있다고 가정하면, 사용되는 모든 부분을 찾아 변경해줘야한다. 하나라도 놓친다면 심각한 버그를 유발할 수 있다. 하지만, 상수를 사용한다면 상수를 정의한 부분의 값만 변경해주면 된다.

private static final int PREPARE_ORDER = 0;
private static final int SHIPPING = 1; // 여기를 다른 값으로 변경해주면 됨!

public void cancleOrder() {
	if (this.state == PREPARE_ORDER) {
		...
    } else if (this.state == SHIPPING) {
    	...
    }
}

public void shipOrder() {
	this.state = SHIPPING;
}

예시를 보면 위의 코드와 같이 SHIPPING을 여러 군데에서 사용한다면 매직 넘버일 때에는 하나하나씩 찾아 변경해야하지만, 상수를 사용할 때에는 맨 위의 선언부분에 넣어준 값만 변경해주면 된다.

2. 쉽게 변경할 수 있다.

public void func() {
	IntStream.rangeClosed(0, 100)
    	.forEach(System.out::println);
    IntStream.range(0, 101)
    	.forEach(System.out::println);
}

위의 코드가 특별한 이유가 있어 정확히 0부터 100까지의 숫자를 2번 출력하도록 만들고, 100이 타인에게 충분한 의미를 전달하지 못하는 매직 넘버라고 가정하자. 그리고 사정이 있어서 위에는 rangeClose를 사용하고, 아래에는 range 메서드를 사용해야한다고 가정하자. (실제로 코드에 따라서 어떤 로직에서는 경계값을 포함하도록 만들고, 다른 곳에서는 경계값을 포함하지 않도록 만들어질 수 있다고 생각합니다.) 이때 100이라는 값을 변경하기 위해서는 모든 곳을 찾아야 하는데, 앞의 경우(1. 신뢰성을 가지고 변경할 수 있다.)와 달리 경계값 포함 유무 때문에 변경점을 찾기 쉽지 않을 것이다.

private static final int MAX_VALUE = 100;

public void func() {
	IntStream.rangeClosed(0, MAX_VALUE)
    	.forEach(System.out::println);
    IntStream.range(0, MAX_VALUE + 1)
    	.forEach(System.out::println);
}

위의 코드와 같이 100이라는 의미를 MAX_VALUE로 상수화 시켰을 때에는 MAX_VALUE의 값만 변경해주면 되어서 이전과 달리 손쉽게 변경할 수 있다.

3. 코드를 쉽게 읽을 수 있다.

앞에서 이미 충분히 다뤘지만, 단순 숫자 값에서 의미 있는 이름을 가진 상수를 사용하기 때문에 더욱 명확하게 코드를 파악할 수 있다. 하지만, 여기서 중요한 부분이 있다. 의미를 더욱 명확하게 하기 위해서는 상수를 적절한 이름으로 지어줘야 한다.

private static final int ONE_HUNDRED = 100;

public void func() {
	IntStream.rangeClosed(0, ONE_HUNDRED)
    	.forEach(System.out::println);
    IntStream.range(0, ONE_HUNDRED + 1)
    	.forEach(System.out::println);
}

위의 상수 이름 ONE_HUNDRED가 적절한 이름일까? func 라는 메서드가 특별한 도메인 로직을 담고 있지 않기 때문에... 판단하기 어렵다고 생각이 든다. 하지만, func 메서드가 단순 100번을 반복하는 것이 아닌 정해진 최댓값만큼 반복하는 로직이라면 ONE_HUNDRED 보다는 MAX_COUNTMAX_VALUE 같은 이름이 좀 더 의미를 명확하게 할 것이다.

과연 로또의 일치 개수는 매직 넘버인가?!

매직 넘버에 대한 정의와 이점을 알아봤으니 다시 한번 문제의 코드를 살펴보고 로또의 일치 개수가 매직 넘버인지 생각해보려한다. (앞의 코드가 조금 길어서 isFirstPrize 메서드만 가져왔습니다~)

private static boolean isFirstPrize(int matchCount, boolean bonusMatch) {
    return matchCount == 6;
}

우선, 6이 현재 상황에서 의미를 충분히 전달하지 못하는 요소인지 생각해보자. 로또에서 1등은 보너스와 상관없이 당첨 번호 6개 모두와 일치해야한다. 코드를 읽을 타인이 로또에 대한 지식이 전혀 없다면... 해당 코드를 난해하게 받아들일까? 개인적으로는 메서드 이름과 파라미터 이름을 통해서 충분히 파악할 수 있다고 생각한다.

하지만, 사람마다 생각이 다양하므로 6이 의미를 충분히 전달하지 못한다고 생각할 수도 있다. 그렇다면 어떤 좋은 의미를 가진 이름으로 지어주는 것이 좋을까?

개인적으로 생각해본 이름은 2가지가 있다. 하나는 SIX, 다른 하나는 FIRST_PRIZE_MATCH_COUNT이다. 먼저, SIX 라는 이름은 리터럴 숫자 6을 사용할 때와 다른 점을 전혀 느끼지 못했다.

private static final int SIX = 6;

private static boolean isFirstPrize(int matchCount, boolean bonusMatch) {
    return matchCount == SIX;
}

그 이유는 위의 코드를 보면 도메인 규칙에 대해서 리터럴 숫자 6을 사용할 때와 비교했을 때 의미를 더욱 명확하게 해주지 않기 때문이다. 그렇다면 FIRST_PRIZE_MATCH_COUNT는 어떨까?

private static final int FIRST_PRIZE_MATCH_COUNT = 6;

private static boolean isFirstPrize(int matchCount, boolean bonusMatch) {
	return matchCount == FIRST_PRIZE_MATCH_COUNT;
}

확실히 SIX에 비해 좀 더 명확한 의미를 주는 것 같다. 하지만, 개인적으로 살짝 과하다는 생각이 든다. 그 이유는 isFirstPrize 메서드를 읽을 때, 리터럴 숫자로 되어 있었다면 "1등은 일치하는 숫자의 개수가 6개가 필요하구나"를 바로 파악할 수 있는 반면에 상수로 되어 있으면 "1등은 일치하는 숫자의 개수가 FIRST_PRIZE_MATCH_COUNT와 같을 경우구나"를 먼저 파악하고 상수의 정의부를 찾아가"FIRST_PRIZE_MATCH_COUNT는 6이네" 라는 2 단계를 밝아야 하기 때문이다.

관련해서 클린 코드17장 냄새와 휴리스틱에 적힌 G25: 매직 숫자는 명명된 상수로 교체하라를 살펴보려 한다.

어떤 상수는 이해하기 쉬우므로, 코드 자체가 자명하다면, 상수 뒤로 숨길 필요가 없다. 예를 들어, 다음 코드를 살펴보자.

double milesWalked = feetWalked/5280.0;
int dailyPay = hourlyRate * 8;
double circumference = radius * Math.PI * 2;

위 예제에서 FEET_PER_MILE, WORK_HOURS_PER_DAY, TWO라는 상수가 반드시 필요할까? 마지막 TWO는 확실히 우습다. 어떤 공식은 그냥 숫자를 쓰는 편이 훨씬 좋다. 두 번째 WORK_HOURS_PER_DAY는 상수를 사용해도 괜찮겠다. 법이나 관례가 바뀔지도 모르니까. 하지만 8이 들어간 공식은 너무 깔끔하기에 굳이 18자나 되는 상수를 추가하기 꺼려진다. 첫 번째 FEET_PER_MILE은 5280이 너무나도 잘 알려진 고유한 숫자라 주변 코드 없이 숫자만 달랑 적어놔도 독자가 금방 알아본다.

클린 코드에서는 코드 자체가 자명하다면 이름을 가진 상수를 사용할 필요가 없다고 말해준다. 개인적으로 isFirstPrize 메서드의 코드는 자명하다고 생각이 들고, 리터럴 숫자 6은 이해하기 어려운 요소가 아니라고 판단된다.

만약 동일한 의미를 가진 리터럴 숫자 6이 여러 군데에서 사용되고, 사용된 코드 중에서 한 곳이라도 명확한 의미를 전달하지 못한다면 그 때는 확실한 매직 넘버라고 생각할 수 있을 것이다. 하지만, 현재의 isFirstPrize 메서드만 놓고 본다면, 6은 로직을 난해하게 만드는 요소보다는 한 눈에 의미를 파악할 수 있는 숫자라고 생각된다.

1개의 댓글

comment-user-thumbnail
2022년 8월 10일

우왕!

답글 달기