PG사 응답이 느려졌을 뿐인데 결제와 무관한 API까지 멈췄습니다. 스레드 고갈이 일어나는 과정과 타임아웃, 트랜잭션 경계, 벌크헤드, 서킷 브레이커를 순서대로 적용하며 겪은 문제를 정리했습니다.

 

어느 평일 오후, 결제 API 응답 시간이 올라간다는 알림이 왔습니다. 확인해보니 PG사 쪽이 느려진 상황이었습니다. 우리 잘못은 아니니 원천사 공지를 기다리면 되겠다고 생각했습니다.

4분 뒤, 전혀 예상하지 못한 알림이 이어졌습니다. 앨범 조회 API가 응답하지 않는다는 내용이었습니다. 결제와 아무 관련이 없는 엔드포인트였습니다. 그다음은 로그인이었고, 곧 헬스체크까지 실패했습니다.

외부 시스템 한 곳이 느려졌을 뿐인데 우리 서비스 전체가 멈춰 있었습니다. 이 글은 그날 이후 하나씩 뜯어고치면서, 각 대응책이 어디까지 유효하고 어디서부터 부족했는지를 정리한 기록입니다.


왜 전체가 멈췄을까

원인을 이해하려면 스레드 모델부터 봐야 합니다.

Spring MVC는 요청 하나당 스레드 하나를 배정합니다. 그 스레드는 요청이 끝날 때까지 다른 일을 하지 않습니다. 외부 API 응답을 기다리는 동안에도 마찬가지입니다. CPU는 놀고 있지만 스레드는 점유된 상태입니다.

톰캣의 기본 max-threads는 200입니다. 여기에 리틀의 법칙을 대입해보면 상황이 명확해집니다.

필요한 스레드 수 = 초당 요청 수 × 평균 응답 시간

평소 결제 요청이 초당 50건, 평균 응답이 0.2초였으니 스레드는 10개면 충분했습니다. 그런데 PG 응답이 8초로 늘어나자 계산이 이렇게 바뀝니다.

50 × 8 = 400

200개로는 감당이 안 됩니다. 스레드가 전부 PG 응답을 기다리는 데 묶이고, 새로 들어온 요청은 큐에서 대기합니다. 그 요청이 앨범 조회인지 결제인지는 상관없습니다. 스레드 풀은 공유 자원이니까요.

이게 첫 번째 답이었습니다. 느린 API 하나가 공유 자원을 전부 점유하면, 그 자원을 쓰는 모든 기능이 같이 죽습니다.


1차 대응: 타임아웃을 건다

가장 먼저 코드를 확인했습니다. 타임아웃 설정이 없었습니다.

 
java
@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();   // 타임아웃 없음
}

RestTemplate의 기본 구현인 SimpleClientHttpRequestFactory는 연결 타임아웃과 읽기 타임아웃이 모두 0, 즉 무한 대기입니다. 상대가 응답하지 않으면 우리 스레드는 영원히 기다립니다.

"PG는 보통 1초면 응답한다"는 경험은 정상일 때만 참입니다. 장애 상황에서 상대가 지켜주는 SLA는 없습니다.

 
java
@Bean
public RestClient pgRestClient(RestClient.Builder builder) {
    ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
            .withConnectTimeout(Duration.ofSeconds(1))
            .withReadTimeout(Duration.ofSeconds(3));

    return builder
            .requestFactory(ClientHttpRequestFactories.get(settings))
            .baseUrl(pgProperties.getBaseUrl())
            .build();
}

여기서 두 타임아웃의 차이를 짚고 넘어가겠습니다. 헷갈리기 쉬운 부분입니다.

  • connect timeout: TCP 연결을 맺기까지 기다리는 시간. 상대 서버가 죽었거나 방화벽에 막히면 여기서 걸립니다.
  • read timeout: 연결된 뒤 데이터를 기다리는 시간. 상대가 살아 있지만 느릴 때 걸립니다.

장애 상황에서 실제로 문제가 되는 건 대부분 read timeout 쪽입니다. 서버는 살아 있고 연결도 되는데 응답만 안 오는 상태니까요.

그런데 여기에도 함정이 있습니다.

read timeout은 "정해진 시간 동안 바이트가 하나도 오지 않을 때" 발동합니다. 상대가 1초에 몇 바이트씩 찔끔찔끔 보내면 타임아웃은 영원히 걸리지 않습니다. 실제로 응답 본문이 큰 API에서 이런 경우를 만난 적이 있습니다.

전체 요청에 대한 상한이 따로 필요합니다. Apache HttpClient 5라면 responseTimeout, OkHttp라면 callTimeout이 그 역할을 합니다.


타임아웃만으로는 부족했다

타임아웃을 3초로 걸고 나서 다시 계산해봤습니다.

50 × 3 = 150

200개 중 150개입니다. 아슬아슬하게 버팁니다. 그런데 트래픽이 평소의 1.5배만 돼도 다시 고갈됩니다. 프로모션이 있는 날이면 그냥 넘어갑니다.

여기서 알게 된 게 있습니다. 타임아웃은 장애의 지속 시간을 줄일 뿐, 장애를 격리하지는 않습니다. 스레드가 8초 대신 3초 묶이는 것뿐이지, 여전히 같은 풀을 갉아먹습니다.


진짜 범인은 따로 있었다

그런데 계산이 좀 안 맞았습니다. 초당 50건이면 톰캣 스레드가 소진되는 데 시간이 걸릴 텐데, 실제로는 훨씬 빨리 전체가 멈췄습니다. 로그를 다시 보니 톰캣 스레드보다 먼저 터진 게 있었습니다. DB 커넥션 풀이었습니다.

코드는 이렇게 생겼습니다.

 
java
@Transactional
public PaymentResponse pay(PaymentRequest request) {
    Payment payment = paymentRepository.save(Payment.pending(request));

    PgResponse pgResponse = pgClient.approve(request);   // 8초

    payment.complete(pgResponse);
    return PaymentResponse.from(payment);
}

@Transactional이 붙어 있으니 메서드 시작 시점에 DB 커넥션을 하나 가져옵니다. 그리고 메서드가 끝날 때까지 반납하지 않습니다. PG 응답을 8초 기다리는 동안 DB 커넥션도 8초 동안 붙잡혀 있습니다.

HikariCP 기본 풀 사이즈는 10입니다. 결제 요청 10건이 동시에 PG를 기다리기 시작하면 그 순간 커넥션 풀이 비어버립니다. 그리고 DB를 쓰는 모든 API가 커넥션을 못 받습니다.

앨범 조회가 죽은 이유가 여기 있었습니다. 앨범 조회는 톰캣 스레드는 받았지만 DB 커넥션을 기다리다 타임아웃이 났던 겁니다.

고치는 방법은 단순합니다. 외부 호출을 트랜잭션 밖으로 빼는 것입니다.

 
java
public PaymentResponse pay(PaymentRequest request) {
    Payment payment = paymentCommandService.createPending(request);   // 짧은 트랜잭션

    PgResponse pgResponse = pgClient.approve(request);                // 트랜잭션 밖

    return paymentCommandService.complete(payment.getId(), pgResponse);   // 짧은 트랜잭션
}

 

대신 중간 상태가 생깁니다. PENDING으로 저장됐는데 PG 호출 직후 서버가 죽으면 그 건은 붕 뜹니다. 이걸 정리할 배치가 별도로 필요합니다.

이 트레이드오프는 피할 수 없다고 봅니다. 한 트랜잭션으로 묶어서 일관성을 지키려 하면 커넥션이 외부 시스템 응답 시간에 인질로 잡히고, 풀어주면 중간 상태 보정이 필요해집니다. 저희는 후자를 택했습니다. 보정은 배치로 할 수 있지만, 커넥션 고갈은 전사 장애가 되니까요.


그래도 남는 문제: 스레드 격리

DB 커넥션을 지켰어도 톰캣 스레드는 여전히 공유 자원입니다. PG가 느려지면 결제 요청들이 스레드를 계속 점유합니다.

그래서 스레드 풀을 분리했습니다. 배에 격벽을 세워 한 구역이 침수돼도 전체가 가라앉지 않게 하는 것과 같아서 벌크헤드라고 부릅니다.

 
yaml
resilience4j:
  thread-pool-bulkhead:
    instances:
      pgApprove:
        max-thread-pool-size: 20
        core-thread-pool-size: 20
        queue-capacity: 10

 

PG 호출은 전용 풀 20개 안에서만 일어납니다. 그 이상은 즉시 거절됩니다. 결제는 실패하더라도 나머지 기능은 살아남습니다.

뚫리는 지점: 거절된 요청을 어떻게 처리할 것인가

벌크헤드가 요청을 거절하면 사용자에게 뭘 보여줄지 정해야 합니다. 여기서 그냥 500을 내려주면 클라이언트가 재시도를 겁니다. 이미 포화된 시스템에 요청이 더 들어옵니다.

명시적인 상태 코드와 함께 재시도 간격을 알려주는 쪽이 낫습니다. 저희는 503에 Retry-After를 실어 보내고, 클라이언트에서는 지수 백오프로 처리하도록 맞췄습니다.


서킷 브레이커는 언제 열어야 하나

마지막 단계입니다. 상대가 계속 실패하는데 계속 호출하는 건 양쪽 모두에게 손해입니다. 일정 비율 이상 실패하면 회로를 열어 아예 시도조차 하지 않습니다.

 
yaml
resilience4j:
  circuitbreaker:
    instances:
      pgApprove:
        sliding-window-type: TIME_BASED
        sliding-window-size: 60
        minimum-number-of-calls: 20
        failure-rate-threshold: 50
        slow-call-duration-threshold: 3s
        slow-call-rate-threshold: 50
        wait-duration-in-open-state: 20s
        permitted-number-of-calls-in-half-open-state: 5

 

뚫리는 지점 1: 느린 응답은 실패가 아니다

가장 흔한 실수입니다. failure-rate-threshold만 설정해두면, PG가 8초 걸려서 200 OK를 주는 상황에서는 회로가 영원히 안 열립니다. 실패한 게 아니라 느린 것뿐이니까요.

우리를 죽인 건 에러가 아니라 지연이었습니다. slow-call-duration-threshold와 slow-call-rate-threshold를 반드시 같이 설정해야 합니다.

뚫리는 지점 2: 윈도우 크기

너무 크게 잡으면 장애가 한참 진행된 뒤에야 열립니다. 너무 작게 잡으면 일시적인 지연에도 열려서 멀쩡한 결제를 막습니다. minimum-number-of-calls를 같이 조정해서, 호출량이 적은 새벽에 두어 건 실패했다고 회로가 열리는 일을 막아야 합니다.

뚫리는 지점 3: 결제에서 회로를 여는 게 맞는가

이건 기술 문제가 아니라 도메인 판단입니다.

승인 요청은 회로를 열면 매출이 막힙니다. 반대로 열지 않으면 전체 장애로 번집니다. 저희는 엔드포인트마다 다르게 가져갔습니다. 조회성 API는 공격적으로 열고, 승인과 취소는 보수적으로 열되 벌크헤드로 피해 범위를 제한하는 쪽입니다.

뚫리는 지점 4: 폴백이 더 위험할 때가 있다

회로가 열렸을 때 폴백으로 뭘 돌려줄지도 신중해야 합니다. 조회 API라면 캐시된 값을 주면 됩니다. 그런데 결제 승인에 폴백으로 성공을 돌려주면 돈을 안 받고 상품을 주는 코드가 됩니다.

폴백이 없는 게 나은 경우도 있습니다. 실패는 실패로 전달하고, 대신 사용자에게 상황을 정확히 알려주는 편이 안전합니다.


정리하면

각 대응책이 담당하는 구간이 다릅니다.

대응막는 것못 막는 것
타임아웃 무한 대기 자원 점유 자체
트랜잭션 분리 DB 커넥션 고갈 톰캣 스레드 고갈
벌크헤드 장애 전파 해당 기능의 장애
서킷 브레이커 무의미한 재시도 상대의 복구

이 중 하나만 적용하면 반드시 다른 곳에서 터집니다. 저희도 타임아웃만 걸고 끝냈다고 생각했다가 커넥션 풀에서 다시 만났습니다.


Lesson & Learn

외부 호출은 트랜잭션 안에 두지 마세요. 이 한 줄이 그날 장애의 절반이었습니다. 코드 리뷰에서 @Transactional 메서드 안에 HTTP 클라이언트 호출이 있으면 무조건 짚고 넘어가는 규칙을 만들었습니다.

기본값을 믿지 마세요. RestTemplate의 타임아웃은 무한이고, HikariCP 풀은 10개이고, 톰캣 스레드는 200개입니다. 이 숫자들을 한 번도 확인하지 않고 운영하는 서비스가 생각보다 많습니다. 저희도 그랬습니다.

