[Java] RS-232 통신 구현하기: COM Port 검색·Baud Rate 설정·송수신 처리

Java로 RS-232 통신 구현하기: COM Port 검색·Baud Rate 설정·송수신 처리#

1장 Java 프로그램이 RS-232 장비와 통신하려면 무엇이 필요할까#

PC와 산업 장비가 RS-232로 연결되어 있다고 하겠습니다.

물리적으로는 다음과 같습니다.

Java Application
      │
      │ COM3
      ▼
Serial Port
      │
      │ RS-232
      ▼
Device

하지만 Cable을 연결했다고 바로 통신되는 것은 아닙니다.

프로그램에서는 최소한 다음 조건이 맞아야 합니다.

올바른 COM Port

Baud Rate

Data Bits

Parity

Stop Bits

Flow Control

송수신 데이터 형식

하나라도 틀리면 장비는:

응답 없음

깨진 문자

잘못된 Packet

Timeout

처럼 보일 수 있습니다.

따라서 Java 장비 통신은 단순히 write() 한 번 호출하는 문제가 아닙니다.

Port를 찾고 → 통신 조건을 맞추고 → byte를 보내고 → 응답을 Buffer에 모아 → Protocol 단위로 해석하는 전체 과정을 구현해야 합니다.

2장 Java에서는 jSerialComm을 이용할 수 있다#

Java 기본 API만으로 모든 운영체제의 Serial Port를 동일한 방식으로 다루기에는 제약이 있기 때문에 일반적으로 별도의 Serial Communication Library를 사용합니다.

대표적인 선택지 중 하나가 jSerialComm입니다.

jSerialComm은 현재 다음과 같은 기능을 제공합니다.

Serial Port 검색

Baud Rate 설정

Data Bit 설정

Stop Bit 설정

Parity 설정

Flow Control

Read / Write Timeout

InputStream / OutputStream

Event Callback

장비 Disconnect 감지

Windows, Linux, macOS 등 여러 Platform을 지원하도록 설계되어 있습니다.

기본 Import:

import com.fazecast.jSerialComm.SerialPort;

실무에서는 Library Version을 프로젝트의 Maven 또는 Gradle Dependency로 명시해 관리하는 것이 좋습니다.

3장 첫 번째 단계는 COM Port를 찾는 것이다#

Windows에서는 Serial Port가 보통:

COM1
COM3
COM7

같은 이름으로 나타납니다.

Linux에서는:

/dev/ttyS0
/dev/ttyUSB0
/dev/ttyACM0

같은 Device Path를 만날 수 있습니다.

jSerialComm에서는 현재 시스템의 Serial Port를 나열할 수 있습니다. 포트 열거와 시스템 포트 이름·설명 조회 기능은 공식 기능으로 제공됩니다.

SerialPort[] ports = SerialPort.getCommPorts();

for (SerialPort port : ports) {
    System.out.println(
        port.getSystemPortName()
        + " / "
        + port.getDescriptivePortName()
    );
}

출력 예:

COM3 / USB Serial Port
COM5 / Communications Port
COM8 / USB-RS232 Adapter

여기서 중요한 점은:

COM3이 존재한다.

와:

COM3이 내가 원하는 장비다.

는 다른 사실이라는 것입니다.

USB-RS232 Adapter를 여러 개 사용하는 환경에서는 COM 번호가 달라질 수 있으므로 Device Description, USB Port 위치, 장비 관리 정보 등을 함께 사용하는 것이 좋습니다.

4장 Serial Port 통신 조건을 정확하게 맞춘다#

장비 Manual에 다음과 같이 적혀 있다고 하겠습니다.

9600
8
N
1

흔히:

9600-8-N-1

이라고 표현합니다.

뜻은:

Baud Rate
9600

Data Bits
8

Parity
None

Stop Bits
1

입니다.

Java에서는 다음처럼 설정할 수 있습니다.

SerialPort port = SerialPort.getCommPort("COM3");

port.setComPortParameters(
    9600,
    8,
    SerialPort.ONE_STOP_BIT,
    SerialPort.NO_PARITY
);

jSerialComm은 Baud Rate, Data Bits, Stop Bits, Parity 및 Flow Control 구성을 지원합니다.

Baud Rate가 다르면 어떻게 될까#

장비:

9600 bps

Java:

19200 bps

라면 정상적인 byte 해석이 어렵습니다.

결과는:

깨진 데이터

이상한 Hex 값

