Search
moon
sun

S3 Object에 대한 FE 캐싱 issue

🤔 문제

기존에는 파일을 MultipartFile로 받아 S3에 PUT으로 저장한 후 조회하는 과정에서 각 파일에 대해 GET 요청을 통해 실제로 S3에 있는지 확인한 후 CDN URL을 문자열 조합하여 응답하는 방식이었습니다.
이 때 저장하는 과정에서 파일의 이름을 강제로 고정했기 때문에 S3의 파일이 변경되어도 CDN URL은 변경되지 않았고 FE에서의 캐싱이 이를 감지하지 못하여 변경된 파일로 업데이트되지 않았습니다.

🌱 해결 방법

우선 파일의 이름을 강제로 고정하는 방식에서 파일의 이름을 UUID를 사용한 이름으로 저장하는 방식으로 변경하기로 하였습니다.
이를 통해 파일이 변경될 때마다 CDN URL도 함께 변경되어 FE쪽에서 감지하여 새로 캐싱할 수 있게 하였습니다.
또한 UUID를 통한 고유의 파일 이름을 BE에서 관리하기 쉽게 하며 S3에 대한 GET 요청을 없애기 위해 파일의 이름을 DB 컬럼으로 저장하도록 변경하였습니다.

🐾 해결 과정

// 수정 전 public void uploadLogo(Long companyId, String name, MultipartFile logo) { if(logo == null || logo.isEmpty()) { return; } if(logo.getSize() > 1000000) { throw new CustomException(ErrorCode.EXCEED_SIZE_FILE); } int pos = logo.getOriginalFilename().lastIndexOf("."); String extension = logo.getOriginalFilename().substring(pos + 1); if (!imageExtension.contains(extension.toLowerCase())) { throw new CustomException(ErrorCode.INVALID_TYPE); } try { ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentType(logo.getContentType()); objectMetadata.setContentLength(logo.getSize()); amazonS3Client.putObject(bucket + "/companies/" + companyId, name, logo.getInputStream(), objectMetadata); } catch (MaxUploadSizeExceededException e) { throw new CustomException(ErrorCode.EXCEED_SIZE_S3); } catch (Exception e) { throw new CustomException(ErrorCode.FAIL_UPLOAD_FILE); } } // 수정 후 public String uploadLogo(Long companyId, MultipartFile file) { if(file == null || file.isEmpty()) { return null; } if(file.getSize() > 1000000) { throw new CustomException(ErrorCode.EXCEED_SIZE_FILE); } String randomName = StringUtils.replace(UUID.randomUUID().toString(), "-", ""); int pos = Objects.requireNonNull(file.getOriginalFilename()).lastIndexOf("."); String extension = file.getOriginalFilename().substring(pos + 1); if (!imageExtension.contains(extension.toLowerCase())) { throw new CustomException(ErrorCode.INVALID_TYPE); } try { ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentType(file.getContentType()); objectMetadata.setContentLength(file.getSize()); amazonS3Client.putObject(bucket + "/companies/" + companyId, randomName + "." + extension, file.getInputStream(), objectMetadata); return bucket + "/companies/" + companyId + "/" + randomName + "." + extension; } catch (MaxUploadSizeExceededException e) { throw new CustomException(ErrorCode.EXCEED_SIZE_S3); } catch (Exception e) { throw new CustomException(ErrorCode.FAIL_UPLOAD_FILE); } }
Java
복사
위 코드는 S3 SDK 라이브러리를 사용하기 위한 S3Util 클래스에서 기업 로고를 업로드 하기 위한 메서드입니다.
UUID로 만들어진 randomName을 파일의 이름으로 S3에 PUT 하게 변경하였습니다.
//수정 전 public ResponseDTO saveCompany(CompanyFactorySaveDTO companyFactorySaveDTO, MultipartFile logo) { Company company = companyFactorySaveDTO.toEntity(StatusCode.IN_PROGRESS); companyRepository.save(company); s3Util.uploadLogo(company.getId(), "logo.png", logo); return ResponseDTO.builder() .status(HttpStatus.CREATED.value()) .statusName(HttpStatus.CREATED.name()) .build(); } // 수정 후 public ResponseDTO saveCompany(CompanyFactorySaveDTO companyFactorySaveDTO, MultipartFile logo) { Company company = companyFactorySaveDTO.toEntity(StatusCode.IN_PROGRESS); if(logo != null && !logo.isEmpty()) { String fileUrl = s3Util.uploadLogo(company.getId(), logo); company.setLogoUrl(fileUrl); } companyRepository.save(company); return ResponseDTO.builder() .status(HttpStatus.CREATED.value()) .statusName(HttpStatus.CREATED.name()) .build(); }
Java
복사
위 코드는 어드민 서비스에서 기업을 등록하는 메서드입니다.
기존에 파일 이름을 지정하여 단순 업로드만 하는 방식에서 파일을 업로드 한 후 랜덤으로 만들어진 이름을 logo_url 컬럼에 저장하는 방식으로 변경하였습니다.
// 수정 전 public ResponseDTO updateCompany(CompanyUpdateDTOforAdmin companyUpdateDTOforAdmin, MultipartFile logo, Long companyId) { Company company = companyRepository.findById(companyId); if(company == null) { throw new CustomException(ErrorCode.BLANK_COMPANY); } company.updateCompany(companyUpdateDTOforAdmin); //Create Position company.createFactory(company, companyUpdateDTOforAdmin); //Update Position companyUpdateDTOforAdmin.getFactoryArr().stream() .filter(p -> p.getId() != null) .forEach(i -> { Factory factory = factoryRepository.findById(i.getId()); factory.updateFactory(i); }); //Delete Position company.deleteCompany(companyUpdateDTOforAdmin); s3Util.uploadLogo(company.getId(), "logo.png", logo); return ResponseDTO.builder() .status(HttpStatus.OK.value()) .statusName(HttpStatus.OK.name()) .build(); } // 수정 후 public ResponseDTO updateCompany(CompanyUpdateDTOforAdmin companyUpdateDTOforAdmin, MultipartFile logo, Long companyId) { Company company = companyRepository.findById(companyId); if(company == null) { throw new CustomException(ErrorCode.BLANK_COMPANY); } if(logo != null && !logo.isEmpty()) { if (company.getLogoUrl() != null) { int pos = company.getLogoUrl().lastIndexOf("/"); String fileName = company.getLogoUrl().substring(pos + 1); s3Util.deleteObject("companies", fileName); } String fileUrl = s3Util.uploadLogo(company.getId(), logo); company.setLogoUrl(fileUrl); } company.updateCompany(companyUpdateDTOforAdmin); //Create Position company.createFactory(company, companyUpdateDTOforAdmin); //Update Position companyUpdateDTOforAdmin.getFactoryArr().stream() .filter(p -> p.getId() != null) .forEach(i -> { Factory factory = factoryRepository.findById(i.getId()); factory.updateFactory(i); }); //Delete Position company.deleteCompany(companyUpdateDTOforAdmin); return ResponseDTO.builder() .status(HttpStatus.OK.value()) .statusName(HttpStatus.OK.name()) .build(); }
Java
복사
어드민 서비스에서 기업을 수정하는 로직에서도 기존 logo.png 파일을 덮어씌우는 방식에서
DB에 저장된 파일 URL을 이용해 기존 파일을 삭제한 후 새로운 파일을 다시 저장하는 방식으로 변경하였습니다.
company.setLogoUrl(s3Util.getUrl("companies", company.getId(), "logo.png"));
Java
복사
위 코드처럼 조회 시 기업 로고 URL이 들어가는 부분은 S3에 logo.png라는 이름의 파일을 GET 요청으로 있는지 확인한 후 CDN URL을 문자열 조합해 직접 넣어주는 방식이었습니다.
위 코드를 모두 제거한 후 Repository를 통해 DB에서 꺼낼 때 DB에 저장되어 있는 로고 URL을 DTO로 받아서 출력하는 방식으로 변경하였습니다.
같은 방식으로 배너 이미지 업로드 로직도 수정하였습니다.
//S3 Object List를 받아와서 id 리스트만 뽑아오는 Python 코드 accessKey = '' secretKey = '' region = '' bucket_name = '' prefix = '' import boto3 s3 = boto3.client('s3', aws_access_key_id=accessKey, aws_secret_access_key=secretKey, region_name=region) obj_list = s3.list_objects(Bucket=bucket_name, Prefix=prefix) contents_list = obj_list['Contents'] id_list = [] for content in contents_list: key = content['Key'] parse1 = key[key.find('/') + 1:] parse2 = parse1[0:parse1.find('/')] id_list.append(int(parse2)) id_list.sort() str(id_list) print(id_list)
Java
복사
//모든 logo_url에 기존 CDN URL을 넣어주기 위한 SQL 코드 UPDATE company SET company.logo_url = CONCAT('https://cdn.gocho-back.com/companies/', company.id, '/logo.png') WHERE company.logo_url IS NULL;
Java
복사
//Python으로 뽑아온 ID 리스트에 포함되지 않는 Row는 logo_url을 NULL로 바꾸는 SQL 코드 UPDATE company SET company.logo_url = NULL WHERE company.id NOT IN (ID 리스트);
Java
복사
마지막으로 파이썬과 SQL을 이용하여 현재 S3에 올라가있는 파일들과 DB에 CDN URL의 싱크를 맞춰주었습니다.

