본문으로 건너뛰기

코드 리뷰 가능한 Jenkins? - 패키징 가능한 코드 기반의 Jenkins 환경 구축

· 약 23분
VSFe
블로그 주인장

이번 주 화요일에, 사내 기술 공유 세션인 NAVER ENGINEERING DAY에서 기술 발표를 진행했다. 물론 정확히는 라이브가 아니고 영상이었지만 ㅎㅎ;;

다만 발표 준비 시점에 너무 업무 및 기타 개인 일정으로 많이 바빴고, 다소 허겁지겁 자료를 만들고 영상을 찍다보니 내용 누락도 있었고 여러모로 아쉬움이 컸던 것 같다.

원래는 외부 영상 업로드까지 신청해두긴 했지만, 업무적인 내용이 포함되어 있어 만약 실제로 올라가게 된다면 발표 자료 재제작 및 영상 재촬영이 필수인 상황이라, 전반적으로 상황을 알 수 없게 되었고 (ㅜㅜㅜ), 아쉬운대로 우선 블로그에 좀 더 꼼꼼하게 내용을 공유해보려고 한다. (혹시 이후에 해당 주제의 영상이 업로드 되었다면, 아마 이 블로그에 적힌 내용과 유사할 것이다.)

왜 Jenkins를?

현재 업무를 하고 있는 부서는 목적과 실제 지역을 기반으로, 리전을 구분하고 있다. (동일한 대한민국 지역이라고 해도, 내부 서비스와 외부 서비스에 따라 리전은 분리되는 느낌이라고 보면 좋다.) 거기에 개발, 운영 환경이 있으니 더더욱 관리해야 하는 리전은 많을 수 밖에 없었다.

각 리전은 db나 배치등이 분리될 수 밖에 없고, ACL 및 네트워크 정책등을 고려하면 자연스럽게 하나의 리전에 하나의 Jenkins가 대응되어야 할 것이다.

문제는... 상술한 것 처럼 리전이 많은 환경이다보니, 스케쥴링 트리거 목적의 Jenkins가 10개 이상 필요했다는 것. 이에 아래와 같은 문제들이 있었다.

  • 유지보수 문제. Job을 추가하거나 수정해야 하는 경우, 약 10개 정도의 Jenkins에 일일히 추가하고 수정해야 했다. 그래도 추가라면 존재하지 않은 경우 금방 눈치챌 수 있으나, 수정을 제대로 했는지 확인하기 위해선 직접 들어가서 세부 설정을 보지 않은 이상 수정 여부를 확인하기 어려웠다. 그러다보니 잦은 휴먼 에러가 발생했고, 심지어 1년 넘게 수정이 안 되었던 케이스도 존재했었다.
  • 신규 리전 구축 문제. 현재 부서는 수십개의 스케쥴링 잡을 관리하고 있는 만큼, 새로운 리전에 Jenkins를 구축하기 위해선 설정 및 잡 추가를 모두 수행해야 한다. 대안으로 Job Import 플러그인이 있긴 하지만, Tomcat의 설정을 일부 변경해야 하며, (해당 플러그인은 Jenkins API를 호출하는 구조로 되어 있는데, 이 과정에서 기본 설정으로는 막혀있는 문자가 포함된 쿼리를 요청한다.) 막상 가져온다고 해도 각 리전 및 dev/real 에 대응하는 prefix 등에 대한 수정이 필요하다.

특히나, 상위 조직 단위에서 올해 컨테이너 환경 전환을 추진하게 되면서 자연스럽게 Jenkins도 컨테이너로 전환이 필요했고, 이는 이전까지 구축한 VM Jenkins 들을 전부 밑바닥부터 전부 재구축해야 하는 상황이 된 것이다. 이 과정에서, 누군가 재미있는 제안을 했다.

코드 리뷰 가능한 Jenkins는 어떨까요?

만약 모든 잡을 코드로 관리한다면, 매번 GUI로 수동으로 추가할 필요 없이 코드리뷰를 통해 잡을 적용할 수 있지 않을까?

즉, 우리는

  • 코드 리뷰가 가능한 Jenkins Job 구조
  • 모든 것들을 패키징해 컨테이너 배포 시 바로 사용 가능한 Jenkins 구조 확립

을 만들기로 했다.

#1 - Jenkins CasC (Configuration as a Code)

가장 먼저, Jenkins CasC에 대해 알아보도록 하자. 뒤에서 설명할 온갖 도구들과 비교하여, 해당 도구는 제법 잘 알려진 도구인 만큼, 간략하게 설명하기로 하겠다.

Jenkins CasC 는 Jenkins의 모든 설정 을 코드로 관리하기 위한 도구이다. 잡이 아니라는 점에 유의하라.

jenkins:
systemMessage: "Jenkins configured automatically by Jenkins Configuration as Code plugin\n\n"
globalNodeProperties:
- envVars:
env:
- key: VARIABLE1
value: foo
- key: VARIABLE2
value: bar
securityRealm:
ldap:
configurations:
- groupMembershipStrategy:
fromUserRecord:
attributeName: "memberOf"
inhibitInferRootDN: false
rootDN: "dc=acme,dc=org"
server: "ldaps://ldap.acme.org:1636"

위와 같은 일반적인 yaml 형식의 구조를 띄고 있으며, 각각의 트리 구조는 실제 설정에서의 폴더 구조와 동일하다.

다만 일반적으로 직접 CasC 파일을 하나하나 다 작성하진 않고, 보통 기존 사용중인 Jenkins 인스턴스에서 CasC 파일을 추출해서 사용하는 방식을 많이 쓴다. (즉, 설정을 복사하여 타 Jenkins 에서 빠르게 적용할 수 있도록 하는 것이 목적이다.)

아래와 같은 사항만 유의하면, Jenkins CasC를 사용하는 데에는 큰 어려움이 없을 것이다.

  • CasC 플러그인이 설치된 경우, Jenkins는 CASC_JENKINS_CONFIG 라는 환경변수의 값을 읽는다.
    • 만약 컨테이너화 등 패키징을 수행하는 것이 목적이라면, 배포 스크립트 내 CASC_JENKINS_CONFIG를 반드시 설정해야 한다.
  • CasC 파일의 값도 환경변수를 사용할 수 있다. (Jenkins 부팅 과정에서 해당 파일을 읽는 구조이므로, 환경변수를 필드의 value로 넣을 수 있다.)
  • 만약 로드된 CasC 파일에서 해당 Jenkins 인스턴스에 없는 설정 값이 있다면, 에러를 띄우고 부팅되지 않는다.
    • 즉, 해당 파일을 추출한 Jenkins 인스턴스에 설치된 플러그인과 관련된 설정이 있다면, 해당 설정 값을 지우고 Import 하거나 아예 처음부터 플러그인 설치를 같이 수행해야 한다.
    • 만약 컨테이너화 등 패키징을 수행하는 것이 목적이라면, Jenkins에서 제공하는 Plugin Installation Manager Tool 을 쓰는 것도 좋다.
    • 부서내 자체 플러그인이나 기타 사유로 Plugin Installation Manager Tool 에서 설치가 불가하다면, 아예 목록에 파일을 포함하는 것도 방법이다. (우리 부서의 경우엔 사내 메신저용 알림 도구 때문에 이런 케이스가 존재했다.)

#2 - Jenkins Job DSL (실패)

일단 설정은 해결했고, 그래서 잡을 어떻게 코드로 관리할 것인가?

플러그인을 뒤져보다보니 Job을 DSL로 관리하는 플러그인이 있었다. Jenkins Job DSL 이라는 플러그인이었다.

이름만 들어봐도 알겠지만 DSL 방식으로 잡을 추가할 수 있는 플러그인이다. 예를 들어, 아래와 같은 DSL을 작성할 수 있다.

job("sendNoti") {
description("알림 발송")
keepDependencies(false)
disabled(false)
concurrentBuild(false)
steps {
shell("/home1/irteam/scripts/launchJob.sh sendNoti")
}
displayName("(DEV) KR3_SERVICE_sendNoti")
configure {
it / 'properties' / 'jenkins.model.BuildDiscarderProperty' {
strategy {
'daysToKeep'('10')
'numToKeep'('-1')
'artifactDaysToKeep'('-1')
'artifactNumToKeep'('-1')
}
}
}
}
  • 기본적인 설정을 제외한 추가 설정은 configure 블록에 포함된다.
    • 플러그인과 관련한 설정이 필요하다면, it / 'properties' / 'plugin 패키지 명' 식으로 접근 가능하다.
  • job의 id와 displayName 이 별도로 존재한다.
  • Jenkins CasC와 동일하게, 해당 DSL 구문도 기존에 등록된 Job을 DSL로 변환하는 기능이 있으니, 처음이라면 해당 방법을 사용해서 export 해도 좋다.

특징이라면, 하나의 DSL 파일에서 여러 잡을 동시에 생성하는 것도 가능하다. (코드 하나에 job(~~) 구문이 여러개라면, 여러 잡을 등록할 수 있다.)

우리의 목표가 너무 쉽게 달성되는 것 아니야? 싶어서 다소 김이 샐 뻔 했지만... 사실 김 샐 게 아니긴 하지만. 아래와 같은 문제로 우리의 상황에선 직접 사용할 수 없었다.

  • 기본적으로, Job DSL 의 목적은 동적으로 Job을 생성하기 위한 것이다.
    • 일반적으로 Seed Job 목적의 Jenkins Project를 생성하고, 해당 프로젝트에서 DSL을 실행시키는 방식으로 잡을 생성해야 한다.
  • Jenkins의 정책 상, Groovy Script 가 Sandbox 바깥에서 실행된다면 반드시 관리자 권한을 갖는 사용자의 승인이 필요하다. (심지어 사용자가 관리자라고 하더라도, 형식적인 승인을 해야 한다.)
    • 이는 Jenkins 의 보안 정책에 의한 것으로, 보안 설정을 조작한다면 무시할 순 있지만, 기본값은 승인 필수이다.

그러나 우리의 목표는 Jenkins가 뜨자마자 바로 Job이 등록되는 구조를 원했기 때문에, 아쉽게도 이 도구를 직접적으로 사용할 순 없었다.

#2.5 - 잠깐, Jenkins의 LifeCycle을 들여다보자.

Jenkins 는 부팅 과정에서, 여러 상태를 갖는다. Jenkins를 이를 InitMilestone 이라고 하며, 아래와 같은 상태가 존재한다.

상태설명
STARTED말 그대로 막 시작한 시점
PLUGINS_LISTED, PLUGINS_PREPARED, PLUGINS_STARTED플러그인 메타데이터와 의존성 검사가 완료된 시점/메타데이터 및 클래스로더가 준비된 시점/시작된 시점
EXTENSIONS_ARGUMENTED코드로 정의된 Extension Point 구현체가 Jenkins에 추가된 시점 (참고)
SYSTEM_CONFIG_LOADED, SYSTEM_CONFIG_ADAPTEDCasC 등으로 인해 적용되는 설정 값들이 로드되고, 필요에 따라 업데이트까지 수행한 시점
JOB_LOADED, JOB_CONFIG_ADAPTED디스크에서 Job 정보가 로드되고, 플러그인 업데이트 등으로 인해 설정이 변경된 것이 반영된 시점
COMPLETED부팅 과정에서의 가장 마지막 시점

이 순서를 인지하면, 앞에서 왜 CasC 설정에 플러그인이 없을 때 에러를 잡을 수 있는지 인지할 수 있다.

Jenkins 관련 플러그인을 개발하게 되면 저 InitMilestone 에 대응하여 특정 시점에 어떤 행동/명령을 수행할 수 있도록 만들 수 있다.

뒤에서 다른 이야기를 할 때 이 LifeCycle이 활용될 것이므로, 우선 가볍게만 이해하도록 하자.

#3 - Jenkins Job DSL + Initialization Script

어쨌거나 Jenkins Job DSL은 컨셉 자체는 상당히 좋아보였기에, 조금 다른 생각을 해봤다.

우리가 어떻게든 강제로 실행시키게 할 순 없을까?

Jenkins Initialization Script

Jenkins 로드가 완료된 이후 실행되는 추가 정의 스크립트로, 사용자가 원하는 작업을 부팅 시점에 실행시키도록 하는 스크립트이다.

  • $JENKINS_HOME/init.groovy 파일이나 $JENKINS_HOME/init.groovy.d/ 폴더 하위 groovy 스크립트가 실행된다.
  • 앞에서 언급한 Job DSL 과 달리 외부 사용자가 생성한 파일이 아니므로, Jenkins 보안 정책의 영향을 받지 않는다.

즉, 어떻게든 Job DSL을 실행할 방법을 찾는다면, 이걸 init.groovy.d 에 집어넣어 부팅 시점에 강제로 실행하도록 유도할 수 있을 것이다.

Job DSL에 대한 고민

Jenkins Project 에서 입력한 Job DSL은 결국 groovy 언어 형식을 따르고 있다. 그렇다면 어딘가에서 해당 groovy 코드를 읽고, 실행하는 클래스가 있지 않을까? 그렇다면 우리는 이 클래스를 강제로 로드하여 Job을 등록할 수 있을 것이다.

final String workspacePath = "/home1/irteam/scripts/jobs"
final String dslScriptPath = "${workspacePath}/sendNoti.groovy" // 1
final File dslScriptFile = new File(dslScriptPath)

try {
final String jobDslScript = dslScriptFile.text // 2

def jobManagement = new JenkinsJobManagement(System.out, [:], new File(workspacePath)) // 3
new DslScriptLoader(jobManagement).runScript(jobDslScript) // 4
} catch (Throwable e) {
println "[BOOT][ERROR] JobDSL: failed to run script: ${e.class.name}: ${e.message}"
e.printStackTrace()
jenkins.setSystemMessage("...")
}
  1. sendNoti.groovy 파일의 경로를 의미한다. 상술한 Job DSL 예시와 완전히 동일한 파일이다.
  2. Job DSL 플러그인이 스크립트를 구동할 수 있도록, 텍스트 형식으로 파일을 전부 읽어들인다.
  3. JenkinsJobManagement 를 생성한다. 해당 클래스 또한 Job DSL 플러그인의 클래스이다.
  4. 해당 클래스의 runScript 메서드를 통해, 강제로 Job DSL을 파싱하여 잡을 등록하도록 유도한다.

해당 스크립트를 구성하게 되면, Jenkins 가 부팅되는 시점에 sendNoti.groovy가 실행될 것이고, 따라서 코드로 Jenkins Job을 모두 관리할 수 있게 된다!

#4 - 더욱 추상화하기

