쿼리 계획이란?

쿼리 계획(Execution Plan)은 오라클 데이터베이스가 SQL 문장을 실행할 때 "어떤 방법으로 데이터를 읽고 처리할지" 미리 계산해서 보여주는 설명서이다.

 

즉,

  • 테이블을 풀스캔할지?
  • 인덱스를 탈지?
  • 조인을 어떤 순서로 할지? 

등을 알려주는 실행 청사진이다.

 

이걸 보면 쿼리 성능 문제를 미리 예측하거나 최적화할 수 있다.

 

 

SQL Developer에서 Explain Plan 확인하는 기본 방법

  1. SQL Developer 실행
  2. 오라클 DB에 로그인
  3. 쿼리 작성  ex) SELECT * FROM employees WHERE department_id = 10;
  4. 쿼리 블록을 드래그하거나 선택한 후, 상단 메뉴에서 Explain Plan(F10) 버튼 클릭 또는 오른쪽 클릭 → Explain Plan 선택

 

Explain Plan 결과 화면 읽는 방법

컬럼 설명
Operation 어떤 작업을 했는지(Full Table Scan, Index Scan 등)
Object Name 대상 테이블이나 인덱스 이름
Rows (Cardinality) 예측 결과로 읽게 될 데이터 건수
Cost 이 작업에 필요한 예상 리소스 소비량(CPU, IO 등)
Bytes 읽어야 할 데이터 크기

 

Cardinality 가 높다 = 많은 데이터가 나온다. / Cardinality가 낮다 = 적은 데이터가 나온다.

 

자주 나오는 Operation 종류 및 의미

Operation 의미
TABLE ACCESS FULL 테이블 전체를 읽는다( 성능 위험 신호 But 더 좋을때도 있음!)
INDEX RANGE SCAN 인덱스를 범위 검색(좋음)
INDEX UNIQUE SCAN 인덱스를 정확히 하나만 검색(매우 좋음)
NESTED LOOPS 두 테이블을 반복적으로 조인(작은 데이터에 적합)
HASH JOIN 대량 데이터 조인 (메모리 사용 많음)
SORT AGGREGATE 집계 연산(SUM, COUNT 등)

 

 

 

Explain Plan 볼 때 주의할 점!

1. Cost가 낮다고 무조건 좋은 게 아니다.

Cost는 참고용이고 "IO 비용"을 줄이는게 최종 목표이다.

 

2. 풀스캔(TABLE ACCESS FULL)이 무조건 나쁜 건 아니다.

아주 작은 테이블이라면 풀스캔이 더 빠를 수도 있다. 하지만 큰 테이블이라면 반드시 인덱스 활용을 고려해봐야한다.

 

3. 실행계획이 변할 수 있다.

바인드 변수, 통계 정보, 힌트 등에 따라 변동이 가능하다. ALWAYS 실제 데이터와 상황을 고려해야 한다.

 

4. AUTOTRACE나 DBMS_XPLAN.DISPLAY를 써보자

Explain Plan은 예측, Autotrace는 진짜 실행 기반이므로 함께 보는 게 좋다.

대부분의 웹 애플리케이션은 DB Connection Pool을 사용한다. DB 커넥션 풀 덕분에 동시 사용자 요청을 빠르게 처리할 수 있다.

 

하지만 어느 순간, 예상치 못한 상황이 발생한다. 

 

그 순간중 하나가 바로 커넥션 풀 고갈(Connection Exhaustion) 이다.

 

  • DB 연결 대기 시간이 급격히 증가한다
  • 요청이 지연된다
  • 최악의 경우 애플리케이션 전체가 다운된다.

 

그렇다면 DB Connection Pool에 대해서 알아보자.

 

DB Connection Pool 이란?

DB 연결(Connection)을 매번 새로 열고 닫으면 비용이 크기 때문에, 일정 수의 커넥션을 미리 생성해 풀(pool)에 담아두고 재사용하는 구조이다.

 

특징 설명
커넥션 생성 비용 절감 연결이 미리 준비되어 빠른 응답 가능
커넥션 수 제한 가능 DB에 과한 부하를 주지 않음
대기시간 단축 필요시 즉시 커넥션 제공 가능

 

인기 있는 커넥션 풀 라이브러리는 다음과 같다.

  • HikariCP (스프링 부트 기본)
  • Tomcat JDBC Pool
  • DBCP2 (Apache)

그렇다면 커넥션 풀 고갈이란 무엇일까?

 

 

