만들리에/CropMon (스크린레코더)

스크린레코더 - 개발 스택 / 환경 구성

gamz 2021. 4. 17. 02:52

개발 툴 조사

스크린레코더의 중요한 요구사항 중의 하나는 윈도우 / 맥 / (가능하다면) 리눅스까지 지원해야한다는 것이다. 이를 위해서 자연스럽게 크로스플랫폼 네이티브 앱 개발 툴들을 조사해봤다.

 

* Qt / PyQt - 한번도 써보진 않았지만 전통을 가진 툴이라 익히 들어 한번 검토해봤다. 그런데 예제들을 보면서 이거 지금 내가 당장 썼을때 생산성이 나올까에 대한 의문이 들었다. Python으로 Wrapping한 PyQt를 보면 조금 낫긴 한데 여전히 이게 미래지향적인 방법이 맞는지에 대해 답이 잘 나오지 않았다.

 

* JavaFX - Java 개발 환경이 주는 안정감과 IntelliJ와의 궁합 등이 꽤나 매력적으로 보였다. 플랫폼 관련 코드는 JNI을 통해 다소 우아하게 연결하는 부분도 생산적일 것 같았다. 일단 킵해두고 다른걸 더 보자.

 

* Electron - 현재로써는 크로스플랫폼 Desktop 앱 개발의 표준인 것 같다. 성숙도도 높고 레퍼런스도 많았다. 무엇보다 Atom / VSCode 와 같은 킬러앱들로 이미 충분히 검증된 툴. 미래지향적인 웹기반 개발 환경. 이걸 안쓸 이유는 없어 보였다. 하지만 다른 것들도 더 조사해보자.

 

* React Native - 이건 모바일만 지원한다고 생각하고 있었는데 찾다보니 Microsoft에서 내놓은 Reactive Native for Windows / MacOS 같은 Desktop 지원 툴도 있었다. React Native 경우는 Javscript 의 뷰를 실제 Native Component와 매핑시켜서 하는 방식이라 성능이나 Look & Feel 부분을 잘 챙길 수 있는 장점이 있다. 하지만 Electron에 비해 문서나 샘플들이 부족한거 보니 Desktop 환경에서 베스트 선택인지 찝찝함이 남아있다.

 

* Flutter 2.0 - 2가 릴리즈 되면서 Desktop을 지원한다고 해서 개발 환경을 세팅해보면서 간단히 사용해봤다. VSCode나 IntelliJ 와도 잘 붙고 생산성 측면에서 괜찮아 보였다. 하지만 현재로써는 (얼리스테이지라 그런지) 플랫폼 코드를 작성하는데 있어 레퍼런스가 많이 부족하고 뭔가 군더더기가 많은 느낌이었다. 무엇보다 다른 것들과 접점이 전혀 없는 Dart를 써야한다는 점이 제일 걸렸다. 대세가 될 수는 있겠지만 이것만 하는게 아닌 이상 시너지를 낼 수 있는 선택인지에 대해 의문이 들었다.

 

선택

일단 Electron이 가장 최적의 선택이 될 것 같은 상황에서 다른 것들이 Electron을 압도할 장점이 있는지가 관건이었다. 반전은 없었다. JavaFX가 오래되긴 했지만 커뮤니티에서의 평도 좋고 안정감이 있어 보여서 조금 고민되긴했지만 오히려 React Native / Flutter는 최신 혹은 서포터가 강력하다 빼면 (이걸 빼면 안되나..) 큰 매력을 못 느꼈다. 그래서 Electron 선택 완료.

 

Electron 기반 환경 구성

Electron을 본격적으로 해본적이 없기 때문에 시작점으로 괜찮은 Boilerplate를 좀 찾아봤다. 빌드/패키지/디버깅 환경 잘 갖춰져있고 리액트(React) 기반의 간단한 코드로 시작하고 있는걸 찾고 있었는데 마침 만족스러운게 하나 있었다. electron-react-boilerplate 이건데 깃헙 스타도 꽤 많이 받았고 해볼만했다.

 

