Board 프로젝트 - Image 업로드 및 콘텐츠 관리 구조

SIHA·2026년 2월 19일
post-thumbnail

이미지 업로드와 게시글 저장을 분리한 이유

WYSIWYG 에디터를 프론트엔드에서 사용한다는 가정하에 백엔드를 구현하고자 하니 고민이 생겼다.

이미지를 어떻게 처리할까?

가장 단순한 방법은 게시글 저장 시 이미지를 함께 전송하는 것이다. 하지만 WYSIWYG 에디터 특성상 사용자는 글을 쓰는 도중에 이미지를 첨부하고, 미리보기로 확인하면서 작성을 이어간다. 이 흐름을 자연스럽게 지원하려면 이미지 업로드와 게시글 저장을 분리하는 것이 맞다고 판단했다.


실제 동작 흐름

사용자 입장에서는 저장 버튼 하나로 모든 게 업로드되는 것처럼 보이지만, 실제로는 두 단계로 나뉜다.

1단계 — 이미지 업로드

WYSIWYG 에디터에 이미지를 첨부하는 순간, 백그라운드에서 먼저 파일이 서버로 전송된다.

POST /api/posts/media/images
Content-Type: multipart/form-data

서버는 파일을 로컬에 저장하고 UUID 기반의 key와 접근 URL을 반환한다.

{
  "key": "posts/2026/02/11/abc.jpeg",
  "url": "/files/posts/2026/02/11/abc.jpeg"
}

에디터는 이 URL을 받아서 HTML에 <img> 태그로 즉시 삽입한다. 덕분에 사용자는 이미지가 본문에 렌더링되는 걸 바로 확인할 수 있다.

2단계 — 게시글 저장

저장 버튼을 누르면 에디터의 HTML 문자열이 JSON으로 전송된다.

POST /api/posts
Content-Type: application/json
{
  "title": "이미지 테스트",
  "contents": "<p>본문 내용</p><img src=\"/files/posts/2026/02/11/abc.jpeg\">"
}

이미지는 이미 업로드가 끝난 상태이므로, 게시글 저장 단계에서는 HTML 문자열만 저장하면 된다.


이미지 메타데이터는 어떻게 관리하나

게시글 HTML 안에 이미지 URL이 포함되어 있긴 하지만, 이것만으로는 "이 게시글에 어떤 이미지가 사용됐는지"를 서버가 파악하기 어렵다. 그래서 PostImage라는 별도 엔티티에 이미지 정보를 저장한다.

@Entity
public class PostImage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    @Column(nullable = false, length = 500)
    private String storageKey;

    @Column(nullable = false)
    private int sortOrder;
}

몇 가지 설계 기준이 있었다.

URL 대신 storageKey만 저장한다. URL은 스토리지 구현에 따라 바뀔 수 있다. 지금은 로컬 파일 서버를 쓰지만 나중에 S3로 전환하면 URL 형식이 달라진다. key만 저장해두면 URL은 런타임에 조립하면 그만이다.

sortOrder로 순서를 보장한다. WYSIWYG 에디터에서는 이미지 순서가 의미를 가질 수 있다. HTML 파싱 순서대로 index를 부여해서 저장한다.


HTML에서 이미지를 추출하는 방법

게시글이 저장될 때, 서버는 HTML을 분석해서 사용된 이미지 목록을 뽑아낸다.

private static final Pattern IMG_SRC_PATTERN =
    Pattern.compile("<img[^>]*\\s+src=[\"'](/files/[^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);

/files/로 시작하는 URL만 인식하도록 제한했다. 외부 이미지 URL(예: 다른 사이트에서 복붙한 이미지)은 무시한다. 이렇게 하면 서버가 관리하는 이미지만 PostImage에 기록된다.


게시글 수정 시 동기화

수정이 까다로운 부분이었다. 기존 이미지 중 일부는 삭제되고, 새 이미지가 추가될 수 있다. 순서도 바뀔 수 있다.

여러 방식을 고민했는데, 결국 전체 삭제 후 재생성 방식을 택했다.

  1. 기존 PostImage 전부 삭제
  2. 수정된 HTML에서 이미지 추출
  3. 새 목록으로 재생성

diff 방식도 고려했지만, WYSIWYG 에디터에서는 사용자가 이미지를 자유롭게 이동/삭제/추가하기 때문에 변경분을 추적하는 게 생각보다 복잡해진다. 전체 재생성이 단순하고 버그 여지도 적었다.


업로드를 분리한 이유

WYSIWYG 에디터는 사용자가 이미지를 붙이는 즉시 본문에서 미리보기를 보여줘야 한다. 이걸 구현하려면 이미지가 에디터 조작 시점에 이미 서버에 올라가 있어야 한다. 게시글 저장 시점까지 이미지를 들고 있을 수가 없다.

그리고 이미지 업로드 실패와 게시글 저장 실패를 분리할 수 있다는 것도 장점이다. 이미지는 잘 올라갔는데 게시글 저장에서 실패했다면, 이미지를 다시 올릴 필요 없이 게시글만 재시도하면 된다.


확장 가능성

현재는 로컬 파일 서버에 저장하지만, FileStorage 인터페이스를 구현체만 교체하면 S3로 전환할 수 있다. storageKey 기반으로 설계한 이유가 여기 있다.

추후 고아 이미지(게시글에 참조되지 않는 이미지) 정리 배치, CDN 적용, XSS 필터링 강화 등도 자연스럽게 붙일 수 있는 구조다.


[Client]
   │
   ├─ POST /media/images (file)
   │        ↓
   │   LocalFileStorage
   │        ↓
   │   key + url 반환
   │
   └─ POST /posts (JSON)
            ↓
       PostService
            ↓
   HtmlImageExtractor
            ↓
      PostImage 저장
profile
뭐라도 해보자

0개의 댓글