이번 시간에는 함수는 어떤식으로 만들어야할까? 에 대한 이야기 입니다
- Arguments
- The Stepdown Rule(어떤 순서로 작성할 것인지)
- Remove Switch and case
- Temporal Coupling (시간, 순서상의 결합도를 어떤식으로 해결할 것인가)
- CQS
- Tell Don't Ask
- Law of Demeter
- Early Return
- Error handling
- Special Case (Null object 패턴 - 수많은 if-else 분기 보다는 특정 문제해결을 위한 클래스를 만들자)
- Null is not an error
- Null is value
- Try 도 하나의 역할/기능이다.
위의 주제들에 대해 이야기를 나눠보겠습니다.
* Argument
문제 1. Argument 인자가 늘면 당연히 프로그램 복잡도가 증가한다. (3개 이상 넘기게 되면, 실수 하게 되는 경우가 있다.)
많은 파라미터를 넘겨야할 때 해결할수 있는 방법
1. 클래스로 따로 뽑아서 처리하자
fun amountInvoicedIn(start: Date, end: Date) // bad
fun amountInvoicedIn(range: DataRange) // good
2. 마틴 파울러는 Java Bean Pattern 를 활용하는 것을 추천한다. (간단하게 get/set 을 이용하는 것이다)
public class NutritionFacts {
public int servingSize;
public int servings;
public int calories;
public int fat;
public int sodium;
public int carbohydrate;
public NutritionFacts() {
}
public NutritionFacts(int serviceSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = serviceSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
public static void main(String[] args) {
NutritionFacts bad = new NutritionFacts(100, 200, 100, 10, 5, 10); // bad case
NutritionFacts good = new NutritionFacts(); // good case
good.servingSize = 100;
good.servings = 200;
good.calories = 100;
good.fat = 10;
good.sodium = 5;
good.carbohydrate = 10;
}
}
이 방식의 단점은 필요한 프로퍼티를 모두 초기화 하지 않으면, 불안전한 상태를 가질 수 있다.
그래서 조금 더 나아보이는 방식은 빌더패턴을 이용하는 것이다. 적절히 null 검사를 해서 throws 를 던져주면 좀더 완벽한 코드가 된다.
public class NutritionFacts {
private int servingSize;
private int servings;
private int calories;
private int fat;
private int sodium;
private int carbohydrate;
private NutritionFacts(Builder builder) {
this.servingSize = builder.serviceSize;
this.servings = builder.servings;
this.calories = builder.calories;
this.fat = builder.fat;
this.sodium = builder.sodium;
this.carbohydrate = builder.carbohydrate;
}
private static class Builder {
// mandatory
private int serviceSize;
private int servings;
// optional
private int calories;
private int fat;
private int sodium;
private int carbohydrate;
public Builder(int serviceSize, int servings) {
this.serviceSize = serviceSize;
this.servings = servings;
}
public Builder calories(int calories) {
this.calories = calories;
return this;
}
public Builder fat(int fat) {
this.fat = fat;
return this;
}
public Builder sodium(int sodium) {
this.sodium = sodium;
return this;
}
public Builder carbohydrate(int carbohydrate) {
this.carbohydrate = carbohydrate;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
public static void main(String[] args) {
NutritionFacts good = new NutritionFacts.Builder(100, 200)
.calories(100)
.fat(20)
.sodium(200)
.carbohydrate(10)
.build();
}
}
문제 2. Boolean 인자를 argument로 넘기는 것을 최대한 자제하자
이 문제는 보지 않아도 함수 내부가 if-else 분기로 나눠져 Indentation 이나 이후 내부 함수에도 영향을 주게된다.
이럴때는 2개의 함수로 분리하는 것이 유리하다.
if(isValid()) {
//......
// 긴 로직들
//
} else {
// .....
// 긴 로직들
//
}
위 로직은 스코프안에 로직을 보고 어떤 기능알 수 있지만
trueMethod();
falseMethod();
로 나눠져 있으면, 함수 이름만 보고도 어떤 기능을 할지 이미 알수 있다.
문제 3. Innies not outies (이 부분은 예제가 정확히 맞는지 모르겠습니다)
Parameter 로 전달받은 Argument 를 Output 인자로 사용하지말자 -> return value 로 처리하자
// Bad
private String toSimpleText(Parse parse, StringBuffer text) {
// logic..
return text.toString();
}
// Good
private Void toSimpleText(Parse parse, StringBuffer text) {
// logic..
}
or
private String toSimpleText(Parse parse, StringBuffer text) {
// logic..
return new String(text.toString());
}
문제 4. Null defense
충분히 코드에서 null 이 전달되고 반환될 수 있는데, 이것이 주가 되면 결국은 boolean 을 전달하는 만큼 잘못된 것으로 우리는 null 인경우 null 이 아닌 경우 모두 분기 처리해야한다. -> 이런 경우도 2개의 함수를 만드는 것이 유리하다.
혹여나 null을 반환하지 않고는, 비즈니스 로직 처리를 할 수 없는 경우 Exception 을 던지는 것이 좋다.
(만약 그렇지 않다면, 해당 코드를 사용하는 모든 곳에 null 체크가 들어가야한다.)
애초에 단위 테스트를 통해 null 을 던지지 않도록 검증하고 리팩토링 해야할 가능성이 있다.
* Stepdown rule
- 모든 Public 은 위에, 모든 private 는 아래에 작성한다.
그리고 전달할 때는, 아래 Private 는 감추고 Public 만 전달한다!
클라이언트는 Public 부분만 봐도, 어떤 기능일지 이해할 수 있어야한다.
(뉴스를 처음부터 끝까지 보는 사람은 없다. 헤드라인을 읽고 관심 있는 부분만 읽는 것처럼 코드도 동일하다)
- Backward reference 없이 Top - Down 방식으로 읽을 수 있게 제공한다.
public void serve(Socket s) {
try {
// 1. 이런 코드가 나왔을 때는
tryProcessInstruction(s);
} catch (Throwable ignored) {
} finally {
s.close();
}
}
// 2. 다음 메서드에는 항상 tryProcessInstructions 메서드가 정의되어 있어, Context 가 유지되도록 해야한다
public void tryProcessInstruction(Socket s) throws Exception {
// TODO...
}
* Switch 문장 사용을 왜 지양해야 하나?
- "Switch 문장은 객체 지향적이지 않다"라는 말이 많다
- OOP 의 가장 큰 이점 중 하나는 의존성 관리 능력이다. - 이게 Switch 와 어떤 관계가 있는지 알아보자.
위의 기름은 모듈 A 가 모듈 B의 함수를 사용하는 경우이고, A 가 고수준(비즈니스) B가 저수준(구현체)이라고 가정해보자.
SourceCode 의존성 면에서는 A 가 B의 함수를 사용하기 때문에 A 안에 B의 객체를 가지고 있을 것이고, RunTime 의존성면에서는 실제로 메모리에 올라가서 실행 될때는 A 에서 B의 호출이 일어나게 된다. 만약 B의 클래스에 변경이 가해져 구조가 바뀐다면, A는 필연적으로 영향을 받게 된다.
하지만 OOP 를 이용한다면, B에 변형이 가해져도 A 가 영향받지 않게할 수 있다.
중간에 인터페이스를 두고 느슨하게 결합시키는 것이 포인다.
A 가 I 의 인터페이스를 호출하면 결국 I 의 구현체인 B 가 실행되는 것이다.
결국 A 는 I 에 의존성이 생기고, B 도 I 의 의존성이 생기지만, 인터페이스의 의존성은 변경이 가해져도 유연하게 대처하기 쉽다.
이를 Dependency inversion principle (의존성 역전 법칙)이라고 한다.
SourceCode 의 의존성이 RunTime 의존성의 반대 방향이 된다.
Switch-case 는 각각의 case 에서 다른 모듈들을 호출해서 사용할 수 있는데, case 들 중 하나만 다른 모듈을 호출할 경우, Switch 문은 영향을 받게 된다. -> Case 문쪽에 변경이 가해진다 정도로 이해하면 될 것 같다
Switch-case 문장을 제거하는 절차는 간단하다. 다형성(그때그때 상황에 맞게 별도의 클래스로 추출하거나, 혹은 Composition 을 이용한다.)을 이용하여 런타임에 디스패치가 일어나도록 변환하면 된다.
case 에 있는 문장들을 별도의 클래스로 추출하여, 변경 영향이 발생하지 않도록 한다.
왼쪽에 있는 다이어그램이 이전, 그리고 오른쪽에 있는 다이어그램이 리팩토링된 상태이다
화살표를 보면 기존은 Switch 가 각각의 모듈을 호출하고있지만, 리팩토링된 이미지는 Switch 문이 제거되었고, Base class 를 통해 Derivatives 가 참조되는 방식으로 제어가 역전이 된 상태(DIP)이다.
* Temporal Coupling
- 함수를 호출할 때 순서를 지켜야 호출되어야하는 것들이 있다.
e.g 파일을 열고 -> 파일 처리를 하고 -> 파일을 닫아준다.
그러나 누군가는 파일을 열지않고, 파일을 처리하려고 할 수도 있고, 실수로 파일 닫아주는 것을 잊을 수도 있다.
이런 문제를 해결하기 위해, 별도의 클래스로 래핑하여 해결할 수 있는데 코드로 보면 이해가 빠를 것 같다.
fileCommandTemplate.process(myFile, new FielCommand() {
public void process(File f) {
// TODO process..
}
})
class FileCommandTemplate {
public void process(File file, FileCommand command) {
file.open(); // 파일을 열고
command.process(file);
file.close(); // 파일을 닫는다
}
}
전략 패턴(FileCommand)을 사용하여, 외부에 전략을 둠으로써 확장성도 고려되었고, 파일을 열고, 닫는 부분까지 구성되어 있어 사용자는 주의하면서 코딩을 해야하는 부담감이 줄어든다.
* CQS (Command Query Separate)
Command - 내부 상태 변경됨, Side effect 가짐, 아무것도 반환하지 않아야한다(반환형 Void)
Query - 내부 상태 변경하지 않음, Side effect 없음, 계산이나 상태 등을 반환
상태를 변경하는 함수는 값을 반환하면 안된다.
값을 반환하는 함수는 상태를 변경하면 안된다
// bad
// User 를 가져오기 위해서는 항상 로그인을 해야함
// login을 하면 원하지 않아도 user 를 받아야함
User u = authorizer.login(userName, password);
// good
// 로그인과 유저를 가지고 오는 부분이 분리되어 원할 때 유저를 로그인 시키고, 또는 유저 정보를 가져올 수 있다.
authorizer.login(userName, password);
User u = authorizer.getUser(userName);
* Tell Don't Ask
- Extreme 한 CQS 는 C 와 Q 를 함께 사용하지 않는 것이다.
// bad
if(user.isLoggedIn()) {
user.execute(command);
} else {
authenticator.promptLogin();
}
// good
try {
user.execute(command);
} catch(User.NotLoggedIn e) {
authenticator.promptLogin();
}
// good - another
user.execute(command, authenticator);
위 예제중 2번째 방식은 현업에서 사용해본적은 많지 않은 것 같아 어색한 부분이 있기도 한데, 3번째 한줄짜리 방법은 상당히 괜찮다고 생각한다. 구현하는 입장에서는 어려울 수 있지만, 사용하는 쪽에서는 알아야할 것 들이 적고, 가장 직관적으로 이해된다.
Tell Don't Ask 를 잘 지키면, Query 부분의 로직이 없어진다. 위의 예제에서도 user.isLoggedIn 과 같은 유저의 로그인 상태를 가져오는 쿼리 부분 로직이 사라졌다.
// bad
o.getX()
.getY()
.getZ()
.doSomething();
// good
o.doSomething();
최대한 Ask(getX, getY, getZ) 하는 메서드를 제거해야한다.
종속적인코드가 늘어나면, 데이터를 모킹하거나 유닛 테스트를 작성하는데 어려움이 있다.
아래 코드는 o 만 만들면 되지만, 위의 코드는 x, y, z를 모두 모킹해야 doSomething()을 실행할 수 있다.
*Law of Demeter
- 하나의 함수가 전체 시스템의 객체들 간의 네비게이션을 아는 것은 잘못된 설계이다.
o.getX() // o 는 x 를 가진다
.getY() // x 는 y 를 가진다
.getZ() // y 는 z 를 가진다
.doSomething(); // z 는 doSomething() 할 수 있다.
// 함수가 시스템에 너무 많은 의존성을 가지고 있다.
- 함수가 시스템의 전체를 알게 하면 안된다.
- 개별 함수는 아주 제한된 지식만을 가져야한다.
Law of Demeter 를 잘 지키기 위해서는 인자로 전달된 객체, Local scope 로 생성된 객체, 필드로 선언된 객체, 전역 객체들만 사용해야한다. 이전 메서드 호출의 결과로 얻은 객체의 메소드를 호출하면 안된다(o.getX().getY().getZ()...)
* Early returns
- 가드 성격의 리턴 같은 것들은 조기에 return 해야한다.
- 함수를 다 읽지 않고, 현재 어떤 조건들일 때 return 되는지 보고싶을 수도 있다. 다양한 경우를 생각하면 리턴은 빠르게 나올 수록 좋다.
- Loop 문 중간에 리턴이 되는 경우는 문제가 된다(코드를 읽어나가는데 어려움이 생긴다)
- 결국은 읽기 쉬운 코드를 만들고 유지보수를 높인다.
* Error handling
- 0보다 작은 사이즈를 가진 스택에서 pop 하면 0을 반환해야할까 -1을 반환해야할까 무엇을 반환해야할까?
public static Stack make(int capacity) {
if(capacity < 0)
throw new IllegalCapacity();
if(capacity == 0)
return new ZeroCapacityStack();
return new BoundedStack(capacity);
}
public static class IllegalCapacity extends RuntimeException {}
답 : 예외를 던져서 처리하자!
Checked Exception 은 사용하는쪽에서는 항상 try-catch 로 감싸야하기 때문에, 유지보수하는데 어려움이 있을 수도 있다
그래서 마틴 파울러는 checked exception은 사용하지 않는 것을 추천하고 있고, exception 이 발생했을 때 해야하는 명확한 일이 있을 때는, 사용하는 것이 좋다.
* Special cases
- 프로그래밍을 하다보면, 특별한 케이스에 마주하는 경우가 있다.
public static Stack make(int capacity) {
if(capacity < 0)
throw new IllegalCapacity();
if(capacity == 0)
return new ZeroCapacityStack();
return new BoundedStack(capacity);
}
위에서 사용했던 예제를 사용할 것이다.
만약 Capacity 가 0 인 Stack 을 생성하면 어떻게 되나 고민해보자.
- 0인 스택은 의미가 없으니, 새로운 Exception 을 추가해야할까? (만약 이런 방식으로 개발한다면, 앞으로 Push, Pop 등의 메서드에는 사이즈가 0 이라면 에러를 던지는 코드가 모두 삽입되어야할 것이다.) 하지만 우리는 많은 Exception 을 생성하는 것을 원하지 않는다.
게다가 정말로 Capacity 가 0 인 Stack 은 에러를 던지는 것이 맞을까?
-> 아니다 Zero size stack 은 잘 정의된 행위를 가지고 있다는 것이 밝혀졌다.
Push 를 하면 Overflow 가 발생하고, Pop 을 호출하면 Underflow 가 발생하고, getSize 는 항상 0을 반환한다
이런 기능 구현을 Stack 클래스의 모든 메소드에 사이즈가 0 인지 체크하는 로직을 추가하는 것은 비효율적이다.
이런 경우 아래와 같이 코딩하면 해결 가능하다. 별도의 클래스로 분리해주었기 때문에, push, pop 등에 영향이 없게 된다.
public static Stack make(int capacity) {
if(capacity < 0)
throw new IllegalCapacity();
if(capacity == 0)
return new Stack() { // TODO ZeroSizeStack 으로 추출하기
public Boolean isEmpty() {
return true;
}
public Integer getSize() {
return 0;
}
public void push(int element) {
throw new Overflow();
}
public int pop() {
throw new Underflow();
}
}
return new BoundedStack(capacity);
}
* Null is not a error
- 에러를 null 로 처리하지 말자.
- 에러는 예외를 던지는 방식으로 처리하자.
* Null is a value
- Stack 에 find 메서드에서 원소를 찾지 못하면 어떻게 해야할까? -> 이럴때 null 을 리턴해주는 것이 올바른 방법이다.
* try 도 하나의 기능이다.
- try 문장 안에는 항상 하나의 메서드만을 남겨두자
- finally 함수가 마지막 블럭이어야한다. 이후에는 어떤 라인도 있으면 안된다.
- 함수는 하나의 일만 해야하고, try 도 함수이기 때문에 하나의 일을 해야한다.
'Code Paradigm' 카테고리의 다른 글
클린 코더스 강의 요약(Architecture) - 5 (0) | 2020.11.03 |
---|---|
클린 코더스 강의 요약(Form) - 4 (0) | 2020.10.05 |
클린 코더스 강의 요약(Function) - 2 (0) | 2020.07.20 |
클린 코더스 강의 요약 (OOP) - 1 (0) | 2020.07.19 |
코틀린으로 작성하는 Double Dispatch(더블 디스패치) (0) | 2020.03.22 |