[너나드리] Implement Layer를 통한 이미지 파일 저장과 Entity 영속화에 대한 고민 해결 과정

eora21·2024년 3월 22일
0

너나드리 개발기

목록 보기
5/8

이미지 관련 Entity를 사용하며 이미지 저장에 대한 고민을 정리한 글입니다.

기존 레이어 구조

새 글 업로드 시 번잡한 코드 구성

너나드리 프로젝트의 작성 글(Post)는 위치 정보(Location)와 이미지들을 지니고 있습니다.
이미지(Picture)는 Post뿐만 아니라, 사용자의 프로필과 연관이 되어 있습니다.
그렇기에 Post - PostPicture - Picture 순으로 일대다-다대일 연결이 되어 있습니다.

따라서 Post를 한 건 저장할 경우 Location, PostPicture, Picture까지 모두 저장해야 했습니다.

@Transactional
public Long uploadNewPost(NewPostRequestDto requestDto, AccountDetail accountDetail) {
	Long accountId = accountDetail.getAccountId();
	Account account = accountRepository.getReferenceById(accountId);
	
	Location location = locationSupplier.supply(requestDto.getLat(), requestDto.getLng());
	locationRepository.save(location);
	
	Post post = requestDto.toPostEntity(account, location);
	
	List<PictureTemp> pictureTemps = requestDto.getImages().stream()
			.filter(pictureSupplier::isImage)
			.map(pictureSupplier::createPictureTemp)
			.toList();
	
	List<Picture> pictures = pictureTemps.stream()
			.map(PictureTemp::picture)
			.toList();
	
	postRepository.save(post);
	pictureRepository.saveAll(pictures);
	
	List<PostPicture> postPictures = pictures.stream()
			.map(picture -> new PostPicture(post, picture))
			.toList();
	
	postPictureRepository.saveAll(postPictures);
	
	pictureTemps.forEach(pictureSupplier::savePicture);
	
	return post.getId();
}

하나의 Post를 저장하기 위해 너무 많은 코드가 첨부되어 있습니다. 특히, Picture의 경우 DB에 이미지 파일의 Binary 정보를 저장하지 않습니다(저장되는 정보들은 폴더경로, 업로드명, 저장명, 확장자입니다). 따라서 Entity 저장과 파일 저장이 따로 이루어 집니다.

양방향, cascade

이를 해소하기 위해 Post에 양방향 및 cascade 설정을 걸어 주었습니다.

public class Post {
    ...
    
    @OneToOne(cascade = {PERSIST, MERGE, REFRESH, DETACH})
	@JoinColumn(name = "location_id")
	Location location;
    
	@OneToMany(mappedBy = "post", fetch = FetchType.EAGER, cascade = {PERSIST, MERGE, REFRESH, DETACH})
	List<PostPicture> postPictures = new ArrayList<>();
    
    ...
}

PostPicture 또한 cascade를 통해 Picture를 저장할 수 있도록 했습니다.

public class PostPicture {
	...

    @MapsId("savedName")
    @ManyToOne(cascade = {PERSIST, MERGE, REFRESH, DETACH})
    @JoinColumn(name = "saved_name")
    Picture picture;
    
    ...
}

따라서 PostService의 많은 persist 코드를 제거할 수 있었습니다.

@Transactional
public Long uploadNewPost(NewPostRequestDto requestDto, AccountDetail accountDetail) {
	Long accountId = accountDetail.getAccountId();
	Account account = accountRepository.getReferenceById(accountId);
	
	Location location = locationSupplier.supply(requestDto.getLat(), requestDto.getLng());
	Post post = requestDto.toPostEntity(account, location);
	
	List<PictureTemp> pictureTemps = requestDto.getImages().stream()
			.filter(pictureSupplier::isImage)
			.map(pictureSupplier::createPictureTemp)
			.toList();
            
	post.addPictures(pictureTemps);
    
	postRepository.save(post);
	pictureRepository.saveAll(pictures);
	
	pictureTemps.forEach(pictureSupplier::savePicture);
	
	return post.getId();
}

그러나 이미지 저장 처리의 경우, 많은 고민이 발생했습니다.

이미지 저장 로직

Picture는 이미지 파일과 매우 깊은 연관이 있습니다.
이미지 파일이 존재하지 않을 경우, Picture는 생성되지 않습니다.
Picture는 Post와 이미지 파일의 관계를 나타내는 것인데, 만약 Picture는 저장했으나 PictureSupplier의 savePicture() 메서드 사용을 잊었을 시 존재하지 않는 이미지 파일에 대한 관계가 맺어질 수 있다는 위험성이 존재합니다.

