본문 바로가기
이슈와해결

If 분기문 문제를 객체지향, 함수형 프로그래밍을 이용해 해결하기(feat. 우아한테크코스, 스프링 시큐리티)

by Renechoi 2023. 6. 22.

 

If문은 실생활에서도 자주 사용되는 문법이다. 

 

만약 네가 오후 4시에 온다면,
난 3시부터 행복해지기 시작할거야!

 

 

위와 같이 간단한 조건문도 있지만 현실에서는 좀 더 복잡한 경우가 많다. 

 

 

하지만 네가 5시 이후에 온다면,
난 다른 일을 계획해야 할 것이고,
만약 네가 오지 않는다면,
난 혼자 영화를 보거나 책을 읽을 거야.
게다가, 만약 비가 온다면,
우리는 실내에서 만나야 할 것이고,
맑은 날씨라면 공원에서 만나 피크닉을 즐길 수 있을 거야.
...


이런 식으로 현실 세계에는 다양한 "if" 경우의 수가 있고, 이를 "~하면 ~하다"라는 문법으로 표현한다. 

 

프로그래밍에서도 if문은 다양하고 그리고 유용하게 쓰인다. 

 

String beverage;

if (season.equals("여름") && weather.equals("좋음")) {
    beverage = "차가운 아메리카노";
} else if (weather.equals("비")) {
    beverage = "따듯한 카푸치노";
} else if (!weather.equals("비") && sky.equals("흐림")) {
    beverage = "요거트";
} else if (month.equals("7월") && temperature.equals("매우 더움")) {
    beverage = "수박 쥬스";
} else {
    beverage = "마시지 않습니다";
}

 

 

문제는 케이스가 늘어나는 것과 동일하게 if문도 계속 늘어나며, 그 결과 테스트 케이스에 비례하여 선형적으로 코드가 길어진다는 것이다. 아래와 같은 경우를 보자. 겨우 4개의 분기만을 했을 뿐인데도 코드의 길이가 벌써 11줄이 되었다. 

 

public void processWeather(String weather) {
    if (weather.equals("맑음")) {
        System.out.println("날씨가 맑습니다."); // 날씨가 맑을 때 수행할 작업
    } else if (weather.equals("흐림")) {
        System.out.println("날씨가 흐립니다."); // 날씨가 흐릴 때 수행할 작업
    } else if (weather.equals("비")) {
        System.out.println("비가 내립니다."); // 비가 내릴 때 수행할 작업
    } else {
        System.out.println("날씨 정보를 알 수 없습니다."); // 날씨 정보를 알 수 없을 때 수행할 작업
    }
}

 

한 메서드가 10줄 이상이 넘어가기 시작하면 단일 책임 원칙(SRP: Single Responsibility Principle)을 지키기 어려워진다. 여러가지 분기문이 한 메서드에 있을 경우 해당 분기별로 서로 다른 로직을 수행하게 되고, 그에 따르는 부수적인 의존관계 설정 및 메서드 호출이 잦아지면서 코드는 복잡해진다. 그 결과 가독성은 물론 유지보수성, 재사용성을 떨어뜨리는 좋지 않은 코드가 되어버린다. 

 

그런데 if 분기문이 명백하게 필요할 것 같은 상황에서 직관적으로 다른 대안을 찾는 것이 쉽지는 않다.

 

이 글은  if 분기문이 강제되는 상황에서 이를 회피하고자 시도한 여러 방법들에 대한 회고이다. 여러 과제과 프로젝트를 하면서 시도해본 대안들을 나누고자 한다. 

 

 

 

 

1. 우아한테크코스 프리코스 5기 다리건너기 과제의 사용자 입력 처리 문제 

- 유형: 연습형 과제 

- 기간: 2022년 11월 

- 깃허브: https://github.com/renechoi/java-bridge/tree/renechoi

- if문이 요구된 상황: 사용자의 입력에 따른 처리

   - 입력: 생성할 다리(bridge)의 길이, 라운드마다 플레이어가 이동할 칸, 게임 종료or재시작 여부 

 

 

표준 입출력을 이용한 콘솔 게임에서 사용자의 입력에 따른 처리를 해야 하는 경우였다.

 

게임을 시작하면 사용자는 생성할 다리의 개수를 입력한다. 이후 라운드를 돌면서 이동할 칸을 위 혹은 아래로 선택한다. 게임 진행 중 종료하거나 실패할 경우 재시도 등의 시스템 컨트롤 명령도 할 수 있다. 

 

위와 같은 요구사항에 대해 가장 먼저 생각해볼 수 있는 코드는 다음과 같을 것이다. 

 

if (입력 == 다리의 길이) { 
// 다리 생성 
} else-if (입력 == 이동할 칸) {
// 이동 로직 처리
} else-if (입력 == 게임 종료or재시작) {
  if (종료){
	// 종료처리
  } else{
	  // 재시작처리
  } 
} else {
	// throw 입력 예외 발생
}

 

직관적이고 이해하기 쉽다. 하지만 if-분기문이 갖는 고질적이고 답답한 문제가 그대로 드러나있는 코드이다. 

 

빠른 리턴을 사용해 조금 리팩토링 해보면 어떨까. 

 

if (입력 == 다리의 길이) {
// 다리 생성
return;
}

if (입력 == 이동할 칸) {
// 이동 로직 처리
return;
}

if (입력 == 게임 종료or재시작) {
	if (종료) {
	종료;
	return;
	} 
	재시작;
	return;
	}
}

// throw 입력 예외 발생
throw new 예외("잘못된 입력입니다.");

 

 

else문과 else-if 문을 줄이는 것만으로도 확실히 가독성은 좋아졌다. 하지만 return문을 추가하면서 여러줄이 추가됐고 그결과 코드 길이 자체는 더 길어졌다. 

 

