Home IoT Hub Series · 23

[23편] AI 집사 - Physical AI의 시작, LLM과 대화하는 스마트홈

[23편] AI 집사 - Physical AI의 시작, LLM과 대화하는 스마트홈 대표 이미지

[1~22편까지의 흐름]: ESP32 두뇌 하나, 센서 노드 19대, 터치 TFT까지 구성했습니다. 집 상태를 실시간으로 보고, 터치로 제어하고, PIR과 온도 조건으로 에어컨을 자동 제어하는 단계까지 진행했습니다.

하지만 이 모든 자동화는 결국 "미리 정해 둔 규칙" 안에서만 동작합니다. 온도가 26℃를 넘으면 에어컨을 켜고, 문이 열리면 부저를 울리는 방식입니다. 규칙 밖의 상황을 스스로 해석하지는 못합니다.

이번 편에서 할 일: 집 안 센서값을 AI와 연결합니다. LLM이 센서값을 읽고 상황을 판단한 뒤, 필요한 행동을 제안하거나 명령으로 이어지는 구조를 확인합니다.

"바깥 온도가 32℃인데 현관문이 10분째 열려 있어요. 거실 온도가 24.5℃에서 26.1℃로 오르는 중입니다. 에어컨이 계속 돌고 있지만 문 때문에 효율이 떨어지고 있어요. 문을 닫으시겠습니까?"

센서값 20개를 한 번에 전달하면 AI가 패턴을 읽고 조언을 생성합니다. 이는 단순 규칙 기반 자동화만으로는 처리하기 어려운 영역입니다.

GPT만 가능한 방식은 아닙니다. 이 글에서 다루는 방식은 OpenAI 호환 API를 쓰는 여러 모델에 적용할 수 있습니다. Claude, Gemini, 로컬에서 Ollama로 돌리는 Llama, LM Studio로 집 PC에서 공짜로 굴리는 오픈소스 모델까지 — 인증 방식만 맞추면 전부 똑같이 동작한다. 모델명과 엔드포인트 URL을 환경에 맞게 바꾸면 됩니다.

개념 정리 — 어려운 말, 쉬운 말

코드를 보기 전에 몇 가지 용어를 먼저 정리합니다. 낯선 단어가 나오더라도 구조와 역할만 이해하면 따라갈 수 있습니다.

용어실무적으로 이해할 의미
API (Application Programming Interface)프로그램끼리 데이터를 주고받는 약속입니다. 정해진 형식으로 요청하면 정해진 형식으로 응답을 받는 구조입니다.
API 키 (Key)외부 API를 사용할 권한을 증명하는 비밀 키입니다. 비밀번호처럼 다루어야 하며 외부에 노출하면 안 됩니다.
엔드포인트 (Endpoint)AI에게 요청을 보내는 인터넷 주소입니다. https://api.openai.com/v1/chat/completions 같은 URL
LLM (Large Language Model)GPT, Claude, Gemini 같은 대형 언어 모델을 통틀어 부르는 말입니다.
TLS (Transport Layer Security)인터넷 통신을 암호화해 중간에서 내용을 훔쳐보기 어렵게 만드는 기술입니다. 브라우저 주소창의 자물쇠 아이콘과 관련된 보안 계층입니다.
Bearer 토큰"나 API 키 가지고 있어"라고 서버에 알리는 HTTP 헤더 규격. Authorization: Bearer sk-xxxx 같은 형식
JSON데이터를 주고받는 표준 포맷. {"key": "value"} 같은 중괄호 덩어리
setInsecure()"TLS 검증 꺼버릴게." 상대방이 신뢰할 수 있는 서버인지 확인하지 않겠다는 뜻이므로 위험합니다.
프롬프트 (Prompt)AI에게 전달하는 질문과 지시문입니다. 구체적으로 작성할수록 안정적인 답변을 받을 가능성이 높습니다.

API 키 발급 방법

OpenAI (GPT-3.5/GPT-4)

1. platform.openai.com 접속 → 회원가입/로그인 2. 우측 상단 프로필 → View API keys 3. Create new secret key → 이름 아무거나 입력 → 키 생성 4. 생성된 키(sk-proj-... 또는 sk-...로 시작)를 복사해둔다. 이 창을 닫으면 다시 볼 수 없다 5. 좌측 메뉴 Settings → Billing → Add payment method → 카드 등록 → Usage limits 설정 6. 월 한도 $5로 걸어두면 실수로 요금 폭탄 맞을 일이 없다. GPT-3.5-turbo 기준 $5면 수천 번 호출 가능

