전체 프로젝트운영 중

Smart Farm

ESP32 센서 → MQTT → 파이프라인 → 규칙 → 펌프 → 다시 기기로 닫히는 온실 실시간 IoT 모니터링과 자동 관수

이 프로젝트가 증명하는 것

  • IoT 데이터가 사라지지 않음을 측정으로 증명: MongoDB 132초 중단에 24/24, 백엔드 20초 중단에 7/7. 수정 전 코드의 유실(12건 중 1건, 배포 때 1건)도 먼저 측정했습니다
  • 보안 감사와 수정: 가입 시 ADMIN 상승, 임의 필드 쓰기, 타 회원 리소스 접근, OTP 무제한 추측, 하드코딩된 JWT 키 등 12개 이상을 프로덕션 악용 여부부터 확인한 뒤 닫았습니다
  • 기기와 백엔드 사이의 루프를 닫음: RELAY_ON/OFF를 MQTT로 보내고, 펌프 시간을 서버(30분)와 펌웨어(1800초) 양쪽에서 제한하며, 자동 규칙이 동시에 두 번 실행되지 않습니다
  • 엔지니어링 규율: 새 테스트 89개는 모두 수정 전 코드에서 실패하는 것을 확인했고, 틀린 가설과 테스트가 부족했던 경우도 기록했습니다
역할
단독 개발: 백엔드, 프론트엔드, ESP32 펌웨어, 배포와 운영 (커밋 171개, 2026-10-04부터 프로덕션)
기간
2026.01 – 2026.10
기술 스택
  • NestJS
  • TypeScript
  • GraphQL
  • MongoDB
  • Redis
  • BullMQ
  • MQTT
  • Socket.IO
  • React
  • Vite
  • Leaflet
  • ESP32
  • MicroPython
  • Jest
  • Docker
  • Caddy

핵심 수치

항목값구분증빙
MongoDB 132초 중단 중 저장된 메시지 (DLQ 0)24 / 24측정값증빙 보기
백엔드 20초 중단 중 도착한 메시지7 / 7측정값증빙 보기
배포 후 10분간 저장된 행 (거부 0)320측정값증빙 보기
보안 probe 거부5 / 5측정값증빙 보기
새 테스트 (모두 수정 전 코드에서 실패 확인)89코드 기준증빙 보기
펌프 최대 가동 시간 (서버)30 min설계 조건증빙 보기
최대 재시도 간격 (30 / 60 / 120초, 3회 뒤 DLQ)120 s설계 조건증빙 보기
MongoDB 컬렉션40코드 기준증빙 보기

해결하려는 문제

온실 센서 값은 끊기거나 튀고, 기기 시간이 틀리기도 하며, DB나 백엔드가 잠시 멈춰도 측정값을 잃으면 안 됩니다. 흙이 마르면 사람이 화면을 보기 전에 펌프가 켜져야 하고, 네트워크가 끊겨도 펌프가 계속 돌아 물이 넘치면 안 됩니다. 남의 농장 데이터에 접근할 수 없어야 하는 것도 기본입니다.

아키텍처

실제 ESP32(DHT22, 정전식 토양 수분, BH1750 조도, 수위, 릴레이 + 12V 펌프)와 프로덕션 데모용 시뮬레이터가 같은 경로로 Mosquitto(TLS 8883, QoS 1)에 보냅니다. NestJS 11 IoT 파이프라인이 기기 API 키, payload 형태, topic과 deviceId 일치, 센서 소유권, 보정을 확인하고 Redis 버퍼(재시도, DLQ)를 거쳐 MongoDB에 원시 값과 90일 TTL time-series로 저장한 뒤 시간·일·월 집계를 만듭니다. 센서별 Z-score 이상 탐지(≥ 2 경고, ≥ 3 심각), 식물 건강 지수, Open-Meteo 날씨(10분 캐시)를 계산하고, 자동화 규칙이 액추에이터 상태를 바꾸면 MQTT로 RELAY_ON/OFF를 기기에 보냅니다. 대시보드는 GraphQL(resolver 51개)과 Socket.IO로 실시간 갱신되며 한국어, 영어, 우즈베크어를 지원합니다.

