[Dart & Flutter] 개발자를 위한 프로세스 메모리 구조 이해

2025. 5. 7. 14:09Dart & Flutter

Flutter 앱을 개발하다 보면 메모리 관련 이슈나 성능 튜닝이 필요할 때가 있어요. 이럴 때 가장 기본이 되는 개념이 바로 프로세스 메모리 구조입니다.

이번 포스트에서는 Dart와 Flutter 관점에서 CODE, DATA, STACK, HEAP 영역과 PC(Program Counter), NULL, Garbage Collector, Stack Overflow까지 자세히 정리해보겠습니다.


우리가 만든 앱이 실행되면, 컴퓨터나 스마트폰의 운영체제가 메모리 공간을 구역별로 나눠서 앱에게 줘요. 이걸 프로세스(Process)의 메모리 구조라고 불러요.

 

보통은 이렇게 4가지 주요 영역으로 나뉘어요:

영역 이름 역할
CODE(TEXT) 영역 앱의 코드(함수, 로직 등)가 저장됨
DATA 영역 앱의 전역 변수, 상수 등 데이터 저장
STACK 영역 함수 호출 시 사용되는 임시 데이터 저장
HEAP 영역 동적으로 생성되는 객체들이 저장됨

1. CODE 영역 (TEXT Segment)

Flutter 앱을 만들 때 Dart 언어로 코드를 작성하죠. 그런데 우리가 작성한 이 코드들은 실행될 때 어디에 저장될까요?

바로 CODE 영역, 또는 TEXT 영역이라고 불리는 메모리 공간이에요.

 

CODE 영역은 프로그램의 실행 가능한 코드(기계어로 변환된 함수와 로직들)가 저장되는 메모리 영역이에요.

여기에는 우리가 Dart로 작성한 함수, 클래스 메서드, 조건문, 반복문 같은 로직이 전부 포함돼요.

 

즉, “어떻게 동작할지를 정의한 로직”들이 담긴 공간이 바로 CODE 영역입니다.


CODE 영역의 주요 특징

  • 읽기 전용: 실행 중 변경할 수 없어요 (보안 & 안정성 이유)
  • 프로그램 시작 시 로딩: 앱 실행 시 가장 먼저 메모리에 올라와요
  • 모든 함수와 로직이 포함됨: 우리가 작성한 Dart 코드의 핵심 실행 부분이에요

Dart 코드로 예시를 들어보겠습니다.

void greet() {
  print("Hello, Flutter!");
}

void main() {
  greet();
}

 

위 코드를 실행하면 다음과 같은 일들이 일어나요.

 

1. main() 함수와 greet() 함수는 Dart 컴파일러에 의해 기계어 코드로 변환돼요.

2. 이 변환된 함수들은 앱 실행 시 CODE 영역에 올라가요.

3. main()이 호출되면, CODE 영역에서 해당 함수의 주소로 Program Counter(PC)가 이동해 실행을 시작해요.

4. greet() 함수도 CODE 영역에 저장돼 있기 때문에, main()에서 호출되면 그 위치로 점프해 실행돼요.

 

이처럼, Dart 코드의 실행 가능한 부분(함수, 메서드)은 전부 CODE 영역에 저장 됩니다.

 

CODE 영역이 읽기 전용인 이유는 보안을 위해서입니다.

앱 실행 중 누군가가 코드 자체를 바꿀 수 있으면 위험하겠죠? 그래서 운영체제는 이 영역을 변경하지 못하게 막아요.

또, 수정되지 않기 때문에 캐시에도 잘 올라가고, 실행 속도도 빨라져요.


Dart에서 CODE 영역에 저장되는 요소

  • void main() { ... } : 앱 실행 진입점 
  • void greet() { ... } : 함수도 전부 저장됨
  • 클래스의 메서드 : 예 메서드는 로직이므로 저장
  • 변수 선언 (var name = "SungEun";) : 값은 CODE 영역이 아닌 HEAP이나 DATA 영역에 저장
  • 실행 중 생성된 함수 (Function) : 로직이므로 저장됨, 단 익명 함수의 경우 관리 방식 다를 수 있음

class User {
  String name;

  User(this.name);

  void introduce() {
    print("Hi, I'm $name!");
  }
}

void main() {
  final user = User("성은");
  user.introduce();
}

 

