오늘은 현재 챗봇 기능을 구현하고 있는 과정에서 실제 현업에서는 이렇게 진행되진 않지만 다이렉트로 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를 사용하면 좋은점!
- 가독성 : 각 API 엔드포인트를 enum의 case로 명시해 API 구조를 한눈에 파악할 수 있다.
- 중복 제거 : Base URL, 공통헤더 , 공통 파라미터 등 한번에 관리가 가능하다.
- 테스트 용이 : sampleData를 활용하면 Mock 응답을 쉽게 구성할 수 있다.
- 확장성 : TargetType 프로토콜을 이용해 API 가 늘어나도 분리해서 관리할 수 있다.
오늘은 여기까지 Moya를 사용해서 API 통신하는 일련의 과정에 대해 큰 그림으로 한번 얘기해봤는데 다음 시간에는 자세한 구현에 대해서 어떤 방식과 생각을 가지고 이런 구조를 통해 구현하게 되었는지 알아보도록 하는 시간을 갖도록 할 예정이다! 오늘은 여기까지!
'◽️ Programming > T I L' 카테고리의 다른 글
모듈화 아키텍처를 적용해 재사용성 높히기 (0) | 2025.03.27 |
---|---|
모듈화 아키텍처를 활용한 프로젝트 관리 (0) | 2025.03.20 |
WebSocket과 Starstream에 대해서 알아보자. (0) | 2025.02.17 |
fastlane를 활용한 CI/CD 환경 세팅하기 (0) | 2025.02.05 |
클린아키텍처를 사용해 로그인 정보 저장 로직 구성 및 데이터 전달 (0) | 2024.12.05 |