10주차_통신 프로토콜 MQTT - sookite22/SmartDivice_24 GitHub Wiki

목차

  • OSI 모델
  • 통신 프로토콜
    • MQTT
      • Publish와 Topic
    • CoAP
    • HTTP와 MQTT
  • 실습
    • 초음파 센서 거리 측정
    • 초음파 센서값 Publish
    • 능동 부저 제어
  • 후기

1. OSI(Open Systems Interconnection) 모델이란?

네트워크 통신 을 일곱 개의 계층으로 나누어 설명하는 표준 참조 모델 이다. 각 계층은 특정 네트워크 기능을 수행하며, 상위 계층은 하위 계층의 서비스를 이용한다.

OSI 모델의 목적은 다양한 네트워크 시스템 간의 상호 운용성을 보장하고 네트워크 통신의 복잡성을 줄이는 것이다. 네트워크 통신의 개념을 계층별로 분리하여 설명함으로써 네트워크의 설계, 관리 및 문제 해결을 용이하게 한다.

스크린샷 2024-06-11 024353

  1. 물리 계층 (Physical Layer): 물리적 매체를 통해 데이터를 전송한다. 전기적 신호, 광 신호, 라디오 신호 등 물리적 신호로 데이터를 전송하는 방식을 정의한다.
  2. 데이터 링크 계층 (Data Link Layer): 물리 계층에서 발생할 수 있는 오류를 감지하고 수정한다. 프레임 단위로 데이터 전송, 흐름 제어, 오류 검출 및 수정을 한다.
  3. 네트워크 계층 (Network Layer): 데이터 패킷을 소스에서 목적지까지 전송한다. 논리적 주소 지정(IP 주소), 라우팅, 패킷 포워딩 등의 기능을 수행한다.
  4. 전송 계층 (Transport Layer): 종단 간 통신을 담당한다. 데이터 전송의 신뢰성, 흐름 제어, 오류 복구를 수행한다.
  5. 세션 계층 (Session Layer): 통신 세션을 관리한다. 세션 설정, 유지, 종료. 체크포인트와 복구 기능을 제공한다.
  6. 표현 계층 (Presentation Layer): 데이터를 애플리케이션이 이해할 수 있는 형태로 변환한다. 데이터 인코딩/디코딩, 암호화/복호화, 압축/해제 등이 있다.
  7. 응용 계층 (Application Layer): 사용자가 네트워크에 접근할 수 있도록 해준다. 네트워크 서비스(예: 이메일, 파일 전송, 원격 로그인)를 제공한다.
  • OSI 모델은 구조가 복잡하여 현재는 OSI 모델을 단순화한 TCP/IP 모델을 많이 사용한다. TCP/IP 모델은 일반적으로 표준이 되었으며 상호 연결성 확보된 모델이라고 볼 수 있다.

2. 통신 프로토콜

2.1. MQTT(Message Queuing Telemetry Transport)란?

Machine-to-Machine(M2M)IoT 응용 프로그램을 위해 설계된 경량 프로토콜이다. 이는 네트워크 대역폭과 전력을 효율적으로 사용할 수 있도록 최적화되어 있다.

주로 센서 데이터 수집, 제어 시스템, 실시간 모니터링 등의 IoT 및 M2M 응용 프로그램에서 사용된다. 이는 IoT 생태계에서 중요한 역할을 하고 있으며, 다양한 IoT 장치 간의 통신을 가능하게 한다.

스크린샷 2024-05-13 164846

주요 특징

  • 간편성: 발행-구독(Publish-Subscribe) 패턴을 기반으로 하며 간단한 프로토콜을 사용하므로 구현이 간단하고 이해하기 쉽다.
  • 경량성: TCP/IP 프로토콜 위 에서 동작한다. 메시지 오버헤드가 작아 작은 장치와 저전력 디바이스에서도 사용 가능하며 IoT 장치같은 제한된 환경에서 유용하다.
  • 확장성: 다양한 플랫폼 및 언어에서 구현되어 있으며 여러 클라이언트와 브로커를 연결하여 확장성을 확보한다.
  • 신뢰성: 메시지의 전달을 보장하는 QoS(Quality of Service) 레벨을 제공한다. 클라이언트는 메시지 발행 시 QoS 레벨을 선택하여 신뢰성을 조절할 수 있다.
  • 유연성: 여러 프로토콜과 통합 가능하며 토픽(Topic) 을 통해 메시지를 구분하고 필요한 기능을 추가할 수 있다.

