포스트

자바 언어의 핵심 문법과 메모리 매핑

자바 언어의 핵심 문법과 메모리 매핑

자바의 데이터 타입, 메모리, 그리고 파라미터 전달의 본질

기본형(Primitive) vs 참조형(Reference) 타입과 메모리 구조

자바의 데이터 타입은 크게 두 가지로 나뉩니다. 이들이 메모리 어디에, ‘왜’ 그렇게 저장되는지 아는 것이 중요합니다.

  • 기본형(Primitive Type) : int, double, boolean, char 등 8가지 기본 타입입니다. 실제 ‘값(Value)’ 그 자체를 저장합니다.
  • 참조형(Reference Type) : 기본형을 제외한 모든 타입입니다. String, 배열, 그리고 개발자가 만든 모든 클래스(Object)가 포함됩니다. 실제 값이 아닌, 객체가 위치한 ‘메모리 주소(Reference)’ 를 저장합니다.

메모리 저장 위치

흔히 “기본형은 스택(Stack)에, 참조형은 힙(Heap)에 저장된다”고 오해합니다. 이는 반만 맞는 이야기입니다. 정확한 기준은 타입이 아니라 ‘변수가 선언된 위치(스코프)’ 입니다.

  • 지역 변수(Local Variable)로 선언될 경우
    • int a = 10; (기본형) : 스택 영역(Stack Area)의 프레임 내부에 10이라는 실제 값이 바로 저장됩니다.
    • Object obj = new Object(); (참조형) : 힙 영역에 실제 Object가 생성되고, 스택 영역에는 그 힙 메모리를 가리키는 ‘주소값(예: 0x1234)’ 이 저장됩니다.
  • 인스턴스 변수(Instance Variable)로 선언될 경우(클래스 내부의 필드)
    • 객체 자체가 힙 영역에 생성되므로, 그 객체 내부에 있는 기본형 변수도 힙 영역에 함께 저장됩니다.

스택은 메서드 종료 시 자동으로 소멸하므로 메모리 할당/해제 속도가 $O(1)$로 극도로 빠릅니다. 크기가 고정된 기본형은 스택에 두어 성능을 극대화합니다. 반면, 객체는 크기가 가변적이고 생명주기가 메서드 호출과 다를 수 있으므로 넓은 힙 영역에 저장하고 가비지 컬렉터(GC) 가 관리하게 만든 것입니다.

래퍼 클래스 (Wrapper Class)와 오토박싱 (Auto-boxing)

자바는 철저한 객체지향 언어이지만, 성능을 위해 예외적으로 순수 객체가 아닌 기본형(Primitive)을 남겨두었습니다. 하지만 이중적인 타입 시스템은 한계를 드러냈습니다.

  • 래퍼 클래스의 등장 배경 : 자바의 컬렉션 프레임워크(List, Map 등)나 제네릭(Generics)은 내부적으로 Object만 다룰 수 있습니다. 즉, List<int>는 문법적으로 불가능합니다. 이를 해결하기 위해 기본형을 객체로 감싸는(Wrap) 클래스인 Integer, Double, Boolean 등을 만들었습니다.
  • 오토박싱과 언박싱 : 기본형과 래퍼 클래스 간의 변환을 컴파일러가 자동으로 해주는 기능입니다.
    • Integer num = 10; (오토박싱: int -> Integer 객체로 자동 변환)
    • int n = num; (언박싱: Integer 객체 -> int로 자동 변환)

오토박싱은 코드를 간결하게 만들지만, 치명적인 성능 저하를 유발할 수 있습니다. 만약 for 문을 100만 번 돌면서 Integer 변수에 연산을 수행하면, 내부적으로 100만 개의 불필요한 Integer 임시 객체가 힙 메모리에 생성되고 버려집니다. 이는 엄청난 GC(가비지 컬렉션) 오버헤드를 발생시키므로, 반복문이나 대용량 연산에서는 반드시 기본형(int)을 사용해야 합니다.

Integer Cache

자바에서 Integer a = 100; Integer b = 100;을 한 뒤 a == b를 비교하면 true가 나옵니다. 하지만 Integer c = 1000; Integer d = 1000;을 한 뒤 c == d를 비교하면 false가 나옵니다.

차이가 발생하는 이유는 자바는 메모리 최적화를 위해 -128 부터 127 까지의 자주 쓰이는 Integer 객체를 미리 캐싱(미리 생성해서 재사용) 해 둡니다. 이 범위를 벗어난 숫자는 매번 새로운 객체(new)를 생성하므로 주소값이 달라져 false가 나오는 것입니다. 래퍼 클래스는 값을 비교할 때 반드시 == 가 아닌 .equals()를 사용해야 하는 근본적인 이유입니다.

Call by Value vs Call by Reference

