<개요>

- 일반적으로 많이 사용하는 지리좌표계는 위도, 경도로 이루어져있다.

  이는 실제 정확한 위치를 측정하는 것이 목표이기 때문에 소수점 표현에 제한이 없이 무한하게 표현한다. (보통 6자리)

- 특정 영역(Area) 에 대한 처리를 하기에는 적합하지 않기 때문에, 영역기반의 검색/표현에 적합한 구조가 필요하다.

- 즉, (무한->유한) 한정된 공간내에서 필요한 만큼만의 데이터(의미있는 데이터) 를 관리할 수 있도록 개선이 필요하다.

 

<내용>

- 지도뷰를 기반으로 하는 시스템에서 좌표를 기반으로 검색하는 것은 속도/공간에서 많은 손해를 본다. (대부분 특정영역 내 검색)

- 지도내에서 살짝만 움직여도 소수점 값이 변경되는데 실제 서비스내에서 의미를 갖는 값으로 보기 어려울때가 있다.

- 또한 Round처리를 통해서 어느정도 고정적으로 표현할 수는 있으나 연산에 역시 불편함이 있다.

- 지역내 검색이나 가까운 위치 등을 계산할때도 복잡한 수식을 사용해야 하며 Index 나 Key를 사용하기에 쉬운 구조는 아니다.

 

<GeoHash Concept>

전 세계를 잘라내기

- 이를 보완하기 위해서 전 세계 지역을 특정영역 단위로 잘라낸 것이 GeoHash의 기본사상이다.

- 좌표값을 특정 해시값으로 변경하여 지역기반 검색 이나 캐시 활용에서 편리하게 활용 할 수 있다. ( f(x) -> y )

- GeoHash의 결과는 특정 영역(Area) 이다.  지점(Point)이 아니다.

 

<GeoHash Algorithm>

- 알고리즘은 간단히 설명하면 Index Tree, Binary Search등과 비슷하다.

1. Latitude (-90 ~ 90), Longitude (-180 ~ 180) 범위 내에서 Binary Search를 수행한다.

2. 왼쪽에 속하면 0, 오른쪽에 속하면 1 이다. (bits)

3. 다음 구간으로 이동하여 1,2를 반복한다.

Longtitude / Latitude 변환

4. 이렇게 해서 얻은 각 bit를 하나씩 꺼내어 결합한다.

- Geohash level이 높아질 수록 더 자세한 위치를 표현해야 하기 때문에, 더 많은 bit를 필요로 하게 된다.

- latitude 1개, longitude 1개 순으로 번갈아가면서 결합한다.

 (Longitude의 값의 범위가 더 넓기 때문에 Level에 따라서 Bit가 1개 더 필요한 경우가 있고, 이 때 마지막 두 개를 연속해서 붙인다. )

 

5. 마지막으로 얻은 bits를 5개씩 나눠서 BASE32 encoding으로 변환하여 알파벳 문자를 얻을 수 있다. (2^5=32)

BASE32

6. Binary Search를 많이 반복할 수록 더욱 정확한 숫자를 얻게 되고, GeoHash 의 길이는 길어진다고 볼 수 있다.

 - GeoHash Stirng의 길이가 GeoHash Level로 생각하면 된다.

 

7. 위의 연산을 통해서 얻은 결과값은 다음과 같다.

위/경도 좌표 (37.385595, 127.122759) -> Level 2 (wy), Level 8 (wydkstzf)

위/경도 좌표 (37.384887, 127.123689) -> Level 2 (wy), Level 8 (wydksv8w)

 

*장점

- 매우 길고 큰 값을 상대적으로 짧고 저장공간을 적게 차지하는 String으로 바꿀 수 있게 된다. (Hash의 기본사상)

- GeoHash알고리즘의 특성상 prefix비교를 통해서 이웃인지 판별할 수 있다.

 예를 들어서 GeoHash를 통해서 gbsuv (Level 5)값을 얻은경우 아래의 지역들은  Neighbours 로 판별할 수 있다.

gbsvh gbsvj gbsvn
gbsuu gbsuv gbsuy
gbsus gbsut gbsuw

 

<코드>

전체코드는 다음 위치에서 확인 가능합니다.

https://github.com/ggthename/geohash

 

GitHub - ggthename/geohash: get a geohash value from a coordinate (latitude,longitude)

get a geohash value from a coordinate (latitude,longitude) - GitHub - ggthename/geohash: get a geohash value from a coordinate (latitude,longitude)

github.com

