Nick Dev

[Outstagram] 템플릿 메서드 패턴을 실제 프로젝트에 적용해보기 본문

Outstagram

[Outstagram] 템플릿 메서드 패턴을 실제 프로젝트에 적용해보기

Nick99 2024. 12. 12. 03:03
반응형

 

 

템플릿 메서드 패턴이란?

정의

  • 상위 클래스에서 공통 로직을 수행하는 템플릿 메서드 구현하고
  • 이를 구현하는 하위 클래스에서 구현을 강제하는 abstract method를 두거나
  • 선택적으로 오버라이딩할 수 있는 hook method를 두는 패턴

상위 클래스의 견본 메서드에서 하위 클래스가 구현하거나 오버라이딩한 메서드들을 호출하는 패턴

내 프로젝트에 이 패턴을 어쩌다가 도입하게 되었는가...

현재 상황

  • 초기 개발 시에는 게시물의 이미지들을 로컬에 저장하고 이미지 정보들(저장 경로, 원본 파일 이름)은 DB에 저장되도록 개발
  • 그리고 추후에 이미지 저장을 S3로 옮길 계획이였기에 ImageServie라는 interface를 두고 구현 클래스로 LocalImageService 클래스를 구현해 사용 중이였다
  • 시간이 흘러 이미지 저장을 로컬에서 S3로 옮길려고 구현 클래스인 S3ImageService 클래스를 구현했다.

초기 코드

public interface ImageService {

    void saveImages(List<MultipartFile> imgFiles, Long postId);

    List<ImageDTO> getImages(Long postId);

    void deleteByIds(List<Long> deleteImgIds);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class LocalImageService implements ImageService {

    @Value("com.outstagram.upload.path")
    private String uploadPath;

    private final ImageMapper imageMapper;


    /**
     * 로컬 디렉토리에 이미지 저장하고, 이미지 정보 DB에 저장하기
     */
    @Transactional
    @Override
    public void saveImages(List<MultipartFile> imgFiles, Long postId) {
        List<ImageDTO> imageDTOList = new ArrayList<>();

        // 로컬 디렉토리에 이미지 저장 & imageDTO 생성
        for (MultipartFile img : imgFiles) {
            String originName = img.getOriginalFilename();
            String savedName = UUID.randomUUID() + "_" + originName;
            Path savePath = Paths.get(uploadPath, savedName);

            try {
                // 로컬 디렉토리에 이미지 파일 저장
                Files.copy(img.getInputStream(), savePath);

                // ImageDTO 생성 및 리스트에 추가(한꺼번에 DB에 저장할 예정)
                imageDTOList.add(
                    ImageDTO.builder()
                        .postId(postId)
                        .originalImgName(originName)
                        .savedImgName(savedName)
                        .imgPath(uploadPath)
                        .createDate(LocalDateTime.now())
                        .updateDate(LocalDateTime.now())
                        .build()
                );
            } catch (IOException e) {
                throw new ApiException(e, ErrorCode.FILE_IO_ERROR);
            }
        }

        // 이미지 정보들 한꺼번에 DB에 저장
        imageMapper.insertImages(imageDTOList);
    }

    /**
     * postId로 이미지 정보들 가져오기
     */
    @Override
    public List<ImageDTO> getImages(Long postId) {
        return imageMapper.findImagesByPostId(postId);
    }

    @Transactional
    @Override
    public void deleteByIds(List<Long> deleteImgIds) {
        int result = imageMapper.deleteByIds(deleteImgIds);
        if (result == 0) {
            throw new ApiException(ErrorCode.DELETE_ERROR, "이미지 삭제에 실패했습니다.");
        }
    }


}
@Slf4j
@Service
@RequiredArgsConstructor
public class S3ImageService implements ImageService{

    private final AmazonS3 amazonS3;
    private final ImageMapper imageMapper;

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

    private final static String UPLOAD_PATH = "https://outstagram-s3.s3.ap-northeast-2.amazonaws.com/";


    @Override
    public void saveImages(List<MultipartFile> imgFiles, Long postId) {
        List<ImageDTO> imageDTOList = new ArrayList<>();
        for (MultipartFile img : imgFiles) {
            String originName = img.getOriginalFilename();
            String savedName = upload(img);

            imageDTOList.add(
                ImageDTO.builder()
                    .postId(postId)
                    .originalImgName(originName)
                    .savedImgName(savedName)
                    .imgPath(UPLOAD_PATH)
                    .createDate(LocalDateTime.now())
                    .updateDate(LocalDateTime.now())
                    .build()
            );
        }

        // 이미지 정보들 한꺼번에 DB에 저장
        imageMapper.insertImages(imageDTOList);
    }

