<개요>

- Designing Data Intensive Applications 를 읽고 그 중 Transactions에 대한 내용을 정리.

- 이전글에서 Weak Isolation Levels 중 Read Commited, Snapshot Isolation(Repeatable Read)에 관련된 내용을 살펴보았고

 이번 글에서는 Preventing Lost Updates, Write Skew (Phantoms) 에 대해서 살펴본다.

- 이전글 https://icthuman.tistory.com/entry/Transactions-2

 

Transactions #2 (Atomicity, Isolation)

- Designing Data Intensive Applications 를 읽고 그 중 Transactions에 대한 내용을 정리. - 이전글 https://icthuman.tistory.com/entry/Transactions-개념정리-1 Transactions #1 (Basic) - Designing Data Intensive Applications 를 읽고 그

icthuman.tistory.com

 

<내용>

Weak Isolation Levels

 

Preventing Lost Updates

- read commited 와 snapshot isolation은 주로 동시 쓰기가 있는 경우, 읽기전용 트랜잭션의 볼수 있는 내용을 보장하는 것이다.

- 이전 글에서는 두 트랜잭션이 동시에 쓰는것에 대해서는 다루지 않았고, 특정 유형의 쓰기-쓰기 에 대해서만 살펴봤다.

- 몇 가지 다른유형의 충돌을 살펴 볼텐데 그 중 가장 유명한 것이 Lost Updates 이다.

- 아래 그림을 살펴보자.

TimeLine 1 2 3 4
User #1 getCounter 42 + 1 setCounter 43  
Data 42 42 43 43
User #2   getCounter 42 + 1 setCounter 43
 

- 두 Client간의 race condition이 발생하였다. 주로 App이 read - modify - write의 사이클을 가지고 있을때 발생한다.

- 즉 write가 발생하기 전에 그 사이에 일어난 modification을 포함하지 않기 때문이다.

 

Solutions

Atomic write Operations

- 많은 DB가 atomic update operations를 제공하기 때문에 App에서 해당 로직을 구현하지 않는 것이 좋다. 일반적인 Best Solution

 예) UPDATE counters SET val = val + 1 WHERE key = 'foo';

- 일반적으로 DB내부에서 execlusive lock을 통해서 구현하기 때문에 (update할때 읽을 수 없다!) "Cursor Stability 유지" 

- 다른 방법으로 all atomic operations를 single thread 에서 수행하는 방법도 있다. (성능 고려)

 

Explicit Locking

- DB에서 해당 기능을 제공하지 않는다면 App에서 명시적으로 update 될 object를 잠그는 방법이다. 

- 잠금을 수행후 read - modify - write 를 수행할 수 있으며, 다른 트랜잭션이 같은 object 에 접근할 경우에 첫 번째 read - modify - write 가 완료될 때 까지 강제로 대기한다.

예 )

BEGIN TRANSACTION

SELECT * FROM ... WHERE ... FOR UPDATE;  (해당쿼리로 반환된 all rows lock !)
UPDATE ... 

COMMIT;

 

Automatically detecting lost updates

- atomic operations & locks : read - modify - write 를 순차적으로 하여 lost updates 를 막는다.

- 대안 : 병렬 수행을 허락하고, Transaction Manager 가 lost update를 감지하면, 트랜잭션을 중단하고 강제로 read - modify - write 를 retry 한다!

- 장점 : DB가 이 검사를 효율적으로 수행할 수 있다는 것. with Snapshot Isolation

PostgreSQL repeatable read automatically detect
and abort the offending transaction
Orale serializable
SQL snapshot isolation
MySQL, InnoDB repetable read X

 

Compare-and-Set

- 우리가 CAS연산이라고 부르는 방법이다. DB가 Transcations를 제공하지 않는 atomic compare-and-set을 찾는 것이다. (Single-object writes)

- 마지막으로 값을 읽은 후 값이 변경되지 않았을때에만 업데이트가 발생할 수 있도록 허용하는 것이다.

- 만약 변경이 일어났다면? read-modify-write연산을 재시도한다. 반드시!

 

주의! Conflict replication

- Locks and Compared and Set은 Single up-to-date, copy of the data를 가정한다.

- replicated DB에서는 여러 노드에 복사본이 존재하고, 데이터 수정이 다른 노드에서 발생할 수 있기 때문에 다른차원의 접근이 필요하다.  즉, 다시 말하면 multi leader 또는 leaderless replication에서는 write가 동시에 발생하고, 비동기 연산이 있다면 보장할 수 없다. (Linearizability)

- 대신 "Detecting Concurrent Writes" 챕터에서 살펴본 내용처럼 concurrent writes 가 충돌된 값의 버전들을 생성하고 (App 또는 별도의 자료구조활용), 이러한 충돌을 versions를 통해서 reslove , merge하는 방법이 가능하다.

- Atomic Operations는 영향을 받지 않는다. (특히 Commutative한 Actions이라면 !)

- 슬프게도.. 많은 replicated DB에서는 기본값으로 Last Write Wins 이다.

 

<정리>

- 개인적으로 매우 유익했던 챕터이다. 결국 두 개 이상의 동시쓰기가 발생한다면 해결방법은 아래와 같이 정리할 수 있다.

 1) 해당 사이클을 통째로 묶는다.

 2) 동시수행을 제한한다. ( Lock or Single Thread )

 3) 일단 진행시켜! 에러나면 다시 시도

 

- 멀티 노드를 가지는 Database라면 여러 곳에서 동시다발적으로 데이터에 대한 복제 / 연산이 일어나기 때문에

 1) Single Leader를 통해서 제어하던지(Hbase 같은)

 2) 마지막에 Write한 값으로 저장

 3) 별도의 Application이나 자료구조를 활용하여 충돌버전을 관리하고 resolve / merge 

- 그래서 대부분의 분산병렬처리 오픈소스 진영에서 zookeeper를 사용하고 있는 듯 하다.

 

- 다음 글에서는 이 글에서 다루지 못한 Isolation Level ( Write Skew, Phantoms read )을 좀 더 자세히 살펴보고 분산환경의 Consistency 에 대해서 정리하도록 해야겠다.

<개요>

- Designing Data Intensive Applications 를 읽고 그 중 Transactions에 대한 내용을 정리.

- 이전글 https://icthuman.tistory.com/entry/Transactions-개념정리-1

 

Transactions #1 (Basic)

<개요> - Designing Data Intensive Applications 를 읽고 그 중 Transactions에 대한 내용을 정리. - 기존에 일반적인 내용들 (e.g RDB기준), 새로 추가되는 개념들 (e.g NoSQL)을 포함하여 정리 <내용> 1. Trans..

icthuman.tistory.com

 

Single-Object and Multi-Object Operation

 

Atomicity

- error 가 발생했을때 transaction은 중단이 되어야하고, 변경되었던 내용은 삭제가 되어야 한다.

- 부분적인 실패가 발생하면 안되는 것으로 이해하면 된다.

- 키워드는 all or nothing !

 

Isolation

- 동시에 수행되고 있는 transactions는 서로에 간섭하지 않아야 한다.

- 가장 많이 나오는 예제로 다음을 살펴본다.

- emails에는 우리가 받은 이메일이 저장되고, mailboxes에서는 읽지않은 메일의 수가 저장된다고 가정할때.

TimeLine 1 2 3 4
User#1 Req INSERT INTO emails
VALUES(2,'Hello', true)
    UPDATE mailboxes
SET unread=unread+1
WHERE recipient_id=2
User#1 Res OK     OK
User#2 Req   SELECT FROM emails
WHERE recipient_id='2'
SELECT * FROM mailboxes
WHERE recipient_id='2'
 