위도/경도 기반의 좌표
GeoHash Level에 따른 Binary Search 응용

 

<정리>

- 이를 통해서 특정 지역 내의 데이터값을 관리할때 Key값으로 사용할 수 있다.

 e.g) 특정지역내 위치한 상점 검색, 50 x 50내 인구 등

 

- 해당 지역간의 인접성도 복잡한 계산없이 판별할 수 있다.

 e.g) 현재 위치에서 10km내 이동가능한 곳에 있는 주유소 위치 등

 

- 최대 Level 12로 32.2mm x 18.6mm의 지역까지 표현할 수 있다. 일반적으로 Level 9 까지 사용한다.

 

<참고>

- https://en.wikipedia.org/wiki/Geohash#Technical_description

 

Geohash - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search This article is about the system for encoding geographic coordinates. For the game, see Geohashing. Public domain geocoding invented in 2008 Geohash is a public domain geocode system i

en.wikipedia.org

- https://en.wikipedia.org/wiki/Base32

 

Base32 - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search Binary-to-text encoding scheme using 32 symbols Base32 is the base-32 numeral system. It uses a set of 32 digits, each of which can be represented by 5 bits (25). One way to represent

en.wikipedia.org

https://www.movable-type.co.uk/scripts/geohash.html

 

Geohash encoding/decoding

Movable Type Scripts Geohashes A geohash is a convenient way of expressing a location (anywhere in the world) using a short alphanumeric string, with greater precision obtained with longer strings. A geohash actually identifies a rectangular cell: at each

www.movable-type.co.uk

 

<개요>

- 기본적인 AWS Java SDK S3 사용예제

- 사용시 주의사항

 

<내용>

현재 버전(2022.05.19기준) 샘플 소스이며, 공식 가이드문서를 참고하는 것이 정확합니다.

1. Configuration

@Configuration
public class AmazonS3Config {
    
    @Value("${aws.credentials.access-key}")
    private String accessKey;

    @Value("${aws.credentials.secret-key}")
    private String secretKey;

    @Value("${aws.region.static}")
    private String region;

    public BasicAWSCredentials awsCredentials(){
        BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return awsCreds;
    }

    @Bean
    public AmazonS3 amazonS3Client(){
        AmazonS3 amazonS3 = AmazonS3ClientBuilder.standard()
                .withRegion(this.region)
                .withCredentials(new AWSStaticCredentialsProvider(this.awsCredentials()))
                .build();
        return amazonS3;
    }
}

- 이와 같이 accessKey, secretKey, region정보를 세팅하여 기본적인 인증을 생성하고 이를 기반으로 S3 ClientBuilder를 통해서 객체를 생성 후 Bean으로 사용한다.

- dev / stg / prod 환경에 따라서 달라질 수 있는 정보들은 외부에 관리한다.

- 주의 : 해당 키값이 탈취당할 경우 매우 위험하니 (보안,비용 등등), Public 공간에 올리는것은 주의하고 올라가게 된다면 반드시 암호화를 한다!

 

2. Repository

@Repository
public class AwsFileRepository {

    private AmazonS3 amazonS3;

    @Value("${aws.s3.bucket}")
    private String bucket;

    @Autowired
    public AwsFileRepository(AmazonS3 amazonS3){
        this.amazonS3 = amazonS3;
    }

    public FileDto upload(MultipartFile file, String prefix) throws IOException {
        SimpleDateFormat date = new SimpleDateFormat("yyyyMMddHHmmss");
        String fileName = prefix +"-"+date.format(new Date())+"-"+file.getOriginalFilename();
        String s3location = bucket +"/"+ prefix;

        amazonS3.putObject(new PutObjectRequest(s3location, fileName, file.getInputStream(), null));

        FileDto fileDto = new FileDto();
        fileDto.setS3Location(amazonS3.getUrl(s3location,fileName).toString());
        fileDto.setS3Key(prefix +"/" + fileName);
        return fileDto;
    }

    public String delete(String s3Key){
        amazonS3.deleteObject(bucket, s3Key);
        return s3Key;
    }

}

- S3 역시 외부의 저장소에 접근하는 것이므로 Repository Layer로 정의하는 것을 추천한다.

- S3 는 버킷단위로 정책을 정의할 수 있으며, 내부에 별도 폴더로 분리 저장이 가능하다. (예제 소스에서 prefix부분)

- 저장할 때의 값 bucketName "/" 이하 부분을 해당 파일에 대한 Key로 관리하면 삭제 시 편리하게 활용할 수 있다.

