Post

소셜 로그인 기능

소셜 로그인 기능 추가 계획

개요

본 문서는 자동 주식 매매 프로그램에 소셜 로그인 기능을 추가하기 위한 구체적인 구현 계획을 설명합니다. 사용자의 편의성을 높이고 별도의 회원가입 절차 없이 프로그램을 이용할 수 있도록, Google 또는 Naver 계정을 활용한 로그인을 Streamlit 환경에 통합하는 것을 목표로 합니다.

1. 주요 기능 및 모듈

기능 번호기능 모듈세부 설명
1소셜 인증 라이브러리 연동Google, Naver OAuth 2.0 프로토콜을 Streamlit 애플리케이션에 통합합니다. streamlit-oauth 또는 stauth와 같은 커뮤니티 라이브러리 또는 requests를 활용하여 직접 구현합니다.
2로그인 UI 구성Streamlit 메인 화면에 ‘Google로 로그인’, ‘Naver로 로그인’ 버튼을 명확하게 배치하고, 로그인 상태에 따라 대시보드 접근 권한을 제어합니다.
3사용자 세션 관리로그인 성공 후 발급받은 인증 토큰을 Streamlit의 st.session_state에 안전하게 저장하여, 앱 내에서 사용자의 지속적인 세션 관리를 수행합니다.
4접근 제어 로직로그인에 성공한 사용자만 기존의 ‘종목 분석 & 자동매매’, ‘나의 포트폴리오’ 탭에 접근할 수 있도록 main.py에 접근 제어(Authorization) 로직을 추가합니다.

2. 구현 상세 가이드: Google OAuth 2.0 연동

Streamlit 환경에서 가장 안정적인 소셜 로그인 구현을 위해 Google OAuth 2.0을 기준으로 설명합니다.

2.1. 개발자 등록 및 키 발급 (선행)

2.1.1 Google

  1. Google Cloud Console 접속: Place (Google Cloud Console)에서 새 프로젝트를 생성합니다.
  2. API 활성화: “OAuth 동의 화면”을 구성하고, “사용자 인증 정보”에서 OAuth 2.0 클라이언트 ID를 생성합니다.
  3. 애플리케이션 유형: “웹 애플리케이션”을 선택하고, 다음 URI를 등록합니다.
    • 승인된 자바스크립트 원본: http://localhost:8501 (로컬 테스트용)
    • 승인된 리디렉션 URI: http://localhost:8501/ (또는 배포 시 Streamlit Cloud 주소)
  4. 키 확보: 클라이언트 ID클라이언트 보안 비밀번호를 발급받아 .env 파일에 저장합니다.

