본문 바로가기
개발 언어/JAVA

이펙티브 자바 9장: 좋은 코드를 위한 일반적인 프로그래밍 원칙 (Items 57~68)

by 코헤0121 2025. 12. 17.
728x90
반응형

1. 변수 관리 및 제어 구조 최적화

아이템 57. 지역 변수의 범위를 최소화하라

지역 변수의 유효 범위(scope)를 최소화하면 코드의 가독성과 유지보수성이 높아지고 오류 발생 가능성이 낮아집니다.

가장 강력한 기법은 변수를 가장 처음 사용하는 시점에 선언과 동시에 초기화하는 것입니다. 반복문에서는 루프 변수의 범위가 반복문 내부로 제한되는 for 문이나 for-each 문을 사용하는 것이 좋습니다.

아이템 58. 전통적인 for 문보다는 for-each 문을 사용하라

배열이나 컬렉션을 순회할 때는 전통적인 for 문(Iterator를 사용하는 형태) 대신 for-each(enhanced for statement)을 사용해야 합니다. for-each 문은 코드가 명료하고, 유연하며, 반복자(Iterator)나 인덱스 변수를 명시적으로 사용하지 않아 버그를 예방할 수 있습니다.

단, 컬렉션을 순회하면서 요소를 제거하는 파괴적 필터링, 요소의 값을 변경하는 변형, 또는 병렬 반복이 필요한 세 가지 상황에서는 for-each 문을 사용할 수 없으므로, 전통적인 for 문이나 Iterator를 사용해야 합니다.

2. 라이브러리 및 타입 사용에 대한 조언

아이템 59. 라이브러리를 익히고 사용하라

아주 특별한 기능이 아니라면, 필요한 기능이 이미 표준 라이브러리에 구현되어 있을 가능성이 높습니다. 표준 라이브러리를 사용하면 코드의 품질, 성능, 유지보수성이 향상되며, 전문가들의 경험을 활용할 수 있습니다. 또한, 라이브러리 제조사가 지속적으로 개선하고 버그를 수정해 주기 때문에 직접 구현하는 것보다 훨씬 안전합니다.

아이템 60. 정확한 답이 필요하다면 float와 double은 피하라

floatdouble은 과학 및 공학 계산에 적합하며 정확한 금융 계산 등에는 사용해서는 안 됩니다. 0.1과 같은 값을 정확하게 표현할 수 없기 때문에 예상치 못한 오류가 발생할 수 있습니다.

정확한 계산이 필요하다면 BigDecimal을 사용해야 합니다. 다만, BigDecimal은 사용하기 불편하고 성능이 느리다는 단점이 있습니다. 숫자가 크지 않다면 소수점을 정수로 관리하는 방식(예: 모든 금액을 센트 단위의 intlong으로 표현)을 사용하는 것이 대안이 될 수 있습니다.

아이템 61. 박싱된 기본 타입보다는 기본 타입을 사용하라

기본 타입(int, boolean 등)과 이에 대응하는 박싱된 기본 타입(Integer, Boolean 등) 중에서는 기본 타입을 사용하는 것이 더 빠르고 간결합니다. 박싱된 기본 타입은 다음과 같은 문제를 일으킬 수 있습니다:

  1. 식별성 비교 오류: == 연산자로 값을 비교할 때 객체 식별성(identity)을 비교하여 예상치 못한 결과가 나올 수 있습니다.
  2. NullPointerException: 박싱된 타입이 null일 경우 언박싱 과정에서 NullPointerException이 발생할 위험이 있습니다.
  1. 성능 저하: 불필요한 박싱과 언박싱 과정이 반복되어 성능에 영향을 줄 수 있습니다.
    => 자동 박싱/언박싱이 반복되면 불필요한 객체 생성과 메서드 호출이 발생 sum += i 의 간단 연산이면 그냥 int를 쓰셔요

-> 여기에 좀 더 첨언 하자면 여러 측면이 있어요

4. 메모리 오버헤드 (int 는 4byte지만 Integer은 16bytes (객체헤더가 붙음))
5. 캐싱의 함정이 있심
-128 ~ 127 범위의 Integer는 캐싱되지만, 이 범위 밖에서는 동작이 달라요.

  • Integer.valueOf()는 IntegerCache 사용 (JLS 명세- Java Language Specification(자바 언어 명세서))
  • 캐싱 범위는 JVM 옵션으로 변경 가능하지만 예측 불가능, 이런 불일치는 버그의 원인