Checksum 오류

응답 없음

등으로 나타날 수 있습니다.

그래서 Serial 장애에서는 가장 먼저:

Baud
Data
Parity
Stop
Flow Control

을 장비 Manual과 비교해야 합니다.

5장 Port를 열고 데이터를 송신한다#

통신 Parameter 설정이 끝났다면 Port를 엽니다.

if (!port.openPort()) {
    throw new IllegalStateException("Serial port open failed");
}

그다음 OutputStream을 이용해 byte를 전송할 수 있습니다.

try {
    var out = port.getOutputStream();

    byte[] request = {
        0x02,
        0x10,
        0x00,
        0x03
    };

    out.write(request);
    out.flush();

} catch (Exception e) {
    e.printStackTrace();
}

여기서 예제의 byte 배열은 실제 장비 명령이 아니라 흐름 설명을 위한 Test Frame입니다.

실제 장비에서는 반드시 제조사가 제공한 Protocol을 따라야 합니다.

예를 들어 실제 Packet이:

STX
Address
Command
Length
Data
Checksum
ETX

구조라면 그 규칙에 맞춰 byte 배열을 만들어야 합니다.

6장 Serial 통신에서는 문자열보다 byte를 먼저 생각한다#

장비 통신을 구현할 때 흔한 실수가 있습니다.

out.write("OPEN".getBytes());

를 보내면 장비가 이해할 것이라고 생각하는 것입니다.

장비 Protocol이 ASCII라면 가능할 수 있습니다.

하지만 Binary Protocol이라면 전혀 다릅니다.

예:

02 10 00 01 7A 03

같은 Frame을 요구할 수 있습니다.

따라서 장비 Manual에서 먼저 확인해야 합니다.

ASCII Protocol인가?

Binary Protocol인가?

Command Code는 무엇인가?

Address가 있는가?

Length가 있는가?

CRC·Checksum이 있는가?

STX·ETX가 있는가?

Java 내부에서는 가능한 한:

String

이 아니라:

byte[]

기준으로 통신 Layer를 설계하는 것이 안전합니다.

7장 데이터를 한 번 읽었다고 Packet 하나가 오는 것은 아니다#

Serial Communication에서 매우 중요한 부분입니다.

다음 Packet이 왔다고 하겠습니다.

02 10 04 41 42 43 44 9A 03

Java의 한 번의 read()가 반드시 이 전체 Packet을 반환하는 것은 아닙니다.

첫 번째 Read:

02 10 04

두 번째:

41 42

세 번째:

43 44 9A 03

처럼 나뉘어 들어올 수 있습니다.

반대로 여러 Packet이 한 Read에 함께 들어올 수도 있습니다.

PACKET 1 | PACKET 2

따라서:

read() 한 번
=
Packet 한 개

라고 구현하면 안 됩니다.

기본 구조는:

Serial Port
↓
Read
↓
Receive Buffer
↓
Packet Boundary 탐색
↓
Complete Packet 추출
↓
Parser

가 되어야 합니다.

8장 Timeout을 반드시 설계한다#

장비에 Request를 보냈다고 하겠습니다.

Java
↓
Command
↓
Device

정상이라면:

Device
↓
Response
↓
Java

가 와야 합니다.

하지만 실제 현장에서는:

Cable 단선

장비 전원 OFF

잘못된 Baud Rate

Protocol 오류

장비 처리 지연

등으로 Response가 오지 않을 수 있습니다.

따라서 프로그램이 무한정 기다려서는 안 됩니다.

jSerialComm은 Blocking·Non-blocking Read/Write Timeout 설정을 지원합니다.

예:

port.setComPortTimeouts(
    SerialPort.TIMEOUT_READ_SEMI_BLOCKING,
    1000,
    0
);

여기서는 Read Timeout을 1초로 두고 있습니다.

하지만:

1초

가 모든 장비에 맞는 값은 아닙니다.

Timeout은:

장비 응답 시간

Protocol 규격

장비 부하

재시도 횟수

업무 중요도

에 따라 결정해야 합니다.

9장 비동기 수신 구조를 만드는 것이 좋다#

장비가 언제 데이터를 보낼지 모르는 경우가 많습니다.

예:

Sensor Event

Alarm

Card Read

장비 상태 변경

주기적 Telemetry

이런 경우 Main Thread에서 계속 Blocking Read를 하면 다른 작업에 영향을 줄 수 있습니다.

