일반적인 서버 애플리케이션에서 엔티티 간 관계는 데이터베이스 외래키와 JPA 연관관계로 표현되는 경우가 많습니다. 이 방식은 강력합니다. 부모가 없는 자식 저장을 막고, 부모 삭제 시 자식 삭제를 전파할 수 있으며, 객체 그래프를 따라 저장과 삭제를 함께 처리할 수도 있습니다.
하지만 실제 도메인의 관계 변화는 단순한 저장소 레벨 관계 처리보다 넓습니다. 어떤 엔티티가 삭제되면 다른 엔티티가 삭제되어야 할 수도 있지만, 어떤 경우에는 빈 부모를 먼저 만들거나, 형제 엔티티를 함께 만들거나, 연결된 값을 재계산해야 합니다. 이 글에서는 Vizend 기본 프로젝트의 JPO 구조를 출발점으로 삼아, 왜 관계를 애플리케이션 레벨에서 다루려 하는지와 그 보완 방법을 정리합니다.
1. JPO를 간결하게 둔다는 것
Vizend 기본 프로젝트의 JPO는 대체로 간결합니다. JPO는 JPA Entity이지만, 관계를 풍부하게 표현하는 ORM 모델이라기보다는 도메인 객체를 영속화하기 위한 저장 모델에 가깝습니다. 다시 말해 JPO가 도메인 전체의 객체 그래프를 품기보다는, 하나의 row를 저장하고 다시 도메인 객체로 변환하는 역할에 집중합니다.
일반적인 JPA 모델에서는 부모와 자식을 객체 참조로 연결하고, 부모가 자식 목록을 영속 컬렉션으로 소유하게 만들 수 있습니다. 그러나 Vizend의 기본 모델은 자식이 부모 id를 @FieldSourceId로 가지고, 부모는 자식 목록을 transient로 둡니다. 그리고 JPO는 이 구조를 관계 매핑 없이 단순 필드 중심으로 저장합니다.
class Board {
private String id;
private String name;
private transient List<Card> cards;
}
class Card {
private String id;
@FieldSourceId
private String boardId;
private String title;
private transient List<Comment> comments;
}
class Comment {
private String id;
@FieldSourceId
private String cardId;
}
이 구조의 의도는 저장 모델을 단순하게 유지하고, DB 레벨 관계 처리에 강하게 기대기보다, 애플리케이션 코드에서 관계를 해석하고 처리하려는 방향을 보여줍니다.
2. 저장소가 아니라 애플리케이션이 관계를 다루는 이유
가장 큰 이유 중 하나는 DB 벤더와 운영 환경이 고정되어 있지 않다는 점입니다. 기본 프로젝트나 코드 생성 기반 프로젝트는 최종적으로 어떤 DB에서 실행될지 처음부터 확정되지 않을 수 있습니다. 외래키 제약은 대부분의 RDBMS가 지원하지만, 제약 조건 생성 방식, 참조 액션의 세부 동작, DDL 자동 생성, migration 전략, trigger 사용 방식은 DB와 운영 환경에 따라 달라집니다.
저장소 레벨의 관계 처리에 도메인 규칙을 강하게 의존하면 프로젝트의 DB 선택지가 좁아집니다. 특정 DB의 constraint, trigger, cascade action 동작을 전제로 도메인 규칙을 설계하면 다른 DB로 옮길 때 예상보다 많은 차이가 발생할 수 있습니다. 따라서 기본 모델은 특정 DB 기능에 기대기보다, 애플리케이션 코드에서 관계 규칙을 해석할 수 있도록 단순한 id 참조를 유지하는 편이 유리합니다.
두 번째 이유는 관계 변화가 삭제 전파보다 넓다는 점입니다. 저장소 레벨 관계 처리는 부모가 없는 자식 저장을 막거나, 부모가 삭제될 때 자식을 함께 삭제하는 데 강합니다. 그러나 실제 도메인 규칙은 여기에 머물지 않습니다.
예를 들어 A가 삭제되면 B도 삭제되어야 하는 경우는 저장소 레벨 참조 액션이나 JPA remove cascade로 어느 정도 표현할 수 있습니다. 그러나 B를 저장하려는데 부모 A가 없어서 빈 껍데기의 A를 먼저 만들어야 하는 경우, A가 생성되면 형제 B도 함께 생성되어야 하는 경우, A의 값이 수정되거나 삭제되어 B의 값이 재계산되어야 하는 경우는 단순한 외래키 액션으로 표현하기 어렵습니다.
데이터베이스 trigger를 사용하면 일부 처리를 DB 내부로 옮길 수는 있습니다. 그러나 그렇게 하면 도메인 로직이 애플리케이션 코드가 아니라 데이터베이스 안에 숨어버립니다. 테스트와 추적이 어려워지고, 서비스 경계를 나누기도 힘들어집니다. 따라서 애플리케이션이 성장할수록 관계 변화는 저장소의 자동 동작보다 유스케이스와 도메인 규칙에 가까워집니다.
3. 애플리케이션 코드로 풀 때 생기는 문제
관계를 애플리케이션 코드에서 다루면 도메인 규칙을 더 명시적으로 표현할 수 있습니다. Board 삭제 시 Card를 삭제하고, Card 삭제 시 Comment를 삭제하는 로직은 코드 안에서 읽힙니다. 단순 삭제뿐 아니라 이력 기록, 상태 검증, 권한 확인, projection 갱신 같은 도메인 행위도 함께 넣을 수 있습니다.
문제는 관계 처리 코드가 점점 흩어진다는 데 있습니다. 처음에는 Board 삭제 시 Card만 지우면 됩니다. 이후 Card 아래에 Comment가 생기면 Card 삭제 로직에 Comment 삭제가 추가됩니다. 또 NotificationRule, DashboardWidget, ActivityLog 같은 개념이 생기면 삭제 전파 코드는 더 커집니다.
첫 번째 문제는 누락입니다. 새로운 참조 필드가 추가되어도 기존 삭제 로직은 자동으로 알 수 없습니다. 개발자가 해당 관계를 기억하고 삭제 로직에 반영해야 합니다. 누락되면 고아 데이터가 남습니다.
두 번째 문제는 다중 경로입니다. A에서 B를 거쳐 C에 도달하는 경로와, A에서 D를 거쳐 C에 도달하는 경로가 동시에 있으면, A 삭제 과정에서 C에 두 번 도달할 수 있습니다. 삭제는 한 번만 일어나야 하지만, 각 handler나 action이 독립적으로 후속 삭제를 호출한다면 같은 C 삭제가 두 번 호출될 위험이 있습니다.
A -> B -> C
A -> D -> C
세 번째 문제는 영향 범위 파악의 어려움입니다. A를 삭제하기 전에 어떤 엔티티가 함께 영향을 받는지 알기 어렵습니다. 저장소 레벨 관계 처리라면 DB나 JPA가 정해진 규칙을 수행하지만, 애플리케이션 코드 cascade에서는 처리 경로가 여러 action과 handler에 흩어져 있습니다.
이 문제들은 저장소 레벨 관계 처리를 쓰지 않았기 때문에 생긴 문제라기보다, 관계 전파를 애플리케이션 코드에서 다루면서도 그 실행 흐름을 일관되게 관리하지 않았기 때문에 생긴 문제입니다.
4. 해결 방향: DataEvent와 CascadeContext
Flow, Logic, PolicyHandler, CascadeContext로 나누어, 각자의 역할을 분리하여 수행하도록 하였습니다.
Flow creates execution context
Logic performs its own CUD
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
Flow는 유스케이스의 진입점입니다. 여기서 하나의 실행 범위를 만들고 CascadeContext를 생성합니다. Logic은 실제 CQRS를 수행합니다. 자기 엔티티를 생성, 수정, 삭제하고 DataEvent를 발행합니다. PolicyHandler는 DataEvent를 받아 연결된 Logic을 호출합니다.
예를 들어 Board 삭제 요청이 들어오면 Flow는 context를 만들고 BoardLogic을 호출합니다. BoardLogic은 Board 삭제와 BoardRemoved 이벤트 발행만 책임집니다. BoardRemoved 이벤트를 받은 BoardPolicyHandler는 Card 목록을 찾아 CardLogic을 호출합니다. 이후 CardLogic은 CardRemoved 이벤트를 발행하고, CardPolicyHandler가 CommentLogic을 호출합니다.
이 구조에서 각 Logic은 자기 것만 처리합니다. BoardLogic은 Card를 모릅니다. CardLogic은 Comment를 모릅니다. 관계 전파는 PolicyHandler가 담당합니다.
이벤트 기반 전파에서 가장 조심해야 할 점은 중복 처리입니다. 같은 엔티티가 여러 경로에서 도달할 수 있기 때문입니다. 이를 막기 위해 하나의 실행 흐름 안에서 이미 처리한 action을 기억하는 CascadeContext를 둡니다.
public class CascadeContext {
private final Set<String> completed = new HashSet<>();
public boolean enter(Class<?> type, String id, String action) {
String key = type.getName() + ":" + id + ":" + action;
return completed.add(key);
}
}
PolicyHandler는 다음 Logic을 호출하기 전에 context를 확인합니다. 이미 처리한 action이면 건너뛰고, 처음 만난 action이면 다음 Logic을 호출합니다.
public class BoardPolicyHandler {
public void onBoardRemoved(DataEvent event) {
CascadeContext context = cascadeContextHolder.current();
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
if (!context.enter(Card.class, card.getId(), "REMOVE")) {
continue;
}
cardLogic.removeCard(card.getId());
}
}
}
이렇게 하면 A에서 B를 거쳐 C에 도달하고, A에서 D를 거쳐 C에 도달하는 구조에서도 C는 한 번만 처리됩니다. 두 번째로 같은 action이 들어오면 context.enter가 false를 반환하고 handler는 다음으로 넘어갑니다.
이 구조를 적용할 때는 Spring 이벤트가 동기적으로 실행된다는 전제를 명확히 해야 합니다. 같은 transaction 안에서 전파하려면 일반적인 동기 EventListener 흐름을 사용해야 합니다. 비동기 이벤트나 commit 이후 이벤트를 사용하면 동일한 CascadeContext와 transaction을 유지하기 어렵습니다.
5. 메타데이터로 관계를 관찰하기
CascadeContext는 실행 중복을 막아 줍니다. 하지만 어떤 관계가 존재하고, 어떤 관계가 처리되고 있으며, 어떤 관계가 누락되었는지를 알기 위해서는 별도의 관찰 가능성이 필요합니다. 여기서 FieldSourceId와 RelationPolicy를 함께 사용할 수 있습니다.
FieldSourceId는 참조 관계를 발견하는 데 사용할 수 있습니다. 예를 들어 Card.boardId에 FieldSourceId가 붙어 있다면, 애플리케이션은 Card가 Board를 참조한다는 사실을 알 수 있습니다. 이 정보를 스캔하면 참조 그래프를 만들 수 있습니다.
ReferenceGraph
Board
Card.boardId
Card
Comment.cardId
하지만 참조 그래프만으로는 부모의 CUD가 자식에게 어떤 영향을 주는지 알 수 없습니다. Board가 삭제될 때 Card를 삭제해야 하는지, Card.boardId를 비워야 하는지, 아니면 삭제를 막아야 하는지는 별도의 정책입니다.
이를 위해 PolicyHandler의 메서드에 RelationPolicy 같은 메타데이터를 둘 수 있습니다. 이 어노테이션은 handler의 동작을 대신하지 않습니다. 실제 동작은 method body에 남아 있습니다. 어노테이션은 이 handler가 어떤 관계 전파를 담당하는지 알려주는 메타데이터입니다.
@RelationPolicy(
source = Board.class,
event = DataEventType.REMOVED,
target = Card.class
)
public void onBoardRemoved(DataEvent event) {
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
cardLogic.removeCard(card.getId());
}
}
이렇게 하면 애플리케이션은 두 종류의 그래프를 가질 수 있습니다. 참조 그래프는 누가 누구를 참조하는지 알려줍니다. 전파 그래프는 어떤 이벤트가 어떤 대상에 영향을 주는지 알려줍니다.
ReferenceGraph
FieldSourceId based graph
PropagationGraph
RelationPolicy based graph
두 그래프를 비교하면 영향 범위, 누락된 정책, 중복 경로, 순환 경로를 검사할 수 있습니다. 예를 들어 참조 그래프에는 Card에서 NotificationRule로 향하는 관계가 있는데, 전파 그래프에는 CardRemoved 이벤트에 대한 NotificationRule 정책이 없다면 누락으로 볼 수 있습니다. 반대로 A에서 B를 거쳐 C에 도달하는 경로와 A에서 D를 거쳐 C에 도달하는 경로가 모두 있다면, 그래프 분석 단계에서 다중 경로를 미리 경고할 수 있습니다.
Impact
Missing policy
Duplicate path
Cycle
이 구조의 장점은 도메인 로직을 어노테이션에 숨기지 않으면서도 관계 처리의 관찰 가능성을 얻는 데 있습니다. FieldSourceId는 참조 메타데이터이고, RelationPolicy는 전파 메타데이터이며, 실제 도메인 반응은 PolicyHandler의 method body에 남아 있습니다.
마지막으로 이 구조는 dry-run을 붙이면 영향 범위 미리 보기로 확장할 수 있습니다. CascadeContext에 dryRun 플래그와 impact 목록을 추가하고, Logic이 dry-run일 때 실제 저장소 변경만 생략하되 DataEvent는 그대로 발행하면 됩니다. 그러면 실제 실행과 같은 이벤트 체인을 따라가면서도 DB 변경은 하지 않고, 어떤 엔티티가 영향을 받는지 계산할 수 있습니다. 다만 dry-run은 모든 DataEvent handler가 동일한 CascadeContext 규칙을 따른다는 전제가 있을 때만 신뢰할 수 있으므로 영향 범위가 통제되는 영역부터 소극적으로 도입하는 편이 안전합니다.
6. 마치며
데이터베이스 외래키와 JPA cascade는 관계를 다루는 강력한 도구입니다. 하지만 모든 관계 변화가 저장소 수준의 생명주기 전파로 설명되는 것은 아닙니다. 애플리케이션이 커질수록 관계 변화는 삭제 전파를 넘어 생성 보정, 형제 생성, 재계산, projection 갱신 같은 도메인 규칙으로 확장됩니다.
Vizend의 기본 JPO가 간결하게 유지되고, 자식이 부모 id를 FieldSourceId로 들고 있으며, 부모의 자식 목록을 transient로 조립하는 방식은 이런 맥락에서 이해할 수 있습니다. 관계를 DB/JPA에 강하게 위임하지 않고, 애플리케이션 코드로 다룰 수 있는 여지를 남기는 선택입니다.
물론 이 선택에는 책임이 따릅니다. DB가 막아주던 무결성을 애플리케이션이 직접 관리해야 하고, 전파 누락이나 중복 실행을 방지해야 합니다. 이를 위해 Flow, Logic, PolicyHandler, CascadeContext의 역할을 나누고, FieldSourceId와 RelationPolicy로 관계를 관찰하는 구조를 이야기 했습니다.
Logic changes only its own entity
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
FieldSourceId describes reference graph
RelationPolicy describes propagation graph
이 구조는 저장소 레벨 관계 처리를 애플리케이션 코드로 그대로 흉내 내는 것에 목적이 있는 것이 아니라, 관계 변화의 의미를 도메인 코드 안에 남기되 그 실행 흐름을 일관되게 관리하려는 시도입니다.
읽어주셔서 감사합니다.
HHkk