연예인이 입은 옷 정보를 팬들이 직접 등록하고 수정할 수 있는 나무 위키 같은 웹을 만들어보기로 했다.
딱 떠오르는 그림은 이거다.
- 유저가 아이템 정보를 올린다
- 다른 유저가 아 그건 가격이 아니고 브랜드가 달라요! 하며 수정한다
- 관리자가 승인/반려한다
- 승인되면 실제 데이터에 반영된다
문제는 이 흐름을 대충 만들면 운영이 바로 지옥이 된다.
오늘은 내가 만든 아라내(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으로 무결성을 확보했다
개발에 대한 의견, 훈수, 가르침, 사랑과 매 모두 환영 입니다.