자바 코드는 컴퓨터가 바로 실행하지 않는다. JVM이라는 프로그램이 컴파일된 코드를 읽어 실행하고, 그동안 객체를 어디에 둘지, 언제 치울지도 JVM이 정한다. Spring으로 만든 서버도 결국 JVM 위에서 돈다.

@Service, @Autowired, @Transactional 같은 어노테이션을 붙이면 Spring이 객체를 대신 만들고(@Service), 필요한 곳에 넣어 주고(@Autowired), 트랜잭션을 열고 닫아 준다(@Transactional). 편하지만 그 뒤에서는 JVM이 메모리를 잡고, 객체의 수명을 따지고, 다 쓴 객체를 치우는 일(GC, Garbage Collection) 이 계속 돌고 있다. 이 글에서는 앞의 두 개(객체를 만들고 넣어 주는 일)가 메모리에서 어떻게 보이는지를 따라간다. @Transactional은 이 글에서 다시 다루지 않는다.

이 글은 JVM이 무엇을 하는지 볼 수 있게 되려고 정리한 것이다. Stack과 Heap의 차이에서 시작해 GC가 객체를 치우는 방식, 그리고 Spring이 빈을 몇 개, 언제까지 들고 있는지 정하는 빈 스코프(Bean Scope)까지 차례로 간다.

핵심 메시지: Spring Bean은 JVM 위에서 도는 평범한 객체다. JVM을 모르면 Spring을 디버깅할 수 없다.


Stack vs Heap - 객체는 어디에 저장될까?

Stack 메모리: 메소드 실행의 작업 공간

스레드는 프로그램 안에서 따로 도는 실행 흐름 하나다. Spring으로 만든 서버는 브라우저 같은 곳에서 「이 사용자 정보를 달라」는 HTTP 요청을 받아 처리하고 답을 돌려준다. 이 서버는 요청 하나를 스레드 하나에 맡긴다. 그래서 두 사람이 동시에 요청을 보내면 두 스레드가 같은 메서드를 동시에 실행한다.

JVM은 스레드마다 Stack을 하나씩 따로 만든다. 메서드를 하나 부를 때마다 그 메서드의 작업 공간인 Stack Frame(프레임)이 그 스레드의 Stack에 하나 쌓이고, 메서드가 끝나면 빠진다. 프레임에는 다음이 들어 있다.

  • 로컬 변수 — int 같은 기본 타입 값, 그리고 객체 타입 변수라면 객체가 아니라 객체를 가리키는 참조
  • 매개변수 — 매개변수도 로컬 변수 칸에 들어간다
  • 계산 도중의 값 — 계산하는 동안 값을 잠시 올려 두는 칸(피연산자 스택)
@Service
public class UserService {
    public User getUser(Long id) {  // Stack Frame 생성
        User user = new User("John"); // user 변수(주소값)는 Stack에
        String name = user.getName(); // name 변수(참조)는 Stack에
        return user;
    } // Stack Frame 제거
}
  • getUser(id)가 불리면 이 메서드의 프레임이 쌓이고, 매개변수 id가 그 안에 들어간다
  • new User("John")으로 만든 객체 자체는 Stack이 아니라 Heap(아래에서 본다)에 생긴다. 프레임의 user 칸에는 그 객체를 가리키는 참조만 들어간다
  • user.getName()은 그 객체의 이름 필드에 든 문자열 "John"을 돌려준다. 문자열도 객체라서 name 칸에는 그 문자열 객체를 가리키는 참조가 들어간다
  • 메서드가 끝나면 프레임이 통째로 빠지고 id, user, name 칸도 같이 사라진다

Thread A와 Thread B가 동시에 호출하면?

요청 두 개가 동시에 와서 두 스레드가 같은 UserService 객체의 getUser()를 동시에 부르는 장면이다. 아래 0x1000 같은 값은 객체가 있는 메모리 주소를 16진수로 적은 것이다. 실제 자바 코드에서는 이 숫자를 볼 수 없고, 설명하려고 붙인 값이다. user=0x1000은 스레드 A의 user가 0x1000에 있는 User 객체를, name=0x2000은 0x2000에 있는 문자열 객체를 가리킨다는 뜻이다. 아래에서 UserService@0x1000처럼 적으면 「0x1000에 있는 UserService 객체」라는 뜻이다.

[Thread A Stack]  id=1, user=0x1000, name=0x2000
[Thread B Stack]  id=2, user=0x3000, name=0x4000

→ 각 Thread는 독립적인 Stack을 가지므로 Thread-safe!

두 스레드의 로컬 변수는 각자의 Stack에 따로 있다. 스레드 A가 자기 user를 바꿔도 스레드 B의 user는 그대로다. 이렇게 여러 스레드가 같은 코드를 동시에 돌려도 서로의 값을 망가뜨리지 않는 성질을 스레드 안전(Thread-safe)이라고 부른다. 로컬 변수만 쓰는 코드는 그래서 스레드 안전하다.

Heap 메모리: 객체의 실제 저장소

