회고/개인적인 생각들

도메인이 다른 데이터를 여러 곳에서 사용해도 되는가?

codesparkling 2025. 6. 7. 14:55

도메인이 다른 데이터를 합쳐서 사용해도 되는가?

팀원 중 한 분이 프로젝션 관련해서 Payment(결제 내역 테이블)에 있는 데이터를 활용해서 최근 7일 사이의 가장 큰 낙찰가의 경매를 보여주는 API를 작성하는 데 도움을 달라는 요청이 왔다. 이 때 의문이 좀 들었다.

약간의 배경 설명을 먼저 하겠다.

 

지금 경매 검색은 bid(입찰 테이블), scrap(쉽게 생각해서 즐겨찾기 기능의 테이블), auction_image(경매 이미지 url 저장 테이블)에 대해 조인 연산을 진행한 뒤에 데이터를 찾아온다. 그 이유는 입찰 횟수, 최고 입찰가, 즐겨찾기 횟수, 경매 썸네일을 가져와야 하기 때문이다. 이 때문에 한 번에 이해가 힘든 쿼리가 발생했다. (글의 마지막에 첨부된 SQL문)
내가 구현한 경매 검색 쿼리와 완전히 같지는 않지만 비슷한 결의 기능을 팀원도 구현해야 했다. 그래서 이런 복잡한 쿼리 + JOIN에 대한 연산 결과를 예측(이 부분은 한방 쿼리와 관련된 의문 및 문제 상황에서 보충 설명이 있을 것이다.)으로 그냥 데이터를 나눠서 받고 프로젝션 쿼리를 재활용하는 방향으로 노선을 틀었다고 한다.

 

 

중요한 건 이 때, JOIN 연산을 줄이기 위해 결제 내역의 데이터를 가져와서 가장 큰 낙찰가의 경매를 보여주겠다고 했다. 나는 확실히는 모르지만 뭔가 이렇게 하면 안될 것 같다는 생각을 했다. 그 근거는 결제와 경매는 전혀 다른 도메인이기 때문이다. 내가 생각했을 때는 코드 유지보수 (경매에서 갑자기 결제 내역을 조회하는 부분이 나오면 이상하지 않은가?) + 결제 내역이 변경됨에 따라 자세하진 않지만 어떤 사이드 이펙트가 따라와서 경매 비즈니스 로직이 고장나는 상황이 그려졌다. 여기에 더해서 비슷한 도메인을 묶어서 응집도를 높이는 것도 좋을 것 같았다. (예를 들어, 경매에 종속되는 입찰 도메인을 묶어서 생각하는 것처럼 말이다.) 그래서 결제와 경매는 다른 도메인이니까 그렇게 하면 안될 것 같다고 이야기를 했다.

 

 

그러자 돌아오는 질문이, 경매와 입찰도 다른 테이블로 관리되는 다른 도메인인데 왜 둘은 밀접하게 상호작용을 하고 결제는 안되는거죠?였다.

당시에는 당황에서 답을 못했는데 10분 정도 생각을 해봤다. 내 생각에 입찰은 애초에 경매에서 파생되는 하위의 개념이라 생각이 들었다. 그래서 경매와 입찰은 자유로워도 상관없지 않을까? 라는 생각을 했고 이 부분을 명확하게 하기 위해 강사님께 도움을 요청했다.

강사님이 알려주신 해답은 DDD였다.

 

 

도메인 주도 개발에서 도메인 사이에는 넘을 수 없는 일종의 벽이 있다는 말을 들었다. 결제와 경매는 다른 도메인이기에 분리해야 한다. 그리고 만약 결제에서 요구사항이 추가되어 쿠폰, 할인 등이 들어가면 실제 결제 금액은 달라질 것이다. 이런 경우에는 결제를 참조하고 있으면 기능에 문제가 발생한다. 이렇게 사이드 이펙트를 최소화 하기 위해서는 도메인 간에 서로 침범을 하지 않는게 중요하다고 하셨다. 이런 것을 격벽이라고 한다. 소프트웨어 엔지니어링에서는 요구사항이 언제든 쉽게 변할 수 있어서 변화에 열려있어야 한다. 이런 인사이트를 얻을 수 있어서 오늘 유익했다. 격벽을 철저하게 세움으로써 변화에 열려 있으며 수정 사항을 줄일 수 있다는 것을 배웠고 DDD를 나중에 더 봐야겠다는 생각도 들었다.

한방 쿼리와 관련된 의문 및 문제 상황

아까 이야기 했던 JOIN 연산을 줄이고 싶다는 팀원의 말에서 다른 질문이 또 파생되었다. 내가 좋아하는 한방쿼리(사실 호눅스님이 좋다고 한 게 뇌에 박혀서 나도 그걸 좋아한다고 생각하는 것 같지만)와 JOIN이 여러 번 일어나는 것에 대한 문제였다.

팀원은 JOIN이 여러 번 일어나면 차라리 데이터베이스를 여러 번 호출해서 그 데이터를 애플리케이션 레벨에서 조합해 사용하는 게 나을 수도 있다는 GPT의 조언을 보고 공통되는 스크랩 횟수를 재사용할 수 있는 쿼리로 분리하는 생각을 했다.

