🎯 "설비 센서 데이터를 실시간으로 수집·분석하고, 작업지시·불량 이력을 자동화하는 제조실행시스템(MES) 통합 백엔드"
단순한 데이터 저장을 넘어, Redis와 MySQL의 이중 저장소 전략으로 고속 수집과 영구 보관을 동시에 달성했습니다.
Server-Sent Events(SSE) 기반 실시간 푸시, Redis 카운터 기반 1분 단위 배치 집계, Discord 알림까지 실제 운영 환경을 고려한 아키텍처를 구축했습니다.
🔗 실제 서비스 접속해 보기: https://mes.luma200ok.com
🔗 Swagger API: https://mes.luma200ok.com/swagger-ui.html
🔗 Discord:https://discord.gg/a9VhVFbqnR
🧪 테스트 계정
┣ ID: admin
┣ PW: admin1234
- Backend: Java 21, Spring Boot 3.4.1, Spring Data JPA, Querydsl 5.1.0, Spring Security 6 + JWT (jjwt 0.12.6)
- Database & Cache: MySQL 8, Redis 7
- Frontend: React 19, Vite 8, React Router 7, React Query 5
- Realtime: Server-Sent Events (SSE)
- Export: Apache POI 5.3.0 (Excel), OpenCSV 5.9
- Notification: Discord Webhook
- Docs: Springdoc OpenAPI 2.8.3 (Swagger UI)
- CI/CD: GitHub Actions + EC2 Blue-Green 무중단 배포 (Spring Boot + React 동시 배포, systemd 시뮬레이터 자동 재시작)
┌──────────────────────────────────────────────────────────┐
│ Client / Browser │
└──────────────────────┬───────────────────────────────────┘
│ HTTPS
┌────▼────┐
│ Nginx │ (Blue-Green 프록시)
└────┬────┘
┌─────────────┴─────────────┐
┌────▼────┐ ┌────▼────┐
│ :8085 │ Blue instance │ :8086 │ Green instance
│ Spring │◄──── 전환 ─────►│ Spring │
│ Boot │ │ Boot │
└────┬────┘ └─────────┘
│
┌──────┴──────┐
│ │
┌─▼───┐ ┌──▼──┐
│MySQL│ │Redis│ 센서 버퍼 (TTL 60s) + WO 카운터 + Spring Cache + Rate Limiter
└─────┘ └─────┘
Python Simulator ──POST /api/sensor/data (X-Api-Key 헤더)──► Spring Boot
(3초 간격, 설비 임계값 동적 로드)
- Redis: 3초마다 쏟아지는 센서 데이터를 메모리에 임시 버퍼링하여 DB 쓰기 부하 차단
- MySQL: 1분 평균값 + 작업지시 수량만 영구 저장하여 스토리지 효율 극대화
- Spring Cache (Redis 백엔드): 설비별 임계값 설정을 캐시하여 매 요청마다 발생하는 DB 조회 제거
- SSE: 롱폴링 없이 서버에서 브라우저로 단방향 실시간 스트리밍
의사결정: 센서 수신마다 DB에 goodQty/defectQty를 직접 UPDATE하지 않고, Redis INCR로 카운터를 쌓은 뒤 1분 배치로 DB에 일괄 반영
- 🚨 Issue: 설비 수가 늘어날수록 매 센서 이벤트마다 WorkOrder UPDATE 쿼리가 선형으로 증가
- 💡 Resolution:
- Redis INCR: 센서 수신 시
wo:good:{woId},wo:defect:{woId}키를 원자적으로 증가 (DB 접근 없음) - wo:active:{equipmentId}: 활성 작업지시 ID를 Redis에 캐시하여 매 센서마다 IN_PROGRESS WO 조회 쿼리 제거
- Batch Flush:
WorkOrderQtyFlushScheduler가 1분마다wo:good:*키를 스캔하여 DB에 일괄 반영 후 Redis 키 삭제 - 자동 완료: 계획 수량(1,000개/일) 달성 시 COMPLETED 전환, 당일 WO는 자정 롤오버까지 유지
- 일일 롤오버: 매일 자정 스케줄러가 IN_PROGRESS WO를 현재 수량으로 강제 완료 처리 후 새 WO 생성 → 날짜 기준 생산 실적 집계 보장
- Redis INCR: 센서 수신 시
- 📈 성과: 센서 수신 경로에서 DB 접근을 0회로 감소, 설비 수 증가에도 쓰기 부하 불변
의사결정: 센서 데이터 수신마다 설비별 임계값을 DB에서 조회하는 반복 I/O를 제거하고자 Redis 백엔드 기반
@Cacheable적용
- 🚨 Issue: 매 센서 요청마다 EquipmentConfig를 DB에서 조회하면, 설비 수 증가 시 불필요한 SELECT 쿼리가 선형으로 증가
- 💡 Resolution:
@Cacheable: 설비 설정 최초 조회 시 Redis에 캐싱하여 이후 요청은 DB 접근 없이 처리@CacheEvict: 설비 설정 변경/삭제 시 캐시 즉시 무효화하여 데이터 정합성 유지
- 📈 성과: 설정 조회 쿼리를 캐시 히트 시 0회로 감소, 임계값 판정 로직의 응답 속도 개선
의사결정: 브라우저에서 최신 센서 데이터를 보여주기 위해 폴링 대신 SSE로 서버 주도 푸시 방식 채택
- 🚨 Issue: 3초마다 브라우저가 API를 폴링하면 불필요한 HTTP 오버헤드가 발생하고, 다중 탭에서 중복 요청이 급증
- 💡 Resolution:
- SSE 구독: 브라우저가
/api/sse/subscribe?equipmentId=X로 연결을 맺으면 서버가 데이터 수신 시 자동으로 푸시 - 다중 탭 지원:
ConcurrentHashMap<equipmentId, CopyOnWriteArrayList<SseEmitter>>구조로 동일 설비를 구독 중인 모든 탭에 동시 푸시 - 30분 타임아웃: 장시간 연결 유지 시 리소스 누수 방지
- SSE 구독: 브라우저가
- 📈 성과: 클라이언트 요청 제거로 서버 부하 감소, 센서 수신 즉시 브라우저 반영
의사결정: 설비 삭제 시 관련 이력 데이터를 즉시 하드 삭제하지 않고 소프트 딜리트 후 스케줄러로 단계적 영구 삭제
- 🚨 Issue: 설비 삭제 직후 대량의 SensorHistory를 한 번에 하드 삭제하면 DB 락 및 응답 지연이 발생할 위험
- 💡 Resolution:
- 소프트 딜리트: 설비 삭제 시 관련 SensorHistory의
deletedAt필드에 타임스탬프만 기록 - 스케줄러 정리: 매일 02:00 스케줄러가 30일 경과 데이터를 배치 삭제
- 데이터 격리: 소프트 딜리트된 데이터는 조회에서 자동 제외
- 소프트 딜리트: 설비 삭제 시 관련 SensorHistory의
- 📈 성과: 설비 삭제 응답 시간 단축, DB 부하 분산 및 스토리지 자동 관리
상황: GitHub Actions CI/CD에서 Blue-Green 배포 시 Nginx 포트 전환이 되지 않고 구버전·신버전 서버가 동시에 떠있는 문제 발생
- 🚨 Issue: 신버전 서버(8081)가 기동됐음에도 Nginx가 구버전(8080)을 계속 바라보고, 구버전 프로세스가 종료되지 않는 현상
- 💡 원인 분석:
- 헬스체크
curl에 타임아웃 옵션이 없어, 서버가 포트를 열기 전까지curl이 무한 대기 상태 진입 - SSH 액션 기본 타임아웃(10분) 초과 → 스크립트 강제 종료 → Nginx 전환·구버전 종료 로직이 실행되지 않음
- 신버전 프로세스는
nohup &백그라운드 실행이라 SSH 세션 종료 후에도 좀비 프로세스로 잔존
- 헬스체크
- 💡 Resolution:
- 헬스체크
curl에--connect-timeout 3 --max-time 5옵션 추가로 빠른 실패 후 재시도 처리
- 헬스체크
- 📈 성과: 헬스체크 실패 시 즉시 다음 재시도로 넘어가 SSH 타임아웃 이내에 배포 완료, 포트 전환 정상화
의사결정: 가동률(Availability)·성능률(Performance)·품질률(Quality) 집계를 애플리케이션 레이어에서 계산하지 않고 DB에 위임하기 위해 Querydsl 도입
- 🚨 Issue: JPA 메서드 네이밍만으로 집계(sum, avg, count)와 날짜 범위·설비 필터를 조합한 동적 쿼리를 표현하기 어려움
- 💡 Resolution:
- Querydsl Projections:
QWorkOrderStatisticsDto,QSensorStatisticsDto로 집계 결과를 DTO에 직접 매핑, 엔티티 불필요 조회 제거 - BooleanExpression: 날짜 범위, equipmentId 조건을 타입 안전하게 조합
- Repository-Custom-Impl 3단 구조: JPA 인터페이스와 Querydsl 구현체를 분리하여 유지보수성 확보
- Querydsl Projections:
- 📈 성과: OEE 집계 로직 DB 위임으로 애플리케이션 메모리 부하 절감, 컴파일 타임 쿼리 검증으로 런타임 오류 사전 차단
의사결정: 무차별 대입 공격 방어를 위해 Redis 카운터로 로그인 실패 횟수를 추적하고, Redis 장애 시 서비스 중단을 방지하는 Fail-Open 방식 채택
- 🚨 Issue: Redis 장애 시 Rate Limiter가
RedisConnectionFailureException을 전파하면 정상 사용자 로그인까지 500 오류로 차단 - 💡 Resolution:
- Fail-Open:
RedisConnectionFailureException발생 시 경고 로그만 남기고 로그인을 허용 - Redis 키:
login:fail:{username}— TTL 15분, MAX 5회 실패 시TOO_MANY_LOGIN_ATTEMPTS(429)반환 - 자동 초기화: 로그인 성공 시 실패 카운터 즉시 삭제
- Fail-Open:
- 📈 성과: Redis 다운 시에도 인증 서비스 정상 운영, 브루트포스 공격 방어 가능
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| POST | /api/auth/login |
❌ | 로그인 (JWT 발급) |
| POST | /api/auth/register |
✅ ADMIN | 사용자 등록 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| POST | /api/sensor/data |
X-Api-Key 헤더 |
센서 데이터 수신 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/equipment |
✅ | 설비 목록 조회 |
| GET | /api/equipment/{equipmentId} |
✅ | 설비 단건 조회 |
| POST | /api/equipment |
✅ | 설비 등록 |
| DELETE | /api/equipment/{equipmentId} |
✅ | 설비 삭제 (소프트 딜리트) |
| GET | /api/equipment-config/{equipmentId} |
✅ | 설비 임계값 조회 |
| POST | /api/equipment-config |
✅ | 설비 임계값 설정 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/work-orders |
✅ | 작업지시 목록 조회 |
| POST | /api/work-orders |
✅ | 작업지시 등록 |
| PATCH | /api/work-orders/{id}/status |
✅ | 상태 전이 |
| GET | /api/work-orders/{id}/history |
✅ | 상태 변경 이력 |
| POST | /api/work-orders/upload |
✅ | Excel 일괄 등록 |
| GET | /api/work-orders/template |
✅ | Excel 템플릿 다운로드 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/defects |
✅ | 불량 목록 조회 |
| POST | /api/defects |
✅ | 불량 등록 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/dashboard/oee |
✅ | OEE 통계 조회 |
| GET | /api/dashboard/sensor-history |
✅ | 센서 이력 기간 조회 |
| GET | /api/dashboard/export/excel |
✅ | Excel 내보내기 |
| GET | /api/dashboard/export/csv |
✅ | CSV 내보내기 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/alarms/equipment/{equipmentId} |
✅ | 설비별 알람 이력 조회 |
| GET | /api/alarms |
✅ | 기간별 알람 이력 조회 (from, to 쿼리 파라미터) |
| GET | /api/alarms/equipment/{equipmentId}/count |
✅ | 설비 최근 알람 횟수 조회 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| POST | /api/users |
✅ ADMIN | 사용자 등록 |
| GET | /api/users |
✅ ADMIN | 전체 사용자 조회 |
| DELETE | /api/users/{userId} |
✅ ADMIN | 사용자 삭제 |
| Method | URI | 인증 | 설명 |
|---|---|---|---|
| GET | /api/sse/subscribe |
❌ | SSE 실시간 구독 |
📄 Swagger UI:
https://mes.luma200ok.com/swagger-ui.html
- Java 21
- MySQL —
localhost:3306/ DB:mes_db - Redis —
localhost:6379
docker-compose up -d| 변수 | 필수 | 기본값 (로컬) | 설명 |
|---|---|---|---|
DB_USERNAME |
❌ | root |
MySQL 사용자명 |
DB_PASSWORD |
❌ | test1234 |
MySQL 비밀번호 |
JWT_SECRET |
✅ (prod) | 로컬 기본값 제공 | JWT 서명 키 (256bit 이상) |
REDIS_PASSWORD |
❌ | (없음) | Redis 비밀번호 |
DISCORD_WEBHOOK_URL |
❌ | (없음) | Discord 알림 웹훅 URL |
MES_SENSOR_API_KEY |
✅ (prod) | mes-sensor-local-key |
센서 API 인증 키 |
⚠️ 운영 환경에서는 반드시JWT_SECRET과MES_SENSOR_API_KEY를 안전한 값으로 설정하세요.
./gradlew bootRun --args='--spring.profiles.active=local'애플리케이션 최초 시작 시 자동 생성됩니다.
| 항목 | 값 |
|---|---|
| username | admin |
| password | admin1234 |
⚠️ 로컬 개발 및 테스트 전용 계정입니다. 실제 운영 환경에서는 반드시 변경하세요.
cd simulator
pip install requests
python simulate.py| 환경변수 | 기본값 | 설명 |
|---|---|---|
MES_BASE_URL |
http://localhost:8080 |
서버 URL |
MES_ADMIN_ID |
admin |
로그인 계정 ID |
MES_ADMIN_PW |
admin1234 |
로그인 계정 PW |
MES_SENSOR_API_KEY |
mes-sensor-local-key |
센서 엔드포인트 API 키 |
SENSOR_INTERVAL |
3 |
전송 주기 (초) |
FAULT_RATE |
0.001 |
이상 데이터 비율 (0.0 ~ 1.0) |
RANDOM_SEED |
42 |
난수 시드 (재현용) |
| 코드 | HTTP | Enum | 설명 |
|---|---|---|---|
| C001 | 400 | INVALID_INPUT_VALUE |
입력값 오류 |
| C002 | 500 | INTERNAL_SERVER_ERROR |
서버 내부 오류 |
| C003 | 401 | UNAUTHORIZED |
인증 필요 |
| C004 | 403 | FORBIDDEN |
접근 권한 없음 |
| A001 | 401 | INVALID_TOKEN |
유효하지 않은 토큰 |
| A002 | 401 | EXPIRED_TOKEN |
만료된 토큰 |
| A003 | 401 | INVALID_CREDENTIALS |
아이디 또는 비밀번호 오류 |
| A004 | 429 | TOO_MANY_LOGIN_ATTEMPTS |
로그인 시도 횟수 초과 (15분 잠금) |
| E001 | 404 | EQUIPMENT_NOT_FOUND |
설비 없음 |
| E002 | 409 | EQUIPMENT_ID_DUPLICATE |
중복 설비 ID |
| E003 | 404 | EQUIPMENT_CONFIG_NOT_FOUND |
설비 임계값 없음 |
| W001 | 404 | WORK_ORDER_NOT_FOUND |
작업지시 없음 |
| W002 | 400 | WORK_ORDER_INVALID_STATUS_TRANSITION |
허용되지 않는 상태 전이 |
| W003 | 400 | WORK_ORDER_ALREADY_COMPLETED |
이미 완료된 작업지시 |
| D001 | 404 | DEFECT_NOT_FOUND |
불량 정보 없음 |
| D002 | 400 | DEFECT_QTY_EXCEEDS_COMPLETED |
불량 수량이 완료 수량 초과 |
| D003 | 400 | DEFECT_QTY_EXCEEDS_PLANNED |
양품 + 불량 수량이 계획 수량 초과 |
| S001 | 404 | SENSOR_DATA_NOT_FOUND |
센서 데이터 없음 |
| U001 | 404 | USER_NOT_FOUND |
사용자 없음 |
| U002 | 409 | USER_ALREADY_EXISTS |
중복 사용자명 |
에러 응답 포맷:
{
"timestamp": "2026-04-22T10:30:00",
"code": "A003",
"message": "아이디 또는 비밀번호가 올바르지 않습니다."
}최근 업데이트 2026.04.22 — README V1.5.0