운영체제를 모르는 개발자는 땅의 구조를 모른 채 건물을 짓는 사람과 같다. 실무에서 메모리 부족, 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
장치 컨트롤러와 장치 드라이버  (1) 2024.08.13
RAID 정의와 종류  (0) 2024.08.08
보조기억장치  (1) 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가 되어, 최종적인 값이 출력되는 것이다.

 

 

 

 

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()는 컬렉션에 포함된 각 요소를 순회하며 동일한 타입의 배열을 만들어, 그 배열에 요소들을 채워 반환한다.

+ Recent posts