반응형

해시 클러스터에서의 해시 몰림

  • 1 ~ 100, 101 ~ 200, 201 ~ 300 키 값이 한 구간에 몰리면 하나의 서버에만 부하가 받게 된다.
  • 클러스터 내에 해시 서버가 추가 또는 삭제되는 경우 리밸런싱 작업이 전체적으로 일어나면서 데이터의 이동과 변경이 발생하게 되고 그 과정에서 사용성이 떨어질 수 있다

해시 클러스터 내에서 안정적인 해시 운영을 위한 방법으로 다양한 방법이 있지만 안정해시라는 개념을 통해 해시 클러스터 내에 해시 데이터를 안정적으로 관리한다.

안정 해시의 컨셉

  • 안정 해시의 컨셉은 링이다
  • 링에 의해 한방향으로 가까운 노드에 해시 값을 저장 또는 삭제한다
  • 노드가 추가되면 링 위에 새로운 노드가 위치하고 한방향으로 가까운 노드만 리밸런싱을 하게 되어 다른 노드들은 영향을 받지 않게 된다

안정 해시의 문제점

  • 안정 해시도 결국 쏠림 현상이 발생할 수 있다
  • 이벤트성 트래픽에서는 특정 데이터에 몰릴 수 있는 문제가 생길 수 있다

안정 해시 쏠림 현상 개선

  • 가상 노드를 통해 링 위에 가상 노드를 다른 노드들 사이에 배치한다
  • 가상 노드의 수를 늘릴 수 록 노드 하나가 담당하게 되는 파티션의 크기가 작아지기 때문에 데이터가 균등하게 분포하게 된다

설계

  • CAP : AP(가용성 + 분단 허용성)
    • 일관성을 유지함에 따라 분단 허용성을 지킬 수 없음
    • 결과적 일관성을 통한 일관성 유지
  1. 링형 아키텍처 적용(양 끝을 연결한다는 것으로 논리적으로 링형)
    • 해시 키에 인접한 노드를 찾아감
  2. Virtual Node 적용
    • 가상 노드를 링 위에 인접하지 않도록 배치하여 적재되는 데이터의 분포를 균등하게 함
  3. 적재되는 데이터를 인접 노드에 함께 저장하여 장애 내성을 갖춤
  4. 해시키 범위 별 참여하고 있는 Lookup 테이블을 관리하는 방식 적용
  5. 데이터 저장시 저장 성공을 위한 성공 Node 수 관리

아키텍처 구성

처리 흐름

get

sequenceDiagram
    autonumber
    participant Client as 클라이언트
    participant Coord as LB
    participant Table as Lookup 테이블
    participant NodeA as Node A 
    participant NodeB as Node B 
    participant NodeC as Node C 

    Client->>Coord: GET(Key)
    activate Coord

    Coord->>Table: hash 값이 속한 담당 범위 조회
    activate Table

    Table-->>Coord: Primary = NodeA<br/>Replica Set = [NodeB, NodeC] (링 상 다음 N-1개 노드)
    deactivate Table

    Coord->>NodeA: GET(Key) 요청 [Primary]
    activate NodeA

    alt NodeA 정상 응답
        NodeA-->>Coord: Value 반환
    else NodeA 장애 / 타임아웃
        deactivate NodeA
        Coord->>NodeB: GET(Key) 요청 [인접 Node]
        activate NodeB
        alt NodeB 정상 응답
            NodeB-->>Coord: Value 반환
        else NodeB도 장애
            deactivate NodeB
            Coord->>NodeC: GET(Key) 요청 [다음 인접 Node]
            activate NodeC
            NodeC-->>Coord: Value 반환
            deactivate NodeC
        end
    end

    Coord-->>Client: Key : Value 응답
    deactivate Coord

put

sequenceDiagram
    autonumber
    participant Client as 클라이언트
    participant Coord as LB
    participant Table as Lookup 테이블
    participant NodeA as Node A
    participant NodeB as Node B
    participant NodeC as Node C

    Client->>Coord: PUT(Key, Value)
    activate Coord

    Coord->>Table: hash 값이 속한 담당 범위 조회
    activate Table
    Table-->>Coord: NodeA<br/>인접 노드 = [NodeB, NodeC] (N-1개 노드)
    deactivate Table

    Coord->>NodeA: PUT(Key, Value) 저장 요청 [Primary]
    activate NodeA
    NodeA-->>Coord: ACK (저장 성공)
    deactivate NodeA

    Coord-->>Client: PUT 성공 응답
    deactivate Coord

    par 인접 노드 비동기 복제
        Coord->>NodeB: PUT(Key, Value) 복제 [인접 Node]
        activate NodeB
        NodeB-->>Coord: ACK (복제 완료)
        deactivate NodeB
    and
        Coord->>NodeC: PUT(Key, Value) 복제 [다음 인접 Node]
        activate NodeC
        NodeC-->>Coord: ACK (복제 완료)
        deactivate NodeC
    end

인접 노드에 대한 복제 시 비동기 워커를 통한 복제
이때 몇개의 노드에 복제되었을 때 저장이 완료되게 할지에 대한 설정 필요

Cluster에 서버 추가

sequenceDiagram
    autonumber
    participant New as 신규 서버
    participant Coord as LB
    participant Table as Lookup 테이블
    participant Succ as 기존 인접 서버 (승계자)

    New->>Coord: 클러스터 참여 요청 (Join)
    activate Coord

    Coord->>Coord: 신규 서버의 가상 서버 해시 위치 계산

    Coord->>Table: 가상 서버 위치를 링에 삽입
    activate Table
    Table->>Table: 삽입 위치 기준 시계방향으로<br/>가장 가까운 기존 가상 서버 탐색<br/>→ 분할될 범위 산정
    Table-->>Coord: 이관 대상 범위 + 기존 담당 서버 반환
    deactivate Table

    Coord->>Succ: 분할 범위에 해당하는 Key 이관 요청
    activate Succ
    Succ->>New: 해당 범위 Key:Value 복사
    activate New
    New-->>Succ: 이관 완료 ACK
    deactivate New
    Succ-->>Coord: 마이그레이션 완료 보고

    Coord->>Table: Lookup 테이블 갱신
    activate Table
    Table-->>Coord: 갱신 완료
    deactivate Table

    Coord->>Succ: 이관 완료된 Key 삭제
    Succ-->>Coord: 삭제 완료
    deactivate Succ

    Coord-->>New: 서버 추가 완료 응답
    deactivate Coord

Cluster에 서버 삭제

sequenceDiagram
    autonumber
    participant Leave as 제거 대상 서버
    participant Coord as LB
    participant Table as Lookup 테이블
    participant Succ as 인접 서버 (승계자)

    Leave->>Coord: 클러스터 제거 요청
    activate Coord

    Coord->>Table: 이탈 서버의 가상 서버 위치 조회
    activate Table
    Table->>Table: 링 상에서 시계방향으로<br/>가장 가까운 다음 가상 서버 탐색<br/>→ 승계할 서버 결정
    Table-->>Coord: 이관 대상 범위 + 승계 서버 반환
    deactivate Table

    Coord->>Leave: 보유 중인 Key 전체 이관 요청
    activate Leave
    Leave->>Succ: 담당 범위 Key:Value 복사
    activate Succ
    Succ-->>Leave: 이관 완료 ACK
    deactivate Succ
    Leave-->>Coord: 마이그레이션 완료 보고
    deactivate Leave

    Coord->>Table: Lookup 테이블 갱신
    activate Table
    Table-->>Coord: 갱신 완료
    deactivate Table

    Coord-->>Leave: 클러스터 제외 및 종료 승인
    deactivate Coord
반응형
반응형

