기술 블로그 모음

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

전체 프론트엔드 백엔드 데브옵스 AI 아키텍처 DB 기타
FAST: 데이터 파이프라인 이제는 웹에서
네이버 페이
FAST: 데이터 파이프라인 이제는 웹에서

목차0. 들어가며 0.1 데이터 활용을 위해 필수불가결한 배치 시스템1. 기존 데이터 활용 배치 시스템의 문제점 1.1 Zeppelin Cron 기능을 이용한 배치 1.2 Airflow기반 파이프라인 생성 배치 1.3 Redash를 통해 주기적으로 대시보드생성2. 새로운 데이터 활용 배치 시스템 2.1 요구사항 및 설계 2.2 아키텍처 3. FAST의 주요기능 3.1 배치 조회 3.2 배치 생성 3.3 이외의 기능4. 도입 후: 기존 프로세스 vs FAST 활용 프로세스 4.1 AS-IS: 기존 프로세스 4.2 TO-BE: FAST 활용 프로세스5. 맺으며안녕하세요, 네이버페이 인텔리전스플랫폼팀 신태범입니다.0. 들어가며저희 팀은 데이터 엔지니어링 팀으로, 조직 내에서 데이터가 성공적으로 활용될 수 있도록 여러 업무를 수행하고 있습니다. 이를 구체적으로 살펴보면, 데이터 인프라 운영과 관리, 네이버페이 서비스에서 생성되는 데이터의 수집, 그리고 데이터 거버넌스를 포함합니다.팀에서 담당중인 업무들데이터 문화가 고도화됨에 따라, 데이터팀이 해야 할 역할은 더욱 다양해지고 있습니다. 그 중 하나는 서비스 담당자들이 최소한의 개발 지식으로도 도메인 지식을 활용해 데이터를 쉽게 다룰 수 있도록 지원하는 것입니다.개인이나 조직이 데이터를 효과적으로 이해하고 활용할 수 있도록 하는 시스템을 ‘데이터 리터러시 플랫폼’이라고 부릅니다. 사용자들이 필요로 하는 데이터 리터러시 도구들을 지속적으로 개선하고 개발하는 것은 조직의 데이터 리터러시를 높이는 중요한 방법 중 하나입니다.이 글에서는 저희 팀의 데이터 리터러시 플랫폼 중 웹기반 데이터 파이프라인 생성 툴인 FAST에 대해 소개하고자 합니다. FAST는 FDC automated self tasker의 약자로 빠르게 배치를 구성할 수 있다는 의미를 담고있습니다. 기능적으로는 웹기반으로 사용자 입력을 받아 Airflow DAG 생성을 자동으로 연계해주는 툴입니다.이 글이 정형화된 파이프라인 템플릿을 보유하고 있거나, 파이프라인을 전사적으로 효과적으로 활용할 방법을 찾고 계신 분들께 도움이 되길 바랍니다. 또한, 파이프라인이 없더라도 조직 내 데이터 리터러시를 향상시키기 위해 고민하는 분들께 유용한 참고자료가 되었으면 좋겠습니다.0.1 데이터 활용을 위해 필수불가결한 배치 시스템### S님의 업무일지처음에는 필요한 데이터를 얻기 위해 제플린 같은 도구를 사용해 직접 찾아보는 단계에서 시작했습니다.하지만 시간이 흐르면서, 매일매일 같은 종류의 데이터를 볼 필요성이 생기기 시작했죠.그래서 우리 팀에선 그런 데이터를 쉽게 볼 수 있게 '마트 테이블'이라는 것을 만들기 시작했습니다. 그리고 일별, 주별, 월별로 데이터를 살펴보고 이상치가 없는지 확인하며, 중요한 부분들을 눈에 띄게 '시각화'하는 걸 배웠습니다.이건 Redash라는 것을 사용해서 가능했죠.그리고 이런 정보들이 필요하다고 생각되는 팀원들에게나, 전사적으로 중요한 KPI와 관련 있는 정보들은 함께 공유하기 시작했습니다.처음엔 데이터를 보기 위한 SQL 쿼리를 작성하는 것이 어려웠지만, 점차 익숙해지더군요.그리고 그 다음 단계로, 데이터를 뽑고 분석하는 과정을 '자동화'하는 방법을 배우게 되었습니다.이에 '배치 시스템'이라는 것을 활용하게 되었죠.이렇게 하면 데이터 처리 과정을 편하게 자동으로 돌릴 수 있어 시간을 절약하여 업무 효율을 높일 수 있었습니다.데이터 기반 의사결정 조직에서 사용자들은 어떻게 데이터 문화에 익숙해져갈까요? 처음에는 SQL을 직접 사용하거나 팀에서 공유받은 쿼리 템플릿을 활용하여 필요할 때마다 제플린 등의 데이터 분석 노트북 툴로 단순히 데이터를 조회합니다.일시적인 쿼리뿐 아니라, 의사결정을 위해 주기적으로 데이터를 추출해야 하는 경우도 있습니다. 이러한 작업이 반복되면 자주 사용하는 테이블을 JOIN하여 다른 구성원들이 쉽게 활용할 수 있도록 미리 마트 테이블을 생성하게 됩니다.사용자들은 이렇게 생성한 마트 테이블을 활용해 일별, 주별, 월별 등으로 이상치와 집계 결과를 정기적으로 확인합니다. 또한, BI 도구로 보다 직관적으로 결과를 시각화하고, 필요한 경우 팀원들과 공유하며 의사결정을 진행할 수 있습니다. 더 나아가 조직의 중요한 KPI와 관련된 경우 전사에 공유하는 자료에도 이를 활용할 수 있습니다.이 과정을 반복하게 되면 사용자들은 쿼리에 빠르게 익숙해지고 능숙하게 데이터를 볼 수 있게 됩니다. 사용자는 계속해서 반복되는 데이터 추출 관련 업무에 쏟는 시간을 최소화하고 효율적으로 일하기 위해 자동화 배치 시스템을 사용하고자합니다.실제 데이터 활용 시나리오1. 기존 데이터 활용 배치 시스템의 문제점위에서 설명한 흐름에 따라 점차 구성원들이 데이터 보는 법을 알게 되고, 사내에 데이터 문화가 자리 잡아갑니다. 네이버 페이 역시 구성원들의 데이터 리터러시가 향상되며 배치 시스템의 니즈는 커졌습니다.이에 따라 저희 팀에서도 사용자들의 니즈에 맞춰 다양한 배치 시스템을 지원 했습니다. 크게 아래 네가지로 나누어집니다.1. Zeppelin상에서 작성한 집계쿼리의 결과를 주기적으로 메일로 받아보고 싶어요- Zeppelin Cron2. 여러 서비스 시스템 간의 수치가 맞는지 맞춰보고 싶어요(시스템 간 대사)- 데이터 엔지니어가 직접 Airflow DAG로 배치 생성3. 좀 더 안정적인 배치를 통해 사용할 마트 테이블을 생성하고 싶어요- 서비스 실무자가 코드작성, 데이터 엔지니어 리뷰를 거쳐 Airflow DAG로 배치 생성4. 데이터를 활용해서 시각화하여 대시보드를 생성하고 싶어요- Redash를 통해 주기적으로 결과가 갱신되는 대시보드 작성그러나 시스템 도입 초기부터 앞으로의 모든 문제를 예측해 설계하는 것은 현실적으로 불가능합니다. 기존 프로세스 또한 당시의 사용자 니즈에 맞춰 도입되다 보니 시간이 지나며 다음과 같은 문제가 발생했습니다.1.1 Zeppelin Cron 기능을 이용한 배치Zeppelin CronZeppelin Cron은 노트북에 스케줄을 설정하여, 설정된 스케줄에 맞춰 노트북이 자동으로 실행되도록 하는 기능입니다. 제공되는 템플릿에 사용자가 결과를 보고 싶은 쿼리만 넣어주고, 스케줄만 지정하면 간단히 사용가능하다는 장점이 있었는데요. 하지만 이 방식에는 아래와 같은 문제점이 있었습니다.1.2 Airflow기반 파이프라인 생성 배치Airflow기반 파이프라인 생성 배치일부 데이터는 서비스와 밀접한 관련이 있기에, 안정적인 배치를 위해 내부 파이프라인 라이브러리를 통해 데이터를 추출 하기도 했습니다. 하지만 YAML, git, jenkins, github 등 비개발자에게는 낯선 지식이 필요하다는 허들이 존재했고, 수정이 필요할 때마다 데이터 엔지니어와의 커뮤니케이션이 필요하다는 문제점이 있었습니다.1.3 Redash를 통해 주기적으로 대시보드생성Redash 대시보드 화면숫자나 문자로 표현된 정보는 직관적이지 않습니다. 데이터를 한 눈에 시각화하고 인사이트를 효과적으로 전달할 수 있도록 Redash를 도입했습니다. 대시보드 내부의 데이터 배치는 Redash Scheduled Query 기능을 통해 주기적으로 갱신합니다.하지만 매번 필요할 때마다 Redash에 접속해야 한다는 불편함이 있어 주기적으로 대시보드를 메일로 받을 수 있는 기능에 대한 갈증이 존재했습니다. 또한 대시보드를 생성하기 위한 마트 테이블 생성을 위해 과도하게 무거운 쿼리가 실행되는 경우가 잦아 리소스 상의 문제가 발생하여 정상적으로 배치가 수행되지 않기도 했습니다.이외 문제점위에서 언급한 컴포넌트별 문제뿐만 아니라, 산발적으로 여러 컴포넌트에서 다양한 배치가 수행되고 있다보니 팀에서 관리할 포인트가 느는 등 유지보수 상에서도 불필요하게 공수를 잡아 먹는 문제가 있었습니다.2. 새로운 데이터 활용 배치 시스템위와 같은 사용자/관리자 입장에서의 불편함으로 인해서, 기존 데이터 활용 프로세스를 개선한 새로운 데이터 활용 배치 시스템이 필요했습니다.저희의 주된 사용자 분들 중에는 개발관련 지식에 친숙하지 않은 서비스 실무자 분들이 포함되어 있습니다. 그렇기에 누구나 쉽게 사용할 수 있도록, 웹기반으로 좀 더 직관적이고 접근성이 높은 툴을 개발하자는 결론을 내렸습니다.추가적으로, 개별 컴포넌트들의 문제 및 운영/관리 상의 문제와 추후 조직의 KPI를 취합하여 정리했을 때 아래 요구사항을 충족하는 툴을 만들고자 했습니다.2.1 요구사항 및 설계요구사항2.2 아키텍처FAST는 위에서 언급한 요구사항을 고려해서 다음과 같이 설계되었습니다FAST 아키텍처실제 배치는 안전성과 유지보수를 고려하여 workflow 툴인 Airflow를 사용Hive(JDBC), Bash, TextMailing, ScreenshotMailing, Join Task(Operator) 등을 쉽게 정의할 수 있게 웹에서 제공사용자는 필요한 Task를 추가하고, 각 Task의 필수 값을 입력만 하는 형태로 간단하게 안정적인 배치를 구성할 수 있음웹에서 구성한 데이터 파이프라인을 Python 코드와 YAML 구성 파일로 자동 변환한 후, 이를 Airflow로 배포하여 Airflow DAG으로 변환3. FAST의 주요기능앞서 소개드린 요구사항과 설계에 맞춰 새로운 데이터 활용 배치시스템을 개발하였습니다. 이 시스템은 빠르게 배치를 구성할 수 있다는 의미를 담아, FDC automated self tasker, FAST로 명명했습니다.웹 UI에서 정해진 필드를 단순히 채우기만 하면 배치를 생성할 수 있도록 개발하여, 그간의 데이터 파이프라인을 구성하기 위한 허들을 많이 낮추었고, 이를 통해 사용성은 높였습니다. FAST의 UI와 주요기능은 아래와 같습니다.3.1 배치 조회FAST Home3.2 배치 생성배치 생성 UI는 두개의 화면으로 나누어져 있습니다. 오른쪽에는 각 Task에서 필요한 인풋들을 입력받는 폼이 있습니다. 왼쪽에는 오른쪽에서 각 Task별로 설정한 디펜던시를 그래프를 보여줘 보다 직관적으로 실행흐름을 확인할 수 있도록 하고 있습니다.Task 설명ScreenshotMailing: 대시보드를 메일링하는 TaskTextMailing: 쿼리 결과를 메일링하는 TaskHive: hive 쿼리를 실행하는 TaskTask별 쿼리에 대해 쿼리검증 버튼을 누르면 실행전 오류를 미리 탐지3.3 이외의 기능이외에도 사용자 편의를 생각한 여러 기능을 제공 중입니다. 자주 사용되는 패턴은 템플릿으로 제공하며, 필요에 따라 이 템플릿을 복제해서 값만 대치하는 형태로 활용할 수 있도록 하고 있습니다. 또한 FAST는 git 커밋로그처럼 변경 메시지와 함께 변경 내역을 관리하고 있어서 과거 버전을 조회하거나 롤백할 수도 있습니다.다른 유저가 작성한 배치 복사배포 이력 확인배포 롤백배치 복제팀계정(키탭) 지원이외에도 사용자 요구사항을 지속적으로 반영하기 위해 사내 데이터 문의창구를 별도로 운영 중입니다. 무엇보다도, 사용자 편의성을 최고로 우선시하여 의견을 적극 반영하여 시스템을 개선해 나가고 있습니다.4. 도입 후: 기존 프로세스 vs FAST 활용 프로세스마지막으로, FAST 도입 전과 도입 후 사용자 입장에서 어떻게 데이터 파이프라인 작성 과정이 변경되었는지 살펴보도록 하겠습니다.AS-IS -> TO-BE4.1 AS-IS: 기존 프로세스기존 프로세스에서는 마트 생성을 위한 배치를 만들기 위해 사용자가 SQL 문법 이외에도 여러 개발지식을 익힐 필요가 있었습니다.사용자가 필요한 지식: git, 환경세팅, YAML, SQL, ndeploy(사내 배포툴)step1 개발환경을 세팅한다(git, vscode 등 설치)step2 YAML, git에 대한 지식을 쌓는다.step3 기존에 작성되어있던 예시를 보고 코드를 작성한다.step4 배포해본다. -> 왜안되지?step5 데이터 엔지니어에게 물어본다.step6 다시 수정한다. -> 배포해본다. -> 왜안되지? ...step7 어찌저찌 완성.. 하고 데이터 엔지니어에게 배포를 요청한다.step8 모니터링은 데이터 엔지니어가 해줌step9 원하는 결과가 나올때까지 반복4.2 TO-BE: FAST 활용 프로세스사용자는 기존에 학습한 SQL 문법과 간단한 FAST 사용법만 인지하면 쉽게 마트 테이블 생성 배치를 만들 수 있게 되었습니다.사용자가 필요한 지식: SQL, FAST 사용법step1 기존에 작성되어 있는 예시를 보고 input값을 적는다.step2 저장하고 배포한다.step3 원하는 결과가 나올때까지 반복5. 맺으며지금까지 개발지식이 필요했던 여러 배치시스템을 웹기반으로 통합한 FAST에 대해 소개드렸습니다. FAST 도입 이후, 사용자는 훨씬 적은 기반지식을 가지고 쉽게 배치를 생성할 수 있게 되었습니다. 또한 저희 팀에서는 커뮤니케이션 및 산발적인 컴포넌트를 관리를 위한 공수가 줄어들어, 인프라 고도화 및 다른 업무에 집중할 수 있게 되었습니다.추후 개발FAST의 성공적인 안착 이후, 현재 비개발자 분들의 손쉬운 모델 개발을 위한 AutoML 및 사용성을 높이기 위한 python, Spark 실행을 지원하는 Pyspark 등을 도입하기위해 내년 상반기들 목표로 인텔리전스서비스팀과 협업하여, 개발중에 있습니다.이를 통해 FAST는 데이터 파이프라인을 넘어서, MLops까지 범주를 확장하여 All-in-one 데이터 리터러시 플랫폼이 되고자 합니다. 프로젝트에 도움을 주신 분들께 감사드리며, 긴 글 읽어주셔서 감사합니다.FAST: 데이터 파이프라인 이제는 웹에서 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

Uplift Modeling을 통한 마케팅 비용 최적화 (with Multiple Treatments)
네이버 페이
Uplift Modeling을 통한 마케팅 비용 최적화 (with Multiple Treatments)

