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

프로세스 또는 스레드 간의 동기화공유 자원 관리를 위해 사용하는 동기화 도구

원리인가, 구현인가?

  1. 원리 측면:
    • 뮤텍스와 세마포어는 동시성 제어 문제를 해결하기 위한 개념적인 원리
    • 프로세스 간 공유 자원 접근 문제(Critical Section Problem)를 해결하기 위해 설계
    • "P/V 연산", "락/언락" 등의 수학적 원리에 기반
  2. 구현 측면:
    • 운영 체제는 뮤텍스와 세마포어를 시스템 콜로 구현하여 동기화를 제공
    • 프로그래밍 언어에서는 이를 감싸는 고급 추상화 클래스/메서드로 구현

 

뮤텍스; 단일 스레드/프로세스가 임계 구역을 독점적으로 보호

특징

  • 초기값이 0인 상태에서 스레드가 자원을 점유하면 값을 0 -> 1로 변경.
  • 뮤텍스는 한 번에 하나의 스레드만 자원을 점유할 수 있도록 제한하는 도구
  • 뮤텍스는 자원의 소유권 개념이 있어, 락을 획득한 스레드만 언락이 가능
  • Java의 ReentrantLock

뮤텍스 초기값 0의 의미

  • 0:
    • 뮤텍스가 열려 있는 상태(사용 가능)
    • 어떤 스레드도 해당 뮤텍스를 점유하고 있지 않은 상태

뮤텍스의 동작 과정

  1. 락(Lock):
    • 스레드가 뮤텍스를 점유하려고 하면, 뮤텍스 값을 1로 변경
    • 동시에 다른 스레드가 뮤텍스를 점유하려고 하면 대기 상태로 전환
  2. 언락(Unlock):
    • 스레드가 작업을 완료하고 뮤텍스를 해제하면, 뮤텍스 값을 0으로 
    • 대기 중인 다른 스레드가 뮤텍스를 점유할 수 있도록 허용
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MutexExample {
    private final Lock lock = new ReentrantLock();

    public void criticalSection() {
        lock.lock(); // 임계 구역 접근
        try {
            System.out.println(Thread.currentThread().getName() + " is in critical section.");
        } finally {
            lock.unlock(); // 접근 해제
        }
    }
/////
    public static void main(String[] args) {
        MutexExample example = new MutexExample();

        Runnable task = example::criticalSection;

        // 스레드 2개 실행
        new Thread(task).start();
        new Thread(task).start();
    }
}

 

  • 첫 번째 스레드가 실행되어 lock.lock()으로 락 획득
    • 임계 구역에 진입해 메시지를 출력
    • lock.unlock()로 락 해제
  • 두 번째 스레드는 첫 번째 스레드가 락을 해제하기 전까지 대기
    • 첫 번째 스레드가 락을 해제하면, 두 번째 스레드가 락을 획득하고 임계 구역에 진입

 

  • 임계 구역 보호:
    • ReentrantLock을 사용하여 임계 구역에 동시에 하나의 스레드만 접근하도록 보장
  • 스레드 간 동기화:
    • 두 스레드는 순차적으로 criticalSection 메서드에 접근하며, 동시에 실행되지 않음
  • 락 해제 보장:
    • try-finally 구조를 통해 락이 반드시 해제되도록 보장하여 데드락을 방지

 

 

 

세마포어; 여러 스레드/프로세스가 제한된 자원을 공유

특징

세마포어는 정수 값(카운터)을 기반으로 동작하며, 공유 자원에 대한 접근을 제어하는 데 사용; Java의 Semaphore

 

  • 정수 값 기반 제어:
    • 세마포어는 정수 값을 사용하여 현재 공유 자원에 접근할 수 있는 허용 가능한 스레드 또는 프로세스의 수를 나타냄
    • 이 값은 초기화된 이후, 특정 연산을 통해 증가하거나 감소
  • P 연산 (wait 또는 acquire):
    • 세마포어 값을 감소
    • 값이 0보다 작아질 경우, 현재 프로세스나 스레드는 대기 상태에 들어가고, 다른 스레드가 세마포어 값을 증가시킬 때까지 블록
  • V 연산 (signal 또는 release):
    • 세마포어 값을 증가
    • 대기 중인 스레드가 있으면 이를 깨워서 실행

세마포어의 초기값

세마포어의 초기값은 관리할 자원의 개수를 나타냄

  • 초기값 1: 바이너리 세마포어(Binaray Semaphore)처럼 동작하여 뮤텍스와 비슷한 역할
  • 초기값 N (N > 1): 자원을 N개까지 동시 허용하는 카운팅 세마포어(Counting Semaphore) 역할
  • 세마포어는 다음과 같은 연산으로 동작한다:
    • P(Wait):
      • 자원의 개수를 감소시킴.
      • 값이 0 이하가 되면 스레드는 자원이 해제될 때까지 대기.
    • V(Signal):
      • 자원의 개수를 증가시킴.
      • 대기 중인 스레드가 있다면 해제된 자원을 할당받아 실행을 재개.

세마포어의 값과 자원의 상태

  1. 값 > 0:
    • 자원이 하나 이상 사용 가능한 상태
    • 대기 중인 스레드가 즉시 자원에 접근 가능.
  2. 값 = 0:
    • 모든 자원이 이미 사용 중
    • 자원을 사용하려는 스레드는 대기 상태로 전환
  3. 값 < 0 (특정 구현에서 허용):
    • 대기 중인 스레드의 수를 나타낼 수 있
    • 하지만 일반적인 세마포어 구현에서는 음수값을 사용하지 않고 **대기열(queue)**로 관리

 

세마포어의 용도

  1. 임계 구역(Critical Section) 보호:
    • 공유 자원에 대한 동시 접근을 제어하여 데이터 무결성을 보장합니다.
  2. 스레드 동기화:
    • 여러 스레드가 특정 순서대로 작업을 수행하도록 제어합니다.
  3. 제한된 자원 관리:
    • 예를 들어, 네트워크 연결, 데이터베이스 연결과 같이 제한된 수의 자원을 사용하는 경우, 세마포어를 사용하여 접근 수를 제한할 수 있습니다.

 

  • 데이터베이스 커넥션 풀 관리
  • 생산자-소비자 문제
  • 네트워크 리소스 관리 (동시에 처리 가능한 연결 제한)
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    private final Semaphore semaphore = new Semaphore(2); // 동시에 2개 허용

    public void accessResource() {
        try {
            semaphore.acquire(); // P 연산
            System.out.println(Thread.currentThread().getName() + " accessing resource.");
            Thread.sleep(1000); // 작업 수행
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            semaphore.release(); // V 연산
            System.out.println(Thread.currentThread().getName() + " released resource.");
        }
    }
////
    public static void main(String[] args) {
        SemaphoreExample example = new SemaphoreExample();

        Runnable task = example::accessResource;

        for (int i = 0; i < 5; i++) {
            new Thread(task).start(); // 스레드 5개 실행
        }
    }
}
/////////
Thread-0 accessing resource.
Thread-1 accessing resource.
Thread-0 released resource.
Thread-2 accessing resource.
Thread-1 released resource.
Thread-3 accessing resource.
Thread-2 released resource.
Thread-4 accessing resource.
Thread-3 released resource.
Thread-4 released resource.
  • 초기 상태에서 Semaphore의 카운터는 2
  • 5개의 스레드가 실행되며, 다음과 같이 동작
  1. Thread-0Thread-1이 먼저 자원을 획득하여 작업을 수행
  2. 다른 스레드(Thread-2, Thread-3, Thread-4)는 자원이 반환될 때까지 대기
  3. 작업이 끝난 스레드가 자원을 반환(release())하면, 대기 중인 스레드 중 하나가 자원을 획득
  4. 이 과정이 반복되며, 모든 스레드가 차례로 작업을 완료

 

 

뮤텍스와 바이너리 세마포어는 같은 것? NO

  • 뮤텍스는 락을 가진 자만 해제 가능, 세마포어는 그렇지 않다.
  • 뮤텍스는 priority inheritance 속성을 가지나 세마포어는 그렇지 않다(누가 시그널을 날릴지 모름)
    • 우선순위 높은 작업이 락에 의해 블라킹되면 그 블라킹 작업의 우선순위를 높여 우선순위작업이 빨리 처리되게끔 하는 것

 

상호배제(단일 자원에 대한 독점적 접근 보장)만 필요하다면 뮤텍스를, 작업 간의 실행순서 동기화가 필요하면 세마포어 사용

언제 뮤텍스를 사용할까?

  • 자원에 대한 단일 접근만 보장하면 되는 경우.
  • 예:
    • 공유 변수나 데이터 구조 보호.
    • 파일 쓰기 작업.

언제 세마포어를 사용할까?

  • 작업 간 실행 순서를 동기화해야 하는 경우.
  • 여러 스레드가 동시에 제한된 자원(예: DB 연결, 네트워크 포트)에 접근할 때.
  • 예:
    • 생산자-소비자 문제.
    • 연결 풀 관리(최대 N개 동시 연결).
      • 디비풀; 커넥션풀
728x90
반응형
반응형

환경: springboot3.3

 

