728x90
반응형
728x90
반응형
반응형

분산락 (Distributed Lock)

분산락은 분산 시스템에서 여러 인스턴스가 동시에 동일한 리소스를 수정하거나 접근할 때, 경쟁 조건(race condition)을 방지하기 위해 사용하는 메커니즘입니다. 주로 여러 서버에서 동시에 동일한 데이터나 자원에 접근할 때, 하나의 서버만이 리소스를 수정하거나 작업을 진행할 수 있도록 동기화(synchronization)합니다.

분산락은 시스템의 일관성을 보장하며, 여러 서비스가 동시에 동일한 작업을 수행하지 않도록 하여 데이터의 무결성을 유지합니다.

분산락 구현 방법

분산락을 구현하는 방법은 여러 가지가 있으며, 각각의 방식은 특정 상황에 맞춰 사용할 수 있습니다. 주요 분산락 구현 방법은 다음과 같습니다:

  1. 데이터베이스 기반 락 (Database Locking)
    • 비관락 쓰기락 베타락
      • 구현 방식: 데이터베이스에서 특정 레코드를 업데이트하거나 SELECT FOR UPDATE와 같은 쿼리를 사용하여 락을 걸고, 이를 통해 락을 구현합니다. 
      • 장점: 간단하게 구현할 수 있으며, 대부분의 관계형 데이터베이스가 지원합니다.
      • 단점: 성능 문제, 락 경합, 교착 상태(deadlock) 등이 발생할 수 있습니다.
    • 네임드락 (Named Lock)
      • 구현 방식: 네임드락은 일반적으로 데이터베이스가 제공하는 GET_LOCK(), RELEASE_LOCK() 등의 함수를 사용하여 이름이 지정된 락을 설정합니다. 이 락은 데이터베이스의 특정 리소스가 아니라, 지정된 이름을 가진 락을 사용하여 락을 설정합니다. 트랙젝션 단위가 아닌 세션 단위의 락
  2. Redis 기반 락 (Redis Lock)
    • 구현 방식: Redis의 SETNX (SET if Not Exists)와 EXPIRE 명령어를 사용하여 락을 구현합니다. 이 방식은 Redis 서버를 통해 분산 환경에서 빠르고 효율적으로 락을 관리할 수 있습니다.
    • 장점: 빠르고, TTL(시간 만료)을 지원하여 자동으로 락을 해제할 수 있습니다. 분산 환경에 적합합니다.
    • 단점: Redis 서버가 다운될 경우 락이 풀리지 않는 문제가 발생할 수 있습니다.
  3. Zookeeper 기반 락 (Zookeeper Lock)
    • 구현 방식: Zookeeper를 활용하여 분산 락을 구현합니다. Zookeeper의 Ephemeral Node와 Watchers 기능을 사용하여 락을 관리합니다.
    • 장점: 강력한 일관성과 고가용성을 제공합니다. 락이 해제되면 자동으로 다른 노드가 락을 획득할 수 있습니다.
    • 단점: 설정이 복잡하고, 성능 이슈가 발생할 수 있습니다.
  4. Consul 기반 락 (Consul Lock)
    • 구현 방식: Consul은 Session과 Key/Value Store를 사용하여 분산 락을 구현할 수 있습니다. 락은 세션을 기반으로 하며, 세션이 만료되면 락이 자동으로 해제됩니다.
    • 장점: Consul의 분산 시스템과 고가용성을 활용할 수 있습니다.
    • 단점: Consul을 설정하고 관리하는 복잡성이 있습니다.
  1.  

Redis 기반 분산락 구현 예시

Redis를 사용하여 분산락을 구현하는 방법을 소개합니다. Redis의 SETNX 명령어와 EXPIRE 옵션을 활용하여 락을 구현할 수 있습니다.

  1. 분산 락을 획득하고, 획득되지 않으면 예외를 던지는 방식 (Throwing)
  2. 스핀락 방식 (Spinlock)

1. Redis 분산락 구현(예외를 던지는 방식)

Redis에서 분산락을 구현할 때 사용할 수 있는 핵심 명령어는 SETNX와 EXPIRE입니다.

  • SETNX: Key가 존재하지 않으면 값을 설정하고, 존재하면 아무 작업도 하지 않습니다. 락을 구현하는 데 유용합니다.
  • EXPIRE: Key에 대한 TTL(시간 제한)을 설정하여 일정 시간 후 락을 자동으로 해제할 수 있습니다.
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisDistributedLock {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String LOCK_PREFIX = "lock:";

    public RedisDistributedLock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 락을 얻고, 성공하면 block을 실행, 실패하면 예외를 던지는 방식
    public <T> T lockAndRun(String key, long timeout, TimeUnit timeUnit, Runnable block) {
        String lockKey = LOCK_PREFIX + key;
        boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", timeout, timeUnit);

        if (lockAcquired) {
            try {
                block.run();
            } finally {
                releaseLock(lockKey);
            }
        } else {
            throw new RuntimeException("Unable to acquire lock for key: " + key);
        }

        return null;
    }

    // 락 해제
    private void releaseLock(String key) {
        redisTemplate.delete(key);
    }
}
  • setIfAbsent: Redis의 SETNX 명령어를 사용하여 락을 획득하려고 시도합니다. 락이 이미 있을 경우 false를 반환하고, 없으면 true를 반환하여 락을 획득합니다.
  • timeout과 timeUnit: 락을 획득할 수 있는 시간 제한을 설정합니다. 이 시간이 지나면 자동으로 락이 풀립니다.
  • 락을 성공적으로 획득하면 block.run()이 실행되고, 작업이 끝난 후 락을 해제합니다.
  • 락을 획득하지 못하면 예외를 던집니다.

장점:

  • 직관적: 락을 획득하지 못했을 때 바로 예외를 던지므로 호출하는 쪽에서 락 실패에 대한 처리를 명확히 할 수 있습니다.
  • 성공적인 락 획득 후 작업 실행 보장: 락을 획득한 후 작업이 실행되므로, 동시에 여러 프로세스에서 동일한 리소스를 수정하지 않게 됩니다.

단점:

  • 락 획득 실패시 예외 처리 필요: 락을 획득하지 못했을 경우 예외가 발생하므로 호출자 측에서 이를 처리해야 합니다. 예외가 자주 발생할 경우 성능에 영향을 줄 수 있습니다.
  • 락 획득 실패에 대한 대처가 복잡할 수 있음: 예외를 던지면 호출자가 반드시 예외를 처리해야 하므로, 이 부분에서 코드가 복잡해질 수 있습니다.

