1. 상품 생성 API 구현
상품명과 가격을 받아 DB에 상품을 저장하고, 생성된 상품 정보를 반환한다. 이후 상품 생성 이벤트를 연결할 수 있도록 Service에 트랜잭션 경계를 둔다.
이번 단계에서는 HTTP 요청 → 입력 검증 → 상품 저장 → 응답 흐름을 완성한다. Outbox 연결은 다음 단계에서 진행한다.
전체 처리 흐름
POST /api/products
↓
JSON → CreateProductRequest 변환
↓
입력값 검증
↓
ProductController
↓
ProductService.create()
├─ Product.register()로 상품 생성 및 도메인 검증
└─ ProductRepository로 DB 저장
↓
DB 트랜잭션 커밋
↓
CreateProductResponse 생성
↓
201 Created 응답
역할에 따라 코드를 나눴다.
| 구성 요소 | 역할 |
|---|---|
| 요청 DTO | 입력 데이터와 검증 규칙 정의 |
| Controller | HTTP 요청을 받고 응답 생성 |
| Service | 상품 생성·저장의 처리 흐름과 트랜잭션 관리 |
| Entity | 상품 데이터와 도메인 규칙 관리 |
| Repository | DB 저장·조회 |
| 응답 DTO | 외부에 반환할 상품 정보 정의 |
1. 요청 DTO와 입력 검증
클라이언트가 입력하는 값은 상품명과 가격이다. 상품 ID와 생성 시각은 서버가 만들므로 요청에 포함하지 않는다.
public record CreateProductRequest(
@NotBlank(message = "상품명은 필수입니다.")
@Size(max = 100, message = "상품명은 100자 이하여야 합니다.")
String name,
@NotNull(message = "가격은 필수입니다.")
@DecimalMin(value = "1", message = "가격은 1 이상이어야 합니다.")
@DecimalMax(
value = Product.MAX_PRICE_VALUE,
message = "가격은 1000000 이하여야 합니다."
)
BigDecimal price
) {
}
record는 데이터를 담는 객체를 간결하게 정의하는 Java 문법이다. 값은 request.name(), request.price()로 꺼낸다.
| 어노테이션 | 검증 내용 |
|---|---|
@NotBlank | 이름이 null이거나 공백 문자열인지 |
@Size | 이름의 최대 길이 |
@NotNull | 가격 누락 여부 |
@DecimalMin | 가격의 최솟값 |
@DecimalMax | 가격의 최댓값 |
가격은 엔티티와 같은 BigDecimal로 받았다. 원 단위 정수인지 확인하는 규칙은 Product.register()에서 검사한다.
@DecimalMin, @DecimalMax의 value는 문자열이므로 "1", "1000000"처럼 작성한다. 공통 상수를 사용한다면 문자열 컴파일 상수로 선언한다.
public static final String MAX_PRICE_VALUE = "1000000";
2. Controller에서 요청 받기
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping
public ResponseEntity<ApiResponse<CreateProductResponse>> create(
@Valid @RequestBody CreateProductRequest request
) {
Product product = productService.create(
request.name(),
request.price()
);
CreateProductResponse response = new CreateProductResponse(
product.getId(),
product.getName(),
product.getPrice().longValueExact(),
product.getCreatedAt()
);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(response));
}
}
Controller는 요청을 받아 Service에 전달하고, 반환받은 상품을 HTTP 응답으로 바꾼다.
| 코드 | 역할 |
|---|---|
@RestController | Controller를 Spring Bean으로 등록하고 반환값을 응답 본문으로 전달 |
@RequestMapping | 기본 요청 경로 지정 |
@PostMapping | POST 요청 연결 |
@RequestBody | JSON을 요청 DTO로 변환 |
@Valid | DTO에 선언한 검증 규칙 실행 |
ResponseEntity | HTTP 상태 코드와 응답 본문 지정 |
처리 순서는 다음과 같다.
요청 JSON 읽기
↓
DTO로 변환
↓
@Valid 검증
↓
Controller 메서드 실행
JSON 변환이나 DTO 검증에 실패하면 Service를 호출하기 전에 예외 처리로 넘어간다.
3. 생성자 주입으로 Service 연결
Controller는 상품 생성 작업을 직접 처리하지 않고 Service에 맡긴다.
private final ProductService productService;
@RequiredArgsConstructor는 위 final 필드를 받는 생성자를 만들어 준다.
public ProductController(ProductService productService) {
this.productService = productService;
}
Spring이 자신이 관리하는 ProductService 객체를 생성자에 전달한다. 이것이 **생성자 기반 의존성 주입(DI)**이다.
- Bean: Spring이 생성하고 관리하는 객체
- DI: 필요한 객체를 외부에서 전달받는 방식
4. Service에서 상품 생성과 저장
Outbox를 연결하기 전 상품 저장 단계의 Service는 다음과 같다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
@Transactional
public Product create(String name, BigDecimal price) {
Product product = Product.register(name, price);
try {
return repository.saveAndFlush(product);
} catch (DataIntegrityViolationException e) {
throw new BusinessException(
ErrorCode.PRODUCT_NAME_DUPLICATED
);
}
}
}
처리는 두 단계로 나뉜다.
Product product = Product.register(name, price);
상품명·가격의 도메인 규칙을 검사하고 Java 객체를 만든다. 객체를 만드는 것만으로 DB에 저장되지는 않는다.
repository.saveAndFlush(product);
생성한 상품을 저장하고 SQL이 DB에 반영되도록 요청한다.
Controller는 HTTP 요청·응답을 담당하고, Service는 상품을 생성하고 저장하는 업무 흐름을 담당하도록 나눴다.
5. 트랜잭션으로 작업 묶기
@Transactional은 상품 생성과 저장을 하나의 DB 작업 단위로 묶는다.
현재 Controller에서 Service를 호출하는 구조에서는 다음과 같이 동작한다.
Service 호출
↓
Spring이 트랜잭션 시작
↓
상품 생성·저장
├─ 정상 완료 → 커밋
└─ 롤백 대상 예외 발생 → 롤백
↓
Controller로 결과 반환 또는 예외 전달
프로젝트의 BusinessException은 RuntimeException을 상속하므로 기본 롤백 대상이다.
지금은 상품 저장이 중심이지만, 이후 같은 메서드 안에 Outbox 저장을 추가한다. 그때 이 트랜잭션이 상품과 발행할 이벤트를 함께 저장하는 경계가 된다.
6. 이름 중복과 saveAndFlush()
상품명 중복은 DB의 유니크 제약으로 막는다.
저장 전에 같은 이름이 있는지 조회하는 것만으로는 동시 요청을 완전히 막을 수 없다.
요청 A: 같은 이름 없음 확인
요청 B: 같은 이름 없음 확인
↓
둘 다 저장 시도
따라서 DB 제약이 최종적으로 중복 저장을 막도록 했다. 저장 중 발생한 제약 위반을 업무 예외로 바꾸고, 클라이언트에는 409 PRODUCT_NAME_DUPLICATED로 응답한다.
save()와 saveAndFlush()의 차이
| 메서드 | 동작 |
|---|---|
save() | 엔티티 저장을 요청하며, SQL 실행 시점은 상황에 따라 달라짐 |
saveAndFlush() | 저장 후 보류된 변경 사항을 DB에 반영하도록 flush까지 요청 |
이번에는 저장 호출 구간에서 이름 중복 제약 위반을 확인하고 예외를 변환하기 위해 saveAndFlush()를 사용했다.
Flush와 커밋은 다르다.
flush → SQL을 DB에 반영
commit → 트랜잭션의 변경을 최종 확정
flush가 성공해도 이후 같은 트랜잭션에서 실패하면 롤백할 수 있다.
다만 DataIntegrityViolationException이 항상 이름 중복만 의미하는 것은 아니다. 현재 상품 저장 구조를 전제로 변환한 것이므로, 다른 제약이 추가되면 구분이 필요하다.
7. Repository로 DB 접근
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
JpaRepository<Product, Long>의 의미는 다음과 같다.
- 관리할 엔티티:
Product - 엔티티 ID 타입:
Long
Spring Data JPA가 구현 객체를 제공하므로 기본 저장·조회 메서드를 직접 구현하지 않아도 된다.
repository.save(product);
repository.saveAndFlush(product);
repository.findById(productId);
테이블·컬럼 연결 정보는 Product 엔티티의 @Entity, @Id, @Column 등을 사용한다.
8. 응답 DTO와 생성 응답
엔티티 전체를 직접 반환하지 않고, API에서 필요한 정보만 응답 DTO로 전달했다.
public record CreateProductResponse(
Long productId,
String name,
long price,
LocalDateTime createdAt
) {
}
가격은 원 단위 정수로 응답하기 위해 long으로 변환한다.
product.getPrice().longValueExact()
longValueExact()는 소수 부분이나 범위를 벗어난 값을 조용히 잘라내지 않고 예외로 알린다.
Controller는 공통 응답 형식과 201 Created 상태를 지정한다.
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(response));
응답 예시는 다음과 같다.
{
"success": true,
"message": "success",
"data": {
"productId": 1,
"name": "키보드",
"price": 30000,
"createdAt": "2026-11-03T10:00:00"
}
}
9. 입력 오류와 업무 예외 응답
오류는 공통 GlobalExceptionHandler에서 응답으로 변환한다.
| 상황 | 응답 |
|---|---|
| 이름·가격 누락 또는 검증 실패 | 400 VALIDATION_ERROR |
| 상품 도메인 규칙 위반 | 400 VALIDATION_ERROR |
| 같은 상품명 저장 | 409 PRODUCT_NAME_DUPLICATED |
| 읽을 수 없는 JSON 또는 잘못된 필드 타입 | 400 VALIDATION_ERROR |
예를 들어 다음 요청은 가격을 BigDecimal로 변환할 수 없다.
{
"name": "키보드",
"price": "abc"
}
이 경우 DTO 검증 이전에 HttpMessageNotReadableException이 발생한다. 일반 서버 오류로 처리되지 않도록 예외 처리를 추가했다.
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadableBody(
HttpMessageNotReadableException ex
) {
return ResponseEntity.badRequest()
.body(ErrorResponse.of(ErrorCode.VALIDATION_ERROR));
}
이렇게 JSON으로 읽을 수 없는 요청과, 읽었지만 검증 규칙을 위반한 요청을 모두 입력 오류로 처리했다.
검증한 내용
- 정상 요청에서
201응답 및 상품 저장 - 이름 누락·공백·길이 초과 시
400 - 가격 누락·범위 초과·소수 금액 시
400 - JSON 변환 실패 시
400 - 중복 상품명 요청 시
409 - 동시 동일 이름 요청에서 중복 상품 저장 방지
여기까지 Controller가 요청을 받고, Service가 트랜잭션 안에서 상품을 저장한 뒤, Controller가 결과를 응답하는 흐름을 구현했다.
다음 정리에서는 이 Service에 Outbox 저장을 연결하여 상품 생성 이벤트를 Kafka로 발행하는 과정을 이어간다.