프로젝트에서 여러 도메인 엔티티를 설계하면서 공통 필드를 관리하기 위해 BaseEntity를 사용하고 있었다.
각 엔티티는 공통적으로 id, code, createdAt, updatedAt과 같은 필드를 가지고 있었고, 이를 반복해서 선언하지 않기 위해 공통 상위 클래스로 분리했다.
하지만 API 테스트를 진행하면서 한 가지 불편함이 생겼다.
문제 상황
초기에는 code 값을 UUID만으로 생성하고 있었다.
UUID는 고유성을 보장하기에는 적합하지만, 값만 보고 어떤 도메인의 데이터인지 파악하기는 어려웠다.
예를 들어 여러 도메인의 데이터가 모두 UUID 형태로 생성되면 다음과 같이 보인다.
69a29997-2c97-433b-a3a5-1656721a4efc
7b421a11-8e45-4b62-a9d7-42143788122a
9c71d442-0af3-4e52-b80c-3a521a21c11b
API 테스트 과정에서 응답값으로 내려온 code를 확인하거나 DB 데이터를 직접 확인할 때, 이 값이 어떤 도메인에서 생성된 데이터인지 바로 구분하기 어려웠다.
특히 여러 도메인이 연결된 API를 테스트할 때는 상품 데이터인지, 게시글 데이터인지, 좋아요 데이터인지 매번 테이블과 함께 확인해야 했다.
그래서 다음과 같은 생각을 하게 되었다.
UUID 앞에 도메인 이름을 붙이면, API 테스트나 DB 확인 과정에서 어느 도메인의 데이터인지 더 쉽게 구분할 수 있지 않을까?
예를 들어 상품 도메인이라면 다음과 같은 형태다.
product-69a29997-2c97-433b-a3a5-1656721a4efc
이렇게 하면 code 값만 봐도 해당 데이터가 product 도메인과 관련된 데이터임을 알 수 있다.
기존 구조의 한계
기존 BaseEntity는 여러 엔티티가 공통으로 사용하는 필드를 관리하는 역할을 했다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String code;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
}
이 구조는 공통 필드를 한 곳에서 관리할 수 있다는 장점이 있었다.
하지만 도메인마다 다른 prefix를 붙여 code를 생성하려고 하니 문제가 생겼다.
BaseEntity는 공통 클래스이기 때문에 특정 도메인의 이름을 직접 알 수 없다.
즉, product, post, favorite와 같은 도메인별 prefix를 BaseEntity 내부에서 고정해서 처리하기 어렵다.
그렇다고 각 엔티티의 생성자에서 직접 code를 생성하게 만들면 다음과 같은 문제가 생길 수 있다.
- 엔티티마다 중복 코드가 발생한다.
- code 생성 규칙이 엔티티마다 달라질 수 있다.
- 생성자에서 code 생성을 누락할 가능성이 있다.
- 공통 식별 코드 생성 정책을 한 곳에서 관리하기 어렵다.
따라서 BaseEntity에서 공통 생성 흐름은 관리하되, 도메인별 prefix는 각 엔티티가 제공하도록 구조를 잡는 것이 필요했다.
개선 방향
개선 방향은 다음과 같았다.
BaseEntity
- code 생성 시점 관리
- UUID 생성 방식 관리
- createdAt, updatedAt 초기화
각 도메인 엔티티
- 자신의 prefix 제공
이를 위해 BaseEntity에 추상 메서드인 getEntityPrefix()를 추가했다.
protected abstract String getEntityPrefix();
그리고 새로운 엔티티가 저장되기 직전에 code가 자동으로 생성되도록 @PrePersist를 사용했다.
@PrePersist
protected void onCreate() {
this.code = getEntityPrefix() + "-" + createUuid();
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PrePersist는 persist() 를 호출하기 전,
즉 엔티티가 처음 영속화되기 직전에 (새로운 엔티티가 영속성 컨텍스트에 관리되기 직전에!!) 호출하도록 만들어준다.
해당 어노테이션으로 인해 엔티티 생성 시점에 onCreate()가 호출되어 공통 규칙에 따라 prifix를 생성할 수 있게 된것이다!
개선된 BaseEntity
개선 후 BaseEntity는 다음과 같은 형태가 되었다.
package com.common.model.persistence;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import lombok.Getter;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@Column(nullable = false, unique = true, updatable = false)
private String code;
@Enumerated(EnumType.STRING)
private DeleteStatus deleteStatus = DeleteStatus.N;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
protected abstract String getEntityPrefix();
@PrePersist
protected void onCreate() {
this.code = getEntityPrefix() + "-" + createUuid();
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
private String createUuid() {
return UUID.randomUUID().toString();
}
public void delete() {
this.deleteStatus = DeleteStatus.D;
this.updatedAt = LocalDateTime.now();
}
public void update() {
this.updatedAt = LocalDateTime.now();
}
public enum DeleteStatus {
N, D
}
}
핵심은 BaseEntity가 직접 도메인명을 알지 않는다는 점이다.
대신 getEntityPrefix()라는 추상 메서드를 통해 각 엔티티가 자신의 prefix를 제공하도록 했다.
이렇게 하면 code 생성 시점과 생성 규칙은 공통으로 관리하면서도, 도메인별 prefix는 유연하게 다르게 적용할 수 있다.
도메인 엔티티 적용 예시
예를 들어 Product 엔티티에서는 다음과 같이 prefix를 제공한다.
@Entity
@Getter
@NoArgsConstructor
public class Product extends BaseEntity {
private String title;
private String description;
@Override
protected String getEntityPrefix() {
return "product";
}
@Builder
public Product(String title, String description) {
this.title = title;
this.description = description;
}
}
이제 Product 엔티티가 저장될 때는 다음과 같은 형태의 code가 자동으로 생성된다.
product-69a29997-2c97-433b-a3a5-1656721a4efc
개선 후 얻은 장점
1. API 테스트 시 데이터 식별이 쉬워졌다
기존에는 UUID만 보고 어떤 도메인의 데이터인지 알기 어려웠다.
69a29997-2c97-433b-a3a5-1656721a4efc
개선 후에는 prefix를 통해 도메인을 바로 확인할 수 있다.
product-69a29997-2c97-433b-a3a5-1656721a4efc
응답값이나 DB 데이터를 확인할 때 어떤 도메인의 데이터인지 빠르게 파악할 수 있어 API 테스트 과정이 더 편해졌다.
2. 도메인별 prefix는 유연하게 확장할 수 있었다
BaseEntity는 code 생성 시점과 공통 생성 규칙만 관리한다.
도메인 prefix는 각 엔티티가 getEntityPrefix()를 구현해서 제공한다.
따라서 새로운 엔티티가 추가되더라도 해당 엔티티에서 prefix만 정의하면 동일한 규칙으로 code를 생성할 수 있다.
@Override
protected String getEntityPrefix() {
return "favorite-product";
}
아쉬웠던 점
이번 개선은 API 테스트 과정에서 UUID만으로는 데이터를 구분하기 어렵다는 불편함에서 시작했다.
product-UUID, post-UUID처럼 prefix를 붙이면 응답값이나 DB 데이터를 확인할 때 어떤 도메인의 데이터인지 쉽게 파악할 수 있었다. 개발 초기 테스트나 학습 과정에서는 분명 편리한 방식이었다.
하지만 실무 관점에서 보면, 식별자에 도메인 prefix가 꼭 필요했는지는 고민이 남는다.
식별자의 주된 역할은 데이터를 고유하게 구분하는 것이다. 그런데 prefix를 붙이면 식별자에 도메인 정보까지 포함되면서 역할이 조금 섞이게 된다.
또한 product, favorite-product와 같은 값이 외부 API 응답에 노출된다면 내부 도메인 구조가 드러날 수 있다.
큰 보안 문제라고 보기는 어렵지만, 굳이 외부에 노출할 필요가 없는 정보일 수 있다.
따라서 이 방식은 운영 환경에서 반드시 필요한 설계라기보다는, 개발 과정에서 테스트 편의성과 학습을 위해 시도한 개선에 가까웠다고 생각한다.
허나 이 과정을 통해 @PrePersist와 엔티티 생명주기를 직접 경험할 수 있었다는 점은 의미가 있었다.
정리
이번 작업은 UUID만으로 구분하기 어려웠던 데이터를 더 쉽게 식별하기 위해, 도메인 prefix + UUID 형태의 code를 생성하도록 개선한 경험이다.
1. persist는 새로운 엔티티를 영속성 컨텍스트에 등록하는 과정이다.
Spring Data JPA에서는 보통 repository.save(entity)를 호출할 때 새로운 엔티티라면 persist가 수행된다.
2. @PrePersist는 엔티티가 처음 저장되기 직전에 실행되는 JPA 생명주기 콜백이다.
즉, insert 쿼리가 실행되기 전에 필요한 값을 엔티티에 채워 넣을 수 있다.
3. prefix 기반 식별자가 실무적으로 항상 적절한 방식인지는 별도로 고민할 필요가 있다.
학습과 테스트 편의성 측면에서는 도움이 되었지만, 운영 환경에서는 내부 정보 노출이나 식별자의 역할 혼합 가능성도 고려해야 한다.
'Back-End > Spring' 카테고리의 다른 글
| [SpringBoot] Entity를 DTO로 변환해야 하는 이유 (0) | 2025.11.22 |
|---|---|
| ID값을 UUID v4에서 UUID v7로 변경한 이유 (0) | 2025.11.21 |
| [JWT] Bearer는 왜 붙을까? Basic 인증과 Bearer 인증 비교하기 (0) | 2025.10.29 |
| [SpringBoot] 스프링부트는 어떻게 응답(Response)할까요? (0) | 2025.10.20 |
| [SpringBoot] ApplicationContext란? (0) | 2025.09.29 |
