반응형
Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
Tags
- 다형성
- Sharding
- 코딩테스트
- 코드트리
- nGrinder
- 연습문제
- 자료구조
- Oidc
- 디프만16기
- java
- c
- 코드 트리
- 디프만
- kakao
- Kotlin
- depromeet
- AOP
- 코딩
- 부하 테스트
- C언어
- pub.dev
- 운영체제
- Spring
- Redis
- flutter
- OAuth
- 상속
- 객체지향
- dip
- 코딩 테스트
Archives
- Today
- Total
Nick Dev
[Outstagram] 템플릿 메서드 패턴을 실제 프로젝트에 적용해보기 본문
반응형
템플릿 메서드 패턴이란?
정의
- 상위 클래스에서 공통 로직을 수행하는 템플릿 메서드 구현하고
- 이를 구현하는 하위 클래스에서 구현을 강제하는 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);
}
}
...
}
문제점
S3ImageService
와LocalImageService
사이에 중복되는 코드가 많았다...- 두 클래스가 다른 점은 이미지를 실제 어디에 업로드 하는지 그 차이 밖에 없고 나머지 로직들은 모두 동일하다
- 즉, 이미지 업로드하는 로직 제외하고 나머지 공통 로직들은 상위 클래스로 빼야 한다
- 또한, 잘 살펴보면 각 메서드는 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가 적용되어 코드 응집도가 올라가고,
- 중복되는 코드를 제거하고 이 로직을 한 곳에서 관리할 수 있다
- 사실 전략 패턴도 들어가 있음ㅋㅋ
- 전략 패턴은 행위를 클래스로 캡슐화하여 동적으로 행위를 바꿀 수 있게 하는 패턴
- 컨텍스트는 로직을 수행할 때 인터페이스를 통해서 전략을 호출하고, 실제 전략은 전략 클래스에 위임한다
LocalImageService
와S3ImageService
는 각각 다른 전략을 제공할 수 있음
@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로 이런 디자인 패턴을 만들어 놨는지 조금은 이해하게 되었다...
위 상황에서는 자동적으로 템플릿 메서드 패턴을 적용할 수 밖에 없는 것 같다...
반응형
'Outstagram' 카테고리의 다른 글
[Outstagram] nGrinder를 활용한 부하 테스트 후 성능 튜닝 과정 (0) | 2024.12.12 |
---|---|
[Outstagram] Redis에서도 동시성 이슈가 발생한다고...? (lua script 적용기) (0) | 2024.12.12 |
[Outstagram] 왜 같은 클래스에서 @Cacheable 달린 메서드 호출하면 씹힐까... (1) | 2024.12.12 |
[Outstagram] kafka를 활용한 피드 push model 구현 과정 (0) | 2024.12.12 |
[Outstagram] 무한 스크롤 구현하려다 Snowflake ID 도입한 이야기 (0) | 2024.12.12 |