본문으로 바로가기
KIM JUNHA Playful Logo Symbol

Smart Messaging System : 마케터를 위한 AI 통합 메시징 서비스

마케터가 고객을 선택하고 메시지를 작성·검토·발송·분석하는 통합 메시징 시스템에서 고객 조회, Redis Draft 기반 수신자 관리, 메시지 링크 추적을 개선한 팀 프로젝트입니다.

플랫폼 · 담당 역할 · 풀스택
작성자 김준하
작성일수정일
Smart Messaging System 고객 관리 화면과 Network 패널

프로젝트 개요

Smart Messaging System은 마케터가 고객을 선택하고, 메시지를 작성·검토한 뒤 여러 채널로 발송하고 결과를 분석할 수 있도록 구성한 통합 메시징 시스템입니다.

  • 기간: 2026.07
  • 구분: 팀 프로젝트
  • 담당 범위: 고객 조회 성능 개선, Redis 기반 수신자 관리, 메시지 링크 추적·수신거부·구매 전환, 발송·통계 UI와 테스트 보강
  • 기술 스택: Java, Spring Boot, Thymeleaf, Oracle, MyBatis, Redis
  • 주요 성과:
    • 고객 목록 API 요청 시간 1.99초 → 265ms (86.7% 감소), 전체 로드 시간 2.64초 → 564ms (78.6% 감소)
    • 고객 정보 요청 8.46초 → 350ms (95.9% 감소), 응답 크기 268KB → 0.6KB (99.8% 감소)
    • 메시지 이력 요청 5.03초 → 315ms (93.7% 감소), 상세 화면 전체 네트워크 완료 시간 11.02초 → 2.54초 (77.0% 감소)
    • Redis Draft에 선택 상태를 30분 동안 보관하고, 1,000개 단위 청크와 pipeline으로 대량 선택 요청 처리

담당 기능

  • 고객 조회: 고객 목록·상세 조회를 분리하고, 페이지네이션·태그 일괄 조회·메시지 이력 조회를 구현했습니다.
  • 수신자 관리: 여러 검색 결과에서 누적 선택한 수신자를 Redis Draft에 저장하고, TTL과 Selected 탭 페이징을 관리했습니다.
  • 메시지 링크와 이벤트 추적: 발송 대상별 단축 URL을 만들고 클릭·구매 전환·수신거부를 서로 다른 목적의 이벤트로 기록했습니다.
  • 멀티 채널 입력 통합과 폴백 처리: Kakao·SMS/LMS·Email의 공통 입력 구조를 채널별 발송 형식으로 매핑하고, 1차 채널이 실패하면 다음 채널로 전환하는 폴백 흐름을 구현했습니다.

개발 기록

1. 고객 목록 조회 및 고객 상세 조회 성능 개선

문제 상황

초기에는 코드 재사용을 위해 고객 목록 API를 상세 모달에서도 사용했습니다. 상세 화면을 열 때 size=1000으로 전체 고객 목록을 다시 조회했고, 목록 쿼리에도 상세 화면에서만 필요한 최근 발송일과 태그 조회가 함께 포함되어 있었습니다.

고객 목록 조회 개선 전 · 페이지 목록 요청과 Network 패널
고객 상세 조회 개선 전 · 전체 목록 API를 다시 호출하는 Network 패널

해결 과정

조회 목적에 따라 목록과 상세를 분리했습니다. 목록은 현재 페이지에 필요한 고객 정보만 조회하도록 바꾸고, 목록 쿼리에서 상세 전용 lastSend 서브쿼리를 제거했습니다. count 쿼리에서도 건수 계산에 필요하지 않은 채널 동의 집계 조인을 제외했습니다.

문제 코드

개선 전 src/main/resources/mappers/CustomerMapper.xml에서는 CustomerResultMap 안에 고객의 태그 목록을 채우기 위한 중첩 조회가 있었습니다.

<resultMap id="CustomerResultMap" type="CustomerResponseDTO">
    <id property="customerId" column="customerId"/>
    <result property="name" column="name"/>
    <!-- ... 기타 고객 컬럼 생략 ... -->

    <!-- 조회된 고객 행마다 selectCustomerTags가 반복 실행됨 -->
    <collection property="tags" column="customerId" select="selectCustomerTags"/>
