본문 바로가기

프로젝트/온라인 서점

온라인 서점 #2 - RESTful API 구현(도서 CRUD)

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로 수정해서 해결