User#2 Res   'Hello',true 0  

 - emails, mailboxes 데이터간의 불일치가 발생하게 된다.

- 이러한 현상을 Dirty Read라고 하며 커밋되지 않은 데이터를 읽기 때문에 발생하는 현상이다.

 (흔히 말하는 가장 낮은 isolation level 0 : UNCOMMITED_READ)

 

Weak Isolation Levels

 

Read Committed

No dirty reads

- commit된 데이터만 읽어야 한다.

No dirty writes

- commit된 데이터만 overwrite한다.

 

COMMIT이 수행된 데이터만 읽도록 하여 Dirty Read를 막는다. 

 

Implementing read committed

- READ_COMMITED를 실제로 구현하는 방식은?

- 수정하려고 할때 lock을 얻는다. (row, document, table level )

 - 오직 한 트랜잭션만이 lock을 보유한다. 앞 트랜잭션이 끝날때까디 대기하는 것이 원칙

- Dirty Read를 방지하기 위해서 읽을 때 lock을 얻고, 읽고난 뒤 lock을 해제하는 방식은 응답시간에 악영향을 미친다.

 -> 객체들의 전/후 값을 기억하고, 트랜잭션이 진행되는 동안에 다른 트랜잭션에서 해당 객체를 읽을때에는 이전값을 제공하는 방식으로 개선할 수 있다.

 

Snapshot Isolation and Repeatable Read

Implementing snapshot isolation

- READ COMMITED의 방식대로 하면 Dirty Read의 문제를 해결할 수 있으나 아래와 같은 문제가 여전히 존재한다.

TimeLine 1 2 3 4
User#1 SELECT * FROM accounts
where id=1
    SELECT * FROM accounts
where id=2
Account#1 Account #1 = 500 Account #1 = 600    
Account#2 Account #2 = 500   Account #2 = 400 Account #2 = 400
Transfer   UPDATE accounts
SET bal = bal+100
WHERE id=1
UPDATE accounts
SET bal=bal-100
WHERE id=2;
COMMIT;
 

- User#1이 계좌를 조회했을때 각각 500, 400으로 조회가 되어 총 잔액이 900인 상태가 된다. ( 100원이 사라짐 )

- 이러한 상태를 Read Skew, Non Repeatable Read 라고 한다.

- 다시 재조회를 하면 문제가 없지만 다음과 같은 상황에서 주의해야한다.

 A) Backup : 대부분의 작업이 오래 걸리며, 일부 백업은 이전버전 데이터, 일부 백업은 최신버전 데이터를 가져가게 되면 불일치가 영구적으로 발생하게 된다.

 B) Analytics : 전체적인 통계작업등의 정합성이 맞지 않는 상황이 발생하게 된다.

 

이러한 현상을 해결하기 위해서 일반적으로 Snapshot Isolation이 사용된다.

- 아이디어는 해당 트랜잭션 시작시 Database에서 커밋된 모든 데이터를 보고 , 이후 다른 트랜잭션에 의해서 데이터가 변경되더라도 각 트랜잭션에서는 이전 데이터만 표시되는 형태이다.

- Read-Only Long query (백업,분석) 에서 유용하게 사용될 수 있다.

 

Implementing snapshot isolation

- Read Commited 처럼, Dirty Write 를 방지하기 위해서는 write lock이 필요하다.

- 하지만 읽기에서는 lock이 필요하지 않음으로 성능을 향상시킬 수 있는 포인트가 된다. 

 (Readers never block writers, Writers never block Readers) -> Lock경합없이 쓰기를 처리하면서 장시간 실행되는 Read Query를  처리할 수 있다.

 

MVCC

- Dirty Read를 방지하기 위해 사용되는 유형을 일반화 시킨 것

- 다양하게 진행되고 있는 트랜잭션들이 서로 다른 시점에서 상태를 확인하기 위해서는 결국 Object의 여러 커밋된 버전을 유지해야 한다.

- Database가 Read Comiited만 제공하고 Snapshot Isolation을 제공하지 않는 다면 단순하게 두 가지 버전으로 접근하면 된다.

 the commited version / the overwritten-but-not-yet-commited version

- 일반적으로 Snapshot Isolation을 지원한다면 Read Commited에서도 MVCC를 사용한다.

- 전체 트랜잭션에 대해서 동일한 Snapshot을 사용함으로 Snapshot Isolation을 적용하게 된다.

- 이 때 내부적으로는 어느 transaction에 의해서 데이터가 created,deleted되었는지 기록하는 방식으로 접근하며

 update 역시 create / delete의 형태로 관리하게 된다. 

 

Repeatable read and naming confusion

- Snapshot Isolation의 경우 매우 유용한 Isolation Level임에도 불구하고 많은 Database에서 각자의 이름과 방식으로 구현하고 있다.

- 예를 들어서 Oracle : serializable, PostgerSQL/MySql : Repetable Read

- 그 이유는 SQL 표준이 만들어 질때에는 snapshot isolation의 개념이 포함되지도 않았으며 만들어 지지도 않은 상태였기 때문..

- 결국 이 책에서는 Isolation Level의 표준 정의자체에 결함이 있다고 이야기한다. (모호함, 부정확함)

 특히 우리가 표준이라고 부르는 것들에는 (e.g RFC문서) 구현에 독립적이어야 하는데 그렇지 못하며, 각 Database들이 표면적으로는 표준화 되어있지만 실제로 guarantee 하는 부분에도 많은 차이가 있다.

실제로 과거에 어플리케이션 개발을 하면서 JDBC Driver의 각 구현체의 차이에 따라서 의도치 않은 결과나 버그를 얻은 적이 있었는데 아마도 DB의 영역만큼 각 벤더사에 특화된 부분이 있을까 싶다.

 

<정리>

- Database가 READ_COMMITED, SNAPSHOT ISOLATION (REPEATABLE_READ) 을 통해서

- 동시에 Write연산들이 수행되는 상황 속에서, Read only Transaction에게 어떤 방식으로 데이터를 제공하여

- 그것들을 guarantee 할 수 있는지에 대해서 정리해봤다.

- 다음 글에서는 동시에 Write연산들이 수행되면서 발생하는 문제인 Lost Updates 에 대해서 정리하도록 한다.

<개요>

- Designing Data Intensive Applications 를 읽고 그 중 Transactions에 대한 내용을 정리.

- 기존에 일반적인 내용들 (e.g RDB기준), 새로 추가되는 개념들 (e.g NoSQL)을 포함하여 정리

 

<내용>

1. Transactions

- read/write 가 일어날때 논리적인 하나의 단위로 묶어서 생각해본다.

- application에서 database에 접근할때 프로그래밍을 좀더 단순화하는데 목적이 있다고 본다. (추상화)

 

2. ACID

Atomicity, Consistency, Isolation, Durability

위와 같은 것은 일반적으로 트랜잭션의 특성으로 보고 있으며, 실제 각 DBMS에서 구현하는 방법들은 조금씩 차이가 있다.

특히, 이 책에서는 수년간 사용해왔던 이 단어들에 역시 모호한 부분들이 존재하며, 관점에 따라 차이가 있음을 소개하고 있다.

 

Atomicity

- 통상적으로 더이상 나눌수 없는 단위로 본다. 그러나 그 세부의 이해에는 약간의 차이가 있다.

- 예를 들어서 멀티쓰레드 환경에서는 하나의 쓰레드가 atomic operation을 수행할때 다른 쓰레드에서는 그 중간값을 참조할 수 없음을 의미한다. 그러나 ACID내에서 Atomicity는 concurrency의미가 아니다. 이 개념은 Isolation에 포한된다.

