Android 앱은 운영체제가 관리하는 컴포넌트, 화면을 구성하는 UI 계층, 상태를 관리하는 계층, 데이터를 보존하는 계층으로 나뉜다.

이 개념들은 서로 연결되어 있지만 생명주기와 책임이 다르다.

Android OS
└── Application Process
    ├── Activity / Service / BroadcastReceiver
    └── UI
        └── Composable
            └── ViewModel
                └── Repository
                    ├── Room
                    ├── DataStore
                    └── Server

Android 애플리케이션과 운영체제의 경계

Android 앱은 하나의 main() 함수에서 시작해 내부 코드만 실행하는 일반적인 프로그램과 다르다. Activity, Service 같은 컴포넌트를 Android 운영체제가 필요할 때 생성하고 호출한다.

운영체제가 앱의 구성요소를 알 수 있도록 AndroidManifest.xml에 다음과 같은 정보를 선언한다.

  • 앱의 시작 Activity
  • Service와 BroadcastReceiver
  • 필요한 권한
  • 처리할 수 있는 Intent와 딥링크

앱 프로세스가 생성되면 Application 객체가 만들어진다. Application은 앱 프로세스 전체 범위의 객체지만 영구 저장소는 아니다. 프로세스가 종료되면 Application과 그 안의 값도 모두 사라진다.

Activity와 Window

Activity는 흔히 화면 하나로 설명되지만, 정확히는 Android가 생명주기를 관리하는 UI 실행 단위다.

Activity는 자신에게 주어진 Window에 UI를 배치한다. 앱은 Window의 내용과 속성을 설정하고, 운영체제의 WindowManager는 화면 배치, 포커스, 다른 창과의 순서를 관리한다. Theme은 Window와 UI에 적용되는 기본 디자인 규칙을 제공한다.

Activity
└── Window
    └── 앱의 UI

Activity가 생성됐다고 바로 사용자에게 보이는 것은 아니다. 화면을 준비 중일 수도 있고, 다른 Activity에 가려졌거나 백그라운드로 이동했을 수도 있다. 이런 상태를 표현하기 위해 onCreate, onStart, onResume, onPause, onStop 같은 생명주기가 존재한다.

화면 회전으로 Activity가 재생성되는 것도 이 구조에서 이해할 수 있다. 회전은 화면 방향과 크기 같은 기기 설정이 변경된 사건이다. Android는 변경된 설정에 맞는 리소스와 UI를 적용하기 위해 기존 Activity를 제거하고 새 Activity를 만든다.

Context와 Intent

Context는 앱 코드가 Android 환경에 접근하기 위한 인터페이스다. 리소스를 읽거나, 시스템 서비스를 가져오거나, 다른 컴포넌트를 실행할 때 사용한다. Context 자체가 시스템 함수나 권한인 것은 아니다.

Intent는 Android에 전달하는 실행 요청 또는 메시지다.

특정 Activity 실행
다른 앱에서 URL 열기
Service 시작
외부 링크를 앱의 특정 화면으로 연결

예를 들어 poby://posts/42 같은 링크를 앱의 게시글 화면으로 연결하는 것이 딥링크다. Intent가 Android 시스템을 통해 앱 안팎의 컴포넌트를 연결한다면, Compose Navigation은 주로 현재 앱 프로세스 내부에서 Composable 화면을 전환한다.

Service는 화면 없이 수행되어야 하는 작업을 표현하는 컴포넌트다. 다만 Service가 별도 프로세스나 별도 스레드를 의미하지는 않는다. 기본적으로 Service의 콜백도 Main Thread에서 실행되므로 무거운 작업은 Coroutine이나 작업 스레드로 보내야 한다.

Main Thread는 가장 무거운 작업을 처리하는 스레드가 아니라 사용자 입력, 생명주기, 화면 그리기를 빠르게 조율하는 스레드다. 이 스레드를 오래 점유하면 입력과 렌더링이 멈춘다.

BroadcastReceiver는 소켓처럼 계속 연결되는 통신 수단이 아니다. 시스템이나 앱에서 특정 이벤트가 발생했을 때 잠깐 실행되는 이벤트 수신기다.

UI 아키텍처: View 시스템에서 Compose로

기존 Android UI는 XML과 View 객체를 기반으로 구성됐다.

<LinearLayout>
    <TextView />
    <Button />
</LinearLayout>

XML은 화면 객체가 아니라 객체를 생성하기 위한 선언이다. Android는 XML을 읽고 실제 LinearLayout, TextView, Button 객체를 메모리에 생성한다. 이 과정이 inflation이다.

XML
 ↓ inflate
View 객체

LinearLayout
├── TextView
└── Button

View 트리는 웹의 DOM 트리와 비슷한 구조를 가진다. ViewGroup은 자식 View를 담는 컨테이너라는 점에서 div와 유사하다. View는 form 같은 특정 태그보다 모든 Android UI 객체의 기반 타입에 가깝다.

화면을 변경할 때는 메모리에 생성된 View 객체를 직접 수정한다.

textView.text = "새로운 내용"
button.isEnabled = false

Fragment와 화면 전환

View 기반 앱에서 Activity가 화면의 모든 UI와 로직을 담당하면 클래스가 지나치게 커진다. Fragment는 Activity 내부 UI를 생명주기가 있는 작은 단위로 나누고 교체하기 위해 사용됐다.

MainActivity
└── FragmentContainer
    ├── HomeFragment
    ├── SearchFragment
    └── ProfileFragment

Fragment를 교체하며 화면을 이동하려면 이전 화면의 이동 기록도 필요하다. Back Stack은 사용자가 방문한 화면 순서를 보관하고, 뒤로 가기를 눌렀을 때 이전 화면을 복원한다.

따라서 Back Stack의 목적은 단순한 Fragment 교체가 아니라 Navigation 기록 관리다.

Compose와 싱글 액티비티

Compose에서는 화면을 View 객체가 아닌 Composable 함수로 표현한다.

@Composable
fun Greeting(name: String) {
    Text("안녕하세요, $name")
}

Composable은 React 컴포넌트와 비슷하지만 Activity와는 역할이 다르다. Activity는 Android OS가 관리하는 화면 호스트이고, Composable은 그 안에서 UI를 표현하는 함수다.

Compose Navigation은 Fragment 없이도 Composable을 화면 목적지로 사용할 수 있다.

MainActivity
└── Compose Navigation
    ├── HomeScreen()
    ├── SearchScreen()
    └── ProfileScreen()

이 때문에 Compose 앱은 하나의 Activity 안에서 Composable 화면을 전환하는 싱글 액티비티 구조를 주로 사용한다.

여러 Activity를 사용하면 각 화면이 독립적인 OS 컴포넌트가 되지만 화면 사이의 상태 공유와 전환 관리가 복잡해질 수 있다. 싱글 액티비티 구조는 앱 내부 Navigation과 상태 공유가 자연스럽지만, 화면 목적지와 Back Stack을 앱에서 명확하게 관리해야 한다.

Fragment는 단순한 코드 정리 단위가 아니라 생명주기와 Navigation 기능을 가진 UI 컴포넌트다. Compose가 그 역할을 다른 방식으로 제공하기 때문에 반드시 사용할 필요가 없어진 것이다.

선언형 UI와 단방향 데이터 흐름

View 시스템에서는 화면 객체를 직접 수정하지만, Compose에서는 현재 상태에서 어떤 UI가 보여야 하는지를 선언한다.

@Composable
fun SubmitButton(enabled: Boolean) {
    Button(
        enabled = enabled,
        onClick = { /* 이벤트 처리 */ },
    ) {
        Text("제출")
    }
}

Composable은 View와 상태가 합쳐진 영구 객체가 아니다. 상태를 읽고 UI 구조를 계산하는 함수다.

Compose Runtime은 Composable을 처음 실행하면서 다음 정보를 기록한다.

  • Composable의 호출 위치와 구조
  • remember로 보관한 값
  • 각 Composable이 읽은 State
  • 다시 실행할 수 있는 재구성 범위

이 기록이 Composition이다.

Composition
└── PostScreen()
    ├── Header()
    ├── PostContent()
    └── LikeButton()
        └── likeCount State를 읽음

likeCount가 변경되면 Compose는 그 값을 읽었던 재구성 범위를 무효화한다. 무효화는 해당 범위를 삭제한다는 뜻이 아니라, 이 범위가 오래된 상태를 사용했으므로 다시 계산해야 한다고 표시하는 것이다.

Recomposer는 표시된 범위를 적절한 시점에 다시 실행한다. 최상위 Composable부터 모든 함수를 무조건 실행하는 것이 아니라 Composition에 기록된 관계를 이용하며, 입력이 바뀌지 않은 하위 Composable은 건너뛸 수도 있다.

상태와 이벤트의 방향

Compose의 UI는 일반적으로 단방향 데이터 흐름을 따른다.

상태
부모 ─────────→ 자식

이벤트
부모 ←───────── 자식
@Composable
fun Counter(
    count: Int,
    onIncrease: () -> Unit,
) {
    Button(onClick = onIncrease) {
        Text("$count")
    }
}

자식은 자신보다 위에 있는 모든 부모를 알지 못한다. 부모가 전달한 콜백 함수만 알고 있다.

사용자의 터치를 감지하는 것은 Compose가 처리하지만, 그 이벤트를 어떤 상태 변화로 연결할지는 개발자가 명시한다. 상태가 변경되면 Compose가 해당 상태를 읽었던 범위를 다시 실행하고 새로운 값을 하위 Composable에 전달한다.

UI 요소 하나에서만 사용하는 상태는 Composable 내부에 둘 수 있다.

var expanded by remember {
    mutableStateOf(false)
}

여러 UI가 공유하거나 화면 로직에서도 필요한 상태는 상위 Composable 또는 ViewModel로 끌어올린다. 이를 State Hoisting이라고 한다.

생명주기와 상태 관리

상태를 어디에 둘지는 데이터의 종류뿐 아니라 얼마나 오래 유지돼야 하는지에 따라 결정된다.

상태 관리 방식재구성화면 회전시스템의 프로세스 종료앱 재실행
remember 유지 소멸 소멸 소멸
ViewModel 유지 유지 소멸 소멸
rememberSaveable 유지 유지 조건부 복원 보장 없음
SavedStateHandle 유지 유지 조건부 복원 보장 없음
Room·DataStore 유지 유지 유지 유지

Composition 범위의 상태

remember는 값을 Composition 메모리에 저장한다.

var expanded by remember {
    mutableStateOf(false)
}

재구성 중에는 값을 유지하지만 해당 Composable이 Composition에서 제거되거나 Activity가 재생성되면 사라진다.

화면 범위의 상태

ViewModel은 화면의 상태와 로직을 관리한다. Activity가 회전으로 교체돼도 ViewModelStore를 통해 기존 ViewModel이 새 Activity에 연결된다.

프로세스가 종료되면 ViewModel도 사라진다. ViewModel의 목적은 영구 보존이 아니라 화면 상태의 소유자와 생명주기를 명확하게 만드는 것이다.

Application 객체도 프로세스 종료 시 사라진다는 점은 같다. 그럼에도 화면 상태를 Application에 두지 않는 이유는 모든 화면에서 접근 가능한 전역 변경 상태가 되어 변경 경로와 생명주기를 추적하기 어려워지기 때문이다.

화면 복원을 위한 Saved State

Saved State는 영구 저장소가 아니라 화면 복구용 스냅샷이다.

상태가 변경되는 동안 실제 값은 앱 프로세스 메모리에 존재한다. Android가 상태를 저장할 때 작은 값을 Bundle 형태로 복사해 Saved State 시스템에 전달한다.

앱 메모리의 현재 상태
       ↓
Bundle로 복사
       ↓
Android가 복원용으로 관리
       ↓
새 Activity·ViewModel·Composition에 전달

Saved State를 시스템 메모리에 저장한다고 단순화할 수는 있지만, Android가 물리적인 저장 위치를 보장하는 것은 아니다. 앱이 보장받는 것은 시스템이 해당 화면을 복원 대상으로 유지하는 동안 저장된 값을 다시 전달할 수 있다는 동작이다.

따라서 Saved State에는 보장된 저장 기간이 없다. 시스템이 앱 프로세스만 제거하고 Task를 유지했다면 복원할 수 있지만, 사용자가 화면이나 Task를 완전히 종료하면 복원은 보장되지 않는다.

