특정 비즈니스 로직을 위한 사용자 정의 롤백 규칙을 가진 트랜잭션 관리 통합
지난주 과제에선 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
이유를 모르겠지만 전체 롤백이 되지 않았다... 좀 더 찾아보고 고쳐야겠다..