Embedded Blog — Embedded 공부 공간입니다.
'WiSoft' 카테고리의 글 목록
// list

WiSoft

소프트웨어 프레임워크란

Summary: 프레임워크는 특정 종류의 애플리케이션에 공통으로 쓰이는 구조와 실행 흐름을 미리 구현해 둔 골격이다. 개발자는 정해진 확장 지점에 필요한 동작을 추가하며, 전체 흐름은 프레임워크가 관리한다. 이 제어 방식은 구조까지 재사용하게 해 주지만 학습과 디버깅, 다른 시스템과의 통합 비용을 동반한다.

프레임워크의 정의

프레임워크는 특정 종류의 애플리케이션이나 하위 시스템을 만들기 위한 재사용 가능한 설계이자 부분 구현이다. 그 자체가 완성된 프로그램은 아니다. 개발자가 정해진 확장 지점에 필요한 동작을 채워 넣어야 하나의 애플리케이션이 되기 때문에 반완성 애플리케이션이라고 부른다.

 

재사용 범위는 함수나 클래스에서 끝나지 않는다. 컴포넌트가 협력하는 방식, 공통 처리 순서와 제어 흐름, 반복되는 정책도 프레임워크에 포함된다. 같은 종류의 애플리케이션에서 반복되는 부분은 프레임워크가 맡고, 프로젝트마다 달라지는 동작은 개발자가 인터페이스와 규약에 맞춰 구현한다.

 

프레임워크를 도입하면 코드뿐 아니라 프레임워크가 정한 구조와 실행 규칙도 함께 들어온다. 넓은 범위를 재사용하는 대신 애플리케이션 구조가 프레임워크의 규약에 영향을 받는 이유다.

 

프레임워크의 핵심 구성

구성 역할
안정된 골격 공통 처리 순서와 컴포넌트 협력 구조 정의
공통 구현·서비스 여러 애플리케이션에서 반복되는 기능 제공
확장 지점(Extension Point) 애플리케이션별 동작을 연결하는 훅, 콜백, 인터페이스
제어 메커니즘 생명주기, 이벤트 루프, 디스패처, 컨테이너 등을 통해 호출 시점 결정
애플리케이션별 코드 확장 지점의 계약에 맞춰 구체적인 동작 제공
설정·등록 정보 사용할 컴포넌트와 구현을 프레임워크에 연결

안정된 골격과 공통 구현은 프레임워크가 맡고, 애플리케이션마다 달라지는 동작은 개발자가 확장 지점에 제공한다. 이 확장 지점에는 호출 시점, 입력과 반환 값, 다른 컴포넌트와 협력하는 방법까지 규약으로 정해져 있다.

 

생명주기나 이벤트 루프, 디스패처, 컨테이너는 어느 시점에 어떤 코드를 호출할지 결정한다. 개발자가 만든 구현은 설정·등록·조합·상속 가운데 프레임워크가 요구하는 방식으로 이 흐름에 연결된다.

 

제어의 역전

제어의 역전(Inversion of Control, IoC)은 애플리케이션이 전체 실행 순서를 직접 주도하지 않는 구조를 말한다. 프레임워크가 흐름을 관리하다가 필요한 시점에 등록된 애플리케이션 코드를 호출한다.

일반적인 라이브러리 사용
애플리케이션의 메인 흐름 → 라이브러리 호출 → 결과 반환 → 애플리케이션 흐름 계속

프레임워크 사용
프레임워크가 생명주기 관리 → 이벤트·단계 감지 → 등록된 애플리케이션 코드 호출 → 프레임워크 흐름으로 복귀

프레임워크는 언제 어떤 단계를 실행할지 정하고, 애플리케이션 코드는 그 단계에서 수행할 일을 제공한다. GUI 프레임워크라면 메인 이벤트 루프는 프레임워크가 관리하고, 애플리케이션은 각 이벤트에 대응할 핸들러를 등록한다. 실제 이벤트가 발생했을 때 핸들러를 호출하는 쪽도 프레임워크다.

 

의존성 주입(Dependency Injection, DI)은 IoC와 관련되지만 같은 개념은 아니다. 클래스가 필요한 객체를 직접 만들거나 찾지 않고 외부에서 받도록 하는 것이 DI다. 이는 클래스와 의존성 사이에서 IoC를 구현하는 한 가지 방법이다. 프레임워크의 IoC는 더 넓게 실행 흐름, 이벤트 처리, 객체 생성과 연결까지 다룰 수 있다.

 

라이브러리와 프레임워크의 차이

구분 라이브러리(Library) 프레임워크(Framework)
주된 재사용 단위 독립 기능을 제공하는 함수·클래스·컴포넌트 애플리케이션 골격, 컴포넌트 관계, 공통 구현
전체 흐름의 주도권 애플리케이션 코드 프레임워크
대표 호출 방향 애플리케이션 → 라이브러리 프레임워크 → 애플리케이션 콜백·훅
사용 방식 필요한 기능을 애플리케이션이 선택해 직접 호출 정해진 확장 계약에 맞춘 코드를 등록·상속·구성
구조에 미치는 범위 호출한 기능 중심 애플리케이션 구조와 생명주기에 영향

라이브러리는 애플리케이션이 실행 흐름을 유지하면서 필요한 기능을 직접 호출한다. 프레임워크는 애플리케이션의 골격을 제공하고, 확장 규약에 맞는 코드를 받아 전체 실행을 조직한다. 코드 크기나 기능 수보다 제어권과 재사용 범위가 더 분명한 구분 기준이다.

 

그렇다고 모든 개별 호출이 한 방향으로만 흐르는 것은 아니다. 프레임워크도 애플리케이션이 직접 호출할 메서드를 제공하고, 내부에서 여러 라이브러리를 사용한다. 표에서 말하는 호출 방향은 전체 실행과 컴포넌트 관계를 어느 쪽이 조직하는지를 뜻한다.

 

프레임워크 동작 흐름

먼저 프레임워크가 제공하는 콜백, 훅 메서드, 인터페이스, 기반 클래스 가운데 어디를 확장해야 하는지 확인한다. 그 규약에 맞춰 애플리케이션 코드를 구현한 뒤 설정이나 등록, 객체 조합 또는 상속으로 프레임워크에 연결한다.

 

실행이 시작되면 프레임워크 런타임이 메인 흐름과 생명주기를 관리한다. 이벤트 루프나 디스패처, 컨테이너가 처리 시점을 판단해 연결된 핸들러·훅·컴포넌트를 호출한다. 해당 처리가 끝나면 제어는 다시 프레임워크로 돌아간다.

[애플리케이션 코드 구현·등록]
              ↓
[프레임워크 시작 및 공통 흐름 관리]
              ↓
[이벤트 또는 생명주기 단계 발생]
              ↓
[등록된 콜백·훅 호출]
              ↓
[프레임워크 공통 흐름으로 복귀]

애플리케이션 코드는 독립적인 메인 흐름을 갖기보다 프레임워크가 마련한 처리 단계에 참여한다. 무엇을 구현할지만큼 언제, 어떤 상태와 입력으로 호출되는지를 나타내는 확장 규약이 중요하다.

 

확장 방식: 화이트박스와 블랙박스

구분 화이트박스 프레임워크 블랙박스 프레임워크
주요 확장 방식 상속, 동적 바인딩, 훅 메서드 재정의 인터페이스·규약 구현, 객체 조합, 위임
필요한 이해 내부 구조와 상속 계층에 대한 지식 외부 인터페이스와 컴포넌트 계약 중심
결합 지점 프레임워크 기반 클래스의 내부 설계 교체 가능한 컴포넌트 인터페이스
사용 측면 내부 구현 의존과 학습 부담 증가 가능 일반적으로 사용·확장하기 쉬움
설계 측면 상속과 재정의를 통해 확장 골격 제공 다양한 사용 사례를 수용할 인터페이스·훅의 선행 설계 필요

화이트박스 프레임워크는 기반 클래스를 상속하고 정해진 메서드를 재정의해 동작을 바꾼다. 어느 메서드를 어떤 규칙으로 재정의할지 판단하려면 상속 계층과 내부 구조를 알아야 한다. 그만큼 사용 코드가 프레임워크 내부 설계에 강하게 결합될 수 있다.

 

블랙박스 프레임워크는 정해진 인터페이스를 구현한 컴포넌트를 조합하거나 위임하는 방식으로 확장한다. 내부 구현보다 외부 인터페이스와 컴포넌트 간 계약을 알면 되므로 일반적으로 사용하기 쉽다. 대신 프레임워크를 설계하는 쪽에서는 여러 사용 사례의 변화 지점을 미리 찾아 인터페이스와 훅으로 만들어야 한다.

 

두 방식의 경계가 항상 선명한 것은 아니다. 하나의 프레임워크 안에서도 어떤 부분은 상속으로, 다른 부분은 컴포넌트 조합으로 확장할 수 있다.

 

장점

변하기 쉬운 구현을 안정된 인터페이스 뒤로 숨기면 변경의 영향을 해당 컴포넌트와 확장 지점 주변으로 제한할 수 있다. 프레임워크가 제공하는 모듈성은 이 경계에서 나온다.

 

재사용 범위도 개별 함수나 컴포넌트보다 넓다. 이들을 연결하는 애플리케이션 구조와 도메인 설계까지 다시 쓸 수 있다. 훅·콜백·인터페이스는 공통 골격을 바꾸지 않고 애플리케이션별 동작을 넣을 자리를 제공한다.

 

생명주기와 외부 이벤트의 전달은 프레임워크가 맡고, 애플리케이션 코드는 구체적인 처리에 집중한다. 여러 애플리케이션이 같은 처리 순서를 공유할 수 있는 이유다.

 

비용과 제약

프레임워크를 사용하려면 확장 지점뿐 아니라 생명주기, 컴포넌트 관계와 호출 규약까지 익혀야 한다. 라이브러리의 기능 하나를 호출할 때보다 이해해야 할 범위가 넓다. 어느 정도 학습하고 직접 적용해 보기 전에는 현재 문제에 맞는지 판단하기 어려울 수도 있다.

 

제어가 프레임워크와 애플리케이션 콜백 사이를 오가기 때문에 실행 경로가 코드에 명확하게 드러나지 않을 수 있다. 디버거로 흐름을 따라가면 프레임워크 내부와 애플리케이션 코드를 계속 넘나들게 되고, 결함이 어느 쪽에서 발생했는지 구분하기도 어렵다. 범용 컴포넌트와 애플리케이션별 확장을 결합한 상태로 검증해야 한다는 문제도 남는다.

 

서로 다른 이벤트 루프나 전달 방식, 컴포넌트 규약을 가진 프레임워크끼리는 통합하기 어렵다. 프레임워크의 요구사항이나 인터페이스가 바뀌면 그 확장 규약을 따르는 애플리케이션도 수정해야 한다. 공통 구조를 넓게 재사용하는 만큼 특정 프레임워크 구조에 대한 종속성도 커진다.

 

선택 기준

확인 항목 판단 질문
문제 영역 프레임워크의 공통 골격이 현재 애플리케이션 종류·도메인과 일치하는가
확장 지점 필요한 변화를 훅·콜백·인터페이스로 표현할 수 있는가
제어 흐름 프레임워크의 생명주기·이벤트 처리 방식이 애플리케이션 요구와 맞는가
통합성 기존 라이브러리·컴포넌트·다른 프레임워크의 이벤트 처리 방식과 결합할 수 있는가
학습 투자 규약과 내부 동작을 익히는 비용을 여러 기능·프로젝트에서 회수할 수 있는가
변경 대응 프레임워크의 버전·인터페이스 변화에 맞춰 애플리케이션을 유지할 수 있는가
검증·디버깅 프레임워크와 애플리케이션 콜백을 함께 추적할 문서·테스트·진단 수단이 있는가

기능 목록만 비교해서는 프레임워크의 적합성을 판단하기 어렵다. 현재 문제와 공통 골격이 맞는지, 필요한 변화를 제공된 확장 지점으로 표현할 수 있는지, 프레임워크의 생명주기와 이벤트 처리 방식이 요구사항과 충돌하지 않는지 확인해야 한다. 기존 라이브러리나 다른 프레임워크를 함께 사용한다면 제어 메커니즘과 컴포넌트 규약도 비교 대상이다.

 

학습과 유지 비용은 재사용 범위와 함께 봐야 한다. 독립된 기능 몇 개만 필요하고 실행 흐름을 직접 관리해야 한다면 라이브러리가 더 적합할 수 있다. 반대로 여러 기능이나 프로젝트에서 같은 구조와 생명주기가 반복된다면 프레임워크의 골격을 재사용할 여지가 있다. 버전 변화에 대응할 수 있는지, 콜백 흐름을 추적할 문서와 테스트·진단 수단이 있는지도 도입 전에 확인할 항목이다.

노트북과 Raspberry Pi의 TCP Client-Server 구성

Summary: 노트북을 Client, Raspberry Pi를 Server로 두고 같은 LAN에서 TCP 소켓으로 통신하는 구성을 정리한다. Raspberry Pi의 IP를 확인하는 방법, 서버가 어느 주소에 bind()할지의 선택, 클라이언트가 접속에 사용하는 정보, 요청-응답 데이터 교환 순서, 그리고 정상·실패 상태를 점검하는 명령과 판단 기준을 다룬다.

구성 전제

이 구성에서 클라이언트는 서버에 연결을 요청하고 데이터를 보내거나 응답을 받는 노트북 프로그램이고, 서버는 정해진 IP와 Port에서 연결을 기다리고 요청을 처리하는 Raspberry Pi 프로그램이다. 역할이 장치에 고정되어 있다는 점을 전제로 삼는다.

 

네트워크 조건은 두 가지다. 노트북과 Raspberry Pi가 같은 LAN에 연결되어 있어야 하고, 두 장치 사이의 통신이 차단되지 않은 상태여야 한다. 통신 방식은 TCP Socket을 쓴다. 소켓과 Client-Server 구조 자체의 개념은 Socket의 개념과 Client-Server 구조에서 다루므로, 이 글은 그 구조를 두 실제 장치에 올리는 문제에 집중한다. 이후 서술에서 Raspberry Pi의 LAN 주소는 <PI_IP>, 서버 Port는 <PORT>로 표기한다.

