패키지 구조 표준은 코드를 패키지(클래스를 묶는 폴더)로 나누는 규칙이다. 그 안에 common이라는 패키지가 있다. 주문 · 회원 같은 특정 도메인(업무 단위)에 속하지 않고 모든 곳이 같이 쓰는 클래스(응답 모양, 오류 코드 같은 것)를 모아 두는 곳이다.

여기서 「A 패키지가 common을 본다」는 A의 클래스가 common의 클래스를 import해서 쓴다는 뜻이다. 표준의 원칙 2는 모든 패키지가 common을 봐도 되는 이유에 조건을 하나 걸었다.

common이 프레임워크나 특정 계층에 의존하지 않는 한,
모든 패키지가 common을 봐도 의존 방향은 깨지지 않는다.

프레임워크는 여기서 Spring을 말한다. 「특정 계층」은 원칙 2가 도메인 하나를 나눈 네 겹(api · application · domain · infra) 중 하나다. 이 가운데 domain은 업무 규칙만 담는 가장 안쪽 패키지라, 원칙 2는 domain이 다른 겹을 쓰지 않게 정해 두었다. 그런데 domain도 common은 쓴다. 그래서 common이 Spring 같은 바깥 것을 쓰고 있으면 domain이 common을 쓰는 순간 그 바깥 것까지 딸려 들어와서, 정해 둔 의존 방향이 거꾸로 된다. 조건문은 그걸 막으려는 것이다.

