no image
[Diary] Apache Cassandra에서 MongoDB 전환기
저는 이커머스 개발 프로젝트에서 배송 도메인의 대한 데이터베이스로 Apache Cassandra를 처음에 선택했는데요, 얼마 지나지 않아 MongoDB로의 마이그레이션을 결정했습니다.이 글에서는 데이터베이스를 선택하면서 했던 고민들을 작성합니다.일단, 왜 NoSQL?쓰기 성능 최적화NoSQL은 설계상 많은 양의 쓰기 작업에 대해 높은 성능을 발휘하도록 최적화되어 있다. NoSQL의 쓰기 성능이 RDBMS보다 빠른 이유는 데이터 일관성 요구 사항을 완화하고, 복잡한 스키마 및 트랜잭션 처리를 배제한 설계 덕분에 쓰기 시 오버헤드가 적기 때문이다.유연한 스키마와 데이터 모델링배송 경로가 자주 추가, 업데이트된다면 NoSQL의 유연한 스키마가 적합하다. NoSQL에서는 구조가 고정되지 않아 데이터를 효율적으로..
2024.10.14
no image
[Reflection] Kafka를 활용한 이벤트 기반 아키텍처 구축 #우아콘2023 #우아한형제들
Kafka를 활용한 이벤트 기반 아키텍처 구축 #우아콘2023 #우아한형제들 (youtube.com)위 동영상을 회고하는 글 입니다.이벤트 기반 아키텍처를 왜?배달 시스템의 복잡도 증가배달 시스템이 커지면서 단순 배달 기능 외에 통계, 알림, 쿠폰 발급, 배달 시간 관리 등 다양한 기능이 요구되고 있습니다. 배달 시스템은 단순히 배달만 잘 수행하길 원했지만, 고객에게 실시간 알림을 제공하고 통계를 위한 데이터를 수집하며 쿠폰을 발급하는 등 복잡도가 크게 증가하고 있습니다. 이런 상황에서 이벤트 기반 아키텍처를 도입하면 시스템 복잡도를 낮추면서도 기능별로 유연하게 확장할 수 있습니다. 이벤트 기반 아키텍처는 핵심적인 배달 흐름과 부가 기능을 분리해 배달의 일관성에 영향을 주지 않고도 통계나 알림 같은 부가..
2024.10.09
no image
[Diary] Domain Driven Design에서 Spring Data Repository 의존성 역전하기
개발을 진행하면서 Repository는 데이터베이스와의 연동을 담당하는 핵심 구성 요소로 사용됩니다.일반적으로 Spring Data JPA를 사용하여 JpaRepository가 있죠. 이를 상속받아 필요한 DB CRUD 기능을 구현하게 됩니다.그렇다면 Domain Driven Design(DDD)에서는 이러한 Repository 클래스들이 어느 계층에 위치해야 할까요? 그리고 어떤 구조로 구현하는 것이 이상적일까요?이 글에서는 Repository에 대한 의존성 역전(DIP)을 구현하는 방법과 그 이유에 대해 자세히 다루고자 합니다.RepositoryRepository는 애플리케이션에서 데이터 접근 계층을 담당하는 핵심 구성 요소이다.데이터베이스와의 상호 작용을 추상화하여 비즈니스 로직이 데이터 저장소의 ..
2024.10.08
[PS] 프로그래머스 level 2 마법의 엘레베이터 java 풀이
코딩테스트 연습 - 마법의 엘리베이터 | 프로그래머스 스쿨 (programmers.co.kr) 프로그래머스코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.programmers.co.kr초기 문제 접근처음엔 백트래킹? BFS? 등 완전 탐색을 생각했다.근데 그건 말이 안 된다. 너무나 많은 경우의 수가 존재한다.그리디인가? 동전 거스름돈처럼 큰 수부터 나누면 되는가?근데 음수도 존재해서 이것도 아니다.직접 if 문으로 조건을 따져야 하는 건가?  현재 자릿수가 6이면 10에서 빼는 거로 하고..그러면 올림의 경우, 앞자리에 1을 더해야 하는데.. 만약 더하는 게 더 많은 돌을 필요로 하면..? (머릿속이..
2024.10.07
[PS] 백준 14940 쉬운 최단거리 java 풀이 (자꾸 3%에서 틀리네)
14940번: 쉬운 최단거리 (acmicpc.net)문제 접근전형적인 BFS 문제 같다.시작점부터 시작해서 BFS 시작그런데 자꾸 3%에서 틀렸다 나오길래 질문게시판을 찾아보았다.메모리 초과다시 같은 곳을 방문하지 않도록 방문 표시를 해주어야 한다. 안 그러면 Queue에 많은 좌표가 가득차게 된다.틀렸습니다도달할 수 없는 곳은 -1로 출력을 해야 한다. 이 처리를 안 해주면 3퍼에서 틀렸다고 나온다.따라서 필자는 방문 표시를 isVisited 배열을 만드는 대신 방문한 곳은 map 값을 -1로 바꾸어주었고, 도달하지 못한 곳은 이 값이 아직 1로 남아있기 때문에 이를 처리해주었다.코드import java.io.BufferedReader;import java.io.IOException;import jav..
2024.09.29
no image
[Diary][Spring] Spring Data Cassandra에서 @CreatedDate가 안 되는 문제 해결 (Spring Data는 이 데이터가 새 데이터인지 모른다.)
Spring Data Cassandra을 사용하면서, @EnableCassandraAuditing를 적용해도 @CreatedDate 필드에 값이 들어가지 않고 null이 저장되는 문제가 발생했습니다. 이 글에서는 문제 상황 및 원인을 분석하고 해결합니다.문제 상황: @EnableCassandraAuditing를 적용해도 null이 들어간다.감사 레코드를 삽입하기 위해 다음과 같이 BaseEntity를 구현했다.이를 Cassandra DB 테이블에 매핑할 클래스에 상속받았다.이후 Auditing을 활성화하기 위해 @EnableCassandraAuditing을 적용했다.또한 @CreatedBy, @LastModifiedBy를 사용하기 위해 getCurrentAuditor를 임시로 구현했다.테스트를 위해 @I..
2024.09.29
[TIL] Spring Data Cassandra와 Spring Data JPA의 차이? (NoSQL과 RDBMS의 차이)
*chatGPT4o 의 답변입니다.Q. does spring data cassandra has concept of transactional? what's difference in spring data jpa?Spring Data Cassandra는 Spring Data JPA와 같은 방식으로 트랜잭션을 지원하지 않습니다. 두 기술에서 트랜잭션이 다르게 작동하는 이유와 차이점을 설명하겠습니다.1. Spring Data JPA에서의 트랜잭션 지원Spring Data JPA(관계형 데이터베이스를 기반으로 하는)는 @Transactional 애노테이션을 사용하여 트랜잭션을 완전히 지원합니다. 이는 관계형 데이터베이스가 기본적으로 ACID(Atomicity, Consistency, Isolation, Durabi..
2024.09.28
no image
[Diary][Spring] Spring에서 Module에 BaseEntity를 추가하면, @EntityListeners(AuditingEntityListener.class)가 잘 되나?
문제 상황createdAt, updatedAt과 같은 감사 로그는 공통적으로 사용되는 필드가 많기 때문에 BaseEntity를 자주 사용한다.그런데 이를 모듈에 추가하고, 메인 프로젝트에서 이 모듈로 추가한 BaseEntitiy를 상속하면 잘 될까?그리고 위와 같이, BaseEntity를 사용하기 위한 엔티티 및 설정을 추가해주었다.처음엔 안 될거라고 생각했다.왜냐하면 우선 Component Scan의 대해 생각해보았다.Component Scan 범위는 @SpringbootApplication 에서 기본적으로 이 어노테이션이 적용된 클래스를 포함한 하위 패키지인데,추가한 모듈은 범위 밖에 있기 때문에 Bean 을 못 찾고, 마찬가지로 JPA Audit 관련 기능도 BaseEntity를 찾지 못하겠지? 란..
2024.09.26

저는 이커머스 개발 프로젝트에서 배송 도메인의 대한 데이터베이스로 Apache Cassandra를 처음에 선택했는데요, 얼마 지나지 않아 MongoDB로의 마이그레이션을 결정했습니다.

이 글에서는 데이터베이스를 선택하면서 했던 고민들을 작성합니다.

일단, 왜 NoSQL?

쓰기 성능 최적화

NoSQL은 설계상 많은 양의 쓰기 작업에 대해 높은 성능을 발휘하도록 최적화되어 있다. NoSQL의 쓰기 성능이 RDBMS보다 빠른 이유는 데이터 일관성 요구 사항을 완화하고, 복잡한 스키마 및 트랜잭션 처리를 배제한 설계 덕분에 쓰기 시 오버헤드가 적기 때문이다.

유연한 스키마와 데이터 모델링

배송 경로가 자주 추가, 업데이트된다면 NoSQL의 유연한 스키마가 적합하다. NoSQL에서는 구조가 고정되지 않아 데이터를 효율적으로 저장하고 변경할 수 있다. 
또한 JSON 또는 BSON 형태로 데이터를 저장하는 문서 기반 NoSQL(DB)에서는 경로 업데이트 시 중첩된 데이터를 다루기 편리하며, 여러 경로를 한 번에 저장하고 업데이트하는 작업이 유연하다.
배송 경로의 경우 택배사 외부 api에 의존할 수 있기 때문에, 유연한 스키마가 필요하다.

확장성 및 성능 유지

NoSQL은 수평 확장이 가능해 데이터량이 급격히 증가하더라도 성능 저하 없이 클러스터를 통해 확장할 수 있다.

Apache Cassandra를 선택한 이유?

배송 도메인은 실시간 배송 경로와 같은 서비스를 포함하며, 대량의 데이터를 고가용성 및 확장성을 가지고 처리할 수 있는 데이터베이스가 필요하다고 판단했다.

위와 같은 점에서 Apache Cassandra가 적합하다고 생각한 이유는 다음과 같다.

  1. 높은 쓰기 및 읽기 처리량: 배송 시스템은 상태 업데이트, 위치 추적과 같은 데이터가 끊임없이 생성된다. Cassandra는 높은 처리량을 처리하도록 설계되어 다수의 읽기 및 쓰기 작업을 동시에 처리하면서 성능 저하 없이 운영할 수 있다.
  2. 확장성: Cassandra의 분산 아키텍처는 클러스터에 노드를 추가하여 수평 확장이 가능하다. 이는 사용자 수와 서비스가 확장됨에 따라 데이터 양이 빠르게 증가하는 배송 도메인에 필요하다.
  3. 최종 일관성 모델: Cassandra의 최종 일관성은 절대적인 즉시 일관성이 항상 필요하지 않은 배송 도메인에 적합하다. 예를 들어, 추적 정보 업데이트에 약간의 지연이 있더라도 시스템이 지속해서 응답성을 유지하고 가용성이 보장되면 충분하다고 생각했다.
  4. 시계열 데이터 처리: 배송 추적에는 서로 다른 시간에 발생한 이벤트가 기록되는 시계열 데이터가 포함된다. Cassandra는 시계열 데이터를 효율적으로 저장하고 조회할 수 있어, 시간이 지남에 따라 배송 상태를 추적하는 데 적합하다.

문제 상황

이론적으로는 적합해 보였지만, 막상 구현을 해보니 Cassandra의 강점을 제대로 사용하지 못하는 문제가 생겼다.

문제: Cassandra의 데이터 모델링 철학

Cassandra에서는 데이터 모델링이 쿼리 중심으로 이루어진다. 이는 데이터를 어떻게 조회해야 하는지에 따라 테이블을 설계한다.

관계형 데이터베이스에서 정규화가 중요하다면, Cassandra에서는 읽기 성능을 최적화하기 위해 비정규화를 권장한다.

따라서 동일한 데이터를 여러 테이블에 저장하는 것은 Cassandra에서 허용되며 일반적이다.

예를 들어, 배송 정보 조회용 테이블 따로, 배송 상세 조회용 테이블 따로, 배송 경로만 보여주는 조회용 테이블 따로 이런 식으로 말이다.

 

하지만 이는 유연한 스키마 설계가 가능하다고 볼 수 없다. 

결국 Cassandra는 각 테이블이 미리 정의된 고정된 스키마를 따르며 컬럼을 동적으로 추가하거나 스키마를 자주 변경하는 것이 비효율적이기 때문에, 새로운 데이터 요구 사항이 발생하면 기존 테이블을 변경하기보다는 새로운 테이블을 생성하는 방식으로 대응해야 한다.

 

문제: Cassandra의 데이터 저장 구조

https://medium.com/systemdesign-us-blog/difference-between-partition-key-composite-key-and-clustering-key-in-cassandra-b68cb024017a

Cassandra의 데이터 저장 구조는 위 그림처럼 생겼다.

Primary Key는 파티션 키(Partition Key)와 클러스터링 키(Clustering Key)로 구성된다. 위 그림에서는 Sort Key로 되어있는데, 주로 클러스터링 키라고 부른다.

이 두 가지 요소가 결합되어 Cassandra의 데이터 분산 및 정렬 방식을 결정한다.

  • Partition Key (파티션 키): 데이터가 분산될 파티션을 결정하는 핵심 요소이다. 파티션 키에 따라 데이터가 클러스터의 노드들에 분산 저장된다.
  • Clustering Key (클러스터링 키): 파티션 내에서 데이터를 정렬하는 데 사용된다. 파티션 키와 함께 Primary Key를 구성하며, 클러스터링 키는 파티션 내에서 데이터를 특정 순서로 정렬할 수 있게 해 준다.

Cassandra는 데이터를 효율적으로 분산하기 위해 파티션 키 기반의 데이터 모델링을 강조한다.

따라서 필자는 배송 ID를 UUID 타입으로 설정하였다. 그러면 이 배송 건의 대한 배송 경로는 이 파티션 키에 저장이 될 것이다.

하지만 이는 기껏 해봐야 5-7개 정도의 데이터가 저장된다.

문제: ALLOW FILTERING 사용의 위험성

그러면 파티션 키를 특정 날짜로 하는 것은 어떨까?

그러면 특정 배송 ID에 대한 배송 경로 쿼리를 하려면 FILTER을 걸어주어야 한다. 하지만 이는 Cassandra에서 권장되지 않는다.

 

Apache Cassandra에서 ALLOW FILTERING은 특정 조건에 따라 데이터를 필터링할 때 사용되는 키워드이다.

Cassandra는 분산 시스템으로, 데이터 조회 시 파티션 키를 기준으로 검색하는 것을 기본으로 한다. 따라서 Cassandra는 데이터를 읽을 때 가능한 한 특정 파티션에 접근하는 방식으로 최적화되어 있어야 성능이 높다. 그러나 특정 필터 조건이 파티션 키에 포함되지 않은 경우 직접적인 조회가 불가능하여 ALLOW FILTERING을 사용하여 데이터베이스에게 필터링을 허용하도록 명시해야 한다.

 

ALLOW FILTERING은 성능에 대한 보장을 하지 않기 때문에, 예기치 못한 상황에서 성능이 급격히 저하될 수 있다.

파티션 키를 사용하지 않는 FILTERING 쿼리는 성능 저하와 리소스 낭비를 초래할 수 있다. 필터링이 필요한 쿼리는 각 노드의 데이터를 스캔해야 하기 때문에 속도가 느려질 수 있다.

 

또한 필터링 쿼리는 각 노드에서 모든 데이터 파티션을 스캔해야 하는 상황이 발생할 수 있다. 이는 작은 데이터셋이라면 문제가 되지 않지만, 대규모 데이터셋에서는 노드의 부하가 크게 증가하게 된다. 이로 인해 조회 시간이 길어지며, Cassandra의 고성능 특성을 저해할 수 있다.

결국 MongoDB로 마이그레이션

스키마 유연성

MongoDB는 스키마리스(NoSQL) 특성을 가지므로, 배송 경로에 새로운 필드가 추가되거나 기존 필드가 변경되는 경우, MongoDB에서는 이를 별도의 마이그레이션 없이 손쉽게 반영할 수 있다.

도큐먼트 기반 저장

배송 데이터는 일반적으로 단일 주문에 대한 경로와 상태 정보를 함께 관리하는 경우가 많다. MongoDB의 도큐먼트 기반 저장 방식은 배송 정보를 하나의 도큐먼트로 저장하여 경로, 상태, 수령인 정보 등을 논리적으로 한 번에 관리할 수 있다.

따라서 복잡한 join query 가 어려운 NoSQL의 단점을 고려하여 하나의 배송 쿼리로 필요한 정보들을 모두 가져오도록 배송 스키마를 설계했다.

마치며

다행히 개발 단계에서 알아챈 문제라 크게 바꿀 건 없었지만, 선택한 기술에 더 조사해보고 어떻게 적용할 수 있을지에 대해 시나리오를 고려해야 한다고 느꼈다.

 

Kafka를 활용한 이벤트 기반 아키텍처 구축 #우아콘2023 #우아한형제들 (youtube.com)

위 동영상을 회고하는 글 입니다.

이벤트 기반 아키텍처를 왜?

배달 시스템의 복잡도 증가

배달 시스템이 커지면서 단순 배달 기능 외에 통계, 알림, 쿠폰 발급, 배달 시간 관리 등 다양한 기능이 요구되고 있습니다. 배달 시스템은 단순히 배달만 잘 수행하길 원했지만, 고객에게 실시간 알림을 제공하고 통계를 위한 데이터를 수집하며 쿠폰을 발급하는 등 복잡도가 크게 증가하고 있습니다.

 

이런 상황에서 이벤트 기반 아키텍처를 도입하면 시스템 복잡도를 낮추면서도 기능별로 유연하게 확장할 수 있습니다. 이벤트 기반 아키텍처는 핵심적인 배달 흐름과 부가 기능을 분리해 배달의 일관성에 영향을 주지 않고도 통계나 알림 같은 부가 기능을 비동기적으로 처리하게 합니다. 대부분 부가 기능은 배달과의 강한 일관성을 필요로 하지 않으며, 배달 데이터가 나중에 반영되는 최종 일관성만으로도 충분합니다.

이벤트는 어떤 정보를 가지고 있어야 하는가?

이벤트 구성 요소

마틴 파울러는 Domain Event를 "도메인에 영향을 미치는 관심 정보"라고 정의하며, 이는 대상, 행위, 정보, 시간을 포함합니다. 예를 들어, ‘배달이 A라이더에게 11시에 배차되었다’라는 이벤트는 다음과 같은 요소를 포함하여 정의될 수 있습니다.

  1. 대상 식별자: 이벤트가 발생한 대상의 식별자로, 어떤 대상(여기서는 배달)이 이벤트의 주체인지 명확히 나타냅니다.
  2. 행위: 이미 발생한 사건이므로 과거형으로 표현합니다. 예를 들어, 배차, 운행 시작 등으로 배달 시스템 내의 특정 상태 변화나 행동을 설명합니다.
  3. 정보: 이벤트와 관련된 구체적인 정보가 포함되며, 배달 수단(자전거, 오토바이 등), 현재 상태(운행 중, 도착 등) 등 주요 속성을 전달합니다.
  4. 시간: 이벤트가 발생한 정확한 시각을 명시해 시간 흐름을 추적할 수 있게 합니다.

이벤트 예시

‘배달이 A라이더에게 11시에 배차되었다’라는 이벤트는 다음과 같이 JSON 형식으로 표현될 수 있습니다.

{
	"eventId": "A라이더",
	"eventType": "운행시작",
	"data": {
    	"state": "운행중",
		"배달수단": "자전거",
    },
    "occurenceDateTime": "2023-11-15 11:00:00"
}

이벤트 기반 아키텍처 적용 후 장점

  1. 배달 시스템 복잡도 감소: 배달 시스템은 배달에만 집중하며, 각종 변경 사항은 이벤트로 전송됩니다. 이에 따라 새로운 요구 사항이 발생해도 배달 기능을 변경할 필요 없이 이벤트 처리 로직을 추가하여 확장할 수 있습니다.
  2. 소비처와의 결합도 감소: 소비처는 배달 이벤트만 수신하여 배달 상태를 파악할 수 있습니다. 배달 시스템의 상세 정보 조회가 불필요해져 API 요청이 줄어들고 시스템 간 결합도가 낮아집니다.
  3. 데이터 분석의 유리함: 이벤트 기반 아키텍처를 통해 도메인 히스토리가 축적되므로 시간에 따른 데이터를 수집하고 분석하기에 용이합니다. 이를 통해 고객 통계와 운영 효율성을 높일 수 있습니다.

이벤트 처리 시 주의사항

  1. 이벤트 데이터의 관리: 이벤트 데이터를 무분별하게 추가하지 않도록 주의해야 합니다. 소비처에서 특정 데이터 요구가 많아지면 이벤트의 크기가 커지고 관리가 어려워질 수 있기 때문에, 행위자 기반의 핵심 데이터만 포함하는 것이 중요합니다.
  2. 이벤트 순서 보장: 이벤트는 발생 순서대로 전달되어야 하는 경우가 많습니다. 배달 과정에서는 주문 생성 → 배달 시작 → 배달 완료와 같은 순서가 중요하며, 이를 준수하지 않으면 시스템에 오류가 발생할 수 있습니다. 예를 들어 주문 취소 후 주문 생성 이벤트가 들어오는 경우가 발생하지 않도록 해야 합니다.

이벤트 순서 보장 방법

이벤트의 순서를 보장하기 위해 이벤트 파이프라인 구성이 필요하며, 일반적으로 메시지 브로커를 통해 이를 구현할 수 있습니다. 대표적인 메시지 브로커로는 카프카(Kafka)가 있으며, 다음과 같은 특징이 있습니다.

  1. 순서 보장: 카프카는 특정 Key에 대해 파티션을 활용해 순서를 보장합니다. 예를 들어, 배달 시스템에서는 배달 번호를 기준으로 이벤트를 발행하여 특정 배달의 이벤트 순서를 보장할 수 있습니다.
  2. 고성능 및 고가용성: 카프카는 파티션을 통해 데이터를 분산하고, 클러스터링을 통해 고가용성을 제공합니다. 이를 통해 대규모 데이터와 트래픽에도 안정적이고 빠르게 이벤트를 처리할 수 있습니다.
  3. 확장된 생태계 지원: 카프카는 Kafka StreamsKafka Connect 같은 다양한 도구를 제공하여 데이터 파이프라인 구축, 데이터 스트리밍 처리, 데이터베이스 연결 등을 유연하게 관리할 수 있습니다.
  4. 운영 지원 도구: 카프카는 Kafka 클러스터를 관리하기 위한 전담팀 지원, 모니터링 및 관리 도구를 제공하여 안정적인 운영을 보장할 수 있습니다.

도메인 상태 != 이벤트 발행 결과

이벤트 기반 아키텍처에서 이벤트 발행 실패는 시스템의 도메인 상태와 실제 이벤트 발행 결과가 일치하지 않는 문제를 야기할 수 있습니다. 예를 들어, EBS 볼륨 이슈, 네트워크 이슈, 주키퍼(Zookeeper) 통신 문제 등으로 인해 이벤트 발행이 실패하거나 재시도될 때 이벤트의 순서가 꼬여버리면, 도메인의 상태가 일관성을 잃고 시스템 장애로 이어질 수 있습니다. 이를 방지하기 위해 Transactional Outbox 패턴Debezium을 활용한 메시지 릴레이가 효과적인 해결책이 됩니다.

Transactional Outbox 패턴

Outbox with Debezium and Kafka — The hidden challenges ❘ by Victor Perepelitsky ❘ Yotpo Engineering ❘ Medium

Transactional Outbox 패턴은 이벤트 기반 아키텍처에서 도메인의 상태와 이벤트 발행 결과 간의 일관성을 보장하기 위해 사용됩니다. 이 패턴은 도메인 처리 트랜잭션과 발행해야 할 이벤트를 하나의 트랜잭션으로 묶어 Outbox 테이블에 이벤트를 저장합니다. 이렇게 하면 도메인의 상태와 이벤트 발행이 동시에 커밋되어 일관성을 유지할 수 있습니다.

이후, 메시지 릴레이(Message Relay)가 Outbox 테이블의 이벤트를 순서대로 읽어 발행함으로써, 이벤트 발행의 순서와 정확성을 보장합니다. 이러한 방식은 이벤트 발행의 실패로 인한 문제를 최소화하고 시스템의 안정성을 높입니다.

Debezium 기반 메시지 릴레이

Debezium은 이벤트 발행을 위한 메시지 릴레이에서 사용하는 오픈소스 플랫폼으로, 데이터베이스에서 발생하는 변경 사항을 감지하고 타 시스템에 전송합니다. Debezium은 Outbox 테이블에서 발생한 변경 사항을 읽어 Kafka와 같은 메시지 브로커로 이벤트를 전송하며, 이 과정에서 이벤트 발행 순서와 일관성을 보장해줍니다.

Debezium을 선택한 이유:

  1. 저비용: Debezium은 설정을 통해 간단히 등록, 실행 및 모니터링이 가능해 비용 효율적입니다.
  2. 안정성: Debezium은 Binary Log를 통해 이벤트 순서를 보장하며, Kafka의 Offset을 활용하여 이벤트 발행을 확실히 보장합니다.
  3. 처리량: Outbox 테이블 파티셔닝을 통해 높은 처리량을 지원할 수 있습니다. 그러나 이벤트 발행 순서를 보장하기 위해 Debezium의 테스크 수는 하나로 고정하는 것이 필요합니다.

이벤트 활용 사례 및 아키텍처 확장

  1. 이벤트 스트림을 통한 CQRS 구현: 이벤트 스트림은 명령(Command)과 조회(Query) 작업을 분리하는 CQRS 패턴을 구현하는 데 활용될 수 있습니다. CQRS는 시스템의 확장성과 성능을 개선하며, 장애 발생 시 쿼리 쪽에만 영향을 미치도록 해 커맨드 작업이 계속 수행될 수 있도록 합니다.
  2. 데이터 분석 환경 구축: 이벤트 스트림을 데이터 분석에 활용해 시스템 성능과 운영을 최적화할 수 있습니다. 이를 위해 Kafka와 S3 Sink Connector를 통해 데이터를 Amazon S3에 저장하고, Glue, Athena, Apache Airflow, Data Lake 등을 활용해 데이터를 수집, 분석, 시각화하는 환경을 구성할 수 있습니다.
  3. 실시간 스트림즈 애플리케이션: 주기적 배치 집계에서 실시간 집계로 전환하기 위해, 이벤트 스트림을 기반으로 실시간 배달 라이더 수를 모니터링하는 스트림즈 애플리케이션을 구현할 수 있습니다. Kafka Streams를 활용하여 실시간 이벤트 데이터를 처리함으로써 즉각적인 모니터링이 가능해지며, 실시간 대응이 필요한 서비스에서 유용하게 사용할 수 있습니다.

이러한 방식으로 이벤트 기반 아키텍처는 이벤트 스트림을 통해 CQRS, 데이터 분석, 실시간 모니터링 등을 지원하여, 시스템의 복잡도를 줄이면서도 안정성과 확장성을 높이는 데 기여합니다.

개발을 진행하면서 Repository는 데이터베이스와의 연동을 담당하는 핵심 구성 요소로 사용됩니다.

일반적으로 Spring Data JPA를 사용하여 JpaRepository가 있죠. 이를 상속받아 필요한 DB CRUD 기능을 구현하게 됩니다.

그렇다면 Domain Driven Design(DDD)에서는 이러한 Repository 클래스들이 어느 계층에 위치해야 할까요? 그리고 어떤 구조로 구현하는 것이 이상적일까요?

이 글에서는 Repository에 대한 의존성 역전(DIP)을 구현하는 방법과 그 이유에 대해 자세히 다루고자 합니다.

Repository

Repository는 애플리케이션에서 데이터 접근 계층을 담당하는 핵심 구성 요소이다.

데이터베이스와의 상호 작용을 추상화하여 비즈니스 로직이 데이터 저장소의 세부 구현에 의존하지 않도록 한다.

예를 들어, Spring Data JPA를 사용하면 JpaRepository 인터페이스를 상속받아 기본적인 CRUD(Create, Read, Update, Delete) 기능을 손쉽게 구현할 수 있다.

Domain Driven Design에서 Repository

Domain Driven Design(DDD)은 복잡한 도메인 지식을 소프트웨어 모델로 표현하는 방법론이다.

DDD에서 Repository는 도메인 계층의 한 부분으로 간주되며, Aggregate Root를 저장하고 조회하는 역할을 한다.

DDD 구조 하면 가장 많이 보이는 그림

위 그림과 같이, 도메인은 가장 상위 수준의 계층으로서 비즈니스 로직의 중심을 이루며, 계층이 멀어질수록 하위 수준의 계층(예: 인프라스트럭처)이 된다. 여기서 Repository가 도메인 계층의 일부로 간주되는 이유는 다음과 같다

  1. 도메인 순수성 유지: 도메인 계층은 데이터베이스나 특정 기술 스택에 의존하지 않고, 비즈니스 로직을 순수하게 유지해야 한다. Repository는 도메인 모델을 조작하는 인터페이스를 정의하여, 도메인 로직이 데이터 접근 세부 사항에 의존하지 않도록 한다. 이로써 도메인 모델의 변경이 데이터베이스나 기술 변화에 종속되지 않게 된다.
  2. Aggregate 관리: DDD에서 Aggregate Root는 관련된 도메인 엔티티를 일관되게 관리하는 집합 단위이다. Repository는 Aggregate Root를 관리하며, 데이터 저장소와의 상호작용을 통해 Aggregate의 상태를 안정적으로 유지하도록 돕는다. 이는 데이터의 일관성과 무결성을 보장하는 데 필수적이다.
  3. 비즈니스 로직의 응집력: Repository는 데이터베이스 접근을 추상화하여 비즈니스 로직이 본래의 책임에 집중할 수 있게 한다. 이를 통해 도메인 계층의 응집력이 높아지고, 비즈니스 규칙과 요구사항에 더 밀접하게 맞춘 모델을 구현할 수 있다.

의존성 역전 원칙 (Dependency Inversion Principle)

의존성 역전 원칙(DIP)은 상위 수준의 모듈이 하위 수준의 모듈에 의존해서는 안 되며, 추상화에 의존해야 한다는 원칙이다. 이는 시스템의 유연성과 확장성을 높여준다.

특히, Domain Driven Design(DDD) 에서는 각 계층이 서로의 역할에 집중하여 단일 책임 원칙을 지키며 도메인 로직이 데이터 접근이나 구현 기술에 종속되지 않도록 의존성 역전이 필요하다.

계층별로 의존성을 역전하여 구현하면, 시스템의 복잡도가 높아질수록 계층 간의 결합도를 낮출 수 있어 유지보수성이 개선되고 테스트가 용이해진다.

또한, 도메인 로직의 순수성을 유지하며, 시스템의 변경에 유연하게 대응할 수 있는 구조가 된다.

 

예를 들어, 아래 클래스는 Spring을 개발하면서 흔히 볼 수 있는 Service와 Repository이다.

public interface CustomerRepository extends JpaRepository<Customer, Long> {
    void save(Customer customer);
    Optional<Customer> findById(Long id);
}
@Service
public class CustomerService {
    private final CustomerRepository customerRepository;

    @Autowired
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    public void createCustomer(Customer customer) {
        customerRepository.save(customer);
    }

Service에서 Repository의 의존성을 주입 받고 있다. 일반적인 상황에선 문제가 없지만, 위 예시는 Domain Driven Design을 위반하고 있다.

DDD에서 Service 클래스는 일반적으로 application 계층에 있으며, JpaRepository의 구현체는 infrastrucure에 위치해야 한다.

그런데 상위 계층인 application 계층이 하위 계층인 infrastructure에 의존하고 있다.

따라서 DDD를 준수하여 설계하기 위해선, 두 의존성 간의 역전이 필요하다.

Repository 의존성 역전 구현

아래의 구조를 따라서 구현을 한다.

최상위 계층인 domain에서 하위 계층에서 사용할 repository 메서드를 정의하고, infrastructure 계층의 interface에 구현을 한다.

 

domain/DeliveryRepository

이 인터페이스는 하위 계층에서 사용할 메서드들을 선언한다. 특히, Spring Data Repository에서 사용하는 메서드로 정의한다.

public interface DeliveryRepository {

    <S extends Delivery> S save(S entity);

    Optional<Delivery> findById(UUID deliveryId);

    boolean existsById(@Nonnull UUID deliveryId);

    void deleteById(UUID deliveryId);

}

infrastructure/DeliveryCassandraRepository

실제 JPA와 같은 구현체 인터페이스로, domain 계층의 Repository를 상속받는다.

참고로 상속 받은 Repository의 이름과 Spring Data Repository에서 지원하는 메서드와 동일한 이름으로 사용하면, 결과적으로 Spring Data Repository의 기능을 사용한다.

public interface DeliveryCassandraRepository extends DeliveryRepository, CassandraRepository<Delivery, UUID> {

    @Override
    <S extends Delivery> @Nonnull S save(@Nonnull S entity);

    @Override
    @Nonnull Optional<Delivery> findById(@Nonnull UUID deliveryId);

    @Override
    boolean existsById(@Nonnull UUID deliveryId);

    @Override
    void deleteById(@Nonnull UUID deliveryId);
}

예를 들어 위 코드에서 DeliveryRepository에서 선언한 save와 CassandraRepository의 save와 메서드 이름과 타입을 같게 설정하면, CassandraRepository의 save를 사용한다.

(물론 Spring Data JpaRepository도 마찬가지다.)

 

application/DeliveryApplicationService

application 계층에서 비즈니스 로직을 수행하는 Service 클래스로, infrastructure의 repository가 아닌, domain 계층의 repository를 선언한다.

@Slf4j
@Service
@RequiredArgsConstructor
@Validated
public class DeliveryApplicationService {

    private final DeliveryRepository deliveryRepository;
    ...

위 설계를 통해, 애플리케이션 계층이 도메인 계층의 Repository 인터페이스에 의존하게 되어, 상위 계층(즉, 도메인 계층)의 추상화에만 의존하도록 한다.

이는 애플리케이션 계층이 데이터 접근 방식이나 infrastructure 구현에 종속되지 않고, 오로지 비즈니스 로직과 관련된 인터페이스에 의존하도록 하여 DDD의 원칙과 일치한다.

정리

  • DDD 에서는 상위 계층이 하위 계층의 구체적인 구현에 의존하지 않도록 설계하는 것이 권장된다. 
  • 왜냐하면 상위 계층(예: 애플리케이션 계층이나 도메인 계층)이 하위 계층(예: 인프라스트럭처 계층)의 구체적 구현을 직접 참조하게 되면, 비즈니스 로직이 데이터베이스나 외부 시스템의 변경에 영향을 받을 가능성이 커지기 때문이다.
  • 따라서 의존성 역전 원칙과 결합하여, 상위 수준의 모듈(비즈니스 로직)이 하위 수준의 모듈(데이터 접근 로직 등)에 의존하지 않고, 추상화에 의존하도록 설계해야 한다.
  • 이 글에서는 애플리케이션 계층의 서비스 클래스에서 도메인 계층의 repository를 의존하고, 구현체 repository는 infrastructure에 위치시켜 직접적으로 하위 계층의 구현체에 의존하지 않도록 의존성 역전을 달성했다.

 

코딩테스트 연습 - 마법의 엘리베이터 | 프로그래머스 스쿨 (programmers.co.kr)

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

초기 문제 접근

  1. 처음엔 백트래킹? BFS? 등 완전 탐색을 생각했다.
  2. 근데 그건 말이 안 된다. 너무나 많은 경우의 수가 존재한다.
  3. 그리디인가? 동전 거스름돈처럼 큰 수부터 나누면 되는가?
  4. 근데 음수도 존재해서 이것도 아니다.
  5. 직접 if 문으로 조건을 따져야 하는 건가?  현재 자릿수가 6이면 10에서 빼는 거로 하고..
  6. 그러면 올림의 경우, 앞자리에 1을 더해야 하는데.. 만약 더하는 게 더 많은 돌을 필요로 하면..? (머릿속이 복잡해지기 시작)

문제 풀이

  1. 뒷자리에서 올림을 수행했는지 carry라는 변수에 1 또는 0 저장
  2. 맨 뒷자리부터 시작한다. (1234이면 4부터) 
  3. 현재 자릿 수의 수가 5보다 크거나, 5인데 앞 자릿수가 5보다 같거나 크면 올림을 수행한다.
    예를 들어 65를 생각해 보자. 일의 자리가 5일 경우, 십의 자리 수가 6이므로 올림을 수행하면
    70 (65 + (+1 x 5 )) 이 된다. 여기서 사용한 동전은 5(10 - 현재 자릿 수의 숫자)이며, 이후 앞자리에 1을 더해야 하므로 carry = 1을 선언한다.
  4. 만약 내림을 사용해야 하면, 현재 자릿 수의 숫자만큼만 사용하면 되며, carry = 0으로 선언한다.
  5. 다음 자릿수를 수행하기 위해 storey /= 10을 수행한다.
  6. 마지막으로 올림을 수행했다면(carry == 1), answer + 1을 리턴한다. 왜냐하면, 최종 숫자가 65에서 올림을 수행해서 70이 되고, 7을 검사할 때 현재 자릿 수가 5보다 크므로 10 - 7인 3이 더해진다. 하지만 이는 70을 100으로 만든 것이며, 다시 -100을 수행해야 하기 때문에 마지막에 + 1을 해야 한다.

코드

class Solution {
    public int solution(int storey) {
        int answer = 0;
        
        int carry = 0;
        
        while (storey > 0) {
            int current = storey % 10 + carry;
            if (current > 5 || ((current == 5) && (storey / 10) % 10 >= 5)) {
                answer += (10 - current);
                carry = 1;
            } else {
                answer += current;
                carry = 0;
            }
                storey /= 10;
        }    
        if (carry > 0) {
            return answer + 1;
        }
        return answer;
    }
}

예시

65를 생각해 보자

current == 5가 true이고, 십의 자리 수 6이 >= 5 이므로 

answer = 5 (10 - 5)가 되고 carry = 1이다.

storey /= 10을 수행하여 storey = 6이다.

 

그다음 십의 자릿수 6을 검사한다. 이전 단계에서 올림을 수행했으므로

current = 6 % 10 + 1  = 7이다.

current > 5가 true 이므로 

answer = 기존 5 + 3(10 - 7) = 8이다.

 

최종적으로 carry = 1인데, 이는 current인 7을 10으로 만들었다는 것을 의미하기 때문에, 다시 -10을 해주어야 해서

answer + 1을 한다.

따라서 9이다.

왜 (storey / 10 ) % 10 > 5가 아니라 >= 이어야 하는가?

올림을 하는 것이 더 효율적일 수 있기 때문이다.

 

  • 다음 자릿수에 대한 최적화 기회 제공:
    • 현재 자릿수에서 올림을 하게 되면 다음 자리수에 1이 더해진다.
    • 이렇게 올림으로 인해 발생한 carry는 다음 자릿수의 값에 영향을 주며, 이를 통해 다음 자리수를 0에 더 가깝게 만들 수 있는 기회를 제공한다. 예를 들어, 다음 자리수가 5 이상이라면 올림으로 인해 다음 자리수가 더 높은 자리에서 0으로 조정될 가능성이 생긴다.
  • 큰 자리수의 영향 감소:
    • 현재 자릿수에서 올림을 선택하면 높은 자리수에서 전체 숫자를 줄일 수 있는 기회를 만든다.
    • 높은 자릿수의 숫자는 숫자 자체가 크기 때문에, 이러한 자릿수를 줄이는 것은 전체적으로 더 큰 차이를 만들 수 있다.

 

14940번: 쉬운 최단거리 (acmicpc.net)

문제 접근

  1. 전형적인 BFS 문제 같다.
  2. 시작점부터 시작해서 BFS 시작
  3. 그런데 자꾸 3%에서 틀렸다 나오길래 질문게시판을 찾아보았다.

메모리 초과

  • 다시 같은 곳을 방문하지 않도록 방문 표시를 해주어야 한다. 안 그러면 Queue에 많은 좌표가 가득차게 된다.

틀렸습니다

  • 도달할 수 없는 곳은 -1로 출력을 해야 한다. 이 처리를 안 해주면 3퍼에서 틀렸다고 나온다.

따라서 필자는 방문 표시를 isVisited 배열을 만드는 대신 방문한 곳은 map 값을 -1로 바꾸어주었고, 도달하지 못한 곳은 이 값이 아직 1로 남아있기 때문에 이를 처리해주었다.

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class Main {

	static int[] rowDir = {1, -1, 0, 0};
	static int[] colDir = {0, 0, 1, -1};
	static int[][] map;

    public static void main(String[] args) throws IOException {
		BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
		StringTokenizer st = new StringTokenizer(br.readLine());
		int maxRow = Integer.parseInt(st.nextToken());
		int maxCol = Integer.parseInt(st.nextToken());

		map = new int[maxRow][maxCol];
		int[][] distance = new int[maxRow][maxCol];
		Queue<int[]> dq = new ArrayDeque<>();
		for (int i = 0; i < maxRow; i++) {
			st = new StringTokenizer(br.readLine());
			for (int j = 0; j < maxCol; j++) {
				map[i][j] = Integer.parseInt(st.nextToken());
				if (map[i][j] == 2) {
					dq.offer(new int[]{i, j, 0});
				}
			}
		}

		while(!dq.isEmpty()) {
			int[] current = dq.poll();
			int currentDistance = current[2];
			distance[current[0]][current[1]] = currentDistance;
			for (int i = 0; i < 4; i++) {
				int nextRow = current[0] + rowDir[i];
				int nextCol = current[1] + colDir[i];
				if (0 <= nextRow && nextRow < maxRow && 0 <= nextCol && nextCol < maxCol) {
					if (map[nextRow][nextCol] == 1 && distance[nextRow][nextCol] == 0) {
						map[nextRow][nextCol] = -1; // 방문 표시
						dq.offer(new int[]{nextRow, nextCol, currentDistance + 1});
					}
				}
			}
		}

		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < maxRow; i++) {
			for (int j = 0; j < maxCol; j++) {
				if (map[i][j] == 1) { // 도달할 수 없을 때
					sb.append(-1).append(" ");
				} else {

					sb.append(distance[i][j]).append(" ");
				}
			}
			sb.append("\n");
		}
		System.out.println(sb);

	}

}

Spring Data Cassandra을 사용하면서, @EnableCassandraAuditing를 적용해도 @CreatedDate 필드에 값이 들어가지 않고 null이 저장되는 문제가 발생했습니다. 이 글에서는 문제 상황 및 원인을 분석하고 해결합니다.

문제 상황: @EnableCassandraAuditing를 적용해도 null이 들어간다.

감사 레코드를 삽입하기 위해 다음과 같이 BaseEntity를 구현했다.

이를 Cassandra DB 테이블에 매핑할 클래스에 상속받았다.

이후 Auditing을 활성화하기 위해 @EnableCassandraAuditing을 적용했다.

또한 @CreatedBy, @LastModifiedBy를 사용하기 위해 getCurrentAuditor를 임시로 구현했다.

테스트를 위해 @Import를 사용하여 위에서 구현한 AuditorConfig를 테스트 컨텍스트에서 사용하도록 했다.

Spirng Data Cassandra는 @GeneratedValue를 지원하지 않기 때문에, 직접 랜덤 값을 생성하고 설정해 주었다.

데이터를 save 하고, 이를 fetch 해서 Debug로 데이터의 내부를 보면 createdDate와 createdBy가 null로 들어가 있다.

하지만 @LastModifiedAt과 @LastModifiedDate로 설정한 값은 잘 들어가 있다.

문제 원인: PersistentEntityIsNewStrategy의 동작

앞 상황에서, Spring Data Cassandra는 해당 데이터가 새로 삽입되는 데이터인지 감지하지 못한다. 

이는 PersistentEntityIsNewStrategy의 동작과 관련 있다.

PersistentEntityIsNewStrategy

Spring Data는 내부적으로 엔티티가 새로운지 판단하기 위해 기본적으로 PersistentEntityIsNewStrategy를 사용한다.

PersistentEntityIsNewStrategy는 기본적으로 사용되는 전략으로, Spring Data는 식별자(ID, 예: UUID)의 존재 여부를 통해 엔티티가 새로운지 판단한다. ID가 null이면 새로운 엔티티라고 가정한다.

 

예를 들어, 가장 흔하게 접할 수 있는 Spring Data JPA의 경우를 살펴보자.

예시 시나리오: 자동 생성된 ID 사용

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Integer version;

    private String name;
    // getters and setters
}
  • 대부분의 경우, JPA 엔티티는 자동 생성되는 기본 키(예: @GeneratedValue)를 사용한다.
  • ID가 null이면 Spring Data JPA는 엔티티가 새로 생성된 것으로 판단하고 데이터베이스에 INSERT 문을 실행한다.
  • ID가 null이 아니면, 엔티티가 이미 저장된 것으로 판단하고 UPDATE 문을 실행한다.
  • 이외에도, Spring Data JPA는 엔티티가 새 엔티티인지 다음과 같이 판단한다.
    • EntityManager: 내부적으로 Spring Data JPA는 EntityManager를 사용하여 엔티티의 상태를 추적한다. EntityManager는 엔티티의 생명주기를 관리하며(persisted 또는 detached), save()를 호출하면 Spring Data가 EntityManager와 상호작용하여 엔티티가 새로운지 여부를 판단한다.
    • Persistence Context: 엔티티가 데이터베이스에서 로드되면, 이는 persistence context에 의해 관리된다. 이 엔티티가 수정된 후 save()를 호출하면 JPA는 업데이트 작업을 수행하고, persistence context에 없는 경우 새 엔티티로 처리하여 삽입한다.

자동 생성되지 않는 Primary Key와 PersistentEntityIsNewStrategy의 한계

Spring Data JPA와 달리, Spring Data Cassandra는 @GeneratedValue와 같은 자동 ID 생성 기능을 지원하지 않는다.

따라서 Primary Key를 수동으로 생성해야 하며, ID가 수동으로 설정되었을 경우 Spring Data Cassandra는 PersistentEntityIsNewStrategy를 통해 새로운 엔티티 여부를 감지할 수 없다.

문제 해결: Persistable Interface 구현

Persistable을 구현하고 isNew()에서 true를 반환함으로써, Spring Data는 이 엔티티가 새로 생성된 것으로 인식하고, createdDate 필드를 설정하게 된다.

그리고 @EnableCassandraAuditing이 활성화된 상태에서 적절한 감사 필드(createdDate 등)를 설정한다.

참조

 

Spring Mongo Auditing not working @CreatedDate @CreatedBy

I am using Spring Mongo Auditing and @CreatedDate @CreatedBy not working but @LastModifiedDate and @LastModifiedBy working fine. I added @EnableMongoAuditing on a configuration class and also def...

stackoverflow.com

 

*chatGPT4o 의 답변입니다.

Q. does spring data cassandra has concept of transactional? what's difference in spring data jpa?

Spring Data Cassandra는 Spring Data JPA와 같은 방식으로 트랜잭션을 지원하지 않습니다. 두 기술에서 트랜잭션이 다르게 작동하는 이유와 차이점을 설명하겠습니다.

1. Spring Data JPA에서의 트랜잭션 지원

Spring Data JPA(관계형 데이터베이스를 기반으로 하는)는 @Transactional 애노테이션을 사용하여 트랜잭션을 완전히 지원합니다. 이는 관계형 데이터베이스가 기본적으로 ACID(Atomicity, Consistency, Isolation, Durability) 트랜잭션을 지원하기 때문입니다.

JPA 기반 애플리케이션에서는 트랜잭션을 통해:

  • 모든 데이터베이스 작업이 하나의 단위로 성공하거나 실패하도록 보장할 수 있습니다.
  • 일련의 데이터베이스 작업 중 오류가 발생하면 변경 사항을 롤백할 수 있습니다.
  • 하나의 트랜잭션에서 발생한 변경 사항을 다른 트랜잭션에서 격리할 수 있습니다.

JPA 트랜잭션의 예시:

@Transactional
public void saveOrder(Order order) {
    orderRepository.save(order);
    // 다른 데이터베이스 작업...
    // 만약 문제가 발생하면, 모든 변경 사항이 롤백됩니다.
}

2. Spring Data Cassandra에서의 트랜잭션 지원

Cassandra는 NoSQL 데이터베이스로, 관계형 데이터베이스와 같은 다중 행 트랜잭션 또는 전체 ACID 트랜잭션을 제공하지 않습니다. Cassandra는 고도로 분산되고, 파티셔닝되며, 확장 가능하도록 설계되었습니다. 하지만 분산된 특성 때문에 트랜잭션은 제한적입니다.

  • 단일 파티션 트랜잭션: Cassandra는 단일 파티션 내에서 원자성을 보장할 수 있습니다(즉, 동일한 파티션 키로 저장된 데이터에 대해서). 모든 작업이 같은 파티션 내에서 이루어질 경우, 해당 작업들은 함께 성공하거나 실패합니다.
  • 다중 파티션 트랜잭션 없음: Cassandra는 기본적으로 여러 파티션에 걸친 트랜잭션을 지원하지 않습니다. 이는 JPA와 달리 여러 행 또는 파티션에 걸친 작업을 트랜잭션으로 묶을 수 없다는 의미입니다.

Spring Data Cassandra에서:

  • 다중 파티션 간 글로벌 트랜잭션 지원 없음.
  • JPA처럼 @Transactional 애노테이션을 사용할 수 없습니다.
  • 여러 파티션에 걸친 트랜잭션 유사한 동작이 필요하면 애플리케이션 레벨에서 이를 관리해야 합니다(보상 트랜잭션, 나중에 일관성 보장 등).

Spring Data JPA와 Spring Data Cassandra의 트랜잭션 차이점

특징 Spring Data JPA Spring Data Cassandra
ACID 트랜잭션 트랜잭션에 대한 완전한 ACID 지원 단일 파티션 원자성으로 제한
@Transactional 애노테이션 전체 트랜잭션 관리를 위한 지원 지원하지 않음 (글로벌 트랜잭션 없음)
원자성 여러 행/테이블에 걸친 원자성 보장 동일 파티션 내 작업에만 원자성 보장
일관성 강한 일관성 (행 잠금 가능) 나중에 일관성 보장 가능, 튜닝 가능한 일관성
격리성 지원됨 (격리 수준을 조정할 수 있음) 다중 파티션 작업에는 적용되지 않음
내구성 높음 (관계형 데이터베이스에서 보장됨) 분산 환경에서 나중에 일관성 보장
롤백 지원 지원됨 (실패 시 자동 롤백) 여러 파티션에 대한 롤백 지원 없음

Cassandra에서 트랜잭션 지원이 제한적인 이유

Cassandra는 높은 가용성과 파티션 내성을 염두에 두고 설계되었으며, 이는 CAP 정리(Consistency, Availability, Partition Tolerance)에 맞춰져 있습니다. 이로 인해 Cassandra는 일관성보다는 가용성과 파티션 내성을 선택하므로, 여러 행이나 파티션에 걸친 ACID 트랜잭션을 제공할 수 없습니다.

Cassandra와 같은 분산 데이터베이스에서는 데이터를 여러 노드와 파티션에 걸쳐 분산하여 확장성을 유지하며, 강한 일관성 또는 ACID 트랜잭션을 유지하려면 성능과 확장성이 저하될 수 있습니다.

Cassandra와 같은 환경에서 트랜잭션 처리 방법

Cassandra가 완전한 ACID 트랜잭션을 제공하지 않기 때문에, 다른 접근 방식을 채택해야 합니다:

  • 멱등성 작업: 작업을 멱등성 있게 설계하여, 동일한 작업을 여러 번 수행해도 결과가 달라지지 않도록 해야 합니다.
  • 나중에 일관성 보장: 나중에 일관성 보장 개념을 이해하고, 시스템이 일시적인 불일치를 처리할 수 있도록 해야 합니다.
  • 애플리케이션에서 트랜잭션 관리: 보상 트랜잭션을 구현하거나 애플리케이션 레벨에서 일관성을 관리해야 합니다.

요약

  • Spring Data JPA는 완전한 ACID 트랜잭션을 제공하며, 여러 행과 테이블에 걸쳐 트랜잭션을 관리하기 위해 @Transactional 애노테이션을 사용합니다.
  • Spring Data Cassandra는 완전한 트랜잭션 지원을 제공하지 않습니다. 단일 파티션 작업에 대해서만 원자성을 보장하며, 여러 파티션에 걸친 트랜잭션은 지원하지 않습니다. 트랜잭션은 애플리케이션 레벨에서 관리해야 하며, 경우에 따라 보상 전략이 필요할 수 있습니다.

Q. and then, is there no the concept of entity too?

네, 맞습니다! Spring Data Cassandra에서는 Spring Data JPA에서 사용되는 "엔터티(Entity)" 개념이 동일하게 적용되지 않습니다. JPA와 Cassandra는 모두 영속성 데이터를 다루지만, 데이터베이스의 특성 때문에 접근 방식과 기본 개념이 크게 다릅니다.

다음은 Spring Data JPASpring Data Cassandra의 "엔터티" 개념 차이점입니다:

1. Spring Data JPA: 엔터티(Entity) 개념

JPA는 관계형 데이터베이스에서 사용되며, 엔터티는:

  • 테이블에 매핑된 객체입니다.
  • 각 엔터티 클래스는 하나의 테이블을 나타내며, 필드는 테이블의 컬럼을 나타냅니다.
  • 엔터티는 @Entity, @Table 및 기타 JPA 애노테이션을 사용하여 관계, 제약 조건, 매핑 등을 정의합니다.
  • JPA는 일대다(one-to-many), 다대다(many-to-many) 및 엔터티 간의 외래 키 제약 조건과 같은 관계형 개념을 처리합니다.

JPA 엔터티 예시:

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    // 기타 필드, 관계 등
}