노트북(Client) ── 같은 Wi-Fi·Ethernet LAN ── Raspberry Pi(Server)
                 <PI_IP>:<PORT>

 

Raspberry Pi의 IP 주소 확인

클라이언트가 접속하려면 먼저 서버인 Raspberry Pi의 주소를 알아야 한다. 가장 간단한 방법은 Raspberry Pi 터미널에서 로컬 IP를 확인하는 것이다.

hostname -I

출력은 Raspberry Pi에 할당된 로컬 IP 주소 목록이다. 여기서 실제로 쓸 주소는 노트북과 같은 LAN에 연결된 wlan0 또는 eth0의 주소이며, 127.0.0.1처럼 Raspberry Pi 자신만 가리키는 Loopback 주소는 제외해야 한다. 이 구분을 놓치면 뒤의 bind 주소 선택에서 그대로 문제로 이어진다.

 

인터페이스별로 IPv4 주소를 나눠 보려면 다음 명령을 쓴다.

ip -4 address show

NetworkManager를 쓰는 환경이라면 nmcli로도 확인할 수 있다.

nmcli device show

이때 Wi-Fi를 쓰면 GENERAL.TYPE이 wifi인 블록의 IP4.ADDRESS를, Ethernet을 쓰면 ethernet인 블록의 IP4.ADDRESS를 본다. 주의할 점은 DHCP 환경에서는 재연결이나 재부팅 후 주소가 달라질 수 있다는 것이다. 따라서 접속 직전에 주소를 다시 확인하는 편이 안전하다. Raspberry Pi OS처럼 mDNS를 쓸 수 있는 환경에서는 IP 대신 raspberrypi.local 또는 변경한 <hostname>.local로도 주소를 확인할 수 있다.

ping raspberrypi.local

 

Server가 연결을 기다리는 IP와 Port

bind() 주소는 서버가 어느 로컬 네트워크 인터페이스와 Port로 들어오는 연결을 받을지 결정하는 값이다. 어떤 주소에 묶느냐에 따라 대기 범위와 외부 접속 가능 여부가 함께 달라진다.

Server의 Bind 주소 대기 범위 노트북에서 접속 가능 여부
0.0.0.0:<PORT> (INADDR_ANY) Raspberry Pi의 모든 로컬 IPv4 인터페이스 Raspberry Pi의 LAN IP로 접속 가능
<PI_IP>:<PORT> 지정한 LAN 인터페이스 해당 IP로 접속 가능
127.0.0.1:<PORT> Raspberry Pi 내부 Loopback 외부 노트북에서 접속 불가

실습용 서버는 대개 INADDR_ANY와 미리 정한 <PORT>에 bind()한 뒤 listen()한다. 모든 인터페이스에서 연결을 받으므로 어느 LAN IP로 들어와도 대응할 수 있기 때문이다. 반대로 127.0.0.1에 묶으면 Loopback으로만 받으므로 외부 노트북에서는 접속이 되지 않는데, 이 조합은 뒤의 실패 상태에서 자주 등장하는 원인이다.

 

여기서 구분해야 할 것은 서버가 대기하는 주소와 클라이언트가 목적지로 쓰는 주소다. 서버가 0.0.0.0에 묶더라도 클라이언트가 목적지로 삼아야 하는 값은 0.0.0.0이 아니라 실제 <PI_IP>:<PORT>다. 0.0.0.0은 "모든 인터페이스에서 받겠다"는 서버 측 의미일 뿐, 접속 대상 주소가 아니다. Port도 마찬가지로 서버가 bind()한 Port와 클라이언트가 connect()할 Port가 같아야 한다. 참고로 Linux에서 1024 미만 Port는 대개 권한이 필요하므로, 실습용 Port는 다른 서비스와 충돌하지 않는 번호로 고른다.

Server bind 주소: 0.0.0.0:<PORT>
Client 목적지:    <PI_IP>:<PORT>

 

Client가 Server 접속에 사용하는 정보

클라이언트가 서버에 붙을 때 필요한 정보는 네 가지이며, 모두 서버 쪽 설정과 짝이 맞아야 한다.

정보 Client에서의 사용
Server IP 또는 해석 가능한 호스트명 Raspberry Pi 위치 지정
Server Port Raspberry Pi 안의 Server 프로그램 지정
프로토콜 Server와 같은 TCP 사용
주소 체계 Server 주소와 같은 IPv4 또는 IPv6 사용

주소를 실제 접속에 쓰는 형태로 바꾸는 경로는 입력이 무엇이냐에 따라 갈린다. IPv4 문자열을 가지고 있으면 inet_pton()으로 주소 구조에 변환하고, 호스트명이거나 IPv4·IPv6를 함께 다뤄야 하면 getaddrinfo()로 접속 후보 주소를 얻는다. 어느 경로든 준비된 서버 소켓 주소를 connect()에 전달하면서 접속이 시작된다. 여기 등장하는 inet_pton()·getaddrinfo()·connect()를 비롯한 소켓 함수들을 C++에서 호출하는 방식은 C++ TCP Socket Programming에서 정리했다.

 

Client와 Server의 데이터 교환 순서

요청-응답 방식의 기본 순서는 다음과 같다.

  1. Raspberry Pi에서 Server 실행
  2. Server가 <PORT>에 bind()하고 listen()으로 연결 대기
  3. 노트북에서 Client 실행
  4. Client가 <PI_IP>:<PORT>로 connect() 호출
  5. TCP 3-way handshake 완료
  6. Server의 accept()가 클라이언트 전용 연결 소켓 반환
  7. Client가 요청 데이터 send()
  8. Server가 요청 데이터 recv() 후 처리
  9. Server가 응답 데이터 send()
  10. Client가 응답 데이터 recv()
  11. 통신 완료 후 각 연결 소켓 close()
노트북 Client                                Raspberry Pi Server
     |                                              |
     |                       bind() → listen() → accept() 대기
     |                                              |
     | -------- connect(<PI_IP>, <PORT>) -------->  |
     | <---------- TCP 연결 성립 -----------------  |
     |                                              |
     | ------------- 요청 데이터 ---------------->  | recv()·처리
     | <------------ 응답 데이터 -----------------  | send()
     |                                              |
     | ---------------- close() ----------------->  | close()

순서 자체는 요청 다음에 응답이 오는 형태지만, 이 순서가 TCP의 성질로 강제되는 것은 아니라는 점을 구분해야 한다. TCP는 연결이 성립하면 양쪽 모두 송신·수신이 가능하고, 실제 어느 쪽이 언제 보낼지는 클라이언트와 서버가 합의한 응용 계층 프로토콜이 정한다. 위의 요청-응답 순서도 그런 합의의 한 예다.

 

또 하나 유의할 것은 TCP의 데이터 단위다. TCP는 메시지가 아니라 바이트 스트림을 다루므로, 한 번의 send()와 한 번의 recv()가 일대일로 대응하지 않을 수 있다. 이 성질은 아래 확인 방법과 실패 상태 양쪽에서 다시 문제로 나타난다.

 

정상 통신 확인 방법

통신이 되는지 확인하는 작업은 주소 → 도달 가능성 → 서버 대기 → Port 접속 → 연결 상태 → 프로그램 동작 → 패킷 흐름 순으로 층을 좁혀 가며 진행할 수 있다.

확인 단계 명령·방법 정상 판단
Raspberry Pi 주소 hostname -I 사용할 <PI_IP> 확인
IP 도달 가능성 노트북에서 ping <PI_IP> 응답 수신, 단 ICMP 차단 시 통신 가능해도 실패 가능
Server 대기 상태 Raspberry Pi에서 ss -ltnp <PORT>가 LISTEN 상태
TCP Port 접속 노트북에서 nc -vz <PI_IP> <PORT> TCP 연결 성공, 응용 데이터 처리까지 보장하지는 않음
연결 상태 양쪽에서 ss -tnp 연결 중 ESTAB 상태 확인
프로그램 동작 accept, send, recv 반환값과 로그 확인 연결 수락, 양수 바이트 수, 예상 요청·응답 일치
패킷 흐름 Raspberry Pi에서 sudo tcpdump -i any tcp port <PORT> handshake와 양방향 TCP 패킷 확인

각 단계가 보장하는 범위가 서로 다르다는 점에 주의해야 한다. ping 성공은 IP 도달 가능성만 말해 주며, ICMP가 차단된 환경에서는 통신이 되는데도 ping이 실패할 수 있다. nc -vz로 TCP 연결이 성공해도 그것은 Port가 열려 있다는 뜻일 뿐 응용 데이터가 정상 처리된다는 보장은 아니다. 그래서 하위 계층 점검만으로는 부족하고, 응용 프로그램 기준의 확인이 따로 필요하다.

 

응용 프로그램 관점에서 정상 통신은 다음 다섯 조건으로 정리된다.

  1. Client의 connect() 성공
  2. Server의 accept() 성공
  3. 송신 측 send()가 양수의 처리 바이트 수 반환
  4. 수신 측 recv()가 양수의 수신 바이트 수 반환
  5. 수신한 실제 데이터가 약속한 요청·응답 형식과 일치

마지막 조건이 특히 중요하다. 앞의 네 조건이 모두 참이어도 데이터가 약속한 형식과 어긋나면 정상 통신으로 볼 수 없기 때문이다. 반환값 점검과 형식 점검은 별개의 문제다.

 

주요 실패 상태 확인

실패는 증상별로 먼저 확인할 항목이 다르다.

현상 우선 확인 항목
Connection refused Server 실행 여부, listen() 상태, Port 일치 여부
연결 시간 초과 <PI_IP> 정확성, 같은 LAN 여부, 방화벽·무선 AP의 장치 간 통신 차단
Raspberry Pi 내부에서만 접속 가능 Server가 127.0.0.1에만 bind()했는지 확인
Address already in use 같은 IP·Port를 사용 중인 다른 소켓 확인
데이터 일부만 수신 send()·recv() 반환 길이 처리, 메시지 경계 규칙 확인

증상을 층위로 나눠 보면 원인의 위치가 드러난다. Connection refused는 목적지에 도달은 했지만 그 Port에서 받아 줄 서버가 없다는 신호이므로 서버 실행과 Port 일치를 먼저 본다. 연결 시간 초과는 응답 자체가 돌아오지 않는 경우로, 주소가 틀렸거나 같은 LAN이 아니거나 중간에서 통신이 차단된 상황을 의심한다. "내부에서만 접속 가능"은 앞서 다룬 127.0.0.1 bind 문제가 실제 증상으로 나타난 형태다. 마지막의 "데이터 일부만 수신"은 TCP가 바이트 스트림이라는 성질에서 비롯되므로, 개별 명령이 아니라 send()·recv() 반환 길이 처리와 메시지 경계 규칙을 점검해야 한다.

C++ TCP Socket Programming

Summary: C++17 프로그램에서 Linux/POSIX TCP 소켓 API를 호출하는 방식을 정리한다. 소켓이 C++ 전용 클래스가 아니라 운영체제의 함수·구조체이고 핸들이 int 파일 디스크립터라는 전제에서 출발해, 주요 헤더와 자료형, 각 소켓 함수의 사용 주체와 역할, 서버·클라이언트의 동작 순서, 그리고 바이트 스트림 송수신에서 주의할 점을 다룬다.

조사 기준

여기서 다루는 구현 기준은 C++17 프로그램에서 Linux/POSIX TCP 소켓 API를 호출하는 방식이며, 대상 환경은 Raspberry Pi OS·Ubuntu 등 Linux다. 전제부터 분명히 하면, C++ 코드에서 쓰는 소켓은 C++ 전용 Socket 클래스가 아니라 운영체제가 제공하는 소켓 함수와 구조체다. 그래서 이후에 다루는 함수와 자료형도 언어 라이브러리가 아니라 시스템 콜과 그 주변의 것들이다.

 

소켓 핸들은 Linux/POSIX에서 int형 파일 디스크립터로 표현된다. 이 점이 소켓을 파일과 같은 방식으로 다루게 만드는 근거다. TCP/IPv4를 쓰는 기본 조합은 AF_INET, SOCK_STREAM, 그리고 프로토콜 인자로 0 또는 IPPROTO_TCP다.

 

주요 헤더와 자료형

필요한 선언은 여러 헤더에 나뉘어 있고, 헤더마다 담당하는 영역이 다르다.

헤더 주요 항목 역할
<sys/socket.h> socket, bind, listen, accept, connect, send, recv 소켓 생성·연결·송수신
<netinet/in.h> sockaddr_in, INADDR_ANY, IPPROTO_TCP IPv4 주소 구조와 상수
<arpa/inet.h> inet_pton, htons, htonl 주소 변환과 네트워크 바이트 순서 변환
<netdb.h> getaddrinfo, freeaddrinfo 호스트명·서비스명을 접속 가능한 주소 구조로 변환
<unistd.h> close 소켓 파일 디스크립터 닫기

자료형 쪽도 각각 다른 대상을 표현한다.

자료형 의미
int 소켓 파일 디스크립터
sockaddr 여러 주소 체계를 함수에 전달하기 위한 공통 주소 구조
sockaddr_in IPv4 주소와 Port를 저장하는 구조
socklen_t 소켓 주소 구조의 크기
ssize_t send, recv가 처리한 바이트 수 또는 오류 값

sockaddr과 sockaddr_in의 관계를 눈여겨볼 만하다. 실제 주소를 담는 것은 IPv4 전용인 sockaddr_in이지만, 소켓 함수들은 주소 체계가 다른 경우까지 받기 위해 공통 구조인 sockaddr을 인자로 요구한다. ssize_t가 "처리한 바이트 수 또는 오류 값"을 함께 표현한다는 점도 뒤의 송수신 처리에서 그대로 쓰인다.

 

주요 TCP Socket 함수

함수는 사용 주체(서버·클라이언트)와 역할, 그리고 성공 시 결과로 구분해서 보면 정리가 된다.

