유저, 스터디, 스터디 공지 등
이미지가 필요한 도메인은 다양하다.
오늘은 이미지 파일을 MultipartFile로 받아와서, S3 서버로 업로드하는 과정을 써보려고 한다.
먼저 이미지 업로드의 전체적인 과정을 설명해보자면
회원의 프로필을 수정할 때 이미지, 유저의 정보를 함께 수정할 수 있다.
1. API 서버 쪽으로 요청이 들어왔을 때, 이미지 정보와 유저 정보를 받아온다.
2. 받아온 정보를 분리(MultiPartFile과 UserDto)한다.
3. MultiPartFile의 이름을 UUID + 확장자명으로 변환 후 서버에 저장한 후, 해당 파일의 이름을 유저 DB에 저장한다.
사실 이게 끝이다. 여기서 몇가지 추가되는 과정은 S3에 파일을 저장할지 서버, 즉 인스턴스에 파일을 저장할지 인데
처음에는 인스턴스에 저장하면 되지않을까? 라고 생각했다.
그러나 곧 생각을 바꿨는데, EC2 인스턴스에 저장하는 것에 비해 S3가 가지는 장점이 우월하다는 것을 알게 되었다.
S3는 확장성과 백업 및 복원이 수월하며 보안적으로 안전한 장소를 제공하고 파일을 업로드함에 있어 편리한 라이브러리를 제공한다.
@Service
@Transactional
@RequiredArgsConstructor
public class DefaultUserAppService implements UserAppService {
private final FileUpload fileUpload;
@Override
public void update(Long userId, String name, UserCareer career, List<UserTechStack> techStacks, MultipartFile image) throws IOException {
User user = userRepository.findById(userId).orElseThrow();
if (!image.isEmpty()) {
UserFile file = (UserFile) fileUpload.upload(image, "user", new UserFile());
user.update(name, career, techStacks, List.of(file));
} else {
user.update(name, career, techStacks);
}
}
컨트롤러를 거친 후 서비스에서 실질적인 비즈니스 로직이 돌아가는데, 먼저 'FileUpload' 라는 의존성을 주입받은 후 작업을 시작한다.
UserFile, StudyFile 등의 파일 객체들은 변수와 메서드가 동일하기 때문에 추상화 후 다형성을 이용해서 로직을 구현했다.
public interface FileUpload {
FileStandard upload(MultipartFile multipartFile, String dirName, FileStandard file) throws IOException;
List<FileStandard> uploads(List<MultipartFile> multipartFile, String dirName, FileStandard file) throws IOException;
}
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class UserFile extends FileStandard {
private String savedName;
private String originalName;
private Long size;
protected static final Long MIN_SIZE = 1L;
@Override
public UserFile create(String savedName, String originalName, Long size) {
require(isNotBlank(savedName));
require(isNotBlank(originalName));
return new UserFile(savedName, originalName, size);
};
}
이런식으로 코드를 구성하게 되면 불필요한 코드가 줄어들고 유지보수성이 높아진다.
마찬가지로 FileUpload 또한 추상화했는데, 왜냐하면 로컬쪽 업로드와 S3 쪽 업로드를 분리시켜야 하기 때문이다. 물론 로컬 업로드는 사용 할 일이 없기때문에 추상화만 시키고 로직은 구체화 하지 않았다.
@Configuration
public class S3Configuration {
@Bean
@ConfigurationProperties("aws")
public S3Properties s3Properties() {
return new S3Properties();
}
@Bean
public AmazonS3Client amazonS3Client(S3Properties s3Properties) {
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(s3Properties.getAccessKey(), s3Properties.getSecretKey());
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(s3Properties.getRegion())
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
}
먼저 S3 쪽으로 연결하기 위한 설정파일을 구성한다. yml 파일에 저장해둔 변수를 가져와서 빈으로 등록해준 뒤,
엑세스키와 시크릿 키를 연결해서 AmazonS3Client와 연결 후 빈으로 등록해준다.
aws:
access-key: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY:1234}
secret-key: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY:1234}
bucket: codelap-image
region: ap-northeast-2
stack-auto: false
yml 파일에서 엑세스키와 시크릿키는 CD 과정에서 환경변수로 삽입 해주는 방식을 이용했기 때문에 코드에서는 드러나지 않는다.
S3 업로드 쪽 레퍼런스는 인터넷에 다양하기 때문에 굳이 정리하지는 않겠다.
해당 로직에 대해선 총 2개의 테스트를 진행했는데,
1. S3 Upload Test
2. Controller Test
S3쪽 Mock 테스트를 지원해주는 의존성을 추가한 뒤 테스트 설정파일 설정 후, S3 업로드 테스트를 진행한다.
@TestConfiguration
public class AwsS3MockConfig {
@Autowired
S3Properties s3Properties;
@Bean
public S3Mock s3Mock() {
return new S3Mock.Builder().withPort(8001).withInMemoryBackend().build();
}
@Bean
public AmazonS3 amazonS3(S3Mock s3Mock){
s3Mock.start();
AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration("http://localhost:8001", s3Properties.getRegion());
AmazonS3 client = AmazonS3ClientBuilder
.standard()
.withPathStyleAccessEnabled(true)
.withEndpointConfiguration(endpoint)
.withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials()))
.build();
client.createBucket(s3Properties.getBucket());
return client;
}
}
@Import(AwsS3MockConfig.class)
@SpringBootTest
@Transactional
public class AwsS3UploadTest {
@Autowired
private S3Mock s3Mock;
@Autowired
private FileUpload FileUpload;
@AfterEach
public void tearDown() {
s3Mock.stop();
}
@Test
void 파일_업로드() throws IOException {
String path = "test.png";
String contentType = "image/png";
String dirName = "test";
MockMultipartFile file = new MockMultipartFile("test", path, contentType, "test".getBytes());
UserFile urlPath = (UserFile) FileUpload.upload(file, dirName, new UserFile());
assertThat(urlPath.getOriginalName()).isNotNull();
assertThat(urlPath.getSavedName()).isNotNull();
assertThat(urlPath.getSize()).isNotNull();
}
}
S3쪽 파일 업로드 정상 테스트 확인.
테스트가 정상적으로 작동하는지 확인할 수 있다.
@Test
@WithUserDetails
void 유저_수정_성공() throws Exception {
User user = prepareLoggedInActiveUser();
UserTechStack techStack = new UserTechStack(Java);
UserUpdateRequestUserCareerDto dto = new UserUpdateRequestUserCareerDto("직무", 10);
MockMultipartFile multipartFile = new MockMultipartFile("multipartFile", "hello.txt", MediaType.TEXT_PLAIN_VALUE, "Hello, World!".getBytes());
String json = objectMapper.writeValueAsString(new UserUpdateRequest("updatedName", dto, List.of(techStack)));
MockMultipartFile req = new MockMultipartFile("req", "req", "application/json", json.getBytes(StandardCharsets.UTF_8));
setMultipartFileMockMvcPerform(POST, getMultipartFiles(multipartFile, req), "/user/update");
assertThat(user.getName()).isEqualTo("updatedName");
assertThat(user.getCareer().getOccupation()).isEqualTo(dto.occupation());
assertThat(user.getCareer().getYear()).isEqualTo(dto.year());
assertThat(user.getTechStacks().stream().map(UserTechStack::getTechStack))
.containsExactly(techStack.getTechStack());
assertThat(user.getFiles().get(0).getOriginalName()).isEqualTo(multipartFile.getOriginalFilename());
assertThat(user.getFiles().get(0).getSavedName()).isNotNull();
assertThat(user.getFiles().get(0).getSize()).isNotNull();
}
protected void setMultipartFileMockMvcPerform(HttpMethod httpMethod, List<MockMultipartFile> multipartFiles, String... urlInfo) throws Exception {
String url = urlInfo[0];
String identifier = urlInfo.length == 1 ? urlInfo[0].substring(1) : urlInfo[1];
switch (httpMethod) {
case POST -> setMultipartFileMockMvcPerform(MockMvcRequestBuilders.multipart(url), multipartFiles, identifier);
default -> throw new IllegalArgumentException("Unsupported HTTP method: " + httpMethod);
}
}
private ResultActions setMultipartFileMockMvcPerform(MockMultipartHttpServletRequestBuilder method, List<MockMultipartFile> multipartFile, String identifier) throws Exception {
for (MockMultipartFile mockMultipartFile : multipartFile) {
method.file(mockMultipartFile);
}
return this.mockMvc.perform(method)
.andExpect(status().isOk())
.andDo(restDocsSet(identifier));
}
마지막으로 컨트롤러 테스트이다. 컨트롤러 쪽에서 RequestPart로 메세지바디에서 MultiPartFile과 해당 유저의 정보를 가져오기 때문에, 둘다 테스트하기 위해서는 Json으로 변환한 dto를 mulitpartFile로 변환해줘야한다.
책임이 다른 메서드들의 로직들은 추상화 한 후, 테스트가 정상적으로 작동하는지 확인할 수 있다.
'CodeLap 프로젝트' 카테고리의 다른 글
[CodeLap] IOExcpetion 언체크 예외로 변환 (리팩토링) (0) | 2023.05.31 |
---|---|
[CodeLap] Github Action, AWS(EC2, S3, RDS, CodeDeploy)를 활용한 자바 + 스프링부트 백앤드 서버 배포 - 2편 (0) | 2023.04.28 |
[CodeLap] DIP와 OCP를 지켜서 코드를 짜게 되면? (0) | 2023.04.27 |
[CodeLap] Github Action, AWS(EC2, S3, RDS, CodeDeploy)를 활용한 자바 + 스프링부트 백앤드 서버 배포 - 1편 (0) | 2023.04.27 |
[CodeLap] 조회 쿼리 성능 최적화 및 DTO 이너클래스 리팩토링 (0) | 2023.04.22 |