new로 만든 모든 객체(Object)는 Heap에 저장되고, Heap은 모든 Thread가 공유한다. 객체의 필드도 객체 안에 들어 있으니 Heap에 있다. 로컬 변수는 프레임(Stack)에, 필드는 객체(Heap)에 있다는 차이다.

@Service를 붙인 클래스는 Spring이 객체를 하나만 만들어 계속 쓴다. 이렇게 하나만 두는 빈을 Singleton Bean이라고 부르고, 아무 설정도 안 하면 Spring은 이 방식을 쓴다(아래 「Singleton Scope」에서 다시 본다). 아래 코드의 UserRepository는 DB에서 데이터를 꺼내 오는 일을 맡는 객체다(Repository). 생성자에 이 타입을 적어 두면 Spring이 이미 만들어 둔 UserRepository 객체를 찾아 넣어 준다(의존성 주입).

@Service  // Singleton Bean
public class UserService {
    private final UserRepository userRepository;  // Heap의 참조값

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;  // Spring이 만들어 둔 객체를 넣어 준다
    }

    public User getUser(Long id) {
        User user = userRepository.findById(id);
        // user 변수는 Stack, 실제 User 객체는 Heap
        return user;
    }
}
  • userRepository는 필드라서 UserService 객체 안, 즉 Heap에 있다
  • 생성자는 Spring이 UserService를 만들 때 한 번 부른다. 이때 userRepository 칸이 채워진다
  • findById(id)는 DB에서 그 id의 사용자를 읽어 User 객체로 만들어 돌려준다고 보면 된다. 그 객체는 Heap에, 그것을 가리키는 user 변수는 이 스레드의 프레임에 있다

메모리 구조:

[Heap 메모리]
UserService@0x1000 (Singleton, 1개만 존재)
  └─ userRepository → UserRepository@0x2000
User@0x3000 (getUser()로 생성된 객체)
User@0x4000 (또 다른 요청으로 생성)

[Thread A Stack]  user → 0x3000
[Thread B Stack]  user → 0x4000

핵심 포인트:

  • UserService는 Heap에 1개만 존재한다(Singleton). 두 스레드가 같은 객체를 함께 쓴다
  • 각 Thread는 독립적인 Stack 변수를 가진다
  • 함께 쓰는 객체라도 필드를 아무도 바꾸지 않으면 스레드끼리 부딪칠 일이 없다. UserService의 필드 userRepository는 처음 넣은 뒤 읽기만 한다. 그래서 이 Singleton Bean은 Thread-safe하다. 필드를 바꾸는 Singleton이 왜 위험한지는 아래 「Singleton Scope」에서 본다

new 키워드의 진짜 의미

User user = new User("John");

이 한 줄에서 일어나는 일은 세 단계다.

1단계: Heap에 메모리 할당

new 키워드 → JVM이 Heap의 Eden 영역에 객체 크기만큼 메모리 할당

Eden은 Heap 안에서 새 객체가 처음 들어가는 칸이다. Heap을 왜 칸으로 나눴는지는 아래 「Young Generation vs Old Generation」에서 본다.

2단계: 생성자 실행

User 생성자 호출 → 필드 초기화

3단계: 참조값 저장

user 변수(Stack)에 객체의 메모리 주소 저장
user = 0x7F8A2C3D

중요한 사실:

변수에 객체가 아니라 참조가 들어 있다는 것이 메서드에 객체를 넘길 때 차이를 만든다.

public class Main {
    public static void main(String[] args) {
        User original = new User("Alice");
        modify(original);
        System.out.println(original.getName());  // "Bob"
    }

    static void modify(User user) {
        user.setName("Bob");          // 원본 객체가 바뀐다
        user = new User("Charlie");   // 매개변수 user만 새 객체를 가리킨다
    }
}
  • main이 Alice 객체를 만들고, original에 그 참조를 넣는다
  • modify(original)을 부르면 original에 든 값, 즉 참조가 복사되어 매개변수 user에 들어간다
  • user.setName("Bob")은 user가 가리키는 객체, 곧 original과 같은 객체의 이름을 바꾼다
  • user = new User("Charlie")는 매개변수 user라는 칸에 새 참조를 넣을 뿐이다. original 칸은 그대로다
  • 그래서 main에 돌아와 찍으면 "Bob"이 나온다

Java는 Pass by Value(값을 복사해서 넘김)이지만, 그 값이 “참조값(주소)”이다!

main과 modify는 같은 스레드에서 돈다. 그래서 아래 그림의 두 줄은 Stack 두 개가 아니라 한 스레드의 Stack에 쌓인 프레임 두 개다.

[main 프레임]   original → 0x1000 (Alice 객체)
[modify 프레임] user → 0x1000 (주소값 복사!)

둘 다 같은 객체를 가리킴 → setName()은 원본에 영향!
새로운 객체 할당(new)은 복사된 변수만 변경!

Garbage Collection - 메모리 회수의 비밀

자바에는 객체를 직접 지우는 명령이 없다. new로 만들기만 하고, 다 쓴 객체의 자리는 GC가 알아서 되찾아 다시 쓴다. 자바(Java SE) 공식 문서를 내는 Oracle의 GC 문서도 GC의 목적을 「개발자가 메모리를 직접 해제하는 일에서 벗어나게 하는 것」이라고 적는다. 그러면 GC는 어떤 객체가 「다 쓴 것」인지를 어떻게 알까.