“자바는 100% Call by Value(값에 의한 호출)로만 동작” 합니다. 자바에는 Call by Reference가 존재하지 않습니다.

  • Call by Value(값에 의한 호출) : 메서드에 파라미터를 넘길 때, 변수에 담긴 ‘값 자체를 복사’ 해서 전달합니다.
  • 자바에서의 오해 원인 : 참조형 타입(객체)을 넘길 때 마치 Call by Reference처럼 원본 객체의 상태가 변하기 때문에 헷갈리기 쉽습니다.

동작 원리 분석

자바에서 참조형 변수를 메서드로 넘기면, 객체 자체가 넘어가는 것이 아니라 ‘스택에 저장된 메모리 주소값’이 복사되어 넘어갑니다.

1
2
3
4
public void modify(Person p) {
    p.setName("Alice"); // 1번: 원본 객체의 이름이 바뀜
    p = new Person("Bob"); // 2번: 원본 객체에는 아무 영향이 없음!
}
  1. 호출한 쪽과 호출받은 쪽(메서드 내부)의 변수가 동일한 힙 메모리 주소를 가리키고 있기 때문에, setName을 통해 내부 상태를 변경하면 원본 객체도 변경된 것처럼 보입니다.
  2. 하지만 파라미터 p에 아예 새로운 객체 new Person()을 할당해버리면, 메서드 내부의 p가 가리키는 주소만 바뀔 뿐, 호출한 쪽의 원본 변수가 가리키는 주소는 절대 변하지 않습니다. C++의 참조 전달(Reference Pointer)과는 근본적으로 다릅니다.

자바 OOP의 뼈대 구축

객체의 탄생

클래스(Class)

현실 세계의 개념을 속성(상태, 필드)과 행위(메서드)로 묶어낸 설계도입니다. 클래스 자체는 메모리에 실체가 없는 개념일 뿐이며, new 키워드를 통해 힙(Heap) 영역에 실체화되어야 비로소 객체(Instance) 가 됩니다.

생성자(Constructor)

객체가 메모리에 할당된 직후, 해당 객체의 초기 상태를 안전하게 세팅하기 위해 단 한 번 호출되는 특별한 메서드입니다. 클래스 이름과 동일하며, 반환 타입(return type)이 없습니다.

객체의 확장

상속(Inheritance)과 super 키워드

자바에서 상속은 extends 키워드를 사용하여 기존 클래스(부모)의 필드와 메서드를 새로운 클래스(자식)가 물려받는 기능입니다.

단순한 ‘코드 재사용’을 넘어, 메모리 관점에서 이 둘이 어떻게 결합되는지 아는 것이 중요합니다.

메모리에서의 상속 : 우리가 자식 클래스를 new 키워드로 생성하면, 힙 메모리에는 자식 객체만 덩그러니 생기는 것이 아닙니다. 부모 객체가 먼저 메모리에 생성된 후, 그 부모 객체를 감싸는 형태로 자식 객체가 생성됩니다. 즉, 자식 객체 내부에는 부모 객체의 실체가 숨어있습니다.

  • super : 자식 객체 내부에서, 자신 안에 숨어있는 부모 객체의 메모리 주소(참조)를 가리키는 키워드입니다. 부모와 자식에게 동일한 이름의 변수나 메서드가 있을 때, 부모의 것을 명시적으로 호출하기 위해 사용합니다. (나 자신을 가리키는 this와 대비됩니다.)
  • super() : 부모 클래스의 생성자를 호출하는 키워드입니다. 자식 클래스의 생성자가 실행될 때, 자바 컴파일러는 자식 생성자의 맨 첫 줄에 super();강제로(숨겨서) 끼워 넣습니다. 부모 객체가 먼저 온전하게 생성(초기화)되어야 자식 객체도 무사히 생성될 수 있기 때문입니다.

앞서 SOLID 원칙에서 다루었듯, 상속은 부모와 자식을 아주 강하게 결합시킵니다. 부모 클래스에 필드 하나만 추가되거나 생성자가 변경되어도, 모든 자식 클래스가 컴파일 에러를 뿜으며 수정되어야 합니다. 따라서 명확한 ‘IS-A(A는 B이다)’ 관계가 아닐 때 단순 편의를 위해 상속을 남발하는 것은 최악의 안티 패턴입니다.

java.lang.Object 클래스

자바에서 개발자가 만드는 모든 클래스는 명시하지 않더라도 눈에 보이지 않게 extends Object를 상속받고 있습니다. 즉, Object 클래스는 자바 세계의 최상위 부모 클래스입니다. 우리가 배열이나 커스텀 클래스에서 .toString(), .equals(), .hashCode() 같은 메서드를 당연하다는 듯이 호출할 수 있는 이유는, 이 객체 생성 과정에서 최상위 부모인 Object 객체가 함께 메모리에 생성되어 그 기능들을 물려주었기 때문입니다.