1. Repository 사용

  • Repository는 Spring Data JPA의 가장 기본 인터페이스로, 기본적인 CRUD 메서드를 제공하지 않는다.
  • 따라서 save(), findById(), delete()와 같은 기본적인 CRUD 기능을 사용하려면 CrudRepository 또는 JpaRepository를 확장해야 한다.
  • Repository는 보통 커스텀 리포지토리 인터페이스에서 사용되며, 실제 데이터베이스 관련 작업은 별도로 구현해야 한다.
public interface RatingRepository extends Repository<Rating, String> {

  Optional<Rating> findByMemberId(String memberId);
}

 

하지만 위와 같이 작성가능하다!?

Repository 인터페이스 자체는 Spring Data JPA에서 기본적으로 제공하는 CRUD 기능을 직접적으로 제공하지 않지만, Spring Data JPA가 제공하는 쿼리 메서드 이름 규칙을 사용하면 findBy와 같은 쿼리 메서드를 Repository 인터페이스에서도 사용할 수 있다. 즉, Repository는 기본적으로 CRUD 메서드를 제공하지 않지만, Spring Data JPA는 메서드 이름 기반의 쿼리 생성 기능을 지원한다.

왜 findBy 메서드가 동작할까?

Spring Data JPA의 인터페이스 상속 구조

  • Repository는 Spring Data JPA의 최상위 마커 인터페이스
    • 실제로 인터페이스 열면 아무 함수도 없음
  • CrudRepository와 JpaRepository는 이 Repository를 상속받아 확장된 기능을 제공한다.
  • findBy 메서드는 실제로 Repository에 구현된 것이 아니라, Spring Data JPA가 런타임에 자동으로 구현해 주는 기능

어떻게?

  • Spring Data JPA는 @EnableJpaRepositories를 통해 등록된 리포지토리를 스캔
  • 리포지토리가 Repository를 상속받으면 Spring은 이를 프록시 객체로 생성하고, 쿼리 메서드(findByXxx)를 자동으로 구현
  • 따라서 JpaRepository나 CrudRepository가 아니라도 Repository를 상속하면 동작한다.

 

2. CrudRepository

CrudRepository는 기본적인 CRUD 메서드를 제공

  • save(): 엔티티 저장 또는 업데이트
  • findById(): ID로 엔티티 조회
  • delete(): 엔티티 삭제
  • deleteById(): ID로 엔티티 삭제
  • count(): 엔티티 개수 조회
  • existsById(): 엔티티 존재 여부 확인

페이징 및 정렬 기능은 제공하지 않으며, 추가 기능이 필요하다면 JpaRepository로 확장해야 합니다.

 

3. JpaRepository

  • CrudRepository에 추가적으로 JPA에 특화된 기능인 페이징, 정렬, 배치 저장 등을 제공.
  • 또한, findAll(Pageable pageable)이나 saveAll()과 같은 메서드를 제공하여 더 고급 기능을 사용할 수 있다.

  • 기본 CRUD만 필요한 경우:
    • CrudRepository 또는 PagingAndSortingRepository를 사용.
  • JPA 관련 기능 사용 필요:
    • JpaRepository를 사용.
  • 커스터마이징된 최소 구현이 필요한 경우:
    • Repository를 상속하여 직접 메서드를 정의.

 

728x90
반응형
반응형

환경: springboot3

 

@Transactional에서 속성을 명시하지 않으면 Spring의 기본값이 적용된다. 알고 써야 한다는!!

1. transactionManager의 기본값

@EnableJpaRepositories로 각 데이터베이스마다 별도의 리포지토리를 설정했더라도, @Transactional을 명시적으로 특정 데이터베이스와 연결하지 않으면 기본 설정된 데이터베이스가 사용된다..! 즉, @Primary로 설정된 빈이 사용됨. 멀티 데이터베이스를 쓰는 프로젝트에서 아무 생각 없이 사용하는 경우 의미 없는 트랜젝션이 설정될 수 있어 조심해야 한다.

2. timeout의 기본값

  • 기본값: -1 (무제한)
  • 트랜잭션이 실행되는 데 시간이 얼마나 걸리든 제한을 두지 않음
  • 데이터베이스에 설정된 타임아웃 값이 있을 경우, 그 값이 적용될 수 있음
  • 명시적으로 설정할 경우, 초 단위로 지정

3. rollbackFor의 기본값

  • 기본값: RuntimeException 및 Error
  • 기본적으로 트랜잭션은 RuntimeException(unchecked exception)이나 Error가 발생했을 때 롤백됨
  • CheckedException(예: SQLException)은 기본적으로 롤백 대상이 아님!!
  • rollbackFor = Throwable.class로 설정하면 모든 예외(Checked와 Unchecked 포함)가 발생 시 트랜잭션이 롤백

4. readOnly

  • 기본값: false
  • 읽기 전용 트랜잭션으로 설정되지 않음
  • 데이터 변경 작업이 가능하며, 최적화를 위해 읽기 전용 작업에서는 readOnly = true를 설정하기

5. propagation

  • 기본값: Propagation.REQUIRED
  • 기존 트랜잭션이 있으면 해당 트랜잭션을 사용하고, 없으면 새로운 트랜잭션을 생성함

6. isolation

  • 기본값: Isolation.DEFAULT
  • 데이터베이스의 기본 격리 수준이 적용됨

커스텀 @Transactional 만들기

매번 트랜젝션 설정을 달아야 하는 게 번거로워서 전용 어노테이션을 만든다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(transactionManager = TransactionConstants.TRANSACTION_MANAGER, timeout = 10, rollbackFor = Throwable.class)
public @interface WriteTransactional {

}

1. @Target

@Target은 애노테이션이 적용될 수 있는 대상(타깃)을 지정

  • ElementType.METHOD: 메서드에 적용 가능
  • ElementType.TYPE: 클래스, 인터페이스, 열거형(enum)에 적용 가능
  • ElementType.FIELD: 필드(멤버 변수)에 적용 가능
  • ElementType.PARAMETER: 메서드 매개변수에 적용 가능
  • ElementType.CONSTRUCTOR: 생성자에 적용 가능
  • ElementType.LOCAL_VARIABLE: 지역 변수에 적용 가능
  • ElementType.ANNOTATION_TYPE: 다른 애노테이션에 적용 가능
  • ElementType.PACKAGE: 패키지에 적용 가능
  • ElementType.TYPE_PARAMETER (Java 8 이상): 제네릭 타입 매개변수에 적용 가능.
  • ElementType.TYPE_USE (Java 8 이상): 모든 타입 선언에 적용 가능

2. @Retention

@Retention은 애노테이션이 얼마나 오래 유지되는지 지정

  • RetentionPolicy.SOURCE:
    • 소스 코드에서만 유지되고, 컴파일 시 제거
    • 코드 문서화나 컴파일러 경고용으로 사용.
    • 예: @Override
  • RetentionPolicy.CLASS:
    • 컴파일된 .class 파일에 포함되지만, 런타임에는 JVM에 의해 로드되지 않음
    • 기본값(Default).
  • RetentionPolicy.RUNTIME:
    • 런타임에도 JVM에 의해 유지
    • 리플렉션(Reflection)으로 접근 가능
    • 예: @Autowired, @RequestMapping.
728x90
반응형
반응형

약간 올드한 방식이지만 디비로 분산락을 구현한 사례가 있어서 정리한다.

 

락을 거는 테이블이 있어야 하고, 락을 거는 역할을 하는 서비스가 있어야 한다

  public void runSynchronousTask(TaskKey taskKey, Runnable task) {
    startSynchronousTask(taskKey); //디비에 키 저장

    try {
      task.run(); //비즈니스 로직 실행
    } finally {
      endSynchronousTask(taskKey); //디비에 키 삭제
    }
  }
  
    private void startSynchronousTask(TaskKey taskKey) {
    try {
      insertTaskKey(taskKey);

    } catch (Exception e) {
      if (selectTaskKeyCount(taskKey) > ZERO) {
        throw BadukTaskException.FAIL_START_SYNCHRONOUS_TASK;
      }

      if (updateTaskKey(taskKey) != SUCCESS) {
        throw BadukTaskException.FAIL_START_SYNCHRONOUS_TASK;
      }
    }
  }
  
    private void endSynchronousTask(TaskKey taskKey) {
    try {
      deleteTaskKey(taskKey);
    } catch (Exception e) {
      deleteTaskKey(taskKey);
    }
  }

 

사용처에서 아래와 같이 사용하면 된다. (자바6기준..)

 public ApiResponse giveEventMoney(final EventMoneyApiRequest eventMoneyApiRequest) {
    runSynchronousTask(
       generateLockKey(),  // lock key
        new Runnable() {
          @Override
          public void run() {
            int resultCode = giveMoney();
            if (resultCode != SUCCESS_CODE) {
              throw new ApiException(ApiErrorCode.FAIL_MONEY_PAYMENT);
            }
          }
        });

    return ApiResponse.OK();
  }

1. DB 자체 락 구현

DB를 사용해 특정 키를 기반으로 락을 관리하고 동시 실행을 방지하는 방식.

장점

  1. 데이터와 락 관리의 일원화:
    • 락이 저장되는 위치와 애플리케이션 데이터가 동일한 데이터베이스에 존재하므로 별도의 외부 시스템 없이 처리 가능.
    • DB 트랜잭션과 자연스럽게 통합되어 데이터 일관성을 유지하기 쉬움.
  2. 분산 환경 지원:
    • 여러 인스턴스에서 동일한 DB를 바라보므로 클러스터 환경에서도 동기화를 보장.
  3. 의존성 최소화:
    • 별도의 락 관리 시스템(Redis, ZooKeeper 등)을 도입할 필요 없이 DB만으로 해결 가능.

