Skip to main content
Summary

웹 화면 밖으로 확장되는 애플리케이션

지금까지 만든 게시판은 브라우저에서 HTML 화면을 보는 방식으로 동작했다. 하지만 실제 서비스는 한 가지 화면만 상대하지 않는다. 모바일 앱이 게시글 목록을 요청할 수 있고, React나 Vue로 만든 프론트엔드가 Laravel 서버에서 JSON 데이터를 받을 수 있다. 결제 서비스, 메일 발송 서비스, 지도 서비스처럼 외부 시스템과도 데이터를 주고받아야 한다.

이때 필요한 입구가 API다. API는 다른 프로그램이 우리 애플리케이션의 기능을 사용할 수 있도록 정해 둔 약속이다. 웹 화면이 사람을 위한 응답이라면, API는 프로그램을 위한 응답이다. 그래서 보통 HTML이 아니라 JSON을 반환한다.

예를 들어 게시글 목록 화면은 Blade 뷰를 반환했다.

return view('posts.index', [
    'posts' => $posts,
]);

API에서는 같은 데이터를 JSON으로 반환한다.

return response()->json([
    'data' => $posts,
]);

중요한 차이는 응답 형식이다. API는 한 번 공개되면 모바일 앱, 프론트엔드, 외부 파트너가 그 형식에 맞춰 개발한다. 그래서 처음부터 일관된 구조를 정하는 것이 좋다.

API 개발과 외부 서비스 연동AI 생성 이미지

API 라우트와 JSON 응답

Laravel의 웹 라우트는 주로 routes/web.php에 둔다. API 라우트는 보통 routes/api.php에 둔다. 최신 Laravel에서는 프로젝트 구성에 따라 php artisan install:api 명령으로 API 라우트와 Sanctum 설정을 준비할 수 있다. API 라우트는 기본적으로 상태를 저장하지 않는 stateless 요청으로 다루어지고, 일반적으로 /api 접두사가 붙는다.1

게시글 API 컨트롤러를 따로 만들면 HTML 화면용 컨트롤러와 JSON 응답용 컨트롤러의 책임이 섞이지 않는다.

php artisan make:controller Api/PostController

routes/api.php에 게시글 조회 API를 등록한다.

use AppHttpControllersApiPostController;
use IlluminateSupportFacadesRoute;

Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);

컨트롤러는 Blade 뷰 대신 JSON 응답을 반환한다.

namespace AppHttpControllersApi;

use AppHttpControllersController;
use AppModelsPost;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::latest()->paginate(10);

        return response()->json([
            'data' => $posts->items(),
            'meta' => [
                'current_page' => $posts->currentPage(),
                'last_page' => $posts->lastPage(),
            ],
        ]);
    }

    public function show(Post $post)
    {
        return response()->json([
            'data' => $post,
        ]);
    }
}

이 방식도 동작한다. 하지만 모델의 모든 컬럼이 그대로 노출될 수 있고, 목록과 상세 응답의 구조가 조금씩 달라지기 쉽다. API에서는 응답 형식 자체가 계약이므로 더 분명한 변환 계층이 필요하다.

Resource 클래스로 응답 형식 고정하기

Laravel의 API Resource는 Eloquent 모델을 JSON으로 바꿀 때 사용할 형식을 한곳에 모으는 클래스다. 모델은 데이터베이스와 연결되는 객체이고, Resource는 그 모델을 외부에 어떤 모양으로 보여 줄지 결정한다.2

php artisan make:resource PostResource

app/Http/Resources/PostResource.php를 다음처럼 작성한다.

namespace AppHttpResources;

use IlluminateHttpRequest;
use IlluminateHttpResourcesJsonJsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'body' => $this->body,
            'author' => [
                'id' => $this->user?->id,
                'name' => $this->user?->name,
            ],
            'created_at' => $this->created_at?->toISOString(),
        ];
    }
}

이제 API 컨트롤러는 Resource를 반환한다.

use AppHttpResourcesPostResource;
use AppModelsPost;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::with('user')
            ->latest()
            ->paginate(10);

        return PostResource::collection($posts);
    }

    public function show(Post $post)
    {
        $post->load('user');

        return new PostResource($post);
    }
}