이 문제에 대해 다음과 같이 해결해보고자 했다. 

 

- 명령을 객체로 만들어 관리한다.

- 사용자 input을 담당하는 전용 Reader 클래스를 만든다.

- 해당 클래스는 enum 필드로서 정의된 명령 객체를 갖는다. 

 

이렇게 접근한 결과가 다음과 같은 코드이다. 

 

 

<Controller 클래스>

package bridge.controller;

import bridge.BridgeMaker;
import bridge.BridgeRandomNumberGenerator;
import bridge.domain.game.BridgeGame;
import bridge.domain.game.ResultRendering;
import bridge.view.output.OutputView;

public class Controller {

	private static final String GAME_START_INFO = "다리 건너기 게임을 시작합니다.";

	public void run() {
		OutputView.withContentOf(GAME_START_INFO, false, true).ConsoleMessage();
		BridgeMaker bridgeMaker = new BridgeMaker(new BridgeRandomNumberGenerator());
		BridgeGame bridgeGame = new BridgeGame();

		bridgeGame.play(bridgeMaker);
		OutputView.withContentOf(ResultRendering.renderFinalResult(bridgeGame.getGameResult()), true, false).ConsoleMessage();
	}
}

 

직관적이고 순차적으로 접근해 while문과 if문이 덕지덕지 붙는다면 바로 이부분이었을 것이다. 

 

<birdgeGame의 play메서드> 

public void play(BridgeMaker bridgeMaker) {
	Bridge bridge = new Bridge((BridgeSizeCommand) InputCommandReader.read(CommandReader.BRIDGE_SIZE).command(), bridgeMaker);

	do {
	  crossingTrial(bridge);
	} while (isTrialContinue());
}

 

브릿지를 생성하는데, 브릿지는 Input에 대한 해석을 마친 Command를 인자로 받는다. 사용자 Input에 대한 raw 값을 Brdige 등의 도메인 레이어는 알지 못한다. 즉, 사용자 Input은 인터페이스 계층에서 모든 처리를 마치고 내부로 접근할 때는 Command로서 진입하여야 한다.

 

돌이켜 보면 당시에는 레이어 분리에 대한 별다른 개념이 없었지만 묵시적으로 그렇게 하려고 했었던 것 같다. 그럼에도 InputCommandReader를 도메인 층에서 결국 의존하는 상황은 발생했다. 

 

<Command 객체들> 

public class BridgeSizeCommand implements InputValidator {

	private static final int BRIDGE_SIZE_MIN = 3;
	private static final int BRIDGE_SIZE_MAX = 20;

	private final int bridgeSize;

	public BridgeSizeCommand(int bridgeSize) {
		validate(bridgeSize);
		this.bridgeSize = bridgeSize;
	}

	@Override
	public void validate(Integer inputBridgeSize) {
		isNumberInBetween(inputBridgeSize);
	}

	public static BridgeSizeCommand valueOf(String inputBridgeSize) {

		try {
			return new BridgeSizeCommand(Integer.parseInt(inputBridgeSize));
		} catch (NumberFormatException e) {
			throw new InputException(InputException.NOT_A_NUMBER);
		}
	}

	private void isNumberInBetween(int inputBridgeLength) {
		if (inputBridgeLength < BRIDGE_SIZE_MIN || inputBridgeLength > BRIDGE_SIZE_MAX) {
			throw new InputException(String.format(InputException.NOT_IN_BETWEEN_PROPER_RANGE, BRIDGE_SIZE_MIN, BRIDGE_SIZE_MAX));
		}
	}

	public int bridgeSize() {
		return bridgeSize;
	}

	@Override
	public void validate(String value) {
	}
}

 

public class GameProceedCommand implements InputValidator {

	public static final String RETRY = "R";
	public static final String QUIT = "Q";

	private final String gameCommand;

	public GameProceedCommand(String gameCommand) {
		validate(gameCommand);
		this.gameCommand = gameCommand;
	}

	public static GameProceedCommand valueOf(String gameCommand) {
		return new GameProceedCommand(gameCommand);
	}

	@Override
	public void validate(String inputGameCommand) {
		isAssignedCommand(inputGameCommand);
	}

	private void isAssignedCommand(String gameCommand) {
		if (!gameCommand.equals(RETRY) && !gameCommand.equals(QUIT)) {
			throw new InputException(InputException.GAME_COMMAND_NOT_ASSIGNED_COMMAND);
		}
	}

	public String gameCommand() {
		return gameCommand;
	}

	@Override
	public void validate(Integer value) {
	}
}
public class MovementCommand implements InputValidator {

	private static final String UPPER_SIDE_MOVEMENT = "U";
	private static final String UNDER_SIDE_MOVEMENT = "D";

	private final String movement;

	public MovementCommand(String movement) {
		validate(movement);
		this.movement = movement;
	}

	public static MovementCommand valueOf(String movement) {
		return new MovementCommand(movement);
	}

	@Override
	public void validate(String inputMovement) {
		isAssignedCommand(inputMovement);
	}

	private void isAssignedCommand(String movement) {
		if (!movement.equals(UPPER_SIDE_MOVEMENT) && !movement.equals(UNDER_SIDE_MOVEMENT)) {
			throw new InputException(InputException.MOVEMENT_NOT_ASSIGNED_COMMAND);
		}
	}

	public String side() {
		return movement;
	}

	@Override
	public void validate(Integer value) {
	}
}

 

3가지 사용자 입력을 명령화한 객체들이다.

 

BridgeSizeCommand 

- 다리의 크기를 나타내는 명령을 처리. 
- 생성된 객체는 다리의 크기를 나타내는 bridgeSize 값을 가지며, 이를 통해 다리를 생성하는 데 사용. 


GameProceedCommand

- 게임 진행에 대한 명령을 처리.
-  게임 진행 명령을 나타내는 gameCommand 값을 가지며, 이를 통해 게임의 진행 여부를 판단.

 

