3. 코드 구성하기

코드만 보더라도 어떤 아키텍처인지 알 수 있다면 좋지 않을까? 보통 새로운 프로젝트를 시작할 때 가장 먼저 패키지 구조를 설계하게 된다.

계층으로 구성하기

기본적인 계층형으로 프로젝트를 생성한다면 아래와 같은 구조가 될 것이다.

1
2
3
4
5
6
7
8
9
10
buckpal
├─domain
│ ├─Account
│ ├─Activity
│ ├─AccountRepository
│ └─AccountService
├─persistence
│ └─AccountRepositoryImpl
└─web
└─AccountController

도메인, 웹, 영속성 계층별로 패키지를 만들었고 앞서 나왔던 의존성 역전을 사용하여 domain 패키지에 AccountRepository 인터페이스를 두고 persistence 패키지에 구현체를 둬서 의존성이 도메인을 바라보도록 구성되어있다.

다만 몇가지 단점이 보이는데

  1. 기능이나 특성을 구분짓는 패키지의 경계가 없다.
    • 새로운 기능(ex) 사용자관리)을 추가하려면 각 계층 패키지에 UserController, UserService, User 등을 추가하게 되는데 다른 기능과 섞이게 되면 예상치 못한 부수효과가 발생할 수 있다.
  2. 애플리케이션이 어떤 유스케이스를 제공하는지 파악하기 어렵다.
    • AccountController와 AccountService가 구체적으로 어떤 기능을 제공하는지 파악하려면 내부 구현 메서드를 살펴봐야 한다.
  3. 패키지만 봐서 의도하는 아키텍처를 짐작하기 어렵다.
    • 육각형 아키텍처라고 추측하고 웹 어댑터와 영속성 어댑터를 찾기 위해 web, persistence 패키지를 조사해볼 순 있지만 어떤 기능이 웹어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알 수 없다. 인커밍 포트와 아웃고잉 포트가 코드 속에 숨겨져 있다.

기능으로 구성하기

1
2
3
4
5
6
7
buckpal
└─account
├─Account
├─AccountController
├─AccountRepository
├─AccountRepositoryImpl
└─SendMoneyService

계좌 관련 기능을 모두 account 라는 패키지에 모았고 AccountService도 책임을 좁히기 위해서 SendMoneyService로 변경하였다.

이렇게 되면 ‘송금하기’ 유스케이스를 구현한 코드는 클래스명만 봐도 바로 찾을 수 있다.

하지만 기능을 기준으로 코드를 구성하면 기반 아키텍처가 명확하게 보이지 않아서 가시성이 많이 떨어진다는 큰 단점이 있다.

아키텍처적으로 표현력 있는 패키지 구조

육각형 아키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉(혹은 주도하거나 주도되는) 어댑터이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
buckpal
└─account
├─adapter
│ ├─in
│ │ └─web
│ │ └─AccountController
│ ├─out
│ └─persistence
│ ├─AccountPersistenceAdapter
│ └─SpringDataAccountRepository
├─domain
│ ├─Account
│ └─Activity
└─application
├─SendMoneyService
└─port
├─in
│ └─SendMoneyUseCase
└─out
├─LoadAccountPort
└─UpdateAccountStatePort

도메인 모델이 속한 domain 패키지와 도메인 모델을 둘러싼 서비스 계층을 포함하는 application패키지가 있다.

SendMoneySerivce는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.

adapter 패키지는 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함한다.

  • 책을 읽다가 곰곰히 생각해 봤지만
    패키지의 구조가 표현력이 있긴한데 아직 익숙치 않아서 한눈에 들어오진 않는다. 다만, 팀원들과 이러한 아키텍처에 대한 논의가 충분히 되고 합의된 상태에서 구조를 잡는다면 코드와 아키텍처가 직접적으로 매핑되면서 추상적이던 아키텍처가 좀 더 구체적으로 파악이 가능해진거 같기도 하다.

1. 계층형 아키텍처의 문제는 무엇일까?

계층형 아키텍처란?

계층(layer)으로 구성된 (웹) 애플리케이션은 전통적인 웹 애플리케이션 구조를 말한다.

