코틀린 명언 앱으로 배우기 - 2

현곤·2025년 2월 21일

삭제

} else if (rq.action == "삭제") {
                val id = rq.getParamValueAsInt("id", 0)

                if (id == 0) {
                    println("id를 정확히 입력해주세요.")
                    continue
                }

                val wiseSaying = wiseSayings.firstOrNull() { it.id == id }

                if (wiseSaying == null) {
                    println("${id}번 명언은 존재하지 않습니다.")
                    continue
                }
                
                wiseSayings.remove(wiseSaying)

                println("${id}번 명언을 삭제하였습니다.")
            }
  • val id = rq.getParamValueAsInt("id", 0)
    -> id 값을 가져옴 (id가 없으면 0 반환)

  • if (id == 0)
    -> id가 0이면 "id를 정확히 입력해주세요." 출력 후 continue , 삭제 진행 X

  • val wiseSaying = wiseSayings.firstOrNull() { it.id == id }
    -> wiseSayigs 리스트에서 id가 일치하는 첫 번째 요소 찾기
    -> 없으면 null 반환

  • wiseSaying == null
    -> "id번 명언은 존재하지 않습니다." 출력 후 continue, 삭제 진행 X

  • wiseSayings.remove(wiseSaying)
    -> 찾은 명언을 리스트에서 제거

id 확인 → 존재하는지 체크 → 리스트에서 삭제 → 성공 메시지 출력


컨트롤러화

class WiseSayingController {
    private var lastId = 0
    private val wiseSayings = mutableListOf<WiseSaying>();

    fun actionWrite(rq: Rq) {

        print("명언 : ")
        val content = readlnOrNull()!!.trim()
        print("작가 : ")
        val author = readlnOrNull()!!.trim()

        val id = ++lastId

        wiseSayings.add(WiseSaying(id, content, author))

        println("${id}번 명언이 등록되었습니다.")
        }

    fun actionList(rq: Rq) {

        if (wiseSayings.isEmpty()) {
            println("등록된 명언이 없습니다.")
            return
        }

        println("번호 / 작가 / 명언")
        println("----------------------")

        wiseSayings.forEach {
            println("${it.id} / ${it.author} / ${it.content}")
        }
    }

    fun actionDelete(rq: Rq) {

        val id = rq.getParamValueAsInt("id", 0)

        if (id == 0) {
            println("id를 정확히 입력해주세요.")
            return
        }

        val wiseSaying = wiseSayings.firstOrNull() { it.id == id }

        if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }

        wiseSayings.remove(wiseSaying)

        println("${id}번 명언을 삭제하였습니다.")
    }

//    fun actionModify(rq: Rq) {
//
//    }
}

App Class

class App {
    fun run() {

        val wiseSayingController = WiseSayingController()
        val systemController = SystemController()

        println("== 명언 앱 ==")

        while (true) {

            print("명령) ")

            val input = readlnOrNull()!!.trim()

            val rq = Rq(input)

            when (rq.action) {
                "종료" -> {
                    systemController.actionExit(rq)
                    break
                }

                "등록" -> wiseSayingController.actionWrite(rq)
                "목록" -> wiseSayingController.actionList(rq)
                "삭제" -> wiseSayingController.actionDelete(rq)
            }
        }
    }
}

각 기능을 메서드로 분리

이렇게 분리하면 뭐가 좋은가?

-> 코드의 재사용성을 높이고, 가독성을 개선하며, 유지보수를 쉽게 만든다.


스마트 캐스트

fun actionDelete(rq: Rq) {

        val id = rq.getParamValueAsInt("id", 0)

        if (id == 0) {
            println("id를 정확히 입력해주세요.")
            return
        }

        val wiseSaying = wiseSayings.firstOrNull() { it.id == id }

        if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }
        
        // 여기서부터는 wiseSaying 변수가 nullable이 아니다.
		// 스마트 캐스트

        wiseSayings.remove(wiseSaying)

        println("${id}번 명언을 삭제하였습니다.")
    }

스마트 캐스트는 Kotlin에서 변수를 별도의 캐스팅 없이
안전하게 사용할 수 있도록 해주는 기능
이다.