👀 테스트

먼저 포스트맨으로 기업 등록 API 를 통해 로고 업로드를 시도해보았습니다.
랜덤으로 이름이 생성되어 DB에 잘 저장되어 있는 모습을 확인할 수 있습니다.
S3 또한 DB에 저장된 이름과 같은 이름으로 정확한 위치에 잘 저장된 것을 확인할 수 있습니다.
DB에 저장된 CDN URL을 통해 정상적으로 접근할 수 있는 것도 확인할 수 있었습니다.
조회 API 에서도 DB에 있는 CDN URL이 잘 조회되는 것을 확인하였습니다.
로고가 등록되지 않은 경우엔 정상적으로 null이 출력되었습니다.
기업 수정에서 로고를 수정하였을 때 아까와는 다른 CDN URL이 저장된 것을 확인할 수 있습니다.
조회 API에서도 변경된 CDN URL이 조회되는 모습을 확인하였습니다.
S3에도 정상적으로 기존의 이미지가 삭제되고 새로운 이미지가 업로드된 것을 확인할 수 있습니다.
DB에 새로 저장된 CDN URL도 접근이 잘 되는 것을 확인하였습니다.
변경 전 APM
변경 후 APM
설치 해놓은 APM으로 Dependency를 확인한 결과 CDN URL 교체하기 전에 존재하던 S3 Dependency가 사라진 것을 확인할 수 있었습니다.
비용면에서는 기존에도 S3 Endpoint를 사용하고 있었기 때문에 NAT Gateway의 사용을 줄인거에 비해서 큰 변화가 없었습니다.

📄 관련 문서

없음