장애는 에러가 아니라 지연으로 옵니다. 에러는 빨리 실패하니 오히려 다루기 쉽습니다. 무서운 건 응답이 오긴 오는데 느린 경우입니다. 모니터링도 에러율만 보면 놓칩니다. p99 응답 시간과 스레드 풀 사용률을 같이 보셔야 합니다.

부하 테스트에 "느린 상대"를 넣으세요. 응답을 10초 지연시키는 목 서버를 하나 띄워두고 돌려보면, 어디가 먼저 터지는지 15분이면 알 수 있습니다. 실제 장애를 기다릴 필요가 없습니다.


읽어주셔서 감사합니다. 비슷한 장애를 겪으셨거나 다르게 푸신 경험이 있다면 댓글로 공유해주시면 좋겠습니다.

UNIQUE 제약을 걸어뒀는데도 중복 결제가 들어왔습니다. 멱등키를 Redis에 둘지 RDB에 둘지 결정하는 기준과, 파티션 테이블에서 UNIQUE가 무력해지는 자정 경계 문제를 정리했습니다.

 

어느 날 아침, 같은 사용자에게 동일한 금액이 두 번 청구됐다는 CS가 올라왔습니다. 결제 시각을 보니 하나는 23시 59분 58초, 다른 하나는 다음 날 00시 00분 03초였습니다.

이상한 건 두 건의 멱등키가 같았다는 점입니다. 멱등키 테이블에는 UNIQUE 제약이 걸려 있었습니다. 같은 키로 두 행이 들어갈 수 없는 구조인데 들어가 있었습니다.

결론부터 말하면 코드는 아무 문제가 없었습니다. 문제는 DDL에 있었습니다. 이 글은 그 원인을 찾아가는 과정에서 정리한, 멱등키를 어디에 어떻게 저장할 것인가에 대한 기록입니다.


멱등키가 실제로 하는 일

먼저 용어부터 맞추고 가겠습니다.

클라이언트가 결제를 요청할 때 고유한 키를 하나 실어 보냅니다. 서버는 그 키를 기록해두고, 같은 키로 요청이 또 들어오면 결제를 다시 하지 않고 처음 처리했던 결과를 그대로 돌려줍니다.

 
http
POST /payments
Idempotency-Key: 0f3a9c21-1e88-4d7b-9a0e-6b2d5f41c7aa

필요한 이유는 재시도 때문입니다. 클라이언트가 타임아웃을 냈을 때 서버는 이미 결제를 끝냈을 수 있습니다. 그런데 클라이언트 입장에서는 성공인지 실패인지 알 수 없으니 다시 보냅니다. 멱등키가 없으면 이 재시도가 그대로 두 번째 결제가 됩니다.

여기서 핵심은 "기록해둔다"는 부분이 전부라는 것입니다. 서비스 로직은 몇 줄 안 됩니다. 실제로 멱등성을 보장하는 건 그 키를 담아두는 저장소의 성질입니다.


저장소에 요구되는 다섯 가지

그래서 저장소를 고르기 전에 뭘 만족해야 하는지부터 정리했습니다.

 

원자적 선점이 첫 번째입니다. "키가 있는지 조회하고, 없으면 기록한다"를 두 번의 연산으로 나누면 그 사이에 다른 요청이 끼어듭니다. 조회와 기록이 하나의 연산이어야 합니다.

지속성은 저장소나 서버가 재시작해도 키가 남아 있어야 한다는 뜻입니다. 결제 직후 장애가 났는데 키가 사라지면, 복구 후 들어온 재시도는 그대로 통과합니다.

응답 보관도 필요합니다. 두 번째 요청에 "중복입니다" 에러를 주면 클라이언트는 실패로 인식하고 또 시도합니다. 중복을 막으려다 중복을 유발하는 구조가 됩니다. 첫 번째 응답을 그대로 돌려줘야 하고, 그러려면 응답 본문을 어딘가 들고 있어야 합니다.

나머지 둘은 자동 만료와 낮은 지연입니다. 멱등키 조회는 모든 결제 요청의 맨 앞에 붙습니다. 여기서 5ms가 늘면 결제 API 전체가 5ms 느려집니다.

표에서 보시듯 다섯 개를 다 만족하는 저장소는 없습니다. 그래서 이건 선택의 문제가 됩니다.


선택 1. Redis에 두기

가장 먼저 검토한 방향입니다. SET NX는 원자적이고, TTL이 내장돼 있고, 응답 시간도 1ms 안쪽입니다. 다섯 가지 중 세 개를 바로 해결합니다.

 
java
public PaymentResponse pay(String key, PaymentRequest request) {
    Boolean acquired = redis.opsForValue()
            .setIfAbsent(KEY_PREFIX + key, IN_PROGRESS, Duration.ofMinutes(10));

    if (Boolean.FALSE.equals(acquired)) {
        String cached = redis.opsForValue().get(KEY_PREFIX + key);
        if (IN_PROGRESS.equals(cached)) {
            throw new BusinessException(ErrorCode.REQUEST_IN_PROGRESS);
        }
        return deserialize(cached);
    }
    PaymentResponse response = process(request);
    redis.opsForValue().set(KEY_PREFIX + key, serialize(response), Duration.ofHours(24));
    return response;
}

뚫리는 지점 1: 휘발성

AOF를 everysec로 두면 최대 1초치가 유실될 수 있습니다. 마스터가 키를 받고 레플리카에 복제하기 전에 죽으면 승격된 새 마스터에는 그 키가 없습니다. 하필 그 순간 재시도가 들어오면 중복 결제가 됩니다.

확률은 낮습니다. 다만 결제에서 "확률이 낮다"는 건 "월 수백만 건 중 몇 건은 일어난다"와 같은 말입니다.

뚫리는 지점 2: Redis가 죽었을 때 뭘 할 것인가

이게 더 중요한 문제였습니다. 조회에 실패했을 때 선택지는 둘뿐입니다.

  • fail-open: 일단 결제를 진행한다 → 중복 위험을 감수
  • fail-close: 결제를 거절한다 → Redis 장애가 곧 결제 전체 중단

어느 쪽도 편하지 않습니다. 그리고 이 결정을 미뤄두면, 장애가 났을 때 코드가 어느 쪽으로 동작하는지 아무도 모르는 상태가 됩니다. 저희는 이걸 명시적으로 정하고 이유까지 주석으로 남겨두는 쪽을 택했습니다.

뚫리는 지점 3: 증적이 남지 않는다

24시간 뒤에 키가 사라지면, 며칠 후 "이 결제가 왜 거절됐나요"라는 문의가 들어왔을 때 답할 근거가 없습니다. 정산 대사에서 불일치가 나와도 마찬가지입니다. 운영에서는 이게 생각보다 자주 발목을 잡았습니다.


선택 2. RDB에 두기

그래서 RDB를 봤습니다. 핵심은 조회 후 삽입이 아니라 삽입을 먼저 시도하는 것입니다. UNIQUE 제약이 원자적 선점을 대신해줍니다.

 
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public IdempotencyRecord occupy(String key) {
    try {
        return repository.save(IdempotencyRecord.inProgress(key));
    } catch (DataIntegrityViolationException e) {
        return repository.findByKey(key)
                .orElseThrow(() -> e);
    }
}

뚫리는 지점 1: 트랜잭션 경계

멱등키 INSERT를 결제 트랜잭션 안에 넣으면, 결제가 롤백될 때 키도 같이 사라집니다. 그러면 재시도가 통과합니다. 반대로 밖으로 빼면(REQUIRES_NEW) 결제가 실패해도 키가 남아서 사용자가 영영 재시도를 못 합니다.

그래서 기록에 상태가 필요합니다.

 
java
public enum IdempotencyStatus {
    IN_PROGRESS,   // 처리 중 — 같은 키로 오면 409
    COMPLETED,     // 완료 — 저장된 응답 그대로 반환
    FAILED         // 실패 — 재시도 허용
}

REQUIRES_NEW로 먼저 IN_PROGRESS를 선점해두고, 결제 결과에 따라 상태를 바꿉니다. 실패했으면 FAILED로 두어 재시도를 열어줍니다. 프로세스가 중간에 죽어 IN_PROGRESS로 남은 건은 일정 시간이 지나면 배치가 정리합니다.

뚫리는 지점 2: 테이블이 계속 커진다

결제 1건마다 멱등키 1행이 쌓입니다. 하루 수백만 건이면 한 달에 억 단위가 됩니다.

지우면 되지 않느냐고 할 수 있는데, 대량 DELETE는 언두 로그와 락을 유발해서 운영 중에 돌리기 부담스럽습니다. 청크로 잘라 새벽에 돌려도 복제 지연이 따라옵니다.

여기서 자연스럽게 파티셔닝을 떠올리게 됩니다. 날짜로 파티셔닝해두고 오래된 파티션을 DROP하면 DELETE 없이 즉시 정리됩니다. 저도 이 방향으로 갔습니다. 그리고 여기서 앞서 말한 사고를 만났습니다.


잠깐, 그전에 — 하이브리드

본론 전에 실제 구성만 짚고 가겠습니다. Redis와 RDB는 양자택일이 아니었습니다.

Redis가 앞에서 대부분의 중복을 쳐내고, RDB가 마지막 보증을 섭니다. Redis가 죽어도 RDB의 UNIQUE 제약은 살아 있으니 fail-open을 선택할 여지가 생깁니다. 응답 시간도 대부분의 요청에서는 Redis 조회만으로 끝납니다.

문제는 그 "마지막 보증"이 생각만큼 튼튼하지 않았다는 데 있었습니다.


진짜 함정: 파티션 테이블의 UNIQUE

MySQL에는 이런 제약이 있습니다.

파티셔닝된 테이블의 모든 UNIQUE 키는 파티셔닝 표현식에 사용된 컬럼을 전부 포함해야 한다.

즉 created_at으로 파티셔닝했다면 이건 만들 수 없습니다.

 
sql
UNIQUE KEY uk_key (idempotency_key)   -- 에러

가능한 건 이것뿐입니다.

 
sql
UNIQUE KEY uk_key (idempotency_key, created_at)

그리고 이 순간 UNIQUE 제약은 사실상 무력해집니다. 같은 멱등키라도 created_at이 다르면 서로 다른 행이니까요.

파티션을 날짜로 잘라뒀으니, 문제가 드러나는 지점도 하나뿐입니다.

23시 59분 58초에 결제 요청이 들어옵니다. PG 응답이 느려 클라이언트가 타임아웃을 냅니다. 5초 뒤 재시도가 들어옵니다. 시각은 00시 00분 03초입니다.

같은 멱등키인데 파티션이 다릅니다. UNIQUE는 파티션 안에서만 유효하니 두 번째 INSERT도 통과합니다. 최종 방어선이라고 믿었던 게 하루에 한 번씩 문이 열리는 구조였던 겁니다.

CS로 올라온 그 건의 시각이 23:59:58과 00:00:03이었던 이유가 여기 있었습니다. 그리고 하필 자정 직전은 정기 결제 배치가 도는 시간대입니다. 트래픽이 몰리고, 응답이 느려지고, 타임아웃과 재시도가 늘어나는 바로 그 시간입니다.


Oracle이라면 다를까

Oracle은 파티션 테이블에 GLOBAL UNIQUE INDEX를 만들 수 있습니다. 테이블 전체에서 유일성이 보장되니 자정 경계 문제가 사라집니다.

그런데 공짜가 아닙니다.

글로벌 인덱스는 파티션 경계를 넘나들기 때문에, DROP PARTITION을 하면 인덱스 엔트리가 고아가 되어 UNUSABLE 상태가 됩니다. UPDATE INDEXES 옵션을 주면 유지되지만, 삭제되는 행마다 인덱스를 정리하느라 드롭이 느려집니다.

애초에 파티셔닝을 택한 이유가 "DELETE 없이 즉시 정리"였는데, 그 이점이 사라집니다. 결국 이렇게 정리됩니다.

전역 유니크와 파티션 드롭 아카이빙은 동시에 가질 수 없다.

DBMS의 한계가 아니라 구조적인 트레이드오프입니다. 유니크 보장 범위를 넓히면 파티션 독립성이 깨지고, 파티션 독립성을 지키면 유니크 범위가 좁아집니다.


그래서 어떻게 풀었나

파티션 키를 날짜에서 멱등키로 바꿨습니다.

 
sql
CREATE TABLE idempotency_record (
    idempotency_key VARCHAR(64) NOT NULL,
    status          VARCHAR(20) NOT NULL,
    response_body   TEXT,
    created_at      DATETIME    NOT NULL,
    UNIQUE KEY uk_key (idempotency_key)
) PARTITION BY KEY (idempotency_key) PARTITIONS 8;

 

