Laravel코루틴 안에서 할 수 없는 일

코루틴 안에서 할 수 없는 일 ​

앞선 장들은 구체적인 상태 누수를 고쳤습니다: auth/session은 컨텍스트를 통해, 트랜잭션 카운터는 트레이트를 통해. laravel-spawn이 이 구멍들을 대신 막아주었습니다. 하지만 프레임워크는 크고, 서드파티 패키지는 그보다 훨씬 더 많으며, 내일이면 팀의 누군가가 자신만의 서비스를 작성할 것입니다. 필요한 것은 한눈에 적용할 수 있는 규칙, 즉 병행 코루틴에 안전한 코드와 언젠가 한 사용자에게 다른 사람의 데이터를 넘겨줄 코드를 구별하는 규칙입니다.

하나의 규칙: 시작 이후에는 static에 쓰지 마라 ​

아래의 모든 예시는 같은 실수의 특수한 경우입니다: 프로세스의 모든 코루틴이 공유하는 메모리에 값이 기록되는데, 그 값은 특정 요청 하나에 속한 것입니다.

서비스의 가변 static 속성.

php
// 위험함
class PriceCalculator
{
    private static array $cache = [];

    public function forProduct(int $id): float
    {
        return self::$cache[$id] ??= $this->computeExpensive($id);
    }
}

무해한 메모이제이션처럼 보입니다. 실제로는 모든 코루틴이 공유하는 배열입니다. computeExpensive()가 요청마다 달라지는 무언가에 의존한다면(사용자의 통화, 지역별 마크업 등), 첫 번째 요청이 이후 모든 요청의 캐시 운명을 결정해버립니다. 해결책은 간단합니다: static 대신 인스턴스 속성을 쓰고, 서비스를 요청마다 리졸브하면 됩니다 (1장의 "접근법 1").

만약 서비스가 여러분 자신의 것이고, 요청별 상태가 의도적으로 필요하다면? laravel-spawn이 ScopedService와 그 프록시를 제공해줄 때까지 기다릴 필요는 없습니다. 이것은 2장에서 트랜잭션 카운터 문제를 해결한 것과 같은 방법이며, 한 단계 위로 올라간 것뿐입니다. 단일 코루틴을 위한 coroutine_context()가 아니라 요청 전체 코루틴 트리가 공유하는 request_context()를 씁니다.

php
class PriceCalculator  // 여전히 싱글턴
{
    private const CTX_CACHE = 'price.cache';

    public function forProduct(int $id): float
    {
        $cache = request_context()->find(self::CTX_CACHE) ?? [];

        return $cache[$id] ??= $this->computeExpensive($id, $cache);
    }
}

이전 예시와의 차이는 작아 보이지만 근본적입니다. 배열이 더 이상 프로세스 전체가 공유하는 클래스 단위의 static 속성에 살지 않고, 특정 요청 하나의 스코프 컨텍스트에 삽니다. 두 병행 요청이 정확히 같은 PriceCalculator 인스턴스를 호출하지만, 각각은 자신만의 $cache를 얻습니다. request_context()가 각각에 대해 다른 스코프로 리졸브되기 때문입니다. coroutine_context()와 request_context()에 대한 자세한 내용은 2장에 있습니다.

싱글턴 위의 once().

php
// 위험함
class CurrentUserService  // 싱글턴으로 등록됨
{
    public function get()
    {
        return once(fn() => Auth::user());  // 첫 번째 사용자를 영원히 캐시함
    }
}

once()는 클로저의 결과를 그 호출이 속한 객체를 키로 하는 WeakMap에 캐시합니다. 싱글턴에서는 그 객체가 프로세스 전체에 하나뿐이므로, 캐시도 하나뿐입니다. 첫 번째 요청이 사용자를 계산하면, 그 뒤의 모든 요청은 같은 사용자를 받게 됩니다. 요청별 객체(컨트롤러, Eloquent 모델)에서는 once()가 완전히 안전한데, 그곳에서는 객체 자체가 매 요청마다 새롭기 때문입니다.

Number::useLocale() 같은 전역 변이.

php
// 위험함
Number::useLocale('de');
$price = Number::format(1234.5);       // 이 줄 전에 코루틴이 잠들면 어떻게 될까?

// 안전함
$price = Number::format(1234.5, locale: 'de');

useLocale()은 Number 클래스의 static 변수를 변경합니다. useLocale() 호출과 format() 사이에는 await, delay(), 데이터베이스 왕복 등 어떤 일도 일어날 수 있습니다. 즉 스케줄러가 다른 코루틴에 제어를 넘기는 지점이 생길 수 있습니다. 만약 그 코루틴도 명시적인 locale 없이 Number::format()을 호출한다면, 방금 다른 누군가가 설정한 locale을 받게 됩니다. 명시적인 locale: 매개변수는 경쟁의 가능성을 완전히 제거합니다. 보호할 것이 아무것도 없기 때문입니다. 공유할 것 자체가 없으니까요.