커넥션 풀 고갈(Connection Pool Exhaustion) 이란?

커넥션 풀 안의 커넥션이 모두 사용 중이어서, 새로운 요청에 빌려줄 커넥션이 없는 상태를 말한다.

 

발생 과정을 간략하게 살펴보면

  1. 사용자는 요청을 보낸다.
  2. 서버는 DB 작업을 하려고 커넥션을 풀에서 꺼낸다.
  3. 풀에 남은 커넥션이 없다.
  4. 요청은 대기(wait) 하거나, 시간 초과(timeout) 되며 실패한다.

커넥션 풀이 고갈되면

현상 설명
서버 응답 지연 커넥션을 얻을 때까지 기다리면서 전체 응답이 느려진다
타임아웃 발생 일정 시간 안에 커넥션을 못 얻으면 에러가 발생한다
DB 부하 급증 또는 다운 일부 트랜잭션이 커넥션을 오래 점유하면 DB에 과부화
서버 과부화/장애 요청이 몰리면서 스레드/큐가 포화, 시스템 전반 문제 발생

 

이런 현상들이 발생할 수 있다.

 

스프링부트 + HikariCP 에서 고갈상황을 억지로 만들어 보자.

spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 30000

 

위 설정을 추가해

  • 최대 10개의 커넥션만 풀에 존재시키고
  • 커넥션을 못 빌리면 최대 30초 동안 대기 후 타임아웃이 발생

만약 동시에 요청이 50개 들어오고, 각각 DB 작업을 오래 잡고 있으면 어떻게 될까?

 

결과 : 

  • 처음 10개는 커넥션 얻고 정상 처리
  • 나머지 40개는 커넥션 풀에서 대기
  • 일부 요청은 30초 대기하다 타임아웃 발생
  • 서버 전체가 느려지고 장애 징후가 보인다.

 

이런 커넥션 풀 고갈의 주요 원인은 다음과 같은 것들이 있다.

 

1. 커넥션 미반납(Conneciton Lock)

val conn = dataSource.connection
// ... 쿼리 수행
// conn.close() 빠뜨림!!

 

close()를 호출하지 않으면, 커넥션 풀로 반환되지 않는다. 시간이 지나면 커넥션이 모두 소비되고 고갈된다.

항상 try-with-resources 또는 finally 블록에서 명시적으로 close 해야 한다.

 

 

2. 긴 트랜잭션(Long Transaction)

트랜잭션 안에서 오래 걸리는 로직(슬로우 쿼리, 외부 API 호출 등)이 있으면, 커넥션을 장시간 점유하게 된다. 이렇게 되면

커넥션을 회수하지 못하고 대기열만 쌓임 → 고갈 상태가 된다.

 

 

3. 예상 외 트래픽 폭주

대규모 이벤트, 외부 봇 공격, 잘못된 반복 호출 등으로 서버가 과도하게 많은 요청을 동시에 처리하게 되면, 커넥션 풀 한계를 초과할 수 있다.

 

 

4. 커넥션 풀 설정 오류

  • maximumPoolSize를 너무 작게 설정
  • connectionTimeout을 너무 짧거나 길게 잡은경우

위와 같은 설정 문제도 고갈을 부추길 수 있다.

 

 

 

고갈 대응 방법

방법 설명
커넥션 풀 크기 조정 CPU 코어 수 x 2 또는 예상 TPS에 맞춰 조정
커넥션 타임아웃 설정 connectionTimeout을 적절하게 조절(ex: 5초)
커넥션 반납 철저 관리 close 누락 방지(try-with-resources 적극 사용)
트랜잭션 최소화 DB 점유 시간을 줄인다(슬로우 쿼리 개선)
커넥션 풀 모니터링 HikariCP Metrics, JMX를 통해 실시간 감지
슬로우 쿼리 탐지 및 개선 DB 쿼리 튜닝, 인덱스 최적화
백프레셔(Backpressure) 적용 서버에 요청이 몰릴 때, 내부적으로 대기시키거나 제한
이중화 및 읽기 전용 Replica DB 활용 부하 분산

Thread에 대해서 오래전에 정리했던 적이 있다. 하지만 일을 하다가.. 아직도 Thread에 대해서 잘모른다는 느낌이 들어서 더 자세하게 다시한번 정리를 하고자 한다.

 

https://codingstudy95.tistory.com/67

 

스레드

