1. 도입

대규모 사용자 기반을 가진 애플리케이션에서 푸시 알림 시스템은 사용자 참여를 유도하고 중요한 정보를 전달하는 핵심적인 역할을 수행한다. 그러나 사용자 수가 증가함에 따라, 시스템은 대량의 메시지를 안정적이고 신속하게 처리해야 하는 기술적 과제에 직면하게 된다. 본 글에서는 과거 대규모 예약 푸시 알림 시스템의 성능을 ThreadPool을 이용한 병렬 처리로 개선했던 경험을 복기하고, 당시의 기술적 한계를 현재의 관점에서 재평가하여 이를 극복하기 위한 새로운 아키텍처를 제안하고자 한다. 본 연구의 목표는 메시지 큐(Message Queue)와 아웃박스 패턴(Outbox Pattern)을 기반으로 한 차세대 비동기 처리 모델을 설계하여, 시스템의 처리량(Throughput), 안정성(Stability), 그리고 확장성(Scalability)을 획기적으로 개선하는 방안을 탐구하는 데 있다.

2. 기존 시스템의 문제 정의

본 연구의 대상이 되는 기존 시스템은 19만 명의 전체 사용자에게 예약 알림을 발송하는 데 5시간 이상이 소요되는 성능 문제를 내포하고 있었다.

2.1. 1차 시스템: 단일 스레드 기반 동기 처리 모델

1차 시스템의 아키텍처는 단일 스레드가 페이징(Paging) 기법으로 사용자 정보를 순차 조회하고, 외부 푸시 서비스 API(e.g., FCM, APNS)를 동기적으로 호출하는 구조이다. 특히, 전체 발송 프로세스가 단일 데이터베이스 트랜잭션 내에서 실행되는 특징을 가진다.

    sequenceDiagram
    participant App as Application
    participant DB as Database
    participant PushAPI as Push Service (FCM/APNS)

    Note over App, DB: @Transactional 시작 (거대 트랜잭션)

    loop 190번 페이지 반복
        App->>DB: 사용자 조회 (페이지 N, size=1000)
        activate DB
        DB-->>App: 1000명 사용자 데이터 반환
        deactivate DB

        loop 1000명 사용자 반복
            App->>PushAPI: User(i) 푸시 발송 요청 (동기 호출)
            activate PushAPI
            Note right of App: API 응답까지
스레드 대기 (Blocking) PushAPI-->>App: 발송 완료 응답 deactivate PushAPI end end Note over App, DB: 모든 발송 완료 후
@Transactional 종료 (Commit)

이러한 구조는 다음과 같은 명확한 한계를 내포한다.

  • 문제점 1: 직렬 처리와 동기 I/O 병목
    각 API 호출마다 발생하는 I/O 대기 시간(Blocking)이 누적되어 전체 프로세스의 극심한 성능 저하를 유발한다.
  • 문제점 2: 초장기 트랜잭션 (Long-Lived Transaction)
    프로세스가 실행되는 수 시간 동안 데이터베이스 리소스에 장시간 락(Lock)이 설정될 수 있으며, 이는 시스템의 다른 부분에 대한 가용성 문제를 야기할 수 있다.
  • 문제점 3: 구조적 취약성
    단일 트랜잭션 모델은 프로세스 중간에 발생하는 단 한 번의 예외에도 전체 작업이 롤백(Rollback)되는 결과를 초래하여, 시스템의 안정성과 신뢰도를 저해한다.

2.2. 2차 시스템: 스레드 풀 기반 병렬 처리 모델

1차 시스템의 성능 한계를 극복하기 위해, 2차 시스템에서는 두 가지 핵심적인 최적화를 적용하였다. 가장 결정적인 개선은 외부 API의 Bulk 처리 방식 도입으로, 이는 네트워크 호출 횟수를 99% 이상 감소시켜 I/O 대기 시간을 획기적으로 줄였다. 이에 더하여, 스레드 풀(Thread Pool)을 이용한 병렬 처리 모델을 적용하여 최적화된 작업을 동시에 수행하도록 하였다.

아키텍처:

  1. (Bulk API 적용) 기존의 단일 사용자 호출 방식에서, 100명 단위로 푸시를 일괄 발송할 수 있는 Bulk API를 호출하는 방식으로 로직을 변경한다.
  2. (병렬 처리) 시스템의 CPU 코어 수에 맞춰 스레드 풀을 생성하고, 메인 스레드가 조회한 사용자 청크(Chunk)를 각 작업 스레드에 할당하여 Bulk API 호출을 병렬로 실행한다.
graph TD
    subgraph "메인 스레드 (Dispatcher)"
        A(Transaction 시작) --> B{페이지 루프 190번 반복};
        B --> C[DB에서 사용자 1000명 조회 Chunk];
        C --> D{ThreadPool에 작업 제출};
        D --> B;
        B -- 모든 페이지 처리 후 --> E(모든 스레드 작업 완료 대기);
        E --> F(Transaction 종료);
    end

    subgraph "ThreadPool (N개 스레드 병렬 처리)"
        T1(Worker Thread 1)
        T2(Worker Thread 2)
        T3(Worker Thread ... N)

        subgraph "각 Worker Thread의 작업 로직"
            W_START(Chunk 수신) --> W_LOOP{Bulk API 루프 10번 반복};
            W_LOOP --> W_CALL[100명 단위로 Bulk API 동기 호출];
            W_CALL --> W_LOOP;
            W_LOOP -- 완료 --> W_END(작업 종료);
        end

        T1 --> W_START;
        T2 --> W_START;
        T3 --> W_START;
    end

    D -- Chunk 1 --> T1;
    D -- Chunk 2 --> T2;
    D -- Chunk 3... --> T3;

이러한 병렬 처리 모델 도입을 통해, 시스템의 총 발송 시간은 5시간에서 30분으로 유의미하게 단축되었다.

한계점:

성능 개선에도 불구하고, 2차 시스템은 여전히 1차 시스템과 동일한 근본적인 한계를 공유한다.

  • 문제점 1: 초장기 트랜잭션 및 DB 병목 지속
    전체 발송 프로세스는 여전히 단일 트랜잭션 내에서 실행된다. 다수의 스레드가 동시에 데이터베이스의 공유 리소스에 접근하면서 경합이 발생하고, 이는 전체 처리량을 저하시키는 병목 현상을 유발하며, 초장기 트랜잭션 문제 또한 해결되지 않았다.
  • 문제점 2: 제한적인 확장성 (Vertical Scaling)
    성능은 단일 서버의 CPU 코어 수와 스레드 풀의 크기에 직접적으로 의존한다. 처리량을 높이기 위해서는 서버의 사양을 높여야 하는 수직적 확장(Vertical Scaling)만 가능하며, 여러 서버로 부하를 분산하는 수평적 확장(Horizontal Scaling)이 어려운 구조이다.
  • 문제점 3: 여전한 강한 결합도
    애플리케이션이 푸시 발송이라는 무거운 작업을 직접 수행하는 구조는 변하지 않았다. 이는 시스템의 복잡도를 높이고, 외부 API의 응답 지연이 스레드 풀 전체의 성능에 영향을 미치는 등 여전히 강한 결합도(Tight Coupling) 문제를 내포한다.

3. 제안하는 아키텍처: 비동기 메시지 기반 분산 처리 모델

기존 시스템의 한계를 극복하기 위해, 본 연구에서는 단순히 속도 개선을 넘어 시스템의 안정성(Reliability), 확장성(Scalability), 데이터 정확성(Accuracy) 까지 확보하는 것을 목표로 새로운 아키텍처를 제안한다.

핵심 전략은 시간이 오래 걸리는 ‘준비(Preparation)’ 단계와 신속해야 하는 ‘발사(Dispatch)’ 단계를 명확히 분리하고, 각 단계의 역할을 전문화된 컴포넌트에 위임하는 것이다. 전체 시스템의 구성은 아래 다이어그램과 같다.