- ACID내에서 Atomicity는 write가 수행될때 (내부적으로 process, network, disk등의 여러 에러가 발생할 수 있는 요인이 있지만) 성공 혹은 실패가 하나로 묶이는 것을 의미한다. (committed / aborted)

 즉, "어디까지는 성공하고 어디까지는 실패하고" 가 발생하지 않도록 하는 것이 그 의미에 가까워서 이 책에서는 abortability가 좀 더 의미적으로 가깝지 않나라고 이야기한다.

 

Consistency

- 역시 다양한 영역에서 혼용되어 사용하고 있다. 예를 들어서 eventual consistency, consistent hashing, CAP, 그리고 ACID내에서 같은 단어들이지만 다른의미를 가지고 있다.

- ACID내 C의 경우 invariants가 항상 참인것에 집중한다고 볼 수 있다. 그 예로 여러가지 constraint를 이야기하고 있는데 이 책에서는 이러한 영역이 database보다는 application이 보장해야하는 것으로 보고 있다.

Data에 어떤 내용을 담을 것인가는 결국 application 에서 정해지며 A,I,D는 database의 속성이지만 consistency는 그렇게 볼수 없다는 것이다. 

- 나도 여기에 동의하는 것이 최근 NoSQL에서는 한 application의 기능에서 다루는 단위(set)를 기준으로 데이터를 구성하는 것이 일반적이며(JOIN X), 그에 따라서 각 데이터의 key값에 대한 정합성을 보장하는 것은 응답속도나 reliability에 따라서 타협하여 구현하는 모습을 통해서 체감할 수 있다고 생각한다.

 

Isolation

- 같은 record에 대해 동시에 여러 clients가 접근하여 경합이 발생했을 때 어떻게 해결할 것인가.

- 특히 우리가 일반적으로 알고 있는 Isolation level과 비슷하면서도 다른 용어, 추가적인 개념설명으로 재미있었던 부분이었다.

 (e.g dirty write, read write skew 등)

- 용어에서 설명하듯이 기본적으로 여러 transactions이 동시에 수행되더라도 각각은 별도로 격리 되어야 한다는 것이 기본적인 원칙이다. 이 때 발생할 수 있는 여러가지 데이터 이상현상이 있으며 (DIRTY READ, NON REPETABLE READ, PHANTOM READ등) 각 현상을 제거할 수 있도록 Level을 조정하여 성능과 정합성을 타협하는 것이다.

- 다만 각 database가 정의하는 isolation level는 차이가 있으며, 그것을 구현하는 방법도 다르다는 내용을 소개하고 있다.

 (e.g snapshot isolation, Inno DB consistent read 등)

 

Durability

- 결국 모든 저장소 (File, Database, Storage)는 안전하게 데이터를 저장하는 것이 목적이다.

- single-node database 에서는 이것을 보장하기 위해서 저장공간 (hdd,ssd 등)에 데이터를 기록하고, write-ahead log를 기록하고 recover event하는 형태로 접근했으며

- replicated database 에서는 복제하여 다른 노드에 저장하고, 각 transaction이 이루어질 때 write / replication을 수행하는 형태로 관리하고 있다.

 

특히 여기에는 개발자들이 놓치는 문제들이 간혹 있는데 간단히 살펴보면 다음과 같다.

- Disk에 기록한뒤 다운될 경우 Data는 잃어버리지 않으며 접근이 불가능한 것으로 접근 가능한 다른 시스템으로 대체할 수도 있다.

- Memory에 저장된 데이터는 휘발성이기 때문에 Disk가 필요하다.

- 비동기로 복제하는 시스템에서는 Leader의 상태에 따라서 최근 변경사항이 누락될 수 있다.

- Storage에도 버그나 문제는 존재하며 완벽은 없다. (e.g 정전, 펌웨어 버그 등)

- Disk는 생각보다 자주 망가진다. 갑자기 죽기도 하지만.. 천천히 망가지기도 한다. (bad sector!) 물리적인 매체가 망가진다는 것은 replicas, backup도 손상되는 것을 의미하고, 그래서 historical backup전략이 존재한다.

- 특히 SSD는 빠르지만 불량블록이 생각보다 자주 발생하고, HDD는 갑자기 죽기도 한다.
- SSD는 전원이 공급되지 않으면 온도에 따라서도 손실이 발생한다고 한다!?


결국 절대적인 보장은 이세상에 존재 하지않기 때문에 우리는 여러 기술을 복합적으로 사용하는 것이다.

 

<정리>

-  ACID는 과거부터 쭉 사용해온 용어이지만 Computing Area가 다양해지면서 비슷한 용어들의 모호함이 존재한다.

- Database에서 보장해야하는 개념들은 결국 각 벤더마다 다를 수도 있다. 

  즉 JDBC 드라이버의 실제 구현은 내가 생각하고 기대한 것과는 다를 수 있다. (과거에 update 쿼리실행 후 결과값이 변경된 건수로 나와야 하는데 0으로 나왔던 기억이 있다.)

- Consistency는 application의 영역으로 보는 것이 어떨까 라는 의견이 있다.

 (유효성을 위한 제약조건 같은 기능들은 Database가 제공할 수 있지만 결국 어떤값을 기록하는가는 Application에 달려있다.)

- 우리는 물리적인 세상에서 살고 있기 때문에 100% 보장이라는 것은 있을 수 없다.

 

Transcation에 대해서 대략적으로 살펴보았으며 다음 글에는 Atomicity와 Isolation에 대해서 좀 더 자세히 정리하도록 하겠다.

<개요>

- 기본생성자 유무

- Serializer / Deserializer 적용

- 클래스 패키지 위치

 

<내용>

Q1. 기본생성자 사용

Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                               Pageable pageable)  {
        if(loginUserDetails.isAdmin()){
            return dataService.findAllDatas();
        }
        return new PageImpl(new ArrayList(),Pageable.unpaged(),0);
    }

- Paging처리를 위해서 JPA에서 제공되고 있는 Pageable를 사용하고 있었다.

- 여기에 Redis를 저장소로 하여 캐시를 적용하면 다음과 같은 형태로 사용이 가능하다.

@Cacheable(value="findDataAll", key="#pageable.pageSize.toString().concat('-').concat(#pageable.pageNumber)")

- 당연히 잘 될것으로 예상하고 수행하였으나 다음과 같은 Exception을 만나게 되었다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot 
 construct instance of `org.springframework.data.domain.PageImpl` (no 
 Creators, like default construct, exist): cannot deserialize from Object 
 value (no delegate- or property-based Creator)
 at [Source: (byte[])"

- 기본 생성자가 존재하지 않기 때문에 발생하는 오류로

 

A1. 기본 생성자를 가진 wrapper class를 활용하여 문제를 해결할 수 있다.

@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}

 

Q2. Page클래스는 다음과 같은 형태로 해결하였으나 Java 8 부터 제공하는 LocalDateTime에 대해서도 비슷한 오류가 발생한다.

기본적으로 LocalDataTime을 저장하면 "yyyy-MM-dd'T'HH:mm:ss" 와 같은 형태로 처리되기를 원하지만 아래와 같은 형태로 저장이 된다. 그래서 json 역직렬화를 할때 문제가 발생하게 된다.

{ 
  "lastPartDate":{ 
    "year":2020,
    "month":"JANUARY",
    "dayOfMonth":1,
    "dayOfWeek":"WEDNESDAY",
    "dayOfYear":1,
    "monthValue":1,
    "hour":0,
    "minute":0,
    "second":0,
    "nano":0,
    "chronology":{ 
      "id":"ISO",
      "calendarType":"iso8601"
    }
  }
}

 

