본문으로 건너뛰기

나의 k3s 구성 둘러보기

· 약 10분

개인 서버 운영의 역사 (TMI 대방출)

개인 서버를 운영해온지도 거의 10여년이 넘어가고 있다. 몇몇 취미로 운영하는 서비스를 돌리거나 RSS 리더, Mastodon 인스턴스 등 개인적인 서비스를 띄우는 데에 사용하고 있다. 무료 크레딧을 받거나 해서 저렴하게 운영할 수 있는 옵션이 생길 때마다 여러 호스팅 서비스를 옮겨다니곤 했다. (생각나는 것만: 카페24, DigitalOcean, Linode, GCE, EC2) 지금은 Lightsail에 정착했다.

이렇다보니 언제든 호스팅 서비스를 옮길 수 있게 해둘 필요를 느꼈다. 단순히 설정 방법을 기록해 두는 건 실수의 여지가 많고, 초기 설정 이후에 바꾼 내용 업데이트를 잊기 쉽다. 직접 서버에 들어가서 뭔가 수정하기보다는 형상 관리가 가능한 방식을 사용하고 싶어서 처음에는 당시에 익숙했던 Ansible로 관리를 했다. 하지만 시스템 전역에 설치한 패키지에 의존하도록 하다보니 OS 버전이 바뀌거나 하면 제대로 작동하지 않는 경우가 많았다.

결국 환경의 영향을 덜 받게 하려면 Docker를 사용해야겠다 생각했다. 물론 Ansible로 Docker 컨테이너를 관리하는 게 불가능하지는 않다. 그렇지만 중단 없이 서비스를 재시작(롤링 업데이트)하는 등 조금 복잡한 작업을 하기는 어렵다. 당시 Kubernetes는 단일 서버에서 사용하기에는 설정이 쉽지 않았기 때문에 Docker Swarm을 잠시 시도해봤으나 여러 가지로 아쉬운 점이 많아서 대충 방치해놓고 시간이 흘렀다.

