ASHD Dev_Blog

면접 준비 : 당근 (나의 당근)

소켓 아키텍쳐 기반 EDA/MSA 중점 면접 준비

이재룡
이재룡 May 8, 2026
 

공고 (공식은 당근에서 지움…)

https://linkareer.com/activity/328217

 
 

1분 자기소개

안녕하세요. 시스템 간의 상호작용과 복합적인 상황을 먼저 고민하는 백엔드 개발자 이재룡입니다.

저는 1만 명이 사용하는 서비스에서 인증 서버 이관과 실시간 메시징 시스템 구축을 경험했습니다. 단순히 요구사항을 구현하는 데 그치지 않고, 여러 시스템이 얽히는 상황을 가정해 POC 단계부터 아키텍처를 점진적으로 발전시켜 왔습니다.

작고 단순한 설계부터 시작해 예외 상황을 가정하고, 문제점을 하나씩 해결해 나가는 그 과정 자체가 저에게는 프로젝트를 하며 밤을 새울 만큼 큰 즐거움이었습니다.

나의당근 팀의 SDUI 서버 또한 복잡하게 얽힌 타 서비스의 데이터를 유저의 조건에 맞춰 실시간으로 합성해 내는 매력적인 시스템이라고 생각합니다.

프론트엔드와의 단순한 연동을 넘어, 흩어진 타 서비스의 데이터들을 '나의 당근'에 모아 안정적으로 정합성을 맞추고, 유저에 따라 실시간으로 합성하는, 복잡하면서도 흥미로운 과정을 당근에서 직접 부딪치며 경험해보고 싶습니다. 감사합니다.

[ 1. 개념 기반 Deep dive]

1. Redis

캐싱 및 기본 개념
  • 인메모리 싱글 스레드
    • linux에서는 모든 io를 file로 fd 개념
    • epoll
    • O(cnt) → multiflexing
    • keys * cluster
    • lettuce
  • 영속성
    • RedisDatabase
    • AOF
      • vs Caffeine cash
      • lookaside Pattern
      • redis template
      • spring session
  • 분산락
pub/sub
streams
 

2.

 
 

심심할 때 마다 보기(CS 개념)

https://velog.io/@youngjun_10/BackEnd-기술-면접-질문-정리

https://dev-coco.tistory.com/163

 

 

[ 2. 프로젝트 기반 ]

미리디 때 준비 했던 Socket server

1. Web Socket Part

WebSocket vs SSE vs Rsocket (+Stomp를 왜 안썼나)
  • WebSocket: 클라이언트와 서버 간의 양방향 실시간 통신을 지원하는 웹 표준 프로토콜
  • SSE: 서버가 클라이언트로 데이터를 일방적으로 밀어넣는 단방향 통신 기술 (서버의 데이터 갱신)
  • RSocket: 진보된 양방향 프로토콜 + Backpressure
  • STOMP: WebSocket과 함께 사용 + 목적지 등의 정보를 기반으로 분기(for Pub/Sub)

1. 왜 순수 WebSocket을 선택했는가?

1-1. 왜 SSE를 쓰지 않았는가? (단방향의 한계)

클라이언트 to 서버 메시지 전송, 읽음 처리, 디바이스 정보 갱신, 연결 유지를 위한 Pong

  • "수신용 SSE 채널"과 "송신용 REST 채널”

1-2. 왜 RSocket을 채택하지 않았는가? (오버엔지니어링 방지)

  • 낮은 트래픽 : 사용자당 초당 수 건의 메시지 일시적인 트래픽
  • Rsocket의 장점인 backpressure와 multiplexing(stream 다중화)가 불필요
    • 채팅에서는 한 (사용자+domain) = 한 논리 스트림(자기 앞으로 오는 모든 이벤트)
    • 외부로 나갈 경우 소켓 off = multi session이 아닌 경우 유저당 1개
    • only Message 이미지 다운로드 등의 정체 구간 x

→ Event Type 단위 분기 + Coroutine Loop로 처리

 