단점

  1. 성능/확장성 제한:
    • 락 관리(INSERT/DELETE)를 위해 DB I/O가 발생하므로 빈번한 락 작업이 성능에 영향을 줄 수 있음.
    • 대량 트래픽 시 DB 부하를 초래할 가능성.
  2. 잠금 해제 누락 위험:
    • 애플리케이션 장애 또는 네트워크 문제로 인해 finally 블록이 실행되지 않으면 락이 DB에 남아 다른 프로세스가 진행되지 못할 수 있음.
  3. 락의 유효 시간 관리 필요:
    • 만약 비즈니스 로직이 예상보다 오래 걸리거나 문제가 발생해 락이 해제되지 않으면, 추가적인 재처리 로직이 필요함.

2. 디비 네임드 락

장점

  1. 성능:
    • 데이터베이스 내부에서 처리되므로 빠르고 효율적.
  2. 자동 해제:
    • 세션 종료 시 락이 자동으로 해제되므로, 애플리케이션 장애로 락이 남아있는 문제를 방지.
  3. 간단한 사용:
    • 복잡한 로직 없이 락 획득/해제를 호출만으로 구현 가능.
  4. 분산 환경 지원:
    • 동일한 데이터베이스를 사용하는 여러 애플리케이션 인스턴스 간의 락 동기화 가능.

단점

  1. 트랜잭션 제약:
    • 네임드 락은 일반적으로 트랜잭션 경계와는 독립적으로 동작하므로, 데이터와 락의 일관성을 따로 관리해야 함.
  2. 락 관리 제한:
    • 락의 TTL이나 재시도 로직 등을 세밀하게 제어하기 어려움.
  3. 데드락 위험:
    • 잘못된 사용으로 인해 데드락 발생 가능.

추가적인 내용 참고

2024.11.01 - [개발/sql] - [분산] mysql 네임드락

728x90
반응형
반응형

 

JPA에서 EntityManager를 타지 않고 데이터베이스에 직접적으로 영향을 주는 쿼리는 주로 영속성 컨텍스트를 우회하여 실행된다. 이런 쿼리들은 영속성 컨텍스트를 거치지 않으므로 JPA EntityListeners, Hibernate EventListener, Hibernate Interceptor 등이 호출되지 않으니 주의해야 한다.  

이런 작업들은 JPA가 아닌 네이티브 SQL 쿼리처럼 작동하며 영속성 컨텍스트를 사용하지 않아 성능 면에서 유리하지만, JPA의 1차 캐시자동 변경 감지 기능은 무시되므로 사용 시 데이터 일관성 문제에 유의해야한다..!

 

1. deleteInBatch

  • JpaRepository에서 제공하는 deleteInBatch는 엔티티를 영속성 컨텍스트에 로드하지 않고 데이터베이스에서 직접 삭제 쿼리를 실행함
  • JPQL 기반 단일 DELETE 쿼리 실행.
  • 직접 쿼리 실행: deleteInBatch는 JPQL을 통해 바로 데이터베이스에서 삭제를 수행하며, 삭제된 엔티티들을 영속성 컨텍스트에서 관리하지 않는다. 따라서 캐시된 상태와 데이터베이스 상태 간의 불일치가 발생할 수 있음.
  • 엔티티 상태 무시: 삭제할 엔티티들이 반드시 영속성 컨텍스트에 존재하지 않아도 삭제 작업이 가능.
    • 엔티티 리스트에 포함된 엔티티가 영속성 컨텍스트에 있더라도, 자동으로 flush 되지 않는다!!
  • 개별 엔티티를 삭제할 때 delete() 메서드를 호출하면 각 엔티티별로 DELETE 쿼리가 발생하지만, deleteInBatch는 단일 DELETE 쿼리를 실행하여 성능을 최적화함.
    • deleteInBatch는 WHERE id IN (...) 형태의 SQL DELETE 쿼리를 생성하고 실행

주의사항

  1. 영속성 컨텍스트: 삭제 작업 후 영속성 컨텍스트와 데이터베이스 간 불일치가 발생할 수 있으므로, 사용 전에 컨텍스트를 비우거나 동기화를 고려
  2. 트랜잭션 필요: deleteInBatch는 반드시 트랜잭션 내에서 호출해야 합니다. 그렇지 않으면 TransactionRequiredException이 발생
  3. 연관 관계: 삭제 대상 엔티티가 다른 엔티티와 연관 관계가 있는 경우, 외래 키 제약 조건으로 인해 예외가 발생할 수 있으므로 관계 설정을 명확히 해야
List<MyEntity> entitiesToDelete = myEntityRepository.findAllByStatus("INACTIVE");
myEntityRepository.deleteInBatch(entitiesToDelete);

2. update / delete JPQL 쿼리

  • JPQL의 update와 delete는 EntityManager를 통하지 않고 데이터베이스에 직접 영향을 준다
  • 영속성 컨텍스트를 거치지 않기 때문에 영속 상태의 엔티티는 업데이트되지 않음
    • 이 방식으로 삭제된 엔티티는 영속성 컨텍스트와 동기화되지 않아 영속성 컨텍스트에 같은 엔티티가 존재한다면 불일치가 발생할 수 있음
@Modifying
@Query("UPDATE MyEntity e SET e.status = :status WHERE e.id = :id")
void updateStatusById(@Param("id") Long id, @Param("status") String status);

@Modifying
@Query("DELETE FROM MyEntity e WHERE e.status = :status")
void deleteByStatus(@Param("status") String status);

3. @Query + 네이티브 쿼리

  • 네이티브 쿼리를 사용하면 영속성 컨텍스트를 무시하고 데이터베이스에 직접 접근
  • @Query에서 nativeQuery = true를 지정하면 네이티브 SQL 쿼리가 실행
@Modifying
@Query(value = "DELETE FROM my_table WHERE status = :status", nativeQuery = true)
void deleteByStatusNative(@Param("status") String status);

4. JdbcTemplate

  • JdbcTemplate은 JPA를 통하지 않고 순수 SQL을 사용해 데이터베이스에 직접 접근한다
  • 영속성 컨텍스트를 전혀 사용하지 않으므로 성능적으로 빠르지만, 엔티티 매핑과는 무관하게 작동함
@Autowired
private JdbcTemplate jdbcTemplate;

public void deleteInactiveUsers() {
    String sql = "DELETE FROM users WHERE status = ?";
    jdbcTemplate.update(sql, "INACTIVE");
}

5. EntityManager.createNativeQuery

  • JPA의 EntityManager를 통해 네이티브 SQL 쿼리를 직접 실행할 수 있는데 이는 영속성 컨텍스트를 거치지 않는다.
@Transactional
public void deleteByCustomQuery(EntityManager entityManager, String status) {
    String sql = "DELETE FROM my_table WHERE status = :status";
    entityManager.createNativeQuery(sql)
                 .setParameter("status", status)
                 .executeUpdate();
}

6. 외부 툴 사용

데이터 처리 작업을 완전히 애플리케이션 외부로 이동하면 엔티티 리스너 및 이벤트 리스너가 작동하지 않음

  • 데이터베이스의 Stored Procedure.

 

EntityManager를 타지 않는 쿼리와 영속성 컨텍스트를 함께 사용하면?

EntityManager를 타지 않는 쿼리(예: 네이티브 SQL, deleteInBatch, JPQL의 UPDATE/DELETE 등)와 영속성 컨텍스트를 함께 사용하는 경우, 데이터베이스 상태와 영속성 컨텍스트 간의 불일치 문제가 발생할 수 있다. JPA의 영속성 컨텍스트는 1차 캐시에 엔티티의 상태를 유지하고, 데이터 변경이 있을 경우 이를 동기화하여 데이터의 일관성을 보장한다. 하지만, 영속성 컨텍스트를 우회하는 작업은 이 동기화를 방해한다.

1. 주요 문제: 데이터 불일치

시나리오

  • 영속성 컨텍스트에 특정 엔티티가 로드된 상태(EntityManager의 1차 캐시).
  • 데이터베이스에서 직접적으로 데이터를 수정하거나 삭제.
  • 영속성 컨텍스트는 이러한 변경 사항을 인지하지 못함.

결과

  • 이후 EntityManager를 통해 조회 시, 여전히 변경 전 상태가 반환될 수 있음.
  • 캐시와 DB 간 데이터 불일치 문제가 발생.

2. 해결 방법

(1) 영속성 컨텍스트 초기화

영속성 컨텍스트를 명시적으로 초기화하거나 새로 고침하면 DB 변경 사항을 반영할 수 있음

  • EntityManager.clear(): 모든 영속성 컨텍스트 초기화(detached).
  • EntityManager.refresh(entity): 특정 엔티티(하나)를 데이터베이스 상태로 새로 고침.
    • 만약 영속 상태가 아닌 엔티티(Detached)에 대해 호출하면 예외 발생: 
    • javax.persistence.EntityNotFoundException 또는 IllegalArgumentException
