0. Intro

이전 게시물에선 간단하게 Spring Boot와 AWS S3를 연동하여 파일 업로드를 진행했다.

그래서, 실제 진행하고 있는 학원 관리 프로젝트에 적용하면서 추가적으로 파일 삭제, 다운로드 기능까지 추가해보겠다.

지금 진행하고 있는 프로젝트에서 파일 관련 기능은 직원의 프로필 사진을 등록할 때와 학원 공지사항 등록 시 첨부파일 업로드를 할 때 사용된다.

🚨 그렇기 때문에, 이전 게시물과는 달리 업로드, 삭제, 다운로드를 할 수 있는 권한인지를 확인하는 부분까지 추가될 것이다. 🚨

또한, 이번 게시물은 직원 프로필과 관련하여 진행할 것이다.

직원 프로필과 관련하여 정한 부분은, 직원의 프로필은 1개만 등록이 가능하고, 프로필을 또 올리고 싶다면 수정이 아니라 덮어써서 기존의 프로필은 삭제되고 새롭게 업로드되는 프로필로 저장되게끔 하기로 했다.

또한, 직원 프로필은 원장, 직원, 강사 모두 본인 프로필만 등록할 수 있도록 정했다.

시작해보자.


1. 기존 사전작업 수정


앞서, Spring Cloud AWS를 사용하면, AmazonS3Client Bean은 자동 생성되기 때문에, 따로 AWS 설정 정보 클래스를 만들어주지 않아도 된다고 했다.

그래서, 과감히 AwsConfig.java파일을 없애도록 하겠다.

또한, 프로젝트에 맞게 관리용 IAM 사용자, 개발용 IAM 사용자, S3 버킷, 폴더 생성은 이전 게시물과 똑같이 생성해주면 된다. 여기서는, 생략하도록 하겠다.


2. Springboot 설정


2-1. 스프링 클라우드 의존성 추가

의존성 추가 부분도 다른게 없다.

인텔리제이 하단에 Dependencies를 통해 해당 Springboot 프로젝트에 가장 적합한 의존성 버전을 추가한다.

image

2-2. application.yml 설정

다음으로, application.yml파일에 이전에 개발용 IAM 사용자와 S3 버킷을 생성하면서 발급받은 엑세스 키, 비밀 엑세스 키, 버킷 이름, 버킷 리전을 입력해준다.

🚨 해당 정보들은 외부에 공개되면 안되므로 yml 파일엔 가짜 정보를 올리고, Edit Configuration을 통해 환경변수로 입력해준다. 🚨

우리 프로젝트의 경우 submodule 으로 yml 파일을 관리하므로 원한다면 참고하도록 하자.

여기에 배포 서버 URL과 서버 패스워드 JWT 토큰 secretKey도 환경 변수로 들어가야 하지만 프로젝트에서 배포를 담당한 분이 계시기에 프로젝트 마무리 전에 추가할 것이다.


cloud:
  aws:
    credentials:
      accessKey: ${AWS_ACCESS_KEY_ID}       # AWS IAM AccessKey
      secretKey: ${AWS_SECRET_ACCESS_KEY}   # AWS IAM SecretKey
    s3:
      bucket: bucket    # 버킷 이름
    region:
      static: ap-northeast-2
    stack:
      auto: false

image

2-3. Entity

엔티티 명을 EmployeeProfile로 정했다.

마찬가지로, S3 버킷에 같은 이름의 파일이 업로드되면 나중에 등록된 파일이 덮어쓰는 문제가 생긴다.

그래서, S3 버킷에 저장되는 파일 이름이 중복되지 않도록 저장된 파일의 URL을 나타내는 필드인 storedFileUrl를 추가했다.

그리고 업로드한 파일 이름을 나타내는 uploadFileName필드도 추가했다.

최초 파일 업로드 시간, 마지막 수정 시간, 삭제 시간을 위해 BaseEntity 클래스를 상속했다.

Employee 테이블과는 1:N 관계로 매핑해주었다.

Employeerofile.java


