@ControllerAdvice
public class GlobalControllerAdvice {
@InitBinder
public void initBinder(WebDataBinder binder) {
//form-data, application/x-www-form-urlencoded에 적용
//POST 요청에서 setter가 없어도 되게끔
binder.initDirectFieldAccess(); // 컨트롤러 실행 전 바인딩 설정
}
@ModelAttribute
public void addGlobalAttributes(Model model) {
model.addAttribute("appName", "MyApp"); // 모든 뷰에 공통 속성 추가
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAll(Exception ex) {
return ResponseEntity.status(500).body("서버 에러 발생");
}
}
순서
요청 수신 ↓ @InitBinder ↓ @ModelAttribute ↓ @RequestMapping (실제 컨트롤러 메서드 실행) ↓ @ExceptionHandler (예외 발생 시)
참고로
binder.initDirectFieldAccess();
@InitBinder는 주로 form-data, application/x-www-form-urlencoded에 적용된다.
JSON 요청이라면 @InitBinder는 영향이 없음
@RequestBody로 받는 JSON 요청은WebDataBinder가 아니라 Jackson이 처리
기존과 같이 new PageImpl()을 이용하여 Page<T> 객체를 내리는데 아래와 같은 에러(warn)가 발생한다.
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))
에러 발생 이유
Spring Boot 3.x
내부적으로 사용하는:
Spring Data Commons 3.x (2023.0.x 이상)
Spring Framework 6.x
이 조합에서 PageImpl을 그대로 @RestController에서 반환하면 Jackson 직렬화 구조가 불안정하다는 경고가 발생한다.
Spring 팀은 PageImpl<T>의 직렬화가 다음 문제를 가진다고 판단했다:
Jackson 직렬화 시 필드 순서나 구조가 변경될 수 있음
필드 이름이 내부 구현에 의존
API 스펙을 안정적으로 유지하기 어려움
그래서 Spring Data 팀이 공식적으로 PagedModel<T> 또는 DTO 변환을 권장하게 됨.
해결 방법
1. hate oas 를 사용하는 경우
PagedModel<T> 사용
return pagedResourcesAssembler.toModel(page);
: 결과가 PagedModel<EntityModel<T>> 형태가 되어 안정적인 JSON 구조를 보장
2. hate oas 사용 안하는 경우
1) 설정 추가로 해결
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO)
public class WebConfig {
}
2) 직접 커스텀 DTO를 만들어서 사용
public class PageResponse<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
public PageResponse(List<T> content, int page, int size, long totalElements) {
this.content = content;
this.page = page;
this.size = size;
this.totalElements = totalElements;
}
// getters, setters
}
환경: 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 문자로 변환해야 함
최신 버전의 스프링부트를 쓰면 어느 새부터 아래와 같은 워닝을 만나는데 상당히 신경 쓰인다. 그동안 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
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의 문서화와 유지보수가 용이
기존에 멀티 데이터베이스를 쓸 때 분산 트랜젝션을 위해 아래와 같이 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 도 단일 트랜젝션 관리 가능한가?
JTA 트랜잭션 관리의 범위:
JtaTransactionManager는 분산 트랜잭션(XA 트랜잭션)을 관리하는 데 최적화되어 있지만, 단일 데이터 소스에서의 트랜잭션도 처리할 수 있음
단일 데이터 소스만 사용할 경우에도 JTA 프로토콜을 통해 트랜잭션이 시작되고 종료됨
단일 트랜잭션 시 처리 동작:
단일 데이터 소스에서 JtaTransactionManager는 해당 데이터 소스에서의 트랜잭션을 관리함
단일 데이터 소스 환경에서는 JpaTransactionManager나 DataSourceTransactionManager처럼 작동함
단일 트랜잭션 환경에서는 JpaTransactionManager나 DataSourceTransactionManager가 더 효율적일 수 있다. 이는 JTA 오버헤드가 없기 때문
분산 트랜잭션이 필요 없는 경우 굳이 JtaTransactionManager를 사용할 필요는 없음
참고:
JpaTransactionManager는 JPA에 특화되어 있으며, 트랜잭션이 하나의 데이터베이스일 경우에 적합
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단계: Prepare
트랜잭션 관리자(Transaction Manager)는 모든 자원 관리자(XAResource)에 "Prepare" 메시지를 보냄
각 자원 관리자는 트랜잭션을 준비하고, 성공 여부를 반환(예: VoteCommit 또는 VoteRollback)
2단계: Commit or Rollback
모든 자원이 VoteCommit을 반환하면 트랜잭션 관리자는 "Commit" 메시지를 보내 트랜잭션을 커밋
하나라도 VoteRollback을 반환하면 모든 자원에 "Rollback" 메시지를 보내 트랜잭션을 롤백
JTA (Java Transaction API)
javax.transaction 패키지
UserTransaction, TransactionManager, XAResource 같은 인터페이스를 제공
트랜잭션을 시작/종료하는 표준 방식 정의
스프링, Java EE, Jakarta EE에서 트랜잭션을 추상화할 때 사용
단독으로 동작 X → 구현체가 필요함
예: Atomikos, Bitronix, Narayana 등이 JTA를 구현
Atomikos
JTA 구현체이자 분산 트랜잭션 관리 라이브러리
다중 데이터소스 (예: MySQL + Oracle), 메시지 브로커 (Kafka, JMS) 등 여러 리소스에 걸친 트랜잭션을 지원
XA 프로토콜 기반의 **2PC (Two-Phase Commit)**을 수행
트랜잭션 복구, 타임아웃, 로그 등 고급 기능 포함
Spring Boot나 Spring에서 사용하려면 AtomikosJtaPlatform, AtomikosDataSourceBean 설정 필요
즉,
JTA = 인터페이스
Atomikos = 구현체 (라이브러리)
스프링에서는 JtaTransactionManager를 사용하고, 실제 구현체로 Atomikos를 등록해 줌
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()과 같은 메서드를 제공하여 더 고급 기능을 사용할 수 있다.
@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를 설정하기