if-else, switch로 시작해서 객체지향으로 리팩토링하기

KYUNGPYO LIM·2026년 4월 8일

Java

목록 보기
2/3

들어가며

시작에 앞서 이 블로그 내용은
Robert C. Martin의 블로그 if-else-switch의 글을 읽고 재구성한 글임을 밝힙니다.

if-else 문과 switch문

우아한 테크코스를 진행하며 계속해서 다음과 같은 요구 사항이 필수로 붙어있었다.

  • else 예약어를 쓰지 않는다.
    • else 예약어를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다.
    • 힌트: if문에서 값을 반환하는 방식으로 구현하면 else 예약어를 사용하지 않아도 된다.

힌트를 보고 early return 방식을 사용해서 else 예약어를 사용하지 않는 방식으로 코딩하라는 것으로 이해했다.

public Game calculateResult(int a) {

	Game result;
	if(a == 1) {
    	result = Game.WIN;
    } else if (a == 2) {
    	result = Game.DRAW;
    } else {
    	result = Game.LOSE;
    }
    
    return result;
}
if, else를 사용하는 코드
public Game calculateResult(int a) {
	switch(a) {
    	case 1: 
        	return Game.WIN;
        case 2:
        	return Game.DRAW;
        default:
        	return Game.LOSE;
    }
}
switch를 사용하는 코드
public Game calculateResult(int a) {
	if(a == 1) {
    	return Game.WIN;
    }
    if (a == 2) {
    	return Game.DRAW;
    } 
    return Game.LOSE;
}
else를 사용하지 않고 early return을 하는 코드

사실 이 세개 버전의 코드는 차이가 크게 느껴지진 않고 단순히 가독성 측면의 차이가 느껴진다.
Early Return은 중첩 if문들이 존재할 때 그 진가가 드러나게 된다.


Early Return 패턴

Early Return 패턴을 사용하면 중첩된 조건문의 수를 줄여서 코드 가독성을 높일 수 있다.

아래 예시는 3가지 조건을 만족할 때 User에게 메시지를 보내는 간단한 코드 예시이다.
1. User가 존재
2. User가 활성화
3. User의 권한 확인

public void sendMessage(User user) {
    if (user != null) {
        if (user.isActive()) {
            if (user.hasPermission()) {
                messageSender.sendTo(user);
            }
        }
    }
}
중첩 if문이 사용되고 있는 구조

조건 분기가 중첩되어 가독성이 현저히 떨어지고 있다.
아래와 같이 코드가 진행되기 때문이다.

1번 조건 만족 → 2번 조건 만족 → 3번 조건 만족 → 로직 실행

이 구조에서는 조건을 하나씩 통과하면서, 앞선 조건들을 계속 기억한 채 코드를 이해해야 한다.

이제 아래 Early Return 패턴을 적용한 코드를 보자

public void sendMessage(User user) {
    if (user == null) {
        return;
    }
    if (!user.isActive()) {
        return;
    }
    if (!user.hasPermission()) {
        return;
    }
    messageSender.sendTo(user);
}
Early Return 패턴을 사용해 구조를 개선한 모습

코드의 depth도 줄어들었고 비교적 간단해진 것 같이 보인다.

  1. User가 없으면 끝
  2. User가 활성화 되지 않으면 끝
  3. User가 권한이 없으면 끝
  4. 메시지 전송

별 차이가 없다고 생각할 수 있지만
이제 각 단계는 조건을 만족하지 않을 경우 즉시 종료되기 때문에, 중첩 if문과 달리 이전 조건을 계속 기억할 필요가 없다.

이것이 가장 큰 차이점이고 강점이다.


Early Return은 만능일까?

그렇다면 Early Return으로 바꾸면
1. 모든 분기를 비교적 깔끔하게 바꿀 수 있고
2. 이전 조건을 기억할 필요가 없어진다

위의 2가지 이점을 얻을 수 있는데 가장 좋은 것 아닌가? 라고 생각할 수 있다.

