[모두의 플리] 관리자 콘텐츠 CRUD API와 썸네일 트랜잭션 정합성 보완

배경

이번 작업에서는 모두의 플리 프로젝트의 Content 도메인에서 관리자 콘텐츠 관리 기능을 구현했다.

이전 작업까지는 Content 엔티티, 타입과 출처 enum, 태그 구조, 조회 API, DTO와 Mapper 같은 기반을 먼저 만들었다.

이번에는 그 기반 위에 관리자가 직접 콘텐츠를 등록, 수정, 삭제할 수 있는 API를 구현했다.

관리자가 직접 등록하는 콘텐츠는 외부 API에서 수집한 콘텐츠와 구분해야 하므로 source = MANUAL로 저장한다.

또 하나 중요한 부분은 썸네일이었다.

콘텐츠 데이터는 DB에 저장되지만, 썸네일 파일은 파일 시스템에 저장된다.

즉, DB 트랜잭션이 롤백된다고 해서 이미 저장된 파일이 자동으로 사라지지는 않는다.

이 부분을 놓치면 기능은 정상처럼 보여도 실패 상황에서 찌꺼기 파일이 남을 수 있다.

그래서 이번 작업에서는 단순 CRUD 구현뿐 아니라 파일 저장과 DB 트랜잭션 사이의 정합성까지 함께 보완했다.

구현 범위

이번 작업의 범위는 크게 네 가지였다.

  • 관리자 콘텐츠 등록 API
  • 관리자 콘텐츠 수정 API
  • 관리자 콘텐츠 삭제 API
  • 관리자 권한 기반 통합 테스트

관리자 콘텐츠 등록 API에서는 제목, 설명, 타입, 태그, 썸네일을 받아 새로운 콘텐츠를 생성한다.

관리자 콘텐츠 수정 API에서는 기존 콘텐츠의 제목, 설명, 타입, 태그, 썸네일을 수정할 수 있게 했다.

관리자 콘텐츠 삭제 API에서는 콘텐츠를 삭제하고, 연결된 썸네일 파일도 정리하도록 했다.

그리고 이 흐름이 관리자 권한에서만 동작하는지 확인하기 위해 통합 테스트도 추가했다.

등록과 수정에서 썸네일 조건을 다르게 둔 이유

처음 콘텐츠를 등록할 때는 썸네일이 필수다.

관리자 수동 등록 콘텐츠는 화면에 바로 노출될 수 있기 때문에 썸네일이 없는 상태로 생성되는 것을 막는 것이 자연스럽다고 판단했다.

반대로 수정 API에서는 썸네일을 선택 값으로 처리했다.

콘텐츠 제목이나 설명만 수정하고 싶은데 매번 썸네일 파일까지 다시 보내야 한다면 API 사용성이 나빠진다.

그래서 수정 요청에 썸네일이 없으면 기존 썸네일을 유지하고, 새 썸네일이 전달된 경우에만 교체하도록 했다.

정리하면 다음과 같다.

등록: thumbnail 필수
수정: thumbnail 선택
수정 시 thumbnail 없음: 기존 썸네일 유지
수정 시 thumbnail 있음: 새 썸네일로 교체

request 객체 자체 null과 내부 필드 null 구분하기

수정 API에서는 요청 값의 의미를 분명히 구분해야 했다.

예를 들어 수정 요청 DTO 안의 특정 필드가 null인 것은 “그 필드는 수정하지 않겠다”는 의미로 볼 수 있다.

하지만 요청 객체 자체가 null인 것은 정상적인 수정 요청이라고 보기 어렵다.

처음에는 방어적으로 다음과 비슷한 형태를 고려할 수 있었다.

request == null ? null : request.title()

하지만 이 방식은 의미가 애매하다.

request가 아예 없는 상황과 request.title()이 없는 상황을 같은 방식으로 흘려보내기 때문이다.