- 예제에서는 ObjectMetadata를 null로 사용하였으며 필요에 따라서 공식가이드에 제공되는 값을 세팅할 수 있다.

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/UsingMetadata.html#object-key-guidelines

 

객체 메타데이터 작업 - Amazon Simple Storage Service

PUT 요청 헤더는 크기가 8KB 이하여야 합니다. PUT 요청 헤더에 포함되는 시스템 정의 메타데이터의 크기는 2KB 이하여야 합니다. 시스템 정의 메타데이터의 크기는 US-ASCII로 인코딩된 각 키와 값의

docs.aws.amazon.com

 

<주의사항>

- 대부분의 Public Cloud는 Endpoint와 HTTP API를 제공하고 있으며, SDK는 이것을 감싸는 구조로 되어있다. 

- 따라서 url 인코딩을 주의하여 사용해야 한다.

- 예를 들어서 얼마전에 테스트하다가 파일명에 특이한 문자들을 넣게 되었는데 아래와 같은 오류가 발생하였다.

2022-05-19 10:15:15.447 DEBUG 30235 --- [nio-8900-exec-6] com.amazonaws.request                    : Received error response: com.amazonaws.services.s3.model.AmazonS3Exception: The request signature we calculated does not match the signature you provided. Check your key and signing method. (Service: Amazon S3; Status Code: 403; Error Code: SignatureDoesNotMatch; Request ID: S80BNXP50DTW9SJM; S3 Extended Request ID: , S3 Extended Request ID: 
2022-05-19 10:15:15.449 DEBUG 30235 --- [nio-8900-exec-6] com.amazonaws.retry.ClockSkewAdjuster    : Reported server date (from 'Date' header): Thu, 19 May 2022 01:15:15 GMT
.
.
.
at com.amazonaws.services.s3.AmazonS3Client.invoke(AmazonS3Client.java:5054) [aws-java-sdk-s3-1.11.762.jar:na]
	at com.amazonaws.services.s3.AmazonS3Client.invoke(AmazonS3Client.java:5000) [aws-java-sdk-s3-1.11.762.jar:na]
	at com.amazonaws.services.s3.AmazonS3Client.access$300(AmazonS3Client.java:394) [aws-java-sdk-s3-1.11.762.jar:na]
	at com.amazonaws.services.s3.AmazonS3Client$PutObjectStrategy.invokeServiceCall(AmazonS3Client.java:5942) [aws-java-sdk-s3-1.11.762.jar:na]
	at com.amazonaws.services.s3.AmazonS3Client.uploadObject(AmazonS3Client.java:1808) [aws-java-sdk-s3-1.11.762.jar:na]
	at com.amazonaws.services.s3.AmazonS3Client.putObject(AmazonS3Client.java:1768) [aws-java-sdk-s3-1.11.762.jar:na]

- 인증토큰이나 이러한 부분에 문제인줄 알았는데 유사한 문제를 겪은 사람들이 상당히 많았고

https://stackoverflow.com/questions/30518899/amazon-s3-how-to-fix-the-request-signature-we-calculated-does-not-match-the-s

 

Amazon S3 - How to fix 'The request signature we calculated does not match the signature' error?

I have searched on the web for over two days now, and probably have looked through most of the online documented scenarios and workarounds, but nothing worked for me so far. I am on AWS SDK for PHP...

stackoverflow.com

- 결국 encoding과정의 문제로 발생한 것이었다.(에러메시지를 잘 주었다면 덜 고생했을텐데;)

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/object-keys.html

 

객체 키 이름 생성 - Amazon Simple Storage Service

Amazon S3 콘솔을 사용하여 키 이름이 마침표 '.'로 끝나는 객체의 경우 다운로드한 객체의 키 이름에서 마침표 '.'가 제거됩니다. 다운로드한 객체에 보존된, 키 이름이 마침표 '.'로 끝나는 객체를

docs.aws.amazon.com

 

<필요사항>

- AWS ECS상 워크로드 중 특정서비스의 로그를 수집/가공 하여 통계 정보를 제공

- 기존에 사용중이던 AWS SDK S3 와 호환성 

 

<개요> 

- https://docs.aws.amazon.com/cloudwatch/index.html

 

https://docs.aws.amazon.com/cloudwatch/index.html

 

docs.aws.amazon.com

- AWS는 Cloudwatch 내에서 특정 로그그룹에 대하여 쿼리할 수 있는 툴 (Insight) 를 제공하고 있다.

- 해당 기능을 Web, SDK등 다양한 형태로 제공하고 있으며 Java SDK를 사용했다.

 

<내용>

- Java용 AWS SDK는 크게 버전 1.x , 2.x 가 있으며 특히 2.x 부터 비동기 NonBlocking 및 CompletableFuture를 제공하고 있기 때문에 신규 개발의 경우 가급적 2.x 를 권장하고 있다.

- 현재 서비스에서 S3용  1.x SDK를 이미 사용중이고 CloudWatchLogs는 2.x SDK를 사용할 예정이기 때문에 Mig 를 하던지 두 가지 버전을 병행하던지 선택한다.

- 기존 기능에 큰 문제가 없기 때문에 일단 병행하기로 결정

 

<환경설정 및 변경사항>

1. pom.xml

- groupId 변경

ver 1.x ver 2.x
com.amazonaws software.amazon.aws.sdk
<dependency>
	<groupId>com.amazonaws</groupId>
	<artifactId>aws-java-sdk-s3</artifactId>
	<version>1.11.762</version>
</dependency>

<dependency>
	<groupId>software.amazon.awssdk</groupId>
	<artifactId>cloudwatchlogs</artifactId>
</dependency>

<dependencyManagement>
  <dependencies>
      <dependency>
          <groupId>com.amazonaws</groupId>
          <artifactId>aws-java-sdk-bom</artifactId>
          <version>1.12.1</version>
          <type>pom</type>
          <scope>import</scope>
      </dependency>
      <dependency>
          <groupId>software.amazon.awssdk</groupId>
          <artifactId>bom</artifactId>
          <version>2.16.1</version>
          <type>pom</type>
          <scope>import</scope>
      </dependency>
  </dependencies>
</dependencyManagement>

 

2. @Configuration

public BasicAWSCredentials awsCredentials(){
        BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return awsCreds;
    }

    @Bean
    public AmazonS3 amazonS3Client(){
        AmazonS3 amazonS3 = AmazonS3ClientBuilder.standard()
                .withRegion("ap-northeast-2")
                .withCredentials(new AWSStaticCredentialsProvider(this.awsCredentials()))
                .build();
        return amazonS3;
    }

 - 기존에는 BasicAWSCredentials 를 사용하고 있었으며 Region 이 String 으로 사용되고 있어서 오타의 위험이 있다.

@Configuration
public class AmazonCloudWatchLogsConfig {

    @Value("${spring.cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${spring.cloud.aws.credentials.secret-key}")
    private String secretKey;

    public AwsBasicCredentials awsBasicCredentials(){
        AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
        return awsCreds;
    }

    @Bean
    public CloudWatchLogsAsyncClient cloudWatchLogsAsyncClient(){

        CloudWatchLogsAsyncClient cloudWatchLogsAsyncClient = CloudWatchLogsAsyncClient.builder()
                .region(Region.AP_NORTHEAST_2)
                .credentialsProvider(StaticCredentialsProvider.create(this.awsBasicCredentials()))
                .build();
        return cloudWatchLogsAsyncClient;
    }

- Ver2에서는 AwsBasicCredentials로 변경이 되었으며, new 를 사용하지 않고 create를 사용한다.

- Region 설정이 enum type으로 변경되어 사용자의 실수를 막아준다.

- 또한 필요에 따라서 HttpClient를 Netty로 변경할 수 도 있다.

 

<AWS SDK를 사용하여 AWSCloudWatchLog를 활용하는 방법>

- SDK에서 사용할 IAM을 사전에 생성할 필요가 있다.

- Log를 조회하는 것은 크게 2단계로 나누어진다.

 a. startQuery

StartQueryResponse ret = cloudWatchLogsAsyncClient.startQuery(StartQueryRequest.builder()
                                                .endTime(endTime)
                                                .startTime(startTime)
                                                .limit(n)
                                                .logGroupName("")
                                                .queryString(query)
                                                .build()
                                  ).join();

 쿼리 수행을 마치고 나서 결과값으로 unique한 queryId를 돌려주는데 이 값을 사용하여 쿼리결과를 조회한다. 

 

b. getQueryResults

GetQueryResultsResponse queryReponse = cloudWatchLogsAsyncClient.getQueryResults(GetQueryResultsRequest.builder().queryId(queryId).build()).join();

 queryId를 통해서 GetQueryResultsResponse 를 얻어올 수 있다.

여기서 주의해야 할 것은 GetQueryResultsResponse 내에는 QueryStatus가 존재하며 Running, Scheduled등 일 경우 전체결과가 조회되지 않을 수 있다는 점이다.

처음 작업할때 ComletableFuture의  join을 호출하는 시점에 모든 결과가 있을것으로 예상하여 원하는 값이 나오지 않아서 고민했었다.

 

c. Async방식

 위에서 언급한 것 처럼 AsyncClient를 사용할 경우 CompletableFuture를 return하도록 되어있기 때문에 callback을 작성하여 불필요한 대기를 최소화할 수 것으로 예상했다.

 하지만 구조자체가 응답으로 queryId를 받아와야 하고, 또 쿼리 결과를 조회할때에도 Complete 상태에 이르러야 완벽한 결과값이 세팅되는 점을 감안한다면 해당 API를 사용하는 유저시나리오는 Async-blocking에 가깝다.

 

<결론>

- 해당 기능을 통해서 AWS Cloudwatch 에 수집되고 있는 로그그룹에 접근하여 일정시간 동안 많이 입력된 키워드, 로그인한 사용자, requestUri정보 등을 집계하여 API로 제공중이다.

- 쿼리 사용시 주의해야할 점은 Scan하는 Log의 양만큼 비용을 지불하기 때문에 startTime, endTime을 적절하게 조정해야 한다.

 

<문제> 

leetcode.com/problems/set-matrix-zeroes/

 

Set Matrix Zeroes - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

Given an m x n matrix. If an element is 0, set its entire row and column to 0. Do it in-place.

Follow up:

  • A straight forward solution using O(mn) space is probably a bad idea.
  • A simple improvement uses O(m + n) space, but still not the best solution.
  • Could you devise a constant space solution?

 

Example 1:

Input: matrix = [[1,1,1],[1,0,1],[1,1,1]] Output: [[1,0,1],[0,0,0],[1,0,1]]

Example 2:

Input: matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]] Output: [[0,0,0,0],[0,4,5,0],[0,3,1,0]]

 

