<개요>

- 일전에는 간단히 MVC Layer로 조회 API를 만들어 봤습니다.

https://icthuman.tistory.com/entry/%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EB%8A%94-%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4-1-%EA%B8%B0%EB%B3%B8%EC%A0%81%EC%9D%B8-MVC

 

사례로 배워보는 디자인패턴 #1 - 기본적인 MVC

<개요> - 일반적인 Web MVC구조에 따라서 Service 등록/수정/삭제/조회 하는 REST API를 만든다고 가정합니다. <내용> 가장 단순한 건당 조회를 살펴봅니다. Controller 클래스 입니다. @RestController @RequestM..

icthuman.tistory.com

- 오늘은 비지니스 로직 구현 및 Null 처리에 대해서 정리해보겠습니다.

 

 

<내용>

이번에는 신규 등록하는 API를 작업해보도록 하겠습니다.

    @RequestMapping(value="/services", method= RequestMethod.POST)
    public @ResponseBody
    ServiceDto createService (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                              @RequestBody ServiceDto service, HttpServletResponse response) throws DataFormatViolationException, ServiceAlreadyExistsException {

        // serviceCode 중복 체크 수행
        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);

        UserEntity userEntity = new UserEntity();
        userEntity.setUserId(loginUserDetails.getUserId());
        service.setUser(userEntity);

        ServiceDto ret = serviceService.createService(service);
        response.setStatus(HttpServletResponse.SC_CREATED);
        return ret;
    }

- 등록은 일반적으로 POST 방식을 사용하며 멱등성을 보장해야 합니다.

- 등록하기 전에 기존에 같은 id가 존재하는지 중복체크 수행을 합니다.

 

신규등록을 하기 위해서 이전에 만들었던 로직을 재사용할 수 있습니다. (getServiceByServiceId)

 

그런데 이전에 만들었던 부분에서 약간 보완해야 할 부분이 있습니다. 

private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     (serviceDto == null) ||
                loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

이와 같이 Service에서 조회된 값이 Null인지를 반드시 체크해줘야 합니다.

serviceDto가 null 인경우 getServiceId()를 수행할때 NullPointerException이 발생하기 때문입니다.

 

NullPointerException의 경우 다른 Layer로 전파되지 않도록 하는 것이 로직을 간결하게 만드는데 도움이 됩니다.

또한 Controller Layer의 경우 앞에서 말씀드린 것처럼 Web과 연결되는 부분에 대해서만 담당하도록 하는 것이 좋습니다.

그럼 어떻게 하는게 좋을까요?

 

시스템의 특성상 다양한 처리방법이 존재하지만 이번 예제에서는 간단히 Service Layer에서 Optional을 이용해서 구현했습니다.

@Service
public class ServiceService {
    @Autowired
    private ServiceRepository serviceRepository;

	public ServiceDto findServiceById(int id){
        ServiceEntity serviceEntity = serviceRepository.findById(id).orElse(new ServiceEntity());
        return modelMapper.map(serviceEntity, ServiceDto.class);
	}

 Null인 경우 빈 객체를 하나 생성하여 Return 하도록 하였습니다.

 

이렇게 하면 더이상 Controller Layer에서는 Null에 대해서 신경쓰지 않아도 되기 때문에 조금 더 간결해집니다.

private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

 

이제 Service Layer에 생성로직을 구현합니다.

신규 생성시 간단한 비지니스 규칙을 하나 추가해 보겠습니다.

 

비지니스 규칙 : serviceCode는 영문소문자와 숫자, '-' 로만 구성되어야 하며 2 ~ 20글자까지 허용한다. 

public ServiceDto createService(ServiceDto serviceDto) throws DataFormatViolationException {

        Pattern codePattern = ValidationPattern.serviceCodePattern;
        Matcher matcher = codePattern.matcher(serviceDto.getServiceCode());

        if(!matcher.matches()){
            throw new DataFormatViolationException("Code value should be consist of alphabet lowercase, number and '-', (length is from 2 to 20)");
        }

        ServiceEntity serviceEntity =modelMapper.map(serviceDto, ServiceEntity.class);
        serviceRepository.save(serviceEntity);
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }
}

이와 같이 로직을 구성하고 값을 저장합니다. Repository Layer에서는 serviceCode 컬럼 값에 대해서 신경쓰지 않고 데이터 저장에만 집중할 수 있습니다.

 

UI에서도 이와 같은 validation을 동일하게 구현할 수 있지만 보다 시스템을 튼튼하게 만들기 위해서는 Service Layer에서 반드시 체크해야 합니다. 나중에 다른 비지니스 프로세스를 개발할 때 createService를 재사용할 수도 있기 때문입니다.

 

ServiceCode를 검증하는 중 발생하는 오류에 대해서는 별도 Exception으로 처리하였습니다.

되도록이면 Raw Exception을 사용하는 것은 지양합니다. 그리고 어떤 Exception을 어느 Layer까지 전파시킬 것인가에 대해서도 사전에 정의하는 것이 좋습니다.

 

아키텍처 레벨의 디자인패턴에 대해서는 나중에 추가로 정리하겠습니다.

 

 

<정리>