하지만 여전히 아쉬운 점이 많았다.

  • 수십개의 스케쥴링 잡을 관리해야 하는 시점에, 각각의 잡을 하나의 groovy 파일로 관리하게 되면, 파일을 관리하는 입장에서도 다소 번거로울 수 있다.
  • 대부분의 잡은 기본 설정은 동일하고, (ex. 실패 시 동작하는 Hook, 실행 방식 등) 일부 값 및 파라미터만 차이가 있다.
  • 잡 이름에 붙는 Prefix 구조를 생성하기 위한 메서드 또한 (위에서 sendNoti 에 대한 displayName을 (DEV) KR3_SERVICE_sendNoti 로 했다는 것을 기억해보자.) 모든 잡에 공통적으로 적용될 것이다.

그렇다면, 아예 템플릿화 시키면 어떨까?

위에서 Job DSL을 설명할 때 언급했던 내용을 잠깐 떠올려보자.

특징이라면, 하나의 DSL 파일에서 여러 잡을 동시에 생성하는 것도 가능하다. (코드 하나에 job(~~) 구문이 여러개라면, 여러 잡을 등록할 수 있다.)

아예 극단적으로 모든 잡이 공유하는 단 하나의 DSL 템플릿 파일을 만들고, 새로운 잡을 추가하는 경우엔 별도의 config 파일만 건드리도록 해볼 수 있을 것이다.

jobs:
- name: sendNoti
type: SERVICE
description: '알림 발송'
schedule: '*/2 * * * *'

- name: correctData
type: SERVICE
description: '데이터 정합성 보정'
schedule: 'H 2 * * *'

- name: fileStat
type: STAT
description: '파일 업로드, 다운로드 통계'
schedule: 'H 3 * * *'
parameter: 'isWrite=true'

아예 이렇게 만든다면 어떨까? 필요한 파라미터만 yaml 에 기록한다면? 이 경우, 아래와 같이 DSL 을 실행하는 시점에 yaml 을 같이 들고오면 된다.

def yamlData = new Yaml().load(new File(YAML_PATH).text)

yamlData.jobs.each { jobConfig ->
// .. 데이터 꺼내는 작업
final String jobNameForDisplay = buildDisplayName(JOB_PREFIX, jobConfig, isUnused)

job(jobConfig.jobName) {
displayName(jobNameForDisplay)
description(jobConfig.description)
// 중략
}
}

즉, 우리는

  • init.groovy.d 디렉토리에 포함되는 초기화 스크립트 (DslScriptLoader 로 Job DSL 을 수행하기 위한 목적)
  • Job DSL 템플릿
  • 설정 정보를 담은 yaml 파일 하나

총 3개의 파일로, 수십개의 설정을 모두 관리할 수 있었다.

#5 - 추가 고민해야 할 부분

어떻게 yaml 에 없는 잡을 삭제할 수 있죠?

결국 Job DSL의 원래 목적을 생각해보면, 삭제는 애초에 염두에 두지도 않았다. 그야 DSL은 보통 생성에 초점에 맞춰져 있기 때문일 것이다.

다만 어차피 yaml 에 각각의 잡의 id가 명시되어 있고, Jenkins 에서도 잡의 목록을 땡겨올 수 있으니, 아래와 같은 코드를 추가하면 쉽게 처리할 수 있다.

def allJobs = jenkins.getAllItems(Job.class)

allJobs.each { job ->
if (!expectedJobNames.contains(job.name)) {
job.delete()
// println "[INFO] Deleting unused job: ${job.name}"
}
}

DSL이 수행되는 동안 잡이 실행되면 어떡하죠?

잡의 설정을 수정하거나 제거해야 한다고 가정 해보자. 그런데 그 작업을 처리하는 중에 Jenkins 부팅이 끝나서 잡이 수정/삭제 되기 전에 실행이 되어버린다면? 상당히 곤란해 질 수 있다.

아니, 애초에 그런 경우가 생길 수 있긴 한가?

이 문제에 대한 답을 하기 전, 앞에서 언급한 Jenkins의 LifeCycle을 다시 떠올려보자. 그렇다면, init.groovy.d는 어느 시점에 실행되는가?

// core/src/main/java/hudson/init/impl/GroovyInitScript.java
public class GroovyInitScript {
@Initializer(after=JOB_CONFIG_ADAPTED)
public static void init(Jenkins j) {
new GroovyHookScript("init", j.servletContext, j.getRootDir(), j.getPluginManager().uberClassLoader).run();
}
}

코드를 보면 알 수 있지만, 정답은 JOB_CONFIG_ADAPTED 상태 이후이다. 즉 COMPLETED 가 되어 Jenkins 의 잡이 실행되기 직전에 동작하는 스크립트이기 때문에, 운이 지지리도 없다면 스크립트 완료전에 잡이 실행될 수 있다.

따라서 시작과 동시에 Jenkins를 Prepared for Shutdown 상태로 만들어버린다면, 잡 실행을 원천적으로 봉쇄할 수 있을 것이다.

Jenkins j = Jenkins.get()
j.doQuiteDown()
j.setSystemMessage("[${ts()}] [BOOT] Jenklins is in QUITE DOWN mode. Preparing jobs via Job DSL...")

// Job Initialization 작업 수행

j.doCancelQuiteDown()

결론

해당 사항은 실제로 부서에 적용된 내용으로, 신규 Job이 추가되거나 수정이 필요하다면 yaml 파일만 코드리뷰 대상에 넣어 모두가 변경사항을 캐치할 수 있게 하였다.

  • 휴먼 에러가 사실상 제로가 되었으며, 배포만 하면 모든 Job의 수정사항이 반영되므로 운영 서버 또한 수정 타이밍을 조정할 수 있었다.
  • 신규 리전 구축 시 추가 작업 없이 환경변수만 교체하여 컨테이너 이미지를 새로 생성하는 것 만으로 모든 작업을 완료할 수 있었다. (GUI 작업 0)

또한, 이런 경험을 발표까지 할 수 있게 되어서 전반적으로 매우 재밌는 경험이었던 것 같다.

작은 도구를 쓰더라도, 어떻게 하면 우리 조직의 상황에 맞춰서 커스텀 할 수 있을지 늘 고민이 필요할 것 같기도 하다. ㅎㅎ;;

Redis Cluster 와 장애 - (1) Redis는 어떻게 Cluster를 만드는가?

· 약 27분
VSFe
블로그 주인장

해당 글은 Redis Cluster에 대한 기본적인 이해를 필요로 합니다.

기본적인 Redis Cluster의 구조와, Hash Slot 에 대해 알고 있어야 하며, 추가적으로 Redis Cluster를 구축하기 위해 어떤 명령어를 사용하는지도 알아두면 좋습니다.

개인적으로 느끼는거지만, 서비스의 장애를 분석하거나 대비하는 과정에서 얻는 것이 더 많은 것 같다.

단순히 이론적으로 이게 뭐가 좋은지, 이게 뭐가 안 좋은지 를 공부하는 것을 넘어서, 실제 장애를 맞아보거나 (물론 그때는 괴롭지만... ㅠㅠ) 사례를 듣고 분석하게 되면 실제로 우리가 간과했던 것이 어떻게 치명적으로 돌아오는지 알 수 있고, 이걸 분석하는 과정에서 사용하는 기술에 대한 내부적인 분석도 할 수 있기 때문이다.

대표적인 예시가 Redis인 것 같다. 부서에서도 장애를 맞아봤고, 다른 부서의 장애 사례를 공유받으면서 얻은 것들이 많았기 때문이다.

이번엔 그 중에서 가장 많이 발생했던 Redis Cluster 와 관련한 이야기를 해보려고 한다. 다만 원래는 FAILOVER 를 수행해서 문제를 해결할 수 밖에 없던 다양한 케이스를 들고, 내부를 파보려고 했으나 이렇게 되면 아무도 글을 이해 못할 것 같아서 (...) 해당 내용은 후속 글로 작성해보기로 하고, 이번엔 Redis Cluster를 구축하는 단계에서부터 꼼꼼히 살펴보려고 한다.

(*블로그의 컨셉과 달리 개념적인 이야기를 많이 하게 되어서 다소 아쉽지만.. 2편을 위한 빌드업이라고 생각하고...)

RESP

RESP는 REdis Serialization Protocol 의 줄임말로, Redis 클라이언트가 Redis 서버와 데이터를 주고 받기 위한 목적으로 사용하는 프로토콜이다.

Redis 1.2 부터 RESP가 도입되었고, 2.0 부터 RESP2, 6.0 부터는 부분적으로 RESP3이 도입되었다.

우리가 이 글을 이해하기 위해 프로토콜을 매우 빠삭하게 알 필요는 없지만, 기본적인 형태와 구조만 이해하면 Redis를 이해하는데 충분히 많은 도움이 된다. (만약 RESP를 전부 빠삭하게 알고 있다면, Redis 클라이언트가 Redis 서버에 연결한 것 처럼 속이게 할 수 있는 가짜 Redis 서버도 만들 수 있다!)

타입Prefix예시설명
Simple String++OK\r\n짧은 문자열 (성공 응답 등)
Error--ERR wrong type\r\n에러 메시지
Integer::1000\r\n정수 값
Bulk String$$3\r\nfoo\r\n길이 지정된 문자열
Array**2\r\n$3\r\nGET\r\n$3\r\nkey\r\n여러 항목을 묶은 배열

모든 데이터는 CRLF(\r\n)로 구분된다.

## Client 가 Server에 보내는 요청 (SET foo bar)
*3\r\n
$3\r\n
SET\r\n
$3\r\n
foo\r\n
$3\r\n
bar\r\n
## Server는 이를 ["SET", "foo", "bar"] 로 인식함
+OK\r\n

우선 너무 깊게 분석하지 말고, 이정도만 알아두자. (이후, 딱 한 군데에서 이걸 다시 언급할 것이다.)

Redis는 어떻게 Cluster를 생성하는가?

사실 어지간한 Cluster 구성이 그렇지만, Redis 또한 Cluster 생성을 위해선 다소 복잡한 Handshake 과정을 거친다.

다만 우리는 Slave 노드 추가시엔 redis.conf 만 수정했고, Cluster 추가를 희망하는 경우엔 --cluster add-node 등을 사용해서 노드를 추가했을 것이다.

그렇다면, 내부적으로는 무슨 과정을 거칠까?

PING

  • TCP Handshake 등을 제외한다면, 가장 먼저 하는 것은 PING 명령어를 통해 해당 서버 및 포트에 Redis가 돌아가고 있는지, 접근이 가능한지 등을 확인한다.
  • 사실상의 Health Check 목적을 위한 명령어이고, 클러스터 구축이나 (CLUSTER MEET를 쓰던, Slave로 추가되던) 클러스터 내부에서의 지속적인 헬스체크 등에서도 사용된다. (결국은, 우리가 해당 명령어를 직접 호출할 일은 사실상 없다.)

image-20251009034224576

처음 Redis 공부했을 땐 클러스터고 뭐고 아무것도 몰라서, 이 명령어는 그냥 심심해서 넣은 줄 알았다...

Master 노드 추가 (Hash Slot 노드)

  • 보통은 cluster를 생성할 때는 redis-cli 에서 --cluster 계열 명령을 쓰다보니 간과할 수 있지만, 앞에서 설명했던 것 처럼 결국 RESP 기반으로 Redis는 통신을 할 것이고, 그걸 기반으로 머리를 조금만 굴려보면 결국 이미 Redis에 존재하는 명령어 셋을 주고 받을 것이라는 예측을 할 수 있다.

    • 실제로는 아래와 같은 명령어가 내부에서 호출된다고 생각하면 된다.
  • 클러스터에 합류하기 위해, CLUSTER MEET를 사용한다.

    • Redis Cluster에 대해 공부했다면, 아래와 같은 Mesh 구조를 갖고 있음을 인지할 것이고, 즉 아무 노드에나 자신을 MEET 해도 자연스럽게 Cluster 내 모든 노드와 정보가 공유된다고 생각하면 된다.

image-20251009053254459

  • 앞에서 PING 을 언급했던 것 처럼, 노드들은 Mesh 의 노드들에 대해 PING 을 주고 받고, Gossip 프로토콜을 활용하여 서로 다른 노드들의 상태들을 전파한다.

  • 여기서 중요한 건, 합류만 된 것이지 Hash Slot을 할당받지 못 했기 때문에, Mesh 에 합류는 되어 있지만 데이터를 전달 받지는 않는다.

  • 신규 클러스터인 경우, CLUSTER SET-CONFIG-EPOCH 이라는 명령어를 호출하여 epoch 을 설정하나, 이에 대한 내용은 해당 포스트에선 생략한다.

    • 해당 값은 내부 Gossip 프로토콜에서 활용하기 위한 값으로, Raft 알고리즘의 term 과 유사한 의미를 가진다. epoch 을 이해하는 것이 Redis Cluster 구조를 이해하는 것에 도움이 될 순 있겠지만... 너무 분량이 폭발할 것 같아 제외한다.
  • 그 후 CLUSTER ADDSLOTS 을 호출하여 0-16383 까지의 슬롯을 n 등분하여 배정한다.

번외 - Vote-Only Node가 가능한가?

글을 작성하다보니 갑자기 궁금해 져서 좀 찾아본 내용이다.

MongoDB Arbiter, ElasticSearch Voting Only Node 등, 이미 수많은 분산형 데이터베이스에는 Vote-Only Node가 존재한다. 해당 노드는 데이터가 저장되진 않지만, 일부 노드의 장애로 새로운 마스터 선출이 필요한 상황에서 투표만 하기 위한 목적의 노드인 것이다. (보통 과반수 문제에서 자유로워지기 위해 사용한다.)

앞에서 말했듯이 CLUSTER ADDSLOTS를 호출하지 않으면 Master 노드임에도 데이터를 할당받지 못하니 사실상 Vote-Only 처럼 사용할 수 있지 않을까?

이건 진짜 검색을 해도 답이 없다보니, 작정하고 코드를 뒤져봤다.

