작업일지#14 Sync와 Async 속도차이

2022. 3. 24. 18:05작업일지

트랜잭션 단위

상품 등록

  1. was
    • 클라이언트 요청
  2. 세션 서버
    • 리소스 접근을 위한 인증 확인
  3. 파일서버(S3)
    • 상품 이미지 put 요청
    • aws lambda를 통해, 원본 이미지를 섬네일 사이즈로 압축 및 복사 (총 2개의 이미지를 2개의 s3 bucket에 저장)
  4. was
    • s3 키값 전달
  5. 데이터베이스
    • MySQL에 상품 정보 Insert
  6. was
    • 상품 등록 성공 여부 및 추가된 상품 정보 응답 메시지로 클라이언트에 전달

실험을 하게 된 계기

  • 기존에는 파일 업로드를 포함하여, 단일 기능을 구현하는 데 필요한 비즈니스 로직을 전부 하나의 데이터베이스 트랜잭션에 포함하였다.
  • 그러나, 최근에 이미지 업로드 기능을 추가하면서, 로컬 환경에서 실험했음에도 불구하고, 응답 시간이 거의 2배 이상 늘었다.
  • tomcat의 thread io 모델은 기본적으로 Blocking synchronous이기 때문에, S3 put요청 처리시간+lambda 처리시간으로 트랜잭션 처리에 병목이 발생한 거라 생각했다.
  • 따라서, 기존의 데이터베이스 트랜잭션에서, 파일을 업로드하는 기능만 분리하여, 해당 기능을 비동기로 처리했을 경우 응답 시간을 얼마나 개선할 수 있을지 확인하고자 했다.

실험 환경

  • Java 11, Spring Boot, Spring Security, Redis, MySQL, log4j
  • lombok
  • 파일 io의 경우, S3의 성능을 테스트하는 것이 아니라, 동기, 비동기 방식의 프로그래밍을 통해, TPS가 개선될 여지가 있는지 확인하는 것이 중요하기 때문에 로컬 환경에서 파일 io 기능을 구현하고, 일부로 Thread를 대기시키는 방식으로 진행을 해보았다.
  • 비동기 메서드의 경우 `@Async`를 활용.

동기식 파일 IO vs 비동기식 파일 IO

동기식 파일 IO

upload method