CODE 영역에 저장되는 요소

 

  • main() 함수 → ✅ CODE 영역
  • User 클래스의 introduce() 메서드 → ✅ CODE 영역
  • User("성은")으로 만들어진 객체 → ❌ HEAP 영역 (동적 생성)
  • user 변수 → ❌ STACK 또는 HEAP (상황에 따라)

 

즉, 함수나 메서드, 클래스 로직 자체는 CODE 영역에,

객체나 변수의 실제 데이터는 다른 영역(HEAP, STACK, DATA 등)에 저장돼요.


CODE 영역은 앱이 제대로 동작하려면 가장 먼저 메모리에 올라가야 할 핵심 로직 저장소예요.

 

만약 CODE 영역에 잘못된 코드가 있다면?

앱 실행이 실패하거나, 런타임 에러가 발생하거나, 심지어 보안에 취약해질 수 있어요

 

그래서 개발 시에도, 최적화 도구나 성능 분석 도구들이 어느 함수에서 병목이 생겼는지를 분석할 때 CODE 영역을 기반으로 분석해요.


CODE 영역 정리

  • CODE(TEXT) 영역은 앱이 실행할 로직이 저장된 공간
  • Dart의 모든 함수, 메서드, 클래스 로직이 여기에 포함됨
  • 읽기 전용이라 안전하고 빠름
  • 실제 데이터는 CODE 영역이 아닌 다른 메모리 공간에 저장됨

Flutter 앱을 만드는 우리는 결국 이 CODE 영역을 채우는 사람이에요.

어떻게 설계하고, 얼마나 효율적으로 작성하는지가 앱의 성능을 결정하기도 합니다


2. DATA 영역

Flutter 앱을 개발할 때 Dart 코드 안에서 변수를 선언하죠.

그런데 모든 변수가 똑같은 메모리에 저장될까요?

 

그건 아니에요.

어떤 변수는 실행되기 전부터 메모리에 존재하고, 어떤 변수는 실행 도중에 메모리에 올라와요.

 

이번에는 그 중에서도 “실행 전부터 존재하는 정적 데이터”, 즉 DATA 영역에 대해 다뤄보도록 하겠습니다.


DATA 영역은 Dart 프로그램이 실행되기 이전부터 메모리에 올라와 있는 전역 변수, static 변수 등의 값을 저장하는 공간이에요.

 

정확히는

프로그램 실행 시점에 이미 메모리에 올라가 있는 데이터

수정 가능한 초기값을 가진 변수들이 저장됨

 

즉, 우리가 “처음부터 가지고 있는 변수”, 그리고 프로그램이 꺼질 때까지 쭉 유지되는 변수가 저장되는 곳이에요.


DATA 영역의 주요 특징

  • 정적(static) 저장소: 실행 중 계속 유지됨
  • 수정 가능: 초기값이 있어도 변경 가능함
  • 프로그램 시작 시 로딩: 앱 실행과 동시에 메모리에 올라감
  • 주로 전역 변수, static 변수 등 저장: 지역 변수는 여기에 저장되지 않음

DART 코드로 예시를 들어보겠습니다.

String globalMessage = "Hello from global!";
int counter = 0;

void increaseCounter() {
  counter += 1;
  print("Counter: $counter");
}

void main() {
  increaseCounter(); // Counter: 1
  increaseCounter(); // Counter: 2
}

 

DATA 영역에 저장되는 요소

  • globalMessage ✅ DATA 영역 : 전역 변수, 실행 전부터 존재
  • counter  ✅ DATA 영역 : 전역 변수
  • increaseCounter() 함수  ❌ CODE 영역 : 실행 로직은 CODE에 저장
  • counter += 1의 계산 결과  ❌ HEAP or 레지스터에서 처리

즉, 전역 변수는 앱이 실행되기 전부터 DATA 영역에 존재하며, 실행 중에도 값이 바뀌면서 살아 있어요.


Dart에서는 함수나 클래스 내부에 static 키워드를 사용할 수 있습니다.

class MyClass {
  static int count = 0;

  void increment() {
    count++;
  }
}

 

위 코드에서 count는 어떤 공간에 저장될까요?

 

countDATA 영역에 저장됩니다.

왜냐하면 static 변수는 클래스 인스턴스와 무관하게 딱 한 번만 메모리에 올라가고, 프로그램 종료 전까지 계속 유지되기 때문이에요.