GC Root: 살아있는 객체의 출발점

GC는 “Root에서 도달 가능한가?”로 객체 생존 여부를 판단한다. 「도달 가능하다」는 Root에서 출발해 참조를 하나씩 따라갔을 때 그 객체에 닿는다는 뜻이다. 어떤 길로도 닿지 않는 객체는 프로그램이 다시 쓸 방법이 없으니 치워도 된다.

GC Root가 되는 것들:

  1. Stack의 로컬 변수 — 지금 실행 중인 메서드가 들고 있는 참조
  2. static 필드 — static 필드는 객체마다가 아니라 클래스에 하나씩 붙어 있다. 클래스는 처음 쓰일 때 클래스 로더가 메모리에 올리고, 그 뒤로 static 필드가 가리키는 객체는 만든 객체를 다 버려도 Root에서 바로 닿는다
  3. JNI References — C · C++ 코드(JNI)가 들고 있는 자바 객체 참조
  4. Active Threads — 살아 있는 스레드 객체. 스레드가 돌고 있는 동안 그 스레드는 치울 수 없다

Spring의 Singleton Bean은 이 목록에 직접 들지 않는다. Spring의 컨테이너인 ApplicationContext는 만든 Singleton Bean을 안에 있는 Map(singletonObjects)에 이름과 함께 넣어 둔다. 그러면 ApplicationContext는 Root 중 어디에서 닿을까. Spring Boot로 띄웠다면 2번(static 필드)이다. SpringApplication 클래스의 static 필드 shutdownHook이 띄운 ApplicationContext를 Set에 담아 둔다. 프로그램이 끝날 때 컨텍스트를 닫으려고 들고 있는 것이고, 이 등록은 기본으로 켜져 있다(Spring Boot 소스의 SpringApplication · SpringApplicationShutdownHook · ApplicationProperties에서 확인했다). 그래서 「static 필드 → ApplicationContext → Map → 빈」으로 Root에서 빈까지 닿는다.

@Service  // ApplicationContext의 Map이 이 객체를 참조 → Root에서 도달 가능
public class UserService {
    private List<User> users = new ArrayList<>();

    public void temp() {
        User local = new User();  // Stack의 로컬 변수 → GC Root
    } // local 참조 사라짐 → GC 수거 대상!
}
  • UserService 객체는 ApplicationContext가 붙들고 있어서 계속 산다. 그 필드 users가 가리키는 리스트와 리스트 안의 User들도 그 길을 따라 닿으니 같이 산다
  • temp() 안의 local은 메서드가 도는 동안만 Root다. 메서드가 끝나 프레임이 빠지면 그 User 객체를 가리키는 곳이 없어진다

GC 동작 방식 (Mark & Sweep):

참조로 이어진 객체들을 점(객체)과 선(참조)으로 그리면 그래프가 된다. Mark 단계는 이 그래프를 Root에서부터 훑는다. 훑는 순서로는 가까운 것부터 넓혀 가는 BFS나 한 길을 끝까지 따라가는 DFS를 쓸 수 있다. 어느 쪽이든 「닿는 객체를 빠짐없이 찾는다」는 결과는 같다.

Step 1 (Mark): Root에서 출발 → 참조 그래프 탐색 (BFS/DFS)
               도달 가능한 객체에 "살아있음" 표시

[Root] → UserService (표시)
         └─ users (표시)
            ├─ User1 (표시)
            ├─ User2 (표시)
            └─ User3 (표시)

Heap의 User@0x9999 (표시 없음, 고아 객체)

Step 2 (Sweep): 표시 안 된 객체들 메모리 회수

User@0x9999는 위 temp()의 local이 가리키던 객체 같은 것이다. 메서드가 끝나 유일한 참조가 사라졌으니 Root에서 닿는 길이 없고, 표시를 못 받아 Sweep 단계에서 회수된다.

Young Generation vs Old Generation

왜 Heap을 나눴을까?

Weak Generational Hypothesis: 대부분의 객체는 금방 죽는다!

public void process() {
    byte[] buffer = new byte[1024];  // 메소드 끝나면 바로 죽음
    User temp2 = new User();         // 바로 죽음
}
// 이런 "젊은" 객체들이 전체의 90% 이상!

Weak Generational Hypothesis(약한 세대 가설)는 「대부분의 객체는 짧게 살고 죽는다」는 관찰이다. Oracle 문서는 이것을 GC가 기대는 가장 중요한 관찰로 꼽는다. 위 코드의 두 객체는 process()가 끝나면 가리키는 곳이 없어진다. 객체 대부분이 이렇게 금방 죽는다면, 새 객체만 모아 둔 칸을 자주 치우는 편이 Heap 전체를 매번 훑는 것보다 싸다.

메모리 구조:

Young Generation (새로 생성된 객체)
├─ Eden: new로 생성되는 곳
└─ Survivor (S0, S1): Minor GC 생존자

Old Generation (오래 살아남은 객체)
└─ Tenured: 여러 번 GC 생존 시 승격

