TDD 학점 계산기 만들기

7 분 소요

학점을 구하는 방법 = (교과목 학점 + 평점)의 합 / 수강신청 총 학점 수.

객체지향 설계 및 구현

  1. 도메인을 구성하는 객체에는 어떤 것들이 있는지 고민.
  2. 객체들 간의 관계를 고민.
  3. 동적인 객체를 정적인 타입으로 추상화하여 도메인 모델링.
  4. 협력을 설계.
  5. 객체들을 포괄하는 타입에 적절한 책임을 할당.
  6. 구현.

1. 도메인을 구성하는 객체에는 어떤 것들이 있는지 고민.

학점 계산기 도메인을 구성하는 객체는 수강신청 과목들과, 해당 과목들의 학점을 계산해주는 학점 계산기가 있다.


2. 객체들 간의 관계를 고민

  • 수강신청 과목 : 객체지향, 자료구조, DB, … 과목의 이름, 학점, 평점을 가진다.
  • 학점 계산기 : 수강한 과목들을 넣으면 학점을 계산해주는 역할.

3. 동적인 객체를 정적인 타입으로 추상화해서 도메인 모델링 하기.

수강신청 과목은 과목의 이름, 학점, 평점이라는 공통 속성을 가지므로 Course라는 클래스로 추상화할 수 있다. Course 클래스 안에 과목의 이름을 나타내는 subject, 과목 학점은 credit, 과목 평점은 grade로 정의한다.

// 각 과목을 나타내는 클래스
public class Course {
    private final String subject; // 객체지향, 자료구조, DB ...
    private final int credit; // 2학점, 3학점 ...
    private final String grade; // A+, B, C ...

    public Course(String subject, int credit, String grade) {
        this.subject = subject;
        this.credit = credit;
        this.grade = grade;
    }
}

학점 계산기는 교과목을 나타내는 Course 객체를 받아서 계산한다.

// 학점 계산기
public class GradeCalculator {
    private final List<Course> courses; // 과목들을 리스트로 저장하여 계산에 사용한다.

    public GradeCalculator(List<Course> courses) {
        this.courses = courses;
    }
}

4. 협력을 설계.

먼저 교과목 클래스인 Course가 잘 생성되는지 테스트한다.

public class CourseTest {

    @DisplayName("교과목을 생성한다.")
    @Test
    void createTest() {
        assertThatCode(() -> new Course("OOP", 3, "A+"))
                .doesNotThrowAnyException();
    }
}

assertj의 assertThatCode()를 사용해 Course객체를 생성하고, 어떠한 에러도 발생하지 않음을 검증한다.

GradeCalculator 클래스를 테스트하는 GradeCalculatorTest 클래스를 생성하여 테스트코드를 작성한다.

public class GradeCalculatorTest {

    @DisplayName("학점을 계산한다.")
    @ParameterizedTest
    @MethodSource("makeCourses")
    void calculatorTest(List<Course> courses) {
        GradeCalculator gradeCalculator = new GradeCalculator(courses);
        assertThat(gradeCalculator.calculate()).isEqualTo(4.5);
    }
}

현재 courses에 들어있는 3과목 전부 A+이므로 학점 계산 결과가 4.5점이 나와야 한다. GradeCalculator 클래스의 calculate() 메서드는 아직 작성하지 않은 메서드이기 때문에 테스트는 실패한다.

public class GradeCalculator {
    private final List<Course> courses;

    public GradeCalculator(List<Course> courses) {
        this.courses = courses;
    }

    public double calculate(List<Course> courses) {
        return 4.5;
    }
}

GradeCalculator 클래스에 calculate() 메서드를 정의해주고 일단 4.5를 반환하여 일단 테스트를 성공시킨다. 테스트가 성공하면 calculate() 메서드를 리팩토링 한다.

public class GradeCalculator {
    private final List<Course> courses;

    public GradeCalculator(List<Course> courses) {
        this.courses = courses;
    }

    // 학점을 구하는 방법 = (교과목 학점 * 평점)의 합 / 수강신청 총 학점 수.
    public double calculate() {
        // 교과목 학점 * 평점을 구한다.
        double multipliedCreditAndGrade = 0;
        for (Course course : courses) {
            // course.getGradeToNumber() -> 학점은 "A+"과 같은 문자열이기 때문에
            // 문자열 학점을 수로 변경하여 계산해야 한다.
            multipliedCreditAndGrade += course.getCredit() * course.getGradeToNumber();
        }

        // 수강신청 총 학점 수를 구한다.
        int totalCompletedCredit = courses.stream()
                .mapToInt(Course::getCredit)
                .sum();

        return multipliedCreditAndGrade / totalCompletedCredit;
    }
}
  1. 교과목 학점 * 평점을 구하기 → 이수과목이 들어있는 courses 리스트를 순회하면서 원소인 Course 객체에 교과목 학점과 문자열 평점을 숫자로 변환한 숫자평점을 교과목 학점과 곱하여 모두 더한다.
  2. 수강신청 총 학점 수를 구하기 → courses 리스트에 원소인 Course를 객체의 교과목 학점으로 변환하기 위해 mapToInt()를 사용하고 sum() 하여 모두 더한다.
  3. 교과목 학점 * 평점을 수강신청 총 학점수로 나눈 값을 반환한다.