사전적 의미로 한 가닥의 실이라는 뜻으로 한가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어놓았다고 해서 유래된 이름이다. 하나의 스레드는 하나의 코드 실행 흐름이므로

codingstudy95.tistory.com

 

 

스레드란 무엇인가!!?

자바에서 Thread(스레드)는 프로그램 내에서 동시에 실행되는 작업의 단위를 의미한다. 회사에서 여러 사람이 각자의 다른 일을 동시에 하는 것과 마찬가지로, 스레드는 하나의 프로그램 안에서 여러 작업을 동시에 처리할 수 있도록 해준다.

(예: 데이터 처리, 사용자 요청 처리, 파일 입출력 등)

  • 단일 스레딩 : 한 사람이 모든 일을 순서대로 처리하는 것 -> 모든 작업이 순차적으로 처리되므로, 하나의 작업이 오래 걸리면 다른 작업들도 지연된다.
  • 멀티스레딩 : 여러 사람이 동시에 각자 일을 분담해서 처리하는 것 -> 여러 스레드가 동시에 작업을 처리하여, 한 작업이 늦어도 다른 작업은 계속 진행될 수 있다.

 

 

그럼 자바에서는 왜 멀티스레딩이 필요할까?

 

식당에서 한 사람이 모든 요리를 한다면 주문이 많은 경우 오래 걸리겠지만, 여러 요리사가 동시에 각자 다른 요리를 준비하면, 음식이 빨리 준비되는것과 비슷하다.

 

사용자 경험 향상

한 번에 한 작업만 처리한다면, 사용자가 어떤 요청을 할 때마다 기다려야 한다. 하지만 멀티스레딩을 사용하면, 여러 작업이 동시에 처리되어 응답 속도가 빨라지고 사용자 경험이 개선된다.

 

자원 활용의 극대화

컴퓨터는 여러 CPU 코어를 가지고 있는데, 멀티스레딩을 통해 이 코어들을 동시에 사용할 수 있다. 즉, 컴퓨터의 능력을 최대한 활용하여 더 빠르고 효율적으로 작업할 수 있다.

 

 

자바에서 스레드를 만들어보자 자바에서는 스레드를 만드는 방법이 두 가지 있다.

 

1. Thread 클래스 상속하기

// MyThread.kt
class MyThread : Thread() {
    // run 메서드를 재정의하여 스레드가 실행할 작업을 정의합니다.
    override fun run() {
        // 스레드가 실행될 때, "Hello from MyThread!"를 5번 출력합니다.
        for (i in 1..5) {
            println("Hello from MyThread! - $i")
            // 잠깐 멈추는 시간 (1000밀리초 = 1초)
            Thread.sleep(1000)
        }
    }
}

fun main() {
    // MyThread 클래스의 인스턴스를 생성하고, start()를 호출하면 스레드가 실행됩니다.
    val thread = MyThread()
    thread.start()
}
  • MyThread는 Thread 클래스를 상속받아 만든 새로운 스레드 클래스이다.
  • run() 메서드 안에 스레드가 해야 할 일을 작성한다.
  • thread.start() 를 호출하면, 새로운 스레드가 시작되어 run() 메서드의 내용이 실행된다.

 

2. Runnable 인터페이스 구현하기

또 다른 방법은 Runnable 인터페이스를 구현하는 것이다. 이 방법은 클래스 상속의 제약을 피할 수 있다는 장점이 있다.

// MyRunnable.kt
class MyRunnable : Runnable {
    override fun run() {
        // 스레드가 실행될 때, "Hello from MyRunnable!"를 5번 출력합니다.
        for (i in 1..5) {
            println("Hello from MyRunnable! - $i")
            Thread.sleep(1000)
        }
    }
}

fun main() {
    // Runnable 인터페이스를 구현한 MyRunnable 인스턴스를 Thread에 전달하여 실행합니다.
    val runnable = MyRunnable()
    val thread = Thread(runnable)
    thread.start()
}
  • MyRunnable 은 Runnable 인터페이스를 구현하여, run() 메서드 안에 작업 내용을 정의한다.
  • 이 객체를 Thread 생성자에 넘겨주고, start() 를 호출하면 스레드가 실행된다.

 

스레드의 생명주기와 상태