⚠ 생성된 키를 텍스트 파일에 복사해둘 수는 있지만, GitHub나 블로그처럼 공개된 위치에 올리면 안 됩니다. 공개되는 순간 다른 사람이 해당 키로 API를 호출해 비용이 발생할 수 있습니다.

Claude (Anthropic)

console.anthropic.com → 회원가입 → API Keys → Create Key. 엔드포인트를 https://api.anthropic.com/v1/messages로 변경하면 됩니다.

Gemini (Google)

aistudio.google.comGet API Key. 무료 할당량이 넉넉하다.

Ollama (로컬, 무료)

집 PC에 ollama.com 설치 → ollama run llama3엔드포인트 http://localhost:11434/v1/chat/completions. API 키 없이 로컬에서 테스트할 수 있으며, 외부 인터넷 연결 없이도 실험할 수 있습니다.

방법은 세 가지

방법구조비용보안난이도
A: ESP32 직결ESP32 → AI API 직접무료(기존 장비)❌ 위험⭐⭐
B: 중간 브로커ESP32 → 구형 PC/맥북 → AI API무료(고물 노트북)⚠ 보통⭐⭐
C: 라즈베리파이 통합Pi가 두뇌+AI+디스플레이 전부2~8만원✅ 안전

이번 편은 Method A입니다. ESP32 하나로 추가 장비 없이 AI API에 직접 연결합니다. 목적은 실사용 권장이 아니라, 이 방식의 한계를 직접 확인하는 데 있습니다.

이 시리즈의 큰 그림: 단계별로 배우는 AI 연동

이 과정이 Physical AI(피지컬 AI)로 가는 기본 흐름입니다. 일반 챗봇은 화면 안에서 대화하지만, 피지컬 AI는 센서로 현실 세계를 감지하고, AI가 상황을 판단하며, 액추에이터로 실제 환경에 개입합니다. 지금까지 만든 20개 센서는 AI의 감각 기관에 해당하고, IR LED와 릴레이는 AI가 행동하는 수단에 가깝습니다. 이번 시리즈는 이 감각 기관과 행동 수단을 AI 두뇌에 연결하는 과정입니다.

피지컬 AI라고 하면 테슬라 옵티머스나 현대 아틀라스 같은 인간형 로봇만 떠올리기 쉽습니다. 하지만 실제 제작 관점에서는 작은 ESP32 보드 하나가 집 안의 온도와 문 열림을 감지하고, AI의 판단을 받아 에어컨을 켜는 구조도 피지컬 AI의 출발점이 될 수 있습니다.

이 글은 단순히 "ESP32에 GPT를 연결합니다" 수준에서 끝나는 튜토리얼이 아닙니다. 단계별 학습 구조이며, 각 Method는 이전 단계의 한계를 하나씩 해결합니다.

┌─────────────────────────────────────────────────────────────┐
│                    AI 집사: 3단계 진화                        │
├──────────────┬──────────────────┬───────────────────────────┤
│              │  Method A (23편) │  Method B (24편)           │
│  누가 AI를    │  ESP32 → GPT     │  ESP32 → PC → GPT          │
│  호출 방식    │  직접 연결        │  중간 서버 경유             │
├──────────────┼──────────────────┼───────────────────────────┤
│  응답은       │  시리얼 모니터    │  TFT + PC 화면             │
│  어디에?      │  (PC 연결 시만)   │  + 텔레그램 푸시            │
├──────────────┼──────────────────┼───────────────────────────┤
│  밖에서도      │  ❌ 불가능       │  ✅ 텔레그램으로             │
│  볼 수 있나?   │                  │  폰에서 실시간 확인         │
├──────────────┼──────────────────┼───────────────────────────┤
│  AI가 직접     │  ❌ 말만 함       │  ✅ 실제로 에어컨 ON/OFF      │
│  제어 방식     │  (사람이 실행)    │  (PI가 대신 실행)           │
├──────────────┼──────────────────┼───────────────────────────┤
│  보안          │  ❌ 키 노출 위험  │  ✅ 키는 PC에만 보관         │
├──────────────┼──────────────────┼───────────────────────────┤
│  추가 비용     │  0원              │  0원 (고물 PC 재활용)       │
└──────────────┴──────────────────┴───────────────────────────┘