Minor GC는 Young만 치우는 GC로, 바로 아래에서 다시 본다. Tenured는 「승격된 객체」라는 뜻으로 쓰는 말이다. HotSpot(JDK에 든 JVM) 소스도 Old 쪽을 「old (promoted/tenured) objects」를 담는 곳으로 적는다. 이 글에서 Tenured와 Old는 같은 곳이다. 칸의 이름과 뜻은 Eden · Survivor · Old에 정리해 두었다. 객체가 옮겨 다니는 순서는 이렇다(Oracle 문서 기준).

  • Eden이 차면 Young만 치우는 Minor GC가 돈다
  • Survivor는 두 개이고 늘 하나는 비어 있다. Minor GC는 Eden과 차 있는 Survivor에서 살아 있는 객체만 빈 Survivor로 복사한다. 끝나면 Eden과 원래 Survivor는 통째로 비고, 다음 Minor GC에서는 두 Survivor의 역할이 바뀐다
  • 객체가 Survivor 사이를 정해진 횟수만큼 옮겨 다녔거나 Survivor에 자리가 모자라면 Old로 복사된다. 이것을 승격이라고 부른다. 그 횟수의 최댓값을 정하는 설정이 MaxTenuringThreshold다. 터미널에서 java -XX:+PrintFlagsFinal -version을 치면 JVM 설정값(JVM 옵션)이 전부 찍히는데, 이 PC의 JDK 21에서는 이 값이 15였다

성능 최적화:

  • Minor GC (Young): 자주 실행, 빠름
  • Major GC (Old): 가끔 실행, 느림. Old가 가득 차면 돌고 Heap 전체를 치운다. 다룰 객체가 훨씬 많아서 Minor GC보다 오래 걸린다고 Oracle 문서가 적는다

→ 자주 죽는 것들만 자주 확인!

Stop-the-World: GC의 치명적 단점

전통적인 GC는 실행되는 동안 애플리케이션 Thread를 모두 멈춘다! 애플리케이션 Thread는 요청을 처리하는 스레드처럼 내 코드를 돌리는 스레드를 말한다. 이 멈춤을 Stop-the-World(STW)라고 부른다.

왜 멈춰야 할까?

GC가 Mark를 하는 동안 내 코드가 계속 돌면서 참조를 바꾸면 무슨 일이 생기는지 보자. 아래는 자바 코드가 아니라 장면을 적은 것이다. ref는 객체 안에서 다른 객체를 가리키는 필드라고 보면 된다.

// GC가 Mark 중이라고 가정
[Root] → A → B → D
GC는 A를 이미 다 훑었고, 이제 B를 볼 차례다. D는 지금 B만 가리킨다.

// 만약 애플리케이션이 동시 실행된다면?
Thread2: A.ref = D;      // A가 D를 가리키게 됨 (새 참조 추가)
Thread1: B.ref = null;   // B는 D를 더 이상 안 가리킴 (참조 끊김)

// GC가 B로 가면 D를 못 만난다. A는 이미 훑었으니 다시 보지 않는다.
// 결과: D는 A가 가리키는 살아 있는 객체인데 표시를 못 받고 지워진다 → 프로그램이 망가진다

멈춰 세우면 Mark하는 동안 참조가 바뀌지 않으니 이런 일이 없다. 대신 멈춘 시간만큼 요청 처리도 멈춘다.

실무 영향:

public User getUser(Long id) {
    // 여기서 갑자기 Full GC 발생!
    // → 0.5초 ~ 10초 멈춤
    return userRepository.findById(id);
}
// 사용자는 응답을 기다리다가 타임아웃!

Full GC는 Heap 전체를 치우는 GC를 부르는 말로, 위의 Major GC와 같은 뜻으로 쓴다. 이 멈춤이 길어지면 요청을 보낸 쪽이 정해 둔 시간을 넘겨 기다리기를 포기한다(타임아웃).

최신 GC의 발전:

# 전통적 GC
Parallel GC: 10GB Heap → 3초 STW

# 최신 GC
G1 GC:  STW 최소화
ZGC:    10GB Heap → 5ms STW (!)
Shenandoah: Concurrent Compaction
  • Parallel GC는 여러 스레드로 GC를 빨리 끝내지만 그동안 프로그램을 멈춘다
  • G1은 오래 걸리는 일 일부를 프로그램과 동시에 해서 멈춤을 줄인다. 이 PC의 JDK 21도 GC를 따로 고르지 않으면 G1을 쓴다(앞의 java -XX:+PrintFlagsFinal -version 출력에 UseG1GC = true가 찍힌다)
  • ZGC는 오래 걸리는 일을 전부 프로그램을 멈추지 않고 한다
  • Shenandoah는 객체를 옮겨 빈 자리를 모으는 일(Compaction)까지 프로그램과 동시에(Concurrent) 한다

위 블록의 3초 · 5ms는 이 글을 처음 쓸 때 적은 값이고, 내가 다시 재 보지는 않았다.


Spring Bean Scope와 메모리 관계

