[NEXTSTEP] 첫 번째 미션을 마무리 하면서
대외활동

[NEXTSTEP] 첫 번째 미션을 마무리 하면서

들어가며

  • 이번 글에서는 자동차 경주 과제를 진행하면서 배웠던점과 느낀 점들에 대해서 정리하고자 합니다.

배운 점

먼저 도메인을 설계하자

1:N 관계일 경우 N을 먼저 테스트하자.

  • Inside-out 방식으로 구현하는 것이 중요합니다.
  • 즉, 1:N 관계일 때 N인 객체를 먼저 테스트하는 것이 TDD 하기에 적합합니다.

테스트가 어려운 메서드는 구조 설계에서 제일 하단으로 옮기자

public class RandomNumberGenerator {
    
    private Random random;
    
    public RandomNumberGenerator() {
        this.random = new Random();
    }
    
    public int generateRandomNumber(int min, int max) {
        return random.nextInt(max - min + 1) + min;
    }
}
  • 위 클래스는 주어진 범위에서 무작위로 숫자를 생성하는 기능을 가지고 있습니다. 하지만 이 클래스의 generateRandomNumber 메소드는 테스트하기가 어려울 수 있습니다. 왜냐하면 실행될 때마다 다른 결과를 반환하기 때문입니다.
  • 따라서 이 메서드를 구조 설계에서 하단으로 옮겨서 테스트하기 쉽게 만들 수 있습니다. 예를 들어, 메서드의 로직을 추출하고 이를 인터페이스를 통해 주입하는 방식을 사용할 수 있습니다.
public interface RandomNumberProvider {
    int getRandomNumber(int min, int max);
}

public class RandomNumberGenerator implements RandomNumberProvider {
    
    private Random random;
    
    public RandomNumberGenerator() {
        this.random = new Random();
    }
    
    @Override
    public int getRandomNumber(int min, int max) {
        return random.nextInt(max - min + 1) + min;
    }
}
  • 이렇게 하면, RandomNumberProvider 인터페이스를 구현한 클래스를 만들어서 테스트할 때 해당 클래스의 Mock 또는 Stub을 사용할 수 있습니다.
  • 이를 통해 예측 가능한 결과를 얻을 수 있게 됩니다. 따라서 random 함수와 같이 실행 결과가 무작위로 변하는 상황에서도 테스트가 쉽고 유지보수가 용이한 코드를 작성할 수 있습니다.

구현할 기능과 테스트 목록을 작성해라.

  • 평소에 문서화는 좋은 개발 방향성이 아니라고 생각했었습니다. 코드의 가독성을 올려서 코드 자체가 문서화된 결과라고 생각했었습니다.
  • 하지만 TDD를 진행하면서 문서화한 결과물을 코드에 바로 적용할 수 있다면 이는 의미 있는 작업이란 걸 알게 되었습니다.

구현할 기능과 테스트 목록들

  • 위와 같이 테스트에 대한 명세서를 먼저 설계하고 테스트 내용은 @DisplayName에 작성한 내용들을 옮겨놓고 작업을 하니 구현해야 할 사항들이 명확해지면서 놓친 테스트들이 없게 되었습니다.
  • 앞으로 신규 개발건 또는 레거시 개발건들에 대해서 테스테에 대한 내용들은 문서화해 놓고 작업을 진행하려고 합니다.

레거시 코드를 빠르게 테스트하고 싶다면?

  • 개인적으로 이번 단원에서 미션을 구현하면서 배운 가장 큰 점이라고 생각합니다
// 기존의 어려운 테스트를 해야하는 메소드
class OriginalClass {
    public int complexMethod(int a, int b) {
        // 복잡한 로직이 포함된 메소드
        return a + b;
    }
}
  • 위와 같이 테스트하기 복잡한 어려운 메서드가 있다고 가정해 보겠습니다.
  • 이럴 경우 인터페이스를 사용하면 테스트하기가 굉장히 쉬워집니다.
// 인터페이스로 리팩토링
interface SimpleInterface {
    int complexMethod(int a, int b); //기존 메소드와 동일하게 이름 설정
}
  • 위와 같이 기존의 복잡한 메서드와 일치한 인터페이스를 생성합니다.
// 테스트 코드
class TestClass {
    public void testMethod() {
        // 인터페이스를 지역 메소드로 사용
        SimpleInterface simpleInterface = new SimpleInterface() {
            @Override
            public int complexMethod(int a, int b) {
                return a + b;
            }
        };

        // 테스트
        int result = simpleInterface.complexMethod(3, 4);
        System.out.println("Result: " + result);
    }

    public static void main(String[] args) {
        TestClass test = new TestClass();
        test.testMethod();
    }
}
  • 이후 위와 같이 인터페이스를 지역메서드를 사용해서 테스트 코드를 작성하면 복잡한 메서드도 테스트 코드 작성이 가능합니다.

