PersistenceService란 무엇인가
PersistenceService는 CAP for Java 런타임이 제공하는 영속성 추상화 계층입니다. 패키지 com.sap.cds.services.persistence.PersistenceService에 정의되어 있으며, CDS 엔터티 모델을 기반으로 정의한 CQN(Core Query Notation) 쿼리를 실행하여 데이터베이스와 상호작용합니다. JPA의 EntityManager나 Spring Data Repository와 다르게, CAP 모델(.cds 파일)에 정의된 엔터티 정의를 단일 진실 공급원(Single Source of Truth)으로 사용한다는 점이 특징입니다.
핵심은 다음과 같습니다.
- CDS 모델 기반의 타입 안전 쿼리 빌더(
Select,Insert,Update,Delete) 제공 - 실행 결과는
com.sap.cds.Result와com.sap.cds.Row로 반환 - 트랜잭션은
ChangeSetContext를 통해 자동으로 묶임 - SAP HANA, H2, PostgreSQL, SQLite 등 다양한 데이터베이스 어댑터를 동일 API로 처리
JPA와 비교: 왜 PersistenceService인가
전통적인 Spring Boot 프로젝트에서는 JPA의 EntityManager나 JpaRepository를 사용해 영속성을 관리합니다. CAP for Java에서도 JPA를 함께 사용할 수는 있지만, CAP 모델과의 통합 측면에서는 PersistenceService가 일반적으로 권장됩니다. 비유하자면 JPA가 "Java 클래스 중심의 ORM"이라면, PersistenceService는 "CDS 모델 중심의 CQN 실행기"입니다.
주요 차이는 다음 표로 정리할 수 있습니다.
| 관점 | JPA EntityManager | PersistenceService |
|---|---|---|
| 모델 정의 | @Entity Java 클래스 | .cds 엔터티 정의 |
| 쿼리 언어 | JPQL / Criteria API | CQN (Select/Insert/Update/Delete 빌더) |
| 결과 타입 | POJO 엔터티 | Result + Row (또는 생성된 인터페이스) |
| 드래프트/로컬라이제이션 | 수동 구현 | 런타임 자동 처리 |
| OData 통합 | 별도 매핑 필요 | OData V4 어댑터와 직접 연동 |
특히 OData V4 서비스를 노출하는 CAP 애플리케이션에서는 PersistenceService가 OData 쿼리 옵션($filter, $expand, $select)을 CQN으로 자연스럽게 변환합니다. 별도 매핑 코드를 작성할 필요가 줄어들기 때문에, 일반적으로 새 CAP 프로젝트에서는 PersistenceService를 1차 선택지로 두는 편이 효율적입니다.
기본 CRUD 패턴 (run() 사용법)
PersistenceService는 Spring Bean으로 자동 등록되며, @Autowired로 주입받아 사용합니다. 모든 작업의 진입점은 db.run(CqnStatement)이며, 반환값은 Result입니다.
import com.sap.cds.Result;
import com.sap.cds.Row;
import com.sap.cds.ql.Select;
import com.sap.cds.ql.Insert;
import com.sap.cds.ql.Update;
import com.sap.cds.ql.Delete;
import com.sap.cds.services.persistence.PersistenceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import static cds.gen.salesservice.SalesService_.ORDERS;
@Service
public class OrderRepository {
@Autowired
private PersistenceService db;
// CREATE
public void create(Map<String, Object> newOrder) {
db.run(Insert.into(ORDERS).entry(newOrder));
}
// READ (전체)
public Result findAll() {
return db.run(Select.from(ORDERS));
}
// UPDATE
public void update(String id, Map<String, Object> changes) {
db.run(Update.entity(ORDERS).data(changes).byId(id));
}
// DELETE
public void delete(String id) {
db.run(Delete.from(ORDERS).byId(id));
}
}
ORDERS는 CDS 모델 컴파일러가 자동 생성한 메타데이터 상수입니다(cds-maven-plugin이 cds.gen.* 패키지를 만들어줍니다). 문자열 리터럴 대신 이 상수를 사용하면 엔터티명 오타가 컴파일 단계에서 검출되므로, 타입 안전성을 확보할 수 있습니다.
CqnSelect로 조건 조회
조건 조회는 CqnSelect 인터페이스로 표현합니다. Select.from(엔터티).where(...) 형태로 빌더 체인을 구성하며, where 내부에서는 람다 표현식으로 컬럼 비교를 작성합니다.
import com.sap.cds.ql.cqn.CqnSelect;
public Result findOpenOrders() {
CqnSelect q = Select.from(ORDERS)
.where(o -> o.get("status").eq("OPEN"));
return db.run(q);
}
public Result findRecentByCustomer(String customerId, int limit) {
CqnSelect q = Select.from(ORDERS)
.where(o -> o.get("customer_ID").eq(customerId))
.orderBy(o -> o.get("createdAt").desc())
.limit(limit);
return db.run(q);
}
결과를 POJO처럼 다루고 싶다면, 생성된 타입 인터페이스(예: Orders)를 활용합니다.
import cds.gen.salesservice.Orders;
import cds.gen.salesservice.Orders_;
public List<Orders> findOpenTyped() {
return db.run(Select.from(Orders_.class)
.where(o -> o.status().eq("OPEN")))
.listOf(Orders.class);
}
Orders_.class는 컴파일러가 생성한 메타 모델로, .status()처럼 메서드 호출로 컬럼을 참조합니다. 컬럼명 오타 시 컴파일 에러가 발생하므로 런타임 사고를 줄일 수 있습니다.
트랜잭션 처리와 자동 관리
CAP for Java는 요청 단위로 ChangeSetContext를 열어 그 안에서 실행되는 모든 db.run() 호출을 한 트랜잭션으로 묶습니다. OData 요청, 액션/펑션, 이벤트 핸들러 호출 등은 자동으로 ChangeSetContext 안에서 실행되므로, 명시적인 @Transactional 선언 없이도 트랜잭션이 일관되게 관리됩니다.
핸들러 외부(예: 배치 잡, 스케줄러)에서 트랜잭션을 명시적으로 열고 싶다면 CdsRuntime을 사용합니다.
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.changeset.ChangeSetContext;
@Autowired
private CdsRuntime runtime;
public void importBatch(List<Map<String, Object>> rows) {
runtime.changeSetContext().run(ctx -> {
for (var row : rows) {
db.run(Insert.into(ORDERS).entry(row));
}
// 블록을 정상 종료하면 commit, 예외 발생 시 rollback
});
}
핸들러 내부에서 예외가 던져지면 ChangeSetContext가 자동 롤백 표시를 갖게 되며, 트랜잭션 종료 시 롤백됩니다. 따라서 비즈니스 오류는 ServiceException으로 던지는 패턴이 일반적으로 권장됩니다.
핸들러 내부에서의 PersistenceService 활용
이벤트 핸들러(@On, @Before, @After) 안에서도 동일한 PersistenceService를 주입받아 사용합니다. 다만 핸들러는 이미 트랜잭션 컨텍스트 안에서 실행되므로, 추가 트랜잭션 제어 코드는 필요하지 않습니다.
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import cds.gen.salesservice.SalesService_;
import cds.gen.salesservice.Orders;
@Component
@ServiceName(SalesService_.CDS_NAME)
public class OrderHandler implements EventHandler {
@Autowired
private PersistenceService db;
@On(event = CdsService.EVENT_CREATE, entity = Orders_.CDS_NAME)
public void onCreate(CdsCreateEventContext ctx, List<Orders> orders) {
for (Orders o : orders) {
// 비즈니스 검증: 같은 고객의 OPEN 주문이 5건 이상이면 거부
long openCount = db.run(Select.from(Orders_.class)
.where(x -> x.customer_ID().eq(o.getCustomerId())
.and(x.status().eq("OPEN"))))
.rowCount();
if (openCount >= 5) {
throw new ServiceException(ErrorStatuses.CONFLICT,
"고객당 OPEN 주문은 최대 5건입니다.");
}
o.setStatus("OPEN");
}
db.run(Insert.into(Orders_.class).entries(orders));
ctx.setResult(orders);
ctx.setCompleted();
}
}
위 예제는 @On CREATE 이벤트를 가로채 비즈니스 규칙을 적용한 뒤 직접 영속화하는 패턴입니다. 핸들러에서 예외를 던지면 ChangeSetContext가 롤백되므로, 데이터 정합성이 자연스럽게 보장됩니다.
실무 패턴과 주의사항
실제 프로젝트에서 자주 만나는 함정과 대응 방식을 정리합니다.
1) byId()가 동작하지 않을 때. byId()는 키 컬럼명이 ID일 때 동작하는 단축 표현입니다. 키 컬럼명이 다르거나 복합키라면 matching(Map.of("key1", v1, "key2", v2))를 사용해야 합니다.
2) Result.first()는 Optional 반환. 단일 행을 얻을 때 result.first(Orders.class)는 Optional<Orders>를 반환합니다. .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND, "...")) 패턴이 일반적입니다.
3) N+1 회피. 연관 엔터티를 함께 가져와야 한다면 columns()에서 expand를 사용합니다. 예: Select.from(ORDERS).columns(o -> o.items().expand()). 핸들러 안에서 루프 돌면서 추가 쿼리를 날리면 성능이 빠르게 악화됩니다.
4) 대용량 처리. 수만 건 이상을 한 번에 다룰 때는 Insert.into(...).entries(list)를 활용한 배치 삽입, 그리고 페이지네이션(limit/offset 또는 streaming) 패턴을 사용합니다.
5) 로깅과 감사 추적. 운영 환경에서는 com.sap.cds.services.persistence 로거를 DEBUG로 설정하면 실행된 CQN과 변환된 SQL을 모두 확인할 수 있습니다. 다만 프로덕션에서는 PII 노출에 주의하고, 필요한 경우 마스킹 로거를 별도로 구성하는 편이 안전합니다.
FAQ.
- Q. JPA와 PersistenceService를 같은 프로젝트에서 함께 써도 되나요? 기술적으로 가능하지만 트랜잭션 경계와 캐시가 별개로 동작하기 때문에 일관성 문제가 생기기 쉽습니다. 한 엔터티는 하나의 영속성 경로로 통일하는 편이 안전합니다.
- Q. 네이티브 SQL은 어떻게 실행하나요?
PersistenceService는 CQN 전용입니다. 불가피한 경우 Spring의JdbcTemplate을 별도 주입해 사용하되, 트랜잭션 매니저가 동일한지 확인해야 합니다. - Q. 테스트는 어떻게 작성하나요?
@SpringBootTest로 H2 인메모리 데이터베이스를 띄우고PersistenceService를 그대로 주입받아 사용하는 방식이 일반적입니다. 별도 목 객체 없이 실제 CQN 실행을 검증할 수 있습니다.
핵심 한 줄
PersistenceService는 CDS 모델을 단일 진실 공급원으로 두고 CQN으로 타입 안전 영속성을 다루는 CAP for Java의 표준 경로이며, JPA의 자리를 대체하기보다 CDS 모델 중심의 일관된 데이터 접근 계층을 제공합니다.
댓글 0
아직 댓글이 없습니다.