본문으로 건너뛰기

· 약 5분

오랜만에 Spring Boot 프로젝트를 멀티 모듈로 구성하려고 Gradle 문서를 읽다보니 멀티 프로젝트에서 subprojects {}, allprojects {}의 사용을 더이상 권장하지 않는다는 내용을 보게 되었다.

위와 같은 기존 방식을 cross project configuration 이라고 하는데, 다음과 같은 문제가 있다고 한다.

  • 서브프로젝트의 빌드 스크립트만 봐서는 부모 프로젝트에서 빌드 로직이 주입된다는 것이 분명하게 드러나지 않기 때문에 로직을 파악하기 힘들다.
  • 설정 시점에 프로젝트 간에 커플링이 생기기 때문에 configuration-on-demand와 같은 최적화가 제대로 작동하지 않는다.

큰 프로젝트가 아니라면 사실 별로 상관 없다고 생각하지만, 아무튼 일리가 있다고 생각되니 권장 방식인 Convention Plugins 방식을 사용해본다.

문제 1: UnknownPluginException

공식 문서를 Kotlin DSL 버전으로 따라해보았는데, 다음과 같은 에러가 나면서 잘 되지 않았다.

Build file '/Users/user/myproject/api/build.gradle.kts' line: 1

Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

* Exception is:
org.gradle.api.plugins.UnknownPluginException: Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Plugin Repositories (plugin dependency must include a version number for this source)
...

이 문제는, buildSrc/build.gradle.kts를 다음과 같이 만들어주면 해결된다.

plugins {
`kotlin-dsl`
}

repositories {
// for kotlin-dsl plugin
gradlePluginPortal()
}

해당 문서화 버그는 gradle/gradle#19667로 등록되어 있다. (이 자식들 ㅠㅠ)

문제 2: 플러그인 버전 지정 불가

Spring Initializr에서 만들어진 플러그인 설정을 그대로 사용했더니 다음과 같은 에러가 났다.

Invalid plugin request [id: 'org.springframework.boot', version: '2.6.4']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.springframework.boot' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'io.spring.dependency-management', version: '1.0.11.RELEASE']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'io.spring.dependency-management' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.jvm', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.jvm' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.plugin.spring', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.plugin.spring' is an implementation dependency of project ':buildSrc'.

일단 plugins {} 블럭에서 다음과 같이 버전을 제거했다.

plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("jvm")
kotlin("plugin.spring")
}

근데 그러면 플러그인 버전을 어떻게 지정해야 할까? 답은 buildSrc/build.gradle.kts에 다음과 같이 의존성을 추가하는 것이다.

dependencies {
implementation("org.springframework.boot:spring-boot-gradle-plugin:2.6.4")
implementation("io.spring.gradle:dependency-management-plugin:1.0.11.RELEASE")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
implementation("org.jetbrains.kotlin:kotlin-allopen:1.6.10")
}

참고로 여기에는 플러그인 ID가 아니라, Maven 좌표를 찾아서 적어주었다. Maven 좌표는 plugins.gradle.org에서 플러그인 ID로 검색해서 찾는다.

예를 들어 org.springframework.boot를 찾아서 들어가보면 "Using legacy plugin application:" 아래에 다음과 같이 되어있다.

buildscript {
...
dependencies {
// v~~~~ 이 부분!
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.6.4"
}
}

플러그인 ID만 가지고 할 수 있는 더 좋은 방법이 있을 수도 있는데 찾아보진 않았다. 끝!

· 약 9분

Nix는 Linux와 macOS를 지원하는 패키지 관리 시스템입니다.

해커 뉴스 등에서 Nix에 대해 종종 접하게 되어서 궁금증이 생겼고, 조금 사용해보면서 파악한 내용을 정리합니다. 계속 사용할지는 아직 모르겠지만 이것저것 찾아보느라 들인 시간이 아까우니까요. 누군가에겐 도움이 되겠죠?

Nix에는 여러 특징이 있지만, 그 중에서도 같은 패키지의 여러 버전을 동시에 설치할 수 있어서 패키지를 한번 설치하면 시스템에 변화가 있더라도 계속 작동이 보장된다는 점이 유용해 보입니다. 이런 특징을 활용하면 프로젝트마다 독립된 개발 환경을 구축하는 데에 쓸 수 있습니다.

의존성 지옥!

APT나 Homebrew 등 일반적인 패키지 관리 시스템에서는 시스템 전역에 특정 패키지 이름으로는 딱 한가지 버전만 설치할 수 있습니다. 이를 우회하기 위해 패키지 이름에 버전을 명시하기도 합니다. (예를 들면 python3.9python3.10을 별개의 패키지로 배포하는 등)

여러 패키지가 하나의 공통 패키지에 의존하는 경우 특히 문제가 있습니다. 예를 들어 OpenSSL 같은 라이브러리를 업그레이드하면 OpenSSL에 직간접적으로 의존하는 모든 패키지가 영향을 받습니다.

패키지마다 호환되는 의존성의 버전을 느슨하게 정의해두고 있기는 하지만, 운이 나쁘면 이전과 똑같이 작동하지 않을 수 있습니다. 그리고 업그레이드하려는 버전이 어떤 패키지에서 요구하는 버전과 충돌하는 경우 아예 업그레이드를 못할 수도 있습니다.

Nix에서는 의존성을 yc41q33h5xrw1zbyw5hp1y1ga0jk9hwd-openssl-1.1.1k과 같이 정확한 버전과 특정 빌드로 정의합니다. 따라서 패키지마다 각자 독립적으로 의존성의 버전을 선택할 수 있습니다. 패키지끼리 서로 영향을 주지 않기 때문에 안전하게 패키지를 설치하거나 업그레이드할 수 있습니다. 또한 의존성이 고정되므로 패키지가 저자의 의도대로 작동할 가능성이 높아집니다.

Nix 맛보기

nix-shell을 사용하면 특정 Nix 패키지가 설치된 환경을 여러개 만들 수 있습니다. 예제로 Python과 Node가 설치된 환경을 만들어보겠습니다.

먼저 Nix를 설치합니다.

프로젝트 디렉토리에 shell.nix 파일을 만들고 다음 내용을 추가합니다.

let
pkgs = import <nixpkgs> {};
in pkgs.mkShell {
nativeBuildInputs = [
pkgs.python3
pkgs.nodejs
];
}

그 다음 해당 디렉토리에서 nix-shell을 실행하면 필요한 패키지를 다운로드 받고 새로운 쉘이 켜집니다. 이 쉘 환경에서는 python, node/nix/store 하위에 설치된 특정 바이너리를 가리키고 있는 것을 확인할 수 있습니다.

~/myproject$ nix-shell
these paths will be fetched (...):
(생략)

