회사에서의 직무는 Researcher지만, 때로는 Engineer의 직무를 수행할 때도 있는데
회사의 도메인 특성상 폐쇄망 환경으로 지원을 나갈 때가 많다.
그러나 폐쇄망 환경에서도 다른 사이트에 붙어서 테스트를 해야하거나, 개발을 해야 할 때가 있는데
이를 위해 스마트폰의 인터넷을 끌어다가 노트북에 연결해서 써야한다.
아래의 글은 Windows OS 노트북이 아닌, MacOS Tahoe를 기준으로 서술하고 있다.

핸드폰에서 노트북에 테더링하기

핸드폰에서 노트북으로 데이터를 테더링하기 위해서는 크게 3가지 방법이 있는데,

  • 모바일 핫스팟(Wi-Fi)
  • 블루투스 테더링
  • USB 테더링

방식으로 이용할 수 있다.모바일 핫스팟(Wi-Fi)폐쇄망 환경이지만 와이파이를 지원한다(보다는 특별히 제재하지 않는다)고 하면, 가장 좋은 방법은 모바일 핫스팟으로 두 디바이스를 연결하는 것이다. 접속 방식은 다음과 같다.
스마트폰이 무선 라우터(AP, Access Point) 역할을 수행한다.

  • 동작 원리
    • 스마트폰의 Wi-Fi 모듈이 '클라이언트 모드'(수신)에서 'AP 모드'(송신)으로 전환된다 : 이 이유 때문에 핸드폰에서는 모바일 핫스팟과 와이파이 연결을 동시에 수행할 수 없다.
    • 스마트폰은 자체적인 SSID를 브로드캐스팅하고, DHCP 서버를 구동해서 맥북에 사설IP를 할당한다.
    • 맥북 입장에서는 일반적인 공유기에 연결된 것과 동일한 en0(Wi-Fi 인터페이스)를 사용한다.
  • 인터페이스 특징
    • 프로토콜 : 802.11 표준(a/b/g/n/ac/ax)을 따른다.
    • 대역폭(bandwidth) : 매우 높음. 5GHz 대역 사용 시 간섭이 적고 빠르다.
    • 오버헤드 : 무선 신호의 암호화/복호화(WPA2/3) 과정과 전파 간섭 관리가 필요하므로 스마트폰 CPU와 배터리 소모가 가장 심하다.

그러나, 모든 환경이 와이파이를 지원하지는 않는데, 예를 들면 금융권같은 곳은 건물 자체를 공인된 wi-fi말고는 동작하지 않게 해놓은 곳도 있다. 이는 대부분 데이터 유출을 방지하기 위해 WIPS로 와이파이 환경을 통제해놓기 때문이다.

와이파이 방어자 WIPS

WIPS(Wireless Instruction Prevention System) 는 은행이나 보안 시설에서 특정 Wi-Fi(사내망)는 허용하면서, 개인 핫스팟이나 외부 공유기(Rogue AP)를 차단하는 기술의 핵심이다. 단순히 신호를 방해(Jamming)하는 것이 아니라, Wi-Fi(IEEE 802.11) 프로토콜의 취약점을 역이용하여 논리적으로 연결을 끊어버리는 방식을 사용한다.

탐지(Scanning & Classification)
건물에 달려 있는 무선 AP들 중 일부는 데이터를 전송하지 않고 센서모드로 동작하며 주변 패킷을 스니핑한다.

  • 화이트리스트 기반 관리
    • 사내 공인AP들의 BSSID(MAC Address)SSID 는 시스템에 등록해놓는다.
  • Rogue AP 탐지
    • 센서가 등록되지 않은 새로운 BSSID(예 : 개인의 스마트폰 핫스팟)가 비콘 프레임(Beacon Frame)을 날리는 것을 감지한다.
    • 스마트폰 제조사의 OUI(MAC 주소 앞 3자리)를 분석해서 사내의 AP가 아니라 Samsung / Apple의 Mobile Device임을 식별한다.
    • 이 기기가 사내 네트워크 범위 내에 있다고 판단되면 인가되지 않은 AP(Rogue AP) 로 분류하고 차단 모드로 진입한다.

Beacon Frame?
비콘 프레임은 무선 AP가 자신이 관리하는 무선랜(BSS)의 존재를 정기적으로 알리는 브로드캐스트 프레임으로, 스테이션이 무선 네트워크를 찾도록 도와주는 역할을 수행한다.
IEEE 802.11 관리 프레임(Frame Type이 00)중 하나로, 비콘 프레임에 BSSID 정보가 들어있고, 프레임 바디에 SSID 정보가 들어있다.

아래는 일반적인 Wi-Fi 프레임 구조이다.

차단(Deauthentication Attack)
전파를 물리적으로 덮어버리는 재밍(Jamming)은 불법 소지가 크고 통신 장애를 일으키므로, WIPS는 Deauth 패킷을 이용한 정밀 타격 방식을 사용한다.

  • 동작 원리
    1. 맥북(클라이언트)이 스마트폰(핫스팟)에 연결을 시도한다
    2. WIPS 센서가 헨드셰이크 과정을 감지한다.
    3. WIPS 센서가 스마트폰인 척 위장하여 맥북에게 "연결을 끊어라"라는 관리 프레임을 보낸다.
    4. 동시에 WIPS 센서가 맥북인 척 위장해서 스마트폰에게도 "연결을 끊어라"라는 Deauthentication 관리 프레임을 보낸다.
  • 결과
    • 맥북과 스마트폰은 서로가 연결을 끊었다고 착각하게 된다.
    • 사용자는 Wi-Fi 목록에는 핫스팟이 뜨지만, '연결할 수 없음'이 뜨거나, 연결되자마자 즉시 끊어지는 현상이 무한 반복된다.
    • 이 방식은 Wi-Fi 규약 상 관리 프레임이 암호화되지 않는 경우가 많다는 점을 이용한 것이다. (최근 WPA3와 802.11w 표준에서는 이를 방어하는 PMF 기능이 강화되었지만, 여전히 하위 호환성 때문에 뚫리는 경우가 많다.)

이 외에도, 조금 더 보안이 중요한 곳은 물리적으로 단말기 제어(MDM)를 할 수도 있다.

블루투스 테더링

블루투스 테더링은 WIPS의 레이더를 피하면서도, 아래에 서술할 USB 연결보다 번거롭지 않게 블루투스 테더링을 잘 사용할 수 있다.
이 방법의 특징은, 저전력 연결을 위해 설계되었으며, PAN(Personal Area Network) 프로파일을 사용한다.

  • 동작 원리
    • 블루투스 스택의 BNEP(Bluetooth Network Encapsulation Protocol)을 사용한다.
    • Wi-Fi처럼 1:N 연결이 가능하긴 하나(Piconet), 한계점이나 사용 용도의 특성으로 인해 보통 1:1 연결을 사용한다.
    • 맥북 입장에서는 Bluetooth PAN이라는 별도의 네트워크 인터페이스를 통해 통신한다.
  • 인터페이스 특징
    • 대역폭 : 매우 낮다(보통 1~2 Mbps 수준). 웹 서핑보다는 텍스트 전송, 메신저, 백그라운드 동기화에 적합하다.
    • 지연 시간(Latency) : Wi-Fi보다 길다(블루투스 특성상 주파수 호핑 방식을 사용하기 때문)
    • 전력 효율 : Wi-Fi 핫스팟 대비 전력 소모가 현저히 적어서 장시간 연결 유지에 유리하다.블루투스 테더링은 어떻게 WIPS의 레이더를 피할까?위에 기술했듯 WIPS의 핵심 무기는 '정밀 타격'으로 'Deauthentication Frame'을 쏘는 것이다.
      그러나 이 기술은 Wi-Fi규약에 정의된 패킷이기에, 블루투스 통신 규약에는 Wi-Fi의 Deauth 프레임을 처리하는 로직이 없기 때문에 블루투스 기기는 이 신호를 전혀 이해하지 못하고, 계속 데이터를 주고받는다.

또한, 블루투스의 '주파수 호핑(Frequency Hopping)' 원리 때문에, WIPS는 블루투스를 잡을 수 없다.

  • Wi-Fi는 특정 채널(예 : 2.4GHz의 6번 채널)에 고정되어 데이터를 주고받는다. WIPS는 그 채널만 노려보면 된다.
  • 블루투스는 FHSS(Frequency Hopping Spread Spectrum, 주파수 도약 확산 대역) 방식을 사용하기 때문에, 2.4GHz 대역을 79개의 얇은 채널로 쪼갠 뒤, 초당 1,600번 채널을 바꿔가며 통신한다.

따라서, WIPS가 특정 순간에 블루투스 신호를 잡아서 방해하려 해도, 이미 블루투스는 다른 주파수로 이동해버린 상태이다. 이를 막으려면 2.4GHz 대역 전체를 Jamming해야 하는데, 이는 무선법 위반 소지가 있고, 또 다른 디바이스(무선 마우스, 무선 키보드 등)까지 전부 먹통으로 만들기 때문에 현실적으로 불가능하다.
아래의 손오공이 에너지파를 맞지 않는 이유와 비슷하다.

맥북에서의 블루투스 테더링 방식의 한계

그러나, 손오공이 순간이동을 하면서 공격을 하지 못하는것과 마찬가지로 블루투스 연결을 통한 테더링도 한계가 존재한다.

블루투스 테더링 자체가 가지고 있는 한계(Bandwidth, 멀티디바이스 등)도 있지만, 위에 소개했듯 맥북 환경에서 가장 큰 한계는, 맥북이 Apple Silicon(정확히는 MacOS Monterey)으로 오면서 Bluetooth PAN 인터페이스 수동 추가에 대한 지원을 하지 않게 되었다는 점이다.

현재 맥북은 블루투스 테더링의 한계를 받아들임과 동시에 자사 기기간의 연결을 선호하고 있으며, iOS과는 iCloud계정과의 연동과 동시에, Mac은 평소에 BLE로 주변에 내 아이폰이 있는지 저전력으로 스캔을 하고 있다가, iPhone이 핫스팟 연결 버튼을 누르는 동시에 Wi-Fi로 고속 연결을 맺는다.

그러나 iPhone에서는 블루투스 테더링을 지원하고 있는데, 핸드폰은 맥북과는 다르게 애플이 만들지 못하는 여러 제품군이나 Wi-Fi 모듈 없이 블루투스만 지원하는 저전력 장치(차 또는 여러 IoT, 임베디드 장치 등)와의 연동을 해야하는 경우가 많으므로 레거시 프로토콜을 남겨두고 있다.

따라서, 블루투스 연결을 하고자 하는 맥북은 Montery OS보다 레거시 OS를 사용하고 있어야 연결을 할 수 있다. 남은 방법은 USB테더링 뿐이다.

USB 테더링

USB 또한 아래 이유로 WIPS를 피할 수 있다.
WIPS는 Wireless라는 이름 그대로 공중의 전파(Radio Frequency)를 감시하는 장비다. 반면 USB테더링은 유선 케이블을 통해서 전기 신호로 데이터를 주고받는다. 이 신호는 케이블 밖으로 새어나지 않는다. 또한 Point-to-Point 통신만을 수행하므로, 비콘을 쏠 일도 없거니와 스마트폰이 AP로 전환되는것이 아닌, NIC(외장 네트워크 카드)처럼 동작하기 때문에 PC에 따로 에이전트가 설치되지 않는 이상 걸릴 일이 없다.