2. Spring Data Cassandra: 엔터티(Entity) 개념 없음

Cassandra에서는 테이블에 초점을 맞추며, 이는 NoSQL 기반의 컬럼 패밀리 스토어로, JPA에서 사용되는 엔터티 개념과 직접적으로 매핑되지 않습니다. @Entity 대신 Spring Data Cassandra는:

  • @Table 애노테이션을 사용하여 Cassandra의 테이블을 나타냅니다.
  • @PrimaryKey 또는 @PrimaryKeyColumn 애노테이션으로 파티션 키와 클러스터링 컬럼을 지정합니다.

Cassandra는 구조를 가지지만, 관계형 데이터베이스에서처럼 엄격한 "엔터티" 관계는 존재하지 않습니다.

Cassandra 테이블 표현 예시:

@Table("users")
public class User {
    @PrimaryKey
    private String userId;

    @Column("username")
    private String username;

    @Column("email")
    private String email;

    // 기타 필드
}

주요 차이점: 엔터티 vs 테이블

  • 관계형 데이터베이스(JPA)는 엔터티 간의 구조화된 관계(예: 외래 키, 조인)에 중점을 둡니다. 엔터티는 테이블을 나타내며, 조인을 통해 관계를 관리할 수 있습니다.
  • Cassandra(Spring Data Cassandra)는 확장성과 성능에 중점을 둡니다. 조인과 같은 복잡한 관계를 피하고, 비정규화된 데이터 및 넓은 행을 선호합니다. @Table로 주석이 달린 클래스는 Cassandra 테이블을 나타내지만, 객체 간의 관계로서 엔터티 개념은 존재하지 않습니다.