[nix-shell:~/myproject]$ which python
/nix/store/d44wd6n98f93hjr6q1d1phhh1hw7a17d-python3-3.8.8/bin/python

[nix-shell:~/myproject]$ which node
/nix/store/y9ay04l5mfm255r296vhcjbxjqkjxp39-nodejs-14.16.1/bin/node

패키지를 추가하자

shell.nix가 무언가 생소한 언어로 작성되어서 혼란스러울 것입니다. 사실 이것은 Nix expression language 코드이지만 일단은 설명하지 않겠습니다.

중요한 부분은 nativeBuildInput입니다. python3, node 외에 다른 패키지는 Nixpkgs에서 검색해서 찾으면 됩니다. Channel을 unstable로 설정해서 찾아야 합니다. 그리고 macOS를 지원하지 않는 패키지가 종종 있으므로 Platforms에 x86_64-darwin가 있는지 확인합시다.

패키지를 찾았다면 pkgs.패키지명을 추가하고, nix-shell에서 나갔다가 (Ctrl-D 입력) 다시 nix-shell을 실행하면 해당 패키지가 추가된 환경으로 들어갈 수 있습니다.

Nixpkgs를 고정하자

2번째 줄 pkgs = import <nixpkgs> {};에서 <nixpkgs>는 시스템 전역에 설정된 채널을 따라가기 때문에 계속 변할 수 있는 값입니다. 정말 개발 환경이 항상 같으려면 Nixpkgs를 특정 버전으로 고정해야합니다.

이를 위해 niv를 사용하겠습니다. 프로젝트 디렉토리에서 다음 명령을 실행합니다.

nix-shell -p niv --run "niv init -b nixpkgs-unstable"

그러면 nix/sources.json, nix/sources.nix가 생성됩니다. 이제 shell.nix를 수정해서 고정된 Nixpkgs를 사용하도록 설정합니다.

let
sources = import ./nix/sources.nix;
pkgs = import sources.nixpkgs {};
in pkgs.mkShell {
...
}

direnv를 연동하자

매번 적절한 nix-shell을 켜는 것은 불편하므로 direnv를 활용하면 좋습니다.

direnv도 Nix를 활용해서 설치해보겠습니다. (이미 direnv가 설치되어 있다면 넘어가시면 됩니다.)

nix-env -iA nixpkgs.direnv

그리고 문서를 참고해서 direnv hook을 추가한 뒤 쉘을 새로 띄웁니다.

프로젝트 디렉토리 하위에 .envrc를 만들고 use nix를 적어줍니다. 최초 한번 direnv allow를 실행해주어야 합니다.

~/myproject$ echo "use nix" > .envrc
direnv: error /home/ditto/myproject/.envrc is blocked. Run `direnv allow` to approve its content

~/myproject$ direnv allow
direnv: loading ~/myproject/.envrc
direnv: using nix
direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +DETERMINISTIC_BUILD +HOST_PATH +IN_NIX_SHELL (...생략)

# 와! nix-shell 안에 있어요
~/myproject$ which node
/nix/store/y9ay04l5mfm255r296vhcjbxjqkjxp39-nodejs-14.16.1/bin/node

# 다른 디렉토리로 빠져나오면 이전 상태로 돌아옵니다
~/myproject$ cd
direnv: unloading
~$ which node
/usr/bin/node

더 알아보기

· 약 13분

마이크로서비스 아키텍처에 대한 이야기는 최소 5년 전부터 꾸준히 들려왔던 걸로 기억한다. 하지만 회사에서 하던 프로젝트가 (MSA라고 하기는 조금 뭐하지만 어쨌든) 여러 서비스의 조합으로 구성되어 있었는데, 나쁜 경험을 많이 해서 막연한 거부감이 있었다. 그래서 서비스를 어떻게 잘 나누는 것이 좋은지 가끔 생각해보긴 했어도 '웬만하면 모노리스가 낫지'라는 마음가짐으로 살아왔다. (DHH가 작성한 The Majestic Monolith라는 글의 영향도 어느 정도 있었다.)

Monolithic vs Microservices
결국 다 똥인가…

세월이 흘러 회사에서 다른 프로젝트를 하게 되었고 이전의 경험을 반면교사 삼아 이번에는 하나의 코드베이스에서 최대한 서비스를 나누지 않았다. 초기에 프로젝트를 빠르게 진행할 수 있었고 지금도 어느 정도 레거시가 쌓이긴 했지만 기능을 추가하는데 크게 무리는 없는 상태다. (다른 팀원들은 어떻게 생각하는지 모르겠다 ㅠㅠ)

하지만 사업 측 이해당사자가 많아지고 팀원도 늘어나면서 서로 다른 기능 영역의 릴리즈 스케줄이 서로 꼬이기 시작했다. 어떤 기능 영역 하나에서 발생한 성능 문제가 서비스 전체에 영향을 주는 일이 생기기도 했다. 그러다보니 서비스를 적절히 나누면 독립적인 배포가 가능할 수도 있겠다는 생각에 MSA에 대해 다시 관심이 생겼다. 또 다른 계기로는 이전보다 MSA로 전환하는 사례가 많이 보이고, 채용공고에도 'MSA 경험자' 같은 말이 등장하기 시작하는 분위기에 약간의 위기감을 느꼈던 것도 있다.

그리하여 MSA 기반의 연습 프로젝트를 해봐야겠다고 마음먹었다. 가장 먼저 프로젝트 주제를 정했는데, 문제 영역이 충분히 복잡해야 한다는 생각이 있었다. 예를 들어 투두리스트 앱이라면 머리를 최대한 짜내도 현실에서는 필요하지 않을 법한 이상한 기능을 추가하지 않는 이상 서비스를 여러 개로 나누기 힘들다고 봤다. 결론은 왓챠 (왓챠 플레이 말고!)를 베끼기로 했다. 오래 해오던 사이드 프로젝트와 비슷해서 구조에 대한 아이디어가 어느 정도 있었기 때문에 내게는 꽤 자연스러운 선택이다. 프로젝트 이름은 microservices + watcha = matcha로 정했다.

마이크로서비스에 대한 뇌피셜

MSA에 대해 나름대로 정의를 내려야 프로젝트의 목표가 좀 더 확실해질 것이다. 내가 생각하기에 대충 다음 조건을 만족하면 마이크로서비스라고 부르는 것 같다.

  • 다른 서비스와는 네트워크로 통신: OS 프로세스를 다른 서비스와 공유하지 않는다.
  • 독립적인 배포가 가능한 단위: 다른 서비스와 의존 관계일 수는 있지만 대부분의 경우에.
  • (논리적인) 데이터베이스를 다른 서비스와 공유하지 않음: 같은 데이터베이스 서버를 사용하더라도 Foreign Key를 걸거나 테이블을 Join하지 않는다.