그러던 중 k3s라는 가볍고 단일 서버에 설치 가능한 Kubernetes 배포판이 나왔다는 소식을 접하고 시험해봤는데 꽤나 만족스러웠다. 설치도 간단해서 좋았다. (명령어 하나면 충분: curl -sfL https://get.k3s.io | sh -) 대기 상태에서도 머신 자원을 생각보다 많이 사용하는 문제는 있지만 그 정도는 편리함과 타협할 수 있는 부분이라고 보고 지금까지 정착해서 사용하고 있다.

물론 직접 서버를 운영하는 게 Vercel이나 fly.io 같은 PaaS를 사용하는 것에 비해 효율이 떨어지는 일인 건 맞다. 그래도 인프라 공부도 되고 취미 생활로는 나쁘지 않은 것 같다.

인그레스 컨트롤러와 TLS 인증서

Kubernetes에서 웹 서비스는 일반적으로 인그레스로 외부에 노출하게 되고 인그레스 컨트롤러를 설치해야 한다. k3s에는 Traefik이 인그레스 컨트롤러로 기본 설치되는데 나는 익숙한 ingress-nginx를 대신 사용하고 있다. Traefik을 설치하지 않으려면 k3s을 처음 설치할 때 --disable traefik 옵션을 줘야 한다.

ingress-nginx는 LoadBalancer 타입의 서비스를 만들고 k3s의 ServiceLB에 의해 호스트 외부에서 접속할 수 있게 된다. 물론 Lightsail의 방화벽 설정에서도 80, 443 포트를 열어줘야 한다. Lightsail 인스턴스가 바뀌어도 IP가 그대로이도록 고정 IP를 할당하고, DNS는 직접 서버 IP를 가리키게 한다.

AWS의 로드 밸런서를 사용하지 않기 때문에 TLS 인증서는 직접 발급해야 한다. cert-manager를 이용해서 Let's Encrypt 인증서를 자동 발급, 자동 갱신하고 있다. 새 서브도메인에 대한 인증서가 필요하면 Ingress에 어노테이션만 달아주면 된다.

모니터링

처음에 별 생각 없이 익숙한 Prometheus Operator를 설치했다가 리소스 낭비가 심해서 삭제하고 그냥 살고 있었는데, 최근 실행하는 서비스가 많아지니 다시 필요성을 느꼈다.

회사 인프라에서 쓰는 걸 보고 알게 된 VictoriaMetrics를 한 번 깔아봤는데 가볍고 잘 작동한다! VictoriaMetrics도 오퍼레이터를 사용하는 게 권장하는 방식 같지만, 오퍼레이터 자체의 오버헤드도 줄이고 싶어서 단일 프로세스인 VictoriaMetrics Single을 띄우는 방향으로 해보았다.

VictoriaMetrics는 딱 Prometheus와 같이 지표 수집, 저장만 하는 역할이어서 지표를 보고 싶으면 Grafana도 설치해야 한다. 이 역시 튜토리얼 문서에 나온대로 하면 된다. 외부에서 Grafana에 접속하게 하려면 인증을 붙이든지 해야 하는데 귀찮아서 일단 Tailscale 프록시를 달아놓고 Tailscale VPN 내에서만 노출해 두었다.

로그도 오래 보관하면 좋긴 하겠지만 당장은 필요가 없어서 그냥 두고 있다. 만약 필요하다면 Loki를 써볼지도...

데이터베이스와 퍼시스턴트 볼륨

내가 돌리는 대부분의 서비스가 PostgreSQL에 의존하고 있기 때문에 k3s에 하나를 설치해서 공유하고 있다. 파드가 재시작되어도 데이터가 날아가면 안되니 퍼시스턴트 볼륨을 마운트해야 하는데, 이 또한 k3s에서 기본 제공하는 Local Storage Provider를 사용한다. (예전에는 기본 탑재가 아니었는데 언젠가부터 기본적으로 설치된다. 여기서 또 TMI, CPU를 많이 잡아먹는 버그를 수정한 적이 있다 😎)

호스트 머신 파일시스템의 /opt/local-path-provisioner 디렉토리 밑에 파일이 저장되는 매우 단순한 구조이므로 파드가 다른 머신으로 옮겨다닐 수 없는 문제가 있지만 어차피 노드를 한 개만 사용할 것이므로 전혀 문제가 되지 않는다. 노드를 추가하더라도 컨트롤 플레인이 있는 노드에서만 DB를 돌리면 오케이.

DB 백업은 별도로 하지 않고, Lightsail의 백업 기능을 켜두었다. 매일 디스크 스냅샷을 떠주고 일주일 간 유지한다.

인프라 형상 관리

위에서 설명한 시스템 구성 요소는 Helm 차트로 설치하고 있다. 개인적으로 Helm을 썩 좋아하진 않지만 (YAML을 템플리팅 할 미친 생각을 대체 누가 했을까?) Kubernetes 환경의 사실상 표준이기 때문에 어쩔 수 없이 타협을 해야 한다. 내가 직접 패키징해야 하는 경우는 거의 Kustomize를 사용하고 있다.

아무튼 여러 개의 Helm 차트를 설치해야 하고, 매번 helm 명령어를 입력할 수는 없기 때문에 Helmfile로 Helm 차트의 목록과 파라미터를 관리한다. 가능하면 직접 helm이나 kubectl로 조작하는 건 지양하고 Helmfile만으로 관리하려고 노력한다. 모든 설정은 나의 Git 저장소에 잘 올려두고 있다.

아직 깔끔하게 정리하지 못한 부분이 DB 암호 등 Helmfile에 주입할 비밀 정보를 관리하는 것이다. 일단은 Git에는 커밋하지 않는 내 로컬 머신에만 있는 파일에 넣어두고 있다. 만약 내 로컬 머신이 불의의 사고로 날아가더라도 k8s Secret에는 남아있어서 복구 가능하므로 큰 문제는 없을 것으로 생각한다.

Gradle Convention Plugins 삽질기

· 약 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만 가지고 할 수 있는 더 좋은 방법이 있을 수도 있는데 찾아보진 않았다. 끝!

‘순수 함수형’ 패키지 관리자 Nix 맛보기

· 약 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

더 알아보기

나도 MSA 한번 해보자 (1)

· 약 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에 대한 많은 자료가 있지만 직접 코드를 작성해보지 않으면 알 수 없는 부분에 부딪혀보는 것이 목표다.

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

Windows 10 Home에 Docker Desktop 설치하기

· 약 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

Kotlin에서 JPA 사용할 때 주의할 점 (2) - Embeddable, IdClass

· 약 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 같이 쓰려면 컬럼명을 지정해주자.

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

· 약 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(...) 함수를 사용해야 합니다.)

Maven의 Transitive Dependency 길들이기

· 약 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>

레퍼런스

Kotlin에서 JPA 사용할 때 주의할 점

· 약 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를 써야하나? 😂

redux-observable 사용하기

· 약 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!') }
});