magic-number | data-abstraction

매직 넘버에서 데이터 추상까지

매직 넘버에서 상수화, Enum, 데이터 추상으로 이어지는 코드 설계의 진화 과정을 살펴보며, Enum만으로 부족한 이유와 비즈니스 규칙을 캡슐화해야 하는 이유를 Java 예제와 함께 설명합니다.

Mimul
MimulJanuary 18, 2015 · 24 min read · Last Updated:

매직 넘버를 발견하면 상수로 대체하고, 상태값이라면 Enum으로 바꾼다. 많은 개발팀에서 여기까지를 “좋은 설계”라고 여긴다. 하지만 상수화는 값에 이름을 붙일 뿐이고, Enum은 허용 가능한 값을 타입으로 정의할 뿐이다. 둘 다 값을 해석하는 판단 규칙을 캡슐화하지는 못한다. 이 글에서는 매직 넘버에서 상수화, Enum, 데이터 추상으로 이어지는 네 단계 진화 과정을 살펴보며, 왜 데이터 추상이 최종 목적지인지 설명한다.

왜 매직 넘버가 상수화와 Enum화만으로는 충분하지 않은가

소프트웨어 개발을 하다 보면 다음과 같은 코드를 자주 보게 된다.

if (age >= 20) {
    issueAdultTicket();
}

또는

if (memberType == 3) {
    applyDiscount();
}

이런 코드에 등장하는 20, 3 같은 숫자를 흔히 매직 넘버(Magic Number) 라고 부른다. 대부분의 개발자는 이런 코드를 발견하면 반사적으로 상수로 추출하고, 상태값이라면 Enum으로 변경한다. 많은 개발팀에서 여기까지를 “좋은 설계”라고 생각한다. 하지만 정말 그럴까?

상수화는 if (age >= 20)if (age >= ADULT_AGE)로 바꾸어 가독성과 변경 용이성을 얻는다. 하지만 이 코드를 읽는 사람은 여전히 “성인 판정 규칙이 있다”, “나이로 비교한다”, “기준은 ADULT_AGE다”라는 세 가지 사실을 직접 파악해야 한다. 값에는 이름이 생겼지만, 그 값을 이용해 판단하는 규칙 자체는 코드 바깥에 노출된 채다.

Enum은 타입 안전성을 추가한다. MemberGrade.GOLD처럼 허용되지 않은 값의 침투를 컴파일 단계에서 막는다. 그러나 아래처럼 동일한 판단 규칙이 여러 곳에 복제되는 문제는 Enum이 해결해주지 않는다.

if (member.getGrade() == MemberGrade.GOLD || member.getGrade() == MemberGrade.VIP) { showDiscountBanner(); }
if (member.getGrade() == MemberGrade.GOLD || member.getGrade() == MemberGrade.VIP) { applyDiscount(); }
if (member.getGrade() == MemberGrade.GOLD || member.getGrade() == MemberGrade.VIP) { issueCoupon(); }

새로운 등급이 추가되어 할인 대상이 바뀌면, 이 조건을 시스템 전체에서 찾아 수정해야 한다. Enum은 데이터를 타입으로 묶었지만, 그 타입에 대한 비즈니스 판단은 외부에 흩어진 채 있다. 이 글에서는 아래 네 단계의 진화 과정을 살펴보며, 왜 데이터 추상이 최종 목적지인지 설명한다.

매직 넘버 → 상수 → Enum → 데이터 추상

1. 매직 넘버

if (age >= 20) {
    issueAdultTicket();
}

20이라는 숫자는 존재하지만 그 의미는 코드 어디에도 적혀 있지 않다. 이 코드를 처음 보는 개발자는 추측해야 한다. 성인 기준 나이인가? 회원 가입 가능 연령인가? 주류 구매 가능 연령인가? 이벤트 참여 기준인가? 정답은 오직 해당 코드를 처음 작성한 사람만 알고 있다. 코드 리뷰 과정에서도 문제가 된다. 리뷰어가 20이라는 숫자를 마주쳤을 때 이 값이 도메인 정책에서 비롯된 것인지, 기술적 한계값인지, 임시로 하드코딩된 것인지 알 방법이 없다. 의미를 파악하기 위해 커밋 로그를 뒤지거나 해당 코드를 작성한 개발자에게 직접 물어봐야 한다.