정규표현식은 Static을 사용해서 사용해라

public class Calculator {
    private static final Pattern numberPattern = Pattern.compile(":|,");
    ...
}
  • 자바 String의 split 메서드는 정규표현식 Pattern 자원을 생성해서 사용하는데 이때 생성하는 리소스가 크기 때문에 위와 같이 힙 영역에 한 번 생성한 뒤 재활용해서 사용하는 게 좋은 방법입니다.
  • 그런데 항상 Pattern을 힙 영역에 생성해서 사용하는게 좋은 방법은 아닙니다. 아래 사진을 보면 split 메서드인데 주석에 보면 fastpath라고 적혀있습니다.

java split 내부 구현체

  • 이게 무슨 의미냐면 정규 표현식을 한 문자로 사용하면서 아래 케이스(fastPath가 안 되는 경우 참조)가 아닌 경우에는 Pattern을 힙 영역에 생성해서 사용하는 것보다 더 빠르다는 것을 의미합니다.
  • 따라서 한 문자면서 아래와 같은 경우가 아니라면 Pattern을 힙 영역에 선언해서 사용하지 않고 그냥 split을 사용하는 게 좋은 방법이고 그게 아니라면 Pattern을 힙 영역에 생성해서 사용하는 것이 좋은 방법입니다.