rememberSaveable과 SavedStateHandle은 모두 이 Saved State 메커니즘을 사용한다.

var expanded by rememberSaveable {
    mutableStateOf(false)
}

rememberSaveable은 Composable에 가까운 UI 상태에 적합하다. 펼침 여부, 입력창의 내용, 탭 선택처럼 UI 요소 자체가 관리할 수 있는 값이 대표적이다.

class SearchViewModel(
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    val query = savedStateHandle.getStateFlow("query", "")

    fun updateQuery(query: String) {
        savedStateHandle["query"] = query
    }
}

SavedStateHandle은 ViewModel의 화면 로직에서도 사용해야 하는 복원 상태에 적합하다.

두 API 모두 전체 게시글 목록 같은 큰 데이터를 보관하는 용도가 아니다. postId, 검색어, 필터처럼 화면을 다시 구성하는 데 필요한 최소한의 값만 저장하고, 실제 데이터는 데이터 계층에서 다시 불러와야 한다. Compose 상태 저장 문서

영속성과 데이터 계층

앱 프로세스나 화면의 생명주기보다 오래 유지돼야 하는 데이터는 디스크 또는 서버에 저장해야 한다.

Android에서는 로컬 영속성 도구로 Room과 DataStore를 주로 사용한다.

DataStore

DataStore는 작은 앱 설정을 저장하는 데 적합하다.

다크 모드
알림 설정
언어 설정
목록 정렬 방식
온보딩 완료 여부

Preferences DataStore는 Key-Value 형태로 데이터를 저장하고, 타입이 정의된 DataStore는 하나의 설정 객체를 저장한다. 데이터는 Coroutine과 Flow를 통해 비동기로 읽고 갱신한다.

Room

Room은 구조화된 데이터를 저장하는 SQLite 기반 데이터베이스다.

게시글
사용자
댓글
채팅 메시지
검색 기록
오프라인 캐시

테이블, 조건 검색, 정렬, 부분 수정, 데이터 관계, 인덱스, 트랜잭션이 필요하면 Room이 적합하다.

@Entity
data class PostEntity(
    @PrimaryKey val id: Long,
    val authorId: Long,
    val title: String,
)

@Dao
interface PostDao {
    @Query("SELECT * FROM PostEntity WHERE authorId = :authorId")
    fun observeByAuthor(authorId: Long): Flow<List<PostEntity>>
}

DataStore와 Room의 차이는 단순히 저장되는 파일 형식이 아니다.

DataStore
→ 작은 설정 묶음을 읽고 갱신

Room
→ 여러 개의 구조화된 데이터를 조회하고 변경

복잡한 데이터, 부분 수정, 참조 무결성이 필요한 경우 Room을 사용하는 것이 적합하다. DataStore 공식 문서, Room 공식 문서

Repository

Repository는 데이터를 직접 저장하는 기술이 아니라 데이터 접근을 추상화하는 계층이다.

Room ─────┐
DataStore ├── Repository ── ViewModel ── Composable
Server ───┘                       ↑             │
                                 └── 이벤트 ───┘

ViewModel은 데이터가 Room에서 왔는지, 서버에서 왔는지 알 필요가 없다. Repository가 다음과 같은 정책을 담당한다.

  • 서버와 로컬 데이터 중 무엇을 사용할지
  • 서버 데이터를 Room에 캐시할지
  • 오프라인일 때 어떤 데이터를 반환할지
  • 저장소의 모델을 화면에서 사용할 모델로 변환할지

Repository 객체는 앱 프로세스가 종료되면 사라진다. 하지만 Repository가 접근하던 Room과 DataStore의 데이터는 디스크에 남는다. 새 프로세스에서 Repository를 다시 생성하고 데이터를 읽으면 된다.

전체 구조

Android 앱의 UI와 데이터 흐름은 다음과 같이 정리할 수 있다.

Android OS
└── Activity
    └── Compose
        └── Composable
            │
            │ 사용자 이벤트
            ↓
        ViewModel
            ├── UI State
            ├── SavedStateHandle
            └── Repository
                ├── Room
                ├── DataStore
                └── Server

각 계층은 서로 다른 책임을 가진다.

  • Activity는 Android OS와 연결되는 UI 실행 환경을 제공한다.
  • Composable은 상태를 UI로 표현한다.
  • Composition은 Composable 호출과 State 읽기 관계를 기록한다.
  • ViewModel은 화면 상태와 로직을 관리한다.
  • Saved State는 시스템이 화면을 재생성할 때 필요한 최소한의 상태를 복원한다.
  • Room과 DataStore는 프로세스보다 오래 유지돼야 하는 데이터를 저장한다.
  • Repository는 여러 데이터 출처를 하나의 접근 방식으로 연결한다.

상태 관리 도구를 선택할 때 가장 먼저 확인해야 하는 것은 저장 위치가 아니라 상태의 책임과 수명이다.

UI가 재구성되는 동안만 필요
→ remember

화면 회전까지 유지
→ ViewModel

시스템의 화면 복원까지 대응
→ rememberSaveable, SavedStateHandle

앱을 다시 실행해도 유지
→ Room, DataStore

여러 기기에서 공유
→ Server

 

 

 


 

'프론트엔드 > Compose' 카테고리의 다른 글

[컴포즈] 기본 UI 컴포넌트  (2) 2025.06.09
[컴포즈] Jetpack Compose란?  (0) 2025.04.17
[컴포즈] Kotlin: Collection  (0) 2025.04.16
[컴포즈] Kotlin: 클래스  (0) 2025.04.16
[컴포즈] Kotlin: 함수  (0) 2025.04.16

노드

독립적인 OS를 가진 하나의 인스턴스

파드

파드는 무엇인가? 프로세스인가? 아니라고 함. 파드는 Linux 네임스페이스로 격리된 환경으로 하나 이상의 컨테이너가 동일한 Linux 네임스페이스 집합을 공유할 수 있게 해준다. 네임스페이스 중 하나가 Network Namespace인데, 내부에 다음과 같은 리소스들을 생성한다.

  • 독립된 네트워크 인터페이스
  • 독립된 IP 주소
  • 독립된 라우팅 테이블
  • 독립된 포트 범위

=> 호스트와 완전히 분리된 네트워크 스택

 

API 관점에선 etcd에 저장되는 json/yaml 객체로, spec, status로 관리되는 '추상적' 개념이다.

그러나 노드에선 커널의 리소스(네임스페이스)로 구체화돼서 실제로 실행된다.

리눅스 네임스페이스

  • PID Namespace: 프로세스 ID 격리. 컨테이너 안에서는 자기 프로세스만 보임
  • Network Namespace: 네트워크 스택 격리. 독립된 IP, 포트, 라우팅 테이블
  • Mount Namespace: 파일시스템 격리. 독립된 마운트 포인트
  • UTS Namespace: 호스트네임 격리. 컨테이너마다 다른 호스트네임 가능
  • IPC Namespace: 프로세스 간 통신 격리. 공유 메모리, 메시지 큐 분리
  • User Namespace: 사용자 ID 격리. 컨테이너 안에서 root여도 밖에서는 일반 유저
  • Cgroup Namespace: cgroup이 설정한 리소스 제한에 대한 뷰 격리
"컨테이너": 여러 네임스페이스를 적용한 프로세스

 

커널이 프로세스마다 어떤 네임스페이스에 속하는지 기록해둔다.

프로세스가 생성되면 커널의 task_struct(프로세스 정보 담는 구조체)nsproxy 포인터가 해당 프로세스가 속하 네임스페이스들을 가리킨다.

task_struct
    ├── pid              (프로세스 ID)
    ├── state            (실행 중/대기 중/종료 등)
    ├── mm               (메모리 정보)
    ├── fs               (파일시스템 정보)
    ├── files            (열린 파일 목록)
    ├── signal           (시그널 정보)
    ├── nsproxy          (네임스페이스 포인터)
    └── ...

 

[커널 메모리]

task_struct (프로세스 A)              
┌─────────────────┐                   nsproxy (호스트용)
│ pid: 1234       │                  ┌──────────────────┐
│ state: running  │                  │ pid_ns  → (주소) │
│ nsproxy ────────┼─────────────────→│ net_ns  → (주소) │
└─────────────────┘              ┌──→│ mnt_ns  → (주소) │
                                 │   └──────────────────┘
task_struct (프로세스 B)         │
┌─────────────────┐              │
│ pid: 1235       │              │
│ state: sleeping │              │
│ nsproxy ────────┼──────────────┘
└─────────────────┘


task_struct (프로세스 C, 컨테이너)              
┌─────────────────┐                   nsproxy (컨테이너용)
│ pid: 1236       │                  ┌──────────────────┐
│ state: running  │                  │ pid_ns  → (주소) │
│ nsproxy ────────┼─────────────────→│ net_ns  → (주소) │
└─────────────────┘              ┌──→│ mnt_ns  → (주소) │
                                 │   └──────────────────┘
task_struct (프로세스 D, 같은 컨테이너)
┌─────────────────┐              │
│ pid: 1237       │              │
│ state: running  │              │
│ nsproxy ────────┼──────────────┘
└─────────────────┘

 

프로세스가 시스템 자원에 접근할 때 커널이 해당 네임스페이스를 참조해서 결과를 필터링한다.

분리하려는 이유

그냥 노드 여러 개 쓰면 되는 거 아닌가? 왜 하나의 노드에 여러 파드를 넣으려 하는 거고, 여러 개 넣는다해도 그냥 프로세스처럼 띄우면 되지 왜 네임스페이스로 분리하려는 걸까? 우선 노드는 하나당 고정 비용(OS, kubelet, kube-proxy 등 시스템 오버헤드)가 있고, 클라우드 환경 같은 경우 인스턴스 요금이 발생하기 때문에 최대한 적게 쓴다. 그래서 한 노드에 여러 파드를 띄워 리소스를 효율적으로 사용해야 한다. 또, 여러 파드를 띄울 때 네트워크/파일시스템/프로세스/리소스 격리를 하는 이유는, 한 노드에 띄울 때 상호 간의 충돌을 방지해 여러 서비스를 안전하게 띄우기 위함이다.

 

파드에 IP 부여

kubelet이 파드 생성 요청을 받으면, containerd 등의 컨테이너 런타임이 새로운 Network Namespace를 생성한다. 이때 CNI 플러그인이 호출돼서 해당 네임스페이스 안에 가상 인터페이스(veth)를 만들고 IP를 할당한다. CNI가 노드의 라우팅 테이블에 해당 파드로 가는 경로를 추가한다.

사용자
   │
   │  kubectl apply -f pod.yaml
   ↓
API 서버
   │
   │  etcd에 "파드 생성해야 함" 저장
   ↓
스케줄러
   │
   │  "이 파드는 노드 A에서 실행" 결정
   │  API 서버에 업데이트 요청 → etcd에 기록
   ↓
kubelet (노드 A)
   │
   │  API 서버 watch하다 변경 감지
   │  파드 생성 요청
   ↓
컨테이너 런타임 (containerd)
   │
   │  1. Network Namespace 생성
   │  2. CNI 플러그인 실행
   ↓
CNI 플러그인 (Flannel, Calico 등)
   │
   │  3. veth 쌍 생성
   │  4. IP 할당
   │  5. 라우팅 설정
   ↓
파드 네트워크 준비 완료

CNI(Container Network Interface)

컨테이너 런타임이 네트워크 플러그인을 호출할 때의 인터페이스를 정의

  • 호출 방식 (실행 파일 + JSON 설정)
  • 입력 파라미터 (컨테이너 ID, 네임스페이스 경로 등)
  • 출력 형식 (할당된 IP 등)

CNI 플러그인은 실행 파일 -> 해당 실행 파일을 호출하면 플러그인이 veth 생성, IP 할당, 라우팅 테이블 수정 등을 수행

/opt/cni/bin/flannel
/opt/cni/bin/calico
/opt/cni/bin/bridge
  • 설정 파일: /etc/cni/net.d/

가상 인터페이스

물리적 네트워크 장치 없이 소프트웨어로 만든 네트워크 인터페이스

  • 물리 인터페이스: 실제 네트워크 카드(NIC). eth0, enp3s0 등. NIC가 패킷을 받으면 CPU에 인터럽트를 걸어서 "패킷 왔다"고 알림. 커널이 그 인터럽트를 받아서 처리.
  • 가상 인터페이스: 커널이 소프트웨어로 생성한 인터페이스. 물리 장치 없이도 네트워크 통신 가능. 인터럽트 없이 커널 내부에서 직접 처리. 커널 네트워크 스택은 그대로 거침.

