우아한테크코스 7기 프리코스 3주 차 [BE]

Yehyeok Bang·2024년 11월 6일
9

회고

목록 보기
8/9
post-thumbnail

약 3주 간 재밌게 몰입한 나에게 칭찬하며, 회고를 작성하려고 합니다.

로또

저는 오늘 하루가 지루하다고 느끼면 1등이 되면 집부터 사고... 이런 상상을 하면서 종종 로또를 구매했습니다.

이번 미션이 로또인 것을 핑계로 저번 주에도 구매를 했어요.

...

아쉽지만, 그래도 저는 현실 로또 구매 과정에서 객체 분리에 대한 힌트를 얻었습니다.

객체와의 협력

생각하라, 객체지향처럼 - 김승영 포스팅에서는 객체지향의 사실과 오해 부분 중에서 ’07장. 함께 모으기’ 중 커피 전문점 도메인 설계 및 구현 예제에 대한 내용을 설명 하듯이 작성하신 것을 볼 수 있어요.

여기서 손님, 메뉴판, 메뉴 항목들(4가지), 바리스타, 커피(4가지)가 각각 하나의 객체가 될 수 있습니다. (저는 처음에 혼자 생각해볼 때 4가지의 메뉴 항목들까지 객체로 생각하지는 못 했습니다. 근데 사실 아직도 어색하긴 합니다..ㅋㅋ)

이런 관점으로 저도 로또 판매점에 들어가서 로또를 구매하기 위한 행동을 적어봤어요.

  1. 로또 판매점 들어가기
    1.1 (선택) 번호를 선택하기
  2. 직원에게 돈을 전달하며 원하는 개수를 말하기
  3. 개수에 맞게 발급 하신 로또를 받기

여기서 저는 로또 판매점, 판매 직원, 로또 발급기를 객체로 만들 수 있겠다고 생각했습니다.

  • 로또 판매점 : 판매 직원이 배치되어 있으며, 이 직원에게 손님이 접근할 수 있습니다.
  • 판매 직원 : 로또 발급기를 가지고 있으며, 손님에게 받은 금액을 확인한 후 발급기에 원하는 개수를 입력합니다.
  • 로또 발급기 : 직원이 입력한 개수만큼 로또를 랜덤하게 발급합니다.

덕분에 각 객체가 하나의 책임만 가지도록 설계하여 기능을 분리했고, 각 기능을 독립적으로 테스트할 수 있었습니다.

다만 아쉬운 점은 1.1 (선택 사항) 번호를 직접 선택하기 부분처럼, 랜덤으로 생성되는 로또가 아니라 사용자가 번호를 직접 선택하는 기능(현실 로또의 수동)을 추가할 때 확장성을 고려하지 못한 점입니다. 이를 개선하기 위해 전략 패턴을 활용하여 번호 생성 로직을 유연하게 변경할 수 있었다면 더 나은 설계가 되었을 것 같다고 생각합니다.

이외에도 당첨 번호(보너스 번호)와 결과 평가자, 규칙 등을 분리하고 단일 책임 원칙을 의식적으로 지키기 위해 노력했습니다.

로또를 구매한 토요일에 위와 같은 구조가 떠올라서 바로 추가했습니다.

4주 차 미션인 편의점도 다녀오면 좋을 것 같아서 고민하고 있습니다.

검증 책임은 어디에?

객체 내부에 (1주 차)

public record Separator(String regex) {

    public Separator {
        validateRegex(regex);
    }

    private void validateRegex(String regex) {
        if (regex == null || regex.isEmpty()) {
            throw new IllegalArgumentException("구분자를 찾을 수 없어요. 입력하신 커스텀 구분자를 확인해주세요.");
        }
        if (regex.length() != 1) {
            throw new IllegalArgumentException("구분자는 한 글자여야 해요. 다른 구분자를 사용해주세요.");
        }
        if (regex.matches(Constants.POSITIVE_NUMBER_REGEX)) {
            throw new IllegalArgumentException("숫자는 커스텀 구분자로 사용할 수 없어요. 다른 구분자를 사용해주세요.");
        }
        if (UnsupportedSeparatorType.isUnsupported(regex)) {
            String reason = UnsupportedSeparatorType.getReason(regex);
            String message = String.format("해당 구분자(%s)는 커스텀 구분자로 사용할 수 없어요. (사유: %s) 다른 구분자를 사용해주세요.", regex, reason);
            throw new IllegalArgumentException(message);
        }
    }
}

