연예인이 입은 옷 정보를 팬들이 직접 등록하고 수정할 수 있는 나무 위키 같은 웹을 만들어보기로 했다.
딱 떠오르는 그림은 이거다.

  • 유저가 아이템 정보를 올린다
  • 다른 유저가 아 그건 가격이 아니고 브랜드가 달라요! 하며 수정한다
  • 관리자가 승인/반려한다
  • 승인되면 실제 데이터에 반영된다

문제는 이 흐름을 대충 만들면 운영이 바로 지옥이 된다.
오늘은 내가 만든 아라내(Aranae) 프로젝트에서 운영 가능한 수준으로 가기 위해 어떤 ERD/JPA 설계를 했는지 정리해본다.

 

1. 큰 그림: 승인 전 데이터와 승인 후 데이터를 분리한다

처음엔 이렇게 생각하기 쉽다.
유저가 올리면 그냥 Item 테이블에 저장하면 되지 않나? 

이것은 아래와같은 문제가 발생할 수 있다.

  • 누군가 악의적으로 이상한 데이터를 올릴 수도 있고
  • 똑같은 아이템이 중복으로 수십 개 생길 수도 있고
  • 수정/등록이 동시에 들어오면 무엇이 최신인지 꼬인다

그래서 결론은 :

유저가 만든 변경은 바로 본 테이블(Item, Celebrity…)에 반영하지 않는다.
먼저 “제안(EditProposal)”로 모아두고 승인되면 반영한다.

이게 오늘 설계의 핵심이다.


2. ERD 설계 포인트

2-1) 도메인 엔티티 목록

  • User: 제안을 올리는 사람(요청자) / 승인하는 관리자
  • Celebrity: 연예인
  • Item: 패션 아이템
  • CelebrityItem: “누가 어떤 아이템을 어디서 입었는지” 매핑
  • EditProposal: 유저의 등록/수정 제안 (승인 대기)
  • ApprovalHistory: 승인/반려 이력

여기서 포인트는 CelebrityItem이 따로 있다는 것.
연예인과 아이템은 N:M 관계인데 드라마명/채널/회차/출처 URL 같은 메타정보는 관계 자체에 붙는다.
그래서 중간 테이블이 아니라 도메인 엔티티로 승격시켰다.

2-2) 설계 포인트 ① 승인 워크플로우: EditProposal 중심 구조

왜 EditProposal이 필요한가?

유저가 올리는 데이터는 확정 데이터가 아니다.
따라서 다음 원칙을 세웠다.

  • Item / Celebrity / CelebrityItem은 승인된 확정 데이터
  • EditProposal은 승인 전 임시 데이터

수정/등록 요청은 EditProposal에만 저장,
관리자가 승인하면 그때 실제 테이블에 반영

 

이 구조는 운영/무결성 측면에서 이득이 크다.

  • 관리자가 리뷰 가능한 단일 큐가 생김
  • 유저가 DB를 직접 어지럽히지 못함
  • 승인/반려 근거(사유, 변경 내용)가 남음

2-3) 설계 포인트 ② JSON 기반 제안 데이터 (proposedData)

EditProposal에 proposedData를 JSON으로 둔 이유는 꽤 현실적이다.

유저가 제안하는 대상은 다양하다.

  • Item 등록 제안: { name, brand, category, price, ... }
  • Celebrity 수정 제안: { name, groupName, profileImageKey, ... }
  • CelebrityItem 제안: { celebrityId, itemId, dramaTitle, episode, ... }

이걸 각각 전용 테이블로 만들면?

  • ItemEditProposal
  • CelebrityEditProposal
  • CelebrityItemEditProposal

처럼 테이블이 폭발한다. 요구사항 하나 바뀔 때마다 마이그레이션도 지옥이다.

그래서 나는 이렇게 선택했다.

EditProposal.proposedData(JSON)에 “승인 전 데이터 봉투”를 통째로 담는다.

승인 전에는 이 데이터가 정답인지 모르니까 구조를 유연하게 가져가고 승인 시점에만 JSON을 파싱해서 실제 엔티티로 변환/저장한다.

 

2-4) 설계 포인트 ③ 중복 제어 전략 (여기서 운영 난이도가 갈린다)

유저 참여형 서비스에서 중복은 필연이다.
그래서 DB 레벨에서 1차 방어선을 둔다.

- Item 중복 방지: uniqueKey

같은 아이템이 이런 형태로 중복 입력될 수 있다.

  • Nike / Air Force 1 / Shoes
  • NIKE / air force1 / shoes
  • 나이키 / 에어포스원 / 신발

문자열이 조금만 달라도 DB는 “다른 아이템”으로 본다.

그래서 uniqueKey를 만든다.

  • brand + name + category를 정규화해서 만든 키
  • 그리고 UNIQUE INDEX를 건다

결과: 승인 후 (제대로 입력된) Item 데이터는 중복 생성되지 않는다.

 

- EditProposal 중복 방지: dedupKey

  • A가 아이템 가격 수정 제안
  • B도 똑같은 가격 수정 제안
  • C도 똑같은 수정 제안

모두 PENDING이면 관리자가 같은 걸 3번 심사해야 한다.

그래서 dedupKey를 둔다.

  • 제안 대상 + 타입을 합쳐서 만든 키
    • 예: ITEM_15_UPDATE

그리고 “PENDING 상태에서만” 중복 제안을 막는다.

결과: 같은 건 한 번만 심사하면 된다.


3. JPA 설계 포인트

3-1) 공통 베이스 엔티티: BaseTimeEntity

모든 엔티티는 생성/수정 시간이 필요하다.

  • createdAt
  • updatedAt

매 엔티티마다 붙이면 중복 코드가 생긴다.
그래서 BaseTimeEntity를 @MappedSuperclass로 만들고 상속했다.

  • @PrePersist : INSERT 전에 자동으로 createdAt/updatedAt 설정
  • @PreUpdate : UPDATE 전에 updatedAt 갱신

결과: 모든 엔티티에서 시간 관리 코드가 사라진다.


3-2) 상태/타입은 Enum

승인 워크플로우에서는 상태가 생명이다.

  • PENDING
  • APPROVED
  • REJECTED

이걸 String으로 두면?

  • “PENDNG” 같은 오타가 DB에 들어가는 순간, 운영이 끝난다.

그래서 상태/타입은 enum으로 고정했다.

  • ProposalStatus
  • ProposalType (CREATE/UPDATE)
  • TargetType (ITEM/CELEBRITY/CELEBRITY_ITEM)
  • Decision (APPROVED/REJECTED)

그리고 DB 저장은 무조건:

@Enumerated(EnumType.STRING)

ordinal(숫자) 저장은 enum 순서 바뀌면 데이터가 망가지기 때문에 피했다.


3-3) 도메인 무결성: 승인/반려는 PENDING에서만 가능

EditProposal 엔티티에 도메인 메서드를 두었다.

  • approve() / reject()

여기서 핵심은:

PENDING이 아닌 상태면 예외를 던져서 막는다.

이렇게 하면 서비스 계층에서 실수로 상태를 바꾸는 사고를 줄일 수 있다.


4. 오늘의 결론

  • 승인 전/후 데이터를 분리하고(EditProposal)
  • JSON으로 제안 데이터를 유연하게 담고(proposedData)
  • uniqueKey / dedupKey로 중복을 통제하며
  • JPA에서는 BaseTimeEntity와 Enum으로 무결성을 확보했다

 

개발에 대한 의견, 훈수, 가르침, 사랑과 매 모두 환영 입니다. 

+ Recent posts