@Transactional
public void processEntitiesSafely() {
    List<MyEntity> entities = myEntityRepository.findAll();

    myEntityRepository.deleteAllInBatch();

    // 영속성 컨텍스트 초기화
    entityManager.clear();
    // entityManager.refresh(entity); 사용 시 단건 처리만 가능하가에 for loop 필요

    // 이후 상태 확인 시 영속성 컨텍스트에 데이터 없음
    System.out.println("Entities after clear: " + myEntityRepository.findAll());
}

(2) 영속성 컨텍스트를 사용하지 않는 작업에서는 캐시 의존 금지

영속성 컨텍스트를 무시하는 작업을 수행한 후에는, 동일한 EntityManager를 통해 엔티티 상태를 확인하거나 사용하지 않아야

(3) 트랜잭션 분리

영속성 컨텍스트가 불일치를 일으킬 여지를 없애기 위해, 트랜잭션을 분리하거나 비영속 작업만 포함된 별도의 서비스 레이어를 작성

@Transactional
public void performOperation() {
    // JPQL DELETE 작업을 별도 메서드에서 수행
    bulkDeleteInSeparateTransaction();

    // 이후 영속성 컨텍스트를 사용하는 작업
    MyEntity entity = entityManager.find(MyEntity.class, 1L);
    System.out.println(entity.getStatus());
}

// 별도 트랜잭션
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void bulkDeleteInSeparateTransaction() {
    myEntityRepository.bulkDeleteByStatus("INACTIVE");
}

(4) Spring Data JPA + @Modifying 쿼리 사용

Spring Data JPA에서 @Modifying을 사용하여 JPQL 또는 네이티브 SQL로 데이터베이스를 직접 변경하면 영속성 컨텍스트를 자동으로 초기화하도록 설정할 수 있다.

  • clearAutomatically = true 속성을 추가하면 JPQL 실행 후 자동으로 영속성 컨텍스트가 초기화됨
@Modifying(clearAutomatically = true)
@Query("UPDATE MyEntity e SET e.status = :status WHERE e.someField = :value")
void bulkUpdateStatus(@Param("status") String status, @Param("value") String value);

clear? flush? refresh?

영속성 컨텍스트

  • 영속성 컨텍스트는 애플리케이션의 메모리에서 엔티티를 관리하는 공간
  • 문제: 영속성 컨텍스트의 상태와 데이터베이스 상태가 불일치할 경우, 이후 작업에서 잘못된 결과를 초래할 수 있음

flush(영속성 컨텍스트 -> 디비)

  • 역할:
    • 영속성 컨텍스트의 변경 사항(insert, update, delete 등)을 즉시 데이터베이스에 반영
    • 영속성 컨텍스트는 그대로 유지되며, 동기화된 최신 상태를 유지; 데이터베이스에만 영향
    • 커밋을 한 건 아니라서 롤백 가능
  • 적합한 상황:
    • 영속성 컨텍스트 상태를 유지하면서 현재 상태를 데이터베이스와 동기화해야 할 때.
    • flush 후에도 엔티티를 계속 사용할 필요가 있을 때.
  • 자동 플러시:
    • JPQL 쿼리 실행 전.
    • 트랜잭션 커밋 직전.

 

clear

  • 역할:
    • 영속성 컨텍스트를 완전히 초기화
    • 초기화 후, 이전에 관리되던 엔티티는 비영속 상태(Detached)
  • 적합한 상황:
    • 영속성 컨텍스트의 상태와 데이터베이스 상태 간의 불일치를 제거하려고 영속성 컨텍스트를 완전히 비워야 할 때.
    • 메모리 사용량을 줄이기 위해 대량 처리 후 컨텍스트를 초기화하려는 경우.

 

flush와 clear를 함께 사용하는 경우

  • 대량 작업을 처리하거나, 영속성 컨텍스트를 유지할 필요가 없는 경우
  • 일반적인 패턴:
    1. flush()로 변경 사항을 데이터베이스에 반영.
    2. clear()로 영속성 컨텍스트 초기화.

 

refresh(디비 -> 영속성 컨텍스트)

  • 역할
    • 데이터베이스의 최신 상태로 영속성 컨텍스트의 엔티티를 다시 로드
    • 영속성 컨텍스트에 있는 엔티티의 상태를 무시하고, 데이터베이스의 값을 가져와 동기화
  • 적합한 상황:
    • 영속성 컨텍스트에만 영향을 미침:
      • 데이터베이스의 최신 상태로 영속성 컨텍스트의 엔티티를 갱신
      • 변경되지 않은 데이터는 그대로 유지
    • 비영속(detached) 상태의 엔티티에 대해 호출하면 예외(IllegalArgumentException) 발생
    • 프록시를 초기화하거나 변경된 상태를 되돌릴 때

 

728x90
반응형
반응형

환경: springboot3.3

 

mybatis에서 프로시져는 아래와 같이 사용한다. 디비에 대고 쓰듯 편하게 사용 가능했다..

<parameterMap id="basicMoneyModel" class="com.money.model.BasicMoneyModel">
    <parameter property="code" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="memberId" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="cip" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="eventMoney" jdbcType="BIGINT" javaType="java.lang.Long" mode="IN"/>
    <parameter property="orderNo" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="nlevel" jdbcType="INTEGER" javaType="java.lang.Integer" mode="IN"/>
    <parameter property="defaultMoney" jdbcType="BIGINT" javaType="java.lang.Long" mode="IN"/>
    <parameter property="gameratTable" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="gamebaseTable" jdbcType="VARCHAR" javaType="java.lang.String" mode="IN"/>
    <parameter property="outval" jdbcType="INTEGER" javaType="java.lang.Integer" mode="OUT"/>
</parameterMap>


<procedure id="updateMoneyBasic" parameterMap="basicMoneyModel">
    {call UPDATE_MONEY_BASIC(?,?,?,?,?,?,?,?,?,?)}
</procedure>

 

이걸 JPA로 옮긴다면?

1. entity manager + query

import javax.persistence.EntityManager;
import javax.persistence.Query;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

@Repository
public class GameMoneyRepository {

    @Autowired
    private EntityManager entityManager;

    public void updateGameMoneyBasic(BasicMoneyModel basicMoneyModel) {
        // 네이티브 쿼리로 저장 프로시저 호출
        String sql = "CALL UPDATE_MONEY_BASIC(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
        
        // 네이티브 쿼리 설정
        Query query = entityManager.createNativeQuery(sql);
        
        // 파라미터 설정
        query.setParameter(1, basicMoneyModel.getCode());
        query.setParameter(2, basicMoneyModel.getMemberId());
        query.setParameter(3, basicMoneyModel.getCip());
        query.setParameter(4, basicMoneyModel.getEventMoney());
        query.setParameter(5, basicMoneyModel.getOrderNo());
        query.setParameter(6, basicMoneyModel.getNlevel());
        query.setParameter(7, basicMoneyModel.getDefaultMoney());
        query.setParameter(8, basicMoneyModel.getGamerateTable());
        query.setParameter(9, basicMoneyModel.getGamebaseTable());
        
        // OUT 파라미터 처리
        query.executeUpdate();
        
        // 프로시저 실행 후 OUT 파라미터 처리 (OUT 파라미터를 직접 처리하려면 ResultSet을 통해 가져와야 합니다)
        Integer outval = (Integer) query.getResultList().get(0);
        basicMoneyModel.setOutval(outval);
    }
}

2. entity manager + spquery

import javax.persistence.StoredProcedureQuery;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;

@Repository
public class GameMoneyRepository {

    @Autowired
    private EntityManager entityManager;

    public void updateGameMoneyBasic(BasicMoneyModel basicMoneyModel) {
        StoredProcedureQuery query = entityManager.createStoredProcedureQuery("UPDATE_MONEY_BASIC");

        // 프로시저 파라미터 설정
        query.registerStoredProcedureParameter(1, String.class, ParameterMode.IN);  // code
        query.registerStoredProcedureParameter(2, String.class, ParameterMode.IN);  // memberId
        query.registerStoredProcedureParameter(3, String.class, ParameterMode.IN);  // cip
        query.registerStoredProcedureParameter(4, Long.class, ParameterMode.IN);    // eventMoney
        query.registerStoredProcedureParameter(5, String.class, ParameterMode.IN);  // orderNo
        query.registerStoredProcedureParameter(6, Integer.class, ParameterMode.IN); // nlevel
        query.registerStoredProcedureParameter(7, Long.class, ParameterMode.IN);    // defaultMoney
        query.registerStoredProcedureParameter(8, String.class, ParameterMode.IN);  // gameratTable
        query.registerStoredProcedureParameter(9, String.class, ParameterMode.IN);  // gamebaseTable
        query.registerStoredProcedureParameter(10, Integer.class, ParameterMode.OUT); // outval

        // 파라미터 값 설정
        query.setParameter(1, basicMoneyModel.getCode());
        query.setParameter(2, basicMoneyModel.getMemberId());
        query.setParameter(3, basicMoneyModel.getCip());
        query.setParameter(4, basicMoneyModel.getEventMoney());
        query.setParameter(5, basicMoneyModel.getOrderNo());
        query.setParameter(6, basicMoneyModel.getNlevel());
        query.setParameter(7, basicMoneyModel.getDefaultMoney());
        query.setParameter(8, basicMoneyModel.getGamerateTable());
        query.setParameter(9, basicMoneyModel.getGamebaseTable());

        // 프로시저 호출
        query.execute();

        // OUT 파라미터 결과 가져오기
        Integer outval = (Integer) query.getOutputParameterValue(10);
        basicMoneyModel.setOutval(outval);
    }
}

