엔지니어링 블로그 - 프록시: 초고속 검색 및 바꾸기 규칙

저희의 첫 번째 엔지니어링 블로그에 오신 것을 환영합니다. 다른 블로그 글들보다 다소 기술적인 내용이 많을 수 있지만, 누구나 이해하기 쉽도록 최선을 다해 작성했습니다. 이번 글에서는 Clonable 내에서 대체 규칙을 적용하는 데 사용되는 새로운 소프트웨어인 Prexy에 대해 이야기해 보겠습니다.

배경

Clonable 사용자라면 이미 치환 규칙 기능에 익숙하실 수도 있습니다. 이러한 검색 및 치환 규칙을 통해 텍스트나 코드의 일부를 직접 선택한 변형으로 바꿀 수 있습니다. 예를 들어, 이 기능을 활용해 API 키나 분석 ID를 치환함으로써 원본 사이트와 번역된 클론 사이트에 대해 서로 다른 분석을 수행할 수 있습니다. 내부적으로는 이러한 규칙이 원본 도메인 이름을 클론의 도메인 이름으로 치환하는 등 다양한 용도로도 사용됩니다.

Clonable의 첫 번째 버전부터 이 기능은 한 번도 업데이트된 적이 없었는데, 그 자체만으로는 놀라운 일이 아닙니다. 왜냐하면 이 기능은 제 역할을 충분히 잘 수행해 왔기 때문입니다. 그럼에도 성능과 사용성 측면 모두에서 개선의 여지는 있었습니다. 가끔씩 저희 Clonable 팀은 오랫동안 손대지 않았던 제품 기능을 골라 개선을 시작하곤 합니다. 예를 들어, 작년에는 데이터 인프라 전체를 대대적으로 개편하여 더 빠르고, 확장성이 뛰어나며, 내결함성이 높은 시스템을 구축했고, 그 이전에는 URL 변환 기능도 처음부터 다시 구축했습니다. 이번 분기에는 대체 규칙을 개선할 차례였습니다.

기존 상황 및 한계

Clonable 에서는 클라이언트에 응답을 반환하기 직전, 가장 마지막 단계에서만 대체 규칙이 적용됩니다. 따라서 Clonable의 첫 번째 버전에서는 OpenResty에서 개발한 replace-filter-nginx-module을 사용하여 NGINX 웹 서버 내에서 이를 구현하기로 결정했습니다. 이 모듈은 OpenResty에서 개발한 스트리밍 정규식 엔진인 sregex를 사용합니다. 스트리밍 기능이 중요한 이유는 각 응답을 메모리에 완전히 로드하지 않으려는 데 있습니다. 대용량 응답이 여러 개일 때 메모리 부족으로 NGINX가 중단되거나 충돌할 수 있기 때문입니다. sregex의 두 번째 중요한 특징은 여러 줄을 병렬로 처리할 수 있다는 점입니다. 이는 각 클론에 이미 몇 가지 표준 규칙이 포함되어 있을 뿐만 아니라, 때로는 해당 클론에 특화되어 추가된 규칙도 있기 때문입니다. 만약 이러한 규칙들을 병렬로 처리할 수 없다면, 첫 번째 규칙 적용 후 두 번째 규칙을 위해 다시 일치 검사를 수행해야 하므로 여전히 전체 응답을 버퍼링해야 할 것입니다.

하지만 작년에 들어서는 점점 더 이상한 성능 문제가 발생하기 시작했습니다. 이러한 문제는 대개 잠시만 지속되었지만, 극단적인 경우에는 페이지 응답 시간을 몇 초나 늘리기도 했습니다. 이러한 성능 급등 현상이 발생한 후에도 페이지는 대개 다시 정상적으로 로드되었기 때문에 문제를 재현하기가 어려웠습니다. 이 문제를 더 자세히 디버깅하기 위해 모든 응답에 Clonable-Timings 헤더를 추가했고, 이를 통해 번역 프로세스의 어느 단계에서 시간이 많이 소요되는지 대략 파악할 수 있었습니다. 그 결과, 거의 모든 경우에서 업스트림의 응답은 빠르고 번역 과정도 상당히 원활하게 진행되는 것으로 나타났습니다. 하지만 번역이 완료된 시점과 요청이 완전히 처리되는 시점 사이에 시간 차이가 있었는데, 이는 대체 규칙과 관련이 있을 수 있다는 단서를 제공했습니다.