더 큰 문제는 동일한 숫자가 코드베이스 여러 곳에 흩어져 있을 때 발생한다.

if (age >= 20) { ... }        // UserService.java
if (age >= 20) { ... }        // TicketService.java
if (age >= 20) { ... }        // CouponService.java

정책이 변경되어 기준 나이가 19세로 바뀌면 세 곳을 모두 찾아 수정해야 한다. 하나라도 놓치면 서비스마다 기준이 달라지는 버그가 발생한다. Juergens et al.의 연구(2019)에서도 코드 클론(Code Clone) 중 일부만 수정될 경우 동일한 결함이 여러 위치에서 반복적으로 발생함을 실증적으로 분석했다. 즉 매직 넘버는 복제될수록 유지보수 비용이 기하급수적으로 늘어난다.

2. 상수화

가장 흔한 리팩토링이다.

public static final int ADULT_AGE = 20;

if (age >= ADULT_AGE)

숫자 대신 이름이 등장하면 코드를 읽는 사람이 의도를 즉시 파악할 수 있다. 정책이 변경될 경우 ADULT_AGE = 19; 한 줄만 수정하면 이 상수를 참조하는 모든 곳에 변경이 반영된다. 상수화는 가독성과 변경 용이성이라는 두 문제를 동시에 해결한다.

다만 변경을 한 곳에서 처리할 수 있다는 이점은 이름 붙이기가 올바를 때만 성립한다. 아래 Constants.java에서 MAX_RETRY = 3VIP_MEMBER_TYPE = 3을 보면, 값은 같지만 전혀 다른 개념이다. 이 둘을 같은 상수로 묶어버리면, 한쪽만 바꿔야 할 때 깨진다. 같은 이름으로 묶인 값은 함께 변하고, 다른 이름으로 구별된 값은 독립적으로 변한다. 명명의 핵심은 값에 의미를 부여하는 것이고, 변경의 집중화는 그 결과로 따라오는 것이다.

그러나 많은 프로젝트에서 상수화는 다음과 같은 방식으로 진행된다.

// Constants.java
public class Constants {
    public static final int ADULT_AGE = 20;
    public static final int MAX_LOGIN_FAIL_COUNT = 5;
    public static final int DEFAULT_TIMEOUT = 3000;
    public static final int MAX_CONNECTION = 100;
    public static final int GOLD_MEMBER_TYPE = 2;
    public static final int VIP_MEMBER_TYPE = 3;
    public static final int MAX_RETRY = 3;
    ...
}

이 파일은 시간이 지날수록 커진다. 도메인과 무관한 값들이 한데 모이면서 맥락(context)이 사라진다. GOLD_MEMBER_TYPE = 2를 보면 이 값이 어디서 사용되는지 알 수 없고, 사용처에서는 Constants.GOLD_MEMBER_TYPE이라는 이름만 보일 뿐 실제 값이 무엇인지 알 수 없다. 정의 쪽을 봐도 어디에 쓰이는지 알 수 없고, 사용하는 쪽을 봐도 값이 무엇인지 알 수 없다. 어느 쪽을 봐도 판단이 서지 않는다. 이는 데이터 추상이 아니라 단순한 이름 부여(Name Assignment)에 가깝다.

더 근본적인 한계도 있다. 상수화가 해결하는 것은 값(Value)의 가독성과 집중화다. 그런데 실제 코드에서 중요한 것은 값만이 아니다.

if (age >= ADULT_AGE)

