개발을 처음 배울 때, 인터넷의 강의, 책, 다양한 멘토들은 이렇게 말한다.
"매직 넘버(Magic Number)를 쓰지 말고, 상수로 정의하라."
처음 들었을 때는 그냥 그런가 보다 하고 넘겼다.
그런데 실제로 코드를 작성하다 보면 이런 생각이 든다.
"그러면 숫자나 문자는 무조건 다 상수로 만들어야 하나?"
정답을 먼저 말하자면, 아니다. 오히려 무분별한 상수화는 코드를 더 읽기 어렵게 만들 수 있다.
본 포스팅에서는 상수(Constants)라는 개념이 왜 등장했는지, 그리고 어떤 기준으로 상수화 여부를 판단해야 하는지를 정리한다.
Wikipedia에 따르면, 매직 넘버는 "소스 코드에서 맥락 없이 그 의미가 불명확한 숫자 리터럴" 을 뜻한다.
이 개념은 COBOL, FORTRAN, PL/1 등의 언어가 등장한 1960년대 프로그래밍 초창기부터 안티 패턴으로 여겨져 왔다. 그 역사가 수십 년에 달한다.
// 이게 바로 매직 넘버의 예시다.
// 9.81 이 뭔지 코드만 봐서는 모른다.
double energy = mass * 9.81 * height;
위 코드를 처음 보는 사람은 9.81이 뭔지 모른다. 물리학 전공자라면 중력가속도임을 눈치채겠지만, 그렇지 않은 사람은 "왜 저 숫자야?"라고 물을 수밖에 없다.
이런 맥락에서 명명된 상수(Named Constant), 즉 설명 상수(Explanatory Constant) 라는 개념이 자리 잡기 시작했다.
// 상수로 만드면, 코드가 스스로 의미를 설명한다.
private static final double GRAVITATIONAL_CONSTANT = 9.81;
double energy = mass * GRAVITATIONAL_CONSTANT * height;
9.81이라는 숫자에 이름이 붙는 순간, 의도가 드러난다.
매직 넘버를 그냥 쓰면 세 가지 문제가 생긴다.
첫째, 의도가 숨겨진다.
숫자 자체로는 "왜 이 값이야?"를 알 수 없다. 나중에 코드를 읽는 사람은, 심지어 미래의 자신도 맥락을 이해하기 어렵다.
둘째, 수정이 위험해진다.
같은 숫자가 여러 곳에 흩어져 있으면, 하나를 바꿀 때 나머지를 빠트리기 쉽다. 그리고 같은 숫자가 전혀 다른 의미로 쓰이는 경우도 있다. 예를 들어, 5가 한 곳에선 최소 비밀번호 길이고, 다른 곳에선 최대 재시도 횟수일 수 있다.
셋째, 오타가 숨는다.
3.14159265358979323846처럼 긴 숫자를 직접 여러 곳에 쓰다 보면, 어딘가 한 자리가 틀려도 눈치채기 매우 어렵다.
Java에는 C/C++처럼 const 키워드가 없다. 대신 static final 조합으로 상수를 구현한다.
// Java의 상수 선언 관용 표현
// - static: 클래스 레벨에서 하나의 값만 존재
// - final: 한 번 할당 후 변경 불가
// - 이름은 UPPER_SNAKE_CASE 관례를 따른다
public static final int MAX_USERNAME_LENGTH = 10;
여러 곳에서 공통으로 사용하는 상수가 많다면, 전용 상수 클래스를 만드는 방법을 자주 쓴다.
// AppConstants.java
// - final 클래스: 상속 방지
// - private 생성자: 인스턴스화 방지
// → 이 클래스는 "상수를 담는 그릇"임을 명확히 한다
public final class AppConstants {
// 인스턴스 생성을 막는다
private AppConstants() {}
// JWT 관련 상수
public static final String BEARER_PREFIX = "Bearer ";
public static final long ACCESS_TOKEN_EXPIRATION = 1800000L; // 30분
// 유저네임 관련 상수
public static final int MIN_USERNAME_LENGTH = 4;
public static final int MAX_USERNAME_LENGTH = 10;
}
주의: 모든 상수를 하나의 Constants 클래스에 몰아넣는 방식은 클래스가 커질수록 역할이 불명확해진다. 관련된 도메인별로 상수를 분리하는 것이 더 좋은 방향이다.
패키지 또는 도메인별로 분리한 예시다.
// 도메인별로 상수 클래스를 분리하는 것이 더 현실적이다
public final class JwtConstants {
private JwtConstants() {}
public static final String BEARER_PREFIX = "Bearer ";
public static final long ACCESS_TOKEN_EXPIRATION = 1800000L;
}
public final class UserConstants {
private UserConstants() {}
public static final int MIN_USERNAME_LENGTH = 4;
public static final int MAX_USERNAME_LENGTH = 10;
}
연관된 상수의 집합이라면,
static final보다enum이 더 나은 선택인 경우가 많다.
Java 5부터 도입된 enum은 단순한 상수 이상의 역할을 한다.
// static final 방식: 타입 안전성이 없다
// "ADMIN"인지 "admin"인지, 오타가 나도 컴파일 시점에 잡히지 않는다
public static final String ROLE_ADMIN = "ADMIN";
public static final String ROLE_USER = "USER";
// enum 방식: 가능한 값 자체를 제한한다
// 잘못된 값을 넘기면 컴파일 에러가 발생한다 → 훨씬 안전하다
public enum Role {
ADMIN, USER
}
enum은 컴파일 타임에 허용된 값 외에는 들어올 수 없게 막아준다. 상태, 타입, 카테고리처럼 "정해진 선택지 안에서 하나를 고르는" 형태라면 enum이 더 적합하다.
// enum은 필드와 메서드도 가질 수 있다
// → 단순한 값 묶음을 넘어, 로직까지 담을 수 있다
public enum HttpMethod {
GET, POST, PUT, DELETE, PATCH;
public boolean isWriteMethod() {
return this == POST || this == PUT || this == PATCH;
}
}
1) 비즈니스 규칙이 담긴 숫자
// "왜 4~10이야?" 라는 질문이 생기는 값
// → 숫자 뒤에 정책적 결정이 있다
private static final int MIN_USERNAME_LENGTH = 4;
private static final int MAX_USERNAME_LENGTH = 10;
2) 여러 곳에서 반복 사용되는 값
// UserController, JwtFilter, JwtUtil 등 여러 클래스에서 동일하게 쓰인다
// → 한 곳에서 관리하면 수정 시 한 줄만 바꾸면 된다
private static final String BEARER_PREFIX = "Bearer ";
3) 나중에 바뀔 수 있는 값
// 정책이 바뀌면 수정할 가능성이 있다
// → 상수화해두면 변경 지점이 하나로 모인다
private static final long ACCESS_TOKEN_EXPIRATION = 1800000L; // 30분
4) 숫자만 봐서는 의미를 알 수 없는 값
// 5가 뭔지, 맥락 없이는 알 수 없다
private static final long ORDER_CANCEL_LIMIT_MINUTES = 5L;
1) 코드 맥락상 의미가 명확한 값
// 반복 횟수임을 누구나 안다. 굳이 THREE_TIMES 같은 상수를 만들 필요 없다
for (int i = 0; i < 3; i++) { ... }
// 0은 시작 인덱스임이 자명하다
list.subList(0, 10);
2) 한 곳에서만 쓰이고, 변경 가능성이 거의 없는 표준 값
// 200이 HTTP OK라는 것은 HTTP 표준이다
// 다만, 이 경우엔 HttpStatus.OK 처럼 이미 정의된 표현을 쓰는 것이 더 낫다
return ResponseEntity.status(200).build();
// ↓ 이게 더 좋다
return ResponseEntity.ok().build();
3) 테스트 코드 안의 픽스처 값
// 테스트용 데이터는 테스트 맥락 안에서만 쓰이며 변하지 않는다
// 상수화를 강요하면 오히려 테스트 가독성이 떨어진다
assertThat(response.getUserId()).isEqualTo(1L);
// 이건 너무 과하다. 코드를 더 읽기 어렵게 만들 뿐이다
private static final int ZERO = 0;
private static final String EMPTY = "";
private static final boolean TRUE = true;
// 아래 코드가 훨씬 직관적이다
if (list.size() == ZERO) { ... } // ← 이것보다
if (list.size() == 0) { ... } // ← 이게 낫다
상수화의 목적은 "값에 의도를 부여하는 것" 이다. 의도가 이미 명확한 값까지 상수로 만드는 것은 의미 없는 노이즈를 추가하는 행위다.
"이 값을 처음 보는 팀원이..."
"왜 이 숫자/문자야?" 라고 물을 것 같다 → 상수로 만든다
"당연히 그렇지" 라고 생각할 것 같다 → 그냥 둔다
실제 개발 환경에서는 이미 구축된 레거시 코드에 상수가 산재해 있는 경우가 많다. 과거에는 인터페이스에 상수를 정의하는 방식도 흔히 쓰였다.
// 과거에 종종 보이던 패턴 (인터페이스에 상수 정의)
// Java 언어 설계자들은 이 방식을 좋지 않게 본다 → 지양하는 것이 좋다
public interface OldStyleConstants {
String APP_NAME = "MyApp"; // 암묵적으로 public static final
}
인터페이스의 본래 목적은 "계약(contract)을 정의하는 것"이다. 상수를 담는 그릇으로 쓰는 것은 의도와 맞지 않고, implements를 통해 상속받는 방식은 특히 나쁜 패턴으로 알려져 있다.
enum의 적극적 활용을 권장하는 분위기최근 Java 생태계에서는 관련된 상수의 집합을 static final로 나열하기보다, enum으로 묶는 방향이 더 권장된다. 타입 안전성(type safety)이 보장되고, 허용된 값의 범위를 명시적으로 제한할 수 있기 때문이다.
// Before: static final 나열 방식
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
// After: enum으로 표현 → 잘못된 값 자체가 들어올 수 없다
public enum OrderStatus {
PENDING, APPROVED, REJECTED
}
Spring Boot 환경에서는 상수를 직접 코드에 두기보다, 변경 가능성이 있는 설정값은 application.properties 또는 application.yml로 분리하는 것이 일반적이다. 진짜 불변인 값만 상수 클래스에 두고, 환경에 따라 달라지는 값은 설정 파일에 위임하는 것이 현실적으로 더 좋은 구조다.
# application.yml
jwt:
access-token-expiration: 1800000
bearer-prefix: "Bearer "
// @Value 또는 @ConfigurationProperties 로 주입받는다
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration;
상수화는 코드의 "가독성"과 "유지보수성"을 위한 도구다. 목적을 잊고 형식만 따르는 순간, 도리어 코드를 복잡하게 만드는 역효과가 생긴다.
핵심은 단 하나다.
"이 값이 왜 이 숫자인지" 설명이 필요하다면 상수로 만들어라. 그렇지 않다면 그냥 써라.
처음에는 기준이 애매하게 느껴질 수 있다. 하지만 코드를 많이 읽고 쓰다 보면, 어느 순간 "이건 이름이 필요하다"는 감각이 자연스럽게 길러진다. 그 감각이 생기는 것이 좋은 개발자로 성장하는 과정이라고 생각한다.