이 책은 애자일에 대한 책으로 가치있는 것을 빠르게 보여주고 피드백 받으면서 목표를 달성하기 위한 수단과 방법을 소개하는 책이다.

 

크게 계획하고 나중에 한 번에 보여주는 것이 아니라, 작게 쪼개서 지금 보여주는 것.

자주 틀리고 빠르게 수정하는 것이 한 번에 완벽하게 맞추려는 것보다 훨씬 낫다는 이야기다.

 

이 책을 보고 든 이미지는 아래와 같다.


출발점에서 목표까지 일직선으로 달리는 것이 아니라, 방향을 확인하며 조금씩 꺾어가는 화살표이다.

중요한 건 목표에 가까워지고 있느냐다.

 

우리가 애자일한 방법을 추구해야하는 이유는 아래의 워터폴의 문제를 들여다 보면 공감할 수 있다.

처음에 완벽하게 계획했다고 생각했던 것이 끝에 다다를 즘에 틀렸다는 걸 알게 된다.

하지만 그때는 이미 되돌아가기엔 너무 늦는다.

 

물론 애자일이 항상 옳은 것은 아니다.

요구사항이 처음부터 명확하게 고정된 프로젝트라면 굳이 애자일일 필요가 없을 것이다.

 

하지만 대부분의 소프트웨어 개발은 그렇지 않다.

요구사항은 바뀌고, 사용자가 원하는 것도 써봐야 안다.

그 불확실한 환경에서 피드백 루프를 통해 방향을 조정할 수 있다는 것, 그것이 애자일의 분명한 강점이다.


 

애자일에 대한 AI의 영향에 대한 생각

애자일의 핵심은 단순하다. 가치를 빠르고 자주 전달하는 것이다.

짧은 주기로 만들어서 보여주고, 피드백을 받아 방향을 수정한다.

그리고 이 사이클을 빠르게 돌릴수록 좋다.

 

AI는 이 사이클을 실질적으로 빠르게 만들어준다.

반복적인 코드 작성, 테스트 생성, 버그 수정에 개발자가 시간을 많이 쏟던 영역들은

AI에 의해 더욱 빠르게 처리된다.

 

결과적으로 더 자주 배포할 수 있고, 피드백을 더 빨리 받을 수 있고, 방향을 더 일찍 수정할 수 있다.

애자일이 원하는 사이클이 AI로 인해 비로소 완성되어가고 있는 모습이다.

 

애자일의 조력자 AI

AI가 애자일에 가져다주는 것들은 결국 하나의 방향으로 수렴한다.

사람이 해야 할 일에 더 집중할 수 있게 해준다는 것.

 

애자일에서 사람만이 할 수 있는 일이 있다.

무엇을 만들지 판단하는 것, 가치를 정의하는 것, 피드백을 해석하는 것, 팀이 같은 방향을 바라보게 하는 것.

이것들은 AI가 대신할 수 없다.

그리고 아이러니하게도, AI가 반복 작업을 빠르게 처리해줄수록 사람은 이 일에 더 많은 시간을 쏟을 수 있다.

 

"가치를 빠르고 자주 전달하는 것" 빠른 것은 수단이고 가치는 목적이다.

AI는 그 수단을 강력하게 만들어줬다.

 

경계해야할 것은?

AI가 빠르게 실행해줄수록 속도 자체가 목적이 되는 순간이 온다.

단순하게 빠르게 라는 것에만 매몰되면 애자일이 말하는 가치는 옅어지게 된다.

 

빠른 것은 수단이고 가치는 목적이다.

AI는 그 수단을 강력하게 만들어줬다.

목적을 잊지 않는 건 여전히 사람의 몫이다.

 

그렇다면 우리는 무엇을 해야 하는가

AI 시대에 개발자에게 필요한 것은 더 빠르게 쓰는 능력이 아니다.

멈추고 생각하는 능력, 가치를 판단하는 능력, 방향을 잡는 능력.

아이러니하게도 AI가 발전할수록 이 능력의 가치는 더 높아진다.

 

AI가 빠르게 달려줄수록, 어디로 달릴지 결정하는 사람의 역할은 더 중요해진다.

그것이 AI 시대에 애자일이 작동하는 방식일 것이다.

 

 

[References]

 

 

 

 

 

 

반응형

'' 카테고리의 다른 글

[책읽기] AI 시대를 관통하는 과학적 관리법  (0) 2026.03.30
반응형

Spring Batch에서 JpaPagingItemReader 같은 Paging 기반 Reader는 처음에는 꽤 합리적인 선택처럼 보인다.

한 번에 많은 데이터를 메모리에 올리지 않아도 되고, 일정 크기로 잘라 읽는 방식이어서 대량 처리에도 잘 어울려 보이기 때문이다.

 

그런데 실제 운영 환경에서는 이런 현상을 자주 만난다.

  • 처음 몇 페이지는 빠르다.
  • 중간 페이지부터 점점 느려진다.
  • 뒤 페이지로 갈수록 같은 page size인데도 처리 시간이 더 길어진다.
  • 전체 데이터가 늘수록 배치 시간이 비정상적으로 증가한다.

이러한 현상의 핵심 원인은 대부분 offset 기반 pagination의 구조적 비용에 있다.


문제는 "읽는 건수"보다 "버리는 건 수"에 있다

많은 사람이 Paging Reader를 이렇게 이해한다.

한 번에 1000건씩 읽으니까, 매 페이지 비용도 비슷하겠지.

 

겉으로 보면 맞는 말처럼 보인다.

실제로 애플리케이션이 받아오는 건수는 매번 limit 1000으로 동일하다.

하지만 DB가 그 1000건을 어떻게 찾아서 반환하는지까지 보면 이야기가 달라진다.

 

예를 들어 아래 같은 쿼리를 생각해보자.

select id, amount, created_at
from payment
where status = 'READY'
order by id asc
limit 1000 offset 0;

첫 페이지는 비교적 단순하다. 앞에서 버릴 데이터가 없기 때문이다.

 

그런데 500번째 페이지를 읽으면 쿼리는 이렇게 바뀐다.

select id, amount, created_at
from payment
where status = 'READY'
order by id asc
limit 1000 offset 499000;

 

마지막 구간에 가까워지면 이런 식이 된다.

select id, amount, created_at
from payment
where status = 'READY'
order by id asc
limit 1000 offset 900000;

애플리케이션은 여전히 1000건만 받는다.

 

하지만 DB는 그 1000건을 주기 전에 앞의 900000건을 지나가야 할 수도 있다.

즉, Paging Reader가 뒤 페이지로 갈수록 느려지는 이유는 더 많이 가져와서가 아니라, 더 많이 버려야 하기 때문이다.


Paging Reader는 실제로 어떻게 동작하는가

Spring Batch의 Paging Reader는 개념적으로 다음 흐름으로 동작한다.

  1. page 0 조회
  2. page 1 조회
  3. page 2 조회
  4. ...
  5. 마지막 page까지 반복

예를 들어 page size가 1000이면 내부적으로는 이런 조회가 이어진다.

-- 1 page
limit 1000 offset 0

-- 2 page
limit 1000 offset 1000

-- 3 page
limit 1000 offset 2000

-- 4 page
limit 1000 offset 3000

이 구조는 웹 화면에서 몇 페이지 정도 넘겨보는 정도라면 큰 문제가 아닐 수 있다.

 

하지만 배치는 다르다.

배치는 보통 첫 페이지부터 마지막 페이지까지 전부 읽는다.

즉, 큰 offset이 "가끔" 등장하는 것이 아니라, 언젠가 반드시 등장한다.

 

