분할 정복 알고리즘의 기본 개념

분할 정복의 핵심 아이디어는 분할 정복 이름 그대로 '분할(Divide)', '정복(Conquer)', '결합(Combine)' 의 세 단계로 이루어져 있다.

  • 분할 : 큰 문제를 여러 개의 작은 문제로 나눈다.
  • 정복 : 나눈 작은 문제들을 각각 해결한다.
  • 결합 : 해결한 결과를 합쳐서 전체 문제의 답을 만든다.

쉽게 설명하면 큰 케이크를 한 번에 먹으려 하면 힘들지만, 조각조각 나눠서 먹으면 쉬운것과 비슷하다.

 

 

분할 정복의 대표 알고리즘으로는 병합 정렬과 퀵 정렬이 있다.

 

 

1. 병합 정렬(Merge Sort)

병합 정렬은 배열을 반으로 나누고, 각각을 정렬한 뒤, 두 정렬된 배열을 합쳐서 전체 배열을 정렬하는 방법이다.

 

멀쩡히 있는 배열을 왜 쪼갤까?

 

배열을 작게 나누면, 작은 문제들은 쉽게 해결되고, 마지막에 이들을 합치면 큰 문제도 쉽게 풀린다는 개념이다.

fun mergeSort(arr: IntArray): IntArray {
    if (arr.size <= 1) return arr  // 배열 크기가 1 이하이면 이미 정렬된 상태

    val mid = arr.size / 2
    val left = mergeSort(arr.copyOfRange(0, mid))
    val right = mergeSort(arr.copyOfRange(mid, arr.size))

    return merge(left, right)
}

fun merge(left: IntArray, right: IntArray): IntArray {
    var i = 0
    var j = 0
    val merged = mutableListOf<Int>()

    while (i < left.size && j < right.size) {
        if (left[i] <= right[j]) {
            merged.add(left[i])
            i++
        } else {
            merged.add(right[j])
            j++
        }
    }
    while (i < left.size) {
        merged.add(left[i])
        i++
    }
    while (j < right.size) {
        merged.add(right[j])
        j++
    }
    return merged.toIntArray()
}

fun mainMergeSort() {
    val arr = intArrayOf(38, 27, 43, 3, 9, 82, 10)
    println("원본 배열: ${arr.joinToString(", ")}")
    val sortedArr = mergeSort(arr)
    println("정렬된 배열: ${sortedArr.joinToString(", ")}")
}

 

위 코드의 실행 결과를 살펴보자

원본 배열: 38, 27, 43, 3, 9, 82, 10
정렬된 배열: 3, 9, 10, 27, 38, 43, 82

 

원본 배열이 정렬되는 것을 볼 수 있다.

 

 

 

2. 퀵 정렬(Quick Sort)

퀵 정렬은 배열에서 하나의 값을 피벗(pivot)으로 선택한 후, 피벗보다 작은 값과 큰 값으로 배열을 분할하고, 이를 재귀적으로 정렬하는 방법이다.

 

코드로 알아보는게 이해가 더 쉬울것 같다.

fun quickSort(arr: IntArray, low: Int = 0, high: Int = arr.size - 1) {
    if (low < high) {
        val pi = partition(arr, low, high)
        quickSort(arr, low, pi - 1)
        quickSort(arr, pi + 1, high)
    }
}

fun partition(arr: IntArray, low: Int, high: Int): Int {
    val pivot = arr[high]  // 1. 피벗으로 배열의 마지막 원소를 선택
    var i = low - 1        // 2. i는 피벗보다 작거나 같은 요소들이 위치할 인덱스의 마지막 위치를 추적

    // 3. low부터 high - 1까지 반복하여 피벗과 비교
    for (j in low until high) {
        if (arr[j] <= pivot) {  // 만약 현재 값이 피벗보다 작거나 같으면,
            i++                // i를 한 칸 증가시키고,
            // 4. arr[i]와 arr[j]를 교환하여, 피벗보다 작은 값들을 배열 앞쪽으로 모읍니다.
            val temp = arr[i]
            arr[i] = arr[j]
            arr[j] = temp
        }
    }
    // 5. i + 1 위치와 피벗(arr[high])을 교환하여, 피벗이 정렬된 위치에 오도록 합니다.
    val temp = arr[i + 1]
    arr[i + 1] = arr[high]
    arr[high] = temp

    return i + 1  // 피벗의 최종 위치를 반환합니다.
}

fun mainQuickSort() {
    val arr = intArrayOf(10, 7, 8, 9, 1, 5)
    println("원본 배열: ${arr.joinToString(", ")}")
    quickSort(arr)
    println("정렬된 배열: ${arr.joinToString(", ")}")
}

 

코드를 살펴보면 분할, 정복, 결합을 모두 볼 수 있다.

Kotlin을 공부중인데 val에 대해서 글을 읽던 중 엥? 이게 무슨 말이지 하는 부분이 있었다.

 

분명 "val 로 선언하면 값을 바꿀 수 없다" 라고 봤는데, val로 선언했는데 객체 내부 값이 변하는걸 목격했다.

 

분명히 고정된 값(val)인데 왜 내부 데이터는 바뀌는건지 혼란스러웠다.

 

이건 Kotlin이 값을 불변하게 만든다고 오해한 데서 시작된 착각이라는 것을 알았다.

 

진짜로 불변(Immutable)한 것은 "참조(reference)" 이지, "객체의 상태(state)" 가 아니다.

 

 

1. Kotlin의 val 과 var 기본 개념

먼저, Kotlin의 val 과 var 는 변수 선언 방식이다.

키워드 의미
val 읽기 전용(Read-only) 참조
var 읽기/쓰기(Read-Write) 참조

 

  • val 은 참조를 변경할 수 없다.
  • var 은 참조를 변경할 수 있다.

즉, val 은 "이 참조가 다른 객체를 가리키지 않도록 고정"하는 것이다. "참조하는 객체 재부"는 건들 수 있다.

 

val list = mutableListOf(1, 2, 3)
list.add(4)      //  가능
list.remove(2)   //  가능
// list = mutableListOf(5, 6, 7)  // ❌ 에러! 참조 변경 불가

 

위 코드에서 알아볼 수 있다.

  • list 가 가리키는 리스트 객체는 수정 가능하다.
  • 그러나 list 자체를 다른 리스트로 바꿀 수 없다.

 

var 은 참조 변경이 가능하다

var list = mutableListOf(1, 2, 3)
list = mutableListOf(10, 20, 30)  // 가능

 

 

오호..이제 알겠다. 근데 왜 이런 설계를 했을까???

 

2. val 설계 이유

Kotlin이 val 을 참조 고정만 보장하고, 객체 불변성을 강제하지 않는 이유는 다음과 같다.

 

이유 1 : 유연성과 기능

  • 객체를 통째로 새로 생성하는 것보다, 기존 객체를 수정하는 것이 빠를 때가 많다.
  • 특히 컬렉션 같은 경우 "수정 가능한 컬렉션 (mutableListOf)" 은 효율적이다.

이유 2: Kotlin의 철학

  • Kotlin은 "개발자에게 선택권을 준다" 는 철학을 지향한다.
  • 불변 객체를 원하면, 개발자가 immutable한 자료구조를 선택해야 한다.

