[번역] 값 객체의 힘 (with Spring Boot)

norimsu·2023년 3월 21일
1
post-thumbnail

우선, 기계적으로 변역을 하고 일부 문장에 대해서 의미에 맞게 수정하였습니다.

원문 : https://dev.to/kirekov/spring-boot-power-of-value-objects-1oah

이 글에서는 여러분께 말씀드리고자 합니다:

  1. 값 객체란 무엇이며 왜 그렇게 중요한가요?
  2. 그리고 스프링 부트 컨트롤러에서 이러한 패턴을 적용하여 코드를 더 안전하고 유지 관리하기 쉽게 만드는 방법에 대해 설명합니다.

이 리포지토리에서 코드 예제를 가져왔습니다. 이를 복제하여 전체 프로젝트가 작동하는 모습을 확인할 수 있습니다.

도메인

도메인은 매우 간단합니다. 엔티티가 하나뿐입니다. 어쨌든 하나의 클래스만 있어도 모호성과 데이터 손상이 발생할 수 있다는 것을 알 수 있습니다.

저는 Hibernate를 영속성 프레임워크로 사용하고 있습니다. 따라서 도메인 엔티티 User도 Hibernate 엔티티입니다. 하지만 제가 제안하는 아이디어는 Hibernate를 전혀 사용하지 않더라도 동일하게 유지됩니다.

먼저 데이터베이스 스키마부터 시작하겠습니다. 테이블이 하나 있습니다. 따라서 어렵지 않습니다.

사용자의 전화 번호를 저장하고 싶다고 가정해 보겠습니다. 다음은 SQL 테이블 정의입니다:

CREATE TABLE users
(
    id           UUID   PRIMARY KEY,
    phone_number VARCHAR(200) NOT NULL UNIQUE
);

타입은 간단합니다. 이제 해당 Hibernate 엔티티를 정의해 보겠습니다. 아래 코드 예시를 보세요.

@Entity
@Table(name = "users")
@Getter
public class User {
    @Id
    private UUID id;

    @Column(name = "phone_number")
    private String phoneNumber;
}

멋지지 않나요? 전화 번호는 String 타입입니다. 필드가 데이터베이스 열에 직접 매핑됩니다. 무엇이 잘못될 수 있을까요? 곧 보게 되겠지만, 많은 문제가 있습니다.

전화번호 무결성 문제

가능한 모든 String 값이 유효한 전화번호인가요? 물론 그렇지 않습니다. 이것이 바로 문제입니다. 사용자는 0, 음수 값 또는 some-unknown-value-string 을 전화번호로 입력할 수 있습니다.

등록 과정에서 어떤 사용자가 전화번호를 -78005553535로 설정했다고 가정해 보겠습니다. 분명히 오타가 있고 - 대신 + 기호가 있어야 합니다. 어쨌든 사용자는 실수를 알아차리지 못하고 설정을 적용했습니다. 나중에 다른 사용자가 이전 사람들을 찾아 그룹에 초대를 보내려고 합니다. 그 또는 그녀는 전화 번호 만 알고 있습니다. 그런데 갑자기 +78005553535 검색에 아무런 결과가 나오지 않습니다. 쿼리 입력은 완전히 정확하지만요. 이제 여러분의 애플리케이션이 수천 명의 사람들에게 서비스를 제공한다고 상상해 보세요. 단 1%의 사용자가 전화번호를 잘못 입력하더라도 데이터베이스의 값을 수정하는 것은 지루한 일이 될 것입니다.

이 문제를 어떻게 극복할 수 있을까요? 정답은 값 객체입니다. 아이디어는 간단합니다:

  1. 값 객체는 불변이어야 합니다.
  2. 값 객체는 비교 가능해야 합니다(즉, equals/hashCode 구현).
  3. 값 객체는 항상 올바른 값을 보유하도록 보장합니다.

아래의 첫 번째 시도인 PhoneNumber 선언을 보세요.

@Value
public class PhoneNumber {
    String value;