이러한 차이점이 존재하는 이유

Cassandra는:

  • 수평 확장성을 위해 설계되었습니다. 데이터는 여러 노드에 분산되며, 외래 키 제약이나 복잡한 관계(조인)를 지원하지 않습니다.
  • 비정규화: 데이터를 정규화된 엔터티로 분리하고 이를 조인하는 대신, 데이터를 비정규화하여 여러 테이블에 중복 저장함으로써 빠른 읽기를 제공합니다.
  • 조인 없음: Cassandra는 조인을 지원하지 않으므로, 데이터 모델은 테이블 간 관계를 피하도록 설계되어야 합니다. 대신 쿼리 패턴에 맞게 데이터를 설계합니다.

Cassandra에서 데이터 모델을 생각하는 방법

Cassandra에서는:

  • 테이블은 주로 데이터를 쿼리하는 방식에 맞게 설계됩니다. 빠른 읽기를 위해 넓은 행으로 데이터를 비정규화하는 것이 일반적입니다.
  • 파티션 키와 클러스터링 컬럼과 같은 기본 키 개념을 가집니다. 하지만 외래 키나 조인 테이블과 같은 엔터티 관계는 없습니다.
  • 복잡한 엔터티 매핑에 의존하는 대신, 효율적인 쿼리 처리를 위해 설계가 이루어집니다.