같은 멱등키는 언제 들어오든 항상 같은 파티션으로 갑니다. 그러니 파티션 안의 로컬 유니크만으로 충분합니다. 자정이든 월말이든 경계가 없습니다. 파티셔닝 컬럼이 곧 유니크 컬럼이니 MySQL의 제약도 자연스럽게 만족합니다.

물론 잃는 게 있습니다. 시간 기준 파티션 드롭을 못 씁니다. 오래된 데이터는 결국 DELETE로 지워야 합니다.

그런데 이 테이블에 한해서는 감수할 만했습니다. 멱등키 테이블은 조회 패턴이 키 하나로 찾는 것뿐이라 범위 스캔이 필요 없고, 삭제도 파티션이 8개로 나뉘어 있어 각각 돌리면 한 번에 걸리는 락의 범위가 작습니다.

검토했다가 접은 대안도 남겨둡니다.

  • 멱등키에 날짜를 인코딩해서 20260114-A1B2 형태로 만드는 방법. 키만 보고 파티션이 결정되니 깔끔한데, 클라이언트가 날짜 부분을 재생성하지 않는다는 보장이 필요합니다. 통제할 수 없는 외부 연동에서는 쓰기 어렵습니다.
  • 파티셔닝을 포기하고 보관 기간을 3일로 줄이는 방법. 단순해서 매력적이었지만 증적 보존 요건과 충돌했습니다.

Lesson & Learn

멱등성은 코드가 아니라 저장소의 속성입니다. 서비스 레이어 로직은 완벽했는데 뚫렸습니다. 뚫린 곳은 DDL이었습니다. 멱등성을 설계한다는 건 결국 어떤 저장소에 어떤 제약을 걸 것인지를 정하는 일에 가까웠습니다.

"UNIQUE 제약을 걸었다"는 말은 파티션 테이블에서 반쯤만 참입니다. 제약이 걸린 범위가 테이블 전체인지 파티션 하나인지 확인하지 않으면, 걸어놓고도 안 걸린 상태가 됩니다. 스키마를 넘겨받으면 파티셔닝 여부부터 보는 습관이 생겼습니다.

경계 조건에서 테스트해야 합니다. 자정, 월말, 연말, 서머타임. 평소 테스트로는 절대 안 잡힙니다. 저희는 시스템 시각을 23:59:55로 고정해두고 재시도 시나리오를 돌리는 테스트를 추가했습니다.

fail-open과 fail-close는 지금 정해두세요. 장애가 터진 뒤에 정하면 이미 늦습니다. 어느 쪽을 골랐는지, 왜 그랬는지를 코드 옆에 남겨두면 다음 사람이 반대로 바꿔놓는 일을 막을 수 있습니다.


읽어주셔서 감사합니다. 비슷한 문제를 겪으셨거나 다르게 푸신 경험이 있다면 댓글로 공유해주시면 좋겠습니다.

결제 도메인에서 중복 요청을 막기 위해 쓰는 다섯 가지 방법을 정리하고, 각 방법이 실제로 어디서 뚫리는지를 다중화 환경 기준으로 짚어봅니다.

 

안녕하세요, 구독 결제와 정산 도메인을 개발하고 있는 백엔드 엔지니어입니다.

 

결제 도메인에서 중복은 버그가 아니라 사고입니다. 화면에 데이터가 두 줄 찍히는 정도로 끝나지 않습니다. 사용자 계좌에서 두 번 빠져나가고, CS가 들어오고, 환불이 나가고, 그날 정산 데이터를 다시 계산해야 합니다. 운이 나쁘면 원천사와의 대사에서 며칠 뒤에야 발견됩니다.

서버가 한 대였을 때는 이 문제가 어렵지 않았습니다. synchronized 하나면 대부분 해결됐으니까요. 그런데 트래픽이 늘어 인스턴스를 늘리고, 오토스케일링을 걸고, 배포 방식을 롤링으로 바꾸는 순간 이전에 쓰던 방법들이 하나씩 무력해지기 시작했습니다.

이 글은 중복 요청을 막으려고 시도했던 다섯 가지 방법과, 각각이 정확히 어디서부터 안 통하는지를 정리한 기록입니다.


먼저, 중복 요청은 왜 생기나

원인을 정리해보면 대부분 우리가 통제할 수 없는 곳에 있었습니다.

  1. 사용자가 결제 버튼을 두 번 누릅니다. 응답이 느리면 세 번 누릅니다.
  2. 클라이언트가 타임아웃 후 재시도합니다. 이때 서버는 이미 처리를 끝냈을 수 있습니다.
  3. PG사 콜백이 여러 번 옵니다. 대부분의 외부 시스템은 at-least-once 전달을 보장합니다. 한 번만 보낸다는 보장은 애초에 없습니다.
  4. 배치가 재실행됩니다. 실패한 줄 알고 다시 돌렸는데 앞 실행이 살아있던 경우입니다.
  5. 게이트웨이나 로드밸런서가 재시도합니다. 우리가 설정한 적 없는 리트라이가 인프라 레이어에 걸려 있을 수 있습니다.

여기서 중요한 전제가 나옵니다. 모든 요청은 두 번 이상 들어올 수 있다고 가정하고 설계해야 한다는 것입니다. 중복을 "막는다"는 표현 자체가 사실 절반만 맞습니다. 들어오는 건 못 막습니다. 두 번 들어와도 결과가 한 번과 같게 만드는 것, 그게 실제로 하는 일입니다.


방법 1. 애플리케이션 레벨 락

가장 먼저 손이 가는 방법입니다.

 
java
public synchronized void pay(Long orderId) {
    if (paymentRepository.existsByOrderId(orderId)) {
        return;
    }
    // 결제 처리
}

로컬에서는 완벽하게 동작합니다. 테스트 코드도 통과합니다.

 

뚫리는 지점: JVM 경계

synchronized도 ReentrantLock도 하나의 JVM 안에서만 유효합니다. 인스턴스가 두 대면 락도 두 개입니다. 각자 자기 락을 잡고 각자 결제를 진행합니다.

이 방법이 아직도 코드에 남아있는 이유는 개발 환경에서 재현되지 않기 때문입니다. 로컬은 서버 한 대고, 스테이지도 한 대인 경우가 많습니다. 운영에 올라가서 인스턴스가 늘어난 뒤에야 문제가 드러납니다.

한 가지 더. 이 방법은 처리량도 갉아먹습니다. 메서드 전체에 락을 걸면 서로 다른 주문끼리도 줄을 섭니다. 막지도 못하면서 느려지기만 하는 셈입니다.


방법 2. 클라이언트 측 중복 방지

버튼을 비활성화하고, 디바운스를 걸고, 진입 시 발급한 토큰을 요청에 실어 보냅니다.

뚫리는 지점: 클라이언트를 신뢰할 수 없다는 사실 자체

  • 새로고침하면 버튼 상태가 초기화됩니다
  • 앱을 강제 종료하고 다시 들어오면 마찬가지입니다
  • 모바일 네트워크가 LTE에서 Wi-Fi로 전환되면 HTTP 클라이언트가 자동 재시도하는 경우가 있습니다
  • API를 직접 호출하면 프론트엔드 로직은 애초에 존재하지 않습니다

실제로 겪은 케이스는 세 번째였습니다. 지하철에서 결제하던 사용자의 요청이 두 번 들어왔는데, 클라이언트 로그에는 한 번만 찍혀 있었습니다. OS 레벨에서 재전송한 거였습니다.

클라이언트 방어는 유효합니다. 다만 그건 서버 부하를 줄이는 UX 장치이지 정합성 보장 수단이 아닙니다. 여기까지가 방어선이라고 생각하는 순간 사고가 납니다.


방법 3. Redis 분산 락

서버가 여러 대여도 락은 하나여야 하니, 락을 외부로 빼는 게 자연스러운 다음 수순입니다.

 
java
RLock lock = redissonClient.getLock("payment:" + orderId);
try {
    if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
        throw new BusinessException(ErrorCode.DUPLICATE_REQUEST);
    }
    paymentService.pay(orderId);
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

여기까지 오면 꽤 안심하게 됩니다. 그런데 뚫리는 지점이 세 개나 있습니다.

 

뚫리는 지점 1: TTL 만료

락에는 TTL이 필수입니다. 없으면 프로세스가 죽었을 때 락이 영원히 남습니다. 그런데 TTL을 걸면 작업이 TTL보다 오래 걸릴 때 락이 스스로 풀립니다.

PG 승인 요청은 평소 1초면 끝나지만 원천사가 느린 날에는 8초도 걸립니다. TTL을 5초로 잡아뒀다면, 5초 시점에 락이 풀리고 대기하던 두 번째 요청이 들어옵니다. 첫 번째 요청은 아직 PG 응답을 기다리는 중입니다.

 

뚫리는 지점 2: 남의 락 해제

위 상황에서 첫 번째 요청이 8초 만에 작업을 마치고 unlock()을 호출하면 어떻게 될까요. 소유자 검증이 없다면 두 번째 요청이 잡고 있던 락을 해제해 버립니다. 그러면 세 번째 요청이 들어옵니다. Redisson은 이걸 막아주지만, 직접 SETNX로 구현했다면 확인해봐야 합니다.

 

뚫리는 지점 3: 페일오버

마스터가 SETNX를 받고 레플리카에 복제하기 전에 죽으면, 승격된 새 마스터에는 그 락이 없습니다. 다른 요청이 같은 키로 락을 또 잡습니다. Redlock이 이 문제를 다루려고 나온 알고리즘인데, 여러 대의 독립 노드가 필요하고 구현 복잡도가 만만치 않습니다.

 

그리고 가장 중요한 한 가지가 남아 있습니다.

분산 락은 동시성을 막을 뿐, 멱등성을 보장하지 않습니다.

첫 번째 요청이 정상적으로 끝나고 락이 해제된 뒤, 3초 후에 도착한 재시도 요청은 락을 아무 문제 없이 획득합니다. 그리고 결제를 한 번 더 합니다. 락 입장에서는 동시 요청이 아니니까요.

타임아웃 후 재시도는 대부분 이 패턴입니다. 즉 분산 락으로 막을 수 있는 건 더블클릭뿐입니다.


방법 4. 멱등키

그래서 필요한 게 멱등키입니다. 요청마다 고유한 키를 받고, 같은 키로 들어온 요청은 처음 처리한 결과를 그대로 돌려줍니다.

 
java
public PaymentResponse pay(String idempotencyKey, PaymentRequest request) {
    return idempotencyRepository.findByKey(idempotencyKey)
            .map(record -> record.getResponse())
            .orElseGet(() -> process(idempotencyKey, request));
}

개념은 단순한데 실제로 만들면 함정이 줄줄이 나옵니다.

 

뚫리는 지점 1: 키를 누가 만드는가

클라이언트가 요청할 때마다 UUID를 새로 생성한다면 멱등키는 아무 의미가 없습니다. 재시도할 때도 같은 키를 써야 하는데, 그러려면 키가 요청 단위가 아니라 의도 단위로 만들어져야 합니다. 결제 화면에 진입할 때 한 번 발급하고, 그 화면에서 나가기 전까지는 같은 키를 씁니다.

 

뚫리는 지점 2: 저장 시점

처리를 끝낸 뒤에 키를 저장하면, 처리 중에 들어온 두 번째 요청은 조회 시점에 아무것도 못 찾고 그대로 통과합니다. 반대로 처리 전에 저장하면 도중에 실패했을 때 그 키가 영원히 막혀버립니다. 사용자는 재시도조차 못 합니다.

그래서 멱등키 레코드에는 상태가 필요합니다.

 
java
public enum IdempotencyStatus {
    IN_PROGRESS,   // 처리 중 — 같은 키로 오면 409 또는 대기
    COMPLETED,     // 완료 — 저장된 응답 그대로 반환
    FAILED         // 실패 — 재시도 허용
}

 

뚫리는 지점 3: 응답을 저장하지 않는 경우

두 번째 요청에 "중복 요청입니다" 에러를 내려주면, 클라이언트 입장에서는 결제가 실패한 걸로 보입니다. 그래서 사용자에게 실패 화면을 띄우고, 사용자는 다시 결제를 시도합니다. 중복을 막으려다 중복을 유발하는 구조입니다.

첫 번째 요청의 응답을 그대로 저장해뒀다가 똑같이 돌려줘야 합니다. 두 번째 요청 입장에서는 자기가 성공한 것처럼 보여야 맞습니다.

 

뚫리는 지점 4: TTL

