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

환경: java17, spring boot 3.1.5, spring batch 5.0.3, mysql5.7

 

이슈:

아래 에러가 간헐적으로 발생하며 배치 실패. 재실행 시 정상 처리

Caused by: com.mysql.cj.jdbc.MysqlXAException: XAER_DUPID: The XID already exists
	at com.mysql.cj.jdbc.MysqlXAConnection.mapXAExceptionFromSQLException(MysqlXAConnection.java:344)
	at com.mysql.cj.jdbc.MysqlXAConnection.dispatchCommand(MysqlXAConnection.java:329)
	at com.mysql.cj.jdbc.MysqlXAConnection.start(MysqlXAConnection.java:290)
	at com.atomikos.datasource.xa.XAResourceTransaction.resume(XAResourceTransaction.java:217)
	... 81 common frames omitted
Caused by: java.sql.SQLException: XAER_DUPID: The XID already exists
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:130)
	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
	at com.mysql.cj.jdbc.StatementImpl.executeInternal(StatementImpl.java:763)
	at com.mysql.cj.jdbc.StatementImpl.execute(StatementImpl.java:648)
	at com.mysql.cj.jdbc.MysqlXAConnection.dispatchCommand(MysqlXAConnection.java:323)

디비에서 xa recover; 로 검색 시 남아있는 트랜젝션 없는 것 확인

 

해결:

XID 중복이라 우선 XID가 어떻게 생성되는지 확인

XID: <gtrid>:<bqual>
  • gtrid (Global Transaction ID): 분산 트랜잭션을 식별하는 고유한 값
  • bqual (Branch Qualifier): 트랜잭션 내에서 개별 브랜치를 구분하는 값

16진수 → ASCII 디코딩
제공된 XID는 16진수(Hex)로 인코딩 되어 있음. 이를 ASCII 문자로 변환해야 함

XID: 172.18.0.3.tm174046320874700011:172.18.0.3.tm11
XID: <IP>:tm<TIMESTAMP>:<BRANCH_ID>

gtrid = 172.18.0.3.tm174046320874700011
bqual = 172.18.0.3.tm11

  • 172.18.0.3 → 트랜잭션을 실행한 클라이언트 서버의 IP(여기서는 배치 서버)
  • tm174046320874700011 → 글로벌 트랜잭션 ID (gtrid)
    • 174046320874700011  타임스탬프 기반의 유니크 ID
  • tm11 → 브랜치 ID (bqual)

 

XID에 배치 서버 ip와 timestamp가 들어가므로 해당 서버에서 그 시간에 동시에 돌았던 'jta transaction'과 연관이 있었을 것으로 생각..

그걸 확인하기 위해 xid에 프로젝트 명이나 job 이름을 넣어보기로 했다.

상수 값도 줄 수 있고 아래처럼 spel을 사용하여 동적으로 줄 수도 있다.

spring.jta.atomikos.properties.transaction-manager-unique-name=${TRANSACTION_MANAGER_NAME:defaultTxManager}

 

  • 설정 대상: Atomikos 전체 트랜잭션 관리자
  • 역할: 트랜잭션 관리자(Transaction Manager)의 고유한 이름 지정
  • 사용 위치: 전역적으로 하나만 설정 (애플리케이션 전체에서 단 하나)
  • 중복되면 안 됨: 동일 네트워크 내 여러 인스턴스에서 서로 다른 값 필요

 

우선 해당 시간에 돈 배치 중 jta transaction 을 사용하는 배치는 이 배치 밖에 없을 것 같고

그래서 중복이 나도 한 프로젝트 안에서, 특히 이 job 안에서 발생했을 것 같긴 한데

우선 그걸 확증하기 위해 위와 같은 설정을 추가해 본다.

그래도 나면 그땐 진짜 우리끼리의 싸움

+ 그때는 XID에 UUID를 심을 수 있는 빈을 위 설정에 연결하는 게 좋을 것 같다.


추가로 

spring.jta.atomikos.properties.transaction-manager-unique-name=${TRANSACTION_MANAGER_NAME:defaultTxManager}

이 설정 값과 소스의 아래 부분이 동일한 설정이라고 생각했는데.. 그것은 아니었다..

AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
dataSource.setUniqueResourceName(resourceNameCreator.createResourceName(BATCH_DATASOURCE_NAME));

 

  • 설정 대상: 특정 DataSource(XA 리소스)
  • 역할: 각 XA 리소스를 식별하는 고유한 이름(Unique Name) 지정
  • 사용 위치: 각각의 AtomikosDataSourceBean 객체에 대해 개별적으로 설정
  • 중복되면 안 됨: 각 DataSource마다 고유해야 함 (여러 개의 XA 리소스를 사용할 경우 필수)

 

 

AtomikosDataSourceBean은 데이터 소스에 해당하는 고유 이름을 주는 것이고 

transaction-manager-unique-name는 atomikos transaction manager의 고유한 이름을 지정하는 것이었다..

728x90
반응형
반응형

환경: springboot3.4, java17

 

