(처음 구현해보는 기능이라 시행착오가 좀 있었다.)
NCP 공식문서를 확인해보니 AWS SDK를 사용해서도 가능하다고 하였다.
이전에 프로젝트에서 AWS로 S3에 이미지를 업로드 할 때, io.awspring.cloud
의 스프링과 AWS를 쉽게 사용할 수 있도록 해주는 라이브러리를 사용한 경험이 있었고.
해당 라이브러리를 사용하면 편리하게 구현할 수 있다고 생각했다.
자료를 찾아보니 2년전 자료고 의존성이 다르긴 하지만 가능하다는 걸 확인하였다.
(저 자료에서 주의해야 할게, 오류 수정 부분이다. NCP가 AWS 리전을 매핑해서 처리하는게 아닌가 싶은데, 이유가 명확하지 않다.)
더 자세히 설명하자면 위 자료에서는 org.springframework.cloud
를 사용하는데, 어차피 동일한 코드 기반으로 io.awspring.cloud
가 최신 코드라고 볼 수 있어서, 작업에는 큰 문제가 없을 것 같다고 생각했다.
awspring 공식문서를 보고 커스텀 옵션을 사용하여 정의하였다.
The starter automatically configures and registers a
S3Client
bean in the Spring application context.
그러므로 S3Client
Bean을 재정의하면 NCP에서도 동작하게 할 수 있을 것 같다고 생각함
다음과 같이 코드를 구현하고 있었다.
@Configuration
@RequiredArgsConstructor
public class NcpConfig {
private final NcpProperties properties;
@Bean
public S3Client ncpS3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(this.properties.getAccessKey(), this.properties.getSecretKey());
return S3Client.builder()
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.endpointProvider()
.build();
}
}
그런데 region을 string으로 입력할 수 없었다. Region
정해진 상수만 입력 받을 수 있는 것이였는데,
@SdkPublicApi
@Generated("software.amazon.awssdk:codegen")
public final class Region {
public static final Region AP_SOUTH_2 = Region.of("ap-south-2");
public static final Region AP_SOUTH_1 = Region.of("ap-south-1");
...
}
NCP 공식문서를 보니 AWS Java SDK 1.11.238 버전을 사용하고 있었다.
하지만 내가 현재 사용하는 io.awspring.cloud:spring-cloud-aws-starter-s3
의 버전은 3.1.1
이고 해당 라이브러리는 software.amazon.awssdk
(AWS Java SDK) 2.21.46 버전을 가지고 있었다.
이제 2가지 선택지로 나뉘어졌다.
- 공식문서대로 AWS Java SDK 1.11.238 사용해서 개발하기
- AWS Java SDK 1.xx 대 버전을 지원하는
io.awspring.cloud
를 도입하기 - 2년전 자료처럼 리전을 AWS의
ap-northeast-2
로 설정하기- 왜 되는지 이유가 명확하지 않아서 고려 안함
우선 1번 선택지인 AWS Java SDK 자체를 사용하는 게 더 나을거 같긴 하지만, 코드가 너무 low하고, 더 깔끔한 코드를 작성하고 싶어서 2번을 먼저 시도해보기로 했다.
단, 보안 문제가 발생할 수 있으므로(AWS 관련된 org.springframework.cloud
만 해도 2020년대 코드인데, 취약점이 있다고 stackoverflow에서 봤던 기억이 있다.) 너무 버전이 오래되었으면 사용하지 않기로 하였다.
https://mvnrepository.com/ 에 접속해서 Spring Cloud AWS S3의 각 버전 별로 software.amazon.awssdk
버전을 확인해보았는데, 가장 낮은 버전도 2.xx대로 시작해서, 2번 방식은 어렵게 되었다.
혹시 몰라서 코드를 다시 확인하는 중에, of
메서드를 제공하는 것을 확인하였다.
문자열로 사용할 수 있었다.
혼자 삽질한 거지만, 문자열을 사용하는 것으로 문제를 해결하였다.
아래처럼 구현하였는데, 프로퍼티 값이 불변이 아니라 불만이였다.
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "ncp")
public class NcpProperties {
private String endPoint;
private String region;
private String accessKey;
private String secretKey;
private String bucketName;
}
이전에 불변으로 했던 것 같아 문서를 찾아봄. 벨덩 문서에서 record를 사용해서 가독성 좋게 구현한 걸 보고 적용
@Configuration
@EnableConfigurationProperties({ NcpProperties.class })
@RequiredArgsConstructor
public class NcpConfig {
private final NcpProperties ncpProperties;
@Bean
public S3Client customS3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(this.ncpProperties.accessKey(),
this.ncpProperties.secretKey());
return S3Client.builder()
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.of(this.ncpProperties.region()))
.endpointOverride(URI.create(this.ncpProperties.endPoint()))
.build();
}
}
불변으로 사용하기 위해서 @EnableConfigurationProperties
도 추가, 다른 예시에선 메인 클래스에 입력하는 것 같은데, 나는 해당 패키지의 구성이 모이는 NcpConfig
에 설정하였다.
그리고 프로퍼티 값을 담은 NcpProperties
는 레코드를 사용해서 아래처럼 되었다.
@ConfigurationProperties(prefix = "ncp")
public record NcpProperties(
String endPoint,
String region,
String accessKey,
String secretKey,
String bucketName
) {
}
이미지를 정상적으로 올렸지만, 주소를 접근해보니 AccessDenied가 발생한다.
공식문서와 구글링을 통해 원인을 파악하였다.
ACL 설정을 하지 않았고, 이로 인해 NCP 권한 있는 사용자만 접근 가능했다.
나는 io.awspring.cloud
를 사용하므로 이미 해당 문제와 관련된 옵션이 있을 꺼라 생각해서 S3Template
소스 코드를 보니 ObjectMetadata
로 메타데이터 값을 지정해 줄 수 있다는 것 알았다.
S3Resource s3Resource = this.s3Template.upload(this.ncpProperties.bucketName(), createFileName(fileExtension),
getInputStream(resource), ObjectMetadata.builder().acl(ObjectCannedACL.PUBLIC_READ).build());
파일을 업로드하는 코드에 ObjectMetadata
의 필드 값으로ObjectCannedACL.PUBLIC_READ
을 추가하여 해결하였다.
ImageUploadServiceImpl
이 잘 동작하는지 확인하기 위해 다음과 같은 테스트코드 작성
@Test
void shouldUploadImage() {
// 애플리케이션 시작 시 실행되는 로직
String fileLocation = "내 로컬 아무 이미지 경로"
Resource resource = new FileSystemResource(fileLocation);
String result = this.service.execute(resource);
System.out.println(result);
assertNotNull(result);
String filename = StringUtils.getFilename(result);
// test가 성공했으므로 필요없는 데이터는 삭제
template.deleteObject(ncpProperties.bucketName(), filename);
}
외부 서비스라서 트랜잭션 롤백 같은게 안 되서, 테스트하고 성공하면 삭제하는 로직을 작성하였다.
그런데 실제로 버킷을 확인해보면 이미지가 삭제되지 않고 남아있음
실제 파일 이름은 976a6178-f538-4bce-8856-ba8ef84639a52024-04-07T15:36:41.820229.png
이런 식인데, 내가 작성한 로직에서는 976a6178-f538-4bce-8856-ba8ef84639a52024-04-07T15%3A36%3A41.820229.png
처럼 나와서 이름이 달라 삭제가 되지 않았다.
딱 봐도 URL Encoding 때문이라 StringUtils.uriDecode
를 사용하도록 수정하였다.
String filename = StringUtils.getFilename(StringUtils.uriDecode(result, StandardCharsets.UTF_8));
디버깅 해보니 filename
값이 잘 나오고, 삭제도 잘 되었다.
아래는 디버깅에서 나온 변수 값.
filename = "976a6178-f538-4bce-8856-ba8ef84639a52024-04-07T15:36:41.820229.png"
result = "https://celog-bucket.kr.object.ncloudstorage.com/976a6178-f538-4bce-8856-ba8ef84639a52024-04-07T15%3A36%3A41.820229.png"
나는 스프링 부트 3.1 버전 부터 지원하는 Docker Compose Support 기능을 사용해서 로컬 환경에서 개발하고 있었다.
다만 이 경우 test 환경에서 실행되지 않는 문제가 있었다. 공식문서에선 spring.docker.compose.skip.in-tests
값을 설정해야 한다고 하는데, 그렇게 동작해도 실패한다.
구글링을 해 보니 이 문제를 해결한 블로그 자료를 보았다. Maven이지만 의존성의 범위를 test로 늘린 것인데, 공식문서에는 이러한 설정이 없어서 쉽지 않았다.
또 공식문서의 Maven과 Gradle의 의존성을 가져오는 코드가 달랐다. Gradle은 developmentOnly
로 가져오는데, Maven은 그런거 없이 optional
만 설정하고 있다. 그래서 내가 Maven으로 개발을 했으면 이런 문제는 없었을 것 같다.
그래서 공식 깃허브에 이슈로 제출하려고 했는데, 아쉽게도 3일 전에 누가 미리 제안을 올렸다.
실제로는 테스트를 위한 별도의 환경을 구축하는게 좋겠지만, 비용 문제도 있고, 도커 컴포즈를 사용해 통합 테스트 환경을 편리하게 구축 가능한 상황이였다. 그래서 CI 내에서 도커 컴포즈를 사용해서 환경을 구축하고 통합 테스트를 실행하기로 결정하였다.
실제 서비스라면 CI에서 너무 큰 규모의 서비스가 동작하기 때문에 좋은 방식은 아닌 것 같다.
통합테스트를 구현하기 위헤서 기존 서비스에서 사용중인 환경변수를 실행해주어야 하는데, Gitub Secrets을 사용해서 변수를 지정해 줄 수 있다는 사실을 알았다. 참고 블로그
하지만 이 경우, 매 변수마다 지정을 해 주어야 한다. 지금 서비스만 해도 13개의 환경변수를 다루기 때문에 매 파일의 변수 변경을 신경쓰기 어렵다고 판단했다.
따라서 Gitub Secrets에서 한 파일로 관리할 수 있는 방법을 생각해 본 결과
환경변수를 만드는 스크립트를 작성하고, 해당 스크립트를 Gitub Secrets에서 저장해서 실행하게하면 되지 않을까? 하는 생각을 했다.
구글링을 해보니 이처럼 (댓글 확인하기) 환경변수를 동적으로 다룰 수 있는 방법을 알게 되었다.
GPT를 사용해서 호출 대신 바로 환경변수로 적용하도록 코드를 작성하였다.
그래서 테스트를 위해 Github Actions 코드와 Gitub Secrets인 스크립트를 다음과 같이 작성하였다. (실제로는 20번 넘게 커밋하며 확인했었다.)
name: Test Environment
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "*" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: export variables from secret
run: |
ENV_JSON='${{ secrets.ENV_CONFIG }}'
echo "$ENV_JSON" | jq -r 'to_entries[] | "\(.key)=\(.value)"' >> $GITHUB_ENV
- name: echo env
run: echo $GITHUB_ENV
- name: Set environment variables
shell: bash
run: |
# Read environment variables from $GITHUB_ENV file
set -o allexport
source $GITHUB_ENV
set +o allexport
- name: print env
run: |
# 설정된 환경 변수 확인
echo "Environment variables set:"
env
{
"NCP_BUCKET_NAME": "my-ncp-bucket",
"NCP_ACCESS_KEY": "my-ncp-access-key",
"NCP_SECRET_KEY": "my-ncp-secret-key"
}
아래는 성공한 Actions에 대한 주소이다. 환경변수가 잘 설정된 모습을 볼 수 있다.
그래서 해당 코드를 기반으로 통합테스트 환경변수를 구성하도록 하였다.
==CI에서 통합 테스트가 수행되도록 스크립트를 수정하려고 했지만, 잘 되지 않아 지금은 작업을 중지하고 다른 것에 신경쓰기로 했다.==
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| postService defined in file [/Users/sijunyang/Documents/GitHub/celog/build/classes/java/main/dev/sijunyang/celog/core/domain/post/PostService.class]
↑ ↓
| replyService defined in file [/Users/sijunyang/Documents/GitHub/celog/build/classes/java/main/dev/sijunyang/celog/core/domain/reply/ReplyService.class]
└─────┘
프로그램을 실행하다가 다음과 같은 순환 문제가 발생하였다.
기존에 팀에서 서비스를 개발할때는 한 클래스가 하나의 기능(메서드)을 가지도록 구현해서 이런 문제가 없었는데, 상호 의존으로 인해서 의존성 순환 문제가 발생하였다.
결국 좋지 않은 설계를 가지고 있고, 그걸 개선해야 하는데, 지금 당장 이 서비스를 크게 구조를 변경할 생각은 없기 때문에 @Lazy
를 사용하여 해결하였다.