Embulk 는대용량 ETL 작업을 위해 사용하는 오픈소스 솔루션입니다

특징

  • 플러그인 형태로 여러개의 소스와 타겟을 지원합니다
  • Maven 및 Ruby gem 리포지토리에서 릴리스된 플러그인
  • 스키마 예측 (Schema guessing)
    • 입력 데이터를 보고 자동으로 입력 데이터의 스키마(테이블 구조)를 예측합니다
    • 일일이 설정을 하려면 귀찮은 일인데 자동으로 스키마를 인식해주어서 설정양을 줄여줍니다
  • 빅 데이터 세트를 처리하기 위한 병렬 실행
  • All or Nothing 보장하는 트랜잭션 제어

왜 필요한가?

  • 데이타 분석에 있어서 아키텍쳐적으로 중요한 모듈중의 하나는 여러 서버로 부터 생성되는 데이타를 어떻게 모을 것인가 입니다
  • Embulk는 대량 데이터 로더입니다. databases, storages, file formats, cloud services 등의 유형 간의 데이터 전송을 돕습니다
    • ex: Bigquery, Oracle, MySQL, PostgreSQL, CSV, JSON…

어떤 개발자가 만들기 시작했을까요?

일본회사에서 시작한 오픈소스

 

구조로 표현하면 아래와 같습니다

Embulk 동작 방식
Embulk 구조

 

ETL 이란? (Extract, Transform, Load)

추출(Extract), 변환(Transform), 적재(Load)

예시) calendar라는 테이블에 년/월/일/시/분/초 형태로 가 컬럼이 존재합니다. 이러한 데이터를 사용해서 통계를 내는 어떤 프로그램을 실행하려고 확인 했더니 해당 프로그램은 년월일/시분초 와 같은 컬럼형태를 요구하고 있을 때

아래와 같은 작업을 하는 경우를 ETL이라고 합니다

1. 기존 테이블의 데이터 추출(Extract)

  • 대상이 되는 calendar 테이블에서 년/월/일/시/분/초 형태의 데이터를 전부 추출

2. 추출한 데이터의 변환 (Transform)

  • 추출한 데이터를 요구하는 형태인 년월일/시분초 형태로 변경

3. 추출 및 변환한 데이터의 적재(Load)

  • 변경이 된 데이터를 새로운 테이블에 적재

간단하게 정의 해보자면 “한 곳에 저장된 데이터를 필요에 의해 다른 곳으로 이동하는 것”, "저장된 데이터를 변형하여(요구사항에 맞게) 다른 곳으로이동하는 것" 입니다

출처

ETL 구조

Source - Target 구조

  
소스와 타겟으로 각각의 시스템 단위로 데이터를 이관하고 있습니다

Embulk 로는 이관하는 것이 가능하지만 모든 Job 들을 관리하는 것은 되지 않습니다

이 부분을 보완해주는 툴로는 Digdag 을 사용하고 있습니다

Data Workflow Management Tool을 쓰는 이유는?

데이터를 추출, 가공, 저장 하는 ETL(Extract, Transform, Load)등을 진행하다보면 여러개의 일들이 연결되어 수행되는 경우가 필연적으로 발생됩니다

여러개의 일들이 연결되어 수행 하는 동작하는 동작 흐름(Workflow)을 실행시키기 위해 배치 형태의 테스트를 실행 , 에러 분기에 따른 알림 , 재실행을 시켜주는 도구의 요구사항이 생기게 되었습니다

이러한 이유들로 인해 Data Workflow를 편리하게 실행시키고 관리하는 자동화 도구로 Airflow , Luigi , Digdag , Oozie)들이 등장하게 되었습니다

오픈소스 workflow 관리 도구들

  • 선언형: XML이나 YAML과 같은 서식으로 기술
  • 스크립트형: 예를 들면 파이썬과 같은 스크립트 언어로 기술한다

Digdag 란?

Digdag는 복잡한 작업 파이프라인을 구축, 실행, 예약 및 모니터링하는 데 도움이 되는 간단한 도구입니다.

태스크가 순서대로 또는 병렬로 실행되도록 종속성 해결을 처리합니다.

digdag workflow ui

진짜 중요하다고 생각하는 이유는?

우리가 운영하는 모든 데이터 플로우는 태스크로 지정되어 실행하는 도중 실패할 수 있기 때문에 이를 인식하고 재시도하고 관리하기 위해 필요합니다 즉 Workflow를 필수적으로 모니터링 할 수 있어야 합니다

  • 태스크의 정기적인 스케줄 실행과 결과 통지
  • 태스크 간의 의존 관계 정의와 정해진 순서대로 실행하기
  • 태스크의 실행결과를 영속적으로(필요하다면 DB로) 보관하고 오류 발생시 재실행 하기

그리고 복구를 위해 재실행할 때는 동일 태스크를 여러 번 실행해도 동일한 결과를 만들어야 하기 때문에 상황에 따라 멱등성을 이끌어 내야 합니다

  • 테이블을 삭제한 뒤 다시 생성하는 것 처럼 태스크를 여러번 시도해도 실패하거나 이미 들어간 데이터로 인해 시스템에 문제가 없어야 합니다

필수적으로 대용량 데이터를 다루는 부분이기 때문에 매우 중요하고 절대적으로 동작이 365일동안 무중단으로 장애없이 유지되야 합니다

아래 그림으로 RDS에서 BigQuery로 데이터 이관 구조를 표현할 수 있습니다

Digdag 구조

 

해당 시스템의 구조나 중요도는 설명했으니 이제 본론으로 들어가서 비즈니스 적으로 협업을 하기 위한 방법에 접근해야 합니다

이 시스템은 다양한 데이터를 모두 다루기 때문에 다양한 비즈니스들이 엮여 있습니다

예를 들면 KOP, CRM, WMS, SCM, Campaign, PILS, EDW 등 연관관계 시스템들의 다양한 Database와 하위 시스템의 데이터들을 이관해야 하기 때문에 Embulk 관리자로서는 각각의 비즈니스에 대해 알지 못합니다

이 부분을 쉽게 파악하기 위해 dig 파일이나 embulk를 실행하는 sh 파일이나 yaml 파일에 규칙을 두어 관리하고 있습니다 

프로젝트를 구성하는 디렉토리

  • .gitlab : gitlab 관련 md 문서 파일
  • add-ons : ruby gem 에 올라가지 않은 자체 개발 customizing 된 plugin 프로젝트들
    • embulk-input-redis-restock : input data를 json 형태로 변환
    • embulk-output-redis-expires : output data에 expire time 을 지정할 수 있게 함(내재화 진행 중)
  • digdag-projects
  • digdag-porjects-fnckop
    • digdag 스케줄링 된 work flow 에 대해 지정하는 폴더
    • .dig 파일들이 모여 있는 곳
      • cron 스케줄 시간 지정
      • parallel 동작 지정
      • 동작시킬 sh 파일 지정
  • doc : 메뉴얼 md 문서 파일
  • embulk-projects
    • embulk 로 실행될 shell script 들이 모여 있는 곳
    • 하위에 yml.liquid 파일에 대한 명세를 보관
      • in 폴더: In 쪽 Schema 파일
      • out 폴더: out 쪽 Schema 파일
      • select 폴더: in 쪽 Schema 파일
      • filter 폴더: 가공이 필요한 경우 지정하는 파일 (컬럼단위 변환, 개인정보 관련 masking 컬럼 지정)
  • GCP : GCP 계정 관련 파일
  • lib : 사용하고 있는 Datasource 관련 jar 파일
  • etc
    • docker 파일
    • jenkins 파일

digdag 디렉토리에 비즈니스 명세를 작성

규칙 1. Source To Target 지정

목적 : dig 파일만 보고 해당 작업이 어떤 작업인지 바로 파악할 수 있을 것

digdag 파일에서는 하나의 cron 스케줄로 관리 됩니다

각각의 파일에서는 동일한 시간대의 workflow 가 모이게 되고 같은 방향의 Source / Target 이라도 스케줄에 따라 dig 파일이 구분됩니다

예를 들어 Bigquery To Campaign 이나 Bigquery To Redis 인 경우 시간/일간/월간 스케줄에 따라각각 구분됩니다

하나의 태스크는 테이블 단위로 명령을 수행합니다

명령이 수행되는 파일은 shell script로 작성되어 있습니다

 

embulk-project 디렉토리에서 동작 파일을 작성

규칙 2. Source 에서 연결할 Target Data Source를 1 : N 관계가 되도록 지정

목적: 현재 구성된 Source 쪽과 Target 을 하나의 폴더에서 관리하고 하위 폴더에서 Target 관련 파일을 관리

 

 

Target 디렉토리에서 계정 단위로 파일을 작성

규칙 3. 접속하는 Datasource 계정명으로 sh 파일과 yml 파일을 작성

목적: embulk 관리자는 Database 관리자는 아니기 때문에 어떤 DB 구성을 사용해야 하는지 알 수없어 파일 이름만 보고 바로 알 수 있게 지정

digdag 파일이 어떤 스케줄과 soruce / target 을 알고자 함이라면 어떤 database 구성으로 작업하는 파일인지 또는 상황에 따라 재사용을 할 것인지 바로 알기 위한 목적으로 구성했습니다

 

위 3가지 규칙으로 embulk 의 비즈니스 개발을 설명해 볼 수 있습니다.

  1. Source 와 Target database가 무엇인지 알아야 한다
  2. Source 와 Target 간 어떤 시간 스케줄로 동작할지 알아야 한다
  3. Source 와 Target 이 어떤 connection 으로 연결되는지 알아야 한다
  4. 상황에 따라 in / out / select / where / filter 작업으로 테이블 하위 컬럼 단위까지 조작이 필요한지 알아야 한다

결국 embulk 와 digdag 로 협업하려면 해당 정보들이 필요합니다

위의 내용을 바탕으로 Asana ticket을 만들어 봤습니다

Asana Ticket

Embulk를 운영해보니 오픈소스 기반이고 주로 비즈니스 처리 위주여서 Tool 자체에 대해 직접 코딩을 하거나 하는 부분은 없었습니다 (github에 오픈소스가 있어서 불가능한 부분은 아닙니다)

다만 이렇게 폴더나 파일을 어떻게 관리하지?

해당 비즈니스가 확장되는 부분을 어떻게 표현해야 하지?

이런 문제 해결을 어떻게 해야 하는지 초반 시행착오가 있던 것 같습니다

생각해보니 데이터를 관리하는 프로그래머의 역할과 파일을 관리해야 하는 부분이 별반 차이가 없지 않나? 하는 생각이 드는 것 같습니다

프로그램의 메모리에 접근해서 New로 동적할당을 하거나 List, Set, Map, Dictinary 와 같은 자료구를 만들어서 효율적인 데이터를 뽑아내는 것과 수십 수백개의 연관 파일들을 어떻게 명시하고 관리하는 부분도 프로그래머가 더 연구해야 하는 부분이라는 생각이 들었습니다

Java, Kotlin, C#, C++ 같은 언어를 쓰는 명령형 기반이 아닌 yaml, css, xml 같은 선언형 기반으로 코딩한 경험이 되었다고 보고 있습니다

이런 부분에 있어 더 좋은 방법이 어떤 것이 있고 이런 ETL 또는 데이터 엔지니어링과 관련된 부분은 어떻게 더 잘할 수 있을지 고민해 볼 수 있는 부분이 되어 좋았습니다

 

References

웹 어플리케이션을 설계하다 보면 많은 Entity, Model, Table 들을 마주하게 되고 정의하게 됩니다

DB의 Table에서는 Auto Increment 기능이 있어서 개발자가 신경을 쓰지 않아도 자동 생성되는 식별자가 있습니다

이 녀석은 index 도 만들어지게 되서 검색 해서 사용하는데 문제가 없지만 DB 라는 미들웨어에 종속된 id로 DB 라는 구간을 거쳐야지만 이 값을 쓸 수 있다는 제약이 있습니다

또 숫자형의 식별자를 쓰기 때문에 문자형의 식별이나 인조키가 아닌 자연키로 쓰는 경우에도 제약이 생깁니다