</resultMap>

collection은 각 고객의 customerIdselectCustomerTags에 전달해 태그 목록을 가져옵니다. 따라서 고객 목록을 조회하면 각 고객마다 쿼리가 실행되는 N+1 문제가 발생했습니다.

개선 방식

현재는 페이지에 표시할 고객 ID를 먼저 모은 뒤, 태그를 IN (101, 102, 103, ...) 조건으로 한 번에 조회합니다. 조회한 태그는 서비스 계층에서 customerId 기준으로 그룹화해 각 고객 객체에 연결했습니다. 따라서 목록의 고객 수가 늘어나도 태그 조회는 페이지당 한 번만 실행되어 불필요한 쿼리와 DB 왕복을 줄일 수 있었습니다.

상세 모달에는 GET /api/customers/{customerId} 단일 고객 API를 추가했습니다. 프론트엔드는 고객 정보와 메시지 이력을 Promise.all로 병렬 요청하고, 이력 조회는 최근 90일·최대 40건으로 제한했습니다. 이력에 포함되던 실패 사유 서브쿼리도 제거해 상세 화면에 필요한 범위만 조회하도록 했습니다.

Promise.all([
  fetch(`/api/customers/${customerId}`).then((res) => res.json()),
  fetch(`/api/customers/${customerId}/history`).then((res) => res.json()),
]);
고객 목록 조회 개선 후 · 페이지 단위 조회로 변경된 화면
고객 상세 조회 개선 후 · 고객 정보와 메시지 이력을 필요한 범위로 조회한 화면

결과

목록 API는 약 1.99초에서 265ms로 86.7%, 전체 로드 시간은 2.64초에서 564ms로 78.6% 줄었습니다. MyBatis 태그 조회도 고객별 추가 조회에서 현재 페이지 기준 일괄 조회로 바뀌어 N+1 쿼리 발생 가능성을 제거했습니다. 상세 화면에서는 고객 정보 요청이 8.46초·268KB에서 350ms·0.6KB로 줄었고, 메시지 이력 요청은 5.03초에서 315ms로 감소했습니다.

2. Redis Draft 기반 대량 수신자 처리

Redis Draft 적재 과정 · 선택한 고객 ID가 Redis에 저장되는 화면

문제 상황

수신자는 여러 검색 조건과 페이지를 오가며 누적 선택할 수 있어야 했습니다.
초기에는 선택한 회원 ID 전체를 localStorage에 저장했지만, 대량 데이터를 직렬화·저장·조회하는 동안 브라우저 메인 스레드를 점유하면서 속도 저하를 느꼈습니다. 또한 고객 식별자 목록을 클라이언트 저장소에 보관하는 보안 부담도 있었습니다. 그렇다고 발송 여부가 확정되지 않은 임시 목록을 DB에 저장하면 불필요한 Insert·Delete와 데이터 정리 문제가 발생할 수 있었습니다.

해결 과정

브라우저에는 전체 고객 ID 대신 Redis Draft를 식별하는 draftId만 두고, 사용자 선택 상태를 Redis에 임시 저장했습니다. Redis 자료구조로 Sorted Set을 선택한 이유는 선택 순서를 score로 저장하면서 고객 ID를 중복 없이 관리하고, 정렬된 목록을 범위 단위로 조회할 수 있기 때문입니다. 신규 선택 고객에는 순차적인 score를 부여하고, 데이터 적재 상태는 별도의 Hash 키로 관리했으며 두 키에 30분 TTL을 적용했습니다.

Selected 탭은 Sorted Set의 ZRANGE로 현재 페이지에 필요한 고객 ID만 조회한 뒤, 해당 ID의 상세 정보만 Oracle에서 가져오도록 구성했습니다. 이를 통해 검색 조건이 바뀌어도 누적 선택 순서를 유지하면서 페이지 단위로 목록을 조회해 성능을 확보했고, 전체 선택 수는 Redis ZCARD로 계산해 페이지 수가 어긋나지 않도록 했습니다.

