객체 하이드레이션은 실제로 측정해 보기 전까지는 해결된 문제처럼 보입니다. 데이터베이스에서 행(row)을 가져와 배열을 객체로 매핑하고 다음 단계로 넘어가면 끝입니다. 우리 대부분은 이를 눈에 보이지 않고, 매력적이지 않으며, 충분히 빠를 것이라고 가정하는 '배관 작업(plumbing)' 정도로 취급합니다. 그러다 어느 날 배치 작업이나 큐 워커(queue worker)를 프로파일링하다 보면, 예상치 못한 상당량의 CPU 시간이 매퍼(mapper)에서 사라지고 있다는 사실을 깨닫게 됩니다. 이러한 깨달음이 바로 HydraType을 탄생시킨 계기가 되었습니다. HydraType은 하나의 고집스러운 규칙을 바탕으로 구축된 하이드레이터입니다. 바로 "클래스는 자신의 프로퍼티가 사용하지 않는 기능에 대해 비용을 지불해서는 안 된다"는 규칙입니다.

만약 어떤 필드가 변환이 전혀 필요 없는 일반 문자열이라면, 생성된 코드는 마치 사람이 직접 작성한 것처럼 보여야 합니다. 직접 할당(Direct assignment) 방식이어야 합니다. 루프도, 파이프라인도, 리플렉션도 없어야 합니다.

하이드레이션의 실제 비용

언뜻 보기에 ['name' => 'Alice', 'age' => 30]User 객체로 변환하는 것은 사소해 보입니다. 문제는 유연성을 원할 때 시작됩니다. 대부분의 범용 하이드레이터는 런타임에 클래스를 조사하기 위해 리플렉션(reflection)에 의존합니다. 메타데이터 맵을 구축하고, 타입 강제 변환(type coercion)을 협상하며, 중첩된 객체 그래프를 해결합니다. 또한 이들은 파이프라인을 선호합니다. 설령 그 시퀀스가 비어 있더라도, 모든 값은 뮤테이터(mutator), 어설션(assertion), 트랜스포머(transformer)의 시퀀스를 거쳐야 합니다. 루프 구조 그 자체만으로도 오버헤드가 발생합니다.

수십 개의 레코드를 가져올 때는 전혀 눈치채지 못할 것입니다. 하지만 현대적인 PHP 애플리케이션은 수천 개의 큐 메시지를 처리하거나, 거대한 CSV 세트를 가져오거나, Elasticsearch에서 깊은 결과 세트를 하이드레이션하는 작업을 일상적으로 수행합니다. 이러한 시나리오에서는 필드당 단 몇 마이크로초라도 소모하는 매퍼가 실질적인 병목 현상이 됩니다. 필드가 8개인 객체 1,000개라면, 그 미세한 차이는 체감할 수 있는 수준의 '세금'으로 불어납니다.

제로 오버헤드 철학

HydraType의 핵심 아이디어는 기능 격리(feature isolation)입니다. 타입 변환, 중첩된 객체 하이드레이션, 어설션 등이 모두 지원되지만, 그 중 어느 것도 필수 사항은 아닙니다. 만약 프로퍼티에 배열에서 객체로의 단순 복사 외에 아무것도 필요하지 않다면, 생성된 라이터(writer)에는 정확히 그 작업만 포함됩니다. 일반 스칼라 필드가 필요하지도 않은 검증기(validator) 뒤에서 줄을 서서 기다리게 만드는 공유 파이프라인은 존재하지 않습니다.

이는 생각보다 달성하기 어렵습니다. 많은 라이브러리가 코드베이스를 작고 균일하게 유지하기 위해 모든 필드에 대해 단일 실행 경로를 공유합니다. HydraType은 정반대의 접근 방식을 취합니다. 각 대상 DTO에 대해 고유한 PHP 클래스를 생성하며, 해당 클래스가 정확히 필요로 하는 작업만을 방출(emit)합니다. 복잡성은 모든 프로퍼티에 일괄적으로 부과되는 세금이 아니라, 필요할 때만 선택하는(opt-in) 방식이 됩니다.

속도가 구현되는 방식

HydraType을 일반적인 리플렉션 기반 도구나 다른 생성 방식과 차별화하는 네 가지 구체적인 선택이 있습니다.

1. 코드는 한 번만 생성하고, 영원히 실행한다

HydraType은 대상 클래스를 단 한 번만 조사한 다음, 그 형태에 딱 맞게 설계된 전용 PHP 클래스를 작성합니다. 만약 DTO에 public string $name, int $status, private DateTime $createdAt이 포함되어 있다면, 생성된 라이터는 이 모든 것을 미리 알고 있습니다. 런타임 중에 반복적인 리플렉션, 문자열 파싱, 지연된 메타데이터 구축은 발생하지 않습니다. OPCache가 생성된 파일을 컴파일하고 나면, 이 하이드레이터는 직접 작성한 PHP 코드와 구분이 불가능합니다.

2. 빈 파이프라인 제거