graph TD
    subgraph "Application Layer"
        WebApp[API/Web Server]
    end

    subgraph "Database Layer"
        JobTable[(Push_Job Table)]
        OutboxTable[(push_outbox Table)]
    end

    subgraph "Phase 1: Preparation (T-10 min)"
        PrepSchedulers["Preparation Schedulers 
(Scale-out Ready)"] end subgraph "Phase 2: Dispatch (T-0 min)" DispatchPublishers["Dispatch Publishers
(Scale-out Ready)"] subgraph "Message Queue Infrastructure" MQ(Kafka / RabbitMQ) DLQ((Dead Letter Queue)) end end subgraph "Phase 3: Consumption & Verification" Consumers["Push Workers
(Scale-out Ready)"] RedisCache[(Redis Cache
User Settings)] end subgraph "External Services" PushService[FCM / APNS] end %% Data Flow (Success Path) WebApp -- "1. Register Push Job" --> JobTable PrepSchedulers -- "2. Claim Job
(FOR UPDATE SKIP LOCKED)" --> JobTable PrepSchedulers -- "3. Prepare in Outbox" --> OutboxTable DispatchPublishers -- "4. Claim & Publish" --> OutboxTable DispatchPublishers --> MQ MQ --> Consumers Consumers -- "6. Final Check" --> RedisCache Consumers -- "7. Send Push (Success)" --> PushService %% Data Flow (Failure Path) Consumers -.->|"8. Final Failure
(After Retries)"| DLQ %% Style style WebApp fill:#f9f,stroke:#333,stroke-width:2px style JobTable fill:#f9f,stroke:#333,stroke-width:2px style OutboxTable fill:#ccf,stroke:#333,stroke-width:2px style PrepSchedulers fill:#ccf,stroke:#333,stroke-width:2px style DispatchPublishers fill:#cfc,stroke:#333,stroke-width:2px style MQ fill:#cfc,stroke:#333,stroke-width:2px style Consumers fill:#e6ffb3,stroke:#333,stroke-width:2px style RedisCache fill:#e6ffb3,stroke:#333,stroke-width:2px style PushService fill:#eee,stroke:#333,stroke-width:2px style DLQ fill:#ffb3b3,stroke:#333,stroke-width:2px

3.1. 1단계: 준비 (Preparation) - 확장 가능한 안정적인 체크포인트 생성

정시성이 중요한 예약 발송 시스템에서, 발송 시점에 대규모 데이터를 조회하는 것은 지연을 유발할 수 있다. 이 문제를 해결하기 위해, 주기적으로 실행되며 수평 확장이 가능한 ‘준비 스케줄러(Preparation Scheduler)’ 를 도입한다.

여러 스케줄러 인스턴스가 동시에 실행되더라도, 각 인스턴스는 SELECT … FOR UPDATE SKIP LOCKED LIMIT 1 구문을 사용하여 경합 없이 하나의 작업만을 안전하게 선점(Claim) 한다. 이는 데이터베이스의 내장 Lock 메커니즘을 활용하는 방식으로, 외부 분산 락을 사용하는 것보다 더 단순하고 효율적이며 트랜잭션의 원자성을 보장한다.

작업을 성공적으로 선점하면, 즉시 상태를 ‘PREPARING’으로 변경하고 메인 DB(필요시 Read Replica DB 사용)에 복잡한 타겟팅 쿼리를 실행한다. 그 결과는 push_outbox 테이블에 INSERT되며, 이 테이블은 실패 시 안전하게 재개할 수 있는 ‘체크포인트(Checkpoint)’ 역할을 수행한다. 이 모든 과정은 하나의 원자적 트랜잭션으로 실행되어 데이터 정합성을 완벽하게 보장하며, 완료 후 Push_Job의 상태를 ‘PREPARED’로 변경한다.

[동작 로직 요약]

  • Trigger: 매분 실행 (@Scheduled(cron = “0 * * * * *”))
  • Query (Job Claiming): SELECT * FROM Push_Job WHERE status = ‘SCHEDULED’ AND scheduled_time <= NOW() + INTERVAL ‘10 MINUTE’ FOR UPDATE SKIP LOCKED LIMIT 1
  • Action (Atomic Transaction):
    1. 작업 상태를 PREPARING으로 변경
    2. INSERT INTO push_outbox SELECT … 실행
    3. 작업 상태를 PREPARED로 변경

[성능 검증] INSERT INTO … SELECT 벤치마크

‘준비’ 단계에서 실행되는 대규모 INSERT INTO … SELECT 쿼리가 실제로 어느 정도의 성능을 내는지 확인하기 위해 간단한 벤치마크를 진행했다.

실행 쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
TRUNCATE TABLE push_outbox_benchmark;
-- 2. 쿼리 실행 및 분석
EXPLAIN ANALYZE INSERT INTO push_outbox_benchmark(user_id, device_token)
SELECT user_id, device_token
FROM users_benchmark
WHERE notification_setting = 1;


-- 실행 2:
TRUNCATE TABLE push_outbox_benchmark;
EXPLAIN ANALYZE INSERT INTO push_outbox_benchmark(user_id, device_token)
SELECT user_id, device_token
FROM users_benchmark
WHERE notification_setting = 1;


-- 실행 3:
TRUNCATE TABLE push_outbox_benchmark;
EXPLAIN ANALYZE INSERT INTO push_outbox_benchmark(user_id, device_token)
SELECT user_id, device_token
FROM users_benchmark
WHERE notification_setting = 1;

실험 결과:
100만 건의 users 테이블에서 특정 조건을 만족하는 80만 건의 데이터를 push_outbox 테이블로 복사하는 벤치마크를 3회 실행한 결과는 다음과 같다.

실행 횟수 소요 시간 (ms) 처리된 Row 수
1회차 1213 ms 800,000
2회차 1195 ms 800,000
3회차 1197 ms 800,000
평균 ~1.2초 800,000

  • 100만 건의 users 테이블에서 특정 조건을 만족하는 80만 건의 데이터를 push_outbox 테이블로 복사하는 데 평균 약 1.2초가 소요됨을 확인했다.
  • EXPLAIN ANALYZE 실행 계획을 통해, 이 과정에서 notification_setting에 생성된 인덱스가 효과적으로 사용되었음을 검증하였다.

결과 분석:
이 벤치마크 결과는, 수십만 건의 대규모 데이터를 처리하는 ‘준비’ 단계가 수 초 내에 완료될 수 있는 매우 빠른 작업임을 실증적으로 보여준다. 따라서, 이 단계가 전체 푸시 발송 시스템의 병목이 될 가능성은 낮다고 판단할 수 있다.


실행 계획 결과
1
2
3
4
5
6
7
8
-> Insert into push_outbox_benchmark
-> Index lookup on users_benchmark using idx_notification_setting (notification_setting = 1) (cost=70275 rows=494009) (actual time=0.0504..1213 rows=800000 loops=1)

-> Insert into push_outbox_benchmark
-> Index lookup on users_benchmark using idx_notification_setting (notification_setting = 1) (cost=69889 rows=494009) (actual time=0.0176..1195 rows=800000 loops=1)

-> Insert into push_outbox_benchmark
-> Index lookup on users_benchmark using idx_notification_setting (notification_setting = 1) (cost=70280 rows=494009) (actual time=0.0181..1197 rows=800000 loops=1)





3.2. 2단계: 발사 (Dispatch) - 메시지 큐를 통한 작업 분산

‘준비’ 단계가 완료되면, ‘발사 발행기(Dispatch Publisher)’ 가 동작하여 체크포인트(push_outbox 테이블)에 저장된 데이터를 실제 실행 큐로 안전하게 옮기는 역할을 수행한다.

‘준비 스케줄러’와 마찬가지로, 이 발행기 역시 주기적으로 실행되며 수평 확장이 가능하다. 여러 발행기 인스턴스는 SELECT … FOR UPDATE SKIP LOCKED 구문을 사용하여 push_outbox 테이블의 데이터를 경합 없이 나누어 읽어온다.

발행기는 읽어온 데이터를 Kafka 또는 RabbitMQ와 같은 메시지 큐에 발행(Publish)하여, 실제 발송이라는 무거운 작업을 다수의 소비자(Consumer)에게 안전하게 분산시킨다. 이 방식은 DB를 작업 큐로 사용하는 것에서 완전히 탈피하여, 대규모 메시지 처리에 최적화된 전문 시스템에 역할을 위임하는 것을 의미한다.