선택한 고객 ID는 1,000개 단위로 나누어 Redis Sorted Set에 저장했습니다. 개별 저장 방식에서는 각 명령마다 명령 전송 → 응답 대기가 반복되어 고객 수가 많아질수록 네트워크 왕복이 누적됩니다. 이를 줄이기 위해 Spring Data Redis의 executePipelined로 여러 Redis 저장 명령을 묶어 전송했습니다.

결과

선택 상태·순서·중복 제거·페이지 조회·만료를 Redis에서 처리하면서 브라우저와 DB의 부담을 줄이고 빠르게 데이터를 주고 받을 수 있었습니다. 대량 선택 데이터를 청크 단위로 처리해 Redis 요청 크기와 임시 데이터 관리 부담을 제한할 수 있었습니다.

3. 메시지 링크로 클릭·구매·수신거부 이벤트 추적

문제 상황

메시지에 원본 URL만 넣으면 링크가 눌렸는지, 어떤 발송 대상이 반응했는지 확인하기 어려웠습니다. 특히 어떤 발송 대상이 메시지를 클릭하고 구매까지 이어졌는지 설명할 수 없어, 구매 안내나 광고 메시지의 캠페인 결과를 분석하는 데 한계가 있었습니다.

또한 원본 URL에 대상 식별을 위한 코드를 추가하면 URL이 길어져 채널별 메시지 길이 제한과 발송 부담을 키울 수 있었습니다.

해결 과정

메시지 길이를 줄이기 위해 원본 URL 대신 발송 대상별 단축 URL을 발급해 DB에서 관리했습니다. 링크 진입·완료 요청을 처리하는 API에서 클릭·수신거부·구매 전환 상태를 갱신해 메시지 대상의 사후 행동을 추적할 수 있도록 구성했습니다.

결과

메시지 발송 결과를 성공·실패로만 판단하지 않고, 발송 대상별 클릭과 구매 전환까지 확인할 수 있게 되었습니다. 링크 목적을 분리했기 때문에 일반 링크와 구매 링크, 수신거부 링크가 서로 다른 통계로 섞이지 않습니다.

4. 멀티 채널 입력 통합과 폴백 처리

문제 상황

Kakao, SMS/LMS, Email은 필요한 입력과 발송 방식이 서로 달랐습니다. 채널마다 별도 구조로 처리하면 화면 입력과 발송 로직이 복잡해지고, 한 채널이 실패했을 때 다음 채널로 전환하는 기준도 필요했습니다.

해결 과정

화면의 제목·본문·버튼명·링크·발송 목적을 하나의 MessageTaskDto 구조로 묶고, 라우터가 선택된 채널에 맞게 서비스 호출 인자로 변환했습니다. 수신자별 채널 동의 상태를 확인해 우선순위에 맞춰 폴백 처리하고, 각 시도 결과는 send_attempt에 저장했습니다.

같은 DTO를 그대로 전달하지 않고, 채널 서비스가 요구하는 인자와 형식에 맞게 매핑했습니다.

switch (currentChannel.toUpperCase()) {
    case "KAKAO":
        return kakaoMessageService.sendFeedMessageToAll(
                task.getKakaoAccessToken(), personalizedTitle, personalizedContent,
                task.getActionButtonName(), task.getActionUrl());
    case "SMS":
    case "LMS":
        return smsMessageService.sendTextMessage(
                task.getPhoneNumber(), personalizedTitle, personalizedContent,
                resolvedMessageType, task.getPurpose(), task.getActionButtonName(),
                task.getActionUrl(), task.getUnsubscribeUrl());
    case "EMAIL":
        return emailMessageService.sendEmail(
                task.getEmail(), personalizedTitle, personalizedContent,
                task.getActionButtonName(), task.getActionUrl());
}

Kakao에는 버튼과 단축 URL을, SMS/LMS에는 문자 유형과 수신거부 URL을, Email에는 제목·본문·링크를 각각 맞춰 전달했습니다.

결과

화면 입력은 하나의 구조로 유지하면서도 채널별 발송 방식은 분리했고, 실패 시 다음 우선순위 채널로 자동 전환되는 폴백 흐름을 구현했습니다.