@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Table(name = "employee_profile_tb")
@Where(clause = "deleted_at is NULL")
@SQLDelete(sql = "UPDATE employee_profile_tb SET deleted_at = current_timestamp WHERE employee_profile_id = ?")
public class EmployeeProfile extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "employee_profile_id")
    private Long id;

    private String uploadFileName;
    private String storedFileUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "employee_id")
    private Employee employee;

    public static EmployeeProfile makeEmployeeProfile(String uploadFileName, String storedFileUrl, Employee employee) {
        return EmployeeProfile.builder()
                .uploadFileName(uploadFileName)
                .storedFileUrl(storedFileUrl)
                .employee(employee)
                .build();
    }
}

2-4. Controller

✅ 파일 업로드부터 살펴보자.

일단, 학원 개념이 들어가기 때문에, academy endpoint가 추가되었고, 어떤 직원 데이터에 파일 업로드를 할지와 관련해서 employee endpoint도 추가되었다.

또한, 이러한 작업을 수행할 권한을 확인하기 위해 Authenticaton 객체도 파라미터로 넘겨준다.

마지막으로, MultipartFile 객체를 파라미터로 받아와서 Service Layer에 요청을 전달한다.


✅ 파일 삭제 요청에선 추가로 employeeProfileId도 endpoint로 추가되어 S3 버킷에서 파일을 삭제할 때, EmployeeProfile db에서도 soft delete 되게끔 구성할 것이다.

또한, @RequestParam으로 filePath를 넘겨줄 것인데, 여기서 filePath는 S3 버킷의 폴더 이하 디렉토리를 말한다.

여기선, employee/저장된 파일명.확장자가 될 것이다.


✅ 파일 다운로드 요청에선 @RequestParam으로 S3 버킷 폴더 이하의 디렉토리가 아니라 저장된 파일 객체의 전체 URL을 넘겨준다.

여기선 String 타입의 fileUrl을 말한다.

물론, Service Layer엔 S3 버킷 폴더 이하의 디렉토리를 전달할 것이다.


EmployeeProfileController.java

@Tag(name = "02-3. 직원", description = "직원 사진 등록,수정,조회")
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("api/v1/academies")
public class EmployeeProfileController {

    private final EmployeeProfileS3UploadService employeeProfileS3UploadService;

    // S3에 파일 업로드
    @Operation(summary = "직원 사진등록", description = "STAFF 회원은 본인만, ADMIN 회원은 모든 STAFF 관련 등록이 가능합니다. \n\n AWS S3서버에 저장됩니다.")
    @PostMapping("/{academyId}/employees/{employeeId}/files/upload")
    public ResponseEntity<Response<CreateEmployeeProfileResponse>> upload(@PathVariable("academyId") Long academyId,
                                                                          @PathVariable("employeeId") Long employeeId,
                                                                          @RequestPart MultipartFile multipartFile,
                                                                          Authentication authentication) throws IOException {
        String requestAccount = AuthenticationUtil.getAccountFromAuth(authentication);
        CreateEmployeeProfileResponse profileResponse = employeeProfileS3UploadService.uploadEmployeeProfile(academyId, employeeId, multipartFile, requestAccount);
        return ResponseEntity.ok().body(Response.success(profileResponse));
    }


    // S3 파일 삭제
    @Operation(summary = "직원 사진삭제", description = "STAFF 회원은 본인 관련만, ADMIN 회원은 모든 STAFF 관련 삭제가 가능합니다. \n\n db에는 soft-delete 되고, AWS S3서버에서는 삭제됩니다.")
    @DeleteMapping("/{academyId}/employees/{employeeId}/employeeProfiles/{employeeProfileId}/files")
    public ResponseEntity<Response<DeleteEmployeeProfileResponse>> delete(@PathVariable("academyId") Long academyId,
                                                                          @PathVariable("employeeId") Long employeeId,
                                                                          @PathVariable("employeeProfileId") Long employeeProfileId,
                                                                          @RequestParam String filePath,
                                                                          Authentication authentication) {
        String requestAccount = AuthenticationUtil.getAccountFromAuth(authentication);
        DeleteEmployeeProfileResponse profileResponse = employeeProfileS3UploadService.deleteEmployeeProfile(academyId, employeeId, employeeProfileId, filePath, requestAccount);
        return ResponseEntity.ok().body(Response.success(profileResponse));
    }