이러한 조건 때문에 발생할 것으로 예상되는 여러가지 어려움이 있고, 어떻게 해결할 지 아이디어가 있는 것도 있고 없는 것도 있다. (없다면 공부하게 될 영역이다.) 하나씩 살펴보자.

분산 트랜잭션

가장 골치아플 것 같은 문제는 데이터의 일관성을 보장하는 것이다. 이전에는 데이터베이스 트랜잭션이 보장해주던 원자성을 잃어버리기 때문이다. 서비스 A가 담당하는 데이터와 서비스 B가 담당하는 데이터를 함께 변경해야 한다면, A의 데이터를 변경하고 나서 B의 데이터를 변경할 것이다. 하지만,

  1. 외부에서는 A의 데이터는 변경되었지만 B의 데이터는 아직 변경되지 않은 상태를 볼 수 있게 된다.
  2. A의 데이터 변경은 성공했지만 B의 장애로 B의 데이터 변경은 실패했다면 A와 B의 상태는 일관성이 깨진 채로 남아있게 된다.
  3. A의 데이터 변경은 성공했지만 B의 데이터 변경이 성립하는 제약 조건이 더이상 성립하지 않으면 A를 원래 상태로 되돌려야 한다.

1번 문제는 중간 상태가 보여도 문제가 없도록 서비스 경계를 잘 나눠서 회피할 수 있다고 본다. 하지만 회피할 수 없다면?

2번 문제는 A의 데이터 변경과 함께 원자적으로 이벤트를 발행하면 될 것 같다. 이러한 이벤트를 받아서 확실히 성공할 때까지 B에 데이터 변경을 전파해주는 녀석을 만들어야 한다. 원자적인 이벤트 발행을 위해서는 DB에 이벤트를 같이 쓰는 방법(outbox 패턴), DB의 데이터 변경 이벤트를 이용하는 방법(change data capture), 변경 자체를 이벤트로 나타내는 방법(이벤트 소싱) 등이 있을 것이다.

3번 문제는 saga 패턴을 적용하면 된다고 들은 적이 있는데 뭔지 잘 모르니 알아봐야 할 것 같다.

서비스 간 통신

서비스끼리 통신하려면 잘 정의된 API가 필요하다. RESTful API를 사용할 것인지, gRPC를 사용할 것인지, 서비스 코드 사이에 프로토콜은 어떤 식으로 공유하고 프로토콜 버전 관리는 어떻게 할 것인지 등의 고민을 해야한다.

서비스끼리 네트워크로 통신한다는 것은 더이상 다른 서비스를 믿을 수 없다는 말이다. 언제나 서비스 호출이 실패할 수 있다고 가정해야 한다. 여러 서비스가 의존하는 서비스가 느려지면 서비스 전체에 문제가 생길 수 있기 때문에 장애가 전파되는 것을 미리 차단할 필요가 있다. (circuit breaker)

또한 함수를 호출하는 것과 달리 서비스 호출은 스택 트레이스가 남는 것이 아니므로 기존 도구로는 디버깅이 어려워질 수 있다. 이를 위해 여러 서비스에 걸친 작업을 추적해야 한다. (distributed tracing)

API 통합

프론트엔드에서 '화면'을 그리기 위해서는 여러 서비스에 분산되어 있는 데이터를 각각 가져와서 적절히 합쳐야 한다. 하지만 프론트엔드를 구현하기 위해 어떤 데이터가 어느 서비스에 있는지 알아야 한다면 불편할 것이다. 그리고 내부 구조가 변할 때 외부 API의 소비자가 모두 업데이트 되어야 하는 문제도 있다.

이를 해결하기 위해 외부에서 오는 모든 API 요청을 받아주는 API 게이트웨이를 도입할 수 있을 것이다. 프론트엔드가 사용하기 쉬운 형태로 데이터를 통합해서 내려주는 역할이다. 가능하다면 프론트엔드에서 필요한 데이터만 가져올 수 있도록 GraphQL을 활용해보면 좋겠다.

또한 모든 요청이 API 게이트웨이를 통해 들어온다면 인증 처리를 API 게이트웨이에서만 하고 내부 서비스들은 검증된 아이덴티티를 그대로 사용할 수 있을 것 같다. 보안상 좋은 구조인지는 모르겠다.

배포와 테스트

고민할 것이 많다.

  • 서비스마다 다른 소스 코드 저장소를 사용할지, 아니면 모든 서비스를 한 저장소에서 관리할 것인지
  • 서비스 간에 코드를 공유할 것인지 말 것인지
  • 새로운 서비스를 쉽게 추가하려면 어떻게 해야 하는지
  • 개발할 때 여러 개의 서비스를 쉽게 띄우려면 어떻게 해야 하는지
  • 여러 서비스에 걸친 통합 테스트를 어떻게 구성할 것인지

마치며

혼자서 마이크로서비스 아키텍처를 구성하면 당연히 실제 프로젝트와 같은 경험을 얻지는 못할 것이다. 특히 서비스 분리 단위에 대한 비즈니스적 제약조건이 없기 때문이다. 그래도 앞서 나열한 것처럼 서비스를 분리하면 생길 기술적인 문제를 해결하는 경험은 해볼 수 있을거라고 봤다. MSA에 대한 많은 자료가 있지만 직접 코드를 작성해보지 않으면 알 수 없는 부분에 부딪혀보는 것이 목표다.

다음 글에서는 프로젝트 초기에 잡은 설계에 대해 소개해 볼 예정이다.

· 약 3분

Docker Desktop for Windows를 설치하려면 Hyper-V를 지원하는 OS가 필요합니다. Home은 여기에 포함되지 않으므로, VirtualBox 기반의 레거시 Docker Toolbox를 사용하라고 친절하게 나와있습니다. 하지만 저는 최신 버전을 쓰고 싶었기에 방법이 없을까 찾아보던 중 Docker 포럼의 한 글을 발견했습니다. 따라해보니까 잘 되어서 정리해 둡니다. (어쩌면 윈도우 라이센스 위반일 수도 있지만...)

1단계: Hyper-V 설치

다음 스크립트를 .bat 확장자의 파일로 저장한 다음 관리자 권한으로 실행합니다.

pushd "%~dp0"
dir /b %SystemRoot%\servicing\Packages\*Hyper-V*.mum >hyper-v.txt
for /f %%i in ('findstr /i . hyper-v.txt 2^>nul') do dism /online /norestart /add-package:"%SystemRoot%\servicing\Packages\%%i"
del hyper-v.txt
Dism /online /enable-feature /featurename:Microsoft-Hyper-V -All /LimitAccess /ALL
pause

약간의 시간이 지나면 설치가 완료되고 재부팅 하라고 나옵니다. 재부팅을 합시다.