💡 스마트 캐스트가 어디서 적용될까?

wiseSaying 변수는 firstOrNull { it.id == id } 를 사용해서 찾았기 때문에
nullable 타입 (wiseSaying?)이 된다.

val wiseSaying = wiseSayings.firstOrNull { it.id == id }
  • 만약 id에 해당하는 명언이 없으면? null 이 반환

  • 그러므로 wiseSayingWiseSaying? (nullable) 타입


📌 스마트 캐스트가 적용되는 부분

if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }
        
        // 여기서부터는 wiseSaying 변수가 nullable이 아니다.
		// 스마트 캐스트

        wiseSayings.remove(wiseSaying)

✅어떻게 적용될까?

  1. wiseSaying == null 인지 체크

    • if(wiseSaying == null) { return } 이 실행되면 함수가 종료

    • 즉 , wiseSayingnull 인 경우 이후 코드 실행 ❌

  2. wiseSaying null 이 아니라는 것이 확정

    • if 문을 통과한 이후에는 wiseSayingnull 이 될 수 없음

    • 따라서, Kotlin이 자동으로 wiseSaying WiseSaying 타입으로 변환 (캐스트)

  3. 명시적 캐스팅 없이 wiseSaying 을 사용할 수 있음

    • 원래 nullable 타입을 사용할 때는 wiseSaying!! 같은 명시적 캐스팅이 필요할 수 있음

    • 하지만, 스마트 캐스트 덕분에 안전하게 non - null 타입으로 간주되고,
      그냥 wiseSaying 을 사용할 수 있음


📌 스마트 캐스트 없으면 어떻게 될까?

만약 스마트 캐스트가 없었다면, wiseSaying 이 nullable 타입이므로
!!?. 를 사용

❌ 스마트 캐스트 없이 강제 캐스트

wiseSayings.remove(wiseSaying!!) // 강제 캐스트
  • wiseSaying!! 을 사용하면 null이 들어오면서 예외 발생 (NullPointerException)

❌ 스마트 캐스트 없이 안전 호출

wiseSaying?.let {
    wiseSayings.remove(it)
}
  • wiseSayingnull 이 아닐 때만 remove(it) 실행

  • 하지만 이미 null 체크를 했기 때문에 불필요한 코드

스마트 캐스트 덕분에 불필요한 캐스팅 없이 안전하게 변수를 사용할 수 있음
Kotlin이 null 체크를 통과한 변수를 자동으로 non - null 타입으로 캐스팅해주므로 !! 같은 강제 캐스트 없이 wiseSaying 을 바로 사용 가능


수정

fun actionModify(rq: Rq) {

        val id = rq.getParamValueAsInt("id", 0)

        if (id == 0) {
            println("id를 정확히 입력해주세요.")
        }

        val wiseSaying = wiseSayings.firstOrNull() { it.id == id }

        if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }

        println("명언(기존) : ${wiseSaying.content}")
        print("명언 : ")
        val content = readlnOrNull()!!.trim()

        println("작가(기존) : ${wiseSaying.author}")
        print("작가 : ")
        val author = readlnOrNull()!!.trim()

        wiseSaying.update(content, author)

        println("${id}번 명언이 수정되었습니다.")
    }
println("명언(기존) : ${wiseSaying.content}")
println("작가(기존) : ${wiseSaying.author}")

기존에 있던 명언, 작가는 wiseSaying 에서 content 불러오기

val content = readlnOrNull()!!.trim()

null 이 될 수 없다고 내가 보장한다 라는 뜻

fun update(content: String, author: String) {
        this.content = content
        this.author = author
    }

수정을 위한 update 메서드


서비스화

class WiseSayingService {

    private var lastId = 0
    private val wiseSayings = mutableListOf<WiseSaying>()

    fun write(content: String, author: String): WiseSaying {
        val id = ++lastId

        return WiseSaying(id, content, author).apply {
            wiseSayings.add(this)
        }
    }

    fun isEmpty(): Boolean {
        return wiseSayings.isEmpty()
    }

