안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다. 저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰...
기술 블로그 모음
국내 IT 기업들의 기술 블로그 글을 한 곳에서 모아보세요


배치 작업을 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와 같은 오픈소스 워크플로가 있으므로 이를 활용해 작업을 관리하는 것이 효율적이고 신뢰성이 높으며 유지 보수에도 유리합니다. 오픈소스를 사용할 수 없는 환경이거나 특수한 요구 사항이 존재하는 경우에는 직접 구현해서 클러스터를 유연하게 확장해보는 것도 좋을 것입니다.

안녕하세요! 당근 알림 경험팀에서 백엔드 엔지니어로 일하고 있는 데이(Daey)라고 해요. 알림 경험팀은 일반적인 플랫폼 조직이 아닌, 모든 유저의 알림 경험을 고민하는 서비스 조직이기도 해요. 알림 기능을 개발하다 보면 아래와 같은 말을 자주 듣곤 하는데요.“알림은 그냥 보내달라는 대로 보내면 되는 거 아닌가요?”네, 그냥 보내달라는 대로 보내면 되는 게 아니에요. 알림은 먼저 사용자에게 접근할 수 있는 강력한 수단이지만, 사용하기에 따라 긍정적 경험뿐만 아니라 부정적 경험도 줄 수 있는 양날의 검이에요. 따라서 알림 경험팀은 단순히 알림을 잘 보내기 위한 플랫폼만 만드는 게 아니라, 유저가 알림을 통해 어떤 경험을 얻어갈지도 치밀하게 고민하고 있어요.그런데 알림 경험팀에서 생각하는 모든 유저에는 단순히 당근 앱 사용자만 포함되지 않아요. 알림을 발송하는 당근의 구성원들까지도 포함하죠. 이는 알림을 발송하는 당근 구성원들의 긍정적 알림 경험이 장기적으로 당근 앱 사용자들의 긍정적 알림 경험에 기여한다고 믿기 때문이에요.이번 글에서는 알림을 받는 사용자뿐만 아니라 알림을 발송하는 당근 구성원들의 경험도 효과적으로 개선한 ‘알림 신호등 프로젝트’를 소개하려 해요. 사용자의 알림 경험을 개선하기 위한 모니터링 기준을 마련하고, 챙겨야 하는 알림들이 모니터링 결과와 함께 담당자를 직접 찾아가는 시스템을 개발한 프로젝트죠. 사용자의 경험을 개선하기 위한 알림 경험팀의 치열한 고민과 해결 방법을 공유해 드릴게요.알림 경험팀이 발견한 문제 상황저희 팀에서는 먼저 당근 앱 사용자들과 당근 구성원들의 알림 경험에 부정적인 영향을 주는 요소들을 면밀히 파악했어요. 크게 세 가지 문제점을 도출했는데요. 각 문제점이 당근 앱 사용자와 당근 구성원 각자에게 어떤 영향을 미치는지 구체적으로 설명해 드릴게요.1. 방치된 알림더 이상 발송하지 않는 알림이 발송 가능한 상태로 남아있는 경우를 생각해 보세요. 이렇게 방치된 알림들은 당근 사용자들의 알림 경험에 어떤 영향을 미칠까요? 예를 들어 22대 국회의원 선거 알림이 지금 발송된다면 어떨까요? 왜 이 알림이 지금 발송됐는지 의아해하며 부정적인 알림 경험이 쌓일 거예요.이런 경우도 한번 생각해 봅시다. 최근 당근 알림함은 스레드 알림 형태로 변경되었는데요.예 - 스레드 알림함스레드 알림에는 알림들을 스레드의 형태로 묶어줄 스레드 제목이 필요해요. 예를 들어 사진 속 알림의 스레드 제목은 ‘동네생활’이고, 알림 내용은 ‘구미동 인기글 확인하실 daeung님 구해요’인 것처럼 말이에요. 그런데 만약 스레드 설정이 되어 있지 않은 알림이 스레드 알림함에 노출된다면 어떨까요? 일반 알림에는 이런 정보들이 입력되어 있지 않기 때문에, 이런 알림이 스레드 알림함에 노출된다면 알림 내용이나 제목이 누락될 수 있어요.이런 상황을 방지하기 위해서는 당근 구성원들이 기존에 설정해 둔 알림들을 주기적으로 살펴봐야 해요. 불필요해진 알림을 제거하거나 내용 업데이트가 필요한 알림들을 수정해야 하죠. 그런데 현실적으로 다른 업무들을 살피다 보면 알림을 일일이 확인하는 작업을 놓치게 되어, 위에서 살펴본 상황들이 종종 발생하곤 해요.2. 알림 오픈율 분석오픈율은 매우 중요한 지표예요. 사용자들에게 해당 알림이 필요했는지 가장 명확하게 보여주기 때문이죠. 만약 사용자에게 필요한 알림이었다면 사용자들은 알림을 오픈해 내용을 확인했을 거예요. 반대로 필요하지 않았다면 굳이 알림을 오픈하지 않겠죠. 따라서 사용자의 알림 경험 향상을 위해선 오픈율이 낮은 알림의 원인을 파악하고 개선하는 작업이 매우 중요해요.하지만 단일 알림의 오픈율만으로는 그 수치가 높은지 낮은지 판단하기가 어려워요. 예를 들어 한 사용자가 인테리어 업체에 댓글로 문의 사항을 남겼다고 가정해 볼까요? 거기에 답글이 달렸다는 알림이 뜬다면, 대부분의 사용자는 확인하는 즉시 곧바로 열어볼 거예요. 사용자가 궁금해하는 중요한 정보가 담겨 있으니까요. 본인 댓글에 대한 답글 알림은 상대적으로 오픈율이 높을 수밖에 없죠. 반면 할인 쿠폰을 사용해 보라는 등의 광고성 알림은 대체로 오픈율이 낮을 거예요.예 - 정보성 알림예 - 광고성 알림알림 경험팀에서는 비슷한 발송 맥락을 가진 알림들을 묶어 알림 카테고리라는 정보를 제공하고 있어요. 카테고리가 동일한 알림들의 오픈율을 분석하면, 관리하고 있는 개별 알림의 오픈율이 높은지 낮은지를 알 수 있을 거예요. 하지만 여기에도 한계는 있어요. 당근의 개별 구성원들은 어떠한 방식으로 알림 카테고리가 구성되는지 그 맥락을 모두 알기 힘들기 때문이에요. 만약 알고 있다 하더라도 개별 구성원들이 각자 자신이 관리하는 오픈율을 분석하기 위해 다른 카테고리의 알림들까지도 확인하는 것은 매우 비효율적이죠.3. 알림 피로도 관리Facebook의 연구 결과에 따르면 알림 발송량을 줄이면 단기적으로는 관련 지표가 악화되지만, 장기적으로는 오히려 지표가 향상된다고 해요. 이는 사용자들의 알림 피로도 관리가 매우 중요하다는 것을 시사해요. 너무 많은 알림을 받게 되면 사용자들은 알림 자체에 피로감을 느끼게 될 거예요. 그렇게 되면 중요한 알림마저도 사용자에게 제대로 전달되지 않을 수 있어요.따라서 알림 피로도를 파악하는 것이 중요해요. 알림 발송량과 알림 수신 거부량을 분석하면 사용자들의 알림 피로도를 가늠할 수 있어요. 알림 발송량이 지나치게 늘어나면 일반적으로 사용자들은 알림에 대한 거부감을 느끼게 되고, 그에 따라 수신 거부 처리가 증가할 것이기 때문이죠.하지만 알림 발송량의 총량을 줄이는 것은 매우 어려운 일이에요. 자신이 발송하는 알림이 굳이 보낼 필요가 없다고 생각하는 당근 구성원이 몇 명이나 있을까요? 또한 알림의 발송량은 서비스의 성장에 의해 자연스럽게 증가할 수도 있는데, 기계적으로 발송량을 제한해서 서비스 성장을 방해할 수도 없는 노릇이고요.알림 경험팀의 해결책1. 모니터링 기준 마련알림 경험팀에서는 각 문제에 대해 가장 먼저 다음과 같은 모니터링 기준을 마련했어요.방치된 알림아래와 같은 기준을 가지고 방치된 알림 모니터링을 수행하기로 결정했어요.30일간 알림 발송이 없는 경우 → 방치된 알림으로 간주30일 이상의 주기를 가지고 알림이 발송되는 경우 → 방치된 알림에서 제외미래에 발송이 예약되어 있는 경우 → 방치된 알림에서 제외기본적으로 30일간 알림 발송이 없는 경우를 방치된 알림으로 간주해요. 하지만 가계부 알림과 같이 꾸준히 사용하는 알림이지만, 발송 주기가 한 달을 넘는 경우가 있어요. 따라서 주기 발송이 설정되어 있거나 미래에 예약 발송이 설정되어 있는 경우는 방치된 알림에서 제외했어요.알림 오픈율 분석오픈율 모니터링의 경우 알림 카테고리별 권장 오픈율을 계산하여, 권장 오픈율보다 높은지 낮은지를 확인했어요. 이때 권장 오픈율은 해당 알림 카테고리에서 극단값을 제거하고 중간값을 선택했죠. 극단값을 제거한 이유는 때때로 테스트 발송이나 특수한 상황으로 인해 오픈율이 100% 또는 0%가 되는 경우가 있기 때문이에요. 이러한 극단값을 그대로 반영하면 실제 오픈율을 왜곡할 수 있어요. 또한 알림 오픈율의 분포가 정규분포를 따르지 않는 경우가 많은데요. 이 경우 평균값은 대표성이 떨어질 수 있기 때문에 중간값을 기준으로 삼았어요.알림 피로도 관리알림 피로도 관리를 위해 알림 발송량과 수신 거부량을 모니터링했어요. 단순히 알림 발송량을 기계적으로 줄이는 것은 상황에 따라 부작용을 일으킬 수 있어요. 따라서 이상치 탐지 기법을 우선적으로 도입하기로 했어요. 알림 발송량 추이나 수신 거부량 추이를 급격하게 벗어나는 경우를 모니터링하기로 한 거예요.이상치 탐지에는 사분위 수 기반 기법을 적용했어요. 이는 권장 오픈율 분석과 마찬가지로, 극단값이 존재하고 정규분포를 따르지 않는 경우가 많기 때문이에요. 평균 및 표준편차 기반의 방식보다는 사분위 수 기반 방식이 더 적절할 것으로 판단했어요.(통계적으로 이상치를 탐지하는 방법을 더 구체적으로 알고 싶은 분은 이 글을 참고하면 도움이 될 거예요.)2. 당근 구성원을 찾아가는 모니터링하지만 알림 경험을 효과적으로 개선하려면 모니터링 기준을 세우는 것만으로는 부족해요. 사용자의 긍정적인 알림 경험을 위해서는 당근 구성원의 관심과 노력 또한 필수적이에요. 예를 들어 30일간 알림 발송이 없어서 방치된 알림으로 간주하는 경우, 담당자가 해당 알림들을 확인하고 발송 불가능한 상태로 처리해야 해요. 또한 권장 오픈율보다 낮거나 알림 발송량 및 수신 거부량에 급격한 증가가 생기는 경우, 그 이유를 면밀히 분석하여 개선해 나가는 작업이 필요해요.다시 말해 당근 구성원들은 알림 센터*의 기능과 관련된 여러 일들을 직접 일일이 챙겨야 하는 상황이었어요. 그런데 만약 스스로 챙기지 않아도, 해야 할 일들이 당근 구성원들을 찾아오게 만들 수 있다면 어떨까요? 모니터링 기준을 충족하지 못하는 알림들을 슬랙이나 알림 센터를 통해 알려준다거나 하면 말이에요. 위와 같은 문제 상황을 방지하면서, 당근 구성원들의 알림 경험 또한 개선할 수 있을 거예요. 그래서 저희 팀에서는 모니터링 결과를 당근 구성원들에게 자동으로 전달하는 모니터링 시스템인 ‘알림 신호등’을 개발하기 시작했어요.알림 센터: 당근 구성원들이 알림 발송을 위해 사용하는 백오피스를 의미해요.예 - 슬랙 알림을 통한 모니터링 결과 확인예 - 알림 센터를 통한 모니터링 결과 확인알림 신호등의 구조알림 신호등의 구조는 다음과 같아요.다양한 알림 관련 데이터를 이용해 모니터링을 수행해요.모니터링 기준을 충족하지 못하는 경우 모니터링 결과를 저장하고 슬랙 알림을 발송해요.알림 센터에서 모니터링 결과 조회 기능을 제공해요.모니터링 기준을 손쉽게 확장할 수 있는 구조좋은 알림 경험을 제공하기 위해서는 다양한 측면에서의 모니터링이 필요해요. 지금은 일정 기간 동안 알림이 발송되지 않는 경우, 알림 실험이 장기화되고 있는 경우, 알림 발송량이나 알림 수신 거부 추이에 변동이 생기는 경우에 대해서만 모니터링을 수행하고 있지만, 추후 새로운 기능이 추가되면 해당 기능에 대한 모니터링이 필요해질 수도 있죠.따라서 이러한 요구사항을 충족시킬 수 있도록 확장성 있는 구조가 필요했어요. 이를 위해 모니터링 기준을 추상화된 인터페이스로 정의하고, 각 기준별 Checker를 구현하는 방식으로 설계했어요. 이를 통해 새로운 모니터링 기준이 추가되더라도 해당 Checker만 구현하면 되는 유연한 구조를 갖출 수 있었어요.모니터링 기준을 정의할 때, 각 기준별로 충족 여부를 확인하는 로직을 추상화된 MonitoringChecker 인터페이스로 정의했어요.type MonitoringChecker interface { Check(<모니터링 대상 알림>) (bool, error)}Check 메서드를 이용해 주어진 알림 시나리오가 해당 모니터링 기준을 충족하는지 여부를 판단하고, 그 결과를 boolean 값으로 반환해요.이를 통해 정의한 모니터링 기준들에 대해 각각의 Checker 를 구현했어요. 예를 들어 “30일 동안 알림 발송이 없다”는 기준에 대한 Checker는 다음과 같아요.type NoNotificationsIn30DaysChecker struct { // 필요한 의존성 주입}func (c *NoNotificationsIn30DaysChecker) Check(<모니터링 대상 알림>) (bool, error) { // 30일 간 알림 발송 내역 조회 // 발송 내역이 없는 경우 true 반환}결과그 결과, 이전 대비 발송 불가 처리된 알림의 비율이 약 130% 증가했어요. 불필요한 알림을 제외하고 사용자에게 보다 정돈된 알림 경험을 제공하게 된 거예요. 더 이상 발송돼서는 안 되는 알림들 때문에 사용자가 혼란을 겪는 일이 줄어들었어요.앞으로의 과제권장 오픈율과 알림 발송량, 수신 거부량 이상치 탐지의 경우 아직 기능을 활발히 테스트 중이에요. 어느 정도의 오픈율, 알림 발송량, 수신 거부량이 적절한 수준인지 논의를 마친 상태는 아니에요. 하지만 실험 결과를 꾸준히 확인하면서 선제적으로 기준치를 당근 구성원들에게 제시하고 있어요. 실험이 마무리되면 다시 한번 논의를 통해 건강한 알림 경험을 더 명확히 정의하고자 해요.또한 알림 신호등을 도입한 게 처음이기 때문에 아직 구체적으로 개선할 부분들이 많은데요. 예를 들어 알림 발송량 이상치 탐지 방식에는 한계가 존재해요. 단순한 통계 기반 방식으로는 알림 발송량이 0으로 떨어지는 경우를 효과적으로 탐지하기가 어렵죠. 필요한 알림들이 발송되지 않는다는 측면에서는 중요한 지표일 수도 있지만, 알림 피로도에는 영향을 미치지 않기 때문이에요. 따라서 이러한 한계를 극복하기 위해 좀 더 고도화된 기법을 고민 중이에요. 시계열 분석 알고리즘이나 머신러닝 기법 등을 활용해 알림 모니터링의 정확성과 민감성을 높여보려고 해요.당근 구성원들에게 슬랙 메시지나 알림 센터를 통해 모니터링 결과를 제공하는 방식에도 한계가 있어요. 대부분의 당근 구성원들은 여러 개의 알림을 동시에 관리하는데, 슬랙 알림이나 알림 센터에서는 개별 알림 단위로 결과를 제공하기 때문이에요. 구성원들은 여전히 자신이 관리하는 여러 개의 알림들을 하나하나 확인해야 하죠. 번거로움이 완전히 해소되지는 않은 거예요. 따라서 개선이 필요한 알림들을 한눈에 확인할 수 있는 대시보드를 만들고 있어요. 대시보드가 제공되면 구성원들은 알림 센터에 접속 후 어떤 알림에 대해 무엇을 해야 하는지 직관적으로 파악하게 될 거예요.마치며지금까지 살펴본 ‘알림 신호등 프로젝트’를 통해 알림 경험팀은 앞으로 어떤 방향으로 나아가야 할지 확신할 수 있었어요. 저도 개인적으로 문제정의부터 해결책을 도출하고 팀원들을 설득하는 것까지, 여러 과정을 거치며 팀의 일원으로서 큰 성장을 이뤄낼 수도 있었고요.알림 경험팀은 앞으로 단순한 플랫폼 조직이 아니라 서비스 조직으로서 당근 앱 사용자와 당근 구성원들의 알림 경험을 개선해 나갈 예정이에요. 부정적 알림 경험을 방지하는 것뿐만 아니라, 사용자에게 긍정적 알림 경험을 적극적으로 제공하기 위해 노력하려고 해요. 예를 들어 사용자에게 꼭 필요한 알림을 발굴하는 기능처럼 사용자가 실질적인 도움을 받을 수 있는 유의미한 알림을 제공하고자 해요.당근 앱 사용자와 당근 구성원들의 알림 경험을 위해 치열하게 고민하는 저희 팀에 흥미가 생기셨다면, 알림 경험팀의 문은 항상 열려있어요!아래 채용 공고를 통해 저희 팀에 합류할 수 있으니 많은 관심 부탁드려요! :)Software Engineer, Backend — Notifications Experience모두를 위한 알림 경험 만들기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.
이 글은 카프카(Kafka)를 사용하는 스프링 환경에서 메시지 처리 속도를 동적으로 조절해야하는 상황과 여러 쓰로틀링 기법들을 소개합니다. 카프카를 사용하는 스프링 프레임워크 환경에 익숙하고, 카프카의 기본 개념 및 컨슈머와 컨슈머 그룹, 파티션 구조를 이해하는 독자를 대상으로 작성되었습니다. 모든 예제의 전체 소스코드는 GitHub 저장소에서 확인하실 수 있습니다. 분산 서버에서 대규모의 요청이 발생할 때 수평 확장을 통해 처리량을 늘릴 […] The post 카프카 컨슈머에 동적 쓰로틀링 적용하기 first appeared on 우아한형제들 기술블로그.

주요내용 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회 발행 되고 있으니 많은 관심 부탁드립니다. ▷ 구독하기

토스증권은 데이터센터 장애 상황에도 유저에게 정상적으로 서비스를 제공하기 위해 대부분의 시스템을 이중화했습니다. Kafka 이중화 구성에 대한 개요를 소개드려요.

안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다. 저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰...

안녕하세요, 네이버클라우드 팀입니다. 작년 7월, 네이버클라우드 테크 앰버서더 기술 컨퍼런스 NAVER Cloud Master Day가 성공적으로 개최되었습니다. (후기 1편 / 2편) 그 열기를 이어, 두 번째 컨퍼런스를 네이버의 두 번째 사옥 1784에서 열게 됐습니다! 클라우드와 AI 기술에 관심 있는 80여 명의 참석자분들과 함께 테크 앰버서더...
![[네이버클라우드캠프] 2024 특별 커리어 성장 세미나 <커리어, 함께 Carry On> 현장 스케치](https://blogthumb.pstatic.net/MjAyNTAxMDdfMTQ5/MDAxNzM2MjE0OTMyODI5.dD_3udVcPDYsxNfXDEUsLEzjnmDBkLzaTzStrMacPZAg.t0g1ujWyunA3uDNeUAqaZCAPLDGUMaWolLhz3uoC1zog.PNG/thumb.png?type=s3)
안녕하세요, 누구나 쉽게 시작하는 클라우드 네이버클라우드 ncloud.com입니다. #네이버클라우드 #네이버클라우드캠프#네클캠 #K-Digital Training #KDT #Together We Rise #성장 세미나 #성장세미나 지난 12월 11일 수요일, 네이버클라우드캠프 2024 특별 커리어 성장세미나 K-Digital Training 과정 설명...

stockcake.com안녕하세요. 29CM 모바일팀 iOS 개발자 김중원입니다. 이번 글에서는 앱 시작 시간을 개선하기 위해 새 기술을 도입하고 이를 정량적으로 평가하기 위한 인프라를 구축하여 명확한 성과를 확인한 내용을 공유드립니다.29CM 모바일 앱은 높은 수준의 성능 유지를 목표로 성능 지표 설정과 정량적 측정을 위해 2분기 과제로 앱 성능 측...
사용자의 배달 주소를 기반으로 어느 행정동/법정동에 속해 있는지를 판단하기 위해 기존에는 C++로 작성된 웹 서버를 사용하였습니다. 서버 한 대당 피크 시간 기준 2000TPS를 상회하는 많은 요청을 10ms 이하 시간으로 응답할 수 있는 높은 성능을 제공했지만, C++의 특성상 여러가지 단점이 존재했습니다. 이를 Java 및 Spring Boot 기반으로 전환하기까지의 경험을 공유합니다. 배경 배달의민족에서는 배달 주소를 기반으로 어느 […] The post 이젠 보내줄 때가 되었다. 대규모 트래픽의 C++ 시스템 Java로 전환하기 first appeared on 우아한형제들 기술블로그.
This article is the last in a multi-part series sharing a breadth of Analytics Engineering work at Netflix, recently presented as part of our annual internal Analytics Engineering conference. Need ...
![[프로그램] CSAP 인증 기업 이용 1위! 네이버클라우드가 준비한 공공 SaaS 지원 프로그램](https://blogthumb.pstatic.net/MjAyNDEyMTdfMTg3/MDAxNzM0NDIxMDc5MDU3.Vq057BNFtwOUIrTuQeqzJFAgLYM1XjfGaIL3kwFGhPEg.aHp_oPxeC2oMxNuiX7VEt16BhM4y3aXeduDQlCUA89Qg.PNG/241217_공공saas썸네일.png?type=s3)
공공 SaaS 시장 진출이 목표지만 CSAP 인증 평가가 고민인 기업들 주목! 공공 SaaS 비즈니스에 필수인, CSAP 획득 심사. 어디서부터 시작해야 할지, 막막하셨나요? *CSAP (클라우드 서비스 보안인증) 한국인터넷진흥원 (KISA)에서 지원하는 클라우드 서비스 보안 관련 인증 제도로 공급자가 제공하는 서비스의 정보보호 기준 준수 여부 평가 ...

안녕하세요 지마켓 Mobile Application Team 강수진입니다. 오늘은 iOS에서 특정 이벤트에 대한 URL 요청이 정상적으로 이루어졌는지 확인하는 방법에 대해 알아보겠습니다. 들어가기 전에 모든 서비스에서 광고는 중요합니다. 왜냐하면 수익과 직결되기 때문이죠 지마켓도 곳곳에 다양한 유형의 광고가 포함되어 있는데요! 일례로...

- ViewModel에서 발생하는 Event를 전파하기 위해 SharedFlow를 활용한 EventFlow로 사용하고 계신가요?- EventFlow 개념을 제일 처음 만든건 헤이딜러 였는데요. 헤이딜러에서도 이제 EventFlow를 사용하지 않습니다.- 어떤 코드로 개선했는지 내용을 확인해보세요안녕하세요헤이딜러 안드로이드팀 박상권입니다.여러분은 안드로...

들어가며 LINE Plus의 MPR(Mobile Productive & Research) 팀은 LINE 클라이언트 앱의 빌드 개선과 CI 파이프라인 관리, 자동화 지원 등의 업무를...
DB Connection과 Garbage Collector의 관계를 중심으로 mysql-connector-j 사용 시 발생할 수 있는 메모리 누수를 탐지하고 해결한 경험을 공유합니다.

요즘 QA…

컴퓨터 과학 교육과 능력에 대한 수요가 전 세계적으로 급증하고 있습니다. JetBrains는 학습자를 이끄는 최신 동향, 과제, 동기 및 이 분야의 현황에 영향을 미치는 기타 요인을 파악하기 위해 2024년 컴퓨터 과학 학습 곡선 설문조사를 실시했습니다. 총 16개국, 7개 주요 지역에서 23,991명의 학습자가 1년 남짓 동안 함께 노력을 기울여 상...
This article is the second in a multi-part series sharing a breadth of Analytics Engineering work at Netflix, recently presented as part of our annual internal Analytics Engineering conference. Nee...
![[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으로 완성하는 초개인화 서비스를 토대로 작성되었습니다.
안녕하세요.Pricing Tech Engineering팀원이자 해당 테크블로그의 에디터장을 담당하고 있는 김민우입니다. 본격적인 글에 앞서 이번에 작업한 테크블로그 UI 변경 건을 잠깐 소개해드립니다. G마켓 테크블로그는 티스토리를 기반으로 제작되었으며, 티스토리에서 ...
![[캘린더️] 1월 무료 교육 웨비나 일정 모음](https://blogthumb.pstatic.net/MjAyNTAxMDJfMjU2/MDAxNzM1Nzg1MjMwODEz.lrRl3k0HChhr0s7dNQHY689INJu8VD8moTXF23ek_F4g.bEXTCsFkHS4kYjWjLVN_Pz86HvFv8lWv6xlwV1DPPbMg.PNG/2501_edu_thumb.png?type=s3)
안녕하세요, 누구나 쉽게 시작하는 클라우드 네이버클라우드 ncloud.com 입니다.
![[웍스 사용 설명서] 중요한 업무 메일 놓치지 않는 법](https://blogthumb.pstatic.net/MjAyNTAxMTVfNzUg/MDAxNzM2OTExMjI5NTk5.8ljmuzBNTKv9lFHFLw6zA0ABO8XOq92ixacHFkk_5OEg.jFQf7gR_p3B8BRoQ_C6zEQ2STjExdh-TVb6Po4iANo0g.JPEG/웍사설메일.jpg?type=s3)
안녕하세요, 협업과 소통을 위한 필수 기능으로 글로벌 53만 기업의 든든한 협업툴 역할을 해온 네이버웍스(NAVER WORKS)입니다! 정신없는 업무 시간, 하루에도 수십 통씩 쏟아지는 메일들을 어떻게 관리하고 계시나요? 메일을 읽었지만 즉시 회신하기 어렵거나 메일로 요청받은 업무를 바로 처리하기 불가능한 경우, 받은 메일함에 용도/목적이 다른 메일들...
딜리버리 프로덕트 개발팀에서 안정적인 서비스 제공을 위한 고군분투기

안녕하세요. 저는 로컬 비즈니스실에서 서버 개발자로 일하고 있는 에렌(Eren)이라고 해요. 당근을 사용해 본 적 있으신가요? 당근의 동네지도 탭에 들어가면 ‘음식점’, ‘카페/간식’ 등 카테고리별로 동네 업체를 모아볼 수 있는데요. 여기서 각 업체의 정보를 담고 있는 ‘비즈 프로필’을 저희 팀이 만들고 있어요. 저희 팀은 사람들은 믿을 만한 동네 업체를 빠르게 찾고, 동네 사장님들은 더 효과적으로 장사할 수 있도록 돕는 플랫폼을 만들고 있어요.로컬 비즈니스실의 핵심 모델은 Business Profile(업체 프로필)이에요. 다양한 카테고리의 업체 사장님들이 비즈 프로필을 효과적으로 사용하기 위해선 여러 도구가 필요해요. 그래서 저희는 상품 판매, 예약, 후기, 쿠폰, 채팅, CRM 같은 여러 기능을 계속 추가하고 있어요. 동네 사장님의 필수 채널이 되는 것을 꿈꾸는 만큼 제품의 복잡도가 빠르게 증가하고 있어요.로컬 비즈니스실은 각 도메인의 복잡한 정책을 나누기 위해 마이크로서비스 아키텍처를 지향하고 있어요. 그럼에도 도메인 자체가 상호작용할 필요가 늘어났고, 각 서비스의 데이터를 조합하는 일도 증가했어요. 특히 다음과 같은 상황에서 복잡한 데이터 조합이 필요했어요. 대표적으론 다음과 같은 경우가 있었어요.모든 정책과 연관 관계가 모이게 되는 프로필 홈 화면.정책 자체가 여러 도메인의 데이터를 기반으로 결정되는 경우.외부 서비스에 데이터를 취합하여 데이터를 제공하는 경우.서비스가 고도화되면서 도메인 간의 관계는 점점 복잡해졌어요. 트래픽 또한 1만 TPS를 뛰어넘게 되었고요. 조회해야 할 데이터 관계는 늘어나고 시스템의 부하도 증가했어요. 로컬 비즈니스실에서 조회 로직과 관련해 구체적으로 어떤 문제가 발생했고, 어떻게 해결했는지 공유하고자 글을 작성하게 되었어요. 비슷한 고민을 하고 계시는 다른 분들에게 도움이 되면 좋겠어요.로컬 비즈니스실이 마주한 문제 — Dynamic Join Aggregator로컬 비즈니스실은 객체 간의 연관 관계를 조합하기 위해 Aggregator 패턴을 사용하고 있었어요. Aggregator 패턴은 간단하게 구현하기 좋다는 장점을 가지고 있어요. 예를 들어 프로필과 프로필의 카테고리 정보가 같이 필요하다면 다음과 같은 코드를 작성할 수 있어요.// 로컬프로필 조회val businessProfiles = businessProfileRepository.findAllBy(businessProfileIds)// 카테고리 조회val categoryIds = businessProfiles.map { it.categoryId }val categories = categoryRepository.findAllBy(categoryIds).associateBy { it.id }// 하나의 Dto로 조합val dtos = businessProfiles.map { Dto( businessProfile = it, category = categories[it.categoryId] )}실제 프로덕션 코드 또한 위의 예시 코드와 비슷한 흐름으로 작성됐어요. 차이점은 조합해야 할 객체 관계가 더욱 방대하고 상황별로 필요한 객체들이 조금씩 다르다는 점이었어요. 예를 들어 위의 코드처럼 카테고리 정보만 필요한 경우도 있었지만, 어떤 경우에는 사업자 검수 여부와 커머스 가입 여부가 동시에 필요했어요.그러다 보니 조인 부분만 조금씩 바뀐 비슷한 Aggregator 객체가 엄청나게 늘어났는데요. 이렇게 중복 코드가 많이 생기면 Circuit breaker, Cache, 동시성 같은 공통의 관심사를 추가할 때 특히 힘들었어요. 변경 누락이 발생해서 의도치 않은 시스템 결함이 생길 위험성도 높아졌고요.그래서 하나의 공통 Aggregator를 만들어 재활용하는 방향성을 가지게 되어요. 물론 이 공통 Aggregator는 매번 모든 관계를 조회하면 안 돼요. 매번 모든 관계를 조인하면 레이턴시도 느려지고 시스템 부하도 매우 커지기 때문이에요. 그래서 include라는 인자를 통해 선택적으로 조인을 할 수 있게 만들었어요. 이해하기 쉽게 코드를 예시로 들면 다음과 같아요.// 카테고리 정보만 필요한 경우aggregator.aggregate( businessProfileIds, includeCategory=true,)// 사업자 검수 여부와 커머스 가입 여부가 동시에 필요한 경우aggregator.aggregate( businessProfileIds, includeBusinessInspection=true, includeCommerceStore=true,)굉장히 뚱뚱한 객체를 가지게 된다는 단점은 있지만 팀은 몇 년 동안 해당 Aggregator를 잘 사용해 왔어요. 그러나 연관 관계가 계속 추가되고 UseCase가 복잡해지면서 여러 어려움을 겪게 되었어요.문제 1. include 유지보수의 어려움조인할 필드가 늘어나고 복잡해짐에 따라 include 옵션이 굉장히 많아졌어요. 아래와 같은 인터페이스를 예시로 들어 볼게요.fun aggregate( businessProfileIds: List<Long>, includeImage: Boolean = false, includeCategorySuggestion: Boolean = false, includeRole: Boolean = false, includePoiId: Boolean = false, includeUserAdminBanner: Boolean = false, includeBusinessRegistration: Boolean = false, includeBusinessAccountBranch: Boolean = false, // ...): BigDTO해당 인터페이스는 시간이 지나면서 Include의 표현력이 떨어지기 쉽다는 위험성을 가지고 있어요. 예를 들어 includeImage는 어떤 객체를 조인하는 것일까요? 처음에는 비즈 프로필에 들어갈 수 있는 이미지는 프로필 이미지밖에 없었기 때문에 직관적으로 프로필 이미지라는 것을 알 수 있었어요. 그러나 시간이 흘러 이미지는 배경 사진, 가격표 사진 등 여러 의미를 가질 수 있게 되었어요.한 번 include를 모호하게 정의하는 실수를 하면 시간이 지나서 되돌리기 매우 힘들었어요. 모호한 include를 다시 세밀하게 만들려면, 모든 호출 코드에서 실제로 배경 사진과 가격표 사진이 필요한지 일일이 파악해야 했기 때문이에요.Include가 모든 조인을 세밀하게 표현하지 못하면 필요 없는 호출이 발생할 가능성이 매우 높아져요. 이로 인해 레이턴시에도 영향이 갔고, 사이드 이펙트를 예측하기 힘들어지는 큰 문제도 생겨요. 섣불리 include 옵션을 건드렸다가 의도치 않은 버그가 생기는 일이 잦아졌어요.저를 포함해 많은 팀원이 해당 객체에 새로운 관계를 추가하거나 include 옵션을 변경하는 데 두려움을 가지게 됐어요. Aggregator를 두려워하는 사람들이 많아지면서 점점 더 위험한 레거시로 성장했어요.문제 2. 예외 처리필드를 조인하다 보면 여러 에러가 발생할 수 있어요. 동일한 조인에 발생한 에러라도 클라이언트마다 필요한 예외 처리가 달라야 했어요. 하지만 대다수의 클라이언트가 중요하지 않게 생각하는 필드는 종종 폴백으로 생략되는 일이 빈번했어요.예를 들어 비즈 프로필에 작성된 후기의 개수를 조회하는 데 실패하면 0으로 채워주고 있었어요. 대다수의 케이스에서는 후기 수 조회에 실패해도 크리티컬 하지 않았기 때문이에요. 그러나 후기 수를 정말 중요하게 취급하는 도메인이 생긴다면 이는 굉장히 위험한 전략이에요. 인터페이스에서 명확하게 에러에 대한 사실이 드러나지 않는 것에 대해 잠재적인 위험성이 크다고 판단했어요.문제 3. 인자 명시성모든 연관 관계가 단순히 비즈 프로필의 아이디로 조인할 수 있는 것은 아니었어요. 예를 들어 현재 유저가 비즈 프로필의 후기를 작성했는지 판단하려면 유저의 아이디가 추가적으로 필요해요. 그러한 이유로 Aggregator는 비즈 프로필 아이디 외에도 유저 아이디를 포함해 여러 인자를 필요로 했어요.여기서 문제점은 굉장히 방대한 Aggregator의 관계 때문에 UserId가 어떤 필드들에 영향을 주는지 파악하는 비용이 커졌다는 것이에요. 여러 함수의 호출을 따라가고 나서야 유저 아이디는 후기 작성 여부, 관리자 여부 등에 영향을 준다는 것을 파악할 수 있었어요. 인자에 대한 사이드 이펙트 추적이 어려워지면서 유지 보수 비용이 증가했어요.문제 4. 동시성과 내결함성Aggregator에서 취급하는 조인이 늘어나면서 동시성에 대한 필요도가 높아졌어요. 순차 호출로는 레이턴시를 보장할 수 없게 되었기 때문이에요. 또한 TPS가 증가하고 서버 간의 의존성이 증가하면서 내결함성도 중요해져요. 그렇지 않아도 복잡한 Aggregator에 쓰레딩, 서킷브레이크, 캐싱등의 로직이 추가되면서 Aggregator를 관리하기가 더 어려워졌어요.QueryFacade 계층 도입기존 Aggregator의 대안을 리서치하다가 넷플릭스의 Optimizing the Netflix API라는 글에서 영감을 받았어요.The API Service Layer abstracts away all backend services and dependencies behind facades. As a result, endpoint code accesses “functionality” rather than a “system”.Netflix의 경우 API 서비스가 QueryFacade 객체를 의존하여 세부적인 사항을 뒤로 숨긴다고 해요.Netflix 블로그에 소개된 QueryFacade는 2013년에 제안된 내용이에요. 모든 내용을 그대로 가져오는 대신 팀 상황에 맞게 필요한 QueryFacade를 새로 정의했는데요. 앞서 설명한 문제를 완화하려면 다음과 같은 조건들을 충족해야 한다고 생각했어요.필요한 맞춤 쿼리를 쉽게 만들어 낼 수 있다.동시성을 지원한다.부분 에러 처리 기능을 지원한다.각 조인에서 필요한 의존성을 쉽게 파악할 수 있다. (ex: 유저 아이디는 유저의 후기 작성 여부를 조회할 때 사용된다.)GraphQL 도입 배경실질적인 구현 상세를 고민하다 보니 GraphQL이 적합한 도구가 될 수 있다고 생각했어요. 먼저 GraphQL이 QueryFacade의 세부 조건을 만족하는지 확인해 봤어요.1. 필요한 맞춤 쿼리를 쉽게 만들어 낼 수 있다.GraphQL 명세는 Query Language라는 DSL을 포함하고 있어요. 예를 들어 다음의 쿼리는 비즈프로필을 지도에 노출하기 위한 데이터를 조인하는 일부 쿼리예요.BusinessProfileMapPreviewQuery(( $userId: Long!, $businessProfileId: Long!) { businessProfile(businessProfileId: $businessProfileId) { // 프로필 이름 name // 프로필 이미지 url profileImage { url } // 프로필의 지역 이름 region { name } // 로컬프로필 상세 화면 진입을 위한 URI, referrer는 지도 targetUri(referrer: "map") }}다만 GraphQL에서 DSL을 지원하더라도 실제 코드에서 활용할 수 있어야 해요. GraphQL 생태계는 각종 프로그래밍 언어에 대해 코드 생성 라이브러리를 지원해요. 예를 들어 Apollo Kotlin라는 라이브러리를 사용하면 위의 쿼리에 대하여 다음과 같은 결과를 얻을 수 있어요.// BusinessProfileMapPreviewQuery은 생성된 코드val query = BusinessProfileMapPreviewQuery( userId = userId, businessProfileId = businessProfileId,)// LocalProfileMapPreviewQuery를 QueryFacade에 넘겨서 결과를 받는다.val result = queryFacade.execute(query)// 프로필 이름result.data.businessProfile.name// 프로필 이미지 URL result.data.businessProfile.profileImage?.urlDSL을 활용하면 필요한 쿼리를 쉽게 만들 수 있어요. 코드 생성 기능을 이용해 코드상에서 GraphQL의 상세 구현을 숨길 수도 있었고요. 따라서 GraphQL이 해당 조건을 만족한다고 판단했어요.2. 동시성을 지원한다.연관 관계를 조인하는 작업은 기본적으로 트리 형태를 가져요. 예를 들어 다음과 같은 그림으로 표현할 수 있어요.하위 노드는 상위 노드의 정보가 있어야만 정보를 조회할 수 있어요. 따라서 트리는 형제 노드 단위로 동시성을 가질 수 있어요. 그러한 관점에서 다음과 같은 요소들이 속도에 영향을 줄 수 있어요.트리의 깊이: 트리의 깊이만큼 대기해야 하는 의존성의 수가 늘어나요.상위 노드의 응답 속도: 위의 예시에서는 BusinessProfile 노드의 리졸빙 속도가 느리면 전체가 대기해야 해요. 대체적으로 상위 노드일수록 응답 속도가 빠른 것이 중요해요.형제 노드의 응답 속도: profileImage 필드가 아무리 빨리 끝나더라도 region 필드가 끝나기 전까지 기다려야 해요.GraphQL 엔진의 경우 여러 동시성 전략을 지원하며 팀에 알맞은 새로운 처리 전략을 구현할 수도 있었어요. 생태계에서 현재 팀에서 필요한 수준의 동시성을 기본적으로 지원하고 있었기 때문에 해당 조건을 만족한다고 봤어요. 또한 Aggregator의 경우 위와 같은 트리 관계를 인지하는 것이 어려는데요. GraphQL의 경우 DSL만 보고도 트리 관계를 쉽게 인지할 수 있다는 부가적인 장점도 있었어요.3. 부분 에러 처리 기능을 지원한다.GraphQL의 경우 필드별로 어떤 에러가 발생했는지 알 수 있어요. 예를 들어 특정 필드에서 에러가 발생하면 다음과 같은 결과를 얻을 수 있어요.{ "errors": [ { "message": "db timeout", "path": [ "businessProfile", "name" ] } ], "data": { "businessProfile": { "name": null, "profileImage": { "url": "https://xxx" } } }}이렇듯 필드별로 어떤 에러가 발생했는지 알 수 있기 때문에, 호출자의 상황에 따라 자유롭게 에러 핸들링을 할 수 있어요.for error in result.errors { if error.path contain(listOf("businessProfile", "name")) { // handle error }}그러나 QueryFacade로 사용하기에 몇 가지 아쉬운 부분이 존재해요.에러가 객체가 아닌 문자열이에요. 문자열로 에러 타입을 구분하는 것은 객체 대비 안정성이 떨어져요. 또한 StackTrace 같은 추가적인 정보를 획득하기 어려워요.error.path가 문자열 기반이에요. 에러가 발생할 필드를 구분할 때 타입 안정성이 떨어져요. 필드 이름을 잘 못 입력하면 에러 핸들링이 누락 될 수 있어요.필드 하나하나 에러 처리를 하는 것은 부담스러워요.이러한 부분을 해결하는 방안은 바로 다음 파트에서 뒤이어 설명해 볼게요. 우선 해당 조건의 경우 GraphQL의 기본적인 기능으로는 완전히 충족되지 않는다고 판단했어요.4. 각 조인에서 필요한 의존성을 쉽게 파악할 수 있다.해당 조건은 GraphQL의 DSL의 강점으로 봤어요.query BusinessProfileQuery( $userId: Long!, $businessProfileId: Long! $referrer: String!) { businessProfile(id: $businessProfileId) { // targetUri 필드와 referrer이 관련 있다는 것을 바로 알 수 있어요. targetUri(referrer: referrer) // 유저 아이디가 사장님에게 작성한 후기 존재 여부를 조회할 때 사용한다는 것을 쉽게 알 수 있어요. hasReview(userId: $userId) } }DSL을 통해 BusinessProfileQuery의 인자가 어떤 필드와 의존성을 가지는지 명확하게 파악할 수 있었어요. 따라서 해당 조건을 만족한다고 판단했어요.GraphQL은 API 서빙 레이어 아닌가?GraphQL은 API나 프론트엔드를 위한 기술이라는 인상이 강해요. 사실 저희 팀 또한 처음에는 QueryFacade를 구현하는 데 있어 GraphQL을 사용하는 것을 고려하지 않았어요. 원래 팀에서는 특정 라이브러리를 사용하지 않고 QueryFacade를 자체 구현하는 방식을 택했어요. 그러다가 구현된 코드가 GraphQL 엔진과 유사한 부분이 많다는 점을 깨닫고 가능성을 검토하기 시작했어요.검토를 마친 후 GraphQL이 제안하는 Resolvers, DataLoader 같은 좋은 구현 패턴과 잘 정의된 DSL을 기반으로 하는 풍부한 툴링 생태계에 이점이 크다고 생각했어요. 따라서 인메모리상에서 사용해도 괜찮다는 결론을 내렸어요. GraphQL은 HTTP, WebSocket 같은 특정 프로토콜과 의존성이 없어요.구체적인 구현 과정팀에서 선택한 핵심 라이브러리는 graphql-java와 apollo-kotlin이에요. 둘 다 JVM GraphQL에서 널리 사용되고 있는 만큼 문서화가 잘 되어 있고 레퍼런스도 풍부해요. 따라서 해당 라이브러리의 기본적인 사용 방법보다는, GraphQL을 QueryFacade로 구현하기 위해 필요했던 추가적인 작업에 관해 설명드려볼게요.1. 에러 핸들링앞서 “부분 에러 처리 기능을 지원한다.”는 조건을 확인하는 데 있어 아쉬운 부분이 있다고 말씀드렸어요. GraphQL에선 이렇게 아쉬운 부분이 있을 때 Directive를 통해 원하는 기능을 추가적으로 정의 할 수 있어요.예를 들어 저희는 errorGroup이라는 커스텀 Directive를 만들었는데요. 다음과 같이 사용되었어요.directive @errorGroup( name: String!) on FIELDquery BusinessProfileMapPreviewQuery( $userId: Long!, $businessProfileId: Long!) { businessProfile(businessProfileId: $businessProfileId) @errorGroup(name = "panic") { // 프로필 이름 name // 프로필 이미지 url profileImage @errorGroup(name = "fallback") { url } }}위의 쿼리를 해석하는 방법은 다음과 같아요.businessProfileId과 businessProfileId 하위에서 발생하는 에러는 전부 panic 그룹 하위에 포함한다.profileImage의 경우 panic 대신 fallback 그룹에 포함시킨다.실제 Kotlin 코드에서는 다음과 같이 처리할 수 있도록 했어요.val result = queryFaade.execute(query)// panic 그룹에 에러가 존재하면 에러를 발생시킨다.result.throwIfHasError(ErrorGroupName.PANIC)// fallback 그룹에 발생한 에러는 log를 찍고 무시한다.for (exception in result.errors[ErrorGroupName.FALLBACK]) { logger.error(exception)}errorGroup Directive 의 구현 요구 사항은 두 가지예요.부모의 errorGroup을 상속받는다.발생한 에러를 가져올 수 있다.위의 요구 사항을 구현하기 위해 사용한 구현 방법은 다음과 같아요.GraphQLContext: 발생한 에러를 저장해요.LocalContext: 트리의 노드에게 개별 컨텍스트를 전달해요.Decorator: 모든 필드에 Decorator를 적용해 errorGroup 처리좀 더 수월한 이해를 위해 핵심 부분을 예시 코드로 작성해봤어요.class ErrorGroupingDataFetcherDecorator( private val original: DataFetcher<*>) : DataFetcher<Any> { override fun get(environment: DataFetchingEnvironment): Any? { val newLocalContext = // get from environment.localContext or Directive try { val result = original.get(environment) return DataFetcherResult.newResult() .data(result) // 새로운 LocalContext를 하위에 전파한다. .localContext(newLocalContext) .build() catch (e: Exception) { // ErrorContext에 에러 추가 (thread safe) environment.graphQlContext.get<ErrorContext>().add( newLocalContext.errorGroupName, e ) } }}2. Cache 제어TPS가 높기 때문에 캐시에 대한 고민도 필요했어요. 개별 API 마다 데이터 최신성이 매우 중요한 경우가 있고 느슨하게 관리해도 될 때가 있었어요. 중요도에 따라 캐시 무효화 정책을 가져가는 경우도 있고 TTL에 의존하는 경우도 있었는데요. TTL 또한 Directive를 이용해 제어할 수 있어요.query LocalProfileMapPreviewQuery( $userId: Long!, $localProfileId: Long!) { localProfile(localProfile: $localProfileId) @errorGroup(name = "panic") { // 프로필 이름 name // 프로필 이미지 url profileImage @errorGroup(name = "fallback") @cache(scope: LOCAL, minutes: 5) @cache(scope: DISTRIBUTED, minutes: 10) { url } }}cache는 errorGroup과 유사한 방법으로 구현할 수 있기에 구체적인 코드는 생략할게요.도입 과정에서의 문제 해결QueryFacade를 통해 기존 문제점들에 대응할 수 있었지만, 예상치 못한 문제를 새롭게 마주하기도 했어요. 실제로 QueryFacade를 프로덕션에 적용했을 때, p50의 레이턴시가 약 2배 줄었지만 p99가 2배 가량 늘어났는데요. 그 원인을 아래 두 가지로 파악했어요.원인 1. 필드 수에 의한 부하graphql-java의 경우 필드 수가 늘어날수록 부하가 증가하는 이슈가 있었어요. 일부 원인은 다음과 같아요.내부적으로 생성되는 객체 수가 필드 수와 비례해요.직렬화 / 역직렬화할 필드가 늘어나요.특히 중첩 배열 상태일 때, 아래의 쿼리 또한 큰 부하를 발생시킬 수 있어요.query BusinessProfileQuery( $businessProfileIds: [Long!]!) { // 로컬프로필을 Bulk 조회 businessProfiles(ids: businessProfileIds) { // 로컬프로필의 이름 name // 배경 사진 목록 backgroundImages { url } }}예를 들어 로컬프로필이 1,000개고 모든 프로필이 배경 사진을 10개씩 가지고 있다고 가정해 볼게요.name 필드가 천 번 실행됨backgroundImages 필드가 천 번 실행됨backgroundImages.url 필드가 만 번 실행됨 (로컬프로필 수 * 배경 사진 수)작은 쿼리라도 순식간에 필드 수가 굉장히 늘어날 수 있기 때문에 p99의 케이스에서는 문제가 발생했어요.원인 2. Query 파싱 부하GraphQL의 Query를 파싱하는 작업 자체에도 1ms~4ms 정도의 부하가 발생함을 확인했어요. 부하가 발생하는 이유는 다음의 작업들이 수반되기 때문이에요.쿼리를 읽어 내부 객체로 변환.쿼리가 문법적으로 올바른지 검사.해결 방법 1. EntityScalarGraphQL Java 엔진에서 필드 수가 성능상의 문제가 됐기에 필드 수 자체를 줄일 방법을 고민했어요. 팀에서 선택한 방법은 Scalar를 사용하는 것이었어요. GraphQL에서 Scalar는 Int, Float, String, Boolean 같은 primitive 타입을 의미하는데요. 원한다면 자제적인 Scalar를 정의할 수도 있어요. GraphQL이 인메모리에서 동작한다는 특성을 살려서 객체 자체를 Scalar로 만드는 방법을 채택했어요.예를 들어 다음과 같이 쿼리를 구성했어요.scalar BusinessProfileScalarscalar ImageScalarquery BusinessProfileQuery( $businessProfileIds: [Long!]!) { businessProfiles(ids: localProfileIds) { // 비즈프로필 객체 자체 (BusinessProfileScalar) businessProfileScalar // 사진 객체 자체 ([ImageScalar]) backgroundImageScalars }}위의 쿼리를 실행한 결과로 얻는 반환 값은 BusinessProfile과 Image 객체예요.// 쿼리 실행val result = queryFacade.execute(query)// 기존에 사용하던 BusinessProfile 모델을 반환val businessProfile = result.businessProfiles[0].businessProfileScalar// 기존에 정의한 메소드 그대로 활용 가능businessProfile.isOwner(user) == true이렇게 쿼리를 구성하면 최악의 경우에도 필드 실행이 2천 번으로 줄어요. 따라서 해당 방식을 적용했을 때 p99를 비약적으로 개선할 수 있었어요. 또한 기존에 정의한 모델을 그대로 사용하기 때문에 코드 관리 측면에서도 훨씬 좋았어요.더 구체적인 사항이 궁금하신 분들을 위해 실제로 EntityScalar를 어떻게 구현했는지 설명드릴게요. 설명을 위해 먼저 두 가지 개념을 이해해야 해요.GraphQL의 Coercing: Custom Scalar를 직렬화/역직렬화하는 책임을 담당하는 객체예요Apollo-GraphQL의 Adapter: Custom Scalar를 CodeGen된 클래스에 값을 넣을 때 사용되는객체예요.Coercing에서 객체를 직렬화하고 Adapter에서 역직렬화할 때 다시 객체로 만드는 것이에요. 직렬화와 역직렬화를 위해 EntityScalarRegistry라는 객체를 만들었어요. 해당 객체의 책임 범위는 다음과 같아요.하나의 쿼리에서 유니크한 객체 ID를 발급해요.쿼리 시작 시점에 생성되고 쿼리가 끝나면 제거돼요.실제로 다음과 같이 간단한 객체예요.class EntityScalarRegistry { private val scalars = ConcurrentHashMap<Int, Any>() private val scalarId = AtomicInteger(0) fun resolve(id: Int): Any? { return scalars[id] } fun register(scalar: Any): Int { val id = scalarId.incrementAndGet() scalars[id] = scalar return id }}해당 EntityScalarRegistry를 기반으로 생성된 ID를 GraphQL의 결과를 직렬화할 때 사용해요. GraphQL의 결과를 다시 코드로 표현하는 시점에는 EntityScalarRegistry를 통해 ID를 객체로 변환해요. 그림으로 그 관계를 표현한다면 다음과 같아요.해결 방법 2. Trusted DocumentTrusted Document는 GraphQL 생태계에서 가장 널리 알려진 베스트 프렉티스예요. 퍼블릭 네트워크에서 불특정 다수가 취약한 쿼리를 날리지 않게 막아주는 개념이에요. QueryFacade의 경우 GraphQL이 네트워크로 노출되지 않기 때문에 필요 없는 개념이라고 생각했어요. 하지만 graphql-java의 경우 PreparsedDocument라는 개념과 연관 있기 때문에 상관이 있었어요. PreparsedDocument는 쿼리를 객체화하고 검증하는 작업이 이미 완료된 객체예요. 그렇기 때문에 해당 객체를 이용하면 쿼리 파싱 시간을 제거할 수 있었고 성능 문제를 해결할 수 있었어요.네트워크 레이어에서 Trusted Document를 사용할 때는 워크 플로우를 복잡하게 만들어내는 단점이 있어요. 그러나 인메모리에서 사용하는 경우에는 쿼리 등록, 버전, 호환성 등에 대한 고민이 필요 없기 때문에 비교적 간단하게 구현할 수 있었어요.// graphql-java에서 제공하는 객체들val cache = InMemoryPersistedQueryCache(emptyMap())val preparsedDocumentProvider = ApolloPersistedQuerySupport(cache)return GraphQL .newGraphQL(graphQLSchema) .preparsedDocumentProvider(preparsedDocumentProvider) .build()QueryFacade 도입 후 성과Aggregator의 가장 큰 페인 포인트는 무분별하게 큰 모델이 사용된다는 것이었어요. 편리하기도 했고 매번 작은 DTO를 만들면 중복 코드가 너무 많이 생성되었기 때문이에요. 시간이 지나면서 Aggregator 모델이 수용할 수 있는 복잡도의 한계를 넘어섰고 앞서 언급한 여러 문제가 대두됐어요.QueryFacade를 도입한 결과 DSL을 기반으로 각 케이스에서 정확히 필요한 객체만 쿼리할 수 있게 되었어요. 이제는 작은 DTO를 쉽게 만들 수 있게 되었고 중복 코드 또한 생기지 않게 되었죠. 그로 인해 각 케이스에서 필요한 의존성을 이전보다 빠르게 파악할 수 있게 됐고요. 심지어 필요한 의존성이 변경되더라도 DSL을 통해 쉽게 해소할 수 있었어요. 이러한 점들 덕분에 개발 생산성 측면에서 유의미한 결과를 낼 수 있었어요.또한 QueryFacade를 도입하면서 API 성능 또한 개선할 수 있었어요. 가장 큰 이유는 이전과 다르게 정말로 필요한 의존성만 사용되기 때문이에요. 불필요한 의존성을 제거한 일은 I/O 부하에도 큰 영향을 주었어요. 그로 인해 API 응답 속도는 향상되었고 서버 인스턴스 수와 네트워크 비용 감소에 유의미한 영향을 주게 되었어요. 앞서 언급한 동시성 처리와 캐싱 처리도 한몫을 해주었고요. 대표적인 성능 개선은 다음과 같은 것들이 있었어요.[전체 응답 속도]p95: 45ms → 18ms[Top Call Grpc 응답 속도]p95: 45ms → 14msp50: 18ms → 5ms[홈 상세 HTTP API응답 속도]p95: 100ms → 50msp50: 73ms → 34ms마무리하며로컬비즈니스실은 사장님 도구를 만드는 서비스적인 성격과 당근의 여러 팀의 요구사항을 받는 플랫폼 팀의 성격을 둘 다 가지고 있어요. 여러 팀이 의존하는 만큼 하위 호환성이 중요한 경우도 많고요. QueryFacade 또한 하위 호환성을 지키면서 내부 복잡도를 줄이기 위해 나온 솔루션이기도 해요.아직 만들어야 할 서비스가 많지만 이미 만들어온 서비스 또한 많아요. 이제는 파편화된 서비스를 통합하고 쌓여 있는 레거시를 정리하는 일의 중요성도 높아졌어요. 기존의 복잡한 시스템을 명확하게 정리하는 일을 좋아하신다면 로컬 비즈니스실에서 정말 즐겁게 같이 일할 수 있을 것 같아요. 관심이 있으신 분들은 아래 채용 공고를 통해 언제든지 지원해 주세요!👉 로컬 비즈니스실 채용 공고 보러가기GraphQL을 이용한 QueryFacade 개발기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.