2.1.2 Naver

  1. Naver Developers 접속.
  2. Application 등록.
  3. 애플리케이션 이름 입력 : StockTradingApp
  4. 사용 API 선택: 네이버 로그인 (회원이름, 이메일 주소 필수 선택)
  5. 환경: PC 웹.
  6. 서비스 URL: http://localhost:8501
    1. 웹앱 배포 후 실제 서비스의 경우 : 실제 서비스하는 URL을 입력
      (예: https://stock-trading-app.streamlit.app/)
  7. Callback URL: 아래 두 Callback URL을 등록해야만 합니다.
    1. 로컬 테스트 : http://localhost:8501/
    2. 실제 서비스: 서비스 URL와 동일
      (예: https://stock-trading-app.streamlit.app/)

Streamlit의 구조적 한계

Streamlit은:

  • /auth/naver/callback 같은 서버 라우트가 없음
  • ❌ Express / Spring처럼 URL 경로별 컨트롤러 없음
  • 모든 요청이 하나의 Streamlit 앱(streamlit run main.py)으로 들어옴

즉,

네이버가 redirect할 수 있는 “엔드포인트”는
오직 앱의 메인 URL 하나뿐이야.

Streamlit에서 로그인 처리 구조 (개념)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[사용자]
   ↓
[네이버 로그인 버튼 클릭]
   ↓
[네이버 로그인 페이지]
   ↓
[로그인 성공]
   ↓
https://stock-trading-app.streamlit.app/?code=...&state=...
   ↓
[Streamlit app.py에서 code 감지]
   ↓
[네이버 토큰 API 호출]
   ↓
[로그인 완료]

2.2. .env 파일 설정

1
2
3
4
5
6
7
8
9
10
11
# .env 파일 내용
GOOGLE_CLIENT_ID=<발급받은_클라이언트_ID>
GOOGLE_CLIENT_SECRET=<발급받은_클라이언트_SECRET>
REDIRECT_URI=http://localhost:8501 # 배포 시 변경 필요

# Naver OAuth
NAVER_CLIENT_ID="발급받은_NAVER_CLIENT_ID"
NAVER_CLIENT_SECRET="발급받은_NAVER_CLIENT_SECRET"
NAVER_REDIRECT_URI="http://localhost:8501" # 배포 시 변경 필요

COOKIES_PASSWORD=your_cookie_password # 쿠키 비밀번호

뒤 [웹앱 배포]에서 다시 설명하겠지만 서비스 배포 시 .env 파일은 포함되지 않기 때문에 Streamlit에서 만든 앱(stock_trading_app)의 설정에서 Secrets를 입력해야 합니다.
그리고 추가적으로 페이지를 새로 고침하면 st.session_state에 초기화되어서 로그인 정보가 사라져서 다시 로그인 페이지가 표시되기 때문에 로그인에 성공하면 cookie에 로그인 정보를 저장해야 합니다.
이와 관련된 cookie 비밀번호(길고 랜덤한 최소 길이 32인 문자열을 추천)를 .env에 저장합니다.

cookie 관리를 위한 필요 패키지를 requirements.txt에 기입합니다.

1
2
# ... 기존 패키지들 ...
streamlit-cookies-manager-v2 # Cookie Management for Streamlit

Secrets에 아래와 같이 입력하면 됩니다.

1
2
3
4
5
NAVER_CLIENT_ID = "xxxx"
NAVER_CLIENT_SECRET = "yyyy"
NAVER_REDIRECT_URI = "https://your-app.streamlit.app/"

COOKIES_PASSWORD="your_cookie_password"

2.3. 로그인 모듈 (modules/auth_manager.py) 작성

Google과 Naver의 인증 방식을 통합 관리하는 클래스를 작성합니다. requests 라이브러리를 사용하여 직접 토큰을 교환하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# modules/auth_manager.py
import os
import requests
import urllib.parse
import streamlit as st
from dotenv import load_dotenv
import secrets
from streamlit.errors import StreamlitSecretNotFoundError

load_dotenv()

class AuthManager:
    def __init__(self):
 # 로컬 .env 로딩 (Streamlit Cloud에서는 보통 영향 없음)
        load_dotenv()

 # Streamlit Cloud에서는 st.secrets가 정석
        def get_secret(key: str):
            # 1) Streamlit secrets가 존재하는 환경(Cloud 등)에서는 secrets 우선
            try:
                if hasattr(st, "secrets") and key in st.secrets:
                    return st.secrets[key]
            except StreamlitSecretNotFoundError:
                # secrets.toml 자체가 없는 로컬 환경이면 여기로 떨어짐
                pass
            return os.getenv(key)

 # Google 설정
        self.google_client_id = get_secret("GOOGLE_CLIENT_ID")
        self.google_client_secret = get_secret("GOOGLE_CLIENT_SECRET")
        self.google_redirect_uri = get_secret("GOOGLE_REDIRECT_URI")
        
        # Naver 설정
        self.naver_client_id = get_secret("NAVER_CLIENT_ID")
        self.naver_client_secret = get_secret("NAVER_CLIENT_SECRET")
        self.naver_redirect_uri = get_secret("NAVER_REDIRECT_URI")

    def _require(self, **kwargs):
        missing = [k for k, v in kwargs.items() if not v]
        if missing:
            raise ValueError(f"필수 설정 누락: {', '.join(missing)}")

    def get_google_auth_url(self):
        """Google 로그인 URL 생성"""

 self._require(
            GOOGLE_CLIENT_ID=self.google_client_id,
            GOOGLE_REDIRECT_URI=self.google_redirect_uri,
        )

        params = {
            "client_id": self.google_client_id,
            "redirect_uri": self.google_redirect_uri,
            "response_type": "code",
            "scope": "openid email profile",
            "access_type": "offline",
            "prompt": "consent"
        }
        return f"https://accounts.google.com/o/oauth2/v2/auth?{urllib.parse.urlencode(params)}"

    def get_naver_auth_url(self):
        """Naver 로그인 URL 생성"""

 self._require(
            NAVER_CLIENT_ID=self.naver_client_id,
            NAVER_REDIRECT_URI=self.naver_redirect_uri,
        )

 # state를 랜덤 생성 + 세션에 저장(콜백에서 검증)
        state = secrets.token_urlsafe(16)
        st.session_state["naver_oauth_state"] = state

        params = {
            "client_id": self.naver_client_id,
            "redirect_uri": self.naver_redirect_uri,
            "response_type": "code",
            "state": state
        }
        return f"https://nid.naver.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"

    def authenticate_google(self, code):
        """Google 인증 코드로 사용자 정보 가져오기"""
        # 1. 토큰 교환
        token_url = "https://oauth2.googleapis.com/token"
        data = {
            "code": code,
            "client_id": self.google_client_id,
            "client_secret": self.google_client_secret,
            "redirect_uri": self.google_redirect_uri,
            "grant_type": "authorization_code"
        }
        res = requests.post(token_url, data=data)
        if res.status_code != 200:
            return None
        
        token_info = res.json()
        access_token = token_info.get("access_token")

        # 2. 사용자 정보 조회
        user_info_url = "https://www.googleapis.com/oauth2/v1/userinfo"
        headers = {"Authorization": f"Bearer {access_token}"}
        user_res = requests.get(user_info_url, headers=headers)
        
        if user_res.status_code == 200:
            return user_res.json() # {id, email, name, picture...}
        return None

    def authenticate_naver(self, code, state):
        """Naver 인증 코드로 사용자 정보 가져오기"""

 # state 검증(권장)
        expected = st.session_state.get("naver_oauth_state")
        if expected and state != expected:
            return None

        self._require(
            NAVER_CLIENT_ID=self.naver_client_id,
            NAVER_CLIENT_SECRET=self.naver_client_secret,
        )

        # 1. 토큰 교환
        token_url = "https://nid.naver.com/oauth2.0/token"
        params = {
            "grant_type": "authorization_code",
            "client_id": self.naver_client_id,
            "client_secret": self.naver_client_secret,
            "code": code,
            "state": state
        }
        res = requests.get(token_url, params=params, timeout=10)
        if res.status_code != 200:
            return None
            
        token_info = res.json()
        access_token = token_info.get("access_token")
 if not access_token:
            return None

        # 2. 사용자 정보 조회
        user_info_url = "https://openapi.naver.com/v1/nid/me"
        headers = {"Authorization": f"Bearer {access_token}"}
        user_res = requests.get(user_info_url, headers=headers, timeout=10)
        
        if user_res.status_code == 200:
            return user_res.json().get('response') # {id, email, name, profile_image...}
        return None

2.4. ui/login_page.py 작성 (로그인 화면 UI)

사용자가 로그인을 하지 않았을 때 보여줄 화면입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# ui/login_page.py
import streamlit as st

def render_login_page(auth_manager):
    """
    로그인 버튼이 있는 화면을 렌더링합니다.
    """
    st.markdown(
        """
        <style>
        .login-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            margin-top: 100px;
            padding: 50px;
            border-radius: 10px;
            background-color: #f0f2f6;
        }
        .login-btn {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border-radius: 5px;
            text-decoration: none;
            color: white;
            text-align: center;
            font-weight: bold;
            display: block;
        }
        .google { background-color: #DB4437; }
        .naver { background-color: #03C75A; }
        h1 { text-align: center; }
        </style>
        """,
        unsafe_allow_html=True
    )

    col1, col2, col3 = st.columns([1, 2, 1])
    
    with col2:
        st.markdown("<h1>🔐 로그인</h1>", unsafe_allow_html=True)
        st.write("서비스를 이용하려면 로그인이 필요합니다.")
        
        # Google 로그인 버튼
 try:
        	google_url = auth_manager.get_google_auth_url()
 	st.link_button("Google 계정으로 로그인", google_url)
 except Exception as e:
st.error(f"Google 로그인 설정 오류: {e}")
        
        # Naver 로그인 버튼
        try:
        	naver_url = auth_manager.get_naver_auth_url()
       st.link_button("Naver 계정으로 로그인", naver_url)
 except Exception as e:
 	st.error(f"네이버 로그인 설정 오류: {e}")

2.5. main.py에 로그인 로직 통합 (접근 제어)

메인 앱 실행 전에 로그인 상태를 확인하고, 로그인이 필요하면 로그인 버튼을 표시합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# main.py
import os
import json
import time
import streamlit as st
# ... 기존 import 유지 ...
from modules.auth_manager import AuthManager # 추가
from ui.login_page import render_login_page  # 추가
from streamlit_cookies_manager import EncryptedCookieManager
from streamlit.errors import StreamlitSecretNotFoundError
from dotenv import load_dotenv

load_dotenv()

# ... 설정 코드 ...

def main():
    def get_secret(key: str, default=None):
        try:
            if key in st.secrets:
                return st.secrets.get(key, default)
        except StreamlitSecretNotFoundError:
            pass
        
        return os.getenv(key, default)

    # --- 쿠키 매니저 (반드시 초반) ---
    password = get_secret("COOKIES_PASSWORD")
    if not password:
        st.error("❌ COOKIES_PASSWORD가 설정되지 않았습니다. 관리자에게 문의하세요.")
        st.stop()

    cookies = EncryptedCookieManager(
        prefix="stock-trading-app/",  # 앱 고유 prefix
        password=password
    )

    if not cookies.ready():
        st.stop()  # 쿠키 컴포넌트 준비될 때까지 대기

    # -----------------------------------------------------
    # [1] 로그인 세션 관리
    # -----------------------------------------------------
    if 'user_info' not in st.session_state:
        st.session_state['user_info'] = None
    
    # --- ✅ 새로고침(F5) 후에도 쿠키에서 로그인 복원 ---
    if st.session_state['user_info'] is None and cookies.get("user_info"):
        try:
            st.session_state['user_info'] = json.loads(cookies["user_info"])
        except Exception:
            # 쿠키가 깨졌거나 형식이 이상하면 지움
            del cookies["user_info"]
            cookies.save()

    auth_manager = AuthManager()

    # URL 쿼리 파라미터 확인 (로그인 후 리다이렉트 되었을 때)
    # Streamlit 최신 버전은 st.query_params 사용
    query_params = st.query_params
    
    # 로그인 처리 로직
    if st.session_state['user_info'] is None:
        # A. Google 로그인 콜백
        if "code" in query_params and "state" not in query_params: # Google은 state 필수가 아님(설정 안했을 시)
            code = query_params["code"]
            user_info = auth_manager.authenticate_google(code)
            if user_info:
                st.session_state['user_info'] = user_info
                # 쿠키에도 저장
                cookies["user_info"] = json.dumps(user_info, ensure_ascii=False)
                cookies.save()
                st.query_params.clear() # URL 파라미터 청소
                st.rerun() # 새로고침
        
        # B. Naver 로그인 콜백
        elif "code" in query_params and "state" in query_params:
            code = query_params["code"]
            state = query_params["state"]
            user_info = auth_manager.authenticate_naver(code, state)
            if user_info:
                st.session_state['user_info'] = user_info
                # 쿠키에도 저장
                cookies["user_info"] = json.dumps(user_info, ensure_ascii=False)
                cookies.save()
                st.query_params.clear()
                st.rerun()
        
        # C. 로그인 화면 표시
        render_login_page(auth_manager)
        return # 메인 앱 실행 중단

    # -----------------------------------------------------
    # [2] 메인 앱 실행 (로그인 성공 시)
    # -----------------------------------------------------
    user = st.session_state['user_info']
    
    # 사이드바에 사용자 정보 표시
    with st.sidebar:
        st.write(f"👋 환영합니다, **{user.get('name', 'User')}**님!")
        if st.button("로그아웃"):
            st.session_state['user_info'] = None

            # 쿠키에서도 삭제
            if cookies.get("user_info"):
                del cookies["user_info"]
                cookies.save()

            st.rerun()
        st.divider()

    # ... (여기서부터 기존 main() 함수의 나머지 로직 실행) ...
    # config = render_sidebar() ...

작동 방식

  1. 초기 접속: session_state['user_info']가 없으므로 로그인 버튼이 있는 화면이 뜹니다.
  2. 버튼 클릭: Google 또는 Naver의 인증 페이지로 이동합니다.
  3. 인증 및 리다이렉트: 사용자가 로그인하면 http://localhost:8501/?code=... 형태로 다시 돌아옵니다.
  4. 토큰 교환: main.pycode 파라미터를 감지하고 auth_manager를 통해 실제 유저 정보를 받아옵니다.
  5. 세션 저장: 유저 정보를 session_state에 저장하고 화면을 st.rerun()하여 URL을 깨끗하게 만들고 메인 대시보드를 보여줍니다.

    로컬에서 테스트할 때는 정상적으로 동작했는데 Streamlit에 배포하고 테스트하니 Naver에서 연결을 거부했습니다.

    ✅ 네이버 로그인 페이지가 “iframe(프레임) 안에서 열려고 해서” 네이버가 차단한 것
    (Naver는 보안 때문에 X-Frame-Options/CSP로 로그인 페이지를 프레임에 못 띄우게 막아놔서, 프레임 안에서 열리면 브라우저가 “연결 거부”로 보여준다고 합니다.)
    → “Naver 계정 로그인” 버튼의 타겟은 “_blank”로 수정해줘야 합니다.
    아니면 Streamlit에서 제공하는 link 버튼을 사용하면 됩니다.

3. 개발 일정 (소셜 로그인 기능)

본 일정은 기존 개발 일정에 추가되는 내용입니다.

단계시작일완료일주요 작업 내용
소셜 API 키 발급DateDateGoogle, Naver 개발자 등록 및 클라이언트 ID/Secret 발급
인증 모듈 구현DateDatemodules/auth.py 작성, OAuth 인증 코드 및 토큰 교환 로직 구현
UI 및 세션 통합DateDatemain.py에 접근 제어 및 st.session_state 기반 로그인 상태 관리 구현
통합 및 테스트DateDate배포 환경 (REDIRECT_URI)에서 최종 로그인 및 접근 제어 테스트

4. 연락처 및 참고 사항

소셜 로그인 기능 추가에 대한 문의 사항은 Person 또는 백흠경 에게 연락 주시기 바랍니다.

File (Google OAuth 2.0 공식 문서)를 참고하여 개발을 진행합니다.

로그인 기능 추가 완료 및 시연 미팅은 Calendar event 일정에 진행될 예정입니다.