자바 스레드는 여러 상태를 가진다. 각 상태는 스레드가 어떤 작업을 하고 있는지를 나타낸다.

 

  • New: 스레드가 생성되었지만 아직 실행되지 않은 상태
  • Runnable: 실행 중이거나 실행 준비가 된 상태
  • Blocked/Waiting: 다른 스레드에 의해 잠시 멈춰 있는 상태
  • Timed Waiting: 일정 시간 후에 다시 실행될 상태
  • Terminated: 스레드의 작업이 모두 끝난 상태

한 사람이 일어나서 출근 준비를 하는 것처럼, 스레드도 만들어진 후 실행 준비, 작업 중, 대기, 그리고 작업 종료의 과정을 거친다.

 

 

 

근데 문제가 발생할 수 있다. 멀티스레딩에서 여러 스레드가 동시에 같은 데이터를 수정하려 할 때 문제가 발생할 수 있다.

이를 경쟁 조건 (Race Condition) 이라고 하며, 이를 해결하기 위해 동기화(Synchronization) 를 사용한다.

 

예를 들어 콘서트 티켓을 예매할때 한 좌석을 동시에 두명이 예매하려고 할때 좌석을 누구에게 할당해야 할까?  이런 문제를 해결하려면, 한 사람이 작업을 끝낼 때까지 기다리도록 해야 한다.

 

 

synchronized 키워드 사용 예제

class Counter {
    var count: Int = 0

    // synchronized를 사용해 여러 스레드가 동시에 count를 수정하지 않도록 보호합니다.
    @Synchronized
    fun increment() {
        count++
    }
}

fun main() {
    val counter = Counter()
    val threads = mutableListOf<Thread>()

    // 10개의 스레드를 생성하여 동시에 increment()를 호출합니다.
    for (i in 1..10) {
        val thread = Thread {
            for (j in 1..1000) {
                counter.increment()
            }
        }
        threads.add(thread)
        thread.start()
    }

    // 모든 스레드가 끝날 때까지 대기합니다.
    threads.forEach { it.join() }

    // 10개의 스레드가 각각 1000번씩 increment했으므로, 최종 결과는 10000이어야 합니다.
    println("최종 count: ${counter.count}")  // 결과: 10000
}
  • @Synchronized 어노테이션을 사용해 increment() 메서드에 동시에 접근하는 것을 막는다.
  • 여러 스레드가 동시에 increment() 를 호출해도, 동기화 덕분에 안전하게 실행된다.   

 

 

내가 회사에서 일하면서 실제로 겪은 스레드 문제가 있다.

 

경쟁 조건과 데드락

  • 경쟁조건 : 여러 스레드가 동시에 데이터를 수정할 때 예상치 못한 결과가 발생하는것
  • 데드락(DeadLock) : 두 스레드가 서로 상대방이 가진 자원을 기다리면서 무한 대기에 빠지는 상황이다.

동기화 블록이나 Lock 객체를 사용해, 자원에 접근하는 순서를 잘 관리해야 한다. 

 

실제로 데드락에 빠지는 로직을 개발하여 정말..난리 난리가 났었던...일이...후..

 

 

스레드 풀 (Thread Pool) 

매번 새로운 스레드를 생성하는 대신, 미리 일정 개수의 스레드를 만들어 두고 재사용하는 방법. 스레드의 생성 비용을 줄이고, 시스템 자원을 효율적으로 사용할 수 있다.

import java.util.concurrent.Executors

fun main() {
    // 고정 크기의 스레드 풀 생성 (3개의 스레드)
    val executor = Executors.newFixedThreadPool(3)

    // 10개의 작업을 스레드 풀에 제출합니다.
    for (i in 1..10) {
        executor.submit {
            println("작업 $i 시작: ${Thread.currentThread().name}")
            Thread.sleep(1000)
            println("작업 $i 완료: ${Thread.currentThread().name}")
        }
    }

    // 스레드 풀 종료
    executor.shutdown()
}
  • Executors.newFixedThreadPool(3)를 통해 3개의 스레드로 이루어진 풀을 생성한다.
  • 여러 작업이 동시에 제출되지만, 동시에 최대 3개 작업만 실행되고 나머지는 대기한다.

사용자 관점에서 한번 생각해보자

 

대부분의 웹 애플리케이션에서는 사용자가 데이터를 수정하고 저장을 누르면, 스레드 풀(Thread Pool) 에서 미리 만들어진 스레드 중 하나가 해당 요청을 처리한다.

 

즉, 사용자가 콘텐츠를 수정하고 저장 버튼을 클릭하면

  1. 웹 서버는 이미 생성되어 대기 중인 스레드 풀에서 하나의 스레드를 할당한다
  2. 해당 스레드가 수정 작업 로직을 실행하고
  3. 작업이 완료되면 그 스레드는 스레드 풀로 돌아가 재사용된다.

