0. Intro
이전 게시물에선 직원 프로필과 관련하여 파일이 1개씩 올라가도록 구현했다.
하지만 학원 공지사항 게시물의 경우, 공지사항과 관련된 첨부파일은 여러 개도 올라갈 수 있기 때문에 여러 파일을 업로드 할 수 있도록 구현해보겠다.
다운로드와 삭제의 경우는 이전 게시물과 같기 때문에 본 게시물에선 업로드에 한정해서 작성해보도록 하겠다.
시작해보자.
1. Entity
엔티티 명을 AnnouncementFile
로 정했다.
마찬가지로, S3 버킷에 같은 이름의 파일이 업로드되면 나중에 등록된 파일이 덮어쓰는 문제가 생긴다.
그래서, S3 버킷에 저장되는 파일 이름이 중복되지 않도록 저장된 파일의 URL을 나타내는 필드인 storedFileUrl
를 추가했다.
그리고 업로드한 파일 이름을 나타내는 uploadFileName
필드도 추가했다.
최초 파일 업로드 시간, 마지막 수정 시간, 삭제 시간을 위해 BaseEntity
클래스를 상속했다.
Announcement
테이블과는 1:N 관계로 매핑해주었다.
Announcement.java
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Table(name = "announcement_file_tb")
@Where(clause = "deleted_at is NULL")
@SQLDelete(sql = "UPDATE announcement_file_tb SET deleted_at = current_timestamp WHERE announcement_file_id = ?")
public class AnnouncementFile extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "announcement_file_id")
private Long id;
private String uploadFileName;
private String storedFileUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "announcement_id")
private Announcement announcement;
public static AnnouncementFile makeAnnouncementFile(String uploadFileName, String storedFileUrl, Announcement announcement) {
return AnnouncementFile.builder()
.uploadFileName(uploadFileName)
.storedFileUrl(storedFileUrl)
.announcement(announcement)
.build();
}
}
2. Controller
일단, 학원 개념이 들어가기 때문에, academy endpoint가 추가되었고, 어떤 공지사항 데이터에 파일 업로드를 할지와 관련해서 announcement endpoint도 추가되었다.
🚨 제일 중요한 부분은, @RequestPart
의 MultipartFile이 List 형태로 바뀐다는 점이다. 여러 파일을 업로드 하기 위해 List 형태로 요청을 전달한다. 🚨
또한, 이러한 작업을 수행할 권한을 확인하기 위해 Authenticaton 객체도 파라미터로 넘겨준다.
AnnouncementFileController.java
@Tag(name = "03-2. 학원 공지사항", description = "학원 공지사항 첨부파일 등록,삭제,다운로드")
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("api/v1/academies")
public class AnnouncementFileController {
private final AnnouncementFileS3UploadService announcementFileS3UploadService;
// S3에 파일 업로드
@Operation(summary = "공지사항 첨부파일 등록", description = "ADMIN,STAFF 회원만 등록이 가능합니다. \n\n AWS S3서버에 저장됩니다.")
@PostMapping("/{academyId}/announcements/{announcementId}/files/upload")
public ResponseEntity<Response<CreateAnnouncementFileResponse>> upload(@PathVariable("academyId") Long academyId,
@PathVariable("announcementId") Long announcementId,
@RequestPart List<MultipartFile> multipartFile,
Authentication authentication) throws IOException {
String requestAccount = AuthenticationUtil.getAccountFromAuth(authentication);
CreateAnnouncementFileResponse fileResponse = announcementFileS3UploadService.UploadAnnouncementFile(academyId, announcementId, multipartFile, requestAccount);
return ResponseEntity.ok().body(Response.success(fileResponse));
}
}
3. Service
업로드 할 때, 빈 파일을 첨부하거나, 파일을 첨부해도 파일 안에 내용이 없는 경우 FILE_NOT_EXISTS
에러를 발생하도록 validateFileExists()
메서드로 검증한다.
다음으론, 학원 존재 유무와 업로드 진행하는 직원이 해당 학원 소속인지, 그리고 그 직원이 업로드할 권한이 있는지 확인한다.
파일 업로드 대상인 공지사항의 존재 유무도 확인한다.
🚨 공지사항 파일 업로드와 관련된 코드들을 설명해보면 다음과 같이 진행된다. 🚨
- 원본 파일 이름들과 S3에 저장될 파일 이름들을 담을 List를 각각
originalFileNameList
와storedFileNameList
로 생성한다. - 그리고,
forEach
안에서 직원 프로필 업로드할 때 했던 방식 그대로 진행하고,forEach
구문 마지막에 각각의 원본 파일 이름과 S3에 저장될 파일 이름을 List에 차례로 담아주기만 하면 된다.
AnnouncementFileS3UploadService.java
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AnnouncementFileS3UploadService {
private final AmazonS3Client amazonS3Client;
private final AcademyRepository academyRepository;
private final EmployeeRepository employeeRepository;
private final AnnouncementRepository announcementRepository;
private final AnnouncementFileRepository announcementFileRepository;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
/**
* @param academyId 학원 id
* @param announcementId 파일 업로드 대상인 공지사항 id
* @param multipartFile 파일 업로드를 위한 객체
* @param account 파일 업로드 진행하는 직원 계정
*/
public CreateAnnouncementFileResponse UploadAnnouncementFile(Long academyId, Long announcementId, List<MultipartFile> multipartFile, String account) {
// 파일이 들어있는지 확인
validateFileExists(multipartFile);
// 학원 존재 유무 확인
Academy academy = validateAcademy(academyId);
// 업로드를 진행하는 직원이 해당 학원 소속 직원인지 확인
Employee employee = validateAcademyEmployee(account, academy);
// 직원이 공지사항 파일 업로드할 권한이 있는지 확인 (강사 외 모든 직원 가능)
if(Employee.isTeacherAuthority(employee)) {
throw new AppException(ErrorCode.INVALID_PERMISSION);
}
// 파일 업로드 대상인 공지사항 존재 유무 확인
Announcement announcement = validateAnnouncement(announcementId);
// 원본 파일 이름, S3에 저장될 파일 이름 리스트
List<String> originalFileNameList = new ArrayList<>();
List<String> storedFileNameList = new ArrayList<>();
multipartFile.forEach(file -> {
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(file.getContentType());
objectMetadata.setContentLength(file.getSize());
String originalFilename = file.getOriginalFilename();
int index;
// file 형식이 잘못된 경우를 확인
try {
index = originalFilename.lastIndexOf(".");
} catch (StringIndexOutOfBoundsException e) {
throw new AppException(ErrorCode.WRONG_FILE_FORMAT);
}
String ext = originalFilename.substring(index + 1);
// 저장될 파일 이름
String storedFileName = UUID.randomUUID() + "." + ext;
// 저장할 디렉토리 경로 + 파일 이름
String key = "announcement/" + storedFileName;
try (InputStream inputStream = file.getInputStream()) {
amazonS3Client.putObject(new PutObjectRequest(bucket, key, inputStream, objectMetadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch (IOException e) {
throw new AppException(ErrorCode.FILE_UPLOAD_ERROR);
}
String storeFileUrl = amazonS3Client.getUrl(bucket, key).toString();
AnnouncementFile announcementFile = AnnouncementFile.makeAnnouncementFile(originalFilename, storeFileUrl, announcement);
announcementFileRepository.save(announcementFile);
storedFileNameList.add(storedFileName);
originalFileNameList.add(originalFilename);
});
log.info("파일 등록 완료");
return CreateAnnouncementFileResponse.of(originalFileNameList, storedFileNameList);
}
private Academy validateAcademy(Long academyId) {
// 학원 존재 유무 확인
Academy validatedAcademy = academyRepository.findById(academyId)
.orElseThrow(() -> new AppException(ErrorCode.ACADEMY_NOT_FOUND));
return validatedAcademy;
}
private Employee validateAcademyEmployee(String account, Academy academy) {
// 해당 학원 소속 직원 맞는지 확인
Employee employee = employeeRepository.findByAccountAndAcademy(account, academy)
.orElseThrow(() -> new AppException(ErrorCode.ACCOUNT_NOT_FOUND));
return employee;
}
private Announcement validateAnnouncement(Long announcementId) {
Announcement validatedAnnouncement = announcementRepository.findById(announcementId)
.orElseThrow(() -> new AppException(ErrorCode.ANNOUNCEMENT_NOT_FOUND));
return validatedAnnouncement;
}
// 빈 파일이 아닌지 확인, 파일 자체를 첨부안하거나 첨부해도 내용이 비어있으면 에러 발생
private void validateFileExists(List<MultipartFile> multipartFile) {
for(MultipartFile mf : multipartFile) {
if (mf.isEmpty()) {
throw new AppException(ErrorCode.FILE_NOT_EXISTS);
}
}
}
}
4. Dto
여러 파일을 업로드하기 때문에 업로드 Dto에서도 바뀐 부분이 있다.
원본 파일 이름과 S3에 저장될 파일 이름 필드를 List로 생성해준다.
CreateAnnouncementFileResponse.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class CreateAnnouncementFileResponse {
private List<String> uploadFileName;
private List<String> S3StoredFileName;
private String message;
public static CreateAnnouncementFileResponse of(List<String> original, List<String> stored ) {
return CreateAnnouncementFileResponse.builder()
.uploadFileName(original)
.S3StoredFileName(stored)
.message("파일 첨부 완료")
.build();
}
}
Leave a comment