이전 진행 상황
- PostService 추가
- Post 엔티티 메서드 추가
- PostRepository 메서드 추가
- PostQueryRepository 인터페이스 추가
- 커스텀 예외 추가 - Post
- Comment
- Photo
- Post
Comment
CommentService가 개발되며 usecase 수정과 커스텀 예외가 추가되었습니다.
변경 사항
- usecase createComt -> 파라미터 추가
- Comment 엔티티 메서드 추가
추가 사항
- CommentService 추가
- 커스텀 예외 추가 Comment
변경 내용
interface CommentCrudUseCase - 수정
createComt에 대댓글 구현과 게시글과의 외래키 연결을 위한 파라미터가 추가되었습니다.
public interface CommentCrudUseCase {
Comment createComt(Comment comt, Long parentId, Long postId);
Comment updateComt(Long comtId, CommentUpdateDTO dto);
void deleteComt(Long comtId);
List<Comment> viewComts(Long postId);
}
class Comment - 수정
자식 댓글 리스트와 대댓글, 게시글과의 외래키 연결을 위한 setter가 추가되었습니다.
@OneToMany(mappedBy = "parentComment")
private List<Comment> childComments = new ArrayList<>();
public void setParentComment(Comment parentComment) {
this.parentComment = parentComment;
}
public void setPost(Post post) {
this.post = post;
}
추가 내용
class CommentService - 추가
CommentService가 개발되었습니다. 댓글을 생성하는 createComt의 경우 게시글과의 연결을 시켜준 뒤 해당 댓글이 새로 생성되는 댓글인지 부모 댓글이 존재하는 대댓글인지 체크를 한 후 대댓글인 경우 부모 댓글과 연결한 뒤 저장하게 됩니다.
@Service
@RequiredArgsConstructor
public class CommentService implements CommentCrudUseCase {
private final CommentRepository repository;
private final PostRepository postRepository;
@Override
@Transactional
public Comment createComt(Comment comt, Long parentId, Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
comt.setPost(post);
if (parentId != null) {
Comment parentComment = repository.findById(parentId)
.orElseThrow(() -> new CommentNotFoundException(parentId));
comt.setParentComment(parentComment);
}
return repository.save(comt);
}
@Override
@Transactional
public Comment updateComt(Long comtId, CommentUpdateDTO dto) {
Comment comt = repository.findById(comtId)
.orElseThrow(() -> new CommentNotFoundException(comtId));
comt.modify(dto);
return comt;
}
@Override
@Transactional
public void deleteComt(Long comtId) {
repository.deleteById(comtId);
}
@Override
@Transactional(readOnly = true)
public List<Comment> viewComts(Long postId) {
return repository.findByPostId(postId);
}
}
class CommentNotFoundException - 추가
댓글을 찾지 못할 때 발생하는 커스텀 예외입니다. 예외 코드로 C001을 가집니다.
public class CommentNotFoundException extends BaseException {
public CommentNotFoundException(Long comtId) {
super("Comment not found with id: " + comtId, "C001");
}
}
Photo
이미지 업로드 방식이 결정되며 엔티티 구조부터 많은 수정이 진행되었습니다. 이미지 업로드 방식을 본문 내 이미지 경로를 삽입하는 방식을 사용하게 구현되었습니다.
변경 사항
- Photo 엔티티 구조 수정 -> 원본 이름, 저장 이름 필드 추가
- usecase 수정 -> 임시 저장 메서드 추가, 임시 저장 파일 삭제 메서드 추가
추가 사항
- PhotoService 추가
- WebConfig 추가 -> global
- 커스텀 예외 추가
변경 내용
class Photo - 수정
기존에 존재하던 photoName 필드가 원본 파일 이름, 저장된 파일 이름 필드로 분리되었습니다.
@Column(name = "saved_photo_name", nullable = false)
private String savedPhotoName;
@Column(name = "original_photo_name", nullable = false)
private String originalPhotoName;
interface PhotoCrudUseCase - 수정
본문에 이미지 경로를 저장하는 방식으로 변경함에 따라 임시 저장 후 경로를 반환하는 메서드가 추가되었습니다. 임시 저장되었던 파일들을 제거하는 메서드가 추가되었습니다.
public interface PhotoCrudUseCase {
String tempUploadPhoto(MultipartFile file, String sessionId);
List<Photo> uploadPhoto(List<MultipartFile> photos, Long postId, String sessionId);
void deletePhoto(Long photoId);
List<Photo> viewPhotos(Long postId);
void cleanupTempFiles();
}
추가 내용
class PhotoService - 추가
PhotoService의 경우 로직이 길어 나눴습니다. 먼저 존재하는 필드들과 tempUpload 메서드입니다. 이미지가 저장될 로컬 PC 저장소 경로와 임시 파일을 나타내기 위한 접두사를 상수로 선언하였습니다. 저장 경로의 경우 단위 테스트에서 경로 변경을 위해 기본 필드가 같이 선언되어 있는 상태입니다. 임시 파일 구분을 위해 Map을 사용해 임시 파일 이름을 저장해 주었습니다. 동시성 문제를 위해 ConcurrentHashMap을 사용했고 넘겨받은 sessionId를 기준으로 구분하게 됩니다.
@Service
@RequiredArgsConstructor
public class PhotoService implements PhotoCrudUseCase {
private final PhotoRepository repository;
private final PostRepository postRepository;
// private static final String UPLOAD_PATH = "D:\\project\\images\\";
private String UPLOAD_PATH = "D:\\project\\images\\";
private static final String TEMP_PREFIX = "temp_";
private final Map<String, Set<String>> tempFileMap = new ConcurrentHashMap<>();
/**
* 실행 순서
* 이미지 추가 요청 -> tempUploadPhoto 실행 -> 본문에 이미지 경로 반환 ->
* 게시글 등록 요청 -> createPost 실행 -> uploadPhoto 실행
*/
@Override
public String tempUploadPhoto(MultipartFile file, String sessionId) {
String originalFilename = file.getOriginalFilename();
String tempFileName = TEMP_PREFIX + UUID.randomUUID() + getExtension(originalFilename);
try {
File savedFile = new File(UPLOAD_PATH + tempFileName);
file.transferTo(savedFile);
// 임시 파일명과 원본 파일명 매핑 저장
tempFileMap.computeIfAbsent(sessionId, k -> new HashSet<>()).add(tempFileName);
return "/images/" + tempFileName;
} catch (IOException e) {
throw new FileUploadException("Failed to upload temp file: " + originalFilename);
}
}
임시 파일은 접두사 + UUID + 확장자 형식으로 생성되고 저장 경로와 함께 파일을 생성한 뒤 넘겨받은 이미지를 옮겨 줍니다. map에 sessionId를 기준으로 임시 저장 파일들을 관리하게 되는데 현재 사용자는 한 번에 한 개의 게시글을 등록할 수 있고 게시글 임시 저장과 같은 기능이 존재하지 않기 때문에 sessionId를 기준으로 파일을 임시 저장하고 업로드가 끝난 뒤 해당 sessionId에 저장된 임시 파일을 제거하는 방식으로 구현했습니다. 정상 수행이 끝난다면 웹에서 접근 가능한 임시 파일 경로를 반환하게 됩니다.
@Override
@Transactional
public List<Photo> uploadPhoto(Long postId, String sessionId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
List<Photo> savedPhotos = new ArrayList<>();
Map<String, String> pathMap = new HashMap<>();
String content = post.getContent();
// 본문에서 임시 파일명들 추출
Set<String> tempFileNames = tempFileMap.get(sessionId);
if (tempFileNames != null) {
for (String tempFileName : tempFileNames) {
if (content.contains(tempFileName)) {
String newFileName = String.format("%d_%s%s",
postId, UUID.randomUUID(), getExtension(tempFileName));
try {
// 1. 임시 파일을 정식 파일로 이동
File tempFile = new File(UPLOAD_PATH + tempFileName);
File newFile = new File(UPLOAD_PATH + newFileName);
if (tempFile.exists()) {
tempFile.renameTo(newFile);
}
// 2. Photo 엔티티 생성 및 저장
Photo photo = Photo.builder()
.post(post)
.originalPhotoName(tempFileName)
.savedPhotoName(newFileName)
.photoPath("/images/" + newFileName)
.build();
savedPhotos.add(repository.save(photo));
// 3. 경로 매핑 저장
pathMap.put("/images/" + tempFileName, "/images/" + newFileName);
} catch (Exception e) {
throw new FileUploadException("Failed to process file: " + tempFileName);
}
}
}
}
// 4. 본문의 이미지 경로 업데이트
for (Map.Entry<String, String> entry : pathMap.entrySet()) {
content = content.replace(entry.getKey(), entry.getValue());
}
post.updateContent(content);
// 5. 처리된 임시 파일 정보 삭제
tempFileMap.remove(sessionId);
return savedPhotos;
}
임시 저장되었던 이미지를 데이터베이스에 저장하는 메서드입니다. sessionId를 통해 tempFileMap에서 해당 임시 파일들을 가져옵니다. 게시글의 본문에서 적혀있는 임시 파일 경로를 찾아 postId + UUID + 확장자 형태의 이름으로 변경해 줍니다. 그리고 기존의 존재하던 임시 파일을 새로운 파일 이름으로 변경해 주고 Photo 엔티티를 데이터베이스에 저장합니다. 그리고 본문 내용에 있는 경로를 변경하기 위해 pathMap에 저장해 줍니다. 모든 이미지에 대한 작업이 끝나면 pathMap에 저장된 값들을 통해 본문 내용에 있는 경로를 변경하고 tempFileMap에 해당 sessionId로 저장되어 있던 임시 파일 이름을 제거해 줍니다.
private String getExtension(String originalFilename) {
if (originalFilename == null || originalFilename.lastIndexOf(".") == -1) {
throw new IllegalArgumentException("Invalid file name: " + originalFilename);
}
return originalFilename.substring(originalFilename.lastIndexOf("."));
}
@Override
@Transactional
public void deletePhoto(Long photoId) {
repository.deleteById(photoId);
}
@Override
@Transactional(readOnly = true)
public List<Photo> viewPhotos(Long postId) {
return repository.findByPostId(postId);
}
@Override
@Scheduled(cron = "0 0 1 * * ?") // 매일 새벽 1시에 실행
public void cleanupTempFiles() {
File directory = new File(UPLOAD_PATH);
File[] tempFiles = directory.listFiles((dir, name) -> name.startsWith(TEMP_PREFIX));
if (tempFiles != null) {
for (File file : tempFiles) {
if (file.lastModified() < System.currentTimeMillis() - 24 * 60 * 60 * 1000) {
file.delete();
}
}
}
}
}
나머지 확장자를 얻는 메서드와 삭제, 조회, 그리고 매일 실행되는 저장되어 있던 임시 파일을 삭제하는 메서드입니다.
class WebConfig - 추가
WebConfig는 웹 설정에 관한 클래스로 현재 본문 내용에 있는 파일 경로 접근 시 실제 로컬 PC 경로로 접근할 수 있게 해주는 핸들러가 정의되어 있습니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:///D:/project/images/");
}
}
class FileUploadException - 추가
이미지 업로드 실패 시 발생하는 예외입니다. Post와 중복을 피하기 위해 예외 코드로 F를 가집니다.
public class FileUploadException extends BaseException {
public FileUploadException(String message) {
super(message, "F001");
}
}
Post
게시글의 추천, 비추천 기능을 세션 단위로 제한하기 위해 Redis를 통해 추천, 비추천 기록을 저장하도록 변경했습니다.
변경 사항
- Redis 추가
- 추천, 비추천 세션 단위로 제한 -> 24시간
class PostService - 수정
Redis를 통해 추천에 관한 키를 저장하게 됩니다. 회원이 추천을 하는 경우 postId와 userId의 조합으로 키를 만들고 비회원인 경우 postId와 sessionId의 조합으로 키를 만든 뒤 추천 여부를 저장하게 됩니다. 이미 추천한 경우 커스텀 예외를 발생시키고 추천 하지 않은 경우 Redis에 추천 이력을 저장합니다. 추천 이력은 24시간이 지나면 자동으로 삭제 됩니다.
private final RedisTemplate<String, String> redisTemplate;
private static final long RECOMMEND_EXPIRE_TIME = 24 * 60 * 60;
@Override
@Transactional
public void recommendPost(Long postId, Long userId, String sessionId) {
String recommendKey = userId != null ?
getRecommendKey(postId, String.valueOf(userId)) :
getRecommendKey(postId, sessionId);
String notRecommendKey = userId != null ?
getNotRecommendKey(postId, String.valueOf(userId)) :
getNotRecommendKey(postId, sessionId);
// 이미 추천했는지 확인
if (Boolean.TRUE.equals(redisTemplate.hasKey(recommendKey))) {
throw new AlreadyRecommendedException("이미 추천한 게시글입니다.");
}
// 비추천 여부 확인
if (Boolean.TRUE.equals(redisTemplate.hasKey(notRecommendKey))) {
throw new AlreadyNotRecommendedException("이미 비추천한 게시글입니다.");
}
Post post = repository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
post.recommendPost();
// Redis에 추천 이력 저장
redisTemplate.opsForValue().set(recommendKey, "true", RECOMMEND_EXPIRE_TIME, TimeUnit.SECONDS);
}
@Override
@Transactional
public void notRecommendPost(Long postId, Long userId, String sessionId) {
String recommendKey = userId != null ?
getRecommendKey(postId, "user" + userId) :
getRecommendKey(postId, sessionId);
String notRecommendKey = userId != null ?
getNotRecommendKey(postId, "user" + userId) :
getNotRecommendKey(postId, sessionId);
// 이미 추천했는지 확인
if (Boolean.TRUE.equals(redisTemplate.hasKey(recommendKey))) {
throw new AlreadyRecommendedException("이미 추천한 게시글입니다.");
}
if (Boolean.TRUE.equals(redisTemplate.hasKey(notRecommendKey))) {
throw new AlreadyNotRecommendedException("이미 비추천한 게시글입니다.");
}
Post post = repository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
post.notRecommendPost();
redisTemplate.opsForValue().set(notRecommendKey, "true", RECOMMEND_EXPIRE_TIME, TimeUnit.SECONDS);
}
private String getRecommendKey(Long postId, String sessionId) {
return String.format("recommend:%s:%d", sessionId, postId);
}
private String getNotRecommendKey(Long postId, String sessionId) {
return String.format("notRecommend:%s:%d", sessionId, postId);
}
추천과 비추천을 동시에 할 수 없게 양쪽에서 체크하게 됩니다.
Exception - 추가
이미 추천과 비추천을 한 경우 발생하는 커스텀 예외입니다.
public class AlreadyRecommendedException extends BaseException {
public AlreadyRecommendedException(String message) {
super(message, "P002");
}
}
public class AlreadyNotRecommendedException extends BaseException {
public AlreadyNotRecommendedException(String message) {
super(message, "P003");
}
}
class RedisConfig - 추가
Redis에 관한 설정이 정의된 클래스입니다.
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
'개인 프로젝트 > Toy & Side' 카테고리의 다른 글
[TOY] 개발 - Adapters(User) (0) | 2024.12.03 |
---|---|
[TOY] 개발 - 단위 테스트(domain, service) (2) | 2024.12.02 |
[TOY] 개발 - Application(PostService) (1) | 2024.11.30 |
[TOY] 개발 - Application(UserService) (0) | 2024.11.30 |
[TOY] 개발 - UseCase & DTO (1) | 2024.11.29 |