- 1장과 동일한 포인트입니다. 각 Layer는 역할에 맞는 기능이 구현되어야 합니다.

- 로직은 Controller Layer에 담지 않습니다.

- Null 처리는 표준화 합니다.

- Exception 은 상세하게 사용합니다.

 

 

 

 

<개요>

- 일반적인 Web MVC구조에 따라서 Service 등록/수정/삭제/조회 하는 REST API를 만든다고 가정합니다.

 

 

<내용>

가장 단순한 건당 조회를 살펴봅니다.

Controller 클래스 입니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {

    
    @RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findService (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        // @ResponseBody means the returned String is the response, not a view name
        // @RequestParam means it is a parameter from the GET or POST request

        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);
        return serviceDto;
    }

    private ServiceDto getServiceByServiceId(int serviceId, LoginUserDetails loginUserDetails) throws ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceById(serviceId);
        if(     (serviceDto == null) ||
                loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(String.valueOf(serviceId));
        }
        return serviceDto;
    }

- LoginUserDetails 의 경우 로그인한 사용자의 정보를 저장합니다.
- serviceId를 이용하여 데이터를 조회하고 결과값을 간단하게 검증하는 로직입니다.

- 결과값이 다음 중 하나와 같으면 현재 Service가 존재하지 않는 것으로 판단합니다.

 1. 객체가 null 인 경우

 2. ID필드의 값이 없는 경우

 3. 값이 존재하나 로그인한 사용자의 정보에 해당하지 않는(본인의 서비스가 아닌 경우) 경우

 

이와 같이 Service 를 조회하고 값을 검증하는 로직은 조회 이외에 등록/수정에서도 필요하기 때문에 별도 메소드를 작성하여 재사용하였습니다.

 

그리고 현재 ServiceId와 로그인 사용자의 ServiceId를 비교하는 로직의 경우 Controller에서 구현하는 것보다는 정보은닉화와 설계원칙에 적합해 보여서 LoginUserDetails 내부에 구현하였습니다.

 

좀더 자세히 살펴보겠습니다.

public class LoginUserDetails extends User {

    private static String ROLE_ADMIN = "ROLE_ADMIN";

    private Integer userId;

    private Collection<Integer> services;