SELECT … FOR UPDATE SKIP LOCKED를 사용하는 ‘발사’ 단계의 DB 작업은 매우 가볍고 빠르다. 이 단계의 전체 처리량은 데이터베이스가 아닌 메시지 큐의 수신 성능에 의해 결정되며, Publisher 인스턴스의 수를 늘리는 것만으로 손쉽게 수평 확장이 가능하므로 아키텍처의 주된 병목 지점으로 고려되지 않는다.

  • 플랫폼별 Topic 분리: 이 단계에서 발행기는 사용자의 플랫폼(FCM, APNS 등)을 확인하고, 각각에 해당하는 별도의 Topic으로 메시지를 발행한다. 이는 플랫폼 간의 장애가 서로에게 영향을 주지 않도록 시스템을 격리하는 효과를 가진다.

3.3. 3단계: 소비 및 최종 검증 (Consumption & Verification)

각 Topic의 메시지는 독립된 ‘푸시 워커(Push Worker)’ 그룹에 의해 처리된다. 이 워커 그룹은 인스턴스의 수를 조절하는 것만으로 전체 발송 처리량을 탄력적으로 조절할 수 있어, 시스템의 최종 성능을 결정하는 핵심적인 확장 포인트를 제공한다.

가장 중요한 것은, 워커가 푸시 API를 호출하기 직전에 수행하는 ‘최종 검증(Final Check)’ 단계이다. ‘준비’ 시점과 ‘발사’ 시점 사이의 시간차로 인해 발생할 수 있는 데이터 불일치 문제(e.g., 사용자가 그 사이에 알림 설정을 끔)를 해결하기 위해, 워커는 Redis와 같은 고속 캐시에서 사용자의 현재 알림 설정을 조회한다. 설정이 ON인 경우에만 최종적으로 푸시를 발송함으로써, 시스템은 성능, 정시성, 그리고 데이터 정확성이라는 세 가지 목표를 모두 달성할 수 있다.

3.4. 트래픽 분산을 통한 안정성 확보

대규모 푸시 발송은 성공적으로 완료되더라도, 수많은 사용자가 동시에 앱을 실행하여 발생하는 접속 부하(Thundering Herd Problem)로 인해 서비스 전체의 장애를 유발할 수 있다. 제안하는 아키텍처는 이 문제에 대한 명확한 제어 포인트를 제공한다.

‘발사 발행기(Dispatch Publisher)’ 가 push_outbox에서 메시지 큐로 데이터를 발행하는 속도를 의도적으로 조절(Rate Limiting)하거나, user_id 해싱(user_id % N)을 통해 생성된 사용자 그룹별로 시차를 두어 발행하는 전략을 사용할 수 있다. 이를 통해 백엔드 서버에 가해지는 트래픽을 완만한 형태로 분산시켜, 푸시 발송으로 인한 2차 장애를 예방하고 서비스 전체의 안정성을 확보한다.

4. 시스템의 견고성(Robustness) 확보 방안

제안하는 분산 시스템이 안정적으로 동작하기 위해서는, 애플리케이션 로직의 실패뿐만 아니라 시스템을 구성하는 핵심 인프라의 장애에도 대비해야 한다.

4.1. 애플리케이션 수준의 실패 처리

외부 시스템 연동 시 발생 가능한 실패 상황에 대응하기 위한 전략은 다음과 같다.

  • 지수 백오프 기반 재시도 (Exponential Backoff Retry): Push Worker가 외부 API 호출 시 일시적인 오류(e.g., 5xx HTTP Status)를 수신하면, 재시도 간격을 점진적으로 늘려가는 지수 백오프 알고리즘을 적용하여 시스템의 불필요한 부하를 방지하고 성공률을 높인다.
  • 데드 레터 큐 (Dead Letter Queue, DLQ): 지정된 횟수 이상 재시도에 실패한 메시지(e.g., 영구적으로 유효하지 않은 토큰)는 별도의 DLQ로 격리한다. 이를 통해 실패 원인을 분석하고 후속 조치를 위한 데이터를 확보하며, 실패한 메시지가 전체 시스템을 방해하지 않도록 격리한다.

4.2. 인프라 수준의 단일 실패 지점(SPOF) 제거

본 아키텍처의 핵심 컴포넌트인 메시지 큐와 Redis 캐시가 단일 노드로 구성될 경우, 해당 노드의 장애는 전체 시스템의 중단으로 이어진다. 이를 방지하기 위해 각 컴포넌트의 고가용성(High Availability) 확보는 필수적이다.

  • 메시지 큐 고가용성: Kafka 또는 RabbitMQ를 클러스터(Cluster) 모드로 구성한다. 여러 브로커 노드가 데이터를 복제하고 리더(Leader)를 선출하는 메커니즘을 통해, 일부 노드에 장애가 발생하더라도 데이터 유실 없이 메시징 서비스를 지속할 수 있다.
  • Redis 캐시 고가용성: Push Worker의 최종 검증 단계에서 의존하는 Redis 역시 센티넬(Sentinel)을 이용한 주/부 복제(Master/Replica) 또는 클러스터(Cluster) 모드로 구성한다. 마스터 노드 장애 시, 센티넬이 자동으로 부 노드를 새로운 마스터로 승격시키는 장애 극복(Failover)을 통해 캐시 서비스의 중단을 방지한다.

이러한 애플리케이션 및 인프라 수준의 견고성 확보 방안을 전체 프로세스에 통합하면, 다음 시퀀스 다이어그램과 같이 완전한 형태의 데이터 흐름이 완성된다.

이 다이어그램은 초기 예약 등록부터 시작하여, ‘준비’, ‘발사’ 단계를 거쳐, 최종 ‘소비’ 단계에서 발생하는 재시도(Retry) 및 데드 레터 큐(DLQ)로의 실패 처리까지의 모든 경로를 상세히 보여준다.

전체 아키텍처 시퀀스 다이어그램(이미지를 클릭하면 원본 크기로 볼 수 있습니다.)


Mermaid Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
sequenceDiagram
participant Admin as Admin
participant App as "Web Server"
participant JobTable as "DB: Push_Job Table"
participant PrepScheduler as "Preparation Scheduler"
participant OutboxTable as "DB: push_outbox Table"
participant Publisher as "Dispatch Publisher"
participant MQ as "Message Queue"
participant Worker as "Push Worker"
participant Redis as "Redis Cache"
participant PushSvc as "FCM/APNS"
participant DLQ as "Dead Letter Queue"

Note over Admin, JobTable: [Phase 1] 푸시 예약 등록
Admin->>App: 1. 예약 요청 (09:00)
App->>JobTable: INSERT INTO Push_Job

Note over Admin, Worker: ... 시간 경과 (T-10 min, 08:50) ...

Note over PrepScheduler, OutboxTable: [Phase 2] '준비' - 무거운 작업을 안전하게 처리
activate PrepScheduler
PrepScheduler->>JobTable: 2. 작업 선점 (SELECT... FOR UPDATE SKIP LOCKED)
activate JobTable
JobTable-->>PrepScheduler: Job Details (ID: 123)
deactivate JobTable

PrepScheduler->>JobTable: 3. (In TX) UPDATE Job ID 123 status to 'PREPARING'
PrepScheduler->>JobTable: 4. Heavy Query: SELECT users WHERE [타겟 조건]
PrepScheduler->>OutboxTable: 5. (In TX) INSERT results into 'push_outbox'
PrepScheduler->>JobTable: 6. (In TX) UPDATE Job ID 123 status to 'PREPARED'
deactivate PrepScheduler

Note over Admin, Worker: ... 시간 경과 (T-0 min, 09:00) ...

Note over Publisher, MQ: [Phase 3] '발사' - 준비된 데이터를 신속하게 발행
activate Publisher
loop
Publisher->>OutboxTable: 7. 메시지 선점 (SELECT... FOR UPDATE SKIP LOCKED)
activate OutboxTable
OutboxTable-->>Publisher: Chunk of Messages
deactivate OutboxTable

Publisher->>MQ: 8. Publish to Message Queue
Publisher->>OutboxTable: 9. (In TX) UPDATE messages status to 'PUBLISHED'
end
deactivate Publisher

Note over Worker, DLQ: [Phase 4] 메시지 '소비', '검증', 그리고 '실패 처리'
activate Worker
Worker->>Redis: 10. GET user_setting (Final Check)
activate Redis
Redis-->>Worker: Setting is 'ON'
deactivate Redis

