프로젝트 구조에 대한 고민2 - 트랜잭션을 최대한 짧게 유지하기

최창효·2024년 2월 8일
0
post-thumbnail

들어가기 전에

이번 글 역시 MVC 패턴을 사용하면서 들었던 고민에 대한 내용입니다.

Java/Spring(SpringBoot) + JPA + MySQL을 활용한 개발을 진행할 때 트랜잭션 기능을 사용하려면 @Transactional어노테이션을 어디에 활용해야 할까요?

저는 이전 글에서도 설명했듯이 관성적으로 Controller / Service / Repository로 나눠서 개발을 진행했었습니다.

그 중 Controller는 사용자의 입력값을 검증하고 리소스에 대한 표현을 담당하는 계층이고, Repository는 DB와의 통신을 담당하는 계층이라 생각했기 때문에 트랜잭션을 사용하기 위한 @Transactional어노테이션은 Service계층에서 사용하고 있었습니다.

이번 글은 제목처럼 트랜잭션을 짧게 유지하기 위한 설계이기도 하지만
Service계층을 (비교적 역할이 분명했던) Controller와 Repository를 제외한 나머지(소위 짬통)처럼 사용했던 점, 그리고 관성적인 설계로 인해 다른 계층을 추가할 생각을 못했던 과거의 잘못된 습관을 되짚어보는 글이기도 합니다.

트랜잭션은 왜 짧아야 하는가?

애플리케이션에서 트랜잭션 처리를 위해서는 DB와 통신하기 위한 커넥션이 필요합니다. 요즘은 통신을 요청할 때마다 커넥션을 생성했다가 요청이 완료되면 커넥션을 소멸시키는 방법보다는 미리 커넥션을 만들어두고 커넥션 풀이라는 곳에서 관리하는 방법을 주로 사용합니다.

커넥션 풀에 있는 커넥션의 개수는 유한합니다. 모두가 공동으로 사용하는 커넥션 풀인데 누군가 커넥션을 빌려가서 오랫동안 반납하지 않으면 점점 요청을 기다리는 시간이 길어지게 될겁니다. 결국 트랜잭션이 길어지면 어플리케이션을 이용하기 위한 대기시간이 길어진다는 얘기입니다.

어플리케이션에서 대기시간이 늘어난다는 건 명백히 부정적인 시그널입니다. 누구나 느린 서비스보다 빠른 서비스를 원하니까요!

내 트랜잭션은 왜 길어질까?

아래는 프로젝트를 진행하면서 저와 제 팀원들의 코드에서 자주 발견된 유형들입니다.

1. 트랜잭션 내부에서 외부 API호출

저희 프로젝트는 MSA구조로 설계되어 있어 로직 처리에 필요한 데이터가 다른 마이크로서비스에 존재하면 이를 HTTP통신으로 가져와야 합니다. 이러한 외부 통신은 긴 시간이 소요되는 작업에 속합니다.

보통 다음과 같은 형태로 코드를 작성했었습니다.

@Service
public class MyService {
	@Transactional // (1)
    public void myMethod() {
    	
        String externalData = feignClient.getData() // 외부통신 (3)

		MyEntity myEntity = new MyEntity(externalData, ...) // (2)
        MyDetailEntity myDetailEntity = new MyDetailEntity(...)
        
		MyRepo.save(myEntity)
        MyRepoDetail.save(myDetailEntity)
    }
	
}
  • (1) : myEntity와 myDetailEntity는 둘 다 저장되거나 둘 중 하나라도 문제가 있으면 둘 다 저장되지 않아야 합니다(All Or Nothing). 이를 위해 두 작업을 트랜잭션으로 묶었습니다.
  • (2) : MyEntity객체를 생성하기 위해서는 다른 마이크로서비스에 있는 데이터가 필요합니다.
  • (3) : (2)에서 필요한 데이터를 다른 마이크로서비스에 요청하기 위해 myMethod에서 외부 통신을 진행합니다.

