엔티티 객체는 어떤 객체일까?
- 변별할 수 있는 사물 - Peter Chen (1976)
- 데이터베이스 내에서 변별 가능한 객체 - C.J Date (1986)
- 데이터를 저장할 수 있는 어떤 것 - James Martin (1989)
- 데이터가 저장될 수 있는 사람, 장소, 물건, 사건 그리고 개념 등 - Thomas Bruce (1992)
엔티티에 대해서 데이터 모델과 데이터 베이스 권위자들은 위와 같이 정의한다고 합니다.
즉, 엔티티 객체는 "비즈니스를 수행하기 위한 데이터를 저장하고 관리하기 위한 객체"라 할 수 있습니다.
이에 아래 UserEntity
는 비즈니스를 수행하기 위한 데이터뿐만 아니라 행위까지 가지고 있음을 확인할 수 있습니다.
public class UserEntity {
// 비즈니스를 수행하기 위한 데이터
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, updatable = false)
@CreatedDate
private LocalDateTime createdAt;
@Column(nullable = false)
@LastModifiedDate
private LocalDateTime updatedAt;
@Column(updatable = false, nullable = false)
@Size(max = 100)
private String userId;
@Builder.Default
@Column(updatable = false, nullable = false)
@Size(max = 255)
private String password;
// 비즈니스를 수행하기 위한 행위
// 분리가 필요한 부분
public boolean matchPassword(String password) {
return this.password.equals(password);
}
}
도메인 객체는 어떤 객체일까?
- An object model of the domain that incorporates both behavior and data. - Martinfowler
마틴 파울러에 따르면 도메인 객체는 데이터와 행위를 함께 가지는 것이라고 합니다.
엔티티 객체가 데이터베이스의 테이블과 대응하기 위해 필요한 객체라면 도메인 객체는 비즈니스의 요구사항을 수행하기 위한 데이터와 행위를 가지는 객체라 할 수 있습니다.
UserEntity
에서 회원가입/로그인 요구사항을 수행하기 위한 User
도메인 객체를 아래와 같이 정의할 수 있습니다.
public class User {
private final Long id;
private final UserId userId;
private final UserPassword password;
public User(Long id, UserId userId, UserPassword password) {
this.id = id;
this.userId = userId;
this.password = password;
}
public static User of(Long id, String userId, String password) {
return new User(id, new UserId(userId), new UserPassword(password));
}
public Long getId() {
return this.id;
}
public UserId getUserId() {
return this.userId;
}
public UserPassword getUserPassword() {
return this.password;
}
// 비즈니스를 수행하기 위한 행위
public boolean matchPassword(String password) {
return this.password.match(password);
}
}
요구사항 구현에서 도메인 객체
회원가입/로그인 요구사항 구현에 도메인 객체 도입 전후를 비교하며 도메인 객체의 장단점에 대해 알아봅시다.
도메인 객체 도입 이전 구현
public class AuthUserUseCase {
private final UserJpaRepository userDao;
public AuthUserResponse execute(AuthUserRequest request) {
String userId = request.getUserId();
String password = request.getPassword();
// 요청의 userId를 기준으로 저장된 정보를 조회한다.
Optional<UserEntity> optionalUserEntity =
userDao.findByUserIdAndDeletedFalse(userId);
// 조회 결과가 존재하지 않는다면 회원가입을 진행한다.
if (optionalUserEntity.isEmpty()) {
// userId와 password를 기반으로 UserEntity를 생성한다.
UserEntity entity = UserEntity.builder().userId(userId).password(password).build();
// UserEntity를 저장한다.
UserEntity savedUserEntity = userDao.save(entity);
// 요청에 대한 응답을 생성한다.
return AuthUserResponse.builder()
.id(savedUserEntity.getId)
.userId(savedUserEntity.getUserId())
.status(AuthStatus.SAVED)
.build();
}
// 조회 결과가 존재하지 않는다면 로그인을 진행한다.
UserEntity userEntity = optionalUserEntity.get();
// UserEntity의 password와 요청의 password가 동일한지 확인한다.
if (!userEntity.matchPassword(password)) {
throw new IllegalArgumentException("member.mismatch.password");
}
// 요청에 대한 응답을 생성한다.
return AuthUserResponse.builder()
.id(userEntity.getId())
.userId(userEntity.getUserId())
.status(AuthStatus.LOGIN)
.build();
}
public enum AuthStatus {
LOGIN,
SAVED
}
}
위의 구현을 살펴보면 엔티티 객체인 UserEntity
가 서비스 클래스인 AuthUserUseCase
에 직접적으로 사용되고 있음을 확인할 수 있습니다.
엔티티 객체를 직접 사용하여 요구사항 구현을 한다면 편리할 수 있지만 이로 인해 엔티티 객체의 변경이 서비스 클래스의 변경으로 이어질 수 있다는 문제를 가지게 됩니다.
당장의 구현의 편리도 중요하지만 기능 요구사항은 언제든 변경될 수 있기에 각 계층에 적절한 역할과 책임을 부여하고 계층마다 독립성을 보장하여 서로 협력하는 코드를 작성하는 것이 좋습니다.
이에 도메인 객체를 도입하여 엔티티 객체가 아닌 도메인 객체에 기능 구현에 대한 역할과 책임을 부여한다면 엔티티 계층과 서비스 계층을 서로 독립적이고 협력하는 관계를 구성할 수 있게 됩니다.
도메인 객체 도입
1. 도메인 객체를 정의합니다
엔티티 객체에 따라서가 아닌 어떤 요구사항을 위해 도메인 객체가 필요한지 고려하여 도메인 객체를 정의해야 합니다.
위의 User
객체는 회원가입/로그인 요구사항에 사용되는 객체로 해당 요구사항 구현에 필요한 id
, userId
, password
를 멤버로 가지는 객체로 정의하였습니다.
이때 userId
, password
는 요구사항에 따라 제한과 검증이 필요한 멤버이기에 별도의 클래스를 생성하였습니다.
├── entity
├─── UserEntity.java
├── domain
├──── model
├────── element
├─────── UserId.java
├─────── UserPassword.java
├───── User.java
2. 도메인 컨버터를 정의합니다
도메인 클래스는 엔티티로 저장한 데이터를 기반하여 생성되고 이를 위한 컨버터가 필요합니다.
저는 해당 컨버터를 유틸리티가 아닌 컴포넌트 컨버터로 구현하였습니다.
유틸리티 컨버터의 문제
도메인-엔티티 컨버터를 유틸리티 컨버터로 구성한다면 도메인이 엔티티 멤버에 대한 모든 정보를 알고 있어야 한다는 문제를 가지고 있습니다.
해당 문제는 엔티티의 일부 데이터만으로 구성된 도메인 객체를 사용하며 확인할 수 있었습니다.
UserEntity
의 password
데이터만을 가지고 있는 UserPassword
도메인 객체를 활용한 비밀번호 변경 요구사항 구현을 가정하여 유틸리티 컨버터를 사용하며 생길 수 있는 문제를 확인해 봅시다.
public class UserPasswordModel {
private final UserPassword password;
public void update(String password) {
this.password = new UserPassword(password);
}
}
// 비밀번호 변경 서비스 클래스 구현
UserEntity userEntity = userDao.findByUserId(userId).orElse(null); -- (1)
UserPasswordModel userPassword = UserPasswordModelConverter.from(userEntity); -- (2)
userPassword.update(newPassword); -- (3)
UpdatePasswordCommand command= UserCommandConverter.to(userPassword); -- (4)
UserEntity updatedUserEntity = UserConverter.to(command) -- (5)
구현의 (1)을 통해 UserEntity
는 Jpa의 영속성 콘텍스트에 등록되게 됩니다.
(2)에서는 userEntity
의 id
와 password
만을 활용하여 UserPasswordModel
를 생성합니다.
(3)을 통해 비밀번호를 변경하고 (4)를 통해 비밀번호 변경 명령을 위한 커멘드 객체를 생성합니다.
(5)에서 커멘드 객체의 정보를 기반으로 수정된 비밀번호가 포함된 updatedUserEntity
를 생성합니다.
Jpa는 마법사가 아니다
위의 구현에서 저는 (5)에서 생성한 updatedUserEntity
객체의 id
와 (1)에서 Jpa에 등록된 객체의 id
가 동일하기에 Jpa가 영속성 콘텍스트에 등록한 객체와 updatedUserEntity
를 비교하는 더티 체킹 작업을 수행할 것이라 기대하였습니다.
하지만 Jpa는 마법사가 아니고 그러한 작업은 수행되지 않음을 확인할 수 있었습니다.
userDao.save(updatedUserEntity); -- (6)
이에 더티 체킹이 아닌 위와 같이 명시적으로 save
메서드를 사용하여 업데이트 작업 수행하였습니다.
하지만 이는 역시 password
데이터는 변경되었지만 다른 기존의 데이터들이 null
로 변경되는 문제를 확인할 수 있었습니다.
위와 같은 문제가 생기는 이유를 파악하기 위해 디버깅을 해본 결과 해당 문제는 id
가 동일하면 Jpa가 영속성 콘텍스트에서 동일한 객체로 인식할 것이라는 저의 오해가 가져온 문제였습니다.
영속성 콘텍스트에서 등록된 객체를 조회는 id
를 기준으로 수행하지만 id
가 동일하다고 해서 Jpa가 동일한 객체로 인식하지는 않습니다.
이에 엔티티의 일부 데이터만 변경하기 위해서는 영속성 콘텍스트에 등록된 객체를 조작하거나 모든 기존 데이터와 함께 변경 값을 저장하여야 한다는 것을 알 수 있었습니다.
이를 통해 static
메서드를 사용하는 유틸리티 컨버터의 경우 메서드 내에서 영속성 콘텍스트에 접근하거나 기존 데이터를 조회할 수 없기에 도메인-엔티티 컨버터로 적합하지 않다는 판단을 할 수 있었습니다.
컴포넌트 컨버터
반면 컴포넌트 컨버터에서는 엔티티 매니저를 주입받아 영속성 콘텍스트에 접근하거나 레퍼지토리 클래스를 주입받아 기존 데이터를 조회할 수 있습니다.
// 컨버터 클래스
private final EntityManager em;
public UserEntity to(UpdatePasswordCommand command) {
if (command == null) {
return null;
}
UserEntity reference = em.getReference(UserEntity.class, command.getId()); -- (1)
return reference.toBuilder().password(command.getPassword()).build(); -- (2)
}
위 구현의 (1)의 경우 userXXXRepository.findById(command.getId()).orElse(null)
과 같이 레퍼지토리 클래스를 활용하는 방법으로 대체할 수 있습니다.
주목해야 하는 것은 (2)입니다.
(2)의 구현에서 주의할 점은 영속성 콘텍스트에 등록된 객체(reference
)를 사용한 것이 아닌 toBuilder
메서드를 활용해 변경된 값이 저장된 모든 기존 데이터가 포함된 새로운 객체를 생성하였다는 것입니다.
영속성 콘텍스트에 있는 객체를 사용하지 않고 새로운 객체를 사용하는 선택은 Jpa에서 제공하는 더티 체킹이라는 엔티티 수정 방법을 사용하지 못하고 save
메서드를 통해 명시적으로 업데이트 작업을 수행해주어야 한다는 단점이 존재합니다.
하지만 도메인 객체를 도입한 이유가 도메인 객체는 요구사항 수행에 대한 역할과 책임을 엔티티 객체는 데이터 저장과 관리를 위한 역할과 책임만을 담당할 수 있도록 하기 위함이었기에 위의 단점을 감수할 수 있다 판단하였습니다.
3. 도메인 객체가 요구사항을 수행할 수 있도록 합니다
public class User {
private final Long id;
private final UserId userId;
private final UserPassword password;
...
// 비즈니스를 수행하기 위한 행위
public boolean matchPassword(String password) {
return this.password.match(password);
}
}
public class UserId {
private final String userId;
public UserId(String userId) {
// UserId 생성에 대한 제한 사항 구현
this.userId = userId;
}
}
public class UserPassword {
private final String password;
public UserPassword(String password) {
// UserPassword 생성에 대한 제한 사항 구현
this.password = password;
}
// UserPassword가 수행하는 비즈니스
public boolean match(String password) {
return this.password.equals(password);
}
}
위와 같이 도메인 객체에 각자의 역할과 책임을 부여한 이후 서비스 구현은 아래와 같습니다.
public class AuthUserUseCase {
private final UserJpaRepository userDao;
public AuthUserResponse execute(AuthUserRequest request) {
String uid = request.getUid();
String userId = request.getUserId();
String password = request.getPassword();
// 요청의 userId를 기준으로 저장된 정보를 조회한다.
Optional<User> optionalUser =
userDao.findByUserIdAndDeletedFalse(userId).stream()
.map(userConverter::from)
.map(Optional::of)
.findFirst()
.orElse(Optional.empty());
// 조회 결과가 존재하지 않는다면 회원가입을 진행한다.
if (optionalUser.isEmpty()) {
// UserEntity 생성을 위한 커멘드 객체를 생성한다.
SaveUserCommand command = SaveUserCommand.of(userId, password);
// UserEntity를 저장하고 저장된 값을 User 객체로 변환한다.
User savedUser = userConverter.from(userDao.save(userConverter.to(command)));
// 요청에 대한 응답을 생성한다.
return AuthUserResponse.builder()
.id(savedUser.getId())
.userId(savedUser.getUserId())
.status(AuthStatus.SAVED)
.build();
}
User user = optionalUser.get();
// User의 password와 요청의 password가 동일한지 확인한다.
if (!user.matchPassword(password)) {
throw new IllegalArgumentException("member.mismatch.password");
}
// 요청에 대한 응답을 생성한다.
return AuthUserResponse.builder()
.id(user.getId())
.userId(user.getUserId())
.status(AuthStatus.LOGIN)
.build();
}
public enum AuthStatus {
LOGIN,
SAVED
}
}
도메인 객체 도입의 장단점
도메인 객체 도입의 장점
- 객체지향적인 코드를 작성할 수 있다.
- 서비스 계층과 엔티티 계층을 분리할 수 있다.
- 도메인 객체와 엔티티 객체가 각자에게 필요한 역할과 책임만 수행할 수 있다.
- 데이터와 관련 없는 비즈니스 요구사항이 추가되면 도메인 객체만 수정하면 된다.
도메인 객체 도입의 단점
- 구현해야 하는 코드가 많아져 복잡성이 올라간다.
- 더티 체킹을 사용할 수 없다.
'개발' 카테고리의 다른 글
데코레이터 패턴을 활용하여 행위를 정의하기 (0) | 2024.04.10 |
---|---|
인터페이스가 필요한 순간 (0) | 2024.04.09 |
GCP로 프로젝트 배포하기 (0) | 2024.04.02 |
Spring Batch로 와이파이 공유기 관리자 페이지 크롤링하기 (0) | 2024.03.17 |
Consumer Acknowledgements (0) | 2024.03.12 |