2단계: Docker 인스톨러의 윈도우 에디션 체크 우회

Hyper-V를 켜도 Docker 인스톨러가 지원하는 윈도우 버전인지 확인하기 때문에 우회가 필요합니다.

레지스트리 편집기(regedit)을 켜고 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion에 가서 EditionIDProfessional로 변경합니다.

이제 인스톨러를 실행하면 설치가 잘 될 것입니다. 설치가 끝난 다음 해당 레지스트리 값을 원래대로 돌려놓으세요!

3단계: 설치가 잘 되었는지 확인

Docker Desktop을 실행하고, 명령 프롬프트에서 다음 명령을 입력해서 잘 되는지 확인해 봅니다.

docker run hello-world

· 약 10분

Kotlin에서 JPA 사용할 때 주의할 점을 쓴 이후로 직장에서 하는 프로젝트에도 Kotlin + JPA를 사용하게 되었습니다. 그러다보니 좀 더 고급 기능을 사용하게 되고 또 여러가지 새로운 어려움에 부딪혔습니다.

Embeddable

기간(시작 날짜, 끝 날짜)이나 좌표(X, Y) 등 항상 같이 다니는 값들을 객체로 묶어서 Entity의 속성으로 지정할 수 있습니다.

이러한 객체의 클래스에 @Embeddable 어노테이션을 붙여서 선언하고, Entity에서 @Embedded 어노테이션을 붙여서 사용합니다. 예제 코드를 보면,

@Embeddable
data class Coordinate(
var x: Int,
var y: Int
)

와 같이 선언하고

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate
)

처럼 가져다 쓸 수 있습니다.

데이터베이스 스키마에서는 Coordinate에 대한 새로운 테이블이 생기지 않고, Marker 테이블에 x, y 컬럼이 추가됩니다.

주의사항

Embeddable 클래스도 이전 글에서 설명한 Entity 클래스와 마찬가지로 다음 속성을 만족해야 합니다.

Embedded와 null

JPA 스펙에 의하면 Embedded 속성은 null이 될 수 없습니다. 하지만 Hibernate 같은 구현체들은 null을 지원합니다.

당연히 일단 Kotlin에서 nullable 타입으로 수정해야 null을 넣을 수 있습니다.

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate? // <- nullable 타입으로 수정
)

그리고 실제 데이터베이스 스키마에서도 컬럼을 nullable하게 만들어야 합니다.

그런데 Hibernate에서 자동으로 테이블을 생성하는 경우(hbm2ddl.auto 사용시), Embeddable에 속한 컬럼은 무조건 not null 컬럼이 되는 문제가 있습니다. 그런 경우 다음과 같이 수정하면 nullable 컬럼이 생성되게 할 수 있습니다.

@Embeddable
data class Coordinate(
@get:Column
var x: Int,
@get:Column
var y: Int
)

참고로, Embeddable 클래스의 어노테이션 위치 (필드 vs 프로퍼티)는 포함된 Entity 클래스의 어노테이션 위치를 따라갑니다. 앞의 예제에서 Marker는 프로퍼티(getter)에 어노테이션을 달았기 때문에 Embeddable에서도 getter에 달아야 인식이 됩니다.

Kotlin에서 JPA 관련 어노테이션은 무조건 @get:으로 달아야 한다고 기억해두면 혼란이 적은 것 같습니다.

같은 타입의 Embedded 속성을 여러 개 선언하기

Kotlin과는 무관하지만 알아두면 좋은 내용입니다.

@Entity
data class Line(
@get:Id
var id: Int,

@get:Embedded
var start: Coordinate,

@get:Embedded
var end: Coordinate
)

위와 같이 선언하면 startend가 동일한 컬럼 x, y를 가지려고 해서 다음과 같은 오류가 발생합니다.

org.hibernate.MappingException: Repeated column in mapping for entity: org.sapzil.jpa.Line column: x (should be mapped with insert="false" update="false")

정석 해결 방법은 @AttributeOverride를 사용하는 것이지만 이런 속성이 많아지면 일일히 달기는 귀찮습니다. 이 때 ImplicitNamingStrategy를 이용하면 자동으로 start_x, end_x와 같이 prefix 붙은 컬럼을 지정할 수 있습니다.

Spring에서는 spring.jpa.hibernate.naming.implicit-strategy 프로퍼티를 org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl로 지정하거나, ImplicitNamingStrategyComponentPathImpl을 Bean으로 주입하여 설정하면 됩니다.

IdClass

JPA에서 복합 기본키(composite primary key)를 매핑하기 위해서는 @IdClass 어노테이션을 사용합니다. 다음과 같이 PK가 될 속성에 모두 @Id를 붙이고, PK의 속성을 모두 가진 클래스를 만들어서 @IdClass 어노테이션으로 지정합니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
var firstName: String,
@get:Id
var lastName: String,
var phoneNumber: String
)

어떤 클래스를 IdClass로 사용하려면 Serializable 인터페이스를 구현해야 돼서 처음에 다음과 같이 선언해 봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable

그랬더니 객체를 저장하려고 할 때 이런 오류가 발생했습니다.

java.lang.IllegalArgumentException: No argument provided for a required parameter: parameter #0 firstName of fun <init>(kotlin.String, kotlin.String): org.sapzil.jpa.Name
at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod(KCallableImpl.kt:138)
at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:110)
at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:765)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:170)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:124)
...

메시지를 보니 아무래도 Spring이 생성자에 인자를 넘기지 않고 객체를 만들려고 해서 그런 것 같습니다. 그래서 인자를 받지 않는 생성자를 추가해봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable {
constructor() : this("", "")
}

그랬는데도 같은 오류가 발생했습니다. 스택 트레이스에 나타난 BeanUtils의 코드를 보면...

Constructor<T> ctor = (KotlinDetector.isKotlinType(clazz) ?
KotlinDelegate.getPrimaryConstructor(clazz) : clazz.getDeclaredConstructor());
return instantiateClass(ctor);

Kotlin 클래스일 경우 Primary Constructor를 찾게 되어있습니다. Primary Constructor는 클래스 선언 헤더에 같이 선언되는 생성자를 말합니다. 방금 추가한 인자 없는 생성자는 Secondary Constructor이기 때문에 인식되지 않은 것입니다.

그래서 data class를 포기해야 하나... 생각하고 있었는데, 구글링하던 중 Gist를 하나 발견했습니다. 결론은 생성자의 모든 파라미터에 기본값을 지정하면 된다는 것입니다.

data class Name(var firstName: String = "", var lastName: String = "") : Serializable

이렇게 하면 Primary Constructor를 인자 없이 호출할 수 있게 되어서 정상적으로 저장이 됩니다.

IdClass와 ImplicitNamingStrategy

