file_exists($path)를 호출할 때 $path가 Amazon S3 버킷을 가리키고 있다면, 이는 로컬 디스크를 확인하는 것이 아니라 네트워크 요청을 보내는 것입니다. 단 한 줄의 체크가 S3로의 왕복(round-trip) 요청이 되어, 매 요청마다 수십 밀리초의 지연을 추가할 수 있습니다.

PHP는 스트림 래퍼(stream wrappers)를 통해 원격 저장소를 숨깁니다. fopen(), file_exists(), is_dir(), unlink(), file_put_contents()와 같은 함수들은 이러한 래퍼를 통해 라우팅되며, 이 래퍼들이 호출을 S3 API 작업으로 변환합니다. 매핑은 다음과 같이 직관적입니다:

  • file_exists()HeadObject
  • is_dir()ListObjects
  • unlink()DeleteObject
  • file_put_contents()PutObject

각 호출은 해당 S3 API 요청과 동일한 지연 시간을 발생시킵니다. 스크립트가 수십 개의 파일을 확인하면, 매 확인마다 하나의 네트워크 홉(hop)이 발생하는 N+1 스타일의 패턴이 만들어집니다.

지금 이것이 중요한 이유

일반적인 개발 환경에서는 파일 시스템이 동일한 머신에 존재하므로 확인 작업이 거의 즉시 완료됩니다. 하지만 에셋이 S3에 있는 운영(production) 환경에서는 동일한 코드가 성능 급락(performance cliff)을 일으킵니다. AWS SDK는 일부 결과를 메모리에 캐싱하여 단일 PHP 프로세스 내에서의 반복적인 확인은 빠르게 보이게 합니다. 그러나 대부분의 PHP 배포 환경은 각 웹 요청마다 새로운 프로세스를 생성하므로 매번 캐시가 초기화됩니다. 그 결과, 매 요청마다 모든 개별 경로에 대해 전체 네트워크 왕복이 발생하게 됩니다.

한 WordPress 사이트는 홈페이지를 렌더링하는 데 10초가 걸린 적이 있습니다. 데이터베이스 쿼리는 빨랐지만, 페이지 콘텐츠를 구성하기 위해 73개의 개별 S3 호출이 트리거되었습니다. 각 호출은 콜드 요청(cold request)이었으며, 겉보기에는 무해해 보이는 file_exists()를 눈에 띄는 지연 시간으로 바꾸어 놓았습니다.

완화 전략

  • 요청 간 캐시 유지 – 프로세스 내 캐시를 Redis와 같은 공유 저장소로 이동합니다. 한 요청에서 특정 키가 S3에 존재함을 확인하면, 후속 요청은 S3에 다시 접속하는 대신 캐시된 결과를 읽습니다.
  • 로컬 우선 워크플로우 채택 – 파일을 로컬 디스크에 먼저 쓰고, 비동기적으로 S3에 동기화합니다. 메인 요청 경로는 로컬 파일 시스템만 건드리며, 네트워크 비용은 백그라운드 작업으로 전환됩니다.
  • 코드베이스 감사 – 원격 래퍼로 해석될 수 있는 파일 시스템 함수를 체계적으로 검색합니다. 루프 내부나 요청에 중요한 경로(request-critical paths) 내에 있는 함수들을 찾아냅니다.
  • APM 데이터 조사 – 데이터베이스 메트릭은 안정적인데 응답 시간이 급증한다면, 스토리지 SDK 타이밍을 자세히 살펴보십시오. SDK 지연 시간이 갑자기 증가한다면 숨겨진 S3 호출이 원인일 가능성이 높습니다.

핵심 요점은 다음과 같습니다. 마이크로초 단위의 로컬 체크처럼 보이는 함수가 수십 밀리초의 네트워크 지연을 숨기고 있을 수 있습니다. S3에서의 file_exists()를 외부 호출로 취급하고, 그 결과를 캐싱하며, 무거운 작업은 요청 경로 밖으로 옮기십시오. 그렇지 않으면 숨겨진 지연 시간이 보이지 않는 네트워크 요청을 통해 계속해서 사이트 속도를 늦출 것입니다.