원시 타입 (Primitive Types)
Java는 8가지 원시 타입을 제공한다. JLS Chapter 4에 정의되어 있다.
| 타입 | 크기 | 범위 | 기본값 |
|---|---|---|---|
byte |
1 byte | -128 ~ 127 | 0 |
short |
2 bytes | -32,768 ~ 32,767 | 0 |
int |
4 bytes | -2^31 ~ 2^31-1 | 0 |
long |
8 bytes | -2^63 ~ 2^63-1 | 0L |
float |
4 bytes | IEEE 754 | 0.0f |
double |
8 bytes | IEEE 754 | 0.0d |
char |
2 bytes | 0 ~ 65,535 (UTF-16) | '\u0000' |
boolean |
1 byte* | true/false | false |
boolean 크기: JLS는 boolean의 정확한 크기를 명시하지 않는다. JVM 구현상 배열에서는 1 byte, 스택에서는 32비트 슬롯 단위로 할당된다.
JVM 스택 슬롯 구조
JVM 스택은 슬롯(slot) 단위로 동작하며, 한 슬롯이 32비트다. boolean도 한 슬롯을 차지하지만, 실제로 32비트를 다 쓰는 건 아니다. JVM이 내부적으로 최적화할 수 있다.
JVM 스택 슬롯:
┌─────────────────────────────┐
│ boolean (1bit~1byte 사용) │ ← 32비트 슬롯 하나 차지
└─────────────────────────────┘Word Tearing 문제
CPU는 메모리를 바이트 단위가 아닌 word 단위(32비트 또는 64비트)로 읽고 쓴다. 만약 boolean을 1바이트로 저장하면, 인접한 4개의 boolean이 하나의 word에 들어간다.
메모리: [a][b][c][d] ← 4개의 boolean (각 1바이트)
└─────────┘
CPU는 이걸 한 덩어리로 읽음스레드 A가 a를 수정하고, 스레드 B가 b를 수정할 때:
- 스레드 A: word 전체 읽기 →
a변경 → word 전체 쓰기 - 스레드 B: word 전체 읽기 →
b변경 → word 전체 쓰기
두 스레드가 동시에 실행되면, 한쪽의 변경이 덮어씌워질 수 있다. 이게 word tearing(단어 찢어짐)이다.
배열은 왜 1바이트?: 배열은 대량 데이터를 저장하므로 메모리 효율이 더 중요하다.
대신 멀티스레드 환경에서 인접한 요소에 동시 접근하면 word tearing이 발생할 수 있다.
이런 경우 AtomicIntegerArray를 사용해야 한다.
기본값이 적용되는 경우 (JLS 4.12.5)
public class Example {
static int staticField; // 0으로 초기화
int instanceField; // 0으로 초기화
int[] array = new int[10]; // 모든 요소 0으로 초기화
void method() {
int localVar; // 초기화 안 됨 - 사용 시 컴파일 에러
// System.out.println(localVar); // 컴파일 에러
}
}기본값은 클래스 변수(static), 인스턴스 변수, 배열 요소에만 적용된다. 지역 변수는 명시적 초기화 없이 사용하면 컴파일러가 Definite Assignment 규칙으로 에러를 발생시킨다.
원시 타입 vs 참조 타입
메모리 저장 방식
int primitive = 42; // 스택에 4 bytes (값 직접 저장)
Integer wrapper = 42; // 스택에 참조(4-8 bytes) + 힙에 객체(16-28 bytes)| 구분 | 원시 타입 | 참조 타입 |
|---|---|---|
| 저장 위치 | 스택 (값 직접) | 스택 (참조) + 힙 (객체) |
| 메모리 | 타입 크기만큼 | 객체 헤더 + 값 + 패딩 |
| null 가능 | 불가능 | 가능 |
| 제네릭 사용 | 불가능 | 가능 |
박싱/언박싱 성능 문제
// Bad - 매 반복마다 박싱/언박싱 발생
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i; // 언박싱 → 연산 → 박싱
}
// Good - 원시 타입 사용
long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}성능 차이: 원시 타입이 래퍼 클래스보다 빠르다.
메모리 차이: Integer는 int 대비 약 7배 메모리 소비 (16-28 bytes vs 4 bytes).
Integer Cache (-128 ~ 127)
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true (같은 객체)
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false (다른 객체)JLS는 -128 ~ 127 범위의 autoboxing에서 캐시된 객체를 재사용하도록 명시한다.
JDK 구현 (java.lang.Integer):
private static class IntegerCache {
static final int low = -128;
static final int high; // 기본 127, 설정 가능
static final Integer[] cache;
static {
int h = 127;
String prop = VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (prop != null) {
int i = parseInt(prop);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1);
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
}
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}- autoboxing은 내부적으로
valueOf()를 호출하므로 캐시 혜택 new Integer(i)는 캐시를 사용하지 않음 (deprecated)- 상한값은 JVM 옵션으로 조정 가능:
-XX:AutoBoxCacheMax=<size>
접근 제어자 (Access Modifiers)
JLS 6.6에 정의된 4가지 접근 제어자.
| 접근 제어자 | 같은 클래스 | 같은 패키지 | 다른 패키지 서브클래스 | 모든 곳 |
|---|---|---|---|---|
private |
O | X | X | X |
| default | O | O | X | X |
protected |
O | O | O (제한적) | X |
public |
O | O | O | O |
default (package-private)
명시적 접근 제어자가 없는 경우. JLS에서는 package access라고 부른다.
// package com.example.p1
class DefaultClass { // package-private 클래스
int defaultField; // package-private 필드
void defaultMethod() {} // package-private 메서드
}
// package com.example.p2
class Other {
void test() {
DefaultClass obj = new DefaultClass(); // 컴파일 에러
}
}protected의 특수한 동작 (JLS 6.6.2)
다른 패키지의 서브클래스에서 protected 멤버에 접근할 때는 제한이 있다.
// package p1
public class Parent {
protected int x;
}
// package p2
public class Child extends Parent {
void test(Parent p, Child c, Sibling s) {
this.x = 10; // OK - this는 Child 타입
c.x = 10; // OK - Child 타입 참조
p.x = 10; // 컴파일 에러 - Parent 타입 참조
s.x = 10; // 컴파일 에러 - 형제 서브클래스
}
}
class Sibling extends Parent {}핵심: protected 멤버는 해당 객체의 구현에 책임이 있는 코드에서만 접근 가능하다. 서브클래스가 자신의 타입(또는 하위 타입) 참조를 통해서만 접근할 수 있다.
클래스 레벨 vs 멤버 레벨
최상위 클래스: public, default만 사용 가능
public class PublicClass {} // OK
class PackagePrivateClass {} // OK
private class PrivateClass {} // 컴파일 에러
protected class ProtectedClass {} // 컴파일 에러멤버 레벨(필드, 메서드, 내부 클래스): 4가지 모두 사용 가능
public class Outer {
private class PrivateInner {} // OK
protected class ProtectedInner {} // OK
}static 키워드
메모리 저장 위치
static 변수는 Method Area(Java 8 이후 Metaspace)에 저장되며, 모든 스레드가 공유한다. JVM 프로세스 생명주기 동안 유지된다.
초기화 순서 (JLS 12.4)
public class InitOrder {
// 1. final static 상수 (컴파일 타임 상수)
static final int CONSTANT = 100;
// 2. static 필드와 static 블록은 소스코드 순서대로
static int value1 = getValue("value1"); // 2번째
static {
System.out.println("static block 1"); // 3번째
}
static int value2 = getValue("value2"); // 4번째
static {
System.out.println("static block 2"); // 5번째
}
static int getValue(String name) {
System.out.println("Initializing " + name);
return 0;
}
}
// 출력:
// Initializing value1
// static block 1
// Initializing value2
// static block 2상속 계층에서의 초기화: Object -> 부모 클래스 -> 현재 클래스 순서
클래스 초기화 트리거 (Active Use)
Java는 클래스를 프로그램 시작 시 미리 다 올리지 않는다. 처음 사용할 때 로딩한다(Lazy Loading).
"객체 없이 static에 접근 가능하다"는 건 사용 방법이고, "클래스 로딩 시점"은 언제 메모리에 올라가느냐다. 접근하는 순간 클래스가 없으면 그때 올리고, 있으면 바로 접근한다.
아래 6가지 경우에 클래스가 메모리에 올라간다.
// 1. 새 인스턴스 생성
new MyClass();
// 2. static 메서드 호출
MyClass.staticMethod();
// 3. non-constant static 필드 접근 (아래 설명 참고)
MyClass.staticField;
// 4. 리플렉션 API
Class.forName("MyClass");
// 5. 하위 클래스 초기화
class Child extends Parent {} // Parent도 초기화
// 6. JVM 시작 시 초기 클래스
// java MainClass이때 static 블록도 실행된다.
왜 constant는 클래스 로딩을 트리거하지 않는가?
public class MyClass {
static final int CONSTANT = 100; // 컴파일 타임 상수
static int nonConstant = 100; // non-constant
// 이것도 non-constant -> 이건 실행해봐야 알기에 복사 안 됨.
static final int RUNTIME = getNumber();
}
// 다른 클래스에서
int x = MyClass.CONSTANT; // 클래스 로딩 안 됨 - 컴파일러가 100으로 대체
int y = MyClass.nonConstant; // 클래스 로딩됨 - 런타임에 값 필요static final + 리터럴 값은 컴파일러가 사용하는 곳에 값을 직접 복사한다. 런타임에 MyClass를 찾을 필요가 없다.
static 메서드에서 this를 사용할 수 없는 이유
public class Example {
int instanceField; // 객체가 있어야 존재
public static void staticMethod() {
// this.instanceField; // 컴파일 에러
}
}
// 호출 시점을 보면 이해됨
Example.staticMethod(); // 객체 없이 호출
// 이 시점에 instanceField는 어디에도 없음 -> this가 가리킬 대상이 없음this는 "현재 객체"를 가리킨다. 그런데 static 메서드는 객체 없이 호출할 수 있다. 객체가 없는데 "현재 객체"가 있을 수 없다.
Lazy Initialization Holder 패턴
JLS 12.4.2를 활용한 스레드 안전 싱글톤 패턴.
기존 Eager 방식의 문제
public class Singleton {
private static final Singleton INSTANCE = new Singleton(); // 클래스 로딩 시 바로 생성
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}Singleton 클래스가 로딩되는 순간 객체가 생성된다. getInstance()를 한 번도 안 불러도 메모리를 점유한다.
LazyHolder 방식
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE; // 이때 LazyHolder 초기화
}
}왜 중첩 클래스를 사용하는가?
중첩 클래스는 별도의 .class 파일로 컴파일된다.
Singleton.class // Singleton 클래스
Singleton$LazyHolder.class // LazyHolder 클래스 (별도 파일)JVM 입장에서 Singleton과 Singleton$LazyHolder는 완전히 다른 클래스다. Singleton이 로딩돼도 LazyHolder는 안 올라간다.
| 요소 | 로딩 시점 |
|---|---|
| static 필드 | 바깥 클래스 로딩 시 |
| static 블록 | 바깥 클래스 로딩 시 |
| 중첩 클래스 | 해당 클래스 첫 사용 시 |
왜 static 중첩 클래스여야 하는가?
public class Singleton {
private Singleton() {}
private class LazyHolder { // static 없음
final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
// LazyHolder를 만들려면 Singleton 인스턴스가 필요
// 근데 Singleton 인스턴스가 아직 없음 (싱글톤이니까)
// 컴파일 에러!
}
}non-static 내부 클래스는 바깥 객체가 먼저 있어야 생성 가능하다.
바깥 객체(Singleton)가 바로 우리가 막으려는 그 객체인데, 바깥 객체 없이 접근하려면 static이어야 한다.
| static 중첩 클래스 | non-static 내부 클래스 | |
|---|---|---|
| 바깥 인스턴스 필요 | X | O |
| static 메서드에서 접근 | O | X |
| 바깥 객체 참조 | 없음 | 숨겨진 Outer.this 있음 |
static vs non-static 중첩 클래스
웬만하면 static으로 만든다.
non-static 내부 클래스의 메모리 누수 문제
public class Outer {
private String data;
private class Inner {
// 숨겨진 Outer.this 참조를 갖고 있음
}
public Inner createInner() {
return new Inner();
}
}
// 문제 상황
Outer outer = new Outer();
Outer.Inner inner = outer.createInner();
outer = null; // Outer 해제하고 싶음
// 하지만 inner가 Outer.this를 들고 있어서 GC 안 됨 (메모리 누수)static을 기본으로 쓰는 이유
| static 중첩 클래스 | non-static 내부 클래스 | |
|---|---|---|
| 바깥 객체 참조 | 없음 | 숨겨진 참조 있음 |
| 메모리 누수 위험 | 없음 | 있음 |
| 인스턴스 크기 | 작음 | 참조 하나 더 있음 |
non-static을 쓰는 경우: 바깥 클래스의 인스턴스 필드에 자주 접근해야 할 때만
// non-static이 자연스러운 경우: Iterator
public class MyList<E> {
private E[] elements;
private class MyIterator implements Iterator<E> {
int cursor = 0;
public E next() {
return elements[cursor++]; // 바깥 필드에 직접 접근
}
}
}결론: 바깥 인스턴스가 꼭 필요한 게 아니면 static으로 만들어라 (Effective Java 권장).
클래스와 객체
개념 정의
| 개념 | 설명 | 비유 |
|---|---|---|
| 클래스 | 객체의 설계도, 타입 정의 | 붕어빵 틀 |
| 객체 | 클래스를 기반으로 생성된 실체 | 붕어빵 |
| 인스턴스 | 특정 클래스의 객체임을 강조 | "이 붕어빵은 팥 붕어빵 틀의 인스턴스" |
// 클래스 정의 (설계도)
public class Car {
String model;
int speed;
void accelerate() {
speed += 10;
}
}
// 객체 생성 (실체화)
Car myCar = new Car(); // myCar는 Car 클래스의 인스턴스메모리 관점
Car car1 = new Car();
Car car2 = new Car();- 클래스 메타데이터: Metaspace에 한 번만 로드
- 객체: Heap에 각각 생성 (car1, car2는 서로 다른 메모리 주소)
- 참조 변수: 스택에 저장 (Heap의 객체 주소를 가리킴)
오버라이딩과 오버로딩
오버로딩 (Overloading)
같은 이름, 다른 시그니처로 메서드를 정의.
public class Calculator {
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
}메서드 시그니처 : 메서드 이름 + 파라미터 타입
시그니처에 포함되지 않는 것:
- 반환 타입
- 예외 타입
- 접근 제어자
// 컴파일 에러 - 시그니처가 같음
int process(int x) { return x; }
double process(int x) { return x; } // 반환 타입만 다름바인딩 시점: 컴파일 타임 (정적 바인딩)
바인딩이란 메서드 호출 코드와 실제 실행될 메서드를 연결하는 것이다.
오버로딩은 파라미터 타입만 보면 어떤 메서드인지 알 수 있다. 실행 전에 결정 가능하다.
calc.add(1, 2); // 컴파일러: "int, int니까 add(int, int) 호출"
calc.add(1.0, 2.0); // 컴파일러: "double, double이니까 add(double, double) 호출"오버라이딩 (Overriding) - JLS 8.4.8
상위 클래스의 메서드를 하위 클래스에서 재정의.
class Animal {
void sound() { System.out.println("..."); }
}
class Dog extends Animal {
@Override
void sound() { System.out.println("Bark"); }
}바인딩 시점: 런타임 (동적 바인딩)
오버라이딩은 다형성 때문에 컴파일 타임에 결정할 수 없다. 실제 객체가 뭔지 런타임에 봐야 안다.
Animal animal = new Dog(); // 변수 타입: Animal, 실제 객체: Dog
animal.sound(); // 뭐가 실행될까?
// 컴파일러: "Animal.sound() 호출하겠다" (변수 타입만 봄)
// 런타임: "어? 실제로는 Dog 객체네. Dog.sound() 실행" (실제 객체 봄)| 오버로딩 | 오버라이딩 | |
|---|---|---|
| 결정 시점 | 컴파일 타임 | 런타임 |
| 결정 기준 | 파라미터 타입 | 실제 객체 타입 |
| 바인딩 | 정적 | 동적 |
오버라이딩 규칙
1. 공변 반환 타입(JLS 8.4.5)
Java 5부터 오버라이딩 메서드가 더 구체적인 반환 타입을 가질 수 있다.
class Animal {
Animal create() { return new Animal(); }
}
class Dog extends Animal {
@Override
Dog create() { return new Dog(); } // OK - Dog은 Animal의 하위 타입
}2. 접근 제어자
오버라이딩 메서드는 더 넓은 접근 범위를 가져야 한다.
class Parent {
protected void method() {}
}
class Child extends Parent {
@Override
public void method() {} // OK - public > protected
// @Override
// private void method() {} // 컴파일 에러 - private < protected
}3. 예외 처리(JLS 11)
오버라이딩 메서드는 같거나 더 좁은 예외만 던질 수 있다.
class Parent {
void method() throws IOException {}
}
class Child extends Parent {
@Override
void method() throws FileNotFoundException {} // OK - 하위 예외
// @Override
// void method() throws Exception {} // 컴파일 에러 - 상위 예외
}@Override 애노테이션 (JLS 9.6.4.4)
class Child extends Parent {
@Override
public void metohd() {} // 컴파일 에러 - 오타, 실제로 오버라이드 아님
}컴파일러가 실제로 오버라이드하는지 검증한다. 항상 사용하는 것을 권장.
동적 바인딩 vs 정적 바인딩
class Animal {
void sound() { System.out.println("Animal"); }
static void info() { System.out.println("Animal info"); }
}
class Dog extends Animal {
@Override
void sound() { System.out.println("Bark"); }
static void info() { System.out.println("Dog info"); }
}
Animal animal = new Dog();
animal.sound(); // "Bark" - 동적 바인딩 (런타임에 Dog.sound() 호출)
animal.info(); // "Animal info" - 정적 바인딩 (컴파일 타임에 Animal.info() 결정)| 구분 | 동적 바인딩 | 정적 바인딩 |
|---|---|---|
| 결정 시점 | 런타임 | 컴파일 타임 |
| 기준 | 실제 객체 타입 | 참조 변수 타입 |
| 적용 대상 | 인스턴스 메서드 | static, private, final 메서드 |
왜 static 메서드는 정적 바인딩인가?
static 메서드는 객체가 아니라 클래스에 속한다. 오버라이딩은 객체 기반 다형성인데, static은 객체를 안 보니까 다형성이 적용될 여지가 없다.
Animal animal = new Dog();
animal.info(); // 컴파일러: "animal의 타입이 Animal이네. Animal.info() 호출"
// 실제 객체가 Dog인지 안 봄자식 클래스에서 같은 이름의 static 메서드를 만들면 그건 hiding(숨김)이다. 부모 메서드를 "대체"하는 게 아니라 "가리는" 것이다.
class Dog extends Animal {
@Override // 컴파일 에러! static 메서드는 오버라이드 불가
static void info() { System.out.println("Dog info"); }
}| 오버라이딩 | 하이딩 | |
|---|---|---|
| 대상 | 인스턴스 메서드 | static 메서드 |
| 동작 | 부모 메서드 대체 | 부모 메서드 숨김 |
| 바인딩 | 동적 (런타임) | 정적 (컴파일 타임) |
인터페이스와 추상클래스
인터페이스 암묵적 특성 (JLS 9.3, 9.4)
public interface MyInterface {
// 필드: 암묵적으로 public static final
int CONSTANT = 100;
// == public static final int CONSTANT = 100;
// 메서드: 암묵적으로 public abstract
void method();
// == public abstract void method();
}왜 암묵적으로 public static final인가?
| 키워드 | 이유 |
|---|---|
| public | 인터페이스는 외부에 공개하는 계약이다. private이면 의미가 없다. |
| static | 인터페이스는 인스턴스를 만들 수 없다. 인스턴스가 없으니 인스턴스 필드도 없다. |
| final | 인터페이스는 계약이다. 구현체마다 값이 달라지면 계약이 깨진다. |
인터페이스 메서드 제약:
protected,package-private불가final,synchronized,native불가 (구현 특성이므로)abstract,default,static중 두 개 이상 동시 사용 불가
Java 버전별 인터페이스 발전
| Java 버전 | 추가 기능 |
|---|---|
| Java 7 이전 | 상수 + 추상 메서드만 |
| Java 8 | default 메서드, static 메서드 |
| Java 9 | private 메서드 |
default 메서드 (Java 8)
기존 인터페이스에 새 메서드를 추가해도 구현체가 깨지지 않도록 도입.
왜 필요한가?
Java 8 이전에는 인터페이스에 메서드를 추가하면 모든 구현 클래스가 깨졌다.
// Java 7 시절
public interface List<E> {
void add(E e);
E get(int index);
// ... 기존 메서드들
}
// 구현 클래스가 100개 있다고 가정
class MyList implements List<String> { ... }
class YourList implements List<Integer> { ... }
// ... 98개 더여기에 stream() 메서드를 추가하고 싶다면?
public interface List<E> {
void add(E e);
E get(int index);
Stream<E> stream(); // 새로 추가
}이 순간 100개의 구현 클래스가 전부 컴파일 에러가 난다. 모든 클래스에 stream() 구현을 추가해야 한다.
default 메서드는 이 문제를 해결한다.
public interface List<E> {
void add(E e);
E get(int index);
default Stream<E> stream() {
// 기본 구현 제공
return StreamSupport.stream(spliterator(), false);
}
}기본 구현이 있으니 기존 100개 클래스는 아무것도 안 해도 된다. 필요하면 오버라이드하면 된다.
다이아몬드 문제 해결
interface A { default void m() { System.out.println("A"); } }
interface B { default void m() { System.out.println("B"); } }
class C implements A, B {
@Override
public void m() {
A.super.m(); // A 인터페이스로부터 상속받은 m() 호출
B.super.m(); // B 인터페이스로부터 상속받은 m() 호출
}
}A.super 문법
A.super는 "A의 부모"가 아니라 A라는 경로를 통해 접근하는 super다.
// 클래스 상속: 부모가 하나라 super만 쓰면 됨
class Child extends Parent {
void m() { super.m(); } // 부모는 하나
}
// 인터페이스 다중 구현: super가 여러 개라 구분 필요
class C implements A, B {
void m() {
super.m(); // 컴파일 에러! A의 m()? B의 m()?
A.super.m(); // A로부터 상속받은 m()
B.super.m(); // B로부터 상속받은 m()
}
}이 문법은 JDK나 Spring 같은 라이브러리에서 거의 사용되지 않는다. 라이브러리 설계자들이 다이아몬드 문제가 생기는 상황 자체를 피하기 때문이다. 주로 면접 질문이나 개념 설명에서 등장한다.
충돌 해결 규칙 (우선순위순):
- 클래스가 인터페이스보다 우선
- 하위 인터페이스가 상위 인터페이스보다 우선
- 충돌 시 명시적 오버라이드 필수 (컴파일 에러)
private 메서드 (Java 9)
default 메서드 간 코드 재사용을 위해 도입.
interface Calculator {
default int add(int a, int b) {
return calculate(a, b, (x, y) -> x + y);
}
default int multiply(int a, int b) {
return calculate(a, b, (x, y) -> x * y);
}
// 인터페이스 내부에서만 사용
private int calculate(int a, int b, BiFunction<Integer, Integer, Integer> op) {
return op.apply(a, b);
}
}추상클래스 vs 인터페이스
| 특성 | 추상클래스 | 인터페이스 |
|---|---|---|
| 관계 | is-a (A는 B이다) | can-do (A는 ~할 수 있다) |
| 상속 | 단일 상속 | 다중 구현 |
| 생성자 | 가능 | 불가능 |
| 필드 | 모든 접근 제어자 | public static final만 |
| 설계 목적 | 공통 구현 + 정체성 정의 | 능력/계약 정의 |
언제 무엇을 사용할까?
추상클래스:
- 관련 클래스들의 공통 베이스를 정의할 때
- 상태(필드)와 구현을 공유해야 할 때
- 예:
HttpServlet,AbstractList
인터페이스:
- 클래스의 능력(capability)을 정의할 때
- 다중 구현이 필요할 때
- 느슨한 결합이 필요할 때
- 예:
Comparable,Serializable,Runnable
JDK 모범 사례: 골격 구현 패턴 (Effective Java Item 20)
Skeletal Implementation 패턴은 인터페이스와 추상 클래스의 장점을 결합한 설계다.
인터페이스 (List)
│ "get(), add(), size() 있어야 해" (계약)
▼
추상 클래스 (AbstractList)
│ "isEmpty()는 size() == 0이면 돼" (공통 구현)
│ "get()은 구현체마다 다르니까 abstract로 남김" (원시 메서드)
▼
구체 클래스 (ArrayList, LinkedList)
└ "get()은 이렇게 구현할게" (핵심 구현)원시 메서드 vs 파생 메서드
추상 클래스에서 뭘 구현하고 뭘 남기는가가 핵심이다.
public abstract class AbstractList<E> implements List<E> {
// 원시 메서드: 구현체마다 다름 → abstract로 남김
abstract public E get(int index);
abstract public int size();
// 파생 메서드: 원시 메서드 조합으로 구현 가능 → 여기서 구현
public boolean isEmpty() {
return size() == 0;
}
public boolean contains(Object o) {
for (int i = 0; i < size(); i++) {
if (Objects.equals(get(i), o)) return true;
}
return false;
}
public int indexOf(Object o) {
for (int i = 0; i < size(); i++) {
if (Objects.equals(get(i), o)) return i;
}
return -1;
}
}ArrayList는 get()과 size()만 구현하면 나머지가 자동으로 동작한다:
public class ArrayList<E> extends AbstractList<E> {
private Object[] elementData;
@Override
public E get(int index) {
return (E) elementData[index]; // 핵심만 구현
}
@Override
public int size() {
return size;
}
// isEmpty(), indexOf(), contains()는 AbstractList 거 그대로 씀
}왜 Java 8 이후에도 추상 클래스가 필요한가?
default 메서드로 다 해결되지 않는다.
| 인터페이스에서 못하는 것 | 추상 클래스에서 가능 |
|---|---|
| 인스턴스 필드 | O |
equals(), hashCode(), toString() 오버라이드 |
O |
| non-public 멤버 | O |
| 생성자 로직 | O |
public abstract class AbstractSet<E> implements Set<E> {
// 인터페이스에서는 equals() 오버라이드 불가
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Set)) return false;
// Set 비교 로직...
}
@Override
public int hashCode() {
int h = 0;
for (E e : this) h += Objects.hashCode(e);
return h;
}
}왜 equals()와 hashCode()를 오버라이드하는가?
Object가 기본 제공하는 equals()는 메모리 주소만 비교한다.
// Object.equals() 실제 구현
public boolean equals(Object obj) {
return (this == obj); // 참조 비교
}이 기본 구현은 내용이 같아도 new로 따로 만들면 다르다고 판단한다.
class User {
String id;
User(String id) { this.id = id; }
}
User user1 = new User("john");
User user2 = new User("john"); // 같은 id
user1.equals(user2); // false! 다른 메모리 주소
// HashSet에 넣으면?
Set<User> set = new HashSet<>();
set.add(user1);
set.add(user2);
set.size(); // 2! 논리적으로 같은 유저인데 두 개로 취급내용이 같으면 같다고 하려면 오버라이드해야 한다.
class User {
String id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
return id.equals(((User) o).id); // id가 같으면 같은 유저
}
@Override
public int hashCode() {
return id.hashCode(); // id 기반 해시
}
}
User user1 = new User("john");
User user2 = new User("john");
user1.equals(user2); // true! id가 같으니까
Set<User> set = new HashSet<>();
set.add(user1);
set.add(user2);
set.size(); // 1! 같은 유저로 취급| Object 기본 | 오버라이드 후 | |
|---|---|---|
| 비교 기준 | 메모리 주소 (동일성) | 내용 (동등성) |
| new User("john") == new User("john") | false | true |
| HashMap/HashSet 동작 | 별개 객체로 취급 | 같은 키로 취급 |
왜 인터페이스에서 equals()를 오버라이드할 수 없는가?
"클래스가 인터페이스보다 우선" 규칙 때문이다.
interface MyInterface {
default boolean equals(Object o) { // 컴파일 에러!
return true;
}
}모든 클래스는 Object를 상속하므로 Object.equals()가 항상 존재한다. 만약 인터페이스에 default equals()를 정의할 수 있다면 어떻게 될까?
class MyClass implements MyInterface {
// Object.equals()가 있음
// MyInterface.equals()도 있음 (가정)
}
MyClass obj = new MyClass();
obj.equals(other); // 뭘 호출해야 하지?Java의 클래스가 인터페이스보다 우선 규칙에 의해 항상 Object.equals()가 호출된다. 인터페이스의 default equals()는 절대 호출될 수 없다.
호출될 수 없는 메서드를 정의하게 허용하는 건 혼란만 야기하므로, Java는 아예 컴파일 에러로 막아버렸다.
정리
| 개념 | 핵심 |
|---|---|
| 원시 타입 | 8가지, 스택에 값 직접 저장, 박싱 오버헤드 주의 |
| 참조 타입 | 힙에 객체, 스택에 참조, null 가능 |
| 접근 제어자 | private < default < protected < public |
| static | 클래스 레벨, 인스턴스 없이 접근, Heap에 저장 (Java 8+) |
| 오버로딩 | 같은 이름, 다른 시그니처, 컴파일 타임 결정 |
| 오버라이딩 | 상위 메서드 재정의, 런타임 결정 (동적 바인딩) |
| 인터페이스 | 계약 정의, 다중 구현, can-do 관계 |
| 추상클래스 | 공통 구현 + 정체성, 단일 상속, is-a 관계 |
댓글