    fun findAll(): List<WiseSaying> {
        return wiseSayings
    }

    fun findById(id: Int): WiseSaying? {
        return wiseSayings.find { it.id == id }
    }

    fun delete(wiseSaying: WiseSaying) {
        wiseSayings.remove(wiseSaying)
    }

    fun modify(wiseSaying: WiseSaying, content: String, author: String) {
        wiseSaying.modify(content, author)
    }
}

등록 (write)

fun actionWrite(rq: Rq) {

        print("명언 : ")
        val content = readlnOrNull()!!.trim()
        print("작가 : ")
        val author = readlnOrNull()!!.trim()

        val wiseSaying = wiseSayingService.write(content, author)

        println("${wiseSaying.id}번 명언이 등록되었습니다.")
    }

목록 (findAll)

fun actionList(rq: Rq) {

        if (wiseSayingService.isEmpty()) {
            println("등록된 명언이 없습니다.")
            return
        }

        println("번호 / 작가 / 명언")
        println("----------------------")

        wiseSayingService.findAll().forEach() {
            println("${it.id} / ${it.author} / ${it.content}")
        }
    }

삭제 (delete)

fun actionDelete(rq: Rq) {

        val id = rq.getParamValueAsInt("id", 0)

        if (id == 0) {
            println("id를 정확히 입력해주세요.")
            return
        }

        val wiseSaying = wiseSayingService.findById(id)

        if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }

        wiseSayingService.delete(wiseSaying)

        println("${id}번 명언을 삭제하였습니다.")
    }

수정 (modify)

fun actionModify(rq: Rq) {

        val id = rq.getParamValueAsInt("id", 0)

        if (id == 0) {
            println("id를 정확히 입력해주세요.")
        }

        val wiseSaying = wiseSayingService.findById(id)

        if (wiseSaying == null) {
            println("${id}번 명언은 존재하지 않습니다.")
            return
        }

        println("명언(기존) : ${wiseSaying.content}")
        print("명언 : ")
        val content = readlnOrNull()!!.trim()

        println("작가(기존) : ${wiseSaying.author}")
        print("작가 : ")
        val author = readlnOrNull()!!.trim()

        wiseSayingService.modify(wiseSaying, content, author)

        println("${id}번 명언이 수정되었습니다.")
    }

테스트 코드의 차이점

표준 입력과 출력을 리다이렉트하여 테스트를 진행하는 방식인 두 코드가 있는데

차이점이 궁금해서 알아봤따

1️⃣번 코드

object TestRunner {

    private val originalIn: InputStream = System.`in`
    private val originalOut: PrintStream = System.out

    fun run(input: String): String {
        // 표준 입력 리다이렉팅
        // 키보드 입력 => 문자열 입력
        System.setIn(
            ByteArrayInputStream(
                ("${input.trimIndent()}\n종료")
                    .toByteArray()
            )
        )

        // 표준 출력 리다이렉팅
        // 콘솔 출력 => 문자열 출력
        val outputStream = ByteArrayOutputStream()
        val printStream = PrintStream(outputStream)

        System.setOut(printStream)
        App().run()

        // 표준 출력 결과를 문자열로 변환
        val result = outputStream
            .toString()
            .trim()
            .replace(Regex("\\r\\n"), "\n") // 개행문자 차이 표준화

        // 다시 표준 입력으로 복구
        System.setIn(originalIn)

        // 다시 표준 출력으로 복구
        System.setOut(originalOut)

        return result
    }
}

2️⃣번 코드

object TestRunner {

    private val originalIn: InputStream = System.`in`
    private val originalOut: PrintStream = System.out

    fun run(input: String): String {

        val formattedInput = input
            .trimIndent()
            .plus("\n종료")

        return ByteArrayOutputStream().use { outputStream ->
            PrintStream(outputStream).use { printStream ->

                try {
                    System.setIn(
                        ByteArrayInputStream(
                            formattedInput.toByteArray()
                        )
                    )

                    System.setOut(printStream)
                    
                    App().run()

                } finally {
                    System.setIn(originalIn)
                    System.setOut(originalOut)
                }
            }
            
            outputStream
                .toString()
                .trim()
                .replace("\r\n", "\n")
        }
    }
}