2. 왜 STOMP도 채택하지 않았는가? (불필요한 추상화 제거)

  • Relay 서버 : userId 기반 + 사용자의 접속 여부 확인 → 목적지에 따른 분배 불필요
  • 경량화 : 가벼운 JSON 객체에 eventType 필드만 추가
  • redisStreams : Pub/Sub을 사용하지 않음


ChatWebSocketHandler - coroutineScope Loop

  • PingPongHandler launch (25초 주기)
    • 10초 동안 pong이 안오면 소켓 해제
    • pong이 오면 redis ttl 초기화 → 25초 후에 ping
 
  • MessageReceivedHandler launch : (Trigger : Flux emit = fanout)
    • MESSAGE_NEW & ROOM_READ_UPDATE
  • Receive Loop : pong/send message/read 등 서버가 받은 것 처리 (Trigger : 클라가 보낸 메시지)
    • payload를 collect 해서 메시지 형태로

중요 : messageFlux.asFlow().collect {}형식이기 때문에

  • suspend 즉 : emit 올 때까지 스레드 놓고 대기 (CPU 점유 x)
  • Flux에 값이 들어오면 → lambda 함수 실행

 

멱등성 처리 (Message Send)

  • 손실된 message = ACK가 오지 않음 = DB에 저장 실패
  • 클라이언트가 재시도
  • 클라이언트가 발급한 requestId를 Unique Key → catch DuplicatedKeyException
    • 동일한 msgId, seq 정보 재 전송
 
 

2. CDC Part

kafka를 어떻게 대체 했나?
  • Kafka : 초당 백만 건이상의 TPS가 필요한 환경 + 여러 데이터 소스에서 발생하는 대규모 데이터를 수집 및 처리하는 분산 스트리밍 플랫폼
    • 단순 메시지큐(휘발성)가 아님
    • 디스크에 log를 append only
    • Offeset 기반 재처리 기능
    • Retry topic / Dead Letter Queue
    • 극단적 병렬성 (파티션 기반)

CDC인 Debezium과 같은 것이 필수! + zookeeper 같은 관리 Broker

 

1. 어떻게 kafka를 대체했나? (신뢰성 이벤트 전파와 에러 복구)

1-1. Mongo Change Stream (영속성 + CDC)

  • outbox event table을 통해 영속성 보장
  • oplog(append-only)를 push 방식으로 확인

1-2. Consumer Group & PEL (파티셔닝, Retry Topic)

  • redisStreams의 Consumer Group은 파티셔닝과 동일한 역할
  • PEL에 미처리 메시지가 저장되므로, 이를 xpending을 통해 재처리 로직으로

1-3. Relay & Sink (Zookeeper cluster) → 장애 감지 및 복구

  • 어떤 kafka brocker 살아있는지 → 서버당 stream할당하고 서버가 살아있는지
    • heartbeat check를 통한 60초 redis timestamp 갱신
  • 토픽에 관한 연결 정보를 → 유저별 Sink연결 정보를 redis로 소유
    • sub에서 바로 목적지인 해당 유저의 websocket으로 다이렉트
 
  1. Transcation Outbox Pattern : 메시지 저장 + outbox 저장을 하나의 transaction으로
  1. MongoChageStreams를 사용해서 insert 기준으로 oplog 인식 → 즉각적 푸시
      • Oplog push (tail -f 방식)
      • ResumeToken 방식 (redis 저장)
      • 실제 Flux 형태
 

3. EDA Part

  1. fanout : RedisStreams
      • 채팅 시스템에서는 roomId 기반 (유저의 실시간 접속 서버를 확인)
      • stream-serverInstanceId
      coroutineScope (suspend = 동기적 블록)
      1. 방안의 유저를 .forEach
      1. 각 유저를 xadd(socket or noti) 하는 블록을 launch
      1. 모든 유저가 launch 완료된 경우만
      1. dispatch 함수 return
  1. 동적 파티셔닝 : dispatcher = relay server에서 분기
  1. 세션 큐 → 서버 단위 큐 : pooling 방식인데 서버가 유저수 만큼 pooling?
      • 인스턴스 다운 시 그 큐의 미처리 메시지를 다른 인스턴스가 인계
        • PendingMessageService
  1. consumer Group + PEL
      • 다음 round-trip에서 PEL을 우선 재처리
  1. SinkRegistry
      • 유저 id 기반 멀티 세션의 두 웹소켓 모두 하나의 sink로
      • pingpong 시에 sink caffeine ttl update
      • Sink emit 성공 시점에 ACK
       

