@Component
public class S3FileLoader {
@Autowired
private S3Client s3Client;
@Autowired
private S3Properties s3Properties;
public boolean isImageExistsInS3(String imageUrl) {
String imageKey = imageUrl.split(s3Properties.getBaseUrl())[1];
try {
s3Client.getObject(GetObjectRequest.builder()
.bucket(s3Properties.getBucket())
.key(imageKey)
.build());
return true;
} catch (NoSuchKeyException e) {
return false;
}
}
}
@Test
void 이미지_파일을_업로드하면_db와_storage에_저장된다() {
// given
MultipartFile imageFile = createMockFile("image.jpg", "image/jpeg");
// when
String imageUrl = fileService.uploadImage(imageFile, Purpose.PROFILE);
// then
assertThat(s3FileLoader.existsImageInS3(imageUrl)).isTrue();
}
GreenWinit 프로젝트 중 S3 파일 업로더를 구현하고 테스트에 실패하는 문제가 발생했다.
org.opentest4j.AssertionFailedError:
Expecting value to be true but was false
필요:true
실제 :false
예상한 결과와 달랐다. 문제를 해결하려고 이리저리 해결 방법을 찾다가 결국 찾아낸 문제의 원인은 바로 imageKey 추출에 실패한 것이다.
imageUrl.split(s3Properties.getBaseUrl())[1]
이 결과는 /images/...
이렇게 file key가 생성된다.
내가 예상하는 이미지 키는 images/...
로 시작할테니 계속 테스트에 실패했던 것이었다.
imageUrl.split(s3Properties.getBaseUrl() + 1)[1]
로 수정하면 해결된다..
그럼 이 간단한 문제를 찾기까지 어떤 삽질을 했을까?
또 삽질을 하며 local stack의 자세한 동작 원리를 이해하게 되었다.
나는 코드에 문제가 없다고 착각하고 localstack 설정에서 문제가 있다고 생각했다. 왜냐하면 업로드에 실패하면 예외가 발생하도록 처리했는데, 예외가 발생하지 않았으므로 업로드에는 성공했다는 것을 알 수 있다.
그래서 디버깅 포인트를 설정하고 디버그 모드로 실행해서 localstack container에 접근해보기로 했다.
컨테이너를 확인한 결과 내부가 4566 port로 열려있다.
@TestConfiguration
public class S3TestContainerConfig {
@Bean(initMethod = "start", destroyMethod = "stop")
public LocalStackContainer localStackContainer() {
DockerImageName dockerImageName = DockerImageName.parse("localstack/localstack:3.0");
LocalStackContainer localStackContainer = new LocalStackContainer(dockerImageName);
localStackContainer.withServices(LocalStackContainer.Service.S3);
return localStackContainer;
}
@Bean
public S3Client testS3Client(LocalStackContainer localStackContainer) {
S3Client s3Client = S3Client.builder()
.endpointOverride(localStackContainer.getEndpointOverride(LocalStackContainer.Service.S3))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")
))
.region(Region.of(localStackContainer.getRegion()))
.build();
s3Client.createBucket(b -> b.bucket("test-bucket"));
return s3Client;
}
}
내가 설정한 코드를 보면 분명 EndPoint를 S3로 Override 했는데 4566 port로 띄워진 것이다. 나는 여기가 문제점이라고 확신했다. 왜냐?
public static enum Service implements EnabledService {
API_GATEWAY("apigateway", 4567),
EC2("ec2", 4597),
KINESIS("kinesis", 4568),
DYNAMODB("dynamodb", 4569),
DYNAMODB_STREAMS("dynamodbstreams", 4570),
S3("s3", 4572),
...
final String localStackName;
final int port;
}
localstackContainer의 구현체이다. 구현체만 보면 S3 Enum Value가 4572 port를 사용하므로 나는 localhost:4572/... 로 컨테이너가 생성 될 것이라고 생각했다. 하지만, 내 도커 컨테이너에서는 4566으로 시작된 것이다. 왜 그런지 알아보자.
/** @deprecated */
@Deprecated
public int getPort() {
return this.port;
}
Service enum class를 보면 실제로 getPort는 중단되었다. 그럼 4572 포트를 반환하지 않는다는 것을 알 수 있다.
public URI getEndpointOverride(EnabledService service) {
try {
String address = this.getHost();
String ipAddress = InetAddress.getByName(address).getHostAddress();
return new URI("http://" + ipAddress + ":" + this.getMappedPort(this.getServicePort(service)));
} catch (URISyntaxException | UnknownHostException e) {
throw new IllegalStateException("Cannot obtain endpoint URL", e);
}
}
config에서 설정했던 해당 메서드를 살펴보면 getServicePort로 포트에 매핑하고 있다.
private int getServicePort(EnabledService service) {
return this.legacyMode ? service.getPort() : 4566;
}
이 코드에서 완전히 문제를 알 수 있었다. legacyMode라면 Service enum class의 port를 반환하고 아니라면 4566을 반환한다.
public LocalStackContainer(DockerImageName dockerImageName) {
this(dockerImageName, shouldRunInLegacyMode(dockerImageName.getVersionPart()));
}
/** @deprecated */
@Deprecated
public LocalStackContainer(DockerImageName dockerImageName, boolean useLegacyMode) {
super(dockerImageName);
this.services = new ArrayList();
dockerImageName.assertCompatibleWith(new DockerImageName[]{DEFAULT_IMAGE_NAME, LOCALSTACK_PRO_IMAGE_NAME});
this.legacyMode = useLegacyMode;
String version = dockerImageName.getVersionPart();
this.servicesEnvVarRequired = isServicesEnvVarRequired(version);
this.isVersion2 = isVersion2(version);
this.withFileSystemBind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), "/var/run/docker.sock");
this.waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1));
this.withCreateContainerCmdModifier((cmd) -> cmd.withEntrypoint(new String[]{"sh", "-c", "while [ ! -f /testcontainers_start.sh ]; do sleep 0.1; done; /testcontainers_start.sh"}));
}
legacy mode인지는 생성자 시점에 판단을 하는 것을 알 수 있다.
먼저, docker 이미지명에서 version part를 추출한다. 이것부터 알아보고 shouldRunInLegacyMode
메서드를 한 번 파헤쳐보자.
if (remoteName.contains("@sha256:")) {
this.repository = remoteName.split("@sha256:")[0];
this.versioning = new Versioning.Sha256Versioning(remoteName.split("@sha256:")[1]);
} else if (remoteName.contains(":")) {
this.repository = remoteName.split(":")[0];
this.versioning = new Versioning.TagVersioning(remoteName.split(":")[1]);
} else {
this.repository = remoteName;
this.versioning = Versioning.ANY;
}
생성자 시점인데 해시명, 버저닝 네임, 일반 네임에 따라 구분 한 것을 알 수 있다.
만약 localstack:latest
라면 버전은 latest, 네이밍에 따라 버전을 추출하는 것을 알 수 있다. 다음으로 shouldRunInLegacyMode
메서드를 보자.
static boolean shouldRunInLegacyMode(String version) {
if (!version.equals("latest") && !version.startsWith("latest-") && !version.endsWith("-latest")) {
ComparableVersion comparableVersion = new ComparableVersion(version);
if (comparableVersion.isSemanticVersion()) {
boolean versionRequiresLegacyMode = comparableVersion.isLessThan("0.11");
return versionRequiresLegacyMode;
} else {
log.warn("Version {} is not a semantic version, LocalStack will run in legacy mode.", version);
log.warn("Consider using \"LocalStackContainer(DockerImageName dockerImageName, boolean legacyMode)\" constructor if you want to disable legacy mode.");
return true;
}
} else {
return false;
}
}
version이 0.11 아래거나 시멘틱 버전(x.x.x)이 아닌 경우 레거시 모드라고 하는 것이다! 친절하게 시멘틱 버전이 아닌 경우에는 로그도 던져준다.
정리하면, 레거시 모드가 아니면 4566 포트로 통합하는 것이었다.
app:
storage:
bucket: test-bucket
base-url: http://localhost:4572
하지만 나는 test용으로 endpoint를 4572 포트로 설정했고 여기서 문제가 발생한 것인줄 알았다. 설정한 s3Client는 4566 포트로 파일을 업로드 했지만, 4572로 파일을 조회하고 있다고 생각했기 때문이다.
하지만, 이것도 사실 관계 없었다. base-url은 cdn을 적용하기 위해 파일 키를 기반으로 imageUrl을 반환하는 용도이기 때문이다. 결국 s3Client에서 파일 키로 업로드하고 조회하기 때문에 전혀 연관이 없었던 것이었다.
그럼 무엇이 문제일까...
생각하기에 어쨌든 localStack에 mockFile을 업로드하니까 파일이 저장되어있을 것이라고 생각했다!
localstack container에서 파일을 찾아보기로 했다. 여러 폴더를 뒤적이며 s3 디렉토리를 찾았다!
그런데 예상과 달리 업로드에 성공했다고 하지만, test-bucket에는 mockFile이 업로드 되지 않았다. 이 때부터 당황하기 시작하면서 무엇이 문제인지 도저히 감이 떠오르지 않았다. 스스로 문제를 해결할 수 없는 부분으로 판단되어 localstack 관련해서 문서를 찾아봤다.
https://docs.localstack.cloud/user-guide/state-management/persistence/
localstack 지속성과 관련된 문서에서 말하길 pro 버전이 아니면 실제 파일을 저장할 수 없다고 한다. 어, 그럼 업로드는 어떻게 성공한 것이지?
라는 생각이 들었다..
이 고민 포인트를 해결해 줄 stack over flow 포스트를 찾았다. url로 접근하면 파일을 볼 수 있다는 것이다!
오.. 정말 디버깅 된 url 주소 대신 바인딩된 56942 port로 들어가서 확인하니 파일을 볼 수 있었다. 왜 응답하는 url과 실제 파일 url이 다를까?
이는 내 코드가 의도한대로 잘 작성한 것을 확인할 수 있다.
실제로 s3에서는 bucket name을 sub domain으로 사용하기 때문에, 실제 s3에 업로드가 된다면 bucket은 숨겨질 것이다. 그 다음으로 실제 localstack의 경로는 localhost:56972이다. 하지만, 우리가 baseUrl을 cdn 주소로 사용하듯 baseUrl로 잘 덮어진 것을 볼 수 있다.
하지만, 궁극적인 궁금점은 해결되지 않았다.
그래서 test-bucket 폴더 안에 실제로 파일은 없는걸까? 그렇다.
앞서 언급한 localstack은 기본적으로 persist를 지원하지 않기 때문이다. 그럼, 어떻게 우리가 url로 접근할 수 있을까?
관련 이슈: https://github.com/localstack/localstack/issues/682 에서 memory 사용량이 증가된다는 이슈가 있고, 같은 경험을 했다는 답변도 있다.
여기서 우리가 유추 할 수 있는 포인트는 localstack이 메모리 기반으로 파일을 저장
하고 있을 것이다. 그리고 http로 접근할 경우 localstack 에서 memory에서 파일 정보를 직접 반환하는 것이다.
그럼 이슈에서 언급한 상황을 실제로 한 번 테스트 해보자.
100MB 랜덤 파일을 만들어서 s3로 업로드를 해봤더니 결과적으로 Memory가 5MB 증가했다. 100MB가 증가해야 되는 것이 아닌가?
당황스럽다.
find /opt/code/localstack -name "*s3*" -type f 2>/dev/null
명령으로 localstack을 구현하고 있는 코드가 python임을 알 수 있다. 그럼, python GC나 폴링에 의해 메모리가 효율적으로 관리되고 있을 것으로 예상된다.
root@e5d4c705454a:~# ps -eo pid,comm,rss,vsz | grep python
19 python 406128 1846396
python이 차지하는 Resident Set Size가 400MB Virtual Memory Size가 1.8GB인 것을 확인할 수 있다.
그럼, 예상한 것을 증명하기 위해 s3에 랜덤 파일을 업로드하고 python memory를 한번 더 확인해보자.
root@e5d4c705454a:~# ps -eo pid,comm,rss,vsz | grep python
19 python 406128 1846396
root@e5d4c705454a:~# dd if=/dev/urandom of=last_test.bin bs=1M count=100
100+0 records in
100+0 records out
104857600 bytes (105 MB, 100 MiB) copied, 0.163779 s, 640 MB/s
root@e5d4c705454a:~# aws --endpoint-url=http://localhost:4566 s3 cp last_test.bin s3://test-bucket/
upload: ./last_test.bin to s3://test-bucket/last_test.bin
root@e5d4c705454a:~# ps -eo pid,comm,rss,vsz | grep python
19 python 407024 1920188
가상 메모리부터 실제 메모리 사용량이 모두 증가한 것을 확인할 수 있다. 실제 파일 크기인 100MB 만큼 증가한 것은 아니지만, 내부적으로 파일 압축, 메타데이터 관리 매커니즘 등이 설계되어 있다고 가정하면 충분히 발생할 수 있는 오차이다.
LocalStack은 메모리에 파일을 업로드하고 http 통신으로 파일에 접근할 수 있다는 것이 증명이 되었다.
본 포스팅에서는 추론을 통해 결과를 구했으니 굳이 container 내부 구현체까지 뜯어 볼 필요는 없을 것 같다고 판단했다.
root@e5d4c705454a:~#
aws --endpoint-url=http://localhost:4566 s3 ls s3://test-bucket/ --recursive
2025-05-31 15:55:43 104857600 big-file.bin
2025-05-31 15:17:37 4 images/profile/62/20250601/7f3ada90_1748704657284.jpg
2025-05-31 16:19:43 104857600 last_test.bin
2025-05-31 15:57:37 104857600 random-file.bin
localsack conatiner 내부에서 aws cli 명령을 통해 지금까지 업로드 한 모든 파일이 업로드 된 것도 확인 할 수 있다!
왜 4566 포트를 사용하는 것일까?
지금까지 내용을 통해, LocalStack Container에서 내부적으로 AWS CLI를 지원하는 구현체가 있는 것을 알 수 있다. 레거시에서는 각 포트 별로 AWS Service를 가상화해 둔 서비스를 지원했던 것이다.
하지만 Legacy 지원이 중단되면서 하나의 포트에서 내부적으로 AWS CLI로 처리하는 방식으로 수정한 것이다.
그럼 파일은 왜 저장되지 않는 것일까?
실제 S3Client를 사용한다고 했을 때, bucket이 없으면 aws s3 sdk 에서 예외가 발생한다. 로컬스택에서 S3Client과 유사한 환경을 위해 실제 bucket을 생성하는 것까지는 동일하게 처리한다. 그리고 파일은 메모리에 저장하면서 처리 속도를 높이는 방식을 채택한 것으로 보인다.
최종적인 Localstack의 동작 원리에 대한 아키텍처이다.
결과적으로 문제는 fileKey 추출을 하는 테스트 유틸리티의 문제였다. 단 1글자의 코드 문제로 여기까지 오게되었다 ...
String imageKey = imageUrl.split(s3Properties.getBaseUrl())[1];
->
String imageKey = imageUrl.split(s3Properties.getBaseUrl() + "/")[1];
덕분에 localStack의 동작 원리와 localStack sdk의 깊은 내용까지 이해하게 되어서 너무 행복하다. 특히 이번 포스트는 공식 문서, 검색을 통해 알아봤지만 아무도 다루지 않은 내용으로 보인다. claude 말로는 크롤링 결과 localstack 관련 이만큼 상세히 다룬 내용은 없었다고 한다. 🫢
localstack이 인기가 없는 것인지.. 하여튼 오늘도 한 기술에 대해 세부적으로 이해 할 수 있는 좋은 시간이었다.