/* Vote for the node asking for our vote if there are the conditions. */
void clusterSendFailoverAuthIfNeeded(clusterNode *node, clusterMsg *request) {
clusterNode *master = node->slaveof;
uint64_t requestCurrentEpoch = ntohu64(request->currentEpoch);
uint64_t requestConfigEpoch = ntohu64(request->configEpoch);
unsigned char *claimed_slots = request->myslots;
int force_ack = request->mflags[0] & CLUSTERMSG_FLAG0_FORCEACK;
int j;

/* IF we are not a master serving at least 1 slot, we don't have the
* right to vote, as the cluster size in Redis Cluster is the number
* of masters serving at least one slot, and quorum is the cluster
* size + 1 */
if (nodeIsSlave(myself) || myself->numslots == 0) return;

L10-L13의 주석을 번역하면 다음과 같다.

Redis Cluster의 클러스터 크기는 1개 이상의 Slot을 갖고 있는 노드의 수 이므로, 만약 Master 노드가 1개의 슬롯도 갖고 있지 않다면 투표권도 없고, Quorum 에도 포함되지 않는다.

즉 노드가 보유하고 있는 슬롯이 없다면, 사실상 클러스터의 구성원으로 보지도 않으니 Vote-Only 노드는 존재할 수 없다고 봐야한다.

Slave 노드 추가

Cluster를 구축할 정도의 환경이라면 당연히 Master와 함께 자연스럽게 Slave도 달아줘야 한다.

  • 언제나 PING 은 필요로 한다.
  • CLUSTER REPLICATE <masterId> 를 호출하여 slave를 등록한다.
    • 여기부터는 일반적인 레플리카 등록과 동일하다.
  • Slave는 Master에게 REPLCONF를 호출하여 복제본 생성과 관련한 메타데이터를 교환하고, 이후 PSYNC를 통해 데이터 전달을 받는다.

Master 노드로 등록했다고 해도, 위에서 언급했듯이 슬롯을 할당받지 못하면 사실상 없는 노드랑 마찬가지라는걸 기억해보자. 즉, 할당받은 슬롯이 없다면 Master 노드라고 해도 CLUSTER REPLICATE를 호출해서 Slave로 전직할 수 있다.

PSYNC

오늘의 핵심 포인트 중 하나로, 사실상 Redis에서 Replication 을 수행하기 위해 반드시 필수적으로 알아야 하는 존재다.

결국 간단하게 말해서, Master 에게 데이터를 전달해 달라고 요청하는 것이다.

  • PSYNC <replid> <offset> 으로 구성되어 있다.
  • 최초 연결시에는 PSYNC ? -1 으로 Full Sync를 요청한다. 이 경우, Master는 RDB를 추출해 데이터를 전달한다.
  • 재연결인 경우엔 일반적으로 Partial Sync를 수행한다.

offset 이라는 것을 보면 알 수 있듯이, 어디까지 전달 받았음 을 요구하는 것을 알 수 있다.

offset의 정의를 정확히 이해하기 위해선, 이전에 진행했던 Redis 세미나에서 언급한 Replication 데이터 전달 방식을 알고 있어야 한다.

image-20251009231718353

// SET foo 1을 Master에 호출 (Slave 전달 성공)
- Master -> Slave 로 SET foo 1 전달
- Slave는 해당 연산을 수행하고, 내부 Offset을 29로 갱신한다.
- 특정 간격 마다, REPLCONF ACK 29를 전달한다.

// SET foo 1을 Master에 호출 (Slave 전달 실패)
- Master -> Slave 로 SET foo 1 전달
- Slave는 해당 연산을 전달받지 못했으므로, 내부 Offset은 여전히 0 이다.
- 특정 간격 마다, REPLCONF ACK 0을 전달한다.

ACK 29 인 이유가 궁금할텐데, 이는 상단에서 언급한 RESP를 알면 설명이 된다. 결국 Master -> Slave로 명령을 전달할 때도 사용자가 보낸 명령을 RESP 형식에 맞게 인코딩할 것이고, 그 인코딩한 데이터의 길이가 29가 되기 때문에, 내부 Offset이 갱신되는 것이다.

결국 연결이 구축되고 나면,

  • Master는 연산이 발생할 때 (마치 AOF 마냥) 자신의 Slave 들에게 명령어를 전달하고,
  • Slave는 해당 명령을 수행하고 Offset을 갱신하여 Master에게 ACK를 전달하며,
  • 자연스럽게 둘 사이에선 계속 PING이 호출될 것이다. (더 자세히 말하면 repl-ping-replica-period 주기로 보내고, repl-timeout 안에 응답이 안 오면 헬스체크 실패로 간주한다.)

그런데, 데이터 전달이 원할하게 이뤄지지 않아 Master와 Slave의 Offset이 점점 벌어지게 된다면? 그럼 결국 명령어를 다시 보내서 재수행하는 것 보단, 아예 통짜 백업을 다시 전달하는 것이 편할 것이다. 그렇다면 offset이 벌어질 때 Redis는 이걸 어떻게 판단할까?

  • 일단 TCP로 연결하는 Redis의 특성상, 전송 과정에서의 손실은 보통 TCP의 수많은 신뢰성 보장 도구로 인해 재전송이 이뤄질 것이다.

    • 다만 Redis 자체는 재전송이라는 기능이 없다. 정말 운이 안 좋아서 TCP Buffer가 초과된다면, TCP 에서 Drop 이 발생하여 offset이 어긋난다고 해도 재전송을 하지 않는다.
  • offset이 벌어지는 것 자체는 full sync 트리거링에 영향을 주지 않지만, offset이 벌어지면 자연스럽게 발생하는 문제들로 인해 full sync가 유발될 수 있다.

    • 네트워크의 특성을 고려하면 Master와 Slave 사이의 연결이 순간적으로 끊길 수 있는데, 이 때문에 보통은 backlog buffer 를 두어 그 시간동안 데이터가 유실되지 않도록 한다. (repl-backlog-size)

    • 다만 offset이 너무 벌어져서 두 offset의 차이가 repl-backlog-size를 넘겨버리면 결국 데이터의 동기화가 불가능하다고 판단하여 full sync를 수행한다.

      -> 즉, 연결이 유지만 된다면 repl-backlog-size 에 의해 full sync가 호출되는 경우는 없다.

    • 이외에도 단순 Master -> Slave 전달을 위한 별도의 버퍼가 존재하나, client-output-buffer-limit 를 초과하는 데이터가 쌓이는 경우 강제로 연결을 끊어버린다.

      -> 이것도 마찬가지로, 연결이 유지되는 동안엔 full sync를 호출하지 않는다고 생각할 수 있다.

client-output-buffer-limit

오늘의 핵심 포인트 두 번째.

redis.conf 파일을 보면 수많은 파라미터가 존재하는데, 사실 Redis를 다소 러프하게 쓰게 되면 이러한 파라미터에 대해 관심을 거의 갖지 않는다.

잠깐 해당 파라미터의 설명을 보고, 중요한 내용을 이야기 해 보자.

# The syntax of every client-output-buffer-limit directive is the following:
#
# client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
#
# A client is immediately disconnected once the hard limit is reached, or if
# the soft limit is reached and remains reached for the specified number of
# seconds (continuously).
# So for instance if the hard limit is 32 megabytes and the soft limit is
# 16 megabytes / 10 seconds, the client will get disconnected immediately
# if the size of the output buffers reach 32 megabytes, but will also get
# disconnected if the client reaches 16 megabytes and continuously overcomes
# the limit for 10 seconds.
#
# By default normal clients are not limited because they don't receive data
# without asking (in a push way), but just after a request, so only
# asynchronous clients may create a scenario where data is requested faster
# than it can read.
#
# Instead there is a default limit for pubsub and slave clients, since
# subscribers and slaves receive data in a push fashion.
#
# Both the hard or the soft limit can be disabled just setting it to zero.
  • hard-limit 에 도달하거나, soft-limit 에 도달하여 특정 시간이 지나면 연결을 강제로 해제한다.
  • normal 은 큰 문제를 보이지 않지만,pub-sub 이나 slave 같은 경우는 제한이 중요하다.

자, 이제 위 내용을 다 합쳐보자.

  • 현재 운영중인 Redis가 존재하고, Replication 을 사용하고 있다고 해보자.
  • 순간적인 네트워크 이슈로 Slave 노드와 Master 노드의 연결이 해제되었다.
  • 다만 트래픽이 많이 들어오는 서버라, 금방 Slave 노드와의 연결이 재개되었음에도 repl-backlog-size 가 넘는 양의 데이터가 인입되었고, 결국 partial sync가 아닌 full sync 가 수행된다.
  • full sync가 수행되어야 하기 때문에 BGSYNC 가 호출되고, RDB 파일을 생성한다.
  • RDB 파일이 생성중인 상황에 들어오는 데이터는 client-output-buffer 에 저장된다.
  • RDB 파일을 전송하고 보니까, client-output-buffer-limit가 초과되어 연결이 바로 끊긴다. (??????)
  • Slave 노드는 다시 Master 노드에 진입을 시도하게 되고, 무한 반복이 수행된다.

이렇게 되면 자연스럽게 Master 노드의 CPU 사용률은 100%로 치솟게 될 것이고, Master 노드의 부하로 Redis 전면 장애가 발생할 수 있다. 연결이 끊기는 건 발생할 가능성이 낮지만, (실제로 이 상황을 목격한 경험이 있는지라) 이에 대한 대비책은 충분히 필요하다.

결국 기본 설정을 그대로 사용하는 것이 아닌, 사용하고 있는 메모리나 트래픽 상황에 맞춰 파라미터 값을 적당히 조정해 줄 필요가 있는 것이다.

Cluster에 Master 노드가 추가된다고 데이터 편향 문제가 해결 될까?

뭔가 어디에 적고 싶다고 이전부터 생각을 많이 했었는데, 2부에는 도저히 못 적을 것 같아 여기에 짤막하게 적는다.

답만 빠르게 적으면, 그럴수도 있고, 아닐수도 있다. 라고 말할 수 있을 것 같다.

  • 당연하지만 Hash 충돌로 인해 특정 Slot 범위에 키가 몰린다면, 노드가 추가되어 구간이 분산이 잘 된다면 어느정도 문제를 해결할 수 있을 것이다.
  • 다만, 특정 key들의 hash slot의 위치가 완전히 똑같거나, 특정 key에 해당하는 value가 많다면 아무리 노드를 많이 추가해도 해결할 수 없다.
  • 특히나, 리스트를 많이 쓰는 경우라면 후자는 필연적으로 발생할 수 밖에 없다.

그렇다면, 어떻게 하는게 좋을까? 사실 정말 간단하지만, 데이터를 분할하면 된다.

예를 들어서, dataList 라는 key가 있다고 해보자.

  • key를 dataList:1, dataList:2, dataList:3 으로 분리
  • 애플리케이션에서 value를 넣을때, 자체 알고리즘을 사용하여 데이터를 3분할 (Java의 hashCode를 사용한다거나...)
  • 애플리케이션에서 데이터를 가져올 때, 전체 key를 순회하여 가져옴

그런데 이것도 잠깐 생각 해볼게 있다. key를 몇 개 만들어야 하는가? 만약 Cluster의 Master 노드의 수가 3이라고 해보자. 그렇다면 3개의 key로 분할했다고 해서, 3개의 key가 세 Master 노드에 균등하게 퍼질것이라는 보장이 있는가? 오히려 아닐 확률이 더 높을 것이다.

만약 key의 개수를 조금 늘려준다면, 약간이나마 더 퍼질 가능성이 존재할 것이다. 하지만 그렇다고 key를 한 2,000개 만들어버리면 그건 그거대로 문제가 될 것이다.

결국 key를 설계하는 관점에서 우리가 생각해보면 좋은 문제는, 어떻게 하면 최대한 모든 노드에 이 분산된 key가 하나 정도는 있도록 할 수 있을까? 라고 정의될 것이다.

대학때 확률론을 공부해봤다면, 쿠폰 수집가의 문제 에 대해 접해봤을 것이다. 배워본 적이 없다면 링크를 들어가도 저게 뭔 소리인가 싶을 것이다.

해당 문제를 우리가 이해하기 쉽게 각색해서 설명하면, 아래와 같다.

여러 종류의 씰이 있는 빵이 있다고 해 보자. 씰은 균등한 확률로 존재한다. (즉, 희귀 씰 같은 것 없다고 가정한다.) 이 때, 모든 씰을 최소 1개 이상 얻기까지 빵을 몇 개를 사야하는가?

도대체 이게 왜 Redis와 연관이 있는건가? 싶을텐데, 이 문제를 Redis와 비교해서 보도록 하자.

쿠폰 수집가 문제Redis Cluster
쿠폰 종류의 수 n (빵에 들어간 씰의 종류 n)마스터 노드 수 n
쿠폰 하나를 얻음 (빵을 까서 씰을 하나 얻음)dataList:{idx} key 를 생성함
모든 쿠폰을 전부 수집 (모든 씰을 전부 수집)모든 노드에 최소 1개 이상의 키가 분배 됨

쿠폰 수집가 문제의 기본 공식은 아래와 같다.

P(k,n)=1n(11n)kP(k,n)=1-n(1-\frac{1}{n})^k

이는 n개의 노드가 있고 k개의 키를 생성할 때 모든 노드가 최소 1개 이상의 키를 갖게 될 확률을 계산하는 공식이다.

다만 우리가 원하는 건 특정 확률 이상으로 모든 노드가 최소 1개 이상의 키를 갖고자 한다면, 생성해야 하는 key의 수 인 것이니, 식을 변형하면 아래와 같이 바뀐다.

k=ln(1pn)ln(11n)k=\frac{ln(\frac{1-p}{n})}{ln(1-\frac{1}{n})}

계산은 우리가 하긴 어려우니, GPT에게 시키자 툴을 사용해서 돌리면, 대략 아래 같은 결과가 나온다.

노드 수 (n)목표 확률 (p)필요한 키 수 ((k)) (근사)
30.958
30.9911
60.9517
60.9924
100.9528
100.9939

실제로 업무에서는 노드의 수가 3개이고, 95% 확률로 키 분산을 목표하고 있으므로, dataList:0 ~ dataList:7 까지의 key를 생성하여 리스트 형식의 데이터를 관리하고 있다.


Redis Cluster 와 관련한 주절거림은 여기서 마무리 한다. 글 초반에도 언급한 것 처럼 이 글의 목적은 2편을 위한 준비운동이니, 2편에서 좀 더 자세한 이야기를 해보도록 하자.

Java 에서의 스케쥴링, 어떻게 가능한 것인가?

· 약 20분
VSFe
블로그 주인장

우리는 Spring 에서 스케쥴링 잡을 수행하기 위해, @Scheduled 라는 어노테이션을 사용하곤 한다.

@Scheduled(cron = "0/5 * * * * ?")
public void scheduledCron() {
// do something...
}

@Scheduled(fixedDelay = 1000)
public void scheduledfixed() {
// do something...
}

그런데 상식적으로 생각을 해보면, 정말로 Java가 1ms 단위까지 세밀하게 스케쥴링을 할 수 있을까? 라는 의문이 들 수 있다.

Java 이야기는 아니지만 Redis/MongoDB 같은 데이터베이스의 TTL 또한 ms 단위로 넣을 수 있지만 실제로는 ms 단위로 데이터 체킹 및 삭제가 이뤄지지 않기도 한다. Redis는 기본적으로 사용자가 데이터 조회 시도 전 까지 삭제를 수행하지 않고, (주기적으로 샘플링하여 체킹하고 지우는 작업이 존재하지만, 이는 보조적인 역할이다.) MongoDB는 TTL Monitor 스레드가 주기적으로 데이터를 확인하여 제거한다. (ttlMonitorSleepSecs 파라미터에 따라 다르지만, 디폴트는 1분이다. 즉, 최대 59초까지는 데이터가 안 지워질 수 있다.)

결국, 수많은 상용 기술들은 스케쥴링 처리가 우리의 의도 처럼 정확한 시간에 수행된다 라고 말하기 어려운데, Java는 어떨까?

Java가 스케쥴링을 지원하는 방법

가장 간단한 생각 - Thread.sleep()

Java의 Thread 는 대부분의 실제 동작을 OS에 위임하고 있다. 그렇기에, Thread.sleep(millis, nanos) 와 같은 메서드는 OS 스케쥴러에 이를 위임하게 되고, 자연스럽게 주기적인 작업을 수행하도록 유도할 수 있을 것이다.

try {
while (true) {
Thread.sleep(5000);
// do something...
}
} catch (InterruptedException e) {
e.printStackTrace();
}

다만, 이 방법이 스케쥴링 관점에서 효율적일까?

  • 운영체제 관점에서, 인터럽트 된 스레드는 인터럽트 처리가 완료되어도 바로 실행되는게 아니라, 스케쥴링의 대상이 될 뿐이다. 즉, 5000ms 이후에 바로 실행됨이 보장되지 않는다.
  • Real-Time 운영체제가 아닌 일반적인 운영체제의 경우, 정확한 Real Time 스케쥴링이 어렵다. (사실 이 부분 때문에라도, ms 단위 스케쥴링의 정확성을 보장하는 것은 거의 불가능하다.)
  • 만약 우선순위를 설정했다면, (Thread.setPriority() 등으로) sleep 으로 시간 지연한 스레드가 바로 수행됨이 보장되지 않는다.
  • 스케쥴링 해야 하는 작업이 2개 이상이라면 각각의 작업에 대해 별도의 스레드가 필요한데, 각각의 스레드가 Sleep 처리가 되면 효율성이 매우 떨어진다.

사실 다른 사항들은 약간의 오차를 감안하고 넘어갈 수 있다고 쳐도, 마지막이 가장 치명적일 것이다. 스레드는 결국 자원을 차지하고 있기 때문에, 블로킹된 스레드의 수가 많으면 많아질 수록 프로그램의 전반적인 효율은 떨어질 것이다.

그렇기에, Java에서는 일반적으로 여러 작업을 병렬적으로 수행하기 위해서 스레드풀을 사용하고 있고, Java에서는 가장 기본적인 ThreadPoolExecutor를 상속한 ScheduledThreadPoolExecutor 를 통해 스레드풀을 사용하면서도 스케쥴링 잡을 수행할 수 있도록 구현하고 있다.

해당 도구의 구현을 좀 더 자세히 살펴보기 전, 위 글만 보면 생각할 수 있을 만한 “블로킹만 최소화 하면 좀 괜찮지 않을까?” 에 대한 해답을 알아보자.

방향 살짝 틀어보기 - Spin Lock

다음 코드를 보자.

var now = System.nanoTime();
while (true) {
var newTime = System.nanoTime();
if (newTime - now > 1_000) {
// do something
now = newTime;
}
}

이건 더 효율적인 솔루션일까?

결론만 말하자면 특정 상황에 따라 더 효율적일 수 있으나, Java 에선 대부분 비효율적이다.

  • 이론적으로는 아주 정확한 타이밍을 모니터링 할 수 있지만, Java 언어 자체가 Hard Real Time 에 적합한 언어는 아니라, 완벽한 정확성을 보장할 수 없다.
  • 그와 반비례하여 자원 소모량이 엄청나게 올라간다. 하나의 작업마다 저렇게 Spin Lock을 걸어버리면, OS 및 JVM 스케쥴러에 악영향을 주기 쉽다.

ScheduledThreadPoolExecutor - Java가 내놓은 해결책

일반적으로, Java에서 병렬처리를 구현하기 위해선 ThreadPoolExecutor 를 사용하는 편이다. 그렇다면 ScheduledThreadPoolExecutor는 어떤 차이를 갖고 있을까?

사실, ScheduledThreadPoolExecutorThreadPoolExecutor 를 상속한 클래스이다.

public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService 

그런데, 이 클래스가 ThreadPoolExecutor 와 비교하여 어떤 차이를 갖고 있는가?

  • ThreadPoolExecutorBlockingQueue<Runnable> 구조 (즉, 일반적인 큐)를 사용하고 있으나, ScheduledThreadPoolExecutor 는 자체적으로 큐를 새로 정의해서 사용한다. (DelayedWorkQueue) 해당 큐가 스케쥴링이 가능하게 된 핵심적인 구조이므로, 해당 큐에 대해서는 뒤에서 더 자세히 설명한다.
  • 또한, Runnable을 구현한 SchduledFutureTask 를 큐에 넣어 사용하고 있다.

image-20250330022818322

해당 다이어그램은 ScheduledFutureTask 의 구조를 표현한 것이다. 모든 구조에 대해서 이해할 필요는 없고, 전체적인 스케쥴러에 대한 이해를 위해 알아야 할 두 가지 특성만 알고 가도록 하자.

  • Delayed 인터페이스에는 getDelay(TimeUnit unit) 메서드가 있다. 이는 얼마나 더 기다려야 하는지를 반환하는 메서드이다.
  • RunnableScheduledFuture 인터페이스에는 isPeriodic() 메서드가 있다. 이는 이 작업이 한 번 수행되는 것이 아니라 주기적으로 실행되는지 여부를 반환하는 메서드이다.

저 두 메서드만 봐도, 이게 왜 스케쥴링이 가능한지 이해가 갈 것이다.

이제, 핵심이라고 할 수 있는 DelayedWorkQueue 에 대해 알아보자.

static class DelayedWorkQueue extends AbstractQueue<Runnable> implements BlockingQueue<Runnable> {

/*
* A DelayedWorkQueue is based on a heap-based data structure
* like those in DelayQueue and PriorityQueue, except that
* every ScheduledFutureTask also records its index into the
* heap array.
// 중략

주석에 적힌 heap-based data structure 라는 말에 집중하자. 즉, 자바에서 스케쥴링은 우선순위 큐 기반의 스케쥴러를 활용하여 수행한다라고 말할 수 있는 것이다.

동작 과정을 조금만 더 살펴보자.

  • ScheduledThreadPoolExecutor 의 Worker Thread 는 기본적인 ThreadPoolExecutor의 동작 방식을 따라간다.
    • 즉, Task를 큐에서 꺼내오고, 있으면 작업을 수행한다.
  • DelayedWorkQueue가장 먼저 수행되어야 하는 작업에 대해 우선순위 큐 형식으로 데이터를 저장하고 있다.
    • 다만 interface 구조 상 일반 Runnable 도 큐에 들어갈 수 있는데, 이렇게 될 경우 delay = 0이고 반복실행이 되지 않는 ScheduledFutureTask 로 간주한다. (즉, 즉시 실행된다.)
    • 만약, 같은 타이밍에 수행되어야 하는 작업이 있다면, 이는 FIFO 로 처리된다. (내부적으로 순서를 기록하기 위한 AtomicLong sequencer 를 포함하고 있다.)
    • Task가 있음에도 큐의 맨 앞에 있는 Task의 수행되어야 하는 시점이 현재 시점보다 과거라면, (다시 말해서 우선순위 큐에 의해 가장 빨리 시작되어야 하는 것으로 판단된 Task가 현재 시점 뒤에 수행되야 한다면) null 을 반환한다. 즉, 큐에 아무것도 없는 것으로 처리한다. (Queue.poll() 수행 기준)
  • Worker Thread 는 기본적으로 큐에서 데이터를 소비하려 하며, 만약 데이터가 없다면 Queue의 poll(keepAliveTime, TimeUnit.NANOSECONDS) 이나 take() 를 수행한다.
    • DelayedWorkQueue는 해당 메서드를 수행할 경우 스레드를 Condition.await() 를 통해 sleep 시켜버리고, 새로운 데이터가 들어오는 순간 Condition.signal() 을 통해 깨운다.
    • 이를 통해, Spin Lock 같은 방식을 사용하지 않고도 스레드를 대기시킬 수 있으며, 자연스럽게 Task를 수행할 수 있다.

물론 위 내용만 읽어본다면, 해결되지 않는 의문이 여전히 존재한다.

  • DelayedWorkQueue에 현재 시점보다 10초 뒤에 수행해야 할 Task를 넣었다고 가정하자.
  • Worker Thread는 Task를 가져오려 하나, 큐에 데이터가 없는 것으로 판단되어 await가 될 것이다.
  • 그렇다면 누가 깨우지...??

사실 이는, Queue를 상속한 BlockingQueue에는 take() 라는 메서드가 추가로 존재한다 라는 사실을 알아야 이해할 수 있다.

  • Queue.poll() - 데이터를 가져온다. 없으면 null을 반환한다.
  • BlockingQueue.take() - 데이터를 가져오려 시도한다. 없으면 데이터를 가져올 수 있을 때 까지 블로킹된다.

그렇기 때문에, DelayedWorkQueue 는 각각의 메서드를 아래와 같이 구현했다.

  • DelayedWorkQueue.poll() - Task를 가져오되, 만약 맨 앞 Task가 현재 시점 뒤에 수행되어야 하면 null을 반환한다.
  • DelayedWorkQueue.take() - Task를 가져오되, 만약 맨 앞 Task가 현재 시점 뒤에 수행되어야 하면 그 시간 까지 await 되었다가 (awaitNano(nanoSecond) 를 통해) task를 가져온다.

물론 실제로는 조금 더 복잡하다. (이미 작업을 수행중인 다른 Worker Thread가 있으면 현재 Thread는 그냥 await 된다거나...) 하지만 이정도만 이해해도, 우리는 충분히 동작 방식을 이해했다고 말할 수 있을 것이다.

이 방법을 통해 효율성을 최대로 높일 수 있으나, 역시 처음 제시한 문제였던 완벽히 정확한 시간에 수행되는 것이 불가하다는 여전히 해결하지 못했다. 다만 이는 일반적인 언어 환경에서는 엄밀한 수행이 불가능하기 때문에, 이해하고 넘어가도록 하자.

Spring 의 @Scheduled 는?

사실 대부분의 사람들은 위에서 언급한 ScheduledThreadPoolExecutor를 써볼 일이 거의 없겠지만, 반대로 Spring 이 제공하는 @Scheduled는 많이 써봤을 것이다.

뭔가 윗 부분을 꼼꼼하게 읽었다면 Spring 이 제공하는 것도 저걸 사용하지 않았을까? 하는 생각이 들텐데, 과연 진짜인지 확인해보도록 하자.

@EnableScheduling

  • 스케쥴링 기능을 활성화 하기 위해 붙이는 메타 어노테이션이다.
  • 해당 어노테이션을 붙이게 되면, 자동으로 SchedulingConfiguration.java 를 import 하여 사용하게 된다.

SchedulingConfiguration

  • ScheduledAnnotationBeanPostProcessor 를 등록한다.

SchduledAnnotationBeanPostProcessor

  • 빈 후처리기 (BeanPostProcessor) 는 Spring Container 에 빈을 등록하기 전, 특정 빈들을 대상으로 추가 작업을 해주는 Processor 이다.
  • @Scheduled, @Schedules 어노테이션이 붙은 메서드들이 캡쳐 대상이며, 해당 설정을 파싱하여 RunnableTrigger 형태로 변경한다.
  • Spring 이 자체적으로 스케쥴링을 추상화 한 인터페이스인 TaskScheduler 에 정보를 전달하며, 주로 기본 구현체인 ThreadPoolTaskScheduler를 사용한다.
  • 그런데, ThreadPoolTaskSchedulerScheduledExecutorService를 갖고 있으며, 해당 인터페이스의 구현체가 위에서 설명한 ScheduledThreadPoolExecutor 이다.
  • 추가적으로, Cron Job의 경우 내부적으로 CronTrigger를 갖고 있는데, 이는 다음 실행 시점을 계산하여 주기적으로 큐에 들어갈 수 있도록 도와준다.

결국 돌고 돌아, 기본적인 원리는 위에서 설명한 것과 동일한 것이다. 다만 사용성을 높이기 위해 많은 사항이 추상화 되어 있고, 그렇기에 이를 사용하는 우리 입장에선 아무 내용도 몰라도 문제 없이 사용할 수 있는 것이다.

하지만 내부 구조를 알고 있으니, 우리는 @Scheduled를 사용함에 있어 추가적인 가이드라인을 얻을 수 있다.

@Scheduled 사용 시 알 수 있는 것들

  • corePoolSize = 1 이다. 즉, 스케쥴링 잡은 병렬로 수행되지 않는다.

    • 내부적으로 스레드풀을 사용하고 있지만, 결국은 스레드가 1개라서 작업은 모두 순차적으로 수행된다.
    • 앞서 동일한 타이밍에 수행되어야 하는 Task가 여러 개라면, FIFO 형식으로 수행된다 했으나, 빈 후처리기에 들어가는 순서를 완벽하게 조정하는 것은 쉽지 않기 때문에, (여러 변수가 존재하므로) 결국 같은 시간에 수행되어야 하는 Task는 순서를 보장하기 어렵다.
    • 병렬 처리를 원한다면, Configuration 으로 새로운 ThreadPoolTaskScheduler를 재정의해야 한다.
  • queueSize 는 제한이 없다. 즉, 아무리 많은 스케쥴링 잡이라도 일단 넣을 수는 있다.

    • 당연히 너무 많으면 문제가 될 수 있겠지만, 구성상 그렇게 많은 작업을 수행하기 어려우므로, 특별하게 조정하지 않아도 문제는 없다.
  • Scheduled Task의 다음 실행 시점은 작업이 끝나고 결정된다.

    • 앞에서 설명을 하지 않았던 부분이지만, DelayedWorkQueue 가 스케쥴링 대상으로 쓰는 ScheduledFutureTaskrun() 메서드를 살펴보자.

    • public void run() {
      if (!canRunInCurrentRunState(this))
      cancel(false);
      else if (!isPeriodic())
      super.run();
      else if (super.runAndReset()) {
      setNextRunTime();
      reExecutePeriodic(outerTask);
      }
      }

      private void setNextRunTime() {
      long p = period;
      if (p > 0)
      time += p;
      else
      time = triggerTime(-p);
      }
    • super.runAndReset() -> setNextRunTime() 순으로 수행됨을 볼 수 있다. 즉, 작업이 수행된 이후, 다음 작업이 실행될 타이밍을 계산한다 라고 말할 수 있다.

    • Spring @Scheduled 환경에서, 특정 작업을 fixedRate로 등록하게 되면 (시작 시점 + 주기) 로 다음 시간이 계산되는데, 만약 10초가 걸리는 작업을 fixedRate = 1000 으로 등록했다고 가정 해보자.

      • 이 경우, 잘 모른다면 10초 동안 해당 작업이 10번 큐에 들어간다고 생각할 수 있지만, 작업이 종료된 후 단 한 번만 계산되기 때문에, 큐에는 1개만 들어가게 된다.
      • 다만 이렇게 되면 작업이 종료되고 텀 없이 바로 이어서 작업이 수행되므로, 텀을 확보하고 싶다면 fixedDelay = 1000 으로 설정하는게 맞다.
    • 참고로, 이와 관련된 ScheduledThreadPoolExecutor의 메서드는 다음과 같다.

      • scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
        • (이전 실행 시작 시점) + period 만큼 대기
      • scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
        • (이전 실행 종료 시점) + period 만큼 대기

Spring 을 사용하지 않고 스케쥴링을 구현해야 하는 상황이 발생하여 처음으로 ScheduledThreadPoolExecutor 를 사용해 보고, 동작 원리가 궁금해져 글을 작성하다 보니 다소 글이 길어졌다.

별개로 올해 Spring/JDK 분석 스터디를 개설하려고 고민하고 있는 중이다. 원래는 해당 스터디를 의식하고 작성한 글은 아니었지만, 작성하다보니 스터디의 방식을 이런 식으로 하면 좋겠다는 생각이 들었다.

boj-boot 라이브러리, Gradle 플러그인 개발기

· 약 22분
VSFe
블로그 주인장

최근에, boj-boot 라는 이름의 라이브러리와 Gradle 플러그인을 개발했다. 라이브러리와 Gradle 플러그인은 개발 자체가 처음이기도 했고, 생각보다 자료도 많지 않아서 다양한 삽질을 했다...

사실 이 글의 작성 목적은 개발에 대한 회고 목적 보단, 개발 과정에서 학습하고 고민한 JUnit5 과 Gradle 에 대한 정보를 공유하기 위함 으로, 해당 플랫폼에 대해 관심이 있다면, 간단하게 읽어보면 좋을 것 같다.

Overview

이번 boj-boot 의 목적은, 자바로 알고리즘 문제를 푸는 사람들이, 코드 수정할 때 마다 일일히 테스트 케이스를 넣는 것이 불편하지 않을까? 였다. 당장 프로그래머스가 좋은 환경을 제공하고 있는 만큼, Java 개발 환경에서도 비슷하게 구현할 수 있을 것이라는 생각이 들었다.

  • 시간 제한, 테스트 케이스 같은 정보는 백준 크롤링만 하면 쉽게 땡겨올 수 있고,
  • 입력에 대한 출력 결과를 String 으로 뺄 수만 있다면 일반적인 테스트 코드로도 정답 여부를 검증할 수 있다.

따라서 크게 문제가 될 게 없었다.

Library vs Plugin

이 프로젝트를 적용한 build.gradle.kts 를 보자.

plugins {
id("java")
}

group = "org.vsfe"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("io.github.vsfe:boj-boot-plugin:1.0.3")
}
}

apply(plugin = "io.github.vsfe.boj-boot-plugin")

dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("io.github.vsfe:boj-commons:1.0.2")
}

tasks.test {
useJUnitPlatform()
}
  • 위쪽의 plugins 에 등록하지 못하고 하단의 apply 를 사용하여 plugin 으로 등록하게 된 이유는, gradle plugin portal 에 등록이 되지 않았기 때문이다. (해당 내용은 후술함.)

도대체 pluginsdependencies 는 무슨 차이일까?

  • 간단하게 설명하자면, Gradle plugin 은 일종의 스크립트 와 유사한 동작을 한다.
  • 반대로 dependencies (라이브러리) 는 일반적인 외부 의존성을 떠올리면 된다.

결과적으로 원하는 작업을 하기 위해선 둘 모두가 필요했는데,

  • 백준 데이터를 크롤링 하여 코드를 자동 생성해줘야 했고, (Gradle Plugin)
  • 이 과정에서 테스트 코드에 추가적인 작업이 필요했기에 사용할 수 있는 코드가 필요했다. (라이브러리)

자, 그렇다면 개발 단계를 조금만 더 살펴보자.

라이브러리 개발 - JUnit5 분해하기

모든 코드를 분석할 건 아니지만, (사실 별거 없기도 하고) 중요한 핵심 로직에 대해서 언급하고 설명하고자 한다.

테스트 구조 잡기

백준 문제 풀이는 다음과 같이 진행된다.

  • 입력을 받는다.
  • 로직을 처리한다.
  • 결과를 출력한다.

결국 입력값이 어떻게 되냐에 따라 출력값이 달라질텐데, 이를 테스트 코드에서 어떻게 검증할 수 있을까? 일반적인 유닛 테스트와 달리 어떤 파라미터 값을 넣어서 함수를 호출하는 것이 아니기 때문에, 다소 어려울 수 있을 것이다.

하지만, 의외로 어렵지 않다. 잠깐 JUnit의 몇몇 어노테이션을 살펴보자.

  • @BeforeAll: 해당 테스트 클래스의 모든 테스트가 시작되기 전, 실행되는 메서드
  • @BeforeEach: 각각의 테스트가 실행되기 전, 실행되는 메서드
  • @AfterAll: 해당 테스트 클래스의 모든 테스트가 수행된 후, 실행되는 메서드
  • @AfterEach: 각각의 테스트가 실행된 후, 실행되는 메서드

따라서, 테스트가 수행되는 LifeCycle은 다음과 같다.

(BeforeAll 실행) ->
(테스트 #1에 대한 BeforeEach 실행) -> (테스트 #1 실행) -> (테스트 #1에 대한 AfterEach 실행) ->
(테스트 #2에 대한 BeforeEach 실행) -> (테스트 #2 실행) -> (테스트 #2에 대한 AfterEach 실행) ->
(AfterAll 실행)

자세한 이야기를 하기 전, 결론부터 말하자면 BeforeEach와 AfterAll 에서 stdin/stdout 을 바꿔치기 함으로써 테스트를 구현할 수 있었다.

테스트 메서드 그리기

JDK의 System.java 를 열어보면, 다음과 같은 메서드가 있다.

private static native void setIn0(InputStream in);
private static native void setOut0(PrintStream out);
private static native void setErr0(PrintStream err);

native 메서드 이기 때문에 실 구현체는 JDK와 아키텍쳐에 따라 다를 것이다. 다만 해당 메서드를 호출하고 있는 다른 메서드의 로직/주석을 통해 확인해보면, 다음과 같은 내용을 정리할 수 있다.

  • System 안에는 public static 인스턴스인 in, out, err 가 존재한다.
  • 처음 System 객체가 생성되는 시점엔, null 값으로 주어진다.
  • thread initialize 작업이 완료된 후 내부적으로 initPhase1() 이라는 메서드가 호출된다.
    • 해당 메서드 내부에서 in, out, err 이 FileDesciptor 의 stdin, stdout, stderr 로 덮어 씌여진다.
    • 참고로 해당 FileDescriptor 는 하단과 같이 정의되어 있다.
    /**
* A handle to the standard input stream. Usually, this file
* descriptor is not used directly, but rather via the input stream
* known as {@code System.in}.
*
* @see java.lang.System#in
*/
public static final FileDescriptor in = new FileDescriptor(0);

/**
* A handle to the standard output stream. Usually, this file
* descriptor is not used directly, but rather via the output stream
* known as {@code System.out}.
* @see java.lang.System#out
*/
public static final FileDescriptor out = new FileDescriptor(1);

/**
* A handle to the standard error stream. Usually, this file
* descriptor is not used directly, but rather via the output stream
* known as {@code System.err}.
*
* @see java.lang.System#err
*/
public static final FileDescriptor err = new FileDescriptor(2);
  • 이후 필요할 시 in, out, err는 setter 메서드로 덮어 씌워버릴 수 있다.

그렇다면 Global 하게 입/출력 결과를 홀딩할 수 있는 어떤 객체가 있다면,

  • 테스트 전에 System.in, System.out을 바꿔치기 한다.
    • System.in 을 주어진 파일에 대한 데이터를 읽도록 바꿔치기 하고, System.out은 결과를 String 형태로 꺼낼 수 있도록 바꿔치기 한다.
  • 테스트를 수행한다.
  • 테스트 이후에 System.in, System.out을 원상복구 한다.

잠깐, 그렇다면 언제 결과를 검증해야 할까? 이 질문에 대한 답을 하기 전, 역으로 질문 하나를 해보겠다.

상술한 테스트의 Life-Cycle 에서, Junit5 가 인식하는 테스트 하나의 범위는 어디까지일까?

정답은 BeforeEach ~ AfterEach 까지의 모든 구간이다. 즉, AfterEach 에서 테스트 결과를 비교해도, 정상적으로 해당 테스트로 인식한다 는 것이다. 따라서 다시 한 번 테스트 구조를 작성하면,

  • @BeforeEach 에서 System.in, System.out을 바꿔치기 한다.
  • 테스트를 수행한다.
  • @AfterEach 에서 테스트 이후에 System.in, System.out을 원상복구 하고, 바꿔치기 했던 OutputStream 에 기록된 문자열을 꺼내서 원하는 결과와 비교한다.

@ExtendWith 에 들어갈 친구 직접 만들기

위에서 @BeforeEach@AfterEach를 사용하면 테스트 구조를 잡을 수 있다고 했으나, 사실 여전히 해결되지 않은 문제가 있다.

  • 테스트케이스의 수가 변동될 수 있는데, 그렇다면 테스트케이스 하나에 대응하는 테스트를 일일히 만들어야 하는가?
  • 맞는 테스트케이스 폴더를 가져오기 위해선 문제 번호를 알아내야 하는데, 이 정보는 어떻게 가져올 수 있는가?

사실 하나의 테스트 코드로 일부 파라미터만 변경하여 테스트를 돌리는 방법은 이미 존재한다. (@ParameterizedTest 어노테이션을 사용하면 되는데, 자세한 것은 https://www.baeldung.com/parameterized-tests-junit-5 을 참고하자.)

다만... 다소 어려운 이유로 @ParametrizedTest를 사용할 수 없었기에, (간단하게 설명하면, @BeforeEach 에서 파라미터 정보를 가져올 수 없기에, 몇 번 테스트케이스 파일을 가져올지 판단할 수 없다는 문제가 있었다.) 다른 방법을 사용할 필요가 있었다. 또한, 가능하면 @BeforeEach@AfterEach 를 감출 수 있다면, 유저 입장에서의 테스트 코드가 훨씬 깔끔해질 것 같기도 했다.

Mockito를 사용한 적이 있다면, 이 코드가 매우 익숙할 것이다.

@ExtendWith(MockitoExtension.class)
public class TestClass {

}

@ExtendWith에는 Extension 을 상속하는 여러 인터페이스를 구현한 클래스를 넣을 수 있는데, 어떤 인터페이스가 있는지 확인해보자.

  • BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback: @BeforeAll, @BeforeEach, @AfterAll, @AfterEach 메서드를 클래스에 정의하는 것이 아닌, 내부적으로 사용할 수 있도록 하기 위한 인터페이스 (구현한 클래스를 @ExtendWith 에 넣을 경우, 해당 콜백을 통해 등록한 메서드가 정해진 Life-Cycle 에 실행된다.)
  • BeforeTestExecutionCallback, AfterTestExecutionCallback: 상단의 네 메서드와 다르게, 유저가 정의한 @BeforeEach@AfterEach 와 별도로 정의하고 싶은 것이 있을 때 사용하는 인터페이스
    • 정확히 말하자면, BeforeTestExecutionCallback@BeforeEach 이후에, AfterTestExecutionCallback@AfterEach 이전에 호출된다.
  • ExecutionCondition: 특정 테스트의 실행 여부를 결정할 때 사용하는 인터페이스로, 리턴값에 따라 @Disabled 어노테이션과 비슷한 역할을 할 수 있다.
  • InvocationInterceptor: 테스트 Life-Cycle 과정 중간 과정을 Intercept 하여, 다양한 추가 작업을 하도록 하는 인터페이스.
  • LifecycleMethodExecutionExceptionHandler, TestExecutionExceptionHandler: 테스트 도중 발생한 Exception 을 처리하는 방법을 정의하도록 하는 인터페이스
    • 전자는 @BeforeAll, @BeforeEach, @AfterAll, @AfterEach 에 대하여, 후자는 테스트 실행 과정에 대해 정의한다.
  • TestInstanceFactory, TestInstancePostProcessor, TestInstancePreConstrctCallback, TestInstancePreDestroyCallback: 테스트의 생명주기를 정의하도록 하는 TestInstance 를 동적으로 생성/관리 하도록 하는 인터페이스
  • ParameterResolver: 테스트에 파라미터가 주어지는 경우, 해당 값을 처리하도록 하는 인터페이스
  • TestWatcher: 테스트 결과를 처리하도록 하는 인터페이스
  • TestTemplateInvocationContextProvider: @TestTemplate 으로 정의된 테스트 템플릿에 대해, 실행 환경을 정의하도록 하는 인터페이스

이외에도 다양한 구현체가 있으므로, 더 자세한 것은 공식문서를 살펴보자.

동적인 테스트를 만들기 위해선, @ParametrizedTest를 쓸 수도 있지만, 상술했듯이 @BeforeEach 에서 테스트의 Context를 읽을 수 없다는 문제가 있었다. 따라서, @TestTemplate 으로 기본 틀을 만들고, 내부적으로 TestTemplateInvocationContextProvider를 구현하여 테스트를 동적으로 생성하도록 변경했다.

(참고로, @DynamicTest 등의 방법도 존재하지만, 이 경우에는 우리가 통제할 수 없는 요소가 너무 많아 채택하지 않았다.)

이런식으로 최대한 내부를 감추고, 추상화를 최대한 하게 되면, 다음과 같은 테스트 코드가 만들어진다.

@BojTest(problemNumber = 1000)
@ExtendWith(BojTestExtension.class)
public class Boj1000Test {
@TestTemplate
@DisplayName("BOJ 1000")
@Timeout(value = 5000L, unit = TimeUnit.MILLISECONDS)
void test() throws Exception {
Main.main(new String[0]);
}
}

내부 코드를 일일히 설명하는 것은 비효율적이므로, 간단하게 동작 과정을 설명하면 다음과 같다.

  • @BojTest 의 problemNumber 파라미터를 읽어 문제 번호를 가져온다.
  • resources/testcase/p{problemNumber} 폴더에 존재하는 파일의 목록을 가져오고, 이를 통해 테스트의 수를 결정한다.
  • 각각의 테스트 마다 상술한 방식으로 @BeforeEach@AfterEach를 구현한다. 이 때, 이 둘은 callback 형태로 구성되어 있으므로 내부적으로 변수를 가질 수 있고, 이에 객체를 생성할 때 context를 멤버 변수로 주입하는 방식으로 호출하는 테스트케이스의 정보를 가져올 수 있다.

결국 이로 인해 사용자는 테스트 코드를 수정하지 않아도 테스트케이스 파일에 따라 자연스럽게 테스트의 수를 조절할 수 있고, 테스트 파일을 생성하는 과정에서도 특별한 로직 없이 문제 번호와 시간 제한만 있으면 쉽게 테스트 파일을 생성할 수 있게 되었다.

Gradle Plugin 개발 - Gradle 조금 더 살펴보기

다음은 Gradle Plugin 으로 넘어가자. 사실 Gradle Plugin는 깊게 설명할 부분이 없어서, 가볍게 설명하고 넘어가려고 한다.

상술했듯이 Gradle Plugin 은 어떤 일련의 행동을 수행하는 도구라고 말할 수 있는데, 어쨌거나 Gradle에 종속적인 만큼 해당 프로젝트에 대한 메타데이터 정보를 모두 갖고 있다는 특징이 있다. 그렇기에 Plugin 을 통해 문제 파일을 생성하기 위한 메타데이터를 어렵지 않게 추출할 수 있었다.

Plugin 의 구조는 다음과 같다. (Kotlin 코드라 Java 유저는 당황할 수 있지만, Java로도 작성할 수 있으니 너무 걱정하지 말자.)

class BojBootPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.tasks.register("boj", BojTask::class.java) { task ->
task.group = "boj"
task.description = "test"
}
}
}

abstract class BojTask : DefaultTask() {
@TaskAction
fun generateFiles() {
// Do something...
}
}
  • Plugin 을 통해 해당 Plugin 의 기본 정보를 정의한다.
  • Task 를 통해 수행하는 일련의 작업을 정의한다.
    • DefaultTaskTask를 상속한 추상 클래스로, 특별한 설정이 필요하지 않다면 위 처럼 사용해도 무방하다.
    • 내부적으로 project 라는 변수를 갖고 있는데, 해당 변수는 프로젝트의 거의 모든 메타데이터를 가지고 있다.
      • 특히 maintest 의 실제 디렉토리 정보를 가져올 수 있기 때문에, 어렵지 않게 파일 추가가 가능했다.

플러그인 Publish 하기

MavenCentral 을 통해 어렵지 않게 Publish 할 수 있는 라이브러리와 달리, Gradle Plugin 은 약간 다른 방법을 통해 배포해야 한다.

  • Gradle Plugin Portal 을 통해 배포한다.
  • 다만, 배포할 때 심사 과정이 포함되며, 심사 기준을 만족하지 못하면 Reject 될 수 있다.
  • 특히, 첫 심사는 상당한 시간이 소요된다. (직접 확인하는 것으로 보인다.)

이번에 개발한 플러그인은 너무 특수 목적이기도 하고, 국내가 아니면 사용하는 경우도 극히 드물 것이므로 심사를 기다리는 건 썩 좋은 선택이 아니라고 봤다.

따라서, MavenCentral 을 통해 일반 라이브러리 처럼 배포를 하되, 받아서 사용하는 사용자가 이걸 플러그인으로 인식할 수 있도록 Gradle 설정을 바꿔줘야 했다.

플러그인 우회해서 사용하기

다시 맨 위에 있던 build.gradle.kts를 살펴보자.

plugins {
id("java")
}

group = "org.vsfe"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("io.github.vsfe:boj-boot-plugin:1.0.3")
}
}

