코드 작성에 AI의 도움을 받을 수 있는 가장 유명한 도구 중 하나로, GitHub이 2021년 발표한 GitHub Copilot이 있습니다. 2024년 12월 GitHub Copilot for VS Code 무료 플랜을 제공하기 시작해, 제한적(2k/월 코드 어시스트, 50건/월 채팅)이긴 하지만 누구나 활용할 수 있게 되었습니다. 그런데 Copil...
기술 블로그 모음
국내 IT 기업들의 기술 블로그 글을 한 곳에서 모아보세요


프로그래밍을 하다 보면 테스트의 필요성을 통감하는 순간이 찾아옵니다. 하지만 테스트를 꾸준히 작성하는 팀은 많지 않습니다. 만일 팀의 룰로 인해 강제로 테스트를 작성하고 있더라도, 업계의 구루가 말하는 TDD의 이점은 별로 와닿지 않고, 프로그램 코드보다 테스트 코드를 작성하는 데 더 많은 시간을 소모하고 있는 자신을 보며 자괴감을 느끼고 있을지도 모...

연구실의 기술은 서비스나 상품을 통해 세상과 만나기를 기다립니다. 제가 속한 네이버랩스의 디지털 트윈(Digital Twin) 기술도 마찬가지입니다. 디지털 트윈은 현실 공간을 복제하여 가상 환경에서 3차원으로 동일하게 구현하는 기술로, 그간에는 주로 로봇과 자율주행의 핵심 기술 중 하나였습니다. 이 기술을 다양한 네이버 서비스에 확대 적용하고자 해왔...

로그 모니터링 시스템은 서비스 운영을 위해서 반드시 필요한 시스템입니다. 로그 모니터링 시스템 구축에는 인덱스 기반의 빠른 검색을 제공하는 검색 엔진인 Elasticsearch가 널리 사용됩니다. 네이버도 Elasticsearch 기반의 로그 모니터링 시스템을 구축했으며, 수천 대의 서버로 수 페타바이트 규모의 로그 데이터를 저장하고 있습니다. 최근 들어 서비스 규모가 확장되고 저장해야 하는 로그 데이터의 규모와 트래픽 양이 급속도로 증가하면서 Elasticsearch 기반의 로그 모니터링 시스템은 비용 문제와 더불어 확장성 문제에 직면하게 됐습니다. 네이버는 저비용으로 대용량의 로그 데이터를 수집할 수 있도록 Apache Iceberg(이하 Iceberg)를 도입한 신규 컴포넌트 Alaska를 개발해 네이버의 로그 모니터링 시스템 플랫폼 NELO에 적용했습니다. 이 글에서는 기존 로그 모니터링 시스템의 문제와 Iceberg의 특징을 소개하고, Alaska의 작동 방식과 Alaska를 NELO에 적용한 이후의 변화를 소개합니다. Elasticsearch 기반 기존 로그 모니터링 시스템의 한계 Elasticsearch를 기반으로 구축된 기존 로그 모니터링 시스템의 구조를 간략하게 도식화하면 다음 그림과 같습니다. 클라이언트로부터 수신된 로그 데이터는 Kafka에 적재된 후 Elasticsearch에 저장됩니다. Elasticsearch는 SSD 타입의 스토리지로 구성된 Hot 계층(Hot Tier)과 HDD 타입의 스토리지로 구성된 Warm 계층(Warm Tier)으로 구분되어 있습니다. 로그 데이터는 Hot 계층에 3일간 저장된 후 Warm 계층으로 이동되어 최대 90일까지 저장됩니다. 이렇게 Hot 계층과 Warm 계층의 두 단계로 나누어 데이터를 저장하면 검색이 빈번하게 일어나지 않는 데이터를 효율적이면서 저비용으로 저장할 수 있습니다. 기존 로그 모니터링 시스템은 수년간 이와 같은 구조로 운영되었습니다. 그동안 데이터가 증가함에 따라 Warm 계층에 저장된 데이터의 크기도 급증했습니다. Elasticsearch는 단일 클러스터로 저장할 수 있는 데이터의 크기에 제한이 있기 때문에 다수의 Elasticsearch 클러스터를 구성해 클러스터 수준에서 확장을 진행했습니다. 그 과정에서 모든 클러스터가 한계 수준까지 도달해 운영 장애를 빈번하게 겪었습니다. 연간 수십억 원의 인프라 사용 비용도 부담이 되었습니다. 두 계층으로 구성된 Elasticsearch 클러스터는 더 이상 로그를 효율적으로 저장할 수 있는 구조가 아니게 되었습니다. 새로운 타입의 데이터 저장 스토리지의 필요성 Warm 계층에 저장된 데이터의 크기가 급증하는 이유에는 장기간 로그의 저장에 대한 요구 사항도 있습니다. 기존 로그 모니터링 시스템은 Elasticsearch에 최대 90일까지 로그 데이터를 저장할 수 있게 허용했습니다. 하지만 서비스의 법적 요구 사항 등의 이유로 예외로 1년 이상의 로그 데이터 저장을 허용하고 있었습니다. 기존 로그 모니터링 시스템이 한계에 도달하면서 이러한 장기간 로그 데이터 저장에 대한 요구 사항을 Elasticsearch로 수용하는 것이 적합한지 검토하게 되었습니다. 이를 확인하기 위해 실제로 로그 데이터의 사용자들이 어느 시점의 데이터에 관심이 많은지 분석해 보았습니다. 다음은 한 달 동안 사용자들의 검색 요청 로그를 분석한 그래프입니다. 그래프에서 X축인 Data age는 데이터가 저장되고 지난 시간을 의미합니다. Y축은 해당 Data age에 속한 데이터를 검색하는 쿼리의 비율입니다. 분석 결과, 전체 검색 쿼리 중 95%의 쿼리가 당일에 발생한 데이터에 대한 것이었으며, 99%의 쿼리가 일주일 이내의 데이터를 위한 것이었습니다. 단 0.5%의 쿼리만이 2주 이상 지난 데이터를 요청하는 쿼리였습니다. Elasticsearch는 일반적으로 데이터 저장과 쿼리 계산을 위한 컴퓨팅을 같은 노드에서 담당하고 있기 때문에 이렇게 거의 검색되지 않는 데이터를 저장하는 것은 효율적인 일이 아닙니다. 최신 버전의 Elasticsearch는 원격 스토리지에 데이터를 저장하고 검색하는 기능을 제공합니다. 하지만 Elasticsearch의 규모가 한계 크기에 도달한 로그 모니터링 시스템에 적용할 수는 없었습니다. Elasticsearch는 마스터 노드가 관리할 수 있는 메타데이터 규모에 한계가 있기 때문입니다. 이러한 문제를 해결하려면 Elasticsearch에는 검색이 자주 일어나는 단기간의 데이터 저장만 허용하고, 장기간 데이터를 저장할 새로운 스토리지가 필요하다는 판단이 들었습니다. Elasticsearch를 대체하는 신규 스토리지에서는 데이터 저장을 위한 스토리지와 검색을 위한 컴퓨팅을 분리한다 아이디어를 기본으로 설계를 시작했습니다. 또한 Elasticsearch처럼 특정 쿼리 엔진에 제한(lock-in)되지 않는 오픈 데이터 포맷을 중요한 요구 사항 중 하나로 설정했습니다. 저비용의 스토리지에 검색이 가능한 데이터 포맷으로 데이터를 저장할 수 있는 여러 방식을 비교하고 분석했습니다. 여러 방식으로 시뮬레이션을 실행한 결과, Iceberg로 실행한 시뮬레이션에서 현재 수준에서 기존 로그 모니터링 시스템보다 최소 50% 이상 비용을 절감할 수 있다는 결론을 얻었습니다. 최종적으로 Iceberg를 선택해 로그 데이터 저장을 위한 새로운 타입의 스토리지를 구현한 컴포넌트인 Alaska를 개발해 적용했습니다. Iceberg의 특징 기존 로그 모니터링 시스템의 구조와 규모 때문에 새로운 타입의 스토리지에는 다음과 같은 요구 사항이 있었습니다. 데이터 쓰기/읽기가 동시에 가능해야 한다. 데이터 쓰기/읽기가 발생하는 상황에서 동시에 스키마 변경이 가능해야 한다. 단일 테이블로 페타바이트 규모의 데이터를 저장할 수 있어야 한다. 수십만 개의 테이블 운영이 가능해야 한다. 데이터 포맷으로 인한 쿼리 엔진 제한이 없어야 한다. 데이터 저장소와 쿼리 컴퓨팅 노드가 분리되어 있어야 한다. 데이터 압축 효율이 우수해야 한다. 이 요구 사항을 구현할 수 있는 기술로는 오픈 테이블 포맷을 사용하는 Iceberg와 Delta Lake, Apache Hudi가 있습니다. 그 중에 Iceberg의 커뮤니티가 가장 활발하게 업데이트되고 있었습니다. 또한 Databricks, Snowflake 등 여러 회사가 Iceberg를 두고 벌이던 기술 경쟁이 Databricks가 Iceberg를 만든 Tabular 회사를 인수하면서 일단락 되었고, Iceberg가 오픈 테이블 포맷 기술의 주도권을 갖게 되었습니다. 여러 사정을 고려해서 Alaska 컴포넌트에 Iceberg의 오픈 테이블 포맷을 적용하기로 결정했습니다. Iceberg는 '데이터', '메타데이터', '카탈로그'의 세 부분으로 나누어 데이터를 저장합니다. 데이터는 칼럼 스토리지 데이터 포맷인 Parquet로 관리되고, zstd 형식으로 압축됩니다. 이러한 데이터는 오브젝트 스토리지에 저장됩니다. 메타데이터는 하나의 테이블을 구성하기 위한 데이터 파일의 집합 관계와 스키마 정보를 JSON, Avro와 같은 형태로 저장합니다. 데이터와 마찬가지로 메타데이터도 오브젝트 스토리지에 저장됩니다. 카탈로그는 메타데이터의 메타라고 볼 수 있습니다. 가장 최신의 메타데이터의 위치 정보와 같은 최소한의 정보만 카탈로그에서 관리됩니다. 일반적으로 카탈로그 데이터는 데이터베이스에 저장됩니다. 이와 같이 테이블을 구성한 파일에 대한 메타데이터까지 함께 관리하기 때문에 Iceberg를 단순한 데이터 포맷이 아니라 테이블 포맷이라고 부릅니다. Iceberg는 ACID 트랜잭션을 지원하며 schema evolution, hidden partitioning 등 데이터를 다루는 데 유용한 기능을 제공합니다. 신규 로그 모니터링 시스템의 구조 Iceberg를 기반으로 개발한 새로운 로그 모니터링 시스템은 기존 Elasticsearch 기반의 로그 모니터링 시스템을 대체하는 것이 아닙니다. Elasticsearch에는 실시간 모니터링이 필요한 짧은 기간의 로그를 저장하고, 장기간 보관이 필요한 데이터는 새로운 스토리지를 활성화해 저장하도록 설계했습니다. 기존 로그 모니터링 시스템에서는 Kafka에 적재된 로그 데이터를 Elasticsearch에 인덱싱하는 방식을 사용했습니다. 신규 로그 모니터링 시스템도 동일한 Kafka 토픽으로부터 데이터를 읽어 Iceberg 테이블 포맷으로 저장합니다. Elasticsearch의 Warm 계층에서 데이터를 읽어 저장하는 방식을 택하지 않은 이유는 다음과 같습니다. 실시간 검색/모니터링이 필요하지 않은 데이터는 Elasticsearch에 저장하지 않고 직접 Iceberg로 저장할 수 있습니다. Elasticsearch와 Iceberg에 중복 데이터가 저장되더라도 Iceberg 기반 시스템 비용이 매우 저렴합니다. Elasticsearch의 Warm 계층으로부터 데이터를 읽으면 HDD 기반의 클러스터에 큰 부하가 발생합니다. 신규 로그 모니터링 시스템의 아키텍처는 다음 그림처럼 크게 데이터 적재 부분(Data ingestion & optimization)과 데이터 쿼리 부분(Data query)으로 나눌 수 있습니다. 데이터 적재 부분은 다음과 같은 요소로 구성되어 있습니다. Orca: Kafka의 데이터를 Iceberg 테이블 포맷으로 변환해 오브젝트 스토리지에 저장하는 컴포넌트 Polarbear: Iceberg 테이블 데이터를 최적화하고 데이터 라이프사이클을 관리하는 컴포넌트 Puffin: Iceberg 카탈로그 컴포넌트 데이터 쿼리 부분은 다음과 같은 요소로 구성되어 있습니다. Trino: Icerbeg 테이블 조회를 위한 쿼리 컴퓨팅 엔진 API Server: Alaska 데이터 조회를 위한 NELO Open API 제공 Frontend: Alaska 쿼리 UI 제공(웹 UI) 신규 로그 모니터링 시스템은 데이터 프로세싱을 위해서 Kappa Architecture를 따르고 있습니다. 즉, 실시간으로 저장되고 있는 로그 데이터 테이블에 사용자가 접근해 데이터를 조회할 수 있는 구조입니다. 전통적인 Lambda Architecture처럼 여러 개의 테이블을 운용해 데이터 변환 과정을 거쳐 사용자에게 제공하는 방식은 로그 저장 목적으로 사용하기에는 너무 복잡하고 비용 측면에서 효율적이지 않은 구조입니다. Iceberg의 오픈 테이블 포맷은 ACID 트랜잭션을 지원하기 때문에 실시간으로 쓰기가 발생하는 테이블을 동시에 사용자가 읽어도 데이터 정합성을 보장하며 서비스할 수 있습니다. 이러한 구조를 통해서 사용자는 짧은 지연 시간(데이터 동기화 주기 5분) 안에 데이터를 조회할 수 있습니다. 데이터 저장을 위해 사용하는 사내 오브젝트 스토리지 서비스인 Nubes는 MinIO라는 S3 게이트웨이를 활용해 S3 인터페이스를 기반으로 Iceberg와 연동되어 있습니다. 신규 로그 모니터링 시스템의 아키텍처에서 설명한 Orca, Polarbear, Puffin은 모두 Iceberg Java SDK를 기반으로 직접 개발한 컴포넌트입니다. 프로젝트 초기에 오픈 소스를 활용해 PoC(Proof of Concep)를 진행했지만 여러 이유로 오픈 소스를 사용할 수 없었습니다. 신규 로그 모니터링 시스템 개발 초기에 검토한 오픈 소스와 사용 불가 이유는 다음과 같습니다. 데이터 적재 kafka-connect: 기능적 요구 사항은 충족했습니다. 하지만 지원하는 동기화 대상 테이블의 수가 적었습니다. 동기화 대상 테이블의 수가 수십만이었지만, kafka-connect는 테이블의 수가 수백 개의 수준에만 도달해도 OOM(Out of Memory)이 발생했습니다. flink: Kafka의 데이터를 Iceberg로 저장하는 기능은 제공하지만 단일 테이블에 대해서만 작동합니다. 즉, 테이블 fan-out 기능이 존재하지 않습니다. 동기화해야 하는 테이블의 수만큼 flink 애플리케이션을 실행해야 하는 경우가 있어, 현실적으로 운영에 어려움이 있는 문제가 있습니다. 데이터 최적화 Trino, Spark, Hive 등 Iceberg 테이블을 지원하는 쿼리 엔진: 데이터 최적화 및 라이프사이클을 관리하는 것이 기능적으로는 가능합니다. 그러나 요구하는 테이블의 규모를 지원하려면 비용 부담이 커집니다. 또한 세부적인 스케줄링 및 스로틀링 설정이 어렵기 때문에 오브젝트 스토리지에 과한 부담이 발생할 수 있습니다. 카탈로그 Hive metastore, Nessie, Polaris, Unity 등 Iceberg 테이블을 지원하는 카탈로그: 최초 설계에서는 Hive metastore를 사용했으나 Hive lock 버그로 인해 경합이 심할 때에는 데드락에 빠지는 이슈가 발생했습니다. 또한 장기적으로 Iceberg REST 카탈로그를 표준으로 만들고, 다른 카탈로그를 직접적으로 사용하는 것을 중단할 계획이 있다는 것을 Iceberg 커뮤니티를 통해서 확인했습니다. REST 카탈로그는 표준 스펙만 존재하며 공식적인 구현체가 존재하지 않습니다. Snowflake에서 최근에 Polaris라는 REST 카탈로그 스펙에 준한 카탈로그를 공개했지만 특정 카탈로그에 제한될 우려가 있습니다. 또한 카탈로그를 사용자에게 공개해 데이터 연동(data federation)을 제공할 계획이 있어, 컴포넌트를 직접 개발하는 것이 효율적이라고 판단했습니다. 데이터 적재 다음 그림은 Orca 컴포넌트가 Kafka의 데이터를 Iceberg 테이블 포맷으로 변환해 저장하는 과정을 도식화한 그림입니다. Kafka에 쌓여 있는 로그 데이터를 Iceberg 테이블 포맷으로 변환해 저장할 때에는 다음과 같은 단계로 데이터를 처리합니다. Kafka 데이터 수신 Kafka 토픽으로부터 데이터를 읽습니다. 다중 컨슈머 구성을 통해서 I/O 병목 문제를 해결하고 처리량을 극대화했습니다. 로그 데이터 관리 및 전달 데이터를 수신한 후 데이터를 내부 메모리 큐에 적재합니다. 메모리 큐에 적재된 데이터는 레코드 리포지토리를 통해 각 Iceberg 테이블에 대응하는 Writer로 분배됩니다. 데이터 포맷 변환 및 저장 각 Writer는 데이터를 Parquet 형식으로 변환한 뒤 Writer 내부 메모리 버퍼에 저장합니다. Flush Manager가 특정 주기로 오브젝트 스토리지에 데이터를 저장하고 Iceberg 테이블에 커밋합니다. 간단해 보이는 구조이지만 다음과 같은 여러 가지 상황을 고려해 설계되었습니다. 테이블 fan-out 기능:Kafka 토픽에 저장되어 있는 로그는 tenant별로 분리되어 각 Iceberg 테이블에 저장됩니다. 그렇기 때문에 단일 데이터 스트림에서 다수의 테이블로 데이터를 전송하는 fan-out 기능이 필요합니다. 테이블 데이터가 처음 인입되는 시점에 동적으로 Writer가 생성되고 flush가 실행되는 시점에 메모리가 해제되도록 설계했습니다. 효율적인 메모리 관리:초당 수십만 건에 이르는 로그 데이터를 실시간으로 처리하려면 메모리 사용량 최적화가 필수입니다. 실시간으로 유입되는 데이터를 변환해 메모리에 적재하고 5분 단위로 flush를 진행해 메모리를 주기적으로 확보하도록 설계했습니다. 특정 테이블에 데이터가 많이 유입될 경우에는 해당 테이블에 해당하는 데이터를 파일로 먼저 내보내는 롤오버 동작을 수행합니다. 메모리 사용량이 급증할 경우에는 전체 Writer에서 강제 flush를 실행해 OOM을 예방합니다. Kafka 오프셋 관리:Kafka로부터 읽은 데이터의 Iceberg 테이블 커밋이 완료된 이후에 Kafka 오프셋 커밋이 가능합니다. Kafka로부터 읽어 온 batch 단위로 Iceberg 테이블에 커밋을 하면 너무 작은 파일 단위로 커밋이 실행되기 때문에 처리량 측면에서 성능이 저하될 수 있습니다. 그래서 Kafka로부터 읽은 데이터가 충분히 메모리에 쌓였을 때 커밋을 실행해야 하는데, 이럴 경우 Kafka에서 제공하는 자동 오프셋 커밋 기능을 사용할 수 없어 수동으로 오프셋을 관리해야 합니다.내부 메모리에 오프셋을 저장하고 실제로 Iceberg 테이블에 커밋이 성공한 위치까지의 오프셋만 다시 Kafka에 커밋되도록 구현했습니다. 데이터 손실은 발생하지 않지만 중복 데이터가 Iceberg 테이블에 저장될 수 있는 구조(at-least-once)로 설계했습니다.Iceberg의 equality delete 기능을 사용하면 중복 데이터를 방지할 수 있지만 Iceberg 테이블 운용 비용이 비싸지기 때문에 채택하지 않았습니다. 로그 데이터 유실은 중요한 문제가 될 수 있지만, 중복 발생은 대부분 크게 문제가 되지 않습니다. 또한 모든 로그에 유니크 아이디를 부여하고 있어서, 필요시 사용자가 쿼리를 통해서 중복 데이터를 제거할 수 있도록 안내하고 있습니다. 데이터 변환:기본적으로 신규 필드가 유입될 경우 시스템에서 해당 필드를 String으로 취급해 스키마를 자동으로 업데이트합니다(사용자는 UI와 API를 통해서 신규 필드를 원하는 타입으로 생성할 수 있습니다). 신규 필드가 유입되면 해당 테이블에 대해서 메모리에 쌓여 있던 데이터에 강제 flush를 실행한 이후에 스키마 업데이트를 진행하고 다시 메모리에 데이터를 쌓기 시작합니다.특정 필드에 대해서 변환이 불가능한 경우 에러 필드에 원본 데이터와 이유를 함께 저장합니다. Iceberg 테이블은 칼럼 이름의 대소문자 구분을 지원하지만, 쿼리 엔진이 대소문자 구분을 지원하지 않기 때문에 칼럼 이름을 대소문자를 구분하지 않게(case-insensitive) 설정해야 합니다. 대소문자만 다른 이름을 가진 중복되는 필드가 유입되면 에러 필드에 저장합니다. 또한 String이 아닌 다른 타입으로 생성된 필드에 대해서 지원되지 않는 값으로 데이터가 유입될 경우(예: 숫자 타입에 문자열 유입) 에러 필드에 저장합니다. 사용자는 에러 필드를 조회해 누락된 데이터 값과 누락된 사유를 확인할 수 있습니다.알 수 없는 이유로 데이터 변환에 실패하면 DLQ(dead-letter queue)에 전송해 후처리를 실행할 수 있도록 합니다. 트래픽이 증가해 데이터 적재 컴포넌트를 많은 수로 확장(scale-out)하면 단일 Iceberg 테이블에 대해서 여러 노드가 동시에 Write를 실행하게 됩니다. 이럴 경우 다음과 같은 문제가 발생할 수 있습니다. Iceberg 테이블에 대해 동시에 발생한 커밋이 충돌해 실패 가능성 높아집니다. 여러 노드에 데이터가 분산되어 작은 파일로 쪼개져서 Write가 일어납니다. 이 때문에 오히려 처리량이 저하될 수 있으며 오브젝트 스토리지에도 작은 파일로 인해 부담이 발생할 수 있습니다. 또한 추후 데이터 최적화를 위한 Rewriting 과정에서도 문제가 될 수 있습니다. 위와 같은 문제 때문에 데이터 적재 컴포넌트가 단일 노드에서 최대한의 성능을 낼 수 있도록 최적화에 많은 신경을 써서 개발을 진행했습니다. 추후 Kafka 토픽 커스텀 파티셔너를 통해서 개선할 계획도 있습니다. 데이터 최적화 데이터 최적화 컴포넌트는 다음과 같은 두 가지 역할을 수행합니다. Iceberg 테이블 데이터 최적화 및 라이프사이클 관리 Iceberg 테이블 관련 API 제공 데이터 최적화를 진행하지 않으면 Iceberg 테이블에 쌓이는 파일이 너무 많아져서 전체적인 성능이 저하될 수 있습니다. 이러한 데이터 최적화 및 라이프사이클 관리를 위한 태스크를 주기적으로 실행해 테이블의 상태를 최적의 상태로 유지합니다. 또한 API 서버로부터 Iceberg 테이블에 관련된 메타데이터 정보 및 스키마 업데이트 등을 요청받아 처리하는 역할도 수행합니다. 데이터 최적화 컴포넌트는 임베디드 분산 캐시를 내장하고 있으며, 해당 캐시를 통해서 노드의 리더를 선출합니다. 리더로 선출된 노드는 수행해야 할 테스크를 주기적으로 스케줄링해 내부 시스템 테이블로 생성합니다. 이때 시스템 테이블 또한 Iceberg 테이블을 기반으로 생성됩니다. 나머지 팔로워 노드는 시스템 테이블이 업데이트될 때 자신에게 할당된 태스크를 읽어 실행합니다. Iceberg 테이블 최적화 및 라이프사이클 관리를 위해 실행하는 배치 잡(batch job)은 다음과 같습니다. Rewriting data:같은 시간 파티션 안에 있는 파일을 병합하는 작업입니다.데이터 적재 시 5분 주기로 데이터 flush를 실행하기 때문에 실제 테이블에 쓰인 데이터는 작은 파일로 나누어져 있습니다. 예를 들어 데이터 적재 노드가 1개라면 하루에 최소 288개의 파일이 생성(5분당 최소 1개 파일 생성)됩니다. fan-out 대상 테이블의 수가 10,000개라면 하루에 최소 288만 개의 파일이 생성됩니다. 이 상태로 오랜 시간 데이터 적재를 진행하면 파일의 수가 많아져 메타데이터가 거대해지고 커밋 성능이 저하됩니다. 그리고 작은 파일에 대한 I/O가 증가해 쿼리 성능이 저하됩니다. 지속적인 작은 파일 쓰기는 오브젝트 스토리지에도 부담이 됩니다. 그래서 파일을 병합하는 작업을 주기적으로 실행합니다.테이블은 시간 파티션으로 나누어져 있는데, 같은 시간 파티션 안에 존재하는 파일을 병합하는 작업을 매시간 실행합니다. 목표 병합 파일의 크기는 128MiB로 설정되어 있습니다. 트래픽이 많은 일부 테이블을 제외하고 대부분 1시간 로그 데이터가 1개의 파일로 병합됩니다.시간 순서로 유입되지 않고 과거 시간의 로그와 뒤섞인 데이터(disorder data)가 인입되는 테이블의 경우에는 긴 시간 범위에 대해서 병합 작업을 진행합니다. 로그 모니터링 시스템이 네이버 모바일 앱의 로그 수집도 지원하기 때문에 disorder data가 발생합니다. 운영체제의 정책 등 모바일 기기의 특성상 네트워크, 배터리 상태에 따라 로그를 전송하는 시점이 늦어질 수 있습니다. 그래서 최대 3일 전의 로그까지 수집하는 것을 정책상 허용합니다. 이때 3일 이내의 시간 파티션에 계속 작은 파일이 생성되는 문제가 발생하고, 이러한 테이블에 대해서는 매시간마다 3일 내의 모든 파티션을 병합하는 작업을 실행합니다.데이터 적재 지연이 발생하거나 제대로 된 정보가 수신되지 않을 경우에는 해당 작업 스케줄링을 중단합니다. 데이터 지연을 고려하지 않으면 이미 병합이 종료된 시간 파티션에 다시 작은 파일이 생성되어 문제를 유발할 수 있기 때문입니다. Expire snapshot: 주기적으로 스냅숏을 삭제하는 작업입니다. Iceberg 테이블은 매 커밋마다 스냅숏 정보를 남깁니다. 스냅숏을 관리하지 않으면 무한대로 스냅숏이 생성되어 메타데이터 파일이 매우 커지고, 작은 파일이 쌓이는 문제가 발생합니다. 주기적으로 테이블마다 최근 10개의 스냅숏만 남기고 삭제합니다. Optimize table: Rewriting data와 Expire snapshot을 하나의 잡(job)으로 구성해 파일 병합이 종료된 이후에 스냅숏을 삭제하는 작업입니다. Retention: 보존 기간이 지난 로그를 삭제하는 작업입니다. 각 테이블마다 설정된 로그 보존 기간이 있습니다. 보존 기간이 지난 로그를 하루에 한 번씩 삭제합니다. Delete table: 삭제 요청이 있는 Iceberg 테이블을 물리적으로 삭제하는 작업입니다. 삭제 요청이 있은 시점으로부터 3일(데이터 복구 가능 기간)이 지난 뒤에 실행합니다. Iceberg SDK가 제공하는 삭제 API 실행 이후에도 실제 스토리지에 가비지 데이터가 남아 있을 수 있습니다. S3 API를 사용해 해당 테이블 디렉터리 하위에 존재하는 모든 파일에 대해 다시 한번 삭제를 실행합니다. Delete orphan files: 가비지 데이터를 삭제하는 작업입니다. Iceberg 테이블에 데이터 커밋 시 충돌이 발생하면 메타데이터, 데이터 영역에 모두 가비지 데이터가 발생할 수 있습니다. 메타파일과 실제 오브젝트 스토리지에 존재하는 파일을 주기적으로 대조해 가비지 데이터를 삭제합니다. 작업 실행 중 신규 파일이 커밋되면 신규 파일도 삭제될 위험이 있어서 생성된 지 7일 이상 지난 파일에 대해서만 가비지 데이터 분류를 실행합니다. 리더 노드는 주기적으로 배치 잡을 실행합니다. 태스크는 각 테이블의 평균 사이즈를 기준으로 빈 패킹(bin packing) 방식으로 모든 노드에 할당됩니다. 할당된 결과는 Iceberg 시스템 테이블로 저장되고, 각 노드는 해당 시스템 테이블을 주기적으로 읽어 자신에게 할당된 태스크를 실행합니다. 실행이 완료된 태스크는 시스템 테이블에서 삭제됩니다. 태스크 스케줄링 상태가 Iceberg 테이블로 저장되어 있기 때문에 노드가 다시 시작되어도 하던 작업을 이어서 실행할 수 있습니다. 카탈로그와 데이터 연동 신규 로그 모니터링 시스템은 Iceberg REST 카탈로그를 사용합니다. REST 카탈로그의 핸들러 등의 구현체는 Iceberg SDK에 포함되어 있습니다. SDK를 기반으로 Spring Boot로 래핑해 서버로 작동하게 만든 것이 Puffin입니다. REST 카탈로그를 사용하려면 실제 메타데이터가 저장될 저장소를 지정해야 하는데, MySQL을 백엔드 카탈로그로 지정해 사용합니다. Alaska의 초기 설계부터 카탈로그를 사용자에게 제공해 데이터 연동을 지원하려 했습니다. 로그에 포함되어 있는 데이터를 분석하려는 사용자가 많은데, 기존 로그 모니터링 시스템 환경에서는 Open API를 사용해 로그를 다운로드해 분석하는 사용자가 대부분이었습니다. 그렇기 때문에 카탈로그를 사용자에게 제공하면 사용자는 데이터 다운로드 없이 자신의 쿼리 엔진과 직접 연동해 바로 SQL 쿼리를 실행해 쉽게 데이터 분석을 실행할 수 있게 됩니다. 데이터 연동을 위해서 카탈로그에 다음과 같은 기능을 구현했습니다. 기존 로그 모니터링 시스템에서 발급받은 access key 기반으로 인증 시스템과 연동합니다(authentication). 인증된 정보를 기반으로 권한이 있는 테이블에만 접근할 수 있도록 제어합니다(authorization). 데이터 연동 시 read-only API에만 접근을 허용해 테이블에 커밋 및 삭제 등을 실행할 수 없게 합니다. 인증 기능은 Iceberg REST 카탈로그 표준 스펙에 맞춰 구현했고, iceberg.rest-catalog.oauth2.token 설정의 access key 값을 통해 사용자가 권한이 있는 테이블에 읽기 전용으로 접근할 수 있게 했습니다. 데이터 쿼리 신규 로그 모니터링 시스템의 쿼리 엔진으로는 Trino를 채택했습니다. Trino에 의존성을 가지지 않도록 내부 구조를 설계했기 때문에 필요하다면 언제든지 Spark와 같은 다른 쿼리 엔진으로 교체할 수 있습니다. 사용자는 웹 UI 혹은 Open API를 통해서 쿼리를 실행할 수 있습니다. 신규 로그 모니터링 시스템에서는 기본적으로 다음과 같이 쿼리를 크게 Main query와 Sub query로 구분합니다. Main query는 원본 로그 데이터 테이블을 대상으로 실행하는 쿼리입니다. 기본적으로 비동기로 실행됩니다(non-interactive query). CTAS(Create Table As Select) 쿼리로 실행되며, 쿼리 결과는 또 다른 테이블로 저장됩니다. Sub query는 메인 쿼리에 의해서 생성된 쿼리 결과 테이블을 대상으로 실행하는 쿼리입니다. 동기 방식으로 실행됩니다(interactive query). 이렇게 Main query를 비동기 방식으로 실행해 쿼리 결과를 테이블로 저장하는 이유는 대용량의 데이터를 검색할 때 실행 시간이 매우 길어질 수 있기 때문입니다. 일반적으로 인덱스가 없기 때문에 Elasticsearch보다 응답 속도가 느립니다. 장기간 검색을 허용하기 때문에 검색하는 데이터의 양과 쿼리 형태에 따라서 결과를 얻는 데 수시간이 소요될 수도 있습니다. 이러한 상황에서 동기 방식으로 쿼리를 실행하면 사용자는 응답이 올 때까지 웹브라우저가 종료되지 않도록 유지하고 대기해야 합니다. 또한 Main query를 통해 최대한 관심 있는 데이터 영역만 필터링해 쿼리 결과 테이블을 만들면 그 이후부터는 빠른 속도로 관심 있는 데이터 영역을 탐색할 수 있게 됩니다. 이러한 사용자 경험을 고려해 위와 같이 쿼리 방식을 설계했습니다. 새로운 타입의 데이터 저장 스토리지의 필요성에서 살펴본 것처럼 장기 보관 데이터에 대해서는 쿼리가 발생하는 비율이 낮습니다. 그렇기 때문에 Trino 클러스터를 적은 리소스로 제공하고 있습니다. 다만 신규 SQL 쿼리 기능의 도입으로 이전에 없던 쿼리 패턴이 등장해 쿼리 리소스가 과도하게 소모될 가능성이 생겼습니다. 이에 따라 다음과 같이 쿼리에 제약 사항을 두었습니다. 테이블마다 Main query는 동시에 최대 한 개만 실행합니다. Sub query의 실행 속도를 사용자마다 15queries/min로 제한합니다. 쿼리를 ANTLR 4 기반으로 분석해 SQL 문법을 제한합니다. SELECT 쿼리만 허용합니다. WITH, JOIN, UNION, INTERSECT, EXCEPT 연산자를 사용할 수 없습니다. 중첩 쿼리(nested query)를 사용할 수 없습니다. FROM 절에는 반드시 한 개의 대상 테이블만 명시합니다. SQL 쿼리 실행 시 사용되는 리소스를 제한합니다. 사용자 쿼리 요청 시 바로 실행하지 않고 쿼리 플래닝을 통해서 리소스를 예측합니다. 예측된 리소스가 제한 값을 초과하면 사용자에게 에러를 반환합니다. 이와 같은 제약 사항이 없다면 무거운 데이터 분석 쿼리가 많이 유입되어 쿼리 엔진 비용이 급속도로 증가할 가능성이 있습니다. 제약 사항을 넘어서는 쿼리 실행이 필요할 경우에는 카탈로그 데이터 연동을 통해서 사용자의 쿼리 엔진 리소스를 사용하도록 안내하고 있습니다. 신규 로그 모니터링 시스템 적용 결과 Iceberg 기반의 신규 로그 모니터링 시스템을 오픈하면서 기존 Elasticsearch 기반의 로그 데이터는 최대 데이터 보관 기간을 14일로 단축했습니다. 이러한 정책을 통해서 2,000대 이상의 Elasticsearch 노드를 줄일 수 있었으며, 데이터 용량도 수 페타바이트 규모에서 수백 테라바이트 규모로 감소했습니다. 대신 기존에 90일로 제한한 최대 로그 보관 기간을 신규 로그 모니터링 시스템을 활성화할 경우 최대 5년까지로 늘였습니다. 이를 통해서 예상되는 인프라 비용이 매년 수 십억 원까지 절감되었습니다. 늘어나는 트래픽 추세를 감안하면 절감되는 비용은 매년 그 이상이 될 것이라 예상합니다. 이렇게 비용을 절감할 수 있는 이유는 다음과 같습니다. 상대적으로 비용이 저렴한 오브젝트 스토리지에 데이터를 저장합니다. Parquet 데이터 포맷에 zstd 압축을 적용해 데이터 압축률이 높습니다. 다음 그래프에 나타난 것처럼 전체 평균 원본 데이터 대비 약 6% 수준으로 압축됩니다. 쿼리 엔진 리소스를 분리해 최소한의 규모로 운영합니다. 기존 Elasticsearch 기반 모니터링 시스템의 Warm 계층의 데이터 노드와 비교해 Trino 클러스터 규모가 더 작습니다. 다음 그래프는 데이터 적재 이후 데이터 최적화 과정의 파일 병합을 통해서 감소된 파일 비율입니다. 평균적으로 약 7.5%의 수준으로 감소했습니다. 파일 병합 작업을 통해 테이블을 최적화하지 않는다면 데이터 쓰기/읽기 측면서 시스템이 정상적으로 작동할 수 없습니다. 다음은 신규 로그 모니터링 시스템의 UI입니다. 원하는 테이블을 선택해 쿼리(Main query)를 실행하면 그 결과가 다시 Iceberg 테이블로 저장됩니다. 그 이후에 해당 결과에 여러 가지 필터를 적용해 실시간으로 데이터를 탐색할 수 있습니다. 마치며 네이버의 기존 로그 모니터링 시스템은 Elasticsearch를 기반으로 구성되었으며, 수 천대의 서버로 수 페타바이트 규모의 로그 데이터를 저장했습니다. 데이터 쿼리 패턴을 분석한 결과, 대규모 데이터 중에서 70%의 데이터는 검색이 거의 이루어지지 않는 콜드 데이터였습니다. 이런 데이터를 고비용, 고성능 저장소인 Elasticsearch에 저장해야 할지 검토하게 되었습니다. 검색이 거의 이루어지지 않지만 법적 요구 사항과 사후 분석을 위해 장기간 로그 저장에 대한 요구 사항이 많았기 때문에 새로운 저비용의 로그 검색 시스템을 구축하기로 결정했습니다. 새로운 로그 모니터링 시스템은 Iceberg라는 오픈 테이블 포맷을 기반으로 구성됩니다. 오브젝트 스토리지에 로그를 저장하는 기술을 개발하고, Trino 쿼리 엔진에 기반해 로그 검색 시스템을 구축했습니다. 새로운 저비용의 로그 모니터링 시스템으로 연간 수십억 원의 인프라 비용을 절감할 수 있는 기반을 마련할 수 있게 되었고, 새로운 방식의 SQL 로그 검색/분석 기능을 사용자에게 제공할 수 있게 되었습니다. Iceberg의 오픈 테이블 포맷을 사용한 데이터 저장은 데이터 분석 플랫폼에서는 흔하게 쓰이는 방식입니다. 하지만 로그 모니터링(observability) 측면에서 기존 로그 모니터링 시스템의 요구 사항을 만족하는 신규 시스템을 구축하는 것은 쉽지 않은 일이었습니다. 특히나 데이터 적재와 최적화를 위한 오픈 소스의 활용이 어려워 Iceberg SDK를 사용해 직접 컴포넌트를 개발해야 했습니다. Iceberg SDK에 대한 레퍼런스가 부족해 데이터를 시간 단위로 나누어 저장하고 다시 병합하며 메타데이터를 관리하는 부분의 개발은 초기 단계에서부터 많은 어려움이 있었습니다. 또한 신규 시스템의 트래픽이 사내 오브젝트 스토리지 시스템에 부하를 발생시켜, 해당 문제를 해결하는 것도 쉽지 않은 일이었습니다. 하지만 컴포넌트를 자체 개발함으로써 특정 엔진에 제한되지 않고, 최신의 Iceberg 버전을 적용할 수 있다는 점은 매우 큰 장점입니다. 신규 로그 모니터링 시스템 오픈 이후에 사용자들은 단순히 장기 보관 데이터에 대한 검색뿐만 아니라 최신 데이터에 대해서도 기존 Elasticsearch의 Lucene 쿼리로 분석하기 힘든 것을 SQL 기반으로 분석하기 시작했습니다. NELO라는 사내 로그 모니터링 플랫폼은 데이터 분석을 위한 시스템은 아니지만 Iceberg 기반의 신규 로그 모니터링 시스템이 데이터 레이크(data lake)의 데이터 소스 중 하나로 활용될 수 있기를 기대하고 있습니다. 해당 글은 N INNOVATION AWARD 2024 특집편으로 수상작 '대용량/장기간 데이터를 위한 저비용 로그 검색 시스템 : NELO Alaska'의 수상팀에서 작성해주셨습니다. N INNOVATION AWARD는 2008년부터 이어진 네이버의 대표적인 사내 기술 어워드로 매년 우수한 영향력과 성과를 보여준 기술을 선정하여 축하와 격려를 이어오고 있습니다.