    // S3 파일 다운로드
    @Operation(summary = "직원 사진다운로드", description = "인증된 모든 회원은 다운로드 가능합니다. \n\n AWS S3서버에 저장된 사진을 다운로드합니다.")
    @GetMapping("/{academyId}/employees/{employeeId}/files/download")
    public ResponseEntity<byte[]> download(@PathVariable("academyId") Long academyId,
                                           @PathVariable("employeeId") Long employeeId,
                                           @RequestParam String fileUrl,
                                           Authentication authentication) throws IOException {
        String requestAccount = AuthenticationUtil.getAccountFromAuth(authentication);
        // 버킷 폴더 이하 경로
        String filePath = fileUrl.substring(56);
        log.info("filePath : {}", filePath);
        return employeeProfileS3UploadService.downloadEmployeeProfile(academyId, employeeId, filePath, requestAccount);
    }
}

2-5. Service

Service 코드에선 메서드 별로 나눠서 살펴보겠다.

1. 업로드

업로드 할 때, 빈 파일을 첨부하거나, 파일을 첨부해도 파일 안에 내용이 없는 경우 FILE_NOT_EXISTS에러를 발생하도록 validateFileExists() 메서드로 검증한다.

다음으론, 학원 존재 유무와 업로드 진행하는 직원이 해당 학원 소속인지, 그리고 그 직원이 업로드할 권한이 있는지 확인한다.

파일 업로드 대상인 직원의 존재 유무도 확인한다.

그리고 업로드하려는 직원이 자신의 프로필을 등록하는지도 확인한다.

권한과 관련해선 여기까지 마무리하고,

