Socket.IO 채팅 만들기 2편: Android 인증, 방 입장과 메시지 복구
이 글은 2022년에 작성한 Android Socket.IO 채팅 예제를 현재 구조에 맞춰 전면 개정한 글입니다. 기존 URL과 post ID는 유지합니다.
1편에서 Node.js 서버와 기본 이벤트를 연결했다면, 2편에서는 실제 앱에 필요한 인증, 채팅방, 메시지 확인 응답과 재연결을 다룹니다.
단순히 socket.connect()가 성공했다고 채팅 기능이 완성되는 것은 아닙니다. 모바일 네트워크는 Wi-Fi와 LTE 사이를 이동하고, 앱은 백그라운드로 내려가며, 연결이 끊긴 동안 메시지가 발생할 수 있습니다.
먼저 보는 핵심
토큰은 연결 시auth로 전달하고 서버 미들웨어에서 검증합니다. 방 입장은 서버가 권한을 확인한 뒤join()하며, 중요한 메시지는 ACK와 데이터베이스 ID를 사용합니다. 재연결은 전송 보장이 아니라 복구를 시작하는 신호입니다.
서버와 Android 클라이언트 버전 맞추기
Android에서 널리 쓰는 Java 클라이언트 io.socket:socket.io-client:2.1.1은 Socket.IO 서버 3.x와 4.x에 대응합니다. 공식 Java 클라이언트 호환성 표를 먼저 확인하세요.
dependencies {
implementation("io.socket:socket.io-client:2.1.1") {
exclude(group = "org.json", module = "json")
}
}
Android가 제공하는 org.json과 중복되지 않도록 제외합니다. Manifest에는 인터넷 권한이 필요합니다.
<uses-permission android:name="android.permission.INTERNET" />
개발 중인 로컬 HTTP 서버에 접속하려고 usesCleartextTraffic="true"를 전체 앱에 켜는 방식은 피하세요. 운영 서버는 HTTPS/WSS를 사용하고, 개발 예외가 필요하면 Network Security Config에서 대상 도메인만 제한합니다.
연결할 때 토큰 전달하기
사용자 ID를 클라이언트가 이벤트마다 임의로 보내게 하면 다른 사용자를 가장할 수 있습니다. 로그인으로 받은 짧은 수명의 액세스 토큰을 Socket.IO handshake의 auth에 넣습니다.
import io.socket.client.IO
import io.socket.client.Socket
import java.net.URI
fun createSocket(baseUrl: String, accessToken: String): Socket {
val options = IO.Options.builder()
.setAuth(mapOf("token" to accessToken))
.setReconnection(true)
.setReconnectionDelay(1_000)
.setReconnectionDelayMax(5_000)
.setTimeout(20_000)
.build()
return IO.socket(URI.create(baseUrl), options)
}
URI에는 https:// 또는 wss:// 같은 scheme이 반드시 있어야 합니다. auth 옵션과 재연결 기본값은 Java 클라이언트 초기화 문서에서 확인할 수 있습니다.
서버는 namespace 미들웨어에서 토큰을 검증합니다.
import { Server } from "socket.io";
import jwt from "jsonwebtoken";
const io = new Server(httpServer, {
cors: { origin: ["https://app.example.com"] }
});
io.use((socket, next) => {
try {
const token = socket.handshake.auth?.token;
const claims = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
socket.data.userId = claims.sub;
next();
} catch {
next(new Error("unauthorized"));
}
});
토큰 원문을 로그에 남기지 말고, 만료된 토큰은 HTTP 로그인 흐름에서 갱신한 뒤 소켓을 다시 연결합니다.
채팅방 입장은 서버가 결정하기
클라이언트가 보낸 roomId만 믿고 socket.join(roomId)를 호출하면 권한 없는 방에 들어갈 수 있습니다. 데이터베이스에서 멤버십을 확인한 뒤 입장시킵니다.
io.on("connection", (socket) => {
socket.on("room:join", async ({ roomId }, ack) => {
const allowed = await roomRepository.isMember(
roomId,
socket.data.userId
);
if (!allowed) {
ack?.({ ok: false, error: "forbidden" });
return;
}
await socket.join(roomId);
ack?.({ ok: true, roomId });
});
});
Android에서는 ACK 결과가 성공한 뒤에만 현재 방 상태를 변경합니다.
fun joinRoom(socket: Socket, roomId: String, onResult: (Boolean) -> Unit) {
val payload = org.json.JSONObject().put("roomId", roomId)
socket.emit("room:join", payload) { args ->
val result = args.firstOrNull() as? org.json.JSONObject
onResult(result?.optBoolean("ok") == true)
}
}
앱 프로세스가 재시작되거나 소켓이 다시 연결되면 서버의 room 가입 상태는 새 연결에 자동 승계된다고 가정하지 마세요. 연결 성공 후 사용자가 보고 있는 방을 다시 요청해야 합니다.
메시지는 서버가 ID와 시간을 부여하기
클라이언트가 만든 임시 ID는 중복 전송을 구분하는 용도로만 사용하고, 최종 메시지 ID와 작성 시각은 서버가 정합니다.
socket.on("message:send", async (request, ack) => {
const allowed = await roomRepository.isMember(
request.roomId,
socket.data.userId
);
if (!allowed) return ack?.({ ok: false, error: "forbidden" });
const message = await messageRepository.insertOnce({
clientMessageId: request.clientMessageId,
roomId: request.roomId,
senderId: socket.data.userId,
text: String(request.text).trim()
});
io.to(request.roomId).emit("message:new", message);
ack?.({ ok: true, message });
});
clientMessageId에는 사용자별 UNIQUE 제약을 두면 ACK를 받지 못해 다시 보낸 요청도 한 번만 저장할 수 있습니다. Socket.IO ACK는 상대 코드가 응답했다는 신호이지 데이터베이스의 영구 저장을 자동 보장하지는 않습니다.
Android에서는 전송 중, 성공, 실패 상태를 분리합니다.
data class ChatMessage(
val clientMessageId: String,
val serverMessageId: String? = null,
val text: String,
val state: SendState = SendState.SENDING
)
enum class SendState { SENDING, SENT, FAILED }
ACK 타임아웃 뒤에는 FAILED로 표시하고 사용자가 재시도할 수 있게 합니다. 재시도해도 같은 clientMessageId를 사용해야 중복 저장을 막을 수 있습니다.
재연결과 누락 메시지 복구
Socket.IO 클라이언트는 기본적으로 재연결을 시도하지만, 끊긴 동안 발생한 이벤트가 모두 자동 전달된다는 뜻은 아닙니다. 마지막으로 저장한 서버 메시지 ID를 기준으로 REST API나 별도 이벤트를 호출해 누락분을 가져옵니다.
socket.on("message:sync", async ({ roomId, afterId }, ack) => {
const messages = await messageRepository.findAfter(roomId, afterId, 100);
ack?.({ ok: true, messages });
});
복구 순서는 다음처럼 단순하게 유지합니다.
- 소켓 연결 성공
- 액세스 토큰 검증 완료
- 현재 방 재입장
- 마지막 서버 메시지 ID 이후 내역 요청
- ID 기준 중복 제거 후 화면 상태 갱신
- 실시간
message:new처리 재개
서버가 여러 대라면 메시지를 데이터베이스에 먼저 저장하고 Redis adapter 같은 공유 계층으로 방 이벤트를 전달해야 합니다. 메모리 배열만 사용하면 다른 인스턴스나 서버 재시작 뒤에 복구할 수 없습니다.
Android 생명 주기와 리스너 정리
Activity나 Composable을 다시 만들 때마다 socket.on()을 추가하면 같은 메시지가 여러 번 보입니다. 소켓 연결은 애플리케이션 범위의 Repository에서 관리하고 화면에는 StateFlow를 노출합니다.
class ChatSocketRepository(private val socket: Socket) {
private val onNewMessage = io.socket.emitter.Emitter.Listener { args ->
val json = args.firstOrNull() as? org.json.JSONObject ?: return@Listener
// JSON을 모델로 변환한 뒤 StateFlow 갱신
}
fun start() {
socket.off("message:new", onNewMessage)
socket.on("message:new", onNewMessage)
if (!socket.connected()) socket.connect()
}
fun stop() {
socket.off("message:new", onNewMessage)
}
}
리스너 인스턴스를 프로퍼티로 보관해야 같은 객체를 off()에 전달할 수 있습니다. 화면에서는 collectAsStateWithLifecycle()로 상태를 수집해 백그라운드에서 불필요한 UI 작업을 줄입니다.
운영 전 점검 목록
- 토큰이 로그와 오류 응답에 노출되지 않는가
- 서버가 방 가입과 메시지 전송 권한을 각각 검사하는가
- 텍스트 길이와 빈 문자열을 서버에서 제한하는가
clientMessageId중복 저장이 차단되는가- ACK 타임아웃과 재시도 UI가 있는가
- 재연결 뒤 방 재입장과 누락 메시지 동기화를 수행하는가
- 화면 재생성 뒤 이벤트 리스너가 중복되지 않는가
- HTTPS/WSS와 허용 origin 목록을 사용하는가
마무리
모바일 Socket.IO 채팅의 핵심은 연결 자체보다 연결이 불안정할 때의 상태 관리입니다. 인증은 handshake에서 시작하고, 방 권한은 서버에서 확인하며, 메시지는 ACK·고유 ID·영구 저장소로 중복과 손실을 다뤄야 합니다.
재연결을 “원래 상태로 자동 복귀”라고 생각하지 말고 방 재입장과 누락 메시지 동기화를 시작하는 트리거로 설계하면, 간단한 예제를 실제 서비스 구조로 확장하기 쉬워집니다.
댓글
댓글 쓰기