이제 스레드에 대해 확실히 알게 된 것 같다.

 

 

 

'Back-End > Java' 카테고리의 다른 글

null 체크, try-catch가 난무하는 코드 수정해보기  (6) 2025.06.09
HTTP 요청 하나당 스레드는 몇 개나 동작할까?  (0) 2025.05.12
try-catch  (0) 2024.07.06
스레드  (1) 2024.07.05
프로세스  (0) 2024.07.04

개발을 하다 보면 ArrayList를 쓸지, LinkedList를 쓸지 고민할 때가 많다. 특히 데이터를 추가할 때(add), 성능이나 메모리 측면에서 어떤 차이가 있는지 정확하게 이해하고 있으면, 더 좋은 성능을 만들 수 있을 것 같아서 정리해본다.

 

1. ArrayList 

  • 내부적으로 배열(Array) 기반으로 데이터를 저장하는 컬렉션이다.
  • 데이터가 추가되면서 배열 크기를 초과하면, 더 큰 배열을 새로 만들고 기존 데이터를 복사한다.
val arrayList = ArrayList<Int>()
arrayList.add(10) // 배열에 10 추가

 

특징 : 

  • 인덱스를 통한 접근 ( get(index) ) 이 매우 빠름 → O(1)
  • 중간 삽입/삭제는 느림 → O(N) (요소 이동 발생)

 

2. LinkedList

  • 노드(Node) 들이 포인터로 연결된 구조이다.
  • 각각의 노드는 데이터(data)와 다음 노드(next)를 가리키는 포인터를 가지고 있다.
val linkedList = LinkedList<Int>()
linkedList.add(10) // 노드로 10 추가

 

특징 : 

  • 앞/뒤 삽입/삭제가 매우 빠름 → O(1)
  • 인덱스를 통한 접근은 느림 O(N) (앞에서붜 순회 필요)

 

 

3. add 시 시간 복잡도 비교

상황 ArrayList LinkedList
맨 뒤에 추가( add(E e) ) 평균 O(1)
(가끔 O(N) 리사이즈)
항상 O(1)
중간에 추가 ( add(index, E e) ) O(N) (뒤 요소 이동) O(N) (위치 탐색 필요)

 

ArrayList의 경우

  • 맨 뒤에 추가할 때는 평균적으로 O(1) 이지만, 배열 용량이 부족하면 O(N) 리사이즈가 발생한다.
  • 중간에 추가할 경우, 추가 위치 이후의 모든 요소를 한 칸씩 밀어야 하므로 O(N)이다.

LinkedList의 경우

  • 맨 뒤에 추가는 항상 O(1) (tail 포인터 사용).
  • 중간에 추가할 경우, 목표 인덱스까지 노드를 순차적으로 탐색해야 하므로 O(N)이다.
  • 탐색 이후 삽입 자체는 포인터지만 연결하면 되기 때문에 삽입 동작은 O(1)이다.

 

 

4. 메모리 사용량 차이

항목  ArrayList LinkedList
메모리 구조 연속된 배열 분산된 노드 + 포인터 연결
한 요소 저장 시 추가 메모리 없음 (배열 포인터만) next/prev 포인터 2개 추가 필요

 

ArrayList의 경우

  • 요소 크기 * 배열 크기 만큼 메모리 사용.
  • 공간이 부족할 것을 대비해 capacity (버퍼 공간)을 여유롭게 잡기도 한다.
  • 배열이라서 캐시 친화적(cache friendly) 이다.

LinkedList의 경우

  • 각 노드마다 데이터 + next 포인터 + prev 포인터 (더블 링크드 리스트 기준)를 저장한다
  • 포인터 오버헤드 때문에 메모리 사용량이 훨씬 많음.
  • 메모리가 연속적이지 않아 캐시 미스(cache miss)가 자주 발생한다.

정리하면, 메모리 효율은 ArrayList가 압도적으로 좋다.

 

 

 

5. 상황별 추천

상황 추천 자료구조
빠른 랜덤 접근이 필요할 때 ArrayList
삽입/삭제가 매우 빈번할 때 LinkedList
메모리 효율을 중요시할 때 ArrayList
요소 수를 자주 바꿀 때 (초대량 삭제 등) LinkedList (특수 상황)

 

