Our server coding convention: Prefer early return style!
우리 회사의 Coding convention 중 하나로 Code Readability가 있다. 그 중 하나가 “Prefer early return style”이다.
boolean someMethod() {
if (!someCondition) {
return false;
}
if (!someOtherCondition) {
return false;
}
...do more work...
return true;
}
이것만 보면 “그렇구나”라고 말텐데, 실제로 리뷰를 받아보면 느낌이 확 다르다.
이전에 올렸던 글에서 예제로 풀었던 코드를 닐이 직접 리뷰봐주셨다(어디선가 보고 계실 닐..사랑합니다..ㅎ)
case 2:
System.out.println("출금액> ");
int withdraw = scanner.nextInt();
if (balance - withdraw < 0) {
System.out.println("잔액이 부족해 출금할 수 없습니다.");
} else {
balance -= withdraw;
}
break;
이 코드였다. 그냥 보면 별 문제 없어보이나 아래 내용을 공부하고 나면 바로 눈에 들어온다. 무엇이 문제였는지 Return Early Pattern을 공부해보면서 한 번 살펴보자. (원문 글은 여기 링크) 아래는 링크에 첨부한 아티클을 번역한 글이다.
Return Early Pattern
프로그래밍을 배울 때, 함수를 만들면서 가졌던 기본적인 사고 과정은 결과에 도달할 때까지 해당 요구사항을 검증하는 것이었습니다.
아래 예제 코드를 하나 살펴보자. 무엇이 이상한 것 같나?
public String returnStuff(SomeObject argument1, SomeObject argument2) {
if (argument1.isValid()) {
if (argument2.isValid()) {
SomeObject otherVal1 = doSomeStuff(argument1, argument2)
if (otherVal1.isValid()) {
SomeObject otherVal2 = doAnotherStuff(otherVal1)
if (otherVal2.isValid()) {
return "Stuff";
} else {
throw new Exception();
}
} else {
throw new Exception();
}
} else {
throw new Exception();
}
} else {
throw new Exception();
}
}
우리는 위의 접근 방식에서 무엇을 관찰할 수 있을까?
- 코드의 비선형적인 흐름 → nest(계속 if-if-if..이런 식으로 안으로 파고드는 걸 말함, 중첩이라고 하면 될듯)한 조건으로 인해 코드를 따라가기 어려움.
- 각
if
에 대해 대응하는else
를 확인하기가 어려운데, 특히if
블록이 클 경우if
에서 처리하는 에러를 읽기 혼란스럽게 한다. - 예상되는 결과를 찾으려면 중첩된 if를 따라가면서 코드의 흐름을 읽어야만 한다.
- 이 예제의 경우, 예외(exception) 케이스가 else에서 발생하는 것을 볼 수 있다. 여기서 만약 else가 실행문을 종료하지 않는다면, 코드의 나머지가 실행될 것이다. 이는 불필요한 에러를 일으킬 수 있다.
이는 또한 몇 가지 안티 패턴을 포함한다.
- Else is considered smelly: 조건이 복잡할 경우
else
문은 두 배로 복잡해진다. 왜냐면 읽는 사람은 그 복잡한 조건을 또 뒤집어야 하기 때문이다. 심지어 만약if
블록이 크다면, 무슨 조건을 정했는지 까먹기 쉽다. 따라서 중첩된if
와else
는 독자로 하여금 혼란스럽게 한다. - Arrow anti-pattern은 코드의 모양이 마치 화살과 같다고 해서 붙여진 이름인데, 이는 중첩된 조건 및 루프로 인해서 만들어진다.
// Arrow anti-pattern: 그지같이도 생겼다.
if
if
if
if
do something
endif
endif
endif
endif
Return Early
이제 새로운 마음가짐을 갖고서 코드를 리팩토링해보자.
Return early는 함수 혹은 메소드를 작성하는 방법으로, Return early를 씀으로써 예상하는 결과가 함수의 끝에서 리턴된다. 또한, 조건이 충족되지 않았을 때 코드의 나머지 부분이 실행문을 종료(예외처리를 리턴함으로써)시킨다.
이 방식은 if 조건문으로 에러를 처리하거나 적절한 예외를 반환하여 함수를 실행함으로써 이뤄진다. 아래 예시를 보자.
public String returnStuff(SomeObject argument1, SomeObject argument2){
if (!argument1.isValid()) {
throw new Exception();
}
if (!argument2.isValid()) {
throw new Exception();
}
SomeObject otherVal1 = doSomeStuff(argument1, argument2);
if (!otherVal1.isValid()) {
throw new Exception();
}
SomeObject otherVal2 = doAnotherStuff(otherVal1);
if (!otherVal2.isValid()) {
throw new Exception();
}
return "Stuff";
}
여기서 몇 가지 관찰할 점을 살펴보면
- 인덴테이션이 오직 한 뎁스만 들어가있다. 이렇게 되면 선형적으로(위에서 아래로 linear) 읽을 수 있다.
- 예상 결과를 함수 끝에서 빠르게 찾을 수 있다. (if문은 전부 예외 관련 케이스이니 예상 결과를 바로 보고 싶으면 그냥 맨 밑만 확인하면 끝)
- 이 사고 과정을 활용하면 에러를 가장 먼저 찾는데 주의를 기울이고 비즈니스 로직을 안정적으로 구현하는 걸 나중에 할 수 있다. 이렇게 할 경우, 불필요한 버그를 피할 수 있다.
- 예외 케이스를 먼저 처리하는
fail-first
마인드셋은 TDD(바로 그 TDD!)와 유사하다. 이는 코드를 테스트하기 훨씬 쉽게 해준다. - 함수는 에러가 발생하는 즉시 종료되기에, 의도하지 않게 코드가 실행될 가능성을 방지하게 한다.
Design Patterns
return early 마인드셋을 사용하면 아래와 같은 디자인 패턴을 쓸 수 있다.
Fail Fast
Jim Shore와 Martin Fowler가 2004년에 Fail Fast 컨셉을 창안했다. 이 컨셉은 return early 규칙의 기초이다. 실패를 빠르게 하는 동안, 코드는 더욱 강건해지는데 이는 초기에 코드 실행이 종료될 수 있는 조건을 찾는데 집중하기 때문이다. 이 접근 방식으로, 버그를 찾고 고치는 게 훨씬 쉬워진다.
Guard Clause
guard clause는 return
문이나 예외를 사용하여 즉각적으로 함수를 종료시키는 방식(if문을 뒤집은 방식)을 말한다. guard clause를 사용해, 가능성 있는 에러 케이스를 식별하고 적절한 예외를 반환 또는 폐기해 각 처리를 수행한다.
함수의 happy path(output으로 가는 여정)은 어떤 validation rule도 에러를 일으키지 않는 데 있다. 따라서, 실행문이 끝까지 성공적으로 지속되게 해 긍정적인 response를 반환하는 것이다.
return early 접근을 사용하면 코드는 선형적으로 읽히고, 따라서 happy path에 가까워진다. 이 패턴을 사용하면 코드 흐름을 따라가는데 시간을 낭비할 필요가 없다. 즉, 머슬 메모리를 사용해 목적지를 찾는 게 가능해진다(머리를 많이 쓰지 않고도 맨 밑으로 가서 빠르게 예상 결과를 확인할 수 있다는 뜻).
Bouncer Pattern
bouncer pattern은 반환하거나 예외를 던짐으로써 어떤 조건을 판별하는 방법이다. 판별 코드가 복잡하고 다양한 케이스에 대해 검증할 때 특히 유용하다. 이는 return early 패턴을 보완한다.
private void validateArgument1(SomeObject argument1) {
if(!argument1.isValid()) {
throw new Exception();
}
if(!argument2.isValid()) {
throw new Exception();
}
}
public void doStuff(String argument1) {
validateArgument(argument1);
// do more stuff;
}
단점(Disadvantages)
return early 접근 방식이 좋지만, 이 또한 몇 가지 비판점이 있다.
함수는 오직 하나의 exit point를 가져야만 한다
이 코딩 규칙은 다익스트라의 structured programming(구조화된 프로그래밍)으로 거슬러 올라간다. 이 단일 엔트리, 단일 종료(Single Entry, Single Exit, SESE)개념은 C, 어셈블리어와 같이 명시적인 자원 관리를 가진 언어에서 비롯된다.
자원을 수동으로 관리하지 않는(관리해서는 안되는) 언어에서는 오래된 규칙을 고수할 가치가 없다. SESE는 종종 코드를 더욱 복잡하게 만든다. 이는 (C를 제외하고) 오늘날의 언어 대부분과 잘 맞지 않는 공룡과도 같다. 즉, 코드의 이해를 오히려 방해한다.
자원(Resource) cleaning → 이게 비판점인가?(Q)
Java와 C#과 같은 고수준 언어는 가비지 컬렉션 기능을 제공한다. 하지만 여전히 몇몇 자원을 수동으로 관리해야 할 경우가 종종 있다. 감사하게도, 최신 언어는 아래와 같은 컨셉을 갖는다.
Try, catch, and finally
문을 사용하면 처음try
블록에서 어떤 가능한 예외든 포착한 다음 마지막final
블록에서 리소스를 해제해 메모리 누수가 없도록 한다.(링크)using
문은 블록 안에서 자원 사용을 허용하며, 비록 종료가 조기에(prematurely) 일어날 지라도 자원이 사용된 뒤 자동으로 폐기되게 한다.(링크)
이러한 컨셉은 함수 실행을 종료할 때 사용된 리소스를 처리하는 동시에 return early 규칙을 사용할 수 있게 한다.
로깅(Logging)과 디버깅(Debugging)
하나의 단일한 return은 함수에서 나가는 모든 예외를 잡는 breakpoint(중단점)를 오직 하나만 추가하고 로그 역시 마지막에만 달면 되기 때문에 쉽다는 주장이 있다. 하지만 항상 그렇지만은 않다.
return early 접근을 사용하면 적절하게 예외문을 던지는 게 가능해진다. 만약 코드가 실패하는 원인이 명확하다면 디버깅은 훨씬 쉬울 것이다. 로그는 개발자가 로그 정보를 더 잘 알기 위해 매 종료 지점 이전에 로그를 추가할 수 있다. 만약 다양한 return 문에서 모든 종료 지점에 로그가 필요하다면, 각 함수로부터 값을 얻은 후에 로그를 달면 된다.
(이건 솔직히 뭔 말인지 모르겠음…)
종료 지점이 많으면 가독성에 영향을 끼친다(Multiple exit points affect readability)
함수가 200줄짜리로 짜여 있으면서 많은 return 문이 여기저기 뿌려져 있다면 좋은 프로그래밍 스타일도 아닐 뿐더러 읽기도 힘들다. 하지만 이러한 return문이 없다면 이 함수를 이해하기도 쉽지 않을 것이다. 따라서 함수를 합리적인 크기로 제한시키기 위해 Bouncer 패턴과 extract method 패턴이 반드시 사용되어야 한다.
Code style is subjective
디자인 패턴이란 소프트웨어 설계에서 일반적으로 발생하는 문제에 대한 재사용가능한 솔루션이다. 이는 개발자들이 작업을 쉽게 하는데 도움이 되는 일종의 규칙이며 적절한 경우에만 사용해야 한다. 하지만 이 중 몇몇 패턴은 주관적이다. 예시를 보자.
// First approach
public String returnStuff(SomeObject argument) {
if(!argument.isValid()) {
return;
}
return "Stuff";
}
// Second approach
public String doStuff(SomeObject argument) {
if(argument.isValid()) {
return "Stuff";
}
}
- 첫번째 접근과 두 번째 접근을 비교할 때, 첫번째 접근에서의 코드 줄이 더 길 뿐더러 복잡도도 높다. 하지만, “return early” 사고 방식으로 쓰여졌기에 향후 기능 개선에 더욱 열려있는 구조임. 하지만 이러한 사고 방식은 KISS와 YAGNI 규칙을 위반하게 되는데, 각각 “Keep It Simple, Stupid(단순하게 해, 멍청아)”와 “You Aren’t Gonna Need It(그건 필요하지 않게 될 걸)”이다. 그러니 만약 나중에 “return early” 패턴이 필요할 경우 이 방식으로 변경하는 게 쉽다.
- 두 번째 접근은 훨씬 간결하고 읽기도 좋다. 코드가 정확히 요점을 짚었기에 나라면 이 문제에서 두 번째 접근 방식을 선택할 것이다.
그럼에도, 누군가 왜 첫번째 접근 방식을 썼는지에 대해서는 이해 가능하다. 이 케이스에서 “정확한 방식
"을 두고 논쟁하는 것은 시간 낭비나 다름없다.
Conclusion
return early 패턴은 함수가 복잡해지는 것을 방지하는 훌륭한 방법이다. 하지만 이것이 모든 상황에 다 적용할 수 있다는 뜻은 아니다. 때때로(특히 복잡한 비즈니스 로직에서는) 필연적으로 중첩된 if문을 만들어야 할 수밖에 없는데, 특히 코드를 다른 함수로 추출하는 옵션에서는 그렇다.
더 나은 접근 방법은 각 팀에서 그들끼리 정보를 공유하고 각 케이스에 대해 어떤 패턴을 사용하는 게 좋을지 결정하며 모두가 프로그래밍할 때 비슷한 마인드셋을 갖고 있다고 확신하는 등 align을 맞추는 것이다. 개발자들은 코드를 쓰는 데보다 읽는 데 더 많은 시간을 들이기 때문에 팀원들 간의 align이 모두에게 긍정적인 경험을 제공할 것이다.
Refactoring code
자, 이제 공부를 마쳤으니 적용해보자. 위 내용을 공부하고 나서 고친 코드는 아래와 같다.
//Before refactoring
case 2:
System.out.println("출금액> ");
int withdraw = scanner.nextInt();
if (balance - withdraw < 0) {
System.out.println("잔액이 부족해 출금할 수 없습니다.");
} else {
balance -= withdraw;
}
break;
//After refactoring
case 2:
//Refactoring code: using return early pattern
System.out.println("출금액> ");
int withdraw = scanner.nextInt();
if (balance - withdraw < 0) {
System.out.println("잔액이 부족해 출금할 수 없습니다.");
break;
}
balance -= withdraw;
System.out.println("출금을 완료했습니다.");
break;
- if-else문에서 else문에 해당하는 내용을 아래로 내림 → 예외 케이스 테스트 코드와 예상 결과를 분리
System.out.println("출금을 완료했습니다.");
추가 → 예상 결과(출금 완료)를 명시
리팩토링을 마치고 닐에게 피드백을 요청드렸다. 리뷰 결과는…?(두구두구)
만세!
Further step
- Code style is subjective와 연관지어서 위 내용 다시 보기
- Resource cleaning이 잘 이해되지 않습니다…ㅠ
- <이것이 자바다> 10장(예외 처리) 공부 후 다시 살펴보기
- 로깅과 디버깅 이해 X → 2-3중 루프 관련? 다시 살펴볼 것
'Spring' 카테고리의 다른 글
[SpringBoot]GET API route 중복 issue 에러 해결 & SpringBoot와 React는 어떻게 통신하나?(Feat. Tomcat) (0) | 2022.07.29 |
---|---|
[Spring]Web - Service Layer 간 상호 의존 문제(Circular Dependency) (0) | 2022.07.16 |
[SpringBoot] Error: Unable to find a @SpringBootConfiguration 에러 원인 및 해결 (0) | 2022.07.03 |
Spring의 설계 철학(Design philosophy)은? (0) | 2022.06.08 |
[디자인 패턴]빌더(Builder) 패턴이란? (0) | 2022.06.04 |