동작 원리:

  • USB 케이블을 통해 데이터가 전송되지만, 내부적으로는 RNDIS(Remote Network Driver Interface Specification) 또는 CDC-NCM(Communication Device Class - Network Control Model) 프로토콜을 사용해서 Ethernet 프레임을 USB패킷으로 캡슐화한다.
  • 따라서 맥북에서는 물리적인 LAN 케이블을 꽂은 것처럼 인식되어 USB 10/100/1000 LAN과 유사한 가상 인터페이스가 생성된다.
    인터페이스 특징:
  • 프로토콜 : USB 버스 위에서 이더넷 프레임을 터널링한다.
  • 안정성 : 전파 간섭이 전혀 없다.구식 안드로이드 폰과의 USB 테더링 방식의 한계그러나 맥북과 아이폰은 인식이 잘 되는것에 비해, 구식 안드로이드 폰(Android 10 이하)의 경우 맥북에 USB를 꽂아도 연결이 되지 않는다. MacOS는 기본적으로 안드로이드의 USB 테더링 프로토콜(RNDIS, RNDIS는 마이크로소프트에서 만든 표준임)를 완벽하게 지원하지 않기 때문이다. 따라서 CDC-NCM이 지원되는 Android 11 이상의 핸드폰을 사용하거나 HoRNDIS같은 맥북용 오픈소스 드라이버를 사용해야 한다.
    HoRNDIS는 공식적으로는 MacOS X까지밖에 지원하지 않는다(https://joshuawise.com/horndis 참고). 그러나 MacOS Tahoe 기준에서도 정상적으로 작동함을 확인했다.
    HoRNDIS는 macOS의 커널 확장 프로그램(Kernel Extension, kext) 형태로 설치되어 시스템의 가장 깊은 곳에서 동작한다.
    동작 원리는 다음과 같다.

1. RNDIS 패킷 캡슐 해제
안드로이드 스마트폰은 이더넷 프레임(실제 인터넷 데이터)을 RNDIS 헤더로 감싸서 USB 케이블로 보낸다. HoRNDIS 드라이버는 USB 버스를 타고 들어오는 데이터 중 RNDIS 프로토콜을 사용하는 장치를 감지해서, 데이터 앞뒤에 붙은 Microsoft 전용 포장지(RNDIS 헤더)를 뜯어내고, 그 안에 있는 순수한 표준 이더넷 프레임만 추출한다.

 

2. 가상 네트워크 인터페이스 생성
추출한 이더넷 프레임을 MacOS 네트워크 스택에 전달해야 하기 때문에, HoRNDIS는 MacOS 운영체제에게 자신이 '유선 랜카드'라고 거짓말을 한다.
이로 인해 시스템 환경설정의 네트워크 항목에 Samsung Android같은 이름의 새로운 네트워크 인퍼페이스가 생성된다.

 

3. 양방향 통신 지원
반대로 MacBook에서 인터넷 요청을 보낼 때도, HoRNDIS는 MacOS가 보낸 이더넷 프레임을 받아서 다시 RNDIS 규격으로 포장한 뒤 안드로이드 폰으로 쏴준다.

 

MacOS Catalina 이후부터 Apple은 커널 확장 프로그램의 설치를 매우 까다롭게 막고 있기도 하고, 특히 Apple Silicon으로 오면서부터는 리커버리 모드에서 시스템 무결성 관련 설정을 해제해야 한다. (블로그 참고 - 최신 맥북은 시동 옵션의 기어 아이콘에서 시스템 패스워드까지 입력해야 터미널을 켤 수 있다.)

'네트워크' 카테고리의 다른 글

좌충우돌 와이파이 탐험기  (4) 2024.10.08

AI개발을 하려면 석사 학위, 개발 스킬, AI 발전의 타임라인과 원리에 대해 이해가 필수적이라고 여겨지던 시절이 있었다. 그 때와는 다르게, 지금(2025년 9월)은 많은 회사에서 다양한 개발 직무에 있는 사람들이 AI를 이용한 서비스 개발이나, 자사 혹은 타사의 서비스에 대한 MCP등을 많이 제작하고 사용하는 시대가 왔다.

 

Deep Research를 통해 보다 폭넓고 섬세한 의사결정을 통해 기술 도입의 결정을 처리할수도 있고, 학습 모드를 통해 내가 모르는, 또는 내가 몰랐던 부분에서의 학습도 체계적으로 진행하며 프로젝트를 진행할 수도 있고, Claude-Code같은 LLM 클라이언트를 IDE에 붙여 사용하거나 Cursor같이 IDE를 활용하는 방안도 있고, 너무나 커진 파라미터로 LLM을 다룰 때에 있어서의 섬세한 스킬의 필요성이 희석되었기 때문이다.

 

이 글은 위 조건이 없었던 작년을 기준으로 작성하고 있다.

시작

시작은 작년 9월, 여느 회사와 같이 '우리도 AI를 이용해서 뭘 좀 해볼 수 없을까?'와 같은 대표의 말로부터 첫 삽을 펐다. 그리고 지원자는 나 하나였다. 다른 사람들은 AI개발에 별로 관심이 없거나 두려워했기 때문이었다.

 

지원 사유는 크게 두가지로 나눌 수 있다.
하나는 회사에 입사해 솔루션을 사용해보면서 여기저기 이스터에그처럼 숨어있는 도큐먼트들을 찾아보는게 너무나 귀찮은 일이었고, 전래동화처럼 내려오던 경험에 근거한 솔루션 사용 꿀팁들을 바쁜 사람들에게 일일히 물어봐야 했었는데 INFP로서 그들에게 내가 안고 있는 문제에 대해 물어보기가 껄끄러웠다. 그래서 언젠가는 개인적으로 회사 도큐먼트를 가져와서 사용법을 알려주는 '비서'를 하나 만들고 싶었는데, 마침 지원자가 아무도 없길래 한다고 손을 들었다.

 

다른 하나는 성과였다. 사내 여러 조직에서 대표가 AI개발을 통해 하고 싶어하는 것이 많다는 것을 너무 잘 알았고, 프로젝트를 하느라 바쁜 와중에도 조금씩 '나 AI 조금 압니다'라는 이야기나 글이 회사 커뮤니티를 통해 조금씩 올라오면서 AI개발에 대한 파이를 해당 조직에서 가져가려고 하는게 보였다. 나는 중앙 연구조직에서 당연히 이를 맡아야 한다고 느꼈는데, 위에 서술했듯 지원자도 없었고, 이걸 이용해 개인/조직성과를 정말 많이 가져갈수 있을거라고 판단했고, R&D가 아닌 다른 조직에서 사이드로 AI서비스를 개발하는 것은 자존심이 매우 상하는 일이라고 생각했다.

 

그렇게 나는 도메인 아키텍트에 너무 관심이 있던 웹 백엔드 엔지니어에서 AI 엔지니어로 이동하게 되었다.

첫 번째 관문 : 자바냐, 파이썬이냐

지금은 JVM이든, 파이썬이든 그 위에서 AI 서비스 개발이나 ML 서빙을 충분히 할 수 있고, 굳이 한 언어를 고집해서 할 필요는 없다고 생각한다. 또는 어떤 언어나 프레임워크를 모르더라도 LLM 클라이언트를 IDE와 연계해 plan - execute의 순서로 쉽고 빠르게 프로토타입을 뚝딱 만들어낼 수 있다.
그러나 당시에는 CTO님와 최대한 빠른 의사결정을 통해 스택을 고르고, 당장 개발에 집중해야 했다.

 

먼저 언어를 골라야 했는데, 개발자 입장에서야 당연히 파이썬을 이용해 개발하면 레퍼런스도 많고, 쉽고, 편리하지만(특히 신입 개발자 입장에서 - 정말 깊은 소스 내부의 원리를 모른다고 생각했을 때), 스프링을 메인 프레임워크로 사용하는 회사 입장에서는 그렇기 힘든 경우가 있다.

 

만약 회사에서 프론트엔드 개발에 그동안 사용하던 자바스크립트를 계속 가지고 갈지, 또는 타입스크립트로의 전환을 시도할지 고민의 기로에 서 있다고 해 보자.
열정이 넘치는 개발자라면 당연히 타입스크립트를 선택하지 않을 이유가 없겠지만, 회사의 입장에서는 다르다.
먼저 기존 인원들이 기존 소스코드를 타입스크립트로 마이그레이션했을때 유지보수 또는 신규 건에 대해 개발을 할 수 있게끔 교육 비용이 들어가고, 신규 인원 채용에도 TS를 사용할 수 있는 인력에 한정해서 채용해야 한다.
또한, 만약 타입스크립트 사용에 이슈가 있거나, 더 좋은 결정안이 있어서 해당 결정을 롤백해야 한다면 그동안에 사용된 리소스는 실로 어마어마할 것이다.

 

물론 회사 내에서의 내 위치를 고려할 때, 내가 고민하거나 담당해야 하는 일은 아니라고 생각했지만 내가 작성한 소스코드도 회사 자산의 일부이고 추후 같이 일할 팀원을 구할 때에도 지금 하는 결정은 매우 크게 작용할 것이라 생각해 최선을 다해 여러 시나리오를 고려했다.

 

많은 고민과 회의 끝에 위의 조건을 차치하고도 파이썬을 선택했는데, 사유는 다음과 같다.

안정성과 기술 파이 : 'Time-to-Market'의 압도적 우위

작년 9월 기준으로 Spring-AI 라이브러리에는 하루에 5건 이상의 이슈가 올라오고 있는데에 반해, 파이썬은 든든한 국밥같은 Langchain과 LlamaIndex, HF Transformers등을 업고 AI개발의 표준 라이브러리 자리를 차지하고 있었다. 나는 파인튜닝보다는 RAG레벨에서 더 효용을 발휘하는 서비스를 만들기 위해서 LangChain을 사용할 수 있는 장점이 커서 파이썬으로 가고자 한 것도 있지만, 파인튜닝까지 해야 했다면 Pytorch같은 프레임워크와의 호환도 고려해야 해서 파이썬을 사용하고 싶은 생각이 더 컸을 것이다.
또한 회사의 사정 상 오류가 덜한 프로토타입을 빠르게 출시하고 피드백해야 했기에, 개발마다 코드 수정 > 컴파일 > 런타임을 거쳐야 했던 자바보다는 인터프리터 언어의 특성 상 빠르게 테스트를 실행하고, 결과를 얻고, 수정할 수 있는 파이썬을 사용하는 것이 맞다고 판단했다.
결론적으로 이 선택은 반은 맞고 반은 틀리게 되었다. 사정이 생겨 AI서비스에서 사내 다른 서비스의 피처를 개발하느라 3개월정도 자리를 비웠었는데, 'RAG'가 대세였던 서비스 개발 시장이 돌아와보니 어느새 'Agent'를 지나 'MCP'가 대세가 되려고 하고 있었다. 따라서 빠르게 동향을 훑고 개발에 들어가야 했었기에 정말 많은 테스트와 시행착오를 거쳐 더 빠르게 프로토타입을 내야 했어서 자바로 개발하는것보다 훨씬 빠르게 테스트를 해서 제품을 낼 수 있었다. 그러나 동시에 JVM 위에서 동작하는 여러 라이브러리와 커넥터도 출시되면서 '무조건 파이썬을 사용해야 한다'라는 선택지에 대한 힘도 많이 줄어들었다. 실제로 KSUG(한국 스프링 사용자 모임)등의 컨퍼런스에도 참여해보니, 실력있는 자바/스프링 진영의 개발자분들이 좋은 레퍼런스를 많이 개발해 주고 있었고, 랭체인 프레임워크와 자바 소스코드 기반의 프로젝트도 호환성이 많이 좋아졌다.(물론 아직 Langchain에서 제공하는 Java를 위한 SDK는 존재하지 않긴 하지만)

아키텍처: 의도적인 분리를 통한 안정성

챗봇 모듈 하나 때문에 당장 MSA로 전환해야 하는 것은 아니지만 파이썬이라는 다른 기술 스택을 선택하는 것 자체가 의도적으로 서비스의 경계를 만드는 행위였다. 만약 Java로 개발했다면, 높은 확률로 기존의 모놀리식 프로젝트의 일부로 편입되었을 것이라고 생각했다. 당장은 편할지 몰라도, 향후 챗봇 기능이 고도화될수록 기존 서비스와의 결합도(Coupling)가 높아져 분리가 거의 불가능해지는 기술적 부채를 낳게 된다. 챗봇 모듈은 ITSM을 제공하는 회사 솔루션 특성상 필수 설치가 아니라 옵셔널하기 때문에, 급하게 떼어낼 수 없어 모듈단으로 분리를 해야 하는데, 이렇게 떼어내게 된다면 굳이 자바를 쓰면서 개발해야 할 이유가 없어진다.

 

특히 챗봇은 LLM API 호출로 인한 긴 대기 시간과 간헐적인 대규모 연산(Embedding 등)이라는 독특한 부하 특성을 가진다. 파이썬 기반의 별도 서비스로 분리함으로써, 챗봇의 부하가 회사의 핵심 서비스 안정성에 영향을 미치는 것을 원천적으로 차단할 수 있다. 이는 향후 회사가 클라우드 네이티브 환경으로 본격적으로 전환할 때, 각 서비스를 독립적으로 확장하고 관리할 수 있는 바탕이 된다.(현재도 클라우드네이티브로의 전환이나 온프레미스 - 클라우드 2개 버전을 독립적으로 개발할 계획을 가지고 있다.)

조직: 미래를 위한 기술 자산과 전문성

파이썬 도입은 위에서 말했듯 당장의 유지보수 비용과 채용의 어려움을 동반하는 것이 사실이다. 그러나 회사가 성장함에 따라 모든 것을 아는 제너럴리스트(Generalist) 개발자만으로는 한계에 부딪힌다. 특정 도메인에 대한 깊은 이해를 가진 스페셜리스트(Specialist)가 반드시 필요해지는 순간이 오고, 현재 회사에서 요구하는 역량도 사내의 전체 코드를 이해하고, 피처 중 사람이 빠진 곳에 당장 투입되는 '땜빵'을 할 수 있는 사람보다, 기획과 개발에 동시에 참여하며 조금 더 전문성과 책임감을 가지는 프로덕트 엔지니어적 역량이기 때문에, '파이썬을 사용하는 개발자'를 뽑아야 한다는 위험성에 초점을 맞추는 것이 아니라, AI 서비스 개발을 집중해서 개발할 수 있는 사람을 뽑아야 하는게 맞다고 생각한다.

 

 

다음 글에서는 조금 더 깊이 들어가서, 아키텍처나 사용했던 라이브러리의 변화에 대해서 얘기하려고 한다.

'AI > Python' 카테고리의 다른 글

매직 명령어는 왜 사용할까?  (0) 2025.01.10

리액트의 가상 DOM은 실제 DOM을 효율적으로 관리하고 업데이트하기 위한 인메모리의 가벼운 복사본이다. 가상 DOM은 리액트가 실제 UI를 효율적으로 렌더링하기 위해 사용하는 중간 표현으로, 실제 DOM의 상태와 비교하면서 변경 사항만 최소화해 실제 DOM을 업데이트한다.

주요 특징

  1. 빠른 업데이트 : 버추얼 돔은 실제 돔에 비해 훨씬 빠르게 업데이트할 수 있다. 리액트는 UI의 변화가 발생할 때마다 가상 DOM을 사용하여 빠르게 상태를 업데이트하고, 최종적으로 변경 사항을 실제 DOM에 반영한다.
  2. 인메모리 처리 : 가상 DOM은 메모리에서만 존재하는 자바스크립트 객체로, 실제 DOM과는 별개로 존재한다. 즉, 리액트는 변경 사항을 실제 DOM에 반영하기 전에, 가상 DOM에서 상태를 먼저 업데이트하고 비교한다.
  3. 트리 구조 : 가상 DOM은 실제 DOM과 동일한 트리 구조를 가지고 있으며, 각 트리의 노드는 리액트 컴포넌트를 나타낸다.

실제 돔과 가상 돔의 차이점

  • Real DOM : HTML 문서에서 브라우저가 렌더링하는 실제 UI다. DOM 트리 구조에서 모든 요소는 브라우저가 관리하고, 각 변경 사항은 브라우저의 렌더링 엔진에 의해 직접적으로 처리된다. DOM을 변경하는 작업은 느리고, 복잡한 업데이트가 자주 발생할 때 애플리케이션 성능에 큰 영향을 줄 수 있다.
  • Virtual DOM : 자바스크립트 객체로 이루어진 가벼운 DOM의 복사본으로, 리액트는 UI업데이트를 가상 DOM에서 먼저 수행하고, 이 가상 DOM과 실제 DOM에 최소한의 변화만 적용한다.

리액트가 실제 DOM과 가상 DOM의 차이를 계산하는 과정(리렌더링 과정)

리액트는 리렌더링을 효율적으로 처리하기 위해 가상 DOM을 사용한다. 실제 DOM과 가상 DOM의 차이를 계산하는 방법은 Diffing Algorithm 또는 Reconciliation Algorithm을 통해 이루어진다. 이 과정에서 리액트는 두 가지 DOM 트리를 비교하고, 그 차이를 찾아내서 최소한의 변경을 실제 DOM에 반영한다.

Diffing Algorithm(차이 계산)

Diffing Algorithm의 특징

  1. 렌더링 트리 비교
    • 리액트는 상태나 props가 변경되면, 먼저 가상 DOM을 업데이트한다. 그런 다음, 기존의 가상 DOM과 새로 업데이트된 가상 DOM을 비교한다.
  2. 구성 요소 단위 비교
    • 리액트는 컴포넌트 트리를 비교할 때 컴포넌트의 유형이 동일한지, props가 동일한지, 자식 요소가 동일한지 등을 기준으로 비교한다.
    • 같은 컴포넌트 유형이면, 리액트는 해당 컴포넌트를 완전히 새로운 객체로 바꾸지 않고 업데이트만 처리한다.
  3. 최소화된 업데이트
    • 리액트는 각 요소가 변경되었는지 확인하고, 실제 DOM에는 변경된 부분만 업데이트한다.
    • 예를 들어, UI가 100개 요소로 구성된 리스트일 때, 한 개의 항목만 변경되었다면, 리액트는 전체 100개의 요소를 다시 렌더링하지 않고, 변경된 요소만 업데이트한다.
  4. 트리 구조에 의한 최적화
    • 리액트는 트리 구조를 기반으로 비교하고, 부모-자식 관계를 고려하여 최소한의 변경만을 적용한다.
    • 트리의 구조적 비교를 통해, 동일한 구조를 유지하면서 변경된 부분만을 선택적으로 업데이트한다.

Diffing Algorithm의 핵심

  • O(N) 비교 : 리액트의 Diffing Algorithm은 가장 간단하고 효율적인 방식으로 트리 구조를 비교하며, 변경된 부분만 업데이트한다. 이 알고리즘의 시간 복잡도는 O(n)이다. 즉, 트리의 모든 요소를 한 번만 순차적으로 비교하면 된다.
    아래의 코드를 보자.
    function App() {
    const [count, setCount] = useState(0);
    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={() => setCount(count + 1)}>Increase</button>
      </div>
    );
    }