주요 차이점 요약:

개념 Spring Data JPA Spring Data Cassandra
엔터티 있음 (@Entity) 없음 (@Table로 Cassandra 테이블을 표현)
관계 일대다, 다대다 등의 관계를 지원 관계 없음; 데이터는 비정규화됨
조인 테이블 간의 복잡한 조인 지원 조인 없음, 비정규화된 넓은 행에 중점
ACID 트랜잭션 완전히 지원 단일 파티션 원자성으로 제한
정규화 정규화 원칙을 따름 빠른 읽기를 위해 데이터는 주로 비정규화됨

Spring Data Cassandra에서는 엔터티가 더 간단합니다. 각 클래스는 하나의 테이블을 나타내며, 필드는 컬럼을 나타냅니다. 그러나 JPA에서와 같은 복잡한 관계는 존재하지 않습니다. Cassandra는 확장성과 가용성, 그리고 특정 쿼리 패턴을 최적화하는 데 중점을 둡니다.


Q. it's not just cassasndra, general nosql's feature?

네, 맞습니다! Spring Data CassandraSpring Data JPA의 차이점은 일반적인 NoSQL 데이터베이스의 특징을 반영합니다. 대부분의 NoSQL 데이터베이스는 관계형 데이터베이스(SQL)와는 다른 몇 가지 공통된 특징을 가지고 있습니다.