그래서 배치에서 Paging Reader의 성능 문제는 예외 상황이 아니라 구조적으로 누적되는 문제다.


offset 기반 pagination의 비용 모델

offset pagination의 핵심은 다음 두 숫자다.

  • limit: 최종적으로 반환할 건수
  • offset: 반환 전에 건너뛸 건수

많은 경우 limit는 작고 고정되어 있다. 예를 들어 1000건이다. 반면 offset은 페이지가 뒤로 갈수록 계속 커진다.

이때 비용을 지배하는 것은 대개 limit보다 offset이다.

 

예를 들어 총 100만 건이 있고, page size가 1000이라고 해보자.

  • 1페이지: offset 0
  • 100페이지: offset 99000
  • 500페이지: offset 499000
  • 1000페이지: offset 999000

반환 건수는 매번 1000건으로 같지만, DB가 도달해야 하는 위치는 점점 멀어진다.

이 때문에 뒤 페이지는 앞 페이지보다 느릴 가능성이 높다.

 

각 페이지의 반환 건수는 같지만, 각 페이지의 도달 비용은 같지 않다.

DB는 offset을 보고 "순간이동"하지 못한다

Paging을 사용하는 경우 보통 이렇게 생각을 하게 된다.

offset 900000이면 DB가 그냥 900001번째부터 바로 읽으면 되는 것 아닌가?

 

이렇게 생각하기 쉽지만, 실제 DB는 그렇게 단순하게 움직이지 않는다.

DB는 쿼리 조건과 정렬 조건을 만족하는 결과 집합을 만들고, 그 결과 중 앞의 offset개를 건너뛴 뒤 limit개를 반환해야 한다.

 

즉, offset 900000 limit 1000은 논리적으로 다음과 같은 의미에 가깝다.

  1. 조건에 맞는 후보를 찾는다.
  2. 정렬 기준에 맞춰 순서를 정한다.
  3. 앞의 900000건을 지난다.
  4. 그 다음 1000건을 반환한다.

DB 엔진과 실행 계획에 따라 세부 동작은 다르지만, 핵심은 같다.

큰 offset은 "바로 건너뛰는 숫자"가 아니라, 시스템이 감당해야 하는 작업량의 일부다.


정렬이 들어가면 왜 더 부담스러워질 수 있을까

Paging 쿼리는 거의 항상 order by와 함께 사용된다.

그런데 이 정렬은 생각보다 비용에 큰 영향을 준다.

 

예를 들어 아래 쿼리를 보자.

select id, amount, created_at
from payment
where status = 'READY'
order by created_at asc
limit 1000 offset 900000;

여기서 DB는 단순히 900001번째 행을 찾는 것이 아니라:

  • status = 'READY' 조건을 만족하는 대상을 찾고
  • created_at asc 순서를 맞추고
  • 그 순서에서 앞의 900000건을 건너뛴 뒤
  • 다음 1000건을 반환해야 한다

만약 where 조건과 order by를 함께 잘 받쳐주는 인덱스가 없다면, 부담은 더 커진다.

  • 더 많은 row 접근이 필요할 수 있다.
  • 정렬을 위한 추가 작업이 필요할 수 있다.
  • 임시 정렬 영역이나 filesort 성격의 비용이 발생할 수 있다.

뒤 페이지 성능 저하는 단순히 offset 숫자만의 문제가 아니라, 조건 + 정렬 + offset이 결합되며 커지는 경우가 많다.


인덱스가 있어도 왜 문제가 완전히 사라지지 않을까

이 주제를 쓰면 반드시 나오는 질문이 있다.

인덱스만 잘 잡으면 해결되는 것 아닌가?

 

인덱스는 분명 중요하다.

그리고 실제로 성능을 상당히 개선해줄 수 있다.

하지만 여기서 중요한 것은 인덱스가 offset pagination을 근본적으로 바꾸지는 못한다는 점이다.

 

인덱스가 잘 잡혀 있으면 다음이 좋아질 수 있다.

  • 조건 필터링 비용 감소
  • 정렬 비용 감소
  • Full scan 회피

하지만 인덱스가 있어도 큰 offset에서는 여전히 이런 부담이 남는다.

  • 인덱스 상에서 많은 엔트리를 따라가야 할 수 있다.
  • 원하는 위치에 도달하기 전까지 여러 row를 지나가야 한다.
  • 뒤 페이지일수록 누적 탐색 비용이 커진다.

인덱스는 offset paging을 덜 나쁘게 만들 수는 있어도, 뒤 페이지로 갈수록 느려지는 구조 자체를 없애지는 못한다.


배치에서 이 문제가 더 위험한 이유

웹 서비스의 목록 조회라면 사용자가 1페이지나 2페이지 정도만 보고 떠나는 경우가 많다.

심지어 100페이지 이후를 실제로 보는 사용자는 거의 없을 수도 있다.

 

하지만 배치는 다르다.

  • 첫 페이지부터 마지막 페이지까지 모두 읽는다.
  • 중간 페이지에서 멈추지 않는다.
  • 매일, 혹은 주기적으로 반복된다.
  • 데이터가 늘수록 마지막 offset도 같이 커진다.

웹에서는 offset 문제가 부분적으로 드러날 수 있지만, 배치에서는 그 비용이 항상 끝까지 누적된다.

 

예를 들어 오늘 10만 건이던 테이블이 몇 달 뒤 500만 건이 되면, page size가 그대로여도 뒤쪽 페이지 비용은 훨씬 무거워진다.

그래서 초기에는 별문제가 없어 보이던 배치가 운영 몇 달 후 갑자기 느려지는 경우가 생긴다.

실무에서는 이 패턴이 꽤 흔하다.

 

 

처음에는 충분히 빨랐는데, 데이터가 쌓이자 어느 날부터 새벽 배치가 윈도우 안에 끝나지 않는다.

 

이때 원인은 코드 복잡도보다도, 대개 offset 기반 순회 구조의 누적 비용에 있는 경우가 많다.


숫자로 보면 왜 후반부가 더 느린지 더 직관적이다

총 100만 건, page size 1000이라고 해보자.

각 페이지는 아래 정도의 skip 비용을 가진다.

  • 1페이지: 0건 skip
  • 10페이지: 9000건 skip
  • 100페이지: 99000건 skip
  • 500페이지: 499000건 skip
  • 1000페이지: 999000건 skip

마지막 페이지는 1000건을 받기 위해 앞의 거의 100만 건에 가까운 위치를 지나가야 할 수 있다.

 

이 관점에서 보면 page size가 같다는 사실은 큰 의미가 없다.

중요한 것은 최종적으로 몇 건을 반환하느냐가 아니라, 거기 도달하기 위해 얼마나 많은 데이터를 지나가야 하느냐다.


Spring Batch 코드로 보면 왜 더 헷갈리기 쉬운가

Spring Batch 코드만 보면 Paging Reader는 아주 단순해 보인다.

@Bean
fun paymentPagingReader(entityManagerFactory: EntityManagerFactory): JpaPagingItemReader<Payment> {
    val reader = JpaPagingItemReader<Payment>()
    reader.setName("paymentPagingReader")
    reader.setEntityManagerFactory(entityManagerFactory)
    reader.setPageSize(1000)
    reader.setQueryString(
        """
        select p
        from Payment p
        where p.status = :status
        order by p.id asc
        """.trimIndent()
    )
    reader.setParameterValues(
        mapOf("status" to PaymentStatus.READY)
    )
    return reader
}

애플리케이션 코드 관점에서는 그저 "1000건씩 잘 읽는 Reader"로 보인다.

그래서 많은 사람이 page size, chunk size, thread 수 같은 애플리케이션 설정만 튜닝하려고 한다.

 