🚨 이제 파일 업로드와 관련된 코드들을 설명해보면 다음과 같이 진행된다.

  • ObjectMetadata 객체에 content-length와 size를 지정한다. 지정하지 않으면 warning 로그 발생한다.
  • 파일 이름과 UUID로 생성한 랜덤 값을 합쳐서 S3에 저장될 파일 이름을 만든다.
  • 그리고, 해당 파일을 S3 버킷의 employee 폴더에 저장되도록 한다.
  • 버킷, 저장할 디렉토리 + 파일 이름 등의 데이터와 메타 데이터를 AmazonS3Client Bean에 주입하고, withCannedAcl 메서드의 PublicRead 옵션을 통해 업로드한 파일을 모두가 읽을 수 있게 설정한다.
  • 🚨 다음으로, 제일 중요한 부분이다. 🚨
    • 위에서 말했듯이, 이미 해당 직원이 기존 프로필이 존재하는 경우엔 기존 프로필을 삭제하고 새로 업로드 할 프로필로 대체하기로 했다.
    • 그래서 findByEmployee_Id 메서드로 만약 존재한다면, S3 저장된 기존 프로필 객체 url을 가져와서 s3에서도 삭제하고, db에서도 삭제하도록 구현했다.
  • 마지막으로, getUrl 메서드를 통해 S3에 업로드된 파일 URL, 파일 업로드 시 파일 명, 그리고 어떤 직원과 관련된 파일 업로드인지를 makeEmployeeProfile() 메서드를 통해 저장하고 이를 EmployeeProfile db에 저장한다.
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class EmployeeProfileS3UploadService {

    private final AmazonS3Client amazonS3Client;
    private final AcademyRepository academyRepository;
    private final EmployeeRepository employeeRepository;
    private final EmployeeProfileRepository employeeProfileRepository;

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

    public CreateTeacherProfileResponse UploadTeacherProfile(Long academyId, Long teacherId, MultipartFile multipartFile, String account) throws IOException {

        // 파일이 들어있는지 확인
        validateFileExists(multipartFile);

        // 학원 존재 유무 확인
        Academy academy = validateAcademy(academyId);

        // 업로드를 진행하는 직원이 해당 학원 소속 직원인지 확인
        Employee employee = validateAcademyEmployee(account, academy);

        // 파일 업로드 대상인 직원 존재 유무 확인
        Employee targetEmployee = validateEmployee(employeeId, academy);

        // 원장, 직원, 강사 모두 본인 프로필만 등록 가능
        if(!employee.equals(targetEmployee)) {
            throw new AppException(ErrorCode.INVALID_PERMISSION);
        }

        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentType(multipartFile.getContentType());
        objectMetadata.setContentLength(multipartFile.getSize());

        // 업로드한 파일 이름
        String originalFilename = multipartFile.getOriginalFilename();

        // file 형식이 잘못된 경우를 확인
        int index;
        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 = "employee/" + storedFileName;

        try (InputStream inputStream = multipartFile.getInputStream()) {
            amazonS3Client.putObject(new PutObjectRequest(bucket, key, inputStream, objectMetadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (IOException e) {
            throw new AppException(ErrorCode.FILE_UPLOAD_ERROR);
        }

        // 저장될 프로필의 url
        String storeFileUrl = amazonS3Client.getUrl(bucket, key).toString();

        // 만약 해당 직원의 기존 프로필이 존재하는 경우
        employeeProfileRepository.findByEmployee_Id(employee.getId())
                .ifPresent(employeeProfile -> {
                    // 기존 프로필 객체 url 가져오기
                    String oldFileUrl = employeeProfile.getStoredFileUrl();
                    String oldFilePath = oldFileUrl.substring(52);
                    // s3 버킷에서 기존 프로필 삭제하기
                    amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, oldFilePath));
                    // db에서도 삭제하기
                    employeeProfileRepository.delete(employeeProfile);
                });

        // 프로필 db에 저장하기
        EmployeeProfile newEmployeeProfile = EmployeeProfile.makeEmployeeProfile(originalFilename, storeFileUrl, employee);
        employeeProfileRepository.save(newEmployeeProfile);

        log.info("파일 등록 완료");
        return CreateEmployeeProfileResponse.of(originalFilename, storedFileName);
    }

    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 Teacher validateTeacher(Long teacherId) {
        Teacher validatedTeacher = teacherRepository.findById(teacherId)
                .orElseThrow(() -> new AppException(ErrorCode.TEACHER_NOT_FOUND));
        return validatedTeacher;
    }

    // 빈 파일이 아닌지 확인, 파일 자체를 첨부안하거나 첨부해도 내용이 비어있으면 에러 처리
    private void validateFilExists(MultipartFile multipartFile) {
        if (multipartFile.isEmpty()) {
            throw new AppException(ErrorCode.FILE_NOT_EXISTS);
        }
    }
}

2. 삭제

삭제 부분도 마찬가지로, 학원, 직원, 강사의 존재 유무를 확인하고, 직원의 권한도 확인한 뒤, 저장된 파일이 있는지도 확인한다.

그리고, 파일 삭제의 경우엔 권한 별로 다르다.

강사는 일단 불가능하다. 일반 직원은 본인 관련 프로필만 삭제 가능하고, 원장은 모든 직원의 프로필 삭제가 가능하도록 구현했다.

🚨 다음으로, try-catch 구문에서 deleteObject 메서드를 이용해 S3 업로드 파일을 삭제하고, 해당 업로드 파일을 EmployeeProfile db에서도 같이 soft delete 시켜준다.

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

    private final AmazonS3Client amazonS3Client;
    private final AcademyRepository academyRepository;
    private final EmployeeRepository employeeRepository;
    private final EmployeeProfileRepository employeeProfileRepository;

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

    public DeleteEmployeeProfileResponse deleteEmployeeProfile(Long academyId, Long employeeId, Long employeeProfileId, String filePath, String account) {

        // 학원 존재 유무 확인
        Academy academy = validateAcademy(academyId);

        // 파일 삭제를 진행하는 직원이 해당 학원 소속 직원인지 확인
        Employee employee = validateAcademyEmployee(account, academy);

        // 파일 삭제 대상인 직원 존재 유무 확인
        Employee targetEmployee = validateEmployee(employeeId, academy);

        // 1. 직원이 파일 삭제할 권한이 있는지 확인 (강사 외 모든 직원 가능)
        // 2. 일반 직원은 본인 관련 파일만 삭제 가능
        // 3. 원장은 모든 직원 파일 삭제 가능
        if(!employee.getEmployeeRole().equals(EmployeeRole.ROLE_ADMIN)) {
            if(Employee.isTeacherAuthority(employee) || !employee.equals(targetEmployee)) {
                throw new AppException(ErrorCode.INVALID_PERMISSION);
            }
        }

        // 직원 파일 존재 유무 확인
        EmployeeProfile employeeProfile = validateEmployeeProfile(employeeProfileId);

        try {
            // S3 업로드 파일 삭제
            amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, filePath));
            // 해당 업로드 파일 테이블에서도 같이 삭제
            employeeProfileRepository.delete(employeeProfile);
            log.info("파일 삭제 성공");
        } catch (AmazonServiceException e) {
            e.printStackTrace();
        } catch (SdkClientException e) {
            e.printStackTrace();
        }
        return DeleteEmployeeProfileResponse.of(employee);
    }

    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 Teacher validateTeacher(Long teacherId) {
        Teacher validatedTeacher = teacherRepository.findById(teacherId)
                .orElseThrow(() -> new AppException(ErrorCode.TEACHER_NOT_FOUND));
        return validatedTeacher;
    }

    private TeacherProfile validateTeacherProfile(Long teacherProfileId) {
        TeacherProfile validatedteacherProfile = teacherProfileRepository.findById(teacherProfileId)
                .orElseThrow(() -> new AppException(ErrorCode.TEACHER_PROFILE_NOT_FOUND));
        return validatedteacherProfile;
    }
}