단, 컬렉션의 원소, 키, 값 또는 제네릭 타입 매개변수로는 기본 타입을 사용할 수 없으므로 박싱된 타입을 사용해야 합니다.

제네릭 타입 매개변수로는 기본 타입을 사용할 수 없는 이유

  • 현재 문제: 타입 소거 → 모든 제네릭이 Object → 원시 타입 불가
  • Valhalla 해결책: 컴파일 타임에 List, List 등 각 타입별로 특수화된 버전 생성
  • 결과: 타입 소거 우회 → 원시 타입도 제네릭 사용 가능!
// 미래 (Valhalla)
List<int> numbers = new ArrayList<>();  // 가능!
// 내부적으로 int[]로 직접 처리, 박싱 없음

왜 지금 안 돼?

  • 각 타입마다 별도 클래스 파일 생성 → 클래스 폭발
  • 하위 호환성 유지 필요
  • JVM 내부 구조 대대적 수정 필요

=> 스트림의 IntStream, LongStream이 이미 수동으로 특수화한 예시예요. Valhalla는 이걸 자동화해용

아이템 62. 다른 타입이 적절하다면 문자열 사용을 피하라

문자열(String)은 텍스트를 표현하는 용도로만 사용해야 합니다.

문자열이 부적절한 경우

1. 기본 타입, 열거 타입을 대신할 때

숫자나 boolean을 문자열로 받으면 타입 안전성이 없어요.

// 나쁜 예
String age = "25";
String isActive = "true";

// 좋은 예  
int age = 25;
boolean isActive = true;

컴파일 타임 타입 체크 불가, 형변환 필요, 오류 가능성 증가

2. 혼합된 데이터를 표현할 때

구분자로 데이터를 묶으면 파싱이 복잡하고 오류가 발생하기 쉬워요.

// 나쁜 예
String compoundKey = "className#123";  // 파싱 필요, 구분자 포함 시 문제

// 좋은 예
class CompoundKey {
    private final String className;
    private final int id;
}

느림, 가독성 낮음, 필드 접근 불편, 오류 가능성 높음

3. 권한을 표현할 때

문자열로 권한을 관리하면 보안 문제가 발생해요.

// 나쁜 예
public class ThreadLocal {
    private ThreadLocal() { }
    public static void set(String key, Object value);  // 키 충돌 가능
}

// 좋은 예
public final class ThreadLocal<T> {
    public ThreadLocal();
    public void set(T value);  // 인스턴스 자체가 키
}

네임스페이스 공유로 키 충돌 가능, 보안 취약

4. 문자열을 식별자로 사용할 때

Map 키나 상수로 문자열 사용 시 오타에 취약해요.

// 나쁜 예
Map<String, Object> registry = new HashMap<>();
registry.put("user.name", "Alice");  // 오타 위험

// 좋은 예
enum RegistryKey { USER_NAME, USER_AGE }
Map<RegistryKey, Object> registry = new EnumMap<>(RegistryKey.class);
registry.put(RegistryKey.USER_NAME, "Alice");

오타 위험, 타입 안전성 없음, IDE 지원 제한

아이템 63. 문자열 연결은 느리니 주의하라

문자열 연결 연산자(+)의 반복 사용은 성능 저하를 일으킵니다.

성능 문제

String은 불변 객체라서 연결할 때마다 새 객체를 생성해요.

// 나쁜 예 - O(n²)
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;  // 매번 새 String 생성 + 기존 내용 복사
}

// 좋은 예 - O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);  // 내부 버퍼에 추가만
}
String result = sb.toString();
  • String 연결 시 new String(기존 + 새로운) 반복 → n개 연결 시 O(n²)
  • StringBuilder는 가변 char 배열 사용 → O(n)

StringBuilder vs StringBuffer

     
특징 StringBuilder StringBuffer
동기화
성능 빠름 느림 (동기화 오버헤드)
스레드 안전
권장 상황 단일 스레드 (대부분) 멀티 스레드

동기화가 필요 없는 대부분의 경우 StringBuilder 사용

String Interning (추가 팁)

String Pool을 활용해 같은 문자열은 하나의 객체만 사용해요.

String s1 = "hello";              // 리터럴 → String Pool
String s2 = "hello";              // 같은 객체 재사용
String s3 = new String("hello");  // 힙에 새 객체
String s4 = s3.intern();          // Pool에서 찾거나 추가