하지만 실제 병목은 Reader 바깥, 즉 DB가 뒤 페이지에 도달하기 위해 치르는 offset 비용에 있을 수 있다.

그래서 이 문제는 Spring Batch 설정값만 바꿔서는 해결되지 않는 경우가 많다.


page size를 키우면 해결될까

이것도 자주 시도하는 대응이다.

  • 1000건이 느리면 5000건으로 키워본다.
  • 5000건이 느리면 10000건으로 키워본다.

물론 page 수가 줄어들기 때문에 전체 쿼리 횟수는 감소할 수 있다.

그래서 일정 수준의 개선은 있을 수 있다.

 

왜냐하면 page size를 키워도 여전히 offset 기반이면, 뒤 페이지는 여전히 큰 offset을 가진다.

단지 페이지 개수가 줄어들 뿐이다.

게다가 page size를 너무 키우면 다음 문제가 생긴다.

  • 한 번에 읽는 row 수 증가
  • 메모리 사용량 증가
  • 처리 시간 증가
  • 트랜잭션 부담 증가

page size 조정은 튜닝 포인트일 수는 있어도, 뒤 페이지 성능 저하의 근본 원인을 없애지는 못한다.


해결 방향: offset이 아니라 기준점으로 읽는다

이 문제를 근본적으로 줄이려면 페이지 번호를 기준으로 읽는 대신, 마지막으로 읽은 기준점을 기준으로 다음 데이터를 가져오는 방식이 더 적합하다.

흔히 keyset pagination 또는 seek 방식이라고 부른다.

 

예를 들어 아래 같은 쿼리다.

select id, amount, created_at
from payment
where status = 'READY'
  and id > :lastId
order by id asc
limit 1000;

이 방식은 offset 900000처럼 큰 숫자를 가지고 앞 데이터를 버리지 않는다.

 

대신 마지막으로 읽은 id 다음부터 바로 이어서 읽는다.

  • offset 방식: 몇 번째부터 읽을지 계산한다.
  • keyset 방식: 어디서부터 이어 읽을지 지정한다.

배치에서는 후자가 훨씬 잘 맞는다.

배치는 순차적으로 끝까지 읽는 작업이기 때문이다.


Kotlin 예시: key 기준으로 다음 페이지 읽기

fun findNextPage(lastId: Long, pageSize: Int): List<Payment> {
    return entityManager.createQuery(
        """
        select p
        from Payment p
        where p.status = :status
          and p.id > :lastId
        order by p.id asc
        """.trimIndent(),
        Payment::class.java
    )
        .setParameter("status", PaymentStatus.READY)
        .setParameter("lastId", lastId)
        .setMaxResults(pageSize)
        .resultList
}

이 방식의 장점은 분명하다.

  • 뒤 페이지로 갈수록 offset이 커지지 않는다.
  • 마지막 기준점 이후만 읽기 때문에 순차 처리에 적합하다.
  • 대량 데이터에서 후반부 성능 저하를 줄이기 쉽다.

물론 이 방식도 정렬 기준과 인덱스 설계가 중요하다.

하지만 적어도 "앞의 수십만 건을 버리고 다음 1000건을 읽는" 구조는 피할 수 있다.


마무리

Paging Reader가 뒤 페이지로 갈수록 느려지는 이유는 단순히 데이터가 많아서가 아니다.

offset 기반 조회가 뒤쪽으로 갈수록 더 많은 row를 건너뛰어야 하는 구조이기 때문이다.

 

하지만 Paging Reader를 사용하는 것 자체가 문제는 아니다.

데이터 양이 크지 않고, 조회 범위가 제한적이며, 뒤 페이지까지 깊게 순회하지 않는 상황이라면 Paging 방식은 여전히 단순하고 실용적인 선택이 될 수 있다.

 

결국 중요한 것은 Reader의 이름이 아니라 데이터의 양, 조회 패턴, 정렬 방식, 그리고 배치가 끝까지 어떤 방식으로 순회하는가다.

데이터가 계속 커지고 끝 페이지까지 모두 읽어야 하는 배치라면 offset 기반 Paging이 점점 불리해질 수 있고, 반대로 특정 구간만 가볍게 읽는 작업이라면 충분히 적합할 수도 있다.

 

배치에서는 "익숙한 Reader를 쓰는가"보다 현재 데이터 규모와 운영 상황에 맞는 Reader를 선택하고 있는가를 먼저 봐야 한다.

Paging Reader도 그 선택지 중 하나일 뿐이고, 상황에 따라서는 Cursor Reader나 keyset 방식이 더 적합할 수 있다.

반응형

'공부' 카테고리의 다른 글

안정 해시  (0) 2026.07.18
Redis Sorted Set (ZSet) 들여다 보기  (1) 2026.04.10
대기열 - 시스템 안정성 높이기  (0) 2026.04.02
Command와 Event  (0) 2026.03.27
서킷브레이커를 알았더라면  (0) 2026.03.20
반응형

들어가며

개발을 하다보면 "정렬된 상태를 유지해야 하는" 상황을 꽤 자주 마주친다.

이런 문제를 처음 마주쳤을 때 "DB에서 ORDER BY 하면 되지 않나?"라는 생각이 들게 되는데,

트레픽이 많은 경우에 매번 테이블을 조회하여 정렬된 데이터를 가져오는 것은 부담이 될 수 있다.

 

Redis의 Sorted Set(ZSet)을 활용하면 이러한 문제에서 조금 자유로울 수 있다.

데이터를 넣는 순간부터 항상 정렬된 상태를 유지하여 따로 정렬할 필요가 없다.


ZSet이란 무엇인가

ZSet은 각 멤버(member)에 score라는 숫자 값을 부여하고, 항상 score 기준으로 정렬된 상태를 유지하는 자료구조다. 일반 Set에 "순서"라는 차원이 하나 더 추가된 것이라고 봐도 된다.

  Set ZSet
중복 허용
순서 보장 ✅ (score 기준)
score

 

ZSet이 항상 보장하는 두 가지 성질이 있다.

유일성 — 같은 멤버는 하나만 존재한다. 이미 있는 멤버를 추가하면 score만 갱신

정렬 — 멤버는 score 오름차순으로 정렬된다. score가 동일하면 멤버 이름을 사전순으로 비교해 순서를 결정

 

단순해 보이지만, 이 두 성질을 삽입, 삭제가 발생할 때 마다 이를 위지하느 것은 꽤 부담이되어 보인다.

어떻게 이를 해결했을까?


내부 자료구조

ZSet의 성능 비결은 내부에서 두 가지 자료구조를 동시에 유지한다는 데 있다. 그 전에, 데이터 크기에 따라 구조 자체가 달라진다는 점을 먼저 이해하면 좋다.

인코딩 전략 — 작을 때와 클 때

ZSet은 데이터 규모에 따라 내부의 방식을 자동 전환한다.

멤버 수 ≤ 128  AND  각 멤버 크기 ≤ 64 bytes
  → listpack  (연속된 메모리 블록, 오버헤드 최소화)

그 외
  → skiplist + hashtable  (빠른 탐색과 조회를 위한 구조)

데이터가 적을 때는 단순한 연속 메모리 구조인 listpack을 써서 메모리를 절약한다.

그러다 임계값을 넘는 순간 자동으로 skiplist 기반 구조로 전환된다.

이 임계값은 zset-max-listpack-entrieszset-max-listpack-value 설정으로 조정할 수 있다.

 

실제로 운영 환경에서 ZSet을 쓰다 보면 어느 시점부터 메모리 사용량이 갑자기 늘어나는 것처럼 보이는 경우

대부분 이 인코딩 전환이 일어난 시점이다.


Skip List — 정렬과 범위 조회 담당

