Tuesday, May 29, 2018

메모리 모델로 파헤치는 final과 const: 자바와 자바스크립트의 불변성 심층 탐구

소프트웨어 개발에서 '불변성(Immutability)'은 코드의 예측 가능성을 높이고, 부수 효과(Side Effect)를 줄이며, 동시성(Concurrency) 문제를 해결하는 핵심적인 개념으로 자리 잡았습니다. 많은 개발자들이 자바(Java)의 final 키워드와 자바스크립트(JavaScript, ES6+)의 const 키워드를 사용하며 '상수'를 선언하고, 이를 통해 불변성을 확보하고 있다고 생각합니다. 하지만 이 두 키워드는 우리가 기대하는 '완벽한 불변성'을 보장하지 않습니다. 오히려 특정 조건 하에서만 불변성을 유지하며, 이를 오해하고 사용할 경우 예상치 못한 버그의 원인이 되기도 합니다.

이 글에서는 단순한 문법적 차원을 넘어, 자바와 자바스크립트가 메모리를 어떻게 관리하는지(메모리 모델)를 통해 finalconst의 동작 원리를 심층적으로 파헤쳐 봅니다. '변수'와 '값', '참조'의 관계를 명확히 이해함으로써, 이 키워드들이 '무엇을' 불변으로 만드는지, 그리고 '무엇은' 불변으로 만들지 못하는지를 명확히 구분할 것입니다. 이를 통해 진정한 불변성을 달성하기 위한 구체적인 프로그래밍 패턴과 기법까지 함께 탐구하며, 더 견고하고 안정적인 코드를 작성하는 기반을 다져나갈 것입니다.

1. 자바(Java)의 불변성을 향한 여정: `final` 키워드의 명과 암

자바에서 final은 '마지막' 또는 '변경 불가'의 의미를 지니는 다재다능한 키워드입니다. 변수, 메소드, 클래스에 모두 적용될 수 있으며, 각각의 대상에 따라 다른 제약을 가합니다. 우리는 변수에 초점을 맞춰 final의 동작을 깊이 있게 살펴보겠습니다.

1.1. 기본 자료형(Primitive Type)과 `final`: 완벽한 불변성의 구현

자바의 기본 자료형에는 int, double, boolean, char 등 8가지가 있습니다. 이들은 '값' 자체를 변수 저장 공간에 직접 담습니다. 이 저장 공간은 일반적으로 **스택(Stack)** 메모리 영역에 위치합니다.

final 키워드를 기본 자료형 변수와 함께 사용하면, 해당 변수는 단 한 번만 초기화될 수 있으며 이후에는 어떠한 값도 재할당할 수 없습니다. 이는 컴파일 시점에 체크되므로, 재할당을 시도하는 코드는 컴파일 오류(Compile Error)를 발생시킵니다.


public class FinalPrimitiveExample {
    public static void main(String[] args) {
        // final 기본 자료형 변수 선언과 동시에 초기화
        final int maxUsers = 100;
        final double pi = 3.14159;
        
        System.out.println("최대 사용자 수: " + maxUsers);
        System.out.println("원주율: " + pi);

        // final 변수에 새로운 값을 할당하려고 시도하면 컴파일 오류가 발생합니다.
        // maxUsers = 200; // error: cannot assign a value to final variable 'maxUsers'
    }
}

이 경우, `maxUsers`라는 변수 이름이 가리키는 스택 메모리 공간에 값 100이 직접 저장되고, `final` 선언으로 인해 이 공간의 값은 절대 변경될 수 없습니다. 이것이 우리가 일반적으로 기대하는 '상수'의 완벽한 동작 방식입니다.

1.2. 참조 자료형(Reference Type)과 `final`: 참조의 불변성, 객체의 가변성

문제는 참조 자료형(예: Object, String, Array, 모든 클래스의 인스턴스)에서 발생합니다. 참조 자료형 변수는 객체 '자체'를 저장하지 않습니다. 대신, **힙(Heap)** 메모리 영역에 생성된 객체의 '주소 값(메모리 주소)'을 저장합니다.

