GitHub - jhste102lab/ssh-bridge-kr: Kotlin Multiplatform mobile SSH app snapshot with Android-first SSH, PTY, SFTP, tmux, and Gi
Kotlin Multiplatform mobile SSH app snapshot with Android-first SSH, PTY, SFTP, tmux, and Git diff experiments. - jhste102lab/ssh-bridge-kr
github.com
목차
만든 이유
폰으로 서버를 보는데 한글이 깨진다
밖에 있을 때 폰으로 원격 서버를 봐야 하는 일이 종종 있다. 로그를 확인하거나, 떠 있는 tmux 세션에 잠깐 붙거나, 급하면 명령을 한 줄 치거나.
문제는 그게 생각보다 불편하다는 거였다. 데스크탑에서 SSH는 아무 고민이 없는데, 모바일로 내려오면 두 가지가 계속 걸렸다.
하나는 한국어 입력이고, 하나는 매번 tmux에 붙는 번거로움이었다.
특히 한국어 입력. 서버 작업이라고 영어만 치는 게 아니다. 커밋 메시지, 메모, 검색어에 한글이 들어가는데, 모바일 SSH 앱에서 한글을 치면 조합이 깨지거나 받침이 날아가는 경우가 잦았다.
기존 앱들의 아쉬운 점
쓸 만한 앱을 찾아 몇 개를 직접 써봤다. 결론부터 말하면, 하나씩 다 아쉬웠다.
| 앱 | 한국어 입력 | tmux 세션 선택 | 비고 |
|---|---|---|---|
| NeoServer (iOS) | 받침이 깨지고, 화면 확대도 안 됨 | — | 한글이 사실상 안 됨 |
| Termius | 한글은 됨 | 세션 선택 불가, 스크롤도 아쉬움 | 입력은 괜찮은데 운영 흐름이 막힘 |
| Mosh 계열 앱 | 좋음 | 유료 기능 | 기본은 무료, 정작 원하는 건 결제 뒤 |
마지막 줄이 제일 컸다. 터미널을 열 때마다 손으로 tmux attach를 치고 세션을 고르는 게 매번 여간 귀찮은 게 아니었는데, 그걸 깔끔하게 해주는 앱은 그 기능을 유료 벽 뒤에 두고 있었다.
그래서 내가 원했던 건 단순했다. 한국어가 매끄럽게 들어가고, 접속하면 tmux 세션을 바로 골라 들어갈 수 있는 모바일 SSH 앱. 이 두 개가 출발점이었다.
이 글의 결론을 미리 말하면, 둘 중 하나는 됐고 하나는 안 됐다. 그리고 안 된 쪽이 하필 더 중요했던 쪽이다.
무엇이 나왔나
이틀, 75커밋, Android MVP
sshbridge는 Kotlin Multiplatform + Compose Multiplatform 기반 모바일 SSH 앱이다. iOS까지 보고 구조를 잡았지만, 우선 Android부터 MVP로 굴렸다.
저장소 기록 기준으로, 이틀 동안 75개의 커밋이 쌓였고 다음이 들어왔다.
- 네이티브 SSH 브리지 —
androidSsh모듈에서libssh2 1.11.1+mbedTLS 3.6.6을 정적 링크해 JNI로 올린다. 순수 Kotlin SSH가 아니라 C 라이브러리를 CMake로 빌드해 APK에.so로 넣는 경로다. - host key 검증 — 접속 전에 SHA256 fingerprint를 먼저 받아오고(TOFU), 사용자가 신뢰하기 전에는 transport를 열지 않는다.
- credential 저장 — AndroidKeyStore의 AES-GCM 키로 봉인해서 password / private key / passphrase를 저장한다. 폼에 남은 평문은 저장 후 지운다.
- interactive PTY — 단순
exec가 아니라 PTY shell을 열고 read/write/resize/close까지 가는 실제 터미널 세션. - 운영 흐름 — 저장된 프로필, 재접속, startup command, 그리고
tmux세션 선택 진입. - side-channel — SFTP 업로드/다운로드, Git diff 뷰어, 로컬 포트포워딩 기반 browser preview.
처음 켜서 접속해보면, 솔직히 "이게 이틀 만에 나왔다고?" 싶을 만큼은 굴러간다. 문제는 그 다음인데, 그건 뒤에서 다룬다.
한 줄 대신 가드레일
이전에 게임 하나를 "한 줄 컨셉만 던지고 손 떼고" AI에게 시켜본 적이 있다.(제작기) 굴러가긴 했지만, 사람이 손맛을 말로 못 옮긴 부분에서 정확히 무너졌다.
이번엔 반대로 해보고 싶었다. 한 줄을 던지는 대신, AI가 코드를 어떻게 짜야 하는지를 먼저 규칙으로 못 박아두고 그 안에서 돌리는 것. SSH 앱은 그 실험을 검증하기에 좋은 소재였다. 보안 경계가 분명하고("host key 검증을 우회하면 안 된다", "secret을 로그에 남기면 안 된다"), 동작이 명확하고, 네이티브 빌드까지 끼어 있어서 대충 넘어가기 어렵다.
사람이 한 일과 AI가 한 일을 갈라두면 이렇다.
[사람] 가드레일 설계
│ AGENTS.md 운영 계약
│ ARCH-001~005 자동 아키텍처 게이트
│ ADR (SSH 엔진 / 터미널 / sync / billing 결정)
│ 3중 검증 파이프라인
▼
[AI] 그 안에서 구현
│ Kotlin/네이티브 코드, 유닛 테스트,
│ Android emulator smoke 전부
▼
[결과] 이틀 75커밋, 접속 가능한 Android MVP
나는 코드를 거의 쓰지 않았다. 대신 규칙을 썼다. 핵심은 자동으로 강제되는 아키텍처 게이트였다.
ARCH-001공용/도메인 계층은 플랫폼 구현 세부에 의존하지 않는다ARCH-002타입 탈출(!!, unchecked cast)과 무사유 suppression 금지ARCH-003소스 파일 300줄 초과 금지ARCH-004경계를 흐리는 wildcard/barrel import 금지ARCH-005빈 catch, 실패 삼키기, 의미 없는 fallback 금지
이게 node 스크립트로 돌아가서, 위반하면 npm run gate:full이 빨간불을 낸다. 그 위에 Gradle 빌드/테스트, 그리고 실제 emulator smoke가 얹힌다. 게이트 → Gradle → smoke, 3단이다.
규칙을 어기고 싶으면 예외를 allowlist.json에 사유·소유자·만료일과 함께 적어야 통과한다. 이 파일은 끝까지 비어 있었다([]). AI가 단 한 번도 탈출구를 쓰지 않았다는 뜻이다.
가드레일이 실제로 한 일
규칙을 적어두는 것과 그게 실제로 코드를 바꾸는 건 다른 얘기다. 저장소를 열어보니, 가드레일이 손댄 흔적이 곳곳에 남아 있었다.
300줄 룰이 코드를 쪼개 만들었다
가장 눈에 띈 건 ARCH-003, 300줄 제한이었다. 큰 파일들의 줄 수를 세어봤다.
MainActivity.kt— 299줄AndroidLibssh2InteractiveShellClient.kt— 297줄AndroidMvpProfileStore.kt— 291줄
한 줄도 우연 같지 않다. 전부 300 바로 아래에 붙어 있다. 한도가 가까워지자 AI가 더 키우는 대신 멈추고 쪼갠 것이다.
실제로 그런 커밋이 있다. refactor: split startup form fields. 접속 폼이 커지자 TerminalConnectForm.kt에서 39줄을 들어내 TerminalStartupFields.kt라는 53줄짜리 파일로 분리했다. 시키지 않아도, 파일이 비대해지면 게이트가 막으니 알아서 나눈 셈이다.
별것 아닌 것 같지만, "한 파일에 다 때려넣기"는 AI가 가장 흔히 빠지는 함정이다. 그걸 규칙 한 줄(300줄)이 기계적으로 막았다.
보안은 우회 대신 정직을 골랐다
보안 경계에서 가드레일의 진짜 가치가 나왔다. "막히면 우회한다"가 아니라 "막히면 정직하게 남긴다"로 작동했다.
- Ed25519 host key: Ed25519 전용 handshake 검증이 MVP 시점에 덜 됐는데, 검증을 약화시켜 통과시키는 대신
docs: record Android Ed25519 auth risk커밋으로 리스크를 문서에 적어두고 미구현으로 남겼다. 보안 우회 금지 규칙이 "안 되면 솔직히 적는다"로 번역된 것이다. - 같은 세션 재검증: exec든 PTY든, handshake 직후 그 세션의 host key SHA256을 기대값과 다시 비교한 뒤에야 인증을 시도한다. TOFU로 한 번 신뢰했다고 끝이 아니다.
- 시크릿 redaction: crash/log payload는
SensitiveRedactor를 통과해야 한다. private key, passphrase, fingerprint는 로그에 남지 않는다. - fail-closed: host key mismatch면 transport connect를 아예 호출하지 않는다.
핵심은, 이 중 어느 것도 "테스트를 통과시키려고" 적당히 넘어가지 않았다는 점이다. 안 되는 건 안 된다고 적었다.
경계에서 입력을 안 믿는다
ARCH-005(실패 삼키기 금지)와 "런타임 입력은 타입만 믿지 말고 경계에서 검증한다"는 규칙은 SFTP 쪽에 흔적을 남겼다.
fix: enforce SFTP upload limit before write 커밋이 대표적이다. 파일을 다 올려보고 나서 크기를 확인하는 게 아니라, 쓰기 전에 업로드 한도를 강제하도록 SftpTransferPolicy를 고치고 테스트를 함께 붙였다. remote path는 RemotePathEscaper로 single-quote escaping을 거치고, 빈 값·개행 같은 건 경계에서 막는다.
"일단 동작하면 됐지"가 아니라, 실패 경로와 잘못된 입력을 먼저 막는 코드가 기본값이 됐다.
비용이 무서워서 CI를 아꼈다
여기엔 덜 멋있는 현실도 있다. CI 비용이다.
iOS framework 링크는 GitHub-hosted macOS 러너에서 도는데, 이 분(minute)은 돈이 나간다. 작은 수정마다 원격 CI를 돌리면 비용이 새기 때문에, 로컬 게이트/Gradle/smoke로 묶어 통과시킨 다음 의미 있는 배치에서만 원격 CI를 돌리는 식으로 운영했다.
그 흔적이 커밋 메시지에 그대로 남아 있다. 75개 커밋 중 36개에 [skip ci]가 붙어 있다. 절반 가까이가 "이건 로컬에서 확인했으니 원격은 건너뛴다"는 표시다. Linux/Android 쪽은 아예 self-hosted 러너로 빼서 분 단위 과금을 피했다.
그리고 정직하게 적자면, smoke가 전부 최종 PASS는 아니다. browser preview는 close 재검증 중에 emulator/ADB가 사라지는 flake가 남았고, tmux MainActivity 제품 smoke도 파서·명령·UI·빌드는 확인했지만 현재 환경에서 헤드리스 emulator가 일찍 죽어버려 끝까지 못 찍었다. 이건 게이트가 통제하는 영역 밖이다.
그런데 정작 빠진 것
여기까지만 보면 꽤 잘 굴러간 실험 같다. 보안은 우회 없이 정직하고, 파일은 안 비대하고, 경계는 입력을 안 믿고, 예외 탈출구는 한 번도 안 썼다. 가드레일은 제 할 일을 했다.
그런데 처음으로 돌아가 보자. 내가 이 앱을 만든 이유는 두 개였다. 한국어 입력과 tmux 세션 선택.
tmux는 됐다. 접속하면 세션을 골라 바로 붙을 수 있다. 경쟁 앱이 유료 벽 뒤에 둔 바로 그 흐름이다.
한국어 입력은? 코드에 흔적조차 없다. IME, 한글 조합, InputConnection 처리 — 검색해도 안 나온다. 터미널 렌더러는 ANSI 파서와 버퍼 seed까지만 있고, "완전한 터미널 렌더러"는 미구현으로 남았다. 정작 이 앱을 만들게 한 1순위 가려움이, 손도 못 댄 채로 멈췄다.
왜 이렇게 됐나. 가드레일 탓이 아니다. 내가 처음 지시할 때 한국어 입력을 명확히 박지 않았기 때문이다.
tmux 세션 선택은 기능 목록에 또렷이 들어가 있었다. 그래서 AI는 그걸 충실히 만들었다. 한국어 입력은 내 머릿속에선 1순위였는데, 지시문에선 그만큼의 무게로 적히지 않았다. 그러니 우선순위에서 계속 밀렸고, 결국 안 됐다.
이게 핵심이다. 가드레일은 AI가 코드를 어떻게 짜는지는 강하게 통제했다. 그런데 무엇을 먼저 만들지는 통제하지 못했다. 그건 규칙이 아니라 처음 지시에 달려 있었다.
게임을 시켰을 때도 똑같은 자리에서 걸렸다. 그때는 "급격히 조여오는 긴장감"을 말로 못 옮겨서 후반이 비었고, 이번엔 "한국어가 잘 들어가야 한다"를 충분히 안 박아서 그게 통째로 빠졌다. 받는 쪽(AI)이 알아서 못 채우는 자리는, 결국 주는 쪽이 처음에 명확히 적은 만큼만 나온다.
그래서 뭘 배웠나
정리하면 이렇다.
가드레일로 묶으니, AI가 짠 코드의 바닥은 확실히 올라갔다. 보안을 우회하지 않고, 파일이 비대해지지 않고, 실패를 삼키지 않고, 예외 탈출구를 쓰지 않는다. 이런 건 사람이 매번 리뷰로 잡기 피곤한 것들인데, 규칙 몇 줄이 기계적으로 잡아줬다. 손 안 대고 이만큼 깔끔하게 나온 건 솔직히 예상보다 좋았다.
대신 가드레일이 못 지켜주는 게 있었다. 방향이다. 무엇을 먼저 만들지, 어떤 기능이 이 앱의 존재 이유인지. 게이트는 "이 코드가 규칙을 지켰는가"는 묻지만 "이게 내가 원했던 그건가"는 묻지 못한다. 그래서 규칙을 다 지킨, 깔끔하지만 정작 핵심이 빠진 결과물이 나올 수 있다.
그 빈 곳은 결국 사람이 처음에 메워야 한다. 가드레일을 아무리 잘 짜도, 만든 이유를 지시문에 또렷이 박지 않으면 그 이유는 그냥 빠진다. 이번엔 내가 그걸 안 했고, 그래서 한국어 입력이 빠졌다. 가드레일의 한계라기보다, 가드레일에 방향까지 떠넘긴 사람의 한계에 가깝다.
쓸 만한 결론은 이쯤인 것 같다. 가드레일은 AI가 짠 코드의 품질 바닥을 지켜준다. 하지만 무엇을 만들지는 여전히 사람이 처음에 명확히 박아야 한다. 그 한 칸을 비우면, 보안도 구조도 멀쩡한데 정작 만든 이유가 빠진 앱이 나온다.
GitHub - jhste102lab/ssh-bridge-kr: Kotlin Multiplatform mobile SSH app snapshot with Android-first SSH, PTY, SFTP, tmux, and Gi
Kotlin Multiplatform mobile SSH app snapshot with Android-first SSH, PTY, SFTP, tmux, and Git diff experiments. - jhste102lab/ssh-bridge-kr
github.com