119호. 실전 라라벨 3편 - 의도치 않았던 Redis 캐시 free

2020-04-02

라라벨에겐 redis 는 편리하고 다양한 기능을 제공해주기 때문에 거의 필수 툴로 자리매김 하고 있습니다.


크게 보면 다음의 기능들이겠죠.



  • session

  • queue worker

  • cache

  • database


보통 redis 하면 cache 기능으로 많이 사용합니다. laravel 에는 기본적으로 제공하는 cache driver 의 종류가 다음과 같습니다.



  • array : 1 request 안에서만 유용합니다.

  • file : 서버 인스턴스 안에서 파일 캐시되기 때문에 유용합니다. 파일 IO 리소스를 쓰겠죠.

  • apc : php-fpm 내의 프로세스 간의 공유 메모리 캐시입니다.

  • redis or memcached : 네트워크로 연결되는 캐싱기능을 제공합니다. 고가용성을 보장합니다.


그리고 당연하게 우리는 보통 고가용성을 위해 redis 를 기본 cache driver 로 사용합니다. redis 를 사용하면 cache 뿐만 아니라 session, queue worker 등으로 사용하기도 사용가능 하기 때문이죠.

그러나 당연히 되겠지 하는 기능들이 우리가 생각한 것과는 다르게 작동합니다. 그 다음의 예를 보죠.


redis cache 의 경우 우리는 다음과 같이 사용합니다.


`$data = Cache::store(<span class="colour" style="color:rgb(221, 17, 68)">'redis'</span>)->remember(<span class="colour" style="color:rgb(221, 17, 68)">'cache-key'</span>,  <span class="colour" style="color:rgb(0, 128, 128)">3600</span>,  <span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">()</span> </span>{
$data = <span class="colour" style="color:rgb(221, 17, 68)">'data'</span>;
sleep(<span class="colour" style="color:rgb(0, 128, 128)">10</span>);

<span class="colour" style="color:rgb(51, 51, 51)">**return**</span> $data;
});
`

위의 코드를 풀어쓰면 다음의 내용의 코드일 겁니다.


`$data = Cache::store(<span class="colour" style="color:rgb(221, 17, 68)">'redis'</span>)->get(<span class="colour" style="color:rgb(221, 17, 68)">'cache-key'</span>);
<span class="colour" style="color:rgb(51, 51, 51)">**if**</span> (<span class="colour" style="color:rgb(51, 51, 51)">**empty**</span>($data)) {
$data = <span class="colour" style="color:rgb(221, 17, 68)">'data'</span>;
sleep(<span class="colour" style="color:rgb(0, 128, 128)">10</span>);

Cache::store(<span class="colour" style="color:rgb(221, 17, 68)">'redis'</span>)->put(<span class="colour" style="color:rgb(221, 17, 68)">'cache-key'</span>, <span class="colour" style="color:rgb(0, 128, 128)">3600</span>, $data);
}
`

하나의 스팟성의 캐시로는 Cache::remember() 함수는 간단히 쓸수 있는 좋은 예입니다. 그럼 다음의 예를 봅시다.


`$users = \App\User::limit(<span class="colour" style="color:rgb(0, 128, 128)">10</span>)->latest()

$users = $users
->map(<span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">(\App\User $user)</span> </span>{
$response = Http::get(<span class="colour" style="color:rgb(221, 17, 68)">'[https://another.site.com/api/levels/](https://another.site.com/api/levels/)'</span> . $user->level);
$levelName = data_get($response, <span class="colour" style="color:rgb(221, 17, 68)">'data.level\_name'</span>, <span class="colour" style="color:rgb(221, 17, 68)">'none'</span>);

$user->setAttribute(<span class="colour" style="color:rgb(221, 17, 68)">'level\_name'</span>, $levelName);
});
`

row 의 수가 10개면 그런대로 돌릴수 있겠지만 만일 100, 1000개가 된다면 level_name 을 가져오기에 필요한 http(네트워크) 리소스는 row 수와 같아집니다. 게다가 해봤자 $user->level 별 이름일 뿐일텐데요.


그래서 캐시를 붙이는 건 좋은 전략일 수 있습니다. 그리고 기본 cache driver 인 redis 를 그대로 사용하겠습니다.