다만 가상 인터페이스는 노드 내부에서의 패킷 라우팅/격리 역할을 하고, 노드 외부로 나가야 할 때는 물리 인터페이스를 거친다. 노드 내부에서 파드끼리 통신할 때, 완전히 독립된 네트워크 환경이므로 가상 인터페이스가 필요하다.

 

veth(Virtual Ethernet): 가상 인터페이스 중 하나. 항상 쌍으로 생성되고, 한쪽에 패킷을 보내면 다른 쪽에서 나온다. 한쪽은 파드 네임스페이스 안에, 다른 쪽은 호스트 네임스페이스에 존재해서 네임스페이스 경계를 넘어 통신할 수 있게 해준다

 

 

 


 

'백엔드' 카테고리의 다른 글

[k8s] 쿠버네티스 구성 요소  (1) 2026.01.17

 쿠버네티스 구성 요소

(논리적으로) 하나의 컨트롤 플레인 + 여러 개의 워커 노드

  • 컨트롤 플레인은 여러 노드에 분산돼있을 수 있음
  • 예전엔 '마스터 노드'라 했음

컨트롤 플레인 컴포넌트

클러스터에 대한 전역적인 결정 수행. 이벤트 감지 및 대응

kube-apiserver

컨트롤 플레인의 프론트엔드. 외부 사용자는 컨트롤 플레인의 api 서버와 소통하며, 컨트롤 플레인 내부에서도 모든 데이터의 흐름은 api 서버를 통해서만 이루어짐

etcd

클러스터의 모든 데이터 저장. 키-값 저장소. 클러스터의 현재 상태(status)와 되어야 할 상태(spec) 등 저장. Raft 알고리즘 사용

더보기

Raft 알고리즘 개요

etcd가 분산 환경에서 데이터 일관성을 유지하기 위해 사용하는 합의(consensus) 알고리즘

 

핵심 목표

여러 노드가 동일한 데이터 상태를 유지하도록 보장한다. 일부 노드가 장애가 나더라도 클러스터 전체는 정상 동작한다.

 

노드의 3가지 상태


상태 설명
Leader 모든 쓰기 요청을 처리하고 다른 노드에 복제
Follower Leader의 명령을 수신하고 로그를 복제
Candidate Leader 선출 과정에서 투표를 요청하는 상태

 

동작 방식

1. Leader 선출

 
Follower ──(heartbeat 타임아웃)──> Candidate ──(과반 투표 획득)──> Leader
  • Follower가 일정 시간 Leader의 heartbeat를 받지 못하면 Candidate가 됨
  • Candidate는 다른 노드에 투표 요청
  • 과반수(quorum) 표를 얻으면 Leader로 승격

2. 로그 복제

 
Client 요청 → Leader가 로그 엔트리 생성 → Follower들에게 전파 → 과반 확인(ack) 수신 → 커밋 → Client에 응답
  • 모든 쓰기는 Leader를 통해서만 진행
  • Leader는 로그를 Follower에 복제하고, 과반이 저장하면 커밋 확정

3. Term (임기)

  • 각 Leader 선출 시 term 번호가 증가
  • term이 낮은 Leader의 명령은 무시됨
  • 네트워크 분리 후 복구 시 충돌 해결에 사용

Quorum (정족수)

클러스터 크기 정족수 허용 장애 노드
3 2 1
5 3 2
7 4 3

공식: (n/2) + 1 개 노드가 살아있어야 클러스터가 동작한다.

 

etcd에서의 적용

  • etcd 클러스터의 모든 노드가 Raft로 상태를 동기화
  • 쓰기 요청은 항상 Leader 노드가 처리
  • 읽기는 설정에 따라 Follower에서도 가능 (linearizable read 옵션 시 Leader 경유)
  • Leader 장애 시 자동으로 새 Leader 선출 (수백 ms 내)
 
 
 

kube-scheduler

노드가 배정되지 않은 파드를 감지해, 실행할 노드 선택.

고려 요소: 리소스, 하드웨어/소프트웨어/정책적 제약, affinity 및 anti-affinity 명세, 데이터 지역성, 워크로드-간 간선, 데드라인

kube-controller-manager

컨트롤러 프로세스를 실행하는 컴포넌트. 논리적으로는 분리된 프로세스이나 모두 단일 바이너리로 컴파일되고 단일 프로세스 내에서 실행됨

종류

  • Node Controller: 노드 다운 감지 및 대응
  • Job Controller: 잡(job) 오브젝트 감시, 작업 수행을 위한 파드 생성. (job: 일회성 작업)
  • EndpointSlice Controller: 엔드포인트슬라이스 오브젝트를 채워서 파드와 서비스 사이 연결 제공
  • ServiceAccount Controller: 신규 네임스페이스에 기본 서비스어카운트 생성
  • 등등

cloud-controller-manager

클라우드별 컨트롤 로직 포함. 클러스터를 클라우드 공급자의 api에 연결. 해당 클라우드 플랫폼과 상호 작용하는 컴포넌트와 클러스터와만 상호작용하는 컴포넌트 구분. 온프레미스 환경/개인 pc 환경일 경우 클라우드 컨트롤러 매니저가 없음. 정확히 머하는 애인진 별로 안 궁금함

 

노드 컴포넌트

모든 노드에서 실행. 실행 중인 파드 유지 및 쿠버네티스 런타임 환경 제공

kubelet

각 노드에서 실행되는 에이전트. 파드에서 컨테이너가 확실하게 동작하도록 관리

파드 스펙(PodSpec의 집합을 받아서 컨테이너가 해당 스펙에 따라 잘 동작하도록 함

쿠버네티스를 통해 생성되지 않는 컨테이너는 관리하지 않음

kube-proxy

선택사항. 각 노드에서 실행되는 네트워크 프록시. 노드의 네트워크 규칙 유지 관리.

더보기

핵심 개념

구분 설명
Pod IP 실제 라우팅 가능한 엔드포인트. 단, 파드 재시작 시 변경될 수 있음
Service IP 안정적인 접근을 위한 가상 IP. 실제 목적지가 아닌 트리거용 주소
Service 네트워크 엔티티가 아닌 변환 규칙 객체

 

패킷 흐름

┌─────────────────────────────────────────────────────────────────────┐
│  1. DNS 조회                                                         │
│     Pod A  ──────────────>  CoreDNS                                 │
│             <──────────────  Service IP 반환 (예: 10.96.0.100)        │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  2. 패킷 전송                                                         │
│     Pod A  ──────────────>  dst: 10.96.0.100 (Service IP)           │
│     (파드는 kube-proxy의 존재를 모름)                                    │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  3. 노드 커널 도착 (PREROUTING 단계)                                    │
│     ┌──────────────────────────────────────────────────────────┐    │
│     │  DNAT 변환 (iptables/IPVS 규칙 적용)                        │    │
│     │  dst: 10.96.0.100  →  dst: 10.244.1.5 (Pod IP)           │    │
│     └──────────────────────────────────────────────────────────┘    │
│     * 변환 수행: 커널                                                  │
│     * 규칙 설치: kube-proxy                                           │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  4. 라우팅 및 전달                                                      │
│     라우팅 테이블이 Pod IP(10.244.1.5) 기준으로 동작                        │
│     패킷 → 목적지 파드 프로세스로 전달                                      │
└─────────────────────────────────────────────────────────────────────┘

 

Service IP가 노드까지 도달하는 이유

  • Service IP 대역(예: 10.96.0.0/12)은 클러스터 로컬 대역
  • 모든 노드의 라우팅 테이블이 이 대역을 로컬에서 처리하도록 설정됨
  • 따라서 Service IP 패킷은 외부로 나가지 않고 노드 커널에서 처리

 

역할 분담

컴포넌트 역할
파드 DNS로 Service IP 획득 → 해당 IP로 패킷 전송
kube-proxy iptables/IPVS 규칙을 노드에 설치
노드 커널 규칙에 따라 Service IP → Pod IP 변환 수행

 

참고: kube-proxy가 없으면?

  • Service IP로 향하는 트래픽은 변환 규칙이 없어 처리 불가
  • userspace 모드에서는 kube-proxy가 직접 포워딩을 수행

컨테이너 런타임

컨테이너 실행 담당 소프트웨어. 쿠버네티스는 containerd, CRI-O와 같은 컨테이너 런타임 및 모든 Kubernetes CRI 구현체 지원.

 


동작 흐름

kubectl / client → API Server → (검증·저장·결정) → 워커 노드

 

1. 사용자 요청

kubectl / client → API Server
  • 외부 사용자는 항상 컨트롤 플레인의 API 서버와만 통신

2. API 서버의 요청 처리

  • 인증(Authentication)
  • 인가(Authorization)
  • 어드미션 컨트롤(Admission Control)
  • 스키마 검증

=> 요청은 “클러스터 상태 정의(spec)”

3. etcd 저장

API Server → etcd

 

  • etcd에 저장되는 건 현재 사용자가 원하는 "클러스터의 상태"
  • 상태를 "선언"함
    •  

4. 상태 변화 감지 (watch)

API Server → 컨트롤러들
  • 컨트롤러가 API 서버를 watch
  • etcd 변경 → API 서버 → 이벤트 전파

5. 컨트롤러 동작 (reconcile)

  • desired(spec) vs observed(status) 비교
  • 예:
    • Deployment replicas=3
    • 실제 Pod=0

=> Pod 생성 요청을 API 서버에 보냄

6. 파드 생성 (아직 노드 없음)

API Server → etcd
  • 새 Pod 객체 생성
  • 상태: nodeName 없음 (미스케줄 Pod)

7. 스케줄러 동작

스케줄러 watch → 미스케줄 Pod 감지
  • 고려 요소:
    • CPU / 메모리
    • 노드 상태
    • affinity / taint / toleration

=> “어느 노드에 둘지” 결정

8. 스케줄링 결과 저장

스케줄러 → API Server → etcd
  • Pod.spec.nodeName 설정

9. kubelet 동작

해당 노드의 kubelet watch → Pod 할당 감지
  • 컨테이너 런타임(CRI) 호출
  • 파드 실행

10. 실행 결과 보고

kubelet → API Server → etcd
  • Pod.status 업데이트
  • 노드 상태 보고
더보기
┌─────────────────────────────────────────────────────────┐
│ 1. 사용자 요청                                             │
│    kubectl ──────────────> API Server                   │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────┐
│ 2. API 서버 처리                                          │
│    인증 → 인가 → 어드미션 컨트롤 → 스키마 검증             	  │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────┐
│ 3. etcd 저장                                             │
│    API Server ──────> etcd (원하는 상태 저장)               │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼ watch
┌─────────────────────────────────────────────────────────┐
│ 4-5. 컨트롤러 동작                                         │
│    • spec vs status 비교                                 │
│    • 예: replicas=3, 실제=0 → Pod 생성 요청                 │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────┐
│ 6. 파드 생성                                              │
│    Controller ──> API Server ──> etcd                   │
│    (nodeName 없는 Pod 객체 생성)                           │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼ watch
┌─────────────────────────────────────────────────────────┐
│ 7-8. 스케줄러 동작                                         │
│    • nodeName 없는 Pod 감지                               │
│    • CPU/메모리, affinity/taint 고려                       │
│    • nodeName 배정 → etcd 저장                            │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼ watch
┌─────────────────────────────────────────────────────────┐
│ 9. kubelet 동작                                          │
│    • 자기 노드에 할당된 Pod 감지                              │
│    • Container Runtime 호출 → 파드 실행                    │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────┐
│ 10. 결과 보고                                             │
│    kubelet ──> API Server ──> etcd                      │
│    (Pod.status 업데이트)                                  │
└─────────────────────────────────────────────────────────┘

 

 

 

어떻게 상태 변화를 감지하는가?

이벤트 기반 + watch

 

A. 사용자가 원하는 상태(desired state)를 바꾸는 요청 흐름

1. 주체/데이터

  • 데이터 종류: spec(원하는 상태), status(관측된 실제 상태), metadata
  • 저장소: etcd = 모든 Kubernetes API 오브젝트의 최신 상태(spec/status/metadata) 저장
  • 통신 경로: 모든 주체는 API Server만 호출 (etcd 직접 접근은 API Server만)

2. 사용자/클라이언트 → API Server

  • 전송 데이터: HTTP 요청(예: Deployment/Service/ConfigMap 등 오브젝트의 YAML/JSON)
  • 트리거: 사용자가 create/apply/patch/delete 호출

3. API Server: 인증/인가/어드미션/스키마 검증

  • 입력: 사용자 요청 본문 + 사용자 신원
  • 결과: “저장 가능한 오브젝트”로 확정 또는 거부

4. API Server → etcd (쓰기)

  • 저장 데이터: 해당 오브젝트의 metadata + spec (+ 일부 status 초기값/기본값)
    • create/patch/update/delete 모두 결국 etcd의 오브젝트 레코드가 변경됨
  • 트리거: 2) 검증 통과 후

