JPA / Hibernate의 기본 사고방식
관리 엔티티 → 변경 감지(dirty checking) → flush
장점: 객체 그래프 중심의 개발 생산성이 높고, 생태계와 영속성 컨텍스트 기능이 성숙하다.
비용: 프록시, N+1 관계 탐색, 플러시 시점, 엔티티 생명주기 뒤에 I/O가 숨을 수 있다.
대화형 아키텍처 해설
Exposed는 JPA/Hibernate가 제공하는 객체 그래프 자동화 일부를 명시적인 SQL 의도와 실행 경계로 바꾼다. 마이그레이션의 핵심 질문은 어느 저장소가 트랜잭션을 여느냐가 아니라, 어느 애플리케이션 경계가 전체 작업 단위를 소유하느냐이다.
불변 규칙
관리 엔티티 → 변경 감지(dirty checking) → flush
장점: 객체 그래프 중심의 개발 생산성이 높고, 생태계와 영속성 컨텍스트 기능이 성숙하다.
비용: 프록시, N+1 관계 탐색, 플러시 시점, 엔티티 생명주기 뒤에 I/O가 숨을 수 있다.
명시적 select / join / map → 명시적 insert / update / delete
장점: SQL 의도와 실행 지점이 애플리케이션 코드에 드러난다.
비용: 쿼리 구조, 매핑, 저장 연산, 트랜잭션 경계를 명시적으로 설계해야 한다.
구조 비교
첫 번째 다이어그램은 전형적인 Spring Data JPA와 Hibernate 구조를 Exposed와 같은 책임 수준에서 비교한다. 두 번째 다이어그램은 저장소에서 승인한 Exposed 트랜잭션 소유권 구조를 상세하게 보여 준다.
경계 시나리오
시나리오를 선택하면 실행 순서, 경계 소유자, 종료 결과, 코드 대응 관계, 주의 사항이 함께 바뀐다.
호출 → 반환 → 종료
하나의 동기 경계가 조회, 매핑, 커밋을 감싼다.
컨트롤러 / 서비스 transaction {}하나의 서비스 경계가 호출을 조합해 부분 성공이 외부로 드러나지 않게 한다.
서비스 transaction {}코루틴 컨텍스트가 일시 중단 가능한 저장소 작업 전체에 트랜잭션을 전달한다.
코루틴 suspendTransaction {}콜드 Flow 또는 channelFlow를 트랜잭션 블록이 끝나기 전에 collect한다.
suspendTransaction {} 안의 수집자콜드 Flow를 반환하면 collect가 시작되기 전에 트랜잭션이 닫힌다.
트랜잭션보다 오래 유지되는 호출자예외나 코루틴 취소가 발생하면 저장소 호출 하나가 아니라 전체 작업 단위를 종료해야 한다.
트랜잭션 블록 / 코루틴 스코프책임
| 주체 | 소유 책임 | 이유 |
|---|---|---|
| 애플리케이션 경계 | 업무 트랜잭션을 시작하고 끝낸다 | 여러 저장소 호출을 하나의 원자적 작업으로 묶는다 |
| 저장소 | 쿼리, 매핑, 저장 연산을 표현한다 | 유스케이스 경계를 몰래 넓히지 않는다 |
| Exposed 런타임 | JDBC 스레드 또는 R2DBC 코루틴 컨텍스트에 트랜잭션 상태를 결합한다 | 경계를 벗어난 작업은 트랜잭션 일관성을 깨뜨릴 수 있다 |
| 데이터베이스 | 작업 단위를 커밋하거나 롤백한다 | 커밋 또는 롤백 중 하나의 최종 결과만 반영한다 |
마이그레이션 대응
| JPA 습관 | Exposed 매핑 |
|---|---|
@Transactional 서비스 메서드 | 애플리케이션 경계의 transaction { ... } 또는 suspendTransaction { ... } |
| 관리 엔티티 변경 | 명시적인 update, insert, delete 저장소 연산 |
| 지연 관계 순회 | 동일 경계 안의 명시적 join 또는 후속 쿼리 |
| flush 시점 SQL | Exposed DSL 또는 저장소 호출이 실행되는 지점의 SQL |
| 영속성 컨텍스트 수명 | 트랜잭션 블록 / 코루틴 컨텍스트 수명 |
선택 기준
| 선택 | 판단 신호 |
|---|---|
| JPA/Hibernate가 유리한 경우 | 복잡한 객체 그래프, 도메인 탐색, 생태계 통합의 가치가 숨은 I/O 위험보다 크다. |
| Exposed가 유리한 경우 | SQL 형태, 예측 가능한 I/O, Kotlin DSL 조합, 명시적 경계가 우선이다. |
| 이름만 바꾸는 마이그레이션은 피한다 | 사고방식을 바꾸지 않고 저장소 이름만 옮기면 경계와 N+1 문제도 그대로 남는다. |
| 둘을 의도적으로 함께 쓴다 | 바운디드 컨텍스트별로 선택하되 한 유스케이스 안에서 트랜잭션 소유권을 섞지 않는다. |
검증 근거
| 검증 내용 | 구현 소스 | 테스트 또는 실행 예제 | 검증 방법 |
|---|---|---|---|
| JDBC 저장소 호출은 호출자가 소유한 transaction {} 블록 안에서 실행된다. | JdbcRepository |
ProductController |
rg -n "transaction \\{" examples/jdbc-demo/src/main/kotlin |
| 실행 가능한 JDBC 데모는 컨트롤러 유스케이스에 경계를 둔다. | ProductController |
transaction-ownership.md |
rg -n "transaction \\{" examples/jdbc-demo/src/main/kotlin |
| R2DBC 연산은 suspendTransaction이 제공하는 코루틴 컨텍스트에 의존한다. | R2dbcRepository |
MovieR2dbcRepositoryTest |
./gradlew :bluetape4k-exposed-r2dbc:test |
| R2DBC 데모는 저장소 작업 전에 suspendTransaction 경계를 연다. | ProductController |
repository-patterns.md |
rg -n "suspendTransaction" examples/r2dbc-demo/src/main/kotlin |
| 저장소 테스트는 일시 중단 가능한 쿼리와 트랜잭션 동작을 검증한다. | MovieR2dbcRepositoryTest |
coroutine-transactions.md |
./gradlew :bluetape4k-exposed-r2dbc:test --tests "*MovieR2dbcRepositoryTest" |
| 매뉴얼은 코루틴 수명과 콜드 Flow의 collect 시점을 명시한다. | coroutine-transactions.md |
repository-patterns.md |
ruby scripts/manual/validate_manuals.rb build/manual/module-inventory-1.11.0.json docs/manual/manifest.yaml |