5. Streamlit으둜 DocumentGPT μ•± λ§Œλ“€κΈ°

🎯 이 μ±•ν„°μ—μ„œ 배울 것

  • Streamlit의 κΈ°λ³Έ μ‚¬μš©λ²•κ³Ό λ‹€μ–‘ν•œ UI μ»΄ν¬λ„ŒνŠΈ(μœ„μ ―) ν™œμš©λ²•
  • 닀쀑 νŽ˜μ΄μ§€ Streamlit μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ κ΅¬μ„±ν•˜λŠ” 방법
  • 파일 μ—…λ‘œλ“œ, μ±„νŒ… μΈν„°νŽ˜μ΄μŠ€ λ“± μ‹€μ œ μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ˜ 핡심 κΈ°λŠ₯ κ΅¬ν˜„ν•˜κΈ°
  • RAG νŒŒμ΄ν”„λΌμΈ(Load, Split, Embed, Store, Retrieve)을 Streamlit 앱에 ν†΅ν•©ν•˜λŠ” 방법
  • λŒ€ν™” 기둝을 st.session_state둜 κ΄€λ¦¬ν•˜λŠ” 방법
  • LLM의 닡변을 μ‹€μ‹œκ°„μœΌλ‘œ μŠ€νŠΈλ¦¬λ°ν•˜μ—¬ μ‚¬μš©μž κ²½ν—˜μ„ ν–₯μƒμ‹œν‚€λŠ” 방법

Streamlit 기초

🎯 이번 λ‹¨κ³„μ—μ„œ 배울 것

  • streamlit run λͺ…λ Ήμ–΄λ‘œ 앱을 μ‹€ν–‰ν•˜λŠ” 방법
  • st.title, st.selectbox, st.text_input, st.slider λ“± κΈ°λ³Έ μœ„μ ― μ‚¬μš©λ²•
  • μœ„μ ―μ˜ μž…λ ₯값에 따라 μ•±μ˜ λ™μž‘μ„ λ™μ μœΌλ‘œ λ³€κ²½ν•˜λŠ” 방법

πŸ“ 1단계: Streamlit μœ„μ ― μ‚¬μš©ν•΄λ³΄κΈ°

