기술 블로그 모음

국내 IT 기업들의 기술 블로그 글을 한 곳에서 모아보세요

전체 프론트엔드 백엔드 데브옵스 AI 아키텍처 DB 기타
AI 툴 개발은 처음이라, 당근 비개발자 구성원들의 AI 도전기
당근마켓
AI 툴 개발은 처음이라, 당근 비개발자 구성원들의 AI 도전기

AI 툴 개발은 처음이라, 당근 비개발자 구성원들의 AI 도전기 — 당근 AI Show & Tell #1해당 이미지는 OpenAI의 이미지 생성 모델인 DALL·E를 활용하여 GPT-4o에서 생성되었습니다.최근 IT 업계는 생성형 AI의 등장으로 또 한 번의 큰 전환점을 맞이했어요. 사용자 경험이 AI 중심으로 재편되면서, 기존 서비스의 PMF...

Karpenter 트러블슈팅 — 비용과 안정성 두마리 토끼 잡기
당근마켓
Karpenter 트러블슈팅 — 비용과 안정성 두마리 토끼 잡기

Karpenter 트러블슈팅 — 비용과 안정성 두 마리 토끼 잡기안녕하세요, 저는 당근페이 인프라팀에서 Site Reliability Engineer로 일하고 있는 Yany라고 해요. 저희 팀은 당근페이의 인프라를 안정적으로 관리해요. 개발자들의 프로덕트 개발 속도를 향상하고, 동시에 비용도 최적화하죠.저희는 클러스터 오토스케일링 없이 ASG(AWS ...

구글처럼 복잡한 권한 쉽게 관리하기 feat. GraphQL
당근마켓
구글처럼 복잡한 권한 쉽게 관리하기 feat. GraphQL

안녕하세요. 당근 알림 경험팀에서 프론트엔드 엔지니어로 일하고 있는 딜런(Dylan.lee)이라고 해요.알림 경험팀은 당근 사용자들뿐만 아니라 당근 구성원들의 알림 경험(Notification Experiences)을 책임져요. 사용자가 그동안 받은 알림을 모아볼 수 있는 알림함부터 당근 구성원이 알림을 간편하게 발송할 수 있는 알림 센터까지, 알림과...

건강한 SEO로 성장하는 웹사이트 만들기
당근마켓
건강한 SEO로 성장하는 웹사이트 만들기

안녕하세요, 당근 프로덕트 디자이너 Ina입니다.당근의 웹사이트를 알고 계신가요? 당근 웹사이트에서는 당근의 다양한 서비스를 앱 설치 없이도 만나볼 수 있는데요. 사용자들에게 당근의 매력을 알리는 중요한 창구예요.당근 웹사이트: https://www.daangn.com/Karrot 글로벌 웹사이트(캐나다): https://www.karrotmarket.com/ca/Karrot 글로벌 웹사이트(일본): https://www.karrotmarket.com/jp/이번 글에서는 당근의 글로벌 서비스 Karrot의 사용자들이 웹에서도 당근을 쉽게 만날 수 있도록, 북미와 일본 지역을 대상으로 검색 엔진 최적화(SEO)를 강화하고 자연스럽게 앱 설치로 이어지는 매물 중심 탐색 경험을 개선한 웹사이트 프로젝트를 공유해보려고 해요.SEO란 무엇일까요?SEO(Search Engine Optimization)는 검색 엔진 최적화를 의미해요. 구글이나 네이버 같은 검색엔진에서 사용자가 원하는 정보를 검색했을 때, 당근의 웹사이트가 상단에 노출되도록 만드는 작업이에요.예를 들어, “중고 아이폰 15”라는 키워드를 검색했을 때 당근 웹사이트가 검색 결과 첫 페이지에 노출된다면, 더 많은 사람들이 사이트를 방문하게 될 거예요. 이는 곧 서비스 성장으로도 이어지겠죠.즉, SEO는 검색 결과에서의 노출뿐만 아니라 사용자와의 연결을 강화하는 필수 전략이라고 볼 수 있어요.SEO를 위한 유저 경험 만들기이 프로젝트의 핵심은 당근 웹사이트의 검색 랭킹을 높이기 위해, 다음 세 가지 요소를 충족시키는 것이었어요:관련성(Relevance): 사용자가 실제로 원하는 키워드와 유용한 콘텐츠 제공품질(Quality): 신뢰도를 높이는 양질의 콘텐츠 및 백링크사용성(Usability): 모바일 친화성, 페이지 속도, 보안 등 사용자 중심의 사이트 환경위와 같은 기술적인 SEO 목표를 달성하면서, 동시에 사용자 만족을 높이기 위해선 어떤 경험이 필요할까요?저는 이 문제를 해결하기 위해 당근의 디자인 원칙 세 가지와 검색 랭킹을 높이기 위한 기술적 솔루션 세 가지를 매칭해보고자 했어요.연결된 경험 — 관련성(Relevance)직관적인 경험 — 품질(Quality)사용자를 위한 개선 — 사용성(Usability)(참고) 당근의 디자인 원칙 7가지1. 연결된 경험2. 사용자를 위한 개선3. 직관적인 경험4. 하나의 화면 하나의 목표5. 단순한 시각 요소6. 적절한 피드백7. 간결한 문구1. 관련성(Relevance)을 위해 ‘연결된 경험’을 제공해요맥락에 맞는 키워드를 배치해요사용자들이 실제로 많이 검색하는 키워드를 자연스럽게 배치하고자 검색 결과 페이지에 필터를 추가했어요.필터는 탐색 편의를 높여요. 동시에 필터에 포함된 키워드가 검색 결과에도 노출되죠. 따라서 사용자 경험과 SEO 모두에 긍정적인 영향을 줘요.그 외에도 검색창 아래에 인기 키워드를 배치하는 등 키워드가 노출되는 곳을 다양하게 늘려나가고자 했어요.지역 설정 기능을 제공해요당근을 떠올리면 가장 먼저 생각나는 ‘동네’ 키워드를 웹사이트에 녹여내기 위해, 동네 설정과 검색 기능을 추가했어요.이를 통해 사용자는 동네에서 거래되는 물건을 쉽게 확인할 수 있게 되었어요. 또한 검색 엔진에서 ‘서초동’ + ‘소파’ 같은 지역 키워드를 함께 입력했을 때도 자연스럽게 당근 웹사이트를 만나볼 수 있게 되었어요.2. 품질(Quality)을 위해 ‘직관적인 경험’을 제공해요카테고리 목록을 추가해요글로벌 Karrot은 중고거래 서비스만 제공하고 있어요. 그래서 카테고리 페이지를 추가해 사용자들이 어떤 카테고리가 있는지 한눈에 파악하도록 돕고, 카테고리 자체로도 검색 결과가 보일 수 있도록 개선했어요.카테고리 자체가 검색 결과가 되도록 노출하기카테고리도 하나의 검색 결과로 만들며 검색 엔진에 노출되도록 개선했어요브레드크럼(Breadcrumb)을 추가해요“홈 > 부동산 > 매물”처럼 현재 위치와 다음 동선을 한눈에 파악할 수 있도록 내비게이션 흐름을 구성했어요.브레드크럼(Breadcrumb)은 사이트 품질을 높여요. 동시에 사용자에게 지금 어느 페이지에 있고 이전에 어떤 페이지를 거쳤는지를 명확히 알려줘, 직관적인 탐색 경험을 강화해요.3. 사용성(Usability)을 위해 사용자를 위한 개선을 만들어요반응형 디자인글로벌의 다양한 디바이스 환경을 고려해, 화면 크기에 따른 배치·컴포넌트를 6가지 브레이크포인트로 정교하게 설계했어요. 그 결과, 모바일 디바이스·태블릿·웹 등 다양한 환경에서도 웹사이트를 불편함 없이 이용할 수 있게 되어, 사용성(Usability)이 크게 향상됐어요.결과배포 한 달 이후의 결과예요🇨🇦 북미(캐나다): Impression(노출) 약 20배 성장, 클릭이 2배 성장했어요.배포 이후의 성장 그래프🇰🇷 한국 (24년 11월 초에 동일한 UX/UI로 개편) : 월 접속자 수 약 43% 상승했어요. (기존 426만 → 610만)마치며이번 프로젝트는 ‘관련성(Relevance)’, ‘품질(Quality)’, ‘사용성(Usability)’의 세 가지 핵심 요소를 사용자 중심의 디자인으로 풀어낸 SEO 전략으로, 당근의 웹사이트가 더 많은 사용자에게 노출되고 건강하게 성장할 수 있는 발판을 마련한 프로젝트라 뜻깊게 참여할 수 있었어요.이 프로젝트에 함께해 준 토니, 리바이, 리아, 헤일리, 해나, 브랜딩팀 리지, 쿄, 유니와 이어서 웹사이트를 널리 알리는데 애써주시는 SEO Growth 팀에게 응원과 감사의 마음을 전해요!건강한 SEO로 성장하는 웹사이트 만들기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

LLM을 활용한 스마트폰 시세 조회 서비스 구축
당근마켓
LLM을 활용한 스마트폰 시세 조회 서비스 구축

스마트폰을 바꾼 후 이전에 썼던 기기를 중고로 팔아보신 적 있으세요? ‘이 정도 상태의 기기면 어느 정도 가격대가 적당한 거지?’ 고민하며, 수많은 중고 매물 게시글을 일일이 확인하지는 않으셨나요? 이제는 LLM(대형 언어 모델) 덕분에 이렇게 번거롭고 어려웠던 작업이 훨씬 쉽고 빠르게 해결되고 있어요.이 글에서는 LLM을 활용해 중고거래 게시글에서 스마트폰 정보를 추출하고, 이를 통해 시세를 산출한 방법을 소개하려고 해요. 먼저 스마트폰 시세조회 서비스를 왜 만들게 됐는지 배경을 간단히 살펴본 후, LLM으로 게시글을 분류·정제하는 과정, BigQuery를 이용해 정보를 후처리하고 시세를 집계하는 과정, 마지막으로 벡터 DB 기반으로 유사 게시글을 추천하는 과정을 단계별로 소개해 드릴게요. LLM으로 사용자 경험을 효과적으로 개선할 방법을 고민 중인 분들에게 이 사례가 큰 도움이 되면 좋겠어요.스마트폰 시세조회 서비스의 모습스마트폰 시세조회는 왜 필요할까요?많은 중고거래 판매자들이 물품의 적절한 가격을 결정하는 걸 어려워해요. 개인 간 거래는 워낙 다양한 상품이 혼재되어 있기 때문인데요. 종류도 워낙 다양한데 상태도 가지각색이라, 물품의 정확한 시세를 한눈에 파악하기 어려운 거죠. 스마트폰을 예로 들면 단순히 같은 기종만 검색해서 끝날 일이 아니라, 사용 기간, 배터리 효율, 스크래치 여부 등 상태가 비슷한 기기가 얼마에 팔리는지 일일이 확인해야 하는 거예요.중고거래팀은 사용자가 물품의 시세를 한눈에 확인하고 더 쉽게 가격을 결정할 수 있도록, 아이폰, 갤럭시 기종을 대상으로 한 스마트폰 시세 조회 서비스를 테스트하기로 했어요. 다양한 물품 중 스마트폰을 베타 테스트 대상으로 선정한 이유는 다음과 같아요. 스마트폰은 제품 모델이 명확하고 게시글 수가 많아 데이터 기반 시세 계산에 유리해요. 또 판매 단가가 높아 가격 결정이 중요한 상품이기도 하고요.결과적으로 모델, 용량, 새 상품 여부, 스크래치 및 파손, 배터리 효율 등 구체적인 물품 상태에 따라 시세가 어느 정도인지 파악할 수 있는 서비스를 만들었어요. 예를 들어 사용자가 ‘아이폰 16 Pro 128GB’를 선택하고 필터에서 구체적인 ‘사용 상태’나 ‘배터리 성능’을 설정하면, 곧바로 그에 따른 시세 정보를 ‘OOO만원-OOO만원’과 같은 가격 범위의 형태로 확인할 수 있어요. 이번 프로젝트는 머신러닝을 활용해 당근 중고거래 데이터를 기반으로 정확한 시세를 제공한 첫 번째 시도로, 팀 내에서도 의미가 큰 프로젝트이기도 했는데요. 그럼 본격적으로 기능을 구현해 나간 과정을 단계별로 소개해 드릴게요.Step 1. 상품 정보 추출가장 큰 문제는 게시글에서 상품 정보를 추출하는 것이에요. 당근은 판매자의 글쓰기 허들을 낮추기 위해 중고거래 게시글에 구체적인 기종이나 물품 상태를 입력하도록 요구하지 않아요. 하지만 구체적인 물품 상태별로 스마트폰 시세를 제공하려면 모델, 용량, 새 상품 여부, 스크래치 및 파손 여부, 배터리 효율 등 여러 가지 다양한 조건을 알아내야 했어요.기존에는 이런 데이터를 추출하려면 복잡한 정규식을 만들거나 이에 특화된 ML 모델을 만들어야 했어요. 하지만 LLM을 도입하여 ‘모델명’, ‘용량’, ‘스크래치 여부’ 등을 추출할 수 있게 되었어요. 정규식이나 별도 모델을 구축할 때와 달리, 프롬프트 수정만으로도 추출 정확도를 높일 수 있어 공수가 매우 줄었어요.게시글에서 정보를 추출하는 과정 예시우선 타겟 게시글들을 정한 후 프롬프트 엔지니어링을 통해 결과물을 뽑았어요. 그 결과물을 채점하고 프롬프트를 수정하여 추출의 정확도를 높였어요. 만족할만한 성능이 나온 후에는 게시글이 생성될 때마다 LLM을 적용하여 사내 데이터 웨어하우스인 BigQuery에 적재하는 파이프라인을 구축했어요.BigQuery에 적재한 이후에는 데이터의 후처리를 거쳤어요. LLM 특성상 잘못된 분류를 하거나 허구의 정보를 생성하는 환각 문제를 완전히 피할 수는 없었어요. 특히 모델명을 추출하는 과정에서 나열한 기종 이외에 다른 이름으로 추출한다거나, 복잡한 기종 명의 경우 잘못된 이름으로 추출하는 경우도 있었어요.예를 들어, 1세대 갤럭시 폴드의 경우 “Galaxy Fold”지만 2세대부터는 “Galaxy Z Fold2”라는 이름을 가져요. 하지만 LLM은 “Galaxy Z Fold”나 “Galaxy Fold1”, “Galaxy Fold 1”처럼 사용자의 입력한 잘못된 모델명을 그대로 추출하는 경우가 있었어요.결국 프롬프트에서 모든 예외 케이스를 처리하기보다는, BigQuery View Table을 통해 2차 가공을 하기로 했어요. 아래 코드는 특정 스마트폰 시리즈(예: Galaxy Fold, Galaxy Flip 등)의 여러 가지 잘못된 표기를 정규화하는 SQL 예시예요. 이 로직을 만들 때도 GPT를 활용해 삽질 과정을 크게 줄였어요.-- Galaxy Fold 패턴 처리WHEN REGEXP_CONTAINS( REGEXP_REPLACE(REGEXP_REPLACE(item_name, r'\\s5G$', ''), r'(?i)(\\+|plus)', '+'), r'^Galaxy\\sFold($|\\s?\\d+)' ) THEN CASEWHEN REGEXP_CONTAINS( REGEXP_REPLACE(REGEXP_REPLACE(item_name, r'\\s5G$', ''), r'(?i)(\\+|plus)', '+'), r'^Galaxy\\sFold($|\\s?1)$' ) THEN 'Galaxy Fold'ELSE REGEXP_REPLACE( REGEXP_REPLACE( REGEXP_REPLACE(item_name, r'\\s5G$', ''), r'(?i)(\\+|plus)', '+' ), r'Galaxy\\sFold\\s?(\\d+)', 'Galaxy Z Fold\\\\1' )-- ...Step 2. 데이터 기반 시세 집계위 과정을 통해 정제된 모델명과 흠집, 배터리 용량 등에 대한 원시 데이터를 얻었어요. 이제 이 데이터들을 집계해서 시세 정보를 만들어낼 수 있어요. 데이터를 집계하고, 사용자분들에게 제공하는 데에는 BigQuery와 MySQL, 두 개의 저장소를 사용했어요. 각 저장소의 장단점이 다르다 보니 각각의 장점을 활용해 더 좋은 서비스를 만들어내기 위해서였어요. 두 저장소의 특징을 비교해 보면 다음과 같아요.MySQL주요 용도: 트랜잭션 처리(OLTP), CRUD 작업, 실시간 데이터 제공 및 웹 백엔드성능 특성: 낮은 지연 시간과 빠른 트랜잭션 처리로 실시간 응답에 유리데이터 이동 및 적재 전략: 사용자에게 빠른 응답을 위한 최종 집계 결과나 가공된 데이터 저장에 적합사용 사례: 웹 애플리케이션 백엔드, 실시간 거래 처리BigQuery주요 용도: 대규모 데이터 분석(OLAP), 데이터 웨어하우징, 로그/이벤트 분석성능 특성: 대규모 집계 및 복잡한 분석 쿼리에 최적화데이터 이동 및 적재 전략: 원본 대용량 데이터 분석에 집중, 불필요한 데이터 이동 최소화사용 사례: 데이터 사이언스, 머신러닝, 대규모 로그 분석, 배치 분석두 저장소의 장점을 얻기 위해 팀에서 사용한 방법은 다음과 같아요. 우선 빅쿼리에서 주간 시세조회 처리 같은 대용량 작업을 마친 후, 집계 결과만을 MySQL로 옮겨 저장했어요. 그 후 사용자가 화면에 진입할 때는 BigQuery 접근 없이 MySQL을 활용해서 시세조회 결과를 내려줬어요.BigQuery에서 MySQL로 모든 데이터를 덤프했다면, 비효율이 발생했거나 응답시간이 느려졌을 텐데요. 이 과정을 통해 그런 문제들을 방지할 수 있었어요. 또한 집계 결과를 BigQuery에서 MySQL로 옮겨오는 작업을 멱등하게 설계하여서 운영의 편의성을 높였어요.Step 3. 유사 게시글 제공이 과정을 통해 사용자가 원하는 조건의 상품 시세를 구체적인 가격 범위로 제공하게 됐어요. 그런데 당근에서 물건을 팔기 전 비슷한 물건을 하나하나 확인해 보는 것처럼, 일부 사용자의 경우 좀 더 정확한 가격 책정을 위해 다른 게시글을 직접 확인하고 싶어 할 수도 있겠다고 판단했어요. 이 과정을 편리하게 만들기 위해 시세 조회 화면에서 시세 통계 데이터뿐만 아니라 유사 게시글도 제공하려 했어요. 이 기능은 통계 데이터와는 다르게 게시글의 임베딩을 활용해 구현했어요.임베딩은 텍스트, 이미지 등의 개체를 수학적인 형태로 바꾸어 표현한 것이에요. 좋은 임베딩 모델은 텍스트의 의미를 수학적으로 잘 변환하기 때문에, 의미상으로 유사한 게시글을 빠르게 찾아낼 수 있어요. 예를 들어 영어로 작성한 “iPhone”과 한글로 적은 “아이폰”이 같은 의미라는 것은 단순히 문자열의 유사도로는 알아낼 수 없어요. 하지만 좋은 임베딩 모델을 사용한다면 이 두 단어는 비슷한 벡터로 변환이 되고, 따라서 사용자가 “아이폰”으로 검색하든 “iPhone”으로 검색하든 동일한 결과를 제공할 수 있게 돼요. 또 팀에서는 벡터 저장과 검색에 최적화된 데이터베이스인 벡터 DB, 그중 Pinecone을 도입해서 벡터 서빙을 최적화했어요.그 과정이 순탄하지만은 않았는데요. 쿼리와 문서의 불일치 때문에 어려움을 겪었어요. 당근의 게시글은 제목이나 본문이 모두 길고 상세하게 설명하는 형태예요. 하지만 스마트폰 시세조회의 경우 “아이폰 16 프로 흠집 있음”처럼 아주 짧은 단어로 이루어진 형태인데요. 이러다 보니 생각보다 유사하지 않은 게시글들이 검색되는 경우가 잦았어요.문제 해결을 위해 여러 가지 임베딩 모델을 테스트해 보다가 구글의 임베딩 모델은 작업 유형을 선택할 수 있다는 걸 알게 되었어요. 임베딩 모델을 호출할 때 문서의 경우 task_type: RETRIEVAL_DOCUMENT, 쿼리의 경우 task_type: RETRIEVAL_QUERY과 같은 형태로 옵션을 넘겨 해당 작업에 최적화된 형태로 임베딩을 만들어냈어요.위 옵션을 지정하고 다른 임베딩 모델들과 비교하자 훨씬 좋은 결과를 얻었어요. 임베딩의 평가는 해당 임베딩 모델을 통해 얻어낸 게시글이 추출 모델, 메타데이터 (흠집 유무, 배터리 사이클 등)에 맞을 때마다 더 높은 점수를 부여하는 방식으로 설계했어요. 이 채점 과정 또한 LLM을 통해 자동화하여 공수를 많이 줄였어요.유사한 게시글들을 잘 찾아내지만, 순서가 생각과 잘 맞지 않는 문제도 있었어요. “갤럭시 S24”를 검색했는데 15개의 게시글 중 갤럭시 S24가 10개, S24+가 3개, S23이 2개 있다고 생각해 보세요. 그러면 우리가 기대하는 결과는 S24, S24+, S23 순으로 게시글이 나열되는 거예요. 하지만 모두 높은 유사도를 보이다 보니 순서가 뒤죽박죽이었어요.RAG나 추천 등에 익숙하신 분이라면 ReRanker를 도입해서 문제를 풀면 될 거 같다는 생각이 드실 거예요. 저희도 ReRanker를 테스트해 보았는데, 파인튜닝 같이 도메인에 특화하지 않은 상태로 일반 모델을 적용했을 때는 딱히 더 나은 결과를 얻지 못했어요. 게다가 팀에는 이 과정을 도와줄 수 있는 ML 엔지니어도 없는 상황이어서 저희는 다른 방법을 택하기로 했어요.이미 유사한 게시글을 들고 온 이후기 때문에, 특정 규칙을 기반으로 어떤 문서들은 배제하고 사용했어요. 예를 들어 “탭”, “패드” 같은 단어 등장한 게시글은 사용하지 않는 식이죠. 같은 맥락으로 내 아이템과 일치하는 단어가 많을수록 더 상위에 위치시키고, 일치하지 않는 단어가 있을 경우 순위를 좀 더 아래로 조정했어요. 이 과정에서 기본적인 동의어 처리도 진행했고요. 예를 들어, 갤럭시 S23의 시세를 조회한다면 갤럭시의 동의어인 Galaxy S23이 있는 게시글은 상위에 위치시키고, S23 울트라는 울트라로 인해 감점되어서 더 아래로 내려가는 식이죠.마치며여태까지 LLM과 임베딩 모델 등 새로운 기술을 활용하여 당근의 자체 데이터 기반으로 시세 조회 기능을 만들어간 과정을 소개해 드렸어요. 그동안은 없었던 새로운 도구를 활용하여 사용자의 문제를 풀어나가 기술적으로도, 한 사람의 메이커로서도 즐거운 경험이었어요.이 과정에서 얻은 교훈은 다음과 같아요.LLM이 똑똑하고 좋은 도구는 맞지만, 모든 과정을 프롬프트 엔지니어링으로 해결하려고 하기보다는 후처리 과정을 따로 작성하는 게 더 효율적일 때도 있다는 것내가 필요한 장점을 가진 저장소를 선택하면 효율적으로 일할 수 있다는 것내 작업 유형에 잘 맞는 임베딩 모델을 사용하면 문제를 쉽게 풀어낼 수 있다는 것중고거래실은 이처럼 새로운 도구를 활용하여 사용자들의 문제를 풀고 더 좋은 경험을 제공하는 것에 진심인 팀이에요. 팀에 흥미가 생기셨다면 아래 공고를 통해 지원하실 수 있어요.Software Engineer, Backend — 중고거래LLM을 활용한 스마트폰 시세 조회 서비스 구축 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

검색 형태소 분석 사전 배포 과정 개선하기
당근마켓
검색 형태소 분석 사전 배포 과정 개선하기

안녕하세요! 검색플랫폼팀 테디예요. 당근 검색플랫폼팀은 사용자에게 보다 나은 검색 경험을 제공할 수 있도록 튼튼한 플랫폼을 만드는 팀이에요. 검색 서비스와 인프라를 운영 및 관리하면서 검색 트래픽을 안정적으로 소화할 수 있도록 만들죠.이를 위해 팀에서 다양한 노력을 하고 있는데요. 최근에는 검색 형태소 분석 사전 배포 과정을 개선하는 프로젝트를 진행했어요. 그래서 오늘은 프로젝트를 진행하게 된 계기와 과정, 그리고 결과를 간략하게 공유드려 볼게요.📚 형태소 분석을 위한 기본 사전검색에서 형태소 분석 기능은 필수예요. 이 기능은 주로 문서를 검색하거나 색인할 때, 검색어 또는 문서의 검색 대상 필드(제목, 본문 등)의 형태소를 분석하기 위해 사용돼요.저희 팀은 Elasticsearch를 검색 엔진으로 사용하면서 Nori를 기반으로 자체 구축한 Analysis-Karrot 플러그인을 통해 형태소를 분석하고 있어요. 이 플러그인은 내부에 가지고 있는 기본 사전 (또는 시스템 사전)을 기반으로 형태소를 분석해요. 해당 사전은 한국어 단어들의 품사, 형태, 가중치 등 형태소 분석에 사용되는 다양한 정보를 가지고 있어요.내부 사전 데이터 일부형태소를 잘 분석하고 나아가 검색 품질을 향상하기 위해서는 이 기본 사전을 잘 관리하는 것이 매우 중요해요. 왜냐하면 줄임말, 신조어 등이 계속해서 생겨나고, 어떤 단어라도 검색어로 들어올 수 있기 때문이에요. 이러한 변화에 잘 대응하기 위해 기본 사전 내용을 주기적으로 업데이트하며 고도화해야 해요.⚙️ 기본 사전 업데이트 프로세스AS-IS: 기본 사전 업데이트 프로세스현재 기본 사전 내용을 업데이트하는 과정은 아래와 같아요.검색 어드민을 통해서 새로운 사전 데이터를 사전 DB에 추가한다.사전 DB를 읽어서 새로운 사전 데이터 셋을 생성한다.사전 데이터 셋을 주입하여 Analysis-Karrot 플러그인을 생성한다.Analysis-Karrot 플러그인을 Elasticsearch 검색 클러스터에 재배포한다.여기서 문제는 ES 검색 클러스터 배포는 매우 신중해야 하는 무거운 작업이라는 점이에요. 만약 검색 클러스터 배포 과정에서 문제가 발생한다면 검색 기능 자체를 사용하지 못할 수도 있어요. (SPOF) 그래서 설정 변경 또는 버전 업데이트 등과 같이 검색 클러스터 운영에 필수적인 상황을 제외하고는 배포를 최소화하는 것이 운영 측면에서 지향하는 방향이에요. (DB 운영과 비슷하다고 볼 수 있죠.)🤔 검색 클러스터 배포 없이 사전 업데이트 가능할까?팀에서는 검색 클러스터 배포를 하지 않고 기본 사전을 업데이트할 수 있는 방법이 없을까 고민하면서, 기본 사전을 사용하는 플러그인 내부 코드를 분석했어요.Nori를 기반으로 자체 개발한 Analysis-Karrot ES 플러그인은 내부적으로 Lucene의 BinaryDictionary를 사용해요. 이를 통해 플러그인 내부에 들어있는 불변하는 성격의 사전 데이터를 싱글톤 객체를 사용해서 메모리에 로드하고 있어요. 여기서 저희는 Dictionary 객체를 생성할 때 플러그인 내부에 있는 사전 데이터가 아니라 외부에 저장한 사전 데이터를 참조할 수 있도록 변경하면 어떨까 생각했어요.BinaryDictionary 를 구현해서 만든 TokenInfoDictionary 클래스 명세 예시. 싱글톤으로 구현되어 있다. (코드 보기)💡 아이디어를 구현해 보자! (a.k.a PoC)TO-BE: 기본 사전 업데이트 프로세스위에는 아이디어를 조금 더 구체화하기 위해 그림으로 표현해 본 모습이에요. 기존에는 플러그인을 새로 만들어서 Elasticsearch 도커 이미지에 적용 및 배포를 진행했다면, 새로운 방식에서는 아래와 같은 단계로 새로운 사전을 배포하게 돼요.사전 데이터(*.dat)를 빌드 후 S3에 업로드한다.업로드된 사전 데이터를 검색 엔진 노드의 로컬 파일 시스템에 저장한다.인덱스를 생성할 때 설정에서 사전 데이터가 저장되어 있는 경로를 지정한다.형태소 분석 시 지정된 경로에 있는 사전으로 토크나이저 객체를 생성 및 사용한다.이 아이디어를 구현하기 위해 많은 요소들을 고려해야 했는데요. 그중 가장 핵심적인 부분은 기존에 싱글톤 패턴으로 오직 플러그인 내부에 저장된 사전만 로드해서 사용하던 사전 관련 로직을, 로컬 파일 시스템에 저장된 여러 개의 사전을 경로 기준으로 각각 로드해서 사용할 수 있도록 수정하는 부분이었어요. 아래는 싱글톤 인터페이스의 변경 예시예요.싱글톤 패턴으로 사용하던 사전 로직을 경로 기준으로 여러 개의 사전을 읽을 수 있게 수정한 코드 인터페이스 일부🫠 실패 그리고 원인 분석그러나 안타깝게도 저희 팀은 두 달 넘게 구현 및 단위 테스트를 진행한 이후 통합 테스트를 진행하는 과정에서 문제를 발견했어요. 크고 작은 문제들 중에서 가장 중요했던 건 힙 메모리 사용량이 30% 이상 증가했다는 점이었어요.Elasticsearch에서는 힙 메모리 사용량이 75% 이상이면 매우 주의를 요하며 적절한 최적화를 권유해요. 85% 이상이면 클러스터 퍼포먼스가 저하되고 circuit breaker 에러가 발생할 수 있다고 말하죠. 특히 운영 클러스터에서 circuit breaker 에러가 발생하면, 해당 노드가 일시적으로 중단 또는 재시작되면서 클러스터에 이상 현상을 발생시킬 수 있어요. 매우 조심해야 하는 거죠.PoC 전후 비교 결과 (왼쪽: 전, 오른쪽: 후)결국 PoC를 중단하고 힙 덤프를 통해 힙 사용량 증가에 대한 원인 파악에 나섰어요. 가장 눈에 띄는 문제는 Byte Array 크기 증가였어요. (2.26GB to 7.16GB) 이건 곧 사전 데이터 크기가 증가했다는 뜻이에요. 기존에는 싱글톤 패턴을 사용해서 단일 인스턴스로 사전을 생성하여 서빙했지만, 여러 버전의 사전을 서빙하기 위해 싱글톤 패턴을 제거했는데요. 이로 인해 힙 메모리가 증가할 거라고 어느 정도 예상하긴 했어도, 이렇게까지 많이 증가할 줄은 미처 몰랐어요.Heap Dump 결과 분석왜 Byte Array 크기가 증가했는지 파악하기 위해 계속해서 디버깅을 진행했어요. 그러다 Elasticsearch 코드에서 몰랐던 사실 하나를 발견했어요. 그것은 인덱스 스키마 설정에 Tokenizer를 선언하면 Tokenizer 객체를 생성하면서 내부적으로 기본 사전을 호출한다는 점이었어요. 저희는 인덱스 스키마에는 두 개의 Tokenizer를 정의하고 있었기 때문에, 인덱스를 생성할 때 기존 대비 최소 두 배 이상의 힙 메모리를 사용하게 된 거였어요.PUT my-index-000001{ "settings": { "analysis": { "analyzer": { "my_custom_analyzer": { "type": "custom", "tokenizer": "...", // Tokenizer 선언 예시 ... }...💪 재시도!실패에서 배운 점들을 교훈 삼아 두 번째 PoC를 진행하게 됐어요. 이번에는 레슨런을 바탕으로 두 가지 목표를 세우고 작업을 시작했어요.1) 사용하지 않는 사전 데이터를 제거하여 힙 사용량을 최적화하자저희가 사용하고 있는 플러그인은 Nori를 기반으로 만들어졌기 때문에 직접 구축한 사전 외에도 기본적으로 Nori가 가지고 있는 다양한 종류의 사전을 가지고 있어요. 또한 사전 스키마에도 12가지의 정보가 들어있고요. 여기서 사용하지 않는 데이터를 최대한 제거함으로써 플러그인을 경량화시키고 나아가 힙 메모리 사용량을 줄이고자 했어요. 결과적으로 이 과정을 통해 플러그인 크기를 33MB에서 7MB로 줄이게 됐어요.플러그인의 크기를 과거 33MB에서 7MB까지 감소2) 최대 2개의 최신 사전 데이터만 힙 메모리에 로드하자예시) 시간 순서(왼쪽에서 오른쪽)에 따라 달라지는 사전 업로드 상태앞선 실패로 얻은 교훈 중 하나는 “사전을 버전별로 유일하게 관리해서 같은 버전을 사용할 때 객체 참조가 가능하게 하자”라는 것이었어요. 워낙 빈번하게 객체 참조가 일어나기 때문에 참조할 때마다 새로운 객체를 만들게 되면 힙 메모리 사용량이 금방 100%에 도달하거든요. 그래서 이번에는 버전별 사전 관리를 위해 Map<Version, Dictionary>를 만들고 이 Map을 관리하는 매니저 객체를 싱글톤 패턴으로 구현했어요.싱글톤 패턴으로 사용하던 사전 로직을 매니저 인스턴스를 호출하도록 수정한 코드 인터페이스 일부🚀 배포 및 모니터링여러 번의 크고 작은 테스트를 거치면서 로직을 검증할 시간을 충분히 가졌고, 그 결과 프로덕션 환경에 배포해도 되겠다는 결론을 내렸어요. 그래서 중고거래 클러스터에 해당 기능을 배포했어요. 현재는 사전을 직접 운영 및 관리하고 있는 검색 운영팀에서 기본 사전 업데이트가 필요할 때마다 사전을 배포해 주고 계세요. 저희 팀은 함께 모니터링을 하고 있고요. 이로써 한 달에 한 번씩 몰아서 반영했던 사전 업데이트를 하루에 한 번씩 할 수 있게 되면서, 보다 빠르게 사용자 검색 경험을 개선할 수 있는 기반을 마련했어요.기본 사전 배포 이후 검색실 내부 반응🤔 앞으로 더 해야 할 일은?물론 아직 몇 가지 추가로 해야 할 일들이 남아있어요. 대표적으로는 프로덕션 환경이 다른 클러스터에도 점진적으로 배포해 나가는 일이 남아있어요. 또한 사전 업데이트 알람 추가나 모니터링 대시보드 고도화, 그리고 사전 업데이트 실패에 대한 Fallback 구성 등 안정성 및 가용성 측면의 작업들도 계속해서 진행할 예정이에요.🥕 함께 해요!당근 검색 플랫폼팀은 당근 검색 사용자들의 검색 경험 개선을 위해 앞으로도 빠른 피드백 루프를 바탕으로 플랫폼을 끊임없이 고도화해 나갈 거예요. 또한 검색의 방대한 트래픽을 무리 없이 소화하고 보다 나은 검색 결과를 제공할 수 있는 튼튼한 플랫폼을 만들기 위해 꾸준히 노력할 거예요.이러한 저희의 눈부신 여정에 함께하실 분들을 찾고 있어요. 많은 관심 부탁드려요!https://about.daangn.com/jobs/5688517003/읽어주셔서 감사해요!검색 형태소 분석 사전 배포 과정 개선하기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

당근 웹 플랫폼 외전 — 레거시 시스템 안전하게 제거하기
당근마켓
당근 웹 플랫폼 외전 — 레거시 시스템 안전하게 제거하기

당근 웹뷰 플랫폼 외전 — 레거시 시스템 안전하게 제거하기오래전에 소개했던 당근 웹뷰 배포 변천사에 이어지는 근황으로, 이번에는 조금 다른 주제를 다뤄보려고 해요 — 새로운 기술이 정착하면 이전에 사용하던 기술은 어떻게 되는 걸까요?당근마켓에 웹 프로젝트 배포하기 #1 — 파일 기반 웹뷰당근마켓에 웹 프로젝트 배포하기 #2 — 웹 서버로 돌아가기앞선 글에서 언급했던 것처럼 기존에 구축했던 로컬 파일 기반 웹뷰(이하 “로컬 웹뷰”)를 버리기로 결정하고 정말 오랜 시간이 지났어요.3년도 더 지난 로컬 웹뷰 deprecation 공지기존의 웹뷰와 새로 배포되는 웹뷰를 “로컬 웹뷰” / “리모트 웹뷰”라 구분하여 부르고 공지 이후 1년 간 기존 또는 새로운 웹뷰 배포들이 마이그레이션을 완료하면서 로컬 웹뷰는 사용하지 않는 기술이 되었어요.하지만 사용자가 이전에 사용된 로컬 웹뷰 기반의 딥링크로 진입하는 경우가 있었기 때문에 한 번이라도 로컬 웹뷰를 사용했던 서비스들은 하위 호환성을 위해 이중 배포를 계속 유지해야 했어요.로컬 웹뷰에서 리모트 웹뷰로 마이그레이션되는 방식로컬 웹뷰 배포는 사용자를 리모트 웹뷰로 이동시키는 코드만 포함해서 거의 변경이 없었어요. 시간이 지나면서 자연히 잊히길 원했지만, 맥락은 꾸준히 남아 프론트엔드 개발자들을 불편하게 했어요.라우팅 방식 변경, 진입 경로의 동작 변경 등 몇몇 큰 변경에서 로컬 웹뷰에도 대응하는 코드 추가가 필요했어요. 종종 필요한 대응을 빼먹고 배포를 진행하여 큰 버그가 생기고 뒤늦게 발견되기도 했어요.HTTP 리디렉션을 사용할 수 없어 JavaScript 코드에 의존했고, 로컬웹뷰 동작을 모두 기다린 후에 실행됐기 때문에 매우 느렸어요. 개발자들이 새로운 배포에서 아무리 초기 로딩을 개선하더라도 로컬웹뷰로 진입하는 경우 아무 소용이 없었어요.신규 입사한 개발자 분들이 당근의 웹뷰 구조를 파악하는데 큰 진입장벽이 되었어요. 더 이상 사용하지 않는 기술임에도 불구하고 여전히 로컬웹뷰가 무엇인지 가르치거나 파악하는데 시간을 써야 했어요.모바일 앱 개발자 분들에게도 “오래되어 손대기 어려운 코드”, 인프라 엔지니어 분들에게도 “자세히 모르지만 손대면 안 되는 리소스” 등 특별한 예외로 취급되었어요.문제점을 인지하고 그저 오래 기다리는 것 만으로는 기술을 퇴역시키는 데는 충분하지 않았어요. 로컬 웹뷰는 말 그대로 “기술 부채”로 남았어요.우리는 이제 이 부채를 청산하기 위해 적극적인 방식으로 상황을 개선해 보기로 했어요. 구체적으로는 위에서 언급한 문제들을 해결하기 위해 다음과 같은 목표를 가지고 프로젝트를 진행했어요.프론트엔드 개발자들이 더 이상 로컬 웹뷰 서빙을 하지 않는다.모바일 앱 코드에서 로컬 웹뷰를 처리하는 로직을 완전히 제거할 수 있다.로컬 웹뷰와 관련된 오래된 AWS 리소스를 완전히 정리한다.사용 패턴 분석하기“3년이요? 안 쓴 지 이만큼이나 지났다면 이제 그냥 제거해도 되는 것 아닐까요?”라는 의문이 자연스럽게 나왔지만 플랫폼은 여기에 대해 최대한 보수적으로 생각할 필요가 있어요.AWS CloudFront로 배포되는 웹 에셋들은 표준 로깅과 AWS Athena를 이용해 대략적인 유입량을 확인할 수 있어요. 먼저 표준 로깅을 활성화해서 일주일 정도 쌓인 CloudFront 로그를 대상으로 SQL로 약 일주일 간의 요청 수를 세보았어요.SELECT cs_uri_stem, count(cs_uri_stem) as countFROM "cloudfront"."cloudfront_logs_xxxxxx"WHERE date > CAST('YYYY-MM-DD' as DATE) -- 특정 일자 이후로 모든 로그 취합 AND cs_uri_stem LIKE '%meta.json' -- 로컬 웹뷰의 메타데이터 엔드포인트GROUP BY cs_uri_stemORDER BY count DESC/service-a/meta.json | 129,007,826/service-b/meta.json | 1,252,890/service-c/meta.json | 764,021...오래되었으니 거의 사용되지 않으리라는 희망사항과는 다르게, 실제로는 트래픽이 적지 않게 남아있다는 것을 확인했어요. 시스템을 일방적으로 제거해 버리면 앱 어딘가가 망가져서 여러 사용자들이 불편을 겪을 것이 분명했어요.문제는 로컬웹뷰가 표준 웹 요청과 다르게 HTTP 시맨틱을 온전히 따르지 않기 때문에 표준 로깅만으로 구체적인 사용패턴까지는 분석할 수 없다는 점이었어요.오래된 코드라 클라이언트 측 추적이 마련되어 있지 않았어요. 즉시 리디렉션되는 웹 환경이라 추적을 삽입하기 적절치 않고, 모바일 앱에 일시적으로 추적 코드를 추가하기에도 상황이 여의치 않았어요.결국 실행 가능한 방식으로 문제를 해결하기 위해 프로젝트에 더 도전적인 목표를 추가했어요.기존 로컬웹뷰 진입경로와 100% 호환되는 방식으로 동작한다.변경하지 않고도 리모트 웹뷰와 동등한 효과를 보장한다.망가뜨리지 않고 “업그레이드”하기앞서 설명했던 로컬 웹뷰 — 리모트 웹뷰 이 중 배포에서 사용자가 리모트 웹뷰까지 도달하는 길고 비효율적인 여정을 도식화하면 다음과 같아요.복잡한 기존 동작과 100% 호환되면서도 모든 목표를 달성하려면 전략적인 접근이 필요했어요.Q. “프론트엔드 개발자들이 더 이상 로컬 웹뷰 서빙을 하지 않으려면?”“로컬 웹뷰 서빙” 에는 리디렉션 룰, 라우팅, 추적 등을 위한 클라이언트 측 코드, 배포하기 위한 CI 워크플로우 코드, 관리하기 위한 각 서비스 팀의 개발 정책 등이 암묵적으로 포함되어 있어요.이 것들을 모두 취합하여 한 곳에서 정책화된 코드로 제공하면 문제를 해소할 수 있을 거라 생각했어요. 하지만 동시에 다음 질문에도 답해야 해요.Q. “모바일 앱 코드에서 로컬 웹뷰를 처리하는 로직을 완전히 제거하려면?”로컬웹뷰는 프론트엔드뿐 아니라 모바일 앱 개발자 분들에게도 부담인 부분으로, 단순히 관련 정책을 앱 코드로 이전하는 것으로는 궁극적으로 문제가 해결되지 않아요. 호환을 위해 코드가 들어간다고 하더라도 기존처럼 복잡한 것이 아니라 누가 언제 봐도 이해할 수 있는 수준으로 단순한 것이어야 해요.따라서 도식화된 “스킴 핸들러” 부분의 동작을 이식하면서 프론트엔드의 정책을 다루기 위해 서버 측으로 이전하기로 했어요.Q. “로컬 웹뷰와 관련된 오래된 AWS 리소스를 완전히 정리하려면?”그리고 이건 비교적 쉽게 해결할 수 있어요. AWS를 안쓰면 됩니다(??)하위 호환성을 완전히 보장하려면 스킴 핸들러의 동작을 일부 에뮬레이션 해야 하는데, AWS의 CloudFront Functions나 Lambda@Edge와 같은 서비스로는 다소 처리가 까다롭고 구성이 복잡해질 수 있어요. 기존 리소스를 정리하기는 커녕 더 많은 리소스를 추가하게 되겠죠.이전 글에서도 소개했던 Cloudflare Workers 를 사용하면 단일 위치, 단일 코드에서 ZIP 파일을 다루는 것과 같이 훨씬 더 복잡한 작업을 효과적으로 처리할 수 있어요. 기존의 CloudFront 동작을 Cloudflare Workers에서 처리하는 것으로 다음과 같이 정리할 수 있어요.중간의 CloudFront Distribution과 S3 Bucket, Athena 리소스를 모두 제거하고, 단일 Worker Script 코드로 정책을 관리하게 변경했어요. 동적인 생성 동작으로 변경되었지만, 템플릿 렌더링부터 ZIP 아카이빙까지 버퍼링 없는 단일 스트림으로 처리하여 기존 방식에 비교해도 추가적인 지연이 거의 없어요.(구체적으로는 HTMLRewriter라는 내장 유틸리티와 client-zip이라는 효율적인 ZIP 스트림 라이브러리를 사용했어요.)// 코드 형태의 단일 매니페스트로 모든 이전 로컬웹뷰 배포들의 정책을 통합export const manifest = { 'service-a': { id: 'service-a', title: '서비스 A', updatedAt: '20240920', // 모든 로컬 웹뷰 배포는 서버가 없기 때문에 HashRouter 방식을 사용했음 // 일부 SPA들은 내부 라우팅 방식을 변경하지 않고 그대로 이전 router: 'hash', remote: 'https://service-a', }, 'service-b': { id: 'service-b', title: '서비스 B', updatedAt: '20240621', // 리모트 웹뷰로 옮겨간 서비스들은 대부분 라우팅 방식을 변경했음 router: 'path', remote: 'https://service-b', },...이것만으로도 프론트엔드와 인프라 측의 관리 부담이 크게 개선되지만 모바일 코드도 함께 제거하기 위해 추가 작업이 필요해요.로컬 웹뷰 딥링크에는 서비스를 구분하기 위해 app 파라미터(deprecated)가 전달되는데, 이것을 Cloudflare Workers 기반 스킴 핸들러에 그대로 위임(Delegate)하는 것으로 동작을 변경했어요.워커가 HTTP 컨텍스트에 전달된 유저 에이전트 정보와 app 파라미터만 보고 나머지 동작을 결정할 수 있기 때문에 결과적으로 앱 코드에는 예전 파라미터를 위임하는 몇 줄의 코드만 남을 수 있어요.신규 버전의 앱 코드에서 로컬 웹뷰를 열면 이렇게 동작해요.Scheme Handler 워커는 직접 콘텐츠를 제공할 수 있는 일반 HTTP 기반 웹뷰이면서 동시에 여러 웹뷰들의 라우팅 정책을 취합하고 있는 진실의 원천이에요.더 이상 ZIP을 다운로드하고 압축을 해제하고 웹뷰를 띄우고 JavaScript 를 실행하는 긴 과정을 기다릴 필요가 없이 직접 원하는 웹뷰를 연 것과 동등하게 동작하여 사용성에도 큰 개선이 있어요.추가로 HTTP 기반이 아니어서 어려웠던 서버 기반 사용패턴 분석이 용이해졌어요. 진입점을 이전하기 위해 서비스 별 요청량을 실시간으로 모니터링하면서 숨은 진입점을 찾아 추가로 마이그레이션 할 수 있었어요. Workers Analytics Engine을 사용하면 워커에서 손쉽게 데이터포인트를 남기고 저렴한 비용으로 쿼리(via ClickHouse SQL)할 수 있어요.점차 감소하는 서비스 별 로컬 웹뷰 처리량이 중 실제 다운로드 요청이 의미상 0에 근접하게 된다면 추가적인 검증을 거쳐 “완전한 제거”까지 노려볼 수 있을 거예요.누구나 그럴싸한 계획은 있다…결과만 적어놓아 쉬운 여정으로 보일 수 있지만 꽤 우여곡절이 있었어요.예전 CloudFront Distribution에서 받던 meta.json 자체를 리디렉션하려고 보니 안드로이드 에이전트에 숨어있던 버그로 HTTP 리디렉션 응답을 받으면 크래시가 나서 버그를 먼저 수정해야 했어요.리디렉션 대신 DNS 수준에서 대상지를 변경하는 것으로 접근방식을 바꿨지만 인증서 피닝 업데이트가 누락되어 요청이 차단될 뻔하기도 했어요.AWS 리소스를 정리하고 보니 같은 버킷을 통해 배포되던 타 조직의 어드민 서비스 접근이 막혀 급하게 복구하는 상황도 있었어요. 최근 생성된 리소스는 모두 내부 플랫폼에 의해 관리되고 추적되지만 그전에 통합하여 사용하던 공통 버킷들은 아직도 암묵지에 가깝게 남아있어 어떤 변경이 어떻게 문제가 될지 (트래픽을 봤음에도) 미리 알기 어려웠어요.그러게요. 그게 왜 저기… (먼산)채팅 상단을 그리던 웹뷰는 완전히 커스텀한 로컬웹뷰 배포를 사용하고 있었어요. 구성을 동적으로 변경할 수 있었기 때문에 사전에 이전을 진행했지만 클라이언트에 주소지에 대한 캐시가 남아있어 이전 채팅방을 들어갈 때 오류 메시지가 나타나기도 했어요.캐시된 주소에 있던 리소스 제거로 404 응답이 노출됨(옛날에 열었던 채팅방에서만 나타나고 다시 들어가면 사라지기 때문에 CS 처리가 까다로웠어요.)100% 동작 호환을 목표로 했지만, 검증하고 적용하는 과정에서 여전히 구멍이 발견되고 사후에 수정해야 했어요. 처음 작업을 제안한 2024년 1월 이후 거의 1년 동안의 크고 작은 협력과 사후 이슈 대응을 거쳐, 마침내 당근 모바일 앱 스펙에서 “로컬 웹뷰”가 완전히 제거될 수 있었어요.개발자들은 오래된 맥락에서 자유로워졌고, 사용자는 로컬 웹뷰 리소스를 미리 패치하기 위해 발생하던 낭비되던 대역폭을 아낄 수 있게 되었어요.왜 도입됐고 왜 폐기되었나사실 로컬 웹뷰가 도입되던 그 시점에서 당근에서 배포된 최초의 웹뷰가 이미 비슷한 방식으로 구현되어 있었어요.웹뷰를 채택하는 서비스가 늘어나면서 이들에게 공통된 플랫폼을 제공하고 싶었고, 이전의 구현을 그대로 참고한 것이 시작이었어요. 오프라인 파일을 직접 동기화하면서, 알려져 있던 웹의 단점을 보완하고 오프라인 지원과 캐싱을 개선할 수 있지 않을까 하는 나름의 의도가 있었어요.하지만 거의 업데이트가 없던 기존에 웹뷰 화면과는 다르게 새로 추가되는 웹뷰 기반 서비스들은 매우 빈번한 배포 주기를 가지고 있었고, 결과적으로 로컬 웹뷰는 의도한 목표를 달성하지 못하면서 동시에 웹이 가지는 여러 장점을 희석시켰어요.이건 일종의 “성급한 최적화(Premature optimization)” 사례라고도 볼 수 있어요.We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%— Donald Knuth하지만 누구든지 모든 일에 대해 결과를 100% 알고 시작할 수는 없어요. 인용의 “3%의 기회”를 위해 시도하는 것들로 하여금 때로는 기술을 혁신하고 때로는 교훈을 통해 더 나은 시도의 밑거름 삼을 수 있어요.레거시가 주는 교훈그 이후 관련자가 모여 간단히 로컬 웹뷰 시스템에 대한 회고를 진행했어요. 로컬 웹뷰가 도입되고 폐기되는 여정에서 우리는 무엇을 배웠나요?최적화 의도는 도입하기에 앞서 사례 기반의 시뮬레이션과 측정, 그리고 최적화로 인해 포기하게 되는 것들에 대한 정량적인 평가가 수행되었으면 더 좋은 설계를 할 수 있었을 거예요.이미 앞서서 사용되고 있던 기술이라고 항상 타당하다고 전제하는 것 보단 바뀐 주변 환경과 요구되는 조건에 맞춰 새로운 평가 기준을 마련해야 해요.나중에 환경이 바뀌더라도 유연하게 대응할 수 있도록 충분한 “적응력”을 갖추도록 전체 시스템을 설계해야 해요.특히 캐시를 도입한다면 항상 제거(Purge)를 위한 장치를 함께 마련해야 해요. 그렇지 않으면 캐시가 효과적이더라도 이후 시스템 적응력을 크게 해칠 수 있어요.하위호환성을 제공하는 것은 종종 단순해 보여도 매우 어려운 일이 될 수 있어요.하위호환성을 고려하지 않고 기능을 삭제했다면 수많은 문제가 생겼을 거예요. 이런 문제들은 사용자 경험과 서비스 신뢰도에 악영향을 주고 서비스 개발자가 플랫폼에 의존하기 어렵게 만들어요. 그렇기 때문에 플랫폼은 상황이 어렵더라도 호환성을 쉽게 타협하면 안 돼요.오랜 시간이 지나면 맥락은 희미해져요. 사람의 기억에 의존할 수 없고, 문서조차 완전히 신뢰할 수 없으며, 결국 실행 중인 코드만이 유일하게 진실을 얘기할 때가 있어요.영향도 분석을 위해 코드를 읽는 것도 항상 유효한 수단이 아니에요. 손으로 배포되는 임시(라고 생각했던) 코드는 CI 워크플로우에 포함되지 않고, 독립적인 브랜치로 관리되는 경우 GitHub 코드 검색에 포함되지 않아요.이론적으로 보장되는 것이어도 실제 환경과의 미묘한 차이로 인지하지 못한 문제가 발생할 수 있어요. 잘못되었을 때 영향도가 크거나 돌이킬 수 없는 변경은 적용하기 전에 관련된 추적 코드를 먼저 배포하여 실제 영향도를 추가로 검증해야 해요.이런 교훈들은 “하이럼의 법칙”으로 유명한 Hyrum Wright의 “Deprecation” 에세이에서 읽을 수 있는 결론과도 일맥상통하지만, 책에서만 보던 교훈이 아니라 우리 사례에 맞게 직접 경험했기에 더 값지게 남았어요.점진적 개선을 향한 길당근 웹뷰는 이런 “폐기 작업”을 통해 무거운 짐을 떨쳐내고, 이전 과정을 교훈 삼아 다시 새로운 실험을 할 준비를 하고 있어요.나날이 발전 중인 브라우저 기술과 플랫폼 별 WebView API를 사용하여 또 다른 “3%의 가능성”을 탐구할 거예요. 그 과정에서 도입한 새로운 기술은 당근 웹 개발의 혁신이 될 수도 있지만, 어쩌면 오랜 시간이 지난 후 또 다른 “레거시 시스템”이 될지도 몰라요.플랫폼에서 중간마다 폐기 작업이 수반되지 않으면 결국 복잡한 맥락을 감당하지 못하고 경직되어 버릴 수밖에 없어요.저는 우리가 이런 과정을 통해 성장하여 점점 더 나은 판단과 설계를 하고, 두려움 없이 변화하는 조직이 되기를 희망해요.Deprecating in an organized and well-managed fashion is often overlooked as a source of benefit to an organization, but is essential for its long-term sustainability.— Deprecation by Hyrum Wright, “Software Engineering at Google”당근 웹 플랫폼 외전 — 레거시 시스템 안전하게 제거하기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

RAG를 활용한 검색 서비스 만들기
당근마켓
RAG를 활용한 검색 서비스 만들기

안녕하세요! 당근 검색품질팀 ML 엔지니어 해리예요. 👋이 글에서는 RAG를 활용한 검색 서비스인 “동네생활 기반 동네업체 추천” 기능 구현에 사용된 기술에 대해 이야기해보려고 해요.혹시 활동하고 있는 커뮤니티가 있으신가요?커뮤니티는 고향을 떠나 먼 곳으로 대학을 진학한 제게 절대 없어서는 안 될 정보의 창구였어요. 대학 새내기 시절을 돌아보면, 낯선 학교 근처의 맛집이나 듣기 좋은 꿀강의들을 찾기 위해 교내 커뮤니티 게시판을 열심히 뒤지던 기억이 새록새록 떠올라요. 대학원 진학을 준비할 때는 대학원생 커뮤니티에서 각 랩실의 정보를 얻기도 했죠.이처럼 커뮤니티는 우리 생활과 밀접한, 신뢰도 높은 정보들로 가득해요. 하지만 이런 정보들이 여러 게시글에 흩어져 있다 보니, 사용자는 정보를 모으기 위해 다양한 키워드로 검색하고 많은 게시글을 일일이 확인해야 하는 불편함이 있어요.이러한 정보 검색의 불편함은 당근의 동네 커뮤니티인 동네생활에서도 마찬가지였어요. 동네생활에는 “과잉 진료 없는 치과”, “탈색 잘하는 미용실”, “마카롱이 맛있는 카페” 등 동네 이웃들만이 알 수 있는 신뢰도 높은 업체 정보들이 가득하죠. 하지만 이런 정보들이 여러 게시글과 댓글에 흩어져 있어서, 사용자는 1) 적절한 검색어를 입력하고, 2) 게시글과 댓글 내용을 모두 확인하고, 3) 얻은 정보를 취합하는 과정을 거쳐야 해요.이 글에서는 이런 불편을 해결하고자 RAG를 활용해 “동네생활 기반 업체 추천” 서비스를 만들었던 과정을 자세히 소개해보고자 해요.당근 동네생활의 검색은 어떻게 개선될 수 있을까요?이 질문에 답하기 위해 먼저 유저들의 동네생활 검색 패턴을 분석했어요. 분석 결과, 유저들은 동네생활에서 주변 업체 정보를 활발하게 찾아보고 있었어요. 특히 “용달”, “24시 동물병원”과 같은 동네 업체 관련 검색어가 실제 상위 검색어들 중 큰 비중을 차지했죠.하지만 기존 검색 시스템으로는 이런 정보를 효율적으로 찾기가 어려웠어요. 기존에는 당근에 업체 관련 검색어를 입력하면, 그와 관련된 당근 등록 업체들을 최상단에 보여줬어요. 예를 들어, “치과”라는 검색어가 입력되면 사용자의 동네에 등록된 치과들을 검색 결과로 보여준 거죠.물론 “치과”라는 검색어에 치과를 보여주는 것이 틀린 검색 결과라고 볼 수는 없지만, 이 방식으로는 유저의 니즈를 제대로 충족하기 어려워요. 유저들은 “우리 동네 주민들이 추천한 알짜배기 업체”들을 발견하기를 원하는데, 현재 검색은 단순히 “관련 업체 프로필”들을 최상위로 노출하는 데 그치고 있거든요. “유저가 정말 원하는 동네업체 정보는 동네생활에 있다”는 점을 고려하면, 현재 검색에는 동네생활과 동네업체 간의 정보들을 서로 연결해 주는 도구가 없는 셈이에요.이런 문제를 해결하기 위해 우리는 동네생활 검색 시스템을 개선하기로 했어요. 유저들이 원하는 업체 관련 정보를 동네생활에서 더 쉽고 빠르게 찾을 수 있도록 돕는 것이 핵심이었죠. 그래서 RAG(Retrieval Augmented Generation) 기술을 활용해 동네생활의 정보와 업체 간의 연결을 가능하게 하는 검색 도구를 만들었어요.RAG란 무엇인가요?RAG는 Retrieval Augmented Generation의 약자로, 기존 데이터베이스에서 관련 정보를 검색(Retrieval)하고, 이를 활용(Augmented)하여 새로운 텍스트를 생성(Generation)하는 기술이에요. 예를 들어, 유저가 “강아지 미용” 관련 검색어를 입력하면, RAG는 동네생활에서 강아지 미용과 관련된 게시글과 댓글들을 찾아내요. 게다가 이 정보들을 종합하여 유저에게 도움이 되는 새로운 요약 정보를 만들어내죠.RAG의 장점은 신뢰할 수 있는 기존 데이터를 기반으로 정보를 생성한다는 점이에요. 단순히 AI가 학습한 일반적인 정보가 아닌, 실제 동네 주민들이 공유한 경험과 추천을 바탕으로 정보를 제공하는 거예요. 따라서 더욱 신뢰성 있고 실용적인 정보를 전달할 수 있어요.동네생활 기반의 업체 추천 검색은 어떻게 이뤄지나요?RAG를 활용한 동네생활 기반의 업체 추천 검색은 크게 세 단계로 진행돼요.첫째, 사용자가 검색어를 입력하면 그와 관련된 업체 정보가 담긴 동네생활 게시글을 검색해요. 둘째, 검색된 게시글에서 추천 업체들을 찾아내고 각 업체에 대한 요약문을 만들어요. 셋째, 추출된 업체들이 실제로 추천하기에 적절한지 검토하고 필터링하는 과정을 거쳐요. 이제 각 단계에서 어떤 일이 일어나는지 자세히 살펴볼게요.Retrieval: 동네생활 게시글 검색하기첫 번째 단계인 Retrieval에서는 사용자의 검색어와 관련된 동네생활 게시글들을 찾아내요. 가장 기본적인 방법으로 키워드 기반 검색을 고려할 수 있지만, 이 방식은 의미상 비슷한 게시글을 찾기 어렵다는 명확한 한계가 있어요. 예를 들어, “혼밥”이라는 검색어가 입력되면 “혼자 가기 좋은 식당”, “1인분 메뉴가 있는 곳” 등 혼자 식사하기 좋은 장소와 관련된 게시글들을 모두 찾아낼 수 있어야 해요. 하지만 단순히 “혼밥”이라는 단어가 포함된 게시글만 검색한다면, “여기 혼자 가기 좋네요”와 같은 관련 게시글은 발견되지 못할 거예요. 이런 키워드 기반 검색의 한계를 해결하기 위해 우리는 벡터 검색을 도입했어요.벡터 검색하기벡터 검색은 ElasticSearch(ES)를 활용했어요. 이는 검색실에서 이미 ES를 사용하고 있었기 때문에, 불필요한 추가 인프라 구축을 피하기 위한 선택이었어요.벡터 검색을 위해서는 먼저 동네생활의 게시글과 댓글, 그리고 게시글 임베딩 벡터를 ES 클러스터에 색인해야 해요. 동네생활 게시글을 OpenAI의 임베딩 API로 벡터로 변환하고, 이를 게시글 및 댓글과 함께 Elasticsearch(ES)에 저장하죠. 검색어가 들어오면 이 검색어도 같은 방식으로 임베딩 벡터로 변환해요. 그런 다음 이 벡터로 ES에 쿼리를 보내면, ES는 저장된 게시글 벡터들과의 코사인 유사도를 계산해 각 동네생활 게시글이 검색어와 얼마나 의미적으로 비슷한지를 측정하고, ANN(Approximated Nearest Neighbor)으로 가장 연관성 높은 게시글들을 빠르게 찾아내요.2. 키워드 매칭 점수를 활용하여 정렬하기하지만 벡터 검색은 코사인 유사도를 기반으로 게시글을 찾다 보니 때로는 관련 없는 문서들이 검색되는 한계가 있었어요. 예를 들어 검색어와 유사한 상위 10개 게시글을 찾으려 할 때 실제로 관련된 게시글이 5개뿐이라면, 나머지 5개는 어쩔 수 없이 관련 없는 문서들로 채워지게 돼요. 머신러닝의 정설인 “Garbage-in, Garbage-out”처럼, 검색어와 관련 없는 문서들이 포함되면서 부정확한 추천 업체들이 무분별하게 등장하는 문제가 생겼어요. 이 문제를 해결하기 위해 벡터 검색된 문서들에 키워드 기반 매칭인 BM25 스코어를 적용하고, 높은 점수를 받은 게시글들을 아래 그림처럼 상단과 하단을 번갈아가며 순차적으로 배치했어요.이는 LLM이 프롬프트의 시작과 끝 부분에 더 집중하는 특성을 활용한 방법인데요. 다음 섹션에서 LLM 프롬프트를 어떻게 구성했는지 구체적으로 살펴볼게요.Augmented Generation: 검색된 게시글 속 업체 정보를 LLM으로 요약하기두 번째 단계인 Augmented Generation에서는 검색된 동네생활 게시글들을 LLM이 이해하기 좋게 가공해서 프롬프트를 만들어요. 그리고 이 프롬프트를 바탕으로 LLM이 추천 업체를 추출하고 각 업체에 대한 요약 정보를 생성하죠.1. 검색된 게시글 가공 및 프롬프트 튜닝하기생성 모델로는 OpenAI의 GPT-4o-mini를 사용했어요. 다양한 프롬프트를 시도해 본 결과, 1) 명확한 단계 구분과 2) 구체적인 예시 포함이 더 좋은 품질의 LLM 생성 결과물을 만든다는 걸 발견했어요. 아래는 최종적으로 사용한 프롬프트 전문이에요.[1. Requirement]제시된 동네생활 게시글 질의응답으로부터, 업체명(name)을 추출하고 업체 설명(summary)을 작성해 줘. 꼭 동네생활 게시글과 댓글을 이용하여 작성해 줘.[2. Tone]동네에 대해 궁금한 걸 물어보면 알려주는 동네 친구처럼 행동해 줘. summary를 부드러운 '해요체'로 작성해 줘. [3. Input]유저 질문: {검색어}동네생활 게시글에서 추출된 질의응답:{LLM이 이해하기 좋은 형태로 가공된 동네생활 게시글들}[4. Output format]- 예시를 참고해서 json으로 뽑아줘. 업체명의 key는 "name", 업체 설명의 key는 "summary"이다. summary는 공백을 포함해 40자가 넘지 않게 작성해 줘.- 만약 'poi_name' 중에 적합한 업체명이 존재하면, 그것을 "name"의 value로 그대로 출력해 줘. None이라면 article과 comment를 사용해서 "name"의 value를 출력해 줘.- e.g., {{"name" : 잎사귀치과, "summary" : 과잉 진료 없이 사랑니를 잘 뽑아주는 치과예요.}}[5. Caution]- 동네생활 게시글과 댓글만을 활용하여 작성해 줘.- 유저 질문에 적합한 내용만 추출해 줘.또, LLM은 3) JSON 형식과 같이 구조화된 게시글을 넣어줬을 때 가장 많은 정보를 효율적으로 이용한다는 사실을 발견했어요. 검색된 게시글은 아래와 같은 형태로 위의 프롬프트에 들어가게 돼요.{ "게시글": { "article": 동네생활 게시글 제목 및 내용, "poi_name": 동네생활 게시글에 등장한 업체 이름 }, "댓글":[ { "content": 댓글 내용, "poi_name": 댓글에 등장한 업체 이름 }, { "content": 댓글 내용, "poi_name": 댓글에 등장한 업체 이름 }, ... ]}2. LLM 응답 안전하게 파싱하기생성된 업체 추천 결과는 “업체명”들과 각 업체의 “정보 요약문”으로 구성돼요. 이렇듯 여러 필드를 가진 정보를 생성할 때 OpenAI의 Structured Output API를 활용하면 JSON과 같은 구조화된 형식으로 결과를 받아볼 수 있어요. 따라서 좀 더 일관된 형태의 생성 결과물을 받아 안전하게 파싱할 수 있죠.from pydantic import BaseModelfrom openai import OpenAIclient = OpenAI()class POI(BaseModel): name: str summary: strclass RecommendedPOI(BaseModel): POI: list[POI] completion = client.beta.chat.completions.parse( model = "gpt-4o-mini", messages = [ {"role": "user", "content": prompt} ], response_format = RecommendedPOI,)Filtering: 부적절한 추천 업체 걸러내기마지막으로, Filtering 단계에서는 LLM이 추천한 업체와 요약 정보의 적절성을 검토해요. RAG를 사용하더라도 생성형 AI가 부정확하거나 부적절한 내용을 만들 수 있기 때문이에요. 이 필터링 과정까지 사람 손을 타지 않도록 완전 자동화하기 위해, 다시 한번 LLM을 활용해 추천 업체의 적절성을 판단했어요. 이 판단 결과로 부적절한 업체 추천들을 걸러내고 신뢰할 수 있는 정보만을 유저에게 제공하죠. 적절성 검토는 다음 세 가지 기준으로 이뤄졌어요.관련성 검증관련성 검증에서는 검색어와 추천된 업체가 실제로 연관이 있는지를 확인해요. 이런 관련성 검증이 중요한 이유는 Retrieval 단계에서 검색된 문서들이 검색어와 관련이 있더라도, LLM이 생성한 업체 추천이 검색 의도와 맞지 않을 수 있기 때문이에요. 예를 들어 “독일어”를 검색했을 때 검색된 문서에 독일 음식점이나 맥주집에 대한 내용이 포함되어 있다면, LLM은 이를 바탕으로 부적절한 추천을 할 수 있어요. 아래는 관련성 검증을 위한 프롬프트의 일부분이에요.[1. 'is_relative' 판단하기]검색어/업체명/업체 설명을 보고, 검색어와 업체가 관련성이 있는지 True/False로 판단해 줘.- True : 검색어와 업체가 관련성이 높다. 검색어에 대해 해당 업체가 노출되는 것이 적합하다.- False : 검색어와 업체의 관련성이 낮다. 검색어에 대해 해당 업체가 노출되는 것이 부적절하다.- {{"검색어":"독일어", "업체명":"신나라", "업체 설명":"주말에 시원한 맥주를 판매하는 곳이에요", "is_relative":False}}2. 추천 업체 일치 여부 검증LLM이 생성한 업체명이 실제 당근에 등록된 업체 상호명과 일치하는지 확인해요. 업체명의 문자 단위 일치 여부를 확인할 수도 있지만, “스타벅스”와 “스타벅스 XX점”, “스타벅스(XX점)”처럼 표기 방법이 조금만 달라도 다른 업체로 판별된다는 문제가 있었어요. 그래서 LLM을 활용해 업체명의 동일성을 판단하기로 했죠. LLM은 사람처럼 문맥을 이해하고 표현의 유사성을 파악할 수 있어서 “스타벅스”와 “스타벅스 XX점”이 같은 업체를 가리킨다는 것을 이해할 수 있거든요. 이를 통해 추천된 업체가 당근에 등록된 업체인지 검증할 수 있어요.[2. 'is_matched_with_poi' 판단하기]- '추천 업체명'/'등록 업체명'를 보고, 서로 동일한 장소인지 True/False로 판단해 줘.- True : 추천 업체명과 등록 업체명이 서로 같은 대상을 나타낸다고 유추할 수 있다. - False : 추천 업체명과 등록 업체명이 서로 다른 대상을 나타낸다. 둘의 연관성이 낮아 보인다.3. 부정적 내용 검증마지막으로 업체에 대한 설명이 부정적이거나 불쾌감을 줄 수 있는 내용을 포함하고 있는지 확인해요. 예를 들어, 업체 설명이 “가격이 너무 비싸고 맛도 없어요”와 같이 부정적인 내용을 담고 있다면 True로 판단하고 필터링해요. 반면 “가성비가 좋고 맛있는 식당이에요”처럼 긍정적인 내용이라면 False로 판단하죠. 이런 검증을 통해 업체에 대한 부정적인 리뷰나 불만 사항이 추천 결과에 포함되지 않도록 관리할 수 있어요.[3. 'is_negative' 판단하기]업체명/업체 설명을 보고, 업체 설명에 부정적인 내용이 있는지 True/False로 판단해 줘.- True : 업체에 대한 부정적인 내용 또는 불쾌한 감정을 포함하고 있다.- False : 업체에 대한 부정적인 내용을 찾을 수 없다.이렇게 만들어진 “동네생활 기반 동네업체 추천”위의 과정을 통해 동네 주민들이 추천한 업체들을 캐로셀 형태로 제공할 수 있게 되었어요. 캐로셀에는 동네생활에서 추천된 업체와 각 업체에 대한 추천 요약문이 표시되며, 탭 한 번으로 바로 업체 프로필로 연결돼요. 덕분에 동네 이웃들이 직접 경험하고 추천한, 신뢰도 높은 업체를 더욱 빠르고 효과적으로 찾을 수 있게 됐어요. 동네생활 게시글과 댓글을 일일이 확인한 후, 발견한 업체를 다시 검색해야 했던 번거로움은 이제 사라지게 된 거예요.마치며사실 이 서비스는 당근의 첫 GenAI 해커톤에서 시작된 프로젝트예요. 현재는 관악구에서만 서비스되고 있지만, 점진적으로 전국 확장을 예정하고 있어서 곧 어디서든 이 기능을 만나보실 수 있을 거예요!해커톤부터 기능 배포까지의 여정에서 팀원들과 함께 새로운 기술을 탐구하고, 이를 실제 서비스로 구현하며 값진 경험을 쌓을 수 있었어요. 특히 RAG 시스템을 실제 서비스에 적용하면서 마주친 여러 도전 과제들을 해결해 나가는 과정이 정말 보람찼고, 이를 통해 중요한 인사이트도 얻을 수 있었죠.첫 번째로, RAG 시스템에서도 정보 검색(Retrieval)의 품질이 핵심이라는 점을 다시 한번 깨달았어요. ES 벡터 검색으로 폭넓은 관련 문서를 찾고, 그중에서도 특히 관련도가 높은 문서를 찾아 적절히 배치하는 것이 전체 시스템의 성능을 좌우했죠. 아무리 좋은 LLM이라도 검색된 문서의 품질이 낮거나 정보가 올바르게 제공되지 않으면 좋은 결과를 기대하기 어려웠어요.두 번째로, 생성형 AI를 활용한 서비스에서는 특히 사람의 세심한 서비스 품질 관리가 중요하다는 점을 깨달았어요. 별도의 LLM을 활용해 검색어와 업체의 관련성, 추천 업체 일치 여부, 부정적 내용 포함 여부 등을 검증했지만, 일관되게 저품질 업체를 추천하는 검색어는 수동으로 제외하는 과정이 필요했어요. LLM 필터링을 활용한 서비스 완전 자동화를 꿈꿨지만, 여전히 사람의 판단이 필요한 부분이 있었던 거죠.이처럼 RAG를 활용한 검색 서비스 개발 과정에서 얻은 인사이트를 바탕으로, 검색실은 앞으로도 더 나은 검색 경험을 제공하기 위해 새로운 기술을 탐구하고 도전해 나갈 거예요. 이런 흥미진진한 여정에 함께하고 싶으시다면 🔗검색실의 문을 두드려주세요! 우리는 항상 새로운 동료를 기다리고 있답니다. :)RAG를 활용한 검색 서비스 만들기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

