AOP로 감싼 Redisson 분산 락, 내가 직접 짠 코드에서 발견한 버그 두 개.

|

개인 학습용으로 짜둔 동시성 락 비교 예제 코드를 오랜만에 다시 열어봤다. Optimistic Lock, Pessimistic Lock, MySQL Named Lock, Redis 기반 락(Lettuce/Redisson)을 각각 구현해서 비교해보는 코드인데, 그중 @RedissonLock 이라는 어노테이션 하나로 메서드에 분산 락을 걸 수 있게 만든 AOP @Around advice에서 실제로 동작하는 버그를 두 개 발견했다. 둘 다 “동시성 테스트는 통과하는데 실전에서는 위험한” 유형이라 기록해둔다.

문제의 코드

대략 이런 구조였다.

@Around("@annotation(RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
    RLock lock = redissonClient.getLock(lockKey);

    boolean lockable;
    try {
        lockable = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    if (!lockable) {
        log.info("Lock 획득 실패 = {}", lockKey);
    }

    try {
        joinPoint.proceed(); // 반환값을 버림
    } finally {
        lock.unlock();
    }
}

버그 1 — 락을 못 잡았는데도 로직이 그대로 실행됨

if (!lockable) { ... } 블록 안에서 로그만 남기고 return을 하지 않는다. 그래서 락 획득에 실패해도 코드는 그대로 아래로 흘러내려가 joinPoint.proceed()를 호출한다. 즉 “동시에 하나의 스레드만 실행되게 하겠다”는 락의 목적이, 락 획득에 실패하는 순간 완전히 무력화된다.

더해서 메서드가 void로 선언되어 있어서, 원래 대상 메서드가 값을 반환하더라도 AOP를 거치면 호출부는 항상 null을 받게 되는 문제도 같이 있었다.

수정은 간단하다 — 실패 시 확실히 끝내고, 반환 타입도 Object로 바꿔서 실제 결과를 전달하게 했다.

@Around("@annotation(RedissonLock)")
public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
    RLock lock = redissonClient.getLock(lockKey);

    boolean lockable;
    try {
        lockable = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    if (!lockable) {
        log.info("Lock 획득 실패 = {}", lockKey);
        return null; // 락을 못 잡았으면 원래 로직을 아예 실행하지 않는다
    }

    try {
        return joinPoint.proceed(); // 반환값을 그대로 전달
    } finally {
        lock.unlock();
    }
}

버그 2 — 락을 못 잡았는데 unlock()을 호출하면?

같은 프로젝트의 다른 Facade 클래스(순수 Redisson 락, AOP 없이 직접 호출하는 버전)에는 또 다른 패턴의 버그가 있었다.

boolean available = lock.tryLock(15, 1, TimeUnit.SECONDS);

if (!available) {
    log.info("lock 획득 실패");
}

try {
    stockService.decrease(id, quantity);
} finally {
    lock.unlock(); // available이 false여도 항상 호출됨
}

available이 false인 경우, 즉 락을 획득하지 못한 경우에도 finally 블록에서 lock.unlock()이 호출된다. Redisson 공식 문서를 확인해보면 이건 단순히 “의미 없는 호출” 정도가 아니라 실제로 예외를 던질 수 있는 상황이다.

Only lock owner thread can unlock it otherwise IllegalMonitorStateException would be thrown.

즉 현재 스레드가 보유하지 않은 락을 unlock()하면 IllegalMonitorStateException이 발생한다. 락 획득에 실패했다는 건 이 스레드가 그 락을 보유하지 않았다는 뜻이므로, 잘못하면 “락을 못 잡아서 실패” 로그를 남긴 직후 바로 런타임 예외로 한 번 더 터지는 상황이 된다.

수정은 락을 실제로 획득했을 때만 try/finally로 감싸는 것이다.

boolean available;
try {
    available = lock.tryLock(15, 1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    throw new RuntimeException(e);
}

if (!available) {
    log.info("lock 획득 실패");
    return; // unlock()도 호출하면 안 된다
}

try {
    stockService.decrease(id, quantity);
} finally {
    lock.unlock(); // 여기 도달했다는 건 락을 실제로 획득했다는 뜻
}

왜 기존 테스트는 이걸 못 잡았나

이 프로젝트엔 이미 동시성 테스트가 있었다. 스레드 풀을 만들어 100개 요청을 동시에 날리고, 최종 수량이 예상값과 일치하는지 확인하는 방식이다.

ExecutorService executorService = Executors.newFixedThreadPool(32);
// ... 100개 요청을 동시에 실행 후
assertThat(stock.getQuantity()).isEqualTo(originQuantity - CONCURRENT_COUNT);

문제는 tryLock의 waitTime이 15초로 꽤 넉넉하게 잡혀 있었다는 점이다. 가벼운 로직을 다루는 테스트 환경에서 100개 스레드가 짧은 시간 안에 락을 주고받다 보면, 대부분 15초 안에 어떻게든 차례가 돌아와서 락 획득에 성공한다. 즉 if (!lockable) 분기, 버그가 실제로 있는 그 경로를 테스트가 실질적으로 거의 타지 않았던 것이다. 최종 수량만 검증하는 테스트는 “락이 제대로 걸렸다”를 증명하지, “락 획득에 실패하는 경로가 안전하다”까지는 증명해주지 않는다.

이런 종류의 버그를 잡으려면 waitTime을 아주 짧게(또는 0으로) 준 상태에서 일부러 락 경쟁을 유발하고, 실패 분기가 예외 없이 안전하게 빠져나가는지, 그리고 보호 대상 로직이 정말 실행되지 않았는지를 직접 검증하는 별도 테스트가 필요하다.

정리

  • @Around advice는 void로 선언하지 말고 joinPoint.proceed()의 반환값을 그대로 돌려줘야 한다 — 안 그러면 대상 메서드의 반환값이 항상 사라진다.
  • 락 획득 실패 시에는 반드시 그 자리에서 return해서 보호 대상 로직이 실행되지 않게 해야 한다.
  • Redisson RLock.unlock()은 락을 실제로 보유한 스레드만 호출해야 한다 — 획득 실패 경로에서는 절대 호출하면 안 된다(IllegalMonitorStateException).
  • 동시성 테스트를 짤 때는 “성공 경로가 정상 동작하는지”뿐 아니라 “실패(락 경합) 경로가 안전한지”도 별도로 검증해야 한다. waitTime이 넉넉하면 실패 경로 자체가 테스트에서 거의 실행되지 않을 수 있다.

참고자료


Redis를 캐시로 제대로 사용하기 — 자료구조부터 장애 대응까지.

|

Redis-cli 원격 접속하기, Spring boot 환경에서 Spring Session을 통해 세션 저장하기 글에서 Redis를 이미 써본 적은 있는데, 그때는 설치·연결·세션 저장 정도였다. 이번엔 조금 다른 관점에서 정리해본다.

Redis를 실제 서비스의 캐시로 쓴다면, 무엇까지 알아야 할까?

SET key value / GET key로 값을 넣고 빼는 것 자체는 어렵지 않다. 하지만 실무에서는 금방 다른 질문들이 따라온다 — 어떤 자료구조를 써야 할지, TTL은 얼마로 줘야 할지, 서버가 재시작되면 데이터는 어떻게 되는지, 캐시가 한꺼번에 만료되면 무슨 일이 생기는지, Redis가 죽으면 서비스도 같이 죽어야 하는지, 캐시를 붙였는데 정말 효과가 있는지는 어떻게 확인하는지. 이번 글은 사용법보다 “왜 그렇게 쓰는가”에 초점을 맞춘다.

1. Redis의 핵심 자료구조

Redis는 단순 Key-Value 저장소처럼 보이지만, 실제로는 용도별로 골라 쓸 수 있는 다양한 자료구조를 제공한다.

타입 설명 대표 명령어
String 가장 기본. 문자열/숫자/직렬화된 객체 SET, GET, INCR
List 순서가 있는 문자열 목록 (연결 리스트) LPUSH, RPUSH, LRANGE
Hash 필드-값 쌍의 집합 (객체 하나를 표현하기 좋음) HSET, HGET, HGETALL
Set 중복 없는 집합, 순서 없음 SADD, SISMEMBER, SINTER(교집합)
Sorted Set (ZSet) 각 원소에 score를 매겨 정렬된 집합 ZADD, ZRANGE, ZRANK

좋아요 수 (String + INCR)

INCR post:1001:likes

INCR은 원자적으로 수행되기 때문에, 동시에 여러 요청이 들어와도 애플리케이션에서 synchronized 같은 처리를 따로 할 필요가 없다.

최근 조회 상품 (List)

LPUSH user:42:recent_view 1001
LTRIM user:42:recent_view 0 9   # 앞에서 10개만 남기고 나머지 삭제

새 항목을 앞에 추가하고, 최근 10개만 남긴다. LTRIM을 안 하면 뒤에서 다룰 “Big Key” 문제로 이어질 수 있다.

실시간 랭킹 (Sorted Set)

ZADD leaderboard 1500 "player1"
ZADD leaderboard 2300 "player2"
ZREVRANGE leaderboard 0 9 WITHSCORES   # 상위 10명 조회

관계형 DB라면 매번 SELECT * FROM leaderboard ORDER BY score DESC LIMIT 10을 실행해야 하는 것을, Sorted Set은 정렬된 상태 자체를 자료구조 레벨에서 유지한다 — 읽기가 압도적으로 빈번한 랭킹류 데이터에 잘 맞는다.

사용자 정보 (Hash)

HSET user:42 name "jmlim" email "example@example.com"
HGETALL user:42

객체 하나를 필드별로 관리하고 싶다면 Hash가 좋은 선택지다.

2. Redis도 메모리만 쓰는 게 아니다 — 영속성(Persistence)

Redis는 대표적인 인메모리 저장소지만, 서버가 죽거나 재시작돼도 데이터를 지키기 위한 두 가지 영속화 방식을 제공한다.

방식 동작 장점 단점
RDB (스냅샷) 특정 시점 전체 메모리 상태를 .rdb 파일로 저장 파일이 작고 복구가 빠름, 백업하기 좋음 마지막 스냅샷 이후 데이터는 유실될 수 있음
AOF (Append Only File) 쓰기 명령(SET, INCR, HSET …)을 로그처럼 순서대로 기록, 복구 시 재실행 유실 가능성을 최소화 파일이 크고 복구 시간이 RDB보다 오래 걸림

실무에서는 둘을 함께 쓰는 경우가 많다(RDB로 주기적 스냅샷 + AOF로 세밀한 복구) — “쓰기 속도와 내구성은 트레이드오프”라는, 저널링을 쓰는 다른 DB들과 같은 고민이다.

그런데 어떤 걸 켜야 할지는 결국 Redis의 용도에 달려 있다.

  • 원본이 이미 MySQL 같은 DB에 있고 Redis는 그 복사본을 빠르게 조회하기 위한 순수 캐시라면 → Redis 데이터가 통째로 사라져도 DB에서 다시 채우면 그만이다. 영속성을 꺼도 충분히 가능한 선택이다.
  • 반면 세션, 랭킹, 작업 상태처럼 Redis에 있는 데이터 자체가 원본인 경우라면 → 유실을 감수할 수 없으므로 영속화나 복제 전략을 같이 설계해야 한다.

정리하면: “Redis를 쓰니까 무조건 RDB/AOF를 켠다/끈다”가 아니라, Redis 데이터가 사라졌을 때 서비스가 실제로 어떤 문제를 겪는지를 먼저 따져야 한다.

3. 가장 많이 쓰는 캐시 전략 — Cache-Aside

캐시를 쓸 때 가장 흔한 방식이 Cache-Aside(Lazy Loading)다. 조회 흐름은 이렇다.

Client → Application → Redis 조회
                          │
              ┌───────────┴───────────┐
             HIT                     MISS
              │                       │
            바로 반환              DB 조회 → Redis에 저장 → 반환

코드로 보면 단순하다.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;                                 // Cache Hit
    }

    Product product = productRepository.findById(id)
            .orElseThrow();                             // Cache Miss -> DB 조회
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

첫 요청은 DB까지 내려가지만(MISS → DB 조회 → Redis 저장), 두 번째 요청부터는 HIT → 바로 반환이라 DB에 갈 필요가 없다. 애플리케이션이 캐시 로직을 직접 관리하는 방식이라 가장 유연하고 널리 쓰인다. 다만 DB와 캐시 사이에 “잠깐 다른 값을 보게 되는” 시점이 생길 수 있다(최종적 일관성).

그 외 전략 — Write-Through / Write-Behind

  • Write-Through — 쓸 때 DB와 캐시를 동시에 갱신한다. 캐시가 항상 최신 상태를 유지하지만, 매 쓰기마다 캐시 갱신 비용이 붙는다.
  • Write-Behind (Write-Back) — 캐시에만 먼저 쓰고 DB 반영은 나중에 비동기로 몰아서 처리한다. 쓰기 성능은 가장 좋지만, DB에 반영되기 전에 Redis가 죽으면 그 데이터를 잃을 위험이 있다 — 일반적인 조회 캐시에서는 흔치 않은 방식이고, Cache-Aside가 이해하기도 쉽고 압도적으로 많이 쓰인다.

4. 데이터가 바뀌면 캐시는 어떻게 하나 — Stale Data

DB의 상품 가격이 100만 원 → 90만 원으로 바뀌었다고 해보자. Redis에는 여전히 100만 원이 남아있을 수 있다. 이렇게 원본과 캐시가 어긋난 상태를 Stale Data라고 부른다.

대응 방법은 크게 두 가지다.

DB UPDATE → Redis UPDATE   (캐시도 같이 갱신)
DB UPDATE → Redis DEL key  (캐시 삭제)

Cache-Aside를 쓰고 있다면 보통 삭제 쪽이 더 이해하기 쉽다. 캐시를 지우면 다음 조회에서 MISS → DB 최신 데이터 조회 → Redis 재생성이라는, 이미 쓰고 있던 Cache-Aside 흐름을 그대로 타기 때문이다.

5. TTL, 몇 분으로 잡아야 할까

TTL을 정할 때 흔히 “10분 정도면 되겠지” 하고 감으로 정하기 쉬운데, 기준은 Redis가 아니라 데이터의 특성이어야 한다.

상품 기본정보   → 1시간   (좀 늦게 반영돼도 크게 문제 없음)
카테고리        → 6시간
전시/배너       → 5~10분
재고            → 수초~수십초, 또는 아예 캐시하지 않음
사용자 세션     → 세션 만료시간

기준이 되는 질문은 하나다.

DB와 Redis의 값이 얼마나 오래 달라도 서비스가 버틸 수 있는가?

상품 설명이 1시간 늦게 반영되는 건 괜찮을 수 있어도, 재고가 1시간 동안 “Redis: 있음 / DB: 품절” 상태로 어긋나 있으면 주문 로직이 크게 곤란해질 수 있다. 그래서 모든 캐시에 같은 TTL을 걸기보다는, 데이터 성격별로 TTL을 다르게 가져가는 게 맞다.

TTL에 랜덤값을 섞는 이유 (TTL Jitter)

오전 10시에 상품 1만 건을 캐시에 넣으면서 TTL을 전부 정확히 300초로 줬다고 해보자. 10시 5분이 되는 순간, 1만 개의 키가 동시에 만료되면서 Cache Miss가 한꺼번에 터지고 DB 조회가 폭증한다.

int jitter = ThreadLocalRandom.current().nextInt(0, 60);
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(300 + jitter));