    public PhoneNumber(String value) {
        this.value = value;
    }
}

The @Value Lombok annotation generates equalshashCodetoString methods, getters and defines all fields as private final.

PhoneNumber 클래스는 우리가 정의한 첫 번째와 두 번째 요구 사항을 해결합니다. 그러나 여전히 유효하지 않은 전화번호(예: 0, -123, abc)로 클래스를 생성할 수 있습니다. 즉, 생성자 내부에서 유효성 검사 프로세스를 진행해야 합니다. 아래 수정된 코드 스니펫을 보세요.

@Value
public class PhoneNumber {
    private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();

    String value;

    public PhoneNumber(String value) {
        this.value = value;
        validatePhoneNumber(value);
    }

    private static void validatePhoneNumber(String value) {
        try {
            if (Long.parseLong(value) <= 0) {
                throw new PhoneNumberParsingException("The phone number must be positive: " + value);
            }
            PHONE_NUMBER_UTIL.parse(String.valueOf(value), "RU");
        } catch (NumberParseException | NumberFormatException e) {
            throw new PhoneNumberParsingException("The phone number isn't valid: " + value, e);
        }
    }
}

Google Libphonenumber 라이브러리를 사용하여 입력값의 유효성을 검사하고 있습니다.

문제를 해결한 것 같습니다. 객체를 구성하는 동안 항상 값의 유효성을 검사합니다. 그러나 약간의 세부 사항은 그대로 남아 있습니다. 이를 데이터 정규화(data nomalization)라고 합니다.

사용자 A가 전화번호를 88005553535로 설정하면 사용자 B가 검색창에 +78005553535 값을 입력해도 찾을 수 없습니다. 러시아 사람들은 이 전화번호를 동등하게 취급합니다.

이는 현지(local) 통화에서만 유효한 시나리오입니다. 어쨌든 일부 사용자는 이 시나리오를 기본으로 가정할 수 있습니다.

사실, 비즈니스 관점에서 동일한 유효한 입력값은 항상 동일한 출력 결과로 변환하여 모호성을 제거해야 합니다. 아래의 최종 PhoneNumber 클래스 선언을 보세요.

@Value
public class PhoneNumber {
    private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();

    String value;

    public PhoneNumber(String value) {
        this.value = validateAndNormalizePhoneNumber(value);
    }

    private static String validateAndNormalizePhoneNumber(String value) {
        try {
            if (Long.parseLong(value) <= 0) {
                throw new PhoneNumberParsingException("The phone number cannot be negative: " + value);
            }
            final var phoneNumber = PHONE_NUMBER_UTIL.parse(value, "RU");
            final String formattedPhoneNumber = PHONE_NUMBER_UTIL.format(phoneNumber, E164);
            // E164 format returns phone number with + character
            return formattedPhoneNumber.substring(1);
        } catch (NumberParseException | NumberFormatException e) {
            throw new PhoneNumberParsingException("The phone number isn't valid: " + value, e);
        }
    }
}

또한 동작을 확인하기 위해 몇 가지 단위 테스트를 작성해 보겠습니다.

class PhoneNumberTest {
    @ParameterizedTest
    @CsvSource({
        "78005553535,78005553535",
        "88005553535,78005553535",
    })
    void shouldParsePhoneNumbersSuccessfully(String input, String expectedOutput) {
        final var phoneNumber = assertDoesNotThrow(
            () -> new PhoneNumber(input)
        );
        assertEquals(expectedOutput, phoneNumber.getValue());
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "0", "-1", "-56"
    })
    void shouldThrowExceptionIfPhoneNumberIsNotValid(String input) {
        assertThrows(
            PhoneNumberParsingException.class,
            () -> new PhoneNumber(input)
        );
    }
}

실행 결과는 다음과 같습니다.

마지막 단계는 값 객체를 사용자 Hibernate 엔티티에 넣는 것입니다. 이 상황에서는 AttributeConverter가 유용합니다. 아래 코드 블록을 보세요.

