# Java Spring(전자정부프레임워크) 센서 시스템 개발 가이드 - Part 2

## 🔧 계층별 구현

### 1. DTO 클래스

#### **SensorDataRequest.java**
```java
package com.sensor.spring.dto;

import lombok.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SensorDataRequest {
    
    @NotNull
    private String deviceId;
    
    private Integer nodeId;
    private Double temperature;
    private Double humidity;
    private Double longitude;
    private Double latitude;
    private LocalDateTime recordedTime;
}
```

#### **SensorDataResponse.java**
```java
package com.sensor.spring.dto;

import lombok.*;
import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SensorDataResponse {
    
    private Long id;
    private String deviceId;
    private Integer nodeId;
    private Double temperature;
    private Double humidity;
    private Double longitude;
    private Double latitude;
    private LocalDateTime recordedTime;
    private LocalDateTime receivedTime;
    private LocalDateTime createdAt;
}
```

### 2. Repository 계층

#### **SensorReadingRepository.java**
```java
package com.sensor.spring.repository;

import com.sensor.spring.entity.SensorReading;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Repository
public interface SensorReadingRepository extends JpaRepository<SensorReading, Long> {
    
    List<SensorReading> findByDeviceIdOrderByRecordedTimeDesc(String deviceId);
    
    Optional<SensorReading> findFirstByDeviceIdOrderByRecordedTimeDesc(String deviceId);
    
    @Query("SELECT sr FROM SensorReading sr WHERE sr.deviceId = :deviceId " +
           "AND sr.recordedTime BETWEEN :startTime AND :endTime " +
           "ORDER BY sr.recordedTime DESC")
    List<SensorReading> findByDeviceIdAndTimeRange(
        @Param("deviceId") String deviceId,
        @Param("startTime") LocalDateTime startTime,
        @Param("endTime") LocalDateTime endTime
    );
    
    @Query("SELECT COUNT(sr) FROM SensorReading sr WHERE sr.deviceId = :deviceId " +
           "AND sr.recordedTime >= :since")
    Long countByDeviceIdSince(@Param("deviceId") String deviceId, 
                              @Param("since") LocalDateTime since);
}
```

#### **DeviceRepository.java**
```java
package com.sensor.spring.repository;

import com.sensor.spring.entity.Device;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface DeviceRepository extends JpaRepository<Device, Long> {
    
    Optional<Device> findByDeviceId(String deviceId);
    
    List<Device> findByStatus(Device.DeviceStatus status);
    
    List<Device> findByLastSeenBefore(java.time.LocalDateTime time);
}
```

### 3. Service 계층

#### **SensorDataService.java**
```java
package com.sensor.spring.service;

import com.sensor.spring.dto.SensorDataRequest;
import com.sensor.spring.dto.SensorDataResponse;
import com.sensor.spring.entity.SensorReading;
import com.sensor.spring.repository.SensorReadingRepository;
import com.sensor.spring.repository.DeviceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Slf4j
public class SensorDataService {
    
    private final SensorReadingRepository sensorReadingRepository;
    private final DeviceRepository deviceRepository;
    private final RedisCacheService redisCacheService;
    
    @Transactional
    public SensorDataResponse saveSensorData(SensorDataRequest request) {
        log.info("Saving sensor data for device: {}", request.getDeviceId());
        
        // 디바이스 상태 업데이트
        updateDeviceLastSeen(request.getDeviceId());
        
        // 센서 데이터 저장
        SensorReading reading = SensorReading.builder()
            .deviceId(request.getDeviceId())
            .nodeId(request.getNodeId())
            .temperature(request.getTemperature())
            .humidity(request.getHumidity())
            .longitude(request.getLongitude())
            .latitude(request.getLatitude())
            .recordedTime(request.getRecordedTime())
            .receivedTime(LocalDateTime.now())
            .build();
        
        SensorReading saved = sensorReadingRepository.save(reading);
        
        // Redis 캐시 업데이트
        redisCacheService.updateLatestData(request.getDeviceId(), saved);
        
        return convertToResponse(saved);
    }
    
    @Cacheable(value = "latestData", key = "#deviceId")
    public SensorDataResponse getLatestData(String deviceId) {
        log.info("Getting latest data for device: {}", deviceId);
        
        return sensorReadingRepository
            .findFirstByDeviceIdOrderByRecordedTimeDesc(deviceId)
            .map(this::convertToResponse)
            .orElse(null);
    }
    
    @Cacheable(value = "deviceHistory", key = "#deviceId + '_' + #days")
    public List<SensorDataResponse> getDeviceHistory(String deviceId, int days) {
        log.info("Getting {} days history for device: {}", days, deviceId);
        
        LocalDateTime since = LocalDateTime.now().minusDays(days);
        
        return sensorReadingRepository
            .findByDeviceIdAndTimeRange(deviceId, since, LocalDateTime.now())
            .stream()
            .map(this::convertToResponse)
            .collect(Collectors.toList());
    }
    
    @CacheEvict(value = {"latestData", "deviceHistory"}, allEntries = true)
    public void clearCache() {
        log.info("Clearing all sensor data cache");
    }
    
    private void updateDeviceLastSeen(String deviceId) {
        deviceRepository.findByDeviceId(deviceId)
            .ifPresent(device -> {
                device.setLastSeen(LocalDateTime.now());
                device.setStatus(Device.DeviceStatus.ACTIVE);
                deviceRepository.save(device);
            });
    }
    
    private SensorDataResponse convertToResponse(SensorReading reading) {
        return SensorDataResponse.builder()
            .id(reading.getId())
            .deviceId(reading.getDeviceId())
            .nodeId(reading.getNodeId())
            .temperature(reading.getTemperature())
            .humidity(reading.getHumidity())
            .longitude(reading.getLongitude())
            .latitude(reading.getLatitude())
            .recordedTime(reading.getRecordedTime())
            .receivedTime(reading.getReceivedTime())
            .createdAt(reading.getCreatedAt())
            .build();
    }
}
```