1주 차 미션에서는 각 객체가 자체적으로 검증 로직을 가지도록 구현했습니다. 객체가 직접 자신의 상태를 검증하게 하여, 각 객체가 자신의 책임을 지도록 만든 것입니다. 하지만 이 방식으로 구현하다 보니, 클래스 파일에서 검증 로직이 전체 코드의 절반 이상을 차지하는 경우가 생겼습니다. 결과적으로 해당 클래스가 원래 해야 하는 역할이 검증 로직에 가려져서, 클래스의 책임을 한눈에 파악하기가 어려웠습니다.

여러 방법을 직접 경험하고 더 좋다고 판단되는 것을 고를 수 있는 개발자가 되고 싶었습니다. 그래서 2주 차 미션에서는 검증 로직을 한 곳으로 모아서 책임지는 객체를 만들어보기로 했습니다.

외부에 (2주 차)

public class RegistrationValidator {
    
    // 상수들

    private RegistrationValidator() {
    }

    // 검증 메서드들
}

// 위 메서드들은 신청 폼을 책임지는 객체에서만 사용합니다.

2주 차 미션은 자동차 경주를 주제로 했는데, 저는 자동차 경주에 참가 신청하는 과정을 코드로 표현하고자 했습니다. 이를 위해 등록(신청 폼) 검증 클래스를 별도로 만들고, 사용자가 입력한 데이터를 바탕으로 신청 폼을 생성할 때 이 클래스에서 검증이 이루어지도록 했습니다.

모든 검증 로직이 한 곳에 모여 있어서, 어느 부분에서 어떤 검증이 수행되는지 한눈에 파악할 수 있었고, 여러 클래스에서 공통으로 사용되는 검증 로직(예: 문자열을 숫자로 변환하는 작업 등)을 손쉽게 재사용할 수 있어 코드의 중복을 줄일 수 있었습니다.

그러나 이 방식에도 단점이 있었습니다. 모든 검증 로직이 외부의 검증 클래스로 이동하면서, 개별 객체에는 검증 로직이 남아있지 않게 되었습니다. 이로 인해 런타임에 항상 올바른(요구사항에 맞는) 객체가 생성될 것이라는 보장이 없었습니다. 예를 들어, Car 클래스는 자동차 이름의 길이가 5자 이하라는 규칙을 지켜야 하는데, 검증이 외부로 이동하면서 Car 객체 내부에서는 이 규칙을 강제할 수 없게 된 것입니다.

// 예시
public class Car {

    private final String name;

    public Car(String name) {
        this.name = name;
    }
    ...
}

이 방식은 제가 코드를 작성할 때는 큰 문제가 없을 수 있습니다. 저는 제가 작성했기 때문에 검증 클래스가 필요성을 잘 이해하고 이를 일관되게 사용할 수 있기 때문입니다. 하지만 다른 개발자가 이 코드를 사용할 경우, 검증 클래스를 생략하고 바로 new Car("나는이름이정말길어요");처럼 규칙을 벗어난 객체를 생성할 가능성이 있습니다. 이렇게 되면 프로그램 전체의 일관성이 깨질 수 있습니다.

또한, 테스트 코드도 작성하기 어려웠습니다. 규칙에 어긋나는 이름으로 자동차 객체를 만들 수 있기 때문에 올바른 테스트인가? 라는 고민이 많이 들었습니다.

합치기 (3주 차)

그래서 저는 두 방식의 장점을 섞으면 좋을 것 같다고 생각했습니다. 검증 로직을 한 곳에 모아 재사용성을 높이면서도, 개별 객체가 스스로의 일관성을 보장할 수 있도록 만드는 것이 이번 미션의 목표였습니다.

