104호. 컨텐트 네고시에이션을 깔끔하게 처리해보자. 라라벨 멀티포맷 리스폰스 객체 패키지. free

2019-12-30


안녕하세요. 즐거운 월요일입니다~ :)


오늘은 팀 맥도날드의 멀티포맷 리스폰스 객체 패키지를 소개할까 합니다. 스타 수는 그리 많지 않아서 소개하기 살짝 망설여지기도 하는데 그래도 검토해볼 만 한 거 같아요.


멀티포맷 리스폰스 객체 패키지는 컨텐트 네고시에이션을 깔끔하게 처리할 수 있게 도와주는 패키지입니다. 컨텐트 네고시에이션은 HTTP 요청의 Accept 헤더를 이용해서 특정 형식의 응답을 요청하는 것입니다. 일반적으로 HTTP 요청은 HTML을 응답하지만, CSV로 달라던가, JSON으로 달라던가 다른 형식을 요구할 수 있습니다. 물론 요구에 응하고 말고는 서버 마음이긴 하지요 :)


아래는 멀티포맷 리스폰스 객체 패키지 저장소의 예제를 가져와 봤습니다. 사용자 목록을 CSV로 달라는 요청에 대응하는 코드입니다.


class UserController
{
public function index(Request $request, CsvWriter $csvWriter)
{
// some shared logic...

$query = User::query()
->whereActive()
->whereStatus($request->query('status'));

// format check(s) and format specific logic...

if ($this->wantsCsv($request)) {

// return a CSV...

$query->each(function ($user) use ($csvWriter) {
$csvWriter->addRow($user->only(['name', 'email']));
});

return response()->download($csvWriter->file(), "Users.csv", [
'Content-type' => 'text/csv',
]);
}

// return a webpage...

$memberships = Membership::all();

return view('users.index', [
'memberships' => $memberships,
'users' => $this->query->paginate(),
]);
}
}

팀 맥도날드는 위와 같이 컨트롤러 내에서 컨텐트 네고시에이션을 처리하는 코드에서 다음과 같은 불편함을 느꼈다고 하네요.



  • 사용자가 HTML을 요청하는 경우 CsvWriter는 사용되지 않는다.

  • 지원하는 포맷을 더 늘리면 그만큼 사용하지 않을 의존성도 늘어나게 될 것이다.

  • 지원하는 포맷이 늘어날 수록 if 구문이 늘어날 것이다.

  • 응답별로 다른 응답에서는 사용하지 않을 로직이 포함될 수 있다.(위의 예에서는 $memberships를 HTML에서만 쓰고 CSV에서는 쓰지 않음)


자신의 패키지를 사용하면 컨트롤러를 깔끔하게 다음과 같이 정리할 수 있다고 합니다.


class UserController
{
public function index(Request $request, CsvWriter $csvWriter, )
{
$query = User::query()
->whereActive()
->whereStatus($request->query('status'));

return UserIndexResponse::make(['query' => $query]);
}
}

앞서 언급한 불편 포인트가 모두 제거 된 것 처럼 보이네요.


UserIndexResponse는 다음과 같이 생겼습니다.


use TiMacDonald\MultiFormat\Response;

class UserIndexResponse extends Response
{
public function toCsvResponse(CsvWriter $writer)
{
$this->query->each(function ($user) use ($writer) {
$writer->addRow($user->only(['name', 'email']));
});

return response()->download($writer->file(), "Users.csv", [
'Content-type' => 'text/csv',
]);
}

public function toHtmlResponse()
{
$memberships = Membership::all();

return view('users.index', [
'memberships' => $memberships,
'users' => $this->query->paginate(),
]);
}
}

위의 UserIndexResponse는 CSV와 HTML을 지원합니다. 다른 형식을 추가하고 싶으면 to{포맷}Response() 매서드를 추가해주면 됩니다. 예를 들어 mp3 포맷을 추가하고 싶으면 toMp3Response()를 추가합니다.


Accept 헤더에 따라 어떤 포맷으로 응답할 건지는 심포니의 MimeTypes 클래스와 라라벨의 Request::format()을 이용해서 자동으로 감지한다고 합니다.


단, 하나의 컨텐트 타입에 여러 확장자가 있는 경우, 첫번째 확장자를 자동으로 선택해서 응답하게 됩니다. 아래의 경우 mpga로 응답하게 되는데요.


'audio/mpeg' => ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'],

필요시 컨트롤러나 리스폰스 클래스에서 원하는 포맷으로 강제 지정할 수 있습니다.


// 컨트롤러에서
return UserIndexResponse::make(['query' => $query])
->withFormatOverrides([
'audio/mpeg' => 'mp3',
]);

// 혹은 리스폰스에서
class UserResponse extends Response
{
protected $formatOverrides = [
'audio/mpeg' => 'mp3',
];

// ...
}

멀티포맷 리스폰스 객체 패키지는 Accept 헤더 뿐만 아니라 확장자에 따른 응답도 지원합니다.


<h2>Downloads</h2>
<ul>
<li><a href="/users.csv">CSV</a></li>
<li><a href="/users.pdf">PDF</a></li>
</ul>

위와 같은 링크를 준비해두고 CSV를 클릭하면 /users에서 처리하되 CSV 응답을, PDF를 클릭해도 마찬가지로 /users에서 처리하되 PDF 응답을 줄 수 있는 것이지요. 이러한 패턴은 레딧 등에서 많이 쓰인다고 하네요.


파일 확장자에 따라 다른 응답을 주고 싶은 경우 라우트를 아래와 같이 작성해줍니다.


Route::get('users{extension?}', [
'as' => 'users.index',
'uses' => 'UserController@index',
// this is what we need to add...
'where' => [
'extension' => '^\.(pdf|csv|xlsx)$',
],
]);

라우트 파라미터 정규표현식 제약을 이러식으로 활용하네요. @.@


마치며


요청에 걸맞는 응답을 생성하는 코드를 컨트롤러에서 분리할 수 있고, 사용방법이 어렵지 않아서 괜찮은 패키지인것 같아요. 파일 확장자에 따라 다른 응답을 주는 것도 꽤 신박했습니다. ㅎㅎ


팀 맥도날드가 등장한 이전 뉴스 레터



활기찬 한 주 시작 되세요!


1일 1식 라라벨 104호

2019년 12월 30일


이현석

메쉬 코리아 개발자. 바쁜 팀장님 대신 알려주는 신입 PHP 개발자 안내서를 쓰고, 클린 아키텍처 인 PHP를 번역했습니다. 2020년에 출간될 Laravel Up & Running 2nd Edition을 번역하고 있습니다.