A2. 역직렬화 방안

- Spring boot 1.x 대에서는 JSR-310을 지원하지 않기 때문에 dependency 에 모듈 추가가 필요하다.

-  참조사이트에 나와있는 것처럼 Custom Serializer를 구현하는 방법도 있으며,

- Spring boot 2.x 대를 사용중이면 다음과 같이 쉽게 처리를 할 수도 있다.

@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime creationDateTime;

 

 

Q3. 혹시라도 Redis에 저장하는 Class의 패키지명등을 수정하면 아래 Exception 을 만나게 된다.

com.fasterxml.jackson.databind.exc.InvalidTypeIdException

이유는 우리가 Redis에 실제 저장된 값을 조회해보면 알 수 있는데 다음과 같다.

redis 저장정보

역직렬화에 필요한 클래스 정보가 같이 들어가있는데 기존 클래스와 다르기 때문에 에러가 발생한다.

 

A3. 해당 Cache를 evict해주도록 하자. 일반적으로 데이터가 변경되는 경우에 대해서는 Refresh를 해주지만 리팩토링작업중 클래스가 변경되었을 경우에 까먹는 케이스가 많다.

 

<정리>

- 정리를 해보면 결국 Serializer / Deserializer 에 대한 이야기이다. RedisCacheConfiguration설정을 보면 대부분 다음과 같다.

RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

 

- 특히 Json내부의 상세타입을 다시 객체화 하기 위해서는

 a. 저장되어 있는 클래스 정보가 일치하고,

 b. 기본 생성자 (혹은 그 역할을 수행할 수 있는 무언가) 가 존재해야 하며, 

 c. Serialize한것과 같은 방법으로 Deserialize를 수행해야 한다.

- Redis외의 기타 저장소를 사용할때에도 동일한 

 

<참조>

https://stackoverflow.com/questions/55965523/error-during-deserialization-of-pageimpl-cannot-construct-instance-of-org-spr

 

Error during Deserialization of PageImpl : Cannot construct instance of `org.springframework.data.domain.PageImpl`

Issue is when using Spring cache with redis cache manager, not able to deserializer Spring Pageable response due to no default constructor The spring boot version used is 2.1.4.RELEASE Redis con...

stackoverflow.com

https://github.com/HomoEfficio/dev-tips/blob/master/Java8-LocalDateTime-Jackson-%EC%A7%81%EB%A0%AC%ED%99%94-%EB%AC%B8%EC%A0%9C.md

1. 시간 복잡도 vs 공간 복잡도

일반적인 용어 정리에 따르면 다음과 같습니다.

- 시간 복잡도(Time Complexity) : 알고리즘의 수행시간

- 공간 복잡도(Space Complexity) : 알고리즘의 메모리 사용량

 

시간복잡도와 공간복잡도는 소프트웨어를 만드는 사람이라면 빼놓을 수 없는 문제입니다.

 

위의 그래프는 각 알고리즘 간의 수행속도에 대해서 비교하고 있습니다. 데이터가 적으면 큰 차이가 나지 않지만 (오히려 전후처리 때문에 시간복잡도가 더 좋지만 느려지는 경우도 있습니다.) 많아질 수록 수행시간은 어마어마한 차이가 발생합니다.

수천건의 데이터의 경우 N log N 과 N^2 상에 큰 차이가 발생하지 않지만 수십만 단위로 넘어가게 되면 응답을 받을 수 없게 됩니다.

 

공간복잡도는 쉽게 메모리의 사용량이며 시간복잡도와는 trade-off 의 관계로 알려져있지만 잘 짜여져 있는 알고리즘에서는 시간복잡도와 공간복잡도를 모두 잡는 경우도 간혹 볼 수 있습니다.

 

시간복잡도가 높은 연산은 최소화 하는 것이 사용자 응답속도를 빠르게 하는데 도움이 됩니다.

 

2. 문자열 처리

어떤 문자열의 길이를 체크하는 알고리즘은 시간복잡도가 얼마일까요? 

O(N) 입니다. 끝까지 가보면 압니다.

 

그렇다면 어떤 문자열이 허용하는 문자로 구성되어 있는지, 예를 들어서 영문소문자 + 특수문자 ('-',',' 등) 로 이루어져있는지 체크하려면 어떻게 해야할까요? 

 

우리는 일반적으로 정규표현식을 사용해서 문제를 해결합니다. 정규표현식의 시간복잡도는 얼마일까요?

음.. 자세히는 몰라도 O(N)보다는 클 것 같습니다. (정규표현식은 기본적으로 모든 케이스를 시도해보는 백트래킹에 기반하고 있습니다.)

 

사용자로부터 입력을 받았을때 매번 정규표현식으로 문자열을 판단한다면 시간이 더 걸릴 것 같습니다.

(사실... 문자열은 대부분 길지 않기 때문에 큰 영향은 없습니다.^^;; ) 

 

3. 알고리즘 문제해결 접근법

알고리즘 문제를 많이 풀어보신 분들은 느껴보셨을 겁니다. 주어진 문제상황을 그대로 구현하면 100% Time Over가 발생하는 것을...

제약조건과 주어진 문제상황을 보다 간결하게 정리하게 크게 분류할 수 있는 기본로직을 세운 뒤, 최적화 알고리즘을 적절히 사용하여 구현하는 것이 일반적인 풀이입니다. 정리해보면 다음과 같습니다.

 

- 주어진 제약, 조건, 로직 이해

- 시간복잡도 / 공간복잡도 분석

- 새로운 문제로 (재분류, 대전제, 기본로직) 재정의

- 해당 문제에 적합한 알고리즘 사용 및 구현

- 결과에 대한 검증

 

4. 비지니스 요건에 접근할 때

다음과 같은 상황이 주어진다면?

 

<요건>

- 사용자가 서비스를 등록한다.

- 서비스는 고유 Id가 존재한다.

- 각 서비스를 구분할 수 있는 서비스명이 존재한다.

- 서비스명은 영문소문자로만 20자이하로 구성된다.

- 각 서비스를 구분할 수 있는 서비스코드가 존재한다.

- 서비스 코드는 영문소문자 + '-'  20자이하로 구성된다.

- 서비스에 대한 설명을 100자 이내로 작성할 수 있다.

 

<제약사항>

- 서비스명과 서비스코드는 반드시 입력되어야 한다.

- 서비스명과 서비스코드는 공백을 허용하지 않는다.

- 서비스명과 서비스코드는 트림처리가 되어야 한다.

- 서비스설명은 공백을 허용한다.

- 서비스명과 서비스코드는 반드시 영문소문자로 시작한다.

 

이와 같이 단순하게 요건을 나열된 그대로 구현하게 되면 소스코드는 상당히 지저분하게 됩니다. 그리고 변경이 발생했을 때 유지보수성도 떨어지며 속도에도 영향을 미치게 됩니다.

 

(번외로 알고리즘 공부하다보면 발견하게 되는 것 중에... 소스가 지저분하고 라인이 점점 길어진다면?  매우 높은 확률로 오답입니다 ㅜㅜ )

 

5. 접근방법

<예시코드>

if(StringUtils.isEmpty(serviceDto.getServiceName())){
            // error
}else{
      if(StringUtils.containsWhitespace(serviceDto.getServiceName())){
            serviceDto.setServiceName(StringUtils.replace(serviceDto.getServiceName()," ",""));
            serviceDto.setServiceName(serviceDto.getServiceName().trim());
      }
}

if(StringUtils.isEmpty(serviceDto.getServiceCode())){
            // error
}else {
      if(StringUtils.containsWhitespace(serviceDto.getServiceCode())){
            serviceDto.setServiceCode(StringUtils.replace(serviceDto.getServiceCode()," ",""));
            serviceDto.setServiceCode(serviceDto.getServiceCode().trim());
      }
}