Constraints:

  • m == matrix.length
  • n == matrix[0].length
  • 1 <= m, n <= 200
  • -231 <= matrix[i][j] <= 231 - 1

<풀이>

- 가장 쉽게 접근하는 방법은 원본과 똑같은 공간을 하나 더 만들어서 순차적으로 탐색하는 방법이다. 이 경우 공간복잡도가 O(mn)이며 문제에서는 권장하지 않는 방법이다.

- 그 다음의 접근하는 방법은 0이 있는 위치를 기억하는 것이다. row 와 column 을 각각 체크할 수 있는 공간을 만든다면 공간복잡도는 O(m) + O(n)이 된다. 간단히 살펴보면 다음과 같다.

Row, Column에 대해서 0인 위치를 기억

// o(m+n)
    public void setZeroes(int[][] matrix) {
        int n = matrix.length;
        int m = matrix[0].length;

        int[] rowCheck = new int[n];
        int[] columnCheck = new int[m];

        for(int i=0; i<n; i++){
            for(int j=0; j< m ; j++){
                if(matrix[i][j] == 0) {rowCheck[i]=1; columnCheck[j]=1;}
            }
        }

        for(int i=0; i<n; i++){
            if(rowCheck[i]==1){
                for(int j=0;j<m;j++){
                    matrix[i][j]=0;
                }
            }
        }
        for(int j=0; j<m ; j++){
            if(columnCheck[j]==1){
                for( int i=0; i<n; i++){
                    matrix[i][j]=0;
                }
            }
        }
    }

