의존성을 고려하여 우아하게 설계하기(feat. DIP)

ohzzi·2022년 9월 9일
0

현재 우아한테크코스 레벨 4의 첫 미션인 톰캣 구현하기 미션을 진행중입니다. 그동안 아무 생각 없이 사용하던 톰캣의 내부 구조를 직접 구현하고, 해당 톰캣을 사용하는 간단한 웹 애플리케이션을 구동하는 미션인데요, 미션을 진행하면서 의존성에 관한 고민을 하게 되어 간단히 정리해보았습니다.

의존성이란 변경에 의해 영향을 받는 것을 말합니다. 예를 들어 A가 B에 의존한다.B에 변경이 생기면 A에도 변경이 생긴다. 라고 해석할 수 있는 것이지요.

애플리케이션을 설계할 때 의존성을 고려하는 것은 굉장히 중요합니다. 우리는 객체지향 설계를 통해 다양한 객체들을 구현하고, 그 객체들간의 협력을 통해 애플리케이션을 완성합니다. 이 과정에서 의존성을 제대로 관리하지 않는다면 프로그램의 작은 기능 변경에도 수많은 객체들의 로직을 뜯어고치는 결과를 낳을 수 있습니다.

조영호님의 우아한 객체지향을 참고하면, 객체를 설계할 때는 의존성에 대해 다음과 같은 부분들을 고려해야 합니다.

  1. 양방향 의존성을 피하라
  2. 다중성이 적은 방향을 선택하라
  3. 의존성이 필요 없다면 제거하라
  4. 패키지 사이의 의존성 사이클을 제거하라

이번 미션에서 마주했던 문제를 예시로 의존성에 대해 한 번 알아볼까요? 애플리케이션으로 HTTP 요청이 들어오게 되면, 해당 요청의 HTTP 메서드와 URL에 따라서 그에 맞는 비즈니스 로직을 수행해줘야 합니다. 따라서 저희가 구현할 톰캣은 분기 처리를 통해 HTTP 요청을 적절하게 처리하는 로직을 가지고 있어야 합니다. 리팩토링 이전 최초 구현 단계에서는 분기 처리와 비즈니스 로직을 모두 톰캣 내부의 HttpProcessor 객체에서 처리했습니다.

private HttpResponse access(final HttpRequest httpRequest) throws IOException {
    HttpMethod httpMethod = httpRequest.getHttpMethod();
    if (httpMethod == HttpMethod.GET) {
        // ...
    }
    if (httpMethod == HttpMethod.POST) {
        // ...
    }
    return toFoundResponse(httpRequest, NOT_FOUND_PATH);
}

