Skip to content

전화대장군

2024년 10월부터 11월까지 K-디지털 트레이닝 해커톤에 참여해서 노년층 보이스피싱 피해 방지 서비스 '전화대장군'을 개발했다. 6인 팀으로 진행한 프로젝트에서 백엔드와 DevOps를 담당하며 외부 API 연동과 비동기 처리에 대한 실질적인 경험을 쌓을 수 있었다. 특히 이상과 현실 사이에서 기술 선택을 해야 하는 상황들이 많았는데, 그 과정에서 배운 것들을 정리해보려 한다.

전화대장군 해커톤 전시월

문제 상황과 서비스 개요

우리가 해결하려던 문제는 명확했다. 2023년 기준 보이스피싱 피해액 1,965억 원 중 704억 원이 노년층 피해로, 전체의 36%를 차지하고 있었다. 실시간으로 통화 내용을 분석해서 보이스피싱 패턴을 탐지하고, 사용자와 보호자에게 경고를 보내는 서비스를 기획했다.

기술적으로는 음성을 텍스트로 변환하고, AI 모델로 분석해서 위험도를 수치화한 뒤 문자와 알림으로 경고를 전송하는 흐름이었다. 해커톤 특성상 빠른 프로토타입 개발이 필요해서 인증 기능은 제외하고, Redis와 TTL을 활용해 임시 사용자 정보를 관리하는 방식으로 구현했다.

초기 기획한 백엔드 흐름도는 다음과 같다.

비즈니스 로직 흐름도

외부 API 연동과 응답 지연 문제

처음에는 Spring OpenFeign을 사용해서 외부 API 호출을 구현했다. OpenFeign은 인터페이스 기반으로 API 호출을 간소화할 수 있어서 코드 가독성이 좋았다. 하지만 실제 테스트를 해보니 심각한 지연 문제가 발생했다.

10초 분량의 음성 데이터를 OpenAI Whisper로 전달해서 응답을 받는 데 7.4초, 이후 GPT-4o 모델에서 분석하는 데 추가로 1.2초가 소요되어 총 8초 이상의 응답 지연이 발생했다. 실시간성을 요구하는 서비스에서는 용납할 수 없는 수준이었다.

java
// 초기 동기 처리 방식의 문제점
@Service
public class VoiceAnalysisService {

    @Autowired
    private WhisperFeignClient whisperFeignClient;

    @Autowired
    private GptFeignClient gptFeignClient;

    public AnalysisResult analyzeVoice(MultipartFile audioData) {
        // 7.4초 소요
        String transcript = whisperFeignClient.convertSpeechToText(authHeader, audioData, model).text();

        // 1.2초 소요
        Integer percent = gptFeignClient.analyzeText(authHeader, requestBody).getPercent();

        // 총 8.6초 후에야 결과 반환
        return new AnalysisResult(transcript, percent);
    }
}

사용자가 통화 중에 8초를 기다리는 것은 현실적으로 불가능했다. 무엇보다 보이스피싱은 실시간으로 진행되는 상황이라 분석 결과가 늦게 나오면 의미가 없었다.

비동기 처리로 전환

이 문제를 해결하기 위해 Spring의 @Async 어노테이션과 CompletableFuture를 활용해서 외부 API 호출을 비동기로 변경했다. 핵심 아이디어는 사용자에게는 즉시 응답을 주고, 백그라운드에서 분석을 진행한 뒤 위험이 감지되면 별도로 알림을 보내는 것이었다.

java
// 실제 구현한 비동기 음성 분석 서비스
@Service
@RequiredArgsConstructor
public class VoicePhishingAnalysisService {

    private final OpenAiService openAiService;
    private final FcmService fcmService;
    private final SmsService smsService;

    public void analysisVoicePhishing(String uuid, MultipartFile voiceFile) {
        Elder elder = (Elder) redisTemplate.opsForValue().get(uuid);

        openAiService.convertSpeechToTextAsync(voiceFile)
                .thenCompose(text -> openAiService.analyzeTextAsync(text)
                        .thenApply(percent -> Map.of("text", text, "percent", percent)))
                .thenAccept(result -> {
                    String text = (String) result.get("text");
                    int percent = (Integer) result.get("percent");

                    if (percent >= 80 && !elder.isSendMessageAt80Percent()) {
                        fcmService.sendFcmToSelfAsync(elder.getFcmToken(), "[보이스피싱 주의]",
                                "현재 통화가 보이스피싱으로 의심됩니다.");
                    } else if (percent >= 90 && !elder.isSendMessageAt90Percent()) {
                        fcmService.sendFcmToSelfAsync(elder.getFcmToken(), "[보이스피싱 경고]",
                                "현재 통화가 보이스피싱일 가능성이 매우 높습니다!");
                        smsService.sendSmsToGuardianNumbersAsync(elder.getNickname(),
                                elder.getGuardianNumbers(), text);
                    }
                })
                .exceptionally(throwable -> {
                    discordNotificationService.sendExceptionMessageAsync(
                            getOpenAiExceptionMessage(throwable));
                    return null;
                });
    }
}