jSerialComm은 Stream 방식뿐 아니라 Event Callback 기반 데이터 수신도 지원합니다.

구조적으로는:

Serial Port
      │
      ▼
Receive Event
      │
      ▼
Buffer
      │
      ▼
Packet Parser
      │
      ▼
Business Logic

처럼 역할을 분리하는 것이 좋습니다.

간단한 Listener 예:

port.addDataListener(new SerialPortDataListener() {

    @Override
    public int getListeningEvents() {
        return SerialPort.LISTENING_EVENT_DATA_AVAILABLE;
    }

    @Override
    public void serialEvent(SerialPortEvent event) {
        if (event.getEventType()
                != SerialPort.LISTENING_EVENT_DATA_AVAILABLE) {
            return;
        }

        byte[] data =
            new byte[port.bytesAvailable()];

        int read =
            port.readBytes(data, data.length);

        if (read > 0) {
            handleReceivedBytes(data, read);
        }
    }
});

실제 프로그램에서는 Callback 안에서 무거운 Business Logic을 오래 수행하기보다 Buffer나 Queue에 전달한 뒤 별도의 Worker에서 처리하는 구조를 고려할 수 있습니다.

10장 Buffer에서 Packet을 분리한다#

수신 Buffer:

02 10 03 41 42

까지 들어왔다고 하겠습니다.

아직 Frame이 완성되지 않았을 수 있습니다.

조금 뒤:

43 7F 03

이 들어옵니다.

전체:

02 10 03 41 42 43 7F 03

이 되면 Packet을 완성할 수 있습니다.

Packet Boundary를 찾는 방식은 Protocol마다 다릅니다.

Delimiter 방식#

STX
...
ETX

Length 방식#

Header
Length
Data
CRC

Fixed Length 방식#

항상 16 byte

Timing 방식#

일정 시간의 Silence를 Frame 경계로 사용하는 Protocol도 있습니다.

그래서 Serial 통신 Library와 Packet Parser는 분리하는 것이 좋습니다.

Serial Library
→ byte를 받는다.

Packet Parser
→ byte 의미를 해석한다.

11장 Hex Dump 로그는 장비 통신의 핵심 증거다#

다음 두 로그 중 어떤 것이 장애 분석에 더 유용할까요?

장비 응답 실패

보다:

2026-09-24 18:30:10.124
PORT=COM3
9600-8-N-1

TX
02 10 00 01 13 03

2026-09-24 18:30:10.217

RX
02 06 00 01 A4 03

가 훨씬 유용합니다.

장비 통신에서는 다음 정보를 함께 기록하는 것이 좋습니다.

Timestamp

Port

Baud Rate

TX / RX

Hex Dump

Packet Length

Timeout

Exception

Device ID

Correlation ID

간단한 Hex 변환 함수:

static String toHex(byte[] data, int length) {
    StringBuilder sb = new StringBuilder();

    for (int i = 0; i < length; i++) {
        sb.append(
            String.format("%02X ", data[i] & 0xFF)
        );
    }

    return sb.toString().trim();
}

사용:

System.out.println(
    "RX: " + toHex(data, read)
);

이런 Raw Data Log가 있어야:

송신 자체가 안 됐는가?

응답이 안 왔는가?

응답은 왔지만 Parser가 틀렸는가?

CRC가 틀렸는가?

를 구분할 수 있습니다.

12장 Port Open 실패도 여러 원인이 있다#

다음 Code가:

port.openPort()

실패했다고 해서 Serial Device가 고장났다고 단정하면 안 됩니다.

가능한 원인은 다양합니다.

다른 프로그램이 Port 사용 중

COM Port 이름 오류

USB-RS232 Driver 문제

운영체제 권한 문제

USB Adapter Disconnect

Library·Architecture 문제

특히 Windows에서는 Terminal Program이 COM Port를 이미 사용 중이면 Java Application이 열지 못할 수 있습니다.

Linux에서는 Device Permission도 확인해야 합니다.

예:

/dev/ttyUSB0

에 현재 사용자 계정이 접근할 권한이 있는지 확인해야 합니다.

따라서 Error Log에는:

Port Name

OS

Library Version

Open 결과

Exception

Device Description

등을 기록하는 것이 좋습니다.

13장 Reconnect를 무한 반복하면 안 된다#

USB-RS232 Adapter가 빠졌다고 하겠습니다.

프로그램이 즉시:

Reconnect
Reconnect
Reconnect
Reconnect
Reconnect

