대화형 로그인 라이브러리를 컨테이너에서 자동 로그인시키기

2026.10.04
현장 데이터 서비스 구축기 14편

ID와 비밀번호를 터미널에서 입력받는 사내 데이터 조회 라이브러리를 컨테이너 안 Flask 앱에서 자동 로그인시킨 방법이다. stdin 리다이렉트가 통하지 않는 이유, getpass 패치, 프롬프트 문구로 입력 항목을 판별하는 방법을 코드로 정리했다.

이 글의 순서

    상황

    사내 데이터 레이크를 조회하는 파이썬 라이브러리가 있었다. 사용법은 간단하다. login()을 호출하면 터미널에 ID와 비밀번호를 물어보고, 받은 토큰으로 이후 쿼리를 실행한다. 노트북이나 터미널에서 쓰기엔 문제가 없다.

    문제는 이 라이브러리를 컨테이너 안 Flask 앱에서 쓸 때다. 컨테이너에는 키보드를 칠 사람이 없다. 앱이 기동하면서 데이터를 불러오려고 login()을 부르는 순간 입력을 기다리며 멈추고, 헬스체크가 실패해 Pod가 재시작된다.

    Pod 기동gunicorn이 앱 로드
    login() 호출데이터 초기 적재
    입력 대기ID·비밀번호 프롬프트
    헬스체크 실패재시작 반복

    stdin으로 넣으면 될 줄 알았다

    처음 떠올린 방법은 표준입력에 값을 흘려 넣는 것이다. sys.stdin을 io.StringIO로 바꾸거나 파이프로 넘기면 input()은 그 값을 읽는다.

    그런데 비밀번호는 그렇게 안 됐다. 파이썬 표준 getpass.getpass()는 화면에 입력값을 숨기기 위해 stdin이 아니라 터미널 장치(/dev/tty)를 직접 연다. 이 라이브러리는 한 술 더 떠서 입력 중 별표를 보여주는 별도 패키지 함수를 쓰고 있었고, 이 함수도 stdin 리다이렉트를 우회했다. 터미널이 없는 컨테이너에서는 경고를 내거나 그냥 멈춘다.

    핵심stdin을 바꾸는 게 아니라, 입력을 받는 함수 자체를 잠깐 다른 함수로 바꿔치기한다.

    입력 함수를 잠깐 바꿔치기하기

    파이썬에서는 실행 중에 모듈의 함수를 다른 함수로 바꿀 수 있다. 흔히 몽키패치라고 부른다. 로그인하는 동안만 input과 getpass를 환경변수 값을 돌려주는 함수로 바꾸고, 끝나면 원래대로 되돌린다.

    여기서 하나 놓치기 쉬운 점이 있다. 라이브러리가 from getpass import getpass처럼 함수를 자기 모듈 이름으로 가져왔다면, getpass.getpass만 바꿔서는 소용이 없다. 라이브러리 모듈 안에 원래 함수가 이미 따로 묶여 있기 때문이다. 그 모듈의 속성까지 같이 바꿔야 한다.

    어디를 바꿔야 하나

    라이브러리 코드바꿀 대상
    import getpass 후 getpass.getpass()getpass.getpass
    from getpass import getpass라이브러리모듈.getpass
    별도 입력 패키지 함수 사용그 패키지 함수와 라이브러리 모듈의 해당 이름
    input()builtins.input

    라이브러리 소스에서 import 문을 먼저 확인한다

    순서를 가정하면 환경마다 깨진다

    첫 구현은 "첫 번째 질문은 ID, 두 번째는 비밀번호"라고 가정하고 순서대로 값을 돌려줬다. 로컬에서는 잘 됐는데 운영 환경에서 로그인이 실패했다. 운영 환경에서는 저장된 ID가 있어 비밀번호만 묻거나, 묻는 순서가 달랐다. 결국 비밀번호 칸에 ID가 들어갔다.

    환경실제 질문돌려준 값결과
    로컬User ID → PasswordID → 비밀번호성공
    운영Password만ID실패
    다른 버전Password → User IDID → 비밀번호실패
    환경실제 질문돌려준 값결과
    로컬User ID → PasswordID → 비밀번호성공
    운영Password만비밀번호성공
    다른 버전Password → User ID비밀번호 → ID성공

    질문 문구에 password가 들어 있으면 비밀번호, 아니면 ID를 돌려준다

    입력 함수는 프롬프트 문자열을 인자로 받는다. 이 문자열을 보고 무엇을 묻는지 판단하면 순서와 상관없이 맞는 값을 돌려줄 수 있다.

    코드

    headless_login.py
    import builtins
    import getpass
    import os
    import threading
    from contextlib import contextmanager
    
    PW_HINTS = ("password", "passwd", "pw", "비밀번호")
    
    
    def _answer(prompt="", *args, **kwargs):
        """프롬프트 문구를 보고 ID인지 비밀번호인지 판단해 값을 돌려준다."""
        text = str(prompt or "").lower()
        if any(h in text for h in PW_HINTS):
            return os.environ["DATA_PASSWORD"].strip()
        return os.environ["DATA_USER"].strip()
    
    
    @contextmanager
    def patched_prompts(*targets):
        """targets: (모듈, 속성이름) 목록. 블록 안에서만 _answer로 바꾼다."""
        slots = [(builtins, "input"), (getpass, "getpass")] + list(targets)
        saved = []
        for mod, name in slots:
            if hasattr(mod, name):
                saved.append((mod, name, getattr(mod, name)))
                setattr(mod, name, _answer)
        try:
            yield
        finally:
            for mod, name, orig in saved:
                setattr(mod, name, orig)
    
    
    def login_headless(login_fn, targets=(), timeout=30):
        """로그인이 입력 대기로 멈춰도 timeout 뒤에는 빠져나온다."""
        box = {}
    
        def run():
            try:
                with patched_prompts(*targets):
                    box["result"] = login_fn()
            except Exception as e:  # 로그인 실패도 호출한 쪽에서 알 수 있게
                box["error"] = e
    
        t = threading.Thread(target=run, daemon=True)
        t.start()
        t.join(timeout)
        if t.is_alive():
            raise TimeoutError("로그인이 %d초 안에 끝나지 않았다" % timeout)
        if "error" in box:
            raise box["error"]
        return box.get("result")
    사용 예
    import datalake_client as dl          # 사내 라이브러리 (예시 이름)
    import datalake_client.auth as dl_auth
    
    login_headless(
        dl.login,
        targets=[(dl_auth, "getpass"), (dl_auth, "getpass_asterisk")],
        timeout=30,
    )

    주의바꿔치기는 프로세스 전체에 영향을 준다. 다른 스레드가 같은 시간에 input을 쓰면 엉뚱한 값을 받는다. 앱 기동 시점처럼 요청이 들어오기 전에 한 번만 실행한다.

    스레드와 타임아웃을 둔 이유

    바꿔치기를 놓친 입력 함수가 하나라도 남아 있으면 로그인은 여전히 멈춘다. 그때 앱 전체가 같이 멈추면 원인을 찾기 어렵다. 별도 스레드에서 로그인하고 정해진 시간이 지나면 예외를 내게 하면, 앱은 "로그인 타임아웃"이라는 로그를 남기고 다음 단계로 넘어간다. 데이터 없이라도 화면이 뜨니 로그를 보며 고칠 수 있다.

    그다음에 만난 오류

    순서 문제를 고친 뒤 운영 환경에서 Data must be padded to 16 byte boundary라는 오류가 났다. 라이브러리가 비밀번호를 암호화하는 단계에서 길이가 맞지 않을 때 나는 메시지다. 추정 원인은 환경변수 값에 섞인 공백, 줄바꿈, 한글 같은 비ASCII 문자였다. 배포 설정 화면에 값을 붙여넣다 보면 앞뒤 공백이 같이 들어가기 쉽다.

    환경변수 값 점검
    import os
    
    pw = os.environ.get("DATA_PASSWORD", "")
    print("길이:", len(pw), "| 공백 제거 후:", len(pw.strip()))
    print("ASCII만:", pw.isascii())
    print("줄바꿈 포함:", "\n" in pw or "\r" in pw)

    위 코드의 _answer에서 .strip()을 하는 것도 이 때문이다. 값 자체는 로그에 남기지 않고 길이와 형태만 확인한다.

    토큰 만료

    로그인 토큰은 시간이 지나면 만료된다. 만료된 토큰으로 쿼리하면 인증 오류가 난다. 쿼리 함수를 한 번 감싸서 인증 오류가 나면 login_headless로 다시 로그인하고 한 번만 재시도하게 했다. 무한 재시도는 계정 잠금으로 이어질 수 있으니 횟수를 제한한다.

    정리

    • 비밀번호 입력 함수는 stdin을 읽지 않는다. stdin 대신 입력 함수 자체를 바꾼다.
    • from ... import로 가져온 함수는 라이브러리 모듈 쪽 이름까지 바꿔야 한다.
    • 질문 순서를 가정하지 말고 프롬프트 문구로 무엇을 묻는지 판단한다.
    • 로그인은 별도 스레드와 타임아웃으로 감싸 앱 전체가 멈추지 않게 한다.
    • 환경변수 값의 공백과 비ASCII 문자를 의심한다.
    이전 편13편. pfx 인증서를 Kubernetes TLS Secret으로 바꾸기
    다음 편15편. 보안 점검 후 Flask 서비스에서 고친 7가지
    #Python#getpass#컨테이너#Docker#자동로그인#monkeypatch#Flask#트러블슈팅

    댓글