크게보면 웹 → 도메인 → 영속성 으로 구성된 3계층으로 많이 표현되는데

  • 웹 : 요청을 받아서 서비스로 요청을 보냄
  • 도메인(비즈니스) : 필요한 비즈니스 로직을 수행하고 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층의 컴포넌트 호출
  • 영속성 : 엔티티, 리포지터리 등 데이터베이스 관련 컴포넌트

오랫동안 사용한만큼 견고한 아키텍처 패턴이 맞고 잘 이해하고 사용하면 각 계층에 독립적으로 로직을 작성하고 기존 기능에 영향없는 기능 추가도 가능하다.

다만, 계층형은 코드에 나쁜 습관들이 스며들기 쉽고 시간이 지날수록 유지보수가 힘들어지는 단점들이 있다.

계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.

계층형의 토대는 데이터베이스라서 웹은 도메인을, 도메인은 영속성을 의존하다보니 모두 데이터베이스에 의존하게 된다.

보통 애플리케이션을 만들때 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만드는데 이때 우리는 상태(state)가 아니라 행동(behavior)을 중심으로 모델링한다.

하지만 계층형의 설계는 보통 데이터베이스를 토대로 도메인 로직을 구현하는 방식이라서 아키텍처의 구현으로는 맞더라도 비즈니스 관점에선 다르다.

가장 중요한 도메인 로직을 먼저 만들어야 로직을 제대로 이해하는지 확인하고 이를 토대로 웹과 영속성 계층을 설계할 수 있기 때문이다.

ORM(object-relational-mapping, 객체 관계매핑) 프레임워크(JPA, 하이버네이트 등)를 사용하면 비즈니스 규칙을 영속성 관점에 섞고 싶은 생각이 들게 된다.

ORM에 의해 관리되는 엔티티들은 일반적으로 영속성 계층에 두고 도메인계층에선 엔티티에 접근가능한데 이러한 구조는 영속성 계층과 도메인 계층 사이에 강한 결합을 만들게 된다.

서비스에서 영속성 모델을 마치 비즈니스 모델처럼 사용하다보면 도메인 로직뿐만 아니라 영속성 계층과 관련된 작업들도 해줘야 한다.

영속성 코드가 사실상 도메인 코드에 녹아들면서 둘 중 하나만 바꾸는게 어려워져서 계층형의 목표와 대치되는 코드가 된다.

지름길을 택하기 쉬워진다.

계층형 아키텍처는 특정한 계층에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다는 규칙이 있다.

만약 상위 계층에 위치한 컴포넌트에 접근해야 한다면? 컴포넌트를 계층 아래로 내려버리면 된다. 한번은 괜찮을 수 있다. 근데 2번, 3번이 넘고 나 뿐만 아니라 다른 동료들도 그렇게 하게 되면?

유틸리티나 헬퍼 컴포넌트 등이 아래 계층으로 내려오게 되면 영속성 계층은 모든 것에 접근 가능하기 때문에 시간이 지날 수록 점점 비대해 질 것이다.

테스트하기 어려워진다.

계층형 아키텍처에서 계층을 건너뛰도록 하는 경우도 있다. 엔티티의 필드를 딱 하나만 조작하면 될 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인 계층을 건너 뛰게 된다. 이런 경우 크게 두가지 문제가 발생하는데

  1. 도메인 로직을 웹 계층에 구현하게 된다.
    만약 유스케이스가 확장된다면 더 많은 도메인 로직이 웹 계층에 추가되면서 애플리케이션 전반으로 책임이 섞이고 핵심 도메인 로직들이 퍼져나갈 수 있다.
  2. 웹 계층 테스트에서 도메인 계층뿐만 아니라 영속성 계층도 모킹(mocking)해야 한다.
    이 경우 단위 테스트의 복잡도가 올라가고 이렇게 복잡한 설정을 할 시간이 없어서 테스트를 안하게 되는 시작이 된다.

유스케이스를 숨긴다.

기능을 추가하거나 변경할 적절한 위치를 찾는 일이 빈번하기 때문에 ㅐ아키텍처는 코드를 빠르게 탐색하는데 도움이 돼야 한다.

계층형 아키텍처에서는 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉬운 환경이라 유스케이스가 “간단”해서 도메인 계층을 생략하면 웹 계층에 존재할 수도 있고, 도메인과 영속성 모두에 접근할 수 있도록 컴포넌트의 계층을 내리면 영속성 계층에 존재할 수도 있다.