final 키워드를 참조 자료형 변수에 사용하면, **'변수에 저장된 참조 값(주소)이 불변'**이라는 의미가 됩니다. 즉, 변수가 한 번 특정 객체를 가리키게 되면, 다른 객체를 가리키도록 변경할 수 없습니다. 하지만, 그 변수가 가리키고 있는 객체의 **내부 상태(필드 값)는 변경 가능합니다.**

이것이 `final`의 가장 흔한 오해 지점입니다. 메모리 관점에서 자세히 살펴보겠습니다.


class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{name='" + name + "', age=" + age + '}';
    }
}

public class FinalReferenceExample {
    public static void main(String[] args) {
        // final 참조 변수 선언 및 User 객체 할당
        final User adminUser = new User("Alice", 30);
        
        // 메모리 관점 설명:
        // 1. [Heap] : new User("Alice", 30) 객체가 생성됩니다. (가령, 주소: 0x100)
        // 2. [Stack]: 'adminUser' 변수 공간이 생기고, Heap 객체의 주소 값(0x100)을 저장합니다.
        // 3. 'final' 키워드는 Stack의 'adminUser' 변수가 0x100 이외의 다른 주소를 갖지 못하도록 막습니다.

        System.out.println("초기 상태: " + adminUser); // User{name='Alice', age=30}

        // 1. 참조 대상 변경 시도 (다른 객체로 재할당) -> 컴파일 오류
        // adminUser = new User("Bob", 40); // error: cannot assign a value to final variable 'adminUser'
        // 이 코드는 'adminUser' 변수의 값을 새로운 User 객체의 주소(예: 0x200)로 바꾸려는 시도이므로 final에 의해 금지됩니다.

        // 2. 참조하는 객체의 내부 상태 변경 시도 -> 문제 없이 동작함!
        adminUser.setName("Alice Cooper");
        adminUser.setAge(31);
        
        // 이 코드는 'adminUser' 변수의 값(0x100)을 바꾸는 것이 아니라,
        // 주소 0x100을 따라가서 Heap에 있는 User 객체의 'name'과 'age' 필드를 변경하는 것이므로 허용됩니다.
        System.out.println("상태 변경 후: " + adminUser); // User{name='Alice Cooper', age=31}
    }
}

이처럼 final은 객체의 '내용'까지 얼려버리는 마법의 키워드가 아닙니다. 단지 해당 변수가 평생 한 객체만 바라보도록 '고정'시키는 역할만 할 뿐입니다.

1.3. Java에서 진정한 불변 객체(Immutable Object) 만들기

그렇다면 자바에서 객체의 내용까지 변경 불가능하게 만들려면 어떻게 해야 할까요? 이는 `final` 키워드 하나만으로는 부족하며, 몇 가지 디자인 패턴을 따라야 합니다.

  1. 클래스를 final로 선언: 상속을 통해 불변성이 깨지는 것을 방지합니다.
  2. 모든 필드를 private final로 선언: 외부에서 직접 접근을 막고, 생성 시 한 번만 초기화되도록 강제합니다.
  3. Setter 메소드를 제공하지 않음: 필드 값을 변경할 수 있는 모든 수단을 원천 차단합니다.
  4. 생성자에서 방어적 복사(Defensive Copy): 생성자의 인자로 가변 객체(e.g., Date, List)가 들어온다면, 그 객체의 복사본을 만들어 내부 필드에 할당해야 합니다. 원본 객체에 대한 참조를 유지하면, 외부에서 원본을 변경하여 내부 상태를 바꿀 수 있기 때문입니다.
  5. Getter 메소드에서 방어적 복사: 내부의 가변 객체를 반환할 때도 복사본을 만들어 반환해야 합니다. 그렇지 않으면 Getter를 통해 얻은 참조로 내부 상태를 변경할 수 있습니다.

대표적인 불변 클래스인 String이 바로 이러한 원칙들을 철저히 지키고 있습니다.


// 예시: 불변 클래스 설계
public final class ImmutableUser {
    private final String name;
    private final int age;
    private final List<String> roles; // 가변 객체인 List

