파이썬 비밀번호 입력받기 - paisseon bimilbeonho iblyeogbadgi

지식인에서 좀 재밌어 보이는 문제를 발견하고 답변을 달았었는데, 조금 더 세세하게 다듬어서 소개해보고자 합니다. 오늘 만들어 볼 것은 비밀번호를 입력받는 기능에 관한 것입니다. 파이썬에서 키보드 입력을 받는 함수는 input() 입니다. 이 함수를 호출하면 키보드를 사용해서 어떤 문자열값을 프로그램에 전달할 수 있습니다. 이 때 우리가 입력한 키는 그대로 화면에 노출이 됩니다. 이걸 좀 유식한 척 말하면 에코라고 합니다.

그런데 사용자가 비밀번호를 입력하는데, 타이핑한 내용이 화면에 보이게 되면 왠지 없어 보입니다. 뒤에서 몰래 훔쳐보는 사람이 있을지도 모르니까, 타이핑은 하되 그 내용이 보이지 않도록 하는 것이 필요해 보입니다. 실제로 리눅스 쉘 같은 곳에서는 비밀번호를 요구할 때, 비밀번호를 타이핑해도 화면에 아무것도 나타나지 않게 처리됩니다. 근데, 이렇게하면 키를 잘못 눌러서 들어가지 않아야 할 문자가 들어갔을 때 알아차리기 힘든 불편함도 있습니다. (물론 이것이 보안상으로 더 좋기는 합니다.)

이 기능은 단순하게 input() 함수를 사용해서는 구현할 수 없습니다. 그래서 이번 글에서는 비밀 번호를 입력받는 기능을 구현해보려고 합니다.

1. 키를 눌렀을 때 해당 문자를 표시하는 대신에 * 를 표시해줍니다.

2. 엔터를 누르면 입력이 완료됩니다.

3. 백 스페이스 키를 눌러서 마지막 입력한 글자를 지울 수 있게 합니다.

4. 그외에 화면에 보이지 않는 문자 (화살표, Ctrl-A 같은 입력)는 무시합니다.

또, 윈도우의 콘솔 환경을 가정합니다. 콘솔을 제어하는 방식이 윈도우와 다른 플랫폼이 좀 달라요.

msvcrt

윈도에서 콘솔의 입출력을 제어하기 위해서는 msvcrt 라는 라이브러리를 사용합니다. 별도로 설치할 필요는 없고 표준 라이브러리 중 하나입니다. 여기서 유용하게 사용할 수 있는 함수를 몇 가지 소개합니다. 모듈의 전체 내용은 공식 문서를 참고하면 되겠습니다.

* getch() : 화면에 에코하지 않고 키 입력 1개를 받습니다. 엔터키를 눌러서 입력을 마치는 input()과 달리 키가 눌려지면 바로 리턴합니다. 해당 키의 값을 bytes 객체로 리턴합니다.

* kbhit() : 키보드 입력이 가능한지 여부를 확인합니다. 가능하면 True를 리턴합니다.

* putch(char) : 바이트열 char를 버퍼링 없이 콘솔에 출력하게 합니다.

여기서 버퍼링이 왜 나오냐고 생각할 수 있는데, 기본적인 입출력은 중간에 버퍼가 있게 됩니다. input()의 경우 표준입력을 통해서 문자열을 가져오는 기능입니다. 이 함수가 실행중인 도중, 그러니까 사용자가 키보드를 타이핑하는 동안에는 입력된 내용이 버퍼에 들어갑니다. 그리고 사용자가 입력을 마치고 엔터를 누르는 그 순간, 버퍼의 내용이 문자열 데이터로 변환되어 리턴됩니다. 사실 print() 함수 역시 비슷하게 작동합니다. 실제로 출력될 내용을 생성하고 인코딩하여 버퍼에 기록한 후, 버퍼의 내용을 콘솔쪽으로 털어넣는(flush) 과정을 거치게 됩니다. 다만 일상적인 입출력을 이렇게 하면 귀찮으니까 편의상 input() / print() 로 만들어 놓은 것이지요.

기본적인 입출력은 sys 모듈의 stdin / stdout 객체를 사용합니다. 이는 IO 객체로, 텍스트 파일과 똑같은 방식으로 다룰 수 있습니다. 물론 우리가 작성할 함수에서도 사용할 예정입니다.

기본적인 입력 구현하기

함수의 이름을 get_password()라 하고, 기본적인 입력을 구현해보겠습니다. 이 함수는 input()을 흉내내는 것이기 때문에 기본적으로 input() 이하는 일을 어느 정도 처리해야 합니다. 따라서 버퍼를 하나 준비해야 합니다. getch() 함수는 bytes 타입 값을 리턴합니다. 버퍼는 리스트를 사용해도 상관 없지만, bytearray 를 사용하는 것도 좋겠습니다. bytearray는 bytes의 리스트 버전이라 생각할 수 있습니다. append(), pop()과 같은 메소드를 지원해줍니다.