전체 μ½”λ“œ (Home.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import streamlit as st
from datetime import datetime

today = datetime.today().strftime("%H:%M:%S")
st.title(today)

model = st.selectbox(
"Choose your model",
("GPT-3", "GPT-4"),
)

if model == "GPT-3":
st.write("cheap")
else:
st.write("not cheap")
name = st.text_input("What is your name?")
st.write(name)

value = st.slider(
"temperature",
min_value=0.1,
max_value=1.0,
)
st.write(value)

πŸ” μ½”λ“œ 상세 μ„€λͺ…

1. Streamlitμ΄λž€?
Streamlit은 데이터 κ³Όν•™μžμ™€ λ¨Έμ‹ λŸ¬λ‹ μ—”μ§€λ‹ˆμ–΄λ₯Ό μœ„ν•΄ λ§Œλ“€μ–΄μ§„ Python μ›Ή ν”„λ ˆμž„μ›Œν¬μž…λ‹ˆλ‹€. λ³΅μž‘ν•œ HTML/CSS/JavaScript 없이 Python μ½”λ“œλ§ŒμœΌλ‘œ μΈν„°λž™ν‹°λΈŒν•œ μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ 맀우 λΉ λ₯΄κ³  μ‰½κ²Œ λ§Œλ“€ 수 μžˆμŠ΅λ‹ˆλ‹€.

  • μ‹€ν–‰ 방법: ν„°λ―Έλ„μ—μ„œ streamlit run <파일λͺ…>.py을 μ‹€ν–‰ν•©λ‹ˆλ‹€.
  • λ™μž‘ 원리: μŠ€ν¬λ¦½νŠΈκ°€ μœ„μ—μ„œ μ•„λž˜λ‘œ μ‹€ν–‰λ˜λ©°, st.으둜 μ‹œμž‘ν•˜λŠ” ν•¨μˆ˜λ“€μ€ μ›Ή νŽ˜μ΄μ§€μ— ν•΄λ‹Ή UI μš”μ†Œλ₯Ό λ Œλ”λ§ν•©λ‹ˆλ‹€. μ‚¬μš©μžκ°€ μœ„μ ―κ³Ό μƒν˜Έμž‘μš©(예: λ²„νŠΌ 클릭, ν…μŠ€νŠΈ μž…λ ₯)ν•˜λ©΄ 슀크립트 전체가 λ‹€μ‹œ μ‹€ν–‰λ˜μ–΄ 화면이 μ—…λ°μ΄νŠΈλ©λ‹ˆλ‹€.

2. κΈ°λ³Έ μœ„μ ―

  • st.title(): 큰 제λͺ©μ„ ν‘œμ‹œν•©λ‹ˆλ‹€.
  • st.selectbox(): λ“œλ‘­λ‹€μš΄ 메뉴λ₯Ό λ§Œλ“­λ‹ˆλ‹€. μ„ νƒλœ 값은 λ³€μˆ˜μ— μ €μž₯λ©λ‹ˆλ‹€.
  • st.text_input(): μ‚¬μš©μžλ‘œλΆ€ν„° ν…μŠ€νŠΈλ₯Ό μž…λ ₯λ°›λŠ” μƒμžλ₯Ό λ§Œλ“­λ‹ˆλ‹€.
  • st.slider(): μŠ¬λΌμ΄λ”λ₯Ό λ§Œλ“€μ–΄ 숫자 λ²”μœ„λ₯Ό μ„ νƒν•˜κ²Œ ν•©λ‹ˆλ‹€.

βœ… 체크리슀트

  • streamlit 라이브러리λ₯Ό μ„€μΉ˜ν–ˆλ‚˜μš”?
  • streamlit run Home.py둜 앱을 μ‹€ν–‰ν–ˆλ‚˜μš”?
  • st.selectboxλ₯Ό μ‚¬μš©ν•˜μ—¬ λͺ¨λΈμ„ μ„ νƒν•˜κ³ , 선택에 따라 λ‹€λ₯Έ μœ„μ ―μ΄ λ‚˜νƒ€λ‚˜λŠ” 것을 ν™•μΈν–ˆλ‚˜μš”?

닀쀑 νŽ˜μ΄μ§€ μ•± (Multi-Page Apps)

🎯 이번 λ‹¨κ³„μ—μ„œ 배울 것

  • pages 디렉토리λ₯Ό μ‚¬μš©ν•˜μ—¬ μ—¬λŸ¬ νŽ˜μ΄μ§€λ₯Ό κ°€μ§„ Streamlit 앱을 κ΅¬μ„±ν•˜λŠ” 방법
  • st.set_page_config둜 νŽ˜μ΄μ§€μ˜ 제λͺ©κ³Ό μ•„μ΄μ½˜μ„ μ„€μ •ν•˜λŠ” 방법

πŸ“ 1단계: pages 디렉토리 ν™œμš©ν•˜κΈ°

전체 μ½”λ“œ (Home.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import streamlit as st

st.set_page_config(
page_title="FullstackGPT Home",
page_icon="πŸ€–",
)

st.markdown(
"""
# Hello!
Welcome to my FullstackGPT Portfolio!
... (링크듀)
"""
)

전체 μ½”λ“œ (pages/01_DocumentGPT.py):

1
2
3
import streamlit as st

st.title("DocumentGPT")

πŸ” μ½”λ“œ 상세 μ„€λͺ…

1. 닀쀑 νŽ˜μ΄μ§€ μ•± ꡬ쑰
Streamlit은 맀우 κ°„λ‹¨ν•œ λ°©μ‹μœΌλ‘œ 닀쀑 νŽ˜μ΄μ§€ 앱을 μ§€μ›ν•©λ‹ˆλ‹€. 메인 파일(예: Home.py)이 μžˆλŠ” 디렉토리 μ•ˆμ— pagesλΌλŠ” μ΄λ¦„μ˜ 디렉토리λ₯Ό λ§Œλ“€κ³ , κ·Έ μ•ˆμ— λ‹€λ₯Έ Python νŒŒμΌμ„ λ„£κΈ°λ§Œ ν•˜λ©΄ λ©λ‹ˆλ‹€. Streamlit이 μžλ™μœΌλ‘œ pages 디렉토리λ₯Ό κ°μ§€ν•˜μ—¬ μ‚¬μ΄λ“œλ°”μ— 각 νŽ˜μ΄μ§€λ‘œ 이동할 수 μžˆλŠ” λ„€λΉ„κ²Œμ΄μ…˜ 메뉴λ₯Ό λ§Œλ“€μ–΄μ€λ‹ˆλ‹€.

  • 파일 μˆœμ„œ: 파일λͺ… μ•žμ˜ μˆ«μžμ— 따라 λ©”λ‰΄μ˜ μˆœμ„œκ°€ κ²°μ •λ©λ‹ˆλ‹€. (예: 01_..., 02_...)

2. st.set_page_config()

  • 각 νŽ˜μ΄μ§€μ˜ λΈŒλΌμš°μ € νƒ­ 제λͺ©, νŒŒλΉ„μ½˜(μ•„μ΄μ½˜), λ ˆμ΄μ•„μ›ƒ 등을 μ„€μ •ν•˜λŠ” ν•¨μˆ˜μž…λ‹ˆλ‹€.
  • μŠ€ν¬λ¦½νŠΈμ—μ„œ κ°€μž₯ λ¨Όμ € ν˜ΈμΆœλ˜μ–΄μ•Ό ν•˜λŠ” ν•¨μˆ˜ 쀑 ν•˜λ‚˜μž…λ‹ˆλ‹€.

βœ… 체크리슀트

  • pages 디렉토리λ₯Ό λ§Œλ“€κ³  κ·Έ μ•ˆμ— .py νŒŒμΌμ„ μΆ”κ°€ν–ˆλ‚˜μš”?
  • 앱을 μ‹€ν–‰ν–ˆμ„ λ•Œ μ‚¬μ΄λ“œλ°”μ— νŽ˜μ΄μ§€ λ„€λΉ„κ²Œμ΄μ…˜μ΄ μžλ™μœΌλ‘œ μƒμ„±λ˜μ—ˆλ‚˜μš”?
  • st.set_page_configλ₯Ό μ‚¬μš©ν•˜μ—¬ 각 νŽ˜μ΄μ§€μ˜ 제λͺ©κ³Ό μ•„μ΄μ½˜μ„ μ„€μ •ν–ˆλ‚˜μš”?

μ±„νŒ… κΈ°λŠ₯κ³Ό λŒ€ν™” 기둝

🎯 이번 λ‹¨κ³„μ—μ„œ 배울 것

  • st.chat_inputκ³Ό st.chat_messageλ₯Ό μ‚¬μš©ν•˜μ—¬ μ±„νŒ… UIλ₯Ό κ΅¬ν˜„ν•˜λŠ” 방법
  • st.session_stateλ₯Ό μ‚¬μš©ν•˜μ—¬ λŒ€ν™” 기둝을 μ €μž₯ν•˜κ³  μœ μ§€ν•˜λŠ” 방법

πŸ“ 1단계: μ±„νŒ… UI 및 μƒνƒœ 관리

전체 μ½”λ“œ (pages/01_DocumentGPT.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import streamlit as st

st.title("DocumentGPT")

# 1. μ„Έμ…˜ μƒνƒœμ— λŒ€ν™” 기둝 μ΄ˆκΈ°ν™”
if "messages" not in st.session_state:
st.session_state["messages"] = []

# 2. λ©”μ‹œμ§€ 전솑 및 μ €μž₯ ν•¨μˆ˜
def send_message(message, role, save=True):
with st.chat_message(role):
st.markdown(message)
if save:
st.session_state["messages"].append({"message": message, "role": role})

# 3. 이전 λŒ€ν™” 기둝 ν‘œμ‹œ
def paint_history():
for message in st.session_state["messages"]:
send_message(message["message"], message["role"], save=False)

paint_history()

# 4. μ‚¬μš©μž μž…λ ₯ λ°›κΈ°
message = st.chat_input("Ask anything about your file...")
if message:
send_message(message, "human")
# ... (AI 응닡 둜직)

πŸ” μ½”λ“œ 상세 μ„€λͺ…

1. st.session_state
Streamlit은 μ‚¬μš©μžκ°€ μƒν˜Έμž‘μš©ν•  λ•Œλ§ˆλ‹€ 슀크립트 전체λ₯Ό μž¬μ‹€ν–‰ν•©λ‹ˆλ‹€. 이 λ•Œ 일반 λ³€μˆ˜μ— μ €μž₯된 값은 μ‚¬λΌμ§‘λ‹ˆλ‹€. st.session_stateλŠ” μ‚¬μš©μžμ˜ μ„Έμ…˜ λ™μ•ˆ 데이터λ₯Ό μœ μ§€μ‹œμΌœμ£ΌλŠ” λ”•μ…”λ„ˆλ¦¬ 같은 κ°μ²΄μž…λ‹ˆλ‹€. μ±—λ΄‡μ˜ λŒ€ν™” 기둝처럼 μ—¬λŸ¬ μž¬μ‹€ν–‰ κ³Όμ •μ—μ„œ μœ μ§€λ˜μ–΄μ•Ό ν•˜λŠ” 값을 μ €μž₯ν•˜λŠ” 데 ν•„μˆ˜μ μž…λ‹ˆλ‹€.

2. μ±„νŒ… UI

  • st.chat_input(): ν™”λ©΄ ν•˜λ‹¨μ— κ³ μ •λœ μ±„νŒ… μž…λ ₯창을 λ§Œλ“­λ‹ˆλ‹€. μ‚¬μš©μžκ°€ λ©”μ‹œμ§€λ₯Ό μž…λ ₯ν•˜κ³  μ—”ν„°λ₯Ό 치면 κ·Έ λ©”μ‹œμ§€ λ‚΄μš©μ„ λ°˜ν™˜ν•©λ‹ˆλ‹€.
  • st.chat_message(role): with ꡬ문과 ν•¨κ»˜ μ‚¬μš©ν•˜μ—¬ νŠΉμ • μ—­ν• (human λ˜λŠ” ai)에 λ§žλŠ” ν”„λ‘œν•„ μ•„μ΄μ½˜κ³Ό λ°°κ²½μƒ‰μœΌλ‘œ μ±„νŒ… 버블을 λ§Œλ“­λ‹ˆλ‹€.

λ™μž‘ 흐름:

  1. 앱이 처음 μ‹€ν–‰λ˜λ©΄ st.session_state["messages"]κ°€ 빈 리슀트둜 μ΄ˆκΈ°ν™”λ©λ‹ˆλ‹€.
  2. paint_history()κ°€ ν˜ΈμΆœλ˜μ§€λ§Œ, 아직 λ©”μ‹œμ§€κ°€ μ—†μœΌλ―€λ‘œ 아무것도 ν‘œμ‹œλ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
  3. μ‚¬μš©μžκ°€ st.chat_input에 λ©”μ‹œμ§€λ₯Ό μž…λ ₯ν•©λ‹ˆλ‹€.
  4. μŠ€ν¬λ¦½νŠΈκ°€ μž¬μ‹€ν–‰λ˜κ³ , message λ³€μˆ˜μ— μ‚¬μš©μž μž…λ ₯이 λ‹΄κΉλ‹ˆλ‹€.
  5. send_message(message, "human")κ°€ ν˜ΈμΆœλ˜μ–΄ μ‚¬μš©μž λ©”μ‹œμ§€λ₯Ό 화면에 ν‘œμ‹œν•˜κ³  st.session_state에 μ €μž₯ν•©λ‹ˆλ‹€.
  6. (AI 응닡 둜직 μ‹€ν–‰ ν›„) send_message(response, "ai")κ°€ ν˜ΈμΆœλ˜μ–΄ AI의 닡변을 화면에 ν‘œμ‹œν•˜κ³  st.session_state에 μ €μž₯ν•©λ‹ˆλ‹€.
  7. λ‹€μŒ μž¬μ‹€ν–‰ μ‹œ, paint_history()λŠ” st.session_state에 μ €μž₯된 λͺ¨λ“  λ©”μ‹œμ§€λ₯Ό 화면에 κ·Έλ €μ€λ‹ˆλ‹€.

βœ… 체크리슀트

  • st.session_stateλ₯Ό μ‚¬μš©ν•˜μ—¬ messages 리슀트λ₯Ό μ΄ˆκΈ°ν™”ν–ˆλ‚˜μš”?
  • st.chat_input으둜 μ‚¬μš©μž μž…λ ₯을 λ°›κ³ , st.chat_message둜 화면에 ν‘œμ‹œν–ˆλ‚˜μš”?
  • μ‚¬μš©μžμ™€ AI의 λ©”μ‹œμ§€λ₯Ό st.session_state에 μ €μž₯ν•˜κ³ , 앱이 μž¬μ‹€ν–‰λ  λ•Œλ§ˆλ‹€ paint_history ν•¨μˆ˜λ‘œ λ³΅μ›ν–ˆλ‚˜μš”?

RAG νŒŒμ΄ν”„λΌμΈκ³Ό 슀트리밍

🎯 이번 λ‹¨κ³„μ—μ„œ 배울 것

  • st.file_uploader둜 νŒŒμΌμ„ μ—…λ‘œλ“œν•˜κ³ , @st.cache_data둜 RAG νŒŒμ΄ν”„λΌμΈμ˜ λΉ„μ‹Ό μ—°μ‚°(μž„λ² λ”©)을 μΊμ‹±ν•˜λŠ” 방법
  • LCEL둜 RAG 체인을 κ΅¬μ„±ν•˜κ³  Streamlit μ•±κ³Ό μ—°λ™ν•˜λŠ” 방법
  • BaseCallbackHandlerλ₯Ό μ‚¬μš©ν•˜μ—¬ LLM의 닡변을 μ‹€μ‹œκ°„μœΌλ‘œ μŠ€νŠΈλ¦¬λ°ν•˜λŠ” 방법

πŸ“ 1단계: 파일 μ—…λ‘œλ“œ 및 RAG 체인 연동

전체 μ½”λ“œ (pages/01_DocumentGPT.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# ... (imports)

# 1. μŠ€νŠΈλ¦¬λ°μ„ μœ„ν•œ 콜백 ν•Έλ“€λŸ¬
class ChatCallbackHandler(BaseCallbackHandler):
message = ""
def on_llm_start(self, *args, **kwargs):
self.message_box = st.empty() # 닡변이 ν‘œμ‹œλ  빈 곡간 생성
def on_llm_end(self, *args, **kwargs):
save_message(self.message, "ai") # 슀트리밍 λλ‚˜λ©΄ 전체 λ©”μ‹œμ§€ μ €μž₯
def on_llm_new_token(self, token, *args, **kwargs):
self.message += token
self.message_box.markdown(self.message) # μƒˆ 토큰이 올 λ•Œλ§ˆλ‹€ ν™”λ©΄ μ—…λ°μ΄νŠΈ

llm = ChatOpenAI(temperature=0.1, streaming=True, callbacks=[ChatCallbackHandler()])

# 2. 파일 μž„λ² λ”© ν•¨μˆ˜ (캐싱 적용)
@st.cache_data(show_spinner="Embedding file...")
def embed_file(file):
# ... (파일 μ €μž₯, λΆ„ν• , μž„λ² λ”©, λ²‘ν„°μŠ€ν† μ–΄ 생성 둜직)
return retriever

# ... (UI 및 체인 ꡬ성)
with st.sidebar:
file = st.file_uploader("Upload a file", type=["pdf", "txt", "docx"])

if file:
retriever = embed_file(file)
# ... (μ±„νŒ… 둜직)
if message:
chain = ({ "context": retriever | RunnableLambda(format_docs), "question": RunnablePassthrough() } | prompt | llm)
with st.chat_message("ai"):
chain.invoke(message) # 슀트리밍 μ‹œμž‘

πŸ” μ½”λ“œ 상세 μ„€λͺ…

1. @st.cache_data
이 λ°μ½”λ ˆμ΄ν„°λŠ” ν•¨μˆ˜μ˜ μ‹€ν–‰ κ²°κ³Όλ₯Ό μΊμ‹±ν•©λ‹ˆλ‹€. λ™μΌν•œ μž…λ ₯으둜 ν•¨μˆ˜κ°€ λ‹€μ‹œ 호좜되면, ν•¨μˆ˜λ₯Ό μž¬μ‹€ν–‰ν•˜λŠ” λŒ€μ‹  μΊμ‹œλœ κ²°κ³Όλ₯Ό μ¦‰μ‹œ λ°˜ν™˜ν•©λ‹ˆλ‹€. 파일 μž„λ² λ”©μ²˜λŸΌ λΉ„μš©κ³Ό μ‹œκ°„μ΄ 많이 λ“œλŠ” 계산에 μ‚¬μš©ν•˜μ—¬ μ•± μ„±λŠ₯을 크게 ν–₯μƒμ‹œν‚¬ 수 μžˆμŠ΅λ‹ˆλ‹€.

2. st.file_uploader
μ‚¬μš©μžκ°€ νŒŒμΌμ„ μ—…λ‘œλ“œν•  수 μžˆλŠ” μœ„μ ―μ„ μƒμ„±ν•©λ‹ˆλ‹€. μ—…λ‘œλ“œλœ νŒŒμΌμ€ λ©”λͺ¨λ¦¬ μƒμ—μ„œ 파일 객체둜 μ²˜λ¦¬λ©λ‹ˆλ‹€.

3. 슀트리밍 κ΅¬ν˜„

  • ChatCallbackHandler: LangChain의 콜백 μ‹œμŠ€ν…œμ„ ν™œμš©ν•˜μ—¬ LLM의 응닡 생성 과정에 κ°œμž…ν•©λ‹ˆλ‹€. on_llm_new_token μ΄λ²€νŠΈλŠ” λͺ¨λΈμ΄ μƒˆ 토큰(단어)을 생성할 λ•Œλ§ˆλ‹€ νŠΈλ¦¬κ±°λ©λ‹ˆλ‹€.
  • st.empty(): λ‚˜μ€‘μ— μ½˜ν…μΈ λ₯Ό μ±„μšΈ 수 μžˆλŠ” 빈 μ»¨ν…Œμ΄λ„ˆλ₯Ό λ§Œλ“­λ‹ˆλ‹€. 콜백 ν•Έλ“€λŸ¬λŠ” 이 message_box에 κ³„μ†ν•΄μ„œ μƒˆλ‘œμš΄ 토큰을 μΆ”κ°€ν•˜μ—¬ 슀트리밍 효과λ₯Ό κ΅¬ν˜„ν•©λ‹ˆλ‹€.
  • chain.invoke(message): llm을 streaming=True둜 μ„€μ •ν–ˆκΈ° λ•Œλ¬Έμ—, invokeλ₯Ό ν˜ΈμΆœν•˜λ©΄ 응닡이 μ™„λ£Œλ  λ•ŒκΉŒμ§€ κΈ°λ‹€λ¦¬λŠ” λŒ€μ‹  슀트리밍이 μ‹œμž‘λ©λ‹ˆλ‹€. 콜백 ν•Έλ“€λŸ¬κ°€ λ‚˜λ¨Έμ§€ μž‘μ—…μ„ μ²˜λ¦¬ν•©λ‹ˆλ‹€.

βœ… 체크리슀트

  • st.file_uploaderλ₯Ό μ‚¬μ΄λ“œλ°”μ— μΆ”κ°€ν•˜μ—¬ 파일 μ—…λ‘œλ“œ κΈ°λŠ₯을 κ΅¬ν˜„ν–ˆλ‚˜μš”?
  • @st.cache_dataλ₯Ό embed_file ν•¨μˆ˜μ— μ μš©ν•˜μ—¬ μž„λ² λ”© 과정을 μΊμ‹±ν–ˆλ‚˜μš”?
  • BaseCallbackHandlerλ₯Ό 상속받아 슀트리밍 μ½œλ°±μ„ λ§Œλ“€κ³ , on_llm_new_tokenμ—μ„œ st.empty() μœ„μ ―μ„ μ—…λ°μ΄νŠΈν–ˆλ‚˜μš”?
  • ChatOpenAI λͺ¨λΈμ„ streaming=True와 callbacks와 ν•¨κ»˜ μ΄ˆκΈ°ν™”ν–ˆλ‚˜μš”?

좜처 : https://nomadcoders.co/fullstack-gpt