    @Override
    public List<ImageDTO> getImages(Long postId) {
        return null;
    }

    @Override
    public void deleteByIds(List<Long> deleteImgIds) {

    }


    public String upload(MultipartFile image) {
        if (image.isEmpty() || Objects.isNull(image.getOriginalFilename())) {
            throw new ApiException(ErrorCode.EMPTY_FILE_EXCEPTION);
        }
        return this.uploadImage(image);
    }

    private String uploadImage(MultipartFile image) {
        this.validateImageFileExtention(image.getOriginalFilename());
        try {
            return this.uploadImageToS3(image);
        } catch (IOException e) {
            throw new ApiException(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD);
        }
    }
    ...
}

문제점

  • S3ImageServiceLocalImageService 사이에 중복되는 코드가 많았다...
    • 두 클래스가 다른 점은 이미지를 실제 어디에 업로드 하는지 그 차이 밖에 없고 나머지 로직들은 모두 동일하다
    • 즉, 이미지 업로드하는 로직 제외하고 나머지 공통 로직들은 상위 클래스로 빼야 한다
  • 또한, 잘 살펴보면 각 메서드는 SRP(단일 책임 원칙)을 지키고 있지 않았다.
    • 아래 saveImages() 메서드를 보면 실제 이미지를 업로드하는 로직이미지 정보를 DB에 저장하는 로직 즉, 2가지 책임이 모두 들어가 있다.

해결방안

  • 위에서도 잠깐 말했다시피 이미지 실제로 업로드하는 로직을 제외한 나머지 공통 로직들(이미지 정보 DB에 저장, 이미지 정보 DB에서 조회, 이미지 정보 DB에서 삭제) 모두 상위 클래스로 빼야 한다
  • 이게 바로 템플릿 메서드 패턴을 적용한 것이다

템플릿 메서드 패턴 적용한 코드

  • ImageService와 구현 클래스 사이에 추상 클래스 하나 만들기
  • public interface ImageService { void saveImages(List<MultipartFile> imgFiles, Long postId); List<ImageDTO> getImageInfos(Long postId); List<ImageDTO> getDeletedImages(); /** * DB의 image 테이블에서 해당 레코드의 is_delete = 1로 수정(soft delete) */ void softDeleteByIds(List<Long> deleteImgIds); /** * DB의 image 테이블에서 해당 레코드들 hard delete */ void hardDeleteByIds(List<ImageDTO> deletedImages); /** * 실제 이미지 파일 삭제(hard delete) */ void deleteRealImages(List<ImageDTO> deletedImages); }
@RequiredArgsConstructor
public abstract class AbstractBaseImageService implements ImageService{

    private final ImageMapper imageMapper;

    @Transactional
    @Override
    public void saveImages(List<MultipartFile> imgFiles, Long postId) {
        List<ImageDTO> imageDTOList = new ArrayList<>();

        try {
            for (MultipartFile img : imgFiles) {
                String originName = img.getOriginalFilename();

                // 이미지 (로컬 or s3)에 저장
                String savedName = uploadImage(img);

                imageDTOList.add(
                    ImageDTO.builder()
                        .postId(postId)
                        .originalImgName(originName)
                        .imgUrl(savedName)
                        .createDate(LocalDateTime.now())
                        .updateDate(LocalDateTime.now())
                        .build()
                );
            }

            // DB에 이미지 정보들 저장
            imageMapper.insertImages(imageDTOList);

        } catch (Exception e) {
            // DB 저장 실패시 업로드된 이미지 S3에서 삭제
            deleteRealImages(imageDTOList);
            throw new ApiException(e, ErrorCode.SAVE_IMAGE_ERROR);
        }
    }

    @Override
    @Cacheable(cacheNames = IMAGE, key = "#postId")
    public List<ImageDTO> getImageInfos(Long postId) {
        return imageMapper.findImagesByPostId(postId);
    }

    @Override
    public List<ImageDTO> getDeletedImages() {
        return imageMapper.findDeletedImages();
    }