입력받은 문자값들을 바이트배열에 넣은 다음에, decode() 메소드를 호출하면, 바이너리 데이터를 디코드하여 문자열로 만들 수 있습니다.

bytes는 일련의 바이트입니다. getch() 함수가 리턴하는 바이트 값은 정수로 만들면, 해당 키가 나타내는 문자의 아스키코드값이 됩니다. 엔터키의 아스키코드값은 13이므로 입력이 끝났는지를 판단하려면 두 가지를 할 수 있어야 합니다. 1) 바이트값을 정수로 변환하고, 2) 이 값이 13인지 봅니다.

바이트 값을 정수로 변환하려면, int.from_bytes()를 사용합니다. 이건 컴퓨터에서 정수값을 이진바이트로 표시하는 데이터를 가져와서 정수값으로 만들어줍니다. 그런데, 문제는 컴퓨터마다 '정수를 이진수로 표시하는 방법'이 다릅니다. 대부분의 플랫폼에서 정수라 함은 32비트 정수를 말하는 것이 일반적입니다. 32비트는 4 바이트이므로 정수값 하나를 표현하는데 4개의 바이트를 쓰는 것입니다. 이진수로 말하자면 총 32자리 이진수 값을 다룰 수 있습니다. 그런데 어떤 컴퓨터는 높은 자리를 먼저 쓰고, 어떤 컴퓨터는 낮은 자리를 먼저 씁니다. 이를 빅엔디언, 리틀엔디언이라고 부릅니다. 더 깊이 들어가면 골치가 아프니 그냥 방법만 말씀 드릴게요. int.from_bytes()를 써서 바이트로부터 정수를 만들려면 이 바이트 순서값이 필요합니다. 이 때에는 sys 모듈의 byteorder 값을 그대로 쓰면 됩니다.

암튼 정리해보면, 엔터키를 눌렀는지 여부는 다음과 같이 검사합니다.

x = msvcrt.getch() n = int.from_bytes(x, sys.byteorder)) if n == 13: # 엔터키를 눌렀음

import msvcrt import sys def get_password() -> str: buf = bytearray() while True: if not msvcrt.kbhit(): continue x = msvcrt.getch() n = int.from_bytes(x, sys.byteorder) if n == 13: # Enter return buf.decode() else: buf.append(n) print(get_password())

코드를 테스트해보면, 화면에 아무것도 나타나지 않습니다. 키보드를 대충 두드려서 엔터를 치면 지금까지 입력된 내용이 출력됩니다. 기본적으로는 이렇게 작동합니다. 그럼 이번에는 키보드를 두드릴 때마다 * 이 표시되게 해보겠습니다. msvcrt.putch()를 쓰면 됩니다. 다만 문자열 객체를 그대로 전달하는게 아니라, 기본 입출력 인코딩으로 인코드해서 보내야 합니다.

def get_password() -> str: buf = bytearray() while True: if not msvcrt.kbhit(): continue x = msvcrt.getch() n = int.from_bytes(x, sys.byteorder) if n == 13: # Enter msvcrt.putch('\n'.encode()) return buf.decode() else: msvcrt.putch('*'.encode()) buf.append(n) print(get_password())

이렇게 수정해서 테스트 해보면 작은 문제가 있습니다. 엔터키를 눌러 입력을 마칠 때, 줄바꿈이 되지 않기 때문에 내용이 *** 뒤에 바로 붙어서 표시됩니다. 따라서 엔터키 입력 부분에 개행문자를 출력하도록 하는 처리를 해주어야 합니다.

def get_password() -> str: buf = bytearray() while True: if not msvcrt.kbhit(): continue x = msvcrt.getch() n = int.from_bytes(x, sys.byteorder) if n == 13: # Enter msvcrt.putch('\n'.encode()) return buf.decode() else: msvcrt.putch('*'.encode()) buf.append(n)

삭제 처리 추가하기

백 스페이스 키를 눌러서 입력된 내용을 지우는 처리를 추가해보겠습니다. 지금은 백 스페이스 키를 눌러도 마치 글자를 입력한 것처럼 * 표가 더 생길 것입니다. 우선 백스페이스의 아스키코드값은 8 입니다. 그럼 이 키가 입력됐을 때에는 어떤 처리를 해야할까요?

* 먼저 이전에 입력된 * 표 하나를 지우고

* buf 에서 마지막으로 추가된 값을 지웁니다.

이걸 하면 됩니다. 그런데 어떻게 앞에 출력한 글자를 지울까요? 우리가 줄바꿈을 할 때 쓰는 이스케이프 문자는 "\n" 입니다. (n은 new line 의 약자) "\b"는 뒤로 한 칸 커서를 이동시키는 겁니다. 그런데 이건, 실제로 커서만 이동했지, 앞의 문자를 지우지는 않습니다. 이 상태에서 문자를 출력하면 커서 위치의 글자가 바뀌게 됩니다. 따라서 공백을 하나 출력하고 다시 "\b"를 출력해주면 앞의 문자를 지운 것과 같은 효과를 낼 수 있습니다. msvcrt.putch()를 세번 호출해도 됩니다만, 그냥 sys.stdout 을 써보겠습니다.

