포스트

Java (0) - Introduction

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

Java (0) - Introduction

왜 자바인가

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

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

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

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

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

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

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

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

무엇을 다루나

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

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

쓸 줄은 안다 왜 그런지는 모른다
HashMap.get()을 쓴다 왜 O(1)인지, 언제 O(n)이 되는지 모른다
IDE가 시키는 대로 equals와 hashCode를 같이 만든다 왜 같이 만들어야 하는지 모른다
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에서 나온 127과 128의 차이를 설명합니다.

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