최신 버전의 스프링부트를 쓰면 어느 새부터 아래와 같은 워닝을 만나는데 상당히 신경 쓰인다. 그동안 Page 인터페이스를 아주 많이 사용했던 터라 혹시 안되거나 deprecated 된다면 난감하기 때문이다..

찾아보니 springboot3.3부터 변경되었다고 한다!

2024-12-12 13:57:23 WARN  [ration$PageModule$WarningLoggingModifier.changeProperties    : 156] Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
	For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
	or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables.

내용은 직렬화 시 안정적이지 않으니 Spring Data의 PagedModel 또는 PagedResourcesAssembler를 사용하여 안정적인 JSON 구조를 생성하라는 것이다.

그동안 직렬화할 때 PageImpl을 직접 직렬화하였는데, 더이상 안정적인 방식이 아니니 아래 두 방식 중 하나를 고르라는 뜻

  • HATEOAS를 쓰면 PagedResourcesAssembler, 그렇지 않으면  PagedModel

프로젝트 전역으로 설정하는 방식은 아래와 같다.

@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)

https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables

PageSerializationMode 에는 아래와 같이 두 가지가 있다.

DIRECT

PageImpl 객체를 직접 JSON으로 직렬화함

  • 직렬화된 JSON 구조는 PageImpl의 내부 구조에 따라 다를 수 있음
  • JSON 구조가 API의 변경이나 버전 업그레이드에 따라 변할 수 있으므로, 안정성이 떨어질 수 있음
  • 이 모드는 이전 버전의 Spring Data에서 기본적으로 사용되던 방식

DTO

DTO(Data Transfer Object)를 사용하여 페이지 데이터를 직렬화

  • 안정적이고 일관된 JSON 구조를 제공
  • DTO를 사용함으로써 페이지 데이터의 구조가 API 변경에 영향을 덜 받음
  • 이 모드는 PagedModel 또는 PagedResourcesAssembler와 함께 사용되며, 이를 통해 클라이언트가 예측 가능한 형식의 데이터를 수신할 수 있다.

 

설정하고 기존과 똑같이 페이징하면 된다.

@GetMapping
public Page<BaseResponse> getPrices(

참고로 HATEOAS

HATEOAS는 REST API 설계의 원칙 중 하나로, 클라이언트가 서버 응답에 포함된 하이퍼미디어(hypermedia)를 통해 애플리케이션 상태를 동적으로 탐색할 수 있도록 하는 방식이다. 이 원칙은 REST의 자기 설명(self-descriptive) 특성을 강화한다

HATEOAS의 구성 요소

  • 링크(Link): API 응답에 포함된 URL. 다음 가능한 액션을 안내.
  • 상태(State): 현재 리소스의 상태.
  • 동작(Action): 링크를 따라가면 수행할 수 있는 작업.

HATEOAS의 사용 이유

  • API 탐색성 증가:
    • 클라이언트는 추가적인 문서 없이 서버 응답에 포함된 링크를 통해 어떤 작업이 가능한지 동적으로 파악할 수 있음
  • 클라이언트-서버 결합도 감소:
    • 클라이언트는 서버가 제공하는 링크를 따라가기 때문에 특정 엔드포인트에 강하게 의존하지 않음
  • 유연한 확장성:
    • 서버에서 새로운 액션이나 엔드포인트를 추가하더라도, 클라이언트는 변경 없이 새로운 기능을 사용할 수 있음
  • 자기 설명적 API:
    • 서버 응답에 포함된 하이퍼미디어가 클라이언트에게 리소스 상태 및 가능한 작업을 설명하므로 API의 문서화와 유지보수가 용이
728x90
반응형
반응형

환경: springboot3.3

url의 path variable로 enum을 받을 경우 아래와 같이 한 번에 받을 수 있지만, enum이 대문자로 관리될 경우, url에 대소문자가 혼용되게 된다.

이 경우 localhost:8080/AGAME/member-no 이렇게 된다(이는 StringToEnumConverterFactory 때문이다).

대소문자가 혼용되는 게 싫어서 아래와 같이 변환을 해서 검증을 하곤 하는데, 이게 또 계속 반복되는 문제점이 생긴다.

path variable의 값을 string의 코드로 보내도 자동으로 enum으로 변환할 수 없을까?!

 

HandlerMethodArgumentResolver 사용해 보기

resolver? Spring MVC에서 HTTP 요청 데이터를 Controller의 메서드 매개변수로 바인딩함

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

public class GamePathVariableResolver implements HandlerMethodArgumentResolver {

 @Override
  public boolean supportsParameter(MethodParameter parameter) {
    boolean hasAnnotation = parameter.hasParameterAnnotation(PathVariable.class);
    boolean usedEnum = GameType.class.isAssignableFrom(parameter.getParameterType());
    return hasAnnotation && usedEnum;
  }

  @Override
  public Object resolveArgument(MethodParameter parameter,
      ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) throws Exception {
      
    String value = (String) delegate.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
    return GameType.findByPath(value);
  }
}
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new GamePathVariableResolver());
    }
}

근데? Resolver를 타지 않음

resolver가 스프링에 등록된 것은 확인했다.

그런데 url을 String game으로 호출하면 올바른 enum값이 아니라는 에러가 난다.