임계값을 넘어서면 ZSet은 Skip List로 전환된다.

Skip List는 연결 리스트에 "지름길" 역할을 하는 포인터를 여러 레벨로 추가한 자료구조다.

레벨 4 │ ──────────────────────────────────── [100]
레벨 3 │ ─────────── [30] ────────────────── [100]
레벨 2 │ ──── [10] ─ [30] ──── [60] ───────── [100]
레벨 1 │ [5]─[10]─[20]─[30]─[40]─[60]─[80]─[100]
                       ↑ score 오름차순

탐색할 때는 레벨이 높은 곳에서 시작해 목표 범위를 빠르게 좁힌다.

노드의 레벨은 삽입 시 확률적으로 결정되는데, Redis는 레벨업 확률로 25%를 사용한다.

이 덕분에 균형 트리(AVL, Red-Black Tree)와 비슷한 O(log N) 성능을 내면서도 구현이 훨씬 단순하다.

 

Skip List는 범위 기반 탐색에 특히 강하다.

"score가 100 이상 500 이하인 멤버 전부"를 가져오는 ZRANGEBYSCORE 같은 명령이 빠른 이유다.

연결 리스트 특성상 범위 내 다음 노드를 순서대로 따라가면 되기 때문이다.

 

span 필드

Redis의 Skip List는 각 포인터에 span이라는 값을 저장한다.

span은 그 포인터가 건너뛰는 노드 수를 의미한다. 포인터를 따라 이동하면서 span 값을 누적하면,

별도의 계산 없이 탐색과 동시에 rank(순위)를 구할 수 있다.

이 덕분에 ZRANK나 ZREVRANK가 O(log N)에 동작할 수 있다.


 

Hash Table — score 단건 조회 담당

Skip List는 범위 조회에 강하지만, "alice의 score가 정확히 얼마냐"는 질문에는 O(log N)이 걸린다.

전체 리스트를 탐색해야 하기 때문이다.

 

이걸 해결하기 위해 Redis는 member → score 매핑을 담은 Hash Table을 병행해서 유지한다.

Hash Table 덕분에 ZSCORE 명령은 O(1)에 답을 준다.

Hash Table │ { "alice": 1500.0, "bob": 1320.0, "carol": 1750.0 }
Skip List  │ bob(1320) → alice(1500) → carol(1750)

두 구조는 쓰기(삽입·삭제·갱신)가 일어날 때마다 함께 업데이트 된다.

쓰기 비용이 조금 늘어나지만, 읽기 패턴이 다양한 ZSet의 특성에 적합한 트레이드오프로 볼 수 있다.

 

결과적으로

범위 조회는 Skip List가, score 단건 조회는 Hash Table이 담당한다.


/* check if the element is too large or the list
 * becomes too long *before* executing zzlInsert. */
if (zzlLength(zobj->ptr)+1 > server.zset_max_listpack_entries ||
    sdslen(ele) > server.zset_max_listpack_value ||
    !lpSafeToAdd(zobj->ptr, sdslen(ele)))
{
    zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, zsetLength(zobj) + 1);
} else {
    zobj->ptr = zzlInsert(zobj->ptr,ele,score);
    if (newscore) *newscore = score;
    *out_flags |= ZADD_OUT_ADDED;
    return 1;
}

 

위 소스는 실제 레디스 소스를 일부 가져온 것인데, 특정 임계 치를 넘어가게 되면 Zset의 구조를 전환하는 코드를 확인할 수 있다(zsetConvertAndExpand)


 

 

마치며

ZSet은 정렬된 데이터를 빠르게 가져올 때 쉽게 사용하는데, 그 안에는 Skip List와 Hash Table을 역할에 따라 나눠 쓰는 꽤 정교한 설계가 들어 있다.

동작 구조를 살펴보니 해당 자료구조에 대한 이해도를 높일 수 있었고, 이를 통해 개발이나 운영 때 이러한 전환 구조에 따른 변화를 고려하고 참고 할 수 있을 것 같다.

 

 

참고

1) https://redis.io/docs/latest/develop/data-types/sorted-sets

2) https://github.com/redis/redis/blob/unstable/src/t_zset.c

반응형

'공부' 카테고리의 다른 글

안정 해시  (0) 2026.07.18
처음엔 빠른데 왜 나중엔 느릴까? 배치 Paging Reader의 함정  (1) 2026.04.16
대기열 - 시스템 안정성 높이기  (0) 2026.04.02
Command와 Event  (0) 2026.03.27
서킷브레이커를 알았더라면  (0) 2026.03.20
반응형

대기열은 왜 필요한가?

트래픽이 갑자기 몰리면 서버는 쉽게 무너진다.

티케팅, 선착순 쿠폰, 오픈런 이벤트 — 공통점은 하나다. 평소보다 몇십 배의 요청이 짧은 시간에 집중된다는 것이다.

이 문제를 해결하는 방법이 대기열(Waiting Queue)이다.

 

서버는 슈퍼맨이 아니다

Spring Boot 서버가 요청을 받으면 Tomcat 스레드 풀에서 스레드 하나를 꺼내 처리한다.

기본 설정은 최대 200개. 즉, 동시에 201번째 요청이 들어오는 순간부터는 앞선 요청이 끝나기를 기다려야 한다.

 

DB도 마찬가지다.

커넥션 풀(HikariCP)은 기본 최대 10개의 커넥션을 유지한다.

11번째 요청이 DB를 쓰려는 순간, 커넥션이 반환될 때까지 블로킹된다.

 

평소에는 요청 하나가 수십 ms 안에 끝나기 때문에 스레드와 커넥션이 빠르게 반환된다.

그런데 트래픽이 몇십 배로 폭증하면 이야기는 달라진다.

 

대기열이 없으면 어떤 일이 생기나

스레드 풀이 가득 찬다

요청이 폭발적으로 들어오면 200개의 Tomcat 스레드가 모두 점유된다.

이후 요청은 Tomcat 내부 accept queue에서 대기하다가, 그마저 넘치면 Connection refused로 거절된다.

[요청 폭주]
→ Tomcat 스레드 200개 전부 점유
→ 새 요청: 대기 or Connection refused
→ 응답 시간 급증

커넥션 풀이 고갈된다

스레드들이 각자 DB 커넥션을 잡고 처리 중이다.

커넥션이 10개인데 스레드가 200개면, 190개의 스레드는 커넥션을 기다리며 블로킹된다.

기다리는 시간이 길어질수록 스레드는 더 오래 묶이고, 새 요청은 더 오래 기다리는 악순환이 시작된다.

[DB 커넥션 대기]
→ 스레드가 커넥션을 못 잡고 블로킹
→ 스레드 점유 시간 증가
→ 결국 전체 스레드 고착 (thread starvation)

타임아웃 폭탄이 터진다

HikariCP의 기본 connectionTimeout은 30초다. 30초 동안 커넥션을 못 받으면 예외를 던진다.

수백 개의 요청이 동시에 30초를 기다렸다가 동시에 실패하면, 클라이언트의 재시도가 또 다른 폭주를 만든다.

상황은 오히려 더 악화된다.

연쇄 장애(Cascade Failure)로 번진다

문제는 한 서버에서 끝나지 않는다. MSA 환경에서는 서비스들이 서로를 호출한다.

주문 서비스가 느려지면, 연관된 다른 서비스도 영향을 받아 연쇄 장애로 번질 수 있다.

 

 

대기열이 하는 일

트래픽을 흡수한다

순간 폭주하는 요청을 일단 줄에 넣어두고, 서버가 소화할 수 있는 속도로 천천히 내보낸다.

서버 입장에서는 항상 일정한 속도의 요청만 처리하면 된다.