장애 대응

  • 서버 heartbeat check by RelayServer
  • death
    • xpending → 자세한 메시지형태가 아닌 log형태
    • 각 로그를 기반으로, range로 msg 재조회
    • 현재 접속 instance id로 다시 xadd
  • alive
    • val messages = if (!pending.isNullOrEmpty()) pending else read()
      → read 이전에 pending 먼저


서버 간 통신

  • 모듈화된 redisStreams를 이용해서 (직렬화된 node)를 이용한 통신
    • 서버별 의존 X
  • 각 서버는 직렬화된 node의 형태를 필요한 이벤트 형태로 convert
 

Flux.defer { ... }는 "매 구독 시점마다 새 flux = 새 메시지의 lazy publisher 생성자

retryWhen으로 재 호출 시 새롭게 사이클마다 새로운 메시지 read() 하기 위해

Stream 동작

Flux : Mongo Stream

→ reactiveMongoTemplate.changeStream("outbox", ...)

Reactor : 이후에 outbox 이벤트 처리 파이프라인(Mono)

→ mono { dispatch(outbox) }.then(saveResumeToken(token))

  • Mono : 1-element stream

Coroutine : Dispatcher : 동기 코드 블럭

  • 실제 redisStreams에 xadd

dispatch return → saveResumeToken → 다음 outbox (concateMap으로 강제로 순차적)

1. 메시징 시스템

[기본 설계]

at-least-once로 한 이유 = 왜 pub/sub 안썼는지

exactly once는 분산 환경에서 여렵다고 생각했고, 멱등성 키를 사용하는 방식으로 중복제거를 하는 방식이 더 맞다고 판단했습니다. at most once는 초기 POC 단계에서 pub/sub을 이용해서, ack를 받지 않는 방식으로 구현했을 떄(ㅋㅋ 거짓말), 100개의 메시지를 테스트로 1분간 서로 다른 소켓 서버에서 하나의 토픽으로 통신 했을 때, 약 9개 정도의 메시지가 손실 되었었고, 해당 방식은 실제 유저가 여러 토픽을 받는 상황이 되었을떄, 손실률이 너무 높다고 판단했습니다.

at-least-once 어떻게 보장했는지

실질적으로 CDC에 의해 원 트랜잭션으로 데이터와 함께 기록된 이벤트는 영속성을 가지기 때문에 실패시 retry 하는 방식으로 중계 서버에서 무조건 적으로 xadd하도록 구현하였고, 실제 소켓 서버 쪽으로는 streams의 xack를 이용하여 반드시 한번은 전달되도록 구현하였습니다. 또한 socket 서버의 이벤트 stream을 유저의 websocket으로 연결하는 Sink를 이용해 바로 이벤트 큐에서 웹소켓으로 전달 되도록 하여 emit.ok 시에 반드시 해당 유저의 웹소켓에 전달 되도록 구조를 구성하였습니다.

모순? : at-least-once를 보장 했는데 메시지 손실이 왜 일어나나?

채팅방 내 즉 소켓으로 전달되는 메시지의 손실은 0%였습니다 하지만 미접속 유저에 대해서 firebase FCM과 통신하는 알림 서버로 보내는 처리를 했는데 이 과정에서 기존 방식처럼 미 전송 메시지, 즉 PEL 내부의 메시지에 대해 재전송 과정을 거칠 경우 seq로 정렬을 했던 실제 내부 메시지와 다르게 FCM에서 보내는 메시지는 해당 처리가 어려운 상황이었고, 기획 논의 과정에서 통신 과정에서 잃어버린 메시지가 늦게 도착해서 순서가 꼬이는 것 보다, 해당 문제가 알림 메시지가 1초에 약 20번 오는 특이 케이스에서 프론트 버퍼 오버 플로우 등의 요인으로 발생하기도 했고 알림이라는 비교적 중요도가 낮은 이벤트라고 판단했기 때문에 해당 부분은 PEL 관련 로직을 적용하지 않아. 알림 메시지 약 8000개의 메시지 중 100개 가까이 손실이 있었기 때문에 메시지 손실이 있었다고 표현했습니다