이 코드를 읽는 사람은 여전히 세 가지 사실을 알아야 한다. 성인 판정 규칙이 존재한다는 것, 나이를 비교해서 판단한다는 것, 기준값은 ADULT_AGE라는 것. 숫자는 사라졌지만 규칙 자체는 코드 바깥에 노출되어 있다. 상수화는 이름을 부여했을 뿐, 규칙을 캡슐화하지는 못했다.

Martin Fowler는 이를 “Replace Magic Number with Symbolic Constant” 리팩토링으로 설명한다. 핵심 목적은 숫자를 제거하는 것이 아니라 값에 의미를 부여하는 것이다. 하지만 이 리팩토링은 어디까지나 이름을 부여하는 단계이며, 값을 어떻게 해석하고 사용할지에 대한 규칙까지 캡슐화하지는 않는다. 따라서 상수화는 중요한 출발점이지만 데이터 추상의 종착점은 아니다.

3. Enum

정수 상수가 갖는 타입 안전성 문제를 해결하는 것이 Enum이다. 회원 등급을 예시로 비교해보자.

매직 넘버 버전

if (memberType == 1) { discountRate = 0.0; }
if (memberType == 2) { discountRate = 0.1; }
if (memberType == 3) { discountRate = 0.2; }

숫자가 무엇을 의미하는지 알 수 없다.

상수 버전

public static final int BASIC = 1;
public static final int GOLD = 2;
public static final int VIP = 3;

이름이 생겼지만 정수형이기 때문에 member.setGrade(999)처럼 존재하지 않는 값을 할당해도 컴파일러가 오류를 잡아내지 못한다.

Enum 버전

enum MemberGrade {
    BASIC,
    GOLD,
    VIP
}

if (member.getGrade() == MemberGrade.VIP) {
    applyDiscount();
}

Enum을 사용하면 컴파일 단계에서 허용되지 않은 값을 걸러낼 수 있다. MemberGrade 타입에는 BASIC, GOLD, VIP 외의 값을 넣을 수 없으므로, 잘못된 값이 코드베이스에 침투할 가능성이 사전에 차단된다. MemberGrade라는 이름 하나로 회원이 가질 수 있는 모든 상태를 한눈에 파악할 수 있고, 정수 상수 목록과 달리 관련 개념이 하나의 타입으로 묶이기 때문에 IDE의 자동 완성이나 리팩토링 도구도 효과적으로 활용할 수 있다.

Enum도 충분하지 않다

다음 코드가 시스템 곳곳에 존재한다고 가정해보자.

if (member.getGrade() == MemberGrade.GOLD
        || member.getGrade() == MemberGrade.VIP) {
    showDiscountBanner();
}

if (member.getGrade() == MemberGrade.GOLD
        || member.getGrade() == MemberGrade.VIP) {
    applyDiscount();
}

if (member.getGrade() == MemberGrade.GOLD
        || member.getGrade() == MemberGrade.VIP) {
    issueCoupon();
}

문제는 GOLDVIP가 아니다. “할인을 받을 수 있는 회원”이라는 비즈니스 규칙이 여러 곳에 중복되어 있다는 것이다. 새로운 회원 등급 PREMIUM이 추가되고 이 등급도 할인 대상이 된다면 위의 조건문을 모두 찾아 수정해야 한다. 세 곳을 모두 찾았다면 다행이지만, 다섯 번째 여섯 번째 중복 조건문을 놓치면 버그가 된다. Enum은 데이터를 타입으로 모델링했지만, 그 데이터를 해석하는 행동(Behavior)은 여전히 외부에 노출되어 있다.

4. 데이터 추상

여기서 질문이 바뀐다. 코드를 읽는 사람이 정말 알고 싶은 것은 member.getGrade() == MemberGrade.GOLD인가? 아니다. 실제로 알고 싶은 것은 “이 회원이 할인 대상인가?” 이다. 그렇다면 그 판단을 Member 객체 안으로 가져온다.

class Member {

    private MemberGrade grade;

    public boolean canReceiveDiscount() {
        return grade == MemberGrade.GOLD
            || grade == MemberGrade.VIP;
    }
}