3. 다운로드

다운로드도 마찬가지로, 학원, 직원, 강사의 존재 유무를 확인하지만, 다운로드는 모든 직원이 가능하기 때문에 직원의 권한을 확인하지 않아도 된다.

🚨 다운로드와 관련된 코드들을 설명해보면,

  • S3 객체를 추출해서 byte 배열로 변환을 시켜준다.
  • contentType 메서드를 통해 저장된 파일을 확장자 별로 구분하여 전송 헤더의 확장자로 넣어준다.
  • byte 배열의 길이를 전송 헤더의 contentLength에 입력한다.
  • 다음으로, 파라미터로 받은 fileUrl, 즉, S3 버킷 폴더 이하의 경로에서 bucketFolder 변수에 /를 구분자로 S3 버킷 폴더와 저장된 파일 명을 저장한다.
  • 파일 명을 추출하고, 인코딩한 뒤에 setContentDispositionFormData() 메서드를 통해 전송 헤더의 파일명으로 세팅한다.
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class TeacherProfileS3UploadService {

    private final AmazonS3Client amazonS3Client;
    private final AcademyRepository academyRepository;
    private final EmployeeRepository employeeRepository;
    private final EmployeeProfileRepository employeeProfileRepository;

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

    public ResponseEntity<byte[]> downloadTeacherProfile(Long academyId, Long teacherId, String fileUrl, String account) throws IOException {

        // 학원 존재 유무 확인
        Academy academy = validateAcademy(academyId);

        // 업로드를 진행하는 직원이 해당 학원 소속 직원인지 확인
        validateAcademyEmployee(account, academy);

        // 프로필 사진 대상인 강사 존재 유무 확인
        validateTeacher(teacherId);

        // S3 객체 추출해서 byte 배열로 변환
        S3Object s3Object = amazonS3Client.getObject(new GetObjectRequest(bucket, fileUrl));
        S3ObjectInputStream s3ObjectInputStream = s3Object.getObjectContent();
        byte[] bytes = IOUtils.toByteArray(s3ObjectInputStream);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(contentType(fileUrl));
        httpHeaders.setContentLength(bytes.length);

        // 버킷 폴더 추출
        String[] bucketFolder = fileUrl.split("/");
        log.info("bucketFolder : {}", bucketFolder);

        // 버킷 폴더에 저장된 해당 파일명 추출
        String fileName = bucketFolder[bucketFolder.length - 1];
        log.info("fileName : {}", fileName);

        // 인코딩
        String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
        log.info("encodedFileName : {}", encodedFileName);

        httpHeaders.setContentDispositionFormData("attachment",encodedFileName);

        return new ResponseEntity<>(bytes, httpHeaders, HttpStatus.OK);
    }

    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 Teacher validateTeacher(Long teacherId) {
        Teacher validatedTeacher = teacherRepository.findById(teacherId)
                .orElseThrow(() -> new AppException(ErrorCode.TEACHER_NOT_FOUND));
        return validatedTeacher;
    }

    // 빈 파일이 아닌지 확인, 파일 자체를 첨부안하거나 첨부해도 내용이 비어있으면 에러 처리
    private void validateFilExists(MultipartFile multipartFile) {
        if (multipartFile.isEmpty()) {
            throw new AppException(ErrorCode.FILE_NOT_EXISTS);
        }
    }

    // 저장된 파일 확장자 별로 구분하여 저장
    private MediaType contentType(String keyname) {
        String[] arr = keyname.split("\\.");
        log.info("arr : {}", arr);
        String fileExtension = arr[arr.length - 1];
        switch (fileExtension) {
            case "txt":
                return MediaType.TEXT_PLAIN;
            case "png":
                return MediaType.IMAGE_PNG;
            case "jpg":
                return MediaType.IMAGE_JPEG;
            default:
                return MediaType.APPLICATION_OCTET_STREAM;
        }
    }
}

