Embedded Blog — Embedded 공부 공간입니다.
'분류 전체보기' 카테고리의 글 목록
// list

분류 전체보기

리눅스 파일 접근 권한과 실행 루트 관리(chmod, chown, chroot)

Summary: chmod는 파일 모드 비트를 바꿔 사용자별 접근 범위를 정하고, chown은 그 권한을 적용받는 소유자와 그룹을 바꾼다. chroot는 이 둘과 달리 프로세스가 절대 경로를 해석하는 루트를 변경하며, 별도의 실행 환경을 구성할 수 있지만 완전한 보안 격리를 제공하지는 않는다.

chmod

chmod(Change Mode)는 파일과 디렉터리의 접근 권한을 변경한다. 파일에는 소유자, 그룹, 그 외 사용자라는 세 범주가 있고, 각 범주에는 읽기·쓰기·실행 권한을 서로 다르게 줄 수 있다. chmod를 사용하는 이유는 이 접근 범위를 실제 용도에 맞게 나누기 위해서다. 일반 파일에서는 내용을 읽고 수정하고 실행할 수 있는지를 정하고, 디렉터리에서는 목록 조회, 내부 파일 생성·삭제, 경로 탐색 가능 여부를 조정한다.

 

기본 구문은 다음과 같다.

chmod [OPTION]... {MODE | --reference=REF_FILE} FILE...

MODE를 직접 지정하거나 --reference로 다른 파일의 모드를 가져올 수 있다. 권한은 기호 모드와 8진수 모드 중 하나로 표현한다. 파일 모드 비트를 바꿀 수 있는 프로세스는 유효 사용자 ID가 파일 소유자의 사용자 ID와 일치하거나, 해당 작업에 필요한 권한을 가지고 있어야 한다. 명령이 성공하면 종료 상태는 0, 실패하면 0이 아닌 값이다.

파일 모드 비트

파일 모드 비트는 일반 접근을 제어하는 권한 비트와 일부 파일에만 영향을 주는 특수 모드 비트로 구성된다. 일반 권한을 해석할 때는 먼저 권한의 대상이 누구인지 확인해야 한다.

기호 범주 의미
u user 파일 소유자
g group 파일 그룹에 속한 사용자
o others 그 외 사용자
a all 모든 사용자, ugo와 동일

rwx의 의미는 일반 파일과 디렉터리에서 다르다.

권한 일반 파일 디렉터리
r 4 내용 읽기 항목 목록 조회
w 2 내용 변경 내부 파일 생성·삭제
x 1 프로그램으로 실행 내부 파일 접근·경로 탐색

특히 디렉터리의 w와 x를 일반 파일의 쓰기·실행과 같은 의미로 읽으면 권한을 잘못 판단하기 쉽다. 디렉터리에서 w는 내부 항목을 만들거나 지우는 동작에 관여하고, x는 그 디렉터리를 경로의 일부로 사용해 내부 파일에 접근하는 권한이다.

기호 모드

기호 모드는 현재 권한을 기준으로 일부 비트만 추가하거나 제거할 때 적합하다. 표현은 사용자, 연산자, 권한 순서이며 내부에 공백을 넣지 않는다.

[ugoa][+-=][rwxXst]

연산자는 기존 권한을 어떻게 다룰지 결정한다.

연산자 의미
+ 기존 권한에 지정 권한 추가
- 기존 권한에서 지정 권한 제거
= 기존 권한을 지정 권한으로 교체

r, w, x는 읽기, 쓰기, 실행·탐색을 뜻한다. 대문자 X는 디렉터리이거나 이미 실행 권한이 하나라도 있는 파일에만 실행·탐색 권한을 적용한다. s는 u와 결합하면 set-user-ID, g와 결합하면 set-group-ID를 가리키고, t는 sticky bit를 가리킨다. 서로 다른 변경을 한 번에 적용할 때는 쉼표로 구분한다.

chmod a=rw foo
chmod go-w foo
chmod u=rwx,g=rx,o= foo
chmod a+X dir

첫 번째 명령은 모든 사용자에게 읽기·쓰기만 허용한다. 두 번째는 소유자를 제외한 그룹과 그 외 사용자에게서 쓰기 권한만 제거하므로, 나머지 권한에는 영향을 주지 않는다. 세 번째처럼 =를 사용하면 각 사용자 범주의 권한을 명시한 값으로 교체한다. 마지막 명령의 X는 디렉터리와 기존 실행 파일만 선별한다는 점에서 소문자 x와 구분된다.

8진수 모드

8진수 모드는 소유자, 그룹, 그 외 사용자의 rwx 조합을 각각 하나의 숫자로 나타낸다. 각 자리는 읽기 4, 쓰기 2, 실행·탐색 1을 더해 계산한다.

권한 계산
0 --- 권한 없음
1 --x 1
2 -w- 2
3 -wx 2 + 1
4 r-- 4
5 r-x 4 + 1
6 rw- 4 + 2
7 rwx 4 + 2 + 1

세 자리 앞에는 특수 권한을 나타내는 자리가 올 수 있다.

[특수 권한][소유자][그룹][그 외 사용자]

숫자 모드는 항상 8진수로 해석되므로 선행 0은 필요하지 않다. 기호 모드보다 짧지만, 일반적으로 기존 권한의 일부를 조정하기보다 전체 권한을 절대값으로 설정한다는 차이가 있다.

모드 기호 표현 의미
644 u=rw,g=r,o=r 소유자 읽기·쓰기, 나머지 읽기
664 ug=rw,o=r 소유자·그룹 읽기·쓰기, 나머지 읽기
751 u=rwx,g=rx,o=x 소유자 전체, 그룹 읽기·실행, 나머지 실행
4751 u=srwx,g=rx,o=x 751 + set-user-ID
chmod 644 foo
chmod ug+x file

chmod 644 foo는 세 사용자 범주의 권한을 한 번에 확정한다. 반면 chmod ug+x file은 소유자와 그룹에 실행 권한만 추가한다. 최종 상태를 명확히 지정할 때는 숫자 모드가 짧고, 현재 설정을 유지하면서 특정 권한만 바꿀 때는 기호 모드가 변경 범위를 더 직접적으로 드러낸다.

특수 권한

특수 권한은 일반적인 읽기·쓰기·실행과 별도로 실행 프로세스의 유효 ID나 디렉터리의 파일 관리 방식에 영향을 준다.

8진수 값 기호 명칭  효과
4000 u+s set-user-ID 실행 시 프로세스의 유효 사용자 ID를 파일 소유자로 설정
2000 g+s set-group-ID 실행 시 프로세스의 유효 그룹 ID를 파일 그룹으로 설정, 디렉터리에서는 새 항목이 디렉터리 그룹을 상속
1000 +t sticky bit 디렉터리에서 권한 없는 사용자의 파일 삭제·이름 변경을 파일 또는 디렉터리 소유자로 제한
chmod u+s program
chmod a-s program
chmod +t shared_dir
chmod 6755 shared_dir

디렉터리에 set-group-ID가 설정되어 있으면 그 안에 생긴 새 파일은 디렉터리의 그룹을 상속하고, 새 하위 디렉터리는 set-group-ID까지 상속한다. 여러 사용자가 같은 그룹을 기준으로 파일을 다루는 디렉터리에서는 소유 그룹이 이어지는 방식 자체가 권한 관리의 일부가 된다.

 

GNU chmod에서 디렉터리의 set-user-ID와 set-group-ID를 제거할 때는 제거 의도를 명시해야 한다. chmod a-s DIR, chmod =755 DIR, chmod 00755 DIR이 이에 해당한다. 일반 권한 세 자리만 보고 특수 비트도 항상 함께 지워질 것이라고 가정해서는 안 된다.

주요 옵션

chmod의 옵션은 재귀 처리, 출력 범위, 참조 파일 사용, 심볼릭 링크 처리 방식에 집중되어 있다.

옵션 의미
-R, --recursive 디렉터리와 내부 항목의 권한을 재귀적으로 변경
-c, --changes 실제 변경된 파일만 출력
-f, --silent, --quiet 변경 실패 오류 메시지 생략
-v, --verbose 처리한 모든 파일의 동작·비동작 출력
--reference=REF_FILE 참조 파일과 동일한 모드로 변경
--preserve-root /에 대한 재귀 변경 거부
--dereference 심볼릭 링크 자체가 아닌 대상에 적용
-h, --no-dereference 지원 시스템에서 심볼릭 링크 자체에 적용
-H -R 사용 시 명령행 인수인 디렉터리 심볼릭 링크만 탐색
-L -R 사용 시 발견한 모든 디렉터리 심볼릭 링크 탐색
-P 심볼릭 링크 탐색 안 함

심볼릭 링크를 명령행 인수로 직접 지정하면 기본적으로 링크가 가리키는 파일의 권한을 바꾼다. 반면 재귀 탐색 과정에서 발견한 심볼릭 링크는 기본적으로 무시한다. 같은 링크라도 명령행에서 직접 받은 경우와 디렉터리 아래에서 발견한 경우의 처리가 다르다.

 

-R과 -L 또는 역참조 옵션을 결합하면 범위가 더 넓어진다. 재귀 순회 중 공격자가 심볼릭 링크의 대상을 바꿀 수 있다면 원래 의도하지 않은 파일까지 권한 변경 대상이 될 수 있다. 루트 디렉터리를 재귀적으로 건드리지 않도록 하는 --preserve-root도 변경 범위를 통제하는 옵션이다.

 

chown

chown(Change Owner)은 파일과 디렉터리의 사용자 소유권 또는 그룹 소유권을 변경한다. chmod가 소유자·그룹·그 외 사용자에게 어떤 권한을 줄지 정한다면, chown은 그중 소유자와 그룹 자리에 누가 들어갈지를 바꾼다. 파일을 관리할 사용자나 그룹이 달라졌거나 여러 파일의 소유권을 기준 파일에 맞춰야 할 때 이 명령을 사용한다.

chown [OPTION]... {NEW_OWNER | --reference=REF_FILE} FILE...

소유자와 그룹은 공백 없이 다음 형식으로 지정한다.

[OWNER][:[GROUP]]
형식 사용자 소유자 그룹 소유자
OWNER OWNER로 변경 유지
OWNER:GROUP OWNER로 변경 GROUP으로 변경
OWNER: OWNER로 변경 OWNER의 로그인 그룹으로 변경
:GROUP 유지 GROUP으로 변경, chgrp와 같은 기능
: 유지 유지

OWNER와 GROUP에는 이름 또는 숫자 ID를 넣을 수 있다. 숫자 ID가 같은 문자열의 이름과 혼동될 가능성을 피하려면 앞에 +를 붙일 수 있다. 사용자와 그룹의 구분자는 :를 사용한다. 오래된 스크립트에서 보이는 . 표기는 이식성이 없고 GNU에서도 경고가 발생하며 지원이 제거될 수 있다.

 

Linux에서는 사용자 소유자를 바꾸는 작업을 CAP_CHOWN을 가진 특권 프로세스만 수행할 수 있다. 파일 소유자는 그룹을 자신이 속한 그룹 중 하나로 바꿀 수 있고, CAP_CHOWN을 가진 프로세스는 임의의 그룹으로 변경할 수 있다. 따라서 다음 세 명령은 문법만 다른 것이 아니라 변경 범위와 필요한 권한도 다르다.

chown root /u
chown root:staff /u
chown -hR root /u

첫 번째 명령은 사용자 소유자만 root로 바꾸고 그룹은 유지한다. 두 번째는 사용자 소유자와 그룹을 함께 바꾼다. 세 번째는 /u와 그 아래 항목의 소유자를 재귀적으로 바꾸되, 심볼릭 링크에서는 링크 자체를 대상으로 삼는다. 성공 시 종료 상태는 0, 실패 시 0이 아닌 값이다.

주요 옵션

옵션 의미
-R, --recursive 디렉터리와 내부 항목의 소유권을 재귀적으로 변경
-c, --changes 실제 변경된 파일만 출력
-f, --silent, --quiet 변경 실패 오류 메시지 생략
-v, --verbose 처리한 모든 파일 출력
--from=OLD_OWNER 현재 소유권이 지정값과 일치할 때만 변경
--reference=REF_FILE 참조 파일과 동일한 사용자·그룹으로 변경
--dereference 심볼릭 링크 자체가 아닌 대상에 적용, 비재귀 작업의 기본값
-h, --no-dereference 심볼릭 링크 대상 대신 링크 자체에 적용
--preserve-root /에 대한 재귀 변경 거부
-H -R 사용 시 명령행 인수인 디렉터리 심볼릭 링크만 탐색
-L -R 사용 시 발견한 모든 디렉터리 심볼릭 링크 탐색
-P 심볼릭 링크 탐색 안 함, 재귀 작업의 기본값

--from=OLD_OWNER는 파일의 현재 소유권이 예상한 값과 일치할 때만 변경한다. 소유권을 확인한 뒤 별도의 명령으로 바꾸는 방식보다 확인과 변경 사이의 간격이 짧고, 예상과 다른 소유권의 파일이 함께 바뀔 가능성도 줄어든다.

 

심볼릭 링크 처리는 비재귀 작업과 재귀 작업을 나눠 봐야 한다. 비재귀 작업에서는 링크 대상을 따라가는 --dereference가 기본이고, 재귀 작업에서는 링크를 탐색하지 않는 -P가 기본이다. -R과 -L 또는 역참조를 함께 사용하면 chmod와 마찬가지로 순회 중 바뀐 링크가 의도하지 않은 파일을 가리킬 수 있다.

 

소유권 변경은 권한 비트와 완전히 독립적인 작업도 아니다. 처리 결과 set-user-ID 또는 set-group-ID가 제거될 수 있으며, 실제 동작은 시스템 정책과 시스템 호출에 따라 달라진다. 소유자만 바꾼 뒤 기존 특수 권한이 그대로 남아 있다고 전제해서는 안 되는 이유다.

 

chroot

chroot(Change Root)는 명령을 실행하기 전에 프로세스의 루트 디렉터리를 지정한 디렉터리로 바꾼다. 이후 절대 경로는 원래 시스템의 /가 아니라 NEWROOT를 시작점으로 해석된다. 새 루트 안에 실행 파일, 라이브러리, 데이터 파일을 별도로 배치하고 그 구성을 기준으로 명령을 실행할 때 사용한다.

chroot [OPTION]... NEWROOT [COMMAND [ARGS]...]

동작 순서는 세 단계다.

  1. 존재하는 NEWROOT를 새 루트 디렉터리로 변경한다.
  2. 작업 디렉터리를 새 루트 내부의 /로 변경한다.
  3. COMMAND와 ARGS를 실행한다.

일반적인 절대 경로는 기존 /부터 탐색하지만, chroot 이후에는 같은 / 표기가 NEWROOT를 가리킨다. COMMAND를 생략하면 $SHELL을 사용하고, 이 환경 변수가 설정되어 있지 않으면 /bin/sh를 -i 옵션으로 실행한다. 별도 옵션이 없다면 명령의 실행 자격 증명은 호출 프로세스와 같다.

 

많은 시스템에서 이 명령은 슈퍼유저만 실행할 수 있다. Linux에서는 사용자 네임스페이스 안에서 CAP_SYS_CHROOT가 필요하다. chroot는 경로의 출발점을 바꾸는 작업이므로 새 디렉터리를 준비하는 것만으로 끝나지 않고, 그 안에서 실행할 명령이 실제로 사용할 파일까지 함께 구성해야 한다.

주요 옵션