호출하는 쪽은 이렇게 된다.

if (member.canReceiveDiscount()) {
    applyDiscount();
}

이 한 줄은 이제 비즈니스 의도와 코드 표현이 일치한다.

무엇이 달라지는가

데이터 추상으로 달라지는 것은 크게 네 가지다. 변경의 국소화, 정보 은닉, 중복 지식 제거, 도메인 언어와의 일치.

상수화가 해결한 것은 20 → 19처럼 값(Value)의 변경이다. 데이터 추상은 한 단계 더 나아가 “어떤 회원이 할인 대상인가?”라는 정책(Policy)의 변경까지 한 곳에서 처리한다. PREMIUM 등급이 추가되어 할인 대상에 포함되어야 한다면 canReceiveDiscount() 메서드 하나만 수정하면 된다. 이 메서드를 호출하는 코드는 아무것도 바꾸지 않아도 된다. 변경 범위가 객체 경계 안으로 완전히 들어온 것이다.

David Parnas는 1972년 논문 “On the Criteria To Be Used in Decomposing Systems into Modules”에서 모듈 분해의 핵심 기준으로 변경 가능성이 있는 설계 결정을 숨기는 것을 제시했다. 그가 강조한 것은 단순히 데이터를 감추는 것이 아니라, 변할 가능성이 있는 결정(decision) 자체를 모듈 경계 뒤에 숨기는 것이다. 회원 할인 정책은 비즈니스 환경이 바뀔 때마다 변경된다. 이 결정을 grade == GOLD || grade == VIP라는 형태로 코드 곳곳에 노출하는 대신 member.canReceiveDiscount() 뒤에 숨기면 Parnas가 말한 정보 은닉이 실현된다. 호출자는 “이 회원이 할인 대상인가”라는 질문의 답만 얻고, 그 판단 근거는 알 필요가 없다.

DRY(Don’t Repeat Yourself)의 원래 의미도 같은 방향을 가리킨다. The Pragmatic Programmer에서 Andy Hunt와 Dave Thomas가 정의한 것처럼 시스템 안의 모든 지식은 단 하나의 명확한 표현을 가져야 한다. “할인 가능 회원 판정 규칙”은 하나의 지식이다. 이 지식이 showDiscountBanner(), applyDiscount(), issueCoupon() 등 여러 곳에 같은 형태로 복제되어 있다면, 정책이 변경될 때 모든 복제본을 동기화해야 하는 책임이 생긴다. 하나라도 빠지면 시스템은 일관성을 잃는다. 데이터 추상은 이 지식을 한 객체 안에 단 한 번만 표현한다.

도메인 언어 측면에서도 차이가 있다. 비즈니스 담당자는 “회원 등급이 GOLD 또는 VIP입니다”라고 말하지 않는다. “이 회원은 할인을 받을 수 있습니다”라고 말한다. Eric Evans가 Domain-Driven Design에서 제시한 유비쿼터스 언어(Ubiquitous Language)는 도메인 전문가와 개발자가 같은 언어를 사용해야 한다는 원칙이다. member.canReceiveDiscount()는 비즈니스 언어 그대로를 코드로 표현한다. 반면 grade == GOLD || grade == VIP는 구현 세부 사항이며, 도메인 언어와 거리가 있다.

이름 붙이기의 마지막 단계

Arlo Belshee는 Naming Process에서 이름이 진화하는 단계를 다음과 같이 설명한다.

3 - 값
↓
VIP - 이름(상수화)
↓
MemberGrade - 개념(Enum)
↓
member.canReceiveDiscount() - 행동(데이터 추상화)