데이터 모델

핵심 제약 조건과 인덱스

  • 소유권 체인: sensor → device → greenHouse → farm → member (farms.memberId)
  • deviceApiKeys.apiKey UNIQUE, deviceId UNIQUE
  • sensor_data {sensorId:1, recordedAt:-1}, {recordedAt:-1}
  • timeSeriesSensorData: MongoDB time series, granularity seconds, TTL 90일
  • automationRules: findOneAndUpdate 필터의 cooldown 조건으로 원자적 점유
  • passwordReset / emailVerifications.attempts: 최대 5회, crypto.randomInt 6자리
  • Redis: iot:buffer:messages(hash), inflight / retry(zset), dead(list)

핵심 설계 결정

메시지 버퍼: Redis list 대신 hash + zset 2개

상황
이전 버퍼는 list 하나였고, ack가 list 전체를 lrange로 읽어 JSON을 비교해 지웠으며, 재시도가 아예 없어 메시지가 pending에 영원히 남았습니다.
결정
iot:buffer:messages(hash), iot:buffer:inflight(zset, 처리 시작 시각), iot:buffer:retry(zset, 다음 시도 시각), iot:buffer:dead(DLQ). ack는 id 기준 O(1) hdel + zrem.
트레이드오프
재시도 로직을 직접 관리해야 해서 테스트 10개와 실제 redis-server 검증을 붙였습니다.

읽기값마다 done[] 기록

상황
메시지 하나에 읽기값 8개가 있어, 7개 저장 뒤 1개가 실패해 전체를 다시 보내면 중복 행 7개가 생깁니다.
결정
Promise.allSettled 결과에서 성공한 센서를 done[]에 적고 재시도 때 건너뜁니다. 재시도는 원래 receivedAt을 유지합니다.
트레이드오프
—

권한 오류와 DB 오류 구분

상황
DB가 죽어도 validateApiKey가 오류를 던졌고, 코드는 이를 '잘못된 키'로 보고 메시지를 버렸습니다.
결정
UnauthorizedException만 버리고 나머지 오류는 모두 재시도로 보냅니다.
트레이드오프
—

MQTT 지속 세션 + onApplicationBootstrap 연결

상황
clean:true와 매번 새 clientId로는 백엔드가 꺼진 동안의 메시지를 broker가 보관하지 않았고, onModuleInit에서 연결하면 handler 등록 전에 대기 메시지가 도착해 사라졌습니다.
결정
clean:false와 고정 clientId(MQTT_CLIENT_ID로 두 번째 인스턴스 구분)를 쓰고 모든 onModuleInit 뒤인 onApplicationBootstrap에서 연결합니다.
트레이드오프
—

오류 로그 기록은 best-effort

상황
DB가 꺼진 동안 handleSensorSaveError도 DB에 쓰려다 실패해, 한 번의 시도가 두 번 세어졌습니다(4회 대신 2회 만에 DLQ).
결정
로그 기록을 try/catch로 감싸고 실패하면 warn만 남겨 재시도 횟수에 영향을 주지 않습니다.
트레이드오프
—

소유권 검사: 전역 인터셉터 + @OwnedArgs

상황
resolver마다 손으로 검사를 쓰면 새 resolver에서 빠뜨리기 쉽습니다.
결정
전역 OwnershipInterceptor가 인자 이름으로 리소스를 찾아 farm.memberId까지 체인을 올라가고, 여러 의미의 인자는 @OwnedArgs({ 'input.fieldId': 'fieldMap' })로 지정합니다.
트레이드오프
요청마다 소유권 조회가 추가됩니다.

펌프 제한을 양쪽에

상황
Wi-Fi나 broker가 끊기면 RELAY_OFF가 기기에 닿지 않을 수 있고, 물 넘침은 기기 스스로만 막을 수 있습니다.
결정
서버는 autoOffAt을 최대 30분으로 두고, 펌웨어는 ticks_ms 기준 1800초 + 60초 유예 뒤 스스로 끕니다.
트레이드오프
—

