0~6편에서는...
자바를 이용해 모노레일 게임 어플리케이션을 개발하였다. 0~6편에서 만든 모노레일 게임 어플리케이션은 단순한 데스크탑 어플리케이션이기 때문에 멀티플레이 기능을 수행하지 못한다. 7편부터는 멀티플레이 기능 및 채팅기능을 구현하여 어플리게이션을 더욱 개선해보고자 한다.
데스크탑 어플리케이션의 한계
데스크탑 어플리케이션이란 말 그대로 데스크탑만 있으면 실행이 가능한 어플리케이션이다. 그렇기 때문에 어플리케이션을 구동하는데 필요한 모든 기능이 하나에 프로그램 내에 전부 구현되어 있다. 이러한 데스크탑 어플리케이션은 성능이 좋고 빠르다는 장점이 있지만 이에 따른 한계도 분명하다.
첫째로, 수정/개선이 어렵다. 만약 데스크톱 어플리케이션의 수정이 발생했다면 모든 사용자는 이 어플리케이션을 재설치 해야한다. 이는 굉장히 번거로운 일이다. 그렇기 때문에 사용자가 많을수록 어플리케이션의 수정과 개선이 어려워진다는 단점이 있다.
두번째로는 보안에 취약하다. 사용자가 프로그램의 모든 리소스를 가지고 있다 보니 사용자가 접근해서는 안되는 부분에 접근할 수 있는 가능성이 높아진다.
이러한 데스크탑 어플리케이션의 한계를 극복하기 위해 나온 구조가 바로 서버-클라이언트 모델이다.
서버-클라이언트 모델
Client-server model is a distributed application structure that partitions tasks or workloads between the providers of a resource or service, called servers, and service requesters, called clients.
다음은 위키피디아에 나온 서버-클라이언트 모델에 대한 설명이다. 간단하게 요약하면 서버-클라이언트 모델은 하나의 프로그램을 서버와 클라이언트로 나눠 클라이언트는 작업을 서버에게 요청하고 서버는 클라이언트의 요청의 응답하도록 구현하는 모델이다.
다시 말하자면 클라이언트는 사용자에게 입력을 받고 해당 입력에 해당하는 작업을 서버에게 요청한다. 그럼 서버는 해당 작업을 수행 후, 수행 결과를 다시 클라이언트에게 보내준다. 그러면 클라이언트는 해당 결과를 적절하게 출력한다. 이렇게 구현하게 되면 내부 중요한 동작을 서버가 하므로 사용자(클라이언트)는 프로그램 내의 중요한 리소스의 접근이 어려워진다. 또한 프로그램의 수정은 내부분 내부의 동작에 관해 수행되므로 이경우 클라이언트 프로그램의 수정은 필요가 없다. 즉, 데스크톱 어플리케이션처럼 수정 시 재설치 등의 필요성이 급격히 낮아진다.
서버-클라이언트 모델의 적용
위 그림은 0~6편까지 개발한 모노레일 어플리케이션의 클래스 구조이다. 구조를 자세히 보면 2개의 세부 구조로 분리되어 있는 것을 볼 수 있는데 빨간색 화살표를 기준으로 왼쪽은 GUI이고 오른쪽은 작업을 진행하는 시스템이라고 할 수 있다. 사실, 굳이 서버-클라이언트 구조가 아니더라도 객체 지향 관점에 입각해 코드를 짜다보면 어느 정도 사용자의 입출력과 내부 동작이 분리되는 것을 알 수 있다.
즉, MainGUI를 중심으로한 GUI 파트를 클라이언트로 MainSystem 객체를 중심으로한 내부 동작 파트를 서버로 구현하면 된다. 또한 빨간색 화살표의 경우 sendMessage() 메서드를 통해 구현되어 있는데 이를 TCP 소켓 통신을 통해 구현할 예정이다.
무엇으로 통신할까? : 비동기 소켓 채널
서버-클라이언트가 통신하는 방법은 다양하다. 그 중에서 나는 Java NIO 기반의 비동기 소켓채널을 이용하기로 했다. 일단 NIO 기반의 소켓채널을 이용하는 이유는 멀티스레드 환경에서 안전하기 때문이다. 이는 IO기반의 소켓에서 NIO기반의 소켓채널로 변화하면서 얻은 가장 큰 이점이다. 또한 비동기 방식을 이용하는 이유는 사실 구현의 쉬움 때문이다. 사실 대용량 데이터의 이동이 없기 때문에 블로킹 방식의 통신도 크게 문제는 없지만 비동기 방식 특유의 콜백함수 기능이 프로그래밍의 편의성을 높인다고 생각한다. 물론, 프로그램 특성상 입력 순서가 중요하기는 하다만 그 순서가 무너질 정도로 무거운 코드는 없기 때문에 비동기 방식의 소켓 채널을 이용하기로 결정하였다.
데이터의 전달
기존의 데스크탑 어플리케이션의 경우 ContorlButton의 sendMessage() 메서드를 통해 MainSystem에게 사용자 요청 정보를 보내주었다. 이때 sendMessage()의 인자로는 작업유형(String message)과 작업 필요한 인자(List<Integer> args)를 보내주었다. 이것을 소켓통신을 통해 전송할때는 입출력 버퍼를 이용해야 하므로 바이트 형식으로 데이터를 보내야 한다. 그렇기 때문에 String 타입으로 메세지를 지정 후 Charset을 이용해 ByateBuffer로 변형하여 전송하고, 서버는 다시 ByateBuffer를 인코딩 후 String을 파싱하여 작업유형과 첨부 인자를 얻도록 하였다.
1) 클라이언트 -> 서버
다음은 사용자가 클릭 이벤트를 통해 타일 푸쉬 작업을 요청한 경우다. Client는 해당 요청을 먼저 String 타입으로 변환한다. 작업 유형은 "PutTime" 이고 첨부 인자는 위치는 (5,2), 타일 타입인 3이므로 "PutTile 5 2 3"이 된다. 그 후 문자열을 ByteBuffer로 인코딩후 소켓채널을 통해 전송한다. Server는 소켓채널을 통해 해당 요청을 받아 ByteBuffer를 디코딩후 얻은 String을 split() 메서드를 통해 파싱하여 UserBeginAction() 메서드를 실행한다.
2) 서버 -> 클라이언트
서버는 작업의 결과를 클라이언트에게 알려주어야 한다. 또한 클라이언트가 작업 결과에 따라 GUI를 변경해야 할 수도 있으므로 이에 필요한 인자를 전송해야 한다. 과정은 아래 그림과 같다.
타일 놓기에 성공했으므로 첫번째 인자는 TRUE 그 뒤에는 GUI의 타일 이미지 변경에 필요한 인자인 위치와 타입 정보를 넣어 "TRUE PutTile 5 2 3" 문자열을 만든다. 그 후 문자열을 ByteBuffer로 인코딩하여 버퍼에 Write한다. 그러면 서버는 Buffer을 read하여 다시 ByteBuffer를 디코딩하여 문자열을 얻은 후 파싱해서 이미지를 변경한다.
서버
MainSystem 클래스가 서버의 역할을 할 것이다. 그렇기 때문에 추가적으로 몇개의 메서드가 필요한데 다음과 같다.
- MainSystem() 생성자 : 서버를 시작하는 메서드. 서버소켓채널과 채널그룹을 생성하고, accept() 메서드를 실행해 작업 요청을 기다린다.
- stopServer() : 작업 중 통신 관련 오류가 발생하거나, 게임이 종료됐을 경우 서버를 닫는 역할을 한다.
- Client 클래스 : 각각의 클라이언트와 통신하기 위한 클래스이다. 내부 필드로 소켓채널을 가지고 있고, receive() / send() 메서드를 통해 클라이언트와 직접적인 통신을 하고 내부 작업을 요청한다.
startServer()
public class MainSystem {
private AsynchronousChannelGroup channelGroup;
private AsynchronousServerSocketChannel serverSocketChannel;
private List<Player> players = new ArrayList<>();
private List<Client> connections = new Vector<>();
int curPlayer = 0;
private Lock lock = new Lock();
public MainSystem() {
try {
channelGroup = AsynchronousChannelGroup.withFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
Executors.defaultThreadFactory()
);
serverSocketChannel = AsynchronousServerSocketChannel.open(channelGroup);
serverSocketChannel.bind(new InetSocketAddress("localhost", 5001));
} catch (Exception e) {
// TODO: handling exception for connections
}
System.out.println("[서버 시작]");
serverSocketChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, String>() {
@Override
public void completed(AsynchronousSocketChannel socketChannel, String attachment) {
try {
String message = "[연결 수락] " + socketChannel.getRemoteAddress() + " : " + Thread.currentThread().getName();
System.out.println(message);
} catch (Exception e) {}
Player player = new Player(attachment);
Client client = new Client(socketChannel);
players.add(player);
connections.add(client);
System.out.println("[연결된 플레이어수] : " + connections.size());
if (connections.size() == 1) {
client.send("TRUE Wait");
} else if (connections.size() == 2) {
connections.get(0).send("TRUE BeginGame 0");
connections.get(1).send("TRUE BeginGame 1");
} else {
client.send("TRUE NoGame");
}
serverSocketChannel.accept(null, this);
}
@Override
public void failed(Throwable exc, String attachment) {
// TODO: handling exception for connections
}
});
}
}
- 13-18 : channelGroup과 serverSocketChannel을 생성한다.
- 25 : 클라이언트의 연결 요청을 수락한다
- 27-37 : 연결에 성공했을 경우, 첨부객체로 받은 문자열 객체을 name으로 하는 Player와 결과로 얻은 socketChannel을 갖는 client 객체를 생성한다. 그리고 각각 players, connections 리스트에 삽입한다.
- 39-46 : 접속한 플레이어 수가 1명일 경우, 기다린다. 2명일 경우 두 플레이어에게 게임 시작 메세지를 보낸다. 3명 이상인 경우 접속한 플레이어에게 게임 불가 메세지를 보내고 창을 닫게 한다.
- 48 : 다시 클라이언트의 연결 요청을 수락한다 (연결 요청 수락 작업이 무한 반복됨)
stopServer()
public class MainSystem {
private void stopServer() {
try {
connections.clear();
players.clear();
if (channelGroup != null && !channelGroup.isShutdown()) { channelGroup.shutdownNow(); }
System.out.println("[서버 멈춤]");
} catch (Exception e) {}
}
}
- 5-6 : connections/players 리스트를 비운다.
- 8 : channelGroup이 작동 중인 경우 멈춘다.
Client class
private class Client {
private AsynchronousSocketChannel socketChannel;
public Client(AsynchronousSocketChannel socketChannel) {
this.socketChannel = socketChannel;
receive();
}
private void receive() { ... }
private void send(String data) { ... }
}
- 5-6 : socketChannel을 생성하고 데이터을 받을 준비를 한다.
receive()
private class Client {
private void receive() {
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
socketChannel.read(byteBuffer, byteBuffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
attachment.flip();
Charset charset = Charset.forName("UTF-8");
String message = charset.decode(attachment).toString();
String[] messageList = message.split(" ");
String command = messageList[0];
try {
System.out.println("[요청 처리 RECEIVE] " + message + " - " + socketChannel.getRemoteAddress());
} catch (Exception e) {}
boolean ret;
if (messageList.length > 1) {
List<Integer> args = new ArrayList<>();
for (int i=1; i<messageList.length; ++i) {
int arg = Integer.parseInt(messageList[i]);
args.add(arg);
}
ret = userBeginAction(command, args);
} else {
ret = userBeginAction(command);
}
String sendData = (ret ? "TRUE" : "FALSE") + " " + message + " " + curPlayer;
try {
System.out.println("[요청 처리 SEND] " + sendData + " - " + socketChannel.getRemoteAddress());
} catch (Exception e) {}
for (Client client : connections) {
// TODO : 접근권한 데이터까지 보내기
client.send(sendData);
}
ByteBuffer _byteBuffer = ByteBuffer.allocate(100);
socketChannel.read(_byteBuffer, _byteBuffer, this);
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
try {
System.out.println("[클라이언트 통신 안됨] " + socketChannel.getRemoteAddress() + Thread.currentThread().getName());
connections.remove(Client.this);
socketChannel.close();
} catch (IOException e) {}
}
});
}
}
- 4 : byteBuffer를 생성하고 메모리를 할당한다.
- 6 : 클라이언트로 부터 데이터를 ByteBuffer의 형태로 읽어온다.
- 9 : 읽어온 ByteBuffer(attahcment)의 position(읽기 시작할 위치)를 초기화한다.
- 10-11 : attachment를 문자열로 디코딩한다.
- 12-26 : 읽어온 문자열을 파싱하여 요청 메세지(command)와 첨부 인자(args)로 분리한다.
- 27-31 : command와 args를 인자로 하여 userBeingAction 메서드를 호출하여 내부 동작을 수행한다.
- 32 : 내부 동작 결과(userBeiginAction의 리턴값)과 클라이언트의 gui 변경에 필요한 인자를 모아서 문자열 형태(sendData)로 생성한다.
- 36-41 : 게임에 참여한 모든 플레이어에게 해당 데이터를 보낸다.
send(String data)
private class Client {
private void send(String data) {
Charset charset = Charset.forName("UTF-8");
ByteBuffer byteBuffer = charset.encode(data);
socketChannel.write(byteBuffer, null, new CompletionHandler<Integer, Void>() {
@Override
public void completed(Integer result, Void attachment) {}
@Override
public void failed(Throwable exc, Void attachment) {}
});
}
}
- 4-5 : 보낼 데이터를 ByteBuffer로 인코딩한다.
- 7 : 데이터를 클라이언트로 보낸다.
- 추가적인 콜백함수는 필요가 없다.
다음 편에서는...
쓰다 보니 너무 길어져 Client.java 코드는 다음 편에서 설명하는 것이 좋겠다.
또한 아직 구현하지 못한 부분이 많아 그것도 추후 다루겠다.
(채팅기능, 대기방기능, 턴 표시 이미지 수정, 알림창 개선 등등...)
'프로젝트 이야기 > 지니어스:모노레일' 카테고리의 다른 글
6편 : 불가능(Impossible) 모드 구현 (0) | 2021.12.28 |
---|---|
5편 : 싱글턴(Singleton) 없애기 (0) | 2021.12.23 |
4편 : GUI 구현 (0) | 2021.12.22 |
3편 : Board 객체 구현하기 (feat. 객체가 갖는 데이터(필드)에 대한 이야기...) (0) | 2021.12.17 |
2편 : 구현하기(1) (0) | 2021.12.15 |