Kotlin은 "불면(immutable)"을 강제하지 않는다. 읽기 전용 참조(read-only reference)만 보장한다.

 

 

3. Mutable 과 Immutable 객체

mutable = 내부 상태를 바꿀 수 있다.

immutable = 내부 상태를 바꿀 수 없다.

구분 설명 예시
Mutable 객체 객체 내부 데이터 변경 가능 mutableListOf, HashMap 등
Immutable 객체 객체 내부 데이터 변경 불가 listOf, Map (읽기 전용 View)

 

Kotlin의 listOf() 로 만든 리스트도 사실은 완전한 immutable은 아니다. 완전히 불변한 컬렉션을 원하면 별도로 관리해야 한다.

예: Collecitons.unmodifiableList(), 또는 직접 만드는 데이터 클래스

 

 

즉 비유를 하자면

 

val 은 집주소를 고정하는 것이다. 집 주소를 못 바꾸지만, 집안 가구 배치나 이런건 마음대로 바꿀 수 있지 않은가.

val 집 = MyHouse()
집.소파 = "새 소파로 교체" // OK
// 집 = 다른집() // ❌ 참조 변경은 불가

 

 

 

4. 데이터 클래스와 불변성

Kotlin에서는 데이터 클래스를 많이 사용한다.

data class Person(var name: String, var age: Int)

 

val 로 선언해도 name, age는 변경 가능하다.

 val person = Person("Alice", 25)
person.age = 26  // 내부 필드 변경 가능

 

  • person 이라는 참조는 고정된다
  • 하지만 person 객체 내부 필드(age)는 변경 가능하다.

 

정말 진짜 불변성을 원한다면 다음과 같이 만들 수 있다.

 

1. data class를 val 프로퍼티로만 만든다.

data class ImmutablePerson(val name: String, val age: Int)

 

이제 name, age를 바꿀 수 없다.

 

2. 불변 컬렉션을 사용한다.

val list = listOf(1, 2, 3)
// list.add(4)  // ❌ 컴파일 에러

 

 

 

주의할 점

상황 주의해야 할 점
API 리턴 타입을 val로 선언했지만 내부 객체가 Mutable인 경우 외부에서 상태가 변조될 수 있다.
글로벌 상태를 val로만 선언하고 안심하는 경우 Thread-safety는 별개 문제이다.

 

  • val 은 객체 상태를 보호해주지 않는다.
  • 불변성을 원하면 객체 설계 자체를 immutable하게 헤야 한다.

데이터를 빠르게 검색하거나 저장하는 일은 개발에서 필수적이다. (항상 이걸 어떻게 하면 잘할까 고민한다..) 이러한 작업을 가장 효율적으로 수행하는 자료구조 중 하나가 바로 해시 테이블(Hash Table) 이다.

 

해시 테이블은 키-값(Key-Value) 쌍으로 데이터를 저장하며, 거의 상수 시간(평균 O(1))에 데이터에 접근할 수 있는 강력한 도구이다.

 

 

해시 테이블이란?

해시 테이블은 위에서 언급한것과 같이 키(key)와 값(value)의 쌍으로 데이터를 저장하는 구조이다. 여기서 중요한 개념은 해시 함수(hash function)로, 입력된 키를 고정된 크기의 인덱스로 변환한다. 이 인덱스는 데이터를 저장할 배열의 위치를 결정한다.

 

예를 들어 "apple"이라는 키를 입력하면, 해시 함수는 "apple"을 정수 42와 같이 특정 인덱스로 변환한다. 그러면 해시 테이블은 "apple"에 대응하는 값을 저장하게 된다.

 

 

해시 함수(Hash Function) 

해시 함수는 키를 받아서 배열의 인덱스로 변환하는 역할을 한다. 좋은 해시 함수는 다음 조건을 만족한다.

  • 결정론적 : 동일한 키는 항상 같은 해시 값을 반환해야 한다.
  • 균등 분포 : 가능한 모든 키에 대해 인덱스가 균등하게 분포되어 충돌을 최소화한다.
  • 빠른 계산 : 해시 함수는 빠르게 계산되어야 하며, 전체 성능에 큰 영향을 주지 않아야 한다.

 

해시 함수가 서로 다른 키에 대해 같은 인덱스를 반환하는 경우 이를 충돌(collision)이라고 한다. 충돌은 해시 테이블에서 피할 수 없는 문제이지만, 다양한 해결 기법을 통해 이를 효과적으로 관리할 수 있다.

 

충돌 해결 전략

  • 체이닝(Chaining) : 각 인덱스에 연결 리스트(또는 다른 자료구조)를 사용하여, 동일한 해시 값을 가진 여러 요소를 저장한다.
  • 오픈 어드레싱(Open Addressing) : 충돌이 발생하면, 해시 테이블 내에서 다른 빈 공간을 찾아 데이터를 저장한다. 대표적인 방법으로는 선형 탐사 (Linear Probing), 이차 탐사(Quadratic Probing) 등이 있다.

 

 

해시 테이블의 장단점 및 사용 시 고려사항

 

장점  : 

  • 빠른 검색 및 삽입 : 평균적으로 O(1)의 시간 복잡도로 데이터 접근이 가능하다.
  • 유연성 : 키와 값의 쌍으로 데이터를 관리하므로, 다양한 데이터 타입에 쉽게 적용할 수 있다.
  • 효율적인 메모리 사용 : 적절한 해시 함수와 충돌 해결 기법을 사용하면, 메모리 낭비 없이 효율적으로 데이터를 저장할 수 있다.

단점 및 고려사항 : 

  • 충돌 발생 가능성 : 해시 함수의 품질에 따라 충돌이 많이 발생하면 성능이 저하될 수 있다.
  • 동적 크기 조절 : 해시 테이블은 데이터의 양이 증가하면 크기를 동적으로 조절해야 하며, 이 과정에서 비용이 발생할 수 있다.
  • 해시 함수 선택 : 좋은 해시 함수를 선택하는 것은 해시 테이블의 성능과 직접적으로 연결되므로 매우 중요하다.

 

이런 해시테이블은 어디에 사용할 수 있을까?

  1. 데이터베이스 인덱싱 : 대규모 데이터베이스에서 해시 테이블은 빠른 검색과 데이터 접근을 위한 인덱스로 활용된다.
  2. 캐시(Cache) : 웹 애플리케이션에서 사용자 데이터를 캐싱하거나, 자주 사용하는 데이터를 임시 저장해주더 빠른 응답을 제공하는 데 해시 테이블을 사용한다.
  3. 집합 구현 : 집합은 중복 없는 데이터를 저장하는 자료구조이다. 해시 테이블을 기반으로 구현된 HashSet은 데이터의 중복 여부를 빠르게 판단할 수 있다.

 

Kotlin에서는 표준 라이브러리에서 HashMap을 제공하여, 해시 테이블을 쉽게 사용할 수 있다.

fun main() {
    // HashMap 생성: 키는 String, 값은 Int
    val hashMap = HashMap<String, Int>()
    
    // 데이터 삽입
    hashMap["apple"] = 5
    hashMap["banana"] = 3
    hashMap["cherry"] = 7
    
    // 데이터 조회
    println("사과의 수: ${hashMap["apple"]}") // 출력: 사과의 수: 5
    
    // 데이터 존재 여부 확인
    if (hashMap.containsKey("banana")) {
        println("바나나가 존재합니다.")
    }
    
    // 모든 키와 값 순회
    for ((key, value) in hashMap) {
        println("$key : $value")
    }
}

 

