<현황>

- 다양한 도메인에서 대량의 데이터 분석이 매일 DataLake에서 이루어지고 있음

- 각 도메인별 데이터 분석결과물은 유형에 따라서 일/주/월 간격으로 생성이 됨 ( Partition by date )

- 이 결과물은 RDB에 적재되고 Application은 이를 참조하여 다시 다양한 외부 데이터 / 메타 등과 결합하고 비지니스 로직을 처리

- 최종 데이터 컨텐츠를 서비스형태로 사용자에게 제공함

 

<해결해야하는 점>

1. 각 도메인별로 생성되는 주기가 다르기 때문에 서비스에서 일괄로 최종 적재일자를 파악하기 어려움 (Latest Partition)

2. Application에서 스케쥴링을 통하여 적재일자 갱신작업을 수행하고 있으나 이때 에러가 발생할 경우 다음 스케쥴까지 기다려야 함

3. API / DB 호출은 속도가 느리기 때문에 최대한 캐시를 활용해야 하지만 1,2번과 맞물려 캐시 갱신타이밍을 잡기 어려움

4. 각 서비스 별로 Latest Partition을 관리하다 보니 코드의 복잡성이 증가하고 테스트하기도 어려움

 

<개요>

  1. 실제 쿼리를 수행하는 Repository를 멤버로 가지며 각 테이블의 파티션 일자를 관리하는 Wrapper Class 정의
  2. Wrapper Class에 대해서Abstract형태로 메소드를 추상화하고 각 테이블별로 최신파티션일자를 관리하도록함
    1. AbstractPartitionedRepository
      1. setLatestPartition / getLatestPartition: 구현체는 도메인별 테이블마다 특성을 반영하여 작성
    2. PartitionSyncService
      1. AbstractPartitionedRepository타입에 대해서 Bean주입을 받아서 List형태로 관리 하도록 함(dependency injection)
      2. 도메인별 구현없이 AbstractPartitionedRepository 타입에 대해서는 통합으로 동기화 작업 수행

 

<AS-IS>

- 각 테이블에 대한 Repository 클래스 와 최신 파티션 일자 저장/갱신 로직 을 구현하고 있는 Biz Service

- Service Layer에서 주기적으로 소유하고 있는 Repository의 메소드를 호출하여 최종 적재일자를 가져오는 형태

@Service
@Slf4j
public class BizAService {

  private MonthlyMarketShareRepository monthlyMarketShareRepository;
  private MonthlyUsageStatRepository monthlyUsageStatRepository;
  private MonthlyPointRepository monthlyPointRepository;
  private MonthlyDropoffRepository monthlyDropoffRepository;
  private MonthlyDropoffCombRepository monthlyDropoffCombRepository;
  
  private String monthlyMarketShareLatestPartition;
  private String monthlyUsageLatestPartition;
  private String monthlyPointLatestPartition;
  private String monthlyDropoffLatestPartition;
  private String monthlyDropoffCombLatestPartition;

  @Scheduled(cron = "0 */20 * * * *")
  private void setLatestPartition() {
    monthlyMarketShareLatestPartition = monthlyMarketShareRepository.findFirst...().getExecYm();
    monthlyUsageLatestPartition = monthlyUsageStatRepository.findFirst...().getExecYm();
    monthlyPointLatestPartition = monthlyPointRepository.findFirst...().getExecYm();
    monthlyDropoffLatestPartition = monthlyDropoffRepository.findFirst...().getExecYm();
    monthlyDropoffCombLatestPartition = monthlyDropoffCombRepository.findFirst...().getExecYm();
  }

- 저장되어 있는 최종 파티션 일자를 DB 조회시 조회조건으로 사용

public MarketStatResponse getMarketShareStat(String districtCode) {
    List<MonthlyMarketShare> monthlyMarketShareList = monthlyMarketShareRepository.findistrictCode, monthlyMarketShareLatestPartition);

 

<TO-BE>

1. 최종 파티션 관리 및 일자값 저장을 위한 추상화 클래스 

- getLatestPartition을 위한 Implementation은 각 도메인 테이블별로 수행

public abstract class AbstractPartitionedRepository {
    
