이미지 업로드 파이프라인 개선 : 속도 65% 단축, 서버 직접 처리 제거 본문

이미지 업로드 파이프라인 개선 : 속도 65% 단축, 서버 직접 처리 제거

JinHwan Kim 2024. 2. 23. 04:27

배경

사용자로부터 이미지를 수신, 이를 3D 변환 애플리케이션에서 읽을 수 있도록 이미지를 전달하는 API 서비스를 운영하고 있다.
처음 이 서비스는 특정 고객사 내부망에 납품된 형태로 시작했다. 

폐쇄된 환경이다 보니 디스크 저장 밖에 선택지가 없었고, 외부 접근을 걱정할 일도 없었으니 별도 보안도 신경쓰지 않았던 것 같다.
우리 팀의 목표는 이 서비스를 살려 SaaS로 전환, 클라우드에 배포하고 외부 사용자가 접근할 수 있도록 하는 것이었다.

 

파일 저장소가 S3로 바뀌었고, 보안을 신경써야 했다.

사용자의 사용 패턴을 명확하게 알고 있던 기존보다, 더 다양한 사용 케이스와 이에 따른 사용성을 고민해야 했다. 

여러 개선 포인트가 있었고, 나는 그 중에서도 이미지 업로드 / 관리 서비스를 메인으로 담당했다.

이 글에선 그 개선 과정과 결과를 소개하려고 한다.

 

이슈 

세 가지 문제가 동시에 보였다.

1. 이미지 업로드 속도

- 파일이 클라이언트 → 서버 → S3로 두 번 이동한다.

- 부하 테스트 시, 1MB 파일에 100명이 동시 요청하면 단순히 파일을 수신하는 것만으로도 응답 평균 200ms가 걸렸다.

- S3 업로드 로직이 아니라 파일을 받는 행위 자체가 병목이었다. 파일이 커질수록 당연히 더 느려진다.

 

2. 리사이징 처리 비효율

- 서버가 파일을 받은 뒤 직접 리사이징 / 프레임 캡처를 수행하고 있었다.

- 이를 위해 서버가 파일을 임시 기록해야 했고, 작업이 끝날 때까지 스레드 점유가 필요했다.


3. 정적 파일 보안

- 내부망에서는 신경 쓸 일이 없었는데, 이제는 이미지 URL을 아는 사람이면 누구든 접근할 수 있는 상태가 됐다.

- CDN URL의 리소스 키를 brute force로 찔러보면 타인의 사진에 접근 가능한 구조였고, 이는 큰 보안 문제였다.

세 문제 모두 결국 "서버가 파일을 직접 다루고 있어서" 생긴 것이다. 

클라우드 (AWS)로 전환하면서 이를 대신할 수 있는 더 적합한 방법은 없을까를 공부하기 시작했다.

 

개선 1 : 이미지 업로드 시, 서버를 경유하지 않도록 한다.

클라이언트로부터 서버가 이미지 파일을 직접 받는 구조를 제거했다.

단순히 서버가 이미지를 받는 시간 뿐 아니라, 이미지가 서로 전송, 서버가 스토리지(S3)로 다시 전송하는 시간을 제거한다.

네트워크 홉이 한 차례 줄기에 사용자 처리 시간이 빨라지고, 서버는 불필요한 스레드 점유를 피할 수 있게 된다.

 

S3의 Presigned URL을 쓰면 된다.

서버가 "이 경로에, 이 시간 안에 올려도 된다"는 허가증만 발급하고, 실제 업로드는 클라이언트가 S3에 직접 한다.

서버는 그 경로를 DB에 저장하여 어떤 사용자가 어떤 리소스를 곧 점유하는지를 확인하면 그 뿐이다. 

1. FE -> BE, 파일 사이즈와 함께 업로드 요청, 업로드 가능 여부 확인
2. BE -> FE, Presigned url 반환
3. FE -> S3, 파일 업로드
4. FE -> BE, 파일 업로드 처리 완료 알림, 스토리지 사용량 업데이트

 

사용자의 이미지 경로는 언제 DB에 저장하면 좋을까? 

Presigned url을 발급할 때 저장한다면, 클라이언트가 업로드에 실패했을 때 존재하지 않는 이미지 정보가 DB에 남아 있는 꼴이된다. 