그래서 최종적으로는 요청 객체 자체가 null이면 잘못된 입력으로 보고 예외를 발생시키는 방식으로 정리했다.

request 자체 null: 잘못된 요청
request 내부 필드 null: 해당 필드는 기존 값 유지

이렇게 구분해두면 수정 로직을 읽을 때도 의도가 더 명확해진다.

파일 검증을 보완한 이유

썸네일 업로드에서는 파일 타입과 용량 제한도 필요했다.

단순히 MIME 타입이 image/로 시작하는지만 확인하면 너무 넓게 허용된다.

이미지처럼 보이는 값이라도 실제 서비스에서 허용할 포맷을 명확히 제한하는 편이 안전하다.

그래서 썸네일은 허용 가능한 이미지 포맷만 받도록 제한했다.

또한 파일 용량 제한도 추가했다.

용량 제한이 없으면 매우 큰 이미지 파일이 업로드될 수 있고, 압축 해제나 저장 과정에서 메모리 사용량이 커질 수 있다.

이번 작업에서는 완벽한 보안 처리를 모두 끝냈다기보다, 최소한 API 레벨에서 과도한 업로드를 막을 수 있는 기본 방어선을 만들었다.

가장 중요했던 부분: DB 트랜잭션과 파일 시스템은 함께 롤백되지 않는다

이번 작업에서 가장 많이 배운 부분은 트랜잭션과 파일 처리의 관계였다.

Spring의 @Transactional은 DB 작업을 묶어준다.

하지만 파일 시스템 작업은 DB 트랜잭션에 포함되지 않는다.

예를 들어 콘텐츠 등록 과정이 다음과 같다고 하자.

1. 썸네일 파일 저장
2. Content DB 저장
3. DB 저장 중 예외 발생
4. DB transaction rollback

이 경우 DB 저장은 롤백되지만, 1번에서 저장한 썸네일 파일은 그대로 남을 수 있다.

즉, DB에는 콘텐츠가 없는데 파일만 남는 상태가 된다.

반대로 수정에서는 이런 상황도 생길 수 있다.

1. 새 썸네일 파일 저장
2. Content DB 수정
3. 기존 썸네일 파일 삭제
4. DB rollback

이렇게 처리하면 DB는 기존 썸네일 URL을 바라보도록 롤백됐는데, 실제 기존 파일은 이미 삭제되어 있을 수 있다.

그래서 파일 삭제 시점도 조심해야 했다.

TransactionSynchronization으로 보상 처리하기

이 문제를 해결하기 위해 트랜잭션 완료 시점에 맞춰 파일을 정리하도록 했다.

핵심은 다음 기준이다.

DB rollback 시: 새로 업로드한 썸네일 삭제
DB commit 후: 더 이상 사용하지 않는 기존 썸네일 삭제
삭제 commit 후: 삭제된 콘텐츠의 썸네일 삭제

이렇게 해야 DB 상태와 파일 상태가 최대한 어긋나지 않는다.

등록 API에서는 새 썸네일을 먼저 저장한 뒤, 이후 DB 작업이 롤백되면 새로 저장한 파일을 삭제하도록 했다.

수정 API에서는 새 썸네일 저장 후 DB 수정이 성공적으로 커밋되면 기존 썸네일을 삭제한다.

만약 DB 수정이 롤백되면 새로 업로드한 썸네일을 삭제한다.

삭제 API에서는 DB에서 콘텐츠 삭제가 커밋된 뒤에 썸네일 파일을 삭제한다.

파일 시스템 작업은 DB처럼 완벽한 트랜잭션을 제공하지 않기 때문에, 이런 식으로 실패 시 남을 수 있는 상태를 줄이는 보상 처리가 필요하다.

삭제 API에서 썸네일을 commit 이후 삭제한 이유

콘텐츠 삭제 시 썸네일 파일을 먼저 삭제하면 문제가 생길 수 있다.

