if-else 문은 어느 프로그래밍 언어를 배우더라도, 아마 대부분의 사람들에게 있어서 익숙하게 접할 수 있는 제어 구문일 것이다.
여러 언어 내에서 해당 구문의 세부적인 부분은 조금씩 다를 수 있지만, 핵심은 조건에 따라 코드를 분기하는 기능을 수행해 코드의 흐름을 제어할 수 있다는 점이다.
그런데 개발 커뮤니티를 보다 보면 종종 'if-else 문의 사용을 지양해야 한다'는 주장을 어렵지 않게 찾아볼 수 있다. 어떤 배경에서 이러한 견해가 등장하게 되었을까?
오늘은 그러한 주장의 배경과 함께, 해당 주장을 뒷받침하는 코드 예시를 살펴보고, 코드를 리팩토링 한다면 어떻게 할 수 있을 지에 대해 정리해 보려 한다.
프로그래밍에 있어서 조건문은 필수적인 구문 중 하나이다. 코드를 작성할 때 많은 비중을 차지하기도 하며, 프로그램의 흐름을 제어하는 중요한 역할을 한다.
허나, 과도하게 작성된 조건문은 코드를 읽기 어렵게 만들고 코드의 의도를 파악하기 어렵게 만든다. (여기서 '과도한' 이라는 건 여러 뎁스로 중첩된 if-else 문
이나 줄줄이 길게 이어진 if-else-if 체인
을 의미한다.)
종종 복잡한 비즈니스 로직을 처리하다 보면 코드 속에서 너무 많은 if-else 문을 사용한 경우나, 필요하지 않은데도 굳이 if-else가 사용되는 사례들을 심심치 않게 발견할 수 있다.
function processOrder(order, user) {
if (user.isVerified()) {
if (order.getAmount() <= 0) {
logError("Order amount must be greater than 0.")
} else if (invantoryExists(order.getItem()) {
if (user.getCreditLimit() >= order.getAmount()) {
if (order.isPriority()) {
expediteOrderProcessing(order)
} else {
normalOrderProcessing(order)
}
} else {
notifyUserCreditLimitLow()
}
} else {
notifyUserOutOfStock()
}
} else {
notifyUserUnVerified()
}
}
과장된 표현이기는 하지만, 가슴에 손을 얹고 떠올려 보면 개발하면서 이런 코드를 작성해 보거나 마주한 적이 한 번 쯤은 있지 않을까..?
만일 이와 같이 코드를 작성했다 한들, 분명 제대로 동작은 할 것이다. 여기까진 괜찮다. 그런데 이후 새로운 요구 사항이 추가된다거나, 기존 조건이 변경되기라도 한다면 골머리가 아파지기 시작한다.
이를 유지보수 해야 하는 경우, 코드를 읽고 이해하기 어려운 만큼 고치는 과정에서도 상당한 시간과 노력이 소모될 것이다. 결국 이해하기 어렵고
+ 처리하기도 어려우며
+ 테스트도 어려운
3박자를 고루 갖춘 누구도 원하지 않을 그런 코드가 완성된다.
이와 같은 상황에서 최대한 if-else 문을 제거할 수 있다거나 조건문을 좀 더 단순화 할 수 있다면 좋지 않을까? 지금부터 코드의 가독성을 향상시키고 중첩된 조건문을 최적화하기 위한 기법과 전략들에 대해 알아보자.
먼저 가장 기본적인 접근 방식인데, 간단하게 코드 구조를 변경해 if-else 문을 간소화해볼 수 있다. 작성 순서를 바꾸거나 중복된 조건을 제거하고, 중첩된 구문을 분해하여 별도의 함수로 추출하는 등의 방법을 통해 목표 달성이 가능하다.
함수나 메서드 시작 부분에서 빠르게 예외 상황을 처리하는 방법이다
if (isLoggedIn) {
...some codes // 여기에 수십 줄이 있어요 🤔
} else {
throw new AccessDeniedException();
}
if (!isLoggedIn) {
throw new AccessDeniedException();
}
...some Codes // 나머지 코드들 (이제 이 수십 줄의 코드엔 들여 쓰기가 없습니다! ✅)
조건이 충족되지 않으면 바로 함수를 종료시켜 버리는 방식이다.
특별한 케이스나 예외 사항을 별도로 분리해 코드의 앞쪽에 배치하여 빠르게 조건을 검사한다. 그 뒤로 이어지는 정상적인 코드의 흐름을 명확하게 파악할 수 있을 뿐만 아니라 indent가 줄어들어 코드가 좀 더 단순화 되어 보이기도 한다.
하지만 혹자는 클린 코드의 원칙을 언급하며 오히려 가독성을 해친다는 시각도 존재한다. (Clean Code – "부정 조건문을 피하세요")
else 블록이 if 블록과 거의 동일하다면, 이는 코드의 중복이 발생한 것이다
// 거의 동일한 구조의 코드 구간이 2번 등장합니다.. 🤔
function processPermission(userType) {
let permission;
if (userType === 'ADMIN') {
permission = getAdminPermission(); // 권한 설정
displayAdminDashboard(); // 대시보드 표시
logAdminAccess(); // 접근 로깅
} else {
permission = getUserPermission();
displayUserDashboard();
logUserAccess();
}
return permission;
}
// 이제 비즈니스 로직이 한 번만 나타납니다 ✅
function processPermission(userType) {
const permissionActions = {
'ADMIN': {
getPermission: getAdminPermission,
displayDashboard: displayAdminDashboard,
logAccess: logAdminAccess
},
'USER': {
getPermission: getUserPermission,
displayDashboard: displayUserDashboard,
logAccess: logUserAccess
}
};
const {
getPermission,
displayDashboard,
logAccess
} = permissionActions[userType];
displayDashboard();
logAccess();
return getPermission();
}
위에서 우리는 일종의 Look-up Table을 만들었다. (Javascript Patterns — Lookup Tables)
이 방식은 비즈니스 로직이 변경되어도 중복된 곳에서 여러 번 고칠 필요가 없고, 한 곳에서만 고치면 된다는 이점이 있다.
이 함수가 하는 일은 유저 타입에 따라 권한을 가져오고, 대시보드를 보여주고, 접근 로깅을 수행하는 일이다. 만약 이후에 "SUPER", "GUEST" 등 새로운 타입에 대한 처리가 필요한 상황이 오더라도 객체에 매핑만 추가해 주면 된다.
또한 객체가 아닌 Map 자료구조를 활용해 문자열 뿐만 아닌 모든 타입의 데이터를 key 로 사용할 수도 있다.
다만, 이 경우는 매핑에 단일 key를 사용하므로 여러 개의 매개변수를 이용해야 하는 경우라면 한계가 있다.
if-else 블록에서 아무런 공통점이 없는 블록을 결합하면 의도를 파악하기 어렵다
// 서로 연관성이 떨어지는 두 블록을 한 군데에 합쳐 놓아 코드가 어수선해 보입니다.. 🤔
function processUserAccess(user) {
if (user.type === 'ADMIN') {
grantAdminPermissions(user); // 어드민 권한 부여
logAdminAccess(user); // 어드민 로그 업데이트
sendAdminNotification(user); // 어드민에게 알림 전송
} else {
trackUserActivity(user); // 사용자 활동 추적
checkSubscriptionStatus(user); // 사용자 구독 상태 확인
sendWelcomMessage(user); // 사용자 환영 메시지 전송
}
}
if-else 구문에서 "else"가 무엇을 의미할까? 문법적으로는 조건이 만족하지 않을 때 실행되는 블록이다.
코드를 읽는 사람의 입장에서는, else 블록을 "대안" 이나 "그 외의 경우" 라고 생각할 것이다. if 조건이 거짓인 경우에 실행되는 대체적인 로직 말이다.
구조적으로 봤을 때는 보통 if 블록에서 처리되지 않는 나머지 경우에 해당하는 작업들이 배치되는 곳이다.
따라서 각 블록이 서로 유사한 작업을 수행할 거라는 기대에 따라 코드를 이해하고 분석할 가능성이 높으므로, 이 else 블록에 if 블록에서 수행한 작업과 전혀 동떨어지는 작업을 수행한다면 배경 지식이 없는 사람의 입장에서는 의도를 분명하게 파악하기 어려울 수 있다.
뿐만 아니라, 예제 코드는 하나의 함수 안에서 너무 많은 작업을 수행하고 있다. 이는 '함수는 한 가지 일을 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다'(참고) 라는 프로그래머들에게 지난 수십 여년 간 전해지고 있는 충고에 어긋나기도 한다. 따라서 유저 타입에 따라 별도 함수로 분리하도록 코드를 고칠 수 있다.
이처럼 명확한 분기 로직을 작성하여, 혼란이 생길 여지를 최소화 하는 것이 바람직하다.
또 다른 방법은 class를 도입하는 방식이다.
앞서 살펴본 방법보다는 코드 작성량도 좀 더 늘어날 수 있고, 클래스를 작성하고 구조를 설계하는 데도 추가적인 노력이 필요하며, 때로는 오히려 복잡성을 증가시키기도 한다.
하지만 복잡한 로직이나 다양한 조건에 대응해야 하는 경우라면, 이렇게 초기에 쏟아 부었던 노력과 시간이 향후 발생 가능한 복잡성과 유지보수성의 어려움을 조금이나마 해소하는 데 도움을 줄 수도 있다.
조건문의 각 분기에 해당하는 하위 클래스를 만든다. 이제, 각 하위 클래스에 공통된 메서드가 있고, 조건문의 분기에 있던 코드를 관련 메서드 호출로 대체한다.
// 함수 선언
function calculateTotal(product, memberGrade) {
let discountPercent = 0;
if (memberGrade === 'VIP') {
discountPercent = 30;
} else if (memberGrade === 'GOLD') {
discountPercent = 20;
} else if (memberGrade === 'SILVER') {
discountPercent = 10;
}
const discountAmount = product.price * (discountPercent / 100);
return product.price - discountAmount;
}
// 상품 객체 및 회원 등급
const product = { name: 'Bag', price: 7000 };
const memberGrade = 'VIP';
// 결과
const total = calculateTotal(product, memberGrade);
console.log(total); // 4900
// 상품 클래스 정의
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
calculateTotal(discountPercent) {
return this.price - (this.price * (discountPercent / 100));
}
}
// 회원 클래스 정의
class Member {
constructor(grade = 'General', discountPercent = 0) {
this.grade = grade;
this.discountPercent = discountPercent;
}
getDiscountPercent() { return this.discountPercent; }
}
class VIPMember extends Member {
constructor() { super('VIP', 30); }
}
class GoldMember extends Member {
constructor() { super('Gold', 20); }
}
class SilverMember extends Member {
constructor() { super('Silver', 10); }
}
// 주문 처리 함수
function calculateTotal(product, member) {
return product.calculateTotal(member.getDiscountPercent());
}
// 회원 객체 생성
const vipMember = new VIPMember();
const goldMember = new GoldMember();
const silverMember = new SilverMember();
const generalMember = new Member();
// 상품 객체 생성
const product = new Product('Bag', 7000);
// 결과
console.log(calculateTotal(product, vipMember)); // 4900
console.log(calculateTotal(product, goldMember)); // 5600
console.log(calculateTotal(product, silverMember)); // 6300
console.log(calculateTotal(product, generalMember)); // 7000
수정된 코드에서 다형성은 여러 클래스가 동일한 메서드 명(getDiscountPercent
)을 가지고 있으며, 이들 클래스의 객체들이 해당 메서드를 호출할 때 각 객체의 클래스에 따라 서로 다른 값이 반환되는 형태로 나타난다.
이렇게 다형성을 통해 if-else 문 없이도(조건문을 최소화 하고도) 각 객체의 클래스에 따라 서로 다른 동작이 수행되므로 더욱 유연한 코드를 작성할 수 있다.
코드를 작성할 때 if-else 문을 최소화하면서도 클린 코드 원칙에 따르도록 하는 몇 가지 대안적인 방법에 대해 알아보았다. (예시 코드도 최대한 있을 법한 상황으로 가정해 작성해 보았지만 부적절한 예제라거나 틀린 부분이 있을 수 있으니 의견 있으시다면 댓글로 부탁합니다!)
알아본 내용 이외에도 더 많은 다양한 방법이 있을 테고, 선호되는 코드 스타일은 회사나 팀마다 다를 수 있다.
아예 if-else 문을 사용하지 말자. 코드에서 제거해 버려야 한다. 라는 의미에서 글을 작성했다기 보다는, if-else가 다른 도구와 마찬가지로 쉽게 남용될 수 있으므로, 우리는 도구를 제대로 알고 작업에 적합한 도구를 선택해야 한다는 생각에서 시작하게 되었다.
만약 코드에 앞서 잠깐 언급하였던 것과 같이 매우 복잡하고 깊게 중첩되어 있는 조건문이 있다면, 이는 코드 리팩토링을 고려해야 하는 시점이라고 생각한다.
if-else 문의 사용을 줄여 코드의 유지보수성을 높이고 언제 변할지 모르는 요구사항에 대해서도 한층 더 쉽게 대응할 수 있어야 한다는 걸 강조하고 싶다.
결국, 클린 코드 원칙을 따르면서, 그중에서도 특히 읽기도 쉽고 고치기도 쉬운 코드를 작성하기 위한 노력은 나중에 돌아봤을 때 절약되는 시간과 노력이라는 형태로 보상받게 될 것이다.
나 또한 오래 두고 보았을 때 당장의 손쉬운 선택보다는 주어진 상황에서 가장 올바른 선택을 할 수 있는 개발자가 되고자 한다.