타사 POS의 DB Lock으로 인한 주문 누락과 CS 비용 증가 문제를 해결하기 위해, 프론트엔드 리소스를 기다리는 대신 백엔드단에서 직접 Windows API를 제어하는 방식을 제안했습니다. 기존 라이브러리(zenity)의 세션 처리 한계를 분석하고, WTSSendMessageW를 직접 구현하여 주문 누락 CS를 0건으로 만들었습니다.**
레거시 시스템이나 타사 솔루션과 연동하는 미들웨어를 개발하다 보면, 우리 코드의 품질과는 무관하게 외부 요인으로 인해 치명적인 문제가 발생하곤 합니다.
제가 개발 중인 POS Connector는 테이블오더의 주문을 타사 POS DB로 밀어 넣는 역할을 합니다. 문제는 점주님이 POS의 '주문 상세 화면'을 켜놓고 있을 때 발생했습니다. 타사 POS의 구조적 한계로 인해 해당 화면에서는 DB Lock이 걸려버렸고, 결과적으로 주문 INSERT가 실패하는 상황이 벌어졌습니다.
이로 인해 결제 누락이 주 1~2회 지속적으로 발생했고, 이는 곧바로 금전적 손실과 CS(고객 항의) 폭주로 이어졌습니다. 타사 POS의 코드를 고칠 수도 없는 상황에서 유일한 해법은 "사장님, 지금 화면 좀 닫아주세요!"라고 알리는 것뿐이었습니다.
하지만 프론트엔드 팀은 다른 우선순위 업무로 리소스가 부족해 UI 작업이 계속 미루어지고 있었습니다. 저는 이 상황을 방관하는 대신, 새로운 접근 방식을 제안했습니다.
"굳이 프론트엔드를 거칠 필요가 있을까요? 이미 설치된 Go 기반의 POS Connector가 OS 레벨에서 직접 경고창을 띄우는 건 어떨까요? 제가 윈도우 API를 이용해 바로 처리하겠습니다."
이 제안을 통해 프론트엔드 의존성을 제거하고, 백엔드 개발자가 단독으로 UX와 비즈니스 문제를 해결하는 프로젝트를 시작하게 되었습니다.
처음에는 빠르고 안정적인 구현을 위해 Go 생태계에서 널리 쓰이는 크로스 플랫폼 다이얼로그 라이브러리인 ncruces/zenity 도입을 검토했습니다. 하지만 테스트 결과, 서비스 환경에서는 알림 창이 전혀 뜨지 않았습니다.
원인을 파악하기 위해 라이브러리 내부 코드(notify_windows.go)를 뜯어본 결과, 결정적인 이유를 발견했습니다.
// zenity/notify_windows.go 내부 로직 예시
// 라이브러리는 WTS_CURRENT_SESSION을 사용하여 메시지를 보냄
ret, _, _ := procWTSSendMessageW.Call(
WTS_CURRENT_SERVER_HANDLE,
WTS_CURRENT_SESSION, // <-- 여기가 문제
...
)
이 라이브러리는 메시지를 보낼 세션 ID로 WTS_CURRENT_SESSION을 사용하고 있었습니다.
일반적인 데스크톱 앱이라면 문제없지만, Windows Service는 'Session 0'에서 실행됩니다. 즉, CURRENT_SESSION으로 호출하면 격리된 Session 0 내부에 메시지 박스를 띄우게 되고, 실제 모니터를 보고 있는 사용자(Session 1 이상)에게는 아무것도 보이지 않는 것이었습니다.
이 분석을 통해 "활성 사용자 세션 ID(Active Console Session ID)를 동적으로 찾아 주입하는 로직"이 반드시 필요하다는 결론을 내렸습니다.
아래 다이어그램은 Session 0에 갇힌 서비스가 어떻게 격리된 환경을 뚫고 사용자에게 도달하는지를 보여줍니다.

