Spring

Spring Multi Datasource(다중 데이터소스)를 활용한 Service 구현

신나게개발썰 2022. 3. 22. 09:37
반응형

지난 블로그 게시글인

관련해서 다중 데이터소스 구성 후 정상적인 트랜잭션 사용법에 대한 글을 작성하려고 합니다.

다중 데이터소스 구성은 단일 데이터소스 일때와 다르게 @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 브랜치에서 확인하실 수 있으시고 짧으면서 긴 글을 마지막까지 읽어주신 분들께 감사드리며 하시는 모든 개발에 잦은 실패(로 쓰고 경험이라 읽는...)와 성공 하시길 기도드리겠습니다. 감사합니다.

참고

반응형