5. API Server → Watch 스트림으로 변경 이벤트 발행

  • 이벤트 대상: API Server에 watch 연결을 맺고 있는 컴포넌트들(컨트롤러/스케줄러/기타)
  • 이벤트 내용: (ADDED/MODIFIED/DELETED) + 변경된 오브젝트(또는 변경분) + resourceVersion
  • 트리거: 3) etcd에 반영된 후(커밋된 변경)

6. 컨트롤러(들): watch 수신 → workqueue enqueue

  • 컨트롤러가 보는 데이터 출처: API Server watch/list
  • 트리거: 4)에서 이벤트 수신
  • 내부 처리: 이벤트를 즉시 실행으로 바꾸지 않고 “해당 키를 큐에 넣음”

7. 컨트롤러: reconcile 시작 (API Server에서 현재 스냅샷 재조회)

  • 컨트롤러가 가져오는 데이터(전부 API Server에서 GET/LIST):
    • 본인 책임 오브젝트의 최신 spec/metadata/status
    • 필요시 연관 오브젝트들(라벨 셀렉터 등으로 Pod/ReplicaSet/EndpointSlice 등)
  • 트리거: 5) 큐에서 키를 꺼냈을 때

8. 컨트롤러: desired(spec) vs observed(status/연관오브젝트 상태) 비교

  • 비교 입력:
    • desired = (예: Deployment.spec.replicas, template, selector…)
    • observed = (예: 현재 존재하는 Pod 목록/상태, ReplicaSet 상태, status 필드 등 “API Server에 기록된 것”)
  • 트리거: 6)에서 조회 완료 후

9. 컨트롤러 → API Server (조정 요청: create/update/patch/delete)

  • 전송 데이터: “맞추기 위해 필요한 오브젝트 변경”
    • 예: Pod 생성(정확히는 보통 ReplicaSet/POD 관련 오브젝트 생성/수정), EndpointSlice 갱신, Job의 Pod 생성 등
  • 트리거: 7)에서 불일치 발견 시

10. API Server: (다시) 검증 → etcd (쓰기)

  • 저장 데이터: 컨트롤러가 요청한 오브젝트 변경이 etcd에 반영
  • 트리거: 8) 요청 수신 후

11. 스케줄러: (특정 오브젝트) watch → 스케줄링

  • 스케줄러가 watch하는 대표 대상: “아직 nodeName이 없는 Pod”(미스케줄 Pod)
  • 트리거: 9)로 인해 “새 Pod 생성됨(미스케줄 상태)” 이벤트를 watch로 수신

12. 스케줄러: 노드 선택 계산 (API Server에서 필요한 정보 조회)

  • 조회 데이터(전부 API Server):
    • Node 목록/상태/리소스(보고된 status)
    • Pod의 spec(요구 리소스, affinity/taints 등)
  • 결과: 선택된 nodeName

13. 스케줄러 → API Server (Pod binding / Pod.spec.nodeName 업데이트 요청)

  • 전송 데이터: “이 Pod는 이 노드에 배치”
  • 트리거: 11) 노드 결정 후

14. API Server → etcd (쓰기)

  • 저장 데이터: Pod 오브젝트가 nodeName이 채워진 상태로 갱신
  • 트리거: 12) 반영

15. 해당 노드의 kubelet: API Server watch로 “내 노드에 배정된 Pod” 감지

  • kubelet이 보는 데이터 출처: API Server watch/list
  • 트리거: 13)로 인해 “내 노드(nodeName=me)”에 해당 Pod가 할당된 변경을 watch 수신

16. kubelet: Pod 실행 수행 (CRI 호출)

  • kubelet → CRI(컨테이너 런타임)로 실행/중지/상태조회 호출
  • 트리거: 14)에서 “실행해야 할 Pod”를 감지
더보기
┌──────────────────────────────────────────────────────────┐
│ 1-4. 요청 → 저장                                           │
│                                                          │
│ kubectl ──> API Server ──> etcd                          │
│              (인증/인가/어드미션/스키마 검증)                   │
└──────────────────────────────────────────────────────────┘
                            │
                            ▼ watch 이벤트 발행
┌──────────────────────────────────────────────────────────┐
│ 5-9. 컨트롤러 reconcile                                    │
│                                                          │
│ • 이벤트 수신 → workqueue enqueue                           │
│ • API Server에서 최신 스냅샷 조회                             │
│ • spec vs status 비교 → 불일치 시 조정 요청                    │
└──────────────────────────────────────────────────────────┘
                            │
                            ▼
┌──────────────────────────────────────────────────────────┐
│ 10-14. 스케줄링                                            │
│                                                          │
│ • 스케줄러가 nodeName 없는 Pod 감지                           │
│ • 노드 선택 (리소스/affinity/taint 고려)                      │
│ • Pod.spec.nodeName 배정 → etcd 저장                       │
└──────────────────────────────────────────────────────────┘
                            │
                            ▼ watch
┌──────────────────────────────────────────────────────────┐
│ 15-16. 실행                                               │
│                                                          │
│ • kubelet이 자기 노드 Pod 감지                               │
│ • CRI 호출 → 컨테이너 실행                                   │
└──────────────────────────────────────────────────────────┘

 

B. 실제 실행 상태(observed state)가 변하는 흐름

1. 컨테이너 런타임/커널/프로세스 상태 변화

  • 변화 예: 컨테이너 종료, 재시작, readiness 실패, 이미지 pull 실패 등
  • 트리거: 실제 실행 환경에서 발생

2. kubelet: 변화 감지 (CRI 상태 조회/이벤트/주기 루프)

  • 입력 데이터: CRI에서 받은 컨테이너/파드 상태
  • 트리거: 런타임 변화 감지

3. kubelet → API Server (status 업데이트)

  • 전송 데이터(대표):
    • Pod.status: phase, conditions, containerStatuses, restartCount 등
    • Node.status: Ready/압력(memory/disk/pid) 등
    • Lease 갱신(생존 신호)
  • 트리거: 2)에서 변화 감지 또는 주기적 갱신 시점

4. API Server → etcd (쓰기)

  • 저장 데이터:
    • 해당 Pod/Node/Lease 오브젝트의 status/metadata 갱신
  • 트리거: 3) 반영

5. API Server → Watch 스트림으로 변경 이벤트 발행

  • 대상: 해당 리소스를 watch 중인 모든 컴포넌트(컨트롤러/스케줄러/기타)
  • 이벤트 내용: MODIFIED 등 + 최신 오브젝트(resourceVersion 포함)
  • 트리거: 4) 커밋 후

6. 컨트롤러(들): 이벤트 수신 → 큐 enqueue → reconcile

  • 컨트롤러가 가져오는 데이터: API Server에서 최신 스냅샷 재조회
  • 트리거: 5) watch 이벤트

7. 컨트롤러: 비교 후 조정 필요 시 API Server로 오브젝트 변경 요청

  • 예:
    • ReplicaSet/Deployment 컨트롤러: 부족한 Pod 수만큼 생성 요청
    • Node 컨트롤러: 노드 NotReady 감지 시 파드 eviction/상태 처리
    • Endpoint/Service 계열: Ready Pod 변화에 따라 EndpointSlice 갱신
  • 트리거: 6) reconcile 결과 불일치

8. API Server → etcd (쓰기) → watch 발행

  • 저장 데이터: 컨트롤러가 요청한 변경(새 Pod 생성, 상태 마킹 등)
  • 트리거: 7) 요청 반영
  • 이후: A-10~A-15(필요 시 스케줄링/실행)로 다시 연결
더보기
┌──────────────────────────────────────────────────────────┐
│ 1-4. 상태 감지 → 보고                                       │
│                                                          │
│ 컨테이너 런타임 ──> kubelet ──> API Server ──> etcd          │
│ (상태 변화)        (CRI 조회)   (status 업데이트)              │
└──────────────────────────────────────────────────────────┘
                            │
                            ▼ watch 이벤트 발행
┌──────────────────────────────────────────────────────────┐
│ 5-8. 컨트롤러 반응                                          │
│                                                          │
│ • 이벤트 수신 → reconcile                                   │
│ • 불일치 시 조정 (Pod 재생성, EndpointSlice 갱신 등)            │
│ • 필요 시 A 흐름으로 연결 (스케줄링 → 실행)                      │
└──────────────────────────────────────────────────────────┘

 

C. 누가 무엇을 어디서 가져오나

컴포넌트 읽기 쓰기
API Server etcd etcd
Controller API Server API Server
Scheduler API Server API Server
kubelet API Server, CRI API Server

 

D. 무엇이 언제 etcd에 저장되나

  • etcd 저장됨:
    • 모든 Kubernetes API 오브젝트의 생성/수정/삭제 결과
    • spec 변경(사용자/컨트롤러/스케줄러가 API Server로 보낸 변경)
    • status 변경(주로 kubelet/일부 컨트롤러가 API Server로 업데이트한 결과)
    • Lease 갱신(kubelet이 갱신 → API Server → etcd)
  • etcd 저장되지 않는 것(직접 상태 실행 자체):
    • “컨테이너 프로세스가 실제로 뜨는 행위”는 etcd에 실행 로그로 저장되는 게 아니라,
    • 그 결과가 kubelet → status 업데이트오브젝트에 반영될 때만 etcd에 기록됨

 


 

'백엔드' 카테고리의 다른 글

[k8s] 파드와 네임스페이스  (0) 2026.01.26

 

이벤트 루프 동작 흐름: 콜 스택 비움 -> 마이크로 스택 비움 -> 매크로 스택 비움

  • 마이크로 스택
    • 현재 실행 중인 매크로태스크가 끝나자마자 처리되는 작은 작업들
    • 항상 매크로태스크보다 먼저 처리
    • Promise 관련 콜백, queueMicrotask(), MutationObserver
  • 매크로 스택
    • 이벤트 루프의 큰 단위 태스크
    • 한 번에 하나씩 실행 → 끝날 때마다 마이크로태스크 큐를 전부 비운 뒤 다음 매크로태스크 실행
    • 타이머 계열(setTimeout, setInterval), I/O 콜백, UI 이벤트 콜백, postMessage, MessageChannel, setImmediate, process.nextTick

 

console.log("A: 동기 시작");

// 1) 즉시 resolve 되는 Promise
const p1 = new Promise((resolve) => {
  console.log("B: p1 executor 실행");
  resolve("p1-resolved");                // (1) 즉시 상태 확정(fulfilled)
});

// 2) 0ms 타이머 (매크로태스크)
setTimeout(() => {
  console.log("C: setTimeout(0) 콜백");
}, 0);

// 3) queueMicrotask (마이크로태스크)
queueMicrotask(() => {
  console.log("D: queueMicrotask 콜백");
});

// 4) 아직 pending인 Promise (1초 후 resolve)
const p2 = new Promise((resolve) => {
  console.log("E: p2 executor 실행");
  setTimeout(() => {
    console.log("F: p2 내부 타이머 만료 → resolve");
    resolve("p2-resolved");              // (2) 1초 후 fulfilled
  }, 1000);
});

