
병원에서도 사용할 수 있는 로컬 LLM RAG 시스템을 만들어 보았다【보안 편】
요약
개인정보 보호가 중요한 병원 환경을 위해 로컬 LLM 기반의 RAG 시스템을 구축하고, 보안 취약점을 최소화하기 위한 다양한 보안 대책을 다룹니다. 패스워드 해싱, JWT 인증, PDF 암호화 등 실질적인 보안 구현 방법을 코드를 통해 설명합니다.
핵심 포인트
- 개인정보 유출 방지를 위해 외부 서버 전송이 없는 로컬 LLM 활용
- bcrypt 알고리즘을 이용한 안전한 패스워드 해싱 구현
- JWT 인증 및 토큰 블랙리스트를 통한 사용자 인증 보안 강화
- 데이터 암호화 및 감사 로그, HTTPS 적용 등 다각도 보안 대책 제시
병원에서 시스템 개발을 할 때, 환자의 개인정보가 유출되는 것은 절대로 피해야 합니다. 시스템을 완성하는 것으로 끝이 아닙니다. 보안 취약점이 있다면 개인정보 유출 리스크가 발생합니다.
이번에 로컬 LLM (Local LLM)을 사용한 이유도, 환자의 정보를 다루게 될 경우 외부 서버로 전송되지 않고 병원 내 네트워크 안에 머물게 하여 개인정보 유출 리스크를 억제할 수 있다는 이유가 있었습니다.
하지만 이것만으로는 만전이 아닙니다. 만약 해킹을 당한다면? PC에 침입당한다면? 로컬 LLM을 사용하고 있더라도 개인정보는 유출될 가능성이 있습니다.
그 리스크를 한없이 줄이기 위해, 이번에는 다음과 같은 보안 대책을 포함했습니다.
- 패스워드 해싱 (Hashing)
- JWT 인증 · 토큰 · 토큰 블랙리스트
- 읽어들인 PDF 암호화
- 감사 로그 (Audit Log)
- HTTPS화
- CORS Middleware (요청 제한)
최근 AI 코딩이 주류가 되어가는 시대에, AI가 생성한 코드에 보안 취약점이 없는지 확인하기 위해서라도 최소한의 보안 지식은 필요하다고 생각합니다.
그럼, 하나씩 코드를 곁들여 설명하겠습니다.
패스워드 해싱
시스템을 사용하는 사용자를 관리하기 위해 ID와 패스워드를 입력하여 사용자를 관리하겠지만, 이것만으로는 완벽하지 않습니다.
만약 PC에 침입당해 DB를 보게 될 경우, 패스워드가 유출되어 시스템 내부로 침입당하게 됩니다.
그래서 패스워드를 불가역적인 불규칙 문자열(해시값)로 변환하여 DB에 저장하는 기술을 **해싱 (Hashing)**이라고 합니다. 이번에는 Python에서 사용할 수 있는 passlib.context라는 라이브러리를 사용하여 해싱을 구현했습니다.
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
schemes=["bcrypt"]는 사용할 해싱 알고리즘을 지정하고 있습니다. bcrypt는 패스워드 해싱의 업계 표준적인 알고리즘으로, 계산에 시간을 들임으로써 무차별 대입 공격 (Brute-force attack)에 대한 내성을 갖추고 있습니다. deprecated="auto"는 향후 더 새로운 알고리즘으로 전환했을 때, 오래된 bcrypt 형식의 해시를 감지하면 자동으로 새로운 형식으로 재해싱해 주는 설정입니다.
이것으로 초기화가 완료되었으므로, 남은 것은 프론트엔드에서 신규 등록으로 보내온 패스워드를 pwd_context를 사용하여 해싱하는 것입니다.
hashed_password = pwd_context.hash(user_data.password)
이 () 안이 프론트엔드에서 보내온 생(raw) 패스워드입니다.
그럼, 다음 로그인 시에 이 해싱된 패스워드와 입력된 패스워드가 동일한지 검증해야 합니다.
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
로그인 시 프론트엔드에서 보내오는 것은 생 패스워드 (plain_password)입니다. 이것을 DB에 저장되어 있는 해싱된 패스워드 (hashed_password)와 그대로 문자열 비교할 수는 없습니다. pwd_context.verify()는 내부에서 plain_password를 동일한 방식으로 해싱하여 hashed_password와 일치하는지 확인합니다. 일치하면 True, 일치하지 않으면 False를 반환합니다.
JWT 인증 · 토큰 · 블랙리스트
먼저 토큰에 대한 설명부터 하겠습니다.
이번에 토큰이 어떤 역할을 하고 있냐면, 우선 로그인했을 때 해당 사용자에게 토큰이 발행됩니다.
여기서 토큰은 이해하기 쉽게 말하자면 "그 시스템을 작동시키기 위한 열쇠"라고 생각하시면 됩니다. 이 토큰을 소지하고 있지 않으면 이 시스템에서 API 요청 등을 할 수 없습니다.
그리고 로그아웃했을 때 그 토큰을 파기하지 않으면, 만약 토큰이 유출되었을 경우 그 토큰을 사용할 수 있는 상태 그대로 시스템을 작동시킬 수 있게 됩니다.
그래서 로그아웃했을 때, 그 토큰을 블랙리스트 (Blacklist)에 넣습니다. 이렇게 하면 다음에 API를 요청할 때, 블랙리스트에 동일한 토큰이 있다면 그 토큰은 무효화되어 시스템 사용이 제한됩니다. 간단히 말해 "사용 완료된 열쇠를 무효화하는 메커니즘"입니다.
이번에는 Python에서 사용할 수 있는 jose라는 라이브러리를 사용했습니다.
from jose import JWTError, jwt
FastAPI의 POST 엔드포인트 (Endpoint)에서는 다음과 같이 기술됩니다.
access_token = create_access_token(data={"sub": form_data.username})
프론트엔드에서 데이터 (data)로서 사용자 이름이 전송되므로, 그것을 인자로 하여 함수를 작성해 나갑니다.
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
...
위에서부터 순서대로 설명하겠습니다.
먼저 프론트엔드에서 전송된 data를 복사하여 to_encode라는 변수에 넣습니다. 다음으로 어떤 토큰으로 만들지 설정합니다. ACCESS_TOKEN_EXPIRE_MINUTES는 미리 env 파일에서 설정한 시간으로, 이번에는 480분으로 지정했습니다. 이 480분이 토큰의 유효 기간입니다.
그리고 to_encode를 expire로 업데이트합니다.
마지막으로 토큰을 발행합니다. jwt.encode에 방금 업데이트한 to_encode, env 파일에서 설정한 SECRET_KEY, 알고리즘을 전달하여 실행하는 흐름입니다.
여기서 SECRET_KEY를 사용하는 이유는, 누구나 권한 없이 토큰을 발행할 수 있도록 악용될 우려가 있기 때문이며, 토큰 발행에는 어디까지나 권한이 필요하다는 의미가 됩니다.
이것으로 토큰 발행은 완료되었습니다. 그럼 이 토큰을 어떻게 사용하는지 살펴보겠습니다.
이 토큰의 역할은 현재 사용자 (current_user)가 이 시스템을 사용할 권한을 가지고 있는지 확인하는 것입니다.
먼저 함수를 작성하기 전에, 프론트엔드에서 보내온 요청 (Request)에서 토큰을 추출합니다.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
이것은 "토큰을 발행하는" 것이 아니라, 요청의 Authorization 헤더 (Header)에서 토큰을 추출하는 도구입니다. tokenUrl="token"은 "토큰을 발행하는 엔드포인트는 여기입니다"라는 위치 지정으로, Swagger UI 등의 문서 표시를 위해 사용됩니다. 실제 추출 처리는 Depends(oauth2_scheme)로서 함수의 인자로 전달함으로써, FastAPI가 자동으로 헤더에서 토큰 문자열을 추출하여 전달해 줍니다.
그럼 함수를 작성해 나가겠습니다.
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
if token in token_blacklist:
...
먼저 프론트엔드에서 전송된 토큰이 블랙리스트에 없는지 확인하고, 있다면 에러를 발생시킵니다.
블랙리스트에 없다면 처리를 실행하며, 우선 토큰을 디코드 (Decode, 복호화)합니다. JWT의 페이로드 (Payload, 내용물)는 딕셔너리 (Dictionary) 형식으로 되어 있으며, 토큰 발행 시 {"sub": 사용자 이름}이라는 형태로 sub 키에 사용자 이름을 넣었기 때문에, payload.get("sub")로 해당 sub 키에 대응하는 값, 즉 사용자 이름을 추출할 수 있습니다. 추출한 사용자 이름이 존재하는지 확인하고, 문제가 없다면 return으로 username을 반환합니다.
읽어들인 PDF의 암호화
먼저 왜 PDF의 암호화가 필요하다고 생각했냐면, 만약 이 RAG 시스템에서 외부에 유출되면 안 되는 기밀 정보를 RAG로 사용할 경우, 읽어들인 PDF를 그대로 두면 PC에 침입당했을 때 그 PDF를 보게 됩니다.
여기서 암호화를 해둠으로써, 만약 PC에 침입당하더라도 PDF의 내용은 볼 수 없으므로 안심할 수 있습니다.
그럼 코드를 통해 설명해 드리겠습니다. 필요한 함수는 두 가지가 있습니다. PDF를 암호화하는 함수와, 암호화된 PDF를 복호화하는 함수입니다. 주로 이 함수들은 2차에서 설명한 upload-pdf의 FastAPI 엔드포인트에서 사용됩니다.
from cryptography.fernet import Fernet
fernet = Fernet(ENCRYPTION_KEY)
def encrypt_file(file_path: str):
...
위부터 설명해 드리겠습니다.
PDF 암호화에는 cryptography.fernet이라는 라이브러리를 사용합니다. 먼저 Fernet을 초기화합니다. 토큰 발행과 마찬가지로, Fernet을 사용할 때는 env 파일의 ENCRYPTION_KEY를 사용해야 합니다. 이 KEY가 없으면 암호화나 복호화가 불가능합니다.
업로드된 PDF의 경로를 인수로 받아 함수를 만듭니다. with open은 2차에서도 설명했지만, 파일 열기 방식을 `
def write_audit_log(username: str, action: str, detail: str, ip_address: str = ""):
try:
conn = pyodbc.connect(connection_string)
...
먼저 SQL Server에 기록하고 싶은 데이터를 인수로 받습니다. username은 누가, action은 무엇을 했는지(로그인이나 PDF 업로드 등), detail은 그 내용의 상세 정보, ip_address는 어디에서 접속했는지를 나타냅니다. ip_address에는 기본값으로 빈 문자열을 설정했으므로, 호출하는 쪽에서 생략할 수도 있습니다.
pyodbc.connect(connection_string)를 통해 방금 작성한 연결 문자열(connection string)을 사용하여 SQL Server에 접속합니다. conn.cursor()는 SQL을 실행하기 위한 입구와 같은 역할을 합니다.
cursor.execute()로 실제로 SQL을 실행합니다. INSERT INTO audit_logs (...) 부분은 SQL 문이며, audit_logs라는 테이블에 새로운 행을 추가하라는 명령입니다. VALUES (?,?,?,?)의 ?는 플레이스홀더 (placeholder)라고 불리는 것으로, 실제 값(username, action, detail, ip_address)을 문자열에 그대로 삽입하지 않고 별도로 전달하는 방식입니다. 이는 SQL 인젝션 (SQL Injection) 공격을 방지하기 위한 작성법입니다.
conn.commit()으로 INSERT한 내용을 실제로 데이터베이스에 반영합니다. SQL Server뿐만 아니라 데이터를 변경하는 작업(INSERT, UPDATE, DELETE)은 이 commit()을 호출하지 않으면 반영되지 않습니다. 마지막으로 conn.close()로 접속을 종료합니다.
만일 DB 접속이나 쓰기에 실패할 경우, except pyodbc.Error as e로 에러를 캐치하고 print로 로그를 출력하도록만 구성했습니다. 감사 로그(audit log) 쓰기에 실패했다고 해서 PDF 업로드나 RAG 처리 자체를 중단해 버리면 시스템 전체의 편의성이 저해될 수 있기 때문입니다.
HTTPS화
HTTPS화가 필요한 이유는 통신 경로상에서의 데이터 도청 및 변조를 방지하기 위해서입니다. HTTP 상태로 두면 프론트엔드와 백엔드 사이에서 주고받는 로그인 정보나 질문 내용, PDF 데이터가 통신 경로상에서 제3자에게 엿보일 가능성이 있습니다. 병원 내 네트워크 안에서 완결된다고는 하지만, 같은 네트워크에 접속된 다른 단말기에서 통신을 가로챌 리스크가 제로(0)는 아닙니다.
HTTPS화를 위해서는 인증서가 필요합니다. 인증서에는 크게 두 종류가 있습니다.
자기 서명 인증서 (Self-signed certificate) (이번에 채택)
→ 스스로 작성한 인증서
→ 브라우저로 접속하면 경고가 표시됨
...
이번에는 병원 내 네트워크에서의 이용을 상정하고 있으므로, 자기 서명 인증서를 사용했습니다.
인증서 생성
Python의 pyOpenSSL 라이브러리를 사용하여 인증서를 생성합니다.
pip install pyopenssl
from OpenSSL import crypto
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 4096)
...
crypto.PKey()로 키 쌍(공개키·비밀키)을 생성할 준비를 하고, generate_key로 RSA 방식, 4096비트 키를 생성합니다. crypto.X509()로 인증서 본체를 작성하고, CN (Common Name)에 localhost를 설정합니다. set_serial_number는 인증서 식별 번호이며, gmtime_adj_notBefore와 gmtime_adj_notAfter는 각각 인증서 유효 기간의 시작과 종료를 설정합니다. 이번에는 365일간 유효하도록 설정했습니다.
마지막으로 인증서(cert.pem)와 비밀키(key.pem)를 각각 파일로 출력합니다. cert.pem은 브라우저에 전달하는 공개 정보이며, key.pem은 외부에 유출해서는 안 되는 비밀키입니다.
FastAPI 측의 HTTPS화
생성한 인증서를 사용하여 uvicorn을 HTTPS로 실행합니다.
uvicorn main:app --ssl-keyfile=key.pem --ssl-certfile=cert.pem
--ssl-keyfile와 --ssl-certfile에 방금 생성한 파일을 지정하기만 하면 됩니다. 이렇게 하면 FastAPI 서버에 https://로 접속할 수 있게 됩니다.
프론트엔드 측의 HTTPS화
React의 Vite에서도 마찬가지로 HTTPS화가 필요합니다. @vitejs/plugin-basic-ssl이라는 플러그인을 사용하면 자동으로 인증서를 생성하여 HTTPS화할 수 있습니다.
import basicSsl from '@vitejs/plugin-basic-ssl'
export default defineConfig({
plugins: [react(), basicSsl()]
...
이로써 프론트엔드와 백엔드 모두 HTTPS로 통신할 수 있는 상태가 되었습니다. 자기 서명 인증서(Self-signed certificate)이기 때문에 브라우저로 접속하면 "보호되지 않은 통신"이라는 경고가 표시되지만, 이는 자기 서명 인증서 사용 시 발생하는 정상적인 동작입니다. "고급 설정"을 통해 접속할 수 있습니다.
CORSMiddleware (요청 제한)
마지막으로 CORSMiddleware입니다. CORS (Cross-Origin Resource Sharing)는 서로 다른 오리진 (URL의 조합) 간의 요청을 제어하는 브라우저의 보안 기능입니다.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://localhost:3000", "https://localhost:5173"],
...
프론트엔드 (https://localhost:5173)와 백엔드 (https://localhost:8000)는 포트 번호가 다르기 때문에, 브라우저 입장에서는 "다른 오리진"으로 취급됩니다. 브라우저는 기본적으로 보안상의 이유로 다른 오리진으로의 요청을 차단합니다. 이를 허용하기 위한 설정이 CORSMiddleware입니다.
allow_origins에는 요청을 허용할 오리진을 리스트로 지정합니다. 여기에 기재되지 않은 URL로부터의 요청은 브라우저 측에서 차단됩니다. 예를 들어 외부의 악의적인 사이트에서 이번 시스템의 API에 접근하려고 해도, allow_origins에 등록되지 않은 URL이라면 브라우저가 해당 요청을 통과시키지 않습니다.
allow_methods=["*"]는 GET, POST, PUT, DELETE 등 모든 HTTP 메서드를 허용하는 설정입니다. allow_headers=["*"] 역시 마찬가지로 어떤 헤더든 허용하는 설정입니다.
이 설정을 통해 정해진 프론트엔드 URL로부터의 요청만 수락하고, 그 외의 접근을 제한할 수 있습니다. 병원 내의 특정 단말기나 특정 프론트엔드에서만 시스템을 이용할 수 있도록 하는 의미에서도 유효한 보안 대책입니다.
요약
이번에는 의료 RAG 시스템에 포함된 보안 대책에 대해 코드를 곁들여 해설했습니다.
- 비밀번호 해싱 (Hashing): bcrypt를 사용하여 불가역적인 형태로 변환하여 DB에 저장
- JWT 인증 · 토큰 · 블랙리스트: 로그인 상태 관리 및 로그아웃 시 즉시 무효화
- PDF 암호화: Fernet으로 디스크 상의 PDF를 암호화하여 침입 시 리스크 저감
- 감사 로그 (Audit Log): 언제, 누가, 무엇을 했는지를 SQL Server에 기록
- HTTPS화: 통신 경로상의 도청 및 변조 방지
- CORSMiddleware: 허가된 프론트엔드로부터의 요청만 수락
이것들은 특별히 고도의 기술이라기보다, 웹 애플리케이션 개발에 있어서의 기본적인 보안 대책입니다. 다만 의료 현장의 시스템인 만큼, 이러한 기본을 하나하나 정성스럽게 쌓아 올리는 것이 환자의 정보를 지키는 것과 직결된다고 생각합니다.
총 3회에 걸쳐 의료 종사자가 로컬 LLM과 RAG를 사용한 시스템을 만드는 프로세스를 해설해 왔습니다. 읽어주셔서 감사합니다. 질문이나 지적 사항이 있다면 댓글로 알려주시면 감사하겠습니다.
지금까지 3회에 걸쳐 시스템의 구성, 구현, 보안에 대해 해설해 왔습니다만,
기사마다 코드의 일부를 발췌하여 설명하는 방식이었기 때문에,
실제로 직접 손을 움직여 만들려고 하면, 환경 구축 (Environment Setup) 절차나 코드의 순서가
이해하기 어려운 부분도 있었을 것이라 생각합니다.
그래서 제4회로서, 환경 구축부터 구현까지 일련의 흐름으로
통합하여 해설하는 기사를 추가로 작성할 예정입니다. 시간이 조금 걸릴 수도 있습니다만,
공개되었을 때 꼭 읽어주시면 감사하겠습니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기