    public ImmutableUser(String name, int age, List<String> roles) {
        this.name = name;
        this.age = age;
        // 생성자에서의 방어적 복사
        this.roles = new ArrayList<>(roles); 
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    // Getter에서의 방어적 복사
    public List<String> getRoles() {
        return Collections.unmodifiableList(this.roles); // 수정 불가능한 뷰를 반환하거나
        // return new ArrayList<>(this.roles); // 새로운 복사본을 반환
    }
}

Java 14부터 도입된 레코드(Record)는 이러한 보일러플레이트 코드를 대폭 줄여주며, 간결하게 불변 데이터 객체를 생성할 수 있는 훌륭한 기능을 제공합니다.

2. 자바스크립트(ES6)의 동적인 세계: `let`, `var`, 그리고 `const`

자바스크립트는 본래 동적 타이핑 언어로, 변수 선언이 매우 유연했습니다. ES6(ECMAScript 2015)에서 `let`과 `const`가 도입되면서, 자바와 유사하게 변수의 생명주기와 변경 가능성을 더 엄격하게 제어할 수 있게 되었습니다.

2.1. 변수 선언의 3인방: `var`, `let`, `const` 비교

const를 제대로 이해하려면 먼저 `var`와 `let`과의 차이점을 알아야 합니다.