// 5) p1은 이미 fulfilled 상태 → then 핸들러가 즉시 “마이크로태스크 큐”에 등록
p1.then((v) => {
  console.log("G: p1.then 1:", v);
  // (5-1) then에서 값을 반환하면, 다음 then으로 값이 전달됨(새 Promise fulfilled)
  return v + " -> then1";
})
.then((v) => {
  console.log("H: p1.then 2:", v);
  // (5-2) then에서 Promise 반환하면 “플래트닝”: 그 Promise의 결과를 기다림
  return new Promise((resolve) => {
    console.log("I: p1.then 2에서 새 Promise 생성(비동기)");
    setTimeout(() => {
      console.log("J: p1.then 2 내부 타이머 만료 → resolve");
      resolve(v + " -> then2-promise");
    }, 10);
  });
})
.then((v) => {
  console.log("K: p1.then 3(플랫 결과):", v);
});

// 6) p2는 아직 pending → then 핸들러를 내부 리스트에 “저장”만 해둠
p2.then((v) => {
  console.log("L: p2.then 1:", v);
  // (6-1) 마이크로태스크 안에서 또 마이크로태스크 등록
  queueMicrotask(() => {
    console.log("M: p2.then 1 안의 queueMicrotask");
  });
  // (6-2) 값 반환
  return v + " -> then1";
})
.then((v) => {
  console.log("N: p2.then 2:", v);
});

console.log("O: 동기 끝");

 

 

예상 출력 흐름

  1. 호출 스택(동기 구간)
    • A 실행 -> B 실행 -> p1 상태 fulfilled -> C 매크로 큐에 등록 -> D 마이크로 큐에 등록 -> E 실행 -> F 매크로 큐에 등록 -> p1.then 실행 -> G 마이크로 큐에 등록 -> p2.then 실행 -> L 마이크로 큐에 등록 -> O 실행
  2. 마이크로태스크 드레인(비우기)
    • D 실행 -> G 실행: "G: p1.then 1:p1-resolved" -> H 실행: "H: p1.then 2:p1-resolved -> then1" -> I 실행 -> J 매크로 큐에 등록
  3. 매크로태스크 실행과 그 사이의 마이크로태스크
    • C 실행 -> F 실행 -> p2 상태 fulfilled -> L 실행 -> M 실행 -> N 실행 -> J 실행 -> K 실행
  • A -> B -> E -> O -> D -> G -> H -> I -> C -> F -> L -> M -> N -> J -> K

 

실제 출력

  • A -> B -> E -> O -> D -> G -> H -> I -> C -> J -> K -> F -> L -> M -> N
A: 동기 시작
B: p1 executor 실행
E: p2 executor 실행
O: 동기 끝
D: queueMicrotask 콜백
G: p1.then 1: p1-resolved
H: p1.then 2: p1-resolved -> then1
I: p1.then 2에서 새 Promise 생성(비동기)
C: setTimeout(0) 콜백
J: p1.then 2 내부 타이머 만료 → resolve
K: p1.then 3(플랫 결과): p1-resolved -> then1 -> then2-promise
F: p2 내부 타이머 만료 → resolve
L: p2.then 1: p2-resolved
M: p2.then 1 안의 queueMicrotask
N: p2.then 2: p2-resolved -> then1

 

 

타이머 지연시간 순서: 0ms(C) → 10ms(J) → 1000ms(F)

따라서 (F, L, M, N)보다 (J, K)가 먼저 출력

 

setTimeout 호출 시 타이머 시작, 지정된 시간 이후에 콜백을 매크로태스크 큐에 enqueue

  • 콜백을 매크로태스크 큐에 등록하는 시점은 호출 당시가 아닌 '지정된 시간이 지난 후'

 


 

브라우저에서 말하는 렌더링이란 뭘까? 웹 페이지는 HTML, CSS, JS의 조합으로 구성이 된다. 이들은 결국 어떠한 시각적인 파일이 아닌 해석이 필요한 코드들이기 때문에, 이를 해석하여 사용자의 화면에 '시각화'해주는 엔진이 필요하다. 이 역할을 하는 것이 결국 브라우저의 렌더링 엔진이며, 예시로 크롬과 엣지의 Blink, 사파리의 WebKit, 파이어폭스의 Gecko 엔진이 있다. '해석'의 결과는 다음과 같다.

  • HTML → DOM: 문서 구조를 객체 트리로 변환
  • CSS → CSSOM: 스타일 규칙을 객체 트리로 변환
  • DOM + CSSOM → Render Tree: 화면에 표시될 요소 결정
  • Layout & Paint: 각 요소의 크기, 위치, 색상 등을 계산해 픽셀로 출력

화면 구성 과정: HTML → DOM → CSS → CSSOM → Render Tree → Layout → Paint → Compositing → 화면 출력

 

HTML -> DOM

<body>
  <h1>Hello</h1>
  <p>World</p>
</body>
Document
 └─ html
     └─ body
         ├─ h1 ("Hello")
         └─ p ("World")

 

CSS -> CSSOM

h1 { color: red; }
p { font-size: 14px; }
h1 → { color: red }
p  → { font-size: 14px }

 

Render Tree

DOM, CSSOM을 합친, 화면에 표시될 요소만 포함한 트리

  • display: none과 같은 속성의 요소는 표함되지 않음.
  • visibility: hidden / opacity: 0트리에 포함되고 레이아웃도 계산되지만, 최종 표시가 없거나(투명) 페인트 결과가 보이지 않을 수 있음
  • 노드별 스타일 정보 포함
Render Tree
 └─ body
     ├─ h1 (color: red)
     └─ p (font-size: 14px)

 

Layout

Render Tree를 기반으로 각 요소의 위치, 크기 계산

브라우저의 뷰포트 크기, 폰트 크기, margin/padding 등 모든 스타일 반영

 

Paint

Layout에서 계산된 위치와 크기에 Render Tree의 스타일 속성을 반영

각 노드의 스타일을 그리기 명령 형태로 만듦

아직 픽셀 데이터가 만들어지지 않음

 

Rasterization

기록된 그리기 명령을 실제 픽셀 단위 비트맵으로 변환

레이어를 작은 타일 단위로 나눠 픽셀 채움

 

Compositing

복잡한 페이지는 여러 레이어로 나누어 관리 (z-index, transform 등 활용)

여러 합성 레이어를 GPU에서 결합하여 최종 프레임 출력

 


 

 

수학적 귀납법은 재귀 함수를 구현할 때 흔히 이용된다. 더 간결하고 효율적인 코드를 짤 때 재귀 함수를 이용하는 경우가 있다. 내가 짠 재귀 함수가 정확한지 판단하기 위해 수학적 귀납법에 대해 알아보자.

 

수학적 귀납법

기본형: P(1)이 참이고, P(n-1) -> P(n)이 참이면 모든 자연수 n에 대하여 P(n)이 참이다.

  • Base: P(1)
  • Step: P(n-1) -> P(n)

강한 형태: P(1)이 참이고, P(1)P(2)...P(n-1) -> P(n) 참이면 모든 자연수 n에 대하여 P(n)이 참이다.

  • Base: P(1)
  • Step: P(1)P(2)...P(n-1) -> P(n)

 

Step: P(n-1) -> P(n)

P(n-1)이 참이라면, P(n)이 참이다

 

귀납법에서 Base가 참임을 증명한 후 Step을 증명할 때, P(n-1)을 이용해 P(n)을 만들어낸다. 만약 P(n-1)에서 도출된 식이 P(n)과 일치한다면 모든 자연수 n에 대해 P(n)이 참이다. P(1)이 참이므로 P(2)가 참, P(2)가 참이므로 P(3)이 참, ..., P(k-1)이 참이므로 P(k)가 참, 으로 이어진다.

여기서 의문이 들 수 있다. P(n-1)이 참이란 가정에 문제가 없나? P(n-1)이 거짓일 땐 고려하지 않아도 되나? 사실 난 이런 의문이 들지 않았다. P(n-1)이 참이란 건 '가정'이고, P(1), 즉 Base에서 시작될 명제가 아닌가? 거짓이 되면 그냥 P(1)에서 끝나는 것 아닌가? 애초에 P(n-1)이 거짓이 된다면 명제가 거짓이 되는 자연수가 존재한다는 뜻 아닌가?

 

Vacuously True

그런데 코드상에서 보면 헷갈리는 경우가 있다. 재귀 함수를 구현할 때, 내가 return한 값이 입력에 대해 정확한 출력을 제공한다고 확신할 수 있을까? 이럴 때 return문 내에서 호출하는, 이전 수에 대한 호출의 값이 정확하다고 '믿어'야 한다. 그 부분이 바로 P(n-1)이고, P(n-1)이 거짓일까 걱정하지 않아도 된다. P(n-1)이 맞다는 가정 하에 추가한 연산의 결과가 P(n)이 된다면 말이다! Base인 경우와 return문, 즉 Step만 생각하면 되는 것이다.

int sum(int x) {
    if (x <= 0) return 0;	// Base
    return x + sum(x-1);	// Step
}

 

이는 P(n-1)이 거짓인 경우는 'Vacuously True' 이기 때문이다. Vacuous는 텅 빈, 공허한 이라는 뜻을 갖는다. Vacuously True, '텅 빈 진실'은 전제가 거짓일 때 전체 명제에 해당한다. 전제가 거짓인 경우엔 결론이 참이든 거짓이든 명제는 참이 되는 것이다. p->q, 즉 'p이면 q이다'에서 p가 아닐 땐 q가 무엇인지 정의하지 않았기 때문에, p가 거짓이면 q가 무엇이든 참으로 보는 것이다. p->q가 거짓이 되려면 반례, p가 참일 때 q가 아닌 경우가 있어야 하는데 p가 거짓이 되면 반례를 따질 수 없다.

 

p q p → q
T T T
T F F
F T T
F F T

 

최소 반례 논법

그렇다면 P(n-1)이 거짓일 때는 step이 vacuously true가 된다는 것을 알았다. 그런데 P(n-1)이 거짓이라면, 결국 P(n)이 거짓이 되는 자연수 n이 존재한다는 거 아닌가? 여전히 뭔가 찝찝하다. Vacuously true가 실제 결론과 충돌하지 않는 이유를 살펴보자.

 

m > n₀에 대해, 가장 처음으로 P(n)이 거짓이 되는 n을 m이라고 하자. 

  1. 반례 집합 S = {  n∈N | ¬ P(n) }비어 있지 않다고 가정.
  2. 그중 가장 작은 원소 m = min⁡S를 잡음.
  3. m ≠ n₀ m−1 < m → P(m−1) 은 참.
  4. step에 의해 P(m)도 참이어야 함 → m ∈ S 와 모순.
  5. 모순이므로 S는 공집합 → 반례가 존재하지 않는다∀n, P(n)

=> '만약 P(n-1)이 거짓인 n이 정말 있다면?'을 정면으로 부정

단계 논리적 역할 P(n)이 거짓일 때 발생하는 일
Step을 증명할 때 “모든 n에 대해 P(n-1) ⇒ P(n)”을 확인 전건이 거짓이면 명제는 vacuously true → 증명 완료에 문제 없음
귀납법을 적용해 결론을 얻을 때 P(n₀)가 참 → P(n₀+1)이 참 → … 순차적으로 modus ponens(긍정 논법) 적용 순차 사슬이 이어지므로 실제로는 P(n)이 거짓인 n가 나타나지 않음

 

결국 Base가 참인 상태에서 Step 자체만 증명이 되면 Step의 전제, P(n-1)이 거짓이 되는 경우는 발생하지 않음을 알 수 있다. 그리고 Step 자체를 증명할 때 전제가 거짓이면 명제는 참이되므로, Step을 증명할 때 P(n-1)이 거짓이 되는 경우를 고려하지 않는다.

 

sum()에서 x == 0일 때 sum(x)가 참이고, sum(x-1)이 참일 때 x + sum(x-1)이 sum(x)의 값을 반환한다면 sum(x-1)이 거짓이 되는 경우가 발생하지 않는다.

 

 

결론

뭔가 헷갈리고 다 어렵다. 사실 간단한 수학식에 적용하면 당연하지 않나? Vacuously True인 상황을 왜 고려하지? 라는 생각이 드는데 코드상으로 보면 또 P(n-1)이 미심쩍고 찝찝하긴 하다. 그런데 'Base'가 참이고, P(n-1)이 참이라고 가정하면 P(n)이 참이 되긴 한다! 하면 P(n-1)이 거짓이 되는 경우는 발생하지 않다고 보면 될 것 같다. 결국 P(n-1)도 P(n-2)가 참일 때 참이 되는 Step에 포함됐기 때문일 것이다.


 

'CS > 자료구조' 카테고리의 다른 글