emit.OK이더라도 바로 websocket은 끊길 수 있는데 서버에서는 ack도 받고 웹소켓에도 넣었으니까 손실이 없다고 생각해도 실제로 손실이 발생할 수 있지 않나?

만약 websocket이 끊긴 경우는 pingpong loop로 확인했고 이 과정에서 sink의 hearbeat check를 pong을 받았을 때 registry.touch를 통해 받도록 해서 websocket이 끊길 경우 1초 내로 무조건 sink가 끊기도록 했습니다. 일단 제가 언급한 메시지 손실률 계산에서는 서버 입장에서 이벤트가 유저의 웹소켓까지 전달됨을 의미하기 때문에 고려하지 못한 것은 맞습니다. 하지만 메시지에 대해, 프론트와 어플리케이션 레벨의 ack 교환과, 재전송 로직이 있고, 해당 채팅방의 메시지를 읽거나, 새로운 메시지가 보내질 경우 seq를 전달하고 해당 시퀀스에 문제가 있다고 판단 될 경우 페이지네이션 처리된 채팅 데이터를 api를 통해 다시 불러오는 방식으로 구현이 되어 있기 때문에 어느 정도 보완은 되어 있는 구조라고 생각합니다.

대화의 순서 보장은 어떻게

보내야 할 다음 메시지 번호와,

보통 유저당 큐를 사용하는데 소켓 서버 당 큐를 사용한 이유

유저당 큐라는 것은 논리적인 큐로 이벤트 스트림의 subscriber가 동작하고 있는 서버의 유저 개인을 타겟으로 해서 메시지를 받는 구조를 말한다. 즉 a 유저의 stream에는 a유저가 받아야 할 메시지를 큐에 쌓아 받는 방식이다. 해당 방식은 CDC 처럼 큐에서 들어온 a 유저 타겟 데이터를 a 유저가 접속해 있는 서버로 자동을 넘겨주는게 아니라, 매번 pooling 방식으로 가져와야한다. 유저의 수에 따라 O(N)만큼의 감시 스트림 개수가 생기고, 각 서버에 실제 접속해 있는 유저가 누군지에 따라 XREAD 타겟 스트림도 바꿔줘야한다.

따라서 소켓 서버당 하나의 큐를 사용하여 폴링한 메시지는 타겟 유저의 웹소켓으로 Sink.emit 할 수 있도록 하는 방법을 고안하였습니다.

한 유저가 멀티 디바이스로 붙으면?

서로 다른 유저 세션을 가지되, 동일한 userId를 가지므로, 해당 값을 기준으로 sink registry에서 sink에 ref count를 가지도록 구현하였습니다. 결국 하나의 웹소켓 연결만 남은 상황에서 연결이 끊겨 0개가 되었을 때만 sink가 끊기는 구조입니다. 즉 동일한 메시지가 멀티 디바이스 각각의 웹소켓에 전달되는 방식입니다.

서버의 HeartBeat 체크를 네트워크 단절 등의 요인으로 오인하면?

서버의 hearbeat는 최대 3회 체크하며, delay 30초 기준으로 확인한다 1분 이상 3회 이상 서버가 응답을 받지 못한다면, 서버가 죽었다고 판단합니다. 만약 해당 문제가 네트워크 단절이었다 판단하더라도 서버가 살아 있다면 죽은 서버의 PEL을 Xpeding해 다음 heartbeat 체크 시에 새롭게 redis write)

유저의 접속 서버 정보는 어디에 저장? 언제 상태 업데이트? 심지어는 이게 실제 데이터와 다를 수도 있지 않나?

websocket 정상 생성 (handshake 이후) → register

pong 성공시마다 ttl update

종료 시에 unregister