NoSQL 데이터베이스의 일반적인 특징:

  1. 스키마 없음 (유연한 스키마)
    • 관계형 데이터베이스(SQL): 엄격한 스키마를 가지고 있어, 데이터를 삽입하기 전에 테이블 구조(컬럼, 타입, 관계)를 정의해야 합니다. 스키마 변경은 데이터베이스 마이그레이션을 필요로 합니다.
    • NoSQL 데이터베이스: 일반적으로 스키마가 없거나 스키마가 유연합니다. 즉, 사전 정의된 컬럼이나 필드 유형 없이 문서나 행을 저장할 수 있습니다. 이는 데이터 모델의 변경이 자주 일어나는 경우 빠른 작업을 가능하게 합니다.
  2. 조인 없음 (비정규화)
    • SQL: 여러 테이블 간의 조인을 지원하며, 이를 통해 데이터를 정규화(즉, 중복을 최소화하기 위해 데이터를 여러 엔터티로 나눔)합니다.
    • NoSQL: 테이블이나 컬렉션 간의 조인을 지원하지 않습니다. 대신 비정규화(데이터 중복)를 통해 읽기 성능을 최적화합니다. 분산 시스템에서 조인 작업은 성능 저하를 초래할 수 있기 때문에, NoSQL에서는 이러한 비용을 피하려고 합니다.
  3. 수평 확장성 (Horizontal Scalability)
    • SQL: 일반적으로 수직 확장(서버의 CPU, 메모리 등 하드웨어 업그레이드) 방식으로 확장됩니다.
    • NoSQL: 수평 확장을 위해 설계되었습니다. 즉, 더 많은 서버(노드)를 추가하여 데이터를 분산하고 처리 성능을 확장합니다. 대규모 데이터를 처리하기 위해 데이터를 여러 노드에 분산시킵니다.
  4. 다중 문서/엔터티에 대한 ACID 트랜잭션 없음
    • SQL: 관계형 데이터베이스는 다중 행, 테이블, 데이터베이스 간의 복잡한 트랜잭션을 지원하며, 완전한 ACID(Atomicity, Consistency, Isolation, Durability) 보장을 제공합니다.
    • NoSQL: 일반적으로 일관성을 나중에 보장(eventual consistency)하며, 여러 문서나 행에 걸친 완전한 ACID 트랜잭션을 지원하지 않습니다. 일부 NoSQL 데이터베이스는 단일 문서나 파티션 내에서 ACID 보장을 제공하지만, 여러 컬렉션 또는 테이블에 걸쳐서는 불가능합니다.
  5. 읽기/쓰기 성능 최적화
    • SQL: 일반적으로 읽기와 쓰기의 균형을 맞추면서 강한 일관성을 제공합니다.
    • NoSQL: 매우 높은 성능의 읽기와 쓰기를 위해 최적화되어 있습니다. 특히 대용량 데이터(예: 소셜 미디어 피드, 로그 데이터 등)를 처리할 때 뛰어난 성능을 발휘합니다. 이 과정에서 일부 일관성 보장을 희생하여 나중에 일관성을 보장합니다.
  6. 파티셔닝 및 복제
    • SQL: 파티셔닝 및 복제를 지원하지만, 이는 주요 초점은 아닙니다. 관계형 데이터베이스에서 이러한 기능을 구현하고 구성하는 것은 복잡할 수 있습니다.
    • NoSQL: 파티셔닝(샤딩)복제를 기본적으로 지원하며, 데이터를 여러 서버에 분산시키고 복제하여 가용성과 장애 내성을 보장합니다. 데이터는 고가용성을 위해 여러 노드에 복제됩니다.
  7. 관계보다는 쿼리 패턴에 집중
    • SQL: 데이터 모델은 종종 엔터티 간의 관계를 중심으로 설계되며, 복잡한 쿼리에서 여러 테이블을 조인할 수 있습니다.
    • NoSQL: 쿼리 패턴에 맞추어 데이터 모델을 설계합니다. 데이터를 저장하고 구조화하는 방식은 주로 어떻게 데이터를 조회할지에 최적화되어 있습니다. 관계보다는 분산 환경에서 효율적인 데이터 검색에 중점을 둡니다.
  8. NoSQL 데이터베이스의 다양한 유형
    • 관계형 데이터베이스: 테이블과 관계를 기반으로 일관된 모델을 따릅니다.
    • NoSQL 데이터베이스: 각기 다른 데이터 모델에 따라 여러 유형으로 나뉩니다:
      • 문서 기반 (예: MongoDB): 문서(JSON과 유사한 형식)로 데이터를 저장하며, 각 문서는 유연한 스키마를 가질 수 있습니다.
      • 키-값 저장소 (예: Redis): 매우 빠른 읽기/쓰기를 위한 간단한 키-값 쌍을 저장합니다.
      • 컬럼 패밀리 (예: Cassandra): 열과 행으로 데이터를 저장하며, 분산된 저장과 빠른 쿼리에 최적화되어 있습니다.
      • 그래프 기반 (예: Neo4j): 관계 및 그래프 구조의 데이터를 처리하고 쿼리하는 데 최적화되어 있습니다.