Dart에서 DATA 영역에 저장되는 요소

  • 전역 변수 → ✅ 예 : 실행 전부터 메모리에 존재
  • static 변수 → ✅ 예 : 정적으로 한 번만 할당됨
  • const 변수 → ❌ 아니요 : 컴파일 타임 상수는 별도로 관리됨 (컴파일된 코드에 포함됨)
  • final 변수 (지역) → ❌ 아니요 : 실행 중에 생성되면 STACK/HEAP
  • 함수 내 지역 변수 → ❌ 아니요 : STACK 영역 사용

 

그렇다면 DATA 영역은 언제 사라질까요?

 

DATA 영역은 앱이 종료될 때까지 유지돼요.

그렇기 때문에 전역 상태를 저장하거나, 설정을 기억하는 데 사용돼요.

 

하지만 이 말은 곧 메모리를 계속 점유한다는 뜻이기도 해요.

그래서 DATA 영역을 너무 많이 쓰면 앱이 무거워질 수 있어요.


DATA 영역 사용 시에 주의할 점

Flutter 앱은 가능한 한 전역 변수나 static 상태를 최소화하는 것이 좋다고 권장돼요.

왜냐하면 상태 관리가 어려워지고, 메모리 사용이 예측 불가해질 수 있기 때문이에요.

전역 상태가 꼭 필요하다면 Provider, Riverpod, GetX 같은 상태관리 라이브러리로 잘 감싸주는 게 좋아요!


DATA 영역 정리

  • 정의: 프로그램 시작과 동시에 메모리에 올라가고 종료 시까지 유지되는 공간
  • 저장되는 것: 전역 변수, static 변수
  • 삭제 시점: 프로그램 종료 시
  • Dart 예시: static int count, String globalMessage 등
  • 주의: 너무 많이 쓰면 메모리 낭비 & 상태 관리 어려움

3. STACK 영역

STACK 영역은 프로그램이 함수를 호출하거나 지역 변수를 사용할 때 임시로 사용하는 메모리 공간이에요.

즉, Dart에서 함수가 실행되면 그 함수만을 위한 메모리 공간이 stack에 잠깐 만들어지는 거예요.


STACK 영역의 주요 특징

  • 임시 저장소: 함수 실행 중 생성되는 변수, 파라미터가 저장됨
  • 선입후출(LIFO): 나중에 호출된 함수가 먼저 끝나야 함
  • 자동 메모리 해제: 함수가 끝나면 자동으로 메모리에서 제거
  • 빠름: 메모리 접근 속도가 매우 빠름
  • 지역 변수 저장소: 함수 안에서 선언된 변수는 stack에 저장됨

DART 코드로 예시를 들어보겠습니다.

void greet(String name) {
  String message = "Hello, $name!";
  print(message);
}

void main() {
  greet("성은");
}

 

실행 흐름 & STACK 사용

  1. main() 함수가 호출되면서 STACK에 main의 프레임이 생성
    - 아직은 아무 함수도 호출되지 않음
  2. greet("성은")이 호출됨
    - STACK에 greet 함수의 프레임이 위에 생성됨
    - name, message 같은 지역 변수들이 이 공간에 저장됨
  3. print(message) 실행 후 greet 함수가 종료되면
    greet 함수의 stack 프레임은 자동으로 제거
  4. 다시 main() 함수만 남음

즉, 함수가 끝나는 순간, 해당 함수에서 사용하던 메모리는 자동으로 정리돼요! 이것이 바로 STACK의 강력한 특징이에요.


STACK 구조 다이어그램 (간단한 그림 설명)

[Top of Stack]
|  greet() 함수의 name, message  | ← 함수 호출 시 생성
|-------------------------------|
|  main() 함수의 지역 변수들        |
|-------------------------------|
[Bottom of Stack]

 

greet()가 끝나면 가장 위의 공간은 바로 제거됨

다음 함수가 호출되면 그 위에 새로운 공간이 계속 쌓임


Stack 영역의 한계: Stack Overflow

 

함수를 너무 많이 호출하거나 재귀(recursion)를 과도하게 사용할 경우에는 Stack Overflow 에러가 발생해요.

즉, STACK 메모리가 넘쳐버리는 게 됩니다.

 

아래는 예시 코드입니다.

int recursive() {
  return recursive() + 1;
}

void main() {
  recursive(); // 무한 재귀 → Stack Overflow 발생
}

 

이 경우 함수가 끝나지 않고 계속해서 stack에 쌓이기만 하니까, 결국 메모리를 다 써버려요.


