열린 항목
여기는 아직 정하지 않았거나, 정한 것끼리 부딪히는 지점을 모아두는 곳이다.
표준을 읽다가 빈틈을 발견하면 그 자리에서 여기에 적는다. 대화가 끊겨도 남아야 하기 때문이다. 적을 때는 무엇이 비었는지와 어떻게 발견했는지를 같이 남긴다. 나중에 다시 읽을 때 “이게 왜 문제였지”를 복원할 수 있어야 한다.
- 모순 — 두 표준이 서로를 부정한다. 코드를 짜면 그 자리에서 막힌다
- 미정의 — 정한 적이 없다. 사람마다 다르게 구현하게 된다
닫을 때는 아래 표에서 지우지 않고 닫힌 항목으로 옮긴다. 무엇이 왜 열려 있었는지가 그 표준을 만든 이유이기 때문이다.
요청 한 사이클에서 비어 있는 자리
| # | 항목 | 종류 | 무엇을 정해야 하나 | 파급 |
|---|---|---|---|---|
| 1 | 현재 시각을 얻는 방법 | 미정의 | 마감 시각 같은 정책 판단은 Domain이 해야 하는데, Domain은 POJO라 Clock을 주입받을 수 없다. LocalDateTime.now()를 직접 부르면 테스트에서 시각을 고정할 수 없다 |
중간 |
| 2 | 응답 상태 코드 | 미정의 | HttpApiResponse.success(int status, T data) 오버로드가 예시 코드에 있는데 언제 쓰는지 규칙이 없다. 생성이 200인지 201인지, 삭제가 204인지, 그리고 code가 200000000으로 고정인 것과 status만 달라지는 관계가 미정 |
낮음 |
| 3 | 정상 요청 로깅과 추적 키 | 미정의 | 로그 규칙이 예외 경로에만 있다. 실패를 Advice에서 한 번 남기기로 했는데 그 로그 줄을 특정 요청과 이을 키가 없다. traceId·MDC를 정한 적이 없다 |
중간 |
| 4 | Snapshot·Response의 null 표기 |
미정의 | 계층별 검증 표준이 Request는 @Nullable, Command·Query·Domain은 orNull 접미사로 갈라놨는데 나머지 둘이 어느 쪽인지 없다 |
낮음 |
| 5 | ErrorCode 공통 대역의 경계 |
미정의 | 9자리 체계로 넘어오면서 DomainCode.COMMON(0)으로 자리는 잡혔다. 다만 어떤 실패를 공통으로 볼지의 기준 — 예를 들어 CONCURRENT_UPDATE가 공통인지 도메인인지 — 은 정한 적이 없다 |
낮음 |
닫힌 항목
무엇이 왜 열려 있었는지가 그 표준을 만든 이유다. 지우지 않고 남긴다.
| 항목 | 어떻게 닫혔나 |
|---|---|
인증에 성공한 주체가 Command까지 가는 경로가 없었다 |
인증 주체 표준. common/security에는 실패 핸들러만 있고 성공 경로는 비어 있었다. LoginUser를 api에서 받아 request.toCommand(loginUser)로 넘기고 application에는 Long userId만 보낸다. 본인 확인은 검증 표준의 축이 그대로 답이었다 |
Repository에 delete가 없어 애그리거트를 어떻게 지우는지 정해진 적이 없었다 |
삭제 표준. "삭제"가 가리키던 넷 중 셋은 이미 규칙이 있었고 루트 제거만 비어 있었다. 다른 도메인이 참조하는 애그리거트는 지우지 않고, 지울 수 있는 것에만 delete(XxxDomain)을 둬 컴파일러가 막게 했다. 도메인 간 ID 참조에 DB FK를 걸기로 한 것도 여기서 함께 정했다 |
조인 결과가 infra 밖으로 나갈 타입이 없었다 |
조회 전용 타입 표준. domain에 XxxView를 두고 구현체가 Projection·MapperResult를 변환해 내보낸다. domain에 둘 수 있는 것이 셋에서 넷으로 늘었다 |
여러 Domain을 조합한 결과 타입의 이름 규칙이 없었다 |
조합도 조인과 같은 XxxView를 채운다. 그래서 "쿼리가 반복되면 옮긴다"는 이주가 채우는 주체만 바꾸는 일이 되고, application 위쪽은 안 바뀐다 |
| 동적 검색이면서 조인인 조회의 자리가 없었다 | 충돌이 아니라 층위가 달랐다. 동적 여부는 인터페이스와 구현 기술을, 조인 여부는 infra 안쪽 타입을 가른다. 밖으로 내보낼 XxxView가 생기면서 그 조합이 끝까지 간다 |
| 트랜잭션 경계를 정한 표준이 없는데 세 표준이 그걸 전제로 서 있었다 | 경계를 XxxService의 UseCase 구현 메서드로 못 박았다. 쓰기는 @Transactional, 조회는 readOnly = true. UseCase에 없는 package-private 내부 메서드에는 붙이지 않는다 — 프록시가 가로채지 못한다 |
Domain이 POJO인데 save()가 merge라 Domain이 모르는 컬럼이 조용히 null로 덮였다 |
Domain 영속화 표준. 수정 경로에서 merge를 쓰지 않고 영속 Entity를 꺼내 applyTo로 덮는다. 자식 컬렉션은 id로 대조하고 cascade = ALL + orphanRemoval = true를 건다 |
잡으라고 정한 OptimisticLockingFailureException이 발생할 조건이 없었다 |
@Version을 Entity에만 두고 영속 Entity를 직접 수정하는 방식으로 바꾸니 Domain이 version을 모르는 채로 낙관적 락이 정상 동작한다. 예외의 종류 표준에 이 전제를 명시했다 |
「배치」가 벌크와 Domain 순회 잡을 동시에 가리켰다 |
|
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를 참조 순서 맨 앞에 넣었다 |