프리코스 2주차를 마치며
우아한 테크코스 1주차에서는 git, 커밋 컨벤션, 자바 문법을 익히며 학습하는 시간이었다.
1주차 회고록은 너무 바빴어서 작성하지 못했다.
2주차에서는 기능 별 메서드를 최대한 분리시키고, 테스트 도구를 사용하여 메서드 별 테스트를 작성, 검증하며 테스트에 익숙해지는 시간을 가졌다. 동시에 클린 코드 원칙을 지키기 위해서 많은 고민을 했던 시간이었다.
원칙을 지키기 위해 노력하면서 배웠던 점이 가장 많았기 때문에 원칙과 예제를 중점으로 글을 써보려 한다.
한 메서드에 오직 한 단계의 들여쓰기(indent)만 허용했는가?
2주차 미션에서는 indent = 2까지 허용하도록 되어있었지만, 객체지향 생활 체조의 규칙1을 보면 가독성, 재사용성, 쉬운 버그 판별을 위해 indent = 1을 권장하고 있다.
구현해야 할 기능만을 생각하며 코드를 작성하다 보니 indent가 3이 넘는 경우도 많았는데, 이는 가독성이 떨어뜨렸고, 코드 규모가 커질수록 더 알아보기 힘들 것 같다는 생각이 들었다.
그래서 이번 기회에 어떻게든 indent = 1을 지키면서 코드를 짜보려고 노력했다.
이 원칙을 지키기 위해서는 제어문 중첩 사용을 최소화해야 했고, 이는 메서드를 분리 함으로써 해결할 수 있었다. 가독성이 좋아지는 것 뿐만 아니라 기능 단위를 최소화 시킬 수 있었고, 한 메서드가 하나의 역할만을 수행하도록 할 수 있게 하는데에도 도움을 주었다.
예제 코드를 통해 원칙을 지키기 전과 후를 비교해보았다.
유저가 입력한 숫자 문자열을 검증하는 메서드이고,
검증 내용은 "문자열의 길이가 3인지, 1~9숫자로 이루어져 있는지, 중복되지 않는지"로 이루어져 있다.
public boolean isValid(String input) {
List<Character> nums = new ArrayList<>();
if (input.length() == 3 && input.matches("[1-9]+")) {
for (int i = 0; i < 3; i++) {
if (!nums.contains(input.charAt(i))) {
nums.add(input.charAt(i));
continue;
}
return false;
}
return true;
}
return false;
}
한 메서드에서 여러 검증을 다 하기 때문에 간단한 코드임에도 복잡해 보이고, return이 어느 조건에서 이루어지는지 한 눈에 들어오지 않는다.
이번엔 메서드를 분리시켜 차이를 살펴보았다.
public boolean isOneToNine(String input) {
return input != null && input.matches("[1-9]+");
}
public static boolean isNotDuplicate(String input) {
return Arrays.asList(input.split(""))
.stream()
.distinct()
.count() == INPUT_LENGTH;
}
public boolean isValid(String input) {
return input.length() == 3 && isOneToNine(input) && isNotDuplicate(input);
}
검증 내용을 각각 하나의 메서드로 분리시킴으로써 이전 코드보다 눈에 더 잘 들어오는 것 같다.
메서드의 이름을 통해 어떤 검증을 하는 지 알 수 있고, 필요하다면 다른 곳에서도 재사용 할 수도 있을 것 같다.
isNotDuplicate() 메서드에서 stream을 사용하였는데, stream을 사용함으로써 좀 더 간결하고 명확하게 표현할 수 있었다.
stream을 과다하게 사용한다면 오히려 악효과(성능 저하 등)가 될 수 있지만, stream을 적절하게 사용한다면 더 가독성있고 간결한 코드를 짤 수 있기 때문에 학습해보는 것을 추천한다.
위와 같이 메서드를 적절히 분리시켰을 때, 가독성 뿐만 아니라 재사용을 하기도 쉽고, 오류가 어디에서 발생하는 지 찾기 쉽게 해주는 등 여러가지 이점을 직접 느꼈고, 가능하다면 메서드를 분리시켜 이런 이점들을 이용하는 것이 좋을 것 같다는 생각이 들었다.
else 예약어를 쓰지 않았는가?
이 원칙을 처음 접했을 때는 뭔가 멍했다. else를 쓰지 말라고..?
이런 원칙이 생기게 된 이유를 먼저 알아야 했다.
우리가 코드를 짤 때, 분기문을 사용해야 하는 경우는 생각보다 많다.
간단한 분기문은 크게 상관없지만, 복잡해질수록 봐야하는 조건이 많아지고 가독성은 떨어진다.
예시를 통해 보는게 이해가 더 잘될 것 같다.
public String getPetSpecies(String petCode) {
String petSpecies = "";
if ("1".equals(petCode)) {
petSpecies = "강아지";
} else if ("2".equals(petCode)) {
petSpecies = "고양이";
} else if ("3".equals(petCode)) {
petSpecies = "햄스터";
} else {
petSpecies = "바퀴벌레";
}
return petSpecies;
}
위의 메서드는 펫 코드를 입력으로 받아 애완동물 종의 이름을 반환한다.
예제 코드가 간단하기 때문에 비교적 이해가 쉬워보이지만, 조건이 100개로 늘어난다면?
변수 petSpecies 리턴이 마지막 줄에서 이루어지기 때문에 모든 조건문을 다 확인해야 어떻게 동작하는 지 알 수 있다.
또한, else로 인해 펫 코드가 1, 2, 3이 아닐 시 전부 바퀴벌레로 취급된다.
그럼 else문을 제거하려면 어떻게 해야할까?
두 가지 방법이 있었다
1. early return - throw exception
2. 객제지향적 설계(다형성, enum 활용 등)
early return - throw exception 구조
early return은 말 그대로 빨리 리턴을 해버리는 것이다.
조건을 만족시키면 바로 리턴을 해버리기 때문에 조건을 끝까지 확인하지 않아도 된다는 장점이 있다.
public String getPetSpecies(String petCode) {
String petSpecies = "";
if ("1".equals(petCode)) {
return "강아지";
}
if ("2".equals(petCode)) {
return "고양이";
}
if ("3".equals(petCode)) {
return "햄스터";
}
if ("4".equals(petCode)){
return "바퀴벌레";
}
throw new IllegalArgumentException("존재하지 않는 동물입니다.");
}
위의 코드와 비교해보았을 때 좀 더 명확해 보이고,
이전 코드에서는 1, 2, 3이 아닐 때 전부 바퀴벌레로 취급이 됐었는데 이제는 그런 일이 일어나지 않는다.
throw로 에러를 던져 입력 값이 잘못됐음을 알려줄 수 있고, 가독성도 이전 코드보다 괜찮아 보인다.
적용하기도 쉽고 가독성도 괜찮으니, 그럼 이 방법만 사용하면 되는걸까?
아쉽게도 코드가 복잡해지면 사실상 else문을 사용하는 것과 별다른 차이가 없었다.
(else를 사용할 때보다 오류가 발생할 확률이 낮다는 차이는 있음)
else 사용을 하지 말라는 원칙의 숨겨진 의미를 해석해볼 필요가 있었다.
분기문을 작성할 때는 편하다. 추가나 삭제, 수정도 매우 쉽게 할 수 있다.
하지만 그런 분기문이 늘어나면 늘어날수록 가독성은 떨어지고, 분석해야 할 코드가 많아진다는 것이 문제였다.
결국 else 뿐만 아니라, 근본적으로 분기문을 최소화 시키려고 노력해야 하는 것이다.
그렇다면 최종적으로 분기문을 줄이려면 어떻게 해야할까?
이는 객체 지향적인 설계를 통해 해결할 수 있었다.
객체 지향적 설계
위의 애완동물 종을 반환하는 메서드를 객제 지향적인 코드로 바꿔보자.
public enum Pet {
DOG("1", "개"),
CAT("2", "고양이"),
HAMSTER("3", "햄스터"),
COCKROACH("4", "바퀴벌레")
;
private String petCode;
private String petSpecies;
Pet(String petCode, String petSpecies) {
this.petCode = petCode;
this.petSpecies = petSpecies;
}
public static String petSpecies(String petCode) {
return Arrays.stream(values())
.filter(petAdoption -> petAdoption.petCode.equals(petCode))
.findFirst()
.orElseThrow( () -> new IllegalArgumentException("일치하는 동물이 존재하지 않습니다."))
.name();
}
}
단번에 객체 지향적인 설계를 잘하기는 어렵다. 차근차근 해나가보자.
이제 분기문 대신에 enum을 통해 애완동물의 종을 관리할 수 있고, 애완동물 정보의 변경이 일어나도 enum 내부에서 유연하게 처리될 수 있다.
또한 모든 애완동물의 종을 출력하는 메서드를 쉽게 만들수도 있고, 애완동물과 관련된 기능을 추가로 구현하고 관리할 수 있다.
else 예약어를 쓰지 말라는 규칙을 위해 여기까지 오게되긴 했지만,
클린 코드의 원칙을 지키기 위해서는 동시에 객체 지향적인 코드를 짜려고 노력해야 겠다는 생각이 들었다.
마무리
이외에도 "모든 원시값과 문자열을 포장했는가?", "3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?" 등
다른 원칙에서도 배운점이 있었지만, 앞선 두 원칙을 지키면서 배운게 많았다고 생각하기 때문에 이를 중점으로 작성했다.
메서드를 분리시키는 것과 메서드별 테스트 작성 대해서는 익숙해지는 과정이였고,
왜 원칙을 지켜야 하는 지? 원칙을 지킴으로써 오는 장점이 무엇인지? 에 대해 자세히 알게된 것 같다.
이번 주차 미션을 진행하면서 글에는 작성하지 않았지만 MVC 패턴을 적용하면서 어려운 점을 많이 겪었는데, 기회가 되면 글로 정리해보고, 3주차 미션에서는 MVC 패턴의 규칙을 지키면서 코드를 더 잘 설계해보려고 노력 해봐야겠다.
다음 주차 미션은 배웠던 내용은 적용하고, 추가되는 원칙을 지키면서 배우는게 아주 많은 한 주를 보내게 될 것 같다.
☑️ 해야할 것
- MVC 패턴 공부
- Stream 공부
- 테스트(Junit) 공부
- 객체 지향의 사실과 오해 정주행