Anaconda 사설 로컬 저장소 채널 만들기

프로그래밍 팁 2022. 7. 27. 23:07

 지금 이순간에도 등장하는 새로운 인공지능 알고리즘을 테스트해보고자 라이브러리를 맞추어서 설치해야 하는데 라이브러리 종속성 충돌로 인해 상당히 골치아픈 일이 많을 것입니다. 이 때 라이브러리를 가상 환경에 만들어 제공하는 Anaconda는 굳이 컴퓨터를 리셋하지 않더라도 간단하게 독립된 가상 환경에서 다른 인공지능 알고리즘의 종속성에 구애받지 않아도 되기 때문에 인공지능 라이브러리 관리 프로그램으로 널리 사용되고 있습니다.

 Anaconda는 딥러닝에 필요한 라이브러리를 인터넷을 통해 제공하고 있습니다. 특히 conda-forge 채널은 상당히 많은 라이브러리들을 제공하고 있어 사실상 Anaconda에서 가장 많이 사용되는 저장소이기도 합니다.

 

 그러나 pytorch와 같이 가장 주요한 딥러닝 라이브러리의 경우 용량이 큰데다가 다운로드 받는데에만 상당한 시간이 소요됩니다. 또한 기업 혹은 국가 기관과 같이 보안을 요구하는 곳에서 혹여나 해커에 의해 악성코드가 숨겨진 라이브러리가 유입될 경우 무형 자산의 심각한 손실이 발생하기도 합니다.

 이러한 환경에서는 자주 사용되는 라이브러리를 로컬 공간에 저장해두고 사용하는 방법이 요구됩니다. 같은 망 내에 있는 컴퓨터를 통해 자료를 받기 때문에 다운로드 속도 향상은 물론 검증된 자료만 사설망 내에 활용함으로서 외부로부터 악성 공격의 유입을 막을 수 있습니다.

 이러한 환경을 만들기 위해 Anaconda의 라이브러리 채널을 저장하여 사설 저장소를 구축하는지에 대해 알아보도록 하겠습니다.

 

기본 개념

 

Python - 프로그래밍 언어 中 하나로서 PIP(파이피)를 통해 수많은 라이브러리를 사용할 수 있습니다.

PIP - Python의 라이브러리 설치를 도와주는 패키지 인스톨러로서 명령어만으로 저장소에서 다운로드 하여 설치할 수 있습니다.

ANACONDA - PIP의 최대 단점인 root 호환성 문제 및 서로 다른 버전의 패키지를 요구하는 인공지능 알고리즘들을 별개의 가상환경을 생성하여 관리해주는 사설 패키지 저장소입니다. 단지 원하는 환경을 명령어로 부르는 것 만으로 다른 라이브러리와의 의존성 충돌을 피하는 것 만으로도 개발자들의 생산성 향상에 큰 기여를 하고 있습니다.

CHANNEL - Anaconda의 저장소를 이루는 기본 단위로서, 자신이 원하는 저장소를 선택하여 패키지를 설치할 수 있습니다.

conda-forge - Anaconda의 channel중 하나로서 상당히 많은 패키지들을 제공합니다. 패키지를 설치하고자 할 때는 다음과 같은 명령어를 사용합니다.

 

$ conda install -c conda-forge 패키지명

 

위 명령어에서 -c는 원하는 채널을 설정하는 명령어로 conda-forge 채널에 있는 패키지를 받아오겠다는 명령어입니다.

 

 지금까지 Anaconda 및 채널의 개념에 대해 설명드렸습니다.  이제부터 우리가 직접 Anaconda의 채널을 만들고 이를 외부에 공개하는 방법에 대해 살펴보도록 하겠습니다.

 

1. Channel 저장소를 미러링 하여 가져오기

 자신만을 위한 Channel 저장소를 만들기 위해 저장소에 다운받았던 나의 환경에 있는 패키지를 그대로 사용하는 방법이 있습니다만, 다른 환경에서 설치할 때 의존성 문제가 발생할 수 있으므로 인터넷 상에 공개되어 있는 Channel 저장소를 미러링하여 가져오는 방법에 대해 설명 드리겠습니다.

 

 먼저 Channel 저장소의 패키지들을 로컬 저장소로 복사해주는 패키지인 conda-mirror를 설치합니다.

$ pip install conda-mirror

혹은

$ conda install -c conda-forge conda-mirror

혹은

$ git clone https://github.com/conda-incubator/conda-mirror

 

 다음으로 로컬 저장소로 복사하고자 하는 채널을 미러링 합니다. 이를 위해 사용되는 conda-mirror의 사용 옵션에에 대해 간단히 살펴보도록 합시다.

-h, --help
conda-mirror의 옵션들을 확인하실 수 있습니다.
--upstream-channel UPSTREAM_CHANNEL
로컬 저장소에 복사하고자 하는 채널명 혹은 주소를 입력합니다.
--target-directory TARGET_DIRECTORY
upstream channel에서 미러링할 로컬 저장소의 경로를 설정합니다.
--temp-directory TEMP_DIRECTORY
로컬 저장소의 경로에 저장하기 전 임시로 저장하고자 하는 폴더 경로를 입력합니다.
Windows의 경우 사용자 계정의 Temp 내에 임의로 생성됩니다. 이 경우 폴더 관리가 어려울 수 있습니다.
--platform PLATFORM
미러링 하고자 하는 운영체제의 종류를 선택합니다. {'linux-64', 'linux-32','osx-64', 'win-32', 'win-64'}
-D, --include-depends
받고자 하는 패키지의 조건을 설정할 수 있습니다. 일종의 화의트리스트 기능을 합니다.
--config CONFIG
blacklist 및 whitelist를 설정한 yaml 파일을 불러옵니다.
--num-threads NUM_THREADS
다운로드를 위해 사용할 스레드의 갯수를 입력합니다. 0을 입력할 경우 사용가능한 모든 스레드를 설정합니다.
--minimum-free-space MINIMUM_FREE_SPACE
로컬 저장소의 용량이 제한되어있을 경우 해당 용량이 초과하지 않도록 mb 단위로 설정합니다.
-k, --insecure
SSL 에러 이슈로 에러가 발생할 경우 SSL을 무시하고 사용합니다.

만약 conda-forge에서 linux 64비트 라이브러리 전체를 다운로드 받고자 한다면 다음과 같이 입력합니다.

 

$ conda-mirror --upstream-channel conda-forge --target-directory local_mirror --platform linux-64

 

 conda-forge는 Python의 거의 모든 패키지를 제공한다 해도 무방할 만큼 모두 미러링을 하게 될 경우 상당한 용량을 감당해야 하는 부담감이 있습니다. 우리들이 사용하고자 하는 라이브러리 패키지가 일부이기에 용량만 차지하는 패키지까지 저장해야할 이유가 없지요. 이를 위해 config를 통해 blacklist와 whitelist를 설정하여 원하는 패키지만 설치할 수 있습니다. blacklist란 다운로드를 원치 않는 패키지가 있는 경우 이를 특정고자 할 때 사용하며, whitelist는 특정한 패키지만 다운로드 하고자 할 때 해당 패키지의 상세 내용을 적어 이외의 패키지를 받지 않기 위해 사용합니다. 이를 위해  config에 적용하고자 하는 yaml 파일을 생성합니다.

 

 만약 여러분들이 botocore라는 라이브러리만 얻고자 하고 특정 버전 및 사용하고자 하는 파이썬 버전을 한정하고자 한다면 다음과 같이 내용을 입력합니다.

blacklist:
- name: "*"
whitelist:
- name: botocore
  version: 1.4.10
  build: py34_0

 

 반면, 특정 패키지중 패키지명에 'agpl'이라는 이름이 들어간 패키지를 제외한 모든 패키지를 설치하고자 한다면 다음과 같은 명령어를 입력합니다.

blacklist:
- license: "*agpl*"

 

 다음으로 예제를 실행해봅시다. 이 예제에서는 특정 버전의 git 패키지만 다운로드할 것입니다.

example.yaml

blacklist:
    - name: "*"
whitelist:
  - name: git
    version: ">=2.32"

 

 다음으로 config를 적용하여 원하는 라이브러리를 conda-forge로부터 가져옵니다.

$ conda-mirror --upstream-channel conda-forge --target-directory 로컬폴더 --temp-directory 임시폴더 --platform win-64 --config .\example.yaml

 

  이 명령어를 실행하면 아래의 그림과 같이 2.32버전 이상의 git 라이브러리 패키지가 다운로도 된 것을 확인하실 수 있습니다.

 

2. 사설 로컬 저장소 채널 지정하기

  이번에는 자신이 갖고 있는 패키지 라이브러리를 사설 저장소 채널로 만드는 작업을 진행합니다. 먼저 conda-build 패키지를 설치합니다.

$ conda install conda-build

 

 로컬 저장소 채널로 만들고자 하는 폴더가 들어있는 디렉토리에 위치한 다음 다음과 같은 명령어를 입력합니다.

 

$ conda index 채널명

 

 실행 후 잠시 기다리면 다음과 같이 채널 디랙토리 내에 index 웹페이지 폴더가 생성된 것을 확인하실 수 있습니다.

 

 

3. 웹서버 구축 및 등록하기

 

 custom 채널 디렉토리에 생성된 index 웹페이지를 열면 다음과 같이 미러링 하였던 저장소 목록이 나타나는 것을 확인하실 수 있습니다.

 

 다음으로 외부에서 미러 서버에 접속할 수 있도록 웹 서버를 설치합니다. 웹서버는 Apache를 보편적으로 사용되고 있습니다.

 자신의 운영체제에 맞는 Apache를 설치하여 웹서버를 구축하도록 합니다.

 

https://mainia.tistory.com/5572

 

윈도우10 아파치 웹 서버 설치하기

아파치 Apache 는 아파치 재단에서 만든 웹 서버 입니다. 윈도우뿐만 아니라 Unix, Linux 등의 운영체제에서 쉽게 운영할 수 있도록 만든 소프트웨어 입니다. 웹 어플리케이션을 만들기 위한 가장 기

