장난감 프로젝트

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

장난감 프로젝트

Java 프로그래밍을 밥벌이로 삼고 있지만 JDK에 새로 추가된 기능을 공부하고 연습하기를 멈춘지 꽤 된 것 같다. ScalaClojure 같은 다른 언어를 공부하기도 했지만 많이 나아가지 못했다. Java에 새로 추가된 기능을 제대로 이해하지 못한 상태에서 기본적인 사용법만 익혀 하루하루를 버티다 보니 점점 바닥이 드러났다. 회사 일을 할 때는 레커시 코드에서 자잘한 기능을 추가하거나 버그를 수정하는 작업만 하다보니 새로운 것을 배울 기회가 많지 않다.

내 마음대로 기능을 추가하고 테스트해볼 수 있는 장난감 프로젝트가 필요했다. 퀴즈 풀이 같은 것도 물론 재미있지만, 유용한 기능을 제공하는 프로그램이라면 더 좋을 것이다. 예전에 Node.js로 사이트 맵 생성기를 만들어볼까 하다가 그만 둔 적이 있는데, 불현듯 이걸 Java로 만들어보면 어떨까 하는 생각이 들었다. Java 11에 정식으로 추가된 HttpClient 기능도 확인할 수 있고, 비동기 처리도 시도해 볼 수 있을것 같아 좋은 후보로 보였다.

물론 Hugo는 사이트맵 생성기를 내장하고 있어 블로그를 생성할 때 sitemap.xml도 함께 만들어주기 때문에 이런 걸 만들 필요는 없다. 그렇지만 이 프로그램은 내 블로그 외 다른 사이트에도 활용할 수 있고, 페이지를 읽어 내부 링크를 추적하는 것은 크롤링과도 비슷하니 연습삼아 만들어 보는 것도 나쁘지 않을 것이다. 설득력이 별로 없어 보이는 명분이지만, 어차피 장난감 프로젝트니까.

목표

사이트 맵 생성기를 만들면서 다음과 같은 목표를 세웠다.

  • Java 11에 정식 추가된 HttpClient 기능 확인
  • Java의 새로운 기능, 람다, 스트림, 함수형 인터페이스 등 연습
  • HTML 파싱 연습
  • CompletableFuture를 이용한 비동기 처리 연습
  • Gradle을 통한 종속성 관리
  • JUnit 5로 단위테스트 작성

HttpClient

HttpClient는 Java 9에서 인큐베이터 모듈로 처음 선을 보였다. Java 같이 널리 쓰이는 언어에 표준 라이브러리로 HTTP 클라이언트 모듈이 없었다는 게 말이 안 되는 것 같다. 물론 아파치 HTTP 컴포넌트 모듈을 쓰면 되긴 했지만. HttpClient 사용법은 Java 9에서와 크게 다르지는 않은 것 같다. API 전부를 확인한 것은 아니지만, 일부 메서드 이름이 약간 다른 것 같다.

아무튼 여기서는 다음과 같은 식으로 GET 요청을 보내서 HTML을 받는 것으로 충분하다. 다음과 같은 간단한 코드로 HTTP 요청을 보내 HTML을 문자열로 받을 수 있다.

var request = HttpRequest.newBuilder(URI.create(baseUrl + path))
  .GET()
  .build();

var html = HttpClient.newHttpClient()
  .send(request, BodyHandlers.ofString())
  .body();

HTML 파싱

HTML을 파싱하는 데는 Jsoup을 사용했다. HTML 파싱 라이브라리가 있을 것으로 기대하긴 했지만, 이렇게 간단하게 원하는 작업을 처리할 수 있는지는 몰랐다. Jsoup.parse(html)로 HTML을 파싱해 getElementsByTag로 링크를 모두 추려낸 다음 eachAttrhref 속성을 빼내면 된다. 여기서는 사이트 맵을 만들려 하니 내부 링크만 걸러내면 된다.