2.1.1. Publish(발행)와 Topic(주제)

MQTT 프로토콜에서는 발행-구독(Publish-Subscribe) 모델을 사용한다. 이는 클라이언트가 특정 _주제(Topic)_에 메시지를 발행(Publish) 하고, 다른 클라이언트가 해당 주제를 구독(Subscribe) 하여 해당 메시지를 수신한다. 이를 통해 클라이언트들 간에 효율적인 통신이 가능해진다.

  • Publish: 메시지를 특정 주제에 게시 하는 것을 의미한다. 메시지를 발행하는 클라이언트는 해당 메시지에 대한 주제를 지정하여 발행한다. 예를 들어, 센서에서 수집한 온도 데이터를 "home/livingroom/temperature"이라는 주제에 발행할 수 있다. 발행된 메시지는 MQTT 브로커 를 통해 해당 주제를 구독하고 있는 클라이언트에게 전달된다.

  • Topic: MQTT 메시지를 특정하는 데 사용되는 주소 형식 이다. 예를 들어, "home/livingroom/temperature"과 같이 계층 구조 로 표현된다. 주제는 클라이언트가 메시지를 구독할 때 지정되며, 해당 주제에 발행된 모든 메시지를 수신한다.

MQTT 프로토콜에서는 토픽 이름을 사용하여 메시지를 구분하고 라우팅한다. 토픽은 계층 구조를 가지며, 슬래시("/")로 구분됩니다. 토픽 이름을 선택할 시 다음과 같은 가이드라인을 따르는 것이 좋다:

  1. 아스키 문자만 사용: UTF-8 인코딩된 문자열로 표현된다. 이는 호환성 및 이식성을 보장하기 위함이다.
  2. 간단하고 명확한 구조: 간단하고 명확한 구조여야 한다. 이해하기 쉽고 관리하기 쉬운 토픽 구조를 유지하는 것이 중요하다.
  3. 계층 구조: 토픽은 계층 구조로 구성되며, 각 수준은 슬래시("/")로 구분된다. 이는 토픽 이름의 구조를 조직화하고 메시지 라우팅을 용이하게 한다.
  • Publisher(발행자): MQTT 프로토콜에서 메시지를 발행하는 클라이언트를 가리킨다. Publisher는 특정 Topic에 대한 메시지를 생성하고 MQTT 브로커에 해당 메시지를 발행한다. 이를 통해 다른 클라이언트가 해당 주제를 구독할 경우 메시지를 수신할 수 있다.

2.2. CoAP(Constrained Application Protocol)이란?

제한된 환경 에서 동작하는 디바이스들 간의 효율적인 통신을 지원 하기 위해 설계된 경량 프로토콜이다.

주로 작은 디바이스들 간의 통신에 사용되며, 센서 네트워크, 스마트 홈, 산업 자동화 및 스마트 시티 등 다양한 IoT 응용 분야에서 활용된다. 이는 제한된 환경에서도 효율적인 통신을 가능하게 하여 IoT 발전에 크게 기여하고 있다.

주요 특징

  • 경량성: UDP(User Datagram Protocol)를 기반으로 하여 TCP보다 더 경량하고 적은 오버헤드를 가진다. 이는 제한된 리소스를 가진 디바이스에서도 적은 대역폭과 전력을 사용하여 효율적인 통신을 가능하게 한다.
  • RESTful: HTTP와 유사한 REST(Representational State Transfer) 아키텍처를 따른다. HTTP와 유사한 RESTful 인터페이스로 리소스를 요청하고 제어할 수 있다.
  • 프록시와 캐싱: HTTP와 마찬가지로 프록시 서버를 지원하여 네트워크에서 리소스를 중계한다. 또한, 캐싱 기능을 통해 리소스에 대한 요청을 캐시하여 네트워크 트래픽을 감소시킨다.
  • 신뢰성: Confirmable 메시지를 사용하여 메시지 전달 및 응답을 확인할 수 있다.
  • 보안: DTLS(Datagram Transport Layer Security)를 지원하여 데이터를 암호화하고 보호할 수 있다.

