포스트

Java (0) - Introduction

이 시리즈가 무엇을 다루고 무엇을 다루지 않는지, 그리고 스프링 없이 main 메서드 하나로 확인하는 실습 환경을 다룹니다.

Java (0) - Introduction

왜 자바인가

실무 코드에서 매일 쓰는데 왜 그런지는 모르는 것들이 있습니다.

  • synchronized를 붙이면 왜 되는지, 스레드마다 값이 다르게 보일 수 있는 이유
  • IDE가 자동으로 만들어 주는 equalshashCode정확히 무슨 일을 하는지
  • “메모리에 올린다”는 말은 흔히 쓰면서도 힙이 정확히 뭔지

그리고 면접에서는 스프링보다 자바 질문이 더 많이 나옵니다. @Transactional을 설명해 보라는 질문보다 HashMap은 왜 O(1)인가요” 가 훨씬 자주 나옵니다.

더 실감나는 건 이런 코드입니다.

1
2
3
if (order.getMemberId() == member.getId()) {   // 둘 다 Long
    ...
}

로컬에서도 테스트에서도 잘 돌아갑니다. 그러다 운영에서 특정 시점부터 갑자기 안 맞기 시작합니다. 예외도 안 나고 로그도 깨끗합니다.

이 코드가 언제부터 왜 깨지는지는 1편에서 다룹니다. 스프링을 아무리 깊게 알아도 이건 안 보입니다.

이 시리즈는 그 자리를 메웁니다.

무엇을 다루나

문법 입문이 아닙니다. for 문, 클래스, 상속은 다루지 않습니다. 이미 알고 있고, 모른다면 검색 결과가 이 글보다 낫습니다.

다루는 건 “쓸 줄은 아는데 왜 그런지는 모르는 것” 입니다. 이런 상태를 말합니다.

쓸 줄은 안다 왜 그런지는 모른다
HashMap.get()을 쓴다 왜 O(1)인지, 언제 O(n)이 되는지 모른다
IDE가 시키는 대로 equalshashCode를 같이 만든다 왜 같이 만들어야 하는지 모른다
List<String>을 쓴다 실행 중에는 그 String이 사라진다는 걸 모른다
stream()을 쓴다 for보다 느릴 수 있다는 걸 모른다
예외를 RuntimeException으로 만든다 왜 체크 예외를 안 쓰는지 모른다
GC가 알아서 치워준다고 안다 안 치워지는 경우가 있다는 걸 모른다

도착점은 “왜 그런가요”에 답할 수 있는 상태입니다.

다루지 않는 것

  • 클래스 로더, 바이트코드, JIT 컴파일 — JVM 내부로 한 층 더 들어가는 이야기입니다. 신입 면접 범위를 넘고, main 메서드로 확인하기도 어렵습니다
  • 자바 메모리 모델의 volatile과 happens-before — 주제가 따로입니다. 이 시리즈에서 다루는 메모리는 “객체가 어디에 저장되나” 쪽입니다
  • GC 튜닝 — 7편에서 GC를 다루지만 구조와 로그 읽는 법까지입니다. 옵션을 조합해 성능을 올리는 이야기는 안 합니다
  • 최신 문법 소개record, sealed, 패턴 매칭은 필요한 자리에서만 나옵니다

실습 환경

이 시리즈는 스프링이 필요 없습니다.

다른 준비물도 없습니다. 계정을 만들거나 뭔가를 미리 설치할 필요도 없습니다.

여기서는 main 메서드 하나면 전부 확인됩니다.

항목
Java 21 (LTS)
빌드 도구 필요 없음 (원하면 Gradle)
프레임워크 없음
실행 main 메서드 또는 JUnit 5
DB, Docker 없음

7편에서 GC 로그를 볼 때만 실행 옵션 몇 개(-Xmx, -Xlog:gc)를 붙입니다. 그것도 java 명령 뒤에 붙이는 게 전부입니다.

확인 1: 자바 버전 보기

1
java -version
1
2
3
openjdk version "21.0.2" 2024-01-16
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode, sharing)

21로 시작하면 됩니다. 두 번째 줄의 배포판 이름(Temurin, Corretto, Zulu 등)은 어디서 받았느냐에 따라 다르고, 이 시리즈 내용에는 영향이 없습니다.

이 시리즈의 내용 대부분은 Java 8 이후로 안 바뀐 것들입니다. 다만 7편의 GC 기본값(Java 21은 G1)과 2편의 record 처럼 버전을 타는 부분이 있어서 21로 고정합니다.

확인 2: 파일 하나를 그대로 실행하기

프로젝트를 만들 필요도 없습니다. Java 11부터는 .java 파일을 컴파일 없이 바로 실행할 수 있습니다.