Picture는 많은 Entity에서 사용될 예정이므로 PictureSupplier의 savePicture()를 꼭 써주세요라고 알리는 것보다, Picture save와 이미지 파일 저장을 일치화시키는 것이 좋다고 생각했습니다.

@PostPersist

Entity가 영속화 된 이후 이미지 저장 로직을 직접 작성하면 문제를 줄일 수 있을 거라 생각했고, @PostPersist 어노테이션을 사용해보기로 했습니다.

하지만 Picture 내에 이미지 저장 로직을 직접 작성하는 것은 Entity의 책임 범위를 벗어난다는 생각이 들었고, 기존에 사용되던 PictureSupplier를 Component가 아닌 Util 클래스로 변경하여 사용하였습니다.

그러나 Util 클래스의 단점인, '어디에서든 참조하여 사용할 수 있다'는 점이 아쉬웠습니다. Picture만을 위해 만들어진 로직을 오남용할 수 있는 가능성이 생기기 때문입니다.
따라서 Picture 안에 static inner class로 작성 후 사용하는 쪽으로 의견을 냈으나, 이 또한 entity 내에 침투적인 코드를 작성하게 된다는 문제가 있었습니다.

당시의 코드는 이러합니다.

@Entity
@Table(name = "pictures")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Picture {

    @Id
    @Column
    @UuidGenerator
    private UUID savedName;

    @Column
    @NotNull
    private String folderName;

    @Column
    @NotNull
    private String originName;

    @Column
    @NotNull
    private String extension;

    @Transient
    private MultipartFile multipartFile;

    private Picture(String folderName, String originName, String extension, MultipartFile multipartFile) {
        this.folderName = folderName;
        this.originName = originName;
        this.extension = extension;
        this.multipartFile = multipartFile;
    }

    public String getSavePath() {
        return folderName + "/" + savedName + "." + extension;
    }

    public static List<Picture> fromMultipartFiles(String folderName, List<MultipartFile> multipartFiles) {

        if (Objects.isNull(multipartFiles)) {
            return Collections.emptyList();
        }

        return multipartFiles.stream()
                .map(multipartFile -> fromMultipartFile(folderName, multipartFile))
                .filter(Objects::nonNull)
                .toList();
    }

    public static Picture fromMultipartFile(String folderName, MultipartFile multipartFile) {
        return ImageProcessor.createPictureFromMultipartFile(folderName, multipartFile);
    }


    @PostPersist
    public void saveImage() {
        ImageProcessor.writeImage(this);
    }

    @NoArgsConstructor(access = AccessLevel.PRIVATE)
    private static class ImageProcessor {
        private static final String IMAGE = "image";

        private static Picture createPictureFromMultipartFile(String folderName, MultipartFile multipartFile) {
            if (isNotImage(multipartFile)) {
                return null;
            }

            String originalFilename = multipartFile.getOriginalFilename();

            if (Objects.isNull(originalFilename)) {
                throw new IllegalArgumentException();
            }

            String filenameExtension = StringUtils.getFilenameExtension(originalFilename);
            String stripFilenameExtension = StringUtils.stripFilenameExtension(originalFilename);
            return new Picture(folderName, stripFilenameExtension, filenameExtension, multipartFile);
        }

        private static boolean isNotImage(MultipartFile multipartFile) {
            String contentType = multipartFile.getContentType();

            if (Objects.isNull(contentType)) {
                return true;
            }

            return !contentType.startsWith(IMAGE);
        }

        private static void writeImage(Picture picture) {
            MultipartFile multipartFile = picture.getMultipartFile();

            if (Objects.isNull(multipartFile)) {
                throw new SavePictureFailException();
            }

            try {
                File file = new File(picture.getSavePath());
                multipartFile.transferTo(file);
            } catch (IOException e) {
                throw new SavePictureFailException(e);
            }
        }
    }
}

Picture는 MultipartFile을 통해서만 생성이 가능하며, 영속화될 시 특정 경로에 이미지를 저장하도록 설정했습니다.

다만 이러한 구조가 과연 객체지향적인지, 또한 서비스가 비대해졌을 때 문제를 일으키진 않을지 걱정되었습니다. 특히나 Entity 클래스 내에 이미지 저장에 대한 코드가 같이 산재해 있게 되어 저장방식이 변경될 때 Entity 클래스가 변화되는 상황이 생길 수 있었습니다.

테스트 코드에서도 많은 처리가 필요했습니다. 위의 코드 구조에서는 다행히 multipartFile을 Mock으로 만들고 transferTo() 메서드가 실행될 때 아무것도 하지 않도록 작성할 수 있지만, 그보다 더 복잡한 로직이 @PostPersist에 들어간다면 어떠한 처리가 더 필요할 지 예상할 수 없었습니다.