이렇게 TTL에 작은 랜덤 값(위 예시라면 0~60초)을 더해주면 키마다 만료 시점이 흩어진다. 사소해 보이지만 트래픽이 큰 서비스에서는 꽤 중요한 차이를 만든다.

6. Cache Stampede — 인기 키 하나가 만료되는 순간

TTL Jitter로도 완전히 막지 못하는 상황이 있다. 인기 상품 하나(product:iphone)의 TTL이 끝나는 그 순간, 동시에 들어온 요청들이 전부 Cache Miss가 되어 한꺼번에 DB로 몰려가는 현상을 Cache Stampede라고 한다.

product:iphone 만료
      │
  ┌───┼───┐
Req1  Req2  Req3   ← 모두 동시에 MISS
  │    │    │
  DB   DB   DB      ← DB에 요청이 몰림

평소 Redis가 DB 트래픽 대부분을 막아주고 있었다면, 이 순간 DB가 감당 못 할 수도 있다. 대표적인 완화 방법은 다음과 같다.

  • TTL Jitter (위에서 설명한 방식)
  • 캐시 갱신을 락으로 한 번만 수행 — Cache Miss가 동시에 100번 발생해도, 락을 획득한 요청 하나만 DB를 조회해서 캐시를 채우고 나머지는 그 결과를 기다렸다가 캐시에서 읽는다
  • 캐시가 완전히 만료되기 전에 미리 갱신
  • 요청 병합, 적절한 Rate Limit

