데이터 접근
본 문서는 저장소 구현, 스키마 관리, 조인 허용 기준과 페이징 규칙을 정의합니다. 접근 경로의 분리는 조회와 명령이 소유합니다.
1. 저장소
- 인터페이스는 도메인 언어로 선언합니다.
UserRepository.findByEmail처럼 구현 기술이 인터페이스에 드러나지 않습니다. - 인터페이스는
domain의 애그리거트 패키지에, 구현은infrastructure/repository에 둡니다. - 구현이 생성 레코드를 도메인 엔티티로 변환합니다. 별도 매퍼 계층을 만들지 않습니다(계층 3절).
- 트랜잭션 경계는 명령 서비스의 공개 메서드입니다.
주의
변경 감지가 없으므로 저장은 항상 명시적입니다
ORM 의 더티 체킹이 없어 애그리거트를 바꾼 뒤 저장을 호출하지 않으면 오류 없이 아무 일도 일어나지 않습니다. 예외도 로그도 남지 않고 다음 조회에서 값이 그대로인 것으로만 드러납니다. ORM 에서 넘어온 사람이 가장 자주 겪는 사고이며, 명령 서비스의 단위 테스트가 저장 호출을 확인하는 것이 유일한 방어입니다.
2. 유일성 경합의 처리
존재 검사와 삽입을 나누어 쓰지 않습니다. 한 문장으로 합칩니다.
나누면 그 사이의 경합이 제약 위반 예외가 되고, 데이터베이스가 위반이 난 트랜잭션을 중단 상태로 만들어 같은 트랜잭션에서 기록한 다른 변경까지 함께 유실됩니다. 시도 횟수나 소진 표시가 되살아나는 형태로 나타나며, 정상 경로에서는 재현되지 않아 발견이 늦습니다.
충돌 시 삽입하지 않고 넘어가는 구문을 쓰면 경합이 예외가 아니라 삽입 0건으로 돌아오고 트랜잭션이 살아 있습니다.
3. SQL 안전성
jOOQ DSL 의 바인딩이 타입 안전하므로 주입이 구조적으로 차단됩니다. DSL.field(String) 처럼 문자열을 받는 저수준 API 를 쓸 때만 결합에 주의하며, 사용 시 사유를 주석으로 남깁니다.
4. 조인 기준
| 관계 | 판정 | 근거 |
|---|---|---|
| to-one | 허용합니다 | 여럿을 조인해도 행 수가 늘지 않습니다. 코드에서 키로 결합하면 필터 · 정렬 · 페이징이 SQL 바깥으로 나갑니다 |
| to-many 복수 | 금지합니다 | 서로 무관한 목록이므로 곱집합이 발생합니다 |
| to-many 단일 + 부모 페이징 | 금지합니다 | 잘라내기는 행 단위로 동작하므로 부모 단위 페이징이 성립하지 않습니다. 개수 집계도 부모 수가 아니라 행 수를 셉니다 |
부모와 자식을 함께 내려야 하면 부모를 페이징으로 확정한 뒤 그 식별자로 자식을 한 번에 조회해 메모리에서 묶습니다. 행마다 자식 쿼리를 실행하면 N+1이 되고, 중첩 매핑으로 결합하면 위 두 번째와 세 번째 문제가 그대로 발생합니다.
이 방식도 기계적으로는 애플리케이션 조인입니다. 그럼에도 정상인 이유는 필터 · 정렬 · 페이징이 여전히 SQL 안에 있기 때문입니다. 판정 기준은 결합 위치가 아니라 결정 위치입니다.
to-many 조건으로 검색하거나 정렬해야 하면 존재 여부 서브쿼리나 스칼라 서브쿼리를 씁니다. 조인한 뒤 조건절로 거르면 페이징이 무효화될 뿐 아니라 자식 목록에도 조건에 맞는 것만 남습니다. 필터링과 조회가 섞이기 때문입니다.
집계 함수는 여러 행을 부모당 한 행으로 줄이므로 페이징을 무효화하지 않습니다. 자식을 요약 문자열로만 표시하는 경우에 적합합니다.
5. 페이징
| 항목 | 규격 |
|---|---|
| 페이지 번호 | 1부터 시작합니다 |
| 크기 제한 | 상한을 강제로 보정해 과도한 조회를 차단합니다 |
| 응답 생성 | 공통 팩토리로 만들어 메타 계산을 한자리에 모읍니다 |
| 검색 조건 | 도메인별 검색 DTO를 정의하고 공통 래퍼로 감싸 전달합니다 |
목록 응답에 상한이 필요한 자리가 페이징 밖에도 있습니다. 사용자당 누적되는 목록은 페이징이 없더라도 개수 상한을 둡니다. 상한이 없으면 응답이 커지고 화면도 읽을 수 없게 되며, 그 상태는 정상 사용으로도 도달합니다.
6. 스키마와 코드 생성
마이그레이션 파일이 스키마의 단일 원본입니다. 생성 코드는 항상 그로부터 파생되며 커밋하지 않습니다. 근거는 결정-0054입니다.
| 항목 | 규격 |
|---|---|
| 마이그레이션 | Flyway 가 src/main/resources/db/migration/V*.sql 을 관리하며 적용 이력을 기록합니다 |
| 코드 생성 | jOOQ 의 DDLDatabase 가 마이그레이션 SQL에서 직접 생성합니다. 실행 중인 데이터베이스가 필요하지 않습니다 |
| 생성물 위치 | 빌드 산출물 디렉터리에만 두고 저장소에 커밋하지 않습니다 |
| 정적 분석 | 생성 코드는 포맷 · 컨벤션 · 버그 패턴 검사에서 제외합니다 |
실행 중인 데이터베이스 없이 생성되는 것이 이 구성의 핵심 이득입니다. 스키마와 코드가 어긋난 상태가 빌드 시점에 드러나며, 기동 시점까지 미뤄지지 않습니다. ./gradlew build 가 Docker 없이 동작하고 Docker 를 요구하는 것은 통합 테스트뿐이므로 CI 구성도 단순해집니다.
7. 시각과 비밀
시각은 데이터베이스 기본값(now())이 아니라 주입된 전역 Clock 빈에서 받습니다. 도메인과 통합 테스트가 시각을 통제할 수 있어야 만료와 수명 관련 불변식을 검증할 수 있기 때문이며, 근거는 계층 5절입니다.
비밀은 원문으로 저장하지 않습니다.
| 대상 | 저장 형태 |
|---|---|
| 비밀번호 | bcrypt |
| 세션 토큰 · 일회용 코드 | 애플리케이션 시크릿을 키로 하는 HMAC-SHA256 |
용도별로 라벨을 분리합니다. 분리하지 않으면 한 용도로 만든 값이 다른 용도의 조회에 걸립니다.