(Kotlin과는 무관한 내용이지만) 앞서 설명한대로 ImplicitNamingStrategy를 변경하면 IdClass를 사용할 때 문제가 생길 수 있습니다.

ImplicitNamingStrategyComponentPathImpl을 사용할 때, 위의 예제대로 모델을 선언한 후 저장하려고 하면 이렇게 됩니다.

org.h2.jdbc.JdbcSQLException: NULL not allowed for column "_IDENTIFIER_MAPPER_FIRST_NAME"; SQL statement:
insert into person (phone_number, id_first_name, id_last_name) values (?, ?, ?) [23502-197]

먼저 컬럼 이름 앞에 id_가 붙은 것도 이상하고, _identifier_mapper_first_name이라는 이상한 컬럼이 생긴 것도 문제입니다.

IdClass를 지정하면 Hibernate는 내부적으로 id, _identifierMapper라는 숨은 속성을 생성합니다. 즉 id.firstName, _identifierMapper.firstName 같은 속성이 생기는 건데요. 기본 ImplicitNamingStrategy에서는 속성 경로의 마지막 부분만 취하기 때문에 문제가 없지만, 우리는 속성 경로를 모두 나타내는 전략으로 변경하였기 때문에 이런 이상한 일이 벌어진 겁니다. (결국은 HHH-11427 버그 때문입니다.)

이것을 제대로 해결하려면 ImplicitNamingStrategy의 구현을 수정해야겠지만, 간단하게는 컬럼명을 직접 지정해서 해결할 수 있습니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
@get:Column(name = "first_name") // <- 컬럼명 지정
var firstName: String,
@get:Id
@get:Column(name = "last_name") // <- 컬럼명 지정
var lastName: String,
var phoneNumber: String
)

정리

  • kotlin-jpa 컴파일러 플러그인 사용하자.
  • kotlin-allopen 컴파일러 플러그인 사용하자. (javax.persistence.Embeddable 추가해주자.)
  • Embeddable의 속성은 var로 선언하자.
  • 속성에 어노테이션 붙일 때는 getter에 붙이자. (@get:)
  • IdClass의 생성자에는 모두 기본값을 달아주자.
  • ImplicitNamingStrategyComponentPathImpl 쓰면 편리하다.
  • ImplicitNamingStrategyComponentPathImplIdClass 같이 쓰려면 컬럼명을 지정해주자.

· 약 4분

한 문장으로 요약이 잘 안돼서 제목이 이상한데, 읽어보시면 뭔지 알 수 있습니다.

Gradle 멀티프로젝트 기초

일단 간단하게 Gradle에서 멀티프로젝트 설정 방법을 알아봅시다. model, server, util 세 가지 서브프로젝트로 나눈다고 하면 디렉토리 구조는 다음과 같습니다.

  • model/
    • build.gradle
  • server/
    • build.gradle
  • util/
    • build.gradle
  • build.gradle
  • settings.gradle

여기서 settings.gradle에 모든 서브프로젝트를 선언해줍니다.

rootProject.name = "example"

include("model")
include("server")
include("util")

그리고 만약 server에서 model을 참조하고자 한다면 server/build.gradle에서

dependencies {
compile(project(":model"))
}

과 같이 할 수 있습니다.

서브 프로젝트를 한 디렉토리에 몰아넣기

보통 Git 저장소에는 코드만 있는게 아니라 다른 것들도 있습니다. 예를 들면 스크립트를 모아둔 scripts 디렉토리 같은 것인데, 이게 Gradle 프로젝트 디렉토리와 섞여있으면 기분이 나쁩니다. (개인 취향)

그래서 만약 디렉토리 구조를 다음과 같이 변경하고 싶다면,

  • scripts/
  • subprojects/
    • model/
      • build.gradle
    • server/
      • build.gradle
    • util/
      • build.gradle
  • build.gradle
  • settings.gradle

settings.gradle은 다음과 같이 바뀌고...

rootProject.name = "example"

include("subprojects:model")
include("subprojects:server")
include("subprojects:util")

다른 프로젝트를 참조할때도 project(":subprojects:model")과 같이 subprojects:를 꼭 붙여줘야 합니다.

다행히도 Gradle은 논리적인 프로젝트 경로와 실제 프로젝트 디렉토리를 다르게 설정할 수 있습니다. 따라서 settings.gradle를 다음과 같이 구성하면 됩니다.

rootProject.name = "example"

include("model")
include("server")
include("util")

for (project in rootProject.children) {
project.projectDir = file("subprojects/${project.name}")
}

이렇게 하면 서브프로젝트끼리 참조할 때도 project(":model")처럼 해주면 됩니다.

자세한 작동 원리

일단 저렇게 해서 잘 돌아가는 것은 확인했는데 어떻게 해서 되는 것인지 좀 더 알아보았습니다.

  • settings.gradle에서 제공되는 변수, 함수는 Settings 객체에 대응됩니다.
  • rootProjectProjectDescriptor 객체입니다.
  • include(String...)을 호출하면 해당 이름의 프로젝트가 rootProject에 자식으로 추가되고 추가된 프로젝트의 projectDir은 프로젝트 이름과 같게 됩니다.
  • 그러므로, 먼저 include를 한 다음 rootProject.children으로 전체 ProjectDescriptor를 받아올 수 있습니다. (서브 프로젝트가 여러 계층이라면 프로젝트 계층을 탐색해야겠군요.)
  • ProjectDescriptorprojectDir을 실제 원하는 디렉토리로 설정해주면 목적을 달성할 수 있습니다. (projectDirjava.io.File 타입이므로 file(...) 함수를 사용해야 합니다.)

· 약 6분

Maven으로 의존성을 관리하다보면 라이브러리 버전이 꼬이는 경우가 종종 있습니다. 그동안은 주먹구구식으로 해결하곤 했는데 한번쯤 확실히 알아둬야겠다고 생각해서 정리해 보았습니다.

Transitive Dependency란?

어떤 아티팩트를 의존성으로 추가하면, 그 아티팩트가 가지고 있는 의존성이 함께 딸려옵니다. 그렇게 '딸려온' 의존성을 Transitive Dependency라고 합니다.

아래 의존 관계 트리에서 MyProject A이고 A X이므로 MyProject X의 의존 관계가 생겼습니다.

  • MyProject
    • A
      • X

(참고사항) 의존 관계 디버그

Maven Dependency Plugin을 사용하면 의존 관계 트리를 찍어볼 수 있습니다.

$ mvn dependency:tree
[INFO] [dependency:tree]
[INFO] org.apache.maven.plugins:maven-dependency-plugin:maven-plugin:2.0-alpha-5-SNAPSHOT
[INFO] \- org.apache.maven.doxia:doxia-site-renderer:jar:1.0-alpha-8:compile
[INFO] \- org.codehaus.plexus:plexus-velocity:jar:1.1.3:compile
[INFO] \- velocity:velocity:jar:1.4:compile

