F-LAB JAVA · 2주차 · Phase 1 · 자바 변수 ↔ 메모리 영역의 매핑
🎯 2주차 시작 — 1주차에서 본 JVM 메모리 영역을, 이제 변수 단위로 매핑한다.
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
static 키워드가 만드는 결정적 차이는?static 변수가 거의 없는가?자바 변수는 "어디에 선언했는가"에 따라 3종류로 나뉘고, 각각 다른 메모리 영역에 저장되며, 다른 수명을 가진다.
— 지역 변수 (Local), 인스턴스 변수 (Instance), 클래스 변수 (Class/Static).
1주차에서 본 Heap/Stack/Method Area의 분할이 변수 단위로 정확히 매핑된다.
| 종류 | 어디에 | 누가 쓰나 | 비유 |
|---|---|---|---|
| 지역 변수 | 회의실의 화이트보드 | 회의 참석자만 | 회의 끝나면 지움. 다음 회의는 처음부터 |
| 인스턴스 변수 | 개인 책상 서랍 | 그 책상 주인만 | 직원 100명 = 책상 100개 = 서랍 100개 |
| 클래스 변수 | 공용 게시판 | 모든 직원이 공유 | 회사에 게시판 하나. 누가 바꾸면 모두에게 보임 |
직원 = 객체(인스턴스), 회사 = 클래스.
책상 = 인스턴스마다 1개, 게시판 = 클래스에 1개, 화이트보드 = 회의(메서드)마다 1개.
1. 변수의 본질 — 왜 3종류로 나눠야 했나
2. 지역 변수 (Local) — 가장 짧게 살고 가장 자주 만들어진다
3. 인스턴스 변수 (Instance) — 객체의 정체성을 만드는 것
4. 클래스 변수 (Static) — 클래스에 단 하나, 모두가 공유
5. 3종 비교 — 한눈에 보기 — 표 · 다이어그램으로 완전 정리
6. 매개변수의 정체 — 지역 변수의 특수형
7. ILIC 실무 코드 — 어디서 무엇을 쓰는가
8. 흔한 실수 7가지 — 초기화 누락 · static 남용 · 가변 static 함정
9. 면접 질문 + 자기 점검
int age = 30;
세 가지가 동시에 일어난다.
1. 메모리 어딘가에 공간을 확보 (4바이트, int)
2. 그 공간에 이름 age를 붙임
3. 그 공간에 값 30을 넣음
→ 변수 = 이름 + 메모리 공간 + 값.
여기서 자바는 3가지 선택지를 만들었다.
public class Order {
static int totalOrders = 0; // ← (A) 클래스에, static
private String orderId; // ← (B) 클래스에, static 없음
public void process() {
int retryCount = 0; // ← (C) 메서드 안에
}
}
| 위치 | 이름 | 메모리 영역 | 수명 |
|---|---|---|---|
(A) 클래스 안 + static | 클래스 변수 | Method Area | JVM 종료까지 |
(B) 클래스 안 + static 없음 | 인스턴스 변수 | Heap (객체 안) | 객체가 GC될 때까지 |
| (C) 메서드 안 | 지역 변수 | Stack (스택 프레임) | 메서드 종료까지 |
→ "어디에 선언했는가"가 "어디에 저장되는가"를 결정한다.
→ 그리고 그게 수명(lifetime) 도 결정한다.
세 가지 요구가 동시에 있었다.
자바는 이 셋을 메모리 영역 분리로 해결했다.
⚠️ 1주차 Unit 4.1 (JVM 런타임 데이터 영역)에서 본 5분할이 여기서 변수 단위로 매핑된다.
다음 Unit 1.2에서 메모리 위치를 정확히 본다.
메서드(또는 블록) 안에서 선언되어, 그 메서드 종료 시 사라지는 변수.
public BigDecimal calculateFare(Shipment shipment) {
BigDecimal baseFare = new BigDecimal("100000"); // ✓ 지역 변수
int weight = shipment.getWeight(); // ✓ 지역 변수
if (weight > 1000) {
BigDecimal surcharge = baseFare.multiply(...); // ✓ 지역 변수 (블록 안)
return baseFare.add(surcharge);
}
return baseFare;
} // ← 메서드 끝. baseFare, weight, surcharge 모두 사라짐
public void method() {
int a;
System.out.println(a); // ❌ 컴파일 에러
// variable a might not have been initialized
}
지역 변수는 반드시 사용 전 초기화해야 한다. 컴파일러가 강제.
→ 인스턴스/클래스 변수와의 가장 결정적 차이.
public void process() {
int count = 0; // 호출 때마다 0으로 시작
count++;
}
// 1만 번 호출하면? 1만 개의 count가 만들어졌다 사라짐
각 메서드 호출은 스택 프레임(Stack Frame) 을 만든다. 지역 변수는 그 안에 들어간다.
Stack은 스레드별로 별도 → 지역 변수는 자동으로 스레드 안전.
이게 함수형 스타일 + Stream + 람다가 동시성에 강한 이유.
public void method() {
for (int i = 0; i < 10; i++) {
String temp = "round " + i;
System.out.println(temp);
}
System.out.println(temp); // ❌ for 블록 밖. temp 안 보임
System.out.println(i); // ❌ 마찬가지
}
→ 지역 변수의 스코프는 선언된 블록 {} 안.
클래스에 선언되었으나
static이 없는 변수. 객체마다 별도로 존재하며, 객체의 상태를 표현한다.
public class Shipment {
private Long id; // ✓ 인스턴스 변수
private String blNo; // ✓
private LocalDate eta; // ✓
private BigDecimal freight; // ✓
}
Shipment s1 = new Shipment();
Shipment s2 = new Shipment();
s1.setBlNo("BL-001");
s2.setBlNo("BL-002");
s1.getBlNo(); // "BL-001"
s2.getBlNo(); // "BL-002"
// s1과 s2는 서로 다른 메모리(Heap)에 산다
// 각자의 blNo도 별도
public class Shipment {
private Long id; // → null (참조형 기본값)
private int weight; // → 0 (int 기본값)
private boolean delivered; // → false
private double rate; // → 0.0
}
Shipment s = new Shipment();
s.getId(); // null (NullPointerException 없이 호출 가능)
s.getWeight(); // 0
s.isDelivered(); // false
→ 지역 변수와 다르게 자동 초기화된다. 컴파일러 에러 안 남.
기본값 표:
| 타입 | 기본값 |
|---|---|
byte, short, int, long | 0 |
float, double | 0.0 |
boolean | false |
char | '\u0000' (null character) |
| 참조형 (객체, 배열) | null |
Shipment s = new Shipment(); // 1) Heap에 객체 생성, blNo 등 함께 할당
s = null; // 2) 참조 끊김 → GC 대상
// 3) GC 발동 시 → 인스턴스 변수도 함께 회수
객체가 살아있는 한 인스턴스 변수도 살아있고, 객체가 GC되면 함께 사라진다.
public class Shipment {
private String blNo;
private LocalDate eta;
}
for (int i = 0; i < 100; i++) {
new Shipment(); // 100개 객체 생성
}
// 메모리에 만들어진 것:
// - blNo 100개 (객체 100개 안에 1개씩)
// - eta 100개 (마찬가지)
→ 객체 수만큼 인스턴스 변수가 존재한다.
static으로 선언된 변수. 클래스 단위로 단 1개만 존재하며, 모든 객체가 공유한다.
public class Shipment {
public static final String COMPANY_NAME = "ILIC"; // ✓ 상수
private static int totalCount = 0; // ✓ 카운터
private static final BigDecimal DEFAULT_RATE // ✓ 정책 상수
= new BigDecimal("1.0");
private String blNo; // (인스턴스 변수, 비교용)
}
Shipment s1 = new Shipment();
Shipment s2 = new Shipment();
Shipment s3 = new Shipment();
// Shipment.totalCount는 메모리에 단 1개
// s1, s2, s3가 모두 같은 totalCount를 본다
Heap:
┌───────────┐ ┌───────────┐ ┌───────────┐
│ s1 객체 │ │ s2 객체 │ │ s3 객체 │
│ blNo=... │ │ blNo=... │ │ blNo=... │ ← 인스턴스 변수: 객체마다
└───────────┘ └───────────┘ └───────────┘
↓ ↓ ↓
└─────────────┴─────────────┘
↓
Method Area:
┌───────────────────────┐
│ Shipment 클래스 │
│ totalCount = ? │ ← 클래스 변수: 클래스에 1개
└───────────────────────┘
Shipment.COMPANY_NAME; // ✓ 객체 안 만들고 클래스명으로 접근
Shipment.totalCount; // ✓
Math.PI; // ✓ (대표적 예)
Integer.MAX_VALUE; // ✓
→ 클래스 로딩 시점에 이미 메모리에 존재하기 때문.
JVM 시작
↓
클래스 로딩 (처음 사용될 때)
↓
Method Area에 클래스 정보 + 클래스 변수 할당
↓
... 프로그램 실행 동안 계속 살아있음 ...
↓
JVM 종료
↓
함께 사라짐
이 긴 수명이 양날의 검.
public static final String COMPANY_NAME = "ILIC"; // ✓ 상수
public static final BigDecimal DEFAULT_RATE // ✓ 상수
= new BigDecimal("1.0");
static만 쓰면 변수, static final이면 상수.
상수로 쓸 때의 장점:
Math.PI, Integer.MAX_VALUE 패턴가변 static은 거의 항상 함정이다 (8장 흔한 실수에서).
| 항목 | 지역 변수 | 인스턴스 변수 | 클래스 변수 |
|---|---|---|---|
| 선언 위치 | 메서드/블록 안 | 클래스 안 (static X) | 클래스 안 (static O) |
| 저장 영역 | Stack | Heap (객체 안) | Method Area |
| 생성 시점 | 메서드 호출 시 | new 시 | 클래스 로딩 시 |
| 소멸 시점 | 메서드 종료 시 | 객체 GC 시 | JVM 종료 시 |
| 개수 | 호출마다 1개 | 객체마다 1개 | 클래스에 단 1개 |
| 자동 초기화 | ❌ (수동 필수) | ✓ (기본값) | ✓ (기본값) |
| 객체 없이 접근 | ❌ | ❌ | ✓ |
| 접근 키워드 | (없음) | this.x | ClassName.x |
| 스레드 공유 | ❌ (스레드별) | 객체 공유 시 ✓ | ✓ (항상) |
| GC 대상 | ❌ (자동 소멸) | ✓ | ❌ (JVM 종료까지) |
public class Member {
static int totalCount; // 클래스 변수
String name; // 인스턴스 변수
void greet() {
String hello = "hi"; // 지역 변수
}
}
for (int i = 0; i < 100; i++) {
Member m = new Member();
m.greet();
}
메모리에 생긴 것:
Method Area:
totalCount ← 1개 (클래스에 1개)
Heap:
Member 객체 100개, 각각 안에 name ← 100개
Stack (greet 실행 중 한 순간):
hello ← 1개 (호출당 1개. 호출 끝나면 사라짐)
이 질문이 면접 단골. 즉답할 수 있어야 한다.
public class Shipment {
public static final String COMPANY = "ILIC"; // ← 클래스 변수 (Method Area)
private Long id; // ← 인스턴스 변수 (Heap, 객체 안)
private String blNo; // ← 인스턴스 변수
public BigDecimal calculate(int weight) {
BigDecimal rate = computeRate(); // ← 지역 변수 (Stack)
return rate.multiply(BigDecimal.valueOf(weight));
}
}
호출 흐름:
┌────────────────────────────────────┐
│ Method Area │
│ ┌────────────────────────────────┐ │
│ │ Shipment 클래스 │ │
│ │ COMPANY = "ILIC" │ │ ← 1개. 모두 공유
│ │ (메서드 바이트코드) │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ Heap │
│ ┌──────────┐ ┌──────────┐ │
│ │ Shipment │ │ Shipment │ ... │ ← N개 (객체마다)
│ │ id, blNo │ │ id, blNo │ │
│ └──────────┘ └──────────┘ │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ Stack (스레드별) │
│ ┌────────────────────────────────┐ │
│ │ calculate() 프레임 │ │ ← 호출 중에만 존재
│ │ weight (매개변수, 지역) │ │
│ │ rate (지역) │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────┘
public BigDecimal calculate(int weight, BigDecimal rate) {
// ↑ ↑
// 매개변수 매개변수
// 이 둘은 지역 변수다.
}
매개변수는 메서드 호출 시 자동으로 초기화되는 지역 변수.
calculate(100, new BigDecimal("50"));
// ↑ ↑
// weight=100, rate=50 으로 자동 할당
→ 지역 변수와 똑같이 Stack에 저장, 메서드 종료 시 사라짐, 스레드별로 별도.
public void modify(Shipment s) {
s.setBlNo("CHANGED"); // ← 호출자에게 보임
s = new Shipment(); // ← 호출자에게 안 보임
}
=)은 매개변수의 지역 복사본만 바꿈자바는 Pass by Value (1주차 Unit 4.2). 참조형도 "주소값을 값으로 복사".
매개변수가 지역 변수임을 이해하면 이 동작이 자연스럽다.
public BigDecimal calculate(final int weight, final BigDecimal rate) {
weight = 200; // ❌ final이라 재할당 불가
return rate.multiply(BigDecimal.valueOf(weight));
}
매개변수를 메서드 안에서 바꾸지 않겠다는 의도 표현. 람다에서 외부 변수를 캡처할 때 effectively final 규칙과 직결.
@Entity
public class Shipment {
@Id @GeneratedValue
private Long id; // ✓ 인스턴스 변수
private String blNo; // ✓
private String origin; // ✓
private String destination; // ✓
private LocalDate eta; // ✓
private BigDecimal freight; // ✓
@OneToMany(mappedBy = "shipment")
private List<Cargo> cargoes; // ✓
}
포인트: Entity의 필드는 모두 인스턴스 변수. 객체마다 다른 상태(다른 화물 정보)를 표현하므로.
static 변수가 거의 없는 이유@Service
public class ShipmentService {
// ❌ 가변 static — 멀티스레드 위험!
// private static int processedCount = 0;
private final ShipmentRepository repository; // ✓ 인스턴스 final
private final FareCalculator calculator; // ✓
public ShipmentService(ShipmentRepository repository,
FareCalculator calculator) {
this.repository = repository;
this.calculator = calculator;
}
public Shipment process(ShipmentRequest req) {
Shipment shipment = new Shipment(req); // ✓ 지역 변수
BigDecimal fare = calculator.compute(shipment);
shipment.setFreight(fare);
return repository.save(shipment);
}
}
왜 Service에 가변 static이 없는가?
final + DI로 불변 보장static을 두면 → 상태 공유 + 멀티스레드 → race condition 직격public static finalpublic final class FreightPolicy {
public static final BigDecimal DEFAULT_FUEL_RATE
= new BigDecimal("0.15"); // ✓ 상수
public static final int MAX_WEIGHT_KG = 28_000; // ✓
public static final String CURRENCY_KRW = "KRW";
public static final Set<String> SUPPORTED_ROUTES = Set.of(
"SEOUL-TOKYO", "SEOUL-OSAKA", "BUSAN-SHANGHAI"
);
private FreightPolicy() {} // 인스턴스화 방지
}
// 사용
BigDecimal fuel = price.multiply(FreightPolicy.DEFAULT_FUEL_RATE);
포인트:
private 생성자static final + 불변 객체 = 멀티스레드 안전 + 메모리 절약static이 적절한 드문 경우 — 카운터 with 동시성 보호@Component
public class ShipmentMetrics {
private static final AtomicLong totalProcessed = new AtomicLong(0);
private static final AtomicLong totalFailed = new AtomicLong(0);
public static void incrementProcessed() {
totalProcessed.incrementAndGet();
}
public static long getTotalProcessed() {
return totalProcessed.get();
}
}
왜 여기선 OK?
AtomicLong 자체가 thread-safepublic ShipmentResponse process(ShipmentRequest req) {
Long requestedId = req.getId(); // ✓ 지역
Shipment shipment = repository.findById(requestedId) // ✓ 지역
.orElseThrow(() -> new NotFoundException(requestedId));
BigDecimal baseFare = calculator.compute(shipment); // ✓ 지역
BigDecimal surcharge = computeSurcharge(shipment); // ✓ 지역
BigDecimal total = baseFare.add(surcharge); // ✓ 지역
shipment.setFreight(total);
return ShipmentResponse.from(shipment);
}
포인트: 비즈니스 로직의 임시 계산은 거의 다 지역 변수. 메서드가 끝나면 깨끗하게 정리 → 메모리 누수 걱정 없음.
public BigDecimal calculate(Shipment shipment) {
final BigDecimal baseFare = lookupBaseFare(shipment); // ✓ 의도: 안 바꿀게
final int weight = shipment.getWeight();
// ... 100줄 후
// baseFare = newValue; ← 컴파일 에러로 보호
}
람다 안에서 외부 변수를 쓰려면 사실상 final이어야 함 (effectively final). 명시하면 의도가 더 명확.
public BigDecimal calculate(int weight) {
BigDecimal fare;
if (weight > 1000) {
fare = new BigDecimal("200000");
}
// weight가 1000 이하면? fare 초기화 안 됨
return fare; // ❌ 컴파일 에러
}
// ✅
public BigDecimal calculate(int weight) {
BigDecimal fare = BigDecimal.ZERO; // 명시적 초기화
if (weight > 1000) {
fare = new BigDecimal("200000");
}
return fare;
}
static을 Service에 사용 (멀티스레드 사고)@Service
public class ShipmentService {
// ❌ 모든 요청이 공유. 동시 요청 시 값이 섞임
private static int currentBatchId = 0;
public void process() {
currentBatchId++;
// ... currentBatchId 사용
}
}
문제:
currentBatchId 를 ++ → race condition해결:
// 옵션 1: AtomicInteger
private static final AtomicInteger currentBatchId = new AtomicInteger(0);
// 옵션 2: 인스턴스 변수 (Spring 싱글톤이라 동일 효과지만 의도 명확)
// 옵션 3: 메서드 안의 지역 변수로 옮기기 (가장 깨끗)
static 컬렉션의 메모리 누수public class ShipmentCache {
// ❌ 영원히 자라는 캐시
private static final Map<Long, Shipment> cache = new HashMap<>();
public static void put(Long id, Shipment s) {
cache.put(id, s); // 계속 쌓이기만 함
}
}
문제:
static 변수는 GC 대상 아님 (JVM 종료까지)cache가 들고 있는 Shipment들도 GC 안 됨해결:
Caffeine, LinkedHashMap LRU)public class Shipment {
private String blNo;
public void setBlNo(String blNo) {
blNo = blNo; // ❌ 매개변수가 자기 자신에게 대입. 인스턴스 변수 안 바뀜
}
// ✅
public void setBlNo(String blNo) {
this.blNo = blNo; // ← this 명시
}
}
디버깅 지옥: 컴파일은 되고 IDE 경고도 약함. 운영에서 "값이 안 바뀌네?" 며칠 헤맴.
public class ShipmentService {
@Autowired
private ShipmentRepository repository; // 인스턴스 변수, 자동 초기화로 null
// ❌ Constructor가 끝나기 전에 호출되면?
{
repository.findAll(); // null이라 NPE
}
}
// ✅ 생성자 주입 + final
@RequiredArgsConstructor
public class ShipmentService {
private final ShipmentRepository repository;
}
→ Spring 4.3+ 권장: 생성자 주입 + final 인스턴스 변수.
public class Order {
private BigDecimal total;
public void calculate(List<Item> items) {
BigDecimal total = BigDecimal.ZERO; // ❌ 인스턴스 변수를 가림
for (Item item : items) {
total = total.add(item.price()); // 지역 변수만 바뀜
}
// 인스턴스 변수 total은 안 바뀐 채로 메서드 종료
}
}
→ 이름이 같으면 지역 변수가 우선. 인스턴스 변수에 접근하려면 this.total.
static 변수에 인스턴스를 직접 노출public class Config {
// ❌ 외부에서 마음대로 바꿀 수 있음
public static List<String> ROUTES = new ArrayList<>();
}
// 어디선가:
Config.ROUTES.clear(); // 다른 코드 전체에 영향
Config.ROUTES = null; // 더 심각
해결:
public class Config {
public static final List<String> ROUTES = List.of("SEOUL", "BUSAN");
// 또는
private static final List<String> ROUTES = new ArrayList<>();
public static List<String> getRoutes() {
return Collections.unmodifiableList(ROUTES);
}
}
static final + 불변 컬렉션 = 안전.
| Q | 핵심 답변 |
|---|---|
| 자바 변수 3종류는? | 지역(메서드 안) · 인스턴스(클래스, static X) · 클래스(static O) |
| 같은 클래스 객체 100개 만들면 각 변수 개수? | 인스턴스 변수 100개 · 클래스 변수 1개 |
| 매개변수는 어느 종류? | 지역 변수의 특수형 (호출자가 자동 초기화) |
| 지역 변수와 다른 변수의 결정적 차이? | 자동 초기화 안 됨 (수동 초기화 강제) |
| static 변수가 GC 대상이 아닌 이유? | Method Area에 클래스와 함께 살아있음. JVM 종료 시 함께 소멸 |
| Service에 static 변수가 거의 없는 이유? | Spring 싱글톤 + 멀티스레드 + 가변 static = race condition |
this.x와 x의 차이? | this.x = 인스턴스 변수, x = 가까운 지역/매개변수 |
| 변수 기본값? | 숫자 0, boolean false, 참조형 null. 지역은 기본값 없음 |
public static final 패턴은? | 상수 — 모두 공유 + 불변 + 멀티스레드 안전 |
| 인스턴스 변수가 멀티스레드에 안전한가? | 객체를 공유하면 위험. 객체별이라도 동기화 필요 |
public static final로 정의할 수 있다this 키워드를 적절히 쓸 수 있다final 인스턴스 변수 + 생성자 주입 패턴을 안다1. "어디에 선언했는가"가 모든 것을 결정한다
2. 초기화 · 개수 · 수명이 모두 다르다
3. ILIC 실무 원칙
public static finalfinal 인스턴스 변수 + 생성자 주입이번 Unit에서 "어디에 저장된다"고 한 부분을 메모리 다이어그램으로 정확히 추적한다.