- 최초 입력받은 matrix에서 row와 column에 '0' 인 위치를 기억해두었다가 해당 위치의 값을 변경하는 방식이다.

- 그렇다면 문제에서 제안하는 Best Solution은 무엇일까? 제공되는 힌트를 보면

# Hint 1 (추가 메모리 공간이 없도록 manipulate)

If any cell of the matrix has a zero we can record its row and column number using additional memory. But if you don't want to use extra memory then you can manipulate the array instead. i.e. simulating exactly what the question says.

# Hint 2 (불일치가 일어나지 않도록 market관리)

Setting cell values to zero on the fly while iterating might lead to discrepancies. What if you use some other integer value as your marker? There is still a better approach for this problem with 0(1) space.

# Hint 3 (row/columns를 분리해서 생각하기 보다는 하나로 가져갈수 있는방법)

We could have used 2 sets to keep a record of rows/columns which need to be set to zero. But for an O(1) space solution, you can use one of the rows and and one of the columns to keep track of this information.

# Hint 4 (! 모든 행과열의 첫번째 셀을 flag로 사용할 수 있다.)

We can use the first cell of every row and column as a flag. This flag would determine whether a row or column has been set to zero.

이와 같은 힌트를 동해 다음과 같이 접근해보자.

첫번째 행과 첫번째 열을 flag marker로 사용한다.

