개발 slecs

상품 승인·중계결제·즐거운 연동을 쿼리부터 화면까지 정합성 맞춰 확장

목차

이번 작업은 상품 승인 워크플로우, 중계결제 v2, 즐거운 연동이라는 세 기능 영역을 동시에 건드렸음. 변경 파일만 봐도 SQL 파일 3개, 내부 클래스 1개, SQL 매퍼 1개, 뷰/스타일 1개 - 쿼리 레벨부터 화면 렌더링까지 수직으로 관통하는 작업이었다. 단순 UI 패치처럼 보이지만 실제로는 그렇지 않았음.

왜 이렇게 퍼지는가 - 레이어드 아키텍처에서 기능 추가의 현실

사내 서비스에서 "기능 하나 추가해줘"가 얼마나 무거운지는 해본 사람만 안다. 요청 자체는 단순했지만, 기존 화면과 API가 제공하지 않던 데이터나 동작이 필요했기 때문에 UI만 건드리는 것으로 끝낼 수가 없었다.

레이어를 타고 내려가면 대충 이런 흐름이 된다.

  • 화면에서 새 데이터가 필요하다
  • 그걸 내려주는 서비스 메서드나 엔드포인트가 없다
  • 쿼리가 없다
  • 기존 집계 로직이 그 케이스를 커버하지 않는다

결국 쿼리부터 설계하고, 거기서 나온 결과가 서비스 레이어를 거쳐 뷰까지 정합성을 유지하며 흘러야 한다. 중간에 하나라도 빠지면 화면 숫자가 틀리거나, 특정 사용자에게 공란이 뜨거나, 엣지 케이스에서 예외가 터진다.

금융/결제 도메인에서는 이 정합성 요구가 훨씬 엄격하다. 상품 승인 상태와 결제 집계 금액, 외부 연동 응답값이 화면마다 다르게 보이는 순간 서비스 신뢰가 흔들린다. "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아오는 영역이 여기다.

설계할 때 어디서 가장 오래 멈췄나

실시간 갱신 여부 결정. 중계결제 v2처럼 외부 시스템과 연동되는 데이터는 "언제 기준으로 보여줄 것인가"부터 결정해야 한다. 폴링으로 주기적으로 당겨올지, 이벤트 기반으로 상태 변화 시점에 반영할지에 따라 쿼리 설계와 캐싱 전략이 완전히 달라짐. 이번에는 즉시성 요구가 크지 않아서 요청 시점에 최신 데이터를 조회하는 방식으로 결정했다.

대신 쿼리 실행 계획을 미리 확인해서 인덱스 탐색이 풀스캔으로 빠지지 않는지 점검했음. 기능이 배포되고 나서 쿼리가 문제가 되면 롤백하거나 긴급 인덱스를 추가해야 하는데, 사전에 EXPLAIN으로 확인하는 게 훨씬 낫다.

NULL과 예외 케이스 방어. 새로 추가한 집계 쿼리가 기존 레코드에서 NULL을 돌려줄 때 어떻게 처리할지를 SQL 레벨에서 먼저 막아야 한다. 뷰에서 조건문으로 감싸는 건 임시방편이고, 집계가 복잡할수록 DB 단에서 명시적으로 처리하는 게 낫다.

-- 집계 쿼리에서 NULL 방어 패턴
SELECT
    product_id,
    COALESCE(SUM(amount), 0)       AS total_amount,
    COALESCE(COUNT(order_id), 0)   AS order_count,
    COALESCE(MAX(status_code), 'NONE') AS last_status
FROM relay_payment_logs
WHERE product_id = #{productId}
  AND deleted_yn = 'N'
GROUP BY product_id

매퍼 레벨에서는 resultType 대신 resultMap을 쓰면 컬럼 누락이나 타입 불일치가 생겼을 때 명시적으로 잡을 수 있다. 복잡한 집계 쿼리엔 특히 resultMap이 낫다.

기존 로직과의 충돌 확인. 내부 클래스에 메서드를 추가할 때 가장 조심하는 게 사이드이펙트다. 같은 클래스의 다른 메서드가 공유 상태를 갖거나 트랜잭션 범위가 묶여있으면, 새 메서드가 그 범위에 편승해서 예상치 못한 롤백이 섞일 수 있다. 이번엔 조회 전용 메서드 추가라 트랜잭션 충돌 위험은 낮았지만, 상태 변경이 함께 오는 케이스라면 전파 레벨을 명시하는 게 기본이다.

// 상태 변경 없는 조회는 readOnly 명시
@Transactional(readOnly = true)
public ProductApprovalSummary getApprovalSummary(Long productId) {
    return productMapper.selectApprovalSummary(productId);
}

readOnly = true는 단순 힌트가 아니라 flush/스냅샷 비교 생략 같은 성능 이점도 있고, MyBatis 환경이라도 코드 읽는 사람에게 이 메서드가 부수효과 없다는 신호를 준다. 명시적인 게 낫다.

검증은 구현 마지막이 아니라 중간중간

구현 다 끝내고 한 번에 검증하면 어디서 깨졌는지 찾기가 힘들다. 레이어별로 끊어가며 확인하는 게 결국 총 시간이 더 짧다.

  • SQL 먼저: 매퍼 실행 결과를 DB 직접 쿼리 결과와 비교
  • 서비스 레벨: 반환값이 기대한 형태인지 단계별로 확인
  • 화면 최종: 관련 화면에서 숫자 cross-check, 상태별 케이스를 직접 전환하며 확인

특히 상품 승인 워크플로우는 상태 머신 성격을 가지고 있어서 "승인 대기 → 승인 완료 → 취소" 각 단계를 화면에서 직접 전환하며 집계값이 제대로 바뀌는지 확인했음. 자동화 테스트로 커버하면 이상적이지만, 빠르게 검증이 필요할 때는 시나리오를 직접 시뮬레이션하는 것도 유효하다.

수정 전 화면 수치를 메모해두고 수정 후 같은 데이터로 비교하는 습관이 생각보다 효과적임. 스크린샷 한 장이나 쿼리 결과 복붙 한 줄이 나중에 꽤 귀하다. "전에 어떤 값이었는지"를 기억에 의존하면 반드시 틀린다.


커밋은 논리적으로 독립된 단위로 쪼개는 습관을 유지하고 있음. SQL 변경, 매퍼 추가, 서비스 메서드, 뷰 연동을 전부 묶어서 하나의 커밋으로 내면 나중에 어느 변경에서 뭔가 깨졌는지 추적하는 범위가 너무 넓어진다. 작은 커밋은 단순히 깔끔한 히스토리가 아니라 디버깅 속도 자체가 다르다.

커밋 메시지는 "무엇을"보다 "왜"를 담으려 한다. 코드는 어떻게 동작하는지를 보여주지만, 왜 이 설계를 선택했는지는 메시지에 남겨야 3개월 뒤 같은 파일을 열었을 때 맥락을 다시 파악하는 시간을 줄일 수 있다. 금융/결제처럼 숫자 하나가 틀리면 신뢰가 흔들리는 도메인일수록, 이런 흔적이 나중에 빛을 발한다.

댓글 0

첫 댓글 달아줘.