새로운 컬럼이 늘어날때마다 if-else가...;; 계속 추가됩니다..

임시방편으로 trim에 대한 로직을 DTO내의 setter로 옮길 수 있지만, 기본적인 로직이 정리되지 않은 상태라 근원적인 해결이 되지는 않습니다. 결국 해당 로직은 여러 DTO로 각각 흩어져서 유지보수가 어려워 지는 것은 똑같습니다.

 

요구사항을 바로 구현하는 습관을 버려야 합니다.

오랜 시간동안 요구사항을 읽고 이해한 뒤, 다시 재구성해야 합니다.

또한 로직을 구성할 때에는 반드시 대, 중, 소 로 접근하여 최대한 간결하게 (중복없이, 누락없이) 정리해야 합니다.

 

 

위의 상황을 제가 이해한 모양으로 다시 정리해보면 다음과 같습니다.

- 컬럼은 필수 / 선택으로 나누어진다. 필수컬럼의 경우 허용하는 문자에 대해서 패턴이 존재한다.

- 트림은 문자의 패턴에 따라서 처음이나 마지막에 공백이 올수 없음을 의미한다.

- 반드시 입력되어야 하는 값이라면 문자의 패턴은 최소길이는 1로 표현가능하다.

- 서비스명 과 서비스코드는 필수컬럼이다.

- 서비스명의 문자패턴은 영문소문자(1글자) + 영문소문자(1 ~ 19글자)

- 서비스코드의 문자패턴은 영문소문자(1글자) + 영문소문자와 '-' (1~19글자)

- 패턴은 컬럼별로 다를 수 있다.

- 선택컬럼의 경우 허용하는 문자에 대한 패턴은 없다. 그러나 문자의 길이에 대해서는 조건이 존재할 수 있다.

- 서비스설명은 선택컬럼이다.

 

뭔가 훨씬 깔끔해진 느낌이 듭니다.

 

 

이부분이 제가 생각할 때 소프트웨어 구현에서 가장 중요한 단계입니다. 일반적으로 시니어 소프트웨어 개발자, 혹은 아키텍트들이 같이 해야하는 일입니다.

 

일반인이 이야기한 비지니스 요건을 본인이 이해한바로 잘 정리하여 문제를 재구성하여 소프트웨어로 만들 수 있도록 정의 하는 것이 핵심입니다. 또한 문제해결을 위한 기본적인 알고리즘 (시간복잡도, 공간복잡도) 과 아키텍처가 설계되는 시점입니다.

 

6. 구현

 각 Layer에서 어떠한 Validation을 할것인지 정의합니다.

 

Repository에서는 Data의 무결성을 보장합니다. 일반적으로 Data의 무결성은 DBMS에서 담당하며 Network, File 등을 거쳐서 가야하기 때문에 비용이 가장 많이 듭니다.

 

역으로 Contoller 가 사용자와 가장 가까운 곳에 위치하고 있기 때문에 기본적인 것들은 여기서 걸러주는 것이 많이 도움이 됩니다.

 

기본적인 Validation 체크는 Controller 에서 수행하고 나머지 비지니스 로직에 대한 처리는 Service에서 합니다.

 

(이번 글에서는 편의상 Pattern이 비지니스의 의미를 가지고 있다고 가정했습니다만 Controller에서 처리하는 경우도 많습니다.)

 

일반적인 MVC 구조

 

 Controller Layer에서는 값의 표면적 형태에 대해서만 검증을 수행합니다.

기본적인 Length 체크만을 수행하며 위에서 언급한 것처럼 정규표현식은 시간복잡도가 높기 때문에 Contoller Layer를 정상적으로 통과한 경우에 대해서만 검증을 수행하는 것이 수행속도에도 유리하다고 판단했습니다.

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

    private Logger logger = LoggerFactory.getLogger(ServiceController.class);

    @Autowired
    private ServiceService serviceService;


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

        // serviceCode 중복 체크 수행
        ServiceDto serviceDto = serviceService.findServiceByServiceCode(service.getServiceCode());
        if(serviceDto.checkAvailablePrimaryKey()) {
            throw new ServiceAlreadyExistsException(String.valueOf(service.getServiceCode()));
        }

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

 

체크로직을 구성할 때에도 컬럼별로 비교로직을 Contoller 내에서 구현하는 것보다는 Spring @Valid를 활용하였습니다.

어노테이션 기반으로 Dto검증을 수행하기 때문에 로직이 훨씬 간결해 집니다.

@Data
@Getter
@Setter
public class ServiceDto extends AbstractCommonTable implements ServiceInformation, UserInformation {

    private Integer serviceId;

    @Size(min=1,max=20)
    private String serviceName;

    @Size(min=1,max=20)
    private String serviceCode;

    @Size(min=0,max=100)
    private String description;

 

다음은 Service Layer입니다.

 

공통적인 로직을 Service Layer 에 두면 다른 REST API 나 Controller 활용할 때에도 별도로 검증로직을 추가하지 않아도 됩니다.

또한 값의 의미를 검증하는 부분은 비지니스와 연관성이 있다고 판단하였습니다.

 

먼저 서비스명과 서비스코드의 문자패턴을 정규표현식으로 나타냅니다.

문자패턴을 정규표현식으로 나타내면서 (시작문자,종료문자,트림,길이 등) 에 대한 처리를 합니다.

public class ValidationPattern {
    public final static Pattern serviceNamePattern = Pattern.compile("(^[a-z][a-z0-9]{1,19}$)");
    public final static Pattern serviceCodePattern = Pattern.compile("(^[a-z][a-z0-9-]{1,19}$)");
}

 

이후 해당 패턴을 이용하여 검증하는 로직을 구현합니다.

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

    @Autowired
    private ModelMapper modelMapper;
    
	 public ServiceDto createService(ServiceDto serviceDto) throws DataFormatViolationException {

        String serviceCode = serviceDto.getServiceCode();
        checkServiceCode(serviceCode);

        ServiceEntity serviceEntity =modelMapper.map(serviceDto, ServiceEntity.class);
        serviceRepository.save(serviceEntity);
        return modelMapper.map(serviceEntity, ServiceDto.class);
    }
    
    private void checkServiceCode(String serviceCode) throws DataFormatViolationException {

        if(serviceCode == null){
            throw new DataFormatViolationException("Code value should be not null");
        }else{
            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)");
            }
        }
    }

 

7. 결론

두서없이 쓰다보니 글의 요점이 모호해진 것 같습니다.

정리해보면...

 

- 어떠한 문제를 해결하기 위해서 바로 코딩으로 뛰어들어서는 안된다.

- 문제를 재해석하여 나만의 방식으로 표현하고 시간복잡도 / 공간복잡도 를 정리한다.

- 로직은 최대한 간결하게!  대/중/소, 중복없이, 누락없이!

- MVC 의 경우 각 Layer의 하는 일들이 나누어져 있다.

- 만약 시간복잡도가 높은 연산이 있다면 이러한 연산은 최소한으로 해주는 것이 좋고, 이러한 필터링을 각 Layer별로 해주면 효과적이다.

- 유지보수성유연성은 반드시 따라오는 덤! 

 

 

<참고사이트>

https://ledgku.tistory.com/33

http://www.secmem.org/blog/2019/02/10/a-comprehensive-guide-to-regex/

1. Message Queue

Message Queue 는 기존 레거시부터 최근까지 계속 사용되고 있는 방식입니다.

