📖 개요 및 이 글에서 다루는 것
CAP Java 애플리케이션이 자기 데이터베이스만 바라보는 경우는 드뭅니다. 실무에서는 S/4HANA의 OData API나 다른 팀이 만든 마이크로서비스를 호출해 데이터를 합쳐 보여줘야 하는 상황이 훨씬 많습니다. 이 글에서는 CAP Java의 Remote Service 기능으로 외부 OData 서비스를 연결하고, CQN 쿼리로 호출하고, 로컬 엔티티와 매시업(mash-up)하는 패턴을 3단계 실전 예제로 정리합니다.
- 외부 OData EDMX를
cds import로 프로젝트에 가져오는 방법 이해 application.yaml에서 Remote Service와 Destination을 구성하는 방법 습득- 이벤트 핸들러에서 로컬 READ 요청을 원격 서비스로 위임하는 패턴 구현
- 타임아웃, 에러 처리, 캐싱 등 프로덕션 고려사항 점검
📚 사전 준비 지식
이 글은 intermediate 난이도로, CAP Java 프로젝트를 한 번이라도 생성해 본 경험을 전제로 합니다. CDS 모델링 기본 문법, Spring Boot의 의존성 주입(@Autowired) 개념, OData 프로토콜의 기본 구조(엔티티셋, $filter, $select)를 알고 있으면 수월합니다. SAP BTP Destination 서비스를 처음 접해도 따라올 수 있도록 로컬 구성 예시를 함께 다룹니다.
🔧 환경 / 버전 / 준비물
다음 환경을 기준으로 작성했습니다. 일반적으로 CAP Java 2.x/3.x 계열이면 동일한 패턴이 적용됩니다.
- CAP Java SDK 3.x (Spring Boot 3 기반), JDK 17 이상
- @sap/cds-dk 8.x (
cds import명령용 Node.js CLI) - Maven 3.9+, SAP BTP Cloud Foundry 환경(배포 시) 또는 로컬 실행
- 필수 의존성:
cds-feature-remote-odata(원격 OData 소비),cds-integration-cloud-sdk(Destination 연동)
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-feature-remote-odata</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-integration-cloud-sdk</artifactId>
<scope>runtime</scope>
</dependency>
💡 핵심 개념
CAP의 Remote Service를 이해하는 가장 쉬운 비유는 "전화 교환원"입니다. 애플리케이션 코드는 상대가 로컬 DB인지 외부 시스템인지 몰라도 됩니다. 똑같이 CQN(Core Query Notation) 쿼리를 던지면, 교환원 역할을 하는 CAP 런타임이 대상이 원격 서비스일 경우 쿼리를 OData HTTP 요청으로 번역해 전달하고, 응답 JSON을 다시 CAP의 Result 객체로 되돌려 줍니다.
이 구조를 이루는 세 가지 축은 다음과 같습니다.
- 모델(EDMX → CDS): 외부 서비스의 메타데이터($metadata)를
cds import로 가져오면 CSN 형태의 CDS 모델이 생성됩니다. 이 모델 덕분에 타입 세이프한 정적 모델 클래스(예:Suppliers_)를 코드에서 쓸 수 있습니다. - 구성(application.yaml):
cds.remote.services항목이 "이 서비스 정의는 로컬 DB가 아니라 원격 HTTP 엔드포인트"라고 런타임에 알려줍니다. OData 버전(v2/v4), URL suffix, Destination 이름을 여기서 지정합니다. - 연결(Destination): 실제 URL과 인증 정보는 코드가 아니라 BTP Destination 서비스(또는 로컬 환경 변수)에 둡니다. SAP Cloud SDK가 이 조회를 담당하므로, 인증 방식(BasicAuth, OAuth2ClientCredentials 등)이 바뀌어도 코드는 그대로입니다.
흐름을 도식으로 표현하면 이렇습니다.
클라이언트 → 로컬 CAP 서비스(PurchasingService) → @On 핸들러 → Remote Service(CqnService) → CQN→OData 변환 → Destination 조회 → 외부 시스템 HTTP 호출 → 응답 매핑 → Result 반환
중요한 설계 원칙 하나: 원격 엔티티를 로컬 서비스에 노출할 때는 자동 위임이 일어나지 않습니다. READ 이벤트를 원격으로 넘기는 핸들러를 직접 작성해야 하며, 이것이 CAP이 의도한 명시적 매시업 패턴입니다.
💻 실전 코드 3단계
시나리오: 사내 구매 관리 앱(PurchasingService)이 로컬로 구매오더(PurchaseOrders)를 관리하면서, 공급업체 마스터는 외부 S/4HANA 스타일 OData v2 서비스(SupplierCatalog)에서 실시간으로 읽어옵니다.
1단계: 외부 서비스 가져오기와 기본 호출
먼저 외부 서비스의 EDMX 파일을 프로젝트에 가져옵니다.
# 터미널에서 실행
# cds import ./SupplierCatalog.edmx --as cds
# 결과: srv/external/SupplierCatalog.cds 생성
application.yaml에 원격 서비스임을 선언합니다.
cds:
remote.services:
SupplierCatalog:
type: "odata-v2"
http:
suffix: "/sap/opu/odata/sap"
destination:
name: "supplier-backend"
이제 Java 코드에서 CqnService로 주입받아 일반 쿼리처럼 호출합니다.
@Component
public class SupplierLookup {
@Autowired
@Qualifier(SupplierCatalog_.CDS_NAME)
private CqnService supplierCatalog;
public List<Suppliers> findActiveSuppliers() {
CqnSelect query = Select.from(SupplierCatalog_.SUPPLIERS)
.columns(s -> s.supplierId(), s -> s.companyName(), s -> s.country())
.where(s -> s.isBlocked().eq(false))
.limit(20);
return supplierCatalog.run(query).listOf(Suppliers.class);
}
}
런타임이 이 CQN을 GET .../Suppliers?$select=...&$filter=IsBlocked eq false&$top=20 형태의 OData 요청으로 변환합니다. 로컬 테스트라면 Destination을 환경 변수로 흉내낼 수 있습니다.
{ "destinations": [ { "name": "supplier-backend", "url": "http://localhost:4004", "username": "dev", "password": "dev" } ] }
2단계: 실무 시나리오 — READ 위임 핸들러와 에러/로깅
로컬 서비스에 원격 엔티티를 프로젝션으로 노출하고, READ를 위임합니다.
using { SupplierCatalog as external } from './external/SupplierCatalog';
service PurchasingService {
entity PurchaseOrders as projection on db.PurchaseOrders;
@readonly
entity Suppliers as projection on external.Suppliers {
key supplierId, companyName, country
}
}
@Component
@ServiceName(PurchasingService_.CDS_NAME)
public class SupplierDelegationHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(SupplierDelegationHandler.class);
@Autowired
@Qualifier(SupplierCatalog_.CDS_NAME)
private CqnService supplierCatalog;
@On(event = CqnService.EVENT_READ, entity = Suppliers_.CDS_NAME)
public Result delegateToRemote(CdsReadEventContext ctx) {
long start = System.currentTimeMillis();
try {
Result result = supplierCatalog.run(ctx.getCqn());
log.info("SupplierCatalog READ ok: {} rows, {} ms",
result.rowCount(), System.currentTimeMillis() - start);
return result;
} catch (ServiceException e) {
log.error("SupplierCatalog READ failed: {}", e.getMessage());
throw new ServiceException(ErrorStatuses.BAD_GATEWAY,
"공급업체 시스템 응답이 없습니다. 잠시 후 다시 시도하세요.", e);
}
}
}
핵심 포인트: ctx.getCqn()을 그대로 전달하므로 클라이언트가 보낸 $filter, $top, $skip이 원격 시스템까지 투명하게 전파됩니다. 예외는 원문 그대로 노출하지 말고 상태 코드(502 등)와 사용자 친화적 메시지로 감싸는 것이 권장됩니다.
3단계: 프로덕션 — 타임아웃, 캐싱, 테스트
외부 호출은 반드시 실패를 전제로 설계합니다. Destination 속성으로 타임아웃을 걸고, 자주 변하지 않는 마스터 데이터는 짧은 TTL 캐시를 둡니다.
@Component
public class CachedSupplierLookup {
private final Cache<String, List<Suppliers>> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(100)
.build();
@Autowired
@Qualifier(SupplierCatalog_.CDS_NAME)
private CqnService supplierCatalog;
public List<Suppliers> byCountry(String country) {
return cache.get(country, key ->
supplierCatalog.run(
Select.from(SupplierCatalog_.SUPPLIERS)
.where(s -> s.country().eq(key)))
.listOf(Suppliers.class));
}
}
테스트는 원격 시스템 없이 핸들러 로직을 검증할 수 있게 MockMvc + 스텁 서비스 조합을 사용합니다.
@SpringBootTest
@AutoConfigureMockMvc
class SupplierDelegationTest {
@Autowired MockMvc mockMvc;
@MockBean(name = "SupplierCatalog") CqnService supplierCatalog;
@Test
void readSuppliers_delegatesToRemote() throws Exception {
when(supplierCatalog.run(any(CqnSelect.class)))
.thenReturn(ResultBuilder.selectedRows(
List.of(Map.of("supplierId", "S-100",
"companyName", "한빛부품"))).result());
mockMvc.perform(get("/odata/v4/PurchasingService/Suppliers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.value[0].supplierId").value("S-100"));
}
}
보안 측면에서는 자격 증명을 코드나 yaml에 하드코딩하지 말고 Destination 서비스 바인딩으로 관리하고, OAuth2ClientCredentials 같은 토큰 기반 인증을 일반적으로 권장합니다.
⚠️ 흔한 실수 / 트러블슈팅
- Q1. "Destination not found" 오류가 납니다. — yaml의
destination.name과 BTP 콕핏(또는 로컬 env)의 Destination 이름이 정확히 일치하는지, CF 환경이라면 Destination/Connectivity 서비스 인스턴스가 앱에 바인딩됐는지 확인하세요. 로컬에서는 destinations 환경 변수의 JSON 형식 오류가 흔한 원인입니다. - Q2. 호출은 되는데 응답 필드가 전부 null입니다. —
type을 잘못 지정한 경우가 대부분입니다. 대상이 OData v2인데odata-v4로 선언하면 페이로드 구조가 달라 매핑이 깨집니다. $metadata의Version속성을 확인하고odata-v2/odata-v4를 맞춰주세요. - Q3. 로컬 엔티티와 원격 엔티티를 $expand로 한 번에 조회할 수 없나요? — 원격과 로컬을 넘나드는 자동 조인/expand는 지원되지 않습니다. 각각 조회한 뒤 핸들러(After 핸들러 등)에서 키 기준으로 병합하는 애플리케이션 레벨 조인이 일반적인 해법입니다.
- Q4. POST/PATCH 호출 시 403이 발생합니다. — OData v2 백엔드는 쓰기 요청에 CSRF 토큰을 요구하는 경우가 많습니다. Cloud SDK 기반 연동에서 토큰 처리가 이뤄지는지, Destination의 인증 사용자에게 쓰기 권한이 있는지 함께 점검하세요.
🚀 이어서 볼 만한 주제
Remote Service 호출을 익혔다면 다음 주제로 확장해 보세요. 첫째, 데이터 복제(replication) 패턴 — 매 요청마다 원격 호출하는 대신 이벤트 기반으로 로컬 캐시 테이블에 동기화하는 방식입니다. 둘째, SAP Event Mesh / CloudEvents 연동으로 원격 시스템의 변경을 구독하는 패턴, 셋째, Resilience4j 기반 서킷 브레이커를 붙여 외부 장애가 전체 앱으로 번지지 않게 하는 구성입니다. Node.js 런타임의 동일 기능과 비교해 보는 것도 이해에 도움이 됩니다.
📚 참고 링크 및 문서
댓글 0
아직 댓글이 없습니다.