`$users = \App\User::limit(<span class="colour" style="color:rgb(0, 128, 128)">10</span>)->latest()

$users = $users
->map(<span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">(\App\User $user)</span> </span>{
$levelName = Cache::store(<span class="colour" style="color:rgb(221, 17, 68)">'redis'</span>)->remember(<span class="colour" style="color:rgb(221, 17, 68)">'level-name-'</span> . $user->level, <span class="colour" style="color:rgb(0, 128, 128)">3600</span>, <span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">()</span> </span>{
$response = Http::get(<span class="colour" style="color:rgb(221, 17, 68)">'[https://another.site.com/api/levels/](https://another.site.com/api/levels/)'</span> . $user->level);
$levelName = data_get($response, <span class="colour" style="color:rgb(221, 17, 68)">'data.level\_name'</span>, <span class="colour" style="color:rgb(221, 17, 68)">'none'</span>);
});

$user->setAttribute(<span class="colour" style="color:rgb(221, 17, 68)">'level\_name'</span>, $levelName);
});
`

cache 를 이용하여 $user->level 별로 데이터를 캐싱하는 전략입니다. 레벨의 수가 적으면 적을수록 cache 의 히트율이 높아서 좋은 효율을 낼 수 있습니다.


그런데 실제 위의 코드를 약 row 수를 천개 이상으로 해서 다수의 서버에서 동시에 리퀘스트를 받는 것으로 돌려보면 서비스의 지연이 생김을 직접 느끼실 수 있습니다. 왜 그럴까요?


정답을 말씀드리면 redis 를 호출 할때 드는 네트워크 비용을 고려치 않은 겁니다. 위의 코드를 row 수 천개 이상으로 100개 서버에서 돌리면, 100개의 서버가 동시에 호출을 받을때 100 * 1000 로 순식간에 십만개의 request 를 redis 에 보내는게 됩니다.


의도치 않는 ddos 를 redis 에 보낸 꼴이 되는 거죠. 이럴땐 꼭 redis 를 안써도 됩니다.


`$users = \App\User::limit(<span class="colour" style="color:rgb(0, 128, 128)">10</span>)->latest()

$users = $users
->map(<span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">(\App\User $user)</span> </span>{
$levelName = Cache::store(<span class="colour" style="color:rgb(221, 17, 68)">'file'</span>)->remember(<span class="colour" style="color:rgb(221, 17, 68)">'level-name-'</span> . $user->level, <span class="colour" style="color:rgb(0, 128, 128)">3600</span>, <span style="box-sizing: content-box; line-height: 18.2px;"><span class="colour" style="color:rgb(51, 51, 51)">**function**</span> <span style="box-sizing: content-box; line-height: 18.2px;">()</span> </span>{
$response = Http::get(<span class="colour" style="color:rgb(221, 17, 68)">'[https://another.site.com/api/levels/](https://another.site.com/api/levels/)'</span> . $user->level);
$levelName = data_get($response, <span class="colour" style="color:rgb(221, 17, 68)">'data.level\_name'</span>, <span class="colour" style="color:rgb(221, 17, 68)">'none'</span>);
});

$user->setAttribute(<span class="colour" style="color:rgb(221, 17, 68)">'level\_name'</span>, $levelName);
});
`

각 서버별로 file 캐시로 저장하면 서버별로 각기 저장하기 때문에 캐시 히트율이 떨어지고, file IO 비율은 높아질 겠지만, 실질적 네트워크 비용보다 싼 리소스를 사용하기에 서비스에 문제가 없습니다.


파일 캐시가 file IO 를 일으켜 좀이라도 문제가 된다면 싶으면 apcu 캐시를 쓰는 것도 좋은 방법입니다.

redis 캐시는 고가용성으로 하나의 데이타를 가져오는 스팟성 캐싱에는 유리합니다만, 루프등에 들어가는 캐싱에는 불리한 측면이 있습니다.


게다가 혹시나 함수안에 캐싱을 적용해서 루프로 돌려지게 되면, 나도 모르게 redis ddos 를 하게 되는 셈이 됩니다.

그래서 되도록이면 캐싱은 비지니스 로직에서 사용에 신중해야 합니다.

저의 경우 캐싱은 함수 안보다는 컨트롤러의 액션 메소드까지 내려와서 쓰게 합니다. 클래스안에서 코드가 잘 안보이는데서 사용할 경우 코드를 신중히 보지 않으면 캐싱의 존재 여부 자체를 모르는 케이스가 많으니까요.


캐싱사용에 언제나 유의 하시길 바랍니다. maybe cached with you.


글쓴이


윤영진

Modern PHP Developer, yupmin@gmail.com


윤영진

Modern PHP Developer, yupmin@gmail.com