스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA)

BRINCE·2022년 12월 7일
0
post-thumbnail

서론 🙋‍♂️

  • 스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)를 응용해서 파일을 업로드하고 해당 파일의 경로와 이름, 확장자 그리고 사이즈까지 저장하는 Jpa Repository 를 생성해 어플리케이션 내의 파일을 관리합니다.
  • 파일을 관리할 수 있는 유틸리티 클래스를 생성해서 편리하게 가공할 수 있도록 구현합니다.

본론 🙆🏻‍♂️

전체적인 흐름은 이렇습니다.

  • 파일 엔티티 클래스 생성하기
  • 파일 Dto 생성하기
  • 파일 Repository 생성하기
  • 파일 유틸리티 클래스 생성후 정적 유틸리티 메소드 선언하기
  • 서비스 클래스 생성 후, 기본적인 CRUD 테스트 코드 작성하기
  • 각각 파일 입출력이 필요한 엔티티 서비스 계층에서 활용해보기
  • 기본적인 파일 저장 방식은 commons-io 디펜던시를 추가하여 사용하며, DB에는 파일 자체를 저장하지 않고 경로와 이름을 저장해 출력은 바이트 어레이로 경로를 참조해 출력시킵니다.

파일 엔티티 클래스 생성하고 관련된 Dto 까지 생성하기 💁🏻

yaml 파일에 commons-io 관련 설정을 입력해주고, 파일이 저장될 경로를 지정해주세요. (해당 파일 자체를 DB에 저장하지 않고 경로만 참조합니다.)

(해당 글은 Spring Data Jpa 를 사용합니다.)