를 반복하면 CPU·Log·Device Driver에 불필요한 부하를 줄 수 있습니다.

좀 더 안정적인 구조는:

Connection Lost
↓
Port Close
↓
대기
↓
Port 다시 탐색
↓
Open
↓
통신 Parameter 재설정
↓
Health Check
↓
정상 상태 복귀

입니다.

필요하면:

1초
2초
4초
8초

처럼 Retry 간격을 늘리는 Backoff도 사용할 수 있습니다.

그리고 반드시:

Port 재연결 성공

과:

실제 Device Protocol 정상

을 구분해야 합니다.

Port가 열렸다고 Device가 정상 응답한다는 뜻은 아닙니다.

14장 RS-232 프로그램의 구조를 분리하면 유지보수가 쉬워진다#

처음에는 모든 코드를 하나의 Class에 넣기 쉽습니다.

Port Open
Send
Receive
Packet Parse
Business Logic
DB 저장

하지만 장비가 늘어나면 유지보수가 어렵습니다.

다음과 같이 분리하는 편이 좋습니다.

SerialPortManager
│
├─ Port 검색
├─ Open / Close
└─ Parameter 설정

SerialTransport
│
├─ Send
└─ Receive

ReceiveBuffer
│
└─ Raw Byte 누적

PacketParser
│
├─ Frame Boundary
├─ Length
└─ CRC

DeviceProtocol
│
├─ Command 생성
└─ Response 해석

DeviceService
│
└─ 실제 업무 처리

이렇게 하면 나중에:

RS-232
→ RS-485

Serial
→ TCP

로 Transport가 바뀌더라도 Protocol Layer를 재사용할 가능성이 높아집니다.

15장 간단한 통합 예제를 만들어보자#

다음은 Serial Port를 열고 Test Message를 보내고 응답을 읽는 흐름을 단순화한 예입니다.

import com.fazecast.jSerialComm.SerialPort;

import java.io.InputStream;
import java.io.OutputStream;

public class Rs232Example {

    public static void main(String[] args) {

        SerialPort port =
            SerialPort.getCommPort("COM3");

        port.setComPortParameters(
            9600,
            8,
            SerialPort.ONE_STOP_BIT,
            SerialPort.NO_PARITY
        );

        port.setComPortTimeouts(
            SerialPort.TIMEOUT_READ_SEMI_BLOCKING,
            1000,
            0
        );

        if (!port.openPort()) {
            System.err.println(
                "Failed to open serial port"
            );
            return;
        }

        try (
            InputStream in = port.getInputStream();
            OutputStream out = port.getOutputStream()
        ) {

            byte[] request = {
                0x02,
                0x10,
                0x00,
                0x03
            };

            out.write(request);
            out.flush();

            System.out.println(
                "TX: " + toHex(
                    request,
                    request.length
                )
            );

            byte[] buffer = new byte[256];

            int length = in.read(buffer);

            if (length > 0) {
                System.out.println(
                    "RX: " + toHex(
                        buffer,
                        length
                    )
                );
            }

        } catch (Exception e) {
            e.printStackTrace();

        } finally {
            port.closePort();
        }
    }

    private static String toHex(
        byte[] data,
        int length
    ) {
        StringBuilder sb =
            new StringBuilder();

        for (int i = 0; i < length; i++) {
            sb.append(
                String.format(
                    "%02X ",
                    data[i] & 0xFF
                )
            );
        }

        return sb.toString().trim();
    }
}

이 Code의 핵심은 특정 장비 Command가 아닙니다.

다음 흐름입니다.

COM Port 선택
↓
통신 Parameter 설정
↓
Port Open
↓
Request byte 생성
↓
전송
↓
응답 수신
↓
Hex Log
↓
Port Close

실제 Application에서는 여기에 Packet Parser와 비동기 처리, Retry와 상태 관리가 추가됩니다.

16장 장애를 만났을 때 어떤 순서로 볼까#

Java 프로그램이 장비와 통신하지 못한다고 하겠습니다.

다음 순서로 확인하면 좋습니다.

1단계 물리 연결#

Cable

TX / RX

GND

DTE / DCE

USB-RS232 Adapter

Device Power

2단계 Operating System#

COM Port 존재

Driver

Port Permission

다른 Process의 Port 점유

3단계 Serial 설정#

Baud Rate

Data Bits

Parity

Stop Bits

Flow Control

4단계 송신#

실제로 byte가 나갔는가?

