1. 너무 많은 필드를 보유한 도메인 객체
사이드 프로젝트로 쇼핑몰을 개발하던 중, 사용자 정보를 가지는 도메인 객체에 필드가 아래와 같이 많아졌습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
@Column(length = 30, unique = true, nullable = false)
private String email;
@Column(length = 400, nullable = false)
private String password;
@Column(name = "login_fail_count")
private int loginFailCount = 0;
private LocalDateTime signUpDate;
@Column(nullable = false)
private String telNo;
@Enumerated(EnumType.STRING)
private UserGrade grade;
private int orderCount;
private int amount;
@ColumnDefault("false")
private boolean isLock = false;
// 생성자 및 비지니스 도메인
}
위 정보들을 일렬로 나열해보니, 각각의 필드가 뭘 의미하는지 명확하게 다가오지 않았습니다.
실제로, 사용자 관련 도메인을 구현할 때는 인지하고 있었지만, 다른 도메인을 개발하다가 오랜만에(?) 사용자 관련 도메인을 확인해보려 해당 코드를 보면, "어디에서 어떻게 사용할려고 했더라?" 라는 생각을 간혹하게 됐던 것 같습니다.
제가 짠 코드도 뭐더라? 하고 찾아보는데, 다른 사람이 짠 코드를 보거나 반대로 제가 짠 코드를 다른 사람이 보는 상황이라면 이런 불편한 상황은 더 심해질 것이라고 생각했습니다.
그래서 개선을 해보고자, DDD 공부할 때 배운 JPA 의 기능과 객체지향 생활체조 원칙을 토대로 도메인 객체 리팩토링을 진행해봤습니다.
객체지향 생활체조 원칙 이란, 객체지향 프로그래밍을 하는데 있어 실천하면 좋은 습관을 정리한 원칙으로 자세한 항목은 아래와 같다.
1. 한 메서드에 오직 한 단계의 들여쓰기만 한다.
2. else 표현을 사용하지 않는다.
3. 모든 원시 값과 문자열을 포장한다.
4. 한 줄에 점을 하나만 사용한다.
5. 이름을 줄여 쓰지 않는다.(축약 금지)
6. 모든 엔티티를 작게 유지한다.
7. 3개 이상의 스위프트 기본 데이터타입(Int, String, Double 등) 프로퍼티를 가진 타입을 구현하지 않는다.
8. 일급 콜렉션을 사용한다.
9. getter/setter를 구현하지 않는다
2. 의미가 있는 객체를 새로운 class 로 정의 - @Embedded 활용
일단, 사용자 도메인 객체에서 어느 정도 의미가 묶이는 필드들을 분류해봤을 때, 아래와 같이 나뉘어졌습니다.
- 기본정보 - id, userName, signUpDate, telNo
- 로그인 관련 정보 - email, password, loginFailCount, isLock
- 회원등급 관련 정보 - grade, orderCount, orderAmount
이 중에 기본정보는 일단 놔두고, 로그인 관련 정보 와 회원등급 관련 정보 를 새로운 class 로 나눠봤습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
private LocalDateTime signUpDate;
private String telNo;
private LoginInfo loginInfo;
private UserGradeInfo userGradeInfo;
// 생성자 및 비지니스 메서드
}
public class LoginInfo {
@Column(length = 30, unique = true, nullable = false)
private String email;
@Column(length = 400, nullable = false)
private String password;
@Column(name = "login_fail_count")
private int loginFailCount = 0;
@ColumnDefault("false")
private boolean isLock = false;
// 생성자 및 비지니스 메서드
}
public class UserGradeInfo {
@Enumerated(EnumType.STRING)
private UserGrade grade;
private int orderCount;
private int amount;
// 생성자 및 비지니스 메서드
}
JPA 는 @Entity annotation 이 지정된 객체를 Database Table 로 간주하여 필드변수에 맞게끔 자동으로 테이블을 생성해주는 기능을 제공합니다.(spring.jpa.hibernate.ddl-auto 속성에 따라 테이블을 생성하지 않을 수 도 있습니다.)
예를 들면, class 를 분리하기 전에 User class는 애플리케이션 실행 시, 아래의 DDL 로 매핑되었습니다.
create table users (
amount integer not null,
is_lock boolean default false not null,
login_fail_count integer,
order_count integer not null,
id bigint generated by default as identity,
sign_up_date timestamp(6),
user_name varchar(20) not null,
email varchar(30) not null unique,
password varchar(400) not null,
grade varchar(255) check (grade in ('NORMAL','VIP','VVIP')),
tel_no varchar(255) not null,
primary key (id)
)
하지만, class 를 분리해둔 후에 애플리케이션을 실행해보면, 다음과 같은 오류가 발생합니다.
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Could not determine recommended JdbcType for `shoppingmall.userservice.user.domain.LoginInfo`
해당 오류는 User Entity 객체를 DB 테이블로 생성해야 하는데, User 에 속한 LoginInfo 라는 타입을 데이터베이스 자료형으로 변환할 수 없다는 의미입니다.
기본적으로 JPA가 Entity 를 DB 테이블로 변환시키기 위해, int / String / double 등의 타입을 데이터베이스 내에서 제공하는 특정 자료형으로 변환해주는데, LoginInfo 라는 타입은 그것이 불가능하다는 뜻입니다.
따라서, LoginInfo 라는 타입이 내부적으로 다른 유의미한 데이터를 담고있는 Value 타입이라는 것을 JPA 에게 알려주어야 하는데,
이때 사용하는 annotation 이 바로 @Embedded 와 @Embeddable 입니다.
@Embedded 와 @Embeddable 에 대한자세한 내용은 아래 포스팅을 참고해주세요.
https://byunsw4.tistory.com/16
결론적으로, Entity 객체가 가지는 Value 타입에는 @Embedded 를, @Embedded 의 대상이 되는 Value class 에는 @Embeddable 이 추가되어야 합니다.
위 코드에 @Embedded 와 @Embeddable 이 추가되면 아래와 같은 형태로 나타나게 됩니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
private LocalDateTime signUpDate;
private String telNo;
@Embedded // Value 타입을 사용하는 필드에 명시
private LoginInfo loginInfo;
@Embedded // Value 타입을 사용하는 필드에 명시
private UserGradeInfo userGradeInfo;
// 생성자 및 비지니스 메서드
}
@Embeddable // Value 타입에 명시
public class LoginInfo {
@Column(length = 30, unique = true, nullable = false)
private String email;
@Column(length = 400, nullable = false)
private String password;
@Column(name = "login_fail_count")
private int loginFailCount = 0;
@ColumnDefault("false")
private boolean isLock = false;
// 생성자 및 비지니스 메서드
}
@Embeddable // Value 타입에 명시
public class UserGradeInfo {
@Enumerated(EnumType.STRING)
private UserGrade grade;
private int orderCount;
private int amount;
// 생성자 및 비지니스 메서드
}
위와 같이 @Embedded 와 @Embeddable 을 표기하면, 아래처럼 DDL 이 생성됨을 확인할 수 있습니다.
create table users (
amount integer not null,
is_lock boolean default false not null,
login_fail_count integer,
order_count integer not null,
id bigint generated by default as identity,
sign_up_date timestamp(6),
user_name varchar(20) not null,
email varchar(30) not null unique,
password varchar(400) not null,
grade varchar(255) check (grade in ('NORMAL','VIP','VVIP')),
tel_no varchar(255) not null,
primary key (id)
)
DDL 의 결과를 확인해보면, class 로 나누기 전과 후가 차이가 없음을 확인할 수 있습니다.
결과의 차이가 없다면, 크게 의미가 없다고 생각할 수 도 있겠지만, 동일한 테이블 구조를 얻을 수 있다면, 코드 레벨에서 최대한 가독성이 있게 객체지향의 특징을 잘 살려서 구현하는 것이 좋지 않을까 하는 개인적인 생각을 해봅니다.
이제, 모든 필드를 다 가지고 있는 User Entity 객체가 Value 타입을 가지게 됨으로써 좀 더 간결해졌다고 생각합니다.
한 군데 더 적용을 해보고자 하는데, 바로 연락처 정보를 담는 telNo 입니다.
연락처 정보의 경우, "특정한 형태" 를 가지는 데이터입니다.
보통의 핸드폰 번호라면 010-XXXX-XXXX 라던가, 011/016/019-XXX-XXXX 형태를 지니게 될 것 입니다.
(후자의 방식은 과거에 사용되던 핸드폰 번호라 신규발급되진 않지만, 아직 사용 가능한 번호일 수 있어서 추가했습니다.)
그런데 이렇게 일정한 패턴을 가져야 하는 데이터에 이상한 데이터가 들어온다면 어떻게 될까요?
연락처니까 당연히 010-XXXX-XXXX 와 같은 형태라고 생각하고 개발을 하겠지만, 코드에서나 DB 스키마 상으로나 단순 String 에 불과할 것입니다.
그렇단 얘기는 사용자가 정해진 형태가 아닌 "이건 내 연락처에요" 와 같은 데이터를 넣을 수 도 있고 악의적인 요청에 의해 이상한 데이터가 생성될 수 도 있겠죠. 이러한 현상을 방지하기 위해, 특정 데이터에 대해서는 정규식 등을 활용한 패턴을 검증해야 하는 경우가 발생합니다.
위 코드에서 User Entity 에 해당 내용을 적용해보면, 아래와 같이 적용될 수 있겠습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
private LocalDateTime signUpDate;
@Column(nullable = false)
private String telNo;
@Embedded
private LoginInfo loginInfo;
@Embedded
private UserGradeInfo userGradeInfo;
// 생성자 및 비지니스 도메인
public User(Long id, String userName, LocalDateTime signUpDate, String telNo,
LoginInfo loginInfo, UserGradeInfo userGradeInfo) {
/* 객체 생성 시, 특정 데이터에 대한 포멧 검증 추가 */
if(telNo 가 연락처 포멧에 맞지 않는 데이터인 경우) {
throw new IllegalArgumentException("연락처 형태의 정보가 아닙니다.");
}
this.id = id;
this.userName = userName;
this.signUpDate = signUpDate;
this.telNo = telNo;
this.loginInfo = loginInfo;
this.userGradeInfo = userGradeInfo;
}
}
위 같은 경우, 기능상으로는 아무런 문제가 없습니다. 하지만, 객체지향 관점에서 본다면 조금 고민의 여지가 생긴다고 생각됩니다.
User class 가 연락처를 가지긴 하지만, 연락처가 제대로 넘어왔는지 조차 User 가 검증하는게 맞는건가 하는 생각이 들 수 있다고 생각합니다. 여기선 예시를 들지 않았지만, LoginInfo class 안에 email 필드도 존재할텐데, 이 email 도 LoginInfo class 가 없었다면 User class 에서 email 형태의 데이터인지 검증했어야 했을 것이고, 그렇다면 이런 데이터가 쌓이면 쌓일 수록 User class 의 생성자는 데이터 포멧을 검증하는 로직으로 가득 차게 될 것 입니다.
(그럼 위에서 User class 를 간결하게 만든 노력이 물거품이 되는 것일 지도 모르죠..)
이런 현상을 개선하고자, 맨 앞에서 설명한 "객체지향 생활체조 원칙" 중 "3. 모든 원시 값과 문자열을 포장한다." 이라는 원칙을 적용해보고자 했습니다. 여기서 말하는 원시 값은 흔히 프리미티브 타입(Primitive Type) 이라고 불리는 int/double/boolean 과 같은 자료형을 말합니다. 이러한 자료형을 그대로 쓰는 것이 아니라, 데이터가 가지는 의미를 나타낼 수 있는 별도의 class 로 해당 데이터의 의미를 개발자가 더 쉽게 파악할 수 있게 하기 위해 위와 같은 원칙이 생겼다고 이해해주시면 될 것 같습니다.
그래서 저는 String telno 이라는 데이터를 호장해서 TelNo 이라는 객체를 추가로 생성했습니다.
그리고 아래와 같이 User class 를 변경했습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
private LocalDateTime signUpDate;
@Embedded
private TelNo telNo;
@Embedded
private LoginInfo loginInfo;
@Embedded
private UserGradeInfo userGradeInfo;
public User(Long id, String userName, LocalDateTime signUpDate, String telNo,
LoginInfo loginInfo, UserGradeInfo userGradeInfo) {
this.id = id;
this.userName = userName;
this.signUpDate = signUpDate;
this.telNo = new TelNo(telNo);
this.loginInfo = loginInfo;
this.userGradeInfo = userGradeInfo;
}
// 생성자 및 비지니스 도메인
}
@NoArgsConstructor
@Getter
@Embeddable
public class TelNo {
@Column(nullable = false)
private String value;
public TelNo(String value) {
/* 데이터 유효성 검사를 상위 객체가 아닌 Value 타입에서 체크 */
if(value 가 연락처 포멧에 맞지 않는 데이터인 경우) {
throw new IllegalArgumentException("연락처 형태의 정보가 아닙니다.");
}
this.value = value;
}
}
앞에서와 동일하게 @Embedded 와 @Embeddable 을 사용해서 Value 타입을 JPA 가 판단할 수 있게 처리했고,
데이터 유효성은 User Entity 가 아닌, 별도의 TelNo 객체에서 수행하도록 변경된 형태입니다.
이런 식으로, 원시 값 혹은 문자열 객체를 새로운 객체화 한다면, 1) 최상위 객체(User) 에서 데이터 타입 만으로도 어떠한 데이터인지 의미를 쉽게 파악할 수 있고, 2) 해당 데이터(telNo)에 대한 비지니스 로직이나 데이터 검증이 필요하다면, 최상위 객체(User)와 상관없이 로직을 분리할 수 있다. 는 장점을 얻을 수 있을 것으로 보입니다.
3. @AttributeOverride 를 활용한 테이블 칼럼명 지정
위와 같이 흘러가면 아주 좋겠지만, 아래와 같은 변경사항이 발생했다고 가정해보겠습니다.
??? : "User 테이블에 email 필드는 실제로 Login Id 의 역할을 수행하니까, login_email 로 필드를 변경할거야."
이미 위와 같이 개발이 완료된 시점이라면, 위 변경사항을 적용하기 위해선 아래와 같은 작업이 필요할 것입니다.
- LoginInfo 객체 내에 email 필드 변수의 이름을 loginEmail 로 변경
- loginInfo.getEmail() 이 사용된 곳이 있다면, 모두 loginInfo.getLoginEmail() 로 변경
Getter 를 사용하지 않는다면 좋겠지만, 실제로 개발을 하다보면 어쩔 수 없이 getter 를 사용해야 하는 구간이 존재합니다.
(예를 들면, Entity 객체를 DTO 로 변환한다던가?)
위 케이스를 운영코드/테스트코드 를 모두 살펴봐야 하고, IDE 가 일을 잘해서 알아서 어디어디를 바꿔야하는지 알려준다해도, 실제로 코드를 수정하는 것은 개발자의 몫일 것입니다. 심지어 저런 변경이 한 두개가 아니다? 이런 일이 있으면 안되겠지만, 있다면 귀찮은 구간에서 시간을 많이 투자 해야겠죠...
이렇게, 코드 내에서 사용하는 필드 명과 DB 테이블에서 사용하고자 하는 칼럼명이 다른 경우, @AttributeOverride 라는 annotation 을 통해 해결할 수 있습니다.
@AttributeOverride를 적용하면 아래와 같이 User class 가 수정됩니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String userName;
private LocalDateTime signUpDate;
@Embedded
private TelNo telNo;
@Embedded
@AttributeOverrides({ // 데이터베이스 테이블 칼럼명 재정의
@AttributeOverride(name = "email", column = @Column(name = "login_email")),
@AttributeOverride(name = "password", column = @Column(name = "password")),
@AttributeOverride(name = "loginFailCount", column = @Column(name = "login_fail_count")),
@AttributeOverride(name = "isLock", column = @Column(name = "is_lock"))
})
private LoginInfo loginInfo;
@Embedded
private UserGradeInfo userGradeInfo;
// 생성자 및 비지니스 도메인
public User(Long id, String userName, LocalDateTime signUpDate, String telNo,
LoginInfo loginInfo, UserGradeInfo userGradeInfo) {
this.id = id;
this.userName = userName;
this.signUpDate = signUpDate;
this.telNo = new TelNo(telNo);
this.loginInfo = loginInfo;
this.userGradeInfo = userGradeInfo;
}
}
결과를 먼저 보자면, 아래와 같이 DDL 이 수정돼서 생성된 것을 확인할 수 있습니다.
create table users (
amount integer not null,
is_lock boolean default false,
login_fail_count integer,
order_count integer not null,
id bigint generated by default as identity,
sign_up_date timestamp(6),
user_name varchar(20) not null,
grade varchar(255) check (grade in ('NORMAL','VIP','VVIP')),
login_email varchar(255), -- email -> login_email 로 변경됨
password varchar(255),
value varchar(255) not null,
primary key (id)
)
@AttributeOverrides 와 @Attribute annotation 을 통해서 Value 타입의 필드의 칼럼명을 덮어쓰우는 효과를 얻을 수 있습니다.
@AttributeOverrides 는 @AttributeOverride annotation 을 배열형태로 지정할 수 있도록 지원해주는 annotation,
@AttributeOverride 는 name 과 column 2개의 속성을 가집니다.
- name : Value 타입 내부의 필드 변수 명
- column: name 에 명시된 필드에 덮어쓸 @Column annotation
4. 개인적인 생각
@AttributeOverride 의 경우, 사용에 대한 장단점이 어느정도 있어보입니다.
4-1. 장점
1. 데이터베이스 스키마로 부터 자유로운 코드를 작성할 수 있다.
데이터베이스 컬럼은 login_email 인데, 코드 내에선 loginEmail 이 아닌 단순 email 을 사용하는게 가시적으로 좋다고 판단되는 경우, 좋은 사용 예시가 될 수 있어보입니다. (물론 이 경우, @Column 에 직접적으로 name 속성을 명시해서 해결할 수 도 있습니다.)
2. 동일한 Value 타입을 여러 Entity 에서 사용하기 좋다.
LoginInfo라는 Value 타입이 있는 경우, 로그인에 관련된 정보가 포함되는데 여기에 쇼핑몰에서 물건을 구매하는 구매자의 로그인 뿐만 아닌 판매자의 로그인을 별도로 구현한다면, LoginInfo 타입을 구매자/판매자 Entity 모두에 적용할 수 있고, 칼럼명이 달라져야 하는 경우, 각 Entity 에서 @Column 을 override 할 수 있게되어 코드 중복을 줄이면서, 두 Entity 간 다른 스키마를 원하는대로 적용할 수 있어보입니다. 개인적인 생각으로 사실상 이 현상에 적용하기 위해 지원되는 기능이 아닌가 싶네요.
4-2. 단점
1. override 하는 속성이 많아지면 오히려 Entity 객체가 복잡해보일 수 있다.
Value 타입 하나 하나가 적은 필드를 가진다 한들, 그런 Value 타입을 여러 개 가지는 Entity 에 적용하는 경우, annotation 이 무분별하게 많아져서 Entity 객체가 복잡해보이는 역효과가 초래될 수 있을 것으로 보입니다. 물론, 이 경우 1개의 거대한 Entity 를 여러 개의 작은 Entity 로 나누어서 관리한다면 해결될 문제로 보입니다만, 이것 또한 장단이 있어보입니다.
2. 동일한 Value 타입을 여러 Entity 에 적용하면서 발생할 수 있는 문제가 있다.
앞선 장점에서 "여러 Entity 에서 사용하기 좋다." 는 점을 얘기하면서 생각난 단점입니다.
장점으로 생각했던 상황을 직접 경험해보셨던 우아한 테크캠프 과정 당시 리뷰어 분께서 코드리뷰를 달아주신 내용입니다.
(저는 아직 이런 문제상황을 실무에서 겪어본 적은 없는 것 같습니다. 애초에 공통 처리를 꺼려해서 그랬을지도...?)
위 조언을 보고 생각해보면, 동일한 Value 를 서로 다른 Entity 에서 공유해서 사용하는 것 자체도 비슷한 상황을 초래할 수 있지 않을까 라는 생각에 이 점은 장점이 될 수도, 단점이 될 수도 있을 것 같습니다.
'DB&JPA' 카테고리의 다른 글
[MySQL] 트랜잭션 격리수준 (0) | 2024.03.07 |
---|---|
[MySQL] 계정 생성 및 권한 부여, 그리고 역할 (0) | 2024.02.06 |
[JPA] JPA 쿼리 로그 및 파라미터 바인딩 확인하기 (SpringBoot3 버전) (0) | 2023.07.08 |
[QueryDSL] SpringBoot3 버전 QueryDSL 설정하기 (0) | 2023.06.30 |
댓글