apply(plugin = "io.github.vsfe.boj-boot-plugin")

dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("io.github.vsfe:boj-commons:1.0.2")
}

tasks.test {
useJUnitPlatform()
}

Gradle Plugin Portal 에 등록된 플러그인은 그냥 바로 최상단 plugins 에 사용할 수 있다. 하지만 우리의 boj-boot-plugin 은 다소 복잡한 방법으로 등록되는데, 이를 잠깐만 살펴보자.

  • buildscript는 Gradle이 어떤 방식으로 Build를 수행할지에 대해 정의하는 block 이다.
    • 코드나 스크립트가 들어갈 수 있는데, 우리는 외부에 존재하는 build script 파일을 가져오는 전략을 취한다.
    • 이는 다시 말해서, 플러그인 내부에 존재하는 build.gradle.kts를 로드하는 의미이다.
    • 따라서, 해당 파일을 어디서 다운 받을 수 있는지, (repositories) 어떤 파일인지 (dependencies) 에 대해서만 넘겨주는 것이다.
  • apply 는 script나 plugin 을 등록하기 위해 존재하는 gradle의 함수이다.
    • 정의한 plugin의 id를 넘김으로써, 해당 plugin 을 gradle 프로젝트에 적용되도록 한다.
    • 자세한 정보는 해당 페이지를 참고하자.