슈퍼글로벌. $_GET, $_POST, $_SERVER, $_SESSION은 PHP-FPM 아래에서는 단지 하나의 요청 동안만 살아 있었기 때문에 안전했습니다. 코루틴 워커에서는 이들이 프로세스 전체가 공유하는 변수이며, 매 새 연결마다 PHP가 아니라 서버가 이를 업데이트합니다, 여러분이 익숙한 방식과는 다릅니다. laravel-spawn이 이미 current_context()를 통해 격리해 둔 Request 객체를 사용하고, 슈퍼글로벌은 건드리지 마세요.

static이 실제로 안전한 경우 ​

극단으로 치우쳐서 모든 static을 범죄로 선언하지 않는 것도 마찬가지로 중요합니다. 다음은 안전합니다.

  • readonly static, 값이 초기화 이후 절대 바뀌지 않는다면, 코루틴 사이에서 공유하는 것은 상수를 공유하는 것보다 더 위험하지 않습니다.
  • 부팅 시점 설정, 워커가 시작될 때 한 번 설정되고 그 이후로는 읽히기만 하는 static(라우트, 컴파일된 템플릿, 등록된 매크로).
  • 결정적 캐시, 결과가 "현재" 요청이 아니라 오직 입력 인자에만 의존한다면(가령 주어진 문자열에 대한 Str::camel() 캐시는 항상 같은 문자열입니다), 캐시를 채우는 경쟁이 일어나도 데이터가 손상되지 않습니다. 최악의 경우 두 번 계산될 뿐입니다.
  • 의미 없는 단조 카운터, 고유한 SQL 별칭을 위한 내부 카운터 같은 자동 증가 alias는, 두 요청이 같은 숫자를 잡더라도 충돌이 잘못된 데이터를 만들지 않습니다. 기껏해야 덜 예쁜 별칭이 될 뿐입니다.

차이는 항상 같습니다: 공유되는 것이 요청에 의존하지 않는 계산 결과인가, 아니면 특정 요청 하나에 속하는 상태인가? 전자는 최적화입니다. 후자는 누수입니다.

꼼꼼히 읽는 대신 정적 분석 ​

readonly가 없는 private static을 찾겠다고 모든 서드파티 패키지를 수동으로 스크롤하는 것은 기꺼이 린터에게 넘길 만한 작업입니다. 이 패키지는 바로 이 용도로 만들어진 PHPStan 규칙을 제공합니다.

php
final class MutableStaticPropertyRule implements Rule
{
    public function getNodeType(): string
    {
        return Property::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (! $node->isStatic() || $node->isReadonly()) {
            return [];
        }

        // ... "코루틴 간 잠재적 상태 누수" 메시지
    }
}

이 규칙은 최대한 단순합니다. readonly 수식어가 없는 모든 static 속성을 찾아 표시합니다. 위에서 언급한 "안전한" 경우들처럼 상당수의 오탐이 나올 것입니다. 하지만 이것은 의도적인 트레이드오프입니다. 실제 누수를 놓치는 것이 후보 목록을 한 번 수작업으로 정리하는 것보다 더 비쌉니다.

bash
phpstan analyse app/ --configuration=phpstan.neon
phpstan analyse vendor/some/package/src --configuration=phpstan.neon

이 규칙을 Laravel 프레임워크 자체에 돌려보면 300건이 넘는 결과가 나옵니다. 압도적 다수는 앞 절에서 다룬 안전한 static들입니다. 컴파일된 BladeCompiler 캐시, boot()에서 한 번 설정되는 설정 플래그, 이미 격리된 $app['request']에 내부적으로 접근하는 리졸버 등입니다. 300줄을 한 번 정리하는 데는 15분 정도 걸립니다. 하나라도 놓쳐서 프로덕션에서 누수를 겪는 것은, "다른 사용자의 프로필이 보여요"라는 버그 리포트를 몇 시간 동안 디버깅하는 것을 의미합니다.

결론 ​

요청 핸들러 안에서 실행되는 코드에 static(혹은 가변 속성을 가진 싱글턴)을 남기기 전에 한 가지 질문을 던지세요: 이 값이 현재 요청이 끝난 뒤에도 살아남고, 다음 요청에 대해서도 여전히 올바를까? 그렇다면 안전합니다. 만약 요청과 함께 만료되어야 하는데 물리적으로는 프로세스 전체가 공유하는 메모리에 계속 살아 있다면, 이것은 ScopedService, request_context()/coroutine_context()(2장 참고), 혹은 요청마다 다시 만들어지는 평범한 인스턴스 속성의 후보입니다.

지금까지 여러분 자신의 코드와 기본 Laravel을 다뤘습니다. 하지만 실제 프로젝트에는 보통 spatie/laravel-permission, Telescope, Inertia, Debugbar가 바로 옆에 함께 살고 있으며, 각각 자신만의 가변 상태의 역사를 가지고 있습니다. 그중 무엇이 이미 적응되어 있고, 비동기 모드에서 무엇을 꺼두는 것이 좋은지는 다음 장의 주제입니다.