-
소켓을 사용한 HTTP 서버 만들기 - 파이썬CS/컴퓨터네트워크 2023. 9. 26. 13:24
한 컴퓨터에서 다른 컴퓨터로 데이터를 보낸다면 어떤 과정을 거칠까?
들어가기 앞서 서로 다른 컴퓨터에서 정보를 주고받을 때 내부적으로 어떤 일이 발생할지 생각해보자.
보내는 쪽을 A, 받는쪽을 B라고 생각해보자.
A의 '트랜스포트'계층은 A쪽 '어플리케이션'계층의 프로세스로부터 들어온 메세지를 전송계층의 패킷 (세그먼트)로 변환한다.
이렇게 변환한 세그먼트를 A의 '네트워크'계층으로 전달한다.
A의 '네트워크' 계층은 이 세그먼트를 '데이터그램'안에 캡슐화 한다.
->여기까지가 A에서 일어난 일이다.
이 후 A의 네트워크 계층은 데이터그램을 B쪽으로 전달한다. (이 과정에서 라우터들을 거칠것이다).
B의 네트워크 계층은 데이터그램에서 세그먼트를 추출하여 전송계층으로 전달한다.
B의 전송계층에서는 받은 세그먼트를 처리하여 애플리케이션에서 이해할 수 있도록 전달한다.
아래 그림을 보면서 이해하면 조금 더 쉽게 이해할 수 있을것이다!
그렇다면, 소켓이란 무엇일까?
소켓은 프로그램이 네트워크 상에서 데이터를 통신할 수 있도록 연결해주는 연결부라고 생각하면 된다.
인터넷 상의 모든 정보는 트랜스포트 계층을 반드시 이용하여 나가고 들어오는데,
우리가 프로그램을 짤 때 만지는 범위는 애플리케이션 계층에 한정되어 있다.
애플리케이션 계층에서 트랜스포트 계층을 조작하기 위해서 사용하는 수단이 소켓인 것이다.
즉 위에서 살펴본 a-b로의 정보전달 과정에 대입하여 생각해보면,
a의 트랜스포트 계층과 b의 트랜스포트 계층 사이를 소켓으로 연결하여 정보를 주고받는 것이다.
소켓 프로그래밍의 흐름 위 그림은 클라이언트와 소켓에서 사용되는 소켓 프로그램의 흐름이다.
아래의 설명과정에서 참고하면, 이해에 도움이 될 것이다.
소켓을 사용한 HTTP 서버 만들기 - 코드 구현
이번에 파이썬을 통해 구현해보자 하는 것은, TCP소켓으로 HTTP 웹 서버를 구현하는 것이다.
이번 웹 페이지에서는 html, css, js, jpg의 파일을 불러올 수 있도록 할 것이다.
-소켓 생성 / 세팅
import socket HOST = '' PORT = 8080 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: #소켓 객체 생성 #어드레스 패밀리(IPv4), 소켓타입 s.bind((HOST, PORT)) #소켓과 AF 연결 s.listen(1) #상대방의 접속이 올 때까지 대기. 큐 사이즈 1로 설정 print('Start server')
-with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
파이썬의 with문을 사용하여 소켓 클래스를 s라는 이름의 객체로 사용하였다.
socket 객체를 생성 할 때, 보다싶이 두 개의 인자가 입력된다.
첫 번째 인자로는 '어드레스 패밀리'의 타입이 들어가야 한다.
쉽게 말해서 주소 체계라고 생각하면 된다.
AF_INET은 IPv4를, AF_INET6은 IPv6을 의미하는데, 나는 IPv4의 AF_INET을 넣어주었다.
두 번째 인자로는 소켓의 타입이 들어가야한다.
나는 SOCK_STREAM을 사용하였다.
-s.bind((HOST, PORT))
다음으로 생성한 소캣을 bind 해주어야 한다.
이는 위의 '소켓 프로그래밍 흐름' 그림에서도 확인 할 수 있듯이, 서버 소켓에서만 필요한 작업이다.
bind 한다는 것은 소켓에 IP주소와 열어줄 포트 번호를 할당하는 것이다.
-https://docs.python.org/3/library/socket.html#example
'For IPv4 addresses, two special forms are accepted instead of a host address: '' represents INADDR_ANY, which is used to bind to all interfaces, and the string '<broadcast>' represents INADDR_BROADCAST.' - 파이썬 공식 문서
위에 나와있는 파이썬 공식 문서에서 확인 할 수 있듯이, HOST에 ' '를 입력하면 모든 인터페이스에서 연결할 수 있도록 만드는 것이다.
즉 위의 bind를 통해 8080포트에서 모든 인터페이스에 접근할 수 있도록 하는 것이다.
-s.listen(1)
bind가 끝나고 나면 상대방의 접속을 기다리는 단계가 시작된다.
이 과정 역시 서버에서만 필요한 작업이다.
listen 함수의 인자로는 [backlog]라는 값이 오는데, 새로운 연결을 거부하기 전에 시스템이 허용할 승인되지 않은 연결 수를 지정한다. 쉽게 말하면, 이미 연결이 되어있을 때, 새로운 연결이 담길 대기 큐의 크기라고 보면 된다.
아래에 파이썬 공식 문서의 listen 함수에 대한 설명을 적어놓았으니 참고하면 되겠다.
'socket.listen([backlog])
Enable a server to accept connections. If backlog is specified, it must be at least 0 (if it is lower, it is set to 0); it specifies the number of unaccepted connections that the system will allow before refusing new connections. If not specified, a default reasonable value is chosen.' - 파이썬 공식 문서
이로써 서버의 소켓은 클라이언트의 접속을 기다리는 상태가 된다!
소켓 연결 / 통신
while True: try: conn, addr = s.accept() #소켓에 누군가 접속하여 연결되면, 결과값이 return. #conn:새로운 소캣, 앞으로 이 소켓 이용. addr: 연결의 다른 끝에 이쓴 소켓에 바인드된 주소 with conn: print(f'Connected by {addr}') data = conn.recv(1500) #소켓으로부터 받은 데이터를 저장(1500 byte) ptr = data.find('\r\n'.encode('utf-8')) #해더 끝의 위치 찾기 header = data[:ptr]#해더 끝에 맞춰 자르기 left = data[ptr:] #데이터의 나머지 부분. keep-alive등 다양한 정보가 담겨있음. 여기선 쓰진 않는다. request = header.decode('utf-8')#헤더 디코드 method, path, protocol = request.split(' ')#해더를 공백을 기준으로 잘라 정리 #(method와 경로, 프로토콜(ex:HTTP/1.1)로 구분된다) print(f'Received: {method} {path} {protocol}')
위에까지는 서버의 소켓을 열어서 연결을 기다리는 상태였다.
여기서 만약 클라이언트의 연결 요청이 오면, accept()메서드를 통해 접속을 수락하고 통신할 수 있다.
- conn, addr = s.accept()
accept() 함수는 소켓에 누군가가 연결되었을 때 return값으로 새로운 소캣 객체와 접속한 상대방의 address(AF)를 반환한다.
서버의 접속한 클라이언트와 데이터를 주고받을 때는 이제 새로 생성된 conn이라는 소캣을 사용하게 된다.
- data = conn.recv(1500)
ptr = data.find('\r\n'.encode('utf-8'))
header = data[:ptr]
left = data[ptr:]
request = header.decode('utf-8')
method, path, protocol = request.split(' ')
recv(1500)메서드로 conn소켓으로부터 최대 1500byte의 데이터를 받아오도록 한 것이다.
이렇게 받아온 데이터를 data라는 변수를 만들어 저장한다.
data 안에 저장되어있는 내용 data 안에는 다음과 같은 내용이 bytes형태로 저장되어 있는데,
첫 헤더를 보면 'GET / HTTP/1.1\r\n'과 같은 내용이다.
이는 클라이언트에서 요청한 내용으로, 우리한테 당장 필요한 것은 이 내용이기 때문에 이부분을 잘라서 header라는 변수에 넣어주는 것이다.
find함수를 통해 '\r\n'이 처음 나오는 인덱스를 저장한 후(ptr 변수) 이 인덱스까지 앞에서부터 잘라 header 변수에 넣어주었다. 이 과정에서 data의 자료형이 bytes이기 때문에, '\r\n'을 인코딩 하여 같은 byte형으로 변환해준 후 사용하여야 한다.
header변수 안에는 GET / HTTP/1.1\r\n라는 값이 들어가게 되고,
이를 utf-8로 인코딩 하여 request 변수에 넣어주었다.
이 값이 split함수로 쪼개져 각각 method, path, protocol 값으로 들어간다.
HTML/CSS/JS/JPG/PNG 서빙
if not data: break if path == '/': #패스가 따로 표기되어있지 않으면 기본으로 변경해줌 path = '/index.html' path = f'.{path}'
만약 데이터가 없으면, 종료하고,
path값에 인자가 들어있지 않으면 '/index.html'로 경로를 바꿔준다(default)
그 뒤 path에 경로를 나타내주는 .을 붙여준다.
if not os.path.exists(path): #위치에 뭐가 없을때 - 오류 전달하는 404코드 끼워넣기 header = 'HTTP/1.1 404 Not Found\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/html;charset=utf-8\r\n' header = f'{header}\r\n' header = header.encode('utf-8') body = ''.encode('utf-8')
만약 해당 path가 존재하지 않는다면, 404 에러를 전달한다.
else: if path[-3:] == 'jpg':#읽어야 할 데이터가 jpg형식 일 때 with open(path, 'rb') as f: body = f.read() header = 'HTTP/1.1 200 OK\r\n' #정상 header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: image/jpeg;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8')
path의 뒤 3자리가 jpg라면(파일이 jpg형식이라면),
- with open(path, 'rb') as f:
- body = f.read()
해당 패스에 존재하는 파일을 읽는데 이때 유의해야할 것은,
이미지 파일은 'r'이 아닌 'rb'를 이용하여 이진 바이트로 읽어와야 한다는 것이다.
헤더들 중 주의깊게 봐야하는 헤더가 한가지 더 있는데, 바로 Content-Type의 헤더이다.
여기서는 해당파일의 MIME타입과 파일이 인코딩 되어있는 방법을 위와 같은 방식으로 명시해주어야 한다.
아래 링크에서 내가 서빙하고자 하는 파일의 MIME타입을 확인할 수 있다.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
Common MIME types - HTTP | MDN
This topic lists the most common MIME types with corresponding document types, ordered by their common extensions.
developer.mozilla.org
elif path[-2:] == 'js':#읽어야 할 데이터가 js형식 일 때 with open(path, 'r', encoding='utf-8') as f: body = f.read() body = body.encode('utf-8') header = 'HTTP/1.1 200 OK\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/html;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8')
뒷자리가 js형식일 때는 r로 읽으면 된다.
해당 파일이 utf-8로 인코딩 되어있다고 알려주기 위해 encoding='utf-8'이라는 인자를 함께 전달하였다.
css파일과 html파일도 해당 방식과 같이 MIME type과 파일을 여는 방법을 수정하여 서빙할 수 있다.
당연한 얘기지만, 나와 같은 방식으로 파일들을 서빙하고싶다면, 서버 코드가 있는 폴더 안에 html, jpg등의 파일이 모두 들어가있어야 한다.
아래는 전체코드이니, 필요하신 분들은 사용하시면 되겠다.
import os import socket HOST = '' PORT = 8080 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: #소켓 객체 생성 #어드레스 패밀리(IPv4), 소켓타입 s.bind((HOST, PORT)) #소켓과 AF 연결 s.listen(1) #상대방의 접속이 올 때까지 대기. 큐 사이즈 1로 설정 print('Start server') while True: try: conn, addr = s.accept() #소켓에 누군가 접속하여 연결되면, 결과값이 return. #conn:새로운 소캣, 앞으로 이 소켓 이용. addr: 연결의 다른 끝에 이쓴 소켓에 바인드된 주소 with conn: print(f'Connected by {addr}') data = conn.recv(1500) #소켓으로부터 받은 데이터를 저장(1500 byte) ptr = data.find('\r\n'.encode('utf-8')) #해더 끝의 위치 찾기 header = data[:ptr]#해더 끝에 맞춰 자르기 left = data[ptr:] #데이터의 나머지 부분. keep-alive등 다양한 정보가 담겨있음. 여기선 쓰진 않는다. request = header.decode('utf-8')#헤더 디코드 method, path, protocol = request.split(' ')#해더를 공백을 기준으로 잘라 정리 #(method와 경로, 프로토콜(ex:HTTP/1.1)로 구분된다) print(f'Received: {method} {path} {protocol}') if not data: break if path == '/': #패스가 따로 표기되어있지 않으면 기본으로 변경해줌 path = '/index.html' path = f'.{path}' if not os.path.exists(path): #위치에 뭐가 없을때 - 오류 전달하는 404코드 끼워넣기 header = 'HTTP/1.1 404 Not Found\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/html;charset=utf-8\r\n' header = f'{header}\r\n' header = header.encode('utf-8') body = ''.encode('utf-8') else: if path[-3:] == 'jpg':#읽어야 할 데이터가 jpg형식 일 때 with open(path, 'rb') as f: body = f.read() header = 'HTTP/1.1 200 OK\r\n' #정상 header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: image/jpeg;\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8') elif path[-3:] == 'png':#읽어야 할 데이터가 png형식 일 때 with open(path, 'rb') as f: body = f.read() header = 'HTTP/1.1 200 OK\r\n' #정상 header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: image/png;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8') elif path[-2:] == 'js':#읽어야 할 데이터가 js형식 일 때 with open(path, 'r', encoding='utf-8') as f: body = f.read() body = body.encode('utf-8') header = 'HTTP/1.1 200 OK\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/html;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8') elif path[-3:] == 'css':#읽어야 할 데이터가 css형식 일 때 with open(path, 'r', encoding='utf-8') as f: body = f.read() body = body.encode('utf-8') header = 'HTTP/1.1 200 OK\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/css;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8') else: with open(path, 'r', encoding='utf-8') as f: body = f.read() body = body.encode('utf-8') header = 'HTTP/1.1 200 OK\r\n' header = f'{header}Server: Our server\r\n' header = f'{header}Connection: close\r\n' header = f'{header}Content-Type: text/html;charset=utf-8\r\n' header = f'{header}Content-Length: {len(body)}\r\n' header = f'{header}\r\n' header = header.encode('utf-8') response = header + body conn.sendall(response) except KeyboardInterrupt: print('Shutdown server') break
'CS > 컴퓨터네트워크' 카테고리의 다른 글
Http 서버 부하 테스트 (0) 2023.10.05 DNS - Domain Name System (1) 2023.10.03