    public LoginUserDetails(Integer userId,
                            String password,
                            String userName,
                            Collection<? extends GrantedAuthority> authorities,
                            Collection<Integer> services){
        super(userName, password, authorities);
        this.services = services;
        this.userId = userId;
    
    
    public boolean checkNotAvailableService(Integer serviceId){

        if(this.getAuthorities().contains(new SimpleGrantedAuthority(ROLE_ADMIN)) ){
            return false;
        }

        for(Integer eachService : this.services){
            if(eachService.equals(serviceId)){
                return false;
            }
        }
        return true;
    }

이와 같이 구현하면 현재 사용자의 serviceId등은 외부로 노출시키지 않아도 되며 (getter를 작성하지 않아도 됩니다.)

값의 검증이 필요한 모듈은 LoginUserDetails 객체에 요청하기만 하면 됩니다.

 

Controller layer의 경우 web과 바로 연결되어 있는 부분들을 담당하기 때문에 login관련정보나 인자값을 주로 처리하며 비지니스 로직은 Service Layer에 존재하게 됩니다.

 

 

다음으로 Service Layer를 살펴보겠습니다.

@Service
public class ServiceService {
    @Autowired
    private ServiceRepository serviceRepository;

    @Autowired
    private ModelMapper modelMapper;

    public ServiceDto findServiceById(int id){
        ServiceEntity serviceEntity = serviceRepository.findById(id);
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }

지금은 아무 로직이 없기 때문에 단순히 Repository 로부터 값을 조회하여 객체의 값을 매핑만 합니다.

 

 현재 프로젝트에서는 Spring JPA를 사용하여 시스템을 구축중인데, Entity클래스의 변경은 되도록 줄이는 것이 좋습니다. 만약 Entity 클래스 하나의 유형으로 화면 - 서비스 - 데이터를 모두 처리하게 될 경우 객체지향에서 말하는 대표적인 anti pattern이 될 수 있기 때문에 별도 DTO 클래스를 사용합니다.

 

@Data
@Getter
@Setter
public class ServiceDto {

    private Integer serviceId;

    private String serviceName;

    private String serviceCode;

    private ServiceType serviceType;

    private String description;

    private LocalDateTime creationDateTime;

    private LocalDateTime modificationDateTime;

    private Integer userId;

    private UserEntity user;

}

 

<정리>

- Layer는 일반적으로 자신과 연결되어 있는 부분에 대해서만 인터페이스 하는 것이 원칙입니다.

- Controller Layer에서는 실제 데이터 베이스 저장에 대해서 알 필요가 없으며

- Service Layer에서는 Web기술에 대해서 알 필요가 없고

- Repository Layer는 온전히 데이터의 저장만을 담당합니다.

 

다음시간에는 조금 더 자세한 케이스를 다뤄보겠습니다. 

<배경>

- Azure IoT Hub를 통하여 Device - Server 간에 메시지 전송이 가능하며, 필요할 때 디바이스 원격호출 및 제어가 가능하다.

https://icthuman.tistory.com/entry/Azure-IoT-Hub-%EC%99%80-Device-%EC%97%B0%EA%B2%B0-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%A0%84%EC%86%A1-%EB%B0%8F-%EC%A0%9C%EC%96%B4

 

Azure IoT Hub 와 Device 연결, 메시지 전송 및 제어

<개요> - Azure IoT Hub를 사용하면 Device와 Server를 간편하게 연결하여 D2C (Device to Cloud), C2D (Cloud to Device) 메시지를 쉽게 전달할 수 있습니다. - 디바이스의 메시지는 일반적으로 json string으로..

icthuman.tistory.com

- 원격호출 방식은 일반적인 Browser, Server 시스템 구조와 거의 동일하다.

- Device - Server 간에 공유하는 영역에 값을 Read / Write 함으로 특정로직을 구성할 수 있다.

 

<개요>

 - Server는 Client에 어떤 Action을 하도록 전달하고, Client는 작업 후 상태는 Update하며,

 - Server는 상태를 모니터링한다는 점은 일반적인 시스템구성과 크게 다르지 않다.

 - 다만 사용하는 용어가 IoT 에 특화된 용어를 사용할 뿐이며, 사용되는 기술은 90%이상 동일하다.

IoT Platfrom 일반 System
Device Client
Device Twin Shared Area (e.g. zookeeper, Redis)
Configuration Class, 구조체, JSON Value
Telemetry Data Message
Device Control RPC
Firmware Application Logic

 - 제공되는 API, SDK들을 활용하여 펌웨어 업데이트를 구현하는 샘플시나리오가 있지만 실제 비지니스에서 사용하기에는 적합하지 않다.

 

<작업내용>

- Firmware Update를 위한 Azure SDK 를 wrapping 하여 일반 개발자가 손쉽게 사용할 수 있으며, 유지보수가 쉽도록 한다.

 

IoT Hub를 이용한 Firmware Update 절차

Firmware Update 구현한 샘플코드는 GitHub을 통해서 확인할 수 있다. https://github.com/Azure-Samples/azure-iot-samples-node

 

Azure-Samples/azure-iot-samples-node

azure-iot-node-samples provides a set of easy-to-understand, continuously-tested samples for using Azure IoT Hub and Azure IoT Hub Device Provisioning Service using Node.js SDK. - Azure-Samples/azu...

github.com

<Back-end App>

1. 먼저 IoT Hub에 접근할 수 있는 RegistryManager를 생성한다.

var Registry = require('azure-iothub').Registry;
const chalk = require('chalk');

var connectionString = process.argv[2];
var fwVersion = '2.8.5';
var fwPackageURI = 'https://secureuri/package.bin';
var fwPackageCheckValue = '123456abcde';
var sampleConfigId = 'firmware285';

2. Update할 Configuration 을 정의한다.

// <configuration>
var firmwareConfig = {
  id: sampleConfigId,
  content: {
    deviceContent: {
      'properties.desired.firmware': {
        fwVersion: fwVersion,
        fwPackageURI: fwPackageURI,
        fwPackageCheckValue: fwPackageCheckValue
      }
    }
  },

  // Maximum of 5 metrics per configuration
  metrics: {
    queries: {
      current: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'current\'',
      applying: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND ( properties.reported.firmware.fwUpdateStatus=\'downloading\' OR properties.reported.firmware.fwUpdateStatus=\'verifying\' OR properties.reported.firmware.fwUpdateStatus=\'applying\')',
      rebooting: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'rebooting\'',
      error: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'error\'',
      rolledback: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'rolledback\''
    }
  },

  // Specify the devices the firmware update applies to
  targetCondition: 'tags.devicetype = \'chiller\'',
  priority: 20
};
// </configuration>

deviceContent에는 Device가 참조할 값들이 들어가고, metrics에는 작업 수행 중 Device 가 기록하는 값(DeviceTwin)을 조회하여 모니터링 할 수 있는 쿼리가 들어간다.

 

3. RegistryManager를 통해서 해당 Configuration 정보를 등록한다.

registry.addConfiguration(firmwareConfig, function(err) {
    if (err) {
      console.log('Add configuration failed: ' + err);
      done();
    } else {
      console.log('Add configuration succeeded');
      done();
    }
  });

 

<Device-App>

1. 초기상태를 세팅한다.

// Send firmware update status to the hub
function initializeStatus(callback) {
  var patch = {
    firmware: {
      currentFwVersion: '1.0.0',
      pendingFwVersion: '',
      fwUpdateStatus: 'current',
      fwUpdateSubstatus: '',
      lastFwUpdateStartTime: '',
      lastFwUpdateEndTime: ''
    }
  };
  deviceTwin.properties.reported.update(patch, function(err) {
    callback(err);
  });
}

 

2. 바라보고 있는 변수값(fwVersion, fwUpdateStatus 등)에 변경이 있는지 감지하고, 

값에 따라서 sendStatusUpdate, sendStartingUpdate, initiateFirmwareUpdateFlow 함수를 호출한다.

// <initiateUpdate>
        // Handle firmware desired property updates - this triggers the firmware update flow
        twin.on('properties.desired.firmware', function(fwUpdateDesiredProperties) {
          console.log(chalk.green('\nCurrent firmware version: ' + twin.properties.reported.firmware.currentFwVersion));
          console.log(chalk.green('Starting firmware update flow using this data:'));
          console.log(JSON.stringify(fwUpdateDesiredProperties, null, 2));
          desiredFirmwareProperties = twin.properties.desired.firmware;

          if (fwUpdateDesiredProperties.fwVersion == twin.properties.reported.firmware.currentFwVersion) {
            sendStatusUpdate('current', 'Firmware already up to date', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }
          if (fwUpdateInProgress) {
            sendStatusUpdate('current', 'Firmware update already running', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }
          if (!fwUpdateDesiredProperties.fwPackageURI.startsWith('https')) {
            sendStatusUpdate('error', 'Insecure package URI', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }

          fwUpdateInProgress = true;

          sendStartingUpdate(fwUpdateDesiredProperties.fwVersion, function (err) {
            if (err) {
              console.error(chalk.red('Error occured sending starting update : ' + err.message));
            }
            return;
          });
          initiateFirmwareUpdateFlow(function(err, result) {
            fwUpdateInProgress = false;
            if (!err) {
              console.log(chalk.green('Completed firmwareUpdate flow. New version: ' + result));
              sendFinishedUpdate(result, function (err) {
                if (err) {
                  console.error(chalk.red('Error occured sending finished update : ' + err.message));
                }
                return;
              });
            }
          }, twin.properties.reported.firmware.currentFwVersion);
        });

 

3. 실제 작업을 수행하는 initiateFirmwareUpdateFlow 함수의 구성으로 

downloadImage, verifyImage, applyImage, reboot 를 순차적으로 수행한다. 필요한 로직을 상태에 맞게 구현한다.

각 함수가 수행되고 나면 현재 상태를 update 한다.

// <firmwareupdateflow>
// Implementation of firmwareUpdate flow
function initiateFirmwareUpdateFlow(callback, currentVersion) {

  async.waterfall([
    downloadImage,
    verifyImage,
    applyImage,
    reboot
  ], function(err, result) {
    if (err) {
      console.error(chalk.red('Error occured firmwareUpdate flow : ' + err.message));
      sendStatusUpdate('error', err.message, function (err) {
        if (err) {
          console.error(chalk.red('Error occured sending status update : ' + err.message));
        }
      });
      setTimeout(function() {
        console.log('Simulate rolling back update due to error');
        sendStatusUpdate('rolledback', 'Rolled back to: ' + currentVersion, function (err) {
          if (err) {
            console.error(chalk.red('Error occured sending status update : ' + err.message));
          }
        });
        callback(err, result);
      }, 5000);
    } else {
      callback(null, result);
    }
  });
}

 

위와 같이 Back-end App 와 Device-App을 구성하면

- Server에서 Device에 어떤 명령을 전달하고

- Device는 전달받은 명령을 수행하고 상태를 변경하며, 

- Server에서는 현재 상태를 모니터링하는 작업을 수행할 수 있다.

 

다만 이와 같은 코드는 실제 시스템에서 사용하기에는 적합하지 않는 Design이기 때문에 다음과 같이 Backend-App의 Refactoring을 진행합니다.(예제코드는 javascript로 작성되었으며 리팩토링은 본인에게 친숙한 Java를 사용하였습니다.)

 

1. Java SDK의 RegistryManager를 생성하고 Configuration에 담을 객체를 생성한다.

@Component
public class AzureIotHubConfigurationManager {
        public void addConfiguration(String serviceCode, String deviceModelCode, AzureDeviceFirmwareUpdateInfo azureDeviceFirmwareUpdateInfo) throws IOException, ServiceNotFoundException, IotHubException, DeviceModelNotFoundException {
        // 1. serviceCode validation
        // 2. deviceModelCode validation

        RegistryManager registryManager = RegistryManager.createFromConnectionString(getAzureIotHubConnectionString());
        Configuration configuration = new Configuration(azureDeviceFirmwareUpdateInfo.getConfigurationId());

        ConfigurationContent configurationContent = new ConfigurationContent();
        configurationContent.setDeviceContent(azureDeviceFirmwareUpdateInfo.firmwareUpdateInfoToMap());

        ConfigurationMetrics configurationMetrics = new ConfigurationMetrics();
        configurationMetrics.setQueries( azureDeviceFirmwareUpdateInfo.getMetricQueries() );

        configuration.setContent(configurationContent);
        configuration.setTargetCondition("tags.serviceCode= \'"+serviceCode+"\'"+
                                        " AND " +
                                        " tags.deviceModelCode = \'"+deviceModelCode+"\'");
        configuration.setPriority(20);

        registryManager.addConfiguration(configuration);

    }
}

 

2. Azure IoT Hub에 연관된 정보는 모두 한 곳에 모아두고, 관련된 행위 역시 하나의 클래스로 작업하도록 한다.(SRP원칙)

@Getter
@Setter
@Builder
public class AzureDeviceFirmwareUpdateInfo {
    private String configurationId;
    private String fwVersion;
    private String fwPackageURI;
    private String fwPackageCheckValue;

필요한 정보들을 멤버변수로 선언한다.

 

public java.util.Map firmwareUpdateInfoToMap(){
        java.util.Map temp = new HashMap();
        temp.put("fwVersion", fwVersion);
        temp.put("fwPackageURI", fwPackageURI);
        temp.put("fwPackageCheckValue", fwPackageCheckValue);

        java.util.Map map = new HashMap();
        map.put("properties.desired.firmware", temp);
        return map;
    }

실제로 Azure SDK에서는 Map의 형태로 입력을 받아야 하기 때문에 Map형태로 정보를 제공하는 Method역시 Class내부에 작성한다.

 

 Azure IoT Hub 샘플에서 정의하고 있는 Firmware Update단계는, enum type을 활용하면 효과적으로 처리할 수 있으며 이 때 사용할 String value도 함께 할당하도록 하였다.

public enum FirmwareStatus{
        CURRENT("current"),
        REBOOTING("rebooting"),
        ERROR("error"),
        ROLLEDBACK("rolledback"),
        APPLYING("applying"),
        DOWNLOADING("downloading"),
        VERIFYING("verifying");

        private String label;
        FirmwareStatus(String label){
            this.label = label;
        }
    }

 해당 값을 String으로만 처리하는 경우에는 유효하지 않는 코드값이 들어오거나, 오타가 발생하는등의 오류를 잡아내기가 힘들며 또한 코드값이 변경/추가되었을때 유지보수가 용이하지 않기 때문에 되도록 enum type사용을 권장한다.

또한, enum type을 사용하더라도 코드값에 따른 로직분기의 경우 if - else if문의 반복을 통해서 수행하는 경우가 많은데 추후 요구사항의 변경이 발생하였을 때 코드의 가독성을 떨어뜨리고, 버그를 만드는 원인이 된다. 이부분 역시 enum type을 활용하여 소스를 깔끔하게 유지할 필요가 있다.

if(status == FirmwareStatus.CURRENT){

}else if(status == FirmwareStatus.APPLYING){

}
.....
else{

}

 

Firmware Update의 모니터링쿼리를 살펴보면 각 Status에 따라 쿼리의 조건문이 다르고, 인자의 수에 따라서도 미묘하게 다르게 생성되어야 하지만 공통으로 공유하는 조건도 있다.

 

 예를 들어서 CURRENT인 경우 상태가 'current'인 device에 대해서 조회가 이루어져야 하며, APPLYING의 경우는 'downloading', 'verifying', 'applying' 의 3가지 경우에 대해서 조회가 이루져야 한다. 그러나 조회조건의 configurationId는 동일하다.

 해당 로직을 외부에서 각각 구현한다면 중복로직이 존재하게 되고 추후 변경사항이 발생할 경우 영향을 받는 범위도 넓다. 이는 OCP원칙에 위배되기 때문에 확장에는 열려있고 변경에는 닫히도록 작성할 필요가 있다.

public enum FirmwareUpdate{
        CURRENT(Arrays.asList(FirmwareStatus.CURRENT)),
        REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)),
        ERROR(Arrays.asList(FirmwareStatus.ERROR)),
        ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)),
        APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING));

        List<FirmwareStatus> statusList;

        FirmwareUpdate(List<FirmwareStatus> statusList){
            this.statusList=statusList;
        }

        String query(String configurationId){

            String temp = "SELECT deviceId FROM devices WHERE configurations.[[" + configurationId + "]].status=\'Applied\' AND ";

            if (statusList.size() == 0) {
                throw new ArrayIndexOutOfBoundsException("the size of status list should be positive number");
            } else if (statusList.size() == 1) {
                temp += "properties.reported.firmware.fwUpdateStatus=\'" + statusList.get(0).label + "\'";
            } else {
                temp += "(";

                int count=0;
                for (FirmwareStatus status : statusList) {
                    temp += "properties.reported.firmware.fwUpdateStatus=\'" + status.label + "\'";
                    count++;
                    if(count < statusList.size()){
                        temp+=" OR ";
                    }
                }
                temp += ")";
            }
            return temp;
        }
    }

위와 같이 각 Status에 따라서 동작하는 고유의 로직은 enum형태로 가지고 있도록 하며, 외부에는 query 메소드만 노출하도록 한다.

만약 Status 에 따라서 더욱 구체적인 구현이 필요하다면 query 메소드를  abstract로 정의하고 type별로 개별구현하는 것도 가능하다.

import java.util.Arrays;
import java.util.List;


public enum FirmwareUpdate{
            CURRENT(Arrays.asList(FirmwareStatus.CURRENT)){
                String query(){
                    return null;
                }
            },
            REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)){
                String query(){
                    return null;
                }
            },
            ERROR(Arrays.asList(FirmwareStatus.ERROR)){
                String query(){
                    return null;
                }
            },
            ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)){
                String query(){
                    return null;
                }
            },
            APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING)){
                String query(){
            return null;
        }
    };

    List<FirmwareStatus> statusList;

    FirmwareUpdate(List<FirmwareStatus> statusList){
        this.statusList=statusList;
    }

    abstract String query();

}

 

