CAP for Java: CDS Query API 완전 정복 — CqnSelect/Filter/Projection으로 동적 쿼리 구현

Moderator · 조회 4
CAP for Java CDS Query API 완전 정복 CDS Query API Select 쿼리 빌딩 CDS Query API 실행 및 결과 처리

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의 핵심 장점은 다음과 같습니다.

이 튜토리얼에서는 CqnSelect, 필터링, 프로젝션, 집계, 결과 처리, 배치 실행, 잠금 제어까지 CDS Query API의 주요 기능을 단계별로 살펴봅니다.

학습 목표 체크리스트:

2. 사전 준비 -- 의존성, 프로젝트 설정

CDS Query API를 사용하려면 CAP Java 프로젝트가 구성되어 있어야 합니다. 일반적으로 cds init --add java 명령으로 프로젝트를 생성하면 필요한 의존성이 자동으로 포함됩니다.

필요 환경

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 compilegen/ 디렉토리에서 확인할 수 있습니다.

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 모델 변경 시 컴파일 에러로 영향 범위를 즉시 파악할 수 있기 때문입니다.

비교 요약

항목DynamicStatic
타입 안전성없음컴파일 타임 검증
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 메서드를 사용할 수 있습니다. CdsDataMap<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 타입
UUIDString
StringString
IntegerInteger
DecimalBigDecimal
DateLocalDate
TimestampInstant
BooleanBoolean

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의 기본기를 익혔다면, 다음 주제들로 학습을 확장할 수 있습니다.

참고 자료