%% 알림 설정이 켜져 있을 경우
alt Setting is 'ON'
loop 3 Retries with Exponential Backoff
Worker->>PushSvc: 11. Send Push Request
activate PushSvc
PushSvc-->>Worker: 5xx Error (Temporary Failure)
deactivate PushSvc
end

%% 재시도 후에도 최종 실패 시
opt After Retries, Final Failure
Worker->>DLQ: 12. Send Message to DLQ
end

%% 알림 설정이 꺼져 있을 경우
else Setting is 'OFF'
Worker->>Worker: Drop Message (발송하지 않고 폐기)
end
deactivate Worker

5. 결론 및 논의 (Conclusion & Discussion)

본 글에서 제안한 비동기 메시지 기반 아키텍처는 push_outbox를 체크포인트로 활용하고 메시지 큐를 통해 작업을 분산함으로써 기존 시스템의 DB 병목 문제를 해결하고 안정적인 확장성의 기틀을 마련한다. 또한, 지수 백오프 기반 재시도, DLQ, 최종 검증(Final Check) 메커니즘을 통해 시스템의 견고성과 데이터 정확성을 보장한다.

현실적인 제약과 점진적인 아키텍처 진화

이 아키텍처의 모든 컴포넌트에 고가용성(HA)을 즉시 적용하는 것은 현실적으로 복잡성과 비용 부담이 클 수 있다. 따라서 우리는 ‘점진적인 아키텍처 진화(Architectural Evolution)’ 전략을 취해야 한다. push_outbox 테이블은 인프라 장애 시 데이터 유실을 방지하는 핵심적인 안전장치 역할을 하므로, 단일 노드 환경에서도 데이터 정합성과 회복 가능성을 확보하는 현실적인 트레이드오프를 제공한다. 이후 서비스 규모에 맞춰 Redis와 MQ를 클러스터로 전환하는 방식으로 시스템의 가용성을 점진적으로 높여나가는 것이 바람직하다.

미래 비전: 하이퍼스케일을 위한 스트림 처리 아키텍처

제안된 push_outbox 모델은 대부분의 환경에서 안정성과 성능을 보장하지만, 수천만 건 이상의 데이터를 1분 이내에 처리해야 하는 극한의 하이퍼스케일 환경에서는 push_outbox에 데이터를 쓰고 읽는 I/O 비용 자체가 새로운 병목이 될 수 있다.

최종적인 모델은 중간 저장소를 제거하고 ‘스트림 처리(Stream Processing)’ 로 전환하는 것이다. 이는 DB 조회 결과를 테이블에 저장하는 대신, 데이터베이스 커서(Cursor)를 이용해 Kafka와 같은 메시지 큐에 직접 스트리밍하는 방식을 의미한다. 이 모델은 중간 I/O 단계를 완전히 제거하여 최고의 성능을 달성하지만, 데이터 정합성과 실패 시 복구 로직을 DB 트랜잭션 대신 Kafka의 트랜잭션 기능과 Consumer Offset 관리에 의존해야 하므로 시스템의 복잡도가 극단적으로 높아진다.

결국, 이 시스템 설계의 핵심은 현재의 비즈니스 제약과 타협하되, 미래의 성장과 극한의 성능을 위한 확장 로드맵까지 명확히 확보하는 유연성에 있다. 본 글이 이상적인 설계와 현실적인 제약을 조율하며 시스템을 성숙시키는 과정에 대한 실질적인 가이드라인을 제공하길 기대한다.

댓글 공유

서론: 철학적 문제의식

소프트웨어 개발에서 좋은 코드란 무엇인가? 이는 단순히 기술적 효율성의 문제를 넘어서는 근본적 질문이다. 코드의 품질을 논할 때 우리는 흔히 ‘깔끔함’, ‘명확함’, ‘아름다움’ 같은 미학적 언어를 사용하거나, ‘책임’, ‘역할’, ‘질서’ 같은 도덕적 언어를 사용한다. 이는 우연이 아니다. 좋은 코드에 대한 우리의 직관이 본질적으로 철학적 문제와 맞닿아 있기 때문이다.

본 글은 플라톤의 정의에 대한 탐구를 통해 소프트웨어 개발의 근본 원리를 철학적으로 고찰한다. 플라톤이 『국가』에서 제시한 정의의 개념이 현대 소프트웨어 설계 원칙과 구조적으로 일치함을 보이고, 이를 통해 정의로운 코드라는 새로운 개념적 틀을 제안한다.


1. 플라톤의 정의 탐구: 자기 일의 원칙

1.1 훌륭함의 원리: 정의를 위한 철학적 기초

플라톤의 정의(正義, Justice)를 이해하기 위해서는, 먼저 그가 모든 존재의 ‘훌륭함(德, Virtue)’이란 무엇인지에 대해 제시하는 근본적인 원리를 살펴봐야 한다. 그는 『국가』에서 다음과 같이 말한다:

“어떤 기능이 부여되어 있기도 한 각각의 것에는 ‘훌륭한 상태’와 ‘나쁜 상태’를 지니고 있다. 그 특유의 훌륭한 상태에 의해서 그 기능은 제 할 일들을 훌륭하게 수행하게 되지만, ‘나쁜 상태’에 의해서는 나쁘게 수행된다.”(353c)

이 인용문의 핵심은 플라톤이 모든 존재의 ‘훌륭함’을 그 고유한 ‘기능(function)’ 의 완벽한 수행과 연결 짓는다는 점이다. 즉, 각 존재가 자신에게 주어진 단 하나의 기능, 즉 ‘제 할 일’에 집중하고 전문화되는 것, 이를 기능적 전문화(functional specialization)라 부를 수 있다. 이렇게 될 때 비로소 그 존재는 가장 훌륭한 상태에 도달한다.이는 단순한 효율성의 문제가 아니라 존재론적 완성의 문제다.

1.2 정의의 본질: 자기 일의 원칙

이처럼 모든 ‘훌륭함’이 고유한 기능의 완벽한 수행에서 비롯된다는 대원칙 위에서, 플라톤은 마침내 정의의 본질을 규정한다. 그가 『국가』에서 직접 밝히듯이, 정의란 바로 “제 일을 하고 참견하지 않는 것”(433a) 이며, “제 것의 소유와 제 일을 함”(433e) 이 올바른 상태라고 규정한다.

이러한 철학적 정의는, ‘훌륭함’이 어떻게 성취되는지에 대한 매우 실용적인 관찰에 뿌리를 두고 있다. 플라톤은 국가의 기원을 논하는 대목에서 이미 그 근거를 다음과 같이 제시한 바 있다.

“어떤 이가 일을 더 잘 해내게 되는 것은 한 사람으로서 여러 가지 기술에 종사할 때가 아니라 한 사람이 한 가지 기술에 종사할 때이다.”(370b)

여기서 우리는 플라톤 정의 개념의 핵심, 즉 자기 일의 원칙(The Principle of One’s Own Work) 을 도출할 수 있다. 이는 각자가 자신의 고유한 성향과 능력에 맞는 일에 전념하고 다른 영역을 침범하지 않을 때, 개인의 탁월함은 물론 공동체 전체의 조화로운 질서 가 달성된다는 사상이다. 이때 정의는 외부에서 부과되는 규범이 아니라, 사물의 본성에서 자연스럽게 발현되는 질서다.

바로 이 지점에서 우리는 1.1에서 다룬 ‘훌륭함’과 1.2의 ‘정의’가 하나로 만나는 것을 목격한다. 플라톤에게 ‘정의’, 즉 ‘자기 일을 하는 것’은 그 자체로 도덕적 목표이기에 앞서, 모든 존재가 자신의 고유한 기능을 완벽하게 수행하는 ‘훌륭한 상태’에 도달하기 위한 유일하고 필연적인 방법론인 것이다.

2. 소프트웨어 개발에서의 정의 개념 적용

2.1 조화로운 질서와 무질서의 풍경

