API 설계
엔드 포인트 | HTTP 메서드 | 요청 본문 | 상태 | 응답 본문 | 설명 |
/books | GET | 200 | Book[] | 카탈로그 내 모든 도서 조회 | |
/books | POST | Book | 201 | Book | 카탈로그에 새 도서 추가 |
409 | 동일한 ISBN 도서가 이미 존재 | ||||
/books/{isbn} | GET | 200 | Book | 주어진 ISBN 도서 조회 | |
404 | 주어진 ISBN 도서가 존재하지 않음 | ||||
/books | PUT | Book | 200 | Book | 주어진 ISBN 도서 갱신 |
404 | 주어진 ISBN 도서가 존재하지 않음 | ||||
/books/{isbn} | DELETE | 204 | 주어진 ISBN 도서 삭제 |
주요 HTTP 메서드
- GET : 서버에서 리소스를 조회
- POST : 서버에 리소스를 생성
- PUT : 서버의 리소스를 완전히 수정
- DELETE : 서버의 특정 리소스 삭제
주요 HTTP 상태 코드
- 200 : 서버가 요청을 성공적으로 처리
- 201 : 요청이 처리되어 새로운 리소스가 생성
- 204 : 요청이 처리되었지만 응답 본문은 없음
- 404 : 지정한 리소스를 찾을 수 없음
- 409 : 요청과 현재 리소스의 상태가 다름
CRUD API 구현
Book 도메인 개체 정의
package com.bookstore.catalog.domain;
public record Book(
String isbn,
String title,
String author,
long price
) {}
record는 불가변 데이터를 표현하기 위한 클래스로 생성자, getter, equals 등의 메서드를 자동 생성하지만 상속 및 상태 변이가 불가능
RestController 구현
package com.bookstore.catalog.web;
import com.bookstore.catalog.domain.Book;
import com.bookstore.catalog.domain.BookService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public Iterable<Book> get() {
return bookService.viewBookList();
}
@GetMapping("{isbn}")
public Book getByIsbn(@PathVariable String isbn) {
return bookService.viewBookDetails(isbn);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book post(@RequestBody Book book) {
return bookService.addBookToCatalog(book);
}
@DeleteMapping("{isbn}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String isbn) {
bookService.removeBookFromCatalog(isbn);
}
@PutMapping
public Book put(@RequestBody Book book) {
return bookService.editBookDetails(book);
}
}
- @RestController : REST 전용 컨트롤러, 메서드 반환값을 직렬화(HttpMessageConverter)해서 본문에 담아 보냄
- @RequestMapping : 클래스가 핸들러를 제공하는 루트 패스("books")를 인식
- @GetMapping / @PostMapping / @PutMapping / @DeleteMapping : 해당하는 HTTP 요청을 특정 핸들러 메서드로 연결
- @ResponseStatus : 응답 상태 코드를 지정
- 생성자 주입이 의존성 주입의 표준으로 불변성, 테스트 용이성 등의 장점이 있음
- 생성자가 한 개만 있을 때는 @Autowired 생략 가능
Service 구현
package com.bookstore.catalog.domain;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Iterable<Book> viewBookList() {
return bookRepository.findAll();
}
public Book viewBookDetails(String isbn) {
return bookRepository.findByIsbn(isbn)
.orElseThrow(() -> new BookNotFoundException(isbn));
}
public Book addBookToCatalog(Book book) {
if (bookRepository.existsByIsbn(book.isbn())) {
throw new BookAlreadyExistsException(book.isbn());
}
return bookRepository.save(book);
}
public void removeBookFromCatalog(String isbn) {
bookRepository.deleteByIsbn(isbn);
}
public Book editBookDetails(Book book) {
return bookRepository.findByIsbn(book.isbn())
.map(existingBook -> {
Book bookToUpdate = new Book(
existingBook.isbn(),
book.title(),
book.author(),
book.price());
return bookRepository.save(bookToUpdate);
})
.orElseThrow(() -> new BookNotFoundException(book.isbn()));
}
}
- @Service : 비즈니스 로직 계층 컴포넌트 식별
- Optional<T> : 값 없음을 명시적으로 표현할 수 있는 타입으로 리포지터리 조회 결과에 사용하기 적합
- orElseThrow, orElseGet 등으로 값이 없을 경우의 분기를 간단하게 표현할 수 있음
Repository 구현
package com.bookstore.catalog.domain;
import java.util.Optional;
public interface BookRepository {
Iterable<Book> findAll();
Optional<Book> findByIsbn(String isbn);
boolean existsByIsbn(String isbn);
Book save(Book book);
void deleteByIsbn(String isbn);
}
package com.bookstore.catalog.persistence;
import com.bookstore.catalog.domain.Book;
import com.bookstore.catalog.domain.BookRepository;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryBookRepository implements BookRepository {
private static final Map<String, Book> books = new ConcurrentHashMap<>();
@Override
public Iterable<Book> findAll() {
return List.copyOf(books.values());
}
@Override
public Optional<Book> findByIsbn(String isbn) {
return Optional.ofNullable(books.get(isbn));
}
@Override
public boolean existsByIsbn(String isbn) {
return books.containsKey(isbn);
}
@Override
public Book save(Book book) {
books.put(book.isbn(), book);
return book;
}
@Override
public void deleteByIsbn(String isbn) {
books.remove(isbn);
}
}
- @Repository : 데이터 접근 계층 컴포넌트 식별
- ConcurrentHashMap : 동시성 처리를 지원하는 해시맵
- List.of : 변경 불가능한 고정된 결과를 복제해서 사용, unmodifiableList는 원본 값이 변경되면 같이 변경됨
데이터 유효성 검사 및 오류 처리
도메인 오류 구현
package com.bookstore.catalog.domain;
public class BookAlreadyExistsException extends RuntimeException {
public BookAlreadyExistsException(String isbn) {
super("ISBN이 " + isbn + "인 책이 이미 존재합니다");
}
}
package com.bookstore.catalog.domain;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(String isbn) {
super("ISBN이 " + isbn + "인 책이 존재하지 않습니다.");
}
}
데이터 유효성 검사
자바 빈 유효성 검사를 위해 스프링 부트 유효성 검사 의존성을 build.gradle에 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// 스프링 유효성 검사 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Book 클래스에 다음의 같은 제약 조건을 추가
- ISBN은 반드시 존재하고 10자리 혹은 13자리의 숫자
- 제목, 저자는 반드시 존재
- 가격은 반드시 존재하고 0보다 커야 하고 10원 단위이며 최대 100억
package com.bookstore.catalog.domain;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
public record Book(
@NotBlank(message = "ISBN은 반드시 있어야 합니다")
@Pattern(
regexp = "^(\\d{10}|\\d{13})$",
message = "ISBN은 10자리 혹은 13자리의 숫자여야 합니다"
)
String isbn,
@NotBlank(message = "제목은 반드시 있어야 합니다")
String title,
@NotBlank(message = "저자는 반드시 있어야 합니다")
String author,
@NotNull(message = "가격은 반드시 있어야 합니다")
@Positive(message = "가격은 0보다 커야 합니다")
@WonStep(value = 10)
@Max(10_000_000_000L)
Long price
) {}
- @NotNull, @NotEmpty, @NotBlank 순으로 제약이 강해짐
- @NotNull : null을 허용하지 않음, 빈 값 및 공백 허용
- @NotEmpty : null 및 빈 값을 허용하지 않음, 공백 허용
- @NotBlank : null, 빈 값, 공백을 모두 허용하지 않음
- @NotEmpty와 @NotBlank는 숫자 타입에는 사용 불가
- @Pattern : 정규식과 일치하여야 통과
- @Positive : 값은 양수여야 통과
10원 단위를 확인하는 애너테이션은 따로 없으므로 커스텀 애너테이션을 구현하겠습니다
package com.bookstore.catalog.domain;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = WonStepValidator.class)
@Documented
public @interface WonStep {
String message() default "가격은 {value}원 단위로 입력해야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
long value() default 10;
}
- @Target : 애너테이션을 붙일 수 있는 위치를 제한(필드, 메서드 파라미터)
- @Retention : 컴파일 후 언제까지 애너테이션이 유지되는지 제한(런타임까지)
- @Constraint : 유효성 검사를 진행할 클래스 지정(WonStepValidator.class)
- @Documented : Javadoc에 애너테이션 정보도 함께 생성
- 필수 속성 : message(), groups(), payload()
- message() : 검증 실패 시 메시지
- groups() : 검증이 진행될 그룹
- payload() : 검증 시 전달할 메타 정보
- value() : 몇 원 단위로 제한할 것인지에 관련된 커스텀 파라미터
package com.bookstore.catalog.domain;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class WonStepValidator implements ConstraintValidator<WonStep, Long> {
private long step;
@Override
public void initialize(WonStep constraintAnnotation) {
this.step = constraintAnnotation.value();
}
@Override
public boolean isValid(Long value, ConstraintValidatorContext constraintValidatorContext) {
if (value == null) return true;
return value % step == 0;
}
}
- 애너테이션에서 지정한 value()로 step을 initialize()로초기화한 후 isValid()로 검증
- null인 경우는 @NotNull로 확인하므로 true 반환
Controller 클래스의 Book 개체에 대한 유효성 검사를 @RequestBody로 Book이 사용될 때 @Valid 애너테이션을 사용
package com.bookstore.catalog.web;
import com.bookstore.catalog.domain.Book;
import com.bookstore.catalog.domain.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public Iterable<Book> get() {
return bookService.viewBookList();
}
@GetMapping("{isbn}")
public Book getByIsbn(@PathVariable String isbn) {
return bookService.viewBookDetails(isbn);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book post(@Valid @RequestBody Book book) {
return bookService.addBookToCatalog(book);
}
@DeleteMapping("{isbn}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String isbn) {
bookService.removeBookFromCatalog(isbn);
}
@PutMapping
public Book put(@Valid @RequestBody Book book) {
return bookService.editBookDetails(book);
}
}
- @Valid : 객체에 있는 제약 조건을 검증 트리거로 실행
- @RequestBody 앞에 사용 시 컨트롤러 진입 전에 필드를 검사하고 실패 시 MethodArgumentNotValidException 발생
ControllerAdvice 구현
package com.bookstore.catalog.web;
import com.bookstore.catalog.domain.BookAlreadyExistsException;
import com.bookstore.catalog.domain.BookNotFoundException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class BookControllerAdvice {
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
String bookNotFoundHandler(BookNotFoundException ex) {
return ex.getMessage();
}
@ExceptionHandler(BookAlreadyExistsException.class)
@ResponseStatus(HttpStatus.CONFLICT)
String bookAlreadyExistsHandler(BookAlreadyExistsException ex) {
return ex.getMessage();
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
var errors = new HashMap<String, String>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return errors;
}
}
- @RestControllerAdvice = @ControllerAdvice + @Responsebody
- @ControllerAdvice : @Controller 클래스가 공유하는 공통 로직을 처리하는 클래스로 예외 처리 등에 사용
- @Responsebody : 클래스 내 모든 메서드가 자동으로 JSON 또는 XML 등 HTTP 응답 바디로 반환
- @ExceptionHandler : 지정한 예외를 가로채서 처리
- var : 로컬 변수 타입을 컴파일 시점에 추론
- 복잡한 타입을 간소화하여 가독성 상승, ide대신 컴파일러가 타입을 추론하여 ide 의존성 감소
- 타입이 불명확하면 가독성이 떨어지고 잘못된 추론 가능성이 생김
- 타입이 명확한 복잡한 타입(제네릭)에 사용하기에 좋음
- MethodArgumentNotValidException
- 내부에는 어떤 필드(getField())에서 어떤 에러 메시지(getDefaultMessage())가 있는지 저장되어 있음
- 이를 활용해 맵으로 필드와 에러 메시지를 보내면 어떤 필드에서 어떤 에러가 발생했는지 쉽게 파악 가능
테스트
API 테스트
postman과 같은 도구를 사용해서 API를 테스트 합니다
정상적으로 작동되는 것을 확인했으니 커밋 후 푸시해줍니다
git pull origin main
git checkout -b "feature/catalog"
git add .
git commit -m "feat: 책 관련 CRUD 구현"
git push origin feature/catalog
트러블 슈팅
@NotBlank를 숫자 타입 변수에 사용해서 에러 발생
숫자 타입에 사용 가능한 @NotNull로 수정해서 해결
'프로젝트 > 온라인 서점' 카테고리의 다른 글
온라인 서점 #1 - 프로젝트 기획과 초기 설계 (0) | 2025.06.23 |
---|