NGINX의 동시 실행 모델

하지만 이로써 왜 이러한 지연 현상이 산발적으로만 발생했는지, 그리고 서버 부하가 용량의 절반도 채 되지 않았을 때 왜 이런 일이 일어났는지에 대한 해답은 아직 얻지 못했습니다. 이 현상을 더 잘 이해하려면 NGINX가 워크로드를 어떻게 처리하는지 좀 더 깊이 살펴볼 필요가 있습니다.

NGINX는 하나의 마스터 프로세스와 여러 개의 워커 프로세스로 구성된 워커 아키텍처를 채택하고 있습니다. 연결은 워커들 사이에 분산되어 여러 요청을 동시에 처리합니다. 하지만 이러한 구성에는 단점도 있습니다. 한 워커가 요청 처리에 바쁠 때, 같은 워커에 할당된 다른 요청들은 대기해야만 합니다. 이로 인해 다른 워커들이 할 일이 없더라도, 단 하나의 부하가 큰 요청으로 인해 여러 요청에 지연이 발생할 수 있습니다. 이러한 현상은 아래 이미지에서 잘 확인할 수 있습니다. 이 이미지를 생성하는 동안 10개의 서로 다른 연결에 걸쳐 부하 테스트가 진행 중이었습니다. 이미지에서는 3개의 워커가 바쁘게 작업 중인 반면, 네 번째 워커는 거의 아무것도 하지 않고 있는 것이 명확하게 드러납니다.

Prexy를 사용할 시간

병목 현상이 명확히 드러나자, 이를 개선하기 위한 계획을 수립했습니다. 이 프로젝트의 이름은 RegEx와 Proxy를 합친 ‘Prexy’로 정해졌습니다. 최종 결과물에 대한 주요 요구 사항은 다음과 같이 3가지였습니다:

  1. 이는 현재 설정과 완벽하게 호환되는 대체 솔루션이 될 것입니다. 기존 설정에서 문제가 발생하지 않도록 대체 규칙을 동일한 방식으로 적용해야 합니다.

  2. 솔루션이 메모리를 과도하게 사용하지 않도록 하려면 대체 규칙을 스트리밍 방식으로 처리해야 합니다.

  3. 새로운 솔루션은 현재 솔루션보다 더 빨라야 합니다.

먼저, 요청을 효율적으로 처리할 수 있는 강력한 멀티스레드 런타임을 찾아야 했습니다. 여러 옵션을 검토한 끝에, Rust 프로그래밍 언어용 Tokyo 런타임을 선택하게 되었습니다. Rust는 C++과 같은 다른 저수준 언어의 안전성 위험 없이도 빠른 프로그램을 작성할 수 있는 것으로 알려져 있습니다. Tokyo 런타임은 네트워크 애플리케이션을 위해 만들어진 유연한 비동기 런타임입니다. 저희에게 가장 중요한 기능 중 하나는 ‘워크 스틸링(work-stealing)’ 기능입니다. 이는 NGINX와 달리 요청이 하나의 워커에만 고정되지 않고, 할 일이 없는 워커가 다른 워커의 작업을 ‘빼앗아’ 처리할 수 있음을 의미합니다. 이를 통해 서버 자원을 더 효율적으로 활용할 수 있습니다.

첫 번째 프로토타입