1
2
3
4
5
6
7
8
public class Hello {
    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;
        System.out.println("127 == 127 -> " + (a == b));
        System.out.println("128 == 128 -> " + (c == d));
    }
}
1
java Hello.java
1
2
127 == 127 -> true
128 == 128 -> false

javac를 부르지 않았는데 실행됐습니다. 내부적으로 메모리에서 컴파일하고 바로 돌립니다. 클래스 파일도 안 남습니다.

이 시리즈의 실습 대부분이 이렇게 돌아갑니다. 파일 하나 만들고 java 파일이름.java를 치면 끝입니다.

그리고 방금 나온 출력이 1편의 주제입니다. 127은 같고 128은 다릅니다. 같은 코드인데 숫자만 바뀌었습니다.

확인 3: 내 JVM의 기본 힙 크기 보기

7편에서 다룰 힙은 지금 이미 잡혀 있습니다. 아무 옵션도 안 줬을 때 JVM이 얼마를 쓰기로 정했는지 볼 수 있습니다.

1
java -XX:+PrintFlagsFinal -version | grep -i heapsize
1
2
3
4
5
   size_t ErgoHeapSizeLimit                        = 0                                   {product} {default}
   ...
   size_t InitialHeapSize                          = 268435456                        {product} {ergonomic}
   size_t MaxHeapSize                              = 4294967296                       {product} {ergonomic}
   ...

줄이 여러 개 나오는데(CodeHeapSize처럼 이름에 heapsize가 들어간 것도 같이 걸립니다) 봐야 할 건 두 줄입니다.

플래그 위 예시
InitialHeapSize 시작할 때 잡는 힙 (-Xms) 268435456 = 256MB
MaxHeapSize 최대로 늘릴 수 있는 힙 (-Xmx) 4294967296 = 4GB

아무도 이 숫자를 지정한 적이 없습니다. 오른쪽 끝의 {ergonomic}이 그 뜻입니다. JVM이 장비의 물리 메모리를 보고 알아서 정했습니다. 대략 최대는 물리 메모리의 1/4, 시작은 1/64 입니다. 16GB 노트북이면 위처럼 4GB와 256MB가 나옵니다.

값은 장비마다 다릅니다. 메모리가 8GB면 최대 힙이 2GB로 나옵니다. 컨테이너 안에서는 컨테이너에 준 메모리를 기준으로 계산합니다. 로컬에서는 넉넉했는데 배포하고 나서 OutOfMemoryError가 나는 경우가 여기서 나옵니다.

확인 4: 지금 어떤 GC가 돌고 있나

같은 방식으로 GC 종류도 볼 수 있습니다.

1
java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseParallelGC|UseZGC"
1
2
3
4
     bool UseG1GC                                  = true                             {product} {ergonomic}
     bool UseParallelGC                            = false                               {product} {default}
     bool UseSerialGC                              = false                               {product} {default}
     bool UseZGC                                   = false                               {product} {default}

UseG1GC = true이고 {ergonomic}입니다. Java 21의 기본 GC는 G1이고, 이것도 아무도 안 정했는데 JVM이 골랐습니다.

이 네 줄이 7편의 예고편입니다. 지금은 “골라둔 게 있구나” 정도면 충분합니다.

실행 옵션 붙이기

7편에서 힙 크기를 바꾸거나 GC 로그를 켤 때 옵션을 씁니다. 붙이는 자리는 java와 파일 이름 사이입니다.

1
java -Xmx64m -Xlog:gc Gc.java
옵션 어디서 쓰나
-Xmx64m 최대 힙을 64MB로 7편
-Xms64m 시작 힙을 64MB로  
-Xss256k 스레드 스택 크기 1편
-Xlog:gc GC 로그 출력 7편
-XX:+UseSerialGC GC를 Serial로 바꾼다 7편

순서를 바꾸면 안 됩니다. 파일 이름 뒤에 붙인 건 프로그램의 인자(args) 로 넘어가고 JVM은 무시합니다.

1
java Gc.java -Xmx64m      # 옵션이 아니라 args[0] 이 된다

JUnit으로 하고 싶다면

main 메서드에 System.out.println을 찍는 게 이 시리즈의 기본 방식입니다. 눈으로 보는 게 목적이라 그게 제일 낫습니다.

다만 “이 값이 이렇게 나온다”를 못 박아두고 싶은 실습(2편의 set.size(), 5편의 IllegalStateException 같은 것)은 테스트로 두는 게 편합니다. 그때는 Gradle 프로젝트 하나만 있으면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.10.2')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
    useJUnitPlatform()
}

스프링 의존성이 한 줄도 없습니다. 이 시리즈에서 쓰는 건 JUnit뿐입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;

class MemberTest {