[자료구조] Halting Problem  (0) 2025.06.25
[자료구조] P vs NP 문제  (0) 2024.03.30

클래스

선언부 + 구현부

 

선언부

  • 멤버 변수, 멤버 함수 선언
  • 멤버에 대한 접근 권한 지정: private(default), protected, public

구현부

  • 정의된 모든 멤버 함수 구현

생성자

  • 객체 생성시 자동 호출되는 멤버 함수
  • 클래스와 이름이 동일, 리턴 타입 X (void도 안 됨)
  • 멤버 변수 값 초기화, 메모리 할당, 파일 열기 네트워크 연결 등
  • 선언되어 있지 않으면 컴파일러에 의해 기본 생성자 자동으로 생성

멤버 이니셜라이저

생성자 정의 시 함께 호출 가능

  1. 메모리 공간 할당
  2. 이니셜라이저 -> 멤버 변수 초기화
  3. 생성자 몸체 부분 실행

반드시 이니셜라이저에서 초기화시켜야하는 멤버 변수: const 타입, 참조 타입

 

멤버 변수 초기화 방법

  • 클래스 선언부
class MyClass {
	int count = 0;
}
  • 생성자 내부
MyClass::MyClass() {
	count = 0;
}

MyClass::MyClass(int num) {
	count = num;
}
  • 이니셜라이저
MyClass::MyClass(): count(0) {}

MyClass::MyClass(int num): count(num) {}

 

위임 생성자

  • 생성자에서 다른 생성자 호출
  • 중복 코드 간소화

타겟 생성자

  • 다른 생성자에 의해 호출되는 생성자.
  • 멤버 초기화를 전담

위임 생성자

  • 다른 생성자를 호출하는 생성자.
  • 멤버 초기화를 위임
MyClass::MyClass(int x): count(x) {}

MyClass:MyClass(): MyClass(1) {}

 

디폴트 생성자

  • 생성자는 반드시 필요
  • 사용자가 생성자를 하나도 작성하지 않을 시 컴파일러가 자동으로 생성해주는, 매개변수가 없고 함수 내부 코드도 없는 생성자
  • 생성자를 하나라도 선언했으면 디폴트 생성자 자동 생성하지 않음
  • MyClass()=default; 로 명시적 생성 가능
  • MyClass()=delete; 로 명시적 삭제 가능
  • 디폴트 객체 생성 제한: 디폴트 생성자를 private로 선언

디폴트 생성자가 반드시 필요한 경우

 

객체 배열 생성 시

MyClass arr[10];
  • 디폴트 생성자 10번 호출

 

Private 생성자

  • 특성 함수를 통해서만 객체 생성
  • 자바의 정적 팩토리 메소드 패턴랑 유사한 듯

소멸자

  • 객체 소멸 시점에 자동으로 호출되는 함수
  • 한 번만 호출, 임의 호출 X
  • 객체 메모리 소멸 직전 호출
  • 오직 하나만 존재
  • 동적 할당 메모리 해제, 파일 저장 및 닫기, 네트워크 닫기 등
  • 선언돼있지 않으면 컴파일러에 의해 자동 생성 -> 아무 작업도 하지 않음
  • ~MyClass()

생성자 / 소멸자 실행 순서

  • 지역 객체: 선언된 함수 종료 시 소멸
  • 전역 객체: 프로그램이 종료할 때 소멸

생성 순서

  • 전역 객체: 선언 순서
  • 지역 객체: 선언된 함수의 호출 순서

소멸 순서

  • 생성 순서의 역순

new로 동적 생성된 객체

  • new 실행하는 순간 생성
  • delete 실행할 때 소멸

객체 배열 vs 객체 포인터 배열

객체 배열

MyClass arr1[5];
MyClass* arr2 = new MyClass[5];
  • 배열 생성 시 객체 생성됨

객체 포인터 배열

MyClass* arr[5];

arr[0] = new MyClass();
arr[1] = new MyClass();
arr[2] = new MyClass();
arr[3] = new MyClass();
arr[4] = new MyClass();
  • 배열 생성 이후에 별도로 객체 생성

'C, C++' 카테고리의 다른 글

[C++] 스마트 포인터  (2) 2025.07.02
[C++] 배열  (0) 2025.06.13
[C++] 변수와 자료형  (0) 2025.04.21
[C++] I/O stream  (2) 2025.04.19
[C] 소수 판별하기  (0) 2024.11.17

메모리 누수

힙은 스택처럼 프로그램이 스코프를 벗어나면 자동으로 해제하는 게 아니라 오로지 개발자에게 맡기므로, 개발자가 사용이 끝난 힙 영역의 메모리를 반환하지 않으면 반환할 수도, 사용할 수도 없는 누수된 메모리 발생

 

메모리 누수 체크

_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

 

더보기

스택의 메모리를 해제해주는 주체?

-> 컴파일러가 생성한 코드와 CPU의 스택 포인터(Register)

 


스마트 포인터

메모리 누수를 방지하기 위해 사용하는 포인터. 스마트 포인터로 지정한 객체가 스코프를 벗어나면 메모리 자동으로 해제

  • 헤더 파일: <memory>
  • 종류: unique_ptr, shared_ptr, weak_ptr
  • 스마트 포인터 생성시 힙에 객체 생성 후 해당 주소 소유, 스마트 포인터 자체는 스택에 생성.
    • 구조체 멤버로 들어가면 그 구조체가 있는 곳(힙·전역 등)에 함께 배치
    • new unique_ptr<int>처럼 스마트 포인터 자체를 new로 생성 가능 (그때는 스마트 포인터 객체도 힙)
  • '소유': 객체의 생‧사(할당·해제)의 책임을 지는 권리
    • 객체를 소유하는 스마트 포인터는, 객체가 살아있는 한 포인터가 존재하며, 마지막 소유자 소멸 시 객체의 deleter를 호출한다

 

unique_ptr

복사 불가능한 포인터 객체

  • unique_ptr이 가리키는 객체에 대한 단일 소유권(ownership) 을 가지는 스마트 포인터
  • 누구든 주소를 알 수는 있지만, delete는 오직 소유자만 할 수 있도록 보장
  • C++에서 흔한 메모리 누수나 이중 삭제 문제를 예방
  • 일반 포인터(T*)나 참조(T&)는 가리킬 수 있음
소유권 단독 한 시점에 오직 하나의 unique_ptr만 같은 자원의 소유자가 될 수 있음
복사 불가능 복사 생성자와 복사 대입 연산자는 삭제(delete) 되어 있어서 복사할 수 없음
이동 가능 move semantics를 통해 소유권을 다른 unique_ptr로 이동할 수 있음
소멸 시 자동 삭제 unique_ptr이 스코프에서 벗어나면 자신이 소유한 자원은 자동으로 delete 됨

 

생성 방법

std::unique_ptr<int> upt1 = make_unique<int>();
*upt1 = 100;

std::unique_ptr<int> upt2 = make_unique<int>(100);

// 비권장: 복잡한 객체 생성시 new와 unique_ptr 생성 사이에 오류 발생시 위험성 증가
// 여기선 std::unique_ptr<int> upt2 = make_unique<int>(100);와 완전히 동일
std::unique_ptr<int> upt3(new int(100));

// std::unique_ptr<int> upt4 = make_unique<int>(new int(100)); - ❌ int* 형을 전달하게 되므로 오류

// std::unique_ptr<int> upt5(upt1); - ❌ unique_ptr은 복사 생성자가 명시적으로 delete 돼있음!

 

값 할당

unique_ptr up = make_unique<int>(10);
*up = 11;
    
int* p = new int(12);

// up = p;	❌ 오류: unique_ptr은 복사할 수 없음 (p는 raw pointer임)
// up = new int(13);	❌ 오류: unique_ptr은 raw pointer를 대입받을 수 없음
// up.reset(14);	❌ 오류: reset은 포인터를 인자로 받아야 함
up.reset(p);	// ✅ up이 이전 자원(11)을 해제하고, p 소유

 

메소드

// get(): 일반 포인터처럼 사용
int* pt = upt.get();

// reset(): 내부 포인터 해제, 다른 객체로 변경
int* p = new int(200);
upt.reset(p);

// release(): 내부 포인터 반환 후 스마트 포인터 nullptr로 변환
int* pt = upt.release();
  • upt.reset(p)
    • int* p = new int(200)
      • 동적 메모리에 200 값을 가진 int 객체를 만들고 그 주소를 p가 가리킴
    • upt.reset(p)
      • 기존 upt가 소유한 자원(100을 담은 int 객체)을 delete
      • -> 새롭게 p가 가리키는 메모리의 소유권을 upt가 가져옴
      • upt와 p는 같은 객체를 가리킴
      • 단, 객체의 소유자는 upt
      • 나중에 upt가 소멸되면 delete p;와 같은 효과가 발생

 

복사 불가

std::unique_ptr<int> copyPt = upt; 	// 허용 X

 

이동은 가능

std::unique_ptr<int> movedPt = move(upt);

 

포인터에 대한 unique_ptr

unique_ptr udp1 = make_unique<int*>(new int(21));

int x = 12;
int* pp = &x;
int* ppp = new int(22);
    
unique_ptr udp2 = make_unique<int*>(pp);
unique_ptr udp3 = make_unique<int*>(ppp);

cout << "udp1: " << udp1 << '\t' 
<< "*udp1: " << *udp1 << '\t' 
<< "**udp1: " << **udp1 << endl;

cout << "udp2: " << udp2 << ", &pp: " << &pp << '\t' 
<< "*udp2: " << *udp2 << ", pp: " << pp << '\t' 
<< "**udp2: " << **udp2 << ", *pp: " << *pp << endl;

cout << "udp3: " << udp3 << ", &ppp: " << &ppp << '\t' 
<< "*udp3: " << *udp3 << ", ppp: " << ppp << '\t' 
<< "**udp3: " << **udp3 << ", *ppp: " << *ppp << endl;

 

출력 결과

 

udp1

  • new int(21): 힙에 21이라는 값을 가지는 int를 하나 생성 → int* 타입 반환 (0x122605ff0)
  • make_unique<int*>(...): 위에서 받은 int* 값으로 int* 생성 (0x122605f70에 0x122605ff0 저장)
  • => udp1은 int*를 가리키는 스마트 포인터 (unique_ptr<int*>)가 되고, 그 int* 안에는 0x122605ff0이 들어있음.
udp1 (unique_ptr) ───▶ [int* (heap, 0x122605f70)] ───▶ [int (heap, 0x122605ff0) = 21]

 

udp2

  • int x = 12: 스택에 x 생성 (값: 12, 주소: 0x16d8730dc)
  • int* pp = &x: x를 가리키는 pp를 스택에 생성 (값: 0x16d8730dc, 주소: 0x16d8730d0)
  • make_unique<int*>(pp): pp의 값(&x)을 복사하여 int* 객체를 에 생성 (값: 0x16d8730dc, 주소: 0x1226060d0)
  • udp2: 그 int* 주소를 udp2가 소유
udp2 ───▶ [int* (heap, 0x1226060d0)] ───▶ [&x (stack, 0x16d8730dc)]
         [int* (stack, 0x16d8730d0)] ───▶

 

udp3

  • new int(22): 힙에 22이라는 값을 가지는 int를 하나 생성 → int* 타입 반환 (값: 22, 주소: 0x1226060c0)
  • int* ppp: 스택ppp 생성 (값: 0x1226060c0, 주소: 0x16d8730c8)
  • ppp가 가리키는 주소(0x1226060c0)를 복사해서 int* 객체를  생성 (값: 0x1226060c0, 주소: 0x1226060e0)
  • 그 int*를 udp3가 소유
udp3 ───▶ [int* (heap, 0x1226060e0) = ppp (힙)] ───▶ [int 22 (heap, 0x1226060c0)]
                     [ppp (stack, 0x16d8730c8)] ───▶

 


shared_ptr

리소스 공유 가능한 포인터

  • 여러 개의 shared_ptr 인스턴스가 하나의 자원(동적 메모리)을 공유할 수 있도록 만들어진 포인터
  • shared_ptr에 대한 대입 연산이 발생할 때마다 레퍼런스 카운트가 하나씩 증가
  • 레퍼런스 카운트가 0이 되면 객체 삭제
