비동기 처리는 응답 시간 개선의 대표적인 방법이지만, 무분별하게 적용하면 오히려 시스템을 복잡하게 만들 수 있다. 데브코스 3차 프로젝트에서 동기 방식으로 진행했던 분석 프로세스를 비동기로 전환하며 응답 시간은 개선했지만, 그 과정에서 비동기가 만능 해결책은 아니라는 점 또한 깨닫게 되었다.
이번에는 동기와 비동기의 차이, 비동기 도입 판단 기준에 대해서 작성해 보려고 한다.
[10분 테코톡] 포스티의 비동기, 언제 도입해야 할까?
동기 방식(Synchronous)은 작업을 순차적으로 실행하는 것이다. 한 작업이 완료될 때까지 기다린 후, 그 결과를 받아 다음 작업을 진행한다. 작업 완료 시점과 결과 처리에 대한 즉각적인 책임을 가지는 것이다.
비동기 방식(Asynchronous)은 여러 작업을 동시에 실행하는 것이다. 작업을 시작한 후 작업에 대한 결과를 기다리지 않고, 결과는 나중에 처리한다. 작업 시작과 결과 처리가 분리되어 있다고 생각하면 된다.
내 차례가 되려면 앞 고객의 업무가 완전히 끝날 때까지 무조건 대기해야 한다. 창구 직원은 한 명의 고객 업무가 끝나야 다음 고객을 받을 수 있다. 이렇게 이전 작업의 결과를 확인한 뒤에야 다음 작업을 진행하는 방식이 동기 방식이다.
은행에서 상담을 받기 위해서는 번호표를 뽑고 대기해야 한다. 창구 직원은 여러 고객의 번호표를 받아 두고, 순서대로 손님을 받는다. 그 과정에서 고객은 자신의 번호가 호출될 때까지 다른 일을 할 수 있다. 완벽한 비유는 아니지만, 이런 식으로 결과와 상관없이 동시에 작업이 진행되는 것을 비동기 방식이라고 생각하면 쉽다.
사진 삭제의 프로세스는 다음과 같다.
이를 동기 방식으로 구현하면,
public void deletePhoto(Long photoId, Long userId) {
// 1. 권한 확인 (50ms)
validatePermission(photoId, userId);
// 2. DB 조회 (30ms)
Photo photo = photoRepository.findById(photoId)
.orElseThrow(() -> new PhotoNotFoundException());
// 3. 외부 저장소 삭제 - 외부 API 호출 (500ms)
s3Client.deleteObject(photo.getFilePath());
// 4. DB 삭제 (30ms)
photoRepository.delete(photo);
// 총 예상 응답 시간: 약 610ms
}
이를 비동기 방식으로 개선하면,
public void deletePhoto(Long photoId, Long userId) {
// 1. 권한 확인 (50ms)
validatePermission(photoId, userId);
// 2. DB 조회 (30ms)
Photo photo = photoRepository.findById(photoId)
.orElseThrow(() -> new PhotoNotFoundException());
// 3. DB 삭제 먼저 처리 (30ms)
photoRepository.delete(photo);
// 4. 외부 저장소 삭제는 비동기로 처리
asyncPhotoService.deletePhotoFile(photo.getFilePath());
// 총 예상 응답 시간: 약 110ms (외부 API 대기 시간 제거)
}
이럴 경우, S3 삭제 작업은 백그라운드에서 처리되기 때문에 사용자는 외부 API 대기 시간만큼 일찍 결과를 받을 수 있게 된다. 이런 것들이 쌓이고 쌓여 사용자 경험을 개선할 수 있게 된다.
한 API에 외부 API를 호출하는 로직이 포함되면, 외부 서버의 응답 시간이 내 서버의 응답 시간에 직접적인 영향을 준다. 예를 들어, 결제 API 호출에 평균 2초가 걸린다면 동기 방식은 최소 2초 이상, 비동기 방식은 결과는 이후 웹훅이나 폴링으로 받고 응답 자체는 즉시 받을 수 있다.
이번 3차 프로젝트를 진행하며 동기 방식으로 진행했던 분석 프로세스를 비동기 방식으로 전환했는데, 동기 방식으로 진행했을 때에는 응답 시간이 길어져 때때로 분석이 실패하는 것처럼 보였으나 비동기 방식으로 전환한 후에는 그 수가 확연히 줄어들었다. 또한 K6로 테스트를 진행해 봤을 때도 평균 응답 시간이 99% 가까이 개선되었음을 확인했다.
위에서 말했던 것처럼 동기 방식은 모든 요청의 응답을 기다려야 한다. 이는 다음과 같은 문제를 야기할 수 있다.
@Async 어노테이션이 대표적비동기로 전환할 때는 외부 API 연동이 실패해도 사용자 경험에 문제가 발생하지 않도록 신경 써야 한다. 특히, 실패 시나리오를 고려한 재시도 메커니즘, 실패 로그 기록, 수동 복구 프로세스 등을 함께 설계해야 한다. 관련 경험 내용은 데브코스 3차 프로젝트 [2]에서 확인할 수 있다.
비동기를 도입하기 전에 다음 네 가지 질문에 답해 보자.
외부 API 호출 실패가 곧 핵심 비즈니스 로직의 실패를 의미한다면, 비동기 처리는 부적합할 수 있다.
외부 API 응답 시간이 사용자가 체감하는 대기 시간에 포함된다면, 비동기 처리를 고려해야 한다. 만약 외부 API 호출이 1초 이상 걸린다면, 비동기 처리를 통해 사용자 응답 시간을 줄일 수 있다.
비동기 처리는 즉시 결과를 확인할 수 없으므로, 실패 시 재시도나 보상 로직이 필요하다. 만약, 실시간 재고 확인과 같이 일시적 실패를 허용할 수 없거나 데이터의 정합성이 중요한 로직의 경우에는 응답 시간이 느리더라도 동기 처리가 더 적합한 방식이다.
비동기 작업이 실패했을 떄, 이를 감지하고 복구할 수 있는 메커니즘이 있어야 한다.
데브코스 백엔드 3차 프로젝트 [2]에서도 경험했던 것처럼 비동기 방식이 응답 시간 개선에는 엄청 큰 역할을 하지만, 그만큼 실패했을 경우에 멱등성 보장을 위한 로직을 신경 써서 구현해야 한다는 점을 느꼈다. 실제로 그 부분을 놓쳐서 DB에 없어도 될 데이터가 남아 있기도 했고.