HashMap의 내부 동작 원리에 대해 살짝 알아보면

  • 해시 함수 적용 : 입력된 키에 대해 내부 해시 함수를 적용하여 인덱스를 계산하고
  • 충돌 처리 : 동일한 인덱스가 발생한 경우, 체이닝(LinkedList 등)을 사용하여 여러 값을 저장한다
  • 동적 크기 조절 : 데이터가 많아지면 자동으로 크기를 확장하여 성능 저하를 방지한다.

HashMap 내부적으로 처리를 해주기 때문에 그냥 가져다 쓰기만 하면 된다...!

 

 

해시 테이블 응용 문제

 

1. 중복된 문자 제거

문자열에서 중복된 문자를 제거하고, 결과를 유지하는 문제는 해시 테이블을 활용하여 해결할 수 있다.

fun removeDuplicates(s: String): String {
    val seen = HashSet<Char>()
    val sb = StringBuilder()
    
    for (ch in s) {
        if (seen.add(ch)) {  // add()는 중복된 문자가 없으면 true를 반환합니다.
            sb.append(ch)
        }
    }
    return sb.toString()
}

fun main() {
    val input = "banana"
    println("중복 제거 결과: ${removeDuplicates(input)}") // 출력 예: "ban"
}

 

 

2. 캐시 구현 예제

간단한 캐시를 해시 테이블로 구현하여, 자주 사용하는 데이터를 빠르게 검색하는 방법을 보여준다.

class SimpleCache<K, V> {
    private val cache = HashMap<K, V>()
    
    fun put(key: K, value: V) {
        cache[key] = value
    }
    
    fun get(key: K): V? {
        return cache[key]
    }
    
    fun contains(key: K): Boolean {
        return cache.containsKey(key)
    }
}

fun mainCache() {
    val cache = SimpleCache<String, Int>()
    cache.put("apple", 5)
    cache.put("banana", 3)
    println("apple: ${cache.get("apple")}")  // 출력: apple: 5
}

투 포인터란 무엇일까?

투 포인터 기법은 배열이나 리스트 등에서 두 개의 인덱스(포인터)를 사용하여 문제를 해결하는 알고리즘이다. 주로 정렬된 배열에서 조건에 맞는 쌍을 찾거나, 두 포인터를 이용해 특정 구간을 탐색할 때 유용하다.

 

 

슬라이딩 윈도우란 또 무엇일까?

슬라이딩 윈도우는 연속된 구간(윈도우)을 다루는 기법으로, 한 번에 한 구간씩 보면서 원하는 조건(예: 합, 평균, 최대/최소값 등)을 만족하는 최적의 결과를 찾는 방법이다.

이때 "윈도우"는 배열이나 문자열에서 연속된 부분을 의미하며, 이 윈도우를 한칸씩 옮겨가며 문제를 해결한다.

 

 

이해가 잘 안된다 실생활 비유? or 더 쉬운 비유로 한번 이해를 해보자.

 

투 포인터 비유 : 책의 양쪽 끝에서 찾기

생각해보자  한 권의 책에서 특정 단어를 찾으려고 할 때, 책의 앞쪽과 뒤쪽에서 동시에 찾아본다면 더 빨리 찾을 수 있지 않을까? 투 포인터는 배열의 양쪽 끝에 포인터를 두고, 조건에 맞게 서로 이동시키면서 문제를 해결하는 기법이다.

 

 

슬라이딩 윈도우 비유 : 창문을 옮겨서 보는 풍경

슬라이딩 윈도우는 마치 큰 창문을 옮겨가며 밖의 풍경을 보는 것과 같다. 창문 너머의 한 부분만 보면서 그 부분의 특징(예: 평균, 합 등)을 차악하고, 창문을 조금씩 옮겨 전체 풍경을 보는 것처럼 배열의 연속된 구간을 살펴본다.

 

살짝 느낌이 온다.

 

 

투 포인터의 기본 원리

동작 방식 : 정렬된 배열에서 왼쪽 포인터는 가장 작은 값을, 오른쪽 포인터는 가장 큰 값을 가리킨다. 두 값을 합하여 조건(target)과 비교한다.

  • 합이 target보다 작으면, 더 큰 값을 찾기 위해 왼쪽 포인터를 오른쪽으로 이동한다. 
  • 합이 target보다 크면, 더 작은 값을 찾기 위해 오른쪽 포인터를 왼쪽으로 이동한다.

이렇게 하면 조건을 만족하는 두 수를 찾거나, 조건에 맞는 구간을 빠르게 탐색할 수 있다.

 

흠.. 그렇다면 두 구간의 합을 비교하거나, 두 구간의 조건을 동시에 고려 할 때, 두 수의 합이나 곱, 차 등을 구할때 사용하면 좋을 것 같다는 생각이 든다.

 

 

두 수의 합(target)을 구하는 예제를 Kotlin으로 작성해보자

class Solution {
    fun solution(nums: IntArray, target: Int): IntArray {
        var left = 0
        var right = nums.size - 1
        
        while (left < right) {
            val sum = nums[left] + nums[right]
            when {
                sum == target -> return intArrayOf(left, right)
                sum < target -> left++   // 합이 작으면 왼쪽 포인터 이동
                else -> right--          // 합이 크면 오른쪽 포인터 이동
            }
        }
        return intArrayOf() // target을 만족하는 쌍이 없으면 빈 배열 반환
    }
}

// 테스트
fun main() {
    val sol = Solution()
    val result = sol.solution(intArrayOf(1, 2, 3, 4, 6), 7)
    println("두 수의 인덱스: ${result.joinToString(", ")}")
}

 

코드에서 left, right 변수를 따로 설정해 투 포인터 기법을 통해 원하는 두 수의 인덱스를 찾아주었다.

 

 

 

 

슬라이딩 윈도우의 기본 원리

동작 방식 : 배열이나 문자열에서 고정된 크기의 구간(윈도우)를 설정하고, 이 윈도우를 좌우로 옮기면서 원하는 조건(예: 합,평균)을 계산한다.

 

어떻게 작동을 하는걸까?

 

슬라이딩 윈도우는 매번 윈도우를 이동하면서, 윈도우 내의 값을 효율적으로 갱신한다. 새로운 값이 추가되고, 이전 값이 제외되면서 구간의 합이나 평균 등이 빠르게 업데이트된다.

 

글로만 보니까 무슨 말인지 이해가 잘 안된다. 코드로 보면 좀 나으니 예제를 봐보자

 

 

정수 배열에서 연속된 부분 배열의 합이 target 이상이 되는 최고 길이를 찾는 예제이다.

class Solution {
    fun solution(nums: IntArray, target: Int): Int {
        var sum = 0
        var left = 0
        var minLength = Int.MAX_VALUE
        
        for (right in nums.indices) { // indices 파라미터 인덱스 값에 접근한다.
            sum += nums[right]
            
            // 현재 윈도우의 합이 target 이상이면, 최소 길이를 갱신하고 윈도우를 좁힙니다.
            while (sum >= target) {
                minLength = minOf(minLength, right - left + 1)
                sum -= nums[left] // 윈도우를 좁히는 부분
                left++
            }
        }
        
        return if (minLength == Int.MAX_VALUE) 0 else minLength
    }
}