STACK 영역 정리

  • 저장되는 것: 함수 호출 정보, 매개변수, 지역 변수
  • 구조: LIFO (Last-In-First-Out)
  • 생성/해제 시점: 함수 호출 시 생성, 함수 종료 시 자동 해제
  • Dart 예시: 모든 함수 호출과 지역 변수 처리
  • 주의사항: 재귀 무한 루프 → Stack Overflow 발생 가능

Flutter 앱은 위젯 트리 구조도 깊고, 콜백 함수나 이벤트 핸들러도 많습니다.

이런 구조에서 함수 호출이 계속 일어나는 만큼, STACK의 흐름을 잘 이해하는 게 중요합니다.

 

STACK은 우리가 호출하는 모든 함수의 순서와 임시 데이터를 보관해주는 안전망이지만, 너무 남용하면 앱이 터질 수도 있으니… 적절히 써야해요. ㅎㅎ


4. HEAP 영역

HEAP 영역은 프로그램 실행 중 동적으로 생성되는 데이터가 저장되는 공간입니다.

예를 들어, 리스트, 맵, 클래스 인스턴스 같은 객체들은 HEAP에 저장돼요.

 

이 영역은 STACK과 달리

크기가 크고

수명이 길며

자동으로 해제되지 않고

Dart에서는 Garbage Collector가 해제해줘요.


HEAP 영역의 주요 특징

  • 동적 메모리 공간: 실행 중에 메모리를 할당함
  • 큰 데이터 저장소: 객체, 리스트, 맵 등 저장
  • 자동 해제 X: GC가 판단해서 해제
  • STACK보다 느림: 하지만 더 유연함
  • 모든 객체는 Heap에: Dart에서는 new 없이도 객체는 무조건 Heap에 저장

 

Dart 코드로 예시를 들어볼게요.

class User {
  String name;

  User(this.name);
}

void main() {
  User user = User("성은");
  print(user.name);
}

 

이 코드에서:

User("성은") 이라는 객체HEAP에 저장

user라는 변수는 STACK에 저장되지만, HEAP에 있는 객체를 참조

 

Dart에선 객체가 만들어지는 순간 Heap 메모리에 저장되고, 변수는 그 주소값(참조)를 가지고 있는 구조예요.

 

실행 흐름 & HEAP 사용

  1. User("성은") 객체 생성 → HEAP에 메모리 공간이 확보
  2. user 변수는 STACK에 저장되며, HEAP의 객체 주소를 가리킴
  3. print(user.name) → HEAP에서 객체를 읽어 출력
  4. 프로그램 종료 후, Garbage Collector가 더 이상 참조되지 않는 HEAP 데이터를 자동 정리

HEAP 구조 다이어그램 (간단한 설명)

[STACK]                         [HEAP]
|--------------------------|    |--------------------------|
| user (주소값 0xA1B2)     | →  | User 객체(name: 성은)    |
|--------------------------|    |--------------------------|

 

STACK에는 user 변수만 있고

진짜 데이터(User 객체)는 HEAP에 있음

HEAP에는 여러 객체가 자유롭게 저장됨


Dart에서 HEAP에 저장되는 요소

예시 코드 HEAP 저장 데이터 
List<int> a = [1, 2, 3]; 리스트 [1, 2, 3]
String name = "성은"; 문자열 "성은"
Map data = {"age": 22}; 맵 객체
User u = User("성은"); 클래스 인스턴스

 

변수 이름 자체는 STACK에, 변수 값(객체)은 HEAP에 저장됨


HEAP은 자동 해제가 되지 않기 때문에, Garbage Collector (GC)가 불필요한 객체를 자동으로 제거해요.

 

GC는 다음과 같이 작동합니다.

1. 현재 사용 중인 변수들이 참조하고 있는 HEAP 객체들을 추적

2. 어느 누구도 참조하지 않는 HEAP 객체를 찾아서

3. 자동으로 메모리를 해제

void main() {
  var a = List.generate(1000, (i) => i); // HEAP에 저장
  a = null; // 더 이상 참조하지 않음 → GC 대상
}

a = null; 처럼 변수 참조가 끊기면, 리스트는 GC 대상이 돼요.


STACK vs HEAP