### 4. Controller 계층

#### **SensorDataController.java**
```java
package com.sensor.spring.controller;

import com.sensor.spring.dto.SensorDataRequest;
import com.sensor.spring.dto.SensorDataResponse;
import com.sensor.spring.service.SensorDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping("/api/sensor-data")
@RequiredArgsConstructor
@Slf4j
@Validated
public class SensorDataController {
    
    private final SensorDataService sensorDataService;
    
    @PostMapping
    public ResponseEntity<SensorDataResponse> saveSensorData(
            @Valid @RequestBody SensorDataRequest request) {
        log.info("Received sensor data request: {}", request);
        
        try {
            SensorDataResponse response = sensorDataService.saveSensorData(request);
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
        } catch (Exception e) {
            log.error("Error saving sensor data: {}", e.getMessage(), e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
    
    @GetMapping("/devices/{deviceId}/latest")
    public ResponseEntity<SensorDataResponse> getLatestData(
            @PathVariable String deviceId) {
        log.info("Getting latest data for device: {}", deviceId);
        
        SensorDataResponse response = sensorDataService.getLatestData(deviceId);
        if (response != null) {
            return ResponseEntity.ok(response);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
    
    @GetMapping("/devices/{deviceId}/history")
    public ResponseEntity<List<SensorDataResponse>> getDeviceHistory(
            @PathVariable String deviceId,
            @RequestParam(defaultValue = "7") int days) {
        log.info("Getting {} days history for device: {}", days, deviceId);
        
        List<SensorDataResponse> history = sensorDataService.getDeviceHistory(deviceId, days);
        return ResponseEntity.ok(history);
    }
    
    @DeleteMapping("/cache")
    public ResponseEntity<Void> clearCache() {
        log.info("Clearing sensor data cache");
        
        sensorDataService.clearCache();
        return ResponseEntity.ok().build();
    }
}
```

---

## 🎨 웹 인터페이스

### 1. Thymeleaf 템플릿

#### **dashboard.html**
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>센서 데이터 대시보드</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/chart.js@3.7.0/dist/chart.min.css" rel="stylesheet">
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="#">센서 모니터링 시스템</a>
        </div>
    </nav>
    
    <div class="container mt-4">
        <div class="row">
            <div class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h5>실시간 센서 데이터</h5>
                    </div>
                    <div class="card-body">
                        <div id="latestData">
                            <p>데이터를 불러오는 중...</p>
                        </div>
                    </div>
                </div>
            </div>
            
            <div class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h5>온도 변화 그래프</h5>
                    </div>
                    <div class="card-body">
                        <canvas id="temperatureChart"></canvas>
                    </div>
                </div>
            </div>
        </div>
        
        <div class="row mt-4">
            <div class="col-12">
                <div class="card">
                    <div class="card-header">
                        <h5>디바이스 목록</h5>
                    </div>
                    <div class="card-body">
                        <div id="deviceList">
                            <p>디바이스 목록을 불러오는 중...</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.0/dist/chart.min.js"></script>
    <script th:src="@{/js/dashboard.js}"></script>
</body>
</html>
```

### 2. JavaScript 파일

#### **dashboard.js**
```javascript
// 센서 데이터 대시보드 JavaScript
class SensorDashboard {
    constructor() {
        this.temperatureChart = null;
        this.init();
    }
    
