
【무료】 로컬 LLM으로 RAG 평가해 보기
요약
로컬 LLM 환경에서 RAG(Retrieval-Augmented Generation)의 성능을 직접 구현하여 평가하는 방법을 다룹니다. Retrieval과 Generation 두 가지 측면에서 평가 지표를 설정하고, 데이터베이스를 활용해 평가 메커니즘을 구축하는 과정을 설명합니다.
핵심 포인트
- RAG 평가의 핵심인 Retrieval과 Generation 지표 이해
- RAGAS 라이브러리 대신 직접 평가 메커니즘 구현
- 로컬 환경(RTX 3050 Ti)에서의 실습 방법 제시
- 임베딩 및 벡터 검색을 통한 Retrieval 성능 측정
-
Windows 11 Home 25H2
-
RAM 16.0GB
-
NVIDIA GeForce RTX 3050 Ti Laptop GPU (4 GB)
-
Oracle 26ai free
-
Python
-
Python 초보자
-
AI 초보자
-
평소 Oracle을 사용하고 있는 사람
AI 학습의 시작으로서, 무료이면서 로컬에서 부담 없이 시도해 볼 수 있는 환경에서 동작 확인을 진행하겠습니다.
지난번에는 아오조라 분코(青空文庫)의 소설을 요약하려고 시도하다가 할루시네이션 (Hallucination)을 일으키는 부분까지 확인했습니다.
그렇기에 정밀도를 향상시키고 싶지만, 그 이전에 무엇을 근거로 정밀도가 높다고 간주할 것인지에 대한 지표가 필요합니다.
인간의 주관도 괜찮지만, RAG에서는 평가 메커니즘이 있으므로 이번에는 그것을 구현해 보겠습니다.
RAGAS라는 라이브러리를 사용하는 것이 일반적이지만, 메커니즘을 이해하기 위해 직접 구현해 보겠습니다.
RAG의 평가 지표는 몇 가지가 있지만, 크게 나누면 두 종류입니다.
- Retrieval 평가
⇒ embedding (임베딩)하여 벡터 검색을 했을 때 올바른 청크 (Chunk)를 취득할 수 있는가
질문에 대한 정답이 본문의 어느 부분에 있는지를 올바르게 특정하는 능력과 같은 것입니다.
- Generation 평가
⇒ RAG가 생성한 답변이 정말로 올바른가
지난번과 마찬가지로, 우선 텍스트를 읽어 들여 청크 (Chunk)로 분할하고, 벡터 데이터베이스 (Vector Database)에 저장합니다.
먼저 청크 저장 테이블을 만들어 둡니다.
CREATE TABLE text_documents (
id NUMBER GENERATED ALWAYS AS IDENTITY,
source_file VARCHAR2(200),
...
텍스트는 너무 장문이면 시간이 매우 오래 걸리기 때문에, 이번에는 단편 소설인 「다곤」을 채택했습니다.
import glob
import os
from sentence_transformers import SentenceTransformer
...
다음으로 평가용 데이터를 생성합니다.
사전에 저장용 테이블을 만들어 둡니다.
질문과 답변, 그리고 참고용으로 답변의 근거, 청크 ID (text_documents의 id) 등을 컬럼으로 갖게 합니다. generated_answer는 여기서는 투입하지 않고 나중에 투입합니다.
외래 키 (Foreign Key)는 설정하지 않았지만, 개념적으로는 text_documents의 자식 테이블이 됩니다.
CREATE TABLE rag_evaluation
(
evaluation_id NUMBER GENERATED ALWAYS AS IDENTITY,
...
평가 데이터는 사람이 만들어도 되지만, 이번에는 LLM에게 JSON 문자열로서 만들게 해 보겠습니다.
LLM에 따라 다르지만, 포맷이 꽤 흔들렸기 때문에 아주 엄격하게 고정해 두었습니다. qwen에서는 잘 되지 않았습니다.
하나의 청크 (Chunk)마다 하나의 질문, 답변, 답변 근거를 생성하게 하고 있습니다.
참고로 여기서 생성한 질문이나 답변의 내용에 대해서는 실제로는 잘못된 것도 출력되지만, 이후에는 올바른 것으로 취급하겠습니다.
LLM을 평가하고 있는 것이 아니라 RAG를 평가하고 있는 것이기 때문에, 청크 (Chunk) 설계나 embedding (임베딩) 모델, 벡터 검색 등의 정밀도를 확인하는 것입니다.
먼저 Retrieval 평가에 사용할 테이블을 생성합니다.
...
CREATE TABLE rag_evaluation_candidate
(
evaluation_id NUMBER,
chunk_id NUMBER,
content CLOB,
rank NUMBER,
score NUMBER
);
질문문을 임베딩하여 본문을 벡터 검색하고, 가까운 것부터 10줄을 가져옵니다. (이번 경우에는 16줄밖에 없습니다만)
from sentence_transformers import SentenceTransformer
import oracledb
import json
if name == "main":
Oracle 연결
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
임베딩 모델
model = SentenceTransformer('intfloat/multilingual-e5-base')
질문 가져오기
select_cursor = conn.cursor()
insert_cursor = conn.cursor()
select_sql = """
SELECT
evaluation_id,
question
FROM rag_evaluation
"""
rows = select_cursor.execute(select_sql)
try:
질문 하나씩 처리
for evaluation_id, question in rows:
질문을 임베딩하여 리스트로 만들기
query_embedding = model.encode(
f"query: {question}"
).tolist()
벡터 검색으로 질문문과 가까운 content를 검색하기 위해 커서 재사용
select_cursor2 = conn.cursor()
select_sql2 = """
SELECT
id,
content,
VECTOR_DISTANCE(
embedding,
TO_VECTOR(:vec),
COSINE
)
score
FROM text_documents
ORDER BY score
FETCH FIRST 10 ROWS ONLY
"""
json 문자열로 만들어 실행할 파라미터 준비
params = [
json.dumps(query_embedding)
]
select_cursor2.execute(
select_sql2,
params
)
rank = 1
for result in select_cursor2:
insert_cursor.execute(
"""
INSERT INTO rag_evaluation_candidate
(
evaluation_id,
chunk_id,
content,
score,
rank
)
VALUES
(:1,
:2,
:3,
:4,
:5
)
"""
[
evaluation_id,
result[0],
result[1],
result[2],
rank
]
)
rank += 1
conn.commit()
except Exception as e:
print(f"오류: {e}")
finally:
select_cursor.close()
insert_cursor.close()
conn.close()
rag_evaluation_candidate 안에 올바른 청크, 즉 질문과 관련된 청크가 포함되어 있다면 괜찮습니다.
"EVALUATION_ID","CHUNK_ID","CONTENT","RANK","SCORE"
10,10,"특히 눈길을 끈 것은 조각의 문양이었다. 거대한 크기 덕분에 사이에 강이 끼어 있음에도 분명하게 볼 수 있었으나, 돌의 표면에는 얕은 부조(low relief) 조각들이 늘어서 있었다. 그 모티프는 화가 드레(Doré)를 질투하게 만들 정도였다. 조각은 인간, 적어도 어떤 종류의 인간을 나타내는 듯했다. 다만 그 '인간'은 해저 동굴에서 물고기처럼 노닐며, 파도 아래에 있다고 생각되는 모놀리스(monolith) 제단을 경배하고 있는 것처럼 보였다. 그들의 얼굴이나 모습을 자세히 설명하고 싶지는 않다. 떠올리는 것만으로도 아득해지기 때문이다. 포(Poe)나 블로워(Brewster) 같은 작가들의 상상력조차 미치지 못할 정도로 그들은 그로테스크(grotesque)했지만, 화가 나게도 전체적인 윤곽은 인간과 매우 닮아 있었다. 물갈퀴가 있는 손발, 놀라울 정도로 크고 처진 입술, 부릅뜬 유리 같은 눈동자, 그 외에도 떠올리는 것만으로도 불쾌한 특징들에도 불구하고 말이다. 기이하게도 '인간'과 그 배경이 되는 조각의 크기는 심하게 균형이 맞지 않는 듯했다. 예를 들어, 그들 중 한 명이 고래를 죽이고 있는 장면에서 고래는 인간보다 아주 조금 더 클 뿐이었다. 앞서 말했듯이, 그들은 그로테스크하고 기이하게 컸다.",1,0.1691587231148899
10,9,"공포로 망연자실하는 한편, 과학자나 고고학자가 느낄 법한 약간의 스릴을 느끼며 주변을 더욱 상세히 조사해 보았다. 달은 이제 천장 근처에 있었고, 계곡을 둘러싼 높은 절벽 위를 묘하고 선명하게 비추고 있었으며, 계곡 바닥에는 넓은 강이 흐르고 있다는 것을 알 수 있었다. 강은 굽이쳐 흐르고 있어 상류와 하류가 보이지 않았다. 그리고 물은 경사면에 서 있는 발치까지 차올라 있었다. 계곡 너머에서는 거대한 모놀리스(monolith)의 토대도 파도에 씻기고 있었다. 모놀리스의 표면에 문자나 거친 조각이 새겨져 있는 것을 볼 수 있었다. 비문은 상형문자로 쓰여 있었으나 내가 아는 것이 아니었으며, 또한 책에서 보았던 그 어떤 것과도 달랐다. 문자의 대부분은 물고기, 장어, 문어, 갑각류, 연체동물, 고래 등 수생 동물을 양식화하여 나타내고 있었다. 문자 중에는 해저 융기로 인해 생긴 평지에서 부패한 사체를 목격한 것 외에는 현대 세계에서는 알려지지 않은 해양 생물을 나타내는 듯한 것도 있었다.",2,0.19349706007397593
10,8,"달이 하늘 높이 떠오름에 따라, 계곡의 경사가 생각보다 가파르지 않다는 것을 알게 되었다. 암반이나 노출된 돌들이 내려가기에 적합한 발판이 되어 주었고, 수백 피트의 가파른 내리막을 지나면 경사는 완만해지는 듯했다. 불가해한 충동에 휩싸여 힘들게 바위를 타고 내려가 그 아래의 완만한 경사면에 섰다. 그리고 아직 빛이 비치지 않은 칠흑 같은 바닥을 들여다보았다.
문득 주의를 끈 것은 전방 약 100야드 지점에 우뚝 솟아 있는 맞은편 경사면의 크고 기이한 물체였다. 그것은 점점 높아지는 달빛을 받아 하얗게 빛나고 있었다. 그것이 거대한 암석이라는 것은 금방 알 수 있었다. 하지만 그 형태나 위치가 자연의 힘만으로 만들어진 것이 아니라는 인상도 강하게 받았다. 더욱 자세히 살펴보는 동안 형언할 수 없는 감각을 느꼈다. 엄청나게 거대하고, 해저에 뻥 뚫린 구덩이에 지구가 형성된 지 얼마 되지 않았을 때부터 존재해 왔음에도 불구하고, 그 기이한 물체는 형태가 잡힌 모놀리스(monolith)였으며, 그 거대한 몸체는 과거에 지적 생명체에 의해 가공되었고 아마도 숭배의 대상이었음이 틀림없어 보였다.",3,0.20512105145288861
10,14,"밤, 특히 달이 차기 시작하여 보름달이나 반달이 될 무렵, 그 거인이 보인다. 모르핀(morphine)을 시도해 보았으나 약물이 준 것은 일시적인 안식뿐이었고, 나는 절망적인 노예처럼 약 없이는 살 수 없게 되어 버렸다. 그래서 이제 모든 것을 끝낼 생각이다. 사건의 전말은 다 써 두었다. 동료들에게 어떤 참고가 될지, 아니면 단순히 바보 취급을 받으며 비웃음을 살지도 모르겠다. 자주 스스로에게 묻는다. 저곳에서 본 것은 전부가 아니더라도, 단순한 환각이 아니었을까? 독일 군함에서 탈출한 후, 가로막는 것 없는 보트 위에서 태양 빛에 노출되어 헛소리를 지껄이며 보았던 환각이 아니었을까?"
이렇게 자문하면, 그에 답하듯 무시무시한 이미지가 언제나 선명하게 떠오른다. 심해에 대해 생각하려고 하면, 그 이름 없는 생명체가 생각나 몸서리쳐진다. 바로 이 순간에도, 미끈거리는 해저를 허우적거리며 기어 다니고 있을지도 모른다. 돌로 된 태고의 우상을 숭배하며, 물에 젖은 화강암으로 된 해저의 오벨리스크에 그들 자신의 끔찍한 모습을 새기고 있는지도 모른다. 그들이 파도 위로 올라와, 악취 나는 갈고리 발톱으로 전쟁에 지친 나약한 인류의 생존자들을 끌어내리는 날이 보인다.
상당한 스트레스를 느끼며 이 글을 쓰고 있다. 오늘 밤이면 나는 이미 살아있지 않을 것이다. 돈도, 유일한 희망인 약도 다 떨어졌다. 더 이상 고통을 견딜 수 없다. 이 다락방 창문에서 아래의 지저분한 거리로 몸을 던지기로 하겠다. 모르핀 중독 때문에 몸이 약해지고 정신도 타락했다고 생각하지 말아주길 바란다. 난잡하게 휘갈겨 쓴 이 문장을 읽어본다면, 완전히 이해하는 것은 무리일지라도 도대체 왜 내가 망각과 죽음을 바라는지는 짐작할 수 있을 것이다.
화물 감독관으로서 승선했던 정기선이 독일의 습격정에 붙잡힌 것은, 넓은 태평양 중에서도 유독 광활하고 배의 왕래가 거의 없는 해역이었다. 대전(大戰)은 막 시작된 참이었고, 독일인들의 해군도 나중에처럼 완전히 몰락한 상태는 아니었다. 배는 합법적인 전리품이 되었다고는 하나, 승무원들은 해군의 포로로서 그에 걸맞은 공정함과 배려를 받으며 대우받았다. 그들의 군기가 실로 너그러웠던 덕분에, 나포된 지 닷새 후, 작은 보트에 장기간 버틸 수 있는 물과 식량을 싣고 홀로 도망칠 수 있었다.
예를 들어, 그들 중 한 명이 고래를 잡고 있는 장면에서, 고래는 인간보다 아주 조금 더 클 뿐이다. 앞서 말했듯이, 그들은 기괴하고 이상할 정도로 컸다. 하지만 곧 그것들이 원시적인 어업·해양 민족이 만들어낸 상상 속의 신들이라고 생각했다. 필트다운인이나 네안데르탈인이 탄생하기 수많은 시대 전 멸종한 어떤 종족의 것이 분명했다. 가장 대담한 인류학자조차 생각지 못할 과거의 세계를 뜻밖에 엿보고 경외심에 사로잡혔다. 생각에 잠겨 멍하니 서 있자, 눈앞을 조용히 흐르는 강물에 달빛이 기묘한 그림자를 드리웠다.
그때, 갑자기 나는 그것을 보았다. 수면을 미세하게 출렁이며 부상해 온 그것은, 어두운 수면을 뚫고 시야 속으로 미끄러져 들어왔다. 폴리페무스처럼 끔찍한 그 거인은, 악몽에 나오는 무시무시한 괴물처럼 모놀리스(Monolith)를 향해 돌진했다. 비늘이 돋은 거대한 팔을 모놀리스에 휘감자, 거인은 추악한 머리를 숙이고 느릿하고 일정한 목소리를 내뱉었다. 나는 그때 미쳐버린 것이라고 생각한다.
반광란 상태로 경사면과 절벽을 오르고, 좌초된 보트가 있는 곳으로 정신없이 되돌아갔지만, 그때의 일은 거의 기억나지 않는다. 많은 노래를 불렀고, 노래를 부를 수 없을 때는 기묘한 웃음소리를 냈던 것 같다. 보트에 도착하고 얼마 지나지 않아 큰 폭풍이 몰아쳤던 것을 희미하게 기억한다. 적어도 천둥소리와, 자연이 가장 격렬하게 날뛸 때나 낼 법한 굉음을 들었던 것 같다.
겨우 자유를 얻어 표류하게 되었지만, 내가 어디에 있는지 전혀 알 수 없다. 뛰어난 항해사가 아니었기에, 태양과 별의 위치를 보고 적도의 약간 남쪽에 있다는 정도만 막연히 추측할 수밖에 없었다. 위도에 대해서는 전혀 알 수 없었고, 섬이나 해안선은 어디에서도 보이지 않았다. 맑은 날이 계속되었고, 타는 듯한 태양 아래서 며칠 동안 정처 없이 표류했다. 지나가는 배나 사람이 살 수 있는 육지의 해안에 떠밀려오기를 기다렸다. 하지만 배도 육지도 보이지 않았고, 끝없는 바다의 광활한 너울 속에 고립된 상태에 절망하기 시작했다.
상황이 바뀐 것은 잠든 사이였다. 무슨 일이 일어났는지 자세히는 모른다. 꿈에 시달려 깊이 잠들지는 못했지만, 계속 비몽사몽한 상태였기 때문이다. 겨우 눈을 떴을 때, 몸의 절반이 새카만 진흙의 끈적끈적함 속에 삼켜져 있었다. 둘러보아도 그 진흙탕은 단조로운 기복으로서 주변에 펼쳐져 있었다. 조금 떨어진 곳에는 보트가 좌초되어 있었다.
터무니없고 예상치 못한 풍경의 변화에 우선은 놀랐을 것이라고 생각할지도 모른다. 하지만 사실 놀라움보다는 공포가 더 컸다. 주변의 공기와 썩은 진흙에서 불길한 기운이 느껴져 몸의 중심까지 얼어붙는 것 같았다. 주변에는 지독한 악취가 감돌았고, 썩은 죽은 물고기나, 보기에도 뭐라 말할 수 없는 것들의 사체가 끝없이 펼쳐진 불결한 진흙 평원에서 삐져나와 있었다. 완전한 정적 속, 불모의 무한한 공간에 깃든 말로 다 할 수 없는 공포는 어쩌면 말만으로는 전달되지 않을지도 모른다. 아무것도 들리지 않고, 온통 펼쳐진 검은 진흙 외에는 아무것도 보이지 않는다. 그럼에도 주변의 정적이 완전하다는 것과 풍경이 단조롭다는 것, 바로 그것들이 마음에 무겁게 짓눌려 구역질이 날 것 같은 공포를 느끼게 했다.
이유는 모르겠지만 그날 밤은 광기 어린 꿈을 꾸었다. 차오르기 시작하는 환상적인 형태의 달이 동쪽 평원에 높이 뜨기 전에, 식은땀을 흘리며 눈을 떴고 다시는 잠들지 않기로 결심했다. 방금 본 꿈을 다시 보는 것은 도저히 견딜 수 없기 때문이다. 달빛을 받으며 낮 동안 걸어온 것은 바보 같은 짓이었다고 생각했다. 작열하는 태양이 없었다면 걷는 것이 훨씬 수월했을 것이다. 실제로 일몰 때는 망설였지만, 지금이라면 언덕에 올라갈 수 있을 것 같은 기분이 들었다. 짐을 챙겨 언덕 정상를 목표로 출발했다.
기복이 있는 평원의 끊임없는 단조로움 때문에 말로 다 할 수 없는 공포를 느낀다는 것은 이전에 썼다. 하지만 정상에 도착해 언덕 반대편에 있는 끝없이 깊은 협곡을 내려다보았을 때의 공포는 훨씬 더 심했다. 달은 아직 낮게 떠 있어 어두운 협곡 깊은 곳까지 비추지는 못했다. 마치 자신이 세상의 끝에 서서, 그 가장자리에서 영원히 끝나지 않는 밤의 밑바닥 없는 혼돈을 들여다보고 있는 것처럼 느껴졌다. 기묘하게도 공포를 느끼면서도, '실낙원'과 형체 없는 어둠의 나라에서 올라오는 무시무시한 마왕의 모습이 마음속에 떠올랐다.
참고로 처음부터 10행이 아니라 1행이면 안 되느냐고 묻는다면, 질문과 관련된 청크(Chunk)가 여러 개 존재할 경우 하나의 청크에 단 하나의 정답만 있는 것은 이상하기 때문입니다. (이번에는 하나의 청크에서 정답을 생성하고 있지만 일반적으로는 그렇지 않습니다)
...
SELECT
TRUNC(HIT1/TOTAL*100,1) || '%' AS HIT1,
TRUNC(HIT5/TOTAL*100,1) || '%' AS HIT5,
TRUNC(HIT10/TOTAL*100,1) || '%' AS HIT10
FROM (
SELECT
COUNT(*) total,
SUM(
CASE
WHEN c.rank <= 1 THEN 1
ELSE 0
END
) hit1,
SUM(
CASE
WHEN c.rank <= 5 THEN 1
ELSE 0
END
) hit5,
SUM(
CASE
WHEN c.rank <= 10 THEN 1
ELSE 0
END
) hit10
FROM rag_evaluation e
LEFT JOIN rag_evaluation_candidate c
ON e.evaluation_id=c.evaluation_id
AND e.source_chunk_id=c.chunk_id
);
출력은 다음과 같았습니다.
...
"HIT1","HIT5","HIT10"
"62.5%","81.2%","93.7%"
먼저 질문을 벡터화(Vectorization)한 뒤, 질문 문장과 유사한 본문 내용을 벡터 검색(Vector Search)합니다.
...
from sentence_transformers import SentenceTransformer
from ollama import chat
import oracledb
import json
if __name__ == "__main__":
# Oracle 연결
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
# 임베딩 모델
model = SentenceTransformer('intfloat/multilingual-e5-base')
# 질문 가져오기
select_cursor = conn.cursor()
update_cursor = conn.cursor()
select_sql = """
SELECT
evaluation_id,
question
FROM rag_evaluation
"""
rows = select_cursor.execute(select_sql)
try:
chunks = []
i = 0
# 질문 하나씩 처리
for evaluation_id, question in rows:
# 질문을 임베딩하여 리스트로 만들기
query_embedding = model.encode(
f"query: {question}"
).tolist()
# 벡터 검색으로 질문 문장과 유사한 content를 검색하기
select_cursor2 = conn.cursor()
select_sql2 = """
SELECT
id,
content,
VECTOR_DISTANCE(
embedding,
TO_VECTOR(:vec),
COSINE
)
score
FROM text_documents
ORDER BY score
FETCH FIRST 3 ROWS ONLY
"""
# json 문자열로 만들어 실행
params = [
json.dumps(query_embedding)
]
select_cursor2.execute(
select_sql2,
params
)
select_cursor2.execute(select_sql2)
res = select_cursor2.fetchall()
# select 결과를 chunks에 저장하기
for r in res:
chunks.append(r[1].read())
prompt = f"""
아래 문서를 근거로 질문에 답변해 주세요.
문서 1:
{chunks[0]}
문서 2:
{chunks[1]}
문서 3:
{chunks[2]}
질문:
{question}
답변:
"""
response = chat(
model="gemma3:4b",
messages=[
{
"role": "user",
"content": prompt
}
]
)
generated_answer = response["message"]["content"]
print("====================")
print(evaluation_id)
print(question)
print(generated_answer)
print("====================")
update_cursor.execute(
"""
UPDATE RAG_EVALUATION
SET GENERATED_ANSWER = :1
WHERE EVALUATION_ID = :2
"""
[
generated_answer,
evaluation_id
]
)
i = i + 1
if i % 10 == 0:
conn.commit()
conn.commit()
except Exception as e:
print(f"에러: {e}")
finally:
select_cursor.close()
select_cursor2.close()
update_cursor.close()
conn.close()
10
조각의 모티프는 어떤 특징을 가지고 있었는가?
조각의 모티프는 그로테스크하고 이상하게 크며, 물갈퀴가 있는 손발, 놀라울 정도로 크고 처진 입술, 부릅뜬 유리 같은 눈동자 등 기분 나쁜 특징을 가지고 있었습니다. 또한, '인간'의 모습을 하고 있었으나, 해저 동굴에서 물고기처럼 노닐며 파도 아래에 있는 모놀리스(Monolith) 제단을 경배하고 있는 것처럼 보였습니다.
RAG로 생성한 답변(generated_answer)과 첫 번째 답변(answer)을 비교함으로써, 답변이 올바른지 여부를 점수화해 나갑니다.
...
CREATE TABLE rag_evaluation_score(
EVALUATION_ID NUMBER,
BERT_PRECISION NUMBER,
BERT_RECALL NUMBER,
BERT_F1 NUMBER,
EMB_SIMILARITY NUMBER,
LLM_SCORE NUMBER,
LLM_REASON CLOB,
LLM_CORRECTNESS VARCHAR2(4000),
LLM_MISSING VARCHAR2(4000),
LLM_HALLUCINATION VARCHAR2(4000)
)
;
평가 지표 중 하나인 BERTScore는 답변을 벡터 공간(Vector Space) 상에서 비교하므로, 의미가 가까우면 높은 점수를 받게 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기