전제 - 상수의 정의
원칙적으로 상수는 한 번 정의된 이상 수정할 수 없는(final) 객체이다. 다만 인스턴스 레벨에서 사용하는 게 아닌 클래스 레벨에서 사용되는 경우가 많은데(static), 왜냐하면 상수를 정의할 때 보통 해당 값이 프로젝트 내에서 동일하게 사용되기를 바라기 때문이다. 그리고 상수에 대해 private과 public 접근제어자는 둘 다 사용 가능하며, 경우에 따라 모두 유용하다. 선언한 클래스 내에서만 사용되기를 바라면 private으로, 전역으로 사용하고 싶으면 public을 사용하면 된다. 다만 이번 포스팅에서 다루는 상수는 public이라고 보면 된다. private 상수는 필요한 곳에서 선언해서 쓰면 그만이므로 딱히 더 신경 쓸 건 없어 보인다. 다만, 변수명이 중복될 수 있으므로 이는 조심해야겠다.
전제 - 상수의 네이밍 관습
상수 이름은 네이밍 관습상 대문자와 언더바만으로 구성된다. 다만 말이 관습이지 모두가 그렇게 하고 있고, 상수를 다른 변수들과 구분하여 인식할 수 있는 방법이므로 여러분도 그렇게 하면 되겠다. 물론 상수 컨테이너(일반적으로 사용되는 용어는 아닌 듯하나, 어쨌든 이 포스팅에서는 상수를 담은 클래스 혹은 열거형 타입 따위를 의미한다고 생각하면 되겠다)에는 원래 하던 대로 PascalCase로 작성하면 되겠다.
전제 - 숫자(Integer, Long)의 상수로서의 사용
포스팅에서 다루는 상수는 대부분 문자열(String)이며, 숫자도 밑에서 제시한 모든 원칙은 유효하다. 다만 대부분의 경우에 좀 더 복잡하고 유일한 값일 수 있는 문자열과 달리 숫자는 중복되는 값이 발생할 소지가 많고, (어차피 값으로는 숫자일 뿐이기 때문에) 이들 간 비교도 허용되지 않을 이유가 없으므로 문자열보다는 enum type 혹은 내부 클래스, private 변수 등을 더욱 적극적으로 고려할 만하다고 생각된다. 중요한 건 매직 넘버의 탄생을 막기 위해서, 숫자 상수는 어딘가에는 할당되어 사용하는 게 좋다는 것이다.
상수 관련해서 프로젝트 구조를 설정하다가 고려할 부분이 많은 듯하여 내용을 한 번 정리해본다. '이렇게 하는 게 가장 좋다!'라고 하기에는 필자의 앎이 부족하여 확실하지 않고, 개인 프로젝트를 하면서 배운 내용을 정리하는 것이기에 '실무에서는 이렇게 한다'라고 할 수도 없겠지만, 어쨌든 필자가 AI를 선생님들로 모시며(ㅋㅋ) 깨우친 내용을 최대한 체계적으로 작성할 것이므로 한 번 읽어 봐서 나쁠 건 없다고 하겠다.
어떤 작업을 하든지 간에 중요하게 고려되는 유지 보수성은 상수를 관리 할 때도 염두에 두어야 할 것이다. 이를 위해서 할 만한 여러 방법이 있겠지만, 그중에서 필자가 프로젝트에 적용한 방법 중 하나는 같은 수준의 기능을 갖는 객체를 한데 모아 관리하는 것이다. 이는 한 디렉토리에 비슷한 VO(Value Object) 파일을 보관한다는 의미가 될 수도 있고, 한 파일에 비슷한 단계에서 사용되는 상수를 보관한다는 의미가 될 수도 있겠다. SRP(단일 책임 원칙)를 지키려면 비슷한 단계에서 사용되더라도 실제 사용되는 맥락이 다른 이상, 그러한 객체들의 집합 각각을 모두 다른 파일에 집어넣는 게 맞는 게 아니냐 할 수도 있겠지만, 프로젝트를 만들다 보니 고작 상수 몇 개 때문에 파일을 분할하는 것이 과연 맞는 것인가 하는 생각이 들기도 했다. 어차피 경로에서 이들을 구분하더라도 static import를 고려하면 상수 이름을 각자 다르게 해주는 것이 더 좋은 방법으로 여겨지기 때문이다. 이와 관련한 설명은 밑에서 예시와 함께 더 이어 나가겠다.
다른 방법으로는 상수 이름을 가급적 중복되지 않게 하는 것이 있다. 필자는 이를 리팩토링을 하면서 크게 깨달았다. 이 포스팅을 작성하며 프로젝트 내의 상수의 구조를 대규모로 손봤는데, 파일의 위치가 옮겨지다 보니 인텔리제이 커뮤니티 특징상 변경된 경로를 자동으로 추적해 주지 않아, 일일이 수동으로 import를 다시 실행해야 했다. 그런데 변수 이름이 같은 게 있으면 서로 import 해줘야 하는 클래스가 다름에도 변수 이름이 같으므로 같은 라이브러리의 상수가 import 되는 경우가 있었다. 예로 밑의 코드를 보자.
...
import static site.hixview.domain.vo.manager.RequestURL.*;
import static site.hixview.domain.vo.manager.ViewName.*;
...
@PostMapping(UPDATE_ARTICLE_MAIN_URL + FINISH_URL)
public String submitModifyArticleMain(@ModelAttribute(ARTICLE) @Validated ArticleMainDto articleMainDto,
BindingResult bindingResult, RedirectAttributes redirect, Model model) {
if (bindingResult.hasErrors()) {
finishForRollback(bindingResult.getAllErrors().toString(), UPDATE_PROCESS_LAYOUT, BEAN_VALIDATION_ERROR, model);
model.addAttribute("updateUrl", UPDATE_ARTICLE_MAIN_URL + FINISH_URL);
return UPDATE_ARTICLE_MAIN_VIEW + AFTER_PROCESS_VIEW;
}
}
...
}
해당 로직의 기능과는 상관없이, 여기서 UPDATE_ARTICLE_MAIN_URL과 UPDATE_ARTICLE_MAIN_VIEW라는 두 상수를 주목하자. 만약 URL과 VIEW가 맨 위 import절에 쓰여 있는 것과 같이 경로에 포함되어 있다는 이유로 생략된다면, 변수명만으로는 이 두 변수를 구분하는 게 매우 어려워지게 될 것이다. 물론 이는 qualified access로 해결될 수 있는 문제이기는 하나, 필자가 보기에는 상수에 대한 public static import는 매우 흔한데 변수명 좀 줄이자고 이 기능을 아예 사용할 수 없는 것으로 만드는 건 소탐대실인 듯하다. 게다가 변수 그 자체로는 가독성이 보장되지 않는다는 점도 필자가 보기에는 상당한 문제가 된다.
이러한 이해를 위에서 제시한 "객체를 한데 모아 보관"한다는 개념과 연결지어 보겠다. 필자는 다음과 같은 VO를 구성하여 사용하고 있다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class RequestURL {
// CompanyArticle
public static final String ADD_SINGLE_COMPANY_ARTICLE_URL = "/manager/article/company/add/single";
public static final String ADD_COMPANY_ARTICLE_WITH_STRING_URL = "/manager/article/company/add/string";
public static final String SELECT_COMPANY_ARTICLE_URL = "/manager/article/company/select";
public static final String UPDATE_COMPANY_ARTICLE_URL = "/manager/article/company/update";
public static final String REMOVE_COMPANY_ARTICLE_URL = "/manager/article/company/remove";
// IndustryArticle
public static final String ADD_SINGLE_INDUSTRY_ARTICLE_URL = "/manager/article/industry/add/single";
public static final String ADD_INDUSTRY_ARTICLE_WITH_STRING_URL = "/manager/article/industry/add/string";
public static final String SELECT_INDUSTRY_ARTICLE_URL = "/manager/article/industry/select";
public static final String UPDATE_INDUSTRY_ARTICLE_URL = "/manager/article/industry/update";
public static final String REMOVE_INDUSTRY_ARTICLE_URL = "/manager/article/industry/remove";
// ArticleMain
public static final String ADD_ARTICLE_MAIN_URL = "/manager/article/main/add";
public static final String SELECT_ARTICLE_MAIN_URL = "/manager/article/main/select";
public static final String UPDATE_ARTICLE_MAIN_URL = "/manager/article/main/update";
public static final String REMOVE_ARTICLE_MAIN_URL = "/manager/article/main/remove";
// Company
public static final String ADD_SINGLE_COMPANY_URL = "/manager/company/add/single";
public static final String SELECT_COMPANY_URL = "/manager/company/select";
public static final String UPDATE_COMPANY_URL = "/manager/company/update";
public static final String REMOVE_COMPANY_URL = "/manager/company/remove";
// Member
public static final String SELECT_MEMBER_URL = "/manager/member/select";
}
사실, 디렉토리로서의 구분을 고려하거나 내부 정적 클래스 등을 고려한다면 위의 변수는 예를 들어 다음과 같은 방식으로 구조를 바꿔서 변수명을 축약하는 게 가능할 것이다:
ex1) 디렉토리로서의 구분: ADD_SINGLE_COMPANY_ARTICLE_URL
→ article/company/RequestURL 클래스 내부의 ADD_SINGLE
ex2) 내부 정적 클래스로서의 구분: ADD_SINGLE_COMPANY_ARTICLE_URL
→ RequestURL/CompanyArticle 클래스 내부의 ADD_SINGLE
다만 이런 식으로 코드를 구성한다면 즉각적으로 다음과 같은 문제점이 도출된다:
1. 우선 첫째, 디렉토리로서의 구분을 할 때는 프로젝트 구조가 매우 복잡해진다는 문제가 있다. 내부 정적 클래스를 사용하는 구조 혹은 필자가 위에서 제시한 파일 구조와 달리, 디렉토리로 파일을 일일이 구분하고자 한다면, 서로 다른 Article 클래스(CompanyArticle, IndustryArticle, EconomyArticle)들마다 각각 RequestURL이라는 클래스를 구현해야 한다. 통일성을 고려한다면 이를 유지하는 데도 일정한 시간이 들어갈 것이다. 게다가 위에서 제시한 변수명을 중복했을 때의 문제점에도 불구하고, 클래스 하나당 중복되는 변수명을 가진 변수가 하나씩 생성된다. 그때 만약 이 중복되는 상수를 같은 클래스에서 사용하고자 한다면, RequestURL 앞에서 qualified accss를 위한 어떤 정적 클래스도 구현되지 않았으므로 FQCN(Full Qualified Class Name)으로 변수를 사용해야 한다. 이를 막기 위한 추가적인 방법이 필요해질 것이고, 그게 어떤 방법이든지 결국 코드가 더욱 더 복잡해지는 결과를 맞이할 수밖에 없다.
2. 그렇다고 내부 정적 클래스로 구분하자니, 일단 위에도 언급된 중복된 변수명의 사용이라는 문제가 해결되지 않은 채 남는다. 더욱이, 이러한 방식의 구분도 정도의 차이가 존재할 뿐 코드의 깔끔함에 치명적인 문제를 일으키는데, 왜냐하면 최소한 Double Qualified Class Name(RequestURL.CompanyArticle.ADD_SINGLE)은 사용해야 기존 변수명이 지닌 의미가 보존되기 때문이다. RequestURL.CompanyArticle.ADD_SINGLE과 CompanyArticle.ADD_SINGLE이 주는 의미는 같지 않다. 차라리 이보다는 CompanyArticle.ADD_SINGLE_URL 따위가 나을 것인데, 기존의 CompanyArticle 클래스가 없을 수가 없을 것이다. 그래서 필자는 그냥 CompanyArticle 값도 변수명으로 집어넣는 것을 선택하였고, 이것이 위에서 나열된 변수명들이 형성된 계기가 된다.
참고로 위의 나열된 문자열을 통해 해당 프로젝트에서 어떤 URL이 어떤 목적으로 사용되는지도 한 눈에 알 수 있어 더욱 유용하다고 하겠다.
어쨌든 필자가 적용한 마지막 방법은 어떤 컨테이너를 사용하여 상수를 담을지를 명확히 하는 것이다. 컨테이너는 다음과 같이 네 가지로 생각할 수 있다:
(final) class, abstract class, interface, enum type.
뒤의 세 가지 뿐만 아니라 맨 앞의 객체도 Lombok 어노테이션(@NoArgsConstructor(access = AccessLevel.PRIVATE))을 사용하면 디폴트 생성자의 사용을 차단할 수 있으며, 아마 상수를 관리하는 객체에 요구되는 가장 기본적인 기능 중 하나가 이것일 것이다. 그래서 개발자들은 으레 각자의 생각에 따라 이들 중에서 가장 바람직한 것으로 여겨지는 객체를 사용하여 상수를 관리하는데, "각자의 생각에 따라"라는 건 정답이 없다는 말이다. 그래서 필자도 이 끝없는 논쟁에 한 숟가락 얹어 보려고 한다. 결론적으로, 필자는 다음과 같은 방식으로 이들을 사용하고 있다.
| 컨테이너 | 컨테이너 사용 여부 | 한 번에 사용되는 상수 개수 | 컨테이너 포함 기능 개수 | 스코프 | 비고 |
| final class + Lombok private constructor |
X | 단일 | 단일 또는 다수 | 전역 (public) | 단순한 상수 컨테이너를 사용하고자 할 때 |
| enum type | O | 다수 | 단일 | 전역 (public) | 상수가 특정 객체의 필드 값으로 사용될 때 |
| abstract class | X | 단일 | 단일 또는 다수 | 상속 관계 내부 (protected) | ㅡ |
| interface | X | 단일 | 단일 | 구현 관계 내부 (protected) | ㅡ |
아무리 개개인마다 생각이 다 다르다고는 하지만 설명을 좀 해보자면, enum type의 상수 컨테이너로서의 활용에서 두드러지는 특징은 컨테이너를 사용할 수 있다는 점과 한 번에 다수의 값을 다루는 데 특화되어 있다는 점을 십분 활용할 수 있다는 것이다. enum type은 Class<T>를 사용하여 제네릭 방식으로 모든 상수에 접근하는 것이 가능하며, values()라는 매우 유용한 메소드도 제공하므로 이들의 강점을 최대한 이용하는 방식으로 사용하는 게 옳다고 생각하였다.
또한 나머지를 고려할 때 주안점으로 둔 것은 상속과 구현에 관련해 이 컨테이너들을 어떻게 활용할 수 있을 것인가와 연관된다. final class의 경우 상속을 막기 때문에 상속, 구현과 무관한 전역으로의 사용이 보장될 수 있을 것이다. 또한 abstract class는 상속을 활용하여 위계 관계를 형성할 수 있기 때문에(ex - Article ← CompanyArticle), 상위 클래스에 protected 상수를 생성함으로써 특정 상속 관계에서 사용할 수 있을 것이다. 아니면 interface를 통해 특정 컴포넌트들이 해당하는 상수를 사용할 수 있도록 편의 기능처럼 제공하는 것도 가능할 것이다. 어쨌든 여기서 중요한 건, abstract는 상속에서, interface는 구현에서 사용하여 해당하는 컨테이너 본연의 역할을 수행하도록 함과 동시에 불필요한 전역 네임스페이스 오염이 발생하지 않도록 하는 것이다. 예를 들어, 테스트용으로 만들어진 상수가 있다면, 해당하는 상수가 메인 로직에서 접근되지 못하도록 설정하는 것이 있겠다. 이를 통해 상수의 접근성도 효과적으로 관리할 수 있을 것이다.
필자는 기본적으로 상수 컨테이너는 맨 위의 방법을 채택하여 사용하고 있다. 그리고 abstract class나 interface를 상수를 위해 따로 만든다기보다는 필요한 메소드 등과 합쳐서 컴포넌트로서 제공하려고 하고 있으며, 집합을 다루는 경우에 enum type을 적극적으로 활용하고 있다. 이러한 관리를 통해 좀 더 편리하고 체계적인 프로젝트 구조를 확립할 수 있을 것이다.
요약하자면, 필자는 이번 포스팅을 통해 다음과 같은 원칙을 강조하고자 하였다.
1. 같은 수준의 기능을 갖는 객체를 한데 모아 관리하자.
2. 상수 이름을 가급적 중복되지 않게 하자.
3. 어떤 컨테이너를 사용하여 상수를 담을지를 문서화하고 이를 정확하게 적용하자.
또한 이번 포스팅에서 구체적으로 설명하지 못한 것들 중에 접근제어자(private, protected, public 등) 설정 관리, 문서 관리, 그리고 네이밍에 대한 모범 관행 등이 빠져 있으므로, 해당하는 것들은 각자가 알아서 잘 찾아보도록 하자.
끝!
(참고한 블로그 포스팅)
'웹 개발 (Spring Boot)' 카테고리의 다른 글
| 레이어 아키텍처에서 클린 아키텍처 + DDD로 리팩토링하기 (1) (0) | 2025.12.04 |
|---|---|
| 사이드 프로젝트 팀장으로서의 깨달음 (2) | 2025.06.28 |
| 리플렉션(Reflection)으로 Enum 관련 유틸리티 정적 메소드 만들어 보기 (0) | 2024.10.27 |
| @TestPropertySource를 활용해서 테스트 DB 분리하기 (0) | 2024.09.27 |
| HttpServletRequestWrapper로 요청 매개변수 설정하기 (0) | 2024.09.10 |