mainia.tistory.com

https://askforyou.tistory.com/120

 

[linux] 우분투(ubuntu) 아파치(apache) 웹서버 설치 구축(ufw 방화벽 설정)

아파치 웹서버 설치하기 1. 패키지 목록 업데이트 $ sudo apt-get update 2. 아파치 설치 $ sudo apt-get install apache2 3. 설치 진행 화면에서 "y" 입력 후 진행 3. 설치 완료 후 확인 $ sudo service apache2..

askforyou.tistory.com

 

 Apache httpd를 다운로드하신 후 압축을 풀어 원하는 위치에 설치를 진행합니다. 그 다음 httpd.conf 파일을 열어 4군데를 수정하여 자신의 환경에 맞추어 설정합니다. 

 

 

 위의 절차대로 수행한 다음 설정한 주소를 입력하면 Anaconda Repository 사이트 웹페이지가 나타나는것을 확인하실 수 있습니다.

 

 

4. 구축된 Anaconda custom 저장소 접속하기

 지금까지의 과정을 통해 만든 Anaconda custom 저장소의 Library 패키지를 다운로드해봅니다.

 

> conda install -c http://127.0.0.1/custom-cf git

 

 

 축하합니다! 여러분들만의 Anaconda custom 사설 저장소가 완성되었습니다.

 

 

 

 

 

300x250

열차가 멈추지 않는 정원속 간이역 - 경전전 남평역[2022.06.01]

 

지난 2016년 경전선의 경상도 구간이 현대화 되면서 직선으로 쭉쭉 뻗어나가게 되었음에도 전라도 구간의 서부경전선은 아직까지도 큰 변화는 없어보입니다. 평소 다니는 열차가 많지 않고 수요도 적다보니 개량화에 많은 시간이 걸리는듯합니다.


그런 서부경전선에서도 언제부턴가 공사 소리가 들리기 시작하면서 구간 이설 계획이 진행되고 있습니다. 드디어 서부경전선도 빛을 볼 날이 오는 것일까요?


그러한 와중에 마침 광주에 오게될 기회가 생겨 서부경전선에 있는 역들을 둘러보고자 유일하게 나주시에 위치한 남평역을 찾아가보았습니다.

 

 

 

 

남평역 표지판의 기호가 KTX역으로 표시되어 있군요.

 

 

날씨 맑은 6월 첫째날 찾아간 남평역

 

 

남평역은 하나밖에 없으나 구(舊) 명칭을 붙여주는군요.

 

 

남평역에 나들이 온 사람들을 위해 마련된 간이 밴치

 

 

지금도 화장실은 관리가 되고 있는 것으로 보입니다.

 

 

새하얀 남평역의 자태

 

 

역사 내부는 보존되고 있는 듯 합니다.

 

 

시각장애인을 위한 안내점자까지 마련되어 있군요.

 

 

남평역 바로 맞은편에는 뜬금없이 건널목 신호기가 있습니다.

 

 

역 주변을 걷던 도중 어디선가 새소리가 들려옵니다.

 

 

어떤 새인지는 잘 모르겠지만 도심에서는 만나볼 수 없던 종류의 새였습니다.

 

 

방금전에 보았던 건널목은 알고보니 레일바이크 운영을 위해 만든 것이었습니다. 아쉽게도 레일바이크는 개점휴업 상태였습니다.

 

 

최근까지도 사용되었던 것으로 보이는 남평역 역명판

 

 

열차가 멈추던 시절 이 곳에서 사람들이 타고 내렸겠지요

 

 

타는곳으로 가는 길은 울타리로 굳게 닫혀있습니다.

 

 

특이하게도 역에서 내리자마자 보이는 광경은 흡사 정원같아 보였습니다.

 

 

이 역에 열차가 서던 때엔 손님들에게 비밀의정원으로 인도하는 입구같아 보였을 것 같군요. 

 

 

승객은 없지만 나들이 온 사람들이 아름다운 역 풍경을 보며 멍을 때리는 흡사 역멍을 하기 딱 좋은 풍경입니다.

 

 

300x250

도심속 꼬마열차가 다니는 작은 간이역 - 광주선 극락강역[2022.06.01]

어느덧 2022년 6월이 되면서 여름이 찾아왔습니다. 코로나19 바이러스의 오미크론 변이의 거센 파고가 지나가고 그 위세도 점점 약해져가고 있습니다. 그 덕에 세계로 향하는 하늘길이 조금씩 열리기 시작하였고 염원하던 해외여행도 다시 일상이 되어가는 희소식이 들려옵니다.


한편으로는 감염병 전파 확산 방지를 위한 출입국 제한으로 국내 관광지가 다시 재조명받으면서 국내 여행지 발굴도 이루어지고 있습니다. 그 덕에 굳이 해외를 가디 않더라도 기차만 타고 가도 아름다운 풍경을 감상할 수 있는 여행지를 찾아가는 재미는 더해져가는듯 합니다.


이번 여행은 현재 광주선에서 운행중인 통근열차를 타고 중간 정차역인 극락강역에 다녀와보았습니다.





여행의 출발은 광주역에서부터 시작됩니다.


더이상 KTX 종착역이 아니게 되면서 광주역은 방문할 기회가 거의 없었는데 정말 오랜만에 와봅니다.


지난 2019년 경원선에서 만났던 통근열차를 이 곳에서 다시 보게 될 줄이야!


동두천에서만 달리던 통근열차가 이제서야 본래의 역할대로 광주역에서 부활하였습니다.


이번 여행의 목적지인 극락강역에 다녀와봅니다.


동두천에서 마지막으로 봤던 통근열차를 다시 타게 되어 약간은 어색합니다.


열차 내부는 크게 바뀌지는 않았지만 내부가 더 깔끔해졌습니다.


운행 재개 과정에서 신경써서인지 통근열차에서 느끼던 특유의 냄새가 사라졌습니다.


아마도 그 당시의 냄새는 의자 커버에서 비롯된 것 같군요.


연천에서 보았던 통근열차의 시트는 붉은 와인색이었는데 전동열차의 그 시트 색깔로 바뀌어있습니다.


잠시후 열차가 광주역을 출발합니다.


의외로 광주선 주변은 녹지가 많습니다.


광주송정역까지 짫은 구간이지만 의외로 기차여행을 할때의 설레임이 느껴집니다.


어느덧 열차가 극락강역에 도착합니다.

광주행 무궁화호가 극락강역에서 교행대기중이었습니다.


극락강에서 교행을 마친 두 열차는 거의 동시에 출발합니다.


통근열차는 광주역을 출발한지 8분만에 종착역인 광주송정역을 향해 떠납니다.


통근열차가 순식간에 역에서 멀어져갑니다.


열차를 모두 보내자 극락강역의 아담한 모습이 고스란히 드러납니다.


역 구내가 알록달록하게 꾸며져 있습니다.


작은 간이역 공간이 이렇게 알차게 꾸며져있다니


극락강은 영산강의 다른 이름이라고 하는군요


과연 이 곳은 불교에서 이야기하는 극락일까요?


이 작은역을 찾아오는 손님들은 많지 않지만 손님 맞이만큼은 잘 되있는것같습니다.


역 주변에는 근처 큰길과 아파트단지가 있습니다. 그러나 통근열차를 제외한 열차는 거의 대부분 이 역을 통과하기에 인근의 광주송정역을 사용할겁니다.


드디어 맞이방을 둘러봅니다.


역내 맞이방은 상당히 좁은 편이지만


그 좁은 공간도 최대한 많이 꾸며놓고 있었습니다.


우리나라에서 가장 작은 꼬마역


본래 일부 무궁화호 몇 대만이 서다 가던 역이었으나 광주선 셔틀열차의 등장으로 정차 열차가 많아져 역을 찾는 손님들이 조금이나마 편히 올 수 있게 되었습니다.


중앙선 신림역 1일역장이던 펭수가 극락강역에도 찾아왔을까요?


사실 이렇게 작은 이 역이 주목을 받게된 계기는 내일로 여행객들 덕분일것입니다.


예전 코로나가 없던 시절의 내일로가 그리워집니다.


구내 작은 건널목이지만 건널목명까지 지어주고 상당히 신경쓴 모습입니다.


6월 첫째날이지만 날씨는 벌써 한여름같이 덥습니다.


구름이 솜털같이 날리는 날씨에


광주역에서 출발한 열차가 극락강역에 들어옵니다.


광주역으로부터 출발한지 7분만에 승객을 맞이하는 열차는


때양볕 아래 열차를 기다리던 승객을 집어삼키고


정차중이던 열차를 뒤로 하고


아기자기한 자태를 뽐내는 역 주변을 둘러보던 도중

열차는 유유히 서울 용산역을 향해갑니다.


극락강역을 떠난 무궁화호가 광주선을 빠져나올때 즈음 광주송정역에서 출발한 통근열차가 다시 극락강역에 찾아옵니다.


하루에 15번씩 극락강역을 찾아오는 꼬마열차는


오늘도 극락강역을 찾아온 나그네를 집어 삼키고


다시 광주역을 향해 출발합니다.


그렇게 광주역에 도착하면서 저의 광주선 꼬마여행은 끝이 났습니다.


무더운 여름이 찾아오기전 찾아간 극락강역은 상당히 이색적이었습니다. 언젠간 사라질 운명만을 기다리던 간이역들과는 달리 적지만 꾸준히 찾아오는 손님들 덕에 관광지가 된 극락강역의 모습이 마치 간이역으로서의 황금기를 보내는것이니까요.


다음 여행지는 아직 정하지는 않았습니다. 하지만 앞으로도 사라지는 추억들 뿐 아니라 이렇게 아기자기한 추억을 남겨볼 수 있는 추억을 또 한번 만들어보리라 기도봅니다.

300x250

누적방문객수 100만명 돌파했습니다!