안녕하세요, 네이버페이 인텔리전스서비스 박대한입니다. 마케팅 비용 최적화 문제를 uplift modeling으로 정의하고, 인과추론(causal inference) 모델을 적용한 사례를 공유드립니다. 이론이나 코드보다는 분석 과정에서의 실무적 고민과 생각을 중심으로 이야기하려 합니다. Uplift modeling으로, CATE, 다중 처치(Multiple Treatments)에 관심 있는 분들께 참고가 되길 바랍니다.글의 순서는 다음과 같습니다.0. 문제 정의 0.1. Uplift Modeling 0.2. Multiple Treatments1. 데이터 수집2. 모델 개발 2.1. Double Machine Learning 2.2. 인과효과 추정3. Offline 평가4. Policy 생성5. Online 평가6. 후기Source: Unsplash0. 문제 정의네이버페이는 서비스를 활성화하기 위해 자체적으로 프로모션을 진행합니다. 예를 들어, 마이데이터 등록, 자동차보험 등록, 제휴 가맹점 결제 등과 같은 전환 관련 action을 유도하기 위해 “OOO 하시면 네이버페이 포인트 1000원을 드립니다”와 같은 프로모션을 진행합니다.Npay 마이데이터 만기연장 프로모션여기서 ”어떤 사용자들에게 1000원을 제공해야 하는가?” 라는 마케팅 비용 최적화 문제가 발생합니다. 만약 마케팅 없이도 목표 action을 할 사용자가 있다면, 이들에게는 마케팅을 하지 않는 것이 비용 절감에 도움이 될 것입니다. 마케팅 분야에서는 이를 uplift modeling 문제라고 합니다.또한, ”1000원에 반응하지 않는 사용자들에게 2000원을 제공하면 반응할까?” 라는 질문도 할 수 있습니다. 사용자마다 마케팅에 반응하는 가치가 다를 수 있기 때문입니다. 저는 이 문제를 Multiple treatments 문제로 정의했습니다. 자세한 내용은 아래에서 설명드리겠습니다.0.1. Uplift ModelingCausal inference에서는 변수들을 크게 treatment, outcome, confounder로 구분합니다. treatment(T)는 원인, outcome(Y)은 결과, confounder(X)는 treatment와 outcome에 모두 영향을 미치는 변수를 의미합니다. Uplift modeling은 개별 또는 그룹 수준에서 treatment의 인과 효과를 추정하고 예측하는 기법입니다 [1][2]. 마케팅에서는 이러한 인과 효과를 uplift라고 부릅니다. 예를 들어, “30대 사용자 A가 평소에 10원을 결제하지만 마케팅 후 100원을 결제한다”라면, treatment는 마케팅 여부, outcome은 결제 금액, confounder는 30대, Uplift는 100–10 = 90이 됩니다. Causal inference 분야에서는 uplift보다 Conditional Average Treatment Effect(CATE)가 더 일반적으로 사용됩니다. 이제부터 인과 효과를 uplift 대신 CATE로 명명하겠습니다.Uplift modeling 문제를 treatment를 받았을경우 전환여부(y축)와 받지 않았을 경우(x축) 두 축으로 나누면 다음과 같이 분류할 수 있습니다.Source: https://ambiata.com/blog/2020-07-07-uplift-modeling/- (1) Sure things(항상 구매 고객): 마케팅과 관계없이, 항상 제품을 구매하는 고객 (CATE = 0)- (2) Persuadables(설득 가능 고객): 마케팅 후, 구매 가능성이 높아진 고객 (CATE > 0)- (3) Lost causes(항상 비구매 고객): 마케팅과 관계없이, 구매 의사가 없는 고객 (CATE = 0)- (4) Sleeping dogs(청개구리 고객): 마케팅 후, 구매 가능성이 낮아진 고객 (CATE < 0)마케팅에서 집중해야 할 그룹은 CATE가 0보다 큰 (2) Persuadables입니다. 다른 그룹들은 마케팅 효과가 없거나 부정적이지만, Persuadables 그룹에서는 긍정적인 효과가 발생합니다. 즉, 해결해야 할 문제는 ”어떤 사용자가 Persuadables인가?”, 즉 “CATE가 큰 사용자가 누구인가?”를 예측하는 것입니다.0.2. Multiple TreatmentsTreatment를 마케팅 금액이고 금액은 1000원, 2000원으로 가정해보겠습니다. 이를 연속형(continuous)으로 볼 수도 있지만, 유니크한 값이 세 개뿐이므로 연속형으로 보는 것은 큰 의미가 없다고 판단했습니다. 또한, 장기적으로treatment를 노출 채널, 발송 요일 등으로 확장할 수 있을 것이라 생각했습니다. 확장성을 고려하면 처음부터 범주형(categorical)으로 정의하는 것이 더 적합하다고 판단했습니다.지금까지 uplift modeling이 해결하고자 하는 문제와 multple treatments로 정의한 이유를 살펴봤습니다. 다음으로는 실제 작업 순서를 따라 1.데이터 수집, 2.모델 학습, 3.Offline 평가, 4.Policy 생성, 5.Online 평가를 설명하겠습니다.1. 데이터 수집모델 학습을 위해 랜덤화(randomization)된 interventional data를 수집합니다. 예를 들어, treatment가 0원, 1000원, 2000원이라면, 사용자에게 이 금액들을 랜덤으로 제공하고 결제 여부를 수집합니다. treatment를 랜덤으로 할당하는 이유는 다른 요인의 영향을 배제하기 위해서입니다. 이를 통해 observational data 분석에서 발생하는 교란(confounding) 문제를 사전에 제거할 수 있습니다. Confounding은 treatment와 outcome 모두에 영향을 미치는 공통 원인이 존재하므로써 발생하는 bias를 의미합니다. [3]예를 들어, “30대 사용자만을 대상으로 결제를 유도하는 마케팅”한 데이터를 사용한다고 가정하면, treatment는 마케팅 노출 여부, outcome은 결제 여부, confounder는 30대가 됩니다. 이 데이터를 그대로 사용해 모델을 만든다면, 30대라는 confounder가 treatment에 영향을 미치는 것을 고려해야 합니다. 순수 마케팅 효과인지 30대라는 특성 때문인지 구분하기 어렵기 때문입니다. 하지만 randomization을 통해 연령대를 포함한 다양한 사용자를 대상으로 하면, confounder가 제거된 상태에서 순수한 마케팅 효과를 측정할 수 있습니다. Observational data에 대한 더 자세한 내용은 강의 영상을 참고해주시기 바랍니다.Source: https://causalinference.gitlab.io/causal-reasoning-book-chapter1/#fig:rct-confounders또한, practical한 측면에서는 “어떤 금액이 가장 효과적인가?”를 미리 확인할 수 있다는 장점이 있습니다. AB test를 수행하는 것과 사실상 동일하기 때문입니다. 예를 들어, 2000원의 전환율이 1000원보다 높지 않다면, 앞으로 개발할 모델에서도 2000원을 배제하고 1000원만 고려하는 것이 ROI 측면에서 효율적일 것입니다. 물론, 이러한 실험은 예산 범위 내에서만 가능합니다.2. 모델 개발Causal inference 분야에서는 CATE를 추정하기 위한 다양한 모델이 있습니다. 아래 표는 Microsoft Research에서 개발한 EconML의 모델 리스트입니다. 저는 최종적으로 Double Machine Learning(DML)을 선택했습니다. DML이 무엇인지, 그리고 왜 선택했는지 설명드리겠습니다.Source: KDD 2021 Introduction to EconML2.1. Double Machine LearningDML은 비모수적(non-parmetric) ML 모델의 유연성을 활용하여 낮은 편향(bias)과 유효한 신뢰 구간을 제공하는 CATE 추정 알고리즘입니다. Debiased machine learning 또는 orthogonal machine learning으로도 알려져 있습니다. [3]DML의 첫 번째 특징은 orthogonalization입니다. 이는 outcome과 treatment에 영향을 미치는 confounder로부터 비인과적 효과를 분리하는 과정입니다. 이 과정에서 xgboost와 같이 비선형 관계를 학습하는 ML 모델을 사용할 수 있습니다. DML에서는 총 3개의 모델이 학습됩니다. Confounder(X)로부터 outcome(Y)를 예측하는 모델 M_y, treatment를 예측하는 모델 M_t, 그리고 residual Y와 residual X로부터 인과효과 τ를 추정하는 M_final입니다. 모델 학습 과정은 다음과 같습니다.Source: Causal Inference for The Brave and True두 번째 특징은 cross-fitting입니다. 이는 cross-validation과 유사한 개념으로, 데이터를 k-fold로 나누어 한 부분에서는 모델을 학습하고 다른 부분에서는 CATE 추정치를 계산합니다. 이를 통해 과적합(overfitting)을 방지할 수 있습니다.https://medium.com/media/b59c44a26a6bd2d58784ba8932cb2850/href“No Free Lunch”라는 말처럼, 모든 데이터에서 DML이 최선은 아닙니다. 하지만 성능뿐만 아니라 범용성을 고려했을 때, DML이 가장 적합하다는 결론을 내렸습니다. DML은 treatment와 outcome이 continuous이든 categorical이든 사용할 수 있고, Multiple treatments/outcomes을 처리할 수 있으며, 이들의 관계를 선형이 아닌 비선형으로 가정할 수 있는 유일한 모델이었습니다.DML의 한계점은 ‘no hidden confounder’ 가정을 만족해야 한다는 점입니다. 순수한 인과효과를 측정 가능하려면 모든 confounder가 데이터에 포함되어 있어야 합니다. 사실 이 가정은 DML뿐만 아니라 모든 causal inference 모델에 해당합니다. DML에 대한 자세한 내용은 책 [3][4] 과 DoubleML 문서 [5]를 참고해주시기 바랍니다.2.2 인과효과 추정(Estimation)Causal inference 문제는 ML의 지도 학습(supervised learning) 문제와 달리 ”정답을 알 수 없다” 는 차이점이 있습니다. 앞서 설명한 것처럼, causal inference의 목표는 outcome 자체를 예측하는 것이 아니라 인과 효과를 추정하는 것입니다. 그러나 인과 효과를 측정하려면 (1) treatment가 있을 때의 outcome과 (2) treatment가 없을 때의 outcome, 두 값을 모두 알아야 합니다. 예를 들어, 사용자 A의 (1) 마케팅 참여 시 결제 여부와 (2) 마케팅에 참여하지 않았을 때의 결제 여부를 알아야 합니다. 하지만 현실에서는 한 사람이 두 상황을 동시에 경험할 수 없습니다. 한 사람은 “마케팅에 참여하거나, 참여하지 않거나” 하나의 경험만 할 수 있습니다. 실제로 마케팅에 참여했다면, 참여하지 않았을 때의 결제 여부는 관측할 수 없는 것이죠. causal inference에서는 이러한 현실과 반대되는 상황을 반사실(counterfactual)이라고 합니다. Counterfactual은 모델 학습 뿐만이 아니라 인과 효과 추정과 평가 단계에서도 기존 ML과 다른 접근을 요구합니다. Counterfactual에 대한 더 자세한 내용은 강의 영상을 참고해주시기 바랍니다.Treatment가 binary일 경우, CATE의 수식과 인과 효과 추정 프로세스는 다음과 같습니다. 기존의 머신러닝 문제에서는 Y만 예측하지만, CATE 추정에서는 counterfactual도 예측해야 한다는 차이점이 있습니다.CATE = E[Y(T=1) − Y(T=0) | X]Sourec: https://arxiv.org/pdf/2109.12769Multiple treatments에서는 T=0, 1000, 2000 각각에 대해 CATE를 추정합니다. 모델로부터 각 treatment에 대한 score를 예측하고, treatment=0일 때의 score와의 차이를 계산하여 CATE를 구합니다. 더 자세한 이론은 논문 [2]와 EconML 코드을 참고해주시기 바랍니다.3. Offline 평가앞서 causal inference는 인과효과를 추정하는 문제이며, 현실에서는 counterfactual을 관측할 수 없어 정답인 인과효과를 알 수 없다고 설명했습니다. 그렇다면, 정답을 알 수 없는 상황에서 모델의 성능을 어떻게 평가할 수 있을까요? Causal inference에서는 여러 접근들이 있는데요, counterfactual을 control 그룹의 평균값으로 보는 접근이 있습니다. Binary outcome에 대한 대표적인 평가지표로 Area Under Uplift Curve (AUUC) 가 있습니다. AUUC는 Area Under the ROC curve(AUC) 와 매우 유사한 metric입니다. 차이점은 각 점수의 임계값마다 uplift를 계산한다는 점입니다. uplift는 treatment 그룹과 control 그룹(treatment=0)의 precision 차이를 계산합니다.Source: (2016) Causal Inference and Uplift Modeling A review of the literatureSource: https://medium.com/data-reply-it-datatech/uplift-modeling-predict-the-causal-effect-of-marketing-communications-24385fb04f2eAUUC를 통해 랜덤 모델 또는 다른 모델과의 성능 차이를 확인할 수 있습니다. AUUC에 대한 자세한 내용은 책 [7]과 causalml 코드를 참고해주시기 바랍니다.4. Policy 생성여기까지 진행했다면, 여러 treatment에 대한 개인화된 CATE 값을 확보했을 것입니다. 이제 ”여러 treatment 중 어떤 treatment를 선택해야 하는가? 마케팅 금액으로 얼마를 제공해야 하는가?” 에 대한 답을 찾기 위해 최적의 treatment를 선택하는 과정이 필요합니다.첫 번째로, CATE 값이 가장 큰 treatment를 선택했습니다 [2]. ‘Persuadables’ 고객일 확률이 높은 사용자를 선택하는 것이 타당한 접근이라 생각했습니다. 두 번째로, treatment 간의 CATE 차이를 고려했습니다. 마케팅에서는 큰 금액을 제공할수록 전환율이 높아지는 경향이 있었습니다. 한정된 마케팅 예산을 효율적으로 사용하기 위해, 가장 큰 금액의 treatment는 일부 사용자에게만 제공하도록 했습니다. 이를 위해 ‘CATE gap’ 룰을 휴리스틱하게 설정했습니다. CATE gap은 큰 treatment의 CATE와 작은 treatment 의 CATE 차이를 의미합니다. CATE gap이 큰 사용자에게는 더 큰 금액의 treatment를 할당했습니다.예를 들어, 사용자 A와 B의 CATE를 다음과 같다고 가정합니다:사용자A: CATE_A(T=1000) = 0.7, CATE_A(T=2000) = 0.8사용자B: CATE_B(T=1000) = 0.1, CATE_B(T=2000) = 0.8단순히 CATE만 고려하면 두 사용자 모두에게 2000원을 제공하는 것이 최선입니다. 하지만 1000원 마케팅 효과는 사용자 A는 CATE gap이 0.1로 작지만, 사용자 B는 0.7로 더 큰 값을 가집니다. 따라서 2000원을 제공해야 하는 한 명을 선택해야 한다면, gap이 큰 사용자 B를 선택하는 것이 더 비용효율적입니다. 사용자 A는 1000원을 제공해도 충분히 높은 효과를 기대할 수 있기 때문입니다.5. Online 평가실제 서비스에 마케팅을 적용하고 A/B 테스트를 진행했습니다. 마케팅 비용 최적화가 목표였으므로 비즈니스 KPI로 Cost Per Acquisition(CPA)을 선정했습니다. Control 그룹은 프로모션 금액을 랜덤으로 제공했습니다. Online 평가에서는 treatment 그룹과 control 그룹의 CPA를 비교하여 ”마케팅 비용이 얼마나 절감되었는지” 를 검증했습니다. Treatment와 control 그룹의 CPA를 계산한 뒤, treatment 노출 사용자 수에 control CPA를 곱하여 모델이 적용되지 않았을 경우의 전환 유저 수를 추정했습니다. 그리고 treatment 그룹의 총 비용과 control 그룹의 추정된 총 비용을 비교하여 모델이 비용 절감에 기여했음을 확인했습니다. 아래 표는 가상의 데이터로 계산한 예시입니다. CPA가 treatment 그룹은 1,429원, control 그룹은 1,643원으로 한사람당 마케팅 비용을 214원 절감 했음을 확인할 수 있습니다.후기지금까지 multiple treatments를 다룬 uplift modeling에 대해 살펴보았습니다. 프로젝트를 진행하면서 느낀 점은 confounder를 얼마나 잘 선정했느냐가 가장 중요하다는 것입니다. 아무리 causal inference 모델이 인과효과를 추정하더라도, 의미 있는 confounder가 포함되지 않으면 이는 일반적인 ML 모델과 다를 바가 없게 됩니다.저희 팀은 uplift modeling과 같은 causal inference뿐만 아니라, causal discovery를 통한 product analysis case 같은 의사결정에 도움이 되는 모델도 개발하고 있습니다. 기회가 된다면 causal discovery 관련 사례도 공유드릴 수 있길 기대해봅니다. 프로젝트에 도움을 주신 분들께 감사드리며, 긴 글 읽어주셔서 감사합니다.Reference[1] (2016) Causal Inference and Uplift Modeling A review of the literature[2] (2020) Uplift Modeling for Multiple Treatments with Cost Optimization[3] (2024) 인과관계 추론과 발견 with Python[4] https://matheusfacure.github.io/python-causality-handbook/22-Debiased-Orthogonal-Machine-Learning.html[5] https://docs.doubleml.org/stable/guide/basics.html[6] 2–1. 잠재적결과 프레임워크, Korea Summer Workshop on Causal Inference 2022[7] https://matheusfacure.github.io/python-causality-handbook/19-Evaluating-Causal-Models.html[8] https://medium.com/@playtika-tech-ai/analyzing-uplift-models-a4fbbf4c8ba9Uplift Modeling을 통한 마케팅 비용 최적화 (with Multiple Treatments) was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

상태 관리 라이브러리 vanilla-store
네이버 페이
상태 관리 라이브러리 vanilla-store