📌 공통점

  1. 표준 입력 (System.in) 리다이렉트

    • ByteArrayInputStream 을 사용해서 키보드 입력 대신
      문자열을 입력
      하도록 설정

    • 마지막에 "종료" 를 추가하여 프로그램이 종료되도록 유도

  2. 표준 출력 (System.out) 리다이렉트

    • ByteArrayInputStream 을 사용하여 콘솔 출력이 문자열로 저장되도록 변경
  3. 출력 결과 가공

    • outputStream.toString().trim().replace("\r\n", "\n") 을 사용해
      개행 문자 표준화
  4. 기본적으로 프로그램 실행 (App().run()) 후 원래 상태 복구

    • System.setIn(originalIn) , System.setOut(originalOut) 을 호출하여
      원래 입출력 상태로 되돌림

📌 차이점

1️⃣번 코드의 특징

// 표준 입력 리다이렉팅
System.setIn(
    ByteArrayInputStream(
        ("${input.trimIndent()}\n종료") // input을 정리하고 마지막에 "종료" 추가
            .toByteArray()
    )
)
// 표준 출력 리다이렉팅
val outputStream = ByteArrayOutputStream()
val printStream = PrintStream(outputStream)
System.setOut(printStream)

App().run()

// 표준 출력 결과를 문자열로 변환
val result = outputStream
    .toString()
    .trim()
    .replace(Regex("\\r\\n"), "\n") // 개행문자 표준화

// 표준 입력/출력 복구
System.setIn(originalIn)
System.setOut(originalOut)

return result
  • System.setIn()System.setOut()App().run() 실행
    그 후 원래대로 복구

  • 출력값을 result 변수에 저장한 후 반환

  • 개행문자 변환을 정규식 (Regex("\\r\\n")) 을 사용해서 처리


2️⃣번 코드의 특징

val formattedInput = input
    .trimIndent()
    .plus("\n종료") // input을 정리하고 "종료" 추가

return ByteArrayOutputStream().use { outputStream -> // ByteArrayOutputStream을 use 블록에서 사용
    PrintStream(outputStream).use { printStream ->  // PrintStream을 use 블록에서 사용
        try {
            System.setIn(ByteArrayInputStream(formattedInput.toByteArray()))
            System.setOut(printStream)
            App().run()
        } finally {
            System.setIn(originalIn)
            System.setOut(originalOut)
        }
    }
    outputStream
        .toString()
        .trim()
        .replace("\r\n", "\n")
}
  • use 블록을 사용하여 ByteArrayOutputStreamPrintStream
    자동으로 close

  • try-finally 를 사용하여 System.setIn()System.setOut()
    원래대로 복구

  • 출력값을 outputStream.toString() 에서 직접 반환 (별도의 변수 저장 없이)

  • 개행문자 변환을 정규식 없이 replace("\r\n", "\n") 로 처리


📌 두 코드의 주요 차이점

차이점첫 번째 코드두 번째 코드
리소스 관리명시적으로 System.setIn()/setOut() 복구use 블록과 try-finally 사용하여 자동 복구
출력값 반환 방식result 변수에 저장 후 반환outputStream.toString()에서 직접 반환
개행 문자 변환 방식정규식 (Regex("\\r\\n")) 사용단순 replace("\r\n", "\n") 사용
가독성코드가 길고 명시적use 블록으로 더 깔끔하고 안전

결론은 2번째 코드가 더 안전하고 가독성이 좋다

use 블록을 활용해서 자동으로 스트림을 닫고 (리소스 관리 자동화)

try-finally 를 사용하여 System.setIn() , System.setOut() 안전하게 원복하고,

개행 문자 변환도 정규식 없이 단순 replace() 사용으로 더 간결해진다

하지만 첫 번째 코드도 동작하는 데는 문제없음

👉 다만 "리소스를 명시적으로 관리해야 하는 코드" 라서 좀 더 길고
복잡한 느낌이 있다

profile
코딩하는 곤쪽이

0개의 댓글