전체 완성된 소스는 다음과 같다.

@Getter
@Setter
@Builder
public class AzureDeviceFirmwareUpdateInfo {
    private String configurationId;
    private String fwVersion;
    private String fwPackageURI;
    private String fwPackageCheckValue;

    public java.util.Map firmwareUpdateInfoToMap(){
        java.util.Map temp = new HashMap();
        temp.put("fwVersion", fwVersion);
        temp.put("fwPackageURI", fwPackageURI);
        temp.put("fwPackageCheckValue", fwPackageCheckValue);

        java.util.Map map = new HashMap();
        map.put("properties.desired.firmware", temp);
        return map;
    }

    public java.util.Map getMetricQueries(){
        java.util.Map temp = new HashMap();

        temp.put(FirmwareStatus.CURRENT.label, FirmwareUpdate.CURRENT.query(configurationId) );
        temp.put(FirmwareStatus.REBOOTING.label, FirmwareUpdate.REBOOTING.query(configurationId) );
        temp.put(FirmwareStatus.ERROR.label, FirmwareUpdate.ERROR.query(configurationId) );
        temp.put(FirmwareStatus.ROLLEDBACK.label, FirmwareUpdate.ROLLEDBACK.query(configurationId) );
        temp.put(FirmwareStatus.APPLYING.label, FirmwareUpdate.APPLYING.query(configurationId) );

        return temp;
    }

