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

환경: springboot3.3

 

기존에 멀티 데이터베이스를 쓸 때 분산 트랜젝션을 위해 아래와 같이 ChainedTransaction을 사용하였는데..

@Configuration
public class ChainedTransactionConfiguration {

  @Primary
  @Bean(Constants.CHAINED_TRANSACTION_MANAGER)
  public PlatformTransactionManager transactionManager(@Qualifier(Constants.USER_TRANSACTION_MANAGER) PlatformTransactionManager userPlatformTransactionManager,
      @Qualifier(Constants.LOG_TRANSACTION_MANAGER) PlatformTransactionManager logPlatformTransactionManager,
      @Qualifier(Constants.STATIC_TRANSACTION_MANAGER) PlatformTransactionManager staticPlatformTransactionManager) {
    return new ChainedTransactionManager(userPlatformTransactionManager, logPlatformTransactionManager, staticPlatformTransactionManager);
  }
}

아래와 같이 Deprecated 되었다.

여러 대안을 찾다가 JtaTransaction이 있어 사용가능한지 확인해 본다.

 

조건은

1. 멀티 데이터베이스이기 때문에 각각에 대해 단일 Transactional을 설정할 수 있어야 하고

2. 필요에 따라 복합 트랜젝션도 가능해야 한다.

 

우선 JtaTransaction이 뭔지 간단히 알아보자.

JtaTransactionManager는 기본적으로 여러 데이터베이스에 걸쳐 트랜잭션을 처리하는 역할을 한다. 그러나 이를 제대로 활성화하려면 다음과 같은 조건을 충족해야 한다:

  • XA 데이터 소스 설정: 분산 트랜잭션을 사용하려면 XADataSource를 사용해야 한다. 예를 들어, MySQL을 사용할 경우 MysqlXADataSource를 사용해야 하며, 다른 데이터베이스도 XA 지원을 해야 한다.
  • 트랜잭션 관리자의 설정: JtaTransactionManager는 기본적으로 JTA를 사용하여 트랜잭션을 관리하지만, 분산 트랜잭션을 활성화하려면 여러 데이터 소스를 연결하고 이를 관리할 수 있는 TransactionManager 설정이 필요하다.
@Configuration
public class JtaDbConfig {

    @Bean(name = "chainedTransaction")
    public JtaTransactionManager transactionManager() {
        JtaTransactionManager transactionManager = new JtaTransactionManager();
        // JTA 트랜잭션 매니저 설정
        return transactionManager;
    }

    // DataSource 1 설정 (XA DataSource)
    @Bean
    public DataSource dataSource1() {
        MysqlXADataSource dataSource = new MysqlXADataSource();
        dataSource.setUrl("jdbc:mysql://localhost:3306/db1");
        dataSource.setUser("user1");
        dataSource.setPassword("pass1");
        return dataSource;
    }

    // DataSource 2 설정 (XA DataSource)
    @Bean
    public DataSource dataSource2() {
        MysqlXADataSource dataSource = new MysqlXADataSource();
        dataSource.setUrl("jdbc:mysql://localhost:3306/db2");
        dataSource.setUser("user2");
        dataSource.setPassword("pass2");
        return dataSource;
    }

    // EntityManagerFactory 설정 (각각의 데이터베이스용)
    @Bean(name = "entityManagerFactory1")
    public LocalContainerEntityManagerFactoryBean entityManagerFactory1(EntityManagerFactoryBuilder builder) {
        return builder
                .dataSource(dataSource1())
                .packages("com.example.entity1")
                .build();
    }

    @Bean(name = "entityManagerFactory2")
    public LocalContainerEntityManagerFactoryBean entityManagerFactory2(EntityManagerFactoryBuilder builder) {
        return builder
                .dataSource(dataSource2())
                .packages("com.example.entity2")
                .build();
    }
}