public class Validator {

    private Validator() {
    }

    // 많은 검증 로직들
}

...
// 로또 판매 직원 객체 메서드
public LottoTickets exchangeMoneyForTickets(int purchaseAmount) {
    validatePurchaseAmount(purchaseAmount); // 외부에 있는 검증 메서드를 사용
    int quantity = calculateLottoQuantity(purchaseAmount);
    return lottoMachine.issueTicket(quantity);
}

private void validatePurchaseAmount(int purchaseAmount) {
    // Validator의 검증 메서드를 호출하여 검증 수행
    Validator.checkAboveBaseAmount(purchaseAmount);
    Validator.checkPurchaseAmountUnit(purchaseAmount);
}

검증 로직을 독립된 Validator 유틸리티 클래스로 분리하고, 각 객체가 필요할 때 검증 로직을 스스로 호출하여 자신의 일관성을 유지하도록 설계했습니다.

공통 검증 로직을 중앙 집중화함으로써, 코드 중복 없이 여러 곳에서 재사용할 수 있게 되었고, 검증 규칙이 바뀌어도 Validator 클래스만 수정하면 전체 코드에 반영할 수 있는 것도 큰 장점이라고 생각했습니다.

각 객체는 Validator의 검증 메서드를 스스로 호출하여, 자신의 상태가 유효한지 확인하고 일관성을 유지합니다. 이로써 객체는 자신이 필요로 하는 검증이 잘 이루어졌음을 보장할 수 있습니다. 예를 들어, exchangeMoneyForTickets() 메서드는 로또 구매 금액이 유효한지 검증한 후 티켓 발급을 진행하기 때문에, 올바르지 않은 상태의 객체가 생성되거나 사용되는 위협을 줄일 수 있습니다.

하지만 이 방식은 여전히 Validator와 객체 간의 강한 결합을 가지고 있으며, 각 객체가 매번 Validator의 메서드를 호출해야 한다는 단점이 있는 것 같습니다. (검증은 언제나 있어야 하니까 괜찮은 걸까? 라는 고민도 했습니다.)

검증 세분화

저는 입력 검증과 모델 검증(로또 규칙 검증)을 분리하여 관리했습니다.

예를 들어, 문자열을 금액(숫자)으로 바꿀 수 있는지 확인하는 로직은 입력 검증, 로또에 중복된 숫자가 포함되어 있는지 확인하는 로직은 모델 검증으로 분류하여 관리했습니다.

그렇게 생각하게 된 이유는 다음과 같습니다.

현재는 콘솔을 통해 사용자로부터 문자열 형태의 값을 입력받아 이를 검증하고 사용하고 있습니다. 그러나, 만약 콘솔이 아닌 다른 입력 방식(예시: API)으로 전환하게 된다면, 기존 검증 로직(모든 검증 로직이 모여있는 경우)의 많은 부분을 수정해야 할 가능성이 있습니다.

예를 들어, API에서 JSON 형태로 입력받을 경우, 콘솔에 의존한 검증을 동일하게 사용하기 어렵다고 생각했습니다. (물론, 메서드 분리를 잘 해뒀다면, 큰 변화는 없을 수도 있겠다는 생각도 했습니다.)

따라서, 입력 검증(예시: 입력이 비어있는 값인지 확인하거나 문자열을 정수로 변환 가능한지 확인)과 모델 검증(예시: 로또 번호가 중복되지 않는지 또는 지정된 범위 내에 있는지 확인)을 분리하였습니다. 이를 통해 입력 형태와 관계없이 검증 로직만 조정할 수 있다고 생각했습니다.

앞서 말한 것처럼 검증에 사용하는 메서드는 모두 정적으로 선언하여 한 곳에 모아두고 사용하도록 했습니다.

재시도 로직

사용자의 입력값이 잘못된 경우 그 부분부터 다시 입력받는 것이 요구사항이었습니다.