결론

사실 처음 개발할 땐 가볍게 생각하고 시작한 것이지만, JUnit 에 대해 저렇게나 깊게 공부할 것이라는 생각은 못했다 (...) Gradle Plugin 또한 블로그에서는 가볍게 적었지만, 개발하는 과정에선 상당한 삽질을 했기에 다소 고생을 했다.

뭐, 그래도 덕분에 재밌었다.

네트워크의 "연결" 과 관련한 주저리

· 약 26분
VSFe
블로그 주인장

네트워크의 "연결" 이라는 키워드를 언급하면, 다들 OSI 7계층을 떠올리고, 더 나아가서 TCP에 대해 떠올릴 수 있을 것이다.

솔직히 말해서 기술면접 아니면 그렇게 깊게 다룰일이 없을 것 같았지만... 막상 회사 업무를 하다보면 이런 네트워크적인 이슈가 발생할 때 마다 심심치 않게 언급되는 원인이 연결 자체인 경우가 많았다.

세미나에서도 한 번 언급하기도 했지만, 많은 분들이 회사에 처음 입사하던 나 처럼 (...) 기술면접용으로만 TCP를 다루고 있어서 이 참에 한 번 깊게 파고 들어보자.

또한, 연결이라는 키워드는 7계층에서도 다룰 수 있는 주제인데, 어떤 관점에서 이를 다룰 수 있을지도 알아보자.