    protected PartitionExecDt partitionExecDt;

    public abstract PartitionExecDt getLatestPartition();

    public void setLatestPartition(){
        this.partitionExecDt = getLatestPartition();
    }

    public PartitionExecDt getPartitionExecDt(){
        return this.partitionExecDt;
    }
}

 

2. 최종 파티션 일자에 대한 테이블 별 상세구현을 Repository와 함께 Wrapper로 처리하면 Service Layer에서는 더이상 파티션 조건을 신경쓰지 않아도 됨

public class MonthlyMarketShareRepositoryWrapper extends AbstractPartitionedRepository {

    MonthlyMarketShareRepository monthlyMarketShareRepository;

    @Override
    public PartitionExecDt getLatestPartition() {
        MonthlyMarketShare monthlyMarketShare = this.monthlyMarketShareRepository.findFirstByExecYmNotNullOrderByExecYmDesc();
        return PartitionExecDt.builder()
                .execDt(monthlyMarketShare.getExecYm())
                .build();
    }
	
    // Service Layer에서는 필요한 값만 전달하고 파티션조건에 대한 처리는 Wrapper에서 담당한다.
    public List<MonthlyMarketShare> findAllBySigCodeAndExecYm(String districtCode){
        return this.monthlyMarketShareRepository.findAllBySigCodeAndExecYm(districtCode, this.partitionExecDt.getExecDt());
    }

 

3. Biz Serivce에서는 Logic구현에 대해서만 처리하도록 하며, 스케쥴링을 통한 동기화 작업에 대해서는 공통 관심사로 분리하여 통합

@Service
@Slf4j
public class BizAService {

  private MonthlyMarketShareRepositoryWrapper monthlyMarketShareRepositoryWrapper;
  private MonthlyUsageStatRepositoryWrapper monthlyUsageStatRepositoryWrapper;
  private MonthlyPointRepositoryWrapper monthlyPointRepositoryWrapper;
  private MonthlyDropoffRepositoryWrapper monthlyDropoffRepositoryWrapper;
  private MonthlyDropoffCombRepositoryWrapper monthlyDropoffCombRepositoryWrapper;
public class PartitionSyncService {

    @Autowired
    List<AbstractPartitionedRepository> repositoryList;

    @Autowired
    public PartitionSyncService(List<AbstractPartitionedRepository> repositoryList){
        this.repositoryList = repositoryList;
    }

    @Scheduled(fixedDelay = 1000 * 60 * 10)
    public void setPartition(){
        for(AbstractPartitionedRepository abstractPartitionedRepository : repositoryList){
            abstractPartitionedRepository.setLatestPartition();
        }
    }

 

<테스트 코드 비교>

AS-IS : 각 서비스에 대해서 파티션 일자 세팅 및 Repository 조회조건에서도 동일하게 테스트

@BeforeEach
  public void init(){
    MockitoAnnotations.initMocks(this);
    ReflectionTestUtils.setField(bizService, "monthlyMarketShareLatestPartition", "202406");
    ...
    ...
    ...
  }