    public enum FirmwareStatus{
        CURRENT("current"),
        REBOOTING("rebooting"),
        ERROR("error"),
        ROLLEDBACK("rolledback"),
        APPLYING("applying"),
        DOWNLOADING("downloading"),
        VERIFYING("verifying");

        private String label;
        FirmwareStatus(String label){
            this.label = label;
        }
    }

    public enum FirmwareUpdate{
        CURRENT(Arrays.asList(FirmwareStatus.CURRENT)),
        REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)),
        ERROR(Arrays.asList(FirmwareStatus.ERROR)),
        ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)),
        APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING));

        List<FirmwareStatus> statusList;

        FirmwareUpdate(List<FirmwareStatus> statusList){
            this.statusList=statusList;
        }

        String query(String configurationId){

            String temp = "SELECT deviceId FROM devices WHERE configurations.[[" + configurationId + "]].status=\'Applied\' AND ";

            if (statusList.size() == 0) {
                throw new ArrayIndexOutOfBoundsException("the size of status list should be positive number");
            } else if (statusList.size() == 1) {
                temp += "properties.reported.firmware.fwUpdateStatus=\'" + statusList.get(0).label + "\'";
            } else {
                temp += "(";

                int count=0;
                for (FirmwareStatus status : statusList) {
                    temp += "properties.reported.firmware.fwUpdateStatus=\'" + status.label + "\'";
                    count++;
                    if(count < statusList.size()){
                        temp+=" OR ";
                    }
                }
                temp += ")";
            }
            return temp;
        }
    }
}
@Component
public class AzureIotHubConfigurationManager {