멱등키 레코드를 무한히 쌓을 수는 없으니 보통 24시간 정도로 만료시킵니다. 그러면 하루 뒤에 도착한 재시도는 그대로 통과합니다. 현실적으로 드물긴 하지만, 배치 재실행이나 수동 재처리에서는 충분히 일어날 수 있는 시나리오입니다.


방법 5. DB 유니크 제약

최종 방어선입니다. 앞의 네 단계가 전부 실패해도 여기서는 막힙니다.

 
sql
ALTER TABLE payment
    ADD CONSTRAINT uk_payment_idempotency
    UNIQUE (idempotency_key);

물리적으로 두 줄이 들어갈 수 없으니 확실합니다. 그런데 여기에도 구멍이 있습니다.

 

뚫리는 지점 1: 논리적 중복은 못 막는다

DB는 키가 같은지만 봅니다. 같은 사용자가 같은 상품을 1초 간격으로 결제했을 때, 그게 중복인지 정상적인 재구매인지 DB는 판단할 수 없습니다. 결국 유니크 키를 무엇으로 잡느냐가 전부입니다. 이 설계를 잘못하면 제약 조건이 있어도 중복은 들어옵니다.

 

뚫리는 지점 2: 예외를 어떻게 처리하는가

DuplicateKeyException이 그대로 500 에러로 나가면 사용자는 실패 화면을 봅니다. 실제로는 첫 번째 결제가 성공한 상태인데도요. 이 예외는 잡아서 성공 응답으로 바꿔줘야 합니다.

 
java
try {
    paymentRepository.save(payment);
} catch (DataIntegrityViolationException e) {
    return paymentRepository.findByIdempotencyKey(key)
            .map(PaymentResponse::from)
            .orElseThrow(() -> e);
}

 

뚫리는 지점 3: 샤딩

테이블을 샤딩하면 유니크 제약은 샤드 단위로만 걸립니다. 샤드 키에 포함되지 않은 컬럼으로는 전역 유니크를 보장할 수 없습니다. 이 경우 별도의 유니크 보장 저장소가 필요해집니다.

 

뚫리는 지점 4: 소프트 삭제

deleted_at 컬럼을 쓰는 테이블에 유니크를 걸면 삭제한 데이터와 새 데이터가 충돌합니다. 유니크 키에 deleted_at을 포함시키는 우회가 흔한데, 그러면 삭제된 행이 여러 개일 때 다시 중복이 가능해집니다.


그래서 어떻게 조합했나

결론부터 말하면 어느 하나로 끝나지 않습니다. 각 계층이 막을 수 있는 범위가 다르기 때문입니다.

 


 

계층  막는 것 못 막는 것
클라이언트 사용자 더블클릭 API 직접 호출, 자동 재시도
분산 락 동시 도착 요청 시간 차 재시도
멱등키 시간 차 재시도 키가 다른 논리적 중복
DB 유니크 물리적 중복 삽입 키 설계 오류
정합성 배치 사후 탐지 실시간 차단

지금 운영 중인 구조는 이렇습니다. 분산 락으로 동시 요청을 좁히고, 멱등키로 재시도를 흡수하고, DB 유니크를 최종 방어선으로 두고, 그래도 새는 게 있는지 정합성 배치로 주기적으로 확인합니다.

마지막 항목이 제일 중요합니다. 앞의 네 단계를 다 깔아도 완전히 막았다고 확신할 수는 없습니다. 확신 대신 필요한 건 탐지 장치입니다. 새는 걸 전제하고, 새면 몇 분 안에 알아차릴 수 있게 만들어두는 쪽이 현실적입니다.


Lesson & Learn

중복은 막는 게 아니라 줄이고, 남은 건 탐지하는 것입니다. 완벽한 차단을 목표로 잡으면 어느 시점부터 비용이 급격히 올라가는데, 그 구간에 들이는 노력을 탐지와 복구 쪽으로 돌리는 게 대체로 더 남는 장사였습니다.

락과 멱등키와 유니크 제약은 대체재가 아니라 보완재입니다. 셋 중 뭐가 제일 좋냐는 질문은 성립하지 않습니다. 각각 막는 구간이 다릅니다. 이걸 구분하지 못하면 "분산 락을 걸었는데 왜 중복이 들어오죠?" 같은 상황을 만나게 됩니다.

단일 인스턴스로는 이 문제를 테스트할 수 없습니다. 인스턴스를 두 대 이상 띄운 상태에서 동시 요청을 쏴봐야 합니다. 저는 CountDownLatch로 스레드를 모아뒀다 한 번에 풀거나, k6로 짧은 구간에 요청을 몰아넣는 방식을 씁니다. 로컬에서 통과한 테스트는 아무것도 증명하지 못합니다.

마지막으로, 이 글에서 다룬 방법들은 전부 "요청이 두 번 왔을 때 어떻게 할 것인가"를 다룹니다. 그 앞 단계, 즉 애초에 어떤 저장소에 상태를 둘 것인가는 또 다른 이야기입니다. 그 부분은 다음 글에서 이어가겠습니다.


읽어주셔서 감사합니다. 비슷한 사고를 겪으셨거나 다르게 푸신 경험이 있다면 댓글로 공유해주시면 좋겠습니다.

1. 시작은 코드 리뷰에서 깨진 자존심

주니어 시절에 파일 업로드 기능을 만든 적이 있습니다. 서비스 레이어에서 IOException이 튀어나오길래 별생각 없이 throws IOException을 붙였고, 그 위 컨트롤러도 컴파일이 안 되니까 또 throws를 붙였습니다. 결국 컨트롤러 메서드 시그니처가 이렇게 됐죠.

 
java
@PostMapping("/files")
public ResponseEntity<FileResponse> upload(MultipartFile file) throws IOException {
    ...
}

PR을 올렸더니 리뷰어가 딱 한 줄 남겼습니다. "이 예외, 컨트롤러가 받아서 뭘 할 수 있죠?"

할 말이 없었습니다. 컨트롤러는 그 예외로 아무것도 안 했거든요. 그냥 컴파일러가 시켜서 던진 거였습니다. 그날 이후로 예외를 "처리 대상"이 아니라 "설계 대상"으로 보게 됐습니다. 이 글은 그때의 질문에 대한 답을 정리한 내용입니다.


2. 두 예외는 애초에 뭐가 다른가

자바의 예외 계층은 Throwable에서 출발합니다.

 

Error는 JVM 레벨의 문제라 애플리케이션이 손댈 영역이 아닙니다. 실질적으로 우리가 고민할 건 Exception 아래 두 갈래입니다.

Checked Exception은 컴파일러가 강제합니다. try-catch로 잡거나 throws로 선언하지 않으면 아예 빌드가 안 됩니다. 반면 Unchecked Exception(RuntimeException 계열)은 컴파일러가 관심을 두지 않습니다. 잡든 말든 개발자 마음입니다.

차이는 사실상 이 한 줄입니다. 컴파일 타임에 강제되느냐, 아니냐. 런타임 동작은 완전히 똑같습니다. 둘 다 스택을 타고 올라가고, 아무도 안 잡으면 스레드가 종료됩니다.


3. 자바 설계자는 왜 굳이 나눴을까

기준은 복구 가능성(recoverability)이었습니다.

  • 호출한 쪽이 뭔가 대응할 수 있는 상황 → Checked
  • 개발자가 코드를 잘못 짠 상황 → Unchecked

예를 들어 설정 파일을 읽는데 파일이 없다면? 기본값으로 대체하거나, 사용자에게 경로를 다시 물어볼 수 있습니다. 대응 가능한 상황이니 FileNotFoundException은 Checked입니다.

반대로 null인 참조에 접근했다면 대응할 방법이 없습니다. 잡아서 재시도한다고 해결되지 않죠. 코드를 고쳐야 합니다. 그래서 NullPointerException은 Unchecked입니다.

의도 자체는 꽤 설득력 있습니다. API의 실패 가능성을 주석이나 문서가 아니라 타입 시스템으로 알려주자는 발상이니까요. 문서는 안 읽어도 컴파일 에러는 못 무시합니다.

그런데 이 좋은 의도가 왜 현장에서 외면당했을까요?


4. 그런데 왜 현장에서는 안 쓰일까

4-1. 전파 비용이 너무 큽니다

 

Repository의 메서드 하나에 Checked Exception을 추가했다고 해봅시다. 그러면 그걸 호출하는 Service, 그 위의 Facade, 또 그 위의 Controller까지 전부 시그니처를 고쳐야 합니다. 기능은 하나도 안 바뀌었는데 파일 여러 개가 diff에 잡힙니다.

변경에 닫혀 있어야 할 상위 계층이 하위 계층 사정 때문에 열려버리는 겁니다. OCP 위반이라고 부르는 상황이 정확히 이겁니다.

4-2. 캡슐화가 깨집니다

더 심각한 건 이쪽입니다.

 
java
public interface OrderRepository {
    Order findById(Long id) throws SQLException;  // JDBC를 쓴다고 광고하는 중
}

인터페이스가 SQLException을 던진다고 선언하는 순간, 구현체가 JDBC를 쓴다는 사실이 밖으로 새어 나갑니다. 나중에 JPA나 MyBatis로 갈아타면? 예외 타입이 달라지니 인터페이스부터 뜯어고쳐야 합니다. 추상화하려고 만든 인터페이스가 구현 기술에 묶여버리는 아이러니입니다.

4-3. 결국 삼켜집니다

가장 흔한 결말은 이겁니다.

 
java
try {
    fileService.save(file);
} catch (IOException e) {
    e.printStackTrace();  // 이걸 처리라고 부를 수 있을까
}

컴파일을 통과시키는 게 목적이 되면 이런 코드가 나옵니다. 로그도 제대로 안 남고, 흐름은 그대로 이어지고, 장애가 터지면 원인을 찾을 수가 없습니다. 강제했더니 오히려 더 나쁜 코드가 나온 셈이죠.

4-4. 람다와 궁합이 최악입니다

자바 8 이후로는 이 문제가 결정타였습니다.

 
java
List<String> contents = paths.stream()
        .map(path -> Files.readString(path))  // 컴파일 에러
        .toList();

Function.apply()는 Checked Exception을 던질 수 없게 선언돼 있습니다. 그래서 람다 안에서 try-catch로 감싸고 다시 RuntimeException으로 포장하는, 아무도 좋아하지 않는 코드를 쓰게 됩니다.

 
java
.map(path -> {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        throw new UncheckedIOException(e);  // 결국 언체크로 변환
    }
})

JDK가 UncheckedIOException이라는 클래스를 따로 만들어 놓은 것 자체가, 어떻게 보면 항복 선언에 가깝습니다.


5. Spring이 내린 답: 다 걷어내고 Unchecked로

Spring은 이 문제를 프레임워크 차원에서 정리해 버렸습니다.

DataAccessException

JdbcTemplate을 쓰면 SQLException을 만날 일이 없습니다. Spring이 내부에서 잡아서 DataAccessException 계층으로 바꿔주기 때문입니다. 그리고 이 클래스는 RuntimeException을 상속합니다.

 
java
public abstract class DataAccessException extends NestedRuntimeException { ... }

덕분에 두 가지를 동시에 얻습니다. 서비스 코드가 throws로 더러워지지 않고, MySQL이든 PostgreSQL이든 벤더별 에러 코드가 DuplicateKeyException 같은 통일된 타입으로 추상화됩니다. 데이터베이스를 바꿔도 예외 처리 코드는 그대로입니다.

@Transactional 롤백 정책 (여기 함정 있습니다)

이건 실무에서 꼭 알아야 하는 부분입니다. @Transactional의 기본 롤백 대상은 RuntimeException과 Error뿐입니다. Checked Exception은 롤백하지 않고 커밋합니다.

 
java
@Transactional
public void createOrder(OrderRequest request) throws IOException {
    orderRepository.save(request.toEntity());
    fileStorage.upload(request.getReceipt());  // 여기서 IOException 발생
}

파일 업로드가 실패했는데 주문 데이터는 그대로 저장됩니다. 실제로 결제 관련 로직에서 이 패턴을 겪으면 데이터 정합성이 깨진 채로 며칠을 흘려보내게 됩니다.

막으려면 명시적으로 지정해야 합니다.

 
java
@Transactional(rollbackFor = Exception.class)

물론 애초에 RuntimeException으로 통일했다면 이런 옵션을 신경 쓸 일도 없었겠죠. Spring이 Unchecked를 기본값으로 잡은 이유가 여기 있다고 봅니다.


6. 실무에서 예외를 설계하는 방식

요즘 프로젝트에서는 대체로 이 구조로 갑니다.

