[프로젝트/픽잇] BaseEntity 적용 과정

슬링민키·2025년 8월 25일

픽잇

목록 보기
2/7
post-thumbnail

도입 배경

엔티티를 설계하는 과정에서, 공통으로 사용되는 부분들을 BaseEntity로 묶어 관리하기로 했다.

이렇게 하면 중복되는 필드를 한 클래스에서 통합 관리할 수 있어, 추후 공통 필드를 추가,관리,삭제 할 때 효율적일 것이라 판단했다.

먼저 공통 필드로 떠올린 항목은 다음 세 가지였다.

  • id
  • createdAt
  • updatedAt

설계 과정

@Getter  
@EntityListeners(AuditingEntityListener.class)  
@MappedSuperclass  
@EqualsAndHashCode(of = "id")  
@SoftDelete  
public class BaseEntity {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @CreatedDate  
    @Column(nullable = false, updatable = false)  
    private LocalDateTime createdAt;  
  
    @LastModifiedDate  
    @Column(nullable = false)  
    private LocalDateTime updatedAt;  
}
  • ID값은 Mysql 을 사용하고 있어서 GenerationType.IDENTITY로 설계하였다. Mysql의 Auto_Increment 전략을 사용하고자 하였는데 단순하게 사용할 수 있기에 선택하였다.

  • createDate,updatedAt을 통해서 엔티티가 생성된 시점과 최종 수정된 시점을 저장하고자 하였다. 엔티티의 생성 시점과 변경시점을 알면 로깅을 할 때에 도움을 많이 줄 수 있다고 판단하였다. 또한 데이터를 주기적으로 삭제하거나 관리할 때 해당 시간을 통해서 관리할 수 있다는 장점도 유의미해보였다.

  • createDate의 경우에는 updatable = false 을 설정하여 원래의 의도에 맞게 변경되지 않도록 하였다. JPA가 Update sql문을 사용할때 해당 필드는 자동으로 제외되도록 하였다.

  • @EntityListeners를 붙혀주게 되면서 엔티티 생명주기 이벤트(persist,update,remove,load 등)가 업데이트 될때 마다, AuditingEntityListenr 클래스가 호출되어 @CreatedDate, @LastModified 어노테이션이 붙어있는 필드값을 채워주도록 하였다.

  • AuditingEntityListener 를 사용하기 위해서 메인 어플리케이션 클래스에 @EnableJpaAuditing 를 붙혀주었다. Spring data Jpa의 기본 설정에는 Auditing 기능이 꺼져 있기에, 시작 지점에서 활성화를 해주었다.

  • @EqualsAndHashCode(of = "id") 를 사용하여 Id기반으로 equals와 hash값을 정의하였다.

  • @MappedSuperClass 를 설정해주어 테이블은 생성되지 않고, 자식 엔티티 테이블에 필드가 합쳐진다.

  • @SoftDelete의 경우에는 프로젝트가 진행되고 후에 추가되었다. 추가하게 된 이유에 관해서 추가로 블로깅을 할 예정이다.

BaseEnity를 도입했을 때 장점

앞서 도입 과정에서 말했듯이 공통 필드에 대한 관리가 수월하였다. 매번 엔티티를 설계할 때 마다 반복되는 코드를 칠 필요가 없었고, 필드의 공통 변경 사항이 생길 때 수정이 용이해보인다.

또한 최근에 경험한 @SoftDelete를 사용하게 되었을 때 BaseEntity에서만 코드를 작성하니 변경 되는 부분이 매우 적게 softDelete를 사용할 수 있었기에 이점을 크게 느꼈던 것 같다.

BaseEnity를 도입했을 때 단점 혹은 주의할 점

BaseEntity에 공통 필드를 관리하다 보면 점차 커질 수 있을 것 같다. 불필요한 필드가 추가 될 경우 나중에 추가된 Entity에 필요 없는 필드가 강제로 생기는 경우가 있을 것 같다.

  • 이를 해결하기 위해 BaseEntity를 조합을 통해 하나의 BaseEntity 속성들을 쪼개서 사용하면 어느정도 해결할 수 있어보인다.

설계 회고