var links = Jsoup.parse(html).getElementsByTag("a").eachAttr("href").stream()
  .filter(isInternal)
  .map(toPath)
  .collect(Collectors.toSet());

위 코드에서 사용한 isInternaltoPath는 다음과 같이 정의했다.

Predicate<String> isInternal = (link) -> link.startsWith("/")
  && !link.startsWith("//")
  || link.startsWith(baseUrl);

Function<String, String> toPath = (link) -> link.replace(baseUrl, "");

지금까지의 코드를 모아, 주어진 링크로 HTTP 요청을 보내고 해당 페이지의 내부 링크를 수집하는 함수를 다음과 같이 작성할 수 있다.

HttpRequest newHttpRequest(String path) {
  return HttpRequest.newBuilder(URI.create(baseUrl + path))
    .GET()
    .build();
}

Set<String> getPathsIn(String path) {
  var request = newHttpRequest(path);

  try {
    var html = HttpClient.newHttpClient()
      .send(request, BodyHandlers.ofString())
      .body();
    return Jsoup.parse(html).getElementsByTag("a").eachAttr("href").stream()
      .filter(isInternal)
      .map(toPath)
      .collect(Collectors.toSet());
  } catch (IOException | InterruptedException e) {
    throw new RuntimeException(e);
  }
}

위의 getPathsIn 함수는 인자로 주어진 path로 HTTP 요청을 보내 HTML을 받아 파싱해 페이지에 포함된 내부 링크를 모아 Set<String>으로 리턴한다. 이 정도면 JavaScript로 코딩하는 것만큼 간단하다고 할 수 있겠다.

내부 링크 수집

HTTP 요청을 보낸 다음 HTML을 받아 내부 링크를 추려내고, 다시 내부 링크에 하나식 HTTP 요청을 보낸다. 이 과정을 반복하면 사이트의 내부 링크를 모두 모을 수 있다. 먼저 단일 스레드로 요청을 보내고 내부 링크를 모으는 함수는 다음과 같이 작성할 수 있다. 이미 방문한 링크를 반복해 방문하지 않도록 pathsVisited를 인자로 넘긴다.

Set<String> collectAllPaths(Set<String> pathsToVisit, Set<String> pathsVisited) {
  pathsToVisit.removeAll(pathsVisited);
  if (pathsToVisit.isEmpty()) {
    return pathsVisited;
  }

  Set<String> pathsToVisitNext = pathsToVisit.stream()
    .map(this::getPathsIn)
    .collect(HashSet::new, Set::addAll, Set::addAll);

  pathsVisited.addAll(pathsToVisit);
  return collectAllPaths(pathsToVisitNext, pathsVisited);
}

이제 다음과 같이 위 함수를 호출하면 주어진 사이트의 모든 내부 링크를 모을 수 있다.

Set<String> root = new HashSet<>();
root.add("/");
return collectAllPaths(root, new HashSet<>());

여기까지는 쉬웠다. 다만 이 구현은 속도가 느리다. HTTP 요청 보내고 기다리는 데 대부분의 시간을 허비하기 때문이다.

비동기 처리

여러 스레드에서 동시에 HTTP 요청을 보내서 링크를 수집하면 속도가 훨씬 빨라질 것이다. HttpClientsendAsync 메서드를 쓰면 쉽게 비동기 요청을 보낼 수 있다. sendAsync 메서드는 곧바로 CompletableFuture를 리턴한다. 따라서 getPaths 함수를 수정해 다음과 같이 비동기 요청을 보내는 함수를 작성할 수 있다.

CompletableFuture<Set<String>> getPathsInAsync(String path) {
  var request = newHttpRequest(path);

  return HttpClient.newHttpClient()
    .sendAsync(request, BodyHandlers.ofString())
    .thenApply(response -> Jsoup.parse(response.body())
      .getElementsByTag("a").eachAttr("href").stream()
      .filter(isInternal)
      .map(toPath)
      .collect(Collectors.toSet())
    );
}

