본문 바로가기
Spring

@Transactional의 작동 방식과 롤백되지 않는 문제(Checked Exception)

by 연잔 2023. 8. 31.

 

‘스프링 부트 쇼핑몰 프로젝트 with jpa’ 책의 코드를 따라가던 중 상품 등록 과정에서 문제가 생겼다.

상품의 이미지 등록이 실패하면 상품 정보는 DB에 저장되고 이미지는 저장되지 않았다.

이미지가 저장되지 않았던 원인은 파일을 저장할 폴더가 없기 때문이었다.

하지만 @Transactional 이 제대로 작동한다면 상품정보와 상품이미지 모두 데이터베이스에 들어가지 않았어야 한다고 생각했고, 원인을 찾기 위해 @Transactional의 동작방식과 원인에 대해 알아봤다.

원인

@Transactional 에서 Checked Exception은 롤백하지 않는.

이미지 파일을 저장할 때 FileNotFoundException 이 발생했고, 이는 Checked Exception이기 때문에 롤백하지 않았던 것이다.

 

Spring에서 @Transactional 동작 방식

Spring에서 @Transactional은 AOP를 이용해 구현한다.

@Transactional 을 선언한 메서드가 실행되기 전 Transaction begin 코드를 삽입하고, 메서드가 실행된 후 Transaction commit 코드를 삽입한다.

Spring Boot에서는 default로 CGLIB를 이용해 프록시 객체를 생성하고 코드를 삽입한다.

 

application.properties 설정

트랜잭션 관련 로그를 보기위해 application.properties 파일에 트랜잭션 패키지의 로그 레벨을 아래와 같이 설정한다.

logging.level.org.springframework.transaction=trace

위와 같이 트랜잭션이 작동하지만 Item Image를 저장할 때 예외가 생겼음에도 롤백하지 않는걸 알 수 있음

 

Checked Exception인 경우 롤백 되지않는다.

...JpaTransactionManager        : Creating new transaction with name [com.shop.service.ItemService.saveItem]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@54440f18]
...TransactionInterceptor           : Getting transaction for [com.shop.service.ItemService.saveItem]

...JpaTransactionManager        : Participating in existing transaction
...TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

...JpaTransactionManager        : Participating in existing transaction
...TransactionInterceptor           : Getting transaction for [com.shop.service.ItemImgService.saveItemImg]
...TransactionInterceptor           : Completing transaction for [com.shop.service.ItemImgService.saveItemImg] after exception: java.io.FileNotFoundException: C:\shop\item\33e0aaf5-7f86-4537-94e6-4337195b82e9.jpg (지정된 경로를 찾을 수 없습니다)
...TransactionInterceptor           : Completing transaction for [com.shop.service.ItemService.saveItem] after exception: java.io.FileNotFoundException: C:\shop\item\33e0aaf5-7f86-4537-94e6-4337195b82e9.jpg (지정된 경로를 찾을 수 없습니다)

...JpaTransactionManager        : Initiating transaction commit
...JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1608174189<open>)]

Hibernate: insert into item (created_by,item_detail,item_nm,item_sell_status,modified_by,price,reg_time,stock_number,update_time,item_id) values (?,?,?,?,?,?,?,?,?,?)

FileNotFoundException 이 터졌지만 예외를 잡은 후 최종 커밋이 이루어지고, item insert 쿼리가 나가는 것을 볼 수 있다.

이는 FileNotFoundExceptionIOException을 상속받는 Checked Excception 이기 때문이다.

 

코드 뜯어보기

TransactionAspectSupport.java 670줄을 보면 아래와 같이 “Completing transaction for…” 로그가 찍히는 부분을 볼 수 있다. rollbackOn(ex) 부분을 보면 RuntimeExceptionError인 경우 롤백을 하는것으로 나와있다. 때문에 위의 FileNotFoundException 은 예외가 발생했지만 롤백되지 않는다.

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
        if (txInfo != null && txInfo.getTransactionStatus() != null) {
            if (logger.isTraceEnabled()) {
                logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() +
                        "] after exception: " + ex);
            }
            //여기!!!
            if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
                try {
                    txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
                }
@Override
    public boolean rollbackOn(Throwable ex) {
        return (ex instanceof RuntimeException || ex instanceof Error);
    }

 

해결 방법

1) Checked Exception이 아닌 RuntimeException으로 반환한다.

public String uploadFile(String uploadPath, String originalFileName, byte[] fileData) {
        UUID uuid = UUID.randomUUID();
        String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
        String savedFileName = uuid.toString() + extension;
        String fileUploadFullUrl = uploadPath + "/" + savedFileName;
        try {
            FileOutputStream fos = new FileOutputStream(fileUploadFullUrl);
            fos.write(fileData);
            fos.close();
        } catch (Exception e) {
            throw new RuntimeException("FileNotFound");
        }

        return savedFileName;
    }

2) rollbackFor 추가

@Transactional의 rollbackFor를 Exception.class 로 설정하면 모든 예외 발생에 롤백처리를 할 수 있다.

@Transactional(rollbackFor = Exception.class)

@Transactional(rollbackFor = {Exception.class})
    public Long saveItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception{

        //상품 등록
        Item item = itemFormDto.createItem();
        itemRepository.save(item);

        //이미지 등록
        for(int i=0;i<itemImgFileList.size();i++){
            ItemImg itemImg = new ItemImg();
            itemImg.setItem(item);

            if(i == 0)
                itemImg.setRepimgYn("Y");
            else
                itemImg.setRepimgYn("N");
                        //이미지 저장 메서드
            itemImgService.saveItemImg(itemImg, itemImgFileList.get(i));
        }

        return item.getId();
    }

 

Checked Exception은 이미 알고있는 예외이므로 롤백하지 않는다는 너무 당연한 이야기를 처음 깨달았다…!

 

참고

https://leejaengjaeng.tistory.com/14

https://velog.io/@kdhyo/JavaTransactional-Annotation-알고-쓰자-26her30h

https://velog.io/@ddongh1122/Spring-Transactional-클래스-내부-호출-미작동-이슈

https://joyykim.tistory.com/24

https://techblog.woowahan.com/2606/

https://wildeveloperetrain.tistory.com/218

 

'Spring' 카테고리의 다른 글

프레임워크를 사용하는 이유  (0) 2023.10.06
Spring Data JPA Projections  (0) 2023.04.24