옵션 의미
--userspec=USER[:GROUP] 다른 사용자와 기본 그룹으로 명령 실행
--groups=GROUPS 쉼표로 구분한 보조 그룹 지정
--groups='' --userspec에 따른 보조 그룹 조회 비활성화
--skip-chdir 새 루트 변경 후 작업 디렉터리를 /로 바꾸지 않음, NEWROOT가 기존 /일 때만 허용

--userspec은 새 루트에서 명령을 실행할 사용자와 기본 그룹을 지정하고, --groups는 보조 그룹 목록을 덮어쓴다. 사용자와 그룹 이름은 chroot 외부와 내부에서 모두 조회하며, 내부 조회가 성공하면 그 결과가 우선한다. 이름이 아니라 숫자 ID임을 분명히 할 때는 chown과 마찬가지로 앞에 +를 붙인다.

 

--skip-chdir는 이름 그대로 루트 변경 뒤의 작업 디렉터리 이동을 생략한다. 다만 NEWROOT가 기존 /일 때만 허용되므로, 일반적인 새 루트 구성에서 외부 작업 디렉터리를 그대로 유지하기 위한 옵션은 아니다.

실행 환경 구성

새 루트에서 명령을 찾더라도 실행에 필요한 파일이 그 안에 없다면 명령은 동작하지 않는다. 실행 파일의 링크 방식에 따라 준비 범위가 달라진다.

  • 정적 링크 실행 파일은 실행 파일 자체를 새 루트 내부에 배치한다.
  • 동적 링크 실행 파일은 실행 파일과 필요한 공유 라이브러리를 새 루트 내부의 올바른 경로에 함께 배치한다.
  • 실행 파일이 데이터, 상태 파일, 장치 파일을 요구하면 이 파일들도 필요한 위치에 준비한다.

동적 실행 파일의 공유 라이브러리는 ldd COMMAND로 확인한다. 예를 들어 정적으로 링크한 ls를 /tmp/empty/ls에 배치했다면 다음처럼 실행할 수 있다.

chroot /tmp/empty /ls -Rl /

여기서 명령에 적힌 /ls와 마지막 인수 /는 모두 원래 파일 시스템이 아니라 /tmp/empty를 루트로 해석한다. 새 루트 디렉터리 안의 구조가 실행 환경의 가시 범위를 결정하는 방식이다.

보안 특성

chroot는 경로 해석의 한 요소를 변경할 뿐, 완전한 샌드박스나 보안 격리 수단은 아니다. 명령 이름에 포함된 root가 관리자 계정을 뜻하는 것이 아니라 경로 탐색의 루트 디렉터리를 뜻한다는 점도 함께 구분할 필요가 있다.

 

chroot(2) 시스템 호출은 현재 작업 디렉터리를 바꾸지 않으며 열린 파일 디스크립터도 닫지 않는다. 현재 작업 디렉터리가 새 루트 밖에 있거나, 이미 열린 파일 디스크립터가 외부 파일을 가리키고 있다면 새 루트 바깥에 접근할 가능성이 남는다. GNU chroot 명령이 루트 변경 후 작업 디렉터리를 새 루트의 /로 옮기는 이유는 이 시스템 호출의 동작과 구분해서 봐야 한다. --skip-chdir를 지정하면 이 이동은 생략된다.

 

따라서 chroot가 보여 주는 파일 트리의 시작점이 바뀌었다는 사실과 프로세스가 완전히 격리됐다는 판단은 같지 않다. 이 명령으로 구성한 실행 환경을 보안 경계 자체로 해석할 수 없는 근거가 열린 파일 디스크립터와 현재 작업 디렉터리의 처리에 있다.

종료 상태

chroot는 자체 실패와 실행할 명령의 탐색·호출 실패를 서로 다른 종료 상태로 구분한다.

상태 의미
125 chroot 자체 실패
126 명령을 찾았으나 실행할 수 없음
127 명령을 찾을 수 없음
그 외 실행한 명령의 종료 상태

새 루트 안에 명령이 없으면 127, 파일은 있지만 필요한 조건을 갖추지 못해 호출할 수 없으면 126으로 구분된다. 실행까지 성공한 경우에는 chroot가 별도의 성공 값을 덮어쓰지 않고 실행한 명령의 종료 상태를 그대로 반환한다.

소프트웨어 프레임워크란

Summary: 프레임워크는 특정 종류의 애플리케이션에 공통으로 쓰이는 구조와 실행 흐름을 미리 구현해 둔 골격이다. 개발자는 정해진 확장 지점에 필요한 동작을 추가하며, 전체 흐름은 프레임워크가 관리한다. 이 제어 방식은 구조까지 재사용하게 해 주지만 학습과 디버깅, 다른 시스템과의 통합 비용을 동반한다.

프레임워크의 정의

프레임워크는 특정 종류의 애플리케이션이나 하위 시스템을 만들기 위한 재사용 가능한 설계이자 부분 구현이다. 그 자체가 완성된 프로그램은 아니다. 개발자가 정해진 확장 지점에 필요한 동작을 채워 넣어야 하나의 애플리케이션이 되기 때문에 반완성 애플리케이션이라고 부른다.

 

재사용 범위는 함수나 클래스에서 끝나지 않는다. 컴포넌트가 협력하는 방식, 공통 처리 순서와 제어 흐름, 반복되는 정책도 프레임워크에 포함된다. 같은 종류의 애플리케이션에서 반복되는 부분은 프레임워크가 맡고, 프로젝트마다 달라지는 동작은 개발자가 인터페이스와 규약에 맞춰 구현한다.

 

프레임워크를 도입하면 코드뿐 아니라 프레임워크가 정한 구조와 실행 규칙도 함께 들어온다. 넓은 범위를 재사용하는 대신 애플리케이션 구조가 프레임워크의 규약에 영향을 받는 이유다.

 

프레임워크의 핵심 구성

구성 역할
안정된 골격 공통 처리 순서와 컴포넌트 협력 구조 정의
공통 구현·서비스 여러 애플리케이션에서 반복되는 기능 제공
확장 지점(Extension Point) 애플리케이션별 동작을 연결하는 훅, 콜백, 인터페이스
제어 메커니즘 생명주기, 이벤트 루프, 디스패처, 컨테이너 등을 통해 호출 시점 결정
애플리케이션별 코드 확장 지점의 계약에 맞춰 구체적인 동작 제공
설정·등록 정보 사용할 컴포넌트와 구현을 프레임워크에 연결

안정된 골격과 공통 구현은 프레임워크가 맡고, 애플리케이션마다 달라지는 동작은 개발자가 확장 지점에 제공한다. 이 확장 지점에는 호출 시점, 입력과 반환 값, 다른 컴포넌트와 협력하는 방법까지 규약으로 정해져 있다.

 

생명주기나 이벤트 루프, 디스패처, 컨테이너는 어느 시점에 어떤 코드를 호출할지 결정한다. 개발자가 만든 구현은 설정·등록·조합·상속 가운데 프레임워크가 요구하는 방식으로 이 흐름에 연결된다.

 

제어의 역전

제어의 역전(Inversion of Control, IoC)은 애플리케이션이 전체 실행 순서를 직접 주도하지 않는 구조를 말한다. 프레임워크가 흐름을 관리하다가 필요한 시점에 등록된 애플리케이션 코드를 호출한다.

일반적인 라이브러리 사용
애플리케이션의 메인 흐름 → 라이브러리 호출 → 결과 반환 → 애플리케이션 흐름 계속

프레임워크 사용
프레임워크가 생명주기 관리 → 이벤트·단계 감지 → 등록된 애플리케이션 코드 호출 → 프레임워크 흐름으로 복귀

프레임워크는 언제 어떤 단계를 실행할지 정하고, 애플리케이션 코드는 그 단계에서 수행할 일을 제공한다. GUI 프레임워크라면 메인 이벤트 루프는 프레임워크가 관리하고, 애플리케이션은 각 이벤트에 대응할 핸들러를 등록한다. 실제 이벤트가 발생했을 때 핸들러를 호출하는 쪽도 프레임워크다.

 

의존성 주입(Dependency Injection, DI)은 IoC와 관련되지만 같은 개념은 아니다. 클래스가 필요한 객체를 직접 만들거나 찾지 않고 외부에서 받도록 하는 것이 DI다. 이는 클래스와 의존성 사이에서 IoC를 구현하는 한 가지 방법이다. 프레임워크의 IoC는 더 넓게 실행 흐름, 이벤트 처리, 객체 생성과 연결까지 다룰 수 있다.

 

라이브러리와 프레임워크의 차이

구분 라이브러리(Library) 프레임워크(Framework)
주된 재사용 단위 독립 기능을 제공하는 함수·클래스·컴포넌트 애플리케이션 골격, 컴포넌트 관계, 공통 구현
전체 흐름의 주도권 애플리케이션 코드 프레임워크
대표 호출 방향 애플리케이션 → 라이브러리 프레임워크 → 애플리케이션 콜백·훅
사용 방식 필요한 기능을 애플리케이션이 선택해 직접 호출 정해진 확장 계약에 맞춘 코드를 등록·상속·구성
구조에 미치는 범위 호출한 기능 중심 애플리케이션 구조와 생명주기에 영향

라이브러리는 애플리케이션이 실행 흐름을 유지하면서 필요한 기능을 직접 호출한다. 프레임워크는 애플리케이션의 골격을 제공하고, 확장 규약에 맞는 코드를 받아 전체 실행을 조직한다. 코드 크기나 기능 수보다 제어권과 재사용 범위가 더 분명한 구분 기준이다.

 

그렇다고 모든 개별 호출이 한 방향으로만 흐르는 것은 아니다. 프레임워크도 애플리케이션이 직접 호출할 메서드를 제공하고, 내부에서 여러 라이브러리를 사용한다. 표에서 말하는 호출 방향은 전체 실행과 컴포넌트 관계를 어느 쪽이 조직하는지를 뜻한다.

 

프레임워크 동작 흐름

먼저 프레임워크가 제공하는 콜백, 훅 메서드, 인터페이스, 기반 클래스 가운데 어디를 확장해야 하는지 확인한다. 그 규약에 맞춰 애플리케이션 코드를 구현한 뒤 설정이나 등록, 객체 조합 또는 상속으로 프레임워크에 연결한다.

 

실행이 시작되면 프레임워크 런타임이 메인 흐름과 생명주기를 관리한다. 이벤트 루프나 디스패처, 컨테이너가 처리 시점을 판단해 연결된 핸들러·훅·컴포넌트를 호출한다. 해당 처리가 끝나면 제어는 다시 프레임워크로 돌아간다.

[애플리케이션 코드 구현·등록]
              ↓
[프레임워크 시작 및 공통 흐름 관리]
              ↓
[이벤트 또는 생명주기 단계 발생]
              ↓
[등록된 콜백·훅 호출]
              ↓
[프레임워크 공통 흐름으로 복귀]

애플리케이션 코드는 독립적인 메인 흐름을 갖기보다 프레임워크가 마련한 처리 단계에 참여한다. 무엇을 구현할지만큼 언제, 어떤 상태와 입력으로 호출되는지를 나타내는 확장 규약이 중요하다.

 

확장 방식: 화이트박스와 블랙박스

구분 화이트박스 프레임워크 블랙박스 프레임워크
주요 확장 방식 상속, 동적 바인딩, 훅 메서드 재정의 인터페이스·규약 구현, 객체 조합, 위임
필요한 이해 내부 구조와 상속 계층에 대한 지식 외부 인터페이스와 컴포넌트 계약 중심
결합 지점 프레임워크 기반 클래스의 내부 설계 교체 가능한 컴포넌트 인터페이스
사용 측면 내부 구현 의존과 학습 부담 증가 가능 일반적으로 사용·확장하기 쉬움
설계 측면 상속과 재정의를 통해 확장 골격 제공 다양한 사용 사례를 수용할 인터페이스·훅의 선행 설계 필요

화이트박스 프레임워크는 기반 클래스를 상속하고 정해진 메서드를 재정의해 동작을 바꾼다. 어느 메서드를 어떤 규칙으로 재정의할지 판단하려면 상속 계층과 내부 구조를 알아야 한다. 그만큼 사용 코드가 프레임워크 내부 설계에 강하게 결합될 수 있다.

 

블랙박스 프레임워크는 정해진 인터페이스를 구현한 컴포넌트를 조합하거나 위임하는 방식으로 확장한다. 내부 구현보다 외부 인터페이스와 컴포넌트 간 계약을 알면 되므로 일반적으로 사용하기 쉽다. 대신 프레임워크를 설계하는 쪽에서는 여러 사용 사례의 변화 지점을 미리 찾아 인터페이스와 훅으로 만들어야 한다.

 

두 방식의 경계가 항상 선명한 것은 아니다. 하나의 프레임워크 안에서도 어떤 부분은 상속으로, 다른 부분은 컴포넌트 조합으로 확장할 수 있다.

 

장점

변하기 쉬운 구현을 안정된 인터페이스 뒤로 숨기면 변경의 영향을 해당 컴포넌트와 확장 지점 주변으로 제한할 수 있다. 프레임워크가 제공하는 모듈성은 이 경계에서 나온다.

 

재사용 범위도 개별 함수나 컴포넌트보다 넓다. 이들을 연결하는 애플리케이션 구조와 도메인 설계까지 다시 쓸 수 있다. 훅·콜백·인터페이스는 공통 골격을 바꾸지 않고 애플리케이션별 동작을 넣을 자리를 제공한다.

 

생명주기와 외부 이벤트의 전달은 프레임워크가 맡고, 애플리케이션 코드는 구체적인 처리에 집중한다. 여러 애플리케이션이 같은 처리 순서를 공유할 수 있는 이유다.

 

비용과 제약

프레임워크를 사용하려면 확장 지점뿐 아니라 생명주기, 컴포넌트 관계와 호출 규약까지 익혀야 한다. 라이브러리의 기능 하나를 호출할 때보다 이해해야 할 범위가 넓다. 어느 정도 학습하고 직접 적용해 보기 전에는 현재 문제에 맞는지 판단하기 어려울 수도 있다.

 

제어가 프레임워크와 애플리케이션 콜백 사이를 오가기 때문에 실행 경로가 코드에 명확하게 드러나지 않을 수 있다. 디버거로 흐름을 따라가면 프레임워크 내부와 애플리케이션 코드를 계속 넘나들게 되고, 결함이 어느 쪽에서 발생했는지 구분하기도 어렵다. 범용 컴포넌트와 애플리케이션별 확장을 결합한 상태로 검증해야 한다는 문제도 남는다.

 

서로 다른 이벤트 루프나 전달 방식, 컴포넌트 규약을 가진 프레임워크끼리는 통합하기 어렵다. 프레임워크의 요구사항이나 인터페이스가 바뀌면 그 확장 규약을 따르는 애플리케이션도 수정해야 한다. 공통 구조를 넓게 재사용하는 만큼 특정 프레임워크 구조에 대한 종속성도 커진다.

 

선택 기준

확인 항목 판단 질문
문제 영역 프레임워크의 공통 골격이 현재 애플리케이션 종류·도메인과 일치하는가
확장 지점 필요한 변화를 훅·콜백·인터페이스로 표현할 수 있는가
제어 흐름 프레임워크의 생명주기·이벤트 처리 방식이 애플리케이션 요구와 맞는가
통합성 기존 라이브러리·컴포넌트·다른 프레임워크의 이벤트 처리 방식과 결합할 수 있는가
학습 투자 규약과 내부 동작을 익히는 비용을 여러 기능·프로젝트에서 회수할 수 있는가
변경 대응 프레임워크의 버전·인터페이스 변화에 맞춰 애플리케이션을 유지할 수 있는가
검증·디버깅 프레임워크와 애플리케이션 콜백을 함께 추적할 문서·테스트·진단 수단이 있는가

