티스토리 뷰

728x90

JPA + S3 조합으로 S3에 파일 올리는 것은 예제가 많이 있는데  

JPA + S3 + DB에 저장한 값 불러와서 실제로 사용할 수 있는 예제는 많이 없었다

그래서 글을 작성하게 되었다.

 

 

나의 경우는 팀 프로필 변경 건에 대한 API를 만들어보았다.

1. 코드 예제에서 import는 생략하니 실제로 사용하려면 import선언정돈 직접 하자

2. AMI랑 S3이용해서 만드는건 구글 검색하면 엄청 나오니까 이건 다른 포스팅 참고

 

 

Application.yml의 기본설정 

  # ------- 파일 업로드 시작 ----------
spring:  
  servlet:
    multipart:
      enabled: true
      #      임시 저장경로 - 파일 확장자 체크
      max-request-size: 100MB
      max-file-size: 100MB

# aws 파일 업로드 설정 및 오류 제거
cloud:
  aws:
    s3:
      bucket: hellomyteam123123
    credentials:
      access-key: 
      secret-key: 
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false

# ec2 환경이 아닌 local 환경에서 발생하는 aws 오류 제거
logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

중요한 값들은 환경변수를 통해 주입한다.

 

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

선택 유문데 이거 안넣으면 보기 싫은 에러 남 

 

 

S3 Config

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

application.yml에서 선언한 값들을 주입받음 

S3에 관련된 클라이언트 설정

여기까진 어떤 예제든 다 똑같다.

 

Controller

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/team")
public class TeamController {

@PostMapping(value = "/logo", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
    public CommonResponse<?> logoUpdate(@RequestPart TeamIdParam teamIdParam, @RequestPart MultipartFile imgFile) throws IOException {
        Image savedImage = teamService.saveLogo(imgFile, teamIdParam);
        return CommonResponse.createSuccess(savedImage, "팀 로고 등록 success:List, fail: null");
    }
}

Controller에서 헤맸던 것 중 하나가 

@RequestBody  /  @RequestParam  /  @ModelAttribute  가 아닌

@RequestPart라는 것을 사용해야 한다는 것이다. 

 

@RequestPart로 MultipartFile 레퍼런스 타입인 이미지를 받아야 하고, 

전달받은 팀 id 값을 통해 팀을 조회하기 위해 DTO도  @RequestPart로 선언해야 한다.

 

* API 명세 시 엔티티를 노출하면 외부 변경이 일어날 수 있으로 DTO를 사용한다. 

 

또한 MediaType을 명시해줘야 하는데 

consumes는 프론트로부터 전달받은 타입

produces는 프론트로 전달할 타입이라고 한다.

 

 

TeamIdParam

@Getter
public class TeamIdParam {
    private Long teamId;
}

DTO이다.

팀 id를 전달받아 수정할 팀을 찾는다.

김영한 님이 DTO 네이밍에 관련해서 말씀하신 적이 있는데

 

파라미터의 경우 ~Param,

검색조건일 경우 ~Cond라고 명시해서 사용하신다길래

이번 프로젝트에서는 Param이라고 사용한다.

 

 

TeamService

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TeamService {

    private final TeamRepository teamRepository;
    private final TeamCustomImpl teamCustomImpl;
    private final TeamMemberInfoRepository teamMemberInfoRepository;
    private final FileUploadRepository fileUploadRepository;
    private final S3Uploader s3Uploader;
    
    
public Image saveLogo(MultipartFile multipartFile, TeamIdParam teamIdParam) throws IOException {
    Team team = teamRepository.findById(teamIdParam.getTeamId())
            .orElseThrow(() -> new IllegalArgumentException("teamId가 누락되었습니다."));
    if (!multipartFile.isEmpty()) {

        Map<String, String> storedFileURL = s3Uploader.upload(multipartFile, "teamLogo");
        String fileName = storedFileURL.get("fileName");
        String uploadImageUrl = storedFileURL.get("uploadImageUrl");

        Image image = Image.builder()
                .team(team)
                .imageUrl(uploadImageUrl)
                .storeFilename(fileName)
                .build();
        Image savedImage = fileUploadRepository.save(image);
        return savedImage;
    }
    return null;
   }
}

생성자 주입

private final OOOOOOO ooooo; 

@RequiredArgsConstructor 어노테이션으로 생성자 선언하는 것을 생략할 수 있다.

이거 은근히 모르는 사람 많더라...

 

그리고 생성자 주입 말고 Filed 주입 Setter 주입 등 사용하는 사람들도 있다면 이번 포스팅을 보고

생성자 주입으로 바꾸자

 

아래는 생성자 주입을 사용했을 때의 이점에 대한 글이다.

https://study-easy-coding.tistory.com/88

 

스프링 DI 사용 시 생성자 주입을 사용하자!

👉🏻 Spring에서는 DI기능을 제공합니다. 이는 Applicaiton 시작 시 IOC에 Bean으로 설정된 Object를 관리하고, Singleton 형태로 '@Autowired' 되어있는 객체에 주입이 됩니다. 주입 방식에는 기본적으로 필드

study-easy-coding.tistory.com

 

 

 

다시 본론으로 들어가서

saveLogo 메서드에서 다음과 같이 로직을 처리한다.

 

1.  이미지 파일과 teamParam을 전달받는다. 

2. teamIdParam에서 teamId를 꺼내와서 연관관계를 맺을 Team 객체를 찾는다.

- findById의 리턴값은 Optional 타입이므로 예외처리를 해주었다.

3. 전달받은 이미지파일(multipartFile)이 null 혹은 빈 값이 아니라면 S3 Uploader를 통해 S3에 저장된다.

4. S3 Uploader에서 과정을 통해(아래 참고) 나온 값들을 Map 객체에서 값을 꺼낸다.

5. Image 엔티티에 @Builder를 사용해서 값을 저장한다. 

- @Setter는 지양하고 @Builder를 사용하는 습관을 가지자.

- imageUrl은 이미지 고유 url.  /. sotreFilename은 파일이름

6. fileUploadRepository에 save메서드를 사용하여 image 엔티티를 저장한다.

6. 저장된 image 리턴

 

 

Image 엔티티

@Entity(name = "Image")
@Getter
@NoArgsConstructor
public class Image extends BaseTimeEntity {
    @Id
    @GeneratedValue
    @Column(name = "image_id")
    private Long id;

