
FinSight에 관리자용 SMS 관리 기능을 추가하면서 처음에는 다음과 같은 흐름을 중심으로 구현했습니다.
기능 자체는 정상적으로 동작했습니다.
하지만 테스트 범위를 넓혀가면서 단순한 정상 케이스만으로는 확인되지 않던 문제가 하나씩 나타났습니다.
예를 들어 다음과 같은 질문을 해볼 수 있습니다.
발송 상태와 발송 목적을 동시에 선택하면 어떻게 될까요?
지원하지 않는 메시지 타입이 들어오면 어떻게 될까요?
OTP가 발송 로그에 남아도 괜찮을까요?
네트워크 오류가 발생하면 관리자 화면의 로딩 상태는 어떻게 될까요?
이러한 경우의 수를 하나씩 테스트하면서 기능 구현 단계에서는 잘 보이지 않던 운영 안정성, 보안, UX 문제를 보완했습니다.
SMS 발송 이력에는 여러 검색 조건이 존재합니다.
예를 들어 관리자가 다음과 같이 조회할 수 있습니다.
status = FAILED
purpose = OTP
의도는 명확합니다.
실패한 SMS 중에서 OTP 발송만 조회합니다.
처음 구현은 다음과 비슷한 형태였습니다.
if (status != null) {
result = smsSendLogJpaRepository
.findByStatusOrderByCreatedAtDescIdDesc(status, pageable);
} else if (purpose != null) {
result = smsSendLogJpaRepository
.findByPurposeOrderByCreatedAtDescIdDesc(purpose, pageable);
} else {
// 전체 조회
}
코드만 보면 큰 문제가 없어 보입니다.
하지만 status와 purpose가 동시에 들어오면 첫 번째 조건만 실행됩니다.
즉 다음과 같이 요청해도,
status=FAILED
purpose=OTP
실제 결과는 다음과 같이 나올 수 있습니다.
FAILED 전체
더 문제인 점은 오류가 발생하는 것이 아니라는 점입니다.
정상적으로 조회된 것처럼 보이지만 실제로는 조건이 잘못 적용된 데이터를 보여주게 됩니다.
복합 조건 전용 Repository 메서드를 추가했습니다.
Page<SmsSendLogJpaEntity>
findByStatusAndPurposeOrderByCreatedAtDescIdDesc(
SmsSendStatus status,
SmsPurpose purpose,
Pageable pageable
);
서비스에서도 조건을 명확하게 분리했습니다.
if (status != null && purpose != null) {
result = smsSendLogJpaRepository
.findByStatusAndPurposeOrderByCreatedAtDescIdDesc(
status,
purpose,
pageable
);
} else if (status != null) {
result = smsSendLogJpaRepository
.findByStatusOrderByCreatedAtDescIdDesc(
status,
pageable
);
} else if (purpose != null) {
result = smsSendLogJpaRepository
.findByPurposeOrderByCreatedAtDescIdDesc(
purpose,
pageable
);
} else {
// 전체 조회
}
검색 조건이 여러 개 존재한다면 각각의 단일 조건만 테스트해서는 부족합니다.
최소한 다음과 같은 조합은 확인하는 것이 좋습니다.
status만 존재
purpose만 존재
status + purpose
둘 다 없음
특히 관리자 시스템에서는 조회 결과가 장애 분석이나 감사 자료로 활용될 수 있기 때문에 단순히 “조회된다”보다 “정확한 조건으로 조회된다”가 더 중요합니다.
메시지 타입은 다음과 같이 정의되어 있었습니다.
SMS
LMS
MMS
KAKAO_ALIMTALK
KAKAO_FRIENDTALK
수동 발송 로직을 테스트하면서 KAKAO_ALIMTALK 값을 직접 전달해보았습니다.
처음 구현은 다음과 같은 구조였습니다.
MessageSendResult result = switch (type) {
case LMS ->
solapiMessageService.sendLms(...);
case MMS ->
solapiMessageService.sendMms(...);
default ->
solapiMessageService.sendSms(
phone,
request.message(),
from
);
};
여기에서 문제가 되는 부분은 default입니다.
SMS가 아니고 LMS도 아니며 MMS도 아닌 타입이 들어오면 모두 SMS 발송 로직으로 떨어집니다.
결과적으로 다음과 같은 상태가 발생할 수 있습니다.
요청 타입 = KAKAO_ALIMTALK
실제 발송 = SMS
로그 타입 = KAKAO_ALIMTALK
시스템에는 알림톡으로 기록되지만 실제 사용자는 SMS를 받는 상황입니다.
지원하지 않는 타입은 명시적으로 실패 처리했습니다.
MessageSendResult result = switch (type) {
case LMS ->
solapiMessageService.sendLms(...);
case MMS ->
solapiMessageService.sendMms(...);
case KAKAO_ALIMTALK,
KAKAO_FRIENDTALK ->
MessageSendResult.failure(
"카카오 메시지 수동 발송은 지원하지 않습니다.",
"UNSUPPORTED_MESSAGE_TYPE"
);
case SMS ->
solapiMessageService.sendSms(
phone,
request.message(),
from
);
};
또한 default를 제거했습니다.
이렇게 하면 이후 새로운 MessageType이 추가되었을 때 처리되지 않은 타입을 컴파일 단계에서 발견하기 쉬워집니다.
SMS → SMS 발송
LMS → LMS 발송
MMS → MMS 발송
KAKAO_ALIMTALK → 명시적 실패
KAKAO_FRIENDTALK → 명시적 실패
외부 서비스 연동에서는 암묵적인 fallback이 생각보다 위험할 수 있습니다.
지원하지 않는 타입이라면 비슷한 기능으로 대체하는 것보다 명확하게 실패시키는 편이 안전합니다.
SMS 발송 결과를 추적하기 위해 발송 내용을 contentPreview 형태로 저장하고 있었습니다.
일반 알림이라면 큰 문제가 없습니다.
예를 들면 다음과 같은 내용입니다.
주문이 완료되었습니다.
새로운 알림이 도착했습니다.
서비스 점검이 예정되어 있습니다.
하지만 OTP 시나리오를 테스트하면서 다른 문제가 발생했습니다.
OTP 역시 동일한 저장 로직을 사용하기 때문에 DB에 다음과 같은 값이 저장될 수 있었습니다.
인증번호는 483921입니다.
그리고 관리자 발송 이력 API에서도 해당 값이 다시 노출될 수 있었습니다.
OTP는 단순한 메시지가 아니라 일회용 인증정보입니다.
운영자가 실제 OTP 값을 확인해야 할 이유도 없습니다.
저장 단계에서부터 OTP 내용을 제거했습니다.
String preview =
purpose == SmsPurpose.OTP
? "[REDACTED]"
: content == null
? null
: content.length() > 480
? content.substring(0, 480) + "…"
: content;
OTP라면 DB에는 다음 값만 저장합니다.
[REDACTED]
그리고 조회 단계에서도 한 번 더 방어했습니다.
String contentPreview =
entity.getPurpose() == SmsPurpose.OTP
? "[REDACTED]"
: entity.getContentPreview();
두 단계에서 처리한 이유는 다음과 같습니다.
새로운 OTP 값이 DB에 저장되지 않도록 합니다.
과거에 이미 OTP 데이터가 저장되어 있더라도 API를 통해 다시 노출되지 않도록 합니다.
즉 다음과 같은 구조입니다.
쓰기 단계 차단
+
읽기 단계 차단
purpose = OTP
→ contentPreview = [REDACTED]
purpose = NOTIFICATION
→ 원래 메시지 preview 유지
기존 OTP 데이터가 DB에 존재
→ API 응답에서는 [REDACTED]
민감정보를 다룰 때는 “누가 볼 수 있는가?”보다 먼저 “애초에 저장할 필요가 있는가?”를 생각하는 것이 중요하다고 느꼈습니다.
개발 과정에서 로그는 매우 유용합니다.
log.info(
"SMS 발송 - phone={}, email={}",
phone,
email
);
문제가 발생했을 때 누구에게 발송하려 했는지 바로 확인할 수 있기 때문입니다.
하지만 운영 환경에서는 상황이 달라집니다.
로그는 다음과 같은 여러 시스템으로 복제될 수 있습니다.
서버 로그
CloudWatch
ELK
파일 백업
장애 분석 시스템
따라서 전화번호와 이메일을 그대로 기록하는 것은 필요 이상의 개인정보 저장이 될 수 있습니다.
예를 들어 다음과 같은 전화번호가 있다면,
01012345678
로그에는 다음 정도만 남기도록 했습니다.
****5678
private String maskPhoneNumber(String phone) {
if (phone == null || phone.isBlank()) {
return "-";
}
if (phone.length() <= 4) {
return "[REDACTED]";
}
return "****" + phone.substring(phone.length() - 4);
}
developer@example.com
이라면 다음과 같이 표시할 수 있습니다.
d***@example.com
로그에서도 마스킹된 값만 사용하도록 변경했습니다.
log.info(
"관리자 SMS 수동 발송 - actorId={}, toPhone={}, email={}",
currentUser.getId(),
maskPhoneNumber(request.toPhone()),
maskEmail(request.userEmail())
);
로그의 목적은 사용자의 개인정보를 완전히 복원하는 것이 아니라,
어떤 요청이 어떤 사용자 계열에서 발생했는지 추적하는 것
이라고 보았습니다.
따라서 다음 정도의 정보만 있어도 대부분의 운영 상황은 추적할 수 있습니다.
사용자 ID
발송 결과
messageId
오류 코드
마스킹된 수신자
관리자 화면에서 저장 버튼을 누르면 일반적으로 다음과 같은 흐름을 사용합니다.
setSaving(true)
const result = await updateSmsSettings(body)
setSaving(false)
정상적인 상황에서는 문제가 없습니다.
하지만 테스트 중 API 요청 자체를 실패시켜보았습니다.
예를 들면 다음과 같은 경우입니다.
백엔드 서버 종료
프록시 오류
네트워크 단절
JSON parsing exception
await에서 예외가 발생하면 다음 코드까지 실행되지 않습니다.
setSaving(false)
그 결과 화면에는 계속 다음과 같은 상태가 유지될 수 있습니다.
저장 중...
저장 중...
저장 중...
버튼도 계속 비활성화된 상태로 남을 수 있습니다.
비동기 작업의 상태 정리는 finally에서 수행하도록 변경했습니다.
setSaving(true)
setError(null)
setMessage(null)
try {
const result = await updateSmsSettings(body)
if (!result.ok) {
setError(result.message)
return
}
setSettings(result.settings)
setMessage("SMS 발송 설정을 저장했습니다.")
} catch {
setError(
"SMS 발송 설정 저장 중 오류가 발생했습니다."
)
} finally {
setSaving(false)
}
이 패턴을 다음 기능에 동일하게 적용했습니다.
설정 저장
SMS 발송
이미지 업로드
대시보드 조회
finally는 성공 여부와 관계없이 실행됩니다.
성공
실패 응답
return
throw
어떤 경로로 끝나더라도 로딩 상태를 정리할 수 있습니다.
프론트의 loading 상태는 성공 처리라기보다는 작업 종료 후 반드시 정리해야 하는 상태라고 보는 것이 더 자연스럽습니다.
SMS 관리자 대시보드를 열면 여러 API를 동시에 호출합니다.
예를 들면 다음과 같습니다.
설정
통계
잔액
발송 이력
성능을 위해 Promise.all로 동시에 요청합니다.
const [settings, stats, balance, logs] =
await Promise.all([
fetchSmsSettings(),
fetchSmsStats(),
fetchSmsBalance(),
fetchSmsLogs()
])
문제는 부분 실패 상황이었습니다.
예를 들어 다음과 같은 경우입니다.
설정 → 성공
통계 → 성공
잔액 → 실패
로그 → 성공
이 상황에서 화면에 단순히 다음과 같이 표시된다면,
잔액: -
사용자는 정확한 상태를 알 수 없습니다.
실제 잔액이 없는 것인지
아직 충전되지 않은 것인지
API 호출이 실패한 것인지
구분하기 어렵습니다.
부분 성공은 허용하되 실패는 숨기지 않도록 변경했습니다.
try {
const [s, st, b, l] =
await Promise.all([...])
const failures: string[] = []
if (s.ok) {
setSettings(s.settings)
} else {
failures.push(s.message)
}
if (st.ok) {
setStats(st.stats)
} else {
failures.push(st.message)
}
if (b.ok) {
setBalanceText(b.balance.balanceText)
setSimulation(b.balance.simulation)
} else {
failures.push(b.message)
}
if (l.ok) {
setLogs(l.content)
setTotalPages(
Math.max(1, l.totalPages)
)
} else {
failures.push(l.message)
}
if (failures.length > 0) {
setError(failures.join(" "))
}
} catch {
setError(
"SMS 대시보드 정보를 불러오지 못했습니다."
)
} finally {
setLoading(false)
}
첫째, 부분 성공은 그대로 보여줍니다.
잔액 조회가 실패했다고 해서 설정과 발송 이력까지 숨길 필요는 없습니다.
둘째, 실패는 사용자에게 알려줍니다.
-와 API 오류는 의미가 완전히 다릅니다.
셋째, 전체 장애와 부분 장애를 구분합니다.
이렇게 해야 관리자 화면이 단순 조회 화면이 아니라 실제 운영 도구로 활용될 수 있습니다.
JSON.stringify()를 사용했다고 해서 JSON 요청이 되는 것은 아닙니다프론트에서는 다음과 같은 코드를 사용하고 있었습니다.
fetch("/api/v1/admin/sms/settings", {
method: "PUT",
headers: authHeadersJson(),
body: JSON.stringify(body)
})
겉으로 보기에는 정상적인 JSON 요청처럼 보입니다.
하지만 헤더를 확인해보니 다음과 같았습니다.
export function authHeadersJson(): HeadersInit {
const token = readUsableAccessToken()
if (!token) {
return {
Accept: "application/json"
}
}
return {
Accept: "application/json",
Authorization: `Bearer ${token}`
}
}
Content-Type이 없습니다.
여기에서 Accept와 Content-Type은 의미가 다릅니다.
Accept
→ 서버가 어떤 형식으로 응답해주기를 원하는지 지정합니다.
Content-Type
→ 현재 요청 body가 어떤 형식인지 서버에 알려줍니다.
따라서 JSON body를 전달할 때는 다음과 같이 명시적으로 작성했습니다.
headers: {
...authHeadersJson(),
"Content-Type": "application/json"
}
이 값이 빠지면 Spring의 @RequestBody 처리나 프록시 구성에 따라 다음과 같은 문제가 발생할 수 있습니다.
HTTP 415 Unsupported Media Type
또는 body binding에 실패할 수도 있습니다.
JSON.stringify()는 JavaScript 객체를 문자열로 변환할 뿐이며, HTTP 요청의 미디어 타입까지 지정해주는 것은 아닙니다.
처음에는 다음 정도만 확인하기 쉽습니다.
SMS 발송 버튼 클릭
→ SMS 도착
→ 성공
하지만 실제로는 그보다 더 많은 경우를 테스트할 필요가 있었습니다.
| 영역 | 테스트한 경우 |
|---|---|
| 검색 | status만 / purpose만 / 둘 다 / 둘 다 없음 |
| 메시지 타입 | SMS / LMS / MMS / 미지원 Kakao 타입 |
| OTP | 저장 데이터 / API 응답에서 인증번호 노출 여부 |
| 개인정보 | 전화번호·이메일 로그 노출 여부 |
| 비동기 UI | 성공 / 실패 응답 / 네트워크 예외 |
| 대시보드 | 전체 성공 / 일부 실패 / 전체 실패 |
| HTTP | JSON body + 적절한 Content-Type |
처음 구현에서는 정상 흐름이 중심이었습니다.
요청
→ 성공
→ 응답
하지만 테스트 범위를 넓히면서 다음과 같은 흐름도 함께 확인하게 되었습니다.
잘못된 값
부분 성공
외부 API 실패
네트워크 예외
민감정보
복합 조건
지원하지 않는 enum
이 과정을 거치면서 기능의 완성도가 많이 달라졌습니다.
이번 작업에서 가장 크게 느낀 것은 기능 완료의 기준이었습니다.
처음에는 다음과 같이 생각하기 쉽습니다.
SMS가 실제로 발송되면 기능이 완성된 것 아닐까?
하지만 관리자 기능에서는 그 이후가 더 중요했습니다.
정확한 데이터를 보여주는가?
실패한 상황을 구분할 수 있는가?
민감정보를 불필요하게 남기지 않는가?
잘못된 입력을 안전하게 거절하는가?
외부 시스템이 실패해도 UI가 복구되는가?
운영자가 장애 원인을 추적할 수 있는가?
결국 다음과 같은 기준으로 바뀌었습니다.
기능이 동작합니다.
↓
예외 상황에서도 동작합니다.
↓
실패해도 상태가 망가지지 않습니다.
↓
운영에 필요한 정보만 남깁니다.
↓
민감정보는 최소한으로 다룹니다.
단순히 SMS API 하나를 연동하는 작업이었지만, 테스트할 경우의 수를 늘려가면서 운영 가능한 기능이 무엇인지 다시 생각하게 된 작업이었습니다.
이번에 보완한 내용을 한 줄씩 정리하면 다음과 같습니다.
복합 검색 조건 → AND 조건 명확화
지원하지 않는 메시지 → 명시적으로 거절
OTP → 저장·응답에서 REDACTED
전화번호/이메일 → 로그 마스킹
비동기 요청 → try/catch/finally
부분 API 실패 → 숨기지 않고 사용자에게 표시
JSON 요청 → Content-Type 명시
happy path 하나가 통과했다고 해서 기능 테스트가 끝난 것은 아니었습니다.
오히려 다음과 같은 질문을 하나씩 던졌을 때 실제 문제가 많이 드러났습니다.
이 값이 null이면 어떻게 될까?
두 조건을 동시에 보내면 어떻게 될까?
외부 API가 실패하면 어떻게 될까?
지원하지 않는 enum 값이 들어오면 어떻게 될까?
이 데이터가 로그에 남아도 괜찮을까?
앞으로 관리자 기능이나 외부 API 연동을 구현할 때도 정상 시나리오 이후의 경우의 수를 체크리스트화해서 테스트하는 방식을 계속 적용해볼 생각입니다.