  • `var`: 함수 스코프(Function Scope)를 가집니다. 호이스팅(hoisting) 시 선언과 `undefined` 초기화가 함께 이루어지며, 재선언 및 재할당이 모두 가능합니다. 현대 자바스크립트에서는 예기치 않은 동작을 유발할 수 있어 사용을 지양합니다.
  • `let`: 블록 스코프(Block Scope, {...})를 가집니다. 호이스팅은 되지만, TDZ(Temporal Dead Zone)로 인해 선언 전에 접근하면 참조 에러(ReferenceError)가 발생합니다. 재할당은 가능하지만, 동일 스코프 내에서 재선언은 불가능합니다.
  • `const`: `let`과 마찬가지로 블록 스코프와 TDZ를 가집니다. 가장 큰 특징은 선언과 동시에 초기화해야 하며, 재할당이 절대 불가능하다는 점입니다.

이 '재할당 불가'라는 특징 때문에 const가 '상수'를 선언하는 키워드로 불립니다.


// let: 재할당 가능
let score = 80;
console.log(score); // 80
score = 95;
console.log(score); // 95

// let let = 'error'; // SyntaxError: let is disallowed as a lexically bound name

// const: 재할당 불가능
const API_KEY = "xyz-123-abc";
console.log(API_KEY); // "xyz-123-abc"

// API_KEY = "new-key"; // TypeError: Assignment to constant variable.

// const MAX_CONNECTIONS; // SyntaxError: Missing initializer in const declaration

2.2. `const`와 참조 자료형: 자바 `final`과의 평행이론

여기서 자바의 `final`과 놀랍도록 유사한 상황이 펼쳐집니다. const 역시 자바의 `final`처럼 변수가 가리키는 **메모리 주소의 불변성**을 보장할 뿐, 그 주소에 위치한 객체나 배열의 **내부 내용까지는 보호하지 않습니다.**

자바스크립트의 객체(Object)나 배열(Array)도 참조 자료형입니다. 변수에는 힙 메모리에 생성된 실제 데이터의 참조(주소)가 저장됩니다.


// const와 객체(Object)
const user = {
    name: 'John Doe',
    email: 'john.doe@example.com'
};

// 메모리 관점 설명:
// 1. [Heap]: { name: ..., email: ... } 객체가 생성됩니다. (가령, 주소: 0x500)
// 2. [Stack or Lexical Environment]: 'user' 식별자가 생기고, Heap 객체의 주소 값(0x500)을 바인딩합니다.
// 3. 'const'는 'user' 식별자가 0x500 이외의 다른 주소를 가리키지 못하도록 막습니다.

// 1. 다른 객체로 재할당 시도 -> TypeError 발생
// user = { name: 'Jane Doe' }; // TypeError: Assignment to constant variable.

// 2. 객체의 속성(property) 변경/추가/삭제 -> 문제 없이 동작함!
user.email = 'j.doe@newdomain.com'; // 속성 값 변경
user.age = 42; // 새로운 속성 추가
delete user.name; // 속성 삭제

console.log(user); // { email: 'j.doe@newdomain.com', age: 42 }

// const와 배열(Array)
const fruits = ['apple', 'banana', 'cherry'];
// fruits 변수는 Heap에 있는 배열 객체의 주소를 가리킵니다.

// 배열의 요소를 변경하거나 추가/삭제하는 것은 모두 가능합니다.
fruits[0] = 'apricot';
fruits.push('durian');
const removed = fruits.pop();

console.log(fruits); // ['apricot', 'banana', 'cherry']

이러한 동작 방식은 `const`를 사용했음에도 불구하고 객체나 배열의 상태가 예기치 않게 변경될 수 있음을 의미합니다. 특히 여러 모듈에서 동일한 `const` 객체를 공유할 때, 한 곳에서의 수정이 다른 모든 곳에 영향을 미치는 심각한 버그를 초래할 수 있습니다.

2.3. JavaScript에서 깊은 불변성(Deep Immutability) 확보하기

자바스크립트에서 객체의 내용을 '얼리기' 위해 내장된 몇 가지 도구가 있습니다. 그중 가장 강력한 것이 Object.freeze()입니다.

Object.freeze(): 얕은 동결(Shallow Freeze)

Object.freeze() 메소드는 객체를 동결합니다. 동결된 객체는 더 이상 새로운 속성을 추가하거나, 기존 속성을 제거하거나, 속성의 값, 열거 가능성(enumerable), 구성 가능성(configurable), 쓰기 가능성(writable)을 변경할 수 없습니다. 즉, 객체를 읽기 전용으로 만듭니다.


const config = {
    host: 'localhost',
    port: 3000
};

Object.freeze(config);

config.port = 5000; // 변경 시도 (엄격 모드에서는 TypeError, 비엄격 모드에서는 조용히 무시됨)
console.log(config.port); // 3000 (값이 변경되지 않음)

config.timeout = 1000; // 추가 시도 (무시됨)
console.log(config.timeout); // undefined

하지만 Object.freeze()에도 치명적인 한계가 있습니다. 바로 **얕은(Shallow)** 동결만 수행한다는 점입니다. 만약 객체의 속성 값이 또 다른 객체(중첩 객체)나 배열이라면, 상위 객체만 동결되고 내부의 객체나 배열은 여전히 변경 가능합니다.


const settings = {
    user: {
        name: 'Alex',
        permissions: ['read', 'write']
    },
    theme: 'dark'
};

Object.freeze(settings);

// settings.theme = 'light'; // (X) 불가능. 최상위 속성 변경 불가.
// settings.user = { name: 'Bob' }; // (X) 불가능. user 속성에 다른 객체 할당 불가.

// 하지만, 중첩된 객체의 속성은 변경 가능합니다!
settings.user.name = 'Alexander'; // (O) 가능
settings.user.permissions.push('execute'); // (O) 가능

console.log(settings.user.name); // 'Alexander'
console.log(settings.user.permissions); // ['read', 'write', 'execute']

완벽한 불변성을 위해서는 모든 중첩된 객체와 배열에 대해 재귀적으로 `Object.freeze()`를 호출하는 함수를 직접 만들어야 합니다.

불변성 헬퍼 라이브러리: Immer 와 Immutable.js

실무에서는 매번 깊은 복사나 깊은 동결을 직접 구현하는 것이 번거롭고 비효율적일 수 있습니다. 이 때문에 불변성을 쉽게 다룰 수 있도록 도와주는 전문 라이브러리가 널리 사용됩니다.

  • Immutable.js: Facebook에서 개발했으며, `List`, `Map`, `Set` 등 자체적인 불변 자료구조를 제공합니다. 한 번 생성되면 절대 변하지 않으며, 변경이 필요할 때는 항상 새로운 버전의 데이터를 반환합니다. 구조적 공유(Structural Sharing)를 통해 성능을 최적화합니다.
  • Immer: 좀 더 현대적이고 사용하기 쉬운 접근 방식을 취합니다. 개발자는 일반적인 자바스크립트 객체를 직접 수정하는 것처럼 코드를 작성하면(예: `draftState.user.name = 'new name'`), Immer가 내부적으로 변경 사항을 추적하여 최소한의 변경이 적용된 새로운 불변 객체를 생성해 줍니다. 개발 편의성과 성능 사이의 훌륭한 균형을 제공합니다.

3. 불변성을 추구해야 하는 이유: 견고한 소프트웨어의 초석

이처럼 `final`과 `const`를 넘어서 진정한 불변성을 달성하기 위해 노력하는 이유는 무엇일까요? 불변성이 가져다주는 이점은 명확하고 강력합니다.

