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를 각각 originalFileNameListstoredFileNameList 로 생성한다.
  • 그리고, 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();
    }
}

5. 결과 확인


image

image

image

image

References

Leave a comment