Gemini API 기준으로 설명하였지만, 다른 AI들도 같은 원리(특히 채팅)를 사용하여 통신하기 때문에 아래 내용을 응용하면 충분히 구현할 수 있다.
https://ai.google.dev/gemini-api/docs
Gemini API | Google AI for Developers
Gemini API 문서 및 API 참조
ai.google.dev
Gemini API reference | Google AI for Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 의견 보내기 Gemini API reference 이 API 참조에서는 Gemini 모델과 상호작용하는 데 사용할 수 있는 표준, 스트리밍, 실시간 API를 설명합니다. HTTP
ai.google.dev
AI HUB 서비스를 준비하면서, AI API용 마이크로서비스에 Nest js를 사용하여 구축하면서 정리한 내용이다.
블로그 글이 최신이 아닐 수 있기 때문에, 위 공식문서로 최신 내용을 확인하는 것을 추천한다(25.12.08).
https://github.com/google-gemini/api-examples.git
GitHub - google-gemini/api-examples: Example code for the Gemini API
Example code for the Gemini API. Contribute to google-gemini/api-examples development by creating an account on GitHub.
github.com
특히 위 예시 코드들도 매우 도움이 된다.
아래 내용들은 SSE(Stream 통신) 위주로, api로 채팅을 구현하는 것 위주로 작성되었다.