1) 에러 코드를 enum으로 모읍니다

 
java
@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없습니다."),
    INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "O002", "재고가 부족합니다."),
    INVALID_COUPON(HttpStatus.BAD_REQUEST, "C001", "사용할 수 없는 쿠폰입니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

메시지가 코드 곳곳에 문자열로 흩어지지 않고 한곳에 모이는 게 핵심입니다. 기획에서 문구를 바꿔달라고 하면 이 파일만 고치면 끝납니다.

2) 최상위 비즈니스 예외를 RuntimeException으로 둡니다

 
java
@Getter
public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

3) 전역에서 한 번에 처리합니다

 
java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        ErrorCode code = e.getErrorCode();
        log.warn("business error: {} - {}", code.getCode(), code.getMessage());
        return ResponseEntity.status(code.getStatus())
                .body(ErrorResponse.of(code));
    }
}

이렇게 두면 서비스 코드는 예외를 던지기만 하면 됩니다.

 
java
Order order = orderRepository.findById(id)
        .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

try-catch가 사라지고, 응답 형식은 자동으로 통일되고, 로그 정책도 한곳에서 관리됩니다. 처음 이 구조로 리팩터링했을 때 서비스 클래스에서 사라진 줄 수가 꽤 됐습니다.


7. 그래도 Checked를 쓰는 순간이 있다

Checked Exception이 완전히 죽은 개념은 아닙니다. 판단 기준은 하나면 충분합니다.

"호출하는 쪽이 이 예외를 잡아서 실제로 뭔가 할 게 있는가?"

답이 "그렇다"면 Checked도 괜찮습니다. 예를 들어 외부 결제 API 클라이언트를 라이브러리로 만든다면, 타임아웃 상황은 재시도할지 대체 PG사로 넘길지 호출자가 반드시 결정해야 하는 문제입니다. 이건 Checked로 강제할 명분이 있습니다.

반대로 "잡아봤자 로그 찍고 다시 던지는 것밖에 없다"면 Unchecked가 맞습니다. 대부분의 애플리케이션 코드는 여기에 해당합니다.

Joshua Bloch가 『이펙티브 자바』에서 한 얘기도 결국 같은 맥락입니다. 복구 가능하면 Checked, 프로그래밍 오류면 Unchecked. 다만 그 "복구 가능"의 범위를 우리가 그동안 너무 넓게 잡았던 것 같습니다.

Q. 메시지가 중복 소비될 수 있나요?

네. Kafka의 기본 전달 보장은 at-least-once이므로 컨슈머 로직은 멱등하게 설계하는 것이 원칙입니다.

 

 

한 줄로 넘어갔지만, 사실 이 문장이 실무에서는 가장 많은 시간을 잡아먹는 부분입니다. 특히 결제나 이용권처럼 "한 번만 일어나야 하는 일"을 다루는 도메인이라면 더 그렇습니다.

이번 글에서는 컨슈머를 멱등하게 만드는 구체적인 방법을 다룹니다. Redis, DB 제약, 대사 배치를 겹쳐 쌓는 3중 방어 구조이고, 마지막에는 그게 실제로 동작하는지 테스트로 증명하는 부분까지 정리했습니다.


1. 왜 중복이 발생하는가

Kafka가 못 만들어서 중복이 생기는 게 아닙니다. 분산 환경에서 "정확히 한 번"을 보장하는 게 원래 어렵기 때문입니다.

메시지 전달 보장은 보통 세 가지로 나눕니다.

보장 수준의미결과
at-most-once 최대 한 번 유실 가능, 중복 없음
at-least-once 최소 한 번 유실 없음, 중복 가능 (Kafka 기본값)
exactly-once 정확히 한 번 이상적이지만 조건과 비용이 붙음

결제 도메인에서 유실은 곧 매출 누락이라, at-most-once는 애초에 선택지가 아닙니다. 그래서 at-least-once를 택하고, 중복은 컨슈머가 알아서 막는다는 전제로 갑니다.

중복이 실제로 생기는 지점은 크게 세 군데입니다.

1-1. 처리는 됐는데 커밋 전에 죽는 경우

가장 흔한 시나리오입니다.

 

DB 커밋과 오프셋 커밋은 서로 다른 시스템에 대한 두 번의 커밋입니다. 이 둘 사이에는 아무리 좁혀도 틈이 남습니다.

1-2. 리밸런싱 중 파티션이 넘어가는 경우

 
Consumer A : Partition 0 처리 중 (offset 100 처리, 아직 커밋 전)
             ↓
      리밸런싱 발생 (신규 컨슈머 투입 / 헬스체크 실패 등)
             ↓
Consumer B : Partition 0 할당받음 → 마지막 커밋 오프셋(99)부터 시작
             → offset 100 을 다시 처리

max.poll.interval.ms를 넘겨서 컨슈머가 그룹에서 쫓겨나는 경우가 대표적입니다. 처리 로직이 무거우면 생각보다 자주 만나게 됩니다.

1-3. 프로듀서가 재전송하는 경우

브로커는 저장했는데 ack 응답이 유실되면, 프로듀서는 실패로 판단하고 다시 보냅니다. 이건 enable.idempotence=true(Kafka 3.0부터 기본값)로 상당 부분 막을 수 있지만, 프로듀서 세션이 끊기고 새로 붙는 경우까지 완전히 커버되지는 않습니다.


2. 실제로 겪었던 문제

Kafka 얘기를 잠깐 미뤄두고, 제가 결제 서비스를 운영하면서 겪었던 장애를 하나 소개하겠습니다. Kafka 환경은 아니었지만 구조가 완전히 같은 문제라서 이 글의 출발점으로 삼기 좋습니다.

어느 날 고객센터를 통해 문의가 들어왔습니다. 결제는 한 번 했는데 이용권이 두 개 들어왔다는 내용이었습니다.

확인해보니 원인은 이랬습니다.

 

PG사가 응답을 못 받았다고 판단하면 같은 Webhook을 다시 보냅니다. 그런데 저희 지급 로직에는 중복 진입을 막는 장치가 아예 없었습니다. 결제 승인과 이용권 지급이 서로 다른 트랜잭션으로 분리되어 있다 보니, 한쪽만 두 번 실행돼도 티가 안 났던 겁니다.

가장 아팠던 건 장애 자체보다 이 문제를 고객이 먼저 알았다는 점이었습니다. 우리 쪽에는 이걸 감지할 장치가 하나도 없었습니다.

Kafka의 at-least-once는 이것과 정확히 같은 상황을 만듭니다. Webhook 재전송이 컨슈머 재처리로 이름만 바뀐 것뿐입니다.


3. 방어 전략 개요

이 경험 이후로 저는 멱등성을 한 겹으로 두지 않습니다. 성격이 다른 방어를 세 겹으로 쌓습니다.

 

각 층의 역할이 다르다는 게 핵심입니다.

층역할막지 못하는 것
Redis 선점 대부분의 중복을 싸게 차단 Redis 장애, TTL 만료 이후
DB Unique 중복 저장을 물리적으로 불가능하게 누락(이벤트가 아예 안 온 경우)
대사 배치 앞의 둘을 통과한 예외까지 검출 실시간성 (하루 지연)

앞의 둘은 중복만 막습니다. 누락은 못 잡습니다. 대사 배치를 굳이 두는 이유가 여기 있습니다.


4. 1차 방어 — Redis 선점 처리

먼저 멱등키를 정해야 합니다. 저는 이벤트 자체의 고유 ID를 씁니다.

 
kotlin
data class PaymentEvent(
    val eventId: String,      // 멱등키 (발행 측에서 생성, 재전송 시에도 동일)
    val orderId: String,
    val memberId: Long,
    val productCode: String,
    val occurredAt: Instant
)

⚠️ 파티션+오프셋 조합을 키로 쓰면 안 됩니다. 프로듀서가 같은 이벤트를 두 번 발행하면 오프셋이 달라집니다. 같은 이벤트인데 다른 키가 되어버려서 멱등성이 깨집니다. 키는 비즈니스 의미를 갖는 값이어야 합니다.

Redis SETNX로 선점합니다.

 
kotlin
@Component
class IdempotencyGuard(
    private val redis: StringRedisTemplate
) {
    fun tryAcquire(eventId: String, ttl: Duration): Boolean =
        redis.opsForValue()
            .setIfAbsent(key(eventId), "PROCESSING", ttl) ?: false

    fun release(eventId: String) {
        redis.delete(key(eventId))
    }

    private fun key(eventId: String) = "idem:payment:$eventId"
}

컨슈머는 이렇게 됩니다.

 
kotlin
@KafkaListener(topics = ["payment-events"], groupId = "entitlement-worker")
fun consume(record: ConsumerRecord<String, String>, ack: Acknowledgment) {
    val event = mapper.readValue<PaymentEvent>(record.value())

    if (!guard.tryAcquire(event.eventId, Duration.ofDays(3))) {
        log.info("중복 이벤트 스킵 eventId={}", event.eventId)
        ack.acknowledge()
        return
    }

    try {
        entitlementService.grant(event)
        ack.acknowledge()
    } catch (e: Exception) {
        guard.release(event.eventId)   // 실패했으면 키를 풀어줘야 재시도가 된다
        throw e
    }
}

catch 안의 release()가 은근히 중요합니다. 처음에 이걸 빼먹었다가, 한 번 실패한 이벤트가 TTL 만료 전까지 영원히 스킵되는 상황을 만든 적이 있습니다. 재시도는 계속 도는데 전부 "중복 스킵" 로그만 남아서 원인 찾는 데 한참 걸렸습니다.

TTL은 얼마로 잡아야 하나

정답은 없고, 중복이 도착할 수 있는 최대 시간보다 길게 잡으면 됩니다. 저는 이런 기준으로 봅니다.

  • Kafka retention.ms(기본 7일) 안쪽에서 재처리가 일어날 수 있음
  • 장애 복구 후 오프셋을 되감는 상황까지 고려
  • 그래서 최소 2~3일, 여유 있으면 7일

짧게 잡으면 뒤늦게 온 중복이 그대로 통과합니다. 이게 다음 방어층이 필요한 이유이기도 합니다.

Redis만으로는 왜 부족한가

 
케이스 1. Redis 장애
   SETNX 실패 → 정책상 통과시키면? 중복 유입
                정책상 막으면?   서비스 전체 중단

케이스 2. TTL 만료 후 재처리
   3일 뒤 오프셋 되감기 → 키가 이미 사라짐 → 중복 지급

케이스 3. SETNX 성공 직후 프로세스 다운
   키는 남았는데 지급은 안 됨 → TTL 동안 재시도 전부 스킵

Redis는 캐시지 원장이 아닙니다. 마지막 방어선을 캐시에 맡길 수는 없습니다.


5. 2차 방어 — DB Unique 제약

그래서 지급 테이블 자체에 제약을 겁니다.

 
sql
ALTER TABLE entitlement_grant
    ADD CONSTRAINT uk_entitlement_event UNIQUE (event_id);
 
kotlin
@Transactional
fun grant(event: PaymentEvent) {
    try {
        grantRepository.save(
            EntitlementGrant(
                eventId    = event.eventId,
                memberId   = event.memberId,
                productCode= event.productCode,
                grantedAt  = Instant.now()
            )
        )
    } catch (e: DataIntegrityViolationException) {
        // 이미 지급된 이벤트 — 예외가 아니라 정상 종료로 취급한다
        log.warn("이미 처리된 이벤트 eventId={}", event.eventId)
        return
    }

    memberEntitlement.extend(event.memberId, event.productCode)
}

여기서 포인트 두 가지.

첫째, 제약 위반을 에러로 다루지 않습니다. "이미 처리됨"은 실패가 아니라 원하는 결과가 이미 달성된 상태입니다. 이걸 예외로 던지면 재시도 큐에 쌓이고, DLQ로 흘러가고, 알람이 울립니다. 아무 문제도 없는데 새벽에 깨는 겁니다.

둘째, DB가 최종 판정자입니다. 애플리케이션 레벨에서 exists()로 먼저 조회하는 방식은 동시성에 취약합니다. 두 컨슈머가 동시에 조회하면 둘 다 "없음"을 보고 둘 다 INSERT를 시도합니다. Unique 제약은 이 경합을 DB가 직렬화해서 처리해줍니다.


6. 3차 방어 — 대사 배치

여기까지 하면 중복은 거의 막힙니다. 그런데 앞서 적었듯 누락은 여전히 못 잡습니다.

  • 이벤트가 발행되지 않은 경우
  • 컨슈머가 계속 실패해서 DLQ에만 쌓인 경우
  • 지급은 됐는데 후속 처리가 빠진 경우

그래서 하루에 한 번, 결제 원장과 지급 원장을 대조합니다.

 

②는 자동으로 처리하지 않습니다. 결제 없이 지급이 나갔다는 건 코드 버그거나 더 큰 문제의 신호라서, 사람이 봐야 합니다. 자동 회수했다가 정상 케이스를 건드리면 그게 더 큰 사고입니다.