MovementCommand

- 이동 방향에 대한 명령을 처리.
- 이동 방향을 나타내는 movement 값을 가지며, 이를 통해 플레이어의 이동 방향을 결정.

 

 

각 명령 객체는 InputCommandReader 클래스를 통해 입력을 받아 생성되며, InputValidator 인터페이스를 구현하여 명령의 유효성을 검증한다.  해당 명령에 필요한 데이터를 캡슐화하여 전달함으로써 스스로의 존재 목적에 맞는 필요한 로직을 수행한다.

 

 

다음은 이 명령 객체들이 생성되는 과정을 처리하는 Reader이다. 

 

public class InputCommandReader {

	private static final String REQUEST_BRIDGE_SIZE = "다리의 길이를 입력해주세요.";
	private static final String REQUEST_MOVEMENT = "이동할 칸을 선택해주세요. (위: U, 아래: D)";
	private static final String REQUEST_RETRY = "게임을 다시 시도할지 여부를 입력해주세요. (재시도: R, 종료: Q)";

	private final Object command;

	private InputCommandReader(String objectName) {
		this.command = readObject(objectName);
	}

	public static InputCommandReader read(String object) {
		return new InputCommandReader(object);
	}

	private Object readObject(String objectName) {
		return Arrays.stream(ObjectCommand.values())
				.filter(targetObject -> targetObject.objectName.equals(objectName))
				.findFirst()
				.orElse(ObjectCommand.NoneObject)
				.commandReader
				.read();
	}

	public Object command() {
		return command;
	}

	private enum ObjectCommand {

		BridgeSize("BridgeSize", new CommandReader() {
			@Override
			public BridgeSizeCommand read() {
				OutputView.withContentOf(REQUEST_BRIDGE_SIZE, false, false).ConsoleMessage();
				try {
					return BridgeSizeCommand.valueOf(new InputView().input());
				} catch (IllegalArgumentException ignored) {
					return read();
				}
			}
		}),

		Movement("Movement", new CommandReader() {
			@Override
			public MovementCommand read() {
				OutputView.withContentOf(REQUEST_MOVEMENT, true, false).ConsoleMessage();
				try {
					return MovementCommand.valueOf(new InputView().input());
				} catch (IllegalArgumentException ignored) {
					return read();
				}
			}
		}),

		GameProceed("GameProceed", new CommandReader() {
			@Override
			public GameProceedCommand read() {
				OutputView.withContentOf(REQUEST_RETRY, true, false).ConsoleMessage();
				try {
					return GameProceedCommand.valueOf(new InputView().input());
				} catch (IllegalArgumentException ignored) {
					return read();
				}
			}
		}),

		NoneObject("", () -> {
			return new InputView().input();
		});

		private final String objectName;
		private final CommandReader commandReader;

		ObjectCommand(String objectName, CommandReader commandReader) {
			this.objectName = objectName;
			this.commandReader = commandReader;
		}
	}
}

 

 

InputCommandReader 클래스는 사용자로부터 다리의 길이, 이동 방향, 게임 진행 여부와 같은 입력값을 받아서 적절한 명령 객체로 변환해주는 역할을 한다. 


핵심이 되는 부분은  ObjectCommand 열거형(Enum)을 통해 입력값에 해당하는 명령 객체를 찾아 생성하는 부분과, readeObject() 부분이다. 입력값은 objectName으로 전달되는데, 이를 기반으로 ObjectCommand Enum을 검색하여 해당 명령 객체의 commandReader를 실행한다. 


이때 실행된 commandReader는 실제 입력값을 받아 명령 객체를 생성하며, 명령 객체 생성 중에 예외가 발생하면 재귀적으로 readObject를 호출하여 다시 시도한다. 

 

ObjectCommand Enum은 BridgeSize, Movement, GameProceed, NoneObject의 네 가지 객체가 정의되어 있으며, 위에서 정의한 객체들과 매핑, 즉 생성하는 역할을 한다. 

 

이렇게 입력값을 받아 해당하는 명령 객체로 변환하는 역할을 수행하는 과정에서 정의된 Enum을 사용하여 매핑-생성하도록 하였다. 

 

 

결과적으로 if-분기문을 사용하지 않고 해결을 하긴 하였지만 Enum 타입을 하드코딩에 가깝게 미리 정의를 해야하는 부분과 if-분기문 못지 않은 복잡함, 무엇보다도명령 객체들과 InputCommandReader가 강하게 결합되어 있으며, 명령 객체의 생성과 유효성 검증 로직이 InputCommandReader 내에 하드코딩되어 있는 것이 여전히 문제라고 생각했다. 또 객체를 만들어 나름의 책임과 역할을 분리/할당한 부분은 코드 가독성이나 활용성 측면에서 의의가 있을 수 있지만, 케이스가 늘어날수록 class들이 늘어나는 또 다른 문제의 씨앗도 보였다.  

 

객체지향적으로 if-분기문을 해결해본 첫 번째 시도였다.

 

 

 

2. 은행 업무를 처리하는 어플리케이션에서 사용자 입력 처리 문제 

- 유형: 연습형 과제

- 기간: 2023년 1월 

- 깃허브: https://github.com/renechoi/practice-springboot-by-green/tree/main/src/main/java/work/atm/step3

- if문이 요구된 상황: 사용자의 입력에 따른 처리

   - 1~9번 입력에 따른 처리

   - 1.회원가입 2.회원탈퇴 3.로그인 4.로그아웃 5.입금 6.출금 7.송금 8.잔액조회 0.종료

 

 

사용자의 입력에 따른 처리를 해야 하는 동일한 요구사항이다. 다만 그 수가 좀 더 늘어난 상황이었다. 

 