공유 소유권 여러 개의 shared_ptr 인스턴스가 같은 자원을 공유할 수 있음
복사 가능 복사 생성자와 복사 대입 연산자를 통해 자원 공유 가능 (참조 카운트 증가)
이동 가능 std::move로 소유권 이전 가능 (이동 시 참조 카운트 이동)
참조 카운트 관리 내부적으로 reference count를 유지하며, 마지막 shared_ptr가 파괴될 때 자원 해제
소멸 시 자동 삭제 참조 카운트가 0이 되면 자원을 자동으로 delete
weak_ptr과 함께 사용 가능 순환 참조(Cyclic reference) 문제를 방지하기 위해 weak_ptr로 보조 가능

 

생성 방법

std::shared_ptr<int> spt1 = make_shared<int>();
*spt1 = 100;

std::shared_ptr<int> spt2 = make_shared<int>(100);

// 비권장
std::shared_ptr<int> spt3(new int(100));

 

메소드

// get(): 일반 포인터처럼 사용
int* pt = spt.get();

// reset(): 내부 포인터 해제, 다른 객체로 변경
spt.reset(200);

// release(): 지원하지 않음
int* pt = upt.release();	// ❌

// use_count(): 현재 strong 카운트
spt.use_count();

// unique(): use_count() == 1
spt.unique();

 

복사 가능 (레퍼런스 값 증가)

shared_ptr spt1 = make_shared<int>(23);		// use_count = 1

int x = 34;
int* p = &x;

shared_ptr spt2 = spt1;				// use_count = 2
// shared_ptr spt3 = p;	- ❌
// shared_ptr<int> spt4 = p;	- ❌

shared_ptr spt5(spt1);				// use_count = 3
// shared_ptr spt6(p);	- ❌
shared_ptr<int> spt7(p);
  • Class Template Argument Deduction (CTAD, 템플릿 인자 자동 추론)
    • C++17부터만 지원, shared_ptr에 대해선 다른 shared_ptr/weak_ptr에서 복사할 때만 가이드가 있음
    • raw pointer에서 바로 추론해 주는 가이드는 없음
  • spt3
    • CTAD로 추론 가능한 가이드가 없음
  • spt4
    • shared_ptr<int>(p) 로 변환이 필요한데, explicit 생성자라 암시적 변환이 불가
    • explicit: 암시적(implicit) 형 변환을 막아 주는 키워드. 컴파일러에 알림
  • spt6
    • spt3과 동일: CTAD 가이드 부재

 

이동 가능

std::shared_ptr<int> movedPt = move(spt);

 

shared_ptr의 구조

제어 블록을 가리키는 포인터 + 실제 객체 포인터

en.cppreference.com

 

제어 블록(Control Block)

구성 요소
역할
strong count (shared count) shared_ptr가 몇 개나 이 객체를 소유하고 있는지
weak count weak_ptr가 몇 개나 참조 중인지 (일반적으로 strong count ≥ 1일 때 weak count도 1로 시작해 “+추가 weak” 구조)
deleter 객체를 파괴할 때 호출할 함수 포인터(또는 함수 객체)
allocator 제어 블록·객체를 해제할 때 쓸 할당자
데이터 영역 관리 대상 객체의 포인터 또는 make_shared 계열일 때는 객체 자체

 

두 가지 생성 경로

생성 방법
메모리 할당 횟수 배치(layout) 특징
① shared_ptr<T>( new T(...) )
(또는 shared_ptr<T> p(ptr))
2회
① new T
② 제어 블록
객체와 제어 블록이 분리된 주소 공간 예외 안전성은 괜찮지만 캐시 친화도↓
② make_shared<T>(...)
/ allocate_shared
1회 하나의 메모리 덩어리 안에 제어 블록 + 객체를 연속 배치 장점: 할당·해제 비용↓, 캐시 친화도↑
단점: 객체 크기 정보가 노출돼 최악의 경우 패딩이 늘어날 수 있음
더보기

allocate_shared

  • <memory> 헤더
  • “make_shared의 장점” + “내가 고른 할로케이터”
  • 사용자 정의 할로케이터(Allocator)를 지정해 shared_ptr을 만드는 함수 템플릿
  • make_shared는 내부적으로 std::allocator 를 사용
  • allocate_shared는 아무 할로케이터나 넘길 수 있어, 메모리 풀·고정 크기 버퍼·디버그용 트래킹 할로케이터 등에 활용

 할로케이터 (Allocator)

  • C++ 표준 라이브러리 컨테이너가 “메모리를 언제·어떻게 확보하고 해제할지”를 외부 객체에게 위임하는 메커니즘
  • 컨테이너·스마트 포인터는 할로케이터를 통해서만 operator new/delete 또는 그에 준하는 저수준 함수를 호출
더보기

내부 실행 순서(공통)

  1. 메모리 예약
    • 위 표에 따라 1회 또는 2회의 operator new 호출
  2. 제어 블록 초기화
    • strong count = 1, weak count = 1로 시작
      • weak count가 1인 이유: “자신을 포함한” shared count가 0이 되는 순간에도 제어 블록 자체를 보존해야 함
      • 암묵적 weak 참조(implicit weak reference) — 제어 블록 자신이 들고 있는 “한 자리”
  3. 객체 생성
    • make_shared라면 제어 블록 내부에 placement new.
      • placement-new
        • 이미 확보해 둔 메모리 주소에 객체를 직접 생성하는 연산자
        • 메모리를 한 번에 크게 확보해 두고 그 안에 여러 객체를 “배치”(placement)해 할당·해제 비용을 줄임
        • 특수한 정렬(Alignment)·풀(Pool)·공유 메모리 등 사용자 정의 메모리에 객체를 놓고 싶을 때 사용
        • 일반 new처럼 “메모리 + 생성”이 아니라 “생성만” 수행 → 메모리를 직접 free 하면 안 되고, 객체를 지울 때는 소멸자를 직접 호출해야 함: p->~Type();
    • 그 외에는 앞서 확보한 “객체용 힙”에 new T(...).
  4. shared_ptr 인스턴스 채우기
    • this->ptr_ = 객체주소
    • this->cb_ = 제어블록주소
  5. 예외 발생 시 롤백
    • 객체 생성이나 제어 블록 초기화 중 예외가 나면, 이미 확보한 메모리는 모두 해제(스마트 포인터가 예외 안전성을 갖는 이유).

 

그 후의 레퍼런스 카운트 변화

  • 복사(copy) → strong count += 1
  • 이동(move) → 카운트 변화 없음, 원본 포인터는 nullptr
  • 소멸/reset() → strong count -= 1
    • 0이 되면 객체 파괴 → weak count -= 1
    • weak count도 0이면 제어 블록 자체 해제

(카운트 연산은 대부분의 구현에서 std::atomic 연산으로 수행, 증가 시 memory_order_relaxed, 감소 시에는 파괴 안전을 위해 좀 더 강한 ordering을 사용함)

 

 

std::shared_ptr<T> p = std::make_shared<T>();
단계 strong weak 설명
제어 블록 생성 0 1 암묵적 weak 참조만 갖고 등장
T 객체 placement-new 0 1 객체는 아직 살아 있지만 strong=0
p 에 제어 블록 연결 1 1 첫 shared_ptr 하나 생성
=> steady state 1 1 strong(1) + 암묵적 weak(1)

 

포인터에 대한 shared_ptr

힙에 int* 값 한 칸을 만들어 관리한다는 점에서는 거의 동일하지만, shared_ptr은 제어 블록도 함께 생성

 

레퍼런스 카운터의 변화

void addNum(shared_ptr<int> spt) {
	(*spt)++;
    cout << spt.use_count() << endl;		// 2
}

int main(void) {
	shared_ptr<int> spt = make_shared<int>();
    *spt = 3;
    cout << spt.use_count() << endl;		// 1
    addNum(spt);
    cout << spt.use_count() << endl;		// 1
    
    return 0;
}

 


weak_ptr

shared_ptr의 참조 횟수를 증가시키지 않고 객체에 액세스 할 수 있는 포인터

  • 리소스를 직접 소유하지 않음
  • shared_ptr만 사용하면 자기 자신을 가리키는 순환 참조 발생 위험이 있어 weak_ptr로 관리

 

순환 참조

순환 참조 발생 이유

  • 두 shared_ptr이 서로를 가리킴 + 외부에서 해제 불가
더보기

shared_ptr이 '서로를 가리킨다'?

 

shared_ptr이 가리키는 객체는 하나 아닌가? 어떻게 서로를 가리키지?

shared_ptr이 가리키는 객체 또한 shared_ptr일 때만 가능하지 않나? 했는데 아닌 경우도 있다 함.

 

~지피티가 소개하는 예시 두 가지~

 

1.  shared_ptr이 가리키는 객체 내부에 shared_ptr 존재

class Person {
    std::string name;
    std::shared_ptr<Person> partner;
    Person(std::string n) : name(n) { std::cout << name << "Born" << endl };
    ~Person() { std::cout << name << "Died" << endl };
}

int main(void) {
    {
    	std::shared_ptr<Person> alice = make_shared<Person>("Alice");
    	std::shared_ptr<Person> bob = make_shared<Person>("Bob");
        
        alice->partner = bob;
        bob->partner = alice;
    }
    
    return 0;
}

 

  • 스코프를 벗어나며 alice, bob은 소멸
  • 객체를 가리키는 shared_ptr의 개수가 0이 되어야 ~Person()을 호출할 수 있는데, alice는 bob에 의해, bob은 alice에 의해 레퍼런스 카운트가 1로 남게 되어 ~Person() 호출 불가 -> 객체 해제 불가
  • unique_ptr은 소멸되며 객체가 해제되는 것과 달리, shared_ptr은 레퍼런스 카운트 값으며 객체의 해제 여부를 결정하기 때문에 이런 현상이 발생

 

2. 람다 캡처

#include <iostream>
#include <memory>
#include <functional>

struct Task {
    std::function<void()> work;
    Task()  { std::cout << "Task born\n";  }
    ~Task() { std::cout << "Task died\n"; }
};

int main() {
    // 1) Task 두 개를 각각 shared_ptr 로 소유
    auto A = std::make_shared<Task>();
    auto B = std::make_shared<Task>();

    // 2) 람다(콜백)가 "상대 Task"를 shared_ptr 로 캡처해서 보관
    A->work = [B]() { std::cout << "A calls B\n"; /* … */ };
    B->work = [A]() { std::cout << "B calls A\n"; /* … */ };

    // 3) 외부 포인터를 모두 해제
    A.reset();
    B.reset();

    std::cout << "== main end ==\n";
}
  • 미안 이해 못하겟음 

 

weak_ptr의 역할

  • 객체의 소멸 조건에 영향을 주지 않음 -> 순환 참조 방지
  • lock(): shared_ptr 리턴 받음
    • 마지막 shared_ptr가 사라지는 순간
      • strong count → 0 → T 파괴
      • 하지만 weak count는 여전히 1(암묵적) ⇒ 제어 블록은 아직 존재
      • 남아 있는 weak_ptr들이 lock()을 호출해 nullptr 여부를 안전하게 확인할 수 있음
    • 객체가 아직 살아 있는지 필요할 때 lock()으로 임시 shared_ptr(=소유권) 을 얻어 확인

 

순환 참조 방지

  • 소유권 방향을 먼저 정의 → 큰 쪽이 작은 쪽을 shared_ptr로 소유
  • 컨테이너끼리 교차 보유할 가능성이 있으면 한쪽을 weak_ptr 컨테이너로 두기

 

 

'C, C++' 카테고리의 다른 글

[C++] 클래스와 생성자  (0) 2025.07.03
[C++] 배열  (0) 2025.06.13
[C++] 변수와 자료형  (0) 2025.04.21
[C++] I/O stream  (2) 2025.04.19
[C] 소수 판별하기  (0) 2024.11.17

문제

어떤 프로그램 M과 그에 대한 어떤 입력 X가 있을 때, M(X)는 종료를 하는가?

  • M: 소스코드로 이루어진 프로그램
  • X: 문자열
  • M(X): 입력 X에 대한 M의 실행

M(X)를 실행하기 전에 종료 여부를 알고 싶음

  • M(X)를 실행하면, 종료하지 않는 경우에 대해선 종료 여부 판단이 불가하기 때문
  • 실행 전에 종료 여부를 알 수 있는 방법이 있는가?에 대한 문제
  • for(;;)같은 코드는 종료 여부를 바로 알 수 있으니, 모든 경우에 대해서 판단을 할 수 있지 않을까?
  • 판단이 가능한지 증명해보자

 