상태 관리 라이브러리 vanilla-store를 소개합니다!안녕하세요. 내자산&회원FE팀 김현석입니다. 리액트를 사용하는 개발자들에게 상태 관리는 항상 중요한 고민거리입니다. 갈수록 커지고 복잡해지는 앱은 상태 관리를 더 어렵게 만들고 있으며 이를 효과적으로 다루기 위해 상태 관리 라이브러리를 사용해 개발하는 경우가 많습니다. Redux, MobX, Recoil, Jotai 등 많은 상태 관리 라이브러리들이 있고 네이버페이에서도 각 팀의 필요에 맞는 라이브러리를 선택해 개발하고 있습니다.제가 속한 팀에서는 mobx를 주로 상태 관리라이브러리로 사용했습니다. mobx는 다른 라이브러리에 비해 보일러 플레이트 코드가 적고, observer로 컴포넌트를 감싸주기만 하면 상태가 변했을 때 알아서 업데이트를 수행합니다. 이러한 간결함 덕분에 비즈니스 로직에 집중할 수 있는 장점이 있었고 많은 서비스들을 mobx를 사용해 개발했었습니다.그러던 중 담당 업권 서비스가 복잡해지면서 별도 레포로 이관하게 되었는데 이 과정에서 불필요한 의존성을 줄이자는 기술적인 목표를 설정했습니다. 상태 관리 라이브러리도 그 대상으로 최대한 react-api를 활용하면 이를 대체할 수 있지 않을까라는 생각으로 작업을 진행했습니다.생각보다 많은 부분들을 react-api를 통해 대체할 수 있었습니다. 하지만 작업을 진행하면서 mobx의 부재가 느껴지는 부분이 존재했고 결국 상태 관리 라이브러리의 기능이 필요하다는 결론을 내렸습니다. 다만 mobx를 다시 도입할 정도로 큰 문제는 아니었기에 지금 우리에게 필요한 기능만을 가진 간단한 상태 관리 라이브러리를 만들어 적용하면 어떨까라는 생각으로 vanilla-store를 만들게 되었습니다. 아래 글을 통해서 vanilla-store의 내부 구현, 사용 예제, 실제 사용 예시를 소개합니다.useSyncExternalStore본격적으로 vanilla-store에 대해서 소개하기에 앞서서 리액트의 상태 관리에 대해서 살펴보겠습니다.리액트 생태계 내부에서의 상태 관리는 우리가 익히 알고 있는 `useState, this.setState`등을 통해 이루어집니다. 상태 값이 바뀌면 해당 useState를 포함한 컴포넌트가 리렌더링되어 변경된 상태를 형상에 반영합니다.그럼 리액트 생태계 외부에서는 어떻게 상태 관리를 하면 될까요? 아래 코드처럼 외부에 상태 관리를 해도 될까요?let count = 0function Component() { function handleClick() { count += 1 } return <div onClick={handleClick}>{count}</div>}짐작하셨겠지만 전역변수 count가 바뀌어도 형상에는 반영되지 않습니다. 리액트는 아래 경우에서만 리렌더링을 트리거 합니다.- 클래스 컴포넌트에서 `this.setState`,`forceUpdate` - 함수 컴포넌트에서 `useState`, `useReducer` - 컴포넌트의 props가 변경되는 경우- 부모 컴포넌트가 리렌더링 된 경우리액트 생태계 외부의 상태 값이 바뀌어도 리액트 업데이트 큐에 반영되지 않기에 형상이 바뀌지 않는 것이죠. 외부의 상태가 바뀌었을 때 리액트에 이를 알려주고 생명주기에 맞게 업데이트해주는 무언가가 필요합니다. 리액트는 이를 지원하기 위해 useSyncExternalStore 훅을 제공합니다.useSyncExternalStore는 react18에서 새롭게 도입된 훅으로 외부 스토어와의 동기화를 간편하게 처리할 수 있게 해줍니다.useSyncExternalStore( subscribe: (callback) => Unsubscribe getSnapshot: () => State getServerSnapshot?: () => State) => StateuseSyncExternalStore는 세 매개변수를 받습니다.subscribe는 callback을 받아 등록하고 cleanup으로 등록한 callback을 제거하는 함수입니다. 말이 좀 복잡한데 매개변수로 넘어오는 callback이 리액트의 리렌더링을 트리거 하는 요소로 외부 스토어에서 이를 저장했다가 상태가 변경될 때 callback을 실행해 리액트와 싱크를 맞출 수 있게 합니다.getSnapshot은 렌더링 된 이후 값이 변경되었는지, 문자나 숫자처럼 immutable 한 값인지 확인하는데 사용됩니다. useSyncExternalStore은 이 확인이 끝난 immutable 한 값을 반환합니다.옵셔널 하게 받는 getServerSnapshot은 이름처럼 getSnapshot과 같지만 서버 렌더링의 안정성을 보장하기 위해 사용합니다.useSyncExternalStore는 또한 react18에서 concurrent 렌더링 사용 시 발생할 수 있는 ui 불일치 문제 tearing 을 해결해 줍니다.https://github.com/reactwg/react-18/discussions/69#discussion-3450021 / react disscussionreact18 이전까지는 위 그림처럼 동기적 렌더링만 지원되었습니다. 따라서 바뀐 상태에 맞게 일관된 형상을 노출할 수 있었습니다.https://github.com/reactwg/react-18/discussions/69#discussion-3450021 / react disscussion이와 다르게 concurrent 렌더링은 다른 우선순위가 높은 작업을 먼저 수행하기 위해 현재 렌더링 작업을 일시 중단시킬 수 있습니다.위 그림에서 모든 노드가 파란색이 되기 전 렌더링이 중단되고 그사이 바뀐 빨간색으로 모든 노드의 색이 불일치하는 걸 볼 수 있습니다. 렌더링이 비동기적으로 이루어지고 외부 스토어의 값이 그 사이에 변경되었을 때 문제가 발생하게 되는 것입니다.useSyncExternalStore는 React 18의 concurrent 렌더링 환경에서도 외부 스토어와의 동기화를 보장합니다. 이는 React 18 이전의 동기적(blocking) 렌더링과 유사한 일관성을 제공하여 tearing 문제를 해결합니다.내부적으로 변경사항을 dom에 적용하기 직전에 getSnapshot을 한 번 더 호출하여 처음 호출했을 때와 다른 값을 반환하면 (tearing 문제가 발생했다면) 업데이트를 다시 처음부터 시작해 일관된 형상을 노출할 수 있게 합니다.useSyncExternalStore는 react 18의 concurrent 렌더링을 상정하고 만들어졌기에 그 이전 버전에서는 사용할 수 없습니다. 문제는 react 18로의 전환은 breaking change가 많기 때문에 라이브러리 개발자의 부담이 크다는 것입니다. 이를 해결하기 위해 리액트 팀에서는 react 17 이하에서도 사용할 수 있는 useSyncExternalStore shim 패키지를 제공해 점진적인 마이그레이션을 지원합니다.상기한 이점들로 인해 recoil, zustand 등 많은 상태 관리 라이브러리들에서도 내부에 useSyncExternalStore를 사용하는 것을 볼 수 있습니다. 그리고 이제 소개할 vanilla-store도 useSyncExternalStore를 사용해 구현되었습니다!vanilla-store만들게 된 배경mobx를 제거하고 react-api useContext, useReducer 등을 사용해 상당 부분 상태 관리 라이브러리를 대체할 수 있었지만 동시에 문제점도 나타났습니다. 나빠진 가독성과 과도해진 보일러 플레이트 코드 작성이었습니다.컴포넌트에서 useContext를 사용하려면 반드시 부모 노드에 provider가 있어야 합니다. 여러 컴포넌트들에서 useContext가 사용된다면 그들 간의 공통 부모 노드에 provider가 위치 해야 하고 경우에 따라 최상위 노드까지 provider의 위치가 올라가게 됩니다.팀에서 nextjs를 사용 중이었기 때문에 여러 페이지에 동일한 provider를 적용하는 일이 발생했고, 반복을 줄이기 위해 _app 파일로 provider를 올리면 getInitialProps가 비대해지는 문제가 발생했습니다.또한 페이지에 따라 불필요한 로직, provider가 _app 파일에 추가되는 것이니 성능적으로도 좋을 것이 없었고 이 코드가 현재 사용 중인지 로직을 따라가기에도 어려움이 생겼습니다. 적은 보일러 플레이트 코드를 가지고 여러 컴포넌트에서 전역 상태에 접근할 수 있는 무언가가 필요했습니다. 다시 상태 관리 라이브러리를 사용하면 해결되는 문제였지만 우리에게 필요한 기능에 비해 라이브러리가 컸고 이 정도 기능이라면 만들어서 써도 되지 않을까 하는 호기심에 vanilla-store를 만들게 되었습니다.내부 구현과 인터페이스 소개vanilla-store github linkvanilla-store는 스토어 생성 함수 createVanillaStore와 사용 hooks useStore로 구성되어 있습니다.createVanillaStore는 매개변수로 상태 값과 persist 옵션을 옵셔널로 받습니다. 이중 옵션은 아래에서 다루고 먼저 상태 값부터 보겠습니다const createVanillaStore = <State>(initialState: State, options?: Options<State>): VanillaStore<State> => { …}const initState = { count : 0, name : 'npay'}const store = createVanillaStore(initState)initialState는 store로 관리하려는 상태의 초깃값을 의미합니다. 이때 초깃값 선언과 createVanillaStore 실행은 컴포넌트 외부와 같이 반복적으로 실행될 여지가 없는 독립적인 환경에서 수행해야 합니다. 그래야 올바르게 초기화된 하나의 객체가 생성될 수 있기 때문입니다. createVanillaStore 내부를 좀 더 자세히 살펴보겠습니다.createVanillaStoreconst createVanillaStore = <State>(initialState: State, options?: Options<State>): VanillaStore<State> => { // 초기 상태값 할당 let state = initialState // set실행시 수행할 callback 함수 저장 set. 여기에 useSynExternalStore의 callback이 저장됨. const callbacks = new Set<() => void>() // 상태값 반환 함수. useSyncExternalStore의 snapShot으로 전달 const get = () => state // 상태값 변경 함수. useState처럼 상태값 혹은 함수를 받음 const set = (nextState: State | ((prev: State) => State)) => { // 서버에서의 사용을 허용하지 않는 가드문 if (typeof window === 'undefined') { throw new Error('This function is not available in Server side.') } // 상태값 혹은 함수 여부에 따라 다르게 state 값 갱신 state = typeof nextState === 'function' ? (nextState as (prev: State) => State)(state) : nextState // set에 저장되어있는 모든 callback 함수 실행. 이 동작으로 리액트 리렌더링이 트리거 callbacks.forEach((callback) => callback()) return state}… // useSyncExternalStore에 전달할 subscribe 함수 const subscribe = (callback: () => void) => { // useSyncExternalStore로 부터 전달받은 callback을 set자료구조에 저장 callbacks.add(callback) return () => { // cleanup 함수에서 등록했던 callback 제거 callbacks.delete(callback) } } return {get, set, subscribe, persistStore}}createVanillaStore는 상태 snapShot을 반환하는 get, 상태를 갱신하는 set, useSyncExternalStore에 전달할 subscribe, persist option 활성화 시 사용되는 persistStore 네 가지 값을 반환합니다.subscibe 함수는 useSyncExternalStore에 전달되어 컴포넌트 리렌더링을 트리거 하는 함수를 callback으로 받게 됩니다. 그리고 이를 callbacks 자료구조 Set에 등록하고 set 함수가 실행될 때마다 등록된 모든 callback 함수들을 실행합니다.즉, 상태 값이 바뀌는 set 함수가 실행될 때 컴포넌트 리랜더링을 트리거 하여 바뀐 상태로 형상을 갱신하게 됩니다. 이렇게 해서 외부 스토어와 리액트 간 싱크를 맞출 수 있게 됩니다.useStoreconst useStore = <State>(store: VanillaStore<State>, initialValue?: State) => { // useSyncExternalStore의 명세에 맞게 subscribe, snapShot을 전달하고 immutable한 상태값을 반환 const value = useSyncExternalStore(store.subscribe, store.get, () => initialValue || store.get()) … return [value, store.set] as const}useStore는 createVanillaStore에서 반환하는 subscribe, snapshot을 useSyncExternalStore에 전달하는 함수입니다. useSyncExternalStore를 통해 안전하게 관찰되는 상태 값과, 스토어에서 전달받은 set 함수를 반환합니다.기본 사용 예시// 초기 상태값 정의const initState = { count : 0, name : 'npay'}// createVanillaStore로 vanillaStore객체 생성const store = createVanillaStore(initState)export default function MyAppCount() { // useStore훅에 vanillaStore객체를 전달해 immutable한 상태값, setter함수를 반환 const [state, setState] = useStore(store) const handleClick = () => { // 상태값 변경 파라미터로 함수 전달. count 필드 값 변경 setState((prev) => ({…prev, count+1})) } return <div onClick={handleClick}>{state.count}</div>}export default function MyAppName() { const [state, setState] = useStore(store) const handleClick = () => { // name 필드 값 변경 setState((prev) => ({…prev, name:'point'})) } return <div onClick={handleClick}>{state.name}</div>}우리에게 익숙한 useState처럼 useStore를 통해 상태에 접근하고 변경할 수 있게 됩니다. 또한 useContext처럼 provider의 제약 없이 어떤 컴포넌트든 useStore를 통해 자유롭게 상태에 접근할 수 있습니다.다만 여기서 한 가지 아쉬운 부분이 생깁니다. 위 예시에서 MyAppCount 컴포넌트의 handleClick이 실행되면 count 값이 변경되면서 리렌더링이 트리거 될 것입니다.문제는 count를 사용하지 않는 MyAppName 컴포넌트 역시 store에서 관찰하는 상태가 변경되었기 때문에 마찬가지로 리렌더링이 트리거 됩니다. MyAppName 컴포넌트 입장에서는 불필요한 리렌더링이 실행되는 것이죠.이 문제를 해결하기 위해 vanilla-store에서는 useStoreSelector hooks를 준비했습니다.useStoreSelectorfunction useSyncExternalStoreWithSelector<Snapshot, Selection>( subscribe: (onStoreChange: () => void) => () => void getSnapshot: () => Snapshot, getServerSnapshot: undefined | (() => Snapshot), // 관찰하고 싶은 필드를 특정하기위한 필터링 함수 selector: (snapshot: Snapshot) => Selection, // 필드가 변경되었는지 비교하기위한 함수. 별도로 정의해주지 않으면 내장된 shallowEqual 함수로 비교연산 수행 isEqual: (a: Selection, b: Selection) => boolean = shallowEqual, ): Selection { // selector를 통해 전체 상태 값 중 관찰하고자하는 필드를 특정해 초기값 할당 const initialSelection = selector(getSnapshot()) // 위에 특정된 값을 리렌더링 이후 비교하기위해 useRef에 저장 const stateRef = useRef<Selection>(initialSelection) // useSyncExternalStore는 상태가 변경되면 변경된 상태값을 반환 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) // useSyncExternalStore에서 반환된 상태값을 필터링 해 관찰하고 싶은 필드를 특정 const selection = selector(snapshot) // useSyncExternalStore 수행 전, 후 필드 값을 비교 if (!isEqual(selection, stateRef.current)) { // 만약 필드값이 변경되었다면 useRef 값 변경 stateRef.current = selection } // useRef의 current가 갱신된 경우에 반환값이 바뀌어 컴포넌트 리렌더링을 트리거 return stateRef.current}const useStoreSelector = <State, Value>( store: VanillaStore<State>, selector: (state: State) => Value, options?: {initialStoreValue?: State; isEqual?: (a: Value, b: Value) => boolean},) => { const {initialStoreValue, isEqual} = options || {} // useSyncExternalStore에 selector, isEqual만 파라미터로 추가됨 const value = useSyncExternalStoreWithSelector( store.subscribe, store.get, () => initialStoreValue || store.get(), selector, isEqual, ) return [value, store.set] as const}코드가 다소 복잡해 보이지만 내용은 간단합니다. useStore처럼 useSyncExternalStore를 통해 상태를 관찰하고 리렌더링을 트리거 하지만 selector를 통해 관찰하고자 하는 특정 필드를 선택하고 useRef를 통해 이를 저장합니다.이후 상태가 바뀌어 useSyncExternalStore의 반환값이 바뀌면 마찬가지로 selector를 통해 특정 필드를 선택하고 useRef의 값과 비교해 해당 필드가 변경된 경우에만 useRef의 값을 갱신합니다.이때 비교 함수 isEqual은 리액트의 shallowEqual처럼 동작하도록 구현했습니다. 비교하려는 필드가 중첩된 객체여도 key와 참조 값 value만을 비교할 뿐 하위 객체를 모두 순회해서 내부 필드가 바뀌었는지 까지는 판단하지 않습니다.조금 부정확할 순 있겠지만 비용이 큰 비교 연산을 절약해 성능상의 이점을 가져가기 위함입니다.useStoreSelector 사용 예시// 초기 상태값 정의const initState = { count : 0, name : 'npay'}// vanillaStore 객체 생성const store = createVanillaStore(initState)export default function MyAppCount() { // vanillaStore객체와 selector 전달. selector함수에서 count 필드를 관찰 // MyAppCount 컴포넌트에서는 전체 상태값 중 count 필드가 변경될 때에만 리렌더링을 트리거하게함 const [count, setState] = useStoreSelector(store, (state) => state.count) const handleClick = () => { // count의 상태값을 변경 setState((prev) => ({…prev, count+1})) } return <div onClick={handleClick}>{count}</div>}export default function MyAppName() { // selector함수에서 name 필드를 관찰 // MyAppName 컴포넌트에서는 전체 상태값 중 name 필드가 변경될 때에만 리렌더링을 트리거하게함 const [name, setState] = useStoreSelector(store, (state) => state.name) const handleClick = () => { // name의 상태값을 변경 setState((prev) => ({…prev, name:'point'})) } return <div onClick={handleClick}>{name}</div>}앞서 useStore를 사용했을 때 불필요한 리렌더링을 만들었던 부분을 useStoreSelector를 통해 개선한 모습입니다.useStoreSelector의 두 번째 인자로 selector 함수를 넘겨서 컴포넌트에서 관찰하려는 값을 한정하는 것을 볼 수 있습니다. 이제 MyAppName 컴포넌트는 count 상태 값이 바뀌어도 불필요한 리렌더링 수행하지 않게 됩니다!persist optionvanilla-store의 useStore에는 localStorage, sessionStoreage를 사용하는 persist option을 지원합니다. 이 기능은 페이지 이동, 새로고침을 해도 상태 값을 유지해야 하는 경우에 유용합니다.const createVanillaStore = <State>(initialState: State, options?: Options<State>): VanillaStore<State> => { // 상태값 초기화 및 할당 let state = initialState // localStorage or sessionStorage 저장소를 할당할 변수 let persistStore: Persistent<State> | null = null // 상태값 변경 시 수행할 callback함수 저장 set. // persistent옵션을 활성화한 경우 persistStore에 변경된 상태값을 할당하는 callback이 저장됨 const callbacks = new Set<() => void>()… // persist 옵션이 있는 경우에만 실행 if (options?.persist) { // persistent store에 저장된 value에 접근하기위한 key값 할당 const key = options.persist.key // 옵션에 전달한 타입이 localStorage인 경우 아래 조건문 실행 if (options.persist.type === 'localStorage') { // LocalStoragePersist 인스턴스 할당. 내부에 구현된 window.localStorage 사용 편의를 위해 만든 클래스 persistStore = new LocalStoragePersist(key, initialState, options.persist.typeAssertion) // callback으로 localStorage에 변경된 상태값을 반영하도록 하는 함수 저장. callbacks.add(() => { if (persistStore) { persistStore.value = get() } }) } // 옵션에 전달한 타입이 sessionStorage인 경우 아래 조건문 실행 if (options.persist.type === 'sessionStorage') { // SessionStoragePersist 인스턴스 할당. 내부에 구현된 window.sessionStorage 사용 편의를 위해 만든 클래스 persistStore = new SessionStoragePersist(key, initialState, options.persist.typeAssertion) // callback으로 sessionStorage에 변경된 상태값을 반영하도록 하는 함수 저장. callbacks.add(() => { if (persistStore) { persistStore.value = get() } }) } }… return {get, set, subscribe, persistStore}}// persist 옵션으로 type, key 전달// isStoreValue : 저장될 값 vanildation하는 함수. persistStorage에 저장하기 전 타입 에러 방지를 위해 검증하는 용도로 사용const store = createVanillaStore(initState, persist: {type: 'sessionStorage', key: 'sessionStorageKey', typeAssertion: isStoreValue})options에서 type을 통해 어떤 persist 저장소를 사용할지 선택하고 callbacks에 저장소 값을 변경하는 함수를 등록합니다. 이를 통해 상태가 변경되면 등록된 callback이 실행되어 컴포넌트 리렌더링 트리거와 함께 persist 저장소의 값을 갱신하게 됩니다.실제 사용하고 있는 서비스 예시vanilla-store는 네이버페이 내 자산에 수입 지출 서비스에 적용되어 있습니다. 수입 지출 서비스 내에서도 많은 부분들에 사용되고 있는데 이중 session storage를 사용하는 예시를 소개해드리려 합니다.수입 지출 서비스에는 여러 거래내역이 묶인 복합결제 거래내역이 있는데 클릭 시 세부 거래내역을 노출합니다. 거기에 새로고침을 해도 세부 내역 노출을 유지해야 하는 흔히 볼 수 있는 스펙입니다. 이를 useStore의 persist 옵션을 사용해 구현했습니다const sessionStorageKey = 'sessionStorageKey'type UnfoldStatus = Record<string, boolean>// 상태 초기 값. key로 거래내역의 id, value로 접힘여부를 판단하는 boolean값을 사용const initValue: UnfoldStatus = {}// vanillaStore persist 옵션 넣어서 생성export const unfoldStatusStore = createVanillaStore(initValue, { // sessionStorage 사용, sessionStorageKey를 key로 sessionStorage에 저장된 값 접근 persist: {type: 'sessionStorage', key: sessionStorageKey, typeAssertion: isStoreValue},})function ItemComponent() { const [status, setStatus] = useStore(unfoldStatusStore) …}해당 묶음 거래내역의 id를 key로 가지는 상태를 만들어 sessionStorage에 저장해 key가 있다면 세부 내역을 노출하도록 구현했습니다.복합결제 거래내역복합결제의 세부 내역까지 노출된 형상개발자 도구를 통해 클릭에 따라 sessionStorage 값이 바뀌는 것을 볼 수 있습니다.예시의 수입 지출 외에 네이버페이 내 자산의 다양한 서비스에도 vanilla-store가 적용되어 있습니다!다른 상태관리와 비교vanilla-store의 가장 큰 특징은 가벼움에 있습니다. 배경 자체가 기본 react api의 약간의 아쉬움을 보완하는 것에서 출발했기 때문에 간결한 전역 상태 관리라는 기능에 집중해 번들사이즈가 작습니다.bundle phobia를 통해 상태 관리 라이브러리 recoil, jotai 와 비교해 봤을 때 vanilla-store의 번들 사이즈가 훨씬 작음을 볼 수 있습니다.naverpay/vanilla-storerecoiljotai또한 직접 내부를 js로 구현했기 때문에 별도 package.json의 dependencies가 없습니다. 이 때문에 다른 패키지와의 의존성 문제에서 자유로울 수 있습니다.다만, 리액트 환경만을 지원하고 위에 소개한 useSyncExternalStore를 내장하고 있기에 peer dependencies로 react 18 조건을 가지고 있는 부분은 주의해야 합니다.앞으로 더 개발할 것들실 서비스에서 vanilla-store를 적용하면서 몇 가지 아쉬운 부분이 있었습니다. 개발 과정에서 스토어 내부의 상태를 추적하기 번거로웠던 것입니다. 지금의 vanilla-store는 값이 어떻게 바뀌는지 확인하려면 직접 console.log를 찍어봐야 알 수 있습니다.작은 규모로 사용할 때는 흐름을 따라가기 쉽지만 크고 복잡한 서비스에서는 이 부분이 아쉬울 수 있겠다는 생각을 했습니다. 그래서 persist만 있는 options에 디버깅 옵션을 추가할 예정에 있습니다.또 하나의 아쉬운 점은 ssr 지원을 하지 않는 것입니다. 저희 팀 서비스들은 대부분 nextjs의 ssr을 적극적으로 사용하고 있는데 vanilla-store는 처음엔 클라이언트 사이드에서만 사용하는 것을 상정하고 제작했기 때문에 ssr 시점에는 동작하지 않습니다.이 부분을 보완하기 위한 패키지 내부에서의 provider 제공 작업도 예정되어 있습니다. 위 예시들뿐만 아니라 vanilla-store를 사용하면서 부족한 부분들이 보이면 계속해서 보완해 나갈 예정입니다.마치며지금까지 vanilla-store에 배경과 구현, 실제 적용된 예시들을 살펴봤습니다. 서비스 스펙이 아닌 기술을 위한 개발을 해보고 실 서비스에 적용해 보는 건 색다른 재미를 줬던 것 같습니다. 특히 간단한 상태 관리 기능에만 집중하긴 했지만 생각보다 쉽게 기존의 상태 관리 라이브러리를 대체할 수 있다는 것이 신기했습니다.여기에는 몇 가지 이유가 있다고 생각하는데 기존에도 서비스에서 상태 관리 라이브러리의 일부 간단한 기능만을 사용했다는 점, useSyncExternalStore가 복잡해질 수 있는 리액트와 외부 저장소 간의 싱크 문제를 매우 간단하게 처리해 준 점입니다.저와 비슷한 고민을 하는 분이 있다면 vanilla-store가 작게나마 도움이 되었기를 희망하면서 글을 마치겠습니다. 네이버페이는 서비스 개발뿐만 아니라 다양한 기술적 도전들에 열려있습니다. vanilla-store도 팀에서 다양한 개발을 지원하고 독려하는 분위기가 있어서 끝까지 개발을 추진할 수 있던 것 같습니다. 이런 도전적 문화와 사람들 속에서 함께 성장할 동료를 찾고 있습니다! https://recruit.naverfincorp.com/상태 관리 라이브러리 vanilla-store was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