첫 번째는 값이다. 두 번째는 이름이다. 세 번째는 개념이다. 네 번째는 행동이다. 많은 개발자가 세 번째 단계인 MemberGrade.VIP를 최종 목적지로 여긴다. 그러나 코드를 호출하는 쪽이 알고 싶은 것은 “회원 등급이 무엇인가”가 아니라 “이 회원이 할인을 받을 수 있는가”이기 때문에, 실제 목적지는 네 번째 단계인 member.canReceiveDiscount()다. Enum은 개념을 정확히 모델링했지만, 그 개념을 활용해 판단을 내리는 행동은 여전히 외부에 위임되어 있다. 데이터 추상은 그 판단까지 객체 안으로 흡수한다.

이 단계 구분은 Barbara Liskov와 Stephen Zilles가 1974년 제안한 추상 데이터 타입(ADT) 개념과도 연결된다. ADT는 “사용자는 인터페이스만 알면 되고, 구현은 숨겨진다”는 원칙을 기반으로 한다. canReceiveDiscount()는 인터페이스다. 내부에서 GOLDVIP를 어떻게 비교하는지는 구현이다. 사용자는 구현을 알 필요가 없고, 인터페이스가 제공하는 질문에만 집중하면 된다.

Enum을 공개하는 라이브러리는 잘못된 설계인가

HTTP 상태 코드를 나타내는 HttpStatus 같은 타입은 대부분의 라이브러리에서 Enum으로 공개된다. 401이나 404가 호출하는 쪽에서 보인다. 앞서 설명한 데이터 추상의 관점에서 보면 잘못된 설계처럼 보이지만, 그렇지 않다.

라이브러리가 상태 코드를 Enum으로 공개하는 것은 라이브러리가 사용 맥락을 모르기 때문이다. 401을 받아서 재시도할지, 재로그인 화면으로 이동할지, 로그만 남길지. 이 판단은 라이브러리 쪽에 없다. 사용하는 애플리케이션마다 다르다. Parnas의 정보 은닉이 숨기라고 하는 것은 “변하기 쉬운 판단”이며, 판단이 모듈 안에 없으면 숨길 것도 없다.

판단은 사용하는 쪽에 있다. 401을 “재인증이 필요한 상태”로 해석하고, 403이나 만료된 토큰과 함께 처리할지 분리할지를 결정하는 것은 그 애플리케이션의 도메인 판단이다. 따라서 if (res.statusCode == HttpStatus.UNAUTHORIZED)를 곳곳에 복제하는 대신, 그 판단을 authResult.requiresReauthentication()과 같이 자신의 도메인 타입에 담아야 한다. HttpStatusisUnauthorized() 같은 메서드로 일대일 치환하는 것만으로는 Enum을 메서드 이름으로 바꿨을 뿐, 판단을 호출하는 쪽에 남긴 채이기는 마찬가지다.

기준은 레이어의 차이도, 라이브러리인지 애플리케이션인지의 구분도 아니다. 그 코드가 값에 대한 판단을 갖고 있는가 여부다. 판단을 갖는 쪽이 데이터 추상을 만들고, 판단이 없는 쪽은 값을 공개해도 된다. 범용 라이브러리가 Enum을 공개할 수 있는 것은 “범용이기 때문”이 아니라, 판단을 갖지 않는다는 조건을 충족하기 때문이다.

결론

상수화, Enum, 데이터 추상은 각각 다른 문제를 해결한다. 상수화는 값에 이름을 붙이고, Enum은 허용 가능한 값을 타입으로 정의하며, 데이터 추상은 값에 대한 판단을 객체 안으로 가져온다. 많은 팀이 Enum을 도입하면 충분하다고 생각하지만, Enum이 해결하는 것은 타입 안전성이지 판단의 중복이 아니다.

매직 넘버를 발견했다면 단순히 상수로 추출하는 데서 멈추지 말자. 그 값이 어떤 비즈니스 개념을 나타내는지, 그리고 그 개념을 어떤 객체가 책임져야 하는지를 고민해 보자. 그 질문이 바로 데이터 추상과 도메인 모델링의 출발점이다.

추가로 읽어볼 만한 자료


Mimul

Written byMimul
Mimul is a programmer, technologist, exercise enthusiast and more.
Connect