나는 한방쿼리가 DB 요청, 트랜잭션 관리 측면에서 더 좋은 성능을 낼 수 있고 데이터 정합성을 고려하면 이 방법이 낫지 않을까 싶었다.

그래서 한방 쿼리 VS 여러 쿼리로 데이터 조립의 대립이 있었다. 강사님이 이 부분에 대해 의견을 주셨는데 도움이 많이 되었다.

 

 

결론부터 말하면 직접 해봐야 안다.

 

 

JOIN 여러 번이 나쁜 성능을 내는지 보기 위해서는 데이터 분포, 데이터 개수, 데이터 확장성이 중요하다. 옵티마이저는 위의 값들을 참고해서 쿼리를 평가/튜닝 하는데, 쿼리가 실행될 때 File I/O가 많이 일어나는지를 알아야 한다. 이 내용은 랜덤 액세스, 카디널리티의 높고 낮음, 인덱스 등과 연관되는 문제다. 결국 실행 계획을 보면서 MySQL 통계 데이터를 참고하며 성능을 평가해야 하는 것이다. 예를 들어, 카디널리티가 낮은 데이터는 중복도가 높아서 WHERE 절 조건에 의해 많은 데이터가 필터링 되기에 빠르게 검색할 수 있을 것이다.

이런 통찰력을 키우기 위해 REAL MySQL 책과 데이터베이스 개론에 대해 더 공부해야 겠다는 생각이 들었다.

SELECT a.id, a.title, a.start_time, a.end_time, a.status, a.base_price, COALESCE(i.url, 'test') as thumbnail_url,
 COALESCE(MAX(b.price), a.base_price) as current_price, COUNT(DISTINCT(b.bidder_id)) as bidder_count,
 COUNT(DISTINCT(s.id)) as scrap_count
 FROM auction a
 LEFT JOIN bid b ON a.id = b.auction_id AND b.is_deleted = false
 LEFT JOIN scrap s ON a.id = s.auction_id
 LEFT JOIN auction_image i ON a.id = i.auction_id
 JOIN category c ON a.category_id = c.id
 WHERE
 MATCH(a.title, a.description)
 AGAINST (:#{#request.keyword} IN NATURAL LANGUAGE MODE) AND
 -- 카테고리 SQL
 (:#{#request.categoryId} IS NULL OR a.category_id IN (
     WITH RECURSIVE CategoryHierarchy AS (
     SELECT id FROM category WHERE id = :#{#request.categoryId}
     UNION ALL
     SELECT c.id FROM category c
     JOIN CategoryHierarchy ch ON c.parent_id = ch.id
     )
     SELECT id FROM CategoryHierarchy
 )) AND
 --
 (:#{#request.maxPrice} IS NULL OR a.base_price <= :#{#request.maxPrice}) AND
         (
             ((:#{#request.isBrandNew} IS NULL OR :#{#request.isBrandNew} = FALSE) AND
              (:#{#request.isLikeNew} IS NULL OR :#{#request.isLikeNew} = FALSE) AND
              (:#{#request.isGentlyUsed} IS NULL OR :#{#request.isGentlyUsed} = FALSE) AND
              (:#{#request.isHeavilyUsed} IS NULL OR :#{#request.isHeavilyUsed} = FALSE) AND
              (:#{#request.isDamaged} IS NULL OR :#{#request.isDamaged} = FALSE))
             OR
             ((:#{#request.isBrandNew} = TRUE AND a.item_condition = 'brand_new') OR
              (:#{#request.isLikeNew} = TRUE AND a.item_condition = 'like_new' ) OR
              (:#{#request.isGentlyUsed} = TRUE AND a.item_condition = 'gently_used' ) OR
              (:#{#request.isHeavilyUsed} = TRUE AND a.item_condition = 'heavily_used' ) OR
              (:#{#request.isDamaged} = TRUE AND a.item_condition = 'damaged' ))
         ) AND
         (
             ((:#{#request.isPending} IS NULL OR :#{#request.isPending} = FALSE) AND
              (:#{#request.isActive} IS NULL OR :#{#request.isActive} = FALSE) AND
              (:#{#request.isCompleted} IS NULL OR :#{#request.isCompleted} = FALSE))
             OR
             ((:#{#request.isPending} = TRUE AND a.status = 'pending' ) OR
              (:#{#request.isActive} = TRUE AND a.status = 'active' ) OR
              (:#{#request.isCompleted} = TRUE AND a.status = 'completed' ))
         )
GROUP BY a.id, a.title, a.start_time, a.end_time, a.status, a.base_price, a.created_at, i.url
HAVING
    (:#{#request.minPrice} IS NULL OR COALESCE(MAX(b.price), a.base_price) >= :#{#request.minPrice}) AND
    (:#{#request.maxPrice} IS NULL OR COALESCE(MAX(b.price), a.base_price) <= :#{#request.maxPrice})
ORDER BY a.created_at DESC
LIMIT :#{#request.limit} OFFSET :#{#request.offset}

'회고 > 개인적인 생각들' 카테고리의 다른 글

태도를 반성하게 된 날  (0) 2025.06.07
슬럼프 넘겨버리기  (0) 2025.04.03
이력서, 자소서를 작성하며 든 생각  (3) 2025.03.17