    init() {
        this.initTemperatureChart();
        this.loadLatestData();
        this.loadDeviceList();
        this.startRealTimeUpdates();
    }
    
    initTemperatureChart() {
        const ctx = document.getElementById('temperatureChart').getContext('2d');
        this.temperatureChart = new Chart(ctx, {
            type: 'line',
            data: {
                labels: [],
                datasets: [{
                    label: '온도 (°C)',
                    data: [],
                    borderColor: 'rgb(75, 192, 192)',
                    tension: 0.1
                }]
            },
            options: {
                responsive: true,
                scales: {
                    y: {
                        beginAtZero: false
                    }
                }
            }
        });
    }
    
    async loadLatestData() {
        try {
            const response = await fetch('/api/sensor-data/devices/15328737/latest');
            if (response.ok) {
                const data = await response.json();
                this.displayLatestData(data);
            }
        } catch (error) {
            console.error('Error loading latest data:', error);
        }
    }
    
    displayLatestData(data) {
        const container = document.getElementById('latestData');
        container.innerHTML = `
            <div class="row">
                <div class="col-6">
                    <strong>디바이스 ID:</strong> ${data.deviceId}<br>
                    <strong>온도:</strong> ${data.temperature}°C<br>
                    <strong>습도:</strong> ${data.humidity}%
                </div>
                <div class="col-6">
                    <strong>위도:</strong> ${data.latitude}<br>
                    <strong>경도:</strong> ${data.longitude}<br>
                    <strong>수신 시간:</strong> ${new Date(data.receivedTime).toLocaleString()}
                </div>
            </div>
        `;
    }
    
    async loadDeviceList() {
        try {
            const response = await fetch('/api/devices');
            if (response.ok) {
                const devices = await response.json();
                this.displayDeviceList(devices);
            }
        } catch (error) {
            console.error('Error loading device list:', error);
        }
    }
    
    displayDeviceList(devices) {
        const container = document.getElementById('deviceList');
        const table = `
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th>디바이스 ID</th>
                        <th>이름</th>
                        <th>상태</th>
                        <th>마지막 연결</th>
                    </tr>
                </thead>
                <tbody>
                    ${devices.map(device => `
                        <tr>
                            <td>${device.deviceId}</td>
                            <td>${device.name || '-'}</td>
                            <td><span class="badge bg-${this.getStatusColor(device.status)}">${device.status}</span></td>
                            <td>${device.lastSeen ? new Date(device.lastSeen).toLocaleString() : '-'}</td>
                        </tr>
                    `).join('')}
                </tbody>
            </table>
        `;
        container.innerHTML = table;
    }
    
    getStatusColor(status) {
        switch (status) {
            case 'ACTIVE': return 'success';
            case 'INACTIVE': return 'secondary';
            case 'ERROR': return 'danger';
            case 'MAINTENANCE': return 'warning';
            default: return 'secondary';
        }
    }
    
    startRealTimeUpdates() {
        setInterval(() => {
            this.loadLatestData();
        }, 30000); // 30초마다 업데이트
    }
}

// 페이지 로드 시 대시보드 초기화
document.addEventListener('DOMContentLoaded', () => {
    new SensorDashboard();
});
```

---

## 🔄 기존 시스템 연동

### 1. Docker Compose 수정

#### **docker-compose.yml 업데이트**
```yaml
version: '3.8'

services:
  # 기존 서비스들
  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: sensor_db
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  sensor-bridge:
    build: ./sensor-bridge
    ports:
      - "8020:8020"
    environment:
      GO_SERVER_URL: http://sensor-spring-app:8080
    depends_on:
      - postgres
      - redis

  # 새로운 Spring 애플리케이션
  sensor-spring-app:
    build: ./sensor-spring-app
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/sensor_db
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
    depends_on:
      - postgres
      - redis
    volumes:
      - ./sensor-spring-app/logs:/app/logs

volumes:
  postgres_data:
  redis_data:
```

### 2. 기존 Go 서버와의 차이점

| 구분 | Go 서버 | Spring Boot |
|------|---------|-------------|
| **언어** | Go | Java 11 |
| **프레임워크** | Gin | Spring Boot + 전자정부프레임워크 |
| **데이터 접근** | 직접 SQL | JPA + Hibernate |
| **캐싱** | Redis 클라이언트 | Spring Data Redis |
| **템플릿** | 없음 | Thymeleaf |
| **보안** | 기본 | Spring Security (추가 가능) |
| **모니터링** | 기본 | Spring Actuator + Micrometer |

---

**계속...**