@Transactional
public Long uploadNewPost(NewPostRequestDto requestDto, AccountDetail accountDetail) {
    Long accountId = accountDetail.getAccountId();
    Account account = accountRepository.getReferenceById(accountId);
    
    Location location = locationSupplier.supply(requestDto.getLat(), requestDto.getLng());
    Post post = requestDto.toPostEntity(account, location);
    
    List<Picture> pictures = Picture.fromMultipartFiles(folderName, requestDto.getImages());
    post.addPictures(pictures);
    
    postRepository.save(post);
    return post.getId();
}

서비스 로직은 상당히 줄어들었으나, 이 구조가 과연 옳은 것인지 의구심이 갔습니다. 따라서 카카오톡 오픈채팅방에 이 글을 링크로 걸어 다른 분들의 의견을 여쭤보았습니다.

그러다 호석님께서 현재 상황을 타개할 지속 성장 가능한 소프트웨어를 만들어가는 방법이란 글을 공유해 주셨고(호석님께는 이전에도 SRID 관련한 내용에 대해 도움 받은 적이 있습니다. 매번 많은 가르침을 받는 것 같아 정말 감사합니다!), 이전에 서비스와 트랜잭션 범위를 고려하며 Service에서 다른 Service 의존에 대하여라는 글도 본 적이 있는데, 이러한 내용을 모두 참고하여 프로젝트의 구조를 변경해보기로 했습니다.

새 레이어 구조

구조 적용 전, 현재의 문제점부터 파악해보자

가장 큰 문제점은 역시 Picture Entity의 nested static class로 인한 의존성 문제입니다. Service 로직의 복잡도를 줄이려 시도하는 과정에서 코드의 오남용 가능성을 인지했고, Entity의 영속화와 이미지 파일의 저장을 일체화하였으나 이미지 저장 방식 등에 따라 Entity 클래스의 변화가 쉽게 일어날 수 있었으며, 이는 역할과 책임에 맞지 않는 코드 구성이었습니다.

구조 적용 시 해결할 수 있는 문제점

지속 성장 가능한 소프트웨어를 만들어가는 방법에서는 기존의 스프링 웹 레이어(Controller, Service, Repository)의 구조에서 하나가 추가된 레이어(Presentation Layer, Business Layer, Implement Layer, Data Access Layer)를 소개하고 있습니다.

기존의 서비스에 산재된 코드를 대신할 Implement Layer를 구현해 서비스 로직의 복잡도를 낮추며, 이로 인해 로직의 흐름 이해를 높일 수 있어 보였습니다. 또한 '해당 레이어를 통해 코드를 구축한다'는 규약으로 팀원들의 코드 오남용 방지가 가능하며, 이는 곧 nested static class를 제거할 수 있음을 뜻했습니다.

또한 서비스의 역할을 비즈니스 로직의 진입부이자 트랜잭션의 범위로 설정하여, 커밋과 롤백의 범위를 훨씬 더 쉽고 간편하게 가져갈 수 있으리란 생각이 들었습니다.

코드 개선

Implement Layer 구조 정립

코드를 작성하기 전, Implement Layer에서 어떠한 로직을 수행할 것인지 먼저 생각해봐야 했습니다.

기존의 서비스 로직을 살펴보면, 대부분의 작업이

  • Entity find
  • Entity Persist
  • Class create

였습니다. 특정 Entity를 찾고, 해당 Entity와 연결될 Entity를 만들고, 그 과정에서 특정 Class를 생성하여 도움을 받는 구조였습니다.

이러한 로직들을 쪼개기 위해, 세 종류의 Implement Layer를 정의하였습니다.

reader는 Entity를 읽어오고, Supplier는 영속화한 Entity를 반환하고, Provider는 연산에 필요한 class들을 제공해주도록 역할을 나누었습니다.

Implement Layer의 책임 범위

또한 해당 레이어 클래스들의 책임 범위를 고민해 보았습니다. 범위를 너무 좁게 잡으면 서비스 로직이 전과 같이 파편화 될 것이고, 범위를 너무 넓게 잡으면 서비스 로직 전체를 하나의 레이어에서 갖고 있게 될 수 있었습니다. 비대해진 레이어는 다시금 흐름이 보이지 않는 코드를 지닐 것이고, 같은 문제가 지속될 수 있었습니다.

따라서 각각의 레이어들이 사용하는 Entity, Class, Library 범위만큼의 책임을 지니게끔 결정하였습니다. 해당 범위가 내부적으로 이해하기 쉬운 코드의 흐름을 가져올 수 있다고 생각하였습니다.