소켓부터 살펴보자.

우선, 소켓이 무엇인지부터 생각해 볼 필요가 있다. (취준생 분들이 소켓을 어떻게 이해하는지 궁금해서 구글링을 해봤더니, 심각한 오개념으로 넘쳐나는 글들이 구글 최상단에 있어서 부득이하게 정리해본다.) 다만 글의 목적은 이게 아니므로, 글을 이해할 수 있고, 오개념을 거를 수 있는 수준으로만 알아보자.

정말 짧게 요약하자면, "통신을 위한 인터페이스" 정도로 요약할 수 있다. 인터넷을 통한 외부 네트워크와의 통신이던, 같은 PC 내 서로 다른 프로세스 간의 통신이던 가리지 않고 지원하는 인터페이스라고 보면 된다. (일반적으로 사용하는 BSD Socket 기준)

리눅스의 철학상 당연히 파일이며, 이에 따라 내부적으로 소켓을 구분할 때도 파일 디스크립터를 사용하고 있다. (이후 포스팅으로 작성하겠지만, 파일이기 때문에 각 프로세스가 맺을 수 있는 연결의 수 또한 파일 정책을 적용 받아 제한될 수 있다.)

엔드포인트의 정보를 포함하고 있기 때문에 하나의 연결에 대해 하나의 소켓이 존재하며, 따라서 하나의 프로세스는 동시에 여러 소켓을 가질 수가 있다. (상술했듯이 이 또한 파일이기 때문에, 하나의 프로세스가 여러 파일을 열 수 있다는 걸 생각하면 쉽게 이해할 수 있다.)

크게 분류하자면 Unix Domain Socket, Network Socket (Internet Socket)으로 구분이 가능한데, 아주 짧게만 알아보자.

Network Socket

우리가 흔히 "소켓" 이라고 말하면 떠올리는 것을 의미한다. (이 글의 이후 섹션에서도 해당 Network Socket을 소켓이라고 지칭할 것이다.)

Network Socket의 주 목적은 "우리가 Transport Layer의 내부 구현을 알지 못해도, API 형태로 원하는 방식으로 통신 흐름을 제어하기 위함" 이다. (사실 내부 구현에 대한 이해 없이 원하는 목적을 달성할 수 있도록 하는 것이 API의 목적인건 다들 알죠?)

우리가 일반적으로 "네트워크 통신" 을 한다고 말한다면 99.999%는 내부적으로 소켓을 사용한다. (어쨌거나 소켓은 API 이기 때문에, NIC에 다이렉트로 때려박는 방식을 사용하면 소켓을 안 쓸 수는 있다. DPDK, PF_RING 등의 대안이 있지만, 이건 Network I/O 의 성능을 극한으로 올리기 위해 개발된 도구이고, 웹서버 개발하는 우리의 입장에선 잊어버리는게 좋다.)

생각 이상으로 많은 블로그가 이 개념을 혼동하다보니 생각보다 많은 오해를 하는데...

  • 이상할 정도로 많은 블로그가 "HTTP 통신" 과 "소켓 통신" 에 대한 비교를 한다.
    • 당연히 HTTP는 7계층 프로토콜이고, 4계층에선 소켓을 쓰기 때문에, 애초에 비교가 불가능하다.
    • WebSocket을 보고 소켓이라고 착각하는 것 같은데, WebSocket은 7계층 프로토콜이고, HTTP와 WebSocket 모두 Socket API를 내부적으로 쓰고 있다.
      • WebSocket 스펙 자체가 실시간 통신을 구현하기 위해 Socket API를 직접 구현하여 사용하던 이전의 문제를 해결하기 위해 고안된거지, 그래봤자 대신 Socket API를 호출하는건 변함이 없다.
    • 다만 프로토콜 스펙의 차이로 인해 HTTP는 데이터 전송 완료 후 TCP 연결을 해제하고, WebSocket은 그렇지 않고 연결을 유지할 뿐이다.
    • 사실 이 섹션을 읽어보고 고민을 해보면 알겠지만 HTTP "통신" 이라는 말도 어색하다.
  • HTTP는 단방향이고 소켓 통신은 양방향이라고 말한다.
    • 이 또한 프로토콜 스펙의 차이이다. 4계층의 문제가 아니라 7계층의 문제다.
  • Stream Socket은 TCP 이고, Datagram Socket은 UDP 이다.
    • Stream Socket을 의미하는 SOCK_STREAM은 바이트 단위의 스트림을 통한 데이터 전송을 하는 소켓인거지, TCP가 아니다. SOCK_DGRAM 또한 마찬가지다.
    • 4계층이 TCP와 UDP만 있는 건 아니기도 하고... TCP/UDP 가 저 형식으로 데이터를 전송하는거지 그 역은 성립하지 않는다.
    • ICMP 프로토콜 등의 다른 네트워크 프로토콜도 데이터그램이나, 스트림을 통한 데이터 전송이 가능하므로 해당 소켓을 사용할 수 있다.

사실 처음 공부하는 분들이 블로그를 통한 학습을 많이 하는데, 이러다보니 블로그에 적힌 오개념을 무비판적으로 받아들이는 문제가 많은 것 같아서 다소 아쉽다.

Unix Domain Socket

반대로, 운영체제 내에서 서로 다른 프로세스들이 통신하기 위해 사용하는 소켓은 Unix Domain Socket으로 부른다.

localhost:8080 와 같은 방식이랑 무슨 차이이죠?

  • localhost:8080은 Network Socket을 사용한다.
    • 다만 Loopback address임이 확인되면, 하위 계층으로 내려보내지 않고 중간에 방향을 틀어 애플리케이션을 찾고 올라가긴 한다.
  • 성능적으로는 Unix Domain Socket이 더 우수하다. (참고)

하지만 예시를 많이 보지 못했기에, 와닿지 않아 보인다. 물론 많이 쓰고 있지만 사용하는 것 자체를 모르고 있으니 와닿지 않는 것이긴 하다. 아쉽게도 해당 포스트는 Unix Domain Socket에 대한 글이 아니기 때문에, 자세한 설명은 생략한다. (~~.sock 과 같은 확장자를 사용하고 있다면, 그 프로그램/서비스는 UDS를 쓸 확률이 굉장히 높다. 예를 들어, docker.sock, gunicorn.sock, mysql.sock이 있다.)

소켓의 State와 함께 살펴보는 4 Way Handshake

우리가 알고 있는 4-Way Handshake는 다음 그림과 같다.

image-20240721201950241

이를 소켓 관점으로 살펴보자.

사실 소켓은 일종의 State를 갖고 있고, 3-Way Handshake와 4-Way Handshake를 수행하는 과정에서 이 State가 변화한다. (실제로 linux 에서는 ss, mac 에서는 netstat 명령으로 존재하는 소켓의 state를 조회할 수 있다.)

따라서 State를 포함하여 위 그림을 다시 그리면 다음과 같다.

image-20240721202119158

실제로 우리가 사용하고 있는 소켓은 다 특정 State를 갖고 있고, 이 State를 확인하는 것 만으로도 생각보다 많은 이야기를 할 수 있다. (이건 3-Way handshake 부분에서도 정말 할 말이 많다.)

Handshake의 유의점

항상 저 State는 이상적인 경우이다. 진짜 문제는, 전달되는 데이터가 유실될 가능성이 항상 존재한다는 점이다.