여기서 “캐시 갱신을 락으로 한 번만 수행”하는 부분은 결국 분산 락 문제이기도 하다. Redis 분산 락을 직접 구현하다 보면 놓치기 쉬운 함정들을 AOP로 감싼 Redisson 분산 락에서 발견한 버그 두 개 글에서 다뤘다.

7. Cache Penetration — 존재하지 않는 데이터를 계속 찾는 요청

GET /products/999999999처럼 애초에 존재하지 않는 데이터를 계속 요청받는 경우도 있다. 없는 데이터는 캐시에 저장된 적이 없으니 매번 MISS → DB 조회 → 없음을 반복하게 되고, DB는 이 무의미한 조회를 계속 떠안는다. 이를 Cache Penetration이라 한다.

해결책은 “없다”는 사실 자체도 짧게 캐시하는 것이다.

product:999999999 = "__NULL__"   (TTL 30초)

두 번째 요청부터는 HIT → 없는 상품 처리로 끝나기 때문에 DB까지 반복해서 내려가는 걸 막을 수 있다. 다만 NULL 캐시의 TTL은 일반 데이터보다 짧게 가져가는 게 일반적이다(나중에 진짜 데이터가 생겼을 때 너무 오래 “없음”으로 남아있으면 안 되므로).

8. Hot Key와 Big Key — Redis인데 왜 느리지?

Hot Key

평소엔 상품마다 트래픽이 고르게 분산돼 있다가, 특정 상품 하나(신제품 예약 판매 등)에 요청이 집중되는 경우가 있다. 특히 Redis Cluster에서는 Key가 특정 노드(shard)에 고정 배치되기 때문에, 클러스터 전체 자원은 충분해 보여도 그 키를 담당하는 노드 하나만 병목이 될 수 있다. 대규모 이벤트나 인기 상품처럼 특정 키에 트래픽이 몰릴 가능성이 있다면 미리 고려해야 하는 문제다.

Big Key

“최근 조회 상품” 같은 List에 삭제/트림 없이 계속 값만 추가하면, 그 키 하나가 수백만 개 원소를 가진 거대한 값이 될 수 있다. 이런 키를 Big Key라고 부른다. HGETALL, SMEMBERS, LRANGE 0 -1처럼 컬렉션 전체를 읽는 명령은 데이터가 커질수록 비용이 커지므로 특히 주의해야 한다.

Redis가 빠르다는 것과, “내가 쓰는 명령어가 빠르다”는 것은 다른 이야기다 — 자료구조뿐 아니라 사용하는 명령어의 시간복잡도도 같이 봐야 한다.