이 배치를 붙인 뒤로 얻은 가장 큰 소득은 정합성 문제를 고객보다 먼저 안다는 것이었습니다. 검출 건수 자체는 월 10건 안팎으로 많지 않지만, CS로 올라오기 전에 정리된다는 점이 중요합니다.


7. 테스트로 증명하기

"멱등하게 설계했습니다"는 누구나 말할 수 있습니다. 문제는 그게 진짜인지 확인할 방법입니다.

Testcontainers로 실제 Kafka를 띄우고, 같은 이벤트를 100번 발행해봅니다.

 
kotlin
@Test
fun `동일 이벤트를 100회 발행해도 지급은 1건만 생성된다`() {
    val event = PaymentEvent(
        eventId     = "evt-20260801-0001",
        orderId     = "ord-1001",
        memberId    = 1001,
        productCode = "MUSIC_30D",
        occurredAt  = Instant.now()
    )

    repeat(100) {
        producer.send("payment-events", event.orderId, mapper.writeValueAsString(event))
    }

    await().atMost(15, SECONDS).untilAsserted {
        val grants = grantRepository.findAllByEventId(event.eventId)
        assertThat(grants).hasSize(1)
    }
}

Redis가 죽은 상황도 테스트합니다. 1차 방어가 무력화돼도 2차가 버티는지 봐야 하니까요.

 
kotlin
@Test
fun `Redis 장애 상황에서도 DB 제약으로 중복 지급이 차단된다`() {
    redisContainer.stop()   // 1차 방어 무력화

    repeat(50) {
        producer.send("payment-events", event.orderId, mapper.writeValueAsString(event))
    }

    await().atMost(15, SECONDS).untilAsserted {
        assertThat(grantRepository.findAllByEventId(event.eventId)).hasSize(1)
    }
}

두 번째 테스트가 통과하는 걸 보고 나서야 마음이 놓였습니다. 설계는 그럴듯한데 실제로 안 돌아가는 경우가 생각보다 많습니다.


8. exactly-once를 쓰지 않은 이유

Kafka에는 트랜잭션 API 기반의 EOS(Exactly-Once Semantics) 가 있습니다. 그런데 왜 굳이 멱등 컨슈머를 직접 만들까요?

EOS가 보장하는 범위가 Kafka 안쪽으로 한정되기 때문입니다.

 

이용권 지급은 결국 DB에 쓰는 일입니다. Kafka 트랜잭션과 DB 트랜잭션을 원자적으로 묶으려면 2PC 같은 게 필요한데, 실무에서 이걸 도입하는 비용이 만만치 않습니다.

여기에 EOS 자체의 부담도 있습니다.

  • 트랜잭션 코디네이터 오버헤드로 처리량 감소
  • read_committed 컨슈머는 트랜잭션 종료까지 대기 → 지연 증가
  • 운영 복잡도 상승

정리하면 이렇습니다.

Kafka → Kafka 파이프라인이면 EOS가 답이고, Kafka → DB 라면 멱등 컨슈머가 답이다.

결제 도메인은 대부분 후자입니다.

 

9. 자주 묻는 질문 (FAQ)

Q1. DB Unique 제약만 걸면 Redis는 없어도 되지 않나요? 동작은 합니다. 다만 중복 요청이 전부 DB까지 도달해서 예외를 발생시킵니다. 커넥션을 잡고, 예외를 만들고, 롤백하는 비용이 매 건 발생합니다. Redis는 그 비용을 앞단에서 걷어내는 역할이고, DB 제약은 Redis를 못 믿을 때의 최종 방어입니다. 역할이 다릅니다.

Q2. 멱등키는 뭘로 잡는 게 좋나요? 발행 측에서 만든 이벤트 고유 ID가 가장 안전합니다. 주문 ID도 자주 쓰이는데, 한 주문에 여러 이벤트가 생기는 구조라면 충돌하니 주의하세요. (결제완료 / 취소 / 부분환불이 전부 같은 주문 ID를 갖는 경우)

Q3. 처리에 실패하면 Redis 키를 지워야 하나요? 네. 안 지우면 TTL 만료까지 재시도가 전부 스킵됩니다. 다만 release() 직후 프로세스가 죽으면 키만 사라진 상태가 되는데, 이 경우는 DB Unique가 받아줍니다. 그래서 층을 겹치는 겁니다.

Q4. 대사 배치까지 정말 필요한가요? 중복만 걱정한다면 없어도 됩니다. 하지만 누락은 앞의 두 층이 절대 못 잡습니다. 이벤트가 아예 안 온 경우, 지급 로직에는 아무 일도 일어나지 않으니까요. 결제 도메인에서 누락은 중복만큼, 때로는 더 치명적입니다.

Q5. 컨슈머가 실패하면 무한 재시도하나요? 아니요. 재시도 횟수를 제한하고 초과분은 DLQ로 보냅니다. 결제 이벤트를 무한 재시도하면 파티션 전체가 그 메시지에 막혀서 뒤의 정상 이벤트까지 못 나갑니다. 이 부분은 분량이 있어서 다음 글에서 따로 다루겠습니다.


10. 마무리

이번 글에서는 Kafka 컨슈머를 멱등하게 만드는 방법을 다뤘습니다. 핵심만 정리하면 이렇습니다.

  • Kafka의 기본은 at-least-once다. 중복은 버그가 아니라 전제로 두고 설계해야 한다.
  • 멱등키는 비즈니스 의미를 갖는 이벤트 ID로 잡는다. 오프셋은 키가 될 수 없다.
  • Redis 선점 → DB Unique → 대사 배치 세 층은 각각 막는 것이 다르다. 앞의 둘은 중복을, 마지막은 누락까지 본다.
  • Unique 제약 위반은 에러가 아니라 정상 종료로 다룬다.
  • Kafka → DB 구조에서는 EOS보다 멱등 컨슈머가 현실적인 답이다.
  • 그리고 테스트로 증명하지 않은 멱등성은 아직 멱등한 게 아니다.

마지막 항목을 제일 강조하고 싶습니다. 설계 문서상으로는 완벽한데 실제로 100번 쏴보면 2건이 들어가 있는 경우가 있습니다. 저도 그랬습니다.


1. 쿠버네티스란 무엇인가?

쿠버네티스(Kubernetes, 줄여서 K8s)는 컨테이너화된 애플리케이션의 배포, 확장, 복구를 자동화하는 컨테이너 오케스트레이션 플랫폼입니다. 구글이 내부 시스템(Borg) 운영 경험을 바탕으로 만들어 오픈소스로 공개했고, 현재 CNCF(Cloud Native Computing Foundation)가 관리하는 사실상의 업계 표준입니다. 이름은 그리스어로 '조타수'라는 뜻이며, K8s는 K와 s 사이 8글자를 줄인 표기입니다.

도커로 컨테이너를 쓰다 보면 개수가 빠르게 늘어납니다. 서버 10대에 컨테이너 100개를 굴리는 순간, 다음 질문들이 생깁니다.

  • 죽은 컨테이너는 누가 다시 띄우는가?
  • 트래픽이 몰리면 누가 컨테이너 수를 늘리는가?
  • 새 버전 배포 시 어떻게 무중단으로 교체하는가?
  • 어떤 컨테이너를 어느 서버에 배치할 것인가?

이 "컨테이너 운영 자동화"를 담당하는 것이 쿠버네티스입니다. 비유하자면 도커가 컨테이너선 한 척을 만드는 조선소라면, 쿠버네티스는 수백 척을 지휘하는 항만 관제탑입니다. 둘은 대체 관계가 아니라 계층 관계입니다.


2. 사전 지식 — 컨테이너와 VM의 차이

쿠버네티스를 이해하려면 먼저 컨테이너가 무엇인지 정확히 알아야 합니다. 흔한 오해와 달리 컨테이너는 "가벼운 VM"이 아니라 격리된 프로세스입니다.

  • VM: 하이퍼바이저가 하드웨어를 가상화하고, 그 위에 게스트 OS를 통째로 올립니다. 격리는 강력하지만 부팅에 수 분, 용량은 GB 단위입니다.
  • 컨테이너: 호스트 OS의 커널을 그대로 공유하면서, 리눅스의 namespace(프로세스·네트워크·파일시스템 격리)와 cgroup(CPU·메모리 제한)으로 벽만 세웁니다. 수 초 만에 뜨고, 용량은 MB 단위입니다.

이 가벼움 덕분에 컨테이너는 "죽으면 버리고 새로 띄운다"는 일회용(disposable) 운영이 가능해지고, 쿠버네티스의 자동 복구 모델도 이 전제 위에 서 있습니다.


3. 쿠버네티스 아키텍처 한눈에 보기

쿠버네티스 클러스터는 크게 두뇌 역할의 컨트롤 플레인과 실제 작업을 수행하는 워커 노드로 나뉩니다.

컨트롤 플레인 (Control Plane)

구성요소역할
API 서버 클러스터의 유일한 관문. kubectl을 포함한 모든 요청이 여기를 거침
etcd 클러스터의 모든 상태를 저장하는 분산 키-값 저장소 (단일 진실 공급원)
스케줄러 새 파드를 어느 노드에 배치할지 결정 (리소스, 제약조건 고려)
컨트롤러 매니저 "원하는 상태"와 "현재 상태"를 비교하며 차이를 계속 메우는 조정 루프 실행

워커 노드 (Worker Node)

구성요소역할
kubelet 각 노드의 에이전트. 컨트롤 플레인의 지시대로 파드를 실행·감시
컨테이너 런타임 실제로 컨테이너를 실행 (containerd, CRI-O 등)
kube-proxy 서비스로 들어온 트래픽을 파드로 라우팅

4. 핵심 오브젝트

4-1. Pod(파드) — 배포의 최소 단위

쿠버네티스는 컨테이너를 직접 다루지 않고, 파드라는 한 겹의 포장 단위로 다룹니다. 파드는 "반드시 함께 실행되고, 함께 죽어야 하는 컨테이너들의 최소 묶음"이며, 파드 내부는 다음과 같이 생겼습니다.

 
 ┌─────────────────────────── Pod (IP: 10.244.1.5) ───────────────────────────┐
 │                                                                            │
 │   ┌──────────────────┐        localhost         ┌──────────────────┐       │
 │   │   앱 컨테이너      │ ◀──────────────────────▶ │  사이드카 컨테이너  │       │
 │   │  Spring Boot     │                          │   로그 수집기      │       │
 │   │  :8080           │                          │                  │       │
 │   └────────┬─────────┘                          └────────┬─────────┘       │
 │            │                                             │                 │
 │            ▼                                             ▼                 │
 │   ┌────────────────────────────────────────────────────────────────┐      │
 │   │                     공유 볼륨 (emptyDir)                          │      │
 │   └────────────────────────────────────────────────────────────────┘      │
 └────────────────────────────────────────────────────────────────────────────┘

왜 컨테이너를 바로 안 띄우고 파드로 감쌀까요?

  1. 함께 살아야 하는 컨테이너가 존재하기 때문 — 앱 옆에 로그 수집기, 프록시(서비스 메시의 Envoy) 같은 보조 컨테이너를 붙이는 사이드카 패턴을 지원하려면, 네트워크와 볼륨을 공유하는 단위가 필요합니다.
  2. 스케줄링의 원자 단위 — 파드 안의 컨테이너들은 반드시 같은 노드에 배치되고, 전부 성공하거나 전부 실패합니다. 트랜잭션의 원자성과 같은 발상입니다.
  3. 네트워크 모델 단순화 (IP-per-Pod) — 파드마다 고유 IP가 부여되어 컨테이너들이 포트 충돌 없이 원래 포트를 그대로 사용합니다.
  4. 런타임 추상화 — containerd든 CRI-O든 사용자에게 보이는 스펙은 동일한 파드입니다. 실제로 쿠버네티스 1.24에서 도커 전용 연동(dockershim)이 제거됐지만 사용자 경험은 바뀌지 않았습니다.

💡 실무에서는 파드 하나 = 컨테이너 하나가 압도적 다수입니다. 사이드카는 필요할 때 쓰는 옵션입니다.

4-2. Deployment — 파드를 선언적으로 관리

파드는 죽으면 끝인 일회용입니다. 그래서 파드를 직접 만들지 않고 Deployment로 감싸서 관리합니다.

 
 Deployment: "nginx 파드를 3개 유지하라"
      │
      ▼
 ReplicaSet (복제본 수 관리)
      │
      ├──▶ Pod 1   ── 죽으면?
      ├──▶ Pod 2      → Deployment가 감지하고 새 파드를 자동 생성
      └──▶ Pod 3
  • 파드가 죽으면 자동으로 재생성 (자가 치유)
  • 새 버전 배포 시 롤링 업데이트로 파드를 하나씩 교체 (무중단 배포)
  • 문제가 생기면 kubectl rollout undo로 즉시 롤백