System.out.println(s1 == s2);  // true (같은 객체)
System.out.println(s1 == s3);  // false (다른 객체)
System.out.println(s1 == s4);  // true (Pool의 같은 객체)

리터럴 문자열은 자동 인턴되어 메모리 절약, 명시적 intern() 호출도 가능

3. 설계, 리플렉션 및 성능 최적화

아이템 64. 객체는 인터페이스를 사용해 참조하라

이 원칙은 프로그램의 유연성을 극대화하는 데 핵심적인 역할을 합니다.

  • 원칙: 매개변수, 반환값, 지역 변수, 그리고 필드를 선언할 때 구현 클래스가 아닌 적절한 상위 인터페이스를 타입으로 사용해야 합니다.
  • 예시: LinkedList와 같은 구체적인 구현 클래스 대신, List와 같은 상위 인터페이스를 타입으로 사용해야 합니다. 예를 들어, Set sonSet = new LinkedHashSet<>();과 같이 인터페이스를 타입으로 선언하는 것이 올바른 방법입니다.
  • 장점: 객체를 인터페이스 타입으로 참조하면, 나중에 구현 클래스를 쉽게 교체할 수 있습니다. 주변 코드는 인터페이스의 존재만 알기 때문에, 구현 클래스가 변경되더라도 해당 코드에는 아무 영향이 없습니다.
  • 예외: 적절한 인터페이스가 없는 경우(예: String, BigInteger와 같은 값 클래스 또는 프레임워크가 제공하는 클래스)에는 구현 클래스로 참조해야 합니다. 또한, 인터페이스의 계층 구조 중 가장 구체적인 (상위) 클래스를 타입으로 사용하여 필요한 기능을 모두 만족시켜야 합니다.
  1. 의존성 역전 원칙(Dependency Inversion Principle)과의 관계

    인터페이스 사용은 SOLID의 DIP를 실현하는 방법이에요.
    고수준 모듈(비즈니스 로직)이 저수준 모듈(구현체)에 의존하지 않음
    둘 다 추상화(인터페이스)에 의존하여 결합도 감소, 테스트 용이성 증가
  2. 리스코프 치환 원칙(Liskov Substitution Principle) 위반 위험

    모든 구현체가 동일하게 동작한다고 보장할 수 없어요.
    List.add()는 UnsupportedOperationException 던질 수 있음 (Collections.unmodifiableList)
    인터페이스 계약만으로는 동작 보장 안 됨
    구현체별 문서 확인 필요
  3. 인터페이스 분리 원칙(Interface Segregation Principle)

    너무 넓은 인터페이스는 오히려 유연성을 해칠 수 있어요.
    Collection vs List vs Set - 필요한 만큼만 요구
    클라이언트가 사용하지 않는 메서드에 의존하지 않도록
    가장 좁은(구체적인) 인터페이스가 아니라 필요한 기능을 제공하는 가장 일반적인 인터페이스 선택
  4. 타입 추론과 다이아몬드 연산자의 한계

    인터페이스 타입 선언 시 우측의 타입 추론이 제한될 수 있어요.
    var와 함께 사용 시 구체 타입 노출
    제네릭 메서드 체이닝 시 타입 추론 실패 가능
    명시적 타입 선언 vs 타입 추론 트레이드오프
  5. 직렬화(Serialization)와 인터페이스

    인터페이스는 직렬화할 수 없어요.
    Serializable은 마커 인터페이스지만 실제 직렬화는 구체 클래스 정보 필요
    역직렬화 시 구체 타입 정보 필요
    DTO/VO에서는 구체 클래스 사용 불가피할 수 있음
  6. 성능 - 인터페이스 호출 vs 구체 클래스 호출

    인터페이스 메서드 호출은 가상 메서드 테이블(vtable) 조회가 필요해요.
    구체 클래스 직접 호출: 정적 바인딩 가능
    인터페이스 호출: 런타임 동적 바인딩 (invokeinterface)
    JIT 컴파일러가 대부분 최적화하지만, 극한 성능 상황에서는 차이 존재
    Megamorphic call site 문제
더보기

성능 - 인터페이스 호출 vs 구체 클래스 호출

 

인터페이스 호출 vs 구체 클래스 호출 성능 분석

1. 메서드 호출의 종류

설명: JVM은 메서드 호출을 4가지 바이트코드로 구분해요.