기능 목록만 비교해서는 프레임워크의 적합성을 판단하기 어렵다. 현재 문제와 공통 골격이 맞는지, 필요한 변화를 제공된 확장 지점으로 표현할 수 있는지, 프레임워크의 생명주기와 이벤트 처리 방식이 요구사항과 충돌하지 않는지 확인해야 한다. 기존 라이브러리나 다른 프레임워크를 함께 사용한다면 제어 메커니즘과 컴포넌트 규약도 비교 대상이다.

 

학습과 유지 비용은 재사용 범위와 함께 봐야 한다. 독립된 기능 몇 개만 필요하고 실행 흐름을 직접 관리해야 한다면 라이브러리가 더 적합할 수 있다. 반대로 여러 기능이나 프로젝트에서 같은 구조와 생명주기가 반복된다면 프레임워크의 골격을 재사용할 여지가 있다. 버전 변화에 대응할 수 있는지, 콜백 흐름을 추적할 문서와 테스트·진단 수단이 있는지도 도입 전에 확인할 항목이다.

노트북과 Raspberry Pi의 TCP Client-Server 구성

Summary: 노트북을 Client, Raspberry Pi를 Server로 두고 같은 LAN에서 TCP 소켓으로 통신하는 구성을 정리한다. Raspberry Pi의 IP를 확인하는 방법, 서버가 어느 주소에 bind()할지의 선택, 클라이언트가 접속에 사용하는 정보, 요청-응답 데이터 교환 순서, 그리고 정상·실패 상태를 점검하는 명령과 판단 기준을 다룬다.

구성 전제

이 구성에서 클라이언트는 서버에 연결을 요청하고 데이터를 보내거나 응답을 받는 노트북 프로그램이고, 서버는 정해진 IP와 Port에서 연결을 기다리고 요청을 처리하는 Raspberry Pi 프로그램이다. 역할이 장치에 고정되어 있다는 점을 전제로 삼는다.

 

네트워크 조건은 두 가지다. 노트북과 Raspberry Pi가 같은 LAN에 연결되어 있어야 하고, 두 장치 사이의 통신이 차단되지 않은 상태여야 한다. 통신 방식은 TCP Socket을 쓴다. 소켓과 Client-Server 구조 자체의 개념은 Socket의 개념과 Client-Server 구조에서 다루므로, 이 글은 그 구조를 두 실제 장치에 올리는 문제에 집중한다. 이후 서술에서 Raspberry Pi의 LAN 주소는 <PI_IP>, 서버 Port는 <PORT>로 표기한다.

노트북(Client) ── 같은 Wi-Fi·Ethernet LAN ── Raspberry Pi(Server)
                 <PI_IP>:<PORT>

 

Raspberry Pi의 IP 주소 확인

클라이언트가 접속하려면 먼저 서버인 Raspberry Pi의 주소를 알아야 한다. 가장 간단한 방법은 Raspberry Pi 터미널에서 로컬 IP를 확인하는 것이다.

hostname -I

출력은 Raspberry Pi에 할당된 로컬 IP 주소 목록이다. 여기서 실제로 쓸 주소는 노트북과 같은 LAN에 연결된 wlan0 또는 eth0의 주소이며, 127.0.0.1처럼 Raspberry Pi 자신만 가리키는 Loopback 주소는 제외해야 한다. 이 구분을 놓치면 뒤의 bind 주소 선택에서 그대로 문제로 이어진다.

 

인터페이스별로 IPv4 주소를 나눠 보려면 다음 명령을 쓴다.

ip -4 address show

NetworkManager를 쓰는 환경이라면 nmcli로도 확인할 수 있다.

nmcli device show

이때 Wi-Fi를 쓰면 GENERAL.TYPE이 wifi인 블록의 IP4.ADDRESS를, Ethernet을 쓰면 ethernet인 블록의 IP4.ADDRESS를 본다. 주의할 점은 DHCP 환경에서는 재연결이나 재부팅 후 주소가 달라질 수 있다는 것이다. 따라서 접속 직전에 주소를 다시 확인하는 편이 안전하다. Raspberry Pi OS처럼 mDNS를 쓸 수 있는 환경에서는 IP 대신 raspberrypi.local 또는 변경한 <hostname>.local로도 주소를 확인할 수 있다.

ping raspberrypi.local

 

Server가 연결을 기다리는 IP와 Port

bind() 주소는 서버가 어느 로컬 네트워크 인터페이스와 Port로 들어오는 연결을 받을지 결정하는 값이다. 어떤 주소에 묶느냐에 따라 대기 범위와 외부 접속 가능 여부가 함께 달라진다.

Server의 Bind 주소 대기 범위 노트북에서 접속 가능 여부
0.0.0.0:<PORT> (INADDR_ANY) Raspberry Pi의 모든 로컬 IPv4 인터페이스 Raspberry Pi의 LAN IP로 접속 가능
<PI_IP>:<PORT> 지정한 LAN 인터페이스 해당 IP로 접속 가능
127.0.0.1:<PORT> Raspberry Pi 내부 Loopback 외부 노트북에서 접속 불가

실습용 서버는 대개 INADDR_ANY와 미리 정한 <PORT>에 bind()한 뒤 listen()한다. 모든 인터페이스에서 연결을 받으므로 어느 LAN IP로 들어와도 대응할 수 있기 때문이다. 반대로 127.0.0.1에 묶으면 Loopback으로만 받으므로 외부 노트북에서는 접속이 되지 않는데, 이 조합은 뒤의 실패 상태에서 자주 등장하는 원인이다.

 

여기서 구분해야 할 것은 서버가 대기하는 주소와 클라이언트가 목적지로 쓰는 주소다. 서버가 0.0.0.0에 묶더라도 클라이언트가 목적지로 삼아야 하는 값은 0.0.0.0이 아니라 실제 <PI_IP>:<PORT>다. 0.0.0.0은 "모든 인터페이스에서 받겠다"는 서버 측 의미일 뿐, 접속 대상 주소가 아니다. Port도 마찬가지로 서버가 bind()한 Port와 클라이언트가 connect()할 Port가 같아야 한다. 참고로 Linux에서 1024 미만 Port는 대개 권한이 필요하므로, 실습용 Port는 다른 서비스와 충돌하지 않는 번호로 고른다.

Server bind 주소: 0.0.0.0:<PORT>
Client 목적지:    <PI_IP>:<PORT>

 

Client가 Server 접속에 사용하는 정보

클라이언트가 서버에 붙을 때 필요한 정보는 네 가지이며, 모두 서버 쪽 설정과 짝이 맞아야 한다.

정보 Client에서의 사용
Server IP 또는 해석 가능한 호스트명 Raspberry Pi 위치 지정
Server Port Raspberry Pi 안의 Server 프로그램 지정
프로토콜 Server와 같은 TCP 사용
주소 체계 Server 주소와 같은 IPv4 또는 IPv6 사용

주소를 실제 접속에 쓰는 형태로 바꾸는 경로는 입력이 무엇이냐에 따라 갈린다. IPv4 문자열을 가지고 있으면 inet_pton()으로 주소 구조에 변환하고, 호스트명이거나 IPv4·IPv6를 함께 다뤄야 하면 getaddrinfo()로 접속 후보 주소를 얻는다. 어느 경로든 준비된 서버 소켓 주소를 connect()에 전달하면서 접속이 시작된다. 여기 등장하는 inet_pton()·getaddrinfo()·connect()를 비롯한 소켓 함수들을 C++에서 호출하는 방식은 C++ TCP Socket Programming에서 정리했다.

 

Client와 Server의 데이터 교환 순서

요청-응답 방식의 기본 순서는 다음과 같다.

  1. Raspberry Pi에서 Server 실행
  2. Server가 <PORT>에 bind()하고 listen()으로 연결 대기
  3. 노트북에서 Client 실행
  4. Client가 <PI_IP>:<PORT>로 connect() 호출
  5. TCP 3-way handshake 완료
  6. Server의 accept()가 클라이언트 전용 연결 소켓 반환
  7. Client가 요청 데이터 send()
  8. Server가 요청 데이터 recv() 후 처리
  9. Server가 응답 데이터 send()
  10. Client가 응답 데이터 recv()
  11. 통신 완료 후 각 연결 소켓 close()
노트북 Client                                Raspberry Pi Server
     |                                              |
     |                       bind() → listen() → accept() 대기
     |                                              |
     | -------- connect(<PI_IP>, <PORT>) -------->  |
     | <---------- TCP 연결 성립 -----------------  |
     |                                              |
     | ------------- 요청 데이터 ---------------->  | recv()·처리
     | <------------ 응답 데이터 -----------------  | send()
     |                                              |
     | ---------------- close() ----------------->  | close()

순서 자체는 요청 다음에 응답이 오는 형태지만, 이 순서가 TCP의 성질로 강제되는 것은 아니라는 점을 구분해야 한다. TCP는 연결이 성립하면 양쪽 모두 송신·수신이 가능하고, 실제 어느 쪽이 언제 보낼지는 클라이언트와 서버가 합의한 응용 계층 프로토콜이 정한다. 위의 요청-응답 순서도 그런 합의의 한 예다.

 

또 하나 유의할 것은 TCP의 데이터 단위다. TCP는 메시지가 아니라 바이트 스트림을 다루므로, 한 번의 send()와 한 번의 recv()가 일대일로 대응하지 않을 수 있다. 이 성질은 아래 확인 방법과 실패 상태 양쪽에서 다시 문제로 나타난다.

 

정상 통신 확인 방법

통신이 되는지 확인하는 작업은 주소 → 도달 가능성 → 서버 대기 → Port 접속 → 연결 상태 → 프로그램 동작 → 패킷 흐름 순으로 층을 좁혀 가며 진행할 수 있다.

확인 단계 명령·방법 정상 판단
Raspberry Pi 주소 hostname -I 사용할 <PI_IP> 확인
IP 도달 가능성 노트북에서 ping <PI_IP> 응답 수신, 단 ICMP 차단 시 통신 가능해도 실패 가능
Server 대기 상태 Raspberry Pi에서 ss -ltnp <PORT>가 LISTEN 상태
TCP Port 접속 노트북에서 nc -vz <PI_IP> <PORT> TCP 연결 성공, 응용 데이터 처리까지 보장하지는 않음
연결 상태 양쪽에서 ss -tnp 연결 중 ESTAB 상태 확인
프로그램 동작 accept, send, recv 반환값과 로그 확인 연결 수락, 양수 바이트 수, 예상 요청·응답 일치
패킷 흐름 Raspberry Pi에서 sudo tcpdump -i any tcp port <PORT> handshake와 양방향 TCP 패킷 확인

각 단계가 보장하는 범위가 서로 다르다는 점에 주의해야 한다. ping 성공은 IP 도달 가능성만 말해 주며, ICMP가 차단된 환경에서는 통신이 되는데도 ping이 실패할 수 있다. nc -vz로 TCP 연결이 성공해도 그것은 Port가 열려 있다는 뜻일 뿐 응용 데이터가 정상 처리된다는 보장은 아니다. 그래서 하위 계층 점검만으로는 부족하고, 응용 프로그램 기준의 확인이 따로 필요하다.

 

응용 프로그램 관점에서 정상 통신은 다음 다섯 조건으로 정리된다.

  1. Client의 connect() 성공
  2. Server의 accept() 성공
  3. 송신 측 send()가 양수의 처리 바이트 수 반환
  4. 수신 측 recv()가 양수의 수신 바이트 수 반환
  5. 수신한 실제 데이터가 약속한 요청·응답 형식과 일치

마지막 조건이 특히 중요하다. 앞의 네 조건이 모두 참이어도 데이터가 약속한 형식과 어긋나면 정상 통신으로 볼 수 없기 때문이다. 반환값 점검과 형식 점검은 별개의 문제다.

 

주요 실패 상태 확인

실패는 증상별로 먼저 확인할 항목이 다르다.

현상 우선 확인 항목
Connection refused Server 실행 여부, listen() 상태, Port 일치 여부
연결 시간 초과 <PI_IP> 정확성, 같은 LAN 여부, 방화벽·무선 AP의 장치 간 통신 차단
Raspberry Pi 내부에서만 접속 가능 Server가 127.0.0.1에만 bind()했는지 확인
Address already in use 같은 IP·Port를 사용 중인 다른 소켓 확인
데이터 일부만 수신 send()·recv() 반환 길이 처리, 메시지 경계 규칙 확인

증상을 층위로 나눠 보면 원인의 위치가 드러난다. Connection refused는 목적지에 도달은 했지만 그 Port에서 받아 줄 서버가 없다는 신호이므로 서버 실행과 Port 일치를 먼저 본다. 연결 시간 초과는 응답 자체가 돌아오지 않는 경우로, 주소가 틀렸거나 같은 LAN이 아니거나 중간에서 통신이 차단된 상황을 의심한다. "내부에서만 접속 가능"은 앞서 다룬 127.0.0.1 bind 문제가 실제 증상으로 나타난 형태다. 마지막의 "데이터 일부만 수신"은 TCP가 바이트 스트림이라는 성질에서 비롯되므로, 개별 명령이 아니라 send()·recv() 반환 길이 처리와 메시지 경계 규칙을 점검해야 한다.

C++ TCP Socket Programming

Summary: C++17 프로그램에서 Linux/POSIX TCP 소켓 API를 호출하는 방식을 정리한다. 소켓이 C++ 전용 클래스가 아니라 운영체제의 함수·구조체이고 핸들이 int 파일 디스크립터라는 전제에서 출발해, 주요 헤더와 자료형, 각 소켓 함수의 사용 주체와 역할, 서버·클라이언트의 동작 순서, 그리고 바이트 스트림 송수신에서 주의할 점을 다룬다.

조사 기준

여기서 다루는 구현 기준은 C++17 프로그램에서 Linux/POSIX TCP 소켓 API를 호출하는 방식이며, 대상 환경은 Raspberry Pi OS·Ubuntu 등 Linux다. 전제부터 분명히 하면, C++ 코드에서 쓰는 소켓은 C++ 전용 Socket 클래스가 아니라 운영체제가 제공하는 소켓 함수와 구조체다. 그래서 이후에 다루는 함수와 자료형도 언어 라이브러리가 아니라 시스템 콜과 그 주변의 것들이다.

 

소켓 핸들은 Linux/POSIX에서 int형 파일 디스크립터로 표현된다. 이 점이 소켓을 파일과 같은 방식으로 다루게 만드는 근거다. TCP/IPv4를 쓰는 기본 조합은 AF_INET, SOCK_STREAM, 그리고 프로토콜 인자로 0 또는 IPPROTO_TCP다.

 

주요 헤더와 자료형

필요한 선언은 여러 헤더에 나뉘어 있고, 헤더마다 담당하는 영역이 다르다.

헤더 주요 항목 역할
<sys/socket.h> socket, bind, listen, accept, connect, send, recv 소켓 생성·연결·송수신
<netinet/in.h> sockaddr_in, INADDR_ANY, IPPROTO_TCP IPv4 주소 구조와 상수
<arpa/inet.h> inet_pton, htons, htonl 주소 변환과 네트워크 바이트 순서 변환
<netdb.h> getaddrinfo, freeaddrinfo 호스트명·서비스명을 접속 가능한 주소 구조로 변환
<unistd.h> close 소켓 파일 디스크립터 닫기

자료형 쪽도 각각 다른 대상을 표현한다.

자료형 의미
int 소켓 파일 디스크립터
sockaddr 여러 주소 체계를 함수에 전달하기 위한 공통 주소 구조
sockaddr_in IPv4 주소와 Port를 저장하는 구조
socklen_t 소켓 주소 구조의 크기
ssize_t send, recv가 처리한 바이트 수 또는 오류 값

