| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Kotlin
- 알고리즘
- k8s
- java
- Effective Java
- 이펙티브 자바
- 스프링핵심원리
- 이펙티브자바
- 함수형프로그래밍
- Spring
- 알고리즘정렬
- ElasticSearch
- 스프링부트
- 오블완
- Sort
- 자바
- 티스토리챌린지
- JavaScript
- 자바스크립트
- 엘라스틱서치
- Effective Java 3
- kubernetes
- 예제로 배우는 스프링 입문
- 클린아키텍처
- effectivejava
- springboot
- 스프링
- 스프링 핵심원리
- 카카오
- 김영한
- Today
- Total
Kim-Baek 개발자 이야기
메모리 관리 - 5년차 개발자가 겪은 메모리 누수와의 전쟁 본문
서버가 느려지는 이유를 찾아서
월요일 오전, Court Alarm 서버의 응답 속도가 점점 느려지고 있었다.
배포 직후에는 평균 응답시간 200ms였는데, 2일이 지나니 2초, 3일째는 5초, 그리고 금요일엔 거의 응답이 없었다. 서버를 재시작하면 다시 빨라졌지만, 며칠 지나면 또 느려졌다.
모니터링 그래프를 보니 메모리 사용량이 계속 증가하고 있었다. 시작할 때 500MB였던 메모리가 일주일 후엔 3.8GB(전체 4GB 중)를 사용하고 있었다. 전형적인 메모리 누수(Memory Leak) 증상이었다.
5년차가 되어서도 메모리 관리는 여전히 어려웠다. 하지만 이번 경험을 통해 Heap과 Stack의 차이를 머리가 아닌 손으로 이해하게 됐다.
메모리 구조의 기본
메모리는 왜 영역을 나눌까
프로그램이 실행되면 운영체제는 메모리를 할당한다. 이 메모리를 하나의 큰 덩어리로 쓰지 않고 여러 영역으로 나누는 이유가 있다.
1. 효율성
- 자주 쓰는 데이터와 가끔 쓰는 데이터를 분리
- 필요 없어진 데이터를 빠르게 제거
2. 안정성
- 각 영역의 크기를 제한해서 무한 증가 방지
- 한 영역의 문제가 다른 영역에 영향 안 줌
3. 최적화
- CPU 캐시 효율 극대화
- 메모리 할당/해제 속도 개선
메모리 4대 영역 상세 분석
메모리 주소 (높음)
┌─────────────────────┐
│ Stack │
│ (지역 변수, 함수) │ ← 높은 주소에서 낮은 주소로 증가
│ ↓ │
├─────────────────────┤
│ (여유 공간) │
├─────────────────────┤
│ ↑ │
│ Heap │ ← 낮은 주소에서 높은 주소로 증가
│ (동적 할당 메모리) │
├─────────────────────┤
│ Data │
│ (전역/정적 변수) │
├─────────────────────┤
│ Code │
│ (프로그램 코드) │
└─────────────────────┘
메모리 주소 (낮음)
Stack 영역 깊이 파헤치기
Stack이란 무엇인가
Stack은 함수 호출과 지역 변수를 관리하는 영역이다. 이름 그대로 접시를 쌓듯이(Stack) 데이터를 쌓는다.
Stack의 특징:
- LIFO(Last In First Out): 마지막에 들어간 게 먼저 나옴
- 컴파일 타임에 크기 결정
- 자동으로 메모리 할당/해제
- 매우 빠름 (포인터만 이동)
- 크기 제한 있음 (보통 1MB~8MB)
Stack은 어떻게 동작하나
함수를 호출하면 Stack에 Stack Frame이라는 단위가 쌓인다.
fun main() {
val a = 10
println("main 시작")
functionA(a)
println("main 종료")
}
fun functionA(x: Int) {
val b = x * 2
println("functionA: b = $b")
functionB(b)
}
fun functionB(y: Int) {
val c = y + 5
println("functionB: c = $c")
}
실행 과정을 Stack으로 보면:
Step 1: main() 호출
┌─────────────────┐
│ main() │
│ - a = 10 │
│ - return address│
└─────────────────┘
Step 2: functionA() 호출
┌─────────────────┐
│ functionA() │
│ - x = 10 │
│ - b = 20 │
│ - return address│
├─────────────────┤
│ main() │
│ - a = 10 │
│ - return address│
└─────────────────┘
Step 3: functionB() 호출
┌─────────────────┐
│ functionB() │
│ - y = 20 │
│ - c = 25 │
│ - return address│
├─────────────────┤
│ functionA() │
│ - x = 10 │
│ - b = 20 │
│ - return address│
├─────────────────┤
│ main() │
│ - a = 10 │
│ - return address│
└─────────────────┘
Step 4: functionB() 종료
┌─────────────────┐
│ functionA() │
│ - x = 10 │
│ - b = 20 │
│ - return address│
├─────────────────┤
│ main() │
│ - a = 10 │
│ - return address│
└─────────────────┘
Step 5: functionA() 종료
┌─────────────────┐
│ main() │
│ - a = 10 │
│ - return address│
└─────────────────┘
Step 6: main() 종료
(Stack 비어있음)
함수가 종료되면 Stack Frame이 자동으로 제거된다. 개발자가 따로 메모리를 해제할 필요가 없다. 이것이 Stack의 가장 큰 장점이다.
Stack Overflow는 왜 발생할까
Stack은 크기가 제한되어 있다. 무한 재귀나 너무 큰 지역 변수를 만들면 Stack이 넘친다.
// ❌ Stack Overflow 발생 코드
fun recursiveFunction() {
val largeArray = IntArray(1000000) // 4MB 할당
recursiveFunction() // 무한 재귀
}
fun main() {
recursiveFunction()
// Exception in thread "main" java.lang.StackOverflowError
}
왜 발생하나:
- 함수 호출마다 Stack Frame 생성 (약 4MB)
- Stack 크기는 보통 1MB~8MB
- 2~3번 호출하면 Stack 초과
- Stack Overflow Error 발생
실무에서 겪은 사례:
Court Alarm에서 예약 가능 날짜를 재귀로 찾는 코드를 작성했다.
// ❌ 문제가 있는 코드
fun findAvailableDates(
startDate: LocalDate,
endDate: LocalDate,
result: MutableList<LocalDate> = mutableListOf()
): List<LocalDate> {
if (startDate.isAfter(endDate)) {
return result
}
if (isAvailable(startDate)) {
result.add(startDate)
}
// 매일 재귀 호출
return findAvailableDates(startDate.plusDays(1), endDate, result)
}
// 1년치 조회
val dates = findAvailableDates(
LocalDate.now(),
LocalDate.now().plusYears(1)
)
// Stack Overflow! (365번 재귀)
해결:
// ✅ 반복문으로 변경
fun findAvailableDates(
startDate: LocalDate,
endDate: LocalDate
): List<LocalDate> {
val result = mutableListOf<LocalDate>()
var current = startDate
while (!current.isAfter(endDate)) {
if (isAvailable(current)) {
result.add(current)
}
current = current.plusDays(1)
}
return result
}
재귀를 반복문으로 바꾸니 Stack Frame이 하나만 유지되어 문제 해결.
Heap 영역 깊이 파헤치기
Heap이란 무엇인가
Heap은 동적으로 메모리를 할당하는 영역이다. 프로그램 실행 중에 크기를 결정할 수 있다.
Heap의 특징:
- 런타임에 크기 결정
- 수동 또는 GC로 메모리 해제
- Stack보다 느림 (복잡한 할당 알고리즘)
- 크기가 Stack보다 훨씬 큼 (수 GB)
- 객체, 배열이 저장됨
Heap은 어떻게 동작하나
Java/Kotlin에서 new 키워드나 객체 생성 시 Heap에 할당된다.
fun main() {
// Stack에 변수 user (참조값만 저장)
val user = User("규철", 30) // Heap에 실제 객체 생성
val age = 30 // Stack에 저장 (원시 타입)
}
data class User(val name: String, val age: Int)
메모리 구조:
Stack Heap
┌─────────────────┐ ┌──────────────────┐
│ main() │ │ User 객체 │
│ - user ────────────────────→ │ - name: "규철" │
│ (참조: 0x1234)│ │ - age: 30 │
│ - age = 30 │ └──────────────────┘
└─────────────────┘
user 변수는 Stack에 있지만, 실제 User 객체는 Heap에 있다. Stack에는 Heap 주소(참조)만 저장된다.
객체는 언제 Heap에서 사라질까
Java/Kotlin은 Garbage Collection(GC)이 자동으로 메모리를 해제한다.
GC의 기본 원리:
- 루트(Root)에서 시작해서 참조 추적
- 도달할 수 없는 객체는 "쓰레기"로 판단
- 쓰레기 객체의 메모리 해제
fun createUser() {
val user = User("규철", 30)
println(user.name)
} // user 변수 사라짐 → User 객체 도달 불가 → GC 대상
fun main() {
createUser()
// 이 시점에서 GC가 User 객체 제거
}
메모리 누수 실전 경험
사건의 시작
Court Alarm 서버가 이상했다. 매주 금요일마다 서버가 느려져서 재시작해야 했다.
증상:
- 월요일: 응답시간 200ms, 메모리 500MB
- 화요일: 응답시간 300ms, 메모리 1.2GB
- 수요일: 응답시간 500ms, 메모리 2.1GB
- 목요일: 응답시간 1초, 메모리 3.2GB
- 금요일: 응답시간 5초, 메모리 3.8GB (서버 재시작)
명백한 메모리 누수였다.
원인 찾기 1단계: Heap Dump 분석
메모리가 가득 찬 시점에 Heap Dump를 떴다.
# Heap Dump 생성
jmap -dump:format=b,file=heap_dump.hprof <pid>
# 파일 크기 확인
ls -lh heap_dump.hprof
# 3.5GB
Eclipse MAT(Memory Analyzer Tool)로 분석했다.
분석 결과:
Leak Suspects Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem Suspect 1:
WebSocketSession 인스턴스 2,847개가 2.1GB 차지
Accumulation Point:
ConcurrentHashMap<String, WebSocketSession>
- 키: userId
- 값: WebSocketSession 객체
WebSocket 세션이 계속 쌓이고 있었다!
원인 분석: 코드 리뷰
// ❌ 문제가 있는 코드
@Component
class NotificationWebSocketHandler : TextWebSocketHandler() {
// 모든 연결을 저장
private val sessions = ConcurrentHashMap<String, WebSocketSession>()
override fun afterConnectionEstablished(session: WebSocketSession) {
val userId = getUserId(session)
sessions[userId] = session // 연결 시 저장
logger.info("WebSocket 연결: $userId")
}
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
// 메시지 처리
}
// ❌ 연결 종료 시 제거하는 코드가 없음!
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
logger.info("WebSocket 연결 종료")
// sessions에서 제거 안 함 → 메모리 누수!
}
fun sendNotification(userId: String, message: String) {
sessions[userId]?.sendMessage(TextMessage(message))
}
}
문제점:
- 사용자가 앱을 켜면 WebSocket 연결 → sessions에 저장
- 앱을 끄거나 연결이 끊어져도 sessions에 그대로 남음
- 일주일이면 수천 개 세션이 쌓임
- 각 세션이 평균 1MB → 2,847개 × 1MB = 2.8GB
해결 방법
// ✅ 수정된 코드
@Component
class NotificationWebSocketHandler : TextWebSocketHandler() {
private val sessions = ConcurrentHashMap<String, WebSocketSession>()
override fun afterConnectionEstablished(session: WebSocketSession) {
val userId = getUserId(session)
sessions[userId] = session
logger.info("WebSocket 연결: $userId, 전체 세션 수: ${sessions.size}")
}
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
val userId = getUserId(session)
// ✅ 세션 제거
sessions.remove(userId)
logger.info("WebSocket 연결 종료: $userId, 남은 세션: ${sessions.size}")
}
fun sendNotification(userId: String, message: String) {
val session = sessions[userId]
// ✅ 세션이 닫혀있으면 맵에서도 제거
if (session == null || !session.isOpen) {
sessions.remove(userId)
return
}
try {
session.sendMessage(TextMessage(message))
} catch (e: Exception) {
logger.error("메시지 전송 실패: $userId", e)
sessions.remove(userId) // 실패 시에도 제거
}
}
// ✅ 주기적으로 끊어진 세션 정리
@Scheduled(fixedRate = 60000) // 1분마다
fun cleanupClosedSessions() {
val closedSessions = sessions.filter { !it.value.isOpen }
closedSessions.keys.forEach { sessions.remove(it) }
if (closedSessions.isNotEmpty()) {
logger.info("끊어진 세션 ${closedSessions.size}개 정리")
}
}
}
결과
Before:
- 일주일 후 메모리: 3.8GB
- 활성 WebSocket 세션: 100개
- 메모리에 남은 세션: 2,847개 (메모리 누수)
After:
- 일주일 후 메모리: 800MB
- 활성 WebSocket 세션: 100개
- 메모리에 남은 세션: 100개 (정상)
메모리 사용량이 80% 감소했고, 더 이상 금요일마다 재시작하지 않아도 됐다.
또 다른 메모리 누수: Static Collection
문제 코드
초기에 사용자 통계를 수집하기 위해 이런 코드를 작성했다.
// ❌ 메모리 누수 코드
@Service
class UserStatisticsService {
companion object {
// static 변수에 모든 사용자 기록 저장
private val userLoginHistory = mutableListOf<UserLogin>()
}
fun recordLogin(userId: String) {
userLoginHistory.add(
UserLogin(
userId = userId,
timestamp = Instant.now(),
ipAddress = getCurrentIpAddress()
)
)
logger.info("총 로그인 기록: ${userLoginHistory.size}건")
}
fun getLoginCount(userId: String): Int {
return userLoginHistory.count { it.userId == userId }
}
}
data class UserLogin(
val userId: String,
val timestamp: Instant,
val ipAddress: String
)
문제점:
- companion object(static)에 저장 → GC가 절대 제거 못 함
- 사용자가 로그인할 때마다 리스트에 추가
- 제거하는 로직 없음
- 6개월 후: 500만 건 × 100 bytes = 500MB
왜 GC가 못 지우나
Stack Static 영역 Heap
┌──────────────┐
│ companion │
│ object │
│ - userLogin──┼─────→ MutableList
│ History │ │
└──────────────┘ ├→ UserLogin1
├→ UserLogin2
├→ UserLogin3
└→ ... (500만 개)
Static 변수는 프로그램 종료 시까지 살아있다. GC가 도달 가능하다고 판단해서 절대 제거하지 않는다.
해결 방법
// ✅ 수정된 코드
@Service
class UserStatisticsService(
private val userLoginRepository: UserLoginRepository
) {
fun recordLogin(userId: String) {
// ✅ DB에 저장 (메모리가 아님)
val userLogin = UserLogin(
userId = userId,
timestamp = Instant.now(),
ipAddress = getCurrentIpAddress()
)
userLoginRepository.save(userLogin)
}
fun getLoginCount(userId: String): Int {
// ✅ DB에서 조회
return userLoginRepository.countByUserId(userId)
}
fun getRecentLogins(userId: String, days: Int): List<UserLogin> {
val since = Instant.now().minus(days.toLong(), ChronoUnit.DAYS)
return userLoginRepository.findByUserIdAndTimestampAfter(userId, since)
}
}
// ✅ 주기적으로 오래된 데이터 삭제
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
fun deleteOldLoginHistory() {
val threeMonthsAgo = Instant.now().minus(90, ChronoUnit.DAYS)
val deleted = userLoginRepository.deleteByTimestampBefore(threeMonthsAgo)
logger.info("90일 이전 로그인 기록 ${deleted}건 삭제")
}
교훈:
- Static 컬렉션에 데이터를 무한정 쌓지 말 것
- 장기 저장 데이터는 DB 사용
- 메모리는 캐시 용도로만 (제한된 크기)
Stack vs Heap 성능 비교
실제로 얼마나 차이날까? 테스트해봤다.
// 성능 테스트 코드
fun stackAllocationTest() {
val start = System.nanoTime()
repeat(1_000_000) {
val x = 10 // Stack 할당
val y = 20
val z = x + y
}
val duration = (System.nanoTime() - start) / 1_000_000
println("Stack 할당: ${duration}ms")
}
fun heapAllocationTest() {
val start = System.nanoTime()
repeat(1_000_000) {
val obj = SimpleObject(10, 20) // Heap 할당
val z = obj.x + obj.y
}
val duration = (System.nanoTime() - start) / 1_000_000
println("Heap 할당: ${duration}ms")
}
data class SimpleObject(val x: Int, val y: Int)
fun main() {
// Warm-up
repeat(5) {
stackAllocationTest()
heapAllocationTest()
}
// 실제 측정
println("\n=== 실제 측정 ===")
stackAllocationTest()
heapAllocationTest()
}
결과:
=== 실제 측정 ===
Stack 할당: 2ms
Heap 할당: 156ms
Heap이 Stack보다 78배 느렸다!
왜 Heap이 느릴까:
- Stack 할당:
- Stack 포인터만 이동 (1개 명령어)
- CPU 캐시에서 바로 접근
- 해제 필요 없음 (자동)
- Heap 할당:
- 적절한 크기의 빈 공간 찾기 (복잡한 알고리즘)
- 메모리 단편화 처리
- GC가 나중에 정리 (오버헤드)
Garbage Collection 깊이 파기
GC는 어떻게 동작하나
Java/Kotlin의 Heap은 세대별로 나뉜다.
Heap 구조
┌─────────────────────────────────────────────┐
│ Young Generation │
│ ┌──────────┬──────────┬──────────┐ │
│ │ Eden │Survivor 0│Survivor 1│ │
│ └──────────┴──────────┴──────────┘ │
├─────────────────────────────────────────────┤
│ Old Generation │
│ (오래 살아남은 객체) │
└─────────────────────────────────────────────┘
GC 과정:
- Minor GC (Young Generation):
- 새 객체는 Eden에 생성
- Eden이 가득 차면 Minor GC 발생
- 살아남은 객체는 Survivor로 이동
- 여러 번 살아남으면 Old Generation으로 승격
- Major GC (Old Generation):
- Old Generation이 가득 차면 Major GC 발생
- 전체 Heap을 검사 (느림)
- 애플리케이션 일시 정지 (Stop-The-World)
실전: GC 튜닝 경험
Court Alarm에서 GC 때문에 서비스가 멈추는 현상이 있었다.
증상:
- 갑자기 모든 API가 3초씩 멈춤
- 로그를 보니 "Full GC" 발생
- 빈도: 하루에 5~10번
GC 로그 분석:
# GC 로그 활성화
java -Xlog:gc*:file=gc.log -jar app.jar
# 로그 내용
[2024-01-05 10:23:45] GC(123) Pause Full (Allocation Failure)
- Young Generation: 512MB → 0MB
- Old Generation: 1.8GB → 1.7GB
- Duration: 3.2s
3.2초 동안 전체 서비스가 멈췄다!
원인:
- Heap 크기: 2GB (너무 작음)
- Old Generation이 자주 가득 참
- Full GC가 자주 발생
해결: Heap 크기 조정
# Before
java -Xms512m -Xmx2g -jar app.jar
# 초기 512MB, 최대 2GB
# After
java -Xms2g -Xmx4g \
-XX:NewRatio=2 \
-XX:SurvivorRatio=8 \
-jar app.jar
# 초기 2GB, 최대 4GB
# Young:Old = 1:2
# Eden:Survivor = 8:1
결과:
- Full GC 빈도: 하루 5~10번 → 하루 1번
- GC 시간: 3.2초 → 0.8초
- 서비스 멈춤 현상 거의 사라짐
GC 알고리즘 선택
Java는 여러 GC 알고리즘을 제공한다.
1. Serial GC
- 단일 스레드로 GC
- 작은 애플리케이션에 적합
- -XX:+UseSerialGC
2. Parallel GC (기본)
- 여러 스레드로 GC
- 처리량(Throughput) 중시
- -XX:+UseParallelGC
3. G1 GC
- 큰 Heap에 최적화 (>4GB)
- Stop-The-World 시간 최소화
- -XX:+UseG1GC
4. ZGC
- 초대용량 Heap (수십 TB)
- Stop-The-World < 10ms
- -XX:+UseZGC
Court Alarm 선택: G1 GC
java -Xms2g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-jar app.jar
# GC 일시정지 목표: 200ms 이하
이유:
- Heap 크기 4GB (G1 GC 적합)
- 응답 시간 중요 (실시간 알림 서비스)
- Major GC 일시정지 시간 200ms로 감소
메모리 최적화 실전 팁
1. 객체 풀(Object Pool) 사용
자주 생성/삭제되는 객체는 재사용한다.
// ❌ 매번 새 객체 생성
fun sendNotification(userId: String, message: String) {
val notification = Notification(userId, message, Instant.now())
// Heap에 객체 생성 → GC 부담
kafkaTemplate.send("notifications", notification)
}
// ✅ 객체 풀 사용
class NotificationPool {
private val pool = ArrayDeque<Notification>()
fun obtain(): Notification {
return if (pool.isNotEmpty()) {
pool.removeLast() // 재사용
} else {
Notification() // 풀이 비었을 때만 생성
}
}
fun recycle(notification: Notification) {
notification.reset()
pool.add(notification)
}
}
fun sendNotification(userId: String, message: String) {
val notification = pool.obtain()
notification.apply {
this.userId = userId
this.message = message
this.timestamp = Instant.now()
}
kafkaTemplate.send("notifications", notification)
pool.recycle(notification) // 재활용
}
효과:
- 객체 생성 횟수: 100만 개 → 100개 (1만 배 감소)
- GC 빈도 감소
- 응답 시간 개선
2. StringBuilder 사용
String 연결은 매번 새 객체를 만든다.
// ❌ String 연결 (Heap 부담)
fun generateReport(users: List<User>): String {
var report = ""
for (user in users) {
report += "이름: ${user.name}, 나이: ${user.age}\n"
// 반복마다 새 String 객체 생성!
}
return report
}
// 사용자 10,000명이면 10,000개 String 객체 생성
// ✅ StringBuilder 사용
fun generateReport(users: List<User>): String {
val report = StringBuilder()
for (user in users) {
report.append("이름: ${user.name}, 나이: ${user.age}\n")
// 같은 객체에 계속 추가
}
return report.toString()
}
// String 객체 1개만 생성
성능 비교:
// 벤치마크
fun stringConcatTest() {
val start = System.currentTimeMillis()
var str = ""
repeat(10000) {
str += "test"
}
println("String concat: ${System.currentTimeMillis() - start}ms")
}
fun stringBuilderTest() {
val start = System.currentTimeMillis()
val sb = StringBuilder()
repeat(10000) {
sb.append("test")
}
val str = sb.toString()
println("StringBuilder: ${System.currentTimeMillis() - start}ms")
}
// 결과:
// String concat: 2,847ms
// StringBuilder: 2ms (1,400배 빠름!)
3. WeakReference 사용
캐시는 메모리가 부족하면 자동으로 제거되어야 한다.
// ❌ 강한 참조 캐시 (메모리 누수 가능)
class UserCache {
private val cache = ConcurrentHashMap<String, User>()
fun getUser(userId: String): User? {
return cache[userId]
}
fun putUser(user: User) {
cache[user.id] = user
// 계속 쌓이면 메모리 부족 발생!
}
}
// ✅ WeakReference 캐시
class UserCache {
private val cache = ConcurrentHashMap<String, WeakReference<User>>()
fun getUser(userId: String): User? {
val ref = cache[userId] ?: return null
val user = ref.get()
if (user == null) {
// GC에 의해 제거됨
cache.remove(userId)
}
return user
}
fun putUser(user: User) {
cache[user.id] = WeakReference(user)
// 메모리 부족하면 GC가 자동으로 제거
}
}
면접에서 이렇게 답하자
1단계: 기본 개념 (30초)
"메모리는 Stack, Heap, Data, Code 4개 영역으로 나뉩니다. Stack은 함수 호출과 지역 변수를 저장하며 자동으로 관리됩니다. Heap은 동적 할당 영역으로 GC가 관리하며, Stack보다 느리지만 크기 제한이 적습니다."
2단계: 차이점 강조 (30초)
"가장 큰 차이는 할당/해제 방식입니다. Stack은 포인터 이동만으로 빠르게 할당하고 함수 종료 시 자동 해제됩니다. Heap은 적절한 공간을 찾아야 해서 느리고, GC가 주기적으로 정리합니다."
3단계: 실무 경험 (1분 30초)
"Court Alarm에서 WebSocket 세션을 ConcurrentHashMap에 저장했는데, 연결 종료 시 제거하지 않아 메모리 누수가 발생했습니다. 일주일 만에 메모리가 500MB에서 3.8GB로 증가했고, Heap Dump로 분석해 세션 2,847개가 2.1GB를 차지하는 걸 발견했습니다."
"afterConnectionClosed에서 세션을 제거하고, 주기적으로 끊어진 세션을 정리하는 스케줄러를 추가했습니다. 결과적으로 메모리 사용량이 80% 감소했고, 더 이상 주기적인 재시작이 필요 없어졌습니다."
4단계: GC 튜닝 경험 (1분)
"Full GC로 인해 서비스가 3초씩 멈추는 문제도 있었습니다. Heap 크기를 2GB에서 4GB로 늘리고 G1 GC를 적용했습니다. Full GC 빈도가 하루 5~10번에서 1번으로 줄었고, GC 시간도 3.2초에서 0.8초로 개선됐습니다."
핵심 팁
숫자로 말하기:
- ❌ "메모리를 많이 썼어요"
- ✅ "메모리가 500MB에서 3.8GB로 증가했고, 세션 2,847개가 2.1GB를 차지했습니다"
문제 → 분석 → 해결 → 결과:
- ❌ "메모리 누수를 고쳤어요"
- ✅ "Heap Dump로 원인을 찾아 afterConnectionClosed에서 제거하도록 수정해 메모리를 80% 절감했습니다"
도구 활용 언급:
- Heap Dump, Eclipse MAT, GC 로그 분석 등 실제 사용한 도구를 구체적으로 언급
정리
메모리 관리는 5년 차에게도 어려운 주제다. 하지만 실제 장애를 겪고 해결하면서 확실히 이해하게 된다.
꼭 기억해야 할 것:
Stack:
- 함수 호출, 지역 변수
- 빠르지만 크기 제한 (1~8MB)
- 자동 관리 (함수 종료 시 해제)
- Stack Overflow 주의 (무한 재귀)
Heap:
- 동적 할당 (new, 객체 생성)
- 느리지만 큰 용량 (수 GB)
- GC가 관리
- 메모리 누수 주의 (참조 제거 안 하면)
메모리 누수 방지:
- 컬렉션에 추가만 하고 제거 안 하기
- Static 변수에 무한정 쌓기
- 리스너/콜백 등록 후 해제 안 하기
- 캐시 크기 제한 안 하기
GC 튜닝:
- Heap 크기 적절히 설정 (너무 작으면 GC 빈번)
- G1 GC 사용 (4GB 이상 Heap)
- GC 로그 분석으로 병목 찾기
다음 글에서는 "스케줄링 알고리즘"을 다루면서, 운영체제가 어떻게 프로세스 실행 순서를 정하는지, 그리고 실제 서버에서 어떤 영향을 미치는지 이야기해보겠다.
'컴퓨터 공학 > 운영체제' 카테고리의 다른 글
| 네트워킹 - 운영체제가 네트워크를 다루는 방법 (0) | 2026.01.08 |
|---|---|
| 동기화와 통신 - 멀티스레드의 가장 어려운 문제 (0) | 2026.01.07 |
| 프로세스와 스레드 - 경력 5년차 개발자의 실전 경험 (1) | 2026.01.05 |
| OS 메모리 구조, 스케쥴러 (0) | 2020.10.09 |