페이지네이션 결과를 Resource 컬렉션으로 반환하면 Laravel은 data, links, meta 같은 정보를 함께 구성한다. 프론트엔드는 data에서 게시글 배열을 읽고, meta에서 현재 페이지와 마지막 페이지를 확인할 수 있다.

Resource의 장점은 숨겨야 할 컬럼을 실수로 내보내지 않는 데 있다. 예를 들어 게시글 모델에 내부 검토 상태, 관리자 메모, 신고 횟수 같은 컬럼이 추가되어도 Resource에 넣지 않으면 API 응답에는 포함되지 않는다.

Sanctum으로 API 인증하기

공개 게시글 목록은 누구나 볼 수 있어도, 글 작성이나 내 정보 조회는 인증이 필요하다. 웹 화면에서는 세션 기반 로그인을 사용했다. API에서는 사용 주체에 따라 인증 방식이 달라진다.

Laravel Sanctum은 두 가지 흐름을 지원한다. 첫째, 모바일 앱이나 외부 클라이언트처럼 서버 밖에서 접근하는 프로그램에는 개인 액세스 토큰을 발급해 Authorization: Bearer ... 헤더로 인증하게 할 수 있다. 둘째, 같은 서비스의 프론트엔드 SPA가 Laravel API를 호출할 때는 토큰을 직접 저장하지 않고 쿠키와 세션, CSRF 보호를 함께 사용할 수 있다.3

토큰 방식은 다음처럼 이해하면 쉽다.

  1. 사용자가 웹 화면에서 API 토큰을 발급한다.

  2. 클라이언트는 그 토큰을 안전하게 보관한다.

  3. API 요청마다 Authorization 헤더에 토큰을 담아 보낸다.

  4. Laravel은 auth:sanctum 미들웨어로 토큰을 확인한다.

사용자 모델에는 Sanctum 토큰 기능을 추가한다.

use LaravelSanctumHasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
}

토큰 발급은 로그인된 사용자에게만 허용하는 편이 안전하다.

Route::post('/tokens', function (Request $request) {
    $request->validate([
        'name' => ['required', 'string', 'max:100'],
    ]);

    $token = $request->user()->createToken(
        $request->input('name'),
        ['posts:create']
    );

    return response()->json([
        'token' => $token->plainTextToken,
    ], 201);
})->middleware('auth');

plainTextToken은 발급 직후에만 사용자에게 보여 주어야 한다. 데이터베이스에는 해시된 값이 저장되므로 나중에 원문 토큰을 다시 확인할 수 없다. 이는 비밀번호를 평문으로 저장하지 않는 것과 같은 방향의 안전장치다.

API 라우트는 auth:sanctum으로 보호한다.

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me', function (Request $request) {
        return $request->user();
    });

    Route::post('/posts', [PostController::class, 'store']);
});

토큰에는 abilities, 즉 권한 범위를 줄 수 있다. 예를 들어 posts:create 토큰은 글 작성에는 사용할 수 있지만 관리자 기능에는 사용할 수 없게 만들 수 있다. 인증은 사용자가 누구인지 확인하는 일이고, ability는 그 토큰이 어디까지 할 수 있는지 제한하는 일이다.

SPA 인증은 목적이 다르다. 같은 회사가 운영하는 프론트엔드와 Laravel API가 함께 동작한다면, 브라우저 쿠키 기반 세션 인증이 더 자연스러울 수 있다. 이 경우 프론트엔드는 먼저 /sanctum/csrf-cookie를 호출해 CSRF 쿠키를 받고, 이후 로그인 요청을 보낸다. 요청에는 쿠키가 함께 전송되어야 하므로 CORS와 credentials 설정도 맞아야 한다. 외부 앱에 줄 토큰 방식과, 내 서비스의 SPA가 사용할 쿠키 방식은 섞어 생각하지 않는 것이 좋다.

외부 API 호출을 서비스로 분리하기

API는 받기만 하는 것이 아니다. 우리 서버가 외부 API를 호출해야 할 때도 많다. 예를 들어 게시글을 저장한 뒤 외부 요약 서비스에 본문을 보내 자동 요약을 받을 수 있다.

외부 API 키는 코드에 직접 쓰지 않는다. .env와 config/services.php로 분리한다.