자동 규칙: cooldown을 원자적으로 점유

상황
센서 메시지 두 개가 동시에 오면 두 처리 모두 규칙을 실행했습니다.
결정
findOneAndUpdate 필터에 cooldown 조건을 넣어 하나만 규칙을 차지합니다.
트레이드오프
—

recordedAt 대체값

상황
NTP 동기화 전 ESP32가 1970년 시각을 보냈습니다.
결정
기기 시각이 24시간보다 오래됐거나 5분보다 미래면 서버 수신 시각을 쓰고, 초 단위 시각도 자동 인식합니다. 펌웨어는 NTP를 매시간 다시 시도합니다.
트레이드오프
—

개선 전후 측정

개선 전DB outage 58 s → 11 / 12 saved · restart → 1 lost증빙 보기
개선 후DB outage 132 s → 24 / 24 saved, DLQ 0 · restart → 7 / 7증빙 보기
무엇을 바꿨나
hash + zset 버퍼와 30/60/120초 backoff, DLQ, 멈춘 메시지 복구(e49f6d32), DB 오류를 '잘못된 키'와 구분(e49f6d32), MQTT 지속 세션과 onApplicationBootstrap 연결(c4010d09), best-effort 오류 로그(9ddb7dbb). DB 중단 중 handler 오류는 4건에서 0건이 됐습니다. 측정은 sf-outage-check.sh와 sf-restart-check.sh로 프로덕션에서 했고, 시뮬레이터 부하(30초에 약 2건)입니다.

장애 대응 기록

증상
가입 mutation에 memberRole: "ADMIN"을 보내면 ADMIN 회원이 만들어졌습니다(probe before).
원인
CreateMemberInput에 memberRole 필드가 있었고 memberModel.create(input)이 입력 전체를 저장했습니다. 프로필 수정의 MemberUpdateInput도 역할, 상태, 이메일, 비밀번호를 받았습니다. 같은 감사에서 @Roles(MANAGER)는 있지만 @UseGuards(RolesGuard)가 없어 회원 목록이 토큰 없이 열려 있었고({"total":7}), guard가 클래스 수준 @Roles를 읽지 않아 버퍼 관리 resolver도 열려 있었습니다.
해결
입력에서 필드를 없애고 create와 $set에 허용된 필드만 넘깁니다(22a760bd). guard를 붙이고 getAllAndOverride로 handler와 클래스를 함께 읽어, 잘못된 토큰은 401입니다.
재발 방지
고치기 전 프로덕션의 악용 흔적을 먼저 확인했습니다(회원 7명 모두 WORKER/ACTIVE, 권한 계정 0, 의심 호출 0). member-security.spec.ts 13개가 수정 전 코드에서 12개 실패하는 것을 확인했고, 배포 후 외부 probe 5개가 모두 거부됐습니다.

테스트와 품질

테스트 수
89
커버리지
—
CI
—
테스트 도구
Jest 30, ts-jest, 실제 redis-server, Mosquitto 2.0.18, MicroPython stub harness

배포와 운영

배포 환경
VPS 한 대의 docker-compose.prod.yml: mongo(768m), redis(128m, requirepass, AOF), mosquitto(64m, 외부에는 8883 TLS만, Caddy 인증서 read-only, allow_anonymous false, password file + ACL, persistence), backend(768m, internal 네트워크만, mongo/redis가 healthy일 때 기동), web(64m), device-simulator(96m, demo 프로필). 공용 Caddy가 HTTPS를 관리합니다. 배포: git pull --ff-only → build backend → up -d backend. 비밀값(MONGO_PASSWORD, REDIS_PASSWORD, MQTT_BACKEND_PASSWORD, SECRET_TOKEN)은 서버 .env에만 있고 없으면 compose와 백엔드가 시작되지 않습니다.
백업
—
롤백
—
모니터링
관리자용 GraphQL로 버퍼 상태(pending / retrying / deadLetter) 조회, DLQ 메시지 확인과 정리, Message retry scheduled / Message moved to DLQ 로그. 직접 만든 운영 스크립트: sf-incident-check.sh, sf-security-probe-v2.ps1, sf-outage-check.sh, sf-restart-check.sh

