From f9abd0c989d6813c93e9081f0f3419f1cd9be14a Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 7 Jun 2026 19:17:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=97=85=EB=A1=9C=EB=93=9C(DB=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?)=20=E2=80=94=20base64=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - board_image 테이블 + BoardImageController/Service/Mapper · POST /api/board/images(로그인) → {url:/api/images/{id}} · GET /api/images/{id}(공개, img src 로 로드) — 1년 캐시 - 본문엔 짧은 URL 만 들어가 글이 비대해지지 않음 Co-Authored-By: Claude Opus 4.8 --- .../controller/BoardImageController.java | 57 +++++++++++++++++++ .../com/sb/web/board/domain/BoardImage.java | 26 +++++++++ .../sb/web/board/mapper/BoardImageMapper.java | 13 +++++ .../web/board/service/BoardImageService.java | 33 +++++++++++ src/main/resources/db/board.sql | 12 ++++ .../resources/mapper/BoardImageMapper.xml | 18 ++++++ 6 files changed, 159 insertions(+) create mode 100644 src/main/java/com/sb/web/board/controller/BoardImageController.java create mode 100644 src/main/java/com/sb/web/board/domain/BoardImage.java create mode 100644 src/main/java/com/sb/web/board/mapper/BoardImageMapper.java create mode 100644 src/main/java/com/sb/web/board/service/BoardImageService.java create mode 100644 src/main/resources/mapper/BoardImageMapper.xml diff --git a/src/main/java/com/sb/web/board/controller/BoardImageController.java b/src/main/java/com/sb/web/board/controller/BoardImageController.java new file mode 100644 index 0000000..5fa45a1 --- /dev/null +++ b/src/main/java/com/sb/web/board/controller/BoardImageController.java @@ -0,0 +1,57 @@ +package com.sb.web.board.controller; + +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import com.sb.web.board.domain.BoardImage; +import com.sb.web.board.service.BoardImageService; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 게시글 인라인 이미지. + * POST /api/board/images 업로드(로그인 필요) → {url: "/api/images/{id}"} + * GET /api/images/{id} 이미지 바이트(공개 — img src 로 인증 없이 로드) + */ +@RestController +@RequiredArgsConstructor +public class BoardImageController { + + private final BoardImageService service; + + @PostMapping("/api/board/images") + public Map upload( + @RequestParam("image") MultipartFile file, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException { + if (file.isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지가 비어 있습니다."); + } + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 파일만 업로드할 수 있습니다."); + } + Long id = service.save(current.getId(), contentType, file.getBytes()); + return Map.of("url", "/api/images/" + id); + } + + @GetMapping("/api/images/{id}") + public ResponseEntity get(@PathVariable Long id) { + BoardImage img = service.get(id); + if (img == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(img.getContentType())) + .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) + .body(img.getData()); + } +} diff --git a/src/main/java/com/sb/web/board/domain/BoardImage.java b/src/main/java/com/sb/web/board/domain/BoardImage.java new file mode 100644 index 0000000..fb36e9f --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/BoardImage.java @@ -0,0 +1,26 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 게시글 인라인 이미지. board_image 테이블과 매핑(DB 저장). + * 본문에는 base64 대신 /api/images/{id} URL 만 들어간다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BoardImage { + + private Long id; + private Long memberId; + private String contentType; + private byte[] data; + private Integer byteSize; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java b/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java new file mode 100644 index 0000000..6c769c0 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java @@ -0,0 +1,13 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.BoardImage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface BoardImageMapper { + + int insert(BoardImage image); + + BoardImage findById(@Param("id") Long id); +} diff --git a/src/main/java/com/sb/web/board/service/BoardImageService.java b/src/main/java/com/sb/web/board/service/BoardImageService.java new file mode 100644 index 0000000..028d05f --- /dev/null +++ b/src/main/java/com/sb/web/board/service/BoardImageService.java @@ -0,0 +1,33 @@ +package com.sb.web.board.service; + +import com.sb.web.board.domain.BoardImage; +import com.sb.web.board.mapper.BoardImageMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 게시글 인라인 이미지 저장/조회 (DB 저장). + */ +@Service +@RequiredArgsConstructor +public class BoardImageService { + + private final BoardImageMapper mapper; + + @Transactional + public Long save(Long memberId, String contentType, byte[] data) { + BoardImage img = BoardImage.builder() + .memberId(memberId) + .contentType(contentType) + .data(data) + .byteSize(data.length) + .build(); + mapper.insert(img); + return img.getId(); + } + + public BoardImage get(Long id) { + return mapper.findById(id); + } +} diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index d91f512..022de7a 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -70,6 +70,18 @@ CREATE TABLE IF NOT EXISTS board_setting ( INSERT IGNORE INTO board_setting (id, tag_category_id) VALUES (1, NULL); +-- 게시글 인라인 이미지 (DB 저장). 본문에는 /api/images/{id} URL 만 포함(base64 미사용). +CREATE TABLE IF NOT EXISTS board_image ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + content_type VARCHAR(100) NOT NULL, + data LONGBLOB NOT NULL, + byte_size INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_board_image_member (member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 댓글 CREATE TABLE IF NOT EXISTS comment ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/BoardImageMapper.xml b/src/main/resources/mapper/BoardImageMapper.xml new file mode 100644 index 0000000..01e7e15 --- /dev/null +++ b/src/main/resources/mapper/BoardImageMapper.xml @@ -0,0 +1,18 @@ + + + + + + INSERT INTO board_image (member_id, content_type, data, byte_size, created_at) + VALUES (#{memberId}, #{contentType}, #{data}, #{byteSize}, NOW()) + + + + +