위 함수를 이용해 비동기로 사이트 내부 링크를 수집하는 함수는 다음과 같이 작성할 수 있다.

Set<String> collectAllPaths(Set<String> pathsToVisit, Set<String> pathsVisited) {
  pathsToVisit.removeAll(pathsVisited);
  if (pathsToVisit.isEmpty()) {
    return pathsVisited;
  }

  List<CompletableFuture<Set<String>>> pathSetFuture = pathsToVisit.stream()
    .map(this::getPathsInAsync)
    .collect(Collectors.toList());

  Set<String> pathsToVisitInNextRound = pathSetFuture.stream()
    .map(CompletableFuture::join)
    .collect(HashSet::new, Set::addAll, Set::addAll);

  pathsVisited.addAll(pathsToVisit);
  return collectAllPaths(pathsToVisitInNextRound, pathsVisited);
}

이 함수 자체는 다중 스레드를 사용하지 않지만 여전히 재귀 함수이고 getPathsInAsync가 리턴하는 CompletableFuture를 다뤄야 한다. 인자로 받은 pathsToVisit에서 이미 방문한 경로를 모두 제거하고 남은 링크에 대해 getPathsInAsync를 호출한다.

내 블로그의 사이트 맵을 생성 테스트를 해보니 동기 처리시에는 12초, 비동기 처리시에는 5초로 역시 비동기 방식을 사용했을 때 속도가 빨랐다. 몇 배 더 빠를 거라 예상했는데 겨우 두 배 정도밖에 차이가 나지 않아 조금 실망했다. 아마도 내 블로그에 링크가 충분히 많지 않아서 그럴거라 추측해본다. 아니면 더 빠르게 구현하는 방법이 있을지도 모르겠다.

Gradle

Java 프로그래밍을 오래 했지만 Maven이나 Gradle을 사용할 기회는 별로 없었다. 예전 회사에서 Gradle을 사용하긴 했지만, 빌드 파일이 이미 작성되어 있었고 업데이트할 일이 거의 없었다. XML로 된 POM 파일은 쓸데없이 복잡하고 장황하게 보여 Maven은 좋아하지 않았고, 예전 회사의 Gradle 빌드 파일도 매우 복잡해서 Gradle을 좋아하지 않았다. 이 장난감 프로그램을 만들며 Gradle 빌드 파일을 처음으로 만들어 봤는데, 나름 괜찮다는 느낌이 들었다.

Java 9에 추가된 모듈 개념을 이 프로젝트에 적용해보려 하다 실패했다. Gradle에서 Java 모듈 지원에 대한 문서를 찾긴 했지만 간단하지가 않았다. 얼마 전 Gradle 5가 나와 기대를 했는데 아직 문서 업데이트가 안 된 건지 기능 업데이트가 안 된 건지 모르겠다. Java 모듈을 조금 더 공부한 다음 다시 시도해봐야 겠다.

명령행 인자 처리

예전에 Python으로 간단한 테스트 스크립트를 작성할 때 argparse로 쉽게 명령행 인자를 파싱할 수 있어 감탄했던 기억이 있다. Java에도 혹시 그런게 없을까 찾아봤는데, Apache Commons CLI가 있었다.

사용법도 복잡하지 않았다. DefaultParser 객체를 생성한 다음 parse 메서드에 옵션과 명령행 인자를 넘겨 호출하면 파싱이 끝난다.

var parser = new DefaultParser();
var cmd = parser.parse(options, args);

옵션을 만드는 방법도 상당히 직관적이다. 명령행 인자의 긴 이름 짧은 이름을 정할 수 있고, 간단한 설명도 추가할 수 있으며, 해당 인자가 필수인지 생략 가능한 옵션인지 쉽게 지정할 수 있다.