4-3. Service — 파드에게 고정된 주소를

파드는 수시로 죽고 다시 태어나며 그때마다 IP가 바뀝니다. Service는 이런 파드들 앞에 고정된 접근 지점을 제공하고, 뒤에 있는 파드들로 트래픽을 로드밸런싱합니다.

 
                     ┌─────────────────────┐
  요청 ──────────▶   │  Service (고정 IP)   │
                     └──────────┬──────────┘
                                │  라벨 셀렉터 (app=nginx)
                 ┌──────────────┼──────────────┐
                 ▼              ▼              ▼
              Pod (IP 변동)  Pod (IP 변동)  Pod (IP 변동)
Service 타입용도
ClusterIP (기본) 클러스터 내부 통신용
NodePort 각 노드의 특정 포트로 외부 노출 (개발/테스트)
LoadBalancer 클라우드의 L4 로드밸런서와 연동 (운영)

4-4. 그 외 자주 쓰는 오브젝트

  • ConfigMap / Secret — 설정값과 민감 정보를 이미지와 분리해 주입
  • Namespace — 클러스터를 논리적으로 분할 (dev/staging/prod 등)
  • Ingress — L7(HTTP) 라우팅. 도메인/경로 기반으로 여러 서비스에 트래픽 분배

5. 쿠버네티스의 철학 — 선언형과 자가 치유

쿠버네티스를 한 문장으로 요약하면 선언형(Declarative) 시스템입니다.

 
  명령형: "컨테이너를 띄워라"                (절차를 지시)
  선언형: "파드 3개가 떠 있는 상태여야 한다"   (결과를 선언)

  ┌──────────────┐   비교   ┌──────────────┐
  │  원하는 상태   │ ◀─────▶ │   현재 상태    │
  │ (YAML, etcd) │          │  (실제 클러스터) │
  └──────────────┘          └──────────────┘
          │    차이가 있으면?
          ▼
   컨트롤러가 스스로 조치 (파드 재생성, 개수 조절…)
   = Reconciliation Loop (조정 루프)

원하는 상태를 YAML로 적어두면, 쿠버네티스는 현재 상태와 끊임없이 비교하며 차이를 스스로 메웁니다. 파드 하나가 죽으면 "3개여야 하는데 2개네" 하고 즉시 하나를 새로 띄우는 식입니다. 운영자가 장애에 일일이 대응하는 게 아니라, 시스템이 스스로 원하는 상태로 수렴하는 것이 쿠버네티스 운영 모델의 핵심입니다.


6. 쿠버네티스 설치하기

로컬 학습·개발 환경 기준으로 가장 간단한 minikube 방식을 소개합니다. (운영 환경은 EKS, GKE, NKS 같은 관리형 서비스를 쓰는 것이 일반적입니다.)

6-1. 사전 준비

  • Docker Desktop 또는 Docker 엔진이 설치·실행 중이어야 합니다
  • 확인: docker version

6-2. kubectl 설치 (클러스터 조작 CLI)

 
bash
# macOS
brew install kubectl

# Windows (관리자 PowerShell)
choco install kubernetes-cli

# 설치 확인
kubectl version --client

6-3. minikube 설치 및 클러스터 시작

 
bash
# macOS
brew install minikube

# Windows (관리자 PowerShell)
choco install minikube

# 클러스터 시작 (Docker 드라이버 사용)
minikube start --driver=docker

# 노드 확인 — STATUS가 Ready면 성공
kubectl get nodes
 
NAME       STATUS   ROLES           AGE   VERSION
minikube   Ready    control-plane   30s   v1.33.x

💡 대안 — kind(Kubernetes in Docker): 멀티 노드 클러스터를 로컬에서 흉내 내고 싶다면 kind가 더 가볍고 빠릅니다. CI 환경에서 테스트용 클러스터를 띄울 때도 많이 사용합니다.


7. 첫 배포 실습

① Deployment 작성

nginx-deployment.yaml 파일을 만듭니다.

 
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 3                    # 파드 3개 유지 선언
  selector:
    matchLabels:
      app: nginx
  template:                      # 파드 템플릿
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          readinessProbe:        # 트래픽 받을 준비 확인
            httpGet:
              path: /
              port: 80

② 배포 및 확인

 
bash
kubectl apply -f nginx-deployment.yaml

# 파드 3개가 Running인지 확인
kubectl get pods
kubectl get deployment

③ Service로 노출

 
bash
kubectl expose deployment nginx-deployment \
  --type=NodePort --port=80

# minikube에서 브라우저로 바로 접속
minikube service nginx-deployment

④ 자가 치유 직접 확인해보기

 
bash
# 파드 하나를 강제로 삭제
kubectl delete pod <파드이름>

# 몇 초 뒤 다시 조회 — 새 파드가 자동 생성되어 다시 3개!
kubectl get pods

⑤ 롤링 업데이트와 롤백

 
bash
# 이미지 버전 변경 → 파드가 하나씩 무중단 교체됨
kubectl set image deployment/nginx-deployment nginx=nginx:1.28
kubectl rollout status deployment/nginx-deployment

# 문제가 생기면 즉시 롤백
kubectl rollout undo deployment/nginx-deployment

자주 쓰는 kubectl 명령어 모음

 
bash
kubectl get pods -o wide          # 파드 목록 + 배치된 노드 확인
kubectl describe pod <이름>        # 파드 상세 정보 (이벤트 포함, 디버깅 필수)
kubectl logs <파드이름>            # 컨테이너 로그
kubectl logs -f <파드이름>         # 로그 실시간 스트리밍
kubectl exec -it <파드이름> -- sh  # 파드 내부 쉘 접속
kubectl delete -f <파일.yaml>     # 리소스 삭제

8. 자주 묻는 질문 (FAQ)

Q1. 도커만 알아도 되지 않나요? 쿠버네티스는 언제 필요한가요? 단일 서버라면 도커(+docker compose)로 충분합니다. 여러 서버에 걸친 무중단 배포, 오토스케일링, 자동 복구가 필요해지는 시점부터 쿠버네티스의 가치가 나타납니다. 반대로 소규모 서비스에 쿠버네티스를 도입하면 운영 복잡도만 커질 수 있습니다.

Q2. 파드와 컨테이너의 차이가 뭔가요? 컨테이너는 격리된 프로세스이고, 파드는 "함께 배치되고 함께 죽는 컨테이너들의 묶음"이자 쿠버네티스 배포의 최소 단위입니다. 파드 안의 컨테이너들은 IP와 볼륨을 공유하며 localhost로 통신합니다. 대부분은 파드 하나에 컨테이너 하나입니다.

Q3. "쿠버네티스가 도커를 버렸다"는 말이 사실인가요? 절반만 사실입니다. 쿠버네티스 1.24에서 도커 전용 연동 계층(dockershim)이 제거되고 containerd 같은 표준 런타임(CRI)을 직접 사용하게 된 것입니다. 도커로 빌드한 이미지는 표준(OCI) 규격이므로 지금도 쿠버네티스에서 그대로 실행됩니다.

Q4. Deployment가 있는데 ReplicaSet은 왜 따로 있나요? ReplicaSet은 "파드 개수 유지"만 담당하고, Deployment는 그 위에서 롤링 업데이트·롤백 같은 배포 전략을 담당합니다. 실무에서는 ReplicaSet을 직접 만들 일이 거의 없고 항상 Deployment를 사용합니다.

Q5. readinessProbe와 livenessProbe는 뭐가 다른가요? livenessProbe는 "살아 있는가"를 검사해 실패 시 컨테이너를 재시작하고, readinessProbe는 "트래픽 받을 준비가 됐는가"를 검사해 실패 시 Service의 로드밸런싱 대상에서 제외합니다. 롤링 업데이트 시 무중단이 성립하는 것도 readinessProbe 덕분입니다.

Q6. etcd가 죽으면 어떻게 되나요? etcd는 클러스터 상태의 단일 진실 공급원이므로, etcd 장애 시 새로운 배포·변경이 불가능해집니다(이미 떠 있는 파드는 계속 동작). 그래서 운영 환경에서는 etcd를 3대 이상 홀수로 구성해 고가용성을 확보합니다.


9. 마무리

이번 글에서는 쿠버네티스의 존재 이유(컨테이너 오케스트레이션), 아키텍처(컨트롤 플레인/워커 노드), 핵심 오브젝트(Pod, Deployment, Service), 그리고 minikube 설치와 첫 배포 실습까지 다뤘습니다. 핵심만 다시 정리하면 다음과 같습니다.

  • 도커는 컨테이너를 만들고 실행하고, 쿠버네티스는 컨테이너들을 배치·복구·확장한다. 대체가 아닌 계층 관계다.
  • 배포의 최소 단위는 컨테이너가 아니라 파드이며, 파드는 스케줄링 원자성·네트워크 공유·런타임 추상화를 위한 포장 단위다.
  • 파드는 일회용이므로 Deployment로 개수와 배포 전략을 선언하고, IP가 바뀌는 파드 앞에는 Service로 고정 접점을 둔다.
  • 쿠버네티스는 선언형 시스템이다. 원하는 상태를 선언하면 조정 루프가 현재 상태를 그 방향으로 계속 수렴시킨다.

 

이 글이 도움이 되셨다면 좋겠습니다! 질문은 댓글로 남겨주세요!

 

1. Kafka란 무엇인가?

Apache Kafka는 LinkedIn에서 개발되어 오픈소스로 공개된 분산 이벤트 스트리밍 플랫폼입니다. 초당 수백만 건의 메시지를 처리할 수 있는 높은 처리량(Throughput)과, 디스크 기반의 내구성(Durability)을 동시에 제공하는 것이 특징입니다.

한 줄로 요약하면 이렇습니다.

"데이터를 발행(Publish)하고 구독(Subscribe)하는, 대용량 처리에 특화된 분산 로그 저장소"

실무에서는 주로 다음과 같은 용도로 사용됩니다.

  • 서비스 간 비동기 통신 — 주문 서비스 → 결제 서비스 → 알림 서비스처럼 시스템을 느슨하게 연결
  • 이벤트 소싱 / 데이터 파이프라인 — DB 변경 사항(CDC)을 다른 시스템으로 실시간 전파
  • 로그 수집 — 여러 서버의 로그를 한 곳으로 모아 Elasticsearch, S3 등으로 적재
  • 실시간 스트림 처리 — 거래 이벤트 집계, 실시간 랭킹, 이상 거래 탐지

2. 왜 Kafka를 쓰는가?

RabbitMQ 같은 전통적인 메시지 큐(Message Queue)와 비교하면 Kafka의 설계 철학이 명확해집니다.

구분전통적 메시지 큐 (RabbitMQ 등)Apache Kafka
메시지 보관 소비되면 삭제 설정된 기간 동안 디스크에 보존 (기본 7일)
재소비(Replay) 불가능하거나 제한적 오프셋만 되감으면 언제든 가능
처리량 중간 매우 높음 (순차 I/O + 배치 + 제로카피)
소비 모델 브로커가 푸시(Push) 컨슈머가 풀(Pull)
주 용도 작업 큐, 라우팅 이벤트 스트리밍, 대용량 파이프라인

핵심 차이는 **"메시지를 지우지 않는다"**는 점입니다. Kafka는 메시지를 소비해도 삭제하지 않고 로그로 보존하기 때문에, 여러 시스템이 같은 데이터를 각자의 속도로 읽을 수 있고, 장애가 나도 오프셋을 되감아 재처리할 수 있습니다. 이 특성 덕분에 정합성이 중요한 결제·정산 시스템에서도 안심하고 사용할 수 있습니다.


3. 핵심 개념 한눈에 보기

3-1. 전체 아키텍처

 
                        ┌─────────────────────────────────────┐
                        │           Kafka Cluster             │
                        │                                     │
 ┌──────────┐           │  ┌─────────┐ ┌─────────┐ ┌────────┐ │          ┌──────────────┐
 │ Producer │──발행──▶  │  │ Broker1 │ │ Broker2 │ │Broker3 │ │ ◀──구독──│  Consumer     │
 └──────────┘           │  └─────────┘ └─────────┘ └────────┘ │          │  Group A      │
 ┌──────────┐           │                                     │          ├──────────────┤
 │ Producer │──발행──▶  │   Topic: "order-events"             │ ◀──구독──│  Consumer     │
 └──────────┘           │   ├─ Partition 0                    │          │  Group B      │
                        │   ├─ Partition 1                    │          └──────────────┘
                        │   └─ Partition 2                    │
                        └─────────────────────────────────────┘