기반 기술을 선정한 후, 우리는 이 프로젝트가 대략적으로 얼마나 많은 속도 향상을 가져올지, 그리고 과연 그만한 가치가 있는지 파악하기 위해 초기 프로토타입을 구축하기로 결정했습니다. 정규식 엔진(실제로 규칙을 처리하고 적용하는 부분)의 첫 번째 구현체로, 표준 정규식 라이브러리(Rust 용어로는 '크레이트'라고 함)를 사용했습니다. 이 엔진은 sregex와 마찬가지로 비백트래킹 방식이므로 ReDoS 문제가 발생할 위험이 적습니다. ReDoS란 정규 표현식이 특정 입력에 직면했을 때 해당 입력을 평가하는 데 걸리는 시간이 기하급수적으로 증가하는 현상을 말합니다. 이는 성능에 치명적인 영향을 미칠 수 있으며 실제로 클라우드플레어(Cloudflare)가 대규모 서비스 중단을 겪은 원인이 되기도 했습니다. 따라서 저희에게는 사용하는 엔진이 비백트래킹 방식인 것이 매우 중요합니다.

이 정규식 엔진을 사용하여 초기 프로토타입을 구축했습니다. 이 프로토타입은 하드코딩된 규칙을 사용했으며, 정규식 엔진은 응답을 스트리밍하는 대신 전체를 버퍼링했지만, 초기 성능 테스트를 진행하기에는 충분했습니다.

이 정규식 엔진을 사용하여 초기 프로토타입을 구축했습니다. 이 프로토타입은 하드코딩된 규칙을 사용했으며, 정규식 엔진은 응답을 스트리밍하는 대신 전체를 버퍼링했지만, 초기 성능 테스트에는 이 정도면 충분했습니다. 테스트 환경은 두 대의 가상 머신(VM)으로 구성되었으며, 그중 한 대는 기존 솔루션과 새로운 솔루션 모두를 실행할 수 있는 환경을 갖추고 있었습니다. 이를 통해 두 방법을 쉽게 비교할 수 있었습니다. 다른 서버에서는 NGINX를 실행하여 오리진 서버 역할을 하고 테스트 파일을 제공했습니다. 또한 같은 서버에서 부하를 생성하는 도구인 wrk도 함께 실행되었습니다. 순수한 데이터 테스트의 경우 부하 생성기를 별도의 VM에서 실행하는 것이 더 이상적이지만, 이 VM에는 NGINX와 wrk가 서로 간섭하지 않을 만큼 충분한 리소스가 있었습니다.

첫 번째 테스트 결과는 명백했습니다. Prexy는 단일 요청을 처리하는 데 약 22배 더 빨랐습니다(아래 이미지 참조). 이러한 큰 성능 차이로 인해 기능을 완성하는 과정에서 발생할 수 있는 일부 성능 저하를 감당할 여유가 충분했기 때문에, 이 프로젝트는 최종 승인을 받게 되었습니다.

초기 테스트를 마친 후, 컴파일된 정규 표현식을 캐싱하는 등 몇 가지 명백한 최적화 조치를 적용했습니다. 또한 업스트림과의 연결을 재사용하는 더 효율적인 방식도 구현했습니다. 이를 통해 약 20%의 속도 향상을 달성했습니다. 그 후, 서버에 가능한 한 많은 요청을 전송하여 두 솔루션 모두를 피크 부하 조건에서 테스트했습니다. 이 경우에도 성능 차이는 뚜렷하게 나타났으며, Prexy는 기존 솔루션보다 약 25배 더 높은 처리량을 기록했습니다.

스트리밍 정규식 엔진

이 프로젝트의 요구 사항 중 하나는 사용되는 정규식 엔진이 스트리밍 방식이어야 한다는 것이었습니다. 프로토타입의 `regex create`는 스트리밍 방식이 아니었기 때문에 이 부분에 대한 추가 작업이 필요했습니다. 하지만 `regex` 크레이트가 매우 잘 최적화되어 있는 것으로 밝혀져, 결국 이 구현을 스트리밍 엔진의 기반으로 삼기로 결정했습니다. 정규식 엔진을 통해 데이터를 스트리밍할 때 유의해야 할 몇 가지 사항이 있습니다. 첫째, 앞으로 어떤 데이터가 올지 알 수 없습니다. 따라서 아직 완료되지는 않았지만 이미 1개 이상의 문자와 일치한 상태인 부분 일치부터 처리해야 합니다. 완전한 일치를 찾았을 때는 중복되는 부분 일치가 없는지 확인해야 합니다. 중복되는 부분 일치도 최종적으로 일치로 간주될 수 있기 때문이며(중복 시 더 긴 일치가 더 짧은 일치보다 우선합니다), 따라서 현재 모든 부분 일치가 완전한 일치가 아님이 확인된 경우에만 해당 일치의 처리를 시작할 수 있습니다.