이제 교과목 학점을 가져오는 getCredit(), 문자열 평점을 숫자로 변환하는 getGradeToNumber()를 작성한다.

public class Course {
    private final String subject;
    private final int credit;
    private final String grade;

    public Course(String subject, int credit, String grade) {
        this.subject = subject;
        this.credit = credit;
        this.grade = grade;
    }

    // 교과목의 학점을 가져온다.
    public int getCredit() {
        return this.credit;
    }

    // 문자열 평점을 숫자로 변경한다.
    public double getGradeToNumber() {
        double numberGrade = 0;
        switch (grade) {
            case "A+":
                numberGrade = 4.5;
                break;
            case "A":
                numberGrade = 4.0;
                break;
            case "B+":
                numberGrade = 3.5;
                break;
            case "B":
                numberGrade = 3.0;
                break;
            case "C+":
                numberGrade = 2.5;
                break;
            case "C":
                numberGrade = 2.0;
                break;
        }
        return numberGrade;
    }
}

getGradeToNumeber()는 switch를 사용하여 해당 학점에 맞게 변환하여 double 타입의 평점을 반환한다. 테스트가 성공했고 정상적으로 로직을 구현한 것을 확인했다.

public class GradeCalculator {
    private final List<Course> courses;

    public GradeCalculator(List<Course> courses) {
        this.courses = courses;
    }

    // 학점을 구하는 방법 = (교과목 학점 + 평점)의 합 / 수강신청 총 학점 수.
    public double calculate() {
        // 교과목 학점 + 평점을 구한다.
        double multipliedCreditAndGrade = 0;
        for (Course course : courses) {
            // course.getGradeToNumber() -> 학점은 "A+"과 같은 문자열이기 때문에
            // 문자열 학점을 수로 변경하여 계산해야 한다.
            multipliedCreditAndGrade += course.getCredit() * course.getGradeToNumber();
        }

        // 수강신청 총 학점 수를 구한다.
        int totalCompletedCredit = courses.stream()
                .mapToInt(Course::getCredit)
                .sum();

        return multipliedCreditAndGrade / totalCompletedCredit;
    }
}

하지만 현재까지 작성한 코드에는 문제가 있다. Course 객체가 학점과 평점을 가지고 있는데, 학점과 평점을 가지고 있는 Course가 계산하는 것이 아니라, 이 정보를 GradeCalculator 클래스에 getCredit(), getGradeToNumber() 메서드로 가져와서 계산을 수행하고 있다는 점이다. 이런 상태의 코드는 학점 계산을 여러군데에서 사용하고 있다고 가정할 때 학점 계산에 대한 로직이 수정되면, 사용하고 있는 모든 곳의 코드를 수정해야된다. 즉 응집도가 약하다.

하지만 학점 계산을 Course 객체가 직접 수행하게 하면 어떤 점이 좋을까? getter 메서드를 사용해서 가져오지 않고 Course 객체가 직접 계산하게 리팩토링 해보자.

public class GradeCalculator {
    ...

    // 학점을 구하는 방법 = (교과목 학점 + 평점)의 합 / 수강신청 총 학점 수.
    public double calculate() {
        // 교과목 학점 + 평점을 구한다.
        double multipliedCreditAndGrade = 0;
        for (Course course : courses) {
            // multipliedCreditAndGrade += course.getCredit() * course.getGradeToNumber();
            // 학점과 평점을 가진 Course 객체에게 메세지를 보내 결과를 받아온다.
            multipliedCreditAndGrade += course.multiplyCreditAndGrade();
        }

        // 수강신청 총 학점 수를 구한다.
        int totalCompletedCredit = courses.stream()
                .mapToInt(Course::getCredit)
                .sum();

        return multipliedCreditAndGrade / totalCompletedCredit;
    }
}

public class Course {
    ...

    // 학점 * 평점 계산을 수행하는 메서드 추가.
    public double multiplyCreditAndGrade() {
        // Course 객체의 학점과 숫자로 변환한 평점을 곱한 값을 반환한다.
        return this.credit * getGradeToNumber();
    }
    ...
}

Course 객체에 multiplyCreditAndGrade() 라는 메서드를 만들어서 Course 객체가 직접 학점과 평점을 곱한 값을 반환하게 리팩토링 하였다. 이렇게 정보를 가진 객체에게 메시지를 보내서 처리를 위임하면, 코드의 응집도가 높아져서 이전처럼 메서드를 사용하는 곳 마다 수정해야했을 변경포인트가 한 곳으로 줄어든다.