@Converter
public class PhoneNumberConverter implements AttributeConverter<PhoneNumber, String> {
    @Override
    public String convertToDatabaseColumn(PhoneNumber attribute) {
        return attribute.getValue();
    }

    @Override
    public PhoneNumber convertToEntityAttribute(String dbData) {
        return new PhoneNumber(dbData);
    }
}

@Entity
@Table(name = "users")
@Getter
public class User {
    @Id
    private UUID id;

    @Column(name = "phone_number")
    @Convert(converter = PhoneNumberConverter.class)
    @NotNull
    private PhoneNumber phoneNumber;
}

원시 타입(raw type)과 비교하여 값 객체를 사용하면 어떤 이점이 있을까요? 여기 있습니다:

  1. PhoneNumber 인스턴스를 받으면 유효한지 확실히 알 수 있으므로 유효성 검사를 반복할 필요가 없습니다.
  2. 빠른 실패(fast-fail) 패턴. 전화 번호가 유효하지 않은 경우 예외가 빠르게 발생합니다.
  3. 코드가 더 안전합니다. 원시 타입을 비즈니스 값으로 전혀 사용하지 않는다면 모든 입력 값이 확실한 유효성 검사를 통과했음을 보장할 수 있습니다.
  4. Hibernate 사용자라면 JPQL 쿼리는 컨텍스트가 없는 String 타입이 아닌 PhoneNumber 값 개체를 반환합니다.
  5. 모든 검사를 하나의 클래스에 캡슐화합니다. 새로운 비즈니스 요구 사항에 따라 이를 조정해야 하는 경우 한 곳에서만 수행해야 합니다.