자바 OOP의 동적 제어와 다형성 완성

오버로딩(Overloading) vs 오버라이딩(Overriding)

두 개념은 이름이 비슷하여 자주 혼동하지만, 동작하는 시점(컴파일 타임 vs 런타임)이 완전히 다릅니다.

오버로딩(Overloading - 과적하다)

같은 클래스 내에서 메서드 이름은 같지만, 파라미터의 타입이나 개수를 다르게 여러 개 정의하는 것입니다. 비슷한 기능을 하는 메서드들의 이름을 통일하여 개발자의 암기 부담을 줄이고 API의 가독성을 높입니다. (System.out.println())

  • 정적 바인딩 : 컴파일러가 코드를 컴파일할 때 어떤 메서드를 호출할지 파라미터를 보고 미리 결정해 버립니다(Static Binding). 따라서 반환(Return) 타입만 다르게 하는 것은 오버로딩이 성립하지 않으며 컴파일 에러가 납니다.

오버라이딩(Overriding - 덮어쓰다)

부모 클래스로부터 상속받은 메서드의 내부 로직(구현부)을 자식 클래스에서 재정의하는 것입니다. 메서드의 이름, 매개변수, 반환 타입이 모두 같아야 합니다.

  • 동적 바인딩 : 런타임(실행 시점)에 JVM이 힙 메모리에 있는 실제 객체의 타입을 확인하고, 자식이 덮어쓴 최신 메서드를 찾아 실행합니다(Dynamic Binding).

타입의 변신

자바에서는 변수의 타입과 실제 메모리에 생성된 객체의 타입이 다를 수 있습니다. 이 유연함이 다형성의 기반이 됩니다.

업캐스팅(Upcasting)

자식 타입의 객체를 부모 타입의 참조 변수에 담는 것입니다. (Parent p = new Child();)

컴파일러가 자동으로 형변환을 해줍니다. 자식 객체 내부에는 이미 부모 객체가 존재하므로, 그저 참조하는 시야를 부모 수준으로 좁히는 것뿐입니다. 따라서 부모에 정의된 메서드만 호출할 수 있습니다.

다운캐스팅(Downcasting)

업캐스팅되어 부모 타입으로 취급받던 객체를 다시 본래의 자식 타입으로 되돌리는 것입니다. (Child c = (Child) p;)

컴파일러가 자동으로 해주지 않으므로 괄호 ()를 이용해 명시적으로 강제 형변환을 해야 합니다.

다운캐스팅은 매우 위험합니다. 메모리상에 실제로 존재하지도 않는 자식 객체로 억지로 캐스팅하려 하면 런타임에 치명적인 ClassCastException이 발생하며 시스템이 죽습니다. 이를 방지하기 위해 다운캐스팅 전에 반드시 instanceof 연산자를 사용하여 실제 힙 메모리에 있는 객체가 해당 타입이 맞는지 검증하는 방어 로직을 작성합니다.

다형성(Polymorphism)

오버라이딩과 업캐스팅이 합쳐지면 자바 최고의 무기인 다형성이 발동합니다. 다형성이란 “하나의 부모 타입 참조 변수로 여러 자식 객체들을 다루면서, 각 자식들의 고유한 동작(오버라이딩된 메서드)을 실행할 수 있는 능력”입니다.

1
2
3
4
5
6
7
List<Animal> animals = new ArrayList<>();
animals.add(new Dog()); // 업캐스팅
animals.add(new Cat()); // 업캐스팅

for (Animal a : animals) {
    a.cry(); // 다형성 발동 (동적 바인딩)
}

컴파일러는 a.cry()가 부모인 Animal의 메서드라고 생각하지만, JVM은 런타임에 메모리를 확인하여 실제 객체인 Dogcry()Catcry()를 각각 다르게 실행합니다.

이것이 SOLID의 OCP(개방-폐쇄 원칙)를 구현하는 방법입니다. 새로운 동물 Bird가 추가되어도, 이를 실행하는 for문(클라이언트 코드)은 단 한 줄도 수정할 필요가 없습니다. 확장에 열려있고 수정에 닫혀있는 견고한 시스템이 완성됩니다.

접근 제어자와 제어 키워드

접근 제어자(Access Modifiers)

접근 제어자는 클래스, 메서드, 변수 앞에 붙어서 “외부에서 이 요소에 어디까지 접근할 수 있는지” 그 권한의 범위를 통제하는 키워드입니다. 앞서 객체지향의 4대 원칙 중 하나로 배운 캡슐화(정보 은닉) 를 자바 코드 레벨에서 구현하는 가장 직접적인 수단입니다.

private(철저한 고립)

오직 자신이 선언된 클래스 내부에서만 접근 가능합니다.