sockaddr과 sockaddr_in의 관계를 눈여겨볼 만하다. 실제 주소를 담는 것은 IPv4 전용인 sockaddr_in이지만, 소켓 함수들은 주소 체계가 다른 경우까지 받기 위해 공통 구조인 sockaddr을 인자로 요구한다. ssize_t가 "처리한 바이트 수 또는 오류 값"을 함께 표현한다는 점도 뒤의 송수신 처리에서 그대로 쓰인다.

 

주요 TCP Socket 함수

함수는 사용 주체(서버·클라이언트)와 역할, 그리고 성공 시 결과로 구분해서 보면 정리가 된다.

함수 사용 주체 역할 주요 결과
socket() Server·Client 통신 끝점 생성 성공 시 소켓 파일 디스크립터
setsockopt() 주로 Server 소켓 동작 옵션 설정 SO_REUSEADDR 등 설정
bind() 주로 Server 소켓에 로컬 IP와 Port 할당 성공 시 주소 연결
listen() Server 연결 대기 소켓으로 전환, 대기 큐 크기 지정 성공 시 수동 소켓
accept() Server 대기 중인 연결 하나 수락 성공 시 새 연결 소켓
getaddrinfo() Server·Client 호스트·서비스 정보를 주소 구조로 변환 IPv4·IPv6 후보 주소 목록
inet_pton() Server·Client 문자열 IP를 네트워크 주소 형식으로 변환 IPv4·IPv6 숫자 주소 변환
connect() Client 지정한 Server 주소로 연결 시작 성공 시 연결 상태
send() Server·Client 연결된 소켓에 바이트 전송 실제 전송 처리한 바이트 수
recv() Server·Client 연결된 소켓에서 바이트 수신 수신 바이트 수, 0이면 상대의 정상 종료
close() Server·Client 파일 디스크립터 해제 해당 소켓 사용 종료

bind(), listen(), accept()가 서버 쪽에 몰려 있고 connect()만 클라이언트 전용인 점은, 연결을 받아 두고 기다리는 쪽과 먼저 연결을 여는 쪽의 역할 차이가 함수 수준에서 드러난 것이다. 이 역할 차이를 수동 개방(Passive Open)·능동 개방(Active Open)으로 정리한 개념적 배경은 Socket의 개념과 Client-Server 구조에서 다룬다.

 

오류를 알리는 방식은 함수 계열에 따라 다르므로 섞어 쓰지 않도록 주의해야 한다. 대부분의 소켓 함수는 실패 시 -1을 반환하고 원인을 errno에 담지만, getaddrinfo()는 0이 아닌 오류 코드를 직접 반환하며 그 설명은 gai_strerror()로 얻는다. 즉 getaddrinfo()의 결과를 errno로 판정하려 하면 어긋난다.

 

값을 저장할 때의 변환도 정해져 있다. Port는 htons(<PORT>)로 호스트 바이트 순서를 네트워크 바이트 순서로 바꿔 저장하고, IPv4 주소는 inet_pton(AF_INET, <문자열_IP>, &주소.sin_addr)로 변환하거나 getaddrinfo()로 얻는다.

 

Server 프로그램의 동작 순서

서버는 소켓을 만들고 주소에 묶은 뒤 대기 상태로 전환하고, 연결을 수락해 데이터를 주고받은 다음 소켓을 닫는 순서로 진행된다.

  1. 소켓 생성 — socket(AF_INET, SOCK_STREAM, 0)
  2. 소켓 옵션 설정 — 필요 시 setsockopt()로 주소 재사용 등 설정
  3. 로컬 주소 구성 — sockaddr_in에 AF_INET, 대기 IP, htons(<PORT>) 저장
  4. 로컬 주소 할당 — bind()
  5. 연결 대기 시작 — listen()
  6. Client 연결 수락 — accept()가 새 연결 소켓 반환
  7. 데이터 수신·처리 — 연결 소켓에 recv() 사용
  8. 응답 전송 — 같은 연결 소켓에 send() 사용
  9. Client 연결 종료 — 연결 소켓 close()
  10. Server 종료 — 연결 대기 소켓 close()
socket() → setsockopt() → bind() → listen()
                                      ↓
                                  accept()
                                      ↓
                            recv() ↔ send()
                                      ↓
                           close(연결 소켓)
                                      ↓
                           close(대기 소켓)

이 순서에서 소켓이 두 개로 갈라진다는 점을 다시 확인하게 된다. accept()는 기본적으로 연결 요청이 없으면 대기하다가, 요청이 들어오면 선택된 클라이언트와 통신할 새 연결 소켓을 반환한다. 원래의 대기 소켓은 사라지지 않고 다음 클라이언트 연결을 계속 수락하는 데 쓰인다. 그래서 여러 클라이언트를 처리하려면 accept()를 반복 호출하면서 클라이언트별 연결 소켓을 따로 관리해야 한다. 9단계와 10단계에서 연결 소켓과 대기 소켓을 각각 닫는 것도 이 둘이 서로 다른 소켓이기 때문이다.

 

Client 프로그램의 동작 순서

클라이언트는 서버 주소를 준비해 변환하고, 소켓을 만들어 연결을 요청한 뒤 데이터를 주고받고 정리하는 순서로 진행된다.

  1. Server 주소 준비 — Server IP·호스트명과 Port 확인
  2. 주소 변환 — getaddrinfo() 또는 inet_pton() 사용
  3. 소켓 생성 — 주소 후보에 맞춰 socket() 호출
  4. Server 연결 요청 — Server 주소를 connect()에 전달
  5. 데이터 전송 — 연결 소켓에 send() 사용
  6. 응답 수신 — 같은 소켓에 recv() 사용
  7. 연결 종료 — close()
  8. 주소 목록 해제 — getaddrinfo() 사용 시 freeaddrinfo()
getaddrinfo() 또는 inet_pton()
              ↓
          socket()
              ↓
          connect()
              ↓
       send() ↔ recv()
              ↓
           close()

서버 순서와 비교하면 bind()·listen()·accept()가 없고 connect()가 그 자리를 대신한다. 서버는 자신의 주소에 소켓을 묶고 기다리는 반면, 클라이언트는 상대 주소를 만들어 그쪽으로 연결을 여는 것이 이 대비의 핵심이다. 3단계에서 소켓 생성을 "주소 후보에 맞춰" 한다고 한 것은 getaddrinfo()가 여러 후보 주소를 돌려줄 수 있기 때문이며, 그래서 마지막 8단계에서 freeaddrinfo()로 그 목록을 해제하는 정리가 따라온다.

 

TCP 송수신 시 주의점

TCP 바이트 스트림은 송신 순서를 보존하지만 응용 프로그램의 메시지 단위는 보존하지 않는 연속 바이트 흐름이다. 앞선 개념 단계에서 미뤄 둔 "메시지 경계 미보존"이 실제 송수신 코드에서 어떻게 문제로 나타나는지가 여기서 구체화된다.

  • send()는 요청한 전체 바이트보다 적은 수를 처리할 수 있다. 따라서 반환값만큼만 전송된 것으로 보고 나머지를 누적해서 마저 보내야 한다.
  • recv()는 요청한 크기가 다 찰 때까지 기다리지 않고, 현재 사용 가능한 일부 데이터만 반환할 수 있다.
  • recv()의 반환값은 세 가지로 해석된다. 양수는 수신 바이트 수, 0은 상대가 정상적으로 연결을 종료했다는 신호, -1은 오류다.
  • 메시지를 구분하려면 소켓 계층이 아니라 응용 계층에서 규칙을 정해야 한다. 고정 길이, 길이 헤더, 구분자 같은 방식이 그 예다.
  • Blocking Socket의 기본 동작상 accept()와 recv()는 처리할 연결이나 데이터가 없으면 대기한다.

send()가 부분 전송을, recv()가 부분 수신을 할 수 있다는 두 성질을 합치면, "한 번 보낸 것이 한 번에 그대로 도착한다"는 가정은 성립하지 않는다는 결론이 나온다. 메시지 경계를 응용 계층에서 직접 정의해야 하는 이유가 바로 이것이다. recv()가 0을 반환하는 경우를 오류로 오해하기 쉬운데, 이는 오류가 아니라 상대의 정상 종료라는 점도 구분해 두어야 한다. 이 송수신 규칙을 노트북·Raspberry Pi 구성에서 반환값과 명령으로 점검하는 방법은 노트북과 Raspberry Pi의 TCP Client-Server 구성에서 다룬다.

Socket의 개념과 Client-Server 구조

Summary: 소켓은 프로세스가 네트워크로 데이터를 주고받기 위해 사용하는 통신 끝점이며, 운영체제가 관리하고 Linux/POSIX에서는 파일 디스크립터로 접근한다. 이 글은 소켓과 TCP 스트림 소켓의 정의, IP 주소와 Port 번호가 각각 식별하는 대상, 서버와 클라이언트의 역할 비대칭, 그리고 서버 대기부터 클라이언트 접속·데이터 교환까지의 흐름을 정리한다.

Socket의 개념

소켓은 프로세스가 네트워크로 데이터를 송수신할 때 사용하는 통신 끝점(endpoint)이다. 실제 통신은 운영체제의 네트워크 스택이 담당하고, 응용 프로그램은 그 스택에 직접 손대는 대신 소켓이라는 추상화를 거친다. 소켓을 운영체제가 관리한다는 점과, Linux/POSIX에서 소켓이 파일 디스크립터로 접근된다는 점은 이 추상화의 성격을 그대로 보여준다. 응용 프로그램은 파일을 다룰 때와 같은 파일 디스크립터로 네트워크 입출력을 다루게 된다.

 

소켓의 역할은 네 가지로 나뉜다. 응용 프로그램과 운영체제 네트워크 스택을 연결하고, 상대 소켓과 연결을 설정하며, 데이터를 송수신하고, 마지막으로 연결을 종료한다. 이 네 역할은 뒤에서 다룰 접속 흐름의 각 단계와 거의 일대일로 대응한다.

 

소켓을 생성할 때는 세 가지 정보를 지정한다.

지정 정보 구분하는 것
주소 체계 AF_INET, AF_INET6 IPv4 / IPv6
통신 방식 SOCK_STREAM, SOCK_DGRAM 스트림 / 데이터그램
프로토콜 통신 방식에 대응하는 프로토콜 실제 사용할 프로토콜

이 중 SOCK_STREAM을 고르면 TCP 스트림 소켓이 된다. TCP 스트림 소켓은 연결된 두 끝점 사이에서 전이중(full-duplex) 바이트 스트림을 제공한다. 특성을 정리하면 연결이 성립한 뒤에야 송수신이 가능하고, 양방향 통신이 되며, 보낸 바이트의 순서가 보존된다. 여기서 주의할 것은 마지막 특성이다. 바이트 순서는 유지되지만 응용 프로그램이 구분한 메시지 경계는 보존되지 않는다. 뒤에서 "한 번 보낸 것이 한 번에 도착한다"는 가정이 깨지는 원인이 바로 이 성질이므로, 개념 단계에서 미리 짚어 둔다.

 

소켓 주소는 IP 주소와 Port 번호의 조합으로 정의된다.

소켓 주소 = IP 주소 + Port 번호

 

IP 주소와 Port 번호

IP 주소는 네트워크에서 통신 대상의 인터페이스를 식별하고 패킷이 전달될 위치를 정하는 주소다. Port 번호는 한 호스트 안에서 TCP·UDP 통신을 받을 서비스 또는 프로세스의 통신 끝점을 구분하는 16비트 번호다. 두 값은 서로 다른 층위를 식별한다.

구분 식별 대상 TCP 연결에서의 역할
IP 주소 네트워크 인터페이스 데이터가 도착할 호스트·인터페이스 지정
Port 번호 호스트 내부의 통신 끝점 도착한 데이터를 받을 서비스 구분

두 값이 서로를 대체할 수 없다는 점은 각각을 단독으로 가졌을 때를 따져 보면 분명해진다. IP 주소만 알면 어느 호스트로 보낼지는 정할 수 있지만 그 호스트 안의 어느 서비스가 받을지는 정할 수 없고, 반대로 Port 번호만 알면 서비스는 알아도 대상 호스트를 특정할 수 없다. 그래서 하나의 TCP 연결은 로컬 IP, 로컬 Port, 원격 IP, 원격 Port라는 네 값의 조합으로 식별된다. 소켓 주소가 IP와 Port의 쌍이었던 이유가 여기서 드러난다. 연결은 그 쌍 두 개, 즉 로컬 끝점과 원격 끝점으로 규정된다.

 

Server와 Client의 역할

서버는 미리 정한 로컬 IP와 Port에 소켓을 연결하고 클라이언트의 연결 요청을 기다리는 프로그램이고, 클라이언트는 서버의 IP와 Port를 지정해 연결을 먼저 요청하는 프로그램이다. 둘의 차이는 "누가 먼저 연결을 여는가"로 요약된다.

구분 Server Client
연결 방식 수동 개방(Passive Open) 능동 개방(Active Open)
필요한 주소 자신의 로컬 IP와 Port Server의 원격 IP와 Port
핵심 동작 연결 대기·수락, 요청 처리, 응답 전송 연결 요청, 데이터 전송, 응답 수신
대표 소켓 연결 대기 소켓, 연결된 소켓 연결된 소켓

주소를 다루는 방식의 비대칭이 특히 눈에 띈다. 서버는 자신의 로컬 주소를 알아야 하고, 클라이언트는 상대인 서버의 주소를 알아야 한다. 이 비대칭은 Port 할당에서도 이어진다. 서버 Port는 클라이언트가 접속하기 전에 미리 알고 있어야 하는 고정된 번호인 반면, 클라이언트 Port는 보통 운영체제가 사용 가능한 임시 Port를 자동으로 할당한다. 클라이언트 쪽 Port를 따로 명시하지 않아도 통신이 되는 이유가 여기에 있다.

 

대표 소켓 항목에서 서버만 소켓을 두 종류 가진다는 점도 짚어 둘 만하다. 다음 절에서 이 구분이 흐름의 핵심으로 다시 등장한다.

 

Server 대기부터 Client 접속까지의 흐름

전체 과정은 아홉 단계로 정리된다.

  1. Server 소켓 생성 — TCP/IPv4를 쓰면 AF_INET, SOCK_STREAM 지정
  2. Server 주소 지정 — 로컬 IP와 Port를 소켓에 연결
  3. Server 대기 상태 전환 — 들어오는 연결 요청을 받을 수 있는 수동 소켓으로 전환
  4. Client 소켓 생성 — 서버와 같은 주소 체계·통신 방식 사용
  5. Client 연결 요청 — 서버 IP와 Port를 목적지로 지정
  6. TCP 연결 설정 — 운영체제 TCP 스택이 SYN → SYN-ACK → ACK의 3-way handshake 수행
  7. Server 연결 수락 — 대기 중인 요청을 꺼내 클라이언트 전용 연결 소켓을 만들고, 기존 대기 소켓은 다음 연결을 계속 기다림
  8. 데이터 송수신 — 연결 소켓으로 바이트 스트림 교환
  9. 연결 종료 — 사용을 마친 각 연결 소켓을 닫음

시간 축으로 놓으면 다음과 같이 진행된다.

Server                                           Client
소켓 생성                                         소켓 생성
   ↓                                                 ↓
로컬 IP·Port 연결                                   Server IP·Port 지정
   ↓                                                 ↓
연결 대기                 ←──── 연결 요청 ────       접속 시도
   ↓                    TCP 3-way handshake           ↓
