📨 사내 메시지 발송 관리 서비스 마이그레이션
레거시 SMS/알림톡 관리자 웹서비스 마이그레이션 및 기능 보강 수기로 운영되던 조직 데이터 관리를 시스템화하고, 멀티 데이터소스 환경에서 발송 이력 조회를 구현한 프로젝트
목차
📋 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 기간 | 2026.03.03 ~ 2026.06.02 (약 3개월) |
| 소속 | 웅진 · 그룹IT혁신팀 (백엔드 인턴) |
| 역할 | Java 백엔드 개발 및 관리자 화면 구현 |
| 대상 | SMS/알림톡 발송 내역을 관리하는 사내 웹서비스 |
핵심 문제 인식
기존 서비스의 일부 코드와 운영 화면, 검증용 데이터베이스를 바탕으로 기능과 데이터 구조를 분석했습니다. 과제의 목표는 기술 스택만 교체하는 것이 아니라 기존 운영 과정의 문제를 찾아 더 유지하기 쉬운 구조로 개선하는 것이었습니다.
- ❌ XML 설정 과다, 뷰 로직과 비즈니스 로직 혼재로 유지보수성 저하
- ❌ 회사/부서/사용자 데이터를 운영자가 SQL로 직접 수기 관리 → 휴먼 에러로 인한 정합성 오류 누적
- ❌ 기존 인증 데이터가 신규 저장 정책과 다른 형식으로 관리됨
- ❌ 발송 이력(Oracle)과 메타데이터(MariaDB)가 분리되어 통합 조회 어려움
- ❌ 신규 발송 이력이 계속 유입되어 offset 페이지 이동 중 조회 기준이 변동
해결 방향
- ✅ Spring Boot + Thymeleaf + JPA/QueryDSL로 마이그레이션 (JSON API 기반 뷰 분리)
- ✅ 웹 UI 기반 조직 데이터 CRUD 시스템화 + 계단식 soft-delete 자동화
- ✅ 기존 인증 데이터를 신규 저장 정책에 맞게 해시 기반으로 이관
- ✅ 멀티 데이터소스 인메모리 조인으로 발송 이력 통합 조회
- ✅ 최초 조회 시점의 앵커를 고정하고 신규 데이터 건수를 별도로 안내
🏗️ 시스템 아키텍처
┌──────────────────────────────────────────────┐
│ Spring Boot Admin (msg_manage) │
│ Thymeleaf(껍데기) + Alpine.js + Axios │
└───────────────┬───────────────────────────────┘
│ JSON API
┌────────┴─────────┐
│ Service / QueryDSL │
└───┬───────────┬────┘
메타데이터 │ │ 발송 이력
┌───────▼────┐ ┌───▼─────────┐
│ MariaDB │ │ Oracle │
│ 회사/부서 │ │ 발송 내역 │
│ 채널/사용자 │ │ │
└────────────┘ └─────────────┘
▲ ▲
└──── LEGACY_ID 로 ─┘
애플리케이션 레벨 IN절 인메모리 조인
- 핵심 설계 원칙
- 뷰-서버 분리: Thymeleaf는 HTML 껍데기, 데이터는 JSON API로 전달 → 추후 CSR 전환 가능
- 메타데이터 중앙화: 저장 트랜잭션 복잡도를 줄이기 위해 메타데이터를 단일 DB에서 관리
- 레거시 호환: 외부 발송 시스템이 참조하는 기존 ID를
LEGACY_ID로 보존
🔧 기술 스택
Backend
| 기술 | 선택 이유 |
|---|---|
| Java + Spring Boot | 프로젝트 표준 기술 스택 |
| Thymeleaf | 기술 제약사항으로 지정, 레이아웃/프래그먼트 역할 |
| JPA + QueryDSL | 기본 CRUD 자동화 + 타입 안전한 동적 쿼리 |
| MariaDB | 조직/사용자 메타데이터 (중앙 관리) |
| Oracle | 기존 SMS 발송 이력 (레거시 운영 DB) |
Frontend
- Alpine.js: DOM 직접 제어 시 늘어나는 보일러플레이트를 선언적 방식으로 축소
- Axios: 인터셉터로 인증 만료/커스텀 예외 처리 중앙화
🎯 담당 역할
1. Thymeleaf 화면과 JSON API의 책임 분리
Thymeleaf는 레이아웃과 화면 골격에 사용하고 목록·상세 데이터는 JSON API로 제공했습니다. Alpine.js로 모달, 선택 상태, 이벤트를 선언적으로 처리해 반복적인 DOM 조작을 줄였고, 추후 클라이언트 렌더링 방식을 도입하더라도 API를 재사용할 수 있도록 화면과 데이터 전달 책임을 분리했습니다.
2. 메타데이터 일원화와 서비스 레벨 조인
기존에는 동일한 메타데이터를 Oracle과 MariaDB에 각각 등록했습니다. 이를 그대로 시스템화하면 두 DB의 부분 실패와 동기화를 관리해야 하므로 등록·수정은 MariaDB로 일원화하고, 발송 이력 조회 시 두 DB의 결과를 서비스 계층에서 연결했습니다.
검색 조건 사용 시,
// 1) MariaDB: 회사·사용자 조건에 맞는 후보 식별자 조회
List<String> candidateIds = userRepository.findLegacyIdsByCondition(condition);
// 2) Oracle: 후보 식별자로 발송 이력 페이징
Page<SendHistory> histories = sendHistoryRepository
.findByLegacyIdIn(candidateIds, pageable);
// 3) MariaDB: 필요한 메타데이터를 Map으로 구성
Map<String, User> userMap = userRepository.findByLegacyIdIn(candidateIds).stream()
.collect(Collectors.toMap(User::getLegacyId, Function.identity()));
// 4) 서비스 계층에서 결합
List<SendHistoryView> result = histories.getContent().stream()
.map(h -> SendHistoryView.of(h, userMap.get(h.getLegacyId())))
.toList();
기본 조회 시,
// 1) Oracle: 발송 이력 조회 후, 등장하는 사용자 식별자만 추출
List<String> legacyIds = histories.stream()
.map(SendHistory::getLegacyId)
.distinct()
.toList();
// 2) MariaDB: IN 절로 한 번에 조회 → Map 변환
Map<String, User> userMap = userRepository.findByLegacyIdIn(legacyIds).stream()
.collect(Collectors.toMap(User::getLegacyId, Function.identity()));
// 3) 메모리에서 매핑
List<SendHistoryView> result = histories.stream()
.map(h -> SendHistoryView.of(h, userMap.get(h.getLegacyId())))
.toList();
3. 조직 데이터 CRUD 시스템화 + 계단식 soft-delete
비활성화는 UPDATE 연산이라 DB FK cascade로 처리할 수 없어, 애플리케이션 레벨에서 상위 → 하위 계단식 비활성화를 자동 처리했습니다 (QueryDSL bulk UPDATE).
4. 앵커 기반 발송 이력 조회
최초 조회 시점의 정렬 기준을 앵커로 저장하고, 같은 조회 흐름에서는 앵커 이전 데이터만 페이지 대상으로 제한했습니다. 앵커 이후 신규 데이터는 별도 count API로 표시하고 사용자가 갱신할 때 현재 시점으로 기준을 다시 설정했습니다.
5. 데이터 마이그레이션 (보안 / 운영 규칙)
신규 테이블 이관 과정에서 기존 인증 데이터를 신규 저장 정책에 맞게 해시 기반으로 변환했습니다. 외부 시스템이 참조하는 식별자는 별도 LEGACY_ID로 유지해 데이터 연결 관계를 보존했습니다.
🔥 핵심 기술 도전
1️⃣ 서로 다른 두 DB를 조인할 수 없을 때
문제: 메타데이터(MariaDB)와 발송 이력(Oracle)이 물리적으로 분리되어 단일 쿼리 조인 불가
해결: 메타데이터를 단일 DB에 중앙화하고, 조회 시 IN절 기반 인메모리 조인 채택. 데이터 규모(회사당 사용자 최대 300건, 전체 1,000건 미만)를 근거로 성능 부담이 허용 가능하다고 판단
2️⃣ 수기 운영 데이터의 정합성 복구
문제: 부서 245건 중 상·하위 상태 불일치 87건과 잘못된 상위 데이터 참조 2건 확인
해결: 웹 기반 CRUD와 계단식 soft-delete를 적용해 직접 SQL 수정으로 발생할 수 있는 입력 오류 가능성을 줄임
3️⃣ 계속 추가되는 발송 이력의 페이지 기준 유지
문제: 최신순 offset 페이징 중 신규 데이터가 들어오면 기존 데이터가 다음 페이지로 밀려 중복되거나 누락된 것처럼 보일 수 있음
해결: 최초 조회 시점의 정렬 기준을 앵커로 고정하고 앵커 이후 데이터는 신규 건수로 별도 표시
4️⃣ 화면과 데이터 전달 책임 분리
문제: Model과 Fragment 중심 데이터 전달 및 바닐라 JavaScript DOM 처리의 결합도와 반복 코드 증가
해결: Thymeleaf는 화면 골격, 데이터는 JSON API, UI 상태와 이벤트는 Alpine.js가 담당하도록 분리
5️⃣ 레거시 호환성과 비밀번호 보안
문제: 외부 시스템이 기존 식별자를 참조하는 상황에서 인증 데이터를 신규 저장 정책에 맞게 이관해야 함
해결: LEGACY_ID로 연결을 유지하고 기존 인증 데이터를 해시 기반으로 이관
📊 주요 성과
- ✅ 레거시 기능을 분석해 화면 골격과 JSON API의 책임을 분리
- ✅ 멀티 DB 발송 이력 통합 조회 구현 (저장 트랜잭션 복잡도 최소화)
- ✅ 부서 245건 중 상태 불일치 87건과 잘못된 상위 데이터 참조 2건 정비
- ✅ 기존 인증 데이터를 신규 저장 정책에 맞게 해시 기반으로 이관
- ✅ 앵커 기반 조회로 신규 데이터 유입 중에도 페이지 조회 기준 유지
💡 배운 점 & 회고
기술적 배움
- 트레이드오프 기반 설계: "인메모리 조인이 좋다"가 아니라 "이 데이터 규모에서는 이게 맞다"를 근거로 설명하는 경험
- 레거시 호환성: 최신 프레임워크가 생성하는 SQL이 구버전 DB에서 항상 통하지 않는다는 점
- soft-delete와 cascade: UPDATE 기반 비활성화는 DB cascade로 처리 불가 → 애플리케이션 레벨 설계 필요
회고 (아쉬웠던 점)
- 권한 설계 시 역할(Role) 중심으로 구현했으나, 기존 시스템은 메뉴별 세부 권한(Permission) 할당 방식이었음. 기존 시스템 분석이 부족했던 점이 회고 포인트이며, Role + Permission 조합 설계가 더 적합했을 것
- SSE 기반 이상 탐지 알림은 일정상 미구현으로 남김
사내 프로젝트 특성상 소스 코드 및 일부 상세 구성은 비공개입니다.