함수 사용 주체 역할 주요 결과
socket() Server·Client 통신 끝점 생성 성공 시 소켓 파일 디스크립터
setsockopt() 주로 Server 소켓 동작 옵션 설정 SO_REUSEADDR 등 설정
bind() 주로 Server 소켓에 로컬 IP와 Port 할당 성공 시 주소 연결
listen() Server 연결 대기 소켓으로 전환, 대기 큐 크기 지정 성공 시 수동 소켓
accept() Server 대기 중인 연결 하나 수락 성공 시 새 연결 소켓
getaddrinfo() Server·Client 호스트·서비스 정보를 주소 구조로 변환 IPv4·IPv6 후보 주소 목록
inet_pton() Server·Client 문자열 IP를 네트워크 주소 형식으로 변환 IPv4·IPv6 숫자 주소 변환
connect() Client 지정한 Server 주소로 연결 시작 성공 시 연결 상태
send() Server·Client 연결된 소켓에 바이트 전송 실제 전송 처리한 바이트 수
recv() Server·Client 연결된 소켓에서 바이트 수신 수신 바이트 수, 0이면 상대의 정상 종료
close() Server·Client 파일 디스크립터 해제 해당 소켓 사용 종료

bind(), listen(), accept()가 서버 쪽에 몰려 있고 connect()만 클라이언트 전용인 점은, 연결을 받아 두고 기다리는 쪽과 먼저 연결을 여는 쪽의 역할 차이가 함수 수준에서 드러난 것이다. 이 역할 차이를 수동 개방(Passive Open)·능동 개방(Active Open)으로 정리한 개념적 배경은 Socket의 개념과 Client-Server 구조에서 다룬다.

 

오류를 알리는 방식은 함수 계열에 따라 다르므로 섞어 쓰지 않도록 주의해야 한다. 대부분의 소켓 함수는 실패 시 -1을 반환하고 원인을 errno에 담지만, getaddrinfo()는 0이 아닌 오류 코드를 직접 반환하며 그 설명은 gai_strerror()로 얻는다. 즉 getaddrinfo()의 결과를 errno로 판정하려 하면 어긋난다.

 

값을 저장할 때의 변환도 정해져 있다. Port는 htons(<PORT>)로 호스트 바이트 순서를 네트워크 바이트 순서로 바꿔 저장하고, IPv4 주소는 inet_pton(AF_INET, <문자열_IP>, &주소.sin_addr)로 변환하거나 getaddrinfo()로 얻는다.

 

Server 프로그램의 동작 순서

서버는 소켓을 만들고 주소에 묶은 뒤 대기 상태로 전환하고, 연결을 수락해 데이터를 주고받은 다음 소켓을 닫는 순서로 진행된다.

  1. 소켓 생성 — socket(AF_INET, SOCK_STREAM, 0)
  2. 소켓 옵션 설정 — 필요 시 setsockopt()로 주소 재사용 등 설정
  3. 로컬 주소 구성 — sockaddr_in에 AF_INET, 대기 IP, htons(<PORT>) 저장
  4. 로컬 주소 할당 — bind()
  5. 연결 대기 시작 — listen()
  6. Client 연결 수락 — accept()가 새 연결 소켓 반환
  7. 데이터 수신·처리 — 연결 소켓에 recv() 사용
  8. 응답 전송 — 같은 연결 소켓에 send() 사용
  9. Client 연결 종료 — 연결 소켓 close()
  10. Server 종료 — 연결 대기 소켓 close()
socket() → setsockopt() → bind() → listen()
                                      ↓
                                  accept()
                                      ↓
                            recv() ↔ send()
                                      ↓
                           close(연결 소켓)
                                      ↓
                           close(대기 소켓)

이 순서에서 소켓이 두 개로 갈라진다는 점을 다시 확인하게 된다. accept()는 기본적으로 연결 요청이 없으면 대기하다가, 요청이 들어오면 선택된 클라이언트와 통신할 새 연결 소켓을 반환한다. 원래의 대기 소켓은 사라지지 않고 다음 클라이언트 연결을 계속 수락하는 데 쓰인다. 그래서 여러 클라이언트를 처리하려면 accept()를 반복 호출하면서 클라이언트별 연결 소켓을 따로 관리해야 한다. 9단계와 10단계에서 연결 소켓과 대기 소켓을 각각 닫는 것도 이 둘이 서로 다른 소켓이기 때문이다.

 

Client 프로그램의 동작 순서

클라이언트는 서버 주소를 준비해 변환하고, 소켓을 만들어 연결을 요청한 뒤 데이터를 주고받고 정리하는 순서로 진행된다.

  1. Server 주소 준비 — Server IP·호스트명과 Port 확인
  2. 주소 변환 — getaddrinfo() 또는 inet_pton() 사용
  3. 소켓 생성 — 주소 후보에 맞춰 socket() 호출
  4. Server 연결 요청 — Server 주소를 connect()에 전달
  5. 데이터 전송 — 연결 소켓에 send() 사용
  6. 응답 수신 — 같은 소켓에 recv() 사용
  7. 연결 종료 — close()
  8. 주소 목록 해제 — getaddrinfo() 사용 시 freeaddrinfo()
getaddrinfo() 또는 inet_pton()
              ↓
          socket()
              ↓
          connect()
              ↓
       send() ↔ recv()
              ↓
           close()

서버 순서와 비교하면 bind()·listen()·accept()가 없고 connect()가 그 자리를 대신한다. 서버는 자신의 주소에 소켓을 묶고 기다리는 반면, 클라이언트는 상대 주소를 만들어 그쪽으로 연결을 여는 것이 이 대비의 핵심이다. 3단계에서 소켓 생성을 "주소 후보에 맞춰" 한다고 한 것은 getaddrinfo()가 여러 후보 주소를 돌려줄 수 있기 때문이며, 그래서 마지막 8단계에서 freeaddrinfo()로 그 목록을 해제하는 정리가 따라온다.

 

TCP 송수신 시 주의점

TCP 바이트 스트림은 송신 순서를 보존하지만 응용 프로그램의 메시지 단위는 보존하지 않는 연속 바이트 흐름이다. 앞선 개념 단계에서 미뤄 둔 "메시지 경계 미보존"이 실제 송수신 코드에서 어떻게 문제로 나타나는지가 여기서 구체화된다.

  • send()는 요청한 전체 바이트보다 적은 수를 처리할 수 있다. 따라서 반환값만큼만 전송된 것으로 보고 나머지를 누적해서 마저 보내야 한다.
  • recv()는 요청한 크기가 다 찰 때까지 기다리지 않고, 현재 사용 가능한 일부 데이터만 반환할 수 있다.
  • recv()의 반환값은 세 가지로 해석된다. 양수는 수신 바이트 수, 0은 상대가 정상적으로 연결을 종료했다는 신호, -1은 오류다.
  • 메시지를 구분하려면 소켓 계층이 아니라 응용 계층에서 규칙을 정해야 한다. 고정 길이, 길이 헤더, 구분자 같은 방식이 그 예다.
  • Blocking Socket의 기본 동작상 accept()와 recv()는 처리할 연결이나 데이터가 없으면 대기한다.

send()가 부분 전송을, recv()가 부분 수신을 할 수 있다는 두 성질을 합치면, "한 번 보낸 것이 한 번에 그대로 도착한다"는 가정은 성립하지 않는다는 결론이 나온다. 메시지 경계를 응용 계층에서 직접 정의해야 하는 이유가 바로 이것이다. recv()가 0을 반환하는 경우를 오류로 오해하기 쉬운데, 이는 오류가 아니라 상대의 정상 종료라는 점도 구분해 두어야 한다. 이 송수신 규칙을 노트북·Raspberry Pi 구성에서 반환값과 명령으로 점검하는 방법은 노트북과 Raspberry Pi의 TCP Client-Server 구성에서 다룬다.

Socket의 개념과 Client-Server 구조

Summary: 소켓은 프로세스가 네트워크로 데이터를 주고받기 위해 사용하는 통신 끝점이며, 운영체제가 관리하고 Linux/POSIX에서는 파일 디스크립터로 접근한다. 이 글은 소켓과 TCP 스트림 소켓의 정의, IP 주소와 Port 번호가 각각 식별하는 대상, 서버와 클라이언트의 역할 비대칭, 그리고 서버 대기부터 클라이언트 접속·데이터 교환까지의 흐름을 정리한다.

Socket의 개념

소켓은 프로세스가 네트워크로 데이터를 송수신할 때 사용하는 통신 끝점(endpoint)이다. 실제 통신은 운영체제의 네트워크 스택이 담당하고, 응용 프로그램은 그 스택에 직접 손대는 대신 소켓이라는 추상화를 거친다. 소켓을 운영체제가 관리한다는 점과, Linux/POSIX에서 소켓이 파일 디스크립터로 접근된다는 점은 이 추상화의 성격을 그대로 보여준다. 응용 프로그램은 파일을 다룰 때와 같은 파일 디스크립터로 네트워크 입출력을 다루게 된다.

 

소켓의 역할은 네 가지로 나뉜다. 응용 프로그램과 운영체제 네트워크 스택을 연결하고, 상대 소켓과 연결을 설정하며, 데이터를 송수신하고, 마지막으로 연결을 종료한다. 이 네 역할은 뒤에서 다룰 접속 흐름의 각 단계와 거의 일대일로 대응한다.

 

소켓을 생성할 때는 세 가지 정보를 지정한다.

지정 정보 구분하는 것
주소 체계 AF_INET, AF_INET6 IPv4 / IPv6
통신 방식 SOCK_STREAM, SOCK_DGRAM 스트림 / 데이터그램
프로토콜 통신 방식에 대응하는 프로토콜 실제 사용할 프로토콜

이 중 SOCK_STREAM을 고르면 TCP 스트림 소켓이 된다. TCP 스트림 소켓은 연결된 두 끝점 사이에서 전이중(full-duplex) 바이트 스트림을 제공한다. 특성을 정리하면 연결이 성립한 뒤에야 송수신이 가능하고, 양방향 통신이 되며, 보낸 바이트의 순서가 보존된다. 여기서 주의할 것은 마지막 특성이다. 바이트 순서는 유지되지만 응용 프로그램이 구분한 메시지 경계는 보존되지 않는다. 뒤에서 "한 번 보낸 것이 한 번에 도착한다"는 가정이 깨지는 원인이 바로 이 성질이므로, 개념 단계에서 미리 짚어 둔다.

 

소켓 주소는 IP 주소와 Port 번호의 조합으로 정의된다.

소켓 주소 = IP 주소 + Port 번호

 

IP 주소와 Port 번호

IP 주소는 네트워크에서 통신 대상의 인터페이스를 식별하고 패킷이 전달될 위치를 정하는 주소다. Port 번호는 한 호스트 안에서 TCP·UDP 통신을 받을 서비스 또는 프로세스의 통신 끝점을 구분하는 16비트 번호다. 두 값은 서로 다른 층위를 식별한다.

구분 식별 대상 TCP 연결에서의 역할
IP 주소 네트워크 인터페이스 데이터가 도착할 호스트·인터페이스 지정
Port 번호 호스트 내부의 통신 끝점 도착한 데이터를 받을 서비스 구분

두 값이 서로를 대체할 수 없다는 점은 각각을 단독으로 가졌을 때를 따져 보면 분명해진다. IP 주소만 알면 어느 호스트로 보낼지는 정할 수 있지만 그 호스트 안의 어느 서비스가 받을지는 정할 수 없고, 반대로 Port 번호만 알면 서비스는 알아도 대상 호스트를 특정할 수 없다. 그래서 하나의 TCP 연결은 로컬 IP, 로컬 Port, 원격 IP, 원격 Port라는 네 값의 조합으로 식별된다. 소켓 주소가 IP와 Port의 쌍이었던 이유가 여기서 드러난다. 연결은 그 쌍 두 개, 즉 로컬 끝점과 원격 끝점으로 규정된다.

 

Server와 Client의 역할

서버는 미리 정한 로컬 IP와 Port에 소켓을 연결하고 클라이언트의 연결 요청을 기다리는 프로그램이고, 클라이언트는 서버의 IP와 Port를 지정해 연결을 먼저 요청하는 프로그램이다. 둘의 차이는 "누가 먼저 연결을 여는가"로 요약된다.

구분 Server Client
연결 방식 수동 개방(Passive Open) 능동 개방(Active Open)
필요한 주소 자신의 로컬 IP와 Port Server의 원격 IP와 Port
핵심 동작 연결 대기·수락, 요청 처리, 응답 전송 연결 요청, 데이터 전송, 응답 수신
대표 소켓 연결 대기 소켓, 연결된 소켓 연결된 소켓

주소를 다루는 방식의 비대칭이 특히 눈에 띈다. 서버는 자신의 로컬 주소를 알아야 하고, 클라이언트는 상대인 서버의 주소를 알아야 한다. 이 비대칭은 Port 할당에서도 이어진다. 서버 Port는 클라이언트가 접속하기 전에 미리 알고 있어야 하는 고정된 번호인 반면, 클라이언트 Port는 보통 운영체제가 사용 가능한 임시 Port를 자동으로 할당한다. 클라이언트 쪽 Port를 따로 명시하지 않아도 통신이 되는 이유가 여기에 있다.

 

대표 소켓 항목에서 서버만 소켓을 두 종류 가진다는 점도 짚어 둘 만하다. 다음 절에서 이 구분이 흐름의 핵심으로 다시 등장한다.

 

Server 대기부터 Client 접속까지의 흐름

전체 과정은 아홉 단계로 정리된다.

  1. Server 소켓 생성 — TCP/IPv4를 쓰면 AF_INET, SOCK_STREAM 지정
  2. Server 주소 지정 — 로컬 IP와 Port를 소켓에 연결
  3. Server 대기 상태 전환 — 들어오는 연결 요청을 받을 수 있는 수동 소켓으로 전환
  4. Client 소켓 생성 — 서버와 같은 주소 체계·통신 방식 사용
  5. Client 연결 요청 — 서버 IP와 Port를 목적지로 지정
  6. TCP 연결 설정 — 운영체제 TCP 스택이 SYN → SYN-ACK → ACK의 3-way handshake 수행
  7. Server 연결 수락 — 대기 중인 요청을 꺼내 클라이언트 전용 연결 소켓을 만들고, 기존 대기 소켓은 다음 연결을 계속 기다림
  8. 데이터 송수신 — 연결 소켓으로 바이트 스트림 교환
  9. 연결 종료 — 사용을 마친 각 연결 소켓을 닫음

시간 축으로 놓으면 다음과 같이 진행된다.

Server                                           Client
소켓 생성                                         소켓 생성
   ↓                                                 ↓
로컬 IP·Port 연결                                   Server IP·Port 지정
   ↓                                                 ↓
연결 대기                 ←──── 연결 요청 ────       접속 시도
   ↓                    TCP 3-way handshake           ↓