연결 수락                 ───── 연결 성립 ────→       연결 완료
   ↓                                                 ↓
데이터 수신·응답          ←──── 데이터 ────→          데이터 송신·수신
   ↓                                                 ↓
연결 소켓 닫기                                      소켓 닫기

이 흐름에서 가장 오해하기 쉬운 지점은 7단계다. 연결 대기 소켓과 실제 데이터 통신 소켓은 서로 다른 소켓이다. 대기 소켓은 새로운 클라이언트 연결을 수락하는 데만 쓰이고, accept()가 반환한 연결 소켓이 해당 클라이언트와의 데이터 송수신을 담당한다. 두 소켓을 하나로 뭉뚱그리면 "왜 accept 이후에도 서버가 다른 연결을 계속 받을 수 있는가"를 설명하기 어려워진다. 대기 소켓이 그대로 남아 다음 요청을 기다리기 때문에 가능한 동작이다. 앞 절에서 서버만 소켓을 두 종류 가진다고 했던 대목이 여기서 구체화된다.

 

이 흐름을 이루는 socket()·listen()·accept()·connect()·send()·recv()를 C++ 코드에서 실제로 호출하는 순서는 C++ TCP Socket Programming에서 다룬다. 같은 구조를 노트북(Client)과 Raspberry Pi(Server)에 배치해 동작을 점검하는 과정은 노트북과 Raspberry Pi의 TCP Client-Server 구성으로 이어진다.

운영체제 구조와 서비스

Summary: 운영체제는 두 얼굴을 가진다. 프로그램과 사용자에게 서비스를 내주는 창구이면서, 동시에 그 서비스를 떠받치는 하나의 소프트웨어다. 시스템 콜과 API·ABI, 정책과 기법의 분리, 모놀리식·계층·마이크로 커널·모듈이라는 커널 구조의 선택지를 훑으며, 밖으로 보이는 인터페이스와 안에서 도는 구현 사이에 어디에 선을 긋느냐가 운영체제의 성격을 만든다는 걸 짚는다.

 

프로그램을 실행한다는 말은 익숙한데, 그 말이 정확히 무슨 일을 가리키는지는 의외로 흐릿하다. 아이콘을 누르면 뭔가가 뜬다는 감각과, 실행 파일이 메모리에 올라가 프로세스가 되고 CPU를 얻어 돌아간다는 사실 사이에는 운영체제가 통째로 들어 있다. 그런데 이 운영체제는 두 얼굴을 가진다. 하나는 프로그램과 사용자에게 서비스를 내주는 창구로서의 얼굴이고, 다른 하나는 그 서비스를 실제로 떠받치는 하나의 소프트웨어로서의 얼굴이다. 이 둘을 오가다 보면 같은 질문이 자꾸 돌아온다. 밖에서 보이는 인터페이스와 안에서 돌아가는 구현 사이에, 선을 어디에 그을 것인가.

 

운영체제는 누구를 위해 일하는가

운영체제가 제공하는 서비스를 늘어놓다 보면, 방향이 둘로 갈린다는 게 보인다. 하나는 위쪽, 사용자와 프로그램의 편의를 향한다. 다른 하나는 안쪽, 시스템 자신의 효율을 향한다.

 

위쪽으로는 이런 것들이 있다. 프로그램을 메모리에 적재해 실행시키고, 정상이든 비정상이든 끝나게 해준다. 입출력을 대신 수행해준다 — 사용자는 장치를 직접 만질 수 없으니 운영체제가 그 수단이 되어야 한다. 파일을 이름으로 만들고 지우고 찾게 해주고, 프로세스끼리 정보를 주고받게(같은 컴퓨터 안에서든 네트워크 건너서든, shared memorymessage passing으로) 다리를 놓아준다. 그리고 CPU·메모리·입출력 장치·사용자 프로그램 어디서 나든 오류를 탐지해 일관성 있는 계산을 지킨다.

 

안쪽으로는 성격이 다르다. 여러 프로세스가 동시에 돌 때 자원을 나눠주는 resource allocation이 있는데, CPU 사이클이나 메모리처럼 특수한 할당 코드를 두는 자원이 있는가 하면 입출력 장치처럼 일반적인 요청/해제로 다루는 자원도 있다. 누가 무엇을 얼마나 썼는지 기록하는 logging은 회계나 사용 통계에 쓰이고, 그 통계가 시스템을 다시 손볼 근거가 된다. 마지막으로 protectionsecurity가 있다. 앞은 시스템 자원에 대한 모든 접근이 통제되도록 보장하는 일이고, 뒤는 네트워크 어댑터 같은 외부 통로를 부적합한 접근으로부터 지키며 접속을 기록하는 일이다. 편의는 위를 보고, 효율은 안을 본다. 같은 운영체제가 두 주인을 섬기는 셈이다.

 

사람이 만지는 표면, 그리고 그것을 커널에서 떼어내는 이유

운영체제와 사람이 만나는 방식은 여럿이다. 명령을 타이핑하는 CLI, 아이콘과 창을 마우스로 다루는 GUI, 손가락 제스처로 조작하는 터치스크린. 여기서 눈여겨볼 건, 명령을 해석하는 부분이 커널이 아니라 별도의 프로그램으로 취급된다는 점이다. 이걸 shell이라 부르고, Bourne shell·C shell·bash 같은 여러 해석기 중 골라 쓴다.

 

셸이 명령을 실제로 처리하는 방식에는 두 갈래가 있다. 하나는 셸 자신이 명령마다 실행 코드를 품는 방식인데, 이러면 명령의 수가 곧 셸의 크기를 결정한다. 다른 하나는 대부분의 명령을 별도의 시스템 프로그램으로 두는 방식이다. 셸은 명령 자체는 전혀 모르고, 그저 실행할 파일을 찾아 돌린다. rm file.txt를 치면 rm이라는 파일을 찾아 메모리에 올리고 file.txt를 인자로 넘겨 수행하는 식이다. 명령이 늘어도 셸이 뚱뚱해지지 않으니 후자가 더 잘 늘어난다.

 

GUI는 1970년대 초 Xerox PARC의 연구에서 나왔고, 최초의 GUI는 1973년 Xerox Alto였지만, 이를 유행시킨 건 1980년대의 Apple Macintosh다. Microsoft의 첫 Windows 1.0은 MS-DOS 위에 얹은 GUI였고, UNIX 진영은 KDE나 GNOME 같은 오픈 소스 데스크톱을 갖게 됐다. 터치스크린은 CLI나 마우스가 실용적이지 않은 모바일에서 자리 잡았다. 흥미로운 지점은 이것이다. 인터페이스는 시스템마다, 심지어 같은 시스템의 사용자마다 다를 수 있어서, 실제 시스템 구조를 따질 때는 보통 논외로 둔다. 사람이 만지는 표면을 일부러 핵심에서 떼어놓는 것이다. 이 분리의 감각은 뒤에서 계속 되풀이된다.

 

 

시스템 콜 — 프로그램이 커널에 부탁하는 통로

프로그램이 운영체제의 서비스를 쓰려면 system call을 통해야 한다. 저수준의 일부는 어셈블리로 쓰이지만, 시스템 콜은 대개 C나 C++ 함수 형태로 제공된다. 그런데 정작 응용 개발자가 시스템 콜을 직접 부르는 일은 드물다. 대신 API(Application Programming Interface)에 맞춰 프로그램을 짠다. Windows API, 거의 모든 UNIX 계열이 쓰는 POSIX API, JVM을 위한 Java API가 대표적이다.

 

왜 굳이 한 겹을 더 두는가. 두 가지 이유가 있다. 같은 API를 지원하는 시스템이면 어디서든 컴파일되고 실행될 거라는 호환성의 기대, 그리고 시스템 콜을 날것으로 다루는 게 API보다 까다롭다는 현실. API 함수들은 응용 프로그래머를 대신해 실제 시스템 콜을 불러준다.

 

이 중개를 맡는 게 RTE(Run-Time Environment)가 제공하는 시스템 콜 인터페이스다. API 호출을 가로채 운영체제의 시스템 콜을 부르고, 결과 값과 상태를 되돌려준다. 각 시스템 콜에는 번호가 붙어 있고, 인터페이스는 이 번호로 인덱싱되는 테이블을 유지한다. 그래서 호출자는 시스템 콜이 내부에서 어떻게 구현됐는지 알 필요가 없다. API를 지키고, 운영체제가 무엇을 해줄지만 알면 된다. 세부는 API 뒤로 숨고 RTE가 관리한다.

 

넘겨줄 매개변수를 커널에 전달하는 방법도 몇 가지다. 레지스터에 담는 방법, 매개변수를 메모리의 블록이나 테이블에 넣고 그 주소만 레지스터로 넘기는 방법, 스택에 push했다가 운영체제가 pop하는 방법. 리눅스는 앞의 두 개를 섞어, 5개 이하면 레지스터로, 그보다 많으면 블록으로 넘긴다. 블록과 스택 방식은 개수나 길이에 제한을 두지 않는다는 장점이 있다. 결국 여기서도 응용 프로그램은 API라는 겉면만 만지고, 시스템 콜이라는 속은 만지지 않는다.

 

요청 하나에 숨은 여러 번의 부탁, 그리고 프로세스를 낳는 법

"파일 하나 복사"처럼 단순해 보이는 작업도, 대화형 시스템에서 시스템 콜로 풀면 한 줄이 아니다. 대략 이런 순서로 펼쳐진다.

write   → 화면에 "어느 파일을 복사할까요?" 출력
read    → 키보드에서 입력/출력 파일 이름을 읽음
open    → 원본 파일을 염
read    → 원본에서 데이터를 읽음
write   → 새 파일에 데이터를 씀
close   → 파일을 닫음

시스템 콜은 프로세스 제어, 파일 조작, 장치 관리, 정보 유지, 통신, 보호로 묶인다. 이 중 프로세스 제어가 특히 재밌다. 실행 중인 프로그램은 정상적으로(end()) 끝나거나 비정상적으로(abort()) 멈출 수 있는데, 비정상 종료 시엔 오류 트랩이 걸려 메모리 덤프가 로그로 남고 디버거가 검사할 수 있게 된다. 한 프로세스가 다른 프로그램을 적재(load())해 실행(execute())할 수도 있다. 이때 던져지는 질문이 하나 있다. 새 프로그램이 끝나면 제어가 어디로 돌아가는가. 기존 프로그램으로 돌아가려면 그 메모리 이미지를 보관해야 하고(한 프로그램이 다른 프로그램을 호출하는 꼴), 둘이 병행해 돈다면 다중 프로그래밍될 새 프로세스를 만든 것이다(create_process()).

이 차이를 두 시스템이 극명하게 보여준다. Arduino에는 운영체제가 없다. 부트 로더가 스케치 하나를 적재할 뿐이고, 한 순간에 메모리에는 스케치 하나만 존재한다 — 단일 태스킹이다. 반면 FreeBSD 같은 다중 태스킹 시스템에서 셸은 이렇게 움직인다.

fork()   → 셸이 자신을 복제해 새 프로세스를 만듦
exec()   → 그 프로세스에 실행할 프로그램을 적재해 덮어씀
         → 포그라운드면 셸은 wait()로 종료를 기다리고,
           백그라운드면 셸은 곧장 다음 명령을 받음
exit()   → 프로그램이 끝나며 상태 코드를 반환(0이면 정상, 아니면 오류)

명령 인터프리터가 다른 프로그램이 도는 동안에도 계속 살아 있을 수 있다는 것, 그게 다중 태스킹이 단일 태스킹과 갈라지는 지점이다.

 

소스가 실행되는 프로세스가 되기까지

내가 C로 코드를 짜서 컴파일하면 무슨 일이 벌어질까. 컴파일러는 소스를 오브젝트 파일로 바꾸는데, 이건 임의의 물리 메모리 위치에 적재되도록 설계된 재배치 가능한(relocatable) 형태다. 그다음 linker가 여러 오브젝트 파일과 라이브러리를 하나의 이진 실행 파일로 묶고, loader가 그 실행 파일을 메모리에 올린다. 이 과정에서 벌어지는 게 relocation이다. 프로그램의 각 부분에 최종 주소를 할당하고, 코드와 데이터를 그 주소에 맞춰 조정해, 실행 중에 라이브러리 함수를 부르고 변수에 접근할 수 있게 만든다. Windows의 DLL(Dynamic Link Library)은 이 링크를 적재 시점으로 미뤄, 여러 프로그램이 같은 라이브러리를 공유하며 메모리를 아낀다.

 

실행 파일은 표준 형식을 따른다. 컴파일된 기계 코드에 더해, 참조되는 함수와 변수를 담은 심벌 테이블 같은 메타데이터가 붙는다. UNIX/Linux는 ELF, Windows는 PE, macOS는 Mach-O를 쓴다. ELF는 재배치 가능 파일용과 실행 파일용 형식이 따로 있고, 메타데이터 중에는 프로그램의 시작점, 즉 실행할 첫 명령어의 주소도 들어 있다. 리눅스에서 file main.o를 치면 ELF 재배치 가능 파일이라 보고하고, file main은 ELF 실행 파일이라 알려준다. readelf로는 파일을 섹션 단위로 뜯어볼 수 있다. 형식이 이렇게 다르다는 것 자체가, Linux 실행 파일을 그냥 Windows에 던져 넣을 수 없는 이유의 절반이다.

 

왜 같은 프로그램이 운영체제를 넘나들지 못하나

이유의 나머지 절반은 시스템 콜에 있다. 운영체제마다 고유한 시스템 콜 집합을 가지므로, 한 운영체제용으로 컴파일한 프로그램은 다른 운영체제에서 그대로 돌지 않는다. 그런데도 같은 앱을 여러 OS에서 쓰는 게 어떻게 가능했을까. 방법은 셋이다.

 

첫째, Python이나 Ruby처럼 운영체제마다 인터프리터가 제공되는 언어로 짜는 것. 인터프리터가 소스를 한 줄씩 읽어 그 OS의 시스템 콜을 부른다. 기계어로 된 앱보다 느리고, 인터프리터가 노출하는 기능만큼만 쓸 수 있다는 한계가 있다. 둘째, Java처럼 가상 머신을 가진 언어로 짜는 것. 로더·바이트코드 검증기·JVM이 RTE의 일부로 딸려 오고, RTE가 있는 곳이면 어디서든 돈다 — 다만 인터프리터와 비슷한 단점을 진다. 셋째, POSIX 같은 표준 API로 짠 뒤 각 OS용 이진 파일로 컴파일하는 것. 대신 실행할 OS마다 이식하고, 새 버전마다 다시 시험·디버깅해야 한다.

 

이식이 왜 이렇게 어려운가. 운영체제마다 헤더·명령어·변수 배치를 강제하는 이진 형식이 있고, CPU마다 명령어 집합이 다르며, 시스템 콜은 피연산자·순서·번호·의미·반환 값까지 제각각이다. ELF가 특정 아키텍처에 묶이지 않는다고 해서 다른 하드웨어에서 돌 거란 보장이 되는 것도 아니다. 여기서 ABI(Application Binary Interface)가 등장한다. 주소 길이, 시스템 콜에 매개변수를 넘기는 방식, 런타임 스택 구성, 시스템 라이브러리의 이진 형식, 자료형의 크기 같은 저수준 세부를 규정한다. API가 소스 수준의 약속이라면 ABI는 그 아키텍처 수준 짝이다. 그러나 ABI는 특정 OS와 특정 아키텍처의 조합마다 따로 정의되기 때문에, 플랫폼을 가로지르는 호환성은 거의 주지 못한다.

 

무엇을 할지와 어떻게 할지를 나누기