흔치않은일상 2022. 5. 30. 19:12

 

 늦깎이 공대생으로 블로그를 시작한게 2014년이었으니 벌써 8년이 되었습니다. 프로그래머로서 배울것도 모르는것도 너무나도 많았던 시절 배움의 즐거움을 누리며 지내온 지난 8년... 항상 부족하기만 하던 저였기에 제 블로그에 지금까지 방문해주신 분이 100만명이 되었다는 것이 한편으로 사람들에게 나의 재능이 조금이나마 도움이 되고 있다는 것에 큰 기쁨을 느낍니다 :)

 

 

 블로그를 처음 개설하였을 때만 해도 넘치는 열정 덕에 한달에 무려 15개의 글을 쓰곤 했었는데 직장을 다니게 되면서 한 달에 글 한 개도 간신히 쓰고 있습니다. 그나마 한 달에 한개 이상 나의 생각을 표현해보자느 본인의 마음가짐 덕에 바쁜 직장생활 속에서도 지금껏 저와 약속을 꾸준히 지켜온 제 자신이 참으로 대견스럽기만 합니다.

 

 프로그래머를 꿈꾸던 8년전만 해도 개발자는 속칭 IT계의 3D 업종이라며 공부해야 할 것도 많고 한창 바쁠때는 새벽까지 잠자는 시간을 아껴가면서 코딩에 빠져 살아야만 합니다. 그러기에 학창시절 나의 미래가 걱정되어 진로를 바꾸고자 고민을 하던 때가 있었습니다. 그러했던 프로그래밍 개발자 라는 위상이 2020년 신종코로나바이러스의 국제적인 창궐과 함께 AI를 필두로한 비대면 시대가 빠르게 다가오면서 IT 시스템 개발 수요가 폭발적으로 늘었고 이로 인해 개발자의 처우가 코로나19 이후의 세계에서는 확연이 달라지게 되었습니다.

 

 이렇게 급속도로 달라지게 된 세계에서도 꾸준이 자신의 꿈을 위해 달려온 나의 길을 돌아보니 방황하던 시간들과 고민들이 결코 헛되지만은 않았구나 하는 생각이 들기도 합니다.

 

 현재 글쓴이는 AI를 필두로한 새로운 시스템을 개발하는 것을 목표로 하고 있습니다. 현재 제가 일하는 분야에서 필요로 하는 다양한 Needs를 해결하는 것에 대해 연구를 하고 있으며 지금보다 더 나은 세상을 만들기 위해 지금껏 그래왔듯 계속해서 앞으로 달가려 합니다.

 

 지난 8년간 저의 블로그를 방문해주신 모든 분들께 진심으로 감사드리며 앞으로 찾아오실 분들에게도 많은 도움 드리고자 앞으로도 계속 분발해보겠습니다!

300x250

Windows Powershell로 긴 명령어를 짧게 만드는 function 함수 사용

공대생의 팁 2022. 5. 12. 19:49

 Windows 환경에서 Python을 사용할 때 pip로 라이브러리를 설치할 때 cmd 혹은 Powershell을 사용합니다. 일반적인 상황에서는 단지

 

 > pip install 라이브러리명

 

 위와 같이 입력만 해줘도 바로 라이브러리를 설치 할 수 있으나 유독 사내망에서는 SSL 인증서 이슈로 인해 조금은 복잡한 설정을 해주어야 합니다.

 

 

python에서 pip install 과정에서 SSL 오류 해결방법

 python의 라이브러리를 사용함에 있어 pip를 통해 관련 라이브러리 패키지를 다운로드 받아 설치할 수 있습니다. 비록 pip가 root 권한 관련 문제가 있지만 그나마 사용할만한 것입니다. 물론 이러

elecs.tistory.com

 

 > pip --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --trusted-host pypi.org install 패키지명

 

 Linux 환경에서는 bashrc에서 설정만 해주면 위와 같은 명령어를 굳이 입력하지 않아도 바로 설치가 진행되지만 Windows 환경에서는 bashrc와 같은 설정을 하기엔 상당히 번거로운 면이 있습니다 

 

 Powershell에서 매번 입력하기엔 긴 명령어의 경우 아래와 같이 function 함수를 사용하면 자신이 정한 임의의 명령어로 아래와 같이 설정할 수 있습니다.

 

1
2
3
function 설정하고자 하는 함수명(){
    입력하고자 하는 긴 명령어
}
cs

 

 만약 여러분들께서 여러개의 Argunent를 명령어로 입력해야 할 경우 foreach 반복문으로 여러번 입력할 수 있습니다.

 

1
2
3
4
5
function 설정하고자 하는 함수명(){
    foreach($arg in $args){
        입력하고자 하는 긴 명령어 $arg
    }
}
cs

 여기서 $args는 자신이 함수를 입력한 다음 여러 개의 인자를 입력할 때 각 인자별로 $arg로 입력이 됩니다.

 아래는 pip의 ssl 이슈를 해결하기 위해 --trusted-host에 3개의 긴 주소명을 적은 다음 설치하고자 하는 다수의 패키지를 설치하기 위한 명령어입니다. 이 때 명령어는 임의로 pipip로 지었습니다.

 

1
2
3
4
5
function pipip(){
    foreach($arg in $args){
        pip --trusted-host pypi.python.org --trusted-host pypi.org --trusted-host files.pythonhosted.org install $arg
    }
}
cs

만약 제가 다음과 같은 명령어를 입력한다면 어떻게 될까요?

 

> pipip opencv-python numpy matplotlib

 

이 경우 pip로 다음과 같은 명령어가 3번 수행되었음을 확인하실 수 있습니다.

 

> pip --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --trusted-host pypi.org install opencv-python
> pip --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --trusted-host pypi.org install numpy
> pip --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --trusted-host pypi.org install matplotlib

 

 위에서 확인할 수 있듯이 긴 명령어를 function 함수로 간편하게 불러올 수 있음을 확인할 수 있습니다.

300x250

석탑, 금성(탑리)버스터미널, 그리고 탑리역[2022.03.26]


코로나19 바이러스 오미크론 변이가 하루 20~30만명의 확진으로 전파되고 있어 매우 위중한 상황임에도 한편으로는 지금까지의 변이에 비해 치사율이 낮아 다소 방역 정책이 완화되고 있는 2022년 봄입니다. 불과 몇주전 확진자가 되어 자연면역을 갖게된 저로서 해외여행 복귀시 격리 면제를 받을 수 있어 한편으로는 지난 4년동안 가지 못했던 해외여행에 대한 꿈을 상상해보기도 합니다. 그러나 러시아의 우크라이나 침공으로 인한 국제적 긴장감으로 인해 갈 수 있는 국가가 그렇게 많지 않은데다가 아직까지도 입국이 제한된 국가가 있어 관련 정보도 계속 찾아보아야만 합니다만..


물론 멍하니 기다리기만 해서는 이 좋은 봄날을 그저 보낼수만은 없겠지요! 그래서 이번에는 중앙선에 위치한 간이역인 탑리역에 가보았습니다.





안동~북영천 구간은 왕복 10회의 무궁화호 열차만 운행되기 때문에 시간에 맞는 열차를 바로 탑승하는데 어려움이 있습니다.

그래서 최근 안동터미널로 자리를 옮긴 안동역에서 무궁화호를 타고 탑리역으로 이동합니다.

약 20분 정도 기차를 타고 탑리역에 도착합니다.

원래 강릉에서 출발했던 열차였는데 출발역이 동해역으로 바뀌면서 행선판을 종이로 덧붙여놓았습니다.
조금은 쌀쌀했지만 그래도 돌아다닐만한 날씨입니다.

승객을 탑리역에 내려준 열차는 다시 달려갑니다.

저를 맞아주는 탑리역의 모습이 심상치 않습니다.

역이 위치한 탑리리에 어울리게 역 건물도 아담한 서양식 탑의 모습입니다.

탑리역 인근 석탑 사진이 걸려있습니다.

화장실 안내판이 옛 철도청 시절 디자인입니다.

열차는 하루 8회로 화본역과 신녕역보다 많습니다.

역의 전체 모습이 성문 앞모습처럼 보입니다.

역 광장은 주민들의 주차장이 되어있습니다.

탑리역 입구 인근 분위기는 마을 골목길처럼 생겼습니다.

탑리리 마을 인근을 돌아다니다가 작은 터미널을 발견하였습니다.

간이역처럼 작고 소중한 크기의 버스터미널이었습니다.

마치 옛날 전성기 시절의 모습을 남긴듯이 버스들을 그린 벽화가 그려져 있습니다.

이 터미널의 전성기 시절엔 이 레스토랑도 한창 잘나갔겠지요?

터미널 대기실에 가봅니다.

상당히 오래되어 보이는 버스요금표가 칠판처럼 분필로 적혀있었습니다.
터미널 건물 내부는 마치 미술관처럼 사진들이 걸려있습니다.

최근 인구 감소로 인해 수요가 줄어들어서린 터미널을 유지하고자 사람들이 찾아올 수 있도록 미술관과 같은 공간을 만들었다고 하네요.

터미널 인근

비석을 판매하는 곳이 있는듯 합니다.

마을 이름의 유래이기도한 국보 탑리리 5층석탑입니다.


동네한바퀴 돌고서 다시 탑리역에 돌아왔습니다.

승객이 많지 않다보니 대기실이 너무나 크게 느껴집니다.

곧 열차가 올 시간이 다가오지만 한산합니다.

상당히 오래되어보이는 오르간. 풍금이라고도 부르지요

이제 슬슬 열차를 타러 가볼까요?

탑리역 바로 옆에는 새로 지어지는 중앙선 철길이 놓이고 있습니다.

탑리역을 떠나기 전 다시 한 번

여느 한적한 간이역답게 열차가 오기 전 고요함이 좋습니다.

기다리던 열차가 들어오고 다시 안동역으로 돌아갑니다.


항상 기약 없던 약속만 이어가다 운좋게도 중앙선 최후의 이설구간인 탑리~신녕 구간 3개역 중 화본역을 제외한 2개의 역에 다녀갔습니다. 사라져가는 풍경들을 기록하며 언제나 행복한 추억들만 남기고 싶은 하루였습니다.

300x250