// 테스트
fun main() {
    val sol = Solution()
    val result = sol.solution(intArrayOf(2, 3, 1, 2, 4, 3), 7)
    println("최소 길이: $result")
}

 

실제로 출력되는 값

최소 길이: 2

 

처음에는 왜? 어떻게 동작하는거지? 했지만... 결국 이해했는데 이해한 내용을 적어둔다.

 

  1. 주어진 배열 : [2, 3, 1, 2, 4, 3]
  2. 목표 합(target) : 7

여기서 연속된 부분의 합이 7 이상이 되는 최소 길이를 찾는 과정인 것이다.

  1. 처음 right 포인터가 이동하면서 누적합(sum)이 증가한다.
  2. 누적합이 7 이상이 된느 첫 번째 구간은 [2, 3, 1, 2]로, 합은 8이고 길이는 4이다.
  3. 윈도우를 좁혀서 최소 길이를 갱신하면, 더 짧은 구간 [4, 3]이 조건을 만족한다. 이 구간의 길이는 2이다.

따라서 최소 길이는 2가 되어, 최종적인 값이 출력되는 것이다.

 

 

 

 

https://school.programmers.co.kr/learn/courses/30/lessons/181886

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

프로그래머스 기초레벨의 5명씩 문제를 한번 풀어봤다.

 

class Solution {
    fun solution(names: Array<String>): Array<String> {
        val result = mutableListOf<String>()
        for (i in names.indices step 5) {
            result.add(names[i])
        }
        return result.toTypedArray()
    }
}

 

코드 설명

  • names.indices step 5:  배열의 인덱스 범위를 5씩 건너뛰면서 반복합니다.
  • result.add(names[i]):   각 그룹의 첫 번째 이름(인덱스 i의 값)을 결과 리스트에 추가합니다.
  • result.toTypedArray(): 결과 리스트를 배열로 변환하여 반환합니다.
더보기

toTypedArray() 함수는 Kotlin의 컬렉션(예: List)을 배열(Array)로 변환해 주는 함수이다. 이 함수는 컬렉션에 있는 요소들을 기반으로 동일한 타입의 배열을 만들어 반환한다.

자세한 설명

  • 컬렉션과 배열의 차이:
    Kotlin에서는 List, Set, Map과 같은 컬렉션과, 고정 크기를 가지는 배열이 있다.
    컬렉션은 요소 추가나 삭제가 가능한 반면, 배열은 생성 후 크기가 고정된다
  • toTypedArray() 사용 이유:
    때때로 함수나 API가 배열 타입을 요구할 때, 컬렉션(List 등)으로 작업한 후 최종적으로 배열로 변환해야 할 필요가 있다. 이때 toTypedArray()를 사용하면 쉽게 변환할 수 있다.
  • 동작 방식:
    내부적으로 toTypedArray()는 컬렉션에 포함된 각 요소를 순회하며 동일한 타입의 배열을 만들어, 그 배열에 요소들을 채워 반환한다.

문자열이란?

문자열(String) 이란 여러 문자가 이어진 데이터이다. 예를 들어, "Hello, Kotlin!" 은 하나의 문자열이다.

 

문자열에서 인덱스 (Index)도 중요한데 인덱스란 문자열의 각 문자의 순서를 나타내며, 첫 번째 문자는 0번 인덱스이다.

val text = "Hello"
println(text[0]) // 출력: H
println(text[1]) // 출력: e

 

Kotlin에서는 문자열을 다루기 위한 다양한 내장 함수를 제공한다.

  • reversed() : 문자열을 뒤집는다.
  • length : 문자열의 길이를 반환한다.
  • contains() : 특정 문자가 포함되어 있는지 확인한다. 리턴값은  true / false
  • split() : 문자열을 특정 구분자로 나눈다.
fun main() {
    val text = "Kotlin Programming"
    println("원본 문자열: $text")
    println("뒤집은 문자열: ${text.reversed()}")   // 뒤집기
    println("문자열 길이: ${text.length}")          // 길이 출력
    println("포함 여부 (o): ${text.contains("o")}")   // 'o'가 포함되었는지
    println("단어 분리: ${text.split(" ")}")          // 공백을 기준으로 분리
}

 

 

 

문제 예시

1. 문자열 뒤집기

  - 문제 설명 : 사용자가 입력한 문자열을 뒤집어서 출력하는 코드를 작성해보자

  - 힌트 : Kotlin의 내장 함수를 사용할 수 있다.

더보기
fun reverseString(str: String): String {
    return str.reversed()
}

fun main() {
    val input = "Hello, Kotlin!"
    println("원본 문자열: $input")
    println("뒤집은 문자열: ${reverseString(input)}")
}

 

 

2. 문자열에서 특정 문자 개수 세기

 - 문제 설명 : 주어진 문자열에서 특정 문자가 몇 번 등장하는지 계산하는 코드를 작성해보자.

 - 힌트 : 반복문과 조건문을 사용하자

더보기
fun countChar(str: String, ch: Char): Int {
    var count = 0
    for (c in str) {
        if (c == ch) {
            count++
        }
    }
    return count
}

fun main() {
    val input = "banana"
    println("문자 'a'의 개수: ${countChar(input, 'a')}")
    // 출력: 문자 'a'의 개수: 3
}

 

 

3. 문자열에서 단어 개수 세기

 - 문제 설명 : 사용자가 입력한 문장에서 단어의 개수를 출력하는 코드를 작성해보자

 - 힌트 : Kotlin의 내장 함수를 사용하자

더보기
fun countWords(sentence: String): Int {
    // 공백을 기준으로 단어 분리 후, 분리된 리스트의 크기를 반환
    val words = sentence.trim().split("\\s+".toRegex())
    return words.size
}

fun main() {
    val sentence = "Kotlin is fun and powerful"
    println("단어 개수: ${countWords(sentence)}")
    // 출력: 단어 개수: 5
}



// val words = sentence.trim().split("\\s+".toRegex()) 동작 순서

// 1. sentence.trim()
// 동작: 입력된 문자열 sentence의 앞뒤(시작과 끝)에 있는 불필요한 공백(스페이스, 탭 등)을 제거합니다.
// 예시: " Hello Kotlin! " → "Hello Kotlin!"

// 2.  .split("\\s+".toRegex())
// 동작:
// trim()으로 정리된 문자열을 기준으로, 하나 이상의 공백 문자(정규표현식 \\s+)를 찾아서 해당 부분에서 문자열을 나눕니다.
// \\s+는 정규 표현식에서 "하나 이상의 공백 문자"를 의미합니다.
// .toRegex()는 문자열 "\\s+"를 정규식 객체로 변환해줍니다.
// 예시: "Hello Kotlin!" → ["Hello", "Kotlin!"]

 

 

 

 

 

 

 

 

 

 

Kotlin에서는 fun 키워드를 사용하여 함수를 정의한다.

 

예를 들어, 두 숫자를 더하는 함수를 작성해보자