2. Redis 분산 락 구현 (스핀락 방식)

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisSpinlock {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String LOCK_PREFIX = "lock:";

    public RedisSpinlock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 스핀락을 이용한 락 획득 및 실행
    public <T> T spinLockAndRun(String key, long timeout, TimeUnit timeUnit, Runnable block) {
        String lockKey = LOCK_PREFIX + key;

        long start = System.currentTimeMillis();
        boolean lockAcquired = false;

        while (System.currentTimeMillis() - start < timeUnit.toMillis(timeout)) {
            lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", timeout, timeUnit);
            if (lockAcquired) {
                try {
                    block.run();
                    break;  // 성공적으로 작업을 마쳤으면 루프 종료
                } finally {
                    releaseLock(lockKey);
                }
            }

            try {
                // 락을 획득하지 못하면 일정 시간 대기 후 재시도 (스핀락)
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }

        if (!lockAcquired) {
            throw new RuntimeException("Unable to acquire lock for key: " + key);
        }

        return null;
    }

    // 락 해제
    private void releaseLock(String key) {
        redisTemplate.delete(key);
    }
}

설명:

  • 스핀락(Spinlock): 락을 획득하지 못하면 일정 시간 동안 계속해서 락을 시도하며 기다립니다. setIfAbsent로 락을 시도하고, 락을 획득하지 못하면 sleep을 이용해 일정 시간 대기한 후 다시 시도합니다.
  • timeout과 timeUnit: 락을 시도할 최대 시간을 설정하고, 락을 획득한 후 해당 시간 동안 작업을 실행합니다.
  • 락을 성공적으로 획득한 후 작업을 실행하고, 완료 후 락을 해제합니다.

장점:

  • 낮은 대기 시간: 예외를 던지지 않고 계속해서 락을 시도하므로 예외 처리보다 더 부드러운 흐름을 유지할 수 있습니다.
  • 재시도 방식: 락을 획득하지 못하면 일정 시간 대기 후 재시도하므로, 락 경합이 많을 때 유용할 수 있습니다.

단점:

  • CPU 자원 낭비: 락을 시도하며 대기하는 동안 CPU를 계속 소모하게 되어, 시스템 부하가 증가할 수 있습니다.
  • 무한 루프 문제: 락을 계속해서 시도하지만, 특정 상황에서는 결국 락을 얻지 못하고 무한 루프에 빠질 수 있습니다. 이를 해결하려면 재시도 횟수를 제한해야 합니다.
  • 성능 저하: 락이 자주 경합되면, 스핀락을 반복하면서 성능 저하가 발생할 수 있습니다.
728x90
반응형

'개발 > cache' 카테고리의 다른 글

[redis] sorted set  (1) 2024.12.16
[redis] 기초  (0) 2023.02.08
반응형

트랜잭션 아웃박스 패턴폴링 퍼블리셔 패턴분산 시스템에서 데이터베이스와 메시징 시스템 간의 메시지 전달을 보장하기 위해 자주 함께 사용되는 패턴입니다. 특히 이벤트 주도 아키텍처에서 데이터베이스 상태 변화와 해당 이벤트 발행의 일관성을 유지해 줍니다.

아웃박스 패턴(유실방지)

배경 및 필요성

분산 시스템에서는 데이터베이스에 데이터를 저장하는 트랜잭션과 외부 메시징 시스템에 이벤트를 보내는 작업이 분리되어 있기 때문에, 두 작업이 동시에 성공하거나 실패하도록 일관성 있는 처리가 어려울 수 있습니다. 특히, 트랜잭션 내에서 데이터베이스는 성공했는데 메시지 큐로의 전송은 실패하는 경우, 데이터 일관성이 깨질 수 있습니다. 이 문제를 해결하는 방법 중 하나가 아웃박스 패턴입니다.

트랜잭션 아웃박스 패턴의 주요 흐름:

  1. 데이터베이스 트랜잭션 및 아웃박스 기록 생성:
    • 애플리케이션이 데이터베이스 변경(예: 주문 생성)을 해야 할 때, 메인 테이블(예: Orders)에 데이터를 저장하는 동시에, 하나의 트랜잭션 안에서 "아웃박스" 테이블에 관련 이벤트 정보를 기록합니다.
    • 이 아웃박스 테이블에는 메시징 시스템에 발행할 이벤트와 관련된 정보가 포함되며, 아직 발행되지 않았음을 나타내는 상태 정보도 포함됩니다.
  2. 트랜잭션 커밋:
    • 모든 데이터베이스 작업(메인 테이블과 아웃박스 테이블에 저장 작업)이 하나의 트랜잭션으로 묶이기 때문에, 트랜잭션이 성공적으로 커밋되면 데이터베이스와 이벤트 기록이 일관성을 유지하게 됩니다.

아웃박스 패턴의 구조

  1. 아웃박스 테이블:
    • 서비스의 데이터베이스에 아웃박스 테이블을 둡니다. 이 테이블은 데이터베이스의 업데이트 내용과 함께 발행할 이벤트를 저장합니다. 즉, 비즈니스 로직에서 데이터베이스에 기록할 때, 메시지 큐에 보내야 할 이벤트 정보도 이 테이블에 같이 저장됩니다.
  2. 로컬 트랜잭션:
    • 데이터베이스에 데이터를 저장할 때, 같은 트랜잭션 내에서 아웃박스 테이블에도 이벤트 데이터를 함께 기록합니다. 단일 트랜잭션을 통해 데이터베이스의 데이터와 이벤트 정보를 동시에 커밋하므로, 이 과정에서 실패가 발생하면 전체 트랜잭션이 롤백됩니다.
  3. 이벤트 리스너/폴링 프로세스:
    • 별도의 폴링 서비스 또는 트리거가 아웃박스 테이블을 주기적으로 스캔하여 새로운 이벤트가 있으면 이를 메시징 시스템(예: Kafka, RabbitMQ)으로 전송합니다. 이 작업은 별도의 비동기 프로세스로 수행되며, 메시지 전송이 성공하면 해당 이벤트는 아웃박스 테이블에서 삭제되거나 상태가 업데이트됩니다. (폴링 주기에 따른 지연 있음)
  • 데이터베이스 상태와 이벤트 전송 상태의 일관성을 보장
  • 메시지 전송 실패 시에도 데이터는 손실되지 않으며, 시스템은 나중에 재시도할 수 있음
    • 유실 발생 시 배치로 재발행

폴링 퍼블리셔 패턴

폴링 퍼블리셔 패턴은 아웃박스 테이블에 저장된 이벤트를 주기적으로 조회(polling)하여 메시징 시스템에 발행하는 역할을 합니다.

폴링 퍼블리셔 패턴의 주요 흐름:

  1. 주기적인 조회:
    • 폴링 작업은 일정한 간격으로 아웃박스 테이블을 조회하여 발행되지 않은 이벤트를 찾습니다.
  2. 메시지 발행:
    • 발행되지 않은 이벤트를 찾아 메시징 시스템(예: Kafka 또는 RabbitMQ)으로 발행합니다.
    • 발행이 성공하면 해당 이벤트의 상태를 “발행 완료”로 업데이트하여, 동일한 이벤트가 다시 발행되지 않도록 합니다.

두 패턴을 결합한 이점

  • 일관성 보장: 트랜잭션 아웃박스 패턴을 통해 데이터베이스 변경과 이벤트 기록을 하나의 트랜잭션에서 처리할 수 있어 데이터와 이벤트의 일관성을 유지할 수 있습니다.
  • 내결함성: 폴링 퍼블리셔 패턴을 통해 이벤트 발행이 실패하더라도 재시도가 가능해 내결함성을 확보할 수 있습니다.
  • 확장성: 메시징 시스템을 통해 이벤트가 발행되므로, 여러 마이크로서비스가 이 이벤트를 구독하여 비동기로 처리할 수 있어 시스템 확장성에 유리합니다.

세팅 예시

  • 예시 1. mysql + kafka

1. 트랜젝션 처리 시 outbox 테이블에 이벤트 정보도 추가

@Transactional
public void createOrder(Order order) {
    // 1. Orders 테이블에 주문 정보 저장
    ordersRepository.save(order);
    
    // 2. Outbox 테이블에 이벤트 기록
    OutboxEvent event = new OutboxEvent(
        order.getId(),
        "ORDER",
        "ORDER_CREATED",
        new JSONObject().put("orderId", order.getId()).put("status", order.getStatus()).toString(),
        "PENDING"
    );
    outboxRepository.save(event);
}

2. 폴링용 스케줄 생성 -> 카프카로 이벤트 발행

@Scheduled(fixedDelay = 5000) // 5초마다 실행
public void publishEvents() {
    List<OutboxEvent> pendingEvents = outboxRepository.findByStatus("PENDING");
    
    for (OutboxEvent event : pendingEvents) {
        try {
            // 1. Kafka에 이벤트 발행
            kafkaTemplate.send("order-events", event.getPayload());
            
            // 2. 발행 성공 시, Outbox 테이블에서 상태를 'COMPLETED'로 업데이트
            event.setStatus("COMPLETED");
            outboxRepository.save(event);
            
        } catch (Exception e) {
            // 발행 실패 시 별도의 로깅 또는 재시도 처리
            logger.error("Failed to publish event: " + event.getEventId(), e);
        }
    }
}

  • 예시2. 스케줄 대신 디비 변경 감지 이용하여 이벤트 전송

MySQL Kafka 커넥터Debezium을 사용하면 트랜잭션 아웃박스 패턴을 더욱 쉽게 구현할 수 있습니다. 이 조합은 변경 데이터 캡처(Changed Data Capture, 데이터베이스에서 발생하는 삽입(INSERT), 업데이트(UPDATE), 삭제(DELETE)와 같은 변경 사항을 실시간으로 감지하고 추적하는 기술) 방식을 통해 MySQL 데이터베이스의 변화(즉, 새로운 아웃박스 레코드)를 자동으로 Kafka에 발행하는 구조를 제공합니다. 이를 통해 폴링을 위한 추가 프로세스 없이, 이벤트가 발생할 때마다 Kafka에 실시간으로 이벤트를 전송할 수 있습니다.

  • Debezium은 MySQL Kafka 커넥터 중에서도 가장 널리 사용되고 인기 있는 CDC 커넥터입니다. Debezium은 MySQL을 포함해 다양한 데이터베이스의 변경 사항을 실시간으로 감지하여 Kafka에 전송하는 강력한 기능을 제공하는 오픈소스 CDC 플랫폼입니다. Kafka와 MySQL을 연동하는 데 있어서 CDC를 필요로 할 때, Debezium이 대표적인 솔루션으로 많이 활용됩니다.
  • MySQL Kafka 커넥터는 Kafka Connect 프레임워크를 사용하여 MySQL 데이터베이스와 Apache Kafka 간에 데이터를 스트리밍하는 도구입니다. 주로 MySQL에서 발생한 데이터 변경 사항을 Kafka로 전송하는 데 사용됩니다. 이를 통해 MySQL 데이터베이스의 변경 로그를 실시간으로 Kafka로 스트리밍하고, Kafka의 여러 소비자가 이 데이터를 처리할 수 있습니다.

Debezium을 활용한 MySQL Kafka 커넥터 구현

Debezium은 CDC 플랫폼으로, MySQL의 바이너리 로그(binlog)를 읽어 Kafka로 변경 사항을 스트리밍할 수 있도록 합니다. MySQL의 binlog는 데이터베이스에 일어나는 모든 변경 사항을 기록하며, Debezium은 이를 Kafka 이벤트로 변환해 전송합니다. 

  1. 트랜잭션 아웃박스 패턴 적용: 애플리케이션에서 데이터베이스에 트랜잭션을 수행할 때, Outbox 테이블에 이벤트 정보를 함께 기록합니다.
  2. Debezium이 변경 감지 및 Kafka로 발행:
    • Debezium은 MySQL 바이너리 로그를 모니터링하고, Outbox 테이블에 새로운 행이 추가되면 해당 이벤트를 자동으로 Kafka로 발행합니다.
      • 바이너리 로그를 통한 순서 보장 및 오프셋을 활용한 발행 보장 -> 실패시 재시도로 발행 보장
    • 예를 들어, Outbox 테이블에 새로운 주문 이벤트가 추가되면 Debezium이 이를 감지해 Kafka의 dbserver1.yourDatabase.Outbox 토픽으로 해당 데이터를 스트리밍합니다.
  3. Kafka 소비자 서비스: Kafka에 연결된 소비자 서비스는 dbserver1.yourDatabase.Outbox 토픽에서 이벤트를 수신하여, 그 이벤트를 바탕으로 필요한 로직을 실행하거나 다른 서비스로 전달합니다.

장점

  • 실시간 처리: Debezium이 CDC 방식으로 아웃박스 테이블의 변경을 감지하여 바로 Kafka에 발행하므로 실시간으로 이벤트를 처리할 수 있습니다.
  • 추가 폴링 프로세스 불필요: 별도의 폴링 프로세스를 구현할 필요가 없으므로 시스템 자원을 절약할 수 있습니다.
  • 내결함성: Debezium은 Kafka로 이벤트 발행 중 문제가 발생하더라도 Kafka의 내장된 내결함성 기능 덕분에 안정적으로 이벤트를 재시도하고, 누락 없이 처리할 수 있습니다.
  • 처리량 증설 가능; 아웃박스 테이블 (이벤트 키를 기반으로) 파티셔닝을 통한 처리량 증대 가능 
    • 로그를 순서대로 읽느라 단일 커넥터 사용 -> 테이블에 쌓이는 속도가 더 많아서 slow
    • 분산처리로 속도 향상: 토픽(이벤트 키) 별로 outbox table 분리하여 분산 처리 가능토록(같은 키는 같은 테이블에 쌓이도록 -> 순서보장)

 

참고: https://youtu.be/DY3sUeGu74M?si=L4jk0qBOdTcRYHPb

728x90
반응형
반응형

2024.11.06 - [개발/sql] - 2 Phase Lock & mysql

2024.02.29 - [architecture/micro service] - [arch] EDA, event sourcing, saga, 2pc

 

2PC, 2PL 모두 알아봤는데 뭔가 미래에도 헷갈릴 것 같아서 정리...

 

1. 2PC (Two-Phase Commit)

2PC는 분산 트랜잭션 관리 방식으로, 여러 시스템에서 분산된 트랜잭션을 일관되게 관리하고 커밋/롤백을 보장하는 프로토콜

2PC는 MSA의 서로 다른 서비스 간(회원 컴포넌트 & 배송 컴포넌트), 즉 분리된 데이터의 저장에 대해서(하나의 동작이지만) 하나의 트랜젝션으로 묶지 못하기 때문에 데이터가 틀어지지 않게 하는 방법이다

2PC는 현재 잘 안 쓰이고 MSA의 경우 결국 SAGA chreography가 더 자주 쓰이는 듯. 비동기에 이벤트 기반인 게 제일 안전하다. 결국 메시지 브로커를 누가 누가 잘 쓰냐의 경쟁이랄까..

동작 방식:

  • Phase 1: Prepare Phase
    • 트랜잭션 코디네이터가 참가자들에게 트랜잭션을 커밋할 준비가 되었는지 물어봅니다 (Vote).
    • 각 참가자는 트랜잭션이 커밋 가능한지, 아니면 롤백해야 하는지를 답변합니다.
    • 모든 참가자가 "Yes"를 응답하면 커밋을 진행하고, 하나라도 "No"를 응답하면 롤백합니다.
    • ex. 주문 서비스(코디네이터)는 각 서비스(결제, 재고, 배송 등)에게 트랜잭션을 준비할 수 있는지 확인합니다. 각 서비스는 작업을 완료할 준비가 되었는지 확인하고, 준비되면 "Yes"를 응답합니다. 각 서비스는 해당 작업을 실제로 실행하지 않고, 작업을 예약해 놓습니다.
    • 트랜젝션 걸고 락 필요
  • Phase 2: Commit/Rollback Phase
    • 트랜잭션 코디네이터는 모든 참가자가 "Yes"라고 응답하면 커밋을 확정하고, 하나라도 "No"라고 응답하면 롤백합니다.
    • 트랜잭션 코디네이터는 모든 참가자들에게 최종 결과를 전달합니다.
    • ex. 모든 서비스가 "Yes" 응답을 하면, 주문 서비스는 모든 서비스에 대해 트랜잭션을 커밋하도록 지시합니다. 하나라도 "No" 응답이 있으면, 주문 서비스는 모든 서비스에 롤백을 지시합니다. 
import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;
import javax.naming.InitialContext;

public class TwoPhaseCommitExample {

    public static void main(String[] args) {
        try {
            // JNDI를 통해 트랜잭션 관리자를 조회
            InitialContext ctx = new InitialContext();
            UserTransaction utx = (UserTransaction) ctx.lookup("java:comp/UserTransaction");

            // 1단계: 트랜잭션 시작
            utx.begin();
            System.out.println("Transaction started");

            // 참여자 1: 데이터베이스 A
            DatabaseParticipant dbA = new DatabaseParticipant("DB_A");
            dbA.prepare();

            // 참여자 2: 데이터베이스 B
            DatabaseParticipant dbB = new DatabaseParticipant("DB_B");
            dbB.prepare();

            // 2단계: 커밋 요청
            dbA.commit();
            dbB.commit();
            System.out.println("Transaction committed successfully");

            // 트랜잭션 종료
            utx.commit();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

// 참여자 역할을 하는 클래스
class DatabaseParticipant {
    private String name;

    public DatabaseParticipant(String name) {
        this.name = name;
    }

    // 준비 단계
    public void prepare() {
        try {
            connection.setAutoCommit(false); // 트랜잭션 시작
            // 락 잡기
            PreparedStatement stmt = connection.prepareStatement("SELECT * FROM accounts WHERE id = ? FOR UPDATE");
            stmt.setInt(1, 1); 
            stmt.executeQuery();

            // 데이터 수정
            connection.prepareStatement("UPDATE accounts SET balance = balance - 100 WHERE id = 1").executeUpdate();

            System.out.println(name + " is prepared for the transaction");
        } catch (SQLException e) {
            e.printStackTrace();
            rollback();
        }
    }

    // 커밋 단계
    public void commit() {
        System.out.println(name + " has committed the transaction");
    }

    // 롤백 단계
    public void rollback() {
        System.out.println(name + " has rolled back the transaction");
    }
}

 

 

장점:

  • 원자성 보장: 모든 참가자가 트랜잭션을 커밋하거나 모두 롤백하여 트랜잭션의 원자성을 보장합니다.
  • 분산 트랜잭션 처리: 여러 시스템에서 하나의 트랜잭션을 관리할 수 있습니다.

단점:

  • Blocking 문제: 트랜잭션이 실패하거나 코디네이터가 실패하면, 참가자는 "결정할 수 없다"는 상태로 대기 상태에 빠질 수 있습니다. 트랜잭션 코디네이터나 참가자 서비스가 다운되면 트랜잭션이 중단될 수 있습니다.
  • 성능 저하: 2PC는 동기식으로 트랜잭션을 처리하기 때문에, 각 서비스 간의 통신과 협의 과정에서 지연이 발생할 수 있습니다.
  • 복잡성: 2PC는 구현이 복잡하며, 특히 장애 복구와 같은 시나리오에서 상태를 관리하는 데 어려움이 있을 수 있습니다.
  • 데드락: 준비 단계에서 여러 트랜잭션이 교착 상태에 빠질 수 있으므로, 트랜잭션 순서 및 타임아웃을 적절히 설정해야 합니다. prepare 했는데 commit 못하고 죽으면,, 영원한 데드락?(타임아웃 설정 필요)
  • 락 경합: 준비 단계에서 락이 너무 오래 유지되면, 다른 트랜잭션이 대기 상태가 되어 성능 저하가 발생할 수 있습니다. 이를 해결하려면 트랜잭션 범위를 최소화하고, 락 해제를 신속히 수행해야 합니다.

2. 2PL (Two-Phase Locking)

2PL는 데이터베이스의 동시성 제어 방법으로, 트랜잭션이 데이터에 대해 잠금을 설정하고 해제하는 순서를 규정하는 방식

디비(mysql) 내부에서 같은 요청이 여러번 들어왔을 때, 어떻게 잠그냐~ 하는 방법

@Transactional  Isolation과 연관하여

= 데이터 일관성과 성능 간의 균형을 맞추는 방법, 기본적으로 데이터베이스의 격리 수준을 기반으로 동작

1. READ_COMMITTED (기본)

  • 매핑된 2PL 방식: Basic 2PL
    • 트랜잭션이 진행되면서 필요한 시점에만 락을 걸고, 트랜잭션이 종료될 때 락을 해제
    • Dirty Read, Non-Repeatable Read 방지
    • Phantom Read가 발생할 수 있으며, 이 경우 Repeatable ReadSerializable 격리 수준을 사용해야 함

2. READ_UNCOMMITTED

  • 매핑된 2PL 방식: None (락을 관리하지 않음)
    • 트랜잭션 간에 락을 걸지 않으며, Dirty Read가 가능하고 동시성 제어가 거의 없음
    • 2PL 방식이 적용되지 않음

3. REPEATABLE_READ(mySql default)

  • 매핑된 2PL 방식: S2PL (Strict 2PL)
    • 트랜잭션이 데이터를 읽고 쓰는 동안 해당 데이터를 락을 걸고 트랜잭션 종료 시점에 락을 해제
    • Phantom Read, Non-Repeatable ReadDirty Read 방지

4. SERIALIZABLE

  • 매핑된 2PL 방식: SS2PL (Strong Strict 2PL)
    • 트랜잭션이 시작될 때 모든 락을 미리 걸고, 트랜잭션 종료 시점에 락을 해제
    • Phantom Read, Non-Repeatable Read, Dirty Read를 방지하고, 모든 트랜잭션 간의 충돌을 방지
    • 트랜잭션이 끝날 때까지 모든 락을 보유하므로 성능이 안 좋음

 

동작 방식:

  • Phase 1: Growing Phase
    • 트랜잭션은 필요한 잠금을 획득할 수 있으며, 이 시점에서만 데이터에 대한 잠금을 증가시킬 수 있습니다. 새로운 잠금을 얻거나 기존 잠금을 확장할 수 있습니다.
  • Phase 2: Shrinking Phase
    • 트랜잭션은 잠금을 더 이상 추가할 수 없으며, 잠금을 해제하는 시점입니다. 잠금 해제는 commit 또는 rollback 후에 이루어집니다.

장점:

  • Serializable 격리 수준 보장: 이 방식은 데이터의 일관성을 유지하면서, 트랜잭션 간의 충돌을 방지할 수 있습니다.
  • 동시성 제어: 두 개의 단계에서 데이터 잠금과 해제를 관리하여 동시성 문제를 해결합니다.

단점:

  • 교착 상태 (Deadlock): 만약 여러 트랜잭션이 서로의 잠금을 기다리게 되면 교착 상태가 발생할 수 있습니다. 이를 해결하려면 교착 상태 감지 및 회피 기법이 필요합니다.
  • 성능 저하: 잠금 관리가 복잡하여 성능에 부정적인 영향을 미칠 수 있습니다.

 

728x90
반응형
반응형

아래와 같은 로직은 serializable하지 않아 실행 순서에 따라 결과가 다르다(x=100; y=200에서 시작)

유투브: 쉬운코딩

Serializable트랜잭션 격리 수준(iso-level) 중 가장 높은 수준으로, 실행 순서에 상관없이 동일한 결과를 보장합니다.

Serializable은 트랜잭션들이 서로 겹치지 않도록 순차적으로 실행되는 것처럼 보이도록 보장합니다. 즉, 동시에 실행되는 여러 트랜잭션이 서로 간섭하지 않도록 하여, 트랜잭션이 직렬화된 것처럼 처리됩니다.

  1. 결과의 일관성 보장: 여러 트랜잭션이 동시에 실행되더라도, 실행 순서에 관계없이 동일한 결과를 보장합니다. 즉, 트랜잭션 간에 발생할 수 있는 경쟁 조건이나 읽기-쓰기에 의한 문제(예: 더티 리드, 비반영 읽기, 팬텀 리드 등)를 방지합니다.
  2. 트랜잭션 순차성: 데이터베이스는 트랜잭션들이 마치 순차적으로 실행된 것처럼 처리되도록 합니다. 이는 데이터베이스가 내부적으로 잠금 또는 스케줄링을 관리하여 발생할 수 있는 충돌을 막습니다.
  3. 동시성 감소: 여러 트랜잭션이 동시에 실행되면, 그들이 서로 잠금을 요구하거나 기다리는 상태가 발생할 수 있습니다. 이로 인해 성능 저하가 있을 수 있습니다.

 

이를 보장하기 위해선? 


2단계 잠금(2-Phase Locking, 2PL)은 데이터베이스에서 트랜잭션의 일관성과 동시성을 유지하기 위한 잠금 프로토콜입니다. 2단계 잠금 규칙을 따르면 데이터베이스의 ACID 특성을 유지하면서 다중 트랜잭션이 동시에 실행될 때도 무결성을 보장할 수 있습니다.

2단계 잠금(2-Phase Locking)의 원리

: 모든 잠금 작업이 첫 번째 잠금 해제 작업보다 반드시 먼저 이루어지는 것입니다.

  1. 확장 단계(Growing Phase):
    • 트랜잭션은 필요한 모든 잠금을 획득하는 단계입니다.
    • 잠금 해제는 허용되지 않으며 오로지 잠금 획득만 할 수 있습니다.
    • 트랜잭션이 접근하는 데이터에 대해 읽기 잠금 또는 쓰기 잠금을 설정합니다.
  2. 축소 단계(Shrinking Phase):
    • 트랜잭션이 모든 잠금을 해제하는 단계입니다.
    • 이 단계에서는 더 이상 잠금을 획득할 수 없습니다.
    • 트랜잭션이 모든 작업을 완료하고 나면, 잠금을 해제하여 다른 트랜잭션이 접근할 수 있도록 합니다.

2단계 잠금이 일관성을 보장하는 이유

2PL을 따르는 경우, 트랜잭션 간의 교착 상태(Deadlock)나 무결성 문제를 예방할 수 있습니다. 트랜잭션이 모든 잠금을 획득할 때까지 축소를 시작하지 않기 때문에, 중간에 변경되는 데이터를 읽어 일관성이 깨지는 상황을 방지할 수 있습니다.

2단계 잠금의 단점

  • 교착 상태 발생 가능성: 여러 트랜잭션이 서로의 잠금을 기다리다가 교착 상태가 발생할 수 있습니다.
  • 성능 저하: 트랜잭션이 길어질수록 잠금을 오랫동안 유지해야 하므로 다른 트랜잭션의 병렬 실행을 방해할 수 있습니다.

 

종류

  •  2PL
    • 모든 잠금 작업이 첫 번째 잠금 해제 작업보다 반드시 먼저 이루어지는 것
    • 락은 트랜잭션이 끝날 때까지 지속되는 것이 아니라, 쓰기 작업이 완료되는 시점에 해제

  • C2PL
    • 트랜잭션이 시작되기 전에 필요한 모든 잠금을 미리 획득
    • 모든 리소스를 잠근 후, 트랜잭션을 시작하고 잠금 해제는 트랜젝션 처리하자마자

  • S2PL
    • 트랜잭션 시작 시점에 모든 잠금을 미리 걸지는 않음. 대신, 잠금을 필요로 할 때마다 걸고, 획득한 모든 쓰기 잠금을 트랜잭션이 완료될 때까지 유지
    • write lock의 unlock이 커밋 이후

  • SS2PL
    • 각 데이터에 접근할 때 해당 데이터에 대한 잠금을 획득하며, 획득한 모든 잠금은 트랜잭션이 완료될 때까지 유지
    • read/write lock의 unlock이 커밋 이후

 

MySQL의 2PL 사용 방식

MySQL은 InnoDB 스토리지 엔진을 사용할 때, 트랜잭션 격리 수준에 따라 락킹을 관리합니다. InnoDB는 기본적으로 Strict 2-Phase Locking (S2PL)을 사용하며, 이는 일반적인 2PL과 달리 트랜잭션이 종료될 때까지 쓰기 잠금을 유지합니다. 이 방식은 Repeatable ReadSerializable 격리 수준에서 특히 활용됩니다.

  • Repeatable Read 격리 수준: InnoDB의 기본 격리 수준이며, InnoDB는 기본적으로 S2PL을 사용하여 트랜잭션 중에 읽은 데이터가 변경되지 않도록 보장합니다. 추가로 MySQL에서는 멀티버전 동시성 제어(MVCC)를 사용해 읽기 작업에 대한 잠금 경합을 줄입니다.
  • Serializable 격리 수준: 이 수준에서는 MySQL이 트랜잭션 충돌을 방지하기 위해 더 강한 잠금을 사용합니다. 결과적으로 트랜잭션을 직렬화된 순서로 수행하려는 경향이 있으며, SS2PL과 유사한 동작을 제공합니다.

JPA와 데이터베이스 락킹의 관계

JPA에서 특정 락킹을 요청할 때(예: @Lock(LockModeType.PESSIMISTIC_WRITE)), 이 요청은 데이터베이스로 전달되어 MySQL의 2PL 방식에 따라 적용됩니다. 결국, JPA를 통해 락 모드를 설정해도 최종적인 락킹 동작은 데이터베이스의 격리 수준과 락킹 방식에 의해 결정됩니다.

요약

  1. MySQL + JPA 조합에서는 InnoDB의 2PL 구현(S2PL)에 의해 락킹이 수행됩니다.
  2. 트랜잭션 격리 수준 설정에 따라 2PL의 엄격성이나 일관성 수준이 달라집니다.
  3. JPA는 데이터베이스에 락 모드를 요청할 수 있지만, 락킹 방식은 데이터베이스의 구현에 의존합니다.

 

2PL의 문제점

2PL은 데이터에 공유 락(읽기용) 또는 배타적 락(쓰기용)을 걸어서 트랜잭션이 안전하게 처리되도록 합니다. 하지만 몇 가지 문제점이 있습니다:

  1. 데드락 발생 가능성:
    • 여러 트랜잭션이 서로의 락을 기다리는 상태에 빠져서 교착 상태가 발생할 수 있습니다.
  2. 높은 락 대기 시간:
    • 트랜잭션 간의 상호 락으로 인해 읽기와 쓰기가 겹칠 때마다 대기 시간이 길어질 수 있으며, 대기 상태에서 성능 저하가 발생합니다.
  3. 성능 저하:
    • 특히 읽기 작업이 많은 시스템에서 성능이 크게 저하됩니다. 모든 트랜잭션이 락을 걸어야 하므로, 높은 동시성 요구를 충족하기 어렵습니다.

 

MVCC (Multi-Version Concurrency Control)다중 버전 동시성 제어라고 불리는 방식으로, 데이터베이스에서 동시성 제어를 위해 여러 데이터 버전을 관리하여 성능을 향상시키는 기법입니다. 특히 읽기 작업이 많은 환경에서 락을 걸지 않고도 동시성을 보장할 수 있도록 설계되었습니다.

MVCC의 주요 개념

  • 데이터 버전 관리: MVCC에서는 데이터베이스의 각 행(row)에 대해 여러 버전을 저장합니다. 새로운 트랜잭션이 변경을 가할 때마다, 기존 버전을 덮어쓰지 않고 새로운 버전을 생성합니다. 과거의 데이터는 그대로 유지됩니다.
  • 트랜잭션 격리: MVCC는 트랜잭션이 시작될 때의 스냅샷(시점) 기준으로 데이터를 읽도록 하여, 다른 트랜잭션이 데이터를 변경하는 중에도 해당 스냅샷 기준 데이터를 읽게 됩니다. 이를 통해 락 없이도 일관된 읽기 작업을 제공합니다.
  • 삭제 지연: 과거의 버전 데이터는 특정 조건에서 삭제되며, 데이터베이스가 자동으로 불필요한 버전을 제거하는 가비지 컬렉션 작업을 수행합니다.

MVCC의 작동 방식

  1. 트랜잭션 시작 시점의 스냅샷 사용: 트랜잭션이 시작되면 해당 시점의 데이터 스냅샷을 이용하여 데이터 조회를 수행합니다. 이렇게 하면 다른 트랜잭션에서 데이터가 변경되더라도 현재 트랜잭션은 일관성 있는 데이터를 확인할 수 있습니다.
  2. 데이터 버전 관리: 데이터에 대한 수정이 발생할 때 기존 데이터는 그대로 유지하고 새로운 버전을 생성합니다. 예를 들어, A 트랜잭션이 테이블의 특정 행을 수정하면, 원래 데이터를 덮어쓰지 않고 새로운 데이터 버전을 추가하는 방식입니다.
  3. 커밋 후 가시성: 트랜잭션이 완료되면 변경된 데이터 버전이 다른 트랜잭션에서도 보이게 됩니다. 아직 완료되지 않은 트랜잭션에서의 변경은 다른 트랜잭션에 영향을 미치지 않습니다.
  4. 가비지 컬렉션: 시간이 지나면서 더 이상 참조되지 않는 오래된 데이터 버전은 데이터베이스에서 주기적으로 삭제하여 공간을 확보합니다.

MVCC의 장점

  • 락을 사용하지 않고도 일관된 읽기를 보장하므로, 읽기 성능이 우수합니다.
  • 트랜잭션이 많아도 충돌이 적어, 데드락 발생 가능성이 줄어듭니다.
  • 동시 읽기 및 쓰기 작업을 효율적으로 처리하여 높은 동시성을 제공합니다.

MVCC의 단점

  • 모든 데이터의 버전을 유지해야 하므로, 저장 공간이 더 많이 필요할 수 있습니다.
  • 가비지 컬렉션 작업이 필요하여, 오래된 버전을 제거하는 데 추가적인 관리 비용이 발생할 수 있습니다.

적용 사례

MVCC는 PostgreSQL, MySQL의 InnoDB 엔진, Oracle 등 여러 데이터베이스 시스템에서 사용되며, 특히 트랜잭션 격리 수준을 높이면서도 성능을 유지해야 하는 환경에서 널리 활용됩니다.


출처: https://www.youtube.com/watch?v=0PScmeO3Fig

728x90
반응형

'개발 > sql' 카테고리의 다른 글

[파티셔닝] 하는법, 쓰는법  (0) 2024.11.25
비관락/낙관락 쓰기락/읽기락 베타락/공유락  (1) 2024.11.09
[분산] mysql 네임드락  (0) 2024.11.01
[p6spy] 설정 방법  (0) 2024.10.21
[mysql] delete, drop, truncate  (0) 2024.10.02
반응형

MySQL의 네임드 락(named lock)은 이름을 가진 사용자 정의 락으로, 특정 자원이나 작업의 동시 접근을 제어하기 위해 사용됩니다. 일반적인 테이블 락이나 행 락과는 달리, 네임드 락은 이름을 기준으로 동기화를 제어하며, 트랜잭션 단위 외부에서 유연하게 락을 걸고 해제할 수 있는 특징이 있습니다.

-- 'my_lock'이라는 이름으로 락을 요청하고, 10초 동안 기다립니다.
SELECT GET_LOCK('my_lock', 10);

-- 락을 얻으면 1을 반환, 대기 시간 초과로 실패 시 0을 반환합니다.

-- 락 해제
SELECT RELEASE_LOCK('my_lock');
  • GET_LOCK(name, timeout): 락을 요청합니다. name은 락 이름을 나타내고 timeout은 락을 얻기 위해 대기할 시간을 초 단위로 지정합니다.
    • 1: 락을 성공적으로 획득한 경우
    • 0: 락을 획득하지 못한 경우 (예: 이미 다른 세션에서 해당 락을 보유 중인 경우)
    • NULL: 오류가 발생한 경우 (예: 권한 문제나 기타 오류)
  • RELEASE_LOCK(name): 해당 이름의 락을 해제합니다. 락이 성공적으로 해제되면 1, 요청자가 해당 락을 보유하고 있지 않으면 0, 오류가 발생하면 NULL을 반환합니다.

네임드 락의 주요 기능

  • 임의의 이름 사용: 락 이름을 문자열로 지정하여 GET_LOCK() 함수로 락을 설정하고 RELEASE_LOCK() 함수로 해제할 수 있습니다. 이 락은 테이블이나 특정 레코드와는 관련이 없고, 단순히 이름만으로 관리됩니다.
  • 세션 기반 락: 락은 설정한 세션에서만 해제할 수 있습니다. 세션이 종료되면 해당 세션에서 설정한 모든 네임드 락도 자동으로 해제됩니다.
  • 트랜잭션 외부에서의 사용: 트랜잭션과 별도로 작동하므로, 트랜잭션 경계 외부에서도 락을 걸어 작업 동기화가 가능합니다. 네임드 락은 세션 기반으로 작동하므로 트랜잭션의 시작과 종료에 영향을 받지 않으며, 트랜잭션이 커밋되거나 롤백되더라도 유지됩니다. 이를 통해, 트랜잭션 경계에 관계없이 특정 자원에 대한 락을 설정하고 해제할 수 있습니다.

사용 예시

네임드 락_분산락은 주로 다음과 같은 상황에서 사용됩니다.

  • 단일 자원에 대한 순차적 접근: 예를 들어, 특정 자원에 동시에 접근하면 안 되는 상황에서 충돌 방지를 위해 네임드 락을 사용합니다.
  • 스케줄링 작업: 하나의 프로세스에서만 수행되어야 하는 주기적인 배치 작업에 적용하여 다른 프로세스에서 동일 작업을 수행하지 못하도록 할 때 유용합니다.

주의사항

  • 네임드 락은 세션 단위로 적용되므로, 세션이 종료되면 자동으로 해제됩니다.
  • 데이터베이스 락이므로, 자주 사용하게 되면 데이터베이스 부하가 높아질 수 있습니다.
  • 락의 TTL(Time to Live)을 별도로 지정할 수 없으므로, 락을 영구적으로 점유하지 않도록 주의가 필요합니다.

트랜잭션 내에서의 네임드 락 동작 방식

  1. 트랜잭션 내부에서 락 획득 및 해제 가능: 트랜잭션 내부에서도 GET_LOCK()을 호출해 네임드 락을 설정할 수 있습니다. 이후 작업을 진행한 뒤 트랜잭션을 커밋하거나 롤백하더라도 네임드 락은 해제되지 않습니다.
  2. 락 유지: 트랜잭션이 종료되어도 네임드 락은 세션이 유지되는 한 계속 걸려 있습니다. 따라서 트랜잭션이 커밋이나 롤백으로 끝나도 락이 해제되지 않으며, 명시적으로 RELEASE_LOCK()을 호출하거나 세션이 종료되어야 해제됩니다.
  3. 트랜잭션 종료 후 작업 가능: 네임드 락은 트랜잭션과 독립적이기 때문에, 트랜잭션 종료 후에도 세션 내에서 계속 유효한 상태로 유지됩니다. 따라서 트랜잭션이 완료된 후 다른 작업을 수행할 때도 락을 보유할 수 있습니다.
-- 트랜잭션 시작
START TRANSACTION;

-- 'resource_lock'이라는 이름의 네임드 락 설정
SELECT GET_LOCK('resource_lock', 10);

-- 트랜잭션 내 작업 수행
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;

-- 트랜잭션 커밋 또는 롤백
COMMIT;

-- 네임드 락은 여전히 유지됨
-- 락 해제
SELECT RELEASE_LOCK('resource_lock');

 

API 요청과 MySQL 세션 종료

  1. API 요청의 생명주기: 일반적으로 각 API 요청은 DB와 연결을 생성하고 작업을 수행한 후 요청이 종료되면 세션도 함께 닫히는 방식입니다. 따라서, API 호출이 완료되면 MySQL 세션이 종료되고, 세션에 종속된 리소스(네임드 락 포함)도 자동으로 해제됩니다.
  2. 네임드 락의 해제: 만약 API 요청 중 네임드 락을 설정했다면, 요청이 끝나면서 세션이 종료될 때 자동으로 락도 해제됩니다. 즉, API 요청이 완료될 때마다 새 세션이 시작되고, 기존 세션이 닫히면서 네임드 락은 해제됩니다. 이를 통해 불필요한 락이 시스템에 남아 동시성 문제를 야기하지 않도록 합니다.
  3. 커넥션 풀링의 영향: 커넥션 풀링을 사용하는 경우, API 요청이 끝나도 세션이 종료되지 않고 풀에 반환되어 재사용될 수 있습니다. 이 경우에도, MySQL 네임드 락은 요청마다 명시적으로 해제하는 것이 좋습니다. 그렇지 않으면 동일한 커넥션을 재사용하는 다른 API 요청에서 락 충돌이 발생할 수 있습니다.
public ResponseEntity<String> processTask() {
    try (Connection connection = dataSource.getConnection()) {
        // 네임드 락 획득
        Statement statement = connection.createStatement();
        statement.execute("SELECT GET_LOCK('task_lock', 10)");
        
        // 특정 작업 수행
        performTask();
        
        // 작업 완료 후 락 해제
        statement.execute("SELECT RELEASE_LOCK('task_lock')");
        
        return ResponseEntity.ok("Task completed");
    } catch (Exception e) {
        e.printStackTrace();
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Task failed");
    }
}

 

하지만 분산 락의 경우 Redis를 추천

복잡한 분산 락이 필요한 경우 Redis와 같은 외부 시스템이 더 적합한 이유는 성능, 확장성, 안정성 측면에서 MySQL보다 우수하기 때문입니다.

  1. 성능과 응답 속도:
    • Redis는 메모리 기반의 데이터 저장소로, 읽기/쓰기 속도가 매우 빠릅니다. 락 획득과 해제 시나 락 상태 확인 등의 작업이 신속하게 처리됩니다.
    • MySQL은 트랜잭션과 관계형 데이터 관리에 최적화되어 있고 디스크 기반이기 때문에, 락을 빈번하게 사용하는 경우 MySQL에는 부하가 발생할 수 있으며 성능이 저하될 가능성이 큽니다.
  2. TTL(시간 초과 설정) 지원:
    • Redis는 락에 TTL(Time To Live)을 설정할 수 있습니다. TTL은 락을 획득한 클라이언트가 비정상 종료되거나 네트워크 문제가 발생했을 때 락이 자동으로 해제될 수 있게 합니다. 이를 통해 잠금 상태가 영구적으로 유지되는 문제를 방지할 수 있습니다.
    • MySQL의 네임드 락은 TTL을 기본 제공하지 않기 때문에, 락을 획득한 세션이 종료될 때까지 락이 지속될 수 있어 영구 락 문제를 수동으로 관리해야 합니다.
  3. 분산 환경에서의 락 관리:
    • Redis는 여러 서버나 인스턴스가 동시에 락에 접근하는 분산 환경에서도 쉽게 락을 관리할 수 있습니다. Redis는 분산 락을 위한 Redlock 알고리즘을 통해 안정적인 락 제공을 지원합니다.
    • 반면 MySQL 네임드 락은 기본적으로 단일 데이터베이스 인스턴스 내에서 작동하도록 설계되어 있어 다중 인스턴스나 분산 환경에서 락을 구현하는 데는 적합하지 않습니다.
  4. 락 상태의 유연한 관리:
    • Redis는 여러 클라이언트나 시스템이 락의 상태를 쉽게 확인하고 제어할 수 있는 기능을 제공합니다. 예를 들어, Redis의 SETNX 명령어와 EXPIRE 옵션을 활용해 락의 생성과 동시에 시간 제한을 줄 수 있습니다.
    • MySQL 네임드 락은 네임드 락의 상태를 쉽게 확인하거나 관리하기 어렵고, SQL 쿼리를 통해 제한적으로 확인하는 방법만 제공됩니다.
  5. 분산 트랜잭션 요구사항:
    • Redis는 분산 트랜잭션을 지원하는 라이브러리와 결합하여 다양한 마이크로서비스 아키텍처에서 활용될 수 있으며, 락의 가용성과 일관성을 유지하는 데 최적화되어 있습니다.
    • MySQL은 주로 단일 DB 내에서 트랜잭션을 관리하도록 설계되어 있어 분산 트랜잭션을 다루기 위한 락 시스템으로는 적합하지 않습니다.

Redlock 알고리즘의 동작 원리 for redis

Redlock은 일반적으로 5개의 Redis 노드(인스턴스)를 사용하여 구성됩니다. 락을 획득하기 위해 다음 절차를 수행합니다.

  1. 동시에 모든 Redis 인스턴스에 락을 요청:
    • 클라이언트는 각 Redis 노드에 동일한 락(예: 고유한 UUID)을 설정하려고 시도합니다. 이때 SET resource_name lock_value NX PX 명령어를 사용해 락을 생성합니다.
    • NX는 락이 존재하지 않을 때만 생성하도록 하고, PX는 TTL(타임아웃)을 설정해 락이 일정 시간 후에 자동으로 만료되도록 합니다.
  2. 과반수 이상의 인스턴스에서 락을 획득해야 함:
    • 클라이언트는 5개의 노드 중 최소한 3개 이상의 노드에서 락을 획득해야 합니다.
    • 또한 모든 락이 획득될 때까지 걸린 시간이 전체 TTL의 절반 이하(보통 설정한 TTL의 2/3 이하)여야 합니다. 이는 네트워크 지연으로 인해 TTL이 끝나기 전에 락을 잃어버릴 위험을 줄이기 위함입니다.
  3. 락 획득 성공 여부 판단:
    • 과반수 이상의 노드에서 락을 지정된 TTL 내에 획득한 경우에만 락 획득에 성공한 것으로 간주하고 작업을 수행합니다.
    • 그렇지 않으면 모든 노드에서 락을 해제하고 락 획득을 다시 시도합니다.
  4. 작업 완료 후 락 해제:
    • 클라이언트는 작업을 완료한 후 모든 Redis 노드에서 락을 해제합니다.
    • 락 해제는 각 Redis 노드에서 DEL 명령을 통해 수행되며, 락 획득 시 사용한 고유 ID를 사용해 해당 락을 정확히 해제합니다.

Redlock 알고리즘의 장점

  • 안정성: 과반수 노드에서만 락을 유지하면 되므로, 일부 노드가 다운되거나 네트워크 지연이 발생해도 락을 유지할 수 있습니다.
  • 고성능: TTL을 통해 락이 자동으로 해제되므로, 클라이언트가 비정상 종료되어도 시스템에 락이 영구적으로 걸리지 않습니다.
  • 재진입 허용: 클라이언트가 동일한 락을 여러 번 획득할 수 있는 재진입을 허용하지 않음으로써 락의 일관성을 유지합니다.

Redlock 알고리즘의 사용 사례

Redlock은 특히 분산 시스템에서 여러 인스턴스나 프로세스가 동시에 접근하는 자원을 동기화할 때 사용됩니다. 예를 들어, 다중 서버 환경에서 하나의 자원을 동시에 수정하거나 처리하면 안 되는 경우나, 여러 노드에서 특정 자원에 대한 순차적 접근이 필요한 상황에서 유용하게 활용될 수 있습니다.

Redlock 알고리즘의 단점 및 주의점

일부 분산 시스템 전문가들은 Redlock이 네트워크 지연이나 Redis 서버의 TTL 동기화 문제 때문에 완벽한 일관성을 보장하지 못할 수 있다고 지적합니다. 따라서 중요한 트랜잭션 시스템에서는 Redlock을 단독으로 사용하기보다, 데이터의 일관성 요구 사항과 시스템의 특성을 고려하여 추가적인 안전 장치를 마련하는 것이 좋습니다.

과반수 락 획득?

Redlock 알고리즘에서 클라이언트가 5개의 노드 중 최소한 3개 이상의 노드에서 락을 획득해야 하는 이유는 분산 시스템의 일관성과 내결함성을 유지하기 위해서입니다. 구체적인 이유는 다음과 같습니다.

1. 과반수 규칙 (Majority Rule)

  • 5개의 노드 중 3개의 노드에서 락을 획득하면 과반수를 확보하게 되므로, 일관성을 보장할 수 있습니다.
  • 만약 클라이언트가 3개 이상의 노드에서 락을 획득하면 다른 클라이언트가 동시에 같은 이름의 락을 획득하는 상황을 피할 수 있습니다. 이로 인해, 시스템에서 같은 자원에 대해 두 개의 락이 생성되는 스플릿 브레인(split-brain) 문제를 예방할 수 있습니다.

2. 내결함성 (Fault Tolerance)

  • Redlock에서는 노드 일부가 일시적으로 다운되더라도 시스템 전체가 정상적으로 동작할 수 있도록 설계되었습니다. 5개의 노드 중 2개가 다운되더라도 여전히 3개 이상의 노드에서 락을 획득할 수 있기 때문에 락의 가용성이 보장됩니다.
  • 이로써 노드 장애나 네트워크 지연이 발생해도 여전히 락을 획득할 수 있는 가능성을 높이고, 잠금 상태의 지속성을 보장할 수 있습니다.

3. 데이터 일관성

  • 락을 획득할 때 시간이 지남에 따라 노드별 TTL(락의 만료 시간)이 다르게 적용될 수 있습니다. 하지만 과반수에서 락을 획득함으로써 여러 클라이언트가 동일 자원을 동시에 접근하는 데이터 불일치 문제가 발생할 가능성을 줄일 수 있습니다.
  • 만약 절반 미만의 노드에서만 락을 획득할 수 있다면, 이 락이 실제로 유효한지에 대한 신뢰성이 낮아져 다른 클라이언트가 중복으로 락을 획득할 가능성이 커지게 됩니다.

4. 분산 시스템에서의 합의 기반 접근

  • 분산 환경에서 대부분의 분산 알고리즘은 과반수 합의를 통해 신뢰할 수 있는 결정(일관된 상태 유지)을 도출합니다. Redlock에서도 마찬가지로, 과반수 노드에서 락을 획득해야만 해당 락을 실제로 "획득했다"는 합의를 기반으로 동작하는 방식입니다.
  • 이 접근 방식은 분산 시스템의 리더 선출이나 동기화 등의 문제에서 과반수 합의가 안전성과 일관성을 유지하는 기본 방법론임을 고려해 채택된 것입니다.

 

무슨 문제를 위해서 과반수락?

1. 중복 락 획득 (Duplicate Lock Acquisition)

  • 정의: 중복 락 획득은 여러 클라이언트가 동시에 동일한 자원에 대해 락을 획득하려고 시도하는 상황을 의미합니다. 즉, 두 개 이상의 클라이언트가 동일한 락을 획득하게 되어 자원에 대한 무결성이 깨질 수 있는 상황입니다.
  • 문제점:
    • 자원에 대한 경쟁이 발생할 수 있으며, 두 클라이언트가 서로 다른 작업을 수행할 경우 데이터의 일관성이 무너질 수 있습니다.
    • 예를 들어, 두 클라이언트가 동시에 동일한 파일에 데이터를 추가하거나 수정할 경우, 결과적으로 데이터가 손상되거나 원하지 않는 상태가 발생할 수 있습니다.
  • 해결책: Redlock 알고리즘에서 과반수의 노드에서 락을 획득해야만 락을 실제로 사용할 수 있도록 함으로써, 중복 락 획득을 방지합니다. 이렇게 하면, 동시에 여러 클라이언트가 락을 획득하려 할 때, 과반수의 노드에서 락을 획득하지 못하면 다른 클라이언트가 해당 자원에 접근하지 못하도록 차단할 수 있습니다.

2. 스플릿 브레인 문제 (Split-Brain Problem)

  • 정의: 스플릿 브레인 문제는 분산 시스템에서 네트워크 파티션이 발생하여 서로 다른 노드 그룹이 독립적으로 동작하게 되는 상황을 말합니다. 이로 인해 두 그룹이 각기 다른 결정이나 상태를 유지하게 되어 일관성이 무너지는 문제가 발생할 수 있습니다.
  • 문제점:
    • 예를 들어, 노드 A와 B가 서로 연결되어 있다가 네트워크 파티션으로 인해 C, D, E 노드와 연결이 끊어졌다고 가정해봅시다. C, D, E는 서로 독립적으로 동작하면서 락을 획득할 수 있으며, 이 과정에서 A와 B는 C, D, E의 상태를 알지 못합니다. 이로 인해 C와 D는 같은 자원에 대해 락을 획득하고 작업을 수행하게 됩니다.
    • 결과적으로, A와 B가 네트워크 연결이 복구된 후, C와 D가 작업한 내용이 서로 충돌하거나 불일치하게 될 수 있습니다.
  • 해결책: Redlock 알고리즘에서는 최소 3개 이상의 노드에서 락을 획득해야 한다고 요구합니다. 이렇게 함으로써 과반수의 노드에서 락을 획득해야만 자원을 사용할 수 있게 하여, 스플릿 브레인 상태에서도 하나의 리더를 정의하고 그에 따라 락을 적절히 관리할 수 있습니다. 네트워크 파티션이 발생하더라도, 과반수 노드의 결정에 따라 자원에 대한 접근을 조정할 수 있습니다.

 

근데 노드를 5개로 못하면?

1. 노드 수 감소에 따른 과반수 규칙

  • 과반수 필요: Redlock에서 과반수의 노드에서 락을 획득해야 한다는 규칙은 일관성과 가용성을 보장하기 위해서입니다. 노드 수가 줄어들면 과반수를 정의하는 방법도 바뀌게 됩니다.
  • 예를 들어, 5개의 노드 대신 3개의 노드를 사용할 경우, 과반수는 2개가 되므로 2개의 노드에서 락을 획득하면 락을 사용할 수 있습니다. 이 경우, 락을 획득하는 것이 더 용이해지지만, 노드 장애가 발생했을 때 시스템의 안정성이 떨어질 수 있습니다.

2. 내결함성 저하

  • 장애 상황: 노드 수가 적어지면, 일부 노드가 다운되거나 네트워크 파티션이 발생했을 때 남은 노드들이 과반수를 형성하기 어려워질 수 있습니다. 예를 들어, 3개 노드 중 1개가 다운되면, 남은 2개에서만 락을 획득할 수 있으므로, 이 경우 락이 획득되지 않을 수 있습니다.
  • 신뢰성: 더 적은 노드 수는 네트워크 오류 또는 노드 장애에 더 취약하게 만들어 전체 시스템의 신뢰성을 저하시킬 수 있습니다.

3. 데이터 일관성 문제

  • 스플릿 브레인: 노드 수가 적으면 스플릿 브레인 문제를 더욱 쉽게 겪을 수 있습니다. 예를 들어, 2개의 노드만 있을 경우, 네트워크 파티션이 발생하면 두 노드가 서로 다른 상태를 유지하게 되어, 데이터 일관성 문제가 발생할 수 있습니다.

4. 3개 이상의 노드 사용 추천

  • 3개 이상의 노드를 사용하는 것이 일반적으로 권장되며, 이를 통해 기본적인 가용성과 일관성을 확보할 수 있습니다. 3개 노드에서는 2개 이상에서 락을 획득해야 하며, 이로 인해 최소한의 내결함성과 데이터 일관성을 유지할 수 있습니다.

결론

Redlock 알고리즘은 원칙적으로 5개 노드를 사용하는 것을 권장하지만, 최소 3개 노드를 사용할 경우에도 기본적인 분산 락 기능을 수행할 수 있습니다. 그러나 노드 수가 적을수록 장애에 대한 취약성이 증가하므로, 시스템 설계 시 노드 수를 신중히 결정하는 것이 중요합니다.

728x90
반응형

'개발 > sql' 카테고리의 다른 글

비관락/낙관락 쓰기락/읽기락 베타락/공유락  (1) 2024.11.09
2 Phase Lock & mysql -> MVCC  (3) 2024.11.06
[p6spy] 설정 방법  (0) 2024.10.21
[mysql] delete, drop, truncate  (0) 2024.10.02
[mysql] basic functions  (0) 2024.09.09
반응형

P6Spy: Java 기반 애플리케이션에서 SQL 쿼리의 로깅 및 모니터링을 위한 프레임워크

P6Spy 주요 기능:

  • SQL 로깅: 애플리케이션에서 실행되는 모든 SQL 쿼리, 매개변수, 실행 시간을 기록합니다.
  • 성능 모니터링: 각 쿼리의 실행 시간을 보여주어 느린 쿼리를 식별하는 데 도움을 줍니다.
  • 동적 필터링: SELECT, UPDATE 등의 특정 쿼리 유형을 필터링하여 로그에서 제외할 수 있습니다.
  • 간편한 사용: 표준 JDBC 드라이버 대신 P6Spy 드라이버를 사용하여 SQL 쿼리를 감시하고 로깅합니다.

동작 방식:

  1. 스파이 드라이버: P6SpyDriver가 실제 JDBC 드라이버를 감싸고 모든 SQL 쿼리를 가로챕니다.
  2. 로그 기록: 쿼리를 콘솔 또는 파일로 기록하며, 로그 포맷은 커스터마이징 가능합니다.
  3. 분석: 로그를 통해 느린 쿼리를 찾아 성능을 최적화하거나, SQL 관련 문제를 디버깅할 수 있습니다.

 

implementation("p6spy:p6spy:3.9.1")
spring.datasource.url=jdbc:p6spy:mysql://10.162.5.x:3306/your_database_name
spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver

with springboot 3.2.5

################################################################################################
# P6Spy Options File                                                                           #
# See documentation for detailed instructions                                                  #
# https://p6spy.readthedocs.io/en/latest/configandusage.html#configuration-and-usage           #
################################################################################################
appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=| %(executionTime) ms | %(sql)
databaseDialectDateFormat=yyyy-MM-dd'T'HH:mm:ss
databaseDialectTimestampFormat=yyyy-MM-dd'T'HH:mm:ss

resources/spy.properties 에 위와 같은 내용 작성

import com.p6spy.engine.logging.Category;
import com.p6spy.engine.spy.P6SpyOptions;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
import jakarta.annotation.PostConstruct;
import java.util.Locale;
import org.hibernate.engine.jdbc.internal.FormatStyle;
import org.springframework.context.annotation.Configuration;

@Configuration
public class P6SpyConfig implements MessageFormattingStrategy {

  @PostConstruct
  public void setLogMessageFormat() {
    P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName());
  }

  @Override
  public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) {
    sql = formatSql(category, sql);
    return String.format("[%s] | %d ms | %s", category, elapsed, formatSql(category, sql));
  }

  private String formatSql(String category, String sql) {
    if (sql != null && !sql.trim().isEmpty() && Category.STATEMENT.getName().equals(category)) {
      String trimmedSQL = sql.trim().toLowerCase(Locale.ROOT);
      if (trimmedSQL.startsWith("create") || trimmedSQL.startsWith("alter") || trimmedSQL.startsWith("comment")) {
        sql = FormatStyle.DDL.getFormatter().format(sql);
      } else {
        sql = FormatStyle.BASIC.getFormatter().format(sql);
      }
      return sql;
    }
    return sql;
  }

}

자바로도 설정 가능

 

1. P6SpyConfig 클래스

  • 동적 설정: P6Spy의 동작을 코드 기반으로 설정하고, Spring의 컨텍스트에서 동작하는 방식입니다.
  • 장점:
    • 코드에서 직접 포맷을 제어할 수 있어 더 유연한 설정이 가능합니다.
    • Spring Boot의 DI(의존성 주입) 및 기타 설정들과 쉽게 연동됩니다.
    • 복잡한 포맷이나 특정 로깅 로직을 구현하기 더 편리합니다.
  • 단점: P6Spy의 설정을 코드에서 관리해야 하므로 설정을 변경할 때 코드 수정이 필요합니다.

2. spy.properties 파일

  • 정적 설정: P6Spy의 설정을 별도의 설정 파일로 관리하는 방식입니다.
  • 장점:
    • 코드 수정 없이 spy.properties 파일에서 간단하게 설정을 변경할 수 있습니다.
    • 개발 환경이나 운영 환경에서 설정을 쉽게 바꿀 수 있습니다.
  • 단점:
    • 포맷이 비교적 제한적입니다. 커스텀 포맷이 필요한 경우 코드보다 덜 유연합니다.
    • 복잡한 로직을 설정 파일에 구현하기 어렵습니다.

둘 다 설정해야 할까?

  • 필요에 따라 둘 다 사용할 수 있지만, 일반적으로는 둘 중 하나만 사용합니다.
  • P6SpyConfig 클래스 사용: 코드 기반 설정을 선호하거나, Spring 환경에서 동적으로 설정을 관리하고 싶다면 이 클래스를 설정합니다.
  • spy.properties 파일 사용: 간단한 설정 변경을 원하거나, 설정을 코드와 분리하여 운영 환경에서 쉽게 관리하고 싶다면 spy.properties 파일만 설정합니다.
728x90
반응형

'개발 > sql' 카테고리의 다른 글

2 Phase Lock & mysql -> MVCC  (3) 2024.11.06
[분산] mysql 네임드락  (0) 2024.11.01
[mysql] delete, drop, truncate  (0) 2024.10.02
[mysql] basic functions  (0) 2024.09.09
[mysql] collation이란  (0) 2024.06.10
반응형

mysql 8 기준, 데이터 삭제와 관련된 명령어들 정리한다.

1. DROP

  • 설명: DROP은 데이터베이스 객체 자체를 삭제하는 명령어이다. 주로 테이블, 데이터베이스, 뷰, 인덱스 등과 같은 객체를 완전히 제거하는 데 사용된다.
  • 특징:
    • 테이블 자체가 삭제
    • 테이블의 구조(Schema)와 데이터가 모두 삭제되며, 복구불가
    • 삭제된 테이블에 대한 모든 참조(인덱스, 트리거, 외래 키 등)가 함께 삭제됨
    • 롤백 불가: DROP은 트랜잭션 관리와 상관없이 즉시 반영되며, 복구불가
    • 테이블 삭제 후에 해당 테이블에 대한 쿼리를 실행할 수 없음
    • DROP권한 필요
DROP TABLE employees;
DROP DATABASE my_database;

 

2. TRUNCATE

  • 설명: TRUNCATE는 테이블의 모든 데이터를 삭제하지만, 테이블의 구조는 유지하는 명령어. 즉, 테이블은 그대로 남아있고 데이터만 삭제됨.
  • 특징:
    • 테이블의 모든 데이터가 삭제되지만, 테이블의 구조와 관련된 인덱스 및 제약 조건은 유지됨.
    • 롤백 불가: 기본적으로 TRUNCATE는 트랜잭션으로 처리되지 않으며, 실행 후 롤백할 수 없음.
    • 성능이 매우 빠름. DELETE보다 빠르게 데이터 삭제가 가능하며, 일반적으로 전체 데이터를 삭제하는 상황에서 더 효율적.
    • 테이블에서 자동 증가(AUTO_INCREMENT) 값이 초기화됨
    • 테이블에서 데이터만 삭제하므로 테이블을 계속 사용할 계획이 있을 때 사용.
    • MySQL에서 TRUNCATE는 사실상 DROP + CREATE와 유사하게 동작하므로, DROP 권한이 있어야 한다!
TRUNCATE TABLE employees;

 

3. DELETE

  • 설명: DELETE는 테이블에서 특정 조건에 맞는 데이터를 삭제하는 명령어. 특정 조건을 사용하여 테이블의 일부 또는 전체 데이터를 삭제.
  • 특징:
    • WHERE 절을 통해 특정 행을 선택적으로 삭제.
    • 트랜잭션이 가능하며, 롤백가능
    • 데이터가 삭제되더라도 테이블의 구조는 그대로 유지
    • 인덱스가 많은 테이블에서는 느릴 수 있음
    • 조건 없이 DELETE 명령어를 사용하면 테이블의 모든 데이터가 삭제되지만, 이는 TRUNCATE보다 느릴 수 있음.
    • DELETE 권한 필요
DELETE FROM employees WHERE employee_id = 1;
DELETE FROM employees; -- 모든 데이터 삭제

사용할 때 실행하고자하는 계정의 권한도 함께 확인해야한다!

728x90
반응형

'개발 > sql' 카테고리의 다른 글

[분산] mysql 네임드락  (0) 2024.11.01
[p6spy] 설정 방법  (0) 2024.10.21
[mysql] basic functions  (0) 2024.09.09
[mysql] collation이란  (0) 2024.06.10
DB isolation level  (0) 2024.05.22
반응형

환경: spring batch 5.0.3, java 17

스프링 배치의 fault tolerant에는 크게 retry와 skip이 있다.

  • skip(): ItemReader, ItemProcessor, ItemWriter 모두에서 예외 발생 시 적용할 수 있으며, 예외를 스킵하고 다음 아이템으로 넘어감
  • retry(): ItemProcessor와 ItemWriter에만 적용되며, 예외 발생 시 설정된 횟수만큼 재시도. ItemReader에서는 retry()를 사용할 수 없음
    • ItemReader의 read() 메서드는 한 번 호출될 때마다 단일 항목을 반환함. 만약 재시도하면 여러 번 호출해야 하며, 이는 ItemReader의 기본 설계 원칙에 맞지 않고 재시도는 데이터 읽기와는 별개의 책임이므로, ItemReader 내부에서 이를 처리하는 것은 설계 원칙에 어긋남

이번 글에서는 writer의 retry에 대해 집중해 본다.


에러로 인한 재시도를 하고 싶을 경우. faultTolerant()와 함께. retry()와. retryLimit() 설정을 사용하면 Spring Batch에서 Step 또는 Chunk 단위로 처리 중 발생하는 예외에 대해 재시도 처리를 할 수 있다. 

private Step getCommonStep(
    JobRepository jobRepository,
    PlatformTransactionManager transactionManager,
    String stepName,
    MyBatisCursorItemReader<GmahjongRankingRat> itemReader,
    ItemWriter<GmahjongRankingRat> itemWriter) {
  return new StepBuilder(stepName, jobRepository)
      .listener(deleteAllBeforeStep)
      .<GmahjongRankingRat, GmahjongRankingRat>chunk(CHUNK_SIZE, transactionManager)
      .reader(itemReader)
      .writer(itemWriter) ///-> 에러 발생
      .faultTolerant()
      .retry(RuntimeException.class)
      .retryLimit(2)   // -> 재시도 두번
      .listener(retryListener())
      .build();
}

 

1. .faultTolerant()

  • Fault Tolerant Step을 설정
  • Step이나 Chunk 처리 중 예외가 발생했을 때, 해당 예외를 허용하거나 재시도할 수 있도록 구성
  • retry, skip, noRollback, noRetry 등의 다양한 옵션을 적용할 수 있는 시작점

2. .retry(Class<? extends Throwable> exceptionClass)

  • 재시도 대상 예외 타입을 지정
  • 예외 타입은 특정 예외 클래스(예: RuntimeException.class) 또는 그 하위 클래스

예를 들어, .retry(RuntimeException.class)를 설정하면 RuntimeException과 그 하위 클래스에서 예외가 발생할 때마다 재시도가 이루어짐

3. .retryLimit(int retryLimit)

  • 최대 재시도 횟수를 설정
  • retryLimit 값은 예외가 발생했을 때 최대 재시도 가능 횟수를 의미(총 횟수가 아닌 재시도 횟수)
    • 예를 들어, retryLimit(2)로 설정하면 최대 2번 재시도함
  • retryLimit에 설정된 횟수는 최초 시도에는 영향을 주지 않으며, 최초 시도 후 추가로 재시도할 수 있는 횟수를 의미

4. .faultTolerant().retry(RuntimeException.class).retryLimit(2)의 의미

  • faultTolerant():
    • 예외가 발생했을 때, 예외를 처리하거나 재시도할 수 있는 내구성 있는 단계로 설정
  • 재시도 대상 예외 설정 (retry(RuntimeException.class)):
    • RuntimeException과 그 하위 클래스에서 예외가 발생했을 때 재시도하도록 설정
  • 최대 재시도 횟수 설정 (retryLimit(2)):
    • 최초 시도 외에 최대 2번의 재시도를 허용
    • 즉, 총 3번의 시도(초기 시도 1번 + 재시도 2번) <<<<<< 3일간 나를 고민에 빠트린 오늘의 주제...

재시도 테스트를 위해 3번 실행까지 에러를 발생시킨다.

  @Bean
  @StepScope
  public ItemWriter<GmahjongRankingRat> insertTotalRank(
      @Qualifier(DataSourceConfig.SESSION_FACTORY) SqlSessionFactory casualDb) {
    System.out.println(">>>>>>>>> insertTotalRank");
    return new ItemWriter<RankingRat>() {
      private final SqlSessionTemplate sqlSessionTemplate = new SqlSessionTemplate(casualDb);
      private int attempt = 0;

      @Override
      public void write(Chunk<? extends RankingRat> items) {
        attempt++;
        System.out.println("Attempt " + attempt + ": Writing items " + items);
        if (attempt < 3) { // 2번 이하로는 예외 발생
          throw new RuntimeException("Intentional error on attempt " + attempt);
        }
        for (RankingRat item : items) {
          sqlSessionTemplate.insert(
              Constant.GAME_MAPPER + "insertTotalRank", item);
        }
      }
    };
  }
  •  

retry 할 때 로그를 보기 위해 retryListener도 만들어서 달아준다.

  @Bean
  public RetryListener retryListener() {
    return new RetryListener() {
      @Override
      public <T, E extends Throwable> void onError(
          RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
        // 재시도 중 발생한 예외를 로깅
        System.err.println(
            "Retry attempt "
                + context.getRetryCount()
                + " failed with exception: "
                + throwable.getMessage());
      }
    };
  }

 

예상 시나리오

최초 시도 -> 에러1 -> 재시도1 -> 에러2 -> 재시도2 -> 에러3 -> 재시도 횟수 2번이 지나서 종료처리

그래서 에러가 3번까지 발생하고, 재시도 로그 2번 남을 것이라 기대

10:54:24.210 [main] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write:   ////최초 시도
10:54:24.221 [main] DEBUG o.s.batch.core.scope.StepScope - Creating object in scope=step, name=scopedTarget.insertTotalRank
>>>>>>>>> insertTotalRank
Attempt 1: Writing items... //최초 적재
...
10:54:24.247 [main] DEBUG o.s.b.core.step.tasklet.TaskletStep - Rollback for RuntimeException: java.lang.RuntimeException: Intentional error on attempt 1
10:54:24.247 [main] DEBUG o.s.t.support.TransactionTemplate - Initiating transaction rollback on application exception
java.lang.RuntimeException: Intentional error on attempt 1
...
10:54:24.250 [main] DEBUG c.a.i.imp.CompositeTransactionImp - rollback() done of transaction 10.78.130.172.tm172740206400000010
...
10:54:24.250 [main] DEBUG o.s.b.c.s.i.SimpleRetryExceptionHandler - Handled non-fatal exception
java.lang.RuntimeException: Intentional error on attempt 1
...
10:54:24.251 [main] DEBUG o.s.b.repeat.support.RepeatTemplate - Repeat operation about to start at count=2
...
Retry attempt 1 failed with exception: Intentional error on attempt 1


10:54:24.265 [main] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write: ////재시도 1
...
Attempt 2: Writing items... //재시도1 적재
10:54:24.282 [main] DEBUG o.s.b.core.step.tasklet.TaskletStep - Rollback for RuntimeException: java.lang.RuntimeException: Intentional error on attempt 2
10:54:24.282 [main] DEBUG o.s.t.support.TransactionTemplate - Initiating transaction rollback on application exception
java.lang.RuntimeException: Intentional error on attempt 2
...
10:54:24.282 [main] DEBUG c.a.i.imp.CompositeTransactionImp - rollback() done of transaction 10.78.130.172.tm172740206425100011
...
10:54:24.283 [main] DEBUG o.s.b.c.s.i.SimpleRetryExceptionHandler - Handled non-fatal exception
java.lang.RuntimeException: Intentional error on attempt 2
...
10:54:24.283 [main] DEBUG o.s.b.repeat.support.RepeatTemplate - Repeat operation about to start at count=3
10:54:24.288 [main] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write: ////재시도 2
...
Retry attempt 2 failed with exception: Intentional error on attempt 2


...
10:54:24.295 [main] DEBUG o.s.b.core.step.tasklet.TaskletStep - Rollback for RuntimeException: org.springframework.retry.ExhaustedRetryException: Retry exhausted after last attempt in recovery path, but exception is not skippable.
10:54:24.295 [main] DEBUG o.s.t.support.TransactionTemplate - Initiating transaction rollback on application exception
org.springframework.retry.ExhaustedRetryException: Retry exhausted after last attempt in recovery path, but exception is not skippable.
...
10:54:24.295 [main] DEBUG c.a.i.imp.CompositeTransactionImp - rollback() done of transaction 10.78.130.172.tm172740206428300012
...
10:54:24.298 [main] DEBUG o.s.b.repeat.support.RepeatTemplate - Handling fatal exception explicitly (rethrowing first of 1): org.springframework.retry.ExhaustedRetryException: Retry exhausted after last attempt in recovery path, but exception is not skippable.
10:54:24.299 [main] ERROR o.s.batch.core.step.AbstractStep - Encountered an error executing step gmahjongTotalRankingStep in job gmahjongDailyRankingJob

writer.write() 함수 두 번 호출됨. 3번째 시도를.. 하는 것 같긴 한데.. write 호출은 안 하고.. 횟수 초과로 전체 롤백을 하는 듯한 로그만 남음..

정확하게 세 번 시도를 하는 건지는 모르겠으나..(10:54:24.288과 10:54:24.295 사이에 실행 로그가 있어야 하지 않나)

"Rollback for RuntimeExceptioin", "rollback() done"의 로그가 세 번씩 남으므로 프로그램의 입장에선 에러를 세 번 감지한 것 같긴 하다..

 

참고로 rollback을 한다고 했지만 해당 chunk대한 부분만 rollback이라, beforeStep에서 한 deleteAll은 이미 적용되어 있고(테이블이 비어 있고) writer의 첫 chunk부터 에러 발생이라 결국 빈 테이블이 유지된다.

 

이 상태에서(retryLimit = 2; writer에서 첫 2번만 에러 발생)

retryLimit만 2 -> 3으로 올리면, 에러는 그대로 두 번, 총 시도는 최초 + 재시도 3번 = 4번이라 마지막에 성공해야 한다.

실행해보면, 위와 같은 에러가 두 번나고 마지막 쪽 로그가 아래처럼 바뀌면서 insert가 된다..

14:38:12 DEBUG [main] o.s.b.repeat.support.RepeatTemplate - Repeat operation about to start at count=3
14:38:12 DEBUG [main] o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write: ...
14:38:12 DEBUG [main] org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
...// insert logs

뜻대로 되긴 하는데.. 아직도 왜 마지막 시도에 대한 로그가 제대로 안 찍혔는지 모르겠다.. write을 세 번 부르는 게 아닌가?

 

테스트 코드로 확인

@Test
  void testRetryLimit() throws Exception {
    // 첫 번째, 두 번째, 세 번째 호출에서 예외 발생
    JobParameters jobParameters = getJobParameters();
    given(totalRankingReader.read()).willReturn(userRats().get(0), userRats().get(1), null);
    doNothing().doNothing().doNothing().when(deleteAllBeforeStep).beforeStep(any());
    doAnswer(
            invocation -> {
              System.out.println("First write attempt");
              throw new RuntimeException("error! 1");
            })
        .doAnswer(
            invocation -> {
              System.out.println("Second write attempt");
              throw new RuntimeException("error! 2");
            })
        .doAnswer(
            invocation -> {
              System.out.println("Third write attempt");
              throw new RuntimeException("error! 3");
            })
        .when(insertTotalRank)
        .write(any(Chunk.class));

    // Step 실행
    JobExecution jobExecution =
        jobTestUtils
            .getJobTester(GmahjongRankingJobConfig.JOB_NAME)
            .launchJob(jobTestUtils.makeJobParameters(jobParameters));

    verify(totalRankingReader, times(3)).read();
    // write 메서드가 총 3번 호출되었는지 확인
    verify(insertTotalRank, times(3)).write(any(Chunk.class));

    // Job이 실패했는지 확인 (최대 재시도 후 실패)
    assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.FAILED);
  }

retryLimit = 2 일 때 writer를 3번 부르는지 테스트 코드이다. 시도한 로그를 남기기 위해 doThrow가 아닌 doAnswer을 사용하였다.

위 테스트는 실패한다. 아래 부분에서 실제로 2번 호출했다고 검증된다.

verify(insertTotalRank, times(3)).write(any(Chunk.class));

관련 로그.. 본 로그와 크게 다르지 않다 세 번째 writer를 호출하는지 잘 모르겠는.. 로그다.

14:58:57.449 [Test worker] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write:
14:58:57.449 [Test worker] DEBUG o.s.retry.support.RetryTemplate - Retry: count=0
First write attempt
Retry attempt 1 failed with exception: error! 1
14:58:57.451 [Test worker] DEBUG o.s.retry.support.RetryTemplate - Checking for rethrow: count=1

14:58:57.459 [Test worker] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write:
14:58:57.460 [Test worker] DEBUG o.s.retry.support.RetryTemplate - Retry: count=1
Second write attempt
Retry attempt 2 failed with exception: error! 2
14:58:57.460 [Test worker] DEBUG o.s.retry.support.RetryTemplate - Checking for rethrow: count=2

14:58:57.466 [Test worker] DEBUG o.s.b.c.s.i.FaultTolerantChunkProcessor - Attempting to write:
14:58:57.466 [Test worker] DEBUG o.s.b.core.step.tasklet.TaskletStep - Rollback for RuntimeException: org.springframework.retry.ExhaustedRetryException: Retry exhausted after last attempt in recovery path, but exception is not skippable.
14:58:57.466 [Test worker] DEBUG o.s.t.support.TransactionTemplate - Initiating transaction rollback on application exception

 

파고들기..(w GPT)

1. retryLimit의 동작 원리

  • retryLimit은 최대 재시도 가능 횟수를 의미합니다. 이는 "초기 시도 횟수를 제외한" 재시도 횟수입니다.
  • 예를 들어, retryLimit = 2로 설정한 경우, 최초 시도 1번 + 재시도 2번 = 총 3번의 시도가 이루어집니다.
    • 시도 1: 최초 시도
    • 재시도 1: retryLimit에 의해 허용된 첫 번째 재시도
    • 재시도 2: retryLimit에 의해 허용된 마지막 재시도

2. 로그 분석

2.1. 첫 번째 시도 (count=0)

  • First write attempt 로그가 나타난 후 RuntimeException이 발생하여 재시도가 필요합니다.
  • retry()가 적용되어 재시도 1이 시작됩니다.

2.2. 첫 번째 재시도 (count=1)

  • Second write attempt 로그가 나타난 후 두 번째 예외가 발생합니다.
  • retry()에 의해 마지막 재시도가 필요합니다.

2.3. 두 번째 재시도 (count=2)

  • 이 시도에서도 실패하여 RetryTemplate의 ExhaustedRetryException이 발생합니다.
  • 세 번째 재시도에서 예외 발생 후, 더 이상 재시도가 이루어지지 않습니다.

3. 실제 호출과 예외 발생 횟수 차이

위 로그에서 write() 메서드가 총 3번 호출된 것을 확인할 수 있습니다. 하지만 예외가 명시적으로 두 번만 발생하는 이유는 다음과 같습니다:

  1. 첫 번째 시도와 첫 번째 재시도: 첫 번째 시도와 첫 번째 재시도에서 예외가 발생하여 retry()가 작동했습니다. 이때 retryLimit에 따라 재시도가 시도됩니다.
  2. 두 번째 재시도: 두 번째 재시도에서 예외가 발생하면 retryLimit을 모두 소진하게 됩니다. 이 시점에서 ExhaustedRetryException이 발생합니다.
  3. 재시도 실패 후 처리: ExhaustedRetryException이 발생하면 더 이상 재시도가 이루어지지 않으며, 마지막 재시도 실패 후에는 예외가 발생한 상태로 처리됩니다.

4. ExhaustedRetryException의 의미

  • ExhaustedRetryException은 설정된 재시도 한도(retryLimit)를 모두 소진한 후에도 예외가 해결되지 않았다는 의미입니다. 이 예외는 마지막 시도에서 예외가 발생했다는 것을 의미하며, 재시도가 더 이상 이루어지지 않음을 나타냅니다.

 

: 말이 애매하다. 총 3번의 시도를 하지만 writer를 부르는 시도는 아니고,, writer를 2번 부르고 재시도 횟수가 고갈되면 마지막 시도를 하기 전에 예외가 발생한다고 이해해야 할 것 같다.

 

참고로 첫 번째, 두 번째는 실패 세 번째에서 성공시키는 테스트 코드를 짜도 writer는 2번 불리고 전체 배치는 실패처리 된다.

최초 시도 -> 에러1 -> 재시도1 -> 에러2 -> 재시도2 -> 성공 -> ..전체 성공?이라고 생각하기 쉬운데..

이미 에러2에서 재시도 횟수(2)가 소비되어 에러가 발생하는 플로우다..

 @Test
  void testRetryLimit() throws Exception {
    JobParameters jobParameters = getJobParameters();
    given(totalRankingReader.read()).willReturn(userRats().get(0), userRats().get(1), null);
    doNothing().doNothing().doNothing().when(deleteAllBeforeStep).beforeStep(any());
    // 첫 번째, 두 번째 호출에서 예외 발생
	doAnswer(
            invocation -> {
              System.out.println("First write attempt");
              throw new RuntimeException("error! 1");
            })
        .doAnswer(
            invocation -> {
              System.out.println("Second write attempt");
              throw new RuntimeException("error! 2");
            })
        .doNothing()
        .when(insertTotalRank)
        .write(any(Chunk.class));

    // Step 실행
    JobExecution jobExecution =
        jobTestUtils
            .getJobTester(GmahjongRankingJobConfig.JOB_NAME)
            .launchJob(jobTestUtils.makeJobParameters(jobParameters));

    verify(totalRankingReader, times(3)).read();
    // write 메서드는 2번 호출
    verify(insertTotalRank, times(2)).write(any(Chunk.class));

    // Job이 실패했는지 확인 (최대 재시도 후 실패)
    assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.FAILED);

흐름 설명:

  1. 첫 번째 시도 (Write attempt 1):
    • write() 호출 → RuntimeException 발생 → 재시도 1로 진입.
  2. 첫 번째 재시도 (Write attempt 2):
    • write() 호출 → 다시 RuntimeException 발생 → 재시도 2로 진입.
  3. 두 번째 재시도 (Write attempt 3):
    • write() 호출 → 성공적으로 처리됨.
  4. 결과 처리:
    • 비록 세 번째 시도에서 성공했더라도, 재시도 한도인 retryLimit 2번을 모두 소진했으므로, 전체 Step이 실패로 처리됩니다.
    • ExhaustedRetryException이 발생하여, 더 이상의 재시도 없이 실패로 간주됩니다.

재시도 한도 소진과 처리 방식

  1. 재시도 한도 소진:
    • retryLimit에 따라 재시도 횟수가 소진되면 RetryTemplate은 더 이상 ItemWriter를 호출하지 않습니다.
    • 재시도 중 실패한 예외를 처리할 수 없는 상태가 되면 ExhaustedRetryException을 발생시킵니다.
  2. 최종 예외 처리:
    • 최종적으로 발생한 예외가 스킵 가능하지 않거나, 재시도 후에도 처리되지 않은 경우 전체 Step이 실패로 종료됩니다.
  3. 마지막 시도가 성공하더라도:
    • 마지막 재시도에서 성공하더라도 재시도 한도가 모두 소진된 상태에서는 더 이상 재시도 없이 Step이 실패로 처리됩니다.

이.. 말싸움 때문에 3일 정도 매진한 것 같다..ㅠㅠ

결국, 재시도 횟수 차감되는 시점이 중요.....!

 

728x90
반응형
반응형

테스트 코드 작성 시 mocking을 위한 객체를 보통은 테스트 코드 아래에 private로 선언해서 사용한다.

이게 한두 개면 그냥 쓰는데 케이스가 다양하거나 테스트 양이 많아 테스트 객체를 생성하는 것만으로도 몇백 줄이 되게 되면 점점 테스트코드가 뭐고 객체가 뭔지 가독성을 잃게 된다.

그래서 보통 객체 생성부분을 다 발라서 별도 클래스를 두고 extend 해서 쓰곤 했었다.

class TournamentServiceTest extends TournamentServiceTestArguments {
..


//
public class TournamentServiceTestArguments {
  protected static TournamentRecordRequest getExistSearchValueTournamentRecordRequest(TournamentType tournamentType) {
    return TournamentRecordRequest.builder()
        .searchType(tournamentType)
        .searchValue(TEST_TOURNAMENT_NAME)
        .startDateTime(TEST_START_DATETIME)
        .endDateTime(TEST_END_DATETIME)
        .build();
  }
  ...
  }

오늘 타 회사 기술블로그 글을 읽다가 이걸 명칭한다는게 있다는걸 알고.. 기록해본다.

나만 고민한게 아니구나..ㅋㅋ

 

Object Mother

"Object Mother"는 소프트웨어 개발, 특히 테스트 코드 작성 시 자주 사용되는 디자인 패턴 중 하나. 이 패턴은 테스트에서 사용할 복잡한 객체 인스턴스를 생성하는 방법을 제공한다. 객체를 생성하는 로직을 별도의 클래스나 메서드로 분리하여 테스트 코드의 중복을 줄이고, 객체 생성에 관련된 코드의 가독성과 유지보수성을 높이는 것이 목적이다.

주요 특징:

  1. 객체 생성의 분리:
    • 테스트 코드에서 직접 객체를 생성하지 않고, Object Mother 클래스를 통해 필요한 객체를 생성한다.
    • 테스트 코드와 객체 생성 로직을 분리하여 테스트의 가독성을 높이고, 테스트에서 필요로 하는 객체를 일관되게 생성할 수 있다.
  2. 복잡한 객체 생성:
    • 기본적으로 생성자가 복잡하거나 여러 설정이 필요한 객체를 손쉽게 생성할 수 있도록 한다.
    • 객체의 기본 설정이나 상태를 지정하여, 테스트에서 필요한 특정 상태를 가진 객체를 제공할 수 있다.
  3. 테스트의 유지보수성 증가:
    • 객체 생성 로직이 한 곳에 모여 있어, 객체 생성 로직이 변경되더라도 한 곳에서만 수정을 하면 된다.
    • 테스트 코드에서 중복된 객체 생성 로직을 제거하여, 테스트 코드의 간결함과 유지보수성을 높다.
// User 클래스 예제
public class User {
    private String name;
    private int age;
    private String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    // getter, setter 생략
}

// Object Mother 클래스
public class UserMother {
    public static User createDefaultUser() {
        return new User("John Doe", 30, "john.doe@example.com");
    }

    public static User createUserWithAge(int age) {
        return new User("John Doe", age, "john.doe@example.com");
    }

    public static User createUserWithName(String name) {
        return new User(name, 30, "john.doe@example.com");
    }

    // 다양한 생성 메서드 추가 가능
}

// 테스트 코드
public class UserTest {
    @Test
    public void testDefaultUser() {
        User user = UserMother.createDefaultUser();
        assertEquals("John Doe", user.getName());
        assertEquals(30, user.getAge());
    }

    @Test
    public void testUserWithSpecificAge() {
        User user = UserMother.createUserWithAge(25);
        assertEquals(25, user.getAge());
    }
}

장점:

  • 객체 생성 코드의 중복 제거.
  • 테스트 코드의 가독성 및 유지보수성 향상.
  • 다양한 상태를 가지는 테스트 객체를 쉽게 생성 가능.

단점:

  • Object Mother 클래스가 복잡해질 수 있음.
  • 여러 테스트에서 사용될 경우, 특정 테스트에 의존적인 설정이 들어갈 수 있음.

이 패턴은 주로 테스트 코드에서 객체 생성이 자주 필요하거나 복잡할 때 사용되며, 객체의 일관된 상태를 보장하고 중복 코드를 줄이는 데 도움을 준다.

++ 랜덤한 값을 생성하기 위한 라이브러리도 있다.

Naver에서 관리하고 있는 FixtureMonkey도 있지만 쓰기 편하고 효율적인 프로젝트로 알려진 EasyRandom도 있다.
EasyRandom은 github star 수도 가장 많았고, 이름 값 하는 프로젝트다.

EasyRandom은 굉장히 강력한데 다음과 같은 특징들이 있다.

  1. setter가 없어도 된다.
  2. contructor가 없어도 된다. (private contructor only인 경우)
  3. 자동으로 sub class들의 값도 random하게 채워준다.
  4. test object list 생성이 간단하다.

setter와 constructor가 없어도 된다는 점이 굉장히 좋아보인다! 조만간 도입 예정

728x90
반응형
반응형

OpenFeign은 Spring Cloud에서 제공하는 HTTP 클라이언트로, RESTful 서비스 간의 통신을 간편하게 처리할 수 있게 해준다.

1. OpenFeign의 기본 에러 처리 방식

OpenFeign은 기본적으로 400번대, 500번대의 HTTP 상태 코드를 에러로 간주하며, feign.FeignException을 발생시킨다.

  • 4xx (클라이언트 에러): 잘못된 요청, 인증 실패 등.
  • 5xx (서버 에러): 서버 내부 오류, 서비스 불가 등.

2. 에러 처리(httpCode != 200)의 경우

2-1. Feign Client에 ErrorDecoder 설정

ErrorDecoder의 역할

  • ErrorDecoder는 HTTP 상태 코드가 200번대가 아닐 때 호출됨
  • ErrorDecoder는 주로 4xx(클라이언트 에러)나 5xx(서버 에러)에 대한 예외 처리를 담당
  • OpenFeign은 기본적으로 ErrorDecoder.Default를 사용하여 FeignException을 던지며, 이를 커스터마이징하여 예외를 처리할 수 있음.

클래스 레벨 @FeignClient -> configuration에 커스텀 에러 디코더 달아줄 수 있음

import org.springframework.cloud.openfeign.FeignClient;

@FeignClient(name = "example-client", url = "http://example.com", configuration = FeignConfig.class)
public interface ExampleClient {

    @GetMapping("/resource")
    String getResource();
}
///////////

import org.springframework.context.annotation.Bean;

public class FeignConfig {
    @Bean
    public ErrorDecoder errorDecoder() { //////FeignException이 발생한 경우
        return new CustomErrorDecoder();
    }
}
////////////

import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.http.HttpStatus;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class CustomErrorDecoder implements ErrorDecoder {

	//에러 처리
    @Override
    public Exception decode(String methodKey, Response response) {
        String message = null;
        try {
            if (response.body() != null) {
                message = StreamUtils.copyToString(response.body().asInputStream(), StandardCharsets.UTF_8);
            }
        } catch (IOException e) {
            return new Exception("Failed to process response body", e);
        }

        HttpStatus status = HttpStatus.resolve(response.status());
        switch (status) {
            case BAD_REQUEST:
                return new BadRequestException("Bad Request: " + message);
            case NOT_FOUND:
                return new NotFoundException("Resource Not Found: " + message);
            case INTERNAL_SERVER_ERROR:
                return new InternalServerErrorException("Internal Server Error: " + message);
            default:
                return new Exception("Generic error: " + status + " " + message);
        }
    }
}

위 예제는 http status가 200이 아닐 경우이다. 200이 아닌 400, 500일 경우 Exception으로 처리되고 자동으로 저기에 걸린다.

3. 성공 처리(httpCode == 200)의 경우 

3-1. Decoder 사용해서 처리

Decoder는 OpenFeign에서 HTTP 응답을 처리할 때 사용되는 인터페이스로, 주로 성공적인 HTTP 응답(200번대)을 파싱하여 객체로 변환한다. 기본적으로 Decoder는 예외 처리와는 관계없이 모든 응답을 처리할 수 있다. 그러나 응답이 성공적이지 않은 경우, 즉 HTTP 상태 코드가 200번대가 아닐 경우에는 OpenFeign이 기본적으로 ErrorDecoder를 사용하여 예외를 던지기 때문에 Decoder가 호출되지 않는다.

만약 200인데 body에 있는 값으로 에러를 처리해야한다면 Decoder를 사용할 수 있다.

import feign.Response;
import feign.Util;
import feign.codec.Decoder;

import java.io.IOException;
import java.lang.reflect.Type;

public class CustomDecoder implements Decoder {
    
    private final Decoder defaultDecoder;
    
    public CustomDecoder(Decoder defaultDecoder) {
        this.defaultDecoder = defaultDecoder;
    }
    
    @Override
    public Object decode(Response response, Type type) throws IOException {
        // 상태 코드가 200번대일 때만 호출됨
        if (response.status() == 200) {
            // 응답 바디를 읽고 필요한 처리를 수행
            String body = Util.toString(response.body().asReader());
            System.out.println("Response Body: " + body);
            
            // 기본 디코더를 사용하여 바디를 객체로 변환
            return defaultDecoder.decode(response, type);
        }
        
        // 상태 코드가 200번대가 아닌 경우 예외를 던지거나 기본 처리를 수행
        throw new RuntimeException("Unexpected status code: " + response.status());
    }
}

Decoder와 ErrorDecoder의 비교

  • Decoder: 성공적인 HTTP 응답(200번대)에 대한 처리. 응답을 객체로 변환(파싱).
    • 상태 코드가 200이더라도 응답 바디에 따라 예외 처리가 필요하다면, Decoder에서 바디를 확인하고 사용자 정의 예외를 던질 수 있다.
  • ErrorDecoder: 4xx, 5xx 상태 코드에 대한 예외 처리. 예외를 던짐.

 

3-2. feign 의 interceptor사용

@FeignClient -> configuration에 feign의 interceptor를 사용할 수 있다.

이 때 interceptor는 모든 처리를 잡기 때문에 조건을 잘 줘야한다.

import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FeignConfig {

    @Bean
    public ResponseInterceptor responseInterceptor() {
        return new ResponseInterceptor();
    }
}
///////////////

import feign.Request;
import feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class ResponseInterceptor implements feign.ResponseInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(ResponseInterceptor.class);

    @Override
    public void apply(Response response) {
        // HTTP 상태 코드가 200인 경우만 처리
        if (response.status() == 200 && response.body() != null) {
            try {
                // 응답 바디를 읽어온다.
                String body = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
                logger.info("Response Body: {}", body);

                // 응답 바디의 특정 조건을 확인
                if (body.contains("error") || body.contains("INVALID")) {
                    // 응답 바디에 에러 메시지가 포함된 경우 예외를 발생시킴
                    throw new CustomException("Error found in response body");
                }
            } catch (IOException e) {
                logger.error("Failed to read response body", e);
                throw new RuntimeException("Failed to read response body", e);
            }
        }
    }
}

 

3-3. AOP로 처리

@FeignClient(name = "gameserver-client", url = "${external.gameserver.url}")
@ExternalGameServerAdminHeaderValid
public interface ExternalGameServerFeignService {
@Slf4j
@Component
@Aspect
public class ExternalGameServerAdminHeaderValidAspect {

  @Around(
      "@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)")
  public Object validationAdminHeader(ProceedingJoinPoint joinPoint) throws Throwable {
    Object response = joinPoint.proceed(); //메소드 실행

    if (response instanceof ExternalAdminResponseHeader) {
      ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
      String errorMessage = header.getHeader().getErrMsg();
      if (StringUtils.isNotEmpty(errorMessage)) {
        MethodSignature method = (MethodSignature) joinPoint.getSignature();
        String methodName = method.getMethod().getName();
        log.warn("invalid admin header {}, {}", methodName, errorMessage);
        throw new AException(AExceptionCode.ADMIN_SUPPORT_API_ERROR, methodName, errorMessage);
      }
    } else {
      log.warn("response is not ExternalAdminResponseHeader");
    }

    return response;
  }
}

 

클래스/메소드에 사용된 어노테이션 함수 전/후로 실행되도록 짜져 있으나 실질적으로는 후에만 실행하면 되니까 아래처럼 수정해도 될 것 같다.

@AfterReturning(
    pointcut = "@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)",
    returning = "response")
public void validationAdminHeader(JoinPoint joinPoint, Object response) {
    // 메서드가 정상적으로 실행된 후 반환된 response 객체를 이용해 로직 수행
    if (response instanceof ExternalAdminResponseHeader) {
        ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
        String errorMessage = header.getHeader().getErrMsg();
        if (StringUtils.isNotEmpty(errorMessage)) {
            MethodSignature method = (MethodSignature) joinPoint.getSignature();
            String methodName = method.getMethod().getName();
            log.warn("Invalid admin header in method: {}, error message: {}", methodName, errorMessage);
        }
    } else {
        log.warn("Response is not of type ExternalAdminResponseHeader");
    }
}
  • pointcut: 타겟 메서드를 지정하는 포인트컷 표현식을 정의. @within 및 @annotation을 사용하여 특정 어노테이션이 적용된 클래스나 메서드에 대해 어드바이스를 적용
  • returning = "response": 반환 값을 response라는 매개변수로 받아옴. 반환 값이 없거나, void인 경우에는 @AfterReturning 어드바이스가 실행되지 않음

 

1. 포인트컷 표현식의 의미

1.1. @within(com.annotation.ExternalGameServerAdminHeaderValid)

  • 의미: 클래스 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 모든 클래스의 모든 메서드를 포인트컷으로 지정
@ExternalGameServerAdminHeaderValid
public class SomeController {
    public void someMethod() {
        // 이 메서드는 @within 포인트컷에 의해 AOP 적용 대상이 됨
    }

    public void anotherMethod() {
        // 이 메서드도 @within 포인트컷에 의해 AOP 적용 대상이 됨
    }
}

1.2. @annotation(com.annotation.ExternalGameServerAdminHeaderValid)

  • 의미: 메서드 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 특정 메서드를 포인트컷으로 지정
public class AnotherController {
    
    @ExternalGameServerAdminHeaderValid
    public void specificMethod() {
        // 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 됨
    }

    public void otherMethod() {
        // 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 아님
    }
}
 

@Around와 @After, @AfterReturing, @AfterThrowing의 차이

@Around 어노테이션

  • @Around 어노테이션은 메서드 호출 전후에 특정 로직을 실행할 수 있음
  • ProceedingJoinPoint를 통해 메서드 호출 전후에 실행되는 로직을 정의할 수 있으며, joinPoint.proceed()를 호출함으로써 실제 메서드를 실행
    • joinPoint.proceed()를 호출하지 않으면 실제 메서드가 실행되지 않음
  • 메서드의 실행을 제어하거나, 실행 결과를 변환하거나, 예외 처리를 커스터마이징할 때 유용함
    • 메서드 실행 여부를 조건에 따라 결정 가능
@Around("@annotation(com.example.MyAnnotation)")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
    // 메서드 실행 전 로직
    log.info("Before method execution");

    Object result = joinPoint.proceed(); // 실제 메서드 호출

    // 메서드 실행 후 로직
    log.info("After method execution");

    return result; // 메서드의 결과 반환
}

@After 어노테이션 (= finally)

  • @After는 메서드가 정상적으로 실행되었거나 예외가 발생하더라도 항상 실행됨. 메서드의 반환 값에 접근할 수 없고, 메서드가 실행되었음을 확인하거나 리소스 정리 등의 작업을 할 때 사용.
  • @After 어노테이션은 메서드의 실행이 완료된 후에 AOP 로직을 실행함
  • joinPoint.proceed()와 같은 메서드 호출을 제어할 수 없음!!
  • 메서드의 반환 값이나 예외 처리에는 관여하지 않음. 메서드가 종료된 후 추가적인 작업(예: 리소스 정리, 로그 기록)을 수행하는 데 사용됨
    • 현재 상황에 부적합.. 무조건 실행되나 return되는 객체를 잡을 수 없다.

@AfterReturning 어노테이션

  • 메서드가 성공적으로 완료된 후에만 실행. 예외가 발생하지 않은 경우에만 호출!
  • @AfterReturning은 메서드가 성공적으로 반환된 후에만 실행됨. 반환된 값에 접근할 수 있으며, 이를 기반으로 후처리 로직을 작성할 수 있다.
  • 타겟 메서드의 반환 값에 접근할 수 있음

@AfterThrowing 어노테이션

  • @AfterThrowing: 메서드에서 예외가 발생했을 때만 실
  • 타겟 메서드가 던진 예외에 접근할 수 있음
  • 예외 발생 시, 예외를 기반으로 추가 로직이 필요한 경우
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ComprehensiveLoggingAspect {

    @AfterReturning(pointcut = "execution(* com.example.service.MyService.*(..))", returning = "result")
    public void logAfterReturning(Object result) {
        System.out.println("Method returned successfully with value: " + result);
    }

    @AfterThrowing(pointcut = "execution(* com.example.service.MyService.*(..))", throwing = "ex")
    public void logAfterThrowing(Exception ex) {
        System.out.println("Method threw an exception: " + ex.getMessage());
    }
}
  • pointcut: 어떤 메서드에 대해 어드바이스를 적용할지 지정합니다. 이 예제에서는 MyService 클래스의 모든 메서드에 적용됩니다.
  • returning: 타겟 메서드의 반환 값을 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 타겟 메서드의 반환 값에 접근할 수 있습니다.
  • throwing: 타겟 메서드가 던진 예외를 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 발생한 예외에 접근할 수 있습니다.

참고


위의 AOP 함수는 아래의 Decoder로 변환 가능하다.

@Slf4j
@RequiredArgsConstructor
public class FeignDecoder implements Decoder {

  private final Decoder defaultDecoder;

  @Override
  public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
    String responseBody = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
    ExternalAdminResponseHeader header = JacksonUtils.jsonToObject(responseBody, ExternalAdminResponseHeader.class);
    String errorMessage = header.getHeader().getErrMsg();
    if (StringUtils.isNotEmpty(errorMessage)) {
      log.warn("invalid admin header {}", errorMessage);
      throw new AException(AapokerExceptionCode.ADMIN_SUPPORT_API_ERROR, errorMessage);
    }
    return defaultDecoder.decode(response, type);
  }
}

다만 이 경우 기존에 에러가 발생한 함수를 알 수 없다..

feign은 성공했다고 남기에 에러로그가 없어 어떤 함수에서 호출한 응답인지 확인이 어려워 결국 AOP를 적용하였다.

728x90
반응형

+ Recent posts