NoSQL 데이터베이스의 예시 특징

  1. Cassandra (컬럼 패밀리 NoSQL)
    • 조인이나 외래 키를 지원하지 않습니다.
    • 테이블은 특정 쿼리에 맞게 설계됩니다.
    • 나중에 일관성 보장 및 높은 가용성을 제공합니다.
    • 노드를 추가하여 수평 확장이 가능합니다.
  2. MongoDB (문서 기반 NoSQL)
    • 데이터를 유연한 스키마를 가진 문서(JSON과 유사한 형식)로 저장합니다.
    • 조인을 지원하지 않지만, 문서를 중첩하여 데이터를 비정규화할 수 있습니다.
    • 단일 문서 내에서 ACID를 지원하지만, 컬렉션 간에는 지원하지 않습니다.
    • 샤딩을 통한 수평 확장이 가능합니다.
  3. Redis (키-값 저장소)
    • 데이터를 키-값 쌍으로 저장합니다.
    • 스키마나 관계가 없습니다.
    • 캐싱, 실시간 데이터, 세션 관리에 뛰어납니다.
    • 여러 키에 걸친 트랜잭션 지원은 없습니다.
  4. Neo4j (그래프 NoSQL)
    • 관계와 그래프 기반 쿼리에 최적화되어 있습니다.
    • 매우 연결된 데이터에 대한 복잡한 쿼리를 처리합니다.
    • Cassandra나 MongoDB처럼 대규모 분산 시스템에 최적화되지는 않았습니다.