운영체제를 설계할 때 되풀이되는 원칙이 하나 있다. 정책(policy)과 기법(mechanism)의 분리다. 기법은 "어떻게 할 것인가"를, 정책은 "무엇을 할 것인가"를 정한다. 타이머를 예로 들면, CPU 보호를 보장하려고 타이머를 두는 것 자체는 기법이지만, 특정 사용자에게 타이머를 얼마나 길게 줄지는 정책적 판단이다.

 

이 둘을 갈라놓는 이유는 융통성이다. 정책은 장소와 시간에 따라 바뀌기 마련인데, 정책이 바뀔 때마다 밑바탕 기법까지 갈아엎어야 한다면 곤란하다. 충분히 일반적인 기법 하나로 여러 정책을 얹을 수 있는 편이 낫다. 우선순위를 다루는 기법이 정책에서 잘 분리돼 있으면, 입출력 중심 프로그램을 우대하는 정책도, 그 반대 정책도 같은 기법 위에서 지원할 수 있다. 마이크로 커널은 이 분리를 극단까지 밀어, 정책에서 거의 자유로운 기본 블록만 커널에 두고 고급 정책은 사용자 모듈로 얹는다. 반대편에 Windows가 있다. 통일된 룩앤필을 위해 기법과 정책을 밀착시켜 커널과 시스템 라이브러리에 인터페이스를 박아 넣었고, Apple도 macOS·iOS에서 비슷한 길을 택했다. 자원 할당처럼 "무엇을 줄지"를 정할 때마다 정책이 필요하고, 질문이 "무엇"이 아니라 "어떻게"로 바뀌는 순간 기법이 결정된다.

 

커널을 어떻게 짤 것인가

운영체제도 결국 크고 복잡한 소프트웨어라, 함부로 만들면 손대기 어려워진다. 흔한 해법은 하나의 덩어리 대신 잘 정의된 인터페이스를 가진 작은 구성요소로 쪼개는 것이다. 이 쪼개는 방식이 커널의 구조를 가른다.

구조 핵심 아이디어 대가
모놀리식 커널 전 기능을 한 주소 공간·단일 이진에 커질수록 구현·확장이 난감
계층(layered) 아래 층 서비스만 쓰는 층들로 분리 층 나누기 어렵고 효율이 낮음
마이크로 커널 핵심만 커널, 나머지는 사용자 프로세스 메시지 복사·문맥 전환 오버헤드
모듈(LKM) 핵심 + 실행 중 동적 적재되는 모듈 실용적 절충안

모놀리식은 초기 UNIX가 그랬듯 시스템 콜 인터페이스 아래·하드웨어 위의 모든 것을 커널에 담는다. Linux도 단일 주소 공간에서 커널 모드로 전부 도는 모놀리식이되, 실행 중에 커널을 고칠 수 있는 모듈식 설계를 겸한다. 시스템 콜 오버헤드가 거의 없고 커널 내부 통신이 빠르지만, 구조 자체는 확장하기 까다롭다. 계층은 이 결합도를 낮춘다. 최하위가 하드웨어, 최상위가 사용자 인터페이스이고, 각 층은 아래 층이 무엇을 하는지만 알면 되니 검증과 디버깅이 단순하다. 대신 층을 잘 나누기가 어렵고 층을 거칠수록 느려진다.

 

마이크로 커널은 1980년대 중반 카네기멜론의 Mach에서 나왔다. 중요하지 않은 구성요소를 죄다 커널에서 빼내 별도 주소 공간의 사용자 수준 프로그램으로 돌리고, 커널은 그들 사이의 통신만 맡는다. 새 서비스를 커널 손대지 않고 사용자 공간에 붙일 수 있어 확장과 이식이 쉽고 신뢰성도 높다. 문제는 성능이다. 서로 다른 주소 공간의 두 서비스가 통신하려면 메시지를 복사하고 프로세스를 전환해야 하는데, 이 오버헤드가 마이크로 커널의 발목을 잡아 왔다. 모듈(LKM, loadable kernel modules)은 그 절충이다. 핵심 서비스는 항상 올려두고 나머지는 실행 중 동적으로 링크한다. 어떤 모듈이든 다른 모듈을 부를 수 있어 계층보다 유연하고, 정의된 인터페이스를 갖는다는 점은 계층을 닮았으며, 중심 모듈이 다른 모듈의 적재법과 통신법을 안다는 점은 마이크로 커널을 닮았다 — 다만 메시지 전달을 거칠 필요가 없어 더 효율적이다. 리눅스는 주로 장치 드라이버와 파일 시스템을 이 방식으로 지원한다.

 

현실은 어느 하나로 딱 떨어지지 않는다. 엄격하게 단일 구조를 쓰는 운영체제는 사실상 없고, 대부분 여러 구조를 섞어 성능·보안·편의의 균형을 맞춘다. Linux는 모놀리식이면서 모듈을 쓰고, Windows는 대체로 모놀리식이지만 사용자 모드 서브시스템(마이크로 커널적)과 LKM을 함께 지원한다. macOS와 iOS의 커널 환경인 Darwin은 Mach 마이크로 커널과 BSD UNIX 커널을 얹은 계층화된 하이브리드라, 응용 프로그램은 프레임워크를 거치거나 아예 프레임워크를 버리고 POSIX 시스템 콜로 커널과 직접 대화할 수 있다. Android는 맨 아래 Linux 커널(전원 관리·메모리 할당을 고치고 Binder라는 IPC를 더한)에 Bionic과 HAL, 그 위에 ART VM과 Android 프레임워크를 얹은 구조인데, Java로 개발하되 표준 Java API가 아닌 별도의 Android API를 쓴다.

 

켜지고, 무너지고, 관찰되는

운영체제를 새로 빌드한다면 소스를 쓰고, 대상 시스템에 맞춰 구성하고, 컴파일·설치한 뒤 부팅하는 순서를 밟는다. 부팅이란 커널을 적재해 컴퓨터를 시작하는 과정이다. 비휘발성 펌웨어(BIOS, 요즘은 상당수 UEFI로 대체)에 든 작은 부트 로더가 커널의 위치를 찾아 메모리에 올리면, 커널이 하드웨어를 초기화하고 루트 파일 시스템을 마운트한다. 부트스트랩은 그 전에 메모리와 CPU를 점검하고 장치를 찾는 진단도 돌린다. 문제가 생겼을 때를 위해 복구 모드나 단일 사용자 모드로 부팅하는 길도 열어둔다.

 

무너질 때를 대비하는 것도 운영체제의 일이다. 프로세스가 실패하면 로그와 함께, 그 프로세스가 쓰던 메모리를 캡처한 코어 덤프(core dump)가 남아 디버거가 뜯어볼 수 있다. 커널이 무너지는 건 크래시라 부르고 그 상태는 크래시 덤프에 저장되는데, 여기엔 함정이 있다. 파일 시스템 코드 때문에 커널이 죽었다면 그 상태를 파일 시스템에 저장하는 시도 자체가 위험하다. 그래서 파일 시스템을 두지 않은 디스크의 예약된 영역에 커널 메모리를 먼저 쏟아두고, 재부팅 후 그 영역에서 데이터를 거둬 크래시 덤프 파일로 옮긴다.

 

살아 있는 시스템을 들여다보는 도구는 성격이 갈린다. 카운터 기반 도구(ps, top, vmstat, iostat)는 커널이 유지하는 통계의 현재 값을 묻고, 추적 도구(strace, gdb, perf, tcpdump)는 시스템 콜 같은 특정 이벤트의 데이터를 모은다. 리눅스의 BCC(BPF Compiler Collection)는 BPF 위에서 동적 커널 추적을 돕는 툴킷으로, 사용자 코드와 커널 코드가 얽히는 지점을 시스템 안정성을 해치지 않고 관찰하게 해준다. 이런 도구가 필요한 이유는 Kernighan의 경고가 잘 짚는다. 디버깅은 코드를 짜는 것보다 두 배 어려우니, 가능한 한 영리하게 코드를 짰다면 정의상 그걸 디버깅할 만큼 똑똑하지는 못하다는 것.

 

마무리

돌아보면 이 모든 이야기는 선 긋기에 관한 것이었다. 응용 프로그램은 API라는 선 위에서만 커널을 만지고, 시스템 콜의 세부는 그 아래에 감춰진다. 설계자는 정책과 기법 사이에 선을 그어 무엇을 바꿔도 무엇은 그대로 둘지를 정하고, 커널을 층으로 혹은 모듈로 쪼개며 어디까지가 한 조각인지를 정한다. 그래서 운영체제는 밖으로는 안정된 얼굴(서비스와 시스템 콜)을 유지하면서, 안으로는 모놀리식이든 마이크로 커널이든 자기 골격을 다시 짤 수 있다. 얼굴을 바꾸지 않은 채로 말이다. 무엇을 API 뒤에 감출지, 무엇을 정책으로 열어둘지, 커널을 어디서 쪼갤지 — 이 선들을 어디에 긋는가가 쌓여 그 운영체제의 성격이 된다.

DHT22 단선 프로토콜과 PMS7003 UART 프레임 구조

Summary: 강의실 환경 모니터링 프로젝트에서 사용하는 두 센서의 통신 방식을 분석한다. DHT22는 마이크로초 펄스 폭으로 비트를 구분하는 단선 시리얼 40비트 프로토콜을, PMS7003은 UART 9600 8N1 위에서 고정 32바이트 프레임을 사용하며, 각각 리눅스에서 어떻게 읽고 검증하는지까지 정리했다.

Raspberry Pi 4 GPIO 핀맵

DHT22 (온습도 센서)

DHT22(AM2302)는 정전용량식 습도 소자와 서미스터(NTC)를 하나의 모듈에 담아 온도와 상대습도를 함께 측정하는 디지털 센서다. 측정 범위는 습도 0~100%RH(±2~5%RH), 온도 -40~80°C(±0.5°C)이며, 데이터는 단선(single-wire) 시리얼로 40비트를 출력한다.

 

배선은 VCC, GND, DATA 3선으로 단순하고 DATA에 풀업 저항이 필요하다. 이 프로젝트에서는 GPIO4에 연결했다.

 

사양에서 실제 코드 설계에 영향을 주는 항목은 샘플링 주기다. 센서 자체는 1Hz로 샘플링하지만 연속 요청 간격은 최소 2초가 필요하고, 그보다 짧게 재요청하면 이전 값이나 오류가 돌아온다. 즉 "빨리 여러 번 읽어서 평균 내는" 접근이 이 센서에서는 성립하지 않는다.

 

DHT22 단선 시리얼 프로토콜

단선 프로토콜은 데이터선 한 가닥으로 요청과 응답을 주고받는 반이중 방식이다. 이름 때문에 Dallas 1-Wire로 오해하기 쉬운데, DHT 계열은 자체 타이밍 규격을 쓰는 별개의 프로토콜이다.

 

통신 순서는 다음과 같다. MCU가 시작 신호로 데이터선을 최소 1ms LOW로 끌어내리고 20~40µs를 기다리면, 센서가 80µs LOW + 80µs HIGH로 응답한 뒤 40비트 데이터를 전송한다.

 

40비트의 구성은 습도 정수 8bit + 습도 소수 8bit + 온도 정수 8bit + 온도 소수 8bit + 체크섬 8bit이며, 체크섬은 앞 4바이트 합의 하위 8비트다.

 

비트 판별 방식이 이 프로토콜의 핵심이자 문제의 근원이다. 각 비트는 50µs LOW 뒤에 오는 HIGH의 유지 시간으로 구분되는데, 26~28µs면 0, 70µs면 1이다. 0과 1의 차이가 수십 마이크로초에 불과하므로, 일반 사용자 공간 프로세스가 GPIO를 폴링해서 이 폭을 재는 방식은 커널 스케줄링에 밀리는 순간 비트를 통째로 놓친다. 이 프로젝트에서 커널 dht11 IIO 드라이버를 쓰기로 한 것은 이 때문이다. 타이밍 측정을 커널에 위임하면 사용자 코드는 sysfs 파일(in_temp_input, in_humidityrelative_input)을 읽는 평범한 파일 I/O로 단순해진다.

 

PMS7003 (미세먼지 센서)

PMS7003(Plantower)은 레이저 산란 방식으로 입자 농도를 측정하는 디지털 미세먼지 센서다. 0.3~1.0 / 1.0~2.5 / 2.5~10µm 구간의 입자를 감지하며, 유효 범위 0~500µg/m³, 분해능 1µg/m³로 PM1.0/PM2.5/PM10 농도와 입자 개수를 UART로 출력한다.

 

전원 계통은 주의가 필요하다. 팬과 내부 로직은 5V로 구동하지만 통신 핀은 3.3V TTL이라 Raspberry Pi GPIO에 레벨 변환 없이 직결할 수 있다.

 

동작 모드는 두 가지다. 액티브 모드(기본)는 측정값을 자동으로 주기 전송하고, 패시브 모드는 요청이 있을 때만 응답한다. 이 프로젝트는 액티브 모드를 사용한다. 센서가 알아서 보내주므로 수신 측은 명령을 보낼 필요 없이 스트림을 읽기만 하면 되고, 결과적으로 RX 한 선만으로도 수신이 성립한다.

 

UART 통신

UART(Universal Asynchronous Receiver/Transmitter)는 클록선 없이 시작·정지 비트로 프레임을 구분하는 비동기 직렬 통신이다. 송신(TX)과 수신(RX) 두 선을 교차 연결하며, PMS7003의 설정은 9600bps, 데이터 8bit, 패리티 없음, 정지 1bit(8N1)이다.

 

연결은 센서 TX → Pi RX(GPIO15)다. 리눅스 구현은 /dev/ttyAMA0을 open()한 뒤 termios로 raw 모드와 9600bps를 설정하고 read()로 바이트를 받는 구조다. DHT22와 비교하면 대조가 뚜렷하다. UART는 하드웨어(UART 컨트롤러)가 비트 타이밍을 처리해 주므로, 사용자 공간에서 읽어도 DHT22 같은 타이밍 문제가 없다. 같은 "센서 읽기"라도 프로토콜 계층이 어디까지 하드웨어/커널에 내려가 있느냐에 따라 사용자 코드의 난이도가 갈린다.

 

PMS7003 32바이트 프레임 구조

액티브 모드의 출력 프레임은 고정 32바이트이고, 값은 빅 엔디언 16비트 워드 단위로 실린다.

바이트 내용
0–1 시작 문자 0x42 0x4D (ASCII "BM")
2–3 프레임 길이 (2×13+2 = 28)
4–9 PM1.0 / PM2.5 / PM10 농도 (CF=1, 표준 입자 기준)
10–15 PM1.0 / PM2.5 / PM10 농도 (대기 환경 기준)
16–27 0.1L당 입자 개수 (>0.3 / 0.5 / 1.0 / 2.5 / 5.0 / 10µm)
28 버전
29 오류 코드
30–31 체크섬

값 조립은 (상위바이트 << 8) | 하위바이트이고, 체크섬은 앞 30바이트(0~29)의 합이 30~31바이트 값과 일치해야 유효 프레임이다.

 

파싱할 때 헷갈리기 쉬운 지점이 두 곳 있다. 첫째, 같은 PM2.5가 CF=1 기준(바이트 6~7)과 대기 환경 기준(바이트 12~13)으로 두 번 실려 온다. 실내·일반 환경 측정에는 대기 환경 값을 쓰는 것이 맞고, 이 프로젝트는 바이트 12~13(PM2.5), 14~15(PM10)를 파싱한다. 둘째, 수신 시점이 프레임 경계와 일치한다는 보장이 없다. 액티브 모드 스트림을 중간부터 읽기 시작할 수 있으므로, 0x42 0x4D 헤더를 먼저 찾아 동기화한 뒤 나머지 30바이트를 받아야 한다.

 