종류:

  • invokestatic: 정적 메서드 (컴파일 타임 결정)
  • invokespecial: 생성자, private, super (컴파일 타임 결정)
  • invokevirtual: 일반 인스턴스 메서드 (런타임 결정)
  • invokeinterface: 인터페이스 메서드 (런타임 결정)

근거:

  • 앞 2개는 정적 바인딩 - 어떤 메서드 호출할지 컴파일 시 확정
  • 뒤 2개는 동적 바인딩 - 실제 객체 타입에 따라 런타임 결정

2. 가상 메서드 테이블(vtable)

설명: 객체의 실제 메서드를 찾기 위한 룩업 테이블이에요.

동작 방식:

객체 메모리 구조:
[Object Header]
[vtable pointer] ← 이 포인터가 vtable을 가리킴
[필드 데이터...]

vtable:
[0] toString()
[1] equals()
[2] myMethod()  ← 오프셋으로 빠른 접근
[3] ...

근거:

  • 각 클래스는 자신의 vtable 보유
  • 메서드 호출 시: 객체 → vtable 포인터 → vtable[오프셋] → 메서드
  • 오프셋은 컴파일 타임에 결정되어 빠름

3. invokevirtual vs invokeinterface 차이

설명: 인터페이스 호출이 더 느린 이유가 있어요.

invokevirtual (구체 클래스)

List<String> list = new ArrayList<>();
int size = list.size();  // invokevirtual

// JVM 내부 동작:
// 1. list 객체의 vtable 포인터 조회
// 2. vtable의 고정된 오프셋(예: [5])에서 size() 메서드 찾기
// 3. 메서드 호출
// → O(1) 상수 시간

invokeinterface (인터페이스)

List<String> list = new ArrayList<>();
int size = list.size();  // invokeinterface

// JVM 내부 동작:
// 1. list 객체의 클래스 확인 (ArrayList)
// 2. ArrayList가 구현한 인터페이스들 중 List 찾기
// 3. List.size() 메서드의 실제 구현 찾기 (선형 탐색 가능)
// 4. 메서드 호출
// → O(n) 하지만 캐싱으로 완화

근거:

  • 인터페이스는 여러 클래스가 구현 → vtable 오프셋이 클래스마다 다름
  • ArrayList.size()는 vtable[5], LinkedList.size()는 vtable[8]일 수 있음
  • 추가 조회 단계 필요

4. Call Site의 종류

설명: 메서드 호출 지점의 "다형성 정도"가 성능에 영향을 줘요.

Monomorphic (단형성)

// 항상 ArrayList만 들어옴
public void process(List<String> list) {
    list.add("item");  // 항상 ArrayList.add() 호출
}

최적화: JIT가 "이건 항상 ArrayList구나!" 학습 → 직접 호출로 인라인

Bimorphic (이형성)

// ArrayList 또는 LinkedList
public void process(List<String> list) {
    list.add("item");  // 두 가지 중 하나
}

최적화: JIT가 if-else로 분기 생성 (빠름)

Megamorphic (다형성 폭발)

// ArrayList, LinkedList, Vector, CopyOnWriteArrayList...
public void process(List<String> list) {
    list.add("item");  // 타입이 너무 많음!
}

최적화 실패: JIT가 포기 → vtable 조회로 후퇴

근거:

  • Monomorphic: 95%+ 한 타입 → 완전 최적화
  • Bimorphic: 2개 타입 → 분기 예측으로 최적화
  • Megamorphic: 3개+ 타입 → 최적화 포기 (느림)

5. JIT 컴파일러 최적화

설명: HotSpot JIT는 런타임 프로파일링으로 최적화해요.

인라인화 (Inlining)

// 소스 코드
List<String> list = new ArrayList<>();
list.add("item");

// JIT 최적화 후 (개념적)
ArrayList<String> list = new ArrayList<>();
// list.add("item"); ← 이게
list.ensureCapacity();
list.elementData[list.size++] = "item";  // 이렇게 인라인됨

근거:

  • 메서드 호출 오버헤드 제거
  • 추가 최적화 기회 (상수 전파, 죽은 코드 제거)
  • Monomorphic call site만 가능

탈최적화 (Deoptimization)

// 처음엔 ArrayList만 봄 → 인라인 최적화
List<String> list = new ArrayList<>();
// ...

// 나중에 LinkedList 들어옴
list = new LinkedList<>();  // 🔥 가정 깨짐!