[그림 1] Session 0 격리 극복 및 메시지 전송 프로세스
결국 외부 라이브러리 의존성을 제거하고, Windows Terminal Services API인 WTSSendMessageW를 직접 호출하기로 결정했습니다. 핵심은 kernel32.dll의 WTSGetActiveConsoleSessionId를 통해 현재 사용자가 보고 있는 화면의 세션 ID를 가져와서 타겟팅하는 것입니다.
[실제 Go 구현 코드: 유틸리티]
실무 환경에서는 타임아웃 처리와 로깅(zap), 에러 래핑(pkg/errors)을 포함하여 안정성을 강화했습니다.
//go:build windows
// +build windows
package wtsmsg
import (
"[github.com/pkg/errors](https://github.com/pkg/errors)"
"go.uber.org/zap"
"golang.org/x/sys/windows"
"syscall"
"unsafe"
)
var (
modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
procWTSSendMessage = modWtsapi32.NewProc("WTSSendMessageW")
procWTSGetActiveConsoleSessionId = modKernel32.NewProc("WTSGetActiveConsoleSessionId")
)
// DTO는 메시지 전송에 필요한 정보를 담는 구조체라고 가정합니다.
func SendMessage(wtsMsg DTO) error {
// 1. 현재 활성 콘솔 세션 ID 조회
// 단순 Current Session이 아니라, 실제 활성화된 사용자 세션을 찾아야 함
sessionId, _, _ := procWTSGetActiveConsoleSessionId.Call()
titleUTF16 := syscall.StringToUTF16(wtsMsg.Title)
messageUTF16 := syscall.StringToUTF16(wtsMsg.Message)
timeout := wtsMsg.TimeoutSeconds
// 응답 대기 여부 설정
bwait := 0
if wtsMsg.ResponseWait {
bwait = 1
}
var response uint32
// 2. WTSSendMessageW 호출 (SessionId 인자에 조회한 ID 주입)
ret, _, err := procWTSSendMessage.Call(
0, // WTS_CURRENT_SERVER_HANDLE
uintptr(sessionId),
uintptr(unsafe.Pointer(&titleUTF16[0])),
uintptr(len(titleUTF16)*2),
uintptr(unsafe.Pointer(&messageUTF16[0])),
uintptr(len(messageUTF16)*2),
uintptr(0), // MB_OK
uintptr(timeout), // Timeout in seconds
uintptr(unsafe.Pointer(&response)),
uintptr(bwait), // Wait for response
)
// Call은 성공 시에도 err가 nil이 아닐 수 있으므로(Errno 0),
// 실제 반환값(ret)과 함께 꼼꼼한 확인이 필요합니다.
if err != nil && err.Error() != "The operation completed successfully." {
return errors.Wrap(err, "failed to call procWTSSendMessage")
}
if ret == 0 {
return errors.New("WTSSendMessage failed")
} else {
zap.L().Info("User responded", zap.Uint32("response", response))
}
return nil
}
위에서 만든 SendMessage 유틸리티를 실제 비즈니스 로직에 적용할 때는 '사용자 경험(UX)'을 깊이 고려했습니다. 단순히 에러 창만 띄우는 것이 아니라, 사용자가 문제를 인식하고 행동을 취한 뒤, 시스템이 정상화될 때까지 기다리게 만드는 2단계 메시지 전략을 사용했습니다.
func asyncTableLockNotify(err error) {
// ... 생략 ...
if ginErr.Code().StatusCode() == http.StatusLocked {
// 메인 로직을 블로킹하지 않기 위해 고루틴으로 실행
go func() {
defer func() {
if r := recover(); r != nil {
zap.L().Error("asyncTableLockNotify panic", zap.Any("recover", r))
return
}
}()
// 2. [Step 1] 행동 유도 메시지 (무한 대기)
// 사용자가 '확인'을 누른다는 것은 포스 창을 닫았다는 의미로 간주합니다.
if err := wtsmsg.SendMessage(wtsmsg.DTO{
Title: "테이블오더 주문 누락 알림",
Message: "테이블오더 주문이 지연되고 있습니다\n포스의 주문창을 닫은 후, 확인 버튼을 눌러주세요",
TimeoutSeconds: 0, // 유저가 반응할 때까지 무한 대기
ResponseWait: true, // 버튼 클릭을 기다림
}); err != nil {
zap.L().Error("wtsmsg.SendMessage1 error", zap.Error(err))
}
// 3. [Step 2] 처리 대기 메시지 (30초 타임아웃)
// 사용자가 다시 포스를 조작하여 Lock을 걸지 않도록,
// 데이터 연동 시간(약 30초) 동안 화면을 점유하여 대기시킵니다.
if err := wtsmsg.SendMessage(wtsmsg.DTO{
Title: "테이블오더 주문 누락 알림",
Message: "지연된 주문을 연동하고 있습니다\n포스에서 주문을 잠시 멈춰주세요\n30초 후 주문이 연동되고, 자동으로 창이 종료됩니다.",
TimeoutSeconds: 30, // 30초 후 자동 종료
ResponseWait: true,
}); err != nil {
zap.L().Error("wtsmsg.SendMessage2 error", zap.Error(err))
}
}()
}
}
이 로직은 백엔드 개발자로서 단순히 '기능 구현'을 넘어 '현장의 운영 상황'까지 고려한 결과입니다.
업데이트 배포 후, 결과는 극적이었습니다.
주문 실패 시 즉시 뜨는 경고창을 보고 점주님들은 스스로 화면을 닫았고, DB Lock은 해제되었습니다. 그 결과 주문 누락 관련 CS는 완전히 사라졌고(Zero), 불필요한 운영 리소스 낭비를 막을 수 있었습니다.
이번 경험은 단순히 윈도우 API 하나를 알게 된 것 이상이었습니다.
zenity)가 왜 내 환경에서 동작하지 않는지 소스 레벨에서 분석하고, 커스텀 구현을 결정하는 판단력을 길렀습니다.