API(Application Programming Interface)는 “네가 이렇게 말하면 내가 이렇게 해 줄게.”라는 약속이다. 두 소프트웨어가 서로 요청과 응답을 주고받을 때, 한쪽이 어떻게 요청을 보내고 반대쪽은 어떻게 응답을 보내면 되는지 규칙을 메뉴판으로 정해 두는 것과 같다.
예를 들어, 카페에서 메뉴판(API 문서)을 보고 “아메리카노”라는 단어를 정확히 써야 원하는 음료가 나오는 것처럼, API도 정해진 규격대로 요청해야 원하는 응답을 받을 수 있다. 또한, 바리스타가 주문을 받고 마음대로 제조해 음료를 주면 안 되듯, 서버는 정해진 규격대로만 응답해야 한다.
REST(Representational State Transfer)는 웹의 기존 기술과 HTTP 프로토콜을 그대로 활용하여 웹의 장점을 최대한 활용할 수 있는 아키텍처 설계 원칙의 집합체다.
각 요청의 응답에 가용한 다른 요청들의 정보를 포함하는 원칙으로, 클라이언트가 API 문서를 참조하지 않아도 다음에 수행할 수 있는 작업 혹은 이전에 작업했던 작업을 발견할 수 있게 해 준다.
{
"id": 1,
"title": "Spring Boot 완벽 가이드",
"author": "김개발",
"_links": {
"self": {
"href": "http://localhost:8080/api/v1/books/1"
},
"reviews": {
"href": "http://localhost:8080/api/v1/books/1/reviews"
},
"author": {
"href": "http://localhost:8080/api/v1/authors/5"
}
}
}
REST API는 위에서 설명한 6가지 제약 조건을 모두 준수하는 이론적으로 완벽한 API를 의미한다. 즉, 자원 식별, 상태 비저장(Stateless), 캐시 처리, 일관된 인터페이스(Uniform Interface), 계층화, 그리고 선택적인 Code on Demand, HATEOAS까지 모두 구현해야 한다.
반면에, RESTful API는 REST의 핵심 원칙(Stateless, URI를 통한 자원 식별, HTTP 메서드 활용 등)을 따르지만 실무적 제약 때문에 HATEOAS나 Code on Demand 같은 요소는 생략하는 경우가 많다. 따라서 대부분의 웹 서비스는 이론적인 REST API라기보다는 RESTful API라고 부르는 것이 더 적합하다.
@GetMapping("/books")
public List<Book> getBooks(@RequestParam int page, @RequestParam int size) {
// 모든 필요한 정보를 요청에 포함 (O)
return bookService.getBooks(page, size);
}
@GetMapping("/books/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
Book book = bookService.findById(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)))
.body(book);
}
https://api.example.com/v1/books
[프로토콜]://[도메인]/[API버전]/[리소스]
| 원칙 | 설명 | 좋은 예시 | 나쁜 예시 |
|---|---|---|---|
| 리소스는 명사(복수형) | URL은 동사가 아닌 자원(리소스)를 나타내야 하며, 복수형을 사용 | /api/v1/books | /api/v1/getBooks, /api/v1/book/123 |
| HTTP 메서드 활용 | 동작(조회/생성/수정/삭제)은 URL이 아닌 HTTP 메서드로 표현 | GET /api/v1/books/123 | GET /api/v1/books/delete/123 |
| 계층적 구조 | URL 경로는 리소스 간의 계층 관계를 반영 | /api/v1/books/123/reviews | /api/v1/reviews?bookId=123 (계층 구조 무시) |
| 쿼리 파라미터 활용 | 검색, 필터링, 정렬, 페이지네이션 등은 쿼리 파라미터 사용 | /api/v1/books?author=tolkien&page=2 | /api/v1/books/filterByAuthor/tolkien |
| 일관된 규칙 | 소문자 사용, 단어는 하이픈(-)으로 연결, 언더스코어(_) 지양 | /api/v1/user-profiles | /api/v1/userProfiles, /api/v1/user_profiles |
| 버전 관리 | URL 경로에 버전 명시 또는 HTTP Header 활용 | /api/v1/books Accept: application/vnd.example.v1+json | /api/books (버전 불명확) |
API 버저닝은 기존 API를 사용하는 클라이언트들의 호환성을 보장하면서 새로운 기능을 추가하거나 변경사항을 적용하는 방법이다. 카카오톡 앱이 업데이트되어도 구버전을 사용하는 사용자들이 앱이 작동하지 않는 것이 아니듯, API도 마찬가지로 기존 버전을 유지하면서 새 버전을 제공해야 한다.
API 버저닝이 필요한 상황
버저닝 방식
@RestController
@RequestMapping("/api/v1/books")
public class BookControllerV1 {
@GetMapping("/{id}")
public BookResponseV1 getBook(@PathVariable Long id) {
return bookService.getBookV1(id);
}
}
@RestController
@RequestMapping("/api/v2/books")
public class BookControllerV2 {
@GetMapping("/{id}")
public BookResponseV2 getBook(@PathVariable Long id) {
return bookService.getBookV2(id);
}
}
GET /api/v1/books/123 // 구버전
GET /api/v2/books/123 // 신버전
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public BookResponseV1 getBookV1(@PathVariable Long id) {
return bookService.getBookV1(id);
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public BookResponseV2 getBookV2(@PathVariable Long id) {
return bookService.getBookV2(id);
}
API 버저닝 시, 권장사항
API 버저닝은 사용자 호환성을 위해 구버전을 일정 기간 유지하되, 코드 복잡성을 줄이기 위해 실무에서는 가능한 한 빨리 Deprecated 처리 후 최신 버전으로 통합·업데이트하는 방식으로 관리한다. 이 과정에서 개발자에게 마이그레이션 가이드와 유예 기간을 제공하며, 최근에는 GraphQL이나 BFF 패턴, 혹은 하위 호환 가능한 변경을 통해 버저닝 자체를 최소화하려는 접근도 널리 쓰이고 있다.
GET 메서드는 서버로부터 데이터를 조회할 때 사용하는 메서드로, Request Body를 포함하지 않고 URL과 쿼리 파라미터만으로 요청을 전달한다.
이 메서드는 멱등성을 보장하기 때문에 같은 요청을 여러 번 보내더라도 항상 동일한 결과를 얻을 수 있으며, 서버의 상태를 변경하지 않는 안전한 메서드이기도 하다. 또한 응답 결과를 캐시할 수 있어 성능 최적화에 유리하다. 실무에서는 전체 목록 조회와 특정 항목 조회로 나누어 사용하며, 전체 조회 시에는 페이징, 필터링, 정렬 등의 쿼리 파라미터를 함께 사용하기도 한다.
@GetMapping("/books")
public ResponseEntity<List<Book>> getAllBooks(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<Book> bookPage = bookService.findBooks(category, PageRequest.of(page, size));
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(bookPage.getTotalElements()))
.body(bookPage.getContent());
}
@GetMapping("/books/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
return bookService.findById(id)
.map(book -> ResponseEntity.ok().body(book))
.orElse(ResponseEntity.notFound().build());
}
POST 메서드는 새로운 리소스를 생성할 때 사용하며, Request Body에 큰 용량의 데이터를 담아 전송할 수 있다.
이 메서드는 GET 요청과 달리 멱등하지 않기 때문에, 같은 POST 요청을 여러 번 보내면 매번 새로운 리소스가 생성된다. 예를 들어 같은 책 정보로 POST를 3번 보내면, 동일한 내용의 데이터가 3개 생성되는 것이다. 성공적으로 리소스가 생성되면 201 Created 상태 코드와 함께 생성된 리소스 정보를 응답으로 돌려주는 것이 일반적이며, 이를 통해 클라이언트는 방금 생성한 데이터의 ID나 기타 서버에서 생성한 정보를 확인할 수 있다.
@PostMapping("/books")
public ResponseEntity<Book> createBook(@Valid @RequestBody CreateBookRequest request) {
try {
Book savedBook = bookService.save(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedBook.getId())
.toUri();
return ResponseEntity.created(location).body(savedBook);
} catch (DuplicateIsbnException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
PUT 메서드는 기존 리소스를 완전히 새로운 데이터로 교체할 때 사용하는 메서드로, Request Body에 해당 리소스의 모든 필드 정보를 포함해야 한다. 만약 일부 필드를 생략하면 해당 필드는 null이나 기본값으로 설정될 수 있으므로 주의해야 한다.
PUT 메서드는 멱등성을 보장하므로, 같은 데이터로 여러 번 요청해도 결과는 동일하다. 또한 지정된 리소스가 존재하지 않는 경우 새로 생성할 수도 있는데, 이러한 경우엔 POST와 유사한 동작을 수행한다. 응답은 POST와 동일하게 수정된 결과 전체 또는 성공 여부를 반환하며, 실무에서는 사용자 프로필 전체 업데이트나 설정 정보 전체 변경과 같은 상황에 주로 사용한다.
@PutMapping("/books/{id}")
public ResponseEntity<Book> updateBook(
@PathVariable Long id,
@Valid @RequestBody UpdateBookRequest request) {
return bookService.update(id, request)
.map(book -> ResponseEntity.ok().body(book))
.orElse(ResponseEntity.notFound().build());
}
PATCH 메서드는 기존 리소스의 일부분만 수정할 때 사용하는 메서드로, PUT과 달리 Request Body에 변경하고자 하는 부분만 포함하면 된다.
예를 들어 게시글의 제목만 변경하고 싶다면 제목 필드만 Request Body에 포함하면 되고, 다른 필드들은 기존 값을 유지한다. PATCH 메서드도 멱등성을 보장하지만, 구현 방식에 따라 멱등하지 않을 수도 있으므로 설계 시 주의가 필요하다. 응답으로는 수정된 결과 전체를 반환하거나, 단순히 성공 여부만 반환할 수 있다. 실무에서는 사용자가 프로필의 특정 정보만 변경하거나, 게시물의 일부 내용만 수정하는 상황에서 주로 사용된다.
@PatchMapping("/books/{id}")
public ResponseEntity<Book> patchBook(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
return bookService.partialUpdate(id, updates)
.map(book -> ResponseEntity.ok().body(book))
.orElse(ResponseEntity.notFound().build());
}
// DTO를 사용한 더 안전한 방식
@PatchMapping("/books/{id}")
public ResponseEntity<Book> patchBook(
@PathVariable Long id,
@Valid @RequestBody PatchBookRequest request) {
return bookService.partialUpdate(id, request)
.map(book -> ResponseEntity.ok().body(book))
.orElse(ResponseEntity.notFound().build());
}
DELETE 메서드는 지정된 리소스를 삭제할 때 사용하며, 일반적으로 Request Body를 포함하지 않고 URL의 경로 변수를 통해 삭제할 리소스를 식별한다.
이 메서드는 멱등성을 보장하므로 이미 삭제된 소스에 대해 DELETE 요청을 다시 보내더라도 오류가 발생하지 않아야 한다. 성공적으로 삭제가 완료되면 204 No Content 상태 코드를 반환하여 삭제되었지만 응답 본문이 없음을 나타내거나, 200 OK와 함께 삭제된 리소스의 정보를 반환할 수도 있다. 실무에서는 물리적 삭제보다는 논리적 삭제를 구현하는 경우가 많으며, 이때는 삭제 플래그를 설정하거나 삭제 시간을 기록하는 방식을 사용한다.
@DeleteMapping("/books/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
if (bookService.existsById(id)) {
bookService.delete(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
// 논리적 삭제를 사용하는 경우
@DeleteMapping("/books/{id}")
public ResponseEntity<BookDeleteResponse> softDeleteBook(@PathVariable Long id) {
return bookService.softDelete(id)
.map(response -> ResponseEntity.ok().body(response))
.orElse(ResponseEntity.notFound().build());
}
200 OK: 성공적인 GET, PUT, PATCH 요청201 Created: 성공적인 POST 요청으로 새 리소스 생성204 No Content: 성공적인 DELETE 요청, 응답 본문 없음400 Bad Request : 잘못된 요청401 Unauthorized : 인증 실패403 Forbidden : 권한 없음404 Not Found : 리소스 없음409 Conflict : 충돌422 Unprocessable Entity : 유효하지만 처리 불가429 Too Many Requests : 요청 과다 (Rate Limit)500 Internal Server Error: 서버 내부 오류503 Service Unavailable: 서비스 이용 불가쿼리 파라미터는 URL 뒤에 ?를 붙이고 key=value 형태로 추가 정보를 전달하는 방식이다. 웹에서 클라이언트가 서버에게 추가적인 정보나 옵션을 전달할 때 사용하는 가장 일반적인 방법 중 하나이다.
https://example.com/api/books?category=fiction&page=1&size=10
↑
쿼리 시작
?: 쿼리 파라미터의 시작을 알리는 구분자&: 여러 파라미터를 구분하는 구분자key=value: 파라미터 이름과 값의 쌍필터링 (Filtering)
@GetMapping("/books")
public List<Book> getBooks(
@RequestParam(required = false) String category, // 카테고리 필터
@RequestParam(required = false) String author, // 작가 필터
@RequestParam(required = false) String keyword // 검색 키워드
) {
return bookService.findBooks(category, author, keyword);
}
GET /books?category=fiction&author=얄코
GET /books?keyword=스프링부트
GET /books?category=programming&keyword=java
페이징 (Pagination)
: 실무에서는 전체 데이터를 한 번에 반환하면 성능 문제가 발생할 수 있으므로 페이징을 사용한다.
@GetMapping("/books")
public Page<Book> getBooks(
@RequestParam(defaultValue = "0") int page, // 페이지 번호
@RequestParam(defaultValue = "10") int size // 페이지 크기
) {
Pageable pageable = PageRequest.of(page, size);
return bookService.findAll(pageable);
}
GET /books?page=0&size=20 // 첫 페이지, 20개씩
GET /books?page=2&size=5 // 3번째 페이지, 5개씩
정렬 (Sorting)
@GetMapping("/books")
public List<Book> getBooks(
@RequestParam(defaultValue = "title") String sortBy, // 정렬 기준
@RequestParam(defaultValue = "asc") String sortDir // 정렬 방향
) {
return bookService.findAllSorted(sortBy, sortDir);
}
GET /books?sortBy=publishedDate&sortDir=desc // 출간일 내림차순
GET /books?sortBy=title&sortDir=asc // 제목 오름차순
Path Variable (경로 변수): 특정 리소스를 식별할 때
@GetMapping("/books/{id}") // 특정 리소스 식별
public ResponseEntity<Book> getBook(@PathVariable Long id) {
return bookService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
Query Parameter (쿼리 파라미터): 리소스를 필터링, 정렬, 페이징할 때
@GetMapping("/books") // 리소스 필터링/옵션
public ResponseEntity<List<Book>> getBooks(@RequestParam String category) {
List<Book> books = bookService.findByCategory(category);
return ResponseEntity.ok(books);
}