@RestController
@RequestMapping(path="/api")
public class ServiceController {
@RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
public @ResponseBody
List<DeviceModelDto> findServiceByServiceCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
@PathVariable("serviceCode") String serviceCode) 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 = serviceService.findServiceByServiceCode(serviceCode);
if((serviceDto == null) ||
loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
StringUtils.isEmpty(serviceDto.getServiceId())
){
throw new ServiceNotFoundException(String.valueOf(serviceCode));
}
기존에 만들었던 getServiceByServiceId는 사용할 수가 없습니다. serviceId를 인자값으로 하고 있었는데 이제는 serviceCode를 사용해야하기 때문입니다.
Service Layer에도 작업이 필요합니다.
public ServiceDto findServiceByServiceCode(String serviceCode) throws DataFormatViolationException {
Pattern codePattern = ValidationPattern.serviceCodePattern;
Matcher matcher = codePattern.matcher(serviceCode);
if(!matcher.matches()){
throw new DataFormatViolationException("Code value should be consist of alphabet lowercase, number and '-', (length is from 2 to 20)");
}
ServiceEntity serviceEntity = serviceRepository.findByServiceCode(serviceCode).orElse(new ServiceEntity());
return modelMapper.map(serviceEntity, ServiceDto.class);
}
이때 주의해야 할 점은 입력으로 받는 serviceCode에 대해서도 기존과 동일한 검증로직을 적용해주는 것이 좋습니다. 없어도 상관은 없습니다. 그러나 불필요한 요청이 Repository Layer까지 전달될 필요는 없을 것 같습니다. (Repository Layer는 언제나 비용이 가장 비쌉니다.)
- Null 처리를 Service Layer에서 해주었기 때문에 Controller Layer에서는 삭제가 가능합니다.
- serviceCode로 조회되는 경우도 재사용을 할 수 있도록 getServiceByServiceCode로 묶어서 private 메소드로 구현하였습니다.
기본적인 내용이지만 잠깐 짚고 넘어가야할 부분이 있습니다. 많은 분들이 개발을 할때 습관적으로 메소드의 기본을 public 으로 작성합니다.
왜그럴까요? 일단 다 사용할 수 있게 해주는 것이 편리하기 때문입니다. getter, setter 역시 습관적으로 모든 필드값에 만들어 놓고 시작하는 경우를 많이 봅니다.
하지만 이러한 습관은 설계의 기본원칙을 무시하는 위험한 행동입니다. 저는 개인적으로 private을 기본으로 하고 필요한 경우에만 public 메소드를 통해서 열어주는 것을 권장합니다. 메소드와 필드값 모두 동일한 원칙으로 적용합니다.
첫번째 시간에 LoginUserDetails내에서 service Id를 외부로 노출하지 않았던 것을 기억하시기 바랍니다. 현재 사용자의 serviceId를 가지고 작업해야 경우가 생긴다면 해당 객체의 method call을 하는 것이 맞습니다. 교과서적인 용어로는 객체간의 Interaction이라고 합니다.
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까지 전파시킬 것인가에 대해서도 사전에 정의하는 것이 좋습니다.
- 일반적인 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 내부에 구현하였습니다.
이와 같이 구현하면 현재 사용자의 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 클래스를 사용합니다.
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 정보를 등록한다.
해당 값을 String으로만 처리하는 경우에는 유효하지 않는 코드값이 들어오거나, 오타가 발생하는등의 오류를 잡아내기가 힘들며 또한 코드값이 변경/추가되었을때 유지보수가 용이하지 않기 때문에 되도록 enum type사용을 권장한다.
또한, enum type을 사용하더라도 코드값에 따른 로직분기의 경우 if - else if문의 반복을 통해서 수행하는 경우가 많은데 추후 요구사항의 변경이 발생하였을 때 코드의 가독성을 떨어뜨리고, 버그를 만드는 원인이 된다. 이부분 역시 enum type을 활용하여 소스를 깔끔하게 유지할 필요가 있다.
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별로 개별구현하는 것도 가능하다.
Object oriented programming, 이른바 OOP라 불리우는 개념의 기본이다.
흔히 모델링을 접하게 되면 현실세계를 컴퓨터속으로 투영시키는 작업이 모델링이라는 말을 듣게 된다.
RDB모델링에서는 Entity를 도출하고 각 Entity간의 Relation을 정의하는 것이 시작이 된다.
어떠한 정보를 저장하는 가에 중점을 두고 작업을 진행하는 것이 DB모델링이라면
하나의 Object가 어떻게 동작하는가에 중점을 두고 작업을 진행하는 것이 Application모델링.
그중에서도 OOP에 해당한다.
<Responsibility>
Object는 속성과 행위를 갖게 되며 각각 변수와 메소드라는 형태로 표현이 된다. 개발자는 이 object를 통해서 본인이 의도한 바를 구현하고자 한다. 본인이 하고 싶은 일을 대신 시킨다고 봐도 무방하다.
하나의 object로 모든 일을 처리할 것인지, 여러개의 object로 나누어서 처리할 것인지, 나눈다면 몇개로 어떻게 나눌것인지는 만드는 사람의 몫이다. 해당 시스템이 궁극적으로 이루고자 하는 목적과 범위가 무엇인지를 명확히 파악하고 그에 맞게 적절하게 구성하는 것이다.
하나의 object로 모든 일을 처리하는 방식을 흔히 God object라고 부르며 이는 대표적인 anti-pattern의 하나이다. 모든 변경사항에 영향을 받기 때문에 항상 변경될 수밖에 없다.
이는 유지보수성을 심각히 저하시켜서 결국 재개발을 할 수 밖에 없다.
하나의 변경사항에 대한 수정의 범위는 한곳 이 되는 것이 바람직하며(coupling), 유사한 기능에 대해서도 한곳에 모여있는 것이 좋다. (cohesion)