Moya를 활용해서 API 호출해 챗봇 기능 구현하기

오늘은 현재 챗봇 기능을 구현하고 있는 과정에서 실제 현업에서는 이렇게 진행되진 않지만 다이렉트로 GPT API 와 연동해 챗봇 기능을 구현하는 방식에 대해 작성하려고 한다. 일단 기능만 구현해둔 뒤 추후 서버와 연결하여 값을 받는 형태로 진행하기 때문에 클린아키텍처 구조를 활용해 별도의 네트워크 연결 라우터를 두고 해당 라우터를 Moya를 활용해 보다 간편하고 유지보수 및 추후 수정사항이 용이하도록 구조를 짜 구현하게 되었다.

 

먼저 Moya란 무엇인가!!

Moya는 Swift에서 네트워크 요청을 간편하게 관리하기 위해 사용되는 라이브러리이다! Alamofire를 내부적으로 사용하여 HTTP 요청을 수행하지만, TargetType 프로토콜을 통해 API의 각 엔드포인트를 구조적으로 관리할 수 있게 해주는 아주 편리한 라이브러리..

 

API 경로, HTTP 메서드, 파라미터, 헤더 등을 한곳에서 명시적으로 관리할 수 있어 유지보수에 아주 용이하다는 측면이 있어 추후에 변경사항이 발생할 예정이라 Moya를 사용하게 되었다.

 

 

현재 ChatAPI 통신의 구성은 아래 코드와 같다

enum ChatAPI {
    case sendChat(query: ChatQuery)
}

extension ChatAPI: TargetType {
    var baseURL: URL {
        return URL(string: "<https://api.openai.com/v1>")!
    }

    var path: String {
        switch self {
        case .sendChat:
            return "/chat/completions"
        }
    }

    var method: Moya.Method {
        switch self {
        case .sendChat:
            return .post
        }
    }

    var sampleData: Data {
        return Data()
    }

    var task: Task {
        switch self {
        case .sendChat(let query):
            let encoder = JSONEncoder()
            guard let data = try? encoder.encode(query) else {
                return .requestPlain
            }
            return .requestData(data)
        }
    }

    var headers: [String : String]? {
        return [
            "Authorization": "Bearer \\(OpenAIAPIKey)",
            "Content-Type": "application/json"
        ]
    }
}

원래는 직접적으로 통신하는 방식보다 서버를 통해 진행될 예정이지만, 현재는 챗봇 기능 구현 이후 추후 바꿀 예정이기 때문에 이점 참고..

먼저 senChat(query: ChatQuery)를 통해 실제 API 요청 시 필요한 파라미터는 열어형 케이스로 정의 될 수 있도록 했다.

 

기본 베이스로 OpenAI의 도메인을 그대로 사용했고 실제 호출할 엔드포인트를 적용하면 이렇게 간단하게 API 연결이 가능하다. 추후 엔드포인트만 변경해 사용하면 될듯..

struct ChatQuery: Codable {
    let model: String
    var messages: [ChatMessage]
    let temperature: Double?
    let max_tokens: Int?
    let user: String?

    init(
        model: String = "gpt-3.5-turbo",
        messages: [ChatMessage],
        temperature: Double? = 0.7,
        max_tokens: Int? = 500,
        user: String? = nil
    ) {
        self.model = model
        self.messages = messages
        self.temperature = temperature
        self.max_tokens = max_tokens
        self.user = user
    }
}

ChatQuery를 통해 GPT의 모델을 지정하고 실제 대화 메세지와 토큰, 그리고 유저 식별자 등을 넣어 각각 제대로 데이터를 저장할 수 있도록 구현했다.

final class ChatAPIRepository: ChatAPIRepositoryProtocol {
    private let provider: MoyaProvider<ChatAPI>
    private let promptProvider: PromptProvider

    init(provider: MoyaProvider<ChatAPI> = MoyaProvider<ChatAPI>(),
         promptProvider: PromptProvider = DefaultPromptProvider()) {
        self.provider = provider
        self.promptProvider = promptProvider
    }