지원하는 프로토콜(MQTT, AMQP, JMS)에 따라서 분류되기도 하며 메모리 기반/디스크 기반으로 나누어지기도 합니다.

또한 Message를 처리하는 방식에 따라서 Push / Pull 방식으로 나누기도 합니다.

그리고 Queue 의 정상적인 동작을 돕기 위해서 Broker가 개입하는 것이 일반적입니다.

 

그러나 다양한 제품 가운데 공통적인 것이 있으니 결국은 Queue 라는 것입니다.

FIFO(First In First Out)의 메시지 처리가 필요하다는 전제로 각 시스템의 특징과 요구사항에 따라 맞춰서 솔루션을 선택하면 됩니다.

 

2. Message Queue 를 사용하는 이유는?

특히 Queue 는 비동기식 메시지 처리에서 많이 사용되는데 그 이유는 다음과 같습니다.

 

- 어플리케이션을 구현할 때 메시지가 어디서 오는지(Source),  어디로 보내야 하는지(Target) 신경쓸 필요가 없다.

- Producer는 메시지를 작성하는 데에만 집중하고 메시지 전달에 관련된 (목적지, 순서, QoS 등) 항목들에 대해서는 MQ에 모두 위임한다.

- Consumer 역시 해당 메시지를 어떻게 소모하여 처리하는 지에 대해서만 집중하면 된다.

 

- Producer 와 Consumer가 보다 각자의 역할에 집중할 수 있게 도와준다.

 

3. 자료구조 Queue

특정 자료구조에 대한 정리나 코드연습은 Algorithm 카테고리에서 자세히 소개할 예정이기 때문에 간단히 살펴봅니다.

Queue

이해를 돕기 위해서 array를 이용해서 작성한 Queue Sample입니다.

class Queue { 
	private static int front, rear, capacity; 
	private static int queue[]; 

	Queue(int c) 
	{ 
		front = rear = 0; 
		capacity = c; 
		queue = new int[capacity]; 
	} 

	static void queueEnqueue(int data) 
	{ 
		// check queue is full or not 
		if (capacity == rear) {  
			return; 
		} 
		// insert element at the rear 
		else { 
			queue[rear] = data; 
			rear++; 
		} 
		return; 
	} 
 
	static void queueDequeue() 
	{ 
		// if queue is empty 
		if (front == rear) { 
			return; 
		} 

		else { 
			for (int i = 0; i < rear - 1; i++) { 
				queue[i] = queue[i + 1]; 
			} 
			if (rear < capacity) 
				queue[rear] = 0; 

			rear--; 
		} 
		return; 
	} 
} 

-queueEnqueue 

 rear 는 현재 데이터를 넣어야하는 위치의 index 입니다.

 만약에 capacity 만큼 꽉 차있다면 더 이상 데이터를 넣을 수 없습니다.

 데이터를 넣는 것이 가능하다면 삽입 후 index를 변경합니다.

 

-queueDequeue

 front는 데이터가 삭제되는 위치의 index입니다.

 front == rear 라면 비어있는 상태입니다.

 데이터를 제거하는 것이 가능하다면 제거 후 index를 변경합니다.

 

4. Queue 사용시 주의점

위의 sample소스를 통해서 Queue의 특징을 이해하셨다면 주의점도 파악하실 수 있습니다.

- Queue는 무한한 용량이 아니다. 적절히 소모가 이루어지지 않는다면 Queue의 모든 용량을 소모하게 되고 유실이 발생한다.

- Queue에는 많은 쓰레드들이 동시에 접근할 수 있다. enqueue/dequeue 동작은 thread-safe 해야 합니다.

  Java에서는 이를 위해서 concurrent 패키지를 제공합니다.

- 서로 다른 Queue에서는 순서보장이 되지 않습니다. (당연한 말씀)

 

Computer Science의 기초인 DS & Algorithm은 반드시 알고 있는 것이 좋습니다.

 

5. Apache Kafka

 많은 Message Queue 중에서 Apache Kafka는 대용량 실시간 처리에 특화된 아키텍처 설계를 통하여 우수한 TPS를 보장합니다.

기본적으로 pub-sub 구조로 동작하며, scale out과 high availability를 확보하는 것으로 유명합니다.

아키텍처상 다양한 특징이 있지만 이번 글에서는 자료구조 & 알고리즘 측면에서 살펴보겠습니다.

 

 

5-A. Multi Partition

 Kafka가 분산처리가 가능한 것은 Multi Partition이 가능하기 때문입니다.

대용량의 데이터가 들어올 수록 병렬처리를 통해서 속도를 확보할 수 있는 구조를 가지고 있습니다.

 

그렇다면 단점은 없을까요? 

서로 다른 Partition에 대해서는 순서를 보장하지 않습니다.(다른 큐로 보시면 됩니다.)


예를 들어서 Partition 0 내의 메시지들끼리, Partition 1 내의 메시지들끼리 순서를 보장하지만 Partition 0과 1사이에는 순서를 보장할 수 없습니다.

 

반드시 순서를 보장해야 하는 케이스가 존재한다면?

 - Partition 을 하나만 사용하던지

 - Partitioner를 Custom 하게 작성하여 순서가 보장되어야 하는 메시지들은 같은 Partition으로 보내도록 합니다.

 

Topic과 혼동하시면 안됩니다. 특정 Topic내에 여러 Partition이 존재하며 Broker를 통해서 각 Topic별 Parition 의 저장정보와 복제 등이 이루어집니다. 

 

5-B. Partitioner

Kafka의 Default Partitioner는 modulo 연산을 사용하여 파티션을 나누게 되어 있습니다.

Hash 알고리즘을 잘 작성하여 데이터를 고르게 분산하면 병렬처리 성능을 더 끌어올릴 수 있습니다.

간단한 Hash Function의 예제입니다.

private int hashFunction(K key){
    int val=0;
    for(int i=0; i<key.toString().length();i++){
        val+= (key.toString().charAt(i)) % size;
    }
    return val % size;
}

일반적으로 우리가 사용하는 HashMap에서도 같은 Hash값이 자주 발생할 경우 특정 row가 길어지게 되어 chaining hash table을 구현하게 되는데, 이 때 worst case로 특정 bucket에 데이터가 집중되어 메모리를 효율적으로 사용하지 못하는 경우가 발생합니다.

 

Kafka에서도 partition을 적절하게 분산하지 못할 경우 이러한 문제가 발생할 수 있습니다.

 

6. 정리

- Apach Kafka는 자료구조 Queue와 동일합니다.

- 병렬처리를 위해서 multi partiton을 지원합니다. 여러 개의 Queue 를 동시에 사용한다고 생각하면 됩니다.

- Partitioner를 어떻게 구현하는가에 따라서 partiton이 결정됩니다.

- FIFO보장이 필요한 단위에 대해서는 같은 partiton 이 적용되도록 구현합니다.

 

0. 참조

https://sarc.io/index.php/miscellaneous/1615-message-queue-mq

https://ko.wikipedia.org/wiki/%ED%81%90_(%EC%9E%90%EB%A3%8C_%EA%B5%AC%EC%A1%B0)

https://monsieursongsong.tistory.com/

https://epicdevs.com/17

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

https://www.geeksforgeeks.org/array-implementation-of-queue-simple/

소프트웨어 개발이라는 분야는 오래했다고 잘한다는 것이 보장되는 곳이 아닙니다.

끊임없이 Why 와 How 에 대해서 스스로 질문하고 공부해야 성장하는 분야인 것 같습니다.

 

1. 아키텍트 vs 개발자

 

요즘 들어서 "개발자"라는 직업이 아주 핫합니다.

그런데 IT시장에서 오래 일하신분들이 "개발자" 라는 호칭을 들었을 때 반응이 좀 다릅니다.

 