항목  STACK  HEAP
저장 위치 함수, 지역 변수 객체, 리스트, 클래스
목적  함수 실행, 임시 변수 저장  동적 메모리 할당
할당 시점  함수 호출 시 자동  명시적으로 new, List 등
해제  함수 종료 시 자동 해제  가비지 컬렉터가 해제
해체 방식 함수 종료 시 자동 해제 GC가 판단해서 자동 해제
속도  빠름  느림
메모리 크기 작음

HEAP 영역 정리

  • 저장되는 것: 리스트, 객체, 맵, 문자열 등
  • 해제 방식: Garbage Collector가 자동으로 판단하여 해제
  • Dart에서의 특징: 모든 객체는 무조건 HEAP에 저장됨
  • 장점: 크고 유연한 저장 공간
  • 주의사항: 불필요한 객체를 너무 많이 생성하면 메모리 부족 가능

HEAP은 앱이 동작하면서 만들어지는 진짜 앱의 데이터 저장소라고 볼 수 있어요.

Flutter 앱에서 수많은 위젯, 사용자 정보, 상태 객체, 리스트 등은 다 이곳에 있습니다.

 

즉, 우리가 Flutter 앱에서 사용자 입력을 받아 처리하고 상태를 유지하고 데이터를 보여줄 수 있는 건 모두 HEAP이 있기 때문이에요.


5. PC (Program Counter)

Program Counter (PC)는 CPU가 다음에 실행할 명령어의 메모리 주소를 저장하는 레지스터예요.

 

즉, 프로그램이 실행될 때 “다음에 뭘 해야 하지?” 를 기억하고 있는 역할이죠.


PC의 주요 특징

  • PC: Program Counter
  • 역할: 다음 실행할 코드의 주소를 저장
  • 형태: CPU 내부의 작은 저장 공간 (레지스터)
  • 주소 단위: 보통 4 Byte 단위로 증가 (Dart 추상화됨)
  • 언어 레벨: Dart에서는 직접 다루지 않지만, 내부 실행 흐름에서 존재함

Dart 코드로 예시를 들어볼게요.

void main() {
  int a = 5;
  int b = 3;
  int sum = a + b;
  print("합은 $sum");
}

 

이 코드가 실행될 때, 내부적으로는 다음과 같이 동작합니다.

단계  PC가 가리키는 명령  설명
1  int a = 5;  PC는 이 줄을 가리킴
2  int b = 3; PC가 다음 명령으로 이동
3  int sum = a + b;  다음 연산 수행
4  print(...)  출력 수행
5  main() 종료  실행 완료, PC는 종료 플래그로 이동

 

PC는 한 줄씩 명령을 실행하고 다음 줄로 이동하며 프로그램을 끝까지 수행합니다.

 

PC는 프로그램이 실행될 때 다음 흐름대로 작동해요:

  1. main() 함수의 첫 명령어 주소로 설정됨
  2. 명령을 하나 실행하고 → PC + 1
  3. 조건문, 반복문, 함수 호출 등 분기 발생 시 → PC는 해당 위치로 점프
  4. 함수가 끝나면 → 호출한 위치의 다음 명령어로 돌아옴 (call stack 이용)

Dart 예제로 점프 구조 살펴보기

void greet() {
  print("Hello!");
}

void main() {
  print("Start");
  greet();
  print("End");
}
단계  PC 위치  설명
1  print("Start")  시작
2  greet(); → 점프  함수 호출 → greet()의 첫 줄로 점프
3  print("Hello!")  greet 내부
4  main() 복귀 → print("End")  다시 main의 다음 줄 실행

 

함수 호출이 일어나면 PC는 해당 함수로 이동하고, 끝나면 원래 위치로 복귀해요.


PC와 Flutter의 연관성

 

Flutter 앱에서도, 실행 흐름을 따라 PC는 Widget 트리 빌드 → 상태 업데이트 → setState() → 다시 빌드 같은 흐름을 따라 이동해요.

@override
Widget build(BuildContext context) {
  return Column(
    children: [
      Text("Hello"),
      ElevatedButton(
        onPressed: () {
          setState(() {
            // PC는 이 부분을 다시 실행하게 됨
          });
        },
        child: Text("클릭"),
      ),
    ],
  );
}

 

사용자가 버튼을 누르면 → setState() 호출

PC는 build() 함수를 다시 실행

즉, PC는 Flutter에서 UI 변경 흐름을 컨트롤하는 핵심입니다.

 

Dart나 Flutter에서는 PC를 직접 조작할 수는 없지만, 내부에서 모든 실행 흐름은 PC가 관리하고 있어요.