@Entity
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class SaveFile extends AuditingFields{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    @Column(nullable = false)
    private String fileName;
    @Setter
    @Column(nullable = false)
    private String filePath ;
    @Setter
    @Column(nullable = false)
    private String fileType;
    @Setter
    @Column(nullable = false)
    private Long fileSize;
    @Setter
    @Column(nullable = false)
    private String uploadUser;

기본적으로 사용될 필드변수들을 선언해줍니다.

해당 엔티티는 기존에 설정한 파일 경로를 참조해 파일을 불러와서 출력할 예정입니다.

@Builder
    public  record FileDto (
            Long id,
            String fileName,
            String filePath,
            String fileType,
            Long fileSize,
            String uploadUser,
						LocalDateTime createdAt,
            String createdBy,
            LocalDateTime modifiedAt,
            String modifiedBy
    )  {
        public static FileDto from(SaveFile saveFile) {
            return new FileDto(
                    saveFile.getId(),
                    saveFile.getFileName(),
                    saveFile.getFilePath(),
                    saveFile.getFileType(),
                    saveFile.getFileSize(),
                    saveFile.getUploadUser(),
                    saveFile.getCreatedAt(),
                    saveFile.getCreatedBy(),
                    saveFile.getModifiedAt(),
                    saveFile.getModifiedBy()
            );}
        public SaveFile toEntity() {
            return SaveFile.builder()
                    .id(id)
                    .fileName(fileName)
                    .filePath(filePath)
                    .fileType(fileType)
                    .fileSize(fileSize)
                    .uploadUser(uploadUser)
                    .build();
        }
    }

FileDto 는 레코드 형식으로 생성주었습니다. 저같은 경우에는 Dto가 늘어남에 따라서 엔티티 클래스 안에 정적 클래스로 생성해주어서 한 도메인이 담당하는 엔티티클래스와 Dto 클래스들을 묶어서 관리하고 있습니다.

File 엔티티 클래스와 Dto 클래스 필드에 선언된 변수가 다른 이유는 Audititng Fields 인터페이스를 생성해서 각 엔티티 클래스속 공통적으로 들어가는 필드를 따로 분리해서 관리할 수 있게 해줬습니다.

JPA Auditing 기능이란? <- 해당 글을 참고하시면 좋을것 같습니다 :D\

JpaRepository 생성하고 서비스 클래스 생성하기 💁🏻

@RepositoryRestResource
public interface SaveFileRepository extends JpaRepository<SaveFile, Long> {
    public SaveFile findByFileName(String fileName);
}

JpaRepository 를 상속받는 인터페이스를 생성해줍니다.
(@RepositoryRestResource 같은 경우는 Spring Data Rest 디펜던시를 추가하면 사용이 가능한데, 따로 API를 구현하지 않고도 REST API를 디펜던시가 구현해주어서 사용할 수 있게 해주는 어노테이션 입니다. 하지만 여기서는 사용하지 않습니다.)

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

    private final SaveFileRepository fileRepository;

    public SaveFile.FileDto getFile(Long fileId) {
        log.info("getFile() fileId: {}", fileId);
        return fileRepository.findById(fileId).map(SaveFile.FileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
    }
    public void deleteFile(Long fileId) {
        log.info("deleteFile() fileId: {}", fileId);
        fileRepository.deleteById(fileId);
    }
    public SaveFile.FileDto saveFile(SaveFile.FileDto saveFile) {
        log.info("saveFile() saveFile: {}", saveFile);
        return SaveFile.FileDto.from(fileRepository.save(saveFile.toEntity()));
    }
}

마찬가지로 JpaRepository를 생성했으니 비즈니스 로직을 설계할 차례입니다.
테스트 코드를 작성하면서 메소드를 작성 할 수 있으나 , 기본적인 추가 삭제 조회 정도만 구현하면 되기때문에 패스해도록 하겠습니다

File 유틸리티 클래스 생성하기 💁🏻

저번 글에서는 MultipartFile 인터페이스를 사용해서 파일을 받아왔고, 파일 객체로 변환하는 작업을 모두 메소드 안에서 일일히 처리했기때문에 이번에는 유틸리티 클래스를 생성해 관리하도록 편의성을 부여해줍니다.

public class FileUtil {
    private FileUtil() {
    }
    @Value("${com.example.upload.path.profileImg}")
    public static String uploadPath;


	//확장자를 가져오는 메소드
    public  static String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf(".") + 1);
    }
    //파일 이름만을 가져오는 메소드
    public static String getFileName(String fileName) {
        return fileName.substring(0, fileName.lastIndexOf("."));
    }
	//UUDI값을 파일이름에 붙여줘서 가져와주는 메소드
    public static String getFileNameWithUUID(String fileName) {
        return UUID.randomUUID().toString() + "_" + fileName;
    }
    //정적 팩토리 메소드
    public  static File  createFile(String uploadPath, String fileName) {
        return new File(uploadPath, fileName);
    }

    public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
        File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
        multipartFile.transferTo(file);
        return file;
    }

    public static File getFileFromFileDomain(SaveFile.FileDto fileDto) {
        return new File(fileDto.filePath());
    }


    public static void deleteFile(SaveFile.FileDto profileImg) {
        File file = getFileFromFileDomain(profileImg);
        if (file.exists()) {
            file.delete();
        }
    }
    //멀티파트파일을 파일객체로 변환후 FileDto로 리턴해주는 메소드 
    public static SaveFile.FileDto getFileDtoFromMultiPartFile(MultipartFile multipartFile, String uploadUser) throws IOException {
        File file = getMultipartFileToFile(multipartFile);
        String fileName = file.getName();
        String fileType = getExtension(Objects.requireNonNull(multipartFile.getOriginalFilename()));
        Long fileSize = multipartFile.getSize();
        return SaveFile.FileDto.builder()
                .fileName(fileName)
                .filePath("yourpath"+fileName)
                .fileType(fileType)
                .fileSize(fileSize)
                .uploadUser(uploadUser)
                .build();// TODO: 경로가 자꾸 null 로 입력되기 때문에 해결 방안을 찾아야함.
    }

맨 아래 메소드를 가장 많이 사용하게 될 것 같은데요 ,저같은 경우는 다른 메소드에서는 경로를 정상적으로 참조하는 반면에 해당 메소드는 null값으로 입력이 되어서 임시방편으로 메소드 내에 경로를 다시 선언해주었습니다.

해당 유틸리티 클래스로 각각 파일을 전달받는 컨트롤러에서 멀티파트파일을 프론트에서 받아오면 FileDto 로 변환후에 저장후 필요한 객체에 Set 하는 방식으로 이용합니다.

컨트롤러에서 활용해보기 💁🏻

    @Setter
    @ToString.Exclude
    @ManyToOne
    @JoinColumn(name = "profile_img_id")
    @Nullable
    private SaveFile profileImg;