앞서 나온 최근 조회 상품 예시에서 LTRIM으로 개수를 제한했던 이유가 바로 이 Big Key를 애초에 만들지 않기 위해서다.

9. 메모리가 가득 차면 — Eviction Policy

maxmemory에 도달했을 때 Redis가 어떤 키부터 지울지 정하는 정책이다. maxmemory-policy 설정으로 지정한다.

정책 설명
noeviction 새로 못 씀 (에러 반환) — 기본값
allkeys-lru 가장 오랫동안 사용 안 된(Least Recently Used) 키부터 제거 — 캐시 용도로 가장 흔히 씀
allkeys-lfu 사용 빈도(Least Frequently Used)가 가장 낮은 키부터 제거
volatile-ttl TTL이 설정된 키 중 만료가 가장 임박한 키부터 제거

“최근에 쓰인 게 중요하다”면 LRU, “자주 쓰이는 게 중요하다”면 LFU — 접근 패턴에 따라 고를 수 있다. 정답은 하나가 아니다.

10. Redis가 장애 나면 서비스도 같이 죽어야 할까

순수 Cache-Aside 구조라면 HIT → Redis 사용, MISS → DB 사용, Redis 장애 → DB 사용처럼 자연스럽게 DB로 폴백할 수 있을 것 같지만, 여기엔 함정이 하나 있다.

Redis가 평소 5,000 TPS 중 4,750 TPS(95%)를 처리하고, DB는 나머지 250 TPS 정도만 받고 있었다고 해보자. Redis가 죽는 순간 5,000 TPS가 그대로 DB로 몰린다.

Redis 장애 → 5,000 TPS 전부 DB로
          → DB Connection Pool 고갈
          → API Timeout
          → 서비스 장애

평소 250 TPS만 받던 DB가 갑자기 5,000 TPS를 받으면 못 버틸 수 있다. 즉,

“Redis가 죽으면 DB로 폴백한다”는 설계는 절반짜리다 — DB가 그 트래픽을 실제로 감당할 수 있는지까지 같이 설계해야 완전하다.

그래서 Redis 타임아웃, Circuit Breaker, Rate Limiting, DB Connection Pool 크기 같은 것도 캐시 설계와 한 세트로 고민해야 한다.

11. Spring Boot에서는 @Cacheable로 더 간단하게

지금까지는 RedisTemplate을 직접 다루는 예시였는데, Spring Cache 추상화를 쓰면 애너테이션 하나로 같은 Cache-Aside 흐름을 표현할 수 있다.

@Cacheable(cacheNames = "products", key = "#id")
public Product getProduct(Long id) {
    return productRepository.findById(id).orElseThrow();
}

@CacheEvict(cacheNames = "products", key = "#id")
public void updateProduct(Long id, ProductUpdateRequest request) {
    // ...
}

getProduct는 처음 호출되면 MISS → DB 조회 → 캐시 저장 흐름을 타고, 이후엔 HIT → 바로 반환한다. updateProduct가 호출되면 해당 캐시를 지워서 다음 조회 때 최신 값을 다시 채우게 만든다. 결국 @Cacheable/@CacheEvict도 지금까지 설명한 Cache-Aside와 캐시 무효화를, Spring이 대신 배선해주는 것뿐이라고 보면 된다.

12. Redis를 붙였는데 정말 효과가 있었나 — 측정하기

캐시를 적용했다고 성능이 좋아졌다고 가정하면 안 되고, 실제 지표로 확인해야 한다. 대표적으로 보는 값들:

Cache Hit Ratio / Cache Miss Ratio
Redis Latency, CPU, Memory Usage, Evicted Keys
DB QPS, DB Connection Pool 사용률
API 응답시간

Cache Hit Ratio가 특히 중요한 지표다.

Cache Hit Ratio = Cache Hit / (Cache Hit + Cache Miss)

적용 전 상품 API가 5,000 TPS에 DB SELECT도 5,000 QPS, 평균 응답 80ms였다면, 적용 후 Cache Hit이 4,750(Hit Ratio 95%)이 나오면서 DB SELECT는 250 QPS로, 평균 응답은 15ms로 떨어지는 식으로 개선을 수치로 확인할 수 있어야 한다. 반대로 Hit Ratio가 10% 수준이라면, 캐시를 운영하는 비용 대비 실제 효과가 있는지 다시 따져봐야 한다.

정리

  • 자료구조는 용도에 맞게 고른다 — 단순 값은 String, 카운터는 String+INCR, 객체는 Hash, 랭킹/정렬은 Sorted Set.
  • 순수 캐시 용도라면 영속성(RDB/AOF)을 꺼도 되지만, 세션처럼 데이터 유실이 문제가 되는 용도라면 반드시 켜야 한다.
  • 캐시 전략은 Cache-Aside가 기본값이고, Write-Through/Write-Behind는 트레이드오프를 이해하고 필요할 때만 고려한다.
  • TTL은 “데이터가 얼마나 오래 stale해도 괜찮은가”로 정하고, 대량의 키가 한꺼번에 만료되지 않도록 TTL Jitter를 챙긴다.
  • Cache Stampede(인기 키 동시 만료), Cache Penetration(존재하지 않는 데이터 반복 조회), Hot Key/Big Key(특정 키 병목)는 트래픽이 커질수록 실제로 마주치는 문제들이다.
  • Redis 장애 시 DB로 폴백하는 설계라도, DB가 그 트래픽을 실제로 받아낼 수 있는지까지 같이 설계해야 한다.
  • Spring에서는 @Cacheable/@CacheEvict로 이 패턴들을 추상화할 수 있지만, 결국 그 안에서 일어나는 일은 이 글에서 다룬 것과 같다.
  • 마지막으로 Cache Hit Ratio 같은 실제 지표로 캐시가 정말 도움이 되고 있는지 확인한다.

결국 Redis 캐싱에서 중요한 질문은 “Redis를 어떻게 쓸까”보다 “이 데이터는 왜 캐시해야 하고, 캐시가 사라지면 서비스는 어떻게 동작해야 하는가”에 더 가깝다. 잘못 만든 캐시는 성능 개선 수단이 아니라 또 하나의 장애 포인트가 될 수 있다.

참고자료


MySQL 트랜잭션 격리수준과 락 정리.

|

Spring 에서의 트랜잭션 처리와 Spring @Transactional은 어떻게 동작할까? 글에서 트랜잭션의 “논리적 단위(commit/rollback)”와 그걸 가능하게 하는 AOP Proxy를 다뤘는데, 이번엔 그 트랜잭션들이 동시에 여러 개 실행될 때 어떤 문제가 생기고 MySQL(InnoDB)이 그걸 어떻게 막는지를 정리한다.