2.3. HTTP와 MQTT

MQTT와 HTTP는 모두 인터넷을 통한 통신을 위한 프로토콜이지만, 목적과 특징에서 차이가 있으며, 사용하는 상황에 맞게 적절히 선택되어야 한다. 예를 들어, IoT 디바이스 간 실시간 통신에는 MQTT가 적합하고, 웹 애플리케이션과의 상호작용에는 HTTP가 적합하다.

  • 통신 방식: MQTT: 발행-구독(Publish-Subscribe) 모델을 사용한다. 클라이언트가 '구독'하여 관심 있는 토픽의 메시지를 수신하거나, '발행'하여 메시지를 특정 토픽에 전달합니다. HTTP: 요청-응답(Request-Response) 모델을 사용한다. 클라이언트가 서버에 요청을 보내고, 서버가 해당 요청에 대한 응답을 반환합니다.

  • 헤더와 바디: MQTT: MQTT 메시지에서 헤더에는 메시지 유형, 토픽 등의 정보가 포함, 바디에는 실제 데이터가 포함된다. HTTP: 헤더에는 요청 또는 응답에 대한 메타데이터가 포함, 바디에는 요청 본문 또는 응답 데이터가 포함된다.

  • 실시간 통신: MQTT: 실시간 통신에 최적화되어 있다. 클라이언트와 브로커 간 연결을 유지하며 브로커를 통해 메시지를 실시간으로 전달한다. HTTP: 일반적으로 요청-응답 패턴을 따른다. 클라이언트가 요청을 보낼 때만 서버와 통신한다. 따라서 실시간 통신에는 적합하지 않다.

  • 사용 사례: MQTT: IoT 및 M2M 통신에 적합하다. 작은 디바이스 간의 경량하고 효율적인 통신을 지원하며, 대량의 센서 데이터를 실시간으로 전송할 수 있다. HTTP: 웹 브라우저와 서버 간의 통신에 주로 사용된다. 웹 페이지의 로딩, 폼 제출, API 호출 등에 사용된다.

3. 실습

3.1. 초음파 센서 거리 측정

초음파 센서

음파 파동을 사용하여 거리를 측정하는 센서이다.

스크린샷 2024-05-08 144732

회로 연결

스크린샷 2024-05-08 141245

코드 작성

const int trigPin = 12;  // 초음파 센서의 트리거 핀
const int echoPin = 14;  // 초음파 센서의 에코 핀

long duration;          // 음파의 왕복 시간(마이크로초)
float distanceCm;       // 거리(cm)
float distanceInch;     // 거리(인치)

void setup() {
  Serial.begin(115200); // 시리얼 통신 시작 (통신 속도: 115200bps)
  pinMode(trigPin, OUTPUT);  // 트리거 핀을 출력으로 설정
  pinMode(echoPin, INPUT);   // 에코 핀을 입력으로 설정
}

void loop() {
  digitalWrite(trigPin, LOW);           // 트리거 핀 LOW로 초기화
  delayMicroseconds(2);                 // 2 마이크로초 대기
  digitalWrite(trigPin, HIGH);          // 트리거 핀 HIGH로 설정하여 초음파 송신
  delayMicroseconds(10);                // 10 마이크로초 동안 
  digitalWrite(trigPin, LOW);           // 초음파 송신 종료
  
  duration = pulseIn(echoPin, HIGH);     // 에코 핀에서 초음파의 왕복 시간 측정
  
  distanceCm = duration * 0.034/2; // 거리 계산
  
  distanceInch = distanceCm * 0.393701; // 인치로 변환
  
  Serial.print("Distance: ");
  Serial.print(distanceCm);              // 거리 (cm)
  Serial.print("(cm)  ");
  Serial.print(distanceInch);            // 거리 (인치)
  Serial.println("(inch)");
  
  delay(1000);                           // 1초 대기
}

결과 확인

  • 시리얼 모니터를 통해 결과를 확인한다.

스크린샷 2024-05-08 142124

  • 센서에 손을 대보면 1초 간격으로 초음파 센서와 물체와의 거리가 측정되는 것을 확인할 수 있다.