대부분의 하이드레이터는 런타임 파이프라인을 중심으로 작업을 구조화합니다. 뮤테이터, 검증기, 트랜스포머와 같은 콜러블(callable) 배열을 유지하며 모든 값을 반복(iterate)합니다. 설령 그 배열이 비어 있더라도 foreacharray_reduce는 여전히 실행됩니다. HydraType은 이를 완전히 제거합니다. 코드 생성 단계에서 프로퍼티가 실제로 무엇을 필요로 하는지 확인합니다. 작업 목록이 비어 있다면, $object->name = $data['name'];과 같은 단순 할당문을 생성합니다. 생성기가 이미 판단을 끝냈기 때문에 런타임에는 아무것도 반복하지 않습니다.

3. 클로저 스코핑을 이용한 리플렉션 쓰기 제거

프라이빗(private) 프로퍼티는 외부 매퍼들에게 고질적인 골칫거리입니다. 일반적인 해결책은 ReflectionProperty::setValue()를 사용하는 것이지만, 이 메서드는 직접 프로퍼티에 접근하는 것에 비해 상당한 페널티를 수반합니다. HydraType은 Closure::bind를 사용하여 이를 우회합니다. 생성된 라이터는 대상 클래스의 스코프를 가진 클로저를 포함하므로, 리플렉션 없이도 프라이빗 및 프로텍티드(protected) 프로퍼티에 직접 접근할 수 있습니다. 바인딩은 생성 시점에 한 번만 발생하며, 그 이후 클로저는 네이티브에 가까운 속도로 실행됩니다.

4. 라이터 재사용

일부 라이브러리는 하이드레이션하는 매 객체마다 새로운 전략이나 리플렉션 그래프를 인스턴스화합니다. HydraType은 라이터를 한 번 생성한 다음, 수천 개의 객체에 걸쳐 해당 인스턴스를 재사용합니다. 객체당 비용은 프로퍼티 값을 설정하고 결과를 반환하는 최소한의 수준으로 떨어집니다.

수치 데이터

PHP 8.2에서는 그 차이가 극명합니다:

  • HydraType: 267.9 ns
  • Ocramius GeneratedHydrator: 307.9 ns
  • Symfony PropertyNormalizer: 8,499.0 ns
  • Valinor: 10,755.2 ns

Symfony PropertyNormalizer와 Valinor는 강력한 도구이지만, 범용적인 성격을 띱니다. PropertyNormalizer는 포맷 감지, 노멀라이저(normalizers), 그리고 깊은 객체 그래프(deep object graphs)를 처리하는 시리얼라이저(serializer) 컴포넌트의 일부입니다. Valinor는 타입 트리(type trees)와 강제 형변환(coercion)에 대해 엄격합니다. 그러한 강력함에는 대가가 따릅니다. 이번 테스트에서 이들은 HydraType보다 대략 30~40배 정도 느렸습니다. 이미 코드 생성을 사용하는 Ocramius GeneratedHydrator조차도 파이프라인과 프로퍼티 접근 방식의 아키텍처 차이로 인해 약간 뒤처집니다.

맥락이 중요합니다. 단 한 번의 데이터베이스 쿼리나 HTTP 라운드트립(roundtrip)은 이 모든 수치를 무색하게 만듭니다. 쿼리 후 단 세 개의 객체를 하이드레이션(hydrating)하는 상황이라면, 나노초 단위의 성능을 쫓는 것은 에너지 낭비입니다. 하지만 규모가 커지면 그 격차는 의미를 갖게 됩니다. 5만 개의 레코드를 하이드레이션하는 배치 프로세스나, 캐시 배열로부터 객체를 재구성하는 큐 컨슈머(queue consumer)는 267나노초와 10마이크로초 사이의 차이를 체감하게 될 것입니다. 필드와 행 전체로 이 차이를 곱해보면, 더 빠른 매퍼(mapper)는 비즈니스 로직을 전혀 변경하지 않고도 작업 시간을 몇 초씩 단축할 수 있습니다.

핵심 요점

여기서 얻을 교훈은 모든 프로젝트에 커스텀 하이드레이터가 필요하다는 것이 아닙니다. 아키텍처 선택이 누적되어 영향을 미친다는 점입니다. HydraType은 요청한 내용만 포함하는 코드를 생성함으로써, 복잡한 프로퍼티를 위한 탈출구(escape hatch)를 제공하면서도 일반 프로퍼티(plain properties)의 속도를 빠르게 유지합니다. 타입 변환과 중첩된 객체는 필요할 때 사용할 수 있지만, 객체의 모든 스칼라 문자열(scalar string)에 대해 성능 검사를 함께 수행하지는 않습니다.

도구를 바꾸기 전에 프로파일링을 하십시오. 실제 실행 트레이스에서 하이드레이션이 눈에 띄지 않는다면, 고칠 것도 없습니다. 하지만 범용 매퍼를 통해 대규모 데이터셋을 처리하며 CPU 점유율이 치솟는 것을 보고 있다면, 일반적인 PHP 할당(assignment) 방식이 여전히 존재한다는 사실을 기억하십시오. 때로는 가장 빠른 코드가 단순히 여러분이 직접 작성했을 법한 코드, 즉 한 번 생성된 후 잊혀지는 코드일 수도 있습니다.