근거:

  • JIT의 "투기적 최적화" 실패 시 원래 코드로 되돌림
  • 성능 저하 발생 (재컴파일 필요)

6. 실제 성능 영향

측정 결과: (JMH 벤치마크 기준)

Monomorphic invokevirtual:   1.2 ns/op
Monomorphic invokeinterface: 1.3 ns/op  (+8%)
Bimorphic invokeinterface:   2.1 ns/op  (+75%)
Megamorphic invokeinterface: 5.8 ns/op  (+383%)

근거:

  • 일반적 상황: 차이 거의 없음 (JIT 최적화)
  • Megamorphic: 극적인 성능 저하
  • 나노초 단위라 대부분 무시 가능

7. 언제 문제가 되나?

문제가 되는 경우

  • 타이트한 루프에서 수백만 번 호출
  • 실시간 시스템 (레이턴시 중요)
  • 많은 구현체가 섞여 사용되는 핫스팟

문제 안 되는 경우

  • 일반 비즈니스 로직 (I/O가 병목)
  • 호출 빈도 낮음
  • Monomorphic 패턴

근거:

  • 프레임워크/라이브러리 개발자는 고려 필요
  • 애플리케이션 개발자는 거의 무시 가능

8. 최적화 전략

전략 1: 타입 예측 가능하게

// 나쁜 예
List<String> getRandom() {
    return random.nextBoolean() ? 
        new ArrayList<>() : new LinkedList<>();  // Bimorphic
}

// 좋은 예
List<String> getList() {
    return new ArrayList<>();  // Monomorphic
}

전략 2: 인터페이스 범위 좁히기

// 넓은 인터페이스
Collection<String> items;  // Set, List, Queue 모두 가능 → Megamorphic

// 좁은 인터페이스
List<String> items;  // ArrayList, LinkedList → Bimorphic

전략 3: final 클래스 사용

// 인터페이스 대신 final 클래스
public final class ImmutableList<E> {
    // 하위 클래스 없음 → 완벽한 최적화
}

근거:

  • 컴파일러/JIT가 더 공격적으로 최적화
  • 하지만 유연성 손실

9. 바이트코드 비교

// 코드
List<String> list = new ArrayList<>();
list.size();

// 바이트코드
NEW java/util/ArrayList
INVOKESPECIAL ArrayList.<init>
ASTORE 1

ALOAD 1
INVOKEINTERFACE List.size()I  ← 여기가 핵심

근거:

  • INVOKEINTERFACE는 추가 메타데이터 필요
  • 바이트코드 크기도 약간 더 큼

10. 결론: 실무 가이드

원칙:

  • 기본: 인터페이스 사용 (유연성 >> 성능)
  • 극한 최적화 필요 시만 구체 클래스 고려
  • 프로파일링 먼저, 최적화는 나중

예외:

  • 게임 엔진 핵심 루프
  • 고빈도 트레이딩 시스템
  • 실시간 임베디드 시스템

근거:

  • 99%의 경우 JIT가 차이를 없앰
  • 조기 최적화는 악의 근원
  • 가독성/유지보수성이 보통 더 중요

아이템 65. 리플렉션보다는 인터페이스를 사용하라

리플렉션(java.lang.reflect)은 강력한 기능이지만, 심각한 단점 때문에 신중하게 사용해야 합니다.

  • 리플렉션의 위험성:
    1. 컴파일타임 타입 검사 이점 상실: 리플렉션을 사용하면 컴파일타임에 타입 검사의 이점을 누릴 수 없게 되며, 예외 검사도 마찬가지입니다.
    2. 코드의 복잡성과 장황함: 리플렉션 코드는 일반 코드보다 지저분하고 장황해지며, 읽고 이해하기 어렵습니다.
    3. 성능 저하: 리플렉션을 통한 메서드 호출은 일반 메서드 호출보다 훨씬 느립니다.
  • 권장 사용법: 리플렉션이 필요하다면, 객체 생성 용도로만 최소한으로 사용하고, 생성된 객체는 컴파일타임에 알 수 있는 적절한 상위 인터페이스나 클래스로 캐스팅하여 참조해야 합니다. 이는 Item 64의 원칙을 따르는 것입니다. 이 방식을 사용하면 컴파일타임 타입 검사의 이점을 누릴 수 있습니다.

아이템 66. 네이티브 메서드는 신중히 사용하라

