본문 바로가기

CodeLap 프로젝트

CodeLap - S3로 파일 업로드(자바, 스프링부트)

유저, 스터디, 스터디 공지 등

 

이미지가 필요한 도메인은 다양하다.

 

오늘은 이미지 파일을 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로 변환해줘야한다.

 

책임이 다른 메서드들의 로직들은 추상화 한 후, 테스트가 정상적으로 작동하는지 확인할 수 있다.