1. 첫번째 행과 첫번째 열을 우리가 위에서 마련했던 O(m+n) 의 공간으로 사용한다.

2. 단 이 때 기존에 세팅되어 있던 값을 보존해야 한다. 이후 matrix의 값을 "0"을 치환하는 과정에서 첫번째 행과 열의 값을 참조해야 하는데 값이 구분되지 않아서 불일치가 일어날 수 있다. 

3. 따라서 최초 첫번째 행과 열에 있던 값을 기억하여 나중에 일괄처리 할 수 있도록 하며, 2번의 변환과정의 범위에서 첫번째 행과 열은 제외해야 한다.

// constant
    public void setZeroes(int[][] matrix) {
        int m = matrix.length;
        int n = matrix[0].length;

        boolean isFirstColumnHasZero=false;
        boolean isFirstRowHasZero=false;

        //1. first column
        for(int i=0; i<m; i++){
            if(matrix[i][0] == 0){
                isFirstColumnHasZero=true;
                break;
            }
        }
        //2. first row
        for(int j=0; j<n; j++){
            if(matrix[0][j] == 0){
                isFirstRowHasZero=true;
                break;
            }
        }
        //3. check 0
        for(int i=1; i<m ; i++){
            for(int j=1; j<n ; j++){
                if(matrix[i][j]==0){
                    matrix[i][0]=0;
                    matrix[0][j]=0;
                }
            }
        }
        //4. Process 0
        for(int i=1; i<m ; i++){
            for(int j=1; j<n ; j++){
                if(matrix[i][0]==0 || matrix[0][j]==0){
                    matrix[i][j]=0;
                }
            }
        }
        //5. first row
        if(isFirstRowHasZero){
            for(int j=0;j<n;j++){
                matrix[0][j]=0;
            }
        }
        //6. first column
        if(isFirstColumnHasZero){
            for(int i=0; i<m; i++){
                matrix[i][0]=0;
            }
        }
    }

 

 

<참조>

leetcode.com/problems/set-matrix-zeroes/

 

Set Matrix Zeroes - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

 

'Algorithm' 카테고리의 다른 글

LeetCode - ReorganizeString  (0) 2020.09.07

<배경>

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

Batch Architecture 고려사항

아래 언급되는 내용들은 배치뿐 아니라 일반적인 Enterprise Application Architecture에서 공통적으로 해당되는 내용이다.

Language

 최근 금융권 시스템에도 Java가 많이 도입되면서 배치에서도 Java를 사용하는 경우가 늘어나고 있다. 하지만 Java의 경우 가상머신(VM)을 통해서 수행되기 때문에 C나 COBOL에 비해서 속도에 약점을 보일 수 있어서 배치에는 적합하지 않다는 인식도 많이 있다.

 실제로 프로젝트에서 가장 많이 나오는 불만이 "로직 변경없이 똑같이 작성하면 기존보다 느리다"는 것이다. 하지만 장점도 있다. 일반적으로 온라인과 동일한 Language를 사용하기 때문에 아키텍처 통일성이 확보되고 프로그램 재사용이 가능하다. 또한 다양한 Opensource의 활용이 용이하고 멀티쓰레드 처리에 쉽게 접근할 수 있다.

따라서 다양한 관점의 분석과 이해당사자들의 협의를 통하여 Language선택이 이루어져야 한다. 실제로 프로젝트를 수행할 때 성능 최적화에 가장 큰 요소는 Language가 아니라 업무에 관련된 부분이다.

불필요한 작업단계를 제거하거나 통합하고, CriticalPath작업의 위치를 조정하고 작업간 선/후행관계를 재조정하는 등 업무의 최적화에서 더 큰 효과를 볼 수 있다.

Resource Management

