네트워크 프로그래밍

"Java Network Programming" 책을 기반으로 Sockets for Clients을 간략히 요약하여 정리했습니다.
인터넷은 데이터를 한 번에 통째로 보내지 못합니다.
'데이터그램' 또는 '패킷'이라고 불리는 작은 조각으로 쪼개서 보냅니다.
헤더(Header): 보내는 곳 포트, 받는 곳 포트, 체크섬 등
페이로드(Payload): 실제 보내려는 데이터 내용물
데이터를 조각내서 보내다 보니 온갖 문제가 발생합니다.
분할과 재조립: 큰 파일은 수천 개의 조각으로 잘라야 하고,
도착하면 다시 순서대로 붙여야 함
분실(Loss): 가는 도중에 조각이 사라질 수 있음 (재전송 필요)
순서 뒤바뀜(Out of order): 1번보다 2번 조각이 먼저 도착할 수 있음
(순서 재배치 필요)
손상(Corruption): 데이터가 깨져서 올 수 있음
위의 모든 복잡한 작업을 대신 해주는 추상화 계층(Abstraction Layer)입니다.
복잡한 네트워크 연결을 단순한 스트림(Stream)처럼 다루게 해줍니다.
그저 소켓에서 InputStream을 얻어 읽고(read), OutputStream을 얻어 쓰면(write) 됩니다.
공통 (클라이언트 & 서버)
위는 java.net.Socket으로, 주로 클라이언트가 사용합니다.
서버 전용(Server Only)
위는 java.net.ServerSocket으로, 서버가 사용합니다.
소켓 프로그래밍은 연결하고(Connect), 스트림을 얻어서(Stream),
읽고 쓰고(Read/Write), 끊는(Close) 과정의 반복입니다.
Socket socket = new Socket("time.nist.gov", 13); // 소켓 연결 및 생성
단순히 객체만 만드는 것이 아니라, 이 순간 실제로 네트워크를 타고
서버(time.nist.gov)의 13번 포트로 TCP 연결을 시도합니다.
서버가 없거나 포트가 닫혀 있으면 IOException이 발생하므로
반드시 try-catch로 감싸야 합니다.
안전 장치: 타임아웃 설정(중요)
socket.setSoTimeout(15000); // 15초
서버가 연결은 받아줬는데, 데이터를 안 보내고 가만히 있는 경우(무응답)를 대비하는 것입니다.
자원 해제 (Resource Management): 소켓은 시스템의 포트와 리소스를 점유하므로, 사용이 끝나면 반드시 닫아야 합니다. (Close)
//Java 7 이상: try-with-resources 구문 사용
try (Socket socket = new Socket("time.nist.gov", 13)) {
// ... 사용 ...
} // 블록 나가면 자동으로 close() 호출됨
데이터 읽기: 서버로부터 데이터를 읽어오는 과정은 파일 읽기와 매우 유사합니다.
가장 먼저 해야 할 일은 시간의 기준점을 맞추는 것입니다.
서버는 1900년 기준으로 알려주는데, 자바는 1970년부터 센 값을 원합니다.
그래서 그 차이(70년)만큼 빼줘야 합니다.
서버는 32비트 숫자(4바이트)를 1바이트씩 쪼개서 총 4번 보냅니다.
이 4개의 조각을 받아서 하나의 큰 숫자(long)로 합쳐야 합니다.
long secondsSince1900 = 0;
for (int i = 0; i < 4; i++) {
secondsSince1900 = (secondsSince1900 << 8) | raw.read();
}
// 1. 기존에 있던 값 왼쪽으로 8칸(8비트) 밈
// 2. 새로 읽은 바이트(raw.read())를 빈 자리에 채워 넣음
자바의 Date 클래스 생성자는 밀리초(ms) 단위를 받습니다.
프로토콜은 초(s) 단위로 줍니다.
따라서 1000을 곱해줘야 합니다.
long msSince1970 = secondsdSince1970 * 1000;
Date time = new Date(msSince1970);
소켓 통신은 기본적으로 양방향(Full-duplex)입니다.
InputStream으로 듣고, OutputStream으로 말합니다.
DICT 프로토콜(Port 2628)
서버에 데이터를 보낼 때 가장 주의해야 할 점은 버퍼(Buffer)와 플러시(Flush), 그리고 줄 바꿈 문자입니다.
OutputStream out = socket.getOutputStream();
Writer writer = new OutputStreamWriter(out, "UTF-8");
// 문자열 편하게 보내기 위해 Writer로 감싸고, 인코딩(UTF-8) 지정
Writer = new BufferedWriter(writer); // 성능
writer.write("DEFINE eng-lat gold\r\n"); // 명령어 끝에 반드시 \r\n (CRLF) 포함
writer.flush(); // 버퍼 비우기 (매우 중요!)
\r\n: 대부분의 인터넷 프로토콜은 줄바꿈 문자로 명령의 끝을 인식합니다.
이 문자를 보내지 않으면 서버는 무한히 기다립니다.
flush(): BufferedWriter는 데이터가 꽉 찰때까지 전송을 미룹니다.
명령어가 짧으면 아예 전송되지 않을 수 있어 flush()를 호출해 강제로 밀어야합니다.
Socket socket = new Socket("www.oreilly.com", 80);
생성자가 반환(return)되었다는 것은 연결에 성공했다는 뜻입니다.
""연결에 성공하면 예외가 안 난다"를 역이용하면,
특정 서버의 어떤 Port가 열려있는지 확인하는 탐지기를 만들 수 있습니다.
1번부터 1024번까지 포트 번호를 하나씩 바꿔가며 new Socket(host, i)를 시도
연결 성공(예외 없음): "아, 이 포트에는 서버가 있구나!"라고 판단 후 출력
그리고 바로 끊음 (close)
연결 실패(IOException): 조용히 넘어감
new Socket(host, port)는 생성자이자 연결 시도 명령입니다.
연결이 되면 객체 생성, 안 되면 에러를 발생합니다.
소켓은 연결이 성립되는 순간 4가지 고유한 정보를 갖게 됩니다.
소켓은 양방향 통신이므로, 상대방(Remote)의 정보와 나(Local)의 정보가 쌍으로 존재합니다.
getInetAddress() → 상대방 IP 주소: 내가 접속하려고 한 서버의 주소
getPort() → 상대방 포트 번호: 보통 정해진 번호 (예: 웹:80, 텔넷:23)
getLocalAddress() → 나의 IP 주소: 내 컴퓨터의 네트워크 인터페이스 주소
getLocalPort() → 나의 포트 번호: 시스템이 랜덤하게 배정 (중요)
connect 되는 순간 확정되고, 중간에 변경이 불가능합니다. (Setter 메서드 없음)
서버 포트(Remote): www.oreilly.com의 80번 포트처럼, 모두가 아는 고정된 문
클라이언트 포트(Local): 내 컴퓨터가 서버로 나갈 때 사용하는 문
운영체제는 남는 포트 중 하나를 랜덤하게 골라서 할당
이유: 만약 내 포트도 80번으로 고정되어 있다면, 브라우저 창을 2개 띄웠을 때,
충돌이 발생합니다.
다른 포트를 쓰면 서버가 응답을 보낼 때 누가 요청했는지 구분이 가능합니다.