네이티브 메서드(Native Methods, JNI)는 플랫폼 특정 기능이나 레거시 라이브러리를 사용하거나, 혹은 성능 개선을 목적으로 사용할 수 있는 기술이지만, 대부분의 경우 신중해야 합니다.

  • 주요 단점:
    1. 안전성 문제: 네이티브 코드는 자바와 달리 안전하지 않아 메모리 훼손 오류를 일으킬 수 있습니다.
    2. 디버깅 어려움: 디버깅이 더 어렵고, 주의를 기울이지 않으면 오히려 성능이 느려질 수 있습니다.
    3. 가독성 및 이식성 저하: 네이티브 코드는 플랫폼 종속적이므로 이식성이 낮고, 자바 코드와 네이티브 코드 사이의 연결 작업(glue code)이 까다로워 가독성이 떨어집니다.
  • 주의: 성능 개선을 목적으로 네이티브 메서드를 사용하는 경우는 드뭅니다. 자바 플랫폼이 성숙해지면서 성능이 매우 빨라졌기 때문입니다.
더보기

JVM 관점에서 본 네이티브 메서드

1. JNI 경계 비용 (Boundary Crossing Cost)

설명: Java ↔ Native 전환 시 막대한 오버헤드가 발생해요.

비용 요소:

  • 컨텍스트 스위칭: JVM 관리 영역 → OS/하드웨어 직접 접근
  • 인자 마샬링: Java 객체 → C 구조체 변환 (복사 비용)
  • GC Safe Point: 네이티브 코드 실행 중 GC 대기
  • 스택 프레임 전환: Java 스택 → Native 스택

근거:

  • 간단한 네이티브 호출: 수십~수백 나노초
  • 동등한 Java 메서드: 수 나노초
  • 호출 오버헤드가 작업 자체보다 클 수 있음

2. JIT 최적화 단절

설명: 네이티브 메서드는 JIT 컴파일러의 최적화 경계예요.

최적화 불가 영역:

  • 인라인화 불가: 네이티브 메서드는 인라인 불가 → 호출 오버헤드 유지
  • 탈출 분석 실패: 네이티브로 전달된 객체는 "탈출"로 간주 → 스칼라 치환 불가
  • 상수 전파 차단: 네이티브 경계 너머 값 추적 불가
  • 죽은 코드 제거 불가: 네이티브 부작용 예측 불가

근거:

  • JIT는 바이트코드만 분석 가능
  • 네이티브 코드는 블랙박스
  • 최적화 체인이 끊어짐

3. GC와의 상호작용 문제

설명: 네이티브 코드 실행 중 GC 관련 복잡성이 발생해요.

Safe Point 대기

// Java 코드
nativeMethod();  // 네이티브 진입

// JVM 내부:
// 1. GC가 필요함 → Safe Point 도달해야 함
// 2. 네이티브 코드 실행 중인 스레드는 Safe Point 아님
// 3. 네이티브 코드가 끝날 때까지 GC 대기
// 4. Stop-The-World 시간 증가 💥

Critical Native

// JNI에서 Java 객체 접근 시
jstring jstr = (*env)->NewStringUTF(env, "hello");
// GC가 발생하면 jstr이 이동/무효화될 수 있음!

// 해결: Get/ReleasePrimitiveArrayCritical
jbyte* arr = (*env)->GetPrimitiveArrayCritical(env, array, NULL);
// 이 구간 동안 GC 차단! (STW 시간 증가)
(*env)->ReleasePrimitiveArrayCritical(env, array, arr, 0);

근거:

  • 네이티브 코드는 GC-safe 하지 않음
  • JNI 핸들(jobject) 관리 오버헤드
  • Critical section에서 GC 블로킹 → 레이턴시 증가

4. 메모리 모델 불일치

설명: Java Memory Model과 C/C++ 메모리 모델이 달라요.

차이점:

  • Java: 강한 메모리 순서 보장 (volatile, synchronized)
  • C/C++: 약한 메모리 모델 (명시적 배리어 필요)
  • JNI 경계: 암묵적 메모리 배리어 (성능 저하)

근거:

  • JNI 호출마다 메모리 배리어 삽입
  • 멀티코어 환경에서 캐시 일관성 오버헤드
  • 네이티브 코드에서 Java 객체 접근 시 동기화 필요

5. 힙 외부 메모리 (Off-Heap Memory)