이번에는 이것을 Enum 클래스로 미리 정의하여 Reader와 결합하는 방식이 아니라 Reader에 Command를 주입해주는 방식으로 변경하여 구현하였다. 전략 패턴을 적용한 방식이다. 

 

<Controller 클래스>

 

public class Controller {

    public void openBank() {
        Bank bank = new Bank();
        CommandReader commandReader = new CommandReader(
                new RegisterCommand(),
                new UnregisterCommand(),
                new LoginCommand(),
                new LogoutCommand(),
                new DepositCommand(),
                new WithdrawCommand(),
                new TransferCommand(),
                new CheckBalanceCommand(),
                new QuitCommand()
        );

        runBank(bank, commandReader);
    }

    private void runBank(Bank bank, CommandReader commandReader) {
        try {
            bank.showCurrentlyLogin();
            Result result = commandReader.handleCommand(bank, InputView.getSystemCommand());
            result.show();
            if (result.isQuit()) {
                return;
            }
            runBank(bank, commandReader);
        } catch (RuntimeException e) {
            System.out.println(e.getMessage());
            runBank(bank, commandReader);
        }
    }
}

 

<CommandReader 클래스>

public class CommandReader {

    private final List<Command> commands;

    public CommandReader(Command... commands) {
        this.commands = Arrays.asList(commands);
    }

    public Result handleCommand(Bank bank, String userInput) {
        return commands.stream()
                .filter(command -> command.support(userInput))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 명령입니다."))
                .doBankJob(bank);
    }
}

 

 

은행 업무를 처리하는 컨트롤러(Controller)와 커맨드를 읽어 처리하는 CommandReader 클래스이다. 

Controller 클래스:
- openBank() 메서드: 은행 객체와 커맨드 리더(CommandReader) 객체를 생성하고, runBank() 메서드를 호출하여 은행 업무를 실행한다.
- runBank() 메서드: 은행 객체와 커맨드 리더 객체를 매개변수로 받는다. 다양한 사용자 입력을 처리하고, 처리된 결과(Result)를 출력한다. 결과가 종료를 요청하면 메서드를 종료하고, 그렇지 않으면 다시 runBank() 메서드를 재귀적으로 호출한다. 예외가 발생한 경우에도 해당 예외의 메시지를 출력하고, 다시 runBank() 메서드를 재귀적으로 호출하여 프로그램이 끊기지 않도록 한다. 

 
CommandReader 클래스:
- 생성자는 Command객체들을 주입받는데, 이때 가변 인자로 받아 복수의 인자들을 유연하게 받도록 한다.
- handleCommand() 메서드는 은행 객체와 사용자 입력을 매개변수로 받고, commands 리스트에서 사용자 입력을 지원하는 커맨드를 찾아 실행한다. 방식은 commands 리스트를 스트림으로 변환한 후, command.support() 메서드를 통해 사용자 입력을 지원하는 커맨드를 필터링한다. findFirst()를 사용하여 첫 번째로 필터링된 커맨드를 찾아 커맨드가 존재하는 경우, 은행 작업을 수행하고 결과를 반환한다.

 


전략 패턴은 동일한 문제를 정의하여 각각을 캡슐화하여 상호 교환 가능하도록 하는 디자인 패턴으로, 비슷한 알고리즘을 동적으로 선택할 수 있게 해준다. 사용자 입력이라는 유사한 행동을 동적으로 선택하여 커맨드 객체로 반환하고 이때, 커맨드 객체들은 각각이 독립된 전략으로 취급되며, Command 인터페이스를 구현하여 특정 작업을 수행한다는 점에서 전략 패턴을 활용한 구현이라고 할 수 있다. 

 

즉, 사용자의 입력에 따라 각 커맨드를 독립적인 전략으로 취급하고 이를  캡슐화하여 CommandReader에서 동적으로 선택하여 실행한다. 

 

Command 구현은 다음과 같다. 

 

모든 Command는 다음 인터페이스의 메서드를 구현하며 존재성에 맞는 고유한 메서드는 각기 다르게 구현한 Command 구현체들이다.

 

public interface Command {
    boolean support(String userInput);

    Result doBankJob(Bank bank);

}

 

 

<Register>

public class RegisterCommand implements Command {
    @Override
    public boolean support(String userInput) {
        return "1".equals(userInput);
    }

    @Override
    public Result doBankJob(Bank bank) {
        return bank.register(new Member(requestMemberName(), requestMemberId(), requestMemberPassword()));
    }

    private String requestMemberName() {
        return InputView.getMemberName("회원가입");
    }

    private String requestMemberId() {
        return InputView.getMemberId("회원가입");
    }

    private String requestMemberPassword() {
        return InputView.getMemberPassword("회원가입");
    }
}

 

 

 

<Login>

 

public class LoginCommand implements Command {
    @Override
    public boolean support(String userInput) {
        return "3".equals(userInput);
    }

    @Override
    public Result doBankJob(Bank bank) {
        return bank.login(requestMemberId(), requestMemberPassword());
    }

    private String requestMemberId() {
        return InputView.getMemberId("로그인");
    }

    private String requestMemberPassword() {
        return InputView.getMemberPassword("로그인");
    }

}

 

 

<Deposit>

public class DepositCommand implements Command {
    @Override
    public boolean support(String userInput) {
        return "5".equals(userInput);
    }

    @Override
    public Result doBankJob(Bank bank) {
        return bank.deposit(requestDepositAmount());
    }

    private int requestDepositAmount() {
        return InputView.getAmount("입금");
    }

}

 

 

이외 9가지 커맨드에 대한 각각의 커맨드 객체를 구현하였다. 

 

커맨드와 Reader가 맞닿은 부분은 support()로서 String 타입으로 받는 유저의 Input에 대해 Reader가 다음과 같은 메서드를 통해 해당하는 Command로 매핑하도록 해준다. 

 