    public void addConfiguration(String serviceCode, String deviceModelCode, AzureDeviceFirmwareUpdateInfo azureDeviceFirmwareUpdateInfo) throws IOException, ServiceNotFoundException, IotHubException, DeviceModelNotFoundException {
        // 1. serviceCode validation
        // 2. deviceModelCode validation

        RegistryManager registryManager = RegistryManager.createFromConnectionString();
        Configuration configuration = new Configuration(azureDeviceFirmwareUpdateInfo.getConfigurationId());

        ConfigurationContent configurationContent = new ConfigurationContent();
        configurationContent.setDeviceContent(azureDeviceFirmwareUpdateInfo.firmwareUpdateInfoToMap());

        ConfigurationMetrics configurationMetrics = new ConfigurationMetrics();
        configurationMetrics.setQueries( azureDeviceFirmwareUpdateInfo.getMetricQueries() );

        configuration.setContent(configurationContent);
        configuration.setTargetCondition("tags.serviceCode= \'"+serviceCode+"\'"+
                                        " AND " +
                                        " tags.deviceModelCode = \'"+deviceModelCode+"\'");
        configuration.setPriority(20);

        registryManager.addConfiguration(configuration);

    }
}

MS에서 기본적으로 제공하는 Sample을 보다 간결하게 정리하고 유지보수성을 높였다.

각자 필요에 맞게 Logic을 추가로 구성해주면 IoT Hub의 Firmware Update를 보다 편하게 사용할 수 있다.

 

<추가가능한 로직>

- 값의 유효성이나 비지니스 로직을 고려한 Validation Logic (e.g. 서비스코드, 디바이스모델코드)

- Map에 넣을 때 key값을 변수값으로 바꿀수 있도록 처리(e.g. ${azure.firmware.version} )

- AzureDeviceFirmwareUpdateInfo내에 Target Condition포함 (복수개의 target condition 사용시 개선방법)

- Priority의 경우 값이 클 경우 우선순위가 앞에 해당한다. 기존에 Configuration과 우선순위를 비교하여 작업하는 로직 추가가능

<개요>

- Azure IoT Hub를 사용하면 Device와 Server를 간편하게 연결하여 D2C (Device to Cloud), C2D (Cloud to Device) 메시지를 쉽게 전달할 수 있습니다.