왜 격리수준이 필요한가

여러 트랜잭션이 동시에 같은 행(row)에 접근하면 다음과 같은 이상 현상(anomaly)이 생길 수 있다.

현상 설명
Dirty Read 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽어버림. 그 트랜잭션이 롤백되면 존재한 적 없는 값을 읽은 셈이 됨
Non-Repeatable Read 한 트랜잭션 안에서 같은 행을 두 번 조회했는데, 그 사이 다른 트랜잭션이 값을 바꾸고 커밋해서 결과가 달라짐
Phantom Read 한 트랜잭션 안에서 같은 조건으로 두 번 조회했는데, 그 사이 다른 트랜잭션이 행을 추가/삭제해서 조회되는 행의 개수가 달라짐

이 세 현상을 얼마나 허용할지에 따라 격리수준이 4단계로 나뉜다. 아래로 갈수록 격리(안전성)는 강해지고, 동시성(성능)은 떨어진다.

격리수준 Dirty Read Non-Repeatable Read Phantom Read 비고
READ UNCOMMITTED 발생 발생 발생 실무에서 거의 안 씀
READ COMMITTED 방지 발생 발생 Oracle, PostgreSQL 기본값
REPEATABLE READ 방지 방지 (InnoDB는 갭락으로 대부분 방지) MySQL(InnoDB) 기본값
SERIALIZABLE 방지 방지 방지 사실상 트랜잭션을 순차 실행하는 것과 동일. 가장 느림
-- 현재 세션의 격리수준 확인/변경
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

MySQL(InnoDB)이 REPEATABLE READ에서도 Phantom Read를 웬만하면 막는 이유

표준 SQL 정의상으로는 REPEATABLE READ가 Phantom Read를 막아주지 않는데, InnoDB는 두 가지 메커니즘으로 이걸 실질적으로 막아준다.

  1. MVCC (Multi-Version Concurrency Control) — SELECT(일반 읽기)는 락을 걸지 않고, 트랜잭션이 시작된 시점의 스냅샷(undo log 기반)을 읽는다. 그래서 다른 트랜잭션이 중간에 뭘 추가/수정해도, 내 트랜잭션 안에서는 계속 같은 스냅샷을 보게 된다.
    • 읽기 자체는 락 없이 동작하므로 읽기 성능이 좋다 — “읽기는 쓰기를 블로킹하지 않고, 쓰기는 읽기를 블로킹하지 않는다.”
  2. 갭 락(Gap Lock) / 넥스트 키 락(Next-Key Lock) — SELECT ... FOR UPDATE처럼 락을 거는 조회의 경우, 존재하는 행뿐 아니라 행과 행 “사이의 간격(gap)”까지 잠가서 그 범위에 새 행이 삽입되는 것 자체를 막는다.

InnoDB 락의 종류

락 종류 잠그는 대상 예시
레코드 락 (Record Lock) 인덱스에 존재하는 특정 레코드 하나 id = 10 조건으로 FOR UPDATE
갭 락 (Gap Lock) 레코드와 레코드 “사이”의 빈 공간 id BETWEEN 10 AND 20 범위에 새 행 INSERT 방지
넥스트 키 락 (Next-Key Lock) 레코드 락 + 그 직전 갭 락을 합친 것 (InnoDB의 기본 락 방식) REPEATABLE READ에서 범위 조건 조회 시

락은 인덱스를 기준으로 걸린다. 조건절에 걸리는 컬럼에 인덱스가 없으면, MySQL은 어쩔 수 없이 스캔하는 모든 행(사실상 테이블 전체)에 락을 걸게 되어 동시성이 크게 떨어진다. “인덱스 없는 UPDATE/DELETE가 위험한” 진짜 이유가 여기 있다 — 느린 것뿐 아니라, 락 범위 자체가 넓어져서 다른 트랜잭션들을 오래 기다리게 만든다.

데드락 (Deadlock)

-- 트랜잭션 A
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- id=1 락 획득
-- (여기서 트랜잭션 B가 끼어듦)
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- id=2 락을 기다림 (B가 갖고 있음)

-- 트랜잭션 B (A와 동시에)
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- id=2 락 획득
UPDATE accounts SET balance = balance + 50 WHERE id = 1;  -- id=1 락을 기다림 (A가 갖고 있음)

A는 B가 가진 락을, B는 A가 가진 락을 서로 기다리며 영원히 대기 → 데드락. InnoDB는 이를 감지해서 둘 중 하나(대개 되돌릴 비용이 더 적은 쪽)를 강제로 롤백시켜 에러를 던진다.

예방법: 여러 테이블/행을 건드릴 때는 항상 같은 순서로 접근하도록 애플리케이션 코드를 짜는 것이 가장 기본적인 예방책이다. (위 예제에서 A, B 둘 다 “id 오름차순”으로 락을 걸었다면 데드락이 발생하지 않았을 것이다.)

실무에서 격리수준을 고려한 판단 기준

  • 대부분의 웹 서비스: 기본값(REPEATABLE READ) 그대로 두고, 정말 필요한 특정 쿼리에만 SELECT ... FOR UPDATE나 낙관적 잠금(버전 컬럼을 두고 UPDATE ... WHERE version = ?로 충돌을 감지하는 방식)을 적용하는 것이 일반적이다.
  • 격리수준을 낮추는(READ COMMITTED로 내리는) 경우: 갭 락으로 인한 동시성 저하가 심할 때, 애플리케이션 레벨에서 이미 동시성 제어를 하고 있을 때.
  • 격리수준을 SERIALIZABLE로 올리는 경우: 금융/정산처럼 절대 이상 현상이 있으면 안 되는 특수한 로직에 국한 — 성능 저하가 크므로 전체 트랜잭션에 걸지 않고 최소 범위로 적용한다.

이 글에서 다룬 트랜잭션은 어디까지나 하나의 DB 안에서의 이야기다. 서비스가 여러 개로 쪼개지고 DB도 서비스마다 따로 두는 MSA 환경으로 가면, BEGIN...COMMIT으로 묶을 수 있는 범위 자체가 사라진다 — 그 문제와 해법(Saga 패턴)은 MSA 분산 트랜잭션과 Saga 패턴 글에서 다뤘다.

참고자료


자바 ExecutorService와 CompletableFuture 제대로 쓰기.

|