많은 인원이 동시에 몰려도, 서버는 정해진 인원만 처리한다. 나머지는 줄에서 기다린다.

순서를 보장한다

먼저 온 사람이 먼저 처리된다.

도착 시각을 기준으로 줄이 세워지기 때문에, 대기열에 진입한 후로는 집인 순서에 따라 공정하게 처리된다.

대기열 없이 수만 건의 요청이 동시에 DB에 도달하면 처리 순서는 사실상 랜덤이다.

먼저 클릭했어도 나중에 처리될 수 있다.

시스템을 지킨다

서버가 감당할 수 있는 범위를 초과하는 요청은 즉시 처리하지 않는다.

무리하게 받다가 전체가 다운되는 것보다, 정해진 속도로 처리하고 나머지는 기다리게 하는 것이 낫다.

대기열이 유입량 자체를 조절하기 때문에 스레드 풀, 커넥션 풀 등 자원의 고갈로 인한 문제를 예발 할 수 있다.

 

대기열의 흐름

사용자 입장에서는 이렇게 느껴진다.

1. 버튼 클릭 → "현재 347번째입니다. 예상 대기 시간 2초"
2. 주기적으로 순위 확인 → 숫자가 줄어든다
3. 내 차례 → 입장권(토큰) 발급
4. 입장권을 들고 → 실제 서비스 이용
5. 처리 완료 → 입장권 자동 소멸

입장권에는 유효 시간이 있다. 일정 시간 안에 사용하지 않으면 만료된다.

줄을 서다가 자리를 비운 사람이 자원을 점유하지 않도록 하기 위해서다.

 

대기열 X vs 대기열 O

  대기열 없음 대기열 있음
서버 상태 스레드/커넥션 고갈 위험 일정한 부하 유지
처리 순서 사실상 랜덤 도착 시각 기준 FIFO
장애 전파 연쇄 장애 가능 유입량이 제어되어 안전
사용자 경험 흰 화면, 알 수 없는 오류 몇 번째인지, 얼마나 기다릴지 안내
재시도 묻지마 재시도 → 2차 폭주 내 순서가 있으니 재시도 불필요

 

마무리

대기열은 불편한 것이 아니라 서비스를 지키는 안전장치다.

대기열 없이 트래픽 폭주를 그냥 받으면 서비스 자체가 다운된다.

 

반면 대기열이 있으면 늦더라도 "내 차례는 온다"는 신뢰를 줄 수 있다.

병목은 "동시에 너무 많이 들어오는 것"에서 시작된다.

대기열은 그 입구를 조절하는 기술이다.

반응형
반응형

들어가며

프레더릭 테일러의 과학적 관리법은 오래된 책처럼 보인다.

1911년에 출간되었고, 문제의 무대도 공장이다.

그래서 현재의 상황과 관련이 없을 것이라는 생각이 든다.

하지만 그러한 생각은 책을 읽어나가며 점점 생각은 달라지게 된다.

 

테일러가 묻는 것은 결국 하나다.

일을 잘하게 만드는 것은 개인의 감각과 숙련인가, 아니면 분석된 절차와 설계된 체계인가.

이 질문은 공장 노동의 시대를 넘어 오늘의 사무실, 플랫폼, 그리고 AI 협업 환경까지 길게 이어진다.

 

지금 우리가 생성형 AI와 AI agent를 두고 던지는 질문도 크게 다르지 않다.

- 무엇을 맡길 것인가.

- 어떻게 지시할 것인가.

- 어떤 기준으로 결과를 판단할 것인가.

- 어떻게 생산성을 향상시킬 것인가.

 

과학적 관리법은 과거의 책이면서 동시에 현재의  AI 시대를 이해하는 데 뜻밖의 도움을 주는 책이다.

 

과학적 관리법의 핵심 내용

책의 주장은 단순하다.

노동을 개인의 경험과 요령에만 맡겨 두면 비효율이 생긴다. 그러니 작업은 관찰되고, 분석되고, 측정되어야 한다.

그런 다음 가장 효율적인 방식을 찾아 표준으로 삼아야 한다. 생산성은 성실함만으로 생기지 않는다.

설계된 절차와 관리된 흐름이 있어야 한다. 이것이 테일러가 말하는 과학의 핵심이다.

 

이 논리는 산업사회에 큰 힘을 발휘했다.

작업은 더 잘게 나뉘고, 역할은 더 뚜렷해지고, 성과는 더 쉽게 비교되었다.

생산성은 개인의 감각이 아니라 조직의 시스템에서 나온다는 관점도 자연스럽게 자리 잡았다.

 

AI 시대와의 연결

AI 시대의 업무 환경은 겉으로 보면 테일러의 시대와 멀어 보인다.

이제 우리는 삽질의 각도보다 프롬프트의 구조를 고민하고, 물리적 동작보다 정보 처리의 흐름을 설계한다.

그러나 일을 대하는 태도만 놓고 보면 둘 사이에는 닮음이 있다.

더 좋은 결과를 얻기 위해 일을 분해하고, 절차를 정리하고, 반복 가능한 구조를 만들려 한다는 점이다.

 

오늘날 AI agent는 명확한 목표, 구체적인 조건, 분명한 평가 기준을 줄수록 더 안정적으로 작동한다.

반대로 지시가 흐리고 기대치가 모호하면 결과도 흔들린다.

이 사실은 아주 현대적인 기술 현상처럼 보이지만, 테일러의 언어로 옮기면 익숙한 그림이 된다.

성과는 우연히 나오지 않는다. 좋은 도구만으로도 나오지 않는다. 잘 짜인 구조 속에서 비로소 나온다.

 

이 점에서 과학적 관리법은 AI 시대에 꽤 유용한 책이다.

AI를 잘 쓴다는 것은 최신 모델을 안다는 뜻이 아니라, 일을 어떻게 나누고 어디서 검토하며 무엇을 인간의 몫으로 남길지 설계할 수 있다는 뜻에 가깝다.

생산성의 핵심이 기술 자체보다 관리 방식에 있다는 사실을 이 책은 말해준다.

 

이 책이 지금도 유효한 이유

이 책이 오늘도 유효한 이유는 단순하다.

성능 좋은 AI가 있다고 해서 조직이 저절로 효율적으로 변하지는 않는다.

오히려 도구의 성능이 높아질수록, 그 도구를 어떤 구조 안에 배치하느냐가 더 중요해진다.

목표가 흐리면 결과도 흐리고, 책임이 나뉘지 않으면 수정 비용만 커진다.

 

누가 초안을 만들고, 누가 검토하고, 어떤 기준으로 통과와 실패를 가를지 정하지 않으면 AI는 생산성의 도구가 아니라 반복 수정과 책임 공백을 낳는 혼란의 도구가 되기 쉽다.

 

바로 이런 지점에서 테일러의 통찰은 여전히 힘을 가진다.

그가 남긴 핵심은 공장 관리의 기술이 아니라, 생산성은 좋은 도구 자체보다 일을 어떻게 나누고 연결하고 점검하느냐에 달려 있다는 사실이기 때문이다.

 

맺음말

과학적 관리법은 한 세기 전의 책이지만, 지금 읽으면 놀랄 만큼 현재적이다.

그것은 이 책이 오늘에도 그대로 옳아서가 아니라, 우리가 여전히 효율의 언어 안에서 일하고 있기 때문이다.

테일러가 보았던 생산성의 문제는 사라지지 않았다.

다만 그 대상이 공장 노동에서 인간과 AI의 협업으로 옮겨왔을 뿐이다.

 

그래서 이 책을 지금 다시 읽는 일은 단지 오래된 경영 이론을 확인하는 일이 아니다.

