모종닷컴

Spring Shell을 이용해 나만의 CLI를 만들어보자 본문

Programming/Spring

Spring Shell을 이용해 나만의 CLI를 만들어보자

모종 2023. 1. 23. 17:17
반응형

스프링 프레임워크 기반 프로젝트를 계속하다 보니 문득 내가 모르는 스프링 프로젝트가 또 있나 싶어서 확인하다 보니 Spring Shell 프로젝트를 발견하게 되었습니다.

최근 프로젝트 테스트를 위한 방법을 조금 고민하고 있었는데 어쩌면 좋은 방안이 될 수 있을 것 같아 한번 사용해봤습니다. 

Spring Shell ?

모든 애플리케이션에 멋진 사용자 인터페이스가 필요한 것은 아니며, 경우에 따라 대화형 터미널을 통해 응용 프로그램과 상호 작용하는 것이 적절한 방법이 될 수 있습니다. 스프링 쉘에는 고급 기능(구문 분석, 탭 완성, 출력 색상 지정, 입력 변환 및 유효성 검사)이 포함되어 있어 핵심 명령 로직에 집중할 수 있습니다.

스프링 쉘을 써보기로 결정한 이유?

저의 경우 스프링 기반으로 만든 프로젝트가 상당히 많은데 테스트를 위해 개발자용 API를 만들고 이를 포스트맨에 저장하고 사용하고 있었습니다. 그러다 보니 테스트를 위한 개발자용 API를 계속 만들게 되었고, 적절한 권한 처리를 하였지만 여전히 API는 외부에 노출이 되고 있습니다. 또한 테스트를 하다 보면 여러 가지 개발용 API들을 조합해야 하는 경우도 있습니다. 특정 텍스트를 암호화하고 이 암호화된 데이터를 다시 특정 데이터 형태로 다듬어서 API를 호출하는 부분도 있습니다.

이를 스프링 쉘을 사용한다면 아래의 효과를 가질 수 있다고 생각했습니다.

  1. 사용 중인 메인 프로젝트가 동일한 스프링 프레임워크이므로 필요한 기능이나 코드는 단순 복사 붙여 넣기로 옮길 수 있어 편할 것이다. 
    -> 평소에는 파이썬 스크립트를 통해 테스트 로직을 작성하고 사용 중이었습니다.
  2. 메인 프로젝트에 무자비하게 추가되었던 개발자용 API를 어느 정도 줄일 수 있을 것이다.
  3. 테스트 시나리오를 작성해 놓으면 누구든 쉽게 사용할 수 있다.
    EX) 특정 문자를 암호화하고, 해당 암호화된 데이터를 이용하여 다시 적절한 데이터 형태로 만들어 개발 API에 전달한다

스프링 쉘 사용해 보기

build.gradle.kts

프로젝트를 생성했을 때 제너레이팅된 그레이들 빌드 스크립트입니다. logging이 필요해서 kotlin-logging 라이브러리만 추가했습니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.8"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}

extra["springShellVersion"] = "2.1.5"

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.springframework.shell:spring-shell-starter")
    implementation("io.github.microutils:kotlin-logging:3.0.4")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.shell:spring-shell-dependencies:${property("springShellVersion")}")
    }
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

command

먼저 간단한 Hello 커맨드를 만들어보았습니다.

@ShellComponent : 쉘 메서드를 포함하고 있는 클래스라는 것을 스프링에게 알려주기 위한 애노테이션
@ShellMethod : 스프링 쉘 명령어를 위한 애노테이션입니다. key가 명령어가 되고, value는 help에서 보여줄 해당 명령어의 설명이라고 보면 됩니다.
@ShellOption : 명령어에서 사용하는 옵션을 의미합니다. 

private val log = KotlinLogging.logger {  }

@ShellComponent
class HelloCli {
    @ShellMethod(key = ["hello"], value = "welcome text")
    fun test(
        @ShellOption("-n", defaultValue = "World") name: String
    ) {
        log.info { "Hello $name!" }
    }
}

테스트

엄청 간단하게 프로젝트가 생성되었습니다. 이제 스프링 프로젝트를 실행해 보겠습니다. 먼저 스프링에서 기본적으로 제공하는 명령어들을 확인해 보기 위해 help 커맨드를 입력해 보겠습니다.

help command

help를 입력하면 아래와 같이 기본적으로 제공하는 커맨드들이 보입니다. 그리고 아래에 방금 저희가 추가했던 명령어가 보이네요. 이제 hello 명령어도 사용해 보도록 하겠습니다. 

이제 hello에 validation을 추가해 보도록 할까요? 이름을 입력하는 옵션에 아래와 같이 Size 에노테이션을 추가해 최대 10자리까지만 입력가능하도록 제어해 보겠습니다.

@ShellMethod(key = ["hello"], value = "welcome text")
fun test(
    @Size(max = 10, message = "최대 10자리를 넘을 수 없습니다.")
    @ShellOption("-n", defaultValue = "World") name: String
) {
    log.info { "Hello $name!" }
}

손쉽게 validation을 적용할 수 있습니다. 이번에는 간단하게 커맨드를 추가할 수 있다는 것을 보았는데 좀 더 실용적인 예제를 사용해 보도록 하겠습니다. 저는 암복호화를 사용할 때가 정말 많은데 이를 커맨드에 추가해서 사용해보도록 하겠습니다.