3. @Query, nativequery=true

@Query(value = "CALL UPDATE_MONEY_BASIC(:code, :memberId, :cip, :eventMoney, :orderNo, :nlevel, :defaultMoney, :gamerateTable, :gamebaseTable, :outval)", nativeQuery = true)
Integer updateGameMoneyBasic(@Param("code") String code,
                              @Param("memberId") String memberId,
                              @Param("cip") String cip,
                              @Param("eventMoney") Long eventMoney,
                              @Param("orderNo") String orderNo,
                              @Param("nlevel") Integer nlevel,
                              @Param("defaultMoney") Long defaultMoney,
                              @Param("gamerateTable") String gameratTable,
                              @Param("gamebaseTable") String gamebaseTable,
                              @Param("outval") Integer outval);

4. @Procedure 사용; outVal을 함수 리턴값으로 받기

@Procedure(name = "PC_UPDATE_GAMEMONEY_BASIC")
Integer updateGameMoneyBasic(String code, 
                             String memberId, 
                             String cip, 
                             Long eventMoney, 
                             String orderNo, 
                             Integer nlevel, 
                             Long defaultMoney, 
                             String gameratTable, 
                             String gamebaseTable);

5. @Procedure 사용; outVal을 파라미터 값 안에 세팅해서 받기

@Procedure(name = "PC_UPDATE_GAMEMONEY_BASIC")
void updateGameMoneyBasic(String code, 
                          String memberId, 
                          String cip, 
                          Long eventMoney, 
                          String orderNo, 
                          Integer nlevel, 
                          Long defaultMoney, 
                          String gameratTable, 
                          String gamebaseTable, 
                          @Param("outval") Integer outval);

 

성공 여부를 반환값으로 검증하고자 4번 방식으로 작업하였다.

 

Persistence Context프로시저 호출 사이에서 발생할 수 있는 순서 문제로 인한 오류 주의

 

@Transactional이 적용된 서비스에서 JPA는 영속성 컨텍스트를 관리하고, 이를 기반으로 엔티티의 상태 변화를 추적한다. 하지만 저장 프로시저커스텀 SQL 실행에 대한 관리는 하지 않는다. 저장 프로시저는 데이터베이스에서 실행되는 독립적인 SQL 작업으로, JPA 트랜잭션과는 별개로 실행된다. 예를 들어, EntityManager를 사용해서 저장 프로시저를 호출할 수 있지만, 저장 프로시저 자체는 JPA의 트랜잭션과는 독립적으로 DB에서 실행된다.

 

따라서 저장 프로시저를 호출하면, 트랜잭션이 시작되지만, 프로시저의 실행은 JPA 트랜잭션의 외부에서 실행된다. 예를 들어, JDBC로 직접 프로시저를 호출하거나 @Procedure 어노테이션을 사용하여 저장 프로시저를 호출할 수 있지만, 이 호출 자체는 JPA의 트랜잭션 관리 범위에 포함되지 않아 별도의 트랜잭션으로 처리된다.

따라서 아래와 같은 현상이 발생할 수 있다.

 

  1. A 테이블 select: 데이터가 존재하는지 확인하려는 첫 번째 쿼리 실행.
  2. B 테이블 select: 두 번째 쿼리로 데이터를 확인.
  3. C Procedure 실행: 내부적으로 데이터가 없을 경우, A insert
  4. A 테이블 insert: A 테이블에 데이터 삽입 시 PK 충돌 오류 발생.
  5. B 테이블 insert: B 테이블에 데이터 삽입.

 

이 문제는 Persistence Context(즉, Hibernate의 세션 캐시)가 아직 업데이트되지 않은 상태에서 저장 프로시저가 호출되기 때문에 발생한다. Persistence Context에서 수정된 데이터는 DB에 즉시 반영되지 않으며, flush를 호출하지 않으면 데이터베이스에 반영되지 않은 상태에서 프로시저가 실행된다. 그 결과, 프로시저 내에서 insert 쿼리가 실행되기 전에, PK 충돌이나 예상치 못한 동작이 발생할 수 있다.

 

flush()를 호출하면, Persistence Context에 있는 변경 사항을 DB에 반영하여 쿼리 실행 전에 데이터가 반영되도록 할 수 있다. 이렇게 하면 데이터베이스에서 처리되는 순서를 제어할 수 있고, PK 충돌 같은 오류를 예방할 수 있다.

  1. A 테이블 select: 데이터가 존재하는지 확인하려는 첫 번째 쿼리 실행.
  2. B 테이블 select: 두 번째 쿼리로 데이터를 확인.
  3. EntityManager.flush() 호출 → Persistence Context의 변경 사항을 DB에 반영
  4. C Procedure 실행: 이제 DB에 반영된 데이터를 기반으로 처리
  5. A 테이블 insert: A 테이블에 데이터 삽입 시 (이제 PK 충돌이 발생하지 않음)
  6. B 테이블 insert: B 테이블에 데이터 삽입.

결국 아래와 같은 코드가 되는데..

 @Transactional
  public void createGrade(CreateGradeCommand command) {
    //A
    gameRateRepository.save(gameRate);
    gameRateRepository.flush();
    //B
    webRateRepository.save(webRate);
    webRateRepository.flush();
    //procedure	
    changeMoneyService.change(changeMoneyCommand);
  }

참고로 flush를 해도 디비에 커밋을 한 것은 아니다! 즉, 롤백 가능

 

  • flush()는 Persistence Context에 있는 변경 사항을 DB에 즉시 동기화하는 메서드이다. flush()를 호출하면 Hibernate가 세션 캐시에 저장된 데이터를 DB에 동기화하지만, 트랜잭션은 여전히 커밋되지 않는다.
  • 이 경우 flush()는 save() 메서드가 엔티티를 저장한 뒤, 해당 변경 사항을 DB와 동기화하기 위해 호출된다. 다만, flush() 호출 이후에는 트랜잭션 커밋 전까지 DB 상태가 변경될 수 있으며, 만약 트랜잭션 내에서 에러가 발생하면 모든 변경 사항은 롤백된다.
    • 즉 changeMoneyService.change()에서 예외가 발생하면, gameRateRepository.save()와 webRateRepository.save()에서의 변경 사항도 롤백된다.

 

728x90
반응형
반응형

환경: springboot3, java17

오늘의 시행착오..

아래와 같은 컨트롤러 코드가 있다.

@PostConstructor로 map을 채워야 한다.

public class EventRewardController {

  private final List<EventMoneyUseCase> eventMoneyUseCases;

  private EnumMap<GameType, EventMoneyUseCase> eventMoneyMap;

  @PostConstruct
  public void setEventMoneyMap() {
    eventMoneyMap = eventMoneyUseCases.stream().filter(useCase -> useCase.getGameType() != null) // null 키 방지
        .collect(Collectors.toMap(EventMoneyUseCase::getGameType, Function.identity(), (existing, replacement) -> existing,
            () -> new EnumMap<>(GameType.class)));
  }

 

 

@WebMvcTest는 Spring Boot에서 Web Layer 테스트를 위한 어노테이션이다.

주로 컨트롤러와 관련된 테스트를 작성할 때 사용된다. 이 어노테이션은 Spring MVC를 사용하여 HTTP 요청과 응답을 테스트할 수 있도록 해준다. @WebMvcTest는 웹 계층의 컴포넌트들만 로드하고, 데이터베이스나 서비스 계층과 같은 다른 빈들을 로드하지 않기 때문에 경량화된 테스트를 제공한다. @WebMvcTest는 기본적으로 컨트롤러와 관련된 빈들만 로드. 서비스, 리포지토리, 컴포넌트 등 다른 빈들은 자동으로 로드되지 않기 때문에 @MockBean을 사용하여 필요한 의존성을 모킹해야 한다.

 

그리하여 아래와 같이 컨트롤러 테스트를 작성했는데(클래스 부분 생략)

여러 방법으로 해도 다 실패하였다..

단순하게 생각할 수 있는 eventMoneyMap.get을 stubbing 해봤다가

list 자체를 stubbing 하고 싶다고 생각하면서도 이게 controller에 주입이 안 되는 것 같아

하면서도 말도 안 된다고 생각하였지만 injectMock을.. 사용해보기도 해 보고(절박하면 우선 해본다..ㅋㅋ)

PostConstruct 대신 직접 함수 호출을 해보기도 한다.

그러다가 webMvcTest ->@Autowired로 자동 빈 주입이잖아? 그럼 injectMock 말고 바꿔봐!

이래서 성공에 가까워졌다.

 

아래는 성공 코드

@Autowired로 컨트롤러 빈 가져와서 setter 함수로 mockBean을 주입한다.

  private MockMvc mockMvc;
  @MockBean
  private EventMoneyUseCase mockUseCase;
  @MockBean
  private List<EventMoneyUseCase> eventMoneyUseCases;
  @Autowired
  private EventRewardController target;
  
  @BeforeEach
  protected void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) throws Exception {
    this.mockMvc = mockMvc(webApplicationContext, restDocumentation);
  }

  @Test
  @DisplayName("이벤트 머니 지급")
  void giveEventReward() throws Exception {
    // given
    TestMsaMemberResolver.setUp(MEMBER_NO, MEMBER_ID);
    given(mockUseCase.getGameType()).willReturn(GameType.S);
    given(mockUseCase.giveEventMoney(any(EventMoneyRequest.class))).willReturn(MEMBER_NO);
    eventMoneyUseCases = List.of(mockUseCase);
    target.setEventMoneyMap(); // 이 부분은 `@PostConstruct`가 아닌 직접 호출
    //when
    ...
    }
}

 