플라톤의 ‘자기 일의 원칙’이라는 개념을 인지한 채 소프트웨어의 위계적 구조를 들여다보면, 우리는 비로소 ‘좋은 코드’와 ‘나쁜 코드’의 본질적인 차이를 목격하게 된다. 국가에 질서와 혼돈이 있듯이, 소프트웨어에도 그와 같은 풍경이 펼쳐진다.

  • 함수 수준에서: 하나의 함수가 데이터를 조회하고(생산자), 그 데이터의 유효성을 검증하며(수호자), 실패 시 예외를 던져 프로그램의 흐름을 제어한다면(통치자), 이 함수는 모든 역할을 독점하려는 ‘정의롭지 못한 폭군’ 과 같다. 반면 ‘정의로운 함수’ 는 오직 하나의 기능, 즉 ‘자기 일’에만 충실하며, 다른 기능들은 동료 함수들에게 겸손하게 위임하는 협력자다.

  • 클래스 수준에서: User라는 클래스가 사용자의 정보를 담는 책임과 함께, 그 정보를 데이터베이스에 저장하고(UserRepository), 웹에 표시하기 위해 JSON으로 변환하는(JsonConverter) 책임까지 진다면, 이 클래스는 자신의 본분을 망각하고 남의 일에 참견하는 월권적 존재다. ‘정의로운 클래스’ 는 오직 ‘사용자라는 개념을 책임지는 것’이라는 자기 일만 수행한다.

이처럼 각 요소가 자신의 경계를 넘어 서로의 역할을 침범할 때, 소프트웨어라는 국가는 무질서와 혼란에 빠지게 된다.

2.2 ‘정의로운 코드’의 정의

이러한 철학적 탐구를 바탕으로, 우리는 ‘좋은 코드’를 기술적 용어를 넘어선 새로운 차원에서 정의할 수 있다. 이것이 바로 ‘정의로운 코드’ 다.

정의로운 코드란, 소프트웨어의 각 구성요소가 자신의 고유한 기능을 명확히 갖고, 그 기능만을 온전히 수행하며, 다른 구성요소들과 조화로운 관계를 맺는 코드다.

이는 기술적 효율성을 넘어서는 존재론적 완성의 개념이다. 각 요소가 자신의 본성을 실현하고 “제 일”에만 충실할 때 전체가 아름다운 질서를 이룬다.

3. 현대 개발 원칙과의 수렴

3.1 구조적 일치: 시대를 초월한 지혜

놀랍게도 플라톤이 2,400년 전에 제시한 이 철학적 통찰은, 현대 소프트웨어 공학을 지탱하는 핵심 원칙들 속에 그대로 살아 숨 쉬고 있다.

단일 책임 원칙(SRP): 클래스가 단 하나의 변경 이유만을 가져야 한다는 SRP의 요구는, “한 사람이 한 가지 기술에 종사할 때” 가장 훌륭한 결과가 나온다는 플라톤의 주장과 완벽하게 일치한다. 여러 책임을 가진 클래스는 필연적으로 혼란을 낳는다.

관심사의 분리: 이는 국가에서 생산자, 수호자, 통치자의 역할을 명확히 분리해야 한다는 플라톤의 사상과 맞닿아 있다. 데이터 접근 로직, 비즈니스 로직, 프레젠테이션 로직이 뒤섞인 코드는 각자의 ‘기능’을 망각하고 서로의 일에 참견하는 ‘정의롭지 못한’ 상태이며, 유지보수를 악몽으로 만든다.

높은 응집도, 낮은 결합도: ‘높은 응집도’란 하나의 모듈이 자신의 ‘자기 일’에만 충실하게 뭉쳐 있는 상태를 의미한다. ‘낮은 결합도’는 각 모듈이 다른 모듈의 일에 “참견하지 않는 것”을 기술적으로 구현한 것이다. 이 두 가지가 달성될 때, 비로소 플라톤이 말한 ‘조화로운 질서’가 시스템 전체에 발현된다.

캡슐화: 이는 각 객체가 ‘자신의 것’을 지키고 외부의 부당한 간섭으로부터 스스로를 보호하는 원리다. 객체의 내부 상태를 숨기고 오직 공개된 메서드를 통해서만 상호작용하도록 강제하는 것은, 각자가 “남의 것을 취하지도 않도록 하고, 또한 제 것을 빼앗기지도 않도록 하는 것”이 올바름이라는 플라톤의 말을 떠올리게 한다.

이러한 구조적 일치는 결코 우연이 아니다. 좋은 설계의 본질은 시대를 초월하며, 결국 현대 개발 원칙들이 ‘왜’ 좋은지에 대한 근본적인 이유를 플라톤의 정의의 개념이 제공하는 셈이다. 단일 책임 원칙이나 관심사의 분리와 같은 원칙들은 단순히 경험적으로 발견된 실용적 규칙을 넘어선다. 그것들은, 앞서 1.2에서 살펴보았듯 ‘자기 일의 원칙’이라는 정의가 추구하는 궁극적인 상태, 즉 ‘조화로운 질서’라는 보편적 가치를 기술적으로 구현한 것이라 할 수 있다.

4. 비판적 검토: 이상과 현실의 대화

그러나 ‘정의로운 코드’라는 유추가 가진 한계 역시 명확히 해야 한다. 이어지는 비판적 검토는 이 주장을 약화시키는 것이 아니라, 오히려 그 적용 가능한 현실의 범위를 선명히 밝혀줄 것이다.

4.1 유추의 한계: 살아있는 코드와 이상 국가

소프트웨어는 플라톤이 상상했던 이상국가와는 근본적으로 다른 특성을 지닌다. 소프트웨어는 끊임없이 변화하는 요구사항 속에서 유기적으로 성장하는 살아있는 시스템이다. 반면 플라톤의 국가는 이상적인 질서가 한 번 확립되면 영원히 유지되는 정적인 모델에 가깝다. 현대 소프트웨어의 복잡성과 컴포넌트 간의 상호의존성은 고대 사회의 분업과는 비교할 수 없을 정도로 역동적이다.

그럼에도 불구하고, 이러한 차이점이 ‘자기 일의 원칙’의 가치를 훼손하지는 않는다. 이 원칙은 소프트웨어의 구체적인 구현을 지시하는 ‘청사진’이 아니라, 변화와 복잡성 속에서 우리가 지향해야 할 방향을 알려주는 ‘나침반’ 이기 때문이다. 오히려 시스템이 역동적일수록, 각 부분이 자신의 역할에 충실해야 전체의 안정성이 유지된다는 근본 원리는 더욱 중요해진다.

4.2 대안적 해석: 철학 없이도 충분한가?

물론 단일 책임 원칙과 같은 원리들을 굳이 플라톤까지 거슬러 올라가지 않고도 설명할 수 있다. 유지보수 비용을 줄이려는 실용주의적 해석, 복잡한 환경에서 살아남은 설계 패턴이라는 진화론적 해석, 혹은 인간의 인지적 한계에 맞춘 설계 방식이라는 인지과학적 해석 모두 타당하다.

하지만, 이러한 해석들은 ‘무엇(What)’과 ‘어떻게(How)’는 설명할 수 있어도, 좋은 설계가 우리에게 왜 그토록 깊은 만족감과 아름다움을 주는가라는 ‘왜(Why)’ 의 질문에는 온전히 답하지 못한다. 철학적 접근은 다른 해석들을 대체하는 것이 아니라, 그것들을 더 깊은 차원에서 통합하는 ‘근본적인 이유’ 를 제공한다. 실용적 전략이 ‘질서와 조화’라는 보편적 가치와 맞닿아 있음을 이해할 때, 우리는 비로소 코드 작성의 기술적 행위를 넘어 철학적 실천의 영역으로 들어설 수 있다.

4.3 적용 범위의 한계: 언제 정의를 유보해야 하는가?

‘정의로운 코드’라는 이상은 진공 속에서 존재하지 않는다. 소프트웨어 개발은 철학적 이상을 실현하는 예술이기에 앞서, 제한된 자원(시간, 인력, 비용) 안에서 현실의 문제를 해결해야 하는 실용적인 행위다. 따라서 모든 개발자는 이상과 현실 사이에서 끊임없이 트레이드 오프(Trade-off) 를 결단해야 한다.

완벽하게 정의로운 설계를 추구하는 것이 오히려 비즈니스 목표 달성에 해가 되는 경우, 우리는 기꺼이 ‘차선’을 선택하는 지혜를 발휘해야 한다. 시장 출시가 급한 신규 프로젝트든, 복잡한 제약 속에서 기능을 추가해야 하는 거대한 시스템이든, 모든 상황은 우리에게 ‘중용(中庸)’의 미덕을 요구한다. 때로는 빠르게 ‘작동하는 소프트웨어’를 전달하는 것이, 완벽한 구조를 추구하는 것보다 더 시급하고 중요한 ‘정의’가 될 수 있다.