    private String imageUrl;

    private String storeFilename;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    @Builder
    public Image(String imageUrl, String storeFilename, Team team) {
        this.imageUrl = imageUrl;
        this.storeFilename = storeFilename;
        this.team = team;
    }
}

Team엔티티와 연관관계 매핑을 해두었다.

 

Team 엔티티

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Team extends BaseTimeEntity {
    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String teamName;

    private String oneIntro;

    private String detailIntro;

    @Enumerated(EnumType.STRING)
    private TacticalStyleStatus tacticalStyleStatus;

    @ColumnDefault("0")
    private Integer memberCount;                               //팀원 수

    private int mercenaryCount;                            //용병 수

    private Integer teamSerialNo;                          //팀 고유번호

    @JsonIgnore
    @OneToMany(mappedBy = "team")
    private List<TeamMemberInfo> teamMemberInfos = new ArrayList<>();

    @JsonIgnore
    @OneToMany(mappedBy = "team")
    private List<Image> teamLogo = new ArrayList<>();

}

@JsonIgnore를 해주지 않으면 Team과 Image가 순환참조를 하여 에러가 발생한다. 

@JsonIgnore를 달아주자.

 

엔티티 부분은 엔티티 연관관계, @JsonIgnore 말고 딱히 언급할 게 없다. 

본인 프로젝트에 맞게 만들자.

 

S3 Uploader

@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class S3Uploader {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드
    public Map<String, String> upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
        return upload(uploadFile, dirName);
    }

    private Map<String, String> upload(File uploadFile, String dirName) {
        String fileName = dirName + "/" + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)

        Map<String, String> param = new HashMap<>();
        String originFileName = uploadFile.getName().substring(uploadFile.getName().indexOf("_") +1);
        param.put("fileName", originFileName);
        param.put("uploadImageUrl", uploadImageUrl);
        return param;      // 업로드된 파일의 S3 URL 주소 반환
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile)
                        .withCannedAcl(CannedAccessControlList.PublicRead) // PublicRead 권한으로 업로드 됨
        );
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if(targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        }else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws  IOException {
        UUID uuid = UUID.randomUUID();
        File convertFile = new File(uuid.toString()+"_"+file.getOriginalFilename());
        if(convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
}

여기서 주의 깊게 알아야 할 점! 

 

removeNewFile

Postman, swagger를 통해 파일을 등록하게 된다면 나의 로컬에 이미지가 저장된다.

(깃 commit 할 때 잘 보면 찾을 수 있을 것이다.)

 

그렇기 때문에 removeNewFile 메서드가 존재한다. 

S3에 값이 저장되고 나서 removeNewFile메서드가 나의 로컬에 있는 이미지를 삭제한다.

 

 

convert

나의 경우는 UUID를 통해 이미지가 S3에 저장될 때 중복되는 것을 막았다.

convert 하는 곳에 UUID를 이용해서 파일명을 조작해야 한다.

그렇지 않다면 로컬에 있는 나의 파일과, S3에 올릴 파일 이름이 일치하지 않는다는 

오류가 발생한다. 

나도 구글링 하면서 가져온 로직에서는 UUID가 없었는데 이 부분에 추가했다.

 

 

upload

private Map<String, String> upload(File uploadFile, String dirName) {
    String fileName = dirName + "/" + uploadFile.getName();
    String uploadImageUrl = putS3(uploadFile, fileName);

    removeNewFile(uploadFile);  // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)

    Map<String, String> param = new HashMap<>();
    String originFileName = uploadFile.getName().substring(uploadFile.getName().indexOf("_") +1);
    param.put("fileName", originFileName);
    param.put("uploadImageUrl", uploadImageUrl);
    return param;      // 업로드된 파일의 S3 URL 주소 반환
}

여기서 나는 Map을 이용해서 특정 이름과 url 경로를 담아 주었다. 

그리고 위에서 말한 Service단에서 꺼내어 Image엔티티에 저장했다.

 

 

FileUploadRepository

public interface FileUploadRepository extends JpaRepository<Image, Long> {
}

 

결과

postman으로 찔러준다. 

KEY가 Controller에서 받는 파라미터와 일치해야 한다.

 

DB에 저장된 모습

 

 

 

 

 

728x90
댓글
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday