프로그래밍/TIL(국비과정)

TIL #26 - 서버와 클라이언트

양아무개 2020. 5. 4. 19:35

 

# 서버와 클라이언트 

 

서버Server Socket을 가지고 있고

클라이언트서버 IP Port를 가지고 있다. 

서버의 Server Socket은 각자가 가지고 있는 socket과는 별개의 socket이며

socket의 할 일인 데이터 주고받기 용으로 존재하는 것이 아니다. 

서버의 Server Socket은 클라이언트를 기다리며 요청을 하는 클라이언트를 낚아채서(accept) 클라이언트와 연결된 socket을 만들어준다. 

클라이언트의 서버IP는 이를테면 핸드폰 번호와 같은 것이다. 이 번호를 통해 서버와 통신한다. 

port는 방과 같은 기능을 한다. 

서버는 클라이언트와 같은 포트를 잡고 클라이언트가 오기를 기다린다. 

서버가 기다리는 방이 곧 포트인 것 이다. 

 

서버와 클라이언트는 각각 요청과 응답을 주고 받는데 반드시 한쪽에서 요청을 하면 다른 한쪽은 응답을 해야한다.

그러기 위해서는 서로의 연락을 주고받을 핸드폰과 같은 존재가 필요하다. 그것이 바로 Socket이다. 

모든 데이터는 이 핸드폰의 역할을 하는 Socket을 통해 왔다 갔다 한다. 

즉, Socket이 있어야 서버에 데이터를 전송할 수 있다. 

하나의 서버는 여러개의 클라이언트를 가질 수 있고, 서버는 클라이언트 갯수에 따라 socket을 늘린다.  

서버와 클라이언트의 각 socket은 1 : 1 관계이지만 보통 서버는 클라이언트에게 먼저 요청을 하지 않는다. 

서버가 불안정하던가 클라이언트를 찾을 수 없다는 등의 오류 메시지를 보낼 경우를 제외하면

보통은 클라이언트 쪽에서 먼저 요청을 하는 경우가 많다. 

 

만약 클라이언트가 더 이상 서버와 교류를 하고 싶지 않다면 close를 보내서 끊어줘야한다. 

서버와 클라이언트는 동기식으로 돌아가 close를 보내고 상대 소켓에서 close 응답이 오면 끊는다. 

한쪽이 일방적으로 close를 보내고 끊지 않는다. 

 

아래는 나 혼자 떠는 채팅창 프로그램 예시이다. 

내가 곧 서버이고 클라이언트가 되는 경우인 것이다. 

먼저 Protocol을 설정한다. 

final class Protocol { 
	// class Text extends Protocol가 불가능 
	public static final String ENTER = "100";
	public static final String EXIT = "200";
	public static final String SEND_MESSAGE = "300";
}

static final을 걸어서 아무도 이 클래스를 부모클래스로 둘 수 없게 한다. 

그리고 들어올 땐 100, 나갈 떈 200, 메시지를 보낼 땐 300으로 각각 protocol을 설정한다. 

그런데 왜 Protocol을 설정하는 클래스를 final class로 지정해줬을까? 

 

아래의 온라인 오목판 예시를 보자. 

왼쪽엔 오목판, 오른쪽은 채팅창이다. 

유저 A가 (1, 1)에 오목을 둔다면 함께 오목을 두고 있는 유저 B의 오목판 (1, 1)에도 오목이 나타나야할 것이다. 

유저 A가 (1, 1) 좌표에 오목을 두면 좌표값이 서버에 올라가고 서버는 유저 B에도 좌표값을 줄 것이다. 

하지만 이렇게 순차적으로 전달하면 유저 A와 유저 B의 오목판에는 시간차가 생긴다. 

유저 A의 오목판과 유저 B의 오목판의 속도가 다르다면 게임 플레이에 차질이 생길 것이다. 

이 문제를 해결하기 위해서는 유저 A의 좌표값을 서버에 올라가면

유저 A와 유저 B의 오목판에 좌표값을 동시에 내려주어야한다.

그러면 A와 B 둘 다 동시에 오목이 뜰 것이다. 

 

채팅창도 마찬가지이다. 

그런데 문제는 오목과 채팅창이 동시에 돌아가며 많은 데이터들이 구분없이 서버에 마구잡이로 올라간다면 

서버는 자칫 잘못된 데이터를 내려보낼 수 있다. 