[fastpath가 안 되는 경우]

  1. "."
  2. "$"
  3. "|"
  4. "("
  5. ")"
  6. "["
  7. "{"
  8. "^"
  9. "?"
  10. "*"
  11. "+"
  12. ""
  13. """
  14. 아스키 문자가 아니여야 함.
  15. 정규식이 두 글자인데 첫 번째 문자는 백슬레시이면서 아스키숫자나 알파벳이 아닌 경우 (ex: "$")

일급 컬렉션이란?

일급 컬렉션 예시

  • 일급 컬렉션"이란 프로그래밍 언어에서 일급 시민(First-class citizen)으로 취급되는 컬렉션(자료구조)을 가리킵니다.
  • 즉, 해당 언어에서 컬렉션을 변수나 함수의 매개변수, 반환값으로 사용할 수 있는 등의 특성을 말합니다.
  • 일급 컬렉션을 사용하게 될 경우 상위 객체에 책임과 역할을 분담하여 가독성 높은 코드를 작성할 수 있는 설계의 기반이 됩니다.

예외가 발생할 수 있는 Exception들을 정확히 명시해라

public static String inputCarGroupName() {
        System.out.println("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).");
        try {
            String carNames = scanner.nextLine();
            checkCarNamesMoreThan5Letters(carNames.split(","));
            return carNames;
        } catch (Exception e) {
            throw new RuntimeException(UNEXPECTED_ERROR.getMessage(), e);
        }
    }

  • 위와 같이 자동차의 이름을 입력받는 경우 클라이언트가 입력값을 잘못 입력하여 Excepetion을 명시해줘야 하는 경우가 있습니다.
  • 하지만 위와 같은 방식으로 Exception을 명시할 경우 클라이언트가 어떤 잘못된 방식으로 입력해서 예외가 발생한 지 모릅니다.
public static String inputCarGroupName() {
        System.out.println("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).");
        try {
            String carNames = scanner.nextLine();
            checkCarNamesMoreThan5Letters(carNames.split(","));
            return carNames;
        } catch (CarNameExceedException e) {
            throw new CarNameExceedException(e.getMessage());
        } catch (InputMismatchException e) {
            throw new RuntimeException(INVALID_INPUT_TYPE_INT.getMessage(), e);
        } catch (NoSuchElementException e) {
            throw new RuntimeException(INVALID_INPUT_EMPTY.getMessage(), e);
        } catch (IllegalStateException e) {
            throw new RuntimeException(ILLEGAL_STATE.getMessage(), e);
        } catch (Exception e) {
            throw new RuntimeException(UNEXPECTED_ERROR.getMessage(), e);
        }
    }
  • 따라서 위와 같이 try문 안에서 발생할 수 있는 예외들을 정확히 명시하여 상황별 예외를 catch 하여 어떤 예외가 발생하였는지 알 수 있도록 구현해야 합니다.

디미터 법칙을 준수하자

  • 디미터 법칙은 객체 지향 프로그래밍에서 객체 간의 결합도를 줄이기 위한 설계 원칙 중 하나입니다.
  • 디미터 법칙은 객체가 다른 객체와 상호작용해야 할 때 객체 안의 메서드를 직접적으로 사용하는 것이 아닌 해당 객체에 속한 메서드를 사용해 기능을 수행해야 합니다.
  • 즉, 객체는 자신이 직접 접근할 수 있는 범위 내에서만 메서드를 호출해야 합니다. 따라서. 를 여러 번 붙여서 사용하는 코드는 디미터 법칙을 위반하는 대표적인 예시입니다.
public class Person {
    private String name;
    private Department department;

    public Person(String name, Department department) {
        this.name = name;
        this.department = department;
    }

    public Department getDepartment() {
        return department;
    }
}

public class Department {
    private String name;
    private Manager manager;

    public Department(String name, Manager manager) {
        this.name = name;
        this.manager = manager;
    }

    public Manager getManager() {
        return manager;
    }
}

public class Manager {
    private String name;

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

public class Main {
    public static void main(String[] args) {
        Manager manager = new Manager("John");
        Department department = new Department("HR", manager);
        Person person = new Person("Alice", department);

        // 디미터 법칙 위반
        String managerName = person.getDepartment().getManager().getName();
        System.out.println("Manager: " + managerName);
    }
}
  • 위 예시는 **Person** 객체 안의 **Department**, **Manager**, **Name** 정보를 getter로 조회해서 사용하기 위해 도트(.)를 통해서 메서드 체이닝을 사용하여 디미터 법칙을 위반하는 예시입니다.
public class Person {
    private String name;
    private Department department;

    public Person(String name, Department department) {
        this.name = name;
        this.department = department;
    }

    public Department getDepartment() {
        return department;
    }

    // 새로운 메서드 추가
    public String getManagerName() {
        return department.getManagerName();
    }
}

public class Department {
    private String name;
    private Manager manager;

    public Department(String name, Manager manager) {
        this.name = name;
        this.manager = manager;
    }

    public String getManagerName() {
        return manager.getName();
    }
}

public class Manager {
    private String name;

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

    public String getName() {
        return name;
    }
}

public class Main {
    public static void main(String[] args) {
        Manager manager = new Manager("John");
        Department department = new Department("HR", manager);
        Person person = new Person("Alice", department);

        // 디미터 법칙 준수
        String managerName = person.getManagerName();
        System.out.println("Manager: " + managerName);
    }
}
  • 위의 수정된 코드에서는 Person 클래스에 getManagerName() 메서드를 추가하여 디미터 법칙을 준수하였습니다.
  • Person 클래스에서는 Department 클래스의 내부 구조에 직접 접근하지 않고도 **Manager**의 이름을 가져올 수 있습니다. 이러한 변경으로 인해 객체 간의 결합도가 낮아지고, 코드의 유지 보수성과 확장성이 향상되게 됩니다.

주생성자를 잘 이용해서 클라이언트의 편의성을 도모하자!

public class Person {
    private String name;
    private int age;

    // 주 생성자 (Primary Constructor)
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 부 생성자 (Secondary Constructor) - 나이를 기본값으로 설정
    public Person(String name) {
        this(name, 0); // 부 생성자 내부에서 주 생성자 호출
    }
  • 구현한 객체는 클라이언트가 어떤 방식으로든 객체를 생성하기 쉽도록 만들어야 합니다.
  • 이때 주성생 자를 먼저 선언한 뒤 부생성자를 추가하는 방식으로 구현하게 되면 클라이언트가 객체 선언 시 선택지를 다양하게 가져갈 수 있는 구현 방식이 됩니다.
  • 위 코드 예시에서 주생성자만 있었다면 두 가지 필드 정보를 전부 입력해야만 객체를 생성할 수 있었지만 부생성자를 선언하여 문자열만 입력해도 Person 객체를 생성할 수 있게 되었으므로 클라이언트는 객체를 선언할 때 선택지가 늘어나 편의성을 얻게 됩니다.

이외의 좋은 말들

구현하기 어렵다면?

  • 단위테스트, 요구사항 무시하고 일단 구현해라.
  • 구현하다 보면 도메인 지식이 머리에 쌓인다.
  • 이후 구현한 모든 코드를 버려라.
  • 다시 구현할 기능 목록을 작성하고 간단한 도메인을 설계해라.

레거시 코드가 있는 상태에서 리팩터링 하는 것이 더 어렵다.

생성자가 여러 개일 경우 정적메서드를 고려해라.
클래스가 너무 클 경우 별도 클래스로 분리해라

정리

  • 필드 값은 원시 클래스로 분리해서 단일 책임 원칙을 유지하도록 하자
  • 컬렉션은 일급 컬렉션 클래스로 분리하여 단일 책임 원칙을 유지하도록 하자
  • 실무에서 레거시 코드 중 메서드가 테스트코드하기 힘든 코드라면 메서드를 추출한 뒤 테스트 코드에서 지역 메소드를 인터페이스를 재정의해서 테스트할 수 있다.