서론
최근에 사이드 프로젝트에서 S3 버킷에 파일을 업로드해야 하는 일이 생겼고, 자연스럽게 통합 테스트를 작성해야 할 상황이 됐다. 하지만 실제 AWS S3 환경에서 테스트를 작성하는 데는 몇 가지 현실적인 문제들이 예상됐다.
1. 비용 문제
S3는 사용량 기반으로 요금이 부과되기 때문에, 테스트가 자주 실행되는 환경에서는 비용이 계속 쌓일 가능성이 있다. 특히, 개발하면서 테스트를 반복적으로 실행하다 보면 생각 이상으로 비용이 발생할 수밖에 없다. 현재 사이드프로젝트의 테스트코드 실행 주기가 pre-commit에만 달려있어도, 하루에 십 수번은 넘게 실행되고 있다.
2. 보안 문제
테스트 환경에서 IAM의 Access Key와 Secret Key를 사용하는 건 보안상 굉장히 위험할 수 있다. 키가 노출되면 프로젝트뿐만 아니라 AWS 계정 전체에도 영향을 줄 수 있다.
3. 정합성 문제
프로덕션과 테스트 환경이 같은 S3 버킷을 공유한다면, 테스트 중 파일 업로드나 삭제가 프로덕션 데이터에 영향을 미쳐 정합성을 깨트릴 가능성이 있다. 이건 최악의 상황을 초래할 수도 있다.
그래서 이러한 문제를 해결하기 위해 LocalStack을 사용했다. LocalStack은 로컬 환경에서 AWS 서비스를 에뮬레이션할 수 있는 도구로, S3뿐만 아니라 DynamoDB, Lambda 같은 다양한 AWS 리소스를 로컬에서 테스트할 수 있게 해준다. LocalStack을 활용해서 S3 업로드 기능에 대한 통합 테스트를 작성했는데, 공식 문서가 너무 잘 되어있어 쉽게 적용해볼 수 있었다.
Localstack 실행
docker-compose를 통해 테스트 실행 전 활성화를 시켜놓았다. 테스트 시작 구문에서 LocalStack 컨테이너를 세팅해보았으나 한 번에 5초 이상 걸렸다. 초기화 시 매번 세팅해주기에는 테스트 파일이 많아질수록 실행 속도가 느려질 것 같다.
#!/bin/sh
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=000000000000
export AWS_SECRET_ACCESS_KEY=000000000000
awslocal s3 mb s3://my-bucket
version: '3.9'
services:
localstack:
image: localstack/localstack
ports:
- '4566:4566'
environment:
- SERVICES=s3
container_name: localstack
volumes:
- './localstack/init.sh:/etc/localstack/init/ready.d/init-aws.sh'
초기화 쉘스크립트를 작성해주고 compose에서 쉘 스크립트를 참조할 수 있게 마운트를 해주면 끝이다.
테스트 작성
LocalStack을 세팅하고 나니 자연스레 구현해 둔 S3Service에 대한 통합 테스트를 작성할 수 있었다.
S3Service는 모듈 셋업 과정에서 S3Config에서 환경변수를 받아 S3Client를 생성하여 S3Service에 주입하는 구조로 되어있어, 테스트용 환경변수만 세팅해주면 실제 비즈니스 코드의 통합 테스트가 가능했다.
import { S3Module } from '../../src/infra/s3/s3.module';
import { S3Service } from '../../src/infra/s3/s3.service';
import { setupModule } from '../util/setup';
describe('[Integration] EquipmentService', () => {
let s3Service: S3Service;
beforeAll(async () => {
// 테스트를 위한 기본적인 module setup (ConfigModule)
const module = await setupModule([S3Module]);
s3Service = module.get<S3Service>(S3Service);
});
it('파일 업로드에 성공하고 path를 반환한다', async () => {
// given
const file = {
originalname: 'test.jpg',
buffer: Buffer.from('test'),
} as Express.Multer.File;
const directory = 'test-dir/';
// when
const result = await s3Service.uploadFile(file.originalname, file.buffer, directory);
// then
return expect(result).toBe(
`https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${directory + file.originalname}`,
);
});
it('파일 삭제에 성공한다', async () => {
// given
const key = 'test.jpg';
// when then
await expect(s3Service.deleteFile(key)).resolves.not.toThrow();
});
it('파일 다운로드에 성공한다', async () => {
// given
const file = {
originalname: 'test.jpg',
buffer: Buffer.from('test'),
} as Express.Multer.File;
const directory = 'test-dir/';
await s3Service.uploadFile(file.originalname, file.buffer, directory);
// when
const result = await s3Service.downloadFile(file.originalname, directory);
// then
expect(result.mimeType).toBe('application/octet-stream');
expect(result.data).toBeInstanceOf(Uint8Array);
});
});
실제 구현
업로드는 테스트를 위해 실제 경로를 반환했고 삭제는 응답 코드가 204이다.
실제 다운로드를 사용하고자하는 구간이 클라이언트에서 이미지를 미리보기하는 상황이 아니라 파일을 무조건적으로 다운로드 해야하는 상황이다보니 다운로드 시 ContentType을 명시하지 않았고, 디폴트인 octet-stream으로 받아지게 된다.
async uploadFile(key: string, body: Buffer, dir: string = 'equipments-export/'): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: dir + key,
Body: body,
});
await this.s3Client.send(command);
const region = await this.s3Client.config.region();
return `https://${this.bucketName}.s3.${region}.amazonaws.com/${dir + key}`;
}
async downloadFile(
key: string,
dir: string = 'equipments-export/',
): Promise<{ data: Uint8Array; mimeType: string }> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: dir + key,
});
const response = await this.s3Client.send(command);
if (!response.Body) {
throw new Error('File not found');
}
const mimeType = response.ContentType || 'application/octet-stream';
const data = await response.Body.transformToByteArray();
return { data, mimeType };
}
async deleteFile(key: string, dir: string = 'equipments-export/'): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: dir + key,
});
await this.s3Client.send(command);
}
정리
LocalStack 덕분에 실제 AWS를 사용하지 않고도 비슷한 인프라로 S3 버킷의 테스트를 작성할 수 있었다. 무엇보다도 테스트를 반복 실행하더라도 비용이 발생하지 않는다는게 현재 사이드프로젝트에서는 큰 장점인 것 같다.
사실 오늘 처음 LocalStack을 알았는데, 바로 적용이 가능한 데에는 공식문서가 잘 되어있다는 점이 가장 컸다. 다른 AWS의 리소스들도 무료로 지원해주는 게 생각보다 많기 때문에 AWS를 사용할 때 테스트코드 작성에 대한 부담을 느껴 모킹해버리는 경우가 많이 줄어들 것 같다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!