// config/services.php
return [
    'summary' => [
        'url' => env('SUMMARY_API_URL'),
        'token' => env('SUMMARY_API_TOKEN'),
    ],
];

Laravel의 HTTP 클라이언트는 JSON 요청, 헤더, Bearer 토큰, timeout, retry, 오류 확인을 간결하게 제공한다. 400번대나 500번대 응답은 기본적으로 자동 예외가 아니므로 failed(), throw(), successful() 같은 메서드로 실패를 명시적으로 다루어야 한다.4

namespace AppServices;

use IlluminateSupportFacadesHttp;
use IlluminateSupportFacadesLog;

class PostSummaryService
{
    public function summarize(string $body): ?string
    {
        try {
            $response = Http::acceptJson()
                ->withToken(config('services.summary.token'))
                ->timeout(5)
                ->retry(2, 200)
                ->post(config('services.summary.url') . '/summaries', [
                    'text' => $body,
                ])
                ->throw();

            return $response->json('summary');
        } catch (Throwable $e) {
            Log::warning('Summary API failed', [
                'message' => $e->getMessage(),
            ]);

            return null;
        }
    }
}

여기서 중요한 것은 실패를 정상 흐름의 일부로 보는 태도다. 외부 서비스는 느릴 수 있고, 일시적으로 장애가 날 수 있으며, 요청 제한에 걸릴 수 있다. 그래서 다음 기준을 세운다.

  • timeout()으로 오래 멈추지 않게 한다.

  • retry()로 짧은 일시 장애를 한두 번 다시 시도한다.

  • 실패 로그를 남긴다.

  • 외부 API 실패가 핵심 기능 전체를 망가뜨리지 않게 fallback을 둔다.

OAuth가 필요한 API도 있다. OAuth는 사용자의 비밀번호를 우리 서비스가 직접 받지 않고, 외부 서비스가 발급한 접근 권한으로 대신 API를 호출하는 방식이다. 흐름은 대체로 같다. 사용자를 외부 서비스의 승인 화면으로 보낸다. 승인 후 callback URL로 돌아온 code를 access token으로 교환한다. 토큰 만료 시간이 지나면 refresh token으로 새 access token을 받는다. 이 토큰들은 민감한 정보이므로 암호화 저장, 만료 처리, 권한 범위 제한을 함께 설계해야 한다.

간단한 HTTP 호출은 Laravel HTTP 클라이언트로 충분하다. 하지만 OAuth 갱신, 복잡한 서명, 재시도 정책, 요청 제한 대응이 필요하면 검증된 SDK나 Laravel 전용 패키지를 사용하는 편이 유지보수에 유리하다.

웹훅은 외부 서비스가 보내는 API 요청이다

외부 API 호출은 우리가 외부로 요청을 보내는 방식이다. 웹훅은 반대다. 외부 서비스가 우리 서버로 이벤트를 알려 준다. 예를 들어 결제가 완료되면 결제 서비스가 우리 애플리케이션의 /api/webhooks/payments로 POST 요청을 보낸다.

웹훅은 단순한 POST 요청처럼 보이지만 더 조심해야 한다. 인터넷에 공개된 URL이므로 아무나 비슷한 요청을 보낼 수 있다. 그래서 최소한 세 가지를 확인해야 한다.

첫째, 서명 검증이다. 외부 서비스와 우리 서버만 아는 secret으로 서명을 계산하고, 요청 헤더의 서명과 비교한다. 이때 JSON을 다시 인코딩한 값이 아니라 원본 요청 본문을 기준으로 계산해야 한다. 비교할 때는 단순한 === 대신 hash_equals()를 사용한다.

class VerifyPaymentWebhookSignature
{
    public function handle($request, Closure $next)
    {
        $timestamp = $request->header('X-Webhook-Timestamp');
        $signature = $request->header('X-Webhook-Signature');

        if (! $timestamp || ! $signature) {
            abort(403);
        }

        if (abs(now()->timestamp - (int) $timestamp) > 300) {
            abort(403);
        }

        $payload = $timestamp . '.' . $request->getContent();
        $expected = hash_hmac(
            'sha256',
            $payload,
            config('services.payments.webhook_secret')
        );

        if (! hash_equals($expected, $signature)) {
            abort(403);
        }

        return $next($request);
    }
}