그런데 같은 글 원칙 5는 두 무리의 클래스를 common에 둔다(아래 경로는 패키지/클래스 순서이고, *는 그 패키지 안 클래스 전부다).

  • common/error/ApiExceptionHandler — 모든 Controller에서 난 예외를 한곳에서 받아 HTTP 응답으로 바꾸는 클래스
  • common/security/* — 로그인하지 않은 사람 · 권한 없는 사람이 왔을 때 돌려줄 HTTP 응답을 만드는 클래스들

이 클래스들은 Spring이 정해 둔 자리에 끼우는 클래스다. Spring은 「예외 처리 담당은 이 애너테이션을 붙여라」, 「로그인 안 한 사람 응답은 이 인터페이스를 구현해라」처럼 자기 타입으로 자리를 정해 두고, 그 타입을 붙이거나 구현한 클래스를 찾아 부른다. 그래서 이 클래스들은 Spring의 타입을 쓸 수밖에 없어 보인다. 그러면 원칙 2의 조건이 깨진다. 조건과 배치가 서로 맞는지를 컴파일러에게 물었다.


주장 — common 구성은 Spring Web·Security 없이는 컴파일되지 않는다

표준이 common에 두기로 한 구성은 Spring Web과 Spring Security 없이는 컴파일되지 않는다
— 즉 common이 프레임워크에 의존한다.

Spring Web은 Spring 중 HTTP 요청 · 응답을 다루는 부분이고, Spring Security는 로그인 · 권한을 다루는 부분이다. 둘 다 jar 파일로 받는다.

근거 종류는 1(기술 사실)이다. 이 저장소는 증명할 근거를 네 종류로 나누고 종류마다 증명 방법을 하나씩 정해 두었는데(표준을 증명하는 방식 원칙 1), 1번 기술 사실은 직접 돌려 보면 참 · 거짓이 가려지는 종류다.

의존은 의견이 아니라 클래스패스에서 빼면 드러나는 것이다. 어떤 클래스가 Spring 타입을 쓰면, 그 타입이 든 jar를 클래스패스에서 뺐을 때 컴파일이 안 된다. 컴파일이 안 되면 그 클래스는 그 jar 없이는 존재할 수 없다는 뜻이고, 그게 곧 「의존한다」다.


반증 조건 — 두 jar를 빼고도 전부 컴파일되면 반증이다

반증 조건은 실험을 돌리기 전에 「이런 결과면 주장이 틀린 것으로 본다」고 먼저 적어 둔 기준이다. 빼는 두 jar는 Spring Web과 Spring Security의 웹 쪽 jar(spring-web, spring-security-web)이고, 컴파일하는 다섯 파일과 (가) · (나) 두 경우는 아래 「설계」에서 자세히 적는다. 짧게 말하면 (가)는 두 jar가 클래스패스에 있을 때, (나)는 뺐을 때다.

판정은 셋 중 하나다 — 주장이 뒷받침되면 supported, 틀렸으면 refuted, 이 실험으로는 가릴 수 없으면 inconclusive.

  • refuted — (나) 없을 때에도 다섯 파일이 전부 컴파일된다
  • supported — (나)에서 ApiExceptionHandler나 ApiAuthenticationEntryPoint가 컴파일 에러를 낸다
  • inconclusive — (가) 있을 때 컴파일되지 않는다(클래스패스를 잘못 짠 것이다)

세 번째 줄의 뜻은 이렇다. (가)는 필요한 jar를 전부 넣은 경우라 반드시 컴파일돼야 한다. 그런데도 안 되면 소스나 클래스패스 준비 자체가 틀린 것이고, 그러면 (나)의 실패도 jar를 뺀 탓인지 준비가 틀린 탓인지 가릴 수 없다.


설계 — 같은 소스를 클래스패스 두 벌로 각각 컴파일한다

표준 원칙 5의 common 구성을 최소로 재현했다. 다섯 파일이다.

  • HttpApiResponse — 모든 API(주소로 요청을 받아 응답을 돌려주는 창구)가 돌려주는 응답 모양
  • ErrorCode — 실패마다 붙이는 오류 코드 목록(enum)
  • ErrorCodeException — ErrorCode를 담아 던지는 예외
  • ApiExceptionHandler — @RestControllerAdvice를 붙인 클래스. @로 시작하는 애너테이션을 클래스 선언 위에 적어 Spring에게 「이 클래스가 예외 처리 담당이다」라고 알린다
  • ApiAuthenticationEntryPoint — Spring Security의 AuthenticationEntryPoint 인터페이스를 구현한 클래스

Spring을 쓰는 두 파일은 이렇게 시작한다(실험 폴더의 소스에서 import 줄과 선언 줄만 옮겼다).

// ApiExceptionHandler.java
package common.error;                                                   // 1
                                                                        // 2
import common.response.HttpApiResponse;                                 // 3
import org.springframework.http.ResponseEntity;                         // 4
import org.springframework.web.bind.annotation.ExceptionHandler;        // 5
import org.springframework.web.bind.annotation.RestControllerAdvice;    // 6
                                                                        // 7
@RestControllerAdvice                                                   // 8
public class ApiExceptionHandler {                                      // 9
  • 3번 줄은 같은 common 안의 클래스를 가져온다. 프레임워크와 상관없다
  • 4~6번 줄이 Spring Web 패키지(org.springframework.http, org.springframework.web.bind.annotation)에서 클래스를 가져온다. ResponseEntity는 HTTP 응답을 담는 상자다
  • 8번 줄이 6번에서 가져온 애너테이션을 클래스에 붙인다
// ApiAuthenticationEntryPoint.java
import org.springframework.security.web.AuthenticationEntryPoint;       // 7

public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {   // 11
  • 7번 줄이 Spring Security 웹 패키지에서 인터페이스를 가져오고, 11번 줄이 그 인터페이스를 구현한다

나머지 세 파일에는 org.springframework로 시작하는 import가 하나도 없다.

컴파일은 javac를 직접 불렀다. 빌드 도구인 Gradle로 컴파일하면, Gradle이 build.gradle(필요한 라이브러리를 적어 두는 Gradle 설정 파일)에 적힌 것을 보고 필요한 jar를 알아서 찾아 클래스패스에 넣는다(이것을 의존성 해석이라고 한다). 그러면 내가 뺀 jar를 Gradle이 다시 채워 넣었는지 아닌지를 따로 확인해야 한다. javac에 클래스패스를 손으로 넘기면 내가 준 jar만 쓰므로 그런 걱정이 없다. Gradle은 인터넷 저장소(Maven Central)에서 Spring Boot 3.5.16이 정한 버전의 jar를 받아 두 디렉터리로 나누는 데만 썼다.

(가) 있을 때   jar 39개
(나) 없을 때   jar 37개 — spring-web-6.2.19.jar, spring-security-web-6.5.11.jar 만 뺐다

jar가 39개나 되는 것은 Spring Web과 Spring Security를 받으면 그 둘이 쓰는 다른 jar까지 같이 따라오기 때문이다. 이것을 전이 의존이라고 한다. 전이 의존은 그대로 두고 정확히 두 jar만 뺐다. 이렇게 해야 「프레임워크에 의존한다」가 아니라 「이 둘에 의존한다」까지 좁혀 말할 수 있다.


결과 — 두 jar를 빼자 두 파일이 죽었다

아래는 실험 스크립트(run.sh)가 찍은 줄이다. run.sh는 명령들을 차례로 적어 둔 셸 스크립트 파일이라, 명령 한 번으로 jar 받기부터 두 번 컴파일하기까지 처음부터 다시 돌릴 수 있다. PROBE_ENV는 두 클래스패스의 jar 수와 뺀 jar, PROBE는 경우마다 javac의 종료 코드(exit, 0이면 성공이고 0이 아니면 실패) · 에러 수 · 컴파일한 파일 수 · 에러가 난 파일(failed, 없으면 -)이다. DIAG는 javac가 낸 진단 문장이고, 모두 10줄 중 세 줄만 옮겼다. 파일이름:4:는 그 파일의 4번째 줄이라는 뜻이다.

PROBE_ENV withjars=39 withoutjars=37 removed=spring-security-web-6.5.11.jar,spring-web-6.2.19.jar
PROBE case=with    exit=0 errors=0  files=5 failed=-
PROBE case=without exit=1 errors=10 files=5 failed=ApiAuthenticationEntryPoint.java,ApiExceptionHandler.java

DIAG without | ApiExceptionHandler.java:4: error: package org.springframework.http does not exist
DIAG without | ApiExceptionHandler.java:6: error: package org.springframework.web.bind.annotation does not exist
DIAG without | ApiAuthenticationEntryPoint.java:7: error: package org.springframework.security.web does not exist
  • 첫 줄 — (가)는 jar 39개, (나)는 37개이고, 빠진 것은 정확히 그 두 jar다
  • 둘째 줄 — (가)는 종료 코드 0, 에러 0. 다섯 파일이 전부 컴파일됐다
  • 셋째 줄 — (나)는 종료 코드 1, 에러 10. 에러가 난 파일은 ApiAuthenticationEntryPoint와 ApiExceptionHandler 둘이다
  • DIAG 세 줄 — 위 「설계」의 소스에서 본 그 import 줄(4번 · 6번 · 7번)에서 「그런 패키지가 없다」고 한다. 그 패키지가 들어 있던 jar를 뺐으니 컴파일러가 찾지 못한 것이다

옮기지 않은 일곱 줄도 같은 원인이다. ApiExceptionHandler.java의 5번 줄(import)에서 같은 「패키지가 없다」가 한 번 더 나고, 가져오지 못한 RestControllerAdvice · ExceptionHandler · ResponseEntity · AuthenticationEntryPoint를 본문에서 쓰는 줄마다 「그런 클래스를 못 찾겠다(cannot find symbol)」가 다섯 번, 인터페이스를 못 찾으니 @Override가 덮어쓸 메서드가 없다는 에러가 한 번 났다. 전문은 실험 폴더의 result.md에 있다.

판정은 supported다. 대조군(가)이 에러 0으로 전부 컴파일됐으므로 클래스패스를 잘못 짠 경우도 아니다. 나머지 세 파일(HttpApiResponse·ErrorCode·ErrorCodeException)은 두 경우 모두 컴파일된다.


예상과 달랐던 점 — 갈리는 선이 패키지가 아니라 파일 단위로 났다

common이 통째로 프레임워크에 묶인 것은 아니었다. 다섯 중 셋은 순수하고 둘만 묶여 있다. 여기서 「순수하다」는 Spring 타입을 하나도 import하지 않아서 자바 기본 라이브러리만으로 컴파일된다는 뜻이다.

ErrorCode가 HTTP 상태 코드(400, 404 같은 번호)를 Spring의 HttpStatus 타입이 아니라 int로 담기로 한 결정이 여기서 실제로 값을 했다. 이 결정은 예외 처리 표준에서 했고, 원칙 2도 그 결정을 「정확히 이 지점을 위한 결정」이라고 적어 두었다. HttpStatus는 ResponseEntity와 같은 org.springframework.http 패키지의 타입이다. 그 결정이 없었다면 셋도 같이 죽었을 것이다 — ErrorCode가 HttpStatus를 쓰면 ErrorCode가 먼저 컴파일되지 않고, HttpApiResponse는 ErrorCode를 import하고 ErrorCodeException은 ErrorCode를 필드로 들고 있어서 둘도 컴파일되지 않는다.

그래서 원칙 2의 조건문은 지금 이렇게 읽힌다.

원칙 2가 건 조건 실측
common이 프레임워크에 의존하지 않는 한 common/error/ApiExceptionHandler와 common/security/*가 의존한다
그러면 모든 패키지가 common을 봐도 방향이 안 깨진다 전제가 성립하지 않으므로 이 결론은 이 글로 뒷받침되지 않는다

domain이 실제로 Spring Web 타입에 닿는지는 이 실험이 재지 않았다. 「닿는다」는 domain의 클래스가 Spring Web 타입을 직접 쓰거나, Spring Web 타입을 쓰는 common 클래스를 가져다 쓰게 되는 것이다. domain이 쓰는 common 클래스가 순수한 셋뿐이라면 방향은 실제로는 안 깨질 수도 있다. 잰 것은 「common에 둔 파일이 그 jar 없이는 컴파일되지 않는다」까지다.


이 결과가 표준에 남기는 것 — 모순 이슈의 사실 절반이 확정됐다

이 어긋남은 이미 이슈(GitHub에 남겨 두는 「아직 못 푼 문제」 카드)로 열려 있었다. 이 저장소는 표준 안의 두 문장이 서로 부딪히는 이슈에 kind: 모순이라는 라벨을 붙인다. 이슈를 열 때는 원칙 2의 문장과 원칙 5의 목록을 눈으로 대조한 것이었고, 이제 컴파일러가 같은 답을 냈다.

이슈가 묻는 것은 둘이다. 하나는 사실 — 「common이 정말 프레임워크에 의존하는가」이고, 이 실험이 그걸 확정했다. 다른 하나는 판단이고 아직 남았다 — common을 순수한 쪽과 Spring을 쓰는 쪽으로 쪼갤지, 원칙 2의 조건문을 고칠지, 아니면 「common은 프레임워크에 의존해도 된다」로 정할지.

판정이 supported라 표준 글 원칙 2에는 > 실측(supported):으로 시작하는 한 줄을 단다. 블로그 글에서 >로 시작하는 줄은 인용 상자로 보이고, 그 줄에 이 실험의 요약과 이 글 링크가 들어간다. 규칙을 옹호하는 줄이 아니라, 규칙끼리 부딪히는 자리를 드러내는 줄이다.


정리

  • spring-web과 spring-security-web을 빼자 ApiExceptionHandler와 ApiAuthenticationEntryPoint가 에러 10개로 죽었다
  • HttpApiResponse·ErrorCode·ErrorCodeException은 두 경우 모두 컴파일된다. ErrorCode가 상태를 int로 담기로 한 결정 덕이다
  • 원칙 2가 건 조건(common은 프레임워크에 의존하지 않는다)은 원칙 5의 배치에서 성립하지 않는다
  • 재지 않은 것 — domain이 그 타입에 실제로 닿는지, common을 순수한 쪽과 Spring을 쓰는 쪽으로 쪼갰을 때의 비용

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기