Method A를 먼저 다루는 이유: 이 방식의 한계를 직접 확인하기 위해서입니다. API 키 노출, 한글 폰트 처리, TLS 통신 부담 같은 문제를 실제로 겪어 보면 Method B가 왜 필요한지 명확해집니다.

Method A: ESP32에서 AI API 직접 호출

왜 위험한가 — 먼저 확인해야 할 부분

ESP32는 마이크로컨트롤러입니다. 쉽게 말하면 작은 두뇌가 들어간 소형 컴퓨터에 가깝습니다. 다만 API 키와 외부 HTTPS 통신을 직접 처리하기에는 다음과 같은 보안상 한계가 있습니다.

1. API 키가 플래시 메모리에 평문으로 저장됩니다. — 누군가 ESP32 보드를 확보한 뒤 esptool.py read_flash 같은 명령으로 플래시를 읽으면 API 키가 노출될 수 있습니다. GPT 계열 API 키는 실제 비용과 연결되므로 공개 코드나 장치 펌웨어에 직접 넣지 않는 것이 안전합니다.

2. TLS 인증서 검증이 제한적이다. — TLS란 인터넷 통신을 암호화해서 중간에 누가 훔쳐보지 못하게 하는 기술이다(자물쇠 아이콘). ESP32의 WiFiClientSecure는 이 TLS를 구현하긴 하는데, 루트 CA 인증서라는 걸 직접 임베딩해야 해서 만료되면 펌웨어를 다시 올려야 한다. setInsecure()로 검증을 꺼버리는 예제가 인터넷에 널렸는데, 이건 상대방이 진짜 OpenAI 서버인지 확인하지 않겠다는 뜻이다. 중간자 공격(MITM)에 완전히 무방비 상태.

3. OTA 업데이트가 없으면 펌웨어 수정이 번거롭다. — OTA란 "Over The Air", 즉 Wi-Fi로 무선 펌웨어 업데이트를 하는 기술입니다. OTA가 없으면 API 엔드포인트가 바뀌거나 키를 교체해야 할 때마다 USB 케이블을 꽂고 다시 업로드해야 합니다.

요약: 4,000원짜리 ESP32에 한 달 수십만 원 나갈 수 있는 API 키를 박아두는 건 자살행위다. 단, 이걸 직접 해보면 왜 위험한지 몸으로 깨닫게 된다. 학습 목적으로만 따라 해 보고, 실사용은 절대 금지.

준비물

부품가격비고
ESP32 DevKit4,000원22편에서 이미 있음
AI API 키종량제GPT-3.5-turbo 기준 1,000토큰당 $0.0015
추가 부품 없음순수 소프트웨어 확장

전체 코드

이 코드는 ESP32가 5분마다 20개 센서값을 모아 AI API로 보내고, 응답을 시리얼 모니터에 출력하는 예제입니다. API 키를 넣으면 동작을 확인할 수 있습니다.

[23편] AI 집사 - Physical AI의 시작, LLM과 대화하는 스마트홈 - 전체 코드
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
// #include <ArduinoJson.h>  // 제대로 된 JSON 파싱을 원하면 주석 해제

const char* ssid     = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

// ⚠⚠⚠ 절대 실제 API 키를 여기에 하드코딩하지 않습니다 ⚠⚠⚠
// 테스트 후 즉시 폐기할 임시 키만 사용할 것
const char* OPENAI_API_KEY = "sk-your-temporary-test-key";
const char* OPENAI_HOST    = "api.openai.com";
const int    OPENAI_PORT   = 443;
const char* OPENAI_URL     = "/v1/chat/completions";

// ⚠ 경고: setInsecure() = TLS 검증 OFF = 중간자 공격에 취약
WiFiClientSecure client;

// 슬롯 20개 (22편과 동일)
#define SLOT_COUNT 20
String slot[SLOT_COUNT] = {
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--"
};

const char* label[SLOT_COUNT] = {
  "Living Temp","Living Hum","Outside Temp","AC",
  "Boiler","Stove","Gas Leak","Fire",
  "Front Door","Door Lock","Living Light","Fridge Temp",
  "CO2","Dust PM2.5","Power","Water",
  "Hot Water","Rain","Bath Hum","Pet Bowl"
};

unsigned long lastAI = 0;
#define AI_INTERVAL 300000 // 5분

// =============================================
// setup
// =============================================
void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nConnected! IP: " + WiFi.localIP().toString());

  // ⚠ 보안 위험: TLS 인증서 검증 비활성화
  client.setInsecure();
  Serial.println("⚠ WARNING: TLS verification DISABLED");
  Serial.println("⚠ DO NOT use with real API keys!");
  Serial.println();
}