    func sendChat(query: ChatQuery, for category: Category) async throws -> Message {
        // 1) PromptProvider로부터 system 프롬프트 가져오기
        let systemPrompt = promptProvider.prompt(for: category)

        // 2) 기존 메시지 배열 앞에 system 메시지를 삽입
        var messages = query.messages
        messages.insert(ChatMessage(role: "system", content: systemPrompt), at: 0)

        // 3) 최종 query 구성
        let modifiedQuery = ChatQuery(
            model: query.model,
            messages: messages,
            temperature: query.temperature,
            max_tokens: query.max_tokens,
            user: query.user
        )

        // 4) Moya provider로 요청
        let response = try await withCheckedThrowingContinuation { continuation in
            provider.request(.sendChat(query: modifiedQuery)) { result in
                switch result {
                case .success(let response):
                    continuation.resume(returning: response)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }

        // 5) 응답 데이터 디코딩
        let decoder = JSONDecoder()
        let chatResult = try decoder.decode(ChatResult.self, from: response.data)

        // 6) 첫 번째 choice에서 메시지를 추출
        guard let receivedMessage = chatResult.choices.first?.message else {
            throw NSError(domain: "ChatAPIRepository", code: -1,
                          userInfo: [NSLocalizedDescriptionKey: "응답 메시지가 없습니다."])
        }

        // 7) 최종 Message 모델로 변환 후 반환
        let resultMessage = Message(
            id: UUID(),
            content: receivedMessage.content,
            role: .assistant,
            createAt: Date()
        )
        return resultMessage
    }

    func getRandomInitialMessage(for category: Category) -> String {
        category.initialMessages.randomElement() ?? "안녕하세요, 어떤 이야기든 편하게 말씀해주세요."
    }
}

MoyaProvider 를 활용해 실제 API 호출 로직을 수행할 수 있도록 구현한 뒤 PromptProvider를 통해 시스템 프롬프트를 넣어 응답에 대한 흐름을 제어할 수 있도록 구성했다.

 

여기서 ChatResult를 통해 ChatChoice를 추출해 Message로 반환할 수 있도록 구현하였다.

protocol ChatAPIUseCaseProtocol {
    func sendChatMessage(query: ChatQuery, for category: Category) async throws -> Message
    func getRandomInitialMessage(for category: Category) -> String
}

final class ChatAPIUseCase: ChatAPIUseCaseProtocol {
    private let chatAPIRepository: ChatAPIRepositoryProtocol

    init(chatAPIRepository: ChatAPIRepositoryProtocol = ChatAPIRepository()) {
        self.chatAPIRepository = chatAPIRepository
    }

    func sendChatMessage(query: ChatQuery, for category: Category) async throws -> Message {
        return try await chatAPIRepository.sendChat(query: query, for: category)
    }

    func getRandomInitialMessage(for category: Category) -> String {
        chatAPIRepository.getRandomInitialMessage(for: category)
    }
}

그런 다음 UseCase는 Repository 레이어에 대한 의존성을 갖고, ViewModel 등에서 호출할 때 비즈니스 로직을 담당한다.

@MainActor
final class ChatViewModel: ObservableObject {
    @Published var messages: [Message] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    @Published var messageText: String = ""
    @Published var currentChatRoom: ChatRoom?

    private let chatAPIUseCase: any ChatAPIUseCaseProtocol
    private let firebaseChatUseCase: any ChatbotFirebaseUseCase

    init(
        chatAPIUseCase: any ChatAPIUseCaseProtocol = DIContainer.shared.chatAPIUseCase,
        firebaseChatUseCase: any ChatbotFirebaseUseCase = DIContainer.shared.chatbotFirebaseUseCase,
        currentChatRoom: ChatRoom
    ) {
        self.chatAPIUseCase = chatAPIUseCase
        self.firebaseChatUseCase = firebaseChatUseCase
        self.currentChatRoom = currentChatRoom
    }

    func sendMessage() async {
        let content = messageText
        messageText = ""
        await sendAPIChat(content: content, category: .depression)
    }

    func sendAPIChat(content: String, category: Category) async {
        guard let currentChatRoom = currentChatRoom else { return }

        // 1) 사용자 메시지 생성 → messages 배열에 추가
        let userMessage = Message(id: UUID(), content: content, role: .user, createAt: Date())
        messages.append(userMessage)

        // 2) Firebase에 사용자 메시지 저장
        do {
            _ = try await firebaseChatUseCase.sendMessage(to: currentChatRoom, message: userMessage)
        } catch {
            print("사용자 메시지 Firebase 저장 실패: \\(error)")
        }

        // 3) 로딩 상태 On
        isLoading = true

        // 4) 어시스턴트 응답 자리에 placeholder 추가
        let placeholder = Message(id: UUID(), content: "", role: .assistant, createAt: Date())
        messages.append(placeholder)

        // 5) ChatQuery 생성 후 UseCase 호출
        let chatQuery = ChatQuery(messages: [ChatMessage(role: "user", content: content)])
        do {
            let responseMessage = try await chatAPIUseCase.sendChatMessage(query: chatQuery, for: category)

            // placeholder 메시지 제거 → 실제 응답 메시지 추가
            if let index = messages.firstIndex(where: { $0.role == .assistant && $0.content.isEmpty }) {
                messages.remove(at: index)
            }
            messages.append(responseMessage)

            // Firebase에 어시스턴트 메시지 저장
            do {
                _ = try await firebaseChatUseCase.sendMessage(to: currentChatRoom, message: responseMessage)
            } catch {
                print("어시스턴트 메시지 Firebase 저장 실패: \\(error)")
            }

            isLoading = false
        } catch {
            errorMessage = error.localizedDescription
            isLoading = false
        }
    }
}

View Model에서 sendMessge() → sendAPIClient() 과정을 거쳐 Moya를 통한 OpenAI API를 호출해 Firebase 저장 로직과 함께 사용자 메세지와 응답 메세지를 DB에 기록할 수 있도록 구현했다.

 

여기서 지금까지 그냥 큰 흐름에서 구조를 나눠 각각 기능에 맞는 구현을 진행했는데 왜 여기서 Moya를 사용하면 좋을지 짚어보고 다음 글에서 자세한 구현에 대해서 다시 작성해보도록 하자.

 

먼저 Moya를 사용하면 좋은점!

  1. 가독성 : 각 API 엔드포인트를 enum의 case로 명시해 API 구조를 한눈에 파악할 수 있다.
  2. 중복 제거 : Base URL, 공통헤더 , 공통 파라미터 등 한번에 관리가 가능하다.
  3. 테스트 용이 : sampleData를 활용하면 Mock 응답을 쉽게 구성할 수 있다.
  4. 확장성 : TargetType 프로토콜을 이용해 API 가 늘어나도 분리해서 관리할 수 있다.

오늘은 여기까지 Moya를 사용해서 API 통신하는 일련의 과정에 대해 큰 그림으로 한번 얘기해봤는데 다음 시간에는 자세한 구현에 대해서 어떤 방식과 생각을 가지고 이런 구조를 통해 구현하게 되었는지 알아보도록 하는 시간을 갖도록 할 예정이다! 오늘은 여기까지!