티스토리 뷰
빌더 클래스를 별도로 생성하지 않아도 빌더 패턴이 적용된다. 빌더 패턴을 사용하기 위해서는 반드시 생성자가 필요하다.
내가 처음에 설계했던 엔티티는 다음과 같다. 진짜 아무 생각 없이 @AllArgsConstructor, @NoArgsConstructor 그리고 @Builder 애너테이션을 사용했다. 그 결과 바로 피드백이 들어오게 되었다.
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Comment {
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty
private String content; // 작성 내용
private LocalDateTime createdDate; // 작성 시간
private LocalDateTime lastModifiedDate; // 수정 시간
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
}
위 코드는 3가지 문제가 존재한다.
- Auto Increment 전략을 사용하는데 클래스 단위에 @Builder를 왜 사용하는가?
- 엔티티에 등록일, 수정일 필드를 넣는 것은 반복되는 행위 아닌가?
- 엔티티에서 validation 하는 것이 좋은 방법인가?
하나하나 해결해보도록 하겠다.
Auto Increment와 @Builder
DB로 MySQL을 사용하고 있고 엔티티의 기본키 전략으로 Auto Increment 전략을 사용하고 있다. Auto Increment를 사용하면 개발자가 기본키의 값을 직접 지정할 필요가 없다. DB에 값이 저장되면서 자동으로 값이 저장된다.
클래스 단위에 @Builder 를 사용하면 id 필드 또한 직접 설정할 수 있는 가능성이 생긴다. 이는 객체지향의 본질과 맞지가 않다.
따라서 id 필드를 제외한 나머지 필드에 대한 생성자를 만들고 여기에 @Builder 를 추가한다.
수정된 코드는 다음과 같다. 참고로 @AllArgsConstructor 가 제거된 이유는 id 필드를 제외한 생성자를 만들기 위해서이다.
@Entity
@Getter
@NoArgsConstructor
public class Comment {
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty
private String content; // 작성 내용
private LocalDateTime createdDate; // 작성 시간
private LocalDateTime lastModifiedDate = null; // 수정 시간
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
// 생성자에 @Builder 사용
@Builder
public Comment(String content, LocalDateTime createdDate, LocalDateTime lastModifiedDate,
User user,
Section section) {
this.content = content;
this.createdDate = createdDate;
this.lastModifiedDate = lastModifiedDate;
this.user = user;
this.section = section;
}
}
등록일과 수정일을 자동화하는 @Auditing
엔티티 내부에 등록일과 수정일 필드를 직접 선언하였다. 클래스 내부에 필드가 존재하므로 개발자가 직접 필드에 접근해야 한다. 엔티티가 DB에 등록되는 시점에 등록일 필드에 현재 시간을 저장하고, 엔티티가 수정되는 시점에 수정일 필드에 현재 시간을 직접 저장해야 한다.
이는 좋지 않은 방법이다. 개발자가 실수로 잘못된 값을 저장할 수 있는 가능성이 존재한다. 또한 대부분 엔티티에는 등록일, 수정일 필드가 필요하기 때문에 개발자가 일일이 추가해야 하는 번거로움이 존재한다.
JPA는 이를 자동화 해주는 @Auditing 기능을 제공한다. 엔티티 내부에 등록일과 수정일 필드를 선언할 필요가 없다. 또한 등록 시점과 수정 시점에 따라 현재 시간을 저장할 필요도 없다. 모든 것을 알아서 해준다.
@Auditing 에 대한 방법은 여기를 참고하자. JPA가 제공하는 2가지 방법에 대해서 자세히 적어놓았다.
@Auditing 을 적용한 코드는 다음과 같다.
@Entity
@Getter
@NoArgsConstructor
public class Comment extends BaseEntity{
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty
private String content; // 작성 내용
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
@Builder
public Comment(String content, User user, Section section) {
this.content = content;
this.user = user;
this.section = section;
}
}
Validation 제거
엔티티를 보면 @NotEmpty 를 사용하고 있다. 엔티티에 값을 저장할 때 데이터를 검증한다는 의미이다.
먼저 설명하기에 앞서, 이 부분에 대해서는 옳고 틀리고가 없다. 개인마다 생각이 다르므로 참고만 하자.
일단 김영한님의 경우 DTO에만 유효성 검사를 선호한다고 한다. 그 이유로는 실무에서 너무 많은 부분에서 중복 체크가 이루어지고, 결과적으로 체크 로직을 여러 곳에서 관리하는 것이 한쪽을 누락할 가능성을 높인다고 한다. 따라서 원칙상으로는 엔티티, DTO 둘 다 검증을 하는 것이 좋지만 실용적인 관점에서는 DTO에만 유효성 검사를 한다고 한다.
나도 생각을 해보니 DTO에만 유효성 검사를 하는 것이 옳다고 생각했다. 그 이유로는 엔티티와 DTO의 역할이 다르기 때문이다.
- 엔티티 : 실제 DB 테이블과 매핑되는 클래스이다.
- DTO : Layer 간 데이터 교환이 이루어질 수 있도록 하는 객체이다.
DTO의 경우 클라이언트와 통신을 위해 사용한다. 즉, 목적 자체가 데이터를 읽고 쓰는 성격이 강하기 때문에 이곳에서 데이터 유효성 검사를 수행해야 한다고 생각한다.
수정된 엔티티는 다음과 같다.
@Entity
@Getter
@NoArgsConstructor
public class Comment extends BaseEntity{
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content; // 작성 내용
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
@Builder
public Comment(String content, User user, Section section) {
this.content = content;
this.user = user;
this.section = section;
}
}
@NoArgsConstructor 범위
현재 @NoArgsConstructor 의 범위를 지정하지 않았다. 범위를 지정하지 않았기 때문에 Public 상태가 된다.
Public 상태의 문제점은 캡슐화 상태가 아니라는 것이다. 외부에서의 무분별한 접근을 제한할 수가 없다.
그렇다면 Private으로 접근을 제한하면 되겠구나? 라고 생각할 수 있지만 그것은 불가능하다. 그 이유는 Proxy 객체를 생성할 때 문제가 발생하기 때문이다. Proxy 객체를 생성할 때 원본 엔티티를 상속받아 생성한다. 이때 실제 객체의 참조변수를 호출하기 위해 super 를 사용하게 되는데, 엔티티가 private이므로 참조할 수가 없게 된다.
따라서 @NoArgsConstructor을 사용할 때 외부에서 무분별한 접근을 방지하고자 access 레벨을 Protected로 사용해야 하는 것이다. 다시 한번 말하지만 Private 을 사용하게 되면 지연로딩 시에 Proxy 객체를 생성할 수 없는 문제가 발생한다.
다음은 수정된 코드이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends BaseEntity{
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content; // 작성 내용
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
@Builder
public Comment(String content, User user, Section section) {
this.content = content;
this.user = user;
this.section = section;
}
}
기존의 엔티티와 수정 후의 엔티티의 결과는 다음과 같다.
기존 엔티티
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Comment {
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty
private String content; // 작성 내용
private LocalDateTime createdDate; // 작성 시간
private LocalDateTime lastModifiedDate = null; // 수정 시간
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
}
수정된 엔티티
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends BaseEntity{
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content; // 작성 내용
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id")
private Section section; // 어떤 Section의 게시물에서 작성된 댓글인지
@Builder
public Comment(String content, User user, Section section) {
this.content = content;
this.user = user;
this.section = section;
}
}