[Spring Boot] 빌더 패턴(Builder Pattern)으로 우아하게 엔터티 관리하기 (feat. 연관관계 매핑)
안녕하세요! 오늘은 많은 Java와 Spring Boot 개발자들이 애용하는 빌더(Builder) 디자인 패턴에 대해 깊이 있게 알아보려고 합니다.
"객체를 생성할 때 생성자가 너무 복잡해요.", "필수 값과 선택 값을 어떻게 구분하죠?" 이런 고민을 해보셨다면, 이 글이 완벽한 해답이 될 수 있습니다. 특히 Spring Boot 환경에서 JPA 엔터티를 다룰 때 빌더 패턴이 얼마나 강력한 무기가 되는지, 그리고 매핑 관계의 중간 엔터티에서 필수 값을 강제하는 팁하는 방법을 알아봅시다.
1. 그래서, 빌더 패턴이 왜 필요한가요?
빌더 패턴을 이야기하기 전에, 우리가 흔히 겪는 문제 상황 두 가지를 먼저 살펴보겠습니다.
문제 1: 점층적 생성자 패턴 (Telescoping Constructor)
객체 생성을 위해 다양한 매개변수 조합을 가진 생성자를 여러 개 만드는 방식입니다.
// User.java
public class User {
private String email; // 필수
private String password; // 필수
private String name; // 선택
private String phoneNumber; // 선택
public User(String email, String password) {
this(email, password, null, null);
}
public User(String email, String password, String name) {
this(email, password, name, null);
}
public User(String email, String password, String name, String phoneNumber) {
this.email = email;
this.password = password;
this.name = name;
this.phoneNumber = phoneNumber;
}
}
// 사용 예시
User user1 = new User("test@test.com", "1234");
User user2 = new User("test@test.com", "1234", "김개발");
단점:
- 매개변수가 많아질수록 생성자 수가 기하급수적으로 늘어납니다.
- 어떤 매개변수가 어떤 값에 해당하는지 파악하기 어려워 실수하기 쉽습니다. (new User("test@test.com", "1234", null, "010-1234-5678") 처럼요!)
문제 2: 자바빈즈 패턴 (JavaBeans Pattern)
기본 생성자로 객체를 만든 후, setter 메서드를 이용해 값을 설정하는 방식입니다.
// User.java
public class User {
private String email;
private String password;
private String name;
private String phoneNumber;
// 기본 생성자 & 각 필드의 Getter, Setter
}
// 사용 예시
User user = new User();
user.setEmail("test@test.com");
user.setPassword("1234");
user.setName("김개발");
단점:
- 객체 생성이 여러 줄에 걸쳐 이루어져 코드 가독성이 떨어집니다.
- 객체 일관성(Consistency)이 깨질 수 있습니다. setter 메서드를 통해 값을 설정하는 도중에는 객체가 완전히 생성되지 않은, 불완전한 상태일 수 있습니다.
- 불변(Immutable) 객체를 만들 수 없습니다. setter가 열려있어 언제든지 객체의 상태가 변경될 수 있습니다.
2. 구원투수, 빌더 패턴의 등장!
빌더 패턴은 위 두 가지 문제점을 완벽하게 해결합니다.
- 가독성: builder().field(value).field(value).build() 형태로, 어떤 필드에 어떤 값이 들어가는지 명확하게 알 수 있습니다.
- 유연성: 메서드 체이닝을 통해 원하는 필드만 선택적으로 설정할 수 있습니다.
- 불변성: setter를 만들지 않고, build() 메서드를 통해 한 번에 객체를 생성하므로 불변 객체를 만들 수 있습니다.
3. Spring Boot 엔터티에 빌더 패턴 적용하기 (with Lombok)
과거에는 빌더 패턴을 적용하려면 빌더 클래스를 직접 구현해야 했지만, 이제는 Lombok 라이브러리의 @Builder 어노테이션 하나로 모든 것이 해결됩니다.
JPA 엔터티에 빌더 패턴을 적용해 보겠습니다.
// Post.java
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // (1)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
private String author;
@Builder // (2)
public Post(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
}
여기서 주목할 점 두 가지가 있습니다.
- @NoArgsConstructor(access = AccessLevel.PROTECTED): JPA는 DB에서 엔터티를 조회할 때, 리플렉션을 통해 객체를 생성하기 위해 기본 생성자를 필요로 합니다. 하지만 public으로 열어두면 개발자가 의도치 않게 new Post()와 같이 불완전한 객체를 생성할 수 있습니다. PROTECTED로 접근을 제한하여 JPA 스펙은 만족시키면서 안전성을 높이는 것이 핵심입니다.
- @Builder: 이 어노테이션을 클래스 또는 생성자에 붙이면, Lombok이 컴파일 시점에 자동으로 빌더 클래스를 생성해 줍니다.
이제 이 엔터티를 어떻게 생성할까요? 정말 간단하고 우아합니다.
// PostService.java (예시)
public void createPost() {
Post newPost = Post.builder()
.title("빌더 패턴 정말 편하네요!")
.content("내용은 이렇게 들어갑니다.")
.author("김개발")
.build();
// postRepository.save(newPost);
}
new Post(...) 보다 훨씬 명확하고, 어떤 값을 설정하는지 한눈에 들어옵니다.
4. 고급 활용: 연관관계 중간 엔터티에서 필수 값 강제하기
자, 이제 이 글의 하이라이트입니다. Member와 Post가 있고, 멤버가 게시글에 '좋아요'를 누르는 PostLike라는 중간 엔터티가 있다고 가정해 봅시다.
Member 1 <---> * PostLike * <---> 1 Post
PostLike 엔터티는 '누가(Member)' **'어떤 게시글(Post)'**에 좋아요를 눌렀는지가 핵심입니다. 즉, member와 post는 필수 값입니다. 이 필수 값들이 누락된 채 PostLike 객체가 생성되는 것을 막고 싶다면 어떻게 해야 할까요?
이때, @Builder를 클래스가 아닌 특정 생성자에 지정하는 기법을 사용합니다.
// PostLike.java
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostLike {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
// (핵심) 빌더 패턴을 이 생성자에만 적용!
@Builder
public PostLike(Member member, Post post) {
this.member = member;
this.post = post;
}
}
무엇이 달라졌을까요?
@Builder 어노테이션을 클래스 레벨이 아닌, Member와 Post를 매개변수로 받는 생성자에 직접 붙였습니다.
이렇게 하면 Lombok은 오직 이 생성자에 정의된 매개변수(member, post)만을 받는 빌더를 생성합니다.
이제 PostLike 객체를 생성해 봅시다.
// 올바른 사용법
Member findMember = memberRepository.findById(1L).get();
Post findPost = postRepository.findById(1L).get();
PostLike like = PostLike.builder()
.member(findMember) // member 필드는 필수로 제공해야 함
.post(findPost) // post 필드도 필수로 제공해야 함
.build();
// 컴파일 에러 발생!
// PostLike.builder().build(); // -> member와 post를 설정하라는 에러 발생
만약 @Builder를 클래스에 붙였다면 PostLike.builder().build()와 같이 member와 post가 null인 불완전한 객체 생성이 가능했을 겁니다. 하지만 생성자에 @Builder를 지정함으로써, 객체 생성 시점에 필수 연관관계 엔터티를 반드시 포함하도록 컴파일 레벨에서 강제할 수 있게 된 것입니다.
이는 실수할 여지를 원천적으로 차단하고, 도메인 모델의 안정성을 크게 향상시키는 매우 강력한 테크닉입니다.
5. 정리
오늘은 빌더 패턴의 기본 개념부터 Spring Boot 엔터티에 적용하는 실용적인 방법, 그리고 연관관계 매핑에서 필수 값을 강제하는 고급 팁까지 알아보았습니다.
- 빌더 패턴은 복잡한 객체 생성을 가독성 있고, 유연하며, 안전하게 만들어 줍니다.
- **Lombok의 @Builder**를 사용하면 코드를 매우 간결하게 유지할 수 있습니다.
- JPA 엔터티에서는 **@NoArgsConstructor(access = AccessLevel.PROTECTED)**와 함께 사용하는 것이 좋습니다.
- 연관관계 중간 엔터티에서는 필수 값을 받는 생성자에 @Builder를 지정하여 객체의 일관성과 안정성을 보장할 수 있습니다.
'개발일지 > SPRINGBOOT' 카테고리의 다른 글
[SpringBoot] 전략 패턴으로 카카오, 네이버, 구글 소셜 로그인 유연하게 구현하기 (2) | 2025.06.14 |
---|---|
[Spring Security] JWT를 이용한 토큰 기반 인증 (1) | 2025.06.08 |
JPA , 값 객체는 무엇이고 어떻게 활용하는가? (1) | 2025.06.04 |
JPA 에서 @Entity 간 연관 관계 (1) | 2025.06.04 |
예외 처리 , Springboot @RestControllerAdvice (1) | 2025.05.22 |