동작 확인

설정이 끝난 상태에서 각 센서를 코드 없이 먼저 확인할 수 있다.

DHT22는 sysfs를 직접 읽는다. 값은 밀리 단위다(24300 = 24.3°C).

cat /sys/bus/iio/devices/iio:device0/in_temp_input
cat /sys/bus/iio/devices/iio:device0/in_humidityrelative_input

I/O error가 간헐적으로 나오는 것은 DHT22 응답 실패로, 이 센서의 정상 동작 범위다.

PMS7003은 원시 바이트에서 헤더가 주기적으로 보이는지 확인한다.

sudo stty -F /dev/ttyAMA0 9600 cs8 -cstopb -parenb raw
sudo hexdump -C -n 256 /dev/ttyAMA0

출력에서 42 4d가 32바이트 간격으로 반복되면 액티브 모드 송신이 정상이다.

 

데이터 경로 요약

두 센서의 값이 모두 유효할 때만 5분 간격으로 INSERT한다.

 

결론

두 센서는 "타이밍을 누가 책임지는가"에서 갈린다. DHT22의 단선 프로토콜은 µs 타이밍을 수신 측이 재야 하므로 커널 드라이버에 위임했고, PMS7003의 UART는 하드웨어가 타이밍을 처리하므로 사용자 공간 termios로 충분하다. 대신 PMS7003은 스트림 동기화와 체크섬 검증이라는 프레임 파싱 문제를 수신 코드가 책임져야 한다. 다음 편에서는 이 데이터를 받을 PostgreSQL 스키마를 설계한다.

C++ 스마트 포인터에 값 입력받기

Summary: 스마트 포인터에 "값을 넣는다"는 것은 포인터 자체가 아니라 그것이 가리키는 객체에 값을 대입·입력하는 것이다. 값을 넣는 경로는 생성 시 인자로 초기값을 전달하는 방법과 역참조(*p, p->)로 접근하는 방법으로 나뉘며, std::cin 입력, 이후 객체 교체, 함수 경계에서의 소유권 이전, 그리고 생성 방식별 초기화 차이까지 함께 정리한다.

 

스마트 포인터에 값을 넣는 작업은 포인터 변수 자체에 값을 대입하는 것이 아니라, 그것이 가리키는 객체에 값을 넣는 것이다. 값이 저장되는 위치가 포인터가 아니라 피지시 객체이므로, 모든 값 연산은 그 객체를 경유한다.

 

따라서 경로는 두 갈래로 나뉜다. 하나는 객체를 생성할 때 인자로 초기값을 함께 전달하는 방법이고, 다른 하나는 이미 만들어진 객체에 역참조(*p, p->)로 접근해 대입·입력하는 방법이다. 역참조 접근 방식은 unique_ptr와 shared_ptr에서 동일하게 쓰이며, 둘의 차이는 값 접근이 아니라 소유 모델에 있다 — shared_ptr는 복사로 소유를 공유할 수 있는 반면 unique_ptr는 이동만 가능하다.

생성 시 값 지정

가장 직접적인 방법은 객체를 만들 때 초기값을 함께 넘기는 것이다.

  1. std::make_unique<T>(args...): T를 args로 생성해 unique_ptr로 반환한다. new를 직접 쓰는 대신 예외 안전하게 생성하며 C++14에서 도입되었다.
  2. std::make_shared<T>(args...): 제어블록과 객체를 1회 할당으로 함께 만든다. C++11부터 사용할 수 있다.
  3. std::unique_ptr<T> p;: 아무 객체도 소유하지 않는 빈 포인터(nullptr)다.
  4. std::unique_ptr<T> p(new T(args...)): raw 포인터로 직접 생성하는 방식으로, make_ 계열에 비해 권장되지 않는다.
auto pi = std::make_unique<int>(42);            // *pi == 42
auto ps = std::make_shared<std::string>("hi");  // *ps == "hi"
auto pp = std::make_unique<Person>("Kim", 20);  // 생성자 인자 전달

make_unique<Person>("Kim", 20)처럼 전달한 인자는 그대로 대상 타입의 생성자로 넘어간다. 즉 "생성 시 값 지정"은 대상 객체의 생성자 인자를 통해 초기 상태를 결정하는 것이다.

 

역참조로 값 읽기·쓰기

객체를 이미 소유한 상태에서 값을 넣거나 꺼낼 때는 역참조한다. *p는 가리키는 객체 자체를 참조로 돌려주므로 읽기와 쓰기가 모두 가능하고, p->멤버는 (*p).멤버와 같은 멤버 접근이다.

auto p = std::make_unique<int>();   // value-init => *p == 0
*p = 42;                            // 값 쓰기
int x = *p;                         // 값 읽기

auto per = std::make_unique<Person>();
per->age = 20;                      // 멤버 쓰기

여기서 make_unique<int>()처럼 인자 없이 만들면 스칼라도 값 초기화(value-initialization)되어 *p == 0에서 출발한다(이 초기화 규칙은 6절에서 다시 다룬다). 이후 *p = 42;로 값을 쓰고 int x = *p;로 읽는다. 결국 스마트 포인터에 대한 "값 입력"은 역참조로 얻은 객체에 대입·입력하는 것으로 귀결된다.

 

std::cin 입력

표준 입력도 같은 원리를 따른다. 역참조한 객체를 그대로 스트림 추출의 대상으로 삼는다.

auto n = std::make_unique<int>();
std::cin >> *n;                     // 정수 입력

auto per = std::make_unique<Person>();
std::cin >> per->name >> per->age;  // 멤버별 입력

auto name = std::make_unique<std::string>();
std::getline(std::cin, *name);      // 공백 포함 한 줄 입력

std::cin >> *p는 역참조한 객체로 입력을 받고, std::cin >> p->필드는 멤버로 직접 받는다. 대상이 std::string이고 공백을 포함한 한 줄을 받아야 할 때는 std::getline(std::cin, *p)를 쓴다. 전제 조건이 하나 있다. 입력 전에 포인터가 실제 객체를 보유하고 있어야 한다는 것이다 — nullptr을 역참조하는 것은 정의되지 않은 동작(UB)이기 때문이다.

 

나중에 값·객체 교체

이미 소유 중인 객체를 다른 것으로 바꾸는 방법도 소유 모델을 따른다.

  • p = std::make_unique<T>(...): 이동 대입으로, 기존 객체를 해제한 뒤 새 객체를 보유한다.
  • p.reset(new T(...)): 새 객체로 교체한다(기존 해제).
  • p.reset(): 비운다(nullptr, 기존 객체 해제).
  • unique_ptr는 복사 대입이 불가하고 이동 대입만 가능하므로 p2 = std::move(p1)을 쓰며, 이후 p1은 비어 있게 된다.
auto p = std::make_unique<int>(1);
p = std::make_unique<int>(2);   // 이동 대입, 이전 1은 자동 해제
p.reset();                      // nullptr

핵심은 재대입 시점에 기존 객체가 자동으로 해제된다는 점이다. 즉 명시적 delete 없이도 교체·소멸이 소유권 규칙에 묶여 처리된다.

 

소유권으로 "받기" (함수 반환·매개변수)

함수 경계를 넘길 때는 값이 아니라 소유권 관점에서 판단한다.

  • 반환: 함수가 unique_ptr<T>를 반환하면 호출부는 이동으로 받는다(auto p = make();).
  • 매개변수 전달 방식:
    1. unique_ptr<T>(값): 소유권을 이전한다. 호출부는 std::move가 필요하다.
    2. T& / const T& / T*: 소유권은 유지한 채 관찰·수정만 한다. 값을 읽거나 입력할 때 쓴다.
  • 관찰용 raw 포인터가 필요하면 p.get()을 쓰며, 소유권은 스마트 포인터가 계속 유지한다.
std::unique_ptr<Person> makePerson() {
    auto p = std::make_unique<Person>();
    std::cin >> p->name;        // 객체에 입력
    return p;                   // 이동으로 반환
}
auto per = makePerson();        // 소유권 받기

이 예에서 함수는 내부에서 객체를 만들어 p->name으로 입력을 받은 뒤 소유권째 반환하고, 호출부는 그것을 이동으로 넘겨받는다. 매개변수를 설계할 때도 소유권을 넘길 것인지(값 전달) 아니면 값만 읽고 쓸 것인지(참조/포인터)에 따라 형태가 갈린다.

 

초기화 주의

마지막으로 생성 방식에 따라 초기값이 달라지는 지점을 정리한다. 특히 스칼라 타입(int 등)에서 차이가 두드러진다.

생성 방식 스칼라 초기값

make_unique<int>(42) 42
make_unique<int>() 0 (value-initialization)
make_unique_for_overwrite<int>() (C++20) 미정(indeterminate) — 곧바로 입력으로 덮어쓸 때만 사용
std::unique_ptr<int> p; 객체 없음(nullptr)

class 타입은 위 두 make_ 경우 모두 기본 생성자를 호출하므로, 이 차이는 스칼라/trivial 타입에서만 두드러진다. 그리고 nullptr 역참조(*p, p->)는 UB이므로, 사용 전에 if (p)나 operator bool로 객체 보유 여부를 확인해야 한다. 입력으로 즉시 덮어쓸 값이라면 value-init(0)이 낭비일 수 있어 make_unique_for_overwrite를 고려할 수 있으나, 입력 전에 그 값을 읽으면 쓰레기값이 된다는 점이 이 선택의 조건이다.

Ubuntu Server 기반 센서 수집 환경 구축

Summary: 강의실 환경 데이터(온습도, 미세먼지)를 수집하는 프로젝트의 첫 단계로, Raspberry Pi 4에 Ubuntu Server 24.04 LTS 기반 C++ 개발 환경을 구성한다. DHT22를 커널 IIO 드라이버로 읽기 위한 디바이스 트리 설정, PMS7003용 UART 활성화, 그리고 Git/SSH 연동까지의 과정을 정리했다.

대상 환경

이 프로젝트는 Raspberry Pi 4에 DHT22(GPIO4)와 PMS7003(UART)을 연결한 구성이다. OS는 Ubuntu Server 24.04 LTS(arm64), 수집 프로그램은 C++20으로 작성하고 CMake로 빌드하며 PostgreSQL 접속에는 libpqxx를 사용한다.

Raspberry Pi OS 대신 Ubuntu Server를 선택하면서 달라지는 점은 단순한 배포판 교체 이상이었다. 뒤에서 다루겠지만, raspi-config 같은 대화형 설정 도구가 없어 부트 펌웨어 설정 파일을 직접 편집해야 하고, 센서를 읽는 방식도 사용자 공간 라이브러리 대신 커널 드라이버 기반을 택하게 된다.

 

패키지 설치

빌드 도구

sudo apt update
sudo apt install -y build-essential cmake pkg-config

build-essential이 g++ 컴파일러와 표준 라이브러리 개발 파일을 제공하고, cmake가 빌드를 구성한다. pkg-config는 이후 CMake가 libpqxx의 헤더·링크 경로를 자동으로 찾는 데 쓰인다.

PostgreSQL C++ 클라이언트

sudo apt install -y libpqxx-dev libpq-dev

libpqxx는 C++용 PostgreSQL 공식 클라이언트 라이브러리로, Ubuntu 24.04 기준 7.8.x가 설치된다. libpqxx 자체는 C 드라이버인 libpq 위에서 동작하므로 두 패키지를 함께 설치한다. 버전을 기록해 두는 이유는 libpqxx가 7.9를 기점으로 질의 API를 바꾸고 있어, 어떤 API를 쓸 수 있는지가 배포판 버전에 따라 갈리기 때문이다.

GPIO 확인 도구 (선택)

sudo apt install -y gpiod

gpioinfo, gpiodetect 같은 CLI로 GPIO 핀 상태를 확인할 수 있다. 필수는 아니지만 배선 문제를 소프트웨어 설정 문제와 분리해서 진단할 때 유용하다.

 

DHT22 커널 드라이버(IIO) 설정

이 단계가 환경 구축에서 가장 중요한 결정이었다. DHT22의 단선 프로토콜은 비트 하나를 마이크로초 단위 펄스 폭으로 구분하는데, 일반 사용자 공간 프로세스는 커널 스케줄링에 밀려 이 타이밍을 안정적으로 잡아내지 못한다. 사용자 공간 라이브러리로 GPIO를 직접 폴링하는 접근도 있지만, 커널의 dht11 드라이버(IIO 서브시스템)에 타이밍 처리를 맡기고 사용자 코드는 sysfs 파일 읽기만 수행하는 쪽이 구조적으로 유리하다.

 

설정은 /boot/firmware/config.txt 맨 아래에 한 줄을 추가하는 것으로 끝난다.

dtoverlay=dht11,gpiopin=4

오버레이 이름이 dht11이지만 DHT11/DHT22 공용 드라이버이며, 측정값의 스케일로 센서 종류를 자동 구분한다. 재부팅 후 /sys/bus/iio/devices/iio:device0/ 아래에 두 파일이 생긴다.

  • in_temp_input — 온도, 밀리도(m°C) 단위 (예: 24300 = 24.3°C)
  • in_humidityrelative_input — 상대습도, 밀리퍼센트 단위

주의할 점은 읽기 실패(EIO)가 간헐적으로 발생하는 것이 이 센서의 정상 동작 범위라는 것이다. 커널 드라이버를 쓴다고 해서 센서 응답 실패 자체가 사라지는 것은 아니므로, 수집 코드에는 재시도 로직이 여전히 필요하다.

 

UART(PMS7003) 설정

PMS7003은 UART로 데이터를 보내므로 하드웨어 UART를 활성화해야 한다. /boot/firmware/config.txt 맨 마지막에 추가한다.

[all]
dtoverlay=disable-bt
enable_uart=1

disable-bt가 필요한 이유는 Pi 4의 UART 배정 구조 때문이다. Pi 4에서 제대로 된 하드웨어 UART(PL011, ttyAMA0)는 기본적으로 블루투스 모듈에 배정되어 있고, disable-bt 없이 enable_uart=1만 켜면 GPIO14/15에는 미니 UART(/dev/ttyS0)가 잡힌다. 미니 UART는 보(baud) 클럭이 코어 클럭에 종속되어 클럭이 변동하면 통신이 흔들릴 수 있으므로, 블루투스를 내리고 PL011을 센서 전용으로 가져오는 쪽이 안전하다. 이렇게 하면 /dev/ttyAMA0 포트가 GPIO14/15에 생성된다.

 

여기서 놓치기 쉬운 부분이 시리얼 콘솔이다. 시리얼 포트로 OS 콘솔 로그인이 열려 있으면 센서 데이터와 콘솔 트래픽이 충돌한다. /boot/firmware/cmdline.txt에서 console=serial0,115200 항목을 제거하고, sudo systemctl disable serial-getty@ttyAMA0.service로 getty를 내린다. 

 

포트 접근 권한도 필요하다. 실행 계정을 dialout 그룹에 추가한다.

sudo usermod -aG dialout $USER

이후 재부팅으로 설정을 반영한다.

 

Raspberry Pi OS와의 차이

항목 Raspberry Pi OS Ubuntu Server
설정 도구 raspi-config 메뉴 config.txt 직접 편집
설정 파일 위치 /boot/firmware/config.txt /boot/firmware/config.txt (동일)
DHT22 읽기 사용자 공간 라이브러리 중심 생태계 커널 dht11 IIO 드라이버
개발 패키지 배포판 제공 GPIO 라이브러리 중심 build-essential, cmake, libpqxx-dev