Spring Security 의 인증 알아보기
네이버 페이
Spring Security 의 인증 알아보기

안녕하세요. 네이버페이 회원&인증BE 의 최용화입니다.Spring Security는 강력한 보안 프레임워크로서, 애플리케이션의 인증과 인가 과정을 효율적으로 관리합니다. 저희 팀에서는 다음과 같은 기능을 구현하는 데에 Spring Security 를 사용하고 있습니다.인증 여부 확인 (인증이 안된 사용자일 경우 로그인 및 가입 유도 / 인증이 완료된 사용자일 경우 적절한 권한 부여), 권한을 이용한 접근 제어(권한이 없는 사용자일 경우 권한 획득을 위한 절차 수행), 보안 공격으로부터 보호(CSRF 공격 방지), PC / MOBILE 최적화 페이지 제공(사용자가 접근한 환경을 파악하여 최적화된 페이지 제공)이 글에서는 Spring Security의 인증(Authentication) 과정 전반을 살펴보고, 각 단계의 역할과 작동 방식을 자세히 알아보겠습니다.이 글은 Spring Security 6.3.0 공식문서를 기반으로 작성되었습니다.Spring Security 의 Filter 기반 동작 방식 이해Spring Security의 인증 수행을 이해하려면 Spring Security 의 구조에 대한 이해가 선행되어야 합니다. Spring Security는 Servlet Filter 기반으로 동작합니다. 여기서, 중요한 개념인 FilterChainProxy, SecurityFilterChain, 보안 필터(Security Filter)에 대해 알아봅니다.FilterChainProxy의 개념FilterChainProxy는 Spring Security에서 제공하는 특수한 Filter로 SecurityFilterChain을 사용하여 다양한 보안 필터가 동작하게 합니다.사실, Servlet Container의 라이프사이클과 Spring의 ApplicationContext 사이를 연결하는 DelegatingFilterProxy 라는 필터 구현체도 중요한 상위 개념이나 이 글에서는 설명을 생략합니다. 자세한 설명은 이 페이지를 참고하세요.SecurityFilterChain의 개념SecurityFilterChain은 Spring Security에서 보안 필터(Security Filter)의 체인을 정의하는 데 사용됩니다. 요청이 애플리케이션의 Servlet에 도달하기 전에 다양한 보안 검사를 수행하는 필터들이 있으며, 이를 보안 필터라고 부릅니다. SecurityFilterChain은 각 보안 필터가 순차적으로 실행되도록 하여 애플리케이션의 보안 설정을 체계적으로 관리할 수 있게 합니다.보안 필터(Security Filter)의 주요 기능SecurityFilterChain 에 선언된 다양한 보안 필터를 통해 아래의 기능을 수행하게 됩니다.인증(Authentication): 사용자의 신원을 확인합니다. 예를 들어, 사용자가 로그인 폼을 제출하면, 이를 처리하는 필터가 실행됩니다.인가(Authorization): 사용자가 요청한 리소스에 접근할 권한이 있는지 확인합니다.각종 보안 공격으로부터 보호(Protection Against Exploits): CSRF 공격, Session Fixation 공격, sniffing 공격, Clickjacking 등의 보안 공격으로부터 보호합니다.세션 관리: 사용자의 세션을 생성, 관리, 종료하는 과정입니다.기타 기능: HTTP 응답 헤더를 설정하여 보안을 강화하는 기능, Remember Me 기능 등을 지원합니다.보안 필터(Security Filter) 소개여러 개의 보안 필터가 있지만, 이 글에서 자주 보게 될 몇 가지 보안 필터만 가볍게 소개 드리려고 합니다. 나열된 순서대로 실행됩니다.UsernamePasswordAuthenticationFilter: 폼 기반 로그인 처리를 수행합니다.DefaultLoginPageGeneratingFilter: 기본 로그인 페이지를 생성합니다.ExceptionTranslationFilter: ExceptionTranslationFilter 의 다음 Filter 에서 발생한 Exception을 처리하고 이에 대한 적절한 응답을 반환합니다.AuthorizationFilter: 사용자가 요청한 리소스에 대해 접근 권한이 있는지 확인합니다. 권한이 없는 경우 접근을 거부하고, 적절한 에러 페이지를 반환하거나 예외를 발생시킵니다.Spring Security의 Form 기반 인증인증(Authentication)은 특정 리소스에 액세스하려는 주체(Principal)의 신원을 확인하는 과정입니다. Spring Security는 다양한 인증 방법을 지원하며, 이 글에서는 주로 폼 기반 인증(Form-Based Authentication)을 예로 들어 설명하겠습니다.인증 관련 주요 용어Spring Security 인증과 관련하여 자주 사용되는 용어에 대해 설명합니다.SecurityContextHolder: Spring Security가 인증된 사용자의 정보를 저장하는 곳입니다.SecurityContext: SecurityContextHolder에서 가져오며 현재 인증된 사용자의 인증정보(Authentication)를 포함합니다.Authentication: 사용자가 입력한 자격 증명(Pricipal과 Credentials)을 AuthenticationManager에 전달하는 용도로 사용되거나 SecurityContext에서 현재 사용자를 나타내는 용도로 사용되는 객체입니다.GrantedAuthority(Authorities): 인증된 사용자에게 부여된 권한을 나타내며, 역할(role)이나 범위(scope) 등을 포함합니다.AuthenticationManager: Spring Security의 필터가 인증을 수행하는 방법을 정의한 API(인터페이스)입니다.ProviderManager: AuthenticationManager 의 구현체입니다.AuthenticationProvider: ProviderManager가 여러 종류의 인증(Basic 인증, Form 인증 등)을 지원 및 수행하기 위해 사용하는 인터페이스입니다. 하나의 ProviderManager에 여러 개의 AuthenticationProvider를 등록하여 사용할 수 있습니다. 가장 흔히 사용되는 구현체는 DaoAuthenticationProvider입니다.Form 기반 인증 수행 과정 — UsernamePasswordAuthenticationFilter아래는 UsernamePasswordAuthenticationFilter 에서 수행하는 인증 과정에 대한 도식입니다.Form 기반 인증 요청에서 username과 password를 추출하여 UsernamePasswordAuthenticationToke 객체를 ProviderManager에 전달합니다. (여기서, UsernamePasswordAuthenticationToken은 위에서 설명한 Authentication 인터페이스의 구현체이고, ProviderManager는 위에서 설명한 AuthenticationManager 인터페이스의 구현체입니다.)ProviderManager는 DaoAuthenticationProvider 를 이용하여 인증을 수행합니다. (여기서, DaoAuthenticationProvider 는 위에서 설명한 AuthenticationProvider 인터페이스의 구현체입니다.)DaoAuthenticationProvider는 UserDetailsService를 이용해 전달받은 username과 일치하는 UserDetails(저장된 사용자 정보)를 조회합니다.DaoAuthenticationProvider는 PasswordEncoder를 이용해 전달받은 password와 3번 과정에서 조회한 UserDetails의 비밀번호가 일치하는지 검증합니다.4번 과정에서 비밀번호 검증까지 성공하면 사용자 인증은 성공한 것입니다. 이 때, 인증이 완료된 UsernamePasswordAuthenticationToken 을 반환하게 되며 이 구현체의 principal 값은 UserDetailsService에서 조회해온 UserDetails로 설정됩니다.최종적으로, 반환된 UsernamePasswordAuthenticationToken은 SecurityContextHolder에 설정됩니다.Form 기반 인증 수행 예시 — 예제 코드인증 수행 과정을 Spring Security 예제 코드와 TRACE 로깅을 통해 확인해봅니다.먼저, Gradle 의존성부터 설정합니다.dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web'}리소스를 관리하는 ResourceController.java 입니다.@Controllerpublic class ResourceController { @GetMapping("/private") @ResponseBody public String loginSuccess() { return "Private Resource"; }}우리가 보호하려는 리소스에 대한 핸들러를 간단하게 명시하였습니다.다음으로 Spring Security 에서 제공하는 Form 기반 인증을 구성한 SecurityConfig.java 입니다.@EnableWebSecurity@Configurationpublic class SecurityConfig { @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager(); UserDetails user = User.withDefaultPasswordEncoder() .username("user") .password("12345") .authorities("READ") .build(); inMemoryUserDetailsManager.createUser(user); return inMemoryUserDetailsManager; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> { authorize .requestMatchers("/private").hasAuthority("READ") .anyRequest().authenticated(); }); http.formLogin(Customizer.withDefaults()); return http.build(); }}이 구성에서 설정한 내용은 아래와 같습니다.사용자 정보 저장을 위해 메모리 저장소를 이용하며 사용자 정보를 추가해두었습니다. 사용자의 정보는 다음과 같습니다.username: userpassword: 12345권한: READ2. 보호할 리소스인 http://localhost:8080/private 리소스에 접근하기 위해서는 인증된 사용자여야 하며, 인증된 사용자가 가지는 권한 중 READ 권한이 있어야 함을 명시하였습니다.3. Spring Security 에서 기본적으로 제공하는 Form 기반 인증을 수행합니다.다음으로 Spring Security 에서 제공하는 로깅 기능을 명시한 application.properties 파일입니다.logging.level.org.springframework.security=TRACESecurity Filter가 수행되는 순서와 사용자 요청이 어떻게 처리되는지 로그를 통해 확인할 수 있습니다.Form 기반 인증 수행 예시 — 인증되지 않은 사용자가 리소스를 요청할 때흐름도인증되지 않은 사용자가 http://localhost:8080/private 리소스에 접근을 시도합니다.AuthorizationFilter에서 AccessDeniedException을 발생시켜 인증되지 않은 요청이 거부되었음을 알립니다.ExceptionTranslationFilter는 AuthorizationFilter 에서 발생한 AccessDeniedException 에 대한 처리로 아래의 과정을 수행합니다.SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.추후에 인증 과정이 성공할 때, 현재 실패한 http://localhost:8080/private 요청을 바로 수행할 수 있도록 현재의 요청 정보가 담긴 HttpServletRequest 객체를 RequestCache에 저장해둡니다.AuthenticationEntryPoint에 구현된 인증되지 않은 사용자에게 자격 증명을 요청하는 기능을 수행합니다. 여기서 구현된 AuthenticationEntryPoint 객체는 LoginUrlAuthenticationEntryPoint 이므로 로그인 페이지(기본 설정 값은 http://localhost:8080/login)로 redirect 하는 작업을 수행하게 됩니다.4. 사용자의 브라우저는 redirect된 로그인 페이지 (기본 설정 값은 http://localhost:8080/login) 를 요청하게 됩니다.5. LoginController 에서 로그인 페이지(login.html)를 렌더링하여 응답합니다.로그 확인위 로그는 흐름도 상 1 ~ 2번까지의 과정에 대한 로그입니다. 인증되지 않은 사용자가 http://localhost:8080/private 요청을 호출할 때 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.FilterChainProxy를 통해 15개의 보안 필터(Security Filter)가 수행됩니다.8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되지만, 로그인 요청(POST /login)이 아니기 때문에 실질적인 인증 과정은 수행되지 않습니다. Spring Security 내부 구현은 아래와 같습니다.UsernamePasswordAuthenticationFilter — 로그인 요청(POST /login) 이 아닐 경우, 인증 로직을 수행하지 않고 다음 보안 필터 호출UsernamePasswordAuthenticationFilter — 로그인 요청(POST /login) 인지 확인15번째 보안 필터로 AuthorizationFilter가 수행되지만, /private 리소스에 접근하려는 사용자가 인증되지 않은 사용자이기 때문에 AccessDeniedException 을 던집니다.14번째 보안 필터인 ExceptionTranslationFilter 는 15번째 보안 필터인 AuthorizationFilter에서 던지는 AcessDeniedException 을 잡아 이전 절에서 설명한 예외 처리 로직을 수행합니다. Spring Security 내부 구현은 아래와 같습니다.ExceptionTranslationFilter — 다음 보안 필터 수행 중 예외 발생 시, handleSpringSecurityException 메소드 호출handleSpringSecurityException 메소드 — 발생한 예외가 AccessDeniedException 일 경우, handleAceessDeniedException 메소드 호출handleAceessDeniedException 메소드 — 인증 정보가 AnonymousAuthentication이므로 sendStartAuthentication 메소드 호출SecurityContext 제거 / 기존 요청 저장 / 로그인 진입점으로 이동위 로그는 흐름도 상 3 ~ 5번까지의 과정에 대한 로그입니다.로그를 통해 아래와 같은 사실을 알 수 있습니다.14번째 보안 필터인 ExceptionTranslationFilter 에 의해 아래 작업이 수행됩니다.현재의 요청 정보가 담긴 HttpServletRequest 객체를 HttpSessionRequestCache에 저장해둡니다.AuthenticationEntryPoint 에 의해 로그인 페이지(http://localhost:8080/login)로 redirect 하게 됩니다.사용자가 인증을 위해 로그인 페이지(http://localhost:8080/login)를 요청하면, 9번째 보안 필터인 DefaultLoginPageGeneratingFilter 에 의해 기본 설정된 로그인 페이지를 응답합니다. Spring Security 내부 구현은 아래와 같습니다.DefaultLoginPageGeneratingFilter — 기본 로그인 페이지 생성 및 응답Form 기반 인증 수행 예시 — 인증 과정에서 실패할 때흐름도사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 HttpServletRequest 객체에서 username과 password를 추출하여 Authentication의 구현체인 UsernamePasswordAuthenticationToken을 생성합니다.다음으로 UsernamePasswordAuthenticationToken이 인증을 위해 AuthenticationManager 인스턴스로 전달됩니다.인증이 실패하면 아래 과정을 수행합니다.SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.RememberMeServices.loginFail() 메소드가 호출됩니다. RememberMeService 를 설정하지 않은 경우, 어떤 작업도 수행되지 않습니다. 이 예제에서는 RememberMeService 기능을 별도로 설정하지 않았기 때문에 어떤 작업도 수행되지 않습니다.AuthenticationFailureHandler 에 구현된 onAuthenticationFailure() 메소드를 수행합니다. 기본적으로 설정되어 있는 AuthenticationFailureHandler의 구현체는 SimpleUrlAuthenticationFailureHandler 입니다. 인증이 실패하면 /login?error URL로 redirect합니다. 로그인 페이지에서는 error 파라미터의 값을 사용하여 인증 실패 메시지를 사용자에게 표시할 수 있습니다.로그 확인위 로그는 흐름도 상 1 ~ 2번까지의 과정에 대한 로그입니다.인증되지 않은 사용자가 POST http://localhost:8080/login 요청을 잘못된 인증 정보와 함께 전송 시, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되고 ProviderManager 와 DaoAuthenticationProvider 가 순차적으로 수행되며 인증이 수행됩니다. Spring Security 내부 구현은 아래와 같습니다.UsernamePasswordAuthenticationFilter — AuthenticationManager 의 authenticate 메소드 호출AuthenticationManager 의 authenticate 메소드 — AuthenticationProvider의 authenticate 메소드 호출AuthenticationProvider의 authenticate 메소드 — 제출된 username과 일치하는 사용자 정보가 없어 BadCredentialException 메소드 호출위 로그는 흐름도 상 3번 과정에 대한 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.인증 실패 시, 아래 작업을 수행합니다.SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.SimpleUrlAuthenticationFailureHandler 에 구현된 onAuthenticationFailure() 메소드를 수행합니다. /login?error URL로 redirect합니다. Spring Security 내부 구현은 아래와 같습니다.AuthenticationFailureHandler 의 onAuthenticationFailure 메소드 — 인증 실패 시, 로그인 페이지로 redirectForm 기반 인증 수행 예시 — 인증 과정에서 성공할 때흐름도사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 HttpServletRequest 객체에서 username과 password를 추출하여 Authentication의 구현체인 UsernamePasswordAuthenticationToken을 생성합니다.다음으로 UsernamePasswordAuthenticationToken이 인증을 위해 AuthenticationManager 인스턴스로 전달됩니다.인증이 성공하면 아래 과정을 수행합니다.새로운 로그인이 발생한 것을 SessionAuthenticationStrategy에 통지합니다.SessionAuthenticationStrategy는 새로운 로그인이 발생할 때, 세션 관련 작업을 수행하는 전략입니다. 이전 세션을 무효화하거나, 동시 로그인 방지 정책을 적용할 수 있습니다.SecurityContextHolder를 새롭게 인증이 완료된 Authentication 데이터로 설정합니다.RememberMeServices.loginSuccess() 메소드가 호출됩니다. RememberMeService 를 설정하지 않은 경우, 어떤 작업도 수행되지 않습니다. 이 예제에서는 RememberMeService 기능을 별도로 설정하지 않았기 때문에 어떤 작업도 수행되지 않습니다.ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 발행합니다.AuthenticationSuccessHandler의 onAuthenticationSuccess() 메소드가 호출됩니다. 기본적으로 설정되어 있는 AuthenticationSuccessHandler 의 구현체는 SavedRequestAwareAuthenticationSuccessHandler 입니다. 로그인 페이지로 redirect되기 전, ExceptionTranslationFilter에서 RequestCache에 저장해둔 원래의 요청을 꺼내와 해당 요청으로 redirect 합니다.로그 확인위 로그는 흐름도 상 1 ~ 3번 과정에 대한 로그입니다.인증되지 않은 사용자가 POST http://localhost:8080/login 요청을 정상적인 인증 정보와 함께 전송 시, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되고 ProviderManager 와 DaoAuthenticationProvider 가 순차적으로 수행되며 정상적으로 인증이 수행됩니다.인증 성공 시, 아래 작업을 수행합니다.CompositeSessionAuthenticationStrategy 에서 2가지 세션 관련 작업을 수행합니다.ChangeSessionIdAuthenticationStrategy 를 이용하여 Session Fixation 공격을 방지하기 위한 세션 ID 변경 작업을 수행합니다.CsrfAuthenticationStrategy 를 이용하여 세션에 연결된 CSRF 토큰을 교체합니다.SecurityContextHolder를 새롭게 인증이 완료된 Authentication 데이터로 설정합니다.SavedRequestAwareAuthenticationSuccessHandler 에 구현된 onAuthenticationSuccess() 메소드를 수행합니다. 인증 이전에 요청했던 리소스인 http://localhost:8080/private?continue URL로 redirect 를 수행합니다. Spring Security 내부 구현은 아래와 같습니다.기존 요청을 RequestCache에서 꺼내와서 해당 요청을 다시 수행하도록 redirect위 로그는 인증이 완료된 사용자가 GET http://localhost:8080/private로 redirect 될 때, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.15번째로 수행되는 보안 필터인 AuthorizationFilter 에서 사용자의 권한을 확인하는 과정을 수행합니다. 사용자의 권한이 모두 확인되면 FilterChainProxy는 모든 보안 필터가 수행되었으므로 보안적인 절차를 수행되었다는 로그를 출력합니다. 최종적으로 인증을 수행하기 전, 처음 요청했던 리소스에 접근할 수 있게 됩니다.글을 마치며Spring Security의 인증 과정은 체계적이고 확장 가능하게 설계되어 있습니다. 각 구성 요소는 명확한 책임을 가지고 있으며 이를 통해 다양한 인증 요구 사항을 유연하게 처리할 수 있습니다. 이번에는 기본적인 폼 기반 인증 과정을 설명했지만, Spring Security는 OAuth2, JWT, LDAP 등 다양한 인증 방식을 지원하므로 애플리케이션의 보안 요구 사항에 맞춰 적절한 인증 방식을 선택할 수도 있습니다.이 글이 Spring Security의 전반적인 인증 과정과 인증에 필요한 구성 요소에 대한 이해에 도움이 되기를 바랍니다.참고자료https://docs.spring.io/spring-security/reference/servlet/architecture.htmlhttps://docs.spring.io/spring-security/reference/servlet/authentication/architecture.htmlhttps://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.htmlhttps://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.htmlhttps://docs.spring.io/spring-security/reference/servlet/authentication/session-management.htmlSpring Security 의 인증 알아보기 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