public Result handleCommand(Bank bank, String userInput) {
     return commands.stream()
           .filter(command -> command.support(userInput))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 명령입니다."))
            .doBankJob(bank);
}

 

 

Command와 CommandReader 그리고 아래의 InputView는 의존관계를 분리하였다는 측면에서 즉, 위의 Bridge 프로그램에서 가졌던 강결합이 발견되지 않는다는 점에서 보다 나은 설계가 아닐까 싶다. 

 

import java.util.Scanner;

public class InputView {
    private static final Scanner scanner = new Scanner(System.in);

    public static String getSystemCommand() {
        System.out.print("[시스템] 1.회원가입 2.회원탈퇴 3.로그인 4.로그아웃 5.입금 6.출금 7.송금 8.잔액조회 0.종료: \n");
        return scanner.nextLine();
    }

    public static String getMemberName(String CommandType) {
        System.out.printf("[%s] 이름을 입력하세요: \n", CommandType);
        return scanner.nextLine();
    }

    public static String getMemberId(String CommandType) {
        System.out.printf("[%s] 아이디를 입력하세요: \n", CommandType);
        return scanner.nextLine();
    }

    public static String getMemberPassword(String CommandType) {
        System.out.printf("[%s] 비밀번호를 입력하세요: \n", CommandType);
        return scanner.nextLine();
    }

    public static int getAmount(String CommandType) {
        System.out.printf("[%s] 금액을 입력하세요: \n", CommandType);
        return Integer.parseInt(scanner.nextLine());
    }
}

 

 

 

이것이 가능하게 되는 핵심은 컨트롤러에서 아래와 같이 의존성을 주입해주기 때문이다. 

 

public void openBank() {
        Bank bank = new Bank();
        CommandReader commandReader = new CommandReader(
                new RegisterCommand(),
                new UnregisterCommand(),
                new LoginCommand(),
                new LogoutCommand(),
                new DepositCommand(),
                new WithdrawCommand(),
                new TransferCommand(),
                new CheckBalanceCommand(),
                new QuitCommand()
        );

        runBank(bank, commandReader);
    }

 

 

클래스 다이어그램으로 살펴보면 다음과 같다. 

 

 

 

결과적으로 도메인 계층과 Interface/Application 계층이 잘 분리될 수 있었다. 

 

 

 

 

 

 

추후 스프링을 배우게 되었을 때 DI 개념이 이와 같은 의존성 해체, 레이어 분리를 위해 고안된 탁월한 아키텍처라는 것을 깨닫게 되었다. 이를 테면, Bridge에서 발생한 문제들을 스프링 방식으로 해결하면 어떨까? 의존성 주입을 통해 InputCommandReader와 도메인 객체 간의 결합을 풀어내는 점을 고려해볼 것이다. 

 

InputValidator 인터페이스를 구현한 클래스들의 구현체를 스프링 빈으로 등록하고, InputCommandReader에 필요한 의존성을 주입하는 방식으로 작성가능하다. 예를 들어, BridgeSizeCommand, GameProceedCommand, MovementCommand는 각각 InputValidator 인터페이스를 구현하고 있으므로, 해당 클래스들을 스프링 빈으로 등록한다. 그리고 InputCommandReader의 생성자를 통해 필요한 명령 객체들을 주입받도록 수정한다.

// 스프링 빈으로 등록할 클래스들

@Component
public class BridgeSizeCommand implements InputValidator {
}

@Component
public class GameProceedCommand implements InputValidator {
}

@Component
public class MovementCommand implements InputValidator {
}

// InputCommandReader 클래스에서 스프링 DI 적용
@RequiredArgConstructors
public class InputCommandReader {

  private final Object command;
  private final BridgeSizeCommand bridgeSizeCommand;
  private final GameProceedCommand gameProceedCommand;
  private final MovementCommand movementCommand;
  private final String objectName;
  
  // 기타... 
}

// 스프링 DI를 적용하여 객체 생성과 의존성 주입을 담당하는 설정 클래스
@Configuration
public class CommandConfiguration {

  @Bean
  public BridgeSizeCommand bridgeSizeCommand() {
    return new BridgeSizeCommand();
  }

  @Bean
  public GameProceedCommand gameProceedCommand() {
    return new GameProceedCommand();
  }

  @Bean
  public MovementCommand movementCommand() {
    return new MovementCommand();
  }

  @Bean
  public InputCommandReader inputCommandReader(BridgeSizeCommand bridgeSizeCommand,
                                               GameProceedCommand gameProceedCommand,
                                               MovementCommand movementCommand) {
    return new InputCommandReader(bridgeSizeCommand, gameProceedCommand, movementCommand);
  }
}

 

이렇게 스프링 DI를 적용하면 InputCommandReader 클래스에서 객체 생성에 대한 분기문이나 하드코딩된 Enum을 사용할 필요가 없어진다. 

 

은행 어플리케이션에서 사용한 전략패턴과 유사한점은 스프링 DI는 컨테이너에 의해 자동으로 객체들이 생성되고 의존성이 주입되는 반면, 전략패턴은 개발자가 직접 전략 객체를 생성하고 선택하여 직접적으로 주입을 실행해준다는 점이다. 결과적으로 두 방식 모두 if 분기문을 효율적으로 대체하는 해결 방식이라고 생각한다. 

 

 

 

3. 페크페크 웹 쇼핑몰 어플리케이션에서 소셜 유저 인증/인가 처리 문제

- 유형: 팀 프로젝트 

- 기간: 2023년 4월 ~ 5월  

- 깃허브: https://github.com/renechoi/petkpetk/blob/main/service/src/main/java/com/petkpetk/service/domain/user/service/SocialUserAccountService.java

- if문이 요구된 상황: 소셜 인증 타입에 따른 처리 

 - Naver, Kakao, Google 서로 다른 OAuth2 Provider를 프로젝트 객체로 변환하여 가입/로그인을 처리해야 하는 문제 

 

 