일단 코드를 하나 Clone해서 기본적인 코드 윤곽을 보아하니 어떻게 보완하고 쌓아 나가야할지 조금씩 느낌이 오기 시작했다. (그렇게 살을 붙여나갔던 몇가지 내용들을 공유해보고자 한다.)

 

1. 다수의 렌더러(Renderer)를 포함할 수 있는 소스트리 구조 변경

기본 코드는 아래처럼 메인과 렌더러 소스가 flat하게 나열된 구조였다면

src
 ├── __tests__
 ├── App.global.css
 ├── App.tsx
 ├── index.html
 ├── index.tsx
 ├── main.dev.ts
 ├── main.prod.js.LICENSE.txt
 ├── menu.ts
 ├── package.json
 └── yarn.lock

 

메인과 렌더러들을 독립적으로 추가할 수 있는 다음과 같은 구조로 변경했다.

src
 ├── main
 │    └── main.dev.ts
 ├── renderers
 │    ├── main
 │    │    ├── __tests__
 │    │    ├── App.global.css
 │    │    ├── App.tsx
 │    │    ├── index.html
 │    │    └── index.tsx
 │    └── popup
 │         ├── __tests__
 │         ├── Popup.global.css
 │         ├── Popup.tsx
 │         ├── index.html
 │         └── index.tsx
 └── package.json     

 

2. 메인과 렌더러들간의 상태 및 액션 핸들링

Electron은 네이티브 자원에 접근 가능한 Node.js 프로세스인 메인 프로세스와 UI를 위해 브라우저에서 동작하는 렌더러 프로세스로 나눠지는데 이 프로세스들간의 정보교환을 위해 IPC통신을 제공한다.

 

보통 유저의 입력이나 이벤트 등이 렌더러 프로세스에서 시작되며 그것의 핸들링이 Node.js에서 처리해야할 일이라면 IPC를 통해 렌더러 --> 메인 프로세스로 메시지를 보내게 되고 그 이벤트의 처리로 인해 앱의 상태가 바뀌고 이 상태는 다시 메인 --> 렌더러로 메시지를 통해 전해져 UI에 그 결과가 반영하는 식이다.

 

요약하면,

- Action Dispatch 및 Handling, 그로 인한 상태의 변경
- 그 상태 변화를 감지하고 화면에 반영

그런데 이거 어디서 많이 본 패턴이 아닌가? 그래, 상태 관리 시스템이다. 비록 분리된 프로세스로 돌아가는 환경이지만 Redux를 어떻게 잘 적용할 수 있지 않을까? 예전에 브라우저 익스텐션 개발할때 백그라운드 스크립트(프로세스)와 컨텐츠 스크립트(프로세스)간의 redux 통신을 가능케하는 오픈소스(tshaddix/webext-redux)를 써본적이 있어서 왠지 그런 방식으로 하면 가능할 것 같다는 생각이 들었다. 그래서 한번 구현각을 보다가 혹시나 해서 검색해봤는데... 이게 또 있더라.. :) 진짜 존경합니다.

 

 

klarna/electron-redux

Use redux in the main and browser processes in electron - klarna/electron-redux

github.com

electron-redux의 동작 방식은 이렇다.

- 메인과 렌더러들이 동일한 Store(동일한 액션과 리듀서)를 공유
- 발생한 액션을 IPC를 통해 다른 프로세스들한테 전달하는 미들웨어를 추가
- 미들웨어에서는 IPC를 통해 전달된 액션을 현재 프로세스의 스토어에 Replay해서 상태를 동기화

적용은 이런식으로 한다.

import { applyMiddleware } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import { composeWithStateSync } from 'electron-redux';

...

const middlewares = applyMiddleware(someMiddleware);
const composedEnhancer = composeWithStateSync(...[middlewares]);

const store = configureStore({
  reducer: { ... },
  enhancers: [composedEnhancer],
});

 

나는 electron-redux 알파 버전(현재 구조를 리뉴얼하고 리팩토링하는 등의 개선 작업 중)을 사용해봤고 문제없이 돌아가는 것을 확인했다.

 

3. 클린 아키텍쳐 적용