이는 애플리케이션이 정확한 순서로 실행될 수 있도록 보장해주는 시스템이죠.


PC 정리

  • 위치: CPU 내부 (레지스터)
  • 역할: 다음 실행할 명령어의 주소를 기억
  • 동작 방식: 명령 실행 후 PC는 다음 주소로 증가
  • 함수 호출 시: PC는 해당 함수의 시작 주소로 이동
  • Flutter와의 관계: setState, build 등 모든 흐름은 PC의 이동으로 구현됨

우리가 코드를 한 줄 한 줄 쓰면, Dart와 Flutter는 그 모든 흐름을 PC를 통해 정확히 순서대로 실행해줘요.

즉, Flutter 앱도 결국 “PC가 뭘 가리키냐”에 따라 동작이 바뀌는 시스템이라는 점 기억해야 합니다.


6. NULL

NULL은 “아직 어떤 값도 저장되어 있지 않다”는 걸 의미해요.

즉, 변수는 선언됐지만 값은 비어 있음을 나타내는 상태예요.

 

쉽게 말하면

“값이 없다” → null

“아직 뭘 저장할지 정하지 않았다” → null

“주소를 가리키고 있지만, 실제 값은 없다” → null


Dart는 기본적으로 null 안전(null safety) 을 제공하는 언어예요.

String name = null; // ❌ 오류! null을 넣을 수 없음

 

하지만 이렇게 하면 허용돼요:

String? name = null; // ✅ 가능. null을 허용하는 nullable 타입

 

?가 붙으면:

변수에 null을 넣을 수 있음

타입은 “nullable”이 됨

 

아래는 null 허용과 비허용을 비교하는 예제입니다:

String greeting = "Hello"; // null 불가
String? maybeGreeting = null; // null 가능

 

nullable 변수는 사용 전에 null 여부 확인이 필요해요.

if (maybeGreeting != null) {
  print(maybeGreeting.length); // 안전!
}

null safetynull로 인해 생기는 오류를 방지하는 기능이에요.

 

Dart에서는 null safety가 기본적으로 활성화돼 있어, null로 인한 런타임 오류를 줄일 수 있어요.

만약 null 체크 없이 사용하면 아래와 같이 오류가 발생합니다:

String? name = null;
print(name.length); // ❌ 오류! null의 length는 없음

Dart에서 null을 안전하게 다루는 방법

방법  설명  예시
?  nullable 선언  String? name;
!  null 아님을 확신할 때  name!.length
??  null이면 기본값 제공 name ?? "Default"
??=  null일 때만 값 대입  name ??= "Default"
if문 null 여부 직접 체크  if (name != null) {}

 

Flutter UI를 구성하다 보면, 많은 데이터가 네트워크 요청 이후에 도착해요.

초기에는 null일 수밖에 없어요.

 

예시: 비동기 데이터 처리

String? userName;

@override
Widget build(BuildContext context) {
  return Text(userName ?? "로딩 중...");
}

 

아래와 같이 표현할 수도 있습니다.

if (userName != null) {
  return Text(userName!);
} else {
  return CircularProgressIndicator();
}

null 값을 제대로 다루지 않으면 이런 에러가 생길 수 있어요:

Null check operator used on a null value

 

! 연산자를 썼지만 실제로 값이 null이면 런타임에 앱이 터집니다.

그래서 항상 null 여부를 먼저 확인하는 습관이 중요해요


NULL이 필요한 이유

  1. 값이 아직 없을 수도 있어서
    - 네트워크 요청 결과 등은 나중에 도착
  2. 상태를 표현할 수 있어서
    - 초기 상태: null / 로딩 중 / 완료 등 구분 가능
  3. 선택적 값을 가질 수 있어서
    - 옵션 설정 등 유연한 표현 가능

NULL 정리

  • NULL: “값이 없음”을 나타냄
  • Dart에서는: nullable 타입(?)으로 명시해야 사용 가능
  • Null Safety: null로 인한 오류를 컴파일 타임에 막아줌
  • Flutter에서는: 초기 데이터, 비동기 처리 등에서 자주 등장
  • 안전한 사용법: ??, !, ??=, if 문 등으로 null 체크 필요

null은 프로그램의 유연성을 높여주지만, 잘못 다루면 앱이 바로 터질 수 있는 위험한 칼날이에요.

Dart의 null safety 기능을 적극 활용해서 안정적인 코드를 작성해야 합니다.