증명

  • by Alan Turing
  • 귀류법 사용

가정

M(X)의 종료 여부를 반환하는 프로그램 D가 존재

 

D

  • D(M, X)의 반환값: Yes / No
  • M(X) 종료 -> Yes
  • M(X) 무한히 실행 -> No
  • D는 반드시 종료

 

D'

D에 의해 D' 도출

  • D'(M,X)는 M(X)가 종료되면 무한히 실행, M(X)가 무한히 실행되면 종료
  • D'(M,X)는 D(M,X)가 Yes면 무한히 실행, D(M,X)가 No면 종료
  • 소스코드상에서: D(M,X)의 결과에 따라 무한루프 or 반환
  • D가 존재한다면, D'도 존재함
function D_(M, X) {	// D_ == D'
    if (D(M,X) == "Yes") {
        while(1) {};
    }
    return;
}

 

S

D'에 의해 S 도출

  • S(M)은 M(M)이 종료되면 무한히 실행, M(M)이 무한히 실행되면 종료
  • S(M)은 D'(M,M)이 종료되면 종료, D'(M,M)이 무한히 실행되면 무한히 실행
  • 소스코드상에서 D'(M,M) 호출 -> 내부에서 D 호출 중
  • D'이 존재한다면, S가 존재함
function S(M) {
    D_(M,M)
}

 

도출

M = S;

  • S(S)는 S(S)가 종료되면 무한히 실행, 무한히 실행되면 종료
  • M이 S일 때, S는 존재할 수 없게 됨
  • 모순 발생!
  • => D가 존재한다는 가정이 틀림
M(X)의 종료 여부를 판단하는 프로그램 D는 존재하지 않는다.

 

 

 

결론

Halting 문제는 풀 수 없다!

 


 

'CS > 자료구조' 카테고리의 다른 글

[자료구조] 수학적 귀납법  (2) 2025.07.08
[자료구조] P vs NP 문제  (0) 2024.03.30

정적 할당 배열

1차원 배열

초기화

int sa1[5] = {0, 1, 2, 3, 4};
int sa2[] = {0, 1, 2, 3, 4};
int sa3[5]{0, 1, 2, 3, 4};
int sa4[]{0, 1, 2, 3, 4};
int sa5[5] = {};
int sa6[] = {};
int sa7[5]{};
int sa8[]{};
int sa9[5];
// int sa10[]; - 애는 안 됨

 

방식 예시 설명
C 스타일 기본 int a[5] = {0, 1, 2, 3, 4}; 나머지는 0으로 자동 초기화
크기 생략 int a[] = {0, 1, 2, 3, 4}; 컴파일러가 크기 추론
C++11 uniform initialization int a[5]{0, 1, 2, 3, 4}; 중괄호 초기화 (zero-init 가능)
빈 중괄호로 0 초기화 int a[5] = {}; or int a[5]{}; 모든 요소 0으로 초기화
초기값 없는 선언 int a[5]; 쓰레기값 (초기화되지 않음)

 

🚫 초기화 불가능한 선언들

  • int arr[]; → ❌ 크기나 초기값이 없으면 안 됨
  • int arr[0]; → ❌ 대부분 컴파일러에서 경고 or 오류 (표준 위반)

 

2차원 배열

초기화

int sta1[3][3] = {0, 1, 2, 3, 4, 5, 6, 7, 8};
// int sta1_2[3][] = {0, 1, 2, 3, 4, 5, 6, 7, 8}; - 안 됨
int sta2[][3] = {0, 1, 2, 3, 4, 5, 6, 7, 8};
int sta3[3][3]{0, 1, 2, 3, 4, 5, 6, 7, 8};
int sta4[][3]{0, 1, 2, 3, 4, 5, 6, 7, 8};
int sta5[3][3] = {};
int sta6[][3] = {};
int sta7[3][3]{};
int sta8[][3]{};
int sta9[3][3];
// int sta10[][3]; - 얘는 안 됨
// int sta11[][]; - 얘도 안 됨

 

선언 가능 기준: 크기 추론이 가능 한가?

  • int arr[][3] = { ... };
    → 가능! 초기값이 있으면 컴파일러가 행 수를 추론
  • int arr[3][] = { ... };
    → ❌ 오류! 열 크기 생략은 불가능
  • int arr[3][3] = {}; or int arr[3][3]{}
    → 가능! 전부 0으로 초기화
  • int arr[3][3];
    → 가능하지만 초기화되지 않은 쓰레기값

크기 추론은 '초기화'가 있을 때 가능

 

 

❌ 안 되는 선언들

선언 이유
int arr[3][] = { ... }; 오른쪽 차원의 크기는 반드시 명시해야 함 ([][3]은 되지만 [][]는 안 됨)
int sta10[][3]; 초기화 없이 선언하면 크기 추론 불가능

 

Q1. int sta1_2[3][] = {0, 1, 2, 3, 4, 5, 6, 7, 8} 는 크기 추론이 가능하지 않나? 왜 오류가 뜰까?

  • C++에서 다차원 배열을 선언할 때는 맨 오른쪽 차원의 크기필수
  • 컴파일러는 메모리에서 arr[i][j]를 접근할 때 다음과 같이 계산:
*(arr + i * col + j)
 
  • int sta1_2[3][]처럼 오른쪽 크기가 없다면 col(오른쪽 차원 크기)을 모르기 때문에 주소 계산이 불가능
  • 초기화 값 개수랑 행(row) 개수를 알면 열(col) 개수도 알 수 있지 않나?
    • 이론적으로는 맞음. 컴파일러는 허용 X
    • col은 반드시 컴파일 타임에 알아야 함

 

Q2. int sta6[][3] = {}, int sta8[][3]{} 도 크기 추론이 불가능하지 않나? 왜 오류가 뜨지 않을까?

 

  • {}는 빈 초기화 목록이지만, 여전히 초기화 구문
  • 초기화가 있으므로 → 크기를 추론할 기회를 줌
  • int arr[][3] = {} → 빈 배열로 초기화 → 0행으로 추론됨 → OK
  • int arr[][3]; → 초기화 없음 → 추론 불가 → 오류 ❌

int arr[][3]{}은 사실상 int arr[0][3]{}로 동작

  • 허용하는 컴파일러에선 OK (GCC/Clang), 일부에서는 경고

 

2차원 배열에 대한 포인터

int sta1[3][3] = {0, 1, 2, 3, 4, 5, 6, 7, 8};
// int (*psta1)[3] = {0, 1, 2, 3, 4, 5, 6, 7, 8}; - 안 됨
int (*psta2)[3] = sta1;
// int** psta3 = sta1;	- 얘도 안 됨
// int* psta4[3] = sta1; - 얘 역시

 

psta1

  • {0, 1, 2, 3, 4, 5, 6, 7, 8}는 배열 '리터럴' - 주소값이 아님!
  • 해당 배열을 int (*)[3] 타입으로 변환할 방법이 없음

 

psta2

  • sta1은 sta1[0]의 주소로 decay됨, 즉 &sta[0] -> int (*)[3] 타입
  • sta1 자체는 int[3][3] 타입

 

psta3 이건 나만 헷갈렸을 것 같지만 일단 써 봄

  • int**는 포인터 배열을 가리키는 포인터 (int* → 여러 개 존재)
  • int[3][3]은 단순한 연속된 메모리 (int가 9개 존재)
    • sta1은 {0,1,2}, {3,4,5}, {6,7,8}에 대한 포인터가 존재하지 않음
    • 이중 포인터로 표현하고 싶으면 다음과 같아야 함
int* p0 = sta1[0];  // p0 → {0,1,2}
int* p1 = sta1[1];  // p1 → {3,4,5}
int* p2 = sta1[2];  // p2 → {6,7,8}

int* sta[] = {p0, p1, p2};  // 포인터 배열
int** psta = sta;

 

  • C언어에서 문자열의 배열을 이중포인터로 표현했던 것에서 헷갈린 듯
char* arr[] = {"apple", "banana", "cherry"};
  • 여기서 원소 하나하나는 '문자열 리터럴' -> 텍스트 영역에 들어감
  • 문자열 하나하나에 대한 포인터가 스택에 생성
  • 그 포인터들(3개)에 대한 배열이 arr
  • char** arr2 = arr; 가 가능

 

psta4

  • 어? psta2랑 같은 거 아님?
  • ㄴㄴ psta3랑 호환됨
  • int (*)[3]: int가 3개 있는 배열에 대한 주소를 가리키는 포인터
  • int* [3]: int에 대한 주소를 가리키는 포인터가 3개 있는 배열
  • 얘 때문에 이중 포인터도 돼야하는 거 아닌가? 라고 생각한 듯

 


동적 할당 배열

1차원 배열

초기화

int* da1 = new int[5]{0, 1, 2, 3, 4};
// int da2[5] = new int[5]{0, 1, 2, 3, 4}; - 안 됨

 

da2는 왜 안 될까?

  • new int[5]{...}은 동적 할당된 int 배열의 주소를 반환 -> 반환형: int* (포인터)
  • int da2[5] → 정적 배열 선언
  • 포인터 → 배열로 복사하려는 잘못된 시도: 배열에 포인터를 복사할 수 없음

 

2차원 배열

초기화

int (*dta1)[3] = new int[3][3];
int (*dta2)[3] = new int[3][3]{0, 1, 2, 3, 4, 5, 6, 7, 8};
// int (*dta3)[3] = new int[][3]{0, 1, 2, 3, 4, 5, 6, 7, 8}; - 안 됨

 

dta3는 왜 안 될까?

  • C++의 new 연산자: 반드시 전체 크기를 알아야 메모리를 할당 가능 -> 첫 번째 차원의 크기(행 개수) 생략 불가능
  • 크기 추론(size deduction): C++에서 컴파일러가 컴파일 타임에 해주는 일
  • new는 런타임 동적 할당
  • 컴파일러가 초기화 리스트만 보고 크기를 추론하지 않음 -> 행 개수 명시

 

 

이중 포인터

int** pdta1 = new int*[3];
int** pdta2 = new int*[3];

for (int i=0; i<3; i++) {
	pdta1[i] = new int[3];
}

for (int i=0; i<3; i++) {
	pdta2[i] = new int[3]{1, 2, 3};
}

 

 

메모리 해제

// dta - int* dta = new int[3*3] 과 동일
delete[] dta;


// pdta - 행마다 new로 할당 -> 각 행은 독립적인 배열
for (int i = 0; i < rows; ++i) {
    delete[] pdta[i];
}

delete[] pdta;

 

 


함수 전달

일반 변수

#include <iostream>

void plus1(int a) {
    a += 1;
}


int main(void) {
    int x = 1;
    
    plus1(x)

    std::cout << x << std::endl;

    return 0;
}

 

1 출력

=> 함수 내 연산 결과 반영 X

 

 

포인터 변수

#include <iostream>

void plus1(int *a) {
    *a += 1;
}


int main(void) {
    int x = 1;
    
    plus1(&x)

    std::cout << x << std::endl;

    return 0;
}

 

2 출력

=> 함수 내 연산 결과 반영 O

 

 

레퍼런스 타입

#include <iostream>

void plus1(int &a) {
    a += 1;
}


int main(void) {
    int x = 1;
    
    plus1(x)

    std::cout << x << std::endl;

    return 0;
}

 

2 출력

=> 함수 내 연산 결과 반영 O + 함수 내에서 일반 변수처럼 접근 가능

 

내부적으로는 포인터와 동일하게 동작하되, 문법이 더 직관적

 

 


헷갈린 점

int *(&arr1)[];
// int &(*arr2)[]; - 안 됨

 

arr1: 포인터 배열의 레퍼런스 변수

arr2: 레퍼런스를 요소로 가지는 배열을 가리키는 포인터 변수 -> 레퍼런스는 배열의 요소가 될 수 없음

 


 

'C, C++' 카테고리의 다른 글

[C++] 클래스와 생성자  (0) 2025.07.03
[C++] 스마트 포인터  (2) 2025.07.02
[C++] 변수와 자료형  (0) 2025.04.21
[C++] I/O stream  (2) 2025.04.19
[C] 소수 판별하기  (0) 2024.11.17

+ Recent posts