한적한 마을에 열차가 머물다 가는곳 - 중앙선 신녕역[2022.03.27]

지난 1주동안 코로나19 바이러스에 감염되어 병원 생활을 하다 격리 해제날 극적으로 회복하여 간신히 퇴원하였으나 컨디션이 완벽하게 돌아오지 않아 뭔가 애매하고 복잡한 심정으로 나날을 보내고 있었습니다. 그저 적적하게 앉아서 시간을 보내야 하나 싶었다가 조금은 기분전환 겸 기차 여행을 나서기로 하여 영천 여행을 해보고자 하였습니다.

그래서 평소 열차가 자주 다니지 않아서 갈 기회가 많지 않을 것 같았던 영천시 신녕면에 위치한 신녕에 다녀와 보았습니다.


비록 신녕면이 영천에 속해 있지만 한번 즈음 기차를 타고 가고 싶었습니다.

아직 이른 아침이어서인지 영천역 광장에는 사람 없이 한적합니다.

이번 여행은 영천→신녕→신경주 루트로 이동하려 합니다.

차창 밖 푸르른 밭을 보며 어느덧 봄이 찾아왔음을 깨닫게 됩니다.

이렇게 푸른 풍경을 바라보는 것이야 말로 철도 여행의 묘미 아닐까요?

영천역을 떠난지 10여분만에 신녕역에 도착

생각보다 역에서 내리는 사람들이 있습니다.

신녕역 전후에 위치한 화산역과 봉림역은 현재 운행하고 있지 않습니다.

저를 신녕역에 내려다준 열차는 어느새 떠나가고

신녕역 또한 전형적인 간이역들의 자태를 보여줍니다.

요새는 여객 영업을 하고 있는 역에서도 승차권 발매를 더이상 하지 않는 경우가 생기는 듯 합니다.

간이역에 어울리는 듯한 시 한 구절들

열차는 1회 왕복밖에 없지만 동대구역에서 KTX를 환승하는 방법에 대해 자세히 알려주고 있습니다.

아담한 크기의 창구는 더이상 손님을 기다리지 않습니다.

이 날은 생각보다 구름이 많이 낀 날이었습니다.

열차를 기다리며 신녕역 주변을 돌아다녀봅니다.

안동까지 70km

3월이 끝나갈 무렵 자라나는 파들이 봄의 시작을 알리는 듯 합니다.

이건 침목이었을까요?

과연 이 건널목은 자신의 운명을 알고 있을까요?

철길은 신녕면의 마을을 휘저으며 뻗어있습니다.

기찻길옆 닭장 속의 닭들

구름낀 날씨에 꿀꿀한 느낌

화본역을 향해 뻗어있는 철길

주변을 둘러보던 도중 건널목이 큰 소리를 냅니다.

잠시후 멀리서 큰 소리가 나더디

붉은색의 기관차라 달려옵니다.

건널목이 요란한 소리를 내는 사이 열차가 힘차게 달려갑니다.

그렇게 화물을 끌고가는 열차가 지나가고..

기적소리가 채 가시기 전 건널목이 또다시 요란한 소리를 내고

신녕역에서 교행을 마친 열차가 신녕역을 출발합니다.

생각보다 구도는 상당히 좋습니다.

푸른 밭 사이를 붉은색의 기관차가 거침없이 달려

보아하니 석탄을 싣고 있던 듯 합니다.

다시 역으로 돌아왔습니다.

신녕역 내에는 시멘트 저장소가 있습니다.

신녕역을 떠나기 전

신녕역은 하루에 6회 무궁화호가 정차합니다.

이 열차를 놓치면 다음 열차를 무려 5시간을 기다려야 합니다.

1시간 동안의 짧은 여행이었지만 아쉬움을 뒤로 하고 신녕역을 떠납니다.

경주역이 영업 종료된 이후 무궁화호는 신경역에 정차합니다. KTX만 탈 수 있던 신경주역에서 무궁화를 타고 온 것이 아직은 낮설기만 합니다.

신녕역은 생각보다 고요하고 조용한 마을에 위치해 있습니다. 물론 이 고요한 마을에서도 몇년 후면 더이상 열차를 볼 수 없게 되겠지요. 비록 화본역 만큼의 개성있는 모습은 아니지만 마을 한 구석에서 주민들의 발이 되어주는 모습만큼은 추억으로 많은 사람들의 추억으로 간직할 수 있었으면 합니다.

300x250

코로나19와의 사투(3) - 5일만에 퇴원하다

흔치않은일상 2022. 3. 19. 00:07


 2022년 3월 17일 코로나19 일일 확진자수가 62만 1328명이 발생할 정도로 오미크론 변이의 위력은 엄청났습니다. 비록 이렇게 글을 쓰고 있는 저는 지금 무사히 퇴원하였지만 실시간으로 수많은 사람들이 감염되는 도중에 내가 아무런 영향이 없었다면 오히려 이상할 정도로 사람들이 코로나19를 대하는 태도가 이전보다는 많이 달라졌음을 알 수 있었습니다.

 어떤 분은 이러한 상황에 얼른 감염되어 자연 면역을 갖는게 낫겠다고 합니다. 그러나 지난 일주일 동안 코로나19 확진 판정을 받고 투병 생활을 하는 동안 너무나 고통스러웠고, 오미크론 변이가 감기 마냥 결코 만만한 질병은 아님을 여러분들께 말씀드리고자 합니다.

 

 특별히 어딘가를 가 본 적도 없이 일상생활 범위 내에서 활동하던 저 였기에 갑작스레 찾아온 코로나19가 너무나도 뜻밖이었고 밀려오는 통증이 너무나 고통스럽기만 했습니다. 침을 삼키는 것 조차 커터칼날이 목에 박혀 콕콕 찌르는 듯한 고통이 계속 되고 있었고 심지어 설사까지...

 다행히 의사 선생님께서 제가 보이는 증상들이 오미크론 감염자들에게 주로 보이는 증상들이라 하셨고 기저 질환이 없으니 며칠 내로 회복될 것이라 하셨습니다. 뉴스에서도 60세 미만의 3차 접종 완료 확진자는 사망률이 0%라 하니, 지금 그대로 크게 아프지 않고 나 또한 금새 회복할 것이라 약간의 기대를 가져봅니다.

 


 입원하자 마자 영양제 수액이 기본으로 들어가면서 중간중간에 렘데시비르를 투약하였습니다. 이걸 무려 3일동안 하루에 한 개씩 맞고 있었습니다..

 난생 처음으로 병원에 입원해보면서 처음 맞아보는 링거가 상당히 익숙치가 않았습니다. 처음에는 왼팔에 맞았는데 3일째 되는 날 링거 맞은 부위부터 몸통 방향으로 팔뚝에 통증이 느껴지는 겁니다. 너무 아파 도중에 의사 선생님께 여쭈어보니 링거를 맞는 과정에서 링거액이 전부 혈관에 들어가지 못하고 피부 쪽으로 조금씩 새어 나가는 경우 이런 경우가 생긴다고 하더군요. 퇴원 후 며칠이 지났지만 지금도 여전히 팔뚝이 아픕니다. 아무래도 처음 맞다보니 익숙치 않던 터라 너무 많이 움직이는 바람에 링거액이 피부 쪽으로 새어나온 듯 합니다.

 

 의사 선생님께서 상황을 인지하고 조치를 해주신다 했는데 마침 도저히 차도가 없어보이던 인후통이 차차 회복되어가는 모습이 보이는 것 같아 이제 더이상 링거를 안맞아도 되는 줄 알았더니 간호사께서 링거를 맞지 않던 반대팔인 오른쪽 팔에 링거를 새로 꽃아주시더라고요?!

 역시 병원생활이 만만하지만은 않은가봅니다... 근데 사실..

 


 이렇게 방호복을 입고 환자들을 케어하는 의료진들이야 말로 가장 힘든 분들이지 않을까 싶습니다. 매 시간마다 환자들을 문진하고 새벽에도 두 번씩 환자들을 깨워 상태를 확인하고.. 환자는 그저 병상에서 편안하게 하루종일 잠만 열심히 자면 되는 줄 알았더니 생각보다 병원생활이 힘듦을 처음으로 알게 되었습니다.

 

 아무튼 바이러스로부터 회복하기 위해서는 잘 먹고 편한 휴식을 취해야 합니다. 병원에서 제공하는 도시락을 최대한 비워 끼니를 해결합니다. 사실 병원에서 제공하는 도시락 외에는 먹을 수 있는 음식이 없기 때문에 굶게 되면 다음 끼니 까지는 쫄쫄 굶어야 하는 신세가 되지 않으려면 남기지 않고 다 먹어야만 합니다..

 생각보다 병원에서의 하루는 단순합니다. 의사가 문진할 때 마다 증상을 그대로 이야기 하고 종종 찾아오는 간호사가 링거를 바꾸어주거나 약을 추가로 전달받거나 식사가 들어오면 밥을 먹고 시간 나는 틈틈이 수면을 취해 새벽녘에 깨어나도 피곤하지 않도록 휴식을 취하고... 그저 단순한 루틴이지만 아픈 몸을 가누면서 지내야 하기에 생각보다 정신적인 소모가 상당합니다..

 


 의료진들이 병실에 들어오고 나갈 때 마다 병실 문을 바깥에서 걸어 잠그기 때문에 복도를 돌아다니는 것은 촬영을 위해 나갈 때 이외에는 사실상 금지되어 있습니다. 물론 격리 환자가 병실을 빠져나와 밖으로 나갔다간 바이러스의 전파 우려가 있기 때문에 의료진 입장에서도 어쩔 수 없는 최선의 조치였을 겁니다.

 입원 4일차 무렵까지 저를 가장 힘들었던 것은 지속적으로 통증을 유발하는 인후통이었습니다. 가글약으로 목을 소독할 때 약물이 환부에 닿으면서 상당한 고통이 밀려왔는데 이것도 회복의 과정이리라 생각하며 밥먹고 날 때 마다 계속 가글하였습니다. 분명 치료가 되어가는 듯 하지만 입원 4일차 까지도 전혀 진전을 느끼지 못해 의사 선생님께 말씀드려보니 자신도 2주 정도 통증이 계속 되었었다 하시더군요. 좀 더 인내심을 갖고 기다려 봅니다.

 그렇게 입원 5일차가 되었습니다. 놀랍게도 아침 물을 마시면서 무언가 목이 덜 아픈게 느껴지는 겁니다! 불과 전날 까지만 해도 귀끝까지 찢어지는 통증에 고통스러웠는데 드디어 차도가 보이는 겁니다!! 그렇게 점심 시간 즈음 될 때 즈음 문진을 온 의사 선생님께서 저를 보고 딱 봐도 상태가 좋아보인다는 말을 하실 정도로 몸이 회복 있는 겁니다!

 바로 다음날이 격리 해제 예정일임에도 의사 선생님께서 퇴원 일자를 정해주지 않아 혹시 아직까지 바이러스가 회복되지 못한 것이었나 싶었는데 천만 다행히도 격리 해제 전날 인후통이 회복되어 의사 선생님께서 다음날 퇴원해도 된다는 소식을 들을 수 있었습니다. 환자로서 이 순간이 가장 행복했던 순간이 아닐까 싶습니다!

 

 

 격리 해제 첫날 드디어 병실을 나와 복도를 지나갑니다. 불과 5일전 입원 당시만 해도 철저하게 문을 잠가버려 감히 나갈 수 없던 이 곳을 완쾌한 뒤에 나오게 되니 그 어떤 순간보다도 가장 기쁘지 않을 수 없었습니다.

 

 사실 병원생활 중 가장 힘들었던건 같은 방을 사용하는 다른 환자들과 함께 있는것 이었습니다. 얌전히 간호사의 말을 잘 따르면 문제 없이 무난한 병원생활이 되었겠지요. 안타깝게도 제가 입원하고 있었을 때 같이 들어온 할아버지께서 병원 생활을 힘들어 하시면서 의료진들과 같은 방 환자들에게 불편함을 끼칠 때가 가장 함들었던 것 같습니다.

 

 불과 지난주 까지만 해도 평범한 일상생활을 보내던 내가 지독한 바이러스에 감염되고 일주일간의 병원생활까지 하게 될 줄은 꿈에도 몰랐지요. 다행히 지금은 건강하게 일상으로 돌아왔습니다. 지난 5일간 헌신하신 의료진 분들 덕에 퇴원한 지금도 저는 더욱 힘을 내고 있으니까요!

 혹시 현재 코로나19에 감염되어 투병중이신 분들이 계시다면 너무 걱정하지 마시고 의료진들의 지시에 따라 주세요. 지금 당장 조치가 되는 것 같지 않더라도 인내와 안정만이 회복의 지름길입니다. 마음을 차분히 가지고 코로나19를 극복하자고요!