정리하면서 흥미로웠던 점은, 설정 파일의 위치와 문법은 두 배포판이 동일하고 차이는 "그 파일을 누가 편집하느냐"뿐이라는 것이다. raspi-config는 결국 config.txt를 대신 편집해 주는 프론트엔드이므로, Ubuntu에서 직접 편집하는 방식이 오히려 어떤 설정이 어디에 반영되는지를 명확하게 보여준다.

 

git 설치 및 ssh 설정

수집 노드에서 저장소를 직접 받아 빌드하기 위해 Git과 SSH 키를 설정한다.

sudo apt install git -y
git config --global user.name "사용자 이름"
git config --global user.email "사용자 이메일"
git config --list   # 등록 확인

SSH 키는 ed25519로 생성하고(기본 설정으로 진행), 공개 키를 GitHub에 등록한다.

ssh-keygen -t ed25519 -C "등록한 사용자 이메일"
cat ~/.ssh/id_ed25519.pub   # 출력 전체를 복사

복사한 공개 키는 GitHub Settings → SSH and GPG keys → New SSH key에 붙여넣는다.

GitHub repositories 연동

저장소 페이지의 < > Code 버튼에서 SSH 탭의 URL을 복사한 뒤, 복제할 경로에서 clone한다.

git clone [복사한 URL]

저장소 이름과 동일한 폴더가 생성되고 전체 파일이 내려온다. 여기까지가 수집 노드 한 대를 프로젝트에 투입하기 위한 준비의 전부다.

결론

이 단계의 산출물은 "C++ 코드를 빌드할 수 있고, 두 센서가 커널 수준에서 인식되는 Ubuntu Server 노드"다. 핵심 판단은 두 가지였다. DHT22의 마이크로초 타이밍 문제를 사용자 공간에서 싸우지 않고 커널 드라이버에 위임한 것, 그리고 UART를 센서 전용으로 확보하기 위해 블루투스와 시리얼 콘솔을 명시적으로 내린 것. 다음 편에서는 이 두 센서가 실제로 어떤 프로토콜로 데이터를 보내는지를 다룬다.

<regex> 라이브러리와 정규 표현식

Summary: C++11에서 도입된 <regex>의 구성과 사용법을 정리한다. 컴파일된 정규식 객체와 세 알고리즘 함수(match/search/replace), 문법 선택(기본값 ECMAScript)과 ECMAScript 문법 요소, 매칭·치환 플래그, 매치 결과(smatch) 접근, 반복자 기반 다중 매치·분할, 예외 계층, 그리고 이스케이프·컴파일 비용·백트래킹 같은 실무 한계를 다룬다.

 

정규 표현식(Regular Expression)은 문자열의 패턴을 기술하는 형식 언어로, 매칭·검색·치환에 사용된다. <regex>는 이 기능을 몇 개의 축으로 나눠 제공한다. 컴파일된 정규식 객체(basic_regex), 알고리즘 함수(match/search/replace), 매치 결과(match_results·sub_match), 순회 반복자(regex_iterator 등), 예외(regex_error), 그리고 문자 특성(regex_traits)이다. 각 컴포넌트는 char용과 wchar_t용(w- 접두)의 두 계열 별칭으로 제공된다.

 

핵심 컴포넌트

라이브러리는 "패턴을 담는 객체 → 매치를 수행하는 함수 → 결과를 담는 객체 → 결과를 순회하는 반복자 → 오류를 알리는 예외"로 역할이 나뉜다.

컴포넌트 별칭 역할
basic_regex regex(char), wregex(wchar_t) 컴파일된 정규식 객체, 생성 시 문법·수정자(syntax_option_type) 지정
sub_match ssub_match, csub_match 하위식이 매치한 문자 구간 [first, last); 멤버 matched/first/last/str()/length()
match_results smatch, cmatch, wsmatch, wcmatch 한 번의 매치 결과 + 모든 하위식 매치 모음
regex_iterator sregex_iterator, cregex_iterator 시퀀스 내 모든 매치를 순차 순회
regex_token_iterator 매치의 지정 하위식들 또는 미매치 조각을 순회(-1이면 split)
regex_error 라이브러리 오류 예외, code()로 error_type 조회
regex_traits 문자 타입 메타정보 제공(로캘/문자 분류)

 

알고리즘 함수

세 함수는 "어디까지 일치를 요구하느냐"로 갈린다.

  1. regex_match(대상, [결과,] re, [flags]): 대상 전체가 정규식과 완전히 일치할 때만 true이며, 부분 일치는 false다.
  2. regex_search(대상, [결과,] re, [flags]): 대상의 일부(부분 문자열)에서 첫 매치를 탐색한다. 존재 여부 판정과 추출에 쓴다.
  3. regex_replace(대상, re, 포맷, [flags]): 매치를 포맷 문자열로 치환해 반환한다.
#include <regex>
std::regex re(R"(\d+)");                 // 하나 이상의 숫자
std::smatch m;
std::string s = "id=42, x=7";
bool full = std::regex_match(s, re);                 // false (전체 불일치)
bool found = std::regex_search(s, m, re);            // true, m[0]="42"
std::string out = std::regex_replace(s, re, "#");    // "id=#, x=#"

regex_match가 false인 이유는 대상 전체가 숫자만으로 이뤄져 있지 않기 때문이고, regex_search는 첫 숫자열 "42"를 찾아낸다. regex_replace는 매치된 모든 숫자열을 #으로 바꾼다. 결과 인자가 없는 오버로드는 매치 여부(bool)만 필요할 때 쓰며, 대상은 std::string·C 문자열·반복자 쌍 모두 가능하다(별칭이 각각 대응한다).

 

문법 선택 (syntax_option_type)

syntax_option_type은 basic_regex 생성 시 넘기는 BitmaskType로, 문법 하나와 수정자들의 조합으로 구성된다. 문법 선택자(ECMAScript/basic/extended/awk/grep/egrep)는 최대 하나만 지정할 수 있고, 아무것도 지정하지 않으면 ECMAScript가 선택된다. 그래서 std::regex("meow", std::regex::icase)는 std::regex("meow", std::regex::ECMAScript|std::regex::icase)와 같다.

 

문법 선택자는 다음과 같다.

  1. ECMAScript: 수정된 ECMAScript 문법으로 기본값이다. JavaScript/.NET에 가장 근접하며 비탐욕과 역참조를 지원한다.
  2. basic: POSIX 기본 정규식(BRE).
  3. extended: POSIX 확장 정규식(ERE).
  4. awk: POSIX awk 문법으로, extended와 동일하되 C 스타일 이스케이프 시퀀스를 허용한다.
  5. grep: POSIX grep(basic 계열)으로, 개행(\n)을 교대(alternation) 구분자로 허용한다.
  6. egrep: POSIX grep -E(extended 계열)으로, 개행을 교대 구분자로 허용한다.

수정자 플래그는 문법과 OR로 조합한다.

  • icase: 대소문자 구분 없이 매칭.
  • nosubs: 마킹된 하위식을 비마킹으로 취급해 하위 매치를 저장하지 않음.
  • optimize: 생성(컴파일) 속도보다 매칭 속도를 우선.
  • collate: [a-b] 같은 범위 비교가 로캘 의존.
  • multiline(C++17~): ECMAScript 한정으로, ^/$가 입력 전체뿐 아니라 각 줄 시작/끝에도 매칭.

기본값이 ECMAScript라는 선택은 실용적 함의가 있다. 비탐욕과 역참조를 지원하는 가장 표현력 높은 문법이 별도 지정 없이 적용되기 때문이다. POSIX 계열은 각 도구(awk/grep 등)의 관행을 따르려는 경우에 선택한다.

 

ECMAScript 문법 요소

아래 요소는 기본값인 ECMAScript 기준이다.

  • 앵커: ^(시작), $(끝), \b(단어 경계), \B(비단어 경계)
  • 문자 클래스 약어: \d/\D(숫자/비숫자), \w/\W(단어문자[영숫자+_]/비단어), \s/\S(공백/비공백)
  • .: 개행을 제외한 임의의 1문자
  • 대괄호식: [abc](집합 중 하나), [^abc](부정), [a-z](범위)
  • 수량자: *(0회 이상), +(1회 이상), ?(0/1회), {n}(정확히 n), {n,}(n 이상), {n,m}(n~m). 뒤에 ?를 붙이면 비탐욕(lazy): *?, +?, ??, {n,m}?
  • 그룹: (…)(캡처 그룹), (?:…)(비캡처 그룹, 그룹핑만)
  • 교대: |(최저 우선순위)
  • 역참조: \1…\9(앞선 캡처 그룹이 매치한 문자열 재사용)
  • 이스케이프: \ . * + ? { } [ ] ( ) | ^ $는 백슬래시로 이스케이프

매칭 규칙에는 두 가지 유의점이 있다. 수량자는 기본이 탐욕(greedy)이라 가능한 최대까지 소비한 뒤 실패하면 백트래킹한다. 그리고 ECMAScript는 |에서 먼저 매치되는 왼쪽 대안(leftmost)을 선택하는 반면, POSIX 계열은 leftmost-longest(최장 일치)를 택한다. 이 차이 때문에 마크업 파싱처럼 "가장 가까운 닫는 태그까지만" 잡아야 하는 경우 ECMAScript의 비탐욕(.*?)이 적합하다.

 

매칭 플래그 (match_flag_type)

알고리즘 호출 시 매칭 동작을 세부 조정하는 플래그다.

매칭 제어는 다음과 같다.

  • match_default: 기본 동작(빈 비트마스크)
  • match_not_bol / match_not_eol: 대상의 첫/끝 문자를 줄 시작/끝이 아닌 것으로 취급(^/$에 영향)
  • match_not_bow / match_not_eow: \b가 대상 시작/끝 경계에서 매치하지 않음
  • match_any: 여러 매치가 가능하면 아무거나 허용
  • match_not_null: 빈 시퀀스는 매치하지 않음
  • match_continuous: 대상 시작(first)에서 시작하는 부분만 매치
  • match_prev_avail: --first가 유효하며, 이때 match_not_bol·match_not_bow는 무시된다

치환 포맷 플래그(regex_replace)는 다음과 같다.

  • format_default: ECMAScript 치환 규칙($&=전체, $1…=그룹, $`=앞부분, $'=뒷부분)
  • format_sed: POSIX sed 규칙
  • format_no_copy: 미매치 부분을 출력에 복사하지 않음
  • format_first_only: 첫 매치만 치환

경계 관련 플래그들은 대상 문자열이 더 큰 텍스트의 일부일 때 의미가 있다. 예컨대 match_prev_avail은 대상 앞에 유효한 문자가 있음을 알려 앞 경계 처리를 정상화하며, 이때 match_not_bol/match_not_bow가 무시된다.

 

match_results / smatch 접근

매치 결과의 인덱스 규칙은 명확하다. 0번은 전체 매치, 1..n은 캡처 그룹이며, 그룹 번호는 정규식의 여는 괄호 순서를 따른다. 

  • empty(): 매치에 실패하면 true
  • size(): 하위 매치 개수(전체 매치 포함)
  • operator[](n) / str(n) / length(n) / position(n): n번째 sub_match 접근·문자열·길이·시작 위치
  • prefix() / suffix(): 매치 앞/뒤의 미매치 구간(sub_match)
  • begin() / end(): sub_match 순회
  • format(포맷): 매치 결과로 포맷 문자열 생성
std::regex re(R"((\d{4})-(\d{2})-(\d{2}))");   // 연-월-일
std::smatch m;
std::string s = "d=2026-07-01;";
if (std::regex_search(s, m, re)) {
    m.str(0);        // "2026-07-01" (전체)
    m.str(1);        // "2026"       (그룹1)
    m.position(1);   // 그룹1 시작 위치
    m.prefix().str();// "d="
    m.suffix().str();// ";"
}

세 개의 괄호가 각각 그룹 1·2·3이 되고, m.str(0)은 전체 매치를, m.str(1)은 첫 그룹을 돌려준다. prefix()/suffix()는 매치되지 않은 앞뒤 구간을 담으므로, 원문에서 매치가 어디에 위치했는지까지 함께 파악할 수 있다.

 

반복자로 다중 매치·분할

한 번의 regex_search는 첫 매치만 다루므로, 여러 매치를 순회하거나 문자열을 분할하려면 반복자를 쓴다.

  • regex_iterator: 대상 내 모든 매치를 차례로 방문하며, end 반복자까지 순회한다.
  • regex_token_iterator: 각 매치의 지정 그룹을 뽑거나, -1을 지정하면 매치 사이의 조각을 뽑아 split을 구현한다.
std::string s = "a=1, b=2, c=3";
std::regex re(R"(\d+)");
for (std::sregex_iterator it(s.begin(), s.end(), re), end; it != end; ++it)
    (*it).str();                         // "1","2","3" 순회

std::regex sep(R"(\s*,\s*)");
std::sregex_token_iterator tk(s.begin(), s.end(), sep, -1), end;
// tk 순회 => "a=1","b=2","c=3" (구분자 사이 토큰)

앞의 반복자는 숫자열 매치 자체를 순회하고, 뒤의 토큰 반복자는 구분자 정규식(sep)을 기준으로 그 사이의 조각을 돌려준다. -1 인자가 "매치가 아니라 매치 사이"를 가리키게 하여 분할을 표현한다.

 

예외 (regex_error / error_type)

잘못된 정규식이나 매칭 실패는 std::regex_error로 던져지며, code()가 구체적 원인을 담은 error_type을 반환한다.

error_type 의미

error_collate 잘못된 collating 요소 이름
error_ctype 잘못된 문자 클래스 이름
error_escape 잘못된 이스케이프 문자 또는 끝에 오는 백슬래시
error_backref 잘못된 역참조
error_brack [ ] 짝 불일치
error_paren ( ) 짝 불일치
error_brace { } 짝 불일치
error_badbrace {} 안의 잘못된 범위
error_range 잘못된 문자 범위 (예: [b-a])
error_space 정규식을 상태기계로 변환할 메모리 부족
error_badrepeat * ? + { 앞에 유효한 정규식이 없음
error_complexity 매칭 복잡도가 사전 한도 초과
error_stack 매칭 수행 메모리 부족

코드들은 크게 두 부류로 읽힌다. 대부분은 패턴 문법 자체의 오류(괄호·중괄호·대괄호 짝, 범위, 이스케이프, 역참조, 반복 지정)이고, error_space·error_complexity·error_stack은 문법이 아니라 변환/매칭 과정의 리소스·복잡도 한계에서 발생한다.

 

주의 / 한계

  • C++ 문자열 리터럴에서의 이스케이프: 일반 리터럴은 백슬래시를 두 번 써야 하고("\\d+"), 이를 피하려면 raw string R"(\d+)"을 쓰는 편이 낫다.
  • 정규식 컴파일 비용: optimize가 생성 속도와 매칭 속도를 맞바꾸는 데서 보이듯 컴파일에는 비용이 있으므로, 반복 매칭 시 regex 객체를 루프 밖에서 만들어 재사용한다.
  • 탐욕 기본값: 의도치 않은 최장 매치에 주의하고, 필요하면 비탐욕(?)을 쓴다.
  • 과도한 백트래킹: 복잡한 패턴과 큰 입력에서는 error_complexity/error_stack이 발생할 수 있다.
  • 문법 선택자는 2개 이상 동시 지정이 금지되며(최대 1개), 미지정 시 ECMAScript가 적용된다.
  • multiline은 ECMAScript 전용이고 C++17부터 제공된다.
master UTF-8 HTML HIBACHI Ln 1, Col 1