하나의 데이터베이스에 모든 식별자 생성이 몰리게 되면 SPOF(single point of failure) 라는 단일 장애지점이 되어 DB가 멈춤에 따라 식별자 생성도 불가능해 집니다

즉, 식별자 생성을 데이터 생성에 의존하기 보다는 제가 해야 하는 행위(적립/사용/취소/소멸/예약 등) 에 쓰고자 하는 needs 가 있었습니다

CRM의 포인트 시스템에서는 이를 하고자 DB의 식별자와는 별개로 Point Id 를 생성하게 했는데 ksuid 라는 이름의 녀석을 사용했습니다 (2017년 6월 8일 출시)

KSUID는 K-Sortable Unique IDentifier의 약어입니다

생성시의 타임스탬프로 "자연스럽게" 정렬되도록 만들어지기로 한 것으로 UNIX sort 명령을 통해 일련의 KSUID를 실행하면 생성 시간별로 정렬됩다고 합니다

개발 당시에는 정렬이 되는 uuid 라는 요구 조건에 바로 부합되었기 때문에 큰 고민없이 도입하게 되었습니다

전체 E-Kolon 가입자 대상으로 하는 OLO 마일리지 출시를 위해 성능과 부하테스트를 하는 중 동시에 100건의 같은 Request가 왔을때 유일하게 한번만 보장하는 부분을 테스트 할 때 기존에 임직원 50%할인 포인트를 위한 기존의 2개의 포인트 타입과 새로운 타입인 OLO 마일리지간 3개의 포인트간 경합이 생겼습니다

요청하는 비즈니스로만 구분하는 경우는 각각의 클라이언트를 식별할 수 있는 식별자가 없이 막히자 말아야 하는 경우까지 막아 버릴 수 있습니다

사실 이 경우는 clientId + pointType + 주문수량 .. 등등 여러 조합을 통해 문제를 해결할 수 있지만 제가 하고자 하는 것은 지금 문제 해결이 아닌 앞으로 다른 서비스를 만들거나 현재부터 1년 뒤 10년 뒤도 문제가 안되는 해결책이 없을까에 대한 고민이 되었습니다