반대로 업로드 후 클라이언트가 그 결과를 서버에 전달하는 꼴이라면, 서버가 그 처리를 실패했을 때, 스토리지에 더미 파일이 남게 된다.

 

나는 S3의 lifecycle rule 기능을 사용했다.

Presigned url로 업로드할 때는 파일의 tag로 Pending을 표시하고, 

업로드가 마친 후엔 클라이언트가 서버에 그 정보를 알려주면, 서버는 DB에 해당 경로를 기록, Tag를 Commited로 변경한다.

S3의 Lifecycle rule으로 업로드된지 1일 이상 지났으면서 Tag가 여전히 Pending인 파일을 자동 삭제 처리 규칙을 지정할 수 있다.

 

개선 결과 측정

상위 80%의 사진 파일 크기를 기준으로 성능 향상을 확인한다. 

Mysql 8.0부터 도입된 순위/등급 함수 'ntile'를 사용하면 쉽게 N%에 해당하는 데이터를 추출할 수 있다. 

아래는 저장된 사진 메타 데이터에서, size를 기준으로 10등급으로 나누고 그 중 9등급의 첫번째 로우를 읽는 쿼리이다.

select * from \
 (select size, ntile(10) over(order by size) as grade from file_resource where ..) \
t where grade = 9 LIMIT 1;

 

결과는 8.22MB 였고, 결국 사용자는 보통 8MB 이하의 이미지를 업로드한다는 것을 확인할 수 있었다.

이와 유사한 7.97MB의 사진 파일을 기준으로 업로드 테스트를 진행했다.

먼저 기준선을 잡기 위해 S3 업로드만 10회 반복 측정했다.

BE에서 S3로의 단순 업로드이고, 대략 900ms ~ 1초가 걸리는 것을 확인할 수 있다.

 


처리 전, 클라이언트에서 BE로 파일 업로드 후, 다시 S3 업로드까지의 응답 시간은 대략 3.5초가 걸렸다. 

처리 후, 클라이언트가 BE로 Presigned url을 반환받는데 응답 시간은 200ms,

S3 업로드를 마치고 BE에 결과를 알리는 Commit 요청의 응답 시간은 95ms 였다.
결과적으로 처리 전에는 3.5sec가 걸렸던 8mb 파일 업로드는, 서버 경유 제거 이후 1.2sec로, 약 65% 단축된 것을 확인할 수 있었다.

 

개선 2 : 이미지 리사이징 처리 주체를 분리한다.

기존에는 서버가 파일을 받으면 곧바로 리사이징해서 디스크에 저장했다.

별도 인프라 없이 서버 한 대에서 다 처리하는 게 가장 단순한 구조였다.

 

이미지 리사이징이나 동영상 프레임 캡처는 CPU를 쓰는 작업이고, 그 동안 스레드가 점유된다. 

사용자가 몰리는 상황을 고려한 SaaS 전환 이후에는 이 구조를 유지할 수 없었다.

디스크 같은 물리 공간 사용을 최소화하고, 동시 처리에 취약한 구조를 제거해야 했다.


S3에 파일이 올라오면 이벤트가 발생하고, 이를 처리할 수 있는 람다를 정의할 수 있다.

파일 업로드 이벤트를 트리거로, 리사이징을 처리하고 그 결과를 다시 S3에 저장하는 람다를 개발했다.

기존 API 서버는 이 과정에 전혀 관여하지 않는다.

 

서버가 파일 처리를 위해 CPU와 디스크를 사용하는 일이 사라졌다.

파일 업로드 요청이 많이 올라와도 서버 리소스에 영향이 없다는 안정성과 관리 용이성을 얻었다.

 

이미지 처리 중 요청은?

사용자가 업로드 직후의 요청은 리사이징 처리 중으로, 아직 파일이 없는 상태이다.
단순하게 처리했다.

리사이징 파일이 아직 없으면 이미지는 원본을, 동영상은 임시 아이콘을 보여준다.

리사이징이 끝나면 서버에 콜백이 와서 DB에 리사이징된 파일 경로를 기록하고, 이후부터는 이를 사용한다.

천 개 넘는 파일로 테스트를 진행했고, 실제로 사용자가 불편을 느낄 정도의 지연은 아니었다. 

업로드하고 다른 화면으로 넘어갔다 돌아오면 이미 준비돼 있는 수준이었다.