타입화된 엔티티의 ID (Typed entity's IDs)

프로젝트에 엔티티가 하나 이상 있을 수 있습니다. 그리고 대부분의 엔티티가 동일한 타입의 ID(이 경우 UUID 타입)를 공유할 가능성이 높습니다.

그게 무슨 문제일까요? User를 어떤 User Group에 할당하는 서비스가 있다고 가정해 봅시다. 그리고 이 서비스는 두 개의 ID를 입력 매개변수로 받습니다. 아래 코드 예시를 보세요.

public void assignUserToGroup(UUID userId, UUID userGroupId) { ... }

비슷한 코드 조각을 수십 개는 보셨을 겁니다. 어쨌든 누군가 이 코드 줄을 작성했다고 가정해 봅시다.

assignUserToGroup(userGroup.getId(), user.getId());

여기서 버그를 발견할 수 있나요? 실수로 ID를 바꿨습니다.(순서가 변경됨) 이런 실수를 했다면 운이 좋게도 SQL 문 실행 시 외래 키(foreign key) 위반이 발생한다면 다행입니다. 그러나 테이블에 외래 키가 없거나 할당이 성공적으로 진행되어 비즈니스 실행 결과가 올바르지 않게되면 큰 문제가 발생합니다.

그래서 assignUserToGroup 메서드 선언에 약간의 변화를 주려고 합니다. 아래 수정된 옵션을 보세요.

public void assignUserToGroup(User.ID userId, UserGroup.ID userGroupId) { ... }

이제 ID 스와핑 버그는 불가능합니다. 컴파일 타임에 오류로 이어질 수 있기 때문입니다. 더 좋은 점은 Hibernate 엔티티에 대한 접근 방식을 쉽게 구현할 수 있다는 것입니다.

@Entity
@Table(name = "users")
@Getter
public class User {
    @EmbeddedId
    private User.ID id;

    @Column(name = "phone_number")
    @Convert(converter = PhoneNumberConverter.class)
    @NotNull
    private PhoneNumber phoneNumber;

    @Data
    @Setter(PRIVATE)
    @Embeddable
    @AllArgsConstructor
    @NoArgsConstructor(access = PROTECTED)
    public static class ID implements Serializable {
        @Column(updatable = false)
        @NotNull
        private UUID id;
    }
}

이제 모든 findBy Spring Data 쿼리, 모든 사용자 정의 JPQL 문은 User.ID에서 작동하지만 원시 UUID 타입에서는 작동하지 않습니다. 또한 메서드 오버로딩에도 도움이 됩니다. 원시 ID(raw IDs)를 사용하면 서로 다른 엔티티의 ID를 허용하는 동일한 메서드가 필요한 경우, 이름을 다르게 지정해야 합니다. 하지만 타입화된 ID(Typed IDs)를 사용하면 그렇지 않습니다. 아래 예시를 보세요.

public class RoleOperations {

    // doesn't compile
    public boolean hasAnyRole(UUID userId, Role... role) {...}

    public boolean hasAnyRole(UUID userGroupId, Role... role) {...}
}

public class RoleOperations {

    // compiles successfully
    public boolean hasAnyRole(User.ID userId, Role... role) {...}

    public boolean hasAnyRole(UserGroup.ID userGroupId, Role... role) {...}
}

안타깝게도 시퀀스 기반 ID를 값 객체로 원활하게 래핑할 수는 없습니다. 해결책이 있지만 다소 번거롭습니다. 저는 이 기능에 대한 지원을 추가하는 제안을 Hibernate 타입 프로젝트(the proposal to the Hibernate types project)에 맡겼습니다. 이미 사용자 정의 타입의 이점을 보셨을 것입니다. 따라서 제 이슈를 더 인기 있게 만들기 위해 등급을 올려주실 수 있습니다. 하지만 여전히 클라이언트 측에서 번호 ID를 생성할 수 있습니다. 예를 들어 TSID 라이브러리가 이 작업을 수행합니다.

REST endpoints parameters

모든 애플리케이션 수준에서 값 객체를 사용하면 코드 복잡성이 줄어들고 더 안전해진다는 점을 지적한 바 있습니다. 하지만 REST 엔드포인트의 경우 상황이 그렇게 간단하지 않습니다. 두 가지 기능이 필요하다고 가정해 보겠습니다:

  1. 주어진 전화번호로 새 사용자 만들기.
  2. 제공된 전화번호로 기존 사용자 검색하기.

아래에서 가능한 구현을 살펴보세요.

@RestController
class UserController {

    @PostMapping("/api/user")
    void createUser(@RequestParam String phoneNumber) {
        ...
    }

    @GetMapping("/api/user")
    UserResponse getUserByPhoneNumber(@RequestParam String phoneNumber) {
        ...
    }

    record UserResponse(UUID id, String phoneNumber) {
    }
}

보시다시피, 다시 원시 타입 사용법으로 돌아갔습니다(즉, 아이디는 UUID, 전화 번호는 String). @RequestParam 타입을 PhoneNumber로 바꾸기만 하면 런타임에서 예외가 발생합니다. UserResponse 직렬화에서는 오류가 발생하지 않지만 클라이언트는 예기치 않은 형식으로 데이터를 수신하게 됩니다. Jackson(Spring Boot의 기본 직렬화 라이브러리)은 사용자 정의 유형을 처리하는 방법을 모르기 때문입니다.

다행히도 해결책이 있습니다. 먼저 SerdeProvider 인터페이스를 정의해 보겠습니다.

public interface SerdeProvider<T> {
    JsonDeserializer<T> getJsonDeserializer();
    JsonSerializer<T> getJsonSerializer();
    Formatter<T> getTypedFieldFormatter();
    Class<T> getType();
}

그런 다음 Spring 빈으로 등록된 두 개의 구현이 필요합니다. PhoneNumberSerdeProviderUserIdSerdeProvider입니다. 아래 선언을 보세요.

@Component
class PhoneNumberSerdeProvider implements SerdeProvider<PhoneNumber> {
    @Override
    public JsonDeserializer<PhoneNumber> getJsonDeserializer() {
        return new JsonDeserializer<>() {
            @Override
            public PhoneNumber deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
                final var value = p.getValueAsString();
                if (value == null) {
                    return null;
                }
                return new PhoneNumber(value);
            }
        };
    }

    @Override
    public JsonSerializer<PhoneNumber> getJsonSerializer() {
        return new JsonSerializer<>() {
            @Override
            public void serialize(PhoneNumber value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
                if (value == null) {
                    gen.writeNull();
                } else {
                    gen.writeString(value.getValue());
                }
            }
        };
    }

    @Override
    public Formatter<PhoneNumber> getTypedFieldFormatter() {
        return new Formatter<>() {
            @Override
            public PhoneNumber parse(String text, Locale locale) {
                return new PhoneNumber(text);
            }

            @Override
            public String print(PhoneNumber object, Locale locale) {
                return object.getValue();
            }
        };
    }

    @Override
    public Class<PhoneNumber> getType() {
        return PhoneNumber.class;
    }
}

