Computer >> 컴퓨터 >  >> 프로그래밍 >> Android

안드로이드 클라이언트-서버 프로그래밍 완벽 가이드: 소켓 통신으로 실시간 메시지 주고받기

이 튜토리얼에서는 안드로이드에서 클라이언트-서버(Client-Server) 프로그래밍을 구현하는 방법을 단계별로 알아봅니다. 두 대의 안드로이드 기기가 같은 네트워크에 접속되어 있을 때, TCP 소켓을 활용해 실시간으로 메시지를 주고받는 채팅 애플리케이션을 만들어 보겠습니다.

이 예제를 진행하려면 서버 프로젝트클라이언트 프로젝트, 총 두 개의 별도 프로젝트가 필요합니다.


1. 서버(Server) 앱 만들기

Step 1 — 새 프로젝트 생성

Android Studio에서 File → New Project를 선택하고, 필요한 정보를 모두 입력하여 새 프로젝트를 생성합니다.

Step 2 — 레이아웃 작성 (res/layout/activity_main.xml)

아래 코드를 activity_main.xml에 추가합니다. 화면에는 IP 주소, 포트 번호, 연결 상태, 수신 메시지 목록과 메시지 입력창, 전송 버튼이 배치됩니다.

<?xml version = "1.0" encoding = "utf-8"?>
<RelativeLayout xmlns:android = "https://schemas.android.com/apk/res/android"
    xmlns:tools = "https://schemas.android.com/tools"
    android:layout_width = "match_parent"
    android:layout_height = "match_parent"
    android:layout_margin = "16dp"
    tools:context = ".MainActivity">
    <TextView
        android:id = "@+id/tvIP"
        android:layout_width = "wrap_content"
        android:layout_height = "wrap_content"
        android:textAppearance = "@style/Base.TextAppearance.AppCompat.Medium" />
    <TextView
        android:id = "@+id/tvPort"
        android:layout_width = "wrap_content"
        android:layout_height = "wrap_content"
        android:layout_below = "@+id/tvIP"
        android:textAppearance = "@style/Base.TextAppearance.AppCompat.Medium" />
    <TextView
        android:id = "@+id/tvConnectionStatus"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_below = "@+id/tvPort"
        android:textAppearance = "@style/Base.TextAppearance.AppCompat.Medium" />
    <TextView
        android:id = "@+id/tvMessages"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_above = "@+id/etMessage"
        android:layout_below = "@+id/tvConnectionStatus"
        android:inputType = "textMultiLine"
        android:textAppearance = "@style/Base.TextAppearance.AppCompat.Medium" />
    <EditText
        android:id = "@+id/etMessage"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_above = "@+id/btnSend"
        android:hint = "Enter Message"
        android:inputType = "text" />
    <Button
        android:id = "@+id/btnSend"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_alignParentBottom = "true"
        android:text = "SEND" />
</RelativeLayout>

Step 3 — 메인 코드 작성 (src/MainActivity.java)

서버의 핵심 로직은 세 개의 스레드로 구성됩니다.

  • Thread1: ServerSocket을 열고 클라이언트의 접속(accept)을 대기합니다.
  • Thread2: 연결된 클라이언트로부터 들어오는 메시지를 지속적으로 읽어 화면에 출력합니다.
  • Thread3: 사용자가 입력한 메시지를 클라이언트로 전송합니다.

네트워크 작업은 반드시 백그라운드 스레드에서 처리해야 하며, UI 갱신은 runOnUiThread()를 통해 메인 스레드에서 수행한다는 점에 유의하세요.

package com.server.myapplication.server;
import android.annotation.SuppressLint;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

@SuppressLint("SetTextI18n")
public class MainActivity extends AppCompatActivity {
    ServerSocket serverSocket;
    Thread Thread1 = null;
    TextView tvIP, tvPort;
    TextView tvMessages;
    EditText etMessage;
    Button btnSend;
    public static String SERVER_IP = "";
    public static final int SERVER_PORT = 8080;
    String message;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tvIP = findViewById(R.id.tvIP);
        tvPort = findViewById(R.id.tvPort);
        tvMessages = findViewById(R.id.tvMessages);
        etMessage = findViewById(R.id.etMessage);
        btnSend = findViewById(R.id.btnSend);
        try {
            SERVER_IP = getLocalIpAddress();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        Thread1 = new Thread(new Thread1());
        Thread1.start();
        btnSend.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                message = etMessage.getText().toString().trim();
                if (!message.isEmpty()) {
                    new Thread(new Thread3(message)).start();
                }
            }
        });
    }
    private String getLocalIpAddress() throws UnknownHostException {
        WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
        assert wifiManager ! = null;
        WifiInfo wifiInfo = wifiManager.getConnectionInfo();
        int ipInt = wifiInfo.getIpAddress();
        return InetAddress.getByAddress(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(ipInt).array()).getHostAddress();
    }
    private PrintWriter output;
    private BufferedReader input;
    class Thread1 implements Runnable {
        @Override
        public void run() {
            Socket socket;
            try {
                serverSocket = new ServerSocket(SERVER_PORT);
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        tvMessages.setText("Not connected");
                        tvIP.setText("IP: " + SERVER_IP);
                        tvPort.setText("Port: " + String.valueOf(SERVER_PORT));
                    }
                });
                try {
                    socket = serverSocket.accept();
                    output = new PrintWriter(socket.getOutputStream());
                    input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            tvMessages.setText("Connected\n");
                        }
                    });
                    new Thread(new Thread2()).start();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    private class Thread2 implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    final String message = input.readLine();
                    if (message ! = null) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                tvMessages.append("client:" + message + "\n");
                            }
                        });
                    } else {
                        Thread1 = new Thread(new Thread1());
                        Thread1.start();
                        return;
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    class Thread3 implements Runnable {
        private String message;
        Thread3(String message) {
            this.message = message;
        }
        @Override
        public void run() {
            output.write(message);
            output.flush();
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    tvMessages.append("server: " + message + "\n");
                    etMessage.setText("");
                }
            });
        }
    }
}