예를 들어 파일을 먼저 삭제한 뒤 DB 삭제가 실패하면 어떻게 될까?

DB에는 콘텐츠가 여전히 남아 있는데, 썸네일 파일은 사라져 있다.

사용자는 존재하는 콘텐츠를 조회했는데 이미지가 깨지는 상황을 볼 수 있다.

그래서 삭제에서는 DB 삭제가 성공적으로 commit된 뒤 썸네일 파일을 삭제하도록 했다.

이 순서가 더 안전하다.

테스트한 내용

이번 작업에서는 관리자 콘텐츠 관리 흐름에 대한 통합 테스트를 작성했다.

주요 검증 대상은 다음과 같았다.

  • 관리자는 콘텐츠를 등록할 수 있다.
  • 관리자는 콘텐츠를 수정할 수 있다.
  • 관리자는 콘텐츠를 삭제할 수 있다.
  • 일반 사용자는 관리자 콘텐츠 관리 API를 사용할 수 없다.
  • 등록 시 썸네일이 없으면 실패한다.
  • 수정 시 썸네일이 없으면 기존 썸네일을 유지한다.
  • 잘못된 수정 요청은 실패한다.
  • DB rollback 상황에서 새로 업로드된 썸네일 파일이 정리된다.

특히 마지막 테스트는 단순 CRUD 테스트보다 중요했다.

정상 요청만 보면 파일 찌꺼기 문제를 발견하기 어렵기 때문이다.

실패 상황을 직접 테스트해야 “DB는 롤백됐는데 파일만 남는 문제”를 확인할 수 있다.

PR 피드백으로 보완한 내용

처음 구현 후 코드 리뷰에서 몇 가지 중요한 피드백을 받았다.

첫 번째는 @Transactional 안에서 파일 작업과 DB 작업을 함께 수행할 때 파일 찌꺼기가 남을 수 있다는 점이었다.

이 피드백을 반영해 TransactionSynchronization 기반으로 commit/rollback 이후 파일 정리 흐름을 보완했다.

두 번째는 수정 요청에서 request == null ? null : request.field() 형태가 의미가 약하다는 점이었다.

이 부분은 요청 객체 자체가 null이면 명확하게 잘못된 입력으로 처리하도록 수정했다.

세 번째는 이미지 MIME 타입을 image/처럼 포괄적으로 허용하지 말고, 허용할 포맷을 구체적으로 제한하는 것이 좋다는 피드백이었다.

네 번째는 파일 용량 제한이 없으면 큰 이미지 업로드로 인한 문제가 생길 수 있다는 피드백이었다.

이 피드백들을 반영하면서 단순히 동작하는 코드에서 조금 더 안전한 코드로 바뀌었다.

이번 작업에서 배운 점

이번 작업을 하면서 느낀 점은 CRUD API도 단순하지 않다는 것이다.

DB 테이블 하나를 생성, 수정, 삭제하는 수준이라면 어렵지 않을 수 있다.

하지만 파일 시스템처럼 DB 트랜잭션 밖에 있는 자원이 함께 들어오면 이야기가 달라진다.

정상 흐름만 보면 문제가 없어 보여도, 실패 흐름에서는 다음과 같은 질문을 반드시 해야 한다.

  • DB가 롤백되면 파일은 어떻게 되는가?
  • 파일 삭제 후 DB 작업이 실패하면 어떻게 되는가?
  • 기존 파일과 새 파일 중 어느 시점에 무엇을 삭제해야 하는가?
  • 요청 객체 자체가 없는 것과 내부 필드가 없는 것은 같은 의미인가?
  • 테스트는 정상 흐름만 확인하고 있지는 않은가?

이런 질문을 구현 전에 먼저 확인했으면 리뷰 피드백 전에 더 탄탄하게 설계할 수 있었을 것이다.

