지난 블로그 게시글인
관련해서 다중 데이터소스 구성 후 정상적인 트랜잭션 사용법에 대한 글을 작성하려고 합니다.
다중 데이터소스 구성은 단일 데이터소스 일때와 다르게 @Transactional
어노테이션에 다양한 속성(?)을 사용하여야 합니다. 만약 그러지 않을 경우 원하지 않는 트랜잭션을 사용하게 됩니다.
이에 @Transactional
설정에 따른 테스트와 올바르게(?) 사용하는 방법 중 한가지를 공유 합니다.
DB 구성과 다중 데이터소스 설정은 위 제가 작성한 블로그 글을 토대로 되어 있으니 별도로 확인해보고자 하신다면 위 블로그 글들을 차례대로 따라하셔서 구축 혹은 설정을 하시면 되겠습니다.
무슨 문제?
다중 데이터소스를 이용한 트랜잭션 사용 시 문제가 될 수 있는 부분은
저장/수정/삭제(그외 다른 상황) 메서드에서 조회 메서드를 호출
- 정확히는 master 트랜잭션 메서드에서 slave 트랜잭션 메서드를 호출 할 때.
하는 경우에 발생 됩니다. 다중 데이터소스로 구성하였기 때문에 저장/수정/삭제 메서드는 master로 조회는 slave를 사용해야 하는데 별다른 설정 없이 @Transactional
를 사용하면 모든 트랜잭션이 master를 사용하게 됩니다. 이는 다중 데이터소스 구성을 무색케 하는 문제 입니다.
테스트 항목
먼저 들어가기 앞서 테스트 항목은
같은 클래스 내에 트랜잭션 서비스 메서드
@Transactional
속성에 아무런 설정 안함
같은 클래스 내에 트랜잭션 서비스 메서드
@Transactional
에 readOnly(true) 속성 지정
같은 클래스 내에 트랜잭션 서비스 메서드
@Transactional
에 readOnly(true) 및 propagation(Propagation.REQUIRES_NEW) 속성 지정
트랜잭션 서비스 메서드를 클래스로 분리
등으로 되어 있습니다.
여기서 다시 한번 말씀드리자면 master, slave 트랜잭션 구분은 readOnly가 true인 경우slave. false면 master로 구성되어 있음을 알려드립니다.
단일 데이터소스일땐 큰 이유가 있지 않는 한 readOnly와 propagation 속성을 설정할 일이 거의 없지만 다중 데이터소스인 경우에는 위 2개의 속성을 다양하게 설정해야 하는 상황이 발생 됩니다.
propagation엔 다양한 전파설정이 있지만 REQUIRES_NEW
전파 하나만 테스트 하였습니다. 테스트할만한 전파 설정이 NESTED
가 있지만 해당 부분은 제외 하였고 그 외 전파 설정은 다중 데이터소스 트랜잭션 설정과 거리가 있다 판단하여 제외 하였습니다.
만약 테스트를 해보고 싶은 분들은 본 블로그 게시글에 있는 예제 프로젝트를 활용하시면 되겠습니다.
예제코드
예제코드는 크게 실패와 성공 Class로 나눠져있습니다. 그럼 각 클래스 구조를 살펴보겠습니다.
여기서 주목할 부분이 save
메서드 입니다. 예제를 위해
- 회원가입 시 ID 존재 여부 판단 후 회원가입
를 수행하는 메서드입니다. 이때 ID 존재 여부를 위해 읽기(조회) 메서드를 호출하는데 이 부분에서 정상이라면 slave 트랜잭션이 발생되어야 합니다.
실패 예제 클래스
구성은 위 클래스다이어그램과 같이 되어 있습니다.
각 실패 예제 클래스로는
MemberNomalFailExampleService
구현체@Transactional
속성을 설정하지 않는 실패 예제 클래스
MemberReadOnlyFailExampleService
구현체@Transactional
속성 중 readOnly 설정만 한 실패 예제 클래스
MemberPropagationFailExampleService
구현체@Transactional
속성 중 readOnly /propagation 설정한 실패 예제 클래스
입니다.
MemberFailExampleServiceAbs
추상클래스는 구현체에서 @Transactional
설정만 명확하게 보기 위해 각 메서드들의 행위를 구현해둔 추상클래스 입니다.
MemberFailExampleServiceAbs
추상클래스 구현은 아래와 같습니다.
/**
* {@link MemberFailExampleService} 추상 클래스
*
* 실패 예제에 사용되는 행위들을 구현.
* 실제 구현체에서는 트랜잭션 어노테이션만 활용하기 위해 생성
*/
public abstract class MemberFailExampleServiceAbs implements MemberFailExampleService {
private final MemberWrite memberWriteMapper;
private final MemberRead memberReadMapper;
public MemberFailExampleServiceAbs(MemberWrite memberWriteMapper, MemberRead memberReadMapper) {
this.memberWriteMapper = memberWriteMapper;
this.memberReadMapper = memberReadMapper;
}
/**
* {@link MemberWriteService#save(Member)}
*/
@Override
public void save(Member member) {
// 해당 메서드 호출 시 정상적인 설정이라면 slave 트랜잭션이 발생 되어야 한다.
Member findMember = Optional.ofNullable(this.findId(member.getId()))
.orElseGet(Member::defaultObj);
Optional.ofNullable(findMember.getId())
.filter(""::equals)
.orElseThrow(() -> new IllegalStateException("존재하는 회원 ID 입니다."));
this.memberWriteMapper.save(member);
}
/**
* {@link MemberReadService#findId(String)}
*/
@Override
public Member findId(String id) {
return this.memberReadMapper.findId(id);
}
}
성공 예제 클래스
성공 예제는 읽기(조회) 와 저장/수정/삭제 메서드를 클래스로 분리한 구조 입니다.
읽기(조회)클래스
읽기 클래스인 MemberSuccessExampleReadService
구현체 클래스다이어그램은 위 사진 참고 및 코드는 아래를 참고하시면 되겠습니다.
/**
* <pre>
* {@link MemberReadService} 구현체
* </pre>
*/
@Service
public class MemberSuccessExampleReadService implements MemberReadService {
private final MemberRead memberReadMapper;
public MemberSuccessExampleReadService(MemberRead memberReadMapper) {
this.memberReadMapper = memberReadMapper;
}
/**
* {@link MemberReadService#findId(String)}
*/
@Override
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public Member findId(String id) {
return this.memberReadMapper.findId(id);
}
}
저장/수정/삭제 클래스
저장/수정/삭제 클래스인 MemberSuccessExampleWriteService
구현체는 읽기(조회)를 내부 호출이 아닌 MemberReadService
인터페이스의 구현체를 의존해서 사용하게 됩니다.
/**
* {@link MemberWriteService} 구현체
*
* 정상적으로 읽기는 slave로 저장/수정/삭제는 master 트랜잭션을 사용하는 예제 클래스
*/
@Service
public class MemberSuccessExampleWriteService implements MemberWriteService {
private final MemberWrite memberWriteMapper;
private final MemberReadService memberSuccessExampleReadService;
public MemberSuccessExampleWriteService(MemberWrite memberWriteMapper
, MemberReadService memberSuccessExampleReadService) {
this.memberWriteMapper = memberWriteMapper;
this.memberSuccessExampleReadService = memberSuccessExampleReadService;
}
/**
* {@link MemberWriteService#save(Member)}
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public void save(Member member) {
// 해당 메서드 호출 시 정상적인 설정이라면 slave 트랜잭션이 발생 되어야 한다.
Member findMember = Optional.ofNullable(this.memberSuccessExampleReadService.findId(member.getId()))
.orElseGet(Member::defaultObj);
Optional.ofNullable(findMember.getId())
.filter(""::equals)
.orElseThrow(() -> new IllegalStateException("존재하는 회원 ID 입니다."));
this.memberWriteMapper.save(member);
}
}
테스트 코드
테스트 코드 중 일부분 공용으로 사용 되는 테스트 코드는 아래와 같으며 이후 글 작성에서는 거론하지 않도록 하겠습니다.
/**
* private Method
*/
private Member getMemberTest(MemberReadService memberReadService, String id) {
return Optional.ofNullable(memberReadService.findId(id))
.filter(v -> !"".equals(v.getId()))
.orElseThrow(() -> new IllegalStateException("존재하지 않는 회원 입니다."));
}
private void saveMemberTest(MemberWriteService memberWriteService, Member member) {
memberWriteService.save(member);
}
테스트 결과
같은 클래스 내에 트랜잭션 서비스 메서드
실패 예제인 트랜잭션 메서드가 같은 클래스에 구현되어 있고 구현되어 있는 트랜잭션 메서드들을 서로 호출할 경우 입니다.
@Transactional 속성에 아무런 설정 안함
테스트를 위한 클래스는 MemberNomalFailExampleService
구현체 입니다. @Transactional
어노테이션에 별다른 설정을 주지 않는 트랜잭션 메서드 입니다.
@Service
public class MemberNomalFailExampleService extends MemberFailExampleServiceAbs implements MemberFailExampleService {
public MemberNomalFailExampleService(MemberWrite memberWriteMapper, MemberRead memberReadMapper) {
super(memberWriteMapper, memberReadMapper);
}
/**
* {@link MemberFailExampleServiceAbs#save(Member)}
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public void save(Member member) {
super.save(member);
}
/**
* {@link MemberFailExampleServiceAbs#findId(String)}
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public Member findId(String id) {
return super.findId(id);
}
}
단순 조회
테스트 코드
@Test
void 회원조회_읽기_쓰기가_같은_클래스_내에_있는_실패_예제() {
final String id = "data_id_1";
Member member = this.getMemberTest(this.memberNomalFailExampleService, id);
Assertions.assertEquals(id, member.getId());
}
단순히 조회만 시도 했을 경우 입니다. 테스트 로그에서 isCurrentTransactionReadOnly
부분을 확인하면 전부 master 트랜잭션만 사용 했음을 알 수 있습니다.
2022-03-21 15:25:09.766 INFO 2596 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 15:25:10.145 INFO 2596 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.578 seconds (JVM running for 3.478)
2022-03-21 15:25:10.520 INFO 2596 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
저장
테스트 코드
@Test
void 회원가입성공_읽기_쓰기가_같은_클래스_내에_있는_실패_예제() {
Member member = new Member("data_id_4", "data_name_1", 40);
this.saveMemberTest(this.memberNomalFailExampleService, member);
}
테스트 로그에서 isCurrentTransactionReadOnly
를 확인하면 역시 전부 master 트랜잭션만 사용 하였습니다.
2022-03-21 15:26:56.091 INFO 23440 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 15:26:56.443 INFO 23440 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.518 seconds (JVM running for 3.37)
2022-03-21 15:26:56.783 INFO 23440 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
@Transactional에 readOnly(true) 속성 지정
테스트를 위한 클래스는 MemberReadOnlyFailExampleService
구현체 입니다. 읽기(조회)메서드(findId)의@Transactional
readOnly 속성를 true로 설정 하였습니다.
@Service
public class MemberReadOnlyFailExampleService extends MemberFailExampleServiceAbs implements MemberFailExampleService {
public MemberReadOnlyFailExampleService(MemberWrite memberWriteMapper, MemberRead memberReadMapper) {
super(memberWriteMapper, memberReadMapper);
}
/**
* {@link MemberFailExampleServiceAbs#save(Member)}
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public void save(Member member) {
super.save(member);
}
/**
* {@link MemberFailExampleServiceAbs#findId(String)}
*/
@Override
@Transactional(readOnly = true)
public Member findId(String id) {
return super.findId(id);
}
}
단순 조회
테스트 코드
@Test
void 회원조회_읽기_쓰기가_같은_클래스_내에_있는_실패_예제_readOnly() {
final String id = "data_id_1";
Member member = this.getMemberTest(this.memberReadOnlyFailExampleService, id);
Assertions.assertEquals(id, member.getId());
}
테스트 로그에서 isCurrentTransactionReadOnly
부분을 확인하면 읽기(조회) 메서드가 정상적으로 slave 트랜잭션을 사용했음을 알 수 있습니다.
2022-03-21 15:58:41.257 INFO 19804 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 15:58:41.599 INFO 19804 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.541 seconds (JVM running for 3.466)
2022-03-21 15:58:41.936 INFO 19804 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : slave
저장
테스트 코드
@Test
void 회원가입성공_읽기_쓰기가_같은_클래스_내에_있는_실패_예제_readOnly() {
Member member = new Member("data_id_4", "data_name_1", 40);
this.saveMemberTest(this.memberReadOnlyFailExampleService, member);
}
위 단순 조회 시에는 정상적으로 slave 트랜잭션을 사용하였지만 저장 시에는 slave가 아닌 master 트랜잭션이 사용 되었습니다.
2022-03-21 16:01:32.526 INFO 17020 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 16:01:32.911 INFO 17020 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.617 seconds (JVM running for 3.783)
2022-03-21 16:01:33.249 INFO 17020 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
@Transactional에 readOnly(true) 및 propagation(Propagation.REQUIRES_NEW) 속성 지정
테스트를 위한 클래스는 MemberPropagationFailExampleService
구현체 입니다. 이번에는 상위 트랜잭션과 무관하게 새로운 트랜잭션을 생성하는 REQUIRES_NEW
을 사용하였지만 실패한 예제 입니다.
@Service
public class MemberPropagationFailExampleService extends MemberFailExampleServiceAbs implements MemberFailExampleService {
public MemberPropagationFailExampleService(MemberWrite memberWriteMapper, MemberRead memberReadMapper) {
super(memberWriteMapper, memberReadMapper);
}
/**
* {@link MemberFailExampleServiceAbs#save(Member)}
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public void save(Member member) {
super.save(member);
}
/**
* {@link MemberFailExampleServiceAbs#findId(String)}
*/
@Override
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public Member findId(String id) {
return super.findId(id);
}
}
단순 조회
테스트 코드
@Test
void 회원조회_읽기_쓰기가_같은_클래스_내에_있는_실패_예제_Propagation() {
final String id = "data_id_1";
Member member = this.getMemberTest(this.memberPropagationFailExampleService, id);
Assertions.assertEquals(id, member.getId());
}
테스트 로그에서 isCurrentTransactionReadOnly
부분을 확인하면 읽기(조회) 메서드가 정상적으로 slave 트랜잭션을 사용했음을 알 수 있습니다.
2022-03-21 16:07:01.248 INFO 21072 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 16:07:01.586 INFO 21072 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.525 seconds (JVM running for 3.36)
2022-03-21 16:07:01.942 INFO 21072 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : slave
저장
테스트 코드
@Test
void 회원가입성공_읽기_쓰기가_같은_클래스_내에_있는_실패_예제_Propagation() {
Member member = new Member("data_id_4", "data_name_1", 40);
this.saveMemberTest(this.memberPropagationFailExampleService, member);
}
위 단순 조회 시에는 정상적으로 slave 트랜잭션을 사용하였지만 저장 시에는 slave가 아닌 master 트랜잭션이 사용 되었습니다.
2022-03-21 16:07:53.419 INFO 12032 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 16:07:53.771 INFO 12032 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.48 seconds (JVM running for 4.029)
2022-03-21 16:07:54.099 INFO 12032 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
위 테스트를 통한 결론은
읽기/저장/수정/삭제 메서드가 같은 클래스에 위치하고
readOnly(true) 및 propagation(Propagation.REQUIRES_NEW)인 트랜잭션 메서드의 외부 호출인 경우엔 정상적으로 slave 트랜잭션 사용
내부 호출인 경우엔 readOnly(true) 및 propagation(Propagation.REQUIRES_NEW)인 트랜잭션 메서드라 하더라도 무조건 master 트랜잭션 사용(상위 트랜잭션이 master인 경우)
트랜잭션 서비스 메서드를 클래스로 분리
해당 구조는 트랜잭션을 정상적으로 활용 할 수 있는 성공 예제 입니다. 물론 이보다 더 좋은 방법이 있을 수도 있겠지만 개인적으론 괜찮은 방법이라고 생각 합니다.
이유는
한 클래스 내에서 읽기/저장/수정/삭제 메서드들이 모여있는 구조 분리
구조 분리를 해야 하기 때문에 자연스럽게(?) 인터페이스 분리와 가벼운 설계
가벼워진 인터페이스 설계로 인해 다양한 인터페이스 생성이 가능해짐으로 다양한 입맛(?)의 구현체 생성 가능
등과 같은 이유라고 생각합니다. 라고 했지만 결합도를 낮출 수 있는 구조라고 생각 합니다.
코드는 위 예제코드 -> 성공 예제 클래스를 참고하시면 되겠습니다.
단순 조회
테스트 코드
@Test
void 회원조회_읽기_쓰기가_같은_클래스_내에_있는_성공_예제_class() {
final String id = "data_id_1";
Member member = this.getMemberTest(this.memberSuccessExampleReadService, id);
Assertions.assertEquals(id, member.getId());
}
테스트 로그에서 isCurrentTransactionReadOnly
부분을 확인하면 읽기(조회) 메서드가 정상적으로 slave 트랜잭션을 사용했음을 알 수 있습니다.
2022-03-21 16:52:24.496 INFO 24456 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 16:52:24.854 INFO 24456 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.503 seconds (JVM running for 3.317)
2022-03-21 16:52:25.211 INFO 24456 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : slave
저장
테스트 코드
@Test
void 회원가입성공_읽기_쓰기가_같은_클래스_내에_있는_성공_예제_class() {
Member member = new Member("data_id_4", "data_name_1", 40);
this.saveMemberTest(this.memberSuccessExampleWriteService, member);
}
테스트 로그에서 isCurrentTransactionReadOnly
부분을 확인하면 저장 시에도 정상적으로 slave 트랜잭션을 사용했음을 알 수 있습니다.
2022-03-21 16:54:15.728 INFO 27628 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : master
2022-03-21 16:54:16.087 INFO 27628 --- [ Test worker] c.datasource.DatasourceApplicationTests : Started DatasourceApplicationTests in 1.684 seconds (JVM running for 3.613)
2022-03-21 16:54:16.416 INFO 27628 --- [ Test worker] d.s.c.d.p.r.ReplicationRoutingDataSource : isCurrentTransactionReadOnly : slave
마치며
성공 예제가 꼭 정답이라곤 할 순 없습니다. 말씀 드리고 싶은 부분은
- 실무 환경에 따라 다양하게 readOnly/propagation 설정을 해야 할 수도 있다.
라는걸 말씀 드리고 싶네요.
그리고 대부분 구현 시에는
- 무언가 저장 하기 위해서 관련 정보를 조회 후 처리를 거친 다음 저장 (저장 영역에서 조회 행위)
의 흐름이 일반적인 흐름이기 때문에 위 성공 예제 처럼 읽기와 저장/수정/삭제를 클래스로 나누게 되면
Read 트랜잭션 메서드는
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
설정(특이사항이 없는 한 고정)Write 트랜잭션 메서드는
@Transactional(rollbackFor = {Exception.class})
설정(특이사항이 없는 한 고정)
와 같이 @Transactional
옵션을 반 고정 형태를 취할 수 있기 때문에 @Transactional
설정 고민에 대한 일정 부분을 해소 시킬수도 있다고 봅니다.
그럼 마지막으로 해당 예제 프로젝트는 GitHub - sungwookkim/spring-multi-datasource-service에 multi-datasource-service 브랜치에서 확인하실 수 있으시고 짧으면서 긴 글을 마지막까지 읽어주신 분들께 감사드리며 하시는 모든 개발에 잦은 실패(로 쓰고 경험이라 읽는...)와 성공 하시길 기도드리겠습니다. 감사합니다.
참고
Spring 트랜잭션 전파
다중 데이터소스 관련 테스트