PatientPal

AWS, Presigned URL 이용한 프로필 이미지 업로드 구현기

후후후하하하 2024. 6. 29. 14:15

저는 환자와 간병인이 서로를 지역, 조건에 따라 검색하고, 리스트를 찾아서 매칭 신청을 보내고 응답하는 팀프로젝트를 진행중입니다.

이 과정에서 매칭 신청을 보내려면 본인의 세부 프로필이 등록이 되어있어야 한다는 조건이 있습니다.

그래서 세부 프로필 등록 시, 본인의 이미지를 선택사항으로 프로필에 등록할 수 있어야 한다는 기획이 있어서

이미지 등록 기능을 진행하였습니다.

 

고려한 이미지 등록 방법은 크게 두 가지 였습니다.

1. Multipart Upload를 이용한 서버에 저장 or 외부 저장소에 저장

2. Presigned URL를 이용한 외부 저장소에 클라이언트에서 직접 저장

 

Multipart 를 이용한 이미지 처리의 경우는 DB에 저장을 하든 외부 저장소에 저장을 하든, 결국 server에서 이미지를 받아서 처리를 해야하는 것이므로 트래픽이 많아질수록 서버에 부하가 크겠다는 생각이 들었고, 이후 프로젝트에서 채팅 기능 또한 구현될 예정이므로, 채팅에서 발생하는 이미지들도 저장을 multipart를 이용한 server를 통해 진행하게 되면 서버의 부담이 더욱 커질 것이라 판단해, 서버를 거치지 않고 클라이언트에서 외부 저장소로 이미지를 직접 저장하는 presigned url를 이용하기로 결정하였습니다.

 

presigned url이란, server에서는 단순히 인가된 사용자에게 s3 bucket에 저장될 url 경로만 받아와 클라이언트에게 전달하고, 실제 저장 로직은 클라이언트에서 처리하는 방식으로, server의 역할은 url을 받아와 전달해주는 것 뿐이므로 Multipart를 이용한 방식보다 server 사용이 덜하고, 또한 발급되는 presigned url은 일정 시간만 유효하도록 제한 시간을 둘 수 있어 보안적인 측면도 강화됩니다. 

 

그런데 여기서 의문이 들었습니다. 서버에서는 부하가 줄어들었긴 한데, 반대로 클라이언트에서 부담이 늘어난거 아닙니까??=> 클라이언트에서 presigned url을 얻기 위해 서버에 요청을 보내는 과정이 추가되긴 하지만, 이건 일반적인 api 요청과 동일할 뿐이라 크게 부하가 걸리지 않고, 서버에서 직접 이미지 업로드를 처리하는 것에 비하면 성능상 이점이 더 많다고 합니다.

 

이러한 이유들로 AWS s3와 presigned url로 이미지 업로드를 진행하였고, 구현 자체는 어렵지 않았습니다.

 

우선 s3에 접속하여 bucket 정책 설정과 CORS 설정을 진행해주고

Spring에서 aws를 사용하기 위해 build.gradle에 

implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'
implementation 'io.awspring.cloud:spring-cloud-starter-aws-secrets-manager-config:2.4.4'

추가해주고, application.yml에 aws 설정 정보들을 넣어주고 presigned url을 반환해주는 api를 생성하면 됩니다.

 

동작 과정 : 

  1. 프로필 등록 시 이미지를 선택한다.
  2. 후 프로필 등록 최종 완료 버튼 누를 시 @PostMapping("api/v1/caregiver/presigned") 가 호출된다
  3. 클라이언트에서 이미지 이름을 매개변수로 넘기면서 aws s3에 저장될 전체 경로(presigned url)가 반환된다
  4. 반환된 경로로 첨부된 사진을 request body에 포함하여 @PutMapping을 이어서 진행하면 해당 url에 이미지가 저장된다.(s3 저장소에 저장)

위 flow를 보면 보이지만 server의 역할은 multipart 를 이용할 때에 비하면 적다는 것이 보입니다.

단순히 presigned url을 얻어서 반환하는 것 밖에 없죠. 또한 api를 이용하려면 서버에서 인증/인가가 완료된 사용자여야 하므로 최소한의 보안 + presigned url 유효 시간을 설정함으로써 추가적인 보안까지 얻는다는 장점이 있습니다.

 

 

예상치 못한 동시성 문제

처음 PresignedUrlService를 구현할 때

이런 식으로 useOnlyOneFileName 필드(경로prefix가 제외된, UUID가 더해진  파일 이름)를 전역 변수로 선언했었습니다.