빈 스코프는 Spring이 빈 객체를 몇 개 만들고 언제까지 들고 있을지 정한 규칙이다. 스코프에 따라 객체가 Heap에서 얼마나 오래 사는지가 달라진다.

Singleton Scope: Spring의 기본값

@Service  // 기본값 Singleton
public class UserService {
    private final UserRepository userRepository;
}

메모리 특징:

  • 생성 시점: ApplicationContext 초기화. 애플리케이션이 뜰 때 Spring이 @Service 같은 표시가 붙은 클래스를 찾아(ComponentScan) 객체를 만든다
  • 개수: Spring Container(= ApplicationContext) 당 1개
  • 저장 위치: Heap. ApplicationContext의 Map이 참조하므로 Root에서 늘 도달 가능하다
  • GC: 애플리케이션 종료 시까지 절대 수거 안 됨!

왜 Thread-safe?

[Heap] UserService@0x1000 (Singleton, 공유)

[Thread A Stack]  id=1, user=0x3000
[Thread B Stack]  id=2, user=0x4000

→ 메소드의 로컬 변수는 각 Thread Stack에 독립적!
→ userRepository는 읽기만 함 (Stateless) → 안전!

필드를 처음 넣은 뒤 다시 바꾸지 않고, 바뀌는 값은 로컬 변수로만 다루는 객체를 무상태(Stateless)라고 부른다. UserService는 무상태라서 스레드 여럿이 동시에 불러도 안전하다.

하지만 이건 위험하다. 필드에 담긴 값이 실행 중에 바뀌는(Mutable) 경우다.

@Service
public class UserService {
    private List<User> cache = new ArrayList<>();  // 실행 중에 계속 바뀌는 필드

    public void addCache(User user) {
        cache.add(user);  // Thread-unsafe!
    }
}

cache는 Singleton 안의 필드라서 모든 스레드가 같은 리스트 하나를 함께 고친다.

두 가지 문제:

1. Thread-safety 문제

ArrayList는 안에 배열(elementData)과 지금 몇 개 들었는지(size)를 들고 있다. add()는 「배열이 꽉 찼으면 더 큰 배열로 바꾼다(grow()) → elementData[size]에 넣는다 → size를 1 늘린다」를 차례로 한다. 이 세 걸음은 한 번에 끝나는 일이 아니라서, 두 스레드가 동시에 하면 서로의 중간 상태를 읽고 덮어쓴다(경쟁 상태).

Thread A: cache.add(user1)  // 내부 배열 resize 중...
Thread B: cache.add(user2)  // 동시에 resize!
→ ArrayIndexOutOfBoundsException이 날 수 있다

한쪽이 아직 늘리기 전의 배열에 넘치는 칸 번호로 넣으려 하면 예외가 나고, 운이 나쁘면 예외 없이 값 하나가 조용히 사라진다.

2. Memory Leak (OOM)

GC Root → ApplicationContext → UserService (Singleton)
  ↓ (도달 가능)
cache (List)
  ↓ (도달 가능)
User1, User2, ... User999999

→ 모든 User가 GC Root에서 도달 가능!
→ GC가 절대 제거 안 함
→ OutOfMemoryError!

넣기만 하고 빼지 않으면 User가 끝없이 쌓인다. 전부 Root에서 닿으니 GC는 하나도 못 치운다. 결국 Heap에 새 객체를 넣을 자리가 없어져 OutOfMemoryError(줄여서 OOM)가 난다. 더는 안 쓰는데 참조가 남아 못 치우는 이런 상태를 메모리 누수(Memory Leak)라고 부른다.

Prototype Scope: 매번 새 인스턴스

@Component
@Scope("prototype")
public class TaskProcessor {
    // 요청마다 새 인스턴스
}

@Component는 「이 클래스를 빈으로 만들라」는 기본 표시다. @Service도 속에 @Component를 품고 있어서 빈으로 만든다는 뜻은 같고, 역할 이름만 다르다. @Scope("prototype")은 이 빈의 스코프를 prototype으로 바꾸는 표시다. Spring에 이 빈을 달라고 할 때마다 새 객체를 만들어 준다.

메모리 특징:

  • 생성 시점: getBean()으로 꺼낼 때마다, 또는 @Autowired로 어딘가에 넣어 줄 때마다
  • 개수: 요청할 때마다 새로 생성
  • 저장 위치: Heap의 Eden 영역 (일반 객체처럼)
  • GC: Spring이 관리 안 함! Singleton은 ApplicationContext가 Map에 넣어 계속 붙들고 있지만, prototype은 만들어 건네준 뒤 Spring이 참조를 남기지 않는다. 받은 쪽의 참조가 끊기면 바로 GC 대상이다

실무 예시:

@Service
public class TaskService {
    @Autowired
    private ApplicationContext context;

