이전에 올린 포스팅에서 처럼 이번 팀 프로젝트 때 S3를 이용해 이미지 업로드 기능을 구현하였습니다.
배포 서버에 S3 서버를 더해 이미지 업로드를 하는 것은 비교적 쉬웠으나, 로컬 환경에서 이미지 업로드를 테스트 하기 위해 S3 mock을 이용하는 것은 참조 자료가 test 코드에만 사용하거나, 로컬 환경에서만 s3 mock을 사용하는 경우만 있어서 조금 어려웠습니다..
그래서 저는 배포 서버에서는 S3을 이용하고, 로컬환경에서는 임베디드 Mock S3을 이용한 것을 설명하려고 합니다.
도움이 되었던 참조 자료 : https://willseungh0.tistory.com/139
로컬에서 임베디드 S3 사용하기
파일 업로드와 S3 파일 업로드 기능을 구현할 때, 주로 확장성이 좋은 AWS에서 제공하는 S3 서비스를 이용해서 많이 구현할 것입니다. Spring Cloud AWS S3 연동 및 파일 업로드 스프링에서 프로필 사진
willseungh0.tistory.com
구현 전 준비
- 배포서버에서 쓸 profile > application-prod.yml(혹은 applicaion-prod.properties), application-aws.yml
- 로컬환경에서 쓸 profile > application-dev.yml(혹은 application-dev.properties), application-local.yml
이 yml파일 두개 모두 gitignore에 꼭 추가하도록 합니다.
S3을 이용해서 이미지 업로드 하기
AWS S3를 사용하기 위해 의존성 추가
// AWS S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
AwsS3Config
@Slf4j
@Profile("prod")
@Configuration
@RequiredArgsConstructor
public class AwsS3Config {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3 amazonS3Client() {
final BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
-> @Value 어노테이션을 통해 민감 정보는 gitignore에 추가한 application-aws.yml에 명시한 값으로 가져옵니다.
application-aws.yml 예시
cloud:
aws:
credentials:
accessKey: 접근 키
secretKey: 시크릿 키
s3:
bucket: 버킷이름
region:
static: ap-northeast-2
stack:
auto: false
꼭 민감정보가 쓰여있는 yml파일은 commit push 하기전에 꼭 gitignore에 추가하고 노란색(깃헙에 올라가지 않는 표시)를 꼭 확인 해야 합니다!!! 특히 aws 관련은 돈이 타격이 크기 때문에.. 조심하세요!
파일 업로드 관련 인터페이스 생성(로컬 환경에서도 구현하고, 추후 다른 파일 추가도 용이하게 하기 위해)
public interface FileProcessService {
String uploadImage(MultipartFile file, FileFolder fileFolder);
String createFileName(String originalFileName);
String getFileExtension(String fileName);
String getFileName(String url);
}
FileProcessService를 구현한 배포 서버용 이미지 업로드 S3 서버 사용 파일 업로드 서비스
@Profile("prod")
@Service
@RequiredArgsConstructor
public class AmazonS3FileProcessService implements FileProcessService {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
@Override
public String uploadImage(MultipartFile file, FileFolder fileFolder) {
String fileName = getFileFolder(fileFolder) + createFileName(file.getOriginalFilename());
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
try (InputStream inputStream = file.getInputStream()) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata).withCannedAcl(
CannedAccessControlList.PublicReadWrite));
} catch (IOException ioe) {
throw new IllegalArgumentException(String.format("파일 변환 중 에러가 발생했습니다 (%s)", file.getOriginalFilename()));
}
return getFileUrl(fileName);
}
@Override
public String createFileName(String originalFileName) {
return UUID.randomUUID().toString().concat(getFileExtension(originalFileName));
}
@Override
public String getFileExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf("."));
}
@Override
public void deleteImage(String url) {
deleteFile(getFileName(url));
}
@Override
public String getFileName(String url) {
String[] paths = url.split("/");
return paths[paths.length-2] + "/" + paths[paths.length-1];
}
private String getFileFolder(FileFolder fileFolder) {
switch (fileFolder) {
case DOCUMENT:
return "documents/";
case TEAM:
return "teams/";
case PARTICIPANT:
return "participants/";
default:
return "";
}
}
private void deleteFile(String fileName) {
amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
}
public String getFileUrl(String fileName) {
return amazonS3.getUrl(bucket, fileName).toString();
}
}
-> 여기서 @Profile("prod") 어노테이션으로 prod로 구동 시킬때 해당 빈을 활성화 시키는 것으로 명시해둡니다.
팀 참가자 수정 관련 service에 파일 업로드 코드
fileProcessService.uploadImage(
teamParticipantUpdateRequest.getParticipantImg(), FileFolder.PARTICIPANT)
prod로 구동을 시킨 다음 api 테스트
팀참가자 이미지 수정 완료
폴더 별로 저장 가능
팀 참가자 수정시 participants 폴더에 업로드
Mock S3(임베디드 S3)을 이용해서 로컬 환경에서도 이미지 업로드 하기
배포 서버가 아니라 로컬 환경에서 테스트 해야하는 경우가 필요하기 때문에 해당 방법을 사용했으며, 로컬 환경에서 이미지 업로드 외에 테스트 코드로도 가능합니다.
Mock S3과 StringUtils를 사용하기 위해 의존성 추가
// file
implementation 'org.apache.commons:commons-lang3:3.12.0'
// AWS S3 Mock
implementation 'io.findify:s3mock_2.13:0.2.6'
여기서 stringUtils를 추가한 이유는, s3 Mock 서버를 돌릴 때 기본 8001 포트로 임의로 지정했으나 해당 포트가 사용 불가능 할 경우 사용 가능한 포트를 찾기 위한 ProcessUtil을 구현하기 위함입니다. 해당 의존성을 추가하지 않고 구현해도 무방합니다.
ProcessUtil
public final class ProcessUtil {
private ProcessUtil() {
}
private static final String OS = System.getProperty("os.name").toLowerCase();
public static boolean isRunningPort(int port) throws IOException {
return isRunning(executeGrepProcessCommand(port));
}
public static int findAvailableRandomProt() throws IOException {
for (int port = 10000; port <= 65535; port++) {
Process process = executeGrepProcessCommand(port);
if (!isRunning(process)) {
return port;
}
}
throw new IllegalArgumentException("사용 가능한 포트를 찾을 수 없습니다. (10000 ~ 65535)");
}
private static Process executeGrepProcessCommand(int port) throws IOException {
if (isWindows()) {
String command = String.format("netstat -nao | find \"%d\"", port);
String[] shell = {"cmd.exe", "/y", "/c", command};
return Runtime.getRuntime().exec(shell);
}
String command = String.format("netstat -nao | grep LISTEN|grep %d", port);
String[] shell = {"/bin/sh", "-c", command};
return Runtime.getRuntime().exec(shell);
}
private static boolean isWindows() {
return OS.contains("win");
}
private static boolean isRunning(Process process) {
String line;
StringBuilder pidInfo = new StringBuilder();
try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
while((line = input.readLine()) != null) {
pidInfo.append(line);
}
} catch (Exception e) {
throw new IllegalArgumentException("사용 가능한 포트를 찾는 중 에러가 발생했습니다.");
}
return !StringUtils.isEmpty(pidInfo.toString());
}
}
-> 생략 가능한 부분 입니다.
EmbeddedS3Config
@Slf4j
@Profile("dev")
@Configuration
@RequiredArgsConstructor
public class EmbeddedS3Config {
@Value("${embedded.aws.s3.mock.port}")
private int port;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final S3Mock s3Mock;
@PostConstruct
public void startS3Mock() throws IOException {
port = ProcessUtil.isRunningPort(port) ? ProcessUtil.findAvailableRandomProt() : port;
s3Mock.start();
log.info("인메모리 S3 Mock 서버가 시작됩니다. port: {}", port);
}
@PreDestroy
public void destroyS3Mock() {
s3Mock.shutdown();
log.info("인메모리 S3 Mock 서버가 종료됩니다. port: {}", port);
}
@Bean
@Primary
public AmazonS3 amazonS3Client() {
AwsClientBuilder.EndpointConfiguration endpoint = new EndpointConfiguration(getUri(), "ap-northeast-2");
AmazonS3 client = AmazonS3ClientBuilder
.standard()
.withPathStyleAccessEnabled(true)
.withEndpointConfiguration(endpoint)
.withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials()))
.build();
client.createBucket(bucket);
return client;
}
private String getUri() {
return UriComponentsBuilder.newInstance()
.scheme("http")
.host("localhost")
.port(port)
.build()
.toUriString();
}
}
S3MockConfig
@Profile("dev")
@Configuration
public class S3MockConfig {
@Value("${embedded.aws.s3.mock.port}")
private int port;
@Bean
public S3Mock s3Mock() {
return new S3Mock.Builder()
.withPort(port)
.withInMemoryBackend()
.build();
}
}
-> 사실 이 S3Mock Bean 부분도 참조한 블로그 글에서는 EmbeddedS3Config파일에 같이 있었으나, 순환 참조 에러가 나서 따로 bean을 빼서 해결했습니다.
- 해당 순환참조 에러
여기서 localMockS3Config는 EmbeddedS3Config와 동일하고 ImgComponent는 아래에 나오는 LocalFileProcessService부분으로 Amazon을 주입해서 사용하는 부분이었습니다.
내가 혼란스러웠던건 보통 순환 참조가 두가지에서 A에서 B를 참조하고 B에서 A를 참조해서 일어나는데, 나같은 경우에는 하나로만 떠있어서 도대체 뭐가 문제인지 인지하기 어려웠다. 그리고 가장 의심되는 S3Mock 빈을 따로 Config파일로 나눠서 해결했습니다. (이걸로 시간허비를 많이했는데... 내 글을 보고 누군가라도 도움이 되길 바라요..)
application-local.yml 예시
cloud:
aws:
s3:
bucket: local-embedded-bucket
region:
static: ap-northeast-2
stack:
auto: false
embedded:
aws:
s3:
mock:
port: 8001
이건 사실 mock관련 yml파일이라 굳이 gitignore을 하지 않아도 되지만 일관성을 위해 저는 gitignore에 추가했습니다.
그리고 applicaion-local.yml, application-aws.yml에도 stack.auto=false로 둬야 이후 자잘한 warning을 무시할 수 있습니다.
FileProcessService를 구현한 로컬환경에서 이미지 업로드 Mock S3 서버 사용 파일 업로드 서비스
@Profile("dev")
@Service
@RequiredArgsConstructor
public class LocalFileProcessService implements FileProcessService {
@Value("${cloud.aws.s3.bucket}")
private String mockBucket;
private final AmazonS3 amazonS3;
@Override
public String uploadImage(MultipartFile file, FileFolder fileFolder) {
String fileName = getFileFolder(fileFolder) + createFileName(file.getOriginalFilename());
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
try (InputStream inputStream = file.getInputStream()) {
amazonS3.putObject(new PutObjectRequest(mockBucket, fileName, inputStream, objectMetadata).withCannedAcl(
CannedAccessControlList.PublicReadWrite));
} catch (IOException ioe) {
throw new IllegalArgumentException(String.format("파일 변환 중 에러가 발생했습니다 (%s)", file.getOriginalFilename()));
}
return getFileUrl(fileName);
}
@Override
public String createFileName(String originalFileName) {
return "Local-" + UUID.randomUUID().toString().concat(getFileExtension(originalFileName));
}
@Override
public String getFileExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf("."));
}
@Override
public void deleteImage(String url) {
deleteFile(getFileName(url));
}
@Override
public String getFileName(String url) {
String[] paths = url.split("/");
return paths[paths.length-2] + "/" + paths[paths.length-1];
}
private String getFileFolder(FileFolder fileFolder) {
switch (fileFolder) {
case DOCUMENT:
return "documents/";
case TEAM:
return "teams/";
case PARTICIPANT:
return "participants/";
default:
return "";
}
}
private void deleteFile(String fileName) {
amazonS3.deleteObject(new DeleteObjectRequest(mockBucket, fileName));
}
public String getFileUrl(String fileName) {
return amazonS3.getUrl(mockBucket, fileName).toString();
}
}
dev로 구동시킨 다음 api 테스트