비동기 전환의 핵심은 thenComposethenAccept, exceptionally였다. thenCompose로 Whisper API 결과를 받아 GPT API로 연결하고, thenAccept로 분석 결과를 받아서 위험도가 80% 이상이면 FCM 알림, 90% 이상이면 추가로 보호자 SMS를 전송했다. exceptionally로 API 호출 실패 시에도 Discord로 오류 알림을 보내도록 해서 외부 API 장애가 서비스 전체를 마비시키지 않도록 방어 로직을 구현했다.

결과적으로 사용자는 즉시 응답을 받고, 실제 위험이 감지되면 1-2분 내에 FCM 푸시 알림과 보호자 SMS를 받는 구조로 개선되었다. 사용자 경험 측면에서는 훨씬 자연스러워졌다.

해커톤 시퀀스 다이어그램

이상과 현실 사이의 기술 선택

개발 초기에는 비용 절감을 위해 오픈소스 모델을 직접 구축하려고 시도했다. AWS t2.micro 인스턴스에 Whisper Base 모델과 LLaMA 3-8B 모델을 설치해서 성능 테스트를 진행했는데, 결과는 참담했다.

Whisper Base 모델 테스트 결과

  • 10초 음성 데이터 100회 테스트
  • 평균 응답 시간: 27.4초
  • 평균 CER(Character Error Rate): 52.6%

LLaMA 3-8B 모델 테스트 결과

  • 간단한 텍스트 분석에 10분 이상 소요
  • t2.micro 스펙으로는 실용성 없음

이론적으로는 자체 모델을 구축하는 것이 장기적으로 비용 효율적이지만, 현실적으로는 서비스 요구사항을 충족할 수 없었다. 특히 해커톤이라는 제한된 시간 안에 프로토타입을 완성해야 하는 상황에서는 더욱 그랬다.

결국 OpenAI Whisper API와 GPT-4o 모델을 선택했다. 비용은 더 들지만 성능과 안정성을 보장할 수 있었고, 무엇보다 개발 시간을 단축할 수 있었다. 이 경험을 통해 기술 선택에서 이상과 현실 사이의 균형점을 찾는 것이 얼마나 중요한지 깨달았다.

모델 비교 결과

Redis와 TTL을 활용한 상태 관리

해커톤 특성상 복잡한 인증 시스템을 구축할 시간이 없어서, UUID를 생성해 Redis 키로 활용하는 방식을 선택했다. 사용자가 음성 분석을 요청하기 전에 어르신 정보를 등록하면 UUID를 생성해서 어르신 정보와 함께 Redis에 저장하고, 이후 모든 분석 요청에서 이 키를 사용해서 상태를 관리했다.

java
// 실제 구현한 어르신 정보 관리 서비스
@Service
@RequiredArgsConstructor
public class ElderService {

    private final RedisTemplate<String, Object> redisTemplate;

    public String saveElderInformationFromRedis(
            String nickname,
            String fcmToken,
            Collection<String> guardianNumbers
    ) {
        String uuid = UUID.randomUUID().toString();

        redisTemplate.opsForValue().set(uuid,
                new Elder(nickname, fcmToken, guardianNumbers, false, false),
                1, TimeUnit.DAYS);  // 1일 TTL

        return uuid;
    }
}

TTL을 1일로 설정해서 하루가 지나면 자동으로 정리되도록 했다. 이렇게 하면 별도의 정리 로직 없이도 메모리 관리가 가능했고, 임시 데이터의 특성에도 잘 맞았다. RDBMS를 사용했다면 테이블 설계부터 시작해서 개발 시간이 훨씬 오래 걸렸을 것이다.

동기와 비동기의 조화

이 프로젝트에서 가장 까다로웠던 부분은 동기 작업과 비동기 작업을 조화롭게 처리하는 것이었다. Redis에 세션 정보를 저장하고 조회하는 것은 동기로 처리해야 데이터 일관성을 보장할 수 있었고, 외부 API 호출은 비동기로 처리해야 응답 지연을 해결할 수 있었다.

java
@RestController
public class VoiceAnalysisController {

    @PostMapping("/analyze")
    public ResponseEntity<AnalysisResponse> analyzeVoice(
            @RequestParam String sessionId,
            @RequestBody AudioData audioData) {

        // 동기: 세션 검증
        Optional<UserInfo> userInfo = sessionService.getSession(sessionId);
        if (userInfo.isEmpty()) {
            return ResponseEntity.badRequest().build();
        }

        // 동기: 알림 상태 초기화
        redisTemplate.opsForValue().set(
            "notification:" + sessionId,
            "PROCESSING",
            Duration.ofMinutes(30)
        );

        // 비동기: 음성 분석 시작
        asyncVoiceAnalysisService.analyzeVoiceAsync(sessionId, audioData);

        // 즉시 응답
        return ResponseEntity.ok(new AnalysisResponse("분석을 시작합니다", sessionId));
    }
}

핵심은 사용자에게 영향을 주는 부분은 동기로 빠르게 처리하고, 시간이 오래 걸리는 작업은 비동기로 백그라운드에서 처리하는 것이었다. 이렇게 하면 사용자는 즉시 피드백을 받을 수 있고, 시스템은 안정적으로 동작할 수 있었다.