위의 ksuid 를 사용하면되지 않나? 라고 생각될 수 있지만 모든 비즈니스에 식별자를 만들지는 않아 식별자 생성 없이 유일성을 보장해야 하는 함수 들의 처리도 있었습니다 (DB에는 로직이 없고 거의 대부분이 kotlin 만들어진 로직이었습니다 - 레거시 쪽은 DB에 의존하는 SP, Query 가 많은 방식(이게 무조건 적인 단점은 아니고 일단 성능보다 코드레벨에서의 복잡도 개선이 우선 순위였고 추후 테스트를 거쳐 성능을 위해 디비에 로직이 포함됩니다)

전에 가상 면접 사례로 배우는 대규모 시스템 설계 기초 라는 책을 본 적이 있어 관련 내용을 봤었고 7장 분산 시스템을 위한 유일 ID 생성기 설계라는 파트가 있습니다

여기에 지난번 동시성 이슈 빠르게 처리하기 말미에 BillKey 에 대한 언급을 했었는데 이 책에서는 비슷하게 티켓 서버(Ticket Server) 라는 예시로 설명이 되고 있습니다 하지만 이 또한 SPOF 가 될 수 있습니다

그래서 중요한건 장애에 취약하지 않으면서 유일한 고유 식별자 생성이 되고 다변량으로 존재하는 클라이언 들이 비즈니스간 경합이 되지 않는 방식이 필요합니다 (DB에 저장하지 않아도 비즈니스의 유일성을 보장해주는 Id 이면 됩니다)

이는 가장 먼저 선행 하는 로직이 필요한 키 발급을 미리 받아서 요청 데이터 묶음을 처리 할 수 있으면 됩니다

CRM 을 하나의 플랫폼으로 바라본다면 OAuth2.0 의 프로토콜 방식을 생각해 볼 수 있습니다

OAuth 의 Authorization Code Flow 는 Access Token 을 얻기 위해 code 값을 미리 받아서 이용하고 있습니다 해당 Flow 를 Sequence Diagram 으로 보면

OAuth 2.0의 Authrization Code Grant Type Flow

아래는 PortOne(구: 아임포트) 이라는 곳의 결제 Flow 입니다

https://portone.gitbook.io/docs/auth/guide/def

 

뭔가 Authorization Code 와 위의 결제 키 라는 게 비슷하게 느껴집니다

Kolon Mall(가맹점 중 하나) 과 CRM Point 와 User 의 결제창이라는것도 비슷한 관점이 있는것 같네요

하지만 이와 같은 복잡한 구조를 가지게 되는데 아니 포인트 한번 써먹겠다고 인증 서버 수준의 구현을 따로 해야 되? 라는 논란의 여지가 있습니다

일단 ClientId 와 Client Evnet Id 라는 조합이 있기 때문에 token/billKey 발급을 위한 과정은 패스하고 추후 인증 서버를 소개할 때 다시 얘기하겠습니다

하지만 우리는 플랫폼 인증이다 하면 위와 같은 행위가 필요할 수 있다고 보고 같이 고민해주시면 좋을 것 같네요 해당 코드나 키값으로 후속 처리를 쉽게 처리할 수 있습니다 (처음이 어려우면 나중이 쉬워지는 법!!)

 

다시 고유 식별자로 넘어가면 KSUID 말고도 고유 식별자를 생성할 수 있는 방법은 어떤것이 있을까요?

가상 면접 사례로 배우는 대규모 시스템 설계 기초 에서는 UUID 와 티켓서버와 다중 마스터 복제와 마지막으로 트위터의 스노플레이크 방식이 소개 되어 있습니다 티켓 서버와 다중 마스터 복제는 Auto Increment ID 가 대상이므로 패스 합니다(관련 내용)

KSUID

  • 자연스럽게 생성 시간 순서로 정렬됩니다
  • 충돌이 없습니다
  • 단, 160bit 를 사용해서 UUID 의 128 bit 보다 큽니다
KSUID

 

 

JDK 내장된 방법

UUID (Universally Unique Identifier)

  • 컴퓨터 시스템에 저장되는 정보를 유일하게 식별하기 위한 128비트 수 입니다
  • 보편적으로 쉽게 사용할 수 있는 방법(다수의 언어거 기본적으로 제공하는 생성기) 입니다
  • 단점
    • 숫자가 아닌 값이 포함될 수 있으며 옛날 버전의 경우 시간 순으로 정렬할 수 없다
  • 하지만 UUID v6, v7, v8 에서는 타임스탬프 기반으로 정렬이 가능해서 만들어 지고 있다고 합니다
uuid v4

가상 면접 사례로 배우는 대규모 시스템 설계 기초(책)

Twitter’s Snowflake (업데이트가 안되고 있습니다)

https://en.wikipedia.org/wiki/Snowflake_ID
  • 총 64bit 를 사용합니다

도메인 주도 개발 시작하기(책 - 저자 최범균)

Nano Id

  • 엔터티의 식별자 생성 용도로 소개되었습니다
  • UUID 보다 사이즈도 작고 생성기 속도도 빠르다고 합니다
  • UUID 대안으로 더 가볍고, URL 친화적인 ID를 생성하는 라이브러리입니다
  • JavaScript 가 메인 타겟이지만, 다양한 언어 구현체도 존재하고 이 녀석은 사용하는 문자의 범위를 지정하고, 사이즈를 가변적으로 선택할 수 있다는 장점이 있습니다
nano id

그 밖에도

ULID (Universally Unique Lexicographically Sortable Identifier)

  • 이 선택지의 주요 컨셉은 UUID의 단점을 개선하는데 초점이 맞춰져 있습니다
  • UUID 128비트 구조와 호환하면서, 정렬이 가능하며, 특수문자를 포함하지 않아 URL에서 사용해도 안전합니다
https://blog.tericcabrel.com/discover-ulid-the-sortable-version-of-uuid/

 

이것 말고도 CUID 나 정말 다양하게 있는데요 (너무 많아서 소개하기는 패스~!!)

 

상황에 따라 고려할 내용들이 몇개 보였습니다

  • id 의 사이즈(용량)
  • 키 생성 시간(성능)
  • 특수문자가 사용되는지 유무(이식성 및 확장 - URL 에서 사용도 할 때)
  • 외부 노출 허용정도(보안)
  • Entity(JPA) 나 Casandra(NoSQL) 와 같은 DB에서 활용 용도 (비즈니스)

용량, 성능, 확장, 보안, 비즈니스들을 고려했을 때 단순히 조사해서는 결론을 내기 어려워서 추가로 NPM Trend 도 보았습니다

마지막으로 NPM Trend 결과 입니다 언어가 JS 방면으로 한정되겠지만 그래도 가장 인기있는 개발언어입니다

여기서 Star 수는 nano id 가 가장 많네요

https://npmtrends.com/ksuid-vs-nanoid-vs-ulid-vs-uuid

 

추가 참고 내용

 

조만간 OLO 마일리지라는 것이 생길 예정입니다

해당 마일리지(포인트) 처리 개발 과정에서 발생 되었던 서버의 동시성 이슈들과 해결 방법 그리고 그 후의 상에 대해 정리해보고자 합니다

첫 번째 - 포인트 잔액 갱신

포인트 처리 비즈니스에는 적립 / 적립 취소 / 사용 / 사용 취소 / 삭제(탈퇴 처리) 와 같은 경우가 있습니다

여기서 적립의 경우는 SQS 를 이용한 하이브리드한 동기(SQS 전송 후 비즈니스 처리를 적립기에 위임하지만 응답에 unique key 를 담아 보내는 구조) 처리를 하고 있습니다

우아한 형제들의 포인트 처리를 벤치마킹 했어서 비교하자면 적립 취소 까지는 SQS 를 사용하고 있었고 현재 저희 비즈니스는 적립 큐만 사용하고 있어 큐 분기는 없어서 다른 비즈니스는 모두 디비를 통해 처리하고 있습니다 (이 부분은 적립 취소 큐를 따로 쓰거나 적립 큐에서 이벤트를 키로 분기 해도 될 것 같습니다)

어쨌든 포인트 코어 | 포인트 적립기 모두 잔액 갱신이 가능한 분산 환경 상황이었고 동시에 같은 요청이 오게 되면 포인트 잔액이 틀어지는 상황이 생겨났습니다

분산 서버 환경 예시

 

코드로 보자면

val requestPoint = (...)
 
val balance = getRedisBalance(key)
val updateBalance = balance + requestPoint
 
setRedisBalance(key, updateBalance)

적립 Worker 도 N 개 이고 포인트 Service Worker 도 N 개 입니다 이때 동시 요청들이 get 하고 set 할 타이밍에 get 에서 같은 값이 읽히게 되면 처리하지 못하는 적립 요청이 생기게 됩니다

get ~ (사칙연산) ~ set 할 타이밍에 찰나의 유실이 발생됩니다

해결방법

개발자 방에도 문의해봤었고 점심식사 하기 전 엘레베이터 앞에서 충섭 팀장님께 Redis 동시성 처리를 문의 했었는데 그 때 잠깐 Redis 의 Incr 이라는 Command 에 대해 듣게 되었습니다

퍼플아이오 개발자 대화방
출처 :  https://www.slideshare.net/RedisLabs/atomicity-in-redis-thomas-hunter

해당 연산을 Lettuce 라는 Redis Client 에서 지원을 해주어서 증감/감소 연산을 할 때 Atomic 한 동시성 처리가 매우 쉽게 가능해졌습니다

Spring Code 단, Ktor 에서는 약간 다르지만 원리는 같습니다

두 번째 - 포인트 동시 변경 처리

포인트가 변경되는 비즈니스로 아래와 같은 부분이 있습니다

  • 만료 포인트 즉시 만료시키기
  • 가용 포인트 확인해서 사용 시키기
  • 사용 포인트 취소시 취소한 히스토리(이력) 추가하기

이 경우는 첫 번째 경우와 다르게 동시에 처리 하려는 Event(주문, 영수증발급, 포인트 차감/충전) 등 이런 상황이 동시에 처리하려고 하는 경우는 1건에 대해서만 유효한 것으로 인지하고 나머지 경우는 중복되게 처리하지 않기로 했습니다 (비즈니스 적으로 유효한지 안 한지 판단이 필요한 부분)

Mauve 의 포인트 설계는 자유 분방한 Client Event 이기 때문에

[
  { group = 1, eventId = A, member = tom }, { group = 2, eventId = B, member = tom }
]

이런 형태로 되어 있었습니다 만약 아래와 같은 구조였다면 event 의 유일성이 더 보장되었을 것 같은데요

{
  evnetId = A,
  points = [
    { group = 1, member = tom }, { group = 2, member =tom
  ]
}

이런 구조가 아니기 때문에 Array 처리에 대한 모든 형태의 보장이 되야 했고 해당 포인트 들 모두 잘 처리해야 합니다

동시에 여러 포인트 처리가 들어오면 모두 다 처리가 되기 때문에 사용자는 딱 한번만 처리 되길 원하는 상황에 동시에 여러번 들어오면 트랜잭션이 끝나기 전에 기존에 데이터를 읽어서 (Select ~ 이후 Insert ) 주문의 unique 가 보장되지 않고 여러번 처리됩니다

이런 상황에서는 데이터베이스에서 처리와 어플리케이션에서 처리하는 것 에 대해 고려를 해봐야 하는데요

Isolation Level과 Lock 이라는 것이 있어 우리가 데이터를 잘못 읽을 상황을 잡아줍니다

트랜잭션의 격리 수준(isolation)이란?

  • 동시에 여러 트랜잭션이 처리될 때특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것.

트랜잭션 격리 수준은 어떤게 있을까?

  • READ UNCOMMITTED (가장 낮음)
  • READ COMMITTED (현재 어플리케이션에서 사용하는 상태)
  • REPEATABLE READ
  • SERIALIZABLE (가장 높음)

위 내용이 현재 중요한 부분은 아니기 때문에 간략히 넘어갑니다

가장 높은 경우로 하면 성능 측면에서는 동시 처리성능이 가장 낮기 때문에 성능이 필요한 경우에는 쓰지 않는 것으로 알고 있습니다

Lock이란?

데이터의 일관성을 보장하기 위한 방법입니다

Lock의 종류는?

Lock은 상황에 따라서 크게 두가지로 나누어 집니다

  1. Shared Lock(공유 Lock 또는 Read Lock)

보통 데이터를 읽을 때 사용합니다 원하는 데이터에 lock을 걸었지만 다른 세션에서 읽을 수 있습니다 공유Lock을 설정한 경우 추가로 공유Lock을 설정할 수 있지만, 배타적 Lock은 설정할 수 없습니다.

즉, 내가 보고 있는 데이터는 다른 사용자가 볼 수 있지만, 변경할 수는 없습니다.

2. Exclusive Lock(배타적 Lock 또는 Write lock)

보통 데이터를 변경할 때 사용합니다 이름에서 느껴지는 것 처럼 해당 Lock이 해제되기 전까지는 다른 공유Lock, 배타적Lock을 설정할 수 없습니다.

즉, 읽기 기와 쓰기가 불가능하다는 의미입니다.

Lock 을 사용해서 동시에 동일한 요청이 오는 경우 둘 중에 하나만 읽어서 처리하고 두 번째 요청에 대해서 동일한 현상이 발생되지 않게 처리할 수 있습니다

실제로 만료 포인트가 있는지 DB 에서 Select 를 해서 만료할 수치를 계산해서 Insert 를 이후에 하는데 Select 할 때 Lock 을 걸면 중복되어 소멸될 여지가 없기 때문에 문제가 해결됩니다 (JPA 기준 해당 Query 에 Lock 을 사용)

당장 해결 하는 방식은 간단했으나 창수 팀장님께서 DB에 Lock 을 걸어 버리면 특히나 포인트의 모든 히스토리가 하나의 테이블로 관리되는 상황에서 의도치않은 Row Lock, Table Lock 이 생길 수 있고 성능이 저하 될 수 있어서 Redis 를 통한 분산 트랜잭션 처리에 대한 아이디어를 주셨습니다

마침 첫 번째 해결 방안에 대해 고민했을때 Redis 를 통한 분산락을 잡아 처리하는 방식에 대한 학습이 되어 있기 때문에 주저없이 Redisson 을 통한 분산락을 적용했습니다

관련 영상은 인프런에도 있어서 아마도 비슷할 것 같습니다 (그 만큼 대중적인 해결방법인듯)

해결방법

바로 코드로

Redisson 을 통한 분산락 적용

 

원리는 간단합니다

라이브러리에서 Lock 객체를 주기 때문에 해당 락을 잡고 락 유지 시간동안 executingFunction 에 비즈니스 처리를 넣습니다

해당 작업이 수행되기 전에 동시 요청 되는 LockName Event 에 대해 막습니다

executingFunction commit 이 되고 나면 Lock 을 해소해서 그 순간의 동시 요청을 막습니다

라이브러리가 다 해줘서 제가 한게 뭐지? 싶습니다

아무튼 이걸로 해결이 다 될까요?

 

세 번째 - 클라이언트의 잘못된 동시 반복 요청 자체를 차단

두 번째 과정으로 동시에 처리라는 것은 막을 수 있습니다 (즉 00:00 초 사이 순간적인 동시 처리가 막히는 거죠)

하지만 문제는 처리가 끝나고 UnLock() 이 되는 상황에 발생됩니다

순간 동시 처리는 막히지만 UnLock() 이후에 클라이언트가 잘못된 요청을 다시 주면 동시 처리를 막지 못한 것처럼 처리되어 버린다는 괴상한 논리구조가 생기게 됩니다

처음의 동시에 처리는 막혔지만 두 번, 세 번의 동일 요청이 막히지 못하게 됩니다

사실상 클라이언트가 잘못된 요청을 동시에 많이주면 이 때는 처음 동시만 막고 그 이후는 처리가 되는 상황이됩니다

이 상황에서 창수팀장님이 incr 을 통해 키를 잡고 만료시간을 지정해서 키가 만료되는 동안 해당 요청을 막는 방법을 얘기하셨습니다

헤결방법

바로 코드로

incr 을 이용한 만료시간 적용

마침 incr 은 학습이 된 상태라 Redisson 라이브러리는 처음 쓰는 것이지만 그래도 금새 적용해서 만료시간 이전에 동시 요청이 되는 경우도 막아 버렸습니다

하지만 5초간 동일 LockName 으로 오는 경우까지 다 막혀버립니다

즉 이미 동시처리 문제가 해소된 이후에도 방패를 바로 거두지 못하는 경우가 생기게 되는데 이때 executingFunction 이 처리되고 자동 만료가 된다면 또 잘못된 동시 요청을 허용하게 된다는 것이됩니다

즉 안전성을 위해서는 5초간은 잘못된 요청이 와도 막겠다가 됩니다

이 부분을 좀 더 나이스하게 처리하려면 LockName 이 겹치지 않으면 의도하지 않은 Blocking 도 발생하지 않을 수 있습니다

클라이언트 입장에서는 자체 에러 상황에 에러 복구를 위해 ReTry 시도가 생길 수도 있다는 것이지요

어쨌든 INCR 과 DB+REDIS 간 분산락을 잡음으로 DB 트랜잭션의 유일성이 보장되었습니다

 

마지막으로

글 초반에 Client Event Id의 모호함으로 맘에 들지않는 JSON 구조에 대해 잠깐 얘기도 했었습니다

그 밖에도 코어 서비스의 SaaS 비즈니스들의 확장 가능성에 대한 모호함으로 인해 중요한 값을 필수화 하려고 무리해서 Path Variable 로 올리면서 REST API URI 가 매우 맘에 들지 않는 부분도 캐치가 되었는데요

또 거기에 더해서 초기 설계상 포인트의 키 시스템을 잘 도입했으면 하는 아쉬움도 더해지고 있습니다

전에 빌링 파트는 아니지만 빌링 서비스 처리를 옆에서 보면서 BillKey 에 대해서 알고는 있었어서 포인트와 연결되는 BillKey(PointKey) 가 있는 것에 대해 고민들 해봐야 할것 같습니다 (아래는 단순한 하나의 예시)

개인 사이드 프로젝트서 BillKey 사용

퍼플아이오에서 처음 시작하게된 된 업무가 생겼습니다. 어플리케이션을 빌드해서 원격지 서버에 배포하는 부분입니다

젠킨스가 있다는 것은 알았지만 구축된 환경을 사용만 했기 때문에 직접 빌드나 배포를 수정하는 것은 알지 못했고 아.. 이것도 내가 알아야 하는 과목이구나 하지만.. 아직 미뤄둔 숙제 같은 느낌입니다

제가 처음 Gitlab-CI 를 수정하게 된 이유는 Local Test Case 를 서비스에 따라 각각 약 100개~300개 정도 돌리는데 어디 하나 수정이 되어 나갈때

혹시나 딱 눈에 보이는 곳만 테스트하는 개발자 셀프 테스트만 되어 전체 테스트 코드를 돌리지 않고 나갈때

이때 수정 사항이 잠재적인 버그가 되지 않기 위해 CI 에서 꼭 테스트 되어야 게으르고 꼼꼼하지 않는 제가 발 뻗고 잘 수 있는 방화벽을 만들어야 겠다 하는 바람이었습니다

이때 삽질을 좀 겪으면서 어찌저찌 추가하게 되었고 가끔 CI 테스트 실패를 겪으면 꼼꼼하지 못한 저에게 채찍질을 할 명분이 되었습니다

운이 좋게 이번에 스터디가 열리면서 다양한 파트의 다양한 직군의 사람들과 각각의 처한 환경과 다양한 배포 환경을 접하게되었습니다

서론이 길었는데 CI/CD에 대해 연구를 시작하게 되었고 스터디 산출물들이 업무에 적용되어 기존의 불편함이 하나 둘씩 반영될 예정입니다. 개선된 내용이 이어질 2탄~ 3탄 기대해주세요 Coming Soon!

그 전에!! 핵심용어와 현재 상황과 개선점에 대해 공유하고자 해당 포스팅을 하게 되었습니다

일단 지속적 소프트웨어 개발 방법이란 무엇일까요?

바로 답 부터! 반복적인 코드 변경사항을 지속적으로 빌드, 테스트, 배포할 수 있습니다

이 반복적인 프로세스는 버그가 있거나 실패한 이전 버전에서 테스트를 반복하며 리뷰를 하고 완성형의 코드를 배포시키는데 도움이 됩니다.

이 방법을 사용하면 새 코드 개발부터 배포까지 사람의 개입을 줄이거나 전혀 개입하지 않으려고 노력합니다

즉 지속적인 반복에 대해 자동화를 한다는 것 입니다

 

지속적 통합(Continuous Integration, CI)

GitLab의 Git 저장소에 코드가 저장된 애플리케이션을 생각해 보십시오. 개발자는 매일, 하루에 여러 번 코드 변경사항을 푸시합니다.

리포지토리에 푸시할 때마다 애플리케이션을 자동으로 빌드하고 테스트할 수 있습니다.

이 자동화된 스크립트는 애플리케이션에 오류가 발생할 가능성을 줄일 수 있습니다.

이 방법을 지속적 통합이라고 합니다.

애플리케이션에 제출된 각 변경사항은 운영 뿐만 아니라 개발 브랜치에도 자동으로 지속적으로 빌드되고 테스트됩니다.

이러한 정책과 분산 환경의 변경사항이 애플리케이션에 대해 설정한 모든 테스트, 지침 및 코드 준수 표준을 통과하도록 보장합니다.

지속적 전달(Continuous Delivery, CD)

지속적 전달은 지속적 통합을 넘어서는 단계입니다.

코드 변경이 코드베이스에 푸시될 때마다 애플리케이션이 빌드되고 테스트될 뿐만 아니라 애플리케이션도 지속적으로 배포됩니다. 그러나 지속적 전달을 사용하면 배포를 수동으로 트리거합니다.

지속적 전달은 코드를 자동으로 확인하지만 변경사항의 배포를 수동으로 전략적으로 트리거하려면 사람의 개입이 필요합니다.

지속적 배포(Continuous Deployment, CD)

지속적 배포는 지속적 전달과 유사한 지속적 통합을 넘어서는 또 다른 단계입니다.

차이점은 애플리케이션을 수동으로 배포하는 대신 자동으로 배포되도록 설정한다는 것입니다.

사람의 개입이 필요하지 않습니다.

Gitlab-CI 에서 알아야 하는 용어

  • Pipeline
    파이프라인은 지속적 통합, 전달 및 배포의 최상위 구성 요소
  • Jobs
    파이프라인 구성은 Job으로 시작됩니다. Job은 .gitlab-ci.yml 파일의 가장 기본적인 요소입니다.
  • Variable
    CI/CD 변수는 환경 변수의 한 유형입니다.
  • Environments
    환경은 코드가 배포되는 위치를 설명합니다.
  • Runners
    GitLab CI/CD에서 러너는 .gitlab-ci.yml에 정의된 코드를 실행합니다.
    • 공유 러너(Shared runners)는 GitLab 인스턴스의 모든 그룹 및 프로젝트에서 사용할 수 있습니다.
    • 그룹 러너(Group runners)는 그룹의 모든 프로젝트와 하위 그룹에서 사용할 수 있습니다.
    • 특정 러너(Specific runners)는 특정 프로젝트와 연결됩니다. 일반적으로 특정 러너는 한 번에 하나의 프로젝트에 사용됩니다.
  • Artifacts
    단계(Stages) 간에 전달되는 단계 결과에 사용합니다.
  • Cache
    프로젝트 의존성(Dependencies)을 저장하는 데 사용합니다.

.gitlab-ci.yml 파일 생성

.gitlab-ci.yml 파일은 GitLab CI/CD에 대한 특정 지침을 구성하는 YAML 파일입니다.

이 파일에서 다음을 정의합니다.

  • 러너가 실행해야 하는 작업(Job)의 구조와 순서
  • 특정 조건이 발생할 때 러너가 내려야 하는 결정

사전 조건

  • 프로젝트에서 사용할 수 있는 하나 이상의 Runner가 등록되어 있어야 합니다.
    • Settings > CI/CDRunners 섹션에서 확인
Gitlab-CI 에서 사용하는 러너

Gitlab-Ci.yml 파일은 선언형 방식으로 코드를 추가해 나갑니다 상세 내용은 gitlab 회사의 공식문서에 나온 용어 부터 시작해서 뻗어나가는 것을 추천합니다

 

CI Build 에 대한 방식

  • Build 프로세스에서 Docker Image 로 생성하고 활용하는 것을 적극적으로 사용하고 있습니다

우리가 사용하는 방식

./gradlew jibDockerBuild

CI Process 에 대한 방식

  1. 각자 작업한 branch 에서 push 후 Merge Request 를 요청합니다
  2. MR 리뷰 후 -> 담당자가 MR을 머지하면 CI가 자동으로 동작합니다
  3. CI가 동작한다는 것은 Build - (test) 과정을 진행하고 Deploy Stage 가 진행됩니다
  4. 현재 프로젝트에서 Deploy 되는 것은 AWS ERC 에 올리는 것으로 지속적 전달(CD)로 진행됩니다
  5. ArgoCD에 접속해서 로그인 후 해당 어플리케이션의 상대를 Refresh 하고 Sync 를 맞춥니다
  6. 배포가 되었으면 각각의 환경에 맞게 스테이지/운영 테스트를 진행 합니다

 

개발환경에 대한 고민

저는 개발환경이 각각의 외부 연동과 협업 상황에 맞게 분리되어야 한다고 생각합니다. (4~5개 정도)

이 내용을 얘기하면 길어 질 것 같고 글로벌 서비스가 아니라 Admin 개발에 오버엔지니어링 일 수 있다는 반론도 수용되기 때문에 관련 링크만 첨부합니다. (조대협 - 개발환경)

  • 환경이 많아지면 Git Flow 방식도 고려 대상이 됩니다 환경에 따라 각각 Branch Main Stream 이 구분되어 생성될 수 있습니다
  • Development
    • Code
    • Commit
  • CI Pipeline
    • Build
    • Test
      • Unit Test
      • Integration Test
  • CD Pipeline
    • QA
      • Staging
        • Review
    • Deploy
      • Production

대신 그림으로 보자면 이 일련의 과정이 수행되는 환경이 구분이 분명 될 것이고 자동화된 CI는 개발자에게 새벽 배포가 아닌 충분한 숙면을 줄 수 있을 것입니다.

 

본문으로 생각한 내용은 여기까지고 추후에 이어질 성능개선 된 CI/CD 내용도 같이 기대해주세요

  • 힌트는 기울여진 글씨들에 있습니다
  • ArgoCD 와 성능 개선된 부분에 대해서는 다시 게시될 예정입니다

 

Kotlin Spring 개발을 하면서 꾸준히 라이브러리 / 프레임워크 / gradle 버전 및 관련 플러그인들 버전을 체크하고 올리고 있는데 Spring의 메이저 버전이 새롭게 Release 되었네요

Spring 5 -> 6

Spring boot 2.7 -> 3.0

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Release-Notes

2.7 버전 아래에서는 2.7 버전으로 올리라는 추천 문구부터 JDK 17을 최소버전으로 8 -> 17 로 확 올리다니 11 버전은 뭔가 동일선상의 LTS 에서 붕 뜬 느낌이네요

다행히 기존의 프로젝트들도 11에서 17로 올렸고 (Mauve) 신규 CRM도 JDK 17부터 하는 걸 제안해서 하고 있었습니다

kotlin 버전도 1.7.2로 올려야 할 것 같네요

아직 스프링 개발자라고 하기 뭣하기가 스프링의 코어 부분에 대한 지식이 매우 없네요 ㅎㅎ.. (뭐 다른것도 아장아장 걸음마 이지만)

아키텍처를 소개해드리고 싶은데 아래 그림이 해석이 좀 어려운데 공부 겸 한번 시작해보자면

3.0에서는 AOT 플러그인 제공과 Native 지원에 관한 부분이 확대된다는 내용이라고 합니다

  • (그림의 Spring Boot 3 과 Spring Framework 6)

Spring 6.0을 통해 AOT transformation engine도 더 좋아진 버전의 엔진이 사용되는 것이고 Native에 굉장히 힘을 쏟는 것이라는 설명으로 보아 자동차 엔진의 성능이 더 좋아졌다고 보면 될 듯 합니다

  • (그림의 Spring portfolio )

 

엔진이 좋아지면 어떤게 좋아지느냐? 이게 이 고성능 외제차를 사야하나 말아야 하나의 관건 인데요

AOT의 적용 효과라고 합니다

  • 런타임시 Spring 인프라를 적게 사용
  • 런타임 시 검증할 조건 수 감소
  • 리플렉션을 줄이고 프로그래밍적 Bean 등록 방식 사용

위 내용을 볼때 런타임/리플렉션 이런 부분이 어플리케이션의 런타임 성능을 높이려는 주안점으로 보입니다

 

엔진 내부를 조금 더 보면 (AOT : Ahead Of Time)

Spring Boot 3.0 AOT

Spring AOT 엔진은 빌드 시 스프링 애플리케이션을 분석하고 최적화하는 도구이고

AOT 엔진은 GraalVM Native Configuration이 필요로 하는 reflection configuration을 생성해줍니다(그림에는 없음)

이것은 Spring native 실행 파일로 컴파일 하는데 사용 되고 이후에 애플리케이션의 시작 시간과 메모리 사용량을 줄일 수 있게 됩니다 ("Native" 라는 성능 친화적인 이름값을 하려는 것으로 보입니다)

Spring 의 Native-Image compiler 이 녀석을 통해 빌드 시간은 길고, 시작 시간이 짧고 메모리는 적게 사용게 된다는 부분이 있습니다

AOT 확대

위 그림에서 보면 AOT가 Spring Boot 환경에서 하는 일들과 순서를 알 수 있습니다

간단하게 얘기하자면 Bytecode를 분석하고 최적화해서 좀 더 실행하기에 빠르고 메모리적으로 효율적인 코드를 만듭니다

 

그리고 위에서 GraalVM 에 대한 부분도 보이는데.. 이걸 얘기하자니 JVM과 JIT(just in time) 컴파일러 부터 시작해야 하는 부분이라... 난감합니다 어쨌든 기존에 너무 너무 느리고 안좋으니 방식을 바꾸려고 노력했다 라고 해야 할 것 같은데요

  • Just In Time Compiler : 중간 언어를 실행 시점에 번역하는 인터프리터 방식 (기존 방식)
  • Ahead Of Time Compiler : 중간 언어(자바 바이트 코드)를 미리 목표 시스템에 맞춰 번역하는 방식

의 차이가 있고 추가적인 내용은 NHN 에서 리서치한 내용을 참고로 더 보셔도 좋을 것 같습니다 (NHN 링크)

 

이쯤에서 다시 러닝 커브가 오게 되었는데... (아니 그러면 기존의 Open JDK 사용 진영은? GraalVM 쓰지 않으면 그냥 느리게 지내라는 것인가? 기존의 JIT 컴파일러는 노답이라는 건가?)

지금 이런 노력을 왜 하는지 히스토리를 좀 알아야 하는 부분이 있는데요 현재 사용중인 JVM이 성능 한계를 만났기에 Spring Native 와 같은 시작을 하게 되었다고 하네요

이 말이 뭐지?

하는 분들은 Spring 개발이 다른 프레임워크나 언어로 하는것에 비해 서버 실행 시간이나 속도, 메모리 사용량이 많다는 것을 생각해 주셨으면 합니다 (Spring 은 구리다는 단점 들 중)

그럼에도 불구하고 오랜시간 축적된 다양한 기능과 안정성 그리고 컨벤션에 의한 대규모 개발이 가능했기 때문에 단점들이 커버되어서 널리 사랑받았다는 평도 본적이 있었습니다 (대규모 인력 개발로서는 대체제가 있을까? 공감되는 부분)

 

그 밖의 세세하게 변경된 부분을 보자니 내용이 더 길어질 것 같습니다 (이 부분은 업그레이드를 하게 되면 2탄으로 정리해보겠습니다)

그러고 보니 어플리케이션 테스트 코드 짜는 가이드 방법도 마지막 글을 적어야 하는 군요.. ㅎㅎ (1탄, 2탄, 마지막)

어쨌든 새로운 엔진을 교체하면서 AOT를 적용하려면 해당 플러그인도 설치를 해야 할 것 같습니다

Gradle 에 새로운 Configuration 을 추가해야 할 수도 있고요

아직은 0살 버전이라 어느 시니어 개발자 분 우스겟 소리로는 3살은 넘어야 쓸 수 있는 버전이라고 했던 기억도 나서 일단은 변경 부분에 대해 주시해 보려고 합니다

 

성능을 위한 코어 개발자들의 노력들이 대단 한 것 같습니다

 

관련 내용 링크

공통적인 응답을 고민하는 부분에서 예외가 발생하면 처리 해야하는 부분에 대해 정리 했습니다

200(or 204) vs 404 HTTP Status 논쟁

일단 Java Excetpion 의 구조는 아래와 같습니다

Error 는 비정상적인 상황에서 발생하고 시스템 레벨에서 발생하는 심각한 수준의 오류이기 때문에 개발자가 할 수 있는 방법이 거의 없습니다

그에 반해 Exception 은 개발자가 직접 처리하는 코드에서 발생되는 예외 들로서 예외를 잡아서 정상 처리를 하거나 클라이언트에 통보 할 수 있습니다

Exception 을 처리 할 때 상황에 따라 예외발생 후 복구가 가능합니다

예를 들면 Rollback 도 정상적으로 처리 하지 못해 예외 발생 후 복구라고 할 수 있습니다

하지만 현실에서는 예외가 발생 후 꼭 복구가 가능하다고 볼 수는 없습니다

예외에 대해 복구를 진행하는 것이 아닌 문제에 대해 Exception 을 발생하고 그냥 다시 입력을 가하게 하거나 기본값을 셋팅 해서 예외 발생시에는 기본값으로 진행하게 해서 후속 조치를 받거나 하게 할 순 있습니다

 

하지만 Exception 이 발생되지 않는 다면?

Client 는 예외 상황이 발생 되었고 후속 조치에 대해 어떻게 반응 할 것인가? 이런 의문이 듭니다 ( 뭐 아무것도 안 하면 서로 편한거 아닌가? 란 반론은 들 수 있겠지만…)

즉, "Not found" or "Duplicate" 와 같은 있거나 없는 상황에 시스템이 그냥 가만이 있을 것인지 아니면 뭔가 프로세스 상 예외라고 볼 수 있는 상황인지에 대해 근본적으로 묻고 싶어 집니다

실제 이 부분에서 HTTP API 의 Resource 에 대해

  • 204(No Content), 200(OK but Empty)
  • 400(Bad Request), 404(Not Found)

처리에 대한 의견이 불분명해지고 HTTP API or HTTP Request 를 어떻게 구분 할지에 대한 혼란도 생겼습니다

Resource 를 URI 경로와 data 자원 으로 두 가지 형태로 볼 때 브라우저(클라이언트) 입장에선 자원이 웹 페이지 경로고 존재하지 않는 경로(자원)를 요청했기 때문에 404 상태 코드를 응답했기 때문입니다

404 Not Found

 

아래의 글을 읽어 볼까요

여기서 말하는 명분은 REST 입장에서 생각을 해야 한다는 부분을 주장합니다

이쯤에서 한번 REST 의 용어 풀이에 대해 짚고 넘어 가보겠습니다

- REST 약어는 특정한 상태의 자원에 대한 표현 즉 REpresentational State Transfer (표현된 (자원)의 상태 전송)
- 자원의 상태에 대해 논하고 있고 이 자원은 분명 있을 수도 없을 수도 있다

그렇다면 결국은 프론트 쪽에 미안해지지만 브라우저에서 URL로 화면에 안 나온다고 Not Found 만 되는걸로 보는게 아니라

프론트에 사용 된 브라우저는 단지 다양한 클라이언트 중에 하나일 뿐..

경로 못 찾았다고 404 보여준것과 리소스 상태가 없거나 찾을 수 없는 경우는 화면과 관계없는 404로 헷갈림을 준다고 한다면 브라우저 위주의 에러 페이지 보여주기 처리에서 404 에러 페이지 라우팅이 HTTP Status 404를 전체하는가?

이제는 과거의 백엔드가 화면을 컨트롤 하던 웹 개발과 달리 프론트 자체에서 화면 라우트가 직접 제어가능 하도록 API 서버에서 분리되어 자유롭게 기능이 많이 위임되었습니다

이제는 HTTP Status 값에 있어 경로 없는거랑 REST API 상태랑 똑같이 보면 안되지 않나 생각이 앞섭니다

그렇다면 아래와 같은 풀이를 해보자면

처음의 Exception 으로 와서 위에 분명 리소스의 상태에 대해 있을 수도 or 없을 수도 있다고 했습니다 즉 아래와 같은 풀이가 가능하다면

있을 거라 생각했는데 예상 못하고 없는 경우?! 
	- 400(Bad Request)
	- 404(Not Found)
없을 수도 있다고 생각했는데 진짜 없는 경우!?
	- 204(No Content)
	- 200(OK but Empty)

말 장난 같긴 한데… 이게 필요하다고 생각한 이유는 200, 204는 예외로 보지 않는 다는 것이라서 짚은 것 입니다

처음에 가진 궁금증

하지만 Exception 이 발생되지 않는 다면?

  • Client 는 예외 상황이 발생되었고 후속 조치에 대해 스스로 예외 복구를 하는 것과 같은 [있다]/[없다]에 대한 인지 부조화를 타개할 수가 있는지 대한 의문이 든다는 점
  • Not found or Duplicate 와 같은 있거나 없는 상황에 시스템이 그냥 가만이 있을 것인지 아니면 뭔가 프로세스 상 예외라고 볼 수 있는 상황인지에 대해 근본적으로 묻고 싶어 진다

이 부분에서 뭔가 클라이언트 스스로 응답에 대해 판단하고 처리 할 수 있겠지만

서버에서 무조건 예외를 만들어 내 뱉는 오버 헤드를 겪더라도 클라이언트에 직접 뭐라도 해라 라고 알려줬다는 안도감으로 봐야 하는지

아래 글을 읽고 곱씹어 보자..

예외를 Backend 에서 잡아 처리했을 때 정보를 "전달" 하는 역할에서 Runtime Exception 을 발생하는 명분이 된다면

프로세스가 복잡한 화면들인 경우(대출? 구매? 포인트 사용) 예외를 강제함으로 로직의 흐름이 올바르지 않을 때 적절하게 끊어 갈 수 있는 부분을 강제한다면

그래서 이런 경우 400 응답을 명시하게 되었고 404의 경우는 경로 없는 거랑 헷갈리고 싶지 않다고 하는 의견도 있어 타협할 수 있는 Bad Request 로 처리하기도 했습니다

(그런데.. API Backend 는 경로 관여 안 하는 데 언제까지 이 논리를 봐줘야 하는지..)

이렇게 보면 클라이언트(브라우저를 비롯)는 데이터 조작과 같은 행위를 할 때는 해당 Command 를 바로 실행하지 않고 (돌 다리도 두드려 보고 행위를 시작해야 합니다)

즉, 지금 이 행위의 대상인 리소스가 제대로 되어 있는지 없는지 Get Method로 확인 하고 해야 Exception을 처리한 Error Response 를 만나지 않게 됩니다

이러면 Request 단위가 ([Get] -> [Command]) 로 2번으로 묶여버리면 프론트 입장에서는 매우 귀찮을 수 있고 항상 2 Thread 자원을 써야 하는 Spring Boot MVC 는 더 많은 사람을 받는 처리가 어려워 지지 않는 다는 고민도 해볼 수 있습니다 (성능 테스트를 직접 해야 하나.....)

위에 돌 다리도 두드려 보고 가라와 같은 워딩을 몇 개 더 해보자면

  • 마음대로 하지 말고 (백엔드에) 물어 보고 해라
  • 그냥 막 하지 말고 정확한 것만 해라
  • 모르는 상태로 하지 말고 제대로 알고 해라

 

자 이제 이걸 Backend 에서 보장해 주는 것이라고 생각 한다면

2번 하지마세요~ Backend 에서 있는 경우만 완벽하게 보장해서 처리해 줄게요 
때로는 재시도까지 몇번 시도해서라도 꼭 되게 해줄게요 
대신에 그럼에도 불구하고 안되면 왜 안되는 지 클라이언트에서도 한번 다시 체크하거나 처음부터 다시 해주세요

이럴려고 4xx 대 오류를 불가피 하게 넘겨주는 경우가 된다면 조금 수긍이 될까요?

너무 오랫동안 이 부분에 대해 고민했는데.. 일단 소규모 조직이라면 쌍방이 협의하는게 제일 정확할 것 같긴합니다

다만 너무 논리적으로만 생각하려고 해서 HTTP의 본질을 놓쳤을까봐 아래 대응을 추가로 남깁니다

여기서 URI Path 경로와 Query String에 의한 질의가 있는 경우를 얘기해서 해석이 들어가는데

http://localhost:8080/api/goods/:id?type=3

id 까지는 존재하는데 query string 에의한 type=1이 없다면 resouece는 존재하는 것으로 보고 200에 empty 로 처리한다고 합니다

그러면 id로 찾았는데 없다면!!! 이 때는 확실히 4xx NOT FOUND를 명시하면 될까요? resource가 uri 상 없는게 확실한 경우가 됩니다

결론을 보면 둘 다 비즈니스에 따라 네이밍 된 HTTP API URI 설계 상황에 따라 쓰인다가 맞는것으로 보입니다

그러니 API 문서를 작성은 무조건 합시다! (잘 설계하는 것도 포함)

 

일관성 관점에서 클라이언트간 협의가 된다면 HTTP URI Path 에 존재 여부에 따라 2xx / 4xx 이 분기되고 query string 까지 안 찾아지는 경우는 2xx 으로 정상 처리하는 것으로 제안 해봅니다

당연히 프론트에서도 있다/없다 판단하고 스스로 완성할 수 있다는 것은 알고 있어요.. 그래도 우리 커뮤니케이션 맞추고 했으면 하는 바람이 있었어요.. ㅠㅠ

 

[추가 적인 제안]

Http status 는 200,400,500 3개만 있는게 아니에요.. 우리 다같이 공부해야 할 것 같아요... ㅠㅠ

201, 204, 401, 403, 404 말고도 알아야 하지 않을까? 하는 것들

  • 202 : Accepted
  • 405 : Method Not Allowed
  • 429 : Too Many Requests
  • 기타 3xx 대 관련 Redirection
  • 결국은 다다익선??

 

[작성에 사용 된 글 들]

지난번 KCRM 웹 어플리케이션이 서비스 되면서 Rest Docs 기반의 API 명세문서를 사용하게 되었고 아래와 같은 문서로 배달의 민족의 노하우를 일부 흡수해왔습니다

테스트가 뭔지는 알것 같은데 테스트로 개발하는 것은 뭐지? 이런 부분 까지는 모르는 상황에서 일단 Controller Layer 에 대해서 테스트를 진행했고 이번에는 전체적인 개발에 있어 테스트 기반으로 시작했고 그 나름의 가이드를 문서화 했습니다

예제로 쓰인 코드는 사내 깃랩에 오픈했고 필요하시면 권한을 획득해서 접근할 수 있습니다

현재 스프링 부트 테스트(JUnit5) 기반의 내용으로만 되어 있어 도입기 3에는 테스트 기반 개발 방법 보충하기 가 될 것 같습니다 (3부작)

여기에는 지금 담지 못한 일부 내용들이 추가될 것 같습니다

  • Kotlin을 위한 KoTest 사용에 대해
  • Kover 로 Kotlin 기반에서 테스트 커버리지 측정하기 (vs JaCoCo)
  • Kotest, kover, gitlab-ci 로 테스트 자동화로 배포까지 하기

세세하게 모두 남길 수는 없어서 상세한 내용은 개발 가이드 문서인 컨플루언스에 남기고 아래 부분은 용어와 구현 방법 위주로 기록합니다 (한번 보시고 꼭 아래와 같은 방법이 아니어도 코드로 테스트 할 수 있는 방법이 필수가 되야 한다는 부분이 전달되었으면 합니다)

목차는 아래와 같습니다

  • 테스트 코드란
    • 테스트 도구
  • 테스트 더블이란
  • TDD와 BDD
  • 테스트 의 종류
    • Mock 에 대해
    • 통합 테스트
      • API 테스트
      • 서비스 테스트
    • 슬라이스 테스트
      • Mock API 테스트
      • Mock 서비스 테스트
      • Mock Repository 테스트
        • 외전: Mybatis 테스트
      • POJO 테스트
  • 테스트 커버리지와 테스트 자동화
    • JaCoCo
    • Gitlab-ci

 

테스트 코드란?

  • 제품 or 서비스의 품질을 확인하거나 버그를 찾을 때 작성하는 코드들을 의미합니다
  • 제품이 예상대로 동작 되는지 확인할 수 있고 자동화시켜서 지속적인 품질 수준을 유지하거나 높입니다

테스트 도구는 무엇이 있을까요? (JVM 진영 기준입니다)

  • 테스트 라이브러리
    • JUnit : 전통적 강자입니다
    • KoTest : 신흥 강자입니다. 코틀린 진영에서 많이 사용합니다
    • Spek : 코틀린 전용입니다 - 오픈소스가 활동적이지는 않습니다
    • Spock: BDD에 특화되어 있습니다. 단 Groovy를 배워야 합니다
  • Mock 을 위한 도구
    • MockK(Mock) : 코틀린 전용 Mockito 입니다
    • SpringMockK : MockK에서 @MockBean, @SpyBean 이 없어서 추가하게 되는 라이브러리입니다
    • AssertJ : Assertion(단언문) 을 작성하기 위한 라이브러리로 에러나 예외처리르 담당합니다

테스트 더블이란?

  • 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어 주는 객체 입니다
  • Stub, Spy, Mock 이렇게 구분은 있지만 임시 객체라는 큰 맥락에서는 같다고 보시고 쉽게 접근합니다
  • 테스트 더블의 종류
    • Dummy : 인스턴스화 된 객체는 필요하지만 기능은 필요하지 않은 경우
    • Fake : 복잡한 로직에서 내부에서 필요로 하는 다른 외부 객체의 동작을 임의로 단순화 한 경우
    • Stub : 테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공하는 경우
    • Spy : Stub의 역할에 추가해서 실제 객체의 메소드를 호출까지 추가하는 경우
    • Mock : 호출에 대한 기대하는 상황을 명세하고 이에 따라 동작되는 껍데기 객체를 만드는 경우
http://xunitpatterns.com/Test%20Double.html

 

TDD 란?

  • 테스트를 먼저 작성하고 그 뒤에 테스트 케이스를 통과하는 코드를 작성하느 방식으로 테스트가 주도하는 개발 방법입니다

BDD 란?

  • BDD는 (Behavior Driven Development )로 TDD를 근간으로 파생된 개발 방법 입니다
  • TDD에서 한 발 더 나아가 테스트케이스 자체가 요구 사양이 되도록 하는 개발 방법 입니다
  • BDD를 통해 개발을 하게 된다면 테스트 메소드의 이름을 "이 클래스가 어떤 행위를 해야한다 (should do someting)" 라는 식의 문장으로 작성하여 행위에 대한 테스트에 집중할 수 있습니다
  • BDD는 시니리오를 기반으로 테스트 케이스를 작성하고 함수 단위 테스트를 권장하지 않습니다
    • 이 시나리오는 개발자가 아닌 사람이 봐도 이해하는 레벨을 추구합니다
    • 하나의 시나리오는 Given, When, Then 구조를 가지는 것으로 기본 패턴을 권장합니다
      • Feature : 테스트에 대상의 기능/책임을 명시합니다
      • Scenario : 테스트 목적에 대한 상황을 설명합니다
      • Given : 시나리오 진행에 필요한 값을 설정합니다
      • When : 시나리오를 진행하는데 필요한 조건을 명시합니다
      • Then : 시나리오를 완료했을 때 보장해야 하는 결과를 명시합니다
  • 간단히 생각하면 BDD 는 TDD를 어떤 방식으로 할지에 대한 구체적인 제시라고 할 수 있습니다

 

테스트의 종류는 무엇인가요?

  • 테스트 전략
    • 통합 테스트
      • 개발자가 변경할 수 없는 부분(외부) 까지 묶어 검증할 때 사용할 수 있습니다
      • 단위테스트에서 발견되지 않는 에러 부분을 확인할 수 있습니다
    • 시나리오 테스트(Acceptance Test)
      • 사용자 시나리오에 맞춰 수행하는 테스트(비즈니스에 초점을 둔다) 입니다
      • 누가, 어떤 목적으로, 무엇을 하는가에 대한 관점입니다
      • 개발하다 보면 API를 통해 이런 의미가 드러나고 이런 API를 시나리오에 맞게 확인하는 방식으로 이뤄집니다
      • 명세대로 잘 동작하는지 검증하는 부분으로 블랙박스 테스트를 기본으로 합니다

시나리오 테스트 예시 - RestAssured

 

 

통합 테스트에 대해

  • 장점
    • 모든 Bean을 올리고 테스트를 진행하기 때문에 쉽게 테스트가 가능합니다
    • 모든 Bean을 올리고 테스트를 진행하기 때문에 운영 환경과 가장 유사하게 테스트가 가능합니다
    • API를 테스트 할 경우 요청 부터 응답까지 전체적인 테스트를 진행 가능합니다
  • 단점
    • 모든 Bean을 올리고 테스트를 진행하기 때문에 테스트 시간이 오래 걸립니다
    • 테스트의 단위가 크기 때문에 테스트 실패시 디버깅이 어려웁니다
    • 외부 API 요청같은 Rollback 처리가 안되는 테스트를 진행할때 어렵습니다
  • 통합 테스트에 쓰이는 Annotation 종류
    • @SpringBootTest
    • @AutoConfigureMockMvc
  • API 테스트
    • 장점
      • 주로 컨트롤러 테스트를 주로 하며 요청 부터 응답까지 전체 플로우를 테스트 할 수 있습니다
    • 단점
      • 초기 데이터베이스 셋팅이 필요한 부분에 대해 하나하나 셋팅이 되어야 합니다
      • Kotlin DSL 이 잘 되어 있긴 하지만 response 를 검증할 때 라이브러리 사용방법을 익혀야 제대로 사용할 수 있습니다
  • @SpringBootTest 의 Base 클래스인 SpringTestSupport 를 상속받습니다
  • 통합 테스트에 필요한 기능을 protected 를 통해 제공해 줄 수 있습니다
  • 유틸성 메소드도 공통 클래스로 추가해서 테스트 코드의 편의를 높일 수 있습니다

실제 사용 예시(컨플루언스)

  • 서비스 테스트
    • 장점
      • 통합 테스트의 Base 클래스로서 일관성있는 상속으로 테스트를 처리할 수 있습니다
    • 단점
      • DB 까지 연결 됨에 따라 해당 환경에 의존성이 있습니다
  • @TestConstructor 를 통해 테스트 코드에서 생성자 주입이 가능해졌습니다

실제사용 예시

 

Mock 에 대해

  • 다양한 테스트 상황을 위해서 임의이 가짜 객체를 만드는 것을 말합니다
  • Mock의 검증 순서는 아래의 패턴을 반복합니다
    • Mock 만들기 (create)
    • Mock 동작 지정 (stub)
    • Mock 의 사용 (test)
    • 검증 (verify)
  • Mock 사용 시 주의할 점 및 적절한 사용 방법
    • 실제 환경에서는 제대로 동작하지 않을 수 있습니다
    • Mock 을 사용한다면 성공을 의도하고 테스트를 작성할 수 있어 완벽한 테스트로 보기는 힘들게 됩니다

 

슬라이스 테스트에 대해

사용하는 테스트와 구현 클래스

  • 슬라이스 테스트의 Annotation 종류
    • @WebMvcTest
    • @DataJpaTest
    • @MybatisTest
    • @JsonTest
    • @RestClientTest
  • Mock API 테스트
    • 장점
      • 통합 테스트 보다 빠르게 테스트 할 수 있습니다
      • 통합 테스트를 진행하기 어려운 테스트를 진행합니다
        • 외부 API 같은 Rollback 처리가 힘들거나 불가능한 테스트를 주로 합니다
          • 예를 들면 외부 결제 모듈 API를 호출해서 블랙박스를 Mock으로 치환해서 이용해야 할 때 사용할 수 있습니다
    • 단점
      • Mock 기반으로 테스트하기 때문에 실제 환경에서는 제대로 동작하지 않을 수 있습니다
  • @TestEnvironment 로 테스트 Profile 을 주입 받습니다
  • MockMvc 의 경우는 setup 과정을 거쳐서 설정을 하고 ObjectMapper 는 @Autowire 로 의존성 주입을 받습니다
  • @RestDocsConfiguration 을 추가해서 RestDocs 기능을 추가할 수 있습니다
    • Swagger-UI 를 쓰면서 테스트를 같이 할 수 있고 Rest-Doc 을 안 쓰면서 Mvc 테스트를 할 수 있습니다
    • API 개수가 많아진다면 Rest Docs 의 정적 페이지로는 한계가 있습니다
    • Rest Docs 웹 페이지를 도메인에 따라 분리하거나 Swagger-UI로 많은 API 목록을 슬라이드 동적 기능으로 처리하고 Controller Layer 의 단위 테스트를 따로 하는 것도 고려해 볼 수 있습니다

실제사용 예시

  • Mock 서비스 테스트
    • 장점
      • 진행하고자 하는 비즈니스에만 집중해서 진행할 수 있습니다
      • 중요한 관점이 아닌 것들은 Mocking 해서 외부 의존성을 줄일 수 있습니다
    • 단점
      • 의존성있는 객체를 Mocking 하기 때문에 온전한 테스트가 아닙니다
      • Mocking 하기가 귀찮고 이 라이브러리에 대한 선행 학습이 필요합니다
  • 주로 Service 영역이나 Adaptor 영역을 테스트 합니다
  • Spring Layer 는 아니지만 다양한 라이브러리들의 간단한 기능 단위 테스트를 할 수 있습니다
  • Mocking 기반의 테스트를 합니다
    • 코틀린에서는 Mockito 가 아닌 MockK 를 사용합니다

실제사용 예시

  • Mock Repository 테스트
    • 장점
      • Repository 관련된 Bean 들만 등록하기 때문에 통합 테스트에 비해서 빠릅니다
      • Repository 에 대한 관심사만 가지기 때문에 테스트 범위가 작습니다
    • 단점
      • 테스트 범위가 작아서 실제 환경과 차이가 발생할 수 있습니다
  • @DataJpaTest 로 JPA 관련 Bean 만 로드 합니다
  • 테스트가 끝날 때마다 자동으로 테스트에 사용한 데이터를 롤백합니다
  • @TestEnvironment 로 Test Profile 을 주입받습니다
  • @AutoConfigureTestDatabase 로 DB 설정을 합니다
    • Replace.ANY 를 사용하면 기본으로 내장된 임베디드 데이터베이스를 사용합니다
    • Replace.NONE 을 사용하면 profile 에 지정된 데이터소스를 사용합니다

실제사용 예시

 

외전 : JPA 가 아닌 Mybatis 를 쓰고 있어요!!

CRM 의 레거시에는 JPA 는 있지 않습니다 사실 단일화 된 데이터베이스 연결 기술이라는 부분보다는 JDBC 와 관련된 다양한 라이브러리들을 기반으로 어플리케이션 개발하는 일이 더 많을 것으로 생각됩니다

실제 KCRM에서 Mybatis 단위 테스트한 코드를 예제로 담았습니다 여기서 사용한 Test Annotation은 Mock Service 로 만든 @SpringServiceTestSupport 입니다

Port and Adapter 패턴을 따르고 있기 때문에 SQL 이 아닌 NoSQL 연결이 생기더라도 일관성있는 테스트 행위가 필요합니다

그렇기 때문에 라이브러리에 종속되는 Annotation 을 쓰기보다는 어느 Layer 에서 테스트가 이루어 지는지 구조적인 관점에서 범용성을 가져야 한다고 생각했습니다

Mybatis 인지 JPA 인지가 중요하지 않게 되었습니다

검증하려는 기능에 맞게 Given - When - Then 패턴으로 확인 하면 됩니다

분량 떄문에 모든 테스트 케이스에 따른 예제를 담지는 못했는데 전반적인 내용은 위 Mybatis 와 비슷합니다

 

  • POJO 테스트
    • 객체의 기능에 대한 테스트를 진행합니다 (Entity, DTO, VO, etc.. )
    • 객체지향에서 본인의 책임(기능)은 본인 스스로 제공해야 합니다
    • JPA 기반에서는 Entity로 대표되지만 일반적인 POJO 기반의 Test를 통칭합니다
    • Entity 대상이 테스트 될 수 있고 Utility 클래스가 대상이 될 수 있고 Domain 모델이 대상이 될 수 있습니다
      • 장점
        • 외부에서 주입받을 의존성이 없어 Mocking 에 대한 대상도 없습니다
        • Entity 객체는 사용하는 계층이 많으므로 테스트의 효율성이 높습니다
      • 단점
        • 부분 적인 객체에 대해서만 테스트가 되기 때문에 통합적인 관점에서 비즈니스를 대표할 수는 없습니다
  • DTO 에서 쓰이는 Spring Validation 기능을 테스트 하기위해 Validator 를 주입 했습니다
  • POJO 의 생성을 담당하는 Builder는 따로 구현 했습니다 (기본값 지정)

 

테스트 커버리지와 테스트 자동화

  • 더 나은 코드를 만들기 위해서는 피드백이 필요합니다
  • 한번에 완벽하게 해결하기를 바라는 것보다 점진적 개선에 만족하고 피드백을 통해 점점 완벽에 다가 갑니다

코드 커버리지란?

In computer science, test coverage is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs. A program with high test coverage, measured as a percentage, has had more of its source code executed during testing, which suggests it has a lower chance of containing undetected software bugs compared to a program with low test coverage. - wikipedia

  • 소프트웨어의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표 중 하나입니다
  • 테스트를 진행했을 때 코드 자체가 얼마나 실행되었느냐는 것이고 이를 수치를 통해 확인할 수 있습니다

코드 커버리지의 측정 기준

  • 소스 코드를 기반으로 수행하는 화이트 박스 테스트를 통해 측정합니다
    • 블랙 박스 테스트
      • 소프트웨어의 내부 구조나 작동 원리를 모르는 상태에서 동작을 검사하는 방식입니다
      • 올바른 입력과 올바르지 않은 입력을 입력해서 각각 상황에 맞는 출력이 나오는지 테스트 하는 기법입니다
      • 사용자 관점의 테스트 방법이라 볼 수 있습니다
    • 화이트 박스 테스트
      • 응용 프로그램의 내부 구조와 동작을 검사하는 테스트 방식입니다
      • 소프트웨어 내부 소스 코드를 테스트 하는 기법입니다
      • 개발자 관점의 단위 테스트 방법이라고 볼 수 있습니다

코드 커버리지가 왜 중요하죠?

  • 코드 커버리지의 중요성은 테스트 코드의 중요성과 같습니다
  • 테스트 코드는 발생할 수 있는 모든 시나리오에 대해 작성되어야 합니다 그런데 개발자도 사람인지라 테스트로 커버하지 못하는 부분이 발생될 수 있습니다
    • 이럴 때 테스트에서 놓칠 수 있는 부분들을 코드 커버리지를 통해 눈으로 확인할 수 있습니다
  • 코드 커버리지는 휴먼에러를 최대한 방지할 수 있도록 도와주는 용도라고 생각해도 될 것 입니다

코드 커버리지를 활용하는 방법

  • 코드 커버리지와 소나큐브와 같은 정적 코드 분석을 함께 활용해서 코드 커버리지가 기존보다 떨어지는 경우 커밋이 불가능하도록 제한할 수도 있습니다
  • 이처럼 코드 커머비지는 코드의 안정성을 어느정도 보장 해 줄 수 있는 지표이기 때문에 테스트 코드의 중요성을 느낀다면 휴면에러를 내지 않기 위해서라도 코드 커버리지를 측정하고 발전시키려고 노력해야 합니다

Test 단계에서 실패된 CI pipeline 예시 (Mauve point)

 

JaCoCo 를 통한 테스트 커버리지 측정

예제 프로젝트 코드 커버리지 : 82% 달성

 

테스트 코드 작성에서 끝이 아니라 테스트 자동화를 통한 빌드와 배포까지 적용할 수 있어야 어플리케이션의 품질을 보장할 수 있는 수단이 될 것 으로 보입니다

현재 CRM 파트에서 운영하는 어플리케이션에는 테스트 코드와 코드 커버리지를 통한 CI/CD 레벨의 테스트 자동화를 도입해보고 있습니다

여기에 코드 품질(커버리지 수치 높이기, 코드 리뷰 하기, (신규개발자, 기존 인원을 위한)개발 문서 작성 등과 같은 SW 개발의 완성도를 높이는 방법들을 도입해보려 하고 있습니다

아직 걸음마 단계라 많은 피드백과 제안들은 언제나 환영입니다!! (문서 및 코드)

내용이 길어질 것 같아 깨달음에 대한 내용 보다는 인용 부분이 많습니다 추가로 참조 내역을 남깁니다

컨플루언스에는 개발 가이드로서 발전되도록 꾸준히 업데이트 할 예정입니다

 

Reference

'My Work > Tech Blog' 카테고리의 다른 글

Spring Boot 3.0 Release Notes  (0) 2025.10.12
서버의 Error 에 따른 HTTP Status Code  (0) 2025.10.12
테스트 기반 개발 방법 도입기 1  (0) 2025.10.12
코드 리뷰 - 잘할 수 있을까?  (0) 2025.10.12
Ktor를 써 봤습니다  (0) 2025.10.12

Kotlin Spring Boot 기반의 신규 프로젝트를 시작했습니다

기술 스펙은 아래와 같습니다

  • Spring Boot 2.6.6
    • 2.7 버전으로 올릴 수 있음
  • Kotlin 1.6.10
    • 2.0 버전으로 올릴 수 있음 or 1.6.21
  • Persistence
    • JPA
    • Mybatis
  • Test
    • JUnit - Kotlin 기반의 Kotest 가 좋다고 하지만 도입하지는 않았습니다
  • Security
    • Session - Cookie

Clean Architecture 를 표방하기 때문에 Mauve Point 와 비슷한 구조로 진행됩니다

다만 Legacy 부분과 격리 및 연동을 위해 가능하다면 Domain Model 을 도출해 재사용 가능한 Model 과 비즈니스 로직을 분리하려고 합니다

Legacy 영역에서는 Mybatis 가 쓰이고 그 밖의 새로운 기능은 JPA로 구현하기로 했습니다

앞으로는 테스트를 거쳐야만 빌드/배포가 가능한 SW 개발을 하기 위해 시작한 부분을 설명하고 발전시킬 부분에 대해 얘기하고자 합니다

먼저 선행 학습으로 본 내용으로는 최범균님이 소개해준 내용을 참고했습니다

  1. 세미나 공유 - 테스트
  2. 세미나 공유 - TDD 테스트 작성 순서, 기능 명세, 그리고 시연 한 번 더
  3. 세미나 공유 - TDD 테스트 코드 구조, 대역
  4. 세미나 공유 - 테스트 가능 구조

 

첫 번째 시도

위 내용을 기반으로 작성했고 Java 기반으로 되어 있기 때문에 이 부분을 Kotlin 으로 전부 변환 해봤습니다

왜? 수고스럽게 변환을 진행했냐면 기존에 사용했던 Swagger 형태에 비해 UI 가 많이 별로 입니다

Swagger-ui 코드를 커스텀하게 수정해서 사용한 적이 있는데 내부에 Backbone-JS 라는 것으로 동적 HTML 생성이 가능하게 되어 있습니다

정적인 화면이 아니라 클릭 이벤트나 테스트도 가능할 정도로 입력에 있어 사용자와 Interactive 하게 만들어지는 장점이 있습니다

Rest Doc 은 정적인 HTML 화면입니다 즉 스크롤 기능도 제한적이고 슬라이드 업/다운 같은 기능도 없습니다

화면 구성을 직접 구성해야 하고 공통 코드나 Request/Response 에 대해서도 표를 따로 구성해야 합니다

기본 화면 Base를 따라 가기 위해 직접 우아한 형제들 오픈 프로젝트를 Kotlin 언어로 변환해서 클론 코딩을 해봤습니다

적은 시간으로 아래와 같은 Utils 와 공통 부분을 생성했습니다

index.html 이 산출물 파일이며 저 파일을 서버에 올려서 웹 브라우저에서 볼 수도 있습니다
  • ApiDocumentUtil : Pretty Print 설정을 하는 파일입니다
  • CustomResponseFieldsSnippet : 테스트로는 API 에 대한 데이터만 만들어지기 때문에 공통 코드에 대해 Generic 하게 처리하는 부분입니다
  • DocumentFormatGenerator : DateTime Format 을 정하는 부분으로 필요한 Format 을 추가할 수 있습니다
  • DocumentLinkGenerator : 팝업 화면을 설정할 수 있는 부분입니다
  • MockitoHelper : Java 버전에는 없는 부분으로 Kotlin이 기본 Final Class 속성이어서 Mocking 이 안되는 부분이 있어 추가해 넣었습니다
  • CommonDocumentationTest, Docs, EnumViewController : 응답 코드/에러 코드 와 같은 공통 데이터를 정의한 부분입니다

Swagger 로 작성된 문서와 비교하면 더 어렵고 불편한 부분이 있습니다

Swagger 의 장점

  • UI 가 더 예쁘게 나온다
  • 문서가 편하게 자동 완성이 된다

Rest Docs 의 장점

  • 테스트 기반 코드로 문서를 만들 수 있다
    • 즉 테스트기반 코드를 만들 수 있다

 

테스트를 수행하면 아래와 같은 폴더에 snippet 파일이 생기고 index.adoc 파일을 직접 편집하면 파일 기반으로 index.html 이 생성됩니다

Swagger-UI 에 비교하면 아래 내용이 추가로 한땀 한땀 만들어야 하는 부분이 있어 불편하긴 합니다

 

두 번째 시도

  • 빌드 및 배포 상황에 테스트 먼저 수행하기

tasks {
        val snippetsDir by extra { file("build/generated-snippets") }  // 변경   
       
       clean {
              delete("src/main/resources/static/docs")
       }  
        test {
               useJUnitPlatform() 
               outputs.dir(snippetsDir)
        }      
        asciidoctor {
                dependsOn(test)
                inputs.dir(snippetsDir)  
                doFirst {
                         delete("src/main/resources/static/docs")
                }      
        }
        register<Copy>("copyDocument") {
                  dependsOn(asciidoctor)  
                  from(asciidoctor.get().outputDir) {
                         into("src/main/resources/static/docs")
                  }
        }    
        build {
              dependsOn("copyDocument")   
        }  
        bootJar {
                dependsOn("copyDocument")
        }
}
일단 테스트 코드 작성에 대한 내용 자체는 여기서 한번 끊고 가려고 합니다 (일단 인프라 만 만들기)

현재 API 문서를 만들기 위한 테스트 부분은 API 요청과 응답에 대한 Controller 영역에 대한 슬라이스 테스트로 진행되는 부분으로 전체 적인 테스트를 하는 것은 아닙니다

통합 테스트 관점으로 모든 것을 해결하려하면 테스트 수행 시간이 점점 오래 걸리게 됩니다

점점 누적되고 쌓인 테스트로 개발-검증 생산성이 높아져야 하는 부분인데 이 부분은 분리해서 진행하려고 합니다

아래는 추가로 공부하려고 수집한 내용입니다

테스트를 도입하고 설계에 응용하는 방법과 의견들이 있으면 언제든 말을 걸어 주세요 (스터디도 있습니다)

코드 리뷰에 대해 고민된 부분과 이것을 잘 하는 방법은 무엇일까? 고민이 생겨서 코딩한줄 없는 내용이지만 한번 조사하고 정리해봤습니다

사실 코드 리뷰라면 본인이 한 것을 다른 사람에게 설명하고 납득시키고 설득하는 행위와 같은 부분이고 피드백을 받아 수정하고 더 좋은 품질의 코드를 만드는 과정이라고 볼 수 있을 것 같은데요

기존의 경험은 회의실에서 대면 리뷰로만 경험이 있어서인지 깃랩과 같은 플랫폼을 이용하고 비대면으로의 경험이 부족했습니다

지난 포인트 프로젝트의 경우도 구현에만 신경쓰면서 커밋 쪼개기나 작은 Pull Request 와 같은 부분을 자꾸 놓쳐서 아쉬움이 많았는데요

이 때는 상호 커뮤니테이션보다는 서비스 오픈이 중요한 부분이라 어느정도 용인이 되었던 상황입니다

하지만 역으로 제가 하는 경우에서도 제대로 하지 못했습니다. 이 때는 시간에 쫓길 일도 없었는데 전체적인 코드의 구성을 보지 못하고 결론만 보고 지나치게 되었습니다

예를 들면 "A 포인트를 적립하다" 라는 행위에 대해 개발한다면 이런 적립 API 상세 설계를 하게 되면 아래와 같은 행위에 대해 역할/책임/협력 들이 필요할 수 있습니다

누가 -> 회원 인지 / 조회  
무엇을 -> 어떤 포인트 종류들
어떻게 -> 데이터베이스 / 캐시 업데이트  
요청 / 응답 -> 데이터 모델 변환 필요

하지만 단순히 적립할 포인트 정보를 Database에 Insert 하고 끝이나는 내용으로도 볼 수 있을 것 입니다

그렇지만 이렇게 결과만 보고 아! 잘 되었구나 하고 끝나게 되면 "B 포인트를 적립하다" 이런 경우가 생기면 또 똑같은 기능을 다시한번 개발하고 아! 잘 되었구나 하고 또 끝나게 됩니다 또 "C 포인트를 적립하다" 라는 기능을 만들어야 하면 또 똑같은 기능을 만들고 아! 잘 되었구나 하는 의미없는 리뷰를 하게 됩니다

결국 포인트 적립 기능을 3개나 만들게 되었고 캐시업데이트 할 때 포인트 종류에 따라 if ~ else if ~ else if 하는 조건 처리도 늘어날 수 있습니다

각각의 기능은 의존적이지 않지만 포인트 적립이라는 공통의 목적(누가/무엇을/어떻게)은 처리해야 하니까요

비유가 이해되었을 지는 모르겠습니다만 저 스스로 의미 없는 리뷰를 하고 이게 리뷰였다니.. 하는 반성의 예시 였습니다

그렇다고 대면 리뷰를 하는 것이 더 좋을가요?

제 경험으로 얘기하자면 대면 리뷰는 회의실을 예약해야 하고 모니터로 실제 코드를 보면서 한줄 한줄 설명하듯이 진행하게 됩니다

리뷰어와 미리 예약을 하기 때문에 무엇을 할지는 알 수 있지만 그 사람이 모든걸 숙지하고 온다는 보장을 할 수 없고 저의 설명 기반으로 이끌기 때문에 말을 잘 못하면 꼬투리 잡히기 쉽고 실제로 1주일 내내 리뷰만 하다 겨우 머지한 적도 있습니다

이렇게 오래 가게 되면 멘탈도 탈탈 털리게 됩니다 그 당시 리뷰어는 구현 결과는 관심이 없어 보였네요 ㅎㅎ (제가 이름도 잘 못짓고 못한것이지만 느낌은 그냥 절 죽이려는 것 같았어요...)

아무튼 대면으로 진행하는 것도 매우 큰 리소스가 필요해 지는 경우로 보입니다

리뷰를 하는 것도 받는 것도 뭔가 표준화하거나 쉽게 접근하는 방법이 없을까? 이런 고민이 되었습니다

최근에 우아한 형제들 기술 불로그에 감명을 받아서 한번 이런 내용도 있을까 찾아보게 되었는데요 (아래 내용을 다 쓰기에는 글이 너무 길어져서 제가 생각한 중요한 포인트만 추렸습니다)

결국 중요한건 원자적인 접근(작게 쪼개고 작게 커뮤니케이션 하기)

선진국이라고 생각된 회사들도 코드 리뷰 문화에 대한 고민들이 있었고 그것을 개선하려는 움직임들이 있었습니다

각각의 문제 정의에 대한 부분은 저랑도 다를 수 있어서 결국 공통적으로 무엇을 해야 하는지만 끄집어 내보겠습니다

  • MR 템플릿 구성
    • MR 작성 규칙을 정하고 일관성있는 작성을 한다
    • MR 작성에 대한 고민을 줄어들게 하려는 목적
완성된 MR 템플릿
  # 해결하려는 문제가 무엇인가요?
  *
  # 어떻게 해결했나요?
  *
  ## Attachment
  * 이번 MR 의 Front 동작을 이해를 돕는 GIF 파일 첨부!
  * 리뷰어의 이해를 돕기 위한 모듈/클래스 설계에 대한 Diagram 포함!
 
  작성 예시
  # 해결하려는 문제가 무엇인가요?
  * TS2305: Module '"react-router"' has no exported member 'useHistory'. 에러를 내면서 빌드가 깨집니다.
    다른 모듈에 의해 react-router 버전이 5 -> 6으로 올라간 게 문제입니다.
  # 어떻게 해결했나요?
  * 사용하는 react-router의 버전을 package.json에 명시합니다.
  • 코드리뷰 규칙 문서 추가
    • MR 템플릿으로 어느 정도 통일은 갖췄으나 MR 크기와 코멘트 방식이 사람마다 달라서 추가 규칙을 문서로 작성
    • 리뷰이 규칙
      • 코드 리뷰의 설명은 최대한 자세하게 작성되어야 합니다
        • 리뷰 작성자는 본인이 알고 있는 사항을 리뷰어들도 알고 있을 것이라는 가정을 버리고 코드 리뷰에 대한 충분한 문맥이 전달될 수 있도록 코드 리뷰 설명을 자세히 작성해 주세요. (개발자 너 작가가 되라)
        • MR Template에 맞춰서 작성해 주세요. (규칙을 준수해 주세요...)
      • 작은 MR을 유지하세요
        • 리뷰어들이 코드 리뷰에 들어가는 시간을 줄이고 최대한 많은 버그들을 미리 발견해낼 수 있도록 코드 리뷰의 크기는 삭제를 포함해서 최대 300줄 미만으로 유지해 주세요.
        • 적합한 MR 단위는 어떻게 되나요?
          • 하나의 티켓에서 여러 개의 MR을 작성해도 됩니다.
          • 리팩토링 작업은 분리해 주세요.
          • 작게 쪼개기를 참고해 주세요.
      • 라벨로 코드 리뷰의 우선순위를 표시하세요 (D-n 규칙)
        • 코드 리뷰가 완료되어야 하는 시점을 D-N 형식의 라벨로 코드 리뷰에 추가해 주세요. 예를 들어, D-3 은 3일 이내에 코드 리뷰가 리뷰어에게서 확인되어야 한다는 의미입니다.
        • 이슈가 없는 코드 리뷰는 D-3 라벨로 표기하고 당장 긴급하게 시스템에 반영되어야 하는 사항은 D-0 라벨로 표기해서 모든 리뷰어가 긴급하게 코드 리뷰를 할 수 있도록해 주세요.
        • 매일 라벨을 업데이트해주세요.
        • D-0이 된 다음 날까지 어느 리뷰어도 코드 리뷰를 시작하지 않았다면 리뷰 작성자는 스스로 해당 변경 사항을 코드 베이스에 반영(Merge)할 수 있습니다.
      • 최소 한 명의 리뷰어에게 리뷰를 받아야 합니다
        • 같이 프로젝트를 진행하는 팀원들의 코드 리뷰를 받습니다.
        • 필요하면 같이 프로젝트를 진행하지 않는 팀원을 리뷰어로 할당해서 요청합니다.
      • 피드백을 반영하면 코멘트를 남겨주세요
        • 피드백에 변경한 내용이 무엇인지 리뷰어에게 알려주세요.
    • 리뷰어 규칙
      • 리뷰어는 코드 리뷰의 코멘트에 코멘트를 강조하고 싶은 정도를 Pn 규칙에 맞춰서 표기해 주세요:
       P1: 꼭 반영해 주세요 (Request changes)
       P2: 적극적으로 고려해 주세요 (Request changes)
       P3: 웬만하면 반영해 주세요 (Comment)
       P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)
       P5: 그냥 사소한 의견입니다 (Approve)
  • 리뷰 작성자를 칭찬해 주세요
    • 리뷰어는 코드 리뷰에 별달리 코멘트할 내용이 없다면 변경 사항을 작업하기 위해 수고한 리뷰 작성자를 칭찬하는 코멘트를 남겨주세요.

위 내용을 보니 반성이 많이 되었습니다

깃랩의 MR 작성부터 너무나 못했었기 때문에 애초에 리뷰가 잘 시작되는걸 바라는게 욕심이었습니다

상대방을 배려하지 못하고 정보를 개떡 같이 올려 놓고 리뷰 잘해주세요 라고 원하는 것이 기본 자세가 안되었고 가장 먼저 고쳐야 할 부분으로 보였습니다

천 마디 말보다는 잘 정리된 그림이 낫겠죠?

## Attachment
* 이번 MR 의 Front 동작을 이해를 돕는 GIF 파일 첨부!
* 리뷰어의 이해를 돕기 위한 모듈/클래스 설계에 대한 Diagram 포함!

작게 커뮤니케이션 하고 공통으로 대화할 수 있는 문서 템플릿을 만드는 정도의 노력? 으로 엔지니어 문화로 창출한 부분은 부러웠습니다

하지만 글을 자세히 보면 하나 더 필요한 문화가 있습니다

라인 - 코드 분석 및 코드 스타일 확인을 거치자

뱅크샐러드 - 인건비가 가장 비싼것 , 코딩 스타일 ( Indent, Convention )

  • 코드 리뷰에 임하는 자세
    • 코드 리뷰 과정에서 이루어지는 것
      • 일관된 아키텍처를 유지하고 있는지
      • 다른 해결 방법에 대한 의견 제시
      • 버그가 발생할 수 있는 가능성 제시
      • 기술적인 지식, 노하우 공유
      • 히스토리 전달

바로 일관성있는 코드 스타일에 대한 부분입니다 네이밍 규칙부터 언어적인 Case 예를 들면 Java는 Camel(소문자로 시작) 이지만 C#은 Pascal(대문자로 시작) 입니다

극단적으로 우아한형제들의 부트캠프 코스에 들어가려면 아래 제약사항을 지켜서 과제를 해야 합니다

else 를 쓰지않는 부분은 저도 연습중입니다

  • Java Style Guide
    • 내부에서 공통의 코드 형태를 갖추어서 오타같은 사소한 실수들을 막고 가독성을 극대화 하는 것
    • 코딩 스타일을 맞춤으로서 알아보기 어려운 코드 작성 자체를 막아버리고 최소한의 코드 품질을 보장 함
    • 컨플루언스와 같은 사내 위키에 명시해서 규칙을 공유
  • 정적 코드 분석과 코딩 스타일 확인은 SonarQubeESLint와 같은 툴
  • 코딩 스타일의 자동화 관련하여 리서치 결과 SwiftFormat을 도입했고, 스타일 규칙을 팀 내의 합의된 코딩 스타일을 룰로 정했습니다. 빌드 Phase에 SwiftFormat을 실행시켜 자동으로 코딩 스타일을 맞춰지는 프로세스를 만들었습니다
    • 이건 또 새로운 툴 공부...

 

모든 이상적인 부분을 맞추기에는 분명 한계가 있고 결국은 전문화된 QA/QC 조직이 있어서 제조업에서 말하는 Six sigma (완벽에 가까운 제품이나 서비스를 개발하고 제공하려는 목적으로 정립된 품질경영 기법) 정도의 수준으로 관리하려는 것으로 보입니다

많은 SW 회사가 관리 상한/하안을 두고 불량률을 관리하지는 않을 것으로 믿고 싶네요 (인더스트리 내부는 품질 조직은 있을 것 같네요)

마지막으로 사내 컨벤션까지 설정해서 우리가 협의한 대로 코드를 잘 작성했는지 이 부분까지 결정이 되야 진정한 코드 리뷰 조직이 될 수 있을 것 같습니다 모든 사람이 다 다르게 작성하고 리뷰하면 그것 때문에 또 피로감이 생길 수 있으니까요

내용이 길어졌는데 결국 지금 해야할 최소한의 첫걸음은 제가 Merge Request를 진심으로 잘 작성해서 리뷰어를 감동시키는 것이 첫번째가 되야 할 것 같습니다

 

마지막으로 우아한 형제에서 진행하는 교육에서 실제 코드리뷰 사례를 보고 마칩니다

출처(NEXT-STEP) : https://github.com/next-step/java-racingcar

 

+ Recent posts