역시 if-분기문의 구조적 문제를 해결하기 위한 고민과 이번에는 이를 함수형 프로그래밍 방식으로 해결해본 문제이다. 

 

OAuth2 인증 요구가 발생했을 때 Spring Security를 사용하는 프로젝트에서 이를 인증/인가 처리할 때 loadUser() 메서드를 거쳐야한다. UserRequest를 인자로 받아 이를 처리해주는 메서드를 구현해주는 부분에서 소셜 Provider가 다른 Json 형식을 갖고 있을 뿐만 아니라 Google은 Oidc 방식이며 Naver, Kakao은 OAuth2 방식이라는 차이점 때문에 하나로 통일하여 처리하기가 곤란한 상황이었다. 

 

먼저 Security 설정은 다음과 같다. 

 

.oauth2Login(Customizer.withDefaults())
.oauth2Login(oauth2 -> oauth2.clientRegistrationRepository(oAuth2Config.clientRegistrationRepository())
	.authorizedClientService(oAuth2Config.oAuth2AuthorizedClientService())
	.userInfoEndpoint(
		user -> user.oidcUserService(socialUserAccountService)
			.userService(socialUserAccountService)
		)
	)

 

핵심이 되는 부분은 userInfoEndpoint()를 처리하는 부분이다. user 인자에 대해서 각기 다른 서비스를 호출해야 하는 상황에서 기존 코드로 작성한 방식은 user 객체가 요구하듯 oidceUserService와 userService를 각기 나눠서 다른 서비스를 호출하도록 한 방식이었다. 

 

그러나 개념적으로 두 로직은 비슷한 처리를 진행하고 있기 때문에 이를 합치고 싶은 열망이 가득해서 다음과 같이 합쳤는데 문제는 이제 Social Provider를 분기로 처리해야 한다는 점이었다. 

 

public class SocialUserAccountService<T extends OAuth2UserRequest, U extends OAuth2User> implements
	OAuth2UserService<T, OAuth2UserAccountPrincipal> {
	private UserAccountService userAccountService;
	private final Map<String, SocialAccount> socialUsers;

	@Autowired
	public SocialUserAccountService(UserAccountService userAccountService) {
		this.userAccountService = userAccountService;
		socialUsers = Map.of(OAuth2ProviderInfo.KAKAO.getProviderId(), KAKAO, OAuth2ProviderInfo.NAVER.getProviderId(),
			NAVER, OAuth2ProviderInfo.GOOGLE.getProviderId(), GOOGLE);
	}

//...
}

 

 

이에 대해서 먼저 필요한 소스들을 클래스로 나눠 분리하는 작업을 수행했다. 

 

외부 정보를 설정하는 클래스는 다음과 같이 구현했다. 

 

<Provider Info 추상 클래스> 

@Component
public abstract class OAuth2Provider {
protected String clientId;
protected String clientSecret;
protected String redirectUri;
protected String tokenUri;
protected String authorizationUri;
protected String userInfoUri;
protected String userNameAttribute;

public abstract ClientRegistration getClientRegistration();

}

 

 

<각 소셜 Info 클래스들>

@Component
public class NaverOAuth2Provider extends OAuth2Provider {
	@Value("${spring.security.oauth2.client.registration.naver.client-id}")
	private String naverClientId;

	@Value("${spring.security.oauth2.client.registration.naver.client-secret}")
	private String naverClientSecret;

	@Value("${spring.security.oauth2.client.registration.naver.redirect-uri}")
	private String naverRedirectUri;

	@Value("${spring.security.oauth2.client.provider.naver.token-uri}")
	private String naverTokenUri;

	@Value("${spring.security.oauth2.client.provider.naver.authorization-uri}")
	private String naverAuthorizationUri;

	@Value("${spring.security.oauth2.client.provider.naver.user-info-uri}")
	private String naverUserInfoUri;

	@Value("${spring.security.oauth2.client.provider.naver.user-name-attribute}")
	private String naverUserNameAttribute;

	@Override
	public ClientRegistration getClientRegistration() {
		return ClientRegistration.withRegistrationId(NAVER.getProviderId())
			.clientId(naverClientId)
			.clientSecret(naverClientSecret)
			.clientAuthenticationMethod(ClientAuthenticationMethod.POST)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(naverRedirectUri)
			.scope("name", "email", "profile_image")
			.authorizationUri(naverAuthorizationUri)
			.tokenUri(naverTokenUri)
			.userInfoUri(naverUserInfoUri)
			.userNameAttributeName(naverUserNameAttribute)
			.build();
	}
}

 

 

@Component
public class KakaoOAuth2Provider extends OAuth2Provider {
	@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
	private String kakaoClientId;

	@Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
	private String kakaoClientSecret;

	@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
	private String kakaoRedirectUri;

	@Value("${spring.security.oauth2.client.provider.kakao.token-uri}")
	private String kakaoTokenUri;

	@Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}")
	private String kakaoAuthorizationUri;

	@Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}")
	private String kakaoUserInfoUri;

	@Value("${spring.security.oauth2.client.provider.kakao.user-name-attribute}")
	private String kakaoUserNameAttribute;

	@Override
	public ClientRegistration getClientRegistration() {
		return ClientRegistration.withRegistrationId(KAKAO.getProviderId())
			.clientId(kakaoClientId)
			.clientSecret(kakaoClientSecret)
			.clientAuthenticationMethod(ClientAuthenticationMethod.POST)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(kakaoRedirectUri)
			.authorizationUri(kakaoAuthorizationUri)
			.tokenUri(kakaoTokenUri)
			.userInfoUri(kakaoUserInfoUri)
			.userNameAttributeName(kakaoUserNameAttribute)
			.build();
	}
}
@Component
public class GoogleOAuth2Provider extends OAuth2Provider {
    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String googleClientId;

    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String googleClientSecret;