즉, 서버는 지금 들어온 데이터가 오목좌표값인지 채팅창 대화인지 구분할 필요가 있는 것이다. 

그 구분을 위한 것이 '구분자' 이다. 

 

위의 코드에서 확인할 수 있듯이 100이란 숫자는 채팅창에 입장하겠다는 의미를 가진 protocol이다. 

protocol이 즉 구분자 인 것이다. 

 

서버는 이처럼 데이터를 구분하기 위해 프로토콜이 필요하고 그 프로토콜을 구분을 위해 존재하기 때문에 

final class 키워드가 필요한 것이다. 

 

 

이제 클라이언트 코드와 서버 코드이다. 

클라이언트 코드와 서버코드는 각자 따로 cmd 창을 열어 각자 컴파일을 하고 실행한다.

서버 코드가 실행되지 않으면 클라이언트 코드는 실행될 수 없다. 

서버도 역시 클라이언트가 아무런 행동을 취하지 않으면 그저 켜져 있을 뿐이다. 

둘 다 동시에 실행되어야한다. 

 

서버와 클라이언트는 각자의 cmd 창을 왔다 갔다 하면서 데이터를 주고 받는다. 

하나의 cmd는 pc 1대로 간주한다. 

 

 

먼저 클라이언트 코드이다. 

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.net.UnknownHostException;
import java.io.OutputStreamWriter;
import java.net.Socket;