오히려 어디까지 효율을 추구할 수 있는지, 그리고 어디서부터 인간의 판단과 책임을 남겨 두어야 하는지를 묻는 일에 가깝다.

 

AI 시대의 경쟁력은 기술을 먼저 들여오는 데서 생기지 않는다.

인간과 AI의 역할을 어떻게 나누고, 무엇을 표준화하며, 무엇만큼은 끝내 표준화하지 않을지를 정하는 데서 생긴다.

그런 의미에서 과학적 관리법은 낡은 고전이 아니라, 오늘의 노동과 생산성을 다시 생각하게 만드는 여전히 날카로운 책이다.

반응형

'' 카테고리의 다른 글

[책읽기] The Nature of Software Development  (1) 2026.05.01
반응형

처음 시스템을 만들 때는 대개 command 중심으로 출발는 것이 자연스럽다.
사용자는 어떤 동작을 요청하고, 시스템은 그 요청을 처리하면 된다.

 

그런데 시스템이 조금만 커지면 command만으로는 복잡해지는 순간이 온다.
어떤 동작의 결과를 여러 곳에서 알고 싶어지고, 후속 처리가 점점 늘어나고, 한 서비스가 다른 서비스의 내부 후처리까지 알아야 하는 상황이 생긴다.

 

그러면 command에서 event로 전환을 고민해볼 필요가 있다.

 

Command와 Event는 무엇이 다른가

Command는 의도를 담는다.

“이 일을 해라”라는 요청이다.

 

누군가 특정 수신자에게 어떤 행위를 기대하면서 보낸다.

 

예를 들어 다음과 같은 이름은 command에 가깝다.

  • CreateOrder
  • ApprovePayment
  • SendNotification

반면 Event는 이미 일어난 사실을 담는다.
“이 일이 일어났다”라는 기록이다.

 

누군가에게 뭔가를 시키는 것이 아니라, 시스템 안에서 발생한 사실을 알린다.

 

예를 들면 이런 식이다.

  • OrderCreated
  • PaymentApproved
  • NotificationSent


command는 행위의 책임을 특정 대상에게 전달하고, event는 발생한 사실의 전파를 시스템 전체에 열어둔다.

즉, command는 본질적으로 더 직접적이고, event는 더 느슨하게 연결된다.

 

처음에는 왜 Command로 시작하는가

많은 경우 command로 시작하는 것이 맞다.

 

사용자 요청은 대부분 명령의 형태로 들어온다.
“주문해줘”, “결제해줘”, “취소해줘”처럼 시스템에 기대하는 행동이 분명하다.

이때 command는 흐름을 이해하기 쉽고, 책임 주체도 명확하다.

 

예를 들어 주문 생성 유스케이스를 생각해보자.

  1. 사용자가 주문 생성 요청을 보낸다.
  2. 주문 서비스는 주문을 생성한다.
  3. 결과를 반환한다.

이 정도 규모에서는 command만으로도 충분하다.
오히려 event를 너무 일찍 도입하면 구조만 복잡해지고, 실제 이점은 크지 않을 수 있다.

문제는 시스템이 성장하면서 시작된다.

 

Event로 전환해야 하는 순간은 언제인가

핵심은 이 질문이다.

지금 이 메시지는 특정 행동을 시키기 위한 것인가, 아니면 이미 발생한 사실을 다른 주체들이 활용하도록 알리기 위한 것인가?

 

처음에는 주문 서비스가 주문을 만들고 끝난다.
그런데 어느 순간부터 주문 생성 이후 해야 할 일이 늘어난다.

  • 포인트 적립
  • 쿠폰 사용 처리
  • 재고 차감
  • 포인트 적립
  • 알림 발송

이걸 전부 주문 서비스가 직접 command로 호출하기 시작하면 어떤 일이 생길까?

주문 서비스는 점점 더 많은 후속 처리의 존재를 알아야 한다.


어떤 서비스가 있고, 어떤 순서로 불러야 하고, 실패하면 어떻게 해야 하는지도 고민해야 한다.
결국 “주문 생성”이라는 본래 책임보다 “주문 이후 벌어지는 모든 일의 오케스트레이션”에 가까운 역할을 하게 된다.

 

이때 필요한 것은 후속 처리를 향한 더 많은 command가 아니라,OrderCreated 혹은 OrderCompleted 같은 event다.

 

주문 서비스는 “주문이 생성되었다”는 사실만 발행하고,

포인트 서비스는 그 사실을 구독해서 적립하고,
알림 서비스는 그 사실을 구독해서 메시지를 보내고,
통계 서비스는 그 사실을 구독해서 집계하면 된다.

 

하나의 command 결과가 시스템 안의 여러 반응으로 확산되기 시작할 때, event 전환이 필요해진다.

 

예시: 주문 완료 이후

처음에는 이런 구조였다고 가정하면

  • CompleteOrder command를 받는다.
  • 주문 서비스가 주문 상태를 완료로 변경한다.
  • 주문 서비스가 포인트 서비스에 적립 command를 보낸다.
  • 주문 서비스가 알림 서비스에 발송 command를 보낸다.
  • 주문 서비스가 통계 서비스에 집계 command를 보낸다.

처음에는 단순해 보인다.


하지만 시간이 지나면 이런 문제가 생긴다.

  • 주문 서비스가 너무 많은 후속 책임을 안는다.
  • 새 기능이 추가될 때마다 주문 서비스가 계속 수정된다.
  • 일부 후속 처리 실패가 주문 완료 자체를 흔들 수 있다.
  • 장애가 전파되기 쉽다.
  • 테스트도 점점 무거워진다.

이 구조를 event 중심으로 바꾸면 이렇게 된다.

  • CompleteOrder command를 받는다.
  • 주문 서비스가 주문 상태를 완료로 변경한다.
  • 주문 서비스는 OrderCompleted event를 발행한다.
  • 포인트 서비스가 이를 구독해 적립한다.
  • 알림 서비스가 이를 구독해 메시지를 보낸다.
  • 통계 서비스가 이를 구독해 집계한다.

이제 주문 서비스는 “주문 완료”라는 핵심 사실만 책임지게 된다.


후속 처리는 각 서비스가 자신의 관심사로 가져간다.

이 변화의 본질은 단순한 비동기화가 아니라 책임의 방향을 다시 정리한 것이다.

 

그렇다고 항상 Event가 더 좋은 것은 아니다

여기서 흔히 생기는 오해가 있다.
event가 더 유연해 보인다고 해서 모든 것을 event로 만들면 안 된다.

예를 들어 반드시 성공 여부가 즉시 중요하고, 실패 시 사용자에게 바로 알려야 하며, 순서 보장이 핵심인 작업은 여전히 command가 더 적합하다.

대표적으로는 이런 것들이다.

  • 결제 승인 요청
  • 회원 가입 처리
  • 주문 취소 요청

이런 것은 “사실 알림”보다 “의도 전달과 결과 보장”이 더 중요하다.

또 하나 주의할 점은, event를 도입하면 구조가 느슨해지는 대신 추적 난이도가 올라갈 수 있다는 것이다.
동기 호출에서는 한 흐름으로 보이던 것이, 이벤트 기반 구조에서는 여러 소비자의 반응으로 퍼진다.
그래서 idempotency, 재처리, 중복 소비, 순서 보장, 모니터링 같은 운영 관점도 함께 고려해야 한다.

즉, event 전환은 무조건적인 업그레이드가 아니라 다른 종류의 복잡성을 선택하는 일이다.

 

판단 기준을 한 문장으로 정리하면

