드디어 그동안 작업하던 책 원고 작성을 마무리 지었다. 지난 5월에 편집장님으로부터 제안 메일을 받았었는데 11월에 마무리를 지었으니 약 6개월정도 집필기간을 가졌다. 회사일을 마치고 집에 돌아오면 항상 책 원고를 작성해야 해서 마음한켠 부담감을 항상 가지고 있어야 했다. 그렇다고 엄청 열심히 했느냐라고 물어본다면 그렇지도 않다^^;; 글을 쓰는데 소질이 있는 작가들도 책을 집필하는데 어려움을 느낀다고 하는데 글솜씨가 좋지 않은 나로써는 간단명료하게 적어왔던 내용들을 풀어서 쓰려다보니 다소 어색하거나 장황한 표현들도 많았던 것 같다. 그리고 비록 200페이지 내외의 적은 내용의 책이지만 소개하고 싶은 컨텐츠들을 생각해 내느라 분량에 대한 부담도 있었다. 어떤 부분에서는 분량을 위한 내용도 없지않아 있었던 것 같기도 하다 ^^;;

솔직히 잘썼다고 말하기 힘들고 독자층이 넓지도 않으리라 생각되어 큰기대는 하지 않고 있다. 책 내용으로 인해 이슈만 생기지 않았으면 하는 바램이다 ^^; 어찌되었든 책을 쓴다는 것을 한번 해보았다는 것에 만족한다. 앞으로 편집자님께서 내가 작성한 원고를 잘 포장해주실일만 남았다. 잘 마무리되어서 출판되었으면 좋겠다.


요즘들어 우리가 목적보다 수단에 집착하는 모습을 보이고 있지 않는가라는 생각을 많이 하게된다. 이러한 생각을 가지게된 사례가 대표적으로 애자일과 회고이다.

스타트업이라면 어느조직에서나 애자일 방법론을 이야기한다. 하지만 많은 곳에서 애자일 방법을 도입하였고 어떻게 하고 있다고 이야기 하고는 있지만 그 방법을 통해서 제품을 어떻게 성장시켰고 팀이 가진 문제를 어떻게 해결했는지에는 중요하게 다루고 있지 않은것 같다. 애자일하게 제품을 만들어가는 방법중 대표적인 두가지 방식이 바로 칸반과 스크럼이다. 이 두가지 모두 장점과 단점을 분명히 가지고 있기 때문에 팀 입장에서는 둘중 팀에 더 적합한 방법을 선택하고 단점을 보완하기위한 노력을 할것이다. 문제는 팀이 이 수단에 집착할때이다. 예를 들어보겠다. 스크럼방식을 채택한 하나의 팀이 있다. 이 팀은 매주 1주단위 스프린트를 수행한다. 문제는 매 1주마다 스프린트만 실행하지 지난 스프린트를 돌아보지 않는다는 것이다. 그냥 기계적으로 1주단위 스프린트를 실행하고 종료할 뿐이다. 나는 이러한 사례가 바로 목적을 잃어버리고 수단에만 집착하는 경우라고 생각한다. 애자일 방법론은 짧고 지속적인 피드백을 통해서 팀과 제품을 발전시켜나가는 일련의 과정을 말한다고 생각한다. 앞서 소개한 사례에서 진짜 제품을 성장시키고 싶다면 1주단위 스프린트를 당장 중지하고 왜 팀이 기계적으로 스프린트를 수행하는지부터 돌아보고 개선하기 위한 논의를 하는것이 바람직하다고 생각한다.

두번째 사례는 바로 회고이다. 앞서 말한 애자일 방법론에 대한 이야기의 연장선일 수 있을것 같다. 나는 회고의 목적이 바로 피드백과 개선이라 생각한다. 그렇기 때문에 회고는 자주 그리고 주기적으로 수행되는 것이 그 목적을 달성하기 쉬운 방법이라 생각한다. 왜냐하면 애자일에서 추구하는 목표인 짧고 지속적인 피드백을 통한 성장을 회고라는 수단으로도 달성할 수 있기 때문이다. 하지만 어느순간 회고도 그 목적성을 잃고 수단에만 집중하는 모습들을 종종 볼 수 있다. KPT, YWT, AAR 등 수많은 방법들은 회고에서 그렇게 중요하지 않을 수 있다. 다같이 모여 스티커를 붙이거나 Action Item을 도출하는 것도 왜 우리가 그것들을 해야하는지에 대한 목적을 잃는다면 큰 의미를 부여하기 힘들다. Action Item들을 도출해 낼때 중요한 포인트가 실행가능한 구체적인 방법을 나타내는 것이라 하였다. 그 이유는 다음 회고에서 달성여부를 쉽게 파악할 수 있고 이를 통해 개선방법을 모색하기 쉽기 때문이다. 즉, 회고는 일회성으로 끝나는 것이 아닌 짧은 주기로 지속적으로 수행하여 지난 개선사항들을 돌아보고 앞으로 팀에서 해야할 일들을 새롭게 도출해 내는 것이 중요하다고 생각한다. 이러한 목적을 잃어버리고 회고를 수행한다면 분기에 한번, 1년에 한두번 팀원들이 업무에 지치거나 불만이 가득찼을 때 회고한번 하자고 해야 수행하게 될 것이다. 이런 상황에서 개최되는 회고는 어떨지는 안봐도 알 수 있을 것이다. 나는 이러한 행위는 목적을 잃은 회고라 생각한다.