// 두 숫자를 더하는 함수 정의
fun add(a: Int, b: Int): Int {
    return a + b
}

fun main() {
    val sum = add(10, 20)
    println("10과 20의 합: $sum")  // 출력: 30
}

 

이런 함수는 다음의 장점을 갖는다.

  • 코드 재사용 : 같은 기능이 필요한 경우 함수를 여러 번 호출할 수 있다.
  • 모듈화 : 복잡한 문제를 작은 기능 단위로 나누어 작성할 수 있어, 코드 관리가 쉬워진다.
  • 가독성 향상 : 함수 이름만 보고 어떤 역할을 하는지 이해할 수 있다.

 

리스트 (List)

리스트는 여러 데이터를 순서대로 저장할 수 있는 자료구조이다. 배열과 유사하지만, 리스트는 크기가 동적으로 변할 수  있는 장점이 있다.

 

Kotlin에는 불변 리스트와 가변 리스트가 존재한다.

  • 불변 리스트 : 생성 후 수정할 수 없다.
  • 가변 리스트(MutableList) : 생성 후 요소 추가, 삭제 등이 가능하다.
// 불변 리스트 예제

fun main() {
    val fruits: List<String> = listOf("사과", "바나나", "체리")
    println("첫 번째 과일: ${fruits[0]}") // 출력: 사과
}
// 가변 리스트 예제

fun main() {
    val fruits: MutableList<String> = mutableListOf("사과", "바나나", "체리")
    fruits.add("딸기") // 새로운 과일 추가
    println("과일 목록: $fruits") // 출력: [사과, 바나나, 체리, 딸기]
}

 

리스트의 장점

  • 동적 크기 : 리스트는 요소를 자유롭게 추가하거나 삭제할 수 있어, 데이터의 양이 변할때 유용하다.
  • 다양한 내장 함수 : Kotlin은 리스트를 다루기 위한 다양한 함수(map, filter, reduce 등)를 제공하여 데이터를 쉽게 처리할 수 있다.

 

 

문제 예시

1. 두 숫자의 곱과 나눗셈

 - 문제 설명 : 두 정수를 입력받아 곱한 결과와 나눈 결과(몫)을 출력하는 함수를 각각 작성해보자

 - 힌트 : 나눗셈에서는 0으로 나누는 경우를 고려해 예외 처리를 한다.

더보기
fun multiply(a: Int, b: Int): Int {
    return a * b
}

fun divide(a: Int, b: Int): Int {
    if (b == 0) {
        println("0으로 나눌 수 없습니다.")
        return 0  // 또는 적절한 예외 처리를 할 수 있음
    }
    return a / b
}

fun main() {
    println("곱셈 결과: ${multiply(10, 5)}")    // 출력: 50
    println("나눗셈 결과: ${divide(10, 2)}")    // 출력: 5
    println("나눗셈 결과: ${divide(10, 0)}")    // 출력: 0과 "0으로 나눌 수 없습니다." 메시지
}

 

 

2. 학생 성적 평균 구하기

 - 문제 설명 : 학생들의 성적이 저장된 리스트가 주어질 때, 모든 학생의 평균 성적을 계산하여 출력하는 코드를 작성해보자

 - 힌트 : 가변 리스트 또는 불변 리스트를 사용하여 성적 데이터를 저장한다. 리스트의 모든 요소를 합산한 후, 리스트의 크기로 나눈다.

더보기
fun calculateAverage(scores: List<Int>): Double {
    var sum = 0
    for (score in scores) {
        sum += score
    }
    return sum.toDouble() / scores.size
}

fun main() {
    val scores = listOf(80, 90, 100, 70, 60)
    println("평균 성적: ${calculateAverage(scores)}") // 출력: 평균 성적: 80.0
}

 

 

3. 짝수만 출력

 - 문제 설명 : 정수 리스트가 주어졌을 때, 리스트에서 짝수인 숫자만 출력하는 코드를 작성해보자

 - 힌트 : 리스트의 각 요소를 반복문으로 순회하며, 짝수인 경우만 출력한다.

더보기
fun printEvenNumbers(numbers: List<Int>) {
    for (num in numbers) {
        if (num % 2 == 0) {
            println(num)
        }
    }
}

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    println("짝수 리스트:")
    printEvenNumbers(numbers)
    // 출력: 2, 4, 6, 8, 10 (각 줄마다 하나씩)
}

회사에서 일을 하면서 실제로 대규모 트래픽을 경험해 볼 수는 없다. 끽해야 일일 접속자가 350명 정도..? 사내 서비스라 이용자가 너무 적다. 

 

여러 구인구직 공고를 보면 대부분 대규모 트래픽을 구축해보거나 경험해본 사람들을 우대하는 것을 보고 나 스스로 대규모 트래픽에 관해 여러 문서나 사례등을 바탕으로 대규모 트래픽을 처리하는 방법에 대해 많이 공부해야 된다고 느꼈다.

 

이 글 하나만 읽어도 대규모 트래픽 환경에서 어떻게 안정적인 시스템을 설계할 수 있는지 확실하게 정리하고자 한다.(나처럼 실제 트래픽을 경험해 본 적이 없더라도 말이다..)

 

 

1. 대규모 트래픽이란?

대규모 트래픽은 한꺼번에 수많은 사용자가 웹사이트나 애플리케이션에 접속해 데이터를 요청하는 상황을 말한다. 예를 들어, 쇼핑몰의 대규모 세일 기간이나, 로또 청약 신청, 공연 티켓 예매등 평소보다 엄청난 사용자가 몰리는 경우를 의미한다.

 

이런 상황에서는 제대로 준비가 되어있지 않다면 서버가 과부화되어 느려지거나 다운될 위험이 있기 때문에, 효율적인 시스템 설계가 매우매우 중요하다.

 

 

대규모 트래픽 환경에서는 다음과 같은 문제들이 발생할 수 있다.

 

2. 대규모 트래픽이 주는 도전 과제

  • 응답 속도 유지 : 수많은 요청이 동시에 들어와도 빠르게 응답해야 한다.
  • 서버 과부화 방지 : 한 서버에 모든 요청이 몰리면 서버가 다운될 수 있으므로, 부하를 적절히 분산시켜야 한다.
  • 데이터 일관성 유지 : 여러 서버가 동시에 데이터를 처리할 때, 데이터의 정확성과 일관성을 유지하는 것이 필수적이다.
  • 확장성 : 사용자 수가 급증해도 시스템이 원활하게 동작하도록 쉽게 확장할 수 있어야 한다.

 

그렇다면 위의 과제들을 해결하면서 어떻게 하면 효율적인 시스템을 설계할 수 있을까?

 

3. 효율적인 시스템 설계 전략

3-1. 수평 확장(Horizontal Scaling)

수평 확장의 개념은 한 서버의 성능을 높이는 대신, 여러 대의 서버를 추가하여 전체 트래픽을 분산시키는 방법이다. 비유하자면 한 교실에 너무 많은 학생이 모이면 선생님이 수업을 제대로 진행하기 어려우니, 여러 교실로 나누어 진행하는 것과 같다.

 

Spring Boot/Kotlin을 사용하는 백엔드 서버를 예시로 들어보자 (모든 예시는 Spring Boot 프레임워크와 Koltin을 사용하는 백엔드이다.)

 

