BackEnd/Spring

[Spring] @Transactional 사용 시 주의할 점(Feat. 오픈전 발견한 장애)

Wonol 2023. 8. 13. 17:05
반응형

회사에서 신규 서비스 오픈을 앞에 두고 반영 이후 운영 CBT 를 진행하다가, 오류로그가 계속 올라오는 것을 확인하였습니다.

에러 로그를 확인해보니 하나의 로직에서 DB에 Insert(저장) 후에 해당 값을 Select(조회) 하는 과정에서 발생하였습니다.

발생한 이유를 확인해보니 테스트 환경에서는 발견이 안되었는데, 운영 환경은 모두 이중화가 되면서 발생하였다.

로직은 @Transactional 어노테이션으로 트랜잭션을 걸어두었으나 트랜잭션이 끝나기 전에 Insert(저장)은 1번 Session 으로, Select(조회)는 2번 Session 으로 동작 후 Insert하면서 NPE가 발생하고 있었습니다.

위와 같은 에러가 발생했다는 것은 @Transactional 어노테이션이 제대로 동작을 안했다는 것이었는데, 로직을 확인해 보면서는 처음에는 무엇이 잘못된 지 알 수가 없었는데, 결론은 어노테이션의 위치가 잘못 걸린 것을 확인할 수 있었고, 수정하여 정상 확인하였습니다.

해당 글에서는 어떻게 잘못 걸었고, 왜 동작을 하지 못했는지 포스팅해보도록 하겠습니다.


1. 발생한 코드

- 간단하게 프로세스를 설명하자면 controller -> apiService -> 외부 API 호출(로그 DB 저장) -> apiService -> saveService -> DB 와 같이 진행합니다.

- 발생한 구간은 apiService -> saveService -> DB 이고 @Transactional 어노테이션은 apiService 에 걸어 두었습니다.

- 각 클래스는 아래와 같이 되어 있었습니다.

  • ApiService.class
@RequiredArgsConstructor
public class ApiService {

    private ApiSaveService apiSaveService;

    public void apiCall() {

        //  API Call
        Map<String, Object> response = clientService.dataByPost();
        //  ... 어쩌구 저쩌구...
        
        //  API Data 저장 메소드 호출
        apiDataSave(response);
    }

    @Transactional
    public void apiDataSave(Map<String, Object> data) {
        apiSaveService.apiDataSave();
        apiSaveService.apiDataHistorySave();
    }
}
  • ApiSaveService.class
public class ApiSaveService {

    @Transactional
    public void apiDataSave() {
        //  api 정보 DB 저장
        ...
    }

    @Transactional
    public void apiDataHistorySave() {
        //  apiDataSave 에서 저장한 정보를 select 후 api 히스토리 DB 저장
        ...
        //  select 하는 도중 commit 이 되지 않아 조회 시 NPE 발생
    }
}

2. 실수한 내용

- 트랜잭션 전파를 생각하였지만, 같은 클래스(ApiService) 내에서 트랜잭션이 걸린 메소드를 호출하더라도 트랜잭션은 동작하지 않는다.

- ApiService.class 내에서 apiCall 메소드에서 @Transactional 어노테이션이 걸려있는, apiDataSave 메소드를 호출하여 데이터 관련 작업을 진행하여도 트랜잭션 전파도 되지 않을뿐더러, 트랜잭션도 동작하지 않는다는 것이었습니다.

2-1. 원인

- Spring AOP 에서 Proxy(프록시)의 동작 과정을 살펴보면 프록시를 통해 들어오는 외부 메소드 호출을 인터셉트하여 작동합니다.

- 이러한 성격으로 self-invocation(자기호출) 라고 불리는 현상이 발생합니다.

- 즉, 위의 코드 상에서 프록시의 내부 Bean(빈)에서 프록시를 호출한 것입니다.

- Controller 에서 ApiService.class 의 apiCall() 메소드를 호출하고 해당 메소드에서 apiDataSave() 메소드를 호출하면 위 그림과 같이 프록시 내부에서 호출하게 됩니다.

- 그렇기에 Proxy 가 인터셉트를 하지 못하고 트랜잭션은 동작하지 않게 됩니다.(물론 동작하지 않았기 때문에, 트랜잭션 전파도 되지 않습니다.)

2-2. 간략한 AOP/Proxy 설명

- Spring 에서는 AOP 를 하기 위해 기본적으로 디자인패턴 중 하나인 프록시패턴을 사용하고 있습니다.

  • AOP(Aspect Oriented Programming)