redis가 다운되면 어떻게 되는가? (이건 도움 좀..)

HA 구성 + circuit breaker(fastfail: 빠른 실패로 커넥션 풀 고갈 등으로 서버단 까지 장애 전파 막기)

추가로 mongo에 이벤트 저장 + resume token으로 xack (정상 전달)기준 재시작 가능

  • 받았어야할 메시지는 api 방식 reload
  • 중복은 seq기반 dedup

xClaim이 아니라 xPending 즉 조회를 한 이유

xClaim은 redisStreams의 consumer group을 이용하는 방식 → 서버별 큐에서 할 수 없음

 
무슨 유실 복구 로직? + pipe에 retryWhen을 어떻게 사용? 몇번으로 횟수 제한을 할 경우 결국 손실이 되는거 아닌가?

기본적으로 consume 파이프와 pending 파이프 두개로 나뉩니다 consume 파이프는 repeatWhen으로 성공 시 재구독 하는 방식 + delay 방식 무한 루프이기 때문에 손실이 없습니다.

pending pipe는 서버의 alive 상태에 따라 두가지로 나뉘는데 xPending을 통해 죽었다고 판단한 전체 데이터를 가져오고 새롭게 유저가 접속한 서버에 맞춰 xadd 하는 handledeadsever로직과 아직 살아 있는 서버는 무조건 다음에 pending을 먼저 읽기 때문에 자동으로 자가 재시도를 합니다. 하지만 서버가 살아 있는데 pending으로 읽어도 남은 메시지들 심지어, 해당 파이프 라인을 3번이상 겪은 메시지는 alive pipe line에 문제가 있는 메시지가 들어왔다고 판단 후 ack+delete로 제거 합니다.

 

[기술 선택]

MongoDB Change Streams를 구성하려면 Cluster 구현이 필수인데 어떻게 구현했나 → atlas
ChageStream이 죽으면? (oplog + resume token)
Debezium과 같은 CDC 솔루션은 왜 안썼나?
Kafka는 왜 안썼나?
RSocket 같은거는 왜 안썼나?
Webflux와 코루틴 혼용?
Virtual Thread vs Webflux
만약 Redis가 다운되거나 장애가 생겨서 Dispatcher가 이벤트를 발행하지 못하고 지연될 때, MongoDB가 계속 밀어내는 이벤트 스트림의 배압(Backpressure) 처리
메시지 트래픽 상 polling이 비효율적이라는데 테스트해본건지? 어떤 점이 비효율?
만약 서비스가 장시간 점검에 들어가서 MongoDB oplog 보존 기한(또는 용량)을 넘겨버리면, 유실된 Outbox 이벤트를 어떻게 감지하고 재동기화(Re-sync)할 것인지

서비스점검이 장기화 된다면 resume token에 문제가 발생하므로, 사실상 oplog는 사용할 수 없다. 따라서 실제 outbox 컬렉션 자체를 보고 이벤트를 발급하는 reconcilation 로직을 추가하여 이벤트를 재 동기화 할 수 있을 것이다.

인메모리에 유저 매핑해두었는데 메시지 큐 상에서 user sticky 하지 않으면 결국 시간이 지나면 메모리에 모든 유저 정보가 쌓이는 것 아닌가여

인메모리에 유저 매핑 즉 sink- 웹소켓의 관계성의 경우 userId 기반으로 websocket session이 생길 때 cnt++ 끊길 때 마다 cnt —되고 결국 cnt가 0이 되면 인 메모리에서 지우기 때문에 해당 유저가 웹소켓 연결을 끊고 재 연결 해서 다른 서버로 가더라도 유저정보는 쌓이지 않습니다

[오버 스펙 및 수치]

30000개의 메시지 / 10일 이면 그렇게 크지 않은데, 이렇게 까지 복잡하게 한 이유?
 
기본 시스템 9% 에러는 무슨 수치고, 유실 방지 로직을 추가 했을 때, 왜 1% 감소? → 유실 방지를 넣었는데도 왜 1%가 남는가? (납득이 갈만한 이유인지 확인 좀?)