"언제까지 개발만 할거냐?" 라는 질문을 던지며 전체적인 그림을 그리는 아키텍트가 되어야 한다는 사람들이 많이 있습니다.

이런분들은 개발=코딩의 의미로 바라보는 것 같습니다.

 

그러나 "우리는 아키텍트라는 표현을 사용하지 않아. 전부 다 개발자이지" 라고 이야기하는 분들도 상당히 많습니다.

보통 이런회사의 개발자분들은 아키텍처, 설계, 개발, 테스트를 모두 합쳐서 개발로 인식하여 수행합니다.

 

비율을 정확히 따져보지는 않았지만 두 부류 모두 부르는 명칭만 다를 뿐이지 사실 해야하는 일은 비슷합니다.

 

고객과 시장의 요구사항을 만났을 때, 기술적 관점으로 그것을 재해석하여 소프트웨어로 구현해야 하는 것이 궁극적인 목표입니다.

 

 

- 아키텍처 그림만 PPT로 열심히 그리는 사람은 아키텍트가 아닙니다. 그림은 아무나 그릴 수 있습니다.

- 자기가 만드는 프로그램 하나의 개발에만 집중하는 사람은 좋은 개발자가 아닙니다.

   전체적인 구성요소와 관계를 파악하여 기능적/비기능적 요구사항을 파악하고 이에 따른 제약사항과 구현방안을 설계/개발 할 수있는 사람이 좋은 개발자입니다.

 

2. 기술 vs 기본

 

시장의 많은 개발자들끼리 만났을때 심심치 않게 벌어지는 논쟁입니다.

 

"Spring 3.0에서는 어떠했고, 4.0에와서는 어떤 것이 바뀌었으며 ..."

"그럴때는 Spring 의 PostProcessor를 사용해서 구현하고"

등등 시장에서 사용되는 최신기술들에 관심이 많은 분들입니다.

 

이런 분들의 특징은 여러가지 기술등을 효과적으로 활용하여 원하는 바를 빠르게 만들어냅니다.

이른바 "손이 빠릅니다".

 

"이렇게 하면 시간복잡도, 공간복잡도가 이렇게 되고.."
"O(N^2)을 O(NlogN)으로 줄일 수 있고..

"건별 Data가 몇 byte이니, 초당 건수를 고려하면 메모리를 bytes만큼 사용하고"

 

이런 분들은 뭔가 하나에 꽂히면 파고들어서 최적의 구현을 하는 것이 목표입니다.

계속 들여다보고 최적화 / 효율화를 반복합니다. 

그리고 "흐뭇해 하십니다"

 

결론부터 말씀드리면 둘 다 중요합니다!

 

오픈 소스의 홍수 속에서 너무나도 많은 "바퀴"들이 존재합니다. 바퀴를 다시 만들필요는 없습니다.

망치가 있는데 돌멩이로 못질을 할필요는 없죠.

그런데 핵심이 되는 원리는 비슷합니다.

 

- 내가 사용하는 모듈의 시간복잡도, 공간복잡도가 어떠한지

- 내부에서 어느부분이 동기/비동기 처리가 일어나는지 

- Input 이 100건일때와 100만건일때 속도차이는 어떠한지

 

이러한 내용을 파악하지 않고 바퀴를 가져다 만드는데만 집중하면 밸런스가 안맞습니다.

시스템이 작을때는 문제가 없지만 데이터가 늘어나고 사용자가 늘어날 수록 버티기가 힘들어집니다.

 

이름있는 Tech기업들이 개발자(소프트웨어 인력)을 채용할때 알고리즘 테스트를 하는 것은 다 이유가 있습니다.

새로운 기술이 나왔을때 빨리 습득하고, 논리적 문제해결을 바탕으로 접근하여 그 기술을 사용할줄 아는 사람이 필요합니다.

 

 

현장에서 오래 일하다보면 고객의 요구사항을 만족시키는게 급급하여 기본을 무시하는 사람들을 많이 봅니다.

"알고리즘 같은건 그냥 시간있을때나 공부하는거지"

"그게 뭐가 중요해. 내가 하고 있는 업무랑 연관 없는데"

시스템이 커지고 복잡해지면 반드시 문제가 생깁니다.

 

한편으로는 기술을 무시하는 사람도 많습니다.

"남에 만든거 가져다 쓰는게 뭐 대단하다고"

"내가 만들면 그거보다 잘 만드는데"

바퀴를 만드는 것도 중요하지만 자동차를 만드는 것도 중요합니다. 사용자가 없는 소프트웨어는 죽은 겁니다.

 

이번 카테고리에서는 이 두가지를 섞어서 글을 정리해보려고 합니다.

개발과 알고리즘은 떼려야 뗄 수 없는 관계임을 우리는 이미 알고 있습니다.

이러한 주제를 아키텍처 레벨로 확장시켜서 살펴보려고 합니다.

 

우리가 알고있는 자료구조와 알고리즘들이 아키텍처에서는 어떻게 적용되고 있는지를 자세히 공부한다면

위와 같은 논쟁도 많이 없어지지 않을까 하는 생각입니다.

 

 

ps)

3. Design Pattern & Refactoring

위의 주제와는 약간 다른 주제입니다.

급변하는 요구사항을 어떻게 하면 최소한의 비용으로 빠르고 정확하게 만족시킬 수 있을지에 대한 해결방안으로 보면됩니다.

 

최근 Reactive Programing이라는 개념이 많이 사용되고 있어서 관련하여 개념들을 정리를 해보려고 한다.

1. Event Driven

Reactive를 알기 위해서 먼저 Event Driven을 알아볼 필요가 있다.
Event Driven은 말 그대로 프로그램을 만들때 Event를 기반으로 동작하도록 하는 것이다.
가장 대표적으로 많이 사용되는 형태가 Event Bus 를 활용하는 것이다.

Apache Kafka가 마이크로 서비스 구축에서 대표적으로 Event Bus의 역할을 하고 있다고 볼 수 있다.
간단히 설명하면 다음과 같다.

<그림-1>

UI의 mouse clickm, key in등을 포함하여 (타시스템이 오는 Event가 될 수도 있다.) 어떤 Event가 발생했을 때 담당 Application은 해당 요청을 직접 보내고 응답을 기다리는 것이 아니라 Event를 생성하여 저장만 한다. 이후 그 Event에 대한 처리는 다른 Application에서 가져가서 처리하게 된다. 기본적으로 비동기식의 처리흐름을 갖게 된다.


2. Actor Model (Akka)

 위와 같은 환경에서 결국 Event Bus(혹은 큐)를 기준으로 Event생성자와 소비자는 분리되어 동작하며 불필요한 대기현상을 없애는 것이 핵심. 그리고 이때 병렬프로그래밍을 활용하게 되는데 동시성제어를 용이하게 만든 컨셉으로 Actor Model을 사용하기도 한다. Akka는 이를 이용하여 프로그래밍을 쉽게 할 수 있도록 한 프레임워크이다.

Actor Model의 핵심은 다음과 같다.

- 메시지를 보내고 잊는다.

- 메세지를 받는다.

기존의 동기식 처리도 가능하지만 비동기처리가 일반적으로 권장된다.


<장점>

- Scale-out 이 쉽다.

- lock, synchronized 등을 사용하지 않아도 된다. 

- 단순하다. 메시지를 보낸다/받는다


<단점> 

- 전체적인 로직의 파악이 어렵다. (코드 추적이 어렵다)

- 시스템 사용량이 많다. (쓰레드를 많이 생성한다.)

- 상태를 갖지 않는다. (장점이자 단점)