@Component
@Slf4j
@RequiredArgsConstructor
public class DefaultFileClient implements FileClient {
    @Override
    public String upload(InputStream inputStream, long length, String key, String contentType, Map<String, String> metadata) {
        try {
            Files.copy(inputStream, this.root.resolve(Objects.requireNonNull(key)));
            Thread.sleep(500);
            return this.root + File.separator + key;
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 유지보수 효율을 위해 `FileClient` 인터페이스를 따로 만들어서 구현을 했지만, 굳이 인터페이스를 쓸 필요 없이 바로 `DefaultFileClient`를 컴포넌트로 DI해도 상관없다.

upload controller

@Slf4j
@RestController
@RequestMapping("api")
@RequiredArgsConstructor
public class ImageFileController {
    private final FileClient fileClient;

    @PostMapping(value = "file/sync", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<String> resultSync(@ModelAttribute FileRequest fileRequest, @RequestPart(required = false) MultipartFile file) {
        return ResponseEntity.ok(uploadImageFile(fileClient, of(file), fileRequest.getPath()));
    }

    public String uploadImageFile(FileClient fileClient, ImageFile file, String path) {
        log.info("upload image file");

        if (file != null) {
            String key = file.randomName(path, "jpeg");
            try {
                fileClient.upload(file.inputStream(), file.length(), key, file.getContentType(), null);
                return key;
            } catch (RuntimeException e) {
                log.warn("Amazon S3 error (key: {}): {}", key, e.getMessage(), e);
            }
        }
        return null;
    }
}
  • 에러 메시지에선 Amazon S3를 언급했지만, 이 실험에서 실제로 S3가 관여하진 않는다.

비동기식 파일 IO

upload method

@Component
@Slf4j
@RequiredArgsConstructor
public class DefaultFileClient implements FileClient {
    @Override
    public String upload(InputStream inputStream, long length, String key, String contentType, Map<String, String> metadata) {
        try {
            Files.copy(inputStream, this.root.resolve(Objects.requireNonNull(key)));
            Thread.sleep(500);
            return this.root + File.separator + key;
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

upload controller

@PostMapping(value = "file/async", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> resultAsync(@ModelAttribute FileRequest fileRequest, @RequestPart(required = false) MultipartFile file) {
    return ResponseEntity.ok(uploadAsyncImageFile(fileClient, of(file), fileRequest.getPath()));
}

public String uploadAsyncImageFile(FileClient fileClient, ImageFile file, String path) {
    if (file != null) {
        String key = file.randomName(path, "jpeg");
        try {
            fileClient.uploadAsync(file.inputStream(), file.length(), key, file.getContentType(), null);
            return key;
        } catch (AmazonS3Exception e) {
            log.warn("Amazon S3 error (key: {}): {}", key, e.getMessage(), e);
        }
    }
    return null;
}

결과

  • PostMan으로 실험했으며, 단위는 ms이다.
  • 캐싱은 인위적인 캐시가 아니라, Http 통신에서 기본적으로 제공하는 캐싱을 이야기한다.
  • 업로드 실험을 한 이미지의 크기는 약 35kb이다.
  • Transfer start란, 응답 메시지를 얻기 위한 데이터의 첫 byte를 얻는데 까지 소요된 시간이다.

Sync(캐싱 전/후) Async(캐싱 전/후)

Soket initialization 6.9 / 0 거의 동일
DNS Look up 0.9 / 0 거의 동일
TCP Handshake 1 / 0 거의 동일
Transfer start 600 / 528 142 / 13
Download 5 / 3.37 거의 동일

현재 실제 인터넷 네트워크를 구성한 것이 아니라, 로컬 환경에서 실험을 했기 때문에 소켓, DNS 조회, TCP 핸드 셰이킹 등은 신경 쓸 필요가 없다. 다만, Sync의 경우, 트랜잭션이 `Thread.sleep(500)`의 시간을 기다려주느라, 캐싱이 적용되어도, 응답 시간이 크게 최적화되지 않았지만, Async의 경우, 최초 응답시간 뿐만 아니라, 캐싱이 적용되었을 때의 개선된 응답시간이 크게 줄었다.

어째서?

  • Sync의 경우, 요청을 처리하는 스레드가 파일 IO를 포함하여, 트랜잭션에 포함된 모든 로직의 순서를 보장하기 위해, (`Thread.sleep(500)` + 기타 비즈니스 로직 처리)의 시간이 걸렸다.
  • Async의 경우, 요청을 처리하는 스레드 외에 또 다른 스레드(백그라운드 스레드)에게 파일 IO 처리를 위임하고, 나머지의 트랜잭션 처리를 요청 스레드가 맡아서 응답 메시지를 리턴하기 때문에, 요청 스레드에 속한 트랜잭션은 파일 IO의 처리 시간을 기다릴 필요가 없다.

그렇다면 비동기의 단점은 무엇일까?

  • 우선 프로그래밍의 난이도가 올라간다는 점을 꼽을 수 있겠다.
    • 순서를 보장하지 않기 때문에, 트랜잭션의 성질에 따른 예외처리를 폭넓게 처리해야 한다.
  • spring에서 @Async로 비동기 로직을 처리할 경우, 별도의 스레드 풀을 생성해서 관리하는데, 만약 비동기 로직을 처리할 일이 없다면, 그 스레드 풀의 관리비용으로 인해 리소스를 낭비하게 되는 상황이 생긴다.

그렇다면 언제 사용하는 것이 좋을까?

  • 비즈니스 로직을 통해 제공하고자 하는 서비스의 성격을 파악해야 한다.
    • 만약에 정확성보다는 속도가 중요하다면, 비동기 로직을 사용하는 것이 좋다.
    • 그러나 정확성을 100%에 가깝게 유지해야 하는 상황이라면 속도를 포기하고 기존의 동기식 로직을 사용하는 것이 좋다.
  • 만약에 해당 서비스를 이용하는 사용자가 제한되어있고, 그 사용자의 수가 적다면, 굳이 비동기식 로직으로 프로그래밍의 복잡성을 높일 필요는 없을지도 모른다.
    • 예를 들자면, 30명 정도의 직원이 있는 회사에서만 사용하는 사내 홈페이지고, 기존 트랜잭션 처리 시간이 대략 500ms 라면, 전 직원이 같은 서비스를 동시에 사용하더라도 약 1500ms(1.5초)이므로, 굳이 최적화할 필요가 없다.

정리하자면, 비동기 프로그래밍은 많은 요청이 몰리더라도 준수한 사용자 경험(응답 속도)을 유지해야 하는 상황에서는 좋은 선택이 될 수 있겠다.