2. 트랜잭션 작업과 관계없는 작업 진행

트랜잭션 작업을 진행하고 해당 작업이 완료됐다는 사실을 외부에 알려야 하는 경우도 자주 발생합니다. 이때 해당 작업을 트랜잭션에 함께 포함시켜버리는 경우가 많습니다.

@Service
public class MyService {
	@Transactional
    public void myMethod() {
        MyEntity myEntity = new MyEntity(...)
		MyRepo.save(myEntity)

        sqs.publish(message)
        kafka.send(topic)
    }
	
}

트랜잭션은 기본적으로 RDB에 대한 작업을 하나로 묶어주는 기능입니다. 외부로 메시지를 발행하는 작업은 트랜잭션 내부에 포함되더라도 함께 롤백되지 않습니다.

해결 방법

앞에서 살펴본 두 가지 사례 모두 트랜잭션과 관계없는 작업을 상위 계층에서 진행하면 문제를 해결할 수 있습니다. Controller / Service / Repository에서 @Transactional 어노테이션이 존재하는 Service계층의 상위 계층은 Controller입니다.

따라서 다음과 같이 Controller를 설계하면 문제를 해결할 수 있습니다.

@RestController
public class MyController {
	
	public void api() {
		String externalData = feignClient.getData() // (1)
        // == 트랜잭션 시작 == //
		myService.myMethod(externalData) // (2)
        // == 트랜잭션 끝 == //
        sqs.publish(message) // (3)
    }
}
  • 트랜잭션은 myService.myMethod()가 실행되는 동안만 유지됩니다. 그러므로 컨트롤러 계층에서 myService.myMethod()를 호출하기 전/후로 실행되는 코드는 트랜잭션에 포함되지 않습니다.
  • (1) : 트랜잭션 메서드 내에서 필요한 데이터를 트랜잭션 밖에서 외부호출을 통해 미리 받아왔습니다.
  • (2) : 트랜잭션 메서드 내에서 필요한 데이터를 메서드 안에서 직접 얻어오지 않고 외부호출로 받아온 값을 매개변수로 전달받습니다.
  • (3) : 트랜잭션과 관련없는 작업은 트랜잭션이 끝난 뒤 실행합니다.

무엇이 마음에 들지 않을까?

위와 같이 코드를 변경하고나서 컨트롤러에서 외부API요청을 하는게 맞을까? 컨트롤러에서 메시지를 발행하는 게 맞을까?라는 고민이 시작됐습니다.

왜냐하면 처음에 얘기했듯이 저는 컨트롤러를 사용자의 값을 검증하고 리소스에 대한 표현을 담당하는 계층이라 생각하기 때문입니다. 외부API요청이나 메시지 발행과 같은 작업은 제가 생각하는 컨트롤러 계층의 역할과 맞지 않았습니다.

계층 나누기

상위 계층은 필요하지만 그 계층이 Controller인게 마음에 들지 않는다면 Controller와 Service사이에 별도의 계층을 만들면 됩니다.

위에서 보여준 pseudo코드 형태에서는 달라질 게 거의 없기 때문에 실제 프로젝트에서의 완성된 코드를 일부 가져와봤습니다.

@Component
@RequiredArgsConstructor
public class StoreFacade {
	private final StoreService storeService;
    private final ProductFeignClient productFeignClient;
    
    public Long createStore(Long userId, StoreCreateRequest storeCreateRequest) {
        List<FlowerDto> flowers = productFeignClient.getFlowers().getData();
        return storeService.createStore(userId, storeCreateRequest, flowers);
    }
    
}
  • 외부API와의 통신으로 flowers데이터를 가져온 뒤 트랜잭션이 진행되는 createStore에게 매개변수로 flowers를 전달합니다.
