no image
[Spring DB] 트랜잭션 전파 활용
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 예제 프로젝트 더보기 Member @Entity @Getter @Setter public class Member { @Id @GeneratedValue private Long id; private String username; public Member() { } public Member(String username) { this.username = username; } } MemberRepository @Slf4j @Repository @RequiredArgsConstructor public class MemberRepository { private final EntityManager em; @Transacti..
2024.04.11
no image
[Spring DB] 트랜잭션 전파
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 기본적인 트랜잭션 커밋, 롤백 application.properties logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=D EBUG #JPA log logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG logging.level.org.hibernate.resource.transaction=DEBUG #JPA SQL logging.leve..
2024.04.10
no image
[Spring DB] 스프링 트랜잭션의 이해
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 2024.03.02 - [Java Category/Spring] - [Spring DB] 트랜잭션 AOP [Spring DB] 트랜잭션 AOP 이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 트랜잭션 AOP(Aspect-Oriented Programming)는 스프링 프레임워크가 트랜잭션 관리를 위해 제공하는 선언적 트 rebugs.tistory.com 위 포스트와 관련있습니다. 트랜잭션 적용 확인 @Transactional 을 통해 선언적 트랜잭션 방식을 사용하면 단순히 애노테이션 하나로 트랜잭션을 적용할 수 있다. 그런데 이 기능은 트랜잭션 관련 코드가 눈에 보..
2024.04.06
no image
[Spring DB] MyBatis
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. MyBatis 소개와 장점 및 단점 MyBatis는 자바(JAVA) 언어로 작성된 오픈 소스 SQL 매핑 프레임워크이다. JDBC(Java Database Connectivity) 위에 구축되어 데이터베이스와의 상호작용을 추상화하며, 개발자가 SQL 문을 직접 제어할 수 있게 해주는 특징을 가진다. 이는 개발자가 객체와 SQL 문 사이의 매핑을 설정하여, 데이터베이스 작업을 더 쉽고 직관적으로 할 수 있게 돕는다. MyBatis의 주요 기능 SQL 분리: MyBatis는 SQL을 자바 코드에서 분리하여 XML 파일이나 어노테이션에 작성하도록 한다. 이로써, SQL 관리가 용이하고 가독성이 높아진다. 동적 SQL..
2024.04.03
no image
[Spring DB] 데이터 접근 계층 테스트
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. @SpringBootTest와 @SpringBootApplication @SpringBootApplication @SpringBootApplication은 Spring Boot 애플리케이션의 주 진입점에 위치하는 어노테이션이다. 이 어노테이션은 @Configuration, @EnableAutoConfiguration, @ComponentScan 어노테이션들의 기능을 합친 것으로, Spring Boot 애플리케이션을 자동 설정하고, 애플리케이션 컨텍스트에서 빈을 검색하며, 추가적인 설정을 로드하는 역할을 한다. 기본적으로, 이 어노테이션이 붙은 클래스는 애플리케이션의 메인 클래스로, 애플리케이션 실행 시 스프링 ..
2024.04.02
no image
[Spring DB] SimpleJdbcInsert
SimpleJdbcInsert는 Spring Framework에서 제공하는 JDBC 추상화의 일부로, 데이터베이스에 새로운 레코드를 삽입하는 작업을 단순화하고 편리하게 만들어준다. NamedParameterJdbcTemplate과 유사하게, SimpleJdbcInsert는 이름이 지정된 파라미터를 사용하여 SQL 쿼리 없이 데이터베이스 테이블에 직접 삽입할 수 있게 해준다. 이를 통해 코드의 가독성이 향상되고, SQL 쿼리 실수를 줄일 수 있다. 설정 방법 SimpleJdbcInsert는 DataSource를 사용하여 생성될 수 있다. 생성 후, 사용할 데이터베이스 테이블과 해당 테이블의 기본 키 컬럼을 설정할 수 있다. @Autowired private DataSource dataSource; priv..
2024.04.01
no image
[Spring DB] NamedParameterJdbcTemplate
NamedParameterJdbcTemplate은 Spring Framework의 JDBC 접근 방법 중 하나로, JdbcTemplate과 유사하게 작동하지만, SQL 파라미터를 이름으로 지정할 수 있다는 주요 차이점이 있다. 이는 코드의 가독성을 높이고, SQL 쿼리의 파라미터를 더 명확하게 만드는 데 도움을 준다. CRUD 설정 NamedParameterJdbcTemplate 인스턴스를 생성해야 한다. 이는 보통 DataSource를 주입하여 생성된다. @Autowired private DataSource dataSource; private NamedParameterJdbcTemplate jdbcTemplate; @PostConstruct public void postConstruct() { jdbc..
2024.03.31
no image
[Spring DB] JDBC Template
공식 메뉴얼 https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#jdbc- JdbcTemplate DataSource 설정 JDBC Template 사용을 위해서는 데이터베이스와의 연결을 관리하는 DataSource를 설정해야 한다. application.properties spring.datasource.url=jdbc:h2:tcp://localhost/~/test spring.datasource.username=yourName spring.datasource.password=yourPassword 이렇게 하면 내부적으로 DataSource 빈을 자동으로 생성하고 구성합니다. JdbcTemplate 인스..
2024.03.29

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


예제 프로젝트

더보기

Member

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String username;
    public Member() {
    }
    public Member(String username) {
        this.username = username;
    }
}

 

MemberRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
    private final EntityManager em;
    @Transactional
    public void save(Member member) {
        log.info("member 저장");
        em.persist(member);
    }
    public Optional<Member> find(String username) {
        return em.createQuery("select m from Member m where m.username=:username", Member.class)
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

 

Log

@Entity
@Getter
@Setter
public class Log {
    @Id
    @GeneratedValue
    private Long id;
    private String message;
    public Log() {
    }
    public Log(String message) {
        this.message = message;
    }
}

 

LogRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
    private final EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);
        if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {

        /*
        getResultList 메소드를 호출하여 쿼리의 실행 결과를 리스트로 가져온다.
        이후 Java 8의 스트림 API를 사용하여 해당 리스트를 스트림으로 변환하고, findAny 메소드를 통해 결과 리스트 중 임의의 하나를 선택한다.
        findAny는 Optional<Log> 타입을 반환
         */
        return em.createQuery("select l from Log l where l.message = :message", Log.class)
                .setParameter("message", message)
                .getResultList().stream().findAny();
    }
}

 

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

    public void joinV1(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);
        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);

        log.info("== memberRepository 호출 종료 ==");
        log.info("== logRepository 호출 시작 ==");
        logRepository.save(logMessage); 
        log.info("== logRepository 호출 종료 ==");
    }

    public void joinV2(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);
        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);
        log.info("== memberRepository 호출 종료 ==");
        log.info("== logRepository 호출 시작 ==");

        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
            log.info("정상 흐름 변환");
        }
        log.info("== logRepository 호출 종료 ==");
    }
}

 

@Slf4j
@SpringBootTest
class MemberServiceTest {
    @Autowired
    MemberService memberService;
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    LogRepository logRepository;