또는 IntelliJ에 있는 기능을 사용할 수도 있습니다. (근데 Ultimate Edition에서만 되는 듯 합니다.)

의존 관계 중재 (Dependency Mediation)

의존 관계 트리에 한 아티팩트의 여러 버전이 있으면 어떤 버전이 선택될까요? 가장 가까운 정의가 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0

위의 트리에서, MyProject 기준으로 X 1.0이 X 2.0보다 가까이 있습니다. 따라서 X 1.0이 선택됩니다.

만약 거리(깊이)가 같으면 어떻게 될까요?

  • MyProject
    • A
      • X 1.0
    • C
      • X 2.0

이 때는 먼저 선언된 쪽이 이깁니다. A가 C보다 먼저 선언되었으므로 X 1.0이 선택됩니다.

원하는 버전으로 고정하기

의존성 사이에 충돌이 일어났을 때 어떤 알고리즘으로 중재되는지 살펴봤습니다. 이제 충돌을 우리가 원하는 버전으로 해결하는 방법을 알아보겠습니다.

방법 1: 직접 의존성으로 포함

MyProject에 원하는 버전의 아티팩트를 직접 포함시키면, 이것이 가장 가까운 의존성이 되므로 항상 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0
    • X 2.0

하지만 MyProject의 코드에서 X를 직접 사용하지 않는다면 불필요한 의존성을 추가한 것이므로 좋은 방법이 아닐 수 있습니다.

방법 2: 원하지 않는 의존성 제외

<exclusion> 설정을 이용하면 원하지 않는 의존성을 제외할 수 있습니다. 다음과 같이 A에서 X를 제외하면 X 2.0이 가장 가까운 의존성이 되어 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0

이 방법의 단점은 X 1.0에 의존하는 아티팩트가 여러개라면 일일히 제외시켜줘야 한다는 것입니다. 그리고 의존성 버전을 바꾸게 될 때마다 기존에 의도한 버전이 계속 선택되고 있는지 확인해야 합니다.

방법 3: Dependency Management 설정 사용

<dependencyManagement> 설정으로 특정 아티팩트의 딸려온 의존성을 포함한 모든 의존성의 버전을 고정할 수 있습니다.

POM에 다음 내용을 추가하면 모든 X가 기존에 설정된 버전을 무시하고 2.0 버전으로 고정됩니다.

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</dependencyManagement>
  • MyProject
    • A
      • X 1.0 **X 2.0**
    • B
      • C
        • X 2.0

Dependency Management 활용: BOM

규모가 큰 라이브러리는 여러 모듈로 쪼개져서 배포되는 경우가 있습니다. 예를 들어 Jackson은 jackson-core, jackson-databind, jackson-dataformat-yaml 등의 모듈로 나눠져 있습니다.

보통은 문제가 안되지만, 이렇게 나눠진 모듈끼리 버전이 안 맞으면 공포의 ClassNotFoundException을 유발하는 원인이 됩니다. 예를 들어 jackson-core는 2.8인데 jackson-databind는 2.6이라거나요.

그래서 이렇게 쪼개진 라이브러리들은 대부분 "bill of materials" (BOM)을 함께 배포합니다. BOM을 임포트하면 해당 라이브러리의 모든 모듈을 특정 버전으로 고정할 수 있습니다.

다음 내용을 POM에 추가하면 모든 Jackson의 모듈이 2.9.0 버전으로 강제됩니다.

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.9.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>

레퍼런스

· 약 9분

Kotlin에서 JPA를 사용해봅시다! Java에서 쓸 때와 별로 다를 것은 없습니다. 하지만 엔티티 클래스를 데이터 클래스로 선언하였을 때 런타임 프록시 객체를 사용하는 Hibernate/JPA의 기능들이 잘 작동하지 않을 수 있어 주의가 필요합니다.

프로젝트 세팅

예제 프로젝트는 Spring Boot를 사용하겠습니다. 하지만 다른 프레임워크에도 마찬가지로 적용되는 내용입니다.

build.gradle, Application.kt

크게 중요하지 않아서 Gist 링크로 대체합니다.

User.kt

엔티티 클래스입니다.

package org.sapzil

import javax.persistence.*

@Entity
data class User(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

UserRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface UserRepository : CrudRepository<User, Int> {
fun findByName(name: String): User?
}

Demo.kt

package org.sapzil

import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.test.context.junit4.SpringRunner
import javax.persistence.EntityManager

@RunWith(SpringRunner::class)
@DataJpaTest(showSql = true)
open class Demo {
@Autowired lateinit var userRepository: UserRepository
@Autowired lateinit var entityManager: EntityManager

@Before
fun setup() {
userRepository.save(User(name = "alice"))
userRepository.save(User(name = "bob"))
entityManager.clear()
}

@Test
fun simple() {
println(userRepository.findByName("bob"))
}
}

테스트 코드를 실행해보면 오류가 발생합니다.

org.springframework.orm.jpa.JpaSystemException: No default constructor for entity:  : org.sapzil.User; nested exception is org.hibernate.InstantiationException: No default constructor for entity:  : org.sapzil.User

기본 생성자가 없다고 합니다.

기본 생성자를 만들자

사실, 위의 엔티티 선언에는 문제가 있습니다. JPA 엔티티 클래스에는 기본 생성자(다른 말로는, 인자 없는 생성자)가 반드시 필요합니다.

당연히, 기본 생성자를 추가해주면 됩니다.

User.kt (수정 - 예시)

@Entity
data class User(...) {
constructor() : this(null, "")
}

하지만 모든 필드에 기본값을 채워줘야 하니 귀찮습니다. 어차피 JPA가 객체를 생성한 다음에 알아서 값을 채워줄텐데요.

kotlin-jpa 컴파일러 플러그인을 쓰면 @Entity 등의 어노테이션이 붙은 클래스에 자동으로 기본 생성자를 만들도록 할 수 있습니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
}
}

apply plugin: "kotlin-jpa"

실제로 만들어지는지 바이트코드를 까서 확인해봅시다. IntelliJ에서요. Gradle 임포트 후 Build > Rebuild Project 한 다음 User.kt에서 Tools > Kotlin > Show Kotlin Bytecode를 실행하면...

  // access flags 0x1
public <init>()V
L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lkotlin/Unit; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

기본 생성자가 추가된 것을 알 수 있습니다. 이제는 테스트도 통과합니다.

Hibernate: select user0_.id as id1_1_, user0_.name as name2_1_ from user user0_ where user0_.name=?
User(id=2, name=bob)

