CAP for Java: CDS Query API 완전 정복 — CqnSelect/Filter/Projection으로 동적 쿼리 구현
1. CAP Java에서 CDS Query API란 무엇인가
SAP Cloud Application Programming Model(CAP)의 Java 런타임은 데이터베이스 쿼리를 작성하기 위해 CDS Query Notation(CQN)이라는 추상화 계층을 제공합니다. SQL을 직접 작성하는 대신, Java 코드 안에서 빌더 패턴을 사용해 SELECT, INSERT, UPDATE, DELETE 문을 구성할 수 있습니다. 이것이 바로 CDS Query API입니다.
CDS Query API의 핵심 장점은 다음과 같습니다.
- 데이터베이스 독립성: CQN 문은 내부적으로 PersistenceService가 SAP HANA, PostgreSQL, H2 등 대상 DB에 맞는 SQL로 변환합니다.
- 타입 안전성: CDS 모델에서 생성된 정적 모델 클래스를 사용하면 컴파일 타임에 오류를 잡을 수 있습니다.
- 동적 쿼리 구성: 런타임에 조건, 프로젝션, 정렬 기준을 자유롭게 조합할 수 있습니다.
- 일관된 트랜잭션 관리: CAP 프레임워크의 요청 컨텍스트 안에서 자동으로 트랜잭션이 관리됩니다.
이 튜토리얼에서는 CqnSelect, 필터링, 프로젝션, 집계, 결과 처리, 배치 실행, 잠금 제어까지 CDS Query API의 주요 기능을 단계별로 살펴봅니다.
학습 목표 체크리스트:
- Dynamic 스타일과 Static 스타일의 차이를 이해하고 적절히 선택할 수 있다
- where(), byId(), matching(), byParams() 필터를 실무 상황에 맞게 사용할 수 있다
- 프로젝션, expand, 집계 함수로 복잡한 데이터 조회를 구현할 수 있다
- Result 객체에서 타입 안전하게 데이터를 추출할 수 있다
- 배치 실행과 동시성 제어(낙관적/비관적 잠금)를 적용할 수 있다
2. 사전 준비 -- 의존성, 프로젝트 설정
CDS Query API를 사용하려면 CAP Java 프로젝트가 구성되어 있어야 합니다. 일반적으로 cds init --add java 명령으로 프로젝트를 생성하면 필요한 의존성이 자동으로 포함됩니다.
필요 환경
- SAP CAP Java SDK: 3.x 이상 (이 튜토리얼은 3.6 기준)
- Java: 17 이상
- Node.js: 18 이상 (CDS 컴파일러용)
- @sap/cds-dk: 8.x 이상
- DB: 개발 시 H2 또는 SQLite, 프로덕션 시 SAP HANA Cloud(권장) 또는 PostgreSQL
pom.xml에서 다음 의존성이 포함되어 있는지 확인합니다.
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-starter-spring-boot</artifactId>
</dependency>
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-feature-hana</artifactId>
<scope>runtime</scope>
</dependency>
CDS 모델 예시 (db/schema.cds):
namespace bookshop;
entity Books {
key ID : Integer;
title : String(200);
price : Decimal(10,2);
stock : Integer;
author : Association to Authors;
genre : String(100);
createdAt : Timestamp;
}
entity Authors {
key ID : Integer;
name : String(100);
books : Association to many Books on books.author = $self;
}
정적 모델 클래스(예: Bookshop_, Books_)는 빌드 시 cds-maven-plugin이 자동 생성합니다. mvn compile 후 gen/ 디렉토리에서 확인할 수 있습니다.
3. Select 쿼리 기초 -- Select.from(), Dynamic vs Static 스타일
CDS Query API에서 조회 쿼리는 Select.from()으로 시작합니다. 이때 두 가지 스타일을 선택할 수 있습니다.
Dynamic 스타일
엔티티 이름을 문자열로 지정합니다. 빠르게 프로토타이핑하거나 런타임에 엔티티 이름이 결정되는 경우에 적합합니다.
// Dynamic 스타일: 문자열로 엔티티 지정
CqnSelect query = Select.from("bookshop.Books")
.columns("title", "price");
Result result = service.run(query);
result.forEach(row -> {
String title = (String) row.get("title");
BigDecimal price = (BigDecimal) row.get("price");
log.info("Book: {} - {}", title, price);
});
Dynamic 스타일은 유연하지만, 컬럼 이름이나 엔티티 이름의 오타를 컴파일 타임에 잡을 수 없다는 단점이 있습니다.
Static 스타일
CDS 컴파일러가 생성한 모델 인터페이스를 사용합니다. IDE 자동완성과 컴파일 타임 타입 검증이 가능합니다.
import static bookshop.Bookshop_.BOOKS;
// Static 스타일: 생성된 모델 클래스 사용
CqnSelect query = Select.from(BOOKS)
.columns(b -> b.title(), b -> b.price());
Result result = service.run(query);
List<Books> books = result.listOf(Books.class);
books.forEach(book ->
log.info("Book: {} - {}", book.getTitle(), book.getPrice())
);
실무에서는 Static 스타일을 권장합니다. 리팩토링 시 안전하고, CDS 모델 변경 시 컴파일 에러로 영향 범위를 즉시 파악할 수 있기 때문입니다.
비교 요약
| 항목 | Dynamic | Static |
|---|---|---|
| 타입 안전성 | 없음 | 컴파일 타임 검증 |
| IDE 지원 | 제한적 | 자동완성, 리팩토링 |
| 유연성 | 런타임 엔티티 결정 가능 | 고정된 모델 필요 |
| 권장 용도 | 제네릭 핸들러, 동적 시나리오 | 일반적인 비즈니스 로직 |
4. 필터링 심화 -- where(), byId(), matching(), byParams() 실전 비교
데이터를 조회할 때 가장 많이 사용하는 것이 필터링입니다. CDS Query API는 다양한 필터링 메서드를 제공하며, 각각의 사용 시나리오가 다릅니다.
byId() -- 단건 조회
키 필드로 단일 레코드를 조회할 때 가장 간결합니다.
// 단일 키로 조회
CqnSelect query = Select.from(BOOKS).byId(101);
Books book = service.run(query).single(Books.class);
// 복합 키인 경우 Map 사용
Map<String, Object> keys = Map.of("orderID", 1001, "itemNo", 10);
CqnSelect query2 = Select.from(ORDER_ITEMS).matching(keys);
where() -- 유연한 조건 지정
람다 표현식으로 복잡한 조건을 구성합니다. SQL의 WHERE 절에 대응합니다.
// 복합 조건: 가격 범위 + 장르 필터
CqnSelect query = Select.from(BOOKS)
.where(b -> b.price().gt(10.0)
.and(b.price().lt(50.0))
.and(b.genre().eq("Fiction")));
// IN 절 사용
List<Integer> ids = List.of(101, 102, 103);
CqnSelect query2 = Select.from(BOOKS)
.where(b -> b.ID().in(ids));
// NULL 체크
CqnSelect query3 = Select.from(BOOKS)
.where(b -> b.author().isNotNull());
matching() -- Map 기반 동등 조건
여러 필드의 동등 비교를 Map으로 간결하게 표현합니다. 동적으로 필터 조건을 구성할 때 유용합니다.
// Map 기반 필터 - AND 조건으로 결합됨
Map<String, Object> filter = new HashMap<>();
filter.put("genre", "Fiction");
filter.put("stock", 0);
CqnSelect query = Select.from(BOOKS).matching(filter);
// 생성되는 조건: WHERE genre = 'Fiction' AND stock = 0
byParams() -- 파라미터화된 쿼리
배치 실행이나 쿼리 재사용 시 파라미터 바인딩에 사용합니다. SQL prepared statement와 유사한 개념입니다.
// 파라미터 기반 삭제 - 배치 실행에 활용
CqnDelete delete = Delete.from("bookshop.Books").byParams("ID");
Map<String, Object> paramSet1 = Map.of("ID", 101);
Map<String, Object> paramSet2 = Map.of("ID", 102);
Result result = service.run(delete, List.of(paramSet1, paramSet2));
log.info("삭제된 행 수: {}", result.rowCount());
필터링 메서드 선택 가이드
| 메서드 | 사용 시나리오 | 특징 |
|---|---|---|
byId() | 키로 단건 조회 | 가장 간결, 단일 키 전용 |
where() | 복잡한 조건, 범위 검색 | 람다 기반, 가장 유연 |
matching() | 동등 비교 여러 개 | Map 기반, 동적 필터 구성 용이 |
byParams() | 배치 실행, 쿼리 재사용 | 파라미터 바인딩 |
5. 프로젝션과 집계 -- columns(), expand(), groupBy(), having()
성능 최적화를 위해 필요한 컬럼만 선택하고, 연관 데이터를 효율적으로 로딩하며, 집계 작업을 수행하는 방법을 살펴봅니다.
columns() -- 컬럼 선택
조회할 컬럼을 명시적으로 지정합니다. 지정하지 않으면 모든 컬럼이 반환됩니다.
// 기본 컬럼 선택
CqnSelect query = Select.from(BOOKS)
.columns(b -> b.ID(), b -> b.title(), b -> b.price());
// 별칭(alias) 사용
CqnSelect queryAlias = Select.from(BOOKS)
.columns(b -> b.title().as("bookTitle"),
b -> b.price().as("bookPrice"));
expand() -- 연관 데이터 로딩
Association이나 Composition으로 연결된 하위 엔티티를 함께 조회합니다. OData의 $expand와 동일한 개념입니다.
// 작가 정보를 함께 조회 (to-one)
CqnSelect query = Select.from(BOOKS)
.columns(b -> b.title(),
b -> b.author().expand(a -> a.name()));
// 작가의 모든 책을 함께 조회 (to-many)
CqnSelect query2 = Select.from(AUTHORS)
.columns(a -> a.name(),
a -> a.books().expand(b -> b.title(), b -> b.price()));
Result result = service.run(query2);
// 결과: { name: "Kim", books: [{title: "...", price: 29.99}, ...] }
groupBy()와 having() -- 집계
SQL의 GROUP BY, HAVING에 대응합니다. count, sum, avg 등 집계 함수와 함께 사용합니다.
// 장르별 도서 수와 평균 가격
CqnSelect query = Select.from(BOOKS)
.columns(b -> b.genre(),
b -> b.ID().count().as("bookCount"),
b -> b.price().avg().as("avgPrice"))
.groupBy(b -> b.genre())
.having(b -> b.ID().count().gt(5))
.orderBy(b -> b.get("bookCount").desc());
Result result = service.run(query);
result.forEach(row -> {
log.info("장르: {}, 수량: {}, 평균가: {}",
row.get("genre"), row.get("bookCount"), row.get("avgPrice"));
});
정렬과 페이지네이션
orderBy()와 limit()으로 정렬 및 페이징을 구현합니다.
// 가격 내림차순, 페이지당 20건, 2페이지
int pageSize = 20;
int page = 2;
CqnSelect query = Select.from(BOOKS)
.columns(b -> b.title(), b -> b.price())
.orderBy(b -> b.price().desc(), b -> b.title().asc())
.limit(pageSize, (page - 1) * pageSize);
6. 쿼리 실행과 결과 처리 -- run(), Result, listOf(), 타입 안전 접근
구성된 CQN 문은 CqnService.run() 메서드로 실행합니다. 반환되는 Result 객체는 다양한 방식으로 데이터를 추출할 수 있습니다.
기본 실행 패턴
@Autowired
private PersistenceService db;
// 쿼리 실행
CqnSelect query = Select.from(BOOKS).columns("title", "price");
Result result = db.run(query);
// 1) forEach - 스트림 순회
result.forEach(row -> {
String title = (String) row.get("title");
log.info("Title: {}", title);
});
// 2) single() - 정확히 1건 (0건 또는 2건 이상이면 예외)
CqnSelect singleQuery = Select.from(BOOKS).byId(101);
Row row = db.run(singleQuery).single();
// 3) first() - 0~1건 (Optional 반환)
Optional<Row> first = db.run(query).first();
first.ifPresent(r -> log.info("First: {}", r.get("title")));
// 4) list() - 전체를 List<Row>로
List<Row> rows = db.run(query).list();
타입 안전 접근: listOf()와 Accessor Interface
CDS 모델에서 생성된 인터페이스(Accessor Interface)를 활용하면 get() 대신 타입이 지정된 getter 메서드를 사용할 수 있습니다. CdsData는 Map<String, Object>를 확장하며, Struct.create()로 수동 생성도 가능합니다.
// listOf() - 결과를 타입 안전한 리스트로 변환
List<Books> books = db.run(query).listOf(Books.class);
for (Books book : books) {
// getter 사용 - 타입 캐스팅 불필요
String title = book.getTitle();
BigDecimal price = book.getPrice();
Integer stock = book.getStock();
}
// single() 에도 타입 지정 가능
Books book = db.run(Select.from(BOOKS).byId(101)).single(Books.class);
log.info("제목: {}", book.getTitle());
// 결과 건수 확인
long count = result.rowCount();
CDS 타입과 Java 타입 매핑
CDS Query API의 결과에서 데이터를 추출할 때 알아야 할 주요 타입 매핑입니다.
| CDS 타입 | Java 타입 |
|---|---|
| UUID | String |
| String | String |
| Integer | Integer |
| Decimal | BigDecimal |
| Date | LocalDate |
| Timestamp | Instant |
| Boolean | Boolean |
7. 배치 실행과 동시성 제어 -- Batch, Optimistic/Pessimistic Lock
대량 데이터 처리와 동시 접근 제어는 실무에서 반드시 고려해야 할 영역입니다.
배치 실행
동일한 구조의 CQN 문을 여러 파라미터 세트로 한 번에 실행합니다. 네트워크 왕복을 줄여 성능을 크게 향상시킬 수 있습니다.
// 배치 삭제: 여러 ID를 한 번에 삭제
CqnDelete delete = Delete.from("bookshop.Books").byParams("ID");
List<Map<String, Object>> paramSets = List.of(
Map.of("ID", 101),
Map.of("ID", 102),
Map.of("ID", 103)
);
Result result = service.run(delete, paramSets);
log.info("총 삭제 건수: {}", result.rowCount());
// 배치 INSERT도 유사하게 동작
CqnInsert insert = Insert.into("bookshop.Books")
.entries(
Map.of("ID", 201, "title", "Book A", "price", 19.99),
Map.of("ID", 202, "title", "Book B", "price", 29.99)
);
service.run(insert);
낙관적 잠금 (Optimistic Locking)
ETag(버전 정보)를 사용하여 동시 수정 충돌을 감지합니다. 읽기 시점의 ETag 값을 업데이트 조건에 포함시켜, 다른 사용자가 이미 수정한 경우 업데이트가 실패하도록 합니다.
// 낙관적 잠금: ETag 기반 충돌 감지
Map<String, Object> newData = Map.of(
"title", "Updated Title",
"price", 39.99
);
Instant expectedLastModified = // 읽기 시점에 받은 modifiedAt 값
CqnUpdate update = Update.entity(BOOKS).entry(newData)
.where(b -> b.ID().eq(101)
.and(b.modifiedAt().eq(expectedLastModified)));
Result rs = service.run(update);
if (rs.rowCount() == 0) {
// 다른 사용자가 이미 수정 -> 충돌 처리
throw new ServiceException(ErrorStatuses.CONFLICT,
"데이터가 다른 사용자에 의해 변경되었습니다.");
}
비관적 잠금 (Pessimistic Locking)
SELECT 시 lock()을 사용하여 해당 행에 배타적 잠금을 걸 수 있습니다. SQL의 SELECT ... FOR UPDATE에 대응합니다.
// 비관적 잠금: 행 잠금 후 업데이트
CqnSelect lockedQuery = Select.from(BOOKS)
.byId(101)
.lock(); // FOR UPDATE
Books book = service.run(lockedQuery).single(Books.class);
// 잠금된 상태에서 안전하게 수정
if (book.getStock() > 0) {
CqnUpdate update = Update.entity(BOOKS)
.data("stock", book.getStock() - 1)
.byId(101);
service.run(update);
}
// NOWAIT 옵션: 잠금 획득 실패 시 즉시 예외
// lock(Duration.ZERO) 또는 lock() 이후 타임아웃 설정
잠금 전략 선택 기준: 읽기가 많고 충돌이 드문 시나리오에서는 낙관적 잠금이 적합합니다. 재고 차감 같은 경쟁 조건이 빈번한 시나리오에서는 비관적 잠금이 안전합니다.
8. 실무 패턴 -- 동적 쿼리 빌더 패턴, 핸들러 내 활용 예시
실제 CAP Java 애플리케이션에서 CDS Query API를 효과적으로 사용하는 패턴을 소개합니다.
동적 쿼리 빌더 패턴
사용자 입력에 따라 조건을 동적으로 조합하는 패턴입니다. 검색 API 구현에서 자주 등장합니다.
public Result searchBooks(String title, String genre,
BigDecimal minPrice, BigDecimal maxPrice,
int page, int size) {
// 기본 쿼리 빌더
Select<?> query = Select.from("bookshop.Books")
.columns("ID", "title", "price", "genre", "stock");
// 조건을 동적으로 조합
List<CqnPredicate> predicates = new ArrayList<>();
if (title != null && !title.isBlank()) {
predicates.add(CQL.get("title").contains(title));
}
if (genre != null) {
predicates.add(CQL.get("genre").eq(genre));
}
if (minPrice != null) {
predicates.add(CQL.get("price").ge(minPrice));
}
if (maxPrice != null) {
predicates.add(CQL.get("price").le(maxPrice));
}
// 조건이 있으면 AND로 결합
if (!predicates.isEmpty()) {
CqnPredicate combined = predicates.get(0);
for (int i = 1; i < predicates.size(); i++) {
combined = CQL.and(combined, predicates.get(i));
}
query.where(combined);
}
// 정렬 + 페이지네이션
query.orderBy(CQL.get("title").asc())
.limit(size, (page - 1) * size);
return service.run(query);
}
이벤트 핸들러 내 활용
CAP의 @Before, @On, @After 핸들러에서 CDS Query API를 사용하는 전형적인 패턴입니다.
@Component
@ServiceName("CatalogService")
public class CatalogServiceHandler implements EventHandler {
@Autowired
private PersistenceService db;
// 주문 생성 전: 재고 확인
@Before(event = CqnService.EVENT_CREATE, entity = "CatalogService.Orders")
public void validateStock(CdsCreateEventContext context) {
List<CdsData> orders = context.getCqn().entries();
for (CdsData order : orders) {
Integer bookId = (Integer) order.get("book_ID");
Integer qty = (Integer) order.get("quantity");
// 재고 조회
CqnSelect stockQuery = Select.from(BOOKS)
.columns(b -> b.stock())
.byId(bookId);
Books book = db.run(stockQuery).single(Books.class);
if (book.getStock() < qty) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"재고 부족: 요청 " + qty + "권, 잔여 " + book.getStock() + "권");
}
}
}
// 주문 생성 후: 재고 차감
@After(event = CqnService.EVENT_CREATE, entity = "CatalogService.Orders")
public void reduceStock(CdsCreateEventContext context) {
List<CdsData> orders = context.getResult().listOf(CdsData.class);
for (CdsData order : orders) {
Integer bookId = (Integer) order.get("book_ID");
Integer qty = (Integer) order.get("quantity");
CqnUpdate update = Update.entity(BOOKS)
.data("stock", CQL.get("stock").minus(qty))
.byId(bookId);
db.run(update);
}
}
}
흔한 실수와 트러블슈팅
Q1: single() 호출 시 예외가 발생합니다.
single()은 결과가 정확히 1건일 때만 정상 동작합니다. 0건이면 NoSuchElementException, 2건 이상이면 예외가 발생합니다. 결과가 0~1건일 수 있다면 first()를 사용하고, Optional로 처리하세요.
Q2: Dynamic 스타일에서 엔티티 이름을 잘못 지정해도 컴파일 에러가 나지 않습니다.
Dynamic 스타일은 런타임에 이름을 해석하므로 컴파일 타임에 오류를 잡을 수 없습니다. 가능하면 Static 스타일을 사용하고, Dynamic 스타일을 사용해야 한다면 엔티티 이름을 상수로 관리하세요.
Q3: expand()를 사용했는데 연관 데이터가 null입니다.
CDS 모델에서 Association이 올바르게 정의되어 있는지 확인하세요. 또한 columns()에서 expand를 명시하지 않으면 연관 데이터는 기본적으로 로딩되지 않습니다. 깊은 중첩이 필요한 경우 expand() 내부에서 다시 expand()를 체이닝할 수 있습니다.
Q4: 배치 실행 시 일부만 실패하면 어떻게 되나요?
CAP의 요청 컨텍스트는 기본적으로 하나의 트랜잭션으로 묶이므로, 배치 내 하나라도 실패하면 전체가 롤백됩니다. 부분 성공이 필요한 경우 개별 트랜잭션으로 분리해야 합니다.
다음 단계와 관련 주제
CDS Query API의 기본기를 익혔다면, 다음 주제들로 학습을 확장할 수 있습니다.
- CDS Model Reflection API: 런타임에 CDS 모델 구조를 분석하여 범용 핸들러를 구현할 수 있습니다.
CdsModel을 통해 엔티티, 요소, 어노테이션 정보에 접근합니다. - Remote Services: CQN 문을 외부 OData 서비스에 대해 실행하여, 로컬 DB와 동일한 프로그래밍 모델로 외부 API를 호출할 수 있습니다.
- Draft 지원: Fiori UI의 Draft 편집 시나리오에서 CDS Query API가 어떻게 동작하는지 이해합니다.
- Native SQL: CQN으로 표현하기 어려운 복잡한 쿼리는
JdbcTemplate을 통해 네이티브 SQL을 실행할 수 있습니다. - 성능 최적화: Hikari 커넥션 풀 튜닝, SAP HANA 특화 기능 활용 등 프로덕션 환경 최적화를 학습합니다.