연결 수락                 ───── 연결 성립 ────→       연결 완료
   ↓                                                 ↓
데이터 수신·응답          ←──── 데이터 ────→          데이터 송신·수신
   ↓                                                 ↓
연결 소켓 닫기                                      소켓 닫기

이 흐름에서 가장 오해하기 쉬운 지점은 7단계다. 연결 대기 소켓과 실제 데이터 통신 소켓은 서로 다른 소켓이다. 대기 소켓은 새로운 클라이언트 연결을 수락하는 데만 쓰이고, accept()가 반환한 연결 소켓이 해당 클라이언트와의 데이터 송수신을 담당한다. 두 소켓을 하나로 뭉뚱그리면 "왜 accept 이후에도 서버가 다른 연결을 계속 받을 수 있는가"를 설명하기 어려워진다. 대기 소켓이 그대로 남아 다음 요청을 기다리기 때문에 가능한 동작이다. 앞 절에서 서버만 소켓을 두 종류 가진다고 했던 대목이 여기서 구체화된다.

 

이 흐름을 이루는 socket()·listen()·accept()·connect()·send()·recv()를 C++ 코드에서 실제로 호출하는 순서는 C++ TCP Socket Programming에서 다룬다. 같은 구조를 노트북(Client)과 Raspberry Pi(Server)에 배치해 동작을 점검하는 과정은 노트북과 Raspberry Pi의 TCP Client-Server 구성으로 이어진다.

Raspberry Pi에 Ubuntu Server 설치와 네트워크 설정

Summary: Raspberry Pi Imager로 Raspberry Pi 4에 Ubuntu Server를 설치하는 과정과, Raspberry Pi OS와 달리 mDNS가 없어 호스트명.local 접속이 되지 않는 문제를 다룬다. 해결 경로로 Netplan(YAML)을 이용한 네트워크 설정과 ip 명령을 통한 인터페이스·주소·라우팅 확인/수정을 정리하고, 필요 시 avahi를 설치해 mDNS를 복원하는 방법까지 이어진다.

Ubuntu Linux CLI 설치

설치는 Raspberry Pi Imager로 진행하며, 대상 보드는 Raspberry Pi 4다. Imager에서 디바이스와 OS, 저장 장치를 차례로 지정하는데, Ubuntu Server 이미지는 최상위 목록이 아니라 "Other general-purpose OS → Ubuntu → Ubuntu Server" 경로 안에 있다. 데스크탑 환경이 아닌 CLI 전용 서버 이미지를 선택한다는 점이 이후 모든 설정의 전제가 된다.

 

주목할 부분은 이미지를 굽기 전 커스터마이즈 단계다. hostname(home), 지역·시간·키보드 레이아웃, 사용자 이름·비밀번호, Wi-Fi(핫스팟), 그리고 SSH 사용 여부(비밀번호 인증)를 이 시점에 미리 지정한다. 이는 모니터·키보드 없이 SSH로만 접근하는 헤드리스 운용을 전제로 한 구성이다. 즉 부팅 후 처음부터 네트워크와 SSH가 열려 있어야 하므로, Wi-Fi 자격 증명과 SSH 활성화를 이미지에 미리 심는 것이 사실상 필수다. 마지막으로 USB에 기존 데이터가 있는지 확인한 뒤 쓰기를 진행하고, 완료되면 부팅 매체가 준비된다.

여기서 미리 설정한 hostname·SSH·Wi-Fi가 바로 다음 절의 접속 문제와 직접 연결된다.

Ubuntu Linux CLI 설치 실제 진행

1. Imager 실행

2. 디바이스 = Raspberry Pi 4 선택

3. OS = Other general-purpose OS → Ubuntu → Ubuntu Server 선택

4. 저장 장치(USB) 선택

5. hostname 설정 (home)

6. 지역·시간·키보드 레이아웃 설정

7. 사용자 이름·비밀번호 설정

8. Wi-Fi 설정 (핫스팟)

9. SSH 사용 설정 => 비밀번호 인증 방식

10. 최종 설정 확인 후 진행

11. USB 기존 저장 내용 확인 후 진행

12. 쓰기 진행

13. 완료

 

SSH 연결하기

mDNS(Multicast DNS)는 별도의 DNS 서버 없이 로컬 네트워크에서 호스트명.local 형태로 장치를 찾는 프로토콜(zeroconf)이다.

 

Raspberry Pi OS는 이 mDNS를 기본으로 지원하므로 ping 호스트명.local로 IP를 확인하거나 ssh 사용자명@호스트명.local로 곧바로 접속할 수 있었다.

 

문제는 Ubuntu Server에는 mDNS가 기본 탑재되지 않는다는 점이다. 따라서 Raspberry Pi OS에서 익숙하던 .local 접속이 그대로 통하지 않는다. 결국 접속 경로는 세 갈래로 갈린다.

flowchart LR
    A[Ubuntu Server 설치] --> B{mDNS 없음}
    B --> C[ip 명령으로 IP 직접 확인]
    B --> D[Netplan으로 네트워크 설정]
    B --> E[avahi 설치로 mDNS 복원]

아래 절들은 이 갈래를 각각 다룬다. Netplan은 네트워크 자체를 어떻게 구성하느냐의 문제이고, ip는 현재 상태를 진단·수정하는 도구이며, avahi 설치는 애초의 .local 편의를 되살리는 선택지다.

Netplan