관계형 데이터베이스(SQL)와 NoSQL 데이터베이스 비교

특징 SQL (관계형 데이터베이스) NoSQL 데이터베이스
스키마 고정된 스키마, 사전 정의 필요 스키마가 없거나 유연함
조인과 관계 복잡한 조인, 정규화 및 관계 지원 조인 없음, 비정규화와 내장 문서 사용
트랜잭션 완전한 ACID 지원, 복잡한 트랜잭션 가능 제한적이거나 여러 엔터티 간 트랜잭션 미지원
확장성 수직 확장(서버의 자원 추가) 수평 확장(서버/노드 추가)
일관성 강한 일관성(ACID 보장) 나중에 일관성 보장(CAP 이론에 따른 절충)
데이터 모델 테이블, 행, 열 문서, 키-값, 컬럼 패밀리, 그래프
쿼리 방식 SQL로 조인과 집계 쿼리 처리 쿼리 패턴에 따라 다름(예: CQL, MongoDB 쿼리 언어)
사용 사례 구조화된, 트랜잭션이 필요한 데이터 대규모, 실시간 데이터를 처리하는 환경

요약

  • NoSQL 데이터베이스는 스키마 유연성, 비정규화, 조인 미지원, 제한적인 트랜잭션 지원, 수평 확장성을 공유합니다.
  • Spring Data JPA는 엔터티-관계 모델과 완전한 ACID 트랜잭션을 제공하는 반면, NoSQL 데이터베이스(예: Cassandra)는 분산 시스템에서 높은 성능과 확장성을 위해 최적화된 구조를 제공합니다.