7. Garbage Collector (GC)

Garbage Collector(가비지 컬렉터, 줄여서 GC)는 프로그램이 더 이상 사용하지 않는 메모리를 자동으로 정리해주는 시스템이에요.

우리는 보통 new 키워드나 변수 선언을 통해 메모리를 할당하지만, 그걸 언제 어떻게 해제할지는 GC가 자동으로 처리해줘요.

 

프로그래밍 언어에서 메모리는 한정된 자원이에요.

만약 우리가 직접 할당/해제를 일일이 관리해야 한다면 아래와 같은 문제가 발생해요:

  1. 메모리 누수(leak)가 발생할 수 있음
  2. 이미 해제된 메모리를 접근하면 오류 발생
  3. 앱의 성능이 떨어지고 충돌 가능성 높아짐

그래서 Dart 같은 현대 언어는 GC를 내장하고 있어요.


Dart에서의 Garbage Collection 작동 방식

 

Dart는 자동 메모리 관리를 지원하는 언어예요.

따라서 우리가 var, final, late 등을 통해 생성한 객체는 GC가 알아서 수명 주기를 추적하고 정리해줘요.

 

작동 흐름

  1. 객체가 생성됨 → heap 메모리에 저장됨
  2. 참조(reference)가 존재하는 동안은 유지됨
  3. 참조가 사라지면, 일정 시간이 지난 뒤 GC가 감지
  4. GC가 해당 객체의 메모리를 회수함

예제: 객체가 사라지는 순간

class User {
  String name;
  User(this.name);
}

void main() {
  User? user = User("CSE"); // 객체 생성됨
  user = null; // 참조 제거됨 → GC 대상
}

이 코드에서 user = null이 되는 순간,

“CSE”이라는 이름을 가진 User 객체는 더 이상 참조되지 않음

→ 따라서 GC의 대상이 되는 거예요.

 

GC는 Dart에서 필요할 때마다 자동으로 작동해요. 특히 다음과 같은 경우에 실행 확률이 높아요:

  • 메모리 사용량이 일정 수준 이상 증가했을 때
  • 객체들이 더 이상 참조되지 않을 때
  • Flutter 앱에서 상태 변화나 화면 전환이 자주 일어날 때

GC가 작동하지 않는 경우

 

GC는 누군가 참조 중인 객체는 절대 삭제하지 않아요.

User user1 = User("A");
User user2 = user1;
user1 = null; // 아직 user2가 참조하고 있으므로 GC 대상 아님

 

위의 예제처럼 하나라도 참조가 남아 있다면 GC는 그 객체를 안전하게 유지해줘요.


GC와 메모리 구조

메모리 영역  GC의 대상?  설명
Code (Text)    고정된 코드, GC와 무관
Data    정적 변수 등, 수동 해제 불필요
Stack    함수 호출 시 자동 할당/해제
Heap   객체, 리스트, 클래스 인스턴스 등 동적 메모리 저장소

 

Garbage Collector는 Heap 영역에만 작동합니다.

왜냐면 우리가 만드는 객체나 List 같은 데이터가 여기에 저장되기 때문이에요.


Flutter는 UI를 그릴 때마다 위젯을 새로 만들어서 트리 구조를 재구성해요.

이때 위젯, 상태(State), 컨트롤러, 애니메이션 등 다양한 객체가 생성되고 사라져요.

 

따라서 GC가 없으면 메모리가 계속 쌓여 앱 버벅임, 충돌하지만, GC가 적절히 작동하면 메모리 회수가 원활하여 부드러운 성능

 

성능 최적화를 위해서는 아래와 같은 방법을 사용할 수 있어요:

 

1. 불필요한 참조는 빨리 제거하기

controller.dispose(); // 상태 관리 객체는 명시적으로 제거

 

 

2. StatelessWidget에서 불필요한 객체 생성 자제하기: 빌드마다 새 객체가 생기면 GC 부담이 커져요

3. List, Map 등은 사용 후 clear()로 참조 제거하기


GC 정리

  • Garbage Collector: 사용하지 않는 객체를 자동으로 정리하는 시스템
  • GC의 대상: Heap 메모리 내 객체
  • Dart에서의 작동: 자동 추적 및 회수
  • Flutter에서 중요성: 반복적인 객체 생성을 효율적으로 관리
  • 성능 팁: 불필요한 참조 제거, 객체 재사용 고려

