전자계약서 알림 기능 전면 개편과 결제 데이터 정합성 확보
목차
partner-contract 영역에 기능을 추가했다. 이번 작업의 중심은 전자계약서 알림 이메일 HTML 템플릿 개편이었고, 변경된 파일은 내부 클래스 하나. 클래스 하나라는 말에 처음엔 가볍게 봤는데 열어보니 알림 생성 트리거, 뱃지 카운트 쿼리, 타입별 라벨 정의, 읽음 처리, 전체삭제까지 죄다 엮여 있었다.
기존 화면과 API가 제공하지 않던 데이터와 동작이 필요해진 시점이기도 해서, 단순 UI 조작이 아니라 쿼리 레벨부터 다시 설계해야 했음.
구현하면서 결정해야 했던 것들
선택지가 생긴 지점들을 정리하면 이렇다.
| 결정 포인트 | 선택한 방향 | 이유 |
|---|---|---|
| 실시간 갱신 방식 | 폴링 | 계약서 알림은 즉시성 요구가 낮고, 웹소켓/SSE는 연결 유지 부담 |
| 뱃지 카운트 범위 | 중요 알림만 | 전체 카운트 시 시스템 알림이 포함돼 노이즈 발생 |
| 라벨 관리 | 내부 enum 정의 | 화면마다 문자열 하드코딩 시 수정 포인트가 분산됨 |
| 삭제 방식 | soft-delete | 물리 삭제 시 카운트 이력 추적 불가 |
폴링 vs 이벤트는 기능마다 고민이 드는 지점이다. 계약서 알림 특성상 1-2초 딜레이가 치명적이냐 하면 그렇지 않아서 폴링을 선택했음. 웹소켓이나 SSE를 붙이면 연결 상태 관리, 재연결 로직, 서버 리소스 비용이 같이 따라온다. 기능이 더 커질 때 다시 검토할 여지는 남겨뒀다.
뱃지 카운트 쿼리는 이렇게 작성했다.
SELECT COUNT(*)
FROM notifications
WHERE user_id = :userId
AND is_read = false
AND importance = 'HIGH'
AND deleted_at IS NULL;
중요도 필터와 soft-delete 조건을 동시에 걸어야 한다. 중요도 없이 전체를 카운트하면 시스템 알림까지 잡혀서 뱃지 숫자가 체감보다 크게 올라가고, deleted_at 조건을 안 걸면 삭제한 알림이 뱃지에 계속 남는다. 둘 다 실수하기 쉬운 부분이라 조건을 명시적으로 박아뒀음.
알림 타입별 라벨은 enum으로 중앙화했다.
public enum NotificationLabel {
CONTRACT_SIGNED("계약서 서명 완료"),
CONTRACT_REJECTED("계약서 반려"),
CONTRACT_EXPIRED("계약서 만료 예정");
private final String label;
NotificationLabel(String label) { this.label = label; }
public String getLabel() { return label; }
}
타입이 추가되면 여기에 항목 하나만 추가하면 된다. 화면 여러 곳에서 직접 문자열을 박아두면 나중에 텍스트 하나 바꿀 때 찾아다녀야 하고, 빠뜨린 곳이 생긴다.
금융/결제 도메인에서 정합성을 챙기는 방법
이번 작업에서 가장 신경 쓴 부분이다.
기존 데이터가 새 기능과 충돌하면 안 되고, 다른 화면에서 보여주는 숫자와 알림 카운트가 맞아야 한다. 금융/결제 도메인에서 숫자 하나가 틀리면 신뢰가 흔들린다. 그 점에서 이번 작업은 "기능 추가"라기보다 "기존 데이터 흐름에 새 파이프를 끼워넣는" 작업에 가까웠음.
작업 전에 현재 화면 수치를 메모하고, 쿼리 실행 계획(EXPLAIN)을 미리 돌려서 성능 영향도를 확인했다. 인덱스 상태를 보고 추가 인덱싱이 필요한지 먼저 체크하는 게 습관인데, 이번엔 기존 인덱스로 커버가 됐음.
예외 케이스는 세 가지를 명시적으로 처리했다.
- 빈 데이터: 알림이 없을 때 NULL이 아닌 0을 반환하도록.
COUNT(*)는 직접 NULL을 반환하지 않지만, 조인이나 서브쿼리 구조에 따라 결과가 달라질 수 있어서 확인해뒀음. - 권한 없는 접근:
user_id조건이 쿼리에서 누락되면 다른 사용자의 알림이 섞인다. 파라미터 바인딩 레벨에서 항상 강제. - NULL 컬럼 처리:
importance나deleted_at이 NULL인 레코드가 있을 때 조건이 의도와 다르게 동작하지 않도록IS NULL/IS NOT NULL을 명시.
"이 정도는 괜찮겠지"로 넘기면 반드시 특정 케이스에서 다시 나타난다. 그 경험이 쌓이면 방어적으로 써두는 게 자연스럽게 습관이 됨.
구현 후에는 직접 화면에서 동작을 확인했음. 읽음 처리가 뱃지에 즉시 반영되는지, 전체삭제 후 카운트가 0이 되는지, 삭제된 알림이 재등장하지 않는지. 관련 화면이 있으면 함께 열어서 숫자를 cross-check했다.
커밋 방식과 메시지 스타일
이번 작업 커밋을 논리 단위로 쪼갰다.
- 알림 생성 트리거 포인트 추가
- 뱃지 카운트 쿼리
- 타입별 라벨 enum 정의
- 읽음/전체삭제 처리
한 번에 다 넣으면 편하긴 한데, 나중에 어느 변경에서 문제가 생겼는지 추적할 때 범위가 너무 넓어진다. 특히 정합성 이슈는 어떤 시점에 데이터가 깨졌는지가 핵심인데, 커밋이 굵으면 그 지점을 특정하기 어렵다. 작은 단위로 쪼개는 게 번거롭게 느껴지는 순간도 있지만, 사고가 났을 때 git bisect나 단순 로그 훑기에서 그 차이가 확실히 난다.
커밋 메시지는 "무엇을"보다 "왜"를 담으려 한다.
- 나쁜 예:
알림 뱃지 카운트 추가 - 나은 예:
중요 알림만 카운트해 뱃지 노이즈 줄임 - 전체 카운트 시 시스템 알림이 포함되는 문제 방지
나중에 같은 코드를 보는 사람이 나일 확률이 높고, 그때 "왜" 이렇게 했는지가 없으면 건드리기 무서워진다. 특히 정합성 관련 조건은 이유 없이 살짝 바꿨다가 조용히 터지는 케이스가 많다.
사내 서비스를 만들다 보면 기능 하나가 SQL 집계, 상태 처리, 예외 방어, 화면 렌더링, 권한 체크를 전부 물고 들어온다는 걸 계속 체감한다. partner-contract처럼 결제와 맞닿은 도메인일수록 그 연결 고리를 끊지 않으면서 확장하는 게 작업의 핵심이었음.
댓글 0
첫 댓글 달아줘.