    @Override
    public ClientRegistration getClientRegistration() {
        return CommonOAuth2Provider.GOOGLE.getBuilder(GOOGLE.getProviderId())
            .clientId(googleClientId)
            .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
            .clientSecret(googleClientSecret)
            .build();
    }
}

 

 

이 Provider들은 다음과 같은 설정 클래스를 통해 등록된다. 

@Configuration
@RequiredArgsConstructor
public class OAuth2Config {

	private final GoogleOAuth2Provider googleOAuth2Provider;
	private final KakaoOAuth2Provider kakaoOAuth2Provider;
	private final NaverOAuth2Provider naverOAuth2Provider;

	@Bean
	public ClientRegistrationRepository clientRegistrationRepository() {
		List<ClientRegistration> clientRegistrations = Arrays.asList(
			googleOAuth2Provider.getClientRegistration(),
			kakaoOAuth2Provider.getClientRegistration(),
			naverOAuth2Provider.getClientRegistration()
		);
		return new InMemoryClientRegistrationRepository(clientRegistrations);
	}

	@Bean
	public OAuth2AuthorizedClientService oAuth2AuthorizedClientService() {
		return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository());
	}
}

 

 

이렇게 등록하는 부분까지는 스프링 컨테이너를 이용하여 그런대로 클리어하게 구현했지만, 문제는 실제 이 객체들을 사용하는 부분에서 if-분기문이 요구되는 상황이었다. 

 

다음과 같은 loadUser메서드는 요청된 소셜 유저의 타입을 찾아 그에 맞는 처리를 해주어야 한다. 

 

@Override
public OAuth2UserAccountPrincipal loadUser(T userRequest) throws OAuth2AuthenticationException {
	OAuth2UserService<T, U> oAuth2UserService = (OAuth2UserService<T, U>)new DefaultOAuth2UserService();

	SocialAccount socialAccount = socialUsers.get(
		userRequest.getClientRegistration().getRegistrationId().toLowerCase());
        
	if (userRequest instanceof OidcUserRequest) {
		oAuth2UserService = (OAuth2UserService<T, U>)new OidcUserService();
	}

	return socialAccount.signup(oAuth2UserService.loadUser(userRequest));
}

 

이를 해결하고자 함수형 인터페이스를 활용해보았다. 

 

먼저 SocialAccount 객체로서 다음과 같이 정의했다. 

 

@FunctionalInterface
public interface SocialAccount {
	OAuth2UserAccountPrincipal signup(OAuth2User oAuth2User) throws OAuth2AuthenticationException;
}

 

SocialAccount는 signup이라는 메서드를 갖는 함수형 인터페이스이다(signgUp 메서드 네이밍보다 좀더 포괄적인 네이밍이 적절하겠다). 이 메서드는 principal을 리턴하는데 해당하는 Principal은 OAuth2UserAccountPrincipal로서 security의 Principal 객체를 상속한 객체이므로 security의 userService와 호환되도록 하였다. 

 

public class OAuth2UserAccountPrincipal extends UserAccountPrincipal implements UserDetails, Serializable, OAuth2User,
	OidcUser {

	//... 
    
	private OAuth2ProviderInfo OAuth2ProviderInfo;
	private Collection<? extends GrantedAuthority> roles;
	private String nameAttributeKey;
	private Map<String, Object> attributes;
	private OidcIdToken idToken;
	private OidcUserInfo userInfo;

	// ...

	private static OAuth2ProviderInfo fetchProviderInfo(
		Map<String, Object> attributes) {
		return com.petkpetk.service.config.security.oauth2.OAuth2ProviderInfo.valueOf(
			attributes.get("providerInfo").toString().toUpperCase());
	}

	// ...

	@Override
	public OidcUserInfo getUserInfo() {
		return this.userInfo;
	}

	@Override
	public OidcIdToken getIdToken() {
		return this.idToken;
	}
}

 

OidcUser와 UserDetails를 혼합하다 보니 객체 자체가 커진 부분은 있지만 취지에 맞다고 생각했다. 한편 일반 유저로서 Principal을 상속하는 객체는 UserAccountPrincipal이었는데 해당 객체와 통합하는 것도 고려했지만 이부분은 분리해서 사용하도록 했다. 이에 대한 설계와 과정은 다음 글에서 보다 자세히 볼 수 있다. 

 

<소셜 유저… 다 좋은데 Security 인증 객체랑 다르잖아!>

 

아무튼 이렇게 만든 객체는 간단하게 다음과 같은 Enum 타입을 통해 상수로서 호출되며 

 

public enum OAuth2ProviderInfo {
	GOOGLE("구글", "google"),
	NAVER("네이버", "naver"),
	KAKAO("카카오", "kakao");

	private final String providerName;
	private final String providerId;
}

 

 

 

서비스 레이어에서 다음과 같이 사용되었다. 

 

public SocialAccount KAKAO = (oAuth2User) -> {
	OAuth2UserAccountPrincipal oAuth2UserAccountPrincipal = OAuth2UserAccountPrincipal.fromOAuth2(
		getKakaoAttributes(oAuth2User));
	userAccountService.saveSocialUser(oAuth2UserAccountPrincipal);
	return oAuth2UserAccountPrincipal;
};

public SocialAccount NAVER = (oAuth2User) -> {
	OAuth2UserAccountPrincipal oAuth2UserAccountPrincipal = OAuth2UserAccountPrincipal.fromOAuth2(
		getNaverAttributes(oAuth2User));
	userAccountService.saveSocialUser(oAuth2UserAccountPrincipal);
	return oAuth2UserAccountPrincipal;
};