nGrinder를 활용한 부하테스트
네이버 페이
nGrinder를 활용한 부하테스트

안녕하세요. 금융 FE 임문수 입니다.네이버페이 부동산에서 부하테스트를 진행한 경험을 바탕으로 부하테스트에 대한 설명과 nGinder를 활용한 부하테스트를 진행했던 과정에 대해 소개하려 합니다.배경네이버 부동산의 경우 레거시 시스템에서 새로운 프로젝트로의 전환을 진행하고 있습니다.최근 부동산에서 가장 접근이 많은 페이지 중 하나인 각 매물의 정보를 볼 수 있는 상세 페이지 전환을 진행하게 되면서,새로운 페이지가 기존의 사용자 요구를 원활하게 수용할 수 있는지 검증하고 많은 유저가 접근한 경우에도 서비스가 정상 작동을 확인하기 위해 부하테스트를 진행하게 되었습니다.네이버 페이 매물 상세 페이지부하테스트란?부하테스트(Load Testing)는 소프트웨어, 애플리케이션 또는 시스템이 실제 운영 환경에서 예상되는 부하 수준에서 어떻게 수행되는지 평가하는 과정으로 시스템이 사용자의 요구를 충족할 수 있는지, 그리고 특정 부하 조건에서도 안정적으로 작동하는지 확인합니다.서비스 출시 이전 부하테스트를 통해 시스템의 최대 처리 용량을 파악하고, 성능 개선이 필요한 부분을 식별할 수 있습니다.부하테스트 목적1. 성능 한계 확인사용자의 수요나 트래픽 증가 상황에도 안정적으로 서비스를 제공하기 위해 애플리케이션의 최대 처리 가능한 한계를 파악합니다.성능이 과도하게 떨어진다면 필요한 성능 개선 조치를 검토합니다.2. 서비스 코드와 서버 설정 검증서비스 코드에 문제는 없는지 메모리 누수가 발생하거나 응답시간이 과도하게 지연되지 않는지, 서버 설정에 문제가 없는지 확인합니다.3. 목표 TPS 달성을 위한 설정서비스의 목표 TPS 달성을 위한 필요한 서버 구성을 확인하고 예상치 못한 사용자 수요 증가나 트래픽 급증 상황에서도 안정적으로 운영될 수 있도록 준비합니다.이러한 목적을 달성하기 위해 아래 몇가지 점검 사항을 작성하여 부하테스트를 진행하였습니다.- 한 서버당 TPS는 얼마나 나오며 목표 TPS를 달성하기 위해서 필요한 서버 구성은 무엇인가?- 많은 사용자가 접근할 때 메모리 누수나 에러가 발생하지 않는가?- 현재 인프라 설정이 자원이 적거나 과도하게 많지는 않는가?- 하나의 서버당 가장 적합한 인스턴스 수는 몇 개인가?- HPA(HorizontalPodAutoscaler) 설정은 올바른 값을 바라보고 있고 부하 상황에 따라 확장, 축소 되는가?부하테스트 도구부하테스트를 진행하기 전에 아래에서 몇 가지 주요 부하테스트 도구들을 간략히 소개하고 넘어가겠습니다.ApacheBench (ab)ApacheBench는 Apache HTTP 서버 프로젝트에 포함된 경량의 명령줄 부하 테스트 도구입니다.간편한 사용법과 빠른 결과 제공이 장점이나, 고급 기능 부족과 분산 테스트의 한계로 인해 복잡한 테스트에는 제한적일 수 있습니다. 주로 간단한 웹 서버 성능 측정에 활용됩니다.JMeterApache JMeter는 다양한 서버 유형과 프로토콜에 대한 부하 테스트를 지원하며 오픈소스입니다. 사용자는 GUI 및 비GUI 모드를 통해 복잡한 테스트 시나리오를 쉽게 구현하고 실행할 수 있고 오랫동안 사용된 도구라 많은 예시와 커뮤니티 정보가 있습니다.그러나 대규모 테스트를 GUI 모드에서 실행시 리소스 사용이 많아 성능 저하가 발생할 수 있으며 기능이 많아 제대로 사용하려면 학습이 필요합니다.ArtilleryNode.js 기반의 부하테스트 도구로 HTTP/HTTPS, WebSocket, Socket.io 등 다양한 프로토콜과 애플리케이션에 대한 부하 테스트를 지원합니다.간단하게 npm으로 라이브러리를 설치하여 json 혹은 yaml 파일로 부하테스트를 진행할 수 있습니다. 상대적으로 적은 리소스로 높은 성능의 테스트를 실행할 수 있으며 실시간으로 모니터링 제공하고 테스트 결과를 저장하여 그래프로 상세 결과를 볼 수 있습니다.하지만 복잡한 테스트가 필요할 경우 기능이 제한적일 수 있으며 여러 테스트 결과를 비교 분석하기에 조금 불편 할 수 있습니다.nGrindernGrinder는 스크립트 기반 부하테스트 플랫폼으로, 테스트 관리를 위한 Controller와 부하 생성을 위한 Agent로 구성됩니다. 사용자 인터페이스로 테스트를 관리하며, 실시간 보고와 모니터링, 자동 결과 저장 기능을 제공합니다. 또한, 다중 지역 에이전트를 이용해 글로벌 부하 테스트 환경을 손쉽게 구축할 수 있습니다. 하지만, Jython 에 대한 지식이 없다면 복잡한 스크립트를 작성하기에 어려울 수 있고 분산 테스트를 위해 여러 Agent를 사용하는 경우 많은 양의 시스템 자원이 필요합니다.네이버의 경우 이미 사내에서 nGrinder 서비스가 구축되어 있어 테스트와 모니터링, 보고서를 쉽게 볼 수 있는 nGrinder를 이용하여 부하테스트를 진행하였습니다. (nGrinder 설치)테스트 실행 및 모니터링nGrinder 서비스는 부하테스트를 실행하는 성능 테스트 탭과 스크립트를 생성하는 스크립트 탭으로 나누어져 있습니다.부하테스트를 진행하기 위해 스크립트를 먼저 생성합니다.1. 스크립트 작성하기nGrinder는 Groovy와 Jython을 스크립트 언어로 지원합니다. 복잡한 테스트 시나리오 없이 단순 페이지 접근을 수행하는 경우, 샘플 코드를 적절히 수정하여 활용할 수 있습니다.네이버 부동산에서는 비 로그인 상황에서 충분히 테스트가 가능하여 기본으로 제공되는 비 로그인 기반의 샘플 코드를 수정하여 부하테스트를 진행했습니다.스크립트 명을 지정하고 생성하기를 누르면 기본 샘플 코드가 제공되며,단순 접근만 필요한 경우 샘플코드에서 Test 부분을 접근이 필요한 서비스 주소로 변경합니다.@Testpublic void test(){ // 서비스 주소 HTTPResponse response = request.GET("http://please_modify_this.com", params) if (response.statusCode == 301 || response.statusCode == 302) { grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode) } else { assertThat(response.statusCode, is(200)) }}다른 샘플 및 설명은 nGrinder GitHub 에서 확인할 수 있습니다.2. 부하테스트 진행스크립트를 작성했다면 성능 테스트 탭으로 이동하여 부하테스트를 진행합니다.각 에이전트에서 접근하는 사용자 수를 조절하여 서버에 부하를 줄 수 있습니다.우선 1회 실행하여 스크립트가 정상 동작하는지 확인합니다.스크립트가 정상 동작하는 지 확인한 이후 하나의 서버가 받을 수 있는 부하를 확인하기 위해 오토스케일링 옵션이 있다면 동작하지 않도록 설정하고 테스트를 진행합니다.참고 : 다른 서비스나 자원에 영향이 가지 않도록, 해당 서비스가 아닌 부분을 주석 처리하거나 정적 자원으로 변경 후 테스트 진행을 고려합니다.1. 웜업 진행테스트 사이에 코드 변경이나 장비 설정 변경으로 서버가 새로 올라간 경우,초기 서버의 불안정한 상태가 테스트 결과에 영향을 미치지 않도록 부하테스트 시작 전 1~5분 동안 서버 웜업을 진행합니다.nGrinder를 모니터링하여 TPS (Transactions Per Second) 그래프가 안정화될 때까지 웜업을 유지합니다.2. 테스트를 위한 최대 부하 찾기극한의 환경에서 테스트를 진행하기 위해, 서버가 재시작되거나 접근 불가 상태가 되지 않는 범위 내에서 최대 부하를 찾아냅니다.nGrinder의 Agent 수와 vuserPerAgent 값을 조절하여 에러가 발생하지 않는 범위 내에서 테스트를 진행하기 위한 최대 부하를 찾습니다.(참고: 총 vuser가 같더라도 agent가 많을 수록 에러가 더 잘 발생했습니다.)먼저 대략적인 총유저 수를 찾기 위해 유저를 변경하여 실행 종료하면서 nGrinder 모니터링 및 보고서에서 에러가 발생하지 않는 값을 찾습니다.에러가 많이 발생하거나 오른쪽 그래프가 너무 튄다면 총 유저 수를 줄이고에러가 발생하지 않으면서 오른쪽의 TPS 그래프가 너무 튀지 않는 대략적인 범위를 찾습니다.이후 부하 값을 자세히 확인하기 위해 서비스 서버 모니터링 도구를 함께 활용합니다.서버에서 가장 큰 병목 현상을 일으키는 자원을 확인하여 그 자원이 한계에 도달하는 최대 사용자 수를 결정합니다.부동산 FE 서버의 경우 CPU에 가장 많은 병목이 있었고, CPU 부하가 99%에 근접하게 유지되는 총 유저 값을 찾아 부하테스트를 진행했습니다.3. 설정 변경에 따른 데이터 수집인스턴스 개수, 서버 자원, 캐시 적용 등을 통해 결과 값을 기록하여 TPS 성능이 잘 나오면서 그래프가 안정된 형태를 유지하는 최적의 설정 값을 찾습니다.4. 기타 점검 사항 확인부하 테스트를 지속적으로 진행하면서 메모리 누수가 있는지 확인합니다. 이후 다른 점검이 끝났다면 오토스케일링이 발생 가능하도록 설정을 다시 변경하고 부하 상황에 따라 스케일 확장, 축소가 정상 동작 하는지 확인합니다.부하 테스트 결과점검 사항 확인한 서버당 TPS는 얼마나 나오며 목표 TPS를 달성하기 위해서 필요한 서버 구성은 무엇인가?서비스가 예상(혹은 측정) 부하를 견딜 수 있도록, 적절한 안전 마진을 고려하여 최대 RPS를 설정하였습니다. 서버의 실측 RPS 데이터(최대 부하)를 토대로 각 서비스의 목표 RPS에 맞게 대응 가능하도록 pod의 min, max 수치를 결정하였습니다.많은 사용자가 접근할 때 메모리 누수나 에러가 발생하지 않는가?단일 부하 테스트 수행 시 메모리 사용량이 지속적으로 상승하는지 확인하고 지속적으로 부하테스트를 진행하면서 사용량이 계속 상승하고, 가비지 컬렉션이 제대로 이루어지지 않는지 확인하였습니다.계속해서 메모리가 증가하여 재시작 되는 경우(출처: How we resolved a memory leak on our)테스트 진행 시 마다 메모리의 하단 지점과 메모리가 상승 하는 경우현재 인프라 설정이 자원이 적거나 과도하게 많지는 않는가?최대 부하상황에서의 서버 모니터링을 통해 자원을 결정하였습니다.nginx의 경우 최대 부하상황에서도 부하가 크지 않아 자원을 적게 할당했고, nextjs의 경우 부하 테스트 후 잔여 메모리가 높아 기존보다 메모리를 좀 더 높게 설정하였습니다.하나의 서버당 가장 적합한 인스턴스 수는 몇 개인가?부하 테스트를 통해 각 Pod 당 최적의 Instance 수를 도출하였습니다. 이전 부하테스트 경험을 통해 Instance 6~10개 범위 내에서 테스트를 진행하였고 nGrinder 모니터링 그래프의 평균 TPS와 TPS 그래프의 증감폭 (아래 박스 영역), 시스템의 안정성을 고려하여 인스턴스 수를 결정하였습니다.HPA(HorizontalPodAutoscaler) 설정은 올바른 값을 바라보고 있고 부하 상황에 따라 확장, 축소 되는가?최소값을 1로 주고 부하를 주어 바라보고 있는 값이 유의미한 값인지, 해당 값으로 오토스케일링이 잘 동작하는지 확인하였습니다.이외 부하테스트를 통해 확인 및 개선된 사항부하테스트를 진행하면서, 기존에 테스트한 다른 서비스에 비해 TPS가 낮게 나오는 문제가 발생했습니다. 서비스 모니터링 결과, BFF 서버에서 많은 부하로 인해 병목 현상이 발생하는 것을 확인하였고 이를 해결하기 위해 아래와 같은 사항들을 점검하여 일부를 개선했습니다.SSR 요청 개선해당 페이지의 경우 서버 사이드 렌더링(SSR)과정에서 순차적으로 처리되어야 하는 API 호출이 3단계로 나누어져 응답 시간 지연이 발생하고, SSR과정에서 너무 많은 API를 호출하고 있었습니다. SSR 에서 호출되는 API 호출 단계를 2단계로 줄여 next js 서버의 지연을 줄이고 상단 영역이 아닌 API들은 클라이언트에서 필요할 때 호출하도록 하여 페이지 접근과 동시에 발생하는 API 부하를 줄였습니다.캐시 적용해당 페이지의 경우 일부 데이터를 제외하고 사용자 기반이 아닌 매물에 대한 정보를 제공하여 실시간성이 중요한 페이지가 아니었기 때문에 `Cache-Control` 헤더를 설정하였습니다.s-maxage, max-age, stale-while-revalidate 값들을 적절히 지정하여 캐싱이 적용될 수 있도록 설정하였습니다.마치며지금까지 금융 FE에서 nGrinder를 사용하여 진행된 부하테스트를 소개해드렸습니다.처음에는 부하테스트와 관련된 용어와 개념들이 낯설고 복잡하게 느껴질 수 있으나,실제로 nGrinder를 사용해 부하테스트를 진행하면서, 명확한 목적과 방법을 갖고 진행하면 예상보다 접근하기 쉬웠다는 것을 깨닫게 되었습니다.네이버 페이 부동산에서는 이러한 방법으로 부하테스트를 진행하여 개선하였고 안정적으로 배포하여 현재 서비스를 제공하고 있습니다.이 글이 nGrinder를 활용한 부하테스트에 대한 이해를 돕고, 여러분의 서비스에 적용하여 보다 견고하고 안정적인 서비스를 제공하는 데 도움이 되기를 바랍니다.nGrinder를 활용한 부하테스트 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

GitHub Actions 활용하기
네이버 페이
GitHub Actions 활용하기

안녕하세요, 네이버 페이에서 프론트엔드를 개발하고 있는 이창재입니다.네이버 파이낸셜에서는 GitHub를 통해 코드를 관리하고 있고, 자연스레 GitHub Actions를 활용하여 효율성을 더하고 있습니다.이번 글에서는 실제 사용중인 GitHub Actions 중 간단한 예시와 함께 해당 개념에 대해 소개하고자 합니다. 시작에 앞서 기본 개념을 짚고 넘어가겠습니다.👀 GitHub Actions?GitHub Actions란 GitHub 저장소를 기반으로 워크플로우를 자동화 할 수 있는 도구, GitHub가 제공하는 완전관리형 CI/CD 툴입니다.✒️ CI/CD?참고 자료 : RedHat 문서 — CI/CD란?📄 CI(Continuous Integration) — 지속적인 통합* PR이나 push된 코드를 빌드 및테스트하는 프로세스를 자동화하고, 이러한 프로세스를 거친 후 코드를 merge해 주는 자동화 프로세스입니다.* 이 과정에서 충돌이나 Lint, 정상 동작 등 자동화된 테스트를 실행하여 변경 사항을 검증합니다.📄 CD(Continuous Delivery, Deployment) — 지속적인 제공 및 배포* Delivery(지속적 제공) : CI를 거친 후 레포에 업로드되는 것을 의미합니다.* Deployment(지속적 배포) : 배포과정을 자동으로 처리해주는 것을 의미합니다.📄 CI/CD의 목적반복적인 일(빌드, 테스트, 배포 작업 등)을 처리하고 그 과정에서 이슈 발생 시 경고해주는 등, 자동화된 파이프라인을 통해 코드 변경과 배포 단계를 원활하게 진행할 수 있도록 해 줍니다.이 과정에서 시간 절약 및 사람이 직접적으로 처리할 때 발생하는 실수, 즉 휴먼에러를 방지할 수 있습니다.🎯GitHub Actions 특징* 컨테이너(도커) 기반으로 동작합니다.* 개발자는 워크플로우를 작성하여 다양한 이벤트 기반으로 실행시킬 수 있습니다.* 워크플로우는 Runners라 불리는 인스턴스에서 리눅스, 맥, 윈도우 환경에서 실행됩니다. (동시 테스트도 가능!)* GitHub 마켓 플레이스에서 필요한 Workflow를 찾아 사용할 수 있고, 직접 만들어 마켓 플레이스에 공유할 수도 있습니다.😄GitHub Actions의 장점* 다른 CI/CD툴(ex. 젠킨스)처럼 별도 서버 설치와 같은 복잡한 절차 없이 사용이 가능합니다.* 즉 GitHub에서 제공하는 완전 관리형 서비스이므로 설정이 매우 간편하고 GitHub API에도 쉽게 접근할 수 있습니다.* 비동기적 병렬 실행이 가능한 CI/CD입니다.* GitHub 마켓플레이스를 통해 필요한 워크플로우를 내려 받거나 공유할 수 있습니다.🤔GitHub Actions의 단점* 서버에 장애가 일어나거나 리소스를 초과할 경우 개발자가 직접 문제를 해결해야 합니다.⚙️GitHub Actions 핵심 개념참고자료 : GitHub Docs — GitHub Actions 이해Workflows* GitHub Actions의 기본 구성 단위로써 가장 최상위 개념입니다.* 자동화된 프로세스가 정의되어 있는 파일로 YAML 파일에 정의됩니다.* 하나 이상의 작업을 포함할 수 있으며 해당 파일을 실행할 규칙, 동작 등이 작성되어 있습니다.Events* 워크플로우를 시작하는 트리거(push, PR 등)로써, 워크플로우 파일 내에 정의됩니다.Jobs* 워크플로우 내에서 실행되는 개별 작업입니다.* 이벤트로 워크플로우가 실행되면 Job에 작성된 명령들이 Runner에서 실행됩니다.* 기본적으로 Job들은 병렬로 실행되지만, 서로 의존관계를 가질 수도 있고 직렬로 실행할 수도 있습니다.* Job은 자신의 환경 설정과 Steps를 가지고 있습니다.Steps* Job내 작업의 가장 작은 단위입니다.* 각 step들은 스크립트나 명령어 또는 액션을 실행할 수 있습니다.Actions* 작업 흐름에서 공유 및 결합할 수 있는 재사용 가능한 코드 단위입니다. 컴포넌트라고 볼 수 있습니다.* 마켓에 등록되어 있는 Action을 가져오거나, 별도 레포에 작성하여 해당 레포의 이름으로 워크플로우의 파일에서 참조할 수 있습니다.Runners* 워크플로우가 실행되는 가상 머신 또는 자체 호스팅 환경입니다.* 기본적으로 GitHub에서 워크플로우를 구동할 리눅스, 윈도우, 맥OS 운영체제의 Runner를 제공하며, 필요시 self-hosted-runner를 등록할 수도 있습니다.GitHub Actions에는 도커(Docker) 컨테이너 액션, 복합(Composite) 액션, 그리고 자바스크립트를 활용한 액션, 이렇게 3가지 종류의 액션이 존재합니다. 그 중 이번 글에서는 자바스크립트를 활용한 액션 예시를 준비했습니다.아래는 해당 예시를 위한 특정 상황입니다.정기배포 이후 릴리즈 브랜치를 alpha나 beta와 같은 기존 작업 브랜치에 머징해야 할 필요성이 있습니다.하지만 해당 PR 생성 후 Lint나 Unit Test와 같은 테스트 워크플로우들이 모두 실행 완료될때까지 기다리기엔 너무나도 귀찮습니다. 가끔 일에 치여 머징하는걸 깜빡하고 한참 후에 최근 릴리즈 브랜치가 반영되지 않았다는 것을 깨닫기도 합니다.따라서 특정 워크플로우 체킹이 완료된 후 source branch가 `release/`로 시작하면 머징을 진행하는 자동화된 워크플로우가 있으면 좋겠다는 생각을 하게 됩니다.해당 예시의 전체적인 레포 구조는 위와 같습니다.📑action.yaml가장 먼저 action에 대해 작성할 필요가 있습니다.기본적으로 해당 action의 설명과 필요한 파라미터를 기술하는 부분으로 크게 name, description, inputs, runs, branding 5가지 섹션으로 나누어져 있습니다.이 중 name, description, runs는 필수로 작성해주어야 합니다.name: Auto merge when head branch starts with 'release/'description: 'Prevent merging if a specific label is attached to a PR'inputs: token: description: 'Github token' required: true workflow: required: true description: 'File name of the workflow you wanna wait for' interval: description: 'The interval between workflow checks (default is 3s)'runs: using: 'node16' main: 'dist/index.js'📄inputsinputs.<input_id> — token, workflow, interval필요한 input 파라미터의 변수명을 지어줍니다. 이 변수명은 나중에 js 코드상으로 불러올때 필요하기에 의미에 맞게 작성할 필요성이 있습니다.inputs.<input_id>.descriptioninput 파라미터에 대한 설명을 적어줍니다.inputs.<input_id>.required필수 파라미터 여부를 표시해줍니다.inputs.<input_id>.default필요 시 디폴트 값을 설정해 줍니다. 이때 무조건 string 값만 사용할 수 있습니다.📄runsruns.using코드를 실행시킬 환경을 설정합니다. (필수)runs.main실행할 필수 작업 코드가 포함된 파일의 main path를 지정합니다. (필수)runs.env필요한 환경변수를 지정합니다📄branding마켓플레이스 배포 시 표시될 아이콘을 설정해 줍니다.📑src/index.ts본격적인 action 작업코드를 작성하는 파일입니다.해당 자바스크립트 코드 작성 도와주는 다양한 툴킷이 존재합니다.여기서는 @actions/core와 @actions/github을 사용하여 개발을 진행하였습니다.* @actions/core: workflow에 대한 inputs, outputs, logging 등의 함수를 제공합니다.* @actions/github : 인증된 Octokit client를 제공해줍니다.import * as core from '@actions/core'import * as github from '@actions/github'async function run(): Promise<void> { const workflows: string[] = core .getInput('workflow', { required: true, }) .split('|') .map((item) => item.trim()) const interval: number = +core.getInput('interval') * 1000 || 3000 const token: string = core.getInput('token', { required: true, }) const memes = [ '잘했어요 밈1', '잘했어요 밈2', '잘했어요 밈3', '잘했어요 밈4', '잘했어요 밈5', '잘했어요 밈6', ] const octokit = github.getOctokit(token) const {pull_request} = github.context.payload const {owner, repo} = github.context.issue const pr_number = pull_request?.number const head_ref = pull_request?.head?.ref || '' try { for (const workflow of workflows) { core.info(`Waiting until workflow ${workflow} ends`) let workflowIsRunning do { await new Promise((resolve) => setTimeout(resolve, interval)) workflowIsRunning = await checkIfWorkflowIsRunning(workflow) } while (workflowIsRunning) } if (pr_number && head_ref.startsWith('release/')) { await octokit.rest.pulls.createReview({ owner, repo, pull_number: pr_number, body: memes[Math.floor(Math.random() * memes.length)], event: 'APPROVE', }) await octokit.rest.pulls.merge({ owner, repo, pull_number: pr_number, merge_method: 'merge', }) core.info('LGTM & Merge') } else { core.info(`Check exist PR number : ${pr_number} or source branch(${head_ref}) starts with release/`) } } catch (error) { if (error instanceof Error) { core.setFailed(error.message) } }}run()async function checkIfWorkflowIsRunning(workflow: string): Promise<boolean> { const token: string = core.getInput('token', { required: true, }) const octokit = github.getOctokit(token) const {owner, repo} = github.context.issue const { data: {workflow_runs}, } = await octokit.rest.actions.listWorkflowRuns({ owner, repo, workflow_id: workflow, per_page: 5, }) return workflow_runs.some( (workflow_run) => workflow_run.status === 'queued' || workflow_run.status === 'in_progress', )}📄input값 가져오기위 action.yaml에서 지정한 input값의 경우 core.getInput({input_변수명})으로 불러올 수 있습니다.input값을 가져오는 방법만 안다면 나머지 로직의 경우 개발자가 원하는 대로 작성할 수 있습니다.위 예시에서처럼 github의 기능(pull request 작성, apporve, merge 및 owner, repo등 값 추출 등)을 사용하고 싶다면 octokit document를 참고하여 작성해 주면 됩니다. 😎🚀만든 액션 사용해보기위와 같이 작성된 액션은 사용하고자 하는 레포의 ./github/workflows 폴더 내에 워크플로우를 작성하여 사용할 수 있습니다.# 워크플로우의 이름입니다.name: test-workflow# 워크플로우를 동작하게 하는 트리거입니다.# 해당 트리거의 종류는 아래 GitHub Docs에서 확인할 수 있습니다.# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_requeston: # 여기서는 alpha, beta 브랜치를 대상으로 pull reqeust가 발생할 때 트리거링되도록 작성되었습니다. pull_request: branches: - alpha - beta# 위에서 서술한 jobs의 핵심 개념과 동일한 개념입니다.jobs: # 여기서는 call-workflow라는 하나의 job을 작성하였습니다. call-workflow: # 구동 환경입니다. 여기서는 리눅스 환경에서 실행합니다. runs-on: ubuntu-latest # 위에서 서술한 steps의 핵심 개념과 동일한 개념입니다. # 상세 내용은 아래 GitHub Docs에서 확인할 수 있습니다. # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps steps: - name: check PR # 사용할 액션 위치입니다. 소유자/저장소@브랜치 형태로 가리켜주면 됩니다. uses: common/auto-release-merge-action@main # with 키워드를 통해 action에 값을 전달할 수 있습니다. # 이전 작성한 액션에서 token, workflow 파라미터를 필수 필요로 했으므로 해당 값을 전달해 줍니다. with: token: ${{ secrets.ACTION_TOKEN }} workflow: 'ci.yaml | lint.yaml'여기서 token의 secrets 값의 경우 레포 세팅 탭에서 설정할 수 있습니다.Settings > Secrets and Variables > Actions 에서 환경변수를 생성하여 secrets.환경변수명 으로 호출할 수 있습니다.GitHub Actions를 사용할 때 민감한 정보의 경우는 여기서 관리하면 좋습니다.네이버 파이낸셜에서 사용중인 GitHub Actions 중 간단한 예시를 개념과 함께 살펴보았습니다.GitHub Actions은 이 글에서 설명된 작업 말고도 훨씬 다양한 상황에서 사용될 수 있습니다. 여러분들의 레포에서 단순 반복되는 작업이 존재한다면 이번 기회에 GitHub에게 맡겨보는 건 어떨까요? 😊이 포스트가 여러분들께 조금이나마 도움이 되었으면 합니다. 읽어주셔서 감사합니다 🙇‍♂️🔖참고 자료* RedHat 문서 — CI/CD란?* Github Actions or Jenkins? Making the Right Choice for You* GitHub Docs — GitHub Actions* Octokit DocumentGitHub Actions 활용하기 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

코드, 어떻게 관리하세요?
네이버 페이
코드, 어떻게 관리하세요?

안녕하세요, 네이버페이 내자산&회원FE에서 프론트엔드 개발하는 이선아입니다.저희 팀이 담당하는 일은 원용님의 아래 글에 잘 정리되어 있습니다.내자산: 마이데이터 자산 조회 by wonyong01.kim이 글에서는 코드 관리의 관점에서, 모노레포에 대해 간략히 알아보고 기존에 있던 두 레포를 하나로 통합하는 과정과 진행하면서 생겼던 고민거리와 느낀 점을 공유하려 합니다.개편 이전의 코드 관리저희 팀에서는 내자산 서비스뿐만 아니라, 신용점수, 송금, 간편결제 등의 서비스도 담당하고 있는데, 각 서비스에서 다뤄야 할 기능이 많다 보니 서비스 별로 레포를 분리해서 코드를 관리하고 있습니다.그리고 공통으로 사용해야 하는 부분을 패키지로 뽑아 패키지용 레포를 따로 두고 있었습니다. 패키지에는 범용적으로 사용되는 코드를 모아둔 utils 패키지나 원용님께서 설명해 주신 Session IO를 사용하기 위한 패키지 등이 있습니다.저희 팀에서 관리하는 레포가 늘어나다 보니, 패키지만 따로 뽑아둔 레포는 개발 스택 업데이트에서 누락되는 등 관리가 안 되는 문제가 발생했습니다. 서비스 코드 작업이 더 급하다 보니 패키지 코드 개선은 후순위로 밀리는 경우가 많았습니다.또한 패키지는 카나리 배포를 해야 코드 동작을 확인할 수 있다보니, 작업을 위한 컨텍스트 스위칭에 피로도가 높아졌습니다.결정적으로 배포 프로세스가 변경되었는데 패키지 레포에서도 이를 새로 적용해야 했고, 이전의 불편사항을 개선하기 위해 패키지 레포와 서비스 레포를 합쳐 모노레포로 관리하기로 결정했습니다.모노레포란?잠깐 모노레포에 대해 설명하자면, 여러 프로젝트의 코드를 하나의 레포에서 저장, 관리하는 소프트웨어 개발 전략을 뜻합니다.기존에도 패키지들을 하나의 레포에서 관리하고 있었으니 해당 레포는 모노레포 전략을 취하고 있었다고 볼 수 있습니다. 이전에 패키지들만 모아서 관리하던 레포에서 패키지들을 옮겨와 하나의 레포에서 코드를 관리할 수 있도록 변경했습니다.Repo before & afterturbo repo란?모노레포를 구성하기 위해서 저희는 turbo repo를 사용했습니다.turbo repo는 자바스크립트 또는 타입스크립트로 된 프로젝트를 대상으로 하는 고성능의 빌드 시스템입니다.turborepo모노레포를 구성하는 많은 방법 중에서 선택할 때에 저희의 기준은 두 가지였는데요, 적용 및 사용하기 어렵지 않을 것과 빌드 속도가 크게 저하되지 않을 것이었고, 결론적으로 저희 팀은 만족하며 사용하고 있습니다.turbo repo 알아보기turbo repo 사용법을 간략하게만 설명하려 합니다. turbo에서는 `turbo.json` 파일에서 설정을 명시하여 사용합니다.{ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { "cache": false, "dependsOn": ["^build"] }, "deploy": { "cache": false, "dependsOn": ["^build"] }, "start": { "dependsOn": ["^build"] }, "lint": {} }}pipeline은 turbo로 실행할 스크립트의 이름을 키로 가지는 객체입니다. `turbo run`으로 실행할 수 있으며 각 package.json에서 일치하는 스크립트가 있으면 실행시켜줍니다. 예를 들어 `turbo run build`를 실행하면 레포 내의 모든 패키지와 서비스를 빌드 할 수 있습니다.pipeline 별로 설정을 다르게 할 수 있습니다. turbo에서는 빠른 실행을 위해 캐싱을 지원하고 있는데, 스크립트를 실행한 결과를 저장해두어 다시 실행했을 때에 캐시 되어있던 결과가 있다면 그대로 사용합니다. 프로젝트를 빌드 할 때에는 캐싱 된 결과를 사용하지 않도록 cache로 설정을 제어할 수 있습니다. 스크립트 간에 의존성이 있을 경우에는 dependsOn으로 설정합니다.패키지 이관하기패키지 레포에 있는 코드들을 이관하고 turbo로 실행할 수 있도록 스크립트를 정리해 주었습니다.그리고 패키지 매니저인 pnpm에서 제공하는 기능으로, 서비스 코드에서 배포된 버전이 아닌 같은 레포에 있는 코드를 바라볼 수 있도록 package.json을 수정해 주었습니다.{ // ... "dependencies": { "@mydata/packageA": "workspace:*", "@mydata/packageB": "workspace:*" }}배포 프로세스 정리하기코드 이관 후에는 github actions를 이용하여 패키지 버전관리와 배포 프로세스를 정리했습니다. 우선 패키지의 버전 관리와 changelog 작성을 쉽게 하도록 도와주는 Changesets를 도입했습니다.ChangesetsChangesets를 활용한 버전 관리 과정을 짧게 설명하면, PR 생성 시에 패키지에 변경사항이 있는지를 감지하여 코멘트를 달아줍니다.코멘트에서 major, minor, patch 중 어떤 버전을 bump할 것인지 선택하고 changelog를 작성하면 임시 파일을 추가해 줍니다. 해당 PR이 특정 브랜치인 develop에 머지 되면 이를 감지해서 임시파일의 내용으로 버전 업데이트와 changelog를 추가하여 새로운 PR를 생성해주며, 패키지 작업이 모두 완료되었을 때에 작업자가 머지합니다.여기에 나아가서 develop에서 패키지 버전에 변경이 있었을 때에 자동으로 publish를 실행하도록 했습니다. 배포가 정상적으로 완료되면 메신저로 알람을 보내 배포 결과를 공유합니다. 작성한 publish actions 코드를 발췌한 내용입니다.name: Release Publishon: push: branches: - developjobs: publish: runs-on: air-fe steps: - name: Checkout Repo uses: actions/checkout@v2 - name: install dependencies run: pnpm install --frozen-lockfile - name: Create Release Pull Request id: changesets uses: common-fe/changeset-actions@main with: title: "🚀 version changed packages" publish: pnpm run publish - name: Publishing success! if: steps.changesets.outputs.published == 'true' uses: actions/github-script@v6 with: script: # 배포 성공한 경우 사내 메신저인 works 발송마지막으로, 작업 중 카나리 배포를 쉽게 할 수 있도록 PR 코멘트에서 canary-publish를 인식하여 카나리 배포를 자동화했습니다.자동화 작업할 때에는 한주님이 작성해 주신 FinFE Bot을 많이 활용했습니다. 처음 소개해 주신 글보다 지금은 더 많은 기능을 고도화하여 제공하고 있지만, 읽어보시면 자동화에 대한 인사이트를 얻을 수 있습니다!반복적이고 귀찮은 일은 Bot에게! by 이한주불편한 점 개선하기배포 프로세스까지 정리하고 나니 “해야 하는 일”은 다 끝난 상태였고, 약간의 불편한 부분이 남은 상태였습니다.첫 번째로 CI/CD에 걸리는 시간이 길어진 것이었습니다. PR 생성 시에 작성한 코드에 이상이 없는지 확인하는 CI가 걸려있는데, 코드를 모두 빌드 하는 단계가 포함되어 있었습니다. 패키지에 비해 서비스 빌드 시간이 더 길었는데, 패키지 코드를 조금만 고쳐도 긴 서비스 빌드 시간을 기다려야 했습니다.이런 문제를 해결하기 위해서 빌드 스크립트를 분리하고 패키지 변경 시에는 서비스 코드에 대한 테스트를 건너뛸 수 있도록 github actions를 보완했습니다.두 번째로는 브랜치 관리가 이슈가 되었습니다. 패키지 코드 작업과 서비스 코드 작업에 의존 관계가 있어서 문제 되는 부분이 있었습니다.처음에는 패키지 수정 시에 패키지 쪽만 수정해서 PR을 작성하여 changelog에서 쉽게 확인할 수 있도록 하려 했는데, 서비스 코드에서 이를 참조하고 있어서 에러가 발생할 수 있습니다. 브랜치가 잘못 관리 되면 다른 브랜치로 에러가 전파될 수 있어서 브랜치 관리 전략을 새로 논의해야 했습니다.장단점 분석해보기모노레포로 전환하고 몇 달간 사용해 보면서 느낀 장단점을 분석해 봤습니다.장점은- 한 레포에서 관리하다 보니, 패키지 작업이 수월해졌습니다.- 개발 스택 업데이트할 때에 누락되는 경우가 적어졌습니다.- 코드 검색과 관리가 쉬워졌습니다.단점은- 브랜치 관리 방법이 더 복잡해졌습니다.- 팀원 모두 빌드 시스템에 대한 공부가 더 필요했습니다.전체적으로 봤을 때에 코드 작업 측면에서는 모노레포를 사용하는 것이 유리하지만, 환경 설정이나 브랜치 관리가 복잡하여 피로해진 부분도 있었습니다.마치면서작업 착수 전에는 코드 이동만 하면 되려나 했는데, 실제로 작업하면서는 빌드 시스템부터 github actions까지 다양하게 공부해 볼 수 있었습니다.제 경험기가 모노레포 전환을 고려하시는 분들께 조금이나마 도움이 되었으면 좋겠습니다.이번 모노레포 전환 작업을 진행하면서 시행착오도 많이 하고 고민도 많이 하면서 한 뼘 더 성장할 수 있었습니다. 작업 중 막힐 때마다 함께 고민해 주신 팀원분들 덕분에 무사히 마무리할 수 있었습니다.저희 네이버페이 내자산&회원FE에서는 팀원들의 지속적인 성장을 독려하고 있습니다. 저희와 함께하실 열정적인 분들을 언제나 기다리고 있습니다!채용: https://recruit.naverfincorp.com/코드, 어떻게 관리하세요? was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

내자산: 마이데이터 자산 조회
네이버 페이
내자산: 마이데이터 자산 조회

(feat. WebSocket)안녕하세요. 네이버페이 내자산&회원FE 팀의 김원용입니다.저희 팀은 네이버페이 포인트는 물론, 마이데이터를 통한 다양한 금융 기관의 자산 정보를 한 곳에서 손쉽게 확인하고 관리할 수 있는, 네이버페이 내자산 서비스를 개발하고있습니다.이 글에서는 네이버페이 내자산 서비스에 대한 간략한 소개와 함께, 마이데이터 자산 조회 개발 과정을 단계별로 설명해보려고 합니다!네이버페이 내자산 서비스: https://new-m.pay.naver.com/mydata/home네이버페이 내자산 서비스 (이하 “내자산”)는 네이버페이 자산과 마이데이터 자산을 한 곳에서 조회 가능한 서비스 입니다.네이버페이 자산: - 네이버페이 포인트 - 네이버페이 머니(하나 통장) - 미래에셋 네이버통장 - 후불결제 - 우리집 - 마이카마이데이터 자산: - 은행 - 카드 - 보험 - 증권 - 대출 - 연금/IRP마이데이터는 고객이 본인의 개인신용정보를 금융회사로부터 마이데이터사업자에게 전송하도록 요구할 수 있는 권리를 의미합니다.이를 통해 사용자는 네이버페이에서 다양한 금융회사에 등록된 자신의 개인신용정보를 한눈에 조회하고 효율적으로 관리할 수 있습니다.여러 금융회사로부터 데이터를 가져온다는 개념을 바탕으로, 사용자의 모든 은행 계좌 잔액을 조회할 수 있는 은행 총 잔액 컴포넌트를 구현해보겠습니다.단순한 은행 총 잔액 컴포넌트1. 하나의 API로 모든 은행 잔액 확인하기은행 총 잔액을 표시하는 가장 간단한 방법은 한 번의 BE API 호출을 통해 데이터를 가져오는 것입니다.BE 에서는 모든 금융회사의 잔액을 병렬로 조회한 후, 이를 합산하여 응답합니다.// 은행 계좌의 총 잔액을 조회하는 함수const fetchBankTotalBalance = async () => { const bankTotalBalance = await fetch('/api/bank/total-balance'); return bankTotalBalance; // 예: 999,999,999}/* ================================================================== */// 총 잔액 조회 및 화면에 표시const bankTotalBalance = await fetchBankTotalBalance();render(bankTotalBalance);그러나, 만약 어떤 특정 금융기관의 데이터를 받아오는 데 시간이 많이 소요된다면, 이는 사용자 경험에 어떤 영향을 미칠까요?금융회사C 응답이 60초가 걸린다면 … ?2. 병렬 요청 방식마이데이터 자산 데이터는 여러 금융기관으로부터 가져오기 때문에, 특정 기관의 응답 지연은 전체 데이터 처리 속도에 영향을 미칠 수 있습니다. 예를 들어, 금융회사 C의 응답이 지연될 경우, 사용자는 전체 잔액 정보를 볼 수 없게 됩니다.이를 개선하기 위해 FE 에서는 금융회사별로 병렬 요청을 보내는 방식을 도입했습니다.FE 에서 금융회사별로 병렬 요청합니다.// 각 금융회사의 잔액 정보를 저장하는 객체const bankBalanceInfo = { 금융회사A: null, 금융회사B: null, 금융회사C: null,}// 개별 은행 계좌의 잔액을 조회하는 함수const fetchBankBalance = async (bankCode) => { const bankBalance = await fetch(`/api/bank/balance/${bankCode}`); return bankBalance; // 예: 100,000,000}// 금융사별 잔액 업데이트 및 화면에 표시const updateAndRender = (bankCode, balance) => { bankBalanceInfo[bankCode] = balance; render(sum(bankBalanceInfo));}/* ================================================================== */// 금융회사 병렬 요청Object.keys(bankBalanceInfo).forEach(async (bankCode) => { const bankBalance = await fetchBankBalance(bankCode); updateAndRender(bankCode, bankBalance);})이 방식을 통해 빠르게 응답을 받은 금융회사의 잔액 정보부터 합산하여 화면에 표시하게 되므로, 사용자는 신속하게 전체 정보를 확인할 수 있습니다.그러나, 만약 여러 금융기관에서 지연이 생기면, 이는 사용자 경험에 어떤 영향을 미칠까요?금융회사A 만 빠르게 온다면 … ?3. 캐시된 정보 활용여러 금융기관에서 데이터를 가져오는 과정에 지연이 생길 때, 병렬 요청 방식을 사용해도 전체 잔액 정보에 일시적인 오차가 생길 수 있습니다. 예를 들어, 전체 잔액이 999,999,999원인데 금융회사 A의 100,000,000원만 먼저 오면, 화면에는 100,000,000원만 표시되고, 지연 후에 서서히 999,999,999원으로 업데이트됩니다. 이것은 사용자에게 좋지 않은 경험을 줄 수 있습니다.이를 개선하기 위해 BE 에서 마지막으로 조회한 금융회사 데이터를 저장하고, FE 에서는 이 저장된 잔액을 먼저 보여줌과 동시에 최신 잔액으로 업데이트하는 방식을 도입했습니다.캐시된 잔액을 우선적으로 화면에 노출시키고, 병렬 요청 방식으로 금융회사의 최신 잔액으로 갱신합니다.// 각 금융회사의 잔액 정보를 저장하는 객체const bankBalanceInfo = { 금융회사A: null, 금융회사B: null, 금융회사C: null,}// 캐시된 총 잔액 정보를 먼저 가져오는 함수const fetchCachedBankTotalBalance = async () => { const cachedBankTotalBalance = await fetch('/api/bank/cached-total-balance'); return cachedBankTotalBalance; // 예: { 금융회사A: 100,000,000원, 금융회사B: 200,000,000원, ... }}// 개별 은행 계좌의 잔액을 조회하는 함수const fetchBankBalance = async (bankCode) => { const bankBalance = await fetch(`/api/bank/balance/${bankCode}`); return bankBalance; // 예: 100,000,000}// 금융사별 잔액 업데이트 및 화면에 표시const updateAndRender = (bankCode, balance) => { bankBalanceInfo[bankCode] = balance; render(sum(bankBalanceInfo));}/* ================================================================== */// 먼저 캐시된 총 잔액 정보를 화면에 표시const cachedBankTotalBalance = await fetchCachedBankTotalBalance();render(sum(cachedBankTotalBalance));// 금융회사 병렬 요청Object.keys(bankBalanceInfo).forEach(async (bankCode) => { const bankBalance = await fetchBankBalance(bankCode); updateAndRender(bankCode, bankBalance);})이 방법을 통해 사용자는 더욱 신속하고 안정적으로 은행 잔액 정보를 확인할 수 있게 되었습니다!4. WebSocket 적용으로 더 나아가기지금까지 3개 금융회사의 은행 잔액 조회 예시를 살펴봤습니다. 그러나 “내자산” 서비스는 은행뿐만 아니라 카드, 증권 등 다양한 금융회사의 데이터 조회도 지원합니다. 이는 갱신을 위한 HTTP API 요청의 증가를 의미합니다.자산이 100개라면 … ?이러한 상황을 개선하기 위해, Socket.IO 기반의 사내 플랫폼을 활용하여 WebSocket을 적용하였습니다.WebSocket 과 Socket.io 에 대한 자세한 내용은 NAVER D2 포스팅에서 확인 가능합니다.WebSocket은 한 번의 연결 설정으로 지속적인 데이터 교환을 가능하게 합니다. 이는 기존의 HTTP 요청 방식과 비교할 때 큰 이점을 제공합니다. HTTP 방식에서는 각 요청마다 헤더와 추가 데이터를 보내야 하지만, WebSocket은 초기 연결 설정 후에는 이러한 오버헤드 없이 데이터를 전송할 수 있습니다. 이는 특히 많은 양의 데이터를 실시간으로 주고받아야 하는 경우, 더욱 효율적입니다.한 번의 갱신 요청 API를 통해 전체 금융회사에 대한 데이터 요청을 하고, 응답은 조회가 완료된 금융회사 데이터부터 WebSocket을 통해 받습니다. 이 방식은 사용자가 신속하고 원활하게 실시간 데이터를 수신할 수 있게 해줍니다. 이 접근법은 네트워크 트래픽과 서버 부하를 줄이는 동시에 사용자에게 빠른 반응 속도를 제공하는 이점이 있습니다.한 번의 갱신 API 호출 후, Socket.IO 를 통하여 여러개의 응답을 받습니다.// 각 금융회사의 잔액 정보를 저장하는 객체const bankBalanceInfo = { 금융회사A: null, 금융회사B: null, 금융회사C: null,}// 캐시된 총 잔액 정보를 먼저 가져오는 함수const fetchCachedBankTotalBalance = async () => { const cachedBankTotalBalance = await fetch('/api/bank/cached-total-balance'); return cachedBankTotalBalance; // 예: { 금융회사A: 100,000,000원, 금융회사B: 200,000,000원, ... }}// 전체 은행 잔액을 갱신 요청const updateRequestBankTotalBalance = async () => { await fetch('/api/update-request/bank/total-balance');}// 금융사별 잔액 업데이트 및 화면에 표시const updateAndRender = (bankCode, balance) => { bankBalanceInfo[bankCode] = balance; render(sum(bankBalanceInfo));}/* ================================================================== */// WebSocket을 사용하여 갱신된 은행 잔액을 실시간으로 받을 준비const socket = io();socket.on("updateComplete", (bankCode, balance) => { updateAndRender(bankCode, balance);})// 먼저 캐시된 총 잔액 정보를 화면에 표시const cachedBankTotalBalance = await fetchCachedBankTotalBalance();render(sum(cachedBankTotalBalance));// 전체 은행 잔액을 갱신 요청updateRequestBankTotalBalance();이 방법을 통해 사용자는 더욱 신속하고 안정적으로 은행 잔액 정보를 확인할 수 있게 되었습니다!“내자산” 에서 WebSocket을 단방향 통신에만 사용하여, 응답 결과를 수신하는 데에 한정하고 있습니다. 이는 WebSocket의 양방향 통신 능력을 완전히 활용하지 못하고 있음을 나타냅니다. 실제로, 단방향 통신 상황에서는 Server-Sent Events(SSE)를 도입하는 것이 더 적합한 대안이 될 수 있습니다. 또한, WebSocket을 통한 데이터 통신의 효율성을 높이긴 했지만, 일부 데이터는 여전히 별도의 HTTP 요청을 통해 수집되는 상황이 발생하고 있습니다. 이는 향후 개선이 필요한 영역입니다.단순하지 않은 은행 총 잔액 컴포넌트지금까지 은행 총 잔액 컴포넌트 구현을 위해 기본 API 요청부터 WebSocket 통신에 이르기까지 단계별 과정을 살펴보았습니다. 단순한 기술적 최적화를 넘어서, 사용자 경험의 지속적인 향상에도 주의 깊게 노력하고 있습니다.이러한 점진적 개선 방식과 사용자 중심의 접근이 데이터 처리와 최적화를 고민하는 다른 개발자분들에게 조금이나마 도움이 되었기를 바랍니다.이 글을 읽어주셔서 감사합니다! 네이버페이 내자산&회원FE 팀은 긍정적인 에너지와 열정을 가진 팀원들이 모여, 지속적으로 성장하고 발전하는 환경을 조성하고 있습니다.이런 동기부여되는 분위기에서 함께 일할 새로운 팀원을 항상 찾고 있습니다. 저희 팀과 함께 성장하고 싶으신 분들은 언제나 환영합니다!채용: https://recruit.naverfincorp.com/내자산: 마이데이터 자산 조회 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

공개키 암호화와 키관리 (MPC)
네이버 페이
공개키 암호화와 키관리 (MPC)

안녕하세요, 네이버페이 금융인프라개발 임지선 입니다.공개키 암호화의 개념과 거기에서 사용되는 개인키를 안전하게 보관하기 위한 키 암호화 방식에 대해 소개합니다.공개키 암호화단방향 암호화단방향 암호화의 경우 어떠한 해시 함수를 활용하여 암호화만 가능하고 복호화가 불가능합니다. 사이트의 비밀번호를 저장하는 등의 활용이 가능합니다. 사이트에서는 비밀번호의 해시값만 저장하고, 이는 복호화가 불가하기 때문에 비밀번호를 분실할 경우 새로운 비밀번호를 설정해야 합니다.사용 알고리즘 예시: SHA-256 해시 알고리즘항상 256비트의 고정된 사이즈로 출력되는 해시함수. 단방향으로 암호화만 가능하고, 입력값으로 출력값을 유추하거나 출력값을 다시 복호화 할 수 없습니다. 출력속도가 매우 빠른것이 장점으로, 블록체인 등에서 사용됩니다.비밀키 암호화비밀키 암호화는 하나의 비밀키로 암복호화를 하는 방법입니다. 계산 속도가 빠르며, 암호화와 복호화에 동일한 키를 사용할 때의 문제점은, 어떠한 암호화된 문서를 전달하기 위해서는 어떠한 방식으로든 비밀키를 전달해야 한다는 것입니다. 비밀키만 있다면 누구든지 복호화를 할 수 있기 때문에 유출될 경우를 대비해야 할 것입니다.사용 알고리즘 예시: AES키의 길이와 관계없이 128비트의 고정된 블럭 단위로 쪼개어 암호화를 진행합니다 (이때 128비트보다 크기가 작은 블럭이 발생할 경우 임의의 값으로 채우는 Padding으로 해결). 블록 암호 방식 중 CBC (Cipher Block Chaining)를 예시로 확인하면, 128비트로 블럭을 나눈 후 각 블럭을 앞 블럭의 Cipher Text와 XOR 연산을 수행하여 암호화하는 방식입니다. 이때, 가장 첫 블럭은 앞 블럭이 없으므로 IV (Initialization Vector)로 계산합니다.이미지 출처: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation이 외에도 AES-ECB (Electronic Code Book), AES-CTR (Counter) 방식이 있습니다. 행렬을 이용한 자세한 계산방법은 생략.공개키 암호화공개키 암호화는 암복호화에 사용하는 키가 다른 암호화 방식입니다. 누구나 사용할 수 있는 하나의 키를 공개키, 나머지 하나의 키를 개인키라고 합니다. 공개키를 이용해 암호화 한 후 개인키를 이용해 복호화 할 수도, 개인키를 이용해 암호화한 후 공개키로 복호화 할 수도 있습니다.예시1: 신원 증명A가 개인키를 이용해 어떤 문서를 암호화공개키를 공개해 B가 A의 개인키로 문서를 복호화.이를 통해 암호화된 문서가 변질되지 않았으며 발행인이 A임을 증명합니다.예시2: 안전한 데이터 전송A가 공개되어 있는 B의 공개키를 이용해 암호화암호화된 내용은 B의 개인키로만 복호화 가능.비밀키를 전달하지 않으면서 안전하게 암호화된 내용을 전달할 수 있습니다.가장 대표적인 공개키 암호화 사용으로는 HTTPS에 사용되는 SSL/TLS 인증서가 있습니다. 브라우저는 인증서를 확인한 후 세션키를 공개키로 암호화해 전송합니다. 서버는 개인키를 이용해 세션키를 얻고 세션키로 메시지를 암호화합니다. 공개키 암호화의 경우 비밀키 암호화보다 느리고 계산이 복잡하므로 세션이 만료되기까지 브라우저와 서버는 세션키를 사용하여 암복호화를 합니다.사용 알고리즘 예시1: RSA소인수분해의 어려움에 기반한 알고리즘으로, 두개의 큰 소수를 활용해 공개키와 개인키를 생성합니다. 상세한 방법은 아래와 같습니다.두개의 소수 p, q를 곱하여 N을 만든다.φ(n) = (p — 1)(q — 1) 보다 작은, φ(n)와 서로소인 정수 e를 찾는다.de ≡ 1 (mod φ(n)) 인 정수 d를 찾는다.이때 p와 q를 이용한 공개키는 <N, e>, 개인키는 <N, d> 입니다.RSA에서의 암복호화공개키 암호화에서 중요한 것은 개인키의 보안입니다. 개인키가 유출될 경우 실효성을 잃게 되기 때문입니다. 개인키의 소유 당사자만 키를 소유하는 경우 개인키의 분실시 복구할 수 있는 방법이 없습니다. 개인키의 보안을 유지하며 관리하기 위한 방법에는 몇가지 대표적인 방식이 있습니다.MPC (Multi Party Computation, 다자간 연산)이미지 출처: https://www.pentasecurity.com/solutions/blockchain/mpc-solution/MPC는 이름 그대로 비밀로 하고자 하는 값 (개인키)을 여러 end point (3개 이상)로 나누어 각자의 입력값으로 연산을 수행하는 방식입니다. 이때 각 end point는 전체 비밀 값을 알 수 없기 때문에 개인키가 유출될 수 있는 단일 실패점 (SPOF, Single Point Of Failure. 전체 시스템 중단을 야기하는 약점)을 제거합니다.MPC vs Multi-Sig이미지 출처 : https://scalablesolutions.io/news/cryptocurrency-exchange-hacks/다중 서명 (Multi-Sig)은 키를 여러곳에 나누어 저장한다는 점에서 MPC와 비교될 수 있습니다. MPC가 키 자체를 쪼개어 각각 연산을 수행한다면, Multi-sig는 키를 여러개 생성하여 서명하는 방식입니다.각 end point의 오프체인으로 서명을 진행하는 MPC와 달리 Multi-Sig 방식은 여러 키를 하나로 모아 하나의 온체인에서 서명을 진행합니다. MPC와 비교하였을 때 키 분실과 권한 변경에 유연하지 못하고, 키 유출 위험이 존재합니다. (오프체인과 온체인에서 오는 차이)이미지 출처: https://www.fireblocks.com/blog/mpc-vs-multi-sig/SSS (Sharmir’s Secret Sharing)SSS는 키의 독점을 방지하는 비밀공유방식으로 아래 두가지 조건을 만족합니다.하나의 Secret을 n개로 나누어 분산해 저장한다. (share)k개 이상의 Share를 이용해야 Secret을 복원할 수 있다.이를 만족하기 위해 SSS는 “서로 다른 k개의 점을 지나는 (k-1)차 다항식은 유일하게 존재한다.”는 명제를 기반으로 합니다.예를 들어, 2차 다항식 y = ax² + bx + c에서 a, b, c를 구하기 위해서는 3개의 점이 필요하다. 다시 말해 n차 다항식의 계수를 구하기 위해서는 n + 1개의 점이 필요한 것입니다. SSS에서는 이를 활용하여 상수항 S를 가지는 임의의 k-1차 다항식을 생성하고, k개의 점(share)을 이용해 다항식을 복원해 S를 구합니다.TSS (Threshold Signature Scheme)위의 SSS 방식에서의 허점은, 누가 처음 키(S, Secret)를 분배할 것인가? 입니다. 최초의 다항식을 생성하기 위해서는 누군가 S의 원형을 알고 있어야 하기 때문입니다. TSS는 여기에서 DKG (Distributed Key Generation, 분산 키 생성) 방식을 활용합니다. DKG는 다수의 참여자가 S 값을 모르는 상태에서 share를 생성할 수 있도록 합니다.위 예시로 확인해보면 도출된 (1, a=19), (2, b=23), (3, c=27)로 y = 4x + 15, 즉 S 값 (y절편)이 15임을 계산할 수 있지만 각 참여자는 이를 확인할 수 없습니다.SSS방식에서는 S의 복원시에도 동일한 문제가 발생합니다. 단일 시스템이 S를 재조립함으로 S의 원형을 확인할 수 있기 때문입니다. TSS는 이를 임계값 서명 (Threshold Signing)을 이용해 해결합니다. 임계값 서명은 여러 서명에서 단일 디지털 서명을 생성하는 방식으로 S의 복원 없이도 서명이 가능하도록 합니다.여기까지 암호화의 여러 종류와 MPC에 대해 간단하게 정리해보았습니다. 읽어주셔서 감사합니다.참고자료https://en.wikipedia.org/wiki/Block_cipher_mode_of_operationhttps://blog.naver.com/mage7th/222456495143https://scalablesolutions.io/news/cryptocurrency-exchange-hacks/https://www.pentasecurity.com/solutions/blockchain/mpc-solution/https://www.fireblocks.com/blog/mpc-vs-multi-sig/https://academy.binance.com/en/articles/threshold-signatures-explainedhttps://medium.com/atomrigslab/%EC%B4%88%EB%B3%B4%EC%9E%90%EB%8F%84-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-%EC%89%AC%EC%9A%B4-secure-mpc-%EA%B8%B0%EB%B0%98-multi-party-ecdsa-%EC%9D%B4%EC%95%BC%EA%B8%B0-d36aeb9e5ec7https://en.wikipedia.org/wiki/Shamir%27s_secret_sharinghttps://en.wikipedia.org/wiki/Distributed_key_generationetc. (관련해서 추천드리는 유튜브 영상들)AES : https://youtu.be/C4ATDMIz5wc?si=30ckR0aOVcqgNDwcRSA 원리 : https://youtu.be/vcODg8X4OMQ?si=rfH8EtDP7q1FYpnn공개키 암호화와 키관리 (MPC) was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.

deFign : 네이버 페이의 디자인 시스템을 정의하다
네이버 페이
deFign : 네이버 페이의 디자인 시스템을 정의하다

deFign : 네이버페이의 디자인 시스템을 정의하다안녕하세요. 네이버페이 금융FE, 디자인시스템TF 소속 안재연입니다.출처: https://m.post.naver.com/viewer/postView.naver?volumeNo=36104441&memberNo=30633733지난 6월 21일, 네이버페이 모바일 5탭이 오픈되었습니다. 네이버 증권과 부동산이 네이버페이 소속으로 변경된 후 네이버페이 하나로, 결제부터 금융까지 이룰 수 있는 첫 걸음을 떼게 되었습니다. 이와 함께 네이버페이의 모든 서비스를 한 눈에 볼 수 있도록 각 서비스 하단에 5탭 컴포넌트가 추가되었습니다.‘모든 서비스에서 같은 컴포넌트를 보여준다면, 한 곳에서 개발하고 관리하는게 편하지 않을까?’라는 생각부터 출발하여 네이버파이낸셜의 모든 서비스에서 공통으로 사용할 수 있는 디자인 시스템을 설계하게 되었습니다.이 글의 대상 독자는 디자인 시스템을 도입 예정이시거나, 도입하는 과정에서 방향성으로 고민하시는 분들 혹은 디자인 시스템에 관심을 가지고 계시는 분들입니다.Design System디자인 시스템이 무엇인지 생소하신 분들도 있을 것이라 생각합니다. Design은 말 그대로 화면의 UI를 어떻게 설계할 지 고민하는 것이고, System은 하나의 체계를 갖춘다는 의미입니다. 즉, Design System이란 화면의 UI 요소 중 공통 패턴과 주요 컴포넌트를 추출하여, 구성원들이 이를 효율적으로 사용하는 하나의 프로세스를 의미합니다.네이버페이는 어떻게 디자인 시스템을 도입하게 되었을까요?초기의 네이버페이는 운영하고 있는 서비스들의 규모가 그렇게 크지 않았기 때문에, 각 서비스에서 개선 요청 사항들을 각각 반영하고 있었습니다.하지만 운영하는 서비스가 증가하고, 각 서비스의 규모가 커지면서 조금씩 문제 상황이 발생하기 시작했습니다. 동일한 UI임에도 각 서비스에서 UX 향상을 위해 추가한 개선 사항들이 서비스별로 조금씩 다른 동작, 조금씩 다른 코드들을 초래한 것입니다.저희는 디자인 시스템 도입으로 위와 같은 상황을 개선하고자 했습니다. 사용자가 네이버파이낸셜의 모든 서비스에서 동일한 UI, UX를 경험할 수 있고, 개발자가 공통된 UI를 서비스에 쉽게 적용할 수 있도록 디자인 설계부터 마크업 조직, 개발 조직과 함께 기준을 세워 작업의 생산성 향상을 도모하였습니다.우리가 만들고 싶은 디자인 시스템디자인 시스템을 만드는 구성원들과 함께 이야기 해 보았을 때, 우리가 만들고 싶은 디자인 시스템의 키워드는 다음과 같았습니다.#일관성 #효율성 #커뮤니케이션_기준 #공통_UI_라이브러리 #공통_인터랙션 #유연성 #확장성 #UX_가이드 #UX_Writing_가이드 #스토리북키워드를 중심으로 대원칙을 설계하고 작업을 시작하게 되었습니다.대원칙 1. 디자인 변경에 대한 파편화는 가급적 통제한다.대원칙 2. 디자인 변경사항의 빠른 적용을 위한 구조를 설계한다.대원칙 3. 각자의 생각과 노력은 하나의 언어로 표현하여 동일한 이해도를 가져간다.디자인 시스템 작업 과정 살펴보기디자인 시스템 작업 과정을 간단히 소개하면 다음과 같습니다.컴포넌트 단위로 디자인을 설계하여 Figma에 추가합니다.디자인 시스템 컴포넌트에서 사용하는 수치들은 디자인 토큰으로 추출하여 모든 서비스, 컴포넌트에서 함께 사용합니다.컴포넌트는 디자인, 마크업, FE 조직이 함께 만듭니다.디자인 토큰 추출하기모든 서비스, 컴포넌트에서 공통으로 사용하는 디자인 토큰은 자동화하여 추출했습니다.자동화 추출에는 Figma API와 Figma Plugin을 사용하였습니다.Figma API : https://www.figma.com/developers/apiFigma Plugin : https://www.figma.com/plugin-docs/개발에서 사용하기 편한대로 추출 형태도 다양화 하였습니다.컴포넌트 개발하기각 서비스에서 공통적으로 사용하는 UI는 사용성 검토 후 Figma 컴포넌트로 도출합니다. 디자이너가 컴포넌트와 variant(option)을 정의하면 마크업 개발자와 FE 개발자가 컴포넌트를 리뷰하고, 더 나은 사용성을 위해 variant와 디자인 등을 조정합니다.이후 마크업과 FE 개발자들은 추출한 디자인 토큰을 활용하여 컴포넌트 개발을 시작합니다. 이때 엣지 케이스 대응, 디바이스 분기, 에러 케이스 대응을 추가하여 최대한 많은 경우를 커버할 수 있도록 개발합니다.스토리북으로 컴포넌트를 사용하는 개발자들의 생산성 향상만들어진 컴포넌트와 디자인 토큰은 스토리북을 통해 쉽게 형상을 파악할 수 있도록 하였습니다. 다양한 서비스에서 사용하는 만큼, 각 서비스마다 다른 버전을 사용할 수 있어 버전 별로 스토리북 확인이 가능하도록 기능을 추가해두었습니다.공통 인터랙션 혹은 hook 사용이 필요한 컴포넌트의 경우 Docs를 보다 구체적으로 작성하였습니다. 사용법과 사용 예시를 모두 첨부하여 Docs만으로도 손쉽게 컴포넌트 적용이 가능합니다.Footer 컴포넌트 개발기deFign에 대한 소개에 이어 FE 개발자로 디자인시스템 Footer 컴포넌트를 개발하고 출시하기까지의 과정과 경험을 공유합니다.1. 디자인 전달Footer 컴포넌트는 네이버페이 모든 서비스 최하단에 노출되는 컴포넌트입니다. 각 서비스마다 노출되는 정보가 다르기 때문에 디자이너분께서 모든 케이스를 정리해서 figma로 공유해주셨습니다.2. 초기 인터페이스 고민처음 전달받았던 Footer 컴포넌트는 FooterQuickMenu 컴포넌트와 FooterInfo 컴포넌트로 구성되어 있었습니다. 서비스 별로 노출할 정보를 기준으로 어떤 정보를 props로 받아야 하는 지, static한 정보로 보여주어야 할 지 고민했습니다.제가 초기에 생각했던 인터페이스는 다음과 같습니다.FooterQuickMenu : 메뉴 종류와 개수는 서비스마다 다르니, 메뉴 List를 props로 받아서 노출하는 형식// FooterQuickMenu props 예시const props = [ { title : '로그아웃', }, { title : '면책조항', tooltip : true, // 메뉴에 툴팁을 노출하는 케이스 tooltipIcon : <TooltipIcon />, }]FooterInfo : 법적 고지도 서비스마다 달라 props로 받기 / 사업자 정보는 동일하기 때문에 static3. 디자인시스템 팀원들과 함께 인터페이스 의논해보기제가 고민한 인터페이스가 완벽하지 않을 수 있기 때문에, 팀원들과도 함께 검토해 보았습니다.팀원들은 Footer에서 노출하는 정보가 각 서비스마다 하나로 고정된다면, 서비스 type만 props로 받고 필요한 정보는 디자인 시스템에서 모두 제공하는 것이 더 사용성이 좋겠다는 의견을 제시해주었습니다. 또 Footer는 사용처에서 굳이 FooterQuickMenu, FooterInfo를 각각 선언해서 사용할 필요가 없으니 Footer 컴포넌트 하나로 사용자에게 제공하는 것이 어떻겠냐는 의견을 주셨습니다.그리하여, 최종 Footer의 인터페이스를 다음과 같이 정할 수 있었습니다.<Footer type='stock' // type으로 각 서비스의 타입 전달 overrideMenu={{ // 기존에 노출되던 메뉴의 title, url 등을 수정할 때 전달 '전체서비스': { url: 'https://new-url.naver.com' } }} /> Footer 컴포넌트 뿐만이 아니라, 모든 컴포넌트 제작 과정에서 인터페이스를 다 함께 고민하였었는데, 이 과정 덕분에 더 좋아진 사용성을 가진 컴포넌트를 제작할 수 있었던 것 같습니다.4. 예상치 못했던 예외 케이스의 등장계획한 대로, 예상한 대로 모든 일이 진행된다면 정말 좋겠지만 그렇지 않은 것이 현실입니다. Footer 컴포넌트를 개발할 때도 예상치 못했던 예외 케이스가 등장하였습니다.Footer 컴포넌트에서는 각 서비스의 type만 전달받기로 하였는데, 부동산에서만 각각 다른 8개의 Footer가 필요하다는 요청이 추가된 것입니다. 기존의 인터페이스에서 overrideMenu prop을 제공하였지만, 현재 노출되고 있는 메뉴의 title, url 정도만 수정이 가능하고 새로운 메뉴를 추가할 수는 없었기 때문에 overrideMenu 를 활용할 수는 없었습니다.처음에는 type을 8개를 추가해야 할까 고민하였습니다. 하지만 공통적인 부분만 부동산 type으로 제공하고, 수정이 필요한 menu는 custom해서 사용할 수 있도록 overwriteMenuList prop을 열어주는 방향으로 진행하게 되었습니다.<Footer type='realEstate' overwriteMenuList={[ { title: isLogin ? FOOTER_QUICK_MENU_TITLE.LOGOUT : FOOTER_QUICK_MENU_TITLE.LOGIN, href: '#', onClickLogin: () => { alert('클릭 로그인'); }, onClickLogout: () => { alert('클릭 로그아웃'); }, }, { title: FOOTER_QUICK_MENU_TITLE.ALL_SERVICE, href: isMobile ? URL.ALL_SERVICES.MOBILE : URL.ALL_SERVICES.PC, }, FOOTER_QUICK_MENU_ITEM_MAP.realEstate['약관 및 정책'], // Footer에서 기본으로 사용하던 요소는 export 해 두어, 사용처에서 쉽게 재선언이 가능하도록 했습니다. ]}/>5. 적당히 열리고 적당히 닫힌 인터페이스overwriteMenuList prop을 추가할 때 부동산 서비스 개발자분께서 ‘overwriteMenuList를 사용하려면 Footer에서 기본으로 사용하는 모든 menu들도 재작성해야하나요?’라는 질문을 해주셨습니다.서비스별로 공통적으로 사용하는 menu라면 상수로 선언한 후에 export 해 두면, overwriteMenuList를 사용하더라도 각 서비스에서 모든 menu를 재작성할 필요 없이 필요한 것만 import하여 간편하게 사용할 수 있겠다는 생각이 들었습니다. 그래서 추가된 것이 위 예시의 FOOTER_QUICK_MENU_ITEM_MAP 입니다. 원하는 메뉴는 이미 FOOTER_QUICK_MENU_ITEM_MAP 에 선언되어 있으니, 가져다 쓰기만 하면 됩니다.이처럼, Footer를 만들면서 어떻게 하면 사용처에서 조금 더 쉽게 사용할 수 있을까를 많이 고민하게 되었습니다.너무 열어준 인터페이스를 제공하면, 디자인 시스템 Footer는 공통된 마크업만 전달할 뿐, 사용처에서는 모든 정보를 직접 선언해서 사용해야 했습니다. 사실상, 이미 각 서비스에서 사용하고 있던 Footer와 별반 다를게 없었죠.반면 너무 닫힌 인터페이스를 제공하면, 위의 부동산 Footer의 경우처럼 예상치 못한 케이스에 당황하는 사태가 발생하게 됩니다.그래서 인터페이스를 제공할 때 많은 고민과 대화를 했었습니다. Footer 컴포넌트를 만들면서 느낀 점은, 각 서비스의 개발자 분들과 대화를 하면서 적당히 열림과 적당히 닫힘 사이의 인터페이스를 함께 만들어 나가야 한다는 것이었습니다. 열림과 닫힘 사이 딱 좋음이란 기준은 너무나도 주관적이어서 제가 결정할 수 있는 것이 아니더라구요. 서비스 개발자 분들과 꾸준히 대화를 하면서 어떻게 하면 더 편하게 사용할 수 있을까를 끊임없이 물어보았던 과정이 더 나은 인터페이스를 가능하게 해주었다고 생각합니다.deFign : 네이버페이의 디자인 시스템을 정의하다.이런 과정을 거쳐 하나둘씩 만들어진 네이버페이의 디자인 시스템이 바로, deFign입니다. 네이버페이의 디자인 시스템을 정의한다는 의미를 담고 있습니다. (사담이지만, 이름이 정말 마음에 듭니다🥰)2023년 6월 21일 네이버페이 모바일 5탭 서비스를 오픈에 맞추어 모든 서비스에 디자인 시스템 컴포넌트들이 적용되었습니다. 이제 공통된 컴포넌트는 모두 deFign에서 설계, 개발, 관리됩니다.deFign의 도입으로 네이버페이의 어떤 서비스를 이용하더라도, 같은 UI와 사용성을 가진 컴포넌트를 마주할 수 있어 사용자 경험은 보다 향상될 것입니다. 또한 각 서비스의 개발자 역시 반복되는 비슷한 코드의 추가, 분기문 추가의 지옥을 벗어나 보다 효율적인 개발 life가 가능해 질 것입니다.앞으로의 deFign저희 deFign 팀은 version 1.0.0 오픈에 이어 Phase 2도 준비하고 있습니다. 더 많은 UI의 공통화, 동일한 언어로의 설계를 이루어 많은 디자이너와 개발자의 생산성 향상에 힘 쓸 예정입니다.이와 함께 통계 시스템을 구축하여, 사용자가 어떻게, 얼마나 디자인 시스템을 사용하는지에 초점을 맞추어 방향을 설계할 계획입니다.더 발전할 deFign 기대해 주시길 바랍니다!긴 글 읽어주셔서 감사합니다.deFign : 네이버 페이의 디자인 시스템을 정의하다 was originally published in NAVER Pay Dev Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.