돌이켜보니 몇가지 발전시키거나 더 알아봐야 할 점들이 보였다.

  1. @EqualsAndHashCode 롬복 어노테이션 사용 시 주의점
    • 현재 @EqualsAndHashCode(of = "id") 를 사용하고 있는데 영속화 되지 않은 엔티티의 경우에 문제가 생길 수 있다고 생각되었다. 서로다른 두 객체가 id가 null이라 서로 같다고 인식될 수도 있고, 저장 후에는 Id값이 생겨서 해시가 바뀌어 객체를 다시 못찾는 경우도 있을 수 있을 것 같다.
    • 아직 Mysql의 Auto_Increment 전략을 사용하고 있어 문제가 없을 수 있었겠지만 해당 전략이 변경된다면 문제가 생길 수 있을 것 같다.
    • 가능성은 적겠지만 다른 엔티티의 객체와 equals를 비교하였을 때 Id값이 같은 상황도 생길 수 있을 것 같다. 따라서 equals를 하는 과정에서 class 타입까지 비교하면 해결 할 수 있어보이기에 개선해보면 좋을 것 같다.
    • 더 생각해보다보니 JPA가 만든 프록시로 인해서 서로다른 객체라고 인식하는 부분도 주의해서 설계해야할 것 같다.

[프록시를 통해서 다르다고 판단되는 경우]

User u1 = em.find(User.class, 1L);      // 실제 User 객체(비프록시)일 수 있음
Post p  = em.find(Post.class, 10L);
User u2 = p.getAuthor();                // LAZY → User의 프록시가 반환될 수 있음

System.out.println(u1.getClass()); // class com.example.User
System.out.println(u2.getClass()); // class com.example.User$HibernateProxy$abc123

// equals가 getClass()로 비교한다면:
u1.equals(u2); // false (클래스가 다르다고 판단)
  1. 추상 클래스 명시 도입 여부
    • 팀원들과 현재 클래스에 abstact,final 과 같이 추상클래스 혹은 불변 클래스를 명시 하고 있지 않은 상태였다. 다들 인지는 하고 있었지만 컨벤션을 정할때 추후에 결정하기로 하였던 부분이다. BaseEntity와 같이 의도가 명확한 엔티티인데 실수로 @Entity 어노테이션을 붙히는 것 처럼 의도와 다르게 사용될 경우가 많을 수 있을 것 같다. 따라서 클래스 단위에 abstract,final을 붙히는 것을 이야기 해봐야겠다.
  2. GenerationType.IDENTITY
    • 현재 Id 전략을 IDENTITY를 사용하고 있는데 해당 전략은 persist하는 과정에서 바로 Insert 쿼리문이 실행되기에 대량 Insert 시 JDBC 배치 최적화에 제약을 줄 수 있다. 현재 프로젝트에서 대량 삽입을 하는 과정이 있을까?
    • 돌이켜보면 사용자가 만든 Restaurant를 생성할때 생길 수 있다. 하지만 Restaurant를 인메모리를 사용하여 일회성으로 만들고 삭제하는 방향으로 갈 것 같다. 따라서 대량의 Insert를 사용하는 상황이 적을 것 같아 IDENTITY 전략을 그대로 가져가도 될 것 같다. 만약 DB에 저장하는 방식으로 대량의 Insert가 생긴다면 SEQUENCE 전략을 사용하고자 한다.
    • 이때 ID를 처음에 UUID를 사용해서 어플리케이션에서 직접 생성해서 관리하는 방향도 생각했었다. UUID를 사용하면 어떤 점들을 고려해야하는지 추가로 포스팅 해볼 예정이다.

회고를 통한 코드 재설계

위의 내용을 토대로 재설계를 해보자면 적용시킬만한 부분은 equals를 직접 구현하는 것과 추상클래스를 명시하는 것 정도이다.

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@SoftDelete
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @CreatedDate
	@Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

	@LastModifiedDate
    @Column(nullable = false)
	private LocalDateTime updatedAt;

    @Override
    public final boolean equals(Object o) {

        if (this == o) return true;

        if (o == null) return false;

        if (Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
        
        BaseEntity other = (BaseEntity) o;

        if (id == null || other.id == null) return false;

        return id.equals(other.id);

    }

    @Override
    public final int hashCode() {

        return Hibernate.getClass(this).hashCode();
    }
}
profile
하루하루는 성실하게 인생 전체는 되는대로

0개의 댓글