하지만 치명적인 약점이 있다.
이는 Early Return 패턴 자체의 약점이 아닌 조건 분기를 하게 되면 가지게 되는 어쩔 수 없는 약점이다.

  1. 같은 조건 분기 문이 여러 곳에서 중복될 수 있다.
  2. 변경에 취약해진다.
  3. 상위 모듈이 하위 모듈에 의존하게 될 수 있다.

각 경우에 대해서 살펴보자.

1. 같은 조건 분기 문이 여러 곳에서 중복될 수 있다.

Early Return은 하나의 조건문을 읽기 쉽게 정리해줄 수는 있다.
하지만 동일한 판단이 필요한 곳이 늘어나면, 그 조건 분기 역시 여러 곳에 복제되기 쉽다.

문제는 "이 분기들이 항상 같은 반환 형태로 사용되지 않는다"에 있다.
어떤 곳에서는 정수 값으로, 어떤 곳에서는 문자열 값으로, 또는 서로 다른 표현을 섞어서 분기할 수도 있다.

public String reservationStatus(User user) {
    if (user == null) {
        return "USER_NOT_FOUND";
    }
    if (!user.isActive()) {
        return "INACTIVE_USER";
    }
    if (!user.hasPermission()) {
        return "NO_PERMISSION";
    }
    return "AVAILABLE";
}
원본 예시와 동일한 조건 분기가 문자열을 반환하는 경우
public boolean canWrite(User user) {
    if (user == null) {
        return false;
    }
    if (!user.isActive()) {
        return false;
    }
    if (!user.hasPermission()) {
        return false;
    }
    return true;
}
원본 예시와 동일한 조건 분기가 boolean을 반환하는 경우

2. 변경에 취약해진다.

만약 아래의 요구 사항이 추가된다면 어떻게 될까?

  • 차단된 사용자는 메시지를 받을 수 없다.
public void sendMessage(User user) {
    if (user == null) {
        return;
    }
    if (!user.isActive()) {
        return;
    }
    if (!user.hasPermission()) {
        return;
    }
    if (user.isBlocked()) {
    	return;
	}
    messageSender.sendTo(user);
}
마지막 유저 차단 여부 체크 조건 추가

이렇게 분기를 직접 추가해주어야 한다.
요구 사항이 늘어난다면 분기가 계속해서 늘어나게 되고 가장 큰 문제는 1번의 상황과 엮여있을 때 발생하게 된다.

동일한 조건 분기가 여러 곳에 퍼져있다면 요구사항의 변경이 생기면 해당 분기가 발생하는 곳을 모두 찾아서 전부 수정을 해주어야만 한다.

이건 OCP 원칙을 위반하는 것이다.

하나라도 실수하면 의도와는 다르게 동작하는 코드가 되어버린다.
하지만 개발자는 사람이기에 휴먼에러가 발생할 가능성을 절대 무시할 수는 없다.

3. 상위 모듈이 하위 모듈에 의존하게 될 수 있다.

조건 분기를 사용하게 되면 특정 상황에서 상위 모듈이 하위 모듈을 의존하게 되는 경우가 발생할 수 있다.

실제로 내 코드에서도 이런 상황을 자주 마주할 수 있었다.

아래 코드는 상위 모듈이 하위 모듈에 의존하는 상황을 보여주기 위한 예시 코드이다.

public class MessageService {

	private final EmailSender emailSender;
    private final SmsSender smsSender;

	public void sendMessage(MessageType type) {
    	if (type == MessageType.EMAIL) {
    		emailSender.send();
            return;
		}
        if (type == MessageType.SMS) {
    		smsSender.send();
            return;
		}
    }
}
하위 모듈에 의존하고 있는 코드

상위 모듈 MessageServiceEmailSender, SmsSender 같은 구체 구현 클래스에 직접 의존하고 있는 상황이다.

하지만 이러면 DIP 원칙를 위반하게 된다.

그리고 MessageType 종류가 추가되는 경우에는 위의 2번 문제도 또 발생하게 된다.

이 경우 발생하는 또 다른 문제는 의존성의 전이가 일어난다는 점이다.
현재는 MessageService만 하위 모듈에 의존하고 있다.