설명: 네이티브 코드는 JVM 힙 밖의 메모리를 사용해요.

문제점:

  • GC 압박 모니터링 불가: -Xmx에 포함 안 됨
  • 메모리 누수 추적 어려움: VisualVM/JProfiler로 안 보임
  • OOM 예측 실패: 시스템 메모리 고갈 시에만 발견

근거:

  • malloc()으로 할당한 메모리는 JVM 관리 밖
  • DirectByteBuffer는 예외 (Cleaner로 관리)
  • 네이티브 라이브러리 메모리 누수 시 진단 어려움

6. 스레드 상태와 JVM 내부 상태

설명: 네이티브 메서드 실행 중 스레드 상태가 특수해요.

JVM 스레드 상태:

RUNNING (Java 코드 실행)
    ↓ JNI call
IN_NATIVE (네이티브 코드 실행)
    - GC safe point 아님
    - JVM 내부 락 획득 불가
    - ThreadMXBean에서 특수 상태로 보고
    ↓ JNI return
RUNNING

근거:

  • 네이티브 실행 중: Java 스택 프레임 frozen
  • JVM 내부 동기화 메커니즘 접근 불가
  • 데드락 가능성 증가

7. 예외 처리 복잡성

설명: Java 예외와 Native 시그널/예외는 다른 세계예요.

문제 상황:

// C 코드에서 크래시
char* ptr = NULL;
*ptr = 'a';  // Segmentation Fault

// JVM 반응:
// 1. 시그널 핸들러 포착
// 2. 복구 시도 (불가능한 경우 많음)
// 3. JVM 전체 크래시 💥
// Java의 try-catch로 못 잡음!

근거:

  • Java 예외: 제어 가능한 흐름
  • Native 크래시: 프로세스 레벨 문제
  • JVM 안전성 보장 깨짐

8. 클래스로딩과 네이티브 라이브러리

설명: 네이티브 라이브러리는 한 번만 로드 가능해요.

문제:

// ClassLoader A
System.loadLibrary("native");  // 성공

// ClassLoader B (다른 클래스로더)
System.loadLibrary("native");  // UnsatisfiedLinkError!

근거:

  • 네이티브 라이브러리는 프로세스 레벨에서 로드
  • Java 클래스는 ClassLoader별 네임스페이스
  • Hot reload/OSGi 환경에서 문제 발생

9. Panama Project (미래)

설명: JNI를 대체하는 새로운 Foreign Function & Memory API

장점:

  • Zero-copy: 직접 메모리 접근 (마샬링 불필요)
  • 타입 안전성: Java 타입 시스템 통합
  • JIT 최적화 가능: 인라인화 지원
  • 명시적 라이프사이클: Arena 기반 메모리 관리

근거:

  • JNI 문제점 인식한 근본적 재설계
  • Java 19+에서 Preview
  • 네이티브 코드 없이 C 라이브러리 호출

10. 성능 역전 현상

설명: 과거에는 네이티브가 빨랐지만 지금은 Java가 더 빠른 경우 많아요.

이유:

  • JIT 성숙: C2 컴파일러의 공격적 최적화
  • 벡터화: SIMD 명령어 자동 활용
  • 프로파일 기반 최적화: 런타임 정보로 최적화
  • GC 발전: ZGC/Shenandoah의 낮은 레이턴시

예시:

  • Arrays.sort(): Java 구현이 네이티브보다 빠름 (TimSort)
  • String.hashCode(): JIT 인라인으로 극도로 최적화
  • 수학 연산: Math.sin() 등은 여전히 네이티브 (intrinsic)

근거:

  • JIT는 실행 패턴 학습 가능
  • 네이티브는 정적 컴파일 (실행 패턴 모름)
  • JNI 오버헤드가 성능 이득 상쇄

11. Intrinsic 메서드 (중간 지점)

설명: JVM이 특정 메서드를 네이티브로 직접 치환해요.

예시:

System.arraycopy()      // memcpy로 치환
Math.sqrt()             // CPU 명령어로 치환
String.equals()         // 최적화된 어셈블리
Unsafe.getInt()         // 직접 메모리 접근

근거:

  • JIT가 메서드 호출을 CPU 명령어로 직접 변환
  • JNI 오버헤드 없음
  • 안전성 유지 (JVM 관리하)

그럼에도 불구하고 JNI 사용하는 경우