    @Transactional
    @Override
    @CacheEvict(cacheNames = IMAGE, key = "#postId")
    public void softDeleteByIds(Long postId, List<Long> deleteImgIds) {
        int result = imageMapper.deleteByIds(deleteImgIds);
        if (result == 0) {
            throw new ApiException(ErrorCode.DELETE_ERROR, "이미지 삭제에 실패했습니다.");
        }
    }

    @Override
    public void hardDeleteByIds(List<ImageDTO> deletedImages) {
        List<Long> deletedImageIds = deletedImages.stream()
            .map(ImageDTO::getId)
            .collect(Collectors.toList());

        int result = imageMapper.hardDeleteByIds(deletedImageIds);
        if (result == 0) {
            throw new ApiException(ErrorCode.DELETE_ERROR, "image hard delete 하다가 에러 발생!");
        }
    }

    public abstract String uploadImage(MultipartFile image);

    /**
     * 실제 이미지 파일을 (로컬 or s3)에서 삭제
     */
    public abstract void deleteRealImages(List<ImageDTO> deletedImages);

}
@Slf4j
@Service
public class LocalImageService extends AbstractImageService {

    @Value("com.outstagram.upload.path")
    private String uploadPath;

    public LocalImageService(ImageMapper imageMapper) {
        super(imageMapper);
    }

    @PostConstruct
    public void init() {
        File tempFolder = new File(uploadPath);

        if (!tempFolder.exists()) {
            tempFolder.mkdir();
        }

        uploadPath = tempFolder.getAbsolutePath();
        log.info("========================================");
        log.info(uploadPath);
    }

    @Override
    public String uploadImage(MultipartFile image) {
        String savedName = UUID.randomUUID().toString().substring(0, 10) + image.getOriginalFilename();
        Path savePath = Paths.get(uploadPath, savedName);

        try {
            Files.copy(image.getInputStream(), savePath);

        } catch (IOException e) {
            throw new ApiException(e, ErrorCode.FILE_IO_ERROR);
        }

        return uploadPath + savedName;
    }

    @Override
    public void deleteRealImages(List<ImageDTO> deletedImages) {
        if (deletedImages.isEmpty()) return;
        for (ImageDTO image : deletedImages) {
            String imageUrl = image.getImgUrl();
            File file = new File(imageUrl);

            if (file.exists()) {
                if (file.delete()) {
                    log.info("============Deleted file : " + file.getAbsolutePath());
                } else {
                    log.error("============Failed to delete file : " + file.getAbsolutePath());
                }
            } else {
                log.error("============File not found : " + file.getAbsolutePath());
            }
        }
    }
}
@Slf4j
@Service
public class S3ImageService extends AbstractBaseImageService {

    private final AmazonS3 amazonS3;

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

    public S3ImageService(ImageMapper imageMapper, AmazonS3 amazonS3) {
        super(imageMapper);
        this.amazonS3 = amazonS3;
    }

    @Override
    public String uploadImage(MultipartFile image) {
        return upload(image);
    }

    @Override
    public void deleteRealImages(List<ImageDTO> deletedImages) {
        for (ImageDTO image : deletedImages) {
            deleteImageFromS3(image.getImgUrl());
        }

    }


    public String upload(MultipartFile image) {
        if (image.isEmpty() || Objects.isNull(image.getOriginalFilename())) {
            throw new ApiException(ErrorCode.EMPTY_FILE_EXCEPTION);
        }
        return this.uploadS3Image(image);
    }

    private String uploadS3Image(MultipartFile image) {
        this.validateImageFileExtention(image.getOriginalFilename());
        try {
            return this.uploadImageToS3(image);
        } catch (IOException e) {
            throw new ApiException(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD);
        }
    }

    /**
     * 이미지 파일의 확장자 명이 올바른지 확인
     */
    private void validateImageFileExtention(String filename) {
        int lastDotIndex = filename.lastIndexOf(".");
        if (lastDotIndex == -1) {
            throw new ApiException(ErrorCode.NO_FILE_EXTENTION);
        }

        String extention = filename.substring(lastDotIndex + 1).toLowerCase();
        List<String> allowedExtentionList = Arrays.asList("jpg", "jpeg", "png", "gif");

        if (!allowedExtentionList.contains(extention)) {
            throw new ApiException(ErrorCode.INVALID_FILE_EXTENTION);
        }
    }

