Java (0) - Introduction
이 시리즈가 무엇을 다루고 무엇을 다루지 않는지, 그리고 스프링 없이 main 메서드 하나로 확인하는 실습 환경을 다룹니다.
왜 자바인가
실무 코드에서 매일 쓰는데 왜 그런지는 모르는 것들이 있습니다.
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의 차이를 설명합니다.