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
24 changes: 20 additions & 4 deletions docs/API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1639,8 +1639,8 @@ Provider 응답의 `distanceMeters`가 누락되거나 0 이하이면 서버는
{
"content": "광안리 야경 보러 갔는데 날씨가 좋았어요 #야경 #맛집",
"mediaList": [
{ "url": "https://example.com/media/1.jpg", "mediaType": "IMAGE", "sortOrder": 0 },
{ "url": "https://example.com/media/2.jpg", "mediaType": "IMAGE", "sortOrder": 1 }
{ "url": "https://example.com/media/1.jpg", "thumbnailUrl": "https://example.com/media/1_thumb.jpg", "mediaType": "IMAGE", "sortOrder": 0 },
{ "url": "https://example.com/media/2.jpg", "thumbnailUrl": "https://example.com/media/2_thumb.jpg", "mediaType": "IMAGE", "sortOrder": 1 }
],
"categories": ["야경", "맛집"]
}
Expand All @@ -1651,6 +1651,7 @@ Provider 응답의 `distanceMeters`가 누락되거나 0 이하이면 서버는
| `content` | O | 본문. **최대 2000자.** 카테고리는 본문이 아니라 `categories` 로 보낸다 |
| `mediaList` | O | **한 건 이상 열 건 이하.** 사진·영상 기반 서비스라 빈 게시물을 허용하지 않는다 |
| `mediaList[].url` | O | 최대 2048자 |
| `mediaList[].thumbnailUrl` | X | 업로드 응답의 `thumbnailUrl` 을 그대로 넣는다. 목록 화면이 쓴다. 생략하면 목록도 원본을 쓴다 |
| `mediaList[].mediaType` | O | `IMAGE`, `VIDEO` 둘 중 하나. 다른 값은 `MALFORMED_REQUEST` |
| `mediaList[].sortOrder` | O | 표시 순서. 0 이상 |
| `mediaList[].placeId` | X | 이 사진에서 다녀온 장소. 붙이지 않으려면 생략한다 |
Expand All @@ -1666,6 +1667,9 @@ Provider 응답의 `distanceMeters`가 누락되거나 0 이하이면 서버는
없으면 수 MB 본문이 저장되고 그 글이 실린 피드 응답이 전부 부풀어 오른다. 화면에서 긴 본문을
접는 처리는 클라이언트가 한다. 서버는 항상 본문 전체를 보낸다.

**`thumbnailUrl` 도 업로드 응답의 값을 그대로 넘긴다.** 서버가 다시 만들지 않는다. 업로드
때 이미 만들어 두었고, 원본에서 규칙으로 유추하면 사본이 없는 사진에서 깨진 이미지가 된다.

**`url` 은 `POST /api/v1/media` 가 돌려준 값을 그대로 넣는다.** 업로드와 게시물 작성은
호출이 나뉘어 있다. 사용자가 사진을 고른 직후 올려 두면, 글을 쓰는 동안 업로드가 끝나 있어
작성 버튼을 눌렀을 때 기다리지 않는다. 자세한 계약은 `C-17`을 본다.
Expand Down Expand Up @@ -2559,8 +2563,8 @@ iOS 는 홈 화면 추가가 전제다. 알림이 몇십 초 늦게 뜨는 것

`POST /api/v1/media`

게시물을 만들기 전에 파일을 먼저 올린다. 응답의 `url` 과 `mediaType` 을 `C-2` 의
`mediaList` 에 그대로 넣는다.
게시물을 만들기 전에 파일을 먼저 올린다. 응답의 `url`, `thumbnailUrl`, `mediaType` 을
`C-2` 의 `mediaList` 에 그대로 넣는다.

