PDO Pool
임포트가 거의 다 되었습니다. 워커들은 GeoDirectory를 통해 주소를 확인합니다. 남은 것은 확인된 주소를 데이터베이스에 저장하는 일뿐입니다. 사소해 보입니다.
$pdo = new PDO('mysql:host=localhost;dbname=profiles', 'app', 'secret');
for ($i = 0; $i < 10; $i++) {
$workers->spawn(function () use ($queue, $pdo) {
foreach ($queue as $address) {
checkAddress($address);
saveAddress($pdo, $address);
}
});
}하나의 PDO 객체를 열 개의 코루틴이 공유합니다. 채널을 다룬 장에서 우리는 단일 스레드 안에서는 데이터 경쟁이 일어나지 않는다고 했으니, 이 정도는 괜찮겠죠?
아닙니다. 그것은 메모리에 대한 이야기입니다. 하지만 데이터베이스 연결은 메모리가 아니라, 소켓 위에 얹힌 프로토콜입니다. 요청, 응답, 요청, 응답, 엄격하게 순서대로 진행됩니다. 모든 데이터베이스 쿼리는 코루틴이 잠들고 제어가 다른 코루틴에게 넘어가는 대기 지점입니다. 그리고 그 다른 코루틴은 바로 그 같은 소켓에 자기 쿼리를 씁니다.
// Worker 1
$pdo->beginTransaction();
$pdo->exec("INSERT INTO addresses ...");
// 대기 지점: 워커 1이 잠들고, 워커 2가 깨어남
// Worker 2
$pdo->beginTransaction(); // 같은 연결에서!
$pdo->exec("UPDATE ...");
$pdo->commit(); // 자기 트랜잭션과 남의 트랜잭션을 함께 커밋함응답이 뒤섞이고, 트랜잭션이 서로의 작업을 커밋합니다. 경쟁 상태가 돌아왔는데, 다만 이번에는 메모리가 아니라 연결 안에 살고 있습니다.
좋습니다, 그러면 각 코루틴에게 자기만의 연결을 주면 되겠죠?
$workers->spawn(function () use ($queue) {
$pdo = new PDO(/* ... */); // 자기만의 연결
// ...
});워커가 열 개라면 괜찮습니다. 하지만 임포트 작업이 아니라 서버를 상상해 보세요. 요청마다 코루틴이 생성됩니다. 코루틴이 천 개면 TCP 연결이 천 개입니다. MySQL은 기본적으로 151개를, PostgreSQL은 100개를 허용합니다. 그리고 몇 밀리초짜리 작업을 위해 연결을 여는 것은 그저 비쌉니다. 데이터베이스와의 핸드셰이크가 쿼리 자체보다 더 오래 걸릴 수 있습니다.
낯익지 않나요? 채널을 다룬 장에서도 같은 갈림길이 있었습니다. GeoDirectory로의 십만 개 연결이냐, 아니면 열 개를 위한 큐냐.
풀: 거꾸로 된 큐
이 해법을 커넥션 풀이라고 부릅니다. N개의 연결을 미리 열어두고 코루틴에게 작업하는 동안 넘겨주는 것입니다. 마침 우리는 이미 그것을 만드는 법을 알고 있습니다. 풀은 연결을 담는 채널입니다.
$pool = new Channel(5);
for ($i = 0; $i < 5; $i++) {
$pool->send(new PDO(/* ... */));
}
// 코루틴 안에서:
$pdo = $pool->recv(); // 연결을 가져옴
saveAddress($pdo, $address);
$pool->send($pdo); // 반납함비어 있는 연결은 버퍼에 앉아 있습니다. 전부 사용 중이면 recv는 누군가가 연결을 반납할 때까지 코루틴을 재웁니다. 앞 장과 같은 동기화이며, 다만 큐가 작업 대신 리소스를 담을 뿐입니다.
이 방식은 동작하지만 약점이 있습니다. 예외든 취소든 무슨 일이 일어나더라도 연결을 반드시 반납해야 한다는 것을 잊지 않아야 합니다. 게다가 트랜잭션, 끊어진 연결, 재연결 문제도 있습니다. PDO의 경우 그 모든 것이 코어 안에서 이미 처리되어 있습니다.
PDO Pool
풀은 PDO 자체에 내장되어 있으며, 생성자 속성으로 켤 수 있습니다.
$pdo = new PDO('mysql:host=localhost;dbname=profiles', 'app', 'secret', [
PDO::ATTR_POOL_ENABLED => true,
PDO::ATTR_POOL_MIN => 2,
PDO::ATTR_POOL_MAX => 10,
]);바깥에서 보면 아무것도 바뀌지 않았습니다. 하나의 $pdo가 열 개의 워커에게 나가던 바로 그 첫 예제가 이제 올바르게 동작합니다. $pdo 객체는 더 이상 연결이 아니라 풀의 파사드입니다. 코루틴이 첫 쿼리를 실행하면 풀이 그것에게 전용 연결을 건네주고, 그 코루틴의 모든 쿼리가 그것을 통해 지나갑니다. 코루틴이 끝나면 연결은 풀로 돌아가 다음 코루틴을 위해 준비됩니다.
수동 recv와 send는 없습니다. 획득과 반납은, 상황이 어떻게 흘러가든, 적절한 순간에 저절로 일어납니다. 코드는 평범한 PDO를 쓰는 평범한 동기식 PHP처럼 보이며, 바로 그것이 처음부터 의도한 바였습니다.
트랜잭션
트랜잭션은 연결에 속하는 상태이므로 풀은 그것을 특별하게 취급합니다. 트랜잭션이 열려 있는 동안 연결은 자기 코루틴에 고정되어 풀로 돌아가지 않습니다.
$workers->spawn(function () use ($pdo, $queue) {
// ...
$pdo->beginTransaction();
$pdo->exec("INSERT INTO addresses (user_id, region) VALUES (...)");
$pdo->exec("UPDATE users SET address_checked = 1 WHERE id = ...");
$pdo->commit();
// 이제서야 연결이 풀로 돌아갈 수 있음
});코루틴이 commit을 호출하지 않고 끝나면 어떻게 될까요? Scope를 다룬 장을 떠올려 보세요. 워커가 트랜잭션 한가운데서 취소될 수 있는데, 그것은 재앙이 아니라 정상적인 시나리오입니다. 연결을 반납하기 전에 풀은 자동으로 ROLLBACK을 실행합니다. 끝나지 않은 트랜잭션이 다음 코루틴으로 새어 나가거나 데이터베이스에 남아 있는 일은 없습니다.
연결이 끊어질 때
임포트는 한 시간 동안 돌 수 있습니다. 한 시간 안에 데이터베이스가 재시작될 수도, 네트워크가 깜박일 수도, DBA가 세션을 죽일 수도 있습니다. 고전적인 PHP에서는 스크립트가 그냥 죽었겠지만, 오래 도는 애플리케이션은 계속 굴러갈 수 있어야 합니다.
풀은 연결이 반납될 때 검사합니다. 끊어진 연결은 다음 코루틴에게 건네지는 대신 파괴됩니다. 그리고 쿼리가 끊어진 연결 때문에 실패하면, 같은 $pdo에서 그냥 다시 시도하는 것만으로 충분합니다. 풀이 코루틴에게 새 연결을 건네줍니다.
try {
saveAddress($pdo, $address);
} catch (PDOException $e) {
saveAddress($pdo, $address); // 풀이 이미 새 연결로 교체함
}재연결 로직을 작성할 필요도, PDO 객체를 다시 만들 필요도 없습니다. 그저 쿼리를 재시도하면, 나머지는 전부 풀이 알아서 처리합니다.
결국 코드는 마치 모든 코루틴이 자기만의 데이터베이스 연결을 가진 것처럼 동작합니다. 실제로는 연결이 열 개뿐이고, 풀이 끊임없이 그것들을 이 손에서 저 손으로 넘기며, 트랜잭션을 지켜보고, 죽은 것을 버립니다. 하지만 그런 주방 일은 바깥에서 전혀 드러나지 않으며, 그것이 가장 큰 이점입니다. 우리는 그저 PDO를 쓰는 평범한 코드를 작성할 뿐입니다.
그런데 우리 워커들은 아직도 반쯤 눈이 먼 채로 일하고 있습니다. 주소를 확인하고 저장하지만, 얼마나 많은 주소가 잘못된 것으로 드러났는지, 어떤 것들이 그랬는지는 아무도 세지 않습니다. 코루틴에서 결과를 어떻게 되돌려 받아 편리하게 모을 수 있을까요? 그것이 다음 장에서 다룰 내용입니다.