3.2. 초음파 센서값 Publish

라이브러리 설치

  • 'PubSubClient by Nick O’Leary' 를 검색하고 설치한다.

스크린샷 2024-05-08 142944

코드 작성

#include <WiFi.h>          //Wi-Fi 연결 관련 라이브러리
#include <PubSubClient.h>  //MQTT 프로토콜을 사용하기 위한 라이브러리

// 다음 변수들을 당신의 SSID와 비밀번호로 대체하세요.
const char* ssid = "Your_SSID";          // 사용하는 WiFi 네트워크 이름 (SSID)
const char* password = "Your_Password";  // 사용하는 WiFi 네트워크 비밀번호

const int trigPin = 12;  // 초음파 센서의 트리거 핀
const int echoPin = 14;  // 초음파 센서의 에코 핀

// MQTT 브로커 IP 주소를 여기에 입력하세요 (예: "192.168.1.144")
const char* mqtt_server = "test.mosquitto.org";
const int mqttPort = 1883;

WiFiClient espClient;
PubSubClient client(espClient);

long lastMsgTime = 0;

void setup() {
  Serial.begin(115200);
  setup_wifi();
  client.setServer(mqtt_server, mqttPort);

  pinMode(echoPin, INPUT);  // 에코 핀을 입력으로 설정
  // 트리거 핀 초기화
  pinMode(trigPin, OUTPUT);
  digitalWrite(trigPin, LOW);
  delayMicroseconds(2);
}

void setup_wifi() {
  delay(10);
  // Wi-Fi 네트워크에 연결 시작
  Serial.println();
  Serial.print("연결 중인 Wi-Fi: ");
  Serial.println(ssid);

  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("Wi-Fi 연결됨");
  Serial.println("IP 주소: ");
  Serial.println(WiFi.localIP());
}

void reconnect() {
  // 연결이 될 때까지 반복
  while (!client.connected()) {
    Serial.print("MQTT 연결 시도 중...");
    // 랜덤 클라이언트 ID 생성
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);
    Serial.print("클라이언트 ID: ");
    Serial.println(clientId);
    // 연결 시도
    if (client.connect(clientId.c_str())) {
      Serial.println("연결됨");
    } else {
      Serial.print("실패, rc=");
      Serial.print(client.state());
      Serial.println(" 5초 후 다시 시도");
      // 5초 대기 후 다시 시도
      delay(5000);
    }
  }
}

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  // 클라이언트가 메시지를 처리하고 서버와 연결 유지
  client.loop();

  long now = millis();
  if (now - lastMsgTime > 1000) {  //1초 간격
    lastMsgTime = now;
    // 초음파 센서 값을 읽어옵니다.
    float sensorValue = readUltrasonicSensor();
    char sensorString[8];
    dtostrf(sensorValue, 1, 2, sensorString);
    client.publish("user1/esp32/ultra", sensorString);
  }
}

float readUltrasonicSensor() {
  // 트리거 핀을 10 마이크로초 동안 HIGH로 설정하여 초음파를 발사합니다.
  digitalWrite(trigPin, HIGH);
  delayMicroseconds(10);
  digitalWrite(trigPin, LOW);

  // 에코 핀에서 펄스의 지속 시간을 측정합니다.
  pinMode(echoPin, INPUT);
  float duration = pulseIn(echoPin, HIGH);

  // 소리의 속도를 기준으로 거리를 계산합니다.(343m/s로 가정)
  float distance = duration * 0.0343 / 2.0;

  return distance;
}
  • 위 코드에서는 ESP32 디바이스가 초음파 센서 값을 측정하고 MQTT 프로토콜을 사용하여 해당 값을 브로커에 발행하는 역할을 하고 있다. 이를 위한 몇 가지 구성 요소를 살펴보자.

WiFiClient espClient;
PubSubClient client(espClient);
  • 위 코드는 ESP32에서 WiFi 연결 및 MQTT 클라이언트를 설정 한다. WiFiClient는 ESP32와 WiFi 네트워크 간 연결을 관리하고 PubSubClient는 MQTT 프로토콜을 통해 메시지를 발행하고 구독하는 기능을 제공하고 있다.