초기 렌더링 시 가상 DOM은 아래와 같은 형태로 존재한다.

{
  type: "div",
  props: {
    children: [
      {
        type: "p",
        props: {
          children: "Count: 0"
        }
      },
      {
        type: "button",
        props: {
          children: "Increase",
          onClick: [Function] // setCount 함수가 실행될 때 증가하는 코드
        }
      }
    ]
  }
}

이 가상 DOM 객체는 자바스크립트 객체로 이루어져 있으며, 실제 UI구조를 설명한다. type은 DOM 요소의 종류, 그리고 props는 그 요소의 속성이나 자식 요소들을 나타낸다.

실제 DOM은 다음과 같이 생겼다.

<div>
  <p>Count: 0</p>
  <button>Increase</button>
</div>

이 HTML 요소는 브라우저에서 화면에 렌더링되고, 실제 DOM 트리를 형성한다. p와 button 요소는 <div> 내에 존재하며, 각각의 버튼은 Increase 텍스트와 Count: 0을 표시한다.

상태(count)가 변경될 때, 리액트는 새로운 가상 DOM을 생성하고, 이전 가상 DOM과 비교해서 실제 DOM을 업데이트한다. 예를 들어 버튼을 클릭했다고 가정해 보자.

{
  type: "div",
  props: {
    children: [
      {
        type: "p",
        props: {
          children: "Count: 1"
        }
      },
      {
        type: "button",
        props: {
          children: "Increase",
          onClick: [Function]
        }
      }
    ]
  }
}

새로운 가상 DOM에서 p 요소의 내용이 "Count: 1"로 업데이트된 것을 확인할 수 있다.

기존 가상 DOM과 비교했을 때 p요소의 children값만 달라졌음을 확인하고, 실제 DOM에서 해당 부분만 업데이트한다. 리액트는 트리의 루트부터 시작해서, 각 요소를 비교한다. 위에서 설명했듯 리액트는 컴포넌트의 유형이 동일한지, props가 동일한지, 자식 요소가 동일한지를 비교하기 때문에, 나머지는 변경하지 않고, 'p'요소만 변경하게 된다.

예시 소스코드

가상 DOM을 비교하는 간단한 코드 예시를 살펴보자.

function diff(prevTree, nextTree) {
  if (prevTree === nextTree) {
    return null;  // No change
  }

  if (prevTree && nextTree && prevTree.type !== nextTree.type) {
    return nextTree;  // Element type changed
  }

  // Handle changes in children
  const prevChildren = prevTree.props.children;
  const nextChildren = nextTree.props.children;

  if (prevChildren !== nextChildren) {
    return nextTree;  // Children changed
  }

  return null;  // No change
}

// Example usage
const prevTree = {
  type: "p",
  props: { children: "Count: 0" }
};

const nextTree = {
  type: "p",
  props: { children: "Count: 1" }
};

const diffResult = diff(prevTree, nextTree);
if (diffResult) {
  console.log("Change detected:", diffResult);  // { type: 'p', props: { children: 'Count: 1' } }
}

div도 리렌더링 되어야 하는 것 아닌가?

이야기를 듣다 보면, 문득 이런 생각이 들 수 있다.
리액트는 컴포넌트의 유형, props, children을 비교하는데, 'p'가 변했으면 'div'의 children이 변한 것이고, 그렇다면 'div'도 리렌더링 되어야 하는 것 아닌가?
결론적으로 말하자면, 리액트는 상위 컴포넌트(부모 요소)의 리렌더링을 자동으로 수행하지 않도록 최적화하고 있다. 리액트는 컴포넌트 트리를 얕게 비교(shallow comparison)하여 변경된 부분만을 리렌더링한다.
위의 예제를 기반으로 설명하면 다음과 같다.

const prevDiv = {
  type: "div",
  props: {
    children: [
      { type: "p", props: { children: "Count: 0" } },
      { type: "button", props: { children: "Increase", onClick: [Function] } }
    ]
  }
}