Implement Layer를 통한 Picture와 이미지 파일 처리

nested static class가 지닌 코드의 일부를 그대로 옮겨 PictureSupplier라는 클래스를 만들었습니다.
그리고 이미지 파일을 저장하는 부분을 ImageProvider에 작성하였습니다.

private Picture fromMultipartFile(String folderName, MultipartFile multipartFile) {
    if (isNotImage(multipartFile)) {
        return null;
    }
    
    String originalFilename = multipartFile.getOriginalFilename();
    if (Objects.isNull(originalFilename)) {
        throw new IllegalArgumentException();
    }
    
    String filenameExtension = StringUtils.getFilenameExtension(originalFilename);
    String stripFilenameExtension = StringUtils.stripFilenameExtension(originalFilename);
    Picture picture = new Picture(folderName, stripFilenameExtension, filenameExtension, multipartFile);
    
    imageProvider.provide(picture);
    
    return picture;
}

이렇게 나누니 Picture Entity는 PictureSupplier를 통해 생성하여 제공되고, 이미지를 저장하는 부분은 ImageProvider에서 이루어지므로 각각의 책임을 따로 지닐 수 있었습니다. 이를 이용하는 Service에서는 이미지가 어떻게 저장되는지 신경쓰지 않아도 되고, Entity에서도 독립적인 의존성을 이룰 수 있었습니다.

이미지 저장 방법에 대한 고민

또한, 둘을 나누고 나니 이미지 저장 방법 및 타이밍에 대해 고민해볼 수 있었습니다.
기존에는 Picture가 영속화될 때 이미지가 저장되었습니다. 그러나 이미지 저장 이후에 서비스 로직에서 예외가 발생하여 롤백된다면, 이미지 저장을 위했던 IO는 리소스 낭비일 수 있었습니다. 따라서 모든 로직이 문제 없이 수행되어 커밋이 이뤄지기 직전에 이미지들을 저장할 수 있다면, 즉 IO의 타이밍을 최대한 늦출 수 있다면 굉장히 좋을 것 같았습니다.
또한 이미지 저장 도중 예외가 발생하거나, 커밋이 이뤄지는 순간 예외가 발생하여 트랜잭션이 롤백될 때 저장한 이미지들도 삭제되게끔 만든다면 훨씬 나을 것 같다는 생각이 들었습니다.

커밋 직전 이미지 저장, 롤백 이후 이미지 제거

해당 타이밍을 확인하여 로직을 동작시킬 수 있는 방법으로 여러 방법이 있었지만, 가장 손쉬운 @TransactionalEventListener를 사용해보기로 했습니다.

이미지 저장은 여러 작업에서 복합적으로 이루어질 가능성이 있었는데, 매 트랜잭션마다 해당 이벤트를 발생시키는 건 오히려 리소스를 많이 소모한다고 판단하였습니다. 따라서 이미지 저장 요청이 수행될 경우 각 트랜잭션마다의 고유한 곳에 MultipartFile을 두고, 커밋 직전 해당 파일들을 모두 저장하는 것으로 계획을 세웠습니다.

초기에는 트랜잭션의 고유 id를 key로, 이미지들의 목록을 value로 지닌 map을 생성하고자 했습니다.
그러나 트랜잭션의 고유 id를 획득하는 방법이 쉽지 않았습니다. 따라서 다른 방법을 찾아보던 중, 트랜잭션마다 내부에 resource를 유지하고 있다는 것을 발견하였습니다.

따라서 resource 내에 해당 MultipartFile들을 넣어두는 것으로 이를 해결했습니다.

TransactionSynchronizationManager.getResource에 대해

처음에는 각각의 트랜잭션마다 리소스를 유지한다고 생각했으나, 테스트코드를 작성하며 확인해 보니 각 스레드마다 리소스를 유지합니다.

Retrieve a resource for the given key that is bound to the current thread.

스레드 내에서 트랜잭션마다의 이미지 저장 요청을 따로 들고 있게 하기 위해 TransactionSynchronizationManager.getCurrentTransactionName()를 통해 트랜잭션의 이름을 key로 하도록 변경했습니다.

getCurrentTransactionName()