이 블로그의 스프링에서 @Async로 비동기처리하기, 그리고 최근에 쓴 Spring @Async는 실제로 어떻게 동작할까? 글에서 @Async가 결국 쓰레드풀에 작업을 던지는 것뿐이라고 정리했었는데, 그 밑바탕이 되는 자바의 ExecutorService와 CompletableFuture를 스프링 없이 순수 자바 레벨에서 정리해본다.

왜 Thread를 직접 만들면 안 되는가

// 나쁜 예 - 요청마다 새 쓰레드 생성
new Thread(() -> doSomething()).start();
  • 쓰레드 생성/소멸 자체가 비용이 크다 (OS 레벨 자원 할당).
  • 몇 개까지 만들지 제한이 없어서, 트래픽이 몰리면 쓰레드가 무한정 늘어나다가 OutOfMemoryError 로 서버가 죽을 수 있다.
  • → 그래서 쓰레드를 미리 만들어두고 재사용하는 쓰레드 풀(Thread Pool) 이 필요하고, 자바에서는 ExecutorService 로 이를 추상화한다.

ExecutorService 기본

ExecutorService executor = Executors.newFixedThreadPool(10); // 쓰레드 10개 고정 풀

executor.submit(() -> {
    // 비동기로 실행될 작업
    System.out.println("작업 실행: " + Thread.currentThread().getName());
});

executor.shutdown(); // 더 이상 새 작업을 받지 않고, 기존 작업이 끝나면 종료

자주 쓰는 팩토리 메서드 (Executors)

메서드 특징 주의점
newFixedThreadPool(n) 고정 크기 n개 쓰레드 큐가 무제한(LinkedBlockingQueue) — 요청 폭주 시 큐가 무한정 쌓여 메모리 문제 가능
newCachedThreadPool() 필요할 때마다 쓰레드 생성, 60초 유휴 시 회수 상한선이 없어서 트래픽 폭주 시 쓰레드가 무한정 늘어날 수 있음
newSingleThreadExecutor() 쓰레드 1개, 작업을 순차 처리 순서 보장이 필요한 작업에 적합
newScheduledThreadPool(n) 지연/주기 실행 지원 스프링부트 Scheduling 글에서 다룬 @Scheduled의 기반 개념

실무에서는 위 팩토리 메서드보다 ThreadPoolExecutor 생성자를 직접 써서 corePoolSize, maximumPoolSize, queueCapacity, RejectedExecutionHandler 를 명시적으로 지정하는 걸 권장한다.

  • 이유: 기본값(Integer.MAX_VALUE 큐)을 그대로 쓰면, 트래픽이 몰려도 max 쓰레드까지 안 늘어나고 큐에만 계속 쌓이는 현상이 생길 수 있다. 실제로 스케일 인(scale-in) 직후 이 문제 때문에 지연 장애를 겪은 적이 있다 — 파드 개수가 줄어든 상태에서 @Async가 기본 쓰레드 수(코어풀 사이즈 1)만 쓰고 있었고, 몰린 요청이 죄다 큐에 쌓이기만 하면서 처리 지연이 눈덩이처럼 커졌었다. Spring의 ThreadPoolTaskExecutor도 결국 이 ThreadPoolExecutor를 감싼 것이라, corePoolSize/maxPoolSize/queueCapacity를 명시적으로 잡아주는 게 왜 중요한지는 Async 심화 글에서 더 자세히 다뤘다.

Future — 결과를 나중에 받기

ExecutorService executor = Executors.newFixedThreadPool(4);

Future<Integer> future = executor.submit(() -> {
    Thread.sleep(1000);
    return 42;
});

// future.get() 은 결과가 준비될 때까지 현재 쓰레드를 블로킹함 (Async지만 결과를 기다리는 부분은 Blocking)
Integer result = future.get();

Future의 한계

  • get()을 호출하는 순간 블로킹된다 — 완전한 논블로킹이 아니다.
  • 여러 작업을 연결(체이닝)하거나, 실패 시 콜백을 걸거나, 여러 Future를 조합하는 게 번거롭다.
  • → 이 한계를 보완하기 위해 Java 8부터 CompletableFuture가 등장했다.

CompletableFuture — 논블로킹 콜백 체이닝

CompletableFuture<Integer> future = CompletableFuture
    .supplyAsync(() -> {          // 1. 비동기로 값을 만들어냄 (별도 쓰레드풀에서 실행)
        return fetchUserCount();
    })
    .thenApply(count -> count * 2)      // 2. 결과를 받아 가공 (콜백 - 블로킹 없이 연결)
    .thenAccept(result -> System.out.println("결과: " + result)) // 3. 최종 소비
    .exceptionally(ex -> {              // 4. 예외 처리
        System.out.println("에러 발생: " + ex.getMessage());
        return null;
    });

get()으로 결과를 기다리는 대신, “결과가 나오면 이 콜백을 실행해줘”라는 방식으로 코드를 짤 수 있어서 호출 쓰레드를 블로킹하지 않는다. 동기/비동기가 “순서”의 문제이고 블로킹/논블로킹이 “제어권을 바로 돌려주느냐”의 문제라면, CompletableFuture는 정확히 비동기 + 논블로킹 조합에 해당한다.

여러 비동기 작업을 조합하는 예:

CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> getUser(id));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> getOrders(id));

// 두 작업이 모두 끝나면 합쳐서 처리
CompletableFuture<UserDetail> combined = userFuture.thenCombine(ordersFuture,
    (user, orders) -> new UserDetail(user, orders));

기본적으로 supplyAsync, thenApply 등은 ForkJoinPool.commonPool()이라는 공용 풀을 사용한다. 실무에서는 반드시 전용 Executor를 두 번째 인자로 넘겨서 다른 기능과 쓰레드풀을 공유하지 않도록 하는 게 안전하다.

CompletableFuture.supplyAsync(() -> fetchUserCount(), myExecutor);
  • 전용 Executor를 안 넘기면, 애플리케이션 안의 서로 무관한 여러 비동기 작업들이 전부 ForkJoinPool.commonPool()이라는 같은 풀을 공유하게 된다. 한쪽 작업이 오래 걸리면 다른 쪽 작업까지 그 풀의 쓰레드를 기다리게 되는데, 이건 MSA 회복탄력성 패턴에서 다룬 Bulkhead(격벽) 패턴이 막으려는 문제와 정확히 같은 모양이다 — 호출 대상별로 쓰레드풀을 분리해야 한쪽 장애가 다른 쪽까지 안 번진다.

Spring @Async와의 관계

