배경 패키지는 api/(컨트롤러 · 조회 서비스 · DTO 33개)와 domain/(65개)로 나뉘어 있었지만, domain/ 65개 중 21개가
Spring · JDBC · Jackson 을 쓰는 저장소 · 서비스였다 — 규칙과 데이터 접근이 같은 패키지에 섞여, 무엇이 순수한 규칙인지 이름으로 알 수 없었다.
사건 연구 · 어댑터가 저장소의 중첩 record 를 import 하고(데이터 → 규칙 방향이 거꾸로), common.seed 가 기능의 규칙 객체를 만들고,
대시보드 · 배치 화면 서비스가 batch 내부를 직접 불렀다.
기능 모듈 10개 — company · metric · region · market · filing · alert · insight · notify · export · ops. 모듈마다 네 계층:
| 계층 | 하는 일 | 볼 수 있는 것 |
|---|---|---|
domain |
규칙 · 계산 · record | 다른 기능의 domain 만. Spring · JDBC · Jackson · Micrometer 를 모름 |
data |
저장소(SQL) | domain |
app |
흐름 · 검증 · 트랜잭션 · 화면용 조회 | domain · data (다른 기능 것 포함) |
api |
컨트롤러 | app · domain (data 직접 금지, 다른 기능의 api 금지) |
바깥 계층은 adapters(외부 API · seed 파일 → 기능의 domain 객체), batch(Job 구성 — 모든 기능을 엮고, 의존은 ops 만 받음),
common(설정 · 보안 · 오류 · 캐시 · 웹 공통 — 기능을 모름).
ArchitectureTest 로 고정(새 의존성 없이 import 를 읽음): 의존 방향 · domain 순수성 · 모듈 순환 · 모든 클래스가 계층 안에 ·
쓰기 SQL 의 위치. 옮기는 동안 남은 위반은 KNOWN 목록에 적고 줄여 갔다(처음 10건 → 0건, 지금은 빈 목록).AlertLifecycle(무엇을 닫고 · 열고 · 알릴지)로, AlertService 는 읽기 → 계획 → 쓰기만 한다.notify.app — 주소 검사가 DNS 를 풀어 보고(SSRF 방어) 전송 · 암호화가 키 설정을 읽으므로 순수 domain 이 아님.adapters.seed — 코드 밖에서 고치는 외부 데이터를 domain 객체로 바꾸는 바깥 계층. 계획 단계에서는 기능마다
정적 팩터리를 두려 했지만, 위치만 옮겨도 같은 효과에 호출부 변경이 없었다.common.security — 기능이 아닌 공통 보안. 작업 목록(JobCatalog)은 실행 코드가 아닌 정적 설명이라 ops.domain —
대시보드(insight)가 batch 를 보지 않게.덧붙임 (2026-10-09, 독립 검토 뒤) 검사기가 import 줄만 읽어 코드 안에 패키지 이름을 통째로 쓴 참조(com.buildrisk…X)와
소문자 · 별칭 · MERGE · TRUNCATE 쓰기 SQL 을 놓칠 수 있었다 — 주석 · 문자열을 뺀 코드에서 통째 이름도 읽고, 쓰기 SQL 은 대소문자 무시로 찾으며,
검사기 자체가 이런 우회 모양을 잡는지 시험한다(규칙_검사기는_우회하기_쉬운_모양도_잡는다). 강화한 뒤 위반 0건.
조회 서비스가 같은 메서드에서 읽고 바꾸기 시작하면 저장소로 옮긴다(쓰기 SQL 은 테스트가 막음). 모듈을 jar · 서비스로 나눌 필요가 생기면 app 사이 직접 호출을 인터페이스로 바꾼다.
덧붙임 (2026-10-11, VERIFICATION 191) — 바깥 계층의 쓰기
‘쓰기 SQL 은 기능 data 에만’ 검사가 기능 패키지만 봐서 seed 적재(adapters)가 기능 테이블 5개에 직접 썼고, 계정 매핑 규칙은 쓰는 곳이 둘이라 동작이 달랐다(185).
바깥 계층(adapters · batch · common)은 운영 기반 테이블 ops.*(실행 큐 · 실행 기록 · 감사 · 호출 수)에만 쓰도록 검사를 더했고, SeedLoader 는 ops.app 으로 옮겨
각 기능 저장소를 부른다(adapters 의 SeedCatalog 는 파일만 읽음).