해당 값은 트랜잭션이 시작된 메서드의 위치를 선언합니다. 따라서 값이 중복될 여지가 있습니다.
그러나 잘 생각해보면 시작된 메서드 위치의 중복은 '스레드 내에서 같은 메서드를 통해 트랜잭션이 중복하여 발생되었다'는 의미이며, 그렇다는 것은 강제로 빈의 순환참조를 일으키지 않는 이상 이뤄내기 어렵습니다.
스스로의 트랜잭션 활성화를 위해 자기참조하는 코드(AOP를 통해 트랜잭션이 동작되므로 클래스 내에서 ApplictionContext.getBean() 및 기타 방법을 통해 자기 자신을 참조)가 아닌 이상은 로직적으로 순환참조를 강제로 이뤄내는 코드는 만나지 못 하였고, 더군다나 Implement Layer를 통해 해당 구조를 지니지 않게끔 코드를 구성할 수 있으므로 getCurrentTransactionName()을 사용해도 되겠다는 확신을 얻었습니다.

public void provide(List<Image> images) {
    ImageResources imageResources = getOrCreateImageResources();
    imageResources.addAll(images);
}

private ImageResources getOrCreateImageResources() {
    ImageResources imageResources =
            (ImageResources) TransactionSynchronizationManager.getResource(ImageResources.class);
            
    if (Objects.isNull(imageResources)) {
        imageResources = createImageResources();
    }
    
    return imageResources;
}

private ImageResources createImageResources() {
    String currentTransactionName = getCurrentTransactionName();
    ImageResources imageResources = new ImageResources(new ArrayList<>());
    publishImageEvent(imageResources);
    TransactionSynchronizationManager.bindResource(currentTransactionName, imageResources);
    return imageResources;
}

private static String getCurrentTransactionName() {
    return Objects.requireNonNull(TransactionSynchronizationManager.getCurrentTransactionName());
}

private void publishImageEvent(ImageResources imageResources) {
    eventPublisher.publishEvent(new ImageEvent(imageResources));
}

ImageEventListener에서는 BEFORE_COMMIT, AFTER_ROLLBACK 페이즈에 따라 이미지를 저장하고 삭제하는 로직을 작성하였습니다.

개선된 코드 흐름

@Transactional
public Long uploadNewPost(Visibility visibility, String content, boolean locationAvailable, Double lat, Double lng,
                          List<MultipartFile> images) {
                          
    Account account = accountReader.getLoginAccountReference();
    
    Location location = locationSupplier.supply(lat, lng);
    List<Picture> pictures = pictureSupplier.supply(folderName, images);
    Post post = postSupplier.supply(visibility, account, content, locationAvailable, location, pictures);
    
    return post.getId();
}

서비스 로직만 보더라도, 각 Implement Layer 클래스를 통해 어떠한 행동을 하는지 간단히 유추할 수 있게 되었습니다.
또한 각각의 클래스에서도 적절한 범위의 책임을 나눠 지니기에 기존 로직보다 훨씬 재활용이 쉬운 코드 구조가 되었습니다.

@Entity
@Table(name = "pictures")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Picture {

    @Id
    @Column
    @UuidGenerator
    private UUID savedName;

    @Column
    @NotNull
    private String folderName;

    @Column
    @NotNull
    private String originName;

    @Column
    @NotNull
    private String extension;

    @Transient
    private MultipartFile multipartFile;

    public Picture(String folderName, String originName, String extension, MultipartFile multipartFile) {
        this.folderName = folderName;
        this.originName = originName;
        this.extension = extension;
        this.multipartFile = multipartFile;
    }

    public String getSavePath() {
        return folderName + "/" + savedName + "." + extension;
    }
}

Picture Entity도 필요한 부분만 작성할 수 있게 되었습니다(MultipartFile 관련 이슈가 있으나, 이는 추후 ERD 변경 시 같이 재고하기로 하였습니다).

후기

내부적인 작은 문제들을 제외하면 전반적으로 코드가 깔끔해졌으며, 로직 분리가 많이 이루어졌다고 생각합니다.
다만 모든 서비스에서 Implement Layer를 사용한다면, 간단한 로직을 구현하기 위해 복잡도만 늘어날 가능성이 있습니다. 따라서 (이 글과 같은 경우처럼) 크리티컬한 문제에 당돌한 경우 팀원들과의 회의를 통해 결정하는 것이 좋아 보입니다.

또한 Implement Layer를 통한 글 조회 범위 선별 구조에 대해서도 리팩터링이 가능해 보입니다. 구조를 면밀히 살펴보고, 좋은 방향성이라 생각된다면 이 또한 남겨보도록 하겠습니다.

Reference

https://www.youtube.com/watch?v=kM_-mzAFviw
https://youtu.be/L3y3xk56SI8
https://youtu.be/RVO02Z1dLF8
https://www.inflearn.com/course/%EC%A7%80%EC%86%8D-%EC%84%B1%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%86%8C%ED%94%84%ED%8A%B8%EC%9B%A8%EC%96%B4

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글