private HttpResponse accessGetMethod(final HttpRequest httpRequest) {
    String url = httpRequest.getUrl();
    if (url.equals("/")) {
        // ...
    }
    if (url.equals("/login") {
        // ...
    }
    if (url.equals("/register") {
        // ...
    }
    // ...
}

하지만 비즈니스 로직이 톰캣 객체에 들어가 있는 것은 적절하지 못합니다. 비즈니스 로직은 저희가 톰캣에 올려놓을 애플리케이션에 들어가 있어야 하는 것이 맞습니다. 실행된 애플리케이션이 어떤 종류이든, 어떤 비즈니스 로직을 가졌든 톰캣은 그에 대해 알 필요가 없고, 비즈니스 로직이 변경된다고 톰캣이 변경되어서는 안됩니다. 마침 미션 요구사항중에 if 분기들을 컨트롤러 클래스를 만들어 리팩토링 하라는 요구사항이 있기도 해서, 비즈니스 로직들을 org.apache 패키지가 아닌 애플리케이션 패키지인 nextstep 패키지 아래에 컨트롤러로 분리하도록 하겠습니다.

이 때 포인트가 있습니다. 컨트롤러를 만들 때 DIP를 만족시킬 필요가 있다는 것입니다.

여기서 잠깐, DIP란?

의존성 역전 원칙(Dependency Inversion Principle)

  1. 상위 모듈은 하위 모듈에 의존해서는 안 되고 둘 다 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존해서는 안 되고 세부사항(구체적인 구현)은 추상화에 의존해야 한다.

컨트롤러를 만들어서 org.apache 패키지에 있던 비즈니스 로직들을 nextstep 패키지로 옮긴다고 해도, HttpProcessor가 구체적인 컨트롤러 클래스에 의존하고 있다면 nextstep -> org.apache만 의존성이 있어야 하는데 org.apache -> nextstep 의존성도 남아있게 됩니다. 의존성 사이클이 생기게 되는 것이죠. 이 때 톰캣에서 사용할 뼈대 자체는 톰캣의 스펙이고, 그 뼈대를 토대로 한 상세 구현은 애플리케이션의 책임이라는 점에 집중, 추상화를 통해 의존성을 역전시켜줄 수 있습니다.

public interface RequestHandler {

    HttpResponse service(HttpRequest httpRequest) throws Exception;
}

RequestHandler라는 인터페이스를 만들고, 이후로 nextstep 패키지에서 구현하는 모든 컨트롤러 클래스들을 RequestHandler의 구현체로 만들어주도록 합니다.

자 그런데 아직 문제가 있죠, if 분기를 통해 URL에 맞는 적절한 컨트롤러를 선택하는 로직에서는 구체적인 구현체에 의존해야 합니다. 여기서도 구현체에 의존하지 않고 추상화에 의존하도록 하기 위해 많은 고민을 했는데요, 스프링의 InterceptorConfiguration에서 힌트를 얻어왔습니다. 우선 if 분기에 따라 적절한 컨트롤러를 반환해 줄 RequestMapper가 필요합니다.

public class RequestMapper {

    private final Map<String, RequestHandler> handlers = new HashMap<>();
    private RequestHandler defaultHandler;
    private ExceptionHandler exceptionHandler;

    public void setDefaultHandler(final RequestHandler defaultHandler) {
        this.defaultHandler = defaultHandler;
    }

    public void setExceptionHandler(final ExceptionHandler exceptionHandler) {
        this.exceptionHandler = exceptionHandler;
    }

    public void addHandler(final String path, final RequestHandler requestHandler) {
        handlers.put(path, requestHandler);
    }

    public RequestHandler findHandler(final String path) {
        return handlers.getOrDefault(path, defaultHandler);
    }

    public ExceptionHandler getExceptionHandler() {
        return exceptionHandler;
    }
}

그리고 이 RequestHandler를 초기화해주기 위한 Configuration도 만들어줍니다. 이 때, 구체적으로 어떤 컨트롤러를 매핑시킬지는 애플리케이션 단에서 구현할 수 있도록, Configuration 역시 추상화를 통해 의존성을 역전시켜줍니다.

public interface Configuration {

    void addHandlers(RequestMapper requestMapper);

    void setDefaultHandler(RequestMapper requestMapper);

    void setExceptionHandler(RequestMapper requestMapper);
}

그리고 nextstep 패키지에서 Configuration의 적절한 구현체를 만들어 주면 됩니다.

public class NextStepConfig implements Configuration {

    @Override
    public void addHandlers(final RequestMapper requestMapper) {
        requestMapper.addHandler("/", RootController.instance());
        requestMapper.addHandler("/register", RegisterController.instance());
        requestMapper.addHandler("/login", LoginController.instance());
        requestMapper.addHandler("/login.html", LoginController.instance());
    }

    @Override
    public void setDefaultHandler(final RequestMapper requestMapper) {
        requestMapper.setDefaultHandler(DefaultController.instance());
    }

    @Override
    public void setExceptionHandler(final RequestMapper requestMapper) {
        requestMapper.setExceptionHandler(GlobalExceptionHandler.instance());
    }
}

그리고 톰캣 객체가 생성되는 타이밍에 Configuration 인터페이스를 인자로 받아서 addHandlers, setDefaultHandler, setExceptionHandler를 호출하도록 하면 구체적인 구현 사항을 모르면서도 컨트롤러와 예외 핸들러를 매핑할 수 있습니다.

  • nextstep 패키지 아래의 Application
public class Application {

    private static final Logger log = LoggerFactory.getLogger(Application.class);

    public static void main(String[] args) {
        log.info("web server start.");
        final var tomcat = new Tomcat(new NextStepConfig());
        tomcat.start();
    }
}
  • org.apache 패키지 아래의 톰캣 구성 요소들
public class Tomcat {

    private static final Logger log = LoggerFactory.getLogger(Tomcat.class);

    private final RequestMapper requestMapper;

    public Tomcat(final Configuration configuration) {
        this.requestMapper = new RequestMapper();
        configuration.setDefaultHandler(requestMapper);
        configuration.addHandlers(requestMapper);
        configuration.setExceptionHandler(requestMapper);
    }

    public void start() {
        Connector connector = new Connector(requestMapper);
        connector.start();

        try {
            // make the application wait until we press any key.
            System.in.read();
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        } finally {
            log.info("web server stop.");
            connector.stop();
        }
    }
}

public class Connector implements Runnable {

    private final RequestMapper requestMapper;

	public Connector(final RequestMapper requestMapper) {
        this.requestMapper = requestMapper;
    }
    // ...
    private void process(final Socket connection) {
        if (connection == null) {
            return;
        }
        log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
        Http11Processor processor = new Http11Processor(connection, requestMapper);
        executorService.submit(processor);
    }
    // ...
}

public class Http11Processor implements Runnable, Processor {

    private static final Logger log = LoggerFactory.getLogger(Http11Processor.class);

    private final Socket connection;
    private final RequestMapper requestMapper;

    public Http11Processor(final Socket connection, final RequestMapper requestMapper) {
        this.connection = connection;
        this.requestMapper = requestMapper;
    }

    @Override
    public void run() {
        process(connection);
    }

    @Override
    public void process(final Socket connection) {
        try (InputStream inputStream = connection.getInputStream();
             OutputStream outputStream = connection.getOutputStream();
             BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
            HttpRequest httpRequest = HttpRequest.parse(bufferedReader);
            RequestHandler requestHandler = RequestMapper.findHandler(httpRequest.getUrl());
            HttpResponse response = requestHandler.service(httpRequest);

            outputStream.write(response.toResponseFormat().getBytes());
            outputStream.flush();
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
}

결과적으로 org.apache 내부의 톰캣 구성 요소들은 입출력 타입만 정의해놓은 인터페이스에 의존하게 되고, 실제 구현은 nextstep에서 구체적 구현체와 설정 파일을 만들어서 애플리케이션에서 톰캣을 생성할 때 주입해줌으로서 의존성이 한 방향으로 흐르도록 할 수 있습니다.

결과적으로 다음과 같은 그림으로 의존성이 구성됩니다.

그림을 보면 알 수 있듯이, 의존성 방향이 왼쪽에서 오른쪽으로(패키지 의존성) 흐르며, 같은 패키지 안에서 위에서 아래로 의존성이 흐르는 것을 볼 수 있습니다.

여태까지 애플리케이션 설계를 하면서 의존성을 깊이 고민하고 설계했던 적이 없었던 것 같은데, 이번 미션을 통해 객체 사이의, 그리고 패키지 사이의 의존성을 고려하고 의존성 사이클을 끊기 위한 고민과 시도를 해보는 좋은 경험을 했습니다. 막연히 우아한객체지향 같은 세미나나 오브젝트 같은 책을 볼 때는 체감되지 않았던 부분들을 실제로 구현하면서 고려해보니 재밌기도 하고, 이해도 더 잘 되는 듯 합니다.

실제 미션에서는 예외 처리 등을 고려하여 더 많은 중간 객체를 만들고, 패키지 구조를 다듬었습니다. 실제 미션 코드를 보고 싶으시다면 GitHub를 참고해주세요

정리

의존성이 사이클이 되지 않도록 설계하자. 이를 위해 DIP를 만족시키는 것을 고려할 수 있다. 구체 클래스에 의존하는 것 대신 추상화를 적극 활용하면 패키지간 의존성을 한 방향으로 흐르게 할 수 있다.

profile
배울 것이 많은 초보 개발자 입니다!

0개의 댓글