성공하고 나면 별거 아닌데,, 이걸로 오늘 3시간은 날린 것 같다..ㅋㅋ 피곤하다 월요일


mockito를 사용할 경우

@Component
@RequiredArgsConstructor
public class ChangeMoneyUseCaseFactory {

  private final List<ChangeMoneyUseCase> changeMoneyUseCases;

  private EnumMap<GameType, ChangeMoneyUseCase> changeMoneyUseCaseMap;

  @PostConstruct
  private void init() {
    changeMoneyUseCaseMap = changeMoneyUseCases.stream()
        .collect(Collectors.toMap(ChangeMoneyUseCase::getGameType, Function.identity(), (existing, replacement) -> existing,
            () -> new EnumMap<>(GameType.class)));
  }
@ExtendWith(MockitoExtension.class)
class ChangeMoneyUseCaseFactoryTest {

  @InjectMocks
  private ChangeMoneyUseCaseFactory target;

  @Mock
  private SinChangeMoneyService sinChangeMoneyService; //list에 담길

  @BeforeEach
  void setUp() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    given(sinChangeMoneyService.getGameType()).willReturn(GameType.SIN);

    List<ChangeMoneyUseCase> useCases = List.of(sinChangeMoneyService);
    target = new ChangeMoneyUseCaseFactory(useCases); //주입

    //private postconstruct reflection 주입
    Method init = ChangeMoneyUseCaseFactory.class.getDeclaredMethod("init");
    init.setAccessible(true);
    init.invoke(target);
  }

만약 public postconstruct 라면

  @BeforeEach
  void setUp() {
    goodsWithdrawMap = new HashMap<>();
    goodsWithdrawMap.put(ItemType.MONEY_CHIP, chipWithdrawer);
	...
    goodsWithdrawMap.put(null, defaultWithdrawer);
     //public 함수 주입
    ReflectionTestUtils.setField(target, "goodsWithdrawMap", goodsWithdrawMap);
  }
728x90
반응형
반응형

캐시는 다음과 같은 이유로 주로 사용된다.

 

  • 성능 개선: 자주 요청되는 데이터를 빠르게 제공하여 응답 속도를 높임.
  • 부하 감소: 데이터베이스, API 서버 등 원본 소스에 대한 요청을 줄여 리소스 절약.
  • 비용 최적화: 외부 API 호출이나 고비용 데이터 처리 작업을 줄임.
  • 안정성 강화: 원본 데이터 소스 장애 시 캐시 데이터를 활용해 서비스 지속 가능.
  • 복잡한 연산 제거: 고비용 연산 결과를 캐싱하여 반복 작업 최소화.
  • 사용자 경험 향상: 빠르고 개인화된 데이터를 제공해 UX 개선.
  • 분산 시스템 효율성: 로컬 캐시와 CDN을 통해 네트워크 지연 감소.

 

따라서 캐시와 관련된 문제들은 애플리케이션 성능에 영향을 미칠 수 있다. 어떠한 문제들이 있을 수 있을까.

 

1. 캐시 미스(Cache Miss)

  • 정의: 애플리케이션이 필요한 데이터가 캐시에 없는 상황
  • 유형:
    • Cold Miss: 캐시가 초기화되거나 처음 사용될 때 발생하는 미스. 캐시에 아무 데이터가 없어서 모든 요청이 미스 처리
    • Capacity Miss: 캐시의 용량이 부족해 더 이상 데이터를 저장할 수 없는 상황에서 기존 데이터가 삭제되면서 발생하는 미스.
    • Conflict Miss: 특정 데이터가 저장될 수 있는 캐시 공간이 제한되어 있을 때 발생하는 미스입니다. 캐시에 이미 동일한 위치에 저장된 데이터가 있을 때 교체되면서 발생
  • 해결 방안: 캐시 크기 조절(키워서 미스 안 나게), 효율적인 캐싱 전략 설정, LRU(Least Recently Used)나 LFU(Least Frequently Used) 같은 캐시 교체 알고리즘 도입 등

2. 캐시 스탬피드(Cache Stampede)

  • 정의: 캐시가 만료된 경우 다수의 요청이 동시에 캐시 미스를 일으켜 데이터베이스나 원본 서버에 과부하를 유발
  • 예시: 특정 데이터의 캐시가 만료되었을 때, 많은 사용자나 프로세스가 동시에 캐시 된 데이터를 요청하며 원본 서버에 큰 부하가 걸림
  • 해결 방안:
    • 락(Locking): 캐시가 만료되었을 때 첫 번째 요청자만 원본 데이터에 접근하도록 락을 걸어 다른 요청자가 대기하도록.. 대기하고 나서는 캐시에서 가져가게
    • 백오프(Backoff): 캐시 업데이트를 요청하는 다수의 주체들이 동일한 시점에 갱신 요청을 보내는 것을 방지하기 위해, 재시도 전에 일정한 대기 시간을 설정하는 방식
      • API 호출 실패 후 1초 → 2초 → 4초로 대기 시간을 증가시키며 재요청.
      • 캐시가 만료된 데이터를 가져오기 위해 여러 클라이언트가 요청을 보낼 때, 요청 간의 간격을 두어 스탬피드를 방지.
    • 예상 만료 처리(Early Expiration): 캐시의 데이터가 만료되기 전에 미리 갱신 작업을 수행하여 캐시 미스 상황을 방지하는 방식
      • TTL(Time To Live) 설정 시, 실제 만료 시간(T)보다 짧은 시간(T-E)으로 갱신 작업을 예약.
      • 백그라운드 작업을 통해 갱신 작업 수행.
    • 탄력적 TTL: 캐시의 TTL을 랜덤하게 설정하여 여러 캐시 항목이 동시에 만료되지 않도록 조정(TTL skew)

3. 캐시 독(Cache Thundering Herd)

  • 정의: 특정 캐시 키에 대한 동시 접근이 일어나거나 캐시가 갱신되어야 할 때 한 번에 모든 스레드가 동일한 데이터를 요청하는 문제입니다. 캐시 스탬피드와 유사하지만 주로 특정 데이터에 집중된 대량 요청
  • 문제상황
    • 백엔드 부하 증가: 모든 요청이 원본 소스(DB, API 등)에 쏠려 장애를 유발.
    • 성능 저하: 응답 속도가 느려지고, 전체 시스템 성능이 저하.
    • 서비스 가용성 위협: 심한 경우 서비스 중단으로 이어질 수 있음.
  • 해결 방안: 분산 락, 지연 갱신(Lazy Update) 같은 접근을 통해 특정 리소스에 동시 접근하는 것을 방지

 

위와 같이 여러 해결책이 있지만 대체적으로 즉각적인 해결책이 되지 못한다.(응답 지연의 가능성)

캐시 웜업(Cache Warming)

캐시 웜업은 애플리케이션이나 서비스가 시작되거나 재시작될 때, 캐시를 미리 채워두는 작업. 이를 통해 서비스 초기 상태에서 캐시 미스(Cache Miss)로 인한 성능 저하를 방지하고, 원본 데이터 소스(DB, API 등)에 대한 부하를 줄일 수 있다.

캐시 웜업의 필요성

 

  • 초기 캐시 미스 문제 방지 -> 초기 성능 향상
    • 애플리케이션이 시작된 직후 캐시가 비어 있는 상태에서는 모든 요청이 원본 데이터 소스로 전달됨.
    • 갑작스러운 트래픽 증가로 원본 소스(DB/API)가 과부하 상태에 빠질 수 있음.
  • 서비스 안정성 향상, 사용자 경험 개선
    • 캐시 웜업으로 자주 사용되는 데이터를 미리 준비해 두면 초기 요청 처리 속도가 빨라지고, 사용자 경험이 개선됨.
  • 트래픽 분산
    • 캐시가 미리 채워져 있으면 요청이 분산되므로, 백엔드 부하가 줄어듦.

 

캐시 웜업의 일반적인 방법

  1. 프리로드(Preloading): 시스템 초기화나 캐시 시작 시점에 미리 정해진 데이터 집합을 캐시에 로드. 예를 들어, 자주 요청되는 데이터나 중요한 설정값을 미리 캐시에 넣어둔다.
  2. 배치 처리: 배치 작업을 통해 일정 시간 간격으로 특정 데이터를 캐시에 로드하여 캐시가 항상 최신 상태를 유지하도록 한다.
    • @Scheduled(fixedDelay = 300000) 혹은 별도의 배치 프로그램 
    • 만료 전 갱신으로 캐시만을 사용하여 응답하게 끔하여 트래픽 전이 되지 않고 응답 지연 없게 처리; 1분 주기 배치
      • 캐시 웜업 대상 누락 시 cache stampede 발생!
      • 캐시 웜업 대상 조회의 자동화 필요
  3. API 또는 관리자 도구 활용: 관리자 페이지나 별도의 API를 통해 수동으로 캐시를 웜업
  4. 온디맨드 웜업(On-Demand Warming): 실제 사용자가 특정 데이터를 요청하기 직전에, 예측 분석을 통해 해당 데이터가 자주 요청될 것으로 판단될 경우 이를 캐시에 미리 로드한다.
    1. 데이터 분석 시 "호출 횟수(view count) / 최근 호출 시간" 통계화 고려(날짜-id-view)
    2. 로컬 디비에 관련 데이터 쌓고(레디스 부하 발생 가능)
    3. 주기적으로 레디스에 올려
    4. 관련하여 레디스 자료구조 활용(분산 자료구조인 hash)
    5. 레디스에 올리고 로컬 삭제
    6. 해당 데이터 기반으로 캐시를 갱신한다

프리로드 예시(ApplicationRunner 이용)

@Component
public class CacheWarmupRunner implements ApplicationRunner {
    @Autowired
    private CacheService cacheService;
    @Autowired
    private DataService dataService;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<Data> dataList = dataService.getFrequentlyAccessedData();
        dataList.forEach(data -> cacheService.put(data.getKey(), data));
    }
}

