닫힌 항목

여기는 아직 정하지 않은 것에서 표준으로 닫힌 항목을 옮겨두는 곳이다. 지우지 않는 이유는 하나다 — 무엇이 왜 열려 있었는지가 그 표준을 만든 이유이고, 그게 그 글의 도입부가 된다.

읽는 시점이 열린 장부와 다르다. 새 표준을 쓰기 전에 읽어야 하는 건 열린 쪽이고, 여기는 “이건 왜 이렇게 정했더라”를 되짚을 때 온다. 그래서 페이지를 갈랐다.

항목어떻게 닫혔나
페이징 방식을 PagingResult가 필드 하나로 이미 정해버렸다
페이징 방식 표준. 하나를 고르는 문제인 줄 알고 시작했는데 화면 요구가 둘 다였다 — 관리자는 페이지 번호가 필요하고 사용자는 무한 스크롤이다. 그래서 질문이 "offset이냐 커서냐"에서 "둘을 어떤 타입 구조로 담을 것인가"로 바뀌었다. offset만 쓰는 안은 무한 스크롤이 깊이 들어가는 게 정상 사용이라 OFFSET이 계속 커지고 새 행이 들어오면 이미 본 항목이 다시 나온다(성능이 아니라 정확성 문제라 버렸다). 커서만 쓰는 안은 임의 페이지 점프가 원리적으로 불가능해 관리자 화면이 성립하지 않는다. 한 타입에 totalCount·page를 nullable로 두는 안은 어느 방식으로 만들어진 결과인지 타입으로 알 수 없어 컴파일러가 아무것도 못 막는다. CursorResult<T>를 따로 만드는 안을 골랐다 — 타입이 곧 방식이라 커서 응답에 totalCount를 실을 방법이 아예 없다. 대가는 Controller가 두 모양이 되는 것이다. 가르는 기준으로는 넷을 비교했다 — 화면 종류는 관리자 화면에 무한 스크롤이 하나 생기면 깨지고, totalCount 표시 여부는 "총 건수만 보여주는 무한 스크롤"이 반례이고, 결과 깊이는 "깊다"의 숫자가 아직 없다. 남은 "임의 페이지로 점프할 수 있어야 하는가"만이 우연이 아니라 정의상 타입과 1:1로 붙었다. 확정 직전에 기존 글의 근거가 깨지는 걸 발견했다 — DTO 네이밍 표준은 PagingResultService 접두사가 없는 이유를 "totalCount가 COUNT 쿼리에서 나오므로"라고 적었는데, totalCount가 없는 CursorResult는 그 문장으로 설명되지 않았다. 결론은 같고 근거만 어긋난 경우라 근거를 "페이징 메타는 조회 그 자체에서만 나온다"로 한 단계 올리고 기존 글까지 고쳤다. 고른 안이 무너지는 경로도 막았다 — 두 타입을 공통 인터페이스로 묶고 싶어지는데, 묶는 순간 타입으로 방식을 구분할 수 없게 되어 버린 안으로 되돌아간다. 커서를 인코딩하는 이유를 반대로 알 뻔했다 — 콤마 때문에 묶는 게 아니라 묶었더니 콤마가 문제가 된 것이었다. 검문은 한 줄이었다: 콤마가 문제라면 퍼센트 인코딩으로 끝난다. 진짜 이유는 풀어서 실으면 정렬이 바뀔 때 파라미터 이름 자체가 바뀌어 클라이언트가 조립해야 한다는 것이었고, Base64로 감추는 이유는 따로 "읽히면 언젠가 파싱된다"였다. 그래서 불투명한 것과 안전한 것을 갈랐다 — Base64는 서명이 아니므로 디코딩 후 타입을 검증하고 반드시 바인딩 파라미터로 넣는다. 같은 갈림길을 두 번 만나 원칙이 하나 나왔다(정렬 변경, 다중 정렬) — 확장성은 계약의 모양에서 사고 구현에서는 사지 않는다. 계약은 나중에 못 바꾸고 구현은 언제든 바꿀 수 있어서다. 다중 정렬은 그래서 @RequestParam List<String> sort로 계약만 열어두고 지금은 하나만 읽는다 — 조합 수를 세어보니 컬럼 순서만 15가지, ASC/DESC까지 곱하면 78가지였고, 열어놓고 인덱스를 안 만들면 표준이 허용한 것이 보장한 것으로 읽혀 함정이 된다. 마지막으로, 인덱스 원리를 근거로 쓰면서 그 원리를 설명하지 못한다는 걸 알았다 — 표준으로 닫을 수 있는 종류가 아니라 공부 목록 페이지를 새로 만들어 넘겼다. 정렬 무효 커서의 처리와 복합 인덱스 컬럼 순서는 열린 항목에 남겼다
스키마 DDL이 어디 사는지 정한 적이 없었다
스키마 마이그레이션 표준. 장부에 "0건"이라 적혀 있어서 백지인 줄 알았는데, 이미 답이 좁혀져 있었다DB 시간 저장 표준이 컬럼 기본값을 DB에 맡기고(ON UPDATE CURRENT_TIMESTAMP(6)ddl-auto가 못 만든다), 테스트를 실제 MariaDB에서 돌리기로 했고(ddl-auto면 검증 스키마 ≠ 운영 스키마), 동적 검색을 MyBatis로 갈라놨다(Entity에 안 보이는 테이블이 생긴다). 정한 적이 없는 게 아니라 정해놓고 인지하지 못한 상태였다. Liquibase는 이식성이 값인데 MariaDB에 이미 묶여 있어 버렸고, Flyway는 표준의 SQL을 번역 없이 그대로 받아서 골랐다. ddl-autovalidate로 둬 마이그레이션 누락이 런타임이 아니라 부팅에서 죽게 했다(단 Entity → DB 방향만 보므로 MyBatis 전용 테이블은 검사 밖이다). 파일 이름에서 한 번 틀렸다 — 일련번호가 읽기 쉬워 그쪽으로 가려 했는데 근거가 "혼자라 번호 충돌은 아직 없는 문제"였다. 마이그레이션을 만드는 주체가 사람만이 아니었다(AI 에이전트가 병렬로 돈다). 타임스탬프로 바꿨다. 테스트는 운영과 같은 마이그레이션을 돌린다 — 두 벌이면 "테스트는 통과하는데 운영이 깨지는" 방향이 생기고, 그건 검증을 검증이 아니게 만든다. 인덱스 선언은 마이그레이션의 CREATE INDEX로 정하고 @Index를 금지했다(validate에서는 아무것도 안 만드는데 만든 것처럼 보인다). 여기서 드러난 기준에 이름을 붙였다 — 같은 것을 두 벌 적으면 언젠가 어긋난다. 컨트롤러 패키지 분리 표준이 instructions 두 벌을 버린 이유, 배포 스크립트가 .claude.agents를 대조하는 이유가 전부 같은 문장이었다. 복합 인덱스의 컬럼 순서는 못 정하고 미뤘다ORDER BY도 인덱스를 타는데 정렬 키 허용 범위와 페이징 방식이 그 입력이라, 열린 항목 8·11과 한 문제였다
같은 자리를 도메인 드리븐은 api, 3계층은 Controller라 불러 글과 배지가 어긋났다
컨트롤러 패키지 분리 표준. 범위를 잘못 재고 있었던 것이 출발점이다 — 장부에 적힌 "17군데"는 산문만 센 것이고, instructions.mdapplyTo 글로브가 별개의 표면이었다. layered를 단 9편 중 4편이 DDD 경로로 박혀 있어 3계층에서 아무 파일에도 안 붙고 아무 신호도 안 냈다(처음엔 본문 grep으로 15편 중 7편이라 잘못 셌다 — 소속은 front matter에만 있다). 그리고 글로브는 중립어로 얼버무릴 수 없으니 모순을 닫으려면 3계층 폴더 이름을 먼저 정해야 했다. 정하려는 자리에서 컨트롤러가 두 종류(JSON / SSR)라는 사실이 나왔고, 그 순간 api는 「양쪽이 다르게 부르는 자리」가 아니라 한쪽에서도 틀린 이름이 되어 병기 안이 죽었다. 출력 형식으로 api·web 형제 패키지를 가르자 3계층도 같은 이름을 쓰게 되어 모순의 절반이 덤으로 닫혔다. 남은 application·infra 7군데는 중립어를 지어내지 않고 양쪽에 이미 같은 이름으로 있는 클래스(Service·Repository 구현체)로 부르기로 했다. 3계층 폴더를 application/infra로 맞춰버리는 안은 infra가 「domain의 반대편」이라는 뜻인데 3계층에 domain이 없어서 버렸다
204를 배제한 논리가 304까지 배제해 조회 캐싱이 막혀 있었다
성공 응답 상태 표준에 원칙 11·12를 더했다. 204를 죽인 근거가 304에는 걸리지 않는다는 것이 갈림길이었다 — 근거는 "클라이언트가 파싱을 분기해야 한다"였는데, 304If-None-Match를 보낸 클라이언트에게만 가고 브라우저가 그걸 투명하게 처리한다. 그래서 봉투 예외는 요청에 조건이 적혀 있을 때만 열린다. 이름표는 완성된 본문의 해시로 만든다(얕은 ETag). updatedAt·@Version으로 만드는 안은 DB 조회까지 아끼지만 두 표준이 그 값을 Entity에 가둬놨고, 꺼내면 "Domain은 영속성을 모르는 POJO"라는 축이 흔들려서 버렸다. 대신 DB 조회와 직렬화 비용은 하나도 줄지 않고 응답 본문 전체를 버퍼링한다
성공 응답 상태를 언제 바꿀지 정한 적이 없었다
성공 응답 상태 표준. HTTP 메서드가 아니라 실제 처리 결과로 200·201·202를 가른다. 204는 공통 봉투를 깨므로 사용하지 않는다. 생성은 식별자와 상대 Location, 비동기 접수는 추적 식별자와 상태 조회 Location을 반환한다. 모든 Controller는 ResponseEntity<HttpApiResponse<T>>를 반환하며 실제 HTTP 상태, 본문 status, 코드 앞 세 자리를 항상 맞춘다. 성공 코드는 상태에서 계산하고 MockMvc로 계약을 검증한다
DB에 UTC 시점을 저장하는 방법이 없었다
DB 시간 저장 표준. MariaDB 11.8 LTS 이상에서 사건 시점을 TIMESTAMP(6)에 저장하고 모든 DB 연결 세션을 UTC로 초기화한다. Service와 외부 Adapter가 Instant를 마이크로초로 절삭한 뒤 Domain에 전달하므로 저장 전과 조회 후 값이 같다. 업무 사건 시점은 애플리케이션이 만들고, Entity에만 존재하는 createdAt·updatedAt은 DB 기본값과 자동 갱신이 관리한다. 실제 MariaDB에서 JPA와 MyBatis 왕복을 각각 검증한다
BusinessException의 이름과 실제 범위가 충돌했다
예외 처리 표준. 영속성 충돌과 외부 API 타임아웃까지 운반하는 타입을 비즈니스 예외라고 부르면 코드가 실제 범위를 숨긴다. 업무·기술 타입으로 나누는 안은 동시 수정처럼 경계가 모호한 실패에 두 번째 분류를 요구해서 버렸다. 원인과 외부 계약을 이해해 ErrorCode까지 부여한 분류 완료 실패라는 역할에 맞춰 ErrorCodeException으로 바꿨다. 대신 모든 기존 사용처를 바꾸고, 기술 예외를 번역할 때는 cause를 보존한다. 예상하지 못했거나 아직 분류하지 못한 실패는 감싸지 않고 원본 그대로 올린다
ErrorCode 공통 대역의 경계가 없었다
예외의 종류. 예외를 만든 기술이나 클라이언트의 복구 행동이 아니라 식별 가능한 업무 도메인으로 코드를 가른다. 프레임워크·영속성·외부 연동 실패도 도메인을 알 수 있으면 세분화하고, DomainCode.COMMON(0)은 도메인을 전혀 식별할 수 없는 요청과 처리 대상을 특정할 수 없는 최후의 실패에만 남긴다
Snapshot·Response의 null 표기가 없었다
경계 DTO의 null 표기. 다른 도메인의 Java 호출부까지 이름이 따라가는 SnapshotorNull, JSON 키가 계약인 Response@Nullable로 갈랐다. Response에 공개한 필드는 값이 없어도 키를 생략하지 않고 null로 내리며, 컬렉션은 빈 배열로 내린다. 기준은 요청과 응답의 방향이 아니라 그 이름이 JSON 키가 되는가
현재 시각을 얻는 방법이 없었다
애플리케이션 시간 표준. Service가 UTC Clock으로 업무 사건 직전에 시각을 한 번 얻고 Domain에는 Instant 값만 전달한다. 모든 시간 값을 UTC로 만드는 안은 생일과 영업일의 의미를 훼손해서 버리고, At·Date·Time·Zone 접미사와 타입을 함께 고정했다. LocalDateTime은 변경할 수 없는 외부 경계에서만 허용하고 연동별 ZoneId로 즉시 변환한다
3계층의 계층을 무엇으로 부를지 정한 적이 없었다
3계층 아키텍처 가이드. Presentation(Controller) · Business(Service) · Data Access 셋으로 못 박았다. Repository · DAO · MyBatis Mapper를 계층으로 나누지 않은 것이 핵심이다 — 셋은 같은 자리의 다른 구현이고, 기술 스택에 따라 이름만 달라진다. 나누는 순간 「이건 Repository 계층인가 Mapper 계층인가」를 매번 판단해야 한다. 도메인 드리븐과 달리 호출 방향과 의존 방향이 같아서 계층 지도(겹)도 두지 않았다
한 표준이 어느 아키텍처에 속하는지 적을 자리가 없었다
아키텍처를 둘로 나누자마자 걸렸다. 표준을 아키텍처 안에 넣으면 한 글이 한 아키텍처에만 속할 수 있어 양쪽에 걸치는 글은 항목을 복사해야 하고, 그 순간 같은 글의 요약이 두 곳에 생겨 어긋나기 시작한다. 뒤집어서 표준을 최상위에 두고 소속은 글의 front matter architectures: 가 들게 했다. 인덱스를 거치지 않고 검색으로 바로 들어온 사람도 글 머리에서 전제를 볼 수 있어야 하기 때문이다 — urltags를 글에서 가져오기로 한 것과 같은 이유다. 계층 매핑만 아키텍처마다 다르므로 인덱스 데이터에 남겼다
「3계층에도 적용되나」를 판단할 방법이 없었다
위 항목을 정리하다 질문이 둘로 갈린다는 걸 알았다. "이 글이 3계층에도 적용되나"는 3계층이 뭔지 정하기 전에는 답할 수 없다. 반면 "이 글이 계층 구조를 전제하나"는 지금 답할 수 있다 — 글을 읽으면 된다. 후자를 기준으로 삼았다. AssertNull-free 객체 설계Domain 패키지가 있든 없든 성립하지만, 패키지 구조애그리거트 경계는 네 겹이 없으면 문장 자체가 성립하지 않는다. 20개를 10 대 10으로 갈랐다
인증에 성공한 주체가 Command까지 가는 경로가 없었다
인증 주체 표준. common/security에는 실패 핸들러만 있고 성공 경로는 비어 있었다. LoginUserapi에서 받아 request.toCommand(loginUser)로 넘기고 Service에는 Long userId만 보낸다. 본인 확인은 검증 표준의 축이 그대로 답이었다
Repository에 delete가 없어 애그리거트를 어떻게 지우는지 정해진 적이 없었다
삭제 표준. "삭제"가 가리키던 넷 중 셋은 이미 규칙이 있었고 루트 제거만 비어 있었다. 다른 도메인이 참조하는 애그리거트는 지우지 않고, 지울 수 있는 것에만 delete(XxxDomain)을 둬 컴파일러가 막게 했다. 도메인 간 ID 참조에 DB FK를 걸기로 한 것도 여기서 함께 정했다
조인 결과가 infra 밖으로 나갈 타입이 없었다
조회 전용 타입 표준. domainXxxView를 두고 구현체가 Projection·MapperResult를 변환해 내보낸다. domain에 둘 수 있는 것이 셋에서 넷으로 늘었다
여러 Domain을 조합한 결과 타입의 이름 규칙이 없었다
조합도 조인과 같은 XxxView를 채운다. 그래서 "쿼리가 반복되면 옮긴다"는 이주가 채우는 주체만 바꾸는 일이 되고, application 위쪽은 안 바뀐다
동적 검색이면서 조인인 조회의 자리가 없었다
충돌이 아니라 층위가 달랐다. 동적 여부는 인터페이스와 구현 기술을, 조인 여부는 infra 안쪽 타입을 가른다. 밖으로 내보낼 XxxView가 생기면서 그 조합이 끝까지 간다
트랜잭션 경계를 정한 표준이 없는데 세 표준이 그걸 전제로 서 있었다
경계를 XxxService의 UseCase 구현 메서드로 못 박았다. 쓰기는 @Transactional, 조회는 readOnly = true. UseCase에 없는 package-private 내부 메서드에는 붙이지 않는다 — 프록시가 가로채지 못한다
Domain이 POJO인데 save()mergeDomain이 모르는 컬럼이 조용히 null로 덮였다
Domain 영속화 표준. 수정 경로에서 merge를 쓰지 않고 영속 Entity를 꺼내 applyTo로 덮는다. 자식 컬렉션은 id로 대조하고 cascade = ALL + orphanRemoval = true를 건다
잡으라고 정한 OptimisticLockingFailureException이 발생할 조건이 없었다
@Version을 Entity에만 두고 영속 Entity를 직접 수정하는 방식으로 바꾸니 Domain이 version을 모르는 채로 낙관적 락이 정상 동작한다. 예외의 종류 표준에 이 전제를 명시했다
「배치」가 벌크와 Domain 순회 잡을 동시에 가리켰다
벌크와 배치를 새로 쓰고 {도메인}/batchbulk로 리네임. 가르는 질문을 "도메인의 판단이 개입하는가"로 정했다
ErrorCode 뒤 3자리가 두 글에서 다른 뜻이었고, 도메인이 10개까지밖에 안 들어갔다
9자리(상태 3 + 도메인 3 + 일련번호 3)로 넓히고 도메인 번호를 DomainCode enum으로 분리. 공통 대역 침범 문제도 COMMON(0)으로 함께 닫혔다
UseCase 표준이 "Service 출력은 항상 봉투로 감싼다"를 고쳤는데 DTO 표준에 반영되지 않았다
XxxProvider의 반환은 감싸지 않는다는 예외를 DTO 네이밍 표준에 추가했다
XxxSnapshot의 생성 방식이 DTO 생성 표준에 없었다
private 생성자 + package-private 정적 팩토리 from()으로 명시했다
운영에서 -ea가 꺼져 있는데 AssertionError 핸들러를 두는 이유가 규칙 파일에 없었다
도달 불가 분기의 throw new AssertionError-ea와 무관하게 실행된다는 연결을 assert 표준에 넣었다
인덱스에 표준 4개가 빠져 있었다
Null-free 객체 설계, 상속 vs 합성, 팩토리 메소드 VO 생성, Assert를 참조 순서 맨 앞에 넣었다