왜냐하면 url 경로를 서버에 저장하기 위해 (이미지 저장 x, url 경로 저장 o) 환자 컨트롤러에서 저장할 파일의 url을 가져와야 했는데, (findByName)메서드

이 메서드에서 저장되는 url을 가져오기 위해 useOnlyOneFileName가 전역 변수로 사용되어야 했기 때문입니다.

하지만 이는 동시성 문제가 발생하는데,

예를 들어 사용자 A가 이미지 저장을 위해 useOnlyOneFileName 값을 xxxxxxx+UUID로 등록하고 저장하는 중간에, 사용자 B가 곧바로 이미지 저장을 한다면 useOnlyOneFileName 값이 yyyyyyy+UUID로 바뀌어 사용자 A의 이미지 경로가 yyyyyyy+UUID가 되어버리고 맙니다 ! 이는 남의 사진이 내 프로필에 등록될 수 있다는 의미이며 심각한 초상권 문제로까지 이어질 수 있는 큰 문제입니다.

 

이를 해결하기 위해서는 결국 저 useOnlyOneFileName 필드를 전역 변수로 사용하지 않아야 했습니다.

 

방법을 찾아보니 총 3가지 정도가 있었습니다.

1. synchronized

 - getPresignedUrl() 메서드를 synchronized를 사용해 다수의 스레드의 접근을 막아서, useOnlyOneFileName 값을 변경하지 못하게 막는 것입니다. 이렇게 진행하면 다른 스레드들이 getPresignedUrl()에 접근하려고 할때, 이미 다른 스레드가 진행중이라면 대기를 하게 되고, 이는 getPresignedUrl() 이후에 호출되는 findByName() 메서드에도 접근을 못한다는 의미이며, 즉 동시에 값이 바뀌는 문제가 해결됩니다.

 하지만 이 방법은 문제가 있는데, 방금 전 설명에서 말했지만, '대기'입니다. 메서드 동시 접근이 안된다는 것은, 순차 접근만 가능하다는 것이고, 이는 성능 저하에 영향을 미칠 수 있습니다.

ex. 사용자 10000명이 동시에 이미지 등록을 한다고 해봅시다. 서버의 성능이 매우 좋다고 해도 100명까지는 빠르게 된다 하더라도 9999번째, 10000번째 사용자는 높은 확률로 꽤 오랜 시간을 기다려야만 getPresignedUrl()에 접근할 수 있을 것입니다.

제가 이미지 업로드에 aws s3와 presigned url을 사용하기로 한 이유가 server에서 처리량을 줄임으로써 성능 향상에 목적이 있었는데, synchronized를 이용해 성능 저하가 발생한다면 의미가 없다고 판단하여서 이 방법은 고려하지 않았습니다.

 

2. ThreadLocal

 - ThreadLocal은 전역 변수로 사용하더라도 스레드 별로 각각 내부에 독립된 저장소를 부여하여 동시성 문제를 해결할 수 있습니다. 즉, 지하철이나 놀이공원에 가면 있는 본인만의 물품 저장소라고 보면 됩니다.

이를 사용하면 혁신적인데, 왜냐하면 현재 내가 채택한 번거롭게 매개변수로 값을 넘기는 방식을 사용하지 않고, 기존 코드에서 전역변수를 ThreadLocal로 만들어주기만 하면 저절로 동시성문제가 해결되기 때문입니다.

하지만 ThreadLocal을 사용할 때는 꼭 유의해야 할 주의사항이 있습니다.

바로 사용하고 나서 .remove() 를 통해 본인의 물품 보관소에 저장된 내용을 지워줘야 한다는 것입니다.

스레드를 생성하는 것은 비용이 비싸서, 스레드풀은 남는 스레드가 있으면 새로 요청이 들어왔을 때, 새로운 스레드를 생성하는 것이 아니라 기존에 존재하는 스레드를 재사용하는데, 문제는 여기서 발생합니다. remove를 해주지 않으면 스레드에 값이 그대로 남아있게 되고, 새로운 요청이 들어왔을 때, 기존 값을 가지고 있는 스레드가 할당되게 됩니다.

 

실생활 물품보관소를 예시로 보면, 물품 보관소가 가득 찼습니다. 그런데 사용자 A가 이용을 마쳤는데, 보관했던 내용물을 빼지 않았습니다. 이때 사용자 B가 자리가 생긴 보관소를 이용하려고 보니, 물건이 이미 들어가 있는 것입니다 ! 즉, 사용자 B는 예상하지 않았던 결과를 얻게 되었죠.!

 