NoSQL 데이터베이스는 각각의 유형에 맞는 특성이 다르지만, 모든 NoSQL 데이터베이스는 확장성, 유연성, 성능을 우

선시하는 설계 철학을 공유하며, 이는 전통적인 관계형 데이터베이스의 강한 일관성 및 복잡한 관계 지원과는 차별화된 방식입니다.

문제 상황

createdAt, updatedAt과 같은 감사 로그는 공통적으로 사용되는 필드가 많기 때문에 BaseEntity를 자주 사용한다.

그런데 이를 모듈에 추가하고, 메인 프로젝트에서 이 모듈로 추가한 BaseEntitiy를 상속하면 잘 될까?

그리고 위와 같이, BaseEntity를 사용하기 위한 엔티티 및 설정을 추가해주었다.

처음엔 안 될거라고 생각했다.

왜냐하면 우선 Component Scan의 대해 생각해보았다.

Component Scan 범위는 @SpringbootApplication 에서 기본적으로 이 어노테이션이 적용된 클래스를 포함한 하위 패키지인데,

추가한 모듈은 범위 밖에 있기 때문에 Bean 을 못 찾고, 마찬가지로 JPA Audit 관련 기능도 BaseEntity를 찾지 못하겠지? 란 생각을 했다.

@EntityListeners(AuditingEntityListener.class)는 Component Scan 메커니즘에 의존하지 않는다.

@EntityListeners는 JPA 메커니즘이므로 Spring의 Component Scan에 의해 감지될 필요가 없다.

Listener는 엔티티의 애노테이션(@EntityListeners(AuditingEntityListener.class))을 기반으로 JPA에 의해 등록되고 호출되므로, 엔티티가 다른 모듈에 위치해 있더라도 JPA는 이를 처리할 수 있다.

 

따라서 Spring Boot가 @EnableJpaAuditing을 통해 JPA 감사를 설정했다면, 엔티티의 위치와 상관없이 감사 기능이 작동한다.

단, 엔티티는 class path에 존재해야 하며, 이는 Gradle 의존성으로 처리된다.

느낀 점

생각해보면 BaseEntity는 Bean에 등록되는 클래스가 아니다.

Component Scan을 떠올린 것으로 안 될 것이라 판단한 것은 다소 깊게 생각하지 못했던 부분 같다. 흐흐..