var options = new Options()
  .addOption(Option.builder("a")
    .longOpt("async")
    .hasArg(false)
    .required(false)
    .desc("execute asynchronously")
    .build())
  .addOption(Option.builder("s")
    .longOpt("site")
    .hasArg()
    .required()
    .desc("site Url to create sitemap.xml.")
    .build())
  .addOption(Option.builder("x")
    .longOpt("exclude")
    .hasArg()
    .required(false)
    .desc("paths to ignore.")
    .build());
}

위와 같이 옵션을 설정한 다음 프로그램을 실행하면 다음과 같이 명령행 인자에 대한 도움말이 콘솔에 표시된다.

usage: sitemap-gen
 -a,--async           execute asynchronously
 -s,--site <arg>      site Url to create sitemap.xml.
 -x,--exclude <arg>   paths to ignore.

명령행 인자를 파싱하고 나면 다음과 같이 명령행 인자 값을 얻을 수 있다.

var site = cmd.getOptionValue("s");
var excludePaths = cmd.getOptionValues("x");

// ...
if (cmd.hasOption("a")) {
  // ...
} else {
  // ...
}

JUnit 5

회사 코드에서는 아직 JUnit 4를 사용한다. 이 장난감 프로젝트에 테스트를 추가할 때 JUnit 5를 사용해보려 했는데, 아쉽게도 테스트를 작성하지 못했다. 핑게겠지만, 어찌 하다보니 테스트하기 어려운 코드가 되고 말았다. 메서드 안에서 HTTP 요청 객체를 만들기 때문에 모킹도 어렵고 네트워크 I/O에 비동기 요청 코드까지 있다보니 어떻게 테스트를 작성해야 할지 난감했다.

그러나 테스트를 위해 지금의 코드를 희생하고 싶지는 않았다. 아쉽지만 JUnit 5는 나중에 다른 장난감 프로젝트를 만들어 사용해봐야 겠다.

맺음말

예전에는 새로운 기능을 시험할 때 항상 간단한 테스트 프로그램을 작성하곤 했다. 내 작업공간에는 항상 Test란 프로젝트가 있었고 거기에는 온간 기능을 테스트한 코드가 있었다. 이렇게 테스트 코드를 작성하며 새로운 기능을 배우며 연습했고, 나중에 실제로 사용할 일이 생기면 내가 작성한 테스트 코드를 참고했다. 언제부터인지 더 이상 테스트 프로그램을 작성하지 않게 되었고 배움도 정체되었다.

나는 Java를 좋아했지만, 언제부터인지 Java는 회사 일에 쓰는 언어가 되었고 개인적인 관심이 줄어들었고, Scala나 Clojure, JavaScript에 더 많은 관심을 갖게 되었다. 이런 언어에서는 REPL을 사용해 간단한 코드를 테스트할 수 있어 Java에서처럼 클래스를 만들어 테스트를 할 필요는 없다. 그러나 간단한 코드 조각을 작성해 보는 것만으로는 한계가 있다. 좀더 복잡한 프로그램을 만드는 연습이 필요하다.

프로젝트를 천천히 진행하며 이것저것 테스트하고 지속적으로 코드를 개선하며 많은 것을 배우고 싶었는데, 싱겁게도 며칠 만에 끝나버리고 말았다. 지금은 딱히 코드를 계속 업데이트할 만한 부분이 보이지 않는다. 조금 지난 다음에 보면 개선할 부분이 눈에 띌 지 모르겠다. 다음에는 조금 더 복잡하고 어려운 프로젝트를 시도해봐야 겠다.

내가 처음 배우기 시작했을 때의 Java와 지금의 Java는 엄청나게 다르다. 그때 없던 많은 개념과 기능이 추가되었지만 한동안 새로 추가된 부분에 대한 공부를 소홀히 했다. 이번에 Java로 간단한 장남감 프로그램을 만들면서 의도적으로 예전 패턴과 다르게 코드를 작성해보려 했다. 많은 사람들이 Java는 너무 너저분하다며 싫어하지만, 나는 Java가 여전히 괜찮은 언어라 생각한다.

참고