대표적인 사례 2가지만 들었지만 요구사항을 수용하여 제품을 개발할 때에도 마찬가지고 우리가 ‘성장’이라는 이름 하에 공부하는 것 등 많은 활동들에서 목적보다 수단에 집착하게 되면서 정작 중요한 것을 놓치는 경우를 많이 볼 수 있다. 이러한 것들을 경계해야 한다는 생각이 든다.

아래는 11월동안 정리한 이슈 내용들이다.

탐색적 테스팅

기능을 개발하다가 동료분께서 복잡한 기능을 만들 때 테스트 코드를 어떻게 작성하기 시작하면 좋을지 모르겠다는 말씀이 있었다. TDD를 하다보면 간단한 코드는 어렵지않게 작성하지만 복잡한 비지니스 논리를 가지고 있는 기능을 개발할때에는 어떻게 시작하면 좋을지 헤메는 경우를 종종 볼 수 있다. 나는 이럴 때 탐색적 테스팅을 종종 추천하곤한다.

간단한 기능들은 테스트케이스를 얼추 예상할 수 있다. 그래서 미리 테스트 케이스들을 만들어두고 코드를 작성할 수 있다. 하지만 복잡한 기능은 미리 테스트 케이스를 예상해 내기 힘들다. 그래서 테스트가 성공할 수 있는 가장 손쉬운 테스트부터 작성한다. 그런다음 다음으로 간단한 케이스를 하나씩 추가하는 것을 반복하는 것이다. 더이상 테스트케이스가 생각나지 않으면 종료하면 된다. 완벽하게 테스트 커버리지를 만족하지 않을까봐 걱정하지 않아도 된다. 어차피 완벽할 수 없기 때문이다. 내가 생각해 낼 수 있는 방법을 최대한 생각해보고 더이상 생각나지 않으면 거기에서 멈추면 된다. 이러한 방식으로 진행하다보면 의외로 복잡한 기능도 테스트 케이스는 단순하게 나올 수 있음을 발견하게 된다. 그리고 빠른 피드백과 점진적으로 테스트 케이스를 발전시켜나가기 때문에 자신감을 얻기 쉽다.

JVM Default GC

JVM의 Garbage Collector는 GC를 명시적으로 선택하지 않는다면 기본적으로 Application의 실행시점에 Collector를 선택한다. 선택 조건들은 아래와 같다.

  • 어플리케이션이 작은 데이터 셋(최대 약 100MB)을 사용하는 경우 SerialGC를 사용
  • 단일 프로세스에서 실행되고 pause-time requirements이 없는 경우 SerialGC를 사용
  • 어플리케이션 성능 우선순위가 첫번째이고 pause-time requirements이 없거나 1초 이상이 허용되는 경우 ParallelGC를 사용
  • 응답시간이 전체 처리량보다 중요하고 pause-time requirements이 1초 미만으로 유지해야 하는 경우 G1GC 또는 MarkSweepGC를 사용
  • 응답시간이 중요하고 힙 용량이 큰 경우 ZGC를 사용

참고: https://docs.oracle.com/en/java/javase/11/gctuning/available-collectors.html#GUID-F215A508-9E58-40B4-90A5-74E29BF3BD3C

JPA jsonb 컬럼 사용 시 Backing Property 이슈

JPA에서 jsonb 컬럼 저장 시 Backing Property를 사용하면 아래와 같은 오류가 반환된다.

data class EcountErpSaveSaleResponse(
    val data: EcountErpSaveSaleResponseData?,
    val errors: List<EcountErpSaveSaleResponseError>?,
    val status: String,
) {
    val isSuccess: Boolean get() = data != null && status == "200" && data.failCnt <= 0
}
java.lang.IllegalArgumentException: The given byte array cannot be transformed to Json object; nested exception is org.hibernate.HibernateException: java.lang.IllegalArgumentException: The given byte array cannot be transformed to Json object

그래서 아래와 같이 해결할 수 있다.

data class EcountErpSaveSaleResponse(
    val data: EcountErpSaveSaleResponseData?,
    val errors: List<EcountErpSaveSaleResponseError>?,
    val status: String,
    val isSuccess: Boolean = data != null && status == "200" && data.failCnt <= 0
)

매개변수를 받고 싶지 않은데…데이터 구조체에 로직을 넣으려는 나의 구현방법이 잘못된 것이라 생각이 들기도 하다. 차라리 생성자에는 데이터 그대로 받도록 하고 Factory를 만들어주거나 사용하는 코드쪽에서 비지니스 로직을 담아야 하나 싶기도 하다.