참고: getLocalIpAddress() 메서드는 Wi-Fi 연결 정보에서 기기의 로컬 IP 주소를 가져오는 역할을 합니다. ByteBuffer를 활용해 리틀 엔디안(Little-Endian) 방식으로 정수형 IP 값을 문자열 형태의 IP 주소로 변환합니다.

Step 4 — 권한 설정 (androidManifest.xml)

Wi-Fi 상태 조회와 네트워크 통신을 위해 아래와 같은 권한을 매니페스트에 추가해야 합니다.

<?xml version = "1.0" encoding = "utf-8"?>
<manifest xmlns:android = "https://schemas.android.com/apk/res/android"
    package = "com.example.myapplication">
    <uses-permission android:name = "android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name = "android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name = "android.permission.INTERNET"/>
    <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/AppTheme">
        <activity android:name = ".MainActivity"
            android:label = "Server">
            <intent-filter>
                <action android:name = "android.intent.action.MAIN" />
                <category android:name = "android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

2. 클라이언트(Client) 앱 만들기

Step 1 — 새 프로젝트 생성

서버와 마찬가지로 Android Studio에서 File → New Project를 통해 새 프로젝트를 하나 더 생성합니다.

Step 2 — 레이아웃 작성 (res/layout/activity_main.xml)

클라이언트 화면에는 서버의 IP 주소와 포트 번호를 입력하는 필드, 서버 접속 버튼, 그리고 메시지 입출력 영역이 포함됩니다.

<?xml version = "1.0" encoding = "utf-8"?>
<RelativeLayout xmlns:android = "https://schemas.android.com/apk/res/android"
    xmlns:tools = "https://schemas.android.com/tools"
    android:layout_width = "match_parent"
    android:layout_height = "match_parent"
    android:layout_margin = "16dp"
    tools:context = ".MainActivity">
    <EditText
        android:id = "@+id/etIP"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:hint = "IP-Address"
        android:inputType = "text" />
    <EditText
        android:id = "@+id/etPort"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_below = "@+id/etIP"
        android:hint = "Port No"
        android:inputType = "number" />
    <Button
        android:id = "@+id/btnConnect"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_below = "@+id/etPort"
        android:layout_gravity = "center"
        android:layout_marginTop = "16dp"
        android:text = "Connect To Server" />
    <TextView
        android:id = "@+id/tvMessages"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_above = "@+id/etMessage"
        android:layout_below = "@+id/btnConnect"
        android:inputType = "textMultiLine"
        android:textAppearance = "@style/Base.TextAppearance.AppCompat.Medium" />
    <EditText
        android:id = "@+id/etMessage"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_above = "@+id/btnSend"
        android:hint = "Enter Message"
        android:inputType = "text" />
    <Button
        android:id = "@+id/btnSend"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:layout_alignParentBottom = "true"
        android:text = "SEND" />
</RelativeLayout>

Step 3 — 메인 코드 작성 (src/MainActivity.java)

클라이언트 역시 서버와 동일한 세 개의 스레드 구조를 사용하지만, 차이점은 ServerSocket 대신 Socket 객체로 서버에 직접 연결(connect)한다는 점입니다. 사용자가 IP 주소와 포트 번호를 입력한 후 'Connect To Server' 버튼을 누르면 Thread1이 시작되어 서버와의 연결을 시도합니다.

package com.client.myapplication.client;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