UserIdSerdeProvider 구현도 비슷합니다. 소스 코드는 리포지토리에서 찾을 수 있습니다.

이제 해당 사용자 정의 프로바이더를 ObjectMapper 인스턴스에 등록하기만 하면 됩니다. 아래의 Spring @Configuration을 보세요.

@Slf4j
@Configuration
@RequiredArgsConstructor
@SuppressWarnings({"unchecked", "rawtypes"})
class WebMvcConfig implements WebMvcConfigurer {
    private final List<SerdeProvider<?>> serdeProviders;

    @Override
    public void addFormatters(FormatterRegistry registry) {
        for (SerdeProvider<?> provider : serdeProviders) {
            log.info("Add custom formatter for field type '{}'", provider.getType());
            registry.addFormatterForFieldType(provider.getType(), provider.getTypedFieldFormatter());
        }
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new Jdk8Module())
            .registerModule(new JavaTimeModule())
            .registerModule(customSerDeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }

    public com.fasterxml.jackson.databind.Module customSerDeModule() {
        final var module = new SimpleModule("Custom SerDe module");
        for (SerdeProvider provider : serdeProviders) {
            log.info("Add custom serde for type '{}'", provider.getType());
            module.addSerializer(provider.getType(), provider.getJsonSerializer());
            module.addDeserializer(provider.getType(), provider.getJsonDeserializer());
        }
        return module;
    }
}

그 후 초기 REST 컨트롤러를 리팩터링할 수 있습니다. 아래의 최종 버전을 보세요.

@RestController
class UserController {

    @PostMapping("/api/user")
    void createUser(@RequestParam PhoneNumber phoneNumber) {
        ...
    }

    @GetMapping("/api/user")
    UserResponse getUserByPhoneNumber(@RequestParam PhoneNumber phoneNumber) {
        ...
    }

    record UserResponse(User.ID id, PhoneNumber phoneNumber) {
    }
}

그 결과 애플리케이션에서 더 이상 원시 타입을 처리할 필요가 없습니다. 프레임워크는 입력값을 해당 값 객체로 변환하고 그 반대의 경우도 마찬가지입니다. 따라서 Spring은 값 객체를 생성하는 동안 자동으로 유효성을 검사합니다. 따라서 입력이 유효하지 않은 경우 비즈니스 로직을 전혀 건드리지 않고도 가능한 한 빨리 예외를 처리할 수 있습니다. 놀랍습니다!

결론

값 객체는 매우 강력합니다. 한편으로는 코드를 유지 관리하기 쉽고 일반 영어 텍스트처럼 읽을 수 있도록 도와줍니다. 또한 값 객체 인스턴스화에서 항상 입력의 유효성을 검사하기 때문에 더 안전합니다. 값 객체와 도메인 프리미티브(Domain Primitives)에 관한 훌륭한 책(this brilliant book)도 읽어보시라고 권해드릴 수 있습니다. 이 책을 읽으면서 이 글에 대한 영감을 얻었습니다.

질문이나 제안 사항이 있으시면 아래에 의견을 남겨 주세요. 읽어 주셔서 감사합니다!

profile
함께 나누자 (相賢)

0개의 댓글