Netplan은 우분투 17.10부터 네트워크 설정을 관리하기 위해 도입된 도구로, 기존의 /etc/network/interfaces 방식을 대체한다. 우분투 18.04 LTS부터는 네트워크 설정을 /etc/netplan/*.yaml 파일로 관리한다.

 

 

Netplan은 실제 설정을 직접 적용하지 않고 renderer라는 백엔드에 위임한다. renderer는 두 종류로 나뉜다.

  1. NetworkManager: 데스크탑 환경용. 이 값을 renderer로 두면 네트워크 인터페이스는 NetworkManager GUI를 통해 관리되며, Netplan 파일을 직접 편집하기보다 GUI로 설정한다.
  2. networkd: 주로 서버에서 사용되는 renderer.

이 구분은 앞 절의 선택과 맞물린다. GUI가 없는 Ubuntu Server에서는 NetworkManager의 GUI 경로를 쓸 수 없으므로 networkd 기반으로 YAML을 직접 작성하게 된다. 그리고 YAML은 들여쓰기로 구조를 표현하는 형식이므로, 들여쓰기가 어긋나면 그대로 오류가 된다. 

 

설정을 적용하는 명령은 상황에 따라 셋 중 하나를 택한다.

  • sudo netplan apply — 즉시·영구 적용하며 롤백이 없다.
  • sudo netplan try — 임시로 적용한 뒤 사용자가 확인하지 않으면 기본 120초 후 자동으로 이전 설정으로 되돌린다.
  • sudo netplan generate — 적용 없이 문법만 검증한다.

try의 자동 롤백은 헤드리스·원격 환경에서 특히 의미가 있다. SSH로만 접근하는 서버에서 잘못된 네트워크 설정을 apply로 굳히면 접속 자체가 끊겨 스스로를 잠가 버릴 수 있는데, try는 확인이 없으면 원상 복구되므로 이 위험을 줄인다.

Netplan 설정 파일 구조

설정 파일의 최상위 노드는 network이며, 그 아래에 version: 2(Curtin에서 사용하는 YAML 버전)와 renderer가 정의된다. renderer 값에 따라 ethernets:, wifis:, bridges: 같은 디바이스 정의 그룹이 추가되고, 각 디바이스 블록은 map 형태로 세부 항목을 기술한다. 즉 "network → (version, renderer) → 디바이스 그룹 → 개별 인터페이스"로 내려가는 계층 구조다.

 

우분투 18.04 네트워크 설정

아래는 18.04 기준으로 /etc/netplan의 설정을 작성하는 대표적인 패턴들이다.

설정 파일

데스크탑 버전을 설치한 경우 /etc/netplan의 기본 설정은 대개 다음과 같다.

ls /etc/netplan/*.yaml
# Let NetworkManager manage all devices on this system
network:
  version: 2
  renderer: NetworkManager

renderer가 NetworkManager로 지정되어 있으므로, 이 경우 네트워크 설정은 GUI를 통해 정의하는 것이 전제다. 서버 이미지에서 YAML을 직접 손대는 흐름과 대비되는 지점이다.

이더넷 DHCP 설정

DHCP 방식은 주소를 라우터로부터 자동 할당받는다.

network:
 version: 2
 renderer: networkd
 ethernets:
   wlan0:
     dhcp4: yes
     dhcp6: yes

dhcp4/dhcp6를 켜면 IPv4·IPv6 주소를 각각 자동으로 받고, sudo netplan apply로 적용한다. 다만 이 예시에는 유의할 불일치가 있다. 인터페이스 이름 wlan0은 통상 무선 인터페이스를 가리키는데 정작 ethernets:(유선) 그룹 아래에 정의되어 있다. 무선을 설정하려면 아래 "무선랜 설정"처럼 wifis: 그룹을 사용해야 하므로, 이 블록은 그룹과 인터페이스 종류가 어긋난 표기로 읽어야 한다.

이더넷 고정 IP 설정

고정 IP는 주소·게이트웨이·네임서버를 직접 지정한다.

network:
  version: 2
  renderer: networkd
  ethernets:
    wlan0:
      addresses:
        - 192.168.0.100/24
      gateway4: 192.168.0.1
      nameservers:
          addresses: [168.126.63.1,8.8.8.8]

여기서는 고정 IP로 192.168.0.100/24, 게이트웨이로 192.168.0.1, 네임서버로 168.126.63.1과 8.8.8.8을 지정한다. 한 가지 버전 의존적 주의점은 gateway4다. Ubuntu 22.04의 Netplan부터 gateway4(및 gateway6)는 deprecated 되어 경고를 출력하며, 기본 경로를 routes로 명시하는 방식이 권장된다. 구 문법도 당분간은 동작하지만, 대체 형태는 다음과 같다.

      routes:
        - to: default
          via: 192.168.0.1

이 변경은 게이트웨이를 특별 키로 두는 대신 일반적인 라우팅 규칙(기본 경로)으로 통일하려는 방향으로 이해할 수 있다.

무선랜 설정 (이걸 많이 사용)

무선은 wifis: 그룹 아래 접속할 AP 정보를 기술한다.

network:
  version: 2
  renderer: networkd
  wifis:
    wlan0:
      dhcp4: yes
      optional: true
      access-points:
        "network_ssid_name":
          password: "**********"

주소는 DHCP로 할당받고, access-points 아래에 SSID와 패스워드를 넣어 접속 대상 AP를 지정한다. 헤드리스 Raspberry Pi는 유선을 항상 끌어오기 어려운 만큼 실제로 이 무선 설정을 가장 많이 쓰게 된다. 앞의 DHCP 예시와 비교하면, 같은 wlan0이라도 무선은 ethernets:가 아니라 wifis: 그룹에 두어야 한다는 점이 분명해진다.

IP 확인 및 수정

네트워크 상태의 확인과 수정은 ip 명령으로 한다. ip는 다음과 같이 대상(OBJECT)과 동작(COMMAND)을 조합하는 구조다.

ip [ OPTIONS ] OBJECT { COMMAND | help }

주요 OBJECT는 다음과 같다.

OBJECT 의미

link 모든 네트워크 인터페이스의 상태 관리·출력
address IP 주소와 그 특징 정보 출력
route 라우팅 테이블 변경·출력
maddr 멀티캐스트 IP 주소 관리·출력
neigh 이웃 객체 출력, ARP 테이블 출력

이 대상들에 확인(show)이나 추가/삭제(add/del), 활성화/비활성화(up/down) 같은 동작을 붙여 사용한다. 아래 하위 항목들이 그 조합이다.

address Object로 ip 세부사항 확인하기

address 대상으로 IP 세부사항을 조회한다. 다음은 모두 같은 동작의 축약형이다.

ip a
ip addr
ip address
ip address show
ip a show

mDNS가 없는 Ubuntu Server에서 장치의 IP를 확인하는 1차 수단이 이 명령이다.

address로 ip 주소 추가하고 삭제하기

같은 address 대상에 add/del을 붙여 주소를 직접 추가·삭제할 수 있다.

ip addr add 123.123.5.10/24 dev eth0
$ ip addr del 123.123.5.10/24 dev eth0

dev <인터페이스>로 대상 인터페이스를 지정한다. 다만 ip 명령으로 붙인 주소는 런타임 설정이므로, 영구적으로 유지하려면 앞의 Netplan 설정으로 기술하는 것이 정공법이다.

link 명령어로 세부사항 확인하기

link 대상은 인터페이스 자체의 상태를 다룬다. 확인은 다음 축약형들로 한다.

ip l
ip li
ip lin
ip link
ip link show

link로 인터페이스 활성화 / 비활성화 시키기

link set에 up/down을 주어 인터페이스를 켜고 끈다.

ip link set eth0 down
ip link set eth0 up

route로 라우팅 정보를 출력하기

route 대상으로 라우팅 테이블을 확인한다.

ip route show

앞서 Netplan에서 지정한 기본 경로(게이트웨이)가 실제로 반영됐는지 확인하는 지점이기도 하다.

maddr 이용해서 ip 주소 출력하기

maddr 대상으로 관리 가능한 멀티캐스트 주소를 모두 출력한다.

ip maddr show 

maddr로 멀티캐스트 주소 추가하고 삭제하기

멀티캐스트 주소도 add/del로 관리한다.

ip maddr add 33:33:00:00:00:01 dev eth0
ip maddr del 33:33:00:00:00:01 dev eth0

 

기존의 HostName.local을 사용하고 싶다면?

마지막 선택지는 문제의 출발점으로 돌아간다. .local 접속의 편의 자체를 되살리려면 Ubuntu Server에 mDNS를 직접 설치하면 된다. avahi-daemon과 libnss-mdns가 mDNS/zeroconf를 통해 .local 이름 해석을 제공한다.

sudo apt update
sudo apt install -y avahi-daemon libnss-mdns
sudo systemctl enable --now avahi-daemon

설치 후 서비스를 활성화하면 ping 호스트명.local로 응답을 확인할 수 있고, ssh 사용자명@호스트명.local로 IP를 몰라도 곧바로 접속할 수 있다. 결국 이 절은 Raspberry Pi OS가 기본 제공하던 mDNS를 Ubuntu Server에 수동으로 얹어, 앞에서 끊겼던 .local 접속 흐름을 원래대로 복원하는 과정이다.

UART와 RS-232·RS-422·RS-485의 동작과 차이

Summary: UART는 데이터를 직렬 프레임으로 만들고 복원하는 로직 기능이고, RS-232·RS-422·RS-485는 그 프레임이 케이블을 지날 때의 전기적 신호 규격이라 서로 다른 계층에 속한다. 이 글은 single-ended와 차동 전송의 차이, point-to-point·multipoint 연결 구조, RS-485의 송수신 방향 제어와 종단·배선 규칙, 속도와 거리의 트레이드오프를 정리하고, 산업·임베디드 현장에서의 선택 기준을 다룬다.

UART와 RS 계열의 계층 차이

 

UART와 RS-232·RS-422·RS-485를 같은 것으로 묶는 오해가 잦지만, 둘은 애초에 다루는 계층이 다르다. UART(Universal Asynchronous Receiver/Transmitter)는 비동기 직렬 통신에서 데이터를 직렬 비트열로 만들고 복원하는 하드웨어 기능이다. baud rate, 데이터 비트 수, 패리티, 정지 비트, Start/Stop bit 같은 프레임 형식과 타이밍을 담당한다. 말하자면 UART가 정하는 것은 "비트를 어떤 순서와 속도로 보낼 것인가"까지다.

 

반면 RS-232·RS-422·RS-485는 그렇게 만들어진 데이터가 실제 케이블을 통해 이동할 때 쓰는 전기적 신호 규격이다. 전압 기준, 배선 수, 차동 신호 여부, 연결 가능한 장비 수, 종단 저항, 통신 방향 제어 방식이 모두 이 계층에서 갈린다. 두 계층을 위에서부터 쌓아 보면 다음과 같다.

[응용 프로그램]
예: 센서 값 읽기, 모터 속도 변경, PLC 제어
      ↓
[통신 프로토콜]
예: Modbus RTU, 자체 명령 프레임, CRC, 주소 규칙
      ↓
[UART]
예: 115200 bps, 8N1
Start bit + Data bits + Parity(optional) + Stop bit
      ↓
[트랜시버]
RS-232: MAX3232
RS-422: RS-422 Driver / Receiver
RS-485: RS-485 Transceiver
      ↓
[케이블 및 장비]
PC, PLC, 인버터, 센서, 모터 드라이브, 계측기

 

이 구분은 실무에서 곧바로 드러난다. 전압 레벨부터 서로 맞지 않기 때문이다. ESP32의 UART 핀은 3.3 V TTL/CMOS 수준이라 산업용 RS-485 장비에 그대로 연결할 수 없다. 사이에는 로직 신호(GND와 하나의 신호선을 비교해 0과 1을 판단하는 방식)를 차동 신호(두 개의 선으로 위상이 반대인 신호를 보내고, 그 '전압 차이'로 데이터를 인식)로 바꾸고 전압 레벨을 맞추는 트랜시버(데이터를 송수신하는 통신 부품)가 반드시 들어간다.

ESP32 UART TX/RX
      ↓
RS-485 Transceiver
      ↓
A/B Differential Bus
      ↓
PLC / Sensor / Motor Driver

 

데이터의 "의미"는 또 한 단계 위의 이야기다. RS 계열은 전압과 배선만 규정할 뿐, 바이트 구조나 주소, CRC, 응답 방식은 정의하지 않는다. 예컨대 "모터를 50% 속도로 회전"이라는 명령을 어떤 바이트 배열로 표현하고 어떻게 검증·응답할지는 Modbus RTU나 자체 프로토콜의 몫이다. 세 계층의 역할을 나눠 보면 이렇다.

RS-485 = 전기적 버스 규격
UART   = 직렬 프레임 처리
Modbus = 주소·명령·CRC·응답 규칙

 

RS-232란?

RS-232는 가장 오래되고 널리 알려진 직렬 통신 인터페이스로, 구형 COM 포트, 산업용 계측기, CNC 장비, 설정 콘솔, 의료·네트워크 장비의 콘솔 포트에서 흔히 쓰인다. 전기적으로는 single-ended 방식이어서, 각 신호선의 전압을 공통 GND를 기준으로 판별한다. 연결은 기본적으로 한 장비와 한 장비를 잇는 point-to-point 구조이며, TX와 RX 선이 분리되어 있으므로 별도 방향 전환 없이 full-duplex가 가능하다.

장비 A                         장비 B
TX  ──────────────────────── RX
RX  ──────────────────────── TX
GND ──────────────────────── GND

RS-232의 전압 특성

RS-232에서 가장 먼저 걸리는 부분이 전압 규격이다. TTL/CMOS UART와 아예 다르다. ESP32의 UART는 다음과 같은 정논리 신호를 쓴다.

TTL / CMOS UART
Logic 1 = 3.3 V
Logic 0 = 0 V

 

RS-232는 극성과 전압 범위가 모두 다르다.

RS-232 — 송신부와 수신부의 규격이 다르다

[송신부(드라이버) 출력]  ※ 부하 연결 시
  Logic 0 (space) = +5 V ~ +15 V
  Logic 1 (mark)  = -5 V ~ -15 V

[수신부(리시버) 인식 임계]
  Logic 0 (space) = +3 V ~ +15 V
  Logic 1 (mark)  = -3 V ~ -15 V
  -3 V ~ +3 V     = 정의되지 않은 전이 구간

 

논리값과 전압의 대응이 UART TTL과 반대이고 전압 범위도 훨씬 넓다. 따라서 ESP32의 UART 핀을 RS-232 포트에 직접 연결하면 안 되고, MAX3232 같은 레벨 변환기를 거쳐야 한다. 수신기는 +3 V 이상과 -3 V 이하를 각각 논리 상태로 판별하며, 그 사이 구간은 정의되지 않는다. 케이블 부하 용량은 최대 2500 pF로 규정되는데, 이 용량 한계가 사실상 거리를 제약하기 때문에 일반 케이블에서는 대략 15~20 m 수준에서 쓰이는 경우가 많다.

RS-232의 장점

  • 구조가 단순하고, TX·RX·GND만으로 기본 통신이 된다.
  • 별도 방향 제어가 없어 full-duplex가 기본이다.
  • 설정 콘솔·디버그·구형 산업 장비와의 호환성이 높고, RS-485보다 구현과 디버깅이 단순하다.

RS-232의 한계

장점의 단순함은 그대로 한계로 이어진다. GND를 기준으로 단일 선의 절대 전압을 보는 single-ended 방식이라 노이즈에 취약하고, 케이블이 길어질수록 안정성이 떨어진다. 여러 장비를 하나의 버스에 묶는 구조에는 맞지 않으며, 모터·인버터·릴레이·고전류 전원선이 가까운 산업 현장에서는 통신 오류 가능성이 커진다.

RS-232 실습 배선

ESP32와 Mac을 RS-232로 잇는다면 레벨 변환기와 어댑터를 거치는 다음 구성이 필요하다.

ESP32 ── MAX3232 ── USB-RS-232 Adapter ── Mac

 

MAX3232는 3.0~5.5 V 전원에서 동작하는 RS-232 송수신기 계열이라 ESP32 같은 3.3 V 로직 MCU와 연결하기에 적합하다. 장비끼리 연결할 때는 역할에 따라 TX/RX를 교차해야 한다.

장비 A TX → 장비 B RX
장비 A RX ← 장비 B TX
장비 A GND ─ 장비 B GND

 

DB9 커넥터를 쓰는 장비는 DTE/DCE 조합에 따라 straight cable이 필요할 수도, null modem cable이 필요할 수도 있다. 커넥터 모양만 보고 연결하지 말고 매뉴얼에서 각 핀의 TX/RX 역할을 먼저 확인하는 편이 안전하다.

 

RS-422란?

RS-422는 RS-232의 노이즈 취약성을 차동 신호로 해결한 인터페이스다. RS-232가 GND 기준의 절대 전압을 본다면, RS-422 수신기는 두 선의 전압 차이를 본다.

RS-422 송신기                  RS-422 수신기
TX+ ───────────────────────── RX+
TX- ───────────────────────── RX-
Vdiff = V(TX+) - V(TX-)

 

차동 전송이 노이즈에 강한 건 외부 노이즈가 두 선에 거의 같은 크기로 함께 실리기 때문이다. 두 선의 절대 전압은 함께 흔들려도 그 차이는 보존되므로, 차이만 보는 수신기는 공통 노이즈의 영향을 크게 받지 않는다. 이때 수신기는 두 선의 차동 전압(V_A − V_B)이 +200 mV 이상이면 한쪽 논리, −200 mV 이하이면 반대 논리로 판별하고, 그 사이(±200 mV)는 정의되지 않은 구간으로 둔다. 단, 이 판별은 수신기의 공통모드 허용범위(약 −7 V ~ +12 V) 안에서만 보장된다.

아래 예에서 두 선에 각각 1 V의 노이즈가 더해져도 차이는 2 V로 유지된다. 이 2 V는 판별 임계 ±200 mV의 약 10배이므로, 노이즈가 실려도 0/1 판별에는 전혀 문제가 없다. 아래 예에서 두 선에 각각 1 V의 노이즈가 더해져도 차이는 2 V로 유지된다.

정상 상태
TX+ = 3 V
TX- = 1 V
차이 = 2 V

같은 크기의 외부 노이즈 유입
TX+ = 4 V
TX- = 2 V
차이 = 2 V

 

연결 구조 면에서 RS-422는 기본적으로 driver 하나에 receiver 여러 개를 붙이는 형태다. 송신기 하나에 규격 수신기 최대 10개를 연결할 수 있다.

RS-422 단방향 Multi-drop 구조
Driver ───── Receiver 1
       ├──── Receiver 2
       ├──── Receiver 3
       └──── Receiver 4

 

다만 이 multi-drop을 여러 장비가 한 선로를 번갈아 송신하는 다중 송신 버스로 오해하면 안 된다. RS-422는 한 선로에 driver가 하나라는 전제 위에 서 있어 충돌 보호 장치가 없고, 그래서 driver를 여러 개 붙이는 용도에는 맞지 않는다. 이 점이 곧 보게 될 RS-485와의 결정적 차이다.

RS-422의 Full-duplex 구성

 

RS-422로 양방향 통신을 하려면 송신용 차동쌍과 수신용 차동쌍을 따로 둔다. 그래서 보통의 full-duplex RS-422는 총 4선을 쓴다.

RS-422의 특징

항목 내용
신호 방식 Differential / Balanced
기본 연결 1 Driver → 여러 Receiver
통신 방향 한 쌍은 단방향
Full-duplex 송신·수신 차동쌍을 분리한 4선 구성
노이즈 내성 높음
종단 저항 일반적으로 수신 측 끝단에 1개
대표 용도 산업용 카메라, 엔코더, 모터 드라이브, 장거리 전용 링크

 

종단 저항도 RS-485와 다르다. RS-422는 driver가 하나뿐이므로 종단을 마지막 receiver 끝단에 한 개만 두고, 케이블의 특성 임피던스에 맞춘다. 모든 노드에 종단을 넣는 RS-485식 구성이 아니다.

 

RS-485란?

RS-485는 RS-422와 같은 차동 신호를 쓰되, 여러 장비가 하나의 버스를 공유하는 multipoint 구조를 지원한다. 바로 이 점 때문에 산업 현장에서 RS-485가 그렇게 자주 보인다.

Master ───── Node 1 ───── Node 2 ───── Node 3
       A/B Differential Bus

 

PLC, 인버터, 온습도 센서, 전력 계측기, 모터 드라이브, 산업용 제어기에서 자주 쓰이며, 특히 Modbus RTU의 물리 계층으로 흔히 채택된다. 대신 여러 driver가 한 버스에 붙는 만큼, 동시 송신은 반드시 막아야 한다. 둘 이상이 같이 송신하면 버스 경합이 일어나 프레임이 깨진다.

Node 1: 송신
Node 2: 송신
동시 송신
→ Bus Contention
→ 데이터 충돌
→ 프레임 손상

 

버스에 붙일 수 있는 부하도 정해져 있다. 기본 규격은 최대 32 unit load이지만, 최신 트랜시버는 1/2·1/4·1/8 UL처럼 더 작은 부하를 제시하므로 실제 연결 가능한 노드 수는 그만큼 늘어날 수 있다.

RS-485 2선식 Half-duplex

현장에서 가장 많이 쓰는 방식이다. 송신과 수신이 같은 차동쌍을 공유하므로, 한 순간에는 송신과 수신 중 하나만 가능하다.

A ───────────────────────────────── A
B ───────────────────────────────── B

 

선을 공유하니 송신과 수신을 그때그때 전환해 줘야 한다. 이 전환은 트랜시버의 driver enable과 receiver enable로 제어한다.

송신 모드
Driver Enable = HIGH
Receiver Enable = Disable

수신 모드
Driver Enable = LOW
Receiver Enable = Enable

 

트랜시버 핀은 보통 다음과 같이 구성된다.

역할
DI MCU TX 입력
RO MCU RX 출력
DE Driver Enable
/RE Receiver Enable, Active Low
A, B RS-485 차동 버스

 

여기서 /RE가 active low라는 점을 이용하면 배선이 단순해진다. DE는 HIGH에서, /RE는 LOW에서 활성화되므로, 두 핀을 하나의 GPIO에 묶으면 GPIO 한 개로 송수신 방향을 깔끔하게 토글할 수 있다.

GPIO LOW
DE  = LOW
/RE = LOW
→ 송신 Driver 비활성, 수신 활성

GPIO HIGH
DE  = HIGH
/RE = HIGH
→ 송신 Driver 활성, 수신 비활성

RS-485 4선식 Full-duplex

송신과 수신을 별도 차동쌍으로 분리하면 RS-485도 full-duplex로 구성할 수 있다.

TX+ / TX- : 송신용 차동쌍
RX+ / RX- : 수신용 차동쌍

 

다만 현장에서는 배선 수와 비용을 줄이려고 2선식 half-duplex를 더 많이 쓴다.

RS-485의 종단 저항

RS-485는 긴 케이블, 빠른 통신 속도, 다수 노드 환경에서 신호 반사가 생긴다. 이를 줄이기 위해 종단 저항을 버스의 물리적 양 끝단에만 둔다.

120 Ω                         120 Ω
[Master] ─ Node 1 ─ Node 2 ─ [Node 3]

 

120 Ω을 쓰는 건 일반적인 트위스티드 페어 RS-485 케이블의 특성 임피던스가 약 120 Ω이라서다. 종단을 임피던스에 맞춰야 반사가 억제된다. 반대로 모든 노드에 120 Ω을 넣으면 버스 부하가 과도해져 오히려 신호가 약해진다. 그래서 종단은 끝단에만, 그것도 임피던스에 맞춰 둔다.

RS-485 배선 구조

같은 이유에서 배선 구조도 신호 반사에 민감하다. 권장 구조는 노드를 일렬로 잇는 daisy-chain이다.

권장
Master ─── Node 1 ─── Node 2 ─── Node 3

 

반면 분기점에서 여러 갈래로 뻗는 star 구조는 분기마다 반사가 생겨 장거리·고속 환경에서 불리하다.

비권장
             Node 2
               │
Master ────────┼──────── Node 3
               │
             Node 4

 

종단 저항이든 배선 구조든 노리는 것은 똑같다. 신호 반사를 줄여 통신을 안정시키는 일이다.

RS-485와 Modbus RTU

RS-485는 전기적 인터페이스일 뿐이라, 여러 장비가 한 버스를 공유할 때 "누가 언제 송신할지"를 정하는 상위 프로토콜이 필요하다. 앞서 본 '동시 송신 금지'를 실제로 지키게 만드는 규칙이 바로 이 계층에 있다. Modbus RTU는 master가 요청하면 해당 slave만 응답하는 방식으로 그 문제를 푼다.

Master → Slave 1 요청
Slave 1 → Master 응답
Master → Slave 2 요청
Slave 2 → Master 응답

 

slave는 master의 요청 없이 임의로 송신하지 않는다. Modbus serial line은 master-slave 구조를 쓰며 slave 간 직접 통신은 기본 동작이 아니다. RS-485의 물리적 제약(한 번에 한 driver)과 Modbus의 논리적 규칙(요청-응답)이 맞물려야 버스가 충돌 없이 동작한다.

 

RS-232·RS-422·RS-485 차이

구분 RS-232 RS-422 RS-485
신호 방식 Single-ended Differential Differential
기준 전압 GND 기준 두 선의 전압 차 두 선의 전압 차
기본 연결 구조 Point-to-Point 1 Driver → 다수 Receiver 다수 장비 Bus
송신기 수 보통 1개 한 선로당 1개 여러 Driver 가능
수신기 수 보통 1개 기본 10 Unit Load 기본 32 Unit Load
일반적 배선 TX, RX, GND 송신 1쌍 + 수신 1쌍 A/B 2선 또는 4선
일반적인 Duplex Full-duplex 4선 구성 시 Full-duplex 2선 Half-duplex, 4선 Full-duplex
노이즈 내성 낮음 높음 높음
연결 거리 짧음 (대략 15~20 m) 저속 시 약 1200 m, 고속(~10 Mbps) 시 약 15 m 저속 시 약 1200 m, 고속(~10 Mbps) 시 약 15 m
다중 장비 연결 부적합 수신기 다중 연결 가능 적합
종단 저항 일반적으로 없음 수신 끝단 중심 버스 양 끝단
대표 용도 콘솔, 계측기, 구형 장비 전용 장거리 링크, 엔코더 PLC, 센서, 인버터, Modbus RTU

 

세 규격을 용도로 좁히면, RS-232는 짧은 거리의 점대점 연결, RS-422는 한 송신기에서 여러 수신기로 보내는 전용 차동 링크, RS-485는 여러 장비가 하나의 버스를 공유해야 하는 산업 자동화 환경에 각각 맞는다. RS-422와 RS-485는 둘 다 차동 전송이라 긴 거리와 노이즈에 강하지만, 가장 중요한 차이는 버스 구조와 다중 송신기 지원 여부에 있다.

 

거리 수치는 오해하기 쉽다. 차동 전송인 RS-422·RS-485라도 거리와 속도를 동시에 최대로 낼 수는 없다. 약 90 kbps 이하의 저속에서는 약 1200 m까지 유지되지만, 데이터율이 올라갈수록 허용 거리가 줄어 약 10 Mbps에서는 약 15 m 수준으로 짧아진다. 따라서 "RS-485는 1200 m, 10 Mbps"라는 식의 단일 수치는 성립하지 않으며, 실제 값은 케이블 품질·굵기·트랜시버에 따라 달라진다.

 

산업용 장비·임베디드 현장 선택 기준

상황 적합한 방식
ESP32 디버그 콘솔 TTL UART 또는 USB-UART
구형 계측기와 PC 연결 RS-232
MCU와 장비 간 전용 장거리 Full-duplex 링크 RS-422
PLC, 센서, 인버터를 여러 개 연결 RS-485
Modbus RTU 장비 통신 RS-485 2선 Half-duplex
모터 주변 노이즈가 큰 환경 RS-485 또는 절연형 RS-485
서로 다른 전원 시스템 간 장거리 통신 절연형 RS-485 검토
다수 센서 주소 기반 제어 RS-485 + 주소 기반 프로토콜

 

노이즈 내성과 절연을 같은 것으로 보는 혼동도 흔하다. RS-485는 차동 신호 덕분에 노이즈에 강하지만, 그 자체가 전기적 절연을 제공하지는 않는다. 장비 간 GND 전위 차가 크거나 모터·인버터·고전압 설비가 가까운 환경에서는 일반 RS-485 모듈만으로 부족할 수 있고, 이때는 절연형 RS-485 트랜시버와 절연 전원을 함께 검토해야 한다.

채터링, 디바운싱, 엣지 검출과 Pull-up·하드웨어 회로

Summary: 기계식 버튼은 접점이 붙고 떨어지는 순간 채터링이 발생해 한 번의 누름이 여러 입력으로 잡힌다. 이 글은 디바운싱과 엣지 검출을 함께 적용해 누름당 한 번만 처리하는 원리, 평상시 입력값을 정의하는 Pull-up과 전환 순간의 흔들림을 거르는 디바운싱이 서로 다른 문제라는 점, 그리고 소프트웨어 방식과 하드웨어(RC 필터·슈미트 트리거) 방식의 구현과 트레이드오프를 정리한다.

디바운싱으로 버튼 입력을 한 번만 처리하는 방법

 

기계식 버튼의 접점은 누르거나 떼는 순간 단번에 안정된 상태로 넘어가지 않는다. 접점이 물리적으로 붙었다 떨어지기를 짧게 반복하면서, GPIO 입력값은 수 밀리초 동안 HIGH와 LOW 사이를 오간다. 이 현상이 채터링(Chattering, Bounce)이다.

 

문제는 MCU가 이 구간의 모든 변화를 유효한 입력으로 받아들인다는 데 있다. 사람은 버튼을 한 번 눌렀다고 인식하지만, 펌웨어 입장에서는 짧은 시간에 여러 번의 천이(HIGH와 LOW를 왔다갔다 하는 것)가 들어온 것이므로 같은 동작이 중복 실행된다.

 

디바운싱(Debouncing)은 이 구간을 무시하기 위한 기법이다. 입력값이 바뀌어도 곧바로 처리하지 않고, 일정 시간 동안 같은 상태가 유지될 때에만 비로소 실제 상태 변화로 인정한다.

GPIO 값 변화 감지
   ↓
일정 시간 대기
   ↓
그동안 값이 다시 바뀌지 않았는가?
   ↓
Yes → 안정된 버튼 입력으로 확정
   ↓
HIGH → LOW 전환 시 버튼 이벤트 1회 처리

 

예컨대 디바운스 시간을 20 ms로 두면 입력이 LOW로 20 ms 이상 연속 유지되어야 비로소 "눌렸다"고 판단한다. 구간의 흔들림은 이 유지 시간을 채우지 못하므로 자연히 걸러진다.

핵심 조건

디바운싱만으로는 "한 번의 누름 = 한 번의 처리"가 보장되지 않는다. 상태를 안정적으로 확정하는 일과, 확정된 상태가 어느 순간 동작을 트리거할지는 서로 다른 문제이기 때문이다. 따라서 두 조건이 함께 필요하다.

  1. 디바운싱: 상태가 일정 시간 안정적으로 유지되어야 함
  2. 엣지 검출: 안정된 상태가 HIGH → LOW로 바뀌는 순간만 처리함

이 구분을 놓치면 안정화는 했지만 여전히 중복 동작하는 코드가 나온다. 매 루프마다 단순히 LOW인지를 확인하는 방식이 그렇다.

// 잘못된 방식: 누르고 있는 동안 계속 실행될 수 있음
if (digitalRead(BUTTON_PIN) == LOW) {
    doSomething();
}

버튼을 누르고 있는 동안에는 입력이 계속 LOW이므로 doSomething()이 반복 호출된다. 반대로 직전 안정 상태와 현재 안정 상태를 비교해 HIGH → LOW 천이가 일어난 순간에만 처리하면, 누르고 있는 시간과 무관하게 누름당 한 번만 실행된다.

// 안정된 HIGH → LOW 변화가 확정될 때만 1회 실행
if (stableState == LOW && previousStableState == HIGH) {
    doSomething();
}

 

Pull-up과 디바운싱의 차이

구분 Pull-up 디바운싱
해결 문제 버튼을 누르지 않았을 때 GPIO 값이 불안정한 문제 버튼을 누르거나 뗄 때 신호가 여러 번 흔들리는 문제
목적 기본 입력 상태를 확정 한 번의 누름을 한 번의 이벤트로 처리
적용 대상 버튼이 열려 있는 평상시 상태 버튼 전환 순간
구현 위치 내부/외부 저항 소프트웨어 또는 하드웨어 회로
대체 가능 여부 디바운싱을 대체 불가 Pull-up을 대체 불가

 

두 기법은 버튼 입력 회로에서 함께 등장하는 일이 많아 혼동되지만, 애초에 다루는 문제가 다르다.

 

Pull-up이 해결하는 것은 버튼을 누르지 않은 평상시의 입력값이다. GPIO를 입력으로 두고 아무 전압도 연결하지 않으면 핀의 논리값이 정해지지 않는데, 이를 Floating Input이라 한다. 이 상태에서는 주변 노이즈만으로도 값이 흔들려 무작위 입력이 발생할 수 있다.

버튼이 열려 있음
   ↓
GPIO에 HIGH/LOW 기준 없음
   ↓
주변 노이즈 영향
   ↓
무작위 입력 발생 가능

 

Pull-up 저항은 버튼이 열려 있을 때 핀을 끌어올려 기본값을 HIGH로 고정한다. 버튼을 누르면 핀이 GND로 연결되어 LOW가 된다.

VCC
 │
[Pull-up 저항]
 │
 ├──── GPIO
 │
[버튼]
 │
GND
버튼 상태 GPIO 값
누르지 않음 HIGH
누름 LOW

 

Arduino/ESP32에서는 외부 저항 없이 내부 Pull-up을 켤 수 있다.

pinMode(BUTTON_PIN, INPUT_PULLUP);

 

반면 디바운싱이 다루는 것은 버튼이 전환되는 순간의 흔들림이다. Pull-up은 "누르지 않은 상태"의 기준값을 정하고, 디바운싱은 "누르고 떼는 순간"의 신호를 걸러낸다 — 적용 시점도, 대상도 다르다. 그래서 한쪽이 다른 쪽을 대체할 수 없고, 실제 회로에서는 보통 둘을 함께 쓴다.

 

소프트웨어 방식 디바운싱

소프트웨어 디바운싱은 별도 회로 없이 GPIO를 주기적으로 읽으면서, 입력 상태가 일정 시간 유지될 때에만 실제 버튼 상태로 인정하는 방식이다. 구현은 역할이 다른 세 가지 상태를 구분하는 데서 출발한다.

rawState       : GPIO에서 즉시 읽은 값
candidateState : 현재 안정화 중인 상태
stableState    : 최종 확정된 버튼 상태

 

rawState는 매 순간의 원시 입력이고, candidateState는 아직 검증 중인 후보 상태, stableState는 시간 조건을 통과해 확정된 상태다. 버튼은 GPIO ↔ 버튼 ↔ GND로 연결하고 내부 Pull-up을 사용한다.

#include <Arduino.h>

constexpr uint8_t BUTTON_PIN = 2;
constexpr uint32_t DEBOUNCE_MS = 20;

bool stableState = HIGH;       // 확정된 상태
bool candidateState = HIGH;    // 현재 확인 중인 상태
uint32_t changedAt = 0;

void onButtonPressed() {
    Serial.println("버튼 입력 1회 처리");
}

void setup() {
    Serial.begin(115200);
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    stableState = digitalRead(BUTTON_PIN);
    candidateState = stableState;
}

void loop() {
    const bool rawState = digitalRead(BUTTON_PIN);
    const uint32_t now = millis();

    // 원본 입력값이 변하면 안정화 시간 측정을 다시 시작
    if (rawState != candidateState) {
        candidateState = rawState;
        changedAt = now;
    }

    // 후보 상태가 일정 시간 유지되면 안정 상태로 확정
    if ((now - changedAt >= DEBOUNCE_MS) &&
        (stableState != candidateState)) {
        const bool previousStableState = stableState;
        stableState = candidateState;
        // INPUT_PULLUP 기준: LOW = 버튼 눌림
        if (previousStableState == HIGH && stableState == LOW) {
            onButtonPressed();
        }
    }
}

 

동작의 핵심은 loop() 안의 두 조건문이다. 첫 번째 조건은 원시 입력이 후보와 달라질 때마다 candidateState를 갱신하고 변경 시각을 changedAt에 기록한다. 즉 입력이 흔들릴 때마다 타이머가 처음부터 다시 시작되므로, 구간 동안에는 유지 시간 조건이 충족되지 않는다.

 

두 번째 조건은 후보 상태가 DEBOUNCE_MS 이상 유지되고 그 값이 기존 확정 상태와 다를 때에만 stableState를 갱신하며, 이때 직전 확정 상태가 HIGH, 새 확정 상태가 LOW인 눌림 천이에서만 onButtonPressed()를 호출한다. 앞에서 짚은 디바운싱과 엣지 검출이 각각 이 두 조건문에 대응하는 셈이다.

 

동작을 차례대로 따라가면 이렇다. 입력이 LOW로 변해도 곧바로 처리하지 않고, 20 ms 동안 LOW가 유지되는지 지켜본 뒤에야 눌림으로 확정하며, HIGH → LOW 전환 순간에만 함수를 한 번 실행한다.

디바운스 시간

적절한 디바운스 시간은 버튼의 물리적 특성에 따라 달라진다. 일반적인 버튼은 10~30 ms 범위에서 시작한다.

환경 권장 시작값
일반 택트 스위치 10~20 ms
오래되었거나 품질이 낮은 버튼 20~50 ms
일반 사용자 입력 20 ms

 

표가 보여주듯 품질이 낮거나 오래된 버튼일수록 더 긴 유지 시간이 필요하다. 다만 유지 시간을 늘릴수록 입력이 확정되기까지의 지연도 함께 늘어나므로, 버튼 특성에 맞는 최소값에서 시작해 조정하는 편이 합리적이다.

 

하드웨어 방식 디바운싱

하드웨어 디바운싱은 버튼 신호가 MCU에 도달하기 전에 회로적으로 흔들림을 줄이는 방식이다. 소프트웨어 방식이 입력을 읽은 뒤 걸러낸다면, 하드웨어 방식은 입력 자체를 완만하게 다듬어 보낸다.

RC 필터 방식

RC 필터는 저항과 커패시터로 급격한 전압 변화를 완만하게 만든다.

VCC
 │
[Pull-up R]
 │
 ├──── MCU GPIO
 │
 ├──── C
 │     │
 │    GND
 │
[버튼]
 │
GND

 

버튼을 누르면 GPIO가 GND 쪽으로 내려가고, 버튼을 떼면 커패시터가 저항을 통해 천천히 충전된다. 짧은 바운스 신호는 커패시터가 흡수하므로 MCU 입력단까지 전달되지 않는다. 변화의 완만함은 시간상수로 결정된다.

τ = R × C
R = 10 kΩ
C = 1 µF
τ = 10 ms

 

이 정도 시간상수는 수 밀리초 수준의 짧은 채터링을 완화하는 데 쓸 수 있다.

RC 필터 + 슈미트 트리거

RC 필터만 사용하면 입력 전압이 HIGH와 LOW의 경계 구간을 천천히 통과하게 된다. 이때 MCU가 경계 부근의 작은 흔들림을 불안정하게 판단할 가능성이 남는다. 따라서 더 안정적인 구조는 RC 필터 뒤에 슈미트 트리거를 두는 것이다.

버튼
  ↓
Pull-up + RC 필터
  ↓
Schmitt Trigger
  ↓
MCU GPIO

 

슈미트 트리거(Schmitt Trigger)는 상승할 때와 하강할 때의 임계 전압을 다르게 사용한다.

상승 시 HIGH 판정 기준
      ≠
하강 시 LOW 판정 기준

 

이 임계 전압의 차이를 히스테리시스(Hysteresis)라 한다. 판정 기준이 한 점이 아니라 두 점으로 벌어져 있으므로, 입력 전압이 경계 근처에서 흔들려도 출력이 반복적으로 뒤집히지 않는다. RC 필터가 신호를 완만하게 만들면, 슈미트 트리거가 그 신호를 깔끔한 디지털 천이로 끊어 주는 식의 역할 분담이다.

 

소프트웨어와 하드웨어 방식 비교

항목 소프트웨어 방식 하드웨어 방식
구현 코드로 처리 RC 회로, 슈미트 트리거 등 사용
비용 추가 부품 없음 저항·커패시터·논리 IC 필요 가능
조정 시간값 수정만 하면 됨 부품값 변경 필요
노이즈 대응 버튼 바운스 처리 중심 전기적 노이즈 감소에 유리
일반 MCU 프로젝트 대부분 충분 긴 배선·노이즈 환경에서 권장

 

두 방식은 배타적이지 않다. 소프트웨어 방식은 부품을 추가하지 않고 시간값만 바꾸면 되므로 조정이 쉽고, 대부분의 일반 MCU 프로젝트에서는 이것만으로 충분하다. 일반적인 Arduino·ESP32 버튼 입력에는 다음 구성이 가장 실용적이다.

버튼 → GND
GPIO → INPUT_PULLUP
펌웨어 → 10~30 ms 디바운싱
처리 방식 → 안정된 HIGH → LOW 전환 시 1회 실행

 

반면 하드웨어 방식은 버튼 바운스뿐 아니라 전기적 노이즈 자체를 줄이는 데 유리하다. 모터·릴레이·PWM처럼 노이즈원이 있거나 배선이 긴 환경에서는 하드웨어 필터로 신호를 먼저 다듬고, 그 위에 소프트웨어 디바운싱을 더해 엣지 기반으로 단일 이벤트를 처리하는 다단 구성이 더 적절하다.

버튼
  ↓
외부 Pull-up
  ↓
RC 필터
  ↓
Schmitt Trigger 또는 히스테리시스 입력
  ↓
소프트웨어 디바운싱
  ↓
엣지 기반 단일 이벤트 처리

UART · SPI · I2C · CAN

Summary: UART, SPI, I2C, CAN은 임베디드에서 흔히 쓰는 직렬 통신 방식으로, 클럭 사용 여부·장치 선택 방식·다중 노드 지원·노이즈 내성에서 갈린다. UART는 클럭이 없는 1:1 비동기 통신, SPI는 클럭 기반 고속 통신, I2C는 두 선으로 여러 IC를 주소로 구분하는 버스, CAN은 차동 신호로 여러 제어기를 잇는 견고한 버스다. 이 글은 각 방식의 정의·배선·속도/거리·사용처와, 임베디드에서의 선택 기준을 정리한다.

UART란?

UART(Universal Asynchronous Receiver/Transmitter)는 클럭선을 따로 두지 않는 비동기식 직렬 통신이다. 공유 클럭이 없으므로 송신기와 수신기가 미리 같은 통신 조건을 맞춰야 한다. 흔히 쓰는 설정은 baud rate 115200, 데이터 8비트, 패리티 없음, 정지 1비트이며, 이를 묶어 8N1이라 부른다. 프레임은 Start Bit | Data 5~9bit | Parity(옵션) | Stop Bit(1개 이상) 형태다. 클럭이 없는 만큼 양쪽 baud rate 오차가 너무 크면 수신 오류가 난다.

배선

항목 내용
필수 신호선 TX, RX
실제 최소 배선 TX + RX + GND, 총 3선
추가 가능 신호 RTS, CTS 같은 하드웨어 Flow Control
연결 구조 기본적으로 1:1
주소 지정 없음
다중 장치 연결 UART 자체만으로는 부적합

 

주소 지정이 없고 연결이 1:1이기 때문에, 여러 장치를 한 버스에 묶는 용도로는 UART 자체만으로는 부적합하다.

 

속도와 거리에는 표준 상한이 없고 MCU·클럭·전압 레벨·배선 품질에 따라 달라진다. 흔한 baud rate는 9600, 57600, 115200, 921600이다. MCU 간 직접 TTL UART는 보드 내부나 매우 짧은 케이블에 적합하고, 긴 거리나 노이즈 환경에서는 UART 신호를 그대로 쓰지 않고 RS-232 또는 RS-485 트랜시버를 붙인다. 즉 UART는 데이터 프레임 방식을 정의할 뿐, 긴 거리의 전기적 안정성은 별도의 물리 계층이 담당한다.

 

사용위치로는 USB-UART 디버거, GPS·BLE·Wi-Fi 모듈, Raspberry Pi↔ESP32, LiDAR·산업용 센서 등이 있다.

예를 들어 Raspberry Pi ─UART(JSON)─ ESP32 ─ TB6612FNG ─ Motor 구조에서 UART는 상위 제어 명령을 모터 제어 펌웨어로 전달하는 데 쓰인다. 다만 제어기가 여럿이고 배선이 길고 모터 노이즈가 강한 환경이라면 UART보다 CAN이나 RS-485가 더 적합하다.

SPI란?

SPI(Serial Peripheral Interface)는 클럭을 사용하는 동기식 고속 직렬 통신이다. Controller가 클럭을 만들고 Target 장치가 그 클럭에 맞춰 데이터를 주고받는다. 최근 문서는 Master/Slave 대신 Controller/Target, MOSI/MISO 대신 COPI/CIPO라는 표현을 쓰기도 하지만, 실제 데이터시트와 코드에는 아직 MOSI/MISO가 훨씬 많이 보인다.

배선

신호 역할
SCLK Controller가 만드는 클럭
MOSI Controller → Target 데이터
MISO Target → Controller 데이터
CS / SS 특정 Target 선택
GND 기준 전압 공유

 

기본 4선은 SCLK + MOSI + MISO + CS이며, 실제로는 GND까지 필요하므로 최소 5선으로 본다. 여러 Target을 붙일 때는 SCLK/MOSI/MISO를 공유하고 장치마다 CS 선을 따로 둔다.

특징

항목 내용
동기 방식 Clock 사용
전송 방식 일반적으로 Full-duplex
주소 없음
장치 선택 CS 핀
속도 MCU·Target 데이터시트에 따라 결정
거리 PCB 내부 또는 장비 내부의 짧은 거리
단점 장치가 많아질수록 CS 핀이 많이 필요

 

SPI는 규격 전체에 공통된 속도 상한이 있는 방식이 아니어서 "SPI는 무조건 몇 MHz"라고 말하면 틀린다. 실제 속도는 MCU와 연결 대상의 데이터시트가 결정하며, 임베디드에서는 수 MHz에서 수십 MHz 범위가 흔하다. 주소가 없어 장치 선택을 CS 핀으로 하기 때문에, 장치가 많아질수록 CS 핀도 그만큼 늘어난다는 점이 단점이다.

 

사용처는 SPI Flash, SD Card, 고속 ADC/DAC, RF 모듈, 고속 IMU, UWB 모듈 등이다(예: ESP32-S3↔DWM3000 UWB). 짧은 거리에서 빠르게 데이터를 주고받아야 하는 장치, 즉 샘플링 주기가 높거나 처리량이 큰 장치에 적합하다.

I2C란?

 

I2C(Inter-Integrated Circuit)는 두 개의 신호선으로 여러 IC를 연결하는 동기식 버스다.

배선

신호 역할
SDA 양방향 데이터
SCL Clock
GND 기준 전압 공유

 

신호선은 SDA, SCL 두 개이고 GND까지 포함하면 최소 3선이다. 핵심은 두 선이 Open-drain/Open-collector 구조라는 점이다. 장치가 High를 직접 출력하지 않고 Low로만 끌어내리며, High는 Pull-up 저항으로 만들어진다. 이 구조 덕분에 여러 장치가 한 버스를 공유할 수 있다. 또한 I2C는 주소 기반 통신이어서 SPI의 CS 핀 없이도 여러 장치를 구분한다.

속도

모드 최대 속도
Standard-mode 100 kbps
Fast-mode 400 kbps
Fast-mode Plus 1 Mbps
High-speed mode 3.4 Mbps
Ultra Fast-mode 5 Mbps (단방향 특수 모드)

 

다만 실제 센서 모듈 대부분은 100 kHz 또는 400 kHz만 지원한다. MCU가 1 MHz나 3.4 MHz를 지원해도 센서가 지원하지 않으면 쓸 수 없다.

 

거리에는 단일 수치가 없다. 케이블 길이 자체보다 전체 버스 정전용량, Pull-up 저항값, 신호 상승 시간, 통신 속도, 분기 구조가 한계를 정한다. 일반 I2C 버스는 총 정전용량 제한이 약 400 pF이므로 기본적으로 PCB 내부나 장비 내부의 짧은 거리에 적합하고, 케이블이 길어지거나 센서가 많아지면 속도를 낮추거나 버퍼·리피터·차동 확장기를 써야 한다.

 

사용위치로는 온습도·조도·ToF 센서, RTC, EEPROM, 저속 IMU, OLED 등이다(예: ESP32↔VL53L0X ToF, ESP32↔LSM6DS3 IMU). 선 수를 줄이면서 여러 저속 센서를 한 MCU에 붙일 때 가장 적합하다.

CAN이란?

CAN(Controller Area Network)은 차량·로봇·산업장비에서 여러 제어기를 안정적으로 연결하기 위해 만든 통신 버스다. UART·SPI·I2C와 달리 처음부터 모터 노이즈, 긴 배선, 여러 제어기, 전기적 간섭, 메시지 충돌, 일부 노드 오류 같은 환경을 전제로 설계되었다. 배선은 선형 버스가 원칙이고 양 끝에 보통 120 Ω 종단 저항을 둔다. 별 모양으로 길게 분기하면 반사파 문제가 생기므로 기본 CAN 배선에는 부적합하다.

배선

구분 내용
데이터선 CANH, CANL
통신 방식 차동 신호
최소 데이터 배선 2선
실제 배선 CANH/CANL + GND 기준선, 필요 시 실드
종단 저항 버스 양 끝에 보통 120Ω
MCU 연결 MCU CAN Controller ↔ CAN Transceiver ↔ CANH/CANL

 

여기서 중요한 점은 MCU의 CAN_TX/CAN_RX 핀을 CANH/CANL에 직접 연결하면 안 된다는 것이다. CAN 트랜시버가 MCU의 로직 신호를 차동 CAN 신호로 변환하는 역할을 한다.

MCU CAN_TX/RX → CAN Transceiver → CANH/CANL Bus

속도와 거리

종류 속도 거리
Classical CAN 최대 1 Mbps 1 Mbps 기준 약 40 m
CAN FD 중재 구간은 CAN 속도, 데이터 구간만 더 빠름 속도↑일수록 거리·stub 제약↑
CAN FD 실사용 대체로 최대 5 Mbps 5 Mbps에서는 더 짧은 버스 필요
CAN FD 최적 설계 최대 약 8 Mbps 가능 매우 짧고 정교한 설계 필요

 

대표 기준은 1 Mbps, 버스 길이 40 m, 짧은 stub다. 속도를 낮추면 더 긴 거리를 구성할 수 있지만, 그 비율은 케이블·트랜시버·노드 수·분기 길이·노이즈 환경에 따라 달라진다. CAN FD는 데이터 구간을 더 빠르게 보내며 실사용에서는 대체로 5 Mbps 이하가 현실적인 상한이고, 최적화된 조건에서 8 Mbps 수준까지 본다.

CAN이 UART/SPI/I2C보다 강한 이유

기능 CAN
노이즈 내성 차동 신호라 강함
다중 노드 다수 노드가 같은 버스 공유
충돌 처리 메시지 ID 우선순위 기반 중재
오류 검출 CRC, ACK, Bit Monitoring 등
오류 격리 오류가 반복되는 노드를 Bus-off로 격리
사용 환경 차량, 로봇, 산업 장비, 배터리 시스템

 

사용처는 차량 ECU 간 통신, 모터 제어기↔상위 제어기, 배터리 BMS↔인버터, 로봇 관절 제어기↔메인 제어기, 산업용 PLC↔센서·액추에이터, AGV/AMR↔모터 드라이버·안전 제어기 등이다. 예컨대 UGV가 Raspberry Pi와 ESP32 한 대만 쓴다면 UART로 충분하지만, 모터 제어기 둘에 배터리 BMS, 센서·카메라·안전 제어기까지 늘어나면 UART를 여러 개 두는 것보다 CAN 버스에 각 제어기를 노드로 붙이는 편이 배선·확장성·오류 처리·노이즈 내성 면에서 낫다.

임베디드에서의 선택 기준

네 방식은 결국 클럭 유무, 장치 선택 방식, 다중 노드 지원, 노이즈 내성이라는 축에서 갈린다.

  • 단순한 1:1 명령·디버그 연결 → UART
  • 짧은 거리에서 빠른 데이터 전송 → SPI
  • 여러 저속 센서를 적은 배선으로 연결 → I2C
  • 여러 제어기를 긴 배선·노이즈 환경에서 연결 → CAN
상황 우선 선택
ESP32 로그를 Mac에서 보기 UART
Raspberry Pi가 ESP32에 모터 명령 전송 UART
ESP32에 UWB, Flash, 고속 IMU 연결 SPI
ESP32에 ToF, RTC, 저속 IMU, OLED 연결 I2C
차량형 UGV의 모터 제어기·BMS·안전 제어기 연결 CAN
자동차 ECU 통신 CAN / CAN FD
여러 센서를 한 보드에 붙이기 I2C
SD 카드·외부 Flash에 빠르게 접근 SPI

배열 리스트와 링크드 리스트

Summary: 배열 리스트와 링크드 리스트는 모두 데이터를 순서대로 다루지만, 메모리에 배치하는 방식이 다르고 그 차이가 접근·삽입·삭제 비용과 메모리 사용을 가른다. 배열 리스트는 연속된 공간에 저장해 인덱스 접근과 순차 처리가 빠른 대신 중간 삽입·삭제와 확장에 비용이 들고, 링크드 리스트는 노드를 포인터로 연결해 위치를 아는 삽입·삭제가 싸지만 인덱스 접근과 캐시 효율이 불리하다. 이 글은 두 구조의 내부 구성과 장단점, 그리고 시간 복잡도·메모리·사용 상황의 차이를 정리한다.

배열 리스트란?

 

배열 리스트는 배열을 기반으로 데이터를 순서대로 저장하는 자료구조다. 처음 크기를 정하면 바꾸기 어려운 일반 배열과 달리, 공간이 부족해지면 더 큰 배열로 확장할 수 있다. 가장 중요한 성질은 데이터가 메모리의 연속된 공간에 저장된다는 점이며, 장단점은 대부분 이 연속성에서 비롯된다.

내부 구조

배열 리스트는 보통 실제 데이터를 담는 연속된 배열과 함께 현재 개수(size), 최대 수용량(capacity)을 관리한다.

구성 요소 설명
데이터 배열 실제 데이터를 저장하는 연속된 메모리 공간
size 현재 저장된 데이터 개수
capacity 저장할 수 있는 최대 데이터 개수

 

빈 공간이 남아 있으면 새 데이터를 바로 넣지만, 용량이 가득 차면 더 큰 배열을 새로 만들고 기존 데이터를 통째로 복사해야 한다. 이 과정을 크기 확장이라고 한다.

장점

연속 저장 덕분에 인덱스로 특정 위치에 곧장 접근할 수 있어 인덱스 접근은 O(1)이고, 데이터 수가 늘어도 접근 비용은 거의 변하지 않는다. 데이터가 메모리에 붙어 있어 처음부터 끝까지 순회할 때 CPU 캐시 효율도 좋다. 끝에 추가하는 것도 빈자리에 값만 넣으면 되므로 보통 O(1)이다. 다만 용량이 차서 확장·복사가 일어나는 순간은 O(n)인데, 추가 연산 전체를 길게 평균 내면 분할 상환 기준 O(1)로 본다.

단점

같은 연속성이 약점도 만든다. 중간에 삽입하려면 그 뒤 데이터를 한 칸씩 뒤로 밀어야 하고, 중간을 삭제하면 빈자리가 남지 않도록 뒤 데이터를 앞으로 당겨야 한다. 둘 다 O(n)이다. 또 확장 시점에는 기존 데이터를 새 배열로 복사하므로, 데이터가 많을수록 그 순간의 추가 연산이 느려질 수 있다.

 

링크드 리스트란?

 

링크드 리스트는 데이터를 연속 공간에 두지 않는다. 각 데이터를 노드로 나눠 저장하고, 노드마다 다음 노드의 주소를 들고 있어 사슬처럼 연결된다. 마지막 노드는 다음이 없으므로 nullptr을 가리킨다.

내부 구조

각 노드는 실제 값과 연결 정보(다음 노드, 경우에 따라 이전 노드 주소)를 가진다.

구성 요소 설명
데이터 실제 저장할 값
연결 정보 다음(또는 이전) 노드를 가리키는 주소

 

노드들이 메모리상 서로 떨어진 위치에 흩어져 있을 수 있다는 점이 배열 리스트와 근본적으로 다르다.

종류

  1. 단일 링크드 리스트: 각 노드가 다음 노드만 가리켜 앞→뒤 단방향 이동만 된다.
  2. 이중 링크드 리스트: 이전·다음 노드를 모두 가리켜 양방향 이동이 가능하지만, 그만큼 메모리를 더 쓴다.
  3. 원형 링크드 리스트: 마지막 노드가 다시 첫 노드를 가리켜 순환 구조가 된다(라운드 로빈, 반복 순회 등).

장점

삽입·삭제 위치를 이미 알고 있다면, 뒤 데이터를 밀거나 당길 필요 없이 포인터 연결만 바꾸면 되므로 O(1)이다. 삭제도 마찬가지인데, 단일 링크드 리스트는 대상의 이전 노드를 알아야 연결을 고칠 수 있고, 이중 링크드 리스트는 현재 노드가 양쪽을 알고 있어 더 직접적으로 처리된다. 새 데이터를 추가할 때 배열처럼 전체를 복사할 필요도 없다.

단점

대신 인덱스로 한 번에 접근할 수 없다. n번째 데이터를 찾으려면 head부터 순서대로 따라가야 하므로 접근이 O(n)이다. 노드마다 주소를 저장해야 해서(이중은 두 개) 같은 데이터라도 메모리를 더 쓴다. 게다가 노드가 메모리에 흩어져 매번 다른 위치를 따라가므로 캐시 효율이 낮다. 그래서 이론상 삽입·삭제가 유리한 링크드 리스트보다 배열 리스트가 실제로 더 빠르게 동작하는 경우도 있다.

 

배열 리스트와 링크드 리스트 차이

시간 복잡도

연산 배열 리스트 링크드 리스트
인덱스로 접근 O(1) O(n)
맨 앞 삽입 O(n) O(1)
맨 앞 삭제 O(n) O(1)
맨 뒤 삽입 평균 O(1) 꼬리 노드를 알면 O(1)
중간 삽입 O(n) 위치를 이미 알면 O(1)
중간 삭제 O(n) 이전 노드/위치를 알면 O(1)
특정 값 탐색 O(n) O(n)

 

표에서 가장 주의할 점은 링크드 리스트의 삽입·삭제가 항상 O(1)은 아니라는 것이다. 위치를 모르면 그 노드를 찾는 탐색에 O(n)이 든다. 예를 들어 100번째 위치에 삽입하려면 head부터 100번째 노드까지 따라가야 한다. 즉 O(1)이 성립하는 것은 삽입·삭제할 위치를 이미 알고 있을 때뿐이다.

메모리 사용 방식

구분 배열 리스트 링크드 리스트
저장 위치 연속된 메모리 공간 서로 떨어진 위치에 저장될 수 있음
추가 저장 정보 크기, 용량 등 다음(또는 이전) 노드 주소
메모리 사용량 비교적 적음 포인터 저장 공간 필요
크기 확장 새 배열 생성 후 기존 데이터 복사 새 노드 생성 후 연결
캐시 효율 일반적으로 좋음 상대적으로 불리할 수 있음

 

배열 리스트는 연속 저장이라 메모리 접근 효율이 좋고 따로 둘 정보도 적다. 링크드 리스트는 노드마다 포인터가 필요하고 노드가 메모리 곳곳에 흩어질 수 있어 접근 비용이 늘 수 있다.

사용 상황

  • 배열 리스트가 적합한 경우: 특정 인덱스에 자주 접근하거나, 처음부터 끝까지 반복 처리하거나, 끝에 추가가 잦거나, 메모리를 아끼고 싶거나, 순서와 인덱스가 중요한 경우. 학생 점수·센서 측정값·상품·게임 오브젝트 목록처럼 인덱스로 관리하는 데이터가 여기 해당한다.
  • 링크드 리스트를 고려할 경우: 중간 삽입·삭제가 매우 잦고 그 위치를 이미 알고 있거나, 앞뒤에서 자주 추가·제거하거나, 노드 연결을 직접 관리해야 하는 경우. 작업 대기열, 이벤트 목록, 연결 관계 기반 구조 등이다.

다만 일반적인 프로그램에서는 배열 리스트가 기본 선택이 되는 경우가 많다. 인덱스 접근과 순회가 빠르고 구조가 단순하기 때문이다. 링크드 리스트는 중간 삽입·삭제가 유리한 구조이지만, 실제로는 원하는 위치를 찾는 탐색 비용과 포인터 관리 비용까지 함께 따져야 한다.

비대칭키 암호화와 대칭키 암호화

Summary: 비대칭키 암호화는 암복호화에 서로 다른 키(공개키/개인키)를 사용해 키 배송 문제를 근본적으로 없애고 기밀성·인증·부인방지를 제공하지만 속도가 느리다. 대칭키 암호화는 동일한 키를 사용해 속도가 빠르고 대용량 처리에 적합하지만, 키를 안전하게 전달해야 하는 키 배송 문제를 가진다. 이 글은 두 방식의 정의, 키 처리 구조, 장단점, 대표 알고리즘을 정리한다.

비대칭키 암호화란?

비대칭키 암호화는 암호화와 복호화에 서로 다른 키를 사용하는 방식이다. 두 키 중 하나를 공개키로 두기 때문에 공개키 암호라고도 불린다.

키 구조부터 보면 암호화 키와 복호화 키가 분리되어 있다. 두 키를 각각 키A, 키B라 하면, 키A로 암호화한 암호문은 키B로만 복호화되고 키B로 암호화한 암호문은 키A로만 복호화된다. 이 중 하나만 비밀로 보관하고(비밀키 또는 개인키) 다른 하나는 공개한다(공개키). 통상적인 사용을 위해서는 둘 중 하나가 반드시 공개되어야 한다.

 

이 구조는 키 배송 문제와 직결된다. 공개키로 암호화한 암호문은 개인키를 가진 사람만 복호화할 수 있으므로, 통신 양측은 공개키만 교환하고 상대의 공개키로 암호화해 데이터를 주고받으면 된다. 비밀로 지켜야 하는 키를 따로 전달할 필요가 없어, 키 배송 문제가 근본적으로 발생하지 않는다.

 

이러한 특성 덕분에 비대칭키 암호화는 키 분배가 필요 없고 기밀성·인증·부인방지 기능을 제공한다. 대신 속도가 느리다는 단점이 있다.

 

키를 어떤 순서로 사용하는지에 따라 두 가지 방식으로 나뉜다.

방식  키 사용  목적
암호 모드 송신자 공개키로 암호화 → 송신자 개인키로 복호화 소량 메시지 암호화, 주로 키 교환 용도
인증 모드 송신자 개인키로 암호화 → 송신자 공개키로 복호화 메시지 인증(부인방지)

 

대표 알고리즘은 다음과 같다.

알고리즘  설명
Diffie-Hellman 최초의 공개키 알고리즘, 위조에 취약
RSA 대표적 공개키 알고리즘
DSA 전자서명 알고리즘 표준

 

대칭키 암호화란?

대칭키 암호화(비공개키 방식)는 암호화와 복호화에 동일한 키를 사용하는 방식이다. 그래서 이 키는 외부에 공개하지 않고 비공개로 유지한다.

 

이 방식의 결정적 한계는 키 배송 문제다. 송신 측은 어떻게든 수신 측에 암호 키를 전달해야 하는데, 이 키가 배송 과정에서 탈취되면 아무리 뛰어난 암호화 알고리즘을 써도 평문이 드러난다. 평문을 안전하게 전달하려고 암호문을 만들지만, 정작 그 키를 안전하게 전달할 방법이 마땅치 않은 것이다. 앞 절에서 본 비대칭키 암호화가 바로 이 문제를 해결한 방식이다.

 

장점은 암호화 속도가 빠르고 대용량 데이터 암호화에 적합하다는 점이다. 단점은 키를 교환해야 한다는 점, 탈취 관리에 대한 부담, 그리고 사용자가 늘어날수록 관리할 키가 많아져 확장성이 떨어진다는 점이다.

 

대칭키는 Session Key, Secret Key, Shared Key, 단용키 등으로도 불린다.

 

보안 기능 측면에서 대칭키 암호화는 기밀성을 제공하지만 무결성·인증·부인방지는 보장하지 않는다. 인증과 부인방지까지 제공하는 비대칭키 암호화와 대비된다.

 

대표 알고리즘으로는 공인인증서의 암호화 방식으로 잘 알려진 SEED를 비롯해 DES, 3DES, AES, ARIA, 그리고 최근 주목받는 ChaCha20이 있다.

master UTF-8 HTML HIBACHI Ln 1, Col 1