수평 확장은 보통 클라우드 환경(AWS, GCP, Azure)이나 컨테이너 오케스트레이션(Kubernetes)과 함께 사용한다. Spring Boot 애플리케이션은 별도의 추가 설정 없이도 여러 인스턴스를 띄우면 수평 확장이 가능하다.

 

더보기

* 관련해서 더 알아보아야 하는 것 : Docker를 이용해 여러 개의 Spring Boot 컨테이너를 실행하고, Kubernetes를 사용하여 Auto Scaling 기능을 적용하는 방법을 찾아보자.

 

3-2. 부하 분산(Load Balancing)

부하 분산은 사용자 요청을 여러 서버에 골고루 분산시켜 한 서버에 부담이 집중되지 않도록 하는 기술이다. 음식점에서 한 웨이터가 모든 손님을 상대하기 어려우니, 여러 웨이터가 각 테이블을 나누어 케어하는 것과 같다.

 

Spring Boot 애플리케이션은 외부 로드 밸런서(Nginx, HAProxy, AWS ELB 등)와 함께 사용하여 부하 분산을 쉽게 구현할 수 있다.

직접 로드 밸런싱 코드를 작성하는게 아니라 로드 밸런서 설정 파일을 통해 서버 간 트래픽 분산을 관리한다.

 

Nginx 설정 일부 예시 :

upstream spring_backend {
    server 192.168.1.101:8080;
    server 192.168.1.102:8080;
    server 192.168.1.103:8080;
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://spring_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

 

 

3-3. 캐싱(Caching)

캐싱은 자주 사용되는 데이터를 미리 저장해 두어, 데이터베이스나 다른 서버에 매번 접근하지 않고 빠르게 응답하는 기술이다. 냉장고에 좋아하는 간식을 미리 저장해두면, 매번 마트에 가지 않고도 간식을 즐길 수 있는 걸 생각하면 비슷하다.

 

Spring Boot는 Redis와 같은 캐시 솔루션과 쉽게 통합할 수  있다.

 

Redis 캐시 설정 및 사용 예시 : 
1. build.gradle.kts에 Redis 의존성 추가

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    // ... 기타 의존성
}

 

2. applicaiton.properties에 Redis 설정 추가

# Redis 서버 설정 (로컬에서 Redis가 실행 중임을 가정합니다)
spring.redis.host=localhost
spring.redis.port=6379

# 기본 서버 포트 (8080번 포트로 실행)
server.port=8080

 

3. Kotlin 코드 예제 - 캐시 서비스 구현

 

CacheService.kt

package com.example.demo.service

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit

/**
 * CacheService 클래스는 Redis를 이용하여 캐시 기능을 제공하는 서비스이다.
 * 이 클래스에서는 데이터를 캐시에 저장(setCache)하고, 조회(getCache)하는 기능을 구현한다.
 */
@Service
class CacheService(@Autowired val redisTemplate: RedisTemplate<String, String>) {

    /**
     * setCache 함수는 지정된 key와 value를 캐시에 저장합니다.
     * timeout은 캐시에 데이터가 유지될 시간(초)입니다.
     */
    fun setCache(key: String, value: String, timeout: Long = 60) {
        // opsForValue()는 단순한 key-value 캐싱에 사용됩니다.
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)
    }

    /**
     * getCache 함수는 지정된 key에 해당하는 캐시된 값을 반환합니다.
     * 만약 캐시에 값이 없으면 null을 반환합니다.
     */
    fun getCache(key: String): String? {
        return redisTemplate.opsForValue().get(key)
    }
}

 

AsyncService.kt

package com.example.demo.service

import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service

/**
 * AsyncService 클래스는 비동기 작업을 처리하기 위한 서비스이다.
 * @Async 어노테이션을 사용하여, 이 클래스의 메서드가 호출될 때 즉시 반환되고,
 * 별도의 스레드에서 작업을 수행하게 된다.
 */
@Service
class AsyncService {

    /**
     * doAsyncWork 함수는 3초 동안 대기한 후 "비동기 작업 완료!" 메시지를 콘솔에 출력한다.
     * 이 함수는 @Async 어노테이션 덕분에 호출 즉시 비동기적으로 실행된다.
     */
    @Async
    fun doAsyncWork() {
        // 3초간 대기하여, 긴 작업을 비동기적으로 처리하는 예제를 시뮬레이션한다.
        Thread.sleep(3000)
        println("비동기 작업 완료!")
    }
}

 

 

DemoController.kt

package com.example.demo.controller

import com.example.demo.service.AsyncService
import com.example.demo.service.CacheService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * DemoController는 REST API를 제공하는 컨트롤러이다.
 * 이 컨트롤러는 캐시 기능과 비동기 작업 기능을 테스트하기 위한 API를 제공한다.
 */
@RestController
class DemoController(
    val cacheService: CacheService,  // CacheService를 주입받아 캐시 기능을 사용한다.
    val asyncService: AsyncService   // AsyncService를 주입받아 비동기 작업을 수행한다.
) {

    /**
     * /ping API는 캐시에서 "greeting" 키의 값을 조회하고,
     * 값이 없다면 "pong"을 캐시에 저장한다.
     * 동시에 비동기 작업을 실행하고, "pong"을 응답한다.
     */
    @GetMapping("/ping")
    fun ping(): String {
        val key = "greeting"
        // 캐시에서 key "greeting"의 값을 가져온다.
        var value = cacheService.getCache(key)
        // 만약 캐시에 값이 없으면,
        if (value == null) {
            value = "pong"
            // 캐시에 60초 동안 "pong" 값을 저장한다.
            cacheService.setCache(key, value, 60)
        }
        // 비동기 작업을 실행한다.
        // 이 작업은 백그라운드에서 3초 후 완료된다.
        asyncService.doAsyncWork()
        // "pong"을 응답으로 반환한다.
        return value
    }
}

 

 

3-4. 비동기 처리와 큐

사용자의 요청을 즉시 처리하지 않고, 큐에 저장한 후 차례대로 처리하는 방식이다. (인터파크에서 앞에 몇명이 남았다고 알려주는게 큐를 사용해서 그런게 아닐까?) 놀이공원에서 사람들이 대기열에 서 있다가 순서대로 놀이기구를 타는 것처럼, 요청을들 순차적으로 처리한다.

 

@Async 어노테이션을 사용하면 쉽게 비동기 작업을 구현할 수 있다. 

간단한 예제를 보면

// AsyncService.kt
package com.example.demo.service

import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service

/**
 * AsyncService는 긴 작업을 비동기적으로 처리하는 서비스이다.
 * @Async 어노테이션을 사용하여, 이 메서드가 호출되면 별도의 스레드에서 실행된다.
 */
@Service
class AsyncService {

    /**
     * doAsyncWork 함수는 3초간 대기 후 콘솔에 "비동기 작업 완료!" 메시지를 출력한다.
     */
    @Async
    fun doAsyncWork() {
        // 3000 밀리초 (3초) 동안 대기한다.
        Thread.sleep(3000)
        println("비동기 작업 완료!")
    }
}

 

 

