왜 작은 수정에도 하루가 걸리나요?

개발자라면 누구나 경험해봤을 겁니다. 처음엔 모든 게 순조롭게 진행되다가, 어느 순간부터 작은 기능 하나 추가하는데도 하루 종일 걸리는 상황 말이죠. 😓

"어제까지만 해도 잘 돌아갔는데, 왜 갑자기 이렇게 됐지?"
"기획자님, 그거 수정하려면 일주일은 걸릴 것 같아요..."

이런 말을 하고 있는 자신을 발견하게 됩니다. 코드는 점점 복잡해지고, 새로운 기능 추가는 점점 더 어려워집니다. 그리고 어느새 야근은 일상이 되어버리죠.

왜 이런 일이 발생할까요? 그리고 어떻게 하면 이 상황을 벗어날 수 있을까요?

이 글에서는 코드 복잡성 문제의 실체를 파헤치고, 이를 해결할 수 있는 실질적인 방법을 알아보겠습니다. 브라우저 호환성 같은 까다로운 문제도 함께 다뤄볼 거예요. 실제 코드 예시와 효과적인 디자인 패턴을 통해, 여러분의 개발 인생을 바꿀 수 있는 인사이트를 얻어가세요!

기획자의 요청이 두려워요 ㅠ_ㅠ

개발을 하다 보면 누구나 한 번쯤 이런 경험이 있을 겁니다. 처음엔 간단해 보이던 기능이 구현하다 보니 예상 외로 복잡해지는 상황 말이죠. 😓

예를 들어볼까요? SNS 앱에 댓글 기능을 추가한다고 생각해봅시다. "텍스트 입력창 하나 추가하면 되겠지!"라고 생각했다가 큰 코 다칠 수 있습니다. 멘션 기능, 이모티콘, 답글, 신고 기능 등 요구사항들이 하나둘 쌓이다 보면, 간단해 보이던 기능이 순식간에 복잡한 괴물로 변합니다. 🐉

문제는 여기서 끝이 아닙니다. 이런 복잡성이 누적되면 코드 전체가 느려지고, 새로운 기능을 추가하는 데 몇 배의 시간이 걸리게 됩니다. 그리고 그때부터 당신은 기획자의 새로운 아이디어가 두려워지기 시작하겠죠. 😱

제 경우엔 이랬답니다. 저는 피그마나 chatjs같은걸 만들기 위해 랜더링 엔진 프레임워크를 만들고 있었는데요. 오픈소스 이용자가 이슈로 텍스트 필드를 구현하라는 요청이 들어왔습니다. 처음엔 "텍스트 필드 하나쯤이야!"라고 생각했지만, 실제로 구현해보니 상황이 달랐습니다.

캔버스 위에 텍스트 필드를 직접 구현하려니 HTML의 input이나 textarea를 그대로 쓸 수가 없었어요. 이 요소들을 보이지 않게 설정하고, 키보드 이벤트를 처리한 다음 입력된 값을 캔버스에 렌더링해야 했죠. 게다가 한글 자모 결합 같은 입력 처리는 생각보다 훨씬 더 복잡했습니다. 브라우저마다 이벤트 처리 방식이 달라서 호환성 문제까지 겪었답니다.

이런 복잡한 상황이 계속 쌓이다 보니 코드는 감당할 수 없을 만큼 복잡해졌고, 작은 기능 하나 추가하는 데도 엄청난 시간이 걸리게 되었어요.

캔버스 위에서 텍스트 필드
flitter

브라우저마다 다른 동작, 이것이 개발자를 미치게 한다

한글 자모 결합 UX에 대해서 좀 더 설명해 볼게요. 한글을 타이핑 하면 자음과 모음이 결합될 때 밑줄이 표시되고, 그 상태에서 입력이 진행됩니다. 브라우저는 이를 알려주기 위해 composingstart 이벤트를 사용합니다. 이 이벤트가 발생하면 자모 결합 중이라는 신호를 받고, 드이를 기반으로 텍스트를 처리하면 됩니다. 그런데, 여기서부터 문제가 시작됩니다.

  • 크롬에서는 composingstart 이벤트가 예상대로 동작하지 않는 경우가 꽤 많습니다.
  • 사파리에서는 더 큰 문제가 있죠. 이 이벤트가 아예 작동하지 않거나 엉뚱하게 처리되는 경우가 많습니다.

결국, 우리는 브라우저마다 다르게 처리해야 하는 복잡한 상황에 직면하게 됩니다. 이때 사용하는 방법이 keydown 이벤트 객체 안에 있는 isComposing이라는 값을 통해 현재 입력 상태를 추적하는 것입니다. 하지만, 이마저도 완벽하지 않습니다. 특수 키(예: 엔터 키나 스페이스바)를 누를 때마다 예상치 못한 결과가 발생하곤 하죠.