```
Content-Type: multipart/form-data
Expand All @@ -2582,6 +2586,7 @@ files: (파일)
"mediaList": [
{
"url": "https://smbdh-community-media-372641920957-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/posts/2026/08/9f2c....jpg",
"thumbnailUrl": "https://smbdh-community-media-372641920957-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/posts/2026/08/9f2c..._thumb.jpg",
"mediaType": "IMAGE"
}
]
Expand All @@ -2591,6 +2596,14 @@ files: (파일)
응답의 `mediaList` 는 **보낸 순서 그대로**다. 클라이언트가 정렬을 다시 맞출 필요가 없고,
`sortOrder` 는 이 순서를 그대로 쓰면 된다.

**목록용 축소본을 함께 만든다.** 사진은 긴 변 480px JPEG 사본을 원본 옆에
`..._thumb.jpg` 로 올리고 그 주소를 `thumbnailUrl` 로 준다. 휴대폰 사진 한 장이 수백
KB~수 MB 라, 스무 건을 한 번에 부르는 목록이 원본을 쓰면 첫 화면에서만 10MB 가까이 내려간다.

`thumbnailUrl` 이 `null` 인 경우는 세 가지다. 영상, ImageIO 가 읽지 못하는 `webp`,
그리고 이미 480px 보다 작은 사진이다. **오류가 아니다.** 이때 화면은 원본을 쓴다.
축소에 실패해도 업로드는 성공한다. 사본 하나 때문에 올린 사진을 전부 되돌리지 않는다.

**하나라도 규칙에 어긋나면 아무것도 올라가지 않는다.** 절반만 성공하면 클라이언트가 어디까지
됐는지 알 수 없고, 쓰이지 않는 파일이 저장소에 남는다.

Expand All @@ -2600,6 +2613,9 @@ files: (파일)
**올린 파일 이름은 쓰지 않는다.** 저장 경로는 `posts/연도/월/임의값.확장자` 다. 같은 이름이
서로를 덮어쓰는 것과, 한글·공백이 섞인 이름으로 URL 이 깨지는 것을 막는다.

**축소본은 원본과 함께 지워진다.** 게시물이 완전히 지워질 때도, 글에 붙지 않은 파일을
정리할 때도 두 객체를 같이 다룬다.

**지운 게시물의 파일은 30일 뒤 함께 사라진다.** 삭제한 게시물이 복구 기한을 넘겨 완전히
지워질 때 저장소의 파일도 지운다. 그전까지는 복구할 수 있어야 하므로 남겨 둔다.

Expand Down
9 changes: 8 additions & 1 deletion docs/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,16 @@ DB에서 직접 증감시킨다. 동시에 들어온 요청이 같은 값을 읽
| `post_id` | bigint | FK, O | 소속 `posts.id` |
| `media_type` | varchar | O | `IMAGE`, `VIDEO` 둘 중 하나. 애플리케이션의 열거형으로 고정한다 |
| `url` | text | O | 업로드된 파일 URL |
| `thumbnail_url` | text | X | 목록용 축소본 URL. 업로드 때 원본과 함께 만든다 |
| `sort_order` | integer | O | 게시물 안의 표시 순서 |

파일 업로드는 서버가 하지 않는다. 클라이언트가 저장소에 올린 뒤 URL만 전달한다.
파일 업로드는 `POST /api/v1/media` 가 받아 S3 에 올리고 URL 만 이 표에 남는다.

**`thumbnail_url` 은 비어 있을 수 있다.** 영상, ImageIO 가 읽지 못하는 `webp`, 이미 작은
사진, 그리고 이 열이 생긴 `V22` 이전에 저장된 사진이다. 화면은 이때 `url` 을 쓴다.

축소본은 저장소에 따로 놓인 객체다. 게시물을 완전히 지울 때와 글에 붙지 않은 파일을 정리할
때 두 주소를 함께 다룬다. 한쪽만 보면 쓰이는 사본을 지우거나 버려진 사본이 남는다.
**저장소 선정과 업로드 API는 미정이다.**

인덱스: `idx_post_media_post_id`
Expand Down
9 changes: 9 additions & 0 deletions docs/api-change-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ API 계약이 변경될 때마다 최신 항목을 위에 추가한다.
- 이유: 목록 응답마다 따라다니는 필드라 상한이 없으면 응답 크기를 한 사용자가 좌우한다. `size` 는 문서에 이미 "1 이상 50 이하, 넘기면 400" 이라고 적혀 있었고 이 엔드포인트만 지키지 않았다.
- 호환성 파괴: 없음. 정상 범위의 요청은 그대로 동작한다.
- DB/ERD: 변경 없음
## 2026-09-16 (목록용 축소본 추가)

- API: `POST /api/v1/media`, `POST /api/v1/posts`, `PATCH /api/v1/posts/{postId}`, `GET /api/v1/posts/{postId}`
- 구분: 추가 (응답·요청 필드)
- 이전: 목록의 `thumbnailUrl` 이 첫 사진의 **원본** 주소였다. 휴대폰 사진이 장당 0.7MB 라 피드 첫 화면에서 10MB 가까이 내려받았다.
- 이후: 업로드가 긴 변 480px JPEG 사본을 함께 만들어 `thumbnailUrl` 로 주고, 게시물 작성·수정이 그 값을 받아 저장한다. 목록 응답은 저장된 사본을 쓰고, 없으면 원본으로 돌아간다. 게시물 상세의 `mediaList[]` 에도 `thumbnailUrl` 이 함께 나간다.
- 이유: 목록에 실제로 그려지는 크기는 한 변 200px 안팎이라 원본 화질이 필요 없다.
- 호환성 파괴: 없음. 요청 필드는 선택이고, 예전 사진은 원본으로 돌아간다.
- DB/ERD: `post_media.thumbnail_url` 추가 (V22)

## 2026-09-16 (즉흥여행 코스 실패 안내 구체화)

Expand Down
11 changes: 9 additions & 2 deletions src/main/java/com/server/media/dto/MediaUploadResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@
import io.swagger.v3.oas.annotations.media.Schema;

/**
* @param url 게시물 작성 요청의 {@code mediaList[].url} 에 그대로 넣는다
* @param mediaType 같은 요청의 {@code mediaList[].mediaType} 에 그대로 넣는다
* @param url 게시물 작성 요청의 {@code mediaList[].url} 에 그대로 넣는다
* @param thumbnailUrl 같은 요청의 {@code mediaList[].thumbnailUrl} 에 그대로 넣는다.
* 목록 화면이 쓰는 작은 사본이며, 만들지 못했으면 {@code null} 이다
* @param mediaType 같은 요청의 {@code mediaList[].mediaType} 에 그대로 넣는다
*/
@Schema(name = "MediaUpload", description = "업로드된 파일 한 건")
public record MediaUploadResponse(
@Schema(description = "공개 URL", example =
"https://smbdh-community-media.s3.ap-northeast-2.amazonaws.com/posts/2026/08/a1b2.jpg")
String url,

@Schema(description = "목록용 축소본 URL. 만들지 못했으면 null 이고, 이때 화면은 원본을 쓴다.",
example =
"https://smbdh-community-media.s3.ap-northeast-2.amazonaws.com/posts/2026/08/a1b2_thumb.jpg")
String thumbnailUrl,

@Schema(description = "파일 내용으로 판별한 종류", example = "IMAGE")
MediaType mediaType
) {
Expand Down
51 changes: 48 additions & 3 deletions src/main/java/com/server/media/service/MediaService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import com.server.media.domain.MediaFormat;
import com.server.media.dto.MediaUploadListResponse;
import com.server.media.dto.MediaUploadResponse;
import com.server.post.domain.MediaType;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -30,10 +33,16 @@ public class MediaService {

private final ObjectProvider<MediaStorage> storageProvider;
private final MediaProperties properties;
private final ThumbnailGenerator thumbnailGenerator;

public MediaService(ObjectProvider<MediaStorage> storageProvider, MediaProperties properties) {
public MediaService(
ObjectProvider<MediaStorage> storageProvider,
MediaProperties properties,
ThumbnailGenerator thumbnailGenerator
) {
this.storageProvider = storageProvider;
this.properties = properties;
this.thumbnailGenerator = thumbnailGenerator;
}

/**
Expand All @@ -50,9 +59,10 @@ public MediaUploadListResponse upload(Long userId, List<MultipartFile> files) {
for (int index = 0; index < files.size(); index++) {
MultipartFile file = files.get(index);
MediaFormat format = formats.get(index);
String key = key(format);
try (InputStream content = file.getInputStream()) {
String url = storage.upload(key(format), format.getContentType(), content, file.getSize());
uploaded.add(new MediaUploadResponse(url, format.getMediaType()));
String url = storage.upload(key, format.getContentType(), content, file.getSize());
uploaded.add(new MediaUploadResponse(url, thumbnailUrl(storage, key, format, file), format.getMediaType()));
} catch (IOException exception) {
throw new BusinessException(ErrorCode.INVALID_MEDIA_FILE, exception);
}
Expand All @@ -62,6 +72,41 @@ public MediaUploadListResponse upload(Long userId, List<MultipartFile> files) {
return new MediaUploadListResponse(List.copyOf(uploaded));
}

/**
* 목록용 축소본을 올린다. 키는 원본 옆에 {@code _thumb.jpg} 로 둔다. 원본에서 규칙으로
* 얻을 수 있어야 나중에 원본을 지울 때 사본도 같이 지울 수 있다.
*
* <p><b>실패해도 업로드를 되돌리지 않는다.</b> 축소본이 없으면 화면이 원본을 쓸 뿐이다.
* 사진을 다 올려놓고 사본 하나 때문에 전부 실패로 돌리는 편이 사용자에게 더 나쁘다.
*
* @return 올린 사본 URL, 만들지 못했으면 {@code null}
*/
private String thumbnailUrl(MediaStorage storage, String key, MediaFormat format, MultipartFile file) {
if (format.getMediaType() != MediaType.IMAGE) {
return null;
}
try {
Optional<byte[]> thumbnail = thumbnailGenerator.generate(file.getBytes());
if (thumbnail.isEmpty()) {
return null;
}
byte[] bytes = thumbnail.get();
return storage.upload(
thumbnailKey(key), ThumbnailGenerator.CONTENT_TYPE,
new ByteArrayInputStream(bytes), bytes.length);
} catch (IOException | RuntimeException exception) {
log.warn("축소본을 올리지 못했다. 원본만 쓴다. key={}", key, exception);
return null;
}
}

/** {@code posts/2026/09/uuid.png} 에서 {@code posts/2026/09/uuid_thumb.jpg} 를 만든다. */
public static String thumbnailKey(String key) {
int dot = key.lastIndexOf('.');
String base = dot < 0 ? key : key.substring(0, dot);
return "%s_thumb.%s".formatted(base, ThumbnailGenerator.EXTENSION);
}

/** 업로드가 꺼진 환경에서도 서버는 떠야 한다. 나머지 위임 계층과 같이 503 으로 알린다. */
private MediaStorage requireStorage() {
MediaStorage storage = storageProvider.getIfAvailable();
Expand Down
117 changes: 117 additions & 0 deletions src/main/java/com/server/media/service/ThumbnailGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.server.media.service;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Iterator;
import java.util.Optional;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/**
* 목록에 쓸 작은 사본을 만든다.
*
* <p>휴대폰으로 찍은 사진은 한 장에 수백 KB~수 MB 다. 커뮤니티 목록은 한 번에 스무 건을
* 불러오므로 원본을 그대로 쓰면 첫 화면에서 10MB 가까이 내려받는다. 목록에 실제로 그려지는
* 크기는 한 변 200px 안팎이라 그만한 화질이 필요 없다.
*
* <p>JPEG 로 저장한다. 투명한 PNG 는 흰 바탕에 얹는다. 목록 카드의 배경이 흰색이라
* 검게 나오는 것보다 낫다. 원본은 지우지 않으므로 상세와 확대 보기는 그대로다.
*
* <p><b>실패해도 업로드를 막지 않는다.</b> ImageIO 가 읽지 못하는 형식(webp)과 영상은
* 빈 값을 주고, 그 경우 화면은 원본을 쓴다.
*/
@Component
public class ThumbnailGenerator {

private static final Logger log = LoggerFactory.getLogger(ThumbnailGenerator.class);

/**
* 긴 변 기준 목표 크기. 목록 카드가 가장 큰 화면에서도 한 변 400px 을 넘지 않고,
* 고해상도 화면을 감안해 그 1.2배를 둔다.
*/
static final int MAX_EDGE = 480;

/** JPEG 품질. 0.8 아래로 내리면 사진에 얼룩이 보이기 시작한다. */
private static final float QUALITY = 0.8f;

static final String CONTENT_TYPE = "image/jpeg";
static final String EXTENSION = "jpg";

/**
* @return 축소한 JPEG. 읽을 수 없는 형식이거나 이미 충분히 작으면 빈 값
*/
public Optional<byte[]> generate(byte[] source) {
if (source == null || source.length == 0) {
return Optional.empty();
}
try {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(source));
if (original == null) {
return Optional.empty();
}
int longestEdge = Math.max(original.getWidth(), original.getHeight());
if (longestEdge <= MAX_EDGE) {
// 이미 작다. 사본을 만들어 봐야 용량이 줄지 않고 객체만 하나 더 생긴다.
return Optional.empty();
}
return Optional.of(encode(resize(original, (double) MAX_EDGE / longestEdge)));
} catch (IOException | RuntimeException exception) {
// OutOfMemoryError 가 아닌 한 업로드는 계속되어야 한다. 원본만 쓰면 그만이다.
log.warn("Thumbnail generation failed. Falling back to the original image.", exception);
return Optional.empty();
}
}

private static BufferedImage resize(BufferedImage original, double ratio) {
int width = Math.max(1, (int) Math.round(original.getWidth() * ratio));
int height = Math.max(1, (int) Math.round(original.getHeight() * ratio));

BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = resized.createGraphics();
try {
graphics.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.setRenderingHint(
RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// JPEG 에는 투명도가 없다. 먼저 흰색으로 덮지 않으면 투명한 부분이 검게 남는다.
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.drawImage(original, 0, 0, width, height, null);
} finally {
graphics.dispose();
}
return resized;
}

private static byte[] encode(BufferedImage image) throws IOException {
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(EXTENSION);
if (!writers.hasNext()) {
throw new IOException("JPEG writer not available");
}
ImageWriter writer = writers.next();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (MemoryCacheImageOutputStream target = new MemoryCacheImageOutputStream(out)) {
writer.setOutput(target);
ImageWriteParam params = writer.getDefaultWriteParam();
if (params.canWriteCompressed()) {
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
params.setCompressionQuality(QUALITY);
}
writer.write(null, new IIOImage(image, null, null), params);
} finally {
writer.dispose();
}
return out.toByteArray();
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/server/post/domain/PostMedia.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@ public class PostMedia {
@Column(nullable = false, columnDefinition = "text")
private String url;

/** 목록용 축소본. 만들지 못한 형식과 이 열이 생기기 전의 사진은 비어 있다. */
@Column(name = "thumbnail_url", columnDefinition = "text")
private String thumbnailUrl;

@Column(name = "sort_order", nullable = false)
private int sortOrder;

protected PostMedia() {
}

public PostMedia(Post post, MediaType mediaType, String url, int sortOrder) {
this(post, mediaType, url, null, sortOrder);
}

public PostMedia(Post post, MediaType mediaType, String url, String thumbnailUrl, int sortOrder) {
this.post = post;
this.mediaType = mediaType;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.sortOrder = sortOrder;
}

Expand All @@ -49,6 +58,16 @@ public String getUrl() {
return url;
}

/** @return 축소본 주소, 없으면 {@code null}. 화면에 그릴 때는 {@link #getListImageUrl()} 을 쓴다 */
public String getThumbnailUrl() {
return thumbnailUrl;
}

/** 목록에 그릴 주소. 축소본이 없으면 원본으로 돌아간다. */
public String getListImageUrl() {
return thumbnailUrl == null || thumbnailUrl.isBlank() ? url : thumbnailUrl;
}

public int getSortOrder() {
return sortOrder;
}
Expand Down
Loading
Loading