sys.stdout 은 표준 출력이라 부르는 IO 장치를 나타냅니다. 보통 콘솔에서는 화면 출력이에요. 이 객체 자체는 텍스트 파일과 사용방법이 똑같습니다. write() 메소드를 써주면 됩니다. 이건 버퍼상에 데이터를 기록하는 것이기 때문에 실제 콘솔에 표시하려면 flush() 메소드를 한 번 호출해주어야 합니다.

그리고 주의할 점! buf 가 비어있는 경우 (더 이상 지울 글자가 없는 경우)에는 이 동작을 수행해서는 안됩니다. 비어있는 배열에 pop()을 호출하면 오류가 나게 됩니다.

def get_password() -> str: buf = bytearray() while True: if not msvcrt.kbhit(): continue x = msvcrt.getch() n = int.from_bytes(x, sys.byteorder) if n == 13: # Enter msvcrt.putch('\n'.encode()) return buf.decode() elif n == 8: if buf: sys.stdout.write('\b \b') sys.stdout.flush() buf.pop() else: msvcrt.putch('*'.encode()) buf.append(n)

여기까지 하고나면 거의 된 것 같지만, 아직 남은 부분이 많습니다. 위 코드는 엔터키와 백스페이스를 제외한 모든 입력 가능한 키보드 입력을 받아서 버퍼에 기록합니다. 그런데 실제로는 여러가지 키 입력이 사실 문자가 아닌 것이 많습니다. 탭 키라든지 방향키, 그리고 ctrl 키와 같이 눌러지는 키들이 그렇습니다. ctrl + a 같은 키도 실제로는 문자가 아닌 키 입력입니다. 이런 것들을 걸러내는 동작을 추가해 보겠습니다.

출력가능한 문자만 입력 받게 하기

아스키코드에서 출력가능한 문자는 십진수 코드값 32~126까지의 범위입니다. 아래 위키피디아 링크를 참고하세요.

ASCII - Wikipedia

그래서 n 값의 범위를 32 이상 126 이하로 제한하겠습니다. 이렇게하면 기능키와 ctrl + x 조합의 키를 막을 수 있습니다. 그런데 이렇게해도 방향키를 입력하면 이상하게 일반 문자 M, H 같은 문자로 입력되는 문제가 있습니다. 왜 그럴까요?그 외에 F1, F2 같은 기능키도 * 가 추가됩니다.

이러한 특수 키는 1바이트로 표현되는 내용이 아니기 때문입니다. 사실 이 키들은 아스키보드 범위 밖에 있습니다. 따라서 2바이트로 구성되는 값을 표현합니다. 이런 키들이 입력되면 해당 키 코드는 버퍼에 남아있고, getch() 함수는 호출될 때마다 이 버퍼에서 한 바이트씩만 가져오기 때문입니다. 이들 키의 두 번째 바이트는 주로 일반적인 문자와 동일하기 때문입니다. 따라서 각 바이트를 읽었을 때, 그 바이트가 특수 키의 바이트시퀀스의 첫 바이트 값이면 그 다음번 읽어온 바이트를 무시하도록 보완이 필요합니다.

skip 이라는 변수를 따로 하나 정의하겠습니다. 입력한 값을 무시한다는 뜻이며, 초기값은 False 입니다. 입력받은 바이트가 `\x00' 이나 `\xe0'으로 시작한다면 특수키로 보면 됩니다. (어떻게 이런 값을 아냐구요? getch() 에서 얻은 값을 출력해보면서 테스트해보면 알아낼 수 있습니다;;;)

그렇게 해서 최종적으로 정리한 코드는 다음과 같습니다.

def get_password(prompt="password:") -> str: sys.stdout.write(prompt) sys.stdout.flush() buf = bytearray() skip = False while True: if not msvcrt.kbhit(): continue x: bytes = msvcrt.getch() if x.startswith(b"\xe0") or x.startswith(b"\x00"): skip = True continue if skip: skip = False continue n = int.from_bytes(x, sys.byteorder) if n == 13: # Enter msvcrt.putch("\n".encode()) return buf.decode() elif n == 8: if buf: sys.stdout.write("\b \b") sys.stdout.flush() buf.pop() elif 32 <= n < 126: msvcrt.putch("*".encode()) buf.append(n)

그럼 오늘 예제의 작동 모습을 확인하면서, 오늘 글은 여기까지 하겠습니다. 다들 건강하시고, 힘들지만 훈훈한 연말 보내시길 바랄게요. 안녕~~

Toplist

최신 우편물

태그