그래서 이후 작업부터는 구현 전에 다음 항목을 먼저 확인하기로 했다.

  • 트랜잭션 경계
  • DB 외부 자원 사용 여부
  • null과 optional 값의 의미
  • 실패 시 보상 처리
  • 타 도메인 영향
  • 실패/경계 테스트
  • PR에 명시해야 할 한계

정리

이번 작업은 관리자 콘텐츠 CRUD API를 구현하는 작업이었지만, 실제 핵심은 “파일과 DB의 정합성”이었다.

DB 트랜잭션 안에서 처리되는 값과 그렇지 않은 값을 구분해야 한다.

그리고 실패했을 때 어떤 상태가 남는지까지 고려해야 한다.

기능이 정상적으로 동작하는지 확인하는 것도 중요하지만, 예외 상황에서 서비스가 이상한 상태로 남지 않게 만드는 것도 그만큼 중요하다는 것을 배웠다.

수정 보완 기록

2026-07-06: 콘텐츠 타입 enum JSON 변환 기준 수정

관리자 콘텐츠 등록 API를 실제로 호출해보는 과정에서 콘텐츠 등록이 실패하는 문제가 있었다.

처음에는 썸네일 업로드, 관리자 권한, CSRF 처리 중 하나가 문제일 수 있다고 생각했다. 그래서 실제 multipart 요청을 만들어 등록을 시도했다.

결과는 다음과 같았다.

request.type = "movie"  -> 400 COMMON_400
request.type = "MOVIE"  -> 201 Created

즉 문제는 파일 업로드나 권한 문제가 아니라 ContentType enum의 JSON 변환 기준이었다.

Java 내부 enum 이름은 다음과 같다.

MOVIE
TV_SERIES
SPORT

하지만 API 명세와 프론트에서 사용하는 값은 다음과 같다.

movie
tvSeries
sport

기존 ContentType에는 MOVIE("movie")처럼 API 표현값을 따로 가지고 있었지만, Jackson이 요청 JSON을 enum으로 변환할 때 이 값을 사용하도록 설정하지 않았다.

그래서 API 명세대로 "movie"를 보내면 Jackson은 MOVIE라는 enum 이름을 찾지 못하고 JSON 파싱 실패를 발생시켰다.

해결은 ContentType에 JSON 변환 기준을 명시하는 방식으로 했다.

@JsonValue
public String getValue() {
  return value;
}

@JsonCreator
public static ContentType from(String value) {
  return Arrays.stream(values())
      .filter(type -> type.value.equals(value))
      .findFirst()
      .orElseThrow(...);
}

이렇게 하면 Java 내부에서는 ContentType.MOVIE를 사용하되, API 요청과 응답에서는 명세 기준인 movie를 사용할 수 있다.

이번 문제에서 더 중요했던 부분은 테스트였다.

기존 테스트는 ContentCreateRequest 객체를 만들면서 ContentType.MOVIE enum을 직접 넣었다. 그래서 실제 프론트 요청처럼 "movie" 문자열을 보내는 상황을 검증하지 못했다.

테스트도 다음처럼 실제 API 명세와 같은 raw JSON multipart part를 보내도록 보완했다.

{
  "type": "movie",
  "title": "등록 제목",
  "description": "등록 설명",
  "tags": ["등록태그", "영화"]
}

그리고 응답도 "MOVIE"가 아니라 "movie"로 내려오는지 확인했다.

이번 보완으로 얻은 교훈은 단순하다.

  • Java enum 이름과 API 명세값은 다를 수 있다.
  • 내부 코드에서 enum 객체를 직접 넣는 테스트만으로는 실제 API 요청을 완전히 검증할 수 없다.
  • 명세가 문자열 값을 정의하고 있다면, 테스트도 그 문자열 값을 직접 보내야 한다.
  • @JsonCreator, @JsonValue는 enum의 내부 이름과 외부 표현값을 분리할 때 필요하다.

기능 자체는 작은 수정이었지만, 테스트가 실제 사용 방식과 달라서 문제가 숨어 있었던 사례였다.