
이전 포스팅
온프레미스 서버 부하 한계, AWS Presigned URL로 트래픽 95% 절감한 이야기
https://velog.io/@snghyun331/aws-presigned-url
이전 포스팅에서는 AWS Presigned URL로 업로드 트래픽을 95% 절감하고, 검사 녹화 영상과 마스킹한 신분증 이미지의 저장 방식을 온프레미스에서 S3 직접 저장으로 전환한 내용을 공유했다.
이제 남은 문제는 "이 데이터를 어떻게, 얼마나 오래 보관할 것인가"였다.
파기 정책이 없다면 검사가 종료된 영상과 신분증 파일은 S3에 계속 쌓이게 된다. 저장 용량이 늘어날수록 비용도 함께 증가하고, 개인정보를 불필요하게 오래 보관하는 위험도 커진다.
이번 글에서는 S3 저장 구조를 설계하는 과정에서 AWS의 스토리지 과금 정책과 라이프사이클 정책을 함께 검토하고, 이를 바탕으로 스토리지 클래스(Storage Class)와 데이터 파기 전략이 결정된 배경을 소개하고자 한다.
앞서 언급했듯 S3에 저장되는 데이터는 신분 확인용으로 마스킹된 신분증 이미지와 응시 중 화면 녹화 영상이며, 둘 다 부정행위 여부를 확인하기 위한 목적으로만 사용된다.
이러한 데이터는 다음 두 가지 특성을 지닌다.
이 두 특성을 종합해서, 1~2주보다 좀 더 넉넉히 보관 기간을 30일로 정했다.
그럼, 30일 동안 이 데이터를 어떤 스토리지 클래스에 저장하는 것이 가장 적절할까?
S3는 데이터의 접근 빈도와 보관 목적에 따라 여러 스토리지 클래스를 제공한다.
(AWS SAA를 공부하면서 정리해둔 표를 가져와봤다)
| Storage Class | 접근 빈도 | 즉시 조회 | 고가용성 |
|---|---|---|---|
| Standard | 자주 접근 | 가능 | 높음 (다중 AZ) |
| Standard-IA | 가끔 접근 | 가능 | 높음 (다중 AZ) |
| One Zone-IA | 가끔 접근 | 가능 | 낮음 (단일 AZ) |
| Glacier Instant Retrieval | 거의 접근 X | 가능 | 높음 (다중 AZ) |
| Glacier Flexible Retrieval | 거의 접근 X | X (최대 1시간 소요) | 높음 (다중 AZ) |
| Glacier Deep Archive | 거의 접근 X | X (최대 12시간 소요) | 높음 (다중 AZ) |
검사 녹화 영상과 신분증 이미지는 부정행위 확인을 위해 보관되는 만큼, 필요할 때 즉시 조회할 수 있어야 한다. 따라서 복구에 시간이 걸리는 Glacier 계열은 검토 대상에서 제외했다.
또한 검사 진행 중 오류가 발생하면 즉각 대응해야 하는 서비스 특성상 고가용성이 보장되어야 했기에, 단일 AZ에 저장하는 One Zone-IA도 후보에서 제외했다.
S3 Standard vs Standard-IA
| S3 Standard | S3 Standard-IA | |
|---|---|---|
| 저장 비용 | GB당 약 $0.023 | GB당 약 $0.0125 |
| 조회 비용 | 없음 | GB당 약 $0.01 |
| 적합한 데이터 | 자주 조회되는 데이터 | 거의 조회되지 않는 데이터 |
| 최소 과금 객체 크기 | 제한 없음 | 128KB |
| 최소 보관 기간 | 없음 | 30일(30일 전에 삭제해도 30일치 저장 비용을 모두 청구한다) |
저장 비용만 보면 Standard-IA가 유리해 보인다. 하지만 검사 데이터를 실제로 어떻게 쓰는지 따져 보면, Standard가 더 적합해보였다.
① 자주 조회하는 데이터에는 IA가 안 맞는다. Standard-IA는 저장비가 싼 대신, 데이터를 꺼내 볼 때마다 별도 요금이 붙는다. 즉 "거의 안 꺼내 보는" 데이터에 어울리는 Class 이다. 그런데 검사 데이터는 초반 1~2주에 조회가 몰린다.
실제로 1GB를 30일간 보관하는 경우,
결국 데이터를 두 번만 조회해도 IA가 더 비싸져, 검사 데이터처럼 자주 조회하는 경우엔 IA의 저장비 할인이 의미가 없어지는 셈이다.
② 신분증 같은 작은 파일에는 IA가 불리하다. IA는 128KB보다 작은 파일도 무조건 128KB로 쳐서 요금을 매긴다. 마스킹된 신분증 이미지는 이보다 작은 경우가 많은데, 그러면 실제 용량보다 부풀려 청구된다. 응시자 수만큼 신분증이 쌓이므로 손해가 누적될 수 밖에 없다.
③ 30일만 보관할 데이터는 IA로 옮길 수가 없다. IA는 최소 30일은 보관한다는 전제로 요금이 매겨지고, 라이프사이클 규칙으로 데이터를 IA로 옮기려면 그 전에 Standard에 30일을 먼저 둬야 한다. 그런데 검사 데이터는 30일이 되면 바로 삭제되기 때문에 IA로 전환하는 의미가 없다.
➡️ 정리하면, IA의 할인 효과는 "오래 두면서 거의 조회하지 않는" 데이터에서만 유효하다. 검사 데이터는 이와 반대로 "짧게 두면서 초반에 자주 조회하는" 특징이기 때문에, S3 Standard에 저장하고 30일이 지나면 자동 삭제하는 방향으로 결정했다.
"30일 후 자동 삭제"를 적용한다고 하면, AWS를 다뤄본 사람이라면 가장 먼저 S3 수명 주기 규칙(Lifecycle)을 떠올릴 것이다.
Lifecycle은 코드 작성 없이 특정 객체를 업로드한 시점으로부터 N일 후 자동 삭제를 할 수 있고, 삭제 이벤트(s3:LifecycleExpiration:Delete)로 로그도 남길 수 있다.
하지만 우리의 파기 기준은 객체가 업로드된 날짜가 아닌, 객체 키에 박힌 검사일이었다.
{고객사명1}/20260401/...
{고객사명2}/20260401/...
예를 들어 위 객체는 2026년 4월 1일에 진행된 검사 데이터이고 실제 파기 여부도 이 검사일을 기준으로 판단해야 한다. 하지만 Lifecycle은 객체 키에 포함된 날짜를 읽거나 해석할 수 없기 때문에 “검사일 기준 30일 후 삭제”와 같은 조건을 표현할 수 없다.
또한 고객사별 삭제 건수와 용량을 집계하고 이를 로그로 남기는 기능도 필요했다. 이러한 로직은 단순히 “N일 후 삭제”를 선언하는 Lifecycle만으로는 구현하기 어렵다.
| 구분 | 삭제 기준 | 파기 방식 |
|---|---|---|
테스트 데이터 (TEST_{고객사명}/) | 접두어(Prefix) + 업로드 후 경과일 | Lifecycle |
운영 데이터 ({고객사명}/) | 키에 박힌 검사일 + 고객사별 집계·로그 | Lambda |
테스트 데이터는 일시적으로 생성되는 리소스이므로 “TEST_로 시작하는 객체를 N일 후 삭제” 정도면 충분했기 때문에 Lifecycle을 적용해도 문제가 없다.
반면 운영 데이터는 객체 키에서 검사일을 추출해 파기 여부를 판단해야 했고, 고객사별 삭제 건수 및 용량 집계와 같은 추가 작업도 필요했다. 따라서 Lambda가 객체 키를 직접 파싱하고 삭제 여부를 결정하는 방식으로 구현했다.
➡️ 따라서 단순 만료 정책만 필요한 테스트 데이터는 Lifecycle로 처리하고, 비즈니스 로직이 필요한 운영 데이터는 Lambda로 처리하는 구조를 선택했다.
S3의 ListObjectsV2와 DeleteObjects API는 한 번에 최대 1000개까지만 처리할 수 있다.
따라서 객체 수가 1000개를 넘는 경우에는 ContinuationToken을 사용해 여러 페이지로 나누어 조회하고 삭제하는 페이징 처리가 필요하다.
let continuationToken = null;
do {
// S3는 한 번에 최대 1000개까지만 반환한다.
// 다음 페이지가 존재하면 ContinuationToken을 이용해 이어서 조회한다.
const response = await s3.send(
new ListObjectsV2Command({
Bucket: bucketName,
Prefix: prefix,
MaxKeys: 1000,
ContinuationToken: continuationToken,
})
);
// 객체 키({사명}/20260401/...)에서 검사일을 추출한 뒤,
// 삭제 기준일보다 오래된 데이터만 삭제 대상으로 선정한다.
const objectsToDelete = response.Contents.filter((obj) => {
const datePart = obj.Key.split('/')[1];
return datePart < cutoffDate;
});
// 삭제 대상 객체들을 일괄 삭제한다
await s3.send(
new DeleteObjectsCommand({
Bucket: bucketName,
Delete: {
Objects: objectsToDelete.map((obj) => ({ Key: obj.Key })),
},
})
);
// 다음 페이지가 있다면 토큰이 반환된다.
// 토큰이 없을 때까지 반복하면서 모든 객체를 순회한다.
continuationToken = response.NextContinuationToken;
} while (continuationToken);
대량의 객체를 처리하다 보면 S3와 오랜 시간 통신하게 된다. Lambda가 S3에 보내는 요청은 내부적으로 HTTPS 연결(소켓)을 통해 처리되는데, 페이징이 길어질수록 동일한 연결을 오랫동안 사용하게 된다.
이 과정에서 연결이 일정 시간 동안 유휴(idle) 상태로 판단되면 소켓이 종료될 수 있고, 아래와 같이 socket hang up 오류가 발생할 수 있다.
Your socket connection to the server was not read from or
written to within the timeout period. (socket hang up)
객체 수가 몇백 개 수준일 때는 페이징이 금방 끝나기 때문에 이런 문제가 잘 드러나지 않는다. 하지만 수만 건 이상의 객체를 처리하는 작업에서는 충분히 발생할 수 있는 이슈다.
따라서 대량의 S3 객체를 다루는 경우에는 S3 클라이언트의 네트워크 설정도 함께 고려해서 요청 타임아웃을 늘리고 재시도(Retry) & Keep-Alive 설정을 추가하는 것이 좋다
const s3 = new S3Client({
region: 'ap-northeast-2',
maxAttempts: 5, // 일시적인 네트워크 오류 발생 시 자동 재시도
requestHandler: new NodeHttpHandler({
requestTimeout: 120000, // 요청 타임아웃 120초
httpsAgent: new Agent({
keepAlive: true, // 기존 연결 재사용
maxSockets: 100, // 최대 동시 연결 수
}),
}),
});
한 가지 더 고려해야 할 점: Lambda 함수 자체의 실행 시간
실제 테스트 결과 약 6~7만 건의 객체를 삭제하는 데 3분 정도가 소요된다. 따라서 Lambda의 기본 타임아웃(3초)으로는 작업을 정상적으로 완료할 수 없다.
우리는 Lambda 콘솔에서 함수 타임아웃을 15분으로 늘려서 작업이 중간에 종료되지 않도록 충분한 실행 시간을 확보했다.
처음에는 여러 고객사의 Prefix를 동시에 처리하면 더 빠를 것이라고 생각했다. 모든 Prefix를 Promise.all로 한꺼번에 처리했었다.
하지만 실제로는 동시성이 높다고 해서 항상 성능이 좋아지는 것은 아니었다.
객체 수가 많은 대형 Prefix가 S3 연결을 과도하게 사용하면서 다른 작업들이 사용할 소켓이 부족해지는 이슈가 있었다.
소켓은 maxSockets: 100으로 한정돼 있는데, 대형 prefix가 페이징하며 연결을 잔뜩 점유하면 나머지 prefix는 소켓을 얻지 못한 채 대기하다가 결국 2번과 같은 socket hang up 으로 연결이 끊겼다.
그래서 모든 prefix를 동시에 처리하는 대신, 2개씩 묶어 실행하도록 변경했다.
const CONCURRENCY = 2;
for (let i = 0; i < prefixes.length; i += CONCURRENCY) {
const batch = prefixes.slice(i, i + CONCURRENCY);
await Promise.all(batch.map((prefix) => processPrefix(prefix)));
}
개인적으로 AWS를 실무에서 사용하고 있거나 앞으로 사용할 예정이라면 AWS SAA 공부를 한 번쯤 해보는 것을 추천한다.
특히 데이터의 보관 기간과 접근 패턴에 따라 적절한 스토리지 클래스를 선택하고, 비용과 운영 정책까지 함께 고려하는 과정은 AWS SAA에서 자주 다루는 내용이기도 하다. 공부할 때는 단순히 시험을 위한 지식처럼 보였지만, 실제 서비스에서는 이러한 판단들이 비용과 운영 효율에 직접적인 영향을 준다는 점이 인상적이었다.
이번 작업에서도 S3에 데이터를 저장하기 전에 보관 기간과 접근 패턴을 먼저 분석하고 그에 맞는 저장 전략을 설계할 수 있었다. 만약 AWS의 스토리지 클래스나 과금 정책에 대한 이해가 없었다면 단순히 데이터를 저장하는 것에만 집중했을지도 모른다.
물론 자격증 취득 자체도 의미가 있지만 그보다 AWS 서비스들이 어떤 문제를 해결하기 위해 만들어졌고 어떤 상황에서 선택해야 하는지 이해하는 데 더 큰 가치가 있다고 생각한다.
이번 글을 정리하면서도 다시 한번 느낀 것은, 서비스를 설계할 때 단순히 기능이 동작하는 것에만 집중해서는 안 된다는 점이다. 기능 구현은 물론 중요하지만 비용 구조와 운영 관점까지 함께 고려해야 더 지속 가능하고 효율적인 서비스를 만들 수 있다.
앞으로도 이러한 관점을 잊지 않고 서비스를 바라보려 한다.