비동기에 대해서 감이 안올수 있다. Controller에서 비동기를 호출 후 처리하는 간단한 예제를 살펴보자

// DemoController.kt
package com.example.demo.controller

import com.example.demo.service.AsyncService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * DemoController는 간단한 REST API를 제공하여, 비동기 작업을 테스트할 수 있게 한다.
 */
@RestController
class DemoController(val asyncService: AsyncService) {

    /**
     * /asyncTest 경로를 호출하면, 비동기 작업이 실행되고 즉시 응답을 반환
     */
    @GetMapping("/asyncTest")
    fun asyncTest(): String {
        asyncService.doAsyncWork()  // 비동기 작업 실행
        return "비동기 작업이 시작되었습니다!"
    }
}

 

위 코드가 어떻게 동작할까? 사용자가 /asyncTest API를 호출하면, 서버는 즉시 "비동기 작업이 시작되었습니다!" 라는 응답을 반환하고, 백그라운드에서 3초 후 "비동기 작업 완료!" 메세지를 콘솔에 출력한다. 순서대로 실행되는게 아니다.

 

Spring Boot와 Kotlin에서 간단한 메시지 큐를 구현해보자(RabbitMQ 사용)

 

1. build.gradle.kts에 의존성 추가

dependencies {
    // RabbitMQ와 Spring Boot 연동을 위한 의존성
    implementation("org.springframework.boot:spring-boot-starter-amqp")
    // 기타 의존성...
}

 

2. application.properties에 RabbitMQ 설정

# RabbitMQ 서버 설정 (기본적으로 로컬에서 실행 중이라고 가정)
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672

 

메세지 큐 서비스 구현

// MessageQueueService.kt
package com.example.demo.service

import org.springframework.amqp.rabbit.annotation.RabbitListener
import org.springframework.amqp.rabbit.core.RabbitTemplate
import org.springframework.stereotype.Service

/**
 * MessageQueueService는 RabbitMQ를 사용하여 메시지 큐를 통한 비동기 처리를 구현한 서비스
 */
@Service
class MessageQueueService(val rabbitTemplate: RabbitTemplate) {

    /**
     * sendMessage 함수는 지정한 메시지를 "queue.tetris" 큐로 전송한다.
     */
    fun sendMessage(message: String) {
        rabbitTemplate.convertAndSend("queue.tetris", message)
    }

    /**
     * receiveMessage 함수는 "queue.tetris" 큐를 구독하여 메시지를 수신한다.
     * 메시지가 수신되면 콘솔에 출력하고, 필요한 추가 처리를 수행할 수 있다.
     */
    @RabbitListener(queues = ["queue.tetris"])
    fun receiveMessage(message: String) {
        println("메시지 수신: $message")
        // 메시지 처리 로직을 여기에 작성할 수 있다.
    }
}

 

위에서 만든 메시지 큐를 활용하는 Controller 를 만들어보자.

// QueueController.kt
package com.example.demo.controller

import com.example.demo.service.MessageQueueService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * QueueController는 메시지 큐를 통해 작업을 비동기적으로 처리하는 API를 제공
 */
@RestController
class QueueController(val messageQueueService: MessageQueueService) {

    /**
     * /queueTest 경로를 호출하면, "테트리스 블럭 이동"과 같은 작업 메시지를 큐에 전송한다.
     */
    @GetMapping("/queueTest")
    fun queueTest(): String {
        // 메시지 큐에 메시지를 전송한다.
        messageQueueService.sendMessage("테트리스 블럭 이동")
        return "메시지가 큐에 전송되었습니다!"
    }
}

 

사용자가 /queueTest API를 호출하면 메시지 "테트리스 블록 이동"이 RabbitMQ 큐에 전송되고, 해당 큐를 구독 중인 receiveMessage  메서드가 메시지를 수신하여 처리한다.

 

 

전체 아키텍쳐 다이어그램을 살펴보면

 

이런 식으로 표현할 수 있다. 사용자의 요청이 로드 밸런서를 통해 여러 서버에 분산되고, 각 서버는 캐싱, 비동기 처리. 메시지 큐, 데이터베이스를 활용해 안정적으로 응답을 제공하는 구조를 보여준다.

 

뭔가 부족한것 같다. 앞으로 각각의 자세한 내용을 더 공부해야겠다.

우선 로그란 컴퓨터 프로그램이 실핸되는 동안 일어나는 사건이나 상태를 기록한 기록부라고 생각하면 된다.

예를 들어, 사람이 일기를 쓰고 나중에 일기장으로 그날 하루를 돌아보듯 프로그램도 "어떤 일이 일어났는지"를 기록한다. 

 

로그 관리는 문제를 빠르게 찾아내거나, 시스템. 상태를 확인할 때 매우 중요하다.

 

Gradle을 이용한 프로젝트 설정 (Gradle로 개발을 하고 있기 때문에 Gradle에 대해서 작성)

더보기
더보기

Gradle에 대해 모르거나 까먹었을 수 있다. 

Gradle은 프로젝트를 빌드(컴파일, 테스트, 배포 등) 하는 도구이다.  그렇다면 또 빌드에 대해서 헷갈릴 수 있다. 빌드란? 소스코드 파일을 컴퓨터에서 실행할 수 있는 독립적인 형태로 변환하는 과정과 결과를 말한다. 즉, 개발자가 작성한 소스코드, 각각의 파일 자원 ( .xml, .jpa, .jpg, .properties)을 jvm이나 톰캣 같은 WAS가 인식할 수 있도록 패키징하는 과정 및 결과물을 빌드라고 한다.

 

다시 돌아와서 Gradle은 스프링 부트와 안드로이드에서 사용되며 빌드 속도가 Maven에 비해 10 ~ 100배 정도 빠르며, Java, C/C++, Python 등을 지원한다.

 

Gradle의 특징으로는 

1. 가독성이 좋다.

2. 재사용에 용이

3. 구조적인 장점

4. 편리함

5. 멀티 프로젝트

등이 있다. 

 

로그 관리를 위해서는 Spring Boot의 로깅 기능(기본적으로 Logback)을 사용한다.

build.gradle 파일에 아래와 같이 의존성을 추가한다.