Garbage Collector는 Flutter 앱의 생명줄과 같은 역할을 합니다.

불필요한 객체를 자동으로 치워주는 청소부 역할을 하니까, 우리는 코드만 깔끔히 쓰면 나머지는 GC가 알아서 해결해줘요.


8. Stack Overflow

Stack Overflow는 프로그램 실행 중, 함수 호출 스택(stack)이 너무 커져서 메모리 한도를 초과했을 때 발생하는 오류예요.

 

한마디로 말하면 함수를 너무 많이, 끝없이 호출해서 프로그램이 터지는 것을 Stack Overflow라고 합니다.


위에서 정리했다시피, Stack은 프로세스 메모리 구조 중 하나로 함수 호출 시마다 임시 데이터를 저장하는 메모리 공간이에요.

 

예시로 지역 변수, 함수의 매개변수, 함수가 돌아갈 위치 (return address) 등을 저장합니다.

 

Stack의 특징은 다음과 같아요:

  • 선입후출 구조 (Last-In-First-Out, LIFO)
  • 함수가 호출될 때 스택에 쌓이고
  • 함수 실행이 끝나면 다시 꺼내짐 (pop)

Stack Overflow 발생 예제

 

가장 흔한 경우는 함수의 무한 재귀 호출이에요.

 

예: 재귀 함수에서 종료 조건 누락

void repeat() {
  repeat(); // 종료 조건 없음 → 무한히 호출됨
}

void main() {
  repeat(); // Stack Overflow 발생
}

 

이렇게 되면 repeat() 함수가 끝도 없이 자기 자신을 호출하고, 스택이 계속 쌓이다가 한계 메모리를 넘어서 터지는 거예요.


Dart에서 Stack Overflow 오류 메시지:

Unhandled exception:
Stack Overflow
#0      repeat (file:///main.dart:2:3)
#1      repeat (file:///main.dart:2:3)
...

 

계속 같은 함수가 호출된 로그가 찍히고, 결국 프로그램이 종료돼요.


Stack Overflow는 다음과 같은 이유 때문에 위험해요.

  • 앱이 비정상 종료됨
  • 사용자 경험(UX)에 큰 타격
  • 불필요한 메모리 낭비 발생
  • 디버깅이 어려움

이러한 문제를 예방하려면 다음과 같은 방법을 실천해야 해요.

 

1. 재귀 함수에는 반드시 종료 조건을 명확히 넣기

void countdown(int n) {
  if (n == 0) return;
  print(n);
  countdown(n - 1); // 종료 조건 있음
}

 

재귀 함수는 편리하지만, 종료 조건이 없으면 무한 호출로 인해 Stack Overflow가 발생할 수 있습니다.

 

2. 재귀 대신 반복문으로 대체하기

// 재귀 대신 for문 사용
void countdownIterative(int n) {
  for (int i = n; i > 0; i--) {
    print(i);
  }
}

가능한 경우, 재귀 호출 대신 반복문을 사용하는 것이 스택 메모리 사용을 줄이고 더 안전합니다.

 

3. 깊은 트리 구조나 중첩된 호출 처리 시 주의하기

예를 들어, 트리 탐색 알고리즘이나 복잡한 위젯 중첩 구조를 구현할 때는 재귀 호출의 깊이가 커질 수 있으므로, 종료 조건이 명확한지 꼼꼼히 확인하고 필요하다면 반복문이나 큐/스택 등 다른 방식으로 로직을 바꿔야 합니다.


Flutter에서 Stack Overflow 예시

 

위젯 트리에서 무한 빌드 유발 예:

class InfiniteWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return InfiniteWidget(); // 자기 자신을 계속 반환함
  }
}

 

이렇게 하면 위젯 빌드가 무한 반복되면서 스택이 넘치고 앱이 크래시돼요.


Stack Overflow 디버깅 팁

  • StackTrace 확인하기
  • 함수 호출 깊이를 추적하기
  • 종료 조건이 명확한지 점검
  • 상태 관리에서 setState, build()가 반복 호출되는지 확인

Stack Overflow 정리

  • Stack Overflow: 함수 호출이 지나치게 반복되어 스택 메모리가 초과된 상태
  • 주요 원인: 종료 조건 없는 재귀 호출
  • 예방 방법: 종료 조건 명시, 반복문 사용, 호출 깊이 제한
  • Flutter에서의 주의: 위젯 트리 무한 생성 방지