117호. 실전 라라벨 2편 - 의도치 않았던 database sticky 모드 free

2020-01-17


라라벨의 경우 보통 MySQL 을 사용하게 되고, 트래픽이 늘어서 DB 부하가 늘 경우, 보통 read, write 로 DB를 나눠서 사용하게 됩니다.

DB replication 이란 기능을 사용하게 되면 1개의 마스터와 N개의 슬레이브 DB 군을 유지해서 각각 엔드포인트를 분리합니다. 마스터 DB는 write DB가 되고, 다수의 슬레이브 DB는 read DB 군(1개 이상)이 되게 됩니다. 대부분의 사이트들은 write 보단 read 쿼리가 많기 때문에 read DB 인스턴스를 늘리면 폭발적으로 증가하는 트래픽을 감당할 수 있게 됩니다.


라라벨은 물론 이런 관련 기능을 쉽게 제공하고 있습니다. /config/database.php 에 설정만 하면 쉽게 마이그레이션이 가능합니다.


https://laravel.com/docs/6.x/database#read-and-write-connections


그런데, 여기서 한가지 중요한 옵션이 있습니다. 바로 sticky 옵션입니다.

mysql replication 을 사용해 보시면 알겠지만, read, write DB 간의 미세한 오차가 존재합니다. master DB 에서 생성한 bin log 가 slave DB 들에 전달되고 반영되는 시간 차로 인해, 쿼리 문 잘못 구성해서 실행하면 오류가 생길 확율(?)이 있습니다.


다음의 구분을 봅시다. 유저를 생성하고 바로 가져오는 구문입니다.


$user = User::create($data);
$anotherMe = User::findoOrFail($user->id);

sticky: false 으로 옵션을 끄게 되면 첫째줄 create 쿼리는 write DB 에 실행되고, 두번째 select query 는 read DB 를 통해서 실행되어서 방금 생성한 유저 데이타를 가져오게 될 것입니다. 그리고 아까 말했던 약간의 시간차로 인해 아직 write DB에서 read DB 로 데이타가 전달되지 않을 수도 있어서 ModelNotFoundException(Illuminate\Database\Eloquent\ModelNotFoundException) 이 날수도 있고, 빠르게 전달 반영되었다면 안날 수도 있게 됩니다.


그래서 read, write DB를 분리한다면 sticky 옵션은 필수 옵션이 됩니다. 그러면 sticky 는 어떻게 동작할까요?


\vendor\laravel\framework\src\Illuminate\Database\Connection.php:935 line, laravel 6.x


    /**
* Get the current PDO connection used for reading.
*
* @return \PDO
*/
public function getReadPdo()
{
if ($this->transactions > 0) {
return $this->getPdo();
}

if ($this->recordsModified && $this->getConfig('sticky')) {
return $this->getPdo();
}

if ($this->readPdo instanceof Closure) {
return $this->readPdo = call_user_func($this->readPdo);
}

return $this->readPdo ?: $this->getPdo();
}

위의 구문을 보면 알겠지만, 트랜젝션일때 혹은 레코드가 수정되었고, sticky 모드 일때, $this-readPdo 가 존재하지 않으면 write pdo 인스턴스를 반환합니다.

(참고로 DB::getReadPdo() 요 함수를 이용하면 현재 내가 무슨 커넥션을 사용하고 있는가 체크가 됩니다.

)


그래서 $this->recordsModified && $this->getConfig('sticky') 을 참고 삼아 보자면,

sticky: true 으로 옵션을 켜게 되면 보통 처음부터 read DB 로 읽습니다. read DB 로 읽다가 write 쿼리를 마주하게 되면 그뒤로 오는 read 쿼리도 write DB 를 바라보게 됩니다.

그러므로 바로 위의 구문은 오류가 나지 않습니다. 물론 write 쿼리 뒤로는 모두 write DB 에 붙기 때문에 read, write 로 나눈게 무색하게 되겠죠.


그래서 sticky 를 활성화 시킨 뒤에도 혹여나 read DB 로 커넥션을 강제로 틀어도 되는 부분이 존재하면 이 부분을 체크해서 강제 해야 합니다. 강제 할때는 다음과 같이 connection 이라는 함수를 쓰면 됩니다. ::read, ::write 를 커넥션뒤에 붙여쓰면 각각 read, write 를 강제 할 수 있습니다.


User::connection('mysql::read')->firstOrFail();

::read, ::write 를 처리하는 로직은 다음과 같습니다.


\vendor\laravel\framework\src\Illuminate\Database\DatabaseManager.php:99 line, laravel 6.x


    /**
* Parse the connection into an array of the name and read / write type.
*
* @param string $name
* @return array
*/
protected function parseConnectionName($name)
{
$name = $name ?: $this->getDefaultConnection();

return Str::endsWith($name, ['::read', '::write'])
? explode('::', $name, 2) : [$name, null];
}

혹은 write DB 에서 read 쿼리를 먼저 돌리려면 Eloquent 모델의 경우 다음과 같은 onWriteConnection 함수도 가능합니다. firstOrCreate 함수때는 거의 필수 겠지요?


\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Model.php:448 line, laravel 6.x


    /**
* Begin querying the model on the write connection.
*
* @return \Illuminate\Database\Query\Builder
*/
public static function onWriteConnection()
{
return static::query()->useWritePdo();
}

이런 sticky 정책이 아까 언급했듯이 write 쿼리 뒤의 read 쿼리가 무조건 write DB 로 붙는 건 뭔가 다 옳아 보이진 않습니다.

그러면 read 와 write 가 섞인 로직은 어떻게 해결하는 게 좋을까요?



  1. write 쿼리는 최대한 후방에 배치합니다.

  2. 비동기 처리가 가능한 write 쿼리는 event-listener 로 queue-worker 로 뺍니다. write 쿼리를 다른 커넥션에서 처리하게 합니다.

  3. 정 어쩔수 없을때는 ::read, ::write 로 구분해줍니다.


쩝 database config 설정만 하면 모든게 OK 만 하면 되는게 아니군요. 조금이라도 도움이 되셨길 바랍니다.


윤영진

Modern PHP Developer, yupmin@gmail.com