파일 업로드
export interface UploadedFileType {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
async uploadFile(file: UploadedFileType): Promise<FileUploadResponseDto> {
// OS의 임시 폴더 경로와 충돌 방지용 UUID 파일명 조합
const tempFileName = `${randomUUID()}_${file.originalname}`;
const tempFilePath = path.join(os.tmpdir(), tempFileName);
try {
// 비동기로 파일 쓰기 (서버 블로킹 방지)
await fs.writeFile(tempFilePath, file.buffer);
// Gemini 업로드
const myfile = await this.gemini.files.upload({
file: tempFilePath,
config: { mimeType: file.mimetype },
});
return new FileUploadResponseDto(myfile.uri);
} catch (error) {
console.error('File upload failed:', error);
throw new InternalServerErrorException(
'파일 업로드 중 오류가 발생했습니다.',
);
} finally {
// 성공하든 실패하든 임시 파일은 무조건 삭제 (try-finally)
try {
await fs.unlink(tempFilePath);
} catch (unlinkError) {
// 이미 삭제되었거나 파일이 없는 경우 등은 무시하거나 로그만 남김
console.warn('Failed to delete temp file:', unlinkError);
}
}
}
여기서 업로드한다는 것은 AI API 제공업체의 서버에 업로드하는 것을 말한다. 대형 AI 회사들은 파일 업로드 api를 지원한다.
api문서에는 저장된 파일을 업로드하는 법이 예시로 작성되어있다. 하지만 채팅을 구현할 때는 파일을 전송하여 업로드하기 때문에, 임시로 파일을 저장한 뒤 삭제하는 구조를 사용한다.
mimeType은 없어도 상관 없지만, file의 메타데이터에 있으므로 추가해주었다.
여기서 반환되는 myfile.uri 를 추후 채팅에서 사용한다. 다른 방식으로 api에 파일을 첨부해도 되지만, 사진을 따로 업로드하고 [사진 uri]를 활용하여 채팅을 진행하는것을 공식문서에서도 권장하고있다.
채팅(SSE)
채팅을 구현하기 전, AI와의 멀티 턴 채팅이 어떤 원리로 작동하는 지 아는 것이 중요하다.
멀티 턴 구조

말 그대로 turn이 다중적인, 한 개가 아닌 것을 의미한다. 우리는 말을 할때 차례대로 한 마디씩 진행한다. 하지만 여기서 충격적인 사실은? AI는 이전 대화를 전혀 기억 못한다는 것이다. 마치 JWT를 사용하여 stateless를 구축하는 것과 같다고 볼 수 있다.
그럼 어떻게 이전 대화를 구현할까? 정답은..바로...
이전 대화를 손수 전부 다시 보내는 것
이다. 난 이 내용을 처음 들었을때 굉장히 충격적이였다. 아니, 이런 원시적인 방법을..?
쉽게 예시를 들어 설명하자면 다음과 같다.
USER: 이 사진을 설명해줘 (사진 첨부)
AI: 이 사진은 이러이러한 것 입니다.USER: {USER: 이 사진을 설명해줘 (사진 첨부), AI: 이 사진은 이러이러한 것 입니다., USER: 내가 방금 뭐라고 물어봤지?}
AI: 사진을 설명해달라고 하셨습니다.
(중략)
물론 이 방식보다 더 진보된(마지막 채팅 id만 보내면 전부 기억하는) 회사(openai)도 있다. 하지만 극 대다수의 ai회사들은 위 방식을 사용하기에, 아래 코드도 위 방식에 맞춰서 구현하였다.
기본적인 파라미터
아주 필요한 기본적인 내용들만 작성했다. 더 자세한 내용은 API문서를 확인하면 볼 수 있다.
contents: 필수적인 파라미터이고, 우리가 보내는 채팅 내용을 넣는 공간이다. content 객체를 넣는다.
content 객체는

구조를 지닌다. 이 구조에서 role은 user인지 ai인지 구분하는 구분자 역할을 한다.
part: 미디어를 포함한 데이터타입이다. part는 text데이터부터, fileData(사진, pdf등), 심지어 실행 가능한 코드(executableCode)까지 타입이 존재한다.
{
"thought": boolean,
"thoughtSignature": string,
"partMetadata": {
object
},
// data
"text": string,
"inlineData": {
object (`[Blob](https://ai.google.dev/api/caching#Blob)`)
},
"functionCall": {
object (`[FunctionCall](https://ai.google.dev/api/caching#FunctionCall)`)
},
"functionResponse": {
object (`[FunctionResponse](https://ai.google.dev/api/caching#FunctionResponse)`)
},
"fileData": {
object (`[FileData](https://ai.google.dev/api/caching#FileData)`)
},
"executableCode": {
object (`[ExecutableCode](https://ai.google.dev/api/caching#ExecutableCode)`)
},
"codeExecutionResult": {
object (`[CodeExecutionResult](https://ai.google.dev/api/caching#CodeExecutionResult)`)
}
// Union type
// metadata
"videoMetadata": {
object (`[VideoMetadata](https://ai.google.dev/api/caching#VideoMetadata)`)
}
// Union type
}
thouht, thoughtSignature, partmetadata는 모두 optional이다. 아래 data구조를 사용하는 것이다. 각 data구조에 들어가는 object들은 위 링크들을 참고하면 된다.
part 공식문서 링크: https://ai.google.dev/api/caching#Part
Caching | Gemini API | Google AI for Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 의견 보내기 Caching 컨텍스트 캐싱을 사용하면 동일한 미디어 파일에 관해 여러 질문을 하는 경우와 같이 반복적으로 사용하려는 사전 계산
ai.google.dev
Nest JS에 구현한 코드
스트림+멀티턴(이전 대화 기록 기억)+파일 기능을 한 메서드에서 구현하기 위하여, IsOptional 필드들을 많이 사용한 DTO를 사용한다.
export class ChatRequestDto {
@IsOptional()
@IsString()
message?: string;
@IsOptional()
@IsString()
model?: string;
@IsOptional()
@IsString()
previous_response_id?: string;
@IsOptional()
@IsString()
file_id?: string;
/**
* Gemini API Content 배열 형식
* [
* { role: "user", parts: [{ text: "안녕하세요" }] },
* { role: "model", parts: [{ text: "안녕하세요!" }] }
* ]
*/
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContentDto)
contents?: ContentDto[];
}
api 문서에 따라, 메시지를 보낼 때 주석과 같은 구조로 contents 배열을 보내게끔 해야 한다.
import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type, Transform } from 'class-transformer';
/**
* 파일 데이터 (URI 참조)
*/
export class FileDataDto {
@IsString()
fileUri: string;
@IsOptional()
@IsString()
mimeType?: string;
}
/**
* 인라인 데이터 (Base64)
*/
export class InlineDataDto {
@IsString()
mimeType: string;
@IsString()
data: string;
}
/**
* 메시지의 각 Part (텍스트, 이미지, 파일 등)
*
* Gemini API 지원 구조:
* - text: 텍스트 콘텐츠
* - fileData: 파일 참조 (URI 방식)
* - inlineData: Base64 인코딩 데이터
*/
export class PartDto {
@IsOptional()
@IsString()
text?: string;
@IsOptional()
@ValidateNested()
@Type(() => FileDataDto)
fileData?: FileDataDto;
@IsOptional()
@ValidateNested()
@Type(() => InlineDataDto)
inlineData?: InlineDataDto;
}
/**
* 과거 채팅 메시지 (Gemini API 형식)
* { role: "user", parts: [{ text: "안녕하세요" }] }
*/
export class ContentDto {
@IsString()
role: 'user' | 'model';
@IsArray()
@ValidateNested({ each: true })
@Type(() => PartDto)
parts: PartDto[];
}
공식 문서의 구조에 맞춰서 DTO를 작성하였다. 공식 SDK의 객체를 가져다가 쓰려면, 필요 없는 필드들이 많기 때문에 필요한 필드만 따로 만들었다.
async *streamChat(request: ChatRequestDto): AsyncIterable<any> {
if (!request.contents || request.contents.length === 0) {
throw new Error('contents 필드가 비어있습니다');
}
const response = await this.gemini.models.generateContentStream({
model: request.model || 'gemini-2.0-flash',
contents: request.contents,
});
let finalMetadata: {
promptTokenCount?: number; // 질문(Prompt)의 총 토큰 수
totalTokenCount?: number; // 총합 (Prompt + Candidates)
} | null = null;
for await (const chunk of response) {
const text = chunk.text;
if (text) {
yield {
type: 'content',
text: text,
};
}
// 메타데이터가 들어있는 청크 변수에 저장 (계속 덮어씌움)
if (chunk.usageMetadata) {
finalMetadata = {
promptTokenCount: chunk.usageMetadata.promptTokenCount,
totalTokenCount: chunk.usageMetadata.totalTokenCount,
};
}
}
// 루프가 종료된 후, 최종 메타데이터를 한 번만 전송
if (finalMetadata) {
const usageDto = new UsageMetadataDto();
usageDto.input_tokens = finalMetadata.promptTokenCount || 0;
usageDto.total_tokens = finalMetadata.totalTokenCount || 0;
usageDto.output_tokens = usageDto.total_tokens - usageDto.input_tokens;
yield {
type: 'usage',
data: usageDto,
};
}
}
이를 활용하여 stream 채팅을 구현한다. DTO에서 필요한 객체를 곧바로 가져와서 사용하고, 반환 데이터중에서 메타데이터를 가져와서 저장할 수 있다.
중요한 점은 contents 배열 순서대로 ai가 인식하기 때문에, 과거 채팅 내역과 같이 추가 질문을 진행하고 싶다면 contents배열의 마지막에 질문을 추가하면 된다.
공식 문서에 채팅 관련 메서드가 따로 있는데, 공식문서에도 나와있는 내용이지만 채팅 관련 메서드도 결국은 generateContentStream을 활용하여 구현한다 라고 나와있다. 따라서, 추후 확장성을 위해서라도 generateContentStream를 사용하였다.