이런 경우 새로운 기능을 추가할 적당한 위치를 찾기 어려워지고 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어질 수도 있다.

넓은 서비스는 영속성 계층에 많은 의존성을 갖게되고, 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 서비스는 점점 더 복잡해지고 테스트하기도 어려워진다.

동시 작업이 어려워진다.

새로운 기능을 추가하기 위해 3명의 개발자가 있을때 각 계층에 각각의 기능을 동시에 개발할 수 있을까?

계층형에선 영속성 계층 위에 모든 것이 만들어지기 때문에 영속성 계층을 먼저 개발ㄹ해야 하고, 그 다음에 도메인 계층, 웹 계층을 만들어야 한다.

동시에 한꺼번에가 아니라 한번에 한명의 개발자만 일할 수 있는 것이다.

또한 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기 어려운데 병합 충돌(merge conflict)이나 롤백이 필요한 문제가 발생할 수 있다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

올바르게 구축하고 몇 가지 추가적인 규칙들을 잘 적용한다면 계층형 아키텍처는 유지보수하기 매우 쉬워지며 코드를 쉽게 변경하거나 추가할 수 있다.

하지만 잘못된 방향으로 흘러가기 쉽다보니 계층형 아키텍처로 만들든 다른 아키텍처 스타일로 만들든, 지름길을 택하지 않고 유지보수하기에 더 쉬운 솔루션을 만드는 데 도움이될 것이다.

[WebFlux] Flux와 Stream의 reduce, 그리고 chatGPT

평소처럼 개발하던 중 reduce를 사용한 코드에서 오류가 발생했다.

1
2
3
4
5
java: cannot find symbol
symbol: variable accumulatedMap

java: cannot find symbol
symbol: variable currentMap

오류가 발생한 상황을 샘플코드로 대략적으로 재현해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// sample 1
List<Integer> list = IntStream.range(0,10).boxed().collect(Collectors.toList());
var collectMap = list.stream()
.map(i -> {
Map<String, Integer> map = new HashMap<>();
map.put("key" + i, i * 10);
return map;
})
.reduce(**Collections.emptyMap()**, (accumulatedMap, currentMap) -> new HashMap<>() {
{
putAll(accumulatedMap);
putAll(currentMap);
}
});
System.out.println("Stream reduce Map size: " + collectMap.size());
// Stream reduce Map size: 10

// sample 2
Flux.range(1, 10)
.map(i -> {
Map<String, Integer> map = new HashMap<>();
map.put("key" + i, i * 10);
return map;
})
//.reduce(**Collections.emptyMap()** ,(accumulatedMap, currentMap) -> new HashMap<>() { // compile error!! > `java: cannot find symbol`
.reduce((accumulatedMap, currentMap) -> new HashMap<>() {
{
putAll(accumulatedMap);
putAll(currentMap);
}
})
.doOnNext(map -> {
System.out.println("Flux reduce Map size: " + map.size());
})
.subscribe();
// Flux reduce Map size: 10

Stream과 Flux의 reduce를 호출 시 initial 파라미터로 각각 Collections.emptyMap()를 사용하였으나 Stream은 정상동작하지만 Flux에선 컴파일 오류가 발생하였다.

여러 케이스로 바꿔가면서 테스트를 해보니 Collections.emptyMap()의 반환값이 불변객체라서 안되는거 같다는 추론까진 도달했지만 확신은 없었다.

구글링으로 찾아보려고 했지만 공식문서를 봐도 Flux에선 안되고 Stream에선 되는 이유에 대해서 명확한 답변을 찾기 어려워서 고민하던차에 요즘 핫한 chatGPT에 물어볼까 하는 생각이 들어서 바로 실행해봤다.

아래는 chatGPT 와 대화한 내용


i want public final <A> Mono<A> reduce(A initial, BiFunction<A,? super T,A> accumulator) example code.

  • Here’s an example of how you could use the reduce method in Spring WebFlux:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import reactor.core.publisher.Flux;
    import reactor.core.publisher.Mono;

    import java.util.function.BiFunction;

    public class ReduceExample {
    public static void main(String[] args) {
    Flux<Integer> numbers = Flux.just(1, 2, 3, 4, 5);

    BiFunction<Integer, Integer, Integer> accumulator = (a, b) -> a + b;
    Mono<Integer> sum = numbers.reduce(0, accumulator);

    sum.subscribe(result -> System.out.println("The sum of the numbers is: " + result));
    }
    }

    This example creates a Flux of integers and then uses the reduce method to sum them up. The reduce method takes an initial value, in this case 0, and a BiFunction that specifies how the current accumulation (a) is combined with each element from the flux (b) to produce the next accumulation.
    The subscribe method is then used to subscribe to the Mono returned by reduce and print out the result.