void reconnect() {
  // 연결이 될 때까지 반복
  while (!client.connected()) {
    Serial.print("MQTT 연결 시도 중...");
    // 랜덤 클라이언트 ID 생성
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);
    Serial.print("클라이언트 ID: ");
    Serial.println(clientId);
    // 연결 시도
    if (client.connect(clientId.c_str())) {
      Serial.println("연결됨");
    } else {
      Serial.print("실패, rc=");
      Serial.print(client.state());
      Serial.println(" 5초 후 다시 시도");
      // 5초 대기 후 다시 시도
      delay(5000);
    }
  }
}
  • 위 코드는 MQTT 브로커에 재연결 하는 함수이다. 클라이언트가 연결되지 않은 경우에만 실행되며, 랜덤한 클라이언트 ID를 생성하여 브로커에 연결을 시도한다.

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  // 클라이언트가 메시지를 처리하고 서버와 연결 유지
  client.loop();

  long now = millis();
  if (now - lastMsgTime > 1000) {  //1초 간격
    lastMsgTime = now;
    // 초음파 센서 값을 읽어옵니다.
    float sensorValue = readUltrasonicSensor();
    char sensorString[8];
    dtostrf(sensorValue, 1, 2, sensorString);
    client.publish("user1/esp32/ultra", sensorString);
  }
}
  • 위 코드는 브로커에 메시지를 발행 하는 부분이다. 초음파 센서 값을 측정하여 "user1/esp32/ultra"라는 주제로 메시지를 발행하고, 해당 메시지에는 센서 값이 포함된다. 1초마다 실행되어 메시지를 주기적으로 발행한다.

if (now - lastMsgTime > 1000) {  //1초 간격
  lastMsgTime = now;
  float sensorValue = readUltrasonicSensor();
  char sensorString[8];
  dtostrf(sensorValue, 1, 2, sensorString);
  client.publish("user1/esp32/ultra", sensorString);
}
  • 위 코드는 메시지를 발행하는 조건을 확인 하는 부분이다. 1초마다 메시지를 발행하기 위해 현재 시간과 이전 메시지 발행 시간을 비교하여 일정 시간 간격을 유지한다.

결과 확인

먼저 MQTT 앱 을 설치한다.

KakaoTalk_20240514_202617624

다음과 같이 설정한 후 CREATE 버튼을 클릭하여 브로커를 생성한다.

KakaoTalk_20240514_202957733(1)

'ADD PANNEL'을 클릭한 후 'text Log'를 선택한다.

스크린샷 2024-05-14 204611

다음과 같이 설정한 후 CREATE 버튼을 클릭한다.

KakaoTalk_20240514_214048300_02

그러면 다음과 같이 위젯에서 결과값을 확인할 수 있다. KakaoTalk_20240514_214048300_01


3.3. 능동 부저 제어

능동 부저

전기 신호를 가지고 직접 소리를 발생시키는 부저이다.

스크린샷 2024-05-13 153948

회로 연결

스크린샷 2024-05-13 154021

코드 작성

const int buzzerPin = 2; //led 핀 번호 설절
 
void setup (){
  pinMode (buzzerPin,OUTPUT );//buzzerPin 을 출력으로 설정
}
 
void loop (){
  digitalWrite (buzzerPin, HIGH ); //buzzerPin 에 HIGH 값 쓰기
  delay (500 ); // 0.5 초 기다리기
  digitalWrite (buzzerPin, LOW ); //buzzerPin 에 LOW 값 쓰기
  delay (1000 ); // 1 초 기다리기
}

결과 확인

  • 능동 부저는 자체적으로 진동체를 가지고 있어 전원만 넣어 주면 소리가 난다.
KakaoTalk_20240513_160750695.mp4

1초 간격으로 소리가 나는 것을 확인할 수 있다.


3.4. 능동 부저 Subcriber

"user1/esp32/buzzer"토픽을 구독하고, 해당 토픽으로 전송된 메시지를 수신하여 부저를 켜고 끄는 Subscriber이다.

코드 작성

#include <WiFi.h>
#include <PubSubClient.h>
 
// 다음 변수들을 당신의 SSID와 비밀번호로 대체하세요.
const char* ssid = "여기에_당신의_SSID_입력";
const char* password = "여기에_당신의_비밀번호_입력";