스프링에서 @Async로 비동기처리하기에서 다룬 @Async는, 내부적으로 이 ExecutorService(스프링에서는 ThreadPoolTaskExecutor)에 작업을 던지는 걸 어노테이션으로 감싸놓은 것이다. @Async 메서드가 값을 반환해야 한다면 리턴 타입을 CompletableFuture<T>로 선언하면, 호출부에서 결과를 논블로킹으로 조합할 수 있다.

정리

  • 쓰레드를 직접 만들지 말고 ExecutorService(쓰레드 풀)로 재사용한다.
  • 팩토리 메서드(newFixedThreadPool 등)보다 ThreadPoolExecutor를 직접 구성해서 큐/최대 쓰레드 수를 명시적으로 관리하는 게 운영 환경에서 안전하다.
  • 결과를 기다려야 한다면 Future보다 CompletableFuture로 논블로킹 콜백 체이닝을 쓰는 게 낫다.
  • CompletableFuture를 쓸 때는 공용 풀(ForkJoinPool.commonPool())을 쓰지 말고 전용 Executor를 넘겨서, 다른 기능과 쓰레드풀 자원을 두고 서로 발목 잡는 일을 막는다.

참고자료


Spring Boot Actuator, 운영 환경에서 어디까지 노출해도 될까?

|

이 블로그의 MSA 회복탄력성 패턴 글에서 서비스 디스커버리/헬스체크 얘기를 잠깐 했었는데, 실제로 Spring Boot 애플리케이션에서 그 헬스체크(/actuator/health)를 어디까지 열어둬야 하는지는 또 별개의 고민이다.

Spring Boot 서비스를 운영하다 보면 /actuator/health를 사용하는 경우가 많다.

특히 Kubernetes, AWS ALB, 모니터링 시스템 등에서 애플리케이션이 정상적으로 살아있는지 확인하기 위해 Health Check 용도로 많이 사용한다.

그런데 Actuator 설정을 하다 보면 이런 설정을 만나게 된다.

management:
  endpoints:
    web:
      discovery:
        enabled: false

처음 보면 이런 생각이 든다.

/actuator가 노출되면 보안상 위험한 건가? discovery.enabled: false는 반드시 설정해야 하나?

결론부터 말하면 꼭 필요한 설정은 아니다.

더 중요한 것은 /actuator라는 경로 자체를 숨기는 것이 아니라, 어떤 Actuator Endpoint를 실제로 외부에 노출하고 있는지 관리하는 것이다.

1. /actuator에 접속하면 무엇이 나올까?

Spring Boot Actuator가 활성화되어 있다면 기본적으로 다음과 같은 경로를 사용할 수 있다.

/actuator
/actuator/health

/actuator에 접속하면 환경에 따라 다음과 비슷한 응답을 볼 수 있다.

{
  "_links": {
    "self": {
      "href": "https://example.com/actuator"
    },
    "health": {
      "href": "https://example.com/actuator/health"
    }
  }
}

여기서 중요한 점이 있다.

/actuator가 애플리케이션의 내부 정보를 전부 보여주는 것은 아니다.

쉽게 말하면 /actuator는

“현재 웹으로 접근할 수 있는 Actuator Endpoint는 이런 것들이 있습니다.”

라는 Endpoint 탐색 정보(Discovery Page) 를 제공한다.

2. 그렇다면 discovery.enabled: false는 무엇일까?

다음 설정을 추가해보자.

management:
  endpoints:
    web:
      discovery:
        enabled: false

이 설정의 역할은 Actuator Endpoint 자체를 비활성화하는 것이 아니다.

/actuator에서 제공하는 Endpoint 목록, 즉 Discovery Page를 비활성화한다.

예를 들어 기존에

GET /actuator

를 호출했을 때 다음과 같은 정보가 보였다면,

{
  "_links": {
    "health": {
      "href": "https://example.com/actuator/health"
    }
  }
}

discovery.enabled: false를 적용하면 /actuator에서 이런 목록을 제공하지 않게 된다.

하지만 중요한 점이 있다.

/actuator/health

가 함께 비활성화되는 것은 아니다.

즉,

/actuator
    ↓
Endpoint 목록 제공 안 함

/actuator/health
    ↓
여전히 접근 가능

이라고 이해하면 된다.

discovery.enabled: false는 Endpoint를 막는 설정이라기보다는 Endpoint 목록을 보여주지 않는 설정이다.

3. 그러면 보안상 중요한 설정은 무엇일까?

개인적으로 Actuator 설정에서 더 중요하게 봐야 할 부분은 discovery보다 exposure다.

예를 들어 다음 설정을 보자.

management:
  endpoints:
    web:
      exposure:
        include: health

이 설정은 웹을 통해 노출할 Endpoint를 health로 제한한다.

둘의 차이를 정리하면 다음과 같다.

설정 역할
discovery.enabled: false /actuator에서 Endpoint 목록을 보여주지 않음
exposure.include: health 웹으로 접근 가능한 Endpoint를 health로 제한
endpoint.health.enabled: true Health Endpoint 자체를 활성화
health.show-details: never Health의 상세 정보를 숨김

즉 보안 관점에서는

exposure:
  include: health

가 훨씬 본질적인 설정이다.

4. 왜 Exposure 설정이 더 중요할까?

Actuator에는 Health 외에도 다양한 Endpoint가 존재한다.

대표적으로 다음과 같은 것들이 있다.

/actuator/env
/actuator/beans
/actuator/configprops
/actuator/mappings
/actuator/loggers
/actuator/heapdump

이런 Endpoint가 운영 환경에 불필요하게 노출되면 애플리케이션 구조나 설정 등에 대한 정보를 외부에 제공할 수 있다.

따라서 중요한 것은

/actuator라는 주소를 알고 있느냐

보다는

실제로 어떤 Endpoint에 접근할 수 있느냐

이다.

예를 들어 /actuator에서 Health Endpoint가 존재한다는 사실을 확인할 수 있다고 하더라도,

/actuator/health

하나만 접근할 수 있다면 공격자가 얻을 수 있는 정보는 상당히 제한적이다.

반대로 /actuator Discovery Page를 숨겼다고 해도

/actuator/env
/actuator/heapdump
/actuator/mappings

등이 실제로 접근 가능한 상태라면 Endpoint 주소를 직접 알고 있는 요청까지 막아주는 것은 아니다.

그래서 목록을 숨기는 것보다 실제 Endpoint의 노출 범위를 제한하는 것이 더 중요하다.

5. Health도 상세 정보는 숨기자

Health Endpoint를 공개하더라도 상세 정보까지 공개할 필요가 없는 경우가 많다.