- 여전히 DeadLock이 발생할 여지가 있다.   


3. 동기 vs 비동기

 동기식 : 호출자는 상대방이 결과값 혹은 Exception을 줄때까지 이후 작업을 진행하지 않고 대기한다.

 비동기식 : 호출자는 상대방의 응답에 관계없이 호출 후 작업을 진행한다.

                 응답처리는 callback, Future 등을 통해서 처리한다.


간혹 동기 vs 비동기, Non-blocking vs Blocking, 싱글쓰레드 vs 멀티쓰레드의 개념을 헷갈리거나 혼재해서 사용하는 경우가 있는데 반드시 분리가 필요하다. (Non-blocking IO 는 또다른 주제이다.)


위와 같이 비동기 방식을 사용하여 Event 혹은 메시지를 생성/소비 하며 Applicatino이 동작하는 것이 Event Driven이라고 볼 수 있으며 현재 Kafka, Akka를 사용하여 많이 구성되고 있다.

Kafka를 제외하고 Akka로만 구성하는 경우도 있는데 내부에 Message Queue, MailBox등을 가지고 있기 때문에 가능하다. (권장하진 않는다)


4. Reactive programming

wikipedia의 정의에 따르면 다음과 같다.

reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change

핵심은 모든 것을 Data streams로 간주하여 처리하는데 이 흐름이 비동기로 처리된다는 것이다.

여기까지 보면 Event Driven 과 유사해보이는데 좀 더 자세히 살펴보면 반응형이라는 것이 추가된다.

검색창에 타이핑을 할 때 자동완성 검색어가 표시되거나 웹 페이지상의 표현내용이 새로고침없이 실시간으로 바뀌는 것이다.


<그림-2>

이를 구현하는 핵심은 비동기와 Observer  패턴이다.

- 어떠한 정보를 주고 받을때 비동기적으로 DataStream의 형태로 처리를 하여 메인작업에 영향을 주지 않아야 하며

- 값에 대해서 즉각 반응을 하기 위해서 Observer패턴으로 값을 관찰하여 연산을 수행한다.


쉽게 사용할 수 있도록 여러가지 라이브러리가 존재한다. (Rx... )


사실 이것보다 더 중요한 차이가 있다고 생각하는데

Reactive는 데이터의 흐름을 중심으로 프로그래밍을 한다는 것이다. 

과거에 Control,제어의 흐름을 중심으로 프로그래밍을 해왔다면 최근에는 Data의 흐름을 중심으로 프로그래밍하는 것으로 추세가 변하고 있다.

객체가 주고받는 메시지에 집중하고, 함수에 들어가는 In,Out에 집중하고, 데이터를 어떻게 변화시킬 지 map, filter, aggregation등에 집중한다.

빅데이터 분석에 반드시 필요한 데이터 수집부, 전처리부에서도 이러한 개념이 많이 필요하고, 시스템 자원의 효율화를 이끌어내기 위해서도 이러한 개념이 필요하여 최근 비슷한 컨셉이 각광받는 다고 생각한다. 

<그림-3>

이 자료를 보면 중간에 패러다임의 변화에 대해서 설명이 잘 되어 있다.(https://www.slideshare.net/jongwookkim/ndc14-rx-functional-reactive-programming)

위 자료도 몇 년전에 만들어졌고, 본인이 2015년경부터 데이터수집, DataFlow처리를 위해서 많은 OpenSource들(Nifi, Storm, Spark, Kafka, Flume)을 활용해 왔는데 프로그래밍 패러다임이 변하고 있다는 것을 막연히 느낄 수 있었다.

(http://icthuman.tistory.com/entry/Data-%EC%88%98%EC%A7%91-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%82%AC%EB%A1%80%EA%B2%80%ED%86%A0?category=541260)


Reactive와 유사한 흐름으로 Flow-based Programming 패러다임도 있다.


5. Flow-based Programming

위에서 언급했던 Flow-based Programming에 대해서 잠깐 살펴보면 위키피디아에 아래와 같이 정리가 되어있다.

In computer programmingflow-based programming (FBP) is a programming paradigm that defines applications as networks of "black box" processes, which exchange data across predefined connections by message passing, where the connections are specified externally to the processes

간단히 설명하면 어플리케이션을 블랙박스 프로세스들의 네트워크로 정의한다. 각 데이터들은 connection을 통해서 교환되며 블랙박스 프로세스들을 연결한다.

이러한 컨셉을 사용한 오픈소스가 Apache Nifi 이다. 최초 미국NSA에 의해서 개발되었으며 오픈소스로 공개되어 지금은 호튼웍스의 Data Flow 제품에 포함되어 있다.
거의 10년도 넘는기간 동안 NSA에 의해서 만들어졌다. 그만큼 컴포넌트나 지원하는 프로토콜도 많고 안정성,확장성이 뛰어나다. (그런데 오픈된 버전은 뭔가 좀 부족하다. 잘 죽는다..)

<그림-4>

직접 사용하지 않더라도 시간이 된다면 소스를 보고 분석하는 것을 추천한다.

구조가 잘 짜여있고 유연하게 설계가 되어있다.


6. Functional Programming , Immutable

Reactive Programming 을 보면 반드시 나오는 개념이 함수형 프로그래밍이다.

이 개념은 이미 많은 곳에서 사용되고 있다. (Scala, Spark, Akka 등)

그럴 수 밖에 없는 것이 최근 함수형 프로그래밍의 선두주자가 Scala인데 Spark, Akka, Kafka 등 왠만큼 병렬처리로 유명한 오픈소스들은 Scala로 개발되어 있기 때문이다.


함수형 프로그래밍은 결국 우리가 수학시간에 배웠던 함수를 프로그래밍으로 만드는 개념이다.

y=f(x) 

즉, 어떠한 입력에 대해서 내부계산에 의한 출력값을 주는 함수이다. 숨겨져있는 입/출력이 없고, 상태를 가지고 있지 않다. 

이러한 특징이 병렬처리에서 큰 장점을 가져온다.


- 함수내에서 입력을 변화시키는 것이 원칙적으로 불가능하기 때문에 immutable이 보장

- 매번 같은 수행결과를 보장할 수 있고 

- 복구/재처리가 쉽게 가능하다. 이는 원본을 수정하지 않기 때문인데 Spark를 사용해보면 filter, map등의 메서드등을 호출해도 원본에는 변화가 없으며 새로운 결과객체를 생성하여 돌려준다.

- Akka actor도 이와 유사한 개념으로 받아온 메세지에 수정을 가할 수 없으며, 다른 actor에게 메세지를 보낼 때는 새로운 메세지를 생성한다. 즉 immutable 하다.

- Scala에서 var 와 val의 차이를 찾아보면 좀 더 이해가 쉽다.

- Java도 primitive type이 immutable이긴 하지만 reflection 을 사용하면 객체레벨의 변경이 가능하기 때문에 완벽하진 않다.


7. Reactive vs Event Driven

에서 보면 수많은 사람들이 각각의 방법으로 설명을 하고 있다.

현재까지 이해한바로는 결국 여기에도 개념과 용어의 혼재가 있는 것으로 보인다.
Reactive programming 은 프로그래밍의 하나의 패러다임으로 보는 것이 바람직하고
Event Driven은 시스템을 구현하는 방법, 아키텍처에 가까워 보인다. (Event Driven Architecture라고 많이 이야기한다.)

Event도 결국 DataStream의 일종으로 처리한다면 비슷한 개념으로 볼 수 있으며 Reactive에서는 특정 구성요소등의 언급이 없는 것으로 볼때 맞지 않나 싶다.. (혼자만의 생각?)


참조


+ Recent posts