개요 및 이 글에서 다루는 내용
CAP for Java(SAP Cloud Application Programming Model for Java)는 기본적으로 모든 비즈니스 로직을 트랜잭션 컨텍스트 안에서 실행합니다. 문제는 단순히 상품 카탈로그를 조회하거나 주문 내역을 검색하는 읽기 전용 시나리오에서도 쓰기 가능한 트랜잭션이 열린다는 점입니다. 이 글에서는 Read-only Transaction을 활용해 CAP Java 애플리케이션의 조회 성능을 개선하는 방법을 다룹니다.
- CAP Java의 트랜잭션 기본 동작 이해
@Transactional(readOnly = true)의 내부 메커니즘- CDS
@readonly어노테이션 적용 시점 - EventHandler에서 읽기 전용 트랜잭션 설정
- 커넥션 풀 분리와 쓰기 락 회피로 얻는 처리량 개선
알아두면 좋은 배경 지식
이 글은 CAP Java의 EventHandler, CqnService 사용 경험이 있는 개발자를 대상으로 합니다. Spring Boot의 @Transactional 동작 원리, JDBC 커넥션 풀(HikariCP)의 기본 개념, 그리고 데이터베이스의 트랜잭션 격리 수준(특히 SAP HANA의 MVCC 동작)에 대한 이해가 있다면 본문 내용을 더 빠르게 흡수할 수 있습니다. CDS 모델 정의(.cds 파일)와 어노테이션 문법도 함께 알고 있어야 합니다.
실행 환경 및 준비물
- CAP Java SDK 2.4 이상 (cds-services-api)
- Spring Boot 3.2.x
- Java 17 LTS
- SAP HANA Cloud 또는 H2 (개발용)
- Maven 3.9.x,
cds-maven-plugin
핵심 개념 — 왜 읽기에 쓰기 트랜잭션이 문제인가
CAP Java는 요청이 들어오면 RequestContext 와 ChangeSetContext 를 자동으로 생성합니다. ChangeSetContext는 트랜잭션의 경계를 의미하며, 기본값은 읽기/쓰기 모두 가능한 상태입니다. 비유하자면 도서관에서 책 한 권만 보러 들어가는 손님에게 매번 "책 대출 + 반납 + 수정 권한"이 포함된 전체 카드를 발급하는 것과 같습니다.
구체적으로 다음과 같은 차이가 발생합니다.
- 커넥션 라우팅: HANA Cloud의 경우 read replica로 라우팅이 가능합니다.
- 변경 추적 비활성화: CAP의 ChangeSetListener가 변경 사항을 수집하지 않으므로 메모리 사용이 줄어듭니다.
- 락 회피:
SELECT FOR UPDATE등이 차단되며, 동일 레코드에 대한 쓰기 작업과 경합하지 않습니다. - 커밋 비용 감소: 변경된 행이 없으므로 커밋 단계의 WAL flush 비용이 사라집니다.
실전 코드 1단계 — 기본 읽기 전용 서비스 선언
// srv/catalog-service.cds
service ProductCatalogService {
@readonly
entity Products as projection on cat.Products;
}
@Component
@ServiceName(ProductCatalogService_.CDS_NAME)
public class ProductCatalogHandler implements EventHandler {
@On(event = CqnService.EVENT_READ, entity = Products_.CDS_NAME)
@Transactional(readOnly = true)
public void onReadProducts(CdsReadEventContext context) {
// readOnly 컨텍스트 활성화 — 쓰기 락 없이 실행
}
}
@readonly 는 CDS 어노테이션으로 OData 표면에서 POST/PUT/DELETE를 차단합니다. @Transactional(readOnly = true) 는 Spring 레벨에서 JDBC 커넥션에 readOnly 힌트를 전달합니다. 두 가지는 서로 다른 레이어에서 동작하므로 함께 사용해야 완전한 읽기 전용이 됩니다.
실전 코드 2단계 — 검색 핸들러에 로깅과 예외 처리 추가
@Component
@ServiceName(SalesService_.CDS_NAME)
public class SalesOrderQueryHandler implements EventHandler {
private static final Logger log = LoggerFactory.getLogger(SalesOrderQueryHandler.class);
private final PersistenceService db;
private final SearchAuditClient auditClient;
@On(event = CqnService.EVENT_READ, entity = SalesOrders_.CDS_NAME)
@Transactional(readOnly = true, timeout = 5)
public void onReadOrders(CdsReadEventContext context) {
long start = System.nanoTime();
try {
Result result = db.run(context.getCqn());
context.setResult(result);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
log.info("SalesOrders read in {} ms, rows={}", elapsedMs, result.rowCount());
// 감사 로그는 비동기 처리 — 트랜잭션을 막지 않음
auditClient.recordReadAsync(context.getUserInfo().getName(), elapsedMs);
} catch (DataAccessException ex) {
log.error("Failed to read SalesOrders", ex);
throw new ServiceException(ErrorStatuses.INTERNAL_SERVER_ERROR,
"Order lookup temporarily unavailable", ex);
}
}
}
timeout = 5 옵션으로 장기 조회를 차단합니다. 감사 로그 호출은 비동기로 분리해 트랜잭션 경계 밖으로 빼냅니다.
실전 코드 3단계 — Customer 검색과 커넥션 분리
@Component
@ServiceName(CustomerInsightService_.CDS_NAME)
public class CustomerInsightHandler implements EventHandler {
private final PersistenceService db;
private final MeterRegistry metrics;
@On(event = CqnService.EVENT_READ, entity = Customers_.CDS_NAME)
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED, timeout = 8)
public void onReadCustomers(CdsReadEventContext context) {
Timer.Sample sample = Timer.start(metrics);
Result customers = db.run(context.getCqn());
context.setResult(customers);
if (context.getParameterInfo().getQueryParams().containsKey("withStats")) {
enrichWithLifetimeValue(customers);
}
sample.stop(metrics.timer("cap.customer.read"));
}
private void enrichWithLifetimeValue(Result customers) {
customers.listOf(Customers.class).forEach(c -> {
CqnSelect ltv = Select.from(SalesOrders_.class)
.columns(o -> o.totalAmount().sum().as("ltv"))
.where(o -> o.customer_ID().eq(c.getId()));
BigDecimal value = db.run(ltv).first(Map.class)
.map(row -> (BigDecimal) row.get("ltv"))
.orElse(BigDecimal.ZERO);
c.put("lifetimeValue", value);
});
}
}
application.yaml 에서 읽기 트래픽 전용 커넥션 풀을 분리하면 쓰기 트랜잭션과의 자원 경합을 피할 수 있습니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
cds:
persistence:
pool:
read-only:
maximum-pool-size: 40
connection-timeout: 3000
흔한 실수와 트러블슈팅
Q1. @Transactional(readOnly = true)를 붙였는데 INSERT가 실행됩니다.
Spring의 readOnly 플래그는 힌트에 가깝습니다. 확실하게 막으려면 CAP의 RequestContext.setReadOnly(true) 를 함께 사용하거나 핸들러 메서드를 조회 전용으로 분리하세요.
Q2. @readonly 어노테이션을 entity에 붙였는데도 admin 사용자는 수정이 가능합니다.
@readonly 는 OData 표면의 메서드 노출을 제어할 뿐, 내부 서비스 호출까지 막지는 않습니다. 보안 요건이라면 @restrict 와 함께 사용하세요.
Q3. 읽기 전용 트랜잭션 적용 후 HANA에서 오류가 발생합니다.
timeout 값이 너무 짧게 설정된 경우이거나, readOnly 트랜잭션이 read replica로 라우팅되었는데 직전 쓰기가 아직 복제되지 않은 stale read 상황입니다.
Q4. ChangeSetContext 리스너가 호출되지 않습니다.
readOnly 컨텍스트에서는 변경 사항이 없다고 간주되어 일부 리스너가 호출되지 않을 수 있습니다. 반드시 실행되어야 하는 로직은 핸들러 본문에 직접 두거나 별도 비동기 처리로 분리하세요.
이어서 살펴보면 좋은 주제
- CAP Java의 Outbox 패턴 — readOnly 트랜잭션에서 분리된 비동기 이벤트 발행
- HANA Read Replica 라우팅 — multi-tenant 환경에서의 커넥션 풀 전략
- CqnAnalyzer를 통한 쿼리 재작성 — 읽기 전용 컨텍스트에서 안전하게 쿼리를 변형하는 패턴
- Resilience4j 통합 — 외부 시스템 호출 시 readOnly 트랜잭션 보호
레퍼런스 모음
댓글 0
아직 댓글이 없습니다.