Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ dependencies {

// 액추에이터 추가
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// 썸네일 라이브러리
implementation 'net.coobird:thumbnailator:0.4.20'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ public record InnerPhotoMarker(
Long id,
LocalDateTime capturedDt,
String filePath,
double lat,
double lng
String thumbnailPath,
Double lat,
Double lng
) {

public static InnerPhotoMarker from(Photo photo) {
return new InnerPhotoMarker(
photo.getId(),
photo.getCapturedDt(),
photo.getFilePath(),
photo.getLocation().getY(),
photo.getLocation().getX()
photo.getThumbnailPath(),
photo.getLongitude(),
photo.getLatitude()
Comment on lines +47 to +49
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사진조회 응답 필드로 썸네일 경로를 추가했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A

현재 지도 사진 마커 조회 API 에서 filePath (원본 이미지 파일), thumbnailPath (리사이징된 이미지 파일) 을 모두 반환하고 있네요.

클라이언트에서 지도 사진 마커 조회를 요청했을 때는 썸네일 이미지만 반환해주고,
클라이언트가 썸네일 이미지를 클릭하면 그 때 원본 이미지를 반환해주는 것은 어떻게 생각하시나요?

어차피 이미지 파일 URL 만 보내주는 것이라 큰 상관은 없을 것이라 생각되지만 고민이 되는 부분이네요:)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클라이언트가 썸네일 이미지를 클릭하고 원본 이미지를 반환 받기 위해 사진 마커 조회 API에서 썸네일 이미지 url을 같이 반환하는 것이 적절하다고 생각했습니다.

);
}
}
Expand All @@ -67,16 +69,20 @@ public static InnerPoiMarkers from(List<Photo> photos) {
public record InnerPoiMarker(
Long id,
LocalDateTime capturedDt,
double lat,
double lng
String filePath,
String thumbnailPath,
Double lat,
Double lng
) {

public static InnerPoiMarker from(Photo photo) {
return new InnerPoiMarker(
photo.getId(),
photo.getCapturedDt(),
photo.getLocation().getY(),
photo.getLocation().getX()
photo.getFilePath(),
photo.getThumbnailPath(),
photo.getLongitude(),
photo.getLatitude()
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,21 @@ public static InnerPageInfo from(Page<Photo> page) {
public record InnerPhotoResponse(
Long id,
String filePath,
String thumbnailPath,
LocalDateTime capturedDt,
Double lat,
Double lng,
Long userId
) {

public static InnerPhotoResponse from(Photo photo) {
Optional<Point> location = Optional.ofNullable(photo.getLocation());
return new InnerPhotoResponse(
photo.getId(),
photo.getFilePath(),
photo.getThumbnailPath(),
photo.getCapturedDt(),
location.map(Point::getY).orElse(null),
location.map(Point::getX).orElse(null),
photo.getLongitude(),
photo.getLatitude(),
Comment on lines -60 to +62
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NPE 방지 로직을 photo 도메인 모델로 캡슐화했습니다.

photo.getUser().getId());
}
}
Expand Down
70 changes: 60 additions & 10 deletions src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package kr.kro.photoliner.domain.photo.infra;

import static kr.kro.photoliner.global.code.ApiResponseCode.DIRECTORY_CREATION_FAILED;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
Expand All @@ -10,31 +12,79 @@
import java.util.UUID;
import kr.kro.photoliner.global.code.ApiResponseCode;
import kr.kro.photoliner.global.exception.CustomException;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Component
public class FileStorage {

private final Path uploadLocation;
private final Path originalLocation;
private final Path thumbnailLocation;

private static final String ORIGINAL_DIR = "original";
private static final String THUMBNAIL_DIR = "thumbnail";
private static final String BASE_IMAGES_DIR = "/images";
Comment on lines +26 to +28
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사진 저장 경로 하위로 original, thumbnail 폴더를 생성합니다.
원본, 썸네일 이미지 각각 같은 이름으로 나누어 저장합니다.

Copy link
Contributor Author

@kih1015 kih1015 Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BASE_IMAGES_DIR 클라이언트에서 접근하는 기본 경로입니다.

실제 저장 경로와 다릅니다.


private static final int THUMBNAIL_WIDTH = 300;
private static final int THUMBNAIL_HEIGHT = 300;

public FileStorage(@Value("${photo.upload.base-dir}") String baseDir) {
this.uploadLocation = Paths.get(baseDir)
.toAbsolutePath()
.normalize();
Path rootLocation = Paths.get(baseDir).toAbsolutePath().normalize();
this.originalLocation = rootLocation.resolve(ORIGINAL_DIR);
this.thumbnailLocation = rootLocation.resolve(THUMBNAIL_DIR);

try {
Files.createDirectories(this.uploadLocation);
Files.createDirectories(this.originalLocation);
Files.createDirectories(this.thumbnailLocation);
} catch (IOException e) {
throw new IllegalArgumentException("Failed to create upload directory", e);
throw CustomException.of(DIRECTORY_CREATION_FAILED);
}
}

public String store(MultipartFile file) {
validateFile(file);
String fileName = generateFileName(file);
saveFile(file, fileName);
return fileName;
saveFile(file, this.originalLocation, fileName);
return BASE_IMAGES_DIR + "/" + ORIGINAL_DIR + "/" + fileName;
}

public String storeThumbnail(String originalRelativePath) {
String fileName = Paths.get(originalRelativePath).getFileName().toString();
Path sourceLocation = this.originalLocation.resolve(fileName);
Path targetLocation = this.thumbnailLocation.resolve(fileName);

try {
Thumbnails.of(sourceLocation.toFile())
.size(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
.toFile(targetLocation.toFile());
return BASE_IMAGES_DIR + "/" + THUMBNAIL_DIR + "/" + fileName;
} catch (IOException e) {
throw CustomException.of(ApiResponseCode.FILE_CREATION_FAILED);
}
}

public void deleteOriginalImage(String originalPath) {
String fileName = Paths.get(originalPath).getFileName().toString();
Path sourceLocation = this.originalLocation.resolve(fileName);

try {
Files.delete(sourceLocation);
} catch (IOException e) {
throw CustomException.of(ApiResponseCode.FILE_DELETE_FAILED);
}
}

public void deleteThumbnailImage(String thumbnailPath) {
String fileName = Paths.get(thumbnailPath).getFileName().toString();
Path sourceLocation = this.thumbnailLocation.resolve(fileName);

try {
Files.delete(sourceLocation);
} catch (IOException e) {
throw CustomException.of(ApiResponseCode.FILE_DELETE_FAILED);
}
Comment on lines +68 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileStorage 파일 삭제 API를 추가했습니다.

}

private void validateFile(MultipartFile file) {
Expand All @@ -53,9 +103,9 @@ private String generateFileName(MultipartFile file) {
return UUID.randomUUID() + "." + extension;
}

private void saveFile(MultipartFile file, String fileName) {
private void saveFile(MultipartFile file, Path directory, String fileName) {
try {
Path targetLocation = uploadLocation.resolve(fileName);
Path targetLocation = directory.resolve(fileName);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING);
}
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import kr.kro.photoliner.common.model.BaseEntity;
import kr.kro.photoliner.domain.user.model.User;
Expand Down Expand Up @@ -42,6 +43,10 @@ public class Photo extends BaseEntity {
@Column(name = "file_path", nullable = false)
private String filePath;

@NotNull
@Column(name = "thumbnail_path", nullable = false)
private String thumbnailPath;

@Column(name = "captured_dt")
private LocalDateTime capturedDt;

Expand All @@ -66,4 +71,18 @@ public void updateCapturedDate(LocalDateTime capturedDt) {
public void updateLocation(Point location) {
this.location = location;
}

public Double getLatitude() {
if (Objects.isNull(location)) {
return null;
}
return location.getX();
}

public Double getLongitude() {
if (Objects.isNull(location)) {
return null;
}
return location.getY();
}
Comment on lines +74 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위치가 존재하지 않을 경우 npe 가능성이 충분해서 캡슐화했습니다.

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package kr.kro.photoliner.domain.photo.service;

import java.util.List;
import kr.kro.photoliner.domain.photo.dto.DeletePhotosRequest;
import kr.kro.photoliner.domain.photo.dto.request.MapMarkersRequest;
import kr.kro.photoliner.domain.photo.dto.request.PhotoCapturedDateUpdateRequest;
import kr.kro.photoliner.domain.photo.dto.request.PhotoLocationUpdateRequest;
import kr.kro.photoliner.domain.photo.dto.response.MapMarkersResponse;
import kr.kro.photoliner.domain.photo.dto.response.PhotosResponse;
import kr.kro.photoliner.domain.photo.infra.FileStorage;
import kr.kro.photoliner.domain.photo.model.AlbumPhotos;
import kr.kro.photoliner.domain.photo.model.Photo;
import kr.kro.photoliner.domain.photo.model.Photos;
Expand All @@ -28,6 +30,7 @@ public class PhotoService {
private final AlbumPhotoRepository albumPhotoRepository;
private final PhotoRepository photoRepository;
private final GeometryFactory geometryFactory;
private final FileStorage fileStorage;

@Transactional(readOnly = true)
public PhotosResponse getPhotos(Long userId, Pageable pageable) {
Expand Down Expand Up @@ -72,6 +75,9 @@ public void updatePhotoLocation(Long photoId, PhotoLocationUpdateRequest request

@Transactional
public void deletePhotos(DeletePhotosRequest request) {
List<Photo> photos = photoRepository.findAllById(request.ids());
photos.forEach(photo -> fileStorage.deleteOriginalImage(photo.getFilePath()));
photos.forEach(photo -> fileStorage.deleteThumbnailImage(photo.getFilePath()));
photoRepository.deleteAllByIdInBatch(request.ids());
}
Comment on lines 76 to 82
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사진 삭제 시 실제 이미지도 함께 삭제합니다.

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,27 @@ public class PhotoUploadService {

@Transactional
public PhotoUploadResponse uploadPhotos(Long userId, List<MultipartFile> files) {
User user = userRepository.findUserById(userId).orElseThrow(
() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId));
List<InnerUploadedPhotoInfo> uploadedPhotos = files.stream()
.map(file -> {
ExifData exifData = exifExtractor.extract(file);
String filePath = fileStorage.store(file);
String thumbnailPath = fileStorage.storeThumbnail(filePath);
String fileName = file.getOriginalFilename();
User user = userRepository.findUserById(userId)
.orElseThrow(
() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId));
Photo photo = createPhoto(user, exifData, filePath, fileName);
Photo photo = createPhoto(user, exifData, fileName, filePath, thumbnailPath);
Photo savedPhoto = photoRepository.save(photo);
return InnerUploadedPhotoInfo.from(savedPhoto);
})
.toList();
return PhotoUploadResponse.from(uploadedPhotos);
}

private Photo createPhoto(User user, ExifData exifData, String fileName, String filePath) {
private Photo createPhoto(User user, ExifData exifData, String fileName, String filePath, String thumbnailPath) {
return Photo.builder()
.fileName(fileName)
.filePath(filePath)
.thumbnailPath(thumbnailPath)
.capturedDt(exifData.capturedDt())
.location(exifData.location())
.user(user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public enum ApiResponseCode {
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 오류가 발생했습니다."),
FILE_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 처리 중 오류가 발생했습니다."),
FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다.");
FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다."),
FILE_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 생성 중 오류가 발생했습니다."),
DIRECTORY_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다."),
FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다.")
;
Comment on lines +62 to +66
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A

디테일한 오류 코드 정의는 정말 명확하고 좋네요:)


private final HttpStatus httpStatus;
private final String message;
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/db/migration/V5__alter_photo_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table photos
add column thumbnail_path text not null
Comment on lines +1 to +2
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

photos 테이블에 썸네일 경로 컬럼을 추가했습니다.

Loading