redis streams 와 유실 방지 로직을 도입 하기 전 POC로 진행했던 pub/sub 채팅 모듈에서 기존 약 100개의 메시지를 상호 테스트 했을 때, 즉 저부하 환경에서도 약 9개의 메시지를 손실했습니다 그리고, 그 손실 과정에 대해 백로그를 알 수 없는 pub/sub의 특성상 영속성을 보강 한다고 해도, 사용할 수 없다라고 판단했습니다. redis Streams를 도입하고, PEL 로직을 도입하고 간단하게 동일한 테스트 및 서버 장애 테스트를 했을 때, 백로그를 통해 접속 메시지 전달 성공률 및 소켓 서버 장에 발생시에 대한 복구 로직은 정상 작동하여 100%, 알림에서 1개의 메시지 손실이 있었기에 1%라고 적었습니다.

이는 단순 저부하 환경 테스트였고, k6와 같은 시나리오 테스트 툴은 시간상의 이유로 사용하지 못했고, 실제 환경에서는 최종 데이터 기반 측정 결과 앞에서 언급한 1.7% 정도의 손실률을 나타내었습니다.

 

3. 결제 정합성 시스템

이건 물어보면 감으로 해 → 시간이 부족하다..

 

2. 인증/인가 시스템

Kubernetes 환경의 Keycloak 을 AWS 로 무중단 이관 이게 도대체 무슨 말인가요? 온프렘 → AWS?

전체 이관 과정

 

[인증 / 인가]

jwt 에서 민감 정보만 숨김 처리하면 session을 사용할 필요 없지 않나?

그러면 두가지 방법 중에 선택을 해야한다.

  1. 매번 유저정보를 keycloak을 통해 호출한다.
  1. 유저 정보를 DB에 저장한다
  • 2번의 경우 유저 정보는 다중 서버 환경에서 여러 데이터 베이스에 있을 경우 중복 등 정합성 처리가 어렵다고 판단해서 제외했고,
  • 1번의 경우 사실 상 uuid를 제외한 이름/전화번호/나이 등의 데이터 모두 유저의 민감 정보라고 판단했기에 session 방식을 사용했다.
기존 jwt 방식과 달리 매번 유저 데이터를 찾기 위해, session 방식에서는 keycloak을 방문 해야하지 않나?

→ spring session redis 캐싱

keycloak이 SPOF 아닌가?

맞다 → redis 캐싱으로 부하 최소화 + envoy나 사이드카 방식도 고려했었다. (적용은 x)

redis에서 데이터는 언제 업데이트 되나?
 
 

[migration]

왜 라이브 동기화를 하지 않았나?

온프레 서버를 이관해야했지만, 실질적으로 DB는 AWS RDS를 사용했기 때문에 replica를 만들다던가 하는 방식으로 원본데이터를 복사하고, CDC나 AWS DMS 같은 동기화 방식으로 쉽게 동기화를 할 수 있을거라 생각했었습니다.

하지만, 단순히 이전만 하는게 아니라, oauth추가 및 전화번호 링킹 커스텀 모듈 추가로 인해, 버전도 바뀌어야 했고, custom provider 관련 테이블이 추가 되는 등 DB 내부 테이블 구조도 바뀌어야 했습니다. 따라서 단순한 DB replica 방식으로 라이브 동기화는 어려움이 있었습니다.

왜 서비스 정지를 하지 않고 운영중에 마이그레이션 하는 방법을 선택했나?

학교 시스템과 연결되는 부분이 많은 서비스였는데 연동 정책이 바뀐게 많아, 기존에 사용하던 공식 api 차단이나, 트래픽 증가에 따른 ip 차단 등 문제가 되는 부분이 많았고, 해당 부분들을 하나씩 해결해 나가고 서비스를 재런칭 한지 얼마 안된 시기였기 때문에, 서비스 안정성에 예민한 시기였습니다.

약간의 기술적 리스크를 가져가더라도, 작은 규모, 새벽 시간 활용, 테스트 환경 검증 등의 리스크를 줄일 수 있는 부분들이 있다는 점과 학생 서비스 특성상 운영적인 면에서 유저 신뢰를 더 중요하게 판단했기에 해당 방식을 선택했습니다.