캐시 웜업의 장단점

  • 장점: 캐시 미스로 인한 부하를 줄이고, 사용자에게 보다 빠른 응답 시간을 제공
  • 단점: 데이터 신선도 이, 필요성이 낮은 데이터의 경우 불필요한 리소스 사용이 될 수 있음

 

하이브리드 캐시

: 로컬 캐시 + 리모트 캐시

  • 로컬 캐시로: 빈번하게 조회되고/공통 데이터/데이터 크기가 작고/업데이트 빈도가 낮은 데이터
  • 리모트 캐시: 최신 상태 데이터/중앙 관리(마스터 캐시)
  • 디비 -> 리모트 -> 로컬 순으로 갱신

주의사항

  • 로컬 캐시마다 TTL이 다 다르기 때문에 데이터 일관성을 유지하기 힘듦
  • 데이터 변경 시 누가 old로 내리는지 찾기 힘듦
  • 즉시 반영 시에는 별도의 만료 프로세스 필요
    • 주키퍼 활용해서 데이터 변경 시 이벤트 발행하여 동기화 가능
    • 변경 이벤트 ->리모트 캐시 만료 처리 -> 로컬 캐시 만료 처리

 

참고

초단위 캐시 웜업(준 실시간)

로컬 캐시 TTL 다 달라.. 초단위 웜업 시 rabbit 의 DLQ 활용 가능

1~60초 각각의 만료시간을 가진 메세지 발행

https://youtu.be/BUV4A2F9i7w?si=eVdHEBzJ3h8bZOIu


캐시 갱신 정책

캐시와 원본 데이터 간의 동기화 방법.

  1. Write-Through
    • 캐시에 쓰는 동시에 원본 데이터도 즉시 갱신.
    • 장점: 데이터 일관성 보장.
    • 단점: 쓰기 성능 저하.
  2. Write-Behind (Write-Back)
    • 캐시에 먼저 쓰고 나중에 원본 데이터 갱신.
    • 장점: 쓰기 성능 향상.
    • 단점: 데이터 손실 가능성.
  3. Read-Through
    • 캐시에 없을 때 원본 데이터를 읽고 캐시에 저장.
    • 장점: 자동 갱신.
    • 단점: 초기 요청 지연.
  4. Manual Update
    • 명시적으로 캐시를 갱신.
    • 장점: 제어 용이.
    • 단점: 관리 복잡.

 

캐시 만료 정책

캐시 데이터의 유효 기간을 설정하는 방법.

  1. TTL (Time-To-Live)
    • 데이터가 캐시에 저장된 후 유지 시간 설정.
    • 예: 30초 후 만료.
  2. TTI (Time-To-Idle)
    • 마지막 접근 후 일정 시간 지나면 만료.
    • 예: 10분 동안 미사용 시 삭제.
  3. Fixed Expiry
    • 특정 시간에 모든 데이터 만료.
    • 예: 매일 자정 초기화.
  4. Forever Cache
    • 만료 없이 지속적으로 유지.
    • 데이터 변경이 거의 없을 때 사용.
728x90
반응형

'architecture > micro service' 카테고리의 다른 글

zookeeper  (0) 2024.12.23
[Dead Letter] PDL, CDL  (0) 2024.11.14
E2E(end to end) 테스트  (0) 2024.11.13
대용량 데이터 처리 고민  (1) 2024.11.10
transaction outbox pattern + polling publisher pattern  (0) 2024.11.07
반응형

컨트롤러단 테스트를 할 때 주로 mockMvc를 사용했었는데, @RestClientTest는 뭐지?

:: @RestClientTest는 REST 클라이언트를, MockMvc는 컨트롤러를 테스트

 

MockMvc

목적

  • Spring MVC의 컨트롤러 레이어를 테스트할 때
    • 컨트롤러 레이어의 요청 매핑, 응답 구조, 상태 코드 등을 검증할 때
    • @WebMvcTest는 애플리케이션 컨텍스트에서 컨트롤러와 관련된 빈만 로드
      • @Controller, @RestController, @ControllerAdvice, @JsonComponent 등
  • 실제 HTTP 서버를 띄우지 않고, HTTP 요청과 응답을 모킹하여 컨트롤러 동작을 검증함
  • 비즈니스 로직이 아닌 HTTP 요청-응답 플로우를 테스트하고자 할 때
    • 서비스 계층(bean)이나 리포지토리(bean)는 로드되지 않으며, 이를 테스트하려면 MockBean으로 대체

주요 특징

  • 테스트 범위: 컨트롤러 레이어에 한정. 서비스나 리포지토리 레이어는 빈으로 로드되지 않으며, 해당 의존성은 목(Mock)으로 주입
  • 컨트롤러의 HTTP 요청 및 응답 동작을 테스트하고, 입력 검증(@Valid), JSON 직렬화/역직렬화, HTTP 상태 코드 등을 확인.
  • 어노테이션 설정: 주로 MockMvc와 함께 사용하며, 필요한 의존성을 명시적으로 목(Mock)으로 처리
@RestController
@RequestMapping("/users")
public class UserController {

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        User user = new User(id, "Alice");
        return ResponseEntity.ok(user);
    }

    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        user.setId(1L); // ID는 생성된 값으로 설정
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }
}
///
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService; // 서비스 계층은 Mock으로 처리
    
    @Test
    void testGetUserById() throws Exception {
        // MockMvc를 사용해 GET 요청 수행
        mockMvc.perform(get("/users/1"))
               .andExpect(status().isOk()) // HTTP 상태 코드 검증
               .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // 응답 Content-Type 검증
               .andExpect(jsonPath("$.id").value(1)) // JSON 필드 검증
               .andExpect(jsonPath("$.name").value("Alice"));
    }
    @Test
    void testCreateUser() throws Exception {
        // 요청 본문
        String userJson = """
            {
                "name": "Alice"
            }
        """;

        // MockMvc를 사용해 POST 요청 수행
        mockMvc.perform(post("/users")
                   .contentType(MediaType.APPLICATION_JSON) // 요청 Content-Type 설정
                   .content(userJson)) // 요청 본문 설정
               .andExpect(status().isCreated()) // HTTP 상태 코드 검증
               .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // 응답 Content-Type 검증
               .andExpect(jsonPath("$.id").value(1)) // JSON 필드 검증
               .andExpect(jsonPath("$.name").value("Alice"));
    }

}

예외 처리 테스트 가능(controller advice)

컨트롤러가 존재하지 않는 리소스를 요청할 때 적절한 상태 코드와 메시지를 반환하는지 테스트

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<String> handleNoSuchElementException(NoSuchElementException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
    }
}
/////
@Test
void testUserNotFound() throws Exception {
    // 존재하지 않는 사용자 요청
    mockMvc.perform(get("/users/999"))
           .andExpect(status().isNotFound()) // HTTP 상태 코드 검증
           .andExpect(content().string("User not found")); // 응답 메시지 검증
}

 

@RestClientTest

목적

  • REST 클라이언트(RestTemplate, WebClient) 가 외부 API와 올바르게 상호작용하는지 테스트하는 데 사용
  • 외부 API와의 통신이 예상대로 작동하는지 검증
  • 실제 HTTP 호출을 하지 않고, MockRestServiceServer를 사용하여 요청과 응답을 모킹

주요 특징

  • REST 클라이언트와 관련된 스프링 컨텍스트만 로드
  • 외부 API와의 상호작용(요청/응답 데이터 처리)을 집중적으로 테스트
  • 클라이언트 요청의 헤더, URL, 페이로드 등을 검증
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setConnectTimeout(2000); // 연결 타임아웃 2초
        factory.setReadTimeout(2000);    // 읽기 타임아웃 2초
        return new RestTemplate(factory);
    }
}

@Service
public class UserClient {

    private final RestTemplate restTemplate;

    public UserClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public User getUser(Long userId) {
        String url = "/api/users/" + userId;
        return restTemplate.getForObject(url, User.class);
    }

    public User createUser(User user) {
        String url = "/api/users";
        return restTemplate.postForObject(url, user, User.class);
    }
}
/////
@RestClientTest(UserClient.class)
class UserClientTest {

    @Autowired
    private UserClient userClient;

    @Autowired
    private MockRestServiceServer mockServer;