예를 들어 다음과 같은 정보가 노출된다고 생각해보자.

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP",
      "details": {
        "database": "Oracle",
        "validationQuery": "isValid()"
      }
    },
    "redis": {
      "status": "UP"
    }
  }
}

외부 Health Check의 목적이 단순히

애플리케이션이 정상인가?

를 확인하는 것이라면 이런 상세 정보까지 보여줄 필요가 없다.

따라서 운영 환경에서는 다음처럼 설정할 수 있다.

management:
  endpoint:
    health:
      show-details: never

그러면 응답을 최소한으로 유지할 수 있다.

{
  "status": "UP"
}

이 정도면 ALB나 모니터링 시스템에서 Health Check를 수행하기에도 충분한 경우가 많다.

[2026년 추가] show-details의 기본값 자체가 never다 — 즉 아무 설정도 안 해도 기본적으로는 상세 정보가 숨겨져 있다. 이 값을 명시적으로 써두는 건 “우리 팀은 의도적으로 숨긴 것”이라는 걸 코드로 남겨두는 의미가 크다.

6. 운영 환경에서 보수적으로 설정한다면

Health만 필요한 서비스라면 다음처럼 구성할 수 있다.

management:
  endpoints:
    enabled-by-default: false
    web:
      exposure:
        include: health

  endpoint:
    health:
      enabled: true
      show-details: never

각 설정을 하나씩 보면 이해하기 쉽다.

enabled-by-default: false

Actuator Endpoint를 기본적으로 활성화하지 않는다.

그리고

endpoint:
  health:
    enabled: true

필요한 Health Endpoint만 활성화한다.

마지막으로

web:
  exposure:
    include: health

Health만 HTTP를 통해 노출한다.

결과적으로 의도는 명확하다.

Actuator Endpoint 기본 비활성화
        ↓
Health만 활성화
        ↓
웹에서도 Health만 노출
        ↓
Health 상세 정보는 숨김

[2026년 추가] 위 enabled-by-default / endpoint.health.enabled는 Spring Boot 3.4부터 deprecated 되었다. Boot 3.4에서 Actuator의 접근 제어 모델 자체가 단순 on/off에서 none / read-only / unrestricted 3단계 접근 수준으로 바뀌면서, 아래 두 프로퍼티로 대체됐다.

  • management.endpoints.enabled-by-default → management.endpoints.access.default
  • management.endpoint.<id>.enabled → management.endpoint.<id>.access

같은 의도를 최신 방식으로 쓰면 다음과 같다.

management:
  endpoints:
    access:
      default: none        # 모든 엔드포인트 접근을 기본적으로 막음 (opt-in)
    web:
      exposure:
        include: health

  endpoint:
    health:
      access: read-only     # health만 읽기 접근 허용
      show-details: never

access.default: none으로 “기본 차단, 필요한 것만 opt-in” 하는 구조 자체는 동일하다. 최근 버전으로 새로 프로젝트를 시작한다면 enabled-by-default가 아니라 이 access 기반 설정을 쓰는 게 맞다. (물론 exposure.include로 HTTP 노출 자체를 제한하는 건 이 변경과 별개로 여전히 유효하다.)

7. 그럼 discovery.enabled: false도 추가해야 할까?

여기서 처음 질문으로 돌아가 보자.

management:
  endpoints:
    web:
      discovery:
        enabled: false

반드시 추가해야 하는 설정이라고 보기는 어렵다.

이미

exposure:
  include: health

로 Health만 노출하고 있고,

show-details: never

로 Health 상세 정보까지 숨겼다면 /actuator Discovery Page에서 얻을 수 있는 정보 자체가 많지 않다.

물론 보안 정책상

외부에서 Actuator Endpoint의 존재 자체를 최대한 노출하지 않는다.

라는 기준이 있다면 추가할 수 있다.

management:
  endpoints:
    access:
      default: none
    web:
      discovery:
        enabled: false
      exposure:
        include: health

  endpoint:
    health:
      access: read-only
      show-details: never

하지만 여기서 기억해야 할 것은

discovery.enabled: false

가 /actuator/health 접근 자체를 막아주는 보안 설정은 아니라는 것이다.

8. 적용 후에는 직접 확인해보자

설정을 변경했다면 실제 운영 환경과 동일한 Profile로 애플리케이션을 실행하고 직접 호출해보는 것이 가장 확실하다.

Health 확인

curl -i http://localhost:8080/actuator/health

정상적으로

{
  "status": "UP"
}

정도만 반환되는지 확인한다.

불필요한 Endpoint 확인

curl -i http://localhost:8080/actuator/env
curl -i http://localhost:8080/actuator/beans
curl -i http://localhost:8080/actuator/configprops
curl -i http://localhost:8080/actuator/mappings
curl -i http://localhost:8080/actuator/heapdump

여기서 중요한 것은 이 Endpoint들에 실제로 접근할 수 없는지 확인하는 것이다.

설정 파일만 보고

아마 안 열려 있겠지.

라고 생각하는 것보다 실제 HTTP 요청으로 확인하는 것이 좋다.

9. 정리

Actuator 보안 설정을 처음 보면 /actuator라는 경로 자체를 숨겨야 할 것처럼 느껴질 수 있다.

하지만 조금 나눠서 보면 생각보다 단순하다.

discovery
    ↓
어떤 Endpoint가 있는지 목록을 보여줄 것인가?

exposure
    ↓
어떤 Endpoint를 HTTP로 접근할 수 있게 할 것인가?

access (구 enabled)
    ↓
해당 Endpoint에 어느 수준까지 접근을 허용할 것인가?

show-details
    ↓
Health 내부 정보를 어디까지 보여줄 것인가?

따라서 Health Check만 필요한 운영 서비스라면 우선 신경 써야 할 부분은 다음과 같다. (Spring Boot 3.4+ 기준)

management:
  endpoints:
    access:
      default: none
    web:
      exposure:
        include: health

  endpoint:
    health:
      access: read-only
      show-details: never

그리고 /actuator의 Endpoint 목록까지 굳이 보여줄 필요가 없거나 사내 보안 정책에서 요구한다면 추가로

management:
  endpoints:
    web:
      discovery:
        enabled: false

를 적용하면 된다.

결국 핵심은 “Actuator를 숨겼느냐”가 아니라 “필요한 Endpoint만 실제로 열어두었느냐”다.

/actuator에서 Health Endpoint가 있다는 사실이 보이는 것보다 /env, /mappings, /heapdump 같은 불필요한 Endpoint가 실제로 열려 있는지가 훨씬 중요한 점검 포인트다.

참고자료