[Project] Socket 통신 (VAN 연동)

수호·2025년 12월 22일
post-thumbnail

큰 흐름

  1. connect → startReader → send → (프레임 수신 콜백) → 모드 기준으로 계속/종료

  2. 데이터는 STX(0x02) ~ ETX(0x03) + LRC(1byte) 프레이밍

  3. 두 가지 수신 기준을 모두 지원:

  • 길이 헤더 기반 (두 번째 바이트가 ‘I’ 이고, 3..6에 ASCII 4자리 길이)
  • ETX 도발 기반 (길이 헤더가 없을 때)
  1. LRC(XOR)은 STX 포함/제외 두 방식 모두 검증(벤더 사양 혼재 대응)

  2. 매 프레임마다 ACK(0x06) 자동 전송(기존 Kovan 리더가 프레임마다 ACK 요구하는 패턴 대응)

  3. ReceiveMode로 종료 조건을 제어:

  • Continuous: 앱이 close() 할 때까지 계속
  • StopAfterEndFrame: *E…(혹은 E…) 프레임 받으면 종료(가맹점 다운로드 용)
  • StopAfterFirstFrame: 유효 프레임 1개 받자마자 종료(단건 응답용)

Public API 요약

  • suspend fun connect(host, post) → 소켓 연결만 수행(블로킹 읽기, soTimeout=0). 스트림 래핑
  • fun startReader() → IO dispatchers에서 수신루프 시작. (메인스레드에서 네트워크 호출 피하려고 UNDISPATCHED를 쓰지 않음 → NetworkOnMainThreadException 회피)
  • suspend fun send(data: ByteArray) → byte를 서버에 전송
  • fun setOnFrame(listener: (ByteArray) → Boolean) → 완성 프레임(STX..ETX..LRC 전체) 가 올 때마다 호출 → 리스너가 false를 반환하면 “리스너 의사”로 루프를 끌낼 수 있음.
  • fun setReceiveMode(mode) → 수신 종료 정책 지정 (Continuous / StopAfterEndFrame / StopAfterFirstFrame)
  • suspend fun close() → reader 취소 - 스트림/소켓 닫기

단건 조회는 StopAfterFirstFrame, 가맹점 다운로드는 StopAfterEndFrame(서버가 *E…로 끝내줌), 스트리밍은 Continuous

수신 루프(프레이밍) 상세

while (isReaderRunning && isOpen()) {
    // 1바이트 블로킹 읽기 (IO 디스패처)
    localInputStream.read(singleByteBuffer)

    // STX를 기다렸다가, STX가 오면 프레임 버퍼 초기화+수집 시작
    // 'I' 포맷이면 3..6 ASCII 길이(예: "0123")를 파싱해 STX..ETX 길이를 알 수 있음
    // 길이를 알면 그 길이만큼 딱 모은 뒤 LRC 1바이트를 추가로 읽어서 "완성 프레임"
    // 길이를 못 알면, ETX를 만나면 LRC 1바이트 추가로 읽어서 "완성 프레임"
}
  1. 길이 헤더 파싱

    → specFormatByte == ‘I’ 이고 바이트가 7개 이상 모이면 collected[3..6]을 ASCII 정수로 변환 - STX..ETX 길이로 해석. 그 길이만큼 모이면 LRC 1byte를 추가로 읽어 완성

  2. ETX 기반

    → 길이를 못 얻은 경우 ETX 도착 시 LRC 1Byte를 추가로 읽어 완성

RLC 검증과 ACK

val etxIndex = fullFrameBytes.indexOf(ETX)
val readLrc   = fullFrameBytes.last() & 0xFF
val lrcInclStx = xor(fullFrame, from=0, to=etxIndex)   // STX 포함
val lrcExclStx = xor(fullFrame, from=1, to=etxIndex)   // STX 제외

val valid = readLrc == lrcInclStx || readLrc == lrcExclStx
  1. 장비마다 LRC 정의가 STX 포함/제외 혼용되는 경우가 있어 둘다 허용

  2. 유효/무효와 관계 없이 (프로토콜 요구에 맞춰) ACK(0x06)를 전송하도록 구현되어 있음

    (무효 LRC 시에도 계속 받을지 여부는 ReceiveMode에 따름)

종료 조건(ReceiveMode + isEndFrame)

  • isEndFrame(payload) → payload[0] == ‘’ && payload[1] == ‘E’ - 종료(예: E0) → 또는 payload[0] == ‘E’ - 예비 종료 패턴
  • StopAfterEndFrame 에서는 위 패턴을 받을 때까지 계속
  • StopAfterFirstFrame 은 첫 유효 프레임에서 바로 종료
  • Continuous 는 앱이 close() 할 때까지 종료하지 않음

startReader()가 따로 있는 이유는?

  • connect() 는 소켓 연결만 담당
  • startReader() 는 IO 디스패처에서 백그라운드 수신을 시작
  • 이렇게 분리하면, 연결 직후 초기화/전송 타이밍을 제어하기 쉽고, 메인 스레드에서 수신 루프가 즉시 시작되며 네트워크를 건드리는 상황(=UNDISPATCHED)을 피할 수 있어서 NetworkOnMainThreadException을 막을 수 있다.

궁금한거

  1. 이건 어떻게 사용되는거야?
private val supervisorJob = SupervisorJob()
private val ioCoroutineScope = CoroutineScope(Dispatchers.IO + supervisorJob)

→ 뭐가 만들어지나?

  • SupervisorJob()
    • 이 스코프의 “부모 잡(Job)” 이다. 한 자식 코루틴이 실패해도 다른 자식들/스코프 전체가 같이 취소되지 않도록 한다.(일반 Job이면 한 자식의 예외가 스코프 전체로 전파되어 모두 취소됨)
  • CoroutineScope(Dispatchers.IO + supervisroJob) 코루틴 컨텍스트를 합친 것
    • Dispatchers.IO: 네트워크/파일/IO 같은 블로킹 작업을 위한 백그라운드 스레드 풀 → StricMode의 “NetworkOnMainThread” 위반을 피함
    • supervisorJob: 위에서 말한 실패 고립(subpervion) 역할
  • 이 스코프에서 launch{ … } 하면:
    • 작업은 항상 IO 스레드풀에서 돌아가고(메인 스레드 아님)
    • 어느 하나의 지식(예: 수신 루프)이 예외로 죽어도 다른 자식(예: 추후 보낼 heartbeat, 재시도 타이밍 등)은 유지된다.

요즘은 계속해서 개발 진행을 하고 있는데 재미있다! 하지만 가끔 블로그를 써야하는데 주제를 뭐로 할 지 항상 고민인 것 같다. 그래서 앞으로는 개발하면서 궁금하거나 개발한 건에 대해서 작성을 해볼 예정이다!!

profile
처음부터 다시 시작!!

0개의 댓글