데이터 클래스로 선언했으니 toString()도 자동으로 구현된 것을 알 수 있습니다. 매우 편리하네요.

@ManyToOne과 지연 로딩 문제

이제 새로운 Post 엔티티를 추가해봅시다.

Post.kt

package org.sapzil

import javax.persistence.*

@Entity
data class Post(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
var user: User,
var content: String)

PostRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface PostRepository : CrudRepository<Post, Int>

Demo.kt (코드 추가)

@Autowired lateinit var postRepository: PostRepository

@Test
fun lazy() {
val bob = userRepository.findByName("bob")!!
val postId = postRepository.save(Post(user = bob, content = "Hello world")).id!!
entityManager.clear()
println("*** EntityManager cleared")

val post = postRepository.findOne(postId)
println("... Accessing post.user.id")
println(post.user.id)
println("... Accessing post.user.name")
println(post.user.name)
}

Post를 가져온 뒤, 연관된 User에 접근하는 테스트 코드입니다. 실행해봅시다.

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=? <- ???
... Accessing post.user.id
4
... Accessing post.user.name
bob

쿼리 로그에서 알 수 있듯이 Post를 가져올 때 User까지 동시에 가져와져 버렸습니다. 즉 지연 로딩이 작동하지 않은 것입니다.

지연 로딩을 하려면 프록시 객체를 만들어야 하는데, Kotlin의 모든 클래스는 final이라 상속을 받을 수 없습니다. 일반 클래스는 open할 수 있지만 데이터 클래스는 불가능합니다.

사실, JPA 표준에서는 엔티티 클래스가 final이면 안됩니다. 하지만 이 예제에서는 Hibernate를 JPA 구현체로 사용하기 때문에 어떻게든 작동하긴 하는 것 같네요.

아무튼 이번에도 컴파일러 플러그인의 도움을 받아서 엔티티 클래스를 확장할 수 있도록 만듭니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
}
}

apply plugin: "kotlin-allopen"

allOpen {
annotation "javax.persistence.Entity"
}

Gradle 임포트와 Rebuild를 한 다음 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
4
... Accessing post.user.name
bob

post.user에 접근할 때가 돼서야 User를 불러왔습니다. 이번에는 제대로 지연 로딩이 작동한 것을 확인할 수 있습니다.

연관 객체의 ID에 쿼리 없이 접근

아래 내용은 이제 사실이 아닙니다. Hibernate 5.2.13/5.3에서 문제가 수정되어 필드 접근 모드에서도 ID 접근시 엔티티가 로드되지 않습니다. 읽을 때 참고 바랍니다.

post.user.id는 사실 User를 쿼리해보지 않아도 post.user_id 컬럼으로 알아낼 수 있습니다. 알고보면 Hibernate에서 원래 지원하는 기능입니다. 하지만 위의 예제 코드에서는 작동하지 않았죠. 왜일까요?

데이터 클래스의 필드에 어노테이션을 달면, getter 메소드가 아니라 JVM 필드에 어노테이션이 달립니다. 그러면 프로퍼티 접근 모드가 아니라 필드 접근 모드가 되고, 이 경우 Hibernate는 지연 로딩을 지원하지 않습니다.

어쨌든 getter에 어노테이션이 붙도록 수정하면 됩니다.

User.kt (수정)

@Entity
data class User(@get:Id
@get:GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

Rebuild 후 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id <- 이때는 쿼리가 안날아감
4
... Accessing post.user.name
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
bob

post.user.name에 접근할 때 User를 가져오는 것을 확인할 수 있습니다.

세 줄 요약

  • kotlin-jpa 컴파일러 플러그인을 써서 기본 생성자를 자동으로 추가하자.
  • kotlin-allopen 컴파일러 플러그인을 써서 엔티티 클래스를 상속 가능하게 만들자.
  • JPA 어노테이션은 getter에 달자.

고민해볼 점

  • 데이터 클래스를 쓸 때도 equals()hashCode()를 오버라이드 해야하나? toString()은?
  • 모든 필드를 데이터 클래스의 생성자에 선언해야 하나? @OneToMany 붙은 콜렉션은?
  • 이쯤되면 JPA가 문제인 것 같다. 굳이 JPA를 써야하나? 😂

· 약 6분

redux-observable은 RxJS로 Redux에서 비동기 액션을 처리할 수 있게 해줍니다.

기초

액션을 Observable로 다루기

redux-observable에서는 Redux 스토어에 들어오는 액션들을 Observable로 다룰 수 있게 해줍니다. dispatch가 호출되면, 액션이 스토어에서 처리된 후에 Observable에 액션이 출력됩니다.

실제로는 Observable을 확장한 ActionsObservable을 얻을 수 있는데 여기에는 특정 종류의 액션만 걸러낼 수 있는 ofType 연산자가 추가로 제공됩니다. .ofType('ACTION_TYPE').filter(action => action.type === 'ACTION_TYPE')와 동일합니다.

Epic

redux-observable에서는 액션이 들어오는 이벤트를 받아서 추가적인 액션을 발생시킬 수 있습니다. (이미 들어온 액션을 바꾸거나 없앨 수는 없습니다.) 이렇게 액션의 Observable을 추가로 발생시킬 액션의 Observable로 바꿔주는 함수를 Epic이라고 부릅니다. 그림으로 보면 다음과 같습니다.

그림

Epic은 '서사시'라는 뜻인데 Epic이 실행되는 동안 발생하는 액션을 어떻게 처리할지에 대한 이야기이기 때문에 그런 이름이 된 것이 아닐까 생각합니다.

PING 액션을 받아서 PONG 액션을 발생시키는 가장 간단한 Epic을 생각해볼 수 있습니다. (별로 쓸모는 없지만)

function pingEpic(action$) {
return action$.ofType('PING')
.map(action => ({ type: 'PONG' }));
}

실제로도 유용할 것 같은, 액션을 받아서 비동기 API를 호출하고 성공 액션을 발생시키는 가장 기본적인 Epic은 다음과 같이 생겼습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
.catch(err => Observable.of({
type: 'FETCH_POSTS_ERROR', payload: err, error: true
}))
);
}

리듀서는 이런 식으로 만들 수 있을겁니다.

function reducer(state = {}, action) {
switch (action.type) {
case 'FETCH_POSTS':
// Epic과 무관하게 FETCH_POSTS는 리듀서로 들어옵니다!
return { isLoading: true };

case 'FETCH_POSTS_SUCCESS':
return { isLoading: false, posts: action.payload };

case 'FETCH_POSTS_ERROR':
return { isLoading: false, error: action.payload };

default:
return state;
}
}

여러 Epic 합성하기

