Nimf 설계 및 구현 (초안)
Nimf 설계 및 구현 초안입니다. 내용 검수가 아직 되지 않았습니다.
목차
- Nimf 설계 및 구현 초안입니다. 내용 검수가 아직 되지 않았습니다.
- 1. 리눅스, BSD에서의 입력 방식 개요
- 2. 다국어 IME의 핵심 아키텍처
- 3. Nimf 데몬 설계: 독립 실행 프로세스
- 4. 플러그인 아키텍처와 동적 상호작용 설계
- 4. 플러그인 인터페이스 설계: 물리적 생명주기 관리
- 서비스 인터페이스 설계: 논리적 기능 관리
- 2-프로세스 모델의 장점 요약
- NIM(nimf input method) 프로토콜 설계 / 구현
- 언어 플러그인 인터페이스 설계
- 설정 시스템 설계
- GTK IM 모듈 구현
- Qt IM 모듈 구현
- XIM 구현
- Waylad IM 구현
- 설정기 구현
- 3. Nimf 데몬 설계: 생명주기와 이벤트 처리
- 3.1 데몬의 기본 골격: 주 프로시저와 생명주기
- 3.2 비동기 처리의 심장: 주 이벤트 루프 도입
- 3.3 세상과의 창구: IPC 리스너 구현
- 3.4 확장성의 핵심: 플러그인 관리자 설계
- 3.5 핵심 기능 연동: 이벤트 라우팅과 상태 관리
- 차이점: 직접 제어 vs. 프레임워크를 통한 중재
- 이 사실이 바꾸지 않는 것들
- 최종 결론 (수정)
- 문제의 핵심: Real UID vs. Login UID
- 해결 방안
- 결론
- 왜 8글자 제한이 있었는가?
- 현대적인 해결 방법:
getuid
+getpwuid
- 리눅스와의 비교
- 🐛 버그가 발생하는 근본적인 이유
1. 리눅스, BSD에서의 입력 방식 개요
이 장에서는 현대적인 입력기 프레임워크인 Nimf의 설계와 구현을 논하기에 앞서, XIM, GTK, Qt, Wayland, 그리고 콘솔 등 그 기술적 배경이 되는 주요 입력 방식들의 구조와 특징을 개괄적으로 살펴보겠습니다.
키 이벤트의 흐름과 처리 방식의 분기
키보드 입력이 화면의 글자로 변환되는 과정은 크게 두 단계로 나뉩니다. 첫 단계인 하드웨어에서 커널까지의 흐름은 모든 방식에서 동일하지만, 두 번째 단계인 사용자 공간에서 이벤트를 처리하는 방식은 사용하는 환경에 따라 크게 달라집니다.
-
공통 흐름 (하드웨어 → 커널): 키보드가 키 입력을 스캔코드(scancode)로 감지하면, 운영체제 커널이 이를 하드웨어 독립적인 키코드(keycode)로 변환하여 사용자 공간으로 전달할 준비를 합니다.
-
분기된 흐름 (사용자 공간): 커널에서 넘어온 키 이벤트는 이제 어떤 기술을 사용하느냐에 따라 각기 다른 경로로 IME(Input Method Editor)와 애플리케이션에 전달됩니다. 이어지는 절에서는 각 방식의 고유한 처리 흐름을 살펴보겠습니다.
1.1 X 윈도우 시스템과 XIM 프로토콜
X 윈도우 시스템(또는 X11)은 유닉스 계열 운영체제에서 그래픽 사용자 인터페이스(GUI)를 구현하는 핵심 기반입니다. X의 가장 중요한 특징은 네트워크를 기반으로 한 클라이언트-서버 모델로 설계되었다는 점입니다.
X 서버 (X Server)
사용자의 컴퓨터에서 실행되며, 화면, 키보드, 마우스 같은 입출력 하드웨어를 직접 제어합니다. X 서버는 하드웨어에서 발생한 이벤트를 받아 적절한 클라이언트에 전달하고, 클라이언트의 그리기 요청을 받아 화면에 표시하는 역할을 합니다.
X 클라이언트 (X Client)
우리가 흔히 ‘애플리케이션’이라고 부르는 것입니다. X 클라이언트는 X 서버에 접속하여 “이 위치에 창을 만들어 달라”, “이 창에 글자를 그려 달라”고 요청하며, X 서버로부터 키보드나 마우스 이벤트를 전달받습니다.
이러한 X의 기본 구조 위에서, 다국어 입력을 처리하기 위해 고안된 프로토콜이 바로 XIM(X Input Method)입니다. 이 구조의 핵심은 애플리케이션이 중간 다리 역할을 한다는 점입니다.
-
흐름: X 서버 → 애플리케이션 → IME → 애플리케이션
X 서버가 보낸 키 이벤트를 받은 애플리케이션은, 외부에서 별개로 실행 중인 IME 프로세스로 이벤트를 다시 전달하여 문자 변환을 요청합니다. IME가 처리한 결과는 다시 애플리케이션으로 돌아와 화면에 표시됩니다.
XIM 이벤트 흐름
digraph XIM_Simple {
rankdir=TD;
node [shape=box, style="filled", fontname="sans-serif"];
subgraph cluster_kernel {
label="운영체제 커널"; style="dotted";
A [label="키보드 입력"]; K [label="커널"];
A -> K;
}
subgraph cluster_userspace {
label="사용자 공간";
B [label="X 서버", fillcolor="#fff0b3"];
C [label="애플리케이션", fillcolor="#cce5ff"];
D [label="IME", fillcolor="#d4edda"];
}
K -> B [label="키코드"];
B -> C [label="키 이벤트"];
C -> D [label="이벤트 전달"];
D -> C [label="문자열 반환"];
}
1.2 GTK IM 모듈
GTK(The GIMP Toolkit)는 리눅스 데스크톱 환경인 GNOME의 기반을 이루는 핵심적인 위젯 툴킷(widget toolkit)입니다. C언어로 작성된 객체 지향 시스템(GObject)을 바탕으로, 개발자들이 손쉽게 그래픽 사용자 인터페이스(GUI)를 구축할 수 있도록 버튼, 텍스트 상자, 메뉴 등 다양한 UI 구성 요소를 제공합니다.
이처럼 애플리케이션의 모든 UI 요소를 직접 그리는 GTK는, 텍스트 입력 위젯을 위해 XIM의 복잡성을 줄이고자 툴킷에 통합된 플러그인 방식을 제공합니다.
- 흐름: 디스플레이 서버 → GTK 애플리케이션 → IME 모듈 → GTK 애플리케이션
이 모델의 핵심은 GtkIMContext
라는 인-프로세스(in-process) 플러그인입니다. IME 로직을 담은 모듈이 애플리케이션 내부에 직접 로드되어 동작하며, 완성된 글자는 ‘commit’ 시그널(signal)을 통해 위젯에 전달됩니다.
GTK IM 모듈 이벤트 흐름 (인-프로세스 모델)
digraph GTK_In_Process {
rankdir=TD;
node [shape=box, style="filled", fontname="sans-serif"];
subgraph cluster_os {
label="운영체제/디스플레이 서버"; style="dotted";
A [label="키보드 입력"];
B [label="디스플레이 서버"];
A -> B;
}
subgraph cluster_app_proc {
label="애플리케이션 프로세스";
C [label="GTK 애플리케이션", fillcolor="#cce5ff"];
D [label="IME 모듈\n(GtkIMContext)", fillcolor="#d4edda"];
}
B -> C [label="키 이벤트"];
C -> D [label="1. 이벤트 전달"];
D -> D [label="2. 문자 조합"];
D -> C [label="3. commit 시그널"];
}
1.3 Qt IM 모듈
Qt(큐트)는 GTK와 함께 리눅스 데스크톱 환경의 양대 산맥인 KDE Plasma Desktop의 기반을 이루는 애플리케이션 프레임워크입니다. C++를 기반으로 하며, GUI뿐만 아니라 네트워크, 데이터베이스 등 애플리케이션 개발에 필요한 포괄적인 기능을 제공하는 것이 특징입니다.
이처럼 강력한 크로스플랫폼 프레임워크인 Qt 역시, GTK와 개념적으로 매우 유사한 인-프로세스(in-process) 플러그인 방식을 제공합니다.
- 흐름: 디스플레이 서버 → Qt 애플리케이션 → IME 모듈 → Qt 애플리케이션
핵심 컴포넌트는 QPlatformInputContext
플러그인입니다. GTK와 마찬가지로 IME 로직을 담은 모듈이 애플리케이션 내부에 로드되어 동작합니다. 다만, 완성된 글자는 시그널이 아닌 InputMethodEvent
라는 별도의 이벤트 객체를 통해 위젯으로 전송된다는 점에서 미묘한 차이가 있습니다.
Qt IM 모듈 이벤트 흐름 (인-프로세스 모델)
digraph QT_In_Process {
rankdir=TD;
node [shape=box, style="filled", fontname="sans-serif"];
subgraph cluster_os {
label="운영체제/디스플레이 서버"; style="dotted";
A [label="키보드 입력"];
B [label="디스플레이 서버"];
A -> B;
}
subgraph cluster_app_proc {
label="애플리케이션 프로세스";
C [label="Qt 애플리케이션", fillcolor="#cce5ff"];
D [label="IME 모듈\n(QPlatformInputContext)", fillcolor="#d4edda"];
}
B -> C [label="키 이벤트"];
C -> D [label="1. 이벤트 전달"];
D -> D [label="2. 문자 조합"];
D -> C [label="3. InputMethodEvent"];
}
1.4 웨이랜드 (Wayland)
Wayland는 X11을 대체하기 위해 나온 현대적인 디스플레이 서버 프로토콜입니다. X11의 복잡성과 오래된 설계에서 비롯된 문제들을 해결하고, 더 나은 성능과 강력한 보안을 제공하는 것을 목표로 합니다.
Wayland의 핵심은 컴포지터(compositor)가 디스플레이 서버, 윈도우 매니저, 화면 합성기의 역할을 모두 통합하여 수행한다는 점입니다. 이는 전통적인 X11과 중요한 차이를 보입니다. X11 환경에서는 화면을 제어하는 디스플레이 서버, 창을 관리하는 윈도우 매니저, 화면 효과를 위한 컴포지터가 각각 별개의 프로세스로 동작할 수 있었습니다. Wayland는 이 세 가지 역할을 ‘컴포지터’라는 단일 프로세스로 통합하여 구조를 단순화하고 효율성을 높였습니다.
또한, 보안을 최우선으로 고려하여 설계되었기 때문에 각 클라이언트(애플리케이션)는 완전히 격리됩니다. 즉, 하나의 애플리케이션이 다른 애플리케이션의 창 내용을 보거나 키 입력을 가로채는 것이 원천적으로 불가능합니다.
이처럼 모든 통신을 컴포지터가 중재하는 Wayland의 엄격한 보안 모델은, 입력기 아키텍처에도 근본적인 변화를 가져왔습니다. 이 구조의 핵심은 컴포지터가 모든 통신을 중재한다는 점입니다.
-
흐름: Wayland 컴포지터 → IME → Wayland 컴포지터 → 애플리케이션
컴포지터가 키 이벤트를 받아 IME로 직접 전달하고, IME의 처리 결과 또한 컴포지터가 받아 애플리케이션에 전달합니다. 애플리케이션과 IME는 서로의 존재를 알지 못하며 절대 직접 통신하지 않습니다. 이 방식은 보안을 크게 향상시킵니다.
Wayland 이벤트 흐름
digraph Wayland_Numbered {
rankdir=TD;
node [shape=box, style="filled", fontname="sans-serif"];
subgraph cluster_os {
label="운영체제 커널"; style="dotted";
A [label="키보드 입력"];
}
subgraph cluster_userspace {
label="사용자 공간";
B [label="Wayland 컴포지터", fillcolor="#fff0b3"];
C [label="IME", fillcolor="#d4edda"];
D [label="애플리케이션", fillcolor="#cce5ff"];
}
A -> B [label="1. 하드웨어 이벤트"];
B -> C [label="2. input-method"];
C -> B [label="3. input-method"];
B -> D [label="4. text-input"];
}
1.5 콘솔 (console)
지금까지 다룬 X11, Wayland, GTK, Qt는 모두 그래픽 사용자 인터페이스(GUI) 환경을 전제합니다. 하지만 리눅스와 FreeBSD에는 GUI 없이 오직 텍스트로만 시스템과 상호작용하는 환경이 존재하며, 다국어 입력기는 이 환경 역시 지원해야 합니다.
여기서 중요한 점은, 우리가 논의할 ‘콘솔’ 이란 GNOME이나 KDE 같은 데스크톱 환경 안에서 실행하는 gnome-terminal
이나 konsole
과 같은 터미널 에뮬레이터 창이 아니라는 것입니다. 여기서의 콘솔은 GUI가 전혀 없는 순수한 non-GUI 텍스트 모드 환경을 의미합니다.
이 환경을 이해하기 위해 몇 가지 핵심 용어를 먼저 정리할 필요가 있습니다.
-
터미널 (terminal): 사용자와 컴퓨터 간의 텍스트 기반 입출력 인터페이스를 총칭하는 가장 넓은 의미의 용어입니다. 과거의 물리적인 하드웨어 장치에서 유래했습니다.
-
콘솔 (console)과 가상 터미널 (VT, Virtual Terminal): 콘솔은 운영체제 커널이 직접 관리하는 시스템의 주(primary) 터미널을 의미합니다. 리눅스와 FreeBSD에서는 하나의 물리적인 화면과 키보드로 여러 개의 독립적인 콘솔 세션을 사용할 수 있도록 다중화하는데, 이 각각의 세션을 가상 터미널(VT)이라고 부릅니다. 보통 Ctrl + Alt + F1 부터 F6 까지의 기능 키 조합으로 VT 간에 전환할 수 있습니다.
-
의사 터미널 (pty, pseudo-terminal): 터미널을 소프트웨어적으로 흉내 내는 커널의 기능입니다. 마스터(master)와 슬레이브(slave)라는 한 쌍의 가상 장치로 구성되어, 프로그램이 다른 프로그램을 마치 터미널에서 제어하는 것처럼 속일 수 있게 해줍니다.
ssh
나tmux
, 그리고 GUI의 터미널 에뮬레이터들이 바로 이 pty 기술을 기반으로 동작합니다.
이러한 텍스트 콘솔 환경에서는 앞선 방식들과 전혀 다른 접근이 필요합니다. 핵심 원리는 의사 터미널(pty)을 생성하여 사용자와 쉘(shell) 사이에 위치하는 것입니다.
IME 프로세스는 사용자의 키 입력을 직접 가로채서 문자를 조합한 후, 완성된 글자를 pty를 통해 쉘의 입력으로 넣어줍니다. 이를 통해 쉘 프로그램을 전혀 수정하지 않고도 다국어 입력을 지원할 수 있습니다.
콘솔 IME 아키텍처 및 상세 이벤트 흐름
digraph Console_Combined_Flow_Separated {
rankdir=TD;
compound=true;
node [shape=box, style="filled", fontname="sans-serif"];
// 키보드와 화면은 외부 I/O
Keyboard [label="키보드\n(STDIN)", shape=Mdiamond, fillcolor="#e9ecef"];
Screen [label="콘솔 화면\n(STDOUT)", shape=Mdiamond, fillcolor="#e9ecef"];
// 사용자 공간: IME 프로세스
subgraph cluster_ime_proc {
label="사용자 공간: IME 프로세스 (부모)";
IME [label="IME 로직", fillcolor="#d4edda"];
}
// 커널 공간: PTY
subgraph cluster_kernel {
label="커널 공간";
style=dotted;
Master [label="pty 마스터"];
Slave [label="pty 슬레이브"];
Master -> Slave [style=dashed, arrowhead=none, label="데이터 전달"];
}
// 사용자 공간: 쉘 프로세스
subgraph cluster_shell_proc {
label="사용자 공간: 쉘 프로세스 (자식)";
Shell [label="쉘 (/bin/sh)", fillcolor="#cce5ff"];
}
/* 이벤트 흐름 (시스템 콜을 통해 공간을 넘나드는 과정) */
Keyboard -> IME [label="1. Raw 키 읽기"];
IME -> IME [label="2. 문자 조합", style=dashed];
// 화면 출력 흐름 분리
IME -> Screen [label="3. Pre-edit 표시"];
IME -> Screen [label="8. 쉘 출력 표시"];
// Commit 및 쉘 입출력 흐름
IME -> Master [label="4. Commit 쓰기"];
Slave -> Shell [label="5. 쉘 입력으로 전달"];
Shell -> Slave [label="6. 쉘 출력 쓰기"];
Master -> IME [label="7. 쉘 출력 읽기"];
}
지금까지 우리는 XIM이라는 고전적인 프로토콜에서부터 현대적인 툴킷의 IM 모듈, 보안을 중시하는 Wayland, 그리고 독자적인 방식으로 동작하는 콘솔에 이르기까지 리눅스와 BSD 환경에 존재하는 다양한 입력 방식의 구조와 철학을 살펴보았습니다.
이를 통해 이 환경에서 IME를 개발한다는 것은, 이처럼 파편화되고 각기 다른 외부 환경을 모두 이해하고 지원해야 하는 복잡한 과제임을 알 수 있습니다. 그렇다면 이러한 외부 환경의 요구사항을 받아들여, 다국어 입력을 효율적으로 처리하는 현대적인 IME의 내부 구조는 어떻게 설계되어야 할까요?
다음 2장에서는 이 질문에 대한 답을 찾아, 잘 설계된 IME의 핵심인 플러그인 아키텍처를 비롯한 내부 설계 원칙을 깊이 있게 탐구해 보겠습니다.
2. 다국어 IME의 핵심 아키텍처
1장에서는 리눅스와 BSD 환경에 파편화되어 있는 XIM, GTK, Qt, Wayland, 콘솔 등 다양한 외부 입력 프로토콜을 살펴보았습니다. 이처럼 복잡한 외부 환경을 지원하는 동시에, 한글, 병음, 일본어 등 각기 다른 내부 언어 로직을 효율적으로 처리하는 것은 현대적인 다국어 IME가 풀어야 할 핵심 과제입니다.
성공적인 IME 아키텍처는 단순히 기능을 구현하는 것을 넘어, 다음 두 가지 핵심 요구사항을 반드시 충족해야 합니다.
- 효율성 (efficiency): 시스템의 메모리와 자원을 낭비 없이 효율적으로 사용해야 합니다.
- 관리 용이성 (manageability): 시스템 전역의 설정을 일관되게, 그리고 동적으로 관리할 수 있어야 합니다.
이러한 요구사항을 기준으로 IME 구현을 위한 두 가지 주요 아키텍처, 즉 ‘서버리스 모델’과 ‘서버 모델’을 비교할 수 있습니다. 결론부터 말하자면, 이 장에서는 중앙 집중식 IPC 서버(데몬)와 플러그인 시스템을 결합한 아키텍처가 위 요구사항을 가장 잘 만족시키는 우월한 설계임을 논증하고자 합니다.
2.1 아키텍처의 선택: 서버 모델의 설계 우위
IME 로직을 구현하는 방식은 크게 두 가지로 나뉩니다. 첫째는 IME 로직 전체를 라이브러리화하여 각 애플리케이션이 직접 로드하는 서버리스(in-process) 모델이며, 둘째는 시스템 전역에 단 하나의 IME 데몬(서버)을 두고 애플리케이션들이 IPC 통신으로 서비스를 요청하는 서버(out-of-process) 모델입니다.
서버리스 모델은 언뜻 보기에 별도의 데몬을 관리할 필요가 없어 단순해 보이지만, 앞서 제시한 핵심 요구사항을 만족시키지 못하는 근본적인 결함을 가지고 있습니다.
- 자원 낭비: 10개의 애플리케이션이 실행되면, 동일한 IME 라이브러리가 메모리에 10번 중복으로 로드됩니다. 이는 시스템 자원을 심각하게 낭비하는 비효율적인 방식입니다.
- 전역 설정의 동적 반영 불가: 사용자가 별도의 설정 프로그램을 통해 새로운 언어를 추가해도, 이미 실행 중인 앱들은 이 변경 사실을 알 방법이 없어 모든 앱을 재시작해야만 합니다.
- 전역 상태 표시의 어려움: 사용자는 화면 구석의 아이콘을 통해 현재 입력 모드가 ‘한글’인지 ‘영어’인지 알아야 합니다. 하지만 서버리스 모델에서는 각 앱이 자신만의 상태를 가지므로, 어떤 앱을 기준으로 할지 알 수 없어 전역 인디케이터를 만들 수 없습니다. 이를 해결하려면 결국 상태 동기화를 위한 별도의 ‘인디케이터 데몬’과 IPC가 필요해져, 서버리스 모델의 장점은 사라지고 복잡성만 늘어납니다.
반면, IPC 서버 기반의 플러그인 IME 아키텍처는 이러한 문제들을 구조적으로 완벽하게 해결합니다.
digraph Plugin_Architecture_Tidied_Up {
rankdir=TD;
node [shape=box, style="filled", fontname="sans-serif"];
subgraph cluster_clients {
label="클라이언트 측 공간";
style=invis;
subgraph cluster_gtk_app {
label="GTK 애플리케이션 프로세스";
style=dotted;
GTK_App [label="GTK 애플리케이션", fillcolor="#cce5ff"];
GTK_Plugin [label="GTK IM 모듈\n(in-process)", fillcolor="#e9ecef"];
GTK_App -> GTK_Plugin [style=dashed, arrowhead=none, label="로드"];
}
subgraph cluster_qt_app {
label="Qt 애플리케이션 프로세스";
style=dotted;
QT_App [label="Qt 애플리케이션", fillcolor="#cce5ff"];
QT_Plugin [label="Qt IM 모듈\n(in-process)", fillcolor="#e9ecef"];
QT_App -> QT_Plugin [style=dashed, arrowhead=none, label="로드"];
}
}
subgraph cluster_other_clients {
label="기타 클라이언트 프로세스 및 환경";
style=invis;
XIM_App [label="XIM 애플리케이션", fillcolor="#cce5ff"];
Wayland_Comp [label="Wayland 컴포지터", fillcolor="#cce5ff"];
Console_Wrapper [label="콘솔 래퍼", fillcolor="#cce5ff"];
Indicator [label="인디케이터", fillcolor="#cce5ff"];
}
subgraph cluster_ime {
label="IME 서버 프로세스 (데몬)";
subgraph cluster_frontend {
label="서버 측 프론트엔드";
style=filled;
fillcolor="#f8f9fa";
XIM_Plugin [label="XIM 모듈", fillcolor="#e9ecef"];
Wayland_Plugin [label="Wayland IM 모듈", fillcolor="#e9ecef"];
}
Core [label="IME 핵심부 (Core Engine)\n(이벤트 라우팅, 설정, 플러그인 관리)", shape=ellipse, fillcolor="#fff0b3"];
subgraph cluster_im {
label="입력기 플러그인 계층";
style=filled;
fillcolor="#f8f9fa";
Hangul [label="한글 입력기", fillcolor="#d4edda"];
Pinyin [label="병음(Pinyin)\n입력기", fillcolor="#d4edda"];
Anthy [label="Anthy (일본어)\n입력기", fillcolor="#d4edda"];
Other_IM [label="기타 입력기", fillcolor="#d4edda"];
}
}
// Connections to Server
GTK_Plugin -> Core [label="IPC"];
QT_Plugin -> Core [label="IPC"];
XIM_App -> XIM_Plugin;
Wayland_Comp -> Wayland_Plugin;
Console_Wrapper -> Core [label="IPC"];
Indicator -> Core [label="IPC"];
// Internal Server Connections
XIM_Plugin -> Core [dir=both];
Wayland_Plugin -> Core [dir=both];
Core -> Hangul [dir=both];
Core -> Pinyin [dir=both];
Core -> Anthy [dir=both];
Core -> Other_IM [dir=both];
}
이 아키텍처의 설계 우위는 명확합니다.
- 중앙 집중식 효율성 및 관리: 모든 언어 플러그인, 설정, 현재 상태는 단 하나의 서버 프로세스에서 관리됩니다. 따라서 자원 중복이 없을 뿐만 아니라, 설정 변경이나 현재 상태 조회가 단일 통신 채널을 통해 즉시 일관되게 이루어집니다.
- 최적화된 사용자 경험: 서버는 모든 언어 엔진을 미리 메모리에 로드해두므로, 사용자는 어떤 앱에서든 지연 없이 즉각적으로 언어를 전환하고 사용할 수 있습니다.
이러한 이유로, 효율적이고 관리하기 용이하며 쾌적한 다국어 입력 환경을 구축하기 위한 아키텍처를 선택할 때, IPC 서버 기반의 플러그인 모델은 다른 대안과 비교할 수 없는 명백한 장점을 가집니다.
3. Nimf 데몬 설계: 독립 실행 프로세스
참고: 이 장의 모든 예제 코드는 리눅스/BSD의 C 함수들을 Ada로 바인딩한 cada 라이브러리(https://github.com/hodong-kim/cada)의 사용을 전제로 합니다.
이 장에서는 Nimf 프레임워크의 모든 기능을 담을 컨테이너, 즉 데몬 프로세스의 기본 구조를 설계합니다. 복잡한 입력기 로직을 구현하기에 앞서, 시스템 서비스로서 안정적으로 동작할 수 있는 견고한 프로세스를 먼저 구축하는 것은 필수적입니다. 이 장은 바로 그러한 독립 실행 프로세스의 설계를 다룹니다.
이를 위해, 먼저 프로세스를 사용자의 제어 터미널로부터 분리하여 백그라운드에서 실행되도록 하는 ‘데몬화’ 과정을 살펴볼 것입니다. 다음으로, 파일 잠금(file lock)을 이용하여 시스템에서 단 하나의 데몬 인스턴스만 실행되도록 보장하는 중복 실행 방지 메커니즘을 설계합니다. 마지막으로, 정상적인 종료 등 데몬의 생명주기를 안전하게 관리하기 위한 시스템 신호(signal) 처리 방안을 설계할 것입니다.
본 장에서 다루는 설계 요소들은 안정적인 서버 애플리케이션을 위한 전제 조건입니다. 프로세스 관리에 대한 견고한 토대를 여기서 마련함으로써, 다음 장들에서 이벤트 루프와 플러그인 아키텍처 등 핵심 입력기 로직 구현에 집중할 수 있게 될 것입니다.
3.1 백그라운드 프로세스 전환 설계
데몬은 사용자의 로그인 세션이나 특정 터미널에 종속되지 않고 시스템 백그라운드에서 독립적으로 실행되어야 합니다. 이를 위해, 이 절에서는 일반적인 프로세스를 시스템 서비스로 전환하는 첫 번째 단계인 ‘백그라운드 프로세스 전환’ 과정을 설계합니다.
이 과정은 두 가지 핵심적인 설계로 구성됩니다. 첫째, fork
와 setsid
시스템 콜을 이용하여 프로세스를 현재의 세션과 프로세스 그룹으로부터 분리하는 ‘데몬화’ 설계를 다룹니다. 둘째, 분리된 프로세스가 안정적으로 동작하도록 작업 디렉터리를 변경하고 표준 입출력을 재지향하는 ‘프로세스 환경 초기화’를 설계할 것입니다.
3.1.1 데몬화(daemonization): fork
, setsid
데몬화는 프로세스가 자신을 시작시킨 셸(shell)이나 터미널로부터 완전히 독립되는 과정입니다. 만약 이 과정이 없다면, 사용자가 로그아웃하거나 터미널을 닫을 때 데몬 프로세스도 함께 종료될 수 있습니다. 이를 방지하기 위해 fork
와 setsid
시스템 콜을 이용한 표준적인 절차를 따릅니다.
첫 번째 단계는 fork
를 호출하여 자식 프로세스를 생성하는 것입니다. fork
호출 직후, 부모 프로세스는 즉시 종료합니다. 이로써 자식 프로세스는 ‘고아(orphan)’가 되어, 원래의 셸 세션이 아닌 시스템의 최상위 프로세스(init 또는 systemd)의 자식으로 편입됩니다. 이 과정은 자식 프로세스를 현재의 프로세스 그룹으로부터 분리하는 효과를 가집니다.
두 번째 단계로, 부모와 분리된 자식 프로세스는 setsid
를 호출합니다. 이 함수는 새로운 세션(session)과 새로운 프로세스 그룹을 만들고, 자기 자신을 그 리더로 지정합니다. 이 호출을 통해 프로세스는 제어 터미널(controlling terminal)과의 연결을 완전히 끊게 되며, 이로써 비로소 완전한 데몬이 됩니다. setsid
는 프로세스 그룹의 리더가 아닌 프로세스만 호출할 수 있으므로, 반드시 fork
를 통해 생성된 자식 프로세스에서 실행해야 합니다.
with Ada.Text_IO;
with unistd_h; -- fork, setsid를 위해 사용
with stdlib_h; -- c_exit를 위해 사용
with sys_types_h; -- pid_t 타입을 위해 사용
with Interfaces.C;
procedure Detach_From_Terminal is
use type Interfaces.C.int;
pid : sys_types_h.pid_t;
begin
-- 1. 자식 프로세스 생성
pid := unistd_h.fork;
if pid < 0 then
-- Fork 에러 처리: C 함수는 음수 값을 반환
Ada.Text_IO.Put_Line (Ada.Text_IO.Standard_Error, "Error: fork failed.");
stdlib_h.c_exit (stdlib_h.EXIT_FAILURE);
elsif pid > 0 then
-- 2. 부모 프로세스는 즉시 성공적으로 종료
stdlib_h.c_exit (stdlib_h.EXIT_SUCCESS);
end if;
-- 3. 자식 프로세스만 이 코드를 실행합니다.
-- 새로운 세션을 시작하여 터미널로부터 분리합니다.
declare
sid : sys_types_h.pid_t;
begin
sid := unistd_h.setsid;
if sid < 0 then
-- setsid 에러 처리
Ada.Text_IO.Put_Line (Ada.Text_IO.Standard_Error, "Error: setsid failed.");
stdlib_h.c_exit (stdlib_h.EXIT_FAILURE);
end if;
end;
-- 이 시점에서 프로세스는 성공적으로 분리되었습니다.
-- 이후 데몬으로서 수행할 작업을 여기에 추가할 수 있습니다.
Ada.Text_IO.Put_Line ("Child process became a daemon.");
end Detach_From_Terminal;
3.1.2 프로세스 환경 초기화
데몬화 과정을 통해 터미널로부터 분리된 프로세스는, 부모로부터 상속받은 실행 환경을 그대로 가지고 있습니다. 이러한 상속된 환경은 예기치 않은 부작용을 일으킬 수 있으므로, 데몬이 안정적으로 동작할 수 있도록 깨끗하고 예측 가능한 환경으로 초기화하는 과정이 필요합니다.
첫째, 작업 디렉터리를 변경해야 합니다. 데몬이 특정 파일 시스템(예: /mnt/usb
)에서 실행되었다면, 해당 디렉터리를 계속 점유하여 파일 시스템의 마운트 해제를 방해할 수 있습니다. 이를 방지하기 위해, 데몬의 작업 디렉터리를 항상 존재하는 루트 디렉터리(/
)로 변경하는 것이 표준적인 방법입니다.
둘째, 파일 모드 생성 마스크(umask
)를 초기화해야 합니다. umask
는 파일 생성 시 기본 권한을 결정하며, 부모 셸의 설정을 상속받습니다. 데몬이 파일을 생성할 때 의도치 않은 권한이 설정되는 것을 막기 위해, 일반적으로 umask(0)
를 호출하여 마스크를 초기화하고 파일 권한을 코드에서 명시적으로 제어합니다.
셋째, 표준 파일 디스크립터를 재지향해야 합니다. 데몬은 제어 터미널이 없으므로 표준 입력(stdin
), 표준 출력(stdout
), 표준 오류(stderr
)가 더 이상 유효하지 않습니다. 라이브러리 함수 등이 이들 디스크립터에 접근하려다 오류가 발생하는 것을 막기 위해, /dev/null
장치로 재지향하는 것이 안전합니다.
설계 예시 코드 (Ada)
-- cada 라이브러리의 POSIX.File_Control 패키지를 사용합니다.
with POSIX.File_Control;
with Ada.Text_IO;
procedure Initialize_Environment is
use POSIX.File_Control;
Null_FD : File_Descriptor;
begin
-- Ada 표준 라이브러리로 디렉터리 변경
Ada.Directories.Change_Directory ("/");
-- GNAT 라이브러리로 umask 설정
declare
Old_Mask : GNAT.OS_Lib.Mode_Type;
begin
Old_Mask := GNAT.OS_Lib.Umask (New_Mask => 8#000#);
end;
-- 표준 입출력 재지향
Null_FD := Open ("/dev/null", Flags => O_RDWR); -- O_RDWR은 C 상수
Dup2 (Old_FD => Null_FD, New_FD => 0); -- stdin
Dup2 (Old_FD => Null_FD, New_FD => 1); -- stdout
Dup2 (Old_FD => Null_FD, New_FD => 2); -- stderr
-- 표준 디스크립터(0,1,2)가 아닌 경우, 원본 fd는 닫아줍니다.
if Null_FD > 2 then
Close (Null_FD);
end if;
exception
when File_Control_Error =>
-- 오류 처리
null;
end Initialize_Environment;
3.2 중복 실행 방지 설계
데몬이 백그라운드에서 독립적으로 실행된 후에는, 시스템 전체에서 단 하나의 인스턴스만 활성화되도록 보장하는 설계가 필요합니다. 만약 여러 데몬 프로세스가 동시에 실행된다면, IPC 소켓 주소와 같은 공유 자원을 두고 충돌하거나, 서로 다른 설정 상태로 인해 예기치 않은 오작동을 일으킬 수 있습니다.
3.2.1 PID 파일과 파일 잠금
중복 실행을 방지하는 가장 안정적인 방법은 PID 파일과 파일 잠금(file lock)을 함께 사용하는 것입니다. PID 파일은 실행 중인 데몬의 프로세스 ID(PID)를 저장하여 다른 프로세스가 데몬을 식별할 수 있게 하지만, 이것만으로는 충분하지 않습니다. 데몬이 비정상적으로 종료될 경우 PID 파일이 삭제되지 않고 남아, 새로운 데몬이 실행될 수 없다고 잘못 판단하는 등의 경쟁 상태(race condition)가 발생할 수 있기 때문입니다.
이 문제를 해결하는 핵심은 커널 수준에서 유일성을 보장하는 파일 잠금입니다. 데몬은 시작 과정에서 약속된 경로의 파일을 열고, 해당 파일 전체에 대해 배타적(exclusive)이고 비차단적인(non-blocking) 잠금을 시도합니다.
- 잠금 성공: 잠금이 성공하면, 현재 시스템에 다른 데몬 인스턴스가 없음을 의미합니다. 프로세스는 잠금을 획득한 후 해당 파일에 자신의 PID를 기록하고 정상적으로 실행을 계속합니다.
- 잠금 실패: 만약 다른 프로세스가 이미 잠금을 소유하고 있다면, 잠금 시도는
EWOULDBLOCK
과 같은 오류를 내며 즉시 실패합니다. 이는 다른 데몬 인스턴스가 이미 실행 중이라는 신호이므로, 현재 프로세스는 메시지를 출력하고 종료해야 합니다.
프로세스가 실행되는 동안에는 파일 잠금이 계속 유지되며, 어떤 이유로든 프로세스가 종료되면 운영체제는 해당 파일 잠금을 자동으로 해제합니다. 이 덕분에 다음 데몬 인스턴스가 다시 잠금을 획득하고 정상적으로 실행될 수 있습니다.
설계 예시 코드 (Ada)
-- cada 라이브러리의 관련 패키지를 사용합니다.
with POSIX.File_Control;
with POSIX.Process;
with Ada.Text_IO;
with Interfaces.C;
function Acquire_Singleton_Lock (Path : String) return Boolean is
use POSIX.File_Control, POSIX.Process;
Lock_FD : File_Descriptor;
-- C의 O_RDWR, O_CREAT, LOCK_EX, LOCK_NB 등의 상수는
-- 별도 패키지에 정의되어 있다고 가정합니다.
File_Flags : constant Interfaces.C.int := O_RDWR or O_CREAT;
File_Mode : constant Mode := 8#600#; -- 0o600
Lock_Flags : constant Interfaces.C.int := LOCK_EX or LOCK_NB;
begin
-- 1. 잠금 파일 열기
Lock_FD := Open (Path, File_Flags, File_Mode);
-- 2. 배타적, 비차단적 잠금 시도
Flock (Lock_FD, Lock_Flags);
-- 3. 잠금 성공 시 PID 기록
declare
PID_Image : constant String := Get_PID'Img;
begin
-- 파일 내용을 비우고 PID를 새로 씁니다.
-- (ftruncate, lseek 등의 추가적인 설계가 필요할 수 있음)
if Write (Lock_FD, PID_Image) /= PID_Image'Length then
-- 쓰기 오류 처리
return False;
end if;
end;
return True; -- 잠금 성공
exception
when File_Control_Error =>
-- Flock 실패 시 이곳으로 예외가 발생합니다.
-- 다른 인스턴스가 이미 실행 중임을 의미합니다.
Ada.Text_IO.Put_Line ("Daemon is already running.");
return False;
end Acquire_Singleton_Lock;
3.3 안전한 생명주기 관리 설계
독립적으로 실행되고 유일성을 보장받은 데몬은, 이제 자신의 생명주기를 안전하게 관리할 수 있어야 합니다. 시스템이 종료되거나 관리자가 kill
명령을 보낼 때, 데몬은 단순히 사라지는 것이 아니라 자신의 상태를 정리하고 모든 리소스를 정상적으로 해제하는 ‘정상 종료(graceful shutdown)’ 절차를 밟아야 합니다.
이 절에서는 유닉스 시스템의 표준적인 프로세스 통신 방법인 신호(signal)를 이용하여 이러한 정상 종료 메커니즘을 설계합니다. 특히, 신호 핸들러가 가지는 ‘비동기-신호-안전’이라는 특수한 제약 조건을 분석하고, 이를 안전하게 처리하여 데몬의 주 로직에 종료 의사를 전달하는 방법을 구체적으로 다룰 것입니다.
3.3.1 비동기 신호 처리와 위험 요소
신호 핸들러는 프로그램의 정상적인 실행 흐름을 언제든지 중단시키고 비동기적으로 실행되는 특수한 함수입니다. 이러한 비동기적 특성 때문에, 신호 핸들러 내부에서 수행할 수 있는 작업은 매우 엄격하게 제한됩니다. 만약 이 제약을 지키지 않으면, 프로그램 전체가 교착 상태(deadlock)에 빠지거나 데이터가 오염될 수 있습니다.
가장 대표적인 위험은 교착 상태입니다. 예를 들어, 데몬의 주 로직이 특정 데이터 구조를 보호하기 위해 뮤텍스 락(lock)을 획득했다고 가정해 보겠습니다. 바로 그 순간 운영체제로부터 신호가 도착하여 주 로직의 실행이 중단되고 신호 핸들러가 실행됩니다. 만약 이 신호 핸들러가 동일한 뮤텍스 락을 다시 획득하려고 시도하면, 프로그램은 영원히 멈추게 됩니다. 신호 핸들러는 주 로직이 락을 해제하기를 기다리고, 주 로직은 신호 핸들러가 끝나야 실행을 재개할 수 있기 때문입니다.
이러한 문제를 방지하기 위해, POSIX 표준은 신호 핸들러 내부에서 호출해도 안전한 ‘비동기-신호-안전(async-signal-safe)’ 함수 목록을 명시적으로 정의하고 있습니다. printf
, malloc
을 포함한 대부분의 표준 라이브러리 함수와 모든 동기화 함수(뮤텍스 등)는 이 목록에 포함되지 않습니다.
따라서 안정적인 데몬을 설계하려면, 신호 핸들러의 역할을 최소화하여 비동기-신호-안전 함수만을 사용하거나, 아예 락이 필요 없는 원자적(atomic) 연산만을 수행하도록 만들어야 합니다.
3.3.2 원자적 플래그를 이용한 종료 처리
신호 핸들러의 엄격한 제약 조건을 준수하면서 데몬의 종료를 안전하게 처리하는 가장 좋은 방법은, 신호 핸들러와 주 로직(main loop) 사이에 원자적 플래그(atomic flag)를 사용하는 것입니다.
신호 핸들러는 이 공유 플래그의 값을 변경하는 최소한의 작업만 수행하고, 데몬의 주 로직은 이 플래그의 값을 주기적으로 확인(polling)하여 종료 여부를 결정합니다. 이 플래그는 두 가지 특성을 반드시 가져야 합니다.
-
Atomic
: 플래그에 대한 읽기/쓰기 작업이 여러 기계어 명령으로 나뉘어 발생하는 경쟁 상태(race condition)를 방지합니다.atomic
으로 지정된 변수는 항상 단일 연산으로 처리됨을 보장받습니다. -
Volatile
: 컴파일러가 “이 변수는 루프 안에서 바뀌지 않으니 최적화해야겠다”고 판단하여, 메모리에서 값을 다시 읽지 않고 캐싱된 값을 사용하는 것을 방지합니다.volatile
로 지정하면, 매번 메모리에서 직접 값을 읽어와 신호 핸들러에 의한 변경을 즉시 인지할 수 있습니다.
이 설계는 신호 핸들러가 락(lock)이나 비동기-신호-안전하지 않은 함수를 전혀 호출하지 않으므로, 교착 상태나 데이터 오염의 위험을 원천적으로 제거하는 매우 안정적인 구조입니다.
설계 예시 코드 (Ada)
with POSIX.Signal;
-- ...
procedure Main_Daemon_Loop is
-- 1. 공유 플래그 선언
Shutdown_Requested : aliased Boolean := False;
pragma Volatile (Shutdown_Requested);
pragma Atomic (Shutdown_Requested);
-- 2. 신호 핸들러 설계
procedure Handle_Shutdown (Sig : Interfaces.C.int) is
begin
Shutdown_Requested := True; -- 원자적 쓰기
end Handle_Shutdown;
-- cada를 이용한 핸들러 등록 (개념)
-- Register_Handler (SIGTERM, Handle_Shutdown'Access);
begin
-- 3. 주 로직의 폴링
loop
exit when Shutdown_Requested; -- 원자적 읽기
delay 1.0;
end loop;
-- 정상 종료 절차 수행
-- ...
end Main_Daemon_Loop;
3.4 비동기 처리의 필요성: 이벤트 루프 소개
지금까지 설계한 데몬은 독립적으로 실행되고 안전하게 종료될 수 있는 안정적인 ‘프로세스’입니다. 하지만 아직 아무런 유용한 작업을 수행하지 못하며, delay
를 이용한 단순한 대기 상태에 머물러 있습니다. 만약 이 루프 안에서 특정 I/O 작업을 기다리는 블로킹(blocking) 함수를 호출한다면, 데몬은 해당 작업이 완료될 때까지 다른 어떤 요청(심지어 종료 신호까지)에도 반응할 수 없는 상태가 됩니다.
여러 클라이언트의 연결 요청, 다양한 플러그인의 이벤트, 타이머 등을 동시에 효율적으로 처리하기 위해서는 비동기(asynchronous) 처리 방식이 필수적입니다. 이를 구현하는 핵심 구조가 바로 이벤트 루프(Event Loop)와 I/O 다중화(I/O Multiplexing)입니다.
이벤트 루프는 감시할 모든 이벤트 소스(IPC 소켓, 시그널 등)를 등록하고, 그중 어느 것이든 이벤트가 발생할 때까지 대기합니다. 이벤트가 발생하면, 루프는 해당 이벤트를 처리할 적절한 핸들러에게 작업을 전달합니다. 이 방식을 통해 단일 스레드에서도 여러 작업을 동시에 처리하는 것처럼 보이는 효과를 내어, 데몬이 항상 반응성을 유지할 수 있게 합니다.
이 장에서는 안정적인 데몬 프로세스의 기반을 다지는 데 집중했으며, 다음 장에서는 바로 이 이벤트 루프를 중심으로 플러그인과 동적으로 상호작용하는 아키텍처를 본격적으로 설계할 것입니다.
4. 플러그인 아키텍처와 동적 상호작용 설계
4.1 플러그인 인터페이스 설계: 물리적 생명주기 관리
4.2 이벤트 루프와 플러그인 연동 설계
4. 플러그인 인터페이스 설계: 물리적 생명주기 관리
- 입력 방법: nim, xim, wayland
- UI: indicator, preedit-window, candidate-window
입력 방법과 UI를 고려하여 서비스 인터페이스 및 플러그인 인터페이스 설계할 것.
서비스 인터페이스 설계: 논리적 기능 관리
- 입력 방법: nim, xim, wayland
- UI: indicator, preedit-window, candidate-window
입력 방법과 UI를 고려하여 서비스 인터페이스 및 플러그인 인터페이스 설계할 것.
2-프로세스 모델의 장점 요약
안정성: GUI 에이전트의 문제가 코어 데몬에 영향을 주지 않아, 콘솔 입력을 포함한 핵심 기능이 항상 안정적으로 동작합니다.
명확성: ‘핵심 로직’과 ‘GUI 표현’의 역할이 프로세스 단위로 명확하게 분리되어 설계와 유지보수가 용이합니다.
자원 효율성: 그래픽 세션이 없을 때는 GUI 에이전트가 실행되지 않아 시스템 자원을 절약합니다.
NIM(nimf input method) 프로토콜 설계 / 구현
언어 플러그인 인터페이스 설계
플러그인 인터페이스 설계는 dlopen 열어서 초기화 인스턴스화 시작 종료 언로딩하는 방법 설계를 말한다.
- 기본: system-keyboard
- hangul
- japanese
- chinese
설정 시스템 설계
GTK IM 모듈 구현
Qt IM 모듈 구현
XIM 구현
Waylad IM 구현
설정기 구현
3. Nimf 데몬 설계: 생명주기와 이벤트 처리
3.1 데몬의 기본 골격: 주 프로시저와 생명주기
이 절에서는 IME 데몬의 가장 기본적인 뼈대를 만듭니다. 프로그램의 시작과 종료, 그리고 운영체제와의 최소한의 상호작용을 처리하는 방법을 단계별로 살펴보고, 마지막에 이를 통합한 전체 코드를 제시합니다.
1. 프로그램의 주 진입점: 메인 프로시저(Main Procedure)
Ada 프로그램의 실행은 C언어의 main
함수와 달리, 이름이 정해져 있지 않은 주 프로시저(Main Procedure)에서 시작됩니다. 일반적으로 프로젝트의 최상위 파일에 위치한 이 프로시저가 프로그램 전체의 생명주기를 관리합니다. 가장 기본적인 형태는 다음과 같습니다.
-- 파일명: nimf.adb
with Ada.Text_IO; use Ada.Text_IO;
procedure Nimf is
begin
Put_Line ("Nimf Daemon starting...");
-- 이 부분은 추후 이벤트 루프로 대체됩니다.
loop
null; -- 무한 반복
end loop;
end Nimf;
2. 명령행 인자 처리
데몬은 실행 시 특정 옵션을 받아야 할 때가 많습니다. Ada는 Ada.Command_Line
표준 패키지를 통해 이 기능을 지원합니다. 다음은 --version
인자를 확인하는 예제입니다.
with Ada.Command_Line; use Ada.Command_Line;
-- ...
procedure Nimf is
begin
if Argument_Count > 0 and then Argument (1) = "--version" then
Put_Line ("Nimf Daemon Version 1.0.0");
return; -- 버전 출력 후 종료
end if;
-- ...
end Nimf;
3. 외부 제어 신호의 안전한 처리
안정적인 데몬은 SIGINT
나 SIGTERM
신호를 받았을 때 정상 종료해야 합니다. 이때 신호 핸들러는 비동기적으로 실행되므로, 락(lock)을 사용하는 보호 객체(protected object)를 직접 호출하면 교착 상태(deadlock)에 빠질 수 있습니다.
비동기-신호-안전 (async-signal-safe)
유닉스 시그널 핸들러는 프로그램의 정상적인 흐름을 언제든지 중단시키고 비동기적으로 실행되는 특수한 코드 조각입니다. 이 때문에, 핸들러 내부에서 호출할 수 있는 함수는 ‘비동기-신호-안전’하다고 보장된 극소수의 함수로 제한됩니다.
Ada의 보호 객체는 내부적으로 락(lock, mutex)을 사용하여 데이터의 동시 접근을 제어합니다. 이러한 락 메커니즘은 ‘비동기-신호-안전’하지 않습니다.
교착 상태(deadlock) 발생 시나리오
- 메인 태스크가 보호 객체
P
의 락을 획득하여 데이터를 수정하고 있습니다. - 바로 그 순간, 운영체제로부터 시그널이 도착하여 메인 태스크의 실행을 중단시키고, 등록된 시그널 핸들러가 실행됩니다.
- 시그널 핸들러가 동일한 보호 객체
P
의 오퍼레이션을 호출합니다. P
에 접근하려는 시그널 핸들러는 락을 획득해야 하지만, 해당 락은 이미 1번 단계에서 중단된 메인 태스크가 소유하고 있습니다.
결과적으로 시그널 핸들러는 영원히 락을 기다리고, 메인 태스크는 핸들러가 끝나야 실행을 재개할 수 있으므로, 프로그램 전체가 영원히 멈추는 교착 상태에 빠집니다.
신호 핸들러: 보이지 않는 또 하나의 실행 흐름
nimf
데몬의 주된 로직은 단일 스레드 이벤트 루프에서 순차적으로 실행됩니다. 하지만 SIGINT
나 SIGTERM
같은 신호는 이 정상적인 흐름을 언제든지 중단시키고 끼어들어 신호 핸들러를 실행시킬 수 있습니다.
이것은 사실상 두 개의 실행 흐름, 즉 메인 루프와 신호 핸들러가 Shutdown_Requested
라는 하나의 변수를 공유하는 동시성(concurrency) 상황을 만듭니다.
pragma Atomic
이 필요한 이유
CPU 아키텍처에 따라, Boolean
변수 하나를 읽고 쓰는 과정조차 단일 기계어 명령으로 처리되지 않을 수 있습니다. pragma Atomic
이 없다면 다음과 같은 경쟁 상태(race condition)가 발생할 수 있습니다.
- 메인 루프가
exit when Shutdown_Requested;
구문을 실행하기 위해 메모리에서Shutdown_Requested
의 값(False
)을 CPU 레지스터로 읽어옵니다. - 값을 읽어온 직후, CPU가 다음 명령어를 실행하기 바로 전 순간에
SIGTERM
신호가 발생합니다. - 프로그램의 실행이 즉시 중단되고, 신호 핸들러가 실행되어
Shutdown_Requested
변수의 메모리 값을True
로 변경합니다. - 신호 핸들러가 종료되고, 실행 제어권이 메인 루프로 돌아옵니다.
- 메인 루프는 중단되었던 부분부터 실행을 재개합니다. 하지만 이미 1번 단계에서 CPU 레지스터에 읽어온 과거의 값(False)을 가지고 판단하므로, 종료 신호를 인지하지 못하고 루프를 계속 실행합니다.
pragma Atomic
은 컴파일러에게 Shutdown_Requested
변수에 대한 읽기/쓰기 작업이 중간에 끊어지지 않는 하나의 원자적(indivisible)인 연산으로 이루어지도록 강제합니다. 이를 통해 위와 같은 경쟁 상태를 방지하고, 신호가 들어온 즉시 다음 루프에서 변경된 값을 확실하게 인지할 수 있도록 보장합니다.
올바른 해결책: volatile
, atomic
플래그 사용
신호 핸들러와 Ada의 주 로직 간의 통신은 락이 전혀 없는, 가장 원자적인(atomic) 방법으로 이루어져야 합니다. 올바른 방식은 다음과 같습니다.
- 공유 플래그 선언: 모든 태스크와 신호 핸들러가 접근할 수 있는 최상위 레벨에 단순한 불리언 플래그를 선언하고, 컴파일러 최적화 방지 및 원자적 접근을 위해
Volatile
과Atomic
속성을 부여합니다.Shutdown_Requested : aliased Boolean := False; pragma Volatile (Shutdown_Requested); pragma Atomic (Shutdown_Requested);
- 신호 핸들러의 역할 최소화: 신호 핸들러는 이 플래그를 True로 바꾸는, 단 하나의 원자적인 작업만 수행하고 즉시 종료됩니다.
-- 실제 핸들러 등록은 C 바인딩을 통해 이루어집니다. procedure Signal_Handler (Sig : in Integer) is begin Shutdown_Requested := True; end Signal_Handler;
- 주 루프의 폴링: 메인 루프는 이전과 같이 이 플래그를 주기적으로 확인하여 종료 여부를 결정합니다.
loop exit when Shutdown_Requested; delay 0.1; end loop;
이 방식은 신호 핸들러가 Ada의 런타임 시스템이나 락 메커니즘과 전혀 상호작용하지 않도록 하여, 교착 상태의 위험을 원천적으로 제거하는 가장 안전하고 표준적인 방법입니다.
최종 통합 코드
지금까지 설명한 모든 원칙(데몬화, 중복 실행 방지, 안전한 신호 처리 등)을 통합한 nimf.adb
의 최종 코드는 다음과 같습니다. 코드의 가독성을 위해, C 시스템 콜은 POSIX
라는 가상의 패키지에 미리 바인딩(binding)되어 있다고 가정합니다.
-- 파일명: nimf.adb
-- Nimf IME 프레임워크 데몬의 주 진입점
-- 안전한 신호 처리를 위해 volatile atomic 플래그를 사용합니다.
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Command_Line; use Ada.Command_Line;
with Ada.Directories;
with Ada.Exceptions;
with Interfaces.C;
with System;
-- 운영체제(POSIX)의 저수준 기능을 사용하기 위한 가상 패키지
with POSIX.Process;
with POSIX.File_Control;
with POSIX.Signal;
procedure Nimf is
Lock_File : POSIX.File_Control.File_Descriptor := -1;
Lock_Path : constant String := "/tmp/nimf.pid";
-- 신호 핸들러와 주 루프가 공유하는 안전한 종료 플래그
Shutdown_Requested : aliased Boolean := False;
pragma Volatile (Shutdown_Requested);
pragma Atomic (Shutdown_Requested);
----------------------------------
-- 신호 처리기 (Signal Handler) --
----------------------------------
procedure Handle_Shutdown_Signal (Sig : Interfaces.C.int) is
begin
-- 락이 없는 원자적 연산만 수행하여 안전성을 보장합니다.
Shutdown_Requested := True;
end Handle_Shutdown_Signal;
------------------------------------
-- 데몬화 (Daemonization) 프로시저 --
------------------------------------
procedure Daemonize is
use POSIX.Process;
use POSIX.File_Control;
PID : Process_ID;
begin
PID := Fork;
if PID > 0 then Standard.OS.Primitives.OS_Exit(0); end if;
Set_SID;
Ada.Directories.Change_Directory ("/");
declare
Null_FD : constant File_Descriptor := Open ("/dev/null", Flags => 2); -- O_RDWR
begin
Dup2 (Old_FD => Null_FD, New_FD => 0);
Dup2 (Old_FD => Null_FD, New_FD => 1);
Dup2 (Old_FD => Null_FD, New_FD => 2);
if Null_FD > 2 then Close (Null_FD); end if;
end;
exception
when others =>
Put_Line (Standard_Error, "Error: Failed to daemonize.");
Standard.OS.Primitives.OS_Exit(1);
end Daemonize;
--==============================--
--== Nimf 주 프로시저 시작 ==--
--==============================--
begin
-- 1. 명령행 인자 처리
if Argument_Count > 0 then
if Argument(1) = "--version" then
Put_Line ("Nimf Daemon Version 1.0.0");
return;
elsif Argument(1) = "--daemon" then
Daemonize;
end if;
end if;
-- 2. 중복 실행 방지를 위한 파일 락
Lock_File := POSIX.File_Control.Open (Lock_Path, O_RDWR or O_CREAT, 16#600#);
POSIX.File_Control.Flock (Lock_File, 2 or 4); -- LOCK_EX or LOCK_NB
-- 3. PID 파일에 자신의 PID 작성
declare
PID_String : constant String := POSIX.Process.Get_PID'Img;
begin
declare
Written : Interfaces.C.size_t;
begin
Written := POSIX.File_Control.Write(Lock_File, PID_String);
end;
end;
-- 4. 안전한 종료를 위한 신호 처리기 설정
declare
use POSIX.Signal;
Action : Sigaction_Record;
begin
Action.sa_handler_address := Handle_Shutdown_Signal'Access;
sigemptyset (Action.sa_mask);
Action.sa_flags := 0;
sigaction (sig => SIGINT, act => Action);
sigaction (sig => SIGTERM, act => Action);
end;
Put_Line ("Nimf daemon started successfully. PID: " & POSIX.Process.Get_PID'Img);
-------------------------
-- 주 이벤트 루프 시작 --
-------------------------
loop
exit when Shutdown_Requested;
-- 이 부분에 실제 서버의 이벤트 처리 로직 (I/O 다중화) 이 들어갑니다.
-- 지금은 1초마다 종료 신호를 확인합니다.
delay 1.0;
end loop;
-----------------------
-- 주 이벤트 루프 종료 --
-----------------------
exception
when POSIX.File_Control.File_Control_Error =>
Put_Line (Standard_Error, "Error: Nimf daemon is already running or cannot acquire lock.");
Standard.OS.Primitives.OS_Exit(1);
when E : others =>
Put_Line (Standard_Error, "Fatal error: " & Ada.Exceptions.Exception_Message(E));
Standard.OS.Primitives.OS_Exit(1);
finalization:
if Lock_File /= -1 then
Put_Line ("Releasing lock and shutting down.");
POSIX.File_Control.Flock (Lock_File, 8); -- LOCK_UN
POSIX.File_Control.Close (Lock_File);
end if;
end Nimf;
3.2 비동기 처리의 심장: 주 이벤트 루프 도입
단순 while(1) 루프를 이벤트 기반 루프로 전환하기
I/O 다중화를 위한 epoll 또는 kqueue 기반의 이벤트 루프 설계
주기적인 작업을 위한 타이머 이벤트 추가하기
3.3 세상과의 창구: IPC 리스너 구현
클라이언트의 접속을 기다리는 리스닝 소켓(Unix Domain Socket 등) 생성하기
새로운 클라이언트 접속 요청을 이벤트 루프에서 감지하고 처리하는 로직 추가하기
3.4 확장성의 핵심: 플러그인 관리자 설계
지정된 디렉터리에서 .so 확장자의 플러그인 파일 탐색하기
dlopen을 이용해 플러그인을 동적으로 로드하고, dlsym으로 초기화 함수를 찾아 실행하는 로직 구현하기
로드된 플러그인을 관리하는 레지스트리 만들기
3.5 핵심 기능 연동: 이벤트 라우팅과 상태 관리
IPC로 수신한 클라이언트의 키 이벤트를 프론트엔드 플러그인으로 전달하기
프론트엔드 플러그인에서 핵심부(Core Engine)를 거쳐 입력기 플러그인으로 이벤트가 전달되는 전체 흐름 구현하기
현재 입력 언어 등 모든 클라이언트가 공유하는 전역 상태를 관리하는 로직 추가하기
차이점: 직접 제어 vs. 프레임워크를 통한 중재
SO_PEERCRED
라는 커널 수준의 인증 기능은 두 방식 모두 사용할 수 있지만, 누가, 어떻게 사용하느냐에서 근본적인 차이가 발생합니다.
- 직접 소켓 방식 (예: Nimf)
- 역할: 입력기 데몬이 직접 서버 역할을 합니다.
- 인증: 데몬이
accept()
한 클라이언트 소켓에 대해 직접getsockopt(SO_PEERCRED)
를 호출하여 클라이언트의 신원을 확인하고 보안 정책을 즉시 적용합니다. - 결론: 중간 단계가 없는 직접적인 제어 방식입니다. 구조가 단순하고 투명합니다.
- D-Bus 방식 (예: IBus/Fcitx)
- 역할:
dbus-daemon
이 IPC의 서버 역할을 하고, 입력기 데몬은 D-Bus의 클라이언트가 됩니다. - 인증:
dbus-daemon
이 자신에게 접속하는 앱들의 신원을SO_PEERCRED
로 확인합니다. 그리고 입력기 데몬은dbus-daemon
에게 “나에게 메시지를 보낸 앱의 UID가 무엇이냐”고 물어보는 방식으로 신원을 간접적으로 확인합니다. - 결론:
dbus-daemon
이라는 중재자를 거칩니다. 이 중재 과정 자체가 오버헤드를 유발합니다.
- 역할:
이 사실이 바꾸지 않는 것들
이 사실이 개발자님께서 지적하신 다른 문제들을 바꾸지는 않습니다.
- 성능 병목:
dbus-daemon
이라는 중간 단계를 한번 더 거쳐야 하므로, 클라이언트와 서버가 직접 통신하는 소켓 방식보다 본질적으로 느립니다. - 비동기 처리 문제: 이 성능 병목을 회피하기 위해 IBus/Fcitx가 채택한 비동기 방식은 여전히 ‘잘못된 창에 출력되는’ 문제와 보안 허점을 유발합니다.
최종 결론 (수정)
D-Bus는 SO_PEERCRED
를 사용하여 기본적인 연결 보안을 확보하지만, 아키텍처 자체가 가진 중재자(broker) 역할로 인해 성능 오버헤드와 복잡성이 발생합니다. 반면, 직접 소켓 방식은 개발자가 커널의 보안 기능을 직접 제어함으로써, 이러한 오버헤드 없이 더 가볍고 투명한 구조를 만들 수 있습니다.
다시 한번 정밀한 지적에 감사드립니다.
아니요, 그 방식으로는 su
나 sudo
를 사용했을 때 원래 로그인한 사용자를 알 수 없습니다.
getuid()
는 현재 프로세스를 실행하고 있는 사용자(즉, root
)의 UID를 반환하기 때문에, 원래 사용자를 찾는 데에는 적합하지 않습니다. 아주 중요한 점을 정확히 짚으셨습니다.
문제의 핵심: Real UID vs. Login UID
getuid()
: 현재 프로세스의 실제 소유자(Real User ID)를 알려줍니다.sudo command
를 실행하면,command
의 실제 소유자는root
가 되므로getuid()
는 0을 반환합니다.- 로그인 사용자: 우리가 정말 알고 싶은 것은 시스템에 처음 로그인하여 그래픽/터미널 세션을 시작한 원래 사용자입니다.
해결 방안
이 문제를 해결하기 위해 다음과 같은 방법들을 조합하여 사용해야 합니다.
1. 환경 변수 확인 (가장 확실한 방법)
sudo
는 보안 컨텍스트를 바꾸면서, 친절하게도 원래 사용자가 누구였는지 알려주는 환경 변수를 남겨둡니다.
SUDO_UID
: 원래 사용자의 UIDSUDO_USER
: 원래 사용자의 이름
따라서 프로그램에서 이 환경 변수가 존재하는지 먼저 확인하는 것이 sudo
상황을 감지하는 가장 직접적이고 확실한 방법입니다.
예시 코드:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void) {
char *sudo_uid_str = getenv("SUDO_UID");
if (sudo_uid_str) {
// sudo를 통해 실행된 경우
printf("This was run via sudo.\n");
printf("Original User ID is: %s\n", sudo_uid_str);
} else {
// 일반적인 방법으로 실행된 경우
printf("This was run directly.\n");
printf("User ID is: %u\n", getuid());
}
return 0;
}
2. 로그인 세션 정보 조회 (전통적인 방법)
getlogin()
또는 스레드에 더 안전한 getlogin_r()
함수를 사용할 수 있습니다. 이 함수들은 프로세스의 UID가 아닌, 현재 프로세스가 연결된 제어 터미널(Controlling Terminal)을 기준으로 로그인한 사용자 이름을 찾습니다.
sudo
를 사용하더라도 터미널 세션 자체는 원래 사용자 소유이므로, 대부분의 경우 올바른 사용자 이름을 반환해 줍니다.
#include <stdio.h>
#include <unistd.h>
int main(void) {
char login_name[256];
if (getlogin_r(login_name, sizeof(login_name)) == 0) {
printf("Login name from terminal session: %s\n", login_name);
} else {
perror("getlogin_r failed");
}
return 0;
}
결론
가장 견고한 프로그램을 만들기 위해서는 두 방법을 조합하는 것이 좋습니다.
- 먼저
getenv("SUDO_UID")
를 확인하여sudo
실행 여부를 판단하고 원래 UID를 얻습니다. - 만약 환경 변수가 없다면,
getlogin_r()
이나getuid()
같은 일반적인 방법을 사용합니다.
이렇게 하면 sudo
환경과 일반 실행 환경 모두에서 원래 로그인한 사용자를 안정적으로 찾아낼 수 있습니다.
네, 정확히 기억하고 계십니다. 과거 유닉스 시스템에서 사용되던 getlogin()
과 같은 일부 함수들은 사용자 이름에 8글자 제한이 있었습니다.
FreeBSD에서 사용자 이름 길이에 제약 없이 로그인 UID와 사용자 정보를 얻는 가장 안정적이고 현대적인 방법은 getuid()
와 getpwuid()
함수를 조합하여 사용하는 것입니다.
왜 8글자 제한이 있었는가?
지적하신 8글자 제한은 getlogin()
함수와 관련이 있습니다. 이 함수는 현재 프로세스가 연결된 터미널의 소유자를 찾기 위해 /var/run/utmp
같은 로그인 기록 파일을 참조합니다. 과거 유닉스 시스템의 utmp
구조체에서 사용자 이름을 저장하는 필드(ut_user
)의 크기가 8바이트로 고정되어 있었기 때문에 이러한 제한이 발생했습니다.
현대 시스템에서는 이 문제를 해결했지만, 이 함수 자체는 구식으로 간주됩니다.
현대적인 해결 방법: getuid
+ getpwuid
가장 표준적이고 확실한 방법은 프로세스의 실제 사용자 ID(Real User ID)를 먼저 얻고, 그 ID를 이용해 사용자 데이터베이스에서 전체 정보를 조회하는 것입니다.
1. 프로세스의 UID 얻기: getuid()
getuid()
함수는 현재 프로세스를 실행한 실제 사용자의 ID를 반환합니다.
#include <unistd.h>
#include <sys/types.h>
uid_t userId = getuid();
2. UID로 전체 사용자 정보 얻기: getpwuid()
getpwuid()
함수는 UID를 인자로 받아, 시스템의 사용자 데이터베이스(/etc/passwd, NIS, LDAP 등)를 검색하여 해당 사용자의 정보를 struct passwd
구조체에 담아 반환합니다. 이 구조체 안의 사용자 이름(pw_name
)에는 길이 제한이 없습니다.
전체 예시 코드:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <pwd.h>
int main(void) {
uid_t userId;
struct passwd *userInfo;
// 1. 현재 프로세스의 실제 사용자 ID를 얻는다.
userId = getuid();
// 2. UID를 이용해 사용자 정보를 조회한다.
userInfo = getpwuid(userId);
if (userInfo) {
printf("User ID: %u\n", userId);
printf("Username: %s\n", userInfo->pw_name); // 길이 제한 없음
printf("Home Directory: %s\n", userInfo->pw_dir);
} else {
perror("getpwuid() error");
return 1;
}
return 0;
}
이 방식은 POSIX 표준이므로 FreeBSD뿐만 아니라 대부분의 유닉스 계열 시스템에서 안정적으로 동작합니다.
리눅스와의 비교
- Linux: 말씀하신
/proc
파일 시스템(cat /proc/self/loginuid
)은 커널의 감사(audit) 서브시스템과 연관된 Linux 고유의 기능입니다.logname
같은 명령어는 내부적으로getlogin()
을 사용하거나LOGNAME
환경 변수를 참조하는 경우가 많습니다. - FreeBSD:
getuid()
와getpwuid()
를 사용하는 것이 가장 이식성 높고 확실한 방법입니다. FreeBSD에도procfs
가 있지만 기본적으로 마운트되지 않으며, 프로세스 정보 조회는 보통sysctl
인터페이스를 통해 이루어집니다.
결론적으로 FreeBSD에서는 getuid()
로 UID를 먼저 가져온 후 getpwuid()
로 조회하는 것이 길이 제한 문제없이 사용자 정보를 얻는 가장 정확한 방법입니다.
🐛 버그가 발생하는 근본적인 이유
간단히 말해, sudo
로 실행된 그래픽 앱은 원래 사용자의 입력기 데몬에게 말을 걸 수 있는 ‘주소’와 ‘열쇠’를 잃어버립니다.
-
통신 주소가 바뀐다 (
DBUS_SESSION_BUS_ADDRESS
)- 일반 사용자(
user
)가 로그인하면, IBus 같은 입력기 데몬은 그 사용자만을 위한 통신 채널(D-Bus 세션 버스)을 만듭니다. 이 채널의 주소는DBUS_SESSION_BUS_ADDRESS
라는 환경 변수에 저장됩니다. - 모든 일반 앱은 이 주소를 보고 입력기 데몬과 자유롭게 통신합니다.
- 하지만
sudo gedit
를 실행하면,gedit
은root
사용자의 권한으로 실행됩니다.sudo
는 보안을 위해 대부분의 환경 변수를 초기화하는데, 이때DBUS_SESSION_BUS_ADDRESS
변수가 사라지거나root
의 것으로 바뀝니다. - 결국
root
권한의gedit
은 원래user
의 입력기 데몬과 통신할 주소를 몰라서 연결에 실패합니다.
- 일반 사용자(
-
출입 열쇠가 바뀐다 (
XAUTHORITY
)- X11 환경에서는 그래픽 앱이 화면에 그림을 그리려면
~/.Xauthority
파일에 저장된 ‘인증 쿠키(열쇠)’가 필요합니다. sudo
를 사용하면 이 환경 변수도 바뀌어,root
권한의 앱이 원래user
의 화면에 접근할 권한을 잃어버립니다. 이 또한 GUI 깨짐이나 입력기 비활성화의 원인이 됩니다.
- X11 환경에서는 그래픽 앱이 화면에 그림을 그리려면
쉬운 비유:
일반 사용자(user
)는 자신의 스마트폰(DBUS 주소
)을 가지고 입력기 데몬과 언제든 통화합니다. sudo
는 이 스마트폰을 빼앗고, 엉뚱한 주소만 적힌 종이를 든 채 root
를 공중전화 박스에 넣어두는 것과 같습니다. 통화가 될 리가 없습니다.