์๋ ํ์ธ์ dev_writer์ ๋๋ค.
ํ ํ์ฌ์ ์ ์ฌํ๋ฉด์ axios๋ฅผ ํ์ ์์ ์ฒ์ ๋ค๋ฃจ๊ฒ ๋์๋๋ฐ์, ๊ทธ ๊ณผ์ ์์ ์๊ฒ ๋ axios์ axios ์ธํฐ์ ํฐ์ ๋ํด ์ ๋ฆฌํด ๋ณด๊ฒ ์ต๋๋ค.
๋ณธ ๊ธ์ ์ฌ์ฉ๋๋ axios๋ 1.7.9 ๋ฒ์ ์ ์ด์ฉํ์์ต๋๋ค.
Axios
์ฐ์ axios๋ node.js์ ๋ธ๋ผ์ฐ์ ๋ฅผ ์ํ Promise ๊ธฐ๋ฐ HTTP ํด๋ผ์ด์ธํธ์ ๋๋ค. (axios ์๊ฐ ๋ฌธ์)
ํน์ง
- ์๋ฒ ์ฌ์ด๋์์๋ node.js์ http ๋ชจ๋์, ๋ธ๋ผ์ฐ์ ์์๋ XMLHttpRequest๋ฅผ ์ฌ์ฉํฉ๋๋ค.
- Promise API๋ฅผ ์ง์ํฉ๋๋ค.
- ์์ฒญ ๋ฐ ์๋ต์ ์ธํฐ์ ํธํ ์ ์์ต๋๋ค.
- JSON ๋ฐ์ดํฐ๋ฅผ ์๋์ผ๋ก ๋ณํํฉ๋๋ค.
- XSRF (Cross-site request forgery, ํฌ๋ก์ค ์ฌ์ดํธ ์์ฒญ ์์กฐ)๋ฅผ ๋ง๊ธฐ ์ํ ํด๋ผ์ด์ธํธ ์ฌ์ด๋๋ฅผ ์ง์ํฉ๋๋ค.
axios์ ์ฃผ๋ก ๋น๊ต๋๋ fetch๋ ์๋๋ฐ, axios์ fetch์ ๋น๊ต๋ ๋ด์ฉ์ด ๊น์ด์ง๊ธฐ์ ์๋ reference์ ์ถ๊ฐํ ๊ธ๋ค์ ์ฝ์ด๋ณด์๊ธฐ๋ฅผ ์ถ์ฒํฉ๋๋ค.
axios ์ด์ฉ ์์
axios๋ฅผ ์ด์ฉํ์ฌ OpenAI์ ๋ฉ์์ง ์์ฑ์ ํ๋ ๊ฒ์ ์์๋ก ๋ค๊ฒ ์ต๋๋ค. (POST ์์)
1๏ธโฃ axios (Axios ์ธ์คํด์ค)๋ฅผ ๋ง๋ ํ ํธ์ถํ๊ธฐ
axios๋ฅผ ๋ง๋ ๋ค ์ด์ฉํ๋ ๋ฐฉ์์ axios.post๋ฅผ ์ด์ฉํ๋ฉด ๋ฉ๋๋ค. axios์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฝ๋๋ฅผ ๋ณด๋ฉด post ์ด์ธ์๋ get, delete ๋ฑ HTTP Method ๋ณ ์์ฒญ์ ์ ์ํด ๋ ๊ฒ์ ๋ณผ ์ ์์ต๋๋ค.
์ด๋ T๋ ๋ฐ๊ณ ์ ํ๋ ์๋ต ๋ฐ์ดํฐ ํ์, R์ T๋ฅผ data๋ก ๊ฐ์ง๊ณ ์๋ AxiosResponse, D๋ ์์ฒญ ํ์์ ์๋ฏธํฉ๋๋ค.
๊ฐ๋ น ChatGPT๋ฅผ ์ฐ๊ฒฐํ๋ ์ผ์ด์ค๋ฅผ ๋ณธ๋ค๋ฉด ์๋ ์ฝ๋์ฒ๋ผ ์์ฑํ๋ฉด ๋ฉ๋๋ค. (openai์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ด์ฉํ์ง ์๋ ๊ฒฝ์ฐ)
import { Axios } from 'axios';
// OpenAI API ํค ์ ์ฅ
const OPENAI_API_KEY = 'Open AI API ํค';
// POST ์์ฒญ ์ ๋ณด๋ผ ๋ฐ์ดํฐ ์ ์
const requestData = {
model: 'gpt-4o',
messages: [
{
role: 'developer',
content: 'You are a helpful assistant.',
},
{
role: 'user',
content: 'Tell me about axios',
}
]
};
// ๋ถ๊ฐ์ ์ธ ์์ฒญ ์ค์ ์ ์
const requestConfig = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`,
}
};
// ๋ฐ์ ์๋ต ํ์
์ ์
type responseData = {
id: string;
object: string;
created: number;
model: string;
system_fingerprint: string;
choices: {
index: number;
message: {
role: string;
content: string;
},
logprobs: object;
finish_reason: string;
}[];
usage: {
prompt_token: number;
completion_tokens: number;
total_tokens: number;
completion_tokens_details: {
reasoning_tokens: number;
accepted_prediction_tokens: number;
rejected_prediction_tokens: number;
}
}
};
export class ChatGptApiInstance {
protected readonly axios: Axios;
constructor() {
// ๊ธฐ๋ณธ baseURL ์ค์
this.axios = new Axios({ baseURL: 'https://api.openai.com' });
}
// data๋ responseData ํ์
async generateText() {
const { data } = await this.axios.post<responseData>('/v1/chat/completion', requestData, requestConfig);
}
}
์ด๋ post์ ์ ์๋ T (responseData์ ํด๋น) ๋ง๊ณ R, D๋ฅผ ์ ์ํ์ง ์์๋ ๋๋ ์ด์ ๋ T๋ฅผ ์ ์ํ ์ R์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก AxiosResponse<T>๋ก ์ ์๋๋ฉฐ, D๋ any ํ์ ์ผ๋ก ์ ์๋๊ธฐ ๋๋ฌธ์ ๋๋ค. ๋ง์ฝ ์์ฒญ ํ์ (type)์ ๋ช ํํ ํ ๊ฒ์ด๋ผ๋ฉด, post<์๋ต ํ์ , AxiosResponse<์๋ต ํ์ >, ์์ฒญ ํ์ >์ผ๋ก ์์ฑํ๋ฉด ๋ฉ๋๋ค.
2๏ธโฃ axiosInstance๋ฅผ ์ด์ฉํ ๋ฐฉ๋ฒ (axios.create)
๋ค์์ผ๋ก ๊ฐ๋ฅํ ๋ฐฉ๋ฒ์ axios (AxiosStatic).create๋ฅผ ํตํด AxiosInstance๋ฅผ ์ด์ฉํ๋ ๋ฐฉ๋ฒ์ด ์์ต๋๋ค.
์ฒซ ๋ฒ์งธ ๋ฐฉ์๊ณผ ์ฝ๋๋ ๊ฑฐ์ ๊ฐ์ต๋๋ค.
import axios, { AxiosInstance } from 'axios';
// .. ๊ฐ์ ์ฝ๋๋ค
export class ChatGptApiInstance {
protected readonly axiosInstance: AxiosInstance;
constructor() {
// ๊ธฐ๋ณธ baseURL ์ค์
this.axiosInstance = axios.create({
baseURL: 'https://api.openai.com',
});
}
// data๋ responseData ํ์
async generateText() {
const { data } = await this.axiosInstance.post<responseData>('/v1/chat/completion', requestData, requestConfig);
}
}
1๋ฒ๊ณผ 2๋ฒ์ ์ฐจ์ด
๊ทธ๋ ๋ค๋ฉด 1๋ฒ (new Axios)์ ์ด์ฉํ๋ ๋ฐฉ๋ฒ๊ณผ 2๋ฒ (axios.create)์ ์ด์ฉํ๋ ๋ฐฉ๋ฒ์ ์ฐจ์ด์ ์ ๋ฌด์์ผ์ง ์์๋ณด๊ฒ ์ต๋๋ค.
AxiosInstance ํ๋๋ฅผ ์ด์ฉํ ์, ํ๋๋ก ์ง์ ์์ฒญ์ด ๊ฐ๋ฅํฉ๋๋ค. (ํจ์๋ก์ ์ด์ฉ ๊ฐ๋ฅ)
async generateText() {
const { data } = await this.axiosInstance<responseData>('/v1/chat/completions', { method: 'POST', data: requestData, headers: requestConfig.headers });
return data;
}
ํด๋น ์์ ์ฝ๋์ฒ๋ผ ํ๋๋ฅผ ํจ์๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค. new Axios๋ฅผ ์ด์ฉํ ๋์๋ ๋ถ๊ฐ๋ฅํฉ๋๋ค.
์ด ์ธ์๋ ํค๋๋ฅผ ์ข ๋ ์ธ๋ฐํ ๋ค๋ฃฐ ์ ์๋ค๋ ํน์ง์ด ๋ณด์ ๋๋ค.
declare class Axios {
constructor(config?: axios.AxiosRequestConfig);
defaults: axios.AxiosDefaults;
...
}
interface AxiosDefaults<D = any> extends Omit<AxiosRequestConfig<D>, 'headers'> {
headers: HeadersDefaults;
}
interface HeadersDefaults {
common: RawAxiosRequestHeaders;
delete: RawAxiosRequestHeaders;
get: RawAxiosRequestHeaders;
head: RawAxiosRequestHeaders;
post: RawAxiosRequestHeaders;
put: RawAxiosRequestHeaders;
patch: RawAxiosRequestHeaders;
options?: RawAxiosRequestHeaders;
purge?: RawAxiosRequestHeaders;
link?: RawAxiosRequestHeaders;
unlink?: RawAxiosRequestHeaders;
}
Axios๋ฅผ ์ด์ฉํ ๊ฒฝ์ฐ์๋ axios.defaults.headers. [common | delete |... unlink?]๊ฐ ๊ฐ๋ฅํ ๋ฐ๋ฉด
AxiosInstance์์๋ axios.defaults.headers ['example'] = 'value'์ฒ๋ผ HeaderDefaults ์ด์ธ์ ํค๋ ๋ฌธ์์ด๋ก ์ง์ ํ ์ ์๊ฒ ๋์ด ์์ต๋๋ค.
๊ตฌ๋ฒ์ ์์๋ Axios ํด๋์ค๊ฐ ๋ฐ๋ก ์๊ณ AxiosInstance๋ง ์์๋ ๋ฐ๋ฉด, ์ง๊ธ์ Axios์ AxiosInstance๊ฐ ๋๋๊ฒ ๋์์ต๋๋ค. (AxiosInstance๊ฐ Axios๋ฅผ ์์๋ฐ๋๋ก ์ค๊ณ) AxiosInstance๊ฐ Axios๋ฅผ ์์๋ฐ์ ์ธํฐํ์ด์ค์ธ ๋งํผ, ํฅํ ํ์ฅ์ฑ์ ๊ณ ๋ คํ์ ๋์๋ AxiosInstance๋ฅผ ์ด์ฉํ๋ ๊ฒ ๋ ๋์ ๋ณด์ ๋๋ค.
Interceptor
๊ฐ๋ฐ ์ํ๊ณ์์ ์ธํฐ์ ํฐ (Interceptor)๋ ์์ฒญ๊ณผ ์๋ต์ ๊ฐ๋ก์ฑ๋ ์ญํ ์ ํ๋ ๊ธฐ๋ฅ์ ๋ปํฉ๋๋ค. ์์ ํน์ง์ ์์ฑํ์๋ฏ, axios์์๋ ์ธํฐ์ ํฐ๋ฅผ ์ด์ฉํ์ฌ ์์ฒญ๊ณผ ์๋ต์ ๊ฐ๋ก์ฑ๊ณ ์์ ํ ์ ์์ต๋๋ค.
axios ๊ณต์ ๋ฌธ์์์๋ ์๋์ ๊ฐ์ ์์ ์ฝ๋๋ฅผ ์๋ดํ๊ณ ์์ต๋๋ค.
/* ํด๋น ์์ ์ฝ๋์์์ axios๋ axios ๋ชจ๋์ AxiosStatic์ ์๋ฏธํฉ๋๋ค. */
// ์์ฒญ ์ธํฐ์
ํฐ ์ถ๊ฐํ๊ธฐ
axios.interceptors.request.use(function (config) {
// ์์ฒญ์ด ์ ๋ฌ๋๊ธฐ ์ ์ ์์
์ํ
return config;
}, function (error) {
// ์์ฒญ ์ค๋ฅ๊ฐ ์๋ ์์
์ํ
return Promise.reject(error);
});
// ์๋ต ์ธํฐ์
ํฐ ์ถ๊ฐํ๊ธฐ
axios.interceptors.response.use(function (response) {
// 2xx ๋ฒ์์ ์๋ ์ํ ์ฝ๋๋ ์ด ํจ์๋ฅผ ํธ๋ฆฌ๊ฑฐ ํฉ๋๋ค.
// ์๋ต ๋ฐ์ดํฐ๊ฐ ์๋ ์์
์ํ
return response;
}, function (error) {
// 2xx ์ธ์ ๋ฒ์์ ์๋ ์ํ ์ฝ๋๋ ์ด ํจ์๋ฅผ ํธ๋ฆฌ๊ฑฐ ํฉ๋๋ค.
// ์๋ต ์ค๋ฅ๊ฐ ์๋ ์์
์ํ
return Promise.reject(error);
});
์ธํฐ์ ํฐ๋ ์ธ์ ์ฐ๊ธฐ ์ ์ฉํ ๊น?
์์ฒญ๊ณผ ์๋ต์ ๊ฐ๋ก์ฑ๊ณ ์์ ํ ์ ์๋ค๋ ์ ์์, ์ธํฐ์ ํฐ๋ ๋ฐ๋ณต๋๋ ์์ฒญ ํ์ ์์ฑ์ ๋จ์ถํ ์ ์๋ค๋ ์ด์ ์ด ์์ต๋๋ค.
์์ฒญ์ ๊ฒฝ์ฐ, ๋จ์ํ ํค๋์ ํ ํฐ์ ์ฃผ์ ํ๋ ๊ฒ ๋ง๊ณ ๋ ์์ฒญ ํ๋ผ๋ฏธํฐ๋ค์ ๋ชจ๋ ์ด์ฉํ์ฌ ๋ณ๋์ signature๋ฅผ ์์ฑํ ๋ค API ์์ฒญ ์ ์ ๋ฌํด์ผ ํ๋ ์์ ๋ฑ์ด ์๋ค๋ฉด ์ฃผ์ด์ง ์์ฒญ ํ๋ผ๋ฏธํฐ๋ค์ ์ทจํฉํ์ฌ signature๋ฅผ ์์ฑํ๋ ํจ์๋ฅผ ์ฃผ์ ์ํฌ ์๋ ์์ต๋๋ค.
์๋ต์ ๊ฒฝ์ฐ์๋ ๋ก๊น ์ ํด์ผ ํ๋ ๊ฒฝ์ฐ๊ฐ ์๋ค๋ฉด, ์ธํฐ์ ํฐ์์ ๋ก๊น ์ ํ๋๋ก ํ ์๋ ์์ต๋๋ค.
์ ๋ฆฌํ์๋ฉด ์ธํฐ์ ํฐ๋ฅผ ์ด์ฉํ๋ฉด ๋ฐ๋ณต์ ์ธ ํ์๋ฅผ ํ ๋ฒ์ ์ฝ๋๋ก ์๋ํํ ์ ์๋ค๋ ํน์ง์ด ์์ต๋๋ค.
์ธํฐ์ ํฐ ์ฌ์ฉ ์์
ํด๋น ์์์์์ ์์ฒญ/์๋ต ํ์์ ์ค์ OpenAI์ ๋ค๋ฅผ ์ ์์ต๋๋ค.
AxiosInstance ๋ฐฉ์์ ์ธํฐ์ ํฐ๋ฅผ ์ด์ฉํ ์ฝ๋๋ฅผ ์์ฑํด ๋ณด๊ฒ ์ต๋๋ค.
์กฐ๊ธ ๋ ์๊ตฌ์ฌํญ์ ๋ณต์กํ๊ฒ ํ๊ธฐ ์ํด, ChatGPT์ POST ์์ฒญ ์ ์ ๋ฌ๋๋ body์ key ๊ฐ๋ค์ ๋ชจ๋ ์ทจํฉํ์ฌ signature๋ฅผ ์์ฑํด์ผ ํ๋ ์๊ตฌ์ฌํญ์ด ChatGPT API์ ์๋ค๊ณ ๊ฐ์ ํด ๋ณด๊ฒ ์ต๋๋ค.
1๋จ๊ณ: ChatGptApiInstance ๋ฐ ์ธํฐ์ ํฐ ์ ์
๊ฐ API๋ง๋ค ๊ณตํต์ ์ผ๋ก ์ฌ์ฉํ ChatGptApiInstance๋ฅผ ์ ์ํฉ๋๋ค.
const OPENAI_API_KEY = 'Open AI API ํค';
const ENDPOINT = 'https://api.openai.com';
export type GptApiResponse<T = any> = {
data: T,
code: string;
};
export class ChatGptApiInstance {
protected readonly axiosInstance: AxiosInstance;
protected constructor(
) {
this.axiosInstance = axios.create({
baseURL: ENDPOINT,
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': `Bearer ${OPENAI_API_KEY}`,
}
});
// ์์ฒญ ์ธํฐ์
ํฐ
this.axiosInstance.interceptors.request.use(
(config) => this.onFulfilledRequest(config),
(error) => this.onRejectedRequest(error),
);
// ์๋ต ์ธํฐ์
ํฐ
this.axiosInstance.interceptors.response.use(
(response) => this.onFulfilledResponse(response),
(error) => this.onRejectedOnResponse(error),
);
}
protected async onFulfilledRequest(
config: InternalAxiosRequestConfig,
): Promise<InternalAxiosRequestConfig> {
Logger.log(`[OpenAI API request] ${config.method} ${config.url}`);
const payload: string = generateSignaturePayload(config.data);
return {
...config,
data: {
...config.data,
// ๊ธฐ์กด ์์ฒญ๋ data ์ด์ธ์ ์ถ๊ฐ๋ก signature ๋ง๋ถ์
sign: generateSign(payload),
}
};
}
private onFulfilledResponse(
res: AxiosResponse<GptApiResponse>
): Promise<AxiosResponse> {
Logger.log(`[OpenAI API response] ${config.method} ${config.url}`);
if (res.data.code === '0') {
return Promise.resolve({
...res,
data: res.data,
});
}
return Promise.reject(new Error(`[${res.data.code}]`));
}
protected async onRejectedRequest(error: unknown): Promise<never> {
return Promise.reject(error);
}
protected async onRejectedOnResponse(error: unknown): Promise<never> {
return Promise.reject(error);
}
}
- AxiosInstance๋ฅผ ChatGptApiInstance์ ํ๋๋ก ๊ด๋ฆฌ๋๋๋ก ํฉ๋๋ค.
- ์์ฒญ ์ธํฐ์
ํฐ๋ฅผ ์ ์ํฉ๋๋ค.
- ์์ฒญ ์ ์์ฒญํ ๋ฐ์ดํฐ๋ค (config ํ์ ํ๋)์ ์ด์ฉํ์ฌ signature๋ฅผ ์์ฑํ๋ ํจ์๋ฅผ ์ธํฐ์ ํฐ์ ์ ์ํฉ๋๋ค. ๋ํ ์์ฒญ ์ค ์๋ฌ ๋ฐ์ ์ ์ฒ๋ฆฌํ ํจ์๋ฅผ ์ ์ํฉ๋๋ค.
- ์๋ต ์ธํฐ์
ํฐ๋ฅผ ์ ์ํฉ๋๋ค.
- axios ์๋ต์ ์ด์ฉํ์ฌ ์ ์ ์ฌ๋ถ๋ฅผ ํ์ ํ๊ณ ์ ์์ด๋ผ๋ฉด resolve, ์ค๋ฅ๋ผ๋ฉด reject ์ํค๋ ์์ ์ ์ธํฐ์ ํฐ์ ์ ์ํฉ๋๋ค.
์ด์ ๊ฐ API ๊ตฌํ์ฒด๋ค์ signature ์์ฑ์ ์ง์ ์งํํ์ง ์์๋ ๋ฉ๋๋ค.
2๋จ๊ณ: ๊ฐ ๊ตฌํ์ฒด ์ ์ (Text, Image)
๊ฐ ๊ตฌํ์ฒด์์๋ ์๋์ฒ๋ผ ์์ฑํ๋ฉด ๋ฉ๋๋ค.
// ํ
์คํธ ์์ฑ API ๊ตฌํ์ฒด
export class ChatGptTextApi extends ChatGptApiInstance {
constructor() {
super();
}
async generateText(): Promise<GptTextResponse> {
const { data } = await this.axiosInstance.post<GptTextResponse>(
'/v1/chat/completions',
{
...textRequestData,
}
)
return data;
}
}
// ์ด๋ฏธ์ง ์์ฑ API ๊ตฌํ์ฒด
export class ChatGptImageApi extends ChatGptApiInstance {
constructor() {
super();
}
async generateImage(): Promise<GptImageResponse> {
const { data } = await this.axiosInstance.post<GptImageResponse>(
'/v1/images/generations',
{
...imageRequestData,
}
)
return data;
}
}
๊ฒฐ๋ก
axios์ ์ด๊ฒ์ด ๊ฐ์ง๊ณ ์๋ ์ธํฐ์ ํฐ์ ๋ํด ์ ๋ฆฌํด ๋ณด๋ ํ ์คํธ๋ก๋ง ์๊ณ ์๋ ๋ด์ฉ์ ์ง์ ์ ํ ์ ์๋ ์ข์ ๊ณ๊ธฐ์์ต๋๋ค.
- ์ฌ๋ด ์ฝ๋๋ ํ์ฌ ๋ฒ์ (1.7.9) ๋ณด๋ค ๋ ์ด์ ๋ฒ์ ์ด๊ธฐ์, ๊ตฌ์กฐ ๋ฐ ์๋ฆฌ๊ฐ ์ง๊ธ ์ ๋ฆฌํ ๊ธ๊ณผ ์ฝ๊ฐ ๋ค๋ฅด๊ฒ ํ๋ฌ๊ฐ๋๋ค. ์ด ๋๋ฌธ์ axios๋ ์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ด๋ฏ๋ก ์ ๋ฐ์ดํธ์ ๋ฐ๋ผ ๊ตฌ์กฐ, ์๋ฆฌ๊ฐ ๋ฌ๋ผ์ง ์ ์๋ค๋ ํน์ง์ ์ฒด๊ฐํ ์ ์์์ต๋๋ค. (Axios ํด๋์ค๊ฐ ์ ์๋๊ณ ์์ฒญ ์ธํฐ์ ํฐ์ config๊ฐ AxiosRequestConfig์์ InternalAxiosRequestConfig๋ก ๋ณ๊ฒฝ๋ ๊ฒ ๋ฑ)
- axios์์๋ ์ ๋ฆฌํ ๊ธ์ฒ๋ผ ์ธํฐ์ ํฐ๋ฅผ ๋ฐ๋ก ์ง์ํ์ง๋ง, fetch์์๋ ์ง์ ์ธํฐ์ ํฐ๋ฅผ ๊ตฌํํด์ผ ํ๋ ๋ฑ fetch๊ฐ axios์ ๋นํด ์ง์๋๋ ๊ธฐ๋ฅ๋ค์ด ์ ํ๋๋ค๋ ์ ๋ ์ฒด๊ฐํ ์ ์์์ต๋๋ค.
์ง๊ธ๊น์ง axios ๋ฐ axios์ ์ธํฐ์ ํฐ์ ๋ํด ์์๋ดค์ต๋๋ค. ๋ง์ฝ ํ๋ฆฐ ์ ์ด ์๊ฑฐ๋ ๊ณ ์น ์ ์ด ์๋ค๋ฉด ํผ๋๋ฐฑ ๋ฐ์ ์ ๋ฐ์ํ๊ฒ ์ต๋๋ค.
Reference
'๐ ํ (๊ธฐ์ ์ ์ฉ ๋ฐฉ๋ฒ ๋ฑ) > JS&TS' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[์ค์ ] JS/TS ํ๋ก์ ํธ ์ค์ ๋ฒ (in Webstorm) (0) | 2025.01.28 |
---|