현재 시스템에서 수행되어야 할 작업의 상세한 내용을 5w1h원칙으로 정확하게 파악해야 한다.

  • who : 작업명은 무엇인가? 작업의 주체는 누구인가?
  • when : 언제 수행되는가? 일단위인가? 월단위인가?
  • what : 무엇을 수행하는 작업인가? File처리인가? 그렇다면 어느파일에 접근하는가? DB처리인가? 그렇다면 어느 테이블에 접근하는가?
  • why : 작업의 목적은 무엇인가? 어떤한 작업의 선행인가? 후행인가?
  • where : 어느서버에서 수행되는가? 어느계정으로 수행되는가?
  • how : 스케쥴링에 의해서 수행되는가? 원격호출로 수행되는가?

    또한 배치 시스템의 특성상 많은 데이터가 생성되고 참조되는데 이에 대한 관리도 필요하다. 예를 들면 다음과 같다.

  • 파일명명규칙 : 생성되는 파일에 대해서 명명규칙이 필요하다. 업무구분/보관주기/생성모듈등 정보관리가 필요하다. 특히 장기간 불필요파일들이 남아있을 경우 저장공간 부족을 야기시킬수 있기때문에 보관주기에 따라 주기적으로 정리하는 것이 좋다.
  • 테이블레이아웃 : 배치에서 LOAD/UNLOAD작업을 하기 위한 테이블 정보도 관리 되어야 한다.
  • FTP목록 : 작업결과를 FTP로 전송할 경우가 있다. 이 때 사용되는 서버/계정정보/권한 등도 관리 되어야 한다.

    이외에도 실제로 현장에서는 정보 관리주체가 각각 다르다 보니 한눈에 영향도를 파악하기 어려운 경우가 많이 있다.

Capacity

 총 작업의 개수, 각 작업이 소모하는 자원, 작업의 처리대상/주기, Peak Time등을 알고 있어야 한다. 예를 들어서 평상시에는 정상적으로 동작하던 작업이 특정요일만 되면 지연현상이 발생하는 경우가 있다. 상황을 분석해보니 특정요일 같은시간대에 수행되는 다른작업이 같은 처리대상에 접근하고 있어서 Table Lock발생하는 경우였다. 또 월초/월말에 처리해야하는 데이터의 양이 큰 폭으로 증가하여 작업시간이 지연되거나 시스템에 부하를 발생하는 경우도 있을 수 있다. RuleEngine을 사용하는 배치작업의 경우 메모리를 많이 소모하여 동시에 다수의 작업이 수행될 경우 자원부족현상이 발생할 수도 있다.

 이러한 상황을 예방하기 위해서 작업Dependency Diagram작성, 배치작업관리 메타시스템 구축등을 통해서 작업 패턴을 사전에 분석하는 등의 노력이 필요하다.

Deployment & FailOver

 시스템 장애가 발생하여 부득이하게 다른 서버를 사용할 경우를 대비하여 사전에 준비가 되어 있어야 한다. 예를 들어서 배치프로그램은 물론이고 배치프로그램들이 생성하는 File들도 동일한 버전으로 배포가 되어 있어야 하며 스케쥴러와 같은 기타 솔루션들도 유지가 되어야 한다. 또한 설정정보들 역시 신속하게 변경/적용할 수 있어야 한다. 최근에는 파일공유 솔루션이나 NAS를 통해서 어플리케이션 및 파일을 공유하여 사용하고 있다.

개발표준

 전체 개발표준 뿐만 아니라 대표적인 케이스를 선정하여 유형별 Sample코드를 제공하는 것도 하나의 방법이 될 수 있다. 배치의 경우 예를 들면 입력되는 데이터에 따라서 후행배치를 분기시키는 데몬배치도 있고 Resource를 읽어서 처리하는 비지니스 배치도 있다. 비지니스 배치도 처리하는 Resource유형에 따라서 File to File, DB to File등이 존재할텐데 이에 따른 처리패턴을 확보하여 Sample코드로 제공하면 개발뿐 아니라 성능테스트에도 많은 도움이 된다.

 

Enterprise Batch

 과거에 배치는 단순히 main()함수에 의해서 순차적으로 실행되도록 작성되는 프로그램이었고 그 수도 많지 않았으며 요건도 단순했다.

 하지만 대용량의 데이터, 매일 수백,수천만 건의 데이터 처리작업이 매일 끊임없이 발생하는 엔터프라이즈 환경에서 필수적인 배치작업을 개발/실행/운영하기 위한 아키텍처의 필요성이 대두되고 있다.

 현장에서 업무를 수행하면서 느꼈던 특히 대용량 배치시스템을 구성하기 위해서 어떠한 요소를 살펴야할지 정리해본다.