이제 핵심 비즈니스 로직들을 어떻게 담을지 고민해볼 차례. 클린아키텍쳐 관점으로 접근을 해보자. (이전에 클린아키텍쳐를 다룬 포스팅 참고)

 

엔티티 및 유스케이스와 헬퍼 등의 구조는 쉽게 정리가 되는데, Redux 부분을 클린아키텍쳐에 어떻게 붙일지가 고민이었다. 한참을 생각해도 뭔가 정리가 잘 안되는 부분이 있었는데 Redux를 Core(Domain) 영역에 둬야하나 아니면 그 바깥 영역에 둬야하나였다. Redux의 상태가 앱 전체의 상태를 나타내고 리듀서라는게 왠지 중요한 비즈니스로직을 담게되는 등 코어에 둬야할 것 같은 생각이 드는데 또 한편으로는 이건 너무 프레임워크/라이브러리라서 이걸 깊게 의존하는게 맞나 싶어서다.

 

그러다 문득 그런 생각이 들었다. Redux는 사용자와 인터랙션하고 렌더링을 위한 상태 관리 등을 담당하는게 본질인데 이는 클린아키텍쳐 관점에서는 UI에 가까운 세부사항이다. 그래서 바깥 영역의 원에 Presenter 레이어라는 영역에 두는게 맞다고 판단했다. 즉, 아래와 같은 그림으로 레이어링을 해봤다.

 

좋다. 일단 개념은 잡았으니 손을 더렵혀보자. 여기서 고민 포인트는 뭘까? 두 가지 정도가 있었다.

 

- Dependency Injection은 어떻게?

클린아키텍쳐에 따르면 Core(Domain) 영역에 해당하는 UseCase의 로직은 플랫폼 의존적인 세부 사항을 직접 의존하면 안된다. 대신 인터페이스를 통해 의존의 역전을 시켜 상세 구현 객체에 접근해야한다. 그러기 위해서는 상세 구현 객체를 인터페이스에 주입해줘야하는데 이때 DI(Dependency Injection)가 필요하다. 기왕이면 손수하는거보다 자동으로 생성 및 주입을 해주는 DI Container를 쓰면 좋기 때문에 타입스크립트에서의 DI 툴을 찾아봤다. 대표적으로는 Microsoft에서 만든 tsyringeInversifyJS가 좀 쳐주는 것 같은데 깃헙 스타 차이가 InversifyJS가 압도적이라 이걸 써보기로 했다. 공식 예제를 하나 가져와봤는데 이런식이다.

import { Container, injectable, inject } from "inversify";

@injectable()
class Katana {
    public hit() {
        return "cut!";
    }
}

@injectable()
class Shuriken {
    public throw() {
        return "hit!";
    }
}

@injectable()
class Ninja implements Warrion {
    private _katana: Katana;
    private _shuriken: Shuriken;

    public constructor(katana: Katana, shuriken: Shuriken) {
        this._katana = katana;
        this._shuriken = shuriken;
    }

    public fight() { return this._katana.hit(); };
    public sneak() { return this._shuriken.throw(); };
}

var container = new Container();
container.bind<Ninja>(Ninja).to(Ninja);
container.bind<Katana>(Katana).to(Katana);
container.bind<Shuriken>(Shuriken).to(Shuriken);

 

- 유스케이스 실행을 어디서 해야하나?

유저의 액션에 따른 비즈니스 로직을 수행하기 위해 유스케이스를 실행해야하는데 이를 어디서 하는게 적절할지도 고민 포인트였다. 쉽게 생각해서 유저의 이벤트 핸들러에서 직접 호출하거나 액션 디스패치하는 시점이라던지 리듀서(Reducer)를 수행하는 부분에서 유스케이스를 실행할 수 있지 않을까?

 

만약 이벤트 핸들러에서 바로 호출하면 이런식으로 호출하게 될 것이다.

import { CaptureUseCase } from '../../../core/usecases/capture';

const captureUseCase = diContainer.get(CaptureUseCase);

const onEventHandler = () => {
  captureUseCase.startCapture();
}

 