대부분의 일반적인 경우 (조회 + 추가)는 ArrayList가 훨씬 효율적이다. LinkedList는 특정 상황(삽입/삭제가 매우 빈번한 경우) 에만 신중하게 써야 한다.

 

 

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

분할 정복의 핵심 아이디어는 분할 정복 이름 그대로 '분할(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하게 헤야 한다.

Q1: JWT 기반 인증 방식의 장점과 구현 방법에 대해 설명해 주세요.

A1:
"JWT(JSON Web Token)는 상태 정보를 서버에 저장하지 않는 stateless 인증 방식으로, 확장성과 성능 면에서 유리합니다. 토큰은 헤더, 페이로드, 서명으로 구성되며, 클라이언트에게 발급된 토큰을 요청 시 함께 전송하여 인증을 수행합니다. Spring Security와 함께 JWT 필터를 구현해 토큰의 유효성을 검사하고, 인증 정보를 설정하는 방식으로 구현할 수 있습니다."

 

 

Q2: Kotlin의 주요 특징과 Spring Boot와의 통합에서의 이점에 대해 설명해 주세요.

A2:
"Kotlin은 간결한 문법, Null 안전성, 데이터 클래스, 확장 함수, 람다 및 고차 함수 등 다양한 기능을 제공하여 코드의 가독성과 생산성을 크게 향상시킵니다. Spring Boot와 통합 시, 코루틴을 활용한 비동기 처리와 DSL을 통한 설정 간소화 등으로 개발 효율성을 높일 수 있습니다."

 

 

Q3: Java와 Kotlin 간의 상호 운용성에서 발생할 수 있는 문제점과 해결 방법은 무엇인가요?

A3:
"Java와 Kotlin은 JVM 기반 언어이지만, Null 처리, 제네릭, 애노테이션 등에서 차이가 발생할 수 있습니다. Kotlin에서는 Java 라이브러리를 사용할 때 Non-null과 Nullable을 명확히 구분해야 하며, 필요한 경우 @NotNull, @Nullable 애노테이션을 활용하여 상호 운용성을 보완합니다. 또한, 자동 변환 도구를 활용해 Java 코드를 Kotlin으로 마이그레이션할 때 안전하게 변환할 수 있습니다."

 

 

 

Q4: 분산 트랜잭션 문제를 해결하기 위한 SAGA 패턴에 대해 설명해 주세요.

A4:
"SAGA 패턴은 분산 환경에서 하나의 트랜잭션을 여러 개의 로컬 트랜잭션으로 나누어 실행하고, 각 단계에서 문제가 발생할 경우 보상 트랜잭션을 실행해 데이터 일관성을 유지하는 방법입니다. 이를 통해 전통적인 분산 트랜잭션의 단점을 보완할 수 있습니다."

 

 

 

Q5: CI/CD 파이프라인 구축 시 고려해야 할 사항과 이를 Spring Boot 프로젝트에 적용하는 방법은 무엇인가요?

A5:
"CI/CD 파이프라인은 소스 코드 변경 시 자동으로 빌드, 테스트, 배포가 진행되도록 하는 프로세스입니다. Jenkins, GitLab CI, GitHub Actions 등 도구를 활용하여, 코드 커밋마다 자동화된 테스트와 빌드, 그리고 스테이징/프로덕션 환경에 배포하는 과정을 구성합니다. 이를 통해 빠른 피드백과 안정적인 배포가 가능해집니다."

 

 

각각에 대해서 자세하게 알아야 할 것 같다. 프로젝트에 하나씩 적용해 나가며 학습해보자!

'기술면접준비' 카테고리의 다른 글

Spring Boot 관련 5가지 예상질문 및 답변  (0) 2025.04.16
WAS와 WS  (1) 2023.12.20
기술면접준비(1)  (0) 2023.12.19

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

운영체제를 모르는 개발자는 땅의 구조를 모른 채 건물을 짓는 사람과 같다. 실무에서 메모리 부족, CPU 점유율 급등, 스레드 병목 같은 문제를 처음 만났을때 대부분 이렇게 느낀다.

 

운영체제(OS)란?

운영체제는 컴퓨터 하드웨어와 소프트웨서 사이에서 통역사 역할을 하는 프로그램이다. 우리가 작성한 코드가 실제 컴퓨터에서 "언제", "어떻게" 실행될지 결정하는 건 바로 이 운영체제다.

 