이는 ‘정의로운 코드’라는 이상의 실패가 아니다. 오히려 그것은, 우리가 추구하는 이상이 얼마나 높은 가치를 지니는지를 알기에, 현실의 무게를 감당하며 그 이상을 향해 한 걸음이라도 더 나아가려는 전문가로서의 책임감을 증명하는 것이다. 이상은 모든 현실에 기계적으로 적용되기 위해 존재하는 것이 아니라, 혼란스러운 현실 속에서 우리가 더 나은 결정을 내리도록 길을 밝혀주는 별과 같은 역할을 한다.

5. 결론: 정의로운 코드, 그 철학적 실천을 향하여

우리는 ‘좋은 코드란 무엇인가?’라는 소박한 질문에서 출발하여, 2,400년 전 플라톤의 철학이라는 깊은 바다를 항해했다. 이 여정을 통해 우리는 코드 작성이라는 기술적 행위가 ‘질서’와 ‘조화’를 창조하는 철학적 실천이 될 수 있음을 목격했다. 이제 ‘정의로운 코드’라는 나침반이 우리 개발자들의 여정에 어떤 실천적 함의를 던져주는지 숙고하며 이 글을 마무리하고자 한다.

5.1 장인으로서의 책임: 단순한 구현을 넘어

‘정의로운 코드’라는 개념은 우리에게 코드 작성을 바라보는 새로운 관점을 제시한다. 그것은 단순히 주어진 기능을 구현하는 기술적 행위를 넘어, 시스템 전체의 건강과 미래를 책임지는 ‘장인(Craftsman)’의 행위로 확장된다.

좋은 장인이 나무의 결을 살려 최적의 형태를 찾아내듯, 우리 역시 코드의 구조 속에서 최선의 질서를 고민하게 된다. 하나의 함수, 하나의 클래스를 설계하는 것은 시스템이라는 작은 공동체 안에서 각 시민에게 가장 적합한 역할을 부여하고 그 책임의 경계를 설정하는 일이다. 이것은 더 나은 결과물을 만들기 위해 우리가 매일 마주하는 ‘전문가로서의 신중한 선택’ 이다.

이제 우리는 키보드 앞에서 스스로에게 묻게 될 것이다. “이 코드는 자신의 ‘자기 일’에 충실한가? 이것은 시스템 전체의 ‘조화로운 질서’에 기여하고 있는가?” 이 질문을 품는 개발자는 단순히 코드를 생산하는 것을 넘어, 코드 속에 장기적인 안정성과 명료함을 구현하는 창조자가 된다.

5.2 이상과 현실의 변증법: 더 나은 타협을 위한 별

물론 우리는 4장에서 살펴보았듯, 늘 시간과 자원의 제약이라는 현실의 중력 속에서 살아간다. 비즈니스의 요구는 때로 우리에게 완벽한 정의가 아닌 ‘차선’의 타협을 강요한다.

그러나 ‘정의로운 코드’라는 이상은 바로 이 지점에서 가장 밝게 빛난다. 그것은 모든 상황에 기계적으로 적용해야 할 율법이 아니라, 어두운 현실 속에서 더 나은 타협을 가능하게 하는 ‘북극성’ 이다. 우리가 어디를 향해 가야 하는지 알고 있을 때, 우리는 길을 잃더라도 최소한 방향을 잃지는 않는다. 현실의 제약 속에서 ‘정의’를 향한 최선의 노력을 담아내는 것, 이것이야말로 진정한 전문가의 책무이자 이 철학적 탐구의 가장 현실적인 결론이다.

5.3 미래를 향한 제언: 우리에게는 철학이 필요하다

소프트웨어가 세상을 집어삼키는 시대에, 우리는 그 어느 때보다 더 깊은 성찰을 요구받고 있다. 인공지능이 코드를 생성하고 기술적 효율성이 극대화될 미래에, 인간 개발자의 대체 불가능한 가치는 어디에 있을까? 그것은 바로 ‘왜’를 묻는 능력, 즉 좋음과 올바름, 질서와 조화를 추구하는 철학적 사유에 있을 것이다.

단순히 기술 규칙을 암기하는 교육을 넘어, 그 규칙들의 철학적 근거를 탐구하는 문화가 필요하다. 기술적 효율성만으로는 부족하다. 우리에게는 질서와 조화, 아름다움과 정의를 추구하는 소프트웨어, 그리고 그것을 만들 수 있는 ‘개발자’가 필요하다.

플라톤의 정의의 개념을 통해 본 소프트웨어 개발은 단순한 공학적 행위를 넘어선다. 그것은 혼돈에 질서를 부여하고, 무질서 속에서 조화를 창조하는 고귀한 실천이다.

결국 좋은 코드란, 각 부분이 ‘자기 일’을 다하는 코드다. 이것이 2,400년 전 철학자로부터 배운, 현실 속에서 분투하는 현대 개발자를 위한 영원한 교훈이다.


참고 문헌

댓글 공유

도입

동시성 제어는 여러 사용자가 동시에 시스템을 사용할 때 데이터 무결성을 유지하고 성능 저하를 방지하는 데 중요한 역할을 한다. 특히 분산 환경에서는 이러한 문제가 더욱 두드러지며, 이를 해결하기 위한 방법으로 Redis와 Redisson 라이브러리가 자주 사용된다. 본 포스팅에서는 동시성 제어를 위해 사용되는 Redisson의 tryLock 메서드가 어떻게 작동하는지 소개한다.
본 포스팅은 Redisson 3.41.0 버전을 기준으로 작성되었다.


이론적 배경

1. 동시성 제어의 필요성

  • 동시성 이슈는 여러 프로세스가 동시에 동일한 자원에 접근할 때 발생한다. 이를 해결하기 위해 락(lock) 메커니즘이 사용된다.
  • 단일 시스템에서는 일반적인 락으로 해결할 수 있지만, 분산 시스템에서는 분산 락이 필요하다.

2. Redis와 Redisson

  • Redis는 인메모리 데이터베이스로, 높은 성능과 다양한 데이터 구조를 제공한다.
  • Redisson은 Redis 기반의 클라이언트 라이브러리로, 분산 락 및 다양한 동시성 제어 기능을 지원한다.
  • Redisson을 이용한 실제적인 분산락 적용 방법은 이전 포스팅 동시성 이슈와 Redis( Redisson )를 이용한 해결방법에서 확인할 수 있다.

tryLock 메서드의 작동 원리

Redisson의 tryLock 메서드는 분산 락을 구현하는 데 핵심 역할을 한다. 아래는 주요 작동 원리와 코드에 대한 설명이며, 아래 코드는 Redisson 3.41.0 기준으로 동작한다. 특정 버전에서는 내부 구현이 달라질 수 있으니 공식 문서를 참고하기 바란다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 1. 최초 락 획득 시도
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
// 2. 타이머 갱신
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
// 3. 락에 대한 pub/sub 채널 구독
current = System.currentTimeMillis();
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);

try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException("Unable to acquire subscription lock after " + time + "ms. Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
this.unsubscribe(res, threadId);
}

});
}

this.acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException var22) {
ExecutionException e = var22;
LOGGER.error(e.getMessage(), e);
this.acquireFailed(waitTime, unit, threadId);
return false;
}

boolean var16;
try {
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
boolean var25 = false;
return var25;
}

do {
long currentTime = System.currentTimeMillis();
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
var16 = true;
return var16;
}

time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}

currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}

time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);

this.acquireFailed(waitTime, unit, threadId);
var16 = false;
} finally {
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}

return var16;
}
}
}

1. 최초 락 획득 시도

1
2
3
4
5
6
7
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
// ttl != null이면 락이 선점되어 있음을 의미
...
}
  • this.tryAcquire()는 락 획득 가능 여부를 확인한다.
    • 반환값이 null인 경우, 현재 락을 바로 획득할 수 있음을 의미한다.
    • 반환값이 null이 아닌 경우, 이미 락이 선점되었거나 대기가 필요함을 나타낸다.

2. 타이머 갱신

1
2
3
4
5
6
7
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
...
}
  • 남은 대기 시간을 갱신하며, 시간이 0 이하인 경우 락 획득을 포기한다.