2-6. ErrorCode, ExceptionManager, 파일 업로드, 삭제 반환 DTO

ErrorCode.java

EMPLOYEE_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 직원 파일을 찾을 수 없습니다."),
FILE_NOT_EXISTS(HttpStatus.BAD_REQUEST, "보낼 파일이 비어있습니다."),
FILE_SIZE_EXCEED(HttpStatus.BAD_REQUEST, "파일 업로드 용량을 초과했습니다.")

ExceptionManager.java

application.yml에 설정한 10MB 용량이 넘는 파일을 업로드 할 시, FILE_SIZE_EXCEED 에러를 발생시키도록 했다.

    /**
     * 파일 업로드 용량 초과시 발생
     */
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    protected ResponseEntity<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
        log.info("handleMaxUploadSizeExceededException", e);
        ErrorResponse errorResponse = new ErrorResponse(ErrorCode.FILE_SIZE_EXCEED);
        return ResponseEntity.status(errorResponse.getErrorCode().getHttpStatus())
                .body(Response.error("ERROR", errorResponse));
    }

CreateEmployeeProfileResponse.java

업로드 시 성공 메세지와 함께 업로드 한 파일의 이름을 반환한다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class CreateEmployeeProfileResponse {

    private String uploadFileName;
    private String S3StoredFileName;
    private String message;

    public static CreateEmployeeProfileResponse of(String original, String stored) {
        return CreateEmployeeProfileResponse.builder()
                .uploadFileName(original)
                .S3StoredFileName(stored)
                .message("파일 첨부 완료")
                .build();
    }
}

DeleteEmployeeProfileResponse.java

삭제 시, 성공 메세지와 함께 삭제 작업을 진행한 직원을 반환한다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class DeleteEmployeeProfileResponse {

    private Long deletedEmployee;
    private String message;

    public static DeleteEmployeeProfileResponse of(Employee employee) {
        return DeleteEmployeeProfileResponse.builder()
                .deletedEmployee(employee.getId())
                .message("삭제 성공")
                .build();
    }
}


3. 기능 테스트


3-1. 파일 업로드

png 파일 업로드 요청을 보냈더니, 성공적으로 S3 버킷과 EmployeeProfile db에 잘 저장되었다.

image

image

image

image

3-2. 파일 다운로드

방금 업로드한 png 파일 다운로드 요청을 보냈다.

아래의 객체 URL을 그대로 fileUrl의 쿼리 파라미터로 전달한다.

image

업로드한 사진이 나왔다. 나중에 프론트를 구현하면 다운로드 되게 만들겠다.

image

3-3. 파일 삭제

로고 사진을 삭제해보겠다.

S3 버킷 폴더 이하의 디렉토리 경로를 filePath의 쿼리 파라미터로 전달한다.

아래 사진을 보면, S3 버킷에서도 더이상 파일이 존재하지 않고, EmployeeProfile db에서도 soft delete 되었다.

image

image

image

이렇게, 실제 진행하고 있는 프로젝트에 파일 업로드, 삭제, 다운로드 기능을 적절히 적용해보았다.

하나의 파일 엔티티에서 관리하면 좋겠지만, 그렇게 되면 파일이 섞이게 되어 관리하기가 복잡하기도 하고 연관관계 매핑 시 외래키에 null값이 들어갈 수 있어 권장하지 않는다.

따라서, 직원, 공지사항과 관련된 파일 엔티티를 추가적으로 만들면 되겠다.

추가적으로, 다음 게시물에선 학원 공지사항 파일 업로드 부분을, 여러 파일을 한꺼번에 올릴 수 있도록 구현해보도록 하겠다.

References

Leave a comment