혹은 액션을 디스패치 하는 시점에 수행하고 싶다면 이런식으로 될 것이다.  (Redux-Thunk 이용)

import { CaptureUseCase } from '../../../core/usecases/capture';

const captureUseCase = diContainer.get(CaptureUseCase);

const captureActionAsync = (dispatch) => {
  captureUseCase.startCapture();
  dispatch(somePostCaptureAction());
}

 

그리고 조금 더 미뤄서 리듀서(Reducer)에서 수행한다면 아래 같은 식이 될 것이다.

import { CaptureUseCase } from '../../../core/usecases/capture';

const captureUseCase = diContainer.get(CaptureUseCase);

const reducer = (state, action) => {
  if (action.type === CAPTURE) {
    captureUseCase.startCapture();
    return { ...state, status: IN_PROGRESS };
  }
  return state;
}

 

그런데 세가지 방법 모두 문제가 있다.

 

일단 위 두가지 방법의 문제는 저 코드들이 유저의 이벤트가 일어나는 렌더러 프로세스에서 수행된다는 것이다. 렌더러 프로세스는 네이티브 자원을 접근할 수 없기 때문에 복잡한 비즈니스로직이 수행되는데 제약이 있다. 그렇기 때문에 저 시점에 유스케이스를 수행하는건 좋은 방법이 아니다.

 

그럼 세번째처럼 리듀서에서 수행하는건 괜찮을까? 이건 전형적인 안티패턴이다. 리듀서는 Side Effect가 없어야한다. 즉, 리듀서에서는 상태(State)만 빠르게 계산해서 넘기는게 좋은 프랙티스다. 리듀서에서 파일 I/O이나 External 호출 혹은 다른 액션을 디스패치하는 등의 일들을 수행하는건 지양해야한다.

 

그럼 어디에서 유스케이스를 호출하란 말인가. 일단 항상 메인 프로세스에서 실행되면 좋겠고, 액션디스패치-리듀서의 흐름에 연동되지만 Side Effect를 허용할 수 있는 부분이어야할 것 같다. Side Effect 허용? 여기에 딱 어울리는게 Redux-Saga다. Redux-Saga를 메인 프로세스에만 설정해두면 액션디스패치-리듀서와 자연스럽게 연동되면서도 Reducer 자체는 Pure function으로 유지한채 발생한 액션을 처리하면서 새로운 액션을 디스패치 하는 등의 작업을 수행할 수 있다.

import { put, takeLatest } from 'redux-saga/effects';

const captureUseCase = diContainer.get(CaptureUseCase);

function* handleStartCapture(action: PayloadAction) {
  const updatedContext = captureUseCase.startCapture();
  yield put(captureStarted(updatedContext));
}

function* sagaEntry() {
  yield takeLatest(startCapture.type, handlStartCapture);
}

export default sagaEntry;

 

4. 소스 코드 구조 정리

최종적으로 소스 코드 구조를 다듬어서 아래 구조로 잡아보았다. 코어에는 엔티티, 유스케이스, 유스케이스에서 사용하게 될 컴포넌트(인터페이스)가 존재하며 이것들의 구현체는 인프라스트럭처에서 플랫폼 별로 각각 구현하게 되고 DI를 통해 유스케이스에 주입되게 된다. 프리젠터에는 메인 프로세스와 렌더러들이 자리잡고 있으며 리덕스도 여기에 위치한다.

src
 ├── core
 │    ├── entities
 │    ├── components
 │    │    └── recorder.ts
 │    └── usecases
 ├── infrastructures
 │    ├── components
 │    │    ├── recorder-mac.ts
 │    │    └── recorder-win.ts
 ├── presenters
 │    ├── redux
 │    │    ├── store.ts
 │    │    └── store-main.ts
 │    ├── main
 │    │    └── main.dev.ts
 │    └── renderers
 │         ├── main
 │         └── popup
 ├── di.ts
 └── package.json     

 

5. 정리

Electron + React + Redux + Electron-Redux + Redux-Saga + InversifyJS + Clean Architecture 들을 활용하고 조합해서 꽤 괜찮은 데스크탑 네이티브 앱 개발 환경을 구성해봤다.