[{"content":"아버지께서 시 한 편을 쓰셨다. \u0026ldquo;참회(懺悔)\u0026rdquo; 라 이름 붙이셨다. 시는 길고, 한 번에 다 읽히지 않는다. 시를 그대로 옮기고 짧게 감상문을 써볼까 한다.\n참회(懺悔)\n술을 먹으면 알딸딸하게 좋다가도\n새벽에 악몽으로 깨고 나면\n왜 이리 심장이 끊어질 듯 아려오는가?\n저 깊고도 아득한 기억의 저편\n음습한 혼돈의 바다 속\n잠자던 악령이 깨어나\n사지를 쇠사슬로 묶고\n펄떡이는 심장을 도려낸다.\n도대체 왜?\n한편의 일그러진 분노와\n또 한편의 형언할 수 없는 불안\n뜯겨나간 허탈한 자리에\n불현듯 부끄러움이 밀려든다.\n중학교 때였던가?\n참 마음이 고왔던 아이\n소아마비로 다리를 절었던 아이\n그 누구보다도 당당했던 아이\n서로 마주보다가 묘한 감정에\n서로를 밀어냈던 애틋한 아이\n난 쬐끔, 넌 악성 소아마비\n병신새끼들! 육갑떨고 자빠졌네.\n짝패들의 시선과 조롱에 움츠러들며\n너를 홀로 화살받이로 세워놓고는\n숨더니 외면하고 도망치는 나.\n백옥의 얼굴이 촛농처럼 흘러내리며\n점점 악다구니로 변해가는 너.\n\u0026lsquo;미안해\u0026rsquo; 말도 못하고 피해 다니는\n\u0026ldquo;비겁한 새끼\u0026rdquo;\n나의 또 다른 이름\n고등학교 ?학년 때였지.\n\u0026ldquo;잘난 체 하지 마!\u0026rdquo;\n짝궁이 하는 말에\n대뜸 인정사정없이\n그의 여린 뺨에 주먹을 날린다.\n뭐 대단치도 않은 말에\n의기양양 거들먹거리며\n왜 그리 거만하게 굴었는지?\n\u0026ldquo;못난 새끼\u0026rdquo;\n또 다른 나의 악령\n그 모습을 지켜보다 못한\n절친의 \u0026ldquo;너무 하네!\u0026rdquo;\n한마디에 절교를 한\n\u0026ldquo;옹졸한 새끼\u0026rdquo;\n또 하나의 악업이 추가된다.\n언제였던가?\n육중한 체구의 그들은\n칠성판에 뉘어 놓고 사지를 비틀며\n나의 부끄러운 심장을 해부한다.\n나는 수백 번의 진술서를 써내려가며\n\u0026ldquo;비겁한 새끼\u0026rdquo;,\n\u0026ldquo;못난 새끼\u0026rdquo;,\n\u0026ldquo;졸렬한 새끼\u0026quot;임을 자인한다.\n신념과 의지는 한낱 공염불이었음을\n수십 번의 반성문으로 또 다시 입증한다.\n나의 육신과 영혼은 너덜너덜 찢긴 채\n쇠사슬에서 풀려나 허공 속으로 흩어진다.\n이따금 미몽을 헤맬 때마다\n뫼비우스 띠처럼\n한편의 광기와\n반대급부의 수치심이\n당혹스럽게 외길에서 만나\n과거와 현재가 얼굴을 마주한다.\n그들이 나의 심장을 도륙하듯\n나 또한 너의 심장을 도려냈구나!\n그들이 나의 영혼을 난도질하듯\n나 또한 너의 영혼을 산산이 부숴났구나!\n그렇게 잊혀진 세상은!\n현란한 시니피앙의 깃발에 난무하는\n시니피에의 지루박 댄스 장\n시니컬한 포스트모더니즘 장단에\n초조한 욕망들만이 아우성친다.\n기껏 살아낸 삶의 족적들, 나의 새끼들!\n더러운 새끼, 치사한 새끼, 야비한 새끼\n오만한 새끼, 응큼한 새끼, 추잡한 새끼\n편협한 새끼, 냉담한 새끼, 비열한 새끼\n교활한 새끼, 악랄한 새끼, 역겨운 새끼…\n무수한 새끼들이 옷깃을 여미며\n버젓이 음흉한 거리를 활보하는데…\n인생은 꼬일 대로 꼬여버린 새끼줄\n새빨간 위선에 포박당한 오랏줄.\n황혼 녘 시뻘건 구름이 취한 듯\n상처받은 영혼들이 비틀거린다.\n심장과 영혼들이 녹아내리며\n붉게 물든 얼굴이 일그러진다.\n질척대는 욕망의 잔재를 태우고\n내안의 순수를 깨울 수 있다면\n영혼을 정화하고 위로하기위해\n너와 나, 우리의 희망을 위해\n새로이 피어나는 여명을 위해…\n슬픈 대지를 품에 안은 붉은 노을\n스러질 듯 몸부림치는 불길 속을\n부나방이 되어 걷고 또 걸어간다.\n몽환적인 핑크보랏빛 그라데이션에\n취해 흑갈색 침묵 속으로 사라지더라도…\n시를 다 읽고 한참을 멈춰 있었다. 처음 읽었을 때는 단어가 너무 강해서 따라가기가 어려웠다. 두 번째 읽으면서 \u0026ldquo;그들이 나의 심장을 도륙하듯 / 나 또한 너의 심장을 도려냈구나\u0026rdquo; 한 구절에서 한 번 더 멈췄다. 아버지가 칠성판에서 사지가 비틀린 그 자리에서, 본인이 도망쳤던 소아마비 친구의 얼굴이 다시 떠올랐다는 게 시 안에 적혀 있다. 받은 고통이 준 고통을 비춘다는 인식이 시 전반에 걸쳐 있다.\n아버지가 부끄러워하는 그 \u0026ldquo;새끼들\u0026rdquo; — 비겁한 새끼, 옹졸한 새끼, 졸렬한 새끼 — 을 한 줄씩 읽으면서 다른 생각이 들었다. 그 이름들을 차마 입에 담지 못했던 시간이 길었을 텐데, 시 안에서는 그 이름을 다 부르고 있었다. 오랫동안 안에 쌓아두셨던 부끄러움을 끝내 시로 꺼내놓으신 일 자체가 다행스러웠다. 그 부끄러움을 끝내 글로 꺼낸 사람이라는 게 단어들 사이로 보였다. 아버지가 두려워하는 그 새끼들이 내게는 그렇게 무서운 얼굴로 보이지 않는다. 시를 읽으면서 아버지의 부끄러움을 처음 만난 것 같았다.\n마지막 연을 한참 들여다봤다. 황혼의 불길 속을 부나방이 되어 걷고 또 걷는다. 침묵 속으로 사라진다고 적혀 있지만, 나는 그 부분이 사라지는 장면으로 읽히지 않았다. 시가 끝났는데도 부나방의 발걸음만 계속 가고 있는 게, 아버지가 시 한 편으로 참회를 끝내지 않으셨다는 인상으로 남았다. 시를 끝까지 읽었고, 한 번 더 읽었다.\n","permalink":"https://wid-blog.github.io/posts/daily/notes/fathers-repentance/","summary":"아버지께서 쓰신 시 \u0026lsquo;참회(懺悔)\u0026rsquo; 의 전문과 내가 적은 짧은 감상문.","title":"아버지의 '참회' 시를 읽으며"},{"content":"직접 굴리는 자동매매 시스템을 개발하고 있다. Rust + Python + React 로 시세 조회, 백테스트, 자동 주문, 시그널 매매까지 기능은 갖췄다.\n그동안 PR 흐름을 돌아보면 BackgroundTask supervisor, pre-condition guard, DeploymentStatus::Abandoned 전이, stuck monitor, LocalSim 시세 — 켜지도 않은 시스템에 안전 잠금장치만 줄줄이 만들고 있었다. 처음에는 이상하게 느껴졌다. 다른 기능을 더 만들지 않고 왜 끄는 법부터 짜고 있나.\n자동매매라는 게 그렇다. 잘못 돌아가면 돈이 움직이고, 새벽에 깨워줄 사람도 없고, 아직 실 데이터로 검증하지도 못한 상태다. 이 셋이 겹치니까 \u0026ldquo;잘 동작\u0026rdquo; 보다 \u0026ldquo;안전하게 중지\u0026rdquo; 부터 만들게 됐다.\nETF 리밸런싱과 포트폴리오 자동 관리 만들고 싶었던 건 증권 기반의 자산 관리 시스템이었다. 자산 포트폴리오를 수학적 알고리즘으로 정의하고, 그 정의대로 자동으로 비중을 조정하는 일을 사람 손 없이 굴리는 게 목표였다.\n시작은 ETF 기반 리밸런싱이었다. ETF 는 한 종목을 사면 분산이 따라오니까 종목 선정의 의사결정 비용이 낮고, 매수/매도 빈도도 낮아 거래 비용 부담이 적다. 자산배분 단위의 자동 매매로 첫 단계에 적합했다. 자산배분 전략을 백테스트로 검증하고, 검증된 전략을 계좌에 배포해서 정해진 주기로 비중을 자동으로 맞춘다.\n다음 단계는 개별 종목 단위였다. 팩터 스크리닝으로 종목을 추리고, 백테스트로 검증하고, 시그널 매매로 진입과 청산을 자동화한다. 자산배분보다 의사결정 단위가 더 잘게 쪼개진다. 다만 흐름은 같다 — 수학적 정의를 만들고, 정의대로 자동으로 굴린다.\nRust + Python 분리와 broker abstraction 초기 코드 골격은 두 가지였다. Rust + Python 분리와 broker abstraction. 둘 다 안전을 의도하고 한 결정은 아니었다.\nRust + Python 분리는 워크로드 분담이었다.\n실주문 흐름 — 자동 주문, 잔고 동기화, 시그널 평가 — 은 Rust 서버가 담당한다. 운영 환경(Oracle Cloud ARM Always Free)의 자원이 한정이라 메모리 사용량이 적은 언어가 유리했고, 시그널 매매의 latency 도 GC pause 없는 쪽이 안전하다고 봤다. Rust 를 배우고 싶었던 동기도 있었다.\n백테스트, 팩터 스크리닝 같은 stateless 계산은 Python 퀀트 엔진이 맡고, DB 의존이 없다. vectorbt 가 백테스트를 잘 지원해서 Rust 로 직접 구현할 이유가 없었다.\nbroker abstraction 으로 KIS 와 LocalSim 을 같은 trait 로 추상화했다. 주문 실행기는 trait 만 의존하고, 실제 KIS 구현체와 LocalSim 구현체는 같은 인터페이스를 만족한다. LocalSim 은 처음에는 단순히 KIS 가 없는 환경에서 개발하려고 만든 것이었다.\n운영 진입을 앞두고 다시 보니, 이 둘이 그대로 첫 번째 안전선이 되어 있었다. Rust + Python 분리가 두 프로세스의 장애 전파 범위를 나누고, broker abstraction 으로 모든 자동 흐름을 LocalSim 위에서 dry-run 으로 굴려볼 수 있다.\n자동매매 흐름에 이후에 의식적으로 이어붙인 안전선이 중지, 차단, 감지, 시뮬레이션이다.\nBackgroundTask supervisor 와 abandoned 전이 처음 추가한 안전선은 중지였다.\n가장 먼저 한 작업은 BackgroundTask supervisor 였다. 시그널 엔진, 리스크 모니터, US 시장 폴링 같은 장기 실행 백그라운드 작업이 panic 으로 죽었을 때 그 사실을 아무도 모르는 상황이 위험하다고 봤다. supervisor 가 panic 을 감지하고, 정책에 따라 재기동하거나 영구 정지시킨다. trait 와 enum 으로 restart policy 를 정의해두니 작업마다 다른 정책을 끼울 수 있어서 깔끔했다.\n다음으로 DeploymentStatus::Abandoned 전이를 붙였다. 처음에는 청산이 안 끝나면 운영자가 직접 정지시키는 흐름이었는데, 실제로 며칠씩 stuck 인 채로 두면 위험할 것 같았다. 청산 진척이 일정 시간 이상 없으면 배포 상태가 Abandoned 로 전이되도록 했다. Abandoned 상태에서는 자동 매매 흐름이 멈추고, 더 이상의 주문이 나가지 않는다.\nforce-abandon API 도 같은 흐름에서 추가했다. 자동 전이가 동작하지 않을 경우를 대비해서 외부에서 한 번에 멈추는 수단을 따로 뒀다. \u0026ldquo;절대 멈출 수 없는 상태\u0026rdquo; 가 생기지 않게 하는 게 목적이었다.\n마지막으로 스케줄러에 자동 abandoned 전이를 붙였다. 매일 정해진 시점에 청산 상태를 점검하고, 조건이 맞는 배포는 자동으로 abandoned 처리한다. 사람이 매일 점검하지 않아도 비정상 상태가 누적되지 않게 됐다.\npre-condition guard 와 chat_id gate 다음 작업은 차단이었다. 중지가 동작 중에 거는 잠금이라면, 차단은 시작 자체를 막는 잠금이다.\n첫 번째는 deployment 의 pre-condition guard 였다. 브로커 자격증명이 등록되지 않은 상태에서는 배포 활성화와 청산이 차단된다. 원래는 활성화 도중에 자격증명을 가져와서 누락되면 실패시키는 흐름이었는데, 실패 시점이 너무 늦었다. 활성화 직전에 자격증명을 먼저 확인해서, 누락되면 그 자리에서 차단되도록 바꿨다.\nTelegram chat_id onboarding gate 도 비슷한 결정이었다. 자동 매매가 시작되면 체결, 시그널, 정합성 불일치 알림이 즉시 도착해야 하는데, 알림 채널이 설정되지 않은 상태로 배포가 활성화되면 이슈가 생겨도 알 수 있는 방법이 없었다. 그래서 chat_id 가 등록되지 않은 사용자가 배포를 만들 때 강한 경고를 띄우는 검증 단계를 두었다.\npre-condition guard 도, chat_id gate 도 처음에는 활성화 시점에 잡다가 차츰 앞으로 당겨졌다. 차단을 늦게 잡으면 잘못된 상태가 깊이 들어와서 되돌리는 비용이 컸다.\nstuck monitor 와 잔고 정합성 중지와 차단이 동작하려면 먼저 이상이 감지되어야 했다. 감지가 다음 작업이었다.\nLiquidationStuckMonitorTask 가 첫 번째다. 청산이 진행 중인 배포의 진척을 주기적으로 보고, 일정 시간 이상 변화가 없으면 Sentry 로 알람을 올린다. 자동 abandoned 전이와 짝이 된다 — 자동으로 멈출 시점이 됐다는 신호를 사람이 알 수 있게 됐다.\n잔고 정합성 검증은 결이 좀 달랐다. KIS 의 실 잔고와 장부에 기록된 strategy_position 을 비교해서 불일치가 나면 Telegram 으로 알린다. 자동 매매가 의도한 만큼 정확히 반영됐는지를 브로커 쪽 잔고로 대조하는 일이다. drift 가 생기면 reconcile API 로 보정한다.\n마지막으로 알림 채널을 정리했다. Sentry 와 Telegram 의 Trading/System 채널을 분리해서, 실주문 흐름 관련 이벤트는 Trading 채널로, 그 외 운영/시스템 이상은 System 채널로 라우팅했다. 1인 운영이라 알림이 잡음에 묻히면 감지 자체가 무의미해진다는 게 동기였다.\nLocalSim 과 dev seed 마지막은 시뮬레이션이었다. 실제 자금을 굴리기 전에 전체 회로를 흘려볼 수 있게 만들고 싶었다.\nLocalSim 시세 스트림이 그 출발점이다. 처음에는 단조 함수로 시세를 흉내 냈다. 회로가 동작하는지만 보면 됐을 때는 충분했는데, 시그널 평가와 리밸런싱을 다양한 패턴에서 검증하려니 부족했다. 그래서 GBM 으로 모델을 바꿨다. 종목별로 drift 와 volatility 를 다르게 주면 상승/하락/정체가 섞인 시세가 만들어진다.\ndev trading_credentials seed 도 시뮬레이션을 위해 만든 도구였다. 로컬에서 실주문 흐름을 흘려보려면 자격증명이 필요한데, 매번 수동으로 넣기 귀찮았다. 멱등 UPSERT 로 시드 데이터를 까는 명령을 Makefile target 으로 묶었다. dev 환경 재현이 한 줄로 끝나니까 검증을 자주 돌리게 됐다.\n시뮬레이터가 없으면 실주문이 곧 첫 통합 테스트가 된다. 자동매매에서는 그게 가장 비싼 테스트라 봤다. LocalSim 위에서 며칠을 무사히 도는 모습을 봐야 실주문으로 옮길 자신감이 생긴다.\n회고 켜지 않은 시스템에 끄는 법부터 짜는 게 처음에는 이상했는데, 개발하다 보니 자동매매 + 1인 + 운영 전이 겹친 상황에서 가장 합리적인 순서였다고 느낀다. 다른 기능을 더 만드는 것보다 안전선을 먼저 갖추는 게 운영 진입 시점에 손이 떨릴 일을 줄여준다.\n그래도 켜기까지는 아직 멀었다. LocalSim 위에서 전체 자동 흐름이 일정 기간 무사히 도는 모습을 보고 싶다. 시그널 발생부터 주문, 체결, 잔고 동기화, 청산까지 한 사이클이 깨끗하게 도는지 확인하지 못한 상태에서 실주문으로 옮기는 건 위험하다. 각 안전선이 실제로 작동하는지도 회로 단위로 한 번씩 깨봐야 한다. supervisor 가 정말 panic 을 잡는지, guard 가 잘못된 활성화를 막는지, stuck monitor 가 실제로 알람을 올리는지.\n첫 실주문도 그 자체가 안전선이다. 작은 금액으로 일정 기간을 검증한 뒤에 단계적으로 비중을 늘릴 생각이다. 한 번에 큰 비중을 올리는 건 그동안 만든 안전선과 반대 방향의 결정이다.\n다음 회고는 운영을 시작한 뒤에 쓸 것이다.\n참고 백테스트 성과 지표 — 자산배분과 개별 종목 전략을 검증한 백테스트 지표 포트폴리오 운용과 팩터 스코어링 — 개별 종목 단위 팩터 스크리닝의 배경 기술적 지표와 매매 시그널 — 시그널 매매에 사용한 기술적 지표 한국 계좌 유형별 투자 제약 — KIS 계좌 연동의 배경이 되는 한국 시장 제약 ","permalink":"https://wid-blog.github.io/posts/career/personal/quant-investment-platform-mid-retrospective/","summary":"Rust + Python + React 로 개발하고 있는 개인 자동매매 플랫폼의 중간 회고. ETF 리밸런싱과 개별 종목 시그널 매매까지 기능은 갖췄지만, 운영 진입 전에 안전 잠금장치(중지/차단/감지/시뮬레이션)부터 만들게 된 경위 기록.","title":"quant-investment-platform — 중간 회고"},{"content":"앞선 글에서 백테스트 성과 지표와 다섯 가지 함정 중 하나로 과적합을 짚었지만, 과적합을 어떻게 정량적으로 검증할지는 미뤄두었다. 자매편으로 다룬 효율적 프론티어 최적화 역시 입력 μ, Σ 추정 오차가 결과를 지배하므로 OOS 안정성 검증이 필수다.\nWalk-forward 분석은 시간을 따라 훈련-검증 구간을 반복적으로 적용해 IS(In-Sample)와 OOS(Out-of-Sample) 성과의 차이, 파라미터 안정성을 정량화한다.\n단일 백테스트의 함정 같은 데이터로 파라미터를 탐색하면 IS 성과는 거의 항상 좋아진다. 시그널 후보, lookback, 임계값을 충분히 시도하면 우연히 잘 맞는 조합이 나오기 때문이다. 이 현상이 data snooping이다. IS 결과만 보고 실전에 배치하면 OOS에서 무너진다.\n해법으로 떠올리기 쉬운 방식이 train/test split이다. 데이터를 시간순으로 한 번 자르고 train 구간에서 파라미터를 정한 뒤 test 구간에서 성과를 본다. 하지만 시계열에서는 무작위 셔플을 못 한다. 미래를 보지 않아야 한다는 제약(look-ahead bias) 때문이다. 시간순 한 번 절단은 그 절단점이 좋은 시기였는지 나쁜 시기였는지에 따라 결과가 크게 흔들린다. 단일 split의 OOS 결과는 분산이 크고 시기에 의존적이다.\nWalk-forward의 구조 Walk-forward는 train-test 쌍을 여러 번 만든다. 시간을 따라 슬라이드시키면서 fold마다 train으로 파라미터를 정하고 test로 성과를 측정한다.\n|---- train ----|-test-| |---- train ----|-test-| |---- train ----|-test-| |---- train ----|-test-| 같은 데이터 위에서 여러 OOS 표본을 얻을 수 있고, 그 분포로 전략의 안정성을 판단한다.\nAnchored 방식은 train 시작점을 고정하고 끝점만 확장한다. 시간이 갈수록 train이 길어져 더 많은 과거 정보를 활용한다. 과거 모든 정보가 미래에도 유효하다는 가정에 가깝다.\nRolling 방식은 train 윈도 크기를 고정하고 윈도를 시간 축으로 슬라이드한다. 항상 최근 N년만 사용하므로 시장 구조 변화(regime change)에 적응한다. 오래된 정보는 노이즈라는 가정이다.\n어느 쪽이 맞는지는 자산군과 시장 구조에 따라 다르다. 주식 인덱스에는 anchored가 자주 쓰이고, 거시 환경에 민감한 자산이나 파생 전략에는 rolling이 자주 쓰인다.\n무엇을 측정하나 fold 단위로 측정한 OOS 성과를 모아서 전체 분포를 본다.\n각 fold의 OOS 성과 — fold별 CAGR, Sharpe Ratio, MDD IS-OOS 성과 차이 — IS Sharpe와 OOS Sharpe의 차이. IS 1.5, OOS 0.3이면 1.2 차이로 심한 과적합 신호다. IS 1.0, OOS 0.8이면 안정적인 전략에 가깝다. 파라미터 안정성 — fold마다 선택된 최적 파라미터가 비슷한가. fold마다 다른 lookback이 선택되면 신호 자체가 불안정하다. OOS 분포 — 평균뿐 아니라 fold별 분산과 최악 fold의 손실. 평균은 좋아도 최악 fold가 -40%면 실전 운용은 어렵다. 단일 숫자보다 분포로 판단한다. fold마다 OOS Sharpe가 0.5 근처에서 안정적으로 모이는 전략이, 평균 1.0이지만 fold 분산이 큰 전략보다 신뢰할 만한 경우가 많다.\n케이스: 모멘텀 lookback 튜닝 모멘텀 전략의 lookback 후보를 3개월, 6개월, 12개월로 두자. 단일 백테스트로 전체 기간을 돌리면 평균 Sharpe Ratio가 6개월에서 가장 높게 나올 수 있다. 결론은 \u0026ldquo;6개월이 우승\u0026quot;이 된다.\nWalk-forward로 같은 데이터를 보면 다른 그림이 나온다. fold가 10개라면 fold마다 우승한 lookback이 어떻게 분포하는지를 본다.\n6개월이 7번 우승했다면 안정적인 신호다 3개월이 4번, 6개월이 4번, 12개월이 2번이면 lookback에 안정적인 신호가 없다 전반기 5개 fold에서는 12개월, 후반기 5개 fold에서는 3개월이 우승했다면 regime change 신호다 같은 백테스트 데이터인데도 단일 결과는 \u0026ldquo;6개월 우승\u0026quot;이라는 결정으로 끝나고, Walk-forward는 그 결정의 안정성까지 정량화한다.\n한계 Walk-forward도 만능 검증은 아니다.\n계산 비용 — fold 수 × 파라미터 격자가 빠르게 폭발한다. fold 10개에 lookback 후보 10개면 백테스트 100회를 돌려야 한다. 짧은 시계열 — 10년 미만 데이터에서는 fold 수가 부족하다. 5년 데이터에 fold 5개를 두면 각 fold의 train 구간이 짧아 파라미터 추정 자체가 흔들린다. Survivorship bias는 그대로 — Walk-forward는 시간 분할만 다룬다. 상장폐지 종목이 데이터에서 빠져 있으면 fold마다 같은 편향이 적용된다. Regime change와의 구분 모호 — train과 test의 시장 구조가 다르면 OOS 저조가 과적합 때문인지 regime change 때문인지 구분이 어렵다. 진짜 OOS는 여전히 미래 — 같은 데이터셋 안에서의 OOS는 결국 사후적 분할이다. 진정한 의미의 OOS는 라이브 운용에서의 새로운 데이터뿐이다. 한 단계 더 정교한 방법으로 Combinatorial Purged Cross-Validation이 있다. López de Prado가 제안한 방식으로, 시간순 fold가 아니라 가능한 train-test 조합을 만들되 경계의 정보 leakage를 purge한다. 통계적 검정력은 높지만 구현 복잡도와 계산 비용도 함께 높다.\nWalk-forward는 백테스트 결과의 신뢰도를 정량화하는 도구다. IS-OOS 성과 차이가 작고 fold별 파라미터가 안정적일 때 비로소 \u0026ldquo;운이 아니다\u0026quot;라고 말할 수 있다.\n자매편으로 다룬 효율적 프론티어 최적화도 같은 검증이 필요하다. 마코위츠 모델은 입력 μ, Σ 추정에 민감한데, Walk-forward로 fold마다 입력을 다시 추정하고 결과 비중과 OOS 성과를 본다. 입력이 fold마다 흔들리면 결과 비중도 흔들리고 OOS 성과도 흔들린다.\n참고 자료 Investopedia — Walk-Forward Optimization Investopedia — Overfitting vectorbt — Portfolio Optimization Marcos López de Prado, Advances in Financial Machine Learning (2018), Ch.7 \u0026ldquo;Cross-Validation in Finance\u0026rdquo; Bailey, D., López de Prado, M. (2014). \u0026ldquo;The Probability of Backtest Overfitting\u0026rdquo; ","permalink":"https://wid-blog.github.io/posts/daily/investment/walk-forward-validation/","summary":"백테스트 결과가 운인지 전략 때문인지 정량적으로 가르는 Walk-forward 분석의 구조, IS-OOS 성과 차이와 파라미터 안정성 측정, 모멘텀 lookback 튜닝 케이스, 그리고 분석의 한계.","title":"Walk-forward 분석과 과적합 검증"},{"content":"앞선 글들은 전략 자체의 수학을 다뤘다. 백테스트 지표, 효율적 프론티어, Walk-forward 모두 \u0026ldquo;어떤 비중을, 어떤 시점에, 어떤 종목에\u0026rdquo; 투자할지의 문제였다. 한국 개인 투자자가 그 전략을 실제 운용으로 옮길 때 가장 먼저 마주치는 결정은 다른 층에 있다. 어느 계좌에 담을지다.\n같은 ETF를 사도 종합 계좌에서 사는지 ISA에서 사는지에 따라 세후 수익이 갈린다. 어떤 전략은 특정 계좌에서 아예 실행이 불가능하다. 계좌 제약은 사후 고민이 아니라 전략 설계의 입력 조건이다.\n종합(일반) 계좌 가장 자유로운 계좌다. 국내 주식, 해외 주식, ETF, 펀드, 채권 등 거의 모든 상품을 거래할 수 있다.\n거래 자유도가 높은 대신 비과세 혜택이 없다. 금융 소득(이자·배당)이 연 2,000만원을 넘으면 종합과세 대상이 된다. 국내 상장 주식의 매매 차익은 비과세이지만 해외 주식은 250만원 공제 후 22% 양도소득세가 붙는다.\n해외 ETF와 해외 주식을 직접 사야 하는 전략은 종합 계좌에서만 가능하다. SPY, QQQ, VGK 같은 미국 상장 ETF가 여기에 해당한다.\nISA ISA(개인종합자산관리계좌)는 국내 상장 상품만 거래할 수 있다. 해외 ETF와 해외 주식은 살 수 없다. 다만 TIGER 미국S\u0026amp;P500처럼 국내 상장된 해외지수 추종 ETF는 거래 가능하다.\n세제 혜택이 ISA의 정체성이다. 일반형은 200만원, 서민형(총급여 5,000만원 이하)은 400만원까지 손익 통산 후 비과세이고, 초과분은 9.9% 분리과세로 종결된다. 한도는 연 2,000만원, 누적 1억원까지 납입 가능하며 미사용 한도는 이월된다.\n의무 보유 기간은 3년이다. 만기 후 연금계좌로 이전하면 추가 세제 혜택이 있다. 국내 ETF 자산배분이나 국내 개별주 팩터 전략에 적합한 계좌다.\n연금저축 국내 상장 ETF와 펀드만 거래 가능하다. 개별 주식은 거래할 수 없다.\n세액공제가 가장 큰 동기다. 연 600만원 한도로 세액공제가 적용되며 공제율은 총급여 5,500만원 이하 16.5%, 초과 시 13.2%다. 600만원을 납입하면 최대 99만원의 세금이 돌아온다.\n연금 수령은 만 55세 이후 가능하고 수령 시 3.3~5.5% 연금소득세가 부과된다. 중도해지 시 기타소득세 16.5%가 적용되어 사실상 세제 혜택이 사라진다. 장기 보유를 강제하는 구조다.\n보수적 자산배분(올웨더, 60/40, 3분할 등)을 장기로 굴리기에 적합하다.\nIRP IRP(개인형 퇴직연금)는 국내 상장 ETF와 펀드만 거래 가능한 것이 연금저축과 같다. 결정적 차이는 위험자산 70% 제한이다.\n주식형 자산은 적립금의 70%를 초과할 수 없다. 나머지 30%는 안전자산(채권형, MMF, 예금 등)으로 채워야 한다. 100% 주식 전략은 IRP에서 실행할 수 없다.\n세액공제는 연금저축과 합산하여 연 900만원 한도로 적용된다. 연금저축 600만원 + IRP 300만원 조합이 자주 쓰인다. 수령과 중도해지 조건은 연금저축과 동일하다.\n70% 룰은 제약처럼 보이지만 자연스러운 자산배분 강제이기도 하다. 60/40 자산배분은 IRP에서 그대로 실행 가능하고, 위험자산 한도를 의식해 70/30 정도로 운영하는 것이 자주 보이는 구성이다.\n전략-계좌 매핑 같은 전략이 계좌별로 가능한지 정리하면 다음 표가 된다.\n전략 유형 종합 ISA 연금저축 IRP SPY/QQQ 해외 직투 ✅ ❌ ❌ ❌ 국내 상장 해외지수 ETF ✅ ✅ ✅ ✅ 국내 개별 주식 ✅ ✅ ❌ ❌ 국내 ETF 자산배분 ✅ ✅ ✅ ✅ 60/40 자산배분 ✅ ✅ ✅ ✅ 100% 주식 자산배분 ✅ ✅ ✅ ❌ 세제 혜택과 거래 자유도 사이에 트레이드오프가 명확하다. 종합은 자유도 최고, 세제 혜택 최저. ISA·연금저축·IRP는 세제 혜택이 강하지만 거래 가능 상품과 보유 기간에 제약이 있다.\n실무에서는 계좌별로 다른 전략을 배치하는 구성이 자주 보인다.\n종합: 해외 직접 투자(SPY, QQQ 등). 단기 트레이딩. ISA: 국내 개별주 팩터 전략, 비과세 한도 200만원 활용 연금저축: 국내 상장 해외지수 ETF로 보수적 자산배분 IRP: 60/40 자산배분, 70% 룰을 자연스럽게 활용 세후 수익에 미치는 영향 같은 KODEX 200 ETF를 1억원 매수해 5년간 연 5% 수익률로 운영한다고 가정해본다. 5년 후 평가액은 약 1억 2,762만원, 차익은 약 2,762만원이다.\n종합 계좌에서 국내 ETF 매매 차익은 비과세이지만 분배금에는 배당소득세 15.4%가 부과된다. ISA에서는 차익과 분배금을 합산해 200만원까지 비과세, 초과분은 9.9% 분리과세로 종결되어 종합 계좌보다 세후 수익이 명확히 높아진다. 연금저축·IRP는 운용 중 과세가 이연되고 수령 시점에 연금소득세로 분리되어 누진 종합과세를 피하는 효과가 있다.\n정확한 계산은 분배금 비율, 본인의 다른 금융 소득, 가입 시점 등 변수가 많아 시뮬레이션은 의사결정 방향 정도로만 참고할 수 있다. 정밀한 세무 계산은 전문가 영역이다.\n주의 사항 세법은 매년 바뀐다. 2026년 5월 기준이고 세액공제율, 한도, 비과세 임계 등은 입법 개정으로 자주 조정된다. 의사결정 직전에 최신 정보를 다시 확인하는 것이 안전하다.\n세부 적용 조건은 본인 상황에 따라 달라진다. 총급여 구간, 기존 계좌 가입 이력, 직장 가입 IRP 유무, 연 합산 금융 소득 등이 모두 변수다. 큰 그림을 잡는 정보 수준이지 세무 자문이 아니다.\n계좌 유형은 전략 설계의 입력 조건이다. 어느 계좌에 어떤 전략을 담을지를 먼저 정한 뒤 종목 선정과 비중 결정으로 내려가는 순서가 자연스럽다. 같은 자산배분 전략도 IRP에서는 70% 룰을 적용해 변형해야 하고, 같은 ETF도 ISA에서는 비과세 한도 안에서 가장 큰 효과를 낸다.\n다음 글에서는 다시 백테스트로 돌아가, 함정 표 한 줄로만 짚었던 Look-ahead Bias와 Survivorship Bias의 구체 케이스를 정리하려 한다.\n참고 자료 금융감독원 — 금융상품통합비교공시 국세청 — 연금계좌 세제혜택 Investopedia — Tax-Advantaged Accounts 한국투자증권, KB증권 등 증권사 계좌 안내 페이지 (계좌별 거래 가능 상품 최신 안내) ","permalink":"https://wid-blog.github.io/posts/daily/investment/korean-account-types/","summary":"종합·ISA·연금저축·IRP 네 계좌의 핵심 제약(비과세, 세액공제, 위험자산 70% 룰, 해외 직투 가능 여부), 전략-계좌 매핑, 그리고 세후 수익에 미치는 영향.","title":"한국 계좌 유형별 투자 제약"},{"content":"기존 시리즈 4편(백테스트 성과 지표)에서 백테스트 함정을 표 한 줄씩 짚었다. 그중 Look-ahead Bias와 Survivorship Bias 둘은 다른 함정과 성격이 다르다. 결과를 CAGR이 좋아지는 한 방향으로만 왜곡한다.\n양방향 노이즈가 아니라 체계적 편향이라 인지하지 못하면 그대로 실전에 옮긴다. 자매편으로 다룬 Walk-forward 분석도 이 두 함정은 잡지 못한다. Walk-forward는 시간 분할만 다루기 때문에 데이터 자체에 들어 있는 미래 정보나 살아남은 종목 편향은 fold 안에 그대로 들어간다. 케이스 단위로 양상을 봐야 인지가 명확해진다.\nLook-ahead Bias 시점 t의 의사결정에 t+k 시점의 정보를 사용하는 모든 경우를 일컫는다. 백테스트에서 우연한 미래 누설은 잘 드러나지 않는 형태로 자주 들어온다.\nCase 1. 모멘텀 전 구간 정규화 여러 종목의 모멘텀을 z-score로 정규화해 상위 N개를 선별하는 전략이 있다고 하자. 시점 t의 종목별 z-score를 계산할 때 평균과 표준편차로 전체 기간 통계를 사용하면, 그 평균과 표준편차에는 t+k 시점의 데이터가 들어 있다. t 시점에 알 수 없는 정보를 이미 z-score에 반영한 셈이다.\n회피는 단순하다. 시점 t까지의 rolling window로 평균과 표준편차를 계산한다. 시점 t 이후 데이터는 정규화에 들어가지 않는다.\nCase 2. 재무 데이터 공시 지연 무시 2024-12-31 결산 재무는 보통 2025년 3월쯤 공시된다. 백테스트에서 2025년 1월 의사결정에 그 재무를 사용하면 아직 발표되지 않은 정보를 미리 쓴 것이 된다.\n회피는 공시 시점(lag)을 명시적으로 모델링한다. DART, SimFin 같은 데이터 소스는 결산일과 공시일을 별도 컬럼으로 제공한다. 백테스트 시점이 공시일 이후인지 확인하고 사용한다.\nCase 3. 종가 시그널, 종가 체결 종가 기반 시그널(이동평균 cross, RSI 등)이 발생하면 같은 날 종가에 체결되는 것으로 가정하는 백테스트가 흔하다. 현실에서는 장 마감 시점에 시그널을 확인하면 그 시각에는 주문을 넣을 수 없다. 익일 시가나 VWAP가 현실적인 체결 가격이다.\n종가 시그널 + 종가 체결 백테스트는 익일 갭 다운(또는 갭 업)의 영향을 받지 않는다. 현실보다 좋은 진입가를 가정하는 셈이다. 회피는 시그널 발생일 익일 시가 체결로 모델링하거나, 종가 체결을 유지하되 slippage를 추가한다.\nSurvivorship Bias 현재 시점에서 살아남은 종목만으로 과거 백테스트를 돌리는 경우 발생한다. 상장폐지된 종목은 보통 손실 종목이라, 그 종목들이 빠지면 CAGR이 부풀려진다.\nCase 1. S\u0026amp;P 500 시점별 구성 S\u0026amp;P 500은 구성이 고정된 인덱스가 아니다. 매년 십수 개에서 이십여 개가 교체된다. 10년이면 누적으로 100개를 훌쩍 넘는 종목이 바뀌었다는 의미다.\n\u0026ldquo;S\u0026amp;P 500 종목으로 10년 백테스트\u0026quot;라고 했을 때 어느 시점 구성을 쓰는가에 따라 결과가 크게 갈린다. 오늘 시점의 500개로 10년 전 백테스트를 돌리면 이미 살아남은 종목만 사용한 셈이 된다. 10년 전 구성에는 그 사이 상장폐지되거나 인수합병으로 사라진 종목이 포함되어 있어야 한다.\n회피는 시점별 인덱스 구성을 제공하는 데이터(Compustat, CRSP 등)를 사용한다. 무료 API로는 시점별 구성을 얻기 어려우니, 상용 데이터 접근이 어렵다면 한계를 인지하고 결과를 보수적으로 해석한다.\nCase 2. Yahoo Finance와 무료 API의 한계 Yahoo Finance, 한국의 KIS API, 네이버 시세 같은 무료 데이터 소스는 상장폐지 종목을 보통 보유하지 않는다. 상장폐지 종목은 ticker 자체가 사라지거나 가격 데이터 조회가 불가능해진다.\n한국 시장도 동일하다. 상장폐지된 KOSPI 종목의 과거 가격은 무료 데이터에서 사실상 사라진다. 그 결과 \u0026ldquo;지난 10년간 KOSPI 종목으로 팩터 전략\u0026quot;이라는 백테스트는 자동으로 Survivorship Bias가 들어 있다.\n회피는 두 가지다. 상용 데이터 구매가 가장 확실한 방법이고, 비용을 감수할 수 없다면 결과 해석에 보정 마진을 둔다. 학술 연구에서는 Survivorship Bias가 CAGR을 연 2~4% 부풀린다는 추정이 자주 인용된다.\n사촌 함정들 두 함정과 함께 작동하는 편향들이 더 있다.\nData Snooping은 수많은 파라미터 조합을 시도하다 우연히 좋은 것을 발견하는 현상이다. 시그널 후보 10개 × lookback 10개 × 임계값 10개를 다 돌려보면 1,000개 중 몇 개는 우연히 좋다. 자매편으로 다룬 Walk-forward 분석이 일부 완화하지만 완전 해결은 아니다.\nSelection Bias는 백테스트 잘 나오는 기간만 선택해서 발표하는 형태다. 2010~2020년만 보여주고 2008년을 빼면 같은 전략의 인상이 달라진다. 통계적 의미가 약해진다.\n회피 체크리스트 새 전략을 백테스트하기 전이나 결과를 평가할 때 다음 항목을 확인한다.\n시그널 계산에 시점 t 이후 정보가 들어가지 않았는가 재무 데이터에 공시 지연(lag)을 반영했는가 체결 시점이 시그널 시점과 다른가 (실제로 주문 가능한 시점인가) 유니버스가 시점별로 다른가, 아니면 오늘 구성을 과거에 적용했는가 거래비용과 슬리피지를 시뮬에 포함했는가 체크리스트의 항목 중 일부는 데이터 한계로 완전 해결이 어려울 수 있다. 그 경우 한계를 명시하고 결과 해석에 마진을 두는 것이 차선이다.\n두 함정은 인지하면 회피할 수 있다. 다만 무료 데이터 환경에서는 Survivorship Bias 같은 함정을 완전히 제거하기 어렵고, 결과를 보수적으로 해석하는 것이 합리적이다. 자매편으로 다룬 Walk-forward 분석과 결합하면 신뢰도를 다층으로 보강할 수 있다. 시간 분할은 Walk-forward가, 데이터 정합성은 본 글의 체크리스트가 담당하는 구성이다.\n참고 자료 Investopedia — Look-Ahead Bias Investopedia — Survivorship Bias Investopedia — Data Snooping Bias Marcos López de Prado, Advances in Financial Machine Learning (2018) Bailey, D., López de Prado, M. (2014). \u0026ldquo;The Probability of Backtest Overfitting\u0026rdquo; ","permalink":"https://wid-blog.github.io/posts/daily/investment/backtest-pitfalls-case-study/","summary":"Look-ahead Bias와 Survivorship Bias 두 함정의 구체 케이스 — 모멘텀 전 구간 정규화, 재무 공시 지연, 종가 체결, S\u0026amp;P 500 시점 구성, 무료 API의 한계 — 그리고 회피 체크리스트.","title":"백테스트 함정 케이스 스터디"},{"content":"앞선 글에서 자산 배분의 전통적 비중으로 \u0026ldquo;주식 60% + 채권 40%\u0026rdquo; 같은 조합을 표로 언급했지만, 그 비중이 어디서 오는지는 미뤄두었다. 마코위츠가 1952년에 제안한 평균-분산 모델은 자산 N개의 비중 결정을 수학적 최적화 문제로 환원한다. \u0026ldquo;주어진 위험에서 최대 수익\u0026rdquo; 또는 \u0026ldquo;주어진 수익에서 최소 위험\u0026quot;을 만족하는 비중 벡터를 찾는다.\n모델 자체는 단순하다. 다만 입력으로 들어가는 기대수익률과 공분산을 추정하는 순간 오차가 결과를 지배한다. 실무에서 마코위츠를 그대로 쓰기보다 변형이 자주 등장하는 이유다.\n비중 결정의 수학 자산이 N개일 때 가능한 비중 조합은 무한히 많다. 비중 합이 1이라는 제약(Σwᵢ = 1)만 두면 자유도가 N-1 차원으로 남는다. \u0026ldquo;어떤 조합이 최선인가\u0026quot;는 정의를 정해야 답할 수 있다.\n마코위츠의 정의는 두 객체에서 출발한다.\n기대수익률 벡터 μ ∈ ℝᴺ — 각 자산의 기대 수익 공분산 행렬 Σ ∈ ℝᴺˣᴺ — 자산 간 수익률의 공동 변동 두 객체는 보통 과거 수익률의 표본 평균과 표본 공분산으로 추정한다. 공분산 행렬의 대각은 각 자산의 분산, 비대각은 두 자산 수익이 같이 움직이는 정도다.\n비중 벡터 w가 주어지면 포트폴리오의 기대 수익과 분산은 다음과 같이 표현된다.\nE[Rₚ] = wᵀμ σₚ² = wᵀΣw 두 자산일 때 분산 공식을 펼치면 직관이 분명해진다.\nσₚ² = w₁²σ₁² + w₂²σ₂² + 2w₁w₂ρσ₁σ₂ 상관계수 ρ가 낮을수록 마지막 항이 작아져 분산이 줄어든다. 분산 투자의 수학적 근거가 여기에 있다. ρ = -1인 자산이 두 개 있다면 비중을 잘 조절해 분산을 0까지 낮출 수 있다. 현실에서 그런 자산 쌍은 드물지만, 상관계수가 낮은 자산을 묶는 것만으로도 위험은 의미 있게 떨어진다.\n효율적 프론티어 (위험 σₚ, 수익 E[Rₚ]) 평면에 가능한 모든 비중 조합을 그리면 면적 형태가 된다. 같은 위험 수준에서 최대 수익을 주는 점들만 모은 경계선이 효율적 프론티어다.\n프론티어 위의 점은 \u0026ldquo;더 잘할 수 없는\u0026rdquo; 비중이다. 프론티어 안쪽 점은 비효율적이다. 같은 위험에서 더 높은 수익을 주는 다른 비중이 존재하기 때문이다. 분석은 자연스럽게 \u0026ldquo;프론티어 위에서만 고른다\u0026quot;는 합의로 좁혀진다.\n두 가지 최적해 프론티어 위에서 어느 점을 고를지는 또 다른 정의가 필요하다.\n최소분산 포트폴리오 min wᵀΣw s.t. Σwᵢ = 1 목적은 분산을 최소화하는 비중을 찾는 것이다. 수익률 추정 μ가 등장하지 않는다. 마코위츠 모델의 가장 큰 약점이 μ 추정의 불안정성인데, 최소분산 해는 이 위험을 우회한다. 공분산만 추정하면 풀린다.\n대신 수익률 정보를 버리는 셈이므로 결과가 보수적으로 나온다. 변동성이 낮은 자산에 자연스럽게 비중이 쏠린다.\n접점 포트폴리오 (Tangency) max (wᵀμ - r_f) / √(wᵀΣw) 목적 함수는 포트폴리오의 Sharpe Ratio다. 무위험 수익률 r_f에서 효율적 프론티어로 그은 접선의 접점이 해다. Sharpe Ratio가 최대인 비중이므로 위험 대비 수익이 가장 좋은 점을 잡는다.\n앞선 글에서 Sharpe Ratio는 개별 전략의 위험 대비 수익 효율을 측정하는 지표였다. Tangency는 그 개념을 포트폴리오 비중 결정에 옮겨 놓는다. 단점은 μ 추정에 민감하다는 것이다. 기대수익률이 살짝 어긋나면 접점이 크게 이동하고 비중도 크게 바뀐다.\n제약 조건 이론적 모델은 비중 합 제약 하나만 두지만 실무에서는 추가 제약이 붙는다.\nwᵢ ≥ 0 — long-only. 공매도 금지. 한국 개인 계좌에서 기본 가정이다. wᵢ ≤ w_max — 단일 자산 비중 상한. 한 종목에 집중되는 위험을 줄인다. Σ_{sector} wᵢ ≤ s_max — 섹터 비중 상한. 제약을 추가할수록 해의 공간이 좁아진다. 효율적 프론티어 자체가 안쪽으로 이동하여 \u0026ldquo;이론적 최선\u0026quot;보다 낮은 점이 된다. 효율 손실을 감수하더라도 실현 가능성과 위험 관리를 위해 제약을 건다.\ncvxpy, pypfopt 같은 라이브러리는 위 형태의 최적화 문제를 표준 인터페이스로 풀어준다. 사용자는 μ와 Σ, 제약 조건만 넘기면 된다.\n마코위츠의 함정과 실무 보정 모델의 약점은 입력 추정과 분포 가정에 있다.\n기대수익률 추정 오차가 가장 치명적이다. μ가 살짝 어긋나면 최적해 비중이 극단으로 튄다. 마코위츠 최적화기를 \u0026ldquo;estimation error maximizer\u0026quot;라고 부르는 이유다. 과거 평균을 미래 기대로 사용하는 가정 자체가 약하다.\n공분산 행렬의 불안정성도 무시할 수 없다. 자산 수가 늘어나면 표본 공분산 추정이 ill-conditioned해진다. 이 한계가 뒤에 등장할 Hierarchical Risk Parity 같은 보정 방법이 나오는 동기 중 하나다.\n정규성 가정도 한계다. 분산만으로 위험을 측정하므로 두꺼운 꼬리(fat tail)나 비대칭(skewness)을 잡지 못한다. 2008년 금융위기 같은 극단 사건은 모델이 가정하는 분포 밖에서 발생한다.\n실무에서는 마코위츠를 그대로 쓰기보다 다음 변형이 자주 등장한다.\nEqual-weight(1/N) — 모든 자산에 같은 비중. DeMiguel et al. (2009)는 OOS 성과에서 1/N이 마코위츠 변형을 자주 이긴다는 결과를 보고했다. 추정 오차 자체가 없기 때문이다. Risk parity — 각 자산이 포트폴리오 위험에 동일하게 기여하도록 비중을 조정한다. 수익률 추정이 불필요해 μ의 불안정성을 피한다. Hierarchical Risk Parity — 자산을 상관관계 기반으로 군집화한 뒤 재귀적으로 비중을 분배한다. López de Prado가 제안한 방법으로, 표본 공분산이 ill-conditioned일 때도 안정적이다. Black-Litterman — 시장 균형 비중을 사전 분포로 두고 투자자의 견해를 베이지안으로 결합한다. μ 추정의 불확실성을 모델에 명시적으로 포함한다. 각 방법은 마코위츠의 어느 약점을 우회하느냐가 다르다. Equal-weight는 추정 오차 자체를 없애고, Risk parity와 HRP는 μ 추정을 피하며, Black-Litterman은 μ 추정의 불확실성을 모델에 포함한다.\n효율적 프론티어는 포트폴리오 비중 결정의 출발점이지만 종점은 아니다. 모델이 의존하는 입력의 추정 오차가 결과를 지배하므로, 1/N 같은 단순한 전략이 OOS에서 마코위츠를 자주 이기는 역설이 발생한다.\n이 한계는 곧 다음 질문으로 이어진다. 백테스트에서 좋게 보이는 비중이 실전 운용에서도 유지될지 어떻게 확인할 것인가. 다음 글에서 Walk-forward 분석으로 백테스트 결과의 신뢰도를 정량화하는 방법을 정리하려 한다.\n참고 자료 Investopedia — Modern Portfolio Theory (MPT) Investopedia — Efficient Frontier pypfopt — Efficient Frontier Markowitz, H. (1952). \u0026ldquo;Portfolio Selection\u0026rdquo;. Journal of Finance 7(1) DeMiguel, V., Garlappi, L., Uppal, R. (2009). \u0026ldquo;Optimal Versus Naive Diversification\u0026rdquo;. Review of Financial Studies 22(5) López de Prado, M. (2016). \u0026ldquo;Building Diversified Portfolios that Outperform Out of Sample\u0026rdquo; ","permalink":"https://wid-blog.github.io/posts/daily/investment/efficient-frontier-optimization/","summary":"여러 자산의 비중을 어떻게 결정할지의 수학적 토대인 마코위츠 평균-분산 모델, 효율적 프론티어 위의 두 가지 최적해(Min-Variance, Tangency), 그리고 실무에서 모델이 무너지는 지점과 보정 방법을 정리한다.","title":"효율적 프론티어와 포트폴리오 최적화"},{"content":"모놀리스가 커지면 흔히 MSA 를 고려한다. 빌드가 느려지고, 한 부분의 변경이 다른 부분의 배포를 막으며, 트래픽 부하가 한 군데에서 전체로 번진다. 통념적 처방은 \u0026ldquo;작게 쪼개라\u0026rdquo; 다. 정작 어려운 부분은 어디서 자를지 정하는 일이다.\nMSA 는 시스템을 어느 기준으로 가를지 정하는 결정이다. 도메인 경계, 데이터 소유권, 스케일 패턴, 장애 격리 중 어느 것을 우선하느냐가 서비스 경계를 만들고, 그 경계가 통신·데이터 일관성·운영 비용을 차례로 결정한다. 기준이 잘못 잡히면 후속 결정도 같이 어긋나고 한 번 그어진 경계는 되돌리기 어렵다.\nflowchart LR A[분해 기준도메인 · 데이터 · 스케일 · 장애] --\u003e B[서비스 경계] B --\u003e C[통신 패턴동기 / 비동기] B --\u003e D[데이터 일관성] C --\u003e E[운영 비용] D --\u003e E 분해 기준 서비스를 분할한다는 것은 어떤 차이를 경계 삼느냐의 문제다. 같은 코드라도 도메인을 기준으로 분할하면 한 모양이 나오고, 데이터 소유권으로 분할하면 다른 모양이, 스케일 특성으로 분할하면 또 다른 모양이 나온다. 어느 기준이 옳다고 말할 수는 없다. 이 시스템에서 어느 기준이 지배적인지를 먼저 본다.\n도메인 경계 DDD 의 Bounded Context 가 그대로 부합한다. \u0026ldquo;주문\u0026rdquo;, \u0026ldquo;결제\u0026rdquo;, \u0026ldquo;추천\u0026rdquo; 같은 업무 의미 단위가 서비스 경계와 일치한다. 변경의 응집성이 좋다. 주문 로직이 바뀌면 주문 서비스만 바뀐다.\n약점은 도메인이 흐릿할 때 드러난다. 모델이 충분히 안정되지 않은 상태에서 경계를 그으면 잘못된 모델이 그대로 서비스 경계가 된다. 한 도메인이 두 서비스에 분산되거나, 두 도메인이 한 서비스에 묶이거나. 그 후로는 대부분의 변경이 두 서비스를 동시에 수정하게 된다.\n단일 코드베이스 안의 수직 분할을 서비스 경계까지 확장한 형태로 보면 된다.\n데이터 소유권 누가 어떤 테이블의 쓰기 권한을 가지느냐. 공유 DB 를 허용하는 순간 MSA 의 핵심 이점인 독립 배포와 독립 스키마 진화가 사라진다. 한 서비스의 스키마 변경이 다른 서비스의 코드를 깨뜨릴 수 있기 때문이다.\n이 기준은 도메인 경계와 흔히 겹친다. 한 도메인이 자기 데이터를 소유하는 게 자연 흐름이다. 그런데 같은 도메인 안에서도 쓰기 패턴이 다르면 데이터 소유권이 별도의 분해 기준이 된다. 주문 도메인 안에서도 트랜잭션 처리와 통계 집계는 부하와 일관성 요구가 다르다.\n스케일 패턴 워크로드 특성으로 분할한다. 같은 도메인 안에서도 읽기와 쓰기, CPU 와 IO, 폭증형과 평탄형의 비중이 갈리면 분할이 적합하다.\n채팅 워크로드를 예로 들면, 메시지 발행은 쓰기 무거움 + 폭증형이고 메시지 검색은 읽기 무거움 + IO 무거움이다. 한 서비스로 묶으면 두 패턴 중 어느 쪽에 맞춰도 다른 쪽이 비효율이 된다. 분할하면 각자 자기에게 맞는 방식으로 스케일할 수 있다. 발행은 큐로 흡수하고, 검색은 인덱스와 캐시로 처리한다.\n장애 경계 한 서비스의 장애가 다른 서비스에 번지지 않게 분할한다. 핵심 경로와 비핵심 경로를 분리한다.\n광고 서버 워크로드를 예로 들면, 메인 추천이 장애를 일으키면 fallback 콘텐츠가 나가야 매출이 보호된다. 메인과 fallback 을 한 서비스에 두면 메인의 장애가 fallback 까지 함께 중단시킨다. 분리하면 fallback 은 자기 경로로 살아남는다. 안정성 관점의 분해 기준이다.\n기준이 충돌할 때 도메인 경계로 분할하려는데 스케일 패턴이 다른 분할을 요구하는 경우가 흔하다. 한 도메인이 폭증형 워크로드와 평탄형 워크로드를 모두 가질 때 도메인 통합과 스케일 분리가 충돌한다.\n이 시스템에서 지배적인 기준을 정하고 그것으로 분할한 뒤, 다른 기준은 서비스 내부의 모듈이나 큐 분리로 처리한다. 모든 기준을 서비스 경계로 끌어올리면 서비스 수가 폭증하고 운영 비용이 통제 불가능해진다.\n서비스 간 통신 서비스 경계가 정해지면 다음 결정은 어떻게 통신할 것인가다. 동기 또는 비동기다.\n동기 — gRPC 명령형, 즉시 응답이 필요한 경우. 결제 요청, 인증 확인 같은 호출이 여기에 해당한다. 호출자는 결과를 기다리고, 실패하면 즉시 안다.\ngRPC 는 HTTP/2 기반으로 ProtoBuf 라는 IDL 로 양방향 계약을 정의한다. unary, server streaming, client streaming, bidirectional streaming 의 4가지 모드를 지원하고, ProtoBuf 의 이진 직렬화 덕에 JSON 보다 가볍다. HTTP/1.1 의 head-of-line blocking 을 HTTP/2 의 다중화로 해결한다.\n동기의 비용은 호출 체인이 길어질수록 누적되는 지연이다. 한 곳이 중단되면 호출 체인 전체가 영향받는다. 그래서 동기 통신은 호출 체인을 짧게 유지하고 핵심 경로에 한정하는 편이 권장된다.\n비동기 — Kafka 이벤트 발행, 결과적 일관성, 트래픽 흡수가 필요한 경우. 주문이 생기면 추천 서비스가 그 이벤트를 받아 모델을 업데이트하거나, 사용자 행동 로그가 큐에 쌓이면 분석 서비스가 자기 페이스로 처리하는 식이다.\nKafka 는 분산 로그다. producer 가 topic 에 이벤트를 적고, consumer 가 자기 offset 으로 읽는다. 같은 이벤트를 여러 consumer 가 다른 목적으로 소비할 수 있다(fan-out). 일시적인 트래픽 폭증을 큐가 흡수하므로 downstream 의 부하가 평탄해진다.\n비동기의 비용은 즉시가 아닌 일관성이다. 이벤트가 도착할 때까지 짧지만 빈 시간이 있고, consumer 가 중단되거나 느려지면 lag 이 쌓인다.\n어느 길을 택할 것인가 분해 기준이 답을 준다.\n도메인 경계로 갈랐는데 두 도메인이 서로의 즉시 결과에 의존한다면 동기다. 데이터 소유권으로 갈랐고 한 서비스의 변경이 다른 서비스의 캐시나 뷰를 갱신해야 한다면 비동기 이벤트가 자연스럽다. 스케일 패턴으로 갈랐고 폭증 트래픽을 흡수해야 한다면 비동기 큐다. 장애 격리로 갈랐고 핵심 경로와 비핵심 경로를 분리했다면, 핵심은 동기로 두고 비핵심은 비동기로 빼서 fallback 을 가능하게 한다. 대부분의 실제 시스템은 두 가지를 섞는다. 통신 방식 하나만 고집하는 시스템은 보통 분해 기준을 한 가지로만 보고 있을 가능성이 크다.\n데이터 일관성과 운영 도구 단일 트랜잭션의 상실 모놀리스에서 익숙했던 단일 ACID 트랜잭션이 사라진다. \u0026ldquo;결제와 재고 차감을 하나로 묶는다\u0026rdquo; 가 서비스 경계 너머에서는 자연스럽지 않다.\n분산 환경의 트랜잭션은 두 방향으로 나뉜다. 동기적으로 분산 트랜잭션을 시도하는 길(2PC, TCC) 과 결과적 일관성을 받아들이고 보상 트랜잭션을 설계하는 길(Saga, Outbox) 이다. 어느 쪽도 단일 트랜잭션의 단순함을 회복하지 못한다.\n서비스 경계를 그을 때 단일 트랜잭션이 깨지는 자리를 미리 의식해야 한다. 가장 자주 함께 변하는 데이터를 경계 너머로 분산시키면 모든 쓰기가 분산 트랜잭션이 되고, 그 비용은 무시할 수 없다. 분해 기준이 통신뿐 아니라 데이터 일관성 모델까지 결정하는 셈이다.\n운영 도구가 필요해지는 자리 서비스 수가 늘면 새로운 도구가 필요해지는 자리가 생긴다.\nService Mesh: 서비스 간 통신 정책(retry, timeout, circuit breaker, mTLS) 을 코드 밖으로 빼고 싶을 때 API Gateway / BFF: 외부 진입점에서 인증, rate limiting, 응답 조립을 한곳에 모으고 싶을 때 분산 추적: 호출 체인이 길어져 한 요청이 어디서 느려졌는지 추적하기 어려울 때 컨테이너 오케스트레이션: 서비스 단위가 많아져 배포와 스케일링을 자동화하고 싶을 때 이 도구들은 결과로 뒤따르지 전제는 아니다. 분해 기준이 분명하고 서비스 경계가 단순하면 도구 의존을 늦출 수 있다. 기준이 흐릿한 상태에서 도구부터 만들면 복잡도를 가시화하지 못한 채 누적시킨다.\n잘못된 분해의 비용 분리는 되돌리기 어렵다. 한 번 다른 서비스로 분리되면 그 코드는 자기 데이터, 자기 배포 파이프라인, 자기 모니터링, 자기 팀 의존성을 따로 갖게 된다. 다시 합치려면 이 모든 것을 거꾸로 풀어야 한다.\n분해 기준이 잘못 잡힌 시스템에는 흔히 두 가지 신호가 보인다. 대부분의 변경이 여러 서비스의 동시 배포를 요구하면 도메인 경계가 잘못 그어졌다는 뜻이고, 대부분의 호출이 동기 체인 끝까지 이어지면 통신 결정이 분해 기준이 아니라 관성에 따라 내려졌다는 뜻이다.\n단일 코드베이스 안의 분할 결정도 같은 원리지만, MSA 는 그 결정을 되돌리기 어려운 형태로 고정한다.\n그래서 의심스러우면 자르지 않는 편이 낫다고 본다. 모놀리스 안에서 모듈 경계를 먼저 분명히 그어보고, 그 경계가 충분히 안정되었을 때 자른다. 도메인이 안정되고 데이터 소유권이 분명해지고 스케일 차이가 운영을 어렵게 만드는 시점이다. 자르는 것이 목적이 되면 분해 기준은 사후 정당화가 된다.\n참고 Horizontal vs Vertical Slicing — 단일 코드베이스 안의 수평/수직 분할. MSA 의 도메인 기준이 같은 결정의 서비스 경계 버전. HTTP/1.1과 HTTP/2 — HTTP/2 의 다중화. gRPC 의 동기 통신 모델이 위치한 전송 계층. Kafka 기초와 KRaft 모드 — Kafka producer/consumer 동작과 KRaft 모드. 비동기 통신의 인프라. 분산 트랜잭션 패턴 — 2PC, TCC, Saga, Outbox. 단일 트랜잭션이 깨지는 자리에서 선택하는 패턴들. ","permalink":"https://wid-blog.github.io/posts/tech/architecture/microservices-architecture/","summary":"MSA 는 서비스를 어느 기준으로 가를 것인가의 결정이다. 도메인 경계, 데이터 소유권, 스케일 패턴, 장애 격리 — 어느 것을 우선하느냐가 경계를 만들고 통신과 데이터를 차례로 결정한다.","title":"Microservices Architecture"},{"content":"ES(Event Sourcing) 와 CQRS(Command Query Responsibility Segregation) 는 시스템의 원본 데이터(source of truth) 를 어떤 형태로 둘 것인가, 그것으로부터 표현을 어떻게 만들 것인가의 결정이다.\nES 는 원본 데이터를 상태가 아닌 변화의 시퀀스로 둔다. 현재 상태는 그 시퀀스로부터 도출된다. CQRS 는 같은 원본 데이터로부터 서로 다른 표현을 분리한다. 두 패턴은 별개지만 함께 묶여 동작한다. 새 read model 이 필요하면 새 projection 으로 같은 이벤트 시퀀스를 다시 소비하면 된다.\nflowchart LR A[Command] --\u003e B[(Event Storeappend-only)] B --\u003e C[Projection] C --\u003e D[Read Model검색] C --\u003e E[Read Model통계] C --\u003e F[Read ModelUI] Event Sourcing CRUD 는 흔히 한 가지를 원본 데이터로 둔다. 테이블 한 줄의 현재 값이다. 주문 테이블에 PAID 라고 적혀 있으면 그 주문은 결제됐다. 변화는 UPDATE 로 덮어써지므로 어떻게 그 상태가 되었는지는 사라진다.\nES 는 그 대신 변화의 시퀀스를 둔다. OrderCreated, PaymentRequested, PaymentCompleted, OrderShipped 처럼 일련의 이벤트가 append-only 로 쌓이고, 그 시퀀스가 원본 데이터다. \u0026ldquo;현재 주문이 PAID 인가\u0026rdquo; 는 이벤트들을 처음부터 재생(replay) 한 결과로 얻는다. 상태는 이벤트 시퀀스에서 도출된 결과다.\n이 작은 차이가 시스템 설계의 전제를 바꾼다.\naudit log 가 자연스럽게 따라온다. 모든 변화가 이벤트로 기록되어 있으므로 별도 audit 인프라가 불필요하다. time travel debugging 이 가능해진다. 특정 시점까지 replay 하면 그 시점의 상태가 도출된다. 새 view 를 추가하는 자유가 생긴다. 이벤트 시퀀스로부터 새로운 projection 을 만들면 새 read model 이 나온다. 대신 비용이 따라온다. 이벤트가 누적되면 매번 처음부터 replay 하는 게 비싸진다. 그래서 snapshot 이 도입된다. 특정 시점까지의 상태를 저장해두고 그 이후 이벤트만 replay 한다. schema 변경도 까다롭다. 과거에 발행된 이벤트도 여전히 시스템의 원본 데이터라 함부로 형태를 바꿀 수 없다.\n이벤트 저장소(event store) 로 Kafka 가 자주 거론된다. append-only log 라는 점이 ES 와 부합한다. 단 Kafka 는 일정 기간 후 데이터를 삭제하는 보존 정책이 일반적이라, 모든 이벤트의 영구 보존을 전제하는 ES 의 전제와 어긋난다.\nCQRS CRUD 는 한 모델로 쓰기와 읽기를 모두 처리한다. 주문 도메인이라면 Order 라는 한 모델이 검증, 상태 변경, 검색, 통계를 다 떠안는다. 작은 시스템에서는 깔끔하지만 복잡해지면 한 모델로 두 요구를 모두 충족시키기 어려워진다. 쓰기는 비즈니스 규칙과 트랜잭션 무결성을 원하고, 읽기는 빠른 조회와 다양한 표현을 원하는데 한 모델이 두 요구를 모두 잘 충족시키는 경우는 드물다.\nCQRS 는 이 비대칭을 그대로 받아들이고 쓰기 모델(Command 측) 과 읽기 모델(Query 측) 을 분리한다. 쓰기 모델은 도메인 규칙과 일관성에, 읽기 모델은 조회 효율과 표현에 각각 최적화한다. 둘은 같은 원본 데이터를 다른 형태로 다룬다.\n읽기 모델은 여러 개일 수 있다. 검색용 인덱스, 통계용 집계 테이블, UI 화면용 뷰처럼 다른 형태의 read model 이 같은 원본 데이터로부터 도출된다. 새 화면이 필요하면 새 read model 을 추가하면 된다.\nread model 갱신 시점은 세 갈래로 갈린다.\n동기 갱신 — 쓰기와 같은 트랜잭션에서 read model 도 함께 업데이트. 일관성은 즉시지만 쓰기 트랜잭션의 부담이 커진다. 비동기 갱신 — 쓰기 후 이벤트로 알리고 read model 은 별도 흐름에서 업데이트. eventual consistency 를 받아들이는 가장 흔한 패턴. on-demand 갱신 — 읽을 때 도출. 조회 빈도가 낮은 view 에 어울린다. 비동기 갱신을 택하면 staleness 가 생긴다. 쓰기 직후 짧은 시간 동안 read model 이 옛 값을 반환할 수 있다. 이 staleness 를 비즈니스 측에서 받아들일 수 있느냐가 CQRS 채택 가능 여부를 결정한다.\nES 와 CQRS 의 결합 ES 와 CQRS 는 서로 독립이지만 잘 묶인다.\nES 가 원본 데이터를 이벤트 시퀀스로 두면 CQRS 의 read model 이 그 시퀀스를 입력으로 받는다. 이벤트가 append 되면 projection 이 소비해 read model 을 갱신한다. 이벤트가 그대로 read 측의 자료가 되므로 두 패턴 사이의 결합 비용이 작다. 새 read model 이 필요하면 새 projection 을 만들어 이벤트 처음부터 replay 하면 된다.\n이 결합은 Saga 와 Outbox 패턴과도 이어진다. ES 의 이벤트가 그대로 Saga 트리거가 되고, Outbox 가 보장하는 \u0026ldquo;DB write 와 이벤트 발행의 원자성\u0026rdquo; 은 ES 환경에서 처음부터 같은 트랜잭션으로 묶인다. 이벤트 저장이 곧 비즈니스 데이터 저장이기 때문이다.\n대신 원본 데이터(이벤트) 와 표현(read model) 이 다른 저장소에 있어서 read 와 write 일관성이 즉시가 아니다. 사용자가 자기 행동의 결과를 화면에서 즉시 확인하지 못하는 경우가 생기고, 이 점이 UX 결정에 직접 영향을 준다.\n채택 기준 분해 기준 — 도메인 경계, 데이터 소유권, 스케일 패턴, 장애 격리 — 이 ES/CQRS 채택의 출발점이 된다.\n도메인 경계에서 audit/compliance 가 핵심 요구사항이라면 ES 가 잘 맞는다. 금융, 보험, 의료 같은 도메인. 데이터 소유권 기준에서 한 도메인이 여러 형태의 read model 이 필요하다면 CQRS 가 따라온다. 검색, 통계, 실시간 대시보드가 같은 원본 데이터로부터 다른 표현을 필요로 할 때. 스케일 패턴 기준에서 read 와 write 의 부하 패턴이 다르다면 CQRS 의 read model 분리가 적합하다. read replica 와는 성격이 다른 분리다. 모델 자체가 다르다. 장애 격리 기준에서 read 측의 장애가 write 를 막아서는 안 된다면 read model 의 비동기 갱신이 격리를 제공한다. 모든 시스템이 ES/CQRS 를 필요로 하지는 않는다. 단일 CRUD 로 충분한 시스템에서 ES 를 도입하면 시스템 전반에 도출 비용이 더해진다. 분해 기준이 ES/CQRS 의 가치를 정하고, 그 가치가 비용을 정당화하는지를 보고 채택을 결정한다.\n운영 비용 ES 는 audit log, time travel, 새 view 추가의 자유라는 가치를 가져오지만 그 가치를 받기 위한 비용이 시스템 전반에 분산된다.\nreplay 비용 — snapshot 이 없으면 매번 처음부터 재생. snapshot 이 있어도 운영 부담이 따른다. projection 운영 — read model 별로 갱신 흐름을 따로 관리하고 실패 시 재처리 전략을 준비한다. schema 변경의 어려움 — 과거 이벤트를 함부로 바꿀 수 없어 versioning, upcasting, weak/strong schema 같은 별도 운영 패턴이 필요하다. 별도 글로 다룬다. 디버깅의 추상도 증가 — 이벤트 시퀀스와 projection 의 정합성 추적이 단일 상태 모델보다 어렵다. audit, compliance, time travel debugging, 새 view 추가의 자유가 시스템에서 본질적 가치가 아니라면 단순 CRUD 가 답인 경우가 많다고 본다. 패턴이 도입의 목적이 되면 정상 흐름에서는 잘 동작하지만 ES 의 가치를 누리지 못한 채 비용만 쌓인다.\n참고 Microservices Architecture — 분해 기준(도메인 경계, 데이터 소유권, 스케일, 장애)과 통신 결정. ES/CQRS 채택 가치 판단의 전제. Distributed Transactions — 단일 트랜잭션의 분해/재조립과 Saga·Outbox. ES 의 이벤트가 그대로 Saga 트리거가 되는 결합 지점. Kafka 기초와 KRaft 모드 — Kafka 의 동작과 데이터 보존 정책. ES 의 영구 보존 전제와의 전제 차이를 이해하는 배경. ","permalink":"https://wid-blog.github.io/posts/tech/architecture/event-sourcing-and-cqrs/","summary":"ES 와 CQRS 는 시스템의 원본 데이터와 그 표현의 분리를 다룬다. 도입 비용이 시스템 전반에 분산되므로 채택 가치를 명시할 수 있을 때 채택할 만하다고 본다.","title":"Event Sourcing and CQRS"},{"content":"모놀리스에서 익숙했던 ACID 트랜잭션은 분산 환경에서 자연스럽지 않다. \u0026ldquo;결제와 재고 차감을 하나로 묶는다\u0026rdquo; 가 한 DB 안에서는 한 줄이지만, 두 서비스 사이에서는 어느 쪽도 보장이 약한 상태가 된다. 단일 트랜잭션의 A(Atomicity) 가 서비스 경계 너머에서 사라진다.\n분산 트랜잭션은 단일 ACID 트랜잭션이 어떻게 분해되고 그 조각을 어떻게 재조립하는가의 문제다. 크게 두 갈래다. 분산된 commit 을 동기 합의로 한 번에 묶거나, 각자 로컬에서 commit 하고 실패 시 보상으로 되돌리거나.\nflowchart LR A[모놀리스 단일 트랜잭션ACID] --\u003e B[서비스 경계로 분해] B --\u003e C{재조립 전략} C --\u003e|동기 합의| D[2PC] C --\u003e|로컬 commit + 보상| E[Saga / TCC] E --\u003e F[OutboxDB ↔ 이벤트 발행 일관성] 2PC 2PC(Two-Phase Commit) 는 분산된 조각을 한 번에 commit 하려는 시도다. coordinator 가 모든 참여자에게 \u0026ldquo;준비됐냐\u0026rdquo; 를 묻고(prepare), 모두가 ok 면 \u0026ldquo;commit 해라\u0026rdquo; 를 보내는 두 단계 흐름이다.\nsequenceDiagram participant C as Coordinator participant P1 as 참여자 1 participant P2 as 참여자 2 participant P3 as 참여자 3 Note over C,P3: 1단계 — Prepare (준비) C-\u003e\u003eP1: prepare C-\u003e\u003eP2: prepare C-\u003e\u003eP3: prepare P1--\u003e\u003eC: vote yes P2--\u003e\u003eC: vote yes P3--\u003e\u003eC: vote yes Note over C,P3: 2단계 — Commit (결정) C-\u003e\u003eP1: commit C-\u003e\u003eP2: commit C-\u003e\u003eP3: commit P1--\u003e\u003eC: ack P2--\u003e\u003eC: ack P3--\u003e\u003eC: ack 작동은 깔끔하지만 비용이 크다. prepare 단계 후 각 참여자는 commit 또는 abort 를 받을 때까지 자원을 잠그고 기다린다. 잠금 대기가 길어진다. coordinator 가 이 사이에 중단되면 참여자들은 무한히 대기 상태가 될 수 있다. 단일 장애점이다. 그리고 두 번의 round trip 으로 인한 지연이 모든 트랜잭션마다 누적된다.\n실패 모드의 구조는 분명하다. prepare 에서 누군가 vote no 또는 응답이 없으면 coordinator 가 abort 를 브로드캐스트하면서 정상 종료한다. vote yes 는 자기 WAL 에 prepared 상태를 기록하고 \u0026ldquo;이후 commit 을 받으면 무조건 수행한다\u0026rdquo; 는 계약을 진다. 그래서 commit 단계는 가벼운 작업이고, 참여자가 prepared 상태에서 죽었다 부활해도 WAL 과 coordinator 조회로 복구된다.\n진짜 깨지는 사례는 두 갈래다. coordinator 가 commit 을 일부에게만 보내고 죽으면 blocking 이 발생한다. vote yes 후 디스크 결함 등으로 실제 commit 이 실패하는 경우는 프로토콜 보장을 벗어난 데이터 손상 영역이라, 애플리케이션이나 운영 차원에서 수동 복구해야 한다.\n2PC 는 강한 일관성을 확보하지만 가용성과 성능을 일관성에 지불하는 트레이드오프가 명확하다. 그래서 현대 MSA 에서는 자주 쓰이지 않고, 강한 일관성이 꼭 필요한 좁은 경로에만 한정되는 편이다.\nSaga / TCC Saga 는 하나의 비즈니스 트랜잭션을 여러 서비스의 로컬 트랜잭션으로 나누고 순차적으로 진행한다. 어느 단계에서 실패하면 보상 트랜잭션으로 이전 단계들을 되돌린다.\n예를 들어 주문 생성 → 결제 → 재고 차감 → 배송 등록의 흐름이 있다고 하자. 재고 차감에서 실패하면, 결제는 환불(보상), 주문은 취소(보상). 단일 트랜잭션의 rollback 이 여러 서비스에 걸친 명시적 보상 로직으로 대체된다.\nSaga 는 결과적 일관성을 받아들인다. 각 단계 사이에는 짧지만 빈 시간이 있고, 그 시간 동안 시스템은 일시적으로 불일치 상태에 있을 수 있다. 이 불일치가 비즈니스 측면에서 허용 가능한지가 Saga 채택의 핵심 조건이다.\n보상 트랜잭션이 실패할 때가 Saga 의 어려운 부분이다. 한번 forward 단계에 진입하면 그에 대응하는 보상은 반드시 끝나야 한다. Saga 에는 \u0026ldquo;포기\u0026rdquo; 상태가 없다. 그래서 보상은 멱등하게 설계되어 재시도가 가능해야 한다. 영구 재시도가 안 되면 다른 유효한 종착점으로 밀어붙이거나 (forward recovery — 카드 환불 실패 시 적립금으로 대체), 운영 차원의 수동 개입으로 넘긴다.\nChoreography 각 서비스가 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해서 자기 단계를 진행하는 방식이다. 중앙 조율자가 없다. \u0026ldquo;OrderCreated\u0026rdquo; 이벤트가 발행되면 결제 서비스가 그것을 받아 결제하고 \u0026ldquo;PaymentCompleted\u0026rdquo; 를 발행한다. 재고 서비스가 그것을 받고 차감 후 \u0026ldquo;InventoryReserved\u0026rdquo; 발행. 각 서비스가 자기 트리거와 보상 로직을 안다.\nsequenceDiagram participant O as 주문 participant P as 결제 participant I as 재고 participant S as 배송 O-\u003e\u003eP: OrderCreated P-\u003e\u003eI: PaymentCompleted I-\u003e\u003eS: InventoryReserved Note over O,S: 실패 시 — 역순으로 보상 이벤트 S--\u003e\u003eI: ShipmentFailed I--\u003e\u003eP: InventoryReleased P--\u003e\u003eO: PaymentRefunded Orchestration 중앙 조율자(Saga state machine 또는 orchestrator) 가 흐름을 명시적으로 통제한다. 조율자가 결제 서비스에 명령을 보내고 응답을 받으면 다음으로 재고 서비스에 명령을 보낸다. 실패가 발생하면 조율자가 보상 명령을 역순으로 발행한다.\nsequenceDiagram participant Or as 조율자 participant P as 결제 participant I as 재고 participant S as 배송 Or-\u003e\u003eP: 결제 P--\u003e\u003eOr: 성공 Or-\u003e\u003eI: 재고 차감 I--\u003e\u003eOr: 성공 Or-\u003e\u003eS: 배송 등록 S--\u003e\u003eOr: 실패 Note over Or,S: 실패 응답 시 — 역순 보상 명령 Or-\u003e\u003eI: 재고 복구 Or-\u003e\u003eP: 환불 두 변형의 트레이드오프 결합도. Choreography 는 서비스 간 직접 의존이 없지만, 이벤트 시퀀스가 여러 서비스에 분산되어 있다. Orchestration 은 조율자가 모든 서비스를 알지만 서비스들끼리는 서로 모른다. 가시성. 한 트랜잭션이 어디까지 진행됐는지 보려면, Choreography 에서는 여러 서비스의 로그를 추적해야 하고, Orchestration 에서는 조율자의 상태만 보면 된다. 디버깅. 실패 케이스의 보상 흐름을 따라가는 것은 Orchestration 이 훨씬 직관적이다. Choreography 는 이벤트 그래프가 복잡해질수록 추적이 어려워진다. 비즈니스 로직의 응집성. 한 비즈니스 트랜잭션의 흐름이 한곳에 모이는 것은 Orchestration 의 강점이다. Choreography 는 흐름이 여러 서비스로 나뉘어 있다. 작은 시스템에서는 Choreography 가 가볍게 시작하기 좋고, 흐름이 복잡해지면 Orchestration 으로 옮기는 패턴이 잘 맞는다고 본다. 단, 조율자가 또 하나의 서비스라 그 복잡도를 추가로 가져온다는 점은 의식해야 한다.\nTCC TCC(Try-Confirm-Cancel) 는 같은 보상 원리를 비즈니스 레벨 예약으로 변형한 것이다. Try 단계에서 자원을 예약하고, 모든 Try 가 성공하면 Confirm, 하나라도 실패하면 Cancel(보상) 을 호출한다.\nsequenceDiagram participant C as Coordinator participant P1 as 참여자 1 participant P2 as 참여자 2 participant P3 as 참여자 3 Note over C,P3: 1단계 — Try (예약) C-\u003e\u003eP1: try C-\u003e\u003eP2: try C-\u003e\u003eP3: try P1--\u003e\u003eC: reserved P2--\u003e\u003eC: reserved P3--\u003e\u003eC: reserved Note over C,P3: 2단계 — Confirm (확정) C-\u003e\u003eP1: confirm C-\u003e\u003eP2: confirm C-\u003e\u003eP3: confirm P1--\u003e\u003eC: ack P2--\u003e\u003eC: ack P3--\u003e\u003eC: ack Saga 와의 차이는 잠금 길이다. Saga 는 각 단계가 로컬 트랜잭션으로 즉시 commit 되어 다른 트랜잭션이 그 상태를 자유롭게 본다. TCC 는 예약이 걸려 있는 동안 그 자원의 일부 — \u0026ldquo;예약된 좌석\u0026rdquo;, \u0026ldquo;잠금 처리 중 잔액\u0026rdquo; 처럼 — 가 비즈니스 의미상 잠긴다. 실제 DB lock 은 아니지만 짧은 의미적 lock 이 걸리는 셈이다.\n대가는 명확하다. 모든 참여 서비스가 reserve/confirm/cancel 세 가지 API 를 일관되게 노출해야 한다. Saga 의 단순함을 일부 포기하고 잠금 시간을 짧게 가져가는 트레이드오프다.\nOutbox Saga 든 다른 이벤트 기반 패턴이든 모두 DB write 와 이벤트 발행이 한 트랜잭션이 아니라는 본질적 한계를 공유한다.\n상태를 DB 에 저장하고 그 사실을 이벤트로 발행한다고 하자. 두 작업이 분리되어 있다는 점이 문제다. DB write 후 이벤트 발행 직전에 서비스가 중단되면 이벤트가 유실된다. 반대로 이벤트 발행 후 DB write 가 실패하면 일어나지 않은 일을 알린 셈이 된다. 두 작업을 어떤 순서로 두든 일관성이 깨질 수 있다.\nOutbox Pattern 은 이 문제를 같은 DB 트랜잭션 안에서 두 작업을 묶어 해결한다. 비즈니스 데이터를 쓰는 동시에 발행할 이벤트를 outbox 라는 별도 테이블에 같은 트랜잭션으로 기록한다. 트랜잭션이 commit 되면 outbox 에 이벤트가 안전하게 남는다. 별도의 publisher 프로세스가 outbox 를 polling 하거나 CDC(Change Data Capture) 로 변경을 감지해서 메시지 broker (흔히 Kafka) 에 발행한다.\n이 구조는 at-least-once delivery 를 자연스럽게 받아들인다. publisher 가 발행 후 outbox 에서 지우기 전에 중단되면 같은 이벤트가 다시 발행될 수 있다. 그래서 consumer 측은 멱등성을 가져야 한다. 같은 이벤트를 두 번 처리해도 결과가 같도록 설계한다.\nOutbox 는 Kafka 환경에서 DB 와 broker 의 일관성을 보장한다.\n패턴 선택 기준 분해 기준 — 도메인 경계, 데이터 소유권, 스케일 패턴, 장애 경계 — 이 분산 트랜잭션 패턴 선택의 출발점이 된다.\n도메인 경계로 분할했는데 두 도메인이 강한 일관성을 요구한다면 그 경계가 잘못 그어졌다는 신호다. 정말 강한 일관성이 필요하면 한 도메인으로 합치는 게 낫고, 그래도 분리가 필수면 2PC 를 좁은 경로에 한정해서 쓴다. 데이터 소유권 기준에서 한 서비스의 변경이 다른 서비스의 뷰/캐시를 갱신해야 한다면 Saga (Choreography) + Outbox 의 조합이 잘 맞는다. 이벤트로 갱신을 전파하고 Outbox 로 일관성을 보장한다. 스케일 패턴 기준에서 폭증 트래픽을 흡수해야 한다면 Saga (Choreography) 가 큐에 의존해서 부하를 평탄화하기 좋다. 장애 경계 기준에서 critical/non-critical 을 분리했다면 non-critical 경로는 Saga 로 결과적 일관성을 받아들이고, critical 경로는 강한 일관성 또는 단일 트랜잭션이 가능한 경계로 재설계한다. 여러 트랜잭션이 같은 자원을 두고 경합하고 over-allocation 을 허용할 수 없다면 TCC 의 예약이 적합. Saga 보다 짧은 의미적 lock 으로 정확성을 확보하면서, 2PC 의 DB lock 까지는 가지 않는 중간 지점이다. 패턴 선택을 기술의 선호로 보지 않고 경계 결정의 자연스러운 귀결로 보면, \u0026ldquo;이 시스템에 Saga 를 쓰는 게 맞나\u0026rdquo; 같은 질문이 \u0026ldquo;분해 기준이 이 패턴을 요구하나\u0026rdquo; 로 바뀐다.\n단순함의 비용 분산 트랜잭션은 정상 흐름의 설계가 아니라 실패 시의 회복 설계다. 정상 흐름은 어느 패턴이든 단순해 보인다. 차이가 드러나는 곳은 부분 실패, 메시지 유실, 중복 처리, 데이터 불일치 같은 실패 케이스다. 패턴을 평가할 때 정상 흐름이 아니라 실패 흐름을 머리에 그려보는 것이 정직한 평가 방법이라고 본다.\n모놀리스에서는 단일 ACID 트랜잭션이 이 실패 회복을 기본으로 제공해줬다. 분산 환경에서는 그 단순함이 명시적 비용을 지불한 결과가 된다. 보상 로직 구현, 멱등성 설계, outbox 인프라, orchestrator 운영, 디버깅 도구. 이 비용을 의식하지 않고 패턴부터 꺼내면 시스템이 정상 흐름에서는 잘 동작하다가 첫 부분 장애에서 일관성이 깨진다.\n그래서 패턴 선택은 곧 회복 전략 설계의 다른 이름이다. 어떤 실패가 일어날 수 있고, 그때 시스템이 어떤 상태로 회복되어야 하는가. 그 그림을 먼저 그리고 그에 맞는 패턴을 고르는 순서가 자연스럽다.\n참고 Microservices Architecture — 분해 기준(도메인 경계, 데이터 소유권, 스케일, 장애)과 서비스 간 통신 결정. 분산 트랜잭션 패턴 선택의 전제. Kafka 기초와 KRaft 모드 — Kafka producer/consumer 동작과 partition/offset 의미. Outbox 가 DB 와 broker 사이의 일관성을 보장하는 배경. ","permalink":"https://wid-blog.github.io/posts/tech/architecture/distributed-transactions/","summary":"분산 트랜잭션은 모놀리스의 단일 ACID 트랜잭션이 분산 환경에서 어떻게 분해되고, 그 조각을 어떻게 재조립하는가의 문제다. 2PC, Saga (Choreography vs Orchestration), Outbox 의 역할과 트레이드오프.","title":"Distributed Transactions"},{"content":"처음 Claude Code 를 커스터마이징하려고 열어보면 표면이 흩어져 있다. settings.json, CLAUDE.md, slash commands, subagents, hooks, plugins — 같은 의도를 담을 자리가 여러 곳이라 \u0026ldquo;어디에 넣어야 하는가\u0026rdquo; 부터 막힌다. 한 파일에 다 몰면 CLAUDE.md 가 비대해지고, 나누면 어느 설정이 언제 활성화되는지 추적이 안 된다.\n축 하나만 잡으면 이 문제가 거의 사라진다. 언제 개입하는가. 이 글은 그 축으로 설정을 네 레이어로 정돈하는 이야기다.\n레이어 구조 레이어 언제 개입하나 책임 CLAUDE.md + Rules 항상 (매 턴 컨텍스트 로드) 컨벤션·가드라인 암묵지 Agents Skill 또는 모델이 위임할 때 컨텍스트 격리된 전문 역할 Skills 내가 호출할 때 재사용 가능한 workflow Hooks 도구 이벤트 전/후 자동 검증·자동화·가드레일 이 표가 글의 전부라고 해도 된다. 나머지는 각 레이어가 이 축 위에서 어떻게 자리 잡는지, 그리고 하나의 워크플로우에서 네 레이어가 어떻게 맞물리는지다.\nCLAUDE.md + Rules Claude 가 매 턴 컨텍스트에 싣고 시작하는 지식이다. 호출하지 않아도 항상 적용된다. 두 계층으로 나뉜다.\nCLAUDE.md 는 프로젝트/유저 단위의 최상위 컨텍스트다. 프로젝트 루트(./CLAUDE.md), .claude/CLAUDE.md, ~/.claude/CLAUDE.md 에 둘 수 있고, 여럿이면 계층 순으로 병합된다. 내 CLAUDE.md 에는 언어 독립적인 행동 규칙이 들어간다.\n# CLAUDE.md (발췌) - 접근법 거부 시: 즉시 멈추고 방향을 물어볼 것 - 변경 범위: 명시적으로 요청된 것만 변경할 것 - 커밋 금지: 명시적으로 요청받기 전까지 절대 커밋하지 말 것 - 접근법 사전 제안: 3개 이상 파일을 수정하거나 아키텍처에 영향을 주는 변경은 코드 수정 전에 접근법을 먼저 제안하고 승인 후 실행할 것 rules/*.md 는 언어·도메인별로 쪼개진 하위 규칙이다. ~/.claude/rules/ 또는 .claude/rules/ 에 .md 로 두면 재귀 탐색되어 로드된다. paths frontmatter 를 쓰면 특정 파일 패턴에만 적용되는 scoped rule 도 가능하다.\n# rules/go.md (발췌) - Get prefix 금지: GetName() ❌ → Name() ✅ - 에러 wrapping: return err 금지. fmt.Errorf(\u0026#34;context: %w\u0026#34;, err) - panic 금지 (라이브러리): main/테스트에서만 허용 # rules/typescript.md (발췌) - Destructuring 우선: 함수 파라미터 ({ server, db }: Config) - 중괄호 필수: if (x) return; ❌ → if (x) { return; } ✅ - 데이터 형태 → type, 구현 계약 → interface - enum 사용: as const 객체 대신 enum # rules/code-principles.md (발췌, 언어 공통) - fail-fast: validation 실패 시 즉시 raise/throw - 조기 반환: 중첩 if 대신 Guard Clause - 불변성 우선: mutation 필요 시 명시적 범위 한정 - 순수 함수 우선: side effect 는 호출 경계로 밀어내기 - Any 타입 금지: 제네릭, union, 구체 타입으로 해결 언어 규칙을 CLAUDE.md 에 직접 쓰는 대신 rules/ 에 파일 단위로 나눠두면 CLAUDE.md 가 부풀지 않는다.\n구분 포인트: \u0026ldquo;호출 여부와 무관하게 항상 깔려 있어야 하는가\u0026rdquo; 가 이 레이어의 기준이다.\nAgents ~/.claude/agents/\u0026lt;name\u0026gt;.md 에 정의한다. Skill 이나 모델이 위임하면 별도 컨텍스트 윈도우 에서 실행되고 결과만 돌려준다. 메인 세션에 agent 의 작업 과정이 들어오지 않는 것이 핵심이다. @agent-name 으로 직접 멘션해 호출할 수도 있다.\n# agents/architect.md (frontmatter) name: architect description: 아키텍처 분석, 디버깅 근본 원인 진단 tools: [\u0026#34;Read\u0026#34;, \u0026#34;Grep\u0026#34;, \u0026#34;Glob\u0026#34;] model: opus 내 설정의 agent 목록이다.\nAgent 역할 호출되는 곳 architect 구조 분석, 설계 리뷰, 디버깅 /code Stage Pre planner 작업 분해, 실행 계획 수립 /code Stage Pre code-reviewer 스펙 준수 + 코드 품질 리뷰 /code Stage Post, PR review security-reviewer OWASP Top 10 기반 보안 취약점 분석 /code Stage Post database-reviewer 스키마, 쿼리, 마이그레이션 리뷰 /code Stage Post (DB 변경 시) verify-agent 빌드 → 타입 → 린트 → 테스트 파이프라인 /code Stage Post/Fix refactor-cleaner dead code 제거, 코드 정리 /code Stage Clean Skill 은 오케스트레이션, Agent 는 한 책임의 깊은 실행. 다음 Skills 섹션의 다이어그램에서 이들이 어떻게 호출되는지 보인다.\n구분 포인트: \u0026ldquo;메인 컨텍스트와 분리되어야 하는가\u0026rdquo; 가 Agent 의 기준이다.\nSkills Skills 는 명시적으로 호출하는 워크플로우 레시피다. ~/.claude/skills/\u0026lt;name\u0026gt;/SKILL.md 에 정의하고 /skill-name 으로 부른다. 프롬프트, 허용 도구, 모델 지정이 한 파일에 묶인 단위다. 매번 같은 지시를 타이핑하는 대신 \u0026ldquo;이 상황에서는 이 skill\u0026rdquo; 이라는 레시피가 대신 선다.\n/code 가장 잘 작동하는 자리는 반복되는 개발 워크플로우다. 내 설정에서 가장 무거운 skill 인 /code 를 예시로 본다. /code 는 입력을 보고 두 경로를 자동 감지 한다. 텍스트 설명을 주면 설계(Brainstorming)로, .claude/plans/ 디렉토리를 주면 구현(Pipeline)으로 들어간다.\n텍스트 설명을 입력하면 Brainstorming 경로가 실행된다. 요구사항 탐색, 분리 여부 판단, 설계 문서(DESIGN.md) + sub-task 별 계획 파일(NN-\u0026lt;task\u0026gt;.md) + 의존성 그래프(_dag.yaml) 를 .claude/plans/\u0026lt;topic\u0026gt;/ 아래에 쌓고, architect 에이전트로 설계 리뷰까지 돌린 뒤 멈춘다. 산출물은 Pipeline 경로의 입력이다.\nflowchart TD I[\"아이디어 입력/code (Brainstorming)\"] --\u003e C[\"컨텍스트 수집프로젝트 타입 · CLAUDE.md · git log\"] C --\u003e R[\"요구사항 탐색AskUserQuestion 1:1\"] R --\u003e A[\"접근법 2-3개 제시 + 추천\"] A --\u003e PM[\"pm-code-agent분리 판단\"] PM --\u003e|SINGLE| D1[\"DESIGN.md + _dag.yaml01-main.md\"] PM --\u003e|SPLIT| D2[\"DESIGN.md + _dag.yamlNN-task.md × N\"] D1 --\u003e AR[\"architect agent 리뷰\"] D2 --\u003e AR AR --\u003e|NEEDS REVISION| D2 AR --\u003e|APPROVED| S[\"statusdraft → ready\"] S --\u003e O[(\".claude/plans/\u0026lt;topic\u0026gt;/\")] .claude/plans/\u0026lt;topic\u0026gt;/ 디렉토리를 입력하면 Pipeline 경로로 전환된다. 여기서 흥미로운 점은 상세 로직을 SKILL.md 본문에 담지 않고 references/stage-*.md 로 분리했다는 것. 필요한 순간에만 Read 로 불러온다. 덕분에 평소에는 컨텍스트에 오케스트레이션만 상주하고, 해당 단계에 진입할 때만 stage 문서가 로드된다.\n_dag.yaml 의 sub-task 가 위상정렬된 뒤, 각 sub-task 마다 다섯 stage 를 거친다.\nflowchart TD I[(\"/code 입력.claude/plans/\u0026lt;topic\u0026gt;/\")] --\u003e L[\"_dag.yaml 로드위상정렬 + status 게이트\"] L --\u003e PRE[\"Stage Prearchitect agent+ planner agent\"] PRE --\u003e IMP[\"Stage Impl병렬 구현(에이전트 팀)\"] IMP --\u003e POST[\"Stage Post (병렬)code-reviewersecurity-reviewerdatabase-reviewerverify-agent\"] POST --\u003e|FAIL| FIX[\"Stage Fixverify-agent자동 수정\"] FIX --\u003e POST POST --\u003e|PASS| CLEAN[\"Stage Cleanrefactor-cleaneragent\"] CLEAN --\u003e DONE[\"statusready → done\"] DONE --\u003e N{\"다음 sub-task?\"} N --\u003e|있음| PRE N --\u003e|없음| R[\"종합 리포트\"] Pre (stage-pre.md) — architect + planner 를 순차 호출해 구조 분석과 실행 계획을 수립한다. 결과를 sub-task 문서에 ## Plan 으로 append. Impl (stage-impl.md) — 계획을 바탕으로 병렬 구현 을 실행한다. 단순 작업이면 리더가 직접, 복잡하면 팀원 에이전트를 spawn 한다. Post (stage-post.md) — code-reviewer, security-reviewer, database-reviewer, verify-agent 를 병렬 호출 해 종합 검증한다. PASS / NEEDS ATTENTION / FAIL 판정이 여기서 나온다. Fix (stage-fix.md, 조건부) — Post 가 FAIL 이면 verify-agent 를 돌려 fixable 에러를 자동 수정한 뒤 Post 를 재실행한다. retry-policy.md 의 상한(기본 3 회)과 \u0026ldquo;동일 에러 2 회 연속 → 정체 탐지\u0026rdquo; 규칙을 따른다. Clean (stage-clean.md) — refactor-cleaner 로 dead code, 미사용 import, 중복을 정리한다. 여기서 실패는 non-critical 로 취급해 warning 만 남기고 진행한다. 단, clean 이 빌드를 깨면 Post 재실행에서 잡힌다. 한 sub-task 가 다섯 stage 를 통과하면 해당 NN-\u0026lt;task\u0026gt;.md 의 frontmatter status 가 ready → done 으로 승격된다. 이 전환이 재실행 방지 의 장치다 — 같은 plan 을 다시 /code 로 돌려도 done 인 task 는 스킵되고 남은 것만 실행된다.\n이 skill 이 Skill 레이어의 기본 패턴을 보여준다. 입력 형태에 따라 경로를 자동 감지 하고, 상태 있는 파일을 경로 간 입출력으로 주고받으며, 상세 로직을 references 로 분리 해 컨텍스트를 아끼는 구조.\n/github-ship — branch 에서 merge 까지 구현이 끝나면 코드를 올려야 한다. /github-ship 은 branch 생성부터 merge 까지를 하나의 파이프라인으로 묶는다. 원래 /git-branch, /git-commit, /github-pr-push 세 skill 로 나눠져 있던 것을 하나로 통합한 결과다.\n다섯 phase 를 거친다.\nBranch — 변경 내용의 관심사를 분석해 PR 분리 여부를 결정하고 컨벤션에 맞는 브랜치를 생성한다. Commit — staged diff 를 리뷰해 관심사별로 분리하고 컨벤션에 맞춘 메시지를 쓴다. Push \u0026amp; PR — 정적 분석(lint/typecheck) 후 push, gh pr create 로 PR 을 올린다. Review — 변경 규모(TRIVIAL/SMALL/MEDIUM/LARGE)에 따라 리뷰 강도를 조절해 에이전트 병렬 호출. 이슈 발견 시 수정 → push → 재리뷰 루프. Merge — 리뷰 이슈가 모두 해결되면 squash/merge 선택 후 머지. /code 는 전체 sub-task 가 PASS 이면 /github-ship 실행 여부를 자동으로 물어본다. 승인하면 코드 구현부터 PR merge 까지 끊김 없이 이어진다.\n별도로 남아 있는 PR 관련 skill 은 둘이다.\n/github-pr-review \u0026lt;PR번호\u0026gt; — 이미 올라간 PR 을 심층 리뷰한다. github-ship 내부 Phase 4 와 같은 에이전트를 쓰지만 독립 호출용. /github-pr-respond — PR 리뷰 코멘트를 순차로 돌며 반영 여부를 확인하고 답변을 게시한다. CLI 선택 이유 도구를 붙이는 방식은 크게 MCP 서버와 CLI 호출 두 갈래다. git / GitHub 영역에는 양쪽 구현이 다 있다. 그런데 위 skill 들은 전부 gh, git 같은 CLI 를 Bash(...:*) allowed-tools 로 호출한다. 이유는 컨텍스트 절약 이다.\nMCP 서버는 연결되는 순간 자기 tool 카탈로그를 컨텍스트에 상주시킨다. tool 하나당 수백 토큰, 서버 하나가 20 여 개 도구를 노출하면 \u0026ldquo;아무것도 하지 않아도\u0026rdquo; 수천 토큰이 소비된다. 서버 세 개를 붙이면 타이핑 한 글자 하기 전에 4,000 토큰 이상이 소비된다는 측정이 있다 (Scott Spence). 반면 CLI 는 호출 시점에만 토큰을 소비한다. 실제 비교에서 CLI 는 같은 작업을 두고 MCP 대비 토큰 소모를 약 68% 줄인 것으로 보고되며 (BSWEN — MCP vs CLI), 월 운영 비용 기준으로는 4~32 배 차이 까지 나타났다 (BSWEN — Token usage).\nAnthropic 도 이 비용을 인지해 Tool Search 같은 지연 로딩 최적화를 도입했고, MCP 사용 시 에이전트 토큰 소모를 46.9% 줄였다고 밝혔다 (Joe Njenga, Medium). 그럼에도 git 과 GitHub 처럼 이미 성숙한 CLI 가 있는 도메인은 skill + Bash 조합이 여전히 가장 가볍다. github-ship 을 비롯한 git/GitHub skill 들이 MCP 없이 CLI 로만 구성된 이유다.\n구분 포인트: \u0026ldquo;내가 호출할 것인가, 모델이 알아서 부를 수 있는가\u0026rdquo; 의 두 층이 Skill 레이어 내부의 축이다. disable-model-invocation 플래그가 그 경계를 그린다 — 위험하거나 되돌리기 어려운 쓰기 작업은 잠가두고, 일상적으로 auto-trigger 되어야 하는 것은 풀어둔다.\nHooks Hooks 는 호출되지 않는다. 도구 이벤트에 반응해서 자동으로 실행된다. settings.json 의 hooks 에 PreToolUse / PostToolUse 로 등록하면, 특정 도구 호출 전후에 셸 스크립트가 개입한다.\nHooks 의 자리는 크게 둘이다.\n가드레일 — 위험한 명령을 실행 전에 차단한다. remote-command-guard.sh 는 Bash 호출 전(PreToolUse)에 끼어들어 rm -rf, curl | sh, /etc/passwd 접근 같은 카테고리를 검사하고, 걸리면 exit 2 로 차단한다. 자동화 — 파일 수정 후에 포매터를 돌리거나(format-file.sh), 보안 관련 파일 수정 시 리뷰를 권고하거나(security-auto-trigger.sh), 모든 도구 출력에서 시크릿을 마스킹하는(output-secret-filter.sh) 일이 여기 들어간다. \u0026ldquo;매번 손으로 하고 싶지 않지만 매번 해야 하는 것\u0026rdquo; 의 자리다. Permission allow/deny 는 Hooks 와 짝을 이룬다. settings.json 의 permissions.deny 는 정적 필터다. Bash(*rm -rf*) 같은 패턴을 선언하면 매칭되는 명령은 아예 도구 호출로 넘어가지도 않는다. 그 위에 Hooks 가 동적 검사를 추가한다. 정적 필터로 거르기 어려운 맥락 의존적 위험(특정 경로로의 redirect, 조건부 조합 같은 것)을 스크립트가 판단한다. 정적 선언 + 동적 검사 의 두 층이 한 레이어로 묶여 가드레일 구실을 한다.\n구분 포인트: \u0026ldquo;도구 이벤트에 자동으로 개입해야 하는가\u0026rdquo; 가 Hooks 의 기준이다. 호출이 없어야 한다는 조건이 Skills/Agents 와 Hooks 를 가른다.\n통합 워크플로우 레이어 하나씩 보면 각자의 책임이 선명하지만, 실제로는 하나의 작업 흐름에서 네 레이어가 동시에 실행된다. 한 가지 플로우로 그려본다.\n/code \u0026quot;새 인증 모듈 설계\u0026quot; 를 친다 → Skill 이 Brainstorming 경로로 움직인다. 그 Skill 이 내부에서 architect 와 planner 를 dispatch 한다 → Agents 가 격리 컨텍스트에서 설계 분석과 단계 분해를 한다. 이 과정 내내 매 턴 컨텍스트에 언어별 규칙과 코드 원칙이 로드되어 있다 → Rules 가 조용히 깔려 있다. 설계가 끝나면 /code 가 Pipeline 전환 여부를 묻고, 승인하면 구현이 시작된다 → 매번 Edit / Write 가 호출된다. 그때마다 Hooks 가 반응한다. format-file.sh 가 포매터를 돌리고, code-quality-reminder.sh 가 에러 핸들링·불변성 점검을 상기시키고, 보안 파일이면 security-auto-trigger.sh 가 리뷰를 권고한다. 파이프라인 마지막에 /code 는 다시 verify-agent 를 호출한다 → 빌드·타입·린트·테스트가 격리 실행되어 결과만 돌아온다. 전체 PASS 이면 /code 가 /github-ship 실행 여부를 묻는다 → 승인하면 다시 Skill 이 움직여 branch → commit → push → review → merge 까지 이어진다. 한 작업에 네 레이어가 전부 참여하지만 각자의 책임은 겹치지 않는다. 내가 부른 것 (Skills), Skill 이 위임한 것 (Agents), 항상 깔려 있던 것 (Rules), 이벤트에 자동으로 반응한 것 (Hooks) — 네 가지는 서로의 자리를 침범하지 않는다.\n정리 새 설정을 어디에 넣을지 결정할 때 네 질문만 거치면 된다.\n항상 깔려야 하는가? → CLAUDE.md + Rules 도구 이벤트에 자동 반응해야 하는가? → Hooks 호출해서 실행되는 workflow 인가? → Skills 메인 컨텍스트와 분리 실행되어야 하는가? → Agents 둘 이상에 걸리면 책임을 쪼갠다. 설정 전체는 .dotfiles/claude/ 를 참고.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/claude-code-config-layers/","summary":"settings.json, CLAUDE.md, slash commands, subagents, hooks. Claude Code 커스터마이징 표면은 \u0026lsquo;언제 개입하는가\u0026rsquo;라는 축 하나로 네 레이어로 정돈된다.","title":"Claude Code 설정을 레이어로 나누기"},{"content":"Claude Code 를 본격적으로 쓰기 시작하면서 CLI 기반 에디터의 활용도가 부쩍 높아졌다. GUI 에디터에서 터미널을 띄우던 흐름이, 터미널 안에서 모든 것을 띄우는 흐름으로 뒤집혔다.\n이 글은 위 화면을 구성하는 dotfiles 의 기록이다. 각 도구를 왜 선택했고, 어떻게 조합했는지 정리한다.\nClaude Code 의 세부 설정 — skills, agents, hooks, MCP 같은 것들 — 은 별도 글로 정리할 예정이라 이번에는 짧게만 짚는다.\n스택 구성 터미널 에뮬레이터는 alacritty. 그 창 안에서 화면을 셋으로 나누는 것이 tmux. 각 패인에서 실행되는 셸이 zsh. 좌상 패인의 에디터가 LazyVim 기반의 nvim, 우 30% 패인에서 Claude Code 가 AI 페어로 동작한다.\n도구 역할 alacritty 터미널 에뮬레이터 tmux 세션/창/패인 멀티플렉서 zsh 셸 nvim (LazyVim) 에디터 (좌상 패인) Claude Code AI 페어 (우 30% 패인) 이 글은 그 순서대로 — alacritty, tmux, zsh, nvim, Claude Code — 각 도구가 어떤 역할을 맡고 왜 그렇게 선택했는지 본다.\nalacritty alacritty는 Rust 로 작성된 크로스플랫폼 GPU 가속 터미널 에뮬레이터다. 자체 기능을 최소화하고, 화면 분할이나 세션 관리는 다른 도구에 위임하는 설계를 따른다.\n터미널 에뮬레이터로 alacritty 를 골랐다. 이유는 셋이다.\nGPU rendering — OpenGL 기반 렌더링으로 입력 지연이 작다. config-as-code — 단 하나의 alacritty.toml 에 모든 설정이 모인다. 어디 저장됐는지 찾아 헤맬 일이 없다. 단순함 — alacritty 는 탭, 분할, 세션 같은 기능을 의도적으로 포함하지 않는다. 이 자리는 tmux 가 채운다. 마지막 항목이 핵심이다. 분할과 세션을 alacritty 에 맡기지 않고 tmux 에 위임하면 같은 추상이 macOS 와 Linux 양쪽에서 동일하게 동작한다. 아래 계층이 단순할수록 위 계층의 이식성이 높아진다고 봤다.\n공식 기본 설정은 거의 비어 있다. 색상도 폰트도 창 장식도 사용자가 직접 채워 넣기 전까지 alacritty 는 가장 순수한 \u0026ldquo;터미널\u0026rdquo; 에 가깝다. 이 빈 상태가 config-as-code 의 출발점이 된다.\n내가 적용한 커스터마이징은 단순하다. 창 장식을 꺼서 macOS 의 타이틀 바를 없앴고, 여백을 빼서 픽셀 군더더기를 없앴고, 폰트는 nvim 의 devicons 가 렌더되도록 nerd font 계열로 두었고, 색상은 catppuccin mocha 를 toml 에 직접 적었다. 외부 yml/include 없이 한 파일에서 끝난다.\n키바인딩은 Cmd 키 조합을 ESC 시퀀스로 변환한다.\n[keyboard] bindings = [ { chars = \u0026#34;\\u001Bh\u0026#34;, key = \u0026#34;H\u0026#34;, mods = \u0026#34;Command\u0026#34; }, { chars = \u0026#34;\\u001Bl\u0026#34;, key = \u0026#34;L\u0026#34;, mods = \u0026#34;Command\u0026#34; }, { chars = \u0026#34;\\u001Bw\u0026#34;, key = \u0026#34;W\u0026#34;, mods = \u0026#34;Command\u0026#34; }, ] macOS 만의 문제가 하나 있다. Cmd+H 가 OS 메뉴 레벨에서 \u0026ldquo;Hide Application\u0026rdquo; 으로 가로채진다. 이 키는 alacritty 의 키바인딩을 통해 ESC+h (즉 vim 의 M-h) 로 변환되어 nvim 에 전달돼야 하는데, AppKit 이 먼저 소비하면 그 변환이 일어나지 않는다. 그래서 setup-macos.sh 가 마지막에 한 줄을 추가한다.\ndefaults write org.alacritty NSUserKeyEquivalents -dict-add \u0026#34;Hide Alacritty\u0026#34; \u0026#34;\u0026#34; 이 한 줄로 alacritty 의 nvim 통합이 macOS 에서도 동작한다. 이 결정은 설정 파일로 해결되지 않아 OS 수준의 defaults write 명령이 필요하다.\ntmux tmux는 터미널 멀티플렉서다. 하나의 터미널 안에서 여러 세션, 창, 패인을 관리하고, 세션을 detach 해도 프로세스가 유지된다.\nalacritty 위에서 화면을 나누는 일은 tmux 가 한다. 그래서 alacritty 에는 탭이 없고, 대신 tmux 세션 / 창 / 패인이 그 자리를 채운다.\n설정은 기본값에서 크게 벗어나지 않는다. prefix 는 C-b 그대로 유지한다 (C-a 는 readline 의 line-start 와 충돌해 셸에서 방해가 된다). copy mode 는 vim 키로 동작하게 하고, nvim 과의 ESC 지연은 제거했다. 마지막 항목은 작은 설정이지만 nvim 사용자에게 효과가 크다.\ntmux 는 ESC 키를 prefix 나 meta 시퀀스의 시작으로 해석하기 위해 대기 시간을 둔다. set -gs escape-time 0 으로 이를 제거하면 nvim 에서 모드 전환이 즉시 반영된다.\n분할 키바인딩에 두 가지 결정이 글의 흐름과 직결된다. 우측에 Claude Code 패인을 띄우는 단축키, 그리고 그 배치를 한 키로 정리하는 단축키다. 뒤에 설명할 nc 함수는 이 분할을 함수 한 번으로 자동으로 구성하는 상위 도구에 해당한다.\nbind i split-window -fh -p 30 -c \u0026#34;#{pane_current_path}\u0026#34; \u0026#34;claude\u0026#34; bind o split-window -v -l 15% -c \u0026#34;#{pane_current_path}\u0026#34; prefix i 는 우측에 Claude Code 패인을, prefix o 는 하단에 셸 패인을 생성한다. nc 함수가 자동화하는 분할을 수동으로도 실행할 수 있다.\n또 하나 중요한 결정은 vim 과 tmux 의 패인 이동을 같은 키로 통합한 것이다. C-h/j/k/l 을 prefix 없이 누르면, nvim 안에서는 nvim 의 좌측 창으로, 셸 패인에서는 tmux 의 좌측 패인으로 이동한다. 현재 pane 의 프로세스가 vim 계열인지를 tmux 쪽에서 검사해 자동으로 분기한다. 도구 경계가 사라진다. 손가락이 prefix 키를 거치지 않고 세 패인 사이를 이동할 수 있다.\nis_vim=\u0026#34;ps -o state= -o comm= -t \u0026#39;#{pane_tty}\u0026#39; \\ | grep -iqE \u0026#39;^[^TXZ ]+ +(\\\\S+\\\\/)?g?(view|n?vim?x?)(diff)?$\u0026#39;\u0026#34; bind-key -n \u0026#39;C-h\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-h\u0026#39; \u0026#39;select-pane -L\u0026#39; bind-key -n \u0026#39;C-j\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-j\u0026#39; \u0026#39;select-pane -D\u0026#39; bind-key -n \u0026#39;C-k\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-k\u0026#39; \u0026#39;select-pane -U\u0026#39; bind-key -n \u0026#39;C-l\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-l\u0026#39; \u0026#39;select-pane -R\u0026#39; is_vim 이 패인의 프로세스를 검사한다. vim 계열이면 키 입력을 nvim 에 전달하고, 아니면 tmux pane 이동을 실행한다.\nzsh zsh는 Bash 호환 셸로, 강력한 자동완성과 확장된 글로빙, 플러그인 생태계가 특징이다. macOS Catalina 이후 기본 셸이기도 하다.\nzsh 는 두 가지로 구성된다. PATH/환경 을 잡는 .zshrc 와 함수/alias 를 모아둔 aliases.zsh. ZDOTDIR 로 ~/.config/zsh 를 가리킨 뒤, .zshrc 가 그 디렉토리의 *.zsh 를 모두 source 한다.\nZDOTDIR=$HOME/.config/zsh for _zsh_conf in $ZDOTDIR/*.zsh(N); do source \u0026#34;$_zsh_conf\u0026#34; done 이 패턴 덕분에 alias / 함수 / 플러그인 설정을 파일로 분리해서 추가할 수 있다. 새 함수가 생기면 새 .zsh 파일을 만들고 끝이다. .zshrc 자체는 거의 안 건드린다.\nnc 함수의 정의는 다음과 같다.\nfunction nc() { if [[ -z \u0026#34;$TMUX\u0026#34; ]]; then echo \u0026#34;Not inside a tmux session. Run from within tmux.\u0026#34; return 1 fi local target=\u0026#34;${1:-$PWD}\u0026#34; local dir if [[ -d \u0026#34;$target\u0026#34; ]]; then dir=\u0026#34;$(realpath \u0026#34;$target\u0026#34;)\u0026#34; else dir=\u0026#34;$(realpath \u0026#34;$(dirname \u0026#34;$target\u0026#34;)\u0026#34;)\u0026#34; fi local nvim_pane nvim_pane=\u0026#34;$(tmux display-message -p \u0026#39;#{pane_id}\u0026#39;)\u0026#34; tmux split-window -h -c \u0026#34;$dir\u0026#34; -l 30% \u0026#34;claude; exec $SHELL\u0026#34; tmux select-pane -L tmux split-window -v -c \u0026#34;$dir\u0026#34; -l 15% tmux select-pane -t \u0026#34;$nvim_pane\u0026#34; nvim \u0026#34;$@\u0026#34; } 함수를 단계별로 살펴보자.\n첫 번째 가드는 tmux 안인지 확인한다. tmux 밖에서 nc 를 호출하면 분할 자체가 의미가 없으므로 즉시 종료한다. 메시지를 한 줄 출력하고 1 을 반환한다.\n다음은 작업 디렉토리 결정이다. 인자가 없으면 $PWD, 디렉토리면 그대로, 파일이면 그 부모 디렉토리를 사용한다. realpath 로 절대 경로화한다. 이 dir 이 세 패인 모두의 cwd 로 지정된다. nvim 으로 파일을 열고 옆 패인에서 git status 를 입력하면 같은 repo 가 보인다.\n다음은 현재 패인의 ID 저장이다. 분할이 끝난 뒤 nvim 으로 포커스를 되돌리려면 원래 패인의 id 가 필요한데, 분할 도중에 id 가 바뀔 수 있으므로 미리 저장한다.\n이후 분할을 두 번 수행한다.\n첫 번째 분할은 tmux split-window -h -c \u0026quot;$dir\u0026quot; -l 30% \u0026quot;claude; exec $SHELL\u0026quot; 로, 우측에 30% 폭의 패인을 만들고 claude 를 실행한다. exec $SHELL 을 붙이면 claude 종료 후 패인이 즉시 사라지지 않고 셸로 전환된다.\n두 번째 분할은 tmux select-pane -L 으로 좌측 (원래 nvim 자리) 으로 돌아간 뒤, tmux split-window -v -c \u0026quot;$dir\u0026quot; -l 15% 로 위/아래로 나눈다. 아래 15% 가 작은 터미널 패인이 된다.\n마지막으로 nvim 을 실행한다. 저장해둔 nvim_pane 으로 포커스를 이동한 뒤 (이제 좌상 패인이다) nvim \u0026quot;$@\u0026quot; 으로 에디터를 연다. 인자가 파일이면 해당 파일이 열리고, 디렉토리면 LazyVim 의 대시보드가 표시된다.\n결과적으로 아래와 같은 배치가 nc 한 번으로 구성된다.\n┌───────────────────────────┬──────────────┐ │ │ │ │ nvim │ claude │ │ (LazyVim) │ (30%) │ │ │ │ ├───────────────────────────┤ │ │ shell (15%) │ │ └───────────────────────────┴──────────────┘ 이 함수를 호출하는 alias 가 몇 개 있다.\nalias zrc=\u0026#34;nc ~/.config/zsh/\u0026#34; alias nvimrc=\u0026#34;nc ~/.config/nvim/\u0026#34; alias alc=\u0026#34;nc ~/.config/alacritty/\u0026#34; alias tlc=\u0026#34;nc ~/.tmux.conf\u0026#34; 즉 zrc 한 번이면 zsh 설정 디렉토리를 nvim 으로 열면서 옆에 Claude Code 가 떠 있는 상태가 된다. 설정 파일을 고치다가 바로 옆 pane 에서 검토를 받을 수 있다.\n다른 alias 도 몇 가지 있다. f 는 fzf 로 파일을 골라 nvim 으로 여는 단축키, g 는 lazygit, ?? 는 fabric-ai, ? 는 w3m 검색이다. 그 중 nc 가 이 글의 중심인 이유는, 함수 한 줄이 다섯 도구의 자리를 동시에 정의하기 때문이다.\nnvim Neovim은 Vim 의 리팩토링 포크다. 비동기 플러그인, 내장 LSP 클라이언트, Lua 기반 설정이 추가되었다. LazyVim은 그 위에 합리적 기본값과 모듈식 extras 시스템을 제공하는 설정 프레임워크다.\n에디터는 LazyVim 베이스의 nvim 이다. 처음부터 init.lua 를 짜는 대신 LazyVim 의 합리적 기본값을 받는 쪽을 골랐다. LSP, treesitter, finder, mason 기반 LSP 설치가 이미 묶여 있어서 직접 짜면 며칠이 걸린다. 그리고 언어별 extras 가 lazyvim.json 의 줄 단위로 켜고 꺼지기 때문에, 새 언어가 필요해지면 한 줄 추가 + :Lazy sync 한 번으로 LSP / treesitter / 포매터까지 한꺼번에 설치된다. 현재 14개 언어와 코딩/에디터/포매팅/테스트 extras 를 포함해 32개가 활성화되어 있다.\n커스터마이징은 두 디렉토리로 나뉜다. lua/config/* 는 LazyVim 의 기본값을 재정의하는 위치 (keymap, option, autocmd), lua/plugins/* 는 새 플러그인이나 extras 의 추가 옵션을 배치하는 위치다. 언어별 설정은 plugins/language/\u0026lt;lang\u0026gt;.lua 한 파일로 분리했다. go 를 더 쓰지 않게 되면 그 파일만 지우면 된다.\nlua/plugins/language/ ├── go.lua ├── html.lua ├── java.lua ├── markdown.lua └── typescript.lua nvim 자체에 대해서는 더 깊이 들어가지 않는다. 키맵, LSP 설정, 디버거 통합, snacks.nvim picker, harpoon2 워크플로우 등 각각이 별도 글 분량이다. 이 글의 범위는 \u0026ldquo;LazyVim 베이스에 모듈식으로 구성하는 패턴\u0026rdquo; 까지다.\nClaude Code 우측 30% 패인의 위치는 Claude Code 가 채운다. brew cask 한 줄 (cask \u0026quot;claude-code\u0026quot;) 로 설치되고, nc 함수가 그 자리를 구성한다. 화면 안에서의 역할은 단순하다. 좌측에서 코드를 편집하는 동안 우측에서 같은 디렉토리의 컨텍스트로 함께 작업하는 페어다.\ndotfiles 의 claude/ 모듈은 이보다 더 많은 것을 포함한다. ~/.claude/ 아래에 settings, agents, hooks, rules, skills 가 stow 되며, 이 파일들이 Claude Code 의 동작을 세부적으로 조정한다. agents 는 작업 단위 위임, hooks 는 파일 저장 시점의 자동화, skills 는 재사용 가능한 워크플로우, rules 는 언어별/공통 코드 컨벤션을 담당한다.\n다만 이번 글의 범위는 \u0026ldquo;다섯 도구가 한 화면에 모이는 패턴\u0026rdquo; 까지다. Claude Code 의 각 컴포넌트 (settings, agents, hooks, skills, MCP, output styles) 는 별도 글로 정리할 예정이다.\n이 글에서 정리할 한 가지는 단순하다. Claude Code 는 nc 함수가 만든 우 30% 패인 안에서 실행된다. 그 이상도 그 이하도 아니다. 배치가 도구를 호출하고, 도구는 배치 위에서 동작한다.\n한계와 트레이드오프 이 셋업이 안 맞는 경우가 몇 있다.\nGUI 디버거에 의존하는 작업. 브라우저 devtools, 대형 IDE 의 시각 디버거가 일상 도구라면 터미널 중심 배치는 두 도구 사이를 자주 왕복하게 만든다. 이 구성은 코드 편집 + 셸 + AI 페어가 99% 를 차지한다는 가정 위에 서 있다.\n협업 스크린쉐어. 동료에게 화면을 보여줄 때 nvim 키바인딩의 의도가 전달되지 않는 경우가 자주 있다. dd 가 한 줄을 삭제하는 모습을 처음 보는 사람은 낯설어한다. 페어 프로그래밍이 잦다면 GUI 에디터의 사회적 비용이 더 적다.\nLinux 셋업과의 차이. 같은 dotfiles 가 Linux 에서도 동작하지만, 이 글이 다루지 않은 부분 (Hyprland 윈도우 매니저, Kime 입력기, Linux 전용 패키지) 은 별개다. macOS 에서는 alacritty 가 터미널 에뮬레이터 역할을 맡지만, Linux 에서는 Hyprland 윈도우 매니저가 그 역할을 일부 담당한다.\n빠진 조각도 솔직히 짚는다. 입력기 (kime), 윈도우 매니저 (hypr), 키매핑 (karabiner) 은 이 글의 범위 밖이라 넣지 않았다. 한국어 개발자에게는 입력기 결정이 결국 영향을 주지만, 이건 분리된 글이 더 적합하다고 판단했다.\n마무리 시작 지점으로 돌아가면 nc 한 번에 셋으로 분할된 화면이 있고, 그 안에 다섯 도구가 각자의 역할을 맡는다. dotfiles 는 alacritty / tmux / zsh / nvim / Claude Code 를 어떤 역할로 배치할지에 대한 결정의 묶음이다.\n다음 글들에서는 Claude Code 의 세부 (settings, agents, hooks, skills, MCP) 를 하나씩 다룬다. 이 글이 도구 배치에 대한 글이라면, 다음 글들은 그 역할 안에서 실행되는 도구의 결정을 다루는 글이 된다.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/macos-dev-environment/","summary":"alacritty + tmux + nvim + zsh + Claude Code 다섯 도구로 구성한 터미널 중심 개발 환경. 각 도구의 선택 이유와 조합 방식을 정리한다.","title":"macOS 개발 환경: dotfiles"},{"content":"1년 넘게 쓰던 경품 에어팟을 버렸다.\n2022년, 백엔드 엔지니어로 지금 회사에 입사했다. 새로운 영역에서 배울 것이 많았고, 주어진 과제를 하나씩 해결하는 데 집중했다.\n기능을 만들고, 이슈를 해결하고, 다음 스프린트로 넘어갔다. 그 반복 자체는 문제가 아니었다.\n문제는 그 사이, 귀를 닫고 있었다는 것이다.\n기술적 맥락이나 판단 근거를 동료에게 전달할 때, \u0026ldquo;전달했다\u0026quot;고 생각했지만 실제로는 \u0026ldquo;전달되지 않은\u0026rdquo; 경우가 많았다. 코드 리뷰에서, 기술 논의에서, 장애 대응에서 — 머릿속에 있는 것을 상대방이 이해할 수 있는 형태로 설명하는 데 서툴렀고, 다른 사람의 의견을 그 사람의 기준에서 듣는 데 서툴렀다.\n자신만의 틀에 갇혀 일하는 것이 습관이 되었고, 번아웃이 찾아왔다.\n여러 고민 끝에 3개월 휴직을 제안드렸다.\n번아웃을 단순히 쉬는 것으로 마무리하고 싶지 않았다. 부족한 부분이 무엇인지 회고하고, 개선해 나가는 시간을 가지려 한다.\n회사를 다니면서 문서화를 싫어했다. 필요한 건 알았지만, 글로 남기는 일 자체가 번거로웠다.\n그런데 커뮤니케이션은 머리로 이해한다고 느는 것이 아니었다. 꺼내놓고, 전달해보고, 상대의 반응을 마주해야 조금씩 쌓이는 것이었다.\n그래서 블로그를 시작한다. 잘 쓰려는 것이 아니라, 쓰고 내보내는 경험 자체를 쌓기 위해서다.\n기술적인 것이든 — 일하며 느낀 것이든 — 내 생각과 경험을 꺼내놓는 연습을 해보려 한다.\n\u0026ldquo;좋은 엔지니어 = 기술을 잘 아는 사람\u0026quot;이라고 생각했다. 틀린 말은 아니지만, 부족한 정의였다.\n내가 아는 것이 팀에 전달되지 않으면, 그 지식은 팀에 존재하지 않는 것과 같다.\n좋은 엔지니어는 기술을 잘 아는 사람이 아니라, 그 기술을 팀과 나눌 수 있는 사람이라는 것을 알게 되었다.\n그래서 에어팟을 버렸다. 귀를 기울이고, 적극적으로 소통해보기 위해서다.\n","permalink":"https://wid-blog.github.io/posts/career/dable/starting-sabbatical/","summary":"좋은 엔지니어는 기술을 잘 아는 사람이 아니라, 그 기술을 팀과 나눌 수 있는 사람이라는 것을 알게 되었다.","title":"에어팟을 버렸다"},{"content":"보안 컴플라이언스 작업이 필요했다.\n운영 중인 서비스의 일부 컬럼이 암호화 대상이었다. 데이터는 두 가지 형식으로 나뉘었다. 컬럼 값 자체가 암호화 대상인 경우, 그리고 JSON 객체로 저장된 컬럼에서 특정 필드만 암호화가 필요한 경우. 신규 시스템이 아닌, 이미 트래픽이 흐르고 있는 운영 환경이었다.\n작업은 두 갈래로 보면 정확하다. 하나는 암호화 모듈 을 새로 만드는 일, 다른 하나는 그 모듈을 운영 중 서비스에 적용 하는 일. 결과적으로 두 번째가 더 컸다.\n암호화 전략 대칭키 방식의 AES-256-GCM 을 선택했다. 키 관리는 봉투 암호화 구조로 가져갔다. CMK 가 DEK 를 암호화하고, DEK 가 데이터를 암호화하는 2중 키 구조다. 키 유출 영향을 제한하고 키 회전을 단순화하는 방식인데, 원리 자체는 별도 기술 글에서 정리해 두었다.\n키 저장소로는 관리형 비밀 저장소(managed secret store)를 선택했다. 관리형 KMS 와 시스템 설정 저장소도 후보였지만, 운영 비용과 DEK 저장이라는 용도를 고려하면 비밀 저장소가 적합했다. 시스템 설정 저장소는 키 저장 용도와 결이 맞지 않다는 피드백도 초기 설계 리뷰에서 받았다.\nDEK 관리 단위 — row 단위에서 테이블 단위로 초기 설계는 row 단위 였다. 각 row 마다 별도의 DEK 를 생성하고, 그 DEK 를 row 안에 함께 저장하는 구조. 키가 유출되더라도 영향 범위가 해당 row 에 국한된다는 장점이 있었다.\n그런데 초기 설계 리뷰에서 운영과 관리 복잡도가 너무 올라간다는 피드백을 받았다. 검토 끝에 테이블 단위 로 바꿨다.\nrow 단위는 row 가 늘어날 때마다 키 발급 호출과 저장 공간이 함께 늘어난다. 운영 환경에서 갖는 의미는 단순한 비용 증가가 아니다. 키 관리 API 의 호출 빈도, 백업/복원 시의 처리량, 마이그레이션 시 row 단위 키 발급 로직, 전반적으로 시스템을 무겁게 만든다.\n테이블 단위는 영향 범위가 한 테이블로 넓어지는 대신, 운영이 단순해진다. 민감도 등급별로 키를 분리하는 식으로 영향 범위를 다른 기준으로 좁힐 수 있었다.\n처음에 \u0026ldquo;이게 더 안전하다\u0026rdquo; 고 봤던 답이 운영을 만나면 흔들리는 사례였다. 트레이드오프의 양쪽을 다 보고 나서야 결정의 무게가 와닿는다.\n사내 모듈 — 두 가지 패턴 앞서 본 두 데이터 형식은 처리 방식이 갈렸다.\n하나는 컬럼 값 전체 를 하나의 암호문으로 치환하는 방식. 컬럼 자체가 민감 정보일 때 적용된다.\n다른 하나는 JSON 객체 안 해당 필드 값만 암호문으로 치환하는 방식. 객체 구조와 비민감 필드는 평문으로 유지된다.\n두 패턴을 하나의 모듈로 묶어 두지 않으면 호출부가 두 갈래로 분기된다. 모듈은 둘 다 일급 API 로 제공하는 방향으로 설계했다.\n마이그레이션 — 무중단 3단계 운영 중인 서비스라 단번에 컬럼을 바꿀 수 없었다. 3단계로 나눴다.\n준비. 암호화 컬럼을 DDL 로 추가하고, 코드에는 이중 쓰기를 적용한다. INSERT/UPDATE 는 평문과 암호문 양쪽에 동시에 쓰고, SELECT 는 암호화 컬럼이 있으면 복호화, 없으면 원본 평문을 읽는 fallback 분기를 둔다. 마이그레이션. 기존 평문 데이터를 일괄 암호화해 신규 컬럼을 채운다. dry-run 으로 대상 건수를 먼저 확인하고, 배치 사이즈를 조정해 실행한다. 정리. 신규 컬럼이 완전히 채워진 것을 검증한 뒤 평문 컬럼과 fallback 분기를 제거한다. 각 단계 사이에 PR 머지와 배포가 들어간다. 다음 단계의 코드는 이전 단계가 모두 배포된 후 에만 안전하다는 전제 위에서 작성된다.\nWHERE 절과 HMAC 적용 도중에 발견된 제약이 있었다.\n일부 컬럼이 WHERE 조건으로 쓰이고 있었다. 검색 조회나 중복 체크 같은 용도다. 이 컬럼을 그대로 암호화하면 쿼리가 깨진다. AES-GCM 은 같은 평문이라도 매번 다른 암호문을 만들기 때문에, WHERE email = '...' 식의 동등 비교가 무의미해진다.\n검색 가능성을 유지하려면 결정적인 변환 이 필요했다. 단방향 해시인 HMAC 으로 별도 컬럼을 두기로 했다. 쓸 때 원본을 HMAC 으로 한 번, 암호문으로 한 번, 두 번 저장한다. 검색은 HMAC 컬럼에서, 실제 값 복원은 암호문 컬럼에서.\n이런 제약은 사전 컬럼 분석으로는 잡히지 않았다. 컬럼 이름과 타입만 보고는 그게 쿼리에서 어떻게 쓰이는지 알기 어렵다. 코드 베이스를 직접 훑어야 보이는 종류의 제약이었다.\n조직 확산 — 마이그레이션 자동화 Skill 모듈이 동작한다고 끝이 아니었다. 적용 대상 컬럼은 여러 서비스에 흩어져 있었고, 각 서비스마다 3단계 마이그레이션을 누군가가 직접 작성해야 했다.\n매번 사람이 같은 절차를 반복하면 실수가 따라온다. 이슈 트래커 티켓 형식이 제각각이라 컬럼 정보 파싱이 어렵고, 마이그레이션 스크립트의 dry-run 형태도 사람마다 달라진다.\n작업자 누구나 같은 절차로 마이그레이션할 수 있도록 자동화 Skill 을 만들었다. 이슈 트래커 티켓의 표준 메타데이터에서 컬럼 정보를 파싱하고, 모듈 API 에 맞는 마이그레이션 스크립트를 생성한다. dry-run 결과를 보고 실제 실행으로 넘어가는 흐름까지 표준화했다.\n이슈 트래커 티켓 형식도 같이 정리했다. 서버, 데이터베이스, 테이블, 컬럼명, 타입, 민감 필드 — 표준 테이블 형식을 정의하고, 미달되는 description 은 Skill 이 추가 질문으로 보완한다.\n해커톤에서 AI 도구를 처음 활용해 봤던 경험이, 그때는 짧은 시간에 결과물을 내는 도구로 썼다면, 이번에는 조직 표준 절차 를 만드는 쪽으로 옮겨갔다.\n배운 점 모듈을 만드는 것 보다 적용 이 더 컸다. 봉투 암호화도, 두 패턴도, 3단계 마이그레이션도 출발은 표준 패턴이었는데, 운영에 옮기면서 다듬었다. DEK 단위는 운영 비용 때문에 row 에서 테이블로 바꿨다. AES-GCM 의 기밀성과 검색 가능성을 함께 살리려고 HMAC 보조 컬럼을 더했다. 같은 절차를 여러 서비스에 반복 적용하면서 Skill 로 정리했다. 그 과정이 작업의 실제 분량이었다.\n설계는 한 번 정해서 끝나지 않는다. row 단위에서 테이블 단위로 갔고, 두 패턴이 모두 필요했고, 적용 도중 HMAC 이 추가됐다. 처음에 옳다고 본 답이 운영을 만나며 다듬어지는 흐름이 있었다.\n마지막으로, 모듈을 쓸 수 있게 만드는 것 이 모듈을 만드는 것만큼 컸다. Skill 이 그 빈자리를 채워줬다. 보안 요건은 통과의 목표였지만, 결과적으로 민감 정보를 다루는 조직 표준 이 남았다.\n참고 봉투 암호화 — 봉투 암호화의 CMK/DEK 2중 키 구조와 원리. 사내 해커톤 1등 회고 — AI 도구 활용의 출발점. ","permalink":"https://wid-blog.github.io/posts/career/dable/sensitive-data-encryption-retrospective/","summary":"운영 중인 DB의 민감 정보를 컬럼 레벨로 암호화한 작업 회고. 봉투 암호화 도입, DEK 관리 단위 결정, WHERE 절 제약과 HMAC 우회, 마이그레이션 자동화 Skill까지.","title":"민감 정보 암호화 — 모듈 설계와 마이그레이션 회고"},{"content":"사내 해커톤에 참가했다.\n평소 같이 일하지 않던 동료들과 짧은 시간에 결과를 만들어낸 협업의 경험이었다. AI 도구를 활용하기 시작한 시점이기도 했다.\n아이디어 법인카드를 쓰면 그룹웨어에 전표를 작성해야 한다. 회사의 전표 작성 가이드가 있고, 매번 그 가이드를 찾아 양식을 채우는 게 반복적인 부담이었다. 작은 점심 한 끼만 결제해도 분류, 적요, 세금 코드 같은 항목을 정해진 규칙대로 입력해야 했다.\n이 부담을 LLM Agent 로 자동화하자는 아이디어를 제안했다. 사용자가 결제 적요를 입력하면 Agent 가 회사 전표 작성 가이드를 참조해 양식을 자동으로 채워준다. 해커톤 동안 만들 수 있는 범위로도 가치가 보이는 시도였다.\n형태 전환 — Slack Bot 에서 Chrome Extension 으로 처음에는 Slack Bot 으로 시작하려고 했다. 사내에서 이미 익숙한 인터페이스니까. 그런데 동료가 Chrome Extension 으로 가자는 의견을 냈다. 사용자가 그룹웨어를 보면서 그 화면 위에서 바로 챗봇을 쓸 수 있다면 흐름이 끊기지 않는다. 사용자 친화성 측면에서 결정적인 변화였다.\n평소 같이 일하지 않던 다른 팀 개발자와 비개발자 동료들이 함께였다. Chrome Extension UI, 백엔드 Agent 응답 형식, 사내 가이드를 LLM 에 어떻게 주입할지, 사용자 검토용 흐름 — 각자의 강점이 다른 사람들이 짧은 시간에 한 결과물을 향해 모이는 경험이 신선했다. 평소 같으면 메신저로 한참 주고받았을 의사결정이 옆에 앉아 한두 마디로 정리되는 상황이 자주 있었다.\n만든 것 Chrome Extension 챗봇과 백엔드 LLM Agent 를 결합한 구조였다.\n사용자가 그룹웨어 페이지에서 챗봇을 열고 결제 적요를 입력하면, 백엔드 Agent 가 회사 전표 작성 가이드를 참조해 양식을 채운 응답을 돌려준다. 사용자는 그 결과를 검토해 그룹웨어에 반영하면 된다.\n도구 조합도 이번 해커톤에서 정리됐다. 개발에는 Claude Code 를 활용하기 시작했고, Agent 의 백엔드 LLM 으로는 ChatGPT 의 structured output 을 사용했다. 둘 다 내게는 첫 활용이었다.\nAI 도구를 적극 쓰기 시작한 시점 Claude Code 가 짧은 시간에 결과물을 낼 수 있게 해줬다. 평소 손으로 짜야 했던 분량을 빠르게 통과시키고 다음 결정으로 넘어갈 수 있었다.\n단, 작업 단위를 충분히 분할하지 않으면 실수가 누적된다는 것을 직접 체감했다. 한 번에 큰 변경을 도구에 맡기면 의도한 부분과 의도하지 않은 부분이 함께 바뀌었고, 그 차이를 다시 따라가는 시간이 결국 더 걸렸다. 결과적으로 유지보수가 쉽지 않은 코드도 남았다.\n해커톤이 끝난 뒤 후속 작업 중에 AI 도구가 작성한 코드를 다시 정리하는 task 가 별도로 잡힐 정도였다. 빠르게 만든 결과물이 실서비스로 옮겨가는 과정에서 치러야 하는 비용이었다. 이건 다음 단계로 가는 학습이라고 봤다.\n발표와 런칭 전사 발표를 맡았다. 평소 발표 경험이 많지 않았는데, 짧은 시간에 정리해서 말하는 자극이 좋은 경험이었다. 팀이 1등을 했다.\n해커톤 자체보다 이후가 더 길었다. 사내 런칭으로 이어졌고, 실서비스로 다듬는 데 2개월 정도의 후속 개선이 필요했다. Chrome Extension 안정화(TypeScript 포팅 포함), Agent 응답 형식 정합성, 일괄 처리 기능, 사용자 검토용 흐름 — 해커톤 POC 가 실서비스 형태로 다듬어지는 과정이었다.\n여전히 개선할 부분이 남아 있는 단계지만, 해커톤 결과물이 실제로 사내에서 쓰이고 있다 는 점이 가장 큰 성과라고 생각한다.\n출발점의 의미 1등이라는 결과보다 그 안에서 시작된 것이 더 본질적이었다. 평소 같이 일하지 않던 동료들이 짧은 시간에 한 결과물을 향해 모이는 경험은 회사 안에서 흔치 않다. 그리고 이 해커톤이 내게는 Claude Code 를 본격 활용하기 시작한 출발점이었다. 한계도 같이 봤지만, 다음 단계로 가는 재료가 그 자체로 쌓인 시간이었다.\n","permalink":"https://wid-blog.github.io/posts/career/dable/worthy-hackathon-retrospective/","summary":"사내 해커톤 회고. 본인이 제안한 아이디어가 팀과 함께 발전해 1등 + 사내 런칭으로 이어진 과정과, AI 도구를 본격 활용하기 시작한 출발점이 된 경험.","title":"사내 해커톤 1등 회고"},{"content":"JIRA는 코드의 흐름과 짝지어 가는 작업 단위 — 이슈/티켓 — 의 흐름이다. Sprint, ticket lifecycle, Git/GitHub 연동이 한 짝으로 움직이면 매일의 컨텍스트 전환 비용이 줄어든다.\n이슈와 워크플로우 JIRA의 핵심은 두 가지로 요약된다 — 이슈와 그 위의 워크플로우.\n이슈는 작업의 단위다. 각 이슈는 type(Story, Task, Bug, Epic, Subtask), 상태, 담당자, 그리고 자유로운 메타데이터(라벨, 우선순위, 스토리 포인트)를 갖는다. JIRA를 쓴다는 건 결국 이 이슈를 만들고, 묶고, 상태를 전이시키는 일의 누적이다.\n워크플로우는 이슈가 거치는 상태 전이 규칙이다. 가장 단순한 흐름은 다음과 같다.\nflowchart LR Open[Open] --\u003e InProgress[In Progress] InProgress --\u003e InReview[In Review] InReview --\u003e Done[Done] InReview --\u003e InProgress Open --\u003e Closed[Closed] 각 화살표가 한 전이에 해당한다. 팀 프로세스에 따라 상태와 전이 규칙을 커스터마이징하는데, 단순할수록 사람이 따라가기 쉽고, 복잡할수록 자동화로만 다룰 만한 영역이 된다.\n워크플로우 디자인의 핵심은 상태 수를 절제하는 것이다. \u0026ldquo;Pending Review\u0026rdquo;, \u0026ldquo;Ready for QA\u0026rdquo;, \u0026ldquo;QA in Progress\u0026rdquo;, \u0026ldquo;Ready for Release\u0026rdquo; 같이 미세하게 쪼개진 상태가 늘면 매일 상태를 옮기는 데 더 많은 시간을 쓰게 되고, 결국 사람들이 상태를 무시하기 시작한다.\nSprint Sprint는 시간 박스 안에 이슈 묶음을 두는 단위다. 보통 1주에서 2주 길이가 흔하고, 그 시간 안에 묶인 이슈들이 끝나는 것을 목표로 한다.\nSprint의 라이프사이클은 세 단계로 나뉜다.\nSprint Planning: 백로그에서 이슈를 골라 다음 Sprint에 담는다. 팀 capacity와 이슈 estimate를 비교해 무리하지 않는 양으로 잡는다. Sprint 진행: 이슈를 In Progress → In Review → Done으로 전이시킨다. 매일 standup으로 진행 상황과 막힘을 공유한다. Sprint Review/Retro: 끝에서 결과를 확인하고 회고한다. 무엇이 끝났고, 무엇이 carry over 됐고, 다음 Sprint를 어떻게 더 잘할지. Sprint scope creep — 진행 중에 이슈가 계속 추가되는 — 이 가장 흔한 문제다. 한번 Planning한 scope를 지키지 못하면 Sprint가 단순한 시간 분할로만 남고, planning의 의미가 약해진다. 긴급 이슈가 들어와야 한다면 같은 양의 다른 이슈를 빼는 트레이드오프를 명시하는 게 안전판이다.\n이슈 계층 대부분의 팀이 다음 계층을 사용한다.\n계층 의미 예시 Epic 큰 단위의 작업 묶음, 보통 여러 Sprint에 걸침 \u0026ldquo;결제 시스템 v2 마이그레이션\u0026rdquo; Story 사용자 가치 단위. 한 Sprint 안에서 끝남 \u0026ldquo;사용자가 카드 정보를 저장할 수 있다\u0026rdquo; Task 기술적 작업 단위 \u0026ldquo;결제 API 엔드포인트 구현\u0026rdquo; Bug 결함 보고 \u0026ldquo;결제 실패 시 에러 메시지 누락\u0026rdquo; Subtask Story/Task 안의 더 작은 작업 단위 \u0026ldquo;결제 API 단위 테스트 작성\u0026rdquo; 이 계층은 강제가 아니라 컨벤션이라, 팀이 자기 흐름에 맞춰 변형해서 쓴다. Story와 Task의 구분이 모호한 팀은 둘을 합쳐 쓰기도 하고, Subtask 대신 체크리스트로 처리하는 팀도 있다.\n핵심은 Epic과 Story의 분리다. 한 Sprint에 안 들어가는 큰 작업은 Epic으로 묶고 Story 단위로 쪼개야, Sprint planning에서 다룰 수 있는 단위가 된다.\nGit/GitHub 연동 JIRA가 Git/GitHub과 짝지어 가면 이슈와 코드 변경이 자동으로 묶인다. 컨벤션은 단순하다.\nbranch 이름에 이슈 키 포함\nfeature/PROJ-123-add-search PROJ-123이 이슈 키인 것을 JIRA가 인식한다.\ncommit 메시지에 이슈 키\nPROJ-123: add search endpoint 이 commit이 PROJ-123 이슈에 속한다는 것을 JIRA가 자동 연결한다.\nPR title 또는 body에 이슈 키\nPROJ-123: Add search endpoint closes PROJ-123 PR이 열리면 JIRA가 이슈 화면에 PR 링크를 노출한다. PR 상태(open/merged) 변화도 자동으로 동기화된다.\nSmart commits는 commit 메시지에서 이슈 상태를 직접 전이시키는 문법이다. PROJ-123 #close나 PROJ-123 #time 2h처럼 적으면, commit이 머지될 때 이슈가 close되거나 작업 시간이 기록된다.\n이 연동의 가장 큰 가치는 컨텍스트 손실 방지다. 이슈 화면에서 commit·PR을 바로 보고, GitHub PR에서 이슈 링크로 바로 이동할 수 있으면, \u0026ldquo;이 코드가 왜 이렇게 됐는가\u0026quot;를 추적하는 비용이 크게 줄어든다.\nJIRA Automation JIRA에는 이슈 상태 전이를 자동으로 트리거하는 룰 엔진이 있다.\n자주 쓰이는 패턴 몇 가지:\nPR open 시 In Review 전이: 이슈에 연결된 PR이 열리면 이슈를 자동으로 In Review로 옮김 PR merged 시 Done 전이: PR이 main에 머지되면 이슈를 Done으로 Sprint 종료 시 미완료 이슈 carry over: 완료되지 않은 이슈를 다음 Sprint로 자동 이동 Bug 우선순위에 따른 SLA 알림: P1 버그가 24시간 이상 In Progress 상태면 Slack 알림 담당자 자동 할당: 특정 라벨이 붙은 이슈를 자동으로 팀의 on-call에게 할당 자동화는 작게 시작해야 한다. 한꺼번에 복잡한 룰 묶음을 만들면 디버깅이 어려워지고, 잘못된 룰이 이슈를 임의로 옮기는 사고가 일어난다. PR ↔ 상태 동기화 같은 단순한 룰부터 시작해 신뢰가 쌓이면 확장하는 게 안전하다.\n흔한 함정 이슈와 commit이 분리됨 이슈 키 컨벤션을 강제하지 않으면 commit이 이슈와 자동 연결되지 않는다. 결과적으로 JIRA는 PM 도구로만 쓰이고, 코드 흐름은 GitHub에서만 추적되는 분리가 일어난다. branch naming rule을 lint로 강제하거나, PR template에 이슈 키 입력 필드를 두는 식의 안전판이 필요하다.\n워크플로우가 너무 복잡 5개 이상의 상태 + 다중 분기 + 승인 단계가 들어간 워크플로우는 사람이 따라가지 못한다. 결국 사람들은 \u0026ldquo;Done이 되어야 할 이슈\u0026quot;를 그냥 Done으로 옮기고 중간 상태를 건너뛰는 식으로 우회한다. 단순한 워크플로우 + 자동화로 보강하는 쪽이 거의 항상 낫다.\nSprint scope creep Planning 후에도 새 이슈가 계속 들어오면 Sprint가 시간 분할 의미만 남는다. 긴급 이슈가 들어와야 한다면 같은 양을 빼는 트레이드오프를 강제하는 룰이 안전판이다. PM이나 팀 리드가 그 결정을 진다.\n\u0026ldquo;이슈를 위한 이슈\u0026rdquo; 작은 작업까지 이슈를 만들고 메타데이터를 채우는 데 시간을 쓰면, 이슈 시스템 자체가 일을 만든다. 어떤 작업이 이슈가 될 가치가 있는가에 대한 팀 합의가 필요하다 — 보통 \u0026ldquo;다른 사람과 동기화가 필요한 작업\u0026quot;이 좋은 기준이다.\n시리즈 정리 이 시리즈는 개발자가 매일 쓰는 협업 도구 네 요소를 다뤘다.\nGit (1편) — 변경의 그래프. commit / branch / merge·rebase GitHub PR (2편) — 그래프 위 협업 레이어. PR 단위 설계 + Code Review GitHub Actions (3편) — 자동 검증과 배포. workflow / job / step JIRA (4편) — 그래프와 짝지어 가는 작업 단위. 이슈 + Sprint + Git/GitHub 연동 네 도구는 서로 다른 추상을 갖지만 짝지어 갈 때 가장 빛난다. 이슈 키가 branch·commit·PR을 따라 흐르고, PR이 자동 검증을 통과하고, 머지가 이슈 상태를 자동 전이시키는 흐름. 그 자동화가 매일의 컨텍스트 전환 비용을 줄이고, 팀의 속도가 그 위에서 만들어진다.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/jira-sprint-workflow/","summary":"JIRA의 이슈와 워크플로우를 작업 단위의 그래프로 보고, Sprint 라이프사이클·이슈 계층·Git/GitHub 연동 패턴·자동화 흐름을 정리한다.","title":"JIRA Sprint 워크플로우와 Git/GitHub 연동"},{"content":"GitHub Actions는 저장소 이벤트를 트리거로 빌드·테스트·배포 자동화를 실행하는 이벤트 기반 자동화 엔진이다. 자동화 로직 자체가 저장소 안에 코드로 들어가 있다.\nWorkflow / Job / Step Actions의 모든 자동화는 세 단위로 정리된다.\nflowchart TB Event[(\"저장소 이벤트(push / pull_request / schedule ...)\")] Event --\u003e WF[\"workflow(.github/workflows/*.yml)\"] WF --\u003e J1[\"job A(별개 runner)\"] WF --\u003e J2[\"job B(별개 runner)\"] J1 --\u003e S1[\"step 1\"] J1 --\u003e S2[\"step 2\"] J1 --\u003e S3[\"step 3\"] J2 --\u003e S4[\"step 1\"] J2 --\u003e S5[\"step 2\"] workflow는 .github/workflows/ 안의 YAML 파일 하나다. 어떤 이벤트가 trigger인지, 그 이벤트가 발생하면 어떤 job들이 실행되는지를 선언한다.\njob은 workflow 안에서 실행되는 단위다. job 단위로 별개 runner(가상 머신)에서 실행된다. 같은 workflow 안의 job들은 기본적으로 병렬 실행되고, needs 키워드로 순서 의존을 선언하면 순차로 실행된다.\nstep은 job 안의 한 줄이다. shell 명령(run)이거나, 재사용 가능한 action 호출(uses)이다. 같은 job의 step들은 같은 runner에서 순차로 실행되며 워크스페이스를 공유한다.\n이 셋을 분명히 잡아 두면 복잡한 파이프라인도 안정적으로 구성된다. 자주 헷갈리는 지점은 \u0026ldquo;두 step이 다른 job에 있으면 워크스페이스가 공유되지 않는다\u0026quot;는 점이다. 같은 환경이 필요한 작업은 한 job에 묶거나, artifact upload/download로 데이터를 넘겨야 한다.\ntrigger 이벤트 Actions가 시작되는 trigger는 저장소에서 일어나는 거의 모든 이벤트다. 자주 쓰이는 것만 정리하면:\n이벤트 발생 시점 주 사용처 push 어떤 branch든 push main 푸시 시 배포 pull_request PR open / sync / close PR CI (test / lint / build) schedule cron 표현식 정기 작업 (의존성 체크, 정리) workflow_dispatch 수동 실행 (UI/CLI) 배포, 일회성 작업 release GitHub Release 생성 패키지 publish, changelog repository_dispatch 외부 시스템에서 webhook 외부 트리거 통합 이 중 PR CI는 pull_request trigger에 lint/test/build job을 묶는 가장 흔한 패턴이다. 이 workflow의 status check이 branch protection의 머지 게이트로 묶인다.\nrunner step이 실제로 도는 장소가 runner다. 두 종류로 나뉜다.\nGitHub-hosted runner는 GitHub이 제공하는 가상 머신이다. ubuntu / windows / macos를 골라 쓰고, 매번 깨끗한 환경에서 시작한다. 셋업 비용이 거의 없고 보안 격리가 잘 되어 있어 대부분의 프로젝트가 여기서 시작한다.\nself-hosted runner는 자체 인프라에 runner 데몬을 설치한 형태다. 큰 데이터셋, 특수 하드웨어(GPU), 사내 네트워크 접근이 필요한 경우에 쓰인다. 대신 보안 격리·유지보수·OS 패치까지 직접 책임져야 한다.\n매트릭스 빌드는 같은 step을 여러 환경에서 동시 실행하는 도구다. 예를 들어 [ubuntu, macos] x [node-18, node-20, node-22] 조합이면 6개 job이 병렬로 실행된다. 라이브러리·CLI 도구가 다중 환경에서 동작하는지 검증하는 가장 흔한 패턴이다.\naction 재사용 step에서 uses: actions/checkout@v4 같은 표현은 marketplace나 다른 저장소의 action을 끌어다 쓴다는 뜻이다. 자주 쓰는 action은 거의 정해져 있다.\nactions/checkout — 저장소 코드 가져오기 actions/setup-node, actions/setup-python, actions/setup-go — 런타임 설치 actions/cache — 의존성 캐시 actions/upload-artifact, actions/download-artifact — job 간 파일 전달 자체 step 묶음을 재사용하고 싶다면 두 가지 옵션이 있다. composite action은 action.yml로 step 묶음을 패키징한 형태이고, reusable workflow는 workflow 자체를 다른 workflow에서 호출 가능하게 만든 형태다. 단순 step 묶음은 composite, 큰 파이프라인 단위 재사용은 reusable workflow가 자연스럽다.\n버전 핀은 보안과 안정성에 직결된다. @v4 같은 메이저 태그는 편하지만, 그 태그가 가리키는 SHA가 바뀔 수 있어 공급망 공격의 표적이 된다. 보안에 민감한 환경은 @\u0026lt;full-sha\u0026gt; 형태로 핀해 두는 게 안전하다.\nsecret과 권한 Actions에서 외부 서비스 인증이 필요한 경우 secret을 쓴다.\nsecrets.GITHUB_TOKEN은 workflow가 시작될 때 자동으로 발급되는 토큰이다. 그 저장소에 대한 기본 권한이 부여되며, push·PR comment·issue 코멘트 같은 동작에 쓰인다.\n사용자 정의 secret은 저장소·환경·조직 단위로 등록할 수 있다. 환경 단위 secret은 deployment job에 환경 보호 규칙(승인 필요, 시간 제한)을 함께 걸 수 있어 production 배포에 자주 쓰인다.\npermissions 블록은 토큰의 scope를 좁히는 도구다. 기본값은 저장소에 대해 폭넓은 권한이지만, workflow나 job 단위로 contents: read 같은 최소 권한만 명시하면 token이 탈취되더라도 영향이 제한된다. CI workflow는 read만 있으면 충분한 경우가 많아 명시해 두는 게 안전 마진이다.\n자주 쓰는 패턴 운영 중 가장 흔한 흐름은 네 가지로 압축된다.\nPR CI: pull_request trigger 에 lint / test / build job. branch protection 의 required check 으로 사용.\nmain push 배포: push to main trigger에 build → deploy job. staging 배포는 자동, production은 환경 보호 규칙 + 수동 승인을 거는 형태가 일반적이다.\ntag push 시 release: push 중 tags: ['v*'] 필터로 태그 푸시만 잡아 release artifact 빌드 및 publish.\n매트릭스 빌드: 라이브러리/CLI가 다중 환경에서 동작하는지 검증.\n흔한 함정 secret 노출 echo $SECRET 같은 step이 들어가면 그 값이 로그에 그대로 찍힌다. Actions는 secret 패턴을 마스킹하려 시도하지만, base64 인코딩이나 부분 노출은 마스킹을 우회한다. secret은 절대 출력하지 않는 습관이 가장 단순한 안전판이다.\naction 버전 무핀 uses: some-org/action@main 처럼 브랜치를 가리키면, 그 브랜치가 언제든 바뀔 수 있다. 공격자가 인기 action을 인수해 main 브랜치에 악성 코드를 주입한 사고 사례가 실제로 있었다. 메이저 태그 또는 SHA로 핀하는 게 표준이다.\ncache 누락 매번 의존성을 새로 설치하면 PR CI 시간이 분 단위로 늘어난다. actions/cache로 npm/pip/go module 같은 의존성 디렉토리를 캐시하면 두 번째 실행부터는 거의 즉시 끝난다. cache key는 lock 파일의 hash를 포함해야 의존성 변경 시 자동으로 무효화된다.\n무거운 workflow가 큐 대기 self-hosted runner를 한 대만 두면 동시 실행 요청이 쌓여 큐 대기가 길어진다. concurrency 그룹을 잡아 같은 PR/branch의 이전 실행을 자동 취소하거나, runner를 늘리는 게 일반적인 해결이다.\n정리 GitHub Actions의 자동화는 3단 추상에 얹힌다.\nworkflow / job / step: 한 workflow는 여러 job, 한 job은 여러 step trigger: 저장소 이벤트가 워크플로우 시작점 runner: GitHub-hosted가 기본, self-hosted는 특수 환경 secret + permissions: 최소 권한 + 핀된 action 버전이 보안 안전판 세 단 추상(workflow / job / step) + 이벤트 trigger + runner 조합이 GitHub Actions의 자동화 엔진을 만든다. secret과 permissions의 최소권한이 그 엔진을 안전하게 운용하는 테두리다.\n다음 편은 코드 변경의 짝이 되는 작업 단위 — JIRA Sprint 워크플로우와 Git/GitHub 연동이다.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/github-actions-fundamentals/","summary":"GitHub Actions를 저장소 이벤트 기반 자동화 엔진으로 보고, workflow / job / step 3단 추상과 trigger·runner·secret 운영을 정리한다.","title":"GitHub Actions 기본기 — workflow, job, step"},{"content":"GitHub PR은 Git의 변경 그래프에 협업 레이어를 더한 단위다. 단순한 머지 버튼 화면이 아니라, 변경 가시화 + 리뷰 의사결정 + CI 게이트를 하나로 모으는 단위다.\nPull Request PR은 source branch와 target branch 사이의 diff 묶음이다. 그 diff 묶음에 GitHub이 협업에 필요한 메타데이터 — 리뷰 시스템, conversation thread, status check, branch protection — 를 결합한 형태가 PR이다.\nflowchart LR Open[\"PR open(diff 묶음)\"] --\u003e Review[\"리뷰(comment / approve / request changes)\"] Review --\u003e Checks[\"CI status check(test / build / lint)\"] Checks --\u003e Merge[\"merge\"] Merge --\u003e Close[\"close\"] 이 네 단계가 평소엔 자연스럽게 흐르지만, 단위 설계가 잘못되면 어느 한 곳에서 막힌다. 가장 흔한 막힘이 리뷰 단계다.\nPR 단위 설계 작은 PR이 빠른 리뷰를 만든다. 200~400 LOC 수준이 일반적인 권장선인데, 분량 자체보다 한 PR에 한 가지 의도라는 원칙이 더 본질적이다. 리팩터링과 새 기능을 섞으면 리뷰어가 둘을 분리해서 평가해야 하고, 결과적으로 어느 쪽도 충분히 보지 못한다.\n큰 변경이라도 단계별로 쪼개면 단일 의도를 유지할 수 있다. 인터페이스 추가 → 호출부 마이그레이션 → 옛 인터페이스 제거 같은 분리가 그렇다. 의도 단위로 쪼개야 PR도 작아지고 리뷰도 가벼워진다.\ndraft PR은 미완성 변경에 대한 조기 피드백을 받는 도구다. 머지 가능 상태가 아닌 채로 PR을 열고, 코드 방향에 대한 의견을 받은 뒤 본 작업을 진행한다. 큰 변경에 들어가기 전 확인 단계로 유용하다.\nCode Review 사이클 리뷰는 코드의 옳고 그름을 평가하는 자리만은 아니다. 의도 검증, 지식 공유, 머지 가능성 판단이 함께 일어나는 단계다.\n작성자는 리뷰 요청 전에 자기 PR을 한 번 본다. 자기가 만든 변경을 처음 보는 사람의 눈으로 다시 읽어 보면, 머지 직전에 자주 보이는 작은 실수들 — 디버깅용 코드, 무관한 변경, 빠진 테스트 — 을 미리 잡아낼 수 있다.\n리뷰어는 네 가지 액션 중 하나를 선택한다.\ncomment: 정보 공유, 질문, 제안 suggestion: 직접 적용 가능한 작은 코드 변경 제안 request changes: 머지 전 반드시 반영해야 할 변경 요구 approve: 머지해도 좋다는 동의 여기서 의사결정의 핵심은 blocker와 nit의 구분이다. 코드의 의도·정확성·안전성에 영향을 주는 것은 blocker로 짚고, 스타일이나 사소한 선호 차이는 nit으로 분명히 표시한다. 모든 코멘트가 동등한 무게를 가진 것처럼 다뤄지면 리뷰가 무거워지고, blocker가 묻혀 진짜 위험이 머지된다.\n리뷰어가 코멘트를 남기면 작성자는 답변하거나 코드를 수정한 뒤 thread를 resolve한다. resolve가 모이면 머지 가능 상태가 만들어진다.\n머지 전략 PR을 main에 합칠 때 GitHub은 세 가지 옵션을 준다.\n전략 그래프 결과 특성 merge commit 분기·합류 모양 보존 PR 단위가 그래프에 그대로 남음 squash merge linear, PR 하나 = commit 하나 history 단순. PR 내부 commit 사라짐 rebase merge linear, PR 내부 commit 그대로 linear history. PR 단위 흐릿함 flowchart TB subgraph Source [\"PR 내부\"] s1((c1)) --\u003e s2((c2)) --\u003e s3((c3)) end subgraph MergeCommit [\"merge commit\"] m1((m1)) --\u003e m2((m2)) --\u003e mc((merge)) m1 --\u003e mc1((c1)) --\u003e mc2((c2)) --\u003e mc3((c3)) --\u003e mc end subgraph Squash [\"squash merge\"] sq1((m1)) --\u003e sq2((m2)) --\u003e sq3((squashed)) end subgraph Rebase [\"rebase merge\"] r1((m1)) --\u003e r2((m2)) --\u003e rc1((c1')) --\u003e rc2((c2')) --\u003e rc3((c3')) end 팀 컨벤션의 핵심은 main의 history 모양에 대한 선택이다. 한 PR이 한 변경 단위로 깔끔하게 보이길 원하면 squash merge가 단순하다. PR 내부 commit이 의미 있는 단계라면(예: refactor → feature → cleanup) rebase merge가 그 흐름을 보존한다. 머지 흐름 자체가 협업의 흔적으로 가치 있다고 보면 merge commit이 자연스럽다.\n선택 자체보다 일관성이 더 중요하다. main에 squash와 merge commit이 섞이면 그래프가 부분만 단순하고 부분만 분기 모양인 어색한 형태가 된다.\nCI 게이트와 branch protection PR이 단순히 사람의 리뷰만으로 머지되면 회귀가 자주 새어 나간다. status check이 자동 검증의 자리를 차지한다.\nGitHub은 PR마다 등록된 status check(test, build, lint)의 결과를 본다. branch protection rule로 main에 머지하려면 특정 check이 모두 성공해야 한다고 선언해두면, 한 번 깨진 PR이 main으로 들어오는 일이 막힌다.\nrequired reviewer 수, codeowner 자동 할당, 머지 전 최신 main으로의 강제 update 같은 옵션도 함께 잡아두면 PR 단계에서 빠뜨리는 검증이 줄어든다.\n흔한 PR 함정 너무 큰 PR 수천 LOC PR은 리뷰어가 형식적으로 끝낸다. \u0026ldquo;전체적으로 OK\u0026quot;라는 코멘트만 남고 진짜 위험은 그대로 머지된다. 큰 작업은 의도 단위로 쪼개야 리뷰의 실질이 살아난다.\nbikeshedding 코드의 본질과 무관한 사소한 선호(들여쓰기, 변수 이름의 어감)에 시간을 쓰는 패턴이다. 자동화 가능한 항목(linter, formatter)은 도구로 강제해 리뷰에서 빼는 게 가장 깨끗한 해결이다.\n\u0026ldquo;LGTM\u0026rdquo; 자동 승인 매번 자동으로 approve가 찍히는 패턴이 굳어지면 리뷰가 형식만 남는다. 체크리스트(테스트 커버리지, 의도 변경 여부, 보안 영향)를 두거나 large PR은 다중 리뷰어를 강제하는 식으로 자동화에 의존하지 않는 안전판이 필요하다.\n머지 충돌 누적 PR이 오래 열려 있으면 main과의 차이가 커지고, 머지할 때 충돌이 커진다. 짧은 PR + 빠른 리뷰가 가장 단순한 해결책이고, 그게 어렵다면 PR을 main과 자주 동기화해야 한다.\n정리 GitHub PR은 Git 그래프 위에 협업의 단위를 더한 추상이다.\nPR 단위: 한 가지 의도, 작게 쪼개기 Code Review: blocker와 nit 구분, 작성자가 먼저 자기 PR 보기 머지 전략: 팀 컨벤션 안에서 일관성 유지 CI 게이트: branch protection으로 자동 검증을 머지 조건에 묶기 결국 PR의 가치는 단위 설계(의도 하나), Code Review(blocker / nit 구분), 머지 전략(일관성), CI 게이트 네 고리의 합으로 결정된다. 어느 하나라도 느슨해지면 PR의 마지막 단계에서 막힌다.\n다음 편은 그 자동 검증 자체 — GitHub Actions로 PR을 어떻게 자동으로 빌드·테스트·배포하는가다.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/github-pr-and-code-review/","summary":"GitHub PR을 Git 변경 그래프 위에 협업 레이어를 더한 추상으로 보고, Code Review 사이클·PR 단위 설계·머지 전략을 정리한다.","title":"GitHub PR과 Code Review 사이클"},{"content":"Git은 매일 쓰지만 워크플로우 결정의 근거는 자주 흐릿하다. \u0026ldquo;rebase가 머지보다 commit history를 깨끗하게 만든다\u0026quot;는 말은 들어봤어도, 그게 정확히 무슨 뜻인지 언제 쓰면 안 되는지는 한 번 정리하지 않으면 흐릿하게 남는다.\nGitHub의 PR과 Code Review는 후속 글로 분리한다.\nCommit Git의 모든 것은 commit이다. 한 commit은 그래프의 한 노드이고, 직전 commit을 parent로 가리켜 commit history를 형성한다. branch는 그 노드들 위에 그어지는 이름표일 뿐이고, merge는 두 흐름이 만나는 지점이다.\nflowchart LR A((A)) --\u003e B((B)) --\u003e C((C)) C --\u003e D((D)) C --\u003e E((E)) D --\u003e F((F)) E --\u003e F 이 그래프 모양이 이후 디버깅의 비용을 결정한다. git bisect로 회귀를 찾을 때, git blame으로 의도를 추적할 때, 깔끔한 commit 단위는 검색 공간을 줄여준다.\ncommit hygiene 좋은 commit은 한 가지 의도를 갖는다. 새 기능 추가와 무관한 리팩터링을 같은 commit에 섞으면, 나중에 둘 중 하나만 되돌리고 싶을 때 어쩔 수 없이 손으로 분리해야 한다.\n메시지는 그 의도를 설명한다. 제목은 50자 이내로, 본문이 필요하면 빈 줄을 두고 why를 적는다. what은 diff가 이미 보여주므로, 메시지는 그 변경이 왜 필요했는지를 남기는 게 가치 있다.\nConventional Commits 같은 컨벤션은 feat:, fix:, chore: 접두사로 메시지의 종류를 분류한다. 도구가 자동으로 changelog를 만들거나 semantic version을 결정하는 흐름과 잘 맞는다.\nBranch branch는 commit을 가리키는 이름표다. 새 commit을 만들면 현재 branch가 가리키는 곳이 새 commit으로 한 칸 이동한다. git checkout은 HEAD를 다른 branch로 옮기는 동작이고, 그뿐이다. 디스크에 별도 디렉토리가 만들어지거나 무거운 작업이 일어나는 게 아니다.\n이 단순함이 branch strategy를 가능하게 한다.\nbranching strategy 세 가지 패턴이 자주 비교된다.\n전략 흐름 적합 상황 trunk-based 짧은 feature branch (수 시간~수 일), 자주 main 머지 CI/CD 성숙, 빠른 배포 GitHub Flow feature branch → PR → main 머지 → 즉시 배포 웹 서비스, continuous deployment GitFlow main / develop / feature / release / hotfix 다층 분리 릴리스 주기가 길고 버전 관리가 엄격한 환경 trunk-based가 단순하고 통합 빈도가 높아 머지 충돌이 적은 반면, GitFlow는 릴리스 통제가 강하지만 branch가 많고 운영 비용이 크다. GitHub Flow는 그 중간으로, 대부분의 SaaS 워크플로우에 잘 맞는다.\n선택의 핵심은 \u0026ldquo;main에 얼마나 자주 통합할 수 있는가\u0026quot;다. 통합이 잦을수록 그래프가 단순해지고 충돌이 작아진다.\nmerge vs rebase 두 흐름을 합칠 때 Git은 두 가지 옵션을 준다.\nflowchart TB subgraph Before [\"분기 상태\"] m1((m1)) --\u003e m2((m2)) --\u003e m3((m3)) m1 --\u003e f1((f1)) --\u003e f2((f2)) end subgraph Merge [\"merge 결과\"] mm1((m1)) --\u003e mm2((m2)) --\u003e mm3((m3)) --\u003e mc((merge)) mm1 --\u003e mf1((f1)) --\u003e mf2((f2)) --\u003e mc end subgraph Rebase [\"rebase 결과\"] rm1((m1)) --\u003e rm2((m2)) --\u003e rm3((m3)) --\u003e rf1((f1')) --\u003e rf2((f2')) end merge는 두 commit history를 합치는 새 commit(merge commit)을 만든다. 그래프에 분기와 합류가 그대로 남아 \u0026ldquo;언제 누가 어디서 합쳐졌는지\u0026quot;가 보인다.\nrebase는 한쪽 commit들을 다른 쪽 끝에 다시 쌓는다. 그래프가 linear해지지만 commit hash는 모두 새로 만들어진다(f1 → f1'). 본질적으로 다른 commit이라는 뜻이다.\n선택의 기준은 분명하다. 공유된 commit을 rebase하지 말 것. 동료가 이미 그 commit을 자기 branch에 갖고 있다면, rebase 후 force push는 그 동료의 commit history를 깨뜨린다. 내 로컬 branch나 아직 공유 전인 feature branch라면 rebase로 commit history를 정리해도 안전하다.\nmain 자체의 정책은 팀 컨벤션이다. linear history를 선호하면 rebase 또는 squash, 머지 흐름이 보존되길 원하면 merge commit. 정답은 없고, 한 번 정하면 일관되게 유지하는 게 더 중요하다.\n흔한 함정 git push --force 가 동료의 commit을 덮어쓴다 shared branch에 force push하면 그 시점 이후의 동료 commit이 사라질 수 있다. --force-with-lease를 쓰면 remote가 내가 본 상태와 다를 때 거부되므로 안전판이 된다.\nrebase 중 매 commit마다 충돌 rebase는 commit을 하나씩 다시 쌓는 방식이라, 충돌도 commit 단위로 발생한다. 한 번에 다 해결하고 싶다면 일단 merge로 처리하거나, 또는 rebase가 끝난 뒤 git rebase -i로 commit을 squash해 다시 다듬는 흐름이 효율적이다.\ngit reset --hard로 잃은 작업 reset --hard는 working tree와 index를 함께 초기화한다. commit된 작업은 reflog에 남아 있어 git reflog로 SHA를 찾고 git reset --hard \u0026lt;sha\u0026gt;로 복구할 수 있다. commit조차 안 한 변경은 복구가 어려우므로, 위험한 작업 전에 임시 commit을 만들어두는 습관이 안전판이 된다.\nmerge commit이 그물망처럼 쌓인다 작은 feature branch가 자주 main에 머지되면 그래프가 깔끔하지만, long-lived branch끼리 merge가 반복되면 그물망 모양이 된다. 이때는 squash merge나 rebase merge로 정리하거나, 애초에 long-lived branch 수를 줄이는 쪽으로 워크플로우를 바꾸는 게 근본 해결이다.\n정리 Git 워크플로우의 결정들은 결국 그래프 모양에 대한 선택이다.\ncommit: 한 가지 의도, 메시지에 why branch: 단지 commit 포인터, 통합 빈도가 strategy를 결정 merge vs rebase: 공유된 commit은 건드리지 않기, 그 외에는 팀 컨벤션 결국 commit hygiene, branch strategy, merge·rebase 선택이 모여 그래프의 모양이 된다. 그 모양이 단순할수록 이후 디버깅과 협업의 비용이 작아진다.\n다음 편은 이 그래프를 GitHub PR로 어떻게 협업하는가 — Code Review 사이클과 머지 전략이다.\n","permalink":"https://wid-blog.github.io/posts/tech/devenv/git-workflow-basics/","summary":"Git을 명령어 모음이 아닌 변경의 그래프로 보고, commit hygiene·branch strategy·merge vs rebase 결정이 그래프 모양에 대한 선택임을 정리한다.","title":"Git 워크플로우 기본기 — commit, branch, merge/rebase"},{"content":"VPC 안에서 트래픽이 인스턴스에 도달하기 전에 두 단계의 방어선을 통과한다 — Subnet 경계의 NACL과 인스턴스 경계의 Security Group. 같은 \u0026ldquo;방화벽 규칙\u0026quot;처럼 보이지만 둘은 적용 단위, 평가 방식, 상태성이 모두 다르다. 차이를 이해하지 못한 채 운영하면 \u0026ldquo;outbound는 허용했는데 응답이 막힌다\u0026rdquo; 같은 함정에 자주 빠진다.\nSecurity Group Security Group(SG)은 인스턴스 또는 ENI(Elastic Network Interface) 단위에 부착되는 규칙 묶음이다. 한 인스턴스에 여러 SG를 부착할 수 있고, 그 경우 규칙은 합집합으로 적용된다.\n가장 큰 특징은 stateful이라는 점이다. 한 번 허용된 연결의 return traffic은 별도 규칙 없이도 자동으로 허용된다. inbound로 들어온 요청에 대한 응답을 outbound 규칙에 별도로 명시할 필요가 없다는 뜻이다.\nSG는 allow-only다. deny 규칙 자체가 없고, 명시적으로 허용한 트래픽만 통과한다. 기본값은 outbound 전체 허용, inbound 전체 차단. 새 SG를 만들면 우선 외부와의 모든 inbound가 막혀 있고, 필요한 포트와 출발지를 하나씩 추가해 가는 모델이다.\nSG의 source/destination에는 IP CIDR 외에도 다른 SG의 ID를 지정할 수 있다. 같은 VPC 안의 자원끼리 \u0026ldquo;이 SG가 부착된 자원만 허용\u0026rdquo; 같은 의미적 규칙을 직접 표현할 수 있어, 인스턴스 IP가 바뀌어도 규칙이 깨지지 않는다.\nNACL NACL(Network ACL)은 Subnet 단위에 부착된다. 한 Subnet에는 하나의 NACL이 붙고, 그 Subnet에 속한 모든 자원이 같은 NACL의 영향을 받는다.\nNACL은 stateless다. inbound와 outbound 규칙이 완전히 분리돼 있어, 한쪽에서 허용된 트래픽의 응답이라도 반대쪽에서 명시적으로 허용되지 않으면 차단된다. SG에서는 자동으로 허용되던 return traffic이 NACL에서는 별도 규칙을 요구한다.\nNACL은 allow와 deny 모두 표현할 수 있다. 규칙에는 번호가 매겨지고, 번호 오름차순으로 평가하다가 첫 매칭에서 결정이 난다. 명시적으로 차단해야 할 트래픽이 있을 때 NACL이 필요한 이유다.\n기본값은 두 가지로 갈린다. VPC 생성 시 자동으로 만들어지는 default NACL은 모두 허용, 사용자가 직접 만든 custom NACL은 모두 차단으로 시작한다.\n평가 순서 외부에서 들어오는 트래픽과 내부에서 나가는 트래픽이 두 계층을 거치는 순서는 다음과 같다.\nflowchart LR Ext[\"외부\"] --\u003e|\"inbound\"| NACL_in[\"NACL(Subnet inbound)\"] NACL_in --\u003e SG_in[\"SG(Instance inbound)\"] SG_in --\u003e VM[\"VM\"] VM --\u003e SG_out[\"SG(Instance outbound)\"] SG_out --\u003e NACL_out[\"NACL(Subnet outbound)\"] NACL_out --\u003e|\"outbound\"| Ext2[\"외부\"] 외부에서 시작된 요청은 Subnet 경계의 NACL inbound를 먼저 통과한 뒤, 인스턴스 경계의 SG inbound에서 다시 평가된다. 응답은 반대 순서 — SG outbound를 거쳐 NACL outbound를 통과한다. 두 계층을 모두 통과해야 트래픽이 끝까지 도달한다.\nSG는 stateful이라 인스턴스 입장에서 return traffic이 자동 허용되지만, NACL은 stateless라 응답 트래픽도 별도 규칙으로 명시해야 한다. 흔한 트러블슈팅 함정이 여기서 나온다.\n함정 시나리오 outbound는 허용했는데 응답이 막힌다 NACL의 outbound는 허용했지만, inbound 규칙에서 응답 트래픽의 destination port를 허용하지 않은 경우다. 응답은 일반적으로 ephemeral port(1024-65535) 범위로 돌아오므로, NACL inbound 규칙에 그 범위가 포함돼 있지 않으면 응답이 차단된다.\nSG만 쓰는 환경에서는 stateful이라 이 문제가 보이지 않다가, NACL을 추가로 도입한 순간부터 새로 등장한다. NACL에서 양방향 ephemeral port 범위를 함께 허용해야 한다.\nSG 규칙이 적용되지 않는 것처럼 보인다 규칙을 추가했는데도 트래픽이 막혀 있다면, 같은 인스턴스에 부착된 다른 SG가 충돌하는 게 아니라 NACL에서 차단되고 있을 가능성이 크다. SG는 인스턴스 단위라 하나의 SG가 차단해도 다른 SG가 허용하면 통과되는 데 비해, NACL은 Subnet 전체에 걸리는 단일 규칙이다.\ndefault NACL과 custom NACL의 기본값 차이 default NACL은 모두 허용으로 시작해 명시적으로 차단할 트래픽만 추가하는 형태고, custom NACL은 모두 차단으로 시작해 허용할 트래픽을 추가하는 형태다. 이 차이를 모르고 custom NACL로 교체하면 모든 트래픽이 차단되는 사고가 난다.\n비교 표 두 메커니즘을 한 표에 놓으면 차이가 분명해진다.\n항목 Security Group NACL 적용 단위 인스턴스 / ENI Subnet 상태성 stateful (응답 자동 허용) stateless (응답도 별도 규칙) 규칙 종류 allow only allow + deny 평가 방식 모든 규칙 합집합 평가 번호 순서, 첫 매칭으로 결정 기본값 inbound 차단, outbound 허용 default는 모두 허용 / custom은 모두 차단 Source 표현 CIDR + 다른 SG의 ID 가능 CIDR만 벤더 명칭 매핑 개념 AWS GCP Azure Alibaba Cloud 인스턴스 단위 stateful Security Group Firewall Rule (대상 태그) NSG (NIC 적용) Security Group Subnet 단위 stateless Network ACL (해당 직접 모델 없음) NSG (Subnet 적용) Network ACL GCP는 SG/NACL을 별도로 구분하지 않고 Firewall Rules라는 통합 모델로 다룬다. 인스턴스 태그·네트워크 태그·서비스 계정 등으로 적용 대상을 결정하는 방식이라, AWS·Azure·Alibaba와는 사고 모형이 한 단계 다르다.\nAzure의 NSG는 같은 리소스가 NIC 또는 Subnet 어느 쪽에든 적용될 수 있어, AWS의 SG/NACL을 한 리소스로 합친 구조에 가깝다.\n시리즈 마무리 VPC fundamentals 시리즈가 4편으로 마무리된다. 네 요소가 합쳐져 VPC라는 한 추상이 만들어진다.\n격리 (1편) — IP 공간, Subnet, Tenancy로 사설 네트워크 경계 시뮬레이션 라우팅 (2편) — Route Table이 결정하는 트래픽 경로, IGW/NAT 두 종류의 출입구 연결성 (3편) — VPC Peering, Transit Gateway, VPN, PrivateLink 네 메커니즘으로 외부 연결 보안 (4편) — Security Group과 NACL이 만드는 두 계층의 방어선 벤더 명칭은 다르지만 추상은 거의 같다. 한 벤더의 멘탈 모델을 익혀두면 다른 벤더로 옮겨갈 때 방향을 잃지 않는다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/vpc-security-fundamentals/","summary":"Security Group(stateful, 인스턴스 단위)과 NACL(stateless, Subnet 단위)이 어떻게 서로 다른 계층의 방어선을 만드는지, 그리고 흔한 함정 시나리오를 정리한다.","title":"Security Group 과 NACL"},{"content":"VPC 내부의 라우팅이 해결되면 다음 질문은 VPC 밖과의 연결이다. 다른 VPC, 온프레미스 데이터센터, 외부 SaaS 서비스와는 어떻게 연결되는가.\nVPC Peering, Transit Gateway, Site-to-Site VPN, PrivateLink 네 메커니즘이 각각 다른 토폴로지·비용·운영 트레이드오프를 가지며, 어느 것을 선택하느냐가 시스템 전체의 구조를 결정한다.\nVPC Peering 가장 단순한 옵션이 VPC Peering이다. 두 VPC를 직접 연결해 서로의 사설 IP 공간에 도달할 수 있게 한다.\n같은 리전·같은 계정뿐 아니라 다른 리전, 다른 계정의 VPC도 연결 가능하다. Peering은 토폴로지가 mesh인 1:1 연결이라, 세 VPC를 모두 연결하려면 세 개의 Peering(A-B, B-C, A-C)이 필요하다.\n한계는 transitive 라우팅이 지원되지 않는다는 점이다. A-B와 B-C를 Peering으로 연결해도 A에서 C로는 직접 갈 수 없다. 양쪽이 직접 Peering 관계여야 한다. VPC 수 N이 늘면 연결 수가 N(N-1)/2로 폭증하므로, 소수의 VPC를 묶을 때만 적합하다.\nTransit Gateway VPC 수가 늘어나면 Peering의 mesh 구조는 빠르게 부담이 된다. Transit Gateway가 이 한계를 해소한다.\nflowchart LR subgraph Peering [\"Peering: mesh\"] VA[\"VPC A\"] --- VB[\"VPC B\"] VB --- VC[\"VPC C\"] VA --- VC VC --- VD[\"VPC D\"] VA --- VD VB --- VD end subgraph Transit [\"Transit Gateway: hub-spoke\"] TGW((\"TGW\")) TVA[\"VPC A\"] --- TGW TVB[\"VPC B\"] --- TGW TVC[\"VPC C\"] --- TGW TVD[\"VPC D\"] --- TGW end Transit Gateway는 중앙 허브 역할을 한다. 다수의 VPC가 spoke로 연결되고, 허브를 통해 모든 spoke 간 통신이 transitive하게 이뤄진다. VPC 수가 늘어도 연결 수가 선형으로만 증가한다.\n비용 모델은 Peering과 다르다. 시간당 부착 비용 + 트래픽당 처리 비용이 붙어, 작은 규모에서는 Peering보다 비싸지만 N이 커질수록 효율이 역전된다. 라우팅 도메인을 여러 개로 분리하는 옵션이 있어 멀티 테넌트 환경에서 격리를 유지하기도 좋다.\nSite-to-Site VPN VPC와 온프레미스 데이터센터를 묶어야 할 때 가장 먼저 떠오르는 옵션이다. 공용 인터넷 위에 IPSec 터널을 세워 두 네트워크를 논리적으로 연결한다.\n정적 라우팅과 BGP 동적 라우팅 둘 다 옵션으로 제공된다. 다만 공용 인터넷이 매개라 대역폭과 지연이 변동성을 가지며, 미션 크리티컬 트래픽에는 부담이 될 수 있다. 안정적 연결이 필요하면 Direct Connect나 Cloud Interconnect 같은 전용선 옵션이 별도로 있다.\nPrivateLink 지금까지 셋이 IP 라우팅 기반이었다면, PrivateLink는 결이 다르다. IP/CIDR 단위로 두 네트워크를 잇는 게 아니라 서비스 단위로 endpoint를 노출한다.\n서비스 제공자 측 VPC에 endpoint를 만들면, 소비자 측 VPC는 그 endpoint를 ENI(Elastic Network Interface) 또는 동등 자원으로 자기 VPC 안에서 인식한다. 두 VPC의 IP 공간이 어떻게 분포해 있든 무관하다 — endpoint 단위 연결이라 CIDR 충돌이 문제가 되지 않는다.\n방향성도 다르다. PrivateLink는 단방향이다. 제공자가 노출한 서비스를 소비자가 호출하는 형태고, 그 반대는 별도 endpoint를 만들어야 한다. SaaS 서비스나 자사 서비스의 사적 노출, 클라우드 매니지드 서비스의 VPC 진입점 등에 자주 쓰인다.\n비교 표 네 메커니즘을 한 표에 놓으면 트레이드오프가 분명해진다.\n메커니즘 토폴로지 Transitive 비용 모델 주 사용처 VPC Peering 1:1 mesh ❌ 비교적 저렴 (트래픽당) 소수 VPC 직접 연결 Transit Gateway hub-spoke ✅ 시간당 + 트래픽당 다수 VPC, 라우팅 도메인 분리 Site-to-Site VPN 사이트 ↔ VPC 터널 (BGP 사용 시 ✅) 시간당 + 트래픽당 온프레미스 ↔ VPC PrivateLink 서비스 endpoint (해당 없음) endpoint당 + 트래픽당 서비스 단위 노출, CIDR 무관 CIDR 충돌 함정 VPC Peering과 Transit Gateway 모두 IP 라우팅 기반이다. 두 VPC의 CIDR가 겹치면 패킷의 행선지가 모호해져 라우팅이 깨진다.\n처음부터 겹치지 않게 CIDR 대역을 자르면 나중 연결 단계에서 돌아올 운영 비용을 피할 수 있다. 조직 단위로 IP 대역을 미리 분배해 두는 원칙이 특히 다수의 VPC 환경에서 강하게 작동한다.\n이미 충돌이 발생한 환경이라면 PrivateLink가 우회로가 된다. PrivateLink는 IP 라우팅이 아닌 서비스 endpoint 노출이므로 양쪽 CIDR가 같아도 통신이 가능하다. 다만 IP 단위 광범위 통신이 아닌 \u0026ldquo;특정 서비스만 노출\u0026quot;이라는 좁은 사용 패턴에 한정된다.\n벤더 명칭 매핑 네 메커니즘의 벤더별 명칭은 다음과 같다.\n개념 AWS GCP Azure Alibaba Cloud 1:1 직접 연결 VPC Peering VPC Network Peering VNet Peering VPC Peering Hub-Spoke 다수 연결 Transit Gateway Network Connectivity Center Virtual WAN CEN (Cloud Enterprise Network) 온프레미스 IPSec 연결 Site-to-Site VPN Cloud VPN VPN Gateway VPN Gateway 서비스 단위 노출 PrivateLink Private Service Connect Private Link PrivateLink 이름이 살짝씩 다르지만 추상은 거의 같다. AWS의 PrivateLink와 GCP의 Private Service Connect, Azure의 Private Link가 같은 결의 서비스 endpoint 모델이다.\n정리 VPC 밖과의 연결은 네 메커니즘 중 하나로 풀린다.\nVPC Peering: 1:1 mesh, transitive 불가, 소수 VPC에 적합 Transit Gateway: hub-spoke, transitive 가능, 다수 VPC에서 비용 효율 역전 Site-to-Site VPN: 온프레미스 ↔ VPC IPSec 터널 PrivateLink: IP 라우팅 무관, 서비스 endpoint 단위 노출 어떤 메커니즘을 고르느냐가 VPC 밖과의 토폴로지·비용·격리 정책을 사실상 결정한다. 그리고 IP 라우팅 기반 메커니즘 세 개는 CIDR 충돌이 생기면 그 선택이 못쓰게 되므로, 조직 단위 IP 분배를 처음부터 잡아두는 일이 이 선택을 지탱한다.\n다음 편은 보안 — Security Group과 NACL이 VPC 위에서 어떻게 다른 계층의 방어선을 만드는지 다룬다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/vpc-connectivity-fundamentals/","summary":"VPC 간/온프레미스/서비스 연결을 책임지는 4가지 메커니즘 — Peering, Transit Gateway, Site-to-Site VPN, PrivateLink — 의 토폴로지와 비용 트레이드오프를 정리한다.","title":"VPC 간/외부 연결 — Peering, VPN, Transit, PrivateLink"},{"content":"외부 SSP를 연동하면 트래픽이 늘고 매출도 오른다. 하지만 매출이 오른다고 이익이 오르는 것은 아니다.\n광고 요청이 들어오면 추천 서버가 광고 후보를 생성하고, 필터링 서버가 부적합한 광고를 걸러낸다. 외부 SSP에는 노출 비용(미디어 비용)을 지불한다. 이 과정에서 발생하는 서버비와 미디어 비용을 합치면, 일부 인벤토리는 벌어들이는 수익보다 비용이 더 컸다. 서버비 비중이 올라가면서 공헌이익이 줄어들고 있었다.\n성과가 낮은 인벤토리의 트래픽을 자동으로 식별하고 제한하는 시스템을 만들기로 했다.\n성과 지표 분석 어떤 인벤토리의 성과가 낮은지 판단하려면 기준이 필요했다. 세 가지 후보를 분석했다.\nImp Cost Ratio. 미디어 비용 대비 수익 비율이다. 100%를 넘으면 미디어 비용이 수익보다 크다는 뜻이다. 매출이 발생하더라도 공헌이익 관점에서는 손해를 보고 있는 상태다. 가장 직관적인 지표라고 판단했다.\nRPM. 광고 노출 1,000회당 수익이다. RPM이 낮더라도 수익을 내고 있는 인벤토리가 있었다. Imp Cost Ratio보다 우선순위가 낮다고 봤다. 실제로 Imp Cost Ratio 기준만으로도 제한 대상이 충분했기 때문에 RPM은 고려 대상에서 제외했다.\nWin Ratio. 외부 SSP 비딩에서 이긴 비율이다. Win Ratio가 낮으면 광고를 준비하고도 낙찰되지 못한다는 뜻이다. 서버 자원만 소모하고 수익은 발생하지 않는다. 서버비 절감 관점에서 보조 지표로 활용하기로 했다.\nImp Cost Ratio를 1순위로 두고, Win Ratio는 보조로 사용하기로 했다.\n1차 접근 Imp Cost Ratio가 100%를 넘는 인벤토리를 대상으로 트래픽을 어떻게 제한할지 검토했다.\n두 가지 방식을 비교했다. 첫 번째는 Imp Cost Ratio와 노출 비중에 따라 가중치를 적용하는 방식이었다. Imp Cost Ratio가 높을수록, 해당 인벤토리의 노출 비중이 높을수록 더 많이 제한한다. 두 번째는 대상 인벤토리의 트래픽을 고정 비율로 제한하는 방식이었다. 단순하지만 확실하다.\nRedash로 두 방식의 시뮬레이션을 돌려봤다. 미디어 비용이 수익을 초과하는 인벤토리의 트래픽이 줄어드는 그림이 나왔다.\n그런데 적용하지는 않았다. 논의 끝에 한계가 드러났다. 프로젝트의 원래 목표는 공헌이익 개선이었는데, Imp Cost Ratio는 미디어 비용만 반영한다. 서버비를 고려하지 않는다. 미디어 비용 기준으로는 수익을 내지만 서버비까지 합치면 손해인 인벤토리가 존재했다. 공헌이익의 전체 그림이 아니었다.\n2차 접근 서버비까지 반영한 종합 수익성 지표가 필요했다. 예측 공헌이익률(predicted contribution margin rate) 을 도입했다.\n인벤토리별 서버비는 직접 측정이 어렵다. 노출 기여도 기반의 배분 방식을 택했다.\n이를 바탕으로 수익과 비용 항목을 조합하여 예측 공헌이익률을 산출한다. 이 값이 음수이면 해당 인벤토리는 공헌이익 관점에서 손해를 보고 있다.\n제한 비율 산출 공헌이익률을 트래픽 제한 비율로 변환해야 했다. 여러 함수를 비교했다.\n초반에 강하게 반응하는 함수는 공격적이라고 판단했다. 매출 영향을 최소화하면서 공헌이익을 개선하는 것이 목표였기 때문에, 단계적으로 반응하는 보정 형태를 선택했다. 기준값을 외부에서 조절할 수 있게 하여 제한 대상의 범위도 유연하게 관리할 수 있게 했다.\n배치 구조 서버비 데이터가 하루 단위로 집계되기 때문에 배치도 하루 단위로 동작한다.\n인벤토리별 광고 성과(노출, 미디어 비용, 수익)를 조회한다. 서비스별 서버비를 조회한다. 두 데이터를 결합하여 인벤토리별 예측 공헌이익률을 계산하고, 제한 비율을 산출하여 DB 에 저장한다. 광고 서버는 요청이 들어올 때 해당 인벤토리의 제한 비율을 참조하여 트래픽을 걸러낸다.\n매출만 보면 안 보이는 것들이 있었다. 매출이 오르는데 이익이 줄어드는 구조를 인식한 것이 출발점이었다.\n1차 접근은 미디어 비용만 봤다. 시뮬레이션상 수치는 좋았지만 서버비를 놓치고 있었다. 적용하지 않고 2차 접근으로 넘어가, 서버비를 반영하면서 공헌이익의 전체 그림을 볼 수 있게 됐다. 지표와 목표 사이의 간극을 적용 전에 짚어내고 접근법을 한 단계 발전시킨 경험이 프로젝트에서 가장 큰 배움이었다고 본다.\n공헌이익은 의미 있게 개선됐다.\n","permalink":"https://wid-blog.github.io/posts/career/dable/profitability-based-traffic-throttling/","summary":"외부 SSP 연동 광고 서비스에서 성과가 낮은 인벤토리를 자동으로 식별하고 트래픽을 제한하여 공헌이익을 개선한 프로젝트 회고. Imp Cost Ratio 에서 예측 공헌이익률 기반으로 접근법이 진화한 과정.","title":"수익성 기반 트래픽 제한 시스템 회고"},{"content":"VPC 안의 트래픽이 어디로 갈지는 Route Table이 결정한다. 외부 인터넷으로의 출입은 IGW와 NAT가 각각 양방향·outbound 전용으로 나뉘어 담당한다.\nRoute Table Route Table은 VPC 또는 Subnet에 부착되는 라우팅 규칙 묶음이다. 각 규칙은 목적지 CIDR과 다음 홉(target)을 짝지어, 트래픽이 도착해야 할 곳을 결정한다.\n매칭 방식은 longest prefix match. 더 구체적인 CIDR 규칙이 우선한다. 예를 들어 0.0.0.0/0(default route)과 10.0.5.0/24가 모두 규칙에 있고 패킷의 목적지가 10.0.5.42라면 10.0.5.0/24 규칙이 선택된다.\nVPC 생성 시 Local route가 자동으로 추가된다. VPC의 CIDR 전체를 가리키는 규칙이라, 같은 VPC 안의 자원끼리는 별도 설정 없이도 통신할 수 있다. Local route는 삭제할 수 없다.\nflowchart LR Pkt[\"패킷목적지: 8.8.8.8\"] --\u003e RT[\"Route Table\"] RT --\u003e|\"10.0.0.0/16(local)\"| Local[\"VPC 내부\"] RT --\u003e|\"0.0.0.0/0(default)\"| Out[\"IGW 또는 NAT\"] Internet Gateway (IGW) VPC와 외부 인터넷 사이의 양방향 출입구를 담당하는 컴포넌트가 Internet Gateway다. VPC당 하나만 부착할 수 있고, 부착되어 있어야 외부 트래픽이 오갈 수 있다.\n자원이 외부에서 도달 가능하려면 두 조건이 모두 필요하다. Public IP나 Elastic IP가 부착돼 있어야 하고, 그 자원이 속한 Subnet의 Route Table에 IGW로 향하는 default route가 있어야 한다. 둘 중 하나만 만족해서는 외부에서 접근할 수 없다.\nNAT Gateway NAT Gateway는 outbound 전용 출구다. Private Subnet의 자원이 외부 인터넷에 접근하면서도 외부에서 직접 접근받는 일은 막아야 할 때 사용한다.\nNAT Gateway 자체는 Public Subnet에 위치한다. NAT가 외부로 트래픽을 흘려보내려면 결국 IGW를 거쳐야 하기 때문이다. Private Subnet의 Route Table은 default route를 NAT Gateway로 향하게 잡고, NAT가 그 트래픽을 자신의 Public IP로 변환해 외부로 내보낸다.\n외부에서 시작하는 연결은 NAT를 통과하지 못하므로, Private 자원은 outbound로만 외부와 상호작용한다. NAT의 비대칭성이 그대로 보안 이점이 된다. 다만 NAT Gateway는 시간당 + 트래픽당 과금이 붙어 outbound 트래픽이 많은 워크로드에서는 비용 부담이 크다.\nPublic Subnet vs Private Subnet Public Subnet과 Private Subnet은 Subnet 자체의 속성이 아니라 라우팅 규칙의 결과다.\nPublic Subnet: Route Table의 default route가 IGW로 향하는 Subnet Private Subnet: default route가 NAT로 향하거나, default route가 없는 Subnet flowchart LR subgraph Public [\"Public Subnet\"] VM_P[\"VM (Public IP)\"] -.-\u003e RT_P[\"Route Table0.0.0.0/0 → IGW\"] end subgraph Private [\"Private Subnet\"] VM_R[\"VM\"] -.-\u003e RT_R[\"Route Table0.0.0.0/0 → NAT\"] end RT_P --\u003e IGW[\"IGW\"] RT_R --\u003e NAT[\"NAT Gateway\"] NAT --\u003e IGW 같은 VPC 안의 두 Subnet이 서로 다른 Route Table에 묶여 있을 뿐, Subnet 자체에 Public/Private 플래그가 있는 게 아니다.\n자주 헷갈리는 라우팅 시나리오 Public IP가 있는데 외부에서 접근되지 않는다 Public IP는 도달 가능성의 충분조건이 아니다. 그 자원이 속한 Subnet의 Route Table이 IGW로 향하는 default route를 갖고 있어야 한다. Private Subnet의 인스턴스에 Public IP를 부착해도 외부에서 접근하지 못한다.\nRoute Table이 비어 있는데 같은 VPC 안 자원 간 통신이 된다 Local route는 자동 추가되고 삭제할 수 없다. Route Table에 다른 규칙을 명시하지 않아도 VPC 내부 통신은 항상 가능하다.\nNAT를 두 AZ에 모두 두는 이유 NAT Gateway는 AZ 단위 리소스다. 한 AZ에 NAT를 두고 다른 AZ의 Private Subnet이 그 NAT를 가리키면, NAT가 위치한 AZ에 장애가 생길 때 다른 AZ까지 외부 통신이 끊긴다. 가용성이 중요한 시스템은 AZ별로 NAT를 둔다. 비용 부담이 그만큼 더 는다.\n벤더 명칭 매핑 라우팅 컴포넌트의 벤더별 명칭은 다음과 같다.\n개념 AWS GCP Azure Alibaba Cloud 라우팅 규칙 Route Table Routes Route Table Route Table 외부 출입구 (양방향) Internet Gateway (default internet gateway, 묵시적) Public IP + NSG 조합 Internet Gateway outbound 전용 출구 NAT Gateway Cloud NAT NAT Gateway NAT Gateway GCP는 default internet gateway를 명시적 리소스로 노출하지 않고 Routes의 next hop으로만 다룬다는 점이 다르다. Azure는 별도 Internet Gateway 리소스 없이 Public IP와 NSG 규칙으로 외부 노출을 통제하는 모델이다.\n정리 VPC 내 트래픽의 행로는 세 요소가 결정한다.\nRoute Table: VPC와 Subnet에 부착되는 규칙 묶음. longest prefix match와 Local route가 기본 제공. IGW: 외부 인터넷과의 양방향 출입구. Public IP와 Subnet의 default route 두 조건이 함께 있어야 도달 가능. NAT Gateway: outbound 전용 출구. Public Subnet에 위치하며 IGW를 경유해 트래픽을 내보낸다. Public Subnet과 Private Subnet은 Subnet 자체의 속성이 아니라 default route 행선지가 만드는 결과다.\n결국 Route Table의 규칙이 내부 통신·외부 출입·Public/Private 분류를 구조적으로 결정한다. IGW와 NAT는 출구의 종류를 제공할 뿐, 어떤 주소가 그 출구로 향할지는 여전히 규칙이 정한다.\n다음 편은 VPC와 다른 네트워크의 연결 — Peering, Transit Gateway, VPN, PrivateLink가 만드는 토폴로지다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/vpc-routing-fundamentals/","summary":"VPC 안의 트래픽 경로를 결정하는 Route Table, 외부 출입구 역할의 Internet Gateway와 NAT Gateway, Public/Private Subnet 구분의 실체를 정리한다.","title":"VPC 트래픽 흐름과 Route Table"},{"content":"클라우드에서 VM을 생성하면 기본적으로 어떤 네트워크에 소속된다. 그 네트워크가 VPC다. AWS, GCP, Alibaba는 모두 VPC라고 부르고, Azure만 VNet이라는 이름을 쓰지만, 가리키는 추상은 동일하다 — 공용 클라우드 위에서 사설 IP 공간을 갖는 격리된 가상 네트워크.\n같은 추상에 다른 이름이 붙어 있다 보니, 다른 벤더 문서를 읽을 때마다 길을 잃기 쉽다. 트래픽 라우팅·외부 연결·보안은 후속 글로 분리한다.\nVPC가 왜 필요한가 클라우드는 본질적으로 멀티테넌트 환경이다. 같은 물리 인프라 위에서 여러 고객의 워크로드가 동시에 실행된다. 격리가 없으면 한 고객의 트래픽이 다른 고객에게 보일 수 있고, IP 주소도 서로 충돌한다.\nVPC는 SDN(Software-Defined Networking) 위에 구축된 가상 네트워크다. 각 VPC는 자체 IP 공간과 자체 라우팅 테이블을 가지며, 다른 VPC와는 기본적으로 격리된다. 클라우드 위에 자기만의 데이터센터를 두는 구조에 가깝다.\n이 격리는 세 요소가 합쳐져 만들어진다 — IP 공간, Subnet, Tenancy.\nIP 공간 (CIDR) VPC 생성 시 가장 먼저 정하는 값이 CIDR 블록이다. 10.0.0.0/16 같은 사설 IP 대역을 지정하면, 이 범위 안의 주소가 VPC 내부 자원에 할당된다.\nRFC 1918이 정의한 사설 IP 대역(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)에서 선택하기를 권장한다. 인터넷에서 라우팅되지 않는 대역이라 외부와 충돌하지 않고, 다른 VPC도 같은 대역을 쓸 수 있다.\n다만 향후 다른 VPC와 연결할 가능성이 있다면, 같은 사설 대역끼리도 충돌이 생긴다. 두 VPC가 모두 10.0.0.0/16을 쓰고 있으면 Peering이나 Transit Gateway로 연결할 때 라우팅이 모호해진다. 처음부터 조직 단위로 IP 대역을 분배해 두는 편이 안전하다.\n접두 길이(prefix)도 한 번 정하면 바꾸기 어려우므로, 미래 확장을 고려해 충분한 공간을 확보한다.\nIP 공간이 격리에 기여하는 방식은 명확하다. 같은 VPC 내부 자원만 동일 IP 공간을 의미 있게 인식하고, 다른 VPC와는 IP 수준에서 처음부터 분리된다.\nSubnet VPC 단위 IP 공간을 더 작은 블록으로 쪼갠 단위가 Subnet이다.\nSubnet은 보통 가용영역(AZ) 단위로 만든다. 한 AZ에 위치한 자원은 같은 Subnet에 묶고, 다른 AZ의 자원은 별도 Subnet으로 분리한다. AZ는 물리적으로 분리된 데이터센터 묶음이라, AZ별 Subnet 배치가 가용성 보장의 기본이다. 한 AZ가 장애를 겪어도 다른 AZ의 Subnet은 영향을 받지 않는다.\nflowchart LR subgraph VPC [\"VPC (10.0.0.0/16)\"] subgraph AZ_a [\"AZ-a\"] S_a1[\"Public Subnet10.0.1.0/24\"] S_a2[\"Private Subnet10.0.2.0/24\"] end subgraph AZ_b [\"AZ-b\"] S_b1[\"Public Subnet10.0.3.0/24\"] S_b2[\"Private Subnet10.0.4.0/24\"] end end 격리 관점에서 Subnet의 역할은 분리 단위 그 자체보다는 정책 적용의 최소 단위에 가깝다. 라우팅 규칙과 보안 규칙이 Subnet 단위로 걸리고, Public/Private Subnet이라는 분류도 결국 라우팅 규칙의 결과로 나타난다.\nTenancy 같은 VPC 안의 자원이라도, 그 자원이 도는 물리 하드웨어를 다른 고객과 공유할지 여부는 Tenancy 옵션으로 결정된다. Tenancy는 VPC 기본값으로 잡거나 인스턴스 단위로 지정할 수 있다.\n기본값은 shared tenancy다. 같은 물리 호스트 위에서 여러 고객의 VM이 함께 실행되고, 비용 효율이 좋아 일반 워크로드에 충분하다.\n반면 dedicated tenancy를 선택하면 그 VPC 안의 자원은 다른 고객과 물리 하드웨어를 공유하지 않는다. 금융권 규제, 의료 데이터, 정부 워크로드처럼 컴플라이언스 요구가 강한 경우에 자주 쓴다. 비용은 높아지고 사용 가능한 인스턴스 타입도 제한된다.\n벤더별로 옵션 명칭은 다르다. AWS는 dedicated와 host로 나누고, GCP는 sole-tenant 노드라는 별도 개념을 둔다. 명칭은 달라도 패턴은 동일하다. 격리 강도를 높일수록 비용도 함께 올라간다.\nTenancy는 IP 수준 격리에 더해 물리 하드웨어 수준의 격리까지 부여한다는 점에서, 격리의 강도를 한 단계 높인다.\n벤더 명칭 매핑 벤더별 용어 매핑은 다음과 같다.\n개념 AWS GCP Azure Alibaba Cloud 가상 사설 네트워크 VPC VPC Virtual Network (VNet) VPC Subnet Subnet Subnet Subnet VSwitch 사설 IP 대역 CIDR Block IP range Address space CIDR Block 가용영역 Availability Zone Zone Availability Zone Zone 전용 하드웨어 Dedicated / Host Sole-tenant Dedicated Host Dedicated Host 명칭 차이가 큰 쪽은 Azure(VNet)와 Alibaba(VSwitch)다. 그 외에는 거의 같은 단어를 쓴다. 한 벤더의 개념 지도를 익히면 다른 벤더 문서도 절반 이상 그대로 이해된다.\n클라우드 이전과의 비교 격리된 사설 네트워크라는 개념 자체는 클라우드 이전부터 있었다. 사내 데이터센터에서 VLAN으로 트래픽을 분리하고, RFC 1918 사설 대역을 쓰는 일은 오래된 패턴이다.\nVPC는 이 패턴을 SDN 위에서 다시 구현한 것이다. VLAN 태그 같은 물리 계층 의존이 사라지고, IP 공간 / Subnet / Tenancy 같은 추상이 API와 코드로 조작 가능해졌다. 새 Subnet을 만드는 데 케이블 작업 없이 API 호출 한 번이면 충분하다. 격리의 본질은 같지만, 운영 유연성은 결이 다르다.\n정리 VPC의 격리는 세 요소의 조합이다.\nIP 공간(CIDR): VPC 단위의 사설 IP 주소 공간. RFC 1918 대역 안에서, 미래의 연결까지 고려해 충분한 공간을 잡는다. Subnet: VPC를 가용영역별로 분할한 블록. AZ별 분리가 가용성 보장의 기본이고, 라우팅·보안 정책의 적용 단위. Tenancy: 물리 하드웨어 공유 여부. shared가 기본값이고, 컴플라이언스 요구가 강할 때 dedicated를 쓴다. 벤더 명칭은 다르지만 추상은 동일하다 — VPC, VNet, VSwitch는 모두 같은 개념의 다른 이름표다.\n격리는 한 요소만으로 완성되지 않는다. IP 공간이 주소 충돌을, Subnet이 가용성과 정책을, Tenancy가 물리 하드웨어를 각각 분담한다. 어디까지 격리할지는 워크로드의 요구가 정한다.\n다음 편은 VPC 내부의 트래픽 흐름 — Route Table, IGW, NAT Gateway가 만드는 라우팅 구조다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/vpc-isolation-fundamentals/","summary":"VPC가 IP CIDR, Subnet, Tenancy 세 요소를 조합해 사설 네트워크 경계를 시뮬레이션하는 원리를 정리한다. AWS/GCP/Azure/Alibaba 벤더 명칭 매핑 포함.","title":"VPC와 격리 모델"},{"content":"\u0026ldquo;어떤 종목을 살 것인가\u0026quot;와 \u0026ldquo;언제 사고 팔 것인가\u0026quot;는 다른 질문이다. 앞선 글에서 다룬 PER, ROE, 모멘텀, 배당수익률은 첫 번째 질문에 답하는 지표다. 기업의 가치와 질을 평가한다. 두 번째 질문에 답하려면 가격 자체의 움직임을 읽어야 한다. 기술적 지표가 그 역할을 한다.\n기술적 지표 RSI (상대강도지수) RS = 평균 상승폭 / 평균 하락폭 RSI = 100 - (100 / (1 + RS)) 일정 기간(보통 14일) 동안 가격의 상승과 하락 강도를 비교한다. 0에서 100 사이 값이 나온다.\nRSI \u0026lt; 30이면 과매도 상태다. 하락 폭이 커서 반등 가능성이 있다. 매수 시그널로 본다. RSI \u0026gt; 70이면 과매수 상태다. 상승 폭이 커서 하락 가능성이 있다. 매도 시그널로 본다.\n30/70은 절대적 기준이 아니다. 강한 상승 추세에서는 70을 넘어도 계속 오를 수 있다. 20/80으로 조정하거나 다른 지표와 함께 쓰면 거짓 시그널을 줄일 수 있다.\nSMA Cross (이동평균 교차) SMA(N) = 최근 N일 종가의 평균 단순이동평균(SMA)은 최근 N일간 종가의 산술 평균이다. 단기와 장기 이동평균의 교차로 추세 전환을 판단한다.\n골든크로스. 단기 SMA가 장기 SMA를 아래에서 위로 돌파한다. 상승 추세 시작. 매수 시그널이다. 데드크로스. 반대다. 단기가 장기를 위에서 아래로 돌파한다. 하락 추세 시작. 매도 시그널이다.\n흔히 5일/20일(단기), 20일/60일(중기), 50일/200일(장기) 조합을 쓴다. 기간이 짧을수록 시그널이 빨리 나오지만 거짓 시그널도 늘어난다.\nMACD (이동평균수렴확산) MACD Line = 12일 EMA - 26일 EMA Signal Line = MACD Line의 9일 EMA 히스토그램 = MACD Line - Signal Line Gerald Appel이 개발한 지표다. 지수이동평균(EMA) 기반으로 추세의 방향과 강도를 동시에 본다. EMA는 SMA와 달리 최근 데이터에 더 큰 가중치를 준다.\nMACD Line이 Signal Line을 상향 돌파하면 매수 시그널이다. 하향 돌파하면 매도 시그널이다. 히스토그램은 두 선의 차이를 시각적으로 보여준다. 양에서 음으로, 음에서 양으로 전환되는 것도 추세 전환 시그널이다.\nSMA Cross와 원리는 비슷하지만 EMA를 쓰기 때문에 가격 변화에 더 빠르게 반응한다.\nBollinger Bands (볼린저 밴드) 중심선 = 20일 SMA 상단 밴드 = 중심선 + (2 × 표준편차) 하단 밴드 = 중심선 - (2 × 표준편차) John Bollinger가 개발한 지표다. 이동평균을 중심으로 가격의 변동성을 밴드로 표현한다. 표준편차를 쓰므로 변동성이 크면 밴드가 넓어지고, 작으면 좁아진다.\n가격이 하단 밴드를 이탈하면 과매도. 매수 시그널이다. 상단 밴드를 이탈하면 과매수. 매도 시그널이다. 통계적으로 가격의 약 95%는 밴드 안에 위치한다.\n밴드가 좁아지는 구간(스퀴즈)은 변동성이 낮아진 상태다. 이후 큰 가격 변동이 올 가능성이 있다. 방향은 알 수 없으므로 돌파 방향을 확인한 후 진입한다.\n복합 시그널 네 가지 지표를 살펴봤다. 공통된 한계가 있다. 단일 지표만으로 매매하면 거짓 시그널에 취약하다. RSI가 30 아래로 내려가도 추가 하락이 이어질 수 있다. 골든크로스가 나와도 곧바로 데드크로스로 뒤집힐 수 있다. 여러 지표를 조합한다.\n가중치 기반 조합 각 지표에 가중치를 부여한다. 시그널이 발생한 지표의 가중치 합이 전체 가중치 합 대비 일정 임계값 이상이면 복합 시그널을 발생시킨다.\n시그널 발생 조건: triggered 가중치 합 / 전체 가중치 합 ≥ 임계값(예: 0.5) 예를 들어 RSI(가중치 0.3), SMA Cross(가중치 0.3), MACD(가중치 0.4)를 조합한다. RSI와 MACD가 동시에 매수 시그널을 내면 (0.3 + 0.4) / 1.0 = 0.7이다. 임계값 0.5 이상이므로 복합 매수 시그널이 발생한다.\n임계값이 낮으면 민감하게 반응한다. 높으면 보수적으로 반응한다. 이 상충 관계는 백테스트로 조정한다.\n쿨다운 같은 종목에서 시그널이 연속 발생하는 것을 막는 장치다. 한 번 시그널이 발생하면 일정 시간(예: 300초) 동안 동일 종목의 시그널을 무시한다.\n쿨다운이 없으면 변동성이 높은 구간에서 매수-매도 시그널이 반복 발생한다. 불필요한 거래 비용이 쌓인다.\n세 가지 전략 유형 이 글에서 다룬 기술적 지표는 퀀트 투자의 여러 전략 유형 중 하나에 해당한다. 앞선 글의 팩터 지표도 마찬가지다. 전략 유형별로 시간축과 동작 방식이 다르다.\n전략 유형 판단 기준 시간축 종목 선정 Signal (기술적 시그널) RSI, MACD 등 기술적 지표 초/분 단위 (실시간) 고정 (사용자 지정) Factor (팩터 스크리닝) PER, ROE 등 팩터 스코어 월/분기 단위 (리밸런싱) 동적 (매 리밸런싱마다 교체) Asset Allocation (자산배분) 목표 비중 대비 드리프트 비중 밴드 이탈 시 고정 (자산군 단위) Signal 전략은 실시간 가격 데이터를 받아 기술적 지표로 매수/매도를 판단한다. 이 글에서 다룬 RSI, SMA Cross, MACD, 볼린저 밴드가 여기에 속한다. 가장 짧은 시간축에서 동작한다.\nFactor 전략은 앞선 글들에서 다룬 팩터(PER, ROE, 모멘텀 등)로 종목을 스코어링한다. 리밸런싱마다 상위 N개 종목을 선정한다. 월 또는 분기 단위다.\nAsset Allocation 전략은 종목별 목표 비중을 설정한다. 현재 비중이 밴드를 벗어나면 리밸런싱한다. 목표 비중 자체를 바꾸지 않는 한 종목은 고정이다.\n세 전략은 상호 배타적이지 않다. Factor로 종목을 선정하고, Asset Allocation으로 자산군 비중을 관리하며, Signal로 진입/청산 타이밍을 잡는 조합이 가능하다.\n기술적 지표는 가격 움직임에서 매수/매도 시그널을 읽는 도구다. 하지만 도구를 아는 것과 전략을 설계하는 것은 다르다. 어떤 지표를 조합할지, 어떤 전략 유형을 택할지가 설계의 영역이다.\n이 시리즈는 주식 데이터 기초에서 출발하여 기업 평가 지표, 시장 추세 지표, 백테스트, 포트폴리오 구성, 그리고 가격 기반 기술적 지표까지 이어졌다. 다음은 이 지표들을 실제 데이터에 적용하여 직접 전략을 만들고 검증하는 단계다.\n참고 자료 Investopedia — RSI Investopedia — Moving Average (SMA) Investopedia — MACD Investopedia — Bollinger Bands J. Welles Wilder Jr., New Concepts in Technical Trading Systems (1978) Gerald Appel, Technical Analysis: Power Tools for Active Investors (2005) John Bollinger, Bollinger on Bollinger Bands (2001) ","permalink":"https://wid-blog.github.io/posts/daily/investment/signal-technical-indicators/","summary":"가격 움직임을 읽는 기술적 지표(RSI, SMA Cross, MACD, Bollinger Bands)의 공식과 해석, 복합 시그널 설계, 그리고 세 가지 전략 유형의 시간축 비교를 정리한다.","title":"기술적 지표와 매매 시그널"},{"content":"앞선 글들에서 개별 지표를 배웠다. PER, ROE, 모멘텀, 배당수익률. 이제 질문은 \u0026ldquo;이 지표들을 어떻게 하나의 점수로 합치고, 그 점수로 어떻게 포트폴리오를 구성하고 운용하는가\u0026quot;다.\n팩터 투자 팩터 투자는 특정 특성(팩터)을 가진 종목을 체계적으로 선별하는 투자 방법이다. \u0026ldquo;저PER 종목을 사자\u0026quot;는 것도 팩터 투자의 한 형태다. PER이라는 팩터를 기준으로 종목을 고르는 것이기 때문이다.\n싱글 팩터와 멀티팩터 싱글 팩터 전략은 하나의 지표만으로 종목을 선별한다. 저PER 전략, 고ROE 전략, 고모멘텀 전략 등이 여기에 해당한다. 직관적이지만 한계가 있다. 앞서 정리한 것처럼, 저PER 종목이 가치 함정일 수 있고, 고모멘텀 종목이 이미 고평가 상태일 수 있다.\n멀티팩터 전략은 여러 지표를 결합하여 종합 점수를 만든다. 밸류, 퀄리티, 모멘텀, 배당 등 여러 관점을 동시에 반영하므로 단일 팩터의 약점을 보완한다. 학술 연구와 실무 모두에서 멀티팩터가 싱글 팩터보다 안정적인 성과를 보인다는 결과가 일관되게 나온다.\n멀티팩터를 구성할 때는 팩터별 가중치를 설정한다. 동일 가중(모든 팩터에 같은 비중)이 가장 단순하다. 특정 팩터를 더 중시하고 싶다면 해당 팩터의 가중치를 높인다. 가중치 설정에 정답은 없으며, 백테스트를 통해 검증한다.\n팩터 스코어링 여러 팩터를 하나의 점수로 합치려면 각 팩터의 값을 비교 가능한 단위로 변환해야 한다. PER은 10 단위, ROE는 15% 같은 비율, 모멘텀은 0.3 같은 소수다. 단위가 다른 값을 그대로 더하면 특정 팩터가 점수를 지배한다.\nZ-Score Z = (값 - 평균) / 표준편차 Z-Score는 각 값이 평균으로부터 몇 표준편차 떨어져 있는지를 나타낸다. 모든 팩터를 평균 0, 표준편차 1인 동일한 스케일로 변환한다.\nZ-Score의 장점은 값의 분포 정보를 유지한다는 점이다. 극단적으로 좋은 종목과 보통 종목의 차이를 반영한다. 단점은 이상치에 민감하다는 것이다. PER이 1,000인 종목이 하나 있으면 나머지 종목의 Z-Score가 모두 0 근처로 압축된다.\nRank Rank 기반 스코어링은 값 자체 대신 순위를 사용한다. 100개 종목에서 PER이 가장 낮은 종목이 1위, 가장 높은 종목이 100위다. 순위를 0~1 범위로 정규화하여 점수화한다.\nRank의 장점은 이상치에 강건하다는 점이다. PER이 1,000인 종목이 있어도 그 종목은 단순히 마지막 순위를 받을 뿐, 다른 종목의 점수에 영향을 주지 않는다. 실무에서는 Z-Score보다 Rank를 더 자주 사용한다.\nlower_is_better 처리 PER, PBR, 부채비율은 낮을수록 좋다. 모멘텀, ROE, 배당수익률은 높을수록 좋다. 방향이 다른 팩터를 하나의 점수로 합칠 때는 \u0026ldquo;낮을수록 좋은\u0026rdquo; 팩터의 부호를 반전시키거나, 순위를 역순으로 매겨야 한다. 이 처리를 빠뜨리면 점수가 의도와 반대로 작동한다.\n리밸런싱 리밸런싱은 목표 비중에서 벗어난 포트폴리오를 다시 맞추는 행위다. 시간이 지나면 종목별 수익률 차이로 인해 처음 설정한 비중에서 벗어난다. 이를 주기적으로 원래 비중으로 되돌리는 것이 리밸런싱이다.\n리밸런싱 주기 주기 특성 일간(daily) 가장 정확하지만 거래 비용이 높다 주간(weekly) 비용과 정확성의 균형 월간(monthly) 가장 일반적인 선택 분기(quarterly) 거래 비용이 낮지만 비중 이탈이 클 수 있다 리밸런싱을 너무 자주 하면 거래 비용이 누적된다. 너무 드물게 하면 의도한 비중에서 크게 벗어난다. 이 트레이드오프에서 월간 리밸런싱이 가장 널리 사용된다.\n리밸런싱 밴드 매 리밸런싱 시점마다 무조건 거래하는 것은 비효율적이다. 리밸런싱 밴드는 \u0026ldquo;비중이 목표에서 일정 % 이상 벗어났을 때만 거래한다\u0026quot;는 규칙이다. 비중 차이가 작으면 그대로 두고, 임계치를 넘었을 때만 거래한다. 불필요한 거래를 줄여 비용을 절약한다.\n자산 배분 자산 배분은 여러 자산 유형에 자금을 나누는 것이다. 주식만이 아니라 채권, 금, 원자재 등 다양한 자산에 분산 투자한다.\n분산 투자의 원리 \u0026ldquo;달걀을 한 바구니에 담지 마라\u0026quot;라는 격언이 자산 배분의 핵심이다. 주식이 하락할 때 채권이 오르거나, 인플레이션 시 금이 오르는 등 자산 간 상관관계가 낮을수록 분산 효과가 크다.\n자산 배분의 목적은 수익률 극대화가 아니다. 위험을 줄이면서 적정 수익을 유지하는 것이다. 주식 100%보다 주식 60% + 채권 40%가 수익률은 낮지만, MDD가 크게 줄어든다.\n전통적 배분 배분 특성 주식 60% + 채권 40% 가장 전통적인 균형 포트폴리오 주식 80% + 채권 20% 성장 지향. 젊은 투자자에게 권장되기도 한다 주식 40% + 채권 40% + 금/원자재 20% 올웨더(All Weather) 스타일의 다자산 배분 자산 배분 비율에 절대적 정답은 없다. 투자자의 목표, 투자 기간, 위험 감내 수준에 따라 달라진다.\n여기까지 기업을 평가하는 지표(PER, PBR, ROE, 부채비율), 시장 추세를 보는 지표(모멘텀, 배당), 전략을 검증하는 방법(백테스트), 그리고 포트폴리오를 구성하는 방법(팩터 스코어링, 리밸런싱, 자산 배분)을 정리했다.\n다음 글에서는 관점을 바꿔, 가격 움직임 자체에서 매수/매도 타이밍을 읽는 기술적 지표를 정리하려 한다.\n참고 자료 Investopedia — Factor Investing Investopedia — Rebalancing Investopedia — Asset Allocation Investopedia — Z-Score Andrew Ang, Asset Management: A Systematic Approach to Factor Investing (2014) ","permalink":"https://wid-blog.github.io/posts/daily/investment/portfolio-factor-scoring/","summary":"개별 지표를 종합 점수로 만드는 팩터 스코어링(Z-Score, Rank)과, 포트폴리오를 구성하고 운용하는 리밸런싱·자산 배분의 기초를 정리한다.","title":"포트폴리오 운용과 팩터 스코어링"},{"content":"전략을 만들었으면 과거 데이터로 검증해야 한다. 이것이 백테스트다. 그런데 CAGR이 높으면 좋은 전략인가? MDD가 -50%인데 CAGR이 30%라면? 하나의 지표만 보면 판단을 잘못할 수 있다.\n수익 지표 CAGR CAGR(Compound Annual Growth Rate, 연평균 복리 수익률)은 투자 기간 전체의 수익률을 연 단위로 환산한 값이다.\nCAGR = (최종 자산 / 초기 자산)^(1/투자 년수) - 1 3년간 총 33% 수익을 냈다면 CAGR은 약 10%다. 기간이 다른 전략을 동일한 기준으로 비교할 수 있어 유용하다.\nCAGR 자체는 수익의 크기만 알려줄 뿐, 그 과정에서 얼마나 흔들렸는지는 보여주지 않는다. CAGR이 같아도 한 전략은 안정적으로 올랐고, 다른 전략은 -40% 하락 후 급등했을 수 있다.\nAlpha Alpha = 전략 수익률 - 벤치마크 수익률 Alpha는 시장(벤치마크) 대비 초과 수익이다. 벤치마크로는 KOSPI 지수나 S\u0026amp;P 500을 주로 사용한다. Alpha가 양수이면 시장보다 잘했다는 뜻이고, 음수이면 시장을 이기지 못했다는 뜻이다.\n퀀트 전략의 목표는 양의 Alpha를 만드는 것이다. 시장 수익률은 인덱스 펀드만 사도 얻을 수 있다. 전략의 가치는 그 위에 추가되는 Alpha에 있다.\n위험 지표 MDD MDD(Maximum Drawdown, 최대 낙폭)는 전략 운용 기간 중 고점에서 저점까지의 최대 하락폭이다.\nMDD = (고점 - 저점) / 고점 × 100% **\u0026ldquo;최악의 경우 얼마나 잃는가\u0026rdquo;**를 보여주는 지표다. MDD가 -30%라면 자산이 1억 원에서 7천만 원까지 떨어진 구간이 있었다는 뜻이다.\nMDD가 중요한 이유는 심리적 한계 때문이다. CAGR이 높아도 MDD가 -50%인 전략은 실전에서 유지하기 어렵다. 자산이 절반으로 줄어드는 것을 견디는 투자자는 드물다.\nSharpe Ratio Sharpe Ratio = (전략 수익률 - 무위험 수익률) / 수익률의 표준편차 Sharpe Ratio는 위험 대비 수익의 효율성을 측정한다. 같은 수익률이라도 변동성이 낮으면 Sharpe가 높다.\nSharpe Ratio 해석 \u0026lt; 0 무위험 자산보다 못함 0 ~ 1.0 보통 1.0 ~ 2.0 양호 \u0026gt; 2.0 우수 CAGR 30%에 MDD -50%인 전략과, CAGR 15%에 MDD -15%인 전략이 있다면 어느 것이 나은가? Sharpe Ratio는 후자가 높을 가능성이 크다. 수익은 절반이지만 위험도 크게 낮기 때문이다. 수익률의 크기와 효율성은 다른 개념이다.\n운용 지표 Win Rate Win Rate(승률)는 수익을 낸 거래의 비율이다. 승률 60%라면 10번 거래 중 6번 수익, 4번 손실이다.\n승률이 높아도 손실 한 번의 크기가 크면 전체 성과가 나빠질 수 있다. 반대로 승률이 30%여도 수익 거래의 크기가 충분히 크면 전체 성과는 좋을 수 있다. 승률 자체보다 승률 × 평균 수익/손실 비율의 조합이 중요하다.\nTurnover Turnover(회전율)는 포트폴리오가 얼마나 자주 교체되는지를 나타낸다.\n높은 회전율은 높은 거래 비용을 의미한다. 매수/매도마다 수수료가 발생하고, 호가 차이(슬리피지)로 인한 손실도 누적된다. 백테스트에서 거래 비용을 반영하지 않으면 회전율이 높은 전략의 성과가 과대평가된다.\n백테스트 함정 백테스트 결과가 좋다고 실전에서도 좋을 것이라 믿기 어렵다.\nLook-ahead Bias 미래 데이터를 현재 시점에 사용하는 오류다. 예를 들어, 분기 실적은 발표일이 분기 말과 다르다. 3월 31일 기준 실적을 3월 31일에 사용하면 아직 발표되지 않은 정보를 쓰는 셈이다. 모멘텀을 계산할 때도 리밸런싱 시점 기준으로 과거 데이터만 사용해야 한다.\nSurvivorship Bias 상장폐지된 종목이 데이터에서 빠지면 발생하는 왜곡이다. 살아남은 종목만으로 백테스트하면 성과가 좋게 나온다. 실제로는 폐지된 종목에 투자하여 손실을 봤을 수 있다. 이 편향은 데이터 소스의 한계와 직결된다.\nOverfitting 과거 데이터에만 잘 맞는 전략을 만드는 것이다. 파라미터를 너무 많이 조정하면 과거에는 완벽한 성과를 보이지만 미래 데이터에서는 성과가 크게 떨어진다. 파라미터 수를 최소화하고, 학습에 사용하지 않은 데이터(out-of-sample)로 검증하는 것이 대응 방법이다.\n거래비용 무시 수수료와 슬리피지를 반영하지 않은 백테스트는 실제보다 좋은 결과를 보인다. 특히 회전율이 높은 전략에서 이 차이가 크다. 백테스트 시 수수료율을 설정하여 현실적인 성과를 확인해야 한다.\n유동성 무시 거래량이 적은 소형주는 백테스트에서 매수/매도가 가능한 것처럼 나오지만, 실제로는 원하는 가격에 체결되지 않을 수 있다. 시가총액 필터로 유동성이 부족한 종목을 제외하는 것이 일반적인 대응이다.\n백테스트 성과 지표는 크게 세 축으로 구성된다. 수익(CAGR, Alpha), 위험(MDD, Sharpe Ratio), 운용(Win Rate, Turnover). 하나의 지표만 보면 판단이 왜곡된다. 세 축을 함께 봐야 전략의 실질적 가치를 평가할 수 있다.\n그리고 좋은 백테스트 결과도 다섯 가지 함정(Look-ahead Bias, Survivorship Bias, Overfitting, 거래비용 무시, 유동성 무시)에 오염되어 있을 수 있다. 결과를 읽는 것과 의심하는 것은 함께 가야 한다.\n다음 글에서는 개별 지표를 하나의 점수로 합치고 포트폴리오를 구성하는 방법, 팩터 스코어링과 리밸런싱을 정리하려 한다.\n참고 자료 Investopedia — Compound Annual Growth Rate (CAGR) Investopedia — Maximum Drawdown (MDD) Investopedia — Sharpe Ratio Investopedia — Overfitting Marcos López de Prado, Advances in Financial Machine Learning (2018) ","permalink":"https://wid-blog.github.io/posts/daily/investment/backtest-metrics/","summary":"전략의 성과를 측정하는 CAGR, MDD, Sharpe Ratio 등의 공식과 해석, 그리고 백테스트 결과를 의심해야 하는 다섯 가지 함정을 정리한다.","title":"백테스트 성과 지표"},{"content":"밸류에이션과 퀄리티가 \u0026ldquo;기업 자체\u0026quot;를 평가하는 지표라면, 모멘텀은 \u0026ldquo;시장의 흐름\u0026quot;을, 배당은 \u0026ldquo;현금 흐름\u0026quot;을 본다. 관점이 다른 두 팩터를 이해하면 종목 선별의 시야가 넓어진다.\n모멘텀 정의 모멘텀은 과거 일정 기간 동안의 수익률이다.\nN개월 모멘텀 = (현재가 - N개월 전 가격) / N개월 전 가격 핵심 원리는 단순하다. 오르는 주식이 계속 오르고, 내리는 주식이 계속 내린다. 이를 추세 추종이라 한다. 학술적으로도 검증된 현상이다. Jegadeesh와 Titman이 1993년 발표한 논문에서 과거 3~12개월 수익률이 높은 주식을 매수하고 낮은 주식을 매도하는 전략이 유의미한 초과 수익을 냈다는 것을 보였다.\n기간별 특성 모멘텀은 측정 기간에 따라 성격이 달라진다.\n1개월 모멘텀. 단기 모멘텀에서는 추세 추종보다 반전 효과가 나타나는 경우가 있다. 단기간에 급등한 주식이 과매수 상태에서 되돌아오거나, 급락한 주식이 과매도에서 반등하는 현상이다. 이 때문에 12개월 모멘텀을 사용할 때 최근 1개월을 제외하는 전략이 흔하다.\n3~6개월 모멘텀. 모멘텀 효과가 가장 강하게 나타나는 구간이다. 이 기간의 추세는 기업의 실적 발표, 애널리스트 보고서 등 펀더멘털 정보가 가격에 반영되는 속도와 관련이 있다.\n12개월 모멘텀. 장기 추세를 반영한다. 최근 1개월을 제외하고 계산하는 것이 일반적이다. 단기 반전 효과를 피하기 위해서다.\n모멘텀이 작동하는 이유 모멘텀이 존재하는 이유는 행동경제학에서 설명한다.\n과소 반응. 새로운 정보가 나와도 투자자들이 즉시 반응하지 않는다. 좋은 실적이 발표되어도 주가가 한 번에 반영하지 못하고 서서히 올라간다. 이 과정에서 추세가 형성된다.\n군중 심리. 주가가 오르면 더 많은 투자자가 매수에 참여한다. 이 양의 피드백 루프가 추세를 강화한다.\n확증 편향. 투자자들은 자신의 기존 판단을 지지하는 정보에 더 큰 가중치를 둔다. 상승 추세에서는 긍정적 뉴스에 더 반응하고, 하락 추세에서는 부정적 뉴스에 더 반응한다.\n주의사항 모멘텀 계산에서 가장 중요한 것은 Look-ahead Bias 방지다. 모멘텀 점수는 반드시 리밸런싱 시점 기준으로 계산해야 한다. 미래 데이터를 현재 시점에 사용하면 백테스트 결과가 현실보다 좋게 나온다.\n배당 배당수익률 배당수익률 = 연간 배당금 / 현재 주가 × 100% 배당수익률은 주가 대비 얼마나 배당을 받는지를 보여준다. 배당수익률 5%라면 주가 10,000원 기준으로 연간 500원의 배당을 받는다는 뜻이다.\n배당은 주가 상승과 별개로 확보할 수 있는 현금 흐름이다. 주가가 오르지 않아도 배당만으로 수익을 얻을 수 있다.\n고배당주의 특성 고배당주는 일반적으로 성숙한 기업이다. 사업이 안정 궤도에 올라 이익의 상당 부분을 주주에게 환원한다. 유틸리티, 통신, 금융 업종에서 흔하다.\n장점은 안정적인 현금 흐름이다. 시장이 하락하더라도 배당 수익이 손실을 일부 상쇄한다. 단점은 성장성이 낮을 수 있다는 점이다. 배당으로 나간 돈은 사업 재투자에 쓰이지 않기 때문이다.\n배당수익률이 높다고 항상 좋은 것은 아니다. 주가가 급락하면 배당수익률이 올라간다. 이 경우 높은 배당수익률은 기업의 위험 신호를 반영한 것일 수 있다. 배당의 지속 가능성을 함께 확인해야 한다.\n팩터 간 관계 모멘텀과 배당은 밸류에이션, 퀄리티와 함께 멀티팩터 전략의 구성 요소가 된다. 이들 사이에는 흥미로운 관계가 있다.\n모멘텀과 밸류는 자주 반대 방향을 가리킨다. 가치주(PER/PBR이 낮은 종목)는 주가가 하락한 결과일 수 있다. 하락한 주식은 모멘텀이 낮다. 반대로 모멘텀이 높은 종목은 주가가 올라 PER/PBR이 높아진 상태일 수 있다.\n배당과 퀄리티는 상관관계가 있다. 안정적으로 높은 배당을 지급하려면 꾸준한 이익이 필요하다. ROE가 높고 부채비율이 낮은 기업이 지속적으로 배당을 지급할 가능성이 높다.\n이런 팩터 간 상관관계 때문에 단일 팩터보다 여러 팩터를 결합한 멀티팩터 전략이 더 안정적인 성과를 보인다.\n모멘텀은 시장의 추세를 따르는 팩터이고, 배당은 기업의 현금 흐름을 확인하는 팩터다. 밸류에이션/퀄리티와 관점이 다르기 때문에 함께 사용하면 종목 선별의 다양성이 확보된다.\n다음 글에서는 이렇게 선별한 종목으로 구성한 전략의 성과를 어떻게 측정하는지, 백테스트 성과 지표를 정리하려 한다.\n참고 자료 Jegadeesh \u0026amp; Titman (1993), \u0026ldquo;Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency\u0026rdquo; Investopedia — Momentum Investing Investopedia — Dividend Yield AQR — Fact, Fiction and Momentum Investing ","permalink":"https://wid-blog.github.io/posts/daily/investment/momentum-dividend/","summary":"과거 수익률 추세(모멘텀)와 배당수익률이 팩터 투자에서 어떤 역할을 하는지 정리한다.","title":"모멘텀과 배당"},{"content":"주식이 \u0026ldquo;싸다\u0026quot;와 \u0026ldquo;좋다\u0026quot;는 다른 개념이다. PER이 낮아서 싸 보이는 주식이 실제로는 이익이 줄어드는 기업일 수 있다. ROE가 높아서 좋아 보이는 주식이 이미 과대평가 상태일 수 있다. 기업을 제대로 평가하려면 밸류에이션(싼가 비싼가)과 퀄리티(잘 버는가)를 함께 봐야 한다.\n밸류에이션 지표 밸류에이션 지표는 \u0026ldquo;이 주식이 현재 가격에 비해 싼가, 비싼가\u0026quot;를 판단한다. 공통적으로 낮을수록 저평가, 높을수록 고평가로 해석한다.\nPER PER(Price-to-Earnings Ratio, 주가수익비율)은 주가를 주당순이익(EPS)으로 나눈 값이다.\nPER = 주가 / 주당순이익(EPS) PER 10이라면 \u0026ldquo;현재 이익 수준이 유지된다고 가정할 때, 투자금을 회수하는 데 10년이 걸린다\u0026quot;는 의미다. 직관적이고 가장 널리 사용되는 밸류에이션 지표다.\nPER이 낮으면 이익 대비 저평가로 해석할 수 있다. 하지만 주의할 점이 있다.\n적자 기업의 음수 PER. 이익이 음수이면 PER도 음수가 된다. 음수 PER은 비교 지표로서 의미가 없다. 적자 기업은 PER 대신 PSR을 사용하는 것이 일반적이다.\n업종별 차이. IT 기업의 평균 PER은 2030배인 반면, 은행/유틸리티는 510배가 일반적이다. 성장 기대가 높은 업종은 미래 이익을 반영하여 PER이 높게 형성된다. 같은 PER이라도 업종에 따라 해석이 달라진다.\nPBR PBR(Price-to-Book Ratio, 주가순자산비율)은 주가를 주당순자산(BPS)으로 나눈 값이다.\nPBR = 주가 / 주당순자산(BPS) PBR은 기업의 자산 가치 대비 주가를 평가한다. PBR 1이면 주가와 순자산이 같다는 뜻이다. PBR이 1 미만이면 회사를 청산해도 주가보다 자산이 더 많다는 의미가 된다.\n벤저민 그레이엄은 PBR이 낮은 주식을 \u0026ldquo;안전마진이 있는 투자\u0026quot;라고 봤다. 가치 투자의 핵심 지표 중 하나다. 다만 PBR이 낮다고 항상 좋은 것은 아니다. 자산 가치가 실제보다 과대 계상된 경우(부실 자산, 감가상각 미반영 등)에는 PBR이 낮아도 실질적 안전마진이 없을 수 있다.\nPSR PSR(Price-to-Sales Ratio, 주가매출비율)은 주가를 주당매출액으로 나눈 값이다.\nPSR = 주가 / 주당매출액 PSR의 장점은 적자 기업에도 적용할 수 있다는 점이다. 매출은 거의 항상 양수이기 때문이다. 아직 이익을 내지 못하는 성장 초기 기업의 밸류에이션에 유용하다.\nPSR이 낮으면 매출 대비 주가가 저평가된 것으로 해석한다. 하지만 PSR은 이익률을 반영하지 않는다. 매출이 커도 이익을 내지 못하는 기업이라면 PSR만으로 평가하기 어렵다.\n퀄리티 지표 퀄리티 지표는 \u0026ldquo;이 기업이 돈을 잘 버는가, 재무적으로 안전한가\u0026quot;를 판단한다. 밸류에이션 지표와 달리 높을수록 좋은 것이 일반적이다(부채비율은 반대).\nROE ROE(Return on Equity, 자기자본이익률)는 순이익을 자기자본으로 나눈 비율이다.\nROE = 순이익 / 자기자본 × 100% 주주가 투자한 돈으로 얼마나 벌었는지를 보여준다. ROE 15%라면 자기자본 100억 원으로 15억 원의 순이익을 냈다는 뜻이다.\n워런 버핏은 ROE 15% 이상을 꾸준히 유지하는 기업을 우량 기업의 기준으로 삼는다고 알려져 있다. ROE가 높을수록 주주 자본 대비 수익성이 좋다.\n다만 ROE가 높은 이유가 부채 레버리지 때문일 수 있다. 자기자본이 적고 부채가 많으면 분모가 작아져 ROE가 높게 나온다. 이런 경우를 구분하기 위해 ROA와 함께 본다.\nROA ROA(Return on Assets, 총자산이익률)는 순이익을 총자산으로 나눈 비율이다.\nROA = 순이익 / 총자산 × 100% 회사의 전체 자산(자기자본 + 부채)을 얼마나 효율적으로 활용하는지 보여준다.\nROE와 ROA를 함께 보면 부채 레버리지 효과를 구분할 수 있다. ROE가 높은데 ROA가 낮다면 부채를 통해 수익률을 끌어올린 것이다. ROE와 ROA가 모두 높다면 자산 효율성이 좋은 기업이라 판단할 수 있다.\nROE가 높고 ROA도 높음 → 자산 효율성이 좋은 우량 기업 ROE가 높고 ROA가 낮음 → 부채 레버리지에 의존 ROE가 낮음 → 수익성 자체가 낮음 부채비율 부채비율은 총부채를 자기자본으로 나눈 비율이다.\n부채비율 = 총부채 / 자기자본 × 100% 낮을수록 안전하다. 부채비율 100%라면 자기자본과 부채가 같다는 뜻이다. 200%를 넘으면 일반적으로 위험 신호로 판단한다.\n부채 자체가 나쁜 것은 아니다. 적절한 부채는 사업 확장에 필요하다. 문제는 이자 비용을 감당할 수 없을 정도로 부채가 많거나, 경기 하강 시 부채 상환 부담이 커지는 경우다. 퀀트 전략에서는 부채비율을 안전성 팩터로 사용하여 과도한 부채를 가진 종목을 걸러낸다.\n두 축의 조합 밸류에이션과 퀄리티 지표를 각각 보는 것보다 함께 보는 것이 중요하다.\n낮은 PER + 높은 ROE. 이익 대비 주가가 싸면서 수익성도 좋다. 저평가 우량주의 이상적 조합이다. 하지만 이런 종목이 실제 시장에서 오래 남아있기는 어렵다. 시장이 빠르게 가격을 반영하기 때문이다.\n낮은 PER + 낮은 ROE. 싸지만 잘 벌지 못한다. \u0026ldquo;싼 데는 이유가 있는\u0026rdquo; 주식이다. 이를 가치 함정(Value Trap)이라 한다. PER만 보고 투자하면 이런 함정에 빠질 수 있다.\n높은 PER + 높은 ROE. 비싸지만 잘 번다. 성장주에서 자주 나타나는 조합이다. 미래 성장을 반영한 프리미엄인지, 과대평가인지를 판단해야 한다.\n지표 하나만 보면 판단을 잘못할 수 있다. 밸류에이션과 퀄리티를 함께 보는 것이 멀티팩터 전략의 출발점이다.\n밸류에이션 지표(PER, PBR, PSR)는 \u0026ldquo;주가가 싼가\u0026quot;를, 퀄리티 지표(ROE, ROA, 부채비율)는 \u0026ldquo;기업이 잘 버는가, 안전한가\u0026quot;를 판단한다. 이 두 축을 함께 봐야 종목을 제대로 평가할 수 있다.\n다음 글에서는 기업 자체가 아닌 시장의 흐름을 보는 모멘텀과, 현금 흐름을 확인하는 배당 지표를 정리하려 한다.\n참고 자료 Investopedia — Price-to-Earnings Ratio (P/E Ratio) Investopedia — Price-to-Book Ratio (P/B Ratio) Investopedia — Price-to-Sales Ratio (P/S Ratio) Investopedia — Return on Equity (ROE) Investopedia — Return on Assets (ROA) Investopedia — Debt-to-Equity Ratio Benjamin Graham, The Intelligent Investor (1949) ","permalink":"https://wid-blog.github.io/posts/daily/investment/valuation-quality-indicators/","summary":"기업이 싼지(밸류에이션)와 잘 버는지(퀄리티)를 판단하는 지표 — PER, PBR, PSR, ROE, ROA, 부채비율의 공식과 해석을 정리한다.","title":"밸류에이션과 퀄리티 지표"},{"content":"퀀트 투자는 데이터에서 출발한다. 종목을 분석하든, 전략을 백테스트하든, 가장 먼저 마주치는 것은 가격 데이터다. OHLCV가 무엇인지, 수익률을 어떻게 계산하는지, 시가총액이 왜 중요한지. 이후 모든 지표와 전략의 전제가 되는 기초 용어다.\nOHLCV OHLCV는 하루 동안의 가격 움직임을 다섯 가지 숫자로 요약한 것이다.\n약어 의미 설명 O(Open) 시가 장이 시작될 때의 가격 H(High) 고가 하루 중 가장 높았던 가격 L(Low) 저가 하루 중 가장 낮았던 가격 C(Close) 종가 장이 마감될 때의 가격 V(Volume) 거래량 하루 동안 거래된 주식 수 대부분의 퀀트 분석은 종가(Close)를 기준으로 한다. 종가는 하루의 최종 합의 가격이며, 수익률 계산, 이동평균, 기술 지표 등에서 기본 입력값으로 사용된다. 시가는 전일 종가와 차이가 있을 수 있는데, 이를 갭(Gap)이라 한다. 장 마감 후 뉴스나 해외 시장 변동이 반영되기 때문이다.\n거래량은 가격의 신뢰도를 보여준다. 가격이 올랐는데 거래량이 적다면 소수의 거래로 인한 일시적 변동일 수 있다. 반대로 거래량이 동반된 상승은 시장 참여자들의 합의가 반영된 움직임이라 판단할 수 있다.\nOHLCV 데이터는 Yahoo Finance, 한국투자증권 API, KRX 정보데이터시스템 등에서 수집한다.\n수익률 수익률은 투자 성과를 측정하는 가장 기본적인 지표다. 같은 수익률이라도 계산 방식에 따라 두 가지로 나뉜다.\n단순 수익률 단순 수익률 = (오늘 종가 - 어제 종가) / 어제 종가 직관적이다. 어제 10,000원이던 주식이 오늘 10,500원이 되었다면 수익률은 5%다. 하루 단위로 보면 정확하다.\n문제는 여러 기간의 수익률을 합산할 때 발생한다. 1일차에 +10%, 2일차에 -10%라고 하자. 단순히 더하면 0%지만, 실제로는 원금이 줄어든다.\n10,000원 × 1.10 = 11,000원 (1일차) 11,000원 × 0.90 = 9,900원 (2일차) 결과는 -1%다. 단순 수익률의 산술 합(+10% + (-10%) = 0%)은 실제 수익률과 다르다. 이 문제를 해결하는 것이 로그 수익률이다.\n로그 수익률 로그 수익률 = ln(오늘 종가 / 어제 종가) 자연로그를 사용한 수익률이다. 로그 수익률의 핵심 장점은 시간에 대해 합산 가능하다는 점이다.\n1일차 로그 수익률: ln(11,000 / 10,000) = 0.0953 2일차 로그 수익률: ln(9,900 / 11,000) = -0.1054 합계: 0.0953 + (-0.1054) = -0.0101 이 합계를 다시 실제 수익률로 변환하면 e^(-0.0101) - 1 ≈ -1.00%로, 실제 결과와 일치한다.\n퀀트 분석에서는 로그 수익률을 선호한다. 여러 기간의 수익률을 더하거나, 통계적 분석(정규분포 가정)을 적용할 때 수학적으로 다루기 쉽기 때문이다. 다만 일간 수익률이 작은 경우(±5% 이내) 두 수익률의 차이는 미미하다. 차이가 유의미해지는 것은 변동성이 큰 구간이나 장기 누적 수익률을 다룰 때다.\n시가총액 시가총액 = 현재 주가 × 발행 주식 수 시가총액은 시장이 해당 기업에 매기는 총 가치다. 주가만으로는 기업의 크기를 비교할 수 없다. 주가가 5,000원인 기업이 주가 500,000원인 기업보다 시가총액이 클 수 있다. 발행 주식 수가 다르기 때문이다.\n규모 분류 시가총액은 종목을 규모별로 분류하는 기준이 된다. 한국 시장 기준으로 한국거래소(KRX)는 KOSPI 200, KOSPI 중형주, KOSPI 소형주 등으로 구분한다. 일반적으로 사용되는 분류 기준은 다음과 같다.\n분류 한국 시장 기준 (대략) 대형주 시가총액 상위 100개 내외 (KOSPI 200 구성 종목) 중형주 대형주 아래, 상위 300개 내외 소형주 그 이하 미국 시장에서는 대형주를 시가총액 100억 달러 이상, 소형주를 20억 달러 미만으로 분류하는 것이 일반적이다.\n스크리닝에서의 역할 퀀트 전략에서 시가총액은 종목 필터링의 첫 관문이다. 소형주는 거래량이 적어 실제로 매수/매도가 어려울 수 있고, 가격 변동성이 크다. 백테스트에서는 좋은 성과를 보여도 실전에서 재현하기 어려운 경우가 많다. 이런 이유로 최소 시가총액 기준을 설정하여 소형주를 제외하는 것이 일반적이다.\nOHLCV는 가격 데이터의 기본 단위이고, 수익률은 성과를 측정하는 언어이며, 시가총액은 종목의 규모를 판단하는 기준이다. 이 세 가지가 이후 밸류에이션, 퀄리티, 모멘텀 지표를 이해하기 위한 전제가 된다.\n다음 글에서는 기업의 가치와 질을 숫자로 판단하는 밸류에이션과 퀄리티 지표를 정리하려 한다.\n참고 자료 Investopedia — Open-High-Low-Close (OHLC) Investopedia — Rate of Return Investopedia — Market Capitalization Investopedia — Log-Normal Distribution and Logarithmic Returns KRX 정보데이터시스템 ","permalink":"https://wid-blog.github.io/posts/daily/investment/stock-data-basics/","summary":"퀀트 투자의 출발점인 OHLCV 데이터, 단순 수익률과 로그 수익률의 차이, 시가총액의 의미를 정리한다.","title":"주식 데이터 기초"},{"content":"운영 중인 서비스에서 데이터 형식을 바꿔야 하는 상황은 자주 발생한다. 컬럼 암호화, 타입 변경, JSON 스키마 수정, 정규화/비정규화. 서비스를 멈추고 한 번에 전환하는 빅뱅 방식은 위험하다. 전환 중 문제가 생기면 서비스 전체가 멈춘다.\n이중 쓰기(dual write)와 fallback 읽기를 조합하면 서비스 중단 없이 데이터 형식을 전환할 수 있다. 각 단계에서 롤백 가능한 상태를 유지하는 것이 핵심이다.\n이중 쓰기 + fallback 읽기 이중 쓰기는 데이터를 저장할 때 기존 형식과 새 형식 양쪽에 기록한다. 전환 기간 동안 같은 데이터가 양쪽에 공존한다.\nfallback 읽기는 데이터를 읽을 때 새 형식에 값이 있으면 그 값을 쓰고, 없으면 기존 형식에서 가져온다. 아직 새 형식으로 변환되지 않은 데이터도 정상적으로 읽힌다.\nflowchart TD W[\"쓰기\"] --\u003e W1[\"기존 형식에 저장\"] W --\u003e W2[\"새 형식에 저장\"] R[\"읽기\"] --\u003e C{\"새 형식에값이 있는가?\"} C --\u003e|\"있다\"| D[\"새 형식 사용\"] C --\u003e|\"없다\"| E[\"기존 형식 사용\"] 이 두 가지를 조합하면 기존 데이터와 신규 데이터가 공존하는 전환 기간을 만들 수 있다.\n3단계 프로세스 전환은 세 단계로 나뉜다. 각 단계는 이전 단계가 배포된 뒤에 진행한다.\nflowchart LR S1[\"Step 1: 준비새 형식 추가이중 쓰기 시작fallback 읽기 적용\"] S2[\"Step 2: 마이그레이션기존 데이터를새 형식으로 변환dry-run → 실행\"] S3[\"Step 3: 정리fallback 제거기존 형식 삭제\"] S1 -- \"배포 완료\" --\u003e S2 -- \"검증 완료\" --\u003e S3 Step 1: 준비 새 형식을 추가하고 코드를 변경한다.\n스키마 변경: 새 컬럼이나 필드를 추가한다. 초기에는 nullable로 생성한다. 기존 데이터는 아직 새 형식이 없으므로 null이어야 한다. 이중 쓰기 적용: INSERT와 UPDATE에서 기존 형식과 새 형식 양쪽에 값을 기록한다. fallback 읽기 적용: SELECT에서 새 형식에 값이 있으면 사용하고, 없으면 기존 형식의 값을 반환한다. 이 단계가 배포되면 신규 데이터는 양쪽에 동시 저장된다. 기존 데이터는 여전히 기존 형식에만 존재하고, fallback 읽기가 이를 처리한다.\n롤백: 새 형식을 무시하면 기존 동작 그대로다. 코드 변경만 되돌리면 된다.\nStep 2: 마이그레이션 기존 데이터를 새 형식으로 일괄 변환한다.\n배치 스크립트를 작성해서 실행한다. 새 형식이 비어 있는 행을 찾아 기존 형식의 값을 변환하고 새 형식에 저장한다.\ndry-run을 먼저 실행한다. 대상 건수와 예상 소요 시간을 확인한다. 대량 데이터라면 배치 크기를 조절해서 DB 부하를 관리한다.\n실행 후에는 검증이 필요하다. 새 형식의 값이 기존 형식과 일치하는지 확인한다. 전체 행 수도 대조한다. 검증이 마이그레이션보다 더 많은 시간을 차지하는 경우가 많다.\n롤백: Step 1의 fallback 읽기가 살아 있으므로, 새 형식에 문제가 있어도 기존 형식으로 자동 전환된다.\nStep 3: 정리 마이그레이션이 완료되고 검증을 통과한 뒤, 기존 형식과 fallback 로직을 제거한다.\n데이터 검증: 새 형식에 null이나 빈 값이 없는지 한 번 더 확인한다. Step 2 이후 신규 데이터도 빠짐없이 새 형식으로 저장되었는지 점검한다. 코드 정리: 이중 쓰기를 제거하고 fallback 분기를 제거한다. 새 형식만 사용하도록 단일화한다. 스키마 정리: 기존 형식의 컬럼이나 필드를 삭제한다. 롤백 불가: 기존 형식을 삭제하면 원본 데이터가 사라진다. 검증이 철저해야 하는 이유다.\n적용 사례 이 패턴은 DB 컬럼 암호화에만 국한되지 않는다. 데이터 형식이 바뀌고, 서비스를 멈출 수 없는 상황이라면 동일한 구조가 적용된다.\n컬럼 암호화 전환. 평문 컬럼 옆에 암호화 컬럼을 추가하고, 이중 쓰기로 양쪽에 저장한다. 기존 평문을 일괄 암호화한 뒤 평문 컬럼을 삭제한다.\n컬럼 타입 변경. varchar(100) → text, int → bigint 같은 변경도 같은 패턴이다. 새 타입 컬럼을 추가하고, 이중 쓰기 + fallback으로 전환한 뒤 기존 컬럼을 삭제한다.\nJSON 스키마 변경. JSON 컬럼의 키 이름을 바꾸거나 구조를 변경할 때, 기존 구조와 새 구조를 동시에 지원하는 전환 기간을 만든다.\n데이터 정규화/비정규화. 조인을 줄이기 위해 비정규화 컬럼을 추가하거나, 반대로 정규화를 위해 별도 테이블로 분리할 때도 이중 쓰기 + fallback 구조가 유효하다.\n패턴의 비용 이 패턴은 안전성을 제공하지만 비용이 따른다.\n전환 기간 동안 코드 복잡도가 올라간다. 이중 쓰기와 fallback 분기가 서비스 코드 곳곳에 추가된다. 이 코드는 Step 3에서 제거되지만, 전환 기간 동안은 리뷰와 유지보수 부담이 늘어난다.\n전환 대상이 여러 개라면 이 비용이 반복된다. 테이블이 10개면 같은 패턴을 10번 적용해야 한다. 구조가 동일한 반복 작업이므로 자동화를 고려할 수 있는 지점이다.\n이 패턴이 적합한 조건은 명확하다. 복수의 서비스가 같은 데이터를 참조하고, 대량의 데이터가 있으며, 서비스 중단이 허용되지 않는 경우. 이 조건이 아니라면 점검 시간을 잡고 한 번에 전환하는 것이 더 단순할 수 있다.\n참고 봉투 암호화 — 컬럼 암호화 전환에 함께 적용되는 키 관리 구조를 다룬다. ","permalink":"https://wid-blog.github.io/posts/tech/architecture/zero-downtime-data-transition/","summary":"이중 쓰기와 fallback 읽기를 조합하여 운영 중인 서비스의 데이터 형식을 무중단으로 전환하는 3단계 패턴을 정리한다.","title":"무중단 데이터 전환 패턴"},{"content":"DB에 저장된 민감 정보를 암호화할 때, 가장 단순한 방법은 하나의 키로 모든 데이터를 직접 암호화하는 것이다. 그런데 이 키가 유출되면 전체 데이터가 노출된다. 키를 교체하려면 모든 데이터를 다시 암호화해야 한다.\n봉투 암호화는 이 문제를 \u0026ldquo;키를 암호화하는 키\u0026quot;와 \u0026ldquo;데이터를 암호화하는 키\u0026quot;를 분리하는 구조로 해결한다.\n대칭키 암호화 DB 컬럼 암호화처럼 쓰기마다 암호화, 읽기마다 복호화가 필요한 경우에는 대칭키 방식이 적합하다. 하나의 키로 암호화와 복호화를 모두 처리하므로 연산 비용이 낮다.\n비대칭키 방식은 공개키/비밀키 쌍을 사용한다. 키 교환이나 전자서명에는 유용하지만, 대칭키 대비 연산 비용이 높고 키 쌍 관리가 복잡하다. 빈번한 암/복호화가 필요한 DB 컬럼 암호화에는 과하다.\nAES-256-GCM 대칭키 알고리즘 중에서 AES-256-GCM이 자주 선택되는 이유는 보안, 무결성, 성능을 함께 충족하기 때문이다.\n먼저 키 길이를 보면, AES-256은 256비트 키로 무차별 대입 공격에 대한 충분한 내성을 확보한다. 현재 가장 널리 검증된 대칭키 알고리즘이기도 하다.\n운용 모드인 GCM(Galois/Counter Mode)은 다른 모드, 예를 들어 CBC와 비교했을 때 두 가지 장점을 갖는다.\n첫째, 무결성 보장이다. GCM은 암호화와 동시에 인증 태그를 생성한다. 복호화 시점에 암호문이 변조되었는지 감지할 수 있다. CBC 모드에서는 이를 위해 별도의 HMAC 처리가 필요한데, GCM은 하나의 연산으로 해결한다.\n둘째, IV(초기화 벡터)를 사용해 같은 평문이라도 매번 다른 암호문을 생성한다. IV는 암호문과 함께 저장되며, 복호화 시 동일한 IV를 사용해야 원본을 복원할 수 있다.\n다만 GCM에는 한 가지 함정이 있다. 같은 키와 같은 IV로 두 번 암호화하면 보안이 완전히 무너진다. IV는 매 암호화마다 유일해야 하며, 보통 안전한 난수 생성기로 만들거나 카운터 기반으로 관리한다.\nCMK/DEK 2중 키 구조 봉투 암호화의 핵심은 키를 두 계층으로 나누는 것이다.\nflowchart LR CMK[\"CMK(마스터 키)\"] --\u003e|\"DEK 암호화/복호화\"| DEK[\"DEK(데이터 암호화 키)\"] DEK --\u003e|\"데이터 암호화/복호화\"| DATA[\"평문 데이터\"] 최상위에 있는 CMK(Customer Master Key)는 DEK를 암호화하는 용도로만 쓰이고, 데이터에는 직접 닿지 않는다. 일반적으로 HSM(하드웨어 보안 모듈) 안에 보관되어 외부로 추출할 수 없다.\n실제 데이터는 그 아래 계층인 DEK(Data Encryption Key)가 담당한다. 평문 DEK로 데이터를 암호화한 뒤, CMK로 DEK 자체를 다시 암호화해 저장한다.\n암호화 흐름 DEK는 처음 한 번 키 관리 서비스에서 발급받는다. 이때 평문 DEK와 암호화된 DEK를 동시에 받아, 평문 DEK로는 데이터를 즉시 암호화하고 암호화된 DEK는 데이터와 함께 DB에 저장한다.\nsequenceDiagram participant App as 서비스 participant KMS as 키 관리 서비스 participant DB as DB App-\u003e\u003eKMS: 새 DEK 발급 요청 (CMK 지정) KMS--\u003e\u003eApp: 평문 DEK + 암호화된 DEK 반환 App-\u003e\u003eApp: 평문 DEK + IV로 데이터 암호화 App-\u003e\u003eDB: 암호문 + IV + 암호화된 DEK 저장 Note over App: 평문 DEK 즉시 폐기 읽을 때는 DB에서 암호문, IV, 암호화된 DEK를 가져온 뒤, 키 관리 서비스에 암호화된 DEK의 복호화를 요청한다. 반환된 평문 DEK와 IV로 암호문을 복호화한다. 평문 DEK는 메모리에서만 잠시 사용하고 즉시 폐기한다.\n단일 키 방식과의 비교 단일 키 방식에서는 마스터 키가 유출되면 모든 데이터가 위험에 노출된다. 봉투 암호화에서는 DEK가 유출되어도 해당 DEK로 암호화된 데이터만 영향을 받는다. CMK는 HSM 안에 있으므로 유출 가능성 자체가 낮다.\n키 회전 봉투 암호화의 실질적 장점은 키 회전에서 드러난다.\n단일 키 방식에서 키를 교체하려면 모든 데이터를 새 키로 다시 암호화해야 한다. 데이터 양이 많으면 시간과 비용이 크게 늘어난다.\n봉투 암호화에서는 CMK를 교체할 때 DEK만 다시 암호화하면 된다. 데이터 자체는 여전히 같은 DEK로 암호화되어 있으므로 다시 암호화할 필요가 없다. DEK의 크기는 데이터 대비 극히 작으므로 키 회전 비용이 낮다.\nflowchart LR subgraph Before [\"키 회전 전\"] CMK1[\"CMK v1\"] --\u003e DEK_ENC1[\"암호화된 DEK\"] end subgraph After [\"키 회전 후\"] CMK2[\"CMK v2\"] --\u003e DEK_ENC2[\"암호화된 DEK(재암호화)\"] end DEK_ENC1 -.-\u003e|\"DEK 재암호화만 수행데이터는 그대로\"| DEK_ENC2 내부자 위협에 대응하려면 정기적인 키 회전이 필수적이다. 봉투 암호화는 이 비용을 최소화한다.\n클라우드 키 관리 서비스 활용 봉투 암호화를 직접 구현할 수도 있지만, 클라우드 키 관리 서비스를 활용하는 것이 일반적이다. 서비스 선정 시 고려할 기준은 다음과 같다.\nHSM 기반 키 보관: CMK가 소프트웨어가 아닌 하드웨어 안에서만 존재하는지. HSM 기반이면 키 추출 자체가 불가능하다. 자동 키 회전: CMK 회전을 자동으로 지원하는지. 수동 회전은 운영 부담이 크다. 컨테이너 환경 호환: K8S 같은 컨테이너 환경에서 API 호출 없이 키를 주입할 수 있는지. 서비스 코드의 복잡도에 영향을 준다. 키 발급을 담당하는 서비스와 암호화된 키를 저장하는 서비스를 분리하는 구성도 자주 사용된다. 단일 서비스의 침해가 전체 키에 영향을 주는 경로를 줄일 수 있고, 권한 범위를 좁히는 보안 원칙(least privilege)에도 부합한다.\n봉투 암호화가 적합한 경우 봉투 암호화는 다음 조건에서 효과적이다.\n암호화 대상 데이터가 대량이고 키 회전이 정기적으로 필요한 경우 여러 서비스가 같은 암호화 키를 공유해야 하는 경우 컴플라이언스 요구로 키 관리 감사 추적이 필요한 경우 반면 소규모 데이터를 일회성으로 암호화하는 경우에는 단일 키 방식으로도 충분하다. 봉투 암호화는 키 관리 복잡도를 추가하는 만큼, 그 복잡도를 정당화할 규모와 요구가 있을 때 선택하는 것이 맞다.\n참고 무중단 데이터 전환 패턴 — 컬럼 암호화 전환처럼 데이터 형식을 무중단으로 바꾸는 패턴을 다룬다. ","permalink":"https://wid-blog.github.io/posts/tech/security/envelope-encryption/","summary":"봉투 암호화의 CMK/DEK 2중 키 구조가 키 유출 영향을 제한하고 키 회전을 단순화하는 원리를 정리한다.","title":"봉투 암호화"},{"content":"작년 말, 광고 Fallback의 성과를 개선하기 위해 CTR 예측 모델을 도입하는 이야기가 나왔다.\nFallback은 primary 광고 시스템이 내보낼 광고가 없다고 판단한 경우에 동작한다. 광고 슬롯 대비 노출 비율(Fillrate)을 끌어올리는 것이 목적이다.\n나는 백엔드 엔지니어였다. AI 배경은 없었다.\n모델 자체보다 주변 시스템을 엮는 작업이 더 많을 거라는 판단이 있었고, 그래서 내가 맡게 됐다.\n모델 선택: Logistic Regression 모델은 Logistic Regression으로 정했다.\n광고 CTR 개선이다보니 클릭 여부를 학습시키면 됐다. 이항 분류 문제로 풀 수 있었다.\n광고 플랫폼에서는 LR과 LightGBM이 일반적으로 쓰인다. 하지만 이 프로젝트는 초기 버전이었고, 복잡한 튜닝과 운영 부담을 처음부터 짊어지고 싶지 않았다.\n그래서 더 단순한 LR을 택했다.\n언어 및 프레임워크 선택 Python과 sklearn으로 정했다. 학습 배치와 인퍼런스 서버 모두.\n처음에는 ONNX + Go를 생각했다. 새 프로젝트라면 Go로 시작해볼 수 있을 것 같았다. 인퍼런스는 ONNX로 빼면 프레임워크 독립성과 성능 이점을 함께 얻는다.\n그런데 사내 ML 운영 환경이 Python 중심이었다. 참고할 사례, 공유할 코드, 배포 패턴이 전부 Python이었다. 조언과 리뷰가 필요한 상황에서는 같은 언어가 맞겠다고 생각했다. 성능 이점은 뒤로 미루고 운영 연속성을 택했다.\n프레임워크도 비슷한 논리였다. ONNX가 sklearn보다 인퍼런스 성능이 낫다는 건 알고 있었지만, LR 같은 경량 모델에서는 그 이득이 크지 않을 거라고 봤다. sklearn만으로 학습과 저장이 충분하다고 생각했고, 가벼운 모델에 무거운 파이프라인을 얹는 것은 과잉 설계라고 판단했다.\nML Lifecycle 아키텍처 ML Lifecycle을 세 개의 컴포넌트로 분리했다.\n학습 배치: 주기적으로 LR 모델을 학습하고, 학습된 모델을 모델 저장소에 push한다. 모델 저장소: MLflow 기반. 학습 배치가 쓴 모델을 버전별로 보관한다. 인퍼런스 서버: 모델 저장소에서 최신 모델을 로드하고, 실시간 predict를 제공한다. flowchart LR A[\"학습 배치\"] --\u003e|\"① 모델 push② champion alias 이동\"| B[\"모델 저장소(MLflow)\"] A --\u003e|\"③ 배포 트리거\"| C[\"인퍼런스 서버\"] B -.-\u003e|\"④ POD 기동 시 champion 로드\"| C 흐름은 단순하다. 학습 배치 → 모델 저장소 → 인퍼런스 서버. 세 컴포넌트는 모델 파일을 통해서만 연결되고, 학습 주기와 인퍼런스는 서로 독립적으로 동작한다.\n학습 배치 내부와 Promotion Gate 학습 배치 안쪽은 그냥 \u0026ldquo;학습 → 저장\u0026quot;이 아니었다. 학습이 끝난 모델이 자동으로 배포되지 않고, Promotion Gate라는 품질 검증 단계를 통과해야 champion alias가 교체된다.\nflowchart LR A[\"데이터 로딩\"] --\u003e B[\"전처리\"] --\u003e C[\"학습\"] --\u003e D[\"평가\"] --\u003e E{\"Promotion Gate\"} E --\u003e|\"PASS\"| F[\"champion alias 교체+ Rollout 트리거\"] E --\u003e|\"FAIL\"| G[\"기존 champion 유지\"] 기준은 단순했다. 학습된 모델의 평가 지표가 미리 정한 임계값을 넘으면 PASS, 그렇지 않으면 FAIL. PASS면 champion alias를 새 버전으로 옮기고 배포를 트리거한다. FAIL이면 새 모델은 registry에 기록만 남기고 기존 champion이 계속 서비스된다.\n이 덕분에 성능이 떨어진 모델이 프로덕션에 실수로 나가는 상황을 코드 변경 없이 막을 수 있었다.\n배포 새 모델을 인퍼런스 서버에 반영하는 과정에서는 k8s 기반 롤링 배포를 활용했다.\nMLflow의 alias는 모델 버전에 \u0026ldquo;champion\u0026rdquo; 같은 별칭을 붙여 현재 production 모델을 가리키는 기능이다. 학습 배치는 Promotion Gate에서 PASS를 받으면 champion alias를 새 버전으로 옮기고 배포를 트리거한다. 인퍼런스 서버 POD가 순차 교체되고, 새 POD는 기동 시 champion alias가 달린 모델을 로드해서 서비스에 들어간다.\n회고 LR + sklearn + MLflow 조합은 단순했지만 가볍고 빠르게 돌아갔다.\n가장 아쉬웠던 것은 Python + sklearn을 택한 결정이었다. Feature가 늘어나면서 인퍼런스 비용이 올라갔고, 필요한 자원도 함께 늘어났다. ONNX + Go 조합으로 멀티 코어를 활용했다면 같은 부하를 더 적은 자원으로 처리할 수 있었을지 모른다. 당시에는 운영 연속성을 택하는 게 맞다고 판단했지만, 그 결정의 비용이 운영 단계에서 드러났다.\n시작할 때 가장 큰 걱정은 \u0026ldquo;AI 배경이 없는데 할 수 있을까\u0026quot;였다. 끝나고 나니 필요한 건 조금 달랐다는 걸 알게 됐다. 중요했던 건 ML 알고리즘이나 인프라 전문성이 아니라, 도메인을 얼마나 정확히 이해하고 있는가, 그리고 그에 맞춰 어떤 feature를 어떻게 조합할지 판단하는 능력이었다. 데이터를 읽고 패턴을 찾는 분석 능력도 그만큼 필요했다.\n참고 Logistic Regression 다시 보기 모델 학습 프레임워크 고르기: sklearn vs ONNX ML Lifecycle의 틈새를 메우는 MLflow ","permalink":"https://wid-blog.github.io/posts/career/dable/dsp-fallback-ctr-ml-lifecycle/","summary":"AI 배경이 없는 백엔드 엔지니어로서 광고 Fallback CTR을 위한 첫 ML Lifecycle 3단 구조를 만들며 내린 기술 결정들과, 운영 끝에 배운 것들.","title":"LR 기반 ML Lifecycle 회고"},{"content":"MLflow는 ML Lifecycle의 experiment와 model 사이 경계를 다루는 도구다. experiment 쪽에서는 \u0026ldquo;어떤 파라미터로 무엇을 학습했는가\u0026quot;를, model 쪽에서는 \u0026ldquo;어느 버전이 지금 production을 가리키는가\u0026quot;를 기록한다. 그 경계는 대형 ML 팀에만 있는 것이 아니다. 가벼운 Logistic Regression 모델 하나를 운영할 때도 똑같이 나타난다.\nML Lifecycle ML 프로젝트는 대체로 네 단계로 움직인다.\nExperiment — 데이터를 보고, 파라미터를 바꿔가며 모델을 학습해본다. 메트릭을 기록하고 돌아가서 다시 실행한다. Model — 쓸 만한 결과가 나오면 그 모델을 \u0026ldquo;이것이 지금 우리의 모델\u0026quot;이라고 선언한다. 버전과 lineage가 붙는다. Deployment — 그 모델을 서빙 환경에 배치한다. 롤아웃, 롤백, 트래픽 전환 같은 문제가 여기서 발생한다. Monitoring — 서빙 중인 모델의 드리프트와 성능 저하를 감시한다. 각 단계는 고유한 문제를 가진다. experiment는 \u0026ldquo;무엇을 해봤는지 기억하는 것\u0026quot;이 어렵고, model은 \u0026ldquo;지금 어느 것이 진짜인지\u0026rdquo; 합의하는 것이 어렵다. deployment는 \u0026ldquo;바꿔 끼우는 것\u0026quot;이 어렵고, monitoring은 \u0026ldquo;언제 다시 학습시켜야 하는지\u0026rdquo; 판단하는 것이 어렵다.\nMLflow는 이 중 앞의 두 칸을 주로 메운다. deployment와 monitoring 영역에도 걸쳐 있지만, 중심축은 experiment와 model이다.\n파일 이름 버전 관리 처음에는 간단하다. model.pkl을 S3에 올리고, 인퍼런스 서버가 그걸 읽는다. 학습이 끝날 때마다 덮어쓰면 된다.\n그러다 한 번 롤백이 필요해진다. 어제 버전으로 돌아가야 하는데 파일은 이미 덮어써졌다. 그래서 model_v2.pkl, model_v3.pkl로 나누기 시작한다. 얼마 안 가 model_v3_final.pkl이 등장한다. 그다음은 model_v3_final_really.pkl이다.\n이 이름들이 해결하지 못하는 것이 세 가지 있다.\nLineage — model_v3_final.pkl이 어떤 코드로, 어떤 데이터로, 어떤 파라미터로 학습됐는지 추적할 방법이 없다. 재현하려 해도 같은 결과가 나오지 않는다. Alias — \u0026ldquo;지금 production이 가리키는 모델\u0026quot;을 코드 외부의 문자열 규칙으로 관리하게 된다. 인퍼런스 서버가 latest.pkl을 읽도록 할지, 환경 변수로 버전을 주입할지, 매번 결정해야 한다. 재현성 — 몇 달 뒤에 같은 실험을 실행하고 싶은데, 그때의 파라미터와 코드를 모은 기록이 어디에도 없다. 이 세 가지를 풀려면 결국 \u0026ldquo;파일 이름\u0026rdquo; 레이어 위에 메타데이터 레이어 하나가 필요해진다. MLflow는 이를 다룬다.\nMLflow 컴포넌트 MLflow는 서로 독립적인 네 개의 컴포넌트로 구성된다. 전부 하나의 패키지 안에 있지만, 쓰는 쪽에서 골라 쓸 수 있다.\nTracking 학습 한 번을 run이라는 단위로 기록한다. 파라미터, 메트릭, 그리고 학습 결과물(모델 파일, 플롯, 로그)을 run에 묶어둔다. 여러 run은 experiment라는 이름으로 묶인다.\nimport mlflow with mlflow.start_run(): mlflow.log_param(\u0026#34;C\u0026#34;, 0.1) mlflow.log_metric(\u0026#34;val_auc\u0026#34;, 0.782) mlflow.sklearn.log_model(model, \u0026#34;model\u0026#34;) 이 한 덩어리가 lineage의 씨앗이다. 몇 달 뒤에 \u0026ldquo;그때 val_auc가 0.78이었는데 파라미터가 뭐였지?\u0026ldquo;를 물어볼 수 있는 기록이 된다.\nModel Registry Tracking이 \u0026ldquo;어떻게 학습했는가\u0026quot;를 기록한다면, Registry는 \u0026ldquo;어떤 결과물을 우리 것으로 선언할 것인가\u0026quot;를 기록한다. 학습 결과물 중 하나를 registered model로 승격시키면 버전이 붙는다. v1, v2, v3가 자동으로 쌓인다.\n그리고 그 버전들 위에 alias를 붙일 수 있다. champion이라는 alias는 특정 버전을 가리키는 mutable reference다. 새 버전이 검증을 통과하면 champion alias를 옮긴다. 코드를 바꾸지 않고, 이름 규칙을 바꾸지 않고, alias 하나만 이동시키는 것으로 \u0026ldquo;production이 가리키는 모델\u0026quot;이 교체된다.\nmlflow.register_model(\u0026#34;runs:/\u0026lt;run-id\u0026gt;/model\u0026#34;, name=\u0026#34;ctr-model\u0026#34;) client.set_registered_model_alias(\u0026#34;ctr-model\u0026#34;, \u0026#34;champion\u0026#34;, version=7) Registry는 앞서 말한 model_v3_final.pkl 문제를 모두 치운다. lineage는 run과 자동 연결되고, alias는 이름 규칙을 대체하고, 재현은 run id로 가능해진다.\n중요한 제약이 하나 있다. Registry를 쓰려면 DB backend가 필수다. 파일 스토리지(./mlruns)만으로는 registry API가 동작하지 않는다. 가볍게 시작하고 싶어도 PostgreSQL이나 MySQL, 최소한 SQLite 하나는 띄워야 한다. MLflow 3.7.0부터 default backend가 SQLite로 바뀌어서 처음 진입 장벽이 조금 낮아졌다.\nModels \u0026ldquo;모델 파일\u0026quot;이 무엇인지 표준화하는 조각이다. sklearn, pytorch, xgboost 같은 프레임워크마다 flavor가 있고, 같은 모델을 여러 flavor로 저장할 수 있다. 저장된 모델은 로드할 때 원래 프레임워크 코드 없이도 불러올 수 있다.\nModels는 experiment와 deployment 사이를 이어주는 포터빌리티 계층이다. Tracking/Registry가 \u0026ldquo;어떤 모델이냐\u0026quot;를 다룬다면, Models는 \u0026ldquo;그 모델을 어떻게 직렬화하느냐\u0026quot;를 다룬다.\nProjects MLproject 파일과 conda/docker 설정을 묶어서 \u0026ldquo;누가 돌려도 같은 환경\u0026quot;을 만든다. mlflow run .으로 실행하면 환경이 세팅되고 학습이 실행된다.\n네 조각 중 가장 덜 쓰이는 편이다. 이미 내부에 배치 실행 표준이 있는 팀은 Projects를 덮어쓰지 않고 자기 표준을 유지한다.\nLifecycle 매핑 flowchart LR E[Experiment] --\u003e|\"Tracking(run, param, metric)\"| M[Model] M --\u003e|\"Registry(version, alias)\"| D[Deployment] M -.-\u003e|\"Models(flavor)\"| D P[Projects] -.-\u003e|\"실행 환경\"| E D --\u003e Mo[Monitoring] Tracking: experiment 단계 내부 Registry: experiment와 deployment 사이의 model 칸 Models: model에서 deployment로 넘어가는 포터빌리티 축 Projects: experiment 칸의 재현성 계층 (선택) monitoring은 MLflow가 직접 담당하지 않는다. 별도 도구가 필요하다. 이 그림이 MLflow의 범위를 가장 간결하게 보여준다. 네 조각이 각자 역할을 맡고, 어느 칸을 채울지는 프로젝트가 정한다.\nTracking 과 Registry 선택 경량 LR 모델을 production에 운영하는 시나리오를 가정해보자. 네 조각 중 자주 마주치는 조합은 둘이다. Tracking과 Registry.\nTracking이 필요한 이유. 학습 배치가 매 주기 LR을 다시 실행하면, 그때마다 파라미터와 validation 메트릭이 달라진다. 어느 run이 어떤 숫자를 냈는지 나중에 추적해야 한다. 파일 이름으로 관리할 수 있는 수준의 기록이 아니다. Tracking이 메워주는 자리가 바로 이 지점이다.\nRegistry가 필요한 이유. 학습 배치가 만든 모델 중 검증 단계를 통과한 것만 \u0026ldquo;champion\u0026quot;으로 승격해야 한다. 인퍼런스 서버는 그 champion을 로드한다. 이걸 파일 규칙으로 하면 서버가 latest.pkl을 polling하게 되고, 검증이 안 끝난 모델이 먼저 올라가는 race가 생긴다. alias를 쓰면 그 race가 사라진다. 배포의 방아쇠를 당기는 주체와 배포되는 객체가 깔끔히 분리된다.\nalias가 움직이는 것과 실제 인퍼런스 서버 교체는 별개의 사건이다. champion alias가 옮겨진 뒤 배포 도구(예: Argo Rollouts)가 POD 교체를 트리거한다. Rollouts가 새 POD를 띄우면, 그 POD는 기동 시 champion alias가 가리키는 모델을 로드해서 서비스에 투입된다. MLflow는 \u0026ldquo;어느 것이 champion인가\u0026quot;까지만 말하고, \u0026ldquo;어떻게 서비스에 배치할 것인가\u0026quot;는 배포 도구의 몫이다.\n이 분리가 핵심이다. MLflow가 모든 것을 할 필요는 없다. 경계만 메우면 된다.\n사용하지 않는 컴포넌트 Models 포맷은 Tracking에 모델을 로깅할 때 자동으로 따라온다. 명시적으로 고르는 조각은 아니지만 혜택은 받는다. Registry에서 runs:/\u0026lt;id\u0026gt;/model URI로 꺼낼 수 있게 되는 것이 이 포맷 덕분이다.\nProjects는 잘 쓰이지 않는다. 팀이 이미 안정적인 배치 실행 표준을 가지고 있다면, 그 위에 MLproject 레이어를 추가하는 것은 중복이다. 한 배치가 한 프레임워크 안에서 실행되면 Projects의 재현성 이득은 크지 않다.\nServing도 선택적이다. MLflow는 자체 서빙 엔드포인트(mlflow models serve)를 제공하지만, LR 같은 경량 모델의 인퍼런스는 기존 서버에서 sklearn으로 직접 처리하는 쪽이 더 가볍고, 기존 인프라에 통합하기도 쉽다. 서빙 레이어를 MLflow에 위임할 이유가 없는 경우가 많다.\n네 조각 중 두 개만 쓴다고 해서 MLflow를 \u0026ldquo;반만 쓴\u0026rdquo; 것은 아니다. 메워야 할 경계만 메우고 나머지는 다른 도구에 맡기는 것이 이 도구의 정석적인 사용 방식에 가깝다.\n맺음 경계에 부딪힌다고 했다. 그 경계는 파일 이름이 설명하지 못하는 meta 정보(언제, 어떻게, 무엇으로, 지금 어느 것이 진짜인가)가 쌓이기 시작하는 지점이다. MLflow는 그 지점에 놓이는 가벼운 메타데이터 레이어다. 얼마나 가볍게 쓸지는 프로젝트가 정한다.\n대형 ML 팀에만 있는 도구가 아니다. LR 하나를 운영하더라도 같은 경계는 찾아온다. 그때 필요한 칸만 골라 채우면 된다.\n","permalink":"https://wid-blog.github.io/posts/tech/ml/mlflow/","summary":"MLflow의 네 조각이 ML Lifecycle의 어느 칸을 메우는지, 그리고 경량 팀이 그 중 어떤 조각을 고를 수 있는지.","title":"MLflow 와 ML Lifecycle"},{"content":"Circuit Breaker 는 실패한 의존성으로 가는 호출 자체를 차단해서, 호출자의 자원이 실패한 의존성에 점유되지 않게 한다. 의존성 호출 실패가 호출자의 쓰레드와 커넥션에 누적되면, 누적된 실패가 호출자 자체의 상태까지 악화시키고, 한 의존성의 장애가 호출 체인을 따라 연쇄적으로 번진다 — 이게 cascade 다.\n차단의 기준(트리거)과 회복의 방법(회복 전략)은 별개의 결정처럼 보이지만, 두 결정이 어긋나면 차단과 회복 사이에서 시스템이 진동한다. 정밀한 차단 + 단순한 회복 조합이 cycling 안티패턴의 대표 사례다.\n상태 모델 Circuit Breaker 는 세 가지 상태를 갖는다.\nClosed: 정상 동작. 호출이 통과한다. Open: 차단 상태. 호출이 즉시 실패한다 (fail-fast). Half-Open: 회복 시도 상태. 제한된 호출만 통과시켜 의존성 상태를 검증한다. 전이 트리거는 Closed → Open (차단 기준), Open → Half-Open (회복 시도 기준), Half-Open → Closed/Open (회복 검증 기준) 세 지점이다. 모든 라이브러리가 이 모델을 따른다.\n차단 트리거 Closed → Open 전이 기준은 세 가지가 주로 쓰인다.\n실패율 기반은 슬라이딩 윈도우 내 실패율이 임계를 넘으면 차단한다. 트래픽이 안정적이고 통계적 판단이 의미 있을 때 적합하다. 윈도우가 충분히 커야 잡음에 흔들리지 않는다.\n지연/슬로우 콜 기반은 응답 시간이 임계를 넘은 호출의 비율로 차단한다. 의존성이 살아있지만 느려진 상황에 대응한다. 응답이 오긴 하니까 실패율로는 보이지 않지만, 호출자의 자원이 점유되는 시간이 길어지면 결국 같은 효과다. fail-fast 가 필요한 환경에 적합하다.\n카운트 기반은 연속 실패 횟수가 임계를 넘으면 차단한다. 가장 단순하고 빠른 반응이 가능하다. 트래픽이 낮아 통계적 판단이 어렵거나, 응답 시간보다 실패 자체의 발생이 더 명확한 신호일 때 우선 후보다.\n트리거 선택은 실패 신호의 모양으로 정해지지만, 차단이 의도한 보호는 회복 전략이 함께 설계되어야 완성된다.\n회복 전략 Open → Half-Open → Closed 전이 기준은 두 가지가 주로 쓰인다.\nTimeout 기반은 일정 시간 후 자동으로 Half-Open 으로 전이한다. 시간만 보고 회복 시도를 하는 단순한 방식이다. 의존성이 자가 회복하는 패턴(일시적 GC 압력, 짧은 네트워크 단절 등)에 적합하다.\n점진적 통과율은 Half-Open 에서 일부 호출만 통과시켜 성공률을 보고, 성공이 일정 수준 이상이면 Closed 로, 미달이면 Open 으로 되돌아간다. 회복을 검증하는 방식이다. 정확한 대신 구현이 복잡하고, 의존성에 작은 시험 부하를 허용해야 한다.\n단순성을 가져가면 Timeout, 정확성을 가져가면 점진적이다. 두 전략은 상호 배타가 아니라 결합 가능하다 — Timeout 으로 Half-Open 진입한 뒤 통과율로 Closed/Open 을 결정하는 하이브리드가 실전에서 흔하다. 어느 쪽이 적합한지는 차단 트리거와의 쌍 안에서 결정된다.\n쌍 매트릭스 차단 트리거와 회복 전략의 조합이 Circuit Breaker 의 정체성을 결정한다. 잘 맞는 조합과 그렇지 않은 조합이 있다.\n차단 트리거 회복 전략 정합도 비고 실패율 점진적 통과율 우세 양쪽 모두 통계 프레임 지연/슬로우 콜 점진적 통과율 우세 회복 후 지연 잔존을 다시 검증 카운트 기반 Timeout 적합 양쪽 모두 단순·빠른 반응 실패율 Timeout 위험 차단 정밀도 ↔ 회복 단순성 비대칭 → cycling 실패율 × 점진적이 우세한 이유는 양쪽 판단 모두 통계적이라 일관성을 갖기 때문이다. 차단 기준이 윈도우 내 실패율이고 회복 기준도 Half-Open 통과 성공률이면, 같은 통계 프레임으로 진입과 회복을 결정한다.\n지연 × 점진적도 우세하다. 의존성이 살아있지만 느려진 상황에서는 회복 시점에 여전히 느린지를 검증해야 한다. Timeout 으로 부하 전체를 재개하면 다시 느려지자마자 Open 으로 되돌아갈 가능성이 크다. 같은 이유로 지연 × Timeout 도 위험 조합에 속한다.\n카운트 × Timeout은 적합한 조합이다. 양쪽 모두 단순하고 빠른 반응을 우선하므로, 운영 단순성과 정합한다. 의존성이 자가 회복 패턴을 갖고 짧은 주기가 허용되는 환경에서 충분하다.\n실패율 × Timeout은 위험하다. 차단은 통계 기반으로 신중하게 결정하면서, 회복은 검증 없이 시간만 보고 부하 전체를 재개한다. 의존성이 회복되지 않은 채 부하를 받으면 즉시 실패율이 다시 임계를 넘고 Open 으로 되돌아간다. 결과는 무의미한 cycling — Closed/Open 사이를 진동하면서 호출자는 매 주기마다 fail-fast 의 비용을 지불한다. 차단 설계와 회복 설계의 정밀도가 맞지 않을 때 이 cycling 이 나타난다.\n도구 매핑 실전 도구는 양쪽의 특정 조합으로 굳어져 있다.\n도구 차단 트리거 (기본) 회복 전략 (기본) 굳어진 이유 Resilience4j (Java) 실패율 + 슬로우 콜 점진적 통과율 비즈니스 단위 보호의 통계 정밀도 Polly v8 (.NET) 실패율 (FailureRatio) Timeout (BreakDuration) .NET resilience 표준 통합 Istio / Envoy 카운트 기반 (연속 5xx) Timeout (ejection time) 사이드카는 비즈니스 컨텍스트 없음 Resilience4j 가 실패율 + 점진적 조합을 기본으로 한 이유는 비즈니스 로직 단위 보호에서 정밀 트리거와 회복 검증이 함께 필요해서다. 실제 동작은 두 회복 전략을 결합한다 — waitDurationInOpenState 시간 후 Half-Open 으로 자동 전이(Timeout 기반)하고, permittedNumberOfCallsInHalfOpenState 만큼의 통과 콜의 실패율로 Closed/Open 을 결정(점진적 통과율)한다.\nPolly v8 이 실패율 + Timeout 조합인 이유는 .NET resilience 의 표준 컴포넌트로 통합되면서 통계적 판단이 기본이 되었기 때문이다. FailureRatio 임계와 MinimumThroughput 으로 잡음을 거른 뒤 차단하고, BreakDuration 후 Half-Open 으로 전이한다 (v7 까지는 연속 실패 카운트 기반이었으나 v8 에서 변경됨).\nIstio / Envoy 가 카운트 + Timeout 인 이유는 사이드카 환경에서 외부 호출의 가장 명확한 실패 신호가 연속 5xx 응답이어서다. 사이드카는 비즈니스 컨텍스트가 없으므로 통계 판단 대신 단순 신호로 차단한다. outlier detection 의 consecutive_5xx 와 base_ejection_time 이 그 조합을 직접 노출한다.\n세 도구 모두 자기 환경에 맞는 쌍을 기본 조합으로 굳혔다. 도구 선택은 이미 굳어 있는 쌍 중에서 자신의 환경과 맞는 한 행을 고르는 일이 된다.\nBulkhead Circuit Breaker 단독으로는 cascade 를 막지 못한다. 한 의존성에 대해 차단이 걸렸어도, 다른 의존성 호출이 같은 자원 풀(쓰레드, 커넥션)을 공유하면 이미 점유된 자원이 풀리지 않는다. 차단이 효과를 발휘하기 전에 자원 풀이 고갈될 수 있다.\nBulkhead 패턴은 의존성별로 자원을 격리해서 이 문제를 해결한다. 각 의존성에 독립된 쓰레드 풀(또는 세마포어 슬롯)을 할당하면, 한 의존성의 실패가 다른 의존성 호출의 자원에 영향을 주지 않는다. Circuit Breaker 와 Bulkhead 는 결합 패턴으로 쓰인다. 한쪽만 적용하면 cascade 차단이 불완전해진다.\n결정 순서 Circuit Breaker 의 결정은 단일 결정이 아니라 한 쌍의 설계다. 먼저 차단 트리거를 신호의 모양으로 정하고(실패율 / 지연 / 카운트), 회복 전략을 차단 트리거와 쌍으로 정하고(단순성이면 Timeout, 정확성이면 점진적), 마지막에 Bulkhead 와 결합해 의존성별 자원을 격리한다.\nCircuit Breaker 의 차단 트리거와 회복 전략은 분리해서 결정할 수 없다. 차단을 정밀하게 설계했다면 회복도 같은 정밀도의 검증이 필요하고, 단순한 차단이라면 회복도 단순한 주기면 충분하다. 양쪽 무게가 맞아야 Circuit Breaker 가 의도한 보호를 제공한다.\n참고 Rate Limiting — 보호 계층과 알고리즘 두 결정의 제약 관계를 다룬 직전 글. 광고 시스템 장애 회고 — 공유 의존성과 단일 장애점 — Circuit Breaker 와 Bulkhead 가 함께 필요했던 cascade 사례. ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/circuit-breaker/","summary":"Circuit Breaker 의 차단 트리거와 회복 전략은 함께 설계되어야 한다. 회복 없는 차단은 의존성을 영구히 끊고, 차단 기준 없는 회복은 무의미한 cycling 이다.","title":"Circuit Breaker"},{"content":"Rate Limit 은 오토 스케일링이 반응하기 전 정상 인스턴스의 자원이 고갈되지 않게 보호하는 장치다. 부하 스파이크가 새 인스턴스의 준비 시간보다 빠르게 도착할 때, 일부 요청을 일찍 거절해서 정상 인스턴스가 한계에 도달하지 않도록 막는다.\nRate Limit 을 어떻게 셀지는 알고리즘의 문제고, 어디서 셀지는 보호 계층의 문제다. 두 기준이 독립처럼 보이지만, 보호 계층이 가능한 알고리즘의 선택지를 먼저 좁힌다. 알고리즘을 고르기 전에 계층부터 정해야 하는 이유다.\n보호 계층 L4, L7, Application 세 계층이 후보다. 식별 정밀도와 알고리즘 선택지가 계층을 따라 함께 넓어진다.\nL4 (Load Balancer 단)는 가장 외부에 위치한다. TCP 연결 단위로 카운팅하며, 식별 기준은 IP 정도가 한계다. 처리 비용이 낮아 빠른 대신, 카운팅 단위가 거칠어 단순한 알고리즘만 적용 가능하다. 클라이언트가 NAT 뒤라면 식별이 더 거칠어진다.\nL7 (Gateway 또는 Sidecar)는 HTTP 단위로 처리한다. 헤더, 경로, 토큰 같은 애플리케이션 식별자를 카운팅 키로 쓸 수 있다. 사용자 단위, API 단위로 카운트가 분리되므로 알고리즘 선택지가 넓어진다. 마이크로서비스 환경에서는 사이드카(Envoy 등)가 우선 후보다.\nApplication 계층은 비즈니스 컨텍스트까지 본다. 사용자 등급별 다른 제한, 특정 엔드포인트만 보호, 인증된 토큰 종류별 분리 같은 결정이 가능하다. 가장 정밀한 만큼 가장 무겁고, 인스턴스마다 카운터가 분산되는 비용까지 따라온다.\n세 계층은 트레이드오프 관계가 명확하다. 외부 계층일수록 식별이 거친 대신 처리 비용이 낮고, Application 쪽으로 갈수록 정밀해지지만 무거워진다.\n계층 선택이 알고리즘 후보 공간을 좁히는 이유는 식별 정밀도와 카운팅 단위에 있다. L4 처럼 IP/연결 단위 식별만 가능한 환경에서는 Sliding Window 같은 정밀 알고리즘이 식별 키의 모호함 때문에 정밀도의 이점을 잃는다. 반대로 Application 처럼 인증된 사용자 단위 식별이 가능하면 모든 알고리즘이 의미 있게 동작한다. 식별 정밀도가 곧 알고리즘의 효용을 결정한다. 다음 절의 알고리즘 비교는 이 계층 선택 이후의 후속 결정이다.\n알고리즘 주로 쓰이는 알고리즘은 Token Bucket, Fixed Window, Leaky Bucket, Sliding Window 다. burst 허용 여부로 두 그룹으로 나뉜다.\nburst 허용 그룹 Token Bucket 은 일정 속도로 토큰이 채워지는 버킷을 두고, 요청이 들어올 때마다 토큰을 하나 소모한다. 토큰이 있으면 통과, 없으면 거절. 한동안 요청이 없으면 토큰이 쌓이고, 그만큼 짧은 burst 가 허용된다. 평소 한가하다가 짧게 몰리는 워크로드에 적합하다.\nFixed Window 는 시간 윈도우 안의 카운트만 셈한다. 윈도우 안에서는 burst 가 허용되고, 윈도우가 바뀌면 카운트가 리셋된다. 구현이 가장 단순한 대신, 윈도우 경계에서 burst 가 두 번 가능한 약점이 있다 (윈도우 끝 직전과 다음 윈도우 시작 직후에 몰린 요청이 모두 통과).\nburst 제거 그룹 Leaky Bucket 은 일정 속도로만 요청이 흘러나가는 큐 비유다. 입력이 burst 로 들어와도 출력 속도는 평탄해진다. 다운스트림이 일정 속도까지만 처리 가능할 때 그 속도에 맞춰야 한다면 우선 후보다. 외부 결제 게이트웨이로 보내는 호출이 대표적인 사례다.\nSliding Window 는 시간 윈도우를 이동시키며 카운트한다. 윈도우 경계라는 개념이 없으니 Fixed Window 의 경계 burst 문제가 사라진다. 정밀도는 가장 높지만, 각 요청의 타임스탬프를 따로 보관해야 하므로 메모리와 계산 비용이 가장 무겁다.\n두 그룹의 선택은 다운스트림이 burst 를 흡수할 수 있느냐로 결정된다. 흡수 가능하면 burst 허용 그룹으로 운영 단순성을 가져가고, 흡수 불가능하면 burst 제거 그룹으로 평탄화를 보장한다.\n도구 매핑 계층과 알고리즘이 모든 조합으로 가능한 건 아니다. 실전 도구에서는 특정 조합이 굳어져 있다.\n계층 대표 도구 자연스러운 알고리즘 비고 L4 Nginx (limit_req) Leaky Bucket 연결 단위 처리에 적합 L7 Sidecar Istio / Envoy Token Bucket HTTP 헤더 기반 식별 Application Resilience4j (Java) Cycle 기반 비즈니스 컨텍스트 기반 Application Bucket4j (Java) Token Bucket 분산 백엔드 연동 가능 Nginx 의 limit_req 모듈은 Leaky Bucket 으로 동작한다. 연결을 받아 일정 속도로 흘려보내는 구조가 Leaky 의 출력 평탄화와 직접 대응하기 때문이다. burst 옵션으로 짧은 입력 burst 흡수도 허용한다.\nIstio / Envoy 의 Rate Limit 필터가 Token Bucket 을 기본으로 한 이유는 HTTP 헤더 단위로 식별된 클라이언트별 burst 허용이 게이트웨이 환경의 일반적 요구사항이기 때문이다. 사이드카 자체는 로컬 모드(단일 인스턴스 내)와 글로벌 모드(외부 RLS 서버 연동)를 모두 제공한다.\nResilience4j 의 RateLimiter 모듈은 cycle 기반 카운팅으로 동작한다. limitRefreshPeriod 마다 limitForPeriod 만큼의 permission 이 리셋되는 구조로, 토큰을 누적하는 Token Bucket 과 다르게 cycle 경계에서 카운트가 일괄 갱신된다. 메소드 단위 보호에서 단순한 cycle 카운팅이 충분한 시나리오에 적합하며, Circuit Breaker, Retry 등과 같은 컴포넌트 모음의 일부로 제공된다.\nBucket4j 는 Token Bucket 전용 라이브러리다. 분산 환경에서 Redis 같은 백엔드로 카운터를 공유하는 형태를 지원하며, 단일 JVM 보호가 아닌 클러스터 전체 보호가 필요할 때의 후보다.\n도구별로 굳어진 조합을 정리하면, L4 에서는 Leaky 가 우세하고, L7 의 Sidecar 와 Application 의 분산 보호(Bucket4j)에서는 Token 이 우세하다. Resilience4j 같은 cycle 기반 도구는 단일 JVM 안에서 단순 카운팅이 충분한 시나리오에 적합하다. Sliding Window 가 표에서 빠진 건 도구 차원에서 기본으로 채택되는 경우가 드물기 때문이다 (자체 구현이거나 분산 카운터 위에 구성하는 형태가 일반적).\n결정 순서 위 정리에서 한 가지 흐름이 보인다. 알고리즘은 트래픽 모양 단독으로 결정되는 것이 아니라, 계층을 먼저 정한 뒤 그 계층이 허용하는 후보 안에서 트래픽 모양으로 좁혀진다.\n비즈니스 컨텍스트 기반 보호가 필요한가 → Application 계층 → Token Bucket HTTP 단위 식별로 충분한가 → L7 → Token Bucket (로컬 또는 글로벌) 연결 단위 보호로 충분한가 → L4 → Leaky Bucket 이 위에서 burst 허용 여부가 알고리즘을 마지막으로 좁히는 기준이 된다.\n보호가 필요한 의존성 앞단에 장치를 둘 때 알고리즘 비교부터 시작하면 도구의 선택지가 좁아진 채로 알고리즘을 고르게 된다. 계층 결정이 선행해야 알고리즘의 후보 공간이 열린다.\n참고 광고 시스템 장애 회고 — 공유 의존성과 단일 장애점 — Rate Limit 이 cascade 의 시작점을 차단한 실제 사례. ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/rate-limiting/","summary":"Rate Limit 알고리즘을 고르기 전에, 어느 계층에서 보호할지가 먼저 알고리즘의 선택지를 좌우한다. L4/L7/Application 계층과 Token/Leaky/Sliding/Fixed 알고리즘의 교차 관계를 정리한다.","title":"Rate Limiting"},{"content":"외부 이벤트로 광고 트래픽이 평소보다 크게 늘었다.\n평상시 부하 기준으로 튜닝된 오토 스케일링이 그 속도를 따라가지 못했다. 새 POD 가 Ready 되기 전에 기존 정상 POD 들이 무너졌고, 그 충격은 다음 시스템으로 번졌다.\n장애 직후의 표면 진단은 \u0026ldquo;트래픽 스파이크 + 스케일링 지연\u0026rdquo; 이었다. 그런데 회고를 풀어보니 진짜 문제는 따로 있었다. 필터링 컴포넌트 한 곳이 무너졌는데, primary 와 fallback 이 같이 무너졌다 는 점이다.\n사건의 흐름 먼저 무너진 건 필터링 컴포넌트였다. 트래픽 증가 속도가 HPA 의 새 POD 기동 속도보다 빨랐다. 새 POD 가 Ready 되기 전에 기존 정상 POD 들의 CPU 가 한계를 넘었고, Readiness Failed 가 줄줄이 발생했다. 스케일 아웃이 진행 중인 와중에 기존 POD 들도 무너지는 패턴 이었다. cascade 의 시작이었다.\n그다음은 primary 광고 시스템이었다. primary 광고 시스템은 광고 후보를 결정할 때 필터링 컴포넌트에 필터 요청을 보낸다. 필터링 컴포넌트의 상태가 나빠지자 그 요청들이 TIMEOUT 으로 누적됐고, 누적된 실패가 primary 광고 시스템 자체의 상태까지 끌어내렸다. 자동으로 회복되지 않는 흐름이었다 — 수동 재시작이 필요했다.\n세 번째는 추천 컴포넌트였다. primary 광고 시스템이 회복되자, 그동안 막혀 있던 요청이 한꺼번에 풀렸다. 줄어들었던 부하가 갑자기 정상치로 돌아오는 과정에서 5xx 가 발생했다. HPA 가 따라잡을 때까지 다시 시간차가 있었다.\ncascade 는 문제가 풀리는 순간에도 다음 마디로 이어진다 는 것을 보여줬다. 필터링 컴포넌트가 회복돼도 primary 광고 시스템이 막혀 있었고, primary 광고 시스템이 회복되니 추천 컴포넌트가 휘청였다. 표면적인 타임라인만 따라가면 \u0026lsquo;서로 다른 세 장애\u0026rsquo; 처럼 보였지만, 본질은 한 흐름이었다.\n진단 — 공유 의존성과 단일 장애점 장애의 표면 원인을 정리하면 두 가지가 떠오른다. 외부 이벤트가 만든 갑작스러운 부하. 그리고 그 속도를 못 따라간 오토 스케일링.\n여기서 멈추면 후속 개선은 \u0026ldquo;HPA 를 더 빠르게\u0026rdquo;, \u0026ldquo;알람을 더 빠르게\u0026rdquo;, \u0026ldquo;수동 대응을 더 빠르게\u0026rdquo; 로 흐른다. 다 맞는 말이지만, 모두 증상을 더 빠르게 다루는 답이다.\n회고를 한 단계 더 밀어보면 다른 그림이 보였다. 우리 광고 시스템에는 필터링 컴포넌트가 있고, primary 광고 시스템이 그 컴포넌트에 의존한다. 장애에 대비해 fallback 시스템도 따로 두고 있었다. 평소엔 유휴 상태로 대기하다 primary 가 실패하면 활성화되는 구조다.\n그런데 그 fallback 안에서 필터링 로직 은 코드 중복을 피하기 위해 필터링 컴포넌트 API 를 그대로 호출하는 방식으로 구성돼 있었다.\n이 한 줄이 모든 걸 바꿨다.\nprimary 도, fallback 도 같은 필터링 컴포넌트에 묶여 있었다. 필터링 컴포넌트는 단일 장애점 이었고, 그 한 곳이 무너지자 양쪽이 동시에 무너졌다.\n같은 의존성을 공유한 fallback 은 primary 의 부하를 흡수하지 못한다. 오히려 그 의존성으로 추가 트래픽을 보내 cascade 를 증폭 시킨다. 우리가 따로 띄운 fallback 서버가, 실제로는 두 번째 primary 처럼 같은 단일 장애점에 묶여 있던 셈이었다.\n한 가지 더 있다. 그 단일 장애점이 왜 그렇게 빨리 무너졌는가. 필터링 컴포넌트는 광고 후보 평가와 필터링처럼 CPU 점유가 높은 작업이 많은 컴포넌트였다. Node 런타임의 단일 스레드 이벤트 루프는 그런 워크로드와 결이 잘 맞지 않는다. 한 POD 의 CPU 한계가 더 빨리 다가왔고, cascade 의 첫 마디가 그렇게 빨리 시작된 배경이기도 했다.\n이번 장애의 진짜 문제는 외부 이벤트도, 오토 스케일링의 속도도 아니었다. 필터링 컴포넌트가 단일 장애점이었고, fallback 까지 같은 곳에 묶여 있었으며, 그 점 자체가 CPU 한계에 빠르게 도달하는 구조였다 는 것이다.\n개선 진단이 명확하니 개선의 방향도 갈래로 나뉘었다. fallback 의 의존성을 끊어 양쪽을 떼어내는 길, 필터링 컴포넌트 자체에 보호를 두어 단일 장애점을 단단하게 만드는 길, 그리고 그 점의 처리량 자체를 늘리는 길. 서로 대신하지 않는다. 같이 가야 단일 장애점이 cascade 로 번지지 않는다.\nfallback 의 광고 필터링 의존 제거 첫 번째 갈래는 fallback 의 필터링 로직을 독립적으로 갖추는 것이다.\n운영 비용은 늘어난다. 필터링 컴포넌트의 로직을 fallback 쪽에도 두면 코드와 데이터 동기화가 추가된다. 처음 fallback 을 만들 때 그 비용 때문에 필터링 컴포넌트 API 호출 방식을 택했던 것이고, 그 결정 자체가 비합리적이지는 않았다.\n다만 이번 장애가 보여준 건, 그 절약이 fallback 의 정의를 깎아먹었다 는 것이다. fallback 의 운영 비용을 fallback 의 존재 이유와 바꾼 셈이었다. 이 트레이드오프는 다시 들여다보면 한쪽이 명백히 무겁다고 봤다.\n대안 하나는 fallback 의 필터링을 Serverless 로 구성하는 방식이다. 평소엔 유휴 상태가 fallback 의 본 모습이니, Serverless 의 idle 비용 0 특성이 잘 맞는다. 운영 비용을 줄이면서 독립성을 회복하는 방향이다.\n필터링 컴포넌트에 Rate Limit 두 번째 갈래는 필터링 컴포넌트 자체를 단단하게 만드는 것이다.\ncascade 의 첫 마디는 \u0026lsquo;스케일 아웃이 끝나기 전에 기존 정상 POD 들이 무너지는\u0026rsquo; 패턴이었다. 오토 스케일링은 부하가 발생한 후 늘어나는 반응형이라, 갑작스러운 스파이크 앞에선 항상 시간차를 갖는다. 그 시간차 동안 정상 POD 들이 한계까지 끌려가는 것을 막아야 했다.\nRate Limit 이 그 역할이다. 새 POD 가 Ready 될 때까지 일부 요청을 일찍 거절해서, 정상 POD 들이 한계까지 내몰리지 않도록 한다. 또는 CircuitBreaker 로 의존성을 향한 요청 자체를 일정 조건에서 끊는 방식도 가능하다. 어느 쪽이든 단일 장애점이 한 번에 무너지지 않게 보호하는 장치다.\nfallback 독립화가 양쪽을 떼어내는 작업이라면, Rate Limit 은 그 점 자체를 단단하게 만드는 작업이다. 단일 장애점을 양쪽에서 푸는 셈이다.\n런타임 재검토 세 번째 갈래는 그 점의 처리량 자체를 늘리는 방향이다.\n필터링 컴포넌트의 작업 특성을 다시 보면 Node 런타임은 그 워크로드와 결이 맞지 않는 선택이었다. CPU 시간을 길게 쓰는 평가/필터링 작업이 많고, 단일 스레드 이벤트 루프에서는 한 요청의 처리가 다음 요청의 응답을 늦춘다.\n같은 POD 자원으로 더 많은 처리량을 내려면 시스템 언어 런타임이 자연스럽다. 런타임 재검토가 진행 단계에 있었다. Rate Limit 이 단일 장애점을 한 번에 무너지지 않게 막는 길이라면, 런타임 재검토는 그 점의 처리량 자체를 늘리는 길이다. 둘은 단일 장애점을 다른 각도에서 강화한다.\n검토 단계에서 멈춘 상태지만, cascade 의 첫 마디가 \u0026lsquo;CPU 한계 도달\u0026rsquo; 이라는 점을 보면 이 방향도 여전히 유효한 선택지로 봤다.\n그 외 후속 운영 흐름도 같이 다듬을 부분이 있다. HPA 의 타겟을 CPU Utilization 대신 요청 수 로 두면 부하 증가를 더 일찍 감지할 수 있다. CPU 기반은 부하의 결과 를 보는 신호고, 요청 수는 부하의 원인 을 보는 신호다. 선제 감지에 가까워진다.\n수동 Scale Out 이 필요한 순간에 k8s 설정을 직접 수정하는 흐름도 시간이 든다. Slack 봇으로 명령을 보내는 형태로 바꾸면 그 시간이 짧아진다.\n둘 다 진단의 세 갈래만큼 본질적인 변화는 아니지만, cascade 의 다음 마디들을 짧게 만들어 준다.\n배운 점 그날의 외부 이벤트는 트리거였을 뿐이다. 오토 스케일링의 한계도 사실이다. 그런데 그 모든 것 위에 우리가 만든 구조 가 있었다. 필터링 컴포넌트라는 단일 장애점이 있었고, fallback 까지 같은 곳에 묶여 있었으며, 그 점이 CPU 한계에 쉽게 도달하는 구조였다.\ncascade 의 시간 흐름을 따라가다 보면 \u0026lsquo;HPA 를 더 빠르게, 알람을 더 빠르게\u0026rsquo; 로 답이 모이기 쉽다. 그 자리에서 한 단계 더 미는 질문이 왜 fallback 이 cascade 를 막지 못했는가 였고, 거기서 필터링 컴포넌트가 사실은 양쪽의 단일 장애점이었다 는 진단이 나왔다. 그 진단을 한 번 더 밀면 그 점이 왜 그렇게 빨리 무너졌는가 까지 닿는다. 표면 진단과 구조 진단 사이의 거리가 회고의 가치였다.\n단일 장애점은 fallback 까지 같은 의존성에 묶일 때 cascade 가 된다. 양쪽을 떼어내고, 그 점 자체에 보호를 두고, 그 점의 처리량 자체를 늘리는 것이 시스템이 단단해지는 길이었다.\n참고 광고 fallback 서버 설계 — 이 글에서 다룬 fallback 시스템의 초기 설계 회고. 이번 장애는 그 fallback 이 필터링 컴포넌트에 의존하던 한 줄이 만든 사건이었다. Rate Limiting — cascade 의 시작점을 차단할 수 있는 보호 계층과 알고리즘 정리. Circuit Breaker — 공유 의존성에 Circuit Breaker + Bulkhead 를 결합해 cascade 를 막는 패턴 정리. ","permalink":"https://wid-blog.github.io/posts/career/dable/cascading-failure-retrospective/","summary":"외부 이벤트로 광고 트래픽이 급증하면서 cascading failure 가 발생했다. 진짜 문제는 필터링 컴포넌트가 단일 장애점이었고 fallback 까지 그 위에 묶여 있어서 한 곳의 무너짐이 양쪽의 동시 붕괴로 이어졌다는 것이었다. 개선은 세 갈래 — fallback 의 의존 제거(독립화), 필터링 컴포넌트에 Rate Limit(보호), 그리고 런타임 재검토(처리량 증대).","title":"광고 시스템 장애 회고 — 공유 의존성과 단일 장애점"},{"content":"sklearn과 ONNX는 같은 질문의 답이 아니다. \u0026ldquo;LR 하나 학습하는데 뭘 쓰지?\u0026ldquo;로 시작해서 이 둘을 나란히 놓는 순간, 비교 자체가 착시가 된다. 한 쪽은 모델을 학습시키는 프레임워크고, 다른 쪽은 학습된 모델을 담아 운반하는 포맷이다. 같은 레이어에 속하지 않는다.\nsklearn과 ONNX를 고르는 결정 자체보다, 왜 \u0026ldquo;sklearn이냐 ONNX냐\u0026quot;가 애초에 성립하는 질문이 아닌지가 먼저다.\n프레임워크 비교의 전제 \u0026ldquo;sklearn vs ONNX\u0026quot;를 검색하면 두 도구가 같은 역할을 놓고 경쟁하는 것처럼 묶여 나온다. 장단점 표, 벤치마크, 사용 예시가 나란히 붙는다. 이 배치 자체가 착시를 만든다.\nsklearn은 데이터를 받아 모델을 학습시키는 라이브러리다. LogisticRegression, RandomForest, GradientBoosting — 학습 알고리즘과 그 구현이 들어 있다. 학습이 끝나면 결과 모델 객체를 .pkl 파일로 저장하고, Python 프로세스에서 다시 읽어 predict을 실행한다. 학습부터 서빙까지 Python 생태계 안에서 완결된다.\nONNX에는 학습 알고리즘이 없다. ONNX가 제공하는 것은 \u0026ldquo;이미 학습된 모델\u0026quot;을 프레임워크 중립적으로 표현하는 포맷이다. PyTorch에서 학습한 트랜스포머도, sklearn에서 학습한 LR도, 모두 같은 ONNX 그래프로 변환할 수 있다. 그다음에 어떤 런타임에서든 그 그래프를 실행하면 된다.\n정리하면 — 한 쪽은 학습자고 다른 쪽은 운송 수단이다. \u0026ldquo;학습자와 운송 수단 중 뭘 쓸까\u0026quot;라는 질문은 성립하지 않는다. 둘이 같이 움직이거나, 운송 수단이 필요 없거나 둘 중 하나다.\nsklearn sklearn은 두 가지 일을 한 번에 한다. 모델을 학습시키는 것, 그리고 학습된 모델을 Python 객체로 저장해 다시 읽는 것.\nfrom sklearn.linear_model import LogisticRegression import joblib model = LogisticRegression() model.fit(X_train, y_train) joblib.dump(model, \u0026#34;model.pkl\u0026#34;) 이 .pkl 파일은 Python의 네이티브 직렬화 포맷을 따른다. Python 외 언어에서는 읽지 못한다. 같은 sklearn 버전, 같은 NumPy 버전이 설치된 환경이어야 안전하게 재로딩된다. 대신 학습 → 저장 → 서빙이 하나의 파이프라인에서 끊김 없이 연결된다.\n대부분의 ML 코드는 Python으로 학습하고 Python 프로세스로 서빙한다. 이 경로에 다른 레이어를 추가할 이유가 없다면, sklearn이 직접 내놓는 저장 포맷이 가장 짧은 길이다.\nONNX ONNX는 프레임워크-중립 중간 표현(Intermediate Representation)이다. 모델의 연산 그래프를 표준화된 opset으로 기록하고, ONNX Runtime 같은 별도 런타임이 그 그래프를 읽어 실행한다.\n이 한 단계가 추가되면 몇 가지 제약이 해제된다.\n언어 경계 — PyTorch/sklearn에서 학습한 모델을 C++, C#, Java, Rust 프로세스에서 추론할 수 있다. Python 없이. 하드웨어 경계 — ONNX Runtime은 그래프 최적화와 하드웨어별 execution provider를 제공한다. 같은 모델이 CPU, CUDA GPU, TensorRT, CoreML에서 실행된다. 프레임워크 경계 — 팀 안에 PyTorch 모델과 TensorFlow 모델이 섞여 있는데 서빙 스택은 하나로 통일하고 싶을 때, ONNX가 공통분모가 된다. 이 세 경계가 실제로 존재하는 프로젝트라면 ONNX 레이어는 도입 비용을 정당화한다. 경계가 없으면, 이 레이어는 파이프라인에 단계 하나를 추가하는 일 이상이 아니다.\nONNX Runtime 성능 \u0026ldquo;ONNX Runtime이 더 빠르다\u0026quot;는 이야기가 자주 들린다. 절반만 맞다.\nONNX Runtime은 그래프 최적화(operator fusion, constant folding)와 하드웨어 가속기(CUDA, TensorRT, OpenVINO)를 붙일 수 있다. 그래서 같은 모델을 네이티브 프레임워크보다 빠르게 실행할 수 있는 경우가 있다. 핵심은 경우가 있다는 것이다.\n그 이득이 실제로 생기려면 보통 다음 중 하나 이상이 전제되어야 한다.\nGPU나 가속기 같은 전용 하드웨어 Python GIL을 벗어나는 non-Python 런타임 그래프가 충분히 커서 최적화 효과가 유의미한 모델 Logistic Regression은 이 중 어느 조건에도 해당하지 않는다. 가중치 벡터와 입력 벡터의 내적 한 번이 전부다. 여기에 graph fusion을 적용해도 줄일 연산이 거의 없다. CPU에서 벡터를 한 번 곱하는 작업에 ONNX Runtime과 sklearn 사이의 유의미한 지연 차이는 기대하기 어렵다.\n그래서 \u0026ldquo;ONNX가 빠르다\u0026quot;는 문장은, 어떤 모델인지 어디서 실행하는지를 덧붙이지 않으면 참이 되지 않는다.\nONNX 채택 기준 추상적인 판단 기준 몇 개보다는, ONNX를 도입했을 때 이득이 명확해지는 조건들을 나열하는 쪽이 낫다.\n학습 언어와 서빙 언어가 다르다. Python으로 학습하고 C++/Java/Go 서버에서 추론해야 한다. ONNX가 그 사이를 연결한다. GPU나 edge 추론이 필요하다. 모델이 크거나, 지연 요구가 엄격하거나, edge device에 올려야 한다. ONNX Runtime의 execution provider가 이를 지원한다. 여러 프레임워크의 모델을 한 서빙 스택으로 통일하고 싶다. PyTorch, sklearn, TensorFlow가 섞인 모델들을 같은 인퍼런스 서버에서 실행해야 한다. ONNX가 공통 포맷이 된다. 학습 코드와 서빙 인프라의 수명이 분리된다. 학습 코드는 자주 리팩토링하고 싶지만 서빙 쪽 바이너리는 안정적으로 고정되어 있어야 한다. ONNX가 그 사이의 고정점이 된다. 이 조건 중 어느 것도 해당하지 않으면, ONNX 레이어가 주는 것은 \u0026ldquo;변환 단계 하나 + opset 버전 호환성 걱정 + float/double precision 디버깅\u0026quot;이다. 비용만 들고 얻는 것이 없다.\n경량 LR 시나리오 경량 LR 모델을 Python 학습 + Python 서빙 경로에서 운영하는 시나리오를 가정해보자. GPU 추론은 필요 없고, 모델은 가중치 벡터 하나 크기다. 다른 프레임워크의 모델을 함께 배치할 계획도 없다. 위에서 나열한 네 조건 중 어느 것도 걸리지 않는다.\n이 경우 실제 결정은 \u0026ldquo;ONNX를 쓸 것인가\u0026quot;가 아니라, 애초에 \u0026ldquo;ONNX 레이어가 이 그림에 들어갈 자리가 있는가\u0026quot;로 내려간다. 자리가 없다. sklearn이 직접 내놓는 .pkl 저장이 학습에서 서빙까지 가장 짧은 길이다.\n정리 처음 질문으로 돌아가자. \u0026ldquo;sklearn과 ONNX 중 뭘 쓸까?\u0026ldquo;는 답할 수 있는 형태의 질문이 아니다. 두 도구가 같은 레이어에 속하지 않기 때문이다.\n이 질문은 둘로 쪼개져야 한다. 하나는 \u0026ldquo;어떤 라이브러리로 학습할 것인가\u0026rdquo; — sklearn, PyTorch, XGBoost 같은 학습 프레임워크들 사이의 선택이다. 다른 하나는 \u0026ldquo;학습된 모델을 어떤 포맷으로 배포할 것인가\u0026rdquo; — 각 프레임워크의 네이티브 저장 포맷일 수도, ONNX일 수도 있다.\n두 질문으로 분리하면, \u0026ldquo;ONNX 레이어가 필요한가\u0026quot;는 학습 프레임워크 선택과 독립된 문제가 된다. 그리고 가벼운 모델에서 이 질문은 대체로 \u0026ldquo;아니다\u0026quot;로 빠르게 닫힌다. 결론이 난 문제에 레이어를 추가할 이유는 없다.\n같은 질문의 답이 아닌 두 도구를 같은 질문에 억지로 넣으면, 답은 매번 어색해진다. 질문을 먼저 다시 써야 한다.\n","permalink":"https://wid-blog.github.io/posts/tech/ml/model-training-frameworks/","summary":"sklearn과 ONNX는 같은 레이어의 경쟁자가 아니다. 두 도구의 자리를 분리해서 보면 \u0026lsquo;ONNX 레이어가 필요한가\u0026rsquo;라는 질문이 자연스럽게 남는다.","title":"모델 학습 프레임워크 고르기: sklearn vs ONNX"},{"content":"CTR 예측의 baseline을 정할 때 후보는 많다. Gradient Boosting, Neural Network, 그리고 Logistic Regression. 이 중 LR은 여전히 자주 baseline 으로 선택된다. 오래된 모델이 그 선택을 받는 데에는 이유가 있다.\nLR 의 특성 경량. 모델이 벡터 내적 한 번이다. 학습도, 인퍼런스도, 피처 수에 선형.\n해석 가능. 계수 하나하나가 \u0026ldquo;이 피처가 결과에 얼마나 기여하는가\u0026quot;를 직접 말해준다.\n확률 출력. 0과 1 사이의 값을 출력한다. 광고에서는 입찰가를 곱할 때 그대로 쓰인다.\n모델 구조 Logistic Regression을 가장 빠르게 이해하는 방법은 선형 회귀에서 출발하는 것이다.\n선형 회귀는 입력의 가중합을 출력한다.\n$$ z = w \\cdot x + b $$문제는 $z$가 실수 전체를 범위로 갖는다는 점이다. CTR 같은 확률을 내놓으려면 출력이 0과 1 사이여야 한다. 선형 회귀는 이를 보장하지 않는다.\n시그모이드 함수가 이 문제를 해결한다.\n$$ \\sigma(z) = \\frac{1}{1 + e^{-z}} $$시그모이드는 실수 전체를 $(0, 1)$ 구간으로 부드럽게 압축한다. 입력이 아무리 커져도 1에 수렴하고, 아무리 작아져도 0에 수렴한다. 선형 회귀의 출력을 시그모이드에 통과시키면 확률이 된다.\n이 단순한 합성이 Logistic Regression의 전부다. 선형 모델 + 확률 출력.\n주목할 점 하나. 확률 출력은 비선형이지만, decision boundary, 즉 확률 0.5를 기준으로 양쪽을 나누는 경계는 여전히 선형이다. $w \\cdot x + b = 0$ 이라는 초평면이 그대로 경계가 된다. LR은 \u0026ldquo;선형 분류기에 확률을 결합한 모델\u0026quot;이다.\nlog-loss 모델 구조가 정해졌다면, 학습은 \u0026ldquo;좋은 $w$와 $b$를 찾는 일\u0026quot;이다. 기준이 필요하다.\n선형 회귀는 MSE를 쓴다. LR은 MSE를 쓰지 않는다. 이유는 출력 형태에 있다.\nLR의 출력은 확률이다. 확률 모델의 손실에는 더 적합한 선택이 있다. log-loss (또는 cross-entropy).\n$$ L = -\\frac{1}{N} \\sum_{i=1}^{N} \\left[ y_i \\log \\hat{y}_i + (1 - y_i) \\log (1 - \\hat{y}_i) \\right] $$정답이 1일 때는 $\\log \\hat{y}$가 커질수록 손실이 줄고, 정답이 0일 때는 $\\log(1 - \\hat{y})$가 커질수록 손실이 준다. 확률 예측이 정답과 가까워질수록 손실은 0에 수렴한다.\nlog-loss는 LR에 대해 convex하다. 지역 최적점에 빠지지 않는다. 전역 최적으로 수렴할 수 있다는 뜻이다. 이 성질이 LR을 대규모 데이터에서 빠르게 학습시킬 수 있는 수학적 근거다.\n특성의 근거 앞서 정리한 세 가지 특성, 경량, 해석 가능, 확률 출력은 위 구조에서 그대로 따라 나온다.\n경량 학습된 LR 모델은 결국 가중치 벡터 $w$와 편향 $b$ 한 쌍이다. 인퍼런스는 내적 한 번과 시그모이드 한 번. 피처가 백만 개든 천만 개든, 연산량은 피처 수에 선형이다. 트리 앙상블이나 신경망의 수많은 곱셈과 비선형 연산과는 비교할 수 없이 가볍다.\n해석 가능 계수 $w_i$는 \u0026ldquo;피처 $i$가 1만큼 증가할 때 log-odds가 $w_i$만큼 변한다\u0026quot;는 뜻이다. 부호는 방향, 크기는 영향력을 말해준다. 광고 도메인에서 \u0026ldquo;어떤 피처가 클릭에 긍정적으로 작용하는가\u0026quot;를 알고 싶을 때, LR은 계수표 하나로 대답한다. 현업의 설명 책임에 적합하다.\n확률 출력 많은 분류기는 ranking용 score만 출력한다. LR은 calibrated probability를 출력한다. 광고의 기대값 계산은 이 숫자를 그대로 곱할 수 있어야 한다. 예측 CTR × 입찰가 = 기대 수익. probability가 아닌 score는 입찰 공식에 바로 들어가지 못한다.\nCTR 예측 적용 CTR 예측이라는 문제를 보면 LR이 선택되는 이유가 드러난다.\n희소. 피처 대부분은 one-hot 인코딩된 카테고리다. 수백만 차원 중 몇 개만 1이고 나머지는 0이다.\n고차원. 광고, 사용자, 컨텍스트의 조합은 수백만에서 수억 단위로 퍼진다.\n대규모. 학습 데이터는 일 단위로 대량 축적된다.\nLR은 이 세 특성과 정확히 맞물린다. 희소 벡터의 내적은 non-zero 항목만 계산하면 되므로 피처 차원이 커도 연산은 실제 값이 있는 수에 비례한다. 학습은 SGD 계열로 분산이 쉽다. 인퍼런스는 실시간 입찰의 타이트한 지연 예산 안에 들어간다.\nCTR 모델을 처음 올리는 상황에서 이 특성들이 결정적으로 작용한다. baseline을 빠르게 세우고, 학습 파이프라인, 서빙, 모니터링까지 전체 lifecycle을 먼저 검증하는 것이 우선이다. 복잡한 모델로는 그 검증 자체가 지연된다.\n한계와 다음 단계 LR 이 baseline 으로 자리잡은 이유를 봤으니, 떠나는 이유도 함께 봐야 한다.\n가장 큰 한계는 비선형 상호작용의 부재다. 피처들끼리의 곱, 조건부 효과, 복잡한 결합을 LR은 스스로 발견하지 못한다. 사람이 feature engineering으로 미리 정의해야 한다. 피처 조합이 많아질수록 엔지니어링 비용은 커지고, 운영은 피처 설계 리뷰에 묶인다.\n그래서 언제 넘어가는가. 데이터와 운영 여력이 \u0026ldquo;피처 엔지니어링으로 감당할 수 없는 지점\u0026quot;에 이를 때. Gradient Boosting Decision Tree는 상호작용을 스스로 학습한다. 신경망은 더 나아가 embedding으로 고차원 카테고리를 연속 벡터로 변환한다. 두 방향 모두 LR의 한계를 정확히 겨냥한다.\n다만 시작점은 여전히 LR이 합리적이다. baseline 없이 복잡한 모델부터 올리면, 무엇이 모델의 기여이고 무엇이 파이프라인의 기여인지 구분할 수 없다. LR이 준 숫자가 이후 모든 비교의 기준선이 된다.\n마무리 오래된 모델을 고른 데에는 이유가 있었다.\n그 이유는 구조에 있다. 선형 모델과 시그모이드의 합성, log-loss의 convex성, 희소·고차원에서의 가벼움. 세 가지가 합쳐져 LR은 CTR 예측의 baseline으로 오래 유지되고 있다.\n다음 모델로 넘어갈 때가 오더라도, LR이 준 숫자는 baseline으로 남는다.\n","permalink":"https://wid-blog.github.io/posts/tech/ml/logistic-regression/","summary":"CTR 예측의 baseline으로서 Logistic Regression의 구조와 특성을 정리한다. 오래된 모델이 여전히 baseline 으로 쓰이는 이유.","title":"Logistic Regression 다시 보기"},{"content":"배포는 이틀 전에 끝났고 그때부터 매트릭은 정상이었다. 그날 구 캐시 갱신 배치를 제거하고 잔존 캐시를 정리하자, 외부 광고 응답이 멈췄다. 그제야 알았다 — 이틀 전 배포한 변경점은 사실 적용되지 않은 채 잠재해 있었다.\n타임라인 11-25 에 새 캐시 모듈로 교체하는 배포가 끝났다. 모니터링은 정상이었고 사용자 트래픽도 흔들리지 않았다. 그러나 패키지 버전 잠금 실수로, 새 모듈의 코드는 들어왔지만 의존성은 구 버전이 그대로 잠긴 채 실행되고 있었다. 시스템은 구 캐시 키를 보고 있었고, 구 캐시는 별도 배치로 여전히 갱신되고 있어 동작에 이상이 없었다.\n11-27 18:20 에 구 캐시 갱신 배치를 제거했다. TTL 이 살아있는 동안은 구 캐시가 남아있었지만, 18:39 에 잔존 캐시를 수동으로 삭제하자 그 즉시 구 캐시 미스가 시작됐다. 새 캐시 키 쪽에는 데이터를 채워줄 주체가 없었으므로 광고 데이터를 가져오지 못했다. 18:40 부터 외부 광고 응답이 멈췄고, 19:23 에 외부 SSP 제보로 인지, 20:05 에 복구됐다.\n근본 원인 배포가 끝났다는 사실과 변경점이 적용됐다는 사실은 다르다. 그날 이전까지 모니터링은 \u0026ldquo;배포가 끝났고 시스템은 정상이다\u0026rdquo; 만 보고하고 있었다. \u0026ldquo;새 모듈로 정말 전환됐는가\u0026rdquo; 는 어떤 매트릭에도 잡히지 않았다.\n이번 경우 검증의 단서는 명확했다. 새 캐시 키의 조회 비율이 0 이 아니어야 했고, 구 캐시 키의 조회 비율은 0 에 수렴해야 했다. 11-25 배포 직후에 이 둘을 비교하기만 했어도 잘못된 상태를 즉시 발견할 수 있었다. 그러나 그런 매트릭은 없었다. 시스템이 \u0026ldquo;구 캐시로 정상 동작 중\u0026rdquo; 인 상태와 \u0026ldquo;새 캐시로 정상 동작 중\u0026rdquo; 인 상태가 매트릭 수준에서 구분되지 않았다.\n발견을 더 늦춘 두 가지 부수 요인도 있었다. 광고 데이터가 비었을 때도 시스템이 200 응답을 반환하던 버그, 내부 지면 Fallback 이 알람 신호를 함께 흡수한 점. 이 둘이 합쳐져 캐시 미스 직후에도 대시보드는 평온해 보였다. 그러나 근본 원인은 아니었다. 진짜 원인은 그보다 이틀 앞선 시점에 이미 자리잡고 있었다.\n회고 배포 후 모니터링은 \u0026ldquo;주요 매트릭이 그대로인가\u0026rdquo; 보다 \u0026ldquo;변경점이 의도대로 반영됐는가\u0026rdquo; 가 우선이라고 봤다. 의존성 버전 해시, 새 캐시 키 조회 비율, 구 캐시 키 조회 비율 — 어떤 형태든 변경 전과 후를 매트릭으로 분리해서 볼 수 있어야 한다. 그것이 없으면 잘못된 상태는 시간차를 두고 폭발한다.\n돌아보면 이틀 동안의 평온함이 가장 위험했던 신호였다. 매트릭이 가만히 있다는 것은 변경점이 적용된 후에도, 적용되지 않은 채 묻혀있을 때도 똑같이 가능한 모습이었다.\n","permalink":"https://wid-blog.github.io/posts/career/dable/campaign-cache-miss-retrospective/","summary":"배포는 이틀 전에 끝났지만 변경점은 적용되지 않은 채 잠재해 있었다. 캐시 갱신 배치를 끄는 순간 광고 서빙이 멈췄다. 배포 변경점 검증의 부재를 회고한다.","title":"배포 변경점 검증의 사각지대 — 캐시 캠페인 장애 회고"},{"content":"Kubernetes(k8s)는 Container 오케스트레이션 플랫폼이다. 다수의 Container를 클러스터 위에서 배포·확장·복구하는 작업을 자동화한다. 단일 서버에서 Docker로 Container를 관리하는 단계를 넘어 수십, 수백 개 Container를 다뤄야 할 때, Container가 종료되면 누가 재시작하는지, 트래픽이 몰리면 어떻게 늘리는지, Container 간 통신은 어떻게 하는지 같은 문제를 선언적 모델로 푼다.\n클러스터 아키텍처 k8s 클러스터는 Control Plane과 Worker Node로 구성된다.\nflowchart TB subgraph cp[\"Control Plane\"] API[\"API Server\"] ETCD[\"etcd\"] SCHED[\"Scheduler\"] CM[\"Controller Manager\"] end subgraph wn1[\"Worker Node\"] KL1[\"kubelet\"] KP1[\"kube-proxy\"] CR1[\"Container Runtime\"] P1[\"Pod\"] P2[\"Pod\"] end subgraph wn2[\"Worker Node\"] KL2[\"kubelet\"] KP2[\"kube-proxy\"] CR2[\"Container Runtime\"] P3[\"Pod\"] end API --\u003e SCHED API --\u003e CM API --\u003e ETCD API --\u003e KL1 API --\u003e KL2 Control Plane 클러스터 전체를 관리하는 컴포넌트 집합이다.\nAPI Server는 모든 요청의 진입점이다. kubectl이든 내부 컴포넌트든, k8s에 무언가를 요청하면 API Server를 거친다. etcd는 클러스터의 상태를 저장하는 분산 키-값 저장소다. 어떤 Pod이 어디서 실행 중인지, 어떤 Deployment가 존재하는지 같은 정보가 모두 여기 있다.\nScheduler는 새로 생성된 Pod을 어느 Node에 배치할지 결정한다. Node의 자원 여유, affinity 규칙 등을 고려한다. Controller Manager는 클러스터의 현재 상태가 선언된 상태와 일치하는지 감시한다. Deployment에 replicas: 3이라고 적었는데 Pod이 2개뿐이면, Controller가 하나를 더 생성한다.\nWorker Node 실제로 Container가 실행되는 서버다.\nkubelet은 각 Node에서 Pod의 생명주기를 관리한다. API Server로부터 \u0026ldquo;이 Pod을 실행하라\u0026quot;는 지시를 받아 Container Runtime을 통해 Container를 시작한다. kube-proxy는 Node 레벨의 네트워크 규칙을 관리해서 Service로 들어온 트래픽을 적절한 Pod으로 전달한다.\n전체 흐름 kubectl apply -f deployment.yaml을 실행하면 API Server가 요청을 받는다. etcd에 원하는 상태를 저장하고, Scheduler가 Pod을 배치할 Node를 정한다. 해당 Node의 kubelet이 Container를 생성한다. Controller Manager는 이후에도 선언된 상태와 실제 상태의 차이를 계속 감시하고 보정한다.\n백엔드 개발자가 직접 다루는 건 kubectl과 YAML 매니페스트다. 나머지는 k8s가 내부적으로 처리한다.\n핵심 오브젝트 Pod Pod은 k8s에서 배포할 수 있는 가장 작은 단위다. Container를 직접 배포하지 않고 Pod으로 감싸는 건, 같은 Pod 안의 Container끼리 네트워크 네임스페이스와 스토리지를 공유하기 때문이다. 사이드카 패턴처럼 메인 Container 옆에 로그 수집기나 프록시를 함께 배치할 때 유용하다.\n대부분의 경우 Pod 하나에 Container 하나다. Pod을 직접 만드는 일은 드물고, Deployment를 통해 관리한다.\nDeployment Deployment는 Pod의 선언적 관리자다. \u0026ldquo;이 Image로 Pod을 3개 유지하라\u0026quot;고 선언하면 k8s가 자동으로 3개를 생성하고, Pod이 종료되면 재시작한다. 백엔드 서비스를 배포할 때 가장 자주 쓰는 오브젝트다.\n배포 전략도 Deployment가 처리한다. 기본 전략은 롤링 업데이트다. 새 버전의 Pod을 하나씩 생성하면서 이전 버전을 하나씩 제거한다. 전체 서비스가 중단되지 않으면서 점진적으로 교체된다. 문제가 생기면 kubectl rollout undo로 이전 버전으로 돌아간다.\napiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: replicas: 3 selector: matchLabels: app: api-server template: metadata: labels: app: api-server spec: containers: - name: api image: api-server:1.2.0 ports: - containerPort: 8080 Service Pod은 생성과 삭제가 수시로 일어나고, IP가 바뀐다. Pod IP를 직접 사용하면 안 된다. Service는 Pod 집합에 안정적인 접근점을 제공한다.\nClusterIP는 클러스터 내부에서만 접근 가능한 가상 IP를 부여한다. 백엔드 서비스 간 통신에 주로 사용한다. NodePort는 각 Node의 특정 포트를 열어 외부 접근을 허용한다. LoadBalancer는 클라우드 환경에서 외부 로드밸런서를 자동으로 생성한다.\n백엔드 개발에서 가장 자주 쓰는 건 ClusterIP다. 다른 서비스를 호출할 때 http://service-name:port로 접근하면 k8s DNS가 해당 Service의 ClusterIP로 해석한다. 서비스 디스커버리를 별도로 구현하지 않아도 된다.\nNamespace Namespace는 하나의 클러스터를 논리적으로 분리하는 단위다. dev, staging, production 같은 환경을 같은 클러스터 안에서 격리할 때 사용한다. 리소스 이름은 Namespace 안에서만 고유하면 된다.\n네트워크 Ingress Service가 클러스터 내부의 접근점이라면, Ingress는 클러스터 외부에서 내부 Service로 트래픽을 라우팅한다. 도메인이나 경로 기반으로 여러 Service에 분배할 수 있다. API Gateway 없이도 경로별 라우팅이 가능해서 백엔드 서비스 구성에 자주 사용된다.\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: api-ingress spec: rules: - host: api.example.com http: paths: - path: /users pathType: Prefix backend: service: name: user-service port: number: 80 - path: /orders pathType: Prefix backend: service: name: order-service port: number: 80 api.example.com/users는 user-service로, /orders는 order-service로 전달된다. Ingress 자체는 규칙 정의이고, 실제 트래픽 처리는 Ingress Controller(nginx, traefik 등)가 담당한다.\n설정과 저장소 ConfigMap과 Secret 애플리케이션 설정을 Container Image에 포함하면 설정이 바뀔 때마다 Image를 다시 빌드해야 한다. ConfigMap은 설정 데이터를 별도 오브젝트로 분리한다. 환경 변수로 주입하거나 볼륨으로 마운트해서 Container에 전달한다.\nSecret은 ConfigMap과 구조가 같지만, 패스워드나 API 키 같은 민감 정보를 저장한다. base64로 인코딩되어 저장되므로 암호화는 아니지만, RBAC과 결합하면 접근 제어가 가능하다.\n백엔드 개발에서 DB 접속 정보, 외부 API 키 같은 설정을 코드에서 분리할 때 ConfigMap과 Secret이 그 역할을 한다.\nPersistentVolume Pod이 삭제되면 내부 데이터도 사라진다. 데이터베이스처럼 영속 저장이 필요한 워크로드에는 PersistentVolume(PV)을 사용한다. PV는 클러스터 관리자가 미리 프로비저닝한 저장소이고, PersistentVolumeClaim(PVC)은 Pod이 PV를 요청하는 방법이다. Pod은 PVC만 알면 되고, 실제 저장소가 어디인지는 몰라도 된다.\n헬스 체크 k8s가 Pod의 상태를 자동으로 판단하려면 애플리케이션이 건강한지 확인할 방법이 필요하다. 이 부분은 백엔드 개발자가 직접 구현해야 한다.\nreadinessProbe는 Pod이 트래픽을 받을 준비가 됐는지 확인한다. 준비되지 않은 Pod에는 Service가 트래픽을 보내지 않는다. 서버 시작 후 캐시 워밍업이 끝나야 요청을 받을 수 있는 경우에 사용한다.\nlivenessProbe는 Pod이 정상적으로 동작하는지 확인한다. 실패하면 k8s가 Pod을 재시작한다. 데드락에 빠지거나 응답 불능 상태에 빠진 경우를 감지한다.\nstartupProbe는 시작이 느린 애플리케이션에 사용한다. 시작이 완료될 때까지 liveness/readiness 체크를 유예한다.\ncontainers: - name: api image: api-server:1.2.0 readinessProbe: httpGet: path: /health/ready port: 8080 periodSeconds: 5 livenessProbe: httpGet: path: /health/live port: 8080 periodSeconds: 10 백엔드 서버에 /health/ready와 /health/live 엔드포인트를 구현하면 된다. readiness는 DB 연결, 외부 의존성 확인을 포함하고, liveness는 서버 프로세스 자체의 생존 여부만 확인하는 것이 일반적이다.\n스케일링 수동 스케일링 kubectl scale deployment api-server --replicas=5 Deployment의 replicas를 직접 변경한다. 트래픽 패턴이 예측 가능하거나, 이벤트에 맞춰 사전에 증설할 때 사용한다.\nHPA 트래픽이 불규칙하면 수동 스케일링으로는 대응이 어렵다. HPA(Horizontal Pod Autoscaler)는 메트릭을 기반으로 Pod 수를 자동 조절한다.\napiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: api-server-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: api-server minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 이 설정은 \u0026ldquo;cpu 사용률이 평균 70%를 넘으면 Pod을 늘리고, 낮아지면 줄여라. 최소 2개, 최대 10개\u0026quot;를 의미한다.\nmetrics-server가 각 Pod의 자원 사용량을 주기적으로 수집한다. HPA Controller가 현재 평균 사용률과 목표 사용률을 비교해서 필요한 Pod 수를 계산한다. 현재 Pod 3개의 CPU 평균이 90%이고 목표가 70%이면, 90/70 × 3 ≈ 4개로 확장한다.\nHPA가 동작하려면 Deployment에 resource requests가 반드시 설정되어 있어야 한다. requests가 없으면 \u0026ldquo;70%\u0026ldquo;의 기준이 되는 분모가 없다.\ncontainers: - name: api resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi requests는 Pod이 보장받는 최소 자원이다. Scheduler가 Node에 Pod을 배치할 때 이 값을 기준으로 여유 공간을 판단한다. limits는 Pod이 사용할 수 있는 최대 자원이다. CPU limits를 초과하면 스로틀링되고, 메모리 limits를 초과하면 OOMKill로 Pod이 종료된다. 백엔드 서비스의 메모리 사용량을 모니터링해서 적절한 값을 설정하는 것이 중요하다.\nCPU 외에 메모리, 커스텀 메트릭(요청 수, 큐 길이 등)도 HPA 기준으로 사용할 수 있다. Pod 수가 아니라 개별 Pod의 리소스를 자동 조정하는 VPA(Vertical Pod Autoscaler)도 있지만, HPA와 같은 메트릭으로 동시에 사용하면 충돌할 수 있다.\n운영 kubectl 기본 kubectl get pods # Pod 목록 kubectl get pods -o wide # Node 배치 정보 포함 kubectl describe pod \u0026lt;name\u0026gt; # Pod 상세 + 이벤트 kubectl logs \u0026lt;pod-name\u0026gt; # 로그 확인 kubectl logs \u0026lt;pod-name\u0026gt; -f # 실시간 로그 kubectl exec -it \u0026lt;pod-name\u0026gt; -- sh # Container 내부 접속 디버깅 흐름 kubectl get pods로 Pod 상태를 본다. CrashLoopBackOff, ImagePullBackOff 같은 상태가 원인을 알려준다. kubectl describe pod \u0026lt;name\u0026gt;으로 이벤트를 확인하면 Scheduler가 Node에 배치하지 못했는지, 리소스가 부족한지, Image를 가져오지 못했는지 파악할 수 있다. kubectl logs로 애플리케이션 로그를 확인한다. 로그만으로 안 되면 kubectl exec로 Container 안에 들어가서 직접 확인한다.\n배포 후 Pod이 뜨지 않는 상황은 백엔드 개발자가 가장 자주 마주치는 k8s 문제다. 이 흐름을 익혀두면 대부분의 상황에서 원인을 파악할 수 있다.\n정리 k8s는 \u0026ldquo;원하는 상태를 선언하면 시스템이 그 상태를 유지한다\u0026quot;는 선언적 모델로 동작한다. Deployment에 replicas: 3을 적으면 k8s가 3개를 유지하고, HPA에 목표 CPU 사용률을 적으면 Pod 수를 자동으로 조절한다. 백엔드 개발자가 직접 챙겨야 할 부분은 헬스 체크 엔드포인트 구현, resource requests 설정, 그리고 디버깅을 위한 kubectl 사용법이다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/kubernetes-fundamentals/","summary":"Container 오케스트레이션의 기본 구조와 백엔드 개발자가 알아야 할 핵심 오브젝트, 네트워크, 스케일링, 운영 방법을 정리한다.","title":"Kubernetes 기초"},{"content":"Go의 동시성 모델은 CSP(Communicating Sequential Processes)를 기반으로 한다. 핵심 철학은 하나다.\n\u0026ldquo;Do not communicate by sharing memory; instead, share memory by communicating.\u0026rdquo;\n공유 메모리에 락을 거는 대신, 채널을 통해 데이터를 전달한다. Goroutine이 실행 단위를, Channel이 통신을, sync/atomic 패키지가 보조 동기화를 담당한다.\nGoroutine Goroutine은 Go의 경량 실행 단위다. OS 스레드가 아니다. Go 런타임이 여러 goroutine을 소수의 OS 스레드에 멀티플렉싱한다.\ngo func() { // 이 함수는 새 goroutine에서 실행된다 }() go 키워드 하나로 생성된다. 초기 스택은 수 KB에 불과하고, 필요에 따라 런타임이 자동으로 늘리고 줄인다. OS 스레드라면 수천 개를 만들기 어렵지만, goroutine은 수십만 개를 같은 주소 공간에서 생성할 수 있다.\nGMP 스케줄러 Go 런타임은 M:N 스케줄링 모델을 사용한다. 이를 GMP 모델이라 부른다.\nflowchart TB subgraph Runtime[\"Go Runtime\"] subgraph P1[\"P (Processor)\"] LRQ1[\"로컬 큐: G1, G2, G3\"] end subgraph P2[\"P (Processor)\"] LRQ2[\"로컬 큐: G4, G5\"] end GRQ[\"글로벌 큐: G6, G7...\"] end subgraph OS[\"OS\"] M1[\"M (OS Thread)\"] M2[\"M (OS Thread)\"] M3[\"M (OS Thread)\"] end P1 --\u003e M1 P2 --\u003e M2 GRQ -.-\u003e|\"P의 로컬 큐가 비면 가져감\"| P1 G(Goroutine). 실행할 함수와 스택을 가진 경량 실행 단위다.\nM(Machine). OS 스레드다. 실제 CPU에서 명령을 실행한다.\nP(Processor). 논리적 프로세서다. goroutine을 실행하기 위한 컨텍스트를 제공한다. GOMAXPROCS로 P의 수를 설정하며, 기본값은 CPU 코어 수다.\nP는 로컬 큐를 가지고 있다. goroutine이 생성되면 현재 P의 로컬 큐에 들어간다. M은 P에 연결되어 로컬 큐의 goroutine을 하나씩 실행한다. 하나의 goroutine이 시스템 콜로 블로킹되면, 런타임은 같은 P의 다른 goroutine을 다른 M으로 옮겨서 실행을 계속한다.\n오버헤드가 적어 같은 주소 공간에서 수십만 개의 goroutine을 다룰 수 있다.\nChannel Channel은 goroutine 간 데이터를 전달하는 타입이 지정된 통신 수단이다.\nUnbuffered Channel ch := make(chan int) 송신자와 수신자가 동시에 준비되어야 전달이 완료된다. 송신자는 수신자가 값을 가져갈 때까지, 수신자는 송신자가 값을 보낼 때까지 블로킹된다. 통신과 동기화가 동시에 이루어진다.\nsequenceDiagram participant G1 as Goroutine 1 participant Ch as Channel (unbuffered) participant G2 as Goroutine 2 G1-\u003e\u003eCh: 송신 (블로킹) Note over G1,Ch: G2가 수신할 때까지 대기 G2-\u003e\u003eCh: 수신 Ch--\u003e\u003eG1: 송신 완료 Ch--\u003e\u003eG2: 값 전달 Buffered Channel ch := make(chan int, 10) // 버퍼 크기 10 버퍼에 여유가 있으면 송신이 즉시 완료된다. 버퍼가 가득 차면 송신자가 블로킹된다. 동시 실행 수를 제한하는 세마포어로 활용할 수 있다.\n방향성 채널에 방향을 지정하면 함수의 의도가 명확해진다.\nfunc producer(out chan\u0026lt;- int) { // 송신 전용 out \u0026lt;- 42 } func consumer(in \u0026lt;-chan int) { // 수신 전용 val := \u0026lt;-in } select select문은 여러 채널 연산 중 준비된 것을 실행한다. 여러 채널을 동시에 대기하거나, 타임아웃을 처리하거나, 비블로킹 연산을 구현할 때 사용한다.\nselect { case msg := \u0026lt;-ch1: handle(msg) case ch2 \u0026lt;- response: // 송신 완료 case \u0026lt;-quit: return default: // 어떤 채널도 준비되지 않았을 때 } default를 포함하면 어떤 채널도 준비되지 않았을 때 블로킹 없이 넘어간다.\n주요 패턴 flowchart LR subgraph FanOut[\"Fan-Out\"] IN1[\"입력\"] --\u003e W1[\"Worker 1\"] IN1 --\u003e W2[\"Worker 2\"] IN1 --\u003e W3[\"Worker 3\"] end subgraph FanIn[\"Fan-In\"] R1[\"결과 1\"] --\u003e OUT1[\"출력\"] R2[\"결과 2\"] --\u003e OUT1 R3[\"결과 3\"] --\u003e OUT1 end Fan-Out. 하나의 채널에서 여러 goroutine이 읽어 작업을 분배한다.\nFan-In. 여러 채널의 결과를 하나의 채널로 합친다.\nPipeline. 각 단계가 채널로 연결된 처리 파이프라인이다. 입력 채널에서 읽고, 처리하고, 출력 채널로 보낸다.\nsync 패키지 채널이 항상 최선은 아니다. 공유 상태를 보호하는 단순한 경우에는 sync 패키지가 적합하다.\nMutex. 하나의 goroutine만 임계 영역에 접근하도록 보장한다. Lock()과 Unlock()으로 제어한다.\nRWMutex. 읽기는 여러 goroutine이 동시에, 쓰기는 독점적으로 접근한다. 읽기가 쓰기보다 빈번한 경우에 효과적이다.\nWaitGroup. 여러 goroutine의 완료를 대기한다. Add()로 카운터를 증가시키고, Done()으로 감소시키고, Wait()로 0이 될 때까지 대기한다.\nOnce. 함수를 정확히 한 번만 실행한다. 초기화에 사용된다.\natomic 패키지 sync/atomic 패키지는 정수나 포인터에 대한 원자적 연산을 제공한다. 락 없이 단일 변수를 안전하게 읽고 쓸 수 있다.\nCompareAndSwap(CAS)은 lock-free 알고리즘의 기초가 되는 연산이다. 현재 값이 기대한 값과 같으면 새 값으로 교체하고 true를 반환한다. 다르면 false를 반환하고 아무 것도 하지 않는다.\nvar counter int64 // 여러 goroutine에서 안전하게 증가 atomic.AddInt64(\u0026amp;counter, 1) // CAS: 기대값이 맞을 때만 교체 atomic.CompareAndSwapInt64(\u0026amp;counter, oldVal, newVal) sync 패키지보다 낮은 수준의 도구다. 단순 카운터나 플래그에는 적합하지만, 복잡한 동기화에는 Mutex나 Channel이 낫다.\n선택 기준 상황 도구 goroutine 간 데이터 전달 Channel 작업 분배, 결과 수집 Channel (fan-out/fan-in) 공유 상태 보호 (읽기/쓰기) sync.RWMutex 동시 실행 수 제한 Buffered Channel 여러 goroutine 완료 대기 sync.WaitGroup 단순 카운터/플래그 sync/atomic Go 공식 위키도 같은 결론으로 정리한다. 채널은 소유권 전달, 작업 분배, 비동기 결과 전달에 적합하다. Mutex는 캐시, 상태 보호처럼 공유 자원의 접근 제어에 적합하다. 둘 다 유효한 도구이며, 상황에 따라 선택한다.\n","permalink":"https://wid-blog.github.io/posts/tech/language/go-concurrency-model/","summary":"Go의 동시성 모델은 CSP를 기반으로 Goroutine과 Channel을 핵심 도구로 제공한다. 각 도구의 동작 원리와 선택 기준을 정리한다.","title":"Go Concurrency 모델"},{"content":"Go의 동시성 모델을 개념으로는 알고 있었다. Goroutine은 경량 스레드이고, channel로 통신하고, sync 패키지로 동기화한다. 하지만 패턴별 차이를 코드와 수치로 직접 비교해본 적은 없었다.\n직접 구현하고 벤치마크를 돌려보기로 했다. mutex, channel, lock-free 세 가지 접근을 하나의 프로젝트에서 다뤘다.\nMutex 첫 번째로 구현한 것은 sync.RWMutex 기반의 동시성 안전한 맵이었다. 쓰기에는 Lock(), 읽기에는 RLock()을 사용해서 여러 goroutine이 동시에 접근할 수 있게 했다.\n구현 후 Go 표준 라이브러리의 sync.Map과 벤치마크를 비교했다. 세 가지 시나리오를 만들었다. 같은 키에 대한 쓰기 경합, goroutine마다 다른 키에 쓰기, 읽기 90% + 쓰기 10%.\n같은 키 경합에서는 둘의 성능이 비슷했다. 하지만 다른 키에 분산 쓰기를 하면 sync.Map이 2-3배 빨랐고, 읽기 비중이 높을 때도 33% 빨랐다. sync.Map 공식 문서에서 명시한 최적화 조건과 정확히 일치하는 결과였다. 반대로 같은 키에 집중적으로 쓰는 상황에서는 sync.Map이 메모리를 더 사용할 뿐 이점이 없었다.\nChannel 채널 패턴에서는 데이터 흐름 제어를 구현했다. FanOut은 하나의 입력 채널에서 여러 출력 채널로 데이터를 분배한다. select문으로 먼저 받을 수 있는 출력 채널에 전달하는 방식이다.\nTurnOut은 여러 입력에서 여러 출력으로 라우팅하면서 quit 채널로 종료 신호를 처리한다. select문에 quit 채널을 포함시키면 데이터 처리와 종료 신호를 하나의 루프에서 자연스럽게 다룰 수 있었다. 채널을 닫고 남은 데이터를 소진하는 정리 과정도 구현했다.\n제너릭 타입([T any])을 활용해서 타입에 무관하게 재사용할 수 있게 만들었다.\nLock-free 두 가지 lock-free 패턴을 구현했다.\nSpinningCAS는 atomic.CompareAndSwapInt32로 락을 구현한다. 다른 goroutine이 락을 점유하고 있으면 대기 큐에 들어가지 않고, CAS 연산을 반복하며 스핀한다. 여기서 runtime.Gosched()가 중요했다. 스핀 루프에서 CPU를 양보하지 않으면 다른 goroutine이 실행되지 못해 교착 상태에 가까운 상황이 발생했다. 한 줄을 추가하는 것만으로 동작이 달라지는 경험이었다.\nSpinningCAS와 표준 sync.Mutex를 벤치마크로 비교했다. 단일 공유 변수를 증가시키는 높은 경합 시나리오에서 SpinningCAS가 약 7배 빨랐다. Mutex는 goroutine을 대기 큐에 넣고 깨우는 오버헤드가 있지만, CAS는 바로 재시도한다. 짧은 임계 영역에서는 스핀이 유리하다는 것을 수치로 확인했다.\nTicketStorage는 순서 보장이 필요한 경우를 위한 패턴이다. atomic.AddUint64로 티켓 번호를 발급하고, 자신의 번호가 올 때까지 CAS로 스핀한다. 공정성(FIFO)을 보장하지만, 경합이 높으면 대기 시간이 길어지는 트레이드오프가 있다.\n회고 동시성 패턴을 개념으로 아는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.\n가장 크게 배운 것은 벤치마크 방법론이었다. 처음에는 고정 횟수로 goroutine을 생성하는 방식으로 벤치마크를 작성했는데, 결과가 실행마다 달라졌다. Go의 b.RunParallel을 사용해 프레임워크가 반복 횟수를 자동 조절하도록 수정하자, 결과가 안정되고 패턴 간 차이가 명확해졌다. 벤치마크 코드의 정확성이 결과를 결정한다는 것을 체감했다.\nsync.Map은 \u0026ldquo;항상 빠른 map\u0026quot;이 아니라, 공식 문서에서 명시한 조건에서만 이점이 있었다. SpinningCAS는 짧은 임계 영역에서 Mutex를 압도했지만, 긴 임계 영역이나 낮은 경합에서는 다를 수 있다. 도구마다 최적의 조건이 다르고, 그 조건을 확인하는 것이 벤치마크의 역할이었다.\nruntime.Gosched() 한 줄이 동작을 바꿔놓은 경험도 기억에 남는다. 동시성 코드에서는 이론적으로 맞는 구현이 실행 환경에서는 다르게 동작할 수 있다.\n코드와 수치로 마주해야 비로소 패턴마다의 트레이드오프가 손에 잡혔다. 그 차이를 확인한 프로젝트였다.\n참고 concurrency-go GitHub Repository Go Concurrency 모델 ","permalink":"https://wid-blog.github.io/posts/career/personal/concurrency-go-retrospective/","summary":"Go의 동시성 패턴 세 가지(mutex, channel, lock-free)를 직접 구현하고 벤치마크하며 체화한 과정의 기록.","title":"concurrency-go"},{"content":"MongoDB 와 Redis 는 NoSQL 을 다룰 때 가장 자주 마주치는 두 도구다. 같은 NoSQL 범주에 들어가지만 실무에서 맡는 역할은 다르다. MongoDB 는 Document store 로 영구 저장의 주 저장소 역할을 하고, Redis 는 메모리 기반 저장소로 캐시·세션 같은 보조 역할에 주로 쓰인다.\n채팅 시스템 chat-services 를 개발할 때 처음에는 Redis 에 메시지를 저장했다가 영속성이 필요해져 MongoDB 로 옮긴 적이 있다. 회고에서는 짧게 짚고 지나간 결정인데, 같은 NoSQL 인데 두 도구의 역할이 왜 갈리는지를 데이터 모델·스토리지·스키마·확장·사용 사례를 기준으로 풀어둔다.\n데이터 모델 두 도구가 가장 먼저 갈리는 곳은 저장 단위다.\nRedis 는 Key-Value 저장소다. 키 하나에 값 하나를 매핑하는 단순한 구조 위에 String, List, Set, Sorted Set, Hash, Stream 같은 자료구조를 값 타입으로 다룰 수 있다. 단일 키 조회·쓰기가 가장 자연스러운 접근이고, 복합 조회는 클라이언트나 별도 인덱스에 맡긴다.\nMongoDB 는 Document store 다. 키-값을 넘어 JSON-like 문서(BSON) 를 단위로 저장한다. 한 문서에 중첩 필드, 배열, 다양한 타입이 들어갈 수 있고 컬렉션 단위로 묶인다. 한 문서가 한 엔티티에 대응하는 경우가 많아 도메인 모델을 그대로 옮기기에 자연스럽다.\n스토리지와 영속성 Redis 는 데이터를 메모리에 보관한다. 영속성은 선택 사항으로, 스냅샷 방식의 RDB 와 명령 단위 로그 방식의 AOF 가 있다. RDB 만 활성화된 일반 구성에서는 마지막 스냅샷 이후의 변경이 장애 시 유실될 수 있다. AOF 를 켜면 변경 단위로 로그가 남아 손실 범위가 줄지만, 주 저장 매체가 메모리라는 점은 그대로다.\nMongoDB 는 디스크를 주 저장 매체로 쓴다. 스토리지 엔진은 기본으로 WiredTiger 가 쓰이고, 쓰기는 저널과 함께 디스크에 기록된다. 처음부터 영속성을 전제로 동작한다. 여기에 replica set 을 구성하면 여러 노드에 복제본을 두어 단일 노드 장애에서도 데이터를 유지할 수 있다.\n스키마와 쿼리 Redis 의 쿼리는 명령 단위다. GET, SET, HGETALL, ZRANGE 같은 자료구조별 명령을 직접 호출한다. 조건 검색 같은 복합 쿼리는 명령만으로 표현하기 어려워 RediSearch 같은 모듈을 따로 붙여야 한다. 스키마 개념이 없어, 값을 어떻게 해석할지는 애플리케이션이 책임진다.\nMongoDB 는 자체 쿼리 언어 MQL 을 제공한다. find, aggregate, update 같은 연산으로 조건 검색, 집계, 부분 갱신을 표현한다. 컬렉션에 스키마를 강제하지 않지만, 필요하면 JSON Schema 기반 검증을 걸어 일부 필드의 형태를 제한할 수 있다. 도메인이 자주 변하는 초기에는 이 유연함이 잘 맞고, 안정화된 뒤에는 검증을 점진적으로 더해가는 운영도 가능하다.\n확장과 가용성 Redis Cluster 는 키 공간을 일정 수의 슬롯으로 나누어 여러 노드에 분산한다. 각 슬롯이 노드에 할당되고, 한 노드가 자기 슬롯의 키를 책임진다. 복제는 master-replica 구조로 구성한다. 장애가 나면 sentinel 또는 Cluster 가 replica 를 master 로 승격한다.\nMongoDB 의 확장은 sharded cluster 와 replica set 두 가지 방식으로 나뉜다. sharded cluster 는 컬렉션을 shard key 기준으로 잘라 여러 shard 에 나눠 담는다. replica set 은 데이터 사본을 여러 노드에 두고, primary 가 장애를 겪으면 secondary 중 하나가 자동으로 새 primary 로 선출된다. 자동 장애 조치가 기본 동작에 포함된다.\n사용 사례 두 도구의 역할이 여기서 갈린다.\nRedis 는 캐시, 세션 저장, rate limit, 분산 lock, 짧은 큐 같은 보조 역할에 주로 쓰인다. 빠른 응답과 낮은 지연시간이 중요할 때, 잠시 보관해도 되는 데이터를 메모리에서 처리할 때 잘 맞는다.\nMongoDB 는 primary store 자리다. 영구 저장이 필요한 도메인 데이터를 본 저장소로 다루고, 스키마 변경이 잦거나 문서 구조가 자연스러운 도메인에서 자주 선택된다. 사용자, 콘텐츠, 주문, 로그 같은 데이터가 전형이다.\n같은 NoSQL 이지만 실무에서는 \u0026ldquo;이 데이터를 영구 저장할 것인가, 보조 캐시로 둘 것인가\u0026rdquo; 라는 질문이 선택을 가른다.\n함께 쓰는 패턴 실무에서는 둘 중 하나만 쓰는 경우보다 함께 쓰는 경우가 더 잦다.\n가장 흔한 형태는 MongoDB 를 primary store 로 두고 Redis 를 그 앞에 캐시 계층으로 두는 구성이다. 자주 조회되는 데이터를 Redis 에 캐싱해 디스크 접근을 줄이고, 캐시 미스가 나면 MongoDB 에서 읽어 다시 캐시에 채워 넣는다. 세션 저장, rate limit, 일회성 토큰 같은 보조 데이터는 Redis 에만 두고 MongoDB 는 도메인 데이터에 집중하게 한다.\nchat-services 의 메시지 저장도 Redis 로 시작했다가 영속성이 필요해지면서 MongoDB 로 옮겼다. 여기에 캐시 계층으로 Redis 를 추가하면, 같은 두 도구를 역할에 맞게 나눠 쓰는 구성이 된다.\n선택 기준 지금까지의 비교 항목을 표로 정리하면 다음과 같다.\n항목 Redis MongoDB 데이터 모델 Key-Value Document 주 저장 매체 메모리 디스크 영속성 기본값 옵션 (RDB/AOF) 기본 활성 (저널 + replica set) 쿼리 명령 기반 MQL (find/aggregate) 스키마 없음 유연 + 선택적 검증 확장 Cluster (슬롯 분산) sharded cluster (shard key) 자동 장애 조치 sentinel/Cluster replica set 기본 주된 역할 캐시, 세션, rate limit primary store 다음 세 가지를 따져보면 된다.\n영구 저장이 필요하면 MongoDB 를 primary 로 두고 Redis 를 보조 캐시로 둔다. 지연시간이 가장 중요한 경로라면 Redis. 스키마가 자주 변한다면 MongoDB 의 Document 모델이 잘 맞는다. 같은 NoSQL 이지만 두 도구는 결국 서로 다른 역할을 맡는다. 그래서 함께 쓰는 구성이 흔하다.\n참고 chat-services — Redis → MongoDB 전환 맥락 RDB Transaction 의 ACID 가 실제로 보장하는 것 — RDB 의 트랜잭션 보장 MongoDB 공식 docs — Storage Engines, Replica Set, Sharded Cluster Redis 공식 docs — Persistence, Cluster, Replication ","permalink":"https://wid-blog.github.io/posts/tech/database/mongodb-vs-redis/","summary":"같은 NoSQL 우산 아래에서 MongoDB 와 Redis 가 서로 다른 역할로 자리잡은 이유. 데이터 모델·스토리지·스키마·확장·사용 사례를 기준으로 비교 정리.","title":"MongoDB와 Redis — 같은 NoSQL, 다른 역할"},{"content":"실무에서 Kafka를 쓰고 있었지만, 클러스터를 직접 구성하거나 topic 설계부터 consumer group 전략까지 처음부터 결정해본 적은 없었다. Hexagonal Architecture도 마찬가지로 기존 코드에서 port/adapter 패턴을 따르고 있었을 뿐, 빈 프로젝트에서 레이어를 나눠본 경험은 없었다. 둘 다 직접 다뤄보고 싶어 채팅 시스템을 만들기로 했다.\n왜 채팅인가 채팅은 Kafka의 pub/sub 모델과 자연스럽게 맞물리는 도메인이다. 메시지를 발행하고 구독자에게 전달하는 흐름이 채팅의 핵심 동작과 일치한다.\nWebSocket 기반 실시간 통신, 이벤트 기반 아키텍처, 멀티 인스턴스 간 메시지 동기화. 이 세 가지를 하나의 프로젝트에서 다룰 수 있다고 판단했다.\n기술 선택 Go + Java 채팅 서비스는 Go로 만들었다. 경량 goroutine 기반의 동시성 처리가 WebSocket 서버에 적합하다고 봤다. 사용자 인증 서비스는 Java(Spring WebFlux)로 만들었다. OAuth2 + JWT 인증은 Spring Security 생태계가 잘 갖추어져 있었고, 이미 익숙한 프레임워크였다.\nAPI Gateway는 Kotlin으로 Spring Cloud Gateway를 사용했다. user-service와 같은 reactive 스택 위에서 동작하는 점, Java 생태계와의 연속성이 선택 이유였다.\nMongoDB 채팅 메시지는 document 구조로 저장하는 것이 자연스러웠다. 방과 메시지가 비정형 데이터에 가까웠고, 스키마 변경이 잦을 것이라 예상했다.\n처음에는 Redis를 사용했다. 빠르게 프로토타이핑하기에는 좋았지만, 메시지 영속성이 필요해지면서 MongoDB로 전환했다.\nKafka KRaft Kafka는 KRaft 모드로 구성했다. ZooKeeper 의존성 없이 Kafka 자체적으로 메타데이터를 관리하는 방식이다. 별도 ZooKeeper 클러스터를 운영하지 않아도 되어 인프라 구성이 단순해졌다.\n3-node 클러스터를 Docker Compose로 구성했고, 각 노드가 controller와 broker 역할을 겸임하도록 설정했다.\n아키텍처 진화 프로젝트는 한 번에 설계한 것이 아니다. PR 단위로 점진적으로 바뀌어갔다.\n시작 처음에는 user-service(Java)와 chat-service(Go) 두 개로 시작했다. chat-service가 WebSocket 핸들링, 방 관리, 메시지 저장, 브로드캐스팅을 전부 담당했다. 저장소는 Redis였다.\nRedis → MongoDB 메시지를 영속적으로 저장해야 했다. Redis는 인메모리 특성상 적합하지 않다고 판단했고, MongoDB로 교체했다. 이 과정에서 repository 계층만 교체하면 되는 구조의 이점이 와닿았다. Hexagonal Architecture를 적용해둔 덕분이었다.\nHexagonal Architecture 정리 user-service를 먼저 정리했다. 기존에 대략적으로 나눠져 있던 패키지를 domain/entity, port/driving, port/driven, adapter/driving, adapter/driven 구조로 재배치했다. 이후 chat-service에도 같은 구조를 적용했다.\nKafka 도입 Kafka producer를 먼저 구현하고, 이어서 consumer를 추가했다. 이때 동시성 문제를 마주했다.\n채팅 방에 사용자가 join/leave하는 동안 메시지가 동시에 브로드캐스트되면 race condition이 발생했다. RoomManager에 2단계 lock 전략을 도입해서 해결했다. 방 목록 접근에는 RoomManager 레벨의 RWMutex를, 방 내부 참가자 접근에는 LiveRoom별 RWMutex를 사용해 병목을 줄였다.\n서비스 분리 chat-service가 커지면서 messenger-service와 message-service를 분리했다. messenger-service는 Kafka producer/consumer와 WebSocket 핸들링을, message-service는 메시지 저장과 조회를 담당한다.\nFat Domain 초기에는 도메인 엔티티가 데이터만 들고 있었다. 도메인 로직을 엔티티로 옮기고, application 계층에 usecase 패턴을 도입했다. 각 usecase는 단일 Handle 메서드를 가지며, 하나의 비즈니스 동작만 책임진다.\nKafka를 채팅 브로커로 메시지 흐름은 다음과 같다.\nsequenceDiagram participant C as WebSocket Client participant S as SendUseCase participant DB as MongoDB participant K as Kafka participant B as MessageBroker participant R as RoomManager C-\u003e\u003eS: 메시지 전송 S-\u003e\u003eDB: 메시지 저장 S-\u003e\u003eK: Kafka publish K-\u003e\u003eB: Consumer 수신 B-\u003e\u003eS: OnReceive 콜백 S-\u003e\u003eR: Broadcast R-\u003e\u003eC: WebSocket 전달 SendUseCase가 MessageSubscriber 인터페이스를 직접 구현하고, MessageBroker에 자기 자신을 등록한다. Observer 패턴이다. Consumer가 메시지를 수신하면 등록된 모든 subscriber의 OnReceive를 호출하고, subscriber는 RoomManager를 통해 해당 방의 모든 WebSocket 클라이언트에게 메시지를 전달한다.\n이 구조의 이점은 수평 확장이다. 채팅 서비스 인스턴스가 여러 개 떠 있을 때, 한 인스턴스에서 발생한 메시지가 Kafka를 통해 다른 인스턴스에도 전달된다. 같은 방에 접속한 사용자가 서로 다른 인스턴스에 연결되어 있어도 메시지를 주고받을 수 있다.\n회고 Kafka를 직접 다뤄보고 싶어서 시작한 프로젝트였다.\nHexagonal Architecture가 Go에서 자연스럽게 동작한다는 것을 확인했다. Go의 암묵적 인터페이스 덕분에 port를 정의하고 adapter를 구현하는 과정이 간결했다. DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 방식도 오히려 명시적이고 추적하기 쉬웠다.\n동시성 제어에서 가장 많이 배웠다. 처음에는 하나의 RWMutex로 전체 방 목록을 보호했는데, 병목이 생겼다. 방 목록과 방 내부 참가자를 분리해서 각각 lock을 거는 2단계 전략으로 바꾸니, 벤치마크에서 확연한 차이가 나타났다. 이론으로 이해하는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.\n아쉬운 점도 있다. 테스트 코드가 부족했다. Hexagonal Architecture의 핵심 이점 중 하나가 port를 mock으로 교체해서 테스트하기 쉽다는 것인데, 테스트를 충분히 작성하지 못했다.\ngRPC도 설정만 해두고 서비스 간 통신에는 적용하지 못했다. 현재 서비스 간 통신은 전부 REST다. gRPC 적용은 다음 단계로 남겨두었다.\nKafka를 직접 다뤄보고 싶어서 시작했고, 그 이상을 얻었다. 아키텍처 설계, 동시성 제어, 서비스 분리. 하나의 시스템 안에서 함께 마주하는 것은 각각을 따로 공부하는 것과 다른 경험이었다.\n참고 chat-services GitHub Repository Go에서 Hexagonal Architecture 구현 Kafka 기초와 KRaft 모드 Spring WebFlux 기본 — 논블로킹 I/O와 리액티브 스택 HTTP/1.1과 HTTP/2 MongoDB와 Redis — 같은 NoSQL, 다른 역할 — Redis → MongoDB 전환 결정의 배경 정리 ","permalink":"https://wid-blog.github.io/posts/career/personal/chat-services-retrospective/","summary":"실무에서 깊이 다루기 어려웠던 Kafka와 Hexagonal Architecture를 채팅 시스템 개인 프로젝트로 직접 설계하고 구현한 과정의 기록.","title":"chat-services"},{"content":"Spring MVC는 요청이 들어오면 스레드를 하나 할당한다. 그 스레드는 DB 쿼리 결과가 돌아올 때까지, 외부 API 응답이 올 때까지 대기한다. I/O 바운드 워크로드에서는 스레드 대부분이 대기 상태에 놓인다. WebFlux는 이벤트 루프 기반 논블로킹 모델로 이 구조를 바꾼다.\nSpring MVC의 스레드 모델 Spring MVC는 thread-per-request 모델이다. Tomcat이 요청을 받으면 스레드 풀에서 스레드 하나를 꺼내 할당한다. 해당 스레드가 컨트롤러 → 서비스 → DB 접근 → 응답 반환까지 전 과정을 처리한다.\n문제는 I/O 대기 구간이다. JDBC로 DB 쿼리를 실행하면 결과가 올 때까지 스레드가 블로킹된다. RestTemplate으로 외부 API를 호출해도 마찬가지다. 스레드는 아무 일도 하지 않으면서 자원을 점유한다.\n동시 요청 수는 스레드 풀 크기에 제한된다. Tomcat의 기본 스레드 풀은 200개다. 201번째 요청은 앞선 요청이 끝날 때까지 대기열에서 기다린다. I/O 대기가 길어지면 처리량이 급격히 떨어진다.\nWebFlux의 이벤트 루프 모델 WebFlux는 Netty 기반 이벤트 루프 위에서 동작한다. 스레드가 요청을 받으면 I/O 작업을 OS에 위임하고 즉시 다음 요청을 처리한다. I/O가 완료되면 콜백으로 결과를 받아 나머지 로직을 실행한다.\n스레드가 대기하지 않으므로 소수의 스레드로 많은 동시 요청을 처리할 수 있다. CPU 코어 수만큼의 이벤트 루프 스레드가 수천 개의 동시 연결을 감당한다.\nI/O 바운드 워크로드에서 처리량이 올라간다. 외부 API 호출이 많은 API Gateway, OAuth2 인증 서버, 마이크로서비스 간 통신 같은 워크로드가 대표적이다.\nCPU 바운드 작업에서는 이점이 없다. 이벤트 루프 스레드에서 무거운 연산을 실행하면 다른 요청 처리가 밀린다. 이미지 처리, 암호화 같은 작업은 별도 스레드 풀로 분리해야 한다.\nReactor 이벤트 루프 모델은 콜백 기반이다. 콜백이 중첩되면 코드 복잡도가 올라간다. Project Reactor는 이를 선언적 파이프라인으로 해결한다. 핵심 타입은 두 가지다.\nMono: 0 또는 1개의 결과를 비동기로 반환한다. DB에서 사용자 한 명을 조회하거나, 외부 API에서 토큰 하나를 받아올 때 사용한다.\nFlux: 0~N개의 결과를 비동기 스트림으로 반환한다. DB에서 목록을 조회하거나, 실시간 이벤트를 구독할 때 사용한다.\n@GetMapping(\u0026#34;/{id}\u0026#34;) public Mono\u0026lt;User\u0026gt; getUser(@PathVariable String id) { return userRepository.findById(id); } @GetMapping public Flux\u0026lt;User\u0026gt; getAllUsers() { return userRepository.findAll(); } Reactor는 연산자 체이닝으로 비동기 파이프라인을 구성한다.\npublic Mono\u0026lt;AuthToken\u0026gt; login(String code) { return oauth2Client.getToken(code) // Mono\u0026lt;TokenDto\u0026gt; .map(tokenMapper::toDomain) // Mono\u0026lt;Token\u0026gt; .flatMap(userRepository::upsertUser) // Mono\u0026lt;User\u0026gt; .map(authService::generateToken); // Mono\u0026lt;AuthToken\u0026gt; } map은 동기 변환, flatMap은 비동기 변환이다. flatMap은 내부에서 다시 Mono나 Flux를 반환하는 I/O 작업에 사용한다.\n중요한 특성이 하나 있다. Reactor는 구독 시점에 실행된다. Mono나 Flux를 생성하는 것만으로는 아무 일도 일어나지 않는다. .subscribe()가 호출되거나 WebFlux가 응답으로 반환할 때 비로소 파이프라인이 실행된다.\nWebClient RestTemplate은 블로킹이다. 이벤트 루프 스레드에서 블로킹 호출을 실행하면 해당 스레드가 점유되어 전체 처리량이 떨어진다. WebFlux 환경에서는 논블로킹 HTTP 클라이언트인 WebClient를 사용한다.\npublic Mono\u0026lt;UserDto\u0026gt; getResource(String accessToken) { return webClient .get() .uri(uri -\u0026gt; uri.queryParam(\u0026#34;access_token\u0026#34;, accessToken).build()) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, this::handleError) .bodyToMono(UserDto.class); } retrieve()로 응답을 받고, bodyToMono()로 역직렬화한다. 전체 과정이 논블로킹이다. onStatus()로 HTTP 상태별 에러 처리를 선언적으로 정의할 수 있다.\nSpring MVC에서도 WebClient를 사용할 수 있다. RestTemplate은 Spring 5부터 유지보수 모드에 들어갔고, Spring은 WebClient를 권장한다.\n리액티브 데이터 접근 리액티브 스택의 이점은 전 구간이 논블로킹일 때 온전히 발휘된다. 컨트롤러와 서비스가 논블로킹이어도 DB 접근이 블로킹이면 이벤트 루프 스레드가 묶인다.\nMongoDB: Spring Data는 ReactiveMongoRepository를 제공한다. 모든 CRUD 메서드가 Mono나 Flux를 반환한다.\ninterface UserDao extends ReactiveMongoRepository\u0026lt;UserEntity, String\u0026gt; {} 관계형 DB: R2DBC(Reactive Relational Database Connectivity)를 사용한다. JDBC의 리액티브 대안이다. MySQL, PostgreSQL 등의 드라이버를 지원한다.\nJPA는 블로킹이다. Hibernate와 JDBC가 스레드를 블로킹하므로 WebFlux와 함께 사용하면 논블로킹의 이점이 사라진다. 관계형 DB가 필요하면 R2DBC를, 문서형 DB가 적합하면 Reactive MongoDB를 선택한다.\n선택 기준 WebFlux와 Spring MVC는 상호 배타적이지 않다. Spring은 같은 프로젝트에서 두 가지를 함께 사용할 수 있도록 설계했다. 하지만 실질적으로는 스택 전체를 하나로 통일하는 것이 유리하다.\nWebFlux가 적합한 경우:\nI/O 바운드 워크로드가 지배적일 때 높은 동시성이 필요할 때 (API Gateway, 인증 서버) 마이크로서비스 간 통신이 많을 때 MongoDB, Redis 같은 리액티브 드라이버가 존재하는 데이터 저장소를 사용할 때 Spring MVC가 적합한 경우:\nJDBC/JPA 기반 관계형 DB가 핵심일 때 CPU 바운드 작업이 많을 때 팀이 리액티브 프로그래밍에 익숙하지 않을 때 Spring MVC는 요청당 스레드를 할당한다. I/O가 많아지면 스레드가 묶인다. WebFlux는 이벤트 루프로 이 대기 시간을 제거한다. 어떤 모델이 맞는지는 워크로드가 I/O 바운드인지 CPU 바운드인지, 동시성 요구가 얼마나 큰지에 따라 갈린다.\n","permalink":"https://wid-blog.github.io/posts/tech/language/spring-webflux-reactive-stack/","summary":"Spring MVC는 요청당 스레드를 할당한다. I/O 대기가 길어지면 스레드가 묶인다. WebFlux는 이벤트 루프 기반 논블로킹 모델로 이 문제를 해결한다. MVC와의 구조적 차이, Reactor 패턴, 선택 기준을 정리한다.","title":"Spring WebFlux 기본 — 논블로킹 I/O와 리액티브 스택"},{"content":"HTTP/1.1은 요청과 응답이 순차적이다. 하나의 연결에서 한 번에 하나의 요청만 처리한다. 동시에 여러 리소스가 필요하면 연결을 여러 개 열어야 한다. HTTP/2는 하나의 연결에서 여러 요청을 병렬로 처리하는 구조로 이 한계를 해결했다.\nHTTP/1.1의 한계 HOL 블로킹 HTTP/1.1은 하나의 TCP 연결에서 요청-응답을 순차 처리한다. 첫 번째 요청의 응답이 도착하기 전까지 두 번째 요청은 대기한다. 이를 HOL 블로킹, Head-of-Line Blocking이라 한다.\nsequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C,S: HTTP/1.1 — 순차 처리 C-\u003e\u003eS: GET /style.css S--\u003e\u003eC: style.css 응답 C-\u003e\u003eS: GET /script.js S--\u003e\u003eC: script.js 응답 C-\u003e\u003eS: GET /image.png S--\u003e\u003eC: image.png 응답 웹 페이지 하나를 렌더링하는 데 수십 개의 리소스가 필요하다. 순차 처리로는 느리다.\nHTTP/1.1은 이를 우회하기 위해 파이프라이닝을 지원한다. 응답을 기다리지 않고 여러 요청을 연속으로 보내는 방식이다. 하지만 응답은 여전히 요청 순서대로 도착해야 한다. 앞선 응답이 느리면 뒤의 응답도 밀린다. 구현상의 문제도 많아서 대부분의 브라우저가 파이프라이닝을 비활성화했다.\n실제로는 동시 TCP 연결로 대응한다. 브라우저는 같은 도메인에 최대 6개의 TCP 연결을 동시에 열어서 병렬 요청을 처리한다. 하지만 각 연결마다 TCP handshake와 TLS handshake 비용이 발생한다.\n헤더 중복 HTTP/1.1은 헤더를 텍스트로 전송한다. 매 요청마다 User-Agent, Cookie, Accept 같은 헤더가 반복된다. 쿠키가 포함되면 요청 헤더만 수 KB에 달한다. 압축 메커니즘이 없어서 동일한 정보가 매번 전송된다.\nHTTP/2 HTTP/2는 2015년에 표준화되었다. Google의 SPDY 프로토콜을 기반으로 한다. HTTP/1.1과 동일한 의미 체계를 유지하면서 전송 방식을 근본적으로 변경했다.\n바이너리 프레이밍 HTTP/1.1은 텍스트 기반이다. 요청과 응답을 사람이 읽을 수 있는 문자열로 주고받는다. HTTP/2는 모든 메시지를 바이너리 프레임으로 인코딩한다.\nblock-beta columns 3 block:http1[\"HTTP/1.1\"]:3 h1[\"GET /index.html HTTP/1.1\\nHost: example.com\\nAccept: text/html\"] end space:3 block:http2[\"HTTP/2\"]:3 h2a[\"HEADERS 프레임\\n(스트림 1)\"] h2b[\"DATA 프레임\\n(스트림 1)\"] h2c[\"HEADERS 프레임\\n(스트림 3)\"] end style http1 fill:#FFCDD2 style http2 fill:#C8E6C9 하나의 HTTP 메시지가 HEADERS 프레임과 DATA 프레임으로 분리된다. 각 프레임에는 스트림 ID가 태그되어 있어서 수신 측이 프레임을 올바른 메시지로 재조립할 수 있다.\n멀티플렉싱 HTTP/2의 핵심 개선이다. 하나의 TCP 연결에서 여러 스트림이 동시에 동작한다. 각 스트림은 독립적인 요청-응답 쌍이다. 프레임 단위로 인터리빙되므로 한 스트림의 응답이 느려도 다른 스트림은 영향을 받지 않는다.\nsequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C,S: HTTP/2 — 멀티플렉싱 C-\u003e\u003eS: 스트림 1: GET /style.css C-\u003e\u003eS: 스트림 3: GET /script.js C-\u003e\u003eS: 스트림 5: GET /image.png S--\u003e\u003eC: 스트림 3: script.js (일부) S--\u003e\u003eC: 스트림 1: style.css (완료) S--\u003e\u003eC: 스트림 5: image.png (일부) S--\u003e\u003eC: 스트림 3: script.js (완료) S--\u003e\u003eC: 스트림 5: image.png (완료) HTTP/1.1처럼 여러 TCP 연결을 열 필요가 없다. 하나의 연결이 모든 요청을 처리한다. TCP handshake, TLS handshake 비용이 한 번으로 줄어든다.\nHPACK 헤더 압축 HTTP/2는 HPACK이라는 전용 압축 알고리즘으로 헤더를 압축한다. 두 가지 기법을 결합한다.\n정적/동적 테이블: 자주 사용되는 헤더 필드를 인덱스 번호로 대체한다. :method: GET이 정적 테이블에서 인덱스 2로 매핑되면 2바이트면 충분하다. 연결 중에 새로 등장한 헤더는 동적 테이블에 추가되어 이후 요청에서 인덱스로 참조된다.\n허프만 인코딩: 테이블에 없는 값은 허프만 코딩으로 압축한다. 빈도가 높은 문자에 짧은 비트를 할당하여 전체 크기를 줄인다.\nHTTP/1.1에서 매 요청마다 반복되던 수 KB의 헤더가 수십 바이트로 줄어든다.\n서버 푸시 클라이언트가 HTML을 요청하면 서버가 해당 HTML에 필요한 CSS, JavaScript를 클라이언트의 추가 요청 없이 선제적으로 전송할 수 있다.\nsequenceDiagram participant C as 클라이언트 participant S as 서버 C-\u003e\u003eS: GET /index.html S--\u003e\u003eC: PUSH_PROMISE: /style.css S--\u003e\u003eC: PUSH_PROMISE: /script.js S--\u003e\u003eC: /index.html 응답 S--\u003e\u003eC: /style.css 응답 (푸시) S--\u003e\u003eC: /script.js 응답 (푸시) 클라이언트가 HTML을 파싱하고 추가 리소스를 요청하는 왕복 시간을 제거한다. 다만 실제로는 캐시된 리소스를 불필요하게 전송하는 문제가 있어서 활용이 제한적이다.\n스트림 우선순위 클라이언트가 각 스트림에 가중치와 의존성을 설정할 수 있다. CSS 파일의 우선순위를 이미지보다 높게 설정하면 서버가 CSS를 먼저 전송한다. 렌더링에 필수적인 리소스를 우선 처리하여 초기 로딩 속도를 개선한다.\nHTTP/2의 한계 HTTP/2는 HTTP 수준의 HOL 블로킹을 해결했지만 TCP 수준의 HOL 블로킹은 남아 있다. 하나의 TCP 연결 위에서 모든 스트림이 동작하므로, TCP 패킷이 유실되면 해당 패킷이 재전송될 때까지 모든 스트림이 대기한다.\nHTTP/3은 TCP 대신 UDP 기반의 QUIC 프로토콜을 사용하여 이 문제를 해결한다. 각 스트림이 독립적인 전송 단위가 되어 한 스트림의 패킷 유실이 다른 스트림에 영향을 주지 않는다.\ngRPC gRPC는 Google이 개발한 RPC 프레임워크다. HTTP/2를 전송 프로토콜로 사용하고, Protocol Buffers를 직렬화 형식으로 사용한다.\nHTTP/2 활용 gRPC는 HTTP/2의 멀티플렉싱을 활용하여 하나의 연결에서 여러 RPC 호출을 병렬로 처리한다. HPACK 헤더 압축도 그대로 적용된다. HTTP/2의 스트리밍 기능을 확장하여 네 가지 통신 패턴을 지원한다.\nblock-beta columns 2 block:unary[\"단방향\"]:1 columns 3 u1[\"클라이언트\"] space u2[\"서버\"] u1 -- \"요청 1 / 응답 1\" --\u003e u2 end block:server[\"서버 스트리밍\"]:1 columns 3 s1[\"클라이언트\"] space s2[\"서버\"] s1 -- \"요청 1 / 응답 N\" --\u003e s2 end block:client[\"클라이언트 스트리밍\"]:1 columns 3 c1[\"클라이언트\"] space c2[\"서버\"] c1 -- \"요청 N / 응답 1\" --\u003e c2 end block:bidi[\"양방향 스트리밍\"]:1 columns 3 b1[\"클라이언트\"] space b2[\"서버\"] b1 -- \"요청 N / 응답 N\" --\u003e b2 end style unary fill:#E3F2FD style server fill:#E8F5E9 style client fill:#FFF3E0 style bidi fill:#F3E5F5 Protocol Buffers REST가 JSON을 사용하는 것과 달리 gRPC는 Protocol Buffers, Protobuf를 사용한다. .proto 파일에 메시지 구조와 서비스 인터페이스를 정의하면 각 언어의 코드가 자동 생성된다.\nservice ChatService { rpc SendMessage (MessageRequest) returns (MessageResponse); rpc StreamMessages (RoomRequest) returns (stream Message); } message MessageRequest { string room_id = 1; string user_id = 2; string content = 3; } Protobuf는 바이너리 직렬화를 사용한다. JSON 대비 크기가 35배 작고, 직렬화/역직렬화 속도가 510배 빠르다. 스키마가 .proto 파일에 명시되므로 클라이언트와 서버 간 인터페이스 계약이 코드 수준에서 강제된다.\nREST vs gRPC gRPC가 적합한 경우:\n마이크로서비스 간 내부 통신: 낮은 지연, 높은 처리량이 필요할 때 양방향 스트리밍: 실시간 채팅, 이벤트 구독 다중 언어 환경: .proto 파일 하나로 여러 언어의 클라이언트/서버 코드 생성 REST가 적합한 경우:\n외부 API: 브라우저가 gRPC를 직접 지원하지 않는다. gRPC-Web이나 프록시가 필요하다 디버깅 편의성: JSON은 사람이 읽을 수 있고, curl이나 브라우저 개발자 도구로 확인 가능하다 단순 CRUD: RPC 스타일이 필요 없는 리소스 기반 API 실무에서는 내부 서비스 간 통신에 gRPC, 외부 API에 REST를 조합하는 패턴이 일반적이다.\nHTTP/1.1은 요청과 응답이 순차적이다. HTTP/2는 멀티플렉싱으로 이 제약을 제거했다. gRPC는 HTTP/2의 이점 위에 바이너리 직렬화와 스트리밍을 더했다. 프로토콜이 해결하려는 문제가 각각 다르고, 선택은 워크로드가 결정한다.\n","permalink":"https://wid-blog.github.io/posts/tech/network/http1-vs-http2/","summary":"HTTP/1.1은 요청과 응답이 순차적이다. HTTP/2는 멀티플렉싱, 바이너리 프레이밍, 헤더 압축으로 이 구조를 바꿨다. 두 프로토콜의 차이와 HTTP/2 위에서 동작하는 gRPC의 특성을 정리한다.","title":"HTTP/1.1과 HTTP/2"},{"content":"Container는 애플리케이션과 실행 환경을 하나로 구성해서 어디서든 동일하게 실행하는 기술이다.\nContainer 프로세스 격리 Container는 호스트 OS 위에서 실행되는 격리된 프로세스다. Guest OS가 없다. 호스트 커널을 공유하면서 프로세스, 네트워크, 파일시스템만 분리한다.\nnamespace는 프로세스 목록, 네트워크 인터페이스, 파일시스템을 독립된 공간으로 분리한다. Container 안의 프로세스는 다른 Container의 존재를 알 수 없다.\ncgroup(Control Group)은 자원 사용량에 상한을 건다. 격리만으로는 하나의 Container가 호스트의 CPU, 메모리, 디스크 I/O를 전부 소모할 수 있기 때문이다.\nVM과의 비교 namespace와 cgroup으로 프로세스를 격리하는 방식은 VM과 근본적으로 다르다.\nVM은 Hypervisor 위에 Guest OS를 통째로 설치한다. 하드웨어 수준의 격리를 제공하지만, OS 전체를 포함하므로 Image가 크고 시작이 느리다.\nContainer는 호스트 커널을 공유하고 프로세스 수준에서만 격리한다. Guest OS가 없으니 Image가 가볍고 시작이 빠르다. 격리 수준은 VM보다 낮지만, 대부분의 배포 시나리오에서 충분하다.\nflowchart TB subgraph vm[\"VM\"] direction TB HW1[\"하드웨어\"] --\u003e HV[\"Hypervisor\"] HV --\u003e G1[\"Guest OS + App\"] HV --\u003e G2[\"Guest OS + App\"] end subgraph ct[\"Container\"] direction TB HW2[\"하드웨어\"] --\u003e OS[\"Host OS\\n커널 공유\"] OS --\u003e CR[\"Container Runtime\"] CR --\u003e C1[\"App\"] CR --\u003e C2[\"App\"] end Docker 아키텍처 Docker는 클라이언트-서버 구조로 동작한다.\nflowchart LR CLI[\"Docker CLI\"] --\u003e|REST API| D[\"Docker Daemon\\ndockerd\"] D --\u003e CTD[\"containerd\"] CTD --\u003e RUNC[\"runc\"] RUNC --\u003e C1[\"Container\"] RUNC --\u003e C2[\"Container\"] docker run이나 docker build를 입력하면 Docker CLI가 Docker Daemon(dockerd)에 REST API로 전달한다. Daemon이 Container 생명주기를 관리하는 핵심 프로세스다.\n그 아래 두 계층이 더 있다. containerd가 Container 실행, Image 관리, 스토리지를 담당한다. runc가 namespace와 cgroup을 설정해서 Container 프로세스를 시작한다. runc는 OCI(Open Container Initiative) 표준을 구현한 저수준 런타임이다.\n계층이 분리되어 있으므로 Docker Daemon 없이 containerd만으로도 Container를 운영할 수 있다. Kubernetes가 Docker 의존성을 제거한 것도 이 구조 덕분이다.\nDocker 핵심 개념 Image Container를 실행하려면 Image가 필요하다. Image는 애플리케이션 코드, 런타임, 라이브러리, 설정 파일을 담은 읽기 전용 템플릿이다.\nImage는 계층 구조다. 각 계층이 이전 계층 위에 변경 사항만 추가한다. 여러 Image가 공통 계층을 공유하면 디스크 사용량과 빌드 시간이 줄어든다.\nContainer Container는 Image로부터 생성된 실행 인스턴스다. Image의 읽기 전용 계층 위에 쓰기 가능한 계층을 추가한다. 같은 Image로 여러 Container를 독립적으로 실행한다.\nContainer가 삭제되면 쓰기 계층의 데이터도 사라진다. 영속 데이터가 필요하면 볼륨으로 호스트에 별도 저장한다.\nRegistry Image를 저장하고 공유하는 곳이 Registry다. Docker Hub가 대표적인 공개 Registry이고, 조직 내부에서 사설 Registry를 운영하기도 한다.\nImage를 Registry에 저장하는 것이 push, 가져오는 것이 pull이다.\nflowchart LR DF[\"Dockerfile\"] --\u003e|docker build| IMG[\"Image\"] IMG --\u003e|docker run| CT[\"Container\"] IMG --\u003e|docker push| REG[\"Registry\"] REG --\u003e|docker pull| IMG2[\"Image\"] Dockerfile Image를 빌드하려면 Dockerfile이 필요하다. 베이스 Image 선택, 애플리케이션 복사, 의존성 설치, 실행 명령을 순서대로 기술하는 명세 파일이다.\n주요 명령어 FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY . . EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] FROM은 베이스 Image를 지정한다. 모든 Dockerfile의 시작점이다. WORKDIR로 작업 디렉토리를 설정하고, COPY로 호스트 파일을 Image 안으로 복사한다.\nRUN은 빌드 시 실행할 명령이다. 의존성 설치나 컴파일에 사용한다. 각 RUN 명령이 새로운 계층을 만든다.\nEXPOSE는 Container가 수신할 포트를 문서화한다. 실제 포트 바인딩은 docker run -p 옵션으로 한다. CMD는 Container 시작 시 실행할 기본 명령이다.\nCMD와 ENTRYPOINT 비슷해 보이지만 역할이 다르다.\nCMD는 Container 시작 시 실행할 기본 명령이다. docker run에 인자를 전달하면 CMD 전체가 교체된다. ENTRYPOINT는 항상 실행할 명령을 고정한다. docker run의 인자는 ENTRYPOINT 뒤에 추가된다.\n# CMD — docker run 인자로 교체할 수 있다 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] # ENTRYPOINT — 항상 node를 실행하고, 인자를 추가로 받는다 ENTRYPOINT [\u0026#34;node\u0026#34;] CMD [\u0026#34;server.js\u0026#34;] 둘을 함께 쓰면 ENTRYPOINT가 실행 파일을, CMD가 기본 인자를 담당한다. docker run \u0026lt;image\u0026gt; worker.js처럼 인자를 전달하면 CMD 부분만 교체된다. CLI 도구를 Container로 배포할 때 자주 쓰는 패턴이다.\n멀티 스테이지 빌드 하나의 Dockerfile에서 여러 FROM을 사용해 빌드 단계를 분리한다. 빌드에 필요한 도구와 소스는 빌드 단계에만 남기고, 최종 Image에는 실행에 필요한 결과물만 담는다.\n# 빌드 단계 FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN go build -o server . # 실행 단계 FROM alpine:3.19 COPY --from=builder /app/server /server CMD [\u0026#34;/server\u0026#34;] 컴파일러와 소스가 최종 Image에서 빠지므로 크기가 줄어든다. Go 같은 컴파일 언어에서 효과가 크다.\nDocker Compose 실제 서비스는 웹 서버, 데이터베이스, 캐시 등 여러 Container를 함께 실행한다. 각각 docker run으로 구동하면 관리가 번거롭다.\nDocker Compose는 여러 Container를 하나의 파일로 정의하고 관리하는 도구다.\n기본 구조 services: api: build: . ports: - \u0026#34;8080:3000\u0026#34; depends_on: - db - redis environment: DATABASE_URL: postgres://user:pass@db:5432/mydb db: image: postgres:16 volumes: - db-data:/var/lib/postgresql/data redis: image: redis:7-alpine volumes: db-data: services에서 각 Container를 정의한다. build로 Dockerfile 경로를 지정하거나, image로 기존 Image를 사용한다.\nvolumes는 데이터를 Container 외부에 영속 저장한다. Container를 삭제해도 데이터가 유지된다.\ndepends_on은 서비스 간 시작 순서를 정한다. 서비스가 \u0026ldquo;준비 완료\u0026quot;될 때까지 기다리지는 않으므로, 애플리케이션에서 재시도 로직이 필요하다.\n주요 명령어 docker compose up으로 모든 서비스를 시작한다. -d 옵션을 붙이면 백그라운드로 실행된다. docker compose down은 Container와 네트워크를 제거하지만 볼륨은 유지한다. docker compose logs -f로 실시간 로그를 확인한다.\n정리 Container는 프로세스 격리 기술이고, Docker는 이를 Image, Container, Registry 구조로 다룬다. Dockerfile로 빌드하고, Compose로 여러 Container를 구성하면 개발부터 배포까지 동일한 환경을 유지한다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/docker-container-fundamentals/","summary":"Container의 개념과 VM과의 차이를 정리하고, Docker의 아키텍처, Dockerfile, Docker Compose의 기본 사용법을 설명한다.","title":"Docker Container 기초"},{"content":"코드베이스를 어떤 기준으로 나눌 것인가. 기술적 책임(Controller, Service, Repository)으로 나누면 수평 분할이다. 기능이나 도메인(User, Order, Payment)으로 나누면 수직 분할이다. 어떤 기준으로 나누느냐에 따라 변경의 범위, 팀 간 의존성, 배포 단위가 달라진다.\nHorizontal Slicing 수평 분할은 기술적 책임을 기준으로 코드를 계층별로 나눈다. Layered Architecture가 대표적이다.\nsrc/ controllers/ UserController OrderController PaymentController services/ UserService OrderService PaymentService repositories/ UserRepository OrderRepository PaymentRepository 같은 기술적 역할을 하는 코드가 한 디렉토리에 모인다. Controller는 Controller끼리, Service는 Service끼리.\n장점 기술 관심사가 명확히 분리된다. HTTP 요청 처리 로직은 Controller 계층에만 있고, 데이터 접근 로직은 Repository 계층에만 있다. 계층 교체가 용이하다. REST API를 gRPC로 바꿔도 Controller 계층만 수정하면 된다.\n진입 장벽이 낮다. 대부분의 프레임워크(Spring, Express, Django)가 이 구조를 기본으로 제공한다. 새 팀원이 합류해도 구조를 빠르게 파악한다.\n한계 \u0026ldquo;주문 취소\u0026rdquo; 기능을 수정한다고 하면 OrderController, OrderService, OrderRepository, 경우에 따라 PaymentService까지 건드린다. 변경이 여러 계층에 걸친다.\n기능이 늘어나면 각 계층 안에서 파일이 수십 개로 증가한다. services/ 디렉토리에 UserService, OrderService, PaymentService, NotificationService, InventoryService, ShippingService가 뒤섞인다. 기술 계층은 같지만 도메인 맥락은 전혀 다른 코드가 같은 공간에 있다.\nVertical Slicing 수직 분할은 기능이나 도메인을 기준으로 코드를 나눈다. 각 slice가 필요한 계층을 모두 포함한다.\nsrc/ user/ UserController UserService UserRepository order/ OrderController OrderService OrderRepository payment/ PaymentController PaymentService PaymentRepository \u0026ldquo;주문\u0026rdquo; 관련 코드는 전부 order/ 안에 있다. 주문 기능을 수정할 때 다른 디렉토리를 건드리지 않는다.\nVertical Slice Architecture 일반적인 수직 분할이 도메인 모듈 단위(User, Order, Payment)로 나눈다면, Jimmy Bogard가 제안한 Vertical Slice Architecture는 더 세분화된다. 모듈이 아니라 use case 단위로 코드를 분리한다.\nsrc/ features/ CreateOrder/ CreateOrderHandler CreateOrderRequest CreateOrderValidator CancelOrder/ CancelOrderHandler CancelOrderRequest GetOrderDetail/ GetOrderDetailHandler GetOrderDetailQuery 각 use case가 독립적인 slice다. CreateOrder와 CancelOrder는 같은 \u0026ldquo;주문\u0026rdquo; 도메인이지만 서로 다른 slice로 분리된다. 한 slice 안에서 요청 수신부터 데이터 접근까지 전 과정이 완결된다.\n장점 기능별 독립성이 높다. 한 slice를 수정해도 다른 slice에 영향을 주지 않는다. 코드 리뷰 범위가 좁고, 충돌이 줄어든다. 여러 팀이 같은 코드베이스에서 독립적으로 작업하기 좋은 구조다.\n변경의 응집도가 높다. 하나의 기능 변경이 하나의 디렉토리 안에서 완결된다. 코드 리뷰 범위가 좁아지고, 변경이 다른 도메인에 영향을 주지 않는다.\n한계 공통 코드 관리가 까다롭다. 인증, 로깅, 트랜잭션 처리 같은 cross-cutting concern은 여러 slice에서 동일하게 필요하다. 각 slice에 복사하면 중복이 생기고, 공통 모듈로 추출하면 slice 간 의존성이 생겨 독립성이 약해진다. \u0026ldquo;적절한 중복\u0026quot;과 \u0026ldquo;과도한 중복\u0026quot;의 경계를 판단해야 한다.\n일관성 유지 비용이 있다. 각 slice가 독립적으로 변경되면 코딩 스타일, 에러 처리, 로깅 방식이 slice마다 달라질 수 있다. 팀 컨벤션과 코드 리뷰로 맞춰야 한다.\n선택 기준 둘 중 하나를 선택하는 문제라기보다 상황에 따라 적절한 방향이 달라진다.\n프로젝트 초기, 도메인이 작을 때는 수평 분할이 적합하다. 기능 수가 적으면 계층별 파일이 적고, 프레임워크 기본 구조를 그대로 사용할 수 있다. 구조를 잡는 데 드는 비용이 낮다.\n기능이 많아지고 팀이 커질 때는 수직 분할의 이점이 드러난다. 기능별 독립 작업이 가능해지고, 변경 범위가 줄어든다. MSA로 전환할 때도 수직으로 나뉜 코드가 서비스 경계로 자연스럽게 이어진다.\n혼합 사용도 흔하다. 최상위를 도메인별로 수직 분할하고, 각 도메인 안에서는 기술 계층별로 수평 분할하는 구조다.\nsrc/ order/ controller/ service/ repository/ payment/ controller/ service/ repository/ 수직 분할로 도메인 경계를 정의하고, 수평 분할로 각 도메인 내부를 정리한다. 실무에서 자주 마주치는 구조라 판단했다.\n어느 쪽이 더 좋다가 아니라, 프로젝트 규모와 팀 구조에 따라 적절한 방향이 다르다. 작게 시작할 때는 수평 분할로 충분하고, 기능이 늘고 팀이 커지면 수직 분할로 도메인 경계를 정의하는 흐름이 자연스럽다고 봤다.\n","permalink":"https://wid-blog.github.io/posts/tech/architecture/horizontal-vertical-slicing/","summary":"코드를 기술 계층별로 나누는 수평 분할과, 기능/도메인별로 나누는 수직 분할의 차이를 정리한다. 각각의 장단점과 선택 기준.","title":"Horizontal vs Vertical Slicing"},{"content":"레거시 광고 서버를 없애야 했다.\n기존 서버는 primary 광고 시스템에 장애가 발생할 때 광고를 대신 내보내는 역할을 했다. 대시보드 미리보기, 외부 플랫폼 연동, 테스트 요청 처리 같은 부가 기능도 담당하고 있었다. 오래된 코드베이스였다 — 유지보수 비용이 계속 올라가고 있었다.\n레거시를 제거하려면 그 역할을 이어받을 서버가 필요했다. 장애 대응 전용 fallback 서버를 새로 설계하게 됐다.\n요구사항 fallback 서버의 조건은 명확했다.\nprimary 시스템이 정상일 때는 요청이 들어오지 않는다. primary 시스템이 광고를 내보낼 수 없다고 판단한 경우에만 동작한다. 평소에는 유휴 상태로 서버비를 절감하는 것이 목적 중 하나였다.\n요청이 들어오면 광고 후보 조회, 필터링, 응답 생성 같은 처리를 한 번에 수행해야 했다. 다수의 필터가 각각 독립적인 데이터 소스를 참조하는 구조였다.\n단일 API 엔드포인트에 복잡한 비즈니스 로직이 집중된 구조였다.\n기술 스택 Nest.js를 선택했다. 사내 운영 환경이 Node.js/TypeScript 중심이었고, Nest.js가 제공하는 DI 컨테이너와 Module 시스템이 이 규모의 비즈니스 로직을 정리하는 데 적합하다고 판단했다.\nHTTP 서버는 Express 대신 Fastify를 택했다. 광고 서버는 처리량이 중요하고, Fastify가 같은 API 형태로 더 나은 성능을 낸다.\nORM은 TypeORM을 사용했다. 광고 데이터와 추천 데이터가 서로 다른 DB에 있어서 다중 데이터소스 연결이 필요했는데, TypeORM이 이를 자연스럽게 지원했다.\nNest.js의 설계 철학과 핵심 개념은 Nest.js 기본 — DI와 Module 시스템에서 정리했다.\n아키텍처 결정 Nest.js의 기본 접근은 vertical module slicing이다. 도메인별로 모듈을 나누고, 각 모듈 안에 Controller, Service, Repository를 두는 구조다. 사용자 모듈, 상품 모듈, 주문 모듈처럼.\n이 프로젝트에는 맞지 않다고 봤다.\n도메인 경계가 모호했다. 광고 조회, 필터링, 사용자 파싱 같은 기능이 있었지만, 독립된 도메인이 아니었다. 모든 요청이 동일한 흐름을 타고, 모든 기능이 하나의 응답을 만들기 위해 순차적으로 동작했다.\n그래서 수평 계층 아키텍처(horizontal layered architecture)를 선택했다. 기술적 책임 기준으로 계층을 나눴다.\nPresentation: HTTP 요청/응답 처리. Controller, Interceptor, Filter가 여기 속한다. Application: 비즈니스 로직. 여러 서비스를 조율하고 데이터를 변환한다. 인프라 계층과의 경계를 위해 인터페이스를 이 계층에 정의했다. Domain: 비즈니스 엔티티. 기술적 의존성이 없는 순수한 모델이다. Infra: 외부 시스템 연동. DB 접근, Redis 캐시, 외부 API 호출의 구현체가 여기 속한다. 의존성 방향을 지키기 위해 DIP를 적용했다. Application 계층에 인터페이스를 정의하고, Infra 계층에서 구현체를 제공한다. Nest.js의 DI 컨테이너가 Symbol 기반 토큰으로 이 연결을 관리한다.\n결과와 배운 점 설계 검증은 외부 컴포넌트 교체 작업에서 왔다. 기존 내부 구현을 외부 라이브러리로 바꿀 때 Infra 계층의 구현체만 교체하면 됐다. Application 계층의 인터페이스는 그대로였다. 계층 분리가 실제로 유연성을 만들어낸 순간이었다.\n동료로부터 \u0026ldquo;수직 분할 방식으로 잘라서 모듈을 선언하는 것이 좋지 않겠냐\u0026quot;는 피드백도 받았다. Nest.js의 기본 패턴을 따르자는 자연스러운 의견이었다. 하지만 이 프로젝트는 도메인이 여러 개인 서비스가 아니라, 하나의 API가 복잡한 파이프라인을 실행하는 구조였다. 그 특성에는 수평 계층 구조가 더 적합하다고 판단했다.\n서비스 클래스가 비대해지는 문제는 있었다. 필터링 서비스 하나에 다수의 필터 조합이 들어가면서 코드가 길어졌다. 파사드 패턴으로 외부에 노출하는 인터페이스를 줄이고, 내부 서비스를 더 작은 단위로 분리하는 방향으로 대응했다.\n레거시를 없애야 했다. 그래서 새로 만들었다. 단일 API에 다수의 필터가 몰리는 서버에서 — 프레임워크가 권하는 구조가 항상 답은 아니었다.\n참고 Nest.js 기본 — DI와 Module 시스템 레이어드 아키텍처와 의존성 역전 ","permalink":"https://wid-blog.github.io/posts/career/dable/ad-fallback-server-retrospective/","summary":"레거시 광고 서버를 제거하면서 Nest.js 기반 fallback 서버를 새로 설계한 과정. 단일 API에 복잡한 비즈니스 로직이 집중된 구조에서 수평 계층 아키텍처를 선택한 배경과 결과.","title":"광고 fallback 서버 설계 회고"},{"content":"Express로 프로젝트 규모가 커지면 의존성 관리가 개발자 몫이 된다. 서비스 객체를 직접 만들고, 전달하고, 생명주기를 추적해야 한다. Nest.js는 이 문제를 프레임워크 수준에서 해결한다. DI 컨테이너와 Module 시스템으로 애플리케이션에 구조를 부여한다.\nIoC와 DI Nest.js의 기반은 IoC Container와 Dependency Injection이다. @Injectable() 데코레이터로 클래스를 Provider로 등록하면, 다른 클래스의 생성자에서 그 타입을 선언하는 것만으로 인스턴스가 자동으로 주입된다.\n@Injectable() class OrderService { constructor( private readonly productService: ProductService, private readonly paymentService: PaymentService, ) {} } 개발자가 new로 인스턴스를 만들지 않는다. IoC Container가 등록된 Provider에서 타입을 찾아 생성자에 건넨다. DIP, IoC, DI의 위계 자체는 dependency-injection 글에서 다뤘다.\nModule 시스템 Nest.js 애플리케이션은 Module 단위로 구성된다. @Module 데코레이터가 네 가지 메타데이터를 받는다.\nimports: 이 모듈이 의존하는 다른 모듈 providers: 이 모듈이 제공하는 서비스, 리포지토리 등 controllers: HTTP 요청을 처리하는 컨트롤러 exports: 다른 모듈에 노출할 Provider @Module({ imports: [TypeOrmModule.forFeature([ProductEntity])], providers: [OrderService, ProductService], controllers: [OrderController], exports: [OrderService], }) class OrderModule {} Module 경계가 캡슐화를 강제한다. 이 경계가 없으면 프로젝트 전체가 하나의 의존성 그래프로 엉킨다. exports에 포함되지 않은 Provider는 외부에서 접근할 수 없다. OrderModule이 ProductService를 export하지 않으면, 다른 모듈은 이를 직접 사용할 수 없다. OrderService만 외부에 노출된다.\nGlobal Module 모든 모듈에서 공통으로 사용하는 Provider가 있다. 로거, 설정 관리, 에러 추적 같은 것들이다. @Global 데코레이터를 붙이면 한 번 등록으로 전체 애플리케이션에서 접근 가능하다.\n@Global() @Module({ providers: [Logger, ConfigStore], exports: [Logger, ConfigStore], }) class SharedModule {} Dynamic Module 설정에 따라 동작이 달라지는 모듈이 있다. TypeORM의 DB 연결이 대표적이다.\nTypeOrmModule.forRoot({ type: \u0026#34;mysql\u0026#34;, host: \u0026#34;localhost\u0026#34;, }); forRoot는 루트 모듈에서 한 번 호출하여 전역 설정을 등록한다. forFeature는 특정 엔티티를 사용할 모듈에서 호출한다. 같은 모듈 클래스가 다양한 설정으로 재사용된다.\nProvider Provider는 Nest.js에서 의존성으로 주입할 수 있는 모든 것이다. 서비스, 리포지토리, 팩토리 등이 해당한다. @Injectable() 데코레이터를 붙이면 IoC 컨테이너에 등록된다. 다양한 등록 방식을 통해 구현체를 유연하게 교체할 수 있다.\n등록 방식 Provider를 Module에 등록하는 방법은 네 가지다.\nuseClass: 클래스 자체를 Provider로 등록한다.\n{ provide: OrderRepository, useClass: OrderRepositoryImpl } useFactory: 팩토리 함수로 Provider를 생성한다. 다른 Provider를 주입받아 동적으로 인스턴스를 만들 수 있다.\n{ provide: CACHE_CLIENT, useFactory: (config: ConfigStore) =\u0026gt; CacheClient.from(config), inject: [ConfigStore], } useValue: 이미 생성된 값을 Provider로 등록한다.\nuseExisting: 기존 Provider에 대한 별칭을 만든다.\nCustom Provider와 Symbol 토큰 TypeScript에서 인터페이스는 런타임에 존재하지 않는다. DI 토큰으로 사용할 수 없다. 이 문제를 Symbol로 해결한다.\nconst ORDER_REPOSITORY = Symbol(\u0026#34;ORDER_REPOSITORY\u0026#34;); interface OrderRepository { fetchById(orderId: string): Promise\u0026lt;Order | null\u0026gt;; } 모듈에서 Symbol을 토큰으로, 구현체를 Provider로 등록한다.\n{ provide: ORDER_REPOSITORY, useClass: OrderRepositoryImpl } 사용하는 쪽에서는 @Inject 데코레이터로 토큰을 지정한다.\nconstructor(@Inject(ORDER_REPOSITORY) private readonly repo: OrderRepository) {} 이 패턴은 DIP(의존성 역전 원칙)를 구현하는 핵심 도구다. 비즈니스 로직이 인터페이스에만 의존하고, 구현체는 Module 설정에서 교체할 수 있다.\nScope Provider의 생명주기를 제어한다.\nDEFAULT: 싱글톤. 애플리케이션 전체에서 하나의 인스턴스를 공유한다. REQUEST: 요청마다 새 인스턴스를 생성한다. TRANSIENT: 주입할 때마다 새 인스턴스를 생성한다. 대부분의 경우 DEFAULT(싱글톤)가 적합하다. REQUEST 스코프는 요청별 상태가 필요한 경우에만 사용한다.\nController와 요청 파이프라인 Controller는 HTTP 요청을 받아 적절한 서비스에 위임한다. 라우팅과 HTTP 관심사만 담당한다.\n@Controller(\u0026#34;/orders\u0026#34;) class OrderController { constructor(private readonly orderService: OrderService) {} @Post() public async create(@Body() request: CreateOrderDto): Promise\u0026lt;OrderDto\u0026gt; { return this.orderService.create(request); } } Nest.js는 요청 처리 과정에 개입할 수 있는 네 가지 도구를 제공한다.\nGuard: 인증/인가 검사. 요청이 Controller에 도달하기 전에 실행된다. Interceptor: 요청/응답 변환, 로깅, 메트릭 수집. AOP 패턴으로 교차 관심사를 분리한다. Pipe: 입력 데이터 변환과 유효성 검증. Filter: 예외 처리. 발생한 에러를 적절한 HTTP 응답으로 변환한다. 전역으로 등록하면 모든 요청에 일괄 적용된다.\n@Module({ providers: [ { provide: APP_INTERCEPTOR, useClass: MetricInterceptor }, { provide: APP_FILTER, useClass: DefaultExceptionFilter }, ], }) class AppModule {} 실전 적용 패턴 리포지토리 패턴과 DIP 앞에서 본 Symbol 토큰 패턴을 실제 데이터 접근 계층에 적용하면 리포지토리 패턴이 된다. 데이터 접근을 추상화한다. 비즈니스 로직 계층에 인터페이스를 정의하고, 인프라 계층에서 구현체를 제공한다.\n// 인터페이스 (비즈니스 계층) const PRODUCT_REPOSITORY = Symbol(\u0026#34;PRODUCT_REPOSITORY\u0026#34;); interface ProductRepository { fetchById(productId: string): Promise\u0026lt;Product | null\u0026gt;; } // 구현체 (인프라 계층) @Injectable() class ProductRepositoryImpl implements ProductRepository { constructor( @InjectRepository(ProductEntity) private readonly repository: Repository\u0026lt;ProductEntity\u0026gt;, ) {} public async fetchById(productId: string): Promise\u0026lt;Product | null\u0026gt; { return this.repository.findOneBy({ id: productId }); } } Module에서 토큰과 구현체를 연결한다.\n{ provide: PRODUCT_REPOSITORY, useClass: ProductRepositoryImpl } DB를 교체하거나 테스트에서 mock을 주입할 때 Module 설정만 바꾸면 된다. 비즈니스 로직은 변경할 필요가 없다.\nModule 경계를 통한 캡슐화 Module의 exports로 외부에 Facade만 노출한다. 내부 구현은 감춘다.\n@Module({ providers: [OrderService, ProductService, ShippingService], exports: [OrderService], }) class OrderServiceModule {} 다른 모듈은 OrderService만 사용할 수 있다. ProductService나 ShippingService는 OrderServiceModule 내부 구현이다.\nExpress로 프로젝트가 커지면 의존성 관리가 개발자 몫이 된다고 했다. Nest.js는 DI 컨테이너와 Module 시스템으로 이 문제를 프레임워크 수준에서 해결한다. 의존성 방향을 제어하고, 모듈 경계로 캡슐화를 강제하고, Provider 패턴으로 구현체를 교체 가능하게 만든다.\n참고 Dependency Injection — DIP, IoC, DI 의 위계 — Nest.js 가 구현하는 IoC/DI 의 추상 수준과 DIP 원칙의 위계. ","permalink":"https://wid-blog.github.io/posts/tech/language/nestjs-di-module-system/","summary":"Nest.js는 Node.js 생태계에서 DI 컨테이너와 Module 시스템을 프레임워크 수준에서 제공한다. IoC, DI, Module, Provider 등 핵심 설계 원칙을 정리한다.","title":"Nest.js 기본 — DI와 Module 시스템"},{"content":"코드가 커지면 관심사 분리가 필요하다. HTTP 요청 처리, 비즈니스 로직, DB 접근이 한 클래스에 섞이면 변경의 영향 범위를 예측할 수 없다. 레이어드 아키텍처는 기술적 책임 기준으로 코드를 수평 계층으로 나누어 이 문제를 해결한다.\n핵심은 계층 분리 자체가 아니라 의존성 방향의 제어다.\n전통적 3계층 구조 가장 기본적인 형태는 Presentation, Business Logic, Data Access 3계층이다. 웹 요청을 받아서 비즈니스 로직을 실행하고 DB에 저장하는 흐름을 반영한다.\n의존성은 위에서 아래로 흐른다. Presentation이 Business Logic을, Business Logic이 Data Access를 호출한다.\n문제는 비즈니스 로직이 데이터 접근 기술에 직접 의존한다는 점이다. DB를 MySQL에서 MongoDB로 바꾸거나, 외부 API 호출 방식을 변경하면 비즈니스 로직까지 수정해야 한다. 비즈니스 규칙은 변하지 않았는데 기술 선택이 바뀌었다는 이유로 비즈니스 로직까지 수정하게 된다.\n4계층 구조 이 문제를 해결하기 위해 계층을 더 세분화하고 의존성 방향을 재설계한다.\nPresentation: HTTP 요청/응답을 처리한다. 라우팅, 요청 파싱, 응답 직렬화가 이 계층의 책임이다. 컨트롤러, 인터셉터, 예외 필터가 여기에 속한다.\nApplication: 비즈니스 로직을 조율한다. 여러 서비스를 호출하고, 데이터를 변환하고, 트랜잭션 경계를 정의한다. 이 계층이 유스케이스의 진입점이다. 인프라 계층과의 경계를 위한 인터페이스도 이 계층에 정의한다.\nDomain: 비즈니스 엔티티와 규칙을 담는다. 기술적 의존성이 없는 순수한 모델이다. 다른 계층의 변경이 이 계층에 영향을 주지 않는다.\nInfra: 기술적 구현을 담당한다. DB 접근, 외부 API 호출, 메시징, 캐시 등 외부 시스템과의 연동이 모두 여기에 속한다. Application 계층에 정의된 인터페이스를 구현한다.\n의존성 방향과 DIP 3계층 구조에서는 의존성이 Presentation → Business → Data 순서로 아래를 향한다. 4계층 구조에서는 이 방향을 뒤집는다.\n핵심은 의존성 역전 원칙, DIP이다. 상위 계층이 하위 계층에 직접 의존하지 않는다. 대신 상위 계층이 인터페이스를 정의하고, 하위 계층이 그 인터페이스를 구현한다.\nPresentation → Application → Domain ↑ Infra (인터페이스 구현) Application 계층은 \u0026ldquo;주문을 저장한다\u0026quot;는 인터페이스만 알고 있다. 그 저장이 MySQL인지 MongoDB인지는 모른다. Infra 계층이 그 인터페이스를 구현하고, DI 컨테이너가 둘을 연결한다.\n이 구조에서 Domain은 어디에도 의존하지 않는다. 가장 안정적인 계층이다. Infra는 Application이 정의한 인터페이스에 의존한다. 의존성 화살표가 핵심 비즈니스 로직을 향해 수렴한다.\n코드 예시 인터페이스를 Application 계층에 정의하고, 구현체를 Infra 계층에 둔다.\n// application/abstraction/order-repository.ts interface OrderRepository { save(order: Order): Promise\u0026lt;Order\u0026gt;; findById(id: string): Promise\u0026lt;Order | null\u0026gt;; } // infra/persistence/order-repository-impl.ts class OrderRepositoryImpl implements OrderRepository { constructor(private readonly db: Database) {} async save(order: Order): Promise\u0026lt;Order\u0026gt; { const entity = OrderEntity.from(order); await this.db.save(entity); return entity.toDomain(); } async findById(id: string): Promise\u0026lt;Order | null\u0026gt; { const entity = await this.db.findById(id); return entity?.toDomain() ?? null; } } Application 계층의 서비스는 인터페이스에만 의존한다.\n// application/service/order-service.ts class OrderService { constructor(private readonly orderRepository: OrderRepository) {} async createOrder(request: CreateOrderDto): Promise\u0026lt;OrderDto\u0026gt; { const order = Order.create(request); const saved = await this.orderRepository.save(order); return OrderDto.from(saved); } } DB를 교체하거나 테스트에서 mock을 주입할 때 Infra 계층의 구현체만 바꾸면 된다. OrderService는 변경할 필요가 없다.\n적합한 경우 레이어드 아키텍처는 단일 도메인에 비즈니스 로직이 집중된 서비스에 적합하다. 하나의 API가 복잡한 처리 파이프라인을 실행하는 구조에서 계층별 책임 분리가 효과를 발휘한다.\n기술 변경이 잦은 환경에서도 유리하다. 필터링 엔진을 내부 구현에서 외부 라이브러리로 교체하거나, DB를 전환하는 상황에서 Infra 계층만 수정하면 된다. Application 계층의 인터페이스가 변경의 전파를 차단한다.\n한계 도메인이 여러 개인 서비스에서는 적합하지 않을 수 있다. 사용자, 상품, 주문이 각각 독립된 도메인이라면 수평 계층보다 도메인별 수직 분할이 응집도를 높인다.\n서비스 클래스가 비대해지는 문제도 있다. 하나의 Application 서비스에 많은 유스케이스가 모이면 코드가 길어진다. 이 경우 유스케이스별로 서비스를 분리하거나 파사드 패턴으로 외부 인터페이스를 줄이는 방법이 있다.\n계층 간 DTO 변환 비용도 발생한다. Presentation DTO, Application DTO, Domain 엔티티, Infra 엔티티가 각각 존재하면 변환 코드가 늘어난다. 계층 간 경계의 이점과 변환 비용 사이에서 균형을 잡아야 한다.\n코드가 커지면 관심사 분리가 필요하다. 레이어드 아키텍처는 그 분리를 기술적 책임 기준으로 수행한다. 계층을 나누는 것 자체보다 의존성이 어디를 향하는지가 더 중요하다.\n","permalink":"https://wid-blog.github.io/posts/tech/architecture/layered-architecture/","summary":"레이어드 아키텍처는 기술적 책임 기준으로 코드를 수평 계층으로 나눈다. 4계층 구조의 각 역할, 의존성 방향 규칙, DIP로 계층 간 결합을 끊는 방법을 정리한다.","title":"Layered Architecture와 의존성 역전"},{"content":"Kafka는 분산 이벤트 스트리밍 플랫폼이다. 대량의 이벤트를 실시간으로 발행하고 구독하는 구조를 제공한다. 실시간 데이터 파이프라인, 이벤트 기반 아키텍처, 로그 수집 등 다양한 영역에서 사용된다.\n토픽과 파티션 토픽 Kafka에서 메시지는 토픽(Topic)에 발행된다. 토픽은 메시지의 논리적 카테고리다. order-events, user-signups처럼 이벤트 유형별로 토픽을 생성한다.\n토픽은 메시지를 보관하는 로그다. 한 번 기록된 메시지는 변경되지 않는다(append-only). 보존 기간(retention)이 지나면 삭제된다.\n파티션 하나의 토픽은 여러 파티션(Partition)으로 나뉜다. 파티션은 Kafka의 병렬성과 순서 보장을 동시에 제공하는 핵심 단위다.\nflowchart LR subgraph Topic[\"Topic: order-events\"] P0[\"Partition 0msg0, msg3, msg6...\"] P1[\"Partition 1msg1, msg4, msg7...\"] P2[\"Partition 2msg2, msg5, msg8...\"] end 파티션 내에서 메시지는 순서가 보장된다. 파티션 간에는 순서가 보장되지 않는다. 같은 키를 가진 메시지는 같은 파티션에 할당되므로, 특정 엔티티(예: 특정 주문)에 대한 이벤트 순서를 보장할 수 있다.\n파티션 수를 늘리면 처리량이 증가한다. 여러 컨슈머가 각 파티션을 병렬로 처리할 수 있기 때문이다.\n오프셋 각 파티션 내에서 메시지는 고유한 오프셋(Offset) 번호를 가진다. 0부터 시작해서 순차적으로 증가한다. 오프셋은 컨슈머가 \u0026ldquo;어디까지 읽었는가\u0026quot;를 추적하는 기준이 된다.\n프로듀서 프로듀서(Producer)는 토픽에 메시지를 발행한다.\n프로듀서가 메시지를 보낼 때, 어느 파티션에 할당할지 결정해야 한다.\n키 기반 파티셔닝. 메시지에 키가 있으면 키의 해시값으로 파티션을 결정한다. 같은 키는 항상 같은 파티션에 할당된다. 특정 사용자나 주문에 대한 이벤트 순서를 보장할 때 사용한다.\n라운드 로빈. 키가 없으면 파티션에 순서대로 분배한다. 순서 보장이 필요 없고 부하를 고르게 분산할 때 적합하다.\n커스텀 파티셔너. 직접 파티셔닝 로직을 구현할 수도 있다. 특정 비즈니스 규칙에 따라 파티션을 선택해야 할 때 사용한다.\nAcks 프로듀서는 메시지가 브로커에 기록되었는지 확인하는 수준을 설정할 수 있다.\nacks=0: 확인 없이 전송. 가장 빠르지만 유실 가능성이 있다. acks=1: 리더 브로커가 기록하면 확인. 리더 장애 시 유실 가능성이 있다. acks=all: 모든 ISR(In-Sync Replica)이 기록하면 확인. 가장 안전하지만 지연이 증가한다. 컨슈머 컨슈머(Consumer)는 토픽에서 메시지를 읽는다. 프로듀서가 메시지를 \u0026ldquo;push\u0026quot;하는 것과 달리, 컨슈머는 직접 \u0026ldquo;pull\u0026quot;한다. 컨슈머가 자신의 처리 속도에 맞춰 메시지를 가져갈 수 있다.\n컨슈머는 읽은 메시지의 오프셋을 커밋(Commit)한다. 커밋된 오프셋은 Kafka 내부 토픽(__consumer_offsets)에 저장된다. 컨슈머가 재시작되면 마지막 커밋된 오프셋부터 다시 읽는다.\n컨슈머 그룹 여러 컨슈머를 하나의 컨슈머 그룹(Consumer Group)으로 묶을 수 있다. 같은 그룹 내에서 각 파티션은 하나의 컨슈머에만 할당된다.\nflowchart LR subgraph Topic[\"Topic (3 Partitions)\"] P0[\"P0\"] P1[\"P1\"] P2[\"P2\"] end subgraph Group[\"Consumer Group A\"] C1[\"Consumer 1\"] C2[\"Consumer 2\"] C3[\"Consumer 3\"] end P0 --\u003e C1 P1 --\u003e C2 P2 --\u003e C3 컨슈머 수가 파티션 수보다 많으면, 초과 컨슈머는 유휴 상태가 된다. 처리량을 늘리려면 파티션 수를 먼저 늘려야 한다.\n그룹 내 컨슈머가 추가되거나 제거되면 리밸런싱(Rebalancing)이 발생한다. 파티션 할당을 재조정하는 과정이다. 리밸런싱 중에는 해당 그룹의 메시지 처리가 일시 중단된다.\n서로 다른 컨슈머 그룹 서로 다른 컨슈머 그룹은 같은 토픽을 독립적으로 읽는다. 각 그룹이 자체 오프셋을 관리한다.\nflowchart LR subgraph Topic[\"Topic (3 Partitions)\"] P0[\"P0\"] P1[\"P1\"] P2[\"P2\"] end subgraph GA[\"Group A (주문 처리)\"] A1[\"Consumer A1\"] A2[\"Consumer A2\"] end subgraph GB[\"Group B (분석)\"] B1[\"Consumer B1\"] end P0 --\u003e A1 P1 --\u003e A2 P2 --\u003e A1 P0 --\u003e B1 P1 --\u003e B1 P2 --\u003e B1 하나의 토픽에 여러 컨슈머 그룹이 구독하는 구조는 pub/sub 패턴이다. 주문 처리 시스템과 분석 시스템이 같은 이벤트를 독립적으로 소비하는 경우가 대표적이다.\n브로커와 클러스터 브로커 브로커(Broker)는 Kafka 서버 인스턴스다. 메시지를 수신하고, 디스크에 저장하고, 컨슈머에게 전달한다. 여러 브로커가 모여 클러스터(Cluster)를 구성한다.\n각 파티션은 하나의 브로커에 리더(Leader)로 할당된다. 프로듀서와 컨슈머는 리더 브로커와 통신한다.\n복제 파티션은 여러 브로커에 복제(Replication)된다. 리더가 장애를 일으키면 팔로워 중 하나가 새 리더로 승격된다.\nflowchart TB subgraph Cluster[\"Kafka Cluster\"] subgraph B1[\"Broker 1\"] P0L[\"P0 (Leader)\"] P1F[\"P1 (Follower)\"] end subgraph B2[\"Broker 2\"] P0F[\"P0 (Follower)\"] P1L[\"P1 (Leader)\"] end subgraph B3[\"Broker 3\"] P0F2[\"P0 (Follower)\"] P1F2[\"P1 (Follower)\"] end end P0L -.-\u003e|복제| P0F P0L -.-\u003e|복제| P0F2 P1L -.-\u003e|복제| P1F P1L -.-\u003e|복제| P1F2 ISR(In-Sync Replicas)은 리더와 동기화된 복제본 집합이다. 팔로워가 리더를 따라잡지 못하면 ISR에서 제외된다. acks=all로 설정하면 ISR의 모든 복제본에 기록이 완료되어야 프로듀서에게 확인을 보낸다.\nmin.insync.replicas 설정으로 최소 ISR 수를 지정할 수 있다. replication factor가 3이고 min ISR이 2이면, 브로커 1대가 장애를 일으켜도 쓰기가 가능하다. 2대가 장애를 일으키면 쓰기가 거부되어 데이터 정합성을 보호한다.\nZooKeeper와 그 한계 Kafka 3.3 이전까지, Kafka 클러스터의 메타데이터 관리는 ZooKeeper가 담당했다. 브로커 목록, 토픽/파티션 설정, 컨트롤러 선출, ACL 정보 등을 ZooKeeper에 저장하고 조회했다.\nZooKeeper 기반 아키텍처에는 몇 가지 문제가 있었다.\n별도 시스템 운영 부담. Kafka 클러스터와 별개로 ZooKeeper 클러스터(보통 3~5 노드)를 운영해야 한다. 모니터링, 업그레이드, 장애 대응 대상이 두 배가 된다.\n메타데이터 전파 병목. 브로커가 ZooKeeper에서 메타데이터를 가져오는 구조이므로, 파티션 수가 늘어나면 메타데이터 동기화에 시간이 걸린다. 대규모 클러스터에서 컨트롤러 장애 복구가 느려지는 원인이 된다.\n이중 합의 문제. ZooKeeper는 자체 합의 알고리즘(ZAB)으로 동작하고, Kafka는 별도로 ISR 기반 복제를 운영한다. 두 시스템의 상태가 일시적으로 불일치할 수 있다.\nKRaft 모드 KRaft(Kafka Raft)는 ZooKeeper를 제거하고, Kafka가 자체적으로 메타데이터를 관리하는 모드다. Kafka 3.3에서 프로덕션 사용이 가능해졌고, 4.0부터 ZooKeeper 모드가 제거되었다.\nKRaft에서는 일부 브로커가 Controller 역할을 겸임한다. Controller 노드들이 Raft 합의 알고리즘으로 메타데이터 로그에 대해 합의한다. 메타데이터가 Kafka 내부 토픽에 저장되므로, 별도 시스템이 필요 없다.\nZooKeeper 모드와 비교한 주요 변화:\nZooKeeper 클러스터 제거. 운영 대상이 Kafka 하나로 줄어든다. 메타데이터가 이벤트 로그로 관리된다. 브로커가 메타데이터 로그를 구독하여 자체 상태를 유지한다. ZooKeeper에서 풀링하는 방식보다 전파가 빠르다. 컨트롤러 장애 복구가 빨라진다. Raft 프로토콜이 새 리더를 선출하고, 메타데이터 로그를 이어받는다. 정리 Kafka의 핵심은 토픽, 파티션, 컨슈머 그룹이다. 파티션이 병렬성과 순서 보장을 제공하고, 컨슈머 그룹이 수평 확장을 가능하게 한다. 브로커 복제가 장애 내성을 보장한다.\nKRaft 모드는 이 구조에서 ZooKeeper라는 외부 의존성을 제거했다. Kafka만으로 메타데이터 합의와 관리가 완결되는 아키텍처로 전환한 것이다.\n","permalink":"https://wid-blog.github.io/posts/tech/infra/kafka-fundamentals-kraft/","summary":"Kafka의 핵심 개념(토픽, 파티션, 컨슈머 그룹, 복제)을 정리하고, ZooKeeper 의존성을 제거한 KRaft 모드의 등장 배경을 설명한다.","title":"Kafka 기초와 KRaft 모드"},{"content":"Hexagonal Architecture(Ports \u0026amp; Adapters)의 핵심은 의존성 방향 제어다. 비즈니스 로직이 프레임워크나 DB에 종속되지 않도록, 모든 외부 의존성을 인터페이스(Port)로 추상화한다.\nGo에서는 암묵적 인터페이스와 패키지 구조 덕분에 이 패턴이 자연스럽게 구현된다.\nHexagonal Architecture Alistair Cockburn이 제안한 이 패턴은 애플리케이션을 세 영역으로 나눈다.\nDomain. 비즈니스 규칙을 담은 핵심 계층이다. 외부 기술에 의존하지 않는다.\nPort. 애플리케이션과 외부 세계 사이의 인터페이스다. 두 종류가 있다.\nDriving port(inbound): 외부에서 애플리케이션으로 들어오는 진입점. 애플리케이션이 제공하는 기능을 정의한다. Driven port(outbound): 애플리케이션이 외부 시스템에 요청하는 인터페이스. 애플리케이션이 필요로 하는 것을 정의한다. Adapter. Port의 구현체다. Driving adapter는 HTTP handler, gRPC handler처럼 외부 요청을 받아 port를 호출한다. Driven adapter는 DB repository, 메시지 브로커처럼 port 인터페이스를 구현해서 외부 시스템과 통신한다.\n의존성 방향은 항상 안쪽을 향한다. Adapter → Port → Domain. Domain은 Port의 존재를 모르고, Port는 Adapter의 존재를 모른다.\nflowchart LR subgraph Adapter[\"Adapter\"] DA[\"Driving AdapterREST, gRPC\"] DRA[\"Driven AdapterDB, Kafka\"] end subgraph Port[\"Port\"] DP[\"Driving Port\"] DRP[\"Driven Port\"] end subgraph Core[\"Domain + Application\"] D[\"Entity\"] A[\"UseCase / Service\"] end DA --\u003e|호출| DP DP -.-\u003e|정의| A A --\u003e|사용| DRP DRP -.-\u003e|구현| DRA A --\u003e|포함| D Go 디렉토리 구조 Go에서 Hexagonal Architecture를 적용할 때 사용할 수 있는 디렉토리 구조다.\ninternal/ ├── domain/ │ ├── entity/ # 비즈니스 엔티티 │ └── service/ # 도메인 서비스 ├── port/ │ ├── driving/ # inbound 인터페이스 │ └── driven/ # outbound 인터페이스 ├── application/ │ ├── usecase/ # 비즈니스 동작 단위 │ ├── dto/ # 계층 간 데이터 전달 객체 │ └── mapper/ # entity ↔ dto 변환 └── adapter/ ├── driving/ # REST handler, gRPC handler └── driven/ # DB repository, 메시지 브로커 internal/ 패키지를 사용하면 외부 모듈에서 직접 접근할 수 없다. 애플리케이션의 내부 구현이 자연스럽게 캡슐화된다.\nPort Port는 Go 인터페이스로 정의한다.\nDriving Port 외부에서 애플리케이션으로 들어오는 진입점이다. UseCase 단위로 정의하면 각 인터페이스가 단일 책임을 가진다.\n// port/driving/messenger.go type JoinRoomUseCase interface { Handle(ctx context.Context, req dto.JoinRequest) error } type SendMessageUseCase interface { Handle(ctx context.Context, req dto.SendRequest) error } Driven Port 애플리케이션이 외부 시스템에 요청하는 인터페이스다.\n// port/driven/message.go type MessageRepository interface { Create(ctx context.Context, message entity.Message) error FindByRoom(ctx context.Context, roomID string, cursor string, limit int) ([]entity.Message, error) } type MessageBroker interface { Publish(ctx context.Context, message entity.Message) error Subscribe(subscriber MessageSubscriber) } 암묵적 인터페이스 Go의 인터페이스는 암묵적으로 구현된다. Adapter가 Port 인터페이스의 메서드를 가지고 있으면 별도 선언 없이 해당 인터페이스를 만족한다. Java의 implements 키워드가 필요 없다.\n이 특성은 Hexagonal Architecture에 적합하다. Driven adapter가 driven port를 구현할 때, adapter 코드에 port 패키지를 import하지 않아도 된다. 의존성이 코드 수준에서도 분리된다.\n단, 컴파일 타임에 인터페이스 구현을 보장하려면 다음과 같은 관례를 사용한다.\nvar _ driven.MessageRepository = (*MongoMessageRepository)(nil) 이 한 줄이 MongoMessageRepository가 driven.MessageRepository를 만족하는지 컴파일 타임에 검증한다.\nAdapter Driving Adapter HTTP handler가 대표적인 driving adapter다. 외부 요청을 받아서 driving port(usecase)를 호출한다.\n// adapter/driving/rest/handler.go type Handler struct { sendUseCase driving.SendMessageUseCase } func NewHandler(uc driving.SendMessageUseCase) *Handler { return \u0026amp;Handler{sendUseCase: uc} } func (h *Handler) Send(c *gin.Context) { var req dto.SendRequest if err := c.ShouldBindJSON(\u0026amp;req); err != nil { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } if err := h.sendUseCase.Handle(c.Request.Context(), req); err != nil { c.JSON(http.StatusInternalServerError, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } c.Status(http.StatusOK) } Handler는 driving port 인터페이스에만 의존한다. 어떤 구현체가 실제로 동작하는지는 모른다.\nDriven Adapter DB repository가 대표적인 driven adapter다. Driven port 인터페이스를 구현한다.\n// adapter/driven/persistence/repository.go type MongoMessageRepository struct { collection *mongo.Collection } func NewMongoMessageRepository(db *mongo.Database) *MongoMessageRepository { return \u0026amp;MongoMessageRepository{ collection: db.Collection(\u0026#34;messages\u0026#34;), } } func (r *MongoMessageRepository) Create(ctx context.Context, message entity.Message) error { doc := orm.FromMessage(message) _, err := r.collection.InsertOne(ctx, doc) if err != nil { return fmt.Errorf(\u0026#34;insert message: %w\u0026#34;, err) } return nil } ORM 모델과 domain entity는 별도 구조체로 분리한다. orm.FromMessage()와 ToDomain() 메서드로 변환한다. Domain entity가 DB 구조에 종속되지 않기 위함이다.\nDomain과 Application Entity Domain entity는 비즈니스 규칙을 포함한다. 필드를 unexported(소문자)로 선언하고 getter 메서드를 제공한다.\n// domain/entity/message.go type Message struct { id string roomID string userID string body string sentAt time.Time } func NewMessage(roomID, userID, body string) Message { return Message{ id: uuid.New().String(), roomID: roomID, userID: userID, body: body, sentAt: time.Now(), } } func (m Message) ID() string { return m.id } func (m Message) RoomID() string { return m.roomID } func (m Message) Body() string { return m.body } 필드가 unexported이므로 외부에서 직접 수정할 수 없다. 생성은 NewMessage 생성자를 통해서만 가능하다. 도메인 불변 조건(invariant)을 보호한다.\nUseCase UseCase는 하나의 비즈니스 동작을 담당한다. Driving port를 구현하며, driven port에 의존한다.\n// application/usecase/send.go type SendUseCase struct { repo driven.MessageRepository broker driven.MessageBroker } func NewSendUseCase(repo driven.MessageRepository, broker driven.MessageBroker) *SendUseCase { return \u0026amp;SendUseCase{repo: repo, broker: broker} } func (uc *SendUseCase) Handle(ctx context.Context, req dto.SendRequest) error { message := entity.NewMessage(req.RoomID, req.UserID, req.Body) if err := uc.repo.Create(ctx, message); err != nil { return fmt.Errorf(\u0026#34;save message: %w\u0026#34;, err) } if err := uc.broker.Publish(ctx, message); err != nil { return fmt.Errorf(\u0026#34;publish message: %w\u0026#34;, err) } return nil } UseCase는 driven port 인터페이스에만 의존한다. MongoDB든 PostgreSQL이든 MessageRepository 인터페이스를 구현하면 교체할 수 있다.\n의존성 주입 Go에서는 DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 것이 일반적이다.\nfunc main() { // driven adapter db := mongodb.Connect(os.Getenv(\u0026#34;MONGO_URI\u0026#34;)) messageRepo := repository.NewMongoMessageRepository(db) broker := messaging.NewKafkaBroker(kafkaConfig) // usecase (driven port 주입) sendUseCase := usecase.NewSendUseCase(messageRepo, broker) // driving adapter (driving port 주입) handler := rest.NewHandler(sendUseCase) // 서버 시작 server := rest.NewServer(handler) server.Run(\u0026#34;:8080\u0026#34;) } 의존성 그래프가 한 곳에서 명시적으로 드러난다. 어떤 구현체가 어떤 인터페이스에 주입되는지 코드를 따라가면 바로 확인할 수 있다.\nJava/Spring에서는 @Component와 @Autowired로 프레임워크가 의존성을 자동 주입한다. Go에서는 이 과정이 수동이지만 의존성 흐름이 명시적이고 추적이 쉽다.\n정리 Hexagonal Architecture의 구현은 언어마다 관용적 방식이 다르다. Go에서는 암묵적 인터페이스, internal 패키지, 수동 DI가 이 패턴과 잘 맞는다. Port를 인터페이스로 정의하고, Adapter가 이를 구현하고, main에서 조립한다. 프레임워크 없이도 의존성 방향이 코드 구조에 그대로 드러난다.\n","permalink":"https://wid-blog.github.io/posts/tech/architecture/go-hexagonal-architecture/","summary":"Hexagonal Architecture의 핵심 개념과 Go에서의 관용적 구현. 암묵적 인터페이스와 패키지 구조를 활용한 의존성 방향 제어.","title":"Go에서 Hexagonal Architecture 구현"},{"content":"생성자 매개변수가 많아지면 호출부가 읽기 어려워진다. new Pizza(true, false, true, false, true, \u0026quot;cheese\u0026quot;, 12) 같은 호출에서 각 인자의 의미를 알려면 생성자 정의를 다시 봐야 한다. 매개변수 중 일부가 선택적이라면 흔히 매개변수 수가 다른 생성자를 여러 개 둔다 — Telescoping Constructor 안티패턴이다. Builder 가 등장한 배경.\nBuilder 는 세 가지 한계가 동시에 모일 때 등장한다. 매개변수 수가 많고, 일부가 선택적이고, 단계적 검증이 필요한 경우. 셋 중 하나만 있으면 다른 도구로 충분하지만 — 생성자 오버로딩, Static Factory Method, setter — 셋이 같이 모이면 Builder 가 가장 명확하다. 단 언어가 named/default 매개변수를 제공하면 같은 한계가 줄어 Builder 의 필요도 자연스럽게 줄어든다.\n공통 의도 Builder 의 의도는 세 가지다.\n단계적 생성 — 객체를 한 번에 만들지 않고 여러 메서드 호출로 점진적으로 구성한다. 매개변수 검증 — 모든 매개변수가 모인 시점 (build() 호출) 에 일관성을 검증한다. 생성자에서는 검증 시점이 매개변수마다 흩어진다. immutable 결과 — 최종 객체는 setter 없이 만들어진다. setter 기반 빈 객체가 만드는 부분 초기화 상태를 피한다. 세 의도가 한 패턴에 모이는 게 Builder 의 정체성이다. 셋 중 하나가 빠지면 다른 도구가 더 단순하다.\nGoF 원형과 Fluent Builder GoF 의 원형은 Director, Builder, ConcreteBuilder, Product 네 역할이다.\nDirector 가 생성 순서를 결정하고 Builder 인터페이스의 메서드를 호출한다. ConcreteBuilder 가 그 인터페이스를 구현해서 실제 Product 를 생성한다. 같은 Director 가 다른 ConcreteBuilder 를 받으면 다른 Product 가 나온다. 같은 생성 절차로 다른 표현을 만드는 게 GoF 의 핵심이었다.\n실무에서는 이 원형보다 Effective Java 의 Item 2 가 정리한 fluent builder 가 더 흔하다.\nPizza pizza = new Pizza.Builder() .size(12) .cheese(true) .pepperoni(true) .build(); Director 가 사라지고, Builder 가 setter 와 비슷한 모양의 메서드를 fluent 하게 노출한다. build() 가 호출되는 시점에 모든 매개변수가 일관성을 갖춰 검증되고 immutable Product 가 반환된다.\nGoF 원형은 여러 표현을 같은 절차로 생성 하는 데 강점이 있고, Fluent Builder 는 매개변수가 많은 단일 객체의 가독성 에 강점이 있다. 실무 빈도는 후자가 압도적이다.\n언어별 구현 언어마다 같은 의도를 다른 방식으로 표현한다.\nJava 는 가장 풍부한 Builder 생태계를 가진다. 수동 작성도 가능하지만 Lombok 의 @Builder 가 코드 생성을 자동화한다. @Builder.Default 로 기본값을 표현하고, @Singular 로 컬렉션 추가 메서드를 받는다. 다만 Lombok 의존이 추상화 비용으로 따라온다.\nPython 은 Builder 를 직접 만드는 일이 드물다. dataclass 와 기본값이 같은 한계를 대부분 해결한다.\n@dataclass class Pizza: size: int cheese: bool = False pepperoni: bool = False 호출은 Pizza(size=12, cheese=True) 처럼 named argument 로 한다. 매개변수가 많고 선택적이어도 Telescoping 문제가 발생하지 않는다. __post_init__ 으로 검증도 한곳에 모은다. Builder 클래스를 별도로 두는 경우는 검증이 복잡하거나 단계적 의존이 있을 때 정도다.\nTypeScript 도 비슷하다. Object literal + interface 가 같은 역할을 한다.\ninterface PizzaOptions { size: number; cheese?: boolean; pepperoni?: boolean; } const pizza = new Pizza({ size: 12, cheese: true }); 선택적 필드는 ? 로 표현하고, 호출자는 named 형태로 객체를 전달한다. Fluent Builder 도 가능하지만 Object literal 이 더 간결하다.\n언어가 named/default 매개변수를 풍부하게 제공할수록 명시적 Builder 의 자리가 줄어든다는 게 공통 흐름이다.\n생성자, Static Factory Method, Builder 세 도구를 매개변수 수와 선택성으로 비교하면 결정이 명확해진다.\n매개변수 적음 (1~3개) — 생성자. 추가 도구가 오히려 noise. 매개변수 중간 + 명명이 필요 — Static Factory Method (Pizza.cheesePizza() 같은 의미 있는 이름). 매개변수 많음 + 일부 선택 + 검증 필요 — Builder. Effective Java Item 2 가 제시한 기준은 매개변수 네 개 이상 + 일부 선택 이다. 실무에서는 더 보수적으로 — 생성자/Static Factory 로 안 되는 명백한 이유가 있을 때만 Builder 로 — 가는 편이 단순도를 유지한다.\n세 도구는 배타적이지 않다. 한 클래스가 Static Factory Method 와 Builder 를 같이 노출하는 경우도 흔하다. Stream.builder() 같은 호출이 그렇다.\n언어 기능이 흡수하는 경우 Builder 의 세 가지 한계 중 매개변수 수와 선택성 은 언어 기능으로 흡수될 수 있다.\nKotlin 의 named parameter + default value 가 대표다.\ndata class Pizza( val size: Int, val cheese: Boolean = false, val pepperoni: Boolean = false ) val pizza = Pizza(size = 12, cheese = true) 호출부의 가독성이 fluent builder 와 동등하면서 코드는 더 짧다. Telescoping Constructor 문제 자체가 사라진다. Scala 의 case class, Swift 의 named argument 도 같은 방향이다.\n검증이 단계적이거나 의존성이 있는 경우 — 한 필드의 값이 다른 필드의 검증 조건을 결정하는 경우 — 에는 Builder 가 여전히 필요하다. 단순 매개변수 수와 선택성 문제는 named/default 로 해결되므로 Builder 가 자연스럽게 줄어든다.\n이게 디자인 패턴이 시간이 지나며 언어 기능으로 흡수 되는 사례다. 패턴이 사라지는 게 아니라, 그 패턴이 풀던 문제가 언어 차원에서 해결된다.\n결론 Builder 는 생성자의 세 가지 한계 — 매개변수 수, 일부 선택성, 단계적 검증 — 가 동시에 모일 때의 선택이다. 셋 중 하나만 있으면 다른 도구가 더 단순하다.\n선택은 두 가지 질문으로 좁혀진다.\n매개변수가 네 개 이상이고 일부가 선택적이며 검증이 한곳에 모여야 하는가. 그렇다면 Builder. 사용 중인 언어가 named/default 매개변수를 풍부하게 제공하는가. 그렇다면 Builder 의 자리는 단계적 검증이 필요한 좁은 경우로 한정된다. GoF 원형의 Director/Builder/Product 보다 Effective Java 의 Fluent Builder 가 실무 빈도가 압도적이다. 패턴의 형태도 시대와 언어에 따라 진화한다.\n참고 Factory — Static Factory Method 와 Builder 의 결정 기준 비교 Singleton — getInstance 도 Static Factory Method 의 한 형태 Joshua Bloch — Effective Java (3rd ed.), Item 2: Consider a builder when faced with many constructor parameters GoF — Design Patterns: Elements of Reusable Object-Oriented Software (1994) ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/builder/","summary":"Builder 는 생성자의 세 가지 한계가 동시에 모일 때의 해결책이다. 매개변수 수가 많고, 일부가 선택적이고, 단계적 검증이 필요한 경우. 셋이 같이 모이지 않으면 다른 도구로 충분하다. 언어가 named/default 매개변수를 제공하면 Builder 의 필요도 자연스럽게 줄어든다.","title":"Builder"},{"content":"객체 생성을 호출 지점마다 직접 하면 호출자가 구체 클래스에 의존한다. 구현 교체 시 호출자 전체를 건드리게 된다. Factory 는 그 결합을 끊는다 — 생성 책임을 한곳에 모으고 호출자는 추상에만 의존하게 만든다.\nFactory 라는 이름은 한 패턴이 아니라 세 변형을 가리킨다. Factory Method, Abstract Factory, Static Factory Method. 자주 한 묶음으로 다뤄지지만 의도와 적용이 다르다. 같은 이름 아래 의도가 갈리는 패턴이다.\n공통 의도 세 변형이 공유하는 의도는 객체 생성과 사용의 분리다.\n직접 생성하는 코드는 호출자가 구체 클래스를 안다는 뜻이다. new MySQLConnection() 같은 호출은 호출자가 MySQLConnection 이라는 클래스를 안다는 의미고, PostgreSQL 로 교체하려면 모든 호출자를 수정해야 한다. Factory 는 그 호출을 추상화한다. 호출자는 Connection 같은 인터페이스에만 의존하고, 어떤 구체 구현을 받을지는 Factory 가 결정한다.\n여기까지가 셋의 공통점이다. 분리의 방식 에서 갈래가 나뉜다.\nFactory Method Factory Method 는 객체 생성을 서브클래스에 위임한다.\n부모 클래스에 추상 메서드 createProduct() 를 두고, 서브클래스가 그 메서드를 구현해서 구체 클래스를 결정한다. 부모 클래스의 다른 메서드들은 그 추상 메서드의 결과를 사용한다. 호출 흐름 전체가 부모에 있고, 한 가지 결정만 서브클래스로 위임되는 구조다. Template Method 의 한 형태로 봐도 무방하다.\n상속 기반이라는 점이 가장 큰 특징이다. 새 구체 클래스가 필요하면 새 서브클래스를 만들어야 한다. 도메인이 안정되고 확장 지점이 명확할 때 적합하다. 반대로 확장 지점이 자주 바뀌거나 다중 상속이 곤란한 언어에서는 부담이 크다.\nJDK 의 Collection.iterator() 가 전형이다. Collection 인터페이스가 Iterator 생성을 추상으로 정의하고, ArrayList, HashSet 같은 구현체가 자기에게 맞는 Iterator 를 반환한다.\nAbstract Factory Abstract Factory 는 관련된 객체 family 의 일관 생성을 다룬다.\n한 인터페이스에 여러 product 의 생성 메서드를 둔다. 예를 들어 UIFactory 가 createButton(), createWindow(), createScrollbar() 를 묶어서 정의하고, MacUIFactory 는 Mac 스타일 UI 객체들을, WindowsUIFactory 는 Windows 스타일 객체들을 반환한다. 호출자는 한 family 안의 객체들이 서로 어울린다 는 보장을 받는다.\nDB 드라이버도 같은 유형의 사례다. DriverFactory 가 createConnection(), createStatement(), createResultSet() 을 묶고, 각 DB 별 Factory 가 자기 family 의 객체들을 일관되게 반환한다.\n여러 product 가 짝을 이뤄야 의미가 있을 때 적합하다. 단일 객체 생성을 추상화하는 데는 과한 도구다. 그리고 family 에 새 product 가 추가되면 모든 Factory 구현체를 수정해야 한다는 제약이 있다. 도메인이 안정되고 product 종류가 잘 정의된 상황에 맞는다.\nStatic Factory Method Static Factory Method 는 생성자의 한계를 보완하는 정적 메서드다. Effective Java 의 Item 1 이 정리한 패턴.\n생성자는 네 가지 한계를 갖는다. 이름이 없다. 같은 시그니처로 여러 생성자를 만들 수 없다. 호출할 때마다 새 인스턴스를 반환해야 한다. 정확한 반환 타입을 호출자가 알아야 한다. Static Factory Method 는 이 네 가지를 모두 해결한다.\n이름 있는 생성 — BigInteger.probablePrime() 같은 호출은 무엇을 생성하는지 이름이 알려준다. 생성자 오버로딩으로는 같은 의미를 전달하기 어렵다. 캐싱 — Integer.valueOf(int) 는 자주 쓰이는 값 (-128 ~ 127) 을 캐싱해서 같은 인스턴스를 반환한다. Flyweight 패턴의 기반이다. 반환 타입 다양화 — 인터페이스를 반환 타입으로 선언하고 실제로는 구현체를 반환한다. Collections.unmodifiableList() 가 반환하는 객체의 구체 클래스를 호출자는 모른다. 구현이 바뀌어도 호출자는 영향받지 않는다. 반환 객체 클래스가 호출 시점에 존재하지 않아도 됨 — JDBC 의 DriverManager.getConnection() 이 대표다. 호출 시점에 어떤 Driver 가 로딩되어 있느냐에 따라 다른 클래스의 인스턴스가 반환된다. 자바 표준 라이브러리만 봐도 Optional.of, List.of, Map.of, Stream.of, Files.newBufferedReader 같은 메서드가 모두 같은 패턴이다. 실무에서 가장 자주 마주치는 변형이다.\n한계도 있다. 정적 메서드는 상속이 어렵다 — protected 가 없으니 서브클래스에서 재정의할 수 없다. 그리고 메서드 이름이 직관적이지 않으면 생성자보다 발견이 어렵다. 관례로 of, from, valueOf, getInstance, newInstance 같은 이름을 자주 쓴다.\n선택 기준 세 변형의 결을 따라 적용 조건을 정리하면 다음과 같다.\n같은 시그니처로 여러 구체 구현이 필요한가 — Factory Method (상속). 도메인이 안정되고 확장 지점이 명확할 때. 관련 객체들이 짝을 이뤄야 하는가 — Abstract Factory (조합). UI family, DB 드라이버 family 처럼 product 들이 함께 일관성을 가질 때. 생성자의 한계 (이름 없음, 캐싱 불가, 구체 타입 노출) 가 문제인가 — Static Factory Method (대안). 실무에서 가장 흔한 선택. 세 변형은 배타적이지 않다. 한 라이브러리 안에 셋이 모두 등장하는 경우도 많다.\nDI 컨테이너와의 관계 DI 컨테이너는 Factory 의 일반화로 볼 수 있다.\n컨테이너는 객체 생성과 의존성 주입을 모두 담당한다. 어떤 구현을 어디에 주입할지는 설정으로 결정되고, 호출자는 추상 (인터페이스) 에만 의존한다. Abstract Factory 가 product family 의 일관 생성을 담당하던 역할을 컨테이너가 같이 맡는다. Static Factory Method 의 캐싱은 컨테이너의 singleton scope 와 같은 효과를 낸다.\n그렇다면 명시적 Factory 가 사라지는가. 단순한 생성에서는 컨테이너로 충분하지만, 도메인 로직에 따라 다른 구체를 생성해야 하는 경우에는 명시적 Factory 가 여전히 더 명확하다. 결제 수단에 따라 다른 PaymentProcessor 를 만들거나, 사용자 등급에 따라 다른 DiscountPolicy 를 만드는 경우가 그렇다. 컨테이너 설정은 정적 결정이고, 도메인 의존 분기는 런타임 결정이다.\ndependency-injection 글에서 다룬 DIP 가 이 분리를 가능하게 한 추상이고, Factory 는 그 추상을 코드로 표현하는 방식 중 하나다.\n결론 Factory 라는 이름 아래 세 변형이 모이지만 결정 축은 다르다. Factory Method 는 상속을 통한 확장, Abstract Factory 는 객체 family 의 일관성, Static Factory Method 는 생성자의 한계 보완. 같은 의도 (생성과 사용의 분리) 를 다른 방식으로 풀어낸다.\n실무에서 가장 자주 마주치는 변형은 Static Factory Method 다. 자바 표준 라이브러리부터 도메인 코드까지 같은 패턴이 반복된다. Factory Method 와 Abstract Factory 는 특정 조건 (상속 가능 / 객체 family 존재) 에 부합할 때 선택한다.\n세 변형의 이름이 비슷해서 한 패턴으로 묶이지만, 적용 결정은 의도에 맞춰 갈래를 나눠 봐야 한다.\n참고 Singleton — Static Factory Method 의 getInstance 가 단일 인스턴스 보장에 쓰이는 지점 Dependency Injection — DIP, IoC, DI의 위계 — DI 컨테이너가 Factory 의 일반화인 맥락 Joshua Bloch — Effective Java (3rd ed.), Item 1: Consider static factory methods instead of constructors GoF — Design Patterns: Elements of Reusable Object-Oriented Software (1994) ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/factory/","summary":"Factory 의 공통 의도는 객체 생성과 사용의 분리다. 세 변형 (Factory Method / Abstract Factory / Static Factory Method) 은 분리 방식이 다르고 적합한 상황도 다르다. 실무에서는 Static Factory Method 가 가장 자주 쓰이고, DI 컨테이너가 등장하면 명시적 Factory 의 일부 역할이 컨테이너로 흡수된다.","title":"Factory"},{"content":"GoF 23 패턴 중 가장 단순하고 가장 자주 가르치는 첫 패턴이다. 동시에 실무에서는 가장 자주 안티패턴으로 분류된다. 같은 패턴을 두고 \u0026ldquo;기초\u0026rdquo; 와 \u0026ldquo;안티\u0026rdquo; 가 동시에 붙는 이유는 패턴 자체에 있지 않다. Singleton 이 두 가지 의도를 한 패턴에 묶었기 때문이다.\n단일 인스턴스 보장과 전역 접근. 두 의도가 같이 묶이는 순간 강한 결합과 테스트 어려움이 따라온다. dependency-injection 글에서 다룬 DI 가 등장한 배경과 그대로 이어진다.\nGoF 의도 GoF 는 Singleton 을 \u0026ldquo;단일 인스턴스만 존재하도록 보장하고 그 인스턴스에 전역 접근점을 제공한다\u0026rdquo; 로 정의한다. 두 가지 보장이 한 묶음이다.\n단일 인스턴스 보장 — 자원 라이프사이클의 결정. 프로세스 안에서 하나만 존재해야 하는 자원이 있다는 도메인 사실이다. 전역 접근 — 의존성 표현의 결정. 클라이언트가 인스턴스를 어떻게 가져오는가 (생성자 주입 / 메서드 호출 / 전역 참조) 의 선택이다. 두 결정은 본래 별개의 축이다. Singleton 은 둘을 한 패턴에 묶어 단순함을 얻었다. 그 단순함이 안티패턴 논란의 출발점이기도 하다.\n언어별 구현 언어마다 동시성 안전성을 다르게 다룬다.\nJava 는 네 가지 구현이 흔하다.\nEager initialization — 클래스 로딩 시점에 인스턴스 생성. 가장 단순하지만 사용 안 해도 메모리 점유 Lazy initialization — 첫 호출 시점에 생성. 동시성 안전성 직접 처리 필요 DCL (Double-Checked Locking) — volatile + 두 단계 null 체크로 동시성 + 지연 초기화. volatile 누락 시 깨지는 미묘함 Initialization-on-demand Holder — 내부 정적 클래스로 JVM 의 클래스 로딩 보장을 활용. DCL 의 미묘함 없이 같은 효과 Python 은 두 방식이 자주 쓰인다.\n모듈 — 모듈 자체가 import 시점에 단 한 번 로딩되어 자연스러운 Singleton. 가장 일반적인 방식 메타클래스 — __call__ 을 재정의해서 인스턴스 생성을 제어. 클래스 계층이 깊을 때 활용 TypeScript / JavaScript 는 모듈 시스템이 자체적으로 단일 인스턴스를 보장한다. ESM 의 모듈 캐시가 같은 모듈을 한 번만 로딩하기 때문에 export 된 객체가 그대로 Singleton 이 된다. class 의 static getInstance() 패턴도 가능하지만 모듈 export 가 더 자연스럽다.\n동시성 안전성 측면에서는 JVM 기반 (Java/Kotlin) 이 가장 까다롭고, Python 의 GIL 과 JavaScript 의 단일 스레드 모델은 같은 고민이 줄어든다.\n안티패턴 이유 Singleton 이 안티패턴으로 분류되는 이유는 네 가지 문제로 정리된다.\n전역 상태 — 어디서든 접근 가능한 상태는 변경 추적이 어렵다. A 모듈에서 상태를 바꾸면 B 모듈에서 보이는 값이 함께 바뀐다. 추적 범위가 코드베이스 전체로 넓어진다. 테스트 어려움 — 단위 테스트는 격리된 상태에서 실행되어야 한다. 전역 인스턴스는 테스트 간에 공유되어 한 테스트의 변경이 다른 테스트에 영향을 준다. mock 으로 교체하기도 까다롭다 — 호출자가 구체 클래스를 직접 참조하기 때문에 인터페이스 단위로 끊을 지점이 없다. 강한 결합 — Logger.getInstance() 같은 호출은 호출자가 Logger 라는 구체 클래스를 안다는 뜻이다. 구현 교체 (다른 로거 라이브러리로 이동) 가 호출자 전체를 건드린다. 라이프사이클 통제 불가 — Singleton 은 처음 호출되는 시점에 생성되고 프로세스가 끝날 때까지 살아있다. 명시적 해제나 재생성 시점을 호출자가 결정할 수 없다. 네 가지가 함께 작동하면 코드베이스는 시간이 지날수록 변경에 약해진다. 한 모듈에서 시작한 변경이 어디까지 영향을 미치는지 알기 어렵다.\nDI 와의 대조 DI 컨테이너는 Singleton 의 두 의도를 분리한다.\n단일 인스턴스 보장은 컨테이너의 scope 설정으로 표현된다. Spring 의 @Scope(\u0026quot;singleton\u0026quot;), NestJS 의 기본 provider scope 가 같은 효과를 제공한다. 컨테이너가 인스턴스를 한 번만 생성하고 의존성이 필요한 곳에 같은 인스턴스를 주입한다.\n전역 접근은 의존성 주입으로 대체된다. 클라이언트는 생성자 매개변수로 의존성을 받고, 어떻게 얻는지는 모른다. 구현 교체는 컨테이너 설정 한 줄로 끝나고, 테스트에서는 mock 을 주입해서 격리된 환경을 만든다.\n두 의도가 분리되면 단일 인스턴스의 이점은 그대로 살리면서 전역 접근의 비용은 사라진다. dependency-injection 글에서 다룬 DIP 가 이 분리를 가능하게 한 추상이다.\n적합한 경우 DI 가 일반 대안이라고 해서 Singleton 이 모든 경우에 안티는 아니다. 좁은 경우에서는 여전히 적합하다.\n스레드 풀, 커넥션 풀 — 프로세스 단위로 하나만 존재해야 하는 자원. 풀 자체를 여러 개 만들면 자원 경합이 생긴다. 로거 — 출력 채널을 일관되게 관리해야 한다. 인스턴스가 여러 개면 출력 순서나 포맷이 흐트러진다. 설정 로더 — 프로세스 시작 시 한 번 읽어서 메모리에 둔다. 인스턴스가 여러 개면 같은 값을 중복 로딩하거나 일관성이 깨진다. 캐시 — 프로세스 안에서 공유되어야 의미가 있다. 인스턴스마다 다른 캐시면 캐시 효과 자체가 사라진다. 공통점은 두 가지다. 첫째, 라이프사이클이 프로세스 수명과 같다. 인스턴스를 늘리거나 줄일 이유가 없다. 둘째, 상태가 본질이다. 같은 자원을 여러 곳에서 공유해야 의미가 있다. 외부에서 주입받는 방식은 오히려 복잡도를 늘린다.\n이런 경우에도 DI 컨테이너의 singleton scope 로 표현하는 게 더 유연하다. 단 컨테이너가 없는 환경 (간단한 스크립트, CLI 도구, 임베디드) 에서는 Singleton 의 직접 구현이 비용이 더 낮다.\n결론 Singleton 자체가 안티가 아니라 단일 인스턴스 보장과 전역 접근을 한 패턴에 묶은 결정이 안티의 원인이다. 두 의도를 분리하면 단일 인스턴스의 이점은 살리면서 강한 결합과 테스트 어려움을 피할 수 있다. DI 가 그 분리를 일반화한 도구다.\n선택은 두 가지 질문으로 좁혀진다.\nDI 컨테이너가 있는 환경인가. 있다면 singleton scope 로 두 의도를 분리하는 게 첫 선택이다. 분리가 오히려 비용인 좁은 경우인가. 컨테이너 없는 환경에서 라이프사이클이 프로세스와 같고 상태가 본질인 자원이라면 Singleton 의 직접 구현이 자연스럽다. 가장 단순한 패턴이지만 적용 결정이 가장 미묘한 패턴이기도 하다.\n참고 Dependency Injection — DIP, IoC, DI의 위계 — DI 가 Singleton 대안인 맥락 Misko Hevery — Singletons are Pathological Liars GoF — Design Patterns: Elements of Reusable Object-Oriented Software (1994) ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/singleton/","summary":"Singleton 은 가장 단순한 패턴이지만 안티패턴 논란의 대표 사례다. 단일 인스턴스 보장과 전역 접근을 한 패턴에 묶은 결정이 강한 결합과 테스트 어려움의 원인. DI 가 두 의도를 분리한 일반 대안이다.","title":"Singleton"},{"content":"DI는 자주 쓰이는데 자주 혼용된다. \u0026ldquo;DI = IoC\u0026rdquo;, \u0026ldquo;DI Container가 DIP다\u0026rdquo;, \u0026ldquo;Spring을 쓰면 DIP가 만족된다\u0026rdquo; 같은 등치가 학습 자료와 블로그에서 흔히 발견된다. 셋은 같은 자리가 아니라 서로 다른 추상 수준에 있는 개념이고, 위계를 분명히 보지 않으면 프레임워크 기능을 설계 원리와 혼동하기 쉽다.\nDIP는 원칙이다 — \u0026ldquo;고수준 모듈은 저수준 모듈에 의존하지 않는다\u0026rdquo;. IoC는 패턴이다 — 제어 흐름을 호출자가 아닌 외부에 맡긴다. DI는 기법이다 — 의존성을 외부에서 주입한다. 그 위를 받치는 DI Container는 도구다. 원칙이 가장 추상적이고, 패턴이 그 다음 단계로 원칙을 구현하고, 기법이 패턴을 구현하고, 도구가 기법을 자동화한다.\nflowchart TB A[DIP설계 원칙] --\u003e B[IoC제어 패턴] B --\u003e C[DI주입 기법] C --\u003e D[DI Container자동화 도구] DIP 모듈 간 의존성의 방향이 DIP가 다루는 자리다. SOLID 다섯 원칙 중 하나이고, 정의는 두 줄로 요약된다.\n고수준 모듈은 저수준 모듈에 의존하지 않는다. 둘 다 추상에 의존한다. 추상은 세부에 의존하지 않는다. 세부가 추상에 의존한다. 전형적인 위반은 비즈니스 로직이 데이터 접근 기술에 직접 의존하는 경우다.\nclass OrderService { private final MySQLPaymentRepository repository = new MySQLPaymentRepository(); public void charge(Order order) { repository.save(order.payment()); } } OrderService가 MySQLPaymentRepository라는 구체 클래스를 알고 있다. DB를 PostgreSQL로 바꾸거나, 테스트에서 mock으로 갈아끼우려면 OrderService 코드를 건드려야 한다. 의존성 방향이 고수준(비즈니스) → 저수준(DB 접근)으로 흐른다.\nDIP 적용은 추상을 가운데에 두는 방식으로 방향을 뒤집는다.\ninterface PaymentRepository { void save(Payment payment); } class OrderService { private final PaymentRepository repository; public OrderService(PaymentRepository repository) { this.repository = repository; } public void charge(Order order) { repository.save(order.payment()); } } class MySQLPaymentRepository implements PaymentRepository { /* ... */ } 겉으로는 인터페이스가 추가됐을 뿐이지만, 핵심은 인터페이스의 소유자다. PaymentRepository 인터페이스는 고수준 모듈(비즈니스 계층)이 정의한다. 저수준 모듈(인프라 계층)이 그 인터페이스를 구현한다. 의존성 방향이 뒤집힌다 — 저수준이 고수준이 정의한 추상에 의존한다. 그래서 *역전(inversion)*이다.\n인터페이스만 추가하고 인프라 계층이 인터페이스의 소유자가 되면 DIP가 만족되지 않는다. 의존성 방향이 그대로이기 때문이다. DIP는 인터페이스 존재가 아니라 인터페이스 위치의 원칙이다.\nIoC \u0026ldquo;누가 누구를 부르는가\u0026quot;의 통제 방향이 IoC의 자리다. 일반적인 코드에서는 내가 라이브러리 함수를 부른다. IoC가 적용되면 프레임워크가 내 코드를 부른다. Hollywood Principle — \u0026ldquo;Don\u0026rsquo;t call us, we\u0026rsquo;ll call you\u0026rdquo; — 이 자주 인용되는 표어다.\nIoC는 DIP를 만족시키는 한 가지 방법이다. 제어 흐름이 프레임워크 → 내 코드로 흐르면, 내 코드는 자신의 의존성을 직접 만들지 않는다. 프레임워크가 만들어 건네준다. 자연스럽게 추상에 의존하게 된다.\nIoC의 구현체는 DI만 있는 게 아니다.\nDependency Injection. 의존성을 외부에서 주입받는다. Service Locator. 의존성을 중앙 레지스트리에서 찾아온다. Template Method. 부모 클래스가 흐름을 정하고, 자식이 일부 단계를 채운다. Event-driven. 이벤트가 발생하면 등록된 핸들러가 호출된다. DI는 그중 가장 명시적이고 가장 자주 쓰이는 구현이다. 그래서 \u0026ldquo;IoC = DI\u0026quot;라는 등치가 흔하지만 정확하지는 않다.\nDI 객체가 자기 의존성을 직접 만들지 않고 외부에서 받는 기법이 DI다. 주입 방식은 의존성을 어디로 받느냐로 갈린다.\nConstructor Injection. 생성자 파라미터로 의존성을 받는다. 의존성이 필수이고 불변일 때 자연스럽다. 가장 흔히 권장되는 방식. Setter Injection. setter 메소드로 의존성을 받는다. 의존성이 선택적이거나 런타임에 바뀔 수 있을 때 사용. 단, 객체가 의존성 없이 잠시 존재할 수 있는 상태가 생긴다. Interface Injection. 의존성을 받는 인터페이스를 별도로 정의. 거의 쓰이지 않는다. Constructor Injection이 권장되는 이유는 불변성과 필수성의 표현 때문이다. 생성자에 들어간 의존성은 final로 선언되고, 그 객체가 존재하는 한 의존성이 비어 있을 수 없다. setter는 둘 다 보장하지 않는다.\nDI는 IoC의 구체적 구현이고, 동시에 DIP를 만족시키는 자연스러운 경로다. 생성자가 추상(인터페이스)을 받게 만들면, 그 객체는 구체 구현을 모른 채 추상에만 의존한다.\nDI Container 손으로 와이어링하면 의존성 그래프가 커질수록 boilerplate가 쌓인다. DI Container가 그 와이어링을 자동화한다.\n// 손으로 DI PaymentRepository repository = new MySQLPaymentRepository(); OrderService orderService = new OrderService(repository); PaymentController controller = new PaymentController(orderService); // DI Container가 자동화 @Service class OrderService { public OrderService(PaymentRepository repository) { /* ... */ } } 이 자동화는 두 방향으로 오해되기 쉽다.\nDI Container가 없어도 DIP, IoC, DI가 모두 만족된다. 손으로 와이어링하는 코드에서도 인터페이스를 고수준 모듈이 소유하고, 생성자로 의존성을 주입받으면 셋이 다 만족된다. 작은 시스템에서는 손 와이어링이 충분하다.\nDI Container만 도입한다고 DIP가 만족되지도 않는다. @Autowired가 붙어 있어도 인터페이스 분리가 잘못되어 있으면 — 예를 들어 비즈니스 계층이 Repository라는 이름의 인프라 계층 인터페이스에 의존하면 — 의존성 방향이 여전히 고수준 → 저수준이다. DI Container는 도구이지 설계자가 아니다.\n위계 정리 추상 수준 이름 정체 예시 원칙 DIP 설계 원칙 \u0026ldquo;고수준은 저수준에 의존하지 않는다\u0026rdquo; 패턴 IoC 제어 흐름 \u0026ldquo;프레임워크가 내 코드를 부른다\u0026rdquo; 기법 DI 의존성 전달 \u0026ldquo;생성자로 주입한다\u0026rdquo; 도구 DI Container 자동화 Spring @Autowired, NestJS @Injectable 흔한 등치의 정정 위계를 분명히 보면 자주 마주치는 등치의 문제가 드러난다.\n\u0026ldquo;DI = IoC\u0026rdquo;. DI는 IoC의 한 구현이다. Service Locator, Template Method, Event-driven도 IoC를 만족한다. 셋을 등치시키면 \u0026ldquo;프레임워크가 객체를 만들어준다\u0026quot;는 표면적 특성에 갇혀 IoC가 가진 다른 모양들을 못 보게 된다.\n\u0026ldquo;DI Container를 쓰면 DIP가 만족된다\u0026rdquo;. 인터페이스의 위치가 잘못되어 있으면 DI Container는 무의미하다. 비즈니스 계층이 인프라 계층이 정의한 인터페이스에 의존하면, 자동 주입이 일어나도 의존성 방향은 그대로다. DIP는 와이어링 방법이 아니라 구조의 원칙이다.\n\u0026ldquo;Spring을 쓰면 자동으로 DIP\u0026rdquo;. 프레임워크는 DIP를 만족시키기 쉽게 만들지만 자동으로 만족시키지는 않는다. 인터페이스 분리, 모듈 경계, 의존성 방향은 여전히 설계자의 책임이다. 프레임워크는 그 결정을 코드로 표현하기 편리한 환경을 제공할 뿐이다.\n원칙, 패턴, 기법, 도구는 층위가 다르다. 이들을 한 층위로 묶으면 \u0026ldquo;프레임워크가 잘 만들어줬으니 됐다\u0026rdquo; 는 안일함이 생긴다. DIP 는 도구의 결과가 아니라 설계의 결과다. 도구는 그 결과를 만들기 쉽게 거들 뿐이다.\n참고 Nest.js 기본 — DI와 Module 시스템 — Nest.js의 DI Container와 Module 시스템이 이 위계를 어떻게 구현하는지 다룬다. ","permalink":"https://wid-blog.github.io/posts/tech/design-pattern/dependency-injection/","summary":"DIP(원칙), IoC(패턴), DI(기법)는 서로 다른 추상 수준에 있는 개념이다. 위계를 분명히 봐야 프레임워크 기능과 설계 원리가 섞이지 않는다.","title":"Dependency Injection — DIP, IoC, DI의 위계"},{"content":"여러 서비스가 공유하는 캐시에서 설정 데이터를 주기적으로 조회하는 구조가 있다. 전량 갱신 방식에서는 데이터 변경 여부와 관계없이 매 주기마다 전체 데이터를 전송한다. 변경이 드문 데이터일수록 낭비가 크다.\n변경분만 갱신하면 네트워크 전송량을 데이터 변경 빈도에 비례하도록 줄일 수 있다.\n전량 갱신 전량 갱신은 구현이 단순하다. 주기마다 전체 데이터를 가져와서 로컬 상태를 교체하면 된다. 변경 감지 로직이 필요 없다.\n그런데 이 방식의 네트워크 전송량은 데이터 크기 × 소비자 수 × 갱신 빈도로 결정된다. 데이터가 실제로 변경되었는지와 무관하다. 소비자 수가 늘거나 데이터 크기가 커지면 전송량이 선형으로 증가한다.\nCPU와 메모리는 여유로운데 네트워크 전송량만 한계에 도달하는 상황이 생길 수 있다. 이 경우 인스턴스를 스케일 업해야 하는데, CPU와 메모리는 필요 없는 스케일 업이다.\n데이터 분리 변경분 갱신을 적용하려면 먼저 데이터를 갱신 빈도별로 분리해야 한다.\n설정 데이터. 엔티티의 메타 정보, 조건, 규칙 같은 데이터는 관리자가 수정할 때만 변경된다. 갱신 빈도가 낮다.\n실시간 데이터. 카운터, 소진 현황 같은 데이터는 요청마다 갱신된다. 항상 최신 상태를 유지해야 한다. 변경분 갱신 대상이 아니다.\n설정 데이터만 변경분 갱신을 적용하고, 실시간 데이터는 기존대로 매 주기 갱신한다.\n중복 제거 하나의 엔티티에 여러 하위 엔티티가 포함되어 있고, 하위 엔티티가 다른 상위 엔티티에서도 참조되는 구조라면 중복이 발생할 수 있다. 하위 엔티티를 별도로 관리하면 저장 공간과 전송량 모두 줄어든다.\n변경 감지 전략 데이터 비교 배치가 원본(DB)에서 데이터를 가져온 뒤 캐시에 저장된 데이터와 직접 비교한다. 내용이 다른 항목만 캐시에 쓴다.\n장점은 정확하다는 것이다. 원본과 캐시의 불일치를 놓치지 않는다. 단점은 비교를 위해 캐시에서 기존 데이터를 읽어야 하므로 읽기 비용이 추가된다.\nTimestamp 기반 각 항목의 변경 시점을 기록해둔다. Sorted Set의 score로 timestamp를 저장하면, 특정 시점 이후 변경된 항목만 범위 조회로 가져올 수 있다.\nflowchart LR subgraph Write [\"쓰기\"] BATCH[\"배치\"] --\u003e UPD[\"변경된 항목 캐시 갱신\"] UPD --\u003e TS[\"Sorted Set에timestamp 기록\"] end subgraph Read [\"읽기\"] SVC[\"서비스\"] --\u003e RANGE[\"마지막 갱신 이후timestamp 범위 조회\"] RANGE --\u003e CHANGED[\"변경된 항목 ID 목록\"] CHANGED --\u003e GET[\"해당 항목만 조회\"] end 읽는 쪽에서 마지막으로 조회한 시점을 기억하고 있으면, 그 이후에 변경된 항목만 가져올 수 있다. 전량 조회가 범위 조회로 바뀌므로 전송량이 변경 건수에 비례한다.\n두 전략을 함께 쓸 수도 있다. 쓰기 경로에서 데이터 비교로 변경 여부를 판단하고, timestamp를 기록한다. 읽기 경로에서 timestamp 범위 조회로 변경분만 가져온다.\nWrite Path / Read Path 변경분 갱신에서는 쓰기 경로와 읽기 경로를 명확히 분리하는 것이 중요하다.\n쓰기 경로는 배치가 담당한다. 원본에서 데이터를 가져오고, 캐시와 비교하고, 변경분만 쓴다. 변경 시점을 기록한다.\n읽기 경로는 서비스가 담당한다. 마지막 갱신 이후 변경된 항목만 조회하고, 로컬 상태를 부분 갱신한다. 변경이 없으면 로컬 데이터를 그대로 유지한다.\n소비자마다 필요한 데이터 범위가 다를 수 있다. 설정만 필요한 서비스, 설정과 콘텐츠가 모두 필요한 서비스, 실시간 데이터까지 필요한 서비스. 읽기 인터페이스를 소비자별로 분리하면 각 서비스가 필요한 데이터만 조회할 수 있다.\n적용 사례 이 패턴은 공유 캐시에서 설정 데이터를 주기적으로 조회하는 구조에서 자주 적용된다.\n광고 캠페인 설정. 캠페인 메타 정보, 타겟팅 조건은 변경이 드물지만 여러 서버가 동시에 조회한다. 전량 갱신에서 변경분 갱신으로 전환하면 네트워크 전송량이 크게 줄어든다.\n상품 카탈로그. 상품 정보는 등록/수정 시에만 변경된다. 수천 개의 상품을 매 주기 전송하는 대신, 변경된 상품만 갱신하면 효율적이다.\n사용자 권한/설정. 권한 변경은 빈도가 낮지만 여러 서비스에서 참조한다. 변경분 갱신이 적합한 구조다.\n트레이드오프 변경분 갱신은 전량 갱신 대비 복잡도를 추가한다.\n변경 감지 로직, timestamp 관리, 로컬 상태의 부분 갱신 로직이 필요하다. 캐시와 원본의 불일치가 발생했을 때 전량 동기화로 복구하는 메커니즘도 고려해야 한다.\n데이터가 작거나 변경이 빈번한 경우에는 전량 갱신이 더 단순하고 충분하다. 네트워크 비용이 실제 병목이 된 시점에서 변경분 갱신으로 전환하는 것이 적절하다.\n","permalink":"https://wid-blog.github.io/posts/tech/architecture/incremental-cache-refresh/","summary":"전량 갱신 방식의 캐시를 변경분만 갱신하도록 전환하는 패턴. 데이터를 성격별로 분리하고 변경 감지 전략을 적용하여 네트워크 비용을 줄이는 구조를 정리한다.","title":"변경분 갱신 캐시 패턴"},{"content":"캐시의 CPU와 메모리는 여유로운데 네트워크 전송량만 높았다.\n광고 서버 여러 대가 캠페인 설정 정보를 캐시에서 주기적으로 조회하고 있었다. 캠페인 설정은 자주 바뀌지 않는 데이터였다. 그런데 변경 여부와 관계없이 매번 전체 데이터를 가져오는 구조였다. 서버 수가 늘면서 네트워크 전송량이 인스턴스의 허용 범위에 근접했고, 스케일 다운도 할 수 없는 상황이었다.\n데이터 분리 캐시에 저장된 캠페인 데이터를 들여다보니 성격이 다른 데이터가 하나로 묶여 있었다.\n메타데이터. 캠페인 메타 정보, 타겟팅 조건 같은 데이터는 변경 빈도가 낮다. 광고주가 캠페인을 수정할 때만 바뀐다.\n상태 데이터. 예산 소진 현황 같은 데이터는 광고가 노출될 때마다 갱신된다. 항상 최신 상태를 유지해야 한다.\n공유 데이터. 광고 소재 정보는 여러 캠페인에서 동일한 콘텐츠를 사용할 수 있다. 캠페인에 포함시키면 중복이 발생한다.\n세 가지를 분리했다. 메타데이터와 공유 데이터는 변경분만 갱신하고, 상태 데이터는 매번 갱신하는 구조로 바꿨다.\n변경분 갱신 전량 갱신을 변경분 갱신으로 바꾸려면 \u0026ldquo;무엇이 변경되었는가\u0026quot;를 알 수 있어야 한다.\n배치가 DB에서 최신 데이터를 가져온 뒤, 캐시에 저장된 데이터와 비교한다. 내용이 다른 항목만 캐시에 쓴다. 변경 시점은 변경 감지용 인덱스에 timestamp 로 기록해두고, 읽는 쪽에서 마지막 갱신 시점 이후 변경된 항목만 가져온다.\nflowchart LR subgraph Write [\"쓰기 경로\"] DB[\"DB\"] --\u003e BATCH[\"배치\"] BATCH --\u003e CMP{\"캐시와비교\"} CMP --\u003e|\"변경됨\"| WRITE[\"캐시 갱신+ timestamp 기록\"] CMP --\u003e|\"동일\"| SKIP[\"건너뜀\"] end subgraph Read [\"읽기 경로\"] SVC[\"서비스\"] --\u003e TS{\"마지막 갱신이후 변경분?\"} TS --\u003e|\"있다\"| FETCH[\"변경분만 조회\"] TS --\u003e|\"없다\"| LOCAL[\"로컬 데이터 유지\"] end 쓰기 경로와 읽기 경로를 분리한 것이 핵심이었다. 배치는 변경분만 쓰고, 서비스는 변경분만 읽는다. 패턴의 상세한 원리는 별도로 정리했다.\n결과 네트워크 전송량이 크게 줄었다. 변경이 없는 주기에는 거의 전송이 발생하지 않게 됐다. 캐시 인스턴스를 한 단계 낮은 타입으로 스케일 다운할 수 있었다.\n돌아보면 이 작업의 시작점은 \u0026ldquo;무엇이 병목인가\u0026quot;를 정확히 파악한 것이었다. CPU나 메모리가 아니라 네트워크가 병목이라는 것을 먼저 확인했기 때문에, 데이터 분리와 변경분 갱신이라는 방향이 자연스럽게 나왔다.\n참고 변경분 갱신 캐시 패턴 ","permalink":"https://wid-blog.github.io/posts/career/dable/ad-campaign-cache-optimization/","summary":"변경이 드문 캠페인 설정 데이터를 전량 갱신하던 캐시 구조를 개선하여 네트워크 비용을 줄이고 인스턴스를 스케일 다운한 과정을 기록한다.","title":"캐시 갱신 최적화 회고"},{"content":"트랜잭션은 백엔드 개발의 일상이다. 주문 생성, 결제 처리, 재고 차감 — 여러 연산을 묶어서 \u0026ldquo;전부 성공 아니면 전부 실패\u0026rdquo; 로 다루는 단위다. ACID 4글자가 그 보장의 정체지만, 4글자가 실제로 무엇을 보장하는지는 표면적으로만 이해되기 쉽다.\n특히 C 와 I 가 자주 오해된다. C 는 \u0026ldquo;DB 가 알아서 일관성을 지켜준다\u0026rdquo; 로 단순화되고, I 는 단순 on/off 처럼 다뤄진다. ACID 각 속성이 보장하는 것과 보장하지 않는 것을 살펴본다.\nA (Atomicity) 한 트랜잭션의 모든 연산이 다 반영되거나, 하나도 반영되지 않는다. 중간 실패 시 그때까지의 변경은 전체 롤백된다.\n주문 생성 트랜잭션을 예로 들자. 주문 행 삽입, 재고 차감, 결제 기록 세 연산이 한 트랜잭션 안에서 묶인다. 결제 기록 단계에서 에러가 나면 주문 행도 재고 차감도 모두 없던 일이 된다. \u0026ldquo;주문은 들어갔는데 결제는 안 됐다\u0026rdquo; 같은 부분 상태가 존재하지 않는다.\nDB 는 일반적으로 undo log 로 이 보장을 구현한다. 트랜잭션이 변경할 때마다 이전 값 을 따로 기록해두고, 롤백이 필요하면 그 값으로 되돌린다. commit 이 끝나면 undo log 는 의미가 없어진다.\n이 글에서 다루는 atomicity 는 단일 DB 안에서의 보장이다. 분산 환경의 atomicity — 여러 DB 또는 외부 서비스를 묶는 — 는 2PC 나 saga 같은 별도 메커니즘이 필요하고, 이 시리즈의 범위 밖이다.\nC (Consistency) 가장 자주 오해되는 속성이다. \u0026ldquo;DB 가 알아서 일관성을 지켜준다\u0026rdquo; 는 표현은 절반만 맞다.\nC 가 보장하는 영역은 DB constraint 다. primary key 의 유일성, foreign key 의 참조 무결성, check constraint 의 조건, NOT NULL, unique index 같은 것들. 트랜잭션이 commit 되는 시점에 이 constraint 들이 깨졌다면 DB 는 commit 을 거부한다. 이 부분은 DB 가 자동으로 지킨다.\n애플리케이션 invariant 는 다른 이야기다. \u0026ldquo;주문 금액의 합이 결제 금액과 같아야 한다\u0026rdquo;, \u0026ldquo;재고는 음수가 될 수 없다\u0026rdquo;, \u0026ldquo;환불 시 원거래의 상태가 \u0026lsquo;paid\u0026rsquo; 여야 한다\u0026rdquo; 같은 비즈니스 규칙은 DB constraint 로 표현되지 않거나 표현해도 부족한 경우가 많다.\n재고 음수 방지는 CHECK (stock \u0026gt;= 0) 같은 constraint 로 가능하지만, \u0026ldquo;환불 가능 여부 판단\u0026rdquo; 처럼 여러 행과 상태를 조합해야 하는 규칙은 애플리케이션 코드가 책임진다. 트랜잭션 안에서 이 규칙들을 어떻게 검증하고 어떤 순서로 검증하는지는 모두 애플리케이션의 책임이다.\nC 의 \u0026lsquo;C\u0026rsquo; 는 일관된 상태로의 전이 를 보장한다는 의미다. 무엇이 \u0026lsquo;일관됨\u0026rsquo; 인지는 DB constraint 와 애플리케이션 invariant 가 함께 정의하고, DB 는 그중 constraint 부분만 강제한다. 이 책임 분담을 의식하지 않으면 \u0026ldquo;DB 가 알아서 지켜줄 줄 알았다\u0026rdquo; 식의 버그가 나온다.\nI (Isolation) 동시에 실행되는 트랜잭션 사이의 보이는 영역 을 제어한다. 트랜잭션 T1 이 실행되는 도중 T2 가 같은 데이터를 건드릴 때, 서로의 중간 상태를 얼마나 볼 수 있는가의 문제다.\n완벽한 격리는 모든 트랜잭션이 순차적으로 실행된 것과 동일한 결과를 보장한다. 정확성 측면에서 가장 강한 보장이다. 그런데 그 보장은 동시성을 거의 죽인다. 한 번에 하나의 트랜잭션만 실행할 수 있다면 처리량이 급격히 떨어진다.\n그래서 RDB 는 격리를 단순 on/off 가 아니라 단계 로 제공한다. \u0026ldquo;어떤 anomaly 까지 허용할 것인가\u0026rdquo; 의 정책 선택이다. 더 강한 격리는 더 적은 anomaly 를 허용하지만 더 큰 동시성 비용을 동반한다.\nI 의 핵심은 그 단계가 왜 존재하는지에 있다. 정확성과 동시성이 충돌하는 영역이라는 점이 본질이고, 시리즈의 다음 글에서 4단계 Isolation Level 과 각 단계가 막는 anomaly 를 깊게 다룬다.\nD (Durability) commit 이 완료된 트랜잭션의 결과는 시스템 장애가 발생해도 살아남는다. 정전, OS crash, 프로세스 강제 종료 같은 어떤 형태의 실패에도 commit 된 데이터는 유실되지 않는다.\n일반적으로 WAL (Write-Ahead Log) 로 구현된다. 변경 내용을 메인 데이터 파일에 쓰기 전에 별도의 로그 파일에 먼저 기록하고 fsync 로 디스크에 강제 동기화한다. 시스템 재시작 후 그 로그를 재생(replay) 하면 commit 된 상태가 복구된다.\nD 는 commit 시점 의 영속성을 보장한다. commit 이전 단계에서 죽으면 그 트랜잭션은 적용되지 않은 것이고, 이건 A 와 자연스럽게 연결된다. 두 보장이 함께 \u0026ldquo;확실히 끝났거나, 아예 없었거나\u0026rdquo; 의 양극단만 남긴다.\nWAL 의 구체적 동작 — 체크포인트, 로그 재활용, 복구 알고리즘 — 은 그 자체로 큰 주제고, 이 시리즈에서는 D 가 무엇을 보장하는가의 개념 수준에서 멈춘다.\n정리 A 와 D 는 비교적 단순한 보장이다. 모두 적용 or 아무것도 적용 안 함 (A), commit 이후 영구 (D). 구현 디테일은 복잡해도 보장 자체는 명확하다.\nC 는 책임이 분담 되어 있다. DB constraint 가 한쪽을, 애플리케이션 invariant 가 다른 쪽을 책임진다. 이 경계를 의식하지 않으면 트랜잭션 안에서 보장된다고 믿었던 규칙이 깨지는 버그가 나온다.\nI 는 정책 선택 이다. 완벽한 격리는 비싸고, 더 약한 격리는 anomaly 를 허용한다. 단순 on/off 가 아니라 4단계가 존재하는 이유 — 정확성과 동시성의 충돌 — 이 시리즈 전체의 메시지다.\n다음 글에서는 그 4단계가 각각 어떤 anomaly 를 막고 어떤 anomaly 를 허용하는지를 본다.\n","permalink":"https://wid-blog.github.io/posts/tech/database/rdb-transaction-acid/","summary":"RDB 트랜잭션의 ACID 4개 속성이 실제로 무엇을 보장하는가. A/C/D 는 비교적 명확한 보장이지만 I 만 \u0026lsquo;단계\u0026rsquo; 가 존재하는 이유 — 정확성과 동시성의 트레이드오프 도입.","title":"RDB Transaction 의 ACID 가 실제로 보장하는 것"},{"content":"광고 예산 페이싱은 캠페인의 일예산을 시간대별로 균등하게 소진하는 기능이다. 광고주가 노출 가능한 시간 안에서 예산을 안정적으로 쓰고 싶을 때 선택한다.\n기존 방식은 한 마디로 모든 캠페인에 같은 규칙 을 적용하는 구조였다. 캠페인 특성과 무관하게 같은 출발선에서 시작하고, 소진 정도에 따라 일률적으로 노출 확률을 조정했다. 단순했지만 캠페인 특성이 크게 갈리는 환경에서는 잘 작동하지 않았다. 어떤 캠페인은 시간 초기에 예산을 빠르게 다 태웠고, 어떤 캠페인은 초기 캡에 막혀 미소진되었다.\n단일 규칙으로 모든 캠페인을 동일하게 다루는 것 자체가 한계였다. 이 기능을 캠페인별 학습과 실시간 보정 의 2계층 제어 구조로 재설계했다.\n단일 규칙의 한계 기존 구조의 약점은 명확했다. 모든 캠페인이 같은 출발선에 섰다. 캠페인의 특성은 평균이 아니라 분포로 존재하고, 시간대마다·환경마다 계속 바뀐다. 평균을 가정한 단일 규칙은 분포의 양 끝에서 깨졌다 — 빠르게 소진되는 쪽도 미소진되는 쪽도 같은 규칙으로는 흡수되지 않는다.\n같은 확률을 적용한다는 것은 캠페인의 특성을 무시한다는 뜻이다.\n2계층 구조 해법을 단일 계층 안에서 풀려고 했을 때 모순에 부딪혔다. 빠르게 반응하면 노출이 출렁였고, 느리게 반응하면 예산 미스가 누적됐다. 잦은 확률 변동은 광고주 입장에서 노출이 불안정해 보였고, 큰 단위로만 확률을 잡으면 그 사이의 트래픽 변동을 흡수할 수 없었다.\n그래서 두 계층으로 나눴다.\nSlow controller. 큰 시간 단위로 캠페인의 과거 소진 양상을 분석해 다음 구간의 기준선을 도출한다.\nFast controller. 짧은 시간 단위로 예상 대비 실제 소진을 비교해 남은 차이를 흡수한다.\nslow controller 는 \u0026ldquo;이 캠페인이 어떤 페이스로 가야 하는가\u0026rdquo; 라는 가설을 갱신한다. fast controller 는 \u0026ldquo;지금 실제 페이스가 그 가설을 벗어나는가\u0026rdquo; 를 점검한다. 두 제어기의 시간 스케일이 책임을 자연스럽게 갈랐다.\n기준선과 보정의 분리 핵심은 보정의 크기에 있다. slow controller 가 기준선을 잡지 못하면 fast controller 는 매번 큰 폭으로 확률을 흔들어야 한다. 기준선이 정확할수록 보정량은 작아진다. 작은 보정은 노출 안정성을 해치지 않으면서 단기 충격만 흡수한다.\n기준선 자체는 측정 기반이다. 직전 구간에서 어떤 페이스로 움직였는지가 다음 구간의 시드가 된다. 측정-적용-측정의 단순 루프다.\n여기에는 익숙한 함정이 따라온다. 측정값이 없을 때. 직전 구간 동안 노출이 거의 없어 측정 자체가 비는 캠페인이 있다. 이때는 측정 기반 보정 대신 외부에서 기본값을 주입해야 한다. 광고 도메인의 페이싱에서만 만나는 문제가 아니라 일반 제어 시스템의 cold-start 와 같은 결의 문제다.\n적용 후 같은 시간대, 같은 캠페인 묶음으로 비교했을 때 세 가지 변화가 두드러졌다. 초기 집중 소진이 완화됐고, 시간 초기의 노출 폭주가 잦아들었으며, 미소진 캠페인의 소진율이 올랐다. 세 변화는 같은 원인의 다른 표현이다 — 고정 규칙에서 캠페인별 학습으로 옮긴 효과.\n회고 이 작업의 가장 큰 결정은 두 계층으로 나눈 것 자체였다고 본다. 단일 계층 안에서 학습과 보정을 동시에 하려고 했다면 매번 확률이 흔들렸을 것이고, 큰 단위로만 잡았다면 단기 충격을 흡수하지 못했을 것이다. 시간 스케일이 다른 두 신호를 두 제어기로 분리한 것이 자연스러운 책임 분리였다.\ncontrol loop 이라는 언어가 광고 도메인에서도 그대로 맞아 떨어진다는 것이 흥미로웠다. 제어공학에서 다루는 cold-start, integrator windup, 측정 결손 같은 문제가 광고 페이싱에도 똑같이 나타났다. 도메인이 달라도 같은 형태의 문제가 반복되는 자리에서는 같은 언어를 빌려오는 게 사고를 덜 흔들리게 만든다고 봤다.\n다음에 비슷한 결을 만난다면 — 측정 신호의 시간 스케일이 두 개 이상으로 갈리는 자리에서 — 처음부터 계층을 나눠보려 한다.\n","permalink":"https://wid-blog.github.io/posts/career/dable/balanced-pacing-control/","summary":"고정 규칙으로 동작하던 광고 예산 페이싱을, 캠페인별 학습과 실시간 보정의 2계층 제어 구조로 옮긴 회고. 캠페인 특성의 다양성을 어떻게 자동 흡수하게 만들었는가.","title":"광고 예산 페이싱의 2계층 제어 구조 회고"},{"content":"저수준 언어를 직접 다뤄보고 싶었다. 메모리를 런타임이 아닌 언어 규칙으로 관리하는 경험. Rust를 선택했고, Book Chapter 20의 멀티스레드 HTTP 서버를 따라 구현했다. 외부 크레이트 없이 std 라이브러리만으로 약 200줄.\n메모리 관리 Rust에는 가비지 컬렉터가 없다. 대신 소유권 시스템이 메모리 해제 시점을 컴파일 타임에 결정한다.\n모든 값에는 소유자가 하나뿐이다. 소유자가 스코프를 벗어나면 값은 자동으로 해제된다. 다른 변수에 값을 대입하면 소유권이 이동하고, 원래 변수는 더 이상 사용할 수 없다. 컴파일러가 이를 강제한다.\nlet s1 = String::from(\u0026#34;hello\u0026#34;); let s2 = s1; // 소유권 이동 // s1은 여기서 사용 불가 — 컴파일 에러 소유권을 넘기지 않고 값을 빌려줄 수도 있다. 참조(\u0026amp;)를 통한 borrowing이다. 불변 참조는 여러 개 동시에 가능하지만, 가변 참조(\u0026amp;mut)는 한 번에 하나만 허용된다. 이 규칙이 데이터 경합을 컴파일 타임에 차단한다.\nGC 기반 언어에서는 런타임이 메모리를 수거한다. Rust는 그 판단을 컴파일러에게 맡긴다. 런타임 비용 없이 메모리 안전을 보장하는 구조다.\nThread Pool 서버의 핵심은 thread pool이다. TCP 연결이 들어오면 워커 스레드에 작업을 분배한다.\n작업 분배에는 채널을 사용했다. 하나의 sender가 작업을 보내고, 여러 워커가 receiver를 공유한다. 문제는 Rust의 Receiver가 Clone을 구현하지 않는다는 점이었다. 여러 스레드가 하나의 receiver를 공유하려면 다른 방법이 필요했다.\nArc\u0026lt;Mutex\u0026lt;Receiver\u0026lt;T\u0026gt;\u0026gt;\u0026gt;가 그 답이었다. Arc는 참조 카운트를 통해 여러 스레드가 같은 값을 소유할 수 있게 한다. Mutex는 한 번에 하나의 스레드만 receiver에 접근하도록 보장한다. 소유권 규칙이 단일 스레드에서 동시성 환경으로 자연스럽게 확장되는 지점이었다.\nlet (sender, receiver) = mpsc::channel(); let receiver = Arc::new(Mutex::new(receiver)); for id in 0..size { let receiver = Arc::clone(\u0026amp;receiver); // 각 워커가 receiver의 참조 카운트를 공유 } Arc::clone()은 값을 복사하지 않는다. 참조 카운트만 증가시킨다. 이 구분을 타입 시스템이 명확히 드러낸다고 봤다.\nGraceful Shutdown Drop trait은 값이 스코프를 벗어날 때 자동으로 호출된다. pool이 소멸할 때 워커를 정리하는 데 사용했다.\n순서가 중요했다. 먼저 모든 워커에게 Terminate 메시지를 보내고, 그다음 각 스레드를 join한다. 이 순서를 뒤집으면 교착 상태가 발생할 수 있다. 첫 번째 워커의 join에서 블로킹되는 동안 나머지 워커는 종료 신호를 받지 못하기 때문이다.\n// 1. 종료 신호를 먼저 모두 전송 for _ in \u0026amp;self.workers { self.sender.send(Message::Terminate)?; } // 2. 그다음 join for worker in \u0026amp;mut self.workers { if let Some(thread) = worker.thread.take() { thread.join()?; } } worker.thread를 Option\u0026lt;JoinHandle\u0026lt;()\u0026gt;\u0026gt;로 선언한 것도 Rust다운 패턴이었다. take()로 핸들을 꺼내면 원래 자리에 None이 남는다. 같은 스레드를 두 번 join하는 실수를 타입 수준에서 방지한다.\nTrait Bounds thread pool의 제네릭 타입 제약은 세 가지다.\nPool\u0026lt;T: FnOnce() + Send + \u0026#39;static\u0026gt; FnOnce는 클로저가 한 번만 호출된다는 의미다. 작업은 한 워커가 한 번 실행하면 끝이다. Send는 클로저를 다른 스레드로 안전하게 전달할 수 있다는 보장이다. 'static은 클로저가 참조하는 값의 수명이 프로그램 전체와 같다는 제약이다. 스레드가 언제 종료될지 모르니, 빌린 참조가 먼저 해제되는 상황을 원천 차단한다.\n이 세 가지 중 하나라도 빠지면 컴파일되지 않는다. Go에서는 goroutine에 클로저를 넘길 때 이런 제약이 없다. 대신 race condition을 -race 플래그로 런타임에 감지한다. Rust는 그 검증을 컴파일러가 수행한다.\n회고 저수준 언어를 경험하고 싶다는 동기로 시작한 프로젝트였다. 실제로 체감한 것은 \u0026ldquo;저수준\u0026quot;보다 \u0026ldquo;컴파일러가 강제하는 안전성\u0026quot;이었다.\nArc\u0026lt;Mutex\u0026lt;T\u0026gt;\u0026gt;를 조합하지 않으면 여러 스레드가 receiver를 공유할 수 없다. FnOnce + Send + 'static을 명시하지 않으면 클로저를 스레드에 넘길 수 없다. Option\u0026lt;JoinHandle\u0026gt;로 선언하지 않으면 take()를 쓸 수 없다. 컴파일러가 \u0026ldquo;왜 이 조합이 필요한지\u0026quot;를 에러 메시지로 알려주고, 해결하면 동시성 안전이 보장된다.\n결과적으로 배운 것은 타입 시스템이 동시성 버그를 런타임 전에 잡아주는 경험이었다.\n참고 rust-server GitHub Repository ","permalink":"https://wid-blog.github.io/posts/career/personal/rust-server-retrospective/","summary":"Rust Book Chapter 20의 멀티스레드 HTTP 서버를 따라 구현하며 소유권과 동시성이 타입 레벨에서 강제되는 것을 체감한 기록.","title":"rust-server"},{"content":"백엔드 서버를 개발하다 보면 TCP와 UDP를 의식하지 않고 사용하는 경우가 많다. HTTP API와 WebSocket은 TCP 위에서 동작하고, 음성/영상 스트리밍이나 DNS에는 UDP가 쓰인다. 채팅 서버나 광고 서버처럼 성능과 신뢰성 요구가 다른 워크로드를 다루다 보면 전송 계층의 동작 원리를 정리할 필요가 생긴다.\n전송 계층 전송 계층은 애플리케이션 간 데이터 전달을 담당한다. IP가 호스트까지의 경로를 찾는다면, 전송 계층은 해당 호스트의 어떤 프로세스에 데이터를 전달할지 결정한다. 포트 번호가 이 역할을 한다.\nTCP와 UDP는 모두 IP 위에서 동작한다. 차이는 신뢰성과 속도 사이의 선택이다.\nTCP TCP(Transmission Control Protocol)는 연결 지향 프로토콜이다. 데이터를 보내기 전에 연결을 수립하고, 전송 중 유실이 발생하면 재전송한다.\n연결 수립 TCP는 3-way handshake로 연결을 수립한다.\nsequenceDiagram participant C as 클라이언트 participant S as 서버 C-\u003e\u003eS: SYN (seq=x) Note right of S: SYN 수신, 연결 준비 S-\u003e\u003eC: SYN-ACK (seq=y, ack=x+1) Note left of C: SYN-ACK 수신 C-\u003e\u003eS: ACK (ack=y+1) Note over C,S: 연결 수립 완료 클라이언트가 SYN 패킷과 함께 자신의 시퀀스 번호를 보낸다. 서버가 SYN-ACK로 응답하며 서버 측 시퀀스 번호를 전달한다. 클라이언트가 ACK를 보내면 연결이 성립된다. 양쪽이 교환한 시퀀스 번호는 이후 데이터 전송에서 순서를 추적하기 위한 기준점이다.\n연결 종료 연결 종료는 4-way handshake를 사용한다. TCP는 전이중(full-duplex) 통신이므로 양방향을 각각 종료해야 한다.\nsequenceDiagram participant A as 종료 요청 측 participant B as 상대 측 A-\u003e\u003eB: FIN Note right of B: FIN 수신, 수신 방향 종료 B-\u003e\u003eA: ACK Note over B: 남은 데이터 전송 완료 B-\u003e\u003eA: FIN Note left of A: FIN 수신, 송신 방향 종료 A-\u003e\u003eB: ACK Note over A: TIME_WAIT 상태 진입 Note over A,B: 연결 해제 완료 한쪽이 FIN을 보내면 \u0026ldquo;더 이상 보낼 데이터가 없다\u0026quot;는 의미다. 상대는 ACK로 응답한 뒤 자신의 남은 데이터를 마저 보내고, 자기 쪽에서도 FIN을 보낸다. 최초 FIN을 보낸 측은 마지막 ACK를 보낸 후 TIME_WAIT 상태에 진입한다. 네트워크에 남아있을 수 있는 지연 패킷이 도착할 시간을 확보하기 위한 대기다.\n세그먼트 분할 애플리케이션이 보낸 데이터는 TCP가 세그먼트 단위로 분할하여 전송한다. 한 번의 send() 호출로 4KB를 보내도 TCP는 이를 MSS 크기에 맞춰 여러 세그먼트로 나눈다.\nflowchart LR A[\"애플리케이션\\n4KB 전달\"] --\u003e B[\"TCP\"] B --\u003e C[\"세그먼트 1\\nseq=1\\n1460 bytes\"] B --\u003e D[\"세그먼트 2\\nseq=1461\\n1460 bytes\"] B --\u003e E[\"세그먼트 3\\nseq=2921\\n1160 bytes\"] 수신 측 TCP는 도착한 세그먼트를 시퀀스 번호 순서대로 재조립하여 애플리케이션에 전달한다. 분할과 재조립은 TCP가 투명하게 처리하므로 애플리케이션은 원래의 연속된 바이트 스트림만 보게 된다.\n신뢰성 보장 세그먼트 단위로 분할된 데이터는 네트워크를 지나면서 유실되거나 순서가 뒤바뀔 수 있다. TCP는 두 가지 메커니즘으로 데이터 도착을 보장한다.\n순서 보장: 각 세그먼트에 시퀀스 번호가 부여되어 있다. 수신 측은 이 번호를 기준으로 데이터를 원래 순서대로 재조립한다. 세그먼트가 순서 없이 도착해도 애플리케이션에는 정렬된 데이터가 전달된다.\n재전송: 송신 측은 데이터를 보낸 후 ACK를 기다린다. 일정 시간, RTO(Retransmission Timeout) 내에 ACK가 오지 않으면 해당 데이터를 다시 보낸다.\nACK 번호는 누적 확인 방식이다. \u0026ldquo;ACK 3\u0026quot;은 \u0026ldquo;3번 이전까지 모두 받았고, 다음에 3번을 기대한다\u0026quot;는 의미다.\n정상 흐름 sequenceDiagram participant S as 송신 participant R as 수신 S-\u003e\u003eR: Segment 1 R-\u003e\u003eS: ACK 2 S-\u003e\u003eR: Segment 2 R-\u003e\u003eS: ACK 3 세그먼트가 순서대로 도착하면 수신 측은 다음 기대 번호를 ACK로 보낸다. ACK 2는 \u0026ldquo;1번 수신, 다음은 2번\u0026quot;이라는 의미다.\n유실 시나리오 sequenceDiagram participant S as 송신 participant R as 수신 S-\u003e\u003eR: Segment 1, 2 S--xR: Segment 3 [유실] S-\u003e\u003eR: Segment 4 R-\u003e\u003eS: ACK 3 R-\u003e\u003eS: ACK 3 (중복) R-\u003e\u003eS: ACK 3 (중복) Note over S: 중복 3회 → 유실 판단 S-\u003e\u003eR: Segment 3 [재전송] R-\u003e\u003eS: ACK 5 Segment 4가 도착해도 3번이 빠져 있으므로 수신 측은 ACK 번호를 올리지 못하고 ACK 3을 반복한다. 송신 측은 같은 ACK가 3번 중복되면 타임아웃을 기다리지 않고 즉시 재전송한다. 수신 측은 재전송된 3번을 받으면 버퍼에 보관하고 있던 4번과 합쳐서 ACK 5를 보낸다.\n흐름 제어 수신 측의 처리 능력을 초과하면 데이터가 유실된다. TCP는 슬라이딩 윈도우 메커니즘으로 이를 방지한다.\n수신 측은 자신이 처리할 수 있는 버퍼 크기를 수신 윈도우(rwnd, receive window)로 송신 측에 알려준다. 송신 측은 ACK를 받지 않은 상태로 rwnd 크기 이상의 데이터를 보내지 않는다.\nACK 대기 중:\nblock-beta columns 10 block:window[\"송신 윈도우 (rwnd = 4)\"]:4 s3[\"3 전송\"] s4[\"4 전송\"] s5[\"5 대기\"] s6[\"6 대기\"] end s7[\"7\"] s8[\"8\"] s9[\"9\"] s10[\"10\"] s11[\"11\"] s12[\"12\"] style s3 fill:#4CAF50 style s4 fill:#4CAF50 style s5 fill:#42A5F5 style s6 fill:#42A5F5 3, 4는 전송 완료, 5, 6은 윈도우 안이지만 아직 전송하지 않은 세그먼트다. 7 이후는 윈도우 밖이라 보낼 수 없다.\nACK 3 수신 후 — 윈도우가 오른쪽으로 이동:\nblock-beta columns 10 s3[\"3 ✓\"] block:window[\"송신 윈도우 (rwnd = 4)\"]:4 s4[\"4 전송\"] s5[\"5 전송\"] s6[\"6 대기\"] s7[\"7 대기\"] end s8[\"8\"] s9[\"9\"] s10[\"10\"] s11[\"11\"] s12[\"12\"] style s3 fill:#9E9E9E style s4 fill:#4CAF50 style s5 fill:#4CAF50 style s6 fill:#42A5F5 style s7 fill:#42A5F5 3의 ACK가 돌아오면 윈도우가 한 칸 오른쪽으로 이동한다. 3은 완료되어 윈도우 밖으로 빠지고, 7이 새로 윈도우 안에 들어온다. ACK를 받을 때마다 이 과정이 반복된다. 수신 측의 버퍼가 가득 차면 rwnd를 0으로 보내서 송신을 일시 중단시킨다. 버퍼에 여유가 생기면 다시 윈도우 크기를 알려 송신을 재개한다.\n슬라이딩 윈도우는 Stop-and-Wait 방식의 한계를 극복한 기법이다. Stop-and-Wait는 패킷 하나를 보내고 ACK를 기다린 후 다음을 보낸다. 한 번에 하나만 전송하므로 링크 활용률이 낮다. 슬라이딩 윈도우는 ACK를 기다리는 동안에도 윈도우 범위 내의 여러 패킷을 연속으로 전송한다.\n재전송 방식도 두 가지다. Go-Back-N은 유실된 패킷 이후의 모든 패킷을 재전송한다. 구현이 간단하지만 불필요한 재전송이 발생한다. Selective Repeat는 유실된 패킷만 선택적으로 재전송한다. 수신 측 버퍼가 필요하지만 네트워크 효율이 높다. TCP는 Selective Repeat 방식을 사용한다. SACK 옵션이 이를 지원한다.\n혼잡 제어 흐름 제어가 수신 측의 처리 능력에 맞추는 것이라면, 혼잡 제어는 네트워크 경로의 처리 능력에 맞추는 것이다. 네트워크가 혼잡하면 라우터의 버퍼가 넘치고 패킷이 유실된다.\nTCP는 혼잡 윈도우(cwnd, congestion window)라는 변수를 관리한다. 실제 전송량은 rwnd와 cwnd 중 작은 값으로 결정된다.\n슬로우 스타트 연결 초기에는 네트워크 용량을 모른다. cwnd를 1 MSS로 시작하고, ACK가 정상적으로 돌아올 때마다 cwnd를 1 MSS씩 증가시킨다. RTT당 cwnd가 두 배로 늘어나므로 지수적 증가다.\n--- config: xyChart: xAxis: label: \"RTT\" yAxis: label: \"cwnd (MSS)\" --- xychart-beta x-axis [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"] y-axis 0 --\u003e 40 line \"cwnd\" [1, 2, 4, 8, 16, 17, 18, 19, 20, 10, 11] cwnd가 ssthresh(slow start threshold)에 도달하면 혼잡 회피 단계로 전환된다. 위 그래프에서 RTT 4에서 ssthresh(=16)에 도달한 후 선형 증가로 바뀌고, RTT 9에서 패킷 유실이 감지되어 cwnd가 절반으로 줄어든다.\n혼잡 회피 ssthresh 이후에는 cwnd를 RTT당 1 MSS씩만 증가시킨다. 선형 증가다. 패킷 유실이 감지될 때까지 조금씩 전송량을 늘린다. AIMD, Additive Increase/Multiplicative Decrease 전략이다. 네트워크를 과도하게 사용하지 않으면서 가용 대역폭을 점진적으로 탐색한다.\n빠른 재전송 패킷 유실을 감지하는 방법은 두 가지다. 타임아웃(RTO 만료)과 중복 ACK다. 타임아웃은 수백 밀리초에서 수 초까지 걸릴 수 있어 느리다.\n빠른 재전송은 동일한 ACK가 3회 중복되면 타임아웃을 기다리지 않고 즉시 해당 세그먼트를 재전송한다. 중복 ACK 자체가 \u0026ldquo;해당 패킷 이후의 데이터는 도착하고 있지만 중간에 빠진 것이 있다\u0026quot;는 신호이기 때문이다.\n빠른 복구 빠른 재전송 후 슬로우 스타트로 돌아가면 전송량이 급격히 떨어진다. 빠른 복구는 이를 방지한다. 패킷 유실이 감지되면 cwnd를 1로 떨어뜨리는 대신 절반으로 줄이고 혼잡 회피 단계에서 바로 시작한다.\n중복 ACK가 도착한다는 것은 네트워크가 완전히 막힌 것이 아니라 일부 패킷은 전달되고 있다는 의미다. 슬로우 스타트까지 후퇴할 필요가 없다.\nflowchart TD A[연결 시작] --\u003e B[슬로우 스타트cwnd 지수 증가] B --\u003e|cwnd \u003e= ssthresh| C[혼잡 회피cwnd 선형 증가] B --\u003e|타임아웃| D[ssthresh = cwnd/2cwnd = 1 MSS] C --\u003e|3회 중복 ACK| E[빠른 재전송 + 빠른 복구ssthresh = cwnd/2cwnd = ssthresh] C --\u003e|타임아웃| D D --\u003e B E --\u003e C 타임아웃이 발생하면 네트워크가 심각하게 혼잡한 것으로 판단하고 cwnd를 1 MSS로 초기화한다. 중복 ACK로 감지한 유실은 상대적으로 경미한 혼잡이므로 cwnd를 절반만 줄인다.\n구현체별 차이 위 4대 알고리즘의 기본 골격은 동일하지만, 구체적인 동작은 구현체마다 다르다.\nTCP Reno: 위에서 설명한 슬로우 스타트, 혼잡 회피, 빠른 재전송, 빠른 복구를 처음 통합한 구현이다. 한 번에 하나의 패킷 유실만 효율적으로 처리한다.\nTCP NewReno: Reno의 한계를 보완했다. 하나의 윈도우에서 여러 패킷이 유실된 경우, 부분 ACK에도 슬로우 스타트로 후퇴하지 않고 빠른 복구 상태를 유지하면서 유실된 패킷들을 순차적으로 재전송한다.\nTCP CUBIC: Linux의 기본 혼잡 제어 알고리즘이다. 혼잡 회피 단계에서 RTT에 비례하는 선형 증가 대신 3차 함수를 사용한다. 대역폭이 큰 장거리 네트워크에서 가용 대역폭을 빠르게 활용한다.\nBBR(Bottleneck Bandwidth and RTT): Google이 개발한 모델 기반 알고리즘이다. 패킷 유실이 아닌 측정된 대역폭과 RTT를 기반으로 전송 속도를 결정한다. 네트워크 경로의 실제 병목 대역폭을 추정하고, 라우터 버퍼를 과도하게 채우지 않으면서 최대 처리량을 달성한다. 인터넷 트래픽 중 상당 비중이 BBR을 사용하는 것으로 알려져 있다.\nUDP UDP(User Datagram Protocol)는 비연결 프로토콜이다. handshake 없이 데이터를 즉시 전송한다.\n구조 UDP 헤더는 8바이트다. 출발지 포트, 목적지 포트, 길이, 체크섬만 포함한다. TCP 헤더가 최소 20바이트인 것과 비교하면 오버헤드가 적다.\nblock-beta columns 4 block:tcp[\"TCP 헤더 (20+ 바이트)\"]:4 t1[\"출발지 포트\"] t2[\"목적지 포트\"] t3[\"시퀀스 번호\"] t4[\"ACK 번호\"] t5[\"플래그\"] t6[\"윈도우 크기\"] t7[\"체크섬\"] t8[\"옵션...\"] end space:4 block:udp[\"UDP 헤더 (8 바이트)\"]:4 u1[\"출발지 포트\"] u2[\"목적지 포트\"] u3[\"길이\"] u4[\"체크섬\"] end style tcp fill:#E3F2FD style udp fill:#E8F5E9 순서 보장이 없다. 재전송도 없다. 패킷이 유실되면 애플리케이션이 직접 처리해야 한다. 흐름 제어와 혼잡 제어도 제공하지 않는다.\n왜 필요한가 TCP의 신뢰성은 지연을 수반한다. 3-way handshake에 최소 1 RTT가 소요된다. 재전송은 추가 지연을 만든다. 혼잡 제어로 인해 전송 속도가 제한될 수도 있다.\n실시간성이 중요한 워크로드에서는 이 지연이 신뢰성보다 큰 문제가 된다. 음성 통화에서 0.5초 전 음성이 재전송으로 도착하면 대화 흐름이 깨진다. 게임에서 오래된 위치 정보가 뒤늦게 도착하면 의미가 없다. 이런 경우 유실된 데이터를 버리는 것이 재전송하는 것보다 낫다.\n비교 flowchart LR subgraph TCP direction TB tc1[연결 지향] tc2[순서 보장] tc3[재전송] tc4[흐름/혼잡 제어] tc5[20+ 바이트 헤더] end subgraph UDP direction TB uc1[비연결] uc2[순서 보장 없음] uc3[재전송 없음] uc4[제어 없음] uc5[8 바이트 헤더] end TCP --- 신뢰성 UDP --- 속도 선택 기준 TCP가 적합한 경우:\n데이터 무결성이 필수일 때: 웹 통신(HTTP/HTTPS), 파일 전송(FTP), 이메일(SMTP) DB 통신: 쿼리 결과가 정확히 도착해야 한다 API 호출: 요청과 응답이 유실되면 안 된다 UDP가 적합한 경우:\n실시간 스트리밍: 영상, 음성 통화(VoIP) 온라인 게임: 위치, 상태 업데이트 DNS: 작은 요청/응답을 빠르게 주고받는다 IoT 센서 데이터: 주기적 전송, 일부 유실 허용 QUIC: HTTP/3의 기반 프로토콜이다. UDP 위에서 TCP의 신뢰성, 재전송과 순서 보장, 그리고 TLS 암호화를 구현한다. TCP의 3-way handshake + TLS handshake 지연을 줄이면서도 신뢰성을 확보하려는 접근이다. TCP가 OS 커널에 구현되어 변경이 어려운 반면, QUIC는 애플리케이션 수준에서 동작하므로 빠른 개선이 가능하다.\n백엔드 개발에서 전송 계층을 의식하지 않고 지나치기 쉽다. 하지만 HTTP가 TCP 위에서 동작하는 이유, WebSocket이 TCP를 선택한 이유, DNS가 UDP를 쓰는 이유는 모두 여기서 출발한다. 결국 프로토콜 선택은 워크로드가 신뢰성을 더 요구하는지, 지연을 더 줄여야 하는지에 따라 갈린다.\n","permalink":"https://wid-blog.github.io/posts/tech/network/tcp-udp/","summary":"백엔드 개발에서 자주 마주치는 두 전송 프로토콜, TCP와 UDP의 동작 원리를 정리한다. 연결 수립, 신뢰성 보장, 흐름/혼잡 제어 메커니즘과 선택 기준.","title":"TCP와 UDP"},{"content":"지인을 통해 연락이 왔다. 딥페이크 기반으로 밈을 만드는 서비스를 준비하고 있는데, 개발자가 필요하다고. 서비스 소개 자료를 보니 재밌었다. 사용자 얼굴을 GIF 밈에 합성해서 나만의 짤을 만드는 앱이다. 2021년 3월, 4명으로 구성된 창업 팀에 합류했다.\n서비스 SwapDo는 딥페이크 기반 얼굴 합성 밈 생성 서비스다. Android와 iOS를 지원했다.\n핵심 기능은 얼굴 합성이다. 앱에서 제공하는 GIF나 이미지 콘텐츠에 사용자의 얼굴을 합성하면 새로운 짤이 만들어진다. 합성은 백그라운드에서 처리되기 때문에 결과를 기다리는 동안 다른 콘텐츠를 둘러볼 수 있었다. 완료되면 푸시 알림이 왔다.\n가상 성형 기능도 있었다. 유명인의 눈, 코, 입을 부위별로 골라서 자기 얼굴에 합성할 수 있었다. 밈 월드컵은 재밌는 주제의 콘텐츠를 토너먼트 형식으로 선택하는 기능이었다. 커뮤니티에서는 만들어진 짤을 공유하고 댓글을 달 수 있었다.\n역할 팀장을 맡았다. 4개월간 팀 운영을 하면서 동시에 개발을 했다. 기여도로 보면 백엔드 서버 개발이 80%, Android 앱 개발이 30%, 합성 기술 개발이 10% 정도였다.\n아키텍처 서비스 구조는 단순했다. Android/iOS 클라이언트가 Apache 기반 백엔드 서버에 REST API를 호출한다. 백엔드는 PHP로 작성했고, 데이터는 MariaDB에 저장했다. 에러 추적은 Sentry를 사용했다.\n얼굴 합성은 별도의 환경에서 처리했다. 백엔드가 합성 요청을 받으면 Anaconda 가상 환경의 Python 스크립트를 호출한다. 합성이 완료되면 결과 파일의 경로를 백엔드에 반환하는 구조였다.\nflowchart LR subgraph Client Android iOS end Client -- \"에러 로그\" --\u003e Sentry Client -- \"요청 (CRUD)\" --\u003e Backend Backend -- \"응답 (JSON)\" --\u003e Client Backend[\"Apache\\n백엔드 서버\\n(PHP)\"] Backend -- \"데이터 저장/조회\" --\u003e MariaDB[(MariaDB)] Backend -- \"합성 요청\" --\u003e Anaconda[\"Anaconda\\n합성용 가상 환경\\n(Python)\"] Anaconda -- \"합성 결과 위치\" --\u003e Backend Anaconda -- \"합성 결과\" --\u003e Storage[(\"파일\\n스토리지\")] 합성 과정은 여러 단계를 거쳤다. 사용자 얼굴을 인식하고, 얼굴 랜드마크를 추출한다. GIF 콘텐츠의 각 프레임 정보를 가져온 뒤, 3D 모델링으로 얼굴을 합성한다. 경계선과 피부색을 보정하고, 프레임별로 합성한 결과를 다시 GIF로 인코딩한다. OpenCV, Dlib, FaceAlignment 같은 라이브러리를 사용했다.\nflowchart LR A[\"사용자 얼굴 인식\"] --\u003e B[\"얼굴 랜드마크\\n추출\"] B --\u003e C[\"GIF 프레임\\n정보 추출\"] C --\u003e D[\"3D 모델링 및\\n얼굴 합성\"] D --\u003e E[\"경계선 및\\n피부색 보정\"] E --\u003e F[\"프레임별 합성\"] F --\u003e G[\"GIF 인코딩\"] G --\u003e H[\"합성 결과\\n전달\"] 얼굴 인식 랜드마크 추출 3D 모델링 얼굴 합성 합성 결과 기술적 기여 백엔드 리팩토링 합류 당시 백엔드 코드는 하나의 파일에 모든 로직이 들어 있었다. MVC 패턴을 도입하고 OOP로 구조를 분리했다. 요청 처리, 비즈니스 로직, 데이터 접근을 계층별로 나누자 코드 가독성이 올라갔고, 응답 시간도 약 10% 개선됐다.\nAndroid 무한 스크롤 콘텐츠 목록의 스크롤 성능이 좋지 않았다. 스크롤할 때마다 버벅거림이 느껴졌다. 무한 스크롤 로직을 개선해서 스크롤 속도를 약 60%까지 끌어올렸다. 이미지 로딩에 Glide를 사용하고, RecyclerView의 재활용 로직을 정비한 결과였다.\n비동기 합성 요청 얼굴 합성은 서버에서 처리 시간이 걸린다. 사용자가 합성 버튼을 누르고 결과를 기다리는 동안 앱이 멈춰 있으면 안 됐다. Android의 Service 컴포넌트를 활용해서 합성 요청을 비동기로 처리했다. 합성이 백그라운드에서 진행되는 동안 사용자는 다른 콘텐츠를 탐색할 수 있었고, 완료 시 FCM 푸시 알림으로 결과를 안내했다.\n회고 처음으로 창업 팀에 합류한 경험이었다. 기획된 제품을 받아서 만드는 것이 아니라, 제품이 어떤 형태여야 하는지를 함께 고민하면서 코드를 작성했다. 팀장 역할도 처음이었다. 개발과 팀 운영을 동시에 하는 것이 쉽지 않았지만, 제품 전체를 조망하는 시야를 갖게 된 계기였다고 본다.\n기술적으로는 PHP 기반의 REST API 설계, Android 앱의 성능 최적화, 백그라운드 처리 패턴을 경험했다. 작은 팀에서 여러 역할을 맡다 보니 백엔드와 모바일을 넘나드는 개발을 하게 됐고, 그 과정에서 서비스 전체 흐름을 이해하는 감각이 생겼다.\n2021년 7월, 프로젝트는 자연스럽게 마무리됐다. 지인의 연락 한 통에서 시작된 5개월이었다. 돌이켜 보면 제품을 함께 만들어가는 경험이 가장 큰 배움이었다.\n","permalink":"https://wid-blog.github.io/posts/career/startup/swapdo-startup-retrospective/","summary":"딥페이크 기반 얼굴 합성 밈 서비스 SwapDo. 창업 팀에 합류해 백엔드와 Android 앱 개발을 맡았던 5개월의 기록.","title":"SwapDo 창업기"},{"content":"웹 서비스에서 인증을 구현할 때 세션과 JWT 중 어느 쪽을 선택할지 결정해야 한다. 세션은 서버가 상태를 관리하여 즉시 제어가 가능하고, JWT는 서버에 상태를 두지 않아 수평 확장에 유리하다. 핵심은 \u0026ldquo;인증 상태를 어디에 둘 것인가\u0026quot;다.\n로그인한 사용자가 다음 페이지를 요청하면, 서버는 이 사용자가 누구인지 모른다. HTTP가 stateless이기 때문이다. 인증 상태를 유지하려면 상태를 어딘가에 저장해야 한다.\n세션 인증 세션 인증은 서버가 사용자의 인증 상태를 직접 관리하는 방식이다.\n사용자가 로그인하면 서버는 세션 데이터를 생성하고, 고유한 세션 ID를 발급한다. 이 세션 ID는 쿠키를 통해 클라이언트에 전달된다. 이후 클라이언트가 요청을 보낼 때마다 쿠키에 담긴 세션 ID가 함께 전송되고, 서버는 이 ID로 세션 저장소를 조회해 사용자를 식별한다.\n세션 데이터는 서버 메모리, 파일 시스템, 또는 Redis 같은 외부 저장소에 보관된다.\n장점 서버가 세션을 직접 관리하기 때문에 제어가 용이하다. 특정 사용자의 세션을 즉시 무효화할 수 있다. 강제 로그아웃이나 동시 접속 제한 같은 기능을 구현하기 쉽다.\n클라이언트에는 세션 ID만 전달되므로, 사용자 정보가 네트워크를 통해 노출될 위험이 적다.\n한계 서버가 상태를 저장하므로 수평 확장에 제약이 생긴다. 서버 인스턴스가 여러 대일 때, 사용자가 다른 인스턴스로 요청을 보내면 세션을 찾을 수 없다. 이를 해결하려면 sticky session을 설정하거나, Redis 같은 공유 세션 저장소를 도입해야 한다.\n사용자 수가 늘어나면 세션 저장소의 부하도 함께 증가한다.\nJWT JWT(JSON Web Token)는 인증 정보를 토큰 자체에 담는 방식이다. 서버가 상태를 저장하지 않는다.\n구조 JWT는 세 부분으로 구성된다. 점(.)으로 구분한다.\nheader.payload.signature\nheader는 토큰의 타입과 서명 알고리즘을 명시한다. payload는 사용자 식별 정보와 만료 시간 등의 클레임을 담는다. signature는 header와 payload를 비밀 키로 서명한 값이다.\n서버는 토큰을 받으면 signature를 검증한다. payload가 변조되었으면 signature가 일치하지 않으므로, 토큰이 위조되었는지 판별할 수 있다. 세션 저장소를 조회할 필요가 없다.\n장점 서버가 상태를 저장하지 않으므로 수평 확장이 자유롭다. 어떤 서버 인스턴스가 요청을 받아도 토큰만 검증하면 된다. 별도의 세션 저장소가 필요 없다.\n한계 토큰이 발급되면 만료 전까지 무효화하기 어렵다. 서버에 토큰 상태가 없기 때문이다. 강제 로그아웃이 필요하면 별도의 블랙리스트 저장소를 운영해야 하고, 이 경우 stateless 이점이 줄어든다.\npayload는 Base64로 인코딩될 뿐, 암호화되지 않는다. 민감한 정보를 payload에 담으면 안 된다.\n토큰 크기가 세션 ID보다 크다. 매 요청마다 전송되므로 네트워크 오버헤드가 세션 방식보다 크다.\naccess token과 refresh token JWT를 사용할 때, 토큰을 하나만 발급하면 유효 기간 설정에서 트레이드오프가 발생한다. 길게 잡으면 탈취 시 위험하고, 짧게 잡으면 사용자가 자주 다시 로그인해야 한다.\n이 문제를 해결하기 위해 토큰을 둘로 나눈다.\naccess token은 API 요청 시 인증에 사용된다. 유효 기간을 짧게 설정한다. 탈취되더라도 짧은 시간 내에 만료된다.\nrefresh token은 access token을 재발급받는 데 사용된다. 유효 기간이 상대적으로 길다. 서버 측에서 저장하고 관리할 수 있어 필요 시 무효화가 가능하다.\n갱신 흐름은 다음과 같다.\n클라이언트가 access token으로 API를 요청한다. access token이 만료되면 서버가 401을 반환한다. 클라이언트가 refresh token으로 새 access token을 요청한다. 서버가 refresh token을 검증하고, 새 access token을 발급한다. 저장 전략 세션 ID는 쿠키에 저장하는 것이 표준이다. JWT는 선택지가 여러 개이고, 어디에 저장하는가에 따라 보안 특성이 달라진다.\n메모리 JavaScript 변수에 저장한다. 페이지를 새로고침하면 사라진다. XSS 공격에 노출되지 않지만, 새로고침마다 다시 인증해야 한다.\nlocalStorage 브라우저 저장소에 보관한다. 새로고침해도 유지된다. 그러나 JavaScript로 접근할 수 있으므로, XSS 공격에 취약하다. XSS가 발생하면 토큰이 탈취될 수 있다.\ncookie(HttpOnly) HttpOnly 속성을 설정하면 JavaScript에서 접근할 수 없다. XSS로 토큰을 직접 탈취하는 것을 막을 수 있다. 다만 CSRF 공격에는 별도 대응이 필요하다. SameSite 속성과 CSRF 토큰을 함께 사용하는 것이 일반적이다.\n저장 위치 새로고침 유지 XSS 내성 CSRF 내성 메모리 X 노출 없음 해당 없음 localStorage O 취약 해당 없음 cookie(HttpOnly) O 노출 없음 별도 대응 필요 자주 사용되는 조합은 access token을 메모리에, refresh token을 HttpOnly cookie에 저장하는 방식이다. access token은 짧은 수명으로 노출 위험을 줄이고, refresh token은 HttpOnly로 XSS를 차단한다.\n비교 항목 세션 JWT 상태 저장 서버 클라이언트(토큰) 수평 확장 공유 저장소 필요 자유로움 즉시 무효화 쉬움 어려움(블랙리스트 필요) 네트워크 크기 작음(세션 ID) 큼(토큰 전체) 서버 부하 저장소 조회 서명 검증(CPU) 서비스 구조와 요구사항이 선택을 결정한다. 즉시 무효화가 필수인지, 서버 간 상태 공유 비용을 감당할 수 있는지가 핵심 분기점이다. 단일 서버 환경에서 즉시 제어가 중요하면 세션이 적합하다. 분산 환경에서 서버 간 상태 공유 없이 인증을 처리해야 하면 JWT가 적합하다.\n","permalink":"https://wid-blog.github.io/posts/tech/security/session-and-jwt/","summary":"HTTP는 stateless다. 사용자 인증을 유지하려면 상태를 어딘가에 저장해야 한다. 서버에 저장하는 세션 방식과 토큰에 담아 클라이언트에 위임하는 JWT 방식의 구조, 트레이드오프, 저장 전략을 정리한다.","title":"세션 인증과 JWT"},{"content":"예전부터 가끔 하던 게임이 있었다. League of Legends(LoL). 프로 경기를 보다 보면 하나가 눈에 들어왔다. 팀을 짜고, 밴/픽을 하고, 상대 팀과 스크림을 잡는 과정. 일반 유저에게는 이걸 할 수 있는 플랫폼이 없었다.\n2021년 3월, 동료 한 명과 만들기 시작했다. 5vs5 League의 줄임말, 55L. 나중에 GGScrim이라는 이름이 붙었다.\n서비스 55L은 두 축으로 구성했다.\nggscrim.com은 팀 매칭 플랫폼이다. 팀을 등록하고, 상대 팀을 찾아 스크림을 잡는다. banpick.kr은 가상 밴/픽 시뮬레이터다. 프로 씬과 같은 밴/픽 순서를 시뮬레이션할 수 있다.\n밴/픽 서비스에 10,000명 이상의 유저가 모였다.\n기술 선택 API 서버는 PHP로 만들었다. 이전에 PHP로 서버를 구현해본 경험이 있었고, 빠르게 시작하는 것이 우선이었다. MVC 패턴에 DI, Factory, Singleton을 적용했고, Nginx를 리버스 프록시로 앞에 두었다.\n채팅과 알림은 실시간 양방향 통신이 필요했다. Node.js + Socket.io로 별도 서버를 구성했다 — HTTP 요청/응답만으로는 이 요구사항을 해결할 수 없었다.\n데이터 저장은 MariaDB, 인증 토큰 관리는 Redis를 사용했다.\n클라이언트는 TypeScript + Lit으로 SPA를 만들었다. 당시 Web Components 기반 프레임워크 중에서 Lit이 가장 간결했고, 표준 API 위에서 동작해 장기적인 지속성이 있다고 판단했다. Webpack으로 빌드했고, Firebase를 호스팅에 활용했다.\n데스크톱 앱은 Electron으로 만들었다. LoL 클라이언트와 소켓 통신을 해야 했는데, 웹 브라우저에서는 로컬 프로세스에 직접 접근할 수 없다. Electron이 이 제약을 해결했다. 웹 코드를 재사용하면서 Windows와 Mac을 하나의 코드베이스로 대응할 수 있었다.\n모바일은 PWA로 처리했다. 별도 네이티브 앱 없이 설치형으로 제공할 수 있었다. 개발 비용을 줄이면서 모바일 접근성을 확보하는 방법이라 판단했다.\n아키텍처 전체 시스템은 크게 세 영역으로 나뉜다.\n클라이언트는 데스크톱 앱(Electron), 웹(SPA), 모바일(PWA) 세 가지 형태로 제공했다. 데스크톱 앱만 LoL 클라이언트와 소켓으로 직접 통신하고, 나머지는 브라우저 기반이다. 세 클라이언트 모두 같은 API 서버와 Socket.io 서버에 연결된다.\n서버는 인증 서버와 API 서버를 분리했다. 인증은 JWT 기반으로 설계했다. 인증 로직과 비즈니스 로직을 분리하면 각각 독립적으로 수정하고 배포할 수 있다고 봤다. JWT 인증 구조와 세션 방식의 차이는 별도 글에서 정리했다.\nAPI 서버는 Riot API와 통신해서 챔피언, 소환사 등 LoL 데이터를 조회하고 MariaDB에 저장했다.\n팀 처음에는 둘이었다. 서비스 아키텍처 설계, JWT 인증 서버, 데스크톱 앱은 전부 직접 맡았다. 웹 클라이언트도 대부분 담당했다.\n서비스가 커지면서 인원이 늘었다. 5개월 동안 4명까지 합류했다. 역할이 나뉘기 시작했고, 혼자 모든 기술 결정을 내리던 구조에서 분담하는 구조로 바뀌어갔다.\n돌아보면 모든 기술 결정을 직접 내려야 하는 환경이었다. 선택의 근거를 스스로 만들어야 했고, 트레이드오프를 직접 마주했다.\nLit 선택은 다시 한다면 바꿨을 것이다. Web Components 표준의 지속성을 보고 골랐지만, 생태계 크기와 개발 속도를 생각하면 React가 더 나은 선택이었다고 본다. 당시에는 기술의 방향성을 우선했지만, 창업 초기에는 속도가 더 중요했다.\n가끔 하던 게임에서 시작된 프로젝트였다. 없던 플랫폼을 직접 만들었고, 그 과정에서 생긴 기준점은 이후 실무에서도 쓰고 있다.\n참고 세션 인증과 JWT ","permalink":"https://wid-blog.github.io/posts/career/startup/55l-ggs-startup-retrospective/","summary":"예전부터 가끔 하던 롤에서 시작된 창업. 아키텍처 설계부터 데스크톱 앱까지, 2명이 시작해 10,000 유저의 서비스를 만들어간 5개월의 기록.","title":"55L(GGS) 창업기"},{"content":"코로나로 다들 집에 있던 시기였다. 지인들과 떨어져 있어도 YouTube를 같이 볼 수 있으면 좋겠다는 생각이 들었다. 마침 GDG Korea Android에서 주최하는 Android 11 끝장개발대회를 발견했다. 약 3주간 진행되는 해커톤이었다. 단독으로 참가했다.\n서비스 YouTube Together는 지인들과 비대면으로 YouTube 영상을 동시에 시청할 수 있는 앱이다.\nYouTube 시청 기능은 기존 YouTube 앱처럼 동작했다. YouTube API를 통해 영상을 검색하고 재생했다. 미니 플레이어를 지원해서 영상을 보면서 다른 콘텐츠를 탐색할 수 있게 했다.\n핵심은 동시 시청이었다. 친구를 추가하고 영상을 선택하면 동시에 재생이 시작된다. 재생 위치, 재생 여부가 실시간으로 동기화됐다. 채팅도 있어서 영상을 보면서 대화했다.\n아키텍처 3주 안에 끝내려면 검증된 구조가 필요했다. 앱은 MVVM 패턴으로 설계했다 — Single Activity에 Fragment를 구성하고, Hilt로 의존성을 주입했다. ViewModel과 LiveData로 UI 상태를 관리하고, Repository 패턴으로 로컬(SQLite)과 원격 데이터 소스를 분리했다.\n서버는 두 가지를 구성했다. REST API 처리를 위한 Spring 기반 API 서버와, 실시간 동기화를 위한 Java 소켓 서버다.\nflowchart TD Hilt[\"Hilt\\n(DI)\"] -. 의존성 주입 .-\u003e App subgraph App[\"Application\"] Activity[\"Single Activity\\n+ Fragment\"] --\u003e VM[\"ViewModel\\n+ LiveData\"] VM --\u003e Repo[\"Repository\"] end Repo --\u003e Local[\"Local Model\\n(SQLite)\"] Repo --\u003e Remote[\"Remote Data Model\"] Remote --\u003e Socket[\"소켓 서버\\n(Java)\"] Remote --\u003e API[\"API 서버\\n(Spring)\"] 핵심 구현 YouTube API 연동 YouTube Data API로 영상 검색과 재생을 구현했다. 재생 화면에는 Motion Layout을 활용해 미니 플레이어 전환을 넣었다. 아래로 스와이프하면 미니 플레이어로 축소되고, 다시 탭하면 전체 화면으로 돌아간다.\n실시간 동시 시청 동시 시청의 핵심은 재생 상태 동기화였다. Java로 소켓 서버를 구현하고, 참여한 사용자들의 재생 위치와 재생/일시정지 상태를 실시간으로 동기화했다. 한 사용자가 재생 위치를 변경하면 다른 사용자의 영상도 같은 위치로 이동했다. 채팅은 같은 소켓 연결을 통해 처리했다.\n회고 3주가 짧지 않다고 생각할 수 있지만, 서버와 앱을 혼자 만들기에는 빠듯했다. API 서버 설계, 소켓 서버 구현, Android 앱 개발을 동시에 진행해야 했다. 기능을 줄이고 핵심에 집중하는 판단이 핵심이었다 — 커뮤니티나 추천 같은 부가 기능은 잘라내고, 동시 시청 하나에 집중했다.\n대도서관 상을 받았다. 떨어져 있어도 함께 보겠다는 아이디어에서 시작된 3주였다. 돌이켜 보면 시간의 제약 안에서 범위를 잘라내고 완성까지 가본 경험이 해커톤의 핵심 배움이었다.\n","permalink":"https://wid-blog.github.io/posts/career/hackathon/gdg-korea-android-11-hackathon/","summary":"GDG Korea Android 11 끝장개발대회에 단독으로 참가해 서버와 앱을 3주 만에 완성한 기록.","title":"GDG Korea Android 11 끝장개발대회 — YouTube Together"},{"content":"지하철 역을 오가다 보면 잘 알려지지 않은 공간이 눈에 들어왔다. 갤러리, 휴게 공간, 공연 무대. 매일 지나치면서도 모르는 공간이 많았다. 마침 철도산업정보센터에서 주최한 \u0026ldquo;역 편의정보 공공데이터 활용\u0026rdquo; 대회를 발견했다. 약 3주간 진행되는 해커톤이었다. 팀원 두 명과 함께 팀리드로 참가했다.\n서비스 숨겨진 휴식공간은 지하철 역의 잘 알려지지 않은 휴식 공간을 알려주는 Android 앱이다.\n네 가지 기능을 담았다. 역별 숨은 휴식 공간 정보(위치·사진·평점), 역 안에서 열리는 공연 일정, 같은 역 이용자들의 오픈채팅방, 그리고 화장실·수유실·문화시설 같은 일반 편의시설이다.\n마지막 항목은 공공데이터에 이미 들어 있던 자료를 한 화면으로 정리했고, 앞 세 항목은 자체 콘텐츠로 만들었다. 숨은 휴식 공간은 사용자가 평점을 남길 수 있어 추천 흐름이 자연스럽게 만들어졌다.\n아키텍처 3주 안에 끝내려면 검증된 스택으로 가야 했다. Android 앱 + 단일 API 서버 + RDB 의 단순 구조로 설계했다.\nflowchart TD App[\"Android App\\n(Java)\"] --\u003e API[\"API Server\\n(PHP + Apache)\"] API --\u003e DB[(MariaDB)] API -. FCM .-\u003e App App --\u003e Maps[\"Google Maps API\"] App --\u003e PublicData[\"철도산업정보센터\\n공공데이터\"] 서버는 PHP 와 Apache 로 구성했다. 이전에 다뤄본 스택이라 짧은 기간에 안정적으로 운영할 수 있는 점이 컸다. MVC 와 Singleton 으로 코드 구조를 정리하고, MariaDB 에 역과 휴식 공간 데이터를 적재했다.\n앱은 Java 로 개발했다. Google Maps API 로 역 위치를 표시하고, Glide 로 이미지를 캐싱했다. 작은 인터랙션은 Lottie 애니메이션으로 처리했다. 신규 휴식 공간이나 공연 알림은 FCM 으로 전달했다.\n서버와 안드로이드 앱 양쪽을 맡았다. 팀원 두 명과 역할을 나눠 데이터 적재와 클라이언트 화면을 병행했다.\n핵심 구현 공공데이터 활용 대회의 핵심은 공공데이터를 어떻게 가공해 사용자에게 의미 있게 보여주는가였다. 철도산업정보센터에서 제공하는 역 편의정보 데이터를 받아 가공한 뒤 자체 DB 에 적재했다. 원본 데이터 구조가 화면에 그대로 매핑되지 않아 중간 변환 단계를 두었고, 공연이나 채팅 같은 자체 콘텐츠는 별도 테이블로 두었다.\n라이브러리 선택 3주의 짧은 일정에는 라이브러리 선택이 곧 일정 관리였다. Google Maps 로 위치 UI 를 통째로 가져왔고, Glide 로 이미지 처리 코드를 줄였다. 알림은 FCM 으로 서버에서 직접 발송할 수 있게 했다. 손으로 만들었을 때 빠지기 쉬운 자리를 라이브러리로 덮어 두니 핵심 기능에 시간을 더 쓸 수 있었다.\n회고 입선했다. 짧은 기간 안에 공공데이터를 가공하고 자체 콘텐츠를 더한 점이 통한 결과라고 봤다.\n팀리드로서 일정과 범위를 정하는 일이 가장 큰 부담이었다. 기능을 더 넣고 싶은 욕심은 컸지만, 3주 안에 시연 가능한 수준까지 가려면 어느 시점부터는 멈춰야 했다. 핵심 네 기능을 정한 뒤 그 너머로 늘리지 않은 결정이 마감에 닿는 데 가장 크게 작용했다고 본다.\n공공데이터를 직접 다뤄본 첫 경험이기도 했다. 외부 데이터를 그대로 노출하지 않고 우리 도메인에 맞게 재해석하는 과정이 의외로 많은 시간을 잡아먹었다. 다음에 비슷한 결의 데이터를 만난다면, 변환 레이어부터 잡는 순서로 가보려 한다.\n","permalink":"https://wid-blog.github.io/posts/career/hackathon/kric-station-public-data-hackathon/","summary":"철도산업정보센터 주최 공공데이터 활용 대회. 지하철 역의 숨은 휴식 공간을 알려주는 Android 앱을 3주간 3명이 만든 기록.","title":"역 편의정보 공공데이터 활용 대회 — 숨겨진 휴식공간"},{"content":"백엔드 엔지니어. 기술과 일하며 느낀 것들을 글로 씁니다.\n","permalink":"https://wid-blog.github.io/about/","summary":"\u003cp\u003e백엔드 엔지니어. 기술과 일하며 느낀 것들을 글로 씁니다.\u003c/p\u003e","title":"소개"}]