const nextDiv = {
  type: "div",
  props: {
    children: [
      { type: "p", props: { children: "Count: 1" } },
      { type: "button", props: { children: "Increase", onClick: [Function] } }
    ]
  }
}

두 객체의 차이점에서 중요한 점은, p 요소의 내용만 변경되었다는 것이다.
리액트는 prevDiv와 nextDiv를 비교하면서 다음의 내용을 확인한다.

  1. p요소의 내용이 "Count: 0"에서 "Count : 1"로 바뀌었음을 감지한다.
  2. button 요소는 그대로 존재하고, 그 내용이나 속성이 변경되지 않았다.
  3. 따라서 리액트는 부모인 div는 변경되지 않았으므로 리렌더링하지 않고, 변경된 자식인 p만 업데이트한다.

리액트의 diffing algorithm에서는 자식 요소 변화를 효율적으로 처리하기 위해, 두 가지 방식을 사용한다.

주소값 비교(Reference comparison)

객체나 배열, 함수 같은 참조 타입(reference types)에 대해 사용된다. 이 비교는 두 객체가 같은 메모리 위치를 참조하는지 여부를 확인한다. 즉, 두 객체가 정확히 동일한 객체인지를 비교하는 방식이다.
예를 들면 다음과 같다.

const obj1 = { count: 0 };
const obj2 = obj1;  // obj2는 obj1과 동일한 메모리 위치를 참조한다.
const obj3 = { count: 0 };  // obj1과 같은 구조지만 다른 메모리 주소를 가집니다.

console.log(obj1 === obj2); // true (같은 객체를 참조)
console.log(obj1 === obj3); // false (다른 객체)

내용 비교 (Shallow Comparison)

객체의 첫 번째 수준만 비교하는 방식이다. 즉, 객체나 배열의 속성이나 요소를 비교하는데, 중첩된 객체배열 내부의 객체는 비교하지 않고, 첫 번째 수준의 속성만 비교한다.
예를 들면 다음과 같다.

const obj1 = { count: 0, nested: { value: 5 } };
const obj2 = { count: 0, nested: { value: 5 } };
const obj3 = { count: 1, nested: { value: 5 } };

console.log(obj1 === obj2);  // false, obj1과 obj2는 다른 객체
console.log(obj1.count === obj2.count);  // true, count 값은 같음
console.log(obj1.nested === obj2.nested);  // false, 중첩된 객체는 다른 메모리 주소 참조

// Shallow comparison 예시
console.log(shallowCompare(obj1, obj2));  // true, 첫 번째 수준에서 값이 같음
console.log(shallowCompare(obj1, obj3));  // false, count 값이 다름

리액트의 Diffing Algorithm에서 자식 요소를 비교하는 방식은 다음과 같다:
컴포넌트가 동일한 경우: 리액트는 그 자식 요소들의 props와 children을 비교한다.
참조값이 동일한 경우: 자식 요소가 동일하다고 판단하여, 그 자식 요소는 다시 렌더링하지 않는다.
참조값이 다른 경우: 자식 요소가 변경된 것으로 판단하고, 자식 요소를 새로 렌더링한다.
컴포넌트가 다른 경우: 리액트는 완전히 다른 자식 요소가 들어왔을 때, 그 자식 요소를 새로 렌더링한다.

주소값 비교와 내용 비교

리액트는 주소값 비교를 우선적으로 사용하고, 그 후 내용 비교를 사용하여 효율적으로 변경된 부분만 렌더링한다.
배열이나 객체주소값을 비교하여, 배열이나 객체 자체가 새로 생성되었는지 판단한다.
문자열이나 숫자와 같은 원시 타입의 경우, 실제 을 비교하여 변경 여부를 판단한다.

결론

  • p의 Count가 바뀌었을 때는 children의 요소가 문자열 타입인 경우, 실제 값을 비교한다. ("Count : 0" !== "Count : 1")
  • p는 새로 생성되지 않고, 내용만 변경된다.
  • div의 자식인 p는 객체이므로, 주소값 비교를 하는데 p가 새로 생성되지 않았으므로 주소값이 같아 div는 리렌더링 되지 않는다.
  • div를 비교할 때에는 Reference Comparison(참조 비교)를, p를 비교할 때에는 Shallow Comparison(얕은 비교)를 사용한다.

회사에서 개발중인 AI 서버의 프로토타입을 급하게 확인하고 싶다고 해서, FastAPI + React.js를 이용한 서버 개발이 다 되지 않았기에 Streamlit으로 서버를 올린 뒤 사내 솔루션에 업로드하고자 했다.

그런데, 문제는 다음과 같았다. 사내 솔루션의 iframe에 스트림릿 서버를 제공하려고, 솔루션은 HTTPS를 사용했고 스트림릿은 기본적으로 HTTP환경에서 제공되어, HTTPS 서버로 TLS를 얹어 제공해야 했었다.

그래서 부랴부랴 사내의 리버스프록시망에 서버를 얹었으나, 웹소켓쪽에서 연결이 안된다는 오류 메세지가 발생했다. Streamlit은 기본적으로 WebSocket을 이용해 동작하므로, 무조건 이 문제를 해결하고 가야 했다. 웹소켓은 한번도 공부해본적이 없었기에, 최대한 빠르게 공부하고 문제를 해결하러 갔다. 웹소켓의 기본 구조부터 확인해보자.

웹소켓의 동작 원리