내가 이 메시지로 정말 하고 싶은 것이

  • 특정 대상에게 어떤 행동을 시키는 것이라면 command이고,
  • 이미 발생한 사실을 다른 주체들이 활용하게 하는 것이라면 event다.

그리고 특히, 하나의 도메인 결과가 여러 시스템의 반응을 유발하기 시작하면
그 시점부터는 command를 더 추가하는 대신, 결과를 event로 승격시키는 편이 더 자연스럽다.

 

내가 경험에서 적용할 수 있는 포인트를 고민해보면

주문을 다루는 실무에서는 하나의 command가 점점 많은 일을 맡게 되는 순간이 있다.
실제로 주문을 생성하는 과정 안에는 임직원 할인 적용, 프로모션 계산, 최종 주문 생성, 포인트 적립처럼 서로 성격이 다른 로직들이 자연스럽게 함께 들어오게 된다.


처음에는 하나의 흐름으로 묶여 있는 것이 단순해 보이지만, 시간이 지날수록 주문 command가 주문 자체를 처리하는 것을 넘어 주문 이후의 반응까지 모두 책임지는 형태로 커지기 쉽다.

 

이럴 때 중요한 것은 command가 단순히 커졌다는 사실보다, 그 안에 섞인 책임이 정말 같은 성격이냐는 점이다.
예를 들어 임직원 할인이나 프로모션 적용은 주문을 어떤 조건으로 생성할지를 결정하는 핵심 흐름에 가깝다.

반면 포인트 적립은 주문이 정상적으로 생성된 이후에 이어지는 후속 반응으로 볼 여지가 크다.

 

이런 식으로 주문을 성립시키는 책임과 주문 이후에 반응하는 책임이 한 command 안에 함께 들어오기 시작한다면, 그때는 event로의 분리를 한 번쯤 고민해볼 만하다.

 

결국 실무에서 event 전환을 고민하게 되는 지점은 로직의 양이 많아졌을 때라기보다, 하나의 command가 여러 관심사를 함께 끌어안기 시작할 때에 더 가깝다.

 

주문 생성은 command가 책임지고, 그 결과에 대한 포인트 적립이나 다른 후속 처리는 OrderCreated 같은 event를 통해 풀어내는 방식이 구조를 더 자연스럽게 만들어줄 수 있다.

 

마무리

command에서 event로의 전환은 기술 스택의 문제가 아니다.
Kafka를 쓰느냐, 메시지 브로커를 쓰느냐의 문제도 아니다.
본질은 시스템 안에서 무엇을 “의도”로 보고, 무엇을 “사실”로 볼 것인가에 대한 모델링의 문제다.

 

처음에는 command로 시작하는 것이 맞다.
하지만 시스템이 커지면서 후속 반응이 많아지고, 여러 관심사가 얽히고, 한 서비스가 다른 서비스의 내부 사정을 너무 많이 알게 된다면, 그때는 질문을 바꿔야 한다.

 

“다음 command를 어디로 보낼까?”가 아니라 “여기서 정말 중요한 도메인 사실은 무엇인가?”

결국 command에서 event로의 전환은 메시지 종류를 바꾸는 일이 아니라, 시스템 안에서 요청과 사실을 다시 구분하는 과정에 가깝다.

 

중요한 것은 기술 선택이 아니라, 도메인의 사실을 정확하게 포착하고 그것을 중심으로 경계와 책임을 나누는 일이다.

좋은 설계는 command와 event 중 하나를 택하는 것이 아니라, 둘이 가장 자연스러운 자리를 찾게 만드는 데서 시작된다.

반응형
반응형

 

서킷브레이커를 학습하면서 기존 업무에서 고민했던 부분을 해소할 수 있을 것이라는 생각을 하게 되었다

 

주문을 담당했었을 때 특정 PG사의 장애로 주문이 발생하지 못하는 문제가 생겼고 그 결과 매출에 문제가 발생했던 경험이 있다

다행이 여러 PG사를 사용하고 있어 문제가 발생하는 PG사로 결제가 발생하지 않도록 조치하면서 다행이 빠르게 문제를 조치하였었다

 

이후 PG사 문제가 발생할 때, 다른 PG사로의 결제를 시도하는 로직을 Try-Catch를 구현했었는데,

지금 생각해보면 Resilience4j와 같은 서킷브레이커 라이브러리 등을 활용했으면 더 좋았을꺼 같다

 

단순 재시도를 구현하게 되면 아래와 같은 코드가 나오게 될 것이다

List<PaymentGateway> pgs = List.of(pg1, pg2, pg3);

for (PaymentGateway pg : pgs) {
    try {
        PaymentResult result = pg.pay(request);

        if (result.isSuccess()) {
            return result; // 성공 → break 대신 return
        }

    } catch (Exception e) {
        // 실패 → 다음 PG로
    }
}

throw new PaymentAllFailedException();

 

언 듯 보기에는 괜찮아 보이는 것 같기도 하다

하지만 위 로직은 두가지 큰 문제 가 있다

 

1. 불필요한 지연 시간

장애가 발생한 PG사에 매번 요청을 보내고 타임아웃을 기다려야 하고, 그에 따른 지연 시간이 필연적으로 발생할 수 밖에 없다

 

2. 연쇄 부하 발생

장애 PG로의 불필요한 요청이 계속 쌓이면서 지연에 따른 시스템의 스레드 풀이 고갈되는 문제도 발생한다

 

 

그러면 서킷브레이커 적용하면?

서킷브레이커를 적용하면 얻을 수 있는 이점 중 하나는 Fast-Fail이다

서킷브레이커는 다양한 설정이 있지만 실패 비율이 임계점을 넘게 되면 이후 호출시 CallNotPermittedException와 같이 예외를 발생시킴으로써 빠르게 실패할 수 있다

 

그 덕에 PG와 같이 외부 시스템의 응답을 기다리는데 시간을 소비하지 않을 수 있고 Fallback을 통해 안정적인 서비스를 제공할 수 있게 된다

 

흐름도로 보면 아래와 같다

이렇게 하면 PG 장애가 발생했을 때 자동으로 다른 PG로 전환 된다

그에 따라 비즈니스는 멈추지 않고 고객 경험에도 좋은 영향을 미치게 된다

 

서킷브레이커로 더 디벨롭한다면?

서킷브레이커를 도입하는 것만으로도 큰 개선이지만, 몇 가지를 더하면 훨씬 견고한 결제 시스템을 만들 수 있다.

자동 복구

서킷브레이커를 활용하면 Half-Open 상태를 통해 이 과정을 자동화할 수 있다

일정 시간이 지나면 소량의 요청을 다시 시도해보고, 성공하면 자동으로 복구된다

모니터링 연동

서킷이 Open으로 전환되는 순간 Slack 알림 등을 통해 장애 상황을 공유받아 대응할 수 있다

Retry와의 조합

모든 실패가 장애이지는 않다

순간적인 타임아웃은 Retry로, 반복적인 실패는 서킷브레이커로 차단하는 식으로 조합하면 일시적 오류와 실제 장애를 구분해서 대응할 수 있다

 

마무리

서킷브레이커를 학습하면서 자연스럽게 과거 PG 장애 경험이 떠올랐다

지금 돌아보면 그때의 고민들, 즉 불필요한 지연, 수동 복구, 장애 인지 지연 같은 문제들이 서킷브레이커 하나로 상당 부분 해소될 수 있었다는 걸 알게 됐다

직접 겪었던 문제였기에 서킷브레이커의 필요성이 더 와닿았고, 과거의 경험이 새로운 학습의 좋은 연결고리가 되었다

장애는 막을 수 없지만, 장애에 강한 시스템은 만들 수 있다

 

서킷브레이커는 그 출발점이라고 생각한다

반응형

+ Recent posts