도식: 캔버스, 사용자 입력, 그리고 input 요소의 관계

이 상황을 도식으로 한번 살펴볼까요?

+-----------------+          +-----------------------+
|  사용자 입력     |  ----->  |  input 요소            |
|  (키보드 이벤트) |          |  (보이지 않는 HTML 요소)|
+-----------------+          +-----------------------+
           |                               |
           |                               |
           V                               V
   +-----------------+         +---------------------+
   |  캔버스 (화면)    |  <------|  텍스트 입력 표시      |
   |  (텍스트 표현)    |         |  (값 전달 및 표시)     |
   +-----------------+         +---------------------+

이 도식에서 보듯이, 사용자 입력은 눈에 보이지 않는 input 요소로 전달되고, 이 요소가 입력값을 처리한 후 캔버스에 텍스트를 렌더링합니다. 여기서 이벤트 처리가 제대로 동작하지 않으면, 복잡한 예외 처리가 필요해지고, 코드의 복잡성은 점점 더 커지게 됩니다.

방법이 다 있습니다. 브리지 패턴(가교 패턴) 사용방법

이 복잡한 문제를 어떻게 해결할 수 있을까요? 복잡한 브라우저 호환성 문제와 다양한 입력 상황을 처리하기 위해, 브리지 패턴을 도입하는 것이 효과적인 방법입니다. 이 패턴을 사용하면 관심사 분리를 통해 복잡한 입력 처리 로직과 텍스트 필드를 명확하게 분리할 수 있습니다.

브리지 패턴의 개념

브리지 패턴은 인터페이스와 구현을 분리해 각각 독립적으로 관리할 수 있게 해줍니다. 이를 통해 텍스트 필드가 복잡한 입력 로직과 독립적으로 동작할 수 있으며, 브라우저 호환성 문제도 한층 쉽게 해결할 수 있습니다.

브라우저 호환성 문제를 해결하기 위한 브리지 패턴

브라우저마다 composingstart 이벤트를 제대로 지원하지 않는 상황에서, 우리는 호환성 분기 처리를 통해 텍스트 필드가 마치 정상적으로 동작하는 것처럼 만들 수 있습니다.

+----------------+           +---------------------+
|  TextField     | <--------> |  NativeInput        |
|  (위젯)         |           |  (구현)             |
+----------------+           +---------------------+
        |                                |
+----------------+           +---------------------+
|  OtherWidget   |           |  input/textarea     |
+----------------+           +---------------------+

이 도식에서 TextFieldNativeInput과 연결되어 있으며, 복잡한 입력 처리 로직과 독립적으로 관리됩니다. 브라우저 호환성 문제는 NativeInput에서 처리되므로, 텍스트 필드의 복잡성은 줄어들고 유지보수가 쉬워집니다.

구체적인 코드 예시

/**
 * @description This class serves as an abstraction layer to handle browser-specific implementations,
 * ensuring compatibility across different environments.
 * example) chrome: composingstart, composingend does not work
 */
class NativeInput {
  #isComposing: boolean = false;
  #element: HTMLTextAreaElement | null = null;
  #listeners: Partial<{
    [K in keyof InputEventType]: ((event: InputEventType[K]) => void)[];
  }> = {};
  private get element(): HTMLTextAreaElement {
    assert(!this.#disposed, "invalid access. because native input is disposed");

    if (this.#element == null) {
      if (browser) {
        this.#element = this.#createElement();
        document.body.appendChild(this.#element);
      } else {
        this.#element = {
          focus: () => {},
          blur: () => {},
          addEventListener: () => {},
          removeEventListener: () => {},
        } as unknown as HTMLTextAreaElement;
      }
    }

    return this.#element!;
  }

  #createElement() {
    const el = document.createElement("textarea");
    el.setAttribute(
      "style",
      "position: absolute; opacity: 0; height: 0; width: 0;",
    );

    el.addEventListener("input", (e: InputEvent) => {
      this.#dispatch("input", { value: this.value });

      /**
       * Even if you type a space, it seems that composing is not canceled.
       */
      if (e.isComposing && this.value[this.value.length - 1] === " ") {
        this.#setComposing(false);
        return;
      }

      this.#setComposing(e.isComposing);
    });

    el.addEventListener("keydown", (e: KeyboardEvent) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        this.#setComposing(false);
      }

      this.#setComposing(e.isComposing);

      this.#dispatch("keydown", {
        key: e.key,
        ctrlKey: e.ctrlKey,
        shiftKey: e.shiftKey,
      });
    });

    el.addEventListener("focus", () => {
      this.#dispatch("focus", undefined);
    });

    el.addEventListener("blur", () => {
      this.#dispatch("blur", undefined);
    });

    return el;
  }

  #setComposing(isComposing: boolean) {
    if (this.#isComposing === isComposing) return;
    this.#isComposing = isComposing;
    if (isComposing) {
      this.#dispatch("compositionstart", undefined);
    } else {
      this.#dispatch("compositionend", undefined);
    }
  }

  #disposed = false;
  dispose = () => {
    this.#element?.remove();
    this.#element = null;
    this.#disposed = true;
  };

  set value(newValue: string) {
    this.element.value = newValue;
  }
  get value(): string {
    return this.element.value;
  }

  focus = () => {
    this.element.focus({ preventScroll: true });
  };

  blur = () => {
    this.element.blur();
  };

  getSelection = (): [number, number] => {
    return [this.element.selectionStart, this.element.selectionEnd];
  };

  setSelection = (start: number, end: number = start) => {
    this.element.selectionStart = start;
    this.element.selectionEnd = end;
  };

  setCaret = (pos: number) => {
    this.setSelection(pos, pos);
  };

  addEventListener<K extends keyof InputEventType>(
    type: K,
    listener: (event: InputEventType[K]) => void,
  ) {
    if (!this.#listeners[type]) {
      this.#listeners[type] = [];
    }
    this.#listeners[type]!.push(listener);
  }

  #dispatch<K extends keyof InputEventType>(type: K, event: InputEventType[K]) {
    this.#listeners[type]?.forEach(listener => listener(event));
  }

  removeEventListener = (
    ...args: Parameters<HTMLTextAreaElement["removeEventListener"]>
  ) => {
    assert(false, "not implemented removeEventListener on native input" + args);
  };
}