initial Type is HashMap

  • Here’s an example of how you could use the reduce method with an initial value of a HashMap:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import reactor.core.publisher.Flux;
    import reactor.core.publisher.Mono;

    import java.util.HashMap;
    import java.util.function.BiFunction;

    public class ReduceExample {
    public static void main(String[] args) {
    Flux<String> words = Flux.just("apple", "banana", "cherry", "apple", "banana");

    BiFunction<HashMap<String, Integer>, String, HashMap<String, Integer>> accumulator = (map, word) -> {
    if (!map.containsKey(word)) {
    map.put(word, 1);
    } else {
    map.put(word, map.get(word) + 1);
    }
    return map;
    };
    Mono<HashMap<String, Integer>> wordCounts = words.reduce(new HashMap<>(), accumulator);

    wordCounts.subscribe(result -> System.out.println("The word counts are: " + result));
    }
    }

    This example creates a Flux of strings and uses the reduce method to count the occurrences of each word. The reduce method takes an initial value, in this case a new empty HashMap, and a BiFunction that specifies how the current HashMap (map) is combined with each word from the flux (word) to produce the next HashMap.
    The subscribe method is then used to subscribe to the Mono returned by reduce and print out the result.

Why does Mono<HashMap<String, Integer>> wordCounts = words.reduce(Collections.emptyMap(), accumulator); error occur?

  • The error occurs because Collections.emptyMap() returns an unmodifiable map, which means you can’t modify it. The reduce method expects the accumulator function to modify the accumulator object, which is not possible with an unmodifiable map.
    You can resolve the issue by creating a new empty HashMap as the initial value instead of using Collections.emptyMap(), like this:

    1
    Mono<HashMap<String, Integer>> wordCounts = words.reduce(new HashMap<>(), accumulator);

    This will give you a modifiable map that can be updated by the accumulator function as required by the reduce method.

But why does Java Stream API succeed?

  • The Java Stream API allows you to use the reduce method with an initial value of Collections.emptyMap() because the reduce operation in the Stream API operates on an internal data structure that is separate from the original data source. This allows the Stream API to handle unmodifiable maps as the initial value without any issues.
    However, in reactive programming with Spring WebFlux, the reduce method operates on the data stream directly, so you need to provide an accumulator object that is mutable. That’s why you need to use a new empty HashMap instead of Collections.emptyMap() as the initial value.

별로 기대하지 않았는데 원하는 내용에 대해서 생각보다 정확한 답변을 들을 수 있었다.

  1. 먼저 Flux의 reduce 호출 시initial가 HashMap인 케이스로 샘플코드를 요청하고 Collections.emptyMap() 을 초기값으로 사용 시 왜 오류가 나는지 물어봤고
    Collections.emptyMap()으로 생성되는 객체는 returns an unmodifiable map 즉, immutable(불변)객체인데 reduce 메서드는 초기값으로 들어온 accumulator 객체를 수정할 것으로 예상했지만 수정이 불가능한 불변객체가 들어와서 오류가 발생하는거라고 한다.
  2. Stream의 reduce에선 왜 성공하는지? 에 대해선
    Stream의 reduce는 원래 데이터와는 다른 별도의 내부 데이터 구조에서 동작하기 때문에 Collections.emptyMap()의 초기값으로 reduce를 사용할 수 있지만
    리액티브 프로그래밍 기반의 WebFlux에선 데이터 스트림에서 reduce 메서드가 직접 동작하기 때문에 변경가능한 accumulator 객체를 제공해야 하므로 변경이 가능한 빈 HashMap 객체를 생성해야 한다고 답변하였다.

앞으로 나의 코딩선생님이 하나 더 늘었다는 생각에 흥분되면서도 한편으론 chatGPT를 사용해서 구글의 코딩인터뷰 Level3도 통과했다는 뉴스처럼 문맥을 파악해서 의도한 바를 정확하게 알려주는 답변을 보고 소름이 돋았다.