  1. 예측 가능성 향상 및 버그 감소: 데이터가 한 번 생성된 후 변하지 않는다면, 그 데이터를 사용하는 모든 함수와 로직의 동작을 예측하기 매우 쉬워집니다. "언제 어디서 이 객체의 값이 바뀌었지?"를 추적하며 디버깅하는 고통스러운 시간을 줄일 수 있습니다. 이는 순수 함수(Pure Function) 개념과 직결되며, 코드의 신뢰도를 크게 높입니다.
  2. 상태 관리의 단순화: React, Redux, Vuex와 같은 현대 프론트엔드 프레임워크의 상태 관리는 불변성을 핵심 원칙으로 삼습니다. 상태가 변경될 때마다 새로운 객체를 생성하면, 이전 상태와 현재 상태를 단순히 참조 비교(===)만으로 빠르고 효율적으로 감지할 수 있습니다. 이는 복잡한 애플리케이션의 렌더링 성능을 최적화하는 데 결정적인 역할을 합니다.
  3. 안전한 동시성 프로그래밍: 멀티스레드 환경(자바)이나 비동기 로직(자바스크립트)에서 여러 주체가 동일한 데이터를 동시에 수정하려고 할 때 경합 조건(Race Condition)과 같은 심각한 문제가 발생할 수 있습니다. 데이터가 불변이라면, '읽기'만 가능하므로 락(Lock) 없이도 안전하게 여러 스레드나 비동기 작업에서 데이터를 공유할 수 있습니다.
  4. 시간 여행 디버깅 및 캐싱: 상태의 모든 변화가 새로운 불변 데이터의 생성으로 이어진다면, 애플리케이션의 모든 상태 변화를 순차적인 기록으로 남길 수 있습니다. 이는 Redux DevTools에서 보여주는 '시간 여행 디버깅(Time-travel Debugging)'을 가능하게 합니다. 또한, 입력값이 불변일 때 출력값도 항상 같으므로(순수 함수), 결과를 캐싱(메모이제이션)하여 성능을 높이기 용이합니다.

4. 결론: `final`과 `const`를 올바르게 이해하고 사용하기

자바의 final과 자바스크립트의 const는 많은 개발자들이 생각하는 것처럼 만능 '불변' 키워드가 아닙니다. 이 둘의 공통적인 본질은 **'변수와 메모리 주소 간의 연결(바인딩)을 고정'**시키는 데 있습니다. 즉, 변수가 다른 대상을 가리키지 못하도록 막을 뿐, 변수가 가리키는 대상(객체, 배열)의 내부 상태 변화까지는 막지 못합니다.

이 미묘하지만 결정적인 차이를 이해하는 것이 중요합니다.

  • 기본 자료형에 사용할 때는 우리가 기대하는 '상수'로서의 역할을 충실히 수행합니다.
  • 참조 자료형에 사용할 때는 '재할당 방지'의 의미로 받아들이고, 객체 내부의 값은 언제든 변할 수 있다는 사실을 항상 인지해야 합니다.

따라서, 애플리케이션의 핵심 데이터나 여러 곳에서 공유되는 상태에 진정한 불변성이 필요하다면, 단순히 `final`이나 `const`를 사용하는 것을 넘어, 언어가 제공하는 추가적인 기능(자바의 레코드, 방어적 복사 / 자바스크립트의 `Object.freeze()`)이나 설계 패턴, 그리고 전문 라이브러리(Immer, Immutable.js 등)의 도입을 적극적으로 고려해야 합니다.

finalconst를 맹신하는 대신 그 한계를 명확히 인식하고, 불변성이라는 디자인 패턴을 상황에 맞게 적용하는 지혜가 더 안정적이고, 유지보수하기 쉬우며, 예측 가능한 코드를 만드는 핵심 열쇠가 될 것입니다.


0 개의 댓글:

Post a Comment