저는 유저 엔티티 필드변수속 단순 path와 name 으로 선언해두었던 프로필 파일을 엔티티를 변수로 갖도록 변경해주었습니다.

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestPart("signupDto") @Valid UserAccount.SignupDto signupDto, BindingResult bindingResult
    ,@RequestPart(value = "imgFile",required = false) MultipartFile imgFile) throws IOException {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        else if(!signupDto.getPassword1().equals(signupDto.getPassword2())){
            bindingResult.addError(new FieldError("userCreateForm","password2","비밀번호가 일치하지 않습니다."));
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        if(imgFile==null){
            userService.saveUserAccountWithoutProfile(signupDto);
        }
        else {
            userService.saveUserAccount(signupDto, fileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(imgFile,signupDto.getUserId())));
        }
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

기존에 작성해둔 메소드를 수정합니다. multipartFile 자체를 매개값으로 전달했었으나, 이제는 유틸리티 클래스의 도움을 받아서 fileDto 로 변환후 jpaRepository에 저장 후에 식별자를 매개값으로 넘겨주게 됩니다.

    public void saveUserAccount(UserAccount.SignupDto user, SaveFile.FileDto fileDto) throws IOException {
        String password = user.getPassword1();
        UserAccount account = userAccountRepository.save(user.toEntity());
        account.setUserPassword(new BCryptPasswordEncoder().encode(password));
        account.setProfileImg(fileDto.toEntity());
    }

그렇게 전달받은 file을 단순히 계정 엔티티에 set 해주면 됩니다.

출력해보기 💁🏻

이제 DB에 해당 엔티티를 참조할 수 있도록 구현해놨으니 출력하는것도 구현해야합니다.

    @GetMapping("/accounts/{username}")
    public ResponseEntity<?> getProfileImg (@PathVariable String username) throws IOException {
        File profileImg = FileUtil.getFileFromFileDomain(userService.getUserAccount(username).profileImg());
        byte[] imageByteArray = IOUtils.toByteArray(new FileInputStream(profileImg));
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

여기서도 마찬가지로 FileUtil 클래스를 이용해 엔티티를 파일 객체로 만들어줍니다.
그리고 IOUtils 클래스가 제공하는 바이트 어레이 변환 메소드를 이용해 바이트배열로 변환해주고 객체로 전달해줍니다.

이제 이미지 출력이 필요한 부분에 src 태그에 해당 경로를 집어넣으면 프로필 사진이 출력되게 됩니다!

시연 화면 💁🏻

아무 사진 하나만 회원가입할때 업로드해줍니다.

이렇게 가입이 완료되면 이전과는 다르게 파일 경로와 이름이 DB에 저장되는것이 아닌 외래키로 다른 테이블 식별자를 참조하게 됩니다.

이렇게 파일 저장용 테이블이 만들어져서 업로드 유저와 함게 사이즈, 확장자, 그리고 경로 이름이 컬럼값으로 들어가게 됩니다.

자 이렇게 업로드한 프로필 사진이 정상적으로 출력이 됩니다. (사진은 제 셀카에용;;)

마찬가지로 해당 로직을 응용해서 수정도 할 수 있습니다.

수정을 하게 될 경우엔 기존에 존재하던 파일을 삭제하고 새로 저장된 파일 엔티티를 참조하는 방식으로 구현했습니다.

; 자 이렇게 다른 이미지에서 또 다시 원래 이미지로 변경하고 수정을 하게되면

기존에 등록된 프로필 이미지는 삭제됩니다! 물론 deleteFile 메소드도 호출했으니 로컬에서도 삭제됩니다.


마무리 💆🏻‍♂️

  • 생각보다 어려워 보였던 파일 입출력을 쉽게 구현해보았습니다.
  • fileRepository 의존성 주입을 다른 클래스에서 해도 되나가 가장 큰 고민거리였습니다.
  • 해당 고민은 fileservice 를 파일 입출력이 필요한 컨트롤러에서 주입받아 이용하도록 해결했습니다.
  • 바이트 배열로 전달하는것 외에 더 효율적인 출력방법이 있을까요 ? 저는 아직 모르겠습니다 ;(
  • A(프로필 이미지 단건 업로드) -> B (단건 참조) 까지 했으니 다음 목표는 C 게시글에 이미지 여러건 첨부 후 출력 으로 잡으면 되겠습니다! 🎅🏽
profile
자스코드훔쳐보는변태

0개의 댓글