// =============================================
// loop
// =============================================
void loop() {
  unsigned long now = millis();
  if (now - lastAI < AI_INTERVAL) { delay(1000); return; }
  lastAI = now;

  // 20개 센서 데이터로 프롬프트 생성
  String prompt = buildPrompt();

  // GPT API 호출
  String response = callGPT(prompt);

  // 응답 출력
  Serial.println("=== AI RESPONSE ===");
  Serial.println(response);
  Serial.println("====================");

  delay(1000);
}

// =============================================
// 프롬프트 빌드
// =============================================
String buildPrompt() {
  String data = "Current smart home sensor data:\n\n";
  for (int i = 0; i < SLOT_COUNT; i++) {
    data += String(i + 1) + ". " + String(label[i]) + ": " + slot[i] + "\n";
  }

  // 모델명: gpt-3.5-turbo, gpt-4o-mini, claude-3-haiku, llama3 등
  // OpenAI 외 API는 아래 "다른 모델로 바꾸려면?" 참고  String prompt = "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{"
    "\"role\":\"system\","
    "\"content\":\"You are an AI home assistant. Analyze the sensor data "
    "and provide a brief English summary: 1) Any warnings? 2) Any anomalies? "
    "3) Recommended actions. Keep it under 3 sentences.\"},"
    "{\"role\":\"user\","
    "\"content\":\"" + data + "\"}],"
    "\"max_tokens\":150}";

  return prompt;
}

// =============================================
// AI API 호출
// =============================================
String callGPT(String body) {
  if (!client.connect(OPENAI_HOST, OPENAI_PORT)) {
    Serial.println("Connection failed!");
    return "ERROR: Connection failed";
  }

  String request = String("POST ") + OPENAI_URL + " HTTP/1.1\r\n"
    "Host: " + OPENAI_HOST + "\r\n"
    "Content-Type: application/json\r\n"
    "Authorization: Bearer " + String(OPENAI_API_KEY) + "\r\n"
    "Content-Length: " + String(body.length()) + "\r\n"
    "Connection: close\r\n"
    "\r\n"
    + body;

  client.print(request);

  // 응답 대기 (최대 10초)
  unsigned long timeout = millis();
  while (!client.available() && millis() - timeout < 10000) {
    delay(50);
  }

  // 헤더 스킵 → 본문만 읽기
  String resp;
  bool inBody = false;
  while (client.available()) {
    String line = client.readStringUntil('\n');
    if (line == "\r" && !inBody) { inBody = true; continue; }
    if (inBody) resp += line;
  }
  client.stop();

  // JSON에서 content 추출
  String content = parseContent(resp);
  return content;
}

// =============================================
// JSON 응답에서 message.content만 추출
// =============================================
String parseContent(String json) {
  int pos = json.indexOf("\"content\":\"");
  if (pos < 0) return json; // 파싱 실패 → 원본 반환

  pos += 11; // "\"content\":\"" 이후
  String result;
  while (pos < json.length()) {
    char c = json[pos];
    if (c == '\\' && json[pos + 1] == '"') {
      result += '"'; pos += 2; continue;
    }
    if (c == '\\' && json[pos + 1] == 'n') {
      result += '\n'; pos += 2; continue;
    }
    if (c == '"') break;
    result += c;
    pos++;
  }
  return result;
}
다른 모델로 바꾸는 기준 OPENAI_HOST, OPENAI_URL, buildPrompt() 안의 "model" 값을 사용하는 모델에 맞게 조정합니다. Claude는 api.anthropic.com/v1/messages, Gemini는 generativelanguage.googleapis.com 계열 엔드포인트를 확인합니다. Ollama 로컬 테스트는 192.168.x.x:11434, /v1/chat/completions, "llama3"처럼 구성할 수 있습니다. 순서와 인증 헤더 형식은 각 API 문서를 기준으로 확인합니다.

동작 확인

1. OPENAI_API_KEY에 테스트용 키를 넣고 업로드. (위의 "API 키 발급 방법" 참고) 2. 시리얼 모니터 115200bps. 3. ESP32가 5분마다 이런 응답을 출력한다:

=== AI RESPONSE ===
Indoor temp 24.5C, comfortable. CO2 at 780ppm, slightly
elevated - consider ventilation if still high in 30 min.
All warning sensors at 0 - no alerts.