객체의 상태(데이터)를 보호하는 최후의 보루입니다. 시니어 개발자들은 특별한 이유가 없다면 모든 멤버 변수(필드)는 무조건 private으로 선언하는 것을 원칙으로 합니다.

default(패키지 프라이빗, 키워드 생략 시 적용)

같은 클래스 내부 + 동일한 패키지(폴더) 내부에서만 접근 가능합니다.

동일한 패키지로 묶인 관련된 클래스들끼리만 긴밀하게 협력해야 할 때 유용합니다. 하지만 명시적인 키워드가 없어 가독성이 떨어지므로 실무에서는 의도적으로 사용하는 경우가 아니면 잘 쓰지 않습니다.

protected(상속의 특권)

같은 패키지 내부 + 다른 패키지이더라도 상속받은 자식 클래스에서는 접근 가능합니다.

부모 클래스가 자식 클래스에게만 “이 메서드는 오버라이딩(재정의)하거나 내부적으로 가져다 써도 좋아”라고 허락해 주는 API 설계용 키워드입니다.

public(완전 개방)

프로젝트 내의 어떤 클래스, 어떤 패키지에서든 무제한 접근 가능합니다.

외부 세계와 소통하는 공식적인 창구(Interface) 역할을 하는 메서드에만 제한적으로 사용해야 합니다.

접근 제어자같은 클래스같은 패키지상속(자식 클래스)외부 클래스
publicOOOO
protectedOOOX
defaultOOXX
privateOXXX

접근 제어자를 넓게(public) 열어둘수록 당장 코딩하기는 편합니다. 다른 클래스의 변수를 직접 가져다 쓰면 되니까요. 하지만 이는 객체 간의 강한 결합(Tight Coupling) 을 유발하여, 훗날 변수 하나를 수정했을 때 시스템 전체가 무너지는 대참사(나비효과)를 초래합니다. 접근 제어자는 “최소 권한의 원칙(Principle of Least Privilege)” 에 따라 최대한 좁게 설정하는 것이 변경에 유연한 시스템을 만드는 핵심입니다.

static 키워드

static은 “정적인, 고정된”이라는 뜻으로, 클래스의 멤버(변수나 메서드)를 객체(인스턴스) 단위가 아닌 클래스 단위로 묶어주는 강력한 키워드입니다.

인스턴스 멤버 (static 없음)

new 키워드로 객체를 생성할 때마다 힙(Heap) 영역에 매번 새롭게 공간이 할당됩니다. 즉, 객체마다 자신만의 고유한 데이터를 가집니다.

정적 멤버 (static 있음)

  • 클래스로더가 클래스를 메모리에 로딩할 때, JVM의 메서드 영역(Method Area) 에 딱 한 번만 공간이 할당됩니다.
  • new로 객체를 생성하지 않아도 ClassName.methodName() 형태로 즉시 호출할 수 있습니다.
  • 해당 클래스로 만들어진 모든 객체들이 동일한 메모리 주소를 공유하게 됩니다.

활용 패턴

상수(Constant)의 정의 : public static final
  • 자바에서 변하지 않는 전역 상수를 선언할 때는 반드시 이 세 가지 키워드를 조합합니다.
  • 예: public static final double PI = 3.14159;
  • public(누구나 접근 가능) + static(메모리에 단 하나만 존재하여 공유) + final(최초 할당 후 값 변경 불가)의 조합으로, 메모리 낭비 없이 안전하게 상수를 관리할 수 있습니다.
유틸리티 클래스 (Utility Class)
  • java.lang.MathStringUtils처럼 상태(인스턴스 변수)를 가지지 않고 오직 순수 함수(입력값이 같으면 항상 같은 출력값을 반환하는 메서드)들로만 이루어진 클래스는 객체를 생성할 필요가 없습니다. 이런 경우 메서드들을 static으로 선언하고, 생성자를 private으로 막아 객체 생성을 원천 차단하는 것이 Best Practice입니다.

final 키워드

final은 “마지막, 더 이상 변경할 수 없음”을 선언하여 시스템의 안정성을 강제하는 키워드입니다. 위치에 따라 세 가지 역할을 합니다.

  • 변수(final int X = 10;) : 한 번 값을 할당하면 절대 변경할 수 없는 상수가 됩니다. (불변 객체를 만드는 핵심)
  • 메서드(public final void doIt()) : 자식 클래스에서 이 메서드를 오버라이딩(재정의)할 수 없습니다. 핵심 비즈니스 로직을 자식이 함부로 변경하는 것을 막을 때 씁니다.
  • 클래스(public final class String) : 더 이상 상속할 수 없는 클래스가 됩니다. 자바의 String 클래스가 대표적인 final 클래스입니다. 누군가 String을 상속받아 내부 동작을 조작하면 시스템 전체의 보안이 무너지기 때문입니다.
이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.