심화 메커니즘과 모던 자바
자바의 예외 처리
예외의 계층 구조(Exception Hierarchy)
자바에서 발생하는 모든 문제의 최상위 조상은 java.lang.Throwable 클래스입니다. 이 아래로 크게 두 가지 갈래로 나뉩니다.
Error(시스템 레벨의 심각한 오류) :OutOfMemoryError(OOM),StackOverflowError처럼 JVM 자체에 문제가 생겨 애플리케이션 코드로 복구할 수 없는 치명적인 상태입니다. 개발자가catch로 잡으려고 시도해서는 안 됩니다.Exception(애플리케이션 레벨의 예외) : 개발자가 작성한 코드나 외부 환경(네트워크, 파일, DB 등)에 의해 발생하는 문제로, 적절한 코드를 통해 복구(Recover)하거나 회피(Evade) 할 수 있습니다.
Checked Exception vs Unchecked Exception
Exception은 다시 컴파일러가 처리를 강제하는지 여부에 따라 두 가지로 나뉩니다. 자바 설계의 가장 큰 특징이자, 현재까지도 논란이 되는 부분입니다.
Checked Exception(컴파일타임 예외)
RuntimeException을 상속받지 않은 모든 예외(IOException, SQLException 등)입니다. 발생할 가능성이 있는 코드를 작성하면, 컴파일러가 붉은 줄을 그으며 “반드시 try-catch로 잡거나, throws로 호출한 곳에 예외를 던지라”고 강제합니다.
개발자가 예외 상황(예: 파일이 없을 때)을 잊지 않고 처리하도록 강제하는 장점이 있습니다. 하지만 실무에서는 치명적인 단점이 있습니다.
- OCP(개방-폐쇄 원칙) 위반 : 하위 메서드에서 새로운 Checked Exception이 추가되면, 이를 호출하는 상위의 모든 메서드 시그니처(
throws ~)를 연쇄적으로 수정해야 합니다. - 람다/스트림 호환 불가 : Java 8의 함수형 프로그래밍(Stream API) 내부에서는 Checked Exception을 던질 수 없어 코드가 매우 지저분해집니다.
Unchecked Exception(런타임 예외)
RuntimeException을 상속받은 예외(NullPointerException, IllegalArgumentException 등)입니다. 컴파일러가 예외 처리를 강제하지 않습니다.
현대 자바 생태계(특히 Spring 프레임워크)에서는 “모든 사용자 정의 예외는 가급적 Unchecked Exception으로 만든다” 가 업계 표준(Best Practice)으로 굳어졌습니다. 예외를 강제해서 얻는 이득보다, 코드가 지저분해지고 결합도가 높아지는 비용이 훨씬 크다고 판단했기 때문입니다. 복구 불가능한 예외는 런타임 예외로 포장해서 던지고, 최상위(글로벌) 예외 처리기에서 한 번에 공통 처리하는 방식을 사용합니다.
예외 발생의 숨겨진 비용 : Stack Trace 생성
자바에서 throw new Exception()을 하는 순간, JVM은 현재 스레드의 메서드 호출 스택(Call Stack)을 거슬러 올라가며 어떤 경로로 여기까지 왔는지 스냅샷을 찍어 Stack Trace(스택 트레이스) 를 생성합니다.
이 과정은 엄청나게 무겁고 비싼 연산입니다. 따라서 비즈니스 로직의 제어 흐름(Control Flow)을 바꾸는 용도(데이터가 없으면 예외를 던져서 다른 로직을 태우는 방식)로 예외를 사용하면 애플리케이션 성능이 박살 납니다. 예외는 오직 ‘진짜 예외적인 상황’ 에만 사용해야 합니다.
try-with-resources
과거에는 파일이나 DB 커넥션을 열면 메모리 누수를 막기 위해 finally 블록에서 반드시 close()를 호출해야 했습니다. 하지만 코드가 복잡해지고 close() 안에서도 예외가 터지면 원래 예외가 묻히는(Shadowing) 문제가 있었습니다.
try-with-resources 구문은 이를 해결하기 위해 Java 7부터 도입되었습니다. AutoCloseable 인터페이스를 구현한 객체라면, try(...) 괄호 안에 선언하기만 하면 try 블록이 끝나는 순간 JVM이 알아서 안전하게 자원을 해제(close) 해줍니다.
자바의 추가적인 매커니즘
직렬화(Serialization)와 역직렬화(Deserialization)
자바 메모리(Heap)에 생성된 객체는 프로그램이 종료되면 사라지는 ‘휘발성’을 가집니다. 이 객체를 네트워크를 통해 다른 서버로 전송하거나, 파일/DB에 영구 저장하기 위해 다음 과정을 거칩니다.
- 직렬화 (Serialization) : 메모리에 흩어져 있는 객체의 상태(필드 값들)를 모아서, 전송 및 저장이 가능한 연속된 바이트 스트림(Byte Stream) 형태로 변환하는 과정입니다. 자바에서는 클래스에
java.io.Serializable인터페이스(메서드가 없는 마커 인터페이스)를 구현하여 직렬화 가능 여부를 표시합니다. - 역직렬화 (Deserialization) : 반대로 바이트 스트림을 다시 읽어들여 JVM 힙 메모리상에 원래의 객체로 복원하는 과정입니다.
자바의 기본 직렬화 메커니즘은 보안 취약점(원격 코드 실행 공격 등)이 매우 많고, 바이트 크기가 비대하며, 클래스 구조가 조금만 바뀌어도 역직렬화 시
InvalidClassException이 터지는 등 강한 결합도를 유발합니다. 따라서 현대 실무에서는 자바 네이티브 직렬화 대신, JSON(Jackson, Gson) 이나 Protobuf 같은 언어 중립적이고 안전한 포맷으로 직렬화하는 것을 권장(Best Practice)합니다.
== 연산자 vs equals() 메서드
자바에서 “두 데이터가 같다”는 것은 두 가지 의미를 가집니다.
==(동일성, Identity) : 두 변수가 ‘메모리상의 정확히 같은 주소’ 를 가리키고 있는지를 비교합니다. 기본형(Primitive) 타입은 스택에 값이 직접 있으므로==로 값 비교가 되지만, 참조형(객체) 타입에==를 쓰면 메모리 주소(포인터) 비교가 됩니다. 속도는 $O(1)$로 극도로 빠릅니다.equals()(동등성, Equality) : 두 객체의 ‘내부적인 상태(데이터 값)가 같은지’ 를 비교합니다. 최상위 객체인Object클래스에 정의되어 있으며, 개발자가 직접 클래스의 특성에 맞게 오버라이딩(재정의)하여 사용해야 합니다. 예를 들어, 이름과 나이가 같으면 같은Person객체로 취급하도록 로직을 짤 수 있습니다.
만약 커스텀 클래스에서
equals()를 오버라이딩하여 동등성을 정의했다면, 반드시hashCode()메서드도 함께 오버라이딩해야 합니다.
HashMap이나HashSet같은 해시 기반 컬렉션들은 객체가 같은지 판단할 때 1차로hashCode()값을 비교하고, 2차로equals()를 비교합니다.hashCode()를 재정의하지 않으면,equals()상으로는 완벽히 똑같은 두 객체라도 컬렉션 내부에서는 다른 객체로 인식되어 데이터를 찾을 수 없는 끔찍한 버그가 발생합니다.
String, StringBuffer, StringBuilder 비교
자바에서 문자열을 다루는 세 가지 클래스는 ‘불변성(Immutability)’ 과 ‘스레드 안전성(Thread-Safety)’ 을 기준으로 명확한 트레이드오프를 가집니다.
String(불변 객체)
한 번 생성되면 내부 값을 절대 바꿀 수 없습니다. str += "a"를 하면 기존 객체에 “a”가 붙는 것이 아니라, 완전히 새로운 "stra" 객체가 힙 메모리에 생성되고 기존 객체는 가비지(GC 대상)가 됩니다.
변하지 않기 때문에 멀티스레드 환경에서 완벽하게 안전하고, JVM의 String Constant Pool(문자열 상수 풀) 에 캐싱되어 메모리를 절약할 수 있습니다.
반복문 안에서 문자열을 계속 결합하면 엄청난 객체 생성 오버헤드와 GC 부하를 유발합니다.
StringBuffer(가변, 동기화 됨)
문자열을 변경할 수 있는 임시 버퍼를 가집니다. 새로운 객체 생성 없이 내부 배열의 크기만 늘리며 문자열을 조작합니다.
모든 핵심 메서드에 synchronized가 걸려 있어 멀티스레드 환경에서도 안전하지만, 락(Lock) 획득으로 인한 성능 저하가 있습니다.
StringBuilder(가변, 동기화 안 됨)
StringBuffer와 구조는 동일하지만, synchronized 동기화 로직을 제거한 클래스입니다.
단일 스레드 환경에서 문자열을 대량으로 결합할 때 압도적으로 가장 빠른 성능을 냅니다. 실무에서는 보통 지역 변수로 문자열을 조작하므로 StringBuilder 사용이 표준입니다.
어노테이션(Annotation)
코드에 @ 기호를 붙여 사용하는 메타데이터(Metadata)입니다. 본질적으로는 ‘코드에 대한 정보를 담고 있는 주석’ 이지만, 단순한 주석을 넘어 컴파일러나 런타임 환경에 특별한 지시를 내립니다.
어노테이션 자체는 아무런 동작 로직을 가지지 않습니다. 하지만 자바의 리플렉션(Reflection) API를 사용해 런타임에 이 어노테이션을 읽어 들여 특정 동작(예: 객체 자동 주입, 트랜잭션 시작 등)을 수행하는 프레임워크(Spring 등)와 결합할 때 마법 같은 힘을 발휘합니다.
설정 파일(XML)을 작성하거나 반복적인 보일러플레이트 코드를 짜는 대신,
@Autowired,@Transactional처럼 선언만 하면 로직이 알아서 동작(선언적 프로그래밍)하므로 생산성이 극대화됩니다. 하지만 내부에서 무슨 일이 일어나는지 숨겨지기 때문에, 문제 발생 시 디버깅이 매우 어려워지는(Magic 현상) 단점이 있습니다.
모던 자바
자바 8(Java 8)의 등장은 단순한 버전 업데이트가 아니라, 자바 역사상 가장 거대한 패러다임의 전환(Paradigm Shift) 이었습니다.
이전까지 자바는 “모든 것은 객체다”라는 철저한 객체지향(OOP) 사상을 고수했지만, 멀티 코어 프로세서의 대중화와 대용량 데이터 처리의 필요성이 대두되면서 한계에 부딪혔습니다. 이를 극복하기 위해 자바는 함수형 프로그래밍(Functional Programming) 의 장점(불변성, 선언적 사고)을 언어 깊숙이 수용하게 되었습니다.
함수형 인터페이스와 람다식
자바는 함수(메서드)를 일급 객체(First-class citizen)로 취급하지 않기 때문에, 함수를 변수에 담거나 파라미터로 넘길 수 없습니다. 자바는 이 한계를 ‘인터페이스’를 묘하게 비틀어 우회했습니다.
- 함수형 인터페이스(Functional Interface) : 단 하나의 추상 메서드(Single Abstract Method, SAM) 만을 가지는 인터페이스입니다.
@FunctionalInterface어노테이션을 붙여 컴파일러에게 이를 강제할 수 있습니다. (예:Runnable,Callable,Comparator) - 람다식(Lambda Expression) : 이 함수형 인터페이스의 유일한 추상 메서드를 아주 간결한 화살표 문법(
->)으로 구현하는 ‘익명 함수(Anonymous Function)’ 입니다.
람다식의 메모리 트레이드오프와 invokedynamic
과거 익명 클래스를 쓰면 컴파일 시점에 ClassName$1.class 같은 무수한 파일들이 생성되어 메모리(메서드 영역)를 낭비했습니다. 자바 8부터는 람다식을 사용하면 컴파일 시점에 클래스를 만들지 않고, 런타임에 JVM의 invokedynamic 바이트코드 명령어를 통해 동적으로 메모리에 함수 객체를 바인딩합니다. 덕분에 메모리 낭비가 획기적으로 줄어듭니다.
코드가 극도로 간결해지고, 핵심 비즈니스 로직에만 집중할 수 있습니다(선언적 프로그래밍).
예외(Exception)가 발생했을 때 생성되는 스택 트레이스(Stack Trace)가 람다 내부의 복잡한 동적 프록시 호출로 도배되어 디버깅이 매우 힘들어집니다. 또한, 람다식 내부에서는 외부의 지역 변수를 수정할 수 없는 ‘Effectively Final(사실상 상수)’ 제약이 있어, 사이드 이펙트를 강제로 차단당합니다(이것이 함수형 프로그래밍의 핵심이기도 합니다).
컬렉션 vs 스트림의 근본적 차이
이 두 가지는 “여러 개의 데이터를 다룬다”는 점만 같을 뿐, 데이터를 대하는 철학 자체가 다릅니다.
컬렉션(Collection) : 데이터의 ‘보관(Storage)’이 목적
- 공간 중심 : 모든 데이터가 메모리에 적재되어 있어야 합니다. DVD에 구워진 영화 전체를 의미합니다.
- 외부 반복(External Iteration) : 개발자가 직접
for문이나Iterator를 가져와서 요소를 하나씩 꺼내고 로직을 제어합니다. - 즉시 연산 (Eager Evaluation) : 코드를 작성하는 순간 즉시 연산이 수행됩니다.
스트림(Stream) : 데이터의 ‘처리(Computation)’가 목적
- 시간/흐름 중심 : 데이터가 메모리에 한 번에 올라가지 않고 파이프라인을 따라 흘러갑니다. 넷플릭스 같은 실시간 스트리밍(Streaming) 영상과 같습니다.
- 내부 반복(Internal Iteration) : 개발자는 “무엇을 할 것인지(필터링, 매핑)”만 람다식으로 던져주고, 반복 자체는 스트림 프레임워크 내부에서 알아서 처리합니다.
10,000개 이하의 단순 원시 타입(Primitive Type, 예:
int[]) 배열에서 단순 반복문을 돌릴 때는, 기존의for-loop가 JIT 컴파일러의 루프 최적화(Loop Unrolling) 덕분에 스트림보다 수 배 이상 압도적으로 빠릅니다.스트림은 파이프라인 객체 생성, 람다 호출 오버헤드 등이 발생하므로 복잡한 객체 리스트의 변환/필터링 로직을 가독성 있게 짤 때나,
parallelStream()을 활용해 멀티 코어 병렬 처리를 아주 쉽게 구현하고 싶을 때 사용하는 것이 올바른 판단입니다.
스트림의 성능을 끌어올리는 2가지 방법
- 지연 연산(Lazy Evaluation) : 스트림 파이프라인은 중간 연산(
filter,map)과 최종 연산(collect,findFirst)으로 나뉩니다. 자바 스트림은 최종 연산이 호출되기 전까지는 중간 연산을 단 하나도 실행하지 않습니다. - 쇼트 서킷(Short-Circuiting) : 지연 연산 덕분에 엄청난 최적화가 발생합니다. 예를 들어 데이터 100만 개 중
filter()를 거쳐findFirst()(첫 번째 조건 만족 데이터 찾기)를 수행할 때, 기존for문과 리스트 조작 방식이면 100만 개를 다 가공해야 합니다. 하지만 스트림은 파이프라인을 수직으로 흘려보내며 데이터를 하나씩 검사하다가, 조건을 만족하는 첫 데이터를 찾는 순간 나머지 99만 9,999개의 데이터 처리를 즉시 중단(Short-circuit) 해버립니다.