3. 락에 대한 pub/sub 채널 구독

1
2
3
4
5
6
7
8
9
10
current = System.currentTimeMillis();
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);

try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
...
} catch (ExecutionException var22) {
...
}
  • subscribe() 는 Pub/Sub를 통해 락 이벤트를 감지할 준비를 한다.
  • subscribeFuture.get()을 호출해 설정된 시간 동안 구독이 성공하기를 기다린다.
    • 시간이 초과되면(TimeoutException) 구독을 종료하고 false를 반환한다.
    • ExecutionException이 발생하면 에러를 로깅하고 false를 반환한다.
1
2
3
protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {
return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
}
  • threadId는 구독 채널 생성에 직접 사용되지 않고, 락 이름(lock name)과 채널 ID를 기반으로 Pub/Sub 채널이 생성된다.
  • 이 과정은 락 해제 이벤트를 감지하기 위해 필수적이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public CompletableFuture<E> subscribe(String entryName, String channelName) {
// a. Semaphore 생성 및 acquire 호출
AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture();
semaphore.acquire().thenAccept((c) -> {
if (newPromise.isDone()) {
semaphore.release();
} else {
// b. Pub/Sub 채널 확인 및 생성
E entry = (PubSubEntry)this.entries.get(entryName);
if (entry != null) {
// (1) 기존 엔트리가 있는 경우
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (2) 새로운 엔트리를 생성
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (3) Redis Pub/Sub 연결 로직 수행
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
// (4) Pub/Sub 구독 상태 관리 및 실패 처리
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
}
}
}
});
return newPromise;
}

a. Semaphore 생성 및 acquire 호출

1
2
3
4
5
6
7
8
9
10
AsyncSemaphore semaphore = this.service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture();
semaphore.acquire().thenAccept((c) -> {
if (newPromise.isDone()) {
semaphore.release();
} else {
...
}
...
});
  • getSemaphore()를 통해 해당 채널 이름에 대한 비동기 세마포어를 가져온다.
    • 여러 스레드나 인스턴스에서 동일한 채널에 구독 요청이 들어올 수 있으므로, 세마포어를 사용해 동시성을 제어한다.
  • acquire()를 호출하여 락 획득을 시도한다.
    • 이 작업은 비동기로 수행되며, 락 획득이 완료되면 thenAccept 블록이 실행된다.
    • 이 때 획득 시도하는 락은 채널 구독을 위한 락이다.
    • 구독 요청은 AsyncSemaphore 내부의 큐(FastRemovalQueue)를 통해 순서대로 처리된다.
  • 작업 완료 상태 확인 후 락 해제:
    • 만약 newPromise.isDone() 상태라면, 작업이 이미 완료된 것이므로 락을 해제한다.
    • 이는 동일한 채널을 구독 중인 다른 스레드가 작업을 이미 완료했음을 감지하기 위함이다.

b. Pub/Sub 채널 확인 및 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
E entry = (PubSubEntry)this.entries.get(entryName);
if (entry != null) {
// (1) 기존 엔트리가 있는 경우
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (2) 새로운 엔트리를 생성
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
} else {
// (3) Redis Pub/Sub 연결 로직 수행
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
// (4) Pub/Sub 구독 상태 관리 및 실패 처리
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
}
}
  • lock key(entryName)로 Pub/Sub 채널을 확인하거나 생성한다.
    • entries 맵에서 entryName에 해당하는 채널을 가져오고, 이미 존재하면 재사용한다.
    • 채널이 없을 경우 새로 생성해 등록한다.

(1) 기존 채널이 있는 경우:

1
2
3
4
5
6
7
8
9
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
  • entry.acquire()를 호출해 채널을 재사용한다.
  • 이후 semaphore.release()로 채널 구독을 위한 락을 해제한다.
  • 작업 완료 시 entry.getPromise()로 결과를 처리한다.

(2) 새 채널을 생성하는 경우:

1
2
3
4
5
6
7
8
9
10
11
12
13
E value = this.createEntry(newPromise);
value.acquire();
E oldValue = (PubSubEntry)this.entries.putIfAbsent(entryName, value);
if (oldValue != null) {
oldValue.acquire();
semaphore.release();
oldValue.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
} else {
newPromise.complete(r);
}
});
  • createEntry(newPromise)로 새로운 엔트리를 생성한다.
  • putIfAbsent(entryName, value)를 통해 이미 생성된 엔트리가 있는지 확인한다.
  • 기존 엔트리가 있으면 이를 사용하고, 없으면 새로 생성한 엔트리를 등록한다.

(3) Redis Pub/Sub 채널 연결:

1
2
3
4
5
6
7
8
RedisPubSubListener<Object> listener = this.createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = this.service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, new RedisPubSubListener[]{listener});
newPromise.whenComplete((r, e) -> {
if (e != null) {
s.completeExceptionally(e);
}

});
  • createListener(channelName, value)를 통해 실제 Redis Pub/Sub 채널을 구독한다.
  • subscribeNoTimeout()로 비동기적으로 연결을 시도한다.

(4) Pub/Sub 구독 상태 관리 및 실패 처리:

1
2
3
4
5
6
7
8
9
10
11
s.whenComplete((r, e) -> {
if (e != null) {
this.entries.remove(entryName);
value.getPromise().completeExceptionally(e);
} else {
if (!value.getPromise().complete(value) && value.getPromise().isCompletedExceptionally()) {
this.entries.remove(entryName);
}

}
});
  • 구독 실패 처리:
    • e != null 조건은 Pub/Sub 구독 중 예외가 발생했음을 의미한다.
    • 실패한 경우, 다음 작업을 수행한다:
      • this.entries.remove(entryName): 실패한 구독에 해당하는 entryNameentries 맵에서 제거한다.
      • value.getPromise().completeExceptionally(e): 예외를 호출자에게 전달하여 구독 실패 원인을 알린다.
  • 구독 성공 상태 관리:
    • 구독이 성공적으로 완료되었더라도, 추가적인 상태 확인을 수행한다:
    • value.getPromise().complete(value): valuePromise로 완료하고 성공 여부를 반환한다.
    • Promise가 이미 완료되었거나(isCompletedExceptionally로 확인) 정상적으로 완료되지 않은 경우:
      • this.entries.remove(entryName): 일관성을 유지하기 위해 해당 엔트리를 제거한다.
  • 상태 확인의 목적:
    • Redis Pub/Sub 구독 실패 및 이상 상태를 감지하고, 관련 자원을 정리하여 시스템 일관성을 보장한다.
    • 실패나 이상 상태 시 적절한 정리 작업이 이루어지도록 설계되어 있다.

c. 락에 대한 Pub/Sub 채널 구독 정리

1. Semaphore 생성 및 acquire 호출

  • AsyncSemaphore는 채널 단위의 subscribe 작업에 대한 동시성을 제어한다.
  • acquire() 호출 시, 내부적으로 대기 큐가 생성되어 락이 해제될 때 대기 중인 작업이 순차적으로 처리된다.
  • 이를 통해 여러 스레드가 동일한 채널에 대해 동시에 subscribe 요청을 보내더라도 안전하게 작업이 처리된다.
  • 작업 완료 상태(newPromise.isDone()) 확인 후, 이미 완료된 작업에 대한 락을 해제한다.

2. Pub/Sub 채널 확인 및 생성

  • entries 맵에서 entryName에 해당하는 채널을 가져오고, 이미 존재하면 이를 재사용한다.
  • 존재하지 않을 경우:
    • createEntry(newPromise)로 새로운 엔트리를 생성하여 등록한다.
    • putIfAbsent(entryName, value)를 통해 중복 등록을 방지하고, 기존 엔트리가 있으면 이를 재사용한다.
    • 기존 엔트리와 새 엔트리는 동일한 처리 로직으로 이어지며, 작업이 완료되면 결과를 호출자에게 전달한다.

3. Redis Pub/Sub 채널 연결

  • 새로운 엔트리에 대해 createListener(channelName, value)를 통해 Redis Pub/Sub 채널을 구독한다.
  • subscribeNoTimeout() 를 사용하여 비동기적으로 구독을 시도한다.
  • 구독 중 예외 발생 시, 구독 결과를 처리하는 CompletableFuture에 예외 상태를 전달한다.

