IntelliJ에서는 MySQL, Oracle, PostgreSQL, Redis, Mongo DB 등을 포함한 다양한 데이터베이스에 대한 지원을 제공해주고 있습니다. 이를 통해서 동일한 도구를 이용해서 다양한 데이터베이스에 대한 작업을 수행할 수 있습니다.
해당 Database 설정은 Project Data Sources을 통해서 프로젝트 단위로 설정할 수 있습니다. 만약 다양한 프로젝트에서 이 설정을 공유해서 사용하고 싶다면 빨간색 박스 버튼을 누르면 Project Data Sources를 Global로 설정할 수 있습니다.
데이터베이스에서 DateTime이 UTC 기준으로 저장되어 있을 때, 특정 지역의 시간대로 설정하여 데이터 조회를 보다 편리하게 할 수 있습니다. 이 설정은 Data Editor and Viewer에서 Display temporal data in time zone
옵션을 통해 원하는 지역의 시간대로 변경할 수 있습니다.
해당 설정을 한 이후 조회 쿼리를 실행하여 결과를 확인 해보면 Asia/Seoul
기준으로 출력되는 것을 확인할 수 있습니다.
1 | // TODO: xxxx 2024-11-25 by yun.cheese |
Live Template에서 TODO를 설정 하여 코드 작성시 자동으로 주석을 추가할 수 있습니다. 이런 경우 TODO 작성시 $END$를 통해 커서를 이동시킬 수 있습니다. 또한 작성 날짜를 추가하고 싶다면 Edit variables
를 통해 Date
를 추가할 수 있습니다. Data Formatter을 yyyy-MM-dd
와 같이 설정할 수 있습니다.
1 | $ ./gradlew wrapper --gradle-version=8.5 |
Gradle Wrapper를 사용하는 경우, 사용하고 있는 버전을 8.5 이상으로 업데이트 및 IntelliJ를 사용하는 경우 프로젝트의 SDK 버전을 17 이상으로 설정하는 과정을 진행합니다.
Project Structure 설정에서 SDK, Language Level을 17 버전 이상으로 지정합니다.
Module SDK 버전도 동일한 버전으로 설정합니다.
Gradle 마지막으로 gradle 버전도 동일한 버전으로 설정합니다.
1 | plugins { |
3.2.1
으로 설정합니다.1.9.21
으로 설정합니다.1 | java { |
build.gradle.kts 설정에 각종 java version을 사전 설정과 동일한 버전으로 설정합니다.
Spring Data JPA에서의 주요 변경사항 중 하나는 패키지 경로의 변경입니다. 이전에 사용되던 javax.persistence
가 jakarta.persistence
로 업데이트되었습니다. IntelliJ의 Replace 기능을 이용하면 프로젝트 내의 모든 import 경로를 쉽게 변경할 수 있습니다. cmd + shift + r
단축키로 Replace 설정을 할 수 있습니다.
import javax.persistence
-> jakarta.persistence
작성한 이후 REPLACE
버튼으로 적용 합니다.
1 | dependencies { |
기존 querydsl 의존성도 변경을 변경을 진행합니다.
1 | $ ./gradlew build -x test |
해당 프로젝트를 빌드하면 QClass가 생성되는 것을 확인할 수 있습니다.
]]>cannot resolve symbol
오류는 대체로 SDK 버전 문제로 발생합니다. 이를 해결하기 위해서는 프로젝트 내의 SDK 버전을 일관되게 설정해야 합니다. 이 과정에서 여러 설정을 조정해야 하는데, 구체적인 SDK 설정 방법에 대해 안내해 드리겠습니다.Project Structure 설정에서 SDK, Language Level을 동일한 버전으로 설정합니다.
Module SDK 버전도 동일한 버전으로 설정합니다.
Gradle JVM SDK 버전도 동일하게 설정합니다.
Java Compiler 설정도 동일한 버전으로 설정합니다.
]]>1 |
|
이 코드는 Kotlin을 사용하여 MongoDB 문서에 대해 정의된 Member
클래스를 나타냅니다. 이 클래스에는 대략 11개의 필드가 정의되어 있으며, 테스트에 사용될 주요 필드는 name
입니다. 이 Member
클래스는 Auditable
추상 클래스를 상속받아, MongoDB 문서의 생성 및 수정 시간을 자동으로 추적합니다. 테스트 과정에서는 name
필드만을 대상으로 업데이트 작업을 수행하고 성능을 평가할 예정입니다. 이를 통해 MongoDB에서 단일 필드 업데이트의 성능을 파악하고자 합니다.
1 | fun updateSaveAll(members: List<Member>) { |
saveAll
메서드는 Spring Data MongoDB의 CrudRepository
인터페이스에서 제공하는 메서드로, 여러 개의 문서를 데이터베이스에 저장하거나 업데이트하는 데 사용됩니다. 동작 방식은 다음과 같습니다.
saveAll
메서드는 전달된 Member
객체 리스트를 순회하면서 각 객체의 id
필드를 확인합니다.Member
객체에 id
필드가 null
이거나 존재하지 않으면, 해당 객체는 새로운 문서로 간주되어 데이터베이스에 삽입됩니다.id
필드가 있는 Member
객체는 해당 id
를 가진 기존 문서를 업데이트합니다.이번 테스트에서는 saveAll
메서드를 사용하여 Member
객체의 name
필드를 업데이트하는 데 집중합니다. 테스트에 사용되는 모든 Member
객체는 이미 id
를 가지고 있으므로, 이 메서드는 모든 객체를 데이터베이스에 업데이트하는 작업으로 처리합니다. 이를 통해 saveAll
메서드가 대량의 업데이트 작업을 얼마나 효과적으로 처리할 수 있는지 성능을 평가하고자 합니다.
1 | fun updateFirst(id: ObjectId): UpdateResult { |
updateFirst
메서드는 Spring Data MongoDB의 MongoTemplate
을 사용하여 특정 조건을 만족하는 첫 번째 문서를 업데이트하는 기능을 제공합니다. 이 메서드는 주어진 쿼리에 따라 데이터베이스 내에서 일치하는 첫 번째 문서를 찾아 해당 필드를 업데이트합니다. 동작 방식은 다음과 같습니다.
updateFirst
는 Query
객체를 사용하여 업데이트할 문서를 찾습니다. 이 예제에서는 Criteria.where("_id").is(id)
를 통해 특정 id
값을 가진 문서를 찾습니다.Update
객체를 사용하여 업데이트할 내용을 지정합니다. 여기서는 name
필드를 새롭게 생성된 무작위 UUID 문자열로 설정합니다.UpdateResult
를 반환하여 업데이트 작업의 결과를 나타냅니다. 이를 통해 몇 개의 문서가 영향을 받았는지 확인할 수 있습니다.이번 테스트에서는 updateFirst
메서드를 사용하여 Member
클래스의 name
필드를 업데이트합니다. 테스트는 특정 id
를 가진 Member
문서를 대상으로 하며, 이 메서드는 해당 문서의 name
필드를 새로운 값으로 업데이트합니다. 이 방법을 통해 updateFirst
메서드의 단일 문서 업데이트 성능을 평가하고자 합니다.
1 | fun updateBulk( |
bulkOps
메서드는 Spring Data MongoDB의 MongoTemplate
을 사용하여 대량의 업데이트 작업을 효율적으로 처리하는 방법을 제공합니다. bulkOps
는 한 번의 연산으로 여러 업데이트 작업을 모아서 실행할 수 있으며, BulkMode
에 따라 순서대로(ORDERED
) 또는 순서에 구애받지 않고(UNORDERED
) 실행할 수 있습니다. 동작 방식은 다음과 같습니다.
bulkOps
는 주어진 BulkMode
와 문서 클래스(Member::class.java
)를 기반으로 초기화됩니다.updateOne
메서드를 사용하여 각 id
에 대한 업데이트 작업을 추가합니다. 여기서는 name
필드를 새로운 무작위 UUID 문자열로 설정합니다.execute
메서드를 호출하여 누적된 모든 업데이트 작업을 한 번에 실행합니다.BulkWriteResult
를 반환하여 대량 업데이트 작업의 결과를 나타냅니다.이번 테스트에서는 bulkOps
메서드를 사용하여 Member
클래스의 name
필드를 대량으로 업데이트합니다. 여러 id
를 가진 Member
문서에 대해 각각 name
필드를 새로운 값으로 업데이트하는 작업을 모아 한 번에 실행합니다. 이 방법을 통해 bulkOps
메서드의 대량 업데이트 성능과 UNORDERED
와 ORDERED
모드 간의 성능 차이를 평가하고자 합니다.
BulkOperations.BulkMode.UNORDERED
:
BulkOperations.BulkMode.ORDERED
:
rows | saveAll | updateFirst | bulkOps(UNORDERED) | bulkOps(ORDERED) |
---|---|---|---|---|
100 | 1,052 ms | 1,176 ms | 46 ms | 79 ms |
200 | 2,304 ms | 2,196 ms | 103 ms | 124 ms |
500 | 5,658 ms | 5,250 ms | 309 ms | 257 ms |
1,000 | 11,106 ms | 10,846 ms | 418 ms | 412 ms |
2,000 | 22,592 ms | 21,427 ms | 1,060 ms | 1,004 ms |
5,000 | 54,407 ms | 52,075 ms | 2,663 ms | 2,292 ms |
10,000 | 107,651 ms | 110,884 ms | 4,514 ms | 4,496 ms |
결과는 saveAll
, updateFirst
, bulkOps(UNORDERED)
, bulkOps(ORDERED)
네 가지 방법에 대해 다양한 행(rows) 수에 따라 수행 시간(밀리초)을 비교합니다.
saveAll과
updateFirst
saveAll
과 updateFirst
메서드의 성능 차이는 유의미하지 않습니다. 따라서, 상대적으로 데이터 양이 적은 경우에는 upsert 기능을 제공하는 saveAll
을 사용하여 로직을 단순화할 수 있습니다.updateFirst
메서드를 사용하여 기본 키(PK)를 기반으로 업데이트를 수행했습니다. 그러나 다른 키 값으로 조회를 진행할 경우, 조회 속도가 느려져 성능 차이가 발생할 수 있습니다.saveAll
메서드는 Member
객체의 모든 변경 사항을 반영합니다. 따라서, 특정 필드만을 명확하게 업데이트하고자 할 때는 updateFirst
와 같은 메서드를 사용하여 정확한 업데이트 쿼리를 작성하는 것이 좋은 대안이 될 수 있습니다. 이 방법은 업데이트하고자 하는 필드를 직접 지정할 수 있어, 더 세밀한 데이터 업데이트 제어가 가능합니다.bulkOps(UNORDERED)
와 bulkOps(ORDERED)
saveAll
과 updateFirst
에 비해 현저히 빠른 성능을 보입니다. 특히 bulkOps(UNORDERED)
는 가장 빠른 처리 시간을 나타냅니다.bulkOps(UNORDERED)
는 순서에 구애받지 않고 여러 작업을 동시에 처리할 수 있기 때문에, 대량의 데이터 처리에 더 효율적이며, 개별 작업들이 독립적으로 처리됩니다. 이는 특정 작업이 실패해도 다른 작업들에 영향을 주지 않는다는 것을 의미합니다.bulkOps(ORDERED)
도 비교적 빠른 성능을 보이지만, bulkOps(UNORDERED)
에 비해 약간 느린 경향이 있습니다. 이는 작업을 순서대로 처리해야 하는 부가적인 비용 때문 이며, 순차적으로 작업이 진행되기 때문에 한 작업이 실패하면 그 이후의 작업은 실행되지 않을 수 있습니다.bulkOps(UNORDERED)
와 bulkOps(ORDERED)
방식은 10,000개의 데이터 모수까지는 큰 성능 차이가 나타나지 않았습니다. 그러나 데이터가 많은 노드에 분산되어 저장된 경우, 이 두 방식 사이에서 더 유의미한 성능 차이가 발생할 수 있습니다. 분산 환경에서는 데이터의 위치와 네트워크 지연이 성능에 영향을 미칠 수 있으며, 이러한 조건에서는 bulkOps(UNORDERED)
와 bulkOps(ORDERED)
의 처리 방식 차이가 더 명확하게 드러날 가능성이 있습니다.saveAll
과 updateFirst
메서드가 적합할 수 있습니다. 하지만 데이터 양이 많아질수록 이 두 방법의 성능은 상대적으로 감소합니다. 데이터 모수가 적은 경우, saveAll
과 updateFirst
각각의 장단점이 있으므로, 특정 환경과 요구사항에 맞게 적절한 메서드를 선택하는 것이 중요합니다.bulkOps
메서드 사용이 효율적입니다. bulkOps(UNORDERED)
와 bulkOps(ORDERED)
각각의 장단점이 존재하므로, 이 두 방식 중에서는 특정 환경과 요구사항에 맞게 적절한 옵션을 선택하는 것이 중요합니다.이러한 결과는 MongoDB 데이터 업데이트 전략을 선택할 때 중요한 고려 사항을 제공합니다. 데이터의 양, 업데이트의 복잡성, 순서의 중요성 등을 고려하여 적절한 방법을 선택할 필요가 있습니다.
이전 포스팅인 Spring Data MongoDB Repository 확장에서는 MongoCustomRepositorySupport
를 사용해 MongoRepository
에 편의 기능을 추가하고, 보일러플레이트 코드를 줄이는 방법을 소개했습니다. 이 방법은 코드의 재사용성을 높이는 효과가 있습니다. 마찬가지로, bulkOps
와 같은 반복적인 코드도 MongoCustomRepositorySupport
에 통합함으로써 더 편리하게 기능을 제공할 수 있습니다. 이렇게 하면 bulkOps
관련 코드를 중앙화하여 관리 및 사용의 용이성을 향상시킬 수 있습니다.
1 |
|
코드는 외부에서 정의된 쿼리와 업데이트 로직을 사용하여 데이터 업데이트를 수행합니다. 코드는 Pair
리스트를 통해 각 업데이트 작업에 필요한 Query
와 Update
객체를 정의하고, 이를 memberRepository
의 updateName
메서드에 전달하여 BulkOperations.BulkMode.UNORDERED
모드로 업데이트를 진행합니다. 이 방식은 업데이트 과정을 유연하게 처리할 수 있게 해줍니다.
위 이미지처럼 주문 테스트 코드에 대한 다양한 테스트 케이스를 작성하기 위해서는 다양한 데이터를 셋업 하는 것은 필수적입니다. 다양한 테스트 케이스를 작성하지 못하면 테스트 케이스가 커버하는 범위가 좁아지며, 이로 인해 테스트 코드로부터 양질의 피드백을 받을 수 없게 됩니다. 따라서, 테스트 코드를 쉽게 작성하고 다양한 시나리오를 손쉽게 검증할 수 있는 환경을 만드는 것이 중요합니다.
스프링에서는 @Sql 어노테이션을 이용해 테스트 데이터를 간단히 셋업 할 수 있으며, 이를 통해 테스트 케이스를 원활하게 확장할 수 있습니다. 이에 관한 자세한 방법은 Sql을 통해서 테스트 코드를 쉽게 작성하자" 포스팅에서 설명하고 있습니다. 해당 포스팅에서는 Sql을 활용하여 다양한 테스트 데이터를 쉽게 구성하는 방법을 제공합니다.
1 |
|
@SqlGroup
어노테이션을 사용하면 *.sql
파일을 통해 테스트 데이터를 쉽게 준비할 수 있습니다. 이렇게 데이터를 만들면 setter를 막아 데이터 변경 단위를 논리적으로 제공하는 경우라면 큰 장점이 있습니다.
이런 경우 *.sql
파일을 활용하면, setter가 없는 객체에 대해서도 비즈니스 로직의 제약 없이 테스트 데이터를 간편하게 준비할 수 있어, 다양한 테스트 케이스를 쉽게 설정할 수 있습니다.
@Sql
와 같은 어노테이 어노테이션을 제공해주지 않기 때문에 직접 만들어야 합니다. 우선 적용된 코드부터 살펴보도록하겠습니다.
1 |
|
테스트를 위해 Foo, Bar Document를 간단한 필드들로 구성하며, Auditable
을 상속하여 공통된 필드를 갖도록 합니다.
1 | //(1) |
적용된 코드부터 살펴보고 이후 구현 코드를 살펴보도록하겠습니다.
@MongoTestSupport
설정을 통해서 테스트 실행 리스너로 추가하여 데이터 설정을 자동화합니다.@MongoDataSetup
은 해당 JSON 파일일을 읽어 MongoDB에 삽입합니다.jsonPath
은 test/resources/
디렉토리에 위치한 JSON 파일의 경로를 지정합니다. 마지막으로,@MongoDataSetup
어노테이션을 사용하면, JSON 파일을 통해 MongoDB 테스트 데이터를 간편하게 설정할 수 있으며, 테스트 실행 시 Foo Document 객체가 성공적으로 저장되어 조회되는 것을 확인할 수 있습니다.
1 | /** |
단일 문서 데이터 셋업에 사용됩니다.
1 | /** |
여러 문서의 데이터 셋업 시 사용합니다.
1 | class MongoDataSetupExecutionListener : TestExecutionListener { |
MongoDataSetupExecutionListener
는 TestExecutionListener
를 상속받아 구현되며, 스프링의 테스트 컨텍스트 프레임워크를 사용합니다. 이 리스너는 beforeTestMethod
와 afterTestMethod
이벤트를 활용해 테스트 메소드 실행 전에 데이터를 준비하고, 실행 후에 데이터를 정리하는 기능을 수행합니다. 이 리스너는 TestContext
에 의존하여 테스트 애플리케이션 컨텍스트에서 Bean을 쉽게 가져올 수 있으며, 예시에서는 MongoTemplate
를 추출하는 데 사용되며 테스트 환경을 설정하거나, 테스트 데이터를 초기화하거나, 테스트 결과를 정리하는 등의 작업을 자동화하는 데 유용합니다. TestExecutionListener
인터페이스를 구현하고, 스프링의 테스트에 @TestExecutionListeners
어노테이션을 사용하여 리스너를 등록함으로써 활용할 수 있습니다.
1 |
|
MongoDataSetupExecutionListener
는 테스트에 필요한 리스너로, TestExecutionListeners
어노테이션을 통해 등록합니다. 이 과정을 단순화하기 위해 MongoTestSupport
어노테이션을 생성하여, TestExecutionListeners
설정을 손쉽게 적용할 수 있도록 합니다.
1 |
|
클래스 상단에 @MongoTestSupport
어노테이션을 추가함으로써, MongoDataSetupExecutionListener
가 포함된 TestExecutionListeners
를 쉽게 사용할 수 있습니다.
1 |
|
MongoDataSetups
를 사용하면 다수의 MongoDataSetup
인스턴스를 결합하여 여러 문서를 쉽게 설정할 수 있습니다.
1 | data class FooProjection( |
Document 클래스가 아닌 Projection 객체를 이용해 데이터를 설정하려면, collectionName을 사용하여 명시적으로 컬렉션 이름을 지정함으로써 데이터를 원하는 컬렉션에 설정할 수 있습니다.
1 |
|
@SpringBootTest
어노테이션을 사용하는 테스트 클래스에 @MongoTestSupport
를 추가함으로써, 개별 테스트 클래스에서 어노테이션을 중복하여 작성할 필요 없이 모든 테스트에 적용될 수 있으며, 이를 통해 Application Context를 효율적으로 재사용할 수 있습니다. 이러한 방식은 공통적인 설정을 일관되게 관리하는 데에도 도움이 됩니다.
@Sql
방식과 마찬가지로, JSON 파일 기반의 데이터 셋업에는 단점이 있습니다. 코드 내에서 객체를 생성하면 변수명과 주석을 통해 명확한 컨텍스트를 제공할 수 있는 반면, JSON 방식은 이러한 세부 사항을 전달하기 어렵습니다. 또한, Document
클래스의 코드 변경 시 관련 JSON 파일을 수동으로 업데이트해야 하는 번거로움이 있습니다.
@MongoDataSetup
을 사용한 데이터 셋업은 주로 대량의 데이터나 복잡한 데이터 조합이 필요한 로직을 테스트할 때 추천됩니다. 이런 케이스에서는 코드로 직접 작성할 경우 유지보수 비용이 증가하고, 변수명과 주석을 통한 컨텍스트 전달에 한계가 있기 때문에, 이러한 상황에서 @MongoDataSetup
의 사용이 효과적입니다.
이 포스팅에서는 MongoDB 데이터를 쉽게 설정하는 방법을 소개했지만, 강조하고 싶은 주요 메시지는 테스트 코드의 중요성과 함께 테스트 환경 구성의 중요성입니다. 다양한 테스트 케이스를 작성할 수 있는 환경이 준비되어야만, 테스트 코드를 통해 유의미한 피드백을 얻고, 이를 통해 로직을 검증하고 코드 품질을 향상시킬 수 있습니다.
]]>1 |
|
코드의 각 부분은 MongoDB를 사용하는 스프링 애플리케이션에서 도메인 객체를 정의하고, 저장소를 구성하는 데 필요한 요소들을 포함하고 있습니다. 아래는 코드 구조를 기반으로 한 정리입니다:
Member
- MongoDB의 members
컬렉션에 매핑되는 도메인 객체입니다. name
과 email
필드를 가지고 있으며, 각 필드는 MongoDB의 문서 필드에 맞추어 @Field
애노테이션을 사용하여 지정되어 있습니다.MemberRepository
- MongoDB의 기본 CRUD 작업을 위한 MongoRepository
와 사용자 정의 쿼리를 위한 MemberCustomRepository
, Querydsl 지원을 위한 QuerydslPredicateExecutor
를 확장하는 저장소 인터페이스입니다. 이로 인해 Member
객체에 대한 표준 데이터 접근 패턴과 함께 복잡한 쿼리 기능을 제공합니다.MemberCustomRepository
- 사용자 정의 쿼리를 위한 인터페이스로, 실제 사용자 정의 로직을 위한 메소드의 시그니처를 포함할 수 있습니다.MemberCustomRepositoryImpl
- MemberCustomRepository
의 구현체로, 실제 사용자 정의 쿼리 로직을 실행하는 메소드를 포함합니다. MongoTemplate
을 주입받아 MongoDB의 복잡한 작업을 처리하는 데 사용됩니다.이 구성을 통해, 애플리케이션은 MongoDB에 대한 데이터 액세스를 추상화하고 효율적으로 관리할 수 있으며, MemberRepository를
통해 비즈니스 로직에 맞는 복잡한 데이터 접근 패턴을 구현할 수 있어 애플리케이션의 유연성을 증가시키고 코드의 관리를 간소화합니다.
1 | interface MemberCustomRepository { |
MemberCustomRepositoryImpl
클래스에서는 MemberCustomRepository
인터페이스에 선언된 메소드의 구체적인 구현이 이루어집니다. 처음에는 override
키워드 없이 구현을 시작할 수 있습니다. 구현을 완료한 후, IntelliJ의 Refactoring
메뉴에서 Pull Members Up...
옵션을 선택함으로써 해당 메소드를 상위 인터페이스로 이동시킬 수 있습니다.
인터페이스의 메서드 시그니처를 처음부터 명확히 정의하지 않고, 구현 클래스에서 메서드의 세부 구현을 확정한 후에 이를 상위 인터페이스로 옮기는 방식을 개인적으로 선호합니다.
Querydsl Repository Support 활용 포스팅에서는 QuerydslRepositorySupport
를 사용해 CustomRepositoryImpl
의 반복적이고 복잡한 쿼리 로직을 단순화한 방법을 소개했습니다. 이와 유사하게, MongoCustomRepositorySupport
를 생성하여 CustomRepositoryImpl
에 공통 메서드를 중앙화시키면, 코드 중복을 줄이고 재사용성을 높일 수 있습니다. 이러한 방식은 Mongo DB 환경에서도 Querydsl의 장점을 활용하게 해주며, 코드 관리 및 유지보수의 효율성을 향상시킵니다. 대표적으로 페이징 처리 관련된 기능들을 통합해서 제공해줄 수 있습니다.
1 | abstract class MongoCustomRepositorySupport<T>( |
JPA 페이징 성능을 향상시키는 방법으로, 내용을 담은 콘텐츠 쿼리와 개수를 세는 카운트 쿼리를 분리하여 구현하는 것이 유익하다는 내용을 JPA 페이징 Performance 향상 방법에서 다루었습니다. 이 두 쿼리는 상호 의존적이지 않아 병렬 처리를 함으로써 성능을 높일 수 있습니다. 또한, 슬라이스 쿼리의 경우, '다음 페이지가 있는지’를 확인하는 hasNext
메서드를 포함한 공통된 로직을 사용함으로써 코드 중복을 방지하고 재사용성을 극대화합니다. MongoCustomRepositorySupport
클래스는 이러한 공통 기능을 제공하여 효율적인 데이터 조회와 페이지 처리를 가능하게 합니다."
1 | interface MemberCustomRepository { |
MemberCustomRepositoryImpl
클래스는 MongoCustomRepositorySupport
추상 클래스를 상속받아, MongoDB와의 데이터 교환을 더 효율적으로 관리하는 특수한 저장소 구현을 제공합니다. MongoCustomRepositorySupport
는 몽고디비의 mongoTemplate
와 작업할 클래스 타입을 받아 초기화합니다. 이는 MemberRepository
구현에 필수적인 기반 구조를 제공하여 보일러플레이트 코드를 줄이고 코드의 재사용성을 향상시킵니다.
데이터 모수가 적고 단순한 구조로 데이터를 보여주는 경우라면 JPA에서 제공해 주는 방식으로 처리하는 것이 효율적일 수 있으나 데이터 모수가 많고 여러 테이블을 조인해서 표현해야 하는 데이터 구조라면 성능적인 이슈가 발생할 수 있습니다. 이러한 이슈와 성능 개선 방법에 대해 알아보겠습니다.
데이터 모수가 적고 간단한 조회 구조를 가질 때, Querydsl의 applyPagination 메서드를 활용하면 페이징 로직을 더 쉽게 작성할 수 있습니다.
1 | class OrderCustomRepositoryImpl : QuerydslRepositorySupport(Order::class.java), OrderCustomRepository { |
Spring-JPA Best Practices step-15 - Querydsl를 이용해서 Repository 확장하기 (1)에서 공유드린 QuerydslRepositorySupport를 기반으로 JpaRepository 확장시켜 페이징 로직을 구현했습니다.
세부 구현체에서는 조회 로직을 살펴보겠습니다. 이 과정에서 Querydsl를 기반으로 JPAQuery를 생성하며 필요한 조회 조건을 작성합니다. 그런 다음 해당 쿼리 객체를 이용하여 Content 조회와 전체 레코드 수 조회를 수행합니다. 마지막으로 각각의 실제 쿼리를 확인하게 됩니다.
1 | select order0_.id as id1_4_, |
Content 조회에 필요하 조회 쿼리와, 전체 레코드 조회에 필요한 쿼리를 JPAQuery를 통해 동일하게 사용이 가능하며, Querydsl의 applyPagination 메서드를 활용하여 offset 및 limit 관련 페이징 로직을 간단하게 구현할 수 있다는 큰 장점이 있습니다.
Querydsl의 applyPagination을 활용하면 페이징 조회 관련 로직을 간단하게 구현할 수 있어서 개발 생산성 측면에서 큰 이점이 있습니다. 그러나 모든 개발 결정 과정에서는 트레이드오프가 발생합니다. 편리한 기능을 즉시 활용할 수 있지만, 나중에는 추가 비용을 지불해야 하며 이 비용은 이자를 포함하여 청구될 수 있습니다.
어떤 문제가 발생하는지 살펴보겠습니다.
Count 쿼리는 특정 조건에 해당하는 전체 레코드 수를 조회하는 구조로, 데이터 총량이 증가하면 성능 저하가 발생할 수 있습니다. Content를 조회하는 limit 및 offset 쿼리는 빠르게 처리되는(offset 비교적 크지 않은 초반 구간) 반면 Count 쿼리는 시간이 오래 걸려 병목 현상이 발생할 수 있습니다. 또한, 여러 테이블을 조인하여 데이터를 조회하는 경우에는 조회 조건이 복잡해져 정확한 인덱스를 타겟팅하기 어려운 이슈가 발생할 수 있습니다. 이는 조회 조건에 부합하는 전체 레코드를 Count 하는 구조에서 필연적으로 발생할 수밖에 없는 문제입니다.
이러한 문제 외에도 다른 문제가 있습니다. JPAQuery를 사용하여 Content 조회 쿼리와 레코드 Count 조회 쿼리를 동일하게 처리하면 성능적인 손해가 발생할 수 있습니다. 특히 여러 테이블을 조인하여 데이터를 조회하는 경우에 이 문제가 더 두드러집니다.
주문 조회 시에 사용자 및 쿠폰 정보와 함께 내려줘야 하는 경우, 조회 필터에 주문 정보만 있는 상황에서 Count 쿼리를 실행할 때, 다른 테이블의 조인 없이 주문에 대한 Count 쿼리를 작성하는 것이 효율적입니다.
1 | -- Content 조회 쿼리 |
주문 조회에서 address 필드만 조회 조건에 해당된다면, 사용자 및 쿠폰 테이블과의 조인은 필요하지 않습니다. 이 경우, Count 쿼리를 간단하게 주문 테이블만을 대상으로 작성하는 것이 효율적입니다. 조회 조건이 복잡해질 때, Count 쿼리를 별도로 작성하는 것은 성능적으로 장점을 가질 수 있습니다.
JPA Slice 방식은 Page 방식과는 다르게 Total Count를 조회하는 count 쿼리를 실행하지 않는 방식입니다. 따라서 Total Count를 조회하는데 드는 시간을 절약하여 성능적인 이점을 얻을 수 있습니다. 페이지네이션 된 데이터를 불러올 때, 전체 데이터의 총개수를 파악하지 않고도 일부 데이터를 가져올 수 있기 때문에, Total Count가 필요 없는 상황에서 사용하면 성능을 향상시킬 수 있습니다. Slice 방식은 특히 대용량 데이터의 페이징 처리에 유용합니다. 이렇게 Slice 방식은 Total Count를 구하지 않고도 효율적인 페이징 처리를 가능하게 합니다. Total Count가 꼭 필요한 데이터인지 비즈니스 적으로 확인해 보고 꼭 필요한 데이터가 아니라면 사용하지 않는 것을 권장 드립니다.
Spring Data에서는 Slice를 통해 Total Count를 조회하지 않는 형태의 페이징 처리를 지원하고 있습니다.
1 | class OrderCustomRepositoryImpl : QuerydslRepositorySupport(Order::class.java), OrderCustomRepository { |
Total Count가 필요 없기 때문에 생략 가능하며, 페이징 로직은 동일하게 applyPagination
으로 진행하며 중요한 부분은 hasNext
로 앞으로 더 읽을 데이터가 남아 있는지를 결정하는 변수입니다.
Order 데이터가 총 22개 있다고 가정하고 Page 0 ~ 4까지 Size 5개를 기준으로 조회한다고 가정해 보겠습니다.
Page | Size | Content | Last |
---|---|---|---|
0 | 5 | 5 | F |
1 | 5 | 5 | F |
2 | 5 | 5 | F |
3 | 5 | 5 | F |
4 | 5 | 3 | T |
Page 3까지는 Content가 설정한 크기만큼 반환되어 Last가 False 상태입니다. 그러나 Page 4에서는 남은 Content가 3개만 남아 있기 때문에 3개의 Content를 반환하고 Last가 True 상태로 변경됩니다. 이 방식은 코드로 작성하면 content.size >= pageable.pageSize
로 표현됩니다.
이 방식은 Total Count를 알 수 없기 때문에 Last 여부를 확인하기 위해서는 끝까지 데이터를 읽어봐야 정확히 판단할 수 있습니다. 반면에 Slice가 아닌 Page 방식에서는 Total Count를 알고 있어 다음 페이지를 읽지 않아도 Last 여부를 정확히 판단할 수 있습니다.
Order 데이터가 총 22개 있다고 가정하고 동일한 Size를 가지는 Page 방식과 Slice 방식을 비교해 보겠습니다.
방식 | Page | Size | Content | Total Count | Last |
---|---|---|---|---|---|
Page 방식 | 0 | 22 | 22 | 22 | T |
Slice 방식 | 0 | 22 | 22 | 알 수 없음 | F |
Slice 방식 | 0 | 23 | 22 | 알 수 없음 | T |
Page 방식에서는 Total Count를 알고 있기 때문에 Content Size가 동일하다면 Last가 True로 판단할 수 있습니다. 반면에 Total Count을 모르는 Slice 방식에서는 다음 페이지까지 읽어보고 Content Size가 0인 것을 확인해야 Last가 True로 판단할 수 있습니다. Size를 23으로 조회하면 응답하는 Content는 22개로, 요청한 Size보다 Content가 작게 응답되므로 Last를 True로 판단할 수 있습니다.
이로 인해 발생하는 성능적인 차이를 언급하는 것은 아니며, Page 방식과 Slice 방식 간의 구조적인 차이를 설명하기 위해 이를 언급한 것입니다.
테이블 뷰 형식으로 페이징 처리를 할 때, Total Count가 반드시 필요하지 않은 경우에는 대부분 Slice 방식을 활용하는 것이 효율적입니다. 예를 들어, 최근 주문 정보를 기반으로 회원 등급을 업데이트하는 배치 기능을 개발한다고 가정해 보겠습니다. 이 경우에는 Count 쿼리를 사용할 필요가 없습니다. 단순히 필요한 데이터를 offset과 limit 방식으로 읽고 처리하기 때문에 Count 쿼리를 수행하지 않아도 됩니다. 더불어 Count 쿼리는 데이터양에 상관없이 일정 시간이 걸리는데, 데이터양이 많은 경우 Content 조회 쿼리보다 더 많은 시간이 소요됩니다. 그러므로 이 Count 쿼리를 계속 사용하는 것은 성능상의 부담을 가중시킬 수 있습니다.
Spring Batch HTTP Page Item Reader처럼 대량의 데이터를 처리하는 배치 애플리케이션에 API를 제공할 때는 Slice 기반으로 제공하는 것이 성능적으로 이점이 있습니다.
Total Count가 반드시 필요한 경우에는 Slice 방식을 사용할 수 없으므로 Page 방식을 사용해야 합니다. 또한, 위에서 언급한 대로 여러 테이블을 조인해서 복잡한 데이터를 조회하는 경우에는 Count 쿼리를 별도로 구현하는 것이 성능적인 이점을 가져올 수 있습니다.
이는 AbstractJPAQuery의 fetchCount()
가 Deprecated된 이유 중 하나입니다. 조인이 많거나 복잡한 쿼리에서 fetchCount를 사용하면 성능 저하가 발생할 수 있기 때문에 다른 방식으로 count 쿼리를 실행하도록 권장하고 있습니다.
Count 쿼리를 별도로 구현하면 다음과 같이 구현할 수 있습니다.
1 | class OrderCustomRepositoryImpl : QuerydslRepositorySupport(Order::class.java), OrderCustomRepository { |
PageImpl을 사용하여 Page 객체를 생성할 때, totalCount를 Content 쿼리와 별도로 구현하여 작성합니다. totalCount를 구할 때 SimpleExpression의 count()
를 사용하여 질의합니다. 이러한 최종 쿼리를 살펴보겠습니다.
1 | -- Content 쿼리 |
Content 쿼리는 Content에 필요한 정보를 여러 테이블의 조인을 통해 가져오며, Count 쿼리는 조회 조건에 필요한 정보만 가져옵니다. 이때 fetchCount()
가 Deprecated 되었기 때문에 fetchFirst()
로 대체합니다. 이렇게 Count 쿼리를 따로 구현하면 Count 조건에 맞는 방식으로 최적화하여 성능적인 이점을 얻을 수 있습니다.
Count 쿼리가 1,000ms가 소요되고, 이후 Content 쿼리가 500ms 소요된다고 가정하면 총 1,500ms가 소요됩니다. 이 작업을 전체 데이터를 읽을 때마다 반복하면 성능상 문제가 발생할 수 있습니다. 그러나 이 두 작업은 서로 의존성이 없기 때문에 병렬로 처리할 수 있습니다.
Count 쿼리와 Content 쿼리를 병렬로 처리하면 Count 쿼리가 소요 시간이 더 길더라도 1,000ms에 작업을 완료할 수 있습니다. 병렬 처리를 코루틴을 활용하여 구현해 보겠습니다.
1 | class OrderCustomRepositoryImpl : QuerydslRepositorySupport(Order::class.java), OrderCustomRepository { |
코루틴의 async
와 await
를 활용하여 Content 쿼리와 Count 쿼리를 병렬로 처리하였습니다. 이 과정에서 스레드 정보를 확인하기 위해 Thread.currentThread()
를 사용하여 현재 스레드 정보를 출력합니다.
1 | INFO [nio-8080-exec-1] repository.order.OrderApi : thread api : Thread[http-nio-8080-exec-1,5,main] |
OrderApi의 exec-1
요청 스레드를 기준으로 findPagingBy
, content
, count
스레드가 동일한 스레드를 사용하는 것을 확인할 수 있습니다. 이것은 @coroutine#
주석에서 볼 수 있듯이 한 스레드 내에서 여러 코루틴을 실행할 수 있는 구조를 의미합니다.
VM Option에 -Dkotlinx.coroutines.debug
을 추가하면 실행 중인 코루틴이 어떤 스레드에서 실행되는지를 확인할 수 있습니다.
Count 쿼리에는 delay(1_000)
을 지정하여 1초 동안 대기하고, Content 쿼리에는 delay(500)
을 지정하여 0.5초 동안 대기하며 테스트를 진행합니다.
1 |
|
소요 시간은 1,037ms으로 정상적으로 병렬 처리가 되는 것을 확인할 수 있습니다.
Slice, Page 등과 같은 페이징 처리를 위한 중복 로직을 피하고 편리하게 사용하기 위해 해당 기능을 Support 객체에 관련 로직을 위임 시키겠습니다. Querydsl Repository Support 활용에서 소개한 QuerydslRepositorySupport를 기반으로 해당 기능을 한 번 더 감싸는 QuerydslCustomRepositorySupport 클래스에서 페이징 로직을 작성하겠습니다.
1 | abstract class QuerydslCustomRepositorySupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) { |
queryFactory
에서 제공하는 selectFrom
및 select
기능도 제공하여 DSL 표현을 보다 다양하게 활용할 수 있도록 합니다.applyPagination
메서드는 페이징 처리를 위해 Pageable
객체와, Content 쿼리를 위한 contentQuery
, Count 쿼리를 위한 countQuery
객체를 입력으로 받아서 코루틴을 활용하여 병렬 처리를 수행합니다.applySlicePagination
메서드는 Content 쿼리만을 수행하기 때문에 query
객체만을 입력으로 받고, content 조회와 hasNext
로직을 작성합니다.1 |
|
QuerydslCustomRepositorySupport
객체를 상속받아 applyPagination
과 applySlicePagination
로직을 작성합니다. 페이징 로직에 대한 처리는 모두 QuerydslCustomRepositorySupport
로 위임되며, 각 Repository에서는 해당하는 쿼리만 작성하면 되는 구조로 코드가 훨씬 더 간결해졌습니다.
데이터 저장소에 값을 저장하는 경우, 저장된 데이터를 가져오는 경우 적절하게 컨버팅이 필요한 경우 JPA에서는 @Converter
를 사용하면 손쉽게 제어할 수 있습니다. Exposed에서는 VarCharColumnType
를 확장하는 방식으로 해당 기능을 사용할 수 있습니다.
1 | object Writers : LongIdTable("writer") { |
title
필드에 불필요한 공백을 제거하고 싶은 Converter를 사용하고 싶은 경우에는 VarCharColumnType
를 확장하여 구현이 가능합니다.
1 | class TrimmingWhitespaceConverterColumnType(length: Int) : VarCharColumnType(colLength = length) { |
title
칼럼은 varchar
타입이므로 VarCharColumnType
을 통해서 구현합니다. valueToDB
에는 데이터 저장소에 들어가는 컨버팅 로직을, valueFromDB
에는 반대로 데이터베이스에서 가져온 데이터에 대한 컨버팅 로직을 작성합니다. 해당 로직은 공백을 제거하는 로직이므로 trim()
을 사용해서 동일하게 구현합니다. 해당 코드는 String 자료형에만 동작하게 구성했으며 필요에 따라 추가적인 자료형을 추가하면 됩니다.
1 | object Writers : LongIdTable("writer") { |
registerColumn을 활용해서 Converter 코드 TrimmingWhitespaceConverterColumnType을 적용시킵니다.
1 |
|
앞뒤 공백이 있는 문자열에 대해서 실제 데이터베이스에 정상적으로 Converter 로직이 동작하는지 테스트를 진행해 보겠습니다. 실제 디버깅으로 확인해 보겠습니다.
앞뒤 공백이 제거된 yun kim
으로 출력되는 것을 확인할 수 있습니다.
SQL Function 중 자주 사용되는 기능들에 대해서는 이미 구현하여 기능을 제공하고 있습니다. 집계 함수 Books.price.sum(), 문자열 함수 Books.title.lowerCase() 등이 있습니다. 예제 코드로 groupConcat을 살펴보겠습니다.
1 |
|
email 필드는 varchar 타입이며 다음과 같은 구조이기 때문에 groupConcat을 메서드를 사용할 수 있습니다.
1 | fun varchar(name: String, length: Int, collate: String? = null): Column<String> = registerColumn(name, VarCharColumnType(length, collate)) |
Expression
1 | SELECT GROUP_CONCAT(DISTINCT writer.email), writer.`name` |
의도한 대로 GROUP_CONCAT(DISTINCT writer.email)
SQL Function이 정상적으로 출력되었습니다. 그러면 조회한 데이터를 확인해 보겠습니다.
최종 결과로 문자열 email-1@asd.com, email-2@asd.com
을 응답받은 것을 확인할 수 있습니다.
요구사항이 복잡하면 코드 또한 복잡해진다. 결국 이러한 복잡도를 어느 코드에서는 해결해야 하는데 이 부분에 대한 고민이다.
1 | enum class MemberStatus( |
예를 들어 특정 성별 중 현재 활성화된 회원 전체에게 이메일을 보내는 로직에서 활성화 회원들을 조회하는 코드가 있다고 가정해 보자.
1 | class MemberRepositoryImpl( |
조회 코드에서 회원 상태의 복잡도를 직접 제어하면 외부 객체에서 현재 활성화 상태에 대한 복잡도에 대해서 자유로워진다. 즉 호출하는 객체에서는 회원의 상태에 대해서 알바가 없어진다는 장점이 있다. 하지만 단점 또한 있다. 회원 상태가 다른 조회 로직이 있다면 거의 유사한 코드가 중복해서 나온다는 것이다.
1 | class MemberRepositoryImpl( |
조회를 호출하는 코드에서 복잡도 제어를 하면 회원 상태에 대한 세부적인 규칙을 해당 객체를 호출하는 곳에 복잡도가 위임된다. 즉 호출하는 쪽에서 회원 상태에 대해서 명확하게 알고 있어야 한다. 물론 이 정도 상태 정도야 복잡도가 높다고 할 수 없지만 여러 필드들의 조합을 분석해서 조회해야 하는 경우는 복잡도가 높아진다. 또 요구사항이 바뀌어서 코드를 변경했다면 호출하는 코드들을 모두 찾아가서 변경해야 한다. 그 복잡도를 외부에서 제어했기 때문에 당연한 결과이다.
단순 파라미터로 받을 것인가 아닌가에 대한 단순한 고민이 아니라 복잡도를 어디에서 제어할 것인가? 그에 따른 장단점이 있고 어떠한 근거로 어떠한 방법을 택할 것인가 또 그 근거는 무엇인가에 대한 고민을 해봤으면 한다. 나름의 결론이 있다면(또 어떻게 바뀔지 모르겠지만) 네이밍을 통해서 그 의도를 드러나게 하는 것이 좋다고 생각한다.
1 |
|
MemberQueryService 같은 서비스 계층을 두고 해당 객체에서 네이밍으로 명확하게 그 의도를 표현하고, Repository 계층에서는 제너럴 하게 파라미터로 받아 처리한다. 이렇게 하면 서비스 계층에서는 명확하게 현재 활성화 상태의 유저를 조회하게 되며, 인프라 계층에서는 제너럴 하게 조회 로직을 작성함으로써 중복 코드 및 유사 코드를 방지할 수 있다. 인프라스트럭처에 직접적인 의존성을 갖게 하는 것보다 MemberQueryService처럼 서비스 계층을 통해 인프라스트럭처를 간접적으로 의존하는 것이 여러모로 좋다고 생각한다. 관련 포스팅은
Spring Guide - Service 가이드에 정리되어 있다.
일반적으로 Presentation 계층에서 다양한 유효성 검사를 하고 문제가 없다면 서비스 계층으로 넘어가서 비즈니스 로직을 수행하는 것이 일반적이다. 간단하게 코드로 표현하면 다음과 같다.
1 | // 회원가입 요청 DTO |
spring-boot-starter-validation
의존성으로 필드에 대한 유효성 검증을 쉽게 진행할 수 있다. 하지만 상호 베타적인 값 검증, 외부 인프라를 의존하는 유효성 검사는 진행하기가 어렵다. 그래서 ConstraintValidator을 이용해서 효과적인 검증에 대해서 포스팅한 적이 있다. 추가적인 검증을 컨트롤러에서 진행해도 무방하지만 Error Response 관련해서 더 효율적으로 관리하기 위해서는 ConstraintValidator
을 통해서 진행하는 것이 좋다. 예를 들어 요청 필드가 5개고 해당 요청 필드 5개가 모두 문제라면 Error Response을 응답할 때 모든 문제에 대해서 구체적으로 응답해 주는 것이 좋다. 개별적으로 응답을 하면 최악의 경우 5번 요청을 하고 나서야 모든 필드들에 대해서 유효성 확보가 되기 때문이다.
1 | // 한 요청에 대해서 모든 문제를 한 번에 내려주는 것이 좋다 |
사전에 검증하게 되면 문제가 있다. 위 그림처럼 여러 개의 애플리케이션, 혹은 다른 서비스에서 MemberRegistrationService
의존하여 회원가입을 진행할 수 있는 경우 어느 한 구간에서 유효성 검사를 하지 않거나, 유효성 검증 항목의 변경 사항이 제대로 반영되지 않거나 하는 문제가 발생할 수 있다. 결국 여러 애플리케이션, 외부 객체에서 의존하려면 유효성 검사를 모두 MemberRegistrationService
에서 진행 시키는 것이 이런 문제를 해결할 수 있는 방법으로 보일 수 있다.
1 | // ValidatorService에서 모든 예외를 담당한다. |
MemberRegistrationValidatorService를 보면 isExistedEmail는 유효성 검사에 대한 결과를 Boolean 타입으로 응답하며, checkEmailDuplication에서 isExistedEmail을 이용하여 Exception 발생 여부를 결정한다. 또 isExistedEmail은 단순 Boolean 타입이기 때문에 사용하는 서비스 및 애플리케이션 Presentation 계층에서 해당 메서드로 유효성 검사를 진행하며 그에 따른 핸들링을 진행한다. 예를 들어 External API에서는 Presentation 계층에서 Error Response 핸들링, Batch Application에서는 Exception을 발생시켜 핸들링을 진행한다.
1 |
|
MemberRegistrationService 계층에서는 isAlreadyCompletedValidation을 기준으로 추가적으로 유효성 검사를 진행할지 여부를 결정한다. 만약 Presentation 계층에서 동일한 유효성 검사를 진행했다면 더 이상 검증을 하지 않고 등록 로직을 수행한다. 물론 성능상의 큰 차이가 없다면 이런 플래그를 두지 않고 두 번 검사해도 무방하다. 이렇게 진행하면 장점으로는 유효성 검사 로직이 한곳에 모이게 되기 때문에 코드의 응집력이 높아지며, 사전 검증 여부를 확인하고 검증을 진행하지 않았다면 유효성 검사를 담당하는 객체를 통해서 진행하면 된다. 물론 단점으로는 단순 플래그 처리이기 때문에 호출하는 곳에서 이것을 무시하고 유효성 검증을 진행하지 않았음에도 진행을 완료했다고 요청하면 되기 때문에 단점으로 볼 수 있다. 최소한의 방어 로직으로 해당 플래그 default value를 false으로 설정하자. 꼭 이렇게 사용하지 않더라도 유효성을 검증하는 코드를 한 객체에 위임하여 관리하는 것은 좋은 패턴이라고 생각한다.
개인적으로 Setter 사용을 지양한다. 관련 내용은 Spring-Jpa Best Practices Step-06 - Setter 사용하지 않기에서 진행한 적 있다. 최근 대부분의 프로젝트는 Kotlin을 기반으로 코딩하고 있으며, JPA도 많이 사용하고 있다. OOP 설계를 지향하기 위해서는 단순 setter를 지양하는 것은 매우 동의하지만 Kotlin + JPA 조합을 사용하는 프로젝트에서는 적용하는 것이 많이 불편하다.
1 |
|
단순히 생성자로 받아서 처리하는 방식 보다 코드가 간결하지 않다. 만약 이메일을 변경을 제공하지 않는다면 val으로 선언하고 updatable = false까지 설정하는 것을 권장한다. 생각 보다 필드를 변경하지 않아야 하는 값들이 있다. 예를 들어 주문번호, 거래 번호 등등 고유한 번호로 지정받는 값들은 가능하면 val으로 지정해서 이 필드가 변경이 되지 않는다는 것을 명확하게 표현하는 것이 좋다. 또 @Column을 반드시 작성하는 것을 권장한다. @Column을 사용하면 자연적으로 nullable, updatable 등등을 한 번 더 고민하게 되고 그 고민이 코드적으로 자연스럽게 표현될 확률이 높아진다고 생각한다.
개인적으로 단순 setter를 지양한다. 하지만 팀 내 컨벤션으로 가져가야 할 정도로 가치가 있을지는 회의적이다. 회의적인 이유는 효율성이다. setter를 지양하는 다양한 이유가 있겠지만 개인적인 생각으로는 복잡도를 제어하기 위함이라고 생각한다. 결국 서비스가 커지고, 관련 개발자들도 많아지면 복잡도를 제어하기가 더욱더 힘들어진다는 것이다. 즉 그렇게 복잡하지 않은 서비스 및 서비스 초기 경우는 setter를 지양해야 할 필요성이 상대적으로 낮다고 본다.
위 구조처럼 상품, 주문, 결제, 회원, 쿠폰 등등 모든 서비스들이 단일 서비스 즉 모놀리식 으로 구성돼 있다고 가정해 보자. 서비스 초기에는 2 ~ 3명의 개발자가 모든 도메인에 대해서 거의 같은 수준으로 도메인을 이해하고 있다. 하지만 서비스가 커지고, 개발자를 채용하면 문제들이 발생한다. 초기에 있던 2 ~ 3명의 개발자들도 프로젝트가 복잡해지면 도메인의 이해 수준이 달라지게 되며 새롭게 합류한 개발자들은 더더욱 그 이해도가 차이 날 수밖에 없다.
예를 들어 초장기에는 쿠폰을 사용하면 쿠폰 미사용에서 사용으로, 사용한 날짜 정도만 업데이트를 하면 됐지만 여러 업체들이 들어오고 업체와의 쿠폰 비용에 대한 처리 비용, 또 다양한 이벤트 쿠폰 등등 단순했던 쿠폰 사용 로직이 이제는 이제는 고려해야 할 사항들이 쿠폰 외부 상황까지 늘어난 셈이다. 이런 경우 단순히 setter로 각각의 필드를 변경하면 사이드 이펙트가 발생할 가능 성이 높아진다. 결국 이렇게 복잡도가 높아지면 객체지향적인 관점으로 책임과 역할을 부여하고 그 범주에서 기능들을 수행해야 하게 된다.
그렇다면 이렇게 복잡해졌으니 단순 setter를 지양하는 방식으로 프로젝트를 리팩토링할 것인가?라는 의견에는 회의적이다. 단순 setter를 지양하는 코드로 리팩토링할 시관과 리소스로 차리를 서비스를 상품, 주문, 결제, 회원, 쿠폰을 독립적인 애플리케이션으로 분리 시키고 그 복잡도를 애플리케이션 내부에서 해결하게 하는 것이 훨씬 더 효율성이 높다.
위 이미지처럼 적당한 서비스의 크기로 애플리케이션을 나누어 도메인적인 복잡도를 해결하는 것이 더 좋은 방법이라고 생각한다. 물론 이렇게 분산 환경을 구축하면 기술적인 높은 복잡도가 요구된다. 또 이런 부분들은 단순 기술적인 부분뿐만 아니라 정무적인 영략도 함께 필요하다. 이러한 정무적인 부분에 대한 역량은 없기 때문에 기술적인 측면만 본다면 모놀리식 구조에서 setter를 지양하는 것보다는 서비스의 크기를 작게 나누는 것에 대해서 더 리소스를 투자하는 게 효율적이라고 생각한다.
그렇다면 서비스의 크기를 적절한 수준으로 분리한 이후에는 단순 setter를 지양해아할 것인가?라는 생각이 든다. 결론부터 정리하면 개인적으로는 단순 setter를 지양하나 팀 내 컨벤션까지는 작용까지는 아직 확신이 없다는 견해이다. 결국 개발은 협업이며 팀 내 프로덕트를 만들어 가는 사람들과 같은 청사진을 공유하고 얼라인 하는 것이 매우 중요하다. 본인이 setter를 지양한다면 그에 따른 타당한 이유로 팀원들을 설득해야 하며, 결국 입증의 책임은 주장하는 사람이 하는 것이다. 이런 측면에서는 서비스의 크기가 적당하게(적당히 작게) 유지되고 있다고 전제하에는 모두를 설득 시킬만한 타당한 근거는 지금의 나에게는 없다.
단순 setter가 없는 경우 불편한 점은 테스트 코드 작성이 어려운 부분이다. 이 부분에 대한 국체적인 설명은 2023 Spring Camp 실무에서 적용하는 테스트 코드 작성 방법과 노하우에서 다룬 적이 있다. 해당 발표 내용은 이후에 유튜브에 공개되어 대략적으로 설명하면 다음과 같다.
주문 Flow는 주문 -> 결제 확인 -> 상품 준비 -> 배송 시작 -> 배송 완료 순서로 진행되며 반드시 순차적으로만 진행되며 각 Step에 맞게 유효성 체크로직이 꼼꼼하게 작성되어 있다.
Order라는 엔티티 객체를 테스트 코드를 작성하려면 특정 Snapshot 상태로 만들어야 한다. 테스트 코드를 작성하는 구간은 상품 준비 -> 배송시작 임으로 해당 객체를 상품 준비 상태로 만들어야 한다. 하지만 단순 setter가 없기 때문에 상품 준비 중 객체로 직접 만드는 것이 어려운 부분이 있다.
가장 쉬운 해결 책으로는 Order 객체를 하나 만들고 주문 -> 결제 확인 -> 상품 준비의 로직을 각각 호출해서 상품 준비 중 상태로 만들면 된다. 하지만 이 방법도 좋은 방법이 아니다. 해당 테스트 코드의 주요 관심사는 상품 준비 -> 배송 시작에 대한 테스트 코드를 작성하는 것이지 주문 상태부터 결제 확인, 상품 준비까지 객체를 만드는 것이 주요 관심사가 아니다. 이렇게 테스트 코드를 작성하면 주문 -> 결제 확인, 결제 확인 -> 상품 준비 등의 Flow가 변경이 있다면 이 테스트 코드까지 영향 범위가 확대된다. 즉 테스트 코드는 중요 관심사의 변경에만 반영하는 것이 좋다.
이러한 문제로 단순 setter를 없애는 게 다양한 부작용들이 있다. 물론 이런 부작용들은 Given 절에 데이터 셋업의 어려움이기 때문에 필요한 데이터를 sql 파일, json 파일로 데이터를 임의로 만들어서 테스트를 진행하는 방식으로 대체하고 있다. sql 파일 기반으로 테스트하는 방식에 대해서 설명하면 다음과 같다.
스프링 테스트에서 지원해 주는 @Sql 어노테이션 기반으로 Given 절에 해당하는 데이터를 sql 파일 기반으로 만들어서 테스트하는 방식으로, 관련 포스팅은 Sql을 통해서 테스트 코드를 쉽게 작성하자에 정리되어 있다.
sql 파일로 관리하면 비즈니스 로직에 상관없이 특정 시점으로 자유롭게 데이터를 셋업 할 수 있으며 그 결과 폭넓은 테스트 코드를 보다 쉽게 작성할 수 있게 되며, 외부 의존성 없는 순수한 POJO 엔티티 객체를 테스트하고 싶은 경우 이와 비슷하게 json 파일 기반으로 테스트할 수 있다. 이러한 방법이 다양한 테스트 대역폭을 확보하기 위한 좋은 전략이라고 생각은 하지만 이 또한 단점들이 있어서 이 방법을 택할 정도로 압도적인 장점이 크지 않기 때문에 확신은 없고 계속 고민하고 있는 주제이다. 이러한 이유 등등으로 프로젝트를 크기를 적절하게 분리해서 분산 환경으로 관리하고 setter는 그 해당 팀의 정책적으로 선택하는 것이 좋다고 생각한다.
1 | class Member( |
성인 Member를 조회했지만 실제 Member 객체를 리턴하기 때문에 주민등록 필드에 대한 notnull 관련 작업을 진행할 때는 member.residentRegistrationNumber!!
을 사용해야 한다. 이런 경우 Projection을 사용하면 이런 문제를 쉽게 해결할 수 있다.
1 | data class AdultMember( |
자세한 Projection 방법은 Querydsl Projection 방법 소개 및 선호하는 패턴 정리에서 다룬 적 있다. Projection을 사용하면 영속성 컨텍스트가 없기 때문에 JPA에서 제공해 주는 다양한 기능들을 사용하지 못한다. 그 밖에 단점들도 있지만 이것은 조금 더 이후에 살펴보고 Projection을 활용하면 얻을 수 있는 장점들을 살펴보자.
1 | // Refind(환불) 객체를 JSON으로 표현 |
위 데이터 구조처럼 주문에 대한 Refund(환불) 객체가 있고, 신용카드 결제라면 credit_card
정보가 있고, 무통장 입금의 경우에는 account
정보가 있다고 가정해 보자. credit_card
, account
정보는 상호 베타적인 정보이기 때문에 두 객체는 nullable 설정할 수밖에 없다. Refund(환불) 엔티티 객체를 그대로 사용한다면 내가 조회한 데이터와 상관없이 계속 null 안정성에 대한 고민을 할 수밖에 없고 !!
의 불편한 동행이 계속된다. 문제는 그것뿐만이 아니다 환불이라는 컨텍스트의 모호함이 있다. 카드 환불인지, 무통장입금의 환불인지를 명확하게 표시하면 그 컨텍스트를 이해하는 것에 도움이 된다. 물론 변수명으로 표현이 하지만 Projection을 사용해서 CardRefund
타입으로 표현하는 것도 더 명확하며 안전하다고 생각한다.
Projection을 사용하면 영속성 컨텍스트를 사용하지 못하는 단점 말고도 다른 큰 단점이 있다. 리턴되는 타입이 엔티티 객체가 아니기 때문에 엔티티 객체에 있는 로직을 사용할 수 없다는 것이다. 이를 해결하기 위해서 Interface로 묶고 공통적인 로직은 Interface에서 구현하는 것으로 쉽게 해결이 가능해 보인다.
1 | interface GeneralMember { |
GeneralMember 인터페이스를 만들고 필요한 공통 로직을 작성한다. 그리고 Member 엔티티 객체, AdultMember Projection 객체에서 해당 인터페이스를 구현하면 공통 로직을 사용할 수 있다. 하지만 이렇게 인터페이스를 설계하려면 책임과 역할을 명확한 단위(작은 단위)로 구성해야 한다. 그저 엔티티와 일대일로 매핑되는 인터페이스를 두는 행위는 지양해야 한다.
Member라는 교집합에는 이름, 이메일, 주소 세 가지 필드가 있다. GeneralMember가 보호자 연락처, 보호자 동의 여부 필드를 가지고 있다면 어떻게 될까? 정상적으로 override을 할 수 없다. 즉 위 그림처럼 회원이라는 인터페이스는 세 가지 일반(공통), 성인, 미성년자 인터페이스로 구성해야 하며 단순히 인터페이스를 공통 로직으로만 보고 설계하면 안 되고 많은 것들을 고려해야 한다.
그렇다면 일반(공통), 성인, 미성년자 세 가지 타입이면 충분할까? 남성, 여성 등이 나올 수 있으며, 또 어떤 타입들이 나올 수 있는지 확신할 수 없다. 충분히 깊은 고민 없이 단순히 중복 코드를 만들기 위해서 억지로 추상화를 진행하면 결국 파국뿐이다.
위 문제처럼 각 책임에 맞게 적절하게 인터페이스를 두었다고 가정해 보자. 그렇다면 그것이 좋은 설계라고 볼 수 있을까? 나는 그렇지 않다고 생각한다. 결국 인터페이스를 두는 이유는 세부 구현체를 숨기 기고 인터페이스를 바라보게 함으로써 클래스 간의 의존관계를 줄이는 것, 다형성을 사용하여 역할을 대체할 수 있는 것이 중요한 핵심이라고 생각한다. 위 예제처럼 인터페이스는 그저 코드의 중복을 막기 위해 억지로 끼워 맞추는 것에 지나지 않는다고 생각한다.
캡슐화를 쉽게 깨트리고, 상위 클래스에 지나치게 의존하게 돼서 변화에 유연하게 대응하지 못하는 경우 상속보다는 조합(Composition)을 사용해서 이러한 문제를 해결하라고 한다. 또 Kotlin에서는 객체에 있는 모든 public 함수를 이 객체를 담고 있는 컨테이너를 통해 노출할 수 있는 기능을 by 키워드를 통해 제공해 주고 있다. 이런 것으로 해결은 가능하나 JPA와 사용했을 때 궁합이 좋지 않고 이러한 문제를 해결할 내공이 부족하여 이 방법에 대한 확신은 아직 없다.
복잡도를 제어하고 유지 보수하기 좋은 코드 디자인을 갖기 위해 학습했던 것들을 실제 적용하면서 만났던 현실적인 문제들을 정리해 보았다. 이런 것들을 학습할 때는 모든 문제를 해결해 줄 것처럼 느껴지지만 은탄환은 없으며 개발이라는 것은 트레이드오프이며 무언가를 얻으면 반드시 무언가를 어느 정도는 손해 볼 수밖에 없다. 하지만 그 손해 정도를 줄이는 것이 경험이고 실력이라고 생각한다. 만약 위에 언급한 부분을 철저히 지키고 있다면 얻은 것은 무엇이며 그 선택으로 인해 필연적으로 잃어버린 것은 무엇인지, 반대로 이것들을 지키지 않고 있다면 그로 인해 얻은 것과 잃은 것은 무엇인지 많은 개발자들이 고민해 보고 토론해 봤으면 한다.
]]>Name | Hot Key | Desc |
---|---|---|
Split Right | fn + ctr + ➡️ | 현재 화면 오른 쪽으로 분활 |
Split Down | fn + ctr + ⬇️ | 현재 화면 아래 쪽으로 분할 |
Goto Next Splitter | shift + cmd ➡️ | 현재 포커싱 화면애서 다음 Tab으로 이동 |
Goto Previous Splitter | shift + cmd ⬅️ | 현재 포커싱 화면애서 이전 다음 Tab으로 이동 |
<- Back | cmd + [ | 현재 Tab의 이전 Tab, tab limit 1 지정시 유용 |
-> Forword | cmd + ] | 현재 Tab의 다음 Tab, tab limit 1 지정시 유용 |
Recent Files | cmd + e | 최근 Open 파일 리스트 |
Recent Locations | shift + cmd + e | 최근 Open 파일 커서 위치 |
Bookmarks 지정 | cmd + F3 | 북마크 지정 |
Bookmarks | F3 | 지정한 북마크 리스트 |
Go to file | shift + cmd + o | 파일 열기 |
shift + cmd + a
Find Action으로 해당 기능을 찾을 수 있음sout
, psvm
등이 여기에 해당합니다.ss
, tdd
, jobc
, jobcode
, comment-formatter
sf
등등을 커스텀 해서 사용1 | fun asd(): Unit { |
@formatter:off ~ @formatter:on
을 활용하여 제외할 수 있다.build.gradle.kts
에 직접 작성한 TASK도 동작 가능특정 Entity를 응답 객체로 만들어야 하는 경우 동일 문자열을 복사해서 보다 쉽게 Response 객체를 만들 수 있습니다.
String Manipulation Plugin 플러그인은 문자열에 대한 다양한 기능을 제공합니다.
출처 Grep Console
Grep Console를 Grep 해서 보다 쉽게 로그 확인을 도와주는 플러그인
Key Promoter X는 IDE에서 마우스를 사용하면 대체 가능한 단축키를 알려주는 플러그인
Presentation Assistant는 사용하는 단축키와 기능 명을 디스플레이 해주는 플러그인
Kotlin Fill Class는 코틀린 생성자 네임드 파라미터 기반으로 생성을 도와주는 플러그인
Inspection Lens는 오류, 경고 및 기타 검사 하이라이트를 인라인으로 표시해 주는 플러그인
플러그인 검색을 /sortBy:downloads
으로 다운로드가 높은 순으로 정렬하고 필요한 플러그인을 선택
HTTP Client는 Postman처럼 HTTP 호출을 도와주는 도구로 IntelliJ에서 사용 가능
1 | { |
HTTP 호출 환경을 JSON으로 관리할 수 있습니다. HTTP 호출에 대한 환경을 지정하여 호출 가능합니다.
Curl을 붙여넣기를 하면 HTTP Client에 맞는 형식으로 자동으로 변환 됩니다.
반대로 HTTP Client를 Curl 형식으로 변환하여 복사할 수 있습니다.
Query parameter를 쉽게 정리할 수 있습니다.
GitHub 기반으로 코드 리뷰를 진행하면 단순 Diff만 보이기 때문에 디테일한 부분까지 확인하고 싶은 경우는 pull을 받아 진행하곤 합니다. IntelliJ에서는 GitHub 기반 코드 리뷰를 IDEA에서 진행할 있습니다.
PullRequest를 확인합니다. Find Action
기능을 통해서 찾을 수 있고 CMD + 3
단축키로도 찾을 수 있습니다.
PullRequest를 내용을 확인할 수 있습니다.
Diff가 있는 파일을 선택하여 확인이 가능하며 필요한 부분에 코멘트를 작성할 수 있습니다.
작성된 코멘트는 실제 Github에도 작성됩니다.
IDEA에서 해당 PR을 Meger 할 수 있습니다.
Pull Request 항목들을 필터를 통해서 검색도 가능합니다.
]]>1 | data class Member( |
컬렉션의 특정 값으로 그룹화를 진행이 가능하다. 그룹화 한 값은 LinkedHashMap 컬렉션으로 리턴되며 (1),(2)처럼 Key 값으로 리턴할 수 있다.
만약 리턴되는 객체를 컬렉션 요소의 객체가 아닌 객체를 리턴 받고 싶다면 (3)처럼 가능하다. 해당 객체는 Member 객체가 아닌 name 필드만 추출한 코드이다.
1 |
|
groupingBy는 컬렉션에 대해서 그룹화하여 이후 다양한 연산을 편리한 제공할 수 있는 Grouping 객체로 리턴해줍니다.
(1) groupingBy으로 Grouping 객체로 응답받고 해당 확장함 수로 다양한 연산 작업이 가능합니다. (2) aggregate으로 groupingBy된 값 기반으로 가격에 대한 sum 작업, (3) eachCount으로 groupingBy된 카운트를 조회 가능합니다.
1 |
|
chunked는 컬렉션 객체를 넘겨받은 인자 크기만큼 컬렉션을 나눕니다.
100개의 orders 객체를 10개로 나누면 10개 컬렉션이 10개가 됩니다. 말 그대로 청크를 나누는 컬렉션 객체입니다. 처리해야 할 데이터가 너무 크다면 적절한 청크 처리하는 경우 유용합니다.
1 | class Collection { |
flatMap은 여러 컬렉션을 합쳐서 하나의 컬렉션으로 합쳐줍니다. 위처럼 여러 컬렉션 객체 안의 컬렉션 객체가 있는 경우 하나의 컬렉션으로 flatMap을 통해서 합칠 수 있습니다.
orderNumbers로 각 객체에 있던 값들을 한 컬렉션으로 합쳐진 것을 확인할 수 있습니다.
]]>해당 배치 애플리케이션은 등록되어 있는 가맹점(Store)에 대한 상태를 외부 API를 단건으로 조회하여(단건 API만 존재) 가맹점 상태를 OPEN("오픈"),
, CLOSE("폐업"),
업데이트하는 애플리케이션입니다.
위와 같은 Step의 Job이 있는 경우 단일 스레드 기반의 가장 직관적인 JpaWriter 방법, RxKotlin을 이용한 멀티 스레드 방식의 RxWriter, 마지막으로 RxKotlin과 BulkUpdate를 진행하는 RxAndBulkWriter 방식에 대한 Step 코드 샘플과, 실제 성능 측정 정리 하였습니다.
1 |
|
초기 데이터는 모두 EXAMINATION("검토중")
으로 들어갑니다.
1 |
|
이 외부 API는 평균 응답 속도는 150ms
라고 가정하고 하고 성능 측정 시에는 150ms
으로 고정하고 진행하겠습니다.
1 |
|
JpaCursorItemReader 기반으로 성능 측정에서 모드 동일한 리더를 사용했습니다. JPA를 사용한다면 배치 애플리케이션에는 대량 처리 시 Entity 객체를 리턴하는 것이 아니라 Projections 객체를 리턴하는 것을 권장합니다. JPA에서 지원해 주는 Dirty Checking 기반으로 업데이트를 진행할 이는 거의 없으며, 있더라도 merger 기능이 동작할 때 select 쿼리가 한 번 더 발생할 위험도 있으며 Lazy Loading으로 추가 조회를 하는 경우도 거의 없습니다. 무엇보다도 처리할 데이터 rows가 많고 해당 테이블에 칼럼이 맞은 경우 JPA에서 이전에 언급한 기능들 및 다른 기타 기능들을 사용하기 위해서 더 많은 메모리를 사용하게 되기 때문에 성능적인 측면에 유의미한 차이가 있어 가능하면 Projections 객체를 리턴하는 것이 좋습니다.
CursorItemReader와 Reader에 대한 성능 분석은 Spring Batch Reader 성능 분석 및 측정 part 1, Spring Batch Reader 성능 분석 및 측정 part 2를 참고해 주세요. 본 포스팅에서는 Reader에 대해서는 깊게 다루지 않겠습니다.
1 |
|
가장 일반적이고 직관적인 배치 흐름입니다. Processor에서 단건 조회 API를 조회하여 데이터를 가공하고 Writer에서 Query DSL 기반으로 업데이트를 진행합니다. 이렇게 처리하면 total rows * 150ms만큼 소요 시 간이 발생하게 되기 때문에 데이터 모수에 큰 영향을 받습니다.
total rows * 150ms만큼 소요되기 때문에 처리할 수 있는 스레드 수만큼 작업 시간이 줄어들며 이론 상 rows 1,000 * 150ms / 10 Thread(Parallel(10)) 만큼 처리 시간을 단축시킬 수 있습니다. 해당 포스팅은 RxKotlin 기반으로 스레드 처리를 진행합니다.
1 |
|
Writer에서 넘겨받은 stores 객체를 병렬 처리하기 때문에 더 이상 Proccsor가 필요하지 않습니다. 배치 애플리케이션에서 Proccsor에서 데이터 가공 처리하는 것은 역할 책임의 분리로는 적절하나 I/O 작업처럼 상대적으로 느린 작업이 있으면 Proccsor에서 처리하지 않고 가능하면 Writer에서 벌크(병렬) 처리하는 것이 성능적으로 큰 이점이 있습니다.
RxCachedThreadScheduler-1~10
으로 10개의 스레드로 데이터를 사업자 최산 상태 조회를 하고 있으며 이후 blockingSubscribe
의 onNext
는 메인 스레드로 다시 전달받는 것을 확인할 수 있습니다. runOn()
에 각자 환경에 맞는 Schedulers를 적절하게 사용하면 되며 모든 테스트는 10개의 스케줄러 스레드 기반으로 테스트를 진행했습니다.
1 |
|
OPEN("오픈"),
, CLOSE("폐업"),
기반으로 ids 객체 생성where id in
기반으로 일괄 업데이트, 디비 서버와 네트워크 I/O 최소화onComplete
으로 최종 결과를 main Thread로 받는 것을 확인했습니다.
이전 Rx과 거의 동일하며 Query DSL 업데이트 처리하는 방식만 달라졌습니다. Chunk 단위로 데이터를 모아서 가맹점 상태를 기준으로 그룹화를 진행하며, 그룹화를 통해서 ids 통해서 DB 업데이트를 진행합니다. Chunk 단위로는 DB 서버와 최대 2번의 통신을 하기 때문에 기존 방식 대비 네트워크 I/O가 크게 줄어들게 됩니다. 모든 테스트는 로컬 DB 서버와 통신을 했기 때문에 JpaWriter, RxWriter 방식에서 네트워크 I/O에 비용이 크게 발생하지 않았지만 실제 운영 환경에서는 네트워크 I/O 비용이 커짐에 따라 더 안 좋은 성능을 보여주게 되며, RxAndBulkWriter와의 차이는 더 발생할 것으로 보입니다.
1 | # |
Rows | ChunkSize | JpaWriter | RxWriter | RxAndBulkWriter |
---|---|---|---|---|
50 | 10 | 8,252 ms | 1,406 ms | 1,258 ms |
100 | 20 | 16,207 ms | 2,357 ms | 2,078 ms |
500 | 100 | 78,738 ms | 9,106 ms | 8,268 ms |
1,000 | 200 | 156,420 ms | 17,751 ms | 1,6001 ms |
5,000 | 1,000 | 776,786 ms(12 min) | 83,670 ms(1.3 min) | 77,732 ms(1.2 min) |
10,000 | 1,000 | 1,556,775 ms(25 min) | 169,473 ms(2.8 min) | 155,777 ms(2.5 min) |
50,000 | 1,000 | 7,781,424 ms(129 min) | 881,320 ms(14 min) | 774,789 ms(12 min) |
100,000 | 1,000 | 15,622,542 ms(260 min) | 1,699,994 ms(28 min) | 1,581,545 ms(26 min) |
JpaWriter는 단일 스레드, RxWriter는 10 스레드로 진행하여 대략적인 수치는 스레드 차이만큼의 결과를 보여주는 것을 확인할 수 있습니다. RxWriter와 RxAndBulkWriter의 차이는 대략 10% 정도 차이가 있습니다. 이 차이는 배치 애플리케이션과 DB 서버가 로컬에 있어 루프백으로 통신을 진행하여 차이가 크게 발생하지 않았으나 실제 환경에서는 더 유의미한 차이가 있을 것으로 보입니다. 네트워크 I/O 비용뿐만 아니라 트랜잭션을 점유하는 시간, 커넥션을 맺고 있는 시간 등등 그룹화하여 where in 절로 처리가 가능하다면 이렇게 처리하는 것이 훨씬 더 효율적이라고 판단됩니다.
또 RxAndBulkWriter 경우 where in으로 처리하기 때문에 ChunkSize를 늘리면 더 성능이 좋을 것으로 생각했지만 5,000 보다 1,000 Chunk가 더 좋은 성능이 좋았습니다. 아마 Rx에서 스레드를 알맞게 나누고 그것을 다시 병합하는 과정의 비용이 비싸기 때문이라고 추정됩니다. 대량 처리를 진행하는 경우는 각 환경에 맞는 ChunkSize를 측정하여 사용하는 것이 바람직해 보입니다.
]]>1 | // JPA Object |
JPA에서는 Persistence Context 기반인 Dirty Checking을 통한 업데이트와, Persistence Context 없이 상태의 업데이트를 진행했습니다.
1 | class BatchInsertServiceTest( |
Exposed는 일반 업데이트와, addBatch를 통한 batch update를 진행 행했습니다.
JDBC 드라이버에서는 addBatch()를 제공하고 있습니다. 이 기능은 rewriteBatchedStatements
옵션을 활성화하면 MySQL Connector/J가 addBatch() 함수로 레코드를 모아 MySQL 서버로 전달합니다. 일반적으로 Batch Insert를 진행할 때 많이 사용하는 옵션으로 Batch Insert 성능 향상기 1편 - With JPA, Batch Insert 성능 향상기 2편 - 성능 측정에서 다룬 적 있습니다. Insert 쿼리 같은 경우는 addBatch()를 사용하면 다음과 같은 형태로 묶어서 실행시켜 줍니다.
1 | -- addBatch() 사용시 단일 insert에서 아래 SQL 형태로 변경 |
Update 쿼리는 형식의 변경은 없지만 레코드를 모아서 한 번에 MySQL 서버로 전달하여 네트워크 통신을 최소화할 수 있습니다.
rows | JPA Dirty Checking Update | JPA None Persistence Context | Exposed Update | Exposed Bulk Update |
---|---|---|---|---|
50 | 115 ms | 167 ms | 80 ms | 23 ms |
100 | 206 ms | 242 ms | 130 ms | 40 ms |
500 | 71 8ms | 994 ms | 596 ms | 135 ms |
1,000 | 1,388 ms | 1,540 ms | 1,130 ms | 381 ms |
5,000 | 6,204 ms | 6,441 ms | 5,121 ms | 1,127 ms |
10,000 | 12,151 ms | 12,209 ms | 10,094 ms | 2,227 ms |
50,000 | 65,309 ms | 56,295 ms | 46,506 ms | 10,355 ms |
100,000 | 120,906 ms | 11,3194 ms | 99,349 ms | 21,370 ms |
해당 테스트 환경은 로컬 애플리케이션에서 로컬 MySQL 통신으로 진행했기 때문에 네트워크 리소스 비용이 크게 발생하지 않았음에도 Exposed 기반의 Batch Update 성능이 가장 좋았습니다. 실제 운영 환경에서는 물론 Exposed Bulk Update도 시간이 더 오래 걸리겠지만 다른 업데이트 방법들은 네트워크 리소스가 높아짐에 따라 더 많은 시간이 발생할 것으로 보입니다.
그리고 JPA에서는 Dirty Checking Update, None Persistence Context의 성능 차이는 생각보다 크게 발생하진 않았습니다. 물론 영속성 컨텍스트가 반드시 필요하니 조회에 대한 부분까지 포함 시키면 유의미한 차이가 있을 것으로 보입니다. 하지만 이런 대량 조회의 경우 영속성 컨텍스트를 통하지 않고 Projections을 사용하는 것이 일반적이라 그 부분까지 테스트하진 않았습니다. JPA 기반으로 대량 데이터를 조회하는 경우 가능하면 Projections을 사용하는 것을 권장 드립니다. 그리고 이런 대량 데이터를 처리하는 특성상 배치 애플리케이션으로 구성하고 Chunk 단위로 데이터를 처리하기 때문에 100,000 정도의 데이터를 처리하는 것은 권장하진 않습니다. 데이터 모수와 처리 시간에 대한 상관관계를 확인하기 위해 작업했습니다.
실제 운영 환경에서의 네트워크 통신 비용에 따라서 addBatch() 방식과, 그렇지 않은 단건 업데이트 방식의 처리 시간은 더 차이가 날것으로 보이며, 구조적으로 큰 변경 없이 데이터 업데이트 방식만 바꾸는 것으로 6배 가까운 향상이 있기 때문에 대용량 업데이트 처리를 하고 있다면 권장 드립니다. JPA는 정말 좋은 ORM 프레임워크가 생각이 들지만 대량 처리에 대한 도구로는 적절하지 않다는 생각이 많이 듭니다. MySQL 기반의 대용량 처리를 진행하는 경우 다른 적절한 도구를 찾아보는 것이 좋을 거 같습니다.
]]>1 |
|
주문에 대한 sample code가 위처럼 작성되어 있는 경우 적어도 10~20개의 시나리오에 대한 테스트는 필요하다고 생각합니다. 하지만 해당 테스트 코드를 작성하기 위해서는 많은 어려움이 있습니다. 서비스 구조가 커지면 특정 문제를 해결하기 위한 다양한 인프라를 갖추게 됩니다. 위 코드도 각 데이터 특성에 맞는 저장소에 저장하고 조회하고 있습니다. 이런 경우 테스트 코드를 작성하기 위해서는 Given 절에 해당하는 곳에서 해당 인프라에 대한 데이터 셋업이 반드시 필요합니다. 이 작업의 어려움 때문에 다양한 케이스의 테스트 코드 작성이 어렵다고 생각합니다.
테스트 코드를 작성하는 것은 해당 코드의 주요 관심사에 대한 테스트 코드를 작성하는 것입니다. 그렇다면 위 코드의 중요 관심사는 각각의 인프라에서의 조회, 복잡한 로직...
에의 데이터 처리입니다. 두 가지 관심사에 대한 테스트 코드를 작성해야 하고 그 다양한 테스트 케이스에 대한 커버를 해야 하기 때문에 어려움이 있는 것입니다. 물론 중요 관심사라는 것은 대부분 명확하게 나눠지지 않고 항상 애매합니다. 로직이 복잡할수록 이러한 현상이 나타납니다. 그러기 때문에 테스트 코드를 작성하다 보면 설계의 경계선에 대한 피드백을 받을 수 있다는 점이 매우 유용한 장점이라고 생각합니다.
1 |
|
복잡한 로직...
관련 로직을 OrderServiceSupport 객체로 위임합니다. 해당 코드의 주관심사는 다양한 인프라에 해당하는 조회를 정상적으로 진행했는지에 대한 코드를 작성합니다. 각각의 인프라 조회는 해당 서비스 로직에서 주요 관심사로 보고 테스트를 진행하기 때문에 order에서는 중복적인 테스트는 불필요하다고 생각하며 몇 가지 간단한 케이스에 대해서 Order 객체가 영속 화가 알맞게 되었는지 검증하는 테스트 코드를 작성합니다.
1 | /** |
복잡한 로직(데이터 처리) 관심사만 갖는 객체를 만들어 해당 데이터에 대한 처리를 진행합니다. 이때 해당 객체는 Spring Bean Context 및 인프라스트럭처의 관련 코드가 없이 동작하는 순수한 POJO 객체로 만들어 데이터 처리를 진행합니다. Given 절에서 외부 환경에 대한 의존성이 없이 다양한 테스트 케이스를 쉽게 작성할 수 있으며 인프라 및 기타 환경에 구애받지 않기 때문에 비즈니스 로직의 관심사에만 자연스럽게 집중할 수 있게 됩니다.
]]>1 | class OrderService( |
로직은 상품을 주문 시 금액을 환율을 조회하여 해당 환율로 주문 정보를 저장하는 것입니다. 로직에 대한 테스트 코드는 다음과 같습니다.
1 |
|
로컬 환경에 Mock Server를 띄워 Given 절에 미리 요청/응답을 정의한 것으로 동작하게 합니다.
물론 위처럼 정말 간단한 코드는 위 형식처럼 작성하는 것이 적절한 해결법이 될 수 있다고 생각합니다. 하지만 로직이 복잡하여 다양한 케이스에 대한 커버리지를 확보하기 위해서는 어려움이 있습니다. 20220202
기준일 이외의 테스트 시 Given 절에 해당하는 요청/응답을 미리 정의해야 합니다. 1개의 객체를 처리하는 것은 어렵지 않으나 여러 객체를 처리하기 위해서는 어려움이 있습니다. 그리고 getExchangeRate()
메서드를 사용하는 모든 구간에서 동일하게 Mocking을 진행해야 합니다.
order()
메서드의 주 관심사는 주문을 객체를 환율 정보를 기반으로 영속화하는 것입니다. 그렇다는 것은 order()
테스트하는 관점도 해당 관점에서 진행해야 한다고 생각합니다. 즉 HTTP 통신의 관심사는 아니라고 생각하며 테스트 코드는 해당 관심사에 맞는 테스트를 진행해야 한다고 생각합니다.
결과적으로 다양한 케이스를 커버하기 위해서는 실제 Mock 서버로 커버하는 것은 비효율적이고 무엇보다 중요한 것은 order라는 관점에서 실제 외부 HTTP 통신이 큰 관심사가 아니라고 생각합니다. 그렇다면 Mocking을 진행한다면 HTTP 통신에 대한 Mocking을 하는 것보다 객체 자체를 Mocking 하는 것이 더 좋다고 생각합니다.
Interface를 두고 실제 HTTP 통신을 하는 실제 구현체는 ExchangeRateClientImpl에서 진행하고, 테스트 코드 또는 일부 환경에서 구현체는 ExchangeRateClientMock을 사용하게 합니다.
1 |
|
@Configuration
을 통해서 Bean으로 등록할 실제 구현 객체를 작성합니다. 이때 @Profile
을 통해서 특정 환경에서만 등록시킬지, 특정 환경에서만 제외할지 프로젝트에 특성에 맞게 조절하면 됩니다. 다음으로는 테스트에서 사용할 Mock 구현체로 테스트에서만 사용하는 것이라고 하면 test scope에 위치 시켜 실제 동작하는 코드 환경과 분리시키는 것이 바람직합니다.
1 | └── src |
위 경로처럼 test scope에서 ExchangeRateClientMock 객체를 위치 시켜 테스트 환경 외에는 해당 객체에 접근 못하도록 합니다.
1 |
|
테스트 환경에서 사용할 설정 클래스인 @TestConfiguration
객체를 만들고 테스트 환경에서만 Bean이 등록 가능하도록 설정합니다. 그리고 실제 Mocking의 Given 절에 해당하는 코드를 작성합니다. 즉 20220202 날짜의 경우 USD > KRW 환율이 12000으로 내려오게 설정합니다. order에 관심사는 해날 날짜에 환율 정보 기반으로 주문 금액이 정상적으로 영속화의 여부이기 때문에 미리 정의된 환율 정보 기반으로 정상적으로 금액이 잘 되었는지 검증하는 Then 절에 해당하는 테스트 코드를 작성합니다. Mock 구현체를 test 스코프에 위치 시켰지만 일반 main 위치 시키는 경우도 있습니다. 예를 사용해야 하는 외부 인프라가 특정 환경을 갖추지 못하는 경우, 외부 호출 경우 비용이 추가적으로 발생하는 경우 등등 이런 경우에서는 main 클래스에 위치 시키고 해당 환경에서 Mock 객체를 동작 시키는 것도 가능합니다. 물론 Mockito와 같은 테스트 도구를 이용하면 위와 같은 비슷한 방법으로 테스트가 가능합니다. 하지만 몇 가지 문제점들과 실제 객체를 Mockg 하는 방법이 더 효율적이라고 생각하여 위에서 설명한 방법으로 외부 인프라스트럭처 테스트를 진행합니다.
@Mockbean
을 사용하는 경우 스프링 빈 컨텍스트가 n 번 올라가기 때문에 속도적인 측면에서 많은 손해가 발생한다.15000.12
이 응답하게 했습니다. 이처럼 다양한 케이스에 대해서 추가적으로 애플리케이션 단에서 직관적으로 추가 및 변경이 가능합니다.이러한 이유들로 저는 Mockito와 같은 테스트 도구를 사용하지 않고 실제 Mock 객체를 직접 정의하여 사용합니다.
그렇다고 HTTP Server를 Mocking 하여 테스트하는 것은 의미가 없다고 할 수는 없습니다. 결국 중요한 것은 테스트의 관심사입니다. order 입장에서는 해당 행위가 큰 관심사가 아닐 수 있겠지만 getExchangeRate()
메서드에서는 중요한 관점입니다. 즉 해당 테스트의 중요 관심사라면 Mocking 하여 테스트하는 것이 바람직합니다. getExchangeRate()
관심사는 요청/응답입니다. 즉 요청한 HTTP 파라미터들이 미리 정의된 값으로 나갔고 해당 요청에 따른 응답이 오면 그 응답을 적절하게 deserialize 하여 자신을 호출한 객체로 넘겨 줄 수 있는 지입니다.
1 |
|
이러한 관심사에 맞게 Givin 절에서 요청과 응답을 미리 정의하고 getExchangeRate()
메서드를 호출하면 Json 응답을 deserialize 하여 객체로 전달이 잘 되는지에 대한 관심사에 대해서 테스트 코드를 작성합니다. 외부 인프라에 대한 테스트도 마찬가지로 책임과 역할을 명확하게 구분하고 분리해야 한다고 생각합니다. 그렇지 않으면 특정 객체에 책임이 과중되고 결국 전체적인 설계 디자인이 좋지 않게 됩니다. 이러한 현상(냄새)를 가장 빠르게 눈치챌 수 있는 게 테스트 코드라고 생각합니다. 테스트 코드를 작성하다 보면 너무 많은 의존성이 필요 해지고 내가 검증하려는 관심사 외에도 다른 관심사에도 관여하게 되는 경우 문제가 있다고 빠른 피드백을 줄 수 있습니다. 저는 이것이 테스트 코드의 아주 큰 장점 중에 하나라고 생각합니다.
1 | override fun getExchangeRate( |
getExchangeRate()
메서드에서 2xx 응답이 아닌 경우 예외를 발생시키는 코드를 추가했다고 가정하면 해당 메서드를 사용하는 오류가 발생해도 다음 로직을 이어 가야 하는 경우 try catch 묶는 해당 코드에서 예외 처리를 리팩토링 해야 합니다. 이러한 현상도 결국 역할과 책임이 과중 됐다고 생각합니다. getExchangeRate()
메서드는 HTTP 통신으로 요청/응답에 대한 역할과 책임을 갖고 그 부분만 충실하게 하면 됩니다. 예외 처리에 대한 특히 비즈니스 로직에 의한 핸들링은 관여하지 않는 것이 좋은 설계라고 생각합니다.
HTTP Client 책임 분리하기에서 포스팅한 적 있듯 내부, 외부 인프라스트럭처는 추상화 단계를 가지며 그 단계에서 본인의 역할과 책임에 대해서 성실하게 다 하는 것이 좋다고 생각합니다.
JPA를 사용하는 것으로 예를 들면 JpaRepository 영역은 ExchangeRateClient과 동일한 영역이라고 생각합니다. 특정 키값으로 조회하여 없는 경우 같은 비즈니스 영역의 책임을 JpaRepository에서 진행하지 않고 우리 비즈니스 로직을 풀어내는 서비스 영역에서 진행하는 것과 마찬가지로 HTTP 외부 통신도 동일하게 해당 요청/응답에 관한 부분만 책임을 부여하고 그 외의 영역에서는 다른 서비스 객체를 만들고 그 객체에서 핸들링하는 것이 좋다고 생각합니다. 결과적으로 테스트 커버리지를 넓혀 다양한 케이스에 대한 테스트 코드를 작성하기 위해서는 객체 간에 역할 책임을 명확하게 구분하고 본인의 영역에 대해서는 충실하게 수행하는 코드가 동반되어야 가능하다고 생각합니다.
해당 테스트 방법이 대부분의 환경에서 적절한 대안이 될 수 있다고는 생각하지 않습니다. 그래도 제가 경험한 환경에서는 외부 인프라스트럭처에 대한 테스트에 대한 코드를 작성할 때는 하고자 하는 테스트의 중요 관심사가 아닌 경우 불필요한 Mocking에 많은 코드를 작성하는 것이 효율적이지 못하다고 생각했습니다. 그리고 무엇보다 중요하는 것은 어떤 계층을 어떻게 바라보고 어떻게 테스트해야 할 것인지에 대한 팀 내부 차원의 합의된 부분이 먼저 선행되어야 한다고 생각합니다. 다양한 테스트 기법을 공부하는 것도 좋지만 프로젝트로 일을 진행하고 있다면 팀 내에서 토론하여 최소한의 합의를 먼저 구하고 테스트에 대한 기법을 적용하는 것이 더 좋을 거 같습니다.
]]>다양한 제품들이 있지만 저는 DataGrip 선호하며 애용하고 있는 제품입니다. 공식 자료에서도 충분히 다양한 기능들을 소개해 주고 있지만 개인적으로 DataGrip에 좋은 기능들을 정리해 보겠습니다.
DataGrip은 다양한 플랫폼을 지원하기 때문에 한 가지 도구를 이용하여 여러 플랫폼에 대한 제어가 가능합니다. 동일한 도구를 사용하기 때문에 단축키 및 플러그인 등을 그대로 사용이 가능하여 좋은 생산성을 제공해 줍니다.
조회한 데이터를 다양한 형식으로 가공 처리를 도와줍니다. 데이터를 조회한 이후에 추출할 형식을 지정하고 원하는 데이터를 선택하고 복사하기만 하면 됩니다.
1 | id,amount,created_at,order_id,updated_at |
CSV 형식으로 복사를 하면 위 형식처럼 복사가 됩니다.
CSV 형식 같은 경우는 다양한 형식으로 변경이 가능합니다.
SQL-insert-Multirow 형식도 매우 유용합니다. 조회한 결과에 한 insert query를 복사할 수 있습니다.
1 | insert into study.payment (id, amount, created_at, order_id, updated_at) |
조회한 데이터를 추출하는 경우 다양한 형식의 데이터 추출을 지원합니다.
강력하게 자동 완성을 제공해 줍니다. join 쿼리 같은 경우에는 대상 테이블끼리의 칼럼을 비교해서 조인 대상이 될만한 칼럼을 추천해 줍니다.
테이블 간의 관계를 표현해 주는 ERD 기능을 제공합니다. 물리적인 FK를 맺지 않아도 테이블과 칼럼을 분석해서 연결해 줍니다. 실제 프로덕트 환경에서는 다양한 이유로 물리적인 FK를 맺지 않고 사용해서 이 기능이 더 큰 장점인 거 같습니다.
explain
를 시각화하여 제공합니다. 디테일한 내용을 확인하는 것보다는 부족하지만 간단한 쿼리 계획이나 해당 쿼리를 개선하여 리포팅할 매우 유용합니다.
1 | create table study.orders |
SQL Scripts
기능으로 도움 되는 기능들이 있습니다. 저 같은 경우에는 Generate DDL to Clibboard 기능을 자주 사용합니다. sandbox 환경에서 설계 및 개발을 진행하면서 테이블에 대한 변경이 이루어지기 때문에 처음에 작성한 DDL로 운영에 반영되는 일은 흔하지 않은 거 같습니다. 이렇게 설계가 다 끝난 시점에서 해당 DDL을 추출하여 운영에 반영합니다.
Jetbrains에서 제공하고 있는 다양한 플러그인이 존재하며 다른 Jetbrains의 제품에서 사용하던 플러그인을 그대로 사용할 수 있습니다. 아래 소개한 플러그인 외에도 유용한 플러그인이 많기 때문에 플러그인 탭에서 다운로드 높은 순으로 정렬하여 좋은 플러그인을 찾아보는 것을 권장 드립니다.
Settings Repositroy는 해당 툴에 대한 단축키를 비롯한 다양한 설정을 Github에 저장하여 여러 환경에서 동일한 환경을 설정할 수 있게 해줍니다. 해당 설정은 XML으로 관리하며 변경 분을 Pull & Push 하여 설정을 동기화합니다. 저 같은 경우에는 단축키 및 다양한 설정들을 많이 하여 매우 유용하게 사용하고 있습니다.
해당 플러그인을 설치하고 File -> Manage IDE Settings -> Settings Repositroy...
에서 본인의 깃헙 주소로 설정하면 됩니다.
특정 값에 대해서 자동으로 증가시켜 중복되지 않는 값으로 설정할 수 있습니다.
위 이미지와 같은 FK 참조하는 값에 대한 SQL을 작성하는 경우 매우 효율 적입니다. 이전에 Sql을 통해서 테스트 코드를 쉽게 작성하자 포스팅도 참고하시면 좋을 거 같습니다.
Switch는 다양한 문자열 포맷으로 쉽게 변경이 가능합니다.
Switch Case 항목을 보시면 매우 다양한 항목으로 변경이 가능합니다.
]]>1 | http://localhost:5555/ASDASD |
라우팅 되는 서비스 API와는 Error Response 형식이 맞지 않아 문제가 발생할 수 있습니다. 최종적으로 아래와 같이 라우팅하는 서비스 API와 통일된 Error Response를 내려주는 전략에 대한 내용을 정리해보았습니다.
1 | http://localhost:5555/ASDASD |
게이트웨이 내부에서 발생하는 예외에 대한 전체적인 핸들링을 담당하는 객체입니다.
우선 ErrorResponse 객체 정의 및 예외 클래스를 정의합니다. 해당 내용은 Spring Guide - Exception 전략과 유사합니다.
1 | class ErrorResponse( |
Error Respones 객체를 서비스 API와 동일하게 만듭니다. 대부분의 객체는 Error Code 기반 생성자 기반 생성자로 만들게 생성되며 BusinessException는 게이트웨이 내부의 서비스 로직에 최상위 Exception으로 해당 객체를 기반으로 내부 예외가 핸들링됩니다. 만약 게이트웨이에 로직이 거의 없고 라우팅만 사용하는 경우에는 정의하지 않아도 무방합니다.
Code C001은 스프링 게이트웨이 내부에서 발생하는 내부 예외, C002는 정의하지 않은 예외들에 대한 코드입니다. 해당 코드들도 서비스 특성에 맞게 설정하면 됩니다.
Spring Cloud Gateway에서는 @ControllerAdvice
와 같은 것을 지원해주지 않아 ErrorWebExceptionHandler
를 구현하는 것으로 직접 만들어야 합니다.
1 |
|
해당 객체는 예외가 발생하면 발생한 Exception 인스턴스에 맞게 Jackson 기반으로 Error Response를 생성하여 최종 응답 메시지를 만들어 내려주게 됩니다.
ResponseStatusException
는 게이트웨이 내부 예외 중 HTTP와 관련된 예외 객체로 예를 들어 게이트웨이에서 라우팅에 등록하지 않거나 없는 주소로 접근하는 경우, 등록되지 않은 HTTP method로 요청하는 경우 등 HTTP와 관련된 예외의 경우 사용되는 예외 객체입니다.
브레이크 포인트를 걸고 없는 페이지를 호출하면 ex
객체에 ResponseStatusException
는 객체가 있는 것을 확인할 수 있습니다. 해당 객체로 위에서 정의한 ErrorResponse를 생성하여 아래와 같은 형식으로 응답합니다.
1 | http://localhost:5555/ASDASD |
message는 ResponseStatusException
에서 넘겨주는 message를 그대로 사용하고 있습니다. 만약 유저에게 공개되어 있는 API인 경우에는 이런 시스템 내부에서 넘겨주는 메시지를 그대로 사용하는 것보다 내부적으로 정제된 ErrorCode의 message를 사용하여 정제된 메시지가 내려가게 하는 것도 좋은 방법입니다. 프로젝트 특성에 맞게 선택하면 됩니다.
1 | http://localhost:5555/actuator |
위 메시지는 정의되지 않은 HTTP method로 호출했을 때 응답입니다.
BusinessException는 비즈니스 예외가 발생하는 경우를 핸들링을 진행하기 위한 객체입니다. 예를 들어 게이트웨이에서 인증 관련된 내부로 직을 진행하다 오류가 발생했을 경우 핸들링하기 위한 객체입니다. 우선 모든 요청에 대한 필터를 추가하는 로직을 간단하게 작성해 보겠습니다.
1 | # application.yml |
default-filters
를 등록합니다. name
에는 해당 클래스의 이름, args
는 해당 객체에 넘겨줄 arguemnt를 정의합니다. 사용 방법은 아래에서 자세히 설명하겠습니다.
1 |
|
application.yml
에서 정의한 이름인 GlobalFilter으로 클래스명을 지정하고 args
에서 정의한 Argument를 할당할 클래스인 Config 클래스를 지정하고 해당 필드 이름으로 변수를 지정합니다.
해당 필터와 Argument가 정상적으로 동작하는 것을 확인할 수 있습니다. 해당 필터에 인증 관련된 비즈니스 로직이 있다고 가정하고 간단하게 샘플 코드를 만들어 만들어 보겠습니다.
1 | class UnauthorizedException(errorCode: ErrorCode) : BusinessException(errorCode = errorCode) |
인증 관련된 UnauthorizedException 예외 클래스를 생성합니다. BusinessException 해당 객체를 그대로 사용해도 무방합니다. 해당 예외가 발생했을 경우 정의된 UNAUTHORIZED_ERROR
객체를 넘겨주기만 하면 적절한 ErrorResponse가 내려가게 됩니다.
해당 해당 예외가 발생하면 GlobalExceptionHandler
객체에서 BusinessException
객체로 예외 객체가 할당되어 핸들링됩니다.
BusinessException
을 상속한 UnauthorizedException
객체와 해당 객체에UNAUTHORIZED_ERROR
ErrorCode 값이 할당되어 있는 것을 확인할 수 있습니다. 해당 정보 기반으로 최종적으로 Error Response는 다음과 같이 내려지게 됩니다.
1 | http://localhost:5555/a-service/actuator |
1 | val errorResponse = when (ex) { |
해당 코드에서 else
에 해당하며 정의하지 않은 예외에 대한 부분에 대한 핸들링입니다. 예를 들어 Spring Cloud Gateway 등 내부 예외, 새롭게 추가한 의존성의 내부 예외, 우리가 직접 작성한 예외지만 BusinessException
를 기반으로 하지 않은 예외 코드 등이 있습니다.
1 |
|
위 코드처럼 코틀린 자체적으로 지원해 주는 check
메서드를 사용하는 경우 IllegalStateException
을 예외를 발생시킵니다. 이런 경우 BusinessException에 의해서 핸들링되지 않기 때문에 else
항목으로 잡히게 되며 ErrorCode.UNDEFINED_ERROR
에 의해 ErrorResponse가 내려가게 됩니다.
필요하다면 IllegalStateException
객체를 분기문에 추가하여 핸들링 할 수도 있습니다. 하지만 모든 예외에 대한 핸들링을 할 수 없기 때문에 우리가 정의하지 않은 예외에 대해서는 메시지를 통일화하는 작업은 필요합니다.
check
메서드로 발생시킨 IllegalStateException
객체와 해당 메시지가 보이는 것을 확인할 수 있습니다. 해당 객체로 최종적으로 아래와 같은 메시지로 응답을 내려줍니다.
1 | http://localhost:5555/a-service/actuator |
게이트웨이로 여러 내부 API에 대한 서비싱을 제공하고 있다면 내부 서비스들의 Error Response의 통일과 게이트웨이 자체도 서비스 내부의 Error Response와 통일하는 것이 본 내용의 핵심입니다. 그 틀안에서 예외 핸들링 전략은 각 서비스 특성과 처한 환경에 맞게 진행하면 된다고 생각합니다. 위에서 설명한 방법도 게이트웨이에 서비스 관련된 로직이 많지 않다고 가정하고 설명한 전략입니다. 라우팅 관련된 설정으로만 구성된 게이트웨이인 경우 예외 클래스를 따로 정의하고 예외 코드를 정의하지 않고 간단하게 구성하는 것이 더 효율적이라고 판단하며 인증 관련된 코드 및 기타 코드들이 많아 예외를 더 체계적으로 관리하게 된다면 위 전략 보다 더 디테일한 전략이 필요할 거 같습니다.
]]>실제 자주 사용하는 기능들 위주로 다루어 보겠습니다.
1 | object Writers : LongIdTable("writer") { |
Table Object는 이전 포스팅 Exposed: 경량 ORM에서 소개한 적 있어서 중복되는 설명은 진행하지 않고 없는 기능에 대해서 이야기해 보겠습니다.
clientDefault는 클라이언트에서 default 값을 지정할 수 있는 기능입니다. created_at, updated_at과 같은 기본 생성 날짜 같은 칼럼에 사용할 때 유용합니다.
1 | class ExposedTest : ExposedTestSupport() { |
updatedAt, createdAt을 값을 insert에서 지정하지 않았지만 clientDefault를 통해서 자동으로 값을 지정되는 것을 확인할 수 있습니다.
1 | class ExposedTest : ExposedTestSupport() { |
clientDefault는 생성 시에만 동작하고 업데이트에서는 동작하지 않습니다. 위 업데이트에서 it[this.updatedAt]
를 지정하지 않는 경우에는 테스트가 실패하게 됩니다. 즉 칼럼 업데이트는 수기로 진행해야 합니다. 다음은 DAO 방식입니다. JPA로 비교했을 때는 엔티티 방식에 해당합니다.
1 | class Writer(id: EntityID<Long>) : LongEntity(id) { |
DAO 방식도 DSL 방식과 마찬가지로 업데이트를 명시하지 않으면 동작하지 않습니다.
Enum 타입에 해당하는 칼럼의 경우 enumerationByName을 사용하면 편리하게 바인딩 가능합니다. 혹시 단순 문자열이 아닌 순번 타입의 경우는 enumeration을 사용하면 됩니다.
1 | object Books : LongIdTable("book") { |
저장 같은 경우는 Enum 객체를 그대로 사용하면 되고 가져오는 것도 동일합니다.
1 | fun `연관관계 객체 잠조 조인`() { |
연관관계를 객체 기반으로 설정한 경우 위 코드처럼 어렵지 않게 조인을 진행할 수 있습니다. 하지만 객체 연관관계를 설정하지 않는 경우에는 위처럼 조인을 진행할 수 없고 아래와 같은 방법으로 진행해야 합니다.
1 | object Publishers: LongIdTable("publisher") { |
연관관계를 객체 기반으로 하는 것이 아니라 단순 long type으로 지정하여 테이블 객체를 선언합니다. JPA에서도 연관관계 탐색의 오용을 경계하는 것처럼 Exposed에서도 동일하게 무리한 객체 연결은 지양하는 것이 바람직하다고 생각합니다.
1 | class ExposedTest : ExposedTestSupport() { |
1 | SELECT publisher.id, |
실제 원하는 방식으로 조인이 진행되는 것을 확인할 수 있습니다.
특정 조건에 따라 join을 해야 하는 경우가 있습니다. 예를 들어 특정 조건에 만족하는 경우 필요 테이블에 조인을 하여 필요 데이터를 가져오는 경우 Exposed에서는 다음과 같이 진행할 수 있습니다.
1 | class ExposedTest : ExposedTestSupport() { |
1 | # needJoin = false 경우 |
needJoin 분기에 따라 쿼리문이 달라지는 것을 확인할 수 있습니다.
1 | dependencyManagement { |
Sleuth 의존성을 추가하는 것만으로도 logback 설정과 연계되어 애플리케이션 로그에 바로 적용이 됩니다.
기본 설정은 [application name, Trance ID, Span ID] 형식으로 적용됩니다. Application name은 spring.application.name: xxx
설정값을 기준으로 지정됩니다. 로그 형식을 바꾸고 싶은 경우에는 loback 설정을 직접 하여 변경이 가능합니다.
이름 | 설명 |
---|---|
Trace ID | 전체 Request의 고유한 값 |
Span ID | 전체 Request중 일부의 일부의 고유한 값 |
Parent Span ID | 이전 Request의 Span ID로 요청의 흐름을 파악을 위한 값 |
Request의 전체 흐름을 Trace ID를 기준으로 트래킹 하며 Span ID로는 해당 Request의 속했던 서비스의 유니크하게 식별이 가능합니다. 또 Parent Span ID를 통해서 호출 간의 상관관계를 파악할 수 있게 됩니다.
RestTemplate, Feign, WebClient처럼 스프링 진형의 HTTP Client를 사용하면 Sleuth 의존성을 추가하면 자동으로 Sleuth가 동작하게 되며 HTTP Header 정보에 Trace ID, Span ID, Parent Span ID를 자동으로 추가됩니다. 하지만 그 외에 HTTP Client 라이브러리를 사용한다면 해당 설정을 진행해야 합니다. 본 예제는 Kotlin 기반의 HTTP Client 라이브러리 Fuel를 기준으로 설명드리겠습니다.
1 |
|
x-b3-traceid
, x-b3-spanid
, x-b3-parentspanid
의 값을 Tracer 객체 기반으로 설정합니다.1 |
|
Fuel을 기반으로 B Service를 호출합니다. Fuel은 매우 직관적으로 HTTP 통신을 진행할 수 있습니다. API Gateway -> A Service -> B Service를 호출하는 구조에서 A Service의 로그 정보는 아래와 같습니다.
1 | # API Gateway |
모든 Request는 Trace ID: 757d0493f099b94b으로 그룹화가 가능하며 각 서비스마다 Span ID마다 고유한 값으로 트래킹이 가능합니다. 또 Parent Span ID를 통해서 Request의 상관관계를 파악할 수 있습니다.
이렇게 Slueth를 통해서 Request의 상관관계를 로깅을하면 해당 정보를 활용하여 시각화가 가능합니다. 해당 이미지는 Elastic Search APM를 사용했습니다.
]]>Reader와 Processor에서는 1건씩 다뤄지고, Writer에선 Chunk 단위로 처리된다는 것이 중요합니다.
Chunk 지향 처리를 Java 코드로 표현하면 아래처럼 될 것 같습니다.
1 | fun Chunk_처리_방법(chunkSize: Int, totalSize: Int) { |
즉 chunkSize 별로 묶는 다는 것은 total_size에서 chunk_size 만큼 읽어 자장한다는 의미입니다.
1 | { |
HTTP Response는 위와 같이 구성되어 있다고 가정하겠습니다.
실제 데이터는 rows 23개가 저장되어 있다면 size를 10을 기준으로 2페이지 까지 읽으면 모든 데이터를 다 읽게 됩니다. 2페이지에서는 남은 데이터 rows 3개가 응답되며 3페이지를 조회하면 빈 응답 페이지가 넘어오게 됩니다. 즉 HttpPageItemReader는 content
가 빈 배열이 나올 때까지 page를 1식 증가 시키며 다음 페이지를 계속 읽어 나가는 형태로 구성됩니다.
1 |
|
1 | { |
content에 해당하는 내용들만 사용하기 때문에 content 노드를 찾아 해당 정보만 시리얼 라이즈를 진행합니다. HTTP Paging API에 대한 응답 형태를 통일화하여 특정 응답에 대해서만 지원 가능하게 유효성 검사 코드가 있습니다. 유연하게 사용 하기를 원하시면 해당 부분을 외부에는 변경이 가능하게 파라미터로 받는 방식으로 진행해도 무방합니다. 다만 통일된 응답 포맷을 갖는 것이 더 바람직하다고 생각합니다.
1 |
|
Local API를 호출하여 로그를 찍는 간단한 애플리케이션입니다.
1~20 개의 모든 데이터를 조회하고 로그를 찍는 것을 확인할 수 있습니다.