모든 시점에 리사이징된 파일이 준비돼 있어야 한다고 접근했으면 캐시 워밍이나 동기 대기 같은 복잡한 구조가 필요했을 것이다.

"잠깐 없어도 사용자는 모른다"는 사실을 인정하니까 구조가 단순해졌다.
기술적 완벽함보다 사용자가 실제로 뭘 느끼는지를 기준으로 판단하는 게 더 좋은 설계일 수 있다는 걸 배웠던 경험이었다.

 

개선 3 : 접근할 수 있는 사람을 제한하기

내부망에서는 이미지 URL에 접근 제어가 필요 없었다.

그런데 SaaS가 되면서 CDN(CloudFront)으로 이미지를 배포하기 시작했고, 이 URL은 아무나 접근 가능한 상태였다.

리소스 키 패턴을 추측하면 다른 사용자의 사진을 볼 수 있다.
이를 해결하기 위해 Cloudfront의 Signed url을 검토했다.

 

Signed url 을 사용하면 자원에 접근할 수 있는 권한을 확인할 수 있다. 

CDN 에 Public key 를 등록해 두고, 서버에서 내려주는 사용자 이미지 경로를 Private 키로 암호화해 두는 방식이다.

클라이언트에서 암호화된 Url 으로 자원을 요청했을 때 CloudFront 는 Public 키로 요청 URL이 올바르게 인증된 자원인지 확인한다.

URL 암호화에 권한이 포함된 자원 정보, IP 범위, 권한 유효 시작 시간, 권한 유효 종료 시간를 지정하여 접근 사용자를 제한할 수 있다.

 

URL을 알아도 IP가 다르면 접근 불가, 시간이 지나도 접근 불가. 

서버는 이미지 바이트를 건드릴 일 없이 서명된 CDN URL만 반환하면 된다.


자원 캐싱 문제

Signed URL을 적용하니까 새로운 문제가 생겼다.

매 요청마다 서명이 달라지니 URL이 계속 바뀐다.

브라우저 입장에서는 매번 다른 URL이라 Content-Cache가 동작하지 않는다.

CDN을 쓰는데 캐시가 안 먹히면 의미가 빠진다.

서버에서 {클라이언트 IP : 자원 경로} 를 키로 서명된 URL을 캐싱하는 것으로 해결했다. 

같은 IP에서 같은 이미지를 다시 요청하면 이미 만들어둔 URL을 돌려준다. 

URL이 고정되니 브라우저 캐시가 동작한다. 

만료 시간이 지나면 캐시를 비우고 새로 서명한다.
아래는 암호화된 CDN URL 캐시 사용으로 브라우저 Content cache가 적용된 사진 로딩의 모습이다.

 

 

정리

문제 : 이미지 저장 경로를 디스크에서 클라우드로 전환하면서, 서버가 파일을 직접 다루며 있었던 비효율 개선

이슈 : 

 1. 이미지 업로드 속도 : 파일이 클라이언트 → 서버 → S3로 두 번 이동, 이를 위한 업로드 시간 발생.
 2. 리사이징 처리 비효율 : 서버가 파일을 직접 받은 뒤 리사이징 수행, 이를 위한 임시 디스크 처리와 스레드 점유 발생.
 3. 정적 파일 보안 : CDN URL의 리소스 키를 brute force로 찔러보면 타인의 사진에 접근 가능한 구조였고, 보안 문제.

처리 : 

  - Presigned URL 사용, 클라이언트가 직접 S3에 업로드 하는 꼴로 변경

  - S3 Lifecycle rule 사용, 업로드 성공했지만 서버에 기록되지 않은 더미 파일 관리 정책 

  - 섬네일 / 리사이징 처리 람다 개발. 서버가 직접 개입 제거하여 임시 디스크 사용 제거

  - CloudFront Signed URL 사용. IP, 만료 시간 제한 서명 URL 반환

  - 서버에서 Signed URL 캐싱으로, 매번 변경되어 브라우저 컨텐츠 캐싱이 안되는 문제 해결

결과 :

  - 크기 80%를 기준으로, 업로드 시간은 3.58초에서 1.2초로, 65% 단축.

  - 서버는 파일 바이트를 한 번도 만지지 않는 구조로 변경

  - 이미지 업로드가 동시 요청에 디스크, 스레드 점유를 신경쓰지 않아도 되는 구조로 전환 성공

 

 

Comments