- 디바이스의 메시지는 일반적으로 json string으로 구성되어 다양한 형태의 레이아웃을 특별한 처리없이 송수신할 수 있습니다.

- 이러한 유형의 데이터를 Telemetry 라고 합니다.

<내용>

- 디바이스와 서버가 주고 받는 데이터는 Telemetry외에도 제어정보가 있습니다.

- Azure IoT Hub 에서는 Device 제어를 위해서 크게 두 가지 방법을 제공하고 있습니다.

 

1. Device Twin

SKT ThingPlug나 AWS IoT 의 경우 Shadow 라고 부릅니다. Azure IoT Hub의 경우 Device Twin(장치쌍)이라고 부릅니다.

Device Twin은 크게 tags, desired properties, reported properties로 이루어집니다.

공유변수의 형태로 이해하면 됩니다. 디바이스와 서버가 Read/Write할 수 있는 영역이기 때문에 디바이스가 꺼져있거나 서버와 연결되어 있지 않는 상황에서도 처리가 가능합니다. (비동기) 

디바이스 On/Off, Firmware Update, 데이터 주기 조정등 다양한 목적으로 사용가능합니다.

 A. Tags

 - 일반적으로 Server Side에서 세팅하는 값으로 Device를 구분하기 위한 값으로 많이 사용됩니다. (e.g. 모델코드, 위치정보)

 - 나중에 나오는 Firmware Update시나리오에서도 Tags값을 target Condition 으로 활용합니다.

 B. Properties

 - Desired : 서버에서 Write하고 디바이스에서 Read 하는 용도로 사용됩니다. 주로 서버에서 의도하는 바를 디바이스에 전달하기 위한 목적으로 사용됩니다.

 - Reported : 디바이스에서 Write하고 서버에서 Read 하는 용도로 사용됩니다. 디바이스가 Desired값을 읽고 그에 따른 행위를 마친뒤에 서버에 Notification을 보내는 용도로 사용합니다.

 

2. Method Invoke

Device 가 데이터를 보내는 중 Direct Method요청을 받으면 바로 동작한다

일반적 RPC 사용과 동일합니다. 요청/응답 구조로 즉각적인 확인이 가능하며 연결이 되어 있지 않은 경우 실패합니다.

따라서 timeout 도 존재하며 exception 도 발생할 수 있습니다.

 

<정리>

- 결국 과거에 Remote 와 통신하기 위해서 사용했던 방식과 크게 다르지 않습니다. SDK만 별도로 존재하며 실제 내부를 살펴보면 지원하는 프로토콜에 약간의 차이만 있을 뿐 원격호출 방식은 거의 동일합니다.

- 각 방식을 비교한 자료는 다음과 같습니다.

Direct methodsTwin's desired propertiesCloud-to-device messages

Scenario Commands that require immediate confirmation, such as turning on a fan. Long-running commands intended to put the device into a certain desired state. For example, set the telemetry send interval to 30 minutes. One-way notifications to the device app.
Data flow Two-way. The device app can respond to the method right away. The solution back end receives the outcome contextually to the request. One-way. The device app receives a notification with the property change. One-way. The device app receives the message
Durability Disconnected devices are not contacted. The solution back end is notified that the device is not connected. Property values are preserved in the device twin. Device will read it at next reconnection. Property values are retrievable with the IoT Hub query language. Messages can be retained by IoT Hub for up to 48 hours.
Targets Single device using deviceId, or multiple devices using jobs. Single device using deviceId, or multiple devices using jobs. Single device by deviceId.
Size Maximum direct method payload size is 128 KB. Maximum desired properties size is 8 KB. Up to 64 KB messages.
Frequency High. For more information, see IoT Hub limits. Medium. For more information, see IoT Hub limits. Low. For more information, see IoT Hub limits.
Protocol Available using MQTT or AMQP. Available using MQTT or AMQP. Available on all protocols. Device must poll when using HTTPS.

 

<참고사이트>

https://docs.microsoft.com/ko-kr/azure/iot-hub/iot-hub-devguide-c2d-guidance

https://docs.microsoft.com/ko-kr/azure/iot-hub/tutorial-firmware-update

https://www.xenonstack.com/blog/iot-analytics-platform/

 

 

<개요>

 - Function App 사용시 Azure에서 제공하는 Trigger를 사용할 수 있다.

 - EventHub(In) , CosmosDB(Out) Extension을 사용하였다.

 

<현상>

 - 간헐적으로 Portal내에서 비정상적으로 Extension설치시간이 길어진다.

 - 이후 Function 이 정상적으로 동작하지 않으며 503 오류가 발생한다.

 

<원인 및 조치사항>

 - 이유를 알 수 없지만 Function App 의 Extension 설치시 비정상 오류가 간혹 발생한다.

 - 콘솔에 접속하면 다음과 같은 경로에서 extensions.csproj 파일을 확인할 수 있다.

extensions.csproj

 - 그림과 같이 사용하려는 extension이 정상적으로 등록되어 있는지 확인한다.

 - https://docs.microsoft.com/ko-kr/azure/azure-functions/install-update-binding-extensions-manual

 

Azure Functions 바인딩 확장 수동 설치 또는 업데이트

