무지성 비동기

무지성 비동기

잡설

최근 글을 쓸때 항상 초안만 직접 적고 인공지능을 활용해서 나머지를 적곤 했는데,

뭔가 글이 밋밋하다는 생각이 들곤 합니다.

좀 짜임새가 없더라도 글에서 사람냄새(?) 가 나면 더 잘 읽힌다고 느껴지더라구요.

회고라던지, 후기라던지.

그래서 이번엔 적어도 글쓰는것만이라도 러다이트 메타로 직접 써보려고 합니다.

서론

최근 회사에서 AX 트렌드를 수용하려고 하면서 다양한 기능들을 개발하게 되었는데요.

그 과정에서 있었던 이슈 하나를 소개해보겠습니다.

개발하게 된 기능은 AI를 통한 자동 내용 인식 기능인데요, 자세한 정보를 밝힐수는 없지만

간단히 요약하자면 PDF, JPG 등의 형태로 업로드 된 파일에서 원하는 정보를 추출하여 Form 에 채워넣는 형태입니다.

이 기능은 OCR로도 어느정도 구현이 가능하겠지만, 수기 입력된 필기체들이 있었기 때문에 AI를 활용하기로 결정하였습니다.

본론

문제는 해당 기능의 구현이 완료된 후 테스트 중 일어났습니다.

기능의 특성상 정보량이 많은 파일이 업로드 될 경우, AI의 응답 속도가 생각보다 느려지는 경우가 있었습니다.

(의외로 용량과는 전혀 관계없음)

이 경우, 일정 시간동안 응답이 없을 경우 단순하게 타임아웃이 나고 해당 요청이 실패하는 수준에서 끝난 것으로 파악하였습니다.

하지만 모니터링 팀에서 갑작스럽게 데이터베이스와 서버 메모리 부하가 높아져 서버를 재시작했다는 공지를 하였습니다.

심지어 부하에 대해서 사전탐지가 불가능하고 해결방법이 재시작 뿐이라는 언급이 있었습니다.

당시에는 이 문제가 서로 직접적인 연관이 있을꺼란 생각은 못했는데, 수상할정도로 시간대가 겹치자 원인파악을 진행하게 되었습니다.

//코드 참고…

@Async 애노테이션과 @Transactional 애노테이션을 사용하면서 IO 바운더리를 올바르게 설정하지 않았기 때문입니다.

요청이 들어올경우 스프링은 Async 애노테이션에 의해서 새로운 쓰레드에 동작을 위임합니다.

위임받은 쓰레드는 Transactional 애노테이션에 의해서 기본값인 REQUIRED 전파 방식으로 새로운 트랜잭션을 시작합니다.

(어떤 이유로 Async가 먼저 수행되는 걸까?)

결국 N개의 비동기 요청을 처리하는 동안 N개의 트랜잭션이 각자 커넥션을 점유하면서 기다리게 됩니다.

따라서 점유하는 커넥션의 수가 줄어들지 않아 데이터베이스에서 더이상 처리를 진행할 수 없게 됩니다.

//실제 예시 참고…

심지어 이런 경우는 동일한 DB 행에 접근하는 경우도 잦기 때문에 문제가 됩니다.

해결방법

이 문제에 대한 해결책은 사실 매우 간단합니다.

트랜잭션을 점유하는 작업과 비동기 작업을 분리하는 것입니다.

보통은 서비스 코드를 별도로 구현하는 방식을 사용합니다.

@PostMapping("/do")
ResponseEntity<Void> doWork(String note) {
  long id = service.save(note);
  async.process(id);
  return ResponseEntity.accepted().build();
}

아니면 해당 비동기 요청에 타임아웃을 설정하는 방법이 있습니다.

또는 비동기 요청의 최대 스레드 개수를 제한하는것 또한 가능하겠죠.

사례

이런 문제가 발생하는 대표적인 예시는 알람입니다.

@Transactional
public void placeOrder() {
    orderRepository.save(...);
    externalApi.call();
    orderRepository.updateStatus(...);
    notificationRepository.save(...);
}

보통 이런식으로 프로토타입 작성했다가 externalApi에다가만 @Async 붙이고 까먹는 경우가 많습니다.

이렇게 작성하지 말고

@Transactional
public void placeOrder() {
    orderRepository.save(...);
    events.publishEvent(new OrderPlacedEvent(order.getId(), order.getUserId()));

}

@Service
@RequiredArgsConstructor
public class NotificationService {

    // 필요 시 별도 트랜잭션으로 저장
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendOrderPlaced(Long orderId, Long userId) {
        externalApi.call(...); // 외부 호출
        notificationRepository.save(Notification.of(orderId, userId, "PLACED"));
    }
}

이벤트 방식을 사용해서 이렇게 작성하도록 합시다.

정말 정합성이 중요하거나 큰 부하가 예상될 경우에는 차라리 아웃박스 패턴으로 가는것이 최적입니다.

롤백까지 얹으면

참고로 깊이가 늘어나고 롤백까지 참여한다면 디버깅하기 어려운 시나리오도 존재합니다.


ServiceA
@Transactional
public ResponseA methodA() {
    try {
        saveTableA()
        serviceB.methodB();
    } catch(Exception e) {
        e.printStacktrace();
    }
    return response;
}

ServiceB
@Async
public void methodB() {
    try {
        serviceC.methodC();
        save some data to tableB
    } catch(Exception e) {
        e.printStacktrace();
        throw new ServiceException(ex.getMessage());
    }
}

ServiceC
public void methodC() throws Exception {
    try {
        call to another service >>(여기서 실패한다면?)<<
    } catch(Exception e) {
        e.printStacktrace();
        throw e;
    }
}

메소드 C에서 실패하는 경우

C - B - A 를 거치면서 마지막엔 A의 Transactional 애노테이션으로 인해 롤백이 되야 할 것 같습니다.

실제로는 그렇지 않죠.

메소드 B와 C는 동기이므로 합쳐서 보면 조금 간단합니다.

A에서 비동기로 B와 C를 호출했기 때문에, C가 실패한 시점에 A의 메소드는 완료되고 커밋이 이루어져있을 수 있기 때문입니다…

NestJS에서는?

NestJS 이용자들의 의견이 궁금함


쓰다보니까 예전 이벤트의 함정 글이랑 내용이 비슷해진것 같다

업데이트:

댓글남기기