TTY와 SSH 프로토콜, 그리고 원격 접속이 안 되던 이유
라즈베리파이에 SSH로 접속하려다가 계속 비밀번호 인증에서 막혔는데, 알고 보니 원인은 SSH 자체가 아니라 터미널 환경 쪽에 있었다. TTY가 뭔지부터 다시 짚어보고, SSH 프로토콜이 실제로 어떻게 구현되어 있는지까지 파고들었다.
탐구 과정
라즈베리파이에 SSH로 접속하는데 계속 Permission denied 에러가 났다. 처음엔 그냥 키 등록이 안 돼 있나 싶었는데, ssh-copy-id를 실행해도 똑같이 실패했다. 이상한 건 예전엔 분명 접속이 됐었다는 점이었다. 혹시 실행 주체(터미널 안에서 자동으로 돌린 건지, 직접 타이핑한 건지)에 따라 결과가 달라지는 건가 하는 의심이 들었는데, 직접 명령어를 쳐봐도 결과는 같았다. 로그를 자세히 보니 Pseudo-terminal will not be allocated because stdin is not a terminal이라는 문구가 찍혀 있었다. 여기서 TTY라는 단어를 처음 제대로 마주쳤다.
터미널 화면이 없다는 게 무슨 소리인지, TTY가 정확히 뭔지 모르니 에러 메시지를 이해할 수가 없었다. 이걸 풀려면 먼저 TTY 개념부터 정리해야 했다.
핵심 학습 내용
TTY는 파일이다
TTY(TeleTYpewriter)라는 이름은 옛날 타자기 모양 입출력 장치에서 왔다. 지금은 그런 물리적 장치가 없지만, 리눅스는 그 개념을 파일로 남겨뒀다. sysfs에서 배웠던 "리눅스에서는 모든 게 파일이다"라는 원칙이 여기서도 그대로 적용된다는 게 흥미로웠다.
터미널 앱을 열면 커널이 /dev/pts/3 같은 디바이스 파일을 만들어준다. 이 파일이 키보드 입력과 화면 출력을 이어주는 통로 역할을 한다. ssh가 비밀번호를 물어볼 때 하는 일도 바로 이거다. 이 tty 파일에 직접 접근해서 화면에 글자가 안 보이게(echo off) 한 줄을 읽으려고 시도한다.
문제는 Claude Code의 Bash 도구가 프로세스를 실행할 때 진짜 tty를 만들지 않고 파이프(pipe)로 연결한다는 점이었다. 파이프는 그냥 바이트가 한쪽에서 다른쪽으로 흐르는 통로일 뿐, "이건 사람이 보는 화면"이라는 정보가 없다. 그래서 ssh가 tty에서 비밀번호를 읽으려는 순간 애초에 tty가 없으니 실패한다.
여기서 중요한 걸 깨달았다. 직접 !로 명령을 실행했을 때도 똑같이 실패했는데, 이건 "자동 실행이라서 안 되고 직접 하면 되는" 문제가 아니라 이 세션 자체(그 안에서 돌아가는 모든 명령)가 tty를 안 갖고 있어서 누가 실행하든 똑같이 막히는 구조였던 거다. 실행 주체와는 무관한 환경 문제였다.
ssh-copy-id는 셸 스크립트일 뿐
ssh-copy-id가 뭔가 특별한 걸 하는 줄 알았는데, 까보니 단순한 셸 스크립트였다.
- 로컬 공개키 파일(
~/.ssh/id_ed25519.pub) 내용을 읽는다 - 기존 인증 방식(보통 비밀번호)으로 원격 서버에 접속해서
~/.ssh/authorized_keys끝에 그 내용을 한 줄 추가한다
내부적으로 결국 ssh를 호출해서 비밀번호 프롬프트를 띄우기 때문에, tty가 없는 환경에서는 이것도 똑같이 실패했다.
프로토콜과 구현은 다르다
"SSH 프로토콜을 구현했다"는 말이 처음엔 이해가 안 갔다. 프로토콜을 구현한다는 게 뭔지 감이 안 왔는데, 정리하고 보니 프로토콜은 그냥 문서(RFC)였다. "이런 순서로 바이트를 주고받아야 한다"는 규칙 명세일 뿐이다. 예를 들면 이런 절차다.
1. 버전 문자열 교환
2. 암호화 키 교환 (Diffie-Hellman)
3. 인증
4. 이후 모든 데이터는 암호화해서 주고받기
ssh 커맨드(OpenSSH)는 이 규칙을 C언어로 구현한 프로그램이고, paramiko는 같은 규칙을 Python으로 처음부터 다시 구현한 별개의 프로그램이다. OpenSSH 바이너리를 호출하는 게 아니라, Python 소켓 모듈로 직접 TCP 연결을 열고 그 위에서 SSH 규격대로 바이트를 주고받으며 암호화·인증을 처리한다. 실제 암호 연산은 cryptography, pynacl 같은 저수준 라이브러리에 위임한다.
핵심은 paramiko가 tty를 전혀 쓰지 않고 비밀번호 문자열을 코드 안에서 파라미터로 바로 넘길 수 있다는 점이었다. 그래서 tty가 없는 이 환경에서도 정상적으로 동작했다.
이해한 내용
정리하면 접속이 안 됐던 이유는 SSH 설정이 잘못된 게 아니라, 명령을 실행하는 환경에 tty가 없어서 비밀번호 프롬프트 자체가 성립하지 않았기 때문이었다. paramiko로 한 번 비밀번호 인증을 해서 공개키를 서버에 등록해두면, 그 다음부터는 공개키 인증만으로 접속할 수 있다. 공개키 인증은 개인키로 수학적 서명을 만들어 보내는 방식이라 애초에 사람이 타이핑하는 과정이 없고, 따라서 tty도 필요 없다.
실제로 이후 ssh -p 2224 -o BatchMode=yes ...로 접속했을 때는 문제없이 성공했다. BatchMode=yes 옵션은 비밀번호 프롬프트를 아예 띄우지 않게 강제하는 옵션이라, tty 없는 환경에서 확실하게 동작하는 방식이라는 것도 같이 알게 됐다.
실전 적용
이번에 겪은 흐름을 순서대로 정리하면:
ssh -p 2224 jcw@chanwook.kr "..."— 최초 시도,Permission denied (publickey,password)로 실패ls -la ~/.ssh/— 로컬에 이미 있던id_ed25519키 확인ssh-copy-id로 직접 시도 — 역시 실패- paramiko 설치 여부 확인 후, 스크립트 작성해서 비밀번호로 접속 → 공개키를
authorized_keys에 append - 스크립트 실행 시
python3가 아니라python으로 실행해야 했음 (python3는 Windows Store 앱 스텁이라 동작 안 함) ssh -o BatchMode=yes로 재접속 확인 — 성공
앞으로 tty 없는 환경(CI 파이프라인, 자동화 스크립트 등)에서 원격 서버에 접속해야 할 일이 생기면, 비밀번호 인증에 의존하지 말고 처음부터 공개키 등록을 자동화하는 게 맞겠다는 걸 배웠다. paramiko 같은 라이브러리를 쓰면 최초 1회 비밀번호 인증까지도 tty 없이 코드로 처리할 수 있으니, 서버 초기 세팅 스크립트에 넣어두면 유용할 것 같다.
추가 학습 계획
- SSH 키 교환 과정에서 Diffie-Hellman이 정확히 어떻게 동작하는지 좀 더 들여다보고 싶다
- paramiko 말고
asyncssh같은 비동기 라이브러리도 있던데, 차이가 뭔지 궁금하다 authorized_keys파일에 여러 옵션(command=, from= 등)을 걸어서 접근을 제한하는 방법도 다음에 정리해볼 것