300x250

코로나19와의 사투(2) - 퇴소 그리고 입원

흔치않은일상 2022. 3. 13. 23:03


불과 어제까지만 해도 여러분들께 생활치료센터에 입소하여 회복해 나아가는 과정들을 보여드리고 싶었으나 입소 3일차가 되어도 인후통이 호전될 기미는 보이지 않았습니다. 목에서 발생한 통증이 귀 근처까지 느껴질 정도로 매우 심각하였고 급기야 새벽에는 1시간마다 잠에서 깨어나 설사 증세까지 나타나는 것이었습니다.



생활치료센터에서는 진통제인 타이레놀과 탁센을 처방해 주었지만 오히려 인후통 증상이 더 심각해지고 있었던 것을 확인한 관계자 분들께서 병원 입원을 권유하였고 저 또한 차라리 병원에서 회복하는게 더 나을거같다는 생각이 들어 생활치료센터 퇴소 및 병원 입소 절차를 거치기로 하였습니다.

생각보다 생활치료센터를 일찍 퇴소하게 되어 이 곳에서의 일상을 기록하려던 계획이 의도치 않게 단축되었습니다. 그래도 여기에 생활치료센터의 일상을 조금 기록해두고자 합니다.


각 방마다 체온계와 혈압측정기가 있습니다.

 

이렇게 매일 아침 체온과 혈압을 측정하여 생활진료센터에 전달합니다. 오전 8시, 오후 4시, 오후 9시에 스스로 측정을 해야합니다.

 

식사 시간이 되면 문 앞에 식사를 갖다줍니다.

 

생활치료센터 창문뷰는 나쁘지느 않아보입니다. 밤이 되면 야생동물들 우는 소리가 들려옵니다.

 

메뉴도 본도시락에서 주문하는 것이어서 좋은 편이었습니다.


그렇게 생활치료센터에서 쾌차할 수 있었으면 조으련만 결국 증상 악화로 다시 구급차에 타게 되었고 인근 병원에 격리입원치료병동에 입실하게 되었습니다.

저도 결국은 링겔 투어를 하는 처지가 되어버는군요..


코로나19  감염되신 분들에게서 주로 나타나는 증상들이 개인별 차이가 난다 하지만 오미크론 변이가 결코 가볍지 않은 바이러스임을 온몸으로 처절히 경험하고 있습니다. 입원하자마자 담당 의사선생님께 증상을 말씀드렸고 전형적인 증상이므로 걱정은 덜었습니다. 기저질환도 없어서 금방 퇴원할 수 있겠다고 하셨고 실제로 그렇게 되기를 저 자신도 바라고 있습니다.

혹시나 자신이 코로나19에 감염되었다 하시는 분들께서는 큰 걱정 하지 마시고 차분한 마음으로 빠른 쾌유하시기를 기원드리겠습니다!

300x250

코로나19와의 사투(1) - 생활치료센터에 입소하다

흔치않은일상 2022. 3. 12. 22:26

2022년 대통령선거 다음날이었던 3월 10일 평소에 비해 목구멍에서 통증이 밀려왔습니다. 지난달에는 환절기 몸살로 앓아누웠었는데 다행히도 그때는 신속항원검사 결과 양성이었고 일상생활도 별로 달라진게 없었는데 말이죠.

혹시나 해서 이번에도 신속항원검사를 해보았는데...

 

 

테스트기에서 두 줄이 뜨는것을 보고야 만것입니다!!

이럴수가... 결국 저에게도 코로나19가 찾아온것인가 싶었습니다. 안그래도 요새 확진자가 30만명에 근접한데다가 주변에서도 확진 판정을 받으신 분들의 소식이 들려오는 마당에 저라고 오죽할까요?

테스트기에서 양성 판정이 나온 순간 잠시 멍하니 바라보고 있었다가 다시 정신을 차리고 신속히 보건소로 달려갑니다.
보건소에 도착했을때 오후 2시 정도 였는데 정말이지 줄이 너무나 길었고 기다리는 도중에 틈틈이 대기자들을 위해 마련된 의자에 앉아있으면서 휴식을 취했지만 줄은 좀처럼 줄어들지 않고...
그렇게 3시간을 기다리고서야 PCR 검사를 받을 수 있었고 혹시 확진될지 모르기에 격리기간동안 먹을 컵밥을 챙기고 귀가하였습니다. 곧바로 회사에 PCR 검사를 받았음을 알렸고 확진 경험이 있으셨던 리더님께서 내일 하루는 집에서 푹 쉬라는 격려도 받았습니다. 요새 유행중인 오미크론의 주요 증상이던 인후통 증세가 점점 심해지고 있었고 저 또한 확진일 것으로 생각하고 있었습니다.

그렇게 다음날 아침이 밝아올 무렵 문자 통보를 받게 되었습니다.

귀하는 코로나바이러스감염증-19로 확진되셨으므로 감염병예방법 제41조 및 제43조 등에 따른 격리 대상임을 통지합니다. 또한 귀하의 동거인이 10일간 준수하여야 할 권고사항을 함께 안내해 드리오니 동거인에게 본 문자를 공유하여 주시기 바랍니다.

결국 올 것이 오고야 말았습니다... 어느 정도 예상을 하고 있었기에 충격적이지는 않았지만 무엇보다도 감염병으로 인한 통증과의 사투가 이제 시작되었다는 것이니까요.

요새는 확진자들이 너무나 많아서 재택치료가 위주였으나 제가 하필이면 집이 아닌 타지에서 일을 하던 도중에 증상이 발현해서 거주지가 아닌 회사에서 제공하는 시설에서 대기하다 확진 통보를 받게 되었고 생활 시설들도 공용으로 사용되는 공간이라 동선 분리가 불가능한 곳이었습니다. 이러한 사정을 보건소에 전달하였고 운 좋게도 생활치료센터에 자리가 생겨 구급차를 지원받아 안동에 있는 생활치료센터에 입소할 수 있었습니다.

 

 구급차에서 내리자마자 방호복을 입은 의료진 분께서 안내하는 대로 부랴부랴 싸온 짐을 들고 이동합니다. 입실 전 엑스레이 촬영을 간단히 하고나서 위의 사진같은 방에 입실하게 되었습니다.

급하게 입소 준비하느라 짐을 부랴부랴 챙겨왔는데 생각보다 생활치료센터에서 제공해주는 키트에서 생필품들 구성이 잘 되어있어 굳이 짐을 많이 싸올 필요는 없어 보입니다.(굳이 필요하다면 실내에서 편하게 입고 다닐 수 있는 옷 정도?) 심지어 격리 기간동안 방 안에서만 지내기 때문에 여분의 옷 한 벌과 약간의 속옷 정도만 있으면 되는 듯 합니다. 

 

 

 예상외로 제공되는 키트에 빨래비누가 있어서 혹시 속옷이 모자랄 때 손빨래로 세탁을 할 수 있게 해주긴 합니다만 다행히도 챙겨온 속옷이 많아서 퇴소 전까지는 챙겨온 속옷으로 갈아입으며 생활할듯합니다. 

 

 당분간은 건강 관리에 최선을 다하면서 회복의 기회로 삼아보고자 합니다. 한동안 블로그에 글을 쓸 소재에 대해 고민이 많았는데 좋은 주제의 글들을 읽으면서 평소 생각해보았던 내용들을 정리해볼까 합니다.

 

 2022년 3월 전국적으로 코로나19 오미크론 변이 확산의 폭풍우 속에 있습니다. 여러분들께서도 최대한 감염되지 않도록 개인 위생 철저히 하면서 지내시길 바랍니다. 어떤 분들은 가벼운 감기 증상처럼 지나갈 것이라고 하였는데 저의 경우 심한 인후통을 겪고 있습니다. 오미크론 변이의 증상이 경미하다고는 하나 그래도 최대한 걸리지 않고 지나가는 것이 가장 좋은것 같습니다.

 

 부디 다음에는 건강하게 생활치료센터를 퇴소하였다는 소식을 전달할 수 있기를 간절히 기도해봅니다. 여러분들도 화이팅!

 

 