private int 구입금액_입력받기() {
    try {
        String rawInputPurchaseAmount = inputView.requestPurchaseAmount();
        validatePurchaseAmount(rawInputPurchaseAmount);
        return parsePurchaseAmount(rawInputPurchaseAmount);
    } catch (IllegalArgumentException exception) {
        System.out.println(exception.getMessage());
        return readPurchaseAmount();
    }
}
...

처음에는 각 메서드에서 반복문과 try-catch 문을 사용하여 입력과 예외 처리를 했습니다. 예를 들어, 사용자로부터 로또 구매 금액을 입력받고, 입력 값이 유효하지 않으면 다시 입력받는 방식이었습니다. 그러나 이런 로직이 여러 메서드에 반복적으로 작성되었고, 코드가 복잡해지는 문제가 있었습니다.

문제를 해결하기 위해 예외 발생 시 자동으로 재시도하는 로직을 분리했습니다. 이렇게 하면 개별 메서드는 입력에만 집중할 수 있고, 예외가 발생했을 때 재입력하는 로직은 공통으로 처리하도록 했습니다.

@FunctionalInterface
public interface SupplierWithException<T> {

    T get() throws IllegalArgumentException;
}

public class RetryHandler {

    private RetryHandler() {
    }

    public static <T> T retryIfError(SupplierWithException<T> method) {
        while (true) {
            try {
                return method.get();
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }
    }
}

retryIfError() 메서드는 예외가 발생할 수 있는 메서드(함수형 인터페이스)를 파라미터로 받습니다. 예외가 발생하면 해당 메서드를 다시 호출하고, 예외가 발생하지 않을 때까지 반복합니다.

Java의 표준 Supplier 인터페이스는 예외를 처리할 수 없기 때문에, 예외를 던질 수 있는 커스텀 함수형 인터페이스 SupplierWithException을 추가로 정의해서 사용했습니다.

스스로 생각해낸 방법은 아니고, 분리하는 방법을 고민하다가, 다른 언어인 자바스크립트의 함수를 인자로 전달하는 것처럼 자바에서도 재시도_메서드(입력_메서드); 형태로 구현하면 좋겠다는 생각으로 검색하다가 행위를 쉽게 다룰 수 있는 함수형 인터페이스에 대해 알게 되었고 학습 후 적용하게 되었습니다.

RetryHandler.retryIfError(this::구입금액_입력받기());

이런식으로 전달할 수 있게 되었고, 재시도 로직과 입력 로직을 분리하여 사용할 수 있게 되었습니다.

함수와 메서드?

retryIfError() 메서드를 구현하면서 함수메서드의 차이에 대해서도 고민하게 되었습니다. 자바에서는 메서드를 함수형 인터페이스에 전달하는 방식으로 함수형 프로그래밍의 일부 개념을 도입할 수 있는데, 이를 이해하려면 함수메서드의 차이를 명확히 아는 것이 중요할 것 같아서 학습하게 되었습니다.

  • 메서드(Method) : 메서드는 클래스에 소속된 함수로, 객체의 상태에 접근하거나 조작하는 역할을 수행합니다. 따라서 메서드는 객체의 속성과 결합되어 있으며, 객체 지향 프로그래밍(OOP)의 중요한 구성 요소입니다. 메서드는 객체의 상태에 따라 반환 값이 달라질 수 있으며, 주로 객체의 행동을 정의한다고 합니다.

  • 함수(Function) : 함수는 독립적으로 수행되는 작업의 단위로, 특정 객체에 종속되지 않고 입력값을 받아 결과를 반환하는 역할을 합니다. 함수는 외부 상태에 의존하지 않고 전달받은 파라미터에 의해서만 결과가 결정되며, 이를 순수 함수라고도 합니다. Java에서는 함수형 프로그래밍이 도입되면서 람다 표현식과 함수형 인터페이스 등을 통해 특정 객체와 무관하게 동작하는 함수처럼 사용할 수 있는 방법이 제공되었다고 합니다.

// 함수처럼 독립적으로 동작하는 예시
public int sum(int a, int b) {
    return a + b; // 입력값만으로 결과가 결정됨
}

// 메서드 예시 - 특정 객체의 상태에 따라 동작이 달라짐
public class Car {
    