@Component
@RequiredArgsConstructor
public class CouponFacade {
    private final CouponService couponService;
    private final KafkaProcessor<ProcessOrderDto> stockDecreaseKafkaProducer;
    
    @KafkaListener(topics = "coupon-use", groupId = "use-coupon")
    public void useCoupons(ProcessOrderDto processOrderDto) {
        LocalDate useDate = LocalDate.now();
        couponService.useAllCoupons(processOrderDto.getCouponIds(), processOrderDto.getUserId(), useDate);
        stockDecreaseKafkaProducer.send("stock-decrease", processOrderDto);
    }

}
  • 쿠폰 사용에 성공했다면 카프카를 통해 재고감소 메시지를 전송합니다.

이처럼 새로운 계층을 추가하는 방식은 아주 간단하면서도 확실한 해결방법입니다. 쉬운 해결방법이지만 이를 곧바로 생각지 못했던 이유를 돌이켜보면 관성적으로 Controller / Service / Repository계층으로만 개발을 진행하던 습관이 이런 생각을 가로막고 있었던게 아닐까 싶습니다.

오브젝트라는 책에 '설계는 트레이드오프의 산물'이라는 얘기가 나옵니다.
지금까지 저는 어디선가 본 방식을 따라 Controller / Service / Repository계층을 고수했을 뿐 해당 설계의 트레이드오프에 대해 고민하지 않았던 것 같습니다. '어떤 상황에서도 무조건 좋은 설계는 존재하지 않는다'는 걸 깨닫지 못하고 별다른 고민 없이 Controller / Service / Repository형태의 설계를 사용하고 있었던 것이죠.

추가적인 장점 - 내부 호출 해결하기

계층을 분리해서 활용하면 프록시 내부 호출문제도 깔끔하게 해결할 수 있습니다.
@Transcational어노테이션이 적용된 메서드를 동일한 클래스에서 호출했을 때 트랜잭션이 적용되지 않는 문제가 발생할 수 있습니다.
간단한 예시로 살펴보겠습니다.

@Service
public class MyService {
    @Transactional
    public void txMethod() {
    }

    public void internalCall() {
        MyService.txMethod()
    }

}
  1. MyService에는 @Transactional AOP를 적용한 txMethod라는 메서드가 존재합니다.
  2. MyService 내부의 다른 메서드인 internalCall에서 txMethod를 호출하면 txMethod는 트랜잭션이 적용되지 않습니다.

@Transactional을 사용한 메서드가 존재하면 해당 객체는 원본 객체가 아닌 프록시 객체가 스프링 컨테이너에 등록됩니다.

스프링 컨테이너에 등록되는 프록시를 간단히 표현하면 다음과 같은 형태일 겁니다.

public class MyServiceProxy {
    private final MyService myService

    public void txMethod() {
        transaction.begin()
        myService.txMethod()
        transaction.end()
    }

}
  • 프록시 객체는 원본 객체를 변수로 가지고 있습니다.
  • 트랜잭션을 사용하는 메서드가 호출되면 프록시 객체는 트랜잭션을 시작합니다.
  • 원본 객체의 메서드를 실행합니다.
  • 프록시 객체는 트랜잭션을 닫습니다.

내부 호출은 프록시 객체를 호출하지 않습니다. internalCall메서드가 실행하는 txMethod()는 사실 this.txMethod()입니다. 여기서 this는 MyService를 의미합니다. 그렇기 때문에 프록시 객체가 아닌 원본 객체의 메서드만을 호출하고, 트랜잭션이 적용되지 않습니다.

내부 호출을 해결하는 방법 중 하나는 외부에서 메서드를 호출해 프록시 객체를 활용하는 것입니다. 이때 Service의 상위 계층인 Controller에서 진행해도 되지만, 위 글처럼 이러한 호출이 Controller의 역할이 아닌거 같다는 생각이 들면 지금처럼 별도의 계층을 만들어 진행하는 방법도 고려해볼 수 있습니다.

References

profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글