    public void process() {
        TaskProcessor p1 = context.getBean(TaskProcessor.class);
        TaskProcessor p2 = context.getBean(TaskProcessor.class);
        // p1 != p2 (다른 객체!)

        p1.process();
        p2.process();
    } // 메소드 끝 → p1, p2 참조 사라짐 → GC 대상!
}
  • @Autowired를 필드에 붙이면 Spring이 이 자리에 ApplicationContext 자신을 넣어 준다. 그래서 context에 값을 넣는 코드가 없어도 쓸 수 있다
  • context.getBean(TaskProcessor.class)는 「TaskProcessor 타입의 빈을 달라」는 뜻이다. TaskProcessor.class는 객체가 아니라 클래스 자체를 값으로 넘기는 표기다(클래스 리터럴)
  • prototype이라 두 번 부르면 객체가 두 개 생긴다. 그래서 p1과 p2는 다른 객체다
  • 메서드가 끝나면 p1, p2 칸이 사라지고, Spring도 참조를 안 남겼으니 두 객체는 다음 GC 때 치워진다

메모리 흐름:

요청 1: new TaskProcessor@0x1000 → Eden 할당
요청 2: new TaskProcessor@0x2000 → Eden 할당
메소드 종료 → Stack 참조 제거 → GC 수거

vs Singleton:
UserService@0x1000 → 계속 살아있음 (Old Generation)

Singleton은 처음엔 다른 객체처럼 Eden에 생긴다. 그런데 Minor GC를 몇 번 거쳐도 ApplicationContext가 계속 붙들고 있으니 매번 살아남고, 앞에서 본 승격 규칙대로 결국 Old로 옮겨진다. 반대로 prototype은 부를 때마다 Eden에 새 객체를 만든다. 많이 부를수록 Eden이 빨리 차고 Minor GC가 그만큼 자주 돈다.

Request Scope: HTTP 요청마다

@Component
@RequestScope
public class RequestContext {
    private String requestId;
    private Long userId;

    public void setUserId(Long userId) { this.userId = userId; }
}

@RequestScope는 「HTTP 요청 하나마다 이 빈을 하나씩 만들라」는 표시다.

메모리 특징:

  • 생성 시점: HTTP 요청마다 1개
  • 개수: 동시 요청 수만큼
  • 저장 위치: Heap의 Eden 영역
  • GC: 요청 처리 완료 후 참조 끊기면 GC 대상

Thread-local처럼 동작:

앞에서 본 대로 웹 서버는 요청 하나를 스레드 하나에 맡긴다. Spring은 요청이 들어올 때마다 그 요청의 정보를 담는 객체(RequestAttributes)를 하나 두고, 그 객체를 ThreadLocal(스레드마다 값을 따로 들고 있는 변수)에 넣어 둔다. request 스코프 빈을 처음 찾으면 Spring은 빈을 만들어 이 요청 정보 객체에 이름을 붙여 넣어 두고(setAttribute), 같은 요청에서 다시 찾으면 거기서 꺼내 준다. 그래서 스레드 A에서 찾으면 요청 A의 객체가, 스레드 B에서 찾으면 요청 B의 객체가 나온다.

Request A (Thread A) → RequestContext@0x1000
Request B (Thread B) → RequestContext@0x2000

→ 같은 요청 내에서는 같은 인스턴스
→ 다른 요청은 각자 독립!

실무 예시:

@RestController
public class UserController {
    @Autowired
    private RequestContext requestContext;  // 실제로는 프록시가 들어 있다

    @Autowired
    private UserService userService;

    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        requestContext.setUserId(id);  // 이 요청에만 영향
        return userService.getUser(id);
    } // 응답 완료 → RequestContext 참조 제거 → GC 대상
}
  • @RestController는 이 클래스가 바깥 요청을 받는 Controller라는 표시다. Controller도 Singleton이라 객체는 하나다
  • @GetMapping("/user/{id}")은 GET 방식으로 /user/7 같은 주소에 요청이 오면 이 메서드를 부르라는 뜻이고, @PathVariable은 주소의 {id} 자리 값을 매개변수 id로 받는다
  • 그런데 Controller는 하나뿐이고 필드 requestContext도 하나뿐이다. 필드 하나가 어떻게 요청마다 다른 객체를 가리킬까. 답은 이 필드에 진짜 RequestContext가 아니라 프록시가 들어 있다는 것이다. @RequestScope는 기본으로 프록시를 만들게 되어 있다. requestContext.setUserId(id)를 부르면 프록시가 그 순간 「지금 스레드가 처리 중인 요청」의 RequestContext를 찾아 그 객체의 setUserId()를 대신 부른다
  • getUser()가 돌려준 User는 Spring이 HTTP 응답의 본문에 실어 요청한 쪽에 돌려준다. @RestController가 붙은 클래스의 메서드는 반환값을 응답 본문으로 쓰기 때문이다. 응답을 다 보내면 그 요청의 처리가 끝난다
  • 요청이 끝나면 그 요청 정보와 함께 RequestContext 객체를 가리키는 곳이 없어져 GC 대상이 된다

실무: Memory Leak 추적하기

증상 파악

Heap 사용량을 시간 순으로 그리면 정상일 때와 누수가 있을 때 모양이 다르다.

정상: Heap 사용량 (톱니 모양)
████████████ (GC 직전)
███░░░░░░░░░ (GC 후)
████████████ (GC 직전)
███░░░░░░░░░ (GC 후)
...반복...