class ProtocolClient {
	// 클라이언트는 핸드폰이 있어야한다 (서버와 대화를 할 용) 
	// 모든 데이터는 핸드폰을 통해서 (핸드폰 = 소켓)
	private Socket socket;
	private BufferedReader reader; // 소켓을 통해 들어오는 것 
	private BufferedWriter writer; 
	private BufferedReader keyboard; // 키보드를 통해 들어오는 것

클라이언트와 서버 모두 서로의 교류를 위한 socket이 있어야하므로 필드에 socket을 준다. 

그리고 소켓을 통해 값이 들어오는 reader, 값을 보내는 writer, 또 키보드를 통해 들어오는 keyboard 하나를 필드로 준다. 

 

생성자를 하나 만들어 주고 socket, reader, writer를 만든다. 

public ProtocolClient(){
	// 소켓 만들기 
	socket = new Socket("내 IP", 9500);
    // 소켓을 통해 들어오는 경우 
   	 reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
		writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
    // 키보드를 통해 들어오는 경우 
   	 keyboard = new BufferedReader(new InputStreamReader(System.in));
    
  }

지금은 내가 서버고 클라이언트인 상태이므로 내 IP를 socket에 넣어준다. 

socket을 생성해 줄 때는 서버와 연결할 서버 IP( 현 상황에서는 내 IP) 와 포트번호가 필요하다. 

 

여기까지 입력하면 reader와 writer로 인해 IOException 에러가 발생한다. 

그리고 처음보는 예외인 UnknownHostException도 발생하는데 서버를 찾을 수 없는 경우를 대비한 에러이므로 이 둘에 대한 예외처리를 try-catch문으로 잡아준다.

try{
	socket = new Socket("192.168.0.28", 9500); 
			
	// 소켓을 통해 들어오는 경우 
	reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
	writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
			
	// 키보드를 통해 들어오는 경우 
	keyboard = new BufferedReader(new InputStreamReader(System.in));
}catch(UnknownHostException e){
	// 알수없는 서버가 있을 시 
	System.out.println("서버를 찾을 수 없습니다");
	e.printStackTrace();
	System.exit(0);

}catch(IOException e){
	// 서버와 연결이 안되는 경우 : reader와 writer에서 나는 에러 
	System.out.println("서버와 연결이 안되었습니다");
	e.printStackTrace();
	System.exit(0);
}

UnknowHostException이 발생할 경우엔 서버를 찾을 수 없다는 메시지를 띄우고 

IOException이 발생할 경우엔 서버와 연결이 되지 않을 시 나는 에러이므로 서버와 연결이 되지 않았다는 메시지를 띄운다.

 

다음은 직접적인 서비스를 수행할 메서드 service()이다. 

service()는 사용자가 대화를 마치고 나갈 때까지 이어지므로 while문으로 무한 반복시킨다. 

먼저 사용자가 대화를 입력하면 서버인 ProtocolServer.java로 해당 데이터를 보내야한다. 

그런 후 서버로부터 그에 따른 응답을 받아서 읽고 그걸 찍어주면 된다. 

public void service(){
	String msg, line;
    
	while(true){
    	// 서버로 보내기 
    	msg = keyboard.readLine();
	writer.write(msg+"\n");
        writer.flush();

 

메시지는 키보드로부터 입력되기 때문에 키보드에서 데이터를 읽어온다. 

그런데 readLine의 경우엔 엔터를 칠 때 까지 한 라인으로 본다. 

그렇기 때문에 엔터값은 읽지 않아 readLine으로 데이터를 보내면 엔터값이 들어 있지 않다. 

문제는 서버측에서도 reader.readLine으로 데이터를 읽는다. 

그러면 양쪽 다 readLine인 경우 서로 엔터를 찾는 상황이 생긴다. 

따라서 데이터(사용자가 입력한 메시지)를 보내줄 때 \n으로 엔터값을 함께 보내준다. 

(사실, writer.pritnln(msg) 로 엔터값을 자동으로 보내줄 수도 있지만 일단은 엔터의 중요성을 보기 위해서 위와 같은 코드를 작성해 본다. )

여기서 writer.close()는 해주면 안된다. 

아직 대화가 진행중인지 아닌지 모르기 때문에 while문 안에서는 함부러 writer를 닫아줄 수 없다. 

 

우리가 데이터를 보낼때 writer는 buffer를 먼저 거친 후에 데이터를 서버에 보내게 되는데 

그렇게 되면 buffer에 데이터가 남아 있을 수 있다. 

그런 경우 server에서 다시 응답해 올 때 server 역시 buffer를 거치게 되는데 buffer에 남은 데이터 찌꺼기로 인해 buffer 속으로 들어갈 수 없게 된다. 

또한 대화는 단 한 번 만 이루어지는 것이 아니기 때문이다.

그렇기 때문에 한 번 buffer를 거쳤다면 flush()를 이용해 buffer를 비워준다. 

 

이제 서버로 부터 응답을 받아온다. 

아직 server의 코드는 보지 않았지만 server에서는 protocol, 사용자의 이름, 그리고 메시지를 각각 콜론 ( : ) 을 기준으로 나누어 줄 것이다. 

그리고 그것을 클라이언트로 보내준다. (그 외에도 다른 protocol에 대한 요청을 수행한다)

아래의 코드가 서버로부터 받은 것을 처리하는 코드이다. 

line = reader.readLine(); 
System.out.println(line);

어떻게 처리하나 했는데 그냥 reader로 읽어들인 후 찍어내면 된다. 

 

아래는 종료할 경우이다. 

지금 코드는 아직 JFrame으로 따로 frame을 띄운 상황이 아닌 cmd에서 대화를 입력하고 프로토콜을 입력하는 상황이다. 

그렇기 때문에 입력시 프로토콜 : 이름 혹은 메시지 형식으로 입력해줘야한다. 

 

따라서 메시지를 받을 때 콜론과 같은 특수문자를 함께 받기 때문에 프로토콜만을 다루려면 콜론을 기준으로 메시지를 나누어주어야한다. 

String[] arr = msg.split(":"); 
		if(arr[0].equals(Protocol.EXIT)){
					
			reader.close();
			writer.close();
			socket.close();

			System.exit(0);

나눈 문자열들을 문자열 타입 배열에 넣어주면 0번째 인덱스에 프로토콜이, 1번째 인덱스에 이름이나 메시지가 들어가게 된다. 

만약 배열의 0번째 인덱스가 Protocol의 EXIT 와 같다면 reader, writer, socket 을 차례로 닫아준 후 시스템을 종료한다. 

reader, writer, socket을 전부 close 해주면 프로그램을 더이상 유지할 필요가 없기 때문이다. 

 

여기까지 작성하면 또 다시 IOException이 발생한다. 

에러가 발생하면 그냥 에러메시지만 찍으라는 의미의 e.printStackTrace()를 찍어준다. 

	public void service(){
		String msg, line;

		while(true){
			
			try{
				msg = keyboard.readLine();
                		writer.write(msg+"\n");
				writer.flush();
		
				line = reader.readLine(); 
				System.out.println(line);
				
			
				String[] arr = msg.split(":");  
				if(arr[0].equals(Protocol.EXIT)){
					 
					reader.close();
					writer.close();
					socket.close();

					System.exit(0);
				}
			}catch(IOException e){
			

				e.printStackTrace(); 
			}
		}// while()
	}
	public static void main(String[] args) {
		new ProtocolClient().service();
	}
}

 

예외처리를 해줄 때 try - catch의 범위를 어떻게 잡아줘야할지 감이 오지 않았는데, 

그런 경우 에러메시지를 잘 보면 된다는 것을 깨달았다. (늦게나마..) 

맨 처음 Exception 오류가 발생하는 코드부터 끝나는 코드까지 전체적으로 잡아주면 된다. 

IOException이 위와 같이 발생하면 맨 처음 시작하는 코드 msg = keyboard.readLine(); 부터 

가장 마지막 코드 socket.close() 여기까지 try를 잡아준다. 

그런 후에 바로 catch를 작성해준다. 

 

여기까지가 클라이언트 코드이고, 다음은 서버 코드이다. 

 

어떤 프로그램에서 로그인을 할 때를 생각해봤을 때 

로그인이 먼저 일까, 서버가 먼저 일까라는 의문이 생길 수 있다. 

정답은 서버 먼저 이다. 

로그인을 할 때 로그인 이전에 서버가 먼저 가동된다. 

서버가 클라이언트를 먼저 기다리는 형태인 것이다. 

항상 실행은 서버가 먼저이다. 

import java.net.ServerSocket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.net.UnknownHostException;
import java.io.OutputStreamWriter;
import java.net.Socket;


class ProtocolServer {
	private ServerSocket serverSocket;
	private BufferedReader reader; 
	private BufferedWriter writer; 
	private Socket socket; 
	private BufferedReader keyboard;

 

서버의 경우도 클라이언트와 마찬가지로 데이터를 읽고 보낼 reader, writer

데이터를 주고 받는데 꼭 필요한 socker, 키보드를 통해 들어오는 데이터를 받기 위한 keyboard 필드가 필요하다. 

클라이언트와 다른 것이 있다고 하면 ServerSocket이다. 

서버는 데이터를 주고받는데 핸드폰과 같은 역할을 하는 socket과는 별개로 

Server Socket이 있다고 언급한 바있는데 그것을 구현해 준 것이다. 

 

ServerSocket과 Socket은 당연히도 별도로 생성해준다. 

public ProtocolServer(){
	serverSocket = new ServerSocket(9500);
	System.out.println("서버준비 완료");
            
            socket = serverSocket.accept();
            
            reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
            keyboard = new BufferedReader(new InputStreamReader(System.in));

   

서버와 클라이언트는 같은 포트 안에 있어야하므로 같은 포트를 입력해준다. 

serverSocket에 9500번 포트를 적어주면 9500번 포트를 잡고 대기한다. 

만약 클라이언트가 여러개라면 ServerSocket이 while문으로 무한루프를 돌리면서 클라이언트들을 잡으려고 대기하겠지만 지금은 클라이언트가 하나 뿐이므로 while문 없이 간다. 

socket의 accept는 클라이언트를 낚아 채는 역할을 한다. 

그리고 낚아챈 클라이언트를 받아주는 것이 socket이다. 

클라이언트를 accept()로 낚아챔으로써 현재 클라이언트와 내 서버를 연결시켜준다. 

 

또 socket이 클라이언트를 낚았으므로 그 클라이언트의 요청과 응답을 위해 reader와 writer를 생성한다. 

reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));

앞서 언급했듯이 지금은 클라이언트가 하나이고 그에 따라 하나의 클라이언트 소켓을 가지고 있기 때문에 while문 없이 진행한다. 

서버와 클라이언트는 주고 받음이 있어야한다.

그렇기 한 쪽에면 reader, writer를 주지 않고 양 쪽 다 준다. 

즉 client의 writer를 server의 reader가 받아주고 

server의 writer를 client의 reader가 받아주는 것이다. 

 

여기까지 입력하면 또 다시 reader, writer에 의한 IOException이 발생한다. 

오류 메시지가 어디서부터 시작하고 끝나는지 잘 보고 그에 따라 try-catch를 잡아준다. 

public ProtocolServer(){

		try{
			serverSocket = new ServerSocket(9500);
			System.out.println("서버준비 완료");

			socket = serverSocket.accept();
			
			reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
			writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
		
			keyboard = new BufferedReader(new InputStreamReader(System.in));
		}catch(IOException e){
			System.out.println("클라이언트와 연결이 안되었습니다");
			e.printStackTrace();
			System.exit(0);
		}

	}

  여기서 IOException이 발생하면 클라이언트와 연결이 되지 않았다는 뜻이므로 연결이 되지 않았다는 메시지를 띄운다. 

 

그런 다음, 클라이언트 코드에서 말했듯이 각종 프로토콜에 따른 처리와 메시지를 콜론을 기준으로 나누는 기능을 수행할 메서드 service()를 작성한다. 

public void service(){
		String line;

		while(true){
        	line = reader.readLine();
           	String[] arr = line.split(":");

여기서도 역시 대화가 끝나지 않는 한 코드가 끝나지 않게 while문으로 감싸준다. 

먼저 서버는 클라이언트로부터 오는 메시지를 받아 읽어들인다. 

그리고 그 메시지를 콜론 기준으로 분해해 사용자가 어떤 명령을 가진 프로토콜을 함께 보냈는지 보아야한다.

앞에서 static final로 작성한 class를 보면 

프로토콜 100은 입장, 200은 나가기, 300은 메시지 입력이다. 

 

콜론을 기준으로 분리한 문자열은 문자열 타입 배열에 넣어둔다. 

그렇게 되면 프로토콜은 배열의 0번째 인덱스에 담기게 되므로 if문으로 배열의 0번째 인덱스를 보고 프로토콜을 구분할 수 있다. 

 

먼저 입장에 관한 프로토콜 100이다. 

	if(arr[0].equals(Protocol.ENTER)){
		writer.write(arr[1] + "님 입장하였습니다 \n"); 
		writer.flush();

100은 Protocol.ENTER로 표현해주는 것이 가독성에 좋다. 

입장 메시지를 띄워주고 line은 엔터를 인식하지 않기 떄문에 꼭 엔터값을 넣어준다. 

그런 후 buffer를 flush() 해준다. 

 

퇴장의 경우이다. 

}else if(arr[0].equals(Protocol.EXIT)){ 
		
			writer.write(arr[1] + "님 퇴장하였습니다 \n");
			writer.flush();

 퇴장할 경우엔 클라이언트에서 먼저 close()를 하고 그런 후 서버도 close()된다. 

서버도 역시 reader, writer, socket을 차례로 close() 하고 시스템을 종료한다. 

reader.close();
writer.close();
socket.close();

System.exit(0);

 

메시지의 경우에는 사용자의 이름은 1번째 인덱스와 메시지 내용인 2번째 인덱스를 띄워준다. 

				}else if(arr[0].equals(Protocol.SEND_MESSAGE)){
					// SEND-MESSAGE
					writer.write("[" +arr[1]+ "] " +arr[2]+"\n");
					writer.flush();

 

try-catch를 잡아준다. 

	public void service(){
		String line;

		while(true){
	
			try{

				line = reader.readLine(); 
				// 분해 
				String[] arr = line.split(":"); 
				
				if(arr[0].equals(Protocol.ENTER)){ // "100", "angel"
					writer.write(arr[1] + "님 입장하였습니다 \n");
					writer.flush();

					
				}else if(arr[0].equals(Protocol.EXIT)){ 
					// EXIT
					writer.write(arr[1] + "님 퇴장하였습니다 \n");
					writer.flush();
					
					reader.close();
					writer.close();
					socket.close();

					System.exit(0);

				}else if(arr[0].equals(Protocol.SEND_MESSAGE)){
					// SEND-MESSAGE
					writer.write("[" +arr[1]+ "] " +arr[2]+"\n");
					writer.flush();
				}
				
			}catch(IOException e){
				e.printStackTrace();
			}
		}// while
	}
    public static void main(String[] args) {
		new ProtocolServer().service();	
	}
}

 

아래는 코드가 전체적으로 어떻게 데이터를 주고 받는지에 대한 그림이다. 

  ProtocolClient가 ProtocolServer에 데이터를 보내기 위해서는 buffer, Socket 순으로 거친다. 

 ProtocolServer가 ProtocolClient에 데이터를 보낼 때도 마찬가지 이다. 

 

아래는 채팅창 프로그램이 메시지를 입력했을 때의 그림이다. 

ProtocolClient에서 100:user라고 입력하면 ProtocolServer에서 100:user를 콜론 기준으로 100, user로 나누고 

user 님 입장이라는 메시지를 띄운다. 

300과200도 마찬가지이다. 

왼쪽의 ProtocolClient의 경우에는 눈에 보이는 메시지들로 이루어져 있고 (직접 입력하는 부분)

오른쪽의 ProtocolServer의 경우엔 사용자의 눈에는 보이지 않는 과정들로 이루어져 있다.