    /**
     * 실제 S3에 이미지 업로드하는 메서드
     */
    private String uploadImageToS3(MultipartFile image) throws IOException {
        String originalFilename = image.getOriginalFilename(); //원본 파일 명
        String extention = originalFilename.substring(originalFilename.lastIndexOf(".")); //확장자 명

        String s3FileName =
            UUID.randomUUID().toString().substring(0, 10) + originalFilename; //실제 S3에 저장될 파일 명

        InputStream is = image.getInputStream();
        byte[] bytes = IOUtils.toByteArray(is); // image를 byte 배열로 변환

        ObjectMetadata metadata = new ObjectMetadata(); // metadata 생성
        metadata.setContentType("image/" + extention);
        metadata.setContentLength(bytes.length);

        // S3에 요청할 때 사용할 byteInputStream 생성
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

        try {
            PutObjectRequest putObjectRequest =
                new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata);
            //.withCannedAcl(CannedAccessControlList.PublicRead);
            // 실제 S3에 이미지 업로드하는 코드
            amazonS3.putObject(putObjectRequest);
        } catch (Exception e) {
            throw new ApiException(e, ErrorCode.PUT_OBJECT_EXCEPTION);
        } finally {
            byteArrayInputStream.close();
            is.close();
        }

        return amazonS3.getUrl(bucketName, s3FileName).toString();
    }

    public void deleteImageFromS3(String imgUrl) {
        String key = getKeyFromImageUrl(imgUrl);
        try {
            amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
        } catch (Exception e) {
            throw new ApiException(ErrorCode.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }

    private String getKeyFromImageUrl(String imageAddress) {
        try {
            URL url = new URL(imageAddress);
            String decodingKey = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8);
            return decodingKey.substring(1); // 맨 앞의 '/' 제거
        } catch (MalformedURLException e) {
            throw new ApiException(ErrorCode.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }



}

코드 설명

  • AbstractBaseImageService 클래스에서 ImageService 인터페이스의 메서드들을 거의 다 구현해놓고 달라지는 부분인 uploadImage()deleteRealImages() 메서드들을 추상 메서드로 선언해서 하위 클래스에서 구현을 강제하게 했다
  • 공통적인 작업 흐름(DB image table에 이미지 정보를 저장하고 조회하고 삭제하는 작업)은 상위 클래스(AbstractBaseImageService)에 정의하고, 구체적인 작업(이미지를 저장할 위치)은 서브클래스(LocalImageService, S3ImageService)에서 구현하도록 했다.
  • 이로써 하위 클래스에서는 getImageInfos() 와 같은 공통 로직들은 구현할 필요가 없고 딱 2개 메서드(uploadImage(), deleteRealImages())만 구현하면 된다

장점

  • 템플릿 메서드 패턴을 통해 SRP가 적용되어 코드 응집도가 올라가고,
  • 중복되는 코드를 제거하고 이 로직을 한 곳에서 관리할 수 있다
  • 사실 전략 패턴도 들어가 있음ㅋㅋ
  • 전략 패턴은 행위를 클래스로 캡슐화하여 동적으로 행위를 바꿀 수 있게 하는 패턴
    • 컨텍스트는 로직을 수행할 때 인터페이스를 통해서 전략을 호출하고, 실제 전략은 전략 클래스에 위임한다
    • LocalImageServiceS3ImageService는 각각 다른 전략을 제공할 수 있음
@Configuration
public class ImageServiceConfig {

    @Value("${image.service.type}")
    private String imageServiceType;

    @Autowired
    @Qualifier("localImageService")
    private ImageService localImageService;

    @Autowired
    @Qualifier("s3ImageService")
    private ImageService s3ImageService;

    @Bean
    public ImageService imageService() {
        if ("s3".equalsIgnoreCase(imageServiceType)) {
            return s3ImageService;
        } else {
            return localImageService;
        }
    }


}
# local 또는 s3 값으로 설정
image.service.type=s3
  • application.properties에서 s3로 설정하면 S3ImageService 전략으로 local이면 LocalImageService 전략으로 설정된다
  • 즉, application.properties에서 값만 수정하면 로컬, S3 전략을 바꿔가면서 쓸 수 있다

템플릿 메서드 패턴 적용 전후 다이어그램 비교

적용 전

적용 후


디자인 패턴을 책으로 공부할 때는 크게 와닿지 않고, 그냥 그런갑다~ 했는데, 실제 프로젝트에 적용해보니 왜 선조들이 best practice로 이런 디자인 패턴을 만들어 놨는지 조금은 이해하게 되었다...
위 상황에서는 자동적으로 템플릿 메서드 패턴을 적용할 수 밖에 없는 것 같다...

반응형