이제 Client, Server 라는 말을 사용하지 않고 Active Close, Passive Close 라는 용어를 사용하도록 하자.

  • Active Close: 연결을 끊는 주체, 최초 FIN_WAIT 상태를 생성하는 주체이다. 위 그림에서는 Client의 역할이다.
  • Passive Close: 연결 끊는 요청을 받는 대상, ACK 을 받고 CLOSE_WAIT 로 상태를 변경하는 주체이다. 위 그림에서는 Server의 역할이다.

왜 이런 용어를 사용해야 하는 걸까? 그 이유는 Nginx와 Spring Boot (Tomcat) 같은 케이스를 생각해보면 된다. 일반적으로 Nginx 를 앞단에 두고, Spring 서버를 뒷단에 두는데, 이 경우에 무엇이 Client이고 무엇이 Server인지 구분할 수 있을까?

데이터 전달에 실패하여 플래그가 전달되지 않은 경우, 상당히 문제가 될 수 있다.

  • CLOSE_WAIT 에서 ACK를 전달하지 않거나, 전달 실패한 경우에는 다소 위험하다.
    • CLOSE_WAIT를 강제로 제거 하는 방법은 애플리케이션 강제 종료 및 네트워크 재시작 말고는 존재하지 않는다. 애초에 이 상태로 멈춰 있는 케이스가 발견되면 틀림없이 애플리케이션이나 프레임워크에 심각한 버그가 존재하는 것이니, 빠르게 찾아내야 한다.
  • FIN_WAITTIME_WAIT 에서 비슷한 문제가 발생한 경우에는 그래도 다소 괜찮은 편이다.
    • 어쨌거나 둘 다 timeout 이 존재하기 때문에, 병목이 발생하더라도 어쨌거나 자연 해결이 되긴 한다.
    • 극단적으로 활용이 필요하다면, tcp_tw_reuse 커널 파라미터를 조정하면 TIME_WAIT 소켓 재사용이 가능하다.

Passive Close 측면에서 FIN_WAITTIME_WAIT 는 중요한 이슈가 아니다. (일반적인) 클라이언트 소켓은 내부 포트를 명시해야 하지만, (일반적인) 서버 소켓은 포트가 고정되어 있기 때문에 (웹서버를 생각해보면, LB 또는 특수 목적으로 포트를 분리하는게 아닌 이상 1개로 매핑하고 있으니) 소켓의 수가 많아진다고 포트가 고갈되어 문제가 발생하는 일은 없기 때문이다. (다만, 너무 소켓이 많아지면 open 가능한 file desciptor 의 컷에 걸리지만, 이건 수정하면 된다.)

다만, 서버가 Active Closer 역할을 하는 순간 문제가 좀 꼬일 수 있다. 상술한 Nginx와 Spring (Tomcat) 과의 연결 케이스에서 발생할 수 있는 케이스다.

그래도 걱정할 필요가 없는게, 기본 설정을 바꾸지 않는다면 어지간해선 해당 문제가 발생하지 않으므로 안심해도 된다.

좀 더 찾아보기

원래는 좀 더 하고 싶은 이야기가 많았지만, 하나의 포스트에 담기는 너무나도 내용이 방대해 질 것 같았다. 따라서 이후 내용을 이해할 수 있을 정도로만 설명했으며, 만약 좀 더 관심이 있다면, 그러한 분들을 위해 좋은 글과 책을 소개하고 간다.

Timeout 살펴보기

Java에서 외부 요청을 보낼 때, 어떻게 네트워크 연결을 수행할까?

이런 내용에 대한 깊은 지식이 있지 않더라도, 이런 외부 네트워크 통신 또한 I/O의 일종이기에 해당 스레드가 "일반적으로는" Blocking 된다는 것은 다들 알고 있을 것이다. (Selector를 사용하면 Non-Blocking I/O가 가능하다는 것을 이전 세미나에서 다루긴 했고, 실제로 Netty 등을 사용하여 Non-Blocking I/O 를 사용하는 경우도 많다.)

그리고 Blocking 여부와는 상관없이, 일반적인 사용자의 요청을 처리하는 과정에선 요청의 응답값을 필요로 하는 경우도 많다보니, Non-Blocking 이라고 하지만 결국에는 결과를 기다려야 하는 케이스가 많을 것이다.

결국은 잘못된 연결을 오래 붙잡고 있으면 이득될 부분이 없기도 하고, 상황에 따라 장애가 전파되는 상황 까지 도달할 수 있으므로 적절하게 연결을 끊을 필요가 있다. 그렇기 때문에, 일반적으로 외부 연결을 시도할 때는 이에 대응하는 Timeout 시간을 지정한다.

  • ConnectionTimeout: TCP Handshake 과정에서의 timeout
  • readTimeout: 형성된 세션을 통해 데이터를 주고 받는 과정에서의 timeout (다만, 전체 통신 시간이 아닌, 특정 데이터 전송 시간이라고 보면 좋다.)

물론 모두가 저런 이름을 사용하지 않는 경우도 있다.

  • JDBC Driver 계열은 (ex. MySQL JDBC Driver) 대부분 readTimeout 이 존재하지 않는다. 다만 SocketTimeout이 존재하는데, 일부 드라이버가 SocketTimeout 의 내부 구현을 readTimeout 과 유사하게 사용하고 있다.
    • 추가적으로, 애플리케이션 (ex. mybatis, spring) 은 statementTimeout이 따로 존재한다. 이는 쿼리의 수행시간에 대한 timeout으로, 데이터 전송 시간이 포함된 socketTimeout 보다 작게 잡는 것이 좋다.
    • 우리가 사용하는 쿼리의 목적에 따라 statementTimeout 을 다르게 잡는 것도 좋다.

참고를 위해서, 간단한 예시를 남기고 간다. 아직까지 connectionString 에 timeout 을 지정해보지 않았다면, 이 참에 적극적으로 고려해보자.

mongodb://db.asdf.com:27017/database?connectTimeoutMS=3000&socketTimeoutMS=5000&minPoolSize=64&maxPoolSize=256&waitQueueTimeoutMS=1000&w=1&wtimeoutMS=3000&retryWrites=true&readPreference=primary&readConcernLevel=local

Timeout 과 관련한 정보가 더 필요하다면, 다음 글을 참조하면 좋다.

Case Study

Stale Connection

일반적인 스프링 서버에서는 연결을 매번 생성하지 않고, Connection Pool을 생성하고 해당 Pool 안에 수많은 Connection 을 포함하는 방식으로 연결을 구성한다.

그런데 상대방 (ex. DBMS) 이 연결을 끊어버렸다면? 또는 DBMS 에러에 의해 연결 자체가 의미가 없어진다면? 정상적인 종료가 이뤄지지 않았다면, 당연히 그 연결은 의미가 없어진다. 이 과정에서 이걸 어떻게 처리하냐가 중요한 이슈가 될 수 있다.

  • 해당 연결만 문제라면 다른 Connection 을 가져와서 처리하면 된다.
  • 다만, DB 전체가 문제가 생겼다면 Connection 이 전체적으로 의미가 없어진다. 이 경우에는 빠르게 에러 처리를 하고, 연결이 가능할 때 다시 Connection Pool을 채워줘야 한다.

만약 Connection 을 가져오지 않고 그냥 에러를 띄워버린다면, 서비스에 영향이 갈 정도로 에러가 자주 발생할 수 있는 위험이 존재한다. 따라서, 거의 대부분의 Connection Pool은 이러한 구조를 띄고 있다.

이와 관련하여 더 많은 정보가 필요하다면, 다음 글을 살짝 읽어보면 좋다.

Proxy/Load Balancer

이 부분을 이해하려면 TCP Keepalive 에 대한 이해가 있어야 합니다. (HTTP Keepalive 와 헷갈리면 안 됩니다!)

참고: https://www.brainbackdoor.com/network/tcp-keep-alive

트래픽이 많아지거나, 클라우드 환경으로 이어지면서 클라이언트가 서버로 직접 연결하는 것이 아닌, 프록시나 로드밸런서를 통해 Indirect 하게 연결을 하는 케이스가 많아지고 있다.

데이터를 전송하는 케이스를 생각해보자.

  • 클라이언트는 서버로 요청을 하기 위해, 주어진 IP로 요청을 보낸다.
  • IP는 LB를 향해 있고, LB를 통해 요청을 보낸다.
  • LB는 요청을 받아 서버로 전달한다.

이 때, 서버의 데이터는 어떻게 클라이언트에게 전달되어야 할까?

  • 서버 -> 로드밸런서 -> 클라이언트로 전달되어야 하는가?
  • 아니면, 서버 -> 클라이언트로 전달되어야 하는가?

당연하겠지만, 일반적으로 요청보다는 응답의 크기가 훨씬 큰 편이다. (사용자는 HTTP 요청 하나만 보내지만, 응답은 거대한 이미지라고 가정 해보자. 답이 뻔하다!) LB가 여러 서버에서 전달되는 거대한 응답들을 다 받아야 하는가?

그렇기에, 생각보다 많은 케이스에서 요청은 클라이언트 -> LB -> 서버로 향하지만, 응답은 서버 -> 클라이언트 로 전달되는 경우가 많다. 우리는 이를 DSR (Direct Server Routing) 이라고 호칭한다.

자, 이제부터 문제를 잘 살펴보자.

  • 로드밸런서나 프록시는 일반적으로 반복되는 사용자의 요청을 동일한 서버로 매핑하려고 한다. 그렇기에, 세션 테이블을 만들어 클라이언트와 서버간의 매핑을 저장하고 있다.
    • 이는 Idle timeout으로 관리되며, 해당 시간 동안 클라이언트에게 요청이 오지 않을 시 테이블의 정보를 제거한다.
  • 앞에서 우리는 응답이 서버 -> 클라이언트에게 전달된다고 했다. 그 말뜻은...
    • TCP 3-Way Handshake가 진행되면, 처음에는 클라이언트 -> LB -> 서버와 같은 식으로 SYN이 보내진다.
    • 이후 서버 -> 클라이언트 식으로 SYN + ACK 가 생성될 것이다.
  • TCP Keepalive 에 의해, 클라이언트와 서버가 Handshake가 이루어지면 연결이 끊어지지 않고 지속적으로 유지된다.
  • 이 과정에서, TCP Keepalive 에 의해 연결이 끊어지지 않았는데 IdleTimeout에 도달해서 세션 테이블 데이터가 제거되었다면??
    • 클라이언트는 해당 서버 접속이 필요해서 요청을 보냈는데, LB의 세션 테이블에는 데이터가 존재하지 않아 새롭게 매핑을 진행한다.
      • 이 때, 사실상 랜덤으로 목적지 서버가 결정되므로, 상황에 따라 처음 연결한 서버가 아닌 다른 서버로 매핑될 수 있다.
    • 이 경우, 할당된 서버는 Handshake 없이 갑자기 요청이 들어왔으므로 요청을 거부하고, (이에 따라 RST 패킷 전송) 결국 새롭게 연결을 생성해야 한다.
    • 이 모든 과정이 애플리케이션 입장에선 Connection 수립 과정이므로, 클라이언트는 Connection Timeout을 띄우고 오류를 발생시킬 수 있다.

그렇기 때문에, 중간에 프록시를 띄우거나 LB를 띄우는 경우, 클라이언트나 프록시/LB 의 timeout 설정을 다소 신중하게 잡을 필요가 있다.

  • nginx 는 TCP keepalive, HTTP keepalive 에 대한 설정을 제공하고 있다. 대상 서버가 nginx를 사용하고 있다면, 일차적으로 해당 설정 변경을 고려해야 한다.
  • 다양한 MQ (RabbitMQ, Kafka 등) 또한 기본적으로 TCP Keepalive 에 대한 설정을 포함하고 있다. 필요하다면, 해당 설정값을 변경 할 수 있다.

결론

솔직히 위의 내용들을 취준생 레벨에서 고민하고, 사용하는 것은 매우 어렵다. (실제로 이런 이슈가 문제가 되는 건 대부분 1~2 초간의 타이밍에서 발생하는 문제라, 꾸준히 요청이 들어오는 것이 아니면 감지 자체가 어렵기 때문에)

다만, 항상 주장하는 것 처럼 암기식의 CS가 아니라, 문제 해결을 위해 사용될 수 있다는 것을 이해할 수만 있다면, 충분할 것 같다.

Java의 동등성에 대한 고찰

· 약 21분
VSFe
블로그 주인장

개론

우연히 취준방에서 이런 질문을 받았다.

옛날에 이 방에서 a.equals(b)말고도 Objects.equals(a, b) 쓴다는 내용을 봤던 적이 있었는데 Objects.equals를 쓰는 이유가 단순히 null에 대해 안전하기 때문에 사용하는 걸까요?? 가독성은 전자가 더 좋다고 느껴집니다.

단순히 Null Safety를 위해서 저렇게 사용한다! 라고 이야기 할 수도 있지만, 사실 동등성을 비교하는데 있어서 고려해야 할 것은 생각보다 많다. (단순히 NPE를 방지하는 것이 답이 아닌 경우도 있고...)

원래는 질문에 답을 하기 위해 간단하게 작성해보려 했으나, 작성하다보니 자연스럽게 equals() 를 포함한 동등성 자체에 대한 고민을 주욱 작성하게 되었다.

기본적인 equals() 설계 방식

일단은 equals()를 좀 살펴보자. Java의 equals() 에 대한 명세에는 다음과 같은 내용이 포함된다.

Indicates whether some other object is "equal to" this one. The equals method implements an equivalence relation on non-null object references:

  • It is reflexive: for any non-null reference value x, x. equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x. equals(y) should return true if and only if y. equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x. equals(y) returns true and y. equals(z) returns true, then x. equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x. equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x. equals(null) should return false.

한글로 간단하게 번역하면 다음과 같다.

  • 반사성 (Reflexive): null이 아닌 참조값 x에 대해, x.equals(x) 는 참이다.
  • 대칭성 (Symmetric): null이 아닌 참조값 x, y에 대해, x.equals(y)가 true 이면 y.equals(x)도 참이다.
  • 추이성 (Transitivity): null이 아닌 참조값 x, y, z에 대해, x.equals(y)가 참이고 y.equals(z) 가 참이면 x.equals(z) 도 참이다.
  • 일관성 (Consistency): null이 아닌 참조값 x, y에 대해, x.equals(y) 의 값은 언제나 참이거나 거짓이다. (즉, 결과가 변하지 않는다.)
  • Not Null: null이 아닌 참조값 x에 대해, x.equals(null) 은 거짓이다.