그런데 만약 ControllerMessageService 의존 관계가 추가된다고 하면, Controller 역시 하위 모듈에 의존하는 것과 다를바가 없게 된다.

한 번 하위 모듈에 의존하기 시작하면,
그 모듈을 참조하는 상위 모듈들까지 연쇄적으로 하위 모듈에 의존하게 되고,
결국 의존성이 시스템 전체로 퍼지게 되는 것이다.

이러한 구조는 마치 "Dependency Magnet"처럼 동작한다.
-Robert C. Martin


조건 분기의 문제점 중간 정리

결국 Early Return은 조건문을 읽기 쉽게 만들 수는 있지만 조건 분기 자체를 없애주지는 못한다.


다형성을 활용해 조건 분기 줄이기

그러면 어떻게 이를 해결할 수 있을까??

정답은 다형성에 있다.
다형성의 정의는 다음과 같다.

동일한 메시지에 대해 서로 다른 객체가 서로 다른 방법으로 응답하는 것

1번째 예시

public void sendMessage(User user) {
    if (user == null) {
        return;
    }
    if (!user.isActive()) {
        return;
    }
    if (!user.hasPermission()) {
        return;
    }
    if (user.isBlocked()) {
        return;
    }
    messageSender.sendTo(user);
}

이 예시의 경우 아래와 같이 다형성을 활용해 조건 분기를 줄일 수 있다.

public interface User {
    void sendMessage(MessageSender messageSender);
}
User 인터페이스
public class ActiveUser implements User {

    @Override
    public void sendMessage(MessageSender messageSender) {
        messageSender.sendTo(this);
    }
}
활성화 된 사용자
public class InactiveUser implements User {

    @Override
    public void sendMessage(MessageSender messageSender) {
        // 아무것도 하지 않음
    }
}
비활성화 된 사용자
public class BlockedUser implements User {

    @Override
    public void sendMessage(MessageSender messageSender) {
        // 아무것도 하지 않음
    }
}
차단된 사용자

User의 값을 꺼내와서 조건을 확인하는 것이 아닌,
User 객체들이 직접 자신이 message를 보낼 수 있는지 판단하게 하는 것이다.

public void sendMessage(User user) {
    if (user == null) {
        return;
    }
    user.sendMessage(messageSender);
}
메서드 호출부

그러면 메서드를 호출하는 호출부는 위 처럼 간단하게 정리할 수 있다.

하지만 user가 null인지 체크하는 조건 분기는 여전히 남아있다.

이는 객체의 책임이 아니기 때문에, 객체 내부로 이동할 수 없는 로직이다.

좋은 객체 지향이란

"객체를 능동적으로 판단하고 행동하게 만드는 것"

에 있는 것이지,
모든 조건 분기를 객체로 옮기는 것이 아니다.

객체가 비어있는지 여부를 판단하는 것은
객체 스스로의 행동이 아니라, 객체를 사용하는 쪽의 책임이기 때문에 해당 조건은 남겨졌다.

2번째 예시

public class MessageService {

    private final EmailSender emailSender;
    private final SmsSender smsSender;

    public void sendMessage(MessageType type) {
        if (type == MessageType.EMAIL) {
            emailSender.send();
            return;
        }
        if (type == MessageType.SMS) {
            smsSender.send();
            return;
        }
    }
}

이 예시의 경우 아래와 같이 다형성을 활용해 조건 분기를 줄일 수 있다.

public interface MessageSender {
    void send();
}
MessageSender 인터페이스
public class EmailSender implements MessageSender {

    @Override
    public void send() {
        System.out.println("이메일 전송");
    }
}
MessageSender 구현 클래스 EmailSender
public class SmsSender implements MessageSender {

    @Override
    public void send() {
        System.out.println("문자 전송");
    }
}
MessageSender 구현 클래스 SmsSender
public class MessageService {

    private final MessageSenderFactory messageSenderFactory;

    public MessageService(MessageSenderFactory messageSenderFactory) {
        this.messageSenderFactory = messageSenderFactory;
    }

    public void sendMessage(MessageType type) {
        MessageSender messageSender = messageSenderFactory.make(type);
        messageSender.send();
    }
}
구체 클래스 의존성이 제거된 MessageService