plugins {
    id("org.springframework.boot") version "2.7.5" // Spring Boot 버전
    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_11

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot 기본 기능 (웹, 로깅 등)
    implementation("org.springframework.boot:spring-boot-starter-web")
    
    // Spring Boot AOP, 로깅 관련 기능 (Logback은 기본 포함)
    implementation("org.springframework.boot:spring-boot-starter-aop")
    
    // Kotlin 관련 의존성
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

 

 

로그 저장 : Logback과 파일 관리

Logback과 SLF4J에 대해 먼저 알아보자

  • SLF4J는 여러 로깅 라이브러리(Logback, Log4 J 등)를 추상화하여 사용하는 인터페이스이다.
  • Logback 은 Spring Boot에서 기본적으로 사용하는 로깅 프레임워크이다. Logback은 로그를 파일에 저장하거나 콘솔에 출력하는 등의 기능을 제공한다.

 

로그 설정 파일 : logback-spring.xml

Spring Boot 에서는 src/main/resource 폴더 안에 logbak-spring.xml 파일을 만들면, 로깅의 형식, 저장 위치, 파일 회전(일정 시간마다 새 파일로 저장)등을 설정할 수 있다.

<configuration>
    <!-- FILE appender: 로그를 파일에 저장 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 로그 파일 저장 위치 -->
        <file>logs/app.log</file>
        <!-- 로그 파일 회전 정책: 매일 새 파일 생성하고, 30일간 보관 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <!-- 로그 출력 형식: 날짜, 스레드, 로그 레벨, 로거 이름, 메시지 -->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 콘솔에도 로그 출력 (옵션) -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 루트 로거 설정: 기본 로그 레벨을 INFO로 설정 -->
    <root level="INFO">
        <appender-ref ref="FILE" />
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

 

위 설정은 

  • logs/app.log 라는 파일에 로그를 기록한다.
  • 매일 새로운 로그 파일을 만들고, 30일 지난 로그는 삭제한다
  • 콘솔에도 로그가 출력되어, 개발 중에 쉽게 확인할 수 있다.

이렇게 이해하면 된다.

 

 

위와 같이 저장한 로그 파일에서 로그를 조회하기 위한 방법은 몇 가지가 있다.

(1) 파일 탐색기나 텍스트 에디터 사용

  • 로그 파일을 텍스트 에디터(VS Code, Notepad++ 등)로 열어 직접 내용을 확인하는 방법

(2) 터미널 명령어 사용

  • tail : 최신 로그 및 몇 줄을 확인할때 사용한다. ( 예 : tail -f app.log )
  • grep : 특정 키워드(ERROR, 특정 메서드 이름)를 검색할 때 사용한다. ( 예 : grep "ERROR" logs/app.log )

 

 

중앙 집중식 로그 관리 시스템 

ELK 스택 : Elasticsearch, Logstash, Kibana 를 사용하면, 웹 인터페이스에서 로그를 필터링 하고 조회할 수 있따.

이 방법은 여러 서버의 로그를 한 곳에서 모아 볼 때 유용하다.

 

ELK 스택의 구성

  • Elasticsearch: 로그 데이터를 저장하고, 빠르게 검색할 수 있는 데이터베이스 역할을 합니다.
  • Logstash/Filebeat: 여러 서버에서 로그 파일을 수집하여 Elasticsearch로 전송합니다.
  • Kibana: Elasticsearch에 저장된 로그 데이터를 시각화하고, 웹 인터페이스에서 검색할 수 있도록 해 줍니다.

ELK 스택의 장점

  • 중앙 집중식 조회: 모든 서버의 로그를 한 곳에서 볼 수 있습니다.
  • 실시간 검색 및 필터링: 원하는 키워드나 시간대에 맞춰 로그를 빠르게 검색할 수 있습니다.
  • 대시보드: 시각화 도구를 사용해 서버 상태를 모니터링하고 문제를 빠르게 파악할 수 있습니다.

 

추후에 중앙 집중식 로그 관리 시스템에 대해서 정리가 필요할 것 같다.

자바로 개발을 하다 보면 가장 자주 발생하는 오류 중 하나가 NullPointerException 이다. (진짜...)

 

NullPointerException은 프로그램이 '값이 없다'는 상태인 null 값을 사용하려고 할 때 발생하는 오류이다. 정말 초보 개발자일때 뿐만 아니라 숙련이 되더라도 언제든 큰 골칫거리가 될 수 있다고 생각한다.

 

Kotlin은 이러한 문제를 근본적으로 해결하기 위해 Null Safety라는 개념을 도입했다. 

 

우선 Null에 대해서 정의를 해보자

 

 

Null이란 무엇인가?

Null은 "값이 없음"을 의미하는 특별한 값이다.

프로그래밍에서 어떤 변수에 값이 할당되지 않았거나, 사용할 수 없는 상태를 표현할 때 null을 사용한다. 예를 들어, 친구 목록을 저장하는 변수에 친구가 한 명도 없다면, 그 변수는 null일 수 있다.

var friend: String? = null // 친구가 없는 상태

 

여기서 중요한 점은 null을 허용하는 변수와 허용하지 않는 변수가 있다는 점이다.

 

만약 null인 값을 잘못 사용하면 프로그램은 오류를 내며 멈춰버릴 수 있다. 실제로, 많은 프로그래밍 오류는 이런 null 관련 문제에서 발생한다.

 

 

Kotlin의 Null Safety 기능

Kotlin은 변수 선언 시, 해당 변수가 null을 가질 수 있는지 여부를 명확하게 표시한다. 이를 통해 컴파일러가 자동으로 null 관련 오류를 사전에 경고해준다.

 

위에서 언급했던 Non-Nullable 변수와 Nullable 변수에 대해서 알아보자

  • Non-Nullable 변수 : 변수 선언 시에 기본적으로 null을 허용하지 않는다. 예를 들어, var name: String = "Alice" 라고 선언하면, 이 변수는 null 값을 가질 수 없다.
  • Nullable 변수 : 변수 뒤에 ?를 붙여 선언하면 null 값을 허용한다. 예를 들어, var friend: String? = null 이라고 선언하면, 이 변수는 null일 수도 있고, 실제 문자열 값을 가질 수도 있다.
fun main() {
    // Non-Nullable 변수: null 할당 불가
    var name: String = "Alice"
    // name = null  // 컴파일 에러 발생

    // Nullable 변수: null 할당 가능
    var friend: String? = null
    println("친구: $friend")
}

 

안전 호출 연산자 (?.)

Nullable 변수에 접근할 때, 직접 접근하면 null이 반환될 수 있어 오류가 발생할 수 있다. Kotlin에서는 안전 호출 연산아 "?." 를 사용하여 null일 때 안전하게 처리할 수 있다.

fun main() {
    var friend: String? = null
    // friend가 null이면 toUpperCase()를 호출하지 않고 전체 결과가 null로 처리됨
    println(friend?.toUpperCase())  // 출력: null

    friend = "Bob"
    println(friend?.toUpperCase())  // 출력: BOB
}

 

여기서 friend?.toUpperCase()는 friend가 null이 아니기 때문에 대문자로 변환하고, null이면 null을 그대로 반환한다.

 

 

엘비스 연산자 (?:)

안전 호출 연산자와 함께 엘비스 연산자 "?:" 를 사용하면, null인 경우에 기본 값을 제공할 수 있다.

fun main() {
    var friend: String? = null
    // friend가 null이면 "알 수 없음"을 대신 출력
    val friendName = friend ?: "알 수 없음"
    println("친구 이름: $friendName")
}

 

이 예제에서 friend가 null이기 때문에, "알 수 없음"이 friendName에 할당되어 출력된다.

 

 

let 함수

Kotlin에서는 null이 아닐 때만 특정 작업을 실행하는 간단한 방법으로 let 함수를 제공한다.

fun main() {
    var friend: String? = "Charlie"
    
    // friend가 null이 아닐 때만 블록 안의 코드 실행
    friend?.let {
        println("친구 이름 길이: ${it.length}")
    }
    
    friend = null
    // friend가 null이면 let 블록은 실행되지 않음
    friend?.let {
        println("이 코드는 실행되지 않습니다.")
    }
}

 

위처럼 let 함수는 nullable 변수가 null이 아닐 때 안전하게 해당 값을 사용할 수 있도록 도와준다.

+ Recent posts