Leak 발생: Heap 사용량 (GC 후에도 바닥이 계속 올라감)
█░░░░░░░░░░░
███░░░░░░░░░
██████░░░░░░
██████████░░
████████████░
█████████████ → OutOfMemoryError!

정상이면 객체가 쌓이다가 GC가 돌 때마다 사용량이 바닥으로 떨어진다. 누수가 있으면 GC가 돌아도 못 치우는 객체가 남아서, GC 직후의 바닥 자체가 점점 올라간다. 볼 것은 꼭대기가 아니라 GC 직후 바닥이 올라가는가다.

도구 활용

아래 명령에 나오는 <pid>는 살펴볼 자바 프로그램의 프로세스 번호(PID)다. 터미널에서 JDK에 들어 있는 jps -l을 치면 돌고 있는 자바 프로그램과 그 번호가 줄마다 나온다.

1. VisualVM (초보자 추천)

창으로 보는 도구(GUI)다. 예전에는 JDK에 jvisualvm이라는 이름으로 들어 있었지만 지금 JDK에는 없다. 이 PC의 JDK 21을 설치한 폴더 안 bin 폴더(java · jstat · jmap 같은 명령 파일이 든 곳)에도 없었다. visualvm.github.io 에서 따로 받아 실행한다.

Monitor 탭: Heap 사용량 실시간 그래프
Memory 탭: Heap Dump 생성 (.hprof)
Threads 탭: Thread 상태 확인

Heap Dump는 그 순간 Heap에 있던 객체를 통째로 파일로 적어 둔 것이다. VisualVM은 이 파일을 .hprof라는 확장자로 저장한다. 아래 jmap 예에서 만드는 heap.bin도 이름만 다를 뿐 Heap Dump 파일이다. 열어 보면 어떤 객체가 자리를 많이 차지하고 누가 그것을 붙들고 있는지 보인다. Threads 탭의 「상태」는 각 스레드가 지금 도는 중(RUNNABLE)인지, 무언가를 기다리는 중(WAITING)인지 같은 것을 말한다. 자바의 Thread.State가 이 상태들을 정해 둔다.

2. jstat (운영 환경)

창을 띄울 수 없는 서버에서도 터미널로 쓸 수 있다.

jstat -gc -h10 <pid> 1000
# Young, Old 영역 크기, GC 횟수 실시간 추적
# Old 영역이 계속 증가 → Memory Leak 의심!
  • -gc: Heap 칸별 크기 · 사용량과 GC 횟수를 보여 달라는 뜻이다
  • -h10: 숫자 10줄마다 칸 이름 줄을 다시 찍는다
  • 1000: 1000밀리초(1초)마다 한 줄씩 찍는다

3. jmap (Heap Dump)

jmap -dump:live,format=b,file=heap.bin <pid>
# heap.bin 파일 생성
# VisualVM에서 분석: 가장 많은 메모리를 점유하는 객체 확인
  • -dump:: Heap Dump를 뜬다
  • live: 살아 있는 객체만 적는다
  • format=b: 바이너리 형식으로 적는다. 사람이 읽는 글자가 아니라 프로그램(VisualVM 같은 분석 도구)이 읽는 바이트로 적는다는 뜻이다
  • file=heap.bin: heap.bin이라는 파일로 저장한다

옵션 뜻은 jmap -help 출력에 있다.

4. Prometheus + Grafana (모니터링)

Prometheus는 서버 상태를 숫자(메트릭)로 주기적으로 모으고, Grafana는 그 숫자를 그래프로 보여 준다. 아래 두 줄은 Prometheus에 쌓인 JVM 메트릭의 이름이다. 이 숫자를 애플리케이션에서 내보내게 설정하는 법은 이 글에서 다루지 않는다.

jvm_memory_used_bytes{area="heap"} 지속 증가 감지
jvm_gc_pause_seconds 증가 → GC 빈도 증가
  • jvm_memory_used_bytes{area="heap"}: Heap 사용량이다. 앞의 그래프처럼 바닥이 계속 오르는지 본다
  • jvm_gc_pause_seconds: GC로 멈춘 시간이다. 이 값이 늘었다면 GC가 더 자주 돌았거나, 한 번 돌 때 더 오래 멈췄다는 뜻이다. 위 블록은 앞의 경우로 적었고, 둘 중 어느 쪽인지는 아래 GC 로그로 가린다

해결 전략

코드 개선:

// 나쁜 예: 무한정 캐싱
@Service
public class UserService {
    private final UserRepository userRepository;  // 앞의 예처럼 생성자로 넣는다(생성자는 생략)
    private Map<Long, User> cache = new HashMap<>();

    public User getUser(Long id) {
        return cache.computeIfAbsent(id,
            k -> userRepository.findById(k));
    }
}

// 좋은 예: 크기 제한 + TTL
@Service
public class UserService {
    private final UserRepository userRepository;  // 앞의 예처럼 생성자로 넣는다(생성자는 생략)
    private final Cache<Long, User> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build();

    public User getUser(Long id) {
        return cache.get(id,
            k -> userRepository.findById(k));
    }
}