> **참고: 이 데이터는 ChatGPT 채팅창에 나타나지 않는다.** API 호출은 [chat.openai.com](https://chat.openai.com)의 대화창과 완전히 별개다. API는 기계가 기계에게 직접 질문하는 통로일 뿐, 사용자가 웹에서 GPT와 나누는 대화에는 어떤 흔적도 남지 않는다. API 사용 내역은 [platform.openai.com/usage](https://platform.openai.com/usage)에서 토큰 사용량으로만 확인할 수 있다.
====================

4. 테스트 끝나면 즉시 API 키를 폐기하라. OpenAI 대시보드 → API Keys → 해당 키 옆 휴지통 아이콘 클릭 → Revoke. ESP32 플래시에 평문으로 남아 있기 때문이다.

참고: AI 응답은 영어로 요청합니다. 이 코드의 프롬프트는 AI에게 "영어로 요약해 주세요"라고 지시합니다(provide a brief English summary). ESP32의 Adafruit GFX 라이브러리는 한글 폰트를 지원하지 않아 TFT 화면에 한글을 그릴 수 없습니다. 그래서 이번 편에서는 AI 응답을 영문으로 받고, 시리얼 모니터와 TFT 양쪽에서 자연스럽게 표시되도록 구성했습니다. 한글을 TFT에서 보고 싶다면 Method C(라즈베리파이) 구조로 가야 합니다. 라즈베리파이는 OS 레벨에서 한글 폰트를 지원하므로, AI가 보내는 한글 응답을 더 자연스럽게 렌더링할 수 있습니다.

현실적인 문제점

위 코드는 동작합니다. 하지만 실제 운영에 쓰기에는 몇 가지 현실적인 문제가 있습니다.

1. API 키를 소스코드에 하드코딩해야 합니다. — 하드코딩이란 "비밀번호를 코드에 직접 써넣는 것"을 말합니다. 환경변수 같은 방식을 ESP32 단독 구조에 적용하기는 어렵습니다. GitHub에 올리면 키가 공개 저장소에 그대로 노출될 수 있습니다.

2. WiFiClientSecure가 무겁습니다.TLS 핸드셰이크에 10~20초가 걸릴 수 있고, RAM 사용량도 크게 올라갑니다. 22편의 TFT 렌더링과 동시에 실행하면 메모리 부족으로 리셋될 수 있습니다.

3. JSON 파싱이 허접하다. — JSON 파싱이란 "AI가 보낸 긴 답장에서 필요한 부분만 골라내는 작업"이다. 위 parseContent()는 운이 좋아야 동작하는 수준. 제대로 하려면 ArduinoJson 라이브러리의 동적 할당이 필요하고, 그러면 ESP32 Heap이 금방 바닥난다.

4. 센서값이 --일 때 AI가 부정확한 답변을 할 수 있습니다. — "Pet Bowl: --" 같은 초기값이 들어가면 LLM이 "반려견이 없는 것 같다" 같은 이상한 답변을 할 수 있습니다. 프롬프트 필터링이 필요합니다.

Method A 결론

항목평가
작동 여부✅ 된다
추천 여부❌ 절대 실사용 금지
학습 가치✅ 왜 API 키를 하드코딩하면 안 되는지 뼈저리게 느낀다
다음 단계이 경험을 발판 삼아 Method B·C로 넘어간다

핵심 교훈: 마이크로컨트롤러는 센서를 읽고 액추에이터를 제어하는 데 특화된 장치입니다. API 키를 안전하게 저장하고 TLS 통신을 안정적으로 처리하는 역할은 PC나 서버 쪽에서 담당하는 편이 안전합니다.

외부에서는 어떻게 확인할 수 있을까? 현재 구성에서는 AI 응답이 시리얼 모니터와 TFT 화면에만 출력됩니다. 외부에서 집 상태를 확인하려면 텔레그램 봇 연동이 필요합니다. AI 분석 결과를 스마트폰으로 보내고, 사용자의 명령을 받아 실제 장치를 제어하는 구조는 Method B·C에서 함께 다룹니다.

다음 편 예고

Method B — 구형 PC를 AI 브로커로 사용합니다. 오래된 Windows 노트북이나 항상 켜 두는 Mac에 Python Flask 서버를 실행하면 ESP32의 API 키 노출 문제를 줄일 수 있습니다. API 키는 PC에만 저장하고, ESP32는 HTTP로 센서 데이터를 보내고 명령만 받아오는 구조로 단순화합니다.