이 튜토리얼에서는 안드로이드에서 클라이언트-서버(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 적용을 통한 통신 데이터 암호화