보통의 동시성 문제를 해결하는 데 있어서 ThreadLocal은 매우 좋지만 현재 프로젝트에서 presigned url로 이미지 업로드를 진행하는 과정에서는 적절하지 못했습니다.

 

ThreadLocal을 사용해서 진행했을 때, getPresignedUrl() 호출 시, set() 메서드를 통해 ThreadLocal 내부에 값이 잘 들어가는 것을 확인했지만, 이후 프로필 등록 api를 호출할 때는 url값이 null로 나오게 되었습니다. 

 

 그 이유는 현재 s3 저장소 url을 가져올 @PostMapping("/presigned") 엔드포인트와 가져온 url로 프로필 생성과 동시에 이미지 경로 url을 저장할 @PostMapping("/profile") 엔드포인트가 다르기 때문입니다. 즉, 서로 다른 두 개의 엔드포인트가 연속적으로 호출이 되어야 하는데, HTTP는 stateless하다는 특징이 있고, 그 말은 즉, 엔드포인트를 각각 호출할 때마다 새로운 스레드로 할당받아 호출을 한다는 것입니다. 만약 세션을 사용했다면 setAttribute(" profileImageUrl"), getAttribute(" profileImageUrl") 이런 식으로 url을 다른 엔드포인트로 가져올 수 있겠습니다만, 현재 프로젝트는 JWT를 이용한 인증/인가 처리를 진행하고 있습니다. 물론 claim에 값을 저장하고 꺼내오는 식으로 할 수 있겠지만, JWT의 장점인 stateless가 깨지고, jwt는 클라이언트측에 저장되므로 url같은 민감한 정보가 저장되어서는 안된다고 판단하였습니다. 

 

3. 매개 변수 사용 - 채택

 - 기존 findByName() 으로 저장될 url 경로를 얻는 것 대신, 미리 클라이언트에서 얻은 전체 경로를 매개변수로 넘겨서 프로필에 저장될 url 경로를 얻는 것입니다.

public String getSavedUrl(String profileImageUrl) {
    if (profileImageUrl == null) {
        return null;
    }
    int queryIndex = profileImageUrl.indexOf('?');
    return (queryIndex == -1) ? profileImageUrl : profileImageUrl.substring(0, queryIndex);
}

이런 식으로 parameter가 시작되는 ?를 기준으로 프로필에 저장될 이미지 url을 얻어올 수 있었고, 이후 프로필 등록 메서드에서 전달해줌으로써 프로필 이미지 경로 필드에 저장되는 url도 안전하게 얻어올 수 있었습니다 !

 

최종적으로 매개변수로 정보를 넘기는 방식을 이용하여 동시성 문제도 해결되며, stateless 원칙도 지킬 수 있었습니다.

 

 

이미지 캐싱 & 리사이징

 

프로필 조회 시 필요한 이미지 크기는 고정되어 있습니다. 하지만 원본 크기의 이미지를 그대로 불러온다면 메모리 낭비가 있고, 서비스가 커질수록 이러한 비용은 크게 다가올 것입니다. 또한 이미지 조회 시 매번 s3에 접근해야 한다면

1. s3 요청, 전송 비용 증가

2. 지구 반대편의 사람들은 속도가 매우 오래걸릴 수 있음

이러한 문제가 있을 수 있습니다. 

 해결을 위해 AWS CloudFront와 AWS Lambda@Edge를 도입하기로 하였습니다.

 CDN 서비스인 CloudFront를 이용하여 이미지를 매번 s3에 요청하고 받아오는 것이 아닌, 캐싱 기능을 활용하여 이미지를 받아오는 시간을 대폭 줄일 수 있었고,

 Lambda@Edge를 사용하여 첫 요청 시, origin s3에서 응답 받을 시, 리사이징을 진행 후 cloudFront에 저장해 놓음으로 써, 매번 리사이징을 하는 것이 아닌, 첫 요청 시에만 진행 후 두 번째 요청 이후부터는 캐싱된 리사이징 이미지를 조회할 수 있게 되었습니다.

 

속도 실험
Cloud Front 적용 후 첫 요청 시 :

두 번째 요청 :

CloudFront에서 캐싱된 이미지가 변경되지 않았다는 메시지 304 코드를 반환해준다.

 

======================================================

AWS Lambda@Edge를 이용한 이미지 리사이징 후

 

용량 개선 :

 

캐싱,리사이징 적용 방법은 https://velog.io/@su-mmer/CloudFront%EC%99%80-LambdaEdge%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95   를 참고했습니다.