앞서 다형성을 적용하면서 MessageService는 더 이상 구체 클래스에 의존하지 않게 되었다.

하지만 한 가지 문제가 남아있다.
어떤 MessageSender를 사용할 것인지는 여전히 누군가가 결정해야 한다는 점이다.

이 책임을 담당하는 것이 바로 Factory이다.

public class MessageSenderFactory {

    public MessageSender make(MessageType type) {
        if (type == MessageType.EMAIL) {
            return new EmailSender();
        }
        if (type == MessageType.SMS) {
            return new SmsSender();
        }
        throw new IllegalArgumentException("지원하지 않는 메시지 타입입니다.");
    }
}

Factory는 조건 분기를 통해 적절한 구체 클래스를 선택하고,
그 인스턴스를 생성하여 반환하는 역할을 담당한다.

중요한 점은, 이 조건 분기가 더 이상 시스템 전반에 흩어져 있지 않고
단 하나의 위치에 모여 관리된다는 것이다.

Robert C. Martin은 분기를 완전히 제거하는 것보다,
이처럼 분기가 필요한 지점을 한곳으로 모아 관리하는 것이 더 중요하다고 말한다.

Factory 클래스는 프로그램 내에서 여러 곳으로 흩어질 수 있는 조건 분기문을 한곳으로 모아서 관리하고, 상위 모듈(MessageService)에게 적절한 인스턴스(하위 모듈)를 반환해주는 유일한 역할을 수행하게 된다.

이제 의존 관계는 다음과 같이 변경된다.

수정 후에는

  • MessageService는 더 이상 구체 클래스에 의존하지 않고
  • 오직 추상 타입(MessageSender)에만 의존하게 되며
  • 객체 생성에 대한 책임은 Factory로 분리된다.

즉, 상위 모듈이 하위 모듈에 의존하던 구조에서 벗어나
의존성 역전(DIP)을 만족하는 구조로 개선할 수 있다.

화살표에 집중해서 다이어그램을 보면 된다!!

  • 수정 전: 상위 모듈의 화살표가 하위 모듈을 가리킨다.
  • 수정 후: 하위 모듈의 화살표가 상위 모듈을 가리킨다.

하위 모듈의 화살표 방향은 상위 모듈을 가리키고,
상위 모듈은 더이상 하위 모듈을 가리키지 않고 있는 구조다!!

(의존하지 않는다는 의미)


마무리

조건 분기를 사용하면 아래와 같은 문제점이 있었다.

  1. 같은 조건 분기 문이 여러 곳에서 중복될 수 있다.
  2. 변경에 취약해진다.
  3. 상위 모듈이 하위 모듈에 의존하게 될 수 있다.

이 문제를 해결하는 핵심은 다음과 같다.

  1. 다형성을 활용해 조건 분기를 통한 객체의 행동 양식을 옮기기
  2. Factory를 통해 조건 분기와 구체 클래스 의존 관리를 한 곳으로 옮기기

즉, 분기를 무조건적으로 제거한 것이 아니라
적절한 위치로 이동시키고 관리 범위를 줄인 것이다.

Early Return은 조건문을 읽기 쉽게 만들어주지만,
조건 분기의 문제 자체를 없애주지는 못한다.

문제의 본질은 "if문이 있다"가 아니라
"같은 판단이 여러 곳에 흩어질 수 있다"는 데에 있다.

다형성과 Factory를 통해 이 분기들을 제거한 것이 아니라,
하나의 위치로 모아 관리할 수 있다는 것을 알게 되었다.

객체는 자신의 책임에 따라 스스로 판단하고 행동하게 만들고,
그 외의 필요한 분기는 적절한 위치에 모아서 관리한다.

이것이 객체지향에서 분기를 다루는 방식이라는 것을 알게 되었다.

우아한 테크코스에서 요구사항에 계속해서 분기문을 사용하지 말라고 하는 이유도 이와 같은 맥락일 것이라고 생각한다.

profile
개발을 잘하고 싶은 사람

0개의 댓글