나쁜 예부터 따라간다.

  • cache.computeIfAbsent(id, ...)는 Map에 id가 있으면 그 값을 돌려주고, 없으면 두 번째 인자로 값을 만들어 Map에 넣은 뒤 돌려준다
  • k -> userRepository.findById(k)는 람다다. 「키 k를 받아 DB에서 읽어 온 값을 돌려준다」는 동작을 그 자리에서 적어 넘긴 것이다
  • 문제는 넣기만 하고 빼는 코드가 없다는 것이다. 앞의 List 예와 똑같이 Singleton이 붙든 Map에 User가 끝없이 쌓인다

좋은 예는 Caffeine이라는 캐시 라이브러리를 쓴다. JDK에 들어 있지 않아서 Gradle 같은 빌드 도구에 라이브러리로 추가해야 한다.

  • Caffeine.newBuilder()가 설정을 담을 객체를 돌려주고, 거기에 .maximumSize(...), .expireAfterWrite(...)를 이어서 부른 뒤 .build()로 캐시를 만든다. 메서드마다 자기 자신을 돌려주기 때문에 점으로 이어 부를 수 있다
  • .maximumSize(10_000): 캐시에 담을 수 있는 최대 개수다
  • .expireAfterWrite(10, TimeUnit.MINUTES): 값을 넣고 10분이 지나면 저절로 지운다. 이렇게 값이 살아 있는 시간을 TTL(Time To Live)이라고 부른다
  • cache.get(id, k -> ...)는 Map의 get과 달리 인자가 두 개다. 캐시에 id가 있으면 돌려주고, 없으면 람다로 값을 만들어 캐시에 넣고 돌려준다. 위의 computeIfAbsent와 같은 일이다

크기와 시간에 상한이 있으니 Singleton이 붙들고 있어도 Heap을 끝없이 차지하지 않는다.

JVM 튜닝:

아래 값들은 JVM 옵션이다. java -Xms2g -Xmx2g -XX:+UseG1GC -jar app.jar처럼 프로그램을 띄우는 java 명령에 붙여 적는다. app.jar는 내 프로그램의 클래스 파일을 하나로 묶은 파일(jar)이고, -jar는 그 파일을 실행하라는 뜻이다.

# Heap 크기 조정
-Xms2g -Xmx2g

# GC 알고리즘 변경
-XX:+UseG1GC          # 기본 추천
-XX:+UseZGC           # 초저지연 필요 시

# GC 로그 활성화
-Xlog:gc*:file=gc.log
  • -Xms2g: 처음 잡는 Heap 크기를 2GB로 한다
  • -Xmx2g: Heap의 최대 크기를 2GB로 한다. 이 크기를 넘으면 OutOfMemoryError가 난다
  • -XX:+UseG1GC, -XX:+UseZGC: 쓸 GC를 고른다
  • -Xlog:gc*:file=gc.log: GC가 언제 돌고 얼마나 멈췄는지를 gc.log 파일에 적는다. 이 PC의 JDK 21에서 -XX:+UseZGC와 함께 돌려 gc.log에 GC 기록이 남는 것을 확인했다

처음 이 글을 쓸 때는 GC 로그 옵션으로 -XX:+PrintGCDetails도 적었다. JDK 21에서 이 옵션을 주면 -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.라는 경고가 뜬다. deprecated는 「이제 쓰지 말라」는 표시이고, 경고 뒷부분은 「대신 -Xlog:gc*로 처리하겠다」는 뜻이다. 그래서 -Xlog:gc*만 남겼다.


정리하며

핵심 개념 요약

Stack vs Heap

  • Stack: Thread 독립, 메소드 로컬 변수, 빠름
  • Heap: Thread 공유, 모든 객체 저장, GC 대상

Garbage Collection

  • GC Root에서 도달 불가능한 객체 제거
  • Young (Eden → Survivor) → Old 승격
  • Stop-the-World: GC 중 애플리케이션 멈춤

Spring Bean Scope

  • Singleton: Heap에 1개, Old Generation, Thread-safe (Stateless일 때)
  • Prototype: 요청마다 생성, Eden 할당, Spring 관리 안 함
  • Request: HTTP 요청마다 생성, 요청 종료 시 GC 대상

Memory Leak 방지

  • Singleton에 실행 중 계속 바뀌는(Mutable) Collection 저장 금지
  • 캐시는 크기 제한 + TTL 설정
  • Heap 사용량 모니터링 (VisualVM, Prometheus)

실무에서 기억할 것

Spring Bean은 평범한 JVM 객체다

  • Singleton도 결국 Heap에 저장
  • ApplicationContext가 계속 참조해서 절대 수거 안 됨
  • Thread-safety는 코드로 보장해야 함

JVM을 이해해야 Spring을 디버깅할 수 있다

  • OOM 발생 시: Heap Dump 분석
  • 응답 느림: GC 로그 확인
  • Memory Leak: GC Root 추적

성능 튜닝의 출발점

  • 불필요한 객체 생성 최소화 (Prototype은 부를 때마다 Eden에 새 객체를 만들어 Minor GC를 자주 부른다)
  • GC 알고리즘 선택 (G1, ZGC)
  • Heap 크기 적절히 설정

참고 자료


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기