JtaTransactionManager 도 단일 트랜젝션 관리 가능한가?

  1. JTA 트랜잭션 관리의 범위:
    • JtaTransactionManager는 분산 트랜잭션(XA 트랜잭션)을 관리하는 데 최적화되어 있지만, 단일 데이터 소스에서의 트랜잭션도 처리할 수 있음
    • 단일 데이터 소스만 사용할 경우에도 JTA 프로토콜을 통해 트랜잭션이 시작되고 종료됨
  2. 단일 트랜잭션 시 처리 동작:
    • 단일 데이터 소스에서 JtaTransactionManager는 해당 데이터 소스에서의 트랜잭션을 관리함
    • 단일 데이터 소스 환경에서는 JpaTransactionManager나 DataSourceTransactionManager처럼 작동함
    • 단일 트랜잭션 환경에서는 JpaTransactionManager나 DataSourceTransactionManager가 더 효율적일 수 있다. 이는 JTA 오버헤드가 없기 때문
    • 분산 트랜잭션이 필요 없는 경우 굳이 JtaTransactionManager를 사용할 필요는 없음

 

참고:

  1. JpaTransactionManager는 JPA에 특화되어 있으며, 트랜잭션이 하나의 데이터베이스일 경우에 적합
  2. JtaTransactionManager는 JTA를 지원하며, XA 데이터 소스를 사용하는 분산 트랜잭션을 관리할 수 있음

JtaTransactionManager는 단일 트랜잭션도 처리할 수 있지만, 분산 트랜잭션이 필요 없는 경우에는 더 가벼운 트랜잭션 매니저(JpaTransactionManager 또는 DataSourceTransactionManager)를 사용하는 것이 더 효율적임. 하지만 프로젝트 환경에서 단일 및 분산 트랜잭션이 모두 필요하다면 JtaTransactionManager를 사용해 통합적으로 관리 가능

그럼 단일 트랜젝션이 필요할 경우 더 가볍게 설정할 수는 없을까?

그거슨 불가..

JtaTransactionManager가 기본적으로 JTA 규격에 따라 동작하며, 트랜잭션의 범위는 리소스에 따라 자동으로 결정되기 때문이다..

정 필요하면 아래처럼 JpaTransactionManager / JtaTransactionManager 각각 만들어서 필요에 따라 transactionManager를 지정하는 방법뿐.. 이라는데 이건 좀 아닌 듯....

@Configuration
public class DataSourceConfig {

    // 첫 번째 데이터베이스 - 단일 트랜잭션용
    @Bean(name = "dataSource1Hikari")
    public DataSource dataSource1Hikari() {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db1");
        dataSource.setUsername("user1");
        dataSource.setPassword("password1");
        return dataSource;
    }

    // 첫 번째 데이터베이스 - 분산 트랜잭션용
    @Bean(name = "dataSource1XA")
    public DataSource dataSource1XA() {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUrl("jdbc:mysql://localhost:3306/db1");
        xaDataSource.setUser("user1");
        xaDataSource.setPassword("password1");
        return xaDataSource;
    }

    // 두 번째 데이터베이스 - 단일 트랜잭션용
    @Bean(name = "dataSource2Hikari")
    public DataSource dataSource2Hikari() {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db2");
        dataSource.setUsername("user2");
        dataSource.setPassword("password2");
        return dataSource;
    }

    // 두 번째 데이터베이스 - 분산 트랜잭션용
    @Bean(name = "dataSource2XA")
    public DataSource dataSource2XA() {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUrl("jdbc:mysql://localhost:3306/db2");
        xaDataSource.setUser("user2");
        xaDataSource.setPassword("password2");
        return xaDataSource;
    }
}
@Configuration
public class TransactionManagerConfig {