관점지향 프로그래밍이라는 뜻으로 여러 곳에서 사용되는 공통된 로직을 모듈화 하여 비즈니스 로직에서 분리시킨다.
이로 인해 개발자들은 비즈니스 로직 외에 부가적인 로직은 따로 외부에서 관리하여 유지보수 및 재사용성이 용이해진다.
  • 프록시(Proxy)

- 스프링의 AOP 는 프록시를 활용하여 구현하고 있습니다.

- 한 클래스가 AOP 대상일 시 원본 클래스 대신 프록시가 감싸진 클래스가 자동으로 만들어지고, 프록시 클래스가 빈에 등록이 됩니다.(위 그림)

- 이렇게 빈에 등록된 프록시 클래스는 원본 클래스가 호출될 시 자동으로 바꿔서 사용됩니다.

- 즉, 다시 한번 위에 말을 사용해 보면 Controller 에서 ApiService.class 의 apiCall() 메소드를 호출하고 해당 메소드에서 apiDataSave() 메소드를 호출하면 위 그림과 같이 프록시 내부에서 호출하게 됩니다.

3. 해결법

3-0. 선택한 해결

- 제일 간단하게 해결하는 방법으로는 @Transactionl 어노테이션을 apiCall 메소드에 작성하는 것입니다.

- 그렇게 되면 트랜잭션 전파를 통해서 관리가 가능할 것입니다. 그렇지만, apiCall 에서도 따로 DB 작업을 하고 있고 통신 실패 시 모두 Rollback 이 되어 좋은 방법이 아니었습니다.

- 최종적으로 제가 선택한 방법은 ApiSaveService.class 에 @Transactional 어노테이션을 걸어 관리하였습니다.

- 코드는 아래와 같습니다.

  • ApiCallService.class
@RequiredArgsConstructor
public class ApiService {

    private ApiSaveService apiSaveService;

    public void apiCall() {

        //  API Call
        Map<String, Object> response = clientService.dataByPost();
        //  ... 어쩌구 저쩌구...
        
        //  API Data 저장 메소드 호출
        apiDataSave(response);
    }

    public void apiDataSave(Map<String, Object> data) {
        apiSaveService.apiSave();
    }
}
  • ApiSaveServcie.class
public class ApiSaveService {

    @Transactional
    public void apiSave() {
    	apiDataSave();
        apiDataHistorySave();
    }

    public void apiDataSave() {
        //  api 정보 DB 저장
        ...
    }

    public void apiDataHistorySave() {
        //  apiDataSave 에서 저장한 정보를 select 후 api 히스토리 DB 저장
        ...
        //  select 하는 도중 commit 이 되지 않아 조회 시 NPE 발생
    }
}

3-1. 의존성 주입

- 의존성 주입을 통해 프록시로 감싸진 서비스를 다시 받아와서 호출하는 방식입니다.

- 하지만 해당 방법은 추천하지 않습니다.

@RequiredArgsConstructor
public class ApiService {

    private ApiSaveService apiSaveService;
    private ApiService apiService;

    public void apiCall() {

        //  API Call
        Map<String, Object> response = clientService.dataByPost();
        //  ... 어쩌구 저쩌구...
        
        //  API Data 저장 메소드 호출
        apiService.apiDataSave(response);
    }

    @Transactional
    public void apiDataSave(Map<String, Object> data) {
        apiSaveService.apiDataSave();
        apiSaveService.apiDataHistorySave();
    }
}

3-2. 상위 메소드에 @Transactional 선언하기

- 제일 근본적인 해결 방법입니다.

- 내부 메소드를 호출하는 상위 메소드에 @Transactional 을 선언하여 관리합니다.

- 그렇지만, 해당 방법으로는 다른 옵션을 통해 트랜잭션을 관리하고자 한다면 해당 방법으로는 어렵습니다.

3-3. 상위 메소드 분리

- @Transactional 어노테이션이 필요한 상위 메소드를 분리하고 해당 클래스를 외부 클래스에서 호출하는 방식입니다.

- 보통 해당 방식을 추천합니다.

4. 결론

- @Transactional 어노테이션에 대해 잘 알고 사용해야 한다.

- 테스트환경에서도 다중화를 고려해서 잘 테스트를 해야 한다.

- 항상 내 코드를 믿지 말아야 한다.(테스트도 잘 통과하고 정상적인 코드인 줄 알았다...)

 


참고

- https://kdhyo98.tistory.com/53

- https://tecoble.techcourse.co.kr/post/2022-11-07-transaction-aop-fact-and-misconception/

- https://velog.io/@park2348190/Spring-Transaction%EC%9D%98-Self-Invocation

반응형