Getter를 통해서 정보를 가져와서 처리하는 방식이 아닌, 해당 정보와 데이터를 가진 객체에게 메시지를 던져서 작업을 처리하게 한다면 변화에 유연한 코드를 만들 수 있다.

이렇게 리팩토링 한 코드를 다시 테스트를 돌려서 로직을 잘 구현했는지 확인한다.


5. 객체들을 포괄하는 타입에 적절한 책임을 할당.

public class GradeCalculator {
    private final List<Course> courses;

    public GradeCalculator(List<Course> courses) {
        this.courses = courses;
    }

    public double calculate() {
        // Course에 대한 로직을 여전히 GradeCalculator에서 처리하고있다.
        double multipliedCreditAndGrade = 0;
        for (Course course : courses) {
            multipliedCreditAndGrade += course.multiplyCreditAndGrade();
        }

        int totalCompletedCredit = courses.stream()
                .mapToInt(Course::getCredit)
                .sum();

        return multipliedCreditAndGrade / totalCompletedCredit;
    }
}

Course 객체에게 메시지를 보내 직접 처리하게 리팩토링 했지만, 여전히 GradeCalculator 클래스 내부에 Course에 대한 로직 처리가 존재한다. List<Course> 타입을 일급 컬렉션을 통해 리팩토링 해보자.

일급 컬렉션이란?

List나 Set 등 자료구조 단 하나만을 필드로 가진 클래스이다.

public class Courses {
    // 단 한개의 컬렉션만을 가져야 한다.
    private final List<Course> courses;

    public Courses(List<Course> courses) {
        this.courses = courses;
    }
}

이제 GradeCalculator 클래스에서 처리하던 Course의 처리를 일급 컬렉션 Courses 클래스가 수행하게 할 수 있다.

public class GradeCalculator {
    private final Courses courses;

    public GradeCalculator(List<Course> courses) {
        this.courses = new Courses(courses);
    }

    // 학점을 구하는 방법 = (교과목 학점 + 평점)의 합 / 수강신청 총 학점 수.
    public double calculate() {

        // 교과목 학점 + 평점을 구한다.
        double multipliedCreditAndGrade = courses.multiplyCreditAndGrade();

        // 수강신청 총 학점 수를 구한다.
        int totalCompletedCredit = courses.sumTotalCompletedCredit();

        return multipliedCreditAndGrade / totalCompletedCredit;
    }
}

public class Courses {
    private final List<Course> courses;

    public Courses(List<Course> courses) {
        this.courses = courses;
    }

    public double multiplyCreditAndGrade() {
        double multipliedCreditAndGrade = 0;
        for (Course course : courses) {
            multipliedCreditAndGrade += course.multiplyCreditAndGrade();
        }
        return multipliedCreditAndGrade;
    }

    public int sumTotalCompletedCredit() {
        return courses.stream()
                .mapToInt(Course::getCredit)
                .sum();
    }
}

GradeCalculator 클래스 내부에 있던 Course 객체에 대한 처리를 일급 컬렉션 Courses 클래스에 메서드로 만들어 수행하게 한다. 이제 GradeCalculator 클래스에서는 Courses 클래스에 메시지를 보내서 처리 결과만 받아서 반환해주면 된다. 그래서 Course에 있는 정보들을 처리하는 책임을 Courses에서 처리할 수 있게 되었다. 이후 학점 계산에 대한 로직의 수정이 필요하면 Courses 클래스에서만 수정하면 되도록 개선되었다.

마지막으로 Courses 클래스의 multiplyCreditAndGrade() 메서드의 로직 또한 리팩토링할 수 있다.

public class Courses {
    private final List<Course> courses;

    public Courses(List<Course> courses) {
        this.courses = courses;
    }

    public double multiplyCreditAndGrade() {
        return courses.stream()
                .mapToDouble(Course::multiplyCreditAndGrade)
                .sum();
    }

    public int sumTotalCompletedCredit() {
        return courses.stream()
                .mapToInt(Course::getCredit)
                .sum();
    }
}

courses 리스트를 stream()으로 만들고 mapToDouble()을 사용하여 Double타입으로 변환하고 Course 클래스의 multiplyCreditAndGrade() 메서드로 학점 * 평점을 계산한 값을 모두 더하는 것으로 기존 코드를 리팩토링 하였다. 마지막으로 리팩토링 한 코드를 테스트하여 검증하여 성공하는 것으로 학점계산기 개발을 마무리한다.


핵심 포인트

  1. GradeCalculate 클래스에 이수한 과목들을 전달.
  2. 이수한 과목들을 Courses 일급 컬렉션에 메시지를 보내 처리 위임.
  3. Courses에 들어있는 Course에 메시지를 보내 학점 계산 처리를 위임.
  4. 최종적으로 계산한 값을 반환.

이런 프로세스로 객체들간의 데이터를 가지고있는 객체에게 메시지를 보내서 처리를 위임하고 결과를 받아와서 최종적인 결과값을 반환하는 객체지향적인 프로그램을 만들 수 있다.

댓글남기기