CipherService

/**
 * 암호화 인터페이스
 * */
interface CipherService {
    fun isSupport(algorithm: String): Boolean

    /**
     * @param message 암호화할 메시지
     * @return 암호화된 메시지
     * */
    fun encrypt(message: String): String

    /**
     * @param encryptedMessage 암호화된 메시지
     * @return 복호화된 메시지
     * */
    fun decrypt(encryptedMessage: String): String
}

AES

@Service
class AesCipherService: CipherService {
    private val key = "1234567890123456"
    private val algorithm = "AES/CBC/PKCS5Padding"
    private val secretKeySpec = SecretKeySpec(key.toByteArray(), "AES")
    private val iv = IvParameterSpec(key.substring(0..15).toByteArray())

    override fun isSupport(algorithm: String) = algorithm == "AES"

    override fun encrypt(message: String): String {
        val cipher = Cipher.getInstance(algorithm)
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv)
        val cipherText = cipher.doFinal(message.toByteArray())
        return Base64.getEncoder().encodeToString(cipherText)
    }

    override fun decrypt(encryptedMessage: String): String {
        val cipher = Cipher.getInstance(algorithm)
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, iv)
        val plainText = cipher.doFinal(Base64.getDecoder().decode(encryptedMessage))
        return String(plainText)
    }
}

DES

@Service
class DesCipherService: CipherService {
    private val algorithm = "DES"
    private val key = KeyGenerator.getInstance(algorithm).generateKey()

    override fun isSupport(algorithm: String) = algorithm == "DES"

    override fun encrypt(message: String): String {
        val cipher = Cipher.getInstance(algorithm)
        cipher.init(Cipher.ENCRYPT_MODE, key)
        val cipherText = cipher.doFinal(message.toByteArray())
        return Base64.getEncoder().encodeToString(cipherText)
    }

    override fun decrypt(encryptedMessage: String): String {
        val cipher = Cipher.getInstance(algorithm)
        cipher.init(Cipher.DECRYPT_MODE, key)
        val plainText = cipher.doFinal(Base64.getDecoder().decode(encryptedMessage))
        return String(plainText)
    }
}

Cipher Command

@ShellComponent
class CipherCommand(
    private val cipherService: List<CipherService>
) {
    @ShellMethod(key = ["encrypt"], value = "encrypt your message")
    fun encrypt(
        @ShellOption(value = ["--a"], help = "cipher algorithm. we support aes, des") algorithm: String,
        @ShellOption(value = ["--m"], help = "encrypted target message") message: String
    ) {
        println(getCipherService(algorithm).encrypt(message))
    }

    @ShellMethod(key = ["decrypt"], value = "decrypt your message")
    fun decrypt(
        @ShellOption(value = ["--a"], help = "cipher algorithm. we support aes, des") algorithm: String,
        @ShellOption(value = ["--m"], help = "decrypted target message") message: String
    ) {
        println(getCipherService(algorithm).decrypt(message))
    }

    /**
     * @exception IllegalArgumentException 지원하지 않는 타입
     * */
    private fun getCipherService(algorithm: String) =
        cipherService.find { it.isSupport(algorithm = algorithm.uppercase()) }
            ?: throw IllegalArgumentException("${algorithm}은 지원하지 않는 타입입니다.")
}

테스트

애플리케이션을 재실행시키도록 하겠습니다. 이번에는 ShellOption에 help도 사용하였는 데 사용법은 help {command}입니다.

이제 "모종닷컴"이라는 이름을 AES 알고리즘을 이용하여 암호화하고 해당 암호화된 문자열을 다시 복호화해서 제대로 동작하는지 보도록 하겠습니다.

제대로 암복호화가 구현이 된 것 같습니다. 

추가

혹시라도 필요하신 분이 있으실까 추가로 프롬프트를 커스터마이징 하는 코드를 간단하게 공유드립니다.

@Component
class CustomPromptProvider: PromptProvider {
    override fun getPrompt(): AttributedString {
        return AttributedString(
            "monny-cli >> ",
            AttributedStyle.DEFAULT
                .background(AttributedStyle.BLUE)
        )
    }
}

추가적으로 zsh를 사용하시는 분들은 alias를 등록해서 바로 사용해도 편한더라구요. 뭐 아래명령어에 jar 위치 명시해두고 zshrc에 추가해두면 필요한 순간에 alias를 통해 cli를 접속할 수 있습니다.

echo -e 'alias monny-cli="java -jar /Users/{nickname}/monny-cli/build/libs/monny-cli-0.0.1-SNAPSHOT.jar"' >> ~/.zshrc

 

마무리

이런 식으로 필요한 서비스들을 cli에 등록하고 사용할 수 있다는 것을 간단하게 예제를 통해 배워보았습니다. 아직 저도 스프링 쉘 프로젝트 활용을 극대화하는 방법을 생각해보지는 못했지만 적재적소 순간에 분명히 도움이 되는 순간이 있을 거라 생각이 됩니다. 

긴 글 읽어주셔서 너무 감사합니다. 즐거운 설 보내시고 새해 복 많이 받으세요~!

반응형