Spring Boot를 이용한 간단한 투두리스트 프로젝트를 진행하며 투두 아이템이 소속되는 목표 도메인을 만들고 있었다.
JPA Entity
DB ORM으로 JPA를 사용했고, 엔티티이자 도메인 클래스는 아래와 같다.
@Entity
@Table(name = "goal")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Goal extends BaseEntity {
private static final GoalStatus DEFAULT_STATUS = "IN PROGRESS";
private static final PrivacyType DEFAULT_PRIVACY_TYPE = "PRIVATE";
private static final int MAX_NAME_LENGTH = 50;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "name", nullable = false, length = 50)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "status", nullable = false, length = 20)
private String status = DEFAULT_STATUS;
@Column(name = "color", nullable = false, columnDefinition = "CHAR", length = 6)
private String color;
@Column(name = "privacy_type", nullable = false, length = 20)
private String privacyType;
@Builder
private Goal(String name, User user, String color, String privacyType) {
validate(name, user);
this.name = name;
this.user = user;
this.color = color;
this.privacyType = isNull(privacyType) ? DEFAULT_PRIVACY_TYPE : privacyType;
}
public void applyGoalUpdates(
String name, String status, String color, String privacyType
) {
validateName(name);
this.name = name;
this.status = status;
this.color = color;
this.privacyType = isNull(privacyType) ? DEFAULT_PRIVACY_TYPE : privacyType;
}
public boolean isCreatedByUser(Long userId) {
return Objects.equals(this.user.getId(), userId);
}
private void validate(String name, User user) {
validateName(name);
validateUser(user);
}
private void validateName(String name) {
if (isBlank(name)) {
throw new IllegalArgumentException(GoalErrorCode.BLANK_NAME);
}
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException(GoalErrorCode.EXCESSIVE_NAME_LENGTH);
}
}
private void validateUser(User user) {
if (isNull(user)) {
throw new IllegalArgumentException("사용자는 필수값입니다.");
}
}
}
도메인 로직을 강화하기 위해 도메인 생성 시의 검증 로직이 들어갔고, 빌더 패턴을 통해 안정성을 높이고자 했다. 이 때까지만 해도 JPA는 너무 편하기에 안 쓸 수는 없었지만, 마음에 안 드는 점이 몇가지 있었다.
도메인의 사소한 책임 증가
JPA를 사용하면 도메인이 뭔가 복잡해진다. 도메인 로직 뿐만 아니라 JPA 관련 애너테이션 등 Persistence를 위해 관련 코드들을 덕지덕지 붙일 수 밖에 없었다. 간단하게만 사용한다면 불편함을 못 느낄 수도 있지만, JPA와 그 구현체인 Hibernate의 정보량도 어마어마하다. 결과적으로 도메인이 비즈니스 규칙과 기술적인 규칙을 모두 책임지게 된다.
JPA의 리플렉션으로 인한 가변성
JPA와 Hibernate는 리플렉션 기반으로 객체를 만들어 영속화하기 때문에 불변 도메인 객체를 만들 수 없었다. 기본 생성자도 항상 필수였기 때문에 롬복의 `@NoArgsConstructor`를 항상 붙였고, 그나마 접근 제한을 protected로 낮춰 개인적인 기분을 풀었다. 불변 필드를 가질 수도 없고, 필드로 컬렉션이 존재한다면 해당 컬렉션도 외부에서 조작할 수 있어 안정성이 떨어질 수 있다.
사이드 이펙트
JPA를 사용하면 N+1 문제나 순환 참조 등 매핑에서 나오는 문제를 반드시 마주하게된다. 신경 안 쓰다보면 어느 순간 순환 참조, 혹은 `LazyInitializationException` 등의 예외를 마주하게 되고, N+1 문제를 방지하기 위해 항상 쿼리를 자주 보며 신경써주어야 했다. 그렇다고 매핑을 안 쓰면 JPA의 기능성을 무시하는 것 같아 손해보는 것 같은 느낌도 들고..
그럼에도 불구하고
위에 언급한 단점들 때문에, 혹은 다른 이유로 Mybatis나 jOOQ 등이 선호되는 이유가 분명하다. 하지만 그라운드 룰만 잘 만들어 지킨다면 JPA의 단점이 그리 거슬리진 않을 수 있고 신속하거나 가벼운 개발에서 JPA의 편리함은 너무 강력하다. JPA를 사용해보고 난 후 JPA를 안 쓴다는 생각은 해본 적 없는 것 같다.
VO와 Embedded Attribute
투두 아이템이 소속되는 목표는 고유한 색을 가지고 있다. 이 색은 RGB Hex코드로 저장되어야 하기에 6자리 `CHAR` 타입의 속성으로 DB에 저장된다. 이를 위한 특별한 검증이나 로직을 분리해 `Color`라는 VO를 만들었다.
@Getter
public class Color {
private static final int HEX_COLOR_CODE_LENGTH = 6;
private static final Pattern HEX_COLOR_CODE_PATTERN = Pattern.compile(
"^[0-9A-Fa-f]{" + HEX_COLOR_CODE_LENGTH + "}$");
private static final String DEFAULT_COLOR_CODE = "191919";
private String code;
public Color(String code) {
this.code = confirmCode(code);
}
private String confirmCode(String code) {
if (isBlank(code)) {
return DEFAULT_COLOR_CODE;
}
validate(code);
return code;
}
private void validate(String code) {
boolean matches = HEX_COLOR_CODE_PATTERN.matcher(code)
.matches();
if (!matches) {
throw new IllegalArgumentException(GoalErrorCode.INVALID_COLOR_FORMAT);
}
}
}
하지만 `Goal`객체에서 이를 바로 사용할 수는 없었다. JPA가 정상적으로 리플렉션하기 위해선 이에 대한 설정이 반드시 필요했기 때문이다. JPA가 정상 동작하기 위해선 다른 JPA 애너테이션이 필요하다.
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Color {
...
}
@Entity
@Table(name = "goal")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Goal extends BaseEntity {
...
@Embedded
@AttributeOverride(
name = "code",
column = @Column(name = "color", nullable = false, columnDefinition = "CHAR", length = 6)
)
private Color color;
...
@Builder
private Goal(String name, User user, String color, String privacyType) {
validate(name, user);
this.name = name;
this.user = user;
this.color = new Color(color);
this.privacyType = isNull(privacyType) ? DEFAULT_PRIVACY_TYPE : privacyType;
}
public void applyGoalUpdates(
String name, String status, String color, String privacyType
) {
validateName(name);
this.name = name;
this.status = status;
this.color = new Color(color);
this.privacyType = isNull(privacyType) ? DEFAULT_PRIVACY_TYPE : privacyType;
}
...
}
`Color`클래스에는 `@Embeddable`을 선언해주어야 하고, 대상 속성이 될 필드에는 `@Embedded`와 VO 필드들을 컬럼으로 선언하기 위해 `@AttributeOverride`가 필요하다.
Enumerated
목표 상태나 목표의 접근 대상을 판별하기 위한 접근 조건 등은 Enum을 활용해 분리하려고 했다.
public enum GoalStatus {
IN_PROGRESS,
DONE
}
public enum PrivacyType {
PRIVATE,
FOLLOWER,
PUBLIC
}
매우 간단하지만 Enum 사용으로 하드코딩을 피해 안정성을 높이고, 이후 관련 로직은 해당 Enum에 추가할 생각이었다. Java의 Enum 타입을 사용하기 위해 JPA에선 `@Enumerated`를 제공한다.
@Entity
@Table(name = "goal")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Goal extends BaseEntity {
private static final GoalStatus DEFAULT_STATUS = GoalStatus.IN_PROGRESS;
private static final PrivacyType DEFAULT_PRIVACY_TYPE = PrivacyType.PRIVATE;
...
@Enumerated
private String status = DEFAULT_STATUS;
@Enumerated
private String privacyType;
...
}
`@Enumerated`는 두가지 옵션을 제공한다. Enum 파일 내의 해당 Enum 아이템의 순서를 저장하는 `EnumType.ORDER`가 있고, Enum 아이템명을 저장하는 `EnumType.STRING`이 있다. 순서로 저장한다면 만약 Enum 파일에서 아이템들의 순서가 바뀐다면 치명적이기에 아이템명을 저장하고자 했다. 하지만 단순히 `@Enumerated(EnumType.STRING)`을 사용한다면 Hibernate는 DB의 ENUM 타입으로 매핑한다. 만약 해당 컬럼을 단순히 VARCHAR 타입으로 지정했다면 반드시 예외가 발생하기 때문에, 컬럼 정의 또한 클래스 필드에 추가해주어야 한다.
@Entity
@Table(name = "goal")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Goal extends BaseEntity {
...
@Column(name = "status", nullable = false, columnDefinition = "VARCHAR", length = 20)
@Enumerated(EnumType.STRING)
private GoalStatus status = DEFAULT_STATUS;
@Column(name = "privacy", nullable = false, columnDefinition = "VARCHAR", length = 20)
@Enumerated(EnumType.STRING)
private PrivacyType privacyType;
...
}
도메인 로직을 고도화 하기 위해 JPA를 잘 사용하기 위한 추가적인 기술적 리소스가 발생했다. 새로운 지식이 늘었다는 뿌듯함도 있었지만, 이것을 달성하기 위해 많은 시간을 소모해버렸다.
도메인과 엔티티의 분리
개발이 지속되고 도메인이 더 성숙해지면서 비즈니스 규칙은 변경되거나 추가되는 것이 많아질 것이라고 예상했다. 앞으로도 이러한 변경과 추가를 반영하기 위해 이와 비슷한 어려움을 마주하게 될 가능성이 다분하다고 생각한다. 그래서 도메인과 엔티티를 분리하고자 한다! 도메인 로직이 변경되는 것과 상관없이, 엔티티를 간단하게 설정해 DB 테이블과 매핑한다면 비즈니스 로직에만 더욱 집중할 수 있을 것이라고 생각한다.
순수한 도메인
JPA 관련 애너테이션들을 걷어내고 약간의 수정을 거친 목표 도메인은 다음과 같다.
@Getter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public final class Goal {
private static final int MAX_NAME_LENGTH = 50;
@EqualsAndHashCode.Include
private final Long id;
private final String name;
private final Long userId;
private final GoalStatus status;
private final PrivacyType privacyType;
@Getter(AccessLevel.NONE)
private final Color color;
@Builder
private Goal(
Long id, String name, Long userId, GoalStatus status, String statusInput, String color, PrivacyType privacyType, String privacyTypeInput
) {
validate(name, userId);
this.id = id;
this.name = name;
this.userId = userId;
this.status = Objects.requireNonNullElse(status, GoalStatus.of(statusInput));
this.color = new Color(color);
this.privacyType = Objects.requireNonNullElse(privacyType, PrivacyType.of(privacyTypeInput));
}
public String getColor() {
return color.getCode();
}
public Goal applyGoalUpdates(
String name, String color, PrivacyType privacyType
) {
validateName(name);
return buildCopy().name(name)
.color(color)
.privacyType(privacyType)
.build();
}
public Goal changeStatus(GoalStatus status) {
return buildCopy().status(status)
.build();
}
public void validateGoalCreator(Long userId) {
if (!isCreatedBy(userId)) {
throw new SecurityException(GoalErrorCode.INVALID_AUTHORITY.getCodeName());
}
}
public boolean isCreatedBy(Long userId) {
return Objects.equals(this.userId, userId);
}
public boolean isDone() {
return status == GoalStatus.DONE;
}
private void validate(String name, Long userId) {
validateName(name);
validateUser(userId);
}
private void validateName(String name) {
checkArgument(isNotBlank(name), GoalErrorCode.BLANK_NAME.getCodeName());
checkArgument(
name.length() <= MAX_NAME_LENGTH, GoalErrorCode.EXCESSIVE_NAME_LENGTH.getCodeName());
}
private void validateUser(User user, Long userId) {
checkArgument(nonNull(userId), GoalErrorCode.NULL_USER.getCodeName());
checkArgument(userId > 0, GoalErrorCode.NOT_POSITIVE_USER_ID.getCodeName());
}
private GoalBuilder buildCopy() {
return Goal.builder()
.id(id)
.name(name)
.userId(userId)
.status(status)
.color(color.getCode())
.privacyType(privacyType);
}
}
분리된 엔티티는 아래와 같다.
@Entity
@Table(name = "goals")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class GoalEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(
name = "userId",
nullable = false,
)
private Long userId;
@Column(
name = "name",
nullable = false,
length = 50
)
private String name;
@Column(
name = "status",
nullable = false,
length = 20
)
private String status;
@Column(
name = "color",
nullable = false,
columnDefinition = "CHAR",
length = 6
)
private String color;
@Column(
name = "privacy",
nullable = false,
length = 20
)
private String privacyType;
public static GoalEntity from(Goal goal) {
GoalStatus status = goal.getStatus();
PrivacyType privacyType = goal.privacyType();
return GoalEntity.builder()
.id(goal.getId())
.name(goal.getName())
.userId(goal.getUserId())
.status(status.getValue())
.color(goal.getColor())
.privacyType(privacyType.getValue())
.build();
}
public Goal toDomain() {
return Goal.builder()
.id(id)
.name(name)
.userId(userId)
.statusInput(status)
.color(color)
.privacyTypeInput(privacyType)
.build();
}
}
도메인 클래스에는 비즈니스 로직에 집중해 도메인의 추가와 분리 등에 대해 유연하고 신속한 개발을 지속할 수 있도록 만들었고, DB와 관련한 기술적인 부분을 엔티티로 분리했다. 두 클래스 사이의 변환은 오직 엔티티에서만 진행함으로써 도메인의 비즈니스 책임이 영향 받지 않고 의존성 방향이 `Entity -> Domain`으로 단방향이 되도록 구성했다. 그리고 사이드 이펙트를 방지하고 간단하게 JPA를 사용하기 위해 Relation mapping 또한 덜어내고, id 값을 사용하는 것으로 변경했다.
Enum에 대한 고민
Enum에 대한 부분은 고민이 필요하다고 생각한다. DB에 저장할 값이 Enum 상수의 name이 될지, 아니면 별도로 매핑되는 value가 될지에 따라 구현이 다를 것이라고 생각하고, API 요청으로 받을 값 또한 마찬가지로 name인지 특정 value인지 등에 따라 달라질 것이라고 생각한다.
예를 들어, 완료된 목표만 조회하는 API라고 가정해보자. 요청의 스펙은 다음과 같이 표현할 수 있다.
GET http://{service-domain}/api/v1/goals?status="DONE"
GET http://{service-domain}/api/v1/goals?status="완료"
첫번째 요청이라면 Enum을 자연스럽게 사용할 수 있다. 하지만 이는 UI와는 조금 동 떨어진 표현일 것이다. 프론트엔드 애플리케이션에서 별도로 Enum으로 관리하고 있거나 하다면 바람직하고 기분 좋은 사용일 것이다. 팀원들이 두번째와 같은 요청 스펙을 적극적으로 주장한다면 팀에 따라야한다고 생각한다.
DB에 저장되는 값 또한 마찬가지다. 둘 중 어떠한 값으로 저장할지, 혹은 아예 개별적인 코드성 데이터로 관리할 지 등을 정한다면 Enum을 사용할 수 있는 정도는 달라질 것이라고 생각한다. 아예 종류가 빈번하게 변경되거나 추가/삭제되는 코드성 데이터라면, 애초에 Enum이 아니라 DB 테이블로 관리하는 것이 더욱 나은 선택일 것이다.
이후의 방향성
도메인과 엔티티를 분리함으로써 비즈니스 로직에 더욱 시간을 쏟을 수 있었고, JPA는 간략하게 사용해 편의성을 유지할 수 있었다.
이렇게 분리하고 나니 영역이 명확하게 분리되어 보였다.
애플리케이션의 진입점이자 API 엔드포인트를 담당하는 컨트롤러의 영역, 실질적인 비즈니스 로직이 담겨있는 비즈니스 영역, 그리고 DB와의 기술적인 매핑과 커맨드/쿼리를 담당하는 Persistence 영역으로 분리가 명확해졌다.
이와 같이 애플리케이션이 비즈니스에 명확하게 집중될 수 있도록 영역이 분리된 아키텍처를 고려하고 서치하다보니, 자연스럽게 헥사고날 아키텍처를 접하게 되었다. 엔티티와 도메인의 분리로 시작해 헥사고날 아키텍처의 멀티모듈로 전환하게 된 이야기를 다른 포스트에서 다루고자 한다.
Ports & Adapters Architecture
The Ports & Adapters Architecture (aka Hexagonal Architecture) was thought of by Alistair Cockburn and written down on his blog in 2005. This is how he defines its goal in one sentence: Allow a…
herbertograca.com