네이버 검색은 사용자 중심의 빠르고 원활한 검색 경험을 제공하기 위해 지속적으로 웹 성능을 모니터링하고 최적화하고 있습니다. 2024년에는 특히 LCP(Largest Contentful Paint)를 핵심 성능 지표로 삼아 여러 최적화 작업을 진행하였으며, 그 결과 네이버 검색의 웹 성능은 목표로 삼았던 구글 글로벌 LCP와 유사한 수준인 LCP p95 기준 2.31초를 달성할 수 있었습니다. 또한 2024년에 새로운 서치 피드 서비스가 도입되면서 기존의 웹 기반 성능 지표로는 측정하기 어려운 무한 스크롤 영역을 측정할 수 있는 새로운 성능 지표를 개발하여 관리하고 있습니다. 새로운 성능 지표는 동적 콘텐츠 로딩의 체감 속도와 이미지 렌더링 타이밍을 평가하는 데 활용됩니다. 이 글에서는 1) 2024년 네이버 통합검색의 웹 성능을 정리하고, 2) 새롭게 도입된 성능 지표를 소개하며, 3) 2024년 네이버에서 진행한 몇 가지 성능 개선 사례를 살펴봅니다. 2024년 네이버 통합검색 성능 연간 지표 네이버 검색은 LCP를 가장 중요한 성능 지표로 설정하고 있으며, 구글 글로벌 LCP 수준인 p95(95번째 퍼센타일) 기준 2.5초 이하를 목표로 하고 있습니다. 2024년 12월 기준 네이버 검색의 LCP Good Score는 96.59%를 기록했는데, 이는 대부분의 사용자들이 페이지 로딩 시 콘텐츠가 빠르고 원활하게 표시되고 있음을 의미합니다. 꾸준히 시스템을 모니터링하고 개선하기 위해 노력한 덕분에 2024년 하반기부터 성능 지표가 점진적으로 개선될 수 있었습니다. 2024년 네이버 통합검색 성능 개선 사례에서 구체적인 개선 사례를 확인할 수 있습니다. 2024년 네이버 검색의 전체 LCP는 p95 기준 약 2.31초이며, INP(Interaction to Next Paint)는 0.26초입니다. INP는 사용자가 버튼을 클릭하거나 입력을 했을 때 화면이 다음 단계로 넘어가기까지 걸리는 시간을 의미합니다. INP는 Core Web Vitals의 주요 지표 중 하나로 네이버 검색에서도 유심히 모니터링하고 있습니다. 구글에서는 좋은 INP를 200ms 이하로 안내하고 있습니다. 2024년 하반기부터 네이버 검색의 INP 지표가 점점 개선되고 있지만, 아직 목표 기준인 60ms를 초과하므로 추가적인 최적화 작업이 필요합니다. 2024년 네이버 통합검색의 새로운 성능 지표 네이버 검색은 최근 무한 스크롤 방식의 서치 피드(Search Feed)를 도입하여 사용자 경험을 개선하기 위해 노력하고 있습니다. 그런데 기존의 LCP 지표만으로는 동적으로 로드되는 무한 스크롤 영역의 성능을 정확하게 평가하는 데 한계가 있었습니다. LCP는 페이지를 최초로 로딩할 때 주요 콘텐츠의 시각적 완성도를 측정하는 데 효과적이지만, 사용자가 직접 스크롤로 새로운 데이터를 요청하는 서치 피드의 특성을 반영하기 어려웠습니다. 이러한 문제를 해결하기 위해 네이버 검색에서는 새로운 성능 지표를 정의하여 관리하고 있습니다. 피드 사용자 체감 성능 지표: FUPP FUPP(Feed User Perceived Performance)는 서치 피드의 동작 흐름과 사용자 상호작용을 기반으로 설계된 지표입니다. 사용자가 화면 아래까지 스크롤하여 새로운 콘텐츠를 요청하면, 백엔드 API 호출이 트리거되고 해당 데이터를 받아 화면에 렌더링하는 과정이 시작됩니다. FUPP는 API 호출이 시작되는 시점부터 서치 피드의 첫 번째 주요 이미지가 화면에 완전히 렌더링될 때까지의 시간을 측정합니다. 이는 단순히 네트워크 응답 속도뿐 아니라 데이터 처리와 시각적 요소의 표시 시간까지 포괄함으로써, 사용자가 실제로 새로운 콘텐츠를 '인지'하는 순간을 측정합니다. 즉, FUPP는 무한 스크롤 환경에서 사용자가 다음 콘텐츠를 얼마나 빠르게 확인할 수 있는지를 평가하는 기준입니다. 위 내용을 바탕으로 FUPP를 측정한 결과 p95 기준 약 1.5초로 나타났습니다. 이는 대다수 사용자가 1.5초 이내에 새로 로드된 서치 피드 콘텐츠를 확인할 수 있음을 의미합니다. 다시 말해 사용자가 화면 아래에 도달하기 1.5초 전에 서치 피드를 요청하면 끊김 없이 콘텐츠를 확인할 수 있습니다. 피드 이미지 로드 타이밍 지표: FILT 대부분의 서치 피드에서 가장 많은 영역을 차지하는 부분은 이미지입니다. 따라서 이미지 로딩 속도가 사용자 체감 성능에 많은 영향을 주게 됩니다. 네이버 검색에서는 FILT(Feed Image Load Timing)라는 지표를 도입해 서치 피드에서 이미지가 언제, 어떻게 로드되었는지를 분석하고 있습니다. 이미지가 노출되는 시점을 총 4가지 유형으로 세분화하여 FILT를 측정했습니다. 첫 번째 유형은 Standby(대기 중)로 이미지 로딩이 아직 시작되지 않은 상태를 의미합니다. 브라우저의 lazy loading 방식을 이용하므로 Standby는 아직 브라우저가 이미지를 로드하지 않은 상태입니다. 측정한 결과 전체 이미지 중 14%가 이 유형에 해당했습니다. 두 번째 유형인 Early(미리 로드됨)는 사용자가 해당 피드 영역에 도달하기 전에 이미지가 로드된 경우로, 사용자는 끊김 없이 피드를 소비할 수 있습니다. 전체 이미지의 75%를 차지합니다. 세 번째 유형은 Viewport(화면 내 로드됨)로 사용자가 피드에 진입한 시점에는 이미지가 로드 중이었지만 피드를 소비하는 과정에서 로드가 완료되는 경우입니다. 전체 이미지의 약 11%를 차지하고 있습니다. 마지막 유형은 Late(늦은 로딩)로 사용자가 피드를 지나친 후에 이미지가 로드된 경우입니다. 즉, 사용자가 해당 이미지를 확인하지 못한 상태로 피드를 지나쳤음을 의미합니다. 다행히 이런 경우는 0% 수준으로 확인되었습니다. 서치 피드 최적화 FUPP와 FILT 지표로 서치 피드의 성능을 정량적으로 측정하고 모니터링할 수 있는 체계를 구축했고, 이를 활용해서 서치 피드 최적화 실험을 진행했습니다. 무한 스크롤 방식의 고민 중 한 가지는 콘텐츠를 호출하는 시점입니다. 사용자가 화면을 끝까지 스크롤한 이후 콘텐츠를 로드한다면 대기 시간이 발생하여 사용성에 문제가 생길 수 있습니다. 반면 사용자가 화면 아래에 도달하기 전에 콘텐츠를 너무 빠르게 로드하면 소비 의도가 없는 사용자에게도 콘텐츠가 노출되어 서버 리소스가 불필요하게 소모될 수 있습니다. 그래서 우리는 로드 시점에 따른 사용성과 서버 부하 등을 확인할 수 있는 ABT를 진행했습니다. 화면 맨아래에서부터 200&Tilde1,600px 구간을 실험 구간으로 설정하고, 구간별로 서치 피드 호출 시점을 조정하여 소비 지표와 시스템 지표를 종합적으로 확인했습니다. 그 결과 콘텐츠 로드 시점을 100px 조정할 때마다 서버 호출량은 5%, 사용자 도달률은 2% 변동하는 패턴을 확인할 수 있었습니다. 예를 들어 화면 아래에서 600px 위치에서 콘텐츠를 로드하면, 800px 위치 대비 서버 호출량이 10% 감소했으나 사용자 도달률도 4% 하락했습니다. FILT 지표 역시 주목할 만한 변화를 보였습니다. 로드 시점이 늦어질수록 Viewport 비중이 증가했는데, 이는 이미지 요청 시점이 늦어졌기 때문에 발생한 현상으로 예측한 결과였습니다. 흥미로운 부분은 특정 시점(약 1,000px) 이후에는 로드 시점을 더 앞당겨도 Early 비중이 개선되지 않는다는 점이었습니다. 소비 지표(클릭률, 이탈률 등)에서도 유의미한 인사이트를 확인할 수 있었습니다. 로드 시점이 늦어질수록 소비 지표는 안 좋아질 것으로 예상했고, 실제로도 가설을 입증하는 데이터를 확인할 수 있었습니다. 반면 로드 시점이 빠를수록 소비 지표가 개선될 것이라는 초기 가설과 달리 특정 시점(약 1,200px)을 기준으로 소비 지표는 오히려 감소했습니다. 이는 콘텐츠 소비 의도가 없는 사용자에게까지 피드가 노출되므로 해당 사용자들이 피드를 무시하거나 빠르게 이탈하는 행동 패턴에 기인한 것으로 판단하였습니다. 실험 데이터를 종합한 결과, 화면 아래에서부터 600&Tilde1,000px 구간에서 서치 피드를 호출하는 것이 가장 효율적이라는 결론을 내렸습니다. 현재 네이버 검색은 1,000px을 기준점으로 채택하여 서비스 안정성과 사용자 경험을 동시에 개선했으며, 향후 트래픽 변동이나 사용자 패턴 변화에 따라 데이터 기반으로 유연하게 조정할 계획입니다. 2024년 네이버 통합검색 성능 개선 사례 네이버 검색에서 2024년에 진행한 몇 가지 성능 개선 사례를 소개합니다. 지역플러스 영역 LCP 개선 네이버 검색의 지역플러스 영역은 장소 검색 시 표시되는 검색 결과로, 주로 이미지와 함께 노출됩니다. 2024년 5월 30일 업데이트로 LCP p95 수치가 3,000ms에서 2,000ms로 30% 개선되었습니다. 이러한 성능 개선의 핵심 요인은 CSS의 background-image 속성을 사용해 이미지를 로딩하는 방식에서 img 태그를 사용하는 방식으로 변경한 데 있습니다. 과거에는 Internet Explorer 환경에 대응해야 해서 object-fit 속성 대신 background-image 속성을 사용할 수 밖에 없었습니다. background-image 속성을 사용하여 이미지를 삽입하는 방식은 브라우저가 DOM 파싱이 완료된 후에 CSS 파일을 파싱하면서 이미지를 요청합니다. 그 결과 이미지가 렌더링되기까지 추가적인 지연이 발생하며, 이는 LCP를 악화시키는 요인이 될 수 있습니다. 반면 HTML의 img 태그를 활용하는 방식은 HTML 문서를 파싱하는 즉시 img 태그를 인식하고 리소스 요청을 시작합니다. 이 방식은 브라우저의 초기 파싱 단계에서 이미지를 빠르게 불러오므로 이미지 렌더링 시간을 단축하고 LCP에 긍정적인 영향을 줄 수 있습니다. 다음의 그림은 각 이미지 로딩 방식을 적용했을 때 브라우저에서 이미지를 로드하는 과정을 나타낸 것입니다. 파란색 박스로 표시된 영역은 img 태그를 사용했을 때, 빨간색 박스로 표시된 영역은 background-image 속성을 사용했을 때입니다. 이번 개선 사례에서 네이버 검색의 성능 최적화 작업이 단순히 코드 최적화나 서버 인프라 개선에 국한되지 않으며, 프런트엔드 렌더링 전략의 미세 조정만으로도 상당한 효과를 얻을 수 있다는 점을 확인할 수 있었습니다. 네이버 통합검색 성능 개선에 도움을 주신 플레이스 검색 서비스 개발팀의 이대희 님, 임지수 님(메인&플레이스UI개발)께 감사드립니다. 크리스마스 브랜드검색 Flicking 성능 개선 크리스마스 시즌 동안, 네이버 검색의 특정 영역에서 egjs/Flicking을 활용한 UI의 화면이 끊기는 현상이 발생했습니다. 비록 이 문제가 Web Vitals 지표에는 큰 영향을 주지 않았지만, 사용자 경험에는 부정적인 인상을 남길 가능성이 높았습니다. 문제의 원인을 분석한 결과, 첫 번째로 marginLeft 속성을 지속적으로 변경함에 따라 브라우저가 레이아웃 재계산(reflow)을 빈번하게 수행하게 된 부분이 큰 영향을 주었습니다. 이로 인해 화면의 애니메이션이 부드럽게 이어지지 않고 중간에 끊기는 현상이 발생했습니다. 두 번째로, 배경 이미지를 CSS의 background-image 속성으로 로드했기 때문에 이미지 로딩 속도가 상대적으로 느려졌습니다. 이 문제를 해결하기 위해 marginLeft 속성 대신 transform:transX 속성을 적용하도록 안내했습니다. 이렇게 하면 레이아웃을 재계산하지 않고 GPU 가속을 활용하므로 애니메이션이 훨씬 부드럽게 구현됩니다. 또한 앞선 사례에서 소개한 것처럼 background-image 속성 대신 img 태그 기반의 방식으로 이미지를 렌더링하도록 변경하여 이미지 로드 속도를 향상시켰습니다. egjs/Flicking을 사용할 때 marginLeft를 사용한 원인을 확인해 보니 Flicking 애니메이션 도중 Parallax 효과를 구현하기 위해서였습니다. 사실 egjs/Flicking에서도 Parallax 기능을 제공하고 있었지만 당시 제공된 문서의 설명이 충분하지 않아 실제 적용 과정에서 어려움을 겪었던 것으로 파악됩니다. 이에 따라 관련 내용을 보완하였으며, 이외에도 egjs/Flicking에서 제공하는 다양한 기능을 제대로 활용할 수 있도록 지속적으로 문서를 개선할 계획입니다. 마치며 2024년 네이버 통합검색의 웹 성능은 꾸준한 관리로 지속적인 개선을 이루었으며, LCP p95 기준 2.31초를 기록할 만큼 안정적인 수준에 도달했습니다. 또한, 새로운 서치 피드 서비스에 적합한 성능 지표인 FUPP와 FILT를 도입하여 무한 스크롤 영역의 성능을 보다 정밀하게 측정하고 최적화할 수 있었습니다. 앞으로 다음과 같은 부분을 개선할 계획입니다. SSR(ServerSideRendering)과 부분 CSR(ClientSideRendering)이 공존할 때 성능 측정 방법 개발 INP 지표 개선을 위한 추가 최적화 네이버 검색은 지속적으로 성능을 관찰하고 개선하여 더욱 빠르고 효율적인 검색 환경을 제공하고 사용자 경험을 극대화하는 것을 목표로 하고 있습니다.

기존 호텔 검색에서는 블로그에서 장소(Point of Interest, POI) 정보를 추출하고 다국어 음차 변환 및 번역을 수행하며 검색 키워드와 스니펫을 자동 생성하는 과정에서 대형 언어 모델(Large Language Model, 이하 LLM)을 활용했습니다. 하지만 LLM은 강력한 성능을 제공하는 대신 높은 연산 비용과 긴 응답 시간으로 인해 실시간 검색 서비스에 적용하기 어려웠습니다. 반면, sLLM(small large language model)은 빠르고 효율적이지만 성능이 낮아 검색 품질이 저하될 우려가 있었습니다. 호텔 검색에서는 다양한 블로그 데이터를 분석하고, 다국어 지원을 위해 번역 및 음차 변환을 수행해야 합니다. 이를 위해서는 LLM 기반의 자연어 처리 모델이 필수적이었습니다. LLM 수준의 성능을 소형 모델에서도 구현할 수 있다면 실시간 검색 품질을 유지하면서도 서버 부담을 줄일 수 있습니다. 이에 따라 플레이스 AI 팀은 지식 증류(Knowledge Distillation) 기법을 활용해 LLM의 성능을 유지하면서도 sLLM으로 최적화하는 기술을 연구했고, 그 결과 검색 품질을 유지하면서도 효율적인 서비스 운영이 가능해졌습니다. 구현 과정과 주요 도전 과제 Teacher와 Student 모델 선정 Teacher 모델의 성능이 학습 데이터 품질을 결정하고, Student 모델의 성능이 최종 검색 품질에 직접적인 영향을 미치기 때문에, 최적의 모델을 선정하는 것이 중요했습니다. 이에 따라 LLM as Judge 방식을 활용해 다양한 후보군을 평가한 뒤, 증류할 task에 대해 가장 성능이 뛰어난 Teacher와 Student 모델을 task마다 선정했습니다. 정확한 학습 데이터 확보 Teacher 모델에서 환각 현상(Hallucination)이 없는 학습 데이터를 추출하는 것이 핵심이었습니다. 이를 위해 정교한 프롬프트 엔지니어링을 적용하여 학습 데이터를 구성했습니다. 주요 설계 요소는 다음과 같습니다. task 설명과 구체적인 지침 제공 키워드 및 스니펫 생성 방식 가이드라인 적용 모델 응답의 형식과 구조 명확화 청중, 역할, 스타일 지침 제공 프롬프트 설계 시 OpenAI cookbook, Llama cookbook을 참고했습니다. 지식 증류 기법 개선 기존 방식으로는 sLLM이 LLM의 성능을 효과적으로 재현하기 어려웠기 때문에, 여러 단계에 걸쳐 증류 기법을 고도화했습니다. 1. 초기 접근 초기에는 SeqKLD 방식을 사용해 Label을 학습했지만, 기대만큼의 성능이 나오지 않았습니다. 2. 화이트박스 지식 증류 Label LM Loss와 함께 Logit 정보를 활용하는 방식도 시도했지만, 성능 향상 폭이 크지 않아 제외되었습니다. 3. 블랙박스 지식 증류 + 근거 학습 적용 Teacher 모델이 단순히 답을 제공하는 것이 아니라, 왜 그러한 답이 나왔는지에 대한 근거(Rationale)까지 학습하도록 설계했습니다. Label과 Rationale을 별도 Loss로 학습하는 Distilling Step-by-Step 방식을 적용하여 정교한 모델 성능을 확보하고, <|Label|>, <|Rationale|> 같은 특수 토큰을 추가해 학습 과정에서 구체적인 정보 구분이 가능하도록 했습니다. 4. 최적화 단계 학습 과정에서 Rationale 정보가 Label과 충돌하는 경우가 발생해, 이를 줄이기 위해 Label 정보를 Rationale 생성 단계에서 먼저 고려하도록 조정했습니다. 그 결과, 기존 Distilling Step-by-Step 방식 대비 모든 케이스에서 성능이 향상되었습니다(LLM as Judge). 5. 추가 시도 MoE(Mixture of Experts)와 MoE LoRA 방법도 적용해 보았습니다. MoE with LoRA가 가장 좋은 성능을 보였고 다중 작업 학습(multi-task learning)의 가능성을 확인할 수 있었습니다. 다만, 서비스에서는 개별 task의 성능이 서로 간섭하지 않아야 하므로 단일 작업 학습(single-task learning) 방식을 적용하고 있습니다. 마치며 이러한 과정을 통해 플레이스 AI 팀은 실시간 트래픽을 감당할 수 있는 sLLM을 성공적으로 개발해 서비스에 적용했고, 그 결과 LLM 수준의 검색 품질을 유지하면서도 보다 가벼운 시스템으로 서비스 운영이 가능해졌습니다. 앞으로도 지식 증류 기술을 지속적으로 연구하며 모델 성능과 서비스 품질을 더욱 향상시킬 예정입니다. 참고 문헌 Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena LLama cookbook, OpenAI cookbook Distilling Step-by-Step! Outperforming Larger Language Models with Less Training Data and Smaller Model Sizes DAN24; LLM, MULTI-MODAL MODEL로 PLACE VERTICAL SERVICE 개발하기 해당 글은 N INNOVATION AWARD 2024 특집편으로 수상작 'LLM과 함께 호텔 검색의 한계를 넘다'의 수상팀에서 작성해주셨습니다. N INNOVATION AWARD는 2008년부터 이어진 네이버의 대표적인 사내 기술 어워드로 매년 우수한 영향력과 성과를 보여준 기술을 선정하여 축하와 격려를 이어오고 있습니다.

호텔 검색을 고도화하기 위해서 검색 키워드 동의어·유의어 보강, 검색 문서 커버리지 확대, 질의와 연관된 콘텐츠 수급 기술이 필요했습니다. 이를 해결하기 위해 플레이스 AI 팀은 다국어 음차 변환 및 번역 모델 → POI 매칭 → 검색 키워드 및 스니펫 추출의 세 가지 단계를 포함한 프로젝트를 진행했습니다. POI: 사용자가 관심을 가질 만한 장소(Point of Interest)를 의미하며, 레스토랑, 호텔, 관광지, 쇼핑몰 등 다양한 유형의 장소를 포함합니다. 네이버 플레이스에서는 이러한 POI 정보를 체계적으로 관리하며 검색 및 리뷰 서비스에서 활용하고 있습니다. POI 매칭: 블로그, 결제 내역, 영수증, 리뷰 등에서 추출된 POI 정보를 네이버 플레이스 DB의 POI와 연결하는 과정입니다. 구현 과정과 주요 도전 과제 다국어 지원을 위한 음차 변환 및 번역 모델 적용 해외 호텔 및 명소 검색 시, 한국어, 영어, 일본어 등 다양한 언어로 입력된 질의가 일관된 검색 결과로 연결되지 않는 문제가 있었습니다. 기존 시스템은 CP사에서 제공한 필드만 색인하여 활용했기 때문에 다국어 질의 대응력이 부족했고, 이로 인해 일부 유명 업체조차 검색되지 않는 경우가 발생했습니다. 검색 키워드 동의어·유의어 보강을 위해 다국어 음차 변환 및 번역 모델을 적용하여 블로그 등에서 검색 대응 키워드를 확장했습니다. 이를 통해 언어별 명칭 차이로 인한 검색 누락을 방지하고 검색 결과의 일관성을 확보했습니다. 한국어 ↔ 일본어, 한국어 ↔ 로마자 음차 변환 모델을 도입하여 언어별 명칭 차이를 줄이고 검색 정확도를 향상시켰습니다. 업체 카테고리 기반 한국어 → 영어 번역 모델을 추가 개발하여 다국어 검색 커버리지를 확장했습니다. 또한, 호텔 검색, 어떻게 달라졌을까요? 2편 - 지식 증류의 기술을 적용하여 검색 품질을 전반적으로 개선했습니다. 이러한 개선을 통해 검색 결과의 일관성이 높아졌고, 글로벌 사용자가 원하는 정보를 더욱 쉽게 찾을 뿐만 아니라 한국어 사용자도 외국 명칭 검색 시 검색 누락 없이 직관적이고 정확한 결과를 경험할 수 있게 되었습니다. 다국어 지원 모델 도입 이후 검색 요청 수를 나타내는 QC(Query Count)와 고유 검색어 수를 의미하는 UQC(Unique Query Count)가 각각 3%와 13% 증가했습니다. QC와 UQC가 증가한다는 것은 검색에서 다루는 키워드가 다양해지고 더 많은 검색 요청이 발생한다는 의미입니다. 이로 인해 롱테일 검색어(수요는 적지만 누적되면 큰 트래픽을 유발하는 키워드)의 노출이 확대되면서 검색 커버리지가 자연스럽게 증가합니다. 또한, 해외 호텔 및 키워드별 평균 호텔 검색 커버리지도 약 3% 상승이 기대됩니다. POI 정보 추출 및 POI 매칭 호텔 검색의 커버리지를 확대하려면, 검색할 대상인 키워드를 다양하게 확보해야 합니다. 블로그에서 호텔 및 여행지 관련 POI 정보를 추출하고 이를 네이버 POI 플랫폼 DB와 매칭하여 키워드를 유입시킬 연결점을 만드는 것이 핵심 과제였습니다. 기존에는 Exact Match 방식을 활용하여 POI를 매칭했으나, 이 방식은 오탈자가 있거나 일부 데이터(주소, 전화번호 등)가 누락되면 정확한 매칭이 어렵다는 한계가 있었습니다. 예를 들어, 블로그에서 언급된 호텔 이름이 공식 명칭과 미세하게 다르거나, 결제 내역의 업체명이 네이버 플레이스 DB와 일치하지 않는 경우 POI가 제대로 매칭되지 않는 문제가 있었습니다. 이를 해결하기 위해 Dense Retrieval 기반의 POI 매칭 모델을 도입하여 POI 매칭의 정확도를 개선했습니다. 이 모델을 활용하면 블로그에서 추출한 업체명, 주소, 전화번호 등의 정보가 일부 불완전하더라도, 유사도를 분석하여 보다 정교한 매칭이 가능합니다. 이에 대한 자세한 내용은 POI 매칭 모델 구조에서 설명하겠습니다. 블로그에서 추출한 POI 데이터는 POI 플랫폼과 연동하여 검색 문서의 커버리지를 확대하고 검색 품질을 개선했습니다. 해외에서 새로운 POI를 발견하기 위해 블로그에서 업체명과 주소, 전화번호 등을 자동 추출한 후, 기존 DB와 비교하여 일치하는 POI가 없는 경우 신규 POI로 업데이트하는 기능도 적용했습니다. 개선 결과, 전 세계 호텔에 매칭한 블로그 수는 약 41만 개, 블로그에서 추출한 이미지는 380만 개로, 검색 결과의 커버리지가 대폭 확장되었습니다. POI 플랫폼은 단순한 텍스트 비교를 넘어, 플레이스 AI의 모델을 활용하여 POI 정보를 더욱 정교하게 매칭하고 신규 POI를 발견하는 기술 플랫폼으로 자리 잡았습니다. POI 매칭 모델 구조 POI 정보를 블로그에서 추출하더라도, 검색에 필요한 장소명, 주소, 전화번호 등의 정보가 누락되거나 오탈자가 포함될 수 있는데, 이런 경우 일반적인 BM25 검색 방식으로는 검색되기 어려웠습니다. 이를 해결하기 위해 정확도 92% 이상의 POI 매칭 성능을 가진 모델을 개발하여 도입했습니다. POI 매칭 모델은 Encoder → Retrieval → Reranker → Generator의 4단계 구조로 설계되었습니다. 1. Encoder 기존에 진행했던 Pairwise Supervised Contrastive Learning에 추가 loss를 적용함으로써 데이터 증강의 효과를 내서 인코더의 성능을 향상시켰습니다. 그 결과, Query POI 정보와 Target POI 정보를 인코딩하여 비교 가능한 벡터로 변환합니다. 2. Retrieval ANN 인덱스를 통해 Query POI와 가장 유사한 Target POI를 검색합니다. 3. Reranker Retrieval 단계에서 검색된 상위 10개의 Target POI 후보 중 가능성이 높은 상위 4개로 좁히는 Binary Classification 모델을 적용했습니다. 디코더는 bert 기반의 인코더, reranker보다 지식이 많고 성능이 좋기 때문에, 이런 디코더의 성능을 reranker에 증류하는 방식을 차용했습니다. 디코더가 정답이라고 한 후보군의 Logit을 reranker에서 높이도록 학습하여 reranker의 성능을 향상시켰습니다. 4. Generator(Decoder) Teacher Model에서 정답과 그에 상응하는 근거를 추출하여 Place sLLM 학습을 고도화했습니다. 플레이스 AI 팀에서 파인튜닝한 Q model-S 모델을 활용하여, Query POI와 reranker의 상위 4개 후보 POI를 입력으로 받아 최종 정답을 도출하는 구조로 설계했습니다. 검색 키워드 및 스니펫 추출 기존 모델은 UGC(User Generated Content) 기반 키워드 추출을 지원했지만, 자연어 처리(Natural Language Processing, NLP)의 품사 태깅을 활용하는 방식으로 인해 적합하지 않은 키워드가 다수 추출되고, 부정적인 의미로 사용된 키워드까지 포함되는 문제가 있었습니다. 또한, 특정 도메인(여행, 호텔)에 최적화되지 않아 검색 품질이 저하되었으며, 이로 인해 검색 결과의 직관성이 떨어지고 사용자들에게 명확한 정보를 제공하기 어려웠습니다. 개선된 모델에서는 Place sLLM을 학습하여 특정 도메인(여행, 호텔)에 적합한 키워드 및 스니펫을 추출하도록 설계했으며, 여러 POI 정보가 포함된 블로그 글에서도 특정 POI에 대한 키워드 및 스니펫만을 정확하게 추출할 수 있도록 최적화했습니다. 또한, 호텔 검색, 어떻게 달라졌을까요? 2편 - 지식 증류의 기술을 적용하여 검색 품질을 전반적으로 개선했습니다. 예를 들어, 사용자가 '가족 여행 추천 호텔'과 같은 특정 키워드로 검색할 때, 기존에는 블로그에서 연관된 키워드를 추출하고 이미지 검색 솔루션을 활용해 콘텐츠를 제공했지만, 이제는 Place sLLM을 통해 유의미한 검색 키워드만을 선별적으로 추출하고 추가로 키워드가 언급된 스니펫까지 함께 제공하여 검색 결과의 신뢰도를 높였습니다. 또한, 필터링 작업으로 저품질 키워드와 스니펫을 제거함으로써 보다 직관적이고 가독성 높은 검색 결과를 제공할 수 있도록 개선되었습니다. 이를 통해 국내 호텔 약 80만 개, 해외 호텔 41만 개의 검색 키워드 및 스니펫을 추출하여 약 3%의 검색 커버리지를 확대했습니다. 마치며 다국어 지원, POI 매칭, 검색 키워드 및 스니펫 추출 기술은 여행 검색 시스템의 핵심 성능을 좌우합니다. 다국어 지원은 글로벌 사용자뿐만 아니라, 한국어 사용자가 외국 명칭을 검색할 때 발생하는 언어별 명칭 차이를 해소해 검색 누락을 방지합니다. POI 매칭은 Exact Match 방식의 한계를 넘어 오탈자나 데이터 누락에도 정확한 장소 정보를 제공해 검색의 신뢰도와 효율을 높입니다. 검색 키워드 및 스니펫 추출은 도메인 특화 컨텍스트 분석을 통해 사용자 의도에 맞는 핵심 정보를 선별해 검색 결과의 가독성과 직관성을 개선합니다. 이 세 기술은 정확한 정보 전달을 통해 검색 경험을 혁신합니다. 앞으로도 POI 매칭 및 검색 성능을 지속적으로 최적화하여 사용자들이 원하는 정보를 더욱 빠르고 정확하게 찾을 수 있도록 개선해 나갈 예정입니다. 참고 문헌 Supervised Contrastive Learning Pairwise Supervised Contrastive Learning of Sentence Representations Re2G: Retrieve, Rerank, Generate 다운타임 없이 VectorDB 운영하기! DAN24; LLM, MULTI-MODAL MODEL로 PLACE VERTICAL SERVICE 개발하기 해당 글은 N INNOVATION AWARD 2024 특집편으로 수상작 'LLM과 함께 호텔 검색의 한계를 넘다'의 수상팀에서 작성해주셨습니다. N INNOVATION AWARD는 2008년부터 이어진 네이버의 대표적인 사내 기술 어워드로 매년 우수한 영향력과 성과를 보여준 기술을 선정하여 축하와 격려를 이어오고 있습니다.