스키마가 변경되었다면 어떻게 key나 유저realm등을 옮겼나요?

다행히도 keycloak은 모든 설정을 DB 기반으로 관리하고, 버전이 달라도 호환되는 설정을 json 형태로 뽑아서, keycloak 대시보드 자체에서 설정을 DB단으로 저장할 수 있는 import/export 방식이 존재했습니다. 따라서 key라던가 유저 realm이라던가 하는 중요 데이터를 손실 없이 옮길 수 있었습니다.

만약 해당 기능이 없었다면, 해당 부분에 대한 이전 db의 ddl과 현재 db의 ddl 그리고, 테스트로 추가해서 realm이 추가되었을 때, 어떤 식으로 구성되고, 어느 위치에 대응되는지 등을 AI에게 학습시켜 변환 쿼리를 작성해 테스트 유저를 만들어서 각 설정이 정상적으로 옮겨지는 확인하고 점진적으로 마이그레이션 진행했을 것 같습니다.

그럼 1차 동기화가 된 이후에 delta data는 어떻게 추가 했나요? (요것도 봐줘)

해당 이관 작업은 새벽 3시에 최종 배포 하였으며, 마이그레이션 테스트하던 DB나 keycloak을 지우고 실제 배포할 환경을 만든 것은 2시였습니다. 프론트에서도 keycloak 설정에서도 3시 이후부터는 기존 로그인 방식을 지웠기 때문에 딱 1시간 정도의 delta data가 발생할 수 있는 시간이 존재했습니다.

유저 사용량이 적은 3시를 선택했음에도 약 30건의 delta data가 발생했습니다. 해당 데이터는 raw 데이터로 db에 insert 했고, 배포 이후 몇몇 테스트를 실제 운영 환경에서 진행한 이후 insert 했기 때문에 약 30분 가량 동안 카카오 로그인을 진행했을 경우 신규 계정으로 생성되는 문제가 있었습니다. 해당 조건의 유저는 2명이었고, 제가 쿼리로 저희 서비스의 UUID데이터를 새로 생성된 카카오 UUID 데이터로 변경하였습니다. 그외 나머지 delta 데이터의 경우 카카오 로그인을 할 시 전화번호 기반으로 기존 계정에 링킹되기 때문에 문제가 없었습니다.

지금 생각해보면, 이후에 user role update나, 정보 수정 등을 admin api를 사용해서 했는데, 물론 해당 쿼리를 사용했을 때 문제가 없다는 것을 테스트를 했지만, 배치를 이용해서 delta data에 대해 유저 추가 admin api를 사용하는게 더 나은 방향이었을 거라는 생각을 갖고 있습니다.

만약 규모가 큰 환경이라면 delta 데이터가 2건이 아니라 2만건이라 가정하면 어떻게 해야하나요? (음 지금 적어둔게, 근본적인 해결책이 아닌 느낌?)

일단 먼저 서비스 점검 공지와 함께 delta data의 발생 자체를 막을 수 있는게 기본적인 방법이라고 생각합니다. 최소한 write는 못하게 막는 방법만 적용하더라도, 해당 delta data는 발생하지 않습니다.

저희 서비스는 서버 자체를 못쓰게 되는 제한 상황 등의 요인들로, 긴급하게 옮기면서도, 테이블 구조도 변경되는 복잡한 환경이었지만, 미리 준비되어 있는 상태에서 옮기는 방식이라면, read replica를 형성하고, debezium과 같은 CDC 등을 통해, write event를 기록하고, 점진적으로 이벤트를 처리합니다. 그리고 write event를 최소화 할 수 있는 시기에 새로운 db로 승급 시키는 방법을 사용할 것 같습니다. 물론 완벽한 0 delta data는 아니겠지만 최소화 할 수 있다고 생각합니다.

 

[Routing]

도메인-서버-DB를 개별로 만들었다는게 뭔가?