@SuppressLint("SetTextI18n")
public class MainActivity extends AppCompatActivity {
    Thread Thread1 = null;
    EditText etIP, etPort;
    TextView tvMessages;
    EditText etMessage;
    Button btnSend;
    String SERVER_IP;
    int SERVER_PORT;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        etIP = findViewById(R.id.etIP);
        etPort = findViewById(R.id.etPort);
        tvMessages = findViewById(R.id.tvMessages);
        etMessage = findViewById(R.id.etMessage);
        btnSend = findViewById(R.id.btnSend);
        Button btnConnect = findViewById(R.id.btnConnect);
        btnConnect.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                tvMessages.setText("");
                SERVER_IP = etIP.getText().toString().trim();
                SERVER_PORT = Integer.parseInt(etPort.getText().toString().trim());
                Thread1 = new Thread(new Thread1());
                Thread1.start();
            }
        });
        btnSend.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String message = etMessage.getText().toString().trim();
                if (!message.isEmpty()) {
                    new Thread(new Thread3(message)).start();
                }
            }
        });
    }
    private PrintWriter output;
    private BufferedReader input;
    class Thread1 implements Runnable {
        @Override
        public void run() {
            Socket socket;
            try {
                socket = new Socket(SERVER_IP, SERVER_PORT);
                output = new PrintWriter(socket.getOutputStream());
                input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        tvMessages.setText("Connected\n");
                    }
                });
                new Thread(new Thread2()).start();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    class Thread2 implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    final String message = input.readLine();
                    if (message ! = null) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                tvMessages.append("server: " + message + "\n");
                            }
                        });
                    } else {
                        Thread1 = new Thread(new Thread1());
                        Thread1.start();
                        return;
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    class Thread3 implements Runnable {
        private String message;
        Thread3(String message) {
            this.message = message;
        }
        @Override
        public void run() {
            output.write(message);
            output.flush();
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    tvMessages.append("client: " + message + "\n");
                    etMessage.setText("");
                }
            });
        }
    }
}

Step 4 — 권한 설정 (androidManifest.xml)

클라이언트는 인터넷 통신 권한이 필요하므로 아래와 같이 매니페스트를 작성합니다.

<?xml version = "1.0" encoding = "utf-8"?>
<manifest xmlns:android = "https://schemas.android.com/apk/res/android"
    package = "com.client.myapplication.client">
    <uses-permission android:name = "android.permission.INTERNET" />
    <uses-permission android:name = "android.permission.ACCESS_NETWORK_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/AppTheme">
        <activity
            android:name = "com.client.myapplication.client.MainActivity"
            android:label = "Client">
            <intent-filter>
                <action android:name = "android.intent.action.MAIN" />
                <category android:name = "android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

3. 앱 실행 및 결과 확인

이제 서버 앱과 클라이언트 앱을 모두 실행해 보겠습니다. 실제 안드로이드 기기가 컴퓨터에 연결되어 있다고 가정합니다. Android Studio에서 실행할 프로젝트의 액티비티 파일을 연 후 툴바의 Run ▶ 아이콘을 클릭하세요. 목록에서 실행할 모바일 기기를 선택하면 해당 기기에 앱이 설치·실행됩니다.

주의: 서버와 클라이언트 기기는 반드시 동일한 Wi-Fi 네트워크에 연결되어 있어야 합니다. 클라이언트 앱에는 서버 화면에 표시된 IP 주소와 포트 번호(8080)를 입력한 후 접속하면 됩니다.

서버 실행 화면

안드로이드 클라이언트-서버 프로그래밍 완벽 가이드: 소켓 통신으로 실시간 메시지 주고받기

서버 앱이 실행되면 자동으로 할당된 IP 주소와 포트 번호가 화면 상단에 표시되고, 클라이언트의 접속을 대기하는 상태가 됩니다.

클라이언트 실행 화면

안드로이드 클라이언트-서버 프로그래밍 완벽 가이드: 소켓 통신으로 실시간 메시지 주고받기

클라이언트 앱에서 서버의 IP 주소와 포트 번호를 입력하고 'Connect To Server'를 누르면 양쪽 화면 모두 "Connected" 상태로 전환됩니다.

메시지 송수신 결과

안드로이드 클라이언트-서버 프로그래밍 완벽 가이드: 소켓 통신으로 실시간 메시지 주고받기

연결이 완료되면 어느 쪽에서든 메시지를 입력하고 SEND 버튼을 눌러 실시간으로 대화를 주고받을 수 있습니다. 서버에서 보낸 메시지는 클라이언트에, 클라이언트가 보낸 메시지는 서버 화면에 각각 기록됩니다.

마무리

이처럼 Java의 ServerSocket과 Socket 클래스를 활용하면 별도의 외부 서버 없이도 안드로이드 기기 간 직접 통신이 가능합니다. 이 예제는 학습용으로 간단하게 구성되었으며, 실제 서비스에 적용하려면 다음 사항들을 추가로 고려하는 것이 좋습니다.

  • 메시지 끝에 줄바꿈 문자(\n)를 붙여 readLine()이 정상적으로 동작하도록 처리
  • 화면 회전 등으로 인한 Activity 재생성 시 소켓 연결 유지 로직
  • 다중 클라이언트 접속을 위한 멀티스레딩 확장
  • TLS/SSL 적용을 통한 통신 데이터 암호화