검색 서비스는 사용자의 다양한 질의에 대응해야 하며, 새로운 검색 키워드가 지속적으로 추가됩니다. 특히, 이미지 검색에서는 단순한 키워드 기반 매칭이 아니라, 검색 의도에 맞춰 가장 적합한 이미지를 찾아 제공하는 것이 중요합니다. 기존에 공개된 CLIP(Contrastive Language-Image Pre-training) 모델은 일반적인 Text-Image Retrieval에는 활용될 수 있었지만, 플레이스(명소, 호텔, 관광지 등) 도메인에 최적화되지 않아 검색 품질이 충분하지 않았습니다. 특히, 대표 이미지가 특정 이미지로 고정되어 검색 질의와 관련 없는 이미지가 제공되는 경우가 많아 사용자 경험이 저하되었습니다. 또한, 기존 모델은 특정한 질의에 대해 유사한 이미지를 추천하는 데 제한이 있었으며, 새로운 키워드가 등장할 때마다 이미지 매칭이 원활하지 않았습니다. 이를 해결하기 위해 검색 시스템이 키워드뿐만 아니라 이미지 콘텐츠를 깊이 있게 이해하고 활용할 필요가 있었습니다. 이에 플레이스 AI 팀은 플레이스 특화 CLIP 인코더를 학습하여, 특정 도메인에서도 높은 zero-shot inference 성능을 보이는 모델을 구축하게 되었습니다. 이를 통해 단순한 이미지 검색이 아니라, POI 및 장소별 컨텍스트를 고려한 이미지 매칭이 가능해졌습니다. 구현 과정과 주요 도전 과제 멀티모달 검색을 위한 모델 개발 여행, 호텔, 관광지 등의 플레이스 도메인에 적합한 멀티모달 인코더를 개발하고, 검색 키워드와 이미지의 연관성을 학습하여 질의에 맞는 이미지를 검색 결과로 제공할 수 있도록 최적화했습니다. 예를 들어, '수영장'을 검색하면 수영장이 포함된 호텔이나 리조트의 이미지가 노출되도록 개선했습니다. 이를 위해 블로그 및 사용자 리뷰 데이터를 활용하여 실제 사용자 선호도를 반영한 이미지 랭킹 알고리즘을 개발했습니다. 파괴적 망각 문제 방지 기존의 CLIP 인코더는 새로운 도메인을 학습하면 기존의 정보를 잊어버리는 파괴적 망각(Catastrophic Forgetting) 문제가 발생했습니다. 이를 방지하기 위해 다음과 같은 기술을 적용했습니다. Layer-wise Discriminative Learning Rate 모델의 낮은 레이어에서는 기존에 학습된 일반적인 feature를 유지할 수 있도록 낮은 학습률(learning rate)을 적용했습니다. 이를 통해, 기존 모델의 성능을 유지하면서도 새로운 도메인 확장이 가능해졌습니다. Domain-Adaptive Pre-training Continual Pre-training of Language Models에서 소개된 DAS(Continual DA-pre-training of LMs with Soft-masking)를 바탕으로 Pretrained 모델에서 기존 지식에 견고한(robust) 유닛과 그렇지 않은 유닛을 학습 전에 판별한 뒤, 견고한 유닛에 신규 도메인 데이터를 추가 학습시키는 방식을 적용했습니다. 이 접근법을 통해 기존 backbone CLIP과 비교했을 때, ImageNet과 같은 General Knowledge 성능이 향상되었으며(기존 지식을 잊지 않음), 플레이스 도메인의 성능은 최소 20%에서 최대 67%까지 개선되었습니다. 또한, 학습 과정에서 지속적인 모델 평가 및 파인튜닝을 적용하여, 기존 도메인의 성능을 유지하면서 신규 도메인 적응력을 높였습니다. 클래스 확장 시 필요한 이미지 수 최소화 새로운 도메인을 빠르게 확장하기 위해, 적은 이미지 수로도 높은 성능을 유지할 수 있는 기법을 연구했습니다. 각 도메인의 이미지들을 클러스터링하고 대표적인 centroid 이미지들로 학습 데이터를 구성함으로써 적은 데이터로도 도메인 내 다양한 분포를 반영할 수 있도록 했습니다. 실험 결과, 클래스별 20장의 이미지만 학습해도 전체 데이터셋을 학습한 경우와 큰 성능 차이가 없음을 확인하여, 현재 클래스별 20장의 이미지로 효과적인 도메인 확장을 진행 중입니다. 전체 데이터셋 클래스별 50장 클래스별 20장 클래스별 10장 한국음식(Acc@1) 85.98% 85.44% 85.40% 82.13% 숙박업체 내 시설(Acc@1) 96.68% 94.88% 94.72% 93.02% 마치며 이러한 개선을 통해, 플레이스 AI 팀은 기존 대비 검색 결과의 시각적 품질을 크게 향상시킬 수 있었습니다. 멀티모달 인코더 적용 후, 대표 이미지 검색의 정확도가 상승했으며 사용자 경험이 한층 더 직관적이고 풍부해졌습니다. 또한, 검색 결과에서 보다 직관적이고 연관성 높은 이미지를 제공하여 사용자의 체류 시간을 증가시키는 효과를 얻을 수 있었습니다. 향후에는 이미지 검색의 다양성을 더욱 높이고, 사용자 선호도 기반의 개인화된 이미지 추천 시스템을 도입하여 검색 경험을 개선할 예정입니다. 참고 문헌 Layer-wise Discriminative Learning Rate Continual Pre-training of Language Models DAN24; LLM, MULTI-MODAL MODEL로 PLACE VERTICAL SERVICE 개발하기 해당 글은 N INNOVATION AWARD 2024 특집편으로 수상작 'LLM과 함께 호텔 검색의 한계를 넘다'의 수상팀에서 작성해주셨습니다. N INNOVATION AWARD는 2008년부터 이어진 네이버의 대표적인 사내 기술 어워드로 매년 우수한 영향력과 성과를 보여준 기술을 선정하여 축하와 격려를 이어오고 있습니다.

네이버는 호텔 검색이 다루는 범위를 대폭 확대하고 더 풍성한 결과를 제공하기 위해, 검색 엔진을 전환하고 대형 언어 모델(Large Language Model, 이하 LLM)을 도입했습니다. 그 결과 검색 품질과 사용자 경험 모두에서 큰 변화를 이끌어냈습니다. 이 글에서는 기존 문제점과 이를 해결하기 위한 접근법, 그리고 얻은 결과를 실제 사례와 함께 살펴보겠습니다. 문제와 해결 기존 호텔 검색 엔진은 짧은 질의에는 강하지만 다루는 범위는 좁았기에 새로운 유형의 질의 대응이 필요했습니다. 이를 위해 '호텔 검색 의도가 있지만 호텔 검색 결과가 노출되지 않는 질의'를 찾고 우선순위를 정하는 것이 핵심 과제였습니다. 블로그로 유입된 질의 중 호텔 관련 글을 클릭한 질의를 수집하고, LLM을 활용해 호텔 검색 의도가 있는 질의만 선별했습니다. 이렇게 확보한 데이터를 기반으로 검색 품질을 개선했습니다. 블로그에서 장소(Point of Interest, 이하 POI) 기본 정보를 추출해 네이버 POI 플랫폼과 매핑하여 검색 결과의 커버리지를 확장하고, 다국어 음차 변환 및 번역 모델을 도입해 언어 장벽을 해결했습니다. 또한, 키워드 및 스니펫 자동 추출, 이미지 검색 개선을 통해 검색 품질을 높였습니다. 예시를 소개드릴게요. 문제 1: 복잡한 검색 의도 처리의 어려움 예시: '도쿄 수영장이 있는 깨끗한 호텔' 같은 복잡한 질의는 기존 검색 엔진으로 검색할 수 없었습니다. 해결: LLM을 활용해 사용자의 검색 질의를 정밀하게 분석해 복잡한 질의 처리 능력을 강화했습니다. 블로그에서 POI 정보를 추출해 네이버 POI 플랫폼과 매핑했고, 블로그 글로부터 검색에 사용할 키워드('수영장', '깨끗한' 등)를 폭넓게 확보했습니다. 이를 통해 관련 호텔이 풍부하게 검색되고 검색 근거도 노출될 수 있도록 개선했습니다. 더불어 LLM을 이용해 오타와 정타 데이터를 생성 및 학습하여 오타 교정 기능도 탑재했습니다. 문제 2: 다국어 검색의 한계 예시: '호텔 한큐 레스파이어 오사카'는 한국인 여행자들에게 인기가 많은 호텔인데요, 영문명인 'Hotel Hankyu Respire Osaka'를 흔히 '호텔 한큐 리스파이어 오사카'라고 읽기도 하지만 '리스파이어'라는 한글 키워드가 없기 때문에 '한큐 리스파이어 오사카'로는 검색할 수 없었습니다. 해결: 다국어 음차 변환과 번역 모델을 도입해 한국어뿐 아니라 영어, 일본어 등 다양한 언어의 호텔 명칭을 발음하는 방식을 고려해 키워드를 확장했습니다. 이제 해외 호텔 검색도 훨씬 쉬워졌습니다. 문제 3: 콘텐츠 및 시각적 정보의 부족 예시: 사용자가 '도쿄 야경 호텔'을 검색했을 때에는 대표 사진에 글자만 잔뜩 나열된 결과보다는 야경이 보이는 사진, 야경과 관련된 리뷰 등 관련도 높은 직관적인 정보를 원할 것이라고 생각했습니다. 해결: 블로그 데이터를 활용해 키워드와 스니펫을 자동 추출하고 이미지 검색을 강화했습니다. 사용자가 사진과 함께 원하는 정보를 한눈에 파악할 수 있도록 개선했습니다(서비스 반영 준비 중). 문제 4: 튼튼한 시스템을 구축하고 검색 품질 유지하기 POI 데이터 관리를 XBU(eXtended Business Utility, 국내/해외 POI와 관련 데이터 통합 관리 및 파이프라인 운영 플랫폼)로 전환하여 글로벌 확장성을 확보하고 증분 색인 시스템을 구축해 실시간 검색 반영이 가능하도록 했습니다. 클라우드 서빙 플랫폼 CLOUS3.0을 통해 검색 인프라를 자동화하여 보다 튼튼한 검색 시스템을 구축했습니다. 검색 엔진을 기존 Elastic Search에서 네이버 자체 검색 엔진 Nexus로 전환해 질의 분석 정확도를 높이고, 보다 복잡한 질의에 유연하게 대응할 수 있도록 했습니다. 마지막으로, 검색 품질을 유지하기 위해 자동 품질평가 도구를 구축했습니다. 검색 질의에 대한 적절한 검색 결과를 벤치마크 데이터로 만들고, 품질 비교 및 데일리 모니터링을 통해 검색 로직의 타당성을 지속적으로 검증하고 있습니다. 네이버 호텔 검색, 이렇게 달라졌습니다 전년 동일 월 대비 다음과 같은 성과를 얻었습니다. 클릭 수: 전년 대비 70% 상승 – 더 많은 사용자가 원하는 결과를 클릭했습니다. 호텔 예약 건수: 비수기임에도 불구하고 19% 증가 – 검색 품질 개선이 곧 예약으로 이어졌습니다. 사용자 수 증가: 모바일 기준 16% 증가 - 더 많은 사용자가 호텔 검색을 경험했습니다. 검색 커버리지: UQC(Unique Query Count) 450% 상승, QC(Query Count) 157% 상승 – 호텔 검색의 대응 범위가 효과적으로 확대되었습니다. 다국어 검색 성능 개선: 다국어 대응 전후 UQC 13% 상승, QC 3% 상승 – 다국어 질의 처리로 대응 질의가 확대되었습니다. 시각적 정보 강화: 이미지 검색으로 사용자 경험이 한층 풍부해졌습니다. 마치며 네이버 호텔 검색을 개선하는 과정에서 여러 기술적 도전을 겪었습니다. 이에 대한 자세한 내용은 다음 글에서 이어서 설명하겠습니다. LLM 성능을 sLLM(small-Large Language Model)로 압축하며 성능을 유지해야 함 지식 증류(Knowledge Distillation) 기법과 고도의 프롬프트 엔지니어링을 활용 블로그에서 추출한 POI 정보가 불완전하거나 오타가 있는 경우 매칭이 어려움 고밀도 검색(Dense Retrieval) 기반의 POI 매칭 시스템을 도입하고 Milvus DB를 활용해 정확도 92% 이상 증가 새로운 데이터를 학습하면 기존 지식을 잊는 파괴적 망각(Catastrophic Forgetting) 문제 발생 Layer-wise Discriminative Learning Rate과 Domain-Adaptive Pre-training으로 해결 저희는 LLM 기술을 활용해 사용자 중심의 호텔 검색 경험을 지속적으로 혁신해 나가고 있습니다. 곧이어 반응형 이미지/리뷰 스니펫 결과를 서비스 오픈할 예정이고, 검색 컨텐츠의 커버리지도 지속적으로 높여갈 예정입니다. 호텔 검색에 이어 2025년 올해 저희의 목표는 여행 검색 결과를 개선하는 것입니다. 여행 준비가 네이버를 통해 더 편리하고 스마트해질 수 있도록, 앞으로도 기술적 발전과 사용자 경험 개선에 집중하겠습니다. 해당 글은 N INNOVATION AWARD 2024 특집편으로 수상작 'LLM과 함께 호텔 검색의 한계를 넘다'의 수상팀에서 작성해주셨습니다. N INNOVATION AWARD는 2008년부터 이어진 네이버의 대표적인 사내 기술 어워드로 매년 우수한 영향력과 성과를 보여준 기술을 선정하여 축하와 격려를 이어오고 있습니다.

주요내용 25년 3월 소식에서는 다음과 같은 유용한 정보들을 만나보실 수 있습니다. 2024 Frameworks Year in Review Netlify의 웹덕후들이 2024년의 흐름 그리고 25년도에 대한 기대를 정리해 줍니다. How long is a second in JavaScript? JavaScript에서 '초'의 길이와 시간 측정의 역사와 과학적 배경을 탐구해 봅니다. Optimizing the Critical Rendering Path 웹 페이지의 초기 렌더링을 지연시키는 주요 자원들을 식별하고, 이를 최적화하여 사용자 경험을 향상시키는 방법을 다룹니다. Sunsetting Create React App React 공식 팀이 Create React App(CRA)을 더 이상 유지하지 않기로 결정했습니다. >> FE News 25년 3월 소식 보러가기 ◎ FE News란? 네이버 FE 엔지니어들이 엄선한 양질의 FE 및 주요한 기술 소식들을 큐레이션해 공유하는 것을 목표로 하며, 이를 통해 국내 개발자들에게 지식 공유에 대한 가치 인식과 성장에 도움을 주고자 하는 기술소식 공유 프로젝트 입니다. 매월 첫째 주 수요일, 월 1회 발행 되고 있으니 많은 관심 부탁드립니다. ▷ 구독하기

주요내용 25년 2월 소식에서는 다음과 같은 유용한 정보들을 만나보실 수 있습니다. Cascading Spy Sheets: Exploiting the Complexity of Modern CSS for Email and Browser Fingerprinting Javascript와 Cookie 없이 CSS만으로 브라우저 지문 채취의 가능함이 증명되었습니다 The Ai-Assisted Developer Workflow: Build Faster and Smarter Today AI를 활용한 다양한 개발 도구가 어떻게 개발 방식을 변화시킬 수 있는지 알아봅니다. Toss Frontend Fundamentals 토스의 프런트엔드 개발자들이 공개한 변경하기 쉬운 프론트엔드 코드를 위한 지침서 Do JavaScript frameworks still need portals? Dialog, popover, CSS anchor positioning 등의 기능으로 Portal(React), Teleport(Vue) 를 대체하는 방법을 소개합니다. >> FE News 25년 2월 소식 보러가기 ◎ FE News란? 네이버 FE 엔지니어들이 엄선한 양질의 FE 및 주요한 기술 소식들을 큐레이션해 공유하는 것을 목표로 하며, 이를 통해 국내 개발자들에게 지식 공유에 대한 가치 인식과 성장에 도움을 주고자 하는 기술소식 공유 프로젝트 입니다. 매월 첫째 주 수요일, 월 1회 발행 되고 있으니 많은 관심 부탁드립니다. ▷ 구독하기

이 글에서는 리눅스 커널 기능인 Control Groups(이하 cgroups)에 대해서 간단히 알아보고, Kubernetes(이하 k8s)가 cgroups를 어떻게 사용하는지 살펴보겠습니다. Kubernetes와 cgroups 간의 관계를 이해하는 데 도움이 되기를 바랍니다. cgroups란 cgroups는 시스템에서 실행되는 여러 프로세스를 그룹으로 묶고, 각 그룹이 사용할 수 있는 CPU, 메모리, I/O, 네트워크 등의 자원 사용을 제한하고 격리하는 리눅스 커널 기능입니다. 이 글에서는 여러 자원 중 k8s와 관련이 깊은 CPU와 메모리 자원에 대해 살펴보겠습니다. 출처: How I Used CGroups to Manage System Resources 리눅스에서 cgroups를 사용하는 방법에는 여러 가지가 있지만 여기에서는 간단한 cgroupfs를 통해서 진행해 보겠습니다.(이 글에서는 cgroups v1을 이용합니다.) 셸에서 mount 명령어를 실행하면 다음과 같이 cgroups를 사용하기 위한 가상의 파일 시스템이 있는 것을 볼 수 있습니다. 디렉터리를 만들거나 파일 내용을 수정하는 것으로 cgroups의 기능을 사용할 수 있습니다. $ mount ... cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct) cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio) cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids) ... /sys/fs/cgroup 하위 디렉터리를 보면 다음과 같이 다양한 자원을 볼 수 있습니다. 이 글에서는 이 중에서 cpu,cpuacct와 memory 디렉터리만을 사용하겠습니다. $ ll /sys/fs/cgroup dr-xr-xr-x 6 root root 0 6월 19 15:11 blkio lrwxrwxrwx 1 root root 11 6월 19 15:11 cpu -> cpu,cpuacct dr-xr-xr-x 6 root root 0 6월 19 15:11 cpu,cpuacct lrwxrwxrwx 1 root root 11 6월 19 15:11 cpuacct -> cpu,cpuacct dr-xr-xr-x 3 root root 0 6월 19 15:11 cpuset dr-xr-xr-x 6 root root 0 6월 19 15:11 devices dr-xr-xr-x 3 root root 0 6월 19 15:11 freezer dr-xr-xr-x 3 root root 0 6월 19 15:11 hugetlb dr-xr-xr-x 6 root root 0 6월 19 15:11 memory lrwxrwxrwx 1 root root 16 6월 19 15:11 net_cls -> net_cls,net_prio dr-xr-xr-x 3 root root 0 6월 19 15:11 net_cls,net_prio … 메모리 설정 우선 간단히 설정할 수 있는 메모리부터 알아보겠습니다. /sys/fs/cgroup/memory 하위에 test1 디렉터리를 만들었는데요, 이것만으로 하나의 cgroup이 만들어집니다. 디렉터리 안의 내용을 보면 여러 설정값이 있습니다. $ sudo mkdir /sys/fs/cgroup/memory/test1 $ ll /sys/fs/cgroup/memory/test1 … -rw-r--r-- 1 root root 0 8월 6 15:23 cgroup.clone_children --w--w--w- 1 root root 0 8월 6 15:23 cgroup.event_control -rw-r--r-- 1 root root 0 8월 6 15:23 cgroup.procs -rw-r--r-- 1 root root 0 8월 6 15:23 memory.failcnt --w------- 1 root root 0 8월 6 15:23 memory.force_empty -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.failcnt -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.limit_in_bytes -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.max_usage_in_bytes -r--r--r-- 1 root root 0 8월 6 15:23 memory.kmem.slabinfo -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.tcp.failcnt -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.tcp.limit_in_bytes -rw-r--r-- 1 root root 0 8월 6 15:23 memory.kmem.tcp.max_usage_in_bytes -r--r--r-- 1 root root 0 8월 6 15:23 memory.kmem.tcp.usage_in_bytes -r--r--r-- 1 root root 0 8월 6 15:23 memory.kmem.usage_in_bytes -rw-r--r-- 1 root root 0 8월 6 15:23 memory.limit_in_bytes -rw-r--r-- 1 root root 0 8월 6 15:23 memory.max_usage_in_bytes -rw-r--r-- 1 root root 0 8월 6 15:23 memory.memsw.failcnt ... 이 중에서 k8s와 관련된 값은 다음 3가지입니다. memory.limit_in_bytes: 프로세스의 메모리 사용량이 이 값을 초과하면 시스템이 해당 프로세스의 작업을 중단시키거나 오류 발생(기본값은 uint64 max) memory.soft_limit_in_bytes: 프로세스의 메모리 사용량이 이 값을 일시적으로 초과하는 것은 허용, 지속적인 초과는 금지(기본값은 uint64 max) tasks: cgroup에 속한 프로세스 ID(기본값은 없음) 위 설정값을 변경하면서 어떻게 동작하는지 실험해보겠습니다. 우선 100MB의 메모리를 차지하는 프로세스를 백그라운드로 하나 생성합니다. $ stress --vm 1 --vm-bytes 100M & 이제 memory.limit_in_bytes 값을 1MB로 설정하고, tasks에는 이 프로세스의 ID를 써보겠습니다. 파일 내용을 수정하면 cgroup 설정이 변경되므로 매우 편리합니다. OOM이 발생하여 프로세스가 종료되는 것을 볼 수 있습니다. memory.limit_in_bytes 값을 초과하는 프로세스는 바로 중단되기 때문입니다. $ echo 1048576 | sudo tee memory.limit_in_bytes $ echo {PROCESS ID} | sudo tee tasks $ stress: FAIL: [42715] (415) <-- worker 42716 got signal 9 stress: WARN: [42715] (417) now reaping child worker processes stress: FAIL: [42715] (451) failed run completed in 15s [1]+ Exit 1 stress --vm 1 --vm-bytes 100M 그렇다면 k8s에서 메모리 설정은 cgroup 설정과 어떤 관련이 있을까요? 실험을 위해 간단한 Pod를 하나 실행해 보겠습니다. apiVersion: v1 kind: Pod ... resources: requests: memory: "512Mi" limits: memory: "768Mi" k8s는 위 Pod를 위해서 아래 위치에 cgroup 하나를 생성합니다.(위치는 k8s 버전에 따라 다를 수 있습니다.) /sys/fs/cgroup/memory/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podbea85cd8_f91e_45e8_8570_505487b16d77.slice/crio-d8bad6a478b6b509661a9ad2c76d189b0659c75eb2ee5ca5ba93fe87ab093b65.scope/container 위 디렉터리로 이동해서 메모리 관련 croup 설정이 어떻게 변경되었는지 살펴보겠습니다. memory.limit_in_bytes의 값은 limits.memory에 설정된 768Mi입니다. limits 값을 초과하면 Pod이 종료되어야 한다는 것을 생각해보면 당연합니다. $ cat memory.limit_in_bytes 805306368 <- 768Mi 그렇다면 memory.soft_limit_in_bytes 값은 어떻게 설정되었을까요? limits.requests에 설정된 512Mi로 예상하셨겠지만 의외로 기본값인 uint64 max가 들어 있습니다. docker는 --memory-reservation 옵션으로 memory.soft_limit_in_bytes 값을 설정할 수 있지만, k8s는 이를 cgroup 설정에 사용하지 않고 Pod 스케쥴링 시에만 참고한다고 합니다. $ cat memory.soft_limit_in_bytes 9223372036854771712 <- (uint64 max) CPU 설정 다음으로 CPU 설정 테스트를 위해 /sys/fs/cpu,cpuacct 하위에 test2 디렉터리를 만들어서 또 다른 cgroup을 만들어 보겠습니다. $ sudo mkdir /sys/fs/cpu,cpuacct/test2 $ ll /sys/fs/cpu,cpuacct/test2 -rw-r--r-- 1 root root 0 7월 4 18:38 cgroup.clone_children -rw-r--r-- 1 root root 0 7월 4 18:38 cgroup.procs -rw-r--r-- 1 root root 0 7월 4 18:38 cpu.cfs_period_us -rw-r--r-- 1 root root 0 7월 4 18:38 cpu.cfs_quota_us -rw-r--r-- 1 root root 0 7월 4 18:38 cpu.rt_period_us -rw-r--r-- 1 root root 0 7월 4 18:38 cpu.rt_runtime_us -rw-r--r-- 1 root root 0 7월 4 18:38 cpu.shares -r--r--r-- 1 root root 0 7월 4 18:38 cpu.stat -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.stat -rw-r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_all -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_percpu -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_percpu_sys -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_percpu_user -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_sys -r--r--r-- 1 root root 0 7월 4 18:38 cpuacct.usage_user -rw-r--r-- 1 root root 0 7월 4 18:38 notify_on_release -rw-r--r-- 1 root root 0 7월 4 18:38 tasks 이번에도 많은 설정이 보이지만 이중에서 k8s와 관련된 4가지 값만 알아보겠습니다. cpu.cfs_period_us: CPU 자원에 대한 액세스를 재할당하는 주기(기본값 100000 = 0.1초) cpu.cfs_quota_us: 할당된 CPU 사용 시간(기본값 -1). 할당된 CPU 사용 시간을 모두 사용한 경우 나머지 시간 동안 CPU 사용이 제한됨(CPU 스로틀링). cpu.shares: 다른 group에 비해 상대적인 CPU 사용량(기본값 1024) tasks: cgroup에 속한 프로세스 ID(기본값은 없음) cpu.cfs_period_us에 설정된 시간 안에는 cpu.cfs_quota_us에 설정된 시간 동안만 CPU 자원을 제한 없이 사용할 수 있고 나머지 시간에는 CPU 사용량이 제한됩니다. 예를 들어 cpu.cfs_period_us가 100000(100ms)이고 cpu.cfs_quota_us가 50000(50ms)이면, 100ms 중 50ms 동안은 CPU 자원을 제한 없이 쓸 수 있고 나머지 50ms 동안은 절반의 CPU 자원만 쓸 수 있습니다. 이 설정이 잘 적용되는지 테스트해 보겠습니다. 테스트에 사용한 장비는 4코어입니다. 메모리 테스트에서 사용한 stress 프로그램으로 4코어 장비에서 4개 코어를 모두 쓰도록 프로세스를 4개 실행했습니다. 4개 코어 모두 100% 사용되는 것을 볼 수 있습니다. 이 상태에서 프로세스 하나만 100000μs(100ms) 중 50000μs(50ms) 동안만 CPU를 쓸 수 있도록 변경해 보겠습니다. $ echo 100000 | sudo tee cpu.cfs_period_us $ echo 50000 | sudo tee cpu.cfs_quota_us $ echo {SECOND PROCESS ID} | sudo tee tasks 2번째 프로세스가 CPU 자원을 절반만 사용하는 것을 볼 수 있습니다. CPU 관련 설정은 하나 더 있습니다. cpu.shares는 cgroup 간의 상대적인 CPU 사용량을 나타냅니다. 예를 들어 cgroup이 4개 있는데 모두 cpu.shares 값이 1024라면 각 cgroup은 CPU 자원을 1/4씩 사용합니다. 1024/(1024+1024+1024+1024) = 25% 만약 다음 그림처럼 특정 cgroup의 cpu.shares 값을 1536으로 증가시키면 해당 cgroup은 1536/(1536+512+1024+1024) = 37.5%의 CPU 자원을 사용할 수 있습니다. 다시 실험을 해보겠습니다. 4코어 장비에 cgroup을 2개 만들고 각 cgroup마다 4개의 프로세스를 실행해서 전체 CPU 자원을 다 소모할 정도로 부하를 준 다음 다음과 같이 설정을 변경했습니다.(cpu.cfs_period_us와 cpu.cfs_quota_us는 기본값인 100000과 -1 사용) $ echo 1024 | sudo tee cpu.shares $ echo {FOUR PROCESS IDS} | sudo tee tasks $ echo 512 | sudo tee cpu.shares $ echo {OTHER FOUR PROCESS IDS} | sudo tee tasks CPU 사용률이 1024/(512+1024) = 66.6%와 512/(512+1024) = 33.3%로 나뉘는 것을 볼 수 있습니다. 그렇다면 CPU와 관련된 cgroup 설정은 k8s에서 어떻게 적용될까요? Pod을 실행해서 cgroup 설정을 비교해 보겠습니다. cpu.cfs_period_us의 k8s 기본값은 100000(100ms)이고, Pod manifest로는 이 값을 변경할 수 없습니다. cpu.cfs_quota_us 값은 limits.cpu 값으로 결정됩니다. 예를 들어 limits.cpu 값을 "4000m"로 설정하면 최대 4코어의 CPU 자원을 사용할 수 있으며 cpu.cfs_quota_us 값은 400000(400ms)이 됩니다. 만약 limits.cpu를 설정하지 않으면 cpu.cfs_quota_us는 -1이 됩니다. 이는 제한이 없다는 의미입니다. cpu.shares 값은 requests.cpu 밀리코어 값을 1000으로 나누고 1024를 곱한 값입니다. 예를 들어 requests.cpu를 "2000m"로 설정하면 cpu.shares 값은 2000/1000*1024=2048이 됩니다. apiVersion: v1 kind: Pod ... resources: requests: cpu: ”2000m" <-- cpu.shares 2048 limits: cpu: ”4000m" <-- cpu.cfs_period_us 100000 (k8s 기본값) cpu.cfs_quota_us 400000 (만약 4코어 장비라면 -1로 설정한것과 같음) 이 밖에도 CPU의 특정 코어만 독점해서 쓸 수 있는 cpuset 기능도 있지만 이 글에서는 생략하겠습니다. 관심 있는 분은 아래 문서를 참고해 주세요. CPUSETS Kubernetes v1.31: New Kubernetes CPUManager Static Policy: Distribute CPUs Across Cores 마치며 k8s가 cgroups를 어떻게 사용하는지 간단히 알아보았습니다. 끝으로 짧은 k8s 운영상의 경험을 말씀드리면서 글을 마무리하고자 합니다. requests.memory 값은 limits.memory 값과 같게 설정합니다. 두 값을 다르게 한다고 자원을 아껴 쓸 수 있는 것은 아닙니다. API 서버처럼 latency가 중요한 경우 CPU 사용량 제한 때문에 응답 시간이 늦어지면 안 되므로 limits.cpu 값을 설정하지 않습니다. 이렇게 해도 request.cpu(cpu.shares) 비율대로 Pod 간 CPU 자원이 배분되므로 다른 프로세스의 자원을 과도하게 빼앗지는 않습니다.

