RESTful 컨트롤러 만들기
이 장에서는 book 테이블을 적절한 RESTful API로 노출하기 위한 API 엔드포인트를 만듭니다. 거의 보일러플레이트 없이 CRUD 작업을 처리하기 위해 CodeIgniter\RESTful\ResourceController 를 사용합니다.
RESTful이란?
RESTful API는 표준 HTTP 동사 (GET, POST, PUT, DELETE) 를 사용해 URI로 식별되는 리소스에 작업을 수행합니다. 이 방식은 API를 예측 가능하고 사용하기 쉽게 만듭니다. REST API의 세부 기준에 대해서는 여러 논의가 있을 수 있지만, 이런 기본 원칙만 지켜도 많은 경우 충분합니다. 자동 라우팅과 ApiResponseTrait 를 사용하면 CodeIgniter로 RESTful 엔드포인트를 쉽게 만들 수 있습니다.
컨트롤러 생성
다음 Spark 명령을 실행합니다:
php spark make:controller Api/Books
이 명령은 app/Controllers/Api/Books.php 를 생성합니다.
파일을 열고 내용을 다음과 같은 기본 클래스 코드로 교체합니다:
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use CodeIgniter\Api\ResponseTrait;
class Books extends BaseController
{
use ResponseTrait;
/**
* List one or many resources
* GET /api/books
* and
* GET /api/books/{id}
*/
public function getIndex(?int $id = null)
{
}
/**
* Update a book
*
* PUT /api/books/{id}
*/
public function putIndex(int $id)
{
}
/**
* Create a new book
*
* POST /api/books
*/
public function postIndex()
{
}
/**
* Delete a book
*
* DELETE /api/books/{id}
*/
public function deleteIndex(int $id)
{
}
}
자동 라우팅을 사용하므로 URI 세그먼트 매핑과 충돌하지 않도록 index 메서드 이름을 사용해야 합니다. 하지만 HTTP 동사 접두사 (get, post, put, delete) 를 사용해 어떤 메서드가 어떤 동사를 처리하는지 나타낼 수 있습니다. 조금 특이한 점은 getIndex() 인데, 이는 모든 리소스 목록과 ID 기반 단일 리소스 조회에 모두 사용해야 합니다.
팁
다른 이름 규칙을 선호한다면 app/Config/Routes.php 에서 경로를 명시적으로 정의하고 자동 라우팅을 꺼야 합니다.
API 트랜스포머
데이터 모델과 API 응답에서의 표현 방식을 분리하는 것은 좋은 설계 관행으로 여겨집니다. 이는 데이터를 일관되게 형식화하는 트랜스포머나 리소스 클래스를 사용해 구현하는 경우가 많습니다. CodeIgniter는 이를 돕기 위해 API 트랜스포머를 제공합니다.
생성기 명령으로 트랜스포머를 생성합니다:
php spark make:transformer BookTransformer
트랜스포머에는 toArray() 메서드 하나가 필요하며, $resource 라는 혼합 데이터 타입을 인자로 받아야 합니다. 이 메서드는 리소스를 API 응답에 적합한 배열 형식으로 변환합니다. 반환된 배열은 이후 API 응답용 JSON 또는 XML로 인코딩됩니다.
app/Transformers/BookTransformer.php 의 Book 트랜스포머를 수정합니다. 이 예제는 관련 저자 데이터를 포함하므로 조금 더 복잡합니다:
<?php
namespace App\Transformers;
use CodeIgniter\API\BaseTransformer;
class BookTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'id' => $resource['id'],
'title' => $resource['title'],
'year' => $resource['year'],
];
}
protected function includeAuthor(array $book): ?array
{
if (empty($book['author_id']) || empty($book['author_name'])) {
return null;
}
return [
'id' => $book['author_id'],
'name' => $book['author_name'],
];
}
}
트랜스포머의 기능 중 하나는 관련 리소스를 조건부로 포함할 수 있다는 점입니다. 여기서는 응답에 포함하기 전에 책 리소스에 author 관계가 로드되어 있는지 확인합니다. 이를 통해 요청 맥락에 따라 반환되는 데이터 양을 유연하게 조절할 수 있습니다. API를 호출하는 클라이언트는 /api/books?include=author 같은 쿼리 매개변수로 관련 데이터를 명시적으로 요청해야 합니다. 메서드 이름은 include 로 시작하고, 그 뒤에 첫 글자를 대문자로 한 관련 리소스 이름이 와야 합니다.
AuthorTransformer를 사용하지 않은 점을 눈치챘을 수도 있습니다. 저자 데이터는 단순해서 추가 변환 없이 바로 반환할 수 있기 때문입니다. 하지만 더 복잡한 관련 리소스라면 별도의 트랜스포머를 만드는 편이 좋을 수 있습니다. 또한 나중에 N+1 쿼리 문제가 생기지 않도록, 저자 정보는 조회 시점에 함께 가져오게 됩니다.
책 목록 조회
$id 매개변수를 선택 사항으로 만들어, 같은 메서드가 모든 책 목록 조회와 ID로 단일 책 조회를 모두 처리할 수 있게 했습니다. 이제 이를 구현해 봅시다.
* List one or many resources
* GET /api/books
* and
* GET /api/books/{id}
*/
public function getIndex(?int $id = null): ResponseInterface
{
$model = model('BookModel');
$transformer = new BookTransformer();
// If an ID is provided, fetch a single record
if ($id !== null) {
$book = $model->withAuthorInfo()->find($id);
if (! $book) {
return $this->failNotFound('Book not found');
}
return $this->respond($transformer->transform($book));
}
// Otherwise, fetch all records
$books = $model->withAuthorInfo();
return $this->paginate($books, 20, transformWith: BookTransformer::class);
}
이 메서드에서는 $id 가 제공되었는지 확인합니다. 제공되었다면 해당 책을 찾습니다. 그 ID로 책을 찾지 못하면 ResponseTrait 의 failNotFound() 헬퍼를 사용해 404 Not Found 응답을 반환합니다. 책을 찾으면 BookTransformer를 사용해 형식화된 응답을 반환합니다.
$id 가 제공되지 않으면 모델을 사용해 모든 책을 조회하되, 실제 레코드 로드는 나중으로 미룹니다. 이렇게 하면 ResponseTrait 의 paginate 메서드를 사용해 자동으로 페이지네이션을 처리할 수 있습니다. 페이지네이션된 결과 집합의 각 책을 형식화할 수 있도록, 트랜스포머 이름을 paginate 메서드에 전달합니다.
이 두 경우 모두 모델의 withAuthorInfo() 라는 새 메서드를 사용합니다. 이 메서드는 책을 조회할 때 관련 저자 데이터를 조인하기 위해 모델에 추가할 사용자 정의 메서드입니다.
모델 도우미 메서드 추가
BookModel에 withAuthorInfo() 라는 새 메서드를 추가합니다. 이 메서드는 Query Builder를 사용해 author 테이블을 조인하고 관련 저자 필드를 선택합니다. 이렇게 하면 책을 조회할 때마다 별도의 쿼리를 보내지 않고도 연결된 저자 정보를 함께 가져올 수 있습니다.
<?php
namespace App\Models;
use CodeIgniter\Model;
class BookModel extends Model
{
public function withAuthorInfo()
{
return $this
->select('books.*, authors.name as author_name')
->join('authors', 'books.author_id = authors.id');
}
}
목록 엔드포인트 테스트
로컬 서버를 시작합니다:
php spark serve
이제 다음 주소로 접속합니다:
브라우저:
http://localhost:8080/api/bookscURL:
curl http://localhost:8080/api/books
책 목록이 JSON 형식의 페이지네이션 결과로 표시될 것입니다:
{
"data": [
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"year": 1965,
"created_at": "2025-11-08 00:00:00",
"updated_at": "2025-11-08 00:00:00"
},
{
"id": 2,
"title": "Neuromancer",
"author": "William Gibson",
"year": 1984,
"created_at": "2025-11-08 00:00:00",
"updated_at": "2025-11-08 00:00:00"
}
],
"meta": {
"page": 1,
"perPage": 20,
"total": 2,
"totalPages": 1
},
"links": {
"self": "http://localhost:8080/api/books?page=1",
"first": "http://localhost:8080/api/books?page=1",
"last": "http://localhost:8080/api/books?page=1",
"prev": null,
"next": null
}
}
시더에서 가져온 JSON 데이터가 보이면, 축하합니다 — API가 실행 중입니다!
나머지 메서드 구현
나머지 메서드를 포함하도록 app/Controllers/Api/Books.php 를 수정합니다:
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Transformers\BookTransformer;
use CodeIgniter\Api\ResponseTrait;
use CodeIgniter\HTTP\ResponseInterface;
class Books extends BaseController
{
use ResponseTrait;
/**
* List one or many resources
* GET /api/books
* and
* GET /api/books/{id}
*/
public function getIndex(?int $id = null): ResponseInterface
{
$model = model('BookModel');
$transformer = new BookTransformer();
// If an ID is provided, fetch a single record
if ($id !== null) {
$book = $model->withAuthorInfo()->find($id);
if (! $book) {
return $this->failNotFound('Book not found');
}
return $this->respond($transformer->transform($book));
}
// Otherwise, fetch all records
$books = $model->withAuthorInfo();
return $this->paginate($books, 20, transformWith: BookTransformer::class);
}
/**
* Update a book
*
* PUT /api/books/{id}
*/
public function putIndex(int $id): ResponseInterface
{
$data = $this->request->getRawInput();
$rules = [
'title' => 'required|string|max_length[255]',
'author_id' => 'required|integer|is_not_unique[authors.id]',
'year' => 'required|integer|greater_than_equal_to[2000]|less_than_equal_to[' . date('Y') . ']',
];
if (! $this->validate($rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$model = model('BookModel');
if (! $model->find($id)) {
return $this->failNotFound('Book not found');
}
$model->update($id, $data);
$updatedBook = $model->withAuthorInfo()->find($id);
return $this->respond((new BookTransformer())->transform($updatedBook));
}
/**
* Create a new book
*
* POST /api/books
*/
public function postIndex(): ResponseInterface
{
$data = $this->request->getPost();
$rules = [
'title' => 'required|string|max_length[255]',
'author_id' => 'required|integer|is_not_unique[authors.id]',
'year' => 'required|integer|greater_than_equal_to[2000]|less_than_equal_to[' . date('Y') . ']',
];
if (! $this->validate($rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$model = model('BookModel');
$model->insert($data);
$newBook = $model->withAuthorInfo()->find($model->insertID());
return $this->respondCreated((new BookTransformer())->transform($newBook));
}
/**
* Delete a book
*
* DELETE /api/books/{id}
*/
public function deleteIndex(int $id): ResponseInterface
{
$model = model('BookModel');
if (! $model->find($id)) {
return $this->failNotFound('Book not found');
}
$model->delete($id);
return $this->respondDeleted(['id' => $id]);
}
}
각 메서드는 ResponseTrait 의 도우미를 사용해 올바른 HTTP 상태 코드와 JSON 페이로드를 전송합니다.
이것으로 끝입니다! 이제 올바른 HTTP 메서드, 상태 코드, 데이터 변환을 갖춘 책 관리용 RESTful API가 완성되었습니다. 필요에 따라 인증, 유효성 검사, 기타 기능을 추가해 더 확장할 수 있습니다.
더 의미 있는 이름 규칙
이전 예제에서는 작업을 HTTP 동사만으로 판단하고자 했기 때문에 getIndex(), putIndex() 같은 메서드 이름을 사용했습니다. 자동 라우팅이 활성화된 상태에서는 URI 세그먼트와 충돌하지 않도록 index 메서드 이름을 사용해야 합니다. 하지만 더 의미 있는 메서드 이름을 선호한다면, getList(), postCreate(), putUpdate(), deleteDelete() 처럼 수행하는 작업을 드러내도록 이름을 바꿀 수 있습니다. 이렇게 하면 각 메서드의 목적이 한눈에 더 잘 보이고, URI에 새 세그먼트 하나가 추가됩니다.
GET /api/books/list -> getList()
POST /api/books/create -> postCreate()
PUT /api/books/update/(:id) -> putUpdate($id)
DELETE /api/books/delete/(:id) -> deleteDelete($id)