Spring Boot로 첫 번째 REST API 만들기

1. 개요

이 글에서는 Spring Boot로 완전한 REST API를 만드는 방법을 안내하며, Order(주문) 관리를 예제로 다룹니다. 이 글을 읽으면 프로젝트를 구축하고, CRUD를 처리하는 controller를 작성하고, HTTP 표준에 맞는 에러를 반환하고, MockMvc를 사용한 API 테스트를 작성할 수 있게 됩니다.

2. 프로젝트 초기화

Spring Initializr를 통해 프로젝트를 생성하고 다음 dependency들을 추가하세요:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-web은 내장 Tomcat과 Spring MVC를 제공합니다. spring-boot-starter-validation은 request body 유효성 검사에 사용됩니다(5번 항목). spring-boot-starter-test는 JUnit 5, AssertJ, MockMvc를 포함합니다.

애플리케이션이 제대로 실행되는지 확인하세요:

@SpringBootApplication
public class OrderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApiApplication.class, args);
    }
}

mvn spring-boot:run을 실행하면 Tomcat이 port 8080에서 시작하는 것을 볼 수 있습니다.

3. 모델 정의

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // getters, setters
}

이것은 간단한 모델이며 아직 JPA와 무관합니다. 이 글의 목표는 먼저 REST layer를 숙달하는 것이며, persistence 부분은 다른 글에서 다룹니다.

4. 임시 데이터 저장 Service 작성

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMap은 임시 ‘데이터베이스’ 역할을 하여 DB 설정 없이 데모를 실행할 수 있게 합니다. AtomicLong은 증가하는 id를 생성하며, 여러 thread가 동시에 create를 호출할 때 안전합니다.

참고: 이 데이터 저장 방식은 학습용이며 앱을 재시작하면 데이터가 손실됩니다. 프로덕션에서는 Spring Data JPA 또는 동등한 것이 필요합니다.

5. REST Controller 작성

5.1. 생성 및 목록 조회 Endpoint

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestController는 @Controller와 @ResponseBody를 결합하여 view 대신 JSON을 반환합니다. @RequestMapping("/api/orders")는 클래스의 모든 endpoint에 공통 prefix를 붙입니다. @PostMapping은 @RequestBody를 통해 body를 받으며, Spring은 spring-boot-starter-web에 포함된 Jackson을 사용하여 JSON을 OrderRequest로 자동 변환합니다.

OrderRequest는 입력 검증용 record로 Order와 분리되어 있습니다:

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. id 조회 및 삭제 Endpoint

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariable는 URL에서 {id} 값을 가져옵니다. order를 찾을 수 없을 때 API는 null 대신 404 Not Found를 반환합니다 — 이것은 REST API를 처음 만들 때 놓치기 쉬운 부분입니다.

curl로 시도해보세요:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. 중앙집중식 에러 처리

처리하지 않으면 validation 오류는 읽기 어려운 기본 JSON 응답을 반환합니다. @RestControllerAdvice를 사용하여 표준화하세요:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

이 클래스는 @Valid에서 발생한 모든 MethodArgumentNotValidException을 잡고, 필드별로 오류를 수집한 후 읽기 쉬운 body와 함께 400 Bad Request를 반환합니다:

{
  "amount": "must be greater than 0",
  "customerName": "must not be blank"
}

참고: Spring의 기본 exception을 프로덕션에 노출하지 마세요 — stack trace를 노출할 수 있으며 보안에 영향을 줍니다.

7. Controller 테스트 작성

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTest는 web layer(controller, filter, validation)만 로드하며 전체 context를 초기화하지 않아 테스트가 빠르게 실행됩니다. @MockBean은 실제 OrderService를 mock으로 대체하여 controller를 service 로직과 독립적으로 테스트할 수 있게 합니다. 두 개의 테스트 케이스는 성공 경로와 오류 경로를 모두 검증합니다 — 모든 endpoint에 있어야 하는 습관입니다.

service layer에서 AssertJ를 사용한 추가 검증:

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. 자주 발생하는 오류

  • @RequestBody 빠뜨림: Spring이 JSON을 object에 bind하지 않아 field가 항상 null인데 명확한 오류 메시지가 없습니다.
  • ResponseEntity.notFound() 대신 null 반환: client가 body가 null인 200 OK를 받아 frontend에서 bug가 발생하기 쉽습니다.
  • 입력 검증 미실시: API가 음수 amount, 빈 이름 등을 받으면 하위 layer에서 논리 오류가 발생하며 추적하기 어렵습니다.
  • @RestController 대신 @Controller 사용: 각 method에 수동으로 @ResponseBody를 추가해야 하며 빼먹기 쉽습니다.

참고: API가 여러 client(mobile, web, partner)를 지원한다면 처음부터 versioning을 고려하세요(예: /api/v1/orders) 나중에 breaking change를 피하기 위해.

9. 결론

Spring Boot로 기본 REST API를 구축했습니다: model, service, controller, 중앙집중식 에러 처리부터 MockMvc를 사용한 테스트까지. 다음 단계로, ConcurrentHashMap을 Spring Data JPA로 대체하여 데이터를 실제로 persist하고, EntityNotFoundException 같은 다른 exception을 처리하도록 @RestControllerAdvice를 확장해야 합니다.

spring-bootrest-apijavaspring-web