4. Pub/Sub 구독 상태 관리 및 실패 처리

  • Pub/Sub 구독이 성공했는지 여부를 확인하고, 실패 또는 이상 상태가 발생하면 관련 자원을 정리한다:
    • e != null(구독 실패)일 경우:
      • entries 맵에서 해당 entryName을 제거한다.
      • value.getPromise().completeExceptionally(e)로 구독 실패 원인을 호출자에게 전달한다.
    • 구독이 성공했더라도:
      • value.getPromise().complete(value) 호출로 성공 여부를 확인한다.
      • 성공하지 못했거나(isCompletedExceptionally) 예외 상태라면, entries에서 해당 엔트리를 제거한다.
  • 이를 통해 시스템의 데이터 일관성을 유지하고, 자원 누수를 방지한다.

결과적으로

  • Semaphore를 사용해 subscribe 요청의 동시성을 관리한다.
  • Pub/Sub 채널의 상태를 확인하거나 생성하고, Redis 채널과 비동기적으로 연결한다.
  • 구독 성공 및 실패 상태를 처리하고, 필요시 자원을 정리하여 시스템의 안정성을 보장한다.

여기까지가 락 이벤트를 감지하기 위한 Pub/Sub 채널에 구독하기 위한 로직이다.

4. 락 획득 재시도 및 Latch 대기 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
do {
long currentTime = System.currentTimeMillis();
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
var16 = true;
return var16;
}

time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}

currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}

time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);

this.acquireFailed(waitTime, unit, threadId);
var16 = false;

a. 락 획득 재시도

  • Pub/Sub 채널 구독이 성공한 상태에서 다시 tryAcquire를 반복 호출해 락 획득을 재시도한다.
  • ttl == null일 경우, 즉시 락을 획득할 수 있으므로 true를 반환하며 종료한다.

b. 남은 시간 갱신

  • 락 획득을 재시도하기 전에 현재 시간을 기준으로 남은 대기 시간을 갱신한다(time -= ...).
  • 남은 대기 시간이 없으면(time <= 0), acquireFailed() 를 호출해 실패 처리를 진행한 후 false를 반환한다.

c. Latch 대기

  • ttl 값에 따라 Latch를 사용해 일정 시간 대기한다.
    • ttl >= 0인 경우: 락 해제까지의 남은 시간 동안 대기한다.
    • ttl < 0 또는 현재 남은 시간 이하인 경우: 설정된 대기 시간까지 대기한다.
    • Latch다른 스레드/프로세스가 락을 해제하거나 갱신하는 이벤트가 발생하면 해제되는 동기화 장치로 사용되며, Redisson에서는 Semaphore를 Latch를 사용한다.

d. 반복 조건

  • do-while 루프를 통해 남은 대기 시간이 소진될 때까지 락 획득을 반복 시도한다.
  • 대기 시간이 소진되면 acquireFailed()를 호출하고 false를 반환하며 종료한다.

5. 구독 해제

1
2
3
} finally {
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
  • Pub/Sub 채널을 구독했으므로, 메서드를 종료하기 전에 반드시 unsubscribe 메서드를 호출해 리소스를 해제한다.
  • 이는 락 획득 여부와 관계없이 실행되며, Pub/Sub 구독을 통해 사용된 자원을 정리하는 역할을 한다.

tryLock()의 장점 및 한계

장점

  1. 효율적인 이벤트 기반 처리

    • Pub/Sub 메커니즘을 활용하여 락 해제 이벤트를 감지함으로써, 불필요한 대기 시간을 최소화한다.
    • 락이 해제될 가능성이 있는 경우 반복적으로 대기하거나 재시도하지 않고 이벤트 기반으로 처리하므로 자원을 효율적으로 사용한다.
  2. 정교한 타이머 관리

    • 남은 대기 시간을 지속적으로 갱신하며, 설정된 제한 시간 내에 락 획득 여부를 결정한다.
    • 대기 시간이 초과될 경우 즉시 실패 처리를 진행하므로, 클라이언트 응답 지연을 방지한다.
  3. 안정적인 자원 정리

    • 락 획득 여부와 관계없이 finally 블록을 통해 모든 구독 리소스를 해제하여 시스템 자원의 안정성을 유지한다.
  4. Latch를 활용한 동기화

    • 락 획득 시도가 실패하더라도, Latch를 통해 TTL 동안 대기하며 락 해제 가능성을 최대한 활용한다.

한계

  1. 락 획득 시도와 Pub/Sub의 비동기 처리 간의 간격

    • 락 획득 재시도와 Pub/Sub 이벤트 처리 사이의 간격에서 레이스 컨디션이 발생할 가능성이 있다. 락이 해제된 후 새로 락이 설정되는 순간에 간격이 생길 수 있으므로, 타이밍에 민감한 경우 신중한 설계가 필요하다.

      레이스 컨디션 발생 원인
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      do {
      long currentTime = System.currentTimeMillis();
      ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
      if (ttl == null) {
      var16 = true;
      return var16;
      }

      time -= System.currentTimeMillis() - currentTime;
      if (time <= 0L) {
      this.acquireFailed(waitTime, unit, threadId);
      var16 = false;
      return var16;
      }

      currentTime = System.currentTimeMillis();
      if (ttl >= 0L && ttl < time) {
      ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
      } else {
      ((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
      }

      time -= System.currentTimeMillis() - currentTime;
      } while(time > 0L);

      a. 첫 번째 tryAcquire 호출

      1
      ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
      • 현재 스레드에서 락을 획득하려 시도하지만, 락이 이미 다른 스레드에 의해 점유된 상태라 TTL(남은 유효 시간)을 반환받는다.
      • 이 시점에서 현재 스레드는 락 해제 이벤트를 기다릴 준비한다.

      b. 락 반납 발생

      • 락을 점유하고 있던 스레드(또는 프로세스)가 락을 반납한다.
      • 이 시점에서 락은 잠깐 동안 해제된 상태가 된다.

      c. 다른 스레드가 락을 채감

      • 락 해제 이벤트가 Pub/Sub를 통해 현재 스레드에 전달되기 전에, 다른 스레드가 tryAcquire()를 호출하여 락을 빠르게 획득한다.
      • 이로 인해 현재 스레드는 락 해제 이벤트를 감지하지 못하게 된다.

      d. 현재 스레드의 Pub/Sub 대기

      1
      2
      ((RedissonLockEntry) this.commandExecutor.getNow(subscribeFuture))
      .getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
      • 현재 스레드는 여전히 락 해제 이벤트를 기다리지만, 락은 이미 다른 스레드에 의해 점유된 상태이다.
      • 이로 인해 현재 스레드는 대기 시간을 낭비하거나, 최종적으로 time이 초과되어 락 획득에 실패할 수 있다.
  1. Pub/Sub 메시지 손실 가능성

    • Pub/Sub 시스템 특성상 구독자와 퍼블리셔 간 네트워크 이슈가 발생하면 락 해제 메시지를 놓칠 가능성이 있다. 이 경우 락을 획득하지 못하고 대기 시간을 낭비할 위험이 있다.
  2. 복잡한 로직으로 인한 디버깅 난이도

    • tryLock()의 로직이 복잡하여, 장애 발생 시 원인을 파악하거나 디버깅하는 데 시간이 소요될 수 있다.

결론

Redisson의 tryLock()은 분산 환경에서 동시성 제어를 효과적으로 구현하기 위해 설계된 복잡한 로직을 제공한다.

tryLock()은 동시성 제어에서 효율성과 안정성을 동시에 추구하는 설계로, 분산 환경에서의 락 처리 문제를 효과적으로 해결한다. 그러나 복잡한 로직 구조와 Pub/Sub 시스템의 특성을 고려하여 사용 환경에 적합한 설정을 적용하고, 잠재적인 한계를 관리할 수 있는 보완책을 함께 설계해야 한다.

본 글이 tryLock() 내부 로직에 대한 깊은 이해를 돕고, 동시성 제어가 필요한 시스템 설계 시 참고 자료가 되길 바라며, 실제적으로 Redisson을 활용하여 분산락을 적용하고자 하는 사용자는 이전 글 동시성 이슈와 Redis( Redisson )를 이용한 해결방법을 참고하길 바란다.

댓글 공유

  • page 1 of 1

Junggu Ji

author.bio


author.job