티스토리 뷰
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
다시 본론으로 들어가서
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에 저장된 모습
'💻 개발 > 프레임워크' 카테고리의 다른 글
- Total
- Today
- Yesterday