[Spring 실습] 트랜잭션 전파 속성 테스트

Jiwoo Jung·2024년 11월 23일
0

GDGoC Spring 스터디

목록 보기
12/15

특정 비즈니스 로직을 위한 사용자 정의 롤백 규칙을 가진 트랜잭션 관리 통합

지난주 과제에선 jpa를 써서 jdbc template을 쓰는 코드로 수정했다.

schema.sql

CREATE TABLE IF NOT EXISTS member (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL
);

jdbc는 jpa와 달리 자동으로 데이터베이스 설정을 해주지 않는다. resources폴더에 schema.sql 파일을 만들어준다.

TransactionConfig

package com.example.demo.config;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {

    @Bean
    public PlatformTransactionManager txManager(DataSource dataSource){
        return new DataSourceTransactionManager(dataSource);
    } // spring boot의 경우에는 자동으로 설정되어있어서 따로 설정 필요 없음.
}

@EnableTransactionManagement

Member

package com.example.demo.domain;

public class Member{
    private Long id;
    private String username;

    // Constructor
    // Getters and Setters
}

MemberRepository

package com.example.demo.repository;

import com.example.demo.domain.Member;

import java.util.List;
import java.util.Optional;

public interface MemberRepository{
    List<Member> findAll();
    Optional<Member> findById(Long id);
    Optional<Member> findByUsername(String username);
    void deleteById(Long id);
    void save(Member member);
}

//public interface MemberRepository extends JpaRepository<Member, Long> { }

MemberService

package com.example.demo.service;

import org.springframework.stereotype.Service;
import com.example.demo.domain.Member;
import com.example.demo.repository.MemberRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public List<Member> getAllMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> getMemberById(Long id) {
        return memberRepository.findById(id);
    }

    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public Member join(Member member) {
        if ("error".equals(member.getUsername())) {
            throw new IllegalArgumentException("username == error");
        }
        memberRepository.save(member);
        return member;
    }

    public Optional<Member> update(Long id, Member updatedMember) {
        Optional<Member> memberOptional = getMemberById(id);
        memberOptional.ifPresent(member -> member.setUsername(updatedMember.getUsername()));
        return memberOptional;
    }


    public void deleteById(Long id) {
        getMemberById(id); // id 존재 여부 확인하면서 예외 처리
        memberRepository.deleteById(id);
    }
}

join() 메서드의 전파 속성은 REQUIRED로 설정한다. 테스트를 위해 username이 "error"이면 예외가 발생하게 한다.

MemberServiceRequiredNew

package com.example.demo.service;

import com.example.demo.domain.Member;
import com.example.demo.repository.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberServiceRequiredNew {

    private final MemberRepository memberRepository;

    public MemberServiceRequiredNew(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void joinNew(Member member) {
        if ("error".equals(member.getUsername())) {
            throw new IllegalArgumentException("username == error");
        }
        memberRepository.save(member);
    }
}

전파 속성이 REQUIRES_NEW일 때를 테스트하기 위해 Service 클래스를 새로 만들어줬다.

MemberDao

package com.example.demo.repository;

import com.example.demo.domain.Member;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;

class MemberRowMapper implements RowMapper<Member>{
    @Override
    public Member mapRow(ResultSet rs, int rowNum) throws SQLException{
        Member member = new Member();
        member.setId(rs.getLong("id"));
        member.setUsername(rs.getString("username"));

        return member;
    }
}

@Repository
public class MemberDao implements MemberRepository{
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Override
    public List<Member> findAll() {
        String sql = "SELECT * FROM member";
        return jdbcTemplate.query(sql, new MemberRowMapper());
    }

    @Override
    public Optional<Member> findById(Long id) {
        String sql = "SELECT * FROM member WHERE id = ?";
        try {
            return Optional.ofNullable(jdbcTemplate.queryForObject(sql, new MemberRowMapper(), id));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    @Override
    public Optional<Member> findByUsername(String username) {
        String sql = "SELECT * FROM member WHERE username = ?";
        try {
            return Optional.ofNullable(
                    jdbcTemplate.queryForObject(sql, new MemberRowMapper(), username)
            );
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty(); // null 대신 Optional.empty() 반환
        }
    }


    @Override
    public void deleteById(Long id) {
        String sql = "DELETE FROM member WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    @Override
    public void save(Member member) {
        String sql = "INSERT INTO member (username) VALUES (?)";
        try {
            jdbcTemplate.update(sql, member.getUsername());
        } catch (Exception e) {
            throw new RuntimeException("Failed to save member: " + member.getUsername(), e);
        }
    }

    public void clearDb(){
        String sql = "DELETE FROM member";
        jdbcTemplate.update(sql);
    }

}

RowMapper를 상속하여 데이터를 객체로 변환하는 클래스를 정의했다.
JdbcTemplate을 이용하여 MemberRepository의 클래스를 구현했다.


테스트 코드

TestMemberData

package com.example.demo.service;

import com.example.demo.domain.Member;
import com.example.demo.repository.MemberDao;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertTrue;


@SpringBootTest
public class TestMemberData {
    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberServiceRequiredNew memberServiceRequiredNew;

    @Autowired
    private MemberDao memberRepository;

    @AfterEach
    public void afterEach(){
        memberRepository.clearDb();
    }

    @Test
    public void requiredTest() {
        // Given
        Member member1 = new Member("user1");
        Member member2 = new Member("error"); // 오류를 유발할 사용자

        try {
            // 트랜잭션이 시작된 상태에서 두 번의 join 호출
            memberService.join(member1); // 트랜잭션에 참여
            memberService.join(member2); // 예외 발생 → 전체 트랜잭션 롤백
        } catch (Exception e) {
            e.printStackTrace();
        }

        // Then
        // 트랜잭션 롤백 여부 확인
        boolean isUser1Present = memberRepository.findByUsername("user1").isPresent();
        boolean isErrorPresent = memberRepository.findByUsername("error").isPresent();
        System.out.println("MemberService is proxied: " + AopUtils.isAopProxy(memberService));

        System.out.println("User1 present after rollback: " + isUser1Present);
        System.out.println("Error present after rollback: " + isErrorPresent);

        assertTrue(memberRepository.findByUsername("user1").isEmpty(), "User1 should be rolled back");
        assertTrue(memberRepository.findByUsername("error").isEmpty(), "Error should be rolled back");
    }

    @Test
    public void requiredNewTest() {
        // Given
        Member member1 = new Member();
        member1.setUsername("user2");

        Member member2 = new Member();
        member2.setUsername("error");

        try {
            memberServiceRequiredNew.joinNew(member1);
            memberServiceRequiredNew.joinNew(member2);

        } catch (Exception e) {
            e.printStackTrace();
        }

        // Then
        // member2는 롤백되었지만, member1은 커밋되어야 함
        assertTrue(memberRepository.findByUsername("user2").isPresent());
        assertTrue(memberRepository.findByUsername("error").isEmpty());
    }


}
  • requiredTest()
    member2를 join할 때 예외가 발생해서 전체 트랜잭션이 롤백될 것이다. 따라서 member1과 member2 모두 memberRepository에 저장되지 않았을 것이다.

  • requireNewTest()
    member2를 join할 때 예외가 발생한다. 하지만 트랜잭션 속성이 REQUIRES_NEW이기 때문에 member1의 join의 트랜잭션이 아닌 새로운 트랜잭션이 롤백된다. 결과적으로 member1은 커밋되고, member2는 롤백된다.


테스트 결과


requredTest() 테스트가 실패했다. 디버그를 위해서 로그도 확인하고 프록시 객체인지 확인해봤다.

MemberService is proxied: true
User1 present after rollback: true
Error present after rollback: false

이유를 모르겠지만 전체 롤백이 되지 않았다... 좀 더 찾아보고 고쳐야겠다..

0개의 댓글