일반적으로 처리하는 액션 타입에 따라 여러 개의 Epic을 만들어서 합성하여 사용하게 됩니다. 합성은 combineEpics 함수를 사용하고, 이렇게 합쳐져서 최종적으로 만들어진 Epic을 Root Epic이라고 합니다. (리듀서를 combineReducers로 합쳐서 루트 리듀서를 만드는 것과 비슷합니다)

import { combineEpics } from 'redux-observable';

const rootEpic = combineEpics(
pingEpic,
fetchPostsEpic,
);

적용

의존성 설치

npm으로 rxjsredux-observable을 설치합니다.

Epic Middleware 추가하기

Epic을 실제로 적용하려면 미들웨어를 통해서 Redux 스토어에 붙입니다.

import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';

const epicMiddleware = createEpicMiddleware(rootEpic);

const store = createStore(
rootReducer,
applyMiddleware(
epicMiddleware,
...
)
);

실전 팁

프로젝트 구조

공식 문서에서는 Ducks 패턴을 추천하고 있습니다. Ducks 패턴은 연관된 액션 타입, 액션 크리에이터와 리듀서를 하나의 모듈로 묶는 방식인데 여기에 Epic이 추가되는 겁니다.

Epic에서 스토어 상태 가져오기

사실 Epic의 두번째 파라미터로는 Redux 스토어가 들어옵니다. 따라서 필요할 때 getState()를 호출하여 스토어 상태에 따라 액션을 처리할 수 있습니다.

function addCommentEpic(action$, store) {
return action$.ofType('ADD_COMMENT')
.mergeMap(action => {
const { currentUser } = store.getState();
return addComment(currentUser, action.body)
.map(...);
})
}

비동기 요청 취소하기

RxJS의 takeUntil 연산자를 적용하면 특정 액션이 들어올 때 동작을 취소할 수 있습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
// FETCH_POSTS_CANCEL 액션이 들어오면 구독 취소
.takeUntil(action$.ofType('FETCH_POSTS_CANCEL'))
);
}

액션 종료 시에 알림 받기

Epic의 구조상 액션을 dispatch하는 곳에서 액션 처리가 완료된 것을 알기 어렵습니다. 모든 것을 Redux에서 관리하는 것이 최선이긴 하지만 때로는 탈출구가 필요하기도 합니다.

어쩔 수 없을 때는 redux-observable에 올라온 이슈에서 힌트를 얻어서 액션에 콜백을 같이 넘기는 방법을 사용해볼 수 있습니다. (콜백보다는 Promise나 RxJS의 Subject를 사용하면 약간 더 깔끔합니다.)

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.do(posts => {
if (action.meta.callback)
action.meta.callback(posts); // 밖에 알려주기
})
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
);
}

dispatch({
type: 'FETCH_POSTS',
meta: { callback: () => console.log('done!') }
});

· 약 6분

TL;DR: Diff Monster는 GitHub 풀 리퀘스트 리뷰를 조금 편하게 해주는 웹 기반 도구입니다.

아래 글을 읽기 싫다면 지금 바로 접속해보세요.

스크린샷

어쩌다 이런 걸 만들게 되었나요

  • 기: 연초부터 회사에서 Bitbucket Server를 그만 쓰고 GitHub로 옮기게 되었습니다. 다른건 다 좋은데 코드 리뷰 인터페이스가 너무 불편했습니다.
  • 승: 5월에 긴 연휴가 있어서 한번 코드 리뷰 툴을 만들어보자는 무모한 계획을 세웠습니다.
  • 전: 당연히 연휴 중에 완성 못했고, GitHub API에서 되는게 별로 없어서 꾸역꾸역 개발했습니다.
  • 결: 뭔가 간신히 나오긴 했습니다.

무슨 기능이 있나요

파일 트리

변경된 파일을 디렉토리 계층 구조로 묶어서 볼 수 있습니다.

파일 트리

파일명으로 퍼지 검색이 가능합니다.

퍼지 검색
RIC를 입력하면 RealInterceptorChain.java를 찾아줍니다.

GitHub와 비교: GitHub는 파일 목록이 항상 펼쳐져 있지 않아서 파일 사이를 이동할 때 불편합니다. 심지어 목록을 펼칠 때마다 맨 위부터 다시 파일을 찾아야 합니다. 그리고 그냥 목록으로 나열되어 있어서 보기가 힘듭니다.

파일마다 리뷰 완료 기록

파일마다 리뷰 완료 여부를 표시할 수 있습니다. 큰 PR을 볼 때도 리뷰를 어디까지 했는지 쉽게 알 수 있습니다. (일단 작동은 하지만 좀 더 개선의 여지는 있습니다. 단축키를 추가한다거나.)

파일마다 리뷰 완료 기록

GitHub와 비교: GitHub엔 없는 기능!

리뷰 댓글 작성

GitHub에서 제공하는 기능 중 기본적인 것은 대부분 구현했습니다. 모아서 한번에 올리기도 됩니다. (작성한 댓글은 GitHub에 저장됩니다.)

Commented via Diff Monster
댓글을 달 때 마지막에 작은 서명이 붙으니 놀라지 마세요. :)

GitHub와 비교: 아직 댓글 수정, 답글 달기, 리뷰 취소는 지원하지 않습니다.

승인하기

한번 클릭으로 풀 리퀘스트를 승인(approve)할 수 있습니다.

원 클릭

GitHub와 비교: GitHub에서는 Review changes 클릭 - Approve에 체크 - Submit review 클릭의 세 단계를 거쳐야 합니다. 물론 리뷰 요약을 작성할 수 있도록 이렇게 했겠지만 Approve할 때 사족을 다는 경우는 별로 없다고 생각합니다.

3단계
GitHub은 세 번 클릭해야 한다.

인박스

리뷰해야 할 풀 리퀘스트를 한 곳에서 볼 수 있습니다. 오른쪽 위의 상자 아이콘을 누르면 나옵니다. (로그인 필요)

인박스

GitHub와 비교: GitHub에도 풀 리퀘스트 모아보기 페이지가 있긴 합니다. 근데 리뷰 댓글을 하나라도 달면 거기서 풀 리퀘스트가 사라져 버립니다. (왜 그랬을까?) Diff Monster에서는 Reviewing 탭에 계속 나와요.

앞으로의 계획

처음에 계획했던 기능은 대부분 구현했는데 아직 GitHub에서 제공하는 모든 기능이 되지는 않아서 보충이 필요합니다. 그리고 UI에 자잘하게 개선해야 할 부분이 많이 있습니다.

저희 회사에서 몇몇 분들이 써보시긴 했지만 다른 팀의 워크플로우에는 잘 맞지 않을 수도 있어서 그런 부분도 피드백을 받아 보완해보고 싶은 생각입니다. (물론 이대로 방치될 가능성이 상당히 높습니다 ㅋㅋㅋㅋㅋ)

링크