Noir는 Go로 작성한 개별 데이터 특화 검색 엔진으로, 메일처럼 사용자마다 데이터가 분리된 서비스에 효과적입니다. 대표적으로 네이버 메일, 메시지 검색 서비스 등이 Noir를 사용하고 있습니다. 수년간 Noir를 개발 및 운영하는 동안 Noir 서버 메모리 사용량이 시간이 갈수록 천천히 증가하는 현상이 자주 관찰되었고 이를 해결하기 위해 큰 노력을 기울였습니다. Go는 가비지 컬렉터(GC)가 있는 언어로, 개발자가 메모리 관리에 크게 신경 쓰지 않아도 된다는 장점이 있습니다. 하지만 개발자가 메모리 관리에 개입할 수 있는 여지가 적기에, Noir에서 발생한 메모리 사용량 증가 현상은 해결하기 까다로운 이슈였습니다. 다음 그래프는 실제 프로덕션에 배포된 Noir 서버 일부의 메모리 사용량 그래프입니다. 시간이 갈수록 애플리케이션의 메모리 사용량이 늘어납니다. 사용량이 줄어드는 부분은 운영자가 배포 등의 이유로 검색 서버를 재시작하여 메모리 할당이 해제된 경우입니다. 해당 문제의 원인을 파악하기 위해 여러 실험을 해본 결과 원인은 크게 두 가지로 나눌 수 있었습니다. cgo를 사용하는 경우 Go가 아닌 C 언어가 관리하는 메모리가 생기기 때문에 메모리 누수 발생 가능 애플리케이션이 메모리를 많이 사용한다면 GC에 의해 메모리가 할당 해제되는 속도보다 할당되는 속도가 빠를 수 있음 이번 글에서는 Noir의 메모리 사용량 증가 현상을 해결하기 위해 사용한 방법 중 일부를 소개합니다. RES와 heap 해결 방법을 살펴보기 전에 애플리케이션 프로세스의 RES와 heap에 대해서 알아보겠습니다. 출처: Memory - HPC @ QMUL RES(Resident Memory Size, Resident Set Size, RSS)는 프로세스가 실제로 사용하고 있는 물리 메모리의 크기입니다. OS는 프로세스가 요청한 메모리를 실제로 사용하기 전까지 물리 메모리를 할당하지 않습니다. 따라서 프로세스가 사용하는 메모리(virtual memory) 크기와 실제로 사용하는 물리 메모리 크기(RES)에는 차이가 있습니다. RES는 여러 프로세스와 함께 사용하는 공유 라이브러리(shared libraries)와 프로세스가 요청하여 사용하는 메모리(actual ram usage)로 나뉩니다. RES는 top 명령어로 확인할 수 있습니다. heap은 프로세스가 런타임에 데이터를 저장하기 위해 OS로부터 할당받는 메모리입니다. 프로세스는 런타임에 저장하는 데이터가 많아지면 OS로부터 메모리를 할당받고 할당받은 메모리가 필요 없어지면 할당 해제합니다. heap의 구현은 언어마다 다르며 Go의 경우 TCmalloc에 영향을 받은 런타임 메모리 할당 알고리즘을 사용하고 있습니다. Go 애플리케이션의 heap 크기는 runtime 패키지를 사용하거나 GODEBUG=gctrace=1 환경 변수를 설정해서 확인할 수 있습니다. 간단히 말해, RES는 OS가 관리하는 메모리와 관련된 개념이고 heap은 Go가 관리하는 메모리입니다. 그리고 RES는 heap뿐만 아니라 프로세스가 동작하는 데 필요한 모든 메모리의 크기를 말합니다. 즉, RES 값과 heap 크기의 차이가 크다면, 프로세스 내 Go가 관리하지 않는 곳에서 사용하는 메모리 때문일 수 있습니다. Go 버전에 따른 커널의 메모리 동작 Go 애플리케이션의 RES 값을 확인할 때, Go 버전에 따라 RES 값이 달라질 수 있다는 점을 알아두어야 합니다. 리눅스는 madvise라는 시스템 콜을 제공합니다. madvise에 어떤 값을 설정하느냐에 따라 커널이 프로세스의 메모리를 관리하는 정책이 달라집니다. Go 1.12부터 1.15까지는 madvise에 MADV_FREE를 사용하고 그 전 버전이나 그 후 버전은 MADV_DONTNEED 을 사용합니다. MADV_FREE는 성능이 좋은 대신 메모리 압력이 없는 한 커널이 메모리 할당 해제를 미룹니다. 반면 MADV_DONTNEED는 지정한 메모리 영역의 semantic을 변경하여 커널의 메모리 할당 해제를 유도합니다. 다음 그래프는 Go로 작성한 GraphQL DB인 Dgraph가 데이터를 주기적으로 로드할 때 MADV_DONTNEED를 적용한 경우(왼쪽)와 MADV_FREE를 적용한 경우(오른쪽)의 메모리 사용량을 비교한 것입니다. 출처: Benchmarks using GODEBUG=madvdontneed environment variable - Dev - Discuss Dgraph 왼쪽은 데이터 로드 이후 사용한 메모리를 커널이 할당 해제해 메모리 사용량이 감소하는 구간이 존재합니다. 반면 오른쪽은 커널이 메모리를 할당 해제하지 않아 메모리 사용량이 감소하는 구간이 없습니다. 만약 사용하고 있는 Go 버전이 1.12 이상 1.15 이하라면 메모리가 heap에서 할당 해제되었어도 커널이 할당 해제하지 않아 RES에 포함될 수 있습니다. Noir는 Go 최신 버전을 사용하기 때문에 이 이슈에는 해당하지 않았습니다. valgrind를 통한 cgo 코드의 메모리 누수 탐지 Noir는 RES 값과 heap 크기의 차이가 컸습니다. 그래서 메모리 사용량 증가 현상이 Go가 관리하는 메모리가 아닌 부분에서 발생할 확률이 높다고 추측했습니다. Noir는 C++로 작성된 라이브러리를 사용합니다. 보통 Go에서 C 코드를 다루기 위해서는 cgo를 사용합니다. cgo를 사용하면 C++처럼 메모리 할당 및 할당 해제를 개발자가 직접 해야 하는데, 실수로 메모리를 할당 해제하지 않는다면 메모리 누수가 일어날 수 있습니다. 메모리 누수 탐지에 유용한 도구 중 하나는 valgrind입니다. 다만 빌드된 Go 프로그램 바이너리를 그대로 valgrind에 사용하면 많은 경고가 발생합니다. Go는 GC 언어인데, valgrind가 Go 런타임 동작을 정확히 알 수 없기 때문입니다. 경고를 최소화하기 위해 cgo 코드만 사용하는 테스트 코드를 따로 빌드하여 valgrind에 사용하면 메모리 누수를 쉽게 탐지할 수 있습니다. 테스트 코드를 빌드 후 valgrind를 적용하여 다음과 같은 결과를 확인했습니다. ... ==15605== LEAK SUMMARY: ==15605== definitely lost: 19 bytes in 2 blocks ==15605== indirectly lost: 0 bytes in 0 blocks ==15605== possibly lost: 3,552 bytes in 6 blocks ==15605== still reachable: 0 bytes in 0 blocks ==15605== suppressed: 0 bytes in 0 blocks possibly lost는 Go 런타임이 관리하는 메모리를 valgrind가 경고한 것으로, 메모리 누수가 아닙니다.(실행 중인 스레드에 할당된 메모리이므로 잃을 수 있는 '가능성'이 있다고 경고하는 것입니다.) 하지만 definitely lost는 프로그램에는 더 이상 포인터가 존재하지 않는데 가리키는 heap의 메모리 할당이 해제되지 않았을 때 발생하는 오류로, 명백한 메모리 누수입니다. 확인 결과 cgo 코드에서 String 객체를 할당한 후 할당 해제하는 로직이 빠져 있었습니다. 할당 해제 함수를 추가한 후 valgrind를 다시 실행하여 definitely lost 오류를 해결했습니다. ... ==25027== LEAK SUMMARY: ==25027== definitely lost: 0 bytes in 0 blocks ==25027== indirectly lost: 0 bytes in 0 blocks ==25027== possibly lost: 2,960 bytes in 5 blocks ==25027== still reachable: 0 bytes in 0 blocks ==25027== suppressed: 0 bytes in 0 blocks GC 주기에 따른 애플리케이션의 메모리 사용량 변화 GC 주기를 잘 설정하면 애플리케이션 코드를 변경하지 않고도 메모리 사용량을 크게 낮추고 메모리 사용량 증가 문제도 해결할 수 있습니다. 예를 들어 GC가 메모리를 할당 해제하는 속도에 비해 heap에 메모리가 할당되는 속도가 빨라 메모리 사용량이 증가하는 경우 GC 주기 조절이 해결법이 될 수 있습니다. GC의 주기를 단순히 짧게 만들어서 해결되는 것은 아닙니다. 오히려 GC가 너무 자주 일어나면 오버헤드가 커져 메모리 사용량이 더 빠르게 증가할 수도 있습니다. Go의 GC는 크게 Mark와 Sweep의 두 단계로 구성됩니다. 그리고 mark 단계는 STW(stop the world)가 발생하는 구간과 그렇지 않은 구간으로 나눌 수 있어, GC 로그에서 3개의 단계를 확인할 수 있습니다. 보통 Mark and Sweep 방식을 떠올리면 먼저 Mark가 이루어지고 그 후 Sweep이 이루어지는 것으로 생각합니다. 하지만 Go의 GC 가이드 문서나 gctrace 문서를 보면 Sweep(STW 발생) → Mark and Scan → Mark 종료(STW 발생) 순서로 동작하는 것을 알 수 있습니다. 실제 GC 로그를 살펴보겠습니다(Go 1.21 기준). gc 45093285 @891013.129s 8%: 0.54+13+0.53 ms clock, 21+0.053/17/0+21 ms cpu, 336->448->235 MB, 392 MB goal, 0 MB stacks, 0 MB globals, 40 P gc 45093285: 45093285번째 GC 로그입니다. @891013.129s: 프로그램이 시작된 지 891013.129초가 지났습니다. 8%: 프로그램 시작 이후 GC가 사용한 시간의 비율입니다. 0.54+13+0.53 ms clock: wall-clock 시간으로 Sweep(STW) 0.54ms, Mark and Scan 13ms, Mark 종료(STW) 0.53ms 걸렸습니다. 21+0.053/17/0+21 ms cpu: CPU 시간으로 Sweep(STW) 21ms, Mark and Scan(allocation 0.053ms, background 17ms, idle 0ms), Mark 종료(STW) 21ms 걸렸습니다. 336 → 448 → 235MB: 각각 GC 시작 시 heap 크기, GC가 끝났을 때 heap 크기, live heap 크기를 의미합니다. 392MB: 이번 GC 시작 시 목표 heap 크기입니다. GC는 시작 전 목표 heap 크기를 설정하고 heap 크기를 그 이하로 줄이려고 합니다. 위 로그는 GC가 끝난 후 heap 크기가 448MB이므로 목표 heap 크기인 392MB보다 크므로 목표를 달성하지 못했습니다. Mark 중에 애플리케이션이 메모리를 할당할 수 있기 때문에 위 로그처럼 목표 달성에 실패할 수도 있습니다. GC가 끝난 후 live heap 크기(위 로그에서 235MB)를 기준으로 다음 목표 heap 크기가 정해지고, heap 크기가 다음 목표 heap 크기를 초과할 것으로 예상될 때 다음 GC가 발생합니다. GC가 자주 일어나면 GC가 사용한 시간의 비율(로그의 3번째 칼럼)이 높아집니다. GC가 자원을 많이 사용하면 애플리케이션의 로직이 사용할 자원이 적어지므로 GC 주기를 적절히 조절할 필요가 있습니다. GC 주기를 조절할 수 있는 파라미터 Go에서 GC를 조절할 수 있는 파라미터는 GOGC와 GOMEMLIMIT입니다. GOGC GOGC는 목표 heap 크기를 조절할 수 있는 파라미터입니다. 목표 heap 크기는 다음 식에 따라 설정됩니다. 목표 heap 크기 = live heap + (live heap + GC roots) * GOGC / 100 GOGC 기본값은 100으로 설정되어 있습니다. live heap이 비해 충분히 GC roots가 작다면 다음 목표 heap 크기는 live heap의 2배 정도라고 생각할 수 있습니다. 예를 들어 위에서 본 로그에서 다음 목표 heap 크기는 235MB의 두 배인 470MB 정도로 설정됩니다. 실제로 다음 GC 로그를 보면 470MB와 유사한 471MB로 설정된 것을 확인할 수 있습니다. gc 45093286 @891013.160s 8%: 0.39+7.5+0.75 ms clock, 15+1.8/16/0+30 ms cpu, 406->467->196 MB, 471 MB goal, 0 MB stacks, 0 MB globals, 40 P GOGC를 높이면 목표 heap 크기는 증가하고 GC 주기는 길어집니다. GOGC를 낮추면 목표 heap 크기가 작아지고 GC 주기가 짧아집니다. GOMEMLIMIT GOGC만 사용해 목표 heap 크기를 조절하면 문제가 발생할 수 있습니다. 예를 들어 순간적으로 메모리를 많이 사용하는 애플리케이션은 OOM이 발생하지 않도록 GOGC를 낮게 설정해야 하는데, 그러면 피크가 발생하지 않는 시간에는 낮은 GOGC로 인해 GC가 너무 자주 일어날 수 있습니다. 이 경우 목표 heap 크기의 상한값을 설정하면 GOGC를 높게 설정할 수 있습니다. 목표 heap 크기의 상한값을 설정하는 파라미터가 GOMEMLIMIT입니다. GOGC와 GOMEMLIMIT에 따른 애플리케이션의 메모리 사용량 변화 테스트 환경에서 GOGC와 GOMEMLIMIT을 변화시키며 Noir의 메모리 사용량 변화를 측정했습니다. Noir에 약 2주간 다량의 검색 요청을 보내고 gctrace 로그로 heap 크기 변화를 확인했습니다. 아무것도 설정하지 않은 경우(GOGC=100) 시간이 갈수록 heap 크기가 증가했습니다. 반면 GOGC를 높게 설정하면 heap 크기 증가 현상이 사라졌습니다. GC CPU 사용량 live heap 평균값 GC 시작 시 heap 평균값 live heap 실험 전후 변화량 GC 직전 heap 실험 전후 변화량 GOGC=50 9% 342.01MiB 470.22MiB -4.26MiB -4.6MiB GOGC=50, GOMEMLIMIT=800MiB 17% 640.48MiB 697.12MiB -19.23MiB -12.35MiB GOGC=100(기본값) 8% 149.95MiB 260.16MiB 32.01MiB 55.79MiB GOGC=200 6% 102.95MiB 273.76MiB 0.95MiB 2.96MiB GOGC=200, GOMEMLIMIT=800MiB 5% 99.94MiB 267.46MiB -0.87MiB -0.89MiB GOGC=300 4% 98.45MiB 358.30MiB -1.08MiB -2.67MiB GOGC=300, GOMEMLIMIT=800MiB 4% 95.71MiB 350.82MiB 0.63MiB 4.44MiB GOGC=400 3% 97.60MiB 451.43MiB 1.79MiB 6.5MiB GOGC=400, GOMEMLIMIT=800MiB 3% 90.74MiB 425.45MiB -3.2MiB -10.29MiB GOGC=600 2% 93.91MiB 623.61MiB 1.75MiB 9.48MiB GOGC=600, GOMEMLIMIT=800MiB 8% 500.11MiB 677.98MiB -91.57MiB -33.89MiB GOGC가 100일 때와 600일 때의 heap 크기 그래프를 비교하면 GOGC가 100일 때 heap 크기 증가가 두드러지는 반면 600은 증가가 거의 없습니다. 표를 보면 메모리 사용량 증가 외에도 주목해 볼 만한 지점이 있습니다. GOGC가 작을수록 GC가 자주 일어나 GC가 사용하는 CPU 사용률이 높아지지만, heap 크기는 커지는 경향을 보입니다. GOMEMLIMIT은 GOGC 값이 적당한 경우에는 heap 크기를 줄여주지만(GOGC=200, 300, 400), GOGC가 극단적이면(GOGC=50, 600) CPU 사용량과 함께 heap 크기가 오히려 늘어나는 모습을 보입니다. 따라서 GOGC와 GOMEMLIMIT을 적용할 때는 꼭 실험을 통해 적당한 값을 찾은 후 적용해야 합니다. 프로파일러를 통한 과다 메모리 사용 탐지 Go 애플리케이션에 heap 프로파일링을 적용하면 불필요한 메모리 사용이 있는 코드 부분을 탐지할 수 있습니다.(Go 애플리케이션의 프로파일링은 프로파일링 적용기 - 당신의 Go 애플리케이션은 좀 더 나아질 수 있다에서 자세히 설명합니다.) 먼저 Go의 메모리 allocator의 동작을 살펴보겠습니다. 출처: Visualizing memory management in Golang mheap은 Go 프로그램의 모든 heap 메모리 공간을 관리합니다. Resident set은 페이지들(일반적으로 8KB)로 나뉘고, mheap은 mspan과 mcentral을 통해 페이지들을 관리합니다. mspan은 가장 기본이 되는 구조체입니다. mspan은 다음 그림처럼 이중 연결 리스트(doubly linked list)로 이뤄지고 시작 페이지의 주소, span 크기 클래스, span에 속한 페이지 개수 정보가 저장됩니다. 출처: Visualizing memory management in Golang mcentral은 같은 크기 클래스의 두 개의 mspan 리스트를 관리합니다. 사용하고 있는 mspan의 리스트와 그렇지 않은 mspan 리스트를 관리하고, 가지고 있는 모든 mspan 리스트를 사용하면 mheap에 추가 페이지를 요청합니다. 객체가 할당되는 방식은 객체의 크기에 따라 달라집니다. ~ 8byte: mcache의 allocator가 할당 8byte~32KB: mspan의 크기 클래스(8byte ~ 32KB)에 속해 span에 할당 32KB~: mheap이 페이지를 직접 할당 이때 span에 할당되는 객체나 mheap에 의해 직접 할당되는 객체는 내부 단편화(internal fragmentation)가 일어날 가능성이 높습니다. 예를 들어볼까요. 다음은 8byte~32KB 크기의 객체가 할당되는 golang의 크기 클래스 중 일부입니다. // class bytes/obj bytes/span objects tail waste max waste min align ... // 56 12288 24576 2 0 11.45% 4096 // 57 13568 40960 3 256 9.99% 256 13KB 객체를 할당한다고 가정해봅시다. 13KB는 12288byte보다 크고 13568byte보다 작으므로 객체는 57번째 크기 클래스에 할당됩니다. 할당된 후 객체가 사용하는 메모리의 비율은 13000/13568 = 95.8%입니다. 약 4.2%의 내부 단편화가 발생합니다. 크기 클래스와 span 사이에서 발생하는 단편화도 존재합니다. 57번째 크기 클래스는 40960byte span에 3개가 들어갑니다. 3개의 클래스가 span에 채워져도 남는 메모리 40960-3*13568 = 256byte가 발생합니다(tail waste). 극단적으로 단 한 개의 13KB 객체만 생성한다면 40960byte의 span을 13KB 객체 하나만 사용하므로 약 68%의 내부 단편화가 발생합니다. 이번에는 35KB 객체를 할당하는 경우를 생각해봅시다. 크기가 32KB 이상인 객체는 페이지를 직접 할당하므로 크기 클래스를 사용하지 않습니다. // mcache.go // allocLarge allocates a span for a large object. func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan { if size+_PageSize < size { throw("out of memory") } npages := size >> _PageShift if size&_PageMask != 0 { npages++ } // Deduct credit for this span allocation and sweep if // necessary. mHeap_Alloc will also sweep npages, so this only // pays the debt down to npage pages. deductSweepCredit(npages*_PageSize, npages) spc := makeSpanClass(0, noscan) ... } 32KB 이상의 객체를 할당하는 allocLarge 함수는 객체 크기에 맞는 페이지 개수(npage)를 계산합니다. 그 후 해당 객체를 위해 span을 만듭니다(makeSpanClass). OS의 페이지 크기가 8KB인 경우 35KB가 사용하는 페이지 개수는 5개이고 총 메모리는 40KB입니다. 이 경우 35KB/40KB = 87.5%이므로 12.5%의 내부 단편화가 발생합니다. 이제 8byte 이상의 객체의 경우 내부 단편화가 발생할 수 있음을 알았습니다. Go는 8byte부터 32KB까지 총 67개의 크기 클래스를 촘촘하게 존재하여 너무 큰 객체가 아니라면 대부분은 내부 단편화가 문제 되지 않습니다. 하지만 불필요한 메모리 복사 등 잘못된 코드 로직이 중첩된다면 영향이 커질 수 있습니다. 애플리케이션에 내부 단편화가 발생할 가능성이 있는 객체를 쉽게 확인하는 방법이 있습니다. heap 프로파일링을 수행하면 다음 그림처럼 각 함수가 사용하는 객체의 개수(inuse_objects)와 객체들이 사용하는 메모리 크기(inuse_space)를 알 수 있습니다. 이를 이용하면 (객체들이 사용하는 메모리 크기)/(객체의 개수)로 객체 크기의 평균을 계산할 수 있습니다. 앞서 보았다시피 크기가 큰 객체의 개수가 많다면 내부 단편화가 메모리의 사용량에 영향을 끼칠 가능성이 높습니다. 그래서 애플리케이션의 메모리 최적화 작업을 진행할 때 크기가 큰 객체를 사용하는 함수 위주로 객체 할당을 줄이는 것이 좋습니다. 객체는 내부적으로 span에 할당되기 때문에, 같은 크기의 수많은 객체를 순차적으로 할당 후 일부만 사용하는 것도 지양해야 합니다. 포인터를 사용해 할당되는 객체를 분리하더라도 위험합니다. 다음 코드는 16byte 객체를 가리키는 포인터를 슬라이스로 할당 후 인덱스가 512의 배수인 포인터만 사용하는 예입니다. // Allocate returns a slice of the specified size where each entry is a pointer to a // distinct allocated zero value for the type. func Allocate[T any](n int) []*T // Copy returns a new slice from the input slice obtained by picking out every n-th // value between the start and stop as specified by the step. func Copy[T any](slice []T, start int, stop int, step int) []T // PrintMemoryStats prints out memory statistics after first running garbage // collection and returning as much memory to the operating system as possible. func PrintMemoryStats() // Use indicates the objects should not be optimized away. func Use(objects ...any) func Example3() { PrintMemoryStats() // (1) heapUsage: 0.41 MiB, maxFragmentation: 0.25 MiB slice := Allocate[[16]byte](1 << 20) Use(slice) PrintMemoryStats() // (2) heapUsage: 24.41 MiB, maxFragmentation: 0.24 MiB badSlice := Copy(slice, 0, len(slice), 512) slice = nil Use(slice, badSlice) PrintMemoryStats() // (3) heapUsage: 16.41 MiB, maxFragmentation: 16.19 MiB newSlice := Allocate[[32]byte](1 << 19) Use(slice, badSlice, newSlice) PrintMemoryStats() // (4) heapUsage: 36.39 MiB, maxFragmentation: 16.17 MiB Use(slice, badSlice, newSlice) } 출처: Memory Fragmentation in Go | Standard Output badSlice가 일부 포인터만 복사했지만 16*512=8KB(페이지 크기)이므로 페이지마다 사용하는 16byte 객체가 하나씩 남게 됩니다. 그 결과 포인터가 가리키는 객체가 사용하는 span은 하나도 할당 해제되지 못합니다. (2)와 (3)을 비교하면 포인터가 사용하는 메모리 8MB만 할당 해제되고 객체가 사용하는 메모리는 할당 해제되지 못하여 16MB 단편화가 발생하는 것을 확인할 수 있습니다. Noir의 경우 프로파일링을 통해 크기 13KB 이상의 객체를 다량으로 할당하는 것을 발견했습니다. 해당 부분에 불필요한 메모리 복사 등이 존재함을 확인했고 수정하여 전체 메모리 사용량의 30%를 줄일 수 있었습니다. 마치며 이 글에서 설명한 작업의 결과로 Noir는 메모리 사용량 증가 현상을 고쳤을 뿐만 아니라 메모리 사용량도 줄일 수 있었습니다. Go 프로그램의 메모리 문제로 골머리를 앓는 다른 분들께도 이 글이 도움이 되기를 바랍니다.
![[DAN 24] 데이터 기반으로 지속 성장이 가능한 네이버 검색 FE 시스템 구축하기](https://d2.naver.com/content/images/2025/01/01-2.png)
네이버 검색 FE 시스템 구축 과정에서 저희는 다음과 같은 문제에 직면했습니다. 첫째, 유사하지만 영역이 다른 경우 비슷한 작업을 반복하는 경우가 있었습니다. 검색의 각 영역은 마이크로서비스 아키텍처(MSA)로 이루어져 있으며 클라이언트 코드 역시 영역별로 관리되고 있었습니다. 이로 인해 영역 간 동일한 유형의 작업을 반복해야 했고, 경험이 축적되기보다는 업무의 성장이 저해되는 결과를 초래했습니다. 둘째, 유사한 UI를 매번 새로 개발해야 하는 비효율성이 존재했습니다. 코드의 재활용이 어려워서 유사한 패턴의 UI를 구현할 때마다 새로운 코드를 작성해야 했고, 이는 자원의 낭비로 이어졌습니다. 셋째, 데이터가 부족해 개선 작업이 어려웠습니다. 개선이 이루어져도 이를 측정할 수 있는 데이터가 부족해서 실제 효과를 파악하기 어려웠습니다. 그 결과, 어디부터 개선해야 할지, 문제는 없을지 등을 판단하기가 어려워 개선 작업의 방향성을 설정하기 어려웠습니다. 넷째, 피드백 주기가 지나치게 길다는 문제가 있었습니다. 디자인이 실제 코드로 구현되어 동작하기까지의 시간이 너무 오래 걸렸고, 이 과정 중에 디자인이 수정되면 재작업이 많아지는 비효율적인 상황이 발생했습니다. 이 글에서는 이 문제들을 해결하기 위한 저희의 해결 방안과 현재 고민하고 있는 문제를 공유하겠습니다. 해결 방향 이러한 문제를 해결하기 위해 저희가 생각한 해결 방향은 다음과 같습니다. 첫째, 서버 주도 UI(Server Driven UI) 방식을 도입했습니다. 비슷한 작업이 반복되는 문제를 해결하기 위해 UI를 한곳에 모으고 이를 재활용할 필요가 있었습니다. 서버에서는 비즈니스 로직만 구현하고 UI를 생성하는 서버를 별도로 두는 것이 적합하다고 판단하여 서버 주도 UI 방식을 채택했습니다. 이를 통해 클라이언트 측에서 UI를 반복해 개발할 필요 없이 서버에서 다양한 상황에 맞춰 유연하게 UI를 전달할 수 있게 되었습니다. 이는 비슷한 개발 작업을 줄이고 유지 보수의 효율성을 크게 향상시켰습니다. 둘째, 디자인 시스템(Design System)을 통한 해결책을 마련했습니다. 기존에는 UI가 유사해도 개발자가 달라서 별도로 개발하여 중복이 발생했다면, 이제는 자주 사용되는 UI 요소를 일관된 디자인 시스템으로 구축하여 재사용이 가능하도록 만들었습니다. 이는 개발자와 디자이너 모두에게 큰 도움이 되었으며, UI 개발 시간을 단축시키고 중복된 노력을 최소화할 수 있었습니다. 셋째, 데이터 기반의 접근 방식을 도입했습니다. 현재 데이터가 부족해 개선 작업이 어렵다는 문제를 해결하기 위해, 더 많은 데이터를 수집하고 이를 바탕으로 피드백 루프를 강화하는 계획을 수립했습니다. 특히 현재 사용되고 있는 UI를 분석하고 그 데이터를 수집하여, 이후 개발 및 운영 과정에 적극적으로 활용하고자 했습니다. 이를 통해 서비스에 많이 사용되는 컴포넌트와 모듈을 수집하고 사용자의 패턴을 파악하며 어떤 UI가 효과적인지에 대한 통찰을 얻어 UI와 UX를 최적화할 수 있게 되었습니다. 더 나아가, 데이터 분석 결과는 새로운 기능 개발뿐만 아니라 기존 UI의 개선 작업에도 큰 도움이 되었습니다. 마지막으로, Design To Code를 통해 제한된 환경에서의 자동화를 도입했습니다. 긴 피드백 주기를 줄이기 위해, 디자인을 코드로 변환하는 과정을 자동화하는 시스템을 적용하여 디자인과 개발의 간극을 줄였습니다. 이로 인해 디자이너가 수정한 내용을 신속하게 반영할 수 있게 되었고, 개발자는 재작업을 줄이며 효율성을 극대화할 수 있게 되었습니다. Flexible Rendering Engine 해결 방안의 첫 번째 주제인 유연한 렌더링 엔진(Flexible Rendering Engine), 서버 주도 UI와 디자인 시스템에 대해 설명하겠습니다. 서버 주도 UI(Server-Driven UI) 템플릿 및 컴포넌트 기반의 한계 2021년에 처음 도입한 템플릿 기반 방식은 미리 정의된 몇 가지 템플릿을 사용하여 신속하게 화면을 구성해 개발 속도를 향상시킬 수 있었습니다. 그러나 다양한 UI 요구 사항에 맞춰 동적으로 변화하기에는 한계가 있었습니다. 새로운 요구 사항이 발생할 때마다 템플릿을 수정하거나 새로 생성해야 했기 때문에, 마치 정해진 레고 블록으로만 조립해야 하는 것처럼 유연성이 부족했습니다. 그래서 저희는 컴포넌트 기반으로 전환했습니다. 컴포넌트 기반은 UI를 작은 단위로 분할하여 재사용이 가능했고 확장성 측면에서 이점이 있었습니다. 하지만 과도하게 세분화된 컴포넌트 구조로 인해 데이터 전송량이 크게 증가했고 서버와 클라이언트 간의 통신이 복잡해졌습니다. 그 결과, 컴포넌트 구조에서는 성능 저하와 복잡성 문제가 발생했습니다. 개발 방식 장점 단점 템플릿(블록) UI 표현이 적음데이터만 변경 생산성 확장성 컴포넌트 UI 표현이 많음데이터와 함께 조립 확장성 생산성 템플릿 방식과 컴포넌트 방식 모두 각기 다른 한계가 있었기에, 저희는 새로운 접근이 필요하다는 결론을 내렸습니다. Sweet Spot 찾기: 모듈 도입 템플릿 기반은 고정된 틀 안에서 신속하게 UI를 구성할 수 있었으나 확장성이 부족했고, 반면 컴포넌트 기반은 지나치게 많은 컴포넌트를 사용하면서 복잡성과 성능 저하 문제를 겪었습니다. 이러한 상황에서 저희는 이 두 가지 방식의 중간 지점, 즉 스위트 스폿(sweet spot)을 찾기 위해 모듈이라는 개념을 도입했습니다. 모듈은 템플릿보다는 유연하고, 컴포넌트보다는 덜 복잡한 단위입니다. 이를 통해 다양한 상황에서도 적절한 수준의 UI 조립이 가능하고 확장성과 성능을 모두 확보할 수 있다는 장점이 있습니다. 저희는 블록의 크기와 복잡도의 기준을 큰 틀에서는 정했지만 상세한 규칙은 만들지 않았습니다. 이는 서비스가 변화하면서 함께 변화하는 영역이라 저희가 제어하기 쉽지 않았고, 너무 제한된 상황을 만들 수 있었기 때문입니다. 대신 적절한 기준으로 모듈화를 하고, 이후 데이터를 기반으로 모듈을 설정하며, 데이터를 분석하면서 블록 수준을 조정해 최적의 구조를 찾아가는 방식을 선택했습니다. Fender(Flexible Rendering)의 구조 유연한 렌더링 엔진은 JSON 데이터를 통해 클라이언트의 UI를 동적으로 제어하는 서버 주도 UI의 서버입니다. 서버는 UI의 각 블록을 정의하여 클라이언트에 전송하고 클라이언트는 이 데이터를 받아 실시간으로 UI를 렌더링합니다. 이 구조의 중요한 특징은 모든 UI 요소를 자유롭게 변화시키는 것이 아니라, 적정한 수준에서 블록 단위로 제어한다는 점입니다. 이를 통해 저희는 복잡성을 줄이면서도 유연성을 확보할 수 있었습니다. 예를 들어, 서버가 특정 조건에 맞춰 JSON 데이터를 전송하면 클라이언트는 별도의 추가 개발 없이 즉시 UI를 변경할 수 있습니다. 유연한 렌더링의 주요 장점은 크게 세 가지입니다. 서버에서 UI를 제어하므로 유연하게 UI를 업데이트할 수 있다. 블록 단위로 제약을 두어 복잡성을 줄이고 성능을 최적화한다. 다양한 상황에서 블록을 재활용해 개발 효율성을 높일 수 있다. 높은 재사용성(High Portability) 이러한 방식으로 저희는 다양한 UI 요구 사항에 유연하게 대응하면서도 성능과 효율성을 모두 확보할 수 있었습니다. 대표적인 사례로 최근에 배포된 숏텐츠 서비스를 들 수 있습니다. 숏텐츠 서비스는 문서형 콘텐츠를 개인화된 키워드 방식으로 쉽게 보여주는 서비스로, 모바일, PC 등 여러 플랫폼의 검색, 메인 등 다양한 지면에서 활용되고 있습니다. 숏텐츠는 제공되는 각 지면마다 폰트 크기나 패딩, 버튼 등의 스타일이 조금씩 다르지만 대부분의 구조는 유사합니다. 각각 독립적으로 개발했을 때의 장점도 있지만, 다양한 지면에서 재사용함으로써 효율성을 증대시켰습니다. 디자인 시스템 레거시 시스템에 디자인 시스템을 도입하기 위한 전략 처음 디자인 시스템을 도입할 당시의 상황을 살펴보면, 이미 많은 곳에 디자인 시스템이 적용되어 다양한 사례가 있었지만 저희 팀은 경험이 없었습니다. 또한 Figma와 같은 도구가 편의성을 제공했지만 해당 도구에 대한 이해도가 높지 않은 상황이었습니다. 이미 성공한 사례는 많았기에 저희는 실패 사례에 초점을 두고 리서치를 진행했습니다. 대부분의 실패는 시스템을 만드는 것보다 적용하는 과정에서 발생했습니다. 디자인과 개발 사이의 간극으로 인한 이슈, 디자인 시스템을 만드는 사람과 사용하는 팀 사이의 한계가 주요 문제점으로 드러났습니다. 이러한 부분을 고려하여 저희는 디자인 시스템을 완벽하게 만드는 것보다 서비스에 디자인 시스템을 적용하는 것에 집중했습니다. 따라서 서비스 전체를 대표하는 디자인 시스템보다는, 당장 개발해야 하는 서비스를 기반으로 필요한 부분을 디자인 시스템으로 구축했습니다. 또한 개발자와 디자이너가 함께 협의하고 수준을 맞추기 위해 노력했으며, 적용 비용을 낮추기 위해 디자인 시스템을 만드는 사람과 사용하는 사람을 구분하지 않기로 했습니다. 디자인 시스템 구축 vs 서비스 개발: 무엇이 먼저인가 앞서 언급한 것처럼, 디자인 시스템을 도입할 때 저희는 디자인 시스템 구축과 서비스 개발의 우선순위에 대해 많은 고민을 했습니다. 디자인 시스템을 먼저 완성한 후 서비스에 적용하는 것이 이상적이겠지만, 당시 저희는 디자인 시스템 구축 경험이 부족했기에 디자인 시스템을 먼저 완성한 후에 적용하는 것은 비현실적이라는 결론에 도달했습니다. 그래서 저희는 서비스 개발 과정에 디자인 시스템을 점진적으로 도입하는 전략을 선택했습니다. 특히 통합검색 개편 프로젝트에서 새로운 영역을 대상으로 디자인 시스템을 적용해나갔는데, 이는 필요한 컴포넌트를 개발하고 디자인 시스템을 도입하는 데 적절한 테스트베드 역할을 했습니다. 또한, 초기에는 모든 컴포넌트를 직접 만들기보다는 외부 라이브러리인 Bootstrap을 활용해 빠르게 결과물을 확인하며 디자인 시스템의 가능성을 신속하게 검증했습니다. 그리고 점차 저희만의 컴포넌트를 추가하면서 시스템을 확장해나갔습니다. 디자인 시스템 구축 vs 디자인 시스템 사용: 함께 혹은 따로 디자인 시스템을 도입할 때, 저희는 초기 단계에서 시스템을 만드는 사람과 사용하는 사람을 구분하지 않고 동일한 팀에서 함께 일하는 방식을 택했습니다. 이는 디자인 시스템을 빠르게 적용하는 데 효과적이어서 초기에는 매우 유용했지만, 디자인 시스템의 품질을 향상시키는 데는 한계가 있었습니다. 그 이유는 개발자가 서비스 개발에 집중하면서 디자인 시스템 자체를 개선하기 어려웠기 때문입니다. 디자인 시스템의 문서화나 자체 완성도 측면에서 부족함이 발생했고 이는 생산성에도 영향을 미치기 시작했습니다. 이후 저희는 역할을 분리하는 전략을 도입했습니다. 디자인 시스템을 만드는 팀과 이를 사용하는 팀을 분리함으로써 시스템의 품질을 높이고 각 팀이 자기 역할에 집중할 수 있었습니다. 만드는 팀과 사용하는 팀이 같은 팀에서 일했던 경험이 있었기에, 역할을 분리한 이후에도 서로 잘 이해하고 원활하게 협업할 수 있었습니다. 이를 통해 시스템 적용 과정에서 큰 문제 없이 시스템의 품질을 지속적으로 향상시킬 수 있었습니다. 협업의 비용을 낮추기 위한 노력 협업 비용을 낮추기 위해 같이 일했고, 각 영역의 품질을 높이기 위해 분리했습니다. 협업이 원활했지만 협업 비용을 완전히 해소하기는 어려웠기에 프로세스를 더욱 체계화하여 생산성과 효율성을 높였습니다. 디자인 시스템에 없는 컴포넌트는 서비스에서 필요에 따라 빠르게 만들고, 이후 이를 디자인 시스템으로 이관. 비동기(async)적으로 동기화. 컴포넌트의 조합인 모듈은 디자인 시스템 모듈을 공통화하되, 서비스 개발 시 편의성을 위해 컴포넌트를 조합하여 템플릿으로 만든 후, 이후에 데이터를 바탕으로 비동기적으로 동기화. 즉, 디자인 시스템과 서비스를 정확하게 맞추기보다는 개발 과정에서 생산성으로 높이고 기술적으로 빈 공간을 업데이트하여 효율적인 구조를 만들기 위해 노력했습니다. 또한, 기존에 개발자와 디자이너가 수시로 함께 확인하고 피드백을 주고받으며 디자인 시스템을 구축해나가는 것은 효과적이었지만, 디자이너가 Figma를 업데이트할 때마다 개발자가 디자인 시스템에 반영하면서 많은 비용이 소요되었습니다. 이러한 문제를 해결하기 위해 Figma API를 활용해 주기적으로 업데이트 사항을 모아서 알림을 주는 도구를 개발했습니다. 이를 통해 반복되거나 불필요한 협업 포인트를 줄이고 변경 사항을 놓치지 않을 수 있게 되었습니다. 현재 해결하고 있는 고민 저희는 디자인 시스템을 성공적으로 도입하고 많은 문제를 해결했지만, 여전히 몇 가지 고민이 남아있습니다. 첫째, 새로운 디자이너나 개발자가 기존의 디자인 시스템에 적응하는 데 시간이 오래 걸린다는 문제가 있습니다. 현재는 디자이너와 개발자가 매우 긴밀하게 협업하고 있지만, 앞으로는 더 느슨한 협업 구조를 통해 효율성을 높이고 새로운 인력도 쉽게 적응할 수 있는 체계를 마련할 필요가 있습니다. 둘째, 특정 서비스에만 특화된 컴포넌트를 디자인 시스템에 포함시킬 필요가 있는지에 대한 고민이 있습니다. 모든 컴포넌트를 포함시키는 것이 효율적이지 않을 수 있으며, 특정 서비스에만 사용되는 컴포넌트는 별도로 관리하는 것이 더 나을 수 있습니다. 마지막으로, 시간이 지남에 따라 지속적으로 증가하는 컴포넌트를 어떻게 관리할 것인가가 중요한 과제가 되고 있습니다. 컴포넌트가 너무 많아지면 디자인 시스템 자체가 복잡해져 관리와 유지 보수가 어려워질 수 있습니다. 저희는 재사용 가능한 컴포넌트만을 엄선하고 불필요한 중복을 줄이는 방향으로 시스템을 지속적으로 개선해 나가야 합니다. 이런 고민을 저희가 어떻게 해결해 나가고 있는지 이어서 설명하겠습니다. Developer eXperience 지금까지도 많은 디자인과 개발이 이루어졌지만, 팀원이 늘어나면서 앞으로 디자인과 컴포넌트는 점점 증가할 것입니다. 기존 팀원도 모든 디자인과 컴포넌트를 기억할 수는 없을 것이고, 새로운 팀원도 이를 전부 파악해 두기 어려울 것입니다. 이러한 상황을 해결하기 위해 저희는 다음과 같은 질문을 해보았습니다. 우리가 만든 디자인과 컴포넌트는 무엇이 있을까? 어떻게 만들고 있을까? 어떻게 사용하고 있을까? 어떻게 관리할 수 있을까? 모두에게 동일한 경험을 어떻게 제공할 수 있을까? 이 장에서는 개발자 경험을 쌓고 공유하는 과정을 소개하겠습니다. 디자인 시스템을 만드는 사람과 사용하는 사람에게서 경험을 얻어 데이터로 변환하고, 그 데이터를 처음 접하는 사람 또는 모두에게 제공하면 자연스러운 순환이 이루어질 것입니다. 이렇게 되면 자연스럽게 순환하는 데이터를 통한 자동화로 이어질 수 있습니다. 경험을 공유하는 과정에서는 Webpack, Figma Plugin, Action 등 다양한 형식을 이용할 수 있습니다. 여기에서 얻은 데이터는 텍스트나 JSON과 같은 형식으로 최종 저장되며, 순환하여 더 나은 시스템을 만드는 데 도움을 줄 수 있습니다. META: 경험을 데이터로 만들고 공유하기 자동화를 제공하거나 반복적인 작업을 줄이는 등 편의성을 제공하거나 시스템을 만들기 위해서도 데이터와 정보가 매우 중요합니다. 저희의 경험을 데이터로 나타낼 수 있는 첫 시작인 META를 소개하겠습니다. 여기서 말하는 META는 프로젝트에 있는 모든 정보를 의미하며, CSS 파일, 데모, JSDoc, 템플릿, 컴포넌트, 모듈, 타입 등이 있습니다. 이 META를 통해 경험을 공유하려고 합니다. 저희가 만든 META Analyzer를 통해서, 프로젝트에 담긴 코드와 파일로부터 META를 만들 수 있습니다. META Analyzer는 AST(Abstract Syntax Tree, 추상 구문 트리) 파서를 이용해 만들었습니다. AST는 소스 코드의 문법을 트리 형태로 표현한 구조로, 컴파일러에서 많이 사용합니다. export, let, const, boolean, number 등 저희가 알고 있는 문법 하나하나가 트리 구조의 노드에 해당합니다. 잘 알려진 AST 파서로는 Babel, TypeScript가 있으며 번들러, 트랜스파일러, 프레임워크, 문서 등에서 사용되고 있습니다. META에는 TypeScript 모듈의 AST 파서를 사용했습니다. index, entry에서 시작하여 연결된 모든 파일의 구조를 파악할 수 있습니다. 코드를 파싱하면 다음과 같이 export, function, import, JSX 등 어떤 문법을 사용했는지 알 수 있습니다. AST를 이용하면 문법 외에 어떤 정보를 얻을 수 있을까요? Visual Studio Code의 툴팁에서 인터페이스가 사용된 위치와 정의된 위치를 확인할 수 있듯이, 단순히 인터페이스나 컴포넌트의 유무뿐만 아니라 다음과 같은 연관 관계 정보를 알 수 있습니다. 컴포넌트를 사용했을 때 어떤 모듈에서 가져왔는지 스타일을 사용했을 때 어떤 CSS를 가져왔는지 인터페이스를 사용했을 때 정의된 위치가 어디인지 컴포넌트에는 Props를 사용하고 있고, Props에는 JSDoc, 상위 집합(superset), 상속, 의존 관계 등이 포함됩니다. 이를 통해 컴포넌트와 템플릿을 기준으로 시작하여 연관된 모든 정보를 가져올 수 있습니다. META 활용 사례 컴포넌트와 템플릿, 그리고 연계된 정보는 곧 경험입니다. 저희는 META를 통해 이 경험을 공유할 수 있습니다. 그런데 META로 경험을 공유할 수 있다면 정확히 어떤 것이 가능해질까요? 정보를 어떻게 다룰지는 정보를 가지고 있는 사람에게 달려있습니다. 정보를 어떻게 다루고 경험을 어떻게 표현하고 공유할 수 있는지, META를 활용한 여러 사례를 소개하겠습니다. 디자인과 개발의 1:1 매칭: Figma 플러그인 API를 활용한 Design to Code 첫 번째 사례는 디자인과 개발의 매칭입니다. 개발자는 디자인 요소를 개발로 옮겨야 합니다. 어떤 디자인이 어떤 컴포넌트로 연결되는지 모든 개발자가 알기는 어렵고 새로 입사한 팀원은 만들기도 어려울 수 있습니다. 디자인 요소를 개발로 옮기는 방법을 몇 가지 소개하면 다음과 같습니다. 디자인을 재사용하기 위해 컴포넌트화하고, 컴포넌트화된 디자인에 대해 개발 컴포넌트로서 함수를 정의하고 마크업을 작성하여 개발 디자인의 variants 타입에 따라 디자인의 모양과 값을 확인할 수 있으며, 스타일에 hex 값 또는 variable을 사용 가능 개발 시에는 조건에 따라 Props로 동기화해서 개발하고, 스타일에 CSS variable을 사용해 디자인과 개발 매칭 이와 같이 디자인과 개발 요소가 연결되므로 어떤 디자인이 어떤 컴포넌트와 동기화되어 있는지 파악해야 합니다. 개발 시 디자인의 요소 스타일, 변수, variants 등을 고려해야 하며, 디자인을 통해 마크업, CSS, SVG까지 개발해야 할 수도 있습니다. 여기서 다음과 같은 질문이 제기됩니다. 디자인을 통해 마크업을 쉽게 할 수 있을까? HTML 대신 다른 형식으로 바꿀 수 있을까? 이렇게 만든 디자인과 컴포넌트를 전부 기억할 수 있을까? 디자인 컴포넌트처럼 개발 컴포넌트도 재사용할 수 있을까? 모든 개발자가 처음부터 디자인을 잘 이해하는 것은 아닙니다. 스타일, 변수, variants 등 디자인 요소를 이해하기 어려울 수 있고, 마크업 작업 경험이 적다면 더욱 어려울 수 있습니다. 그렇기 때문에 디자인을 쉽게 이해하고 접근하기 위해 디자인을 코드로 바꿔주는 도구가 필요했습니다. 하지만 에디터로 개발하기에는 목적, 대상, 범용성, 기능이 매우 제한적이고 개발 비용과 유지 보수 비용이 매우 클 것이라고 판단해 조건을 변경했습니다. Design To Code라는 개념은 그대로 유지하되 에디터가 아닌 단순 플러그인으로 한정된 기능만 제공하여, 한 번의 클릭으로 하나의 결과를 얻어낼 수 있게 했습니다. 범용성은 적더라도 목적과 대상이 한정되어 유지 보수도 용이하다고 판단했습니다(이 시점은 Figma의 Variable과 Code Connect가 출시되기 이전입니다). Figma 플러그인 API를 이용하면 Figma 디자인에 접근하여 디자인 정보, 파일 정보, 프로젝트 정보를 파악할 수 있으며, 이 정보를 이용해 디자인을 개발로 변환할 수 있습니다. Figma 플러그인은 크게 API 레이어와 UI 레이어로 나뉩니다. API 레이어: Figma 플러그인 API를 통해 디자인 정보를 확인 UI 레이어: UI를 표현할 수 있지만 디자인 정보를 확인할 수 없음 따라서 API 레이어에서 얻은 디자인 정보를 UI 레이어로 전송해야 합니다. API 레이어에서는 플러그인 API를 통해 얻은 정보와 메서드를 호출해 얻은 정보에서 불필요한 정보를 제거하고 이름, 사이즈, 위치, 스타일, 키 등 필요한 정보만으로 정제된 데이터를 생성해 UI 레이어로 전송합니다. UI 레이어는 옵션에 따라 데이터를 JSX(JavaScript XML), JSON 등의 형식으로 변환하여 사용자에게 보여줍니다. 프로토타이핑한 플러그인은 다음과 같이 간단한 UI로 구성되어 있습니다. 왼쪽에는 가져온 코드를 보여주고 오른쪽에는 코드를 그대로 화면으로 렌더링한 결과를 보여줍니다. 플러그인으로 마크업을 대체할 수 있으므로 세부 작업만 추가하고 개발로 옮겨 시간을 크게 절약할 수 있게 되었습니다. 다만, 이 과정은 디자인 정보를 통해 마크업을 새로 만드는 과정이기에 새로 만드는 경우에 효과적입니다. 하지만 이전에 만든 컴포넌트가 존재하는 경우에는 어떨까요? 이미 존재한다는 사실을 모르고 중복 개발하면 낭비가 될 수 있습니다. 디자인이 어떤 컴포넌트와 연결되는지 알려주고 비슷한 디자인을 매칭해 준다면 이를 방지할 수 있을 것입니다. 플러그인에서 디자인 정보를 토대로 기존의 컴포넌트와 매칭하고, 매칭되지 않으면 앞에서 설명한 방식으로 새로 개발하면 됩니다. 기존에 만든 컴포넌트를 어떻게 플러그인에 연결할까요? 플러그인 API로 얻은 디자인 정보를 컴포넌트의 Props 인터페이스에 JSDoc 주석 형태로 주입합니다. JSDoc 주석을 선택한 이유는 개발과 다른 도구에 영향을 끼치지 않기 때문입니다. 이를 위해 커스텀 태그를 사용해 Figma 정보를 주입합니다. 예를 들어 컴포넌트 집합체에서는 set key를 사용하고, 각 컴포넌트에는 key를 사용하고, 패턴이 있는 이름의 경우에는 이름을 사용해서 디자인과 컴포넌트를 매칭합니다. 컴포넌트 매칭과 유사하게 Figma의 구조나 variant의 타입에 따른 값을 Prop의 JSDoc으로 주입함으로써 디자인과 개발을 연결할 수 있습니다. variant의 타입이 instance, boolean, text인 경우 값을 주입할 수 있으며 그 밖의 커스터마이즈한 값을 직접 수정할 수 있습니다. 이러한 정보는 플러그인 API를 통해서만 얻을 수 있기 때문에 일반적으로 얻기는 어렵지만, 플러그인에서 개발하는 경우에는 매칭에 필요한 정보가 JSDoc에 담겨 제공됩니다. 이러한 JSDoc 정보는 인터페이스에 담겨있고, 인터페이스는 META에 포함됩니다. META가 저장된 위치 URL을 플러그인에서 추가하여 사용할 수 있습니다. 저희가 만든 Search Design System과 디자인 시스템을 사용 중인 Fender도 META를 사용하고 있어서, 두 META를 플러그인에서 추가하여 사용할 수 있고 외부의 컴포넌트도 적합하다면 META로 추가하고 사용할 수 있습니다. 디자인 정보가 담긴 첫 데이터는 아무것도 매칭되지 않은 원시(RAW) 데이터입니다. META에 담긴 컴포넌트 정보와 디자인 정보가 일치하면 원시 데이터는 매칭된 컴포넌트 데이터로 변환됩니다. 매칭된 데이터는 매칭되기 전 데이터와 인터페이스가 동일하기 때문에 이전과 동일하게 JSX나 JSON 또는 그 밖의 다양한 형식으로 출력할 수 있습니다. META에는 Props 정보, 컴포넌트 정보, 연계된 스타일 정보도 포함되어 있습니다. 매칭되는 경우 기존 컴포넌트로 대체하여 스타일을 생략하고, Props 연결, 문서 연결 등 개발자에게 다양한 도움을 제공할 수 있습니다. 또한 Fender의 경우 JSON 형식으로 변환이 가능하며, Fender JSON Simulator에서 복사해 붙여넣어서 동일하게 출력할 수 있습니다. 디자인과 개발을 연결하는 플러그인(Design to Code) 적용 전후의 효과를 요약하면 다음과 같습니다. 플러그인 적용 전 플러그인 적용 후 디자인이 어떤 컴포넌트와 연결되었는지 직접 확인해야 함 연결되는 컴포넌트가 존재하지 않는다면 직접 마크업하여 컴포넌트를 개발해야 함 디자인에 해당하는 컴포넌트를 직접 찾을 필요 없이 플러그인에서 매칭 매칭되지 않는다면 새로운 컴포넌트로서 원시 마크업으로 제공됨 새로운 컴포넌트 개발과 기존 컴포넌트 재사용에 있어 더 나은 편의성을 제공하여 마크업 및 JSON 변환 비용을 절감 디자인 차이점 비교(Design Diff) 두 번째 사례는 디자인 차이점 비교(Design Diff)입니다. 개발자는 디자인을 따라 컴포넌트를 개발합니다. 플러그인을 통해 만든 컴포넌트는 하루가 지나도 잘 매칭이 되고 있을까요? 대부분은 매칭이 잘 되고 있을 것입니다. 하지만 매칭이 되지 않거나 디자인이 바뀌었다면 개발자가 알기 어렵습니다. Figma의 웹훅과 람다를 이용하여 변경 알림을 개발해보았지만, UI가 실제로 바뀌지 않아도 1분마다 알림이 왔고 변경 사항 확인도 불가능했습니다. 그래서 웹훅을 사용하는 대신 직접 차이점을 알아내려고 했습니다. 플러그인을 통해 만든 컴포넌트라면 META에 등록 및 관리되고 있다고 볼 수 있습니다. META에는 컴포넌트마다 Figma 디자인 정보인 key를 포함하고 있습니다. 또한 Figma REST API에서는 node-id나 key를 통해 스냅샷 이미지를 생성할 수 있습니다. META에 등록된 모든 컴포넌트의 스냅샷 이미지를 저장하고, 일정 주기마다 새로운 스냅샷과 이미지를 비교해 변화를 감지합니다. 또한 다음과 같은 변경 사항도 감지할 수 있습니다. 이름이 변경된 경우 key 값 변경으로 인한 매칭 해제 잘라내기로 인한 id, key 값 변경 이러한 변경 사항에 대한 알림을 메신저로 전달합니다. 이를 통해 변경 사항을 바로 알 수 있으므로 오류를 방지할 수 있었고 커뮤니케이션 비용이 감소했습니다. 트리거는 다음과 같은 경우에 발생합니다. 개발 코드가 머지되는 경우 디자이너/개발자의 활동 시간에 약 4번 정도 검사 스케줄 실행 실제 적용 사례를 보면, Q 아이콘의 변경에 따른 디자인 변화가 있었을 때 알림이 발생하여, 디자이너가 놓칠 수 있었던 변경 사항을 바로 확인하고 개발에 반영할 수 있었습니다. 디자인 차이점 비교(Design Diff) 적용 전후를 살펴보면 다음과 같습니다. Design Diff 적용 전 Design Diff 적용 후 디자인 변경 시 다음 회의에서 변경 사항을 공유받아 개발 변경 사항 확인을 놓쳐서 QA 단계에서 이슈 발생 변경 사항에 따른 알림이 오면 사유를 확인하여 즉시 개발 반영 및 공유 가능 지금까지 디자인 변경 2건, 개발 변경 1건을 바로 확인하여 오류 방지 통계 분석(Analytics): 템플릿과 컴포넌트 사용 현황 확인 세 번째 사례는 사용처를 확인하는 통계 분석(Analytics)입니다. 저희가 만든 컴포넌트와 템플릿이 어디에서 사용되고 있는지, 디자인은 어디에서 사용되고 있는지, 그리고 반대로 사용되지 않는 컴포넌트, 템플릿, 디자인이 있는지 확인이 필요했습니다. 정적으로는 코드 빌드를 통해 얻은 META에서 연관 관계를 파악하여 자주 사용되거나 사용되지 않는 컴포넌트, 템플릿을 대략 확인할 수 있습니다. 하지만 데모가 변경되어 실제로는 사용되지 않을 수도 있기 때문에, 실시간으로 로그에 컴포넌트와 템플릿 사용 기록이 담겨 전송된다면 더욱 정확하게 사용 현황을 분석할 수 있습니다. 또한 META와 연동하여 전체 템플릿, 컴포넌트 중 가장 많이 사용된 것은 무엇인지, 미사용 템플릿 현황은 어떤지, 해당 미사용 템플릿이 적용된 서비스가 종료되었는지 혹은 출시 전인지 확인하여 정리 대상 여부를 판단할 수 있습니다. Fender와 SDS(Search Design System) META를 통해 템플릿의 실제 사용처를 확인하는 통계 페이지에서 확인할 수 있는 정보는 다음과 같습니다. 템플릿과 컴포넌트의 관계 사용 여부 SDS 컴포넌트를 얼마나 잘 사용하고 있는지 여부 미사용 여부와 기간 등 문서(Docs) 네 번째 사례는 문서입니다. 문서에서는 프로젝트에 어떤 컴포넌트가 있는지, 컴포넌트의 역할, 모습, 사용 방법 등을 확인할 수 있습니다. 여기서 META를 이용하면 인터페이스 prop, type 등에 해당하는 기본적인 정보와 JSDoc 정보를 통해 클래스 이름, 설명, Figma key 등을 확인할 수 있어, 예시와 연결하여 control, 인자 타입, 설명에 도움을 줄 수 있습니다. 이외에도 여러 도구에서 META를 활용할 수 있습니다: Visual Studio Code와 같은 IDE(Integrated Development Environment, 통합 개발 환경) 도구 배포된 컴포넌트에서 JSDoc이 자동으로 툴팁을 제공해 문서 역할을 수행하며 개발 시 즉각적인 도움말 제공 Storybook META를 이용해 타이틀 영역을 자동으로 생성 Figma Key를 이용해 컴포넌트와 연결된 디자인 링크를 추가 Figma 플러그인 디자인과 연결된 컴포넌트 확인 사용 예시를 바로 확인 가능 또한 META를 통해 어떤 Prop이 ReactElement로 사용되었는지에 따라 반자동으로 디자인 구조인 Anatomy를 확인할 수 있습니다. Prop이 어디에 반영되는지, 사이즈와 사용되고 있는 클래스까지 확인할 수 있습니다. 최소한의 작업으로 META에서 사용자에게 필요한 정보를 추출하여 보여줄 수 있고, 문서와 플러그인의 연결, 문서와 디자인의 연결을 통해 언제 어디서든 사용 방법에 대한 도움을 제공할 수 있습니다. 네이버 검색 FE 시스템 아키텍처 디자이너는 디자인 시스템을 설계합니다. 이렇게 설계된 디자인 시스템은 Search Design System 플러그인을 통해 컴포넌트로 전환됩니다. 디자인 시스템을 기반으로 서비스 디자인을 설계하며, 물론 디자인 시스템에 없는 디자인도 존재할 수 있습니다. 기존의 디자인 시스템 컴포넌트는 Search Design System 플러그인을 통해 매칭되고 새로운 컴포넌트는 새로 개발합니다. 개발한 결과는 Fender를 통해 서비스됩니다. 서비스로 출시된 이후에는 사용자의 피드백과 META를 통해 얻은 정보를 바탕으로 개선할 부분, 반복되는 작업 등을 파악합니다. 이 정보는 다시 디자인 시스템과 서비스에 반영됩니다. 이러한 순환 구조를 통해 데이터를 기반으로 지속적으로 성장 가능한 FE 시스템을 구축하고 있습니다. 남아있는 고민 디자인 토큰을 설계하고 Figma와 연동하는 방법, 디자이너와 개발자, 디자인 시스템을 만드는 팀원과 사용하는 팀원 사이의 워크플로우, 서비스를 배포하고 오류를 수집해서 개선하는 DevOps 등 많은 주제가 있지만, 지면 관계상 여기서는 다루지 않겠습니다. 여전히 고민은 남아있습니다. 새로운 팀원이 왔을 때 이런 프로세스에 잘 적응할 수 있을지 현재 UI 재활용성이 사후에 많이 개선되는데, 사전에 재활용성을 높일 수 있는 방법은 없을지 UI 재활용만큼 인터랙션도 재활용할 수 없을지 전보다 많은 데이터를 볼 수 있게 되었는데 이를 어떻게 더 잘 활용할 수 있을지 이러한 고민은 지속적으로 해결해나가고 그 과정을 다른 기회에 공유하겠습니다. 이 글은 TEAM NAVER CONFERENCE 'DAN 24'에서 발표한 내용을 토대로 작성되었으며, 발표 내용과 자료는 DAN 24에서 보실 수 있습니다.
![[DAN 24] DEVIEW 세션 영상이 공개되었습니다.](https://d2.naver.com/content/images/2025/01/-----------2025-01-14------1-40-17-2.png)
기술 공유를 넘어 네이버의 미래 비즈니스와 서비스 변화 방향을 제시한 'DAN 24'가 많은 분들의 관심과 참여로 성황리에 종료되었습니다. 개발자뿐만 아니라 다양한 직군의 참가자분이 함께 기술과 서비스와 관련된 이야기를 나누다 보니 열기가 가득했는데요. 함께해 주신 여러분들께 다시 한번 감사드립니다. 모든 발표영상이 DAN 24 홈페이지와 PLAY NAVER 네이버 TV 채널에 공개 되었습니다. DAN 24 오프라인 현장에서 가장 참여율이 높았던 기술 세션들도 정리했으니 많은 관심 부탁드립니다. 1. 여러분의 웹서비스에는 꼭 필요한 것만 있나요? 번들사이즈 최소화를 통한 웹 성능 개선 - NAVER FINANCIAL 김용찬 님 수년간 운영된 서비스의 번들 사이즈를 최적화하여 성능을 크게 개선한 경험을 공유하고, 매일 여러 개의 PR이 생성되고 병합되는 저장소에서 꼭 필요한 코드만 최소한으로 포함시켜 사용자에게 효율적으로 서비스를 제공하는 방법을 소개합니다. 2. 네이버페이 결제 시스템의 성장과 변화 - NAVER FINANCIAL 김진한 님 손쉬운 확장을 위한 분산 DB와 EDA 적용, 무중단 결제를 위한 다양한 시도들과 함께 결제 서비스에 특화된 모니터링 및 SRE 활동들까지 네이버페이 성장을 견인하기 위한 기술의 변화와 경험을 공유합니다. 3. 사용자 경험을 극대화하는 AI 기반 장소 추천 시스템: LLM과 유저 데이터의 융합 - NAVER 김창회/이준걸 님 AI 기반의 장소 추천 시스템을 주제로 LLM을 추천 모델에 적용하는 과정에서 있었던 고민들과 실제 서비스 적용까지의 기술 노하우를 공유하고, 기존의 추천 모델/데이터와 어떻게 시너지를 내고 서비스 경험을 극대화할 수 있었는지를 소개합니다. 1. 사람을 대신해야 진짜 AI지?: LLM 기반 임베딩부터 검색 품질 자동 평가 모델까지 - NAVER 권오준님 Human 수준의 모델링을 위한 데이터 구축 방법론, 여러 모델의 아키텍처 및 장/단점, 학습 과정에서 발견한 노하우 등을 공유하고, 네이버에서 사용자의 검색 경험 향상을 위해 AI를 어떤 식으로 활용하고 있는지를 공유합니다. 2. 벡터 검색의 정점에 오르다: 최적의 뉴럴 검색 엔진으로 업그레이드 하기 - NAVER 현화림/김인근 님 ColBERT 및 후속 뉴럴 검색 모델들을 소개하고 각 모델을 자세히 들여다봅니다. 웹 검색에 대한 인하우스 검색 엔진 솔루션(NPP)의 부하 특성을 공유하며, 엔지니어링이 솔루션 성능에 어떤 영향을 미치는지 공유합니다. 3. 당신의 PYTHON 모델이 이븐하게 추론하지 못하는 이유 [CPU 추론/모델서빙 PYTHON 딥다이브] - NAVER 김성렬 님 Jupyter Notebook 위에서 추론하는 모델이 실 서버 환경에서 1초에 100,000번의 추론을 수행하기 위해 Python, ML Framework, Model Server 관련 어떤 지식이 필요한지를 공유합니다. [DAN 24] DEVIEW 세션 영상 더 보기 >>

배치 작업을 VM 서버에서 실행해 동시 실행에 어려움을 겪은 적이 있나요? 이 글에서는 Kubernetes Job을 활용해, 기존에는 VM 서버에서 실행되던 배치 작업이 클러스터에서 실행되도록 아키텍처를 변경해 작업의 효율성을 높이고, Kubernetes 커스텀 컨트롤러로 Job 스케줄러를 구현해 Job 실행을 더 유연하게 관리한 방법을 공유하고자 합니다. 동시에 실행하고 싶은 배치가 너무 많다 프로젝트 초기에는 실행해야 하는 배치 수가 적기 때문에 간편하게 VM 서버 한 대에서 모든 작업을 처리할 수 있습니다. 하지만 프로젝트를 운영해 갈수록 실행해야 하는 배치 수는 점점 늘어나고 VM 서버 한 대로는 해결하기 힘든 상황이 옵니다. 예를 들어보겠습니다. VM 서버 한 대로 특정 시간에 사용자에게 다양한 알림을 보내고 싶다면 어떻게 해야 할까요? 일반적인 운영 환경에서는 VM 장비 한 대로는 CPU, 메모리 등 자원 할당의 문제로 여러 배치 작업을 동시에 실행하기 힘들기 때문에 서로 연관이 없는 독립적인 배치 작업이라도 동시에 실행하지 못하고 하나의 작업이 끝날 때까지 기다렸다가 다른 작업을 시작해야 합니다. 만약 동시에 여러 작업을 실행하고 싶다면 서버를 추가해서 각각 실행해야 합니다. 하지만 서버를 추가하면 비용이 증가할 뿐 아니라 관리 포인트가 늘어나고, 이 경우 특정 시간에만 작업을 실행하기 때문에 대부분의 시간에 서버가 사용되지 않아서 자원을 효율적으로 사용할 수 없습니다. 그렇다고 해서 이 작업을 위해 특정 시간에만 새로운 서버를 설정하는 방법은 확장성이 떨어집니다. 그래서 이런 문제점을 해결하기 위해 Kubernetes Job을 활용해 배치 작업을 클러스터에서 실행할 수 있게 했습니다. 클러스터에서 일회성 작업을 실행할 수 있는 Kubernetes Job 오브젝트 Kubernetes Job은 Kubernetes 클러스터에서 일회성 작업을 실행하기 위한 오브젝트입니다. 의존성 없는 다수의 배치 작업을 각각 Job으로 실행하면 하나 이상의 컨테이너에서 배치 작업을 독립적으로 수행할 수 있어 전체 배치 작업의 실행 시간이 단축되고 시스템 전체의 효율이 향상됩니다. 뿐만 아니라 Kubernetes의 자동 확장 및 복구 기능은 시스템의 안정성을 높이고 유지 보수 부담을 줄여줍니다. 또한 한 번 Job을 구성해두면 클러스터만 변경해 실행이 가능하기 때문에 이중화에도 효과적입니다. 그럼 Kubernetes Job이 어떤 식으로 동작을 하는지 간단하게 알아보고, Job 템플릿을 생성 후 실제로 실행하고 모니터링해 보겠습니다. Kubernetes Job 동작 방식 Kubernetes Job은 클러스터 내에서 자원을 조정하고 작업을 스케줄링하는 컨트롤 플레인을 통해 파드에 스케줄링받아 실행됩니다. 출처: Kubernetes Components 사용자가 Job 생성을 요청하면 Kubernetes API 서버가 요청을 받아서, 클러스터의 모든 상태 정보를 저장하는 etcd에 리소스를 저장합니다. etcd에 리소스가 저장되면, 해당 리소스의 이벤트를 감시하는 컨트롤러 매니저가 리소스 생성을 감지하고 리소스를 가져와 사용자가 의도한 Job Spec에 맞게 파드를 생성합니다. 여기서 생성된 파드는 대기(pending) 상태이며, 스케줄러가 클러스터의 상태를 고려해 적절한 노드에 파드를 할당하면서 작업이 실행됩니다. 이런 실행 과정을 거치기 때문에 사용자가 요청하는 각각의 Job은 모두 독립적인 파드에서 병렬 처리될 수 있습니다. Kubernetes Job 생성 그럼 이제 Job 템플릿을 하나 생성해서 실행해보겠습니다. Kubernetes 리소스 정의 파일인 YAML 파일을 이용해서 Kubernetes 클러스터에 Job 생성을 요청할 수 있습니다. 이 YAML 파일에는 Job 생성에 필요한 모든 설정이 포함되어 있습니다. 기본적인 Job 템플릿 YAML 파일은 다음과 같습니다. Job의 메타데이터와 사용자의 의도가 담기는 spec을 정의할 수 있습니다. apiVersion: batch/v1 kind: Job metadata: name: my-job spec: template: metadata: labels: app: name spec: containers: - name: my-container image: my-container-image restartPolicy: OnFailure 템플릿을 실행하면 spec에 정의된 내용에 맞는 파드가 생성되고, 적절한 노드에 할당되어 실행됩니다. 이 Job 템플릿만으로도 클러스터에 Job을 생성할 수 있지만, 여기에서는 Helm을 이용해서 Job을 실행해보겠습니다. Helm은 Kubernetes의 패키지 매니저로, 파라미터나 설정값을 쉽게 패키징해 클러스터에 배포할 수 있게 도와줍니다. Helm 차트를 생성하면 나오는 기본 파일 구조를 보겠습니다. job-chart/ ├── charts/ ├── templates/ │ ├── job.yaml ├── env/ │ ├── dev/ │ ├── values.yaml │ ├── real/ │── ├── values.yaml templates 디렉터리 하위에 정의되어 있는 Job 템플릿 YAML 파일에서 values.yaml 파일의 값을 참조하여 Kubernetes 리소스를 생성합니다. 위치에 맞게 job.yaml 템플릿 파일을 생성하고, values.yaml 파일에 Job 실행에 필요한 기본 설정값을 정의하겠습니다. apiVersion: batch/v1 kind: Job metadata: name: my-job spec: template: metadata: labels: app: name spec: containers: - name: my-container image: {{ .Values.image.name }}:{{ .Values.image.tag }} restartPolicy: OnFailure resources: limits: cpu: "8" memory: "8Gi" command: \["sh", "-c"\] args: \["실행 명령어"\] image: name: my-image config: javaopts: -server -Xms4096m -Xmx8192m ... ... Helm 차트를 배포하면 values.yaml 파일의 값을 참조하여 동적으로 Kubernetes 리소스가 생성됩니다. 그리고 리소스를 생성할 때 동적으로 파라미터를 주입하고 싶다면 다음과 같이 Helm 명령어 파라미터로 --set을 이용해 값을 전달할 수 있습니다. helm upgrade --install test-group-scheduled-1 ./ --values=./env/dev/values.yaml --set metadata.labels.order=1 ... 그럼 Helm 차트를 이용해 Job 여러 건을 동시에 실행해보겠습니다. 정상적으로 파드가 생성되고, 각각의 노드에 할당되어 작업이 수행되는 것을 확인할 수 있습니다. 클러스터 내에서 배치성 작업을 독립적으로 실행할 수 있게 되어, 서로 연관 없는 작업이 불필요하게 다른 작업이 끝날 때까지 기다릴 필요가 없어졌고, 불필요한 의존성을 제거함으로써 전체 작업의 효율성이 높아졌습니다. Kubernetes 커스텀 컨트롤러를 이용한 Job 가변적 스케줄러 구현 앞에서 Job을 활용해 의존성 없는 배치 작업들을 병렬 처리함으로써 작업 효율성을 크게 높일 수 있었습니다. 하지만 배치 작업 중에는 서로 의존성이 있어서 순차적으로 처리해야 하는 경우도 있습니다. 이런 작업을 Job으로 지연 없이 순차 처리하려고 할 때 Job의 한계점이 드러납니다. Job은 파드에서 독립적으로 실행되는 특성상, Job 간의 실행 상태를 클러스터 외부에서 실시간으로 알기 어렵습니다. 그래서 Jenkins같이 외부에서 배치를 실행하고 있다면 상태를 알 수 없기 때문에 순차 처리가 쉽지 않습니다. 배치 작업을 지연 없이 순차 처리하고 싶다면 클러스터 내부에서 Job의 실행 상태를 파악하면서 스케줄링해야 합니다. 이런 문제를 해결하기 위해서, Kubernetes 커스텀 컨트롤러를 이용해 Job의 실행 상태를 유연하게 관리할 수 있는 스케줄러를 구현해보겠습니다. 물론, 스케줄링을 위해 커스텀 컨트롤러를 반드시 구현해야 하는 것은 아닙니다. 필요한 기능을 제공하는 오픈소스가 있다면 이를 사용하는 것이 좋습니다. 그러나 오픈소스를 사용할 수 없는 환경이거나 특수한 요구 사항이 있는 경우, 또는 학습 목적으로 활용하려는 경우에는 직접 구현을 고려해볼 수 있습니다. Kubernetes 커스텀 컨트롤러의 이해 우선 스케줄러를 구현하기에 앞서, 커스텀 컨트롤러에 대해 알아보겠습니다. Kubernetess 커스텀 컨트롤러는 사용자가 정의한 커스텀 리소스의 상태를 관리하며, 리소스의 현재 상태를 지속적으로 모니터링하면서 사용자가 의도한 상태가 되도록 동작하는 컴포넌트입니다. Kubernetes에서 컨트롤러가 동작하는 방식은 다음과 같습니다. 출처: client-go under the hood 컨트롤러는 리소스의 상태를 확인하고 의도한 상태가 될 때까지 동일한 작업을 하는 Reconcile Loop 동작을 수행합니다. 그런데 만약 컨트롤러가 직접 Kubernetes API 서버와 통신을 하면서 필요한 데이터를 조회한다면 서버에 과도한 부하를 주게 됩니다. 이를 방지하기 위해, Kubernetes는 client-go 라이브러리를 활용해 API 서버와의 통신을 효율적으로 처리하는 컴포넌트를 제공합니다. client-go의 주요 컴포넌트를 보겠습니다. Reflector 컴포넌트는 서버와 통신하며 리소스를 감시(watch)합니다. 리소스의 이벤트가 발생하면 로컬 캐시에 동기화해서 최신 정보를 유지하고 리소스 검색 시에 캐시에서 검색되게 해서 서버에 부하가 가지 않게 합니다. Informer 컴포넌트는 발생한 리소스 이벤트 종류에 맞는 이벤트 핸들러를 호출해서 컨트롤러 workQueue에 리소스를 전달합니다. 이제 컨트롤러의 동작 과정을 보겠습니다. workQueue에 저장된 리소스를 순서에 맞게 꺼내 처리하는데, 이 과정이 Process Item입니다. 이 과정 중에 Reconcile 메서드가 호출되어 리소스의 현재 상태가 의도한 상태가 될 때까지 상태를 조정합니다. 즉 우리가 커스텀 컨트롤러를 구현하기 위해서는, 커스텀 리소스를 등록하고 해당 리소스의 이벤트 발생 시 동작할 Reconcile 메서드 로직과 Informer를 사용한 리소스 이벤트 감지 등이 필요하다는 것을 알 수 있습니다. 그럼 이 커스텀 컨트롤러를 어떻게 생성하면 좋을까요? 여러 가지 방법이 있지만 여기에서는 커스텀 컨트롤러를 쉽게 구현할 수 있게 도와주는 Kubebuilder 프레임워크를 이용해 구현하겠습니다. Kubernetes 커스텀 컨트롤러의 생성 Kubebuilder는 어떻게 커스텀 컨트롤러 구현을 쉽게 해줄 수 있을까요? Kubebuilder의 아키텍처는 다음과 같습니다. 출처: Architecture - The Kubebuilder Book main.go 파일을 통해 프로세스가 실행되면 컨트롤러 매니저가 클러스터에 배포되고, 컨트롤러 매니저 내부에서는 Informer를 통해 Kubernetes API 서버와 통신해 커스텀 리소스에 대한 이벤트를 감지합니다. 커스텀 리소스에 대한 이벤트가 감지되면 해당 이벤트를 큐에 저장하고, 하나씩 꺼내 컨트롤러 Reconciler 메서드를 실행해서 리소스를 사용자가 원하는 상태가 되도록 합니다. 즉, Kubebuilder는 컨트롤러 매니저가 구현되어 있기 때문에 커스텀 컨트롤러를 쉽게 구현할 수 있게 도와줍니다. 사용자는 커스텀 리소스 정의와 Reconcile 메서드 로직 구현에 집중할 수 있습니다. 이제 Kubebuilder 프레임워크를 이용해 Job 스케줄러를 구현해보겠습니다. 이 Job 스케줄러는 Operator 패턴을 기반으로 동작하며, 커스텀 리소스 JobScheduler가 생성되면 이를 관리하는 컨트롤러가 스케줄링을 수행합니다. 코드를 보기 전에 스케줄링의 전체 동작 방식을 간단히 정리하면 다음과 같습니다. 실행할 Job의 메타데이터에 그룹명과 실행 순서를 설정한 뒤, Job을 생성하고 일시 정지 상태로 둔다. 커스텀 리소스 JobScheduler에 Job 그룹명을 설정 후 생성하면, 컨트롤러는 그룹명에 해당하는 Job 목록을 조회해서 실행 순서에 맞게 일시 정지를 해제하는 방식으로 스케줄링한다. 그럼 먼저 커스텀 리소스를 구현하고 클러스터에 등록하겠습니다. kubebuilder create api --group <group> --version <version> --kind <Kind> Kubebuilder에서 위 명령어를 사용하면 커스텀 리소스 생성 템플릿이 생성됩니다. 생성된 템플릿에 필요한 코드를 추가해 스케줄러 리소스를 만들겠습니다. type JobScheduler struct { metav1.TypeMeta \`json:",inline"\` metav1.ObjectMeta \`json:"metadata,omitempty"\` Spec JobSchedulerSpec \`json:"spec,omitempty"\` Status JobSchedulerStatus \`json:"status,omitempty"\` } 생성된 템플릿에는 커스텀 리소스의 메타 정보와 Spec, Status를 정의할 수 있습니다. 일반적으로 Spec에는 리소스가 어떻게 동작해야 하는지 나타내는 값이나 리소스가 동작하기 위해 필요한 값을 설정하고, Status에는 리소스의 현재 상태를 설명하는 정보를 저장합니다. 여기에서는 컨트롤러가 클러스터를 모니터링하면서 기록하는 필드 값을 저장합니다. 이제 스케줄러 로직 수행에 필요한 값을 설정하겠습니다. type JobSchedulerSpec struct { JobGroupName string \`json:"jobGroupName"\` } type JobSchedulerStatus struct { ... CurrentActiveJobIndex int \`json:"currentJobIndex"\` JobOrderGroup \[\]JobInfo \`json:"jobOrderGroup"\` ... } Spec에는 스케줄러가 실행할 Job 그룹명을 JobGroupName 필드에 설정했습니다. Status에는 스케줄링 로직을 수행하는 데 필요한 값을 정의했습니다. 스케줄러가 실제 실행할 Job 그룹의 정보가 JobOrderGroup 필드에, 스케줄러가 현재 실행하고 있는 Job Index 값이 CurrentActiveJobIndex에 저장됩니다. 리소스를 정의했으니 리소스의 이벤트가 발생했을 때 실행되는, 실제 스케줄링을 담당할 컨트롤러 로직을 구현하겠습니다. Kubebuilder에서 다음 명령어로 컨트롤러 템플릿을 생성할 수 있습니다. kubebuilder create controller --group <group> --version <version> --kind <Kind> 생성된 컨트롤러를 보면, 리소스의 이벤트가 발생할 때마다 실행될 Reconcile 메서드를 볼 수 있습니다. func (r \*JobSchedulerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ... } Reconcile은 이벤트가 발생한 리소스가 원하는 상태가 될 때까지 조정하는 메서드입니다. 이를 이용해, 스케줄러 리소스가 생성되면 Spec에 설정해둔 Job 그룹이 스케줄링되도록 구현해보겠습니다. 스케줄링 로직을 크게 세 부분으로 나누면 다음과 같습니다. Spec에 설정된 그룹명을 확인해 해당하는 Job 그룹을 가져와 실행 순서에 맞게 정렬 후 Status에 저장한다. 실행 순서대로 Job의 일시 정지 상태를 해제해서 실행한다. 그 후 실행 중인 Job의 상태 값을 모니터링해서, 성공하면 다음 Job을 실행하고 실패하면 종료시키는 등의 스케줄링 작업을 진행한다. 그럼 먼저 1번 작업인 실행할 Job 그룹을 가져와 Status에 저장하는 부분을 보겠습니다. 리소스 Spec에 정의한 Job 그룹명에 해당되는 Job을 모두 가져와 실행 순서에 맞게 정렬 후 Status에 저장해두는 초기화 작업에 해당합니다. func (r \*JobSchedulerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ... var jobs kbatch.JobList if err := r.List(ctx, &jobs, client.InNamespace(req.Namespace)); err != nil { // Namespace에서 Job 리소스를 모두 가져온다. ... for \_, job := range jobs.Items { groupVal := job.ObjectMeta.Labels\["group"\] // Job 리소스 메타데이터에 설정해둔 group 이름과 실행 순서를 가져온다. orderVal := job.ObjectMeta.Labels\["order"\] if groupVal == jobScheduler.Spec.JobGroupName && orderVal != "" { // Spec에 설정한 JobGroupName과 group 이름이 동일한 리소스만 가져온다. jobInfo := v1.JobInfo{Name: job.Name, Namespace: job.Namespace, Order: orderVal} jobScheduler.Status.JobOrderGroup = append(jobScheduler.Status.JobOrderGroup, jobInfo) // 리소스 Status에 값 저장 } } sort.Slice(jobScheduler.Status.JobOrderGroup, func(i, j int) bool { // 실행 순서에 맞게 정렬한다. return jobScheduler.Status.JobOrderGroup\[i\].Order < jobScheduler.Status.JobOrderGroup\[j\].Order }) jobScheduler.Status.IsInitialized = true if err := r.Status().Update(ctx, &jobScheduler); err != nil { // 리소스 Status에 반영한다. logger.Error(err, "Fail update jobScheduler status jobOrderGroup") return ctrl.Result{}, err } } ... } 먼저 Namespace에서 실행 중인 모든 Job을 가져옵니다. Job 메타데이터에는 그룹명과 실행 순서가 적혀있는데, 그룹명이 Spec에 설정된 그룹명과 동일한 Job을 추출합니다. 그리고 추출한 Job을 Status JobOrderGroup 필드에 저장한 후 실행 순서에 맞게 정렬합니다. 스케줄러 리소스가 실행할 Job 목록을 저장해서 초기화를 완료했으므로, 이제 Job의 일시 정지를 풀어서 실행하겠습니다. if err := r.Get(ctx, client.ObjectKey{Name: currentActiveJob.Name, Namespace: currentActiveJob.Namespace}, &job); err != nil { ... suspend := false job.Spec.Suspend = &suspend // Job Spec에 일시 정지 옵션인 suspend 값을 변경해 실행한다. if err := r.Update(ctx, &job); err != nil { ... 실행 순서가 가장 낮은 Job 리소스를 가져와서 일시 정지 옵션인 Suspend 값을 false로 변경하면 해당 Job이 실행됩니다. 첫 번째 Job이 실행되었으니, 이제 상태를 확인해가며 다음 Job을 실행하는 스케줄링을 구현하겠습니다. ... if job.Status.Active > 0 { // Job의 Status에서 실행 상태 값을 확인한다. logger.Info("Active... : ", "job\_name", job.Name) return ctrl.Result{RequeueAfter: time.Minute}, nil // Job이 실행 중이면 Reconcile 메서드 1분 후 재시작 } if job.Status.Succeeded > 0 { ... jobScheduler.Status.CurrentActiveJobIndex = jobScheduler.Status.CurrentActiveJobIndex + 1 // Job 성공 시 다음 Job 실행 if err := r.Status().Update(ctx, &jobScheduler); err != nil { logger.Error(err, "Fail update JobScheduler currentActiveJobIndex", "current\_active\_job\_index", jobScheduler.Status.CurrentActiveJobIndex) return ctrl.Result{}, err } logger.Info("Job Success... ", "job\_name", job.Name, "current\_active\_job\_index", jobScheduler.Status.CurrentActiveJobIndex) return ctrl.Result{RequeueAfter: time.Minute}, nil } if job.Status.Failed > 0 { if job.Spec.BackoffLimit != nil && job.Status.Failed < \*job.Spec.BackoffLimit { // Job 실패 후 Retry 상태 logger.Info("Job is Failed retrying... : ", "job\_name", job.Name) return ctrl.Result{RequeueAfter: time.Minute}, nil // Reconcile 메서드 1분 후 재시작 } else { logger.Info("\[END\] Job Failed... End scheduler", "job\_name", job.Name) return ctrl.Result{}, nil // Retry까지 전부 실패했을 때 Reconcile 메서드 종료 } } ... Job 리소스는 Status에 현재 상태를 알려주는 Active, Failed, Succeeded 값이 있습니다. 이 값을 확인하면서 Job을 스케줄링하는 로직을 구현했습니다. Active: 아직 실행 중인 상태. 1분 간격으로 Reconcile 메서드가 재실행되도록 구현합니다. 이 동작은 Job이 완료될 때까지 반복됩니다. Succeeded: Job이 정상적으로 성공한 상태. 실행해야 할 CurrentActiveJobIndex 값을 올려서 다음 Job이 실행되도록 설정합니다. Failed: Job의 Retry까지 전부 실패한 경우에는 스케줄링을 종료합니다. 이제 정상적으로 스케줄링이 되는지 확인해보겠습니다. 로컬에서 클러스터를 생성해 테스트를 진행하겠습니다. Kubebuilder에서 제공되는 makefile 스크립트를 이용해 make 명령어로 빌드 배포를 포함한 다양한 작업을 수행할 수 있습니다. make install make run 커스텀 리소스를 클러스터에 등록하고 컨트롤러 매니저를 배포해야 합니다. install로 커스텀 리소스가 클러스터에 등록되고 make run으로 커스텀 컨트롤러 매니저가 deployment 됩니다. 이제 배포된 컨트롤러 매니저는 Kubernetes API와 통신해서 커스텀 리소스의 이벤트 발생을 감지하고 컨트롤러 로직을 실행합니다. 다만, 여기서 실행한 make run 스크립트는 로컬 환경에서 테스트할 때 사용되는 명령어입니다. 운영 클러스터에 배포할 때는 kustomization을 이용해 하나의 YAML 파일로 패키징해 배포할 수 있습니다. 컨트롤러 매니저가 배포되었으니, 이제 실행하고 싶은 Job을 실행해보겠습니다. 먼저 Job 메타데이터에 그룹 이름과 실행 순서를 설정하고 Job이 바로 실행되지 않도록 일시 정지 상태로 설정합니다. kind: Job metadata: ... labels: group: test-group order: "1" ... spec: suspend: true ... kind: Job metadata: ... labels: group: test-group order: "2" ... spec: suspend: true ... Job은 다음과 같이 일시 정지 상태입니다. 이제 리소스를 실행해서 Job 스케줄링을 시작해보겠습니다. ... kind: JobScheduler metadata: ... spec: jobGroupName: "test-group" test-group이라는 Job 그룹을 실행하도록 설정한 JobScheduler 커스텀 리소스 템플릿 파일을 생성했습니다. 이제 이 리소스를 실행하겠습니다. 리소스가 생성되면 앞에서 배포해둔 커스텀 컨트롤러 매니저가 커스텀 리소스의 이벤트 발생을 감지해서 해당 리소스를 큐에 넣어 Reconcile 메서드를 실행합니다. 스케줄러 로직이 실행된 후 다시 실행된 Job을 보면 스케줄링 로직에 따라 순서대로 실행된 것을 확인할 수 있습니다. Job 상태를 확인해 보면 모두 정상 성공한 것을 확인할 수 있습니다. 마치며 Job을 활용해 배치성 작업들을 독립적인 컨테이너에서 실행하도록 변경해서 동시에 여러 작업을 실행할 수 있게 했습니다. 이를 통해 병렬성을 높여 작업 효율성을 향상시키고, Kubernetes 커스텀 컨트롤러를 활용해 Job 스케줄링을 지원하는 실시간 워크플로를 구현해 작업 대기 시간을 줄이고 신뢰성을 높일 수 있었습니다. 다만 여기에서는 직접 워크플로를 구현했지만, 이미 잘 구현되어 있는 Argo Workflow나 Apache Airflow와 같은 오픈소스 워크플로가 있으므로 이를 활용해 작업을 관리하는 것이 효율적이고 신뢰성이 높으며 유지 보수에도 유리합니다. 오픈소스를 사용할 수 없는 환경이거나 특수한 요구 사항이 존재하는 경우에는 직접 구현해서 클러스터를 유연하게 확장해보는 것도 좋을 것입니다.

주요내용 25년 1월 소식에서는 다음과 같은 유용한 정보들을 만나보실 수 있습니다. Web Almanac 2024 전 세계 1천7백만개의 웹사이트를 분석, 현재 웹 기술의 사용과 웹페이지들의 상태를 확인할 수 있습니다. JavaScript Benchmarking Is a Mess JS 벤치마크 수행결과는 얼만큼 신뢰할 수 있을까요? 어떤 요소들이 결과에 영향을 미치고 있을까요? How Big Technical Changes Happen at Slack Slack은 기술적 변화를 어떻게 관리하고 기술 도입을 위한 전략을 확인해 보세요. Anders Hejlsberg: How we wrote the TypeScript compiler TS 코어 개발자로부터 들어보는 TS 컴파일러 개발 이야기 >> FE News 25년 1월 소식 보러가기 ◎ FE News란? 네이버 FE 엔지니어들이 엄선한 양질의 FE 및 주요한 기술 소식들을 큐레이션해 공유하는 것을 목표로 하며, 이를 통해 국내 개발자들에게 지식 공유에 대한 가치 인식과 성장에 도움을 주고자 하는 기술소식 공유 프로젝트 입니다. 매월 첫째 주 수요일, 월 1회 발행 되고 있으니 많은 관심 부탁드립니다. ▷ 구독하기
![[DAN 24] LLM의 Re-Ranking Ability 검색에 이식하기 2편 - LLM을 활용한 최신성 반영](https://d2.naver.com/content/images/2024/12/13_0_0.png)
지난 글 LLM의 Re-Ranking Ability 검색에 이식하기 1편 - LLM 이식 방법에서는 거대 언어 모델(Large Language Model, LLM)의 랭킹 능력을 네이버 검색 모델에 이식한 과정을 설명했습니다. 이 글에서는 이어서 LLM을 활용해 이 모델이 문서의 최신성까지 함께 고려할 수 있도록 개선한 경험을 공유하려고 합니다. 연관성만으로 해결하기 어려운 랭킹 문제 검색 결과의 품질에는 문서의 연관성뿐만 아니라 최신성도 중요한 고려 사항입니다. "트래블러스 챔피언십 우승자"라는 질의를 예로 들어보겠습니다. 이 대회는 매년 개최되며 매회 새로운 우승자가 탄생합니다. 단순히 연관성만을 기준으로 본다면 2017년이나 2023년의 우승자 정보도 적절한 검색 결과로 볼 수 있습니다. 그러나 사용자가 실제로 원하는 정보는 최신 우승자인 2024년 우승자와 관련된 내용일 것입니다. 따라서 랭킹 과정에서는 연관성과 함께 시의성을 고려할 필요가 있으며, LLM을 활용한 재순위화에도 최신성 요소를 반영하는 것이 중요합니다. 최신성을 반영한 랭킹 모델은 다음과 같이 구상했습니다. 기존의 RRA 모델이 질의에 대한 문서들 간의 연관성 순위를 결정하는 데 주력했다면, 새로운 접근법에서는 모델이 질의와 문서의 연관성을 0과 1 사이의 점수로 예측하고 이 점수를 활용하여 연관도가 높은 문서들 사이에서 최신성을 고려해 순위를 결정하는 방식을 채택했습니다. 예를 들어, 문서 1과 문서 2가 모두 연관도가 높다면 더 최신 문서인 문서 2를 상위에 배치하는 것이 바람직할 것입니다. 반면 문서 3은 문서 2와 최신성은 동일하더라도 연관도가 낮다면 순위를 높이지 않는 것이 좋습니다. 이러한 접근법으로 기존 RRA와 유사한 모델 구조를 유지하면서도 연관성 점수를 정확히 예측할 수 있다면, 문서의 최신성을 랭킹에 효과적으로 반영할 수 있습니다. 이렇게 개선된 모델을 RRA-T라고 명명했으며, 그 핵심은 신뢰할 수 있는 연관성 점수를 얼마나 잘 산출할 수 있느냐에 달려 있습니다. 기존 모델도 시그모이드(sigmoid) 계층을 통해 0과 1 사이의 값을 출력할 수 있었습니다. 그러나 이는 문서 쌍 간의 순위를 구분하도록 학습된 모델이어서 점수 분포가 균일하지 않았습니다. 이로 인해 어느 구간을 기준으로 최신성 랭킹를 적용해야 할지 판단하기가 매우 어려웠습니다. 이러한 문제를 해결하기 위해 모델을 새로 학습하기로 결정했습니다. 기존에 질의와 문서의 랭킹 레이블을 LLM을 활용해 생성했던 것처럼, 질의와 문서의 연관성 점수 레이블도 LLM을 활용해 생성하는 방식을 채택했습니다. 랭킹과 점수 레이블링 방식 LLM을 통해 RRA-T의 학습 데이터를 생성하는 과정에서 여러 선택지가 있었습니다. 예를 들어 기존의 목록 단위 랭킹 방식을 유지하면서 연관성 점수만 별도로 생성하거나, 새로운 프롬프트를 만들어 랭킹과 연관성 점수를 동시에 생성하는 방식을 고려했습니다. 또한 사고 연쇄(Chain of Thought) 전략을 차용하여 생성 과정에서 순위와 점수를 결정하게 된 근거를 출력하도록 하는 방식도 가능했습니다. 그러나 정답 레이블이 없는 상황에서 LLM을 통해 학습 데이터를 생성하는 만큼, 어떤 전략이 최선인지 선택하기가 쉽지 않았습니다. 다행히도 기존 RRA가 이미 서비스 중이었기 때문에 사용자의 피드백, 즉 RRA가 노출된 검색어와 관련된 클릭 로그를 활용할 수 있었습니다. 저희는 사용자의 클릭 경향과 가장 유사한 레이블을 생성하는 프롬프트를 선택함으로써 최적의 RRA-T 학습 데이터를 생성하고자 했습니다. 이 과정을 간단히 도식화하면 다음과 같습니다. 먼저 사용자 피드백 로그에서 프롬프트를 평가하는 데 유효한 데이터를 추출합니다. 특정 질의에 대한 검색 결과 문서들과 그에 대한 사용자 반응의 정도를 추출한 뒤, LLM에 후보 프롬프트와 함께 질의와 문서를 맥락으로 제공하여 연관성 점수와 순위를 생성하도록 합니다. 이를 통해 실제 사용자 피드백과 경향성이 가장 유사한 프롬프트를 선택할 수 있었습니다. 사용자의 피드백이 있다면 왜 바로 학습 데이터로 사용하지 않는지 궁금하실 수 있을 것 같은데요, 이는 RRA가 목표로 하는 롱테일 질의의 특성과 관련이 있습니다. 발생 빈도가 낮은 질의들이다 보니 실제로 질의와 연관성이 높은 문서라 하더라도 사용자가 클릭하지 않은 경우가 많이 존재할 수 있기 때문입니다. 이러한 데이터를 그대로 학습에 사용하면 모델이 거짓 부정(false negative) 사례를 잘못 학습할 우려가 있습니다. 따라서 저희는 LLM이 가장 사용자 행동과 유사한 결과를 생성하는지 검증하는 용도로 사용자 피드백을 활용하고, LLM이 레이블링한 완전한 데이터를 RRA-T 학습에 사용하는 전략을 채택했습니다. 또한, 프롬프트를 비교하고 평가하기 위해 사용자 피드백 로그를 정제하는 과정도 중요했습니다. 상위 노출을 위한 비정상적인 클릭이 있는 문서를 제거하고, RRA가 목표로 하는 롱테일 질의의 특성에 맞추어 발생 빈도가 지나치게 높은 질의를 제외했습니다. 또한 사용자의 피드백을 충분히 구분할 수 있을 만큼의 검색 결과가 존재하고 실제 문서 클릭이 있었던 질의를 선별했습니다. 이러한 과정을 통해 20만 개의 질의와 이와 연관된 300만 개의 문서를 추출하여 프롬프트 검증에 활용할 수 있었습니다. 프롬프트 선정을 위한 비교 평가 먼저 다음의 두 가지 방식을 비교했습니다. 목록 단위(랭킹) + 개별 단위(스코어링): 기존 방식과 동일하게 랭킹 레이블을 생성하고 연관성 점수 데이터를 추가로 생성하여 합치는 방식 목록 단위(랭킹 & 스코어링): 랭킹과 연관성 점수를 한 번에 생성하는 방식 목록 단위(랭킹) + 개별 단위(스코어링) 목록 단위 랭킹(list-wise ranking)은 하나의 질의와 여러 문서가 주어졌을 때 문서의 순위를 한 번에 결정하는 방식으로, 기존 RRA 모델의 학습 데이터 생성에 사용되었던 방식입니다. 여기에 개별 단위(point-wise)로 문서별 연관성 점수를 레이블링하고 두 데이터를 결합하여 순위와 연관성 점수 학습 데이터를 만들 수 있습니다. 목록 단위(랭킹 & 스코어링) 다른 방식은 질의와 관련 있는 여러 문서들이 주어졌을 때 문서들의 랭킹과 연관 점수를 목록 단위(list-wise)로 한 번에 출력하도록 구성하는 것입니다. 분석 결과 우선 랭킹 관점에서 두 방식을 비교했습니다. 사용자 피드백의 클릭 순서를 정답 순위로 간주하고, 각 프롬프트가 생성한 랭킹의 성능을 비교했습니다. K순위 내 문서 클릭 수나 NDCG, MAP, MRR 등 일반적인 랭킹 평가 지표에서 목록 단위(랭킹 & 스코어링) 프롬프트를 이용한 레이블링 방식이 일관되게 더 우수한 성능을 보였습니다. 다시 한 번 강조하자면 여기서 말하는 성능이란, 사용자가 본 문서의 피드백과 일치하는 정도를 뜻합니다. 연관도 스코어링 관점에서도 비교를 진행했습니다. 이진 분류 문제로 간주하여 실제 클릭된 문서를 양성, 클릭되지 않은 문서를 음성으로 하고, LLM이 예측한 점수가 5점 이상일 때 양성, 미만일 때 음성으로 설정하여 정확도(accuracy), 정밀도(precision), 재현율(recall), F1 스코어를 측정했습니다. AUC(area under the curve) 점수는 LLM이 예측한 점수를 9로 나누어 0에서 1 사이의 값으로 정규화하여 계산했습니다. 스코어링 관점에서도 목록 단위 방식이 더 우수한 성능을 보였습니다. 두 프롬프트를 더 깊이 이해하기 위해 예측 점수와 클릭 여부의 분포를 시각화하여 분석했습니다. 상단 그래프는 X축을 LLM이 예측한 점수, Y축을 발생 빈도로 하고 실제 사용자 클릭이 있었던 경우는 빨간색, 없었던 경우는 파란색으로 표시했습니다. 아래의 그래프는 이를 100% 기준의 비율로 나타낸 것입니다. 분석 결과, 개별 단위 방식은 점수가 특정 구간에 집중되는 현상을 보였습니다. 반면 목록 단위 방식에서는 클릭이 없는 문서는 낮은 점수 구간에, 클릭이 있는 문서는 높은 점수 구간에 잘 분포되는 것을 확인할 수 있었습니다. 이는 목록 단위 방식이 여러 문서를 동시에 평가하면서 상대적인 기준점을 설정할 수 있는 반면, 개별 단위 방식에서는 LLM이 그러한 기준점을 설정하기 어려워하는 것으로 해석됩니다. 결론적으로 랭킹과 스코어링 평가 모두에서 목록 단위 방식의 성능이 우수했기 때문에, 이 방식으로 데이터를 생성하기로 결정했습니다. 근거 생성 여부 다음으로는 생성 과정에서 근거를 포함하는 것이 유용한지, 만약 포함한다면 정답 레이블 이전에 생성하는 것이 좋을지 이후에 생성하는 것이 좋을지를 검토했습니다. 엑사 랭커(ExaRanker)라는 선행 연구에서는 레이블을 먼저 생성하고 근거를 나중에 생성하는 것이 더 효과적이라는 결과를 보고했습니다. 이는 LLM의 자기회귀적(auto regressive) 특성상 레이블 생성 시 이후에 생성될 근거의 영향을 받지 못할 것이라는 직관과는 상반되는 결과였기에, 이 연구 결과가 저희의 사례에도 적용되는지 검증이 필요했습니다. 앞서 선택한 목록 단위(랭킹 & 스코어링) 프롬프트를 기반으로 세 가지 방식을 비교했습니다. 근거 없이 연관성 점수만 생성하는 방식, 근거를 먼저 생성하고 연관성 점수를 생성하는 방식, 그리고 연관성 점수를 먼저 판단하고 근거를 생성하는 방식입니다. 스코어링 관점의 평가에서는 선행 연구의 결과와 일치하는 결과를 얻었습니다. 근거를 포함하지 않거나 먼저 생성하는 것보다 연관성 점수를 먼저 생성하고 근거를 나중에 생성하는 방식이 더 우수한 성능을 보였습니다. 그러나 랭킹 관점의 평가에서는 흥미롭게도 근거를 전혀 생성하지 않는 방식이 가장 높은 성능을 보였으며, 근거를 생성하는 두 방식 중에서는 근거를 나중에 생성하는 방식이 더 우수하여 스코어링과 랭킹 관점에서 상반된 결과가 도출되었습니다. 다만 근거 생성에는 2배 이상의 시간과 계산 비용이 소요된다는 점을 고려하여, 저희는 근거를 포함하지 않는 프롬프트를 채택하기로 결정했습니다. 실제 서비스 환경에서는 성능과 연산 비용 사이의 트레이드오프가 중요한 고려사항이기 때문입니다. 사용자 피드백 로그를 활용한 검증 과정을 통해 각 방식의 성능 차이를 정량적으로 가늠하고 판단할 수 있었습니다. 만약 근거 생성 방식의 성능 향상이 현저히 높았다면 다른 선택을 했을 수도 있었을 것입니다. 선정된 프롬프트를 활용한 모델 학습 앞선 과정을 통해 선정된 프롬프트는 LLM이 실제 사용자의 피드백과 경향성이 일치하는 레이블을 생성하는지 검증했습니다. 이 프롬프트를 통해 질의와 문서를 입력하면 LLM이 질의에 대한 문서의 랭킹과 연관도 스코어 레이블을 생성합니다. 이렇게 생성된 학습 데이터로 모델을 학습시키면 질의와 문서의 랭킹 및 스코어를 예측하는 RRA-T 모델이 만들어집니다. 학습 과정에서 주목할 만한 점은 기존 RRA가 순위 손실(rank loss)만을 사용했던 것과 달리, RRA-T는 연관성 점수 예측이라는 과제도 함께 수행한다는 것입니다. 구체적으로, 순위 손실로는 기존과 동일하게 랭크넷 손실(rank net loss)을 사용했으며, 점수 손실로는 이진 교차 엔트로피 손실(binary cross entropy loss)을 사용했습니다. 최종 손실은 이 두 손실의 가중 합(weighted sum)으로 계산하여 학습을 진행했습니다. Loss = α RankLoss(Q,D) + (1-α) ScoreLoss(Q,D) RankLoss: Rank Net ScoreLoss: Binary Cross Entropy - { y∙log(y ̂ )+(1-y)∙log(1-y ̂ ) } 학습된 모델의 점수 분포를 분석한 결과, RRA-T 모델이 출력하는 점수가 기존 모델에 비해 실제 테스트 데이터셋의 점수 분포와 더 유사한 것으로 나타났습니다. 또한 테스트 데이터셋에서의 성능 비교에서도 랭킹과 스코어링 모두에서 기존 모델보다 우수한 결과를 보였습니다. 여기서의 성능은 교사 모델인 LLM이 생성한 레이블과의 비교 결과이며, 실제 성능은 이후 온라인 A/B 테스트를 통해 검증했습니다. 랭킹에 최신성 반영하기 RRA-T 모델을 활용하여 최신성을 랭킹에 반영하는 방식을 설명하겠습니다. 앞서 설명한 것처럼, 저희는 문서의 랭킹과 연관도 스코어를 잘 예측하는 모델을 개발했습니다. 이제 이 모델의 출력 점수를 활용하여 최신성을 실제 랭킹에 적용하는 방법을 살펴보겠습니다. 랭킹 스코어 계산 방법 랭킹은 관련 문서들을 상대적으로 비교해야 하므로, 문서의 최신성과 랭킹 점수를 수치화할 필요가 있었습니다. 문서의 나이(age)는 현재 검색 시점과 문서의 생성 시점의 차이로 계산 문서의 최신성(recency)은 수식을 통해 정량화 이 최신성 수식은 나이가 적은 구간에서는 최신성의 변별력이 크고, 나이가 많은 구간에서는 변별력이 감소한다는 특성이 있습니다. 최종 랭킹 스코어는 다음과 같은 방식으로 결정됩니다. RRA-T 모델이 예측한 연관성 점수(LLM 점수)가 특정 임계값(llm_score_threshold) 이상인 문서에 대해서는, 최신성 가중치(recency_weight)가 반영된 최신성 점수와 LLM 연관성 점수의 가중 합으로 계산됩니다. 반면, 연관성 점수가 낮은 문서에는 페널티를 부여하여 연관도가 낮은 최신 문서가 상위에 배치되는 것을 방지했습니다. 이러한 모델링 방식에서는 최신성을 얼마나 중요하게 고려할지를 나타내는 최신성 가중치와, 일정 연관도 이하의 문서에 페널티를 부여하기 위한 LLM 점수 임계값을 결정해야 했습니다. LLM을 활용하여 하이퍼파라미터 결정 흥미롭게도 이 두 하이퍼파라미터의 결정에도 LLM을 활용할 수 있었습니다. 하이퍼파라미터 최적화에는 다음과 같은 방식을 활용했습니다. 최신성 가중치와 LLM 점수 임계값이 결정되면 RRA는 입력된 질의와 문서들에 대한 랭킹 목록을 반환합니다. 이렇게 반환된 결과를 LLM이 다시 랭킹하고, 이 LLM의 결과를 정답으로 삼아 RRA-T 모델의 성능을 평가했습니다. 또한 검색 결과 내의 최신 문서 비율도 함께 모니터링하면서, 최신 문서를 최대한 포함하면서도 성능은 유지하는 최적의 파라미터를 도출할 수 있었습니다. 하이퍼파라미터 튜닝 실험 결과를 살펴보면, 상위 5개 문서 중 작성일이 1년 이내인 문서의 비율이 최신성 가중치와 LLM 점수 임계값의 변화에 따라 어떻게 달라지는지 확인할 수 있었습니다. 최신성 가중치가 증가할수록 최신 문서의 포함 비율이 높아졌습니다. 반면 검색 품질과 관련된 NDCG와 MRR의 성능은 최신성 가중치가 과도하게 높아지면 하락하는 경향을 보였습니다. LLM 점수 임계값 역시 값이 커질수록 검색 품질은 전반적으로 향상되었으나, 최신 문서의 비율은 감소하는 상충 관계를 보였습니다. LLM을 통해 최신 문서를 충분히 포함하면서도 성능 하락이 크지 않은 파라미터 조합을 선택할 수 있었습니다. 이러한 파라미터 설정이 절대적인 최적값이라고는 할 수 없지만 좋은 시작점이 되어, 이후 A/B 테스트를 통해 파라미터 변경에 따른 사용자 반응 지표를 관찰하면서 지속적인 튜닝을 진행할 수 있었습니다. 개선 결과 RRA-T 적용 전후의 네이버 통합 검색 결과를 비교해보면, 개선된 시스템에서는 스포츠 관련 질의의 경우 최신 대회 결과가 상위에 배치되고, 최신 공연 정보가 상위에 노출되며, "워렌 버핏의 투자 포트폴리오" 같은 질의의 경우 시간에 따라 지속적으로 변화하는 정보의 특성을 고려하여 최신 문서를 우선 보여주는 등, 문서가 더 적절한 순위로 제공됨을 확인할 수 있었습니다. 최신성을 반영한 RRA-T의 온라인 테스트 결과는 매우 고무적이었습니다. 유의미한 성능 향상이 확인되어 전체 서비스에 적용되었으며, 현재 네이버 검색창에 롱테일 질의를 입력하면 이 모델이 적용된 결과를 확인하실 수 있습니다. 마치며 지금까지 검색 결과 랭킹에 최신성을 반영하기 위해 LLM을 최대한 활용한 경험을 공유했습니다. 문서의 순위와 연관성 점수를 레이블링하기 위한 여러 프롬프트 후보 중 최적의 선택을 위해 사용자 피드백 로그를 활용했으며, 하이퍼파라미터 선택에도 LLM을 효과적으로 활용했습니다. 발견/탐색 프로덕트 부문의 정보성/롱테일 검색 개선을 위한 이 프로젝트는 의미 있는 성과를 거두었으며, 앞으로도 지속적인 개선을 통해 더 나은 검색 경험을 제공하기 위해 노력하겠습니다. 이 글은 TEAM NAVER CONFERENCE ‘DAN 24’ 에서 발표한 내용을 토대로 작성되었으며, 발표 자료는 DAN 24에서 보실 수 있습니다.
![[DAN 24] LLM의 Re-Ranking Ability 검색에 이식하기 1편 - LLM 이식 방법](https://d2.naver.com/content/images/2024/12/01-1.png)
검색은 크게 탐색형과 정보성으로 나눌 수 있습니다. 예를 들어 "캠핑"과 같은 질의는 구체적인 정보 취득보다는 탐색을 목적으로 하며 발생 빈도가 높습니다. 이와 같은 질의는 개인화를 고려하여 캠핑 장비 등 검색 의도 단위로 랭킹이 이루어집니다. 반면 "19개월 잠만자요"와 같은 질의는 영유아가 잠만 자는 문제에 대한 구체적인 정보를 원하는 질의로, 다양하고 발생 빈도가 낮아 롱테일 질의라고도 합니다. 이와 같은 질의는 인기글, 지식iN 등 출처를 기준으로 나뉜 컬렉션 단위로 랭킹이 이루어집니다. 이 글에서는 거대 언어 모델(Large Language Model, LLM)의 랭킹 능력을 활용하여 롱테일 질의의 랭킹을 개선한 방법을 다루고자 합니다. 다음과 같은 순서로 설명하겠습니다. LLM이 검색에 줄 수 있는 효과 롱테일 질의를 위한 재순위화(re-ranking) LLM을 활용한 최신성 반영 LLM이 검색에 줄 수 있는 효과 랭커(ranker)로서 LLM의 장점 "19개월 잠만자요"라는 질의를 예시로 들어보겠습니다. 이는 영유아가 잠만 자는 문제에 대해 알고 싶어하는 질의입니다. 기존 검색 결과에서는 아기의 수면과 관련된 질의라는 것은 파악했으나, '잠만 자요'가 아닌 '잠 안 자요'와 관련된 결과가 상위에 노출되는 등 세부적인 맥락 파악이 미흡한 경우가 있었습니다. 이로 인해 사용자의 정확한 클릭을 유도하지 못하고 양질의 정답 데이터셋 혹은 특징(feature)을 확보하기 어려워지는 악순환이 발생했습니다. 그러나 LLM의 발전으로 이러한 한계를 극복할 수 있게 되었습니다. 동일한 문서 풀에 대해 LLM 기반 재순위화를 적용한 결과, '잠만 잔다'와 관련된 문서는 상위로, '잠 안 잔다'와 관련된 문서는 하위로 적절하게 순위가 조정되었습니다. 이처럼 LLM은 기존에 처리하기 어려웠던 질의와 문서 간의 관계를 효과적으로 파악하고 랭킹할 수 있는 가능성을 보여주고 있습니다. 검색 서비스에서 LLM 사용의 한계 LLM을 랭커로 활용할 때는 크게 두 가지 방식이 있습니다. 첫째는 목록 단위 랭킹(list-wise ranking)으로, 검색된 문서들의 전체 순서를 결정하는 방식입니다. 둘째는 개별 단위 랭킹(point-wise ranking)으로, 각 문서의 연관성 여부를 개별적으로 판단하는 방식입니다. 그러나 이러한 방식들을 실제 검색 서비스에 적용하는 데에는 여러 한계가 존재했습니다. 목록 단위 랭킹 사용 시 생성형 LLM을 사용하면 순차적으로 결과가 생성되기 때문에, 생성이 모두 이루어진 후 렌더링을 하면 검색 결과가 바로 나오지 않습니다. 개별 단위 랭킹은 문서별 연관도를 병렬적으로 산출하고 종합할 수 있다는 장점이 있으나, 대규모 LLM을 병렬로 운용하는 것은 검색 트래픽 처리 측면에서 비용이 높고 관리가 어렵다는 한계가 있었습니다. 즉, LLM을 검색 랭커로 직접 사용하기에는 응답 속도, 비용, 관리 측면의 제약이 존재하여 실제로 활용하기는 어렵습니다. 이러한 한계를 극복하기 위한 저희의 접근 방식을 설명하겠습니다. 롱테일 질의를 위한 LLM 기반 재순위화(Re-Ranking) 네이버 검색에는 다음과 같이 의도가 아주 세밀하지만, 사용자들이 자주 검색하지는 않는 다양한 롱테일 질의가 존재합니다. 저희는 이런 질의에 대한 랭킹 성능을 올리기 위한 개발을 진행했습니다. "19개월 잠만자요" "신차구매시 기존 자동차보험 어떻게 해야하나요" "세입자가 안들어왔다고 돈을 안주는" "80대 요관암 말기 암 항암치료" 랭킹 고도화를 위해 다양한 접근 방법을 시도해보았는데 크게 세 가지 방향으로 분류할 수 있습니다. 접근 방향 한계 사용자 피드백 데이터셋 활용 롱테일 질의 관련 유의미한 피드백이 없거나 매우 적다 양방향 인코더 형식 모델 구조 의도가 명확하지 않거나 복잡하여 맥락을 이해하지 못한다 소형 생성형 모델 활용 어려운 맥락을 이해하지 못한다 먼저, 랭킹에서 일반적으로 활용되는 사용자 피드백 데이터셋을 활용하는 방식을 시도했습니다. 그러나 이는 효과적이지 않았는데, 주로 탐색형 질의에서 발생하는 피드백 데이터만으로는 복잡한 질의의 연관성을 위한 양질의 정답 데이터셋을 구축하기 어려웠기 때문입니다. 두 번째로는 모델 측면에서 일반화를 위해 양방향 인코더(bi-encoder) 형식의 모델 구조를 시도했습니다. 그러나 이러한 구조로는 세부적인 맥락을 포착하기 어려워 성능이 충분치 않았습니다. 세 번째로 LLM의 속도 문제를 해결하고자 소형 생성형 모델을 활용하는 시도도 있었으나, 상대적으로 작은 규모의 생성형 모델로는 복잡한 맥락에 대한 적절한 랭킹 결과를 생성하는 데 한계가 있었습니다. 랭킹을 위한 경량 언어 모델(sLM)과 지식 증류(distillation)의 필요성 앞에서의 시행착오를 바탕으로 문제를 해결하기 위해 다음과 같은 고민을 하고 결론을 얻었습니다. 고민 결론 LLM만의 고유한 장점은 무엇인가 복잡한 질의와 문서에 대한 정답 데이터셋을 만들 수 있다 반드시 대화형/생성형으로 문제를 해결해야 하는가 랭킹만 잘하면 된다 크고 느린 LLM이 꼭 필요한가 목적에 특화된 모델이 필요하다 즉, LLM을 랭커로 직접 사용하는 대신 양질의 정답 데이터셋 생성에 활용하고, 개발 목적상 대화형 결과가 아닌 정확한 랭킹이 핵심이고, 크고 느린 LLM 대신 목적에 특화된(task-specific) 모델이 필요하다는 결론을 내리게 되었습니다. 따라서 사용자 피드백이 아닌 연관성 정답 데이터를 LLM으로 확보하고, 불필요한 단어 생성 과정을 제거한 sLM(small Language Model, 경량 언어 모델) 랭커를 구축하기로 결정했습니다. 최종적으로는 롱테일 질의에 대한 랭킹에 특화된 모델로 지식 증류를 수행하는 방향으로 나아가게 되었습니다. 롱테일 질의의 문서 랭킹 품질 개선 최근 거대 언어 모델을 활용한 데이터셋 구축 연구가 활발히 진행되고 있습니다. 저희는 네이버의 대규모 언어 모델인 하이퍼클로바 X(HyperCLOVA X, 이하 HCX)를 활용하여 정답 데이터셋을 구축했습니다. 그 과정은 다음과 같습니다. 우선 질의에 대해 네이버의 기존 리트리버(retriever)로 문서를 수집합니다. 순위를 매기고자 하는 문서 집합을 LLM에 입력할 때는 순서와 구성이 중요했습니다. 이를 위해 기본적인 성능을 보이는 랭커를 구축하여 검색된 문서들의 순서를 정하고, 상위 10개와 하위 10개 정도로 필터링을 진행했습니다. 이렇게 선별된 20개의 문서에 대해 LLM에 순서 지정을 요청했습니다. 이 과정에서 흥미로운 현상이 발견되었는데, LLM이 일부 문서를 누락시키는 것이었습니다. 분석 결과 주로 연관성이 낮은 문서들이 제외되는 것을 확인했습니다. 여러 실험과 분석을 통해, 이러한 제외된 문서들을 어려운 부정 사례(hard negative) 샘플로 활용할 수 있다고 판단했고 롱테일 질의에 대한 학습 데이터를 이런 방식으로 구축했습니다. 이렇게 구성된 데이터셋으로 모델 학습을 진행했습니다. BERT 스타일과 GPT 스타일의 모델에 대한 학습 방식을 모두 개발했는데, 본 글에서는 개념 위주로 설명하겠습니다. 더 자세한 내용은 EMNLP 2024 Industry Track에 게재된 다음의 논문에서 확인하실 수 있습니다. - Nayoung Choi*, Youngjune Lee*, Gyu-Hwung Cho, Haeyu Jeong, Jungmin Kong, Saehun Kim, Keunchan Park, Sarah Cho, Inchang Jeong, Gyohee Nam, Sunghoon Han, Wonil Yang, Jaeho Choi. 2024. RRADistill: Distilling LLMs’ Passage Ranking Ability for Long-Tail Queries Document Re-Ranking on a Search Engine. EMNLP 2024. BERT 스타일 모델의 학습 방식 우선 BERT(Bidirectional Encoder Representations from Transformers) 스타일 모델의 학습 방식부터 설명하겠습니다. 기본적인 구조는 교차 인코더(cross-encoder) 형태를 채택했으며, 여기에 토큰 선택(token selection) 과정을 추가했습니다. 토큰 선택은 토큰 임베딩을 통해 문서 내의 토큰 중 질의 토큰과 유사한 것들을 식별하는 과정입니다. 이렇게 선택된 토큰들은 조건 제어 계층(term control layer)이라 부르는 얕은 어텐션(attention) 계층을 한 번 더 통과합니다. 이를 통해 CLS 표현(CLS representation)을 얻고, 이는 공유 분류 헤드(shared classification head)를 통과하여 최종 연관성 점수를 산출합니다. 학습 시에는 문서별 쌍의 순위 관계를 고려하는 쌍별 손실(pair-wise loss)인 랭크넷 손실(ranknet loss)을 사용했습니다. 이러한 방식을 통해 모델은 질의-문서 간의 기본적인 맥락을 파악하면서도 질의의 토큰과 유사한 토큰들에 집중할 수 있습니다. 이 접근법의 주목할 만한 특징은, 학습 시에는 용어 제어 계층을 활용하지만 추론 시에는 이를 제거할 수 있다는 점입니다. 이는 추론 시간을 증가시키지 않는 효율적인 방법이라고 할 수 있습니다. GPT 스타일의 랭커 학습 방법 다음으로 GPT 스타일의 랭커 학습 방법을 살펴보겠습니다. 저희는 GPT 모델을 랭커로 활용하기 위해 질의-문서에 대한 점수를 산출할 수 있도록 구성했습니다. 질의와 문서가 입력되면 연관성의 유무를 나타내는 레이블과 그 이유를 설명하는 추론(reasoning)을 출력하도록 학습시켰습니다. 연관성에 대해서는 토큰 확률(token probability)을 활용하여 교차 엔트로피 손실(cross entropy loss)을 적용하고, 이유 설명에는 생성 손실(generation loss)을 사용했습니다. 또한 응답의 은닉 상태(hidden state) 값을 얕은 계층에 통과시켜 점수를 산출하고, 여기에는 쌍별 손실을 적용했습니다. 이러한 방식으로 학습된 모델은 실제 서비스 시에는 레이블과 추론 부분을 생성하지 않도록 하여 랭킹에만 특화된 효율적인 GPT 기반 랭커를 구현할 수 있다는 특징이 있습니다. 학습된 모델의 평가 학습된 모델의 평가 결과는 매우 고무적이었습니다. 롱테일 질의에 대한 특화 학습을 통해 기존 방식과 비교하여 큰 성능 향상을 달성했습니다. 기존의 세 가지 방식이 롱테일 질의를 효과적으로 처리하지 못했던 것과 달리, 저희가 구축한 롱테일 타겟 데이터셋으로 학습한 모델은 현저히 향상된 성능을 보여주었습니다. 특히, 더 우수한 성능을 달성했을 뿐 아니라, 앞서 언급한 것처럼 추론 효율성까지 확보할 수 있었습니다. 실제 서비스에서는 RRA-BERT를 적용한 A/B 테스트를 진행했으며, 상위 문서의 클릭률이 증가하는 등 연관된 문서가 상위에 잘 배치되는 결과를 확인할 수 있었습니다. 실시간 서비스 적용을 위한 세부적인 기술적 고려사항은 네이버 D2 블로그에서 자세히 확인하실 수 있습니다. 생성형 AI 기반 실시간 검색 결과 재순위화 1편 - 서빙 시스템 아키텍처 생성형 AI 기반 실시간 검색 결과 재순위화 2편 - LLM 서빙 다음은 실제 개선된 검색 결과 사례입니다. 참고로 여기서 소개하는 사례는 특정 시점의 결과이며, 롱테일이라는 정의에는 여러 기준이 적용되기 때문에 실제 검색 결과는 시간에 따라 변화할 수 있습니다. 새로운 시스템은 복잡하고 세부적인 질의에 대해서도 효과적으로 관련 문서를 상위에 배치하는 것으로 나타났습니다. 예를 들어 "세입자가 안들어왔다고 돈을 안주는" 질의에 대해서는 임대료 미납 상황에서의 대처 방법을 다루는 문서가 상위에 배치되었습니다. 또한 "80대 요관암 말기 암 항암치료"와 같은 의료 관련 질의에 대해서도 관련성 높은 정보를 제공하는 글이 상위에 노출되었습니다. "19개월 잠안자요"라는 질의의 경우에도 영유아의 수면 문제 원인을 파악하기 위한 글이 효과적으로 상위에 배치되었습니다. 이러한 성과를 바탕으로, 저희는 시스템을 더욱 발전시키기 위한 후속 연구를 진행했습니다. 특히 검색 결과의 시의성을 개선하기 위한 연구가 이어졌는데, 이에 대해서는 다음 글에서 자세히 설명하겠습니다. 이 글은 TEAM NAVER CONFERENCE ‘DAN 24’ 에서 발표한 내용을 토대로 작성되었으며, 발표 자료는 DAN 24에서 보실 수 있습니다.
![[DAN 24] 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스 ① 홈피드와 교차 도메인 컨텍스트](https://d2.naver.com/content/images/2025/01/01.png)
"검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스" 시리즈에서는 네이버 발견/탐색 프로덕트에서 개발한 홈피드의 개인화를 위한 LLM 활용 방안을 소개합니다. 시리즈의 첫 번째 글에서는 홈피드가 무엇인지 소개하고, 교차 도메인 컨텍스트(cross-domain context)가 왜 필요하며, 이를 구현하기 위해 시스템을 어떻게 구성했는지 설명합니다. ② 사용자 검색 의도 세분화 ③ 사용자 관심 주제 추출 홈피드, 개인화 추천 피드 홈피드는 네이버에서 생산되는 블로그, 카페 등 다양한 UGC(user-generated content) 중에서 사용자가 관심 있을 만한 콘텐츠를 찾아서 보여주는 서비스입니다. 기본적으로 사용자가 구독한 채널, 읽은 문서 등 사용자의 활동을 기반으로 맞춤 콘텐츠를 추천합니다. 사용자가 확인한 콘텐츠를 더 자세하게 탐색할 수 있도록 관련 콘텐츠도 함께 추천하며, 관심 있는 키워드도 찾아서 제공하고 있습니다. 네이버 홈피드는 2023년 8월에 정식 서비스를 오픈했습니다. 네이버 앱에서 검색창 하단에 위치해 있으며, 아래로 스크롤해서 진입할 수 있습니다. 기본적으로 1:1 비율의 큰 이미지를 중심으로 네이버의 콘텐츠를 소비할 수 있는 개인화 추천 피드입니다. 기존 사용자 컨텍스트의 한계 홈피드는 개인화 추천이 중요한 서비스입니다. 따라서 개인화 추천의 재료가 되는 사용자 컨텍스트를 잘 쌓는 것이 첫 번째 단계입니다. 하지만 서비스 오픈 초기에는 신규 사용자가 많아 사용자 컨텍스트가 부족한 사용자의 비율이 높았습니다. 2023년 말 기준 홈피드 사용자군을 보면, 라이트 사용자의 비율이 약 85%로 큰 비중을 차지했습니다. 라이트 사용자는 홈피드에서 활동한 데이터가 적어 적절한 수준의 개인화 추천을 제공하기 어렵다는 문제가 있었습니다. 그런데 흥미로운 데이터 분석 결과를 발견했습니다. 라이트 사용자 중에서 검색이나 메인 주제판을 이용하는 사용자의 비율이 각각 95%, 50% 정도로 꽤 높았다는 점입니다. 이러한 발견을 토대로, 다른 서비스의 사용자 활동 정보를 함께 사용하여 부족한 사용자 컨텍스트를 확장하는 교차 도메인 컨텍스트 방법론을 고안하게 되었습니다. 즉, 홈피드에서 쌓인 사용자 컨텍스트만으로는 개인화 추천을 제공하기 어려웠지만, 검색이나 메인 주제판과 같은 네이버의 다른 서비스에서 얻은 사용자 컨텍스트를 함께 활용하여 사용자의 관심사와 선호도를 보다 풍부하게 반영할 수 있었습니다. AiRScout: LLM 기반 사용자 컨텍스트 확장 모델 AiRScout는 3가지 LLM(large language model) 모듈과 이를 통해 생성되는 5가지의 사용자 컨텍스트로 구성된 LLM 기반의 사용자 컨텍스트 확장 모델입니다. AiRScout라는 이름은 팀 이름인 AiRS에 'context of user tag'의 의미를 더한 것입니다. 사용자 관심 주제 추출 파이프라인 사용자의 관심 주제를 추출하는 파이프라인은 다음과 같이 구성했습니다. ① 주제 분류 단계 다양한 형태의 UGC 문서에 대해 통합된 하나의 주제 분류기를 사용하여 콘텐츠의 주제를 생성합니다. IAB 분류 체계를 사용하며 약 600여 개의 주제가 있습니다. 예를 들어, 특정 콘텐츠의 주제가 '게임 중에 E스포츠'라는 것을 파악할 수 있습니다. ② 소비 이력 분석 단계 서비스별 사용자의 소비 이력을 함께 활용합니다. 사용자가 어떤 주제의 콘텐츠를 주로 확인했는지 분석하고 모델링하여 사용자의 관심 주제를 추출합니다. 관심 주제의 선호도 점수는 0에서 1 사이로 결정되며, 선호도가 높을수록 1에 가까운 값이 되도록 모델링됩니다. 예를 들어, 특정 사용자가 부동산 관련 콘텐츠를 많이 검색하거나, 홈피드에서 예능, 골프, SUV 자동차에 관심이 많다는 것을 파악할 수 있습니다. ③ 통합 및 저장 단계 검색, 홈피드, 메인 주제판의 관심 주제를 하나로 통합하여 AiRScout에 저장합니다. 이는 짧은 배치 주기로 연동되어 있으며, AiRScout를 통해 홈피드나 서치피드 등 여러 피드 지면에 유통됩니다. 검색 의도 세분화 과정 검색 의도 세분화는 기본적으로 검색 소비 이력을 활용합니다. 사용자가 검색한 질의에 대해 어떤 문서를 확인했는지를 바탕으로 원질의를 더 구체화된 형태로 생성하는 과정입니다. 단순하게 보면 원질의 Q를 요약 생성 질의 Q&ast로 변환하는 문제입니다. 예를 들어, '에스파'라는 원질의에서 '에스파 일본 앨범 Hot Mess'라는 세부적인 검색 의도를 도출할 수 있습니다. 이러한 추론 파이프라인은 거의 실시간으로 동작하도록 시스템을 구성했습니다. 따라서 검색 직후에 AiRScout에 데이터가 인입되고, 홈피드에서 이를 바로 추천에 사용할 수 있습니다. 서비스 적용 사례 AirScout 기술을 적용해 다양한 개인화 추천 서비스를 제공하고 있습니다. AfterSearch: 실시간 검색 이력 기반 추천 적용 사례 AiRScout 기술을 활용한 실시간 검색 이력 기반 추천을 'AfterSearch'라고 부릅니다. 예를 들어, 사용자가 통합 검색에서 '두바이 초콜릿'을 검색한 후 피드를 탐색하면, 홈피드 결과를 새로 고침했을 때 '최근 찾아본 두바이 초콜릿 콘텐츠'라는 추천 사유와 함께 관련 콘텐츠가 추천됩니다. 서치피드: 숏텐츠 및 재검색 질의 적용 사례 AiRScout 기술은 서치피드 내 숏텐츠와 재검색 질의에도 활용됩니다. 숏텐츠: 생산과 소비가 활발한 최신 인기 문서를 분석해 핵심 주제를 추출합니다. 예를 들어, 이강인과 황희찬 관련 핵심 주제가 추출되고, 이를 바탕으로 관련 콘텐츠를 제공합니다. 이 과정에서 AiRScout의 요약 생성 질의와 주제 분류 결과를 활용하여 '축구'와 같은 주제로 클러스터링합니다. 재검색 질의: '아이유'를 검색하면 '아이유 데뷔 16주년 기부'와 같은 더 구체적인 재검색 질의를 추천할 때도 AiRScout의 요약 생성 질의를 활용합니다. 이상으로 AiRScout에 대한 전반적인 소개를 마칩니다. 이 시리즈의 다음 글에서는 요약 생성 질의, 즉 '의도 세분화'의 구체적인 구현 방법을 설명합니다. 이 글은 TEAM NAVER CONFERENCE ‘DAN 24’에서 발표한 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스를 토대로 작성되었습니다.
![[DAN 24] 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스 ② 사용자 검색 의도 세분화](https://d2.naver.com/content/images/2025/01/07.png)
"검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스" 시리즈의 두 번째 글에서는 '사용자 검색 의도 세분화'에 적용한 기술을 설명합니다. ① 홈피드와 교차 도메인 컨텍스트 ③ 사용자 관심 주제 추출 의도 세분화 구현 배경 "① 홈피드와 교차 도메인 컨텍스트"에서 소개한 홈피드의 AfterSearch와 숏텐츠에서 질의만으로 서비스를 제공할 경우 정교한 추천과 구체적인 트렌드 제공에 한계가 있었습니다. 예를 들어, 사용자가 '에스파'를 검색해 위플래시 관련 문서를 확인한 경우에도 '에스파'라는 키워드만으로 추천이 이루어지면 '에스파 패션'과 같은, 맥락과 동떨어진 콘텐츠가 추천되는 한계가 있었습니다. 숏텐츠에서 트렌드 질의만 표시할 경우에도 사용자가 실제 문서를 읽어보기 전까지는 구체적인 내용을 직관적으로 파악하기 어려웠습니다. 반면 정교한 추천을 위해 문서만 분석하면 해당 문서가 검색된 맥락을 놓치게 되는 문제가 발생했습니다. 네이버의 방대한 UGC(user-generated content)는 하나의 글에 여러 주제를 포함한다는 특징이 있습니다. 따라서 동일한 글이라도 어떤 사용자는 '에스파'를, 또 다른 사용자는 '트와이스 포토카드'를 검색해 접근할 수 있습니다. 이렇게 문서만을 기반으로 요약 질의를 추출하면 해당 문서가 검색된 맥락, 즉 원래 질의와 무관한 키워드가 추출될 위험이 있습니다. 이러한 아쉬움을 해결하고 정교함과 맥락을 모두 확보하려면 질의와 문서를 통합적으로 고려하는 모델이 필요했습니다. 이에 질의 Q를 기반으로 문서 D의 핵심을 결합해 새로운 요약 생성 질의 Q&ast를 생성하는 모델을 개발하기로 했습니다. 이때 언어 모델은 단순 추출 요약뿐만 아니라 패러프레이징, 자연스러운 어순 등을 모두 고려해야 했으므로, 생성형 모델이 적합하다고 판단해 사내 LLM(large language model)인 HyperCLOVA X를 선택했습니다. HyperCLOVA X는 다양한 네이버 내 UGC를 학습해 인플루언서명, 브랜드명, 프로그램명 등 한국어에 특화된 NLU(natural language understanding) 성능을 보유하고 있어 실제 콘텐츠를 이해하는 데 유리했습니다. 검색 로그를 활용한 지도 학습 미세 조정 모델 구조를 살펴보면, 입력값으로는 질의 Q와 연관 문서 D가 사용되며, 출력값은 문서를 기반으로 구체화된 요약 생성 질의 Q&ast가 됩니다. 이때 입력값은 통합 검색 로그로 쉽게 수집할 수 있었으나, 출력값인 Q&ast는 정답 자체가 존재하지 않았습니다. 이에 사용자들이 해당 문서를 찾는 구체적인 이유를 검색 로그로 찾아낼 수 있을 것이라 판단해 Q&ast를 찾아내는 작업을 진행했습니다. 먼저 하나의 문서에 유입된 모든 질의를 수집합니다. 앞선 예시 문서에는 '에스파', '트와이스', '에스파 일본 앨범' 등 다양한 질의로 진입할 수 있었습니다. 그런 다음 수집된 Q 목록에서 서로 포함 관계인 질의 쌍을 나열합니다. 여기서 포함 관계란 각 질의를 토큰화했을 때 하나의 질의가 다른 질의 토큰의 일부를 포함하고 있는 관계를 의미합니다. 예를 들어 '에스파'라는 질의는 '에스파 일본', '에스파 앨범 언박싱' 등과 포함 관계가 됩니다. 동일한 방법으로 '트와이스' 질의의 포함 관계 질의도 수집할 수 있습니다. 이렇게 수집된 질의들은 원질의 Q를 포함하고 있으면서 정보를 더 많이 가지고 있습니다. 즉, '에스파'를 검색하고 예시 문서 D를 소비했을 때 요약 생성 질의 Q&ast가 될 수 있는 후보군입니다. 이 중에서 모델의 정답으로 활용할 Q&ast는 하나만 필요하므로, 정보를 가장 많이 담고 있는 질의를 최종 정답으로 선택합니다. 토큰 수가 가장 많은 질의가 가장 많은 정보를 담고 있다고 가정하고, 해당 질의를 Q&ast로 선택했습니다. 에스파 예시에서는 '에스파 일본 앨범 Hot Mess'를, 트와이스 예시에서는 '트와이스 일본 Dive'를 Q&ast로 선택합니다. 모델 학습 구성 이와 같은 방법으로 질의 Q와 문서 D가 입력됐을 때의 정답 Q&ast를 수천 건 수집한 후, 간결한 명령어(instruction)를 추가해 지도 학습 미세 조정(SFT, supervised fine-tuning)을 진행했습니다. 명령어 따르기(instruction following)를 기대하며 프롬프트를 상세히 작성할 수도 있었으나, 네이버에서 발생하는 검색량이 방대해 배치당 수백만 건의 추론을 신속하게 처리해야 했기에 프롬프트는 간단하게, 모델은 경량화하여 접근했습니다. 이에 따라 HCX-L(HyperCLOVA X-Large) 모델 대비 파라미터 크기가 5% 미만인 HCX-S(HyperCLOVA X-Small) 모델을 활용하기로 했습니다. 학습된 모델의 추론 결과를 분석한 결과, 팝업 스토어를 검색한 사용자가 구체적으로 어떤 팝업에 관심을 가졌는지, 대형 텐트를 검색해 어떤 제품을 상세히 탐색했는지 등 사용자의 의도를 더욱 구체화할 수 있었습니다. 검색 패턴에 따른 데이터 편향 적절한 Q&ast를 생성하는 경우도 있었던 반면, 수집한 데이터세트의 명확한 한계도 발견됐습니다. 특히, 사용자들의 빈번한 검색 패턴에 의해 데이터와 모델이 편향되는 현상이 나타났습니다. 예를 들어, 인물명 다음에는 '인스타'를, 작품명 다음에는 '등장인물'이나 '줄거리'를 붙여 검색하는 경우가 많아서 학습 데이터에도 이러한 패턴이 반영됐고, 결과적으로 모델은 문서의 핵심 내용과 무관하게 자주 등장하는 패턴을 출력하게 됐습니다. 이러한 패턴을 모두 정의하고 필터링할 수 있다면 앞서 구축한 수천만 건의 데이터를 정제해 활용할 수 있겠으나, 문제는 편향되는 패턴의 종류가 무한하다는 점이었습니다. 모든 패턴을 정의하고 제거하기가 매우 어려운 과제임을 인식하고 사용자 피드백에 과도하게 의존하지 않는 방식이 필요하다고 판단해, HCX-L 모델을 활용한 데이터세트 생성을 시도했습니다. HCX-L 기반 적은 예시 데이터 증강 먼저 HCX-L을 활용한 적은 예시 데이터 증강(few-shot augmentation)을 시도했습니다. 대형 모델의 명령어 따르기 능력을 적극 활용하고자 프롬프트에 작업의 성격과 제약 조건, 참고할 만한 예시를 5개 미만으로 포함했습니다. 그러나 작업의 모든 제약 조건을 프롬프트에 상세히 명시하기가 불가능했고, 명시하더라도 실제 모든 결과가 이 조건을 만족하지는 않았습니다. 구체적인 결과를 살펴보면, 질의에 등장한 인물명이나 작품명을 누락하거나, 질의를 적절히 패러프레이징하지 못하고 단순 나열하는 등 품질이 낮은 사례가 자주 발견됐습니다. 질의에 포함된 핵심 키워드를 유지해야 한다거나 단순 나열을 피해야 한다는 조건이 프롬프트에 명시되어 있고 이를 적은 예시로 유추할 수 있음에도 불구하고, 모든 결과가 이러한 조건을 따르지는 않아 품질 관리에 어려움이 있었습니다. HCX-S를 안정적으로 학습시키려면 최소 수천 건 단위의 고품질 데이터세트가 필요했으나, 앞서 시도했던 두 가지 방식만으로는 각각 다음과 같은 문제가 있음을 확인했습니다. 검색 로그를 활용한 지도 학습 미세 조정 시 수집된 데이터가 특정 패턴에 편향되기 쉽습니다. HCX-L 기반 적은 예시 데이터 증강 적용 시 HCX-L의 논리적 사고 능력(reasoning)이 뛰어나도 다양한 패턴을 명시하고 강제할 수 없어 품질 관리가 어렵습니다. 이러한 상황에서, 편향되지 않은 고품질 데이터세트가 소규모로라도 있다면 문제를 효과적으로 해결할 수 있을 것으로 판단했습니다. 일반적으로 파라미터 크기가 큰 LLM일수록 명령어 따르기뿐만 아니라 논리적 사고 능력도 우수하므로, 미세 조정 시 적은 데이터만으로도 모델을 신속하게 수렴시킬 수 있습니다. 즉, 소규모 데이터로도 HCX-L을 한 번 조정(tuning)하여 데이터에 대한 이해도를 높인다면, 이 모델로 데이터를 증강하여 작은 모델을 조정할 데이터세트를 확보할 수 있습니다. 이러한 접근은 HCX-L 모델의 지식을 작은 학생 모델(HCX-S)에게 전수하는 지식 증류(knowledge distillation) 문제로 귀결됩니다. 지식 증류 지식 증류는 크게 4단계의 과정을 거쳐 진행했습니다. 먼저, 직접 레이블링한 수백 건 단위의 데이터를 수집하고 HCX-L을 학습해 교사 LLM(Teacher LLM)을 구축했습니다. 이후 교사 LLM으로 데이터를 증강해 학습 데이터를 수천 건 단위로 확장했으며, 마지막으로 HCX-S 모델을 학습시켜 학생 LLM(Student LLM)을 생성했습니다. 각 단계를 상세히 설명하자면, 먼저 교사 모델 학습을 위한, 사람이 레이블링한 우수 데이터세트(human labeled gold set)의 경우, 최대한 다양한 패턴을 반영하도록 단순 추출 요약부터 패러프레이징, 자연스러운 어순 등을 고려하여 작성했습니다. 또한 특정 카테고리에 편향되지 않도록 질의를 약 30가지 카테고리로 나누고 카테고리별로 수십 건의 데이터를 수집했습니다. 그리고 교사 모델을 구축할 때는 처리 속도보다 품질이 중요하므로, 프롬프트는 앞서 진행한 적은 예시 데이터 증강과 같이 상세하게 작성하여 빠르게 수렴할 수 있도록 했습니다. 이러한 지도 학습 미세 조정 과정을 거쳐 교사 LLM을 구축했습니다. 다음으로, 검색 로그에서 샘플링한 수천 건의 질의 Q와 문서 D를 교사 모델에 입력해 교사가 레이블링한 우수 데이터세트(teacher labeled gold set)를 확보했습니다. 입력 데이터 역시 특정 카테고리에 편향되지 않도록 질의별로 수백 건을 샘플링했습니다. 이렇게 생성된 데이터에 간략한 프롬프트를 추가해 HCX-S를 학습시켰고, 교사 모델의 지식이 전달된 학생 LLM을 구축했습니다. 세 모델의 결과를 정성적으로 비교했을 때, 학생 모델에서 기존 검색 로그 기반 모델의 편향 사례가 개선됐고 교사 모델의 결과와 유사한 수준으로 주요 핵심을 잘 반영하여 질의를 생성하는 것을 확인할 수 있었습니다. 오프라인 품질 평가 앞에서 생성된 모델들의 성능을 정량적으로 평가하기 위해 다음의 3가지 지표를 활용했습니다. ROUGE 스코어: 전통적인 지표로, 토큰 단위 정확 일치(exact match) 성능을 신속하게 평가 BERT 스코어: ROUGE가 포착하지 못하는 의미적 일치(semantic match) 평가 HCX-Eval(HCX-L 모델 활용): ROUGE와 BERT 스코어로는 평가하기 어려운, 즉 단순 키워드 단위 비교로 평가할 수 없는 요소(어순의 자연스러움, 허위 사실 여부 등)까지 정량화 평가 데이터로는 사람이 레이블링한 데이터세트(human labeled dataset) 중 학습에 사용하지 않았던 데이터를 활용했습니다. 평가 결과는 다음과 같았습니다. 검색 로그 기반 모델: BERT 스코어 F1 기준 0.77로 성능이 높지 않음 조정되지 않은 HCX-L(few-shot): 프롬프트 길이와 무관하게 검색 로그 기반 모델보다 성능이 저조 교사 LLM(소규모 고품질 데이터세트 학습): BERT 스코어 F1 기준 0.91로 목표 품질에 높은 수준으로 도달 학생 LLM: 교사 LLM을 제외한 모든 모델의 성능을 상회하며, 검색 로그 기반 모델 대비 BERT 스코어 F1 기준 약 7% 성능 향상 HCX-S 서빙과 추론 파이프라인 오프라인에서 모델이 검증되었으니 온라인에서 그 효과를 확인할 차례입니다. HCX-S 모델을 서빙하기 위한 파이프라인을 구성했습니다. 서빙 최적화 오프라인에서 모델을 검증한 후 온라인 서비스에 적용하기 위해 서빙 전용 프레임워크인 vLLM을 도입했습니다. 허깅 페이스(Hugging Face)로는 온라인 추론(online inference)을 신속하게 처리하기 어려웠기 때문입니다. vLLM의 적용 효과는 다음과 같습니다. 모델 병렬 처리(model parallel)를 적용해 multi GPU로 병렬 연산 가능 페이지드 어텐션(paged attention)과 연속적 배치 처리(continuous batching)로 처리량 향상 기존의 허깅 페이스 배치 파이프라인 대비 QPS 최대 400% 개선 또한 검색량은 시간에 따라 달라지므로 매번 최대 리소스를 점유할 필요가 없습니다. 오토 스케일링(auto scaling)과 제로 스케일링(zero scaling)으로 검색량에 따라 앱 복제 인스턴스(replica) 수를 조절해 GPU 리소스를 효율적으로 활용하도록 구성했습니다. 검색 로그 추론 파이프라인 전체 파이프라인 구성은 다음과 같습니다. 검색 로그가 에어플로(Airflow) 배치를 통해 사용자별, 질의별로 그룹화되어 유입됩니다. 검색어별로 여러 문서를 클릭하면 하나로 연결(concat)하여 추론(inference)합니다. 생성된 데이터는 개인화 피처 스토어인 Brownie에 저장되어 개인화 알림 발송에도 활용됩니다. 온라인 평가 앞에서 언급한 기술을 적용한 결과, 홈피드의 AfterSearch와 숏텐츠 서비스가 다음과 같이 개선된 것을 확인할 수 있었습니다. 예를 들어, 사용자가 '에스파'를 검색하고 'whiplash' 문서를 확인한 경우, 에스파의 위플래시 관련 문서를 보다 정확하게 추천할 수 있게 됐습니다. 정확한 수치로 보면, 기존의 질의 기반 추천 대비 요약 질의 추천의 클릭률(CTR, click-through rate)이 7.74% 상승했습니다. 숏텐츠에서는 사용자들이 문서를 직접 읽지 않고도 현재 발생하고 있는 트렌드를 한눈에 파악할 수 있게 됐습니다. 향후 개선 계획 모델의 성능이 상당히 개선됐으나 여전히 개선할 부분이 남아 있습니다. 특히 사용자의 시선을 사로잡을 수 있는 흥미로운 키워드로 요약하는 능력과, 오해의 소지가 있는 단어를 적절히 순화하는 능력을 향상할 수 있도록 다음과 같은 방향으로 개선할 계획입니다. 선호도 최적화(preference optimization) 기반의 대규모 언어 모델 정렬(LLM alignment) 강화 논리적 사고 능력(reasoning)을 활용한 학생 모델(student model) 성능 강화 "검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스" 시리즈의 다음 글에서는 카테고리 수준의 사용자 관심사를 파악하기 위해 주제 분류기를 고도화한 과정을 설명합니다. 이 글은 TEAM NAVER CONFERENCE ‘DAN 24’에서 발표한 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스를 토대로 작성되었습니다.
![[DAN 24] 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스 ③ 사용자 관심 주제 추출](https://d2.naver.com/content/images/2025/01/24.png)
이 글에서는 사용자 관심 주제 추출의 배경과 적용 기술을 소개합니다. ① 홈피드와 교차 도메인 컨텍스트 ② 사용자 검색 의도 세분화 기존 분류기의 한계 홈피드 추천을 고도화하려면 보다 세부적으로 주제를 분류해야 했습니다. 기존의 주제 분류기에는 몇 가지 주요한 한계점이 있었습니다. 예를 들어, 축구와 야구가 모두 '스포츠'라는 동일한 카테고리로 분류되어 축구에만 관심 있는 사용자에게 야구 콘텐츠가 추천되는 문제가 발생했습니다. 이러한 문제를 해결하기 위해 신규 주제 분류기에서는 인터랙티브 광고 협회(IAB)의 분류 체계를 도입해 640개의 세부적인 카테고리로 분류하고자 했습니다. IAB 분류 체계는 '스포츠/레저>피트니스>요가/필라테스'와 같이 대/중/소 형태의 3단 분류로 구분되어 있습니다. 기존 분류기의 또 다른 한계점은 비교적 단순한 TextCNN 모델을 사용했다는 점입니다. 이로 인해 다중 클래스 분류(multi-class classification)를 적용하려면 N개의 이진 분류기(binary classifier) 모델이 필요했고, 여러 개의 모델을 유지 보수하는 데 어려움이 있었습니다. 이를 해결하기 위해 신규 주제 분류기에서는 콘텐츠와 상관없이 통합된 단일 LLM(large language model)을 도입하고 빔 서치(beam search)를 활용해 다중 클래스 분류를 구현하고자 했습니다. 예를 들어, '나이키 여성 러닝화' 문서는 '패션>신발', '스포츠/레저>걷기/러닝'으로 분류하고, '세부 스쿠버다이빙' 문서는 '스포츠/레저>워터스포츠>스쿠버다이빙', '여행>해외여행>아시아'와 같이 자세한 세부 주제를 다중 레이블(multi-label)로 분류하는 것을 목표로 했습니다. 모델 평가와 학습 주제를 분류하는 데 HyperCLOVA LLM의 2가지 모델인 HCX-L(HyperCLOVA X-Large)과 HCX-S(HyperCLOVA X-Small)를 활용했습니다. HCX-S는 서비스 규모에 적용 가능한 가장 작은 LLM 모델이었으므로 주제 분류기의 백본 모델로 선택했습니다. HCX-L 모델은 데이터를 생성하고 평가하는 데 활용했습니다. 예를 들어, '강원도 강아지 동반 캠핑'과 같은 문서는 캠핑, 강아지, 국내 여행 등 여러 주제로 분류할 수 있으므로 멀티 레이블 평가 데이터세트 구축에 어려움이 있었습니다. 이러한 이유로 HCX-L 모델을 이용한 평가 방식을 채택했습니다. LLM 평가의 신뢰성을 검증하기 위해 다음의 두 논문을 참고했습니다. Can Large Language Models Be an Alternative to Human Evaluations? (ACL 2023) ChatGPT와 text-davinci-003의 평가 결과와 사람의 평가 결과가 상당히 일치함. 특히, '문서'와 '분류된 주제'가 얼마나 관련되어 있는지 나타내는 연관성 측면에서 text-davinci-003의 평가 결과와 사람의 평가 결과 사이에 상관 관계가 높았음. G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment (2023) 사람의 평가 결과와 GPT4의 평가 결과가 여러 특성에서 높은 상관 관계를 나타냄. 사고 사슬(chain of thought)을 활용해 LLM의 논리적 사고 능력(reasoning)을 개선하면 더 좋은 평가 결과를 얻을 수 있음. 이 두 논문에서 LLM이 사람의 평가를 완벽히 대체할 수는 없지만 상대적으로 신뢰할 만하며 시간과 비용 측면에서 장점이 있다는 것을 확인했습니다. 특히 주제 분류 결과의 연관성 측면을 평가하는 데는 LLM을 충분히 활용할 수 있다고 판단했습니다. G-Eval 논문을 바탕으로 HCX-L 평가 프롬프트를 작성했습니다. 먼저, 주제가 분류된 후보의 평가를 요청하는 작업 명령어(task instruction)와 연관성, 구체성에 대한 자세한 평가 지침을 추가했습니다. 이후 사고 사슬 형식으로 제목과 본문을 먼저 읽고, 연관성과 구체성을 판단하여 0&Tilde100점으로 4단계 평가를 요청했습니다. 평가 프롬프트를 구성할 때 구체적인 평가 지침을 제공하면 더 나은 평가가 이루어진다는 점을 고려해 연관성과 구체성에 대한 상세한 설명을 포함했습니다. 평가는 G-Eval과 같이 빈칸에 점수를 직접 예측하는 서식 작성(form filling) 방식을 사용했습니다. G-Eval의 1&Tilde5점 평가에서는 동점이 많이 발생해 텍스트 간의 미묘한 차이를 포착하지 못하는 단점이 있었습니다. HCX 평가에서는 같은 문제가 발생하지 않도록 0&Tilde100점을 기준으로 했습니다. 최종 평가 결과는 다음과 같은 기준으로 점수를 부여했습니다. '여행>해외여행>미국/캐나다'처럼 연관성이 높고 자세한 경우: 100점 '여행>국내여행'처럼 연관성이 틀린 경우: 0점 '여행'처럼 간단히 예측한 경우: 30점 모델 학습 단계에서는 HCX-S 모델을 백본 모델로 사용했으며, 명령어와 제목, 본문으로 주제를 예측하는 학습 프롬프트를 구성했습니다. 학습은 지도 학습 미세 조정(supervised fine-tuning)을 사용했고, 손실 함수로는 모델 출력과 레이블 간의 교차 엔트로피 손실(cross-entropy loss)을 사용해 진행했습니다. 모델 추론 시에는 빔 서치(beam search)를 활용해 다중 레이블(multi-label) 분류를 수행했습니다. 빔 서치는 '빔 크기'만큼 상위 후보 경로만 유지하며 탐색하는 알고리즘입니다. 'num_beams=3' 옵션을 이용해 가장 높은 확률의 주제 3가지로 분류했습니다. 학습 데이터 다음과 같은 단계로 다양한 주제군의 데이터를 학습하고 평가했습니다. Step 1: 사람이 레이블링한 데이터 학습 및 평가 먼저 사람이 레이블링한 3천 건의 데이터로 학습을 진행한 후 평가를 수행했습니다. 기준치(baseline)와 비교했을 때 '여행'과 같은 대분류의 주제 분류에서는 더 좋은 성능을 보였습니다. 하지만 '여행>해외여행>미국/캐나다'와 같이 3단계까지의 주제 분류 평가에서는 점수가 낮았습니다. 이는 사람이 레이블링한 데이터세트의 한계로 인해 IAB 전체 주제군의 50% 주제군에 대해서만 데이터가 존재했기 때문이었습니다. Step 2: 데이터 증강 두 번째 단계에서는 추가 데이터세트를 확보하기 위해 홈피드 콘텐츠 풀과 학습된 HCX-S 모델로 데이터 증강(data augmentation)을 진행했습니다. 제목과 본문이 있는 홈피드 콘텐츠 데이터에서 학습된 HCX-S 모델로 주제를 분류하여 약한 레이블(weak label)을 3가지 생성하고, 이 결과를 다시 HCX-L 모델로 평가하여 80점 이상인 데이터 5천 건을 확보했습니다. 데이터세트 커버리지를 늘려 학습한 결과, 두 번째 학습에서 점수가 기존 대비 11% 향상됐습니다. 하지만 홈피드 추천 데이터에서 데이터 증강을 진행하다 보니 주로 인기 있는 주제군 레이블 데이터가 확보되어 데이터세트가 불균형해지면서 다양성이 감소하는 문제가 발생했습니다. Step 3: HCX-L 지식 증류 세 번째 단계에서는 데이터 수가 부족한 주제(예: 패션>신발)를 보완하기 위해 HCX-L로 합성 데이터를 생성하고(synthetic data generation) 이 데이터를 학습해서 HCX-S로 지식 증류(knowledge distillation)했습니다. 먼저 HCX-L에게 특정 주제에 관한 제목을 작성하라고 요청하여 제목을 생성했습니다. 이후 주제와 생성된 제목을 연결해(concat) 본문을 작성하라고 요청하여 본문을 생성했습니다. 최종적으로 (제목, 본문, 주제)에 대해 주제별로 2개씩, 천 개의 데이터를 보완할 수 있었습니다. 다양한 주제군의 데이터를 학습한 결과, 평가 점수가 첫 번째 학습 대비 20% 상승했고 전체적인 다양성 또한 향상됐습니다. 가이드 텍스트 생성 모델 학습 후에도 한 가지 중요한 문제가 있었습니다. 주제 분류 결과를 서비스에서 사용하려면 항상 일관된 응답을 제공해야 하는데, 다음과 같은 상황이 발생했습니다. '허리 디스크'에 불필요한 철자가 추가되는 경우 '백피킹'처럼 기존 주제군이 아닌 새로운 주제가 생성되는 경우 '요리/레시피'처럼 기존 depth와 다른 주제가 생성되는 경우 이렇게 예측할 수 없는 응답을 해결하기 위해 가이드 텍스트 생성(guided text generation)을 도입했습니다. 가이드 텍스트 생성은 LLM 생성 시 특정한 지침이나 제약 조건에 따라 텍스트를 생성하는 방식을 의미합니다. 주제군에 있는 후보만 생성할 수 있도록 가이드 텍스트 생성 방식 중 하나인 후보 빔 서치(candidate beam search) 방법을 구현했습니다. 이 방식은 다음 토큰 예측(next token prediction) 단계에서 모든 단어 집합(vocab)에 대하여 확률을 구할 때, 가능한 단어 집합을 제외하고 마스킹 처리하는 방식입니다. 이를 위해 가능한 단어 집합이 무엇인지 참조할 수 있는 주제군 토큰 테이블을 사전에 추가했습니다. 예를 들어, '스포츠/레저' 토큰의 다음 토큰을 예측할 때, 확률상으로는 '워터', '동계', '피트' 순으로 선택됩니다. 하지만 원하는 주제군에서만 생성하도록 인코딩된 테이블을 참조하여, 두 번째 시퀀스에 가능한 토큰은 {워터, 피트, }임을 확인하고 불가능한 '동계' 토큰은 마스킹 처리하는 형태로 구현했습니다. 실제 구현 단계에서는 다음과 같은 절차를 따랐습니다. 640개의 주제군 후보들을 인코딩하여 테이블 생성(추론 전 사전 인코딩을 한 번만 수행하여 지속적으로 활용 가능) LLM 생성 시 인코딩 테이블을 옵션으로 주어, 각 토큰 생성 시 해당 테이블을 참조할 수 있도록 구현 이를 통해 LLM으로 주제 분류를 예측할 때 항상 원하는 주제군에 대해서만 응답이 가능하도록 제어할 수 있었습니다. 사용자 주제 선호도 및 온라인 평가 주제 분류기가 완성되면 사용자 로그로 사용자 주제 선호도를 구축할 수 있습니다. 예를 들어, 홈피드에서 사용자가 후쿠오카 관련 문서를 클릭하면, 콘텐츠 피처 스토어(content feature store)를 조회해 사용자가 '아시아'와 관련된 주제를 읽은 것을 파악합니다. 이러한 클릭, 노출 로그를 함께 분석해 배치마다 단계별 주제 선호도를 계산합니다. 주제 선호도 스코어링 시에는 주제별 클릭과 노출 수, 기간, 시간 경과에 따른 시간 감쇠(time decay), 주제별 평균 노출 수를 고려한 안정화 방식(smoothing)을 고려합니다. 이렇게 구축한 홈피드 주제 선호도를 사용자 피처에 추가하고, 아이템별 주제 임베딩을 아이템 피처에 추가해 온라인 AB 테스트를 진행했습니다. 테스트 결과, 대분류 주제만 사용했을 때보다 대분류부터 소분류까지의 상세한 주제 선호도를 파악할수록 클릭률(CTR, click-through rate)이 더 높아지는 것을 확인할 수 있었습니다. 또한 LLM 분류기는 블로그, 카페 등 다양한 콘텐츠 문서를 분류할 수 있으므로 홈피드뿐만 아니라 검색, 네이버 메인의 사용자 로그도 활용할 수 있었습니다. 이를 이용하여 통합 주제 선호도를 생성했습니다. 통합 주제 선호도 생성 시에는 다음과 같은 전략을 사용했습니다. 홈피드 사용성이 낮은 사용자: 검색과 메인판의 지면 선호도를 더 많이 활용 홈피드 사용성이 높은 사용자: 홈피드 선호도를 주로 활용 교차 도메인(cross-domain) 효과를 검증하기 위해 첫 번째 실험과 동일한 설정에서 사용자 피처의 사용자 주제 선호도 변화에 따른 AB 테스트를 진행했습니다. 그 결과 홈피드만 사용할 때보다 홈피드, 검색, 네이버 메인의 선호도를 모두 활용할 때 클릭률이 약 2% 상승한 것을 확인할 수 있었습니다. 최종적으로 문서를 잘 분류하고 여러 지면의 사용자 선호도를 세부적으로 파악할수록 사용자의 클릭 수가 증가하는 것을 확인할 수 있었습니다. 정리 지금까지 3개의 블로그 글로 홈피드에서 LLM을 활용해 초개인화를 실현한 경험과 관련 기술을 공유했습니다. 서비스에 LLM을 적용하면서 얻은 주요 교훈은 다음과 같습니다. 고품질 데이터세트 구축: 노이즈가 제거된 고품질 데이터세트는 LLM 시대에도 여전히 중요한 요소입니다. 지식 증류: 서비스 적용을 위해 작은 모델을 사용해야 할 때는, 먼저 큰 모델을 교사 모델로 학습하고 학생 모델에 지식 증류를 사용하는 방식이 효율적이었습니다. 가이드 텍스트 생성: 가이드 텍스트 생성을 이용해 서비스에 더 안정적으로 LLM 응답을 사용할 수 있었습니다. 네이버와 AiRScout는 사용자의 선호 콘텐츠를 가장 빠르게 맞춤으로 제공하는 초개인화 서비스를 더욱 발전시킬 수 있도록 앞으로도 계속 노력하겠습니다. 긴 글 읽어주셔서 감사합니다. 이 글은 TEAM NAVER CONFERENCE ‘DAN 24’에서 발표한 검색과 피드의 만남: LLM으로 완성하는 초개인화 서비스를 토대로 작성되었습니다.
![[DAN 24] 서치피드: SERP를 넘어 SURF로! 검색의 새로운 물결](https://d2.naver.com/content/images/2024/12/01.png)
네이버 통합 검색은 2023년에 서치피드를 출시하여 통합 검색 모바일 하단에서 피드 형태로 개인화된 콘텐츠를 제공했습니다. 이 서비스의 내부 프로젝트명은 SURF(Search User Recommendation Feed)로, 검색 결과 페이지(Search Engine Results Page, SERP)의 한계를 뛰어넘어 사용자가 마치 바다에서 서핑을 즐기듯 끊임없이 새로운 콘텐츠를 발견하고 탐색할 수 있는 경험 제공을 목표로 하고 있습니다. 이 글에서는 SURF의 도입 배경과 SURF에 적용된 핵심 기술에 대해 설명하겠습니다. 서핑을 즐기듯 끊임없이 새로운 콘텐츠를 만나는 검색 결과 SURF 기존의 검색 경험(SERP)은 사용자가 검색어를 입력하면 출처 또는 의도 단위로 나열되어, 필요할 때 정확한 정보를 빠르게 찾을 수 있다는 장점이 있었습니다. 하지만 연속적이거나 다양한 정보 탐색 니즈를 충족하며, 발견하고 탐색하는 경험으로 확장하기 위해 새로운 방식으로 접근했습니다. SURF는 사용자의 검색 서핑 경험에 다음 3가지 핵심 기능을 제공합니다. A. 신선하고 다양한 파도 가져오기 SURF는 사용자에게 다층적인 콘텐츠 경험을 제공합니다. 먼저 입력된 검색어와 직접 관련된 콘텐츠를 제공하면서, 동시에 사용자의 취향과 관심사를 반영한 추천 결과를 함께 보여줍니다. 예를 들어, 특정 주제의 인기 카페 글이나 최근에 사용자가 클릭했던 문서와 유사한 글을 자연스럽게 노출합니다. 이를 통해 사용자는 원하는 정보를 찾으면서도 새로운 콘텐츠를 발견하는 즐거움을 경험할 수 있습니다. B. 그라데이션 방식으로 정보 제공하기 SURF의 특징적인 기능 중 하나는 그라데이션 방식의 콘텐츠 제공입니다. 사용자가 검색 결과를 아래로 스크롤할 때, 상단에서는 검색어와 가장 밀접하게 연관된 문서가 노출됩니다. 그러다가 점차 아래로 내려갈수록 주제가 자연스럽게 확장되어, 연관성은 있지만 보다 폭넓은 맥락의 콘텐츠가 제공됩니다. 이러한 그라데이션 구조는 사용자가 자연스럽게 관심 영역을 확장하면서 새로운 정보를 탐색할 수 있도록 돕습니다. C. 실시간 피드백 기반으로 동적 최적화하기 SURF는 사용자의 모든 행동을 실시간으로 분석하고 이를 즉각 검색 결과에 반영합니다. 문서 클릭, 스크롤 패턴, 체류 시간 등 다양한 사용자 행동 데이터를 수집하고, 이를 기반으로 사용자의 현재 관심사와 의도를 정교하게 예측합니다. 예를 들어, 특정 주제의 문서를 연속하여 클릭한 사용자에게는 해당 주제와 관련된 더 많은 콘텐츠를 제공하며, 특정 콘텐츠를 건너뛰는 경향을 보이는 경우에는 다른 주제의 콘텐츠 비중을 높이는 방식으로 작동합니다. 또한, SURF는 단순한 키워드 매칭을 넘어 사용자의 검색 맥락을 깊이 있게 이해하고자 합니다. 같은 검색어라도 사용자의 이전 검색 기록, 클릭 패턴, 관심사 등에 따라 다른 맥락의 결과를 제공할 수 있습니다. 특히 동음이의어나 다양한 맥락이 있는 검색어의 경우, 사용자의 실제 의도에 부합하는 결과를 제공하기 위해 개인화된 맥락 분석을 수행합니다. SURF의 가장 큰 특징은 검색이 단순한 정보 찾기를 넘어 지속적인 탐색 경험으로 확장된다는 점입니다. 사용자가 명시적으로 새로운 검색어를 입력하지 않더라도, SURF는 현재 관심사를 바탕으로 연관된 다양한 콘텐츠를 계속해서 제공합니다. 이는 마치 하나의 관심사가 자연스럽게 다른 관심사로 이어지는 실제 정보 탐색 과정을 모사한 것으로, 사용자의 정보 발견 여정을 더욱 풍부하게 만들어줍니다. SURF의 기술적 구현 SURF는 다양한 출처의 문서와 다양한 방법론을 사용하여 네이버 사용자의 검색 니즈를 충족시키기 위해 노력하고 있습니다. 전통적인 IR(information retrieval)은 물론, 최신성에 집중한 리트리버, 사용자 피드백 통계 기반으로 동작하는 리트리버뿐만 아니라 발견 확장을 위해 검색 질의 카테고리에 해당하는 다양한 출처의 인기 문서 리트리버도 사용합니다. 이러한 다양한 리트리버를 사용자의 니즈에 맞게 개인화하여 다양한 출처의 문서를 순위화할 수 있어야 합니다. SURF는 뉴럴 랭커를 학습하여 순위화의 품질을 높이기 위해 노력하고 있습니다. 이렇게 SURF에 활용하고 있는 다양한 방법 중 LLM(large language model)을 활용한 방법론을 위주로 설명하겠습니다. LLM 시대에 진입하면서, 검색 시스템 또한 새로운 변화가 필요했습니다. LLM의 가장 큰 특징은 고차원의 추론 능력으로 마치 사람처럼 텍스트의 맥락을 이해할 수 있다는 점입니다. 그러나 LLM은 크기가 크고 처리 속도가 느려 실시간 검색에 직접 활용하기는 어려웠습니다. 이를 해결하기 위해 저희는 LLM의 이해 능력을 담은 대량의 데이터셋을 만들고 이를 기반으로 실시간 검색에 활용 가능한 sLM(small language model)을 학습시키는 방식을 채택했습니다. 이는 증류(distillation)라고도 불리는 방식으로, LLM의 이해 능력을 더 작고 빠른 모델에 전수하는 것입니다. 저희는 디코더 기반의 sLM 임베딩 모델을 개발했고, 백본(backbone)으로는 사내 모델인 HCX를, LLM의 이해 능력이 녹아 있는 대량의 학습셋은 내부 RRA(re-ranking agent)를 활용하여 지도 학습 파인튜닝(supervised fine-tuning, SFT)을 진행했습니다. 이렇게 개발된 문맥 이해 임베딩 모델을 기반으로, SURF는 사용자에게 제공하고 싶은 4가지 가치를 구현하기 위해 노력했습니다. 반응형, 연관, 확장성, 그리고 개인화로 대표되는 이 '4대 파도'가 각각 어떻게 구현되었는지 자세히 살펴보겠습니다. 1. 반응형 파도(Reactive Wave): 사용자가 클릭한 문서와 연관된 문서 추천하기 SURF에서 가장 먼저 착수한 것은 반응형 파도였습니다. 반응형 파도는 단순한 키워드 매칭을 넘어, 클릭한 문서의 맥락을 파악해 연관 문서를 추천합니다. 예를 들어, 축구 관련 문서를 클릭했다면 같은 단어를 포함하지 않더라도 맥락에 관련된 다른 축구 소식을 추천할 수 있습니다. 따라서, 반응형 파도는 특정 문서와 연관된 문서를 리트리빙하는 것이 핵심 과제였습니다. 기존의 텀 매칭 기반 기술에서는 단어가 가장 많이 겹치는 문서를 찾는 방식을 사용했지만, 이는 중의성 문제로 인해 전혀 관련 없는 문서가 노출되는 한계가 있었습니다. 반면 임베딩 기반 리트리빙은 시드(seed) 문서의 맥락을 이해하고, 단어가 겹치지 않더라도 연관성 있는 문서를 효과적으로 찾아낼 수 있습니다. 반응형 파도의 실제 구현은 다음과 같습니다. 먼저 문서 풀에 대한 sLM 임베딩을 사전에 생성해 두고, SURF에서 사용자 클릭이 발생하면 실시간으로 해당 문서를 sLM에 통과시켜 임베딩을 얻습니다. 이후 ANN(approximate nearest neighbor) 검색을 수행하여 연관 문서를 가져오는 방식입니다. 2. 연관 파도(Related Wave): 검색어와 관련된 최신 문서 찾아주기 연관 파도는 검색 질의에서도 LLM의 맥락 이해 능력을 활용하고자 했습니다. 기존 텀 매칭 기반의 검색은 중의성이나 동음이의어 처리에 어려움이 있었기 때문에 이 문제를 해결하기 위해 LLM 임베딩을 활용하여 사용자의 실제 의도에 맞는 문서를 보여주고자 했습니다. 즉, '손흥민'을 검색하면 손흥민의 실제 경기 내용을 담은 문서를 제공하는 것이 목표였습니다. 하지만 SURF가 주로 다루는 짧은 숏헤드 질의는 문맥을 파악하기에 정보가 너무 부족했기에, 이를 해결하기 위해 '맥락텍스트'라는 개념을 도입했습니다. 예를 들어 '손흥민'에 대한 맥락텍스트는 "손흥민 맨시티 크리스탈팰리스 선발"과 같이 주요 토큰을 연결하여 생성합니다. 맥락텍스트는 반드시 하나일 필요는 없으며, 여러 개를 생성하여 검색의 다양성을 확보할 수 있습니다. 더 나아가 개인별로 다른 맥락텍스트를 생성함으로써 검색 개인화까지 구현할 수 있습니다. 하지만 ANN 사용 과정에서 새로운 과제가 발견되었습니다. 연관 파도는 '연관성 있는 최신 문서'를 제공하는 것이 핵심인데, ANN은 콘텐츠의 관련도만을 기준으로 검색하기 때문에 ANN으로 1차 검색된 문서 풀 내에서 최신 문서를 찾는 방식으로는 최신 문서를 원하는 만큼 가져올 수 없었습니다. 따라서 반응형 파도와는 다른 구조를 채택했습니다. 새로운 방식은 '손흥민'을 검색했을 때 다음과 같이 동작합니다. 문서 풀에서 '손흥민'으로 색인하여 최신 문서 K개를 최신순으로 가져옵니다. '손흥민' 맥락텍스트에 대한 임베딩과 최신 문서 K개의 임베딩을 구합니다. 맥락텍스트와 각 문서 간 코사인 유사도(cosine similarity)를 계산하여 유사도 기반 필터링(cut-off)과 재순위화(re-ranking)를 수행합니다. 이 방식을 통해 '손흥민'으로 색인된 최신 문서 중에서 실제로 손흥민의 경기와 관련된 문서만을 선별하여, 연관도가 높은 순서로 결과를 제공할 수 있게 되었습니다. 3. 확장형 파도(Expansive Wave): 검색어와 관련된 다른 주제로 확장하기 세 번째로 구현된 확장형 파도는 검색 질의와 유사한 다른 질의의 문서를 추천하는 기능입니다. 예를 들어, '손흥민' 검색 시 이강인, 김민재, 홍명보 등과 관련된 문서를 함께 제공하는 것입니다. 이를 '확장 질의'와 '확장 문서'라고 부릅니다. 확장형 파도에서도 동음이의어 문제는 중요한 과제였습니다. '손흥민' 검색에서 '김민재'가 확장 질의로 선정되었을 때, 축구 선수가 아닌 배우 김민재의 문서가 노출된다면 사용자 경험을 해칠 수 있기 때문입니다. 이를 해결하기 위해 맥락텍스트와 sLM을 다시 한 번 활용했습니다. 확장형 파도는 먼저 고품질 질의 풀을 선정하고, 질의의 메타 정보, 패턴, 로그 등을 활용하여 지식 그래프(Knowledge Graph)를 구축합니다. 이 지식 그래프 내에서 검색 질의와 확장 질의 간 매핑이 이루어지며, 동음이의어의 경우 검색 맥락에 따라 적절한 맥락텍스트가 생성됩니다. 예를 들어 '손흥민' 관련 확장 문서를 찾을 때, 지식 그래프에서 '김민재'가 확장 질의로 매핑되면, 축구 선수 김민재에 관한 맥락텍스트가 생성됩니다. 이를 기반으로 ANN 검색을 수행하면 축구 관련 확장 문서만을 제공할 수 있습니다. 더 나아가 지식 그래프와 사용자의 맥락을 결합하여 개인화된 확장 질의 매핑을 생성할 수도 있습니다. 4. 개인화 파도(Personalized Wave): 사용자 행동을 실시간으로 학습하기 마지막 개인화 파도는 랭커를 통해 구현되었습니다. SURF의 랭커는 사용자를 이해하고 피드 스크롤에 따라 적절한 결과를 제공하도록 학습됩니다. 예를 들어 '캠핑' 검색 시, 장소 탐색 의도가 강한 사용자에게는 캠핑장이나 차박 명소를, 장비 탐색 의도가 강한 사용자에게는 캠핑 장비 정보를 우선 제공합니다. 피드 스크롤 중 사용자의 반응도 즉각 반영됩니다. 등유 난로 관련 문서 클릭 시 다음 피드에서는 관련 문서를 적극 추천하고, 우드 테이블 관련 문서를 건너뛰면 해당 주제는 순위가 하향 조정됩니다. 또한 스크롤이 깊어질수록 다양한 주제와 출처로 자연스럽게 확장되도록 설계되어 있습니다. 현재는 더 발전한 형태의 개인화 추천 패러다임을 연구 중입니다. 사용자의 활동 로그를 분석하여 sLM이 관심사를 멀티 프로필로 생성하고, 이를 기반으로 개인화 추천을 제공하는 방식입니다. 추천된 결과에 대한 사용자 반응이 다시 로그로 쌓이면서 지속적으로 프로필이 발전하는 선순환 구조를 만드는 것이 목표입니다. 이러한 에이전트는 사용자의 멀티 프로필을 sLM 임베딩으로 보유하고, ANN 검색을 통해 문서를 피드에 노출합니다. 노출된 문서를 사용자가 클릭하거나 건너뛰는 등의 반응에 따라 해당 프로필의 임베딩을 업데이트하거나 삭제하면서 지속적으로 사용자의 선호도를 학습해 나갑니다. 이를 통해 더욱 정교한 개인화 검색 결과를 제공할 수 있을 것으로 기대됩니다. SURF의 미래: 개인 맞춤형 검색 에이전트를 향해 SURF는 출시 이후 3개월이라는 짧은 시간 동안에 많은 발전을 이루었고, 빠르게 변화하는 기술 환경에 발맞추어 더 큰 진화를 준비하고 있습니다. 검색 서비스의 미래는 단순히 원하는 정보를 찾아주는 것을 넘어서야 합니다. SURF가 그리는 미래의 검색은 개개인의 맥락을 이해하고 함께 고민하는 '나만의 검색 에이전트'입니다. 이 에이전트는 다음과 같은 역할을 수행하게 될 것입니다. 필요한 문서의 핵심을 요약하여 제공 대화형 인터페이스를 통한 심층 정보 탐색 지원 쇼핑 과정에서 제품 탐색부터 구매 결정까지 통합 지원 상황과 맥락을 고려한 적시의 정보 제공 이처럼 SURF는 검색이라는 행위를 더욱 자연스럽고 풍부한 경험으로 발전시키고자 합니다. 검색이 단순한 정보 찾기를 넘어 사용자의 목적 달성을 위한 종합적인 동반자가 되는 것, 그것이 SURF가 그리는 미래의 모습입니다. 우리는 이러한 미래 검색 경험을 실현하기 위해 끊임없이 연구하고 발전해 나갈 것입니다. SURF를 통해 펼쳐질 새로운 검색의 미래에 많은 관심과 기대 부탁드립니다. 이 글은 TEAM NAVER CONFERENCE 'DAN 24'에서 발표한 내용을 토대로 작성되었으며, 발표 내용과 자료는 DAN 24에서 보실 수 있습니다.