Call Girls In Connaught Place (Delhi ncr) 8447389422 Direct h,*nline,Payment...For Genuine call-girl , WhatsApp’s & Call C Affordable price , Real High Profiles Guarantee , % Customers Satisfaction Top Grade Service Cooperative All round Service. ( ***H ON DROP IN HOTEL ROOM ) BJ (Blowjob Without a Condom)? Completion (Oral to completion Covered (Covered blowjobvv Without a Condom) DSL (Dick Sucking Lips)? DT (Dining at the Toes English Spanking) Doggie (Sex style from no behind)?? OutCall- All Over Delhi Noida Gurgaon */ FOR APPOINTMENT Call/Whatsop / 8447389422
예금보험공사 RAG 챗봇 프로젝트 1. 프로젝트 한눈에 보기 프로젝트명 4MATION — 예금보험공사 데이터 관리체계 고도화 및 생성형 AI 서비스 구축 (PoC) 한 줄 소개 두 사이트에 흩어진 예보 안내 데이터를 통합하고, 출처를 붙여 답하는 자연어 챗봇 기간 2026.07.10 ~ 2026.09.02 (8주) 팀 4명 · LikeLion AI/NLP 5기 × 클라비 기업연계 기술 Python, FAISS, BM25, 하이브리드 검색, HyperCLOVA X, FastAPI Repository likelion-4MATION/4MATION 2. 왜 시작했는가 예보에는 이미 챗봇이 있습니다. '예솜24'라는 키워드 매칭 기반 카드형 챗봇입니다. 제시된 카드를 눌러 내려가면 답에 닿는데, 정의된 동작과 키워드를 벗어난 질의에는 대응하지 못합니다. 사용자가 자기 질문이 어느 카드 아래 있는지를 이미 알아야 한다는 게 문제입니다. "은행이 망했는데 제 돈 어떻게 되나요"는 예금자보호제도일 수도, 예금보험금 안내일 수도 있습니다. 예보 내부에서는 다른 업무지만 묻는 사람에게는 하나의 질문입니다. 데이터도 두 사이트에 흩어져 있습니다. www.kdic.or.kr 과 fins.kdic.or.kr . 같은 주제가 안내 페이지형과 FAQ 게시판형으로 따로 존재하고, 어느 쪽이 최신인지 사이트만 봐서는 알기 어렵습니다. 제공 데이터셋도 없었습니다. 로우데이터가 웹사이트 자체였고, 크롤링부터가 과제였습니다. 그리고 이 도메인은 돈과 법입니다. 과제 문서에 이렇게 적혀 있었습니다. 예금보험 도메인은 금액·수수료·기간 등 조건부 정보가 많아, 근거 없는 단정 답변이 민원 리스크로 직결 예금자보호한도는 2025년 9월에 5천만원에서 1억원으로 올랐습니다. 그런데 사이트 일부 페이지에 아직 5천만원이 남아 있었습니다. 그래서 목표를 "그럴듯한 답"이 아니라 "근거 있는 답" 으로 잡았습니다. 3. 무엇을 해결하려는가 사용자는 예보 사이트에 들어왔지만 어느 메뉴로 가야 할지 모르는 민원인입니다. 대상은 6개 업무입니다. 예금자보호제도, 예금보험금 안내, 고객 미수령금 신청, 착오송금 반환지원, 채무조정 안내, 은닉재산 신고. 착수 전 사이트 분석에서 필수 33페이지, 분석 필요 16페이지가 후보로 잡혔습니다. 6개는 서로 독립적이지만 문장이 닮았습니다. "신청 기한", "구비 서류", "수수료"가 업무마다 다 있습니다. "신청 기한 알려줘"를 구분 없이 검색하면 착오송금의 기한과 채무조정의 기한이 같이 나옵니다. 과제가 요구한 산출물은 다섯 가지였습니다. 6개 업무 검색 범위 정의서 및 수집 코퍼스 (계층 메타데이터 포함) 6개 업무 질의에 답하는 RAG 챗봇 (출처 표시·민원 처리 안내 포함) 관리자 트리거 기반 수집·전처리·적재 파이프라인 RAG 파라미터(Top-K, Chunk Size 등)를 테스트하는 관리자 UI 평가 테스트셋 및 성능 평가 리포트 다루지 않기로 한 범위는 팀이 정했습니다. 개인별 사안 판정("제 계좌 지금 어떻게 됐나요")은 문서로 답할 수 없어 상담 연결로 넘깁니다. 개인회생·파산면책은 법원, 신용회복은 신용회복위원회 소관이라 담당 기관을 알려주는 쪽을 택했습니다. 문제 정의 자기 상황이 어느 업무인지 모르는 민원인이 카드 앞에서 막히는 문제를, 출처를 명시한 자연어 답변과 소관 판정으로 해결한다. 4. 어떤 원칙으로 만들 것인가 과제의 답변 원칙 세 가지가 그대로 설계 기준이 됐습니다. 출처 표시 — 모든 답변에 근거 페이지 URL을 함께 제공 민원 처리 안내 — 절차 안내 → 서류 양식 다운로드 → 신청 페이지 연결 환각 방지 — 근거가 없으면 지어내지 않고, 모른다는 사실과 문의 경로를 안내 만들면서 시간이 가장 많이 든 쪽은 답을 잘하는 기능이 아니라 틀린 답을 막는 기능이었습니다. 카드형 챗봇을 걷어내면 잃기 쉬운 게 정확히 이 부분입니다. 카드는 없는 답을 지어내지는 않으니까요. 5. 시스템 전체 그림 과제는 프로젝트 3개가 누적되는 구조였습니다. 실전1이 만든 데이터를 실전2가 검색·답변에 쓰고, 종합 프로젝트가 그 둘을 제어합니다. 1 데이터 파이프라인 (실전1) 웹사이트 → 본문 추출 → 메타데이터 태깅·청킹 → 색인 ↓ 2 RAG 챗봇 (실전2) 질문 → 업무 분류 → 검색 → LLM 답변(출처 표시) → 검증 ↓ 3 관리자 콘솔 (종합) ├ 데이터 CRUD · 재수집 트리거 → 1로 └ RAG 파라미터 조정·테스트 → 2로 층을 나눈 덕에 챗봇이 틀렸을 때 어디가 틀렸는지 따로 잴 수 있었습니다. 원문을 잘못 잘랐는지, 엉뚱한 조각을 찾았는지, LLM이 근거를 벗어났는지가 구분됩니다. 6. 어떻게 개발할 것인가 Milestone 1 — 데이터 파이프라인 목표 두 사이트를 결정론적으로 텍스트화하고, 계층 메타데이터를 붙여 색인한다 완료되면 크롤링→파싱→청킹→색인이 한 명령으로 돌고, 바뀐 페이지만 다시 받는다 Milestone 2 — RAG 챗봇 목표 6개 업무 자연어 질의에 출처를 붙여 답한다 완료되면 민원 처리 질문에 절차·서류·신청 페이지까지 이어지는 답변이 나간다 Milestone 3 — 관리자 콘솔 목표 비전공자 관리자가 코드 없이 데이터와 파라미터를 다룬다 완료되면 파라미터 변경 전후를 화면에서 비교하고 재수집을 직접 돌린다 과제 문서에는 계층 필터링, Parent-Child Retrieval, HyDE 같은 설계 예시가 함께 들어 있었습니다. 다만 "확정 설계가 아니며 실제 설계는 팀이 정의한다"는 단서가 붙어 있었고, 진행하면서 이 중 몇 가지는 다시 판단하게 됐습니다. 그 이야기가 이 시리즈의 대부분입니다. 7. 내 역할과 기대 확인하고 싶은 건 "RAG의 정확도 문제가 검색기가 아니라 측정에 있는가" 입니다. 검색 성능을 올리는 작업과 성능을 제대로 재는 작업 중 어느 쪽이 실제 병목인지를 보고 싶었습니다. 가장 어려울 거라 예상한 부분은 한국어 검색 성능입니다. 6개 업무의 문장이 서로 닮아 있어서, 의미 검색이 업무 경계를 넘어버리는 상황이 자주 나올 것 같았습니다. 마무리 PoC의 목적은 사업화 전에 기술적 타당성을 확인하는 것입니다. 그래서 이 시리즈도 "됐다"보다 "되게 만들려면 무엇이 먼저인가"를 기록하는 쪽으로 쓰려고 합니다.
Buy Verified LinkedIn Accounts ✨📲⚡ ║ Telegram ║ @Getsmmzoneofficial ║ 💠🦋⚡ ⚡💬⚡ ║ WhatsApp ║ +1 (352) 828-5265 ║ 🟢🔥⚡ 💫📧⚡ ║ Email ║ getsmmzone@gmail.com ║ 💜🔮⚡ 🚀🌐⚡ ║ Website ║ https://getsmmzone.com/product/buy-verified-linkedin-accounts/ Buy Verified LinkedIn Accounts Buy verified LinkedIn accounts from GetSmmZone.com and get instant access to professional networking. Our accounts are safe, fully verified and ready to use. Trusted by businesses and marketers worldwide, we offer prompt delivery, excellent customer service, and quality assurance. Take your LinkedIn marketing and outreach to the next level today with a trusted solution from GetSmmZone. Details & Features Verified LinkedIn Accounts: ✮ 100% Verified LinkedIn Account. ✮ 100% KYC Verification Complete. ✮ Email and Number Verified. ✮ ID/Passport/Driving License Verified. ✮ Bank Statement/Utility Bill Verified. ✮ Instant Access. ✮ Instant Delivery. ✮ 24/7 Customer Support. If you need any service, contact us or order your valuable service. You will definitely get satisfaction from our service, which you will not get anywhere else. Our goal is your satisfaction and good quality service. ✨📲⚡ ║ Telegram ║ @Getsmmzoneofficial ║ 💠🦋⚡ ⚡💬⚡ ║ WhatsApp ║ +1 (352) 828-5265 ║ 🟢🔥⚡ 💫📧⚡ ║ Email ║ getsmmzone@gmail.com ║ 💜🔮⚡ 🚀🌐⚡ ║ Website ║ https://getsmmzone.com/product/buy-verified-linkedin-accounts/
GSLB, L4, L7의 차이와 실무 로드밸런싱 아키텍처 서비스를 여러 서버에 배포하면 어느 서버로 요청을 보낼지, 서버 한 대가 장애를 일으켰을 때 어떻게 정상 서버로 전환할지 고민하게 된다. 이 과정에서 GSLB, L4, L7, Nginx 같은 용어를 접한다. 이들을 이해할 때 가장 중요한 기준은 대상을 선택하는 시점과 실제 트래픽이 통과하는 경로 다. 이 글에서는 각 개념을 정리하고, 대표적인 구성에서 장애가 발생했을 때 어떻게 동작하는지 살펴본다. 아래 구성은 이해를 위한 예시다. 특정 기업의 실제 운영 구성을 나타내지는 않는다. GSLB는 구현 방식이 다양하므로, 여기서는 DNS 기반 GSLB를 중심으로 설명한다. 1. GSLB와 L4·L7은 분류 기준부터 다르다 L4와 L7의 L은 Layer를 뜻한다. OSI 7계층 중 어떤 계층의 정보를 이용해 트래픽을 처리하는지를 나타낸다. 반면 GSLB는 Global Server Load Balancing의 약자다. 여러 거점이나 서버 그룹에 걸쳐 서비스 접속 대상을 선택하는 기능을 의미한다. GSLB를 L4나 L7의 다음 단계로 이해하면 혼동하기 쉽다. 구분 GSLB L4 로드밸런싱 L7 로드밸런싱 핵심 역할 접속할 주소·거점 선택 전송 계층 정보로 트래픽 분산 응용 계층 정보로 요청 분산 대표 방식 DNS 응답으로 대상 선택 TCP·UDP 처리 HTTP·HTTPS 처리 판단 정보 정상 여부, 위치, 가중치, 우선순위 IP, 포트, 프로토콜, 연결 수 도메인, URL, 헤더, 쿠키 선택 시점 DNS 조회 시 TCP에서는 주로 새 연결 생성 시 HTTP 요청 처리 시 실제 서비스 트래픽 DNS 기반 방식에서는 통과하지 않음 로드밸런서가 전달에 관여 프록시가 요청·응답 처리에 관여 세 기능은 함께 사용할 수 있다. GSLB가 어느 데이터센터로 접속할지 선택하고, 해당 센터의 L4·L7이 실제 서버로 트래픽을 분산하는 구성이 그 예다.[1][2][3] 2. GSLB: 어떤 IP를 알려줄 것인가 DNS 기반 GSLB는 도메인 조회에 응답할 IP를 정책에 따라 선택한다. 도메인 대상 그룹 api-a.example.com 서버 1, 서버 2 api-b.example.com 서버 3, 서버 4 A 도메인을 조회해 서버 1의 IP를 받았다면, 호출 시스템은 서버 1로 직접 접속한다. GSLB가 API 요청을 받아 전달하는 과정은 없다. 제품과 정책에 따라 주소 하나 또는 여러 주소를 응답할 수 있다.[1] sequenceDiagram participant C as 호출 시스템 participant R as DNS 리졸버 participant G as GSLB participant S as 선택된 서버 C->>R: 도메인 조회 R->>G: 캐시가 없거나 만료되면 질의 G-->>R: 대상 IP 응답 R-->>C: IP 전달 C->>S: API 요청 S-->>C: API 응답 그림에서는 DNS 위임 과정과 다른 캐시 계층을 생략했다. 핵심은 DNS 조회와 API 통신의 경로가 분리된다는 점이다. GSLB의 대상 IP는 실제 WAS 주소일 수도 있고, Nginx 서버 주소나 L4의 VIP일 수도 있다. 내부 DNS와 사설 주소를 대상으로 구축하는 방식도 있으므로, Global이라는 이름이 반드시 해외나 인터넷 서비스만을 뜻하지는 않는다. 사설망 지원 방식은 사용하는 제품과 구성에 따라 확인해야 한다. GSLB로 정확히 반씩 분산할 수 있을까 DNS 응답을 번갈아 제공하더라도 API 요청 100건이 서버마다 50건씩 전달되는 것은 아니다. 호출 시스템이나 DNS 리졸버가 주소를 캐시하면 같은 주소를 계속 사용할 수 있다. HTTP 클라이언트가 기존 TCP 연결을 재사용하는 경우도 마찬가지다. 특히 호출 시스템이 몇 대 없는 내부 API 환경에서는 특정 서버로 요청이 몰릴 수 있다. GSLB를 설계할 때는 응답 정책뿐 아니라 호출 측의 DNS 캐시와 연결 재사용도 확인해야 한다.[4] 3. L4: 들어온 연결을 어느 서버로 보낼 것인가 L4는 OSI 4계층인 전송 계층을 뜻한다. L4 로드밸런서는 IP와 TCP·UDP 포트 등의 정보를 이용해 트래픽을 분산한다. IP 자체는 3계층 정보이므로 실제로는 3·4계층 정보를 함께 사용한다.[2] 예를 들어 서비스 대표 주소로 들어온 새 TCP 연결을 서버 1 또는 서버 2에 배정한다. TCP 연결 하나의 패킷을 두 서버에 번갈아 보내는 방식은 아니다. 따라서 연결 수가 균등하더라도 요청 수나 서버 부하까지 균등하다고 볼 수는 없다. 연결 하나가 많은 요청을 처리하거나 오래 유지될 수 있기 때문이다. UDP는 연결을 맺지 않으므로 TCP의 연결 설명을 그대로 적용하지 않고, 제품의 흐름 관리와 분산 동작을 확인해야 한다. VIP와 L4는 어떤 관계인가 VIP는 Virtual IP, 즉 가상 IP다. 로드밸런싱 구성에서는 여러 서버 앞에서 서비스를 대표하는 접속 주소로 사용된다. 호출 시스템은 개별 서버 IP 대신 VIP에 접속한다. 로드밸런서는 등록된 서버 중 대상을 선택한다. 뒷단 서버가 추가되거나 제외되어도 VIP가 유지되면 호출 측의 접속 주소를 바꿀 필요가 없다. VIP는 주소이고 L4는 처리 방식이다. VIP가 있다는 사실만으로 전용 L4 장비가 있다고 판단할 수는 없다. 소프트웨어 이중화에서도 VIP를 사용할 수 있다.[9] L4의 구현에도 여러 방식이 있다. NAT, 프록시, DSR 등 구성에 따라 주소 변환과 응답 경로가 달라지므로, 모든 환경에서 요청과 응답이 반드시 같은 장비를 통과한다고 가정해서는 안 된다. 4. L7: HTTP 요청을 보고 어디로 보낼 것인가 L7은 OSI 7계층인 응용 계층을 뜻한다. 웹·API 환경에서는 HTTP 도메인, 경로, 헤더, 쿠키 등을 이용해 전달할 대상 그룹을 선택할 수 있다.[3] 요청 조건 전달 대상 예시 api.example.com API Gateway 그룹 portal.example.com 관리 포털 그룹 /orders/ 경로 주문 서비스 그룹 /images/ 경로 이미지 서비스 그룹 선택한 그룹 안에서 서버 1·2로 요청을 분산하는 것도 가능하다. 경로별로 서비스를 나누지 않더라도 같은 서비스를 제공하는 여러 서버에 HTTP 요청을 분산하는 데 사용할 수 있다.[5] HTTPS의 HTTP 경로나 헤더를 확인하려면 해당 지점에서 TLS를 종료해 요청을 읽을 수 있어야 한다. 뒷단 구간도 암호화해야 한다면 서버와 다시 TLS 연결을 맺는다. TLS 종료 위치는 인증서 관리 위치와 구간별 암호화 정책을 결정한다.[6] L4·L7이라는 이름만으로 실제 성능을 단정할 수는 없다. L7은 HTTP와 TLS 처리가 추가될 수 있지만, 실제 처리량과 지연은 제품 구현, 서버 자원, 연결 재사용, 요청 크기와 정책에 따라 달라진다. 5. Nginx가 있으면 전용 L4 장비가 없어도 될까 필요한 기능과 가용성 요구를 충족한다면 가능하다. Nginx는 웹 서버뿐 아니라 리버스 프록시와 로드밸런서로 사용할 수 있다. Nginx 구성 트래픽 역할 http 의 upstream · proxy_pass HTTP·HTTPS L7 프록시와 로드밸런싱 stream 의 upstream · proxy_pass TCP·UDP L4 프록시와 로드밸런싱 stream 을 사용하려면 설치된 Nginx에 해당 모듈이 포함되거나 로드되어 있어야 한다.[5][7] 즉, Nginx는 제품이고 L4·L7은 처리 방식이다. 현장에서 “L4를 쓴다”는 말은 보통 전용 로드밸런서 장비를 뜻하지만, L4 기능은 소프트웨어로도 구현할 수 있다. 전용 장비를 선택하는 이유에는 요구 처리량, 동시 연결 수, 검증된 이중화 기능, 기존 네트워크와의 연동, 기술지원과 운영 책임 등이 있다. 일부 장비는 특정 처리를 하드웨어로 가속한다. 다만 이를 “모든 전용 장비가 모든 Nginx 구성보다 압도적으로 빠르다”로 일반화하기보다, 필요한 처리량과 제품별 사양·측정 결과를 비교해야 한다. 소프트웨어를 선택하면 장비 구매 비용을 줄일 수 있지만 서버, OS, 패치, 모니터링과 이중화 운영은 필요하다. 6. 실무에서 고려하는 대표 아키텍처 다음은 요구사항에 따라 선택하거나 조합할 수 있는 대표 패턴이다. 보안 장비와 방화벽은 로드밸런싱의 역할에 집중하기 위해 생략했다. 6-1. GSLB → 실제 서버 직접 접속 GSLB에 실제 서버 IP를 등록하고 호출 시스템이 선택된 서버로 직접 접속한다. 장점 은 별도의 트래픽 중계 계층을 줄일 수 있다는 것이다. 여러 거점의 주소를 하나의 도메인으로 제공하는 데도 활용할 수 있다. 한계 는 장애 전환이 DNS 캐시와 호출 프로그램의 동작에 영향을 받는다는 것이다. 호출마다 세밀하게 분산하기 어렵고, URL 경로에 따른 라우팅도 DNS 계층에서 처리하지 않는다. 이 구성에서는 “서버 두 대를 등록했는가”와 함께 “호출 측이 주소를 얼마나 오래 사용하고, 실패하면 어떻게 재연결하는가”를 봐야 한다.[4] 6-2. L4 → Nginx WEB → WAS L4가 WEB 서버로 연결을 분산하고, 각 WEB의 Nginx가 WAS로 요청을 분산한다. flowchart TD C[호출 시스템] --> V["L4 서비스 VIP"] V --> W1["WEB 1 · Nginx"] V --> W2["WEB 2 · Nginx"] W1 --> A1[WAS 1] W1 --> A2[WAS 2] W2 --> A1 W2 --> A2 그림의 L4는 논리적인 서비스 진입점이다. 실제 운영에서는 로드밸런서 계층 자체의 이중화도 필요하다. WEB 1이 장애라면 L4가 정상 WEB으로 새 연결을 보내고, WAS 1이 장애라면 Nginx가 정상 WAS를 선택하도록 구성할 수 있다. VIP가 유지되는 한 해당 뒷단 서버 장애 때문에 호출 시스템의 DNS 주소를 바꿀 필요는 없다. 장점 은 연결 분산과 HTTP 라우팅의 역할을 분리할 수 있다는 것이다. 단점 은 계층과 운영 지점이 늘어난다는 것이다. 타임아웃, 원본 클라이언트 주소 전달 방식과 로그 추적을 맞추어야 한다. 또한 각 WEB이 두 WAS 모두에 접근한다는 전제가 중요하다. WEB 1은 WAS 1만, WEB 2는 WAS 2만 바라보도록 묶으면 장애 대응 범위가 달라진다. 6-3. GSLB → Nginx WEB → Gateway 전용 L4 장비 없이 GSLB가 WEB 주소를 선택하고, 각 WEB의 Nginx가 Gateway 두 대에 요청을 분산하도록 구성할 수 있다. flowchart TD G[GSLB] -. DNS 응답 .-> C[호출 시스템] C --> W1["WEB 1 · Nginx"] C --> W2["WEB 2 · Nginx"] W1 --> A1[Gateway 1] W1 --> A2[Gateway 2] W2 --> A1 W2 --> A2 점선은 주소 선택, 실선은 가능한 API 통신 경로다. 호출 시스템이 매 요청마다 두 WEB을 번갈아 사용한다는 뜻은 아니다. 이 구성에서는 Gateway 장애와 WEB 장애를 구분해야 한다. 장애 위치 대응 방식 호출 측 DNS 캐시의 영향 Gateway 1 장애 접속 중인 Nginx가 정상 Gateway 선택 WEB 주소를 바꿀 필요가 없음 WEB 1 장애 GSLB와 호출 시스템이 WEB 2로 전환 캐시된 WEB 1 주소가 남아 있으면 영향 발생 장점 은 Nginx의 HTTP 기능을 활용하면서 별도 L4 계층을 줄일 수 있다는 것이다. 한계 는 WEB 장애의 전환이 여전히 DNS 기반이라는 것이다. Gateway 장애 대응이 잘 동작하더라도 WEB 장애에 같은 복구 시간을 기대할 수는 없다. GSLB의 점검도 WEB 포트가 열렸는지만 볼지, 진입점이 실제 서비스를 제공할 수 있는지까지 볼지 결정해야 한다. 6-4. Nginx 두 대 + Keepalived VIP 호출 시스템은 고정된 VIP에 접속한다. 활성 Nginx 노드에 장애가 발생하면 대기 노드가 VIP를 인계받는다. Keepalived의 VRRP를 활용한 Active–Standby 구성이 대표적인 예다.[9][10] 장점 은 활성 노드가 바뀌어도 접속 IP를 유지할 수 있다는 것이다. 운영 부담 은 주소 인계가 가능한 네트워크와 상태 점검을 직접 갖추어야 한다는 것이다. Nginx 프로세스 장애도 확인하도록 구성하고 설정 파일과 인증서를 일관되게 배포해야 한다. VIP 인계만으로 기존 TCP 연결이나 애플리케이션 세션까지 자동 복원되지는 않는다. 모든 망에서 VIP 이동이 가능한 것은 아니다. VRRP 통신, 네트워크 정책과 주소 소유권 변경 지원을 확인해야 한다. 단일 VIP의 기본 Active–Standby 구성에서는 평상시 두 노드가 동시에 부하를 나누어 처리하지 않는다. 6-5. 여러 센터에 GSLB + 로컬 로드밸런서 GSLB가 접속할 센터를 선택하고, 각 센터의 L4·L7이 내부 서버로 트래픽을 분산한다.[1] 계층 책임 예시 GSLB 센터 A 또는 B의 서비스 진입점 선택 센터 내부 L4·L7 정상 서버로 연결·요청 분산 애플리케이션·DB 전환 후 필요한 상태와 데이터 제공 장점 은 서버 장애와 센터 전체 장애를 서로 다른 범위에서 대응할 수 있다는 것이다. 어려운 점 은 트래픽 전환과 데이터 복구를 함께 설계해야 한다는 것이다. 센터 B로 정상 접속되더라도 주문 데이터가 없거나 세션을 복원할 수 없으면 사용자는 서비스를 정상적으로 이용하지 못한다. DNS 전환과 함께 데이터 복제 지연, 손실 허용 범위와 복구 목표를 검토해야 한다. 7. 헬스체크가 있어도 장애가 보이는 이유 장애 감지, 대상 제외, 사용자 요청 복구는 서로 다른 단계다. GSLB가 서버 1을 장애로 판정해 이후 응답에서 제외해도 호출 시스템이 이미 받은 IP는 캐시에 남아 있을 수 있다. 단계 발생할 수 있는 일 장애 감지 전 GSLB가 장애 서버 IP를 정상 대상으로 응답 감지 후, 캐시 만료 전 GSLB는 제외했지만 호출 측은 이전 IP 사용 주소 갱신 후 정상 서버로 새로 접속하면 호출 복구 TTL은 DNS 응답을 캐시에 보관할 수 있는 시간을 뜻한다. TTL 30초가 장애 발생 후 30초 이내 복구를 보장하지는 않는다. 장애 감지 시점, 캐시 저장 시점, 재조회와 다음 요청 시점이 다르기 때문이다.[4] Java처럼 런타임 자체에 주소 캐시 정책이 있는 경우도 있다. 따라서 DNS 레코드와 함께 실제 호출 애플리케이션의 캐시 설정을 확인해야 한다.[11] L4·L7은 뒷단 서버 장애에 대해 DNS 캐시 만료를 기다리지 않고 다른 서버를 선택할 수 있다. 하지만 감지 전에 들어온 연결이나 처리 중인 요청까지 항상 복구해 주는 것은 아니다. 기존 TCP 연결을 다른 서버에서 그대로 이어받는 것도 별개의 문제다.[12] 8. 헬스체크와 재시도도 함께 설계해야 한다 능동 점검과 수동 점검 능동 헬스체크는 사용자 요청과 별도로 점검 요청을 보내 상태를 확인한다. 수동 헬스체크는 실제 요청에서 발생한 실패를 보고 대상을 제외한다. 오픈소스 Nginx의 기본 upstream 장애 판정은 수동 방식이다. max_fails 와 fail_timeout 등의 설정이 관련된다. 주기적인 HTTP 능동 헬스체크는 공식 Nginx Plus 기능으로 제공된다. 별도 모듈이나 외부 점검 체계를 쓰는 경우는 구분해야 한다.[8] 트래픽 분산 계층과 헬스체크 방식도 별개다. L4로 트래픽을 분산하면서 서버 상태는 HTTP 점검 URL로 확인하도록 구성하는 제품도 있다. 점검 범위는 실제 트래픽을 받을 준비가 되었는지 판단할 수 있어야 한다. 너무 얕은 점검은 서비스 장애를 놓치고, 모든 공통 의존성까지 검사하면 전체 서버를 동시에 제외할 수도 있다. 다른 서버로 재시도하면 모든 오류를 숨길 수 있을까 Nginx의 proxy_next_upstream 처럼 실패한 요청을 다른 서버로 전달하는 기능은 오류를 줄이는 데 도움이 된다. 하지만 조건, 횟수와 허용 시간을 제한해야 한다. 이미 클라이언트로 응답 일부를 보냈다면 다른 서버의 응답으로 처음부터 바꾸어 줄 수도 없다.[13] 주문 서버가 DB 저장을 끝낸 뒤 응답만 유실된 상황을 생각해 보자. 호출 측에는 실패처럼 보이지만 주문은 이미 생성되었다. 같은 요청을 다시 보내면 중복 주문이 생길 수 있다. 따라서 상태를 변경하는 API에는 멱등성 키나 중복 방지 설계가 필요하다. 호출 프로그램과 여러 프록시가 각각 재시도한다면 전체 시도 횟수와 장애 시 부하도 확인해야 한다.[14] 9. 어떤 구성이 적합한지 판단하는 기준 요구사항 검토할 방향 여러 센터·리전의 접속 대상을 선택 GSLB와 각 거점의 가용성 구성 TCP·UDP 트래픽 분산 전용·관리형·소프트웨어 L4 도메인·URL에 따른 API 분산 Nginx 등 L7 프록시·로드밸런서 전용 장비 없이 HTTP 분산 구현 Nginx와 Nginx 계층 자체의 이중화 뒷단 서버 장애 시 DNS 캐시 대기 회피 고정 진입점 뒤에서 정상 서버를 선택하는 구성 주문·결제의 중복 처리 방지 애플리케이션 멱등성과 재시도 정책 L7이 필요하다고 항상 L4를 앞에 추가해야 하는 것은 아니다. L7 로드밸런서가 진입점과 분산을 직접 담당할 수 있다. 기존 인프라의 L4가 WEB 이중화를 담당한다면 Nginx와 함께 사용하는 구성이 자연스러울 수도 있다. 구성을 결정한 뒤에는 실제 호출 경로로 검증해야 한다. 시험 상황 확인할 내용 Gateway 프로세스 종료 정상 Gateway 전환 시간과 실패 요청 수 WEB 서버 중단 진입점 전환 시간과 DNS 캐시 영향 포트는 열렸지만 응답이 멈춘 상태 점검·호출 타임아웃의 장애 감지 기존 연결 유지 중 서버 장애 실제 커넥션풀의 재연결 동작 서버 복구 재편입 시점과 복구 직후 부하 등록 요청의 응답 유실 재시도 시 중복 데이터 발생 여부 결과는 장애 감지 시간, 정상 호출 재개 시간, 실패율과 지연 분포로 남긴다. 단순한 curl 반복 호출과 운영 프로그램은 캐시와 연결 재사용 방식이 다를 수 있다. 최종 판단에는 실제 호출 방식이 반영되어야 한다. 참고 자료 개념과 제품 기능은 아래 공식 문서를 참고했다. 구성별 장단점과 장애 시나리오는 이를 적용해 설명한 예시다. 세부 기능과 기본값은 사용하는 버전·에디션의 문서를 확인한다. Cloudflare — Load Balancing Reference Architecture F5 — Layer 4 Load Balancing F5 — Layer 7 Load Balancing Cloudflare — DNS-only Load Balancing NGINX — HTTP Load Balancing NGINX — Securing HTTP Traffic to Upstream Servers NGINX — TCP and UDP Load Balancing NGINX — HTTP Health Checks Keepalived — Quick Start NGINX — High Availability with Keepalived Oracle — Java Networking Properties F5 — Pools and Action on Service Down NGINX — proxy_next_upstream AWS — Timeouts, Retries, and Backoff with Jitter
모든 커밋과 브랜치와 머지의 앞에서 Git을 사용하는 모든 개발자는 기억할지어다. 네가 남긴 모든 변경은 기록될 것이며, 네가 확인하지 않은 모든 변경은 언젠가 너를 찾아올 것이다. Git은 단순히 코드를 올리는 도구가 아니다. 개발자가 지금까지 무엇을 했고, 무엇을 바꿨으며, 어디서부터 문제가 시작되었는지를 기록하는 개발의 역사 그 자체다. 그렇기에 우리는 Git을 존중해야 한다. 항상 커밋하기 전에는 반드시 변경 사항을 확인해야 하며 git status 와 git diff 를 생활화해야 한다. 내가 뭘 바꿨는지도 모르면서 git add . 부터 박아버리는 것은 Git을 사용하는 것이 아니라 Git에게 코드를 바치는 행위다. 커밋 메시지 역시 아무 생각 없이 update , fix , asdf , 진짜마지막 , final , final2 , final_final 따위로 작성해서는 안 된다. 커밋 하나하나에는 내가 무엇을 했는지 미래의 내가 알아볼 수 있는 의미가 있어야 한다. 작업을 시작하기 전에는 원격 저장소의 상태부터 확인해야 한다. 다른 사람이 이미 코드를 올렸을 수도 있는데 아무 생각 없이 작업부터 시작하고 나중에 push 를 박다가 rejected를 맞고서야 pull 을 하는 것은 Git을 존중하지 않는 자의 전형적인 행동이다. pull 을 했는데 충돌이 발생했다고 당황해서 아무 파일이나 지워버리는 것도 안 된다. Conflict가 발생했다면 먼저 양쪽 변경 내용을 읽고 무엇을 남기고 무엇을 버릴지 판단해야 한다. <<<<<<< HEAD 가 보인다고 무작정 위아래를 지우는 순간, 수 시간 동안 작성한 코드가 조용히 사라질 수 있다. 특히 git pull 은 마법의 명령어가 아니다. 원격 변경 사항을 가져오고 현재 브랜치와 합치는 과정에서 무슨 일이 일어나는지 이해해야 한다. Pull을 했다고 안심하고 바로 작업하는 것이 아니라 현재 브랜치가 어디인지, 원격이 어디를 가리키는지, 내가 지금 어떤 상태인지 확인해야 한다. git branch , git remote -v , git status 정도는 눈 감고도 확인할 수 있어야 한다. 브랜치도 마찬가지다. main 에서 작업하다가 실수로 중요한 파일을 망가뜨리고 나서야 "아 브랜치를 따야 했구나"라고 깨닫는 것은 너무 늦었다. 기능 하나를 개발한다면 기능 브랜치를 만들고, 작업을 끝낸 뒤 검증하고, 커밋하고, 필요한 경우 Pull Request를 통해 병합하는 것이 기본이다. 브랜치 이름을 헷갈려서 다른 브랜치에 커밋하거나, 엉뚱한 브랜치에 push 해버리는 일도 충분히 일어날 수 있으니 항상 내가 어느 브랜치에 서 있는지 확인해야 한다. Remote도 절대 만만하게 보면 안 된다. origin 하나만 있는 줄 알고 살다가 sparta 같은 두 번째 remote를 추가해놓고 어느 저장소로 push해야 하는지 헷갈릴 수도 있다. git remote -v 한 번이면 확인할 수 있는 것을 기억하지 않고 엉뚱한 저장소에 코드를 올리는 것은 Git의 문제가 아니라 사용자의 문제다. 그리고 Git은 사용자의 신원까지 기억한다. 커밋하려는데 갑자기 Author identity unknown 이 뜨고 user.name 과 user.email 을 설정하라고 하는 순간이 온다. 이때 아무 이메일이나 집어넣는 것이 아니라 내가 어떤 계정으로 커밋하고 있는지 확인해야 한다. GitHub 계정과 커밋 이메일의 관계까지 이해해야 나중에 contribution이 제대로 연결되는지도 알 수 있다. 무엇보다 가장 중요한 것은 push 다. git add . 했다고 끝난 것이 아니고, commit 했다고 끝난 것도 아니며, push 했다고 성공한 것도 아니다. 실제로 원격 저장소에 반영되었는지 확인해야 한다. Push가 성공했다는 메시지를 보고도 GitHub에서 확인하지 않고 넘어가면 나중에 "분명 올렸는데 왜 없지?"라는 사태가 발생한다. 그리고 절대로 git add . && git commit -m "update" && git push 를 아무 생각 없이 습관처럼 실행해서는 안 된다. 물론 편리한 명령어를 만들어 사용하는 것은 좋다. 하지만 자동화는 내가 Git의 상태를 이해하고 있다는 전제에서 편리한 것이지, 상태 확인을 대신해주는 것이 아니다. 무슨 파일이 추가되었는지 모르는 상태에서 자동화된 fuck git 을 갈기는 순간, 이름만 웃길 뿐 사고 방식은 전혀 웃기지 않게 된다. git reset , git restore , git revert , git rebase , git commit --amend , git push --force 같은 명령어도 마찬가지다. 각각 무엇을 되돌리고 무엇을 변경하는지 모른 채 인터넷에서 본 명령어를 복붙해서 실행해서는 안 된다. 특히 push --force 는 원격 저장소의 다른 사람 작업까지 날릴 수 있는 강력한 명령어이므로 "안 되면 force push"라는 생각은 버려야 한다. 그리고 .gitignore 도 존중해야 한다. .env , API Key, 비밀번호, 인증서, 개인 설정 파일, 빌드 결과물 따위를 아무 생각 없이 git add . 로 올려버리는 순간 Git은 개발자의 실수를 영구히 기록하는 증인이 된다. 이미 원격 저장소에 올라간 비밀 정보는 단순히 파일을 삭제하고 다시 커밋한다고 사라지는 것도 아니다. 필요하다면 키를 폐기하고 기록에서 제거하는 별도의 조치까지 해야 한다. Commit은 보험이지만 백업과 동일하지도 않다. "커밋했으니까 괜찮겠지"라고 생각해서는 안 된다. Git은 버전을 관리하는 도구이지 모든 사고를 자동으로 복구해주는 신이 아니다. 결국 Git을 잘 사용한다는 것은 명령어를 많이 안다는 뜻이 아니다. 내가 지금 어느 브랜치에 있는지 알고, 어디에서 코드를 가져왔는지 알고, 무엇을 변경했는지 확인하고, 왜 변경했는지를 커밋에 남기고, 원격 저장소의 상태를 확인하고, 충돌이 났다면 무슨 충돌인지 읽어보고, push한 뒤 실제 반영까지 확인하는 것. 이 모든 기본을 지키는 것이 Git을 존중하는 것이다. 그러므로 나는 말한다. Git은 죄가 없다. pull 도 죄가 없고, push 도 죄가 없으며, merge conflict 도 죄가 없다. 문제는 git status 한 번 확인하지 않고 작업한 개발자에게 있으며, git diff 도 보지 않고 git add . 를 갈긴 개발자에게 있으며, 충돌 내용을 읽지도 않고 전부 지워버린 개발자에게 있으며, 어느 브랜치인지 확인하지 않고 push한 개발자에게 있으며, 무엇을 커밋했는지도 모르면서 "update" 라고 적은 개발자에게 있다. 그러니 개발자들이여. 넌 GIT을 존중해야 한다. 항상 확인하고, 항상 이해하고, 항상 기록하고, 그리고 무엇보다 커밋하기 전에 네가 무슨 짓을 했는지 먼저 확인해라. Git은 네 실수를 기억한다.
오늘 한 것 최종 프로젝트(2) 오늘 배운 점 & 느낀 점 오늘 배운 점은 유니티에서는 화면 효과가 URP 렌더러 기능으로 붙어 카메라가 쓰는 렌더러에 따라 적용되고, 시네머신 가상 카메라는 Priority로 전환돼 Main Camera가 아닌 가상 카메라를 옮겨야 하며, 월드 캔버스는 배율이 작아 3D 프리팹을 그 밖에 둬야 한다는 걸 배웠고, git에서는 .meta guid가 바뀌면 씬 연결이 끊기고 멈춘 머지는 git commit으로 마무리해야한다는 걸 배웠다. 오늘은 작은 문제가 있었는데 탄 프리팹에 낙하 스크립트가 붙어 있어 그래도 쓰면 떨어졌고, 커밋 전 작업 순서와 병합 커밋 메시지 규칙을 헷갈려 다시 고쳐야했던 문제가 있었다. 내일은 서버 설계 문서에 맞춰 Spring Boot 서버의 GameRoom 상태 머신과 턴 검증,턴 전환 승패 판정 로직을 구현해보겠다. 내일 할 일 최종 프로젝트(3) 복습
금요일 오후에 코드 리뷰 알림이 옵니다. PR 제목은 「정산 컨슈머 exactly-once 적용」입니다. 설명란에는 「프로듀서 멱등성을 켜고 Kafka 트랜잭션으로 묶었습니다. 이제 결제 이벤트가 두 번 처리될 일은 없습니다.」라고 적혀 있습니다. diff 를 보면 컨슈머는 이벤트를 읽고, 정산 테이블에 쓰고, 결제사 API 를 호출합니다. 리뷰어가 댓글을 한 줄 남깁니다. 「결제사 호출도 그 트랜잭션 안에 들어가나요?」 이 댓글 하나로 「Kafka 는 exactly-once 를 보장하나요?」의 답이 갈립니다. 보장은 합니다. 다만 범위는 Kafka 안의 읽기, 처리, 쓰기까지입니다. DB 와 외부 API 는 그 범위 밖입니다. 그릿 딥다이브 Vol.2 · Kafka 3주차 「생존」에서 다룬 내용입니다. exactly-once 는 메시지가 딱 한 번 간다는 뜻인가요? 아닙니다. 전송과 재시도는 여러 번 일어날 수 있습니다. exactly-once 는 그래도 처리의 효과가 한 번만 반영된다는 뜻입니다. 그래서 보장의 범위는 효과가 남는 저장소가 어디까지인지로 정해집니다. Kafka 트랜잭션은 두 가지를 묶습니다. Kafka 토픽에 쓰는 일과 Kafka 소비 오프셋(컨슈머 그룹이 어디까지 읽었는지 기록한 위치)입니다. MySQL INSERT, 이메일 발송, 결제사 HTTP 요청은 같은 트랜잭션에 자동으로 들어오지 않습니다. 그래서 리뷰어 질문의 답은 「들어가지 않습니다」입니다. 결제 API 가 성공하고 커밋 전에 프로세스가 멈추면 재처리 때 API 를 다시 호출할 수 있습니다. 이미 실행한 외부 HTTP 요청을 Kafka 트랜잭션이 롤백하지 못합니다. 중복은 어디서 생기나요? 중복은 두 종류로 나뉩니다. 하나는 Kafka 로그에 같은 업무 이벤트가 두 번 기록되는 경우입니다. 다른 하나는 로그의 레코드 하나를 컨슈머가 두 번 처리하는 경우입니다. 로그 중복은 프로듀서 재시도에서 생깁니다. 브로커가 레코드를 기록했는데 응답이 끊기면 프로듀서는 실패로 판단합니다. 이때 다시 보내면 레코드가 두 번 남을 수 있습니다. 재시도를 끄면 중복은 줄지만 일시적인 네트워크 오류나 리더 변경 때 유실될 가능성이 커집니다. 재처리 중복은 처리와 오프셋 커밋의 순서에서 생깁니다. Kafka 오프셋 커밋과 외부 DB 트랜잭션은 기본적으로 하나의 원자 연산이 아닙니다. 어느 쪽을 먼저 해도 두 연산 사이에서 멈출 수 있는 구간이 남습니다. 순서 사이에서 멈추면 이름 처리 후 커밋 다음 인스턴스가 같은 레코드를 다시 처리 at-least-once 커밋 후 처리 다음 인스턴스가 그 레코드를 건너뜀 at-most-once 중복을 없애려고 커밋을 처리 앞으로 옮기면 중복이 유실로 바뀔 뿐입니다. 그래서 처리 후 커밋에 멱등한 업무 로직을 함께 쓰는 경우가 많습니다. 멱등 프로듀서는 무엇을 막나요? 멱등 프로듀서는 배치마다 producer ID, producer epoch(같은 ID를 쓰는 프로듀서의 세대 번호), 파티션별 sequence 를 붙입니다. 브로커는 프로듀서마다 최근 sequence 를 추적합니다. 같은 배치가 재전송되면 브로커가 알아보고 로그에 다시 쓰지 않습니다. sequence 는 레코드 내용의 해시가 아닙니다. 같은 프로듀서 세션이 같은 파티션에 보낸 레코드마다 하나씩 올라가는 번호입니다. producer ID 도 애플리케이션이 정하지 않습니다. 프로듀서가 InitProducerId 요청을 보내면 브로커가 발급합니다. 프로세스가 재시작하면 새 ID를 받습니다. Kafka 3.9.0 에서는 enable.idempotence=true 가 기본입니다. 다음 조건이 함께 필요합니다. acks=all retries > 0 max.in.flight.requests.per.connection <= 5 숫자 5에는 이유가 있습니다. 브로커는 파티션마다 producer ID 별로 최근 다섯 개 배치의 sequence 를 기억합니다. 동시에 떠 있는 요청이 이보다 많으면 재전송된 배치를 판정할 근거가 사라집니다. 기대한 sequence 와 어긋난 배치를 브로커가 거부하면 프로듀서는 OutOfOrderSequenceException 을 받습니다. 설정 충돌도 확인해야 합니다. enable.idempotence=true 를 명시하고 충돌하는 값을 넣으면 ConfigException 이 발생합니다. 멱등 설정을 명시하지 않고 acks=1 이나 retries=0 을 넣으면 멱등이 꺼질 수 있습니다. 오래된 설정 한 줄 때문에 기본 보호가 꺼질 수 있으므로 시작 로그에서 최종 적용 설정을 확인합니다. 멱등 프로듀서가 막지 못하는 것도 분명합니다. 애플리케이션이 예외를 잡아 새로 호출한 send() 는 새 레코드 전송입니다. 새 sequence 를 받으므로 브로커는 중복으로 보지 않습니다. 다른 프로듀서 인스턴스가 같은 업무 이벤트를 보낸 경우도 막지 못합니다. sequence 는 파티션마다 따로 매깁니다. 그래서 파티션 하나 안의 중복만 막습니다. 컨슈머가 같은 레코드를 두 번 처리하는 문제와는 관계가 없습니다. 함께 보면 좋은 글: Redis 백업 중 메모리가 두 배로 찍혔다: 서버를 늘리기 전에, 면접에서 답하기 전에 볼 세 가지 Kafka 트랜잭션은 어디까지 묶나요? Kafka 트랜잭션은 여러 토픽 파티션에 쓰는 일과 입력 오프셋 커밋을 한 트랜잭션으로 묶습니다. 입력 토픽 A를 읽어 출력 토픽 B에 쓰는 흐름이라면 출력만 남거나 오프셋만 전진하는 상태가 생기지 않습니다. 둘 다 커밋되거나 둘 다 중단됩니다. 프로듀서에 안정적인 transactional.id 를 설정하고 아래 순서로 호출합니다. 예외 처리는 확인하기 쉽도록 뺐습니다. producer.initTransactions(); while (running) { ConsumerRecords<String, Event> records = consumer.poll(timeout); producer.beginTransaction(); for (ConsumerRecord<String, Event> record : records) { producer.send(toOutputRecord(record)); } producer.sendOffsetsToTransaction( offsetsOf(records), consumer.groupMetadata()); producer.commitTransaction(); } 예외는 두 가지로 나눠 다룹니다. ProducerFencedException 처럼 이 인스턴스가 이미 밀려난 경우는 재시도해도 회복되지 않습니다. 이때는 프로듀서를 닫고 인스턴스를 내립니다. 그 밖의 KafkaException 은 abortTransaction() 으로 트랜잭션을 중단합니다. 오프셋 커밋도 함께 취소되므로 다음 poll 에서 같은 구간을 다시 가져옵니다. transactional.id 가 재시작 전후로 같아야 하는 이유가 있습니다. 같은 ID로 새 인스턴스가 initTransactions() 를 호출하면 트랜잭션 코디네이터가 그 ID의 epoch 를 올립니다. 그 뒤로 낡은 epoch 를 단 이전 인스턴스가 쓰려고 하면 ProducerFencedException 을 받습니다. 재시작할 때마다 새 UUID 를 쓰면 코디네이터가 이전 세대를 알아보지 못합니다. 그러면 두 세대가 서로 다른 ID로 동시에 쓰고, 좀비 차단(밀려난 이전 인스턴스의 쓰기를 막는 장치)이 동작하지 않습니다. 컨슈머 쪽에서는 consumer.groupMetadata() 가 같은 역할을 합니다. 이 값에는 그룹 ID, 현재 generation(리밸런싱마다 하나씩 오르는 세대 번호), 멤버 ID가 들어 있습니다. 그룹 코디네이터는 낡은 generation 의 오프셋 커밋을 거부합니다. Kafka Streams 의 processing.guarantee=exactly_once_v2 가 이 방식(KIP-447)을 씁니다. 읽는 쪽 설정도 바꿔야 합니다. 중단된 트랜잭션의 레코드도 로그에는 물리적으로 남습니다. 커밋과 중단 표시는 제어 레코드로 같은 파티션에 기록됩니다. isolation.level=read_committed 컨슈머는 이 표시를 읽고 중단된 레코드를 건너뜁니다. 기본값 read_uncommitted 는 중단된 레코드도 읽습니다. 그리고 트랜잭션이 오래 열려 있으면 read_committed 컨슈머는 LSO(Last Stable Offset, 결과가 정해지지 않은 트랜잭션 바로 앞 위치)에서 멈춥니다. 소비가 밀린 것처럼 lag 이 쌓여 보입니다. 결제 API 호출은 누가 막나요? Kafka 밖의 효과는 끝점(효과가 실제로 남는 DB나 외부 API)마다 따로 멱등하게 만들어야 합니다. DB에는 업무 ID 고유 제약이나 멱등 소비 기록을 둡니다. 외부 API에는 업무 ID로 만든 idempotency key(같은 요청의 재호출임을 알리는 식별자)를 보냅니다. 처리 상태를 단계별로 저장하고 재시작하면 멈춘 단계부터 이어서 수행합니다. DB 변경과 발행할 이벤트를 함께 기록하는 transactional outbox 를 검토합니다. 멱등 소비 기록은 이미 처리한 이벤트를 표시해 두는 테이블입니다. 처리 전에 표시를 넣어 보고, 표시가 이미 있으면 업무 처리를 건너뜁니다. MySQL 이라면 이렇게 확인할 수 있습니다. CREATE TABLE processed_event ( event_id VARCHAR(64) PRIMARY KEY, processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 영향받은 행이 0이면 이미 처리한 이벤트이므로 업무 처리를 건너뜁니다. INSERT IGNORE INTO processed_event (event_id) VALUES (?); 표시와 업무 변경은 같은 DB 트랜잭션 안에 있어야 합니다. 둘을 나누면 표시만 남고 업무 변경은 빠진 상태가 생길 수 있습니다. 그러면 재처리 때 그 이벤트는 처리 완료로 걸러집니다. 중복을 막으려던 장치 때문에 오히려 누락이 생깁니다. 키를 무엇으로 정하는지도 중요합니다. (topic, partition, offset) 을 키로 쓰면 같은 레코드의 재처리는 걸러집니다. 하지만 프로듀서가 두 번 보내 서로 다른 오프셋에 남은 두 레코드는 통과합니다. 업무 ID를 키로 쓰면 프로듀서가 만든 중복까지 같은 키로 모입니다. 그래서 외부 결제 API로 끝나는 파이프라인에서 현실적인 목표는 at-least-once 전달과 끝점 멱등성입니다. 중복이 없다고 가정하지 않습니다. 중복이 와도 결과가 한 번 처리한 것과 같게 만듭니다. 이런 CS 질문 하나를 아침마다 짧게 같이 풀어 보는 오픈채팅방이 있습니다. 개발자: 데일리 CS 역량 강화 챌린지 들어가 보기 → 면접에서 이 질문을 받는다면 「Kafka 는 exactly-once 를 보장하나요?」에는 이렇게 답할 수 있습니다. Kafka 안의 읽기, 처리, 쓰기까지는 보장합니다. 멱등 프로듀서는 같은 세션의 전송 재시도를 파티션 안에서 걸러 냅니다. 트랜잭션은 출력 토픽 쓰기와 입력 오프셋 커밋을 함께 커밋하거나 함께 중단하고, 컨슈머는 read_committed 로 중단된 레코드를 건너뜁니다. 다만 exactly-once 는 효과가 한 번 반영된다는 뜻이라서 외부 DB나 결제 API에 남는 효과는 이 범위에 들어오지 않습니다. 그래서 외부 연동은 처리 후 커밋하는 at-least-once 소비에 업무 ID 고유 제약과 idempotency key 를 더해 설계합니다. 면접관이 이어서 「멱등 프로듀서가 기본으로 켜져 있는데 왜 업무 중복이 생기나요?」라고 물을 수 있습니다. sequence 는 한 프로듀서 세션이 한 파티션에 보낸 레코드에만 매기는 번호라는 점부터 말합니다. 애플리케이션이 다시 호출한 send() 와 재시작한 프로세스는 새 sequence 나 새 producer ID를 받습니다. 컨슈머가 커밋 전에 멈춰서 생기는 재처리는 프로듀서와 관계없습니다. 여기에 acks=1 같은 오래된 설정이 멱등을 끌 수 있다는 점을 덧붙이면 됩니다. 이런 꼬리 질문을 매일 하나씩 익명으로 같이 풀어 보는 방도 있습니다. 개발자: 데일리 익명 면접 챌린지 들어가 보기 → 같은 장에서 하나 더: 자동 커밋은 중복을 만드나요, 누락을 만드나요? Kafka 3.9.0 컨슈머는 기본으로 enable.auto.commit=true , auto.commit.interval.ms=5000 (5초)입니다. 자동 커밋은 poll() 흐름에서 주기를 확인하고, 반환한 배치 이후 위치를 커밋합니다. 프로세스가 멈추면 최대 한 주기 분량을 다시 읽습니다. 기본값이라면 마지막 5초 동안 처리한 레코드입니다. close() 로 정상 종료하거나 리밸런싱으로 파티션을 반납할 때도 커밋이 일어납니다. kill -9 나 컨테이너 강제 중단은 이 두 경우를 모두 건너뜁니다. 결과가 어느 쪽인지는 처리 구조에 달려 있습니다. 배치를 모두 처리하고 나서 다음 poll() 을 부르면 중복 쪽입니다. 레코드를 작업 스레드에 넘기고 곧바로 poll() 을 부르면 처리보다 커밋이 먼저 나갈 수 있어 누락 쪽입니다. enable.auto.commit=true 한 줄만 보고는 전달 의미를 알 수 없습니다. 같은 장에서 다룬 나머지는 다음과 같습니다. 리밸런싱이 소비 중복을 만드는 방식: session.timeout.ms 와 max.poll.interval.ms 가 서로 다른 것을 감시하는 이유, eager 와 협력적 리밸런싱의 차이, onPartitionsRevoked 와 onPartitionsLost 를 나눠 구현해야 하는 경우를 다룹니다. 처리 실패 시 커밋을 어디까지 전진시킬지: seek 로 되감기, pause 후 재시도, dead letter 토픽, 배치 전체 재처리가 각각 어떤 중복과 누락을 남기는지 비교합니다. 비동기 커밋의 순서 역전: commitAsync() 응답이 늦게 도착해 최신 커밋을 덮는 경로와 콜백에서 이를 막는 방법을 다룹니다. 함께 읽기 Redis 백업 중 메모리가 두 배로 찍혔다: 서버를 늘리기 전에, 면접에서 답하기 전에 볼 세 가지 이 글의 출처 이 글은 팀그릿 「그릿 딥다이브 Vol.2 · Kafka」 3주차 「생존」에서 뽑았습니다. 리밸런싱, 실패 시 커밋 전략, 비동기 커밋 순서 역전의 풀이와 설정은 책에 있습니다. 그릿 딥다이브 Vol.2 · Kafka 목차 보기 →
이진 탐색(Binary Search)은 정렬된 배열에서 원하는 값을 빠르게 찾는 대표적인 탐색 알고리즘이다. 보통은 다음과 같이 설명한다. 찾으려는 값과 배열의 중간값을 비교하면서 탐색 범위를 절반씩 줄여 나간다. 시간 복잡도는 O(log N) 이다. 그런데 문제를 풀다 보면 단순히 값의 존재 여부 만 필요한 경우보다 조금 더 까다로운 상황을 만나게 된다. 예를 들어 다음과 같은 배열이 있다고 하자. int[] arr = {10, 20, 30, 40, 50}; 여기서 30 을 찾는 것은 어렵지 않다. 하지만 26 을 찾으려고 했는데 배열에 26 이 없다면? 이번에는 단순히 -1 을 반환하는 것이 아니라, 배열에 정확한 값이 없다면 가장 가까운 값을 반환하고 싶다. 라는 조건이 생긴다. 추가로 거리가 같다면 더 큰 값을 선택한다 고 해보자. target = 25 20과의 거리 = 5 30과의 거리 = 5 → 거리가 같으므로 30 선택 이 문제를 이진 탐색으로 어떻게 해결할 수 있을까? 1. 가장 단순한 방법: 전체 배열 탐색 가장 먼저 떠올릴 수 있는 방법은 배열 전체를 순회하면서 target과의 거리를 계산하는 것이다. public int findClosest(int[] arr, int target) { int closest = arr[0]; for (int value : arr) { int currentDistance = Math.abs(value - target); int closestDistance = Math.abs(closest - target); if (currentDistance < closestDistance || (currentDistance == closestDistance && value > closest)) { closest = value; } } return closest; } 이 방법은 구현도 쉽고 배열이 정렬되어 있을 필요도 없다. 하지만 모든 원소를 확인해야 하므로 시간 복잡도는 O(N) 이다. 배열이 이미 정렬되어 있다면 이 정렬 상태를 이용해서 더 빠르게 찾을 수 있다. 2. 이진 탐색이 끝난 뒤의 left , right 일반적인 이진 탐색을 살펴보자. int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } 보통은 여기서 값을 찾지 못하면 -1 을 반환한다. 하지만 여기서 중요한 점이 하나 있다. 탐색이 실패했을 때의 left 와 right 에는 의미가 있다. 예를 들어 arr = [10, 20, 30, 40, 50] target = 26 이라고 해보자. 이진 탐색이 끝나면 두 포인터는 다음 위치에 놓이게 된다. right left ↓ ↓ [10, 20, 30, 40, 50] 실제로는 20 < 26 < 30 right → 20 left → 30 즉 일반적인 경우라면 탐색 종료 후 다음 관계가 성립한다. arr[right] < target < arr[left] 따라서 가장 가까운 값을 찾기 위해서는 배열 전체를 볼 필요가 없다. arr[right] 와 arr[left] 두 개만 비교하면 된다. 3. 가장 가까운 값을 찾는 이진 탐색 이를 코드로 만들면 다음과 같다. public int binarySearchClosest(int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } int leftDistance = arr[left] - target; int rightDistance = target - arr[right]; return leftDistance <= rightDistance ? left : right; } 거리까지 같다면 더 큰 값을 선택하고 싶으므로 leftDistance <= rightDistance 일 때 left 를 선택한다. 오름차순 배열에서는 arr[left] 가 arr[right] 보다 크기 때문이다. 하지만 이 코드에는 문제가 있다. 4. target이 배열 범위를 벗어나는 경우 다음 입력을 생각해보자. arr = [10, 20, 30] target = 5 이진 탐색이 끝나면 left = 0 right = -1 이 되므로, arr[right] 에서 ArrayIndexOutOfBoundsException 가 발생한다. 반대의 경우도 마찬가지다. arr = [10, 20, 30] target = 40 이면 탐색 종료 후 left = 3 right = 2 가 되면서, arr[left] = arr[3] 을 접근하게 되어 ArrayIndexOutOfBoundsException 이 발생한다. 그래서 경계 처리가 필요하다. 5. 경계까지 처리한 구현 public int binarySearchClosest(int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } // target이 배열의 최솟값보다 작은 경우 if (right < 0) { return left; } // target이 배열의 최댓값보다 큰 경우 if (left >= arr.length) { return right; } int leftDistance = arr[left] - target; int rightDistance = target - arr[right]; // 거리가 같으면 더 큰 값 선택 return leftDistance <= rightDistance ? left : right; } 이제 다음 세 가지 경우를 모두 처리할 수 있다. target < 배열의 최솟값 배열 내부에 target이 위치 target > 배열의 최댓값 시간 복잡도는 그대로 O(log N) 이다. 6. Arrays.binarySearch() 를 사용하면? Java에는 이미 이진 탐색을 구현한 메서드가 있다. Arrays.binarySearch(arr, target); 값이 존재하면 해당 인덱스를 반환한다. int[] arr = {10, 20, 30}; Arrays.binarySearch(arr, 20); // 1 그런데 값이 존재하지 않을 경우 단순히 -1 을 반환하지 않는다. 예를 들어 target이 25 라면 [10, 20, 30] ↑ 25가 들어갈 위치 삽입 위치는 2 다. Arrays.binarySearch() 는 다음 값을 반환한다. -(삽입 위치) - 1 즉 -(2) - 1 = -3 이다. 반환값에서 삽입 위치를 다시 얻으려면 int insertionPoint = -(result + 1); 을 사용하면 된다. 예를 들어 int result = Arrays.binarySearch(arr, 25); if (result < 0) { int insertionPoint = -(result + 1); } 이 insertionPoint 는 곧 target 이상인 값이 처음 등장할 위치 와 같다. 7. 사실 필요한 것은 "가장 가까운 값"보다 삽입 위치다 여기서 한 단계 더 생각해볼 수 있다. 가장 가까운 값을 직접 찾으려고 하기보다 먼저 target이 정렬 순서를 유지하면서 들어갈 위치가 어디인가? 를 찾는 것이 더 일반적인 접근이다. 예를 들어 arr = [10, 20, 30, 40] target = 27 이라면 [10, 20 | 27 | 30, 40] ↑ insertion point 삽입 위치는 2 다. 그러면 자연스럽게 후보는 left = insertionPoint - 1 right = insertionPoint 가 된다. 즉 20 ← 27 → 30 두 값만 비교하면 된다. 이 개념이 lower bound 와 연결된다. 8. Lower Bound Lower Bound는 정렬된 배열에서 target 이상인 값이 처음 등장하는 위치 를 찾는다. Java로 직접 구현하면 다음과 같다. private int lowerBound(int[] arr, int target) { int left = 0; int right = arr.length; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] < target) { left = mid + 1; } else { right = mid; } } return left; } 일반적인 이진 탐색과 가장 눈에 띄는 차이는 int right = arr.length; 이다. arr.length - 1 이 아니라 arr.length 까지 탐색 범위에 포함한다. 왜냐하면 target이 배열의 모든 값보다 클 경우 [10, 20, 30 | target] ↑ index 3 처럼 반환값이 실제 배열 인덱스를 넘어선 arr.length 가 될 수도 있기 때문이다. 9. Lower Bound를 이용한 가장 가까운 값 탐색 이를 이용하면 가장 가까운 값 찾기도 깔끔해진다. public int findClosestIndex(int[] arr, int target) { int right = lowerBound(arr, target); int left = right - 1; if (left < 0) { return right; } if (right >= arr.length) { return left; } int leftDistance = target - arr[left]; int rightDistance = arr[right] - target; // 거리가 같으면 큰 값인 right 선택 return rightDistance <= leftDistance ? right : left; } private int lowerBound(int[] arr, int target) { int left = 0; int right = arr.length; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] < target) { left = mid + 1; } else { right = mid; } } return left; } 개인적으로는 처음 작성한 "값을 찾는 이진 탐색을 변형하는 방식"보다 이 구조가 더 이해하기 쉽다. 우리가 실제로 원하는 것은 먼저 target의 위치 를 찾고, 그 다음 target 왼쪽 값 과 target 오른쪽 값 을 비교하는 것이기 때문이다. 10. 가장 가까운 값을 여러 개 찾고 싶다면? 여기서 한 단계 더 확장할 수 있다. 예를 들어 다음 배열에서 arr = [1, 2, 3, 4, 5, 6, 7] target = 4 target에 가까운 순서대로 모든 값을 나열하고 싶다고 해보자. 거리만 보면 왼쪽 3 → 거리 1 2 → 거리 2 1 → 거리 3 오른쪽은 4 → 거리 0 5 → 거리 1 6 → 거리 2 7 → 거리 3 가 된다. 즉 target 기준으로 배열을 나누면 양쪽이 각각 이미 거리순으로 정렬된 상태가 된다. 왼쪽 후보: 3, 2, 1 오른쪽 후보: 4, 5, 6, 7 따라서 두 포인터를 두고 가까운 값부터 하나씩 선택하면 된다. int right = lowerBound(numlist, n); int left = right - 1; 그리고 while (left >= 0 && right < numlist.length) { int leftDistance = n - numlist[left]; int rightDistance = numlist[right] - n; if (rightDistance <= leftDistance) { result[current++] = numlist[right++]; } else { result[current++] = numlist[left--]; } } 처럼 두 후보군을 병합할 수 있다. 이 구조는 Merge Sort의 merge 단계와 상당히 비슷하다. 11. 단순 이진 탐색에서 얻은 중요한 관찰 이번 문제를 풀면서 이진 탐색을 단순히 "정렬된 배열에서 값을 빠르게 찾는 알고리즘" 으로만 이해하면 아쉽다는 생각이 들었다. 이진 탐색이 실패했을 때도 정보는 남아 있다. right < left 가 된다는 것은 단순히 값을 찾지 못했다 는 의미만 있는 것이 아니다. 일반적인 경우 arr[right] < target < arr[left] 라는 위치 관계를 얻을 수 있다. 그리고 이 정보는 가장 가까운 값 찾기 삽입 위치 찾기 Lower Bound Upper Bound 특정 조건을 처음 만족하는 위치 찾기 투 포인터와 결합한 탐색 등으로 확장할 수 있다. 정리 정렬된 배열에서 정확한 값이 존재하지 않을 때 가장 가까운 값을 찾고 싶다면 전체 배열을 순회할 필요가 없다. 핵심은 target이 들어갈 위치( InsertionPoint )를 찾는 것이다. target ↓ ... arr[left] | arr[right] ... 그리고 target의 바로 왼쪽과 오른쪽 값만 비교하면 된다. 전체 흐름을 정리하면 다음과 같다. 정렬된 배열 ↓ 이진 탐색 ↓ target의 삽입 위치 탐색 ↓ 왼쪽 후보 / 오른쪽 후보 ↓ 두 값의 거리 비교 ↓ 가장 가까운 값 선택 단순히 하나의 값을 찾는 문제라면 시간 복잡도는 O(log N) 이다. 이번 문제를 통해 가장 크게 얻은 것은 이진 탐색의 구현 자체보다 값을 찾지 못했을 때의 left와 right도 중요한 정보를 가지고 있다 는 점이었다. 이진 탐색은 "정답이 있는 위치"를 찾는 알고리즘으로만 보기보다, 정렬된 탐색 공간에서 조건의 경계를 찾는 알고리즘 으로 이해하면 훨씬 다양한 문제에 활용할 수 있다.