ABOUT ME

-

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

    참고

    반응형

    댓글

Designed by Tistory.