모두를 위한 알림 경험 만들기
당근마켓
모두를 위한 알림 경험 만들기

안녕하세요! 당근 알림 경험팀에서 백엔드 엔지니어로 일하고 있는 데이(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.

GraphQL을 이용한 QueryFacade 개발기
당근마켓
GraphQL을 이용한 QueryFacade 개발기

안녕하세요. 저는 로컬 비즈니스실에서 서버 개발자로 일하고 있는 에렌(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.

당근 홈 피드, Server Driven UI로 실험 이터레이션 빠르게 돌리기
당근마켓
당근 홈 피드, Server Driven UI로 실험 이터레이션 빠르게 돌리기

안녕하세요! 당근 피드실 피드인프라팀 카터예요.홈 피드 화면피드실은 당근의 첫 화면을 통해 사용자들과 다양한 서비스를 연결해요. 중고거래, 동네생활 모임, 알바, 부동산 등 당근의 여러 서비스가 만드는 콘텐츠들을 사용자에게 재미있게 전할 수 있도록 홈 피드를 구성하고 있죠.피드 아이템피드 화면에서 볼 수 있는 하나하나의 콘텐츠를 의미하는 피드 아이템은 크게 두 부분으로 이루어져 있어요. 첫 번째로 피드 엔티티는 앱에서 보이는 실제 콘텐츠(중고거래 게시글이나 당근알바 게시글)를 말하고, 뷰타입은 이 콘텐츠를 어떤 모양으로 보여줄지 정하는 방식이에요. 뷰타입은 피드에서 피드 엔티티를 시각적으로 표현하기 위해 정의된 개념이고, UI 디자인과 스키마를 속성으로 가진다는 특징이 있어요.피드인프라팀에서는 Server Driven UI를 통해 새로운 피드 아이템 구성을 앱 업데이트 없이도 홈 피드에 빠르게 적용할 수 있도록 시스템을 구축했어요. 이번 글에서는 이 과정에서 어떤 기술적 고민들을 하고 어떻게 해결했는지 소개해 드리려고 해요.피드 아이템에는 어떤 문제가 있었나요?뷰타입 재사용의 어려움당근 초기에는 중고거래 게시글을 기반으로 다양한 형태의 뷰타입을 만들었어요. 이후 부동산, 중고차, 당근알바와 같은 신규 서비스들이 성장하면서, 중고거래 게시글과 비슷한 형태지만 기능과 노출 방식이 다른 뷰타입을 계속 추가했어요. 예를 들어 중고거래 게시글은 중고거래 피드 엔티티를 활용해서 중고거래 뷰타입을 유저에게 노출하고, 당근알바 게시글은 당근알바 피드엔티티를 활용해서 당근알바 뷰타입을 그리는 형식이었어요.하지만 뷰타입은 클라이언트 배포에 포함되기 때문에 앱 업데이트가 필요해요. 모든 사용자가 앱을 즉시 업데이트하진 않기 때문에, 서버에서는 하위 호환성을 위해 버전 분기 처리를 해야 했죠.실험 유연성과 속도 저하피드의 성장을 위해서는 다양한 형태와 조합으로 UI를 실험해야 했지만, 각 뷰타입별로 노출 가능한 정보들의 제약이 많아 실험 진행에 상당한 어려움이 있었어요. 이러한 제약은 실험의 유연성과 속도를 크게 저해했죠. 예를 들어 부동산 게시글의 거래상태(예약 중, 거래완료)를 표현하려고 했던 실험을 살펴볼까요?부동산 게시글의 거래상태를 표시하기 위해 새로운 컴포넌트가 필요했는데, 아이러니하게도 이미 중고거래 뷰타입에 동일한 기능이 구현되어 있었어요. 하지만 뷰타입 간에 컴포넌트를 재사용할 수 없어서, 부동산 게시글 뷰타입에 같은 기능을 담아 앱 업데이트를 배포해야 했죠. 게다가 뷰타입 배포 후 충분한 사용자 수가 확보될 때까지 기다려야 실험을 시작할 수 있었어요. 이런 복잡한 과정 때문에 간단한 실험 하나를 진행하는 데에만 2주 이상이 걸렸답니다.합의된 명칭 부재로 인한 소통의 어려움불명확한 요소들의 명칭또한 뷰타입의 UI 요소들에 대한 표준화된 명칭이 없어서 팀원들 간 협업에 어려움이 있었어요. 예를 들어 같은 UI 요소를 두고 누군가는 ‘썸네일’이라고 부르고, 다른 사람은 ‘이미지’라고 불렀어요. 또 타이틀 아래 영역을 누군가는 ‘태그그룹’이라 부르고, 다른 사람은 위치상 ‘서브타이틀’이라고 불렀죠. 이런 용어의 불일치로 인해 소통 과정에서 불필요한 시간과 노력이 들었어요.어떻게 해결했을까?이러한 문제점을 해결하기 위해서 우리는 Server Driven UI를 도입하고, 이를 바탕으로 두 가지 핵심 컴포넌트인 ‘피드 아이템 카드’ 와 ‘피드 아이템 제네레이터’를 만들었어요. 시작하기 전에 Server Driven UI를 먼저 설명드릴게요.Server Driven User InterfaceServer Driven User Interface(SDUI)는 서버에서 UI의 구조와 동작을 정의하고 제어하는 방식이에요. 서버에서 UI 명세를 내려주면 클라이언트는 이 명세에 따라 화면을 그리죠. 이렇게 하면 새로운 UI를 실험할 때 클라이언트 앱을 매번 업데이트하지 않아도 된다는 장점이 있어요.예를 들어, 기존에는 홈 피드의 UI를 변경하려면 위에서 설명한 것처럼클라이언트 개발앱 배포사용자들의 앱 업데이트충분한 사용자 수 확보의 과정이 필요했지만, SDUI를 도입하면 서버에서 UI 명세만 변경하면 바로 실험을 시작할 수 있어요.SDUI를 효과적으로 활용하기 위해서는 적절한 수준의 구현이 중요했는데요. 서버에서 모든 구조를 정의할 수 있는 HTML처럼 세밀한 수준의 SDUI도 가능하지만, 우리 팀은 다음 세 가지 원칙에 따라 SDUI를 구현했어요.1. 검증된 레이아웃 기반의 유연성기존 서비스들(중고거래, 부동산, 중고차, 당근알바)의 검증된 레이아웃을 기반으로 유연성을 가져가요. 피드가 중고거래 게시글을 시작으로 성장했기 때문에, 검증된 UX를 해치지 않는 선에서 썸네일 크기, 텍스트 스타일, 섹션 배치 등을 서버에서 제어해요.2. 효율적인 스타일 관리서버에서는 UI 레이아웃과 디자인 시스템의 아이콘 토큰, 컬러 토큰과 같은 넓은 범위의 스타일만 정의하고, 클라이언트는 이에 따라 렌더링해요. margin, border-radius 같은 세부 스타일 값은 실험 영역에서 제외하여 복잡도를 낮췄어요.3. 표준화된 인터페이스클라이언트의 동작을 표준화된 인터페이스로 정의해요. 이는 기존 뷰타입의 문제점을 해결하기 위한 원칙이에요. 예를 들어 특정 도메인에 종속된 뷰타입은 게시글 ID로만 화면 전환이 가능해서 재사용하기 어려웠거든요. 이제는 뷰타입별로 흩어져 있던 클라이언트 동작 처리 방식을 하나로 모으고, 서버에서 UI 관련 동작을 일관되게 제어할 수 있게 됐어요.피드 아이템 카드이러한 SDUI 원칙을 바탕으로 저희 팀은 ‘피드 아이템 카드’를 만들었어요. 피드 아이템 카드는 저희 팀이 정의한 통합 뷰타입으로, 다양한 서비스의 콘텐츠를 일관된 방식으로 보여줘요. 지금부터 피드 아이템 카드가 어떤 구조로 이루어져 있고, 앞서 설명한 뷰타입 재사용과 소통의 문제를 어떻게 해결했는지 소개해드릴게요.Section? Component? Property?Feed Item Card의 구성요소피드 아이템 카드는 Section, Component, Property 세 개의 계층으로 이뤄져요. 각 계층이 어떤 역할을 하는지 하나씩 살펴볼게요.Section, Component, Property 예시가장 상위 계층인 Section은 피드 아이템 카드에서 가장 큰 영역을 차지하는 요소예요. 게시글의 대표 이미지를 보여주는 Thumbnail Section, 게시글의 제목과 내용 등 주요 정보를 보여주는 Info Section이 대표적이죠. 일관된 사용자 경험을 위해 Section의 순서나 위치는 변경할 수 없고, 일부 Section은 필수로 포함해야 해요.중간 계층인 Component는 Section이 의미를 가지도록 돕는 구성요소예요. Component는 독립적으로 동작하는 기본 단위이며, 각각의 Component는 고유한 Property들을 가지고 있어요. 예를 들어 Info Section 안에 있는 Tag Group Component는 텍스트나 배지 같은 Property들로 원하는 정보를 표시해요.Property는 Component의 특성을 결정하는 가장 기본적인 요소예요. Property는 혼자서는 의미를 가질 수 없고, 반드시 Component에 속해 있어야 해요. Tag Group Component를 예로 들면, 텍스트나 배지, 이미지 등의 Property들은 Tag Group이라는 맥락 안에서만 의미를 가져요.Property는 필수 Property와 선택 Property로 나뉘어요. 예를 들어, 게시글의 상태를 나타내는 Status Component에서 ‘예약중’이나 ‘거래완료’ 배지는 선택 Property예요. 반면 가격이나 게시글의 속성을 나타내는 텍스트는 필수 Property로, 항상 표시되어야 하죠.이러한 계층 구조 덕분에 피드 아이템 카드는 높은 유연성과 재사용성을 갖게 되었어요. 예를 들어 동일한 Tag Group Component를 중고거래와 부동산 게시글에서 각각 다른 Property 조합으로 활용할 수 있게 되었죠.또한 모든 구성 요소가 명확한 계층으로 구분되어 있어 디자이너와 개발자 간 소통도 한결 수월해졌어요. 각자의 역할에서 동일한 구조를 바라보며 작업할 수 있게 되었거든요.새로운 기능이나 디자인을 실험할 때도 큰 이점이 있어요. 기존 컴포넌트들을 새롭게 조합하거나 일부 속성만 수정해서, 실험 모수를 확보하지 않고도 빠르게 변화를 줄 수 있게 되었답니다. 처음부터 새로 만들 필요 없이 검증된 컴포넌트들을 활용할 수 있게 된 거예요.피드 액션!기존에는 클라이언트에서 피드의 이벤트를 처리하는 방식이 뷰타입별로 제각각이었어요. 예를 들어, 중고거래 게시글은 클라이언트가 게시글 ID를 파싱해서 상세화면으로 이동했지만, 당근알바 게시글은 정해진 URI로 이동하는 식이었죠.이런 파편화된 액션들을 하나로 모으기 위해 ‘피드 액션’이라는 통합 시스템을 만들었어요. 이를 통해 서버에서 다양한 액션을 일관되게 제어할 수 있어요.구체적인 예시를 들어볼게요. 홈 피드에서 게시글 숨기기 버튼을 누르면:서버에 숨긴 게시글을 저장하는 HTTP 요청을 보내고유저의 피드에서 피드 아이템을 숨기고아이템 숨기기 이벤트를 로깅하는 것을클라이언트에서 한 번에 처리할 수 있어요. 이런 피드 액션 시스템 덕분에 서버에서 피드 아이템 카드의 동작을 더 체계적이고 유연하게 제어하게 됐어요.피드 아이템을 그리기 위한 피드 아이템 제네레이터피드 아이템 카드의 구조를 만들었으니 각 서비스의 정보를 이 카드에 맞게 변환하는 프로젝트가 필요했어요. 이를 위해 ‘피드 아이템 제네레이터’를 만들었죠. 이름 그대로 피드 시스템의 데이터(피드 엔티티)를 클라이언트 화면(피드 아이템)으로 만들어주는 모듈이에요.기존에는 중고거래팀, 알바팀 등 각 서비스팀이 자신만의 방식대로 화면을 구성했어요. 그러다 보니 아래와 같은 문제점들이 발생했죠.비슷한 화면을 여러 팀에서 각각 만들어 리소스가 낭비됐어요.일관된 사용자 경험을 제공하기 어려웠어요.실험이나 신규 기능을 도입할 때 각 팀과 조율이 필요했어요.이제는 피드 인프라팀이 피드 아이템 생성을 전담하면서 여러 장점이 생겼어요.1. 서비스별, 국가별로 다른 UI를 효율적으로 관리할 수 있어요.예를 들어 중고거래에서는 유저와 매물의 거리를 보여주고, 중고차에서는 누적주행거리를 보여줘요.피드 엔티티 서비스에서 각 서비스의 데이터를 독립적으로 관리하기 때문에 이런 차별화가 가능해요.각 서비스의 특성을 살리면서도 일관된 사용자 경험을 제공할 수 있어요.2. 앱 버전에 따른 하위호환을 체계적으로 지원해요.새로운 컴포넌트가 추가되어도 앱 버전에 따라 적절한 컴포넌트를 보여줄 수 있어요.예를 들어 아이콘 형식이 바뀌면 구 버전 사용자에게는 이전 버전 컴포넌트를 보여주고 최신 버전 사용자에게는 새 컴포넌트를 보여줘요.이를 통해 사용자는 앱 업데이트 여부와 관계없이 안정적인 서비스를 이용할 수 있어요.3. 새로운 실험도 안전하고 효율적으로 할 수 있어요.섹션별로 독립적인 구조라 한 부분의 변경이 다른 부분에 영향을 주지 않아요.실험은 최신 버전에서만 진행해 실험 코드가 여러 곳에 흩어지는 것을 방지했어요.중앙화된 관리로 실험 결과를 빠르게 분석하고 적용할 수 있어요.다만 중앙화된 관리 방식에는 단점도 있어요. 각 서비스팀이 원하는 변경사항을 즉시 적용하기 어렵고, 피드 인프라팀의 작업량이 늘어날 수 있죠. 하지만 일관된 사용자 경험과 안정적인 서비스 제공이라는 이점이 더 크다고 판단했어요. 각 서비스팀과 긴밀하게 소통하며 우선순위를 조율하는 방식으로 이런 단점을 최소화하고 있답니다.이렇게 피드 아이템 제네레이터는 단순히 데이터를 변환하는 것을 넘어, 효율적이고 안정적인 서비스 제공의 핵심 역할을 하고 있어요.결과피드 아이템 카드와 피드 아이템 제네레이터 프로젝트를 실제 피드 프로덕트에 적용하면서 큰 변화가 있었어요. 기존에는 새로운 UI를 실험하려면 클라이언트 앱을 업데이트하고 배포하는 과정이 필요했지만, 이제는 서버에서 바로 새로운 UI를 정의하고 실험할 수 있게 되었어요. 실험 준비에 필요한 서버 개발도 1~2주면 충분해 실험까지의 시간이 크게 단축되었어요. 이를 통해 더 많은 아이디어를 빠르게 검증할 수 있는 환경을 만들 수 있었어요.몇 가지 실험 결과를 살펴볼까요?모임 정보노출 실험동네생활 모임은 운동, 독서 등 공통 관심사를 가지고 이웃들과 온오프라인으로 소통하고 만날 수 있는 서비스인데요. 동네생활 모임 활성화 실험에서는 모임의 최근 활동시간을 보여주고 관심 수를 추가하는 변화를 주었어요. 그 결과 클릭수가 대조군 대비 3% 상승했고, 실험 적용까지는 8일(워킹데이 기준)이 걸렸어요. 특히 이 실험에서는 컴포넌트 단위의 코드만으로 변경사항을 적용할 수 있었고, 실험군의 롤아웃도 기존 실험 코드를 실제 코드로 적용하는 것만으로 충분했어요.비즈니스(업체) 소식 UI 실험비즈니스 소식은 지역의 사장님이 작성한 게시글을 홈피드에 노출하는 서비스인데요. 비즈니스 소식 UI 개선 실험에서는 피드 아이템 카드가 지원하는 다양한 컴포넌트를 활용해 비즈니스 소식의 정보를 전달하고자 했어요. 예를 들어 사장님이 작성한 게시글의 본문을 노출하거나, 단골수를 보여주거나, 업체 이름을 보여주는 실험을 진행했어요. 실험 결과 유저들의 단골 맺기 수가 대조군과 5~10% 만큼의 차이를 보였어요. 이런 긍정적인 결과를 바탕으로 유저에게 더 필요한 정보를 전달하기 위한 추가 실험들을 준비하고 있어요.앞으로의 계획당근은 “활발한 교류가 있는 지역사회를 위해 모바일 기술로 가까운 동네 이웃들을 연결”한다는 비전을 향해 나아가고 있어요. 피드 인프라팀은 이를 기술적으로 실현하기 위해 중고거래, 부동산, 중고차, 당근알바 등 당근의 다양한 서비스들을 하나의 홈 피드로 연결하고 있죠. 앞으로도 피드 아이템 카드의 다양한 컴포넌트와 피드 아이템 제네레이터의 실험 기능을 지속적으로 발전시킬 예정이에요.이를 위해 SDUI와 같은 혁신적인 기술로 복잡한 문제를 해결하고 새로운 아키텍처를 설계하며 함께 성장할 백엔드 엔지니어를 찾고 있는데요. 유저들이 좋아하는 콘텐츠를 안정적으로 서빙할 수 있는 더 나은 방식을 고민하고, 직접 작성한 코드로 수많은 이웃들의 일상을 더 풍요롭게 만드는 경험을 해보고 싶지 않으세요? 피드 인프라팀의 문은 활짝 열려 있답니다!👉 피드 인프라팀 엔지니어 지원하기당근 홈 피드, Server Driven UI로 실험 이터레이션 빠르게 돌리기 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

당근페이 재무 결산 사례로 보는 백엔드와 데이터의 만남
당근마켓
당근페이 재무 결산 사례로 보는 백엔드와 데이터의 만남

안녕하세요. 저는 당근페이 머니서비스팀 백엔드 엔지니어 클로버(Clover)에요! 당근페이에서는 동네에서 쉽고 편하게 쓸 수 있는 금융 서비스를 만들고 있어요. 당근에서 일어나는 거래에는 당근머니가 사용되는데요. 머니서비스팀에서는 당근머니와 관련된 일들에 더해, 동네에서 쓰면 다양한 혜택이 적립되는 당근카드와 관련된 일들을 하고 있어요. 다시 말해 저희 팀은 지역에서 생기는 다양한 거래를 연결하는 게 목표예요!재무 결산의 중요성당근페이에서는 매월 셀 수 없이 많은 거래가 이루어지는데요. 매월 이 거래들을 바탕으로 당근페이 내에서 돈이 어떤 경우에 어디에서 어디로 흘러갔는지 재무 결산을 진행해요. 예를 들면 당근머니를 충전하면 사용자의 계좌에서 당근페이 모계좌로 돈이 이동하는데, 당근머니 충전액의 총합은 모계좌 입금액의 총합과 같아야 해요. 재무 결산을 했는데 단 1원이라도 맞지 않는다면 그 1원을 찾기 위해 모든 업무를 중단해야 해요. 그만큼 핀테크 회사인 당근페이에서는 재무 결산이 중요해요.당근페이 초기에는 거래량이나 거래 종류가 많지 않았기 때문에 데이터를 직접 SQL 쿼리로 뽑았었어요. 그러다가 당근페이가 커지면서…결산 어드민 도입 전 머니 결산 스레드위와 같이 매월 초에 열리는 머니 결산 스레드에서, 백엔드 엔지니어들이 태그되기 시작했어요. 머니서비스팀에 도메인 담당자가 많아질수록 매월 결산 슬랙 스레드에 태그되는 사람도 점점 많아졌죠. 결국 머니서비스팀 백엔드 개발자들은 매월 첫 주에는 정기적으로 코드가 아닌 SQL 쿼리를 치고 있는 상황이 일어났어요.이런 비효율을 어떻게 개선해 볼 수 있을까요? 모두가 SQL 쿼리를 더 정확하고 빠르게 작성하도록 SQLD 자격증 스터디를 열어볼 수도 있을 듯해요. 하지만 저희 팀에서는 이를 시스템적으로 개선해보고자 했어요.Spring Batch와 MySQL기반 결산가장 쉽게 떠오르는 방법은 스프링 배치를 이용하는 방법이에요. 대용량 데이터를 처리할 때 가장 흔하게 사용하는 기술이죠. 처음에는 저희 팀도 이 방법으로 결산을 진행했어요. 대략적인 구조는 다음과 같았어요.당근페이 머니서비스팀은 마이크로서비스로 컴포넌트들이 구성돼 있어서, 각자 다른 데이터베이스를 가지고 있어요. 여러 데이터들을 종합해서 진행하는 결산의 특성상, 배치는 여러 개의 데이터베이스를 바라보고 진행해요.이러한 결산은 크게 두 단계로 구성돼 있어요.원본 DB에서 성공 거래를 바탕으로 원장(raw data)을 구성해 결산 전용 DB에 밀어 넣기위에서 가져온 원장을 기반으로 집계해 일별 결산 리포트를 만들고 결산 전용 DB에 쌓기이런 단계별로 구성된 배치를 매일 00시에 Cron을 통해 트리거해요. 재무 담당자나 결산 이해관계자들은 이렇게 단계별, 일자별로 잘 집계된 데이터들을 스프링 기반 백엔드 및 결산 프론트 화면을 통해 언제든지 조회할 수 있어요. 여기까지는 괜찮은 것 같아요. 그러던 어느 날 문제를 마주했죠.구조의 한계배치 실패 알림 발생어느 날 갑자기 이런 배치 작업 실패 알림을 받게 됐어요. 보통 이렇게 결산 배치를 실패하는 이유는 크게 세 가지가 있어요.결산 코드 비즈니스 로직 상의 문제로 인한 실패원본 테이블 구조(혹은 필드 ENUM 타입) 변경으로 인한 실패인프라나 애플리케이션 자체(JVM, DB 커넥션 등)의 이슈로 인한 실패여기서 첫 번째와 두 번째 이슈가 가장 잦으면서도 중요한 이슈라 조금 더 구체적으로 예시를 들어볼게요. 예를 들어, 당근페이가 “당근머니 송금” 기능만을 제공하다가, “당근머니 결제”라는 기능을 추가적으로 오픈했다고 가정할게요. 이때 당근머니 DB에는 “송금”이라는 거래 타입뿐만 아니라 “결제”라는 거래 타입도 추가될 거예요. 게다가 기존의 “송금”은 단순히 사용자 간의 지갑 잔액이 변한다면, “결제”는 지갑 잔액 변동과 함께 외부로의 정산이 발생해요. 이 경우 테이블 구조뿐만 아니라 결산에 대한 비즈니스 로직도 변경될 수 있어요.물론 당근머니만을 대상으로 결산한다면 이는 큰 문제는 아닐 거예요. 당근머니와 관련된 기능이 추가될 때마다 결산에도 추가적으로 대응하면 되니까요. 하지만 당근페이에서의 결산은 당근머니, 은행 거래, 카드 거래 등 다양한 서비스를 기반으로 진행하게 돼요. 그리고 각 서비스들은 결산에서 긴밀하게 얽혀 있죠. 결국 결산은 언제든 깨질 수 있는, 테이블 끝에 걸친 유리잔이 됐어요. 각 서비스들에 새로운 기능이 생길수록 작은 변화가 결산에 큰 영향을 미치게 되고, 결국 결산 오류가 더 자주 발생하게 된 거예요.이외에도 다른 서비스 컴포넌트에서 기능이 추가될 때 대응이 복잡하다는 문제가 있어요. 결산이 하는 일은 거래들을 일자별로 모아서 더하고 빼는 것뿐이지만, 새 기능이 도입될 때마다 결산에 추가적으로 코틀린 ENUM 타입을 추가하고 코드를 작성해야 하는 건 굉장히 비효율적이죠. 모든 팀원들이 결산의 내부 동작 구조를 이해하고 있는 건 아니니까요.위의 문제를 해결할 수 있는 방법은 없을까요? 조금 더 엄밀히 말하면 테이블 변경이나 비즈니스 로직 변경에 좀 더 유연하게 대응하려면 어떻게 해야 할까요?Airflow와 dbt를 활용한 데이터 기반 결산Airflow와 dbt는 백엔드 개발자에게는 조금은 생소한 개념일 수도 있는데요. 우선 Airflow는 초기에 Airbnb에서 개발한 작업(워크플로)을 코드를 통해 작성하고, 스케쥴링하고, 모니터링할 수 있는 플랫폼이에요.dbt는 원시 데이터(raw data)를 분석 가능한 형태의 데이터로 변환하고, 데이터 중심의 결정을 내리도록 돕는 프레임워크예요. dbt는 Airflow 위에서 실행되면서, 미리 작성해 둔 SQL 쿼리를 바탕으로 데이터를 가공해요. 쿼리 결과에 맞는 형태로 테이블이나 뷰를 만들어주는 툴이라고 볼 수 있어요.기존 구조였던 Spring Batch와 MySQL 기반 결산과 대조되는 개념들로 이해하면 더 쉽게 이해할 수 있어요. 기존의 원장 데이터와 집계된 데이터를 저장하던 MySQL 데이터베이스는 데이터 웨어하우스가 되었고, Argo Workflow가 해주던 작업 스케쥴링은 이제 Airflow가 담당해요. 데이터 변환과 집계 로직을 담고 있던 Spring Batch는 dbt가 대신하게 됐고요!가장 큰 변화는 각 서비스 컴포넌트의 DB 구조를 이해하고 직접 집계해야 했던 기존 결산 구조에서 벗어났어요. dbt를 통해 각 서비스가 오너십을 가지고 집계 쿼리를 작성하고, 결산 서비스는 잘 집계된 데이터를 분류에 맞게 시각화하는 기능을 제공하게 됐어요. 이제 더 이상 각 컴포넌트의 기능 추가로 인해 결산 집계가 실패하는 일은 없어졌어요.큰 그림 말고, 이제 조금 더 깊게 살펴볼까요?dbt 스키마 구조위에서 기존 MySQL 기반 결산을 소개하면서, 결산은 크게 두 단계로 나뉜다고 설명드렸어요. 먼저 거래 원장을 구성하고, 원장을 바탕으로 결산 집계 작업을 진행하죠. 실제 재무 결산에서는 집계된 데이터를 보고 진행하지만, 혹여 결산 과정에서 틀어지거나 올바르지 않은 데이터가 있을 때를 교차검증하기 위해 원장 테이블이 존재해요.예를 들어, “프로모션 지급” 거래의 원장은 다음과 같은 쿼리를 통해 성공한 거래 내역을 추출해요.-- promotion_ledger.sql (프로모션 지급 거래 원장)SELECT 거래ID, 거래타입, 거래금액, 생성일시FROM 거래내역WHERE 거래타입 = '프로모션' AND 거래상태 = '성공'# dbt YAMLmodels: - name: promotion_ledger description: 프로모션 지급 거래 원장 columns: - name: 거래금액 description: 거래에서 발생한 금액 (실제 지급된 금액) data_tests: - not_null - dbt_utils.expression_is_true: expression: "> 0"이러한 형태로 추출된 dbt 스키마는 거래타입별로 존재해요. 모든 거래 타입의 거래 원장을 하나의 스키마로 관리하지 않는 이유는 실제 재무실 니즈에 조금 더 유연하게 대응하기 위함인데요. 예를 들면 같은 “프로모션” 거래타입 일지라도, 당근페이 내에서 진행한 프로모션이 있을 수도 있고 다른 서비스에서 진행한 프로모션이 있을 수 있어요. 원장을 거래타입별로 구분하면서 이러한 구체적인 거래 분기에 조금 더 유연하게 대응할 수 있게 되었어요.data_tests: - not_null - dbt_utils.expression_is_true: expression: "> 0"그리고 이 표현처럼 특정 필드의 값을 테스트할 수 있게 됐어요. 위 경우에서는 거래 금액이 null이 아니고 0보다 커야 함을 테스트하는 표현이에요. 데이터 변환과 함께 원장 데이터의 무결성도 함께 검증할 수 있게 된 셈이에요.이렇게 수집된 원장을 기반으로 다음과 같이 일별 집계를 진행해요.-- daily_report.sql (일별 결산 집계)WITH -- 프로모션 지급daily_promotion AS ( SELECT DATE(생성일시) AS 대상날짜, 거래타입, SUM(거래금액) AS 총거래금액 FROM {{ ref('promotion_ledger') }} GROUP BY 1, 2),-- 머니송금daily_transfer AS ( SELECT DATE(생성일시) AS 대상날짜, 거래타입, SUM(거래금액) AS 총거래금액 FROM {{ ref('transfer_ledger') }} GROUP BY 1, 2)...SELECT * FROM daily_promotionUNION ALLSELECT * FROM daily_transfer...# DBT YAMLmodels: - name: daily_report description: 일별 결산 집계 columns: - name: 총거래금액 description: 일별 거래에서 발생한 총 금액 data_tests: - not_null - dbt_utils.expression_is_true: expression: "> 0"앞서 수집했던 원장 스키마를 바탕으로 일별 거래타입별 거래액을 집계해요. 집계 결과도 마찬가지로 상황에 맞게 테스트를 진행해요.결국 이렇게 정의된 SQL과 dbt YAML들을 바탕으로 Airflow는 dbt Task를 실행시켜 주고, 이 Task는 결국 필요한 데이터를 데이터 웨어하우스에 밀어 넣어줘요. 그렇다면 이 데이터는 어떻게 기존 결산 어드민처럼 재무팀분들에게 보여줄 수 있을까요? 재무팀분들께 DW 접근 권한을 드려야 할까요?Spring Boot에 데이터 웨어하우스 연결하기현재 당근페이에서 사용하고 있는 데이터 웨어하우스는 Redshift인데요. 놀랍게도 Redshift(정확히는 AWS)에서 JDBC 드라이버를 제공하고 있어요. JDBC 드라이버가 존재한다는 것은 Spring Data JPA를 그대로 활용할 수 있다는 것을 의미해요. 결국 실제 백엔드에 새로운 쿼리 전용 라이브러리를 도입할 필요 없이, 기존 기술 스택 그대로 새로운 Airflow와 dbt 기반 결산 데이터를 조회할 수 있게 됐어요.우선 다음과 같이 Gradle에 RedShift JDBC 드라이버를 추가하고,dependencies { runtimeOnly("org.hibernate.orm:hibernate-community-dialects:6.4.4.Final") runtimeOnly("com.amazon.redshift:redshift-jdbc42:2.1.0.30")}다음과 같이 driver-class-name 과 database-platform 값, 그리고 RedShift 접근 정보를 채워주면 돼요.spring: jpa: driver-class-name: com.amazon.redshift.jdbc.Driver database-platform: org.hibernate.community.dialect.PostgreSQLLegacyDialect datasource: url: jdbc:redshift://{DB_HOSTNAME}:{DB_PORT}/{DB_DATABASE} username: {DB_USERNAME} password: {DB_PASSWORD}기존에 Spring Boot 2.x 에서는 Hibernate에 내장된 Dialect인 org.hibernate.dialect.PostgreSQL9Dialect를 사용할 수 있었지만, Spring Boot 3.x에서 해당 클래스가 사라지면서 org.hibernate.community.dialect.PostgreSQLLegacyDialect 로 지정해야 해요.그 외에는 기존 Spring Boot JPA와 똑같아요. 다음과 같이 Entity를 정의하고 쓰면 돼요. (대신 애플리케이션에서 업데이트하는 것을 방지하기 위해 @Immutable 어노테이션을 추가했어요.)@IdClass(DailyReportEntity.DailyReportEntityKey::class)@Immutable@Entity@Table(name = "daily_report")abstract class DailyReportEntity( @Id @Column(name = "대상날짜", nullable = false) val targetDate: LocalDate = LocalDate.now(), @Id @Column(name = "거래타입", nullable = false) val transactionType: String = "", @Column(name = "총거래금액", nullable = false) val totalAmount: Long = 0L,) { data class DailyReportEntityKey( val targetDate: LocalDate = LocalDate.now(), val transactionType: String = "", ) : Serializable}그 외에 다른 로직들은 기존 Spring JPA 사용법과 전부 동일해요. 다시 말해 과거에 Spring Batch와 MySQL기반으로 결산 데이터를 모으고 쌓는 부분만 Airflow로 이전하고, 그 외의 결산 결과를 확인하던 대시보드와 조회 로직은 그대로 사용할 수 있었어요. 덤으로 기존 MySQL로 되어있던 결산 데이터베이스는 필요 없어졌고요!최종 결산 아키텍처 구조는 다음과 같아요.(데이터팀분들이 구축해 주신 부분) 매 시간마다 운영(Reader 인스턴스) MySQL 데이터베이스에서 Spark를 통해 데이터 웨어하우스로 테이블이 싱크돼요.매일 Airflow는 dbt를 통해 결산에 필요한 원장과 일별 집계 데이터를 테이블로 가공해요.재무 담당자분들은 결산 어드민 프론트 화면을 통해서 데이터 웨어하우스에 적재된 결산 데이터를 볼 수 있어요.이루어낸 성과우선 기존 Spring Batch와 MySQL 기반 결산의 한계였던 구조적 복잡성을 해결했어요. 덕분에 머니서비스팀 내의 백엔드 개발자분들이 각자가 개발한 기능에 대한 결산 대응을 SQL 쿼리 수정만으로 쉽게 해결할 수 있게 됐죠.이는 단순히 작업자의 접근성을 끌어올려줬을 뿐만 아니라, 실제 결산 시 기능 추가 과정에서 필요한 작업들을 효과적으로 줄이기까지 했어요. 이제 더 이상 애플리케이션에서 각 타입을 알고 계산 로직을 추가해야 하는 게 아니라 SQL 쿼리에서만 대응하면 되니까요![Before]Spring Batch + MySQL 아키텍쳐에서 결산 로직 변경시 사례[After]Airflow + dbt 아키텍쳐에서 결산 로직 변경시 사례확실히 기존에 코틀린 코드로 존재하던 결산을 SQL 쿼리로 변경하니 수정 사항을 변경하는 게 간단해지기도 하고 가시성도 높아졌죠?마치며현대 사회에서 데이터의 가치는 회사의 중요한 자산과 경쟁력이 되고 있어요. 특히 당근페이와 같은 핀테크 기업에서 이러한 데이터의 정합성과 투명성은 사용자들의 신뢰를 구축하기 위한 핵심 요소예요. 위 사례에서 살펴본 데이터 기반 결산 아키텍처로의 변화는 단순한 결산 과정 비효율 개선을 넘어서, 장기적으로 당근페이 내 구성원들이 데이터 기반의 의사결정과 소통을 할 수 있도록 도와요. 결국 이를 통해 사용자에게 더 나은 서비스를 제공하는 데 기여할 수 있을 거예요.따라서 당근페이에서는 데이터 중심적 사고를 백엔드 엔지니어에게도 중요한 역량으로 인식해요. 당근페이의 백엔드 엔지니어들은 단순히 코드를 작성하는 것을 넘어, 데이터의 정합성과 무결성을 보장하기 위해 위의 Airflow 같은 다양한 데이터 중심의 아키텍처를 탐구하죠. 또한 데이터 중심적 사고방식을 확산하려고 노력하고 있기도 해요. 조직 전체가 데이터를 적극적으로 이해하고 활용하려는 문화가 정착되면서, 모두가 데이터에 기반해 더 나은 의사결정을 할 수 있도록 변화하고 있어요.지역의 다양한 거래를 연결한다는 팀의 비전을 위해 비효율적인 부분을 개선하고 데이터 관점에서도 끝없이 고민하는 머니서비스팀! 저희 팀이나 오늘의 이야기에 대해 궁금하신 게 있다면 clover@daangnpay.com 으로 연락 주세요! 사소한 궁금증도 괜찮아요 ✌️또 현재 머니서비스팀에선 기술의 장벽 없이 비효율적인 부분을 개선하고 다양한 문제를 해결해 나갈 백엔드 엔지니어도 채용하고 있으니 많은 관심 부탁드려요!https://about.daangn.com/jobs/4511184003/참조[1] https://airflow.apache.org/[2] https://www.getdbt.com/product/what-is-dbt당근페이 재무 결산 사례로 보는 백엔드와 데이터의 만남 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

MVP를 빠르고 효과적으로 개발하기: 우리는 협업해요
당근마켓
MVP를 빠르고 효과적으로 개발하기: 우리는 협업해요

안녕하세요. 저는 당근 LocalMaps UGC 팀에서 서버 개발을 하고 있는 조앤(Joanne)이라고 해요. 저희 팀은 얼마 전까지 지역사업실 UGC(User Generated Contents) TF로서 MVP 스펙 개발에 몰입해 있었는데요. 이 글에서는 저희가 TF일 당시 어떻게 빠르고 효과적으로 기능을 구현할 수 있었는지 그 방법을 소개해보려고 해요. 단기간에 새로운 기능을 도입하기 위해 어떤 협업 방식을 갖춰야 할지 궁금하신 분들에게 도움이 되었으면 해요.UGC TF의 배경과 목표먼저 UGC(User Generated Contents)는 후기와 같이 사용자가 자발적으로 생산하는 콘텐츠를 의미해요. 저희는 사용자들이 더 많은 UGC를 생산하도록 동기부여하는 데에 집중하기 위해 UGC TF를 결성했어요. 즉, 사용자에게 UGC를 생산해야 하는 이유를 만들어 줌으로써 UGC 생산을 효과적으로 늘리려고 했죠.개발자의 경우 클라이언트 2명, 서버 4명으로 구성된 저희 TF는 최근 MVP 개발을 완료했어요. 최소 비용으로 사용자 반응을 빠르게 확인하여 가설을 검증하려 했기에 일정을 타이트하게 가져갔죠. 저희 팀은 약 한 달간의 일정 내로 여러 기능을 구현해야 했는데요. 그 중 대표적인 것은 피드백 루프였어요.다시 말해, 생산해 낸 콘텐츠에 피드백을 제공함으로써 사용자에게 자기 효능감을 주는 것을 중심으로 MVP 스펙을 구성했죠. 대표적인 요구사항으로는 내가 참여한 장소와 기여 내역을 확인할 수 있는 프로필 페이지, 내가 생성한 콘텐츠의 조회수가 일정 수준 도달 시 이를 전달해 주는 성과 알림 등이 있어요.단기간에 여러 기능을 구현해야 하는 요구사항이 주어졌을 때, 어떻게 일하는 것이 가장 좋은 방법이라고 생각하시나요? 혼자서 하나를 담당해 빠르게 구현하는 것이 가장 좋은 방법일까요?혼자 하면 빨리 가고, 함께 하면 멀리 간다.잘 알려져 있는 유명한 문구죠. 저도 이 문구를 오랫동안 믿어 왔어요. 하지만 이번 UGC TF에서 협업하는 과정에서 이 문장이 틀렸다는 걸 깨달았죠. 함께 하는 것은 멀리 갈 뿐만 아니라 사실 결국 더 빨리 갈 수 있는 방법이었어요. 멀리 가면서도 빠르게 갈 수 있었던, 저희 TF의 협업 방식을 본격적으로 이야기해 볼게요.1. Tech Spec을 작성하고 함께 리뷰해요.기능 구현을 위해 바로 코드부터 짜시나요? 그 이전에 테크 스펙을 작성하고 팀원들과 공유하는 과정이 필요해요. 테크 스펙은 기술적인 세부 사항을 기록한 문서예요. 테크 스펙은 요약 및 배경, 목표와 목표가 아닌 것, 계획, 임팩트 측정, 고려사항 및 마일스톤, 질의응답 등의 주요 요소로 구성돼 있어요.이해를 돕고자 UGC TF의 요구사항 중 하나인 성과 알림을 예시로 들어볼게요. UGC TF는 유저에게 긍정적인 피드백 루프를 제공하기 위해 성과 알림을 발송하기로 했어요. 아래는 성과 알림 구현을 위한 요구사항 중 일부예요.특정 유저가 작성한 후기의 조회수가 N회에 도달하면 알림을 발송해요.같은 횟수로 이미 발송된 적이 있다면 중복으로 발송하지 않아요.유효한 후기가 아닐 경우 발송하지 않아요.이를 바탕으로 Tech Spec의 핵심이 되는 부분인 목표와 계획 파트를 작성한다면, 다음과 같이 작성할 수 있어요.목표와 목표가 아닌 것목표:사용자가 작성한 후기의 조회수가 특정 기준(N회)에 도달했을 때 성과 알림을 발송해요.중복 알림 발송을 방지하여 사용자 경험을 해치지 않도록 해요.유효한 후기에 대해서만 알림을 발송하여 알림의 품질을 유지해요.목표가 아닌 것:모든 종류의 사용자 활동에 대한 알림을 구현하는 것은 이번 프로젝트의 목표가 아니에요.사용자가 알림 기준(N회)을 직접 설정하는 기능은 이번 단계에서 구현하지 않아요.계획후기 조회수 집계 시스템 구축:기존 후기 조회 로직에 카운터 증가 기능을 추가해요.분산 환경에서의 동시성 문제를 고려하여 원자적 연산을 사용해요.알림 발송 조건 확인 로직 구현:조회수가 N회에 도달했는지 확인하는 로직을 구현해요.중복 발송 방지를 위해 이전 알림 발송 이력을 확인해요.후기의 유효성을 검증하는 로직을 추가해요.알림 발송 시스템 구축:자체 알림 발송 시스템을 활용해 알림을 발송해요.알림 템플릿을 제작하고, 동적으로 내용을 변경할 수 있도록 구현해요.테스트 및 모니터링 시스템 구축:단위 테스트와 통합 테스트를 작성하여 시스템의 안정성을 확보해요.알림 발송 현황을 모니터링할 수 있는 대시보드를 구축해요.Trade-Off:FCM vs 자체 알림 시스템: 주어진 기간 내에 FCM을 구현하기에는 러닝커브가 높기 때문에, 모니터링 및 구현이 간단한 자체 알림 시스템을 선택해요.배치 처리 vs 실시간 처리: 사용자 경험을 위해 실시간 처리 방식도 고려했지만, 시스템 부하를 줄이기 위해 조회수 집계와 알림 발송을 배치 처리하는 방안을 선택해요목표와 계획은 프로젝트의 방향을 명확히 하고, 구체적인 실행 단계를 제시해 줘요. 또한 Trade-Off 부분에서는 의사 결정 과정과 그 이유를 명시하여 팀원들과의 논의 기반을 마련했어요. 이 과정에서 MVP 대비 오버 엔지니어링이 되는 경우는 없는지, 주어진 상황에서 더 적은 리소스를 쓸 수 있는 방법은 없을지 고민할 수 있어요.테크 스펙을 혼자서만 작성하고 끝나는 것이 아니라 서버 엔지니어가 모두 모여 리뷰하는 시간을 가져요. 리뷰 시간에는 주로 선택지에 대한 트레이드 오프나 발생 가능한 문제 위주로 피드백하죠. 또한 작성자가 생각하는 것과는 다른 개선책이 떠오른다면 함께 제안하고 의견을 적극적으로 나눠요.테크 스펙을 작성하고 함께 리뷰하는 과정에서 경험한 장점들을 아래와 같이 정리할 수 있어요.문서를 작성하며 전체적인 설계를 정리하고 다듬을 수 있어 오히려 구현하는 데에 시간이 덜 들었어요. 직관적이고 빠르게 개발할 수 있어요.고민되는 부분에 대해 나의 생각뿐만 아니라 동료의 의견을 들을 수 있어요. 이러한 리뷰를 통해 더 나은 방향으로 갈 수 있도록 유연함을 가져요.문서를 Source Of Truth로 바라보고 개발 직군이 아닌 직군과도 일관되게 소통할 수 있어요. 문서는 주기적으로 업데이트하고 문서의 업데이트를 통해 다시 한번 전체적인 구현에 대해 점검할 수 있어요.예상치 못한 문제들을 미리 방지하고, 함께하는 구성원들과 프로젝트의 상태 및 방향성에 대해 얼라인할 수 있어요.2️. 맥락을 긴밀히 공유해요.앞서 예시로 든 성과알림 외에도 프로필 페이지, 내가 참여한 내역 등 여러 가지 요구사항이 동시에 존재했어요. 담당하는 요구사항에만 집중하다 보면 큰 맥락을 놓치기 쉽고, 전체적인 유저 경험을 고려하기 어려울 수 있어요. 저희 TF는 이런 한계를 보완하고자 페어를 구성했어요. 페어는 맥락 공유를 위해 더욱 긴밀하게 협업하는 관계예요.저(Joanne)는 제니(Jenny)와 페어였는데요, 페어로서 더 잘 일할 수 있는 방법을 함께 많이 고민했어요. 저희는 맥락 공유가 가장 중요하다고 결론 내렸는데요. 이를 위해 가장 처음 한 것은 둘을 한꺼번에 멘션할 수 있도록 Slack에서 JJ라는 유닛을 결성했어요. 멘션 시에도 함께 멘션되도록 해서 사소한 맥락도 공유할 수 있도록 했죠.맥락 공유가 가져다주는 긍정적인 효과로는 다음과 같은 것들을 경험할 수 있었어요.PR 리뷰 과정에서 둘 다 전체 맥락을 이해하고 있어 효과적인 피드백을 주고받을 수 있었고 리뷰에 소요되는 시간이 줄었어요.특정 작업자에 대한 의존도가 줄어들어 프로젝트의 유연성이 향상됐어요.상황에 따라 유동적으로 업무를 나누고 조정할 수 있었어요. 이는 작업 효율성을 높이고 예상치 못한 상황에도 신속히 대응할 수 있었어요.결과적으로 페어를 통한 맥락 공유는 단순히 정보를 나누는 것을 넘어 팀의 생산성과 코드 품질, 그리고 프로젝트의 전반적인 건강성을 향상시키는 핵심 요소로 작용했어요.3. 함께 고민하여 변경에 유연하게 대응해요.프로젝트를 진행하면서 변경에 유연하게 대응하고 팀원들과 함께 고민하는 문화를 만들어갔어요. 이를 위해 여러 가지 방법과 도구를 활용했어요.Schema-First 접근 방식을 활용해요.OpenAPI Spec을 사용함으로써 클라이언트와 서버 개발팀이 API 인터페이스에 대해 함께 논의하고 하나의 소스를 바라보며 병렬 작업을 할 수 있었어요. API 응답에 변경이 필요할 때도 서버에서 일방적으로 수정하는 것이 아니라, OpenAPI Spec을 수정하여 더욱 유연하게 대응할 수 있었어요.OpenAPI의 사용과 관련해서 더 자세한 내용이 궁금하다면, 커뮤니티실의 하이디가 작성해 주신 아래 글을 참고해 보시면 좋을 것 같아요.🔗 커뮤니티실 API Design-First 접근방식 정착기테크 스펙을 기반으로 요구사항 변경에 유연하게 대응해요.요구사항이 변경될 때는 앞서 작성한 테크 스펙을 기반으로 기존에 설계했던 것과 수정되어야 할 것을 명확히 비교할 수 있어요. 수정된 부분을 엔지니어들과 함께 재검토하면서 직관적이고 유연한 수정이 가능해졌죠.위에서 이야기했던 성과 알림을 계속 예시로 들어볼게요. 중복 발송 로직이 고도화되기로 수정되었다고 가정해 봅시다. 기존에 작성한 테크 스펙을 기반으로 ASIS와 TOBE를 작성하여 변경된 부분을 명확히 하거나 변경된 부분에 대해서만 리뷰를 받는 등 유연한 변경이 가능해져요.회고와 개선프로젝트가 끝날 때엔 회고 시간을 가져 개발 과정에서 놓쳤던 부분들을 다시 짚어보고, 더 나은 방향으로 나아갈 수 있도록 수정하는 cool-down 시간을 가졌어요. 이를 통해 지속적인 개선과 성장이 가능했죠.회고 시 주의해야 할 점이 하나 있어요. 그건 바로 프로젝트를 모두 마친 후 회고를 진행하면 작업 중에 고민했던 부분을 놓치기 쉽다는 점이에요. 저희 TF에서는 그런 상황을 방지하기 위해 미리 회고 티켓을 따로 만들어뒀어요. 추후에 함께 고민해 볼 만한 것들을 미리 등록해두고, 회고 때 함께 논의했죠.회고 티켓은 다음과 같은 내용을 담을 수 있어요. ‘패키지 구조를 어떻게 나누어야 할 것인가’에 대한 고민을 예로 들어 볼게요. A라는 Service를 a라는 패키지를 만든 뒤 그 하위에 위치시킬지, 혹은 service라는 상위 패키지 하위에 모두 몰아넣을지 고민해 봤다고 가정해봐요. 그럼 그 당시 고민했던 여러 대안들과 최종적인 선택을 내린 이유 등을 티켓에 함께 작성해두는 거죠.이러한 방식으로 팀원들과 함께 변화에 대응하고 고민하며, 프로젝트의 품질을 높이고 팀워크를 강화할 수 있었어요.우리는 왜 협업할까?혼자 하면 빨리 가고, 함께 하면 멀리 간다.이 문구를 다시 한번 생각해 봐요.만약 모든 일을 혼자 했다면, 모든 맥락을 혼자만 알고 있었을 거예요. 이는 특정 사람에 대한 의존도를 높이고 문제를 빠르게 개선하기 어려운 상황을 만들어요. 저희는 먼저 문서를 작성하고, 이에 대해 피드백을 받고, 함께 고민하고, 유연하게 대응할 수 있는 방법들을 사용했어요. 이는 복잡도가 높은 상황에서 더욱 안정적이고 빠르게 갈 수 있도록 큰 도움을 주었어요.우리가 협업하는 이유는 단순히 일을 나눠서 하기 위함이 아니에요. 지속 가능한 방식으로 팀을 전진시키기 위해 협업하죠. 혼자 일할 때의 장점도 분명히 있지만, 협업을 통해 우리는 서로의 지식과 경험을 공유하고 다양한 관점에서 문제를 바라볼 수 있어요. 이는 개인의 성장뿐만 아니라 팀 전체의 성장으로도 이어지죠. 팀이 더 큰 도전을 성취해 내고 더 큰 목표를 향해 나아갈 수 있도록 해요.여러분은 어떻게 협업하시나요? 더 멋진 방법이 있다면 함께 공유해 주세요.이만 글 줄일게요. 읽어주셔서 감사해요.#programming #workingMVP를 빠르고 효과적으로 개발하기: 우리는 협업해요 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.