    // 첫 번째 데이터베이스 - 단일 트랜잭션
    @Bean(name = "transactionManager1")
    public DataSourceTransactionManager transactionManager1(
            @Qualifier("dataSource1Hikari") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    // 두 번째 데이터베이스 - 단일 트랜잭션
    @Bean(name = "transactionManager2")
    public DataSourceTransactionManager transactionManager2(
            @Qualifier("dataSource2Hikari") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    // JTA 트랜잭션 매니저 (분산 트랜잭션 관리)
    @Bean(name = "jtaTransactionManager")
    public JtaTransactionManager jtaTransactionManager(
            @Qualifier("dataSource1XA") DataSource dataSource1XA,
            @Qualifier("dataSource2XA") DataSource dataSource2XA) {
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        return new JtaTransactionManager(userTransactionImp, userTransactionManager);
    }
}
@Service
public class DbService {

    @Transactional(transactionManager = "transactionManager1")
    public void performDb1Operation() {
        // 첫 번째 데이터베이스 트랜잭션 작업
    }

    @Transactional(transactionManager = "transactionManager2")
    public void performDb2Operation() {
        // 두 번째 데이터베이스 트랜잭션 작업
    }

    @Transactional(transactionManager = "jtaTransactionManager")
    public void performMultiDbOperation() {
        // DB1과 DB2를 조율하는 분산 트랜잭션 작업
    }
}

JTA 프로토콜: 2단계 커밋 (2PC)

JTA는 2PC (Two-Phase Commit) 프로토콜을 사용하여 분산 트랜잭션의 원자성과 일관성을 보장한다. 이 프로토콜은 다음 두 단계를 포함한다.

  1. 1단계: Prepare
    • 트랜잭션 관리자(Transaction Manager)는 모든 자원 관리자(XAResource)에 "Prepare" 메시지를 보냄
    • 각 자원 관리자는 트랜잭션을 준비하고, 성공 여부를 반환(예: VoteCommit 또는 VoteRollback)
  2. 2단계: Commit or Rollback
    • 모든 자원이 VoteCommit을 반환하면 트랜잭션 관리자는 "Commit" 메시지를 보내 트랜잭션을 커밋
    • 하나라도 VoteRollback을 반환하면 모든 자원에 "Rollback" 메시지를 보내 트랜잭션을 롤백
728x90
반응형
반응형

ForkJoinPool은 Java 7에 도입된 병렬 처리 프레임워크로, 작업을 작은 단위로 분할(fork)하고 병렬로 처리한 후 다시 합치는(join) 방식으로 동작한다. 병렬 프로그래밍작업 스케줄링을 위한 강력한 도구로, 특히 대규모 데이터 처리나 계산 집약적인 작업에 유용하다.

이게 프레임워크?

  • 고수준의 작업 관리: ForkJoinPool은 작업 스케줄링, 워크 스틸링, 병렬 처리 등을 관리하는 메커니즘을 제공한다. 개발자가 세부적인 스레드 관리나 큐 처리 등을 직접 코딩하지 않아도 된다.
    • 제어 역전 (IoC, Inversion of Control): 작업 실행과 스레드 관리는 ForkJoinPool이 수행하며, 개발자는 작업의 논리만 작성
  • 작업 분할 및 병합 전략: RecursiveTaskRecursiveAction이라는 추상 클래스를 기반으로 작업을 설계하며, 내부적으로는 효율적인 작업 분할 및 병합을 자동으로 처리한다.
  • 워크 스틸링 (Work Stealing): 스레드 풀에서 작업 큐를 관리하며, 비활성 스레드가 다른 활성 스레드의 큐에서 작업을 가져와 실행하는 동적 작업 분배를 한다.(처리량을 최적화); 개발자가 구현할 필요 없이 forkjoinpool이 자동으로 처리
  • 표준화된 인터페이스: 개발자가 사용할 수 있는 명확한 API (invoke, submit, fork, join 등)를 제공. 이로 인해 복잡한 병렬 프로그래밍을 간단히 구현할 수 있음.

장점

  • 멀티코어를 활용하여 작업을 병렬로 처리하므로 CPU 사용률이 최적화
  • 워크 스틸링을 통해 비효율적인 작업 분배를 방지

단점

  • 작업 분할 및 병합에 대한 오버헤드가 존재
  • I/O 중심 작업에서는 비효율적이며, CPU 집약적인 작업에 적합

적합한 상황

  • 데이터가 많고, 병렬로 처리할 수 있는 작업
  • 재귀작업/반복적으로 작업을 나눌 수 있을 때 (예: 합계, 정렬)
  • CPU 집약적인 작업에서 최적의 성능을 얻고자 할 때

개발 시 전체적인 흐름

  1. 큰 작업이면 Fork하여 병렬 처리.
  2. 작은 작업이면 직접 계산으로 효율적 처리.
  3. 모든 계산이 끝나면 병렬 결과를 Join하여 최종 결과를 얻음.

작은 작업은 직접 계산하는 이유

  1. 작업 분할의 비용 문제:
    • Fork/Join Framework는 큰 작업을 작은 작업으로 나누고 각 작업을 병렬적으로 실행
    • 하지만 작업을 너무 많이 나누면 작업 분할과 작업 병합(merge)에 드는 오버헤드(비용)가 커질 수 있음
    • 작은 작업에 대해서는 작업 분할을 하지 않고 직접 계산하여 오버헤드를 줄임
  2. 효율성 최적화:
    • 일정 크기 이하의 작업은 더 이상 병렬로 처리할 필요가 없으므로 직접 계산이 더 효율적
    • 예를 들어, 배열의 일부를 합산하거나 특정 범위의 숫자를 더하는 간단한 작업이라면, 병렬처리 대신 반복문을 통해 순차적으로 계산하는 것이 빠름

 

ForkJoinPool의 주요 메서드

  • invoke(ForkJoinTask<?> task): 기다리고 결과를 받음
  • execute(ForkJoinTask<?> task): 작업을 비동기로 실행
  • submit(ForkJoinTask<?> task): 작업을 실행하고 Future를 반환

ForkJoinPool 개발 시 RecursiveTaskRecursiveAction의 역할

  1. RecursiveTask<V>:
    • 반환값이 있는 병렬 작업을 정의할 때 사용
    • 작업을 분할하고 결과를 합산하여 반환(compute() 메서드)
  2. RecursiveAction:
    • 반환값이 없는 병렬 작업을 정의할 때 사용.
    • 단순히 작업을 수행하고 결과를 반환하지 않는 경우 적합(compute() 메서드)

꼭 써야해?

RecursiveTask를 상속하지 않고도 직접 ForkJoinTask 또는 Runnable과 같은 인터페이스를 사용할 수 있다. 하지만 이는 더 복잡하고 비효율적이며 코드 복잡성을 증가시킨다.

ForkJoinPool

스레드 갯수를 생략하면, 기본적으로 가용한 CPU 코어 수에 따라 동작

  • 스레드 수 = Runtime.getRuntime().availableProcessors()
    즉, 현재 시스템의 CPU 코어 수(논리적 코어 포함)가 기본 스레드 수로 사용됨
ForkJoinPool pool = new ForkJoinPool(); //내부적으로 가용한 프로세서 수를 기반으로 스레드 풀 크기를 결정

ForkJoinPool pool = new ForkJoinPool(4); // 스레드 4개 사용

예시

import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;

public class ForkJoinExample {
    static class SumTask extends RecursiveTask<Integer> {
        private final int[] arr;
        private final int start, end;
        private static final int THRESHOLD = 10;

        public SumTask(int[] arr, int start, int end) {
            this.arr = arr;
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if (end - start <= THRESHOLD) {
                // 작은 작업은 직접 계산
                int sum = 0;
                for (int i = start; i < end; i++) {
                    sum += arr[i];
                }
                return sum;
            } else {
                // 작업 분할
                int mid = (start + end) / 2;
                SumTask leftTask = new SumTask(arr, start, mid);
                SumTask rightTask = new SumTask(arr, mid, end);

                leftTask.fork(); // 병렬 처리
                int rightResult = rightTask.compute(); // 동기 처리
                int leftResult = leftTask.join(); // 병합

                return leftResult + rightResult;
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = new int[100];
        for (int i = 0; i < arr.length; i++) arr[i] = i + 1;

        ForkJoinPool pool = new ForkJoinPool();
        SumTask task = new SumTask(arr, 0, arr.length);
        int result = pool.invoke(task);

        System.out.println("Sum: " + result); // 출력: Sum: 5050
    }
}

1. leftTask.fork();

  • 작업 분할:
    • leftTask를 병렬로 처리할 수 있도록 Fork-Join Pool에 작업 큐로 제출
    • fork() 메서드는 현재 작업을 Fork-Join Pool의 스레드가 처리하도록 요청하며, 비동기적으로 실행
    • 이 시점에서 leftTask는 아직 결과를 계산하지 않음

2. int rightResult = rightTask.compute();

  • 동기 실행:
    • rightTask는 직접 현재 스레드에서 동기적으로 계산
    • compute() 메서드는 RecursiveTask에서 작업을 처리하는 메인 로직
    • 이렇게 함으로써 하나의 스레드가 rightTask를 바로 계산하여, 자원을 최대한 활용

3. int leftResult = leftTask.join();

  • 결과 병합:
    • join() 메서드는 leftTask가 완료될 때까지 대기하고 결과를 반환
    • 만약 leftTask가 이미 완료되었으면, 바로 결과를 반환
    • 이를 통해 leftTask와 rightTask의 결과를 병합

왜 이런 방식으로 처리?

  • 자원의 효율적 활용:
    • leftTask는 병렬로 실행하도록 요청 (fork())
    • 한편, rightTask는 현재 스레드에서 처리 (compute())
    • 이렇게 하면 다른 작업 스레드가 leftTask를 처리하는 동안, 현재 스레드가 놀지 않고 rightTask를 계산하여 자원을 최대한 활용
  • 병렬성과 동기화의 조합:
    • fork()로 비동기 작업을 시작하고 join()으로 결과를 기다리며 동기화.
    • 병렬성과 동기화의 균형을 유지하면서 성능을 최적화

ForkJoinPool

  • 특징:
    • Java 7에서 도입.
    • 작업 분할(divide-and-conquer)을 기반으로 병렬 처리를 수행.
    • Work-Stealing 알고리즘을 사용해 작업이 끝난 스레드가 다른 스레드의 작업을 훔쳐 효율성을 높임.
    • 주로 재귀적인 작업 처리작업 분할에 사용.
    • RecursiveTask(결과 반환)와 RecursiveAction(결과 없음)을 통해 작업 정의.
  • 사용 사례:
    • 큰 작업을 작은 작업으로 나눠 처리하는 경우.
    • 예: 대규모 데이터 처리, 배열 합산, 병렬 검색.
  • 장점:
    • 스레드 수를 효율적으로 관리 (스레드 풀 크기 설정 가능).
    • Idle(대기) 상태인 스레드가 다른 작업을 훔쳐 병렬 처리 최적화.
  • 단점:
    • 작업 분할이 필요 없는 간단한 병렬 작업에는 적합하지 않을 수 있음.
    • Work-Stealing 비용이 단순 작업에서는 오히려 비효율적.

ExecutorService

  • 특징:
    • Java 5에서 도입.
    • 병렬 작업을 스레드 풀에서 실행하여 스레드 관리를 자동화.
    • Java의 스레드 풀을 관리하는 인터페이스로, 스레드의 생성, 실행, 종료를 간편하게 처리.
    • 개발자는 스레드 풀을 직접 관리할 필요가 없음!
    • 스레드 풀이 다양한 종류로 제공:
      • FixedThreadPool: 고정된 크기의 스레드 풀.
      • CachedThreadPool: 동적으로 크기가 변하는 스레드 풀.
      • ScheduledThreadPool: 예약 및 지연 실행 작업용.
      • SingleThreadExecutor: 단일 스레드로 작업 처리.
  • 사용 사례:
    • 병렬 작업이 분할되지 않거나 작업 분할을 수동으로 처리해야 할 때.
    • 예: 웹 서버 요청 처리, 비동기 작업 관리.
  • 장점:
    • API가 간단하고 다양한 스레드 풀 종류 제공.
    • 반복적이고 독립적인 병렬 작업에 적합.
    • 작업 분할 없이 단순 병렬 실행 가능.
  • 단점:
    • ForkJoinPool만큼 작업 분할에 최적화되지 않음.
    • 대규모 데이터 병렬 처리에는 적합하지 않을 수 있음.
import java.util.concurrent.*;

public class ExecutorServiceExample {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        //4개의 스레드로 구성된 고정 크기 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(4);

        Callable<Integer> task1 = () -> {
            Thread.sleep(1000);
            return 1;
        };
        Callable<Integer> task2 = () -> {
            Thread.sleep(1000);
            return 2;
        };

        Future<Integer> result1 = executor.submit(task1);
        Future<Integer> result2 = executor.submit(task2);

		//Future.get() 호출로 각각의 결과를 대기하고 출력
        //task1과 task2는 1초 동안 대기 후 각각 1과 2를 반환
        System.out.println("Result 1: " + result1.get());
        System.out.println("Result 2: " + result2.get());

		//스레드 풀 종료
        executor.shutdown();
    }
}

 


728x90
반응형
반응형

환경: MySql

예전에 거래내역의 데이터를 파티셔닝 해서 조회한 적이 있는데 직접 설정했던 게 아니라서 구체적으로 알아본다.

파티셔닝 (Partitioning)

파티셔닝하나의 데이터베이스 내에서 데이터를 논리적으로 나누는 방법. 특정 컬럼을 기준으로 데이터를 여러 파티션으로 분할하여 성능을 향상시킬 수 있다.

파티셔닝의 특징:

  • 단일 데이터베이스 인스턴스 내에서 분할
  • 쿼리 성능 향상: 데이터를 작은 블록으로 나누어 특정 파티션만 조회할 수 있어 성능이 향상
  • 트랜잭션 처리: 트랜잭션의 일관성을 유지할 수 있음
  • 관리 용이성: 데이터를 분할해도 동일한 데이터베이스 인스턴스를 사용하므로 관리가 상대적으로 단순

파티셔닝을 선택할 때:

  • 단일 서버에서 성능을 향상시키고 싶을 때
  • 데이터가 일정한 기준으로 나누어지고, 쿼리가 특정 범위 (예: 날짜, 지역 등)로 자주 조회될 때
  • 트랜잭션 일관성 및 데이터 무결성을 유지해야 할 때

예시:

  • 날짜 기반 파티셔닝: order_date가 날짜 범위에 따라 파티셔닝되어, 특정 날짜 범위만 조회하면 해당 파티션만 스캔하여 빠른 성능을 제공

 

파티션 생성

CREATE TABLE orders (
    id INT NOT NULL,
    order_date DATE NOT NULL,
    amount DECIMAL(10, 2),
    PRIMARY KEY (id, order_date)
)
PARTITION BY RANGE (YEAR(order_date) * 100 + MONTH(order_date)) (
    PARTITION p202301 VALUES LESS THAN (202302),
    PARTITION p202302 VALUES LESS THAN (202303),
    PARTITION p202303 VALUES LESS THAN (202304),
    PARTITION pMax VALUES LESS THAN MAXVALUE
);

PARTITION BY RANGE는 각 파티션의 VALUES LESS THAN 조건에 따라 데이터를 할당

MySQL에서는 파티션 조건이 겹치지 않도록 설계되어 있으므로, 데이터가 특정 파티션에만 할당됨

 

  • YEAR(order_date) * 100 + MONTH(order_date) 값이 202302 미만인 데이터가 이 파티션에 들어감
  • 예: 2023년 1월의 데이터(202301), 2022년 12월 이전의 데이터

파티션 확인 가능?

MySQL에서는 내부적으로 파티션이 관리되지만, 사용자 관점에서는 단일 테이블로만 동작하며 클라이언트에서 개별 파티션을 노출하지 않는다. 파티션 정보를 조회하거나 특정 파티션만 쿼리 하는 기능은 있지만, 파티션에 직접 들어가서 작업하는 방식은 지원되지 않는다.

하지만 오라클에서는 파티션이 클라이언트나 관리 도구에서 명시적으로 노출되며, 개별 파티션에 대해 직접 접근하고 작업할 수 있다.

내부 동작 방식

물리적 파일 분리

  • MySQL은 각 파티션을 내부적으로 별도의 파일 형태로 저장
  • 데이터 파일(.ibd 파일, InnoDB 기준)이 각 파티션별로 생성
  • 이로 인해 파티셔닝 된 테이블은 디스크 I/O 및 데이터 관리를 효율적으로 수행

논리적 테이블

  • 사용자는 하나의 테이블로 모든 데이터를 다룸
  • SQL 문장에서 특정 파티션을 명시적으로 참조할 필요가 없음
  • MySQL은 파티션 조건에 따라 자동으로 적절한 파티션에 데이터를 삽입하거나 조회

 

플로우

1. 삽입 시 MySQL은 파티션 조건을 평가하여 해당 파티션에 데이터를 저장

2. 조회 시에도 사용자는 특정 파티션을 신경 쓰지 않아도 됨. MySQL이 자동으로 필요한 파티션만 읽음(파티션 프루닝).

3. 필요하다면 특정 파티션만 직접 조회할 수도 있음

INSERT INTO orders (id, order_date, amount) VALUES (1, '2023-01-15', 100.00);
-- 이 데이터는 p202301 파티션에 저장됨

SELECT * FROM orders WHERE order_date = '2023-01-15';
-- MySQL은 p202301 파티션만 스캔

SELECT * FROM orders PARTITION (p202301);
-- 명시적인 조회도 가능..

 

 

조회 시 파티션 프루닝

MySQL의 파티션 프루닝(Partition Pruning)은 쿼리 실행 시 WHERE 절의 조건에 따라 필요한 파티션만 읽도록 최적화하는 기법

파티션 프루닝이 작동하는 조건

  1. WHERE 절에 파티션 키가 포함될 것
    • MySQL은 WHERE 절에 파티션 키(파티셔닝 기준이 되는 컬럼)가 있을 때만 프루닝을 수행
  2. 상수 또는 단순 연산 사용
    • MySQL은 조건이 상수 값이거나 단순 연산으로 평가될 수 있을 때만 프루닝을 적용
    • 예를 들어, order_date = '2023-01-15'는 가능하지만 order_date = NOW()는 모든 파티션을 탐색
  3. 범위 조건
    • BETWEEN, <, >, = 등의 조건도 프루닝이 가능
  4. IN 조건
    • IN 조건도 가능한 경우 특정 파티션만 선택

그럼 아래와 같이 여러 월에 걸쳐진 데이터도 프루닝이 적용될까?

SELECT * FROM orders WHERE order_date >= '2023-02-01' AND order_date < '2023-03-01';

mysql의 경우 가능, oracle의 경우 불가능..

1. mysql

  • 위 쿼리는 order_date 컬럼이 파티션 키이므로 프루닝이 가능
  • >=, <, BETWEEN과 같은 범위 조건은 프루닝이 지원
    • -> p202301과 p202302 파티션만 검색
  • MySQL에서 프루닝 여부를 확인하려면 EXPLAIN PARTITIONS를 사용
  • 여기서 partitions 열에 p202301, p202302와 같은 값이 표시되면 프루닝이 성공적으로 적용된 것
EXPLAIN PARTITIONS SELECT * FROM orders WHERE order_date >= '2023-01-15' AND order_date < '2023-02-21';

id | partitions    | type  | possible_keys | key  | key_len | ref  | rows  | Extra
---|---------------|-------|---------------|------|---------|------|-------|------
 1 | p202301,p202302 | ALL  | NULL          | NULL | NULL    | NULL | 1000  |

단, 아래와 같은 쿼리는 프루닝 불가

WHERE YEAR(order_date) = 2023 AND MONTH(order_date) = 1; -- 프루닝 불가

 

2. Oracle

  • 조건이 파티션의 경계값과 비교 가능해야 한다.
    • order_date= TO_DATE('2024-01-15', 'YYYY-MM-DD')
      • 파티션 p202401만 접근
    • order_date BETWEEN TO_DATE('2024-01-01', 'YYYY-MM-DD') AND TO_DATE('2024-01-31', 'YYYY-MM-DD')
      • 파티션 p202401만 접근
    • order_date >= TO_DATE('2023-01-15', 'YYYY-MM-DD') AND order_date < TO_DATE('2023-02-10', 'YYYY-MM-DD');
      • 조건이 파티션 경계와 명확히 매칭되므로 Oracle은 p202301과 p202302 두 개의 파티션만 읽습니다.

 

  • 파티션 키에 함수나 연산을 적용하면 Oracle이 프루닝을 할 수 없다.
SELECT * FROM sales WHERE TRUNC(order_date) = TO_DATE('2024-01-15', 'YYYY-MM-DD');
-- 파티션 프루닝 작동하지 않음

 

  • 파티션 키에 연산을 하는 것은 위험하다. 모든 파티션을 스캔할 수 있으니 확인해야한다.
SELECT * FROM orders 
WHERE TRUNC(order_date) >= TO_DATE('2023-01-15', 'YYYY-MM-DD')
  AND TRUNC(order_date) < TO_DATE('2023-02-21', 'YYYY-MM-DD');

 

  • 프루닝 여부 확인
EXPLAIN PLAN FOR
SELECT * FROM orders 
WHERE order_date >= TO_DATE('2023-01-15', 'YYYY-MM-DD') 
  AND order_date < TO_DATE('2023-02-21', 'YYYY-MM-DD');
  
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

--------------
PARTITION START  | PARTITION STOP
-----------------|---------------
202301           | 202302
728x90
반응형

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

[mysql] 복합 인덱스와 explain  (0) 2025.02.03
[mysql] order by null  (0) 2024.12.19
비관락/낙관락 쓰기락/읽기락 베타락/공유락  (1) 2024.11.09
2 Phase Lock & mysql -> MVCC  (3) 2024.11.06
[분산] mysql 네임드락  (0) 2024.11.01
반응형

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

원리인가, 구현인가?

  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
반응형

+ Recent posts