    private String name;
    
    // 생성자
    public Car(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name; // 객체 상태에 따라 결과가 달라질 수 있음
    }
    
    public void setName(String name) {
        this.name = name; // 객체의 상태를 변경
    }
}

함수는 전달받은 입력값에 의해서만 결과가 만들어지고 외부 상태에 영향을 받지 않습니다. 함수형 프로그래밍에서는 이러한 함수들을 사용해 프로그램의 상태 변화와 부작용을 줄이는 것을 목표로 합니다.

메서드는 객체의 상태를 읽거나 변경할 수 있기 때문에, 같은 메서드라도 객체의 내부 상태에 따라 결과가 달라질 수 있습니다.

작은 부분부터 테스트하기

코드가 의도대로 작동하는지 확인하기 위해 각 객체의 역할을 중심으로 단위 테스트를 작성했습니다. 덕분에 기능별로 빠르게 피드백을 받아 수정할 수 있었고, 테스트 코드를 작성하면서 객체의 책임과 역할을 자연스럽게 점검하게 되었습니다. 이 과정이 메서드나 객체가 작은 단위로 명확한 역할을 수행하도록 도움을 준다는 것을 느꼈습니다.

특히, 테스트 코드를 작성할 때 깊은 부분까지 확인하기 힘들거나 많은 경우의 수를 확인해야 하는 경우 이것이 객체의 책임 범위를 점검하는 기준이 된다고 느꼈습니다.

물론 테스트 코드 작성에는 꽤 많은 시간이 소요되지만, 빠른 피드백을 통해 실수를 줄이고 코드의 품질을 높일 수 있을 것 같습니다.

학습한 내용을 공유하기

최근 Java Record 포스팅에 이어, 이번에는 Java Enum에 대해 학습하고 내용을 공유했습니다.

처음에는 Enum을 단순히 상수들을 모아놓은 형태로만 이해하고 있었지만, Java의 Enum은 생각보다 훨씬 강력한 기능들을 제공한다는 것을 알게 되었습니다. 특히, Enum은 단순히 값의 집합 이상으로 각 상수에 상태와 행위를 추가할 수 있으며, 각 Enum의 상수가 객체처럼 작동할 수 있다는 부분이 인상 깊었습니다.

저처럼 Enum을 가볍게 생각하던 분들이 이 글을 통해 Java Enum의 강력한 기능을 이해하고 활용할 수 있었으면 좋겠다고 생각해서 학습한 내용을 좀 더 쉽게 전달하려고 노력했고, 특히 Java를 만든 분들이 Enum을 어떤 의도로 만들었는지, 공식 문서에서 강조하는 핵심 요소들을 반영하여 설명했습니다. 쉽게 설명하기 위해 학습하고 정리하는 과정으로 많은 부분을 고려할 수 있게 되었습니다.

마무리

이제 프리코스의 마지막 미션만 남았습니다. 약 3주 간의 과정으로 다양한 관점을 깨닫고, 많은 것을 배워서 우아한테크코스의 교육 과정에 욕심이 생기는 것 같습니다. 특히 주기적으로 회고를 작성하는 것이 의식적으로 배운 것을 사용하게 만들고, 더 나은 코드를 위해 학습하게 만드는 원동력이 되는 것 같습니다. 함께 성장하기 위해 모인 지원자 분들을 모두 응원합니다. 마지막 미션도 화이팅입니다!

감사합니다.

수행한 미션

프리코스 3주 차 로또 미션 PR

참고 자료

profile
부담 없이 질문하고 싶은 개발자가 목표입니다.

2개의 댓글

comment-user-thumbnail
2024년 11월 7일

객체 간 협력을 고민하신 과정이 재미있어요! 저도 저렇게 생각하면서 객체 설계를 해봐야겠습니다..! 함수형 인터페이스 사용하신 부분도 인상깊어요. 회고 잘 읽었습니다! 👍

1개의 답글