    @Test
    void testGetUser() {
        // Mock 서버 설정
        mockServer.expect(requestTo("/api/users/1"))
                  .andExpect(method(HttpMethod.GET))
                  .andRespond(withSuccess("""
                      {
                          "id": 1,
                          "name": "Alice"
                      }
                  """, MediaType.APPLICATION_JSON));

        // 클라이언트 호출 및 결과 검증
        User user = userClient.getUser(1L);
        assertEquals(1L, user.getId());
        assertEquals("Alice", user.getName());
    }
    
    @Test
    void testCreateUser() {
        // Mock 서버 설정
        mockServer.expect(requestTo("/api/users"))
                  .andExpect(method(HttpMethod.POST))
                  .andExpect(content().json("""
                      {
                          "name": "Alice"
                      }
                  """))
                  .andRespond(withSuccess("""
                      {
                          "id": 1,
                          "name": "Alice"
                      }
                  """, MediaType.APPLICATION_JSON));

        // 클라이언트 호출
        User newUser = new User(null, "Alice");
        User createdUser = userClient.createUser(newUser);

        // 결과 검증
        assertEquals(1L, createdUser.getId());
        assertEquals("Alice", createdUser.getName());
    }

    @Test
    void testTimeout() {
        // Mock 서버에서 응답 지연 설정 (5초)
        mockServer.expect(requestTo("/api/users/1"))
                  .andExpect(method(HttpMethod.GET))
                  .andRespond(request -> {
                      Thread.sleep(5000); // 응답 지연
                      return withSuccess("""
                          {
                              "id": 1,
                              "name": "Alice"
                          }
                      """, MediaType.APPLICATION_JSON).createResponse(request);
                  });

        // 타임아웃 발생 검증
        assertThrows(ResourceAccessException.class, () -> {
            userClient.getUser(1L);
        });
    }
}

 

  • connectTimeout: 서버와 연결하는 데 걸리는 시간 초과 시 예외 발생.
  • readTimeout: 서버로부터 응답을 읽는 데 걸리는 시간 초과 시 예외 발생.

 

MockRestServiceServer

  • RestTemplate 요청을 mock하기 위한 서버
  • 요청의 URL, 메서드, 본문 등을 검증
  • 응답 데이터를 설정하여 실제 API 호출 없이 테스트를 수행
  • content(): 요청 본문(payload)을 검증합니다.
  • withSuccess(): 요청에 대한 성공 응답을 mock

근데 원래의 나라면 restTemplate을 그냥 @Mock 했을 텐데..

MockRestServiceServer이 더 쓰기 좋은 것 같다. 보통 타임아웃 같은 http기능을 테스트하고 하는 게 더 크다 보니..

 

Spring 클라이언트(RestTemplate)에서 타임아웃이 발생하면, 기본적으로 발생한 SocketTimeoutException이 ResourceAccessException으로 감싸져 전달된다..

 

어떤 상황에서 무엇을 선택할까?

사실 전혀 다른 거다..ㅋㅋ 그냥 api 요청 테스트라 헷갈렸을 뿐....

  1. 외부 API와의 통신을 테스트하고 싶을 때:
    • @RestClientTest를 사용하여 REST 클라이언트의 요청 및 응답 처리를 검증
  2. 컨트롤러의 요청 매핑 및 응답을 테스트하고 싶을 때:
    • MockMvc를 사용하여 컨트롤러 동작을 검증
  3. 종단 간 테스트를 수행하고 싶을 때:
    • REST 클라이언트와 컨트롤러를 포함한 전체 플로우를 테스트하려면 @SpringBootTest를 사용!

 

728x90
반응형
반응형

환경: java17, springboot3.3, mockito-core5.11

given(moneyUseCase.giveMoney(any(), anyString(), anyString(), anyLong(), anyString(), anyLong(), anyString(), anyString())).willReturn(
    MoneyResult.SUCCESS);

mockito 테스트 코드를 작성하고 돌려보는 도중 아래와 같은 에러가 발생하였다. 함수의 인자를 보고 쓴 거라 모킹에 틀린 게 없을 텐데..?

org.mockito.exceptions.misusing.PotentialStubbingProblem: 
Strict stubbing argument mismatch. Please check:
 - this invocation of 'giveMoney' method:
    MoneyUseCase.giveMoney(  //나는 이렇게 테스트 날렸는데
    com.aa.Rating@30a73947,
    "HYM0000002",
    "127.0.0.1",
    10000000L,
    null,
    10000L,
    "NORIRAT",
    "NORIBASEV"
);
    -> at com.aa.application.service.EventMoneyService.giveMoney(EventMoneyService.java:59)
 - has following stubbing(s) with different arguments:
    1. MoneyUseCase.giveMoney( //any-family는 이렇게 인식했다는거...?
    null,
    "",
    "",
    0L,
    "",
    0L,
    "",
    ""
);
      -> at com.aa.application.service.EventMoneyServiceTest.giveEventReward__fail_giveMoney(EventMoneyServiceTest.java:92)
Typically, stubbing argument mismatch indicates user mistake when writing tests.

 

이 에러는 무엇인가 PotentialStubbingProblem

이 오류는 Mockito에서 엄격한 스텁(strict stubbing) 설정으로 인해 발생한다. Mockito의 엄격한 스텁 모드에서는 정의한 스텁과 호출된 메서드의 인자가 정확히 일치하지 않으면 예외를 발생하는데... Mockito가 기대한 스텁 설정과 실제 호출 인자가 일치하지 않아 정의된 스텁이 호출되지 않은 것으로 간주되어 오류를 발생시키는 것이다.

하지만 any() 매처(org.mockito.ArgumentMatchers.any*)를 사용하면 Mockito는 특정 값이 아닌 타입에 대한 일치만 확인한다. 이 말은, anyString()이 "정확한 문자열 값"을 요구하지 않고 그저 문자열 타입의 어떤 값이든 허용한다는 뜻이다. 그리고 나는 그것을 의도했다.

그럼 이 에러는 왜 나는 것일까

any 매처가 올바르게 작성되었음에도 에러가 나는게 의아하여 에러로그를 자세히 살펴본다.

위 부분이 제일 의심스럽다. 이 부분을 anyString()으로 stubbing 했는데, 만든 request에는 null로 세팅했다.

anyString에 대해 검색해보니 아래와 같은 내용이 나온다.

anyString() 은 null에 매핑되지 않는다!

이게 좀 걸려서 anyString()에 대해 찾아본다.

Mockito에서 anyString()은 null이 아닌 임의의 문자열과 일치한다. 즉, anyString()은 null 값과 매칭되지 않으며, null이 전달된 경우 Mockito는 anyString()으로 매칭하지 않는다. 만약 null을 포함하여 매칭하려면 nullable(String.class)를 사용해야 한다.

String 값에는 null이 충분히 들어가지만, 이걸 모킹할 때는 빈 스트링("")을 넘겨야 하는구나.. 그럼 any()로 바꿔보자.

given(moneyUseCase.giveMoney(any(Rating.class), anyString(), anyString(), anyLong(), any(), anyLong(), anyString(),
    anyString())).willReturn(MoneyResult.FAIL_INIT);

성공한다...! 이럴 수가..

 

이 에러가 날 때마다 매번 토끼눈 뜨고 비교하는 게 여간 번거로울 듯하다. 그렇다고 any()로 다 처리하는 건 너무 가능성을 열어 놓는 것 같고... 다른 해결책은 없을까?

1. nullable(String.class)

널이 올 확률이 아주 높으면 명시하는 것도 좋을 것 같다.

// anyString()은 null이 아닌 모든 String과 매칭
given(mock.someMethod(anyString())).willReturn("result"); // 이 경우 null은 매칭되지 않음

// null 값을 포함하여 String과 매칭하려면 nullable 사용
given(mock.someMethod(nullable(String.class))).willReturn("result");

2. 해당 모킹에 lenient를 달기

lenient().when(
    moneyUseCase.giveMoney(any(Rating.class), anyString(), anyString(), anyLong(), anyString(), anyLong(), anyString(),
        anyString())).thenReturn(MoneyResult.FAIL_INIT);

추가로 주의해야 하는 점은 lenient()은 given()과는 직접 함께 쓸 수는 없다는 점. lenient()는 Mockito의 when() 구문과 함께 사용해야 한다. lenient()는 Mockito의 엄격한 검사(strict stubbing)를 완화하여 인자 일치 오류와 같은 문제를 방지한다.

3. 클래스 레벨에서 처리

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)

null을 허용하지 않는 매처들

기본 타입용 매처 (Primitive Matchers)

  • anyInt()
  • anyLong()
  • anyDouble()
  • anyFloat()
  • anyBoolean()
  • anyChar()
  • anyShort()
  • anyByte()

이유:

  • 기본 타입(primitive)은 null을 가질 수 없으므로, null을 전달하려고 하면 NullPointerException이 발생합니다.
  • nullable(Long.class) 또는 eq(null): null 값을 처리하고자 할 때 사용.
  • Mockito는 이러한 매처를 사용할 때, 내부적으로 해당 타입의 기본값(예: int는 0, boolean은 false)을 검증 기준으로 삼습니다.
728x90
반응형

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

[동기화] 뮤텍스/세마포어  (0) 2024.11.24
DB로 분산락 구현  (2) 2024.11.21
자바와 스프링에서 thread pool  (0) 2024.11.11
[test] object mother  (2) 2024.09.26
자바 버전별 특징  (0) 2024.09.23

+ Recent posts