org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Failed to convert value of type 'java.lang.String' to required type

GamePathResolver에 가보지도 못한 건가? 그럼 호출 순서는 StringToEnumConverterFactory -> GamePathResolver??

 

우선 방식을 파악하기 위해 enum값으로 호출하고 resolver에 디버그를 걸어 호출 시 resolver를 타는지 확인해 본다.

그런데 resolver를 아애 타지 않는다..? 디버깅이 안 걸린다.

조금 더 다양한 방법으로 테스트해 본다.

  • 아래와 같이 PathVariable도 아니고 RequestParam도 아닌 일반 객체가 있을 때는 탄다.
  • 근데 잘 타다가도 아래 memberNo에 어노테이션(RequestParam 나 ModelAttribute)을 걸면 또 안탄다.
  • 디버깅을 걸어 MethodParam의 값을 확인해보면 어노테이션 PathVariable로 선언된 GameType이 없다....
@GetMapping("/{game}")
public ResponseEntity<?> checkGameFallbackToInGame(@PathVariable GameType game, String memberNo) {

왜?

먼저 등록된 기본 PathVariableMethodArgumentResolver가 처리할 수 있는 파라미터를 모두 소화하기 때문에 커스텀 리졸버가 호출되지 않을 수 있다... 흐름은 아래 참고..

  • 어노테이션이 있는 매개변수는 기본적으로 Spring의 내장 리졸버에 의해 처리됨
    • RequestParamMethodArgumentResolver는 @RequestParam을 처리.
    • PathVariableMethodArgumentResolver는 @PathVariable을 처리.
    • ModelAttributeMethodProcessor는 @ModelAttribute
  • 어노테이션이 없는 경우 아래의 룰로 기본 매핑된다. 
    • 단순 타입(String, int, boolean 등): 단순한 데이터 타입이면 @RequestParam으로 간주
      • RequestParamMethodArgumentResolver
    • 객체 타입 (사용자 정의 객체): 객체 타입이면 @ModelAttribute로 간주
      • ModelAttributeMethodProcessor
    • 그 외의 룰은 커스텀 어노테이션을 개발하여 매핑할 수 있음

궁금한 사항..

근데! WebMvcConfigurer에 커스텀 리졸버가 등록되면, Spring의 기본 리졸버보다 우선 적용되어 커스텀 리졸버에서 기존 동작을 완전히 덮어쓴다고 한다(그래서 확장하거나 위임해야 한다). 근데 아래와 같이 해보니 그냥 실행 자체가 안된다.. 왜?!

우선 PathVariableMethodArgumentResolver 자체를 override 할 수 있는지 궁금해서 임시로라도 해보려고 했는데 그저 안된다.. 

public class GamePathResolver implements HandlerMethodArgumentResolver {

  PathVariableMethodArgumentResolver resolver;

  public GamePathResolver() {
    this.resolver = new PathVariableMethodArgumentResolver(); // 기본 구현 생성
  }

  @Override
  public boolean supportsParameter(MethodParameter parameter) {
    if (resolver.supportsParameter(parameter)) {
      PathVariable path = parameter.getParameterAnnotation(PathVariable.class);
      return "game".equals(path.value());
    }
    return false;
  }

  @Override
  public Object resolveArgument(MethodParameter parameter,
      ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) throws Exception {
    // 기본 처리 로직 호출
    Object resolvedValue = resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
    return GameType.findByPath((String) resolvedValue);
  }
}

그럼 어떻게 해결? ConverterFactory 구현

우선 시간이 없어서 위 resolver 방법은 잠시 중단하고, 커스텀 어노테이션을 이용하여 컨버터와 연결하였다.

public class GamePathConverter implements ConditionalGenericConverter {

  @Override
  public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
    return targetType.hasAnnotation(ConvertGamePath.class);
  }

  @Override
  public Set<ConvertiblePair> getConvertibleTypes() {
    return Set.of(new ConvertiblePair(String.class, GameType.class));
  }

  @Override
  public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
    return GameType.findByPath((String) source);
  }
}
 
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

...

  @Override
  public void addFormatters(FormatterRegistry registry) {
    registry.addConverter(new GamePathConverter());
  }
}

그러면 enum의 코드(string)로 보내도 enum으로 바로 가져올 수 있다.


참고로 포매터와 컨버터는 비슷하지만 아래와 같은 용어적 차이가 있다.

리졸버랑 컨버터는.. 여기서는 사실 두 개의 짬뽕을 하고 싶었고 그걸 리졸버에서 구현하고 싶었는데 안돼서 컨버터를 달아버린..


 

why not AOP?

1. AOP로 파라미터 값을 직접 변경 불가

  • Java의 AOP는 메서드 실행 전에 메서드 파라미터 값을 직접 변경할 수 없음

2. string으로 game argument관리하여 실수 잦음 + 순서도 중요..

@Before("@annotation(org.springframework.web.bind.annotation.PathVariable) && args(game,..)")
  • @PathVariable로 선언된 메서드 파라미터를 가지는 메서드 중, 첫 번째 파라미터 이름이 game인 경우를 대상으로 함..
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
반응형
반응형

환경: 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
반응형
반응형

 

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