용어설명
Producer 메시지를 토픽에 발행하는 클라이언트
Consumer 토픽에서 메시지를 읽어가는 클라이언트
Broker Kafka 서버 프로세스. 여러 대가 모여 클러스터를 구성
Topic 메시지를 분류하는 논리적 채널 (예: order-events)
Partition 토픽을 물리적으로 나눈 단위. 병렬 처리의 핵심
Offset 파티션 내 메시지의 고유 순번. 컨슈머가 "어디까지 읽었는지"의 기준
Consumer Group 컨슈머들의 논리적 묶음. 그룹 단위로 오프셋을 관리
Replication 파티션 복제본. 브로커 장애 시에도 데이터를 보호

3-2. 파티션과 오프셋 — Kafka 이해의 핵심

토픽은 하나의 큰 통이 아니라, 여러 개의 파티션으로 쪼개져 있습니다. 각 파티션은 추가만 가능한(append-only) 로그이며, 메시지마다 순번(오프셋)이 붙습니다.

 
 Topic: order-events

 Partition 0 :  [0][1][2][3][4][5][6] ◀── 새 메시지는 항상 끝에 추가
                          ▲
                          └─ Consumer Group A의 현재 오프셋 (3)

 Partition 1 :  [0][1][2][3][4]
 Partition 2 :  [0][1][2][3][4][5]

여기서 반드시 기억해야 할 두 가지가 있습니다.

  1. 순서 보장은 파티션 단위로만 성립합니다. 토픽 전체의 순서는 보장되지 않습니다. 같은 주문 ID의 이벤트 순서를 지키고 싶다면, 주문 ID를 **메시지 키(Key)**로 지정해 항상 같은 파티션에 들어가도록 해야 합니다.
  2. 오프셋은 컨슈머 그룹별로 독립적으로 관리됩니다. 그룹 A가 오프셋 100까지 읽었어도, 그룹 B는 0부터 다시 읽을 수 있습니다.

3-3. 컨슈머 그룹과 파티션 할당

하나의 파티션은 같은 그룹 내에서 단 하나의 컨슈머에게만 할당됩니다.

 
 Topic (파티션 3개)              Consumer Group A

 Partition 0  ─────────────▶  Consumer 1
 Partition 1  ─────────────▶  Consumer 2
 Partition 2  ─────────────▶  Consumer 3

 ※ 컨슈머가 4명이라면? → 1명은 놀게 됨 (파티션 수 > 컨슈머 수가 될 수 없음)
 ※ 컨슈머가 1명 죽으면? → 리밸런싱 발생, 남은 컨슈머가 파티션을 재분배

따라서 파티션 수 = 해당 토픽의 최대 병렬 처리 수준이 됩니다. 파티션 수 설계가 곧 처리량 설계인 이유입니다.

3-4. 복제(Replication)와 장애 대응

 
 Partition 0 의 복제 (replication.factor = 3)

 Broker 1 : [Leader]   ◀── Producer/Consumer는 리더와만 통신
 Broker 2 : [Follower] ◀── 리더의 데이터를 복제
 Broker 3 : [Follower]

 Broker 1 장애 발생 시 → Follower 중 하나가 새 Leader로 승격

프로듀서의 acks 설정에 따라 내구성 수준이 달라집니다.

  • acks=0 — 응답을 기다리지 않음. 가장 빠르지만 유실 가능
  • acks=1 — 리더 저장까지만 확인. 리더 장애 시 유실 가능
  • acks=all — 동기화된 복제본(ISR) 저장까지 확인. 금융·결제 도메인의 기본값

4. ZooKeeper는 이제 안녕 — KRaft 모드

과거 Kafka는 클러스터 메타데이터 관리를 위해 ZooKeeper라는 별도 시스템이 필수였습니다. 하지만 운영 부담과 확장성 한계 때문에, Kafka 자체에 Raft 합의 알고리즘을 내장한 KRaft(Kafka Raft) 모드가 도입되었습니다.

 
 [과거]  ZooKeeper 클러스터 ──메타데이터 관리──▶ Kafka 클러스터   (2개 시스템 운영)

 [현재]  Kafka 클러스터 (KRaft 내장, Controller가 메타데이터 자체 관리)  (1개 시스템)
  • Kafka 3.3부터 KRaft가 프로덕션 준비 완료(Production-Ready) 판정
  • Kafka 4.0부터는 ZooKeeper 지원이 완전히 제거되어 KRaft만 사용 가능

지금 새로 시작한다면 ZooKeeper를 배울 필요 없이 KRaft 모드로 바로 시작하면 됩니다. 아래 설치 가이드도 KRaft 기준입니다.


5. Kafka 설치하기

5-1. 사전 준비

  • Java 17 이상 (Kafka 4.x 브로커 기준)
  • 확인: java -version

5-2. 방법 A — 바이너리 직접 설치 (macOS / Linux)

① 다운로드 및 압축 해제

 
bash
# 공식 사이트(kafka.apache.org/downloads)에서 최신 버전 확인 후 다운로드
wget https://downloads.apache.org/kafka/4.0.0/kafka_2.13-4.0.0.tgz
tar -xzf kafka_2.13-4.0.0.tgz
cd kafka_2.13-4.0.0

② 클러스터 ID 생성 및 스토리지 포맷 (KRaft 필수 단계)

 
bash
# 클러스터를 식별할 고유 ID 생성
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"

# 로그 디렉토리를 KRaft 형식으로 초기화
bin/kafka-storage.sh format \
  -t $KAFKA_CLUSTER_ID \
  -c config/server.properties

③ 브로커 실행

 
bash
bin/kafka-server-start.sh config/server.properties

터미널에 Kafka Server started 로그가 보이면 성공입니다. 기본 포트는 9092입니다.

5-3. 방법 B — Docker Compose (실무 로컬 개발 추천)

docker-compose.yml 파일을 만들고 아래 내용을 붙여넣습니다.

 
yaml
services:
  kafka:
    image: apache/kafka:4.0.0
    container_name: kafka
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
 
bash
docker compose up -d
docker compose logs -f kafka   # 기동 로그 확인

💡 팁: KAFKA_ADVERTISED_LISTENERS는 클라이언트가 브로커에 접속할 때 안내받는 주소입니다. 로컬 개발이면 localhost:9092, 다른 서버에서 접속한다면 해당 서버의 IP/도메인으로 바꿔야 합니다. Kafka 접속 오류의 8할이 이 설정 때문입니다.


6. CLI로 첫 메시지 주고받기

① 토픽 생성

 
bash
bin/kafka-topics.sh --create \
  --topic order-events \
  --partitions 3 \
  --replication-factor 1 \
  --bootstrap-server localhost:9092

② 토픽 확인

 
bash
# 토픽 목록
bin/kafka-topics.sh --list --bootstrap-server localhost:9092

# 파티션, 리더 등 상세 정보
bin/kafka-topics.sh --describe --topic order-events --bootstrap-server localhost:9092

③ 메시지 발행 (Producer)

 
bash
bin/kafka-console-producer.sh \
  --topic order-events \
  --bootstrap-server localhost:9092

> {"orderId": 1001, "status": "PAID"}
> {"orderId": 1002, "status": "CANCELLED"}

④ 메시지 소비 (Consumer)

새 터미널을 열고 실행합니다.

 
bash
bin/kafka-console-consumer.sh \
  --topic order-events \
  --from-beginning \
  --group my-group \
  --bootstrap-server localhost:9092

프로듀서 터미널에서 입력한 메시지가 컨슈머 터미널에 실시간으로 출력되면 성공입니다. 🎉

⑤ 컨슈머 그룹 오프셋 확인

 
bash
bin/kafka-consumer-groups.sh \
  --describe --group my-group \
  --bootstrap-server localhost:9092

CURRENT-OFFSET(읽은 위치), LOG-END-OFFSET(마지막 메시지 위치), LAG(밀린 양)을 확인할 수 있습니다. LAG 모니터링은 Kafka 운영의 기본입니다.


7. 자주 묻는 질문 (FAQ)

Q1. 파티션 수는 몇 개로 잡아야 하나요? 목표 처리량 ÷ 컨슈머 1대의 처리량으로 최소치를 잡되, 파티션은 줄일 수 없으므로 약간의 여유를 두는 것이 일반적입니다. 무작정 늘리면 리밸런싱 비용과 브로커 부하가 커지니 주의하세요.

Q2. 메시지 순서를 보장하려면 어떻게 하나요? 순서가 필요한 단위(예: 주문 ID, 회원 ID)를 메시지 키로 지정하세요. 같은 키는 항상 같은 파티션에 들어가므로, 파티션 내 순서 보장에 의해 처리 순서가 지켜집니다.

Q3. 메시지가 중복 소비될 수 있나요? 네. Kafka의 기본 전달 보장은 at-least-once이므로 리밸런싱, 재시도 상황에서 중복이 발생할 수 있습니다. 따라서 컨슈머 로직은 멱등(idempotent)하게 설계하는 것이 원칙입니다. (예: 이벤트 ID 기준 Redis SETNX + DB Unique 제약으로 이중 방어)

Q4. 메시지는 언제 삭제되나요? 소비 여부와 무관하게 retention.ms(기본 7일) 또는 retention.bytes 기준으로 오래된 세그먼트가 삭제됩니다. 컨슈머가 읽었다고 지워지는 것이 아닙니다.

Q5. RabbitMQ 대신 Kafka를 써야 하는 기준은? 대용량 이벤트 스트림, 재소비(Replay) 필요, 여러 소비 시스템이 같은 데이터를 사용하는 경우 Kafka가 유리합니다. 반대로 복잡한 라우팅, 지연 큐, 개별 메시지 ACK가 중요한 작업 큐라면 RabbitMQ가 더 단순할 수 있습니다.


8. 마무리

이번 글에서는 Apache Kafka의 핵심 개념(브로커, 토픽, 파티션, 오프셋, 컨슈머 그룹, 복제)과 KRaft 모드 기반의 설치, 그리고 CLI 실습까지 다뤘습니다. 핵심만 다시 정리하면 다음과 같습니다.

  • Kafka는 메시지를 지우지 않는 분산 로그이며, 이 특성이 재처리와 다중 소비를 가능하게 한다.
  • 파티션 수 = 병렬 처리의 상한이고, 순서 보장은 파티션(=키) 단위로만 성립한다.
  • 컨슈머는 중복 소비를 전제로 멱등하게 설계해야 한다.
  • 신규 구축이라면 ZooKeeper 없이 KRaft 모드로 시작하면 된다.

 

참고

URL : https://github.com/shw7349/kafkaProject

REST API에서 반드시 기억할 핵심 개념

 

HTTP Method


Method 역할 예시
GET 조회 /users?id=1
POST 생성 /users
PUT 전체 수정 /users/1
PATCH 부분 수정 /users/1
DELETE 삭제 /users/1

 

상태 코드 (Status Code)


코드 의미
200 OK 성공
201 Created 생성 성공
400 Bad Request 파라미터 오류
401 Unauthorized 인증 필요
403 Forbidden 권한 없음
404 Not Found 리소스 없음
500 Internal Server Error 서버 오류

 

URL 패턴 규칙

잘못된 방식 올바른 방식
/getUser?id=1 /users/1
/deleteUser/1 /users/1 (DELETE 메서드 사용)
/createUser /users (POST 사용)

 

 

Query Parameter vs Path Variable

  • Path Variable → 자원 식별
    /users/3
  • Query Parameter → 검색/필터링
    /users?age=20&sort=name

 

 

JSON 기본 규칙

  • key, value는 "문자형"
  • 리스트 가능
  • 예:
 
{
  "id": 1,
  "name": "Alice",
  "skills": ["Java", "Spring"]
}
 
 
 

 

 

REST API 호출 로직

 
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;

public class ApiRequestTest {

    public static void main(String[] args) {

        try {
            // 1) URL 준비
            String apiUrl = "https://example.com/api/user";

            // 2) 파라미터 구성 (URLEncoder 필수)
            String params = "name=" + URLEncoder.encode("Alice", "UTF-8")
                    + "&age=" + URLEncoder.encode("22", "UTF-8");

            // 3) GET 호출 URL 완성
            String requestUrl = apiUrl + "?" + params;

            URL url = new URL(requestUrl);

            // 4) 연결 + 메서드 지정
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");

            // 5) 응답 코드 확인 (암기 포인트)
            int status = conn.getResponseCode();
            System.out.println("Status Code: " + status);

            // 6) 결과 읽기
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            StringBuilder response = new StringBuilder();
            String line;

            while ((line = br.readLine()) != null) {
                response.append(line);
            }

            br.close();

            // 7) 결과 출력
            System.out.println("Response: " + response.toString());

        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

+ Recent posts