1. 플랫폼 특화 기능

  • 윈도우 레지스트리 접근
  • macOS Keychain 사용
  • Linux 시스템 콜 (epoll 등)

2. 하드웨어 직접 제어

  • USB/시리얼 포트 통신
  • GPU 프로그래밍 (CUDA, OpenCL)
  • 임베디드 디바이스 제어

3. 레거시 C/C++ 라이브러리 재사용

  • OpenCV (컴퓨터 비전)
  • FFmpeg (비디오 처리)
  • 기존 회사 자산 활용

4. 실제 제품 예시

  • Android: 프레임워크 대부분이 JNI (NDK)
  • IntelliJ IDEA: 파일 시스템 감시 (fsnotify)
  • Cassandra: Off-heap 메모리 관리
  • Netty: 네이티브 epoll/kqueue transport

5. 성능이 정말 중요한 경우

  • HFT (고빈도 트레이딩): 마이크로초 레이턴시
  • 게임 엔진 핵심부
  • 대용량 이미지/비디오 처리

근거: Java만으로 불가능하거나, 기존 자산 활용이 재개발보다 효율적인 경우

하지만: 대부분은 Pure Java나 Panama FFI로 대체 가능하고, JNI는 최후의 수단이에요!

 

아이템 67. 최적화는 신중히 하라

최적화는 어렵고 위험하며, 좋은 프로그램을 작성하는 것이 빠른 프로그램을 작성하는 것보다 우선되어야 합니다.

  • 최적화 격언:
    1. "하지 마라."
    2. (전문가가 아니라면) "아직 하지 마라."
    3. (추가 권장) "각 최적화 시도 전후로 성능을 측정하라."
  • 좋은 코드의 우선순위: 좋은 프로그램은 정보 은닉 원칙을 따르고, 구성 요소의 내부를 독립적으로 설계하여 다른 시스템 부분에 영향을 주지 않습니다. 좋은 아키텍처 자체가 최적화의 길을 열어줍니다.
  • 측정의 중요성: 최적화가 필요할 때에는 반드시 프로파일링 도구(profiling tool)를 사용하여 병목 현상을 정확히 파악해야 합니다. 일반적으로 프로그램 실행 시간의 90%코드의 10% 부분에서 사용될 가능성이 높기 때문에, 잘못된 부분을 최적화하는 것은 시간 낭비일 뿐입니다.

아이템 68. 일반적으로 통용되는 명명 규칙을 따르라

자바 플랫폼에서 일반적으로 통용되는 명명 규칙을 따르는 것은 필수적입니다. 이 규칙은 코드를 읽기 쉽고 오해를 유발하지 않도록 돕습니다.

  • 명명 규칙의 분류:
    1. 철자 규칙 (Spelling Conventions):
      • 패키지 및 모듈: 모두 소문자 알파벳으로 작성하며, 계층 구조는 점(.)으로 구분합니다. 패키지 이름은 일반적으로 조직의 인터넷 도메인 이름을 역순으로 사용합니다.
      • 클래스와 인터페이스: 하나 이상의 단어로 구성되며, 첫 글자는 대문자로 시작하는 명사(혹은 형용사)를 사용합니다,.
      • 메서드와 필드: 첫 글자는 소문자로 시작합니다. 메서드는 보통 동사형이며, boolean을 반환하는 메서드는 is 또는 has로 시작하는 형용사 형태를 사용하기도 합니다,.
      • 상수 필드: 모든 단어를 대문자로 사용하고, 단어 사이는 밑줄(_)로 구분합니다 (예: MIN_VALUE, NEGATIVE_INFINITY). 상수 필드는 static final 타입으로, 참조하는 객체가 불변이라면 그 타입이 가변이더라도 상수 필드에 해당합니다.
    2. 문법 규칙 (Grammatical Conventions):
      • 클래스: 일반적으로 객체를 생성할 수 있는 클래스(Thread, PriorityQueue 등)의 이름은 복수형 명사를 사용하거나 명사형을 사용합니다.
      • 메서드: 어떤 동작을 수행하는 메서드는 동사형(예: append, drawImage)을 사용하며, boolean 값을 반환하는 메서드는 isDigit, isProbablePrime과 같은 형용사형을 사용합니다.

명명 규칙은 직관적이고 모호하지 않은 코드를 작성하는 데 중요하며, 규칙을 따르지 않으면 코드를 읽는 다른 프로그래머가 오해할 수 있습니다,.

728x90
반응형