운영체제가 하는 일 : 

  • 프로그램이 CPU를 사용할 수 있도록 순서 정하기(스케줄링)
  • 여러 프로그램이 동시에 돌아가도 문제없게 메모리 나눠쓰기
  • 파일을 읽고 쓰는 걸 도와주는 파일 시스템 관리
  • 사용자와 하드웨어 장치(키보드, 프린터 등) 사이 중재

 

운영체제 핵심 개념 5가지

 

1. 프로세스 vs 스레드

구분 프로세스 스레드
정의 실행 중인 프로그램 프로세스 내의 작업 단위
메모리 독립된 메모리 공간 같은 메모리 공간 공유
충돌 시 하나 죽어도 다른 프로세스 영향 없음 하나가 죽으면 전체 영향 가능

 

 

2. CPU 스케줄링

운영체제는 모든 프로그램이 CPU를 동시에 사용할 수 없기 때문에 누구를 먼저, 얼마나 오래 실행할지 결정한다.

  • FCFS (First Come First Serve) : 먼저 온 순서대로
  • Round Robin : 시간을 나눠서 조금씩 번갈아 실행
  • Priority Scheduling : 우선순위 높은 작업 먼저 실행

실무에서 배치 작업이 느리거나 웹 요청 응답이 늦을 때, 내부적으로는 CPU 스케줄링이 원인일 수 있다.

 

 

3. 메모리 관리

운영체제는 여러 프로그램이 충돌하지 않도록 메모리를 구획을 나눠 관리한다.

  • Stack / Heap / Data / Code 영역
  • 가상 메모리 : 실제 물리 메모리가 부족할 때 디스크를 RAM처럼 쓰는 기술(swap 발생)

실무에서 발생하는 OutOfMemoryError, GC 지연 현상 등은 메모리 구간 이해가 핵심이다.

 

 

4. 동기화와 데드락

여러 스레드가 하나의 자원을 동시에 접근하면 문제가 생길 수 있다.

운영체제는 Lock이나 세마포어(Semaphore) 같은 방법으로 이를 막는다.

 

데드락 : A는 B의 리소스를, B는 A의 리소스를 기다리는 상황 -> 둘 다 멈춤

 

실무에서 DB 커넥션 풀, 멀티스레딩 환경에서 데드락은 진짜 흔하게 발생한다.

 

 

5. 인터럽트 (Interrupt)

컴퓨터는 외부의 신호 (예 : 키보드 입력, 하드디스크 응답 등)를 기다리지 않고, 인터럽트가 발생하면 즉시 반응한다.

  • 동기 -> 순서대로 기다림
  • 비동기(인터럽트 기반) -> 갑자기 들어오는 요청에도 빠르게 대응

실시간 처리가 중요한 서비스에서는 인터럽트를 효율적으로 처리하는 게 핵심이다.

 

 

왜 OS 지식이 필요한지 예시를 들어보자

fun main() {
    val thread1 = Thread { heavyComputation() }
    val thread2 = Thread { heavyComputation() }
    thread1.start()
    thread2.start()
}

 

위 코드에서 스레드 2개가 동시에 실행될 수 있을까???

 

정답은 아니요 이다. CPU 코어 수, 스케줄러 정책, OS가 허용하는 최대 스레드 수에 따라 실제 동작이 달라진다.

 

 

실무에서 마주치는 메모리 문제는 어떤게 있고, 어떻게 해결하는지도 한번 알아보자.

 

서비스는 점점 느려지고, 갑자기 서버가 죽었다? -> 90%는 메모리 문제라고 봐도 무방하다.

 

자바 개발자가 흔히 마주치는 메모리 이슈에는 다음과 같은 것들이 있다.

 

1. OutOfMemoryError (OOM)

JVM이 힙 공간을 더 이상 확보할 수 없을때 발생한다.

 

원인 : 

  • 무한 루프 안에서 List에 데이터 추가
  • 캐시를 비워주지 않음
  • 너무 많은 객체 생성(ex: 수천 개의 파일을 한 번에 처리)

해결전략 : 

  • JVM 힙 크기 조절 : -Xmx1024m
  • 캐시 정책 설정 (예 : LRU, TTL)
  • 대용량 작업 시 스트리밍 처리 (InputStream, Flux, Cursor)

 

2. GC 튜닝 문제로 인한 성능 저하

메모리는 충분한데 느리다?? -> GC에 시간이 다 잡아먹히고 있을 수 있다.

 

