스프링에서는 클래스에 @Entity 어노테이션을 붙임으로써 엔티티를 만들 수 있다.
JPA가 엔티티 클래스를 보고 데이터베이스에 쓰일 필드와 엔티티들 간의 연관 관계를 자동으로 정의해준다.
@Entity
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Builder
public class Notification extends BasicEntity
{
@Id
@Column(nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 계정
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user")
private User user;
// 종류
@NotNull
@Convert(converter = NotificationTypeConverter.class)
private NotificationType type;
// 제목
@NotNull
@Column(length = 255)
private String title;
// 내용
@NotNull
@Column(columnDefinition = "TEXT")
private String content;
// 열람했는가?
@NotNull
private boolean isRead;
// 열람 날짜
private LocalDateTime viewedAt;
// 알림을 읽음으로 표시
public void markAsRead() {
this.isRead = true;
}
}
위는 사용자에게 발송된 알림을 의미하는 Notification 엔티티이다.
찬찬히 뜯어보며 어떠한 설계 방식이 사용되었는지 찬찬히 확인해보자!
기본 생성자와 지연 로딩
@NoArgsConstructor 어노테이션은 매개변수가 없는 기본 생성자를 만들어준다.
클래스에 아무런 생성자가 없다면 자바는 기본 생성자를 자동으로 만들어준다.
하지만, 다른 생성자가 존재한다면 기본 생성자가 자동으로 만들어지지 않기 때문에, 해당 어노테이션을 붙임으로써 명시적으로 기본 생성자를 만들 수 있다.
그렇다면, 엔티티에 기본 생성자는 왜 필요한 것일까?
왜냐하면 JPA가 엔티티를 데이터베이스와 매핑할 때 기본 생성자를 사용하기 때문이다!
Reflection : 자바에서 구체적인 클래스 타입을 알지 못해도 해당 클래스 정보(메서드, 타입, 변수 등)에 접근할 수 있도록 해주는 API
JPA는 개발자가 어떤 타입의 엔티티를 생성할지 알 수 없다.
따라서, 어떤 타입으로 엔티티를 만들더라도 생성 가능하도록 Reflection을 사용하여 엔티티 객체를 만든다.
이때, Reflection을 사용하기 위해서는 엔티티에 기본 생성자가 붙어있어야 한다.
즉, 기본 생성자가 존재하지 않는다면 JPA가 데이터베이스에서 조회해 온 값을 엔티티로 변환할 때 객체를 생성할 수 없다!
따라서, 기본 생성자를 엔티티 클래스에 만들어 둬야 하는 것이다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
음~ 엔티티 클래스에는 기본 생성자가 필수적이구나~
근데 왜 기본 생성자의 접근 제어 레벨을 왜 protected로 설정한 것일까?
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user")
private User user;
이는 지연 로딩과 관련이 있다.
Notification 엔티티에서는 User 엔티티를 지연 로딩 방식으로 참조한다.
이를 통해, Notification 엔티티를 생성할 때, user 자리에는 User를 상속받은 가짜 프록시 객체가 삽입된다.
이후, 해당 user 엔티티 데이터가 실제 필요할 때, select 쿼리를 날려서 실제 User 엔티티를 가져오는 것이다.
즉, 지연 로딩 방식은 참조하는 엔티티가 실제로 필요할 때만 가져오는 방식이다!
이를 위해서는 가짜 프록시 객체가 참조하는 엔티티를 상속받아야 한다.
그런데, 이 상속하는 과정에서 엔티티의 기본 생성자가 사용된다.
엔티티의 기본 생성자의 접근 제어 레벨이 private라면 프록시 객체가 엔티티 객체를 상속받을 수가 없다.
따라서, 일단 엔티티의 기본 생성자의 접근 제어 레벨은 private일 수가 없다.
이러한 이유로, 기본 생성자의 접근 제어 레벨은 private가 아닌 protected로 사용한다.
public에 비해 안전한 접근 제어자를 사용함으로써 객체의 변경 가능성을 줄이고, 객체의 일관성을 최대한 유지하는 것이다.
이를 통해, 무분별한 객체 생성을 방지할 수 있다.
@AllArgsConstructor, @Setter
@AllArgsConstructor : 클래스의 모든 필드 값을 인자로 받는 생성자를 생성해준다.
@Setter : 각 필드에 대한 setter를 자동으로 생성해준다.
위 두 어노테이션은 간편하기 때문에 자주 사용된다.
먼저, setter를 살펴보자.
setter는 의도가 분명하지 않고, 해당 필드 값을 언제든지 변경할 수 있는 상태로 만든다.
따라서 객체의 안정성이 떨어진다.
public void markAsRead() {
this.isRead = true;
}
따라서 엔티티의 필드 값을 변경시킬 필요가 있다면, setter를 사용하기 보다는 위처럼 필드 값을 변경시키는 메서드를 생성해두는 것이 낫다.
이렇게 하면 엔티티 데이터를 어떻게 변경할 것인지 의도가 명확해진다!
다음으로, 엔티티에서 모든 필드를 인자로 받는 생성자를 보자.
빌더 패턴은 객체를 생성할 때 어떤 필드에 어떤 값이 대입될지 명확하게 볼 수 있고, 인자 간의 순서를 신경쓰지 않아도 되는 장점이 있는 디자인 패턴이다.
@Builder 어노테이션을 통해 쉽게 빌더 패턴을 적용할 수 있는데, 사용 방법은 2가지가 있다.
하나는 클래스 단위에 어노테이션을 붙이는 방법이고, 다른 하나는 생성자 단위에 어노테이션을 붙이는 방법이다.
만약 클래스 단위에 @Builder 어노테이션을 붙이려면 @AllArgsConstructor 어노테이션도 붙여야 한다.
@Builder와 @NoArgsConstructor만 같이 쓰면 오류가 발생하기 때문이다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
그런데, @AllArgsConstructor은 id와 같이 자동으로 값이 대입되는 필드에도 인자를 대입할 수 있게 하기 때문에 안전하지 않다.
그러므로, 위와 같이 AllArgsConstructor의 접근 제어 레벨을 private로 설정하는 방법을 사용할 수 있다.
기본 필드 값과 Auditing
public class Notification extends BasicEntity
Notification 클래스를 보면 BasicEntity라는 클래스를 상속받고 있다.
해당 클래스를 한 번 살펴보자!
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public class BasicEntity
{
@NotNull
@CreatedDate
private LocalDateTime createdAt;
@NotNull
@LastModifiedDate
private LocalDateTime updatedAt;
}
BasicEntity 클래스는 생성 시간과 수정 시간을 필드로 가지고 있다.
생소할 수 있는 어노테이션이 많을 수 있다!
먼저, @MappedSuperclass 어노테이션은 해당 클래스를 상속받는 엔티티들이 해당 클래스의 필드를 컬럼으로 인식하게 해준다.
즉, Notification 클래스는 BasicEntity 클래스를 상속받았기 때문에, createdAt과 updatedAt도 컬럼으로 가지게 된다!
여러 테이블에서 공통적으로 사용되는 컬럼을 정의할 때 유용한 기능이다!
@EntityListeners(AuditingEntityListener.class)
그렇다면 위 어노테이션은 뭘까?
Auditing : "감시하다"라는 뜻으로 Spring Data JPA에서 시간과 관련된 값을 자동으로 넣어준다!
해당 기능을 사용하면 엔티티의 생성 시간과 수정 시간 등의 데이터를 개발자가 직접 관리하지 않아도 된다!
Spring Data JPA가 자동으로 갱신해주기 때문이다!
- @CreatedDate : 엔티티가 생성될 때의 시간이 자동으로 저장됨
- @LastModifiedDate : 엔티티의 값이 수정될 때의 시간이 자동으로 저장됨
위의 어노테이션들을 createdAt, updatedAt 각각에 붙여줌으로써 엔티티의 생성 시간과 수정 시간이 자동으로 결정된다.
정말 편리하구만!
@SpringBootApplication
@EnableJpaAuditing
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
참고로 Auditing 기능을 사용하기 위해서는 애플리케이션 클래스에 @EnableJpaAuditing 어노테이션을 붙여줘야 한다~
@NotNull과 nullable = false
@NotNull
@Column(name = "title")
private String title;
@Column(name = "title", nullable = false)
private String title;
컬럼에 NOT NULL 제약 조건을 거는 방법은 위처럼 2가지 방식이 있다.
둘 다 컬럼에 null 값을 대입하는 것을 방지해준다.
그런데, 하이버네이트는 두 방식 중 @NotNull 어노테이션을 붙이는 방식을 권장한다!
두 방식은 모두 테이블을 생성하는 DDL에 not null 제약 조건을 붙여준다.
따라서, 데이터베이스 레벨에서 컬럼에 null 값이 대입될 수 없다!
하지만, DDL만으로는 애플리케이션 레벨에서까지 제약 조건을 설정할 수 없다.
즉, 애플리케이션에서 해당 컬럼의 값을 null로 가지는 엔티티를 테이블에 추가하려는 시도까지 막을 수는 없다는 것이다!
컬럼 제약 조건에 의해 결국 추가되지는 않겠지만 엔티티를 추가하는 insert 쿼리문에 대한 쓸데 없는 오버헤드가 발생한다.
nullable = false 방식은 DDL에 제약 조건을 추가하는 처리만 하기 때문에, insert 쿼리가 발생한다..
하지만 @NotNull 방식은 애초에 insert 쿼리가 발생하지 않도록 해준다!
애플리케이션 레벨에서도 null 검증을 수행해주는 것이다.
인덱스와 unique 조건
Notification 엔티티에는 인덱스와 unique 조건이 설정되어 있지 않다.
그러나, 다음 어노테이션을 엔티티에 붙임으로써 인덱스 파일을 만들거나 unique 조건을 걸 수 있다.
@Table(
indexes = {
@Index(name = "idx_userAndTitle", columnList = "user, title"),
@Index(name = "idx_user_by_createdAt", columnList = "user, created_at DESC")
},
uniqueConstraints = {
@UniqueConstraint(columnNames = {"user", "title"})
}
)
어노테이션만으로 간편하게 이런 것 까지 할 수 있다니!
Enum 컬럼
@NotNull
@Convert(converter = NotificationTypeConverter.class)
private NotificationType type;
데이터베이스의 컬럼에는 Enum 타입이 존재하지 않는다.
그런데 Notification 클래스에는 위와 같이 Enum 타입의 필드가 존재한다.
이는 데이터베이스 컬럼과 Enum 데이터를 매핑해주는 converter가 설정되어 있기 때문이다!
public class NotificationTypeConverter implements AttributeConverter<NotificationType, String>
{
// 데이터베이스에 저장할 때, 작동하는 함수
@Override
public String convertToDatabaseColumn(NotificationType notificationType)
{
if(notificationType == null)
return null;
return notificationType.name();
}
// 데이터베이스에서 가져올 때, 작동하는 함수
@Override
public NotificationType convertToEntityAttribute(String data)
{
if(data == null)
return null;
return NotificationType.valueOf(data);
}
}
converter는 위와 같이 정의되어 있다.
Enum 타입을 컬럼으로 사용하는 방법은 converter 말고도 @Enumerated 어노테이션을 사용하는 방식이 있다.
그러나, converter는 Enum 값의 이름이나 Enum 값 간의 순서가 바뀌는 경우에도 대비할 수 있다!
'개발' 카테고리의 다른 글
[ NAVER D2 ] NELO Alaska: 대용량 로그 데이터 저장을 위한 Apache Iceberg 도입기 (0) | 2025.05.12 |
---|---|
스프링 배치(Spring Batch) 알아보기 (0) | 2025.04.29 |
디자인 패턴 총정리 (0) | 2025.04.16 |
스프링 총정리 (0) | 2025.04.15 |
객체 지향 프로그래밍(OOP) 총정리 (0) | 2025.04.14 |