카테고리 없음

[스프링] 쿼리 최적화로 504 GatewayTimeOut의 근본적인 문제를 해결해보자 feat: jpa, index

_hanbxx_ 2024. 8. 14. 09:53
728x90

문제 상황

회사에서 파일 관리 시스템의 일환으로 Presigned URL을 대량으로 생성하는 API를 개발했습니다. 로컬 환경에서는 100개 이상의 URL도 1초 내로 생성할 수 있었습니다. 그러나 AWS에 배포된 실제 서버에서는 30개 이상의 URL을 요청하면 504 Timeout 오류가 발생했습니다.

원인 파악

로그를 자세히 분석한 결과, 문제의 원인을 파악할 수 있었습니다:

  1. 배포 서버는 AWS Elastic Load Balancer(ELB)를 이용하고 있는데, 이 ELB는 기본적으로 60초 후에 Timeout을 발생시킵니다.Timeout 발생 후 약 1분이 지나면 요청한 작업이 백그라운드에서 완료되었습니다.

->   하지만 이 504 에러코드가 뜨는 근본적인 원인을 찾아야 했습니다. 그래서 기존의 코드를 분석해 보니 쿼리 최적화가 하나도 되어 있지 않은 비효율적인 코드로 구현되어 있었습니다. 그리고 로컬에서 100개를 요청해도 됐던 이유는, 실 서버만큼 100만개가 넘는 데이터가 로컬 디비에 존재하지 않고 있었기 때문입니다. 로컬 디비와 서버 디비 환경을 동일하게 해놓고 문제점을 파악해보니, 쿼리에 대한 개선책이 확실하게 필요한 상황이었습니다.

해결 과정

자세한 문제 상황 파악

 

기존의 코드를 보여드리겠습니다.

    @Transactional
    public List<PresignedUrlResponse> createPresignedUrls(PresignedUrlRequest presignedUrlRequest) {
        List<PresignedUrlResponse> presignedUrlResponses = new ArrayList<>();

        for (PresignedUrlDto request : presignedUrlRequest.presignedUrlDto()) {
            URI presignedUrl = createPresignedUrl(request);
            File file = getFile(request);
            PresignedUrlResponse response = PresignedUrlResponse.from(file, presignedUrl);
            presignedUrlResponses.add(response);
        }

        log.info(
                "Presigned Url 생성 완료: ids={}",
                presignedUrlResponses.stream().map(PresignedUrlResponse::fileId).toList());

        return presignedUrlResponses;
    }

    /**
     * Presigned Url을 발급한다.
     */
    private URI createPresignedUrl(PresignedUrlDto request) {
        return fileUtil.getPresignedUrl(request.projectId(), request.bookId(), request.name(), request.extension());
    }

    /**
     * 이미 존재하는 파일에 대한 검증을 먼저 진행하고
     * 존재한다면 updateFile 후에 해당 파일이 반환된다.
     * 새로운 파일이라면 saveNewFile 후에 해당 파일이 반환된다
     */
    private File getFile(PresignedUrlDto request) {
        Optional<File> file = fileRepository.findByProjectIdAndBookIdAndNameAndExtension(
                request.projectId(), request.bookId(), request.name(), request.extension());
        return file.map(value -> updateExistingFile(value, request)).orElseGet(() -> saveNewFile(request));
    }

    private File updateExistingFile(File file, PresignedUrlDto request) {
        if (isFileTypeDocument(file.getExtension())) {
            file.updateSize(request.size());
            return file;
        }

        ImageFile imageFile = (ImageFile) file;
        imageFile.update(request.size(), request.resolution(), request.width(), request.height());
        return file;
    }

    private File saveNewFile(PresignedUrlDto request) {
        if (isFileTypeDocument(request.extension())) {
            return saveDocumentFile(
                    request.projectId(), request.bookId(), request.name(), request.size(), request.extension());
        }

        return saveImageFile(
                request.projectId(),
                request.bookId(),
                request.name(),
                request.size(),
                request.extension(),
                request.resolution(),
                request.width(),
                request.height());
    }

    private File saveImageFile(
            Long projectId,
            Long bookId,
            String name,
            Long size,
            FileExtension extension,
            Double resolution,
            Long width,
            Long height) {
        ImageFile imageFile = ImageFile.create(
                projectId,
                bookId,
                fileUtil.getDomain(),
                fileUtil.getBucket(),
                name,
                size,
                extension,
                resolution,
                width,
                height);
        return fileRepository.save(imageFile);
    }

    private File saveDocumentFile(Long projectId, Long bookId, String name, Long size, FileExtension extension) {
        DocumentFile documentFile = DocumentFile.create(
                projectId, bookId, fileUtil.getDomain(), fileUtil.getBucket(), name, size, extension);
        return fileRepository.save(documentFile);
    }

    /**
     * 파일 타입을 알아내는 함수
     * 문서는 true반환, 이미지는 false반환
     */
    private boolean isFileTypeDocument(FileExtension extension) {
        return extension == XML || extension == TOC || extension == PDF;
    }