  @Test
  void testGetMarketShareStat() {
  // Repository 호출시 파티션 조건명시 필요
    given(monthlyMarketShareRepository.findAllBySigCodeAndExecYm("0000000000", "202406"))
      .willReturn(Lists.newArrayList(kickboardTestData.monthlyMarketShare));
    ...
    ...
    ...

    MarketStatResponse expected = bizTestData.getMarketStatResponse();
    MarketStatResponse actual = bizService.getMarketShareStat("0000000000");

TO-BE : given형태로 mock객체에 파티션일자 세팅, Repository 조회조건에서는 더이상 관심사가 아님.

@Test
  void testGetMarketShareStat() {
    // ServiceLayer의 관심사가 아님
    given(monthlyMarketShareRepositoryWrapper.getLatestPartition())
            .willReturn(PartitionExecDt.builder().execDt("202406").build());
    // Repository 호출시 파티션 조건 필요없음
    given(monthlyMarketShareRepositoryWrapper.findAllBySigCodeAndExecYm("0000000000"))
      .willReturn(Lists.newArrayList(kickboardTestData.monthlyMarketShare));
    ...
    ...

    MarketStatResponse expected = bizTestData.getMarketStatResponse();
    MarketStatResponse actual = bizService.getMarketShareStat("0000000000");

 

<효과>

  • Service Layer에서 적재 파티션 일자에 대한 관심사 분리 ( Repository Layer )
  • Repository가 추가될 때마다 동기화에 대한 작업을 신경쓰지 않아도 됨
  • Abstract타입 일괄호출을 통한 수동반영 가능
  • 개발 및 테스트 용이성
  • 캐시 Point 설정 용이성

 

- 해당 리팩토링을 통해서 Biz 관심사와 공통 관심사를 분리하여 코드를 보다 간결하게 만들 수 있었다.

- 다음 글에서는 이 작업을 통해서 관심사가 깔끔하게 분리되었을때 Cache에 대한 적용 (Key값, 갱신주기 등) 이 더 효율적으로 진행될 수 있음을 살펴본다.

https://icthuman.tistory.com/entry/AWS-S3-Athena-%EC%82%AC%EC%9A%A9%EC%A4%91-JDBC-Driver%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C

 

AWS S3-Athena 사용중 JDBC Driver동시성 문제

<개요> - S3보안버켓으로 파일을 올려서 Athena 작업중 - 1개의 CSV파일(2.65GB) 12개의 컬럼 - AthenaJDBC42.jar 사용 s3.amazonaws.com/athena-downloads/drivers/JDBC/SimbaAthenaJDBC-2.0.16.1000/docs/Simba+..

icthuman.tistory.com

<현상>

- AWS Athena사용시 quota제한 으로 인하여 다음과 같은 문제가 발생하였습니다.

- 동시에 많은 요청이 몰리는 것을 방지하기 위해서 다음과 같이 API호출수를 제한하도록 하였으며 object wait/notify를 활용하였습니다.

 

<로직>

- API 호출시 기본적으로 @Async호출

- API호출전에 현재 호출count를 확인하여 일정수치 이상일 경우 object.wait()로 대기

- 호출이 성공하면 count를 증가

- 비동기호출을 마치고 응답을 받으면 callback으로 호출count를 감소시키고 object.notify

CompletableFuture<Map<String, Integer>> completableFutureForRule;
    ...
    synchronized (this){
		waitBeforeCall();
		completableFutureForRule = apiCallService.call(request);
		resultCount.incrementAndGet();
	}
    
    completableFutureForRule.thenAccept(retMap -> {
    	synchronized (this){
    		notifyAfterCall();
    }
private void waitBeforeCall(){

	try {
  
		while (apiCallService.getCallCount() >= SIZE) {
			this.wait();
		}
	} catch (InterruptedException e) {
		log.error("Interruped");
	}
	apiCallService.incrementAndGet();
}

private void notifyAfterCall(){
	apiCallService.decrementAndGet();
	this.notifyAll();
}

- blocking queue를 구현할때 와 매우 유사한 로직임을 알수 있습니다.

 

<참고사항>

- caller service 와 callee service가 같은 비율로 증가한다면 이론상 문제는 없습니다. (e.g 10, 20, 30)

  그러나 실제는 두 서비스간에 연관관계가 없을 것으로 예상되며, 또한 이 로직은 한 jvm내 단일 service에서만 유용하기 때문에 ECS와 같이 scale out 이 되는 구조에서 전체 API 호출수를 제한하기 위해서는 다른 방법을 사용해야 합니다. 

e.g) A. cluster shared lock

      B. API gateway를 활용한 limit rate

      C. Redis를 활용한 global count개념

 

- wait후 notify의 경우 잠들어 있는 하나의 쓰레드만 깨우기 때문에 여러가지 예외 상황에 대응하거나 개별제어가 어려워서 일반적으로 notifyAll을 많이 사용합니다.

- while()문으로 체크해야 하는 이유는 여러 쓰레드가 다시 lock을 획득하기 위해 경쟁하기 때문입니다. 또한 Spurious wakeups 처럼 이유없이 쓰레드가 깨어난 경우에도 다시 wait로 진입하기 위해서 필요합니다.

- 이와 같이 일정시간 대기하는 로직을 작성할때 sleep을 사용하는 경우를 간혹 볼 수 있는데, sleep의 경우 wait와 다르게 lock을 반환하지 않으므로 주의해서 사용해야 합니다. (stackoverflow 참조)

https://stackoverflow.com/questions/1036754/difference-between-wait-and-sleep

- 로직이 복잡해질 경우 CountDownLatch 를 이용해서 구현하는 것을 권장합니다!

<개요>

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-2-%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%9D%84-%EB%8B%B4%EC%9E%90

 

사례로 배워보는 디자인패턴 #2 - 비지니스 로직을 담자

<개요> - 일전에는 간단히 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..

icthuman.tistory.com

- 신규 서비스를 등록하는 API를 작성하였습니다.

- 비지니스 로직이 담긴 serviceCode라는 필드가 추가 되었습니다.

 

<내용>

- 지난 시간에는 신규 서비스를 생성하였습니다. 

- 이번시간에는 serviceId외에 입력하였던 serviceCode로 조회하는 API를 추가해보겠습니다.

반복되는 소스는 제외하고 살펴보도록 하겠습니다.

@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는 언제나 비용이 가장 비쌉니다.)

기본적으로 MVC는 Layerd Architecture이기 때문입니다.

 

다시 Controller 로 돌아가서 윗 부분을 로직도 간결하게 만들어 보겠습니다.

@RestController
@RequestMapping(path="/api")
public class ServiceController {
	
     @RequestMapping(value="/services/{serviceCode}/devices", method= RequestMethod.GET)
    public @ResponseBody
    List<DeviceDto> findServiceByServiceCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                                                 @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException, DataFormatViolationException {
        
        ServiceDto serviceDto = getServiceByServiceCode(serviceCode, loginUserDetails);
		return serviceDto;
    }


	private ServiceDto getServiceByServiceCode(String serviceCode, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceByServiceCode(serviceCode);

        if((loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
            StringUtils.isEmpty(serviceDto.getServiceId())
        ){
            throw new ServiceNotFoundException(String.valueOf(serviceCode));
        }

        return serviceDto;
    }

- Null 처리를 Service Layer에서 해주었기 때문에 Controller Layer에서는 삭제가 가능합니다.

- serviceCode로 조회되는 경우도 재사용을 할 수 있도록 getServiceByServiceCode로 묶어서 private 메소드로 구현하였습니다.

 

기본적인 내용이지만 잠깐 짚고 넘어가야할 부분이 있습니다. 많은 분들이 개발을 할때 습관적으로 메소드의 기본을 public 으로 작성합니다.

왜그럴까요? 일단 다 사용할 수 있게 해주는 것이 편리하기 때문입니다. getter, setter 역시 습관적으로 모든 필드값에 만들어 놓고 시작하는 경우를 많이 봅니다.

 

하지만 이러한 습관은 설계의 기본원칙을 무시하는 위험한 행동입니다. 저는 개인적으로 private을 기본으로 하고 필요한 경우에만 public 메소드를 통해서 열어주는 것을 권장합니다. 메소드와 필드값 모두 동일한 원칙으로 적용합니다.

 

첫번째 시간에 LoginUserDetails내에서 service Id를 외부로 노출하지 않았던 것을 기억하시기 바랍니다. 현재 사용자의 serviceId를 가지고 작업해야 경우가 생긴다면 해당 객체의 method call을 하는 것이 맞습니다. 교과서적인 용어로는 객체간의 Interaction이라고 합니다.

 

최종 작업을 통해서 아래와 같은 코드가 작성되었습니다.

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

	@RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceById (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByServiceId(serviceId, loginUserDetails);
        return serviceDto;
    }
    
    @RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceByCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByServiceCode(serviceId, loginUserDetails);
        return serviceDto;
    }


	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;
    }


    private ServiceDto getServiceByServiceCode(String serviceCode, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto =  serviceService.findServiceByServiceCode(serviceCode);

        if((loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId()) ){
            throw new ServiceNotFoundException(String.valueOf(serviceCode));
        }

        return serviceDto;
    }

똑같은 로직이 반복되는 것이 눈에 보입니다.

serviceId , serviceCode의 차이만 있고 나머지는 거의 유사합니다.

 

호출되는 Service Layer의 메소드명, 인자값만 약간 다른 것을 보니 여전히 통합할 수 있는 부분들이 보입니다.

조회하는 조건값을 serviceId, serviceCode로 나누어서 동작하면 service 호출외의 부분은 정리할 수 있을 것 같습니다.

 

과거에는 이러한 분기조건에서 int, char, boolean을 쓰는 경우가 많았지만 적어도 Java에서는 enum type이라는 좋은 대안이 있습니다.

public enum SearchConditionType {
    ID,CODE;
}
@RestController
@RequestMapping(path="/api")
public class ServiceController {

	@RequestMapping(value="/services/{serviceId}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceById (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceId") int serviceId) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByCondition(SearchConditionType.ID, serviceId, loginUserDetails);
        return serviceDto;
    }
    
    @RequestMapping(value="/services/{serviceCode}", method= RequestMethod.GET)
    public @ResponseBody
    ServiceDto findServiceByCode (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                            @PathVariable("serviceCode") String serviceCode) throws ServiceNotFoundException {
        
        ServiceDto serviceDto = getServiceByCondition(SearchConditionType.CODE, serviceCode, loginUserDetails);
        return serviceDto;
    }


private ServiceDto getServiceByCondition(SearchConditionType searchConditionType, Object condition, LoginUserDetails loginUserDetails) throws DataFormatViolationException, ServiceNotFoundException {
        ServiceDto serviceDto = null;
        switch (searchConditionType){
            case ID:
                serviceDto = serviceService.findServiceById((Integer)condition);
                break;
            case CODE:
                serviceDto = serviceService.findServiceByServiceCode((String)condition);
                break;
        }
        if(loginUserDetails.checkNotAvailableService( serviceDto.getServiceId()) ||
                StringUtils.isEmpty(serviceDto.getServiceId())){
            throw new ServiceNotFoundException(condition.toString());
        }

- 이와 같은 방법을 통해서 Validation Logic 을 하나로 통합하여 재사용할 수 있습니다.

  재사용을 고려하지 않고 ctrl + c, v를 무작정 사용해서 개발할 경우 나중에 수정사항이 발생했을 때의 여파는 생각보다 큽니다!

 매번 수작업으로 find usages를 해서 소스를 고쳐야 하고, 다 고치고 나서도 불안하며, 테스트코드도 모든 케이스 개별로 다시 해야합니다.

 

- enum을 사용할 경우 유효하지 않은 값의 입력을 막을 수 있습니다. 그 외에도 enum의 활용도는 무궁무진합니다.

  완벽한 싱글톤 객체로 사용되기도 하고(effective java), 코드 테이블용도로 사용되기도 합니다.

  제 블로그에 Status 및 Operation 을 담는 객체로 활용한 예제도 있으니 참고하시기 바랍니다.

 

- Object 를 특정 타입으로 캐스팅하는 것은 권장하고 싶지 않은 방법이지만 간단한 예제를 위해서 사용했습니다.

 

- switch문으로 분기문을 쭉 나열하는 것도 좋은 방법은 아닙니다..

 

<정리>

- 비지니스 검증은 되도록 Service Layer에서, Repository Layer는 접근 비용이 비쌉니다.

- 필드값, 메소드의 접근자는 생각하면서 사용합니다.

- ctrl + c, v는 없도록 합니다.

- Java enum은 다용도로 활용이 가능합니다.

 

 

 

지금까지는 어찌보면 간단한 구현이었습니다.

하지만 실제 비지니스는 더욱 복잡합니다.

 

- Join데이터들을 조회할 때 문제점

- 늘어나는 케이스마다 동일 변수 사용시 (e.g. serviceCode, serviceId) 공통화 할 수 있는 부분은?

- 테이블의 종류는 점점 늘어날 것인데, 조회된 값이 유효한지 (e.g. Id가 비어있지는 않은지, 0이 오지는 않는지) 매번 검증할 것인가

- 테이블 객체에서 검증할 것인가, 별도 객체에게 위임할 것인가.

- 만약 primary key의 타입이 달라지는 경우는 어떻게 비교할지

 

등등, 시간이 될때 마다 정리해서 올려보도록 하겠습니다.

<개요>

- 일전에는 간단히 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과 우선순위를 비교하여 작업하는 로직 추가가능

<개요>

객체지향 디자인은 일반적으로 서양에서 일반적으로 다루는 철학적 사상을 배경으로 하고 있기 때문에 동양 사고에 익숙한 사람들은 잘 와닿지가 않는 듯 하다.

 

 현장에서 경험하고 여러가지를 공부하여 이해한바를 바탕으로 큰 특징을 잡아서 설계관점에서 접근해보면 좋을 것 같다는 생각에 정리를 해본다.

 

 

<내용>

1. 상호의존

 

- 객체지향 디자인에서 나오는 산출물을 크게 정적, 동적으로 나누어 본다면 정적에서는 Class Diagram, 동적에서는 Seq Diagram으로 볼 수 있는데 이는 결국 각 객체의 의존관계를 표현하기 위함이다.

<그림-1 다이어그램의 분류>

 

 

 

<그림-2 Class Diagram>

 

<그림-3 Seq Diagram>


 

 우리나라 사람들이 이러한 Diagram을 그리기 어려워 하는 것도 결국 사고의 방식이 서양과 다르기 때문이 아닌가 생각해본다. 서양인들은 카테고라이징에 강하고 동양인들은 릴레이션에 강하다고 한다.

(호랑이, 원숭이, 바나나 중에서 관계가 있는 것끼리 묶으라고 하면 서양인들은 같은 동물인 호랑이, 원숭이를 선택하고 동양인들은 원숭이, 바나나를 선택한다고 한다...)

 

 - Class Diagram은 결국 우리가 개발할 때 필요한 객체들을 카테고라이징 한 결과물이고

 - Seq Diagram은 그 객체들이 주고 받는 메시지를 정의한 것이다.

 

 - 특히 Seq Diagram상의 메시지는 기능 (method, function, API등) 의 argument, return 타입을 정의하기 때문에 매우 중요하며 각 객체가 주고받는 내용을 포함하기 때문에 결국 각 객체의 책임을 명확하게 정리하는데 많은 도움이 된다.

 

 <Tip>

 Class Diagram은 분류에 집중하고, Seq Diagram은 메시지와 Life Cycle에 집중하면 좋은 결과물이 나온다.

 

 핵심 : 내가 처리할 수 있는 것은 필요한 정보를 받아서 처리 후 결과를 다른 객체에 알려준다. 내가 모든 것을 처리하지 않는다.

 

 

 

2. Coupling & Cohesion

 

 - 이렇게 각 객체를 도출하여 개발을 할 때 어떤 객체가 특정 객체에서만 참조 된다면?

   이런 경우 우리는 Coupling이 강하다고 한다.

 - 이렇게 만들어진 객체는 다른 객체와 협력하기 어렵다. 즉, 재사용성이 낮다.

 - 여러 곳에서 두루 사용될 수 있도록 객체를 설계해야 하며 이를 Cohesion이 높다고 한다.

 - 수년간 프로그램 개발을 하면서 얻었던 몇가지 Tip을 공유하자면 !

 

  <Tip> - 추후 자세히 설명!

  a. Seq Diagram에서 표현되는 메시지, 즉 메소드의 인자값을 유연하게 가져가야한다.

 

  b. ~ is a~ 의 관계보다 ~ has a ~ 의 관계를 가져가도록 노력해야 한다.

 

  c. Class를 설계할 때에도 ConcreateClass보다 Interface와 Abstract를 적극 활용한다.


 

 

3. God Object 를 피하라

 - 1,2번과 연관이 있는 내용이다.

 - 즉 혼자서 모든 것을 처리하는 객체를 만드는 것을 피해야한다.

 - 특히 개발기간이 짧고 소수의 개발자가 투입될 때 많이 나타나는 모양인데 프로젝트 종료 후 반드시 리팩토링을 거쳐야만 한다!!

 - Design Pattern에서 언급되는 대표적인 Anti-Pattern으로 유지보수성이 매우 낮고

  (만든 사람만 고칠 수 있다;; 그래서 개발자로 오래 살아남는법 이라는 책에서는 이렇게 만들라고 권장한다!)

 - 모든 일을 혼자서 다 처리한다는 이야기는 결국 모든 요구사항과 연결되어 있다는 내용이고 매번 고쳐야하는 객체라는 이야기이다. 즉, 재사용성이 낮다.

 

 

<결론>

 - System이라는 말이 한자로 바꾸 가 된다.

 - 우리가 살고 있는 생태계도 여러종류의 생물, 무생물이 각각의 역할을 수행하고 다른 객체와 소통하여 유지가 되고 있는 것처럼

 - IT System도 여러가지 역할이 적절히 분배된 객체들이 잘 어울려야 안정적인 System을 구현할 수 있다.

 - 다양한 다른 객체와 원활하게 소통할 수 있도록 만들어진 객체는 오래 살아남을 수 있지만, 그렇지 않게 설계된 객체는 System상에서 오래 살아남기가 어렵다 (재사용성 측면)

 - 객체지향에 익숙하지 않거나 싫어하는 개발자는 Operation(method, function) 의 Argument나 Return Type을 신경쓰지 않는 경향이 있다. 협업을 위해서 조금만 더 신경써주세요!

 - 전역변수를 주로 사용하고 argument는 비어있고, return 은 void 가 일반적이다;;

   객체지향에서 항상 나오는 캡슐화 와도 연관이 있는데 정말 안 좋은 습관입니다.

 

 

<참조사이트>

https://en.wikipedia.org/wiki/Class_diagram

1. 조물주가 이 땅에 특별한 목적을 가지고 생명체를 창조해내었다면, 

프로그래머 역시 어떠한 목적이 있기 때문에 한 객체를 시스템내에서 창조한다.

창조된 생명체는 자신의 역할을 다할 책임이 있다.


<모델링>

Object oriented programming, 이른바 OOP라 불리우는 개념의 기본이다.

흔히 모델링을 접하게 되면 현실세계를 컴퓨터속으로 투영시키는 작업이 모델링이라는 말을 듣게 된다.

RDB모델링에서는 Entity를 도출하고 각 Entity간의 Relation을 정의하는 것이 시작이 된다.

어떠한 정보를 저장하는 가에 중점을 두고 작업을 진행하는 것이 DB모델링이라면

하나의 Object가 어떻게 동작하는가에 중점을 두고 작업을 진행하는 것이 Application모델링.

그중에서도 OOP에 해당한다.


<Responsibility>

 Object는 속성과 행위를 갖게 되며 각각 변수와 메소드라는 형태로 표현이 된다. 개발자는 이 object를 통해서 본인이 의도한 바를 구현하고자 한다. 본인이 하고 싶은 일을 대신 시킨다고 봐도 무방하다.

하나의 object로 모든 일을 처리할 것인지, 여러개의 object로 나누어서 처리할 것인지, 나눈다면 몇개로 어떻게 나눌것인지는 만드는 사람의 몫이다. 해당 시스템이 궁극적으로 이루고자 하는 목적과 범위가 무엇인지를 명확히 파악하고 그에 맞게 적절하게 구성하는 것이다.

 하나의 object로 모든 일을 처리하는 방식을 흔히 God object라고 부르며 이는 대표적인 anti-pattern의 하나이다. 모든 변경사항에 영향을 받기 때문에 항상 변경될 수밖에 없다.

이는 유지보수성을 심각히 저하시켜서 결국 재개발을 할 수 밖에 없다.

 하나의 변경사항에 대한 수정의 범위는 한곳 이 되는 것이 바람직하며(coupling), 유사한 기능에 대해서도 한곳에 모여있는 것이 좋다. (cohesion)




+ Recent posts