추가 최적화

예상대로 정규식 엔진을 재구축하는 것은 Prexy의 성능에 부정적인 영향을 미쳤습니다. 하지만 기존 솔루션에 비해 여전히 충분한 여유 공간이 있었고, 추가적인 최적화를 적용한 결과 결국 정규식 엔진 재구축 이전보다 더 나은 성능을 내게 되었습니다. 우리가 적용한 최적화 중 하나는 특정 파일에 규칙을 적용해야 하는지 여부를 기억해 두는 것이었습니다. CSS나 JS 파일과 같은 정적 자산은 거의 변경되지 않기 때문에, 해당 파일에 규칙이 일치하지 않을 때마다 매번 규칙을 실행하는 것은 무의미합니다. 이에 대한 대안으로 치환 결과를 캐싱하는 방법도 있었지만, 이 전략의 단점은 모든 파일을 저장하는 데 많은 메모리가 소요된다는 점입니다. 어떤 규칙을 적용해야 하는지 기억해 두면, 해당 정보를 비트맵으로 저장함으로써 파일당 몇 바이트만으로도 충분합니다.

또한, 예를 들어 치환 과정에서 캡처 그룹이 사용되지 않을 경우 이를 유지하지 않는 등 정규식 엔진 내의 최적화 기능을 최대한 활용합니다. 그 결과, 정규식 엔진은 전체 일치 구간의 시작과 끝만 기억하면 되므로 처리 부하가 줄어듭니다. 또한 스트리밍 방식에서는 명명된 캡처 그룹 처리가 상당히 복잡했고 기존 솔루션에서도 이를 지원하지 않았기 때문에 반드시 필요한 기능은 아니어서 비활성화했습니다. 이러한 모든 최적화를 통해 궁극적으로 다음과 같은 성능을 달성했습니다:

최종 결과

Prexy가 기존 솔루션과 실제로 동일한 동작을 하는지 확인하기 위해 광범위한 테스트를 거친 후, 점진적으로 도입을 시작했습니다. 약 일주일 동안 각 클론에 Prexy를 활성화했습니다. 모니터링 결과, 활성화 시점을 파악하기가 대체로 쉬웠습니다. 응답 시간이 약 50% 감소하는 경우도 드물지 않았습니다. 대체 규칙이 거의 없는 다른 고객들의 경우에도 약 10% 정도의 소폭 차이가 관찰되었습니다. 저희 웹사이트의 속도도 약 30%(~50ms) 향상되었습니다.

전체 인프라 전반에 걸쳐 Prexy를 도입한 후 다음과 같은 차이점을 확인할 수 있습니다.

  • RAM 사용량 33% 감소

  • CPU 사용량 20% 감소

  • 응답 속도 10~50% 향상

종합해 보면, Prexy 프로젝트는 성공적이었다고 말할 수 있습니다. 구식 NGINX 모듈을 대체함으로써, 이제 모든 고객에게 명확하고 측정 가능한 차이를 가져다주는 더 새롭고 효율적인 기술을 사용할 수 있게 되었습니다. 앞으로도 Clonable을 새로운 기능 추가와 기존 기능 개선 양면 모두에서 지속적으로 발전시켜 나갈 것입니다.


이 엔지니어링 블로그를 읽어 주셔서 감사합니다. 저희 제품에 대한 이와 같은 기술적 통찰력을 더 자주 받아보고 싶으시다면 알려주세요. 이 프로젝트에 흥미가 생기셨나요? 그렇다면 채용 공고 페이지를 확인해 보세요.

오늘 바로 Clonable을 무료로 시작해 보세요

단일 플랫폼에서 국제 웹사이트를 중앙 집중식으로 관리하는 방법을 알아보세요.

당사는 사용자 경험을 개선하고, 웹사이트 트래픽을 분석하며, 서비스를 향상시키기 위해 쿠키를 사용합니다.

쿠키 정책 읽어보기