배포된 함수 앱용으로 Azure Functions 바인딩 확장을 설치하거나 업데이트하는 방법에 대해 알아봅니다.

docs.microsoft.com

 해당 페이지를 참고하여 수동설치를 필요에 따라 진행한다.

 - 이전 설치가 비정상적으로 종료된 경우에는 같은 경로에 app_offline.htm파일이 생성되는 경우가 있는데 해당 파일을 삭제해야 정상적으로 동작한다.

<참고사항>

 - Function App에서 현재 제공되고 있는 extension의 종류

 - https://github.com/Azure/azure-functions-host/wiki/Updating-your-function-app-extensions

 

Azure/azure-functions-host

The host/runtime that powers Azure Functions. Contribute to Azure/azure-functions-host development by creating an account on GitHub.

github.com

 

<현상>

- Azure Resource Template을 통하여 Function App 및 App Service Plan생성

- 실제 생성된 Function App이 동작하지 않고 오류가 발생함

Host오류, 런타임오류 등등이 계속 발생함

<원인>

- Resource Template에서 Default Node version이 매우 낮게 설정되어 있음

0.10.28

 

<해결방법>

- WEBSITE_NODE_DEFAULT_VERSION 변수 추가 : 10.14.1

Function App내 Application Settings

- 템플릿에서도 추가

 

정상동작함을 확인하였다.

<현상>

- 우연히 내부 개발서버 테스트중 발견함

- "ParkUser1" 이라는 사용자가 DB에 있는데 로그인API를 "parkuser1"로 해도 로그인이 됨!

 

<이유>

- 기존에 많은 블로그에서 포스팅된 것처럼 MySQL이 대소문자가 다음과 같이 구분한다.

 VARCHAR : 대소문자 구분 안함

 VARBINARY : 대소문자 구분 함

 BINARY() 함수 : 대소문자를 구분하여 WHERE 이하 절 연산

parkuser1로 검색했는데 ParkUser1 조회완료

 

<해결방법>

1. WHERE 절에 BINARY() 함수를 사용하여 조건을 검색하면 대소문자 구분이 됩니다.

정상적으로 대소문자 구분

2. VARBINARY타입으로 테이블 생성

 

<아키텍처 보완을 위한 추가사항>

- 이번 개발계는 Spring JPA로 진행하면서 테이블이 자동생성 되었고 이때 타입이 VARCHAR로 기본생성되어 발생된 문제였다.

- 클라우드를  사용하면서 대부분의 환경을 자동화하려고 한다.

- 또한 JPA가 지향하는 바 Database 디펜던시를 낮추고 대부분의 로직은 어플리케이션으로 처리한다.

- 확장에 열려있고 변경에는 닫혀있는 디자인 원칙을 적용하는 것은, 설계레벨 뿐만 아니라 아키텍처 레벨에서도 적용되어야 한다. (OCP)

 

UserDetailsService에 다음과 같은 로직을 추가하였다.

대소문자 구분을 하지 않아서 로그인에 성공하더라도 Real값과 일치하는지 한번더 비교한다.

 

테스트 결과 MySQL을 사용할 때 Column Type을 VARCHAR로 사용해도 문제가 없었다.

<개요>

- Azure Cosmos 의 경우 RU 단위로 과금을 한다.

<내용>

- 400RU 가 최소단위이다.

- Partition Key를 잘 설정하지 않으면 HotSpot이 발생하여 RU만큼의 성능을 못 느낄 수도 있다.

- RU는 결국 초당 Read/Write를 위해서 접근하는 데이터의 양이라고 볼 수 있는데, 최적화를 잘못할 경우 기대한 한큼은 Throughput이 나오지 않을 수 있다.

- RU는 database 레벨에서 설정할 수도 있고 개별 Collection 단위로도 설정이 가능하다.

- 물리 1파티션당 10기가 용량제한이 있어서 이를 염두해야 한다.

 

<Scale설정화면>

Collection 내의 RU 설정화면

- 개별 Collection 의 사용빈도나 데이터가 다르다면 별도로 세팅하는 것이 좋다.

 

Database 내의 RU설정화면

 

database 레벨에서 RU를 설정할 경우 각 컨테이너의 RU는 별도설정이 불가능하고 database RU를 공유하는 방식이 된다.

컨테이너 RU 설정 공유화면

전체 사용량이 많지 않다면 공유하는 방법을 추천한다. (과금은 RU단위로 되기 때문)

<결론>

-400RU 로만 사용하면 월 5만원 안쪽으로 부담이 크지 않다.

-그러나 지역중복을 체크하면 x로 비용이 추가된다.

-RU를 초과하는 요청에 대해서는 즉시 응답이 오지 않으며 대기하다가 처리하는 방식으로 이루어진다.

-timeout이 발생하는 경우는 명시적으로 exception 이 발생한다.

-전체 data가 많을 경우 스캔해야하는 범위가 늘어나므로 RU최적화를 위해서는 data tts를 조정하는 것도 하나의 방법이 될 수 있다.

-파티션키 조정을 통해서 HotSpot발생을 줄이고 퍼포먼스를 증가시키는 것은 일반적인 NoSQL 과 유사하다.

+ Recent posts