// MQTT 브로커 IP 주소를 여기에 입력하세요 (예: "192.168.1.144")
const char* mqtt_server = "test.mosquitto.org";
const int mqttPort = 1883 ;
const char * mqttTopic = "user1/esp32/buzzer"; // 사용자에 맞게 변경
const int buzzerPin = 2 ; // 부저에 연결된 GPIO 핀 번호
 
WiFiClient espClient;
PubSubClient client(espClient);

void setup_wifi() {
  delay(10);
  // Wi-Fi 네트워크에 연결 시작
  Serial.println();
  Serial.print("연결 중인 Wi-Fi: ");
  Serial.println(ssid);

  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("Wi-Fi 연결됨");
  Serial.println("IP 주소: ");
  Serial.println(WiFi.localIP());
}
 
void setup(){
    // 시리얼 통신 초기화
  Serial.begin(115200);
  // Wi-Fi 연결 설정
  setup_wifi();

  client.setServer(mqtt_server, mqttPort);
  client.setCallback(callback);
 
  // 부저 핀을 출력 모드로 설정
  pinMode(buzzerPin,OUTPUT);
}
 
void loop(){
  if(!client.connected()){
    reconnect();
  }
  client.loop();//MQTT 클라이언트를 유지하기 위해 호출
}
 
void callback(char*topic , byte *payload , unsigned int length){
  Serial.print("Receivedmessage: ");
  Serial.print(topic);
  Serial.print(" ");
  for(int i = 0 ; i <length;i++){
    Serial.print((char)payload[i]);
  }
  Serial.println();
 
  if(strcmp(topic, mqttTopic)== 0){
    if(payload[0] == '1'){
     // 부저를 켜는 코드 작성
     digitalWrite(buzzerPin, HIGH); 
    }else if(payload[0] == '0'){
     // 부저를 끄는 코드 작성
     digitalWrite(buzzerPin, LOW); 
    }
  }
}
 
void reconnect(){
  while(!client.connected()){
    Serial.print("Connectingto MQTT Broker...");
   String clientId = "ESP32Client-";
   clientId += String(random(0xffff), HEX);
    if(client.connect(clientId.c_str())){
     Serial.println("Connected to MQTT Broker");
     client.subscribe(mqttTopic);
    }else {
     Serial.print("Failed, rc=");
     Serial.print(client.state());
     Serial.println("Retrying in 5 seconds...");
     delay(5000);
    }
  }
}

결과 확인

'부저' 대시 보드를 생성한다. 같은 브로커를 사용할 것이므로 “Add new dashboard”를 선택한다.

KakaoTalk_20240514_212354661

대시보드 이름을 입력하고 CREATE 버튼을 클릭한다. KakaoTalk_20240514_212354661_01

화면 하단 '부저' 대시 보드를 클릭한 후 ‘Add Panel’을 클릭한다. KakaoTalk_20240514_212354661_03

'Switch'를 선택한다. KakaoTalk_20240514_212354661_04

다음과 같이 설정한 후 CREATE 버튼을 클릭한다. KakaoTalk_20240514_212354661_06

  • 위젯에서 토글을 on하면 “user1/esp32/buzzer 1” 메시지가 전달되고 부저가 울리는 것을 확인할 수 있다.
  • 위젯에서 토글을 off하면 “user1/esp32/buzzer 0” 메시지가 전달되고 부저가 꺼지는 것을 확인할 수 있다.

KakaoTalk_20240514_213354199

4. 후기

통신 프로토콜 중 HTTP와 같이 많이 접해봤던 것과 달리 MQTT라는 프로토콜에 대해 배우고 직접 적용해 볼 수 있었던 좋은 시간이었다. Publish(발행)와 Topic(주제) 등 MQTT의 작동 구성 요소를 구체적으로 이해할 수 있게 되었다. MQTT 어플을 이용하여 직접 위젯을 생성하고 나니 더욱 흥미롭게 공부할 수 있었고, 앞으로 IoT 등 응용 프로그램을 접하고 개발할 때 유용하게 도움이 될 것으로 보인다.

⚠️ **GitHub.com Fallback** ⚠️