최근 객체지향 프로그래밍에 더욱더 관심을갖고 찾아보던 중 객체지향 체조원칙을 알게되었다.
체조 원칙중 하나는 '모든 원시값과 문자열을 포장한다.' 라는 원칙이 있다.
처음 저 문장을 접했을 때는 원시값인 char, byte, short, int, long, boolean, float, double을 사용하지 말고
wrapper class를 사용하라는 말인줄로 착각했다.
내가 여태까지 해왔던 프로그래밍 스타일을 보았을때 원시값이 아닌 래퍼 클래스를 사용했던 경우는 null값이 들어올 수 있는 경우였다.
예를 들어 JPA를 사용하는 경우 데이터 베이스에 해당 데이터가 존재하지 않을 수 있기 때문에 래퍼 클래스를 사용했다.
하지만, 원칙이 의미하는 그런것이 아니었다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
private String name;
private LocalDateTime lastLoginDate;
public static Member of(String email, String name) {
Member member = new Member();
member.email = email;
member.name = name;
member.lastLoginDate = LocalDateTime.now();
return member;
}
public void changeEmail(Email email) {
this.email = email;
}
public void changeName(Name name) {
this.name = name;
}
public void setLastLoginDateNow() {
this.lastLoginDate = LocalDateTime.now();
}
}
먼저 원칙을 신경쓰지 않고 만든 코드를 보면 당연히 회원의 이메일, 이름은 String을 타입으로 사용한다.
큰 문제가 있어보이지는 않는다. 하지만 String이라는 타입을 보았을때 그 의미가 광범위하다는 문제가 있을 수 있다.
예를 들어 이름은 어떤 형식까지 허용하는지(특수문자, 공백등), 타입이 그 의미를 충분히 담고 있는지등의 단점이 있다.
그리고 프로그래밍 과정에서 실수로 name과 email을 바꾸어 저장한다면? 예기치 못한 데이터에 무결성 문제가 발생할것이다.
그래서 객체지향 체조 원칙을 준수하여 위의 회원 엔티티를 다음과같이 변경할 수 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Email email;
private Name name;
private LocalDateTime lastLoginDate;
public static Member of(Email email, Name name) {
Member member = new Member();
member.email = email;
member.name = name;
member.lastLoginDate = LocalDateTime.now();
return member;
}
public void changeEmail(Email email) {
this.email = email;
}
public void changeName(Name name) {
this.name = name;
}
public void setLastLoginDateNow() {
this.lastLoginDate = LocalDateTime.now();
}
}
@Embeddable
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class Email {
private static final String INVALID_EMAIL_FORMAT = "이메일 형식이 올바르지 않습니다.";
private static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@(.+)$";
private final String email;
public static Email of(String value) {
validateEmail(value);
return new Email(value);
}
private static void validateEmail(String value) {
Matcher matcher = Pattern.compile(EMAIL_REGEX).matcher(value);
if (value.isBlank() || !matcher.matches()) {
throw new IllegalArgumentException(INVALID_EMAIL_FORMAT);
}
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Email email = (Email) o;
return Objects.equals(this.email, email.email);
}
@Override
public int hashCode() {
return Objects.hashCode(email);
}
}
이렇게 VO를 사용하여 원시값과 String을 감싸면 다음과 같은 장점이 있다.
1. 타입만으로 객체의 비즈니스적인 의미를 명시할 수 있다.
타입이 의미하는 바가 단순한 문자열이 아닌 이메일, 이름과 같이 구체적인 도메인을 나타냄으로
코드를 직관적으로 나타낼 수 있습니다.
2. 타입 안정성을 강화하고, 데이터의 무결성을 보장할 수 있다.
이메일과 이름은 더이상 문자열이 아닌 Email과 Name이라는 객체를 참조하기 때문에 타입에 안정성을 강화할 수 있고
또한 Email과 Name은 불변 객체로써 의도치 않은 수정이나 오류를 예방하고
객체 내부에서 스스로 해당 값의 유효성을 검증함으로써 데이터의 무결성을 보장하고 스스로 일하는 객체로 나아갈 수 있다.
3. 의미있는 비교가 가능하다.
equals와 hashCode 메서드를 오버라이딩하여 내부의 값이 같다면 같은 객체로 판단하여
손쉽게 객체를 비교할 수 있다.
4. 확장성에 용이하다.
이메일이나 이름에 추가적인 검증 로직이 필요하다면 해당 클래스만 수정하면 되기때문에
손쉽게 확장이 가능하다.
VO를 사용해오지 않았다면 새로운 클래스를 생성하고 비교하는 작업이 귀찮게 느껴질 수 있지만
그 필요성과 중요성을 느끼게 된다면 훨씬 더 향상된 품질의 코드를 만들고
더 객체지향적인 프로그래밍에 다가갈 수 있다고 판단됩니다.
'OOP' 카테고리의 다른 글
콘솔 게시판 앱에 Composite, Command 패턴 적용하기 - 2 (0) | 2024.10.10 |
---|---|
콘솔 게시판앱을 만들며 Class와 Interface, DI 이해하기 - 1 (0) | 2024.10.10 |