Mutex로 해결한 영수증 프린터 모듈 충돌 문제
Race Condition과 Deadlock을 방지한 비동기 락 구현
1. 문제 상황: 프로그램의 예기치 못한 중지 문제
키오스크 운영 테스트중, 시스템이 예고 없이 멈추는 버그가 발생했습니다. 로그를 분석한 결과, 문제는 단일 하드웨어 자원인 '영수증 프린터'에서 발생하고 있었습니다.
저가형 프린터 모델의 한계로, '용지 없음'이나 '연결 끊김' 등의 상태를 실시간으로 알 수 없어, 프로그램 내에서 주기적으로 상태 확인(Polling) 명령을 보내 상태를 체크해야 했습니다.
- 시나리오: 시스템이 '프린터 상태 확인'을 위해 폴링(Process A)하는 찰나에, 사용자가 '영수증 출력'을 요청(Process B)
- 결과: 두 개의 명령(상태 확인, 인쇄)이 동시에 프린터로 전송되면서, Process A의 응답을 처리하던 중 Process B의 명령으로 인해 연결이 꼬이고, 시스템에 오류가 발생하는 문제에 빠졌습니다.
이는 폴링과 실제 사용이 충돌한, 전형적인 공유 자원 동시 접근(Race Condition, 경쟁 상태) 문제였습니다.
2. 해결 전략: Mutex
이 문제를 해결하기 위해, 컴퓨터 과학의 기본 원칙인 상호 배제(Mutual Exclusion, Mutex)를 적용하였습니다.
간단히 비유하자면, 프린터를 '화장실'로, 프린터에 접근하는 모든 작업(인쇄, 상태 확인)을 '사람'으로 생각했습니다. 화장실은 한 번에 한 명만 사용할 수 있어야 합니다.
- Mutex (열쇠): 단 하나의 '화장실 열쇠'를 만듭니다.
- Queue (대기 줄): 열쇠가 사용 중일 때(다른 작업이 프린터를 쓰고 있을 때), 다음 작업은 줄을 서서 기다려야 합니다.
이 '열쇠 관리자' 역할을 할
printerLock.ts 모듈을 싱글톤 패턴으로 설계했습니다. 인쇄 요청, 상태 확인 폴링 등 프린터 사용이 필요한 모든 로직은, 반드시 이 락을 획득한 후에만 프린터에 접근하도록 아키텍처를 변경했습니다.3. 핵심 구현: Promise와 Queue를 이용한 비동기 Mutex
JavaScript 환경은 싱글 스레드 기반이지만, I/O 작업(프린터 통신 등)은 비동기(async/await)로 처리됩니다. 따라서 Promise와 대기열(queue)을 결합한 비동기 락을 구현했습니다.
printerLock.ts/** * 프린터 잠금 기능을 제공하는 클래스 (Mutex 구현) * isLocked: 열쇠가 사용 중인지 (true) 아닌지 (false) * queue: 대기 줄 (Promise의 resolve 함수들 저장) */ class PrinterLock { private isLocked: boolean; private queue: Array<() => void>; // 대기 중인 작업 실행시킬 함수들 constructor() { this.isLocked = false; this.queue = []; } /** * 락 획득 (열쇠 요청) */ public async acquire(): Promise<void> { // 1. 열쇠가 사용 가능할 때 (isLocked = false) if (!this.isLocked) { this.isLocked = true; // 문을 잠그고 return Promise.resolve(); // 즉시 작업을 실행 } // 2. 열쇠가 사용 중일 때 (isLocked = true) // -> "나중에 작업 끝내면 불러주세요" (resolve) return new Promise((resolve) => { this.queue.push(resolve); // 대기 줄에 resolve 함수 추가 }); } /** * 락 해제 (열쇠 반납) */ public release(): void { // 1. 대기 queue에 다음 사람이 있을 때 if (this.queue.length > 0) { // 2. 락을 풀지 않고, 바로 다음 사람에게 열쇠를 넘김 (대기열의 첫 번째 작업 실행) const next = this.queue.shift(); // 대기열에서 첫 번째 resolve 함수를 꺼냄 if (next) next(); // 이 next()가 호출되면, acquire()에서 대기하던 Promise가 resolve됨 } else { // 3. 대기 줄이 비어있으면, 문을 열어둠 this.isLocked = false; } } } /** * 싱글톤(Singleton)으로 생성 */ export const printerLock = new PrinterLock();
4. try...finally로 데드락(Deadlock) 방지
여기서 추가로, 데드락 상황을 고려한 개선 작업을 진행했습니다.
만약 락을 획득한 작업(Process A)이 인쇄 중 오류가 발생하여 멈추면?
release()가 호출되지 않아 락은 영원히 반납되지 않고, 대기 줄에 있던 모든 작업(Process B, C, D...)은 영원히 기다리게 됩니다. 저는 이 데드락 문제를 피하기 위해, 프린터를 사용하는 모든 로직을
try...finally 구문으로 감쌌습니다.적용 예시 (IPC 핸들러)
import { printerLock } from './printerLock'; import { actualPrintFunction } from './printService'; async function handlePrintRequest(data: any) { try { // 1. [Acquire] 열쇠를 획득할 때까지 기다림 (줄 서기) await printerLock.acquire(); // --- 락 획득 성공 (이 영역은 한 번에 하나의 작업만 실행됨) --- // 2. [Critical Section] 실제 프린터 작업 수행 await actualPrintFunction(data); // --- 작업 완료 --- } catch (error) { // 3. [Error Handling] 작업 중 오류가 발생해도... console.error("인쇄 중 심각한 오류 발생:", error); } finally { // 4. [Release] (성공하든, 실패하든) 반드시 열쇠를 반납 // -> 이로써 데드락을 방지함 printerLock.release(); } }
결과
- 시스템 안정성 확보: Mutex 도입 후, '상태 확인 폴링'과 '인쇄 요청'이 충돌하는 현상 없이 모든 인쇄 관련 작업이 순차적으로 안정적으로 처리되었습니다.
- 견고한 설계의 중요성: 단순히 동작하는 코드를 넘어,
try...finally를 통해 데드락을 방지하는 견고한 시스템을 고려하고 설계하는 법을 적용할 수 있었습니다.
- 학교 객체지향프로그래밍 수업 시간에 배웠던, 사례를 직접 겪으며, 실제 세계에서 어떻게 이러한 문제가 발생할 수 있는지 직접 경험할 수 있었고, 이 경험을 통해 JavaScript/TypeScript 환경에서도 동시성 제어와 같은 CS 기본기가 실제 시스템의 안정성에 얼마나 중요한지 실질적으로 경험할 수 있었습니다.