OSIV

OSIV(Open Session In View)는 Web Request를 통한 요청에 대해 Session을 Transaction이 종료되어도 Request가 종료될 때까지 Session을 유지하는 메커니즘을 말한다.

최근에 배치기능을 만들다가 OSIV의 이러한 특징을 간과해서 JPA Entity의 Lazy Loading 시 오류를 반환하는 버그를 발생시킨 사례가 있었다. 결국 Lazy Loading을 사용하지 않고 문제를 해결할 수 있도록 조치는 하였지만 배치에서도 OSIV와 같이 Session을 유지할 수 있는 메커니즘을 사용할 수 있으면 좋지 않을까라는 생각이 들었다. 물론 OSIV자체가 안티패턴이긴 하다. 그래서 권장하는 방법이 아니고 배치와 같이 무거운 작업에서는 더더욱 권장되지 않는 방법이다. 이 점을 염두해 두고 방법만 모색해 봐야겠다.

Consolidated Shipping

최근에 동일한 배송일을 가진 주문서이라는 이름을 영어로 어떻게 표현하면 좋을지 고민하다가 묶음 배송이라는 명칭을 영어로 표현하면 Consolidated Shipping이라는 것을 알게 되었다. 알아두면 좋을 것 같아서 적어둔다.

참고: https://www.chrobinson.com/en-us/resources/blog/freight-consolidation-bridging-gap-ltl-full-truckload-2/

Spring 3.0.0

스프링 3.0.0이 나왔다. major 버전을 올리는 기능은 하나밖에 없는데 Spring Integration components를 적용하는 방식 중 하나를 추가하는 것이다. 이는 스프링의 근본적인 기능을 변경하는거라 버전이 올라가는듯하다.

참고: https://docs.spring.io/spring-integration/docs/current/reference/html/overview.html

얼마나 바뀌었나 해서 현재 백엔드 서버의 버전을 3.0.0으로 업그레이드를 해보았다. 역시나 여기저기에서 붉은 오류 메시지가 표시된다. 먼저 Hibernate 버전이 6.x.x로 바뀌었다. Hibernate에서 6.x.x로 바뀌면서 패키지가 변경되었는데 이부분을 일단 모두 마이그레이션 해야한다.

Hibernate 마이그레이션 가이드: https://docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html

다음으로 생긴 문제는 QueryDSL에서 hibernate 6.x.x 버전을 지원해주지 않는다는것이다. 이슈를 남겼고 아래와 같이 설정을 해주면 된다고 한다.

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <version>${querydsl.version}</version>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <version>${querydsl.version}</version>
    <classifier>jakarta</classifier>
</dependency>

이슈 링크: https://github.com/querydsl/querydsl/issues/3436

다음은 @Constructingbinding이슈이다. 이제 더이상 Constructingbinding은 사용하지 않아도 된다고 한다.

스프링 마이그레이션 가이드 참고 : https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#constructingbinding-no-longer-needed-at-the-type-level

이틀동안 시간투자를 해보았는데 시간이 조금 일렀던것 같다. 특히 Spring Cloud 쪽에서 아직 Spring Boot 3.x,x 버전 대응이 아직 되어있지 않는 듯 하다. Spring initializer에서도 Spring 3.0.0으로 설정하고 Spring Cloud 의존성을 추가하면 RC 버전을 올리는 것을 볼 수 있다.

//... 생략

repositories {
  mavenCentral()
  maven { url = uri("https://repo.spring.io/milestone") }
}

extra["springCloudVersion"] = "2022.0.0-RC2"

dependencies {
  implementation("org.jetbrains.kotlin:kotlin-reflect")
  implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
  implementation("org.springframework.cloud:spring-cloud-starter-config")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
  imports {
    mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
  }
}

//... 생략

한두달 정도 뒤에 3.0.0 버전 업그레이드를 다시 시도해봐야겠다.

Gradle dynamic versioning issue

Gradle의 dynamic versioning을 실험적으로 사용해보고 있다. 다행스럽게도 최근까지는 별다른 이슈가 없었다. 하지만 Spring 3.0.0 업그레이드 테스트를 하면서 Spring Boot 버전을 3.0.0으로 변경하고 Spring Data ElasticSearch 버전을 4.4.+로 설정하니까 Spring Data ElasticSearch버전이 5.0.0으로 바뀌어버린다. 원치않게 Major 버전이 바뀌어버린 것이다. Dynamic versioning을 하용하지 않고 4.4.1로 설정해주니 정상적으로 4.4.1로 설정되었다. 비록 Spring 3.0.0으로 업그레이드 하지 않아서 해당 이슈는 큰 문제가 되지 않았지만 앞으로 이런 일이 발생하지 않으리란 보장이 없으므로 Dynamic Versioning을 사용하지 않기로 하였다.