생각보다 단순하다고 느껴질 수 있는 원칙들이지만 막상 구현하려고 하다보면 생각보다 원칙이 깨지기가 정말 쉽다. (Effective Java의 Item 10만 참고해도 깨질 수 있는 사례가 정말 많이 언급된다.)

물론 우리가 이 글에서 다루고자 하는 것은 동등성 이지, equals() 에 대한 설명이 아니므로, 우선 간단하게 넘어가도록 하자.

String의 동등성

Primitive Type이 아니지만, 마치 Primitive Type 처럼 활용하는 String을 살펴보자.

어떻게 동등성을 파악해야 하는가?

사실 이 글을 보는 사람들이라면 == 을 사용해서 문자열을 비교하는 경우는 없겠지만, Literal 간의 비교는 == 가 통하기 때문에, 다소 당황하는 경우가 많다.

JVM 구조에 대한 글이 아니므로 (추후 작성할 예정이 있지만, 여기에서는 다루지 않는다.) 간단하게 설명하자면 JVM 내부에 존재하는 String Pool에 의해 가능한 것이다.

구조상 형태가 절대로 변할 수 없기 때문에, 특정 문자열을 JVM 내부에 있는 String Pool 안에 삽입하게 되면 이 문자열들은 == 로 비교가 가능해진다. (== 은 원본에 대한 비교로, 참조형 데이터의 경우 데이터의 원본 주소를 비교한다. 만약 비교하는 문자열이 String Pool 안에 존재한다면, 동일한 문자열은 객체가 최대 1개이므로 같을 수 밖에 없다.)

결과적으로 놓고만 보면 성능이 .equals()에 비해 우수한게 맞다. (다만 삽입 자체의 오버헤드는 상당하므로, 실질적으로는 더 느리다고 봐야한다.)

그리고 String.intern() 을 사용하여 Pool에 해당 문자열을 삽입해 버리는 방식이 존재한다. 하지만 가능하면 사용하지 않는 것이 좋다. 일차적으로 Pool에 삽입하는 것 자체가 오버헤드이며, String Pool의 구조상 반복적으로 참조되어야 의미가 있는 만큼 모든 문자열을 String Pool에 삽입하는 것은 좋은 습관은 아니다.

Literal 이나 상수 형태로 지정된 문자열은 구조상 자주 참조될 수 밖에 없는 문자열이므로, 해당 문자열들은 JVM이 자체적으로 String Pool에 적재한다.

물론 우리가 Literal 끼리 비교할 일은 없으므로, (처음 자바를 공부하는게 아닌 이상) == 는 깔끔하게 잊어버리자.

Literal의 equals()에 대하여

다음과 같은 코드가 있다고 해보자.

String str1 = "Hello";
if ("Hello".equals(str1)) {
// logic...
}

당연하지만, "Hello".equals(str1)) 에서 str1과 "Hello" 라는 문자열은 서로 위치를 바꾸어도 문제 없이 동작한다.

다만, str1 = null 이라면, NPE가 발생하여 코드가 정상적으로 돌아가지 않을 것이다. 이로 인해 몇몇 사람들은 반드시 Literal을 앞에 두는 방식으로 코드를 작성해야 한다고 주장한다.

하지만... 이 부분에 대해서 단순히 null이 발생하니 막아야 한다! 라고 주장하기엔 고민해야 할 사항이 조금 더 있다.

우리는 이런 표기법을 Yoda Condition 으로 호칭하고 있는데, (https://en.wikipedia.org/wiki/Yoda_conditions) 이 표기법의 효용성에 대해서는 아주 많은 갑론을박이 존재한다.

Java의 관점에서 생각하면, (C++ 관점이면 오묘한 성능 차이도 발생하고 그렇지만, 여기서는 다루지 않는다.) 개인적으로는 해당 표기법을 불호하는 편이다.

꼭 Null-Safety 여야 하는가?

Yoda Condition의 장점을 검색하면, "NPE를 방지해준다" 라는 말이 항상 존재한다. 그런데 기계적으로 NPE를 방지해야 하는게 과연 맞는가?

다음과 같은 코드를 살펴보자.

String data = null;

if (!"true".equals(data)) {
int size = data.size();
}

위 코드에서 "true".equals(data) 에서 NPE를 회피한다고 시도해도, 결과적으로 아래에서 걸리게 된다.

이처럼, NPE를 "숨기는" 행동은 오히려 문제를 가중시킬 수 있다.

들어오는 문자열이 nullable 하다는 것이 보장되어 있고, 이것이 자연스러운 로직이라면 문제가 없을 수 있지만, 그렇지 않다면 (즉, 버그등의 문제로 인해 nullable 하지 않아야 하는 상황에서 null이 인입된 케이스라면) 이는 문제를 감추는 것 밖에 되지 않는다.

위 코드와 같이 바로 아랫줄에서 문제가 발생하는 경우가 아닌, 실제로 들어와선 안되는 데이터가 인입되어 DB까지 전달되었다고 가정 해보자. 이렇게 될 경우 우리는 어디에서 문제가 발생했기에 저런 데이터가 인입되었는지 판단하기 매우 어려워진다.

문제의 가능성이 있다면 감추는 것은 좋지 않다. (최소한 logging 등으로 기록이라도 해야 한다.) 특히나 코드의 규모가 커지게 될 경우 문제가 발생했을 때 그 원인을 파악하는 난이도가 더욱 올라갈 수 있기 때문에, 에러는 최대한 빨리 Catch 할 수 있어야 한다.

이 관점으로 위 코드를 다시 보자. 과연 "안전한" 코드라고 할 수 있을까?

가능하면, Null-Safety 여부는 상황에 따라 달라질 수 있어야 하고, 이러한 정책을 코드만 읽어도 이해할 수 있어야 한다. 예를 들어,

// case 1
if (str != null && str.equals(str2)) {

}

// case 2
if (str.equals(str2)) {

}

만약 어떤 코드에서 case 1과 case 2의 코드가 병립되어 있다고 해보자. 두 케이스 중 하나는 nullable 하고, 하나는 엄격하게 not null 이어야 한다고 할 때, 어떤 케이스가 어떤 상황에 해당하는지 이해할 수 있을 것이다.

(Jarkarta Validation 의 경우, 메서드 파라미터에도 적용될 수 있다. nullable을 사전에 체크하고 싶다면 관련 어노테이션을 사용하는 것도 좋은 방법이 될 수 있을 것이다.)

의미론적 혼선

한가지 재밌는 이야기지만, 영어권 화자들은 다른 방향성의 의미론적 혼선을 제기한다. str.equals("Hello")라는 코드를 영어 의미대로 (즉, str equals to "Hello" 와 같은 방식으로) 읽는 사람들이 많다보니, 저 둘의 위치가 뒤집히게 되면 그 자체로 가독성이 떨어진다는 주장이다. (우리는 다소 안 와닿을 수 있지만)

Collection의 동등성

우리가 원하는 동등성이 무엇인가?

개발을 하다보면, List 두 개의 동등성을 체크해야 하는 케이스가 생각보다 많다. 다만 이런 케이스의 경우, 우리는 .equals() 를 사용하지 않고 다른 방식으로 동등성을 체크해야 하는 경우도 생각보다 많다. List를 단순한 데이터의 container 용도로 사용하는 역할이 많다보니, 비교 연산을 수행해야 할 시점에는 데이터의 저장 순서가 중요하지 않게 되는 상황도 많기 때문이다.

하지만 순서가 없다고 해서 Set을 사용할 수는 없는 노릇이다. (중복된 데이터의 존재 가능성이 있으므로) 그러다보니, 두 List를 정렬하여 그 결과값을 비교하는 경우도 있는 등 기본적으로 제공되는 equals() 를 직접적으로 사용하지 않는 케이스도 많다.

대표적인 테스트 라이브러리인 assertJ에서도 containsExactlyInAnyOrder() 같은 검증 메서드가 존재하는 것 처럼, List의 데이터를 검증하는 상황에선 순서를 보장해야 하는지/그렇지 않은지를 충분히 고민하고 비교 로직/검증 로직을 작성해야 한다.

이처럼, Collection의 동등성을 비교해야 할 때는 과연 무조건적인 equals() 를 사용하는게 맞는지 생각해 볼 필요가 있다.

덤으로, 재미있는 코드 하나를 살펴보자.

// org.apache.commons.collections4.CollectionUtils.java
public static boolean isEqualCollection(Collection<?> a, Collection<?> b) {
if (a.size() != b.size()) {
return false;
} else {
CardinalityHelper<Object> helper = new CardinalityHelper(a, b);
if (helper.cardinalityA.size() != helper.cardinalityB.size()) {
return false;
} else {
Iterator var3 = helper.cardinalityA.keySet().iterator();

Object obj;
do {
if (!var3.hasNext()) {
return true;
}

obj = var3.next();
} while(helper.freqA(obj) == helper.freqB(obj));

return false;
}
}
}

Apache Commons 라이브러리에 존재하는 isEqualCollection() 메서드로, 이는 Collection의 종류와 상관없이, 동일한 데이터가 존재하는지에 대한 여부를 확인하는 메서드이다. CardinalityHelper 는 HashBag과 유사하게 데이터의 개수를 Counting 해주는 역할을 하기 때문에, Collection 종류와 상관 없이 데이터의 존재여부 자체만 확인할 수 있다.

Interface 구현체의 차이를 알아야 한다

JDK 에 포함되는 기본적인 Collection 의 구현체가 아닌, 서드파티 라이브러리/프레임워크 등에 포함된 Collection 구현체는 우리의 의도대로 동작하지 않는 경우가 매우 많다.

JDK에 포함된 Collection 구현체는 일반적으로 기본 형태만 같으면 .equals() 를 통한 동등비교가 가능하다.

List<String> list = List.of("1", "2", "3");
ArrayList<String> list2 = new ArrayList<String>(list);

if (list2.equals(list)) {
// true
}

다만 우리가 많이 사용하는 Spring 만 봐도 의도대로 동작하지 않는 경우가 많다.

var map1 = new LinkedMultiValueMap<String, String>(); // Spring에 포함된 자료구조
var map2 = new HashMap<String, String>();

map1.add("hi", "hello");
map2.put("hi", "hello");

타입을 감추기 위해 고의적으로 var 키워드를 사용했다.

과연 map1.equals(map2) 가 성립할까? 결론만 말하면 아니다.

사실, LinkedMultiValueHashMap<String, String> 의 원형은 Map<String, List<String>> 이다. 즉, 해당 map을 비교해야 할 필요성이 있다면, 둘 중 하나의 구조를 변형하는 등의 방식을 취해야 한다.

다른 객체들과 달리 Collection 은 상위 인터페이스로 우리가 타입을 작성하여 사용하는 경우가 많으므로, 이러한 상황에 대해서 꼼꼼하게 체크할 필요가 있다.

객체의 동등성

마지막으로 일반적인 객체의 동등성을 살펴보자. 이 케이스에서 동등성을 비교하는 경우는 대게 직접 equals() 를 오버라이딩하는 경우가 많은지라, 앞쪽과 또 약간의 차이가 존재한다.

은근한 함정

우리가 equals() 를 일반 객체에 도입하는 경우는 대부분 DTO/VO 와 같은 여러 필드를 갖고 있는 객체일 것이다. 하지만 항상 이런 경우엔 시간 정보 필드 때문에 생각 이상으로 equals() 를 짜기 어려운 경우도 많다.

특히나 DTO/VO의 규모가 커지면, 필드의 타입이 또 다른 DTO/VO 일 수 있는데, 위와 같은 필드가 들어가는 상황이라면 정말 조심해야 한다. (상위 클래스의 equals() 가 잘못되었다면, 은근히 원인을 못 찾는다...)

굳이 equals() 를 정의할 필요 없이, 일부 로직에서 특정 필드에 대해서만 비교가 필요하다면 Apache Commons가 제공하는 EqualsBuilder 를 사용해도 좋다.

A a1 = new A(1, 2, "hello");
A a2 = new A(1, 2, "world");

boolean isEqual = EqualsBuilder.reflectionEquals(
/** lhs = */ a1,
/** rhs = */ a2,
/** excludeFields = */ "c"
); // true

record A(
int a,
int b,
String c
) {

}

Stream.distinct() (중복 원소 제거) 등을 사용하고 싶다면 어쩔 수 없이 equals() 를 정의해야 하는데, 이를 이용하지 않아도 고차함수등의 기법을 활용할 수도 있다. (참고: https://stackoverflow.com/a/27872086)

Record의 equals()

Java 16 부터 도입된 Record는 우리가 특별하게 equals() 를 정의하지 않아도 알아서 만들어준다는 특성을 갖고 있다.

내부적으로 invokeDynamic 등의 방식으로 equals() 를 호출하기 때문에 내부 구조를 바로 알 수 없지만, 간단한 스펙은 다음과 같이 적혀있다.

The implicitly provided implementation returns true if and only if the argument is an instance of the same record type as this object, and each component of this record is equal to the corresponding component of the argument; otherwise, false is returned. Equality of a component c is determined as follows:

  • If the component is of a reference type, the component is considered equal if and only if Objects.equals(this.c(), r.c() would return true.
  • If the component is of a primitive type, using the corresponding primitive wrapper class PW (the corresponding wrapper class for int is java.lang.Integer, and so on), the component is considered equal if and only if PW.valueOf(this.c()).equals(PW.valueOf(r.c())) would return true.

The implicitly provided implementation conforms to the semantics described above; the implementation may or may not accomplish this by using calls to the particular methods listed.

간단하게 정리하면 다음과 같다.

  • 동일한 record 타입이어야 한다.
  • 특정 필드가 참조형인 경우, Objects.equals(this.c(), r.c()) 가 참이어야 한다.
  • 특정 필드가 Primitive 인 경우, boxing 타입으로 변환하여 equals() 를 사용한다.

결국, timestamp 와 같은 데이터가 record에 들어가게 되면 equals() 사용이 매우 어려워진다고 봐야한다. 결국 이 경우에도 필요에 따라 상술한 방식을 사용하여 새롭게 정의해야 할 수도 있다.

결론

동등성을 판단하기 위해 무조건적으로 어떤 것을 해야한다! 는 존재하지 않는다. 특정 상황/로직에 따라서 고려해야 할 점이 많고, 그렇기에 단순히 어떤 방법을 사용해야 한다라는 왕도는 없다.

상술했던 내용을 숙지하는 것을 넘어, 스스로 활용하기 위해선 결국 개발을 해보면서 많은 고민이 필요할 것으로 보인다. (필자 또한 매번 고민을 하고 있다...)