실제 헤더 이름과 서명 계산 방식은 서비스마다 다르다. 결제사, 메일 서비스, Git 서비스의 문서를 반드시 확인해야 한다. 하지만 원칙은 같다. 원본 본문, timestamp, secret, 안전한 비교를 사용한다.5

둘째, 중복 처리다. 웹훅 제공자는 응답을 받지 못했거나 실패했다고 판단하면 같은 이벤트를 다시 보낼 수 있다. 그래서 이벤트 ID를 저장하고, 이미 처리한 이벤트면 다시 처리하지 않고 200 응답을 반환해야 한다.

Route::post('/webhooks/payments', [PaymentWebhookController::class, 'store'])
    ->middleware(VerifyPaymentWebhookSignature::class);
class PaymentWebhookController
{
    public function store(Request $request)
    {
        $eventId = $request->input('id');

        if (WebhookEvent::where('provider_event_id', $eventId)->exists()) {
            return response()->json(['ok' => true]);
        }

        $event = WebhookEvent::create([
            'provider' => 'payments',
            'provider_event_id' => $eventId,
            'payload' => $request->all(),
            'status' => 'pending',
        ]);

        ProcessPaymentWebhook::dispatch($event->id);

        return response()->json(['ok' => true]);
    }
}

provider_event_id에는 unique index를 두는 것이 좋다. 동시에 같은 웹훅이 들어와도 데이터베이스가 마지막 방어선이 된다.

셋째, 빠른 응답이다. 웹훅 요청 안에서 결제 반영, 메일 발송, 통계 업데이트를 모두 처리하면 응답이 늦어진다. 응답이 늦어지면 외부 서비스는 실패로 판단하고 재시도할 수 있다. 컨트롤러에서는 검증, 중복 확인, 저장, 작업 등록까지만 수행하고 바로 200을 반환하는 편이 안전하다. 실제 처리는 큐 작업에서 수행한다. 큐와 재시도 전략은 다음 장에서 더 자세히 다룬다.

웹훅을 직접 보내야 하는 경우도 있다. 우리 서비스의 이벤트를 다른 시스템에 알려야 한다면 서명, timeout, 재시도, 실패 기록이 필요하다. 이런 기능을 계속 직접 만들기보다, 서명과 큐 기반 전송, 재시도 정책을 제공하는 전용 패키지를 검토할 수 있다.6

API 개발 체크리스트

API는 컨트롤러에서 return response()->json()을 쓰는 것만으로 끝나지 않는다. 다음 기준을 갖추면 작은 프로젝트에서도 구조가 쉽게 무너지지 않는다.

  • API 라우트는 웹 화면 라우트와 분리한다.

  • 응답 형식은 Resource 클래스로 고정한다.

  • 인증이 필요한 API는 auth:sanctum으로 보호한다.

  • 외부 앱에는 Bearer 토큰을, 내 서비스의 SPA에는 쿠키 기반 세션 인증을 우선 검토한다.

  • 외부 API 키는 .env에 두고 config()로 읽는다.

  • 외부 요청에는 timeout, retry, 오류 로그를 둔다.

  • OAuth 토큰은 암호화 저장과 만료 갱신을 고려한다.

  • 웹훅은 서명 검증, 중복 처리, 빠른 200 응답을 기본으로 한다.

이제 애플리케이션은 브라우저 화면 안에만 머물지 않는다. 라우트는 외부 요청의 입구가 되고, Resource는 응답 계약이 되며, Sanctum은 인증 경계를 만든다. HTTP 클라이언트와 웹훅은 Laravel 애플리케이션을 다른 서비스와 연결한다. 연결이 늘어날수록 실패 가능성도 늘어난다. 그래서 API 개발의 핵심은 기능을 여는 것뿐 아니라, 안전하게 열고 실패를 감당할 수 있게 만드는 것이다.

참고

1Laravel Routing 문서

2Laravel Eloquent API Resources 문서

3Laravel Sanctum 문서

4Laravel HTTP Client 문서

5Building a Webhook Receiver in Laravel That Handles 10,000 Events an Hour Without Dropping One

6Spatie Laravel Webhook Server

책 목록

라라벨 학습서: 웹 기초부터 배포까지 배우는 Laravel 실전 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기