예외 처리와 장애 대응

외부 API에 의존하는 서비스에서는 예외 처리가 특히 중요했다. OpenAI API가 일시적으로 장애가 나거나 응답이 지연되는 경우에도 사용자에게는 최소한의 안내를 제공해야 했다.

java
.exceptionally(throwable -> {
    log.error("음성 분석 중 오류 발생: {}", throwable.getMessage());

    // 알림 상태 업데이트
    redisTemplate.opsForValue().set(
        "notification:" + sessionId,
        "ERROR",
        Duration.ofMinutes(30)
    );

    // 기본 안전 알림 전송
    notificationService.sendSafetyAlert(sessionId,
        "현재 분석 서비스에 문제가 있습니다. 의심스러운 통화는 즉시 끊어주세요.");

    return null;
});

완벽한 분석은 불가능하더라도 기본적인 안전 수칙은 전달하자는 접근이었다. 실제로 해커톤 발표 당일에 음성 출력 문제가 발생했는데, 이런 예외 처리 덕분에 서비스가 완전히 멈추지는 않았다.

팀 협업과 역할 분담

6인 팀에서 백엔드와 DevOps를 담당하면서 느낀 것은 명확한 역할 분담과 소통의 중요성이었다. 특히 제한된 시간 안에 결과물을 만들어야 하는 해커톤에서는 더욱 그랬다.

프론트엔드 팀과 API 스펙을 정의할 때 비동기 처리 방식을 설명하는 것이 쉽지 않았다. "분석 시작 API는 즉시 응답하고, 결과는 별도 알림으로 받는다"는 개념을 이해시키는 데 시간이 걸렸다. 하지만 이런 과정을 통해 팀 전체가 서비스 구조를 제대로 이해할 수 있었고, 발표 때도 일관된 설명이 가능했다.

Docker와 GitHub Actions를 활용한 CI/CD 파이프라인 구축도 팀 협업에 큰 도움이 되었다. 각자 개발한 기능을 빠르게 통합하고 테스트할 수 있어서, 제한된 시간을 효율적으로 활용할 수 있었다.

결과와 배운 점

최종적으로 최우수상을 수상할 수 있었다. 하지만 상보다 더 중요한 것은 과정에서 배운 것들이었다.

전화대장군 수상

첫째, 기술 선택에서 이상과 현실의 균형점을 찾는 것이 중요하다는 점이다. 오픈소스 모델이 비용면에서는 유리하지만, 서비스 요구사항과 개발 일정을 고려하면 상용 API가 더 현실적인 선택일 수 있다.

둘째, 비동기 처리는 단순히 성능 개선 도구가 아니라 사용자 경험을 근본적으로 바꾸는 설계 패턴이라는 것이다. 동기적 사고에서 벗어나 사용자 관점에서 서비스 흐름을 재설계하는 것이 핵심이었다.

셋째, 외부 API에 의존하는 서비스에서는 예외 처리와 장애 대응이 기능 구현만큼 중요하다는 점이다. 완벽한 서비스보다는 문제 상황에서도 최소한의 가치를 제공하는 서비스가 실제로는 더 유용하다.

이런 경험들이 있었기에 나중에 Local Up에서 외부 API를 연동할 때도 비슷한 문제들을 미리 예상하고 대비할 수 있었다. 해커톤은 끝났지만, 그때 배운 것들은 지금도 개발할 때 기준점이 되고 있다.

아쉬운 점과 개선 방향

지금 돌아보면 아쉬운 부분들도 있다. 비동기 처리를 위해 @Async를 사용했는데, 운영 환경에서는 스레드 풀 설정이나 예외 전파 방식 등을 더 세밀하게 고려해야 했을 것이다. 또한 Redis를 단순 캐시로만 사용했는데, pub/sub 패턴을 활용하면 실시간 알림을 더 효율적으로 구현할 수 있었을 것 같다.

모니터링과 로깅도 부족했다. 외부 API 호출 성공률이나 응답 시간 같은 지표들을 추적했다면, 성능 최적화나 장애 대응에 도움이 되었을 것이다.

하지만 이런 아쉬움들조차 다음 프로젝트를 위한 학습 포인트가 되었다. 완벽한 코드보다는 동작하는 서비스를 만들고, 그 과정에서 배우는 것이 해커톤의 진짜 가치라고 생각한다.

해커톤 회의 중

이 프로젝트는 개인적으로도 의미 있는 경험이었지만, K-디지털 트레이닝 과정 전체에서도 주목받는 성과였다. 실제로 카카오테크 부트캠프 블로그에서 수상팀들의 해커톤 경험을 다룬 글에도 우리 팀 '음성감독원'의 이야기가 소개되었다. 이론 학습만으로는 얻기 어려운 실무 감각을 기르는 데 해커톤이 얼마나 효과적인지 다시 한 번 확인할 수 있었다.

팀: 음성감독원

박원준

박원준

팀장

김준호

김준호

팀원

박소연

박소연

팀원

이강윤

이강윤

팀원

전은주

전은주

팀원

조태현

조태현

팀원

Released under the MIT License.