    @Test
    void 값을_못_찾는_것을_못박아둔다() {
        Set<Member> set = new HashSet<>();
        set.add(new Member("kim@example.com"));

        assertFalse(set.contains(new Member("kim@example.com")));
    }
}

이 테스트는 “안 찾아지는 것”을 통과 조건으로 두고 있습니다. 2편에서 hashCode를 재정의하면 이 테스트가 깨집니다. 그게 목적입니다.

이 시리즈를 읽는 법

세 가지만 지키면 됩니다.

1. 출력을 먼저 예상하고 실행합니다. 이 시리즈의 실습 대부분은 예상과 다른 결과를 보여주려고 만든 것입니다. 예상 없이 실행하면 “그렇구나”로 끝나고, 예상하고 실행하면 “왜?”가 남습니다. 그 “왜”가 본문입니다.

2. 코드를 복사해서 조금씩 고쳐봅니다. 1편의 Integer 캐시 예제에서 숫자를 127과 128 말고 다른 값으로 바꿔보는 것, 3편에서 원소 개수를 10만에서 100만으로 늘려보는 것 같은 일입니다. 글에 적힌 숫자를 재현하는 게 목적이 아니라 경계가 어디인지 확인하는 게 목적입니다.

3. 숫자는 안 외웁니다. 이 시리즈에는 시간 측정이 여러 번 나오는데 전부 제 환경에서 나온 예시입니다. 장비가 다르면 다르게 나옵니다. 외울 건 값이 아니라 방향과 자릿수입니다.

목차

여덟 편입니다.

제목 이 편이 푸는 문제
0 Introduction 무엇을 다루고 무엇을 안 다루나
1 Memory: 객체는 어디에 있나 스택과 힙, 참조와 값
2 equals & hashCode: 같다는 것 둘을 같이 재정의해야 하는 이유
3 Collection: HashMap은 왜 빠른가 O(1)의 정체와 깨지는 경우
4 Generic: 컴파일하면 사라진다 타입 소거와 그 대가
5 Stream & Optional: 언제 쓰나 남용하면 오히려 나빠지는 것
6 Exception: 무엇을 잡고 무엇을 던지나 체크 예외 논쟁
7 GC: 안 쓰는 객체는 언제 사라지나 힙 구조와 Stop-the-World

1편이 나머지를 떠받칩니다. 스택·힙·참조를 알아야 2편(같다는 것), 3편(해시 버킷), 7편(GC)이 말이 됩니다. 순서를 건너뛸 거면 1편만은 먼저 보는 게 낫습니다.

순서에는 이유가 있습니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1편 (메모리)
 ├─ "객체는 힙에 있고 변수는 참조만 들고 있다"
 │
 ├──▶ 2편 (equals/hashCode)  "그럼 두 참조가 같은 걸 가리키나?"
 │      │
 │      └──▶ 3편 (Collection)  "그 hashCode로 자리를 정한다"
 │
 ├──▶ 4편 (Generic)  "그 자리에 들어가는 타입은 컴파일 후 사라진다"
 │      │
 │      └──▶ 5편 (Stream)  "그 컬렉션을 다루는 다른 방법"
 │
 ├──▶ 6편 (Exception)  "중간에 실패하면 어디로 가나"
 │
 └──▶ 7편 (GC)  "안 쓰는 객체는 힙에서 언제 사라지나"

2편은 1편의 ==equals에서 곧바로 이어지고, 3편은 2편에서 만든 hashCode가 어디에 쓰이는지를 봅니다. 7편은 1편의 힙 그림에 Young과 Old를 채워 넣는 편입니다.

내가 헷갈렸던 지점

정리

  • 이 시리즈는 문법 입문이 아니라 “쓸 줄은 아는데 왜 그런지 모르는 것” 을 다룬다. 출발점은 HashMap을 쓸 줄 아는 상태, 도착점은 왜 O(1)인지 답할 수 있는 상태다
  • 스프링이 필요 없다. main 메서드 하나면 전부 확인된다. Java 11부터는 java 파일이름.java로 컴파일 없이 바로 돌릴 수도 있다
  • 클래스 로더·바이트코드·JIT·GC 튜닝은 다루지 않는다. 신입 면접 범위를 넘고 실습으로 확인하기도 어렵다
  • 힙 크기와 GC 종류는 아무도 지정하지 않아도 이미 정해져 있다. -XX:+PrintFlagsFinal{ergonomic}이 그 표시고, 값은 장비(그리고 컨테이너에 준 메모리)에 따라 달라진다
  • 1편이 나머지를 떠받친다. 스택·힙·참조를 알아야 2편의 동일성, 3편의 해시 버킷, 7편의 GC가 성립한다

1편에서 new를 한 번 부를 때 메모리에서 무슨 일이 일어나는지부터 봅니다. 그리고 확인 2에서 나온 127128의 차이를 설명합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.