    /**
     * MemberService    @Transactional:OFF
     * MemberRepository @Transactional:ON
     * LogRepository    @Transactional:ON
     */
    @Test
    void outerTxOff_success() {
        //given
        String username = "outerTxOff_success";

        //when
        memberService.joinV1(username);

        //then: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
}

 

JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요하다. (조회는 트랜잭션 없이 가능하다.)
현재 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있다.

 

서비스 계층에 트랜잭션이 없을 때

커밋

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원, 로그 리포지토리 둘다 커밋에 성공한다.
/**
 * MemberService    @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON
 */
@Test
void outerTxOff_success() {
    //given
    String username = "outerTxOff_success";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

  1. MemberService 에서 MemberRepository 를 호출한다. MemberRepository 에는 @Transactional 애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.
    -그림에서는 생략했지만, 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션 con1 을 획득 하고, 해당 커넥션을 수동 커밋 모드로 변경해서 트랜잭션을 시작한다.
    -그리고 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관한다.
    -트랜잭션 매니저의 호출 결과로 status 를 반환한다. 여기서는 신규 트랜잭션 여부가 참이 된다.
  2. MemberRepository 는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1 을 사용 해서 회원을 저장한다.
  3. MemberRepository 가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청 한다.
  4. 트랜잭션 매니저는 con1 을 통해 물리 트랜잭션을 커밋한다.
    -물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한다.

 

이렇게 해서 MemberRepository 와 관련된 모든 데이터는 정상 커밋되고, 트랜잭션B는 완전히 종료된다.

이후에 LogRepository 를 통해 트랜잭션C를 시작하고, 정상 커밋한다.
결과적으로 둘다 커밋되었으므로 Member, Log 모두 안전하게 저장된다.

 

@Transactional과 REQUIRED
트랜잭션 전파의 기본 값은 REQUIRED 이다. 따라서 다음 둘은 같다.
-@Transactional(propagation = Propagation.REQUIRED)
-@Transactional

REQUIRED 는 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고, 기존 트랜잭션이 있으면 참여한다.

 

 

롤백

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원 리포지토리는 정상 동작하지만 로그 리포지토리에서 예외가 발생한다.
/**
 * MemberService    @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOff_fail() {
    //given
    String username = "로그예외_outerTxOff_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    //then: 완전히 롤백되지 않고, member 데이터가 남아서 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}
  • 사용자 이름에 로그예외 라는 단어가 포함되어 있으면 LogRepository 에서 런타임 예외가 발생한다. 
  • 트랜잭션 AOP는 해당 런타임 예외를 확인하고 롤백 처리한다.

 

MemberService 에서 MemberRepository 를 호출하는 부분은 앞서 설명한 내용과 같다. 트랜잭션이 정상 커밋되고, 회원 데이터도 DB에 정상 반영된다.

MemberService 에서 LogRepository 를 호출하는데, 로그예외 라는 이름을 전달한다. 이 과정에서 새로운 트랜잭션 C가 만들어진다.

 

LogRepository 응답 로직

  1. LogRepository 는 트랜잭션C와 관련된 con2 를 사용한다.
  2. 로그예외 라는 이름을 전달해서 LogRepository 에 런타임 예외가 발생한다.
  3. LogRepository 는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
  4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
  5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.

 

참고
트랜잭션 AOP도 결국 내부에서는 트랜잭션 매니저를 사용하게 된다.

 

단일 트랜잭션

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:OFF
 * LogRepository    @Transactional:OFF
 */
@Test
void singleTx() {
    //given
    String username = "singleTx";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

이렇게 하면 MemberService 를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
물론 MemberService 가 MemberRepository , LogRepository 를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.

 

MemberService 만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.

 

@Transactional 이 MemberService 에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.

MemberRepository, LogRepository 는 트랜잭션 AOP가 적용되지 않는다.

 

MemberService 의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.

MemberService 가 호출하는 MemberRepository , LogRepository 도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.

 

참고
같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환한다.

 

 

트랜잭션 전파

각각 트랜잭션이 필요한 상황

  • 클라이언트 A는 MemberService 부터 MemberRepository, LogRepository 를 모두 하나의 트랜잭션으로 묶고 싶다.
  • 클라이언트 B는 MemberRepository 만 호출하고 여기에만 트랜잭션을 사용하고 싶다.
  • 클라이언트 C는 LogRepository 만 호출하고 여기에만 트랜잭션을 사용하고 싶다.

 

클라이언트 A만 생각하면 MemberService 에 트랜잭션 코드를 남기고, MemberRepository , LogRepository 의 트랜잭션 코드를 제거하면 깔끔하게 하나의 트랜잭션을 적용할 수 있다.

 

하지만 이렇게 되면 클라이언트 B, C가 호출하는 MemberRepository, LogRepository 에는 트랜잭션을 적용할 수 없다.

 

트랜잭션 전파 없이 이런 문제를 해결하려면 아마도 트랜잭션이 있는 메서드와 트랜잭션이 없는 메서드를 각각 만들어야 할 것이다.

 

더 복잡하게 다음과 같은 상황이 발생할 수도 있다.

클라이언트 Z가 호출하는 OrderService 에서도 트랜잭션을 시작할 수 있어야 하고, 클라이언트A가 호출하는 MemberService 에서도 트랜잭션을 시작할 수 있어야 한다.

이런 문제를 해결하기 위해 트랜잭션 전파가 필요한 것이다.

 

 

전파 커밋

@Transactional 이 적용되어 있으면 기본으로 REQUIRED 라는 전파 옵션을 사용한다.

이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다. 참여한다는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻이다.

 

이렇게 둘 이상의 트랜잭션이 하나의 물리 트랜잭션에 묶이게 되면 둘을 구분하기 위해 논리 트랜잭션과 물리 트랜잭션으로 구분한다.

  • 이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
  • 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다.
  • 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋된다. 하나라도 롤백되면 물리 트랜잭션은 롤백된다.

 

 

모든 논리 트랜잭션이 정상 커밋되는 경우

회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.

 /**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON
 */
@Test
void outerTxOn_success() {
    //given
    String username = "outerTxOn_success";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

 

 

  • 클라이언트A(여기서는 테스트 코드)가 MemberService 를 호출하면서 트랜잭션 AOP가 호출된다.
    -여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository 를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository 를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋(물리 커밋)을 호출하지 않는다.
  • MemberService 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.

 

예외가 발생해서 전체가 롤백되는 경우

로그 리포지토리에서 예외가 발생해서 전체 트랜잭션이 롤백되는 경우

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOn_fail() {
    //given
    String username = "로그예외_outerTxOn_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    //then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

 

  • 클라이언트A가 MemberService를 호출하면서 트랜잭션 AOP가 호출된다.
    -여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository 로직에서 런타임 예외가 발생한다. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받게 된다.
    -트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않는다. 대신에 rollbackOnly를 설정한다.
    -LogRepository가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • MemberService에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던진다.
    -트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이므로 물리 롤백을 호출한다.
    -참고로 이 경우 어차피 롤백이 되었기 때문에, rollbackOnly 설정은 참고하지 않는다.
    -MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • 클라이언트A는 LogRepository부터 넘어온 런타임 예외를 받게 된다.

 

회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백된다. 따라서 데이터 정합성에 문제가 발생하지 않는다.

 

 

예외가 발생해서 일부 커밋, 일부 롤백

회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 가끔 발생하게 되었다.

그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생하기 시작했다.

회원 이력 로그의 경우 여러가지 방법으로 추후에 복구가 가능할 것으로 보인다.

 

그래서 비즈니스 요구사항이 변경되었다.
회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

 

  • 단순하게 생각해보면 LogRepository 에서 예외가 발생하면 그것을 MemberService 에서 예외를 잡아서 처리하면 될 것 같다.
  • 이렇게 하면 MemberService 에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService 의 트랜잭션 AOP에서 커밋을 수행할 수 있다.
  • 하지만 이렇게 하면 안된다.

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void recoverException_fail() {
    //given
    String username = "로그예외_recoverException_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV2(username))
            .isInstanceOf(UnexpectedRollbackException.class);

    //then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

여기서 memberService.joinV2()를 호출하는 부분을 주의해야 한다.

joinV2()에는 예외를 잡아서 정상 흐름으로 변환하는 로직이 추가되어 있다.

 try {
     logRepository.save(logMessage);
} catch (RuntimeException e) {
	log.info("log 저장에 실패했습니다. logMessage={}", logMessage); 
    log.info("정상 흐름 변환");
}

 

내부 트랜잭션에서 rollbackOnly 를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다.
그리고 UnexpectedRollbackException 이 던져진다.

 

  • LogRepository 에서 예외가 발생한다. 예외를 던지면 LogRepository 의 트랜잭션 AOP가 해당 예외를 받는다.
  • 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에 rollbackOnly 를 표시한다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService 에 던져지고, MemberService 는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService 의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly 를 체크한다.
  • rollbackOnly 가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.
  • 트랜잭션 매니저는 UnexpectedRollbackException 예외를 던진다.
  • 트랜잭션 AOP도 전달받은 UnexpectedRollbackException 을 클라이언트에 던진다.

 

정리
-논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백된다.
-내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생한다.
-rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생한다.

 

REQUIRES_NEW

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional(REQUIRES_NEW) Exception
 */
@Test
void recoverException_success() {
    //given
    String username = "로그예외_recoverException_success";

    //when
    memberService.joinV2(username);

    //then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

  • MemberRepository 는 REQUIRED 옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
  • LogRepository 의 트랜잭션 옵션에 REQUIRES_NEW 를 사용했다.
  • REQUIRES_NEW 는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.

 

  • REQUIRES_NEW 를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되어 버린다.
  • 그리고 REQUIRES_NEW 는 신규 트랜잭션이므로 rollbackOnly 표시가 되지 않는다. 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.

 

  • LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
  • REQUIRES_NEW를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로 rollbackOnly를 표시하지 않는다. 여기서 REQUIRES_NEW를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 난다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 없으므로 물리 트랜잭션을 커밋한다.
  • 이후 정상 흐름이 반환된다.

 

결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백 되는 것을 확인할 수 있다.

정리

-논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
-이 문제를 해결하려면 REQUIRES_NEW를 사용해서 트랜잭션을 분리해야 한다.
-예제를 단순화 하기 위해 MemberService가 MemberRepository, LogRepository만 호출하지만 실제로는 더 많은 리포지토리들을 호출하고 그 중에 LogRepository만 트랜잭션을 분리한다고 생각해보면 이해하는데 도움이 될 것이다.

 

주의

REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.

 

아래와 같이 REQUIRES_NEW 를 사용하지 않고 구조를 변경하는 것도 좋은 방법이다.

이렇게 하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지는 않는다. 순차적으로 사용하고 반환하게 된다.
물론 구조상 REQUIRES_NEW 를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택 해서 사용하면 된다.

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 트랜잭션 전파  (1) 2024.04.10
[Spring DB] 스프링 트랜잭션의 이해  (0) 2024.04.06
[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


기본적인 트랜잭션 커밋, 롤백

 

application.properties

logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=D
EBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
#JPA SQL
logging.level.org.hibernate.SQL=DEBUG

 

아래의 코드에 추가적으로 덧붙이면서 개념을 설명한다.

@Slf4j
@SpringBootTest
public class BasicTxTest {
    @Autowired
    PlatformTransactionManager txManager;

    @TestConfiguration
    static class Config {
        @Bean
        public PlatformTransactionManager transactionManager(DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }
    }

    @Test
    void commit() {
        log.info("트랜잭션 시작");
        TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
        log.info("트랜잭션 커밋 시작");
        txManager.commit(status);
        log.info("트랜잭션 커밋 완료");
    }

    @Test
    void rollback() {
        log.info("트랜잭션 시작");
        TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
        log.info("트랜잭션 롤백 시작");
        txManager.rollback(status);
        log.info("트랜잭션 롤백 완료");
    }
}

@Autowired를 통해 PlatformTransactionManager의 인스턴스를 자동으로 주입받고 있다. 이는 스프링의 의존성 주입 기능을 활용하는 부분이다.

@TestConfiguration은 테스트 전용 설정을 정의할 때 사용되며, 이 예제에서는 데이터 소스로부터 트랜잭션 매니저(DataSourceTransactionManager)를 생성하고 빈으로 등록하는 설정을 포함하고 있다. 이렇게 함으로써 테스트 환경에서 데이터베이스 트랜잭션을 관리할 수 있게 된다.

commit 메소드와 rollback 메소드에서는 각각 트랜잭션을 시작, 커밋 또는 롤백하는 과정을 로깅으로 기록하고 있다. txManager.getTransaction(new DefaultTransactionAttribute())를 통해 새로운 트랜잭션을 시작하며, 이후 commit이나 rollback 메소드를 호출하여 트랜잭션을 완료한다.

 

트랜잭션 두 번 사용

커밋

이 예제는 트랜잭션1이 완전히 끝나고나서 트랜잭션2를 수행한다.

@Test
void double_commit() {
    log.info("트랜잭션1 시작");
    TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋");
    txManager.commit(tx1);

    log.info("트랜잭션2 시작");
    TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 커밋");
    txManager.commit(tx2);
}

두 트랜잭션은 서로 독립적으로 처리되며, 첫 번째 트랜잭션이 커밋된 후에 두 번째 트랜잭션이 시작된다. 

이는 각각의 트랜잭션 내에서 수행된 작업이 서로에게 영향을 주지 않음을 의미한다.

 

로그를 보면 트랜잭션1과 트랜잭션2가 같은 conn0 커넥션을 사용중이다. 이것은 중간에 커넥션 풀 때문에 그런 것이다.

트랜잭션1은 conn0 커넥션을 모두 사용하고 커넥션 풀에 반납까지 완료했다.

이후에 트랜잭션2가 conn0 를 커 넥션 풀에서 획득한 것이다. 따라서 둘은 완전히 다른 커넥션으로 인지하는 것이 맞다.

 

히카리 커넥션 풀에서 커넥션을 획득하면 실제 커넥션을 그대로 반환하는 것이 아니라 내부 관리를 위해 히카리 프록시 커넥션이라는 객체를 생성해서 반환한다.

 

물론 내부에는 실제 커넥션이 포함되어 있다. 이 객체의 주소를 확인하면 커넥션 풀에서 획득한 커넥션을 구분할 수 있다.

 

트랜잭션1: Acquired Connection [HikariProxyConnection@1000000 wrapping conn0]

트랜잭션2: Acquired Connection [HikariProxyConnection@2000000 wrapping conn0]

 

히카리 커넥션풀이 반환해주는 커넥션을 다루는 프록시 객체의 주소가 트랜잭션1은 HikariProxyConnection@1000000(임의) 이고,

트랜잭션2는 HikariProxyConnection@2000000(임의) 으로 서로 다른 것을 확인할 수 있다.

 

결과적으로 conn0 을 통해 커넥션이 재사용된 것을 확인할 수 있고, HikariProxyConnection@1000000 , HikariProxyConnection@2000000 을 통해 각각 커넥션 풀에서 커넥션을 조회한 것을 확인할 수 있다.

트랜잭션이 각각 수행되면서 사용되는 DB 커넥션도 각각 다르다.

이 경우 트랜잭션을 각자 관리하기 때문에 전체 트랜잭션을 묶을 수 없다. 예를 들어서 트랜잭션1이 커밋하고, 트랜잭션2가 롤백하는 경우 트랜잭션1에서 저장한 데이터는 커밋되고, 트랜잭션2에서 저장한 데이터는 롤백된다.

 

 

롤백

@Test
void double_commit_rollback() {
    log.info("트랜잭션1 시작");
    TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋");
    txManager.commit(tx1);

    log.info("트랜잭션2 시작");
    TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 롤백");
    txManager.rollback(tx2);
}

트랜잭션1은 커밋하고, 트랜잭션2는 롤백한다.
전체 트랜잭션을 묶지 않고 각각 관리했기 때문에, 트랜잭션1에서 저장한 데이터는 커밋되고, 트랜잭션2에서 저장한 데이터는 롤백된다.

 

 

 

트랜잭션 전파

기본

트랜잭션 전파의 개념

트랜잭션을 각각 사용하는 것이 아니라, 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 동작할지 결정하는 것을 트랜잭션 전파(propagation)라 한다.

트랜잭션 전파의 기본 옵션인 REQUIRED 를 기준으로 설명

외부 트랜잭션이 수행중이고, 아직 끝나지 않았는데, 내부 트랜잭션이 수행된다.

 

  • 외부 트랜잭션이라고 이름 붙인 것은 둘 중 상대적으로 밖에 있기 때문에 외부 트랜잭션이라 한다. 처음 시작된 트랜잭션으로 이해하면 된다.
  • 내부 트랜잭션은 외부에 트랜잭션이 수행되고 있는 도중에 호출되기 때문에 마치 내부에 있는 것 처럼 보여서 내부 트랜잭션이라 한다.

 

스프링에서 이 경우 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션을 만들어준다.

내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다. 이것이 기본 동작이고, 옵션을 통해 다른 동작방식도 선택할 수 있다.

 

물리 트랜잭션과 논리 트랜잭션

스프링은 이해를 돕기 위해 논리 트랜잭션과 물리 트랜잭션이라는 개념을 나눈다.

 

논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다.

 

물리 트랜잭션은 우리가 이해하는 실제 데이터베이스에 적용되는 트랜잭션을 뜻한다. 실제 커넥션을 통해서 트랜 잭션을 시(setAutoCommit(false)) 하고, 실제 커넥션을 통해서 커밋, 롤백하는 단위이다.

 

논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.

 

이러한 논리 트랜잭션 개념은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 나타난다. 단순히 트랜잭션이 하나인 경우 둘을 구분하지는 않는다. 

 

원칙

1. 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.

 

 

2. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

 

모든 트랜잭션 매니저를 커밋해야 물리 트랜잭션이 커밋된다. 하나의 트랜잭션 매니저라도 롤백하면 물리 트랜잭션은 롤백된다.

 

 

 

예시와 자세한 설명

@Test
void inner_commit() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction()={}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNewTransaction()={}", inner.isNewTransaction());

    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}

외부 트랜잭션이 수행중인데, 내부 트랜잭션을 추가로 수행했다.

외부 트랜잭션은 처음 수행된 트랜잭션이다. 이 경우 신규 트랜잭션( isNewTransaction=true)이 된다.

내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.

트랜잭션 참여

내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다.
다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
외부에서 시작된 물리적인 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.

정리하면 외부 트랜잭션과 내부 트랜잭션이 하나의 물리 트랜잭션으로 묶이는 것이다.

 

내부 트랜잭션은 이미 진행중인 외부 트랜잭션에 참여한다. 이 경우 신규 트랜잭션이 아니다 ( isNewTransaction=false)

 

예제에서는 둘다 성공적으로 커밋했다.

 

내부 트랜잭션을 시작할 때 Participating in existing transaction 이라는 메시지를 확인할 수 있다.

이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다.

실행 결과를 보면 외부 트랜잭션을 시작하거나 커밋할 때는 DB 커넥션을 통한 물리 트랜잭션을 시작(manual commit)하고, DB 커넥션을 통해 커밋 하는 것을 확인할 수 있다. 

 

그런데 내부 트랜잭션을 시작하거나 커밋할 때 는 DB 커넥션을 통해 커밋하는 로그를 전혀 확인할 수 없다.

 

정리하면 외부 트랜잭션만 물리 트랜잭션을 시작하고, 커밋한다.

 

만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에, 트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없다.

 

따라서 내부 트랜잭션은 DB 커넥션을 통한 물리 트랜잭션을 커밋하면 안된다. 스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다.

 

이를 통해 트랜잭션 중복 커밋 문제를 해결한다.

 

 

위 과정을 그림으로 나타내면 아래와 같다.

요청 흐름 - 외부 트랜잭션


1. txManager.getTransaction() 를 호출해서 외부 트랜잭션을 시작한다.

 

2. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.

 

3. 생성한 커넥션을 수동 커밋 모드(setAutoCommit(false))로 설정한다. - 물리 트랜잭션 시작

 

4. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.

 

5. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에 신규 트랜잭션의 여부가 담겨 있다. isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 트랜잭션을 처음 시작했으므로 신규 트랜잭션이다.(true)

 

6. 로직1이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득
해서 사용한다.

 

요청 흐름 - 내부 트랜잭션

7. txManager.getTransaction() 를 호출해서 내부 트랜잭션을 시작한다.

 

8. 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해서 기존 트랜잭션이 존재하는지 확인한다.

 

9. 기존 트랜잭션이 존재하므로 기존 트랜잭션에 참여한다. 기존 트랜잭션에 참여한다는 뜻은 사실 아무것도
하지 않는다는 뜻이다.
-이미 기존 트랜잭션인 외부 트랜잭션에서 물리 트랜잭션을 시작했다. 그리고 물리 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 담아두었다.
-따라서 이미 물리 트랜잭션이 진행중이므로 그냥 두면 이후 로직이 기존에 시작된 트랜잭션을 자연스럽게 사용하게 되는 것이다.
-이후 로직은 자연스럽게 트랜잭션 동기화 매니저에 보관된 기존 커넥션을 사용하게 된다.

 

10. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에서 isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 여기서는 기존 트랜잭션에 참여했기 때문에 신규 트랜잭션이 아니다. (false)

 

11. 로직2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 외부 트랜잭션이 보관한 커넥션을
획득해서 사용한다.

 

 

 

응답 흐름 - 내부 트랜잭션

12. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.

 

13. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다. 실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때까지 이어져야한다.

 

응답 흐름 - 외부 트랜잭션

14. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.

 

15. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 커밋을 호출한다.

 

16. 트랜잭션 매니저에 커밋하는 것이 논리적인 커밋이라면, 실제 커넥션에 커밋하는 것을 물리 커밋이라 할 수 있다. 실제 데이터베이스에 커밋이 반영되고, 물리 트랜잭션도 끝난다.

 

핵심 정리

  • 핵심은 트랜잭션 매니저에 커밋을 호출한다고해서 항상 실제 커넥션에 물리 커밋이 발생하지는 않는다는 점이다.
  • 신규 트랜잭션인 경우에만 실제 커넥션을 사용해서 물리 커밋과 롤백을 수행한다. 신규 트랜잭션이 아니면 실제 물리 커넥션을 사용하지 않는다.
  • 이렇게 트랜잭션이 내부에서 추가로 사용되면 트랜잭션 매니저에 커밋하는 것이 항상 물리 커밋으로 이어지지 않는다. 그래서 이 경우 논리 트랜잭션과 물리 트랜잭션을 나누게 된다. 또는 외부 트랜잭션과 내부 트랜잭션으로 나누어 설명하기도 한다.
  • 트랜잭션이 내부에서 추가로 사용되면, 트랜잭션 매니저를 통해 논리 트랜잭션을 관리하고, 모든 논리 트랜잭션이 커밋되면 물리 트랜잭션이 커밋된다고 이해하면 된다.

 

 

 

외부 롤백과 내부 롤백

외부 롤백

내부 트랜잭션은 커밋되는데, 외부 트랜잭션이 롤백되는 상황

 

논리 트랜잭션이 하나라도 롤백되면 전체 물리 트랜잭션은 롤백된다.

따라서 이 경우 내부 트랜잭션이 커밋했어도, 내부 트랜잭션 안에서 저장한 데이터도 모두 함께 롤백된다.

 

@Test
void outer_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 롤백");
    txManager.rollback(outer);
}

결과적으로 외부 트랜잭션에서 시작한 물리 트랜잭션의 범위가 내부 트랜잭션까지 사용된다.

이후 외부 트랜잭션 이 롤백되면서 전체 내용은 모두 롤백된다.

 

응답 흐름 - 내부 트랜잭션

1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.

 

2. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다.  실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때까지 이어져야한다.

 

응답 흐름 - 외부 트랜잭션

3. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 롤백한다.

 

4. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 롤백을 호출한다.

 

5. 트랜잭션 매니저에 롤백하는 것이 논리적인 롤백이라면, 실제 커넥션에 롤백하는 것을 물리 롤백이라 할 수 있다. 실제 데이터베이스에 롤백이 반영되고, 물리 트랜잭션도 끝난다.

 

내부 롤백

내부 트랜잭션은 롤백되는데, 외부 트랜잭션이 커밋되는 상황

내부 트랜잭션이 롤백을 했지만, 내부 트랜잭션은 물리 트랜잭션에 영향을 주지 않는다. 그런데 외부 트랜잭션은 커밋을 해버린다.

 

@Test
void inner_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    assertThatThrownBy(() -> txManager.commit(outer))
            .isInstanceOf(UnexpectedRollbackException.class);
}

외부 트랜잭션 시작

  • 물리 트랜잭션을 시작한다.

 

내부 트랜잭션 시작

  • Participating in existing transaction
  • 기존 트랜잭션에 참여한다.

 

내부 트랜잭션 롤백

  • Participating transaction failed - marking existing transaction as rollback-only
  • 내부 트랜잭션을 롤백하면 실제 물리 트랜잭션은 롤백하지 않는다. 대신에 기존 트랜잭션을 롤백 전용으로 표시한다.

 

외부 트랜잭션 커밋

  • 외부 트랜잭션을 커밋한다.
  • Global transaction is marked as rollback-only
  • 커밋을 호출했지만, 전체 트랜잭션이 롤백 전용으로 표시되어 있다. 따라서 물리 트랜잭션을 롤백한다.

 

 

응답 흐름 - 내부 트랜잭션

1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다. (로직2에 문제가 있어서 롤백한다고 가정한다.)

 

2. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니 기 때문에 실제 롤백을 호출하지 않는다.
실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 롤백을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때 까지 이어져야한다.

 

3. 내부 트랜잭션은 물리 트랜잭션을 롤백하지 않는 대신에 트랜잭션 동기화 매니저에 rollbackOnly=true 라는 표시를 해둔다.

 

응답 흐름 - 외부 트랜잭션

4. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.

 

5. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다.
따라서 DB 커넥션에 실제 커밋을 호출해야 한다. 이때 먼저 트랜잭션 동기화 매니저에 롤백 전용 (rollbackOnly=true) 표시가 있는지 확인한다. 롤백 전용 표시가 있으면 물리 트랜잭션을 커밋하는 것이 아니라 롤백한다.

 

6. 실제 데이터베이스에 롤백이 반영되고, 물리 트랜잭션도 끝난다.

 

7. 트랜잭션 매니저에 커밋을 호출한 개발자 입장에서는 분명 커밋을 기대했는데 롤백 전용 표시로 인해 실제로는 롤백이 되어버렸다.
이것은 조용히 넘어갈 수 있는 문제가 아니다. 시스템 입장에서는 커밋을 호출했지만 롤백이 되었다는 것은 분명하게 알려주어야 한다.
예를 들어서 고객은 주문이 성공했다고 생각했는데, 실제로는 롤백이 되어서 주문이 생성되지 않은 것이다.
스프링은 이 경우 UnexpectedRollbackException 런타임 예외를 던진다. 그래서 커밋을 시도했지만, 기대하지 않은 롤백이 발생했다는 것을 명확하게 알려준다.

 

정리

-논리 트랜잭션이 하나라도 롤백되면 물리 트랜잭션은 롤백된다.
-내부 논리 트랜잭션이 롤백되면 롤백 전용 마크를 표시한다.
-외부 트랜잭션을 커밋할 때 롤백 전용 마크를 확인한다. 롤백 전용 마크가 표시되어 있으면 물리 트랜잭션을 롤백하고, UnexpectedRollbackException 예외를 던진다.
참고

애플리케이션 개발에서 중요한 기본 원칙은 모호함을 제거하는 것이다. 개발은 명확해야 한다. 이렇게 커밋을 호출했는데, 내부에서 롤백이 발생한 경우 모호하게 두면 아주 심각한 문제가 발생한다. 이렇게 기대한 결과가 다른 경우 예외를 발생시켜서 명확하게 문제를 알려주는 것이 좋은 설계이다.

 

 

REQUIRES_NEW(외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법)

REQUIRES_NEW는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하는 방법이다. 그래서 커밋과 롤백도 각각 별도로 이루어지게 된다.


이 방법은 내부 트랜잭션에 문제가 발생해서 롤백해도, 외부 트랜잭션에는 영향을 주지 않는다. 반대로 외부 트랜잭션에 문제가 발생해도 내부 트랜잭션에 영향을 주지 않는다.

이렇게 물리 트랜잭션을 분리하려면 내부 트랜잭션을 시작할 때 REQUIRES_NEW 옵션을 사용하면 된다. 

외부 트랜잭션과 내부 트랜잭션이 각각 별도의 물리 트랜잭션을 가진다.

별도의 물리 트랜잭션을 가진다는 뜻은 DB 커넥션을 따로 사용한다는 뜻이다.

이 경우 내부 트랜잭션이 롤백되면서 로직 2가 롤백되어도 로직 1에서 저장한 데이터에는 영향을 주지 않는다.

최종적으로 로직2는 롤백되고, 로직1은 커밋된다.

 

@Test
void inner_rollback_requires_new() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction()={}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    TransactionStatus inner = txManager.getTransaction(definition);
    log.info("inner.isNewTransaction()={}", inner.isNewTransaction());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner); //롤백

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer); //커밋
}
DefaultTransactionAttribute 객체 생성
TransactionDefinition 인터페이스를 구현한 DefaultTransactionAttribute 클래스의 인스턴스를 생성한다. 이 인스턴스는 트랜잭션의 다양한 속성을 정의하는 데 사용된다.



전파 행위 설정
생성된 definition 객체에 setPropagationBehavior 메소드를 호출하여 트랜잭션의 전파 행위를 PROPAGATION_REQUIRES_NEW로 설정한다. 이는 현재 진행 중인 트랜잭션이 있을 경우 이를 일시 중단하고 새로운 트랜잭션을 시작하게 한다. 즉, 호출된 메소드가 자신만의 독립적인 트랜잭션을 가지게 된다.

내부 트랜잭션을 시작할 때 전파 옵션인 propagationBehavior 에 PROPAGATION_REQUIRES_NEW 옵션을 주었다.

이 전파 옵션을 사용하면 내부 트랜잭션을 시작할 때 기존 트랜잭션에 참여하는 것이 아니라 새로운 물리 트랜잭션을 만들어서 시작하게 된다.

외부 트랜잭션 시작

  • 외부 트랜잭션을 시작하면서 conn0 를 획득하고 manual commit 으로 변경해서 물리 트랜잭션을 시작한다.
  • 외부 트랜잭션은 신규 트랜잭션이다.( outer.isNewTransaction()=true )

 

내부 트랜잭션 시작

  • 내부 트랜잭션을 시작하면서 conn1 를 획득하고 manual commit 으로 변경해서 물리 트랜잭션을 시작한다.
  • 내부 트랜잭션은 외부 트랜잭션에 참여하는 것이 아니라, PROPAGATION_REQUIRES_NEW 옵션을 사용했기 때문에 완전히 새로운 신규 트랜잭션으로 생성된다.( inner.isNewTransaction()=true )

 

내부 트랜잭션 롤백

  • 내부 트랜잭션을 롤백한다.
  • 내부 트랜잭션은 신규 트랜잭션이기 때문에 실제 물리 트랜잭션을 롤백한다. 내부 트랜잭션은 conn1 을 사용하므로 conn1 에 물리 롤백을 수행한다.

 

외부 트랜잭션 커밋

  • 외부 트랜잭션을 커밋한다.
  • 외부 트랜잭션은 신규 트랜잭션이기 때문에 실제 물리 트랜잭션을 커밋한다. 외부 트랜잭션은 conn0 를 사용하므로 conn0 에 물리 커밋을 수행한다.

 

코드 분석

외부 트랜잭션 시작: PlatformTransactionManager를 사용해 외부 트랜잭션을 시작한다. outer.isNewTransaction()은 true를 반환한다, 이는 외부 트랜잭션이 새로 시작됐음을 의미한다.

내부 트랜잭션 시작: PROPAGATION_REQUIRES_NEW 속성을 설정한 DefaultTransactionAttribute 인스턴스를 생성하고, 이를 사용해 내부 트랜잭션을 시작한다. 이 속성 덕분에, 내부 트랜잭션은 새로운 트랜잭션으로 시작되며, inner.isNewTransaction()은 true를 반환한다.

내부 트랜잭션 롤백: 내부 트랜잭션에 문제가 발생했을 때, txManager.rollback(inner)를 호출하여 내부 트랜잭션만 롤백한다. 이는 내부 트랜잭션에 의한 변경 사항이 데이터베이스에 반영되지 않도록 한다.

외부 트랜잭션 커밋: 마지막으로, 외부 트랜잭션은 정상적으로 커밋된다. 외부 트랜잭션의 작업은 성공적으로 데이터베이스에 반영된다.

 

 

요청 흐름 - 외부 트랜잭션

1. txManager.getTransaction() 를 호출해서 외부 트랜잭션을 시작한다.

 

2. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.

 

3. 생성한 커넥션을 수동 커밋 모드(setAutoCommit(false))로 설정한다. - 물리 트랜잭션 시작

 

4. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에 신규 트랜잭션의 여부가 담겨 있다. 
isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. -> 트랜잭션을 처음 시작했으므로 신규 트랜잭션이다.(true)

 

6.로직1이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.

 

 

요청 흐름 - 내부 트랜잭션

7. REQUIRES_NEW 옵션과 함께 txManager.getTransaction() 를 호출해서 내부 트랜잭션을 시작한다. 
트랜잭션 매니저는 REQUIRES_NEW 옵션을 확인하고, 기존 트랜잭션에 참여하는 것이 아니라 새로운 트랜잭션을 시작한다.

 

8.트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.

 

9. 생성한 커넥션을 수동 커밋 모드(setAutoCommit(false))로 설정한다. - 물리 트랜잭션 시작

 

10. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
이때 con1 은 잠시 보류되고, 지금부터는 con2 가 사용된다. (내부 트랜잭션을 완료할 때까지 con2 가 사용된다.)

 

11. 트랜잭션 매니저는 신규 트랜잭션의 생성한 결과를 반환한다. isNewTransaction == true

 

12. 로직2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저에 있는 con2 커넥션을 획득해서 사용한다.

 

 

응답 흐름 - 내부 트랜잭션

1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다. (로직2에 문제가 있어서 롤백한다고 가정한다.)

 

2. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 현재 내부 트랜잭션은 신규 트랜잭션이다. 따라서 실제 롤백을 호출한다.

 

3. 내부 트랜잭션이 con2 물리 트랜잭션을 롤백한다. 트랜잭션이 종료되고, con2는 종료되거나, 커넥션 풀에 반납된다. 이후에 con1의 보류가 끝나고, 다시 con1을 사용한다.

 

 

응답 흐름 - 외부 트랜잭션

4. 외부 트랜잭션에 커밋을 요청한다.

 

5. 외부 트랜잭션은 신규 트랜잭션이기 때문에 물리 트랜잭션을 커밋한다.

 

6. 이때 rollbackOnly 설정을 체크한다. rollbackOnly 설정이 없으므로 커밋한다.

 

7. 본인이 만든 con1 커넥션을 통해 물리 트랜잭션을 커밋한다. 트랜잭션이 종료되고, con1은 종료되거나, 커넥션 풀에 반납된다.

 

정리

  • REQUIRES_NEW 옵션을 사용하면 물리 트랜잭션이 명확하게 분리된다. 
  • REQUIRES_NEW를 사용하면 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 한다.

 

 

다양한 전파 옵션

스프링은 다양한 트랜잭션 전파 옵션을 제공한다. 전파 옵션에 별도의 설정을 하지 않으면 REQUIRED 가 기본으로 사용된다.

실무에서는 대부분 REQUIRED 옵션을 사용한다. 

그리고 아주 가끔 REQUIRES_NEW 을 사용하고, 나머지는 거의 사용하지 않는다. 

REQUIRED

가장 많이 사용하는 기본 설정이다. 기존 트랜잭션이 없으면 생성하고, 있으면 참여한다. 트랜잭션이 필수라는 의미로 이해하면 된다. (필수이기 때문에 없으면 만들고, 있으면 참여한다.)

  • 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
  • 기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

 

REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다. 

  • 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다. 
  • 기존 트랜잭션 있음: 새로운 트랜잭션을 생성한다.

 

SUPPORT

트랜잭션을 지원한다는 뜻이다. 기존 트랜잭션이 없으면, 없는대로 진행하고, 있으면 참여한다.

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
  • 기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

 

NOT_SUPPORT

트랜잭션을 지원하지 않는다는 의미이다.

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
  • 기존 트랜잭션 있음: 트랜잭션 없이 진행한다. (기존 트랜잭션은 보류한다)

 

MANDATORY

의무사항이다. 트랜잭션이 반드시 있어야 한다. 

  • 기존 트랜잭션이 없으면 IllegalTransactionStateException 예외 발생 
  • 기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

 

NEVER

트랜잭션을 사용하지 않는다는 의미이다. 기존 트랜잭션이 있으면 예외가 발생한다. 기존 트랜잭션도 허용하지 않는 강한 부정의 의미로 이해하면 된다. 

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다. 
  • 기존 트랜잭션 있음: IllegalTransactionStateException 예외 발생

 

 

NESTED

  • 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다. 
  • 기존 트랜잭션 있음: 중첩 트랜잭션을 만든다. 
    중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 중첩 트랜잭션은 외부에 영향을 주지 않는다. 
    중첩 트랜잭션이 롤백되어도 외부 트랜잭션은 커밋할 수 있다. 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 함께 롤백된다.

 

참고

JDBC savepoint 기능을 사용한다. DB 드라이버에서 해당 기능을 지원하는지 확인이 필요하다. 중첩 트랜잭션은 JPA에서는 사용할 수 없다.

 

트랜잭션 전파와 옵션

isolation , timeout , readOnly 는 트랜잭션이 처음 시작될 때만 적용된다. 트랜잭션에 참여하는 경우에는 적용되지 않는다.
예를 들어서 `REQUIRED` 를 통한 트랜잭션 시작, `REQUIRES_NEW` 를 통한 트랜잭션 시작 시점에만 적용된다.

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 트랜잭션 전파 활용  (0) 2024.04.11
[Spring DB] 스프링 트랜잭션의 이해  (0) 2024.04.06
[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.

2024.03.02 - [Java Category/Spring] - [Spring DB] 트랜잭션 AOP

 

[Spring DB] 트랜잭션 AOP

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 트랜잭션 AOP(Aspect-Oriented Programming)는 스프링 프레임워크가 트랜잭션 관리를 위해 제공하는 선언적 트

rebugs.tistory.com

위 포스트와 관련있습니다.


트랜잭션 적용 확인

@Transactional 을 통해 선언적 트랜잭션 방식을 사용하면 단순히 애노테이션 하나로 트랜잭션을 적용할 수 있다. 그런데 이 기능은 트랜잭션 관련 코드가 눈에 보이지 않고, AOP를 기반으로 동작하기 때문에, 실제 트랜잭션이 적용되 고 있는지 아닌지를 확인하기가 어렵다.

 

아래의 테스트 코드로 원리를 이해 할 수 있다.

@Slf4j
@SpringBootTest
public class TxBasicTest {
    @Autowired
    BasicService basicService;

    @Test
    void proxyCheck() {
        //BasicService$$EnhancerBySpringCGLIB...
        log.info("aop class={}", basicService.getClass());
        assertThat(AopUtils.isAopProxy(basicService)).isTrue();
    }

    @Test
    void txTest() {
        basicService.tx();
        basicService.nonTx();
    }

    @TestConfiguration
    static class TxApplyBasicConfig {
        @Bean
        BasicService basicService() {
            return new BasicService();
        }
    }

    @Slf4j
    @Transactional(readOnly = true)
    static class BasicService {

        //우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.
        //클래스 레벨에서 (readOnly = true)이지만 메서드 레벨에서 (readOnly = false) 이기때문에 tx()는 읽기 작업과 쓰기 작업 모두 할 수 있다.
        //(readOnly = true) : 읽기만 가능
        //(readOnly = false) : 읽기. 쓰기 모두 가능
        @Transactional(readOnly = false)
        public void tx() {
            log.info("call tx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); //현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능
            log.info("tx active={}", txActive);
        }
        public void nonTx() {
            log.info("call nonTx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); //현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능
            log.info("tx active={}", txActive);
        }
    }
}

proxyCheck() - 실행

AopUtils.isAopProxy() : 선언적 트랜잭션 방식에서 스프링 트랜잭션은 AOP를 기반으로 동작한다.

@Transactional 을 메서드나 클래스에 붙이면 해당 객체는 트랜잭션 AOP 적용의 대상이 되고, 결과적으로 실제 객체 대신에 트랜잭션을 처리해주는 프록시 객체가 스프링 빈에 등록된다. 그리고 주입을 받을 때도 실제 객 체 대신에 프록시 객체가 주입된다.

클래스 이름을 출력해보면 basicService$$EnhancerBySpringCGLIB... 라고 프록시 클래스의 이름이 출력되는 것을 확인할 수 있다.

 

@Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 그리고 실제 basicService 객체 대신에 프록시인 basicService$ $CGLIB 를 스프링 빈에 등록한다.

그리고 프록시는 내부에 실제 basicService 를 참조하게 된다. 여기서 핵 심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다.

 

클라이언트인 txBasicTest 는 스프링 컨테이너에 @Autowired BasicService basicService 로 의존관계 주입을 요청한다.

스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입한다.

프록시는 BasicService 를 상속해서 만들어지기 때문에 다형성을 활용할 수 있다. 따라서 BasicService 대신에 프록시인BasicService$$CGLIB 를 주입할 수 있다.

 

클라이언트가 주입 받은 basicService$$CGLIB 는 트랜잭션을 적용하는 프록시이다.

 

basicService.tx() 호출

클라이언트가 basicService.tx() 를 호출하면, 프록시의 tx() 가 호출된다. 여기서 프록시는 tx() 메서드가 트랜잭션을 사용할 수 있는지 확인해본다. tx() 메서드에는 @Transactional 이 붙어있으므로 트랜잭션 적용 대상이다.
따라서 트랜잭션을 시작한 다음에 실제 basicService.tx() 를 호출한다.
그리고 실제 basicService.tx() 의 호출이 끝나서 프록시로 제어가(리턴) 돌아오면 프록시는 트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.

 

basicService.nonTx() 호출

클라이언트가 basicService.nonTx() 를 호출하면, 트랜잭션 프록시의 nonTx() 가 호출된다. 여기서 nonTx() 메서드가 트랜잭션을 사용할 수 있는지 확인해본다.

nonTx() 에는 @Transactional 이 없으므 로 적용 대상이 아니다. 따라서 트랜잭션을 시작하지 않고, basicService.nonTx() 를 호출하고 종료한다.

 

TransactionSynchronizationManager.isActualTransactionActive()

현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 true 면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다.

 

 

트랜잭션 적용 위치

스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.

 

예를 들어서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다. 인터페이스와 해당 인터페이스를 구현한 클래스에 어노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다.

@SpringBootTest
public class TxLevelTest {
    @Autowired
    LevelService service;
    
    @Test
    void orderTest() {
        service.write();
        service.read();
    }
    
    @TestConfiguration
    static class TxApplyLevelConfig {
        @Bean
        LevelService levelService() {
            return new LevelService();
        }
    }
    
    //우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.
    //클래스 레벨에서 (readOnly = true)이지만 메서드 레벨에서 (readOnly = false) 이기때문에 tx()는 읽기 작업과 쓰기 작업 모두 할 수 있다.
    //(readOnly = true) : 읽기만 가능
    //(readOnly = false) : 읽기. 쓰기 모두 가능
    @Slf4j
    @Transactional(readOnly = true)
    static class LevelService {
        @Transactional(readOnly = false)
        public void write() {
            log.info("call write");
            printTxInfo();
        }
        
        public void read() {
            log.info("call read");
            printTxInfo();
        }
        
        private void printTxInfo() {
            boolean txActive =
                    TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
            boolean readOnly =
                    TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly={}", readOnly);
        }
    } 
}

 

스프링의 @Transactional 은 다음 두 가지 규칙이 있다.

  1. 우선순위 규칙
  2. 클래스에 적용하면 메서드는 자동 적용

 

우선순위

LevelService 의 타입에 @Transactional(readOnly = true) 이 붙어있다.
write() : 해당 메서드에 @Transactional(readOnly = false) 이 붙어있다.

 

이렇게 되면 타입에 있는 @Transactional(readOnly = true) 와 해당 메서드에 있는 @Transactional(readOnly = false) 둘 중 하나를 적용해야 한다.

 

클래스 보다는 메서드가 더 구체적이므로 메서드에 있는 @Transactional(readOnly = false) 옵션을 사용한 트랜잭션이 적용된다.

 

클래스에 적용하면 메서드는 자동 적용

read() : 해당 메서드에 @Transactional 이 없다. 이 경우 더 상위인 클래스를 확인한다.

클래스에 @Transactional(readOnly = true) 이 적용되어 있다. 따라서 트랜잭션이 적용되고 readOnly = true 옵션을 사용하게 된다.

 

readOnly=false 는 기본 옵션이기 때문에 보통 생략한다.
@Transactional == @Transactional(readOnly=false)

 

TransactionSynchronizationManager.isCurrentTransactionReadOnly

→현재 트랜잭션에 적용된 readOnly 옵션의 값을 반환한다.

 

  • write() 에서는 tx readOnly=false : 읽기 쓰기 트랜잭션이 적용되었다.
  • read() 에서는 tx readOnly=true : 읽기 전용 트랜잭션 옵션인 readOnly 가 적용되었다.

 

인터페이스에 @Transactional 적용

인터페이스에도 @Transactional 을 적용할 수 있다. 이 경우 다음 순서로 적용된다.

 

  1. 클래스의 메서드 (우선순위가 가장 높다.)
  2. 클래스의 타입
  3. 인터페이스의 메서드
  4. 인터페이스의 타입 (우선순위가 가장 낮다.)

 

클래스의 메서드를 찾고, 만약 없으면 클래스의 타입을 찾고 만약 없으면 인터페이스의 메서드를 찾고 그래도 없으면 인 터페이스의 타입을 찾는다.

 

하지만 인터페이스에 @Transactional 사용하는 것은 스프링 공식 메뉴얼에서 권장하지 않는 방법이다.

AOP를 적용하는 방식에 따라서 인터페이스에 아노테이션을 두면 AOP가 적용이 되지 않는 경우도 있기 때문이다. 가급적 구체 클래스에 @Transactional 을 사용하는 것이 좋다.

 

트랜잭션 AOP 주의 사항

프록시 내부 호출 문제 상황

트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.

이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고, 이후에 대상 객체를 호출하게 된다.

만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항 상 실제 객체 대신에 프록시 객체를 주입한다. 

 

프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반 적으로 발생하지 않는다. 

 

하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional 이 있어도 트랜잭션이 적용되지 않는다. 

@Slf4j
@SpringBootTest
public class InternalCallV1Test {
    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1Config {
        @Bean
        CallService callService() {
            return new CallService();
        }
    }

    @Slf4j
    static class CallService {
        
        //외부 메서드는 트랜잭션 어노테이션이 없다.
        public void external() {
            log.info("call external");
            printTxInfo();
            internal(); //외부에서 @Transactional 이 붙은 메서드를 실행하면 트랜잭션이 적용되지 않는다. -> 따로 클래스를 만들어야 한다.
        }

        //내부 메서드는 트랜잭션 어노테이션이 있다.
        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

CallService

  • external() 은 트랜잭션이 없다.
  • internal() 은 @Transactional 을 통해 트랜잭션을 적용한다.
  • @Transactional 이 하나라도 있으면 트랜잭션 프록시 객체가 만들어진다. 그리고 callService 빈을 주입받으면 트랜잭션 프록시 객체가 대신 주입된다.

 

internalCall() 실행

internalCall() 은 트랜잭션이 있는 코드인 internal() 을 호출한다.

  1. 클라이언트인 테스트 코드는 callService.internal() 을 호출한다. 여기서 callService 는 트랜 잭션 프록시이다.
  2. callService 의 트랜잭션 프록시가 호출된다.
  3. internal() 메서드에 @Transactional 이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용한다.
  4. 트랜잭션 적용 후 실제 callService 객체 인스턴스의 internal() 을 호출한다.

실제 callService 가 처리를 완료하면 응답이 트랜잭션 프록시로 돌아오고, 트랜잭션 프록시는 트랜잭션을 완료한다.

  • TransactionInterceptor 가 남긴 로그를 통해 트랜잭션 프록시가 트랜잭션을 적용한 것을 확인할 수 있다. 
  • CallService 가 남긴 tx active=true 로그를 통해 트랜잭션이 적용되어 있음을 확인할 수 있다.

 

externalCall() 실행

externalCall() 은 트랜잭션이 없는 코드인 external() 을 호출한다.

external() 은 @Transactional 애노테이션이 없다. 따라서 트랜잭션 없이 시작한다. 그런데 내부에서 @Transactional 이 있는internal() 을 호출하는 것을 확인할 수 있다.

실행 로그를 보면 트랜잭션 관련 코드가 전혀 보이지 않는다. 프록시가 아닌 실제 callService 에서 남긴 로그만 확인된다. 추가로 internal() 내부에서 호출한 tx active=false 로그를 통해 확실히 트랜잭션이 수행되지 않은 것을 확인할 수 있다.

internal() 에서 트랜잭션이 전혀 적용되지 않았다.

 

 

  1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다. 여기서 callService 는 트랜잭션 프록시이다.
  2. callService 의 트랜잭션 프록시가 호출된다.
  3. external() 메서드에는 @Transactional 이 없다. 따라서 트랜잭션 프록시는 트랜잭션을 적용하지 않는다.
  4. 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external() 을 호출한다.

 

external() 은 내부에서 internal() 메서드를 호출한다. 그런데 여기서 문제가 발생한다.

 

문제 원인

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다.

 

결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체( target)의 인스턴스를 뜻한다.

 

결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다. 결과적으로 target 에 있는 internal() 을 직접 호출하게 된 것이다.

 

 

프록시 방식의 AOP 한계

@Transactional 를 사용하는 트랜잭션 AOP는 프록시를 사용한다. 프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없다.

 

 

프록시 내부 호출 문제 해결

메서드 내부 호출 때문에 트랜잭션 프록시가 적용되지 않는 문제를 해결하기 위해 internal() 메서드를 별도의 클래스로 분리해야 한다.

 

@SpringBootTest
public class InternalCallV2Test {
    @Autowired
    CallService callService;

    @Test
    void externalCallV2() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV2Config {
        @Bean
        CallService callService() {
            return new CallService(innerService());
        }
        @Bean
        InternalService innerService() {
            return new InternalService();
        }
    }
    @Slf4j
    @RequiredArgsConstructor
    static class CallService {
        private final InternalService internalService;
        public void external() {
            log.info("call external");
            printTxInfo();
            internalService.internal();
        }
        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
    @Slf4j
    static class InternalService { //별도의 클래스 생성
        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }
        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}
  • InternalService 클래스를 만들고 internal() 메서드를 여기로 옮겼다. 이렇게 메서드 내부 호출을 외부 호출로 변경했다.
  • CallService 에는 트랜잭션 관련 코드가 전혀 없으므로 트랜잭션 프록시가 적용되지 않는다.
  • InternalService 에는 트랜잭션 관련 코드가 있으므로 트랜잭션 프록시가 적용된다.

  1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다.
  2. callService 는 실제 callService 객체 인스턴스이다.
  3. callService 는 주입 받은 internalService.internal() 을 호출한다.
  4. internalService 는 트랜잭션 프록시이다. internal() 메서드에 @Transactional 이 붙어 있으 므로 트랜잭션 프록시는 트랜잭션을 적용한다.
  5. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 을 호출한다.

 

 

public 메서드만 트랜잭션 적용

스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다. 그래서 protected , private , package-visible(default 접근 제한자) 에는 트랜잭션이 적용되지 않는다.

protected ,package-visible 도 외부에서 호출이 가능하다. 따라서 이 부분은 앞서 설명한 프록시의 내부 호출과는 무관하고, 스프링이 막아둔 것이다.

 

스프링 트랜잭션 관리의 디자인 철학은 애플리케이션의 서비스 레이어에서 비즈니스 로직의 실행을 관리하는 데 중점을 둔다.

서비스 레이어의 메소드는 일반적으로 public 접근 제한자를 사용하여 외부 컴포넌트로부터 호출될 수 있으며, 이러한 메소드에서 트랜잭션 관리의 적용이 가장 의미있게 여겨진다.

 

만약 public 뿐만 아니라 다른 접근 제한자에도 적용이 가능하다면, 트랜잭션을 의도하지 않는 곳 까지 트랜잭션이 과도하게 적용된다.

 

이런 이유로 public 메서드에만 트랜잭션을 적용하도록 설정되어 있다.

스프링 부트 3.0 부터는 protected , package-visible (default 접근제한자)에도 트랜잭션이 적용된다.
https://github.com/spring-projects/spring-framework/commit/37bebeaaaf294ef350ec646604124b5b78c6e690

 

초기화 시점

스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다.

 

초기화 코드(예: @PostConstruct )와 @Transactional 을 함께 사용하면 트랜잭션이 적용되지 않는다.

왜냐하면 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용되기 때문이다. 따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

@PostConstruct
 @Transactional
 public void initV1() {
     log.info("Hello init @PostConstruct");
 }

 


가장 확실한 대안은 ApplicationReadyEvent 이벤트를 사용하는 것이다.

@EventListener(value = ApplicationReadyEvent.class)
 @Transactional
 public void init2() {
     log.info("Hello init ApplicationReadyEvent");
 }

이 이벤트는 트랜잭션 AOP를 포함한 스프링이 컨테이너가 완전히 생성되고 난 다음에 이벤트가 붙은 메서드를 호출해준다.

 

 

트랜잭션 옵션

@Transactional 어노테이션

public @interface Transactional {
    String value() default "";
    String transactionManager() default "";
    Class<? extends Throwable>[] rollbackFor() default {};
    Class<? extends Throwable>[] noRollbackFor() default {};
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
    boolean readOnly() default false;
    String[] label() default {};
}

 

value 또는 transactionManager

스프링 프레임워크에서는 여러 개의 트랜잭션 매니저를 사용할 수 있다. value 속성은 사용할 트랜잭션 매니저의 빈 이름을 지정한다. 만약 애플리케이션에 여러 트랜잭션 매니저가 존재할 경우, 이 속성을 통해 특정 트랜잭션 매니저를 선택할 수 있다. 기본값은 ""이며, 이 경우 스프링은 기본 트랜잭션 매니저를 사용한다.

public class TxService {
     @Transactional("memberTxManager")
     public void member() {...}
     @Transactional("orderTxManager")
     public void order() {...}
 }

어노테이션에서 속성이 하나인 경우 위 예처럼 value 는 생략하고 값을 바로 넣을 수 있다.

 

rollbackFor

예외 발생시 스프링 트랜잭션의 기본 정책은 다음과 같다.

  • 언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면 롤백한다.
  • 체크 예외인 Exception 과 그 하위 예외들은 커밋한다.

rollbackFor는 트랜잭션이 롤백되어야 하는 예외를 지정한다. 이 속성에 지정된 예외 타입이 메소드 실행 중에 발생하면, 스프링은 자동으로 트랜잭션을 롤백한다. 이는 RuntimeException과 그 서브 클래스에 대해 기본적으로 적용되지만, 체크 예외에 대해서는 명시적으로 지정해야 한다.

@Transactional(rollbackFor = Exception.class)

이렇게 지정하면 체크 예외인 Exception 이 발생해도 롤백하게 된다. (하위 예외들도 대상에 포함된다.)

 

noRollbackFor

이 속성은 트랜잭션이 롤백되지 않아야 하는 예외를 지정한다. 즉, 이 속성에 지정된 예외가 발생해도 트랜잭션이 계속 커밋되도록 한다.

 

propagation

트랜잭션의 전파 동작을 결정한다. 예를 들어, 이미 진행 중인 트랜잭션이 있는 상황에서 새로운 트랜잭션을 시작할지, 혹은 기존 트랜잭션에 참여할지를 결정한다. Propagation.REQUIRED는 기본값이며, 현재 진행 중인 트랜잭션이 없을 경우 새로운 트랜잭션을 시작한다.

 

isolation

데이터베이스 트랜잭션의 격리 수준을 설정한다. 격리 수준에 따라 다른 트랜잭션으로부터의 동시성 문제를 어떻게 처리할지 결정한다. Isolation.DEFAULT는 데이터베이스의 기본 격리 수준을 사용한다.

대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다.

  • DEFAULT : 데이터베이스에서 설정한 격리 수준을 따른다.
  • READ_UNCOMMITTED` : 커밋되지 않은 읽기
  • READ_COMMITTED : 커밋된 읽기
  • REPEATABLE_READ : 반복 가능한 읽기
  • SERIALIZABLE : 직렬화 가능

 

timeout

트랜잭션이 완료되기를 기다리는 최대 시간을 초 단위로 지정한다. 이 시간을 초과하면 트랜잭션은 타임아웃되고 롤백된다. TransactionDefinition.TIMEOUT_DEFAULT는 데이터베이스 또는 트랜잭션 매니저의 기본 설정을 사용한다.

 

readOnly

이 속성이 true로 설정되면, 해당 트랜잭션은 읽기 전용으로 간주된다. 이는 트랜잭션이 데이터를 변경하지 않고 오직 읽기만 수행함을 나타낸다. 읽기 전용 트랜잭션은 성능 최적화에 도움을 줄 수 있다.

트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다.
readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 작동한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다.) 그리고 readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.

 

label

트랜잭션에 대한 라벨을 제공하여, 모니터링이나 로깅 시 트랜잭션을 식별하는 데 도움을 준다. 이는 트랜잭션을 보다 쉽게 추적하고 관리하는 데 유용하다. (일반적으로 잘 사용 안함)

 

예외와 트랜잭션 커밋, 롤백

예외가 발생했는데,내부에서 예외를 처리하지 못하고, 트랜잭션 범위(@Transactional가 적용된 AOP)밖으로 예외를 던지면 어떻게 될까?

예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.

 

언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면 트랜잭션을 롤백한다.

 

체크 예외인 Exception 과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.

 

물론 정상 응답(리턴)하면 트랜잭션을 커밋한다.

 

@SpringBootTest
public class RollbackTest {
    @Autowired
    RollbackService service;
    @Test
    void runtimeException() {
        assertThatThrownBy(() -> service.runtimeException())
                .isInstanceOf(RuntimeException.class);
    }
    @Test
    void checkedException() {
        assertThatThrownBy(() -> service.checkedException())
                .isInstanceOf(MyException.class);
    }
    @Test
    void rollbackFor() {
        assertThatThrownBy(() -> service.rollbackFor())
                .isInstanceOf(MyException.class);
    }
    @TestConfiguration
    static class RollbackTestConfig {
        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    @Slf4j
    static class RollbackService {

        //런타임 예외 발생: 롤백 @Transactional
        public void runtimeException() {
            log.info("call runtimeException");
            throw new RuntimeException();
        }

        //체크 예외 발생: 커밋
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        //체크 예외 rollbackFor 지정: 롤백
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
                     log.info("call rollbackFor");
                     throw new MyException();
            }
    }
    static class MyException extends Exception {
    }
}

runtimeException: 이 메소드는 RuntimeException을 발생시키며, @Transactional 어노테이션이 없으므로 스프링의 기본 설정에 따라 이러한 종류의 예외 발생 시 트랜잭션이 롤백된다.

 

checkedException: 이 메소드는 @Transactional 어노테이션을 사용하여 선언적 트랜잭션 관리를 활성화하지만, 스프링의 기본 설정은 체크 예외가 발생할 때 트랜잭션을 롤백하지 않는다. 따라서 MyException 체크 예외가 발생하더라도 트랜잭션은 커밋된다.

 

rollbackFor: 이 메소드는 @Transactional(rollbackFor = MyException.class) 어노테이션을 사용하여, MyException 예외가 발생할 경우 명시적으로 트랜잭션을 롤백하도록 지정한다. 따라서 MyException이 발생하면 롤백이 수행된다.

 

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 트랜잭션 전파 활용  (0) 2024.04.11
[Spring DB] 트랜잭션 전파  (1) 2024.04.10
[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


MyBatis 소개와 장점 및 단점

MyBatis는 자바(JAVA) 언어로 작성된 오픈 소스 SQL 매핑 프레임워크이다. JDBC(Java Database Connectivity) 위에 구축되어 데이터베이스와의 상호작용을 추상화하며, 개발자가 SQL 문을 직접 제어할 수 있게 해주는 특징을 가진다. 이는 개발자가 객체와 SQL 문 사이의 매핑을 설정하여, 데이터베이스 작업을 더 쉽고 직관적으로 할 수 있게 돕는다.

MyBatis의 주요 기능

  • SQL 분리: MyBatis는 SQL을 자바 코드에서 분리하여 XML 파일이나 어노테이션에 작성하도록 한다. 이로써, SQL 관리가 용이하고 가독성이 높아진다.
  • 동적 SQL: 조건에 따라 SQL 쿼리를 동적으로 생성할 수 있는 기능을 제공한다. 이는 검색 조건이 다양하고 복잡한 어플리케이션에서 유용하다.
  • 결과 매핑: SQL 쿼리 결과를 자바 객체에 자동으로 매핑한다. 컬럼 이름과 객체 필드 이름이 다른 경우에도 매핑 설정을 통해 쉽게 처리할 수 있다.

 

MyBatis의 장점

  • 직접적인 SQL 제어: MyBatis는 개발자가 SQL을 직접 작성하게 함으로써, 세밀한 쿼리 최적화와 복잡한 쿼리 작성이 가능하다.
  • 학습 곡선: MyBatis의 학습 곡선은 Hibernate나 JPA 같은 다른 ORM 프레임워크에 비해 상대적으로 낮다. SQL을 이미 알고 있다면 쉽게 배울 수 있다.
  • 유연성: 동적 SQL 지원을 통해 다양한 상황에 맞춤형 쿼리를 쉽게 작성할 수 있다는 점에서 유연성이 뛰어나다.
  • 통합성: Spring Framework와 같은 다른 자바 프레임워크와의 통합이 용이하다.

 

MyBatis의 단점

  • SQL 중심적: 모든 SQL 쿼리를 개발자가 직접 관리해야 하므로, SQL에 익숙하지 않은 개발자에게는 단점이 될 수 있다.
  • 복잡한 관계 매핑: 복잡한 객체 관계를 매핑하는 것이 JPA나 Hibernate에 비해 더 어렵고 수작업이 많이 필요하다.
  • 세션 관리: MyBatis에서는 SQL 세션을 직접 관리해야 할 필요가 있는데, 이는 때때로 복잡할 수 있다.
    MyBatis는 SQL을 직접 다루고 싶어하는 개발자에게 매우 유용한 도구이다. 동시에, 프로젝트의 요구 사항이나 팀의 기술 스택에 따라 ORM 프레임워크를 선택해야 한다. 복잡한 도메인 모델이나 객체 관계 매핑이 중요한 프로젝트의 경우, JPA나 Hibernate와 같은 다른 ORM 솔루션이 더 적합할 수 있다.

 

MyBatis는 JdbcTemplate 보다 더 많은 기능을 제공하는 SQL Mapper 이다.

기본적으로 JdbcTemplate이 제공하는 대부분의 기능을 제공한다.

JdbcTemplate과 비교해서 MyBatis의 가장 매력적인 점은 SQL을 XML에 편리하게 작성할 수 있고 또 동적 쿼리를 매우 편리하게 작성할 수 있다는 점이다.

 

JdbcTemplate - SQL 여러줄

String sql = "update item " +
         "set item_name=:itemName, price=:price, quantity=:quantity " +
         "where id=:id";

 

MyBatis - SQL 여러줄

<update id="update">
     update item
     set item_name=#{itemName},
         price=#{price},
         quantity=#{quantity}
     where id = #{id}
</update>

 

JdbcTemplate - 동적 쿼리

String sql = "select id, item_name, price, quantity from item"; //동적 쿼리
if (StringUtils.hasText(itemName) || maxPrice != null) {
     sql += " where";
 }
 boolean andFlag = false;
 if (StringUtils.hasText(itemName)) {
     sql += " item_name like concat('%',:itemName,'%')";
     andFlag = true;
 }
 if (maxPrice != null) {
     if (andFlag) {
         sql += " and";
     }
     sql += " price <= :maxPrice";
 }
 log.info("sql={}", sql);
 return template.query(sql, param, itemRowMapper());

 

MyBatis - 동적 쿼리

<select id="findAll" resultType="Item">
     select id, item_name, price, quantity
     from item
     <where>
         <if test="itemName != null and itemName != ''">
             and item_name like concat('%',#{itemName},'%')
         </if>
         <if test="maxPrice != null">
             and price &lt;= #{maxPrice}
         </if>
     </where>
 </select>

 

프로젝트에서 동적 쿼리와 복잡한 쿼리가 많다면 MyBatis를 사용하고, 단순한 쿼리들이 많으면 JdbcTemplate을 선 택해서 사용하면 된다. 물론 둘을 함께 사용해도 된다.

마이바티스 공식사이트
https://mybatis.org/mybatis-3/ko/index.html

 

MyBatis 스프링 부트 설정

build.gradle

dependencies {
    //MyBatis 추가
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:버전'
}

위와 같이 의존성을 넣어주면 아래와 같이 라이브러리가 추가된다.

버전 정보는 스프링 부트의 버전과 호환되는 버전이 다르기 때문에 직접 입력해야 한다.

  • mybatis-spring-boot-starter : MyBatis를 스프링 부트에서 편리하게 사용할 수 있게 시작하는 라이브러리
  • mybatis-spring-boot-autoconfigure : MyBatis와 스프링 부트 설정 라이브러리 
  • mybatis-spring : MyBatis와 스프링을 연동하는 라이브러리
  • mybatis : MyBatis 라이브러리

 

 

application.properties

#MyBatis
 mybatis.type-aliases-package=hello.itemservice.domain
 mybatis.configuration.map-underscore-to-camel-case=true



mybatis.type-aliases-package : 마이바티스에서 타입 정보를 사용할 때는 패키지 이름을 적어주어야 하는데, 여기에 명시하면 패키지 이름 을 생략할 수 있다. 지정한 패키지와 그 하위 패키지가 자동으로 인식된다. 여러 위치를 지정하려면 ,와 ; 로 구분하면 된다. 

mybatis.configuration.map-underscore-to-camel-case : JdbcTemplate의 BeanPropertyRowMapper 처럼 언더바를 카멜로 자동 변경해주는 기능을 활성화 한다. 

 

관례의 불일치

자바 객체에는 주로 camelCase 표기법을 사용한다. itemName 처럼 중간에 낙타 봉이 올라와 있는 표기법이다.
반면에 관계형 데이터베이스에서는 주로 언더스코어를 사용하는 snake_case 표기법을 사용한다.item_name 처럼 중간에 언더스코어를 사용하는 표기법이다.

이렇게 관례로 많이 사용하다 보니 map-underscore-to-camel-case 기능을 활성화 하면 언더스코어 표기법을 카멜로 자동 변환해준다. 따라서 DB에서 select item_name 으로 조회해도 객체의 itemName (setItemName()) 속성에 값이 정상 입력된다.

정리하면 해당 옵션을 켜면 snake_case는 자동으로 해결되니 그냥 두면 되고, 컬럼 이름과 객체 이름이 완전히 다른 경우에는 조회 SQL에서 별칭을 사용하면 된다.
예)
- DB select item_name
- 객체 name

별칭을 통한 해결방안 : select item_name as name

 

MyBatis 적용

ItemMapper 인터페이스

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

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

@Mapper
public interface ItemMapper {
    void save(Item item);
    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam);
    Optional<Item> findById(Long id);
    List<Item> findAll(ItemSearchCond itemSearch);
}

마이바티스 매핑 XML을 호출해주는 매퍼 인터페이스이다.
이 인터페이스에는 @Mapper 애노테이션을 붙여주어야 한다. 그래야 MyBatis에서 인식할 수 있다. 이 인터페이스의 메서드를 호출하면 다음에 보이는 xml 의 해당 SQL을 실행하고 결과를 돌려준다.

 

@Mapper

마이바티스(MyBatis) 프레임워크에서 사용되며, 인터페이스를 마이바티스의 매퍼로 표시한다. 이 애노테이션을 사용함으로써 해당 인터페이스의 메소드들이 SQL 쿼리와 매핑될 수 있게 되며, 마이바티스는 이를 통해 SQL 세션을 관리하고, 쿼리 실행 및 결과 매핑을 처리한다.

 

  • 매퍼 인터페이스 표시: @Mapper 애노테이션은 특정 인터페이스가 마이바티스 매퍼 인터페이스임을 나타낸다. 이는 마이바티스에게 이 인터페이스의 메소드를 데이터베이스 쿼리와 매핑하기 위한 것임을 알린다.
  • SQL 세션 관리: 마이바티스는 @Mapper로 표시된 인터페이스를 사용하여 내부적으로 SQL 세션을 관리한다. 개발자는 SQL 세션을 직접 열고 닫을 필요 없이, 매퍼 인터페이스를 통해 데이터베이스 작업을 수행할 수 있다.
  • 쿼리 실행 및 결과 매핑: 매퍼 인터페이스의 메소드는 SQL 쿼리나 명령어와 직접 연결된다. 마이바티스는 이 메소드들을 호출할 때 적절한 SQL 쿼리를 실행하고, 결과를 자바 객체로 매핑한다.

 

-void save() 메소드: 새로운 Item 객체를 데이터베이스에 저장한다. 이 작업은 XML 매핑 파일의 <insert> 태그를 통해 구현된다.

-void update() 메소드: 기존의 Item 객체를 업데이트한다. 이 때, @Param 애노테이션을 사용하여 메소드의 파라미터를 SQL 쿼리에 바인딩한다.

-findById() 메소드: 주어진 ID에 해당하는 Item 객체를 찾아 반환한다.

-findAll() 메소드: 조건에 맞는 모든 Item 객체를 찾아 리스트로 반환한다. 이 메소드는 동적 SQL을 사용하여 구현된다.

 

같은 위치에 실행할 SQL이 있는 XML 매핑 파일이 있어야한다.

참고로 자바 코드가 아니기 때문에 스프링 부트에서 src/main/resources 하위에 만들되, 패키지 위치는 맞추어 주어야 한다.

 

@Param

스프링 프레임워크의 일부로, 주로 스프링 데이터 JPA나 마이바티스(MyBatis) 같은 ORM(Object-Relational Mapping) 라이브러리에서 메소드 파라미터를 SQL 쿼리에 바인딩할 때 사용된다.

@Param 어노테이션을 사용하면 메소드 파라미터를 쿼리 내의 명시적인 파라미터로 전달할 수 있으며, 이는 코드의 가독성과 유지보수성을 향상시킨다.

메소드 파라미터를 XML 또는 어노테이션으로 작성된 SQL 쿼리에 바인딩할 수 있다. 마이바티스는 @Param 애노테이션을 통해 여러 파라미터를 쿼리에 전달할 때 특히 유용하다.
@Mapper
public interface UserMapper {
    @Select("SELECT * FROM users WHERE username = #{username} AND age = #{age}")
    User findUserByNameAndAge(@Param("username") String username, @Param("age") int age);
}​
이 예시에서는 @Param 애노테이션을 사용하여 username과 age 두 파라미터를 SQL 쿼리에 바인딩한다.
마이바티스는 이 애노테이션을 통해 메소드 파라미터의 값을 쿼리의 #{username}과 #{age}에 동적으로 삽입한다.

 

 

ItemMapper 인터페이스의 구현체

 

MyBatis를 사용할 때, 일반적으로 인터페이스의 구현체를 직접 작성하지 않는다.
대신, MyBatis가 런타임에 마이바티스의 매퍼 XML 파일이나 어노테이션을 기반으로 자동으로 구현체를 생성한다.

 

매퍼 구현체

  • 마이바티스 스프링 연동 모듈이 만들어주는 temMapper 의 구현체 덕분에 인터페이스 만으로 편리하게 XML 의 데이터를 찾아서 호출할 수 있다.
  • 원래 마이바티스를 사용하려면 더 번잡한 코드를 거쳐야 하는데, 이런 부분을 인터페이스 하나로 매우 깔끔하고 편리하게 사용할 수 있다.
  • 매퍼 구현체는 예외 변환까지 처리해준다. MyBatis에서 발생한 예외를 스프링 예외 추상화인 DataAccessException 에 맞게 변환해서 반환해준다. JdbcTemplate이 제공하는 예외 변환 기능을 여기서도 제공한다고 이해하면 된다.

 

매퍼 구현체 덕분에 마이바티스를 스프링에 편리하게 통합해서 사용할 수 있다.

매퍼 구현체를 사용하면 스프링 예외 추상화도 함께 적용된다.

마이바티스 스프링 연동 모듈이 많은 부분을 자동으로 설정해주는데, 데이터베이스 커넥션, 트랜잭션과 관련된 기능도 마이바티스와 함께 연동하고, 동기화해준다.

 

 

 

XML 매핑 파일

src/main/resources/hello/itemservice/repository/mybatis/ItemMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
    <insert id="save" useGeneratedKeys="true" keyProperty="id">
        insert into item (item_name, price, quantity)
        values (#{itemName}, #{price}, #{quantity})
    </insert>
    <update id="update">
        update item
        set item_name=#{updateParam.itemName},
            price=#{updateParam.price},
            quantity=#{updateParam.quantity}
        where id = #{id}
    </update>
    <select id="findById" resultType="Item">
        select id, item_name, price, quantity
        from item
        where id = #{id}
    </select>
    <select id="findAll" resultType="Item">
        select id, item_name, price, quantity
        from item
        <where>
            <if test="itemName != null and itemName != ''">
                and item_name like concat('%',#{itemName},'%')
            </if>
            <if test="maxPrice != null">
                and price &lt;= #{maxPrice}
            </if>
        </where>
    </select>
</mapper>

namespace : 앞서 만든 매퍼 인터페이스를 지정하면 된다.

참고 - XML 파일 경로 수정하기

XML 파일을 원하는 위치에 두고 싶으면 application.properties 에 다음과 같이 설정하면 된다.
mybatis.mapper-locations=classpath:mapper/**/*.xml 이렇게 하면 resources/mapper 를 포함한 그 하위 폴더에 있는 XML을 XML 매핑 파일로 인식한다. 이 경우 파일 이름은 자유롭게 설정해도 된다.

 

 

<insert> 태그

void save(Item item);

 <insert id="save" useGeneratedKeys="true" keyProperty="id">
     insert into item (item_name, price, quantity)
     values (#{itemName}, #{price}, #{quantity})
</insert>


save 메소드에 해당하는 SQL 쿼리를 정의한다. useGeneratedKeys="true"와 keyProperty="id"를 설정함으로써, 데이터베이스에 새로운 레코드가 삽입될 때 생성된 키(예: auto-increment ID)를 Item 객체의 id 필드에 자동으로 할당한다.

 

Insert SQL은 <insert> 를 사용하면 된다.
id 에는 매퍼 인터페이스에 설정한 메서드 이름을 지정하면 된다. 여기서는 메서드 이름이 save() 이므로 save 로 지정하면 된다.

파라미터는 #{} 문법을 사용하면 된다. 그리고 매퍼에서 넘긴 객체의 프로퍼티 이름을 적어주면 된다. #{} 문법을 사용하면PreparedStatement 를 사용한다. JDBC의 ? 를 치환한다 생각하면 된다.

useGeneratedKeys 는 데이터베이스가 키를 생성해 주는 IDENTITY 전략일 때 사용한다. keyProperty 는 생성되는 키의 속성 이름을 지정한다. Insert가 끝나면 item 객체의 id 속성에 생성된 값이 입력된다.

 

 

<update> 태그

import org.apache.ibatis.annotations.Param;
 void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam);
 
 <update id="update">
     update item
     set item_name=#{updateParam.itemName},
         price=#{updateParam.price},
         quantity=#{updateParam.quantity}
     where id = #{id}
 </update>


update 메소드의 SQL 쿼리를 정의한다. 이 쿼리는 updateParam 객체의 필드 값을 사용하여 특정 id를 가진 레코드를 업데이트한다.

Update SQL은 <update> 를 사용하면 된다.
여기서는 파라미터가 Long id , ItemUpdateDto updateParam 으로 2개이다. 파라미터가 1개만 있으면 @Param 을 지정하지 않아도 되지만, 파라미터가 2개 이상이면 @Param 으로 이름을 지정해서 파라미터를 구분해야 한다.

 

<select> 태그

Optional<Item> findById(Long id);

 <select id="findById" resultType="Item">
     select id, item_name, price, quantity
     from item
     where id = #{id}
</select>



findById와 findAll 메소드에 해당하는 SQL 쿼리를 정의한다. findById는 단일 Item 객체를 반환하는 반면, findAll은 조건에 따라 여러 Item 객체를 리스트로 반환한다. findAll 메소드에서는 <where> 및 <if> 태그를 사용한 동적 SQL을 통해, itemName이나 maxPrice 같은 조건에 따라 다양한 검색 쿼리를 실행할 수 있다.

 

Select SQL은 <select> 를 사용하면 된다.

 

resultType 은 반환 타입을 명시하면 된다. 여기서는 결과를 Item 객체에 매핑한다.

application.properties 에 mybatis.type-aliases- package=hello.itemservice.domain 속성을 지정한 덕분에 모든 패키지 명을 다 적지는 않아도 된다. 그렇지 않으면 모든 패키지 명을 다 적어야 한다. JdbcTemplate의 BeanPropertyRowMapper 처럼 SELECT SQL의 결과를 편리하게 객체로 바로 변환해준다.

 

mybatis.configuration.map-underscore-to-camel-case=true 속성을 지정하면 언더스코어를 카멜 표기법으로 자동으로 처리해준다. ( item_name → itemName )

 

자바 코드에서 반환 객체가 하나이면 Item , Optional<Item> 과 같이 사용하면 되고, 반환 객체가 하나 이상 이면 컬렉션을 사용하면 된다. 주로 List 를 사용한다.

 

findAll - 동적 SQL

List<Item> findAll(ItemSearchCond itemSearch); 

 <select id="findAll" resultType="Item">
     select id, item_name, price, quantity
     from item
     <where>
         <if test="itemName != null and itemName != ''">
             and item_name like concat('%',#{itemName},'%')
         </if>
         <if test="maxPrice != null">
             and price &lt;= #{maxPrice}
         </if>
     </where>
 </select>

findAll 메소드의 SQL 쿼리에서 와 를 사용하여 조건에 따라 다르게 실행되는 SQL을 구현한다. 예를 들어, itemName 조건이 주어지면 item_name 컬럼에 대해 LIKE 검색을 수행하고, maxPrice 조건이 주어지면 가격에 대한 비교 조건을 적용한다. 

 

이와 같은 동적 SQL은 검색 조건이 유연하게 변경될 수 있는 경우에 매우 유용하다.

 

Mybatis는 <where> , <if> 같은 동적 쿼리 문법을 통해 편리한 동적 쿼리를 지원한다. <if> 는 해당 조건이 만족하면 구문을 추가한다.
<where> 은 적절하게 where 문장을 만들어준다.

  • 예제에서 <if> 가 모두 실패하게 되면 SQL where 를 만들지 않는다.
  • 예제에서 <if> 가 하나라도 성공하면 처음 나타나는 and 를 where 로 변환해준다.

 

XML 특수문자

→and price &lt;= #{maxPrice}

여기에보면 <= 를사용하지않고 &lt;= 를사용한것을확인할수있다.그 이유는 XML에서는 데이터 영역에 <, > 같은 특수문자를 사용할 수 없기 때문이다.

이유는 간단한데, XML에서 TAG가 시작하거나 종료할때 <, >와 같은 특수문자를 사용하기 때문이다. 

  • < : &lt;
  • > : &gt;
  • & : &amp;

 

동적 쿼리

MyBatis 공식 메뉴얼: https://mybatis.org/mybatis-3/ko/index.html 

MyBatis 스프링 공식 메뉴얼: https://mybatis.org/spring/ko/index.html

마이바티스 동적쿼리 메뉴얼 : https://mybatis.org/mybatis-3/ko/dynamic-sql.html

 

마이바티스가 제공하는 최고의 기능이자 마이바티스를 사용하는 이유는 바로 동적 SQL 기능 때문이다.

동적 쿼리를 위해 제공되는 기능은 다음과 같다.

  • if
  • choose (when, otherwise)
  • trim (where, set)
  • foreach

 

if

<select id="findActiveBlogWithTitleLike" resultType="Blog">
   SELECT * FROM BLOG
   WHERE state = ‘ACTIVE’
   <if test="title != null">
     AND title like #{title}
   </if>
</select>
  • 해당 조건에 따라 값을 추가할지 말지 판단한다.
  • 내부의 문법은 OGNL을 사용한다.

 

choose, when, otherwise

<select id="findActiveBlogLike" resultType="Blog">
   SELECT * FROM BLOG WHERE state = ‘ACTIVE’
   <choose>
     <when test="title != null">
       AND title like #{title}
     </when>
     <when test="author != null and author.name != null">
       AND author_name like #{author.name}
     </when>
     <otherwise>
       AND featured = 1
     </otherwise>
   </choose>
</select>

자바의 switch 구문과 유사한 구문도 사용할 수 있다.

 

trim, where, set

 <select id="findActiveBlogLike" resultType="Blog">
   SELECT * FROM BLOG
   WHERE
   <if test="state != null">
     state = #{state}
   </if>
   <if test="title != null">
     AND title like #{title}
   </if>
   <if test="author != null and author.name != null">
     AND author_name like #{author.name}
   </if>
</select>

이 예제의 문제점은 문장을 모두 만족하지 않을 때 발생한다.

모두 만족하지 않게 되면 아래와 같은 SQL이 완성된다. 이는 문법 오류다.

SELECT * FROM BLOG
 WHERE

 

title 만 만족할 때도 문제가 발생한다.

SELECT * FROM BLOG
 WHERE
AND title like ‘someTitle’

 

결국 WHERE 문을 언제 넣어야 할지 상황에 따라서 동적으로 달라지는 문제가 있다. <where> 를 사용하면 이런 문제를 해결할 수 있다.

<select id="findActiveBlogLike"
      resultType="Blog">
   SELECT * FROM BLOG
   <where>
     <if test="state != null">
          state = #{state}
     </if>
     <if test="title != null">
         AND title like #{title}
     </if>
     <if test="author != null and author.name != null">
         AND author_name like #{author.name}
     </if>
   </where>
</select>

<where> 는 문장이 없으면 where 를 추가하지 않는다. 문장이 있으면 where 를 추가한다. 만약 and 가 먼저 시작 된다면 and 를 지운다.


다음과 같이 trim 이라는 기능으로 사용해도 된다. 이렇게 정의하면 <where> 와 같은 기능을 수행한다.

<trim prefix="WHERE" prefixOverrides="AND |OR ">
   ...
</trim>

 

foreach

<select id="selectPostIn" resultType="domain.blog.Post">
    SELECT *
    FROM POST P
        <where>
            <foreach item="item" index="index" collection="list"
                open="ID in (" separator="," close=")" nullable="true">
                #{item}
            </foreach>
        </where>
</select>
  • 컬렉션을 반복 처리할 때 사용한다. where in (1,2,3,4,5,6) 와 같은 문장을 쉽게 완성할 수 있다.
  • 파라미터로 List 를 전달하면 된다

 

기타 기능

어노테이션으로  SQL 작성

@Select("select id, item_name, price, quantity from item where id=#{id}")
Optional<Item> findById(Long id);
  • @Insert , @Update, @Delete, @Select 기능이 제공된다.
  • 동적 SQL이 해결되지 않으므로 간단한 경우에만 사용한다.

 

문자열 대체(String Substitution)

#{} 문법은 ?를 넣고 파라미터를 바인딩하는 PreparedStatement 를 사용한다.

때로는 파라미터 바인딩이 아니라 문자 그대로를 처리하고 싶은 경우도 있다. 이때는 ${} 를 사용하면 된다.

 

ORDER BY ${columnName}

@Select("select * from user where ${column} = #{value}")
User findByColumn(@Param("column") String column, @Param("value") String value);

${} 를 사용하면 SQL 인젝션 공격을 당할 수 있다. 따라서 가급적 사용하면 안된다. 사용하더라도 매우 주의깊게 사용해야 한다.

 

재사용 가능한 SQL 조각

<sql> 을 사용하면 SQL 코드를 재사용 할 수 있다.

<sql id="userColumns"> ${alias}.id,${alias}.username,${alias}.password </sql>

 

 

<select id="selectUsers" resultType="map">
   select
     <include refid="userColumns"><property name="alias" value="t1"/></include>,
     <include refid="userColumns"><property name="alias" value="t2"/></include>
   from some_table t1
     cross join some_table t2
 </select>

<include> 를 통해서 <sql> 조각을 찾아서 사용할 수 있다.

 

<sql id="sometable">
   ${prefix}Table
</sql>
 <sql id="someinclude">
   from
     <include refid="${include_target}"/>
 </sql>
 <select id="select" resultType="map">
   select
     field1, field2, field3
   <include refid="someinclude">
     <property name="prefix" value="Some"/>
     <property name="include_target" value="sometable"/>
   </include>
</select>

프로퍼티 값을 전달할 수 있고, 해당 값은 내부에서 사용할 수 있다.

 

Result Maps

결과를 매핑할 때 테이블은 user_id 이지만 객체는 id 이다.
이 경우 컬럼명과 객체의 프로퍼티 명이 다르다. 그러면 다음과 같이 별칭(as)을 사용하면 된다.

<select id="selectUsers" resultType="User">
  select
    user_id
    user_name
    hashed_password
  from some_table
  where id = #{id}
</select>

 

별칭을 사용하지 않고도 문제를 해결할 수 있는데, 다음과 같이 resultMap 을 선언해서 사용하면 된다.

<resultMap id="userResultMap" type="User">
   <id property="id" column="user_id" />
   <result property="username" column="user_name"/>
   <result property="password" column="hashed_password"/>
 </resultMap>
 <select id="selectUsers" resultMap="userResultMap">
   select user_id, user_name, hashed_password
   from some_table
   where id = #{id}
</select>

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


@SpringBootTest와 @SpringBootApplication

@SpringBootApplication

@SpringBootApplication은 Spring Boot 애플리케이션의 주 진입점에 위치하는 어노테이션이다. 이 어노테이션은 @Configuration, @EnableAutoConfiguration, @ComponentScan 어노테이션들의 기능을 합친 것으로, Spring Boot 애플리케이션을 자동 설정하고, 애플리케이션 컨텍스트에서 빈을 검색하며, 추가적인 설정을 로드하는 역할을 한다.

 

기본적으로, 이 어노테이션이 붙은 클래스는 애플리케이션의 메인 클래스로, 애플리케이션 실행 시 스프링 부트의 자동 설정 메커니즘을 활성화한다.

 

@SpringBootTest

@SpringBootTest는 테스트 클래스에 사용되며, Spring Boot 애플리케이션 컨텍스트를 테스트 환경에서 전체적으로 로드한다. 이를 통해 애플리케이션의 통합 테스트를 수행할 수 있게 되며, 실제 실행 환경과 유사한 조건에서 애플리케이션의 다양한 컴포넌트들을 테스트할 수 있다.

 

@SpringBootTest는 @SpringBootApplication과 연결되어 있다고 볼 수 있는데, 이는 @SpringBootTest가 애플리케이션 컨텍스트를 로드할 때 @SpringBootApplication 어노테이션이 붙은 메인 클래스를 기반으로 애플리케이션의 설정과 구성을 로드하기 때문이다.

 

관계

@SpringBootTest를 사용하는 테스트는 @SpringBootApplication 어노테이션이 붙은 애플리케이션 메인 클래스의 설정을 기반으로 실행된다. 이는 테스트 시 실제 애플리케이션을 실행할 때와 동일한 스프링 부트의 자동 설정, 컴포넌트 스캔, 외부 설정 로드 등이 적용됨을 의미한다.

따라서, @SpringBootTest를 사용한 테스트는 애플리케이션의 구성 요소들이 실제 환경에서 어떻게 상호작용하는지를 포괄적으로 검증할 수 있는 환경을 제공받는다.

 

요약하자면, @SpringBootTest와 @SpringBootApplication은 Spring Boot 애플리케이션에서 각각 테스트와 애플리케이션 구동을 위해 사용되며, @SpringBootTest로 작성된 테스트는 @SpringBootApplication이 적용된 애플리케이션의 전체 컨텍스트와 설정을 기반으로 실행된다. 이 관계 덕분에 개발자는 애플리케이션의 통합 테스트를 실제와 유사한 환경에서 수행할 수 있게 된다.

 

데이터 롤백

테스트에서 매우 중요한 원칙은 다음과 같다.

-테스트는 다른 테스트와 격리해야 한다.

-테스트는 반복해서 실행할 수 있어야 한다.

 

이때 도움이 되는 것이 바로 트랜잭션이다.
테스트가 끝나고 나서 트랜잭션을 강제로 롤백해버리면 데이터가 깔끔하게 제거된다.
테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못해도 괜찮다. 트랜잭션을 커밋하지 않았기 때문에 데이터베이스에 해당 데이터가 반영되지 않는다.
이렇게 트랜잭션을 활용하면 테스트가 끝나고 나서 데이터를 깔끔하게 원래 상태로 되돌릴 수 있다.

//트랜잭션 관련 코드
@Autowired
PlatformTransactionManager transactionManager;
TransactionStatus status;
 @BeforeEach
 void beforeEach() {
    //트랜잭션 시작
    status = transactionManager.getTransaction(new DefaultTransactionDefinition());
}

 @AfterEach
 void afterEach() {
    //트랜잭션 롤백
    transactionManager.rollback(status);
}

PlatformTransactionManager와 TransactionStatus는 Spring Framework에서 트랜잭션 관리를 위해 사용되는 주요 인터페이스이다. 

 

-PlatformTransactionManager는 트랜잭션 관리의 핵심 인터페이스로, 트랜잭션을 시작, 커밋, 롤백하는 데 필요한 메소드를 제공한다.

-TransactionStatus는 진행 중인 트랜잭션의 상태를 나타내며, 트랜잭션이 새롭게 시작되었는지, 롤백이 필요한지 등의 정보를 포함한다.

여기서 transactionManager 객체는 스프링의 PlatformTransactionManager 인터페이스의 구현체를 참조한다. 

 

status 객체는 transactionManager.getTransaction() 메소드를 호출할 때 반환되는 TransactionStatus 객체이다. 이 두 객체를 사용하여 애플리케이션 내에서 트랜잭션을 명시적으로 제어할 수 있다.

 

status = transactionManager.getTransaction(new DefaultTransactionDefinition()); 코드는 트랜잭션 관리 기능을 사용하여 새로운 트랜잭션을 시작하는 과정을 나타낸다. 이 코드는 PlatformTransactionManager 인터페이스의 getTransaction() 메소드를 호출하여 트랜잭션을 시작하고, 트랜잭션의 상태를 나타내는 TransactionStatus 객체를 반환받는다.

 

new DefaultTransactionDefinition()은 기본 트랜잭션 속성과 설정으로 트랜잭션을 시작하기 위한 파라미터다.

transactionManager.rollback(status); 코드는 시작된 트랜잭션이 성공적으로 완료되지 못했을 때, 즉 예외가 발생했거나 비즈니스 로직이 실패하여 트랜잭션을 롤백해야 할 상황에서 사용된다. 여기서 rollback() 메소드는 주어진 TransactionStatus 객체에 해당하는 트랜잭션을 롤백한다.

 

테스트 코드에서 @Transactional

테스트 코드에서 @Transactional 어노테이션을 사용하는 것은 매우 흔한 패턴이다. 이 어노테이션은 테스트 메소드나 테스트 클래스에 적용할 수 있으며, 테스트가 실행될 때 트랜잭션을 시작하고 테스트가 완료된 후에는 롤백을 수행한다. 

이러한 접근 방식은 테스트 중에 데이터베이스에 변경을 가하는 작업을 수행할 때 매우 유용하다. 테스트가 종료되면, 테스트 중에 수행된 모든 데이터 변경 사항이 롤백되어 데이터베이스의 상태가 테스트 실행 전과 동일하게 유지된다.

@Transactional의 장점

  • 격리성: 각 테스트 메소드가 서로에게 영향을 주지 않고 독립적으로 실행될 수 있게 해준다. 이는 테스트 간 데이터 충돌을 방지하고 예측 가능한 테스트 결과를 보장한다.
  • 일관성: 테스트 실행 전후로 데이터베이스의 상태가 변경되지 않으므로, 테스트 환경의 일관성을 유지할 수 있다.
  • 편의성: 데이터베이스를 직접 청소하지 않고도 테스트 후 데이터베이스를 깨끗한 상태로 유지할 수 있다.

 

@RunWith(SpringRunner.class)
@SpringBootTest
public class SomeServiceTest {

    @Autowired
    private SomeService someService;

    @Test
    @Transactional
    public void testSomeServiceMethod() {
        // 테스트 수행
        someService.performSomeDataModification();

        // 검증 로직
        // ...

        // 테스트가 종료되면, performSomeDataModification에 의한 데이터 변경 사항이 롤백된다.
    }
}

이 예제에서, @Transactional 어노테이션은 testSomeServiceMethod 테스트 메소드에 적용되어 있다. 이 메소드에서는 SomeService의 performSomeDataModification 메소드를 호출하여 데이터베이스에 변화를 주는 작업을 수행한다. 

테스트 메소드 실행이 완료되면, 실행 중에 발생한 모든 데이터 변경은 롤백되어 데이터베이스의 상태가 테스트 실행 전과 동일하게 유지된다.

주의 사항

  • @Transactional을 테스트에 사용할 때는, 롤백으로 인해 테스트가 데이터베이스에 영구적인 변경을 가하지 않음을 이해하는 것이 중요하다.
    테스트에서 @Transactional의 롤백 동작을 원치 않는 경우 @Commit 어노테이션을 사용하여 트랜잭션의 커밋을 강제할 수 있다. 그러나 이는 매우 신중하게 사용되어야 한다.

@Transactional 어노테이션은 테스트에서 데이터베이스 상태를 관리하는 강력한 도구이지만, 그 사용 방법과 영향을 잘 이해하고 사용해야 한다.

 

임베디드 모드 DB

임베디드 모드 데이터베이스(Embedded Database)는 애플리케이션과 함께 실행되며, 별도의 서버 설치나 관리가 필요 없는 데이터베이스를 말한다. 

 

이러한 데이터베이스는 주로 개발이나 테스트 환경에서 사용되며, 실제 운영 환경에서는 사용되는 경우가 적다. 임베디드 데이터베이스의 가장 큰 장점은 설치가 간편하고, 애플리케이션과 동일한 라이프사이클을 가진다는 것이다.

임베디드 모드 데이터베이스의 특징

  • 경량성: 별도의 서버 설치나 관리 없이 애플리케이션 내부에서 실행되므로, 경량화가 잘 되어 있고 설정이 간단하다.
  • 이동성: 애플리케이션과 함께 데이터베이스도 패키지화되어 배포될 수 있으므로, 어디서든 같은 환경을 빠르게 구축할 수 있다.
  • 개발 및 테스트 용이성: 개발 및 테스트 단계에서 실제 데이터베이스 서버를 구축하지 않고도, 실제 데이터베이스와 유사한 환경을 손쉽게 구성할 수 있다.
  • 독립성: 애플리케이션과 동일한 프로세스에서 실행되므로, 애플리케이션 실행이 종료되면 데이터베이스도 함께 종료된다.

 

H2 Database

Java로 작성된 경량화된 관계형 데이터베이스로, 매우 빠른 데이터베이스 엔진을 제공한다. 개발 및 테스트 환경에서 널리 사용된다.

 

스프링 부트에서 H2 데이터 베이스를 사용하려면 아래와 같이 build.gradle에 의존성을 주입해야 한다.

dependencies {
	...
	//H2 데이터베이스 추가
	runtimeOnly 'com.h2database:h2'
    ...
}

 

H2 데이터베이스는 자바로 개발되어 있고, JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 

그래서 애플 리케이션을 실행할 때 H2 데이터베이스도 해당 JVM 메모리에 포함해서 함께 실행할 수 있다. DB를 애플리케이션에 내장해서 함께 실행한다고 해서 임베디드 모드(Embedded mode)라 한다. 물론 애플리케이션이 종료되면 임베디드 모드로 동작하는 H2 데이터베이스도 함께 종료되고, 데이터도 모두 사라진다. 

 

쉽게 이야기해서 애플리케이션에서 자바 메모리를 함께 사용하는 라이브러리처럼 동작하는 것이다.

 

@Bean
@Profile("test")
public DataSource dataSource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}
  • @Bean: 스프링 컨테이너에 의해 관리되는 빈을 정의한다. 이 메소드가 반환하는 객체(DataSource)는 스프링 애플리케이션 컨텍스트에 등록되어 다른 빈들에서 주입하여 사용할 수 있다.
  • @Profile("test"): 이 어노테이션이 붙은 빈은 "test" 프로파일이 활성화되었을 때만 생성된다. 프로파일을 이용해 개발, 테스트, 운영 환경 등 여러 환경에서 다른 설정을 적용할 수 있다.
  • DriverManagerDataSource: 스프링이 제공하는 간단한 DataSource 구현체로, 각종 데이터베이스 연결 정보(드라이버 클래스명, URL, 사용자 이름, 비밀번호)를 설정할 수 있다.
  • dataSource.setDriverClassName("org.h2.Driver"): JDBC 드라이버 클래스를 지정한다. H2 데이터베이스를 사용하기 때문에 H2 드라이버 클래스명을 설정한다.
  • dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1"): 데이터베이스 연결 URL을 설정한다. 여기서 jdbc:h2:mem:db는 메모리 내에서 실행되는 H2 데이터베이스에 연결하라는 의미이고, DB_CLOSE_DELAY=-1 옵션은 애플리케이션이 실행되는 동안 데이터베이스가 유지되도록 한다.
  • dataSource.setUsername("sa")와 dataSource.setPassword(""): 데이터베이스 연결을 위한 사용자 이름과 비밀번호를 설정한다. H2의 기본 사용자 이름은 "sa"이고, 비밀번호는 공백이다.

 

애플리케이션에서 자바 메모리를 함께 사용하기 때문에 프로젝트 안에 해당 데이터베이스가 있어야 한다.

예를 들면 아래와 같다.

src/test/resources/schema.sql

drop table if exists item CASCADE;
 create table item
 (
     id        bigint generated by default as identity,
     item_name varchar(10),
     price     integer,
     quantity  integer,
     primary key (id)
 );

 

SQL 스크립트를 사용해서 데이터베이스를 초기화하는 자세한 방법은 다음 스프링 부트 공식 메뉴얼을 참고

https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto.data- initialization.using-basic-sql-scripts

 

 

스프링 부트와 임베디드 모드

application.properties

spring.profiles.active=test

#spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
#spring.datasource.username=sa

spring.datasource.url , spring.datasource.username 를 사용하지 않도록 # 을 사용해서 주석처리 했다.
이렇게 하면 데이터베이스에 접근하는 모든 설정 정보가 사라지게 된다.
이렇게 별다른 정보가 없으면 스프링 부트는 임베디드 모드로 접근하는 데이터소스( DataSource )를 만들어서 제공한다.

 

즉, 스프링 부트는 데이터베이스에 대한 별다른 설정이 없으면 임베디드 데이터베이스를 사용한다.

 

 

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 스프링 트랜잭션의 이해  (0) 2024.04.06
[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01
[Spring DB] NamedParameterJdbcTemplate  (0) 2024.03.31
[Spring DB] JDBC Template  (0) 2024.03.29

SimpleJdbcInsert는 Spring Framework에서 제공하는 JDBC 추상화의 일부로, 데이터베이스에 새로운 레코드를 삽입하는 작업을 단순화하고 편리하게 만들어준다. NamedParameterJdbcTemplate과 유사하게, SimpleJdbcInsert는 이름이 지정된 파라미터를 사용하여 SQL 쿼리 없이 데이터베이스 테이블에 직접 삽입할 수 있게 해준다. 이를 통해 코드의 가독성이 향상되고, SQL 쿼리 실수를 줄일 수 있다.

설정 방법

SimpleJdbcInsert는 DataSource를 사용하여 생성될 수 있다. 생성 후, 사용할 데이터베이스 테이블과 해당 테이블의 기본 키 컬럼을 설정할 수 있다.

@Autowired
private DataSource dataSource;

private SimpleJdbcInsert simpleJdbcInsert;

@PostConstruct
public void postConstruct() {
    simpleJdbcInsert = new SimpleJdbcInsert(dataSource)
        .withTableName("users") // 사용할 테이블 이름 설정
        .usingGeneratedKeyColumns("id"); // 자동 생성되는 키 컬럼 이름 설정
}

 

 

사용 예제

SimpleJdbcInsert를 사용하여 데이터베이스에 레코드를 삽입하는 과정은 다음과 같다.

Map<String, Object> parameters = new HashMap<>();
parameters.put("name", "홍길동");
parameters.put("email", "hong@example.com");

// 삽입 실행 및 자동 생성된 키 반환
Number newId = simpleJdbcInsert.executeAndReturnKey(parameters);

이 예제에서는 Map을 사용하여 삽입할 데이터를 정의한다. 그리고 executeAndReturnKey 메소드를 사용하여 실제 삽입 작업을 수행하고, 자동으로 생성된 키(예를 들어, auto-increment 속성을 가진 ID 컬럼의 값)를 반환받는다.

장점

SQL 쿼리 작성 필요 없음: SimpleJdbcInsert를 사용하면 복잡한 INSERT SQL 쿼리를 작성할 필요가 없어, 실수의 가능성을 줄일 수 있다.
가독성 향상: 삽입할 데이터를 Map이나 SqlParameterSource로 직접 정의하기 때문에, 코드의 가독성이 향상된다.
자동 키 생성 지원: 자동으로 생성된 키 값을 쉽게 얻을 수 있어, 삽입 후 해당 레코드에 대한 후속 작업을 간편하게 수행할 수 있다.

 

withTableName(String tableName)

withTableName 메소드는 SimpleJdbcInsert가 데이터를 삽입할 테이블의 이름을 설정한다.
이 메소드는 SimpleJdbcInsert 객체를 생성한 후 호출되어야 하며, 한 번 설정되면 해당 인스턴스는 설정된 테이블 이름으로만 삽입 작업을 수행한다.

 

usingGeneratedKeyColumns(String... columnNames)

usingGeneratedKeyColumns 메소드는 데이터 삽입 시 데이터베이스에 의해 자동 생성되는 키 컬럼의 이름을 지정한다. 주로 자동 증가(auto-increment) 필드나 시퀀스(sequence)로부터 값을 얻는 필드에 사용된다.
이 메소드를 사용함으로써, SimpleJdbcInsert는 삽입 후 생성된 키 값을 반환할 수 있게 된다. 이는 executeAndReturnKey 메소드를 호출할 때 유용하다.

 

usingColumns(String... columnNames)

usingColumns 메소드는 삽입할 때 사용될 컬럼의 이름을 명시적으로 지정한다. 이는 삽입 작업에 포함될 필드를 제한할 때 유용하다.
만약 이 메소드를 사용하지 않는다면, SimpleJdbcInsert는 파라미터로 전달된 모든 필드를 삽입 작업에 포함시킨다. 하지만 특정 필드만 삽입하고자 할 때 이 메소드를 사용하여 삽입할 컬럼을 지정할 수 있다.

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] NamedParameterJdbcTemplate  (0) 2024.03.31
[Spring DB] JDBC Template  (0) 2024.03.29
[Spring MVC] 파일 업로드  (0) 2024.03.25

NamedParameterJdbcTemplate은 Spring Framework의 JDBC 접근 방법 중 하나로, JdbcTemplate과 유사하게 작동하지만, SQL 파라미터를 이름으로 지정할 수 있다는 주요 차이점이 있다. 이는 코드의 가독성을 높이고, SQL 쿼리의 파라미터를 더 명확하게 만드는 데 도움을 준다.

CRUD

설정

NamedParameterJdbcTemplate 인스턴스를 생성해야 한다. 이는 보통 DataSource를 주입하여 생성된다.

@Autowired
private DataSource dataSource;

private NamedParameterJdbcTemplate jdbcTemplate;

@PostConstruct
public void postConstruct() {
    jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
}

 

 

Create

String insertSql = "INSERT INTO users (name, email) VALUES (:name, :email)";
MapSqlParameterSource parameters = new MapSqlParameterSource()
    .addValue("name", "John Doe")
    .addValue("email", "johndoe@example.com");

int rowsAffected = jdbcTemplate.update(insertSql, parameters);

 

 

Read

단일 객체 조회

String selectSql = "SELECT * FROM users WHERE id = :id";
SqlParameterSource namedParameters = new MapSqlParameterSource("id", 1);
User user = jdbcTemplate.queryForObject(selectSql, namedParameters, new BeanPropertyRowMapper<>(User.class));

 

리스트 조회

String selectAllSql = "SELECT * FROM users";
List<User> users = jdbcTemplate.query(selectAllSql, new MapSqlParameterSource(), new BeanPropertyRowMapper<>(User.class));

 

 

Update

String updateSql = "UPDATE users SET email = :email WHERE name = :name";
MapSqlParameterSource parameters = new MapSqlParameterSource()
    .addValue("email", "newemail@example.com")
    .addValue("name", "John Doe");

int rowsAffected = jdbcTemplate.update(updateSql, parameters);

 

 

Delete

String deleteSql = "DELETE FROM users WHERE id = :id";
SqlParameterSource namedParameters = new MapSqlParameterSource("id", 1);

int rowsAffected = jdbcTemplate.update(deleteSql, namedParameters);

 

 

SqlParameterSource

Spring의 JDBC 접근 방식에서 쿼리 파라미터를 정의할 때 사용되는 인터페이스로, 다양한 구현체를 통해 파라미터 값을 지정할 수 있다. 이 인터페이스를 사용하는 주된 목적은 SQL 쿼리 또는 업데이트 문에 동적으로 파라미터를 전달하는 것이다. 여기서는 SqlParameterSource의 두 가지 주요 구현체인 BeanPropertySqlParameterSource와 MapSqlParameterSource, 그리고 일반 Map을 사용한 방식에 대해 설명한다.

BeanPropertySqlParameterSource

BeanPropertySqlParameterSource는 객체의 속성을 SQL 파라미터로 사용하는 경우에 적합하다. 이 구현체는 객체의 getter 메소드를 통해 속성 값을 읽어, 파라미터의 이름과 값으로 매핑한다.

public class User {
    private String name;
    private String email;
    // getters and setters 생략
}

User user = new User();
user.setName("John Doe");
user.setEmail("johndoe@example.com");

SqlParameterSource namedParameters = new BeanPropertySqlParameterSource(user);

이 방식은 주로 객체의 필드를 데이터베이스의 컬럼과 매핑할 때 유용하며, 반복적인 코드 작성을 줄여준다.

MapSqlParameterSource

MapSqlParameterSource는 키-값 쌍을 이용하여 SQL 파라미터를 정의할 때 사용된다. MapSqlParameterSource는 SqlParameterSource 인터페이스의 구현체 중 하나로, 파라미터의 이름과 값을 맵핑하기 위해 내부적으로 Map을 사용한다.

SqlParameterSource namedParameters = new MapSqlParameterSource()
        .addValue("name", "John Doe")
        .addValue("email", "johndoe@example.com");

이 방식은 동적인 쿼리 생성 시나 특정 조건에 따라 다른 파라미터를 사용해야 할 때 매우 유용하다.

 

Map

일반 Map을 사용하여 파라미터를 정의하는 방법은 NamedParameterJdbcTemplate의 메소드에 파라미터로 Map<String, ?>을 직접 전달하는 것이다. 이 방법은 간단하고 직관적이지만, SqlParameterSource 구현체들이 제공하는 추가 기능은 없다.

Map<String, Object> parameters = new HashMap<>();
parameters.put("name", "John Doe");
parameters.put("email", "johndoe@example.com");

namedParameterJdbcTemplate.update("INSERT INTO users (name, email) VALUES (:name, :email)", parameters);

 

BeanPropertyRowMapper

BeanPropertyRowMapper는 JDBC 지원 부분에서 제공하는 유틸리티 클래스로, ResultSet의 행을 자바 객체로 매핑해주는 역할을 한다. 이 클래스를 사용하면, 데이터베이스 쿼리의 결과를 자바의 POJO(Plain Old Java Object)로 쉽게 변환할 수 있다.

POJO(Plain Old Java Objects)
"단순한 구식 자바 객체"를 의미한다.
이 용어는 특정 자바 모델이나 프레임워크, 규약에 종속되지 않는, 순수한 자바 객체를 지칭하기 위해 사용된다. POJO는 JavaBeans 규약을 따르기도 하지만, 필수는 아니다. 주로 데이터를 표현하는 데 사용되며, 로직을 담고 있는 비즈니스 객체로도 사용된다.

POJO의 주요 특징
-단순함: 복잡한 객체 모델이나 프레임워크에 의존하지 않는 가장 단순한 형태의 자바 객체이다.
-재사용성과 테스트 용이성: 특정 기술에 종속되지 않으므로, 다양한 환경에서 재사용하고 테스트하기가 쉽다.직렬화 가능: 객체의 상태를 저장하거나 네트워크를 통해 전송할 수 있다.
-캡슐화: 데이터와 데이터를 처리하는 로직을 하나의 단위로 묶어 관리한다.

BeanPropertyRowMapper는 RowMapper 인터페이스를 구현하고 있어, JdbcTemplate 또는 NamedParameterJdbcTemplate의 쿼리 메소드와 함께 사용될 수 있다.

관례 불일치

자바 객체는 카멜(camelCase) 표기법을 사용한다. itemName 처럼 중간에 낙타 봉이 올라와 있는 표기법이다. 반면에 관계형 데이터베이스에서는 주로 언더스코어를 사용하는 snake_case 표기법을 사용한다.

item_name 처럼 중간에 언더스코어를 사용하는 표기법이다. 이 부분을 관례로 많이 사용하다 보니 BeanPropertyRowMapper는 언더스코어 표기법을 카멜로 자동 변환해준다. 따라서 select item_name 으로 조회해도 setItemName() 에 문제 없이 값이 들어간다.

정리하면 snake_case는 자동으로 해결되니 그냥 두면 되고, 컬럼 이름과 객체 이름이 완전히 다른 경우에는 조회 SQL에서 별칭을 사용하면 된다.

 

사용 방법

BeanPropertyRowMapper의 인스턴스를 생성할 때는 매핑할 객체의 클래스 타입을 지정해야 한다. 이 클래스는 자바 빈 규약을 따르는 속성(즉, getter와 setter 메소드가 있는 속성)에 대해, 데이터베이스의 컬럼 이름과 같은 이름의 속성을 자동으로 매핑한다. 컬럼 이름과 속성 이름 사이에 대소문자 구분은 기본적으로 무시된다.

User 클래스

public class User {
    private Long id;
    private String name;
    private String email;
    // 게터와 세터 메소드 생략
}

 

BeanPropertyRowMapper 사용 예

import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;

JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
String sql = "SELECT id, name, email FROM users";

List<User> users = jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(User.class));

 

 

KeyHolder

KeyHolder를 사용하는 과정은 자동 생성된 키, 특히 데이터베이스에서 새로운 레코드를 삽입한 후 생성된 기본 키(Primary Key) 값을 얻기 위해 사용된다. NamedParameterJdbcTemplate을 사용하는 경우, KeyHolder와 함께 작업을 실행하여 이러한 키 값을 추출할 수 있다. 주로 GeneratedKeyHolder 클래스의 인스턴스가 KeyHolder 인터페이스의 구현으로 사용된다.


KeyHolder 사용 예제

다음은 NamedParameterJdbcTemplate과 KeyHolder를 사용하여 데이터베이스에 레코드를 삽입하고, 자동 생성된 키를 추출하는 기본적인 과정을 보여주는 예제이다.

// NamedParameterJdbcTemplate 인스턴스 생성
NamedParameterJdbcTemplate namedParameterJdbcTemplate = new NamedParameterJdbcTemplate(dataSource);

// SQL 쿼리. :name과 :email은 파라미터 이름이다.
String sql = "INSERT INTO users(name, email) VALUES(:name, :email)";

// SqlParameterSource를 사용하여 SQL 파라미터 값을 설정한다.
SqlParameterSource parameters = new MapSqlParameterSource()
    .addValue("name", "홍길동")
    .addValue("email", "hong@example.com");

// GeneratedKeyHolder 인스턴스를 생성한다. 이 객체가 자동 생성된 키를 담게 된다.
KeyHolder keyHolder = new GeneratedKeyHolder();

// update 메소드를 실행하면서 SQL 실행, 파라미터, KeyHolder를 전달한다.
namedParameterJdbcTemplate.update(sql, parameters, keyHolder);

// KeyHolder를 통해 자동 생성된 키 값을 얻는다.
Number key = keyHolder.getKey();

이 예제에서는 NamedParameterJdbcTemplate의 update 메소드를 사용하여 데이터베이스에 새로운 레코드를 삽입한다. 삽입 시 사용되는 SQL 쿼리에는 이름과 이메일 주소를 나타내는 파라미터가 포함되어 있다. GeneratedKeyHolder 인스턴스는 이 쿼리가 실행된 후 생성된 키를 담게 된다. 작업이 성공적으로 완료된 후, KeyHolder의 getKey 메소드를 호출하여 자동 생성된 키 값을 얻을 수 있다.

KeyHolder의 사용은 자동 생성된 기본 키 정보가 필요할 때 매우 유용하며, 특히 새로 삽입된 레코드에 대한 후속 작업을 수행해야 할 경우에 필수적이다.

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01
[Spring DB] JDBC Template  (0) 2024.03.29
[Spring MVC] 파일 업로드  (0) 2024.03.25
[Spring MVC] 스프링 타입 컨버터  (1) 2024.03.24

공식 메뉴얼 

DataSource 설정

JDBC Template 사용을 위해서는 데이터베이스와의 연결을 관리하는 DataSource를 설정해야 한다. 

 

application.properties

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=yourName
spring.datasource.password=yourPassword

이렇게 하면 내부적으로 DataSource 빈을 자동으로 생성하고 구성합니다.

 

JdbcTemplate 인스턴스 생성

DataSource 설정 후, JdbcTemplate 인스턴스를 생성하고 이를 빈으로 등록한다.

@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
    return new JdbcTemplate(dataSource);
}

 

CRUD 작업

Create

jdbcTemplate.update("INSERT INTO 테이블명 (컬럼1, 컬럼2) VALUES (?, ?)", 값1, 값2);

 

Read

String name = jdbcTemplate.queryForObject("SELECT 이름 FROM 테이블명 WHERE 아이디 = ?", new Object[]{아이디}, String.class);

 

조회

List<String> names = jdbcTemplate.query("SELECT 이름 FROM 테이블명", (rs, rowNum) -> rs.getString("이름"));

 

Update

jdbcTemplate.update("UPDATE 테이블명 SET 컬럼명 = ? WHERE 조건", 새값);

 

Delete

jdbcTemplate.update("DELETE FROM 테이블명 WHERE 조건", 조건값);

 

jdbcTemplate.update(), queryForObject(), query()

jdbcTemplate.update

update 메소드는 INSERT, UPDATE, DELETE와 같은 DML(Data Manipulation Language) 작업을 실행할 때 사용된다. 즉, 데이터베이스에 데이터를 추가, 수정, 삭제할 때 이 메소드를 활용한다.

int updateCount = jdbcTemplate.update("INSERT INTO 테이블명 (컬럼1, 컬럼2) VALUES (?, ?)", 값1, 값2);

이 예시는 데이터베이스의 특정 테이블에 새로운 행을 추가하는 경우이다. update 메소드는 실행 후 영향을 받은 행의 수를 반환한다.

 

jdbcTemplate.queryForObject

queryForObject 메소드는 단일 객체를 반환할 때 사용된다. 이 메소드는 주로 단일 행의 결과를 반환하는 SELECT 쿼리에 사용되며, 결과가 정확히 하나의 객체로 매핑되어야 한다. 결과가 없거나 둘 이상일 경우 예외가 발생한다.

String itemName = jdbcTemplate.queryForObject("SELECT item_name FROM items WHERE id = ?", new Object[]{itemId}, String.class);

이 예시는 주어진 itemId에 해당하는 아이템의 이름을 데이터베이스에서 조회하여 반환한다. queryForObject는 SQL 쿼리, 쿼리에 바인딩할 파라미터 배열, 그리고 반환될 객체의 타입을 매개변수로 받는다.

 

 

queryForObject() 메소드 시그니처

<T> T queryForObject(String sql, RowMapper<T> rowMapper, Object... args) throws DataAccessException;
  • <T>: 제네릭 타입을 의미하며, 이 메소드가 반환할 객체의 타입을 지정한다.
  • T queryForObject: 메소드가 실행된 후 반환할 객체의 타입을 나타낸다.
  • String sql: 실행할 SQL 쿼리 문자열이다.
  • RowMapper<T> rowMapper: SQL 쿼리의 결과로 반환된 ResultSet을 객체로 매핑하는 방법을 정의한 RowMapper 인터페이스의 구현체이다.
  • Object... args: SQL 쿼리에 바인딩할 파라미터들. 이 파라미터들은 쿼리 내의 '?' 플레이스홀더에 순서대로 바인딩된다.
  • throws DataAccessException: 쿼리 실행 중 발생할 수 있는 예외를 나타낸다. DataAccessException은 Spring이 제공하는 데이터 접근 관련 예외의 베이스 클래스이다.

아래 예제는 queryForObject 메소드를 사용하여 id에 해당하는 사용자의 이름을 데이터베이스에서 조회하는 방법을 보여준다.

String sql = "SELECT name FROM users WHERE id = ?";
Long userId = 1L; // 조회하고자 하는 사용자의 ID

String userName = jdbcTemplate.queryForObject(sql, new RowMapper<String>() {
    @Override
    public String mapRow(ResultSet rs, int rowNum) throws SQLException {
        return rs.getString("name");
    }
}, userId);

또는 람다 표현식을 사용하여 더 간결하게 작성할 수도 있다

String userName = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> rs.getString("name"), userId);

이 메소드는 결과가 정확히 한 행일 때 사용해야 한다. 쿼리 결과가 없거나 두 개 이상의 행이 반환되면, 

IncorrectResultSizeDataAccessException이 발생한다. 따라서 이 메소드를 사용할 때는 반환되는 결과가 항상 단 하나임을 확신

할 수 있을 때 사용하는 것이 좋다. 

 

jdbcTemplate.query

query 메소드는 하나 이상의 결과 행을 반환할 때 사용된다. 이 메소드는 리스트로 결과를 반환하며, 각 행은 RowMapper를 사용하여 자바 객체로 매핑된다.

List<Item> items = jdbcTemplate.query("SELECT * FROM items", (rs, rowNum) -> {
    Item item = new Item();
    item.setId(rs.getLong("id"));
    item.setItemName(rs.getString("item_name"));
    item.setPrice(rs.getInt("price"));
    item.setQuantity(rs.getInt("quantity"));
    return item;
});

이 예시는 items 테이블의 모든 행을 조회하고, 각 행을 Item 객체로 매핑하여 리스트로 반환한다. query 메소드는 SQL 쿼리와 RowMapper 인터페이스를 구현한 람다 표현식이나 클래스를 매개변수로 받는다.

 

ResultSet과 RowMapper

ResultSet을 직접 사용하는 방식과 RowMapper 인터페이스를 사용하는 방식은 데이터베이스에서 데이터를 조회하여 객체로 변환하는 과정에서 차이가 있다.

ResultSet을 직접 사용하는 방식

직접 처리: ResultSet을 사용하는 방식은 조회 결과를 반복문을 통해 직접 순회하며, 각 컬럼의 값을 얻어와서 객체의 필드에 하나씩 설정해야 한다. 이는 상당히 저수준의 작업이며, 개발자가 ResultSet의 관리와 객체 매핑의 전 과정을 책임진다.

 

오류 가능성: ResultSet을 직접 다루는 경우, 타입 오류나 컬럼 이름 오류 등이 발생할 가능성이 있으며, 이러한 오류를 직접 관리해야 한다.

 

자원 관리: ResultSet, Statement, Connection과 같은 JDBC 리소스들의 개방과 폐쇄를 개발자가 직접 관리해야 한다. 이는 코드의 복잡성을 증가시키고, 리소스 누수의 위험을 높일 수 있다.

 

 

RowMapper 인터페이스를 사용하는 방식

자동 처리: RowMapper를 사용하는 방식은 Spring JDBC Template과 함께 사용되며, ResultSet의 각 행을 어떻게 객체로 매핑할지를 정의한다. 그 후, 실제로 데이터베이스에서 데이터를 가져오는 작업과 객체 매핑은 Spring JDBC Template에 의해 자동으로 처리된다.

 

타입 안정성 및 가독성: RowMapper는 타입 안정성을 제공하며, 컬럼 값의 추출과 객체 필드 설정 코드를 한 곳에 모아둠으로써 가독성을 향상시킨다.

 

자원 관리: RowMapper를 사용할 때, JDBC 리소스들의 관리는 Spring JDBC Template에 의해 자동으로 처리된다. 이는 코드의 단순화를 도모하고, 리소스 누수의 위험을 줄여준다.
결론

 

RowMapper 인터페이스를 사용하는 방식은 ResultSet을 직접 사용하는 방식에 비해 개발자의 부담을 크게 줄여주며, 코드의 가독성과 유지보수성을 향상시킨다. 또한, Spring의 JDBC Template과 결합하여 사용됨으로써, 데이터베이스 연결과 같은 저수준의 작업을 추상화하여, 데이터 액세스 로직을 보다 효율적으로 작성할 수 있게 한다.

 

RowMapper 인터페이스

public interface RowMapper<T> {
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}
  • T: 반환될 객체의 타입
  • ResultSet rs: 쿼리 결과 집합
  • int rowNum: 현재 행의 번호

 

다음은 User 클래스의 인스턴스로 데이터베이스 결과를 맵핑하는 예제이다. User 클래스가 다음 필드를 가지고 있다고 가정해 보자

public class User {
    private Long id;
    private String name;
    private String email;
    // getters and setters 생략
}

 

RowMapper를 사용하여 User 객체로 맵핑하는 예시는 다음과 같다

public class UserRowMapper implements RowMapper<User> {
    @Override
    public User mapRow(ResultSet rs, int rowNum) throws SQLException {
        User user = new User();
        user.setId(rs.getLong("id"));
        user.setName(rs.getString("name"));
        user.setEmail(rs.getString("email"));
        return user;
    }
}

 

그리고 JdbcTemplate을 사용하여 UserRowMapper를 이용하는 방법은 다음과 같다

String sql = "SELECT id, name, email FROM users";
List<User> users = jdbcTemplate.query(sql, new UserRowMapper());

이 예제에서 jdbcTemplate.query 메소드는 주어진 SQL 쿼리를 실행하고, UserRowMapper를 사용하여 결과 ResultSet의 각 행을 User 객체로 변환한다. 이렇게 변환된 객체들은 리스트에 담겨 반환된다.

 

RowMapper를 메서드 내부에 정의하여 사용

주로 간단한 쿼리나, 특정 메서드에서만 사용되는 맵핑 로직을 구현할 때 유용하다. 이 방식은 RowMapper를 익명 클래스로 선언하거나, 람다 표현식(Java 8 이상)을 사용하여 직접 구현할 수 있다. 여기서는 두 가지 방법 모두를 예로 들어 설명하겠다.

User 객체를 위한 RowMapper

데이터베이스의 users 테이블에서 사용자 정보를 조회하고, 이를 User 객체로 맵핑하는 예제를 살펴보자. User 클래스가 다음과 같이 정의되어 있다고 가정하자

public class User {
    private Long id;
    private String name;
    private String email;
    // 생성자, getter, setter 생략
}

 

익명 클래스 사용

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;

public List<User> findAllUsers(JdbcTemplate jdbcTemplate) {
    String sql = "SELECT id, name, email FROM users";

    List<User> users = jdbcTemplate.query(sql, new RowMapper<User>() {
        @Override
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            return user;
        }
    });

    return users;
}

 

람다 표현식 사용

import org.springframework.jdbc.core.JdbcTemplate;

public List<User> findAllUsers(JdbcTemplate jdbcTemplate) {
    String sql = "SELECT id, name, email FROM users";

    List<User> users = jdbcTemplate.query(sql, (rs, rowNum) -> {
        User user = new User();
        user.setId(rs.getLong("id"));
        user.setName(rs.getString("name"));
        user.setEmail(rs.getString("email"));
        return user;
    });

    return users;
}

 

RowMapper를 사용함으로써, 데이터베이스의 결과를 객체로 안전하고 효율적으로 맵핑할 수 있다. 이는 코드의 재사용성과 유지 보수성을 향상시킨다.

 

 

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] SimpleJdbcInsert  (0) 2024.04.01
[Spring DB] NamedParameterJdbcTemplate  (0) 2024.03.31
[Spring MVC] 파일 업로드  (0) 2024.03.25
[Spring MVC] 스프링 타입 컨버터  (1) 2024.03.24
[Spring MVC] API 예외 처리  (1) 2024.03.23