코드 분석을 통한 문제점 파악

기존 코드를 분석한 결과, 데이터베이스 접근하는 코드에서 비효율적인 부분을 발견했습니다:

데이터베이스 접근의 비효율성

private File getFile(PresignedUrlDto request) {
    Optional<File> file = fileRepository.findByProjectIdAndBookIdAndNameAndExtension(
            request.projectId(), request.bookId(), request.name(), request.extension());
    return file.map(value -> updateExistingFile(value, request)).orElseGet(() -> saveNewFile(request));
}
  • 이 메서드는 매 반복마다 데이터베이스를 개별적으로 조회하고 있었습니다. 대량 요청 시 심각한 성능 저하를 초래하는 가장 큰 원인 중 하나였습니다.
  • 이로 인해 발생한 문제점들은 바로
    • 데이터베이스 연결 오버헤드: 각 쿼리마다 새로운 데이터베이스 연결을 맺고 끊는 과정이 반복됩니다. 입출력(I/O) 오버헤드가 증가하고 있었습니다.
    • 네트워크 지연: 각 쿼리마다 네트워크 왕복 시간이 소요되기 때문에, 대량의 쿼리가 실행될 경우 네트워크 왕복 시간이 길어지면서 응답 시간이 1분을 넘게 되었습니다. 그 결과, AWS ELB에서 타임아웃이 발생하여 결국 504 오류가 반환된 것입니다.

부하 테스트 : 문제를 대해 더 자세하게 파헤쳐 보기

 이미 데이터베이스에 존재하는 동일한 파일 데이터 500개에 대한 Presigned Url을 반환

-> 약 2분 49초

이 테스트만 해도 충분히 최적화가 필요한 것을 알 수 있지만, 실제로 프론트와 통신할 때 생기는 다른 상황을 추가로 가정해서 부하테스트를 더 진행해보았습니다.

이미 데이터베이스에 존재하는 각기 다른 파일 데이터 500개에 대한 Presigned Url을 반환 -> 약 5분
데이터베이스에 존재하지 않는 각기 다른 파일 데이터 500개에 대한 Presigned Url 반환 -> 약 3분

해결 방법 1: 인덱스 - JPA에서 인덱스 설정하기

1. 스프링 data jpa를 이용하여 테이블 정의할 때 사용하기

인덱스를 걸어버리면 최소 0.6초 가까이 나옵니다 .. 3분에서 0.6초는 엄청나게 줄어든 수치이긴 합니다만...

@Entity
@Table(name = "file", indexes = {
    @Index(name = "idx_file_attributes", columnList = "projectId,bookId,name,extension")
})
public class File {
    // ... 필드 정의
}

2. 여전히 부족했던 이유

인덱스 적용을 하려면 다음과 같은 문제점들도 고려해야 했습니다:

  1. 쓰기 성능 저하: 새로운 파일 정보를 자주 추가하는 우리 서비스 특성상, 인덱스로 인한 쓰기 성능 저하는 무시할 수 없었습니다. 인덱스를 적용한다 해도 데이터베이스 I/O 호출량이 엄청나게 줄어들지 않기 때문입니다.
  2. 저장 공간 증가: 대용량 데이터를 다루는 우리 시스템에서 인덱스로 인한 추가 저장 공간은 상당한 비용 증가를 의미했습니다.
  3. 복잡한 쿼리에 대한 한계: 단순 인덱스만으로는 우리 서비스의 복잡한 조회 패턴을 모두 커버하기 어려웠습니다.

결국 인덱스 적용을 하더라도 현재 수용 가능한 범위를 넘어서면 또 문제가 발생할 수 밖에 없다는 것을 깨달았습니다. 데이터를 많이 사용하는 회사 프로젝트인 만큼 인덱스 적용으로 발생될 메모리도 고려하지 않을 수 없었습니다.

(진) 해결 방법 2: 쿼리 최적화

인덱스 적용은 임시방편임을 깨달은 후, 쿼리 자체를 최적화하는 방향으로 전략을 수정했습니다. 기존 코드의 비효율성을 개선하여 다음과 같이 변경했습니다:

@Transactional
public List<PresignedUrlResponse> createPresignedUrls(PresignedUrlRequest presignedUrlRequest) {
    List<PresignedUrlResponse> presignedUrlResponses = new ArrayList<>();

    List<String> fileNames = presignedUrlRequest.presignedUrlDto().stream()
            .map(PresignedUrlDto::name)
            .toList();

    List<File> fileList = fileRepository.findByProjectIdAndBookIdAndNameIn(
            presignedUrlRequest.projectId(), presignedUrlRequest.bookId(), fileNames);

    for (PresignedUrlDto request : presignedUrlRequest.presignedUrlDto()) {
        URI presignedUrl =
                createPresignedUrl(request, presignedUrlRequest.projectId(), presignedUrlRequest.bookId());
        File file =
                saveOrUpdateFile(request, fileList, presignedUrlRequest.projectId(), presignedUrlRequest.bookId());
        PresignedUrlResponse response = PresignedUrlResponse.from(file, presignedUrl);
        presignedUrlResponses.add(response);
    }

    log.info(
            "Presigned Url 생성 완료: ids={}",
            presignedUrlResponses.stream().map(PresignedUrlResponse::fileId).toList());

    return presignedUrlResponses;
}


1. 배치 조회 도입:

List<File> fileList = fileRepository.findByProjectIdAndBookIdAndNameIn(
           presignedUrlRequest.projectId(), presignedUrlRequest.bookId(), fileNames);

 

개별 파일마다 데이터베이스를 조회하던 방식에서 벗어나, 한 번의 쿼리로 필요한 모든 파일 정보를 가져오도록 변경했습니다. 이 변경으로 데이터베이스 호출 횟수를 대폭 줄였습니다.

 

1. 배치 조회 (Batch Retrieval): 여러 파일 정보를 개별적으로 조회하는 대신, 한 번의 쿼리로 모든 파일 정보를 가져오는 방식입니다. 이는 SQL IN 절을 사용해 fileNames에 포함된 모든 파일을 한꺼번에 가져오는 방식으로, 데이터베이스의 효율성을 극대화할 수 있었습니다.
   
2. 데이터베이스 호출 감소: 데이터베이스에 대한 쿼리 호출 횟수가 한 번으로 줄어듭니다. 결과적으로 데이터베이스 부하를 줄이며 성능을 향상시킬 수 있었습니다.


2. 메모리 내 처리 최적화: 

   private File saveOrUpdateFile(PresignedUrlDto request, List<File> fileList, Long projectId, Long bookId) {
       Optional<File> fileOptional = fileList.stream()
               .filter(file -> file.getName().equals(request.name()))
               .filter(file -> file.getExtension().equals(request.extension()))
               .findFirst();

       return fileOptional
               .map(file -> updateExistingFile(file, request))
               .orElseGet(() -> saveNewFile(request, projectId, bookId));
   }

   데이터베이스에서 가져온 파일 목록을 메모리에 저장하고, 이를 활용하여 파일 존재 여부를 확인하고 업데이트하는 로직을 구현했습니다. 이를 통해 불필요한 데이터베이스 조회를 줄이고, 처리 속도를 향상시켰습니다.

3. 코드 구조 개선: 
   메서드를 더 작고 명확한 책임을 가진 단위로 분리하여, 코드의 가독성과 유지보수성을 향상시켰습니다.

성능 개선 결과
이러한 최적화 적용 후, 다음과 같은 성능 개선을 달성했습니다:
: 평균 응답 시간: 3분 → 600ms (99% 감소)

결론

이번 쿼리 최적화 과정에서 몇 가지 중요한 교훈을 얻었습니다:

1. 데이터 접근 패턴 분석의 중요성: 단순히 기술적 최적화 기법을 무작정 적용하는 것보다, 서비스의 데이터 사용 패턴을 정확하게 이해하는 것이 더욱 중요하다는 것을 배웠습니다.

예를 들어, 인덱스를 무작정 추가하는 것만으로는 성능 개선을 보장할 수 없었습니다. 특히 추후에 발생할 메모리 문제를 감당하지 못했을 것 같습니다. 오히려, 데이터베이스 접근 패턴을 면밀히 분석하고, 실제 성능 저하가 발생하는 부분을 정확히 파악한 후, 그에 맞춘 최적화 전략을 수립하는 것이 성능 향상에 훨씬 더 효과적이었습니다.


2. 벌크 연산의 효과: 개별 처리보다 벌크 연산을 활용함으로써 드라마틱한 성능 향상을 얻을 수 있었습니다.


728x90