배치는 꼭 필요한 것인가

 은행업무를 예로 들어본다면 다음과 같다.

 고객이 창구에 방문하여 돈을 입금하면 직원은 돈을 받아서 금고에 넣고 원장에 금액을 기록한다. 이러한 업무는 최대한 빨리 처리해서 고객에게 결과를 주어야 한다. 빠른응답이 생명인 온라인 업무이다.

 영업시간이 종료되면 그날 있었던 거래들을 집계하고, 타 은행과 정산이 필요할 경우 돈을 주고 받기도 한다. 즉시 처리할 필요는 없지만 비지니스를 위해서는 반드시 해야하는 업무이다. 이러한 것이 배치 업무이다.

 가장 이상적인 시스템은 배치가 없이 모두 실시간으로 처리하는 것이다.

 하지만 자원은 한정되어 있으며 해야할 일은 많다. 일의 우선순위를 나눌수 밖에 없다.

배치 어플리케이션 특징

  1. 수행방식

    • 온라인 : 사용자의 요청을 항상 대기하고 있다가 요청이 들어오면 실시간으로 처리하여 결과를 돌려준다 일반적으로 다양한 채널(대외계, UI등)을 통해서 데이터를 입력받고, 해당내용이 Request로 Server에 전달되어 비지니스 처리 후 다시 Response를 통해서 응답하게 된다.
    • 배치 : 사전에 약속된 Resource로부터 데이터를 입력받고, 데이터를 처리하여 결과를 다시 Resource에 반영한다. 대부분이 정해진 일정에 따라서 자동으로 수행한다.
    • 최근에는 수천건 이내의 데이터의 경우 온라인 호출(sync or Async)을 통하여 배치를 구동하는 경우도 있다. 편의상 온라인 배치라고 부른다.
  2. 수행시간 및 자원소모

    • 온라인 : 대부분 1건단위 요청으로 비지니스처리가 이루어지며 요청에서 응답까지 3초이내로 끝나는 것이 일반적이다.
    • 배치 : 수백~수천만건단위 비지니스 처리이며 짧게는 수 분, 길게는 수 시간이 소요된다. 정해진 시간내에 수행이 완료되어야 한다.(후행작업을 위해서)
    • 따라서 배치는 한정된 자원(CPU, Memory등)을 효율적으로 사용해야 하며, 과도한 자원사용으로 시스템이 down되는 것을 방지해야 한다. 그 예로 온라인의 경우 하나의 서비스 메소드 호출시 Connection,Statement등의 자원을 사용하고 즉시 반환하여 다른 호출에 자원을 양보하지만 배치의 경우 최초 점유한 자원을 끝까지 사용하는 것이 효율적이다.
  3. 처리 패턴의 다양성

    • 배치업무의 처리패턴은 다양하다. 또한 많은 기술적요소와 업무적요소가 혼재되어 이를 구분하기 쉽지 않다. 파일전송, EAI연계, DB Load/Unload등 연계요소는 분리하여 공통화하도록 하고 업무적요소는 처리하는 Resource의 유형에 따라 분류하는 등 다양한 처리패턴에 대해서 정리하고 공통화할 필요가 있다.
  4. 논리적 계층

    • Batch Program : 일반적으로 개발자가 작업하는 하나의 단위이다. 모듈이라고 부르기도 한다. 일반적으로 데이터를 읽고 처리하여 다시 저장하는 형태이며 일부 처리결과를 타 시스템에 전달하는 경우도 있다.
    • Batch Job : 비지니스 관점에서 하나의 목표를 달성하기 위해서 구성되는 단위이다. 1개 이상의 Program을 순차적으로, 혹은 조건적으로 실행한다.
    • Batch Job Group : 각 Batch Job들의 실행조건, 선/후행관계, 실행시간 및 연관성 있는 Batch Job의 그룹이다.
    • Batch System : Batch Job Group이 모여서 기업을 서포트하기 위한 하나의 시스템이 구현된다.

 아직까지는 단일 프로그램으로 배치작업을 구성하고, 스케쥴링 대신에 Jenkins나 crontab을 활용하는 곳도 많이 있다. 또한 Stored Procedure를 활용하여 대부분의 로직을 DB에 구성하고 DB Server내에서 모든 작업을 끝내는 경우도 있다. 배치작업의 개수가 수십개정도인 소규모시스템에서는 그러한 방향이 효율적일 수 있다.

 하지만 일정규모이상의 데이터와 프로세스를 다루는 시스템을 구축하기 위해서는 배치 어플리케이션의 특징을 이해하고 이를 위한 아키텍처 구축에 많은 고민이 필요함을 알 수 있다.

 

+ Recent posts