TX Hex가 맞는가?

5단계 수신#

응답 byte가 들어오는가?

Read Timeout인가?

6단계 Protocol#

STX / ETX

Length

Address

Command

CRC / Checksum

이렇게 경계를 나누면:

통신이 안 됩니다.

라는 막연한 문제를 구체적인 원인으로 좁힐 수 있습니다.

17장 흔히 하는 잘못된 구현#

read() 한 번을 Packet 하나라고 생각한다#

Serial Port는 Application Packet 경계를 자동으로 보장하지 않습니다.

Buffer와 Parser가 필요합니다.

수신 byte를 바로 String으로 변환한다#

Binary Protocol이라면 데이터가 깨지거나 의미를 잃을 수 있습니다.

먼저 byte와 Hex 기준으로 확인합니다.

Timeout을 두지 않는다#

장비 응답이 없을 때 Thread가 오래 대기하거나 Application 흐름이 멈출 수 있습니다.

Exception을 무시한다#

현장 장애의 중요한 단서가 사라집니다.

Port를 닫지 않는다#

재실행 때 Port 점유 문제로 이어질 수 있습니다.

UI Thread에서 Serial Read를 직접 한다#

응답이 늦으면 UI가 멈출 수 있습니다.

Port Open 성공을 장비 정상으로 판단한다#

Port Open과 Device Protocol 응답은 서로 다른 상태입니다.

18장 자기 점검#

Java에서 Serial Port를 어떻게 찾을 수 있는가#

jSerialComm의 SerialPort.getCommPorts()를 이용해 현재 시스템에서 인식된 Serial Port 목록을 확인할 수 있습니다.

9600-8-N-1은 무엇을 의미하는가#

Baud Rate 9600, Data Bits 8, Parity None, Stop Bits 1을 의미합니다.

왜 read() 한 번으로 Packet 하나를 처리하면 안 되는가#

Serial Stream의 Read 단위와 Application Protocol의 Packet 경계가 일치한다는 보장이 없기 때문입니다.

Port는 열렸는데 장비 응답이 없다면 무엇을 확인해야 하는가#

TX·RX 결선, Baud·Parity·Stop Bit, Flow Control, 송신 Frame, Device Address와 Protocol 규격을 순서대로 확인해야 합니다.

Hex Dump를 남기는 이유는 무엇인가#

실제 송수신된 Raw Byte를 확인해 물리 통신 문제와 Protocol Parser 문제를 분리하기 위해서입니다.

19장 이 글을 마치며#

Java에서 RS-232 장비와 통신하는 기본 흐름은 다음과 같습니다.

COM Port 검색
↓
장비 Port 식별
↓
Baud·Data·Parity·Stop 설정
↓
Port Open
↓
byte 송신
↓
byte 수신
↓
Receive Buffer
↓
Packet Parser
↓
업무 처리

특히 다음 다섯 가지를 기억하면 됩니다.

RS-232 프로그램에서는 COM Port를 여는 것보다 장비와 동일한 Baud Rate·Data Bits·Parity·Stop Bits·Flow Control을 정확하게 맞추는 것이 먼저입니다.

장비 통신 데이터는 가능한 한 String보다 Raw byte 기준으로 확인하고 Hex Dump를 남겨야 장애 원인을 정확히 추적할 수 있습니다.

Serial read()의 반환 단위와 장비 Protocol의 Packet 경계는 다를 수 있으므로 Receive Buffer와 Packet Parser를 별도로 설계해야 합니다.

Port Open 성공·byte 수신 성공·Protocol 정상 응답·실제 업무 완료는 각각 다른 상태이므로 하나의 성공 여부로 합쳐 판단하면 안 됩니다.

Port 검색·Transport·Buffer·Packet Parser·Device Protocol·Business Logic을 분리해두면 장비 종류가 늘어나거나 RS-232에서 RS-485·TCP로 확장할 때 유지보수가 훨씬 쉬워집니다.

결국 Java로 RS-232 통신을 구현한다는 것은 Serial Port에 byte를 쓰고 읽는 것만을 의미하지 않습니다.

운영체제의 COM Port에서 시작해 실제 Wire를 지나 들어온 byte를 Buffer에 안전하게 모으고, 장비 Protocol의 Packet으로 복원한 뒤 Application이 신뢰할 수 있는 데이터로 전달하는 전체 통신 경로를 구현하는 것이 핵심입니다.

이 페이지의 목차