진단 방법. :

  • -Xlog:gc* 옵션으로 GC 로그 분석
  • jvisualvm, GCViewer, JFR (Java Flight Recorder) 사용

해결 전략 : 

  • G1 GC, ZGC 등 최신 GC 사용 고려
  • 객체 생명 주기 짧게 설계 (가비지 생성을 줄이기)
  • 메모리 재사용 (Object Pool 등)

 

3. PermGen / Metaspace 부족 (클래스 메타데이터 문제)

대규모 프로젝트, 플로그인 구조에서 자주 발생하는 문제이다.

 

원인 : 

  • 클래스 로더 누수
  • 동적 클래스 생성(ex: JSP, Proxy)

해결 전략 : 

  • -XX:MaxMetaspaceSize 설정
  • 코드 HotReload 시 reload 제한
  • 메모리 분석 툴로 클래스 로더 누수 확인(jmap, MAT)

 

메모리 문제 해결을 위한 필수 도구 모음

도구  용도
jamap 힙 덤프 추출
jhat, Eclipse MAT 힙 덤프 분석
jstat GC/메모리 상태 확인
VisualVM, JFR 실시간 분석
Netdata, Prometheus + Grafana 전체 시스템 메모리 모니터링

 

 

실전 트러블슈팅 사례 한가지만 살펴보자 

무한 수집 중 OutOfMemoryError 가 발생한 케이스다.

 

문제 상황 : 외부 API에서 대량의 데이터를 수집하여 List에 저장 -> 메모리 부족(OOM) 사용자 요청은 점점 늘고, 서버는 점점 느려지다 뻗어버림

 

문제 코드 (잘못된 예시)

@RestController
class LogCollectorController {

    val collectedLogs = mutableListOf<String>()  // 무한히 쌓임 → 힙 폭발

    @GetMapping("/collect")
    fun collect(): String {
        val externalLogs = callExternalApi()  // 외부에서 로그 1000건씩 수신
        collectedLogs.addAll(externalLogs)   // 계속 메모리에 추가만 됨
        return "Collected ${externalLogs.size} logs"
    }

    fun callExternalApi(): List<String> {
        // 외부 로그 API 시뮬레이션
        return List(1000) { "log line $it" }
    }
}

 

문제점

  • colletedLogs에 데이터를 계속 저장한다.
  • GC가 수거하지 못하는 상태로 누적된다 -> OOM 발생
  • 서버는 정상적으로 작동하는 것처럼 보이지만, 메모리는 계속 쌓임

해결 코드 (스트리밍 처리 + 즉시 저장)

@RestController
class LogCollectorController(val logService: LogService) {

    @GetMapping("/collect")
    fun collect(): String {
        val externalLogs = callExternalApi()

        // 스트리밍 처리로 한 건씩 바로 저장하여 메모리 사용 최소화
        externalLogs.forEach { log ->
            logService.save(log)
        }

        return "Collected ${externalLogs.size} logs"
    }

    fun callExternalApi(): Sequence<String> {
        // Sequence로 lazy하게 한 줄씩 처리
        return generateSequence(0) { it + 1 }
            .take(1000)
            .map { "log line $it" }
    }
}

@Service
class LogService {
    fun save(log: String) {
        // DB 또는 파일 저장 등의 실질적인 처리
        println("Saving log: $log")
    }
}

 

개선 포인트 

  • Sequence 사용 : List 에 다 넣지 않고, 한 줄씩 처리하므로 메모리 부담이 내려간다.
  • 즉시 저장 : GC가 객체를 빠르게 수거 가능하다
  • 상태 유지 X : collectedLogs와 같은 전역 메모리 누적 제거

 

메모리 문제는 버그보다 무섭다. 이유는 단순한데 느리거나 죽기 때문이다. 운영 중인 시스템이라면 예방이 가장 중요하고, 대응할 때는 반드시 근거 있는 추측과 도구 기반 분석이 필요하다.

 

'컴퓨터구조와 운영체제' 카테고리의 다른 글

운영체제의 큰 그림  (3) 2024.08.28
운영체제를 알아야 하는 이유  (1) 2024.08.14
장치 컨트롤러와 장치 드라이버  (0) 2024.08.13
RAID 정의와 종류  (0) 2024.08.08
보조기억장치  (0) 2024.07.20

투 포인터란 무엇일까?

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

 

 

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

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

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

 

 

이해가 잘 안된다 실생활 비유? 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가 되어, 최종적인 값이 출력되는 것이다.

 

 

 

 

+ Recent posts