스크린샷

한계와 회고

현재 한계

  • 프로덕션 데이터는 device-simulator에서 옵니다. 실제 ESP32는 아직 로컬 broker(1883, TLS 없음)에 연결되며, 프로덕션 연결에는 펌웨어 TLS와 별도 계정이 필요합니다
  • 모든 기기가 smartfarm-device 계정 하나(ACL sf/devices/#)를 써서 기기가 다른 기기 topic에 쓸 수 있습니다. 백엔드가 API 키, topic–deviceId 일치, 센서 소유권으로 막지만, 맞는 해법은 기기별 계정과 sf/devices/%u/# 패턴입니다
  • 기기 API 키가 DB에 해시되지 않았고, repo에 들어간 테스트 키 하나를 교체해야 합니다
  • Mosquitto는 시작할 때만 Caddy 인증서를 읽어, 인증서 갱신 뒤 broker 재로드가 자동화되지 않았습니다
  • 로그인과 가입에 레이트 리밋이 없고(OTP만 제한), GraphQL subscription에 인증이 없으며, 소셜 로그인의 기존 이메일 연결 규칙이 불명확합니다
  • 백엔드 인스턴스 하나: 버퍼 재시도는 zrem으로 원자적이지만 AI 관수 타이머와 전역 모드 플래그는 메모리에 있어 두 번째 인스턴스에서 두 번 실행될 수 있습니다
  • 감사에서 찾았지만 아직 고치지 않은 것: 이상 통계가 rolling이 아님, 월간 집계, setSpeed, heartbeat online/offline 흔들림
  • CI가 없어 테스트를 수동으로 실행하고, account-context-module의 Nest CLI 기본 spec 15개는 의존성 없이 모듈을 만들어 원래부터 실패합니다(수정과 무관, 따로 정리 필요)
  • 증거의 빈틈: 두 수정은 배포가 먼저 되어 프로덕션 before probe를 놓쳤고, 수정 전 상태는 이전 코드에서 실패하는 테스트로만 증명했습니다. 중단 테스트는 시뮬레이터 부하(30초에 약 2건)라 높은 부하의 버퍼 크기(한도 10,000)와 재시도 batch(50)는 측정하지 않았습니다

핵심 코드

현재 상태와 다음 단계

2026-10-10–11 감사에서 수정 커밋 9개를 냈고, 모두 전후 측정이 있습니다. MongoDB를 132초 멈춰도 메시지 24/24가 저장됐고(재시도 6회, DLQ 0), 백엔드를 20초 멈춰도 7/7이 도착했으며 그중 백엔드가 꺼져 있던 동안 보낸 메시지는 broker 큐에서 왔습니다. 수정 전 코드에서는 58초 중단에 12건 중 1건, 배포 재시작에 1건이 사라졌습니다. 보안 점검에서 12개 이상의 취약점을 찾아 닫았습니다: 가입 때 memberRole로 ADMIN 되기(22a760bd), 프로필 수정으로 임의 필드 쓰기, 남의 농장·섹터·알림 접근(c2c5ca26), OTP 무제한 추측과 Math.random OTP, 코드에 박힌 JWT 키(a5a1b62e), 프로덕션에 열린 GraphQL playground. 고치기 전에 프로덕션의 악용 흔적부터 확인했습니다(회원 7명, 권한 계정 0, 의심 호출 0). 또 백엔드가 DB에서만 펌프를 켜고 기기에는 아무 명령도 보내지 않던 문제(3f3eacb2), NTP 동기화 전 ESP32가 1970년 시각을 보내던 문제도 고쳤습니다. 처음 세운 가설(오류가 heartbeat에서 나온다)이 틀렸을 때는 로그로 실제 원인(/sensors)을 찾았고, 45초 중단은 드라이버의 30초 대기를 넘지 못해 오류 경로를 검증하지 못한다는 점도 기록했습니다.