public SocialAccount GOOGLE = (oidcUser) -> {
	OAuth2UserAccountPrincipal oAuth2UserAccountPrincipal = OAuth2UserAccountPrincipal.fromOidc(
		getGoogleAttributes(oidcUser));
	userAccountService.saveSocialUser(oAuth2UserAccountPrincipal);
	return oAuth2UserAccountPrincipal;
};

private Map<String, Object> getKakaoAttributes(OAuth2User oAuth2User) {
	Map<String, Object> kakaoAttributes = Stream.of("properties", "kakao_account")
		.flatMap(key -> ((Map<String, Object>)oAuth2User.getAttribute(key)).entrySet().stream())
		.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
	kakaoAttributes.put("providerInfo", "kakao");
	kakaoAttributes.put("nameAttributeKey", "id");
	return kakaoAttributes;
}

private Map<String, Object> getNaverAttributes(OAuth2User oAuth2User) {
	Map<String, Object> naverAttributes = oAuth2User.getAttribute("response");
	naverAttributes.put("providerInfo", "naver");
	naverAttributes.put("nameAttributeKey", "response");
	return naverAttributes;
}

private Map<String, Object> getGoogleAttributes(OAuth2User oAuth2User) {
	OidcUser oidcUser = (OidcUser)oAuth2User;
	Map<String, Object> googleAttributes = new HashMap<>(oidcUser.getAttributes());
	googleAttributes.put("providerInfo", "google");
	googleAttributes.put("nameAttributeKey", "sub");
	googleAttributes.put("idToken", oidcUser.getIdToken());
	googleAttributes.put("userInfo", oidcUser.getUserInfo());
	return googleAttributes;
}

}

 

이렇게 SocialAccount 인터페이스를 구현한 객체들이 각각의 소셜 인증 타입에 대한 처리를 수행한다. 이들 객체들은 함수형 인터페이스의 메서드를 구현하여 필요한 처리를 수행하고, 해당 처리 결과를 반환하며, 이로서  if-분기문을 피하는 것은 물론 소셜 인증 타입에 따른 처리 로직을 명확하게 분리할 수 있었다.

그런데 찜찜함은 남아있고 다음과 같은 고민을 계속해서 했지만 명확한 답을 내리지는 못했다. 

 

1) Attributes를 설정하고 처리하는 것을 해당 서비스에서 메서드로서 처리하기보다 객체로 분리하는 것이 더 맞지는 않을까? 

2) 굳이 함수형 인터페이스로 처리할 필요가 있을까? 그냥 메서드로 분리해도 되지 않을까? 

3) 차라리 UserAccountService와 SocialUserAccountService를 합치고, CRUD를 처리하는 해당 서비스에서 loadUser()까지 같이 처리하는 것이 낫지는 않을까? 

4) 아예 Principal 객체가 loadUser() 기능까지 처리하는 것은 어떨까? 

 

이와 같은 질문을 여전히 하고 있고 풀어가고 싶은 숙제로 남겨두었다. 

 

 

 

 

 

4. 맺음말 

 

이렇게 if-분기문이라는 문제를 시작으로 객체지향적 방법과 함수형 프로그래밍 방법을 적용해서 해결을 시도해보았다. 한편, 모든 분기문이 문제인 것은 아니며 필요에 따라서는 분기 방식으로 두어도 괜찮은 경우도 있다고 생각한다. 

 

예를 들어 게시판 검색을 수행하는 다음과 같은 메서드에서는 searchType에 따른 분기 상황을 그대로 구현하였다. 

public Page<ArticleDto> searchArticles(SearchType searchType, String searchValue, Pageable pageable) {
	if (searchType == TITLE) {
		return articleRepository.findByTitleContaining(searchValue, pageable).map(this::convertToDto);
	}

	if (searchType == CONTENT) {
		return articleRepository.findByContentContaining(searchValue, pageable).map(this::convertToDto);
	}

	if (searchType == NICKNAME) {
		return articleRepository.findByUserAccount_NicknameContaining(searchValue, pageable)
			.map(this::convertToDto);
	}

	if (searchType == HASHTAG) {
    	hashtagRepository.findByHashtagName(searchValue).updateHit();
			return articleRepository.findByHashtagNames(Set.of(searchValue), pageable).map(this::convertToDto);
	}

	if (searchType == CATEGORY && !searchValue.isEmpty()) {
		Set<CategoryType> categoryTypes = Arrays.stream(searchValue.split(","))
			.map(value -> CategoryType.valueOf(value.trim()))
			.collect(Collectors.toSet());

		return articleRepository.findByCategoryTypeIn(categoryTypes, pageable).map(this::convertToDto);
	}

	return articleRepository.findAll(pageable).map(this::convertToDto);
}

 

만약 searchType이 좀 더 늘어난다면 그때는 리팩토링이 필요할 수 있겠지만, 도메인 특성상 searchType이 확장될 가능성은 적다고 보았기 때문에 이정도의 분기문을 허용했다. 

 

물론 너무 복잡하지 않으면서도 더 깔끔하게 처리할 수 있는 방법이 있다면 좋을 것이다. 자바 17을 사용하는 프로젝트에서는 이렇게 처리할 수도 있었다. 

 

 return switch (searchType) {
         case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
         case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
         case ID -> articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
         case NICKNAME -> articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
         case HASHTAG -> articleRepository.findByHashtagNames(
                         Arrays.stream(searchKeyword.split(" ")).toList(),
                         pageable
                  )
                  .map(ArticleDto::from);
};

 

여전히 정답이 무엇인지 무엇이 옳고 그른지에 대해서는 잘 모르겠는 부분이 많다. 하지만 다양한 방식을 시도해보면 새로운 것을 배우기도 하고 몰랐던 부분을 고민하다 불현듯 무언가를 깨우치기도 한다. 꼭 해결하지 못하더라도 문제를 계속해서 만나며 생각하고 도전하는 모든 과정이 프로그래밍의 즐거움이다. 

반응형