300x250

안드로이드 기기 간에 Wifi Socket 채팅방 만들기(Kotlin)

 

 안드로이드에 Kotlin이 사용되면서 기존에 Java로 작성하였던 프로젝트를 진행하였던 적이 있었습니다. 그 당시 낮선 언어였던 Kotlin을 처음 공부하면서 소스코드를 다시 만들어봤었는데 그게 벌써 3년전의 일이 되었군요. 그 사이 안드로이드도 12버전(API 32, Snow cake)이 나온 상황입니다. 그런데 신기하게도 통신방식인 TCP는 프로그래밍 언어의 정의 방법의 차이일 뿐 동작 원리는 똑같기에 Kotlin으로 소스코드를 고쳐씀에 다행히 큰 불편함은 없었습니다.

 

 이번 포스팅에서는 기존에 작성하였던 서버-클라이언트 1:1 통신 환경을 확장하여 여러 대의 기기가 서버에 동시 접속하여 대화하는 채팅 시스템을 안드로이드 Kotlin으로 구현해 보았습니다.

 혹시 이전에 제가 작업하였던 서버-클라이언트 1:1 통신 방법에 대한 내용이 궁금하신 분은 아래의 링크를 참조하시기 바랍니다.

 

안드로이드 기기 간에 Wifi Socket 통신하기(Kotlin)

 오랜만에 안드로이드를 다루어보는 기회가 생겼는데 생각보다 많은 변화가 있었습니다. 특히 올해부터는 구글에서 새로 만든 프로그래밍 언어인 Kotlin이 도입되면서 Java 위주로 설계된 안드로

elecs.tistory.com

 

동작원리

 이 프로젝트에서는 1개의 서버에 다수의 클라이언트가 접속할 수 있는 환경으로 아래와 같이 구성하였습니다.

먼저 Server 역할을 하는 기기에서 서버 Port를 엽니다.

포트가 열린 서버에 클라이언트가 접속을 시도하면 서버는 ServerSocket가 클라이언트의 접속을 받습니다.

 클라이언트의 접속을 받아들인 ServerSocket는 해당 클라이언트와 통신을 수행할 별개의 Socket을 생성해 이를 SocketList에 추가합니다.

 이후 다른 클라이언트가 서버에 접속하면 위와 같은 방식으로 해당 클라이언트와 통신을 수행하는 Socket을 생성하여 이를 SocketList에 추가합니다.

 다수의 클라이언트가 연결된 환경에서 클라이언트 한 곳에서 메시지를 서버에 보내면

 서버에서는 SocketList에 있는 모든 Socket에 메시지를 Broadcast합니다.

만약 클라이언트에서 접속을 종료하게 될 경우 서버는 접속이 종료된 소켓을 SocketList에서 삭제함으로서 채팅을 종료합니다.

 

※ 이 프로젝트는 Lolipop 이상의 버전에서 테스트 하였습니다.

 

AndroidManifest.xml

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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.androidchatroom">
 
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
 
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AndroidChatRoom">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
 
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
 
</manifest>
cs

 

 

strings.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<resources>
    <string name="app_name">AndroidChatRoom</string>
    <string name="hello_world">Hello world!</string>
    <string name="action_settings">Settings</string>
    <string name="ip">SERVER IP</string>
    <string name="port">PORT</string>
    <string name="name">NAME</string>
    <string name="msg">MESSAGE</string>
    <string name="hint_port">PORT NUMBER</string>
    <string name="hint_ip">192.168.0.1</string>
    <string name="hint_name">USER</string>
    <string name="hint_msg">Hello, world!</string>
    <string name="button1">Connect</string>
    <string name="button2">Disconnect</string>
    <string name="button3">Set Server</string>
    <string name="button4">Close Server</string>
    <string name="button5">Clear All Text</string>
    <string name="button6">Send Message</string>
</resources>
cs

 

Activity_main.xml

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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
 
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <TextView
                android:id="@+id/textView"
                android:layout_width="70dp"
                android:layout_height="wrap_content"
                android:text="@string/ip" />
 
            <EditText
                android:id="@+id/et_ip"
                android:layout_width="match_parent"
                android:layout_height="48dp"
                android:ems="10"
                android:hint="@string/hint_ip"
                android:importantForAccessibility="no"
                tools:ignore="Autofill"
                android:inputType="textUri"
                android:singleLine="true" />
        </LinearLayout>
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <TextView
                android:id="@+id/textView2"
                android:layout_width="70dp"
                android:layout_height="wrap_content"
                android:text="@string/port" />
 
            <EditText
                android:id="@+id/et_port"
                android:layout_width="match_parent"
                android:layout_height="48dp"
                android:ems="10"
                android:hint="@string/hint_port"
                android:importantForAccessibility="no"
                tools:ignore="Autofill"
                android:inputType="number"
                android:singleLine="true" />
        </LinearLayout>
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <TextView
                android:id="@+id/textView3"
                android:layout_width="70dp"
                android:layout_height="wrap_content"
                android:text="@string/name" />
 
            <EditText
                android:id="@+id/et_name"
                android:layout_width="match_parent"
                android:layout_height="48dp"
                android:ems="10"
                android:hint="@string/hint_name"
                android:importantForAccessibility="no"
                tools:ignore="Autofill"
                android:singleLine="true" />
        </LinearLayout>
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <TextView
                android:id="@+id/textView4"
                android:layout_width="70dp"
                android:layout_height="wrap_content"
                android:text="@string/msg" />
 
            <EditText
                android:id="@+id/et_msg"
                android:layout_width="match_parent"
                android:layout_height="48dp"
                android:ems="10"
                android:hint="@string/hello_world"
                android:importantForAccessibility="no"
                tools:ignore="Autofill"
                android:inputType="textPersonName" />
        </LinearLayout>
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <Button
                android:id="@+id/button_connect"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:minWidth="0dip"
                android:text="@string/button1"
                android:textSize="12sp" />
 
            <Button
                android:id="@+id/button_disconnect"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:minWidth="0dip"
                android:text="@string/button2"
                android:textSize="12sp" />
 
            <Button
                android:id="@+id/button_msg"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:minWidth="0dip"
                android:text="@string/button6"
                android:textSize="12sp" />
        </LinearLayout>
 
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
 
            <Button
                android:id="@+id/button_setserver"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="@string/button3"
                android:textSize="12sp" />
 
            <Button
                android:id="@+id/button_closeserver"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="@string/button4"
                android:textSize="12sp" />
 
            <Button
                android:id="@+id/button_clear"
                style="@style/Widget.AppCompat.Button.Small"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="@string/button5"
                android:textSize="12sp" />
 
        </LinearLayout>
 
        <ScrollView
            android:id="@+id/sv"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
 
            <TextView
                android:id="@+id/text_status"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:textSize="18sp"
                />
        </ScrollView>
 
    </LinearLayout>
 
</androidx.constraintlayout.widget.ConstraintLayout>
cs

Activity_main.xml을 MainActivity.kt에서 바로 불러오기 위해서는 "import kotlinx.android.synthetic.main.activity_main.*"

을 통해 불러올 수 있습니다. 이 기능을 수행하기 위해 Gradle에 아래와 같이 'id kotlin-android-extentions'를 추가하여야 합니다.

 

 먼저 Gradle Scripts→build.gradle (Module: *.app)을 선택합니다.

 

플러그인에 'id kotlin-android-extension' 추가 후 'Sync Now'를 클릭하세요.

 

위 과정을 수행하면 MainActivity.kt에서 id로 설정한 view를 바로 불러올 수 있습니다.

 

MainActivity.kt

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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
package com.example.androidchatroom
 
import androidx.appcompat.app.AppCompatActivity
 
import android.content.Context
import android.net.ConnectivityManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.widget.Toast
 
import kotlinx.android.synthetic.main.activity_main.*
 
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.*
import kotlin.properties.Delegates
 
class MainActivity : AppCompatActivity() {
 
