*상위 문서: 코드 리뷰: 불완전한 객체에서 나타난 문제들
A. 불완전한 객체를 사용하면 안되는 이유.
1. 객체의 일관성이 없어져서 믿고 사용하기 어려워 집니다.
불완전한 객체의 존재가 가능해졌기 때문에 현재 객체가 어떤 상태일지 짐작하기 어려워 질 위험에 처했습니다. 그나마 setter를 public 하지 않게 사용한다면 Null Pointer Exception (NPE)가 예상치 못하게 생길 수 있을 수 있는 정도로 조금 더 문제의 범위가 줄어 들수 도 있지만, 예상을 할 수 없다는 것은 이미 객체간 협력에서 맡은 책임을 정확히 할 수 없다는 것입니다.
일관성이 없는 객체는 객체간 협력에서 맡은 바를 하기 힘들어져서 재사용하기가 어렵습니다. 결국 모델링된 설계대로 객체는 동작하지 않고 임시방편으로 코드를 추가하게 되는 경우 새로 생긴 코드는 기존의 코드와 함께 협업간 의도 파악의 복잡성을 더하게 되어 코드는 점점 레거시화되어 갑니다. 심지어 리팩토링 시기를 놓친다면 최악의 경우 설계를 다시하거나 프로젝트의 기능 추가와 유지보수는 서서히 미지와 두려움의 영역으로 자리 잡을 것입니다.
2. 객체의 책임이 모호해 집니다.
1번의 경우에서 최악의 시나리오로 간 경우인데, public 한 setter가 있거나 비즈니스를 담지 않은 무분별한 setter의 사용(매서드로 묶어서 의도를 분명하게 사용하는 것이 좋습니다.), 또는 불완전 객체를 만드는 팩토리 매서드가 의도가 정확하지 못할 경우 불완전 객체가 최초의 의도와 다르게 사용되어 이렇게 되었을 가능성이 높습니다. 의도가 명확하면 그나마 다른 책임으로 쓰이지 않을 수도 있지만, 이미 존재하는 것만으로도 위험성이 있기에 항상 운이 좋기를 기대 할 수는 없습니다. 기존의 의도와 맞지 않는 사용이 생기면, 결국 코드의 레거시화가 시작된 것입니다. 객체의 캡슐화가 깨졌고 SOLID 관점에서는 SRP가 깨져서 하나의 책임이 아니라 여러개의 책임을 가지게 되었을 수 있어서 객체가 가진 책임의 영역이 모호해 집니다.
2. NPE (Null Pointer Exception)
다행히 1 경우에서 setter의 사용이 제한되어 있어 무분별한 의도의 객체가 생성되고 있지 않을 경우 NPE만 문제가 될 수도 있습니다. 불완전 객체는 null 값을 의도치 않게 가지고 있을 가능성이 높습니다. 그렇지만 엔티티에게서 불완전한 객체를 받을 수 있다는 것은 되돌려받은 객체가 어떤 경우 null이 있는지 알기 어렵습니다. NPE 예측이 어려워지면 서비스 중에 예상치 못한 에러로 이어질 수 있으니 당연히 지양 되어야 합니다. null 이 꼭 필요하다면 적절한 유효성 검사로 널 처리를 의도에 맞게 해줄 수고 매서드의 이름으로 의도를 알 수 있도록 해야 합니다.
B. 해결방법
1. 유효성 검사.
생성때 부터 유효성 검사를 하여서 특정 상태를 가지게 합니다. null인 상황을 아예 배제 시키고 초기화 때 값을 강제 시키기에 불완전한 객체를 생성하지 않습니다.
@Builder
private MemberLocation(Double latitude, Double longitude, LocalDateTime timestamp, String fcmToken) {
if (latitude == null || longitude == null || timestamp == null || fcmToken == null)
{
throw new MemberException(NO_VALUE);
}
this.latitude = latitude;
this.longitude = longitude;
this.timestamp = timestamp;
this.fcmToken = fcmToken;
}
2. 접근 제어하여 캡슐화
이것 자체로 해결방법은 아니지만, 필드값과 setter를 private하게 두며 값을 넣어 줄 때 비지니스의 의미가 있는 매서드로 묶어서 사용하면 값이 의도와 다른 상황에서 변화하는 것을 막아서 캡슐화로 최소한 객체의 상태 변경을 제한 할 수 있습니다.
public void updateLocation(Double latitude, Double longitude, LocalDateTime timestamp) {
if (latitude == null || longitude == null || timestamp == null) {
throw new MemberException(NO_VALUE);
}
this.setLatitude(latitude);
this.setLongitude(longitude);
this.setTimestamp(timestamp);
}
3. 널 사용시 널 의도 분명하게 하기 + 테스트 코드
null이 있다고 항상 불완전한 상태는 아닙니다. 의도적으로 null을 쓸 수도 있고, 이 경우 null의 의도를 분명히 하여야 합니다. MemberLocation이 UNKNOWN인 상황에서는 null이 있도록 하였습니다. 이것과 함께 2번의 코드처럼 결합해 상태가 변하는 순간부터는 null이 아니도록 하였습니다.
public static MemberLocation UNKNOWN = new MemberLocation(null, null, null, "NOT_AVAILABLE");
또한, getter에서 Optional을 리턴하여 사용시 널을 유의하여 사용하도록 하였습니다.
public Optional<Double> getLatitude(Member member) {
return Optional.ofNullable(this.latitude);
}
추가로 테스트 코드까지 작성하여 기본값에 널이 있고 변경시는 널값이 있으면 안 됨을 알립니다.
//기본값
@Test
@DisplayName("기본값 확인")
void default_mode() {
//given
Member member = MemberFixture.create();
//when
MemberLocation memberLocation = member.getMemberLocation();
//then
assertEquals(memberLocation.getFcmToken(), MemberLocation.UNKNOWN.getFcmToken());
assertThrows(NoSuchElementException.class, () -> memberLocation.getLatitude(member).get());
assertThrows(NoSuchElementException.class, () -> memberLocation.getLongitude(member).get());
assertThrows(NoSuchElementException.class, () -> memberLocation.getTimestamp(member).get());
}
// 변경시 널값 있으면 안됨
static Stream<Arguments> nullCases() {
return Stream.of(
Arguments.of(null, 2.0, LocalDateTime.of(2021, 1, 1, 0, 0, 0), "위도 없음"),
Arguments.of(1.0, null, LocalDateTime.of(2021, 1, 1, 0, 0, 0), "경도 없음"),
Arguments.of(1.0, 2.0, null, "시간 없음")
);
}
@ParameterizedTest(name = "{index} - {3}")
@MethodSource("nullCases")
@DisplayName("위치 추가 - 예외 경우")
void addLocation_WHEN_NO_VALUE(Double latitude, Double longitude, LocalDateTime timestamp, String testName) {
//given
Member member = MemberFixture.create();
MemberLocation memberLocation = member.getMemberLocation();
//when
Exception exception = assertThrows(MemberException.class, () -> memberLocation.updateLocation(latitude, longitude, timestamp));
//then
assertEquals(exception.getClass(), MemberException.class);
assertEquals(exception.getMessage(), NO_VALUE.getDeatil());
}
4. 불변 객체 사용
위의 방법들도 있겠지만 객체를 믿고 사용하려면 불변 객체를 사용하면 더 좋습니다. 불변 객체는 생성후 상태가 변경 되지 않으므로 객체의 일관성을 보장합니다. 위의 방법들로 객체가 불완전하게 존재하는 것은 막을 수 있더라도 객체의 상태의 변화 가능성이 있다면 사용 시점에 일관성 있는 객체의 책임을 기대하기 어려울 수 있기 때문입니다. 불변 객체에 대해서는 다시 한번 포스트로 정리해 보려고 합니다. (안정성을 더하는 불변객체)
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public final class MemberLocation {
private final Double latitude;
private final Double longitude;
private final LocalDateTime timestamp;
private final String fcmToken;
//상태 변화시 새로운 객체를 넘겨 줍니다.
public MemberLocation updateLocation(Double latitude, Double longitude, LocalDateTime timestamp) {
if (latitude == null || longitude == null || timestamp == null) {
throw new MemberException(NO_VALUE);
}
return new MemberLocation(latitude, longitude, timestamp, this.fcmToken);
}
}
출처
테이트님: [EFFECTIVE JAVA] 이펙티브 자바 독서스터디 - 2장 객체 생성과 파괴
별별기록님: [OOP] 객체는 왜 일관성을 가져야 하는가
way to happiness님: [SpringBoot] 엔티티에는 setter를 두지 않는다. 확장성 있는 함수 구성
Kangworld님: [객체 생성 패턴] Chatper 4-1. Builder Pattern: 패턴 소개
+ 심화
'코딩 스탠다드 > OOP' 카테고리의 다른 글
DDD 관점에서 연관관계 (0) | 2024.11.24 |
---|---|
안정성을 더하는 불변객체 (0) | 2024.11.12 |