Spring Boot 3 + Apache Olingo 5 기반의 OData V4 학습용 서버입니다. Product / Category / Supplier 3개 도메인으로 OData 표준 쿼리 옵션과 CRUD를 완전히 구현합니다.
| 항목 | 버전 / 값 |
|---|---|
| Java | 21 (IntelliJ JBR) |
| Spring Boot | 3.2.5 |
| Apache Olingo | 5.0.0 (Jakarta Servlet 호환) |
| Gradle | 8.7 |
| H2 Database | in-memory (jdbc:h2:mem:odatadb) |
| Spring Data JPA | Hibernate 6.4.4 |
| Lombok | 최신 |
src/
├── main/java/com/example/odata/
│ ├── ODataApplication.java # 진입점
│ ├── config/
│ │ └── ODataServletConfig.java # Olingo 서블릿 /odata/* 등록
│ ├── edm/
│ │ └── DemoEdmProvider.java # OData EDM 스키마 정의
│ ├── processor/
│ │ ├── DemoEntityCollectionProcessor.java # 컬렉션 Read + 쿼리 옵션
│ │ ├── DemoEntityProcessor.java # 단건 Read + CUD
│ │ └── DemoPrimitiveProcessor.java # $count 지원
│ ├── service/
│ │ ├── ProductService.java # 상품 비즈니스 로직
│ │ ├── CategoryService.java # 카테고리 비즈니스 로직
│ │ └── SupplierService.java # 공급자 비즈니스 로직
│ ├── mapper/
│ │ └── ODataEntityMapper.java # JPA ↔ OData Entity 변환
│ ├── filter/
│ │ └── FilterExpressionVisitor.java # $filter → JPA Specification
│ ├── exception/
│ │ └── ODataErrorHelper.java # OData 표준 에러 응답 생성
│ ├── util/
│ │ ├── ODataPropertyExtractor.java # Entity 프로퍼티 추출 유틸리티
│ │ ├── ExpandOptionResolver.java # $expand 옵션 파싱 유틸리티
│ │ └── ODataStringUtils.java # 문자열 변환 유틸리티 (toCamelCase)
│ ├── entity/
│ │ ├── Product.java
│ │ ├── Category.java
│ │ └── Supplier.java
│ ├── repository/
│ │ ├── ProductRepository.java
│ │ ├── CategoryRepository.java
│ │ └── SupplierRepository.java
│ └── data/
│ └── DataInitializer.java # 시드 데이터 25개 자동 생성
└── test/java/com/example/odata/
├── service/ # 서비스 단위 테스트
├── mapper/ # 매퍼 단위 테스트
├── filter/ # 필터 단위 테스트
└── integration/ # 전체 통합 테스트
Category (1) ──── (N) Product (N) ──── (1) Supplier
| 필드 | 타입 | 설명 |
|---|---|---|
| Id | Long | PK (자동 생성) |
| Name | String | 상품명 (필수) |
| Description | String | 설명 |
| Price | Integer | 가격 (원) |
| Rating | Double | 평점 (0.0 ~ 5.0) |
| CategoryId | Long | 카테고리 FK |
| SupplierId | Long | 공급자 FK |
| CreatedAt | DateTimeOffset | 생성 시각 |
| UpdatedAt | DateTimeOffset | 수정 시각 |
| 필드 | 타입 | 설명 |
|---|---|---|
| Id | Long | PK |
| Name | String | 카테고리명 (필수) |
| Description | String | 설명 |
| 필드 | 타입 | 설명 |
|---|---|---|
| Id | Long | PK |
| Name | String | 공급자명 (필수) |
| String | 이메일 | |
| Phone | String | 전화번호 |
| Address | String | 주소 |
- IntelliJ IDEA 2025.x (JBR 21 포함)
- 시스템 기본 Java가 1.8인 경우 반드시 JBR 사용
export JAVA_HOME="/c/Program Files/JetBrains/IntelliJ IDEA 2025.3.4/jbr"./gradlew bootRun --no-daemon포트 8080이 사용 중이면:
./gradlew bootRun --no-daemon --args='--server.port=8081'서버가 시작되면 시드 데이터(카테고리 3개, 공급자 3개, 상품 25개)가 자동으로 생성됩니다.
./gradlew compileJava --no-daemon기본 URL: http://localhost:8080/odata
# 서비스 문서 (EntitySet 목록)
GET http://localhost:8080/odata/
# EDM 메타데이터 (스키마 전체)
GET http://localhost:8080/odata/$metadata# 상품 목록 (기본 페이지: 20건, nextLink 포함)
GET http://localhost:8080/odata/Products
# 카테고리 목록
GET http://localhost:8080/odata/Categories
# 공급자 목록
GET http://localhost:8080/odata/Suppliers# ID로 조회
GET http://localhost:8080/odata/Products(1)
GET http://localhost:8080/odata/Categories(2)
GET http://localhost:8080/odata/Suppliers(3)GET http://localhost:8080/odata/Products/$count
# 응답: 25# 가격이 100,000원 초과
GET http://localhost:8080/odata/Products?$filter=Price gt 100000
# 평점 4.0 이상이고 가격 500,000원 초과
GET http://localhost:8080/odata/Products?$filter=Price gt 500000 and Rating ge 4.0
# 이름에 '갤럭시' 포함
GET http://localhost:8080/odata/Products?$filter=contains(Name,'갤럭시')
# 이름이 'LG'로 시작
GET http://localhost:8080/odata/Products?$filter=startswith(Name,'LG')
# 가격이 50만 미만 또는 평점 4.5 초과
GET http://localhost:8080/odata/Products?$filter=Price lt 500000 or Rating gt 4.5지원 연산자: eq, ne, gt, lt, ge, le, and, or, not
지원 함수: contains, startswith, endswith
# 가격 내림차순
GET http://localhost:8080/odata/Products?$orderby=Price desc
# 이름 오름차순
GET http://localhost:8080/odata/Products?$orderby=Name asc
# 복합 정렬
GET http://localhost:8080/odata/Products?$orderby=Rating desc,Price asc# Name, Price 필드만 반환
GET http://localhost:8080/odata/Products?$select=Name,Price
# 단건에도 적용 가능
GET http://localhost:8080/odata/Products(1)?$select=Name,Description,Rating주의: 프로퍼티 이름은 EDM 정의 기준 대소문자 구분 (
Name,Price— 소문자 불가)
# 카테고리 정보 포함
GET http://localhost:8080/odata/Products(1)?$expand=Category
# 공급자 정보 포함
GET http://localhost:8080/odata/Products(1)?$expand=Supplier
# 카테고리 + 공급자 모두 포함
GET http://localhost:8080/odata/Products(1)?$expand=Category,Supplier
# 전체 expand
GET http://localhost:8080/odata/Products(1)?$expand=*# 처음 5건
GET http://localhost:8080/odata/Products?$top=5
# 11번째부터 5건
GET http://localhost:8080/odata/Products?$top=5&$skip=10서버 드리븐 페이징: 기본 페이지 크기 20건, 최대 100건.
다음 페이지가 있으면 응답에 @odata.nextLink가 포함됩니다.
# 응답 body에 @odata.count 포함
GET http://localhost:8080/odata/Products?$count=true# 가격 50만 초과 상품을 이름순 정렬, 상위 3건, Name/Price만 반환
GET http://localhost:8080/odata/Products?$filter=Price gt 500000&$orderby=Name&$top=3&$select=Name,Price
# 갤럭시 포함 상품, 공급자 정보 포함, 평점 내림차순
GET http://localhost:8080/odata/Products?$filter=contains(Name,'갤럭시')&$expand=Supplier&$orderby=Rating desccurl -X POST http://localhost:8080/odata/Products \
-H "Content-Type: application/json" \
-d '{
"Name": "신상품",
"Description": "새로 출시된 상품",
"Price": 150000,
"Rating": 4.2,
"CategoryId": 1,
"SupplierId": 1
}'응답: 201 Created + Location: Products(26) 헤더
curl -X POST http://localhost:8080/odata/Categories \
-H "Content-Type: application/json" \
-d '{"Name": "스포츠", "Description": "운동용품 카테고리"}'curl -X POST http://localhost:8080/odata/Suppliers \
-H "Content-Type: application/json" \
-d '{
"Name": "현대전자",
"Email": "contact@hyundai.com",
"Phone": "02-9999-0000",
"Address": "서울시 서초구"
}'변경할 필드만 전송. 나머지 필드는 기존 값 유지.
# 가격만 변경
curl -X PATCH http://localhost:8080/odata/Products(1) \
-H "Content-Type: application/json" \
-d '{"Price": 990000}'
# 여러 필드 변경
curl -X PATCH http://localhost:8080/odata/Products(1) \
-H "Content-Type: application/json" \
-d '{"Price": 1100000, "Rating": 4.9}'
# 카테고리 변경
curl -X PATCH http://localhost:8080/odata/Products(1) \
-H "Content-Type: application/json" \
-d '{"CategoryId": 2}'응답: 200 OK + 수정된 엔티티 전체
전송하지 않은 필드는 null로 설정됩니다.
curl -X PUT http://localhost:8080/odata/Products(1) \
-H "Content-Type: application/json" \
-d '{
"Name": "갤럭시 S25",
"Description": "최신 플래그십",
"Price": 1350000,
"Rating": 5.0,
"CategoryId": 1,
"SupplierId": 1
}'# 상품 삭제
curl -X DELETE http://localhost:8080/odata/Products(26)
# 카테고리 삭제
curl -X DELETE http://localhost:8080/odata/Categories(4)
# 공급자 삭제
curl -X DELETE http://localhost:8080/odata/Suppliers(4)응답: 204 No Content
모든 에러는 OData 표준 형식으로 반환됩니다:
{
"error": {
"code": "404",
"message": "Product not found: 99999"
}
}| HTTP 상태 | 발생 조건 |
|---|---|
| 400 Bad Request | Name 누락, 잘못된 $filter 구문 |
| 403 Forbidden | 접근 권한 없음 |
| 404 Not Found | 존재하지 않는 ID, 존재하지 않는 CategoryId/SupplierId |
| 405 Method Not Allowed | 컬렉션에 PUT/DELETE 요청 |
| 409 Conflict | 리소스 충돌 |
| 500 Internal Server Error | 예상치 못한 서버 오류 |
| 501 Not Implemented | 미구현 기능 요청 |
앱 실행 중 브라우저에서 접속:
URL: http://localhost:8080/h2-console
JDBC URL: jdbc:h2:mem:odatadb
Username: sa
Password: (없음)
export JAVA_HOME="/c/Program Files/JetBrains/IntelliJ IDEA 2025.3.4/jbr"
# 전체 테스트 실행
./gradlew test --no-daemon
# 특정 테스트만 실행
./gradlew test --no-daemon --tests "com.example.odata.integration.ODataIntegrationTest"
./gradlew test --no-daemon --tests "com.example.odata.service.ProductServiceTest"| 테스트 클래스 | 테스트 수 | 내용 |
|---|---|---|
| ProductServiceTest | 11 | CUD + 검증 단위 테스트 |
| CategoryServiceTest | 7 | CUD 단위 테스트 |
| SupplierServiceTest | 7 | CUD 단위 테스트 |
| ODataErrorHelperTest | 4 | 에러 응답 생성 검증 |
| ODataEntityMapperTest | 6 | JPA ↔ OData 매핑 검증 |
| FilterExpressionVisitorTest | 7 | $filter → JPA Predicate 검증 |
| ODataIntegrationTest | 21 | 전체 E2E 통합 테스트 |
참고: Olingo는 별도 서블릿으로 등록되므로
MockMvc사용 불가. 통합 테스트는@SpringBootTest(webEnvironment = RANDOM_PORT)+TestRestTemplate사용.
서비스 베이스 URL: http://localhost:8080/odata
| 항목 | 내용 |
|---|---|
util/ODataStringUtils 추출 |
DemoEntityCollectionProcessor, FilterExpressionVisitor의 중복 toCamelCase() 통합 |
util/ExpandOptionResolver 추출 |
DemoEntityCollectionProcessor, DemoEntityProcessor의 중복 resolveExpandNames() 통합 |
util/ODataPropertyExtractor 추출 |
DemoEntityProcessor의 4개 프로퍼티 추출 헬퍼 통합 |
ODataErrorHelper 확장 |
conflict(), forbidden(), notImplemented() 메서드 추가 |
ProductService 레이지 로딩 개선 |
암묵적 .getName() 트리거 → Hibernate.initialize() 명시적 초기화 |