    companion object{
        var socket = Socket()
        var server = ServerSocket()
        lateinit var writeSocket: DataOutputStream
        lateinit var readSocket: DataInputStream
        lateinit var cManager: ConnectivityManager
        lateinit var myIp: String
 
        var ip = "192.168.0.1"
        var port = 2222
        //var mHandler = Handler()      -> API30부터 Deprecated됨. Looper를 직접 명시해야함
        var mHandler = Handler(Looper.getMainLooper())
        var serverClosed = true
 
        var cList = mutableListOf<Client>()
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
 
        cManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        server.close()
        socket.close()
 
        button_connect.setOnClickListener {    //클라이언트 -> 서버 접속
            if(et_ip.text.isNotEmpty()) {
                ip = et_ip.text.toString()
                myIp = et_name.text.toString()
                if(et_port.text.isNotEmpty()) {
                    port = et_port.text.toString().toInt()
                    if(port<0 || port>65535){
                        Toast.makeText(this@MainActivity, "PORT 번호는 0부터 65535까지만 가능합니다.", Toast.LENGTH_SHORT).show()
                    }else{
                        if(!socket.isClosed){
                            Toast.makeText(this@MainActivity, ip + "에 이미 연결되어 있습니다.", Toast.LENGTH_SHORT).show()
                        }else {
                            Connect().start()
                        }
                    }
 
                }else{
                    Toast.makeText(this@MainActivity, "PORT 번호를 입력해주세요.", Toast.LENGTH_SHORT).show()
                }
            }else{
                Toast.makeText(this@MainActivity, "IP 주소를 입력해주세요.", Toast.LENGTH_SHORT).show()
            }
        }
 
        button_disconnect.setOnClickListener {    //클라이언트 -> 서버 접속 끊기
            if(!socket.isClosed){
                Disconnect().start()
            }else{
                Toast.makeText(this@MainActivity, "서버와 연결이 되어있지 않습니다.", Toast.LENGTH_SHORT).show()
            }
        }
 
        button_setserver.setOnClickListener{    //서버 포트 열기
            if(et_port.text.isNotEmpty()) {
                val cport = et_port.text.toString().toInt()
                if(cport<0 || cport>65535){
                    Toast.makeText(this@MainActivity, "PORT 번호는 0부터 65535까지만 가능합니다.", Toast.LENGTH_SHORT).show()
                }else{
                    if(server.isClosed) {
                        port = cport
                        SetServer().start()
                    }else{
                        val tstr = port.toString() + "번 포트가 열려있습니다."
                        Toast.makeText(this@MainActivity, tstr, Toast.LENGTH_SHORT).show()
                    }
                }
            }else{
                Toast.makeText(this@MainActivity, "PORT 번호를 입력해주세요.", Toast.LENGTH_SHORT).show()
            }
        }
 
        button_closeserver.setOnClickListener {    //서버 포트 닫기
            if(!server.isClosed){
                CloseServer().start()
            }else{
                mHandler.obtainMessage(17).apply {
                    sendToTarget()
                }
            }
        }
 
        button_clear.setOnClickListener {    //채팅방 내용 지우기
            text_status.text = ""
        }
 
        button_msg.setOnClickListener {    //상대에게 메시지 전송
            if(socket.isClosed){
                Toast.makeText(this@MainActivity, "연결이 되어있지 않습니다.", Toast.LENGTH_SHORT).show()
            }else {
                val mThread = SendMessage()
                mThread.setMsg(2, et_name.text.toString(), et_msg.text.toString())
                mThread.start()
            }
        }
 
        mHandler = object : Handler(Looper.getMainLooper()){  //Thread들로부터 Handler를 통해 메시지를 수신
            override fun handleMessage(msg: Message) {
                super.handleMessage(msg)
                when(msg.what){
                    1->Toast.makeText(this@MainActivity, "IP 주소가 잘못되었거나 서버의 포트가 개방되지 않았습니다.", Toast.LENGTH_SHORT).show()
                    2->Toast.makeText(this@MainActivity, "서버 포트 "+port +"가 준비되었습니다.", Toast.LENGTH_SHORT).show()
                    3->Toast.makeText(this@MainActivity, msg.obj.toString(), Toast.LENGTH_SHORT).show()
                    4->Toast.makeText(this@MainActivity, "연결이 종료되었습니다.", Toast.LENGTH_SHORT).show()
                    5->Toast.makeText(this@MainActivity, "이미 사용중인 포트입니다.", Toast.LENGTH_SHORT).show()
                    6->Toast.makeText(this@MainActivity, "서버 준비에 실패하였습니다.", Toast.LENGTH_SHORT).show()
                    7->Toast.makeText(this@MainActivity, "서버가 종료되었습니다.", Toast.LENGTH_SHORT).show()
                    8->Toast.makeText(this@MainActivity, "서버가 정상적으로 닫히는데 실패하였습니다.", Toast.LENGTH_SHORT).show()
                    9-> ((text_status.text as String+ (msg.obj as String+ "\n").also { text_status.text = it }
                    11->Toast.makeText(this@MainActivity, "서버에 접속하였습니다.", Toast.LENGTH_SHORT).show()
                    12->Toast.makeText(this@MainActivity, "메시지 전송에 실패하였습니다.", Toast.LENGTH_SHORT).show()
                    13->Toast.makeText(this@MainActivity, (msg.obj as String+ " 클라이언트와 연결되었습니다.",Toast.LENGTH_SHORT).show()
                    14->Toast.makeText(this@MainActivity,"서버에서 응답이 없습니다.", Toast.LENGTH_SHORT).show()
                    15->Toast.makeText(this@MainActivity, "서버와의 연결을 종료합니다.", Toast.LENGTH_SHORT).show()
                    16->Toast.makeText(this@MainActivity, (msg.obj as String)+" 클라이이언트와의 연결을 종료합니다.", Toast.LENGTH_SHORT).show()
                    17->Toast.makeText(this@MainActivity, "포트가 이미 닫혀있습니다.", Toast.LENGTH_SHORT).show()
                    18->Toast.makeText(this@MainActivity, "서버와의 연결이 끊어졌습니다.", Toast.LENGTH_SHORT).show()
                    19->Toast.makeText(this@MainActivity, "인터넷이 연결되지 않았습니다. 연결 후 다시 시도하세요.", Toast.LENGTH_LONG).show()
                    20->{
                        et_name.setText(msg.obj as String)
                        myIp = msg.obj as String
                    }
                }
            }
        }
 
        ShowInfo().start()    //자신의 IP주소 확인
    }
 
    //클라이언트-서버 접속 시도
    class Connect:Thread(){
 
        override fun run() = try{
            socket = Socket(ip, port)
            writeSocket = DataOutputStream(socket.getOutputStream())
            readSocket = DataInputStream(socket.getInputStream())
            val b = readSocket.readInt()
            if(b==1){    //서버로부터 접속이 확인되었을 때
                mHandler.obtainMessage(11).apply {
                    sendToTarget()
                }
                ClientSocket(myIp).start()
            }else{    //서버 접속에 성공하였으나 서버가 응답을 하지 않았을 때
                mHandler.obtainMessage(14).apply {
                    sendToTarget()
                }
                socket.close()
            }
        }catch(e:Exception){    //연결 실패
            val state = 1
            mHandler.obtainMessage(state).apply {
                sendToTarget()
            }
            socket.close()
        }
    }
 
    //클라이언트-서버 통신 개시
    class ClientSocket(private val addr: String):Thread(){
 
        override fun run() {
            try{
                while (true) {
                    val ac = readSocket.readInt()
                    val cname = readSocket.readUTF()
 
                    if( ac == 3){
                        readSocket.readUTF()
                        if(addr != cname){
                            mHandler.obtainMessage(9"$cname 님이 입장하였습니다.").apply {
                                sendToTarget()
                            }
                        }else{
                            mHandler.obtainMessage(9"채팅방에 입장하였습니다.").apply {
                                sendToTarget()
                            }
                        }
                    }else if(ac == 2) {    //서버로부터 메시지 수신 명령을 받았을 때
                        val bac = readSocket.readUTF()
                        val input = bac.toString()
                        val recvInput = input.trim()
 
                        val clientName = cname.toString().trim()
 
                        val msg = mHandler.obtainMessage()
                        msg.what = 9
                        msg.obj = "$clientName> $recvInput"
                        mHandler.sendMessage(msg)
                    }else if(ac == 4){
                        readSocket.readUTF()
                        if(addr != cname) {
                            mHandler.obtainMessage(9"$cname 님이 퇴장하였습니다.").apply {
                                sendToTarget()
                            }
                        }
 
                    }else if(ac == 10){    //서버로부터 접속 종료 명령을 받았을 때
                        mHandler.obtainMessage(18).apply {
                            sendToTarget()
                        }
                        mHandler.obtainMessage(9,"서버에서 연결을 끊었습니다.").apply {
                            sendToTarget()
                        }
                        socket.close()
                        break
                    }
                }
            }catch(e:SocketException){    //소켓이 닫혔을 때
                mHandler.obtainMessage(15).apply {
                    sendToTarget()
                }
                mHandler.obtainMessage(9"채팅방을 나갔습니다.").apply {
                    sendToTarget()
                }
            }
        }
    }
 
    //클라이언트 접속 종료
    class Disconnect:Thread(){
 
        override fun run() {
 
            try{
                writeSocket.write(10)    //서버에게 접속 종료 명령 전송
                writeSocket.writeUTF(myIp)  //종료 요청 클라이언트 주소
                socket.close()
            }catch(e:Exception){
 
            }
        }
    }
 
    //서버 통신 개시
    class SetServer:Thread(){
 
        override fun run(){
            try{
                server = ServerSocket(port)    //포트 개방
                mHandler.obtainMessage(2"").apply {
                    sendToTarget()
                }
                mHandler.obtainMessage(9"서버가 열렸습니다.").apply {
                    sendToTarget()
                }
 
                while(true) {
                    socket = server.accept()    //클라이언트가 접속할 때 까지 대기
                    val client = Client(socket)    //접속한 Client의 socket을 저장
                    cList.add(client)    //접속 client socket 리스트 추가
                    client.start()    //접속한 클라이언트 전용 socket thread 실행
                }
 
            }catch(e:BindException) {    //이미 개방된 포트를 개방하려 시도하였을때
                mHandler.obtainMessage(5).apply {
                    sendToTarget()
                }
            }catch(e:SocketException){    //소켓이 닫혔을 때
                mHandler.obtainMessage(7).apply {
                    sendToTarget()
                }
            }
            catch(e:Exception){
                if(!serverClosed) {
                    mHandler.obtainMessage(6).apply {
                        sendToTarget()
                    }
                }else{
                    serverClosed = false
                }
            }
        }
    }
 
    //서버 소켓 닫기
    class CloseServer:Thread(){
        override fun run(){
            try{
                if(!socket.isClosed){
                    writeSocket.write(10)    //클라이언트에게 서버가 종료되었음을 알림
                    writeSocket.close()
                    socket.close()
                }
                server.close()
                serverClosed = true
                mHandler.obtainMessage(9"서버가 닫혔습니다.").apply {
                    sendToTarget()
                }
            }catch(e:Exception){
                e.printStackTrace()
                mHandler.obtainMessage(8).apply {
                    sendToTarget()
                }
            }
        }
    }
 
    //메시지 전송
    class SendMessage:Thread(){
        private var state by Delegates.notNull<Int>()
        private lateinit var msg:String
        private lateinit var cname:String
 
        fun setMsg(s: Int, n:String, m:String){
            state = s
            msg = m
            cname = n
        }
 
        override fun run() {
 
            if(cList.size>0){    //메시지를 전송하는 주체가 서버일 경우
                val cIter = cList.iterator()
                while(cIter.hasNext()){
                    val client = cIter.next()
                    if (!client.isClosed()) client.sendMessage(state, cname, msg)
                    else cIter.remove()
                    mHandler.obtainMessage(9"$cname> $msg").apply {
                        sendToTarget()
                    }
                }
            }else {
                try {
                    writeSocket.writeInt(state)    //메시지 전송 명령 전송
                    writeSocket.writeUTF(cname)    //클라이언트 이름
                    writeSocket.writeUTF(msg)    //메시지 내용
                } catch (e: Exception) {
                    e.printStackTrace()
                    mHandler.obtainMessage(12).apply {
                        sendToTarget()
                    }
                }
            }
        }
    }
 
    //자신의 IP주소를 표시
    class ShowInfo:Thread() {
 
        override fun run() {
            var ip = ""
            val en = NetworkInterface.getNetworkInterfaces()
            while (en.hasMoreElements()) {
                val intf = en.nextElement()
                val enumIpAddr = intf.inetAddresses
                while (enumIpAddr.hasMoreElements()) {
                    val inetAddress = enumIpAddr.nextElement()
                    if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) {
                        @Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
                        ip = inetAddress.hostAddress as String
                    }
                }
            }
 
            if (ip == "") {
                mHandler.obtainMessage(19).apply {
                    sendToTarget()
                }
            } else {
                val msg = mHandler.obtainMessage()
                msg.what = 20
                msg.obj = ip
                mHandler.sendMessage(msg)
            }
        }
    }
 
    //서버에 접속한 클라이언트 소켓 제어
    class Client(socket: Socket) : Thread(){
        private lateinit var clientName: String
        private lateinit var clientAddr: String
        private lateinit var cWriteSocket: DataOutputStream
        private val cSocket: Socket=socket
 
        override fun run(){
            cWriteSocket = DataOutputStream(cSocket.getOutputStream())
            val cReadSocket = DataInputStream(cSocket.getInputStream())
 
            cWriteSocket.writeInt(1)    //클라이언트에게 서버의 소켓 생성을 알림
            val socketAddr = socket.remoteSocketAddress as InetSocketAddress
            clientAddr = socketAddr.address.hostAddress as String
 
            mHandler.obtainMessage(13, clientAddr).apply {
                sendToTarget()
            }
            mHandler.obtainMessage(9, clientAddr + "님이 입장하였습니다.").apply {
                sendToTarget()
            }
            Broadcast(cList, 3, clientAddr, "입장").start()
            while (true) {
                val ac = cReadSocket.read()
                clientName = cReadSocket.readUTF().toString()
                if(ac==10){    //클라이언트로부터 소켓 종료 명령 수신
                    mHandler.obtainMessage(16, clientName).apply {
                        sendToTarget()
                    }
                    mHandler.obtainMessage(9"$clientName 님이 퇴장하였습니다.").apply {
                        sendToTarget()
                    }
                    Broadcast(cList, 4, clientName, "퇴장").start()
                    break
                }else if(ac == 2){    //클라이언트로부터 메시지 전송 명령 수신
                    val bac = cReadSocket.readUTF()
                    val input = bac.toString()
                    val recvInput = input.trim()
 
                    val msg = mHandler.obtainMessage()
                    msg.what = 9
                    msg.obj = "$clientName> $recvInput"
                    mHandler.sendMessage(msg)    //핸들러에게 클라이언트로 전달받은 메시지 전송
 
                    Broadcast(cList, 2, clientName, recvInput).start()
                }
            }
            cWriteSocket.close()
            cSocket.close()
        }
 
        fun isClosed(): Boolean {
            return cSocket.isClosed
        }
 
        fun sendMessage(state: Int, cname: String, msg: String){
            try{
                cWriteSocket.writeInt(state)    //메시지 전송 명령 전송
                cWriteSocket.writeUTF(cname)    //클라이언트 이름
                cWriteSocket.writeUTF(msg)    //메시지 내용
            }catch(e:Exception){
                e.printStackTrace()
                mHandler.obtainMessage(12).apply {
                    sendToTarget()
                }
            }
        }
    }
//서버에 접속한 클라이언트에게 메시지 전파
    class Broadcast(private val cList: MutableList<Client>private val state: Int, private val cname: Stringprivate val msg: String):Thread(){
 
        override fun run(){
            if(cList.size>0){
                val cIter = cList.iterator()
                while(cIter.hasNext()){
                    val client = cIter.next()
                    if (!client.isClosed()) {
                        client.sendMessage(state, cname, msg)
                    }
                    else cIter.remove()
                }
            }
        }
    }
 
}
cs

 

 

실행 결과는 다음과 같습니다.

 

※Sever 사용 방법

1. Port에 원하는 포트 번호를 입력합니다.

2. 'SET SERVER' 버튼을 클릭하면 포트를 열 수 있습니다. 만약 다른 프로세스에서 포트를 사용중일 경우 다른 포트 번호를 입력합니다.

3. 인터넷이 연결된 상태에서 앱을 실행시 NAME에 Server의 IP주소가 나타납니다. 이를 Client의 IP란에 입력해주세요.

4. Client와의 연결이 성공하면 Toast로 연결에 성공하였다는 알림이 나옵니다.



※Client 사용 방법

1. IP와 PORT에 서버의 IP주소와 설정 PORT 번호를 입력합니다.

2. 'CONNECT' 버튼을 클릭하여 서버에 접속합니다. 성공시 Toast로 접속에 성공하였다는 메시지를 확인하실 수 있습니다.

3. 'SEND MESSAGE' 버튼을 클릭하면 MESSAGE 칸에 입력한 텍스트 정보를 상대에게 전송할 수 있습니다.


4.  'DISCONNECT' 버튼을 눌러 서버와의 연결을 해제합니다.

 

 

실험을 위해 저희 가족들이 사용하고 있는 폰들을 모두 모아 실험에 사용하였습니다.

 

맨 위의 폰이 Server 역할을 하고 있으며 나머지 3개의 폰은 Client 역할을 하고 있습니다.

 

 

Server에서는 각 클라이언트의 메시지와 접속 상황을 실시간으로 보여줍니다.

 

 

일부 클라이언트 접속 상황이 제대로 반영되지 않은 것으로 보이나 정상적으로 서버와의 통신 및 채팅방 대화가 공유되는 것을 확인하실 수 있습니다!

300x250

KTX-이음을 타고 중부내륙선을 달리다 - 부발역 ~ 충주역[2022.01.01]


지금껏 사라져가는 모습들을 사진으로 남겨왔던 제게 처음으로 개통되는 구간을 가보게 된다는 것이 한편으로는 신기한 경험이었습니다. 없었던 길을 간다는 것은 새로운 길을 개척한다는 것이고 한편으로는 가보지 않은 길을 간다는 의미이기도 합니다.
이번 여행 또한 가보지 않았던 곳을 떠다본다는 설렘과 기대로 2022년 새해의 첫 여행을 시작해보고자 합니다.



경강선 전철을 타고 부발역으로 이동합니다.
부발발 충주행
역 바깥에서 KTX 이음을 바로 볼 수 있습니다.
개통 초기이다 보니 많은 사람들이 보입니다.
새로 개통된 열차를 타보고자 온 사람들인듯 합니다.
충주행 ktx를 타보러 갑니다.
ITX 청춘 열차를 탈 수 있는 곳에서 볼 수 있는 승하차 태그기가 부발역에도 있습니다.
처음으로 탑승해본 KTX 이음
같은 플랫폼에서 전철과 KTX를 모두 이용할 수 있다니
충주에서 열차를 타고 부발에서 바로 판교행 열차를 탈 수 있게 동선이 구성되어 있습니다.
계단 없이 열차에 바로 탈 수 있는게 고상홈의 장점이겠지요
열차에서 바라본 부발역
열차는 가남역을 지나
논밭을 달려 감곡장호원역에 도착합니다.

 

 

희안하게도 역명판이 장호원이라 적혀있네요?
분명 역 이름은 감곡장호원역입니다.
더욱 가관인건 타는곳의 역명판이었습니다.
아무리 역명 정할때 논란이 있었다 하더라고 이렇게 플랫폼에서 조차 이렇게 만들면 행선지로 오인할 수 있을텐데 말이지요
새롭게 개통한 역을 사진으로 남기는 사람들
이제 다시 열차에 올라 충주역으로 갑니다.
드디어 충주역에 도착했습니다.
차후 KTX 역에서도 이렇게 스크린도어가 운영될 듯 합니다.
충주역은 역내 건널목으로 열차를 이용할 수 있는 역이었습니다.
지금은 새 건물을 지어 길을 막아놓았습니다.
KTX 개통을 대비하여 안전하게 에스컬레이터를 설치하였습니다.
도착후 역 바깥으로 나와봅니다.
놀랍게도 기존 역사를 그대로 사용하고 있는군요.
늦은 시간이지만 충주 주변을 여행하고
자고 일어나 충주댐 구경도 하고
맛난 송어비빔회도 먹어보고
다시 충주역으로 돌아옵니다.
역 광장을 택시승강장으로 새로 만들었군요.
기존의 건널목 횡단을 막고 에스컬레이터를 타고 탑승할 수 있게 동선을 만들었습니다.
차후엔 이 곳을 통해 열차를 탑승하게 될 듯 합니다.
충주역에서 만난 KTX 이음
어느덧 충주역에도 어둠이 찾아오고
KTX 타는곳은 새로 지은 승강장을 사용합니다.
저상홈과 KTX 이음
해가 산으로 넘어갈 무렵 도착한 대전행 무궁화호를 타고 충주역을 떠납니다.

 

300x250