
지금까지 기능 구현, 리팩토링, IntelliJ Platform SDK 분석 등 다양한 작업을 하였다.
이번엔 플러그인을 제작할 때 Test 코드를 작성하는 방법에 다뤄보고자 한다.
다소 삽질을 많이 한 부분이기도 한다. 그러다보니, 하고 싶은 얘기가 많지만 이번엔 좀 컴팩트하게 작성하고자 한다. (이전 글들이 너무 길어서 나말곤 아무도 안읽을 느낌..)
공식문서에선 인텔리제이 플랫폼을 구성하고 있는 코드들이 어떤 방식으로 테스트되었는지 서술하고 있다.
우선, 위 사진 속 문장들을 해석해보자.
Testing Overview 간단 요약
- 대부분의 테스트는 mock객체 대신 실제 구현체를 사용하는 Headless 환경에서 실행된다.
(Headless 환경: GUI나 디스플레이 출력 없이 동작하는 환경)
- 테스트들은 일반적으로 개별 함수보단 기능 전체를 검증한다.
- 테스트들은 Swing UI를 테스트하는 것이 아닌 기본 모델을 직접 테스트한다.
- 대부분의 테스트들은 파일로 입력 데이터를 받아 기능을 실행한 후, 예상되는 결과와 비교한다.
대략 인텔리제이 플랫폼 내부의 코드들은 통합 테스트를 지향하고 있는 것으로 보여진다.
4번 항목에서 이러한 관점이 잘 드러난다. 아마 실제와 유사한 테스트 환경을 구성하기 위해 파일을 입력데이터로 사용하는 것으로 느껴진다.
이 문장을 살펴보면, 인텔리제이 측에서 이러한 방법을 애용하는 이유를 서술하고 있다. 간단히 살펴보면 "테스트가 매우 안정적이고, 구현이 변하더라도 테스트 자체의 유지보수는 필요 없다."는 것을 말하고 있다. 일반적으로 통합 테스트의 장점과 직결되는 내용이다.
Light and Heavy Tests 챕터에 테스트를 진행하는 방법에 대해 간략히 나와있다.
플러그인 테스트는 모킹보단 실제 인텔리제이 플랫폼 환경에서 수행되며 실제 프로덕션의 구현(서비스 객체)을 사용한다.
이 말은 즉, 테스트를 수행하기 전에 미리 인텔리제이 플랫폼의 환경을 미리 로드해두어 한다는 것과 같다. 이 때 테스트 시 필요한 환경을 구성하는 방법에 따라서 "Light Test"와 "Heavy Test"로 나누어진다.
"Light Test"는 이전 테스트에서 사용한 프로젝트를 가능한 경우 재사용하는 방식이다. 이는 테스트 실행 속도를 높이지만, 테스트 간 격리가 완벽하지 않을 수 있다.
반면 "Heavy Test"는 매 테스트마다 새로운 프로젝트를 만드는 방식으로, 완벽한 격리를 보장하지만 실행 속도가 느리다.
인텔리제이 플랫폼에선 성능 상의 이유로 "Light Test"를 추천한다.
Java 코드 구조나 특화 기능에 의존하지 않는 테스트라면, LightPlatformTestCase나 BasePlatformTestCase를 사용하면 된다.
현재 제작 중인 플러그인은 Java 코드 자체의 특화된 기능(ex. 코드 하이라이팅 등)에 의존하지 않기 때문에 위 두 클래스 중 하나를 상속받아 테스트를 작성하면 된다.
해당 페이지의 "Examples"에서 실제 테스트 코드의 예시들을 확인할 수 있다.
위 사진과 같이, 공식문서의 링크에 존재하는 3개의 테스트 코드들에서는 모두 BasePlatformTestCase를 상속받아 구현하고 있다.
BasePlatformTestCase와 LightPlatformTestCase의 차이점은 공식 문서에서 직접적으로 언급되지 않았지만, 코드를 살펴보면 그 차이를 명확하게 알 수 있다.
이 사진을 보면, BasePlatformTestCase에는 myFixture라는 필드 하나만 존재하는 반면, LightPlatformTestCase에는 비교적 많은 필드들이 존재하고, 조작되는 것을 볼 수 있다.
Test코드에서 말하는 "Fixture"란 "테스트를 위해서 미리 만들어둔 변경되지 않는 상태와 데이터"를 의미한다. 즉, 인텔리제이 플랫폼에서의 "Fixture"란 프로젝트 구조, 파일 시스템, 에디터 등을 모킹하여 실제 IDE 환경을 시뮬레이션하는 객체이다.
BasePlatformTestCase에서 필드로 사용하는 CodeInsightTestFixture인터페이스를 살펴보면 실제로 IDE환경을 시뮬에이션하기 위해 필요한 굉장히 많은 메서드들이 존재한다.
이러한 차이를 고려하였을 때 BasePlatformTestCase 와 LightPlatformTestCase의 차이점은 다음과 같이 정리할 수 있다.
BasePlatformTestCase
CodeInsightTestFixture를 통해 프로젝트 설정과 테스트 환경을 관리- 더 높은 수준의 추상화와 편의성 제공
- 대부분의 플러그인 테스트에 권장됨
LightPlatformTestCase
- Fixture를 직접 사용하지 않고, 프로젝트와 모듈을 직접 관리
- 더 낮은 수준의 제어를 제공
- 특정 상황에서 더 세밀한 제어가 필요할 때 사용
경우에 따라 다를 순 있겠지만, 우리도 예제들과 같이 BasePlatformTestCase를 사용하여 테스트 코드를 작성하면 될 것으로 보인다.
BasePlatformTestCase와 LightPlatformTestCase는 모두 위 사진의 UsefulTestCase를 상속받아 구현하고 있다.
그런데 이 코드가 상속받는 TestCase라는 클래스는 Junit4에서 제공하고 있는 구현체이다.
즉, 우리는 BasePlatformTestCase를 사용할 때 JUnit 4 기반으로 테스트 코드를 작성해야 한다는 점에 주의해야 한다.
JUnit4에서 JUnit5로 버전이 넘어오면서 많은 것이 바뀌었기 때문에, 약간의 제한 사항도 있고 새로 학습해야 하는 부분도 있다.
개인적으론 @DisplayName을 사용할 수 없어 약간 불편하였다.
또한, 사진처럼 Settings > Build, Excution, Deployment > Gradle의 Run tests using을 "Gradle"로 설정하고 수행해야 동작한다.
이제 기초적인 부분은 모두 알아보았다. 이젠 실제 프로덕션 코드에 대해서 Test코드를 작성해보자.
//알림 기능 구현
@Service(Service.Level.PROJECT)
class NotificationService(private val project: Project) {
fun show(commitMessageNotification: CommitMessageNotification, vararg args: Any?) {
val formattedContent = commitMessageNotification.content.format(*args)
show(commitMessageNotification.title, formattedContent, commitMessageNotification.type)
}
private fun show(title: String, content: String, type: NotificationType) {
NotificationGroupManager.getInstance()
.getNotificationGroup(NotificationConstants.NOTIFICATION_GROUP_ID)
.createNotification(createTitle(title), content, type)
.notify(project)
}
private fun createTitle(title: String): String {
return "${NotificationConstants.PLUGIN_NAME}: $title"
}
}
//알림에 사용될 데이터를 모아두는 일종의 데이터 홀더
enum class CommitMessageNotification(
val title: String,
val content: String,
val type: NotificationType
) {
NO_CHANGES_DETECTED(
"No Changes Detected",
"There are no changes to generate a commit message for.",
NotificationType.WARNING
),
PROJECT_NOT_FOUND(
"Project Not Found",
"Current project is not recognized or is null. Failed to generate commit message.",
NotificationType.ERROR
),
UNEXPECTED_ERROR(
"Unexpected Error",
"An unexpected error occurred. Please check the logs for more details.",
NotificationType.ERROR
),
COMMIT_MESSAGE_GENERATED(
"Commit Message Generated",
"Your commit message has been successfully generated.",
NotificationType.INFORMATION
);
}
첫 번째로 알림 기능에 대한 테스트를 진행해보고자 한다.
현재 NotificationService는 CommitMessageNotification Enum타입을 인자로 받아서 해당 데이터에 맞는 알림을 생성하는 동작을 수행하고 있다.
이 과정에서 NotificationGroupManager를 통해 알림 그룹을 가져와서 알림을 전송하는 동작이 존재하며, Enum타입에 존재하는 데이터를 포맷팅하는 과정도 필요하다.
따라서 우리가 알림 기능에서 테스트해야 되는 동작은 다음과 같다.
알림 기능에서 테스트 해야 될 동작
- 알림이 정상적으로 표시되는지 확인
- 알림 제목/내용이 올바르게 포맷팅되는지 확인
- 알림 타입이 올바르게 설정되는지 확인
이러한 내용을 바탕으로 테스트 코드를 작성해보자.
class NotificationServiceTest : BasePlatformTestCase() {
private lateinit var project: Project
private lateinit var notificationService: NotificationService
private val notifiedMessages = mutableListOf<Triple<String, String, NotificationType>>()
fun test_COMMIT_MESSAGE_GENERATED() {
// given
val notification = CommitMessageNotification.COMMIT_MESSAGE_GENERATED
val arg1 = "arg1"
val arg2 = "arg2"
// when
val (title, content, type) = doTest(notification, arg1, arg2)
// then
assertEquals("${NotificationConstants.PLUGIN_NAME}: ${notification.title}", title)
assertEquals(notification.content.format(arg1, arg2), content)
assertEquals(NotificationType.INFORMATION, type)
}
fun test_NO_CHANGES_DETECTED() {
// given
val notification = CommitMessageNotification.NO_CHANGES_DETECTED
// when
val (title, content, type) = doTest(notification)
// then
assertEquals("${NotificationConstants.PLUGIN_NAME}: ${notification.title}", title)
assertEquals(notification.content, content)
assertEquals(NotificationType.WARNING, type)
}
private fun doTest(
notification: CommitMessageNotification,
vararg args: Any?
): Triple<String, String, NotificationType> {
notificationService.show(notification, *args)
//UI이벤트를 처리
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
assertTrue("알림이 표시되지 않았습니다", notifiedMessages.isNotEmpty())
return notifiedMessages.last()
}
override fun setUp() {
super.setUp()
project = myFixture.project
notificationService = NotificationService(project)
//messageBus를 가로챈 다음, notify메서드를 오버라이드해서 알림을 List에 넣도록 만듦
val messageBus = project.messageBus
messageBus.connect().subscribe(Notifications.TOPIC, object : Notifications {
override fun notify(notification: Notification) {
notifiedMessages.add(Triple(notification.title, notification.content, notification.type))
}
})
}
override fun tearDown() {
notifiedMessages.clear()
super.tearDown()
}
}
CommitMessageNotification Enum 타입에 들어있는 데이터에 따라 알림이 제대로 생성되는지 확인하는 테스트 코드이다. 여기서는 "체크한 변경사항이 없을 때"와 "커밋 메시지가 생성되었을 떄" 두 케이스를 가정하여 테스트를 진행한다.
Junit4를 기반으로 작성하였으며, test 접두사 대신에 @Test 어노테이션을 사용하면 예외가 발생한다. 또한 @DiplayName도 지원하지 않는다. 하지만 이런 제약 사항에도 불구하고 BasePlatformTestCase를 쓰는 것이 테스트 환경을 구성하기엔 편리하다.
이제부터 알림 기능을 테스트하기 위해 어떻게 코드를 구성하였는지 자세히 살펴보자.
인텔리제이에서 알림창이 뜨는 동작은 "UI 이벤트"이다. 하지만, 앞서 언급했듯이 테스트들은 Headless환경에서 이루어져야 한다. 물론 UI를 직접 테스트 하는 방법도 있겠지만 상당히 무거운 작업이 될 것이다.
그렇다면 어떻게 알림기능에 대해서 테스트를 할 수 있을까?
override fun setUp() {
super.setUp()
project = myFixture.project
notificationService = NotificationService(project)
//messageBus를 가로챈 다음, notify메서드를 오버라이드해서 알림을 List에 넣도록 만듦
val messageBus = project.messageBus
messageBus.connect().subscribe(Notifications.TOPIC, object : Notifications {
override fun notify(notification: Notification) {
notifiedMessages.add(Triple(notification.title, notification.content, notification.type))
}
})
}
그 방법은 위 setUp() 메서드처럼 "messageBus의 notify()를 적절히 오버라이드"하는 것이다.
인텔리제이 플랫폼에선 다음과 같은 절차로 알림 전송을 구현한다.
인텔리제이 플랫폼에서의 간단한 알림 전송 과정
NotificationGroupManager를 통해 알림 객체를 생성한다.
- 생성된 알림 객체는
messageBus에 실린다.
messageBus의 구독자들은 해당 알림 이벤트를 받는다.
- UI 쓰레드가 실제 알림창을 띄운다.
우리는 여기서 3번에 해당하는 동작을 적절히 테스트 환경에 맞게 오버라이드하여 알림을 가로채도록 만들 수 있다.
정리하자면 아래와 같은 절차를 통해 UI 이벤트를 띄우지 않더라도 알림 기능을 테스트할 수 있다.
UI 이벤트를 배제한 알림 테스트 방법
- 테스트 셋업 단계에서
messageBus를 구독한다.
notify()메서드를 오버라이드하여 알림 정보를 리스트에 저장하도록 만든다.
- 테스트에서 알림을 발생시키는 메서드를 호출한다.
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()를 호출하여 모든 이벤트가 처리되도록 한다.
- 저장된 알림 정보를 검증한다.
실제 인텔리제이의 환경을 크게 헤치지 않는 선에서 필요한 부분만 적절히 모킹하였기 때문에 적절한 방법이라고 생각한다.
이번엔 체크한 변경 내역을 토대로 Diff를 생성하는 DiffService에 대한 테스트 코드를 작성해보고자 한다.
먼저 DiffService부터 살펴보자.
@Service(Service.Level.PROJECT)
class DiffService(private val project: Project) {
fun getDiff(changes: List<Change>): String {
val summary = project.service<DiffSummaryGenerator>().generate(changes)
val detailedChanges = project.service<DiffDetailGenerator>().generate(changes)
return buildString {
appendLine(DiffConstants.DEFAULT_TITLE)
appendLine()
appendLine(summary)
appendLine()
appendLine(detailedChanges)
appendLine()
appendLine(DiffConstants.ADDITIONAL_NOTES)
}.trimEnd()
}
}
현재 DiffService의 구현은 Summary와 Detail을 생성하는 두 개의 구현체를 합치는 정도의 동작을 수행하고 있다. 실제 포맷팅을 하는 동작은 두 구현체에서 수행한다.
이러한 구조에서 테스트를 작성할 수 있는 방법은 크게 두 가지로 나눌 수 있다:
DiffSummaryGenerator,DiffDetailGenerator각각에 대해 테스트 진행DiffService전체에 대한 통합 테스트 진행
여기서는 2번 방법, 즉 DiffService 전체에 대한 통합 테스트를 진행하기로 결정했다.
class DiffServiceTest : AbstractDiffTest() {
private lateinit var diffService: DiffService
//테스트 데이터의 경로
override fun getRelativeTestDataPath(): String {
return "diffSummary"
}
override fun setUp() {
super.setUp()
diffService = DiffService(project)
}
fun test_비어있는_변경사항() {
val changes = emptyList<Change>()
val diff = diffService.getDiff(changes)
verifyResult("emptyTest", diff)
}
fun test_여러_변경사항() {
//변경 사항 생성
val addedChange = prepareChange("multipleTest/added")
val deletedChange = prepareChange("multipleTest/deleted")
val modifiedChange = prepareChange("multipleTest/modified", "Modified content")
val movedChange = prepareChange("multipleTest/moved")
//변경 사항을 통해 diff 추출
val changes = listOf(addedChange, deletedChange, modifiedChange, movedChange)
val diff = diffService.getDiff(changes)
//검증
verifyResult("multipleTest", diff)
}
}
테스트는 '변경 사항이 비어있을 때'와 '여러 변경 사항이 존재할 때' 두 시나리오에 대해 수행된다.
이번 테스트에서 중요한 점은 변경 내역(Change)을 어떻게 모킹할지였다.
여기서는 실제 파일들을 사용해 변경 내역을 구성하기로 결정했다. 즉, "Test Overview"에서 언급한 대로 파일을 통해 입력 및 검증 데이터를 구성하여 테스트를 진행한다.
이젠 이 테스트의 기반이 되는 AbstractDiffTest에 대해서 알아보자.
abstract class AbstractDiffTest : BasePlatformTestCase() {
//test 데이터의 디렉토리 명을 반환하도록 구현 필요
abstract fun getRelativeTestDataPath(): String
// 테스트 데이터의 전체 경로를 반환
override fun getTestDataPath() = "src/test/testData/${getRelativeTestDataPath()}"
//프로젝트에 테스트 데이터를 로드함.
override fun setUp() {
super.setUp()
myFixture.copyDirectoryToProject(".", "")
}
//일반적인 상태의 Change 객체를 생성
protected fun prepareChange(testName: String): Change {
//파일 가져오기
val beforeFile = FileUtils.copyFileIfExists(myFixture, "$testName/before.txt")
val afterFile = FileUtils.copyFileIfExists(myFixture, "$testName/after.txt")
// change 객체 생성
// before가 null인 경우, after가 null인 경우, 둘 다 null이 아닌 경우에 따라 상태 바뀜.
return Change(
beforeFile?.let { createContentRevision(it) },
afterFile?.let { createContentRevision(it) }
)
}
//MODIFICATION 상태의 Change 객체를 생성
protected fun prepareChange(testName: String, additionalContent: String): Change {
//원본 파일 가져오기
val originalPath = "$testName/before.txt"
val originalFile = FileUtils.copyFileIfExists(myFixture, originalPath)
?: throw IllegalStateException("테스트 $testName 에 대한 before.txt 파일이 없습니다.")
val originalContent = FileUtils.getFileContent(originalFile)
// 파일의 수정된 버전 생성
val modifiedFile = myFixture.copyFileToProject(originalPath, originalPath)
FileUtils.modifyFile(project, modifiedFile, additionalContent)
// 원본 상태를 나타내는 가상 파일 생성
val virtualOriginalFile = FileUtils.createVirtualFile(
name = modifiedFile.name,
content = originalContent,
path = modifiedFile.path,
url = modifiedFile.url
)
return Change(
createContentRevision(virtualOriginalFile),
createContentRevision(modifiedFile)
)
}
//결과물을 검증
protected fun verifyResult(testName: String, summary: String) {
val expected = myFixture.configureByFile("$testName/expected.txt").text
assertEquals(expected.trim(), summary.trim())
}
// 간단한 ContentRevision 생성 메서드
private fun createContentRevision(file: VirtualFile) = object : ContentRevision {
override fun getContent() = FileUtils.getFileContent(file).trimEnd()
override fun getFile() = LocalFilePath(file.path, file.isDirectory)
override fun getRevisionNumber() = VcsRevisionNumber.NULL
}
}
Diff의 테스트를 위한 기반을 Abstract 클래스에서 제공한다. 이러한 구현을 통해 테스트 재사용성을 확보할 수 있다.
언뜻 보기엔 약간 복잡해 보이지만, 결국 "입력데이터 생성"과 "검증"이라는 두 단계가 구현된 것이다.
우선 prepareChange() 메서드는 일반적인 상태의 Change를 생성하는 메서드와, "MODIFICATION" 상태의 Change를 반환하는 메서드 두 종류가 있다.
그리고 이 Change 객체들로 생성한 Diff를 검증하는 메서드가 있다.
여기서 핵심은 어떻게 Change 객체를 생성할 것이냐에 있다.
DiffService를 테스트하기 위해서는 Change 객체를 잘 만드는 것이 핵심이다. 우선 Change 객체의 생성자를 살펴보면, ContentRevision이라는 타입의 값이 파라미터에 필요하다는 것을 알 수 있다.
인텔리제이 플랫폼에서의 ContentRevision은 VCS에서 사용하는 파일에 대한 특정 버전을 나타내는 객체이다. 즉, Change 객체는 변경 내역을 나타내기 위해 파일의 이전 버전과 다음 버전을 인자로 받아 생성되는 객체인 것이다.
private fun createContentRevision(file: VirtualFile) = object : ContentRevision {
override fun getContent() = FileUtils.getFileContent(file).trimEnd()
override fun getFile() = LocalFilePath(file.path, file.isDirectory)
override fun getRevisionNumber() = VcsRevisionNumber.NULL
}
따라서, 우리는 Change 객체를 생성하기 위해 먼저 간단한 ContentRevision 객체부터 만들 필요가 있었다. 테스트 환경에선 버전 넘버는 초기화하지 않고, 컨텐츠와 파일 정보만 초기화하도록 하였다.
컨텐츠에는 해당 버전의 내용(코드, 텍스트)가 담기게 된다.
이 ContentRevision의 null여부에 따라서 Change의 상태가 결정된다.
object FileUtils {
// 파일 수정
fun modifyFile(project: Project, file: VirtualFile, additionalContent: String) {
WriteCommandAction.runWriteCommandAction(project) {
FileDocumentManager.getInstance().getDocument(file)?.let {
it.insertString(it.textLength, additionalContent)
FileDocumentManager.getInstance().saveDocument(it)
PsiDocumentManager.getInstance(project).commitDocument(it)
}
}
}
// 가상 파일 생성
fun createVirtualFile(name: String, content: String, path: String, url: String): VirtualFile =
object : LightVirtualFile(name, content) {
override fun getPath(): String = path
override fun getUrl(): String = url
}
// 파일 복사 (존재하는 경우에만)
fun copyFileIfExists(fixture: CodeInsightTestFixture, path: String): VirtualFile? =
fixture.tempDirFixture.getFile(path)?.let { fixture.copyFileToProject(path) }
// 파일 내용 가져오기
fun getFileContent(file: VirtualFile): String = file.contentsToByteArray().toString(Charsets.UTF_8)
}
파일에 관련된 부분은 유틸 객체로 분리하였다.
기본적으로 현재 프로젝트에서의 test파일 경로가 주어지면, fixture 객체를 통해서 테스트 환경 내부로 로드할 수 있다.
인텔리제이 플랫폼에서 제공하는 PSI니 VFS니 하는 것들을 활용하여 코드 상에서 파일을 변경하는 것도 가능하다.
이 부분까지 여기서 다루기엔 너무 많아 다루진 않겠다.
대략 "아 코드 레벨에서 이렇게 파일을 변경할 수 있고, 파일을 테스트 데이터로 쓰기 위해선 테스트 환경으로 로드해야 되는구나"정도로만 이해하면 될 것같다.
이것 이외에도 몇 개의 단위 테스트를 작성했었다. 하지만, 그것까지 블로그에 다루기엔 너무 내용이 많아질 것 같아 DiffService와 NotificationService에 대한 테스트 코드만 다뤘다.
하지만 이 글을 정독한 사람(아마 있으실지 모르겠다)이라면 "근데 이게 통합 테스트가 맞나? 테스트의 방향성이 맞나?"하는 의문을 가지실 수도 있을 것이다.
예를 들어, Change객체를 만들 때 ContentRevision이라는 객체를 간단히 만들어서 사용한 것 자체가 이미 모킹을 한 것이기 때문에 완전한 통합 테스트는 아니다.
왜 이렇게 할 수 밖에 없었을까?
이유는 우리 플러그인이 VCS(Version Control System)에 의존적이기 때문이다. VCS는 원래 인텔리제이 플랫폼 생태계 속한 것이 아닌 써드파티 모듈(Git4idea)에 가까웠다. 그러다보니 현재의 정형화된 구현이 정착한진 얼마 안된것으로 보인다.
인텔리제이 플러그인 FAQ에서도 초창기 인텔리제이의 VCS가 복잡하게 얽혀있었음을 확인할 수 있다.
일단 나 역시도 인텔리제이 커뮤니티에서 해당 문제에 대해 문의한 상태이다.
만약 코드 레벨에서 VCS의 기능까지 포함된 완전한 통합 테스트가 구축된다면, 향후 구현에 있어 매우 유용하게 쓰일 수 있을 것으로 보인다.
놀랍게도 젯브레인 측에서 빠르게 답변을 해주셨다. 결론부터 말하면, 가장 간단한 테스트 방법은 모킹을 사용하는 것이라고 하셨다.
플랫폼 내부에서의 커밋 작업엔 수많은 UI클래스가 초기화되어야 하기 때문에, 코드도 복잡해지고 시간도 오래걸린다고 말씀해주셨다.
모킹을 사용한 테스트는 인터페이스를 구현한 간단한 구현체만 만들면 해결되기 때문에 이미 진행이 된 상태였다.
인텔리제이 내부의 다른 기능들에 대해서는 자체적으로 테스트용 객체를 제공해주곤 했어서 약간은 기대했었지만, VCS에 관련해선 없나보다.
이젠 다시 새로운 구현을 이어나갈 생각이다. 다음에는 GPT와의 통신전에 간단하게 세팅창을 구성하고, 세팅값을 가져올 수 있는 방법에 대해 알아보고자 한다.