export default classToFunction(TextField);

// Original source: https://github.com/meursyphus/flitter/blob/latest/packages/flitter/src/component/TextField.ts

위 코드에서 NativeInput 클래스는 텍스트 필드에서 복잡한 브라우저 호환성 문제를 대신 처리합니다. 덕분에 텍스트 필드는 깔끔하게 유지되며, 사용자는 마치 브라우저가 모든 것을 제대로 지원하는 것처럼 느끼게 됩니다. 이런 방식으로 우리는 복잡한 문제를 해결할 수 있습니다.

주니어에서 시니어가 되는 과정, 그 열쇠는 실전 패턴에 있다

주니어와 시니어의 큰 차이는 복잡한 문제를 효과적으로 해결하는 능력입니다. 관심사 분리디자인 패턴을 실전에서 활용하면, 여러분도 시니어의 길로 한 걸음 나아갈 수 있습니다.

이런 패턴들을 적용하면 놀라운 일이 일어납니다:

  • 코드 수정 범위가 획기적으로 줄어듭니다.
  • 오류 전파가 줄어 디버깅이 쉬워집니다.
  • 새로운 기능 추가도 부담 없이 할 수 있죠.

결과적으로, 기획자의 수정 요청에도 미소 지을 수 있게 됩니다. 😄

저도 구글의 Flutter 프레임워크에서 이런 인사이트를 얻었습니다. 관심사 분리를 철저히 적용한 구조 덕분에 복잡한 앱도 효율적으로 개발할 수 있다는 걸 깨달았죠.

하지만 이런 패턴을 적용하지 않으면? 수정 사항은 눈덩이처럼 불어나고, 예상치 못한 버그가 여기저기서 튀어나옵니다. 결국 개발자는 끝없는 야근과 스트레스의 늪에 빠지게 되죠. 이런 개발 지옥을 피하고 싶다면, 지금 당장 코드 구조를 개선해야 합니다!

시니어의 길, 그 끝에는 무엇이?

복잡한 문제를 해결하는 능력을 키우면, 개발이 훨씬 수월해집니다. 업무 효율이 높아져 시간적 여유가 생기고, 새로운 기술을 배우거나 사이드 프로젝트를 진행할 수도 있죠.

제 경우에는 이런 경험을 바탕으로 Flitter라는 오픈소스 프로젝트를 만들었습니다. 캔버스 조작을 쉽게 만드는 렌더링 프레임워크인데, SVG 출력 기능까지 있어요.

관심 있으신 분들은 GitHub에서 https://github.com/meursyphus/flitter에 ⭐️ 눌러주세요. 여러분의 스타 하나가 프로젝트에 우주의 기운을 불어넣습니다! 🌠

마무리하며, 코드의 복잡성은 피할 수 없지만, 그것을 관리하는 능력이 시니어의 표식입니다. 이제 야근은 그만하고, 여유로운 개발자의 삶을 만끽해보세요. 그리고 가끔은 오픈소스에 기여도 해보고... (제 오픈소스에도 말이죠 ㅎㅎ? 😉)

profile
스벨트쓰고요. 오픈소스 운영합니다

0개의 댓글