[JPA] JPA Entity를 팩토리 메서드로 생성하는 이유
최근에 JPA를 사용해서 회원가입 기능을 포함한 웹사이트를 개발해보았다. 오늘은 내가 받았던 피드백을 바탕으로, 어떻게 정적 팩토리 메서드가 코드를 리팩토링 해주는지에 대해 알아보려고 한다.
먼저 정적 팩토리 메서드(static factory method)란 인스턴스 생성을 위한 정적 메서드를 뜻하는데, 기본적으로 new 키워드를 사용해서 직접 객체를 생성하는 대신, 클래스 내에 정의된 정적 메서드를 통해 객체의 인스턴스를 반환받는 방식을 의미한다.
한번 예시로 정확하게 어떤 것을 의미하는지 알아보자.
회원가입 기능을 구현하기 위해서 기본적인 코드를 작성했다고 가정하자.
Controller에서 사용자 정보를 검증하는 로직을 거쳐서 해당 정보를 Service에 전달해 줬을 것이고, Service 내부에서는 전달받은 정보를 가지고 새로운 User 객체를 만들어서 Repository에 저장하려고 할 것이다.
User 객체를 만드는 과정을 집중해서 보도록 하자.
일반적으로 이런 코드를 생각해 볼 수 있다.
public SignUpResponseDto signup(SignupRequestDto requestDto) {
User user = userRepository.save(
new User(
requestDto.getUsername(),
requestDto.getPassword(),
requestDto.getEmail()
)
);
return new SignupResponseDto(user);
}
이 코드에서는 SignupRequestDto로 사용자 정보 중 유저이름, 비밀번호, 그리고 이메일 정보를 전달했으며, 그 정보를 User 생성자를 호출해서 사용하고, User 객체를 만들었다.
new 생성자 대신, 정적 팩토리 메서드를 사용해보면 다음과 같다.
public SignUpResponseDto signup(SignupRequestDto requestDto) {
User user = userRepository.save(
User.signupOf(
requestDto.getUsername(),
requestDto.getPassword(),
requestDto.getEmail()
)
);
return new SignupResponseDto(user);
}
바뀐 부분은 바로 User를 생성하는 방식이다. User.signupOf이라는 메서드가 어떤 형태인지 설명하기 위해서 아래 코드를 보자.
public class User {
// User 필드들
...
private User(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
public static User signupOf(String username, String password, String email) {
return new User(username, password, email);
}
}
정적 팩토리 메서드의 핵심은 바로 개발자가 구성한 static 메서드를 사용해서 간접적으로 생성자를 호출하는 객체를 생성하는 것이다. 지금 이 예시에서는 signupOf은 내부에서 User 생성자를 호출하고 있고, User 생성자는 private으로 처리함으로 인해 Service 로직에서 User 생성자를 직접적으로 호출하는 것을 막고 있다. 이런 방식으로 작성하는 정적 메서드를 정적 팩토리 메서드 패턴이라고 부른다.
그렇다면 도대체 왜, 멀쩡한 생성자를 냅두고 이렇게 한단계를 굳이 더해서 객체를 생성하는 걸까에 대해 궁금할 수 있다. 이것은 생성자를 직접적으로 호출하는 데 문제점이 있을 뿐더러, 개발자에게 있어 가독성이 더 좋은 코드를 개발할 수 있어서기도 한데, 이 이유에 대해서 더 자세하게 알아보자.
정적 팩토리 메서드의 특징을 알아보자.
1. 메서드의 이름으로 더 직관적으로 객체 생성 과정을 알 수 있다.
객체를 생성할 때 생성자를 사용하는 방법은 언제나 일관적이다. 객체 이름이 User라면, 생성자는 언제나 같은 이름으로 갈 수밖에 없다. 만약 외부 API를 사용해서 카카오, 구글 로그인을 도입하려고 한 경우를 살펴보자.
public class User {
private final String username;
private final String nickname;
private final String password;
private final String email;
private final String phoneNumber;
// 카카오톡 로그인
//// 카카오톡 로그인을 하기 위해서는 언제나 휴대폰 본인인증을 통한 실명 인증을 하기 때문에
//// 정확한 실명, 이메일, 전화번호를 입력값으로 사용할 수 있다.
//// 소셜 로그인 시 비밀번호는 알 수 없으므로 비워둔다.
public User(String username, String email, String phoneNumber) {
this.username = username;
this.nickname = username;
this.password = null;
this.email = email;
this.phoneNumber = phoneNumber;
}
// 구글 로그인
//// 구글 로그인을 할 때는 특별한 인증이 없기 때문에, username을 실명으로 입력값으로 사용할
//// 수 없다. 마찬가지로 전화번호 역시 인증을 따로 거치지 않았을 수 있으므로 사용하지 않는다.
//// 소셜 로그인 시 비밀번호는 알 수 없으므로 비워둔다.
public User(String nickname, String email) {
this.username = null;
this.nickname = nickname;
this.password = null;
this.email = email;
this.phoneNumber = null;
}
}
public static void main(String[] args) {
// 카카오톡 로그인
User kakaoUser = new User("홍길동", "yokxim@tistory.com", "000-1234-1234");
// 구글 로그인
User googleUser = new User("yokxim", "admin@tistory.com");
}
지금까지는 생성자를 사용해서 객체를 생성할 때, 생성 목적에 따라서 생성자를 오버로딩해서 다르게 사용해 왔다. 하지만 이 때 객체를 new 키워드를 사용해서 다른 패러미터를 넣어주게 될 때, 개발자가 그 생성자가 어떤 방식으로 오버로딩 되었는지를 객체 클래스 코드를 뜯어봐야만 알맞게 객체를 생성할 수가 있다는 번거로움이 있다.
예를 들자면 지금 위 코드에서는 사용자의 소셜 로그인을 위해 User 생성자를 호출하고 있는데, 이때 객체를 생성하는 방법이 두가지로 나뉘게 된다. 이는 생성자의 다른 구현 방식으로 나타나게 되고, 이를 호출하는 클라이언트 코드 쪽에서 생성자의 패러미터를 다르게 입력해 줌으로써 정상적인 호출이 가능해진다.
앞서 말했지만 이런 방식은 문제점이 있다. 바로 가독성이다.
물론 내가 이 코드를 직접 작성했고, User 생성자가 어떤 방식으로 호출되는지 알기 때문에 사용하는데 있어서는 큰 문제가 없다. 하지만 문제는 다른 개발자가 내 코드를 이어 받아 사용할 때, 혹은 배포를 해서 이 코드를 사용해야 하는 입장이 되었을 때이다. 다른 사람이 봤을 때 new User()에 몇개의 인자를, 몇번째에 어떤 타입의 인자를 넣어야 내가 원하는 소셜 로그인을 구현할 수 있는지 알기 위해서는 꼭 User 클래스 객체 코드를 뜯어봐야만 한다. 이때 User 클래스 내부 로직이 복잡하게 구현되어 있었다면 더더욱 알기 어려워진다.
하지만 생성자 특성상 다른 이름으로 바꿀 수가 없기 때문에, 이는 언어 설계상 어쩔 수 없다.
그렇기 때문에 내부 인자에 어떤 값을 넣어야 할지에 대한 정보를 이름으로 알려줄 수 있도록 메서드의 이름을 네이밍 해준다면 이 문제를 더 직관적으로 만들 수 있다. 예를 들자면 다음과 같다.
public class User {
private final String username;
private final String nickname;
private final String password;
private final String email;
private final String phoneNumber;
// private 생성자
private User(String username, String nickname, String password, String email, String phoneNumber) {
this.username = username;
this.nickname = nickname;
this.password = password;
this.email = email;
this.phoneNumber = phoneNumber;
}
// 정적 팩토리 메서드
public kakaoUserOf(String username, String email, String phoneNumber) {
return new User(username, null, null, email, phoneNumber);
}
// 정적 팩토리 메서드
public googleUserOf(String nickname, String email) {
return new User(null, nickname, null, email, null);
}
}
public static void main(String[] args) {
// 카카오톡 로그인
User kakaoUser = User.kakaoUserOf("홍길동", "yokxim@tistory.com", "000-1234-1234");
// 구글 로그인
User googleUser = User.goodleUserOf("yokxim", "admin@tistory.com");
}
이런 식으로 정적 메서드를 사용해서 적잘하게 메서드를 네이밍해준다면 이름만 보고도 어떤 소셜 로그인을 위한 메서드인지 한번에 알 수 있다.
2. 객체 생성의 자세한 로직을 클라이언트쪽에서 알 수 없게 한다.
생성자를 사용하게 되면 클라이언트에게 클래스 내부 로직을 드러내야만 한다. 하지만 정적 팩토리 메서드를 사용하면 클래스 내부 로직을 숨길 수 있다는 특징이 있는데, 여기서 얻을 수 있는 장점은 다음과 같다.
- 클라이언트에게 정보를 노출하지 않는다.
- 따라서 코드 의존성을 낮출 수 있다.
위 예시에서 kakaoUserOf와 googleUserOf 메서드는 소설 로그인 종류에 따라 필요한 필드만 채워서 객체를 생성하는 방식을 제공한다. 예를 들어서 kakaoUserOf는 username과 phoneNumber를 포함하지만, googleUserOf는 그렇지 않다. 이렇게 팩토리 메서드에서만 해당 필드 값에 대한 로직을 처리하므로, 클라이언트는 User 객체가 어떤 필드를 채우고, 비우는지를 신경 쓰지 않아도 된다는 장점으로 이어진다.
따라서, 객체의 생성 로직을 추상화하여 클라이언트가 알아야 하는 정보의 양을 줄일 수 있다.
정적 팩토리 메서드 네이밍
정적 팩토리 메서드를 사용할 때는 네이밍 컨벤션을 지키면서 사용하는 것이 좋다.
각 네이밍의 역할을 알아보자.
1. from : 하나의 매개 변수를 받아서 객체를 생성
Temperature temp = Temperature.fromFahrenheit(98.6);
2. of : 여러개의 매개 변수를 받아서 객체를 생성
Point point = Point.of(5, 10);
3. getInstance | instance : 인스턴스를 생성, 이전에 반환했던 것과 같을 수 있음
DatabaseConnection connection = DatabaseConnection.getInstance();
4. newInstance | create : 항상 새로운 인스턴스를 생성
Session session1 = Session.newInstance();
Session session2 = Session.newInstance();
5. get[OrderType] : 다른 타입의 인스턴스를 생성, 이전에 반환했던 것과 같을 수 있음
Order order = Order.getOnlineOrder();
6. new[OrderType] : 항상 다른 타입의 새로운 인스턴스를 생성
Order customOrder = Order.newCustomOrder();