웹소켓(wss:// 프로토콜)은 클라이언트와 서버 간의 양방향 통신을 지원하는 프로토콜이며, 기본 동작 과정은 다음과 같다.

핸드셰이크(Handshake)

  • 웹소켓은 기존의 HTTP프로토콜을 활용하여 핸드셰이크를 수행한 후, 지속적인 연결을 유지하는 방식이다.
  • 클라이언트가 HTTP Upgrade 요청을 보내서 웹소켓으로의 전환을 요청한다.
  • 서버가 이를 승인하면 연결이 확립되고, 이후에는 계속해서 양방향 데이터 전송이 가능하다.

데이터 전송

  • HTTP처럼 요청/응답 모델이 아니라, 서버가 클라이언트에 데이터를 자유롭게 푸시할 수 있다.
  • 클라이언트와 서버는 한 번 연결되면 계속 유지되고, 지속적인 메시지 교환이 가능하다.

연결 종료

  • 클라이언트와 서버 중 한쪽이 CLOSE 프레임을 전송하면 연결이 종료된다.

웹소켓 통신

웹소켓(WebSocket) 통신을 시작하기 위해서는 기존의 HTTP 프로토콜을 이용하여 연결을 업그레이드(Upgrade)해야 한다. 이는 웹소켓이 기존 HTTP 프로토콜을 활용해서 연결을 설정하지만, 이후에는 HTTP의 요청 - 응답이 방식이 아니라, 지속적인 양방향 통신이 가능한 프로토콜로 변경되기 때문이다.

핸드셰이크

웹소켓 연결을 시작하기 위해 클라이언트는 기존 HTTP 프로토콜을 사용하여 서버에 Upgrade 요청을 보낸다.
이를 위해서는 헤더에 Upgrade와 Connection 필드를 포함해 웹소켓 연결을 요청한다. 예로는 다음과 같다.

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ~
Sec-WebSocket-Version: 13

이 헤더 필드의 의미는 다음과 같다.

  • Upgrade: Websocket -> 웹소켓으로 프로토콜을 업그레이드하고 싶다는 의미
  • Connection: Upgrade -> 클라이언트가 프로토콜 업그레이드를 요청함.
  • Sec-WebSocket-Key: 보안용 난수 값(서버에서 응답을 검증하는 데 사용)
  • Sec-WebSocket-Version: 웹소켓 프로토콜 버전

서버의 Upgrade 응답(HTTP 응답)

서버는 클라이언트의 요청을 확인한 후, 적절한 응답을 반환해야 한다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ~
  • 101 Switching Protocols : HTTP 프로토콜에서 웹소켓으로 변경됨을 의미한다.
  • Upgrade , Connection : Upgrade -> 클라이언트의 요청을 수락했음을 나타낸다.
  • Sec-WebSocket-Accept : 클라이언트가 보낸 Secure Websocket key값을 서버가 변환해서 반환한다.

Upgrade 과정이 필요한 이유

웹소켓은 HTTP 80/443 포트에서 동작하기 때문에, 방화벽 및 프록시 서버에서 차단 없이 사용 가능하다. 많은 네트워크에서 방화벽은 비표준 프로토콜을 차단하기 때문에 첫 메시지는 HTTP 요청처럼 보이도록 만든 후, 연결을 웹소켓으로 전환하는 방식을 사용해야 한다. 그러나 웹소켓은 일반적인 HTTP 요청-응답 모델이 아닌, 지속적인 양방향 통신을 지원해야 하기 때문에 기존 HTTP 프로토콜과는 다른 방식으로 동작해야 한다. 그래서 기존 HTTP 프로토콜을 유지한 채 웹소켓을 사용하려면, Upgrade 요청을 통해 프로토콜을 변경하는 과정이 필요하다.

Streamlit

Streamlit은 기본적으로 HTTPS를 지원하지 않는다. 그러나 두 가지 방법으로 Streamlit을 HTTPS 환경에서 구동할 수 있다.

  • config.toml 파일에 인증서 추가
  • reverse proxy로 환경관리

config.toml

./streamlit/config.toml에서 SSL 인증서의 경로를 추가하여 해당 인스턴스를 HTTPS 환경에서 구동할 수 있다.

[server]
sslCertFile='my/cert/path'
sslKeyFile='my/key/path'

Streamlit + Reverse Proxy

Streamlit 자체는 웹소켓을 활용하는 구조를 가지고 있다.
특히, 프론트엔드(UI)에서 백엔드(Python)와의 통신을 위해 웹소켓(wss://)를 이용해서 데이터를 주고받는다.

클라이언트가 서버에 접속할 때의 흐름

  1. HTTPS 연결 시작
  • 사용자가 브라우저에서 https://my_ai_server로 접속한다.
  • 브라우저는 먼저 HTTPS 연결을 수립하기 위해 Reverse Proxy와 통신한다.
  1. Reverse Proxy가 Streamlit 서버로 요청 전달
  • Reverse Proxy는 HTTPS 요청을 내부의 Streamlit 서버(http://localhost:8501)로 요청 전달한다.
  1. Streamlit은 웹소켓(wss://) 연결을 시도
  • 브라우저에서 Streamlit의 UI가 로딩되면서, wss://my_ai_server/_stcore/stream URL로 웹소켓 연결을 시도함
  • 이 때, 웹소켓 연결이 Proxy 설정에 의해 차단되거나, SSL/TLS 문제로 인해 정상적으로 작동하지 않을 수 있음
  1. 웹소켓 통신 시작
  • 성공하면 웹소켓을 통해 Streamlit과 실시간으로 데이터를 주고받는다.
  • 실패하면 콘솔에서 WebSocket handshake failed등의 에러가 발생한다.

문제 발생

웹소켓 + HTTPS 조합에서 발생하는 대부분의 문제는, wss://my_ai_server에서 거부반응을 일으키는 문제이다.

WebsocketConnection to `wss://my_ai_server/_stcore/stream` failed:

wss 관련 에러가 발생하는 원인과 해결 방법

  1. Reverse Proxy에서 웹소켓 지원 미설정
    Reverse Proxy(Nginx, Apache 등)에서 웹소켓 요청을 제대로 전달하지 못하면 wss:// 연결이 실패할 수 있음. 예를 들어, Nginx 사용 시 다음 설정이 필요하다.여기서 proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "Upgrade"; 설정이 없으면 웹소켓 연결이 정상적으로 되지 않을 수 있음
server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8501;  # Streamlit 서버로 연결
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
    }
}
  1. Streamlit이 wss://를 지원하지 못하는 경우
    기본적으로 Streamlit은 ws:// (HTTP 환경)에서만 웹소켓을 실행하는데, wss://(HTTPS 환경) 에서 실행하려면 HTTPS 트래픽을 처리할 수 있어야 한다. 따라서 Streamlit 서버를 직접 HTTPS로 실행하려면 --server.enableCORS false--server.enableWebsocketCompression false옵션을 추가해야 한다.
streamlit run app.py --server.enableCORS false --server.enableWebsocketCompression false

문제 파악

HTTP 리버스 프록시 설정이 잘못되어 있을 수 있다. 현재 설정은 Nginx의 Reverse Proxy 설정 중 "HTTP(80)에서 내부 Streamlit 서버(localhost:8501)로 요청을 전달하는 부분"이다.
현재)

server {
    server_name my_ai_server;
    listen 80; # 이 서버 블록은 HTTP(포트 80)에서 실행됨
    location / {
        allow 0.0.0.0/24; # 내부 네트워크 대역에서만 접근을 허용함
        deny all; # 이외에는 모두 차단함
        proxy_pass http://localhost:8501; # 클라이언트가 https://my_ai_server에 접속하면 내부의 streamlit 서버(http://localhost:8501)로 요청을 전달함
        proxy_set_header Host $host; # 원본 요청의 Host 헤더를 유지함 / 웹소켓 및 CORS 관련 문제를 방지하는 설정
        proxy_pass_request_headers on;
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;

        proxy_set_header Upgrade $http_upgrade; # 웹소켓(wss://) 연결을 지원하기 위한 설정
        proxy_set_header Connection "upgrade"; # 웹소켓을 정상적으로 처리하기 위해 Connection : upgrade 헤더 추가
        proxy_http_version 1.1; # 웹소켓을 지원하는 HTTP/1.1 사용

        add_header 'Access-Control-Allow-Origin' 'https://dev.egene.io' always; # https://dev.egene.io에서 오는 요청만 CORS를 허용
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; # 허용되는 HTTP 메서드 정의
        add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization' always; # 요청에서 허용되는 헤더를 명시
    }
}

이 설정은 nginx에서 my_ai_server 서버의 HTTP 포트에서 실행된다. HTTPS 처리는 이 설정에 포함되지 않았다. 또한 Nginx의 Reverse Proxy 설정 중 "HTTP(80번 포트)에서 내부 Streamlit 서버(localhost:8501)로 요청을 전달하는 부분"이다.

80포트와, 443포트도 확인해야 한다.

server{
    server_name my_ai_server;
    listen 443 ssl; #http2 써놓으면 http/2 활성화

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; 
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8501;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-Ip $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

설정을 완료했다면, Nginx와 AI 서버에 대해서도 웹소켓이 열려 있는지를 확인해야 한다.

Nginx 및 서버 확인

Nginx 서버 및 AI서버에서는 Curl 요청으로 다음의 3가지를 확인해야 한다.

  • Upgrade로 Websocket 요청이 갔는지
  • http 1.1요청이 갔는지
  • Connection으로 Upgrade가 갔는지

요청

    curl -v -H "Upgrade: websocket" \\
-   H "Connection: Upgrade" \\
-   H "Sec-WebSocket-Version: 13" \\
-   H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \\
-   \-http1.1 \\  
    [http://localhost/\_stcore/stream](http://localhost/_stcore/stream)

답변

root@nginx-reverse:/etc/nginx/sites-available# curl -v -H "Upgrade: websocket" \ -H "Connection: Upgrade" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ --http1.1 \ http://localhost:8501/_stcore/stream`
-   Trying ...
-   Connected to ... port 8501 (#0)

> GET /\_stcore/stream HTTP/1.1  
> Host: localhost:8501  
> User-Agent: curl/7.81.0  
> Accept: **/**  
> Upgrade: websocket  
> Connection: Upgrade  
> Sec-WebSocket-Version: 13  
> Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==

-   Mark bundle as not supporting multiuse

< HTTP/1.1 101 Switching Protocols  
< Server: TornadoServer/6.4.2  
< Date: Mon, 17 Feb 2025 05:02:28 GMT  
< Upgrade: websocket  
< Connection: Upgrade  
< Sec-Websocket-Accept: qGEgH3En71di5rrssAZTmtRTyFk=  
< Vary: Accept-Encoding  
<  
Warning: Binary output can mess up your terminal. Use "--output -" to tell  
Warning: curl to output it to your terminal anyway, or consider "--output  
Warning: " to save to a file.

-   Failure writing output to destination
-   Closing connection 0

위와 같이 101 Switching Protocol 응답이 나와야 정상이다.

실패의 경우 다음과 같다.
예1) 400코드

root@nginx-reverse:/etc/nginx/sites-available# 
curl -i -N -H "Connection: Upgrade"     
-H "Upgrade: websocket"     
-H "Host: http://localhost"     
-H "Origin: https://my_ai_server"     
-H "Sec-WebSocket-Version: 13"     
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ=="     
https://my_ai_server/_stcore/stream  
HTTP/2 400 Bad Request  
Server: openresty  
Date: Fri, 14 Feb 2025 03:48:29 GMT  
Content-Type: text/html; charset=UTF-8  
Content-Length: 34  
Vary: Accept-Encoding  

Can "Upgrade" only to "WebSocket"

이 경우 Nginx에서 Server에 HTTP/2요청을 보내므로 연결 실패가 나온다. 이 경우 Nginx config를 찾아봐야 한다.

예2) 200코드

root@nginx-reverse:/etc/nginx/sites-available# curl -i -N -H "Connection: Upgrade" \  
    -H "Upgrade: websocket" \  
    -H "Host: [ai.cloud.egene.io](http://ai.cloud.egene.io)" \  
    -H "Origin: [https://ai.cloud.egene.io](https://ai.cloud.egene.io)" \  
    -H "Sec-WebSocket-Version: 13" \  
    -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \  
    --http1.1 \  
    https://my_ai_server  
HTTP/1.1 200 OK  
Server: openresty  
Date: Mon, 17 Feb 2025 04:44:39 GMT  
Content-Type: text/html  
Content-Length: 1837  
Connection: keep-alive  
Accept-Ranges: bytes  
Etag: "~"  
Last-Modified: Mon, 10 Feb 2025 10:56:15 GMT  
...

<!--  
 Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)  

 Licensed under the Apache License, Version 2.0 (the "License");  
 you may not use this file except in compliance with the License.  
 You may obtain a copy of the License at  

     [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)  

 Unless required by applicable law or agreed to in writing, software  
 distributed under the License is distributed on an "AS IS" BASIS,  
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  
 See the License for the specific language governing permissions and  
 limitations under the License.  
-->  

<!DOCTYPE html>  
<html lang="en">  
  <head>  
    <meta charset="UTF-8" />  
    <meta  
      name="viewport"  
      content="width=device-width, initial-scale=1, shrink-to-fit=no"  
    />  
    <link rel="shortcut icon" href="./favicon.png" />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-Regular.DZLUzqI4.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-SemiBold.sKQIyTMz.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  
    <link  
      rel="preload"  
      href="./static/media/SourceSansPro-Bold.-6c9oR8J.woff2"  
      as="font"  
      type="font/woff2"  
      crossorigin  
    />  

    <title>Streamlit</title>  

    <!-- initialize window.prerenderReady to false and then set to true in React app when app is ready for indexing -->  
    <script>  
      window.prerenderReady = false  
    </script>  
    <script type="module" crossorigin src="./static/js/index.NJJ4tUjP.js"></script>  
    <link rel="stylesheet" crossorigin href="./static/css/index.mUTQuMqR.css">  
  </head>  
  <body>  
    <noscript>You need to enable JavaScript to run this app.</noscript>  
    <div id="root"></div>  
  </body>  
</html>

200코드가 나온다고 해서 안심할 수도 없다. HTML이 나온다는 것은, Websocket 업그레이드가 차단되면서 정적 페이지가 반환되기 때문이다.

웹소켓은 왜 HTTP/2 연결을 받지 않을까?

웹소켓은 HTTP/1.1에서만 Upgrade가 가능하며, HTTP/2 이상에서는 기본적으로 지원되지 않는다. 그 이유는 HTTP/1.1이 Connection : Upgrade를 지원하는데에 있다. Connection: Upgrade를 지원하면, 현재 연결을 다른 프로토콜로 변경할 수 있기 때문이다.

이에 비해, HTTP/2는 WebSocket Upgrade를 지원하지 않는데, 그 이유는 다음과 같다.

  • HTTP/2는 단일 연결을 여러 스트림으로 처리한다.
    • HTTP/2는 멀티플렉싱(Multiplexing)을 지원해서 하나의 TCP연결에서 여러개의 스트림(요청)을 동시에 처리한다.
    • 그러나 웹소켓은 단일 연결에서 지속적인 데이터 교환이 필요하기 때문에 HTTP/2의 기본 개념과 충돌한다.
  • HTTP/2는 Connection: Upgrade를 허용하지 않는다.
    • HTTP/2는 헤더 압축(HPACK)과 멀티플렉싱 구조로 인해 Upgrade 매커니즘을 제공하지 않는다.

Nginx Proxy Manager

NPM이라고도 불리는 Nginx Proxy Manager는 위와 같이 Nginx만 이용 시 CLI에서 해줘야 했던 설정들을 GUI에서 지원하고 있다.

Edit Proxy Host

그동안, 각각의 Proxy Host에 대해 해 주어야 했던 설정들을 Edit Proxy Host라는 작은 팝업을 통해 지원한다. 결과적으로 말하자면, 여기서 Websockets support 옵션을 켜 주기만 해도, 위의 코드블럭을 활성화하는 것과 같은 효과를 보여준다.

Details

Detailes에서는 인스턴스에 대한 기본 설정을 해줄 수 있다.

  • Domain Names : 프록시할 도메인 이름을 정해줄 수 있다.
  • Scheme : 프록시할 프로토콜을 선택할 수 있다.(http 또는 https)
  • Forward Hostname / IP : 내부 서버의 IP 주소 또는 호스트명( 예 : 192.168.0.100 또는 backend.example.local)
  • Forward Port : 내부 서버의 포트 번호( 예 : 80, 443, 5000)
  • Cache Assets : 정적 자산(이미지, CSS, JS 등)의 캐싱 여부
  • Block Common Exploits : 일반적인 보안 취약점 차단 옵션
  • Websockets Support : Websocket 지원 여부
  • Access List : 접근 제어 리스트 활성화 여부 및 특정 IP 또는 사용자 그룹에 대한 접근 제한 설정 가능

Custom Location

Custom Location에서는 특정 URL 경로(location)를 지정하여 지정한 경로만 프록시할 서버를 설정할 수 있다.

SSL

SSL에서는 보안 정보를 설정할 수 있다.

  • SSL Certificate : SSL 인증서를 적용할 도메인
  • Force SSL : HTTP 요청을 HTTPS로 강제 리디렉션
  • HTTP/2 Support : HTTP/2 활성화
  • HSTS Enabled : HTTP Strict Transport Security 활성화
  • HSTS Subdomains : 서브도메인에도 HSTS 적용

HSTS란?

HSTS(HTTP Strict Transport Security)는 웹사이트가 HTTPS를 강제하도록 브라우저에게 지시하는 보안 기능이다. 즉, 한 번 HTTPS로 접속한 사용자는 이후 모든 요청을 자동으로 HTTPS로 전환하도록 설정하는 기능이다. 이를 통해 중간자 공격(MITM, Man-in-the-Middle Attack)이나 SSL Stripping같은 공격을 방지할 수 있다.

HSTS의 주요 기능

  • 강제 HTTPS 연결
  • 초기 HTTP 연결 허용
  • 중간자 공격 방지
  • 서브도메인 적용 가능
  • HSTS Preload List : 브라우저에 미리 등록 가능, 등록하면 무조건 HTTPS

Advanced

Advanced에서는 nginx의 변수를 이용한 추가 설정을 입력해줄 수 있다.
예를 들어 proxy_set_header X-Real_IP $remote_addr; 등을 입력한다.

100명 가까이 참여하는 프로젝트를 하다 보면, 작은 사이드 프로젝트와는 다르게 역할이 참 많다.
역할에 따라 번역을 할 때도 있고, 개발을 할 때도 있는 등 여러가지 일을 해 볼수록 재미가 더해지는데, Git Conflict를 한번 겪어보면 머리가 뜨거워짐과 함께 성장통이 반드시 오게 마련이다. 해결하면 재미가 2배지만, 포기하고 최후의 방법인 다시 fork뜨기, repository 삭제 등을 하다보면 git이라는 이름만 들어도 러다이트 운동을 일으키고 싶을 만큼의 분노가 발생한다.

언제 Conflict가 발생할까?

나는 3주동안 다음과 같은 팀에 참여했다.

1주차 : 번역 및 검수
2주차 : 번역 및 검수, 개발 1팀
3주차 : 번역 및 검수, 개발 2팀, 개발 3팀

번역 및 검수팀과 개발팀은 서로 역할이 다른데, 먼저 PR 프로세스와 Git 전략을 보고 가자.

PR Process

PR Process는 어렵지 않았다. 사실 어렵게 하면 안 되는게, 비개발자들도 많은 참여를 하고 있는 소스라 최대한 단순하게 가는 것이 좋다. 한 파일의 사이클은 아래와 같이 돌린다.

1주 : 개발팀 개발 -> Peer Review 1 / 2 -> Merge
2주 : 번역검수팀 Review 1 / 2 -> 최종 Merge

Git 전략

Git을 모르는 사람들이 많았어서, 깃 전략 또한 Git Flow 또는 Github Flow같은 정형화된 프로세스를 가져가지 않고, trunk-based보다는 "Fork and Pull"(사실 나도 이름모름) 전략을 따른다.

 PC            github          github organization
local ----->   origin  ------ > upstream
main            main            main

Fork and Pull(또는 Fork-based Workflow) 전략은 간단하다. Upstream(원본)에서 Fork를 해서 각 기여자가 본인의 Repository에 Origin을 구성한 후, local에서 작업해서 origin에 push을 하고, origin에서 upstream으로 PR(Pull Request)을 날려서 관리자가 Merge를 하는 방식이다. 이 방법은 단순하면서도 git 초보자가 하기에 정말 좋은 전략이다.

스멀스멀 올라오는 Conflict의 기운

Git 전략은 좋았지만, git branch에 대해서는 따로 정해진 것이 없었다. 깃 초보자가 있는 프로젝트에서 " 'feature branch'를 이용해보세요 ^^ checkout만 해주면 된답니다~" 하면 경험상 무조건 '파일이 날라갔는데요? 제 파일 어디갔죠?'라는 답변이 오기 마련이다. 나 또한 같이 해본 프로젝트가 5인 미만 프로젝트만 있거나, 기능을 혼자 가져가거나 했기 때문에 local에서는 따로 브랜치를 쓸 필요가 없어서 main 이외의 브랜치를 가져간다는 생각조차 하지 못했다.

문제는 여기서 발생했다.

보통 두가지 경우에서 문제가 일어나는데, 다음과 같다.

검수팀

검수팀의 경우, 어떤 부분에서 문제가 발생했는지 쉽게 알려주기 위해 다음과 같은 프로세스를 따른다.

local  -----> origin -----> upstream
main    push   main
작업           작업물

Upstream까지 가지 않고, Origin에 본인의 작업물을 push하고, upstream에는 issue를 발행하며 본인의 작업물을 그대로 올려서 개발자들이 어떤 부분이 바뀌어야 하는지 쉽게 알려준다. 그러면 개발팀은 2주차에 본인의 작업물을 PR해서 반영하게 된다. 그러면 local, origin, upstream은 다음과 같이 구성된다.

사람 local origin upstream
개발 A' A'  
검수 A'' A'' A''

이 때, 검수는 origin을 upstream과 맞추기 위해 sync fork를 하는데, 여기서 origin 저장소는 A'를 받아들여야 하는지, A''를 받아들여야 하는지 헷갈려서 conflict를 내거나 discard A'(A'의 커밋을 버리고 A''로 동기화하기)를 지시한다.
잔디에 관심이 없거나 커밋에 쿨한 상남자 바이킹들은 '어 커밋 버려~' 할 수 있겠지만, 깃헙 잔디 수호자나 내 '귀엽고소중하고깜찍한' 커밋이 버려지는 것을 싫어하는 사람들은 쉽게 discard 할 수 없다. 이때부터 '내 커밋을 보호하면서 upstream을 받아오는 일'에 대해 검색하거나, 내 리포지토리를 세련된 닥스훈트로 만드려 수술을 집도하지만 merge 과정에서 수없이 많은 resolve conflict를 하다 케르베로스가 되고 눈물을 흘리며 저장소를 지우고 새로 fork를 따게 된다.

개발팀

개발팀의 경우, 문제가 조금 더 복잡해진다. 위에서 설명했듯, 한 주마다 신규 개발건을 맡기 때문에 타임라인이 다음과 같아진다.

1주차 : 1주차 내용 개발 -> peer review 1 / 2 -> PR Merge
2주차 : 2주차 내용 개발 -> peer review 1 / 2 -> PR Merge
        + 1주차 내용 검수 -> 번역검수팀 review 1 / 2 -> PR Merge

1주차는 문제가 없지만, 2주차에 돌입하면 2주차의 내용을 개발함과 동시에 1주차의 검수 교정까지 들어가야 한다. 그러면 local, origin, upstream은 다음과 같이 구성된다.

 

주차 local origin upstream
1주차 A A A
2주차 A'
B
A'
B
???

의도(개발자) : "나는 A' 따로, B 따로 PR 날려야지"
현실(github) : "제가 최신꺼 다 같은 PR에 묶었어요 ㅎㅎ 잘했죠?"

이때부터 github 공포증이 돌면서 러다이트운동이 마려워지는 일이 생긴다. 내가 원하는 커밋만 보내고 싶은데, 보낼줄 모르기 때문이다.

어떻게 해결해야 할까?

사실 본인의 의도에 맞춰서 branch를 따로 따면 된다.

주의 : 초보자라면 항상 파일 백업을 따 두고 시작하자.

브랜치 생성

검수팀이라면, proofread branch를 따로 따서

1주차
           local  -----> origin  -----> upstream
main         A             A               A
proofread    A'            A'

개발팀의 검수 이후 최종 PR 결과(A'')을 local에 sync

2주차
           local  -----> origin  -----> upstream
main         A''           A''             A''
proof(1주)   A'            A'              A'
proof(2주)  A''+B'         A''+B'          

이렇게 관리하면 절대 conflict가 나지 않는다.


본인의 repository(origin)에 가서, branch를 보면 'view all branches'가 보인다.


여기 브랜치 관리에서 'New branch'를 누르게 되면


upstream / origin의 어떤 브랜치를 source root로 브랜치를 딸 지 결정할 수 있다.
위의 예시를 기반으로 위를 선택하면 A''가, 아래를 선택하면 A' 소스부터 시작하는 branch가 새로 파진다.
그러면 local에서는 해당 소스를 받아와서 새로 작업하면 된다.

git fetch origin # 내 origin의 정보를 가져오고 싶어.
git checkout -b proofread origin/tutorial # 내 local에 proofread라는 branch를 만드는데, 내 github 저장소의 tutorial 브랜치에 있는 내용을 가져오고 싶어. 그리고 branch를 proofread 브랜치로 바꿔줘.

원래 브랜치에서 개발하고 있는 내용이 있다면, 잠깐 stash라는 무적의 명령어를 사용해서 각 브랜치에 임시보관할 수 있다. 물론 개발하고 있는 내용을 현재 branch에 커밋하는게 가장 안전하지만, 혹시나 secret key같은 것이 같이 커밋되면 안되는 경우에는 stash를 사용한다.

git stash
git fetch origin
git checkout -b proofread origin/tutorial
# 작업 시작
git add .
git commit -m "작업 다함 ㅋㅋ 개꿀"
git push origin/proofread
# 작업 끝
git checkout main
git stash pop

그리고 github에 가 보면, PR을 날릴 수 있다.
나오지 않는 경우는 직접 눌러서 compare across forks 버튼을 눌러 서로 다른 저장소의 서로 다른 브랜치끼리의 PR이 가능하다. 이렇게 하면 원하는대로 특정 커밋만 보낼 수 있다.

이런 식으로 작업하면 된다. 혹시나 수술 집도가 들어갔을 경우는 아래에서 다루겠다.

이미 수술 집도가 들어간 경우

커밋이 진행됐으면 진행됐을수록, 작업을 중첩으로 했으면 했을수록 더더욱 손을 대기가 어렵다.
conflict가 나지 않으면, github(origin)에서 sync fork를 눌렀을 때 다음과 같은 화면이 뜬다.

이럴때는 무소유 정신으로 Discard 9 commit을 해서 아예 upstream과 똑같이 동기화를 해주거나, Update branch로 최신 upstream 위에 내 commit을 얹어주면 될 일이다. 그러나 문제는 다음과 같다.


이 경우에는 충돌이 나서 Sync fork가 되지 않는다. 그러면 로컬에서 충돌 해결을 한 후 동기화해줘야 하는데, 그 과정이 쉽지 않다.

나는 급하다

지금 동기화가 문제가 아니라 작업을 제출해야 하는 상황이면, 파일을 백업해두고, 브랜치를 위의 방법처럼 새로 파서 파일을 넣어서 먼저 제출한다. 추후 cherry-pick 기능을 이용해 main branch에서 해당 커밋을 가져올 수 있다.

난 너보다 더 급해서 이미 main 브랜치에 커밋을 해버렸다

이럴 때에는 체리픽( cherry-pick ) 기능을 이용해 원하는 커밋을 가지고 올 수 있다.
체리픽의 어원은 체리 피킹에서 나왔는데, 체리 피킹(cherry picking)은 '케이크 위에 얹어져 있는 체리만 집어먹는 행위'라는 뜻으로, 어떤 회사의 제품이나 서비스 중 인기 있는 특정 요소만을 골라 경제적으로 취하려는 소비 현상을 비유하는 용어였다. 깃에서는 이 단어를 가지고 내가 원하는 커밋만 가져올 수 있게끔 지원한다.
내가 원하는 이럴 때에는 다음의 방법대로 할 수 있다.

git log
---
commit 2ef0c22671ab9b72534c2f57966a4e97494f188c (HEAD -> main, origin/main, origin/HEAD)
Merge: 6f97cad 4d92521
Author: 
Date:   Mon Jan 13 22:26:25 2025 +0900

    Merge branch 'LangChain-OpenTutorial:main' into main

commit 4d92521a5378a37225b5336f470f5a09a0561fb5
Merge: 5a125c5 4731410
Author: 
Date:   Mon Jan 13 06:27:12 2025 +0900

    [GitBook] Update: Uploaded README.md file (#332)

commit 47314103c61e9e9f1e92a0d0fa2ef1101601c820
Author: 
Date:   Mon Jan 13 06:25:40 2025 +0900

    [GitBook] Update: Uploaded README.md file

    [GitBook] Update: Uploaded README.md file

git log 명령어를 입력하면, 커밋에 대한 해시번호가 나온다. 이 해시번호를 사용해서, 내가 있는 브랜치에서 명령어를 입력하면 된다.

git cherry-pick 2ef0c22671ab9b72534c2f57966a4e97494f188c 4d92521a5378a37225b5336f470f5a09a0561fb5
git add . # 또는 git add 파일명
git cherry-pick --continue
git commit -m "원하는 것만 가지고 오기"
git push origin/proofread

만약, cherry-pick 중 문제가 터졌다면 충돌을 해결하고 다시 add 후 --continue로 마무리하면 된다.
답이 나오지 않는다면

git cherry-pick --abort

로 체리픽을 중단하고 원래 상태로 돌아가면 된다.

Sync 맞추기

이제, 남은 Sync를 맞추는 일만 남았다. 도식화하면 아래의 작업을 할 것이다.

                1. fetch
   ↙─────────────────────────────────┐
 local ------->  origin  ------->  upstream
   2.   push
resolve conflict

local에서 upstream의 작업 과정을 가지고 와서, local에서 충돌을 해결한 뒤에 다시 origin에 변경 사항을 작업해 줄 것이다.

git fetch upstream
git merge upstream/main

# 충돌 해결 : IDE 또는 VIM 등...

git add file # 충돌 해결한 파일 커밋
git commit -m "resolve conflict"

git push origin main

결론

현실이 차가워도 내 깃은 더 차갑다. 위에서 설명한 것은 이상적인 시나리오고, 현재 작업하고 있는 상황에 따라 충돌이 날 확률이 높고, abort나 rebase등을 섞어 사용하면서 해결하다보면 어느새 내 완성품이 아래처럼 보인다.


이걸 설명하고 있는 나도 리포지토리를 수없이 날려봤고, 아직도 깃 명령어도 잘 모른다. branch를 새로 파는것도 2년 전쯤 한번 해보고 말았다. cherry-pick이 있는 것도 알았지만, 왜 쓰는지도 잘 몰랐고 git 전략도 불필요하다고 생각했으나 실제 상황에 직면해보니 너무나 좋은 기능이고 또 잘 알게 되었다. 이런 기회가 아니었다면 아마 더 나중에 까마득할 때 써볼 것 같아 이런 상황이 너무 즐겁고 재미있게 공부하고 있다.

'GIT' 카테고리의 다른 글

TIL - 아무것도 못 본척 해야 돼! "REVERT"  (0) 2023.02.06
TIL - 협업시 Repository는 FE/BE로 나눈다.  (0) 2023.02.06

오늘 참여하고 있는 랭체인 글로벌 튜토리얼을 제작하는 프로젝트에서 일괄적으로

!pip install package

구문을 아래와 같이 바꾸었다.

%pip install package

!pip 명령어가 돌아가지 않는것도 아니었는데, 왜 그렇게 바꿔야 했을까?

!pip install package
%pip install package 

둘의 차이는 무엇일까?
바로 (!)는 시스템 명령어, (%)는 매직 명령어라는 것이다.
OS에서 실행하는 시스템 명령어와는 달리, Jupyter Notebook이나 IPython에서 제공하는 매직 명령어는 다음 부분에서 시스템 명령어와 차이를 보인다.

  시스템 명령어(!) 매직 명령어(%)
역할 시스템 쉘 명령어 실행 IPython 및 Jupyter에서 제공하는 명령어
문법 !<쉘 명령어> %<매직 명령어>(라인 단위) 또는 %%<매직 명령어>(셀 단위)
예시 !ls %time
Python 변수 지원 Python 변수 삽입 가능 {} 대부분 직접 사용 가능
실행 환경 OS의 shell Jupyter Notebook / IPython

시스템 명령어가 있는데 매직 명령어는 왜 쓸까?

!pip install package를 ipynb파일의 code 셀에서 돌려 보면, 잘 돌아가는것을 알 수 있다. 그런데도 왜 매직 명령어를 쓰는 걸까? 이를 이해하기 위해서는, Jupyter Notebook의 동작 원리부터 이해해야 한다.

Jupyter Notebook의 동작 원리


주피터 노트북은 위와 같은 구조를 가지고 있다. (참조 : https://sawyer-jo.tistory.com/72)
주피터 노트북이 설치된 웹 브라우저(jupyter lab)나, VSC에서 제공하는 jupyter notebook extension을 설치하면, ipynb 파일 내부에 코드를 실행할 수 있는 셀이 제공된다.
이 셀을 위의 사진의 클라이언트(Client)라고 이해하면 쉽다. 이 셀에서 코드를 작성하고 run을 하면, 웹에서는 제공되는 앱 서버, 로컬에서는 로컬 앱 서버에서 '커널'이라는 핵심 컴포넌트에서 코드를 실행하게 된다.

커널

커널은 위에서 설명했다시피, 실제적으로 코드를 실행시키는 파이썬 인터프리터(엔진) 위에 존재한다. 사용자가 작성한 코드를 받아서 파이썬 인터프리터에 코드를 가져다 주고, 그 결과를 반환받아 Jupyter Notebook이라는 사용자 인터페이스에 포매팅하여 제공한다. 다음의 비유로 이해하면 쉽다.
Python Restaruant

  • 손님 : ipynb 셀
  • 웨이터 : 커널
  • 요리사 : 파이썬 인터프리터
  • 요리사마다 다른 주방 : 가상 환경
    Jupyter에서는 프로그램 언어별 여러가지 커널을 지원하지만, 가장 많이 사용되는 커널은 Python용 ipykernel이다.
    통합 환경에서 커널을 설치하였다면 다음과 같은 명령어로 확인할 수 있다.
    pip show ipykernel

그리고, python이 여러 개 설치되어 있거나, 가상 환경을 이용 중이라면, 프로젝트 내부에서 명령어로 확인할 수 있다.

PS C:\Users\User\Documents\langchain-kr> pip show ipykernel

Name: ipykernel
Version: 6.29.5
Summary: IPython Kernel for Jupyter
Home-page: https://ipython.org
Author:
Author-email: IPython Development Team <ipython-dev@scipy.org>
License: BSD 3-Clause License

...

환경 통합

따라서, 프로젝트마다 다른 커널을 사용할 수 있기 때문에 패키지의 분리가 중요하다. pip install package는 위의 예를 기반으로 하면, 요리사가 새로운 재료를 시장에서 사 오는 행위라고 생각하면 된다.

%pip install을 하는 경우, 현재 Jupyter Notebook의 kernel의 가상 환경 위에 패키지를 설치한다. 각각의 요리사가 레스토랑 규정에 따라 재료를 요청하고, 그 재료를 자신의 주방에 넣어놓고 쓰는 행위라고 볼 수 있다. 매직 명령어는 현재 활성화된 Jupyter kernel의 환경에만 패키지를 설치하는 것을 원칙으로 하기 때문이다. 커널이 가상 환경을 사용 중이라면, 가상 환경에서의 설치를 보장한다.

반면 !pip install은 기본 환경에 설치할 수 있어 의도하지 않은 위치에 패키지가 설치될 수 있다.
요리사가 레스토랑 규정을 무시하고 시장에서 재료를 사 와서 공용 주방이나 레스토랑이 대충 관리하는 주방(전역 python 환경 또는 기본 설정된 pip와 연결된 환경)에 설치하는 행위라고 볼 수 있다.

!pip install은 잘못된 방식은 아니다. 그러나 Jupyter 커널이 가상 환경을 사용중인 경우, !pip install은 가상 환경이 아닌 시스템 Python 환경에 패키지를 설치해버릴 수도 있기 때문에, 잘못하다가 패키지가 꼬여버릴 수가 있어 %pip install을 권장한다.
패키지가 충돌하거나, Jupyter kernel이 패키지를 찾지 못하면 그 때부터는 문제가 더 어려워지기 때문이다.

추가 기능성

매직 명령어는 IPython 커널에 의해 해석되기 때문에, 추가 기능을 제공한다.

%lsmagic

으로 다양한 매직 명령어를 확인할 수 있다.
jupyter lab 또는 본인 ipynb 파일에서 %lsmagic 명령어로 확인해보자.

 가끔 커피챗 등으로 개발자들을 만나거나, 개발 업무에 대해 상대방에 설명해야 할 때가 있다. 그럴때면 가끔 듣는 말이 있다.

개발에 정말 열정적이시네요!

 

그런데, 나는 생각보다 개발에 열정이 있지는 않다. 오히려 요즘은 퇴근 후 집에 누워서 유튜브 보면서 드러누워있는게 일이라 오히려 반성하고 있을 정도다. 그런 내가 정말 열정적으로 개발에 임할 때가 있다. 보통 둘 중 하나인데, 다음과 같다.

  • 아무것도 모를 때
  • 분노했을 때

 아무것도 모를 때는 열심히 하는게 당연하다. 예를 들어 회사에서 JSP에서 React.js로 코드 마이그레이션 업무를 받았을 때, 나는 Java만 알고 Javascript에 대해서는 무지했으므로, 알지 못하면 일을 할수가 없어서 살아남기 위해 진짜 열심히 개발공부를 했다.

 그리고 오늘 얘기할 부분, '분노했을 때'이다. 부트캠프에 다니면서 정말 하루에 잠자는 시간 외에는 정말 공부만 했었다. 물론 개발이 재밌어서도 있고, 좋은 동료를 만나서도 있지만 그 이전에는 기저가 있었다.

 

 내가 수료한 부트캠프를 다니기 이전에, 다른 부트캠프를 다녔었다. 어떤 백엔드 언어의 바이블같은 책이 있었는데, 그 책의 저자가 직접 만든 부트캠프를 먼저 갔었다. 가격도 정말 비쌌다. 조금만 과장을 더하면, 지금 수료한 부트캠프의 전체 수강료가 그 부트캠프의 1달 수강료와 맞먹었다. 내가 모아둔 돈을 다 털어서 갔기에 정말 열심히 하려고 했고, 간절한 마음으로 갔다. 질문도 많이 하고, 매주 보는 시험에서 항상 최상위권의 성적을 유지했다.

 

 문제는, 다닌 지 2주일만에 학원에서 잘리게 되었다. 사유는 간단하고도, 어이가 없었다. '질문을 너무 많이 해서.' 처음에는 내가 다른 이유로 잘못했나 싶었다. 왜냐면 다닌지 1주일만에 학원에서 잘렸던 사람이 있었는데, 잘린 사유가 '질문을 너무 안 해서'였으니까. 이 글을 보는 사람들도 내가 잘못된 질문을 했겠거니 할 수도 있겠지만, 나는 맹세코 개발에 관련된 쓸데없는 질문을 하지도 않았고, 또 무슨 수다맨마냥 너무 여러번 질문을 하지도 않았다. 잘리던 날 내가 마지막으로 했던 질문은 '선생님, 컴퓨터는 1초를 어떻게 세나요?' 였다. 지피티도 없을 때라, 질문에 대한 해답을 개인적으로 찾아보기도 어려웠고, 블로그에 정말 많은 잘못된 정보를 봤던 때라 가장 빠르고 확실하게 정답을 알아낼 수 있는 방법은 질문 뿐이었다.

 

  당연히 잘리던 날 거의 울면서 '앞으로는 질문을 절대 안 하겠습니다' 라는 말을 해도, 그 외곬수의 마음에는 이미 내가 없었다. 무슨 짓을 해도 안 된다고 했고, 다음날 학원에서 만난 동기들은 너무나 의아한 표정으로 나에게 물어봤다. '니가 잘렸다고? 도대체 왜?' 그렇지만 나는 '질문을 너무 많이 했대'라는 말 밖에는 할 수가 없었다. 문제는 그 다음이었다. 환불 처리에서도 문제가 있었고, 음식점에 컴플레인 한번 걸어본적 없던 내가 국민신문고에까지 글을 남겼었다. 그제서야 일부 환불을 받을 수 있었다. 자기가 잘라 놓고. 그 후로는 머릿속에 단 한가지 생각밖에 안들었던 것 같다. '내가 정말 어마어마한 사람이 되어 복수해 주겠다'고. 그렇게 정말 한동안 미친듯이 공부했었던것 같다. 물론, 새로운 부트캠프에서 만난, 내 새로운 개발 인생을 열어준 매니저님이 '질문하시면 저도 답변해드리면서 공부할 수 있어서 좋으니, 질문을 꼭 해주세요'라고 말씀해주셨던 부분도 컸지만.

 

 그렇게 열심히 부트캠프를 다녀 최상위 수료생으로 마친 뒤, 몇 달간을 쉬고 첫 회사에 들어갔다. 쉬운 결정은 아니었다. 기존의 사업을 할 때보다 연봉이 1/3토막이 되었으니까. 그래도 원하던 연봉이 있어서(사규 신입 연봉테이블과 크지 않았다) 되지 않을까 하고 여쭈어봤는데, 답변이 충격적이었다. '보여준 일이 없으니 그만큼은 주지 못 할것 같아.' 그 날부터 내가 어떤 사람인지 보여주겠노라고 회사 입사 이후 정말 궂은일 가리지 않고 이 일, 저 일을 다 했다. 물론 불만은 있지만, 그래도 회사에서 백엔드, 프론트엔드, 또 AI 트랙까지 모두 경험해볼수 있었던것이 너무 감사하고 좋았다. 

 

 돌아보니, 그냥 열심히 살았던 것 같은데, 어떻게 보면 분노가 이끄는 삶을 살았던 것 같다. 나는 분노를 부정적 스트레스로 풀지 않고, 긍정적 스트레스로 받아들여 더 열심히 살고자 하는 경향이 강하지만, 그래도 분노가 주 원동력이 되는 것 보다는 앞으로는 재미를 원동력 삼아 나아가고자 한다. 세상에 '노력하는 자는 즐기는 자를 이길 수 없다'는 속담은 있지만, '즐기는 자는 분노하는 자를 이길 수 없다'는 속담은 없으니까.

'잡설 > 회고' 카테고리의 다른 글

24년도 4월 회고  (0) 2024.05.05

 

 나는 예전부터 우리집에서 '마이너스의 손' 또는 '파괴왕'으로 유명했다. 집에서 물건을 고친다고 고쳐보면 항상 물건이 박살나기 일쑤였고, 뭘 만들때도 항상 손이 떨려 제대로 만들 수 없었다. 이 특성은 아마 어머니로부터 유전받은것이 분명한데, 아버지는 옛날부터 항상 물건을 잘 고치셨고 내 동생들 또한 그랬기 때문이다.

 

 이게 무슨 상관이야? 하는 사람들도 있겠지만 이 이유로 인해 개발자가 되기 전까지는 하드웨어 부분에서 아예 손을 떼고 동생들에게 모든것을 일임했다. 개발자가 되어서야 비로소 필요에 의해 조금씩 하드웨어를 손대다보니, 조금씩 흥미가 짙어져 이제는 조금씩 장비들을 사서 연결해보고, 조립해보고 있다.

 

 이사를 조금 더 큰 집으로 간 후, 기존과는 달리 집에 네트워크가 잘 통하지 않았다. 집의 구조도 구조라, 작은 방에 모뎀을 넣어야 하길래 넣었는데, 내 방까지 인터넷 신호가 전달되지 않았다. 부랴부랴 저 네트워크를 끌어올 수 있는 방법을 생각했고, 메쉬로 집안에 와이파이를 채울 것인지, 아니면 증폭기로 무선 신호를 연결할 것인지 선택해야 했다.

 

  선택 : Mesh vs Extender

먼저 방법을 확인하니, Mesh 시스템으로 집안에 균등하게 와이파이를 구축하거나, Extender로 기존의 무선 신호를 확장할 수 있다.

Mesh와 Extender의 차이는 다음과 같다.

  Mesh Extender
(상대적)가격 비쌈 저렴함
초기 세팅 복잡함 비교적 쉬움
성능 데이터 손실이 거의 없음 디바이스가 많이 연결될수록 성능 저하 있음

 

외에 다른 차이도 있지만, 위 3가지 조건을 보고 결정했는데, 처음 이사할 때만 해도 회사 일도 스프린트를 몰아서 하던 시기였고 이래저래 돈이 나갈데가 있어 나는 Extender로 결정했다.

지금 생각해보니 그냥 Mesh 할걸, Extender의 가격이 훨씬 저렴해졌다. 지금 찾아보니 3달만에 25% 할인이라니 말도안돼...!

 문제는 하나 더 있었는데, 아까 설명했던 귀차니즘과 더불어 iptime을 치니 나오던 수많은 모델들이 가격도 천차만별이고 모델명도 이상해서 대체 뭔지 몰랐기에 그나마 쉬워보이는 무선확장기를 구매했었다.

그리고는 동생을 불러 설치를 시키고 동생이 이렇게 하면 돼~ 라는 말만 듣고 몇달이 지났다. (생각해보니 아직도 '자발적' 하드웨어 실험으로의 태도는 좀 먼것 같다.)

이번에는 데스크탑을 내 방에 설치할 일이 있었는데, 데스크탑에는 항상 유선 랜만 꽂아두고 사용했어서 무선 랜카드를 사야했었다.

 집에는 2.5GHz를 지원하는 와이파이와 5G를 지원하는 와이파이가 동시에 출력이 되어 일단 랜카드를 사러 다이소에 갔는데, 5G는 지원하지 않지만 2.5GHz를 지원하는 저렴한 최대 300ms짜리 무선 랜카드를 팔길래, 사온 뒤 pc에 꽂고 오랜만의 휴가에 게임을 잠깐 했었는데, 연결이 너무 잘 안되길래 도대체 이게 속도가 얼마야 하고 보니 10ms가 찍히는 것이었다.

 믿을 수 없는 속도에 쿠팡으로 와이파이 속도가 문젠가 싶어서 보니 현재 wi-fi6까지 찍히는 무선 랜카드를 팔길래 구매했다. 그런데 무선 랜카드를 꽂아도 속도가 너무 안 나오길래 그제서야 부랴부랴 문제점이 뭔지 찾기 시작했다.

 

 문제 1. 세팅을 해두지 않은 와이파이

 첫 번째 발견은, 결론부터 말하자면 무선 확장기에 와이파이 세팅이 되지 않았었다. 저 확장기는 2.5GHz와 5GHz를 모두 수신해서 신호를 송신할 수 있는 모델인데, 5GHz 수신기에 불이 들어오지 않았다. 분명히 와이파이 이름은 iptime_extender_5G였는데, 그것만 믿고 착각을 한 것이다. 

사용한 모델의 무선 확장방식은 여러개인데, 입력으로 들어온 2.4Ghz와 5Ghz를 이용해 여러가지 노선으로 신호를 확장시킬 수 있었다.

찬찬히 설명서를 읽고 따라해보니, 기존에 있던 2.4Ghz를 받아와서 위 사진의 빨간 박스처럼 2.4Ghz와 5Ghz로 뿌리고 있었던 것이다. 어쩐지 속도가 너무 안나오더라니... 세팅만 해 두니 바로 속도가 10배 올라갔다. 

 

 문제 2. 국제 규약

 속도는 해결했고, 집의 모뎀에서 나오는 와이파이가 Wi-Fi 6으로 나오는것도 인지했는데 어째선지 5G로 연결해봐도 Wi-Fi 6이 세팅되지 않았다. 문제는 국제 규격에 있었다. 찾아보니 국제 규약은 다음과 같다.

규약명 (구) 규약명 주파수 특징
Wi-Fi 1 802.11b 2.4Ghz  
Wi-Fi 2 802.11a 5Ghz  
Wi-Fi 3 802.11g 2.4Ghz  
Wi-Fi 4 802.11n 2.4Ghz & 5Ghz Dual-Band 지원
Wi-Fi 5 802.11ac 5Ghz Giga-Wifi 지원 시작
Wi-Fi 6 802.11ax 2.4Ghz & 5Ghz + 1~6Ghz ISM  

쿠팡에서 구매할 때 모델들이 전부 다 AC-1000M AX3000U 이런식으로 되어 있길래 그냥 모델 명이 저런건줄 알았는데, 알고 보니 국제 규약이었다. 내가 구매한 모델을 보면 iptime "AC1200" "Giga-Extender" 이므로 Wi-Fi 5에 맞춘 모델이었던 것이다. 그래서 아무리 모뎀이 Wi-Fi 6을 지원해도, 무선 확장기가 Wi-Fi 5를 이용하기에 Wi-Fi 6이 되지 않았던 것이다.

  추가)

 윈도우를 기준으로, CMD에서 다음의 명령어로 인터페이스 및 드라이버를 확인할 수 있다.

$ netsh wlan show interfaces

시스템에 1 인터페이스가 있습니다

이름          : Wi-Fi 2
설명          : Realtek 8832CU Wireless LAN WiFi 6 USB NIC
...
SSID         : U+Net9443_5G
...
송수신 장치 종류 : 802.11ac
인증          : WPA2-개인


$ netsh wlan show drivers

인터페이스 이름 : Wi-Fi 2

드라이버 : Realtek 8832CU Wireless LAN WiFi 6 USB NIC
...
지원되는 주파수 형식 : 802.11b 802.11g 802.11ac 802.11ax 802.11a
...

이제 내 와이파이 규약명이 무엇인지, 또 내 랜카드는 어느 Wi-Fi를 지원하는지 이해가 됐다.

 

문제 3. SSID

 다시 연결하려고 보니, 분명히 세팅 전에는 iptime_giga_extender5 와이파이가 있었는데 세팅을 하고 나니 없어졌길래 잘못 세팅한 줄 알고 한참을 찾았는데, 찾고 보니 세팅이 완료되면 기존의 SSID를 따라가고, 한 주파수에서 다른 주파수를 분리하면 그제서야 SSID가 따로 세팅된다고 되어 있었다. SSID가 무엇인지는 문제 2번의 cmd에서 확인해 보니 기존 사용하던 와이파이의 이름이었다.

 

 결론

 생각보다 찾으면 금방 나오는 정보들임에도 불구하고, 귀찮거나 바쁘다고 또 습관처럼 미뤄놨던 나를 반성한다. 알고 나니 집에 쌓여있던 랜카드나 공유기 모델명이 이제 무슨 모델을 지원하는지, 또 네트워크가 어떻게 돌아가고 있는지 잘 확인이 된다. 앞으로는 네트워크 말고도 조금 더 하드웨어 레이어에 있는 것들을 만져보면서 탐험해야겠다. 또 글을 쓰면서 다시 한번 제품설명을 보니 iptime Easy Mesh Agent를 지원한다는데, 다음에 시간될때는 동일 기기를 Extender로 쓰느냐, Mesh Agent로 쓰느냐에 대해 포스팅하겠다.

+ Recent posts