.uoslife.com 도메인 인증서를 이용해서 auth-aws.uoslife.com 도메인을 발급하고, 카카오로그인 전화번호 링킹 등이 포함된 서버를 만들고 auth로 path routing을 통해 해당 ecs-fargate로 가게 설정하고, 새로운 rds를 통해 새로운 keycloak과 연결해서 도메인-서버-DB를 기존 k8s 운영 keycloak과 다르게 aws 환경에서 돌아가는 개별로 구현 해서 이후에 트래픽을 라우팅 했습니다.

와일드 카드 시스템이 뭔가
  • A 레코드 : 도메인 → IPv4
  • CNAME : 도메인 → 도메인 ⇒ *.uoslife.com → uos.uoslife.com
  • AAAA : ipv4 → ipv6

라우팅 과정은 크게 2단계로, 먼저 망 라우팅이 있습니다. *.uoslife.com은 기존에 uos.uoslife.com의 도메인 CNAME을 가지게하고 실제로 교내망과 연결하였습니다. 그리고 apis와 같은 aws 시스템의 경우 라우팅 테이블을 통해 (apis.uoslife.com → ELB)로 매핑하여 ALB로 할당 되는 방식입니다.

따라서 롤링 배포 방식에서 문제가 생기지 않기 위해서는 (auth)*.uoslife.com 으로 학교 서버로 가던 라우팅 경로를 test 환경에서 체크 중인 새로 만든 auth-aws.uoslife.com 의 도메인 주소를 갖는 keycloak에서 정상적임을 확인하고, auth → auth-aws 로 가는 라우팅 설정을 추가해서 메인 서버의 트래픽을 설정 변경 없이 옮기고, 차후에 ecs-fargate의 롤링 배포 방식으로 keycloak을 host 도메인을 바꿔 재배포 하며 auth.com으로 바꾸며 바꾸는 과정에서 트래픽 손실이 없도록 했습니다.

CNAME uos.uoslife.com ⇒ 학교망 내부 온프레의 nginx 노드의 경우 충분히 주소가 변경될 수 있기 때문에 CNAME을 활용했다 (간접 참조 + 다중 진입점 관리)

보편적인 무중단 라우팅 방법? (API gate way기반?, proxy 기반?)

제 케이스는 auth 환경을 온프레에서 AWS로 통째로 옮기는 거라, 유저·경로 단위로 잘게 가를 필요가 없어서 DNS 전환이 가장 단순하고 적합했습니다. 다만 DNS는 TTL 때문에 즉시 롤백이 안 되는 약점이 있어서, 새벽 저트래픽 + 사전 검증으로 실패 확률 자체를 낮추는 쪽으로 보완했어요. 만약 같은 인프라 안에서 버전을 점진 전환하고 빠른 롤백이 필요했다면 ALB weighted routing을, 레거시를 기능 단위로 점진 해체하는 거였다면 리버스 프록시를 골랐을 겁니다.

 

Reverse Proxy

 
 
 
 
 

4. 매칭 N+1 인메모리 Map

N+1은 두가지

  • JPA onetomany 지연로딩
  • 애플리케이션 루프단 단건 조회 여러번

<과거>

UUID 기준 유저 데이터와 학교 데이터를 합쳐서 → 프로필 객체 생성

.map { buildProfile(memberId) }

→ 루프 안에서 한건씩 유저 데이터 + 학교 데이터

<수정>

Before: 1 + N(유저) + N(학과) = 2N + 1 (카드 100장 → 201번)
After : 1(유저 IN) + 1~2(학과 IN, +college) = 상수 (약 3~4번)

in 쿼리로 유저 정보와, 학과 정보를 가져와서 associateBy로 인메모리 맵
⇒ mapping : Map<memberId, Profile>

default_batch_fetch_size: 100

읽는 TR 시점 이 동일 (장점)

 
 

5. AI 활용 MCP + SubAgent

OCR 모델 vs VLM 어떤 쪽이 더 나을까요~?
 
하이브리드 검색이란?
 

TIP

일을 쪼개서 → 빨리끝내는 형태로 바꿔서 기간을 매우 타이트하게 + 시간을 갈아 넣겠다!

 

추천 글

BlogPro logo