[WebFlux] publishOn과 subscribeOn

회사에서 WebFlux로 개발하면서 publishOn과 subscribeOn의 사용법이 매번 헷갈려서 최대한 간단하게 정리를 해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Flux.range(1,3)
.subscribeOn(Schedulers.newBoundedElastic(1,1,"sub2"))
.doOnNext(i -> log.info("main 1: {}, number: {}", Thread.currentThread().getName(), i))
.publishOn(Schedulers.newBoundedElastic(1,1,"pub1"))
.doOnNext(i -> log.info("main 2: {}, number: {}", Thread.currentThread().getName(), i))
.subscribeOn(Schedulers.newBoundedElastic(1,1,"sub1"))
.subscribe();

// log
main 1: sub2-3, number: 1
main 1: sub2-3, number: 2
main 1: sub2-3, number: 3
main 2: pub1-2, number: 1
main 2: pub1-2, number: 2
main 2: pub1-2, number: 3

subscribeOn

subscribeOn은 호출된 곳의 앞쪽 publisher 체인과 뒷쪽의 subscriber 체인을 한꺼번에 별도의 스레드로 분리한다.
느린 publisher 와 빠른 subscriber 의 조합에서 쓰기 적합하다고 하는데… 무슨 말인지 잘 모르겠다. 외부서비스에서 데이터를 읽어올때 쓰기 좋다고 한다.

위 예시처럼 subscribeOn을 동일한 체인에서 중첩해서 사용할 경우
첫번째 subscribeOn 앞뒤 체인을 묶어서 sub2 스케줄러에서 담당하고
두번째 subscribeOn 앞뒤 체인을 묶어서 sub1 스케줄러가 담당해야 하지만 이미 sub2에서 담당해서 그대로 sub2로 돌게 된다.

publishOn

publishOn은 호출된 곳의 앞쪽 publisher 체인은 그대로 두고 뒷쪽 subscriber 체인만 별도의 스레드로 분리한다.
빠른 publisher와 느린 subscriber 일때 쓰기 좋다고 하고 외부서비스로 데이터를 쓰는 작업할 때 좋닫고 한다.
위 예시에서 subscribeOn으로 sub2 스레드에서 돌다가 publishOn을 만나면서 pub1 스케줄러에 작업을 할당하고 나머지 연산을 진행한다.

🔗 출처
Reactor Flux의 publishOn subscribeOn을 이용한 스케쥴링

Docker Desktop for window 경로 변경

회사pc에서 docker를 로컬에 설치해서 사용해볼 일이 있어서 설치를 하려는데 보안 프로그램 때문에 C드라이브에 제대로 설치가 안됐다.

D드라이브에 설치를 하고 싶었는데 Docker Desktop for window 설치파일에서 설치경로를 바꾸는 옵션을 제공하지 않고 있는데

🔗 https://github.com/docker/roadmap/issues/94
(최근까지도 기다리는 사람이 많은듯?)

도커포럼에서 해당 이슈에 대한 임시 해결책으로 링크를 생성해서 설치경로를 바꾸는 방법이 있었다.

🔗 https://forums.docker.com/t/docker-installation-directory/32773/11

docker 설치 전 cmd를 관리자모드로 실행하고 아래 명령어를 실행한다.(xxx = 본인 계정)

만약 이미 한번 설치를 해서 각 위치에 폴더가 이미 생성된 상태라 C드라이브 경로에 생성된 폴더들 다 지우고 명령어 실행하면 된다. (타겟 경로의 폴더들은 나중에 만들어줘도 됨)

1
2
3
4
mklink /j "C:\ProgramData\Docker" "D:\ProgramData\Docker"
mklink /j "C:\ProgramData\DockerDesktop" "D:\ProgramData\DockerDesktop"
mklink /j "C:\Program Files\Docker" "D:\Program Files\Docker"
mklink /j "C:\Users\xxx\AppData\Local\Docker" "D:\Users\xxx\AppData\Local\Docker"

이후 installer를 실행해보면 로그에는 C드라이브에 설치하는거로 나오지만 실제 파일은 링크를 걸어놓은 D드라이브에 생성되는 모습을 볼 수 있다.