feat: 게시글 인라인 이미지 서버 업로드(DB 저장) — base64 대체
CI / build (push) Failing after 15m30s

- 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 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-07 19:17:25 +09:00
parent 2da43b2f77
commit f9abd0c989
6 changed files with 159 additions and 0 deletions
@@ -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<String, String> 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<byte[]> 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());
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
}
+12
View File
@@ -70,6 +70,18 @@ CREATE TABLE IF NOT EXISTS board_setting (
INSERT IGNORE INTO board_setting (id, tag_category_id) VALUES (1, NULL); 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 ( CREATE TABLE IF NOT EXISTS comment (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.board.mapper.BoardImageMapper">
<insert id="insert" parameterType="com.sb.web.board.domain.BoardImage"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO board_image (member_id, content_type, data, byte_size, created_at)
VALUES (#{memberId}, #{contentType}, #{data}, #{byteSize}, NOW())
</insert>
<select id="findById" resultType="com.sb.web.board.domain.BoardImage">
SELECT id, member_id, content_type, data, byte_size, created_at
FROM board_image
WHERE id = #{id}
</select>
</mapper>