티스토리 뷰

 

객체지향 언어를 사용하여 개발을 수행할 때, 객체들을 어떻게 조합하고 구성하느냐에 따라 내부 보수성, 가독성 및 성능이 크게 달라질 수 있다는 점은 대부분의 개발자가 잘 알고 있을 것입니다. 그러나 이러한 사실을 알고 있더라도, 입문자가 객체지향 언어를 완전히 활용하기란 쉽지 않을 수 있습니다.

따라서 이 글에서는 입문자들이 객체지향 문법을 쉽게 공부할 수 있도록 돕기 위해 몇 가지 예시를 통해 객체지향 프로그래밍의 기본 개념을 풀어보려고 합니다.

 

유전 그리고 부모 클래스

부모 클래스(parent class 또는 super class)는 특성을 물려받기 위해 상속한 클래스를 지칭합니다, 예시로 사람의 경우 공통된 특성 그리고 물려받은 특성은 동물 또는 생물체와 같습니다.

 

입문자의 경우, 클래스의 원리를 이해하면서도 무의식적으로 절차 지향적인 문법으로 코드를 작성하게 될 수 있습니다.

이러한 경우는 꽤 빈번하게 발생하지만 대부분은 교정되므로 크게 걱정하지 않아도 되는 습관 중 하나입니다.

 

예시를 들어보겠습니다, 게임 개발에서 보여지는 동작들을 수행하는 개체를 엔티티(Entity)라고 정의하고 지칭합니다, 따라서 Player의 경우 엔티티 동작을 수행해야 하므로(Entity의 특성을 물려받아야 하므로) Player의 부모 클래스는 Entity가 될 것입니다.

 

이를 코드로 나타나면 다음과 같습니다.

abstract class Entity {
    // 해당 함수 내에서 패킷과 관련된 여러 네트워크 동작을 수행할 수 있습니다.
    translate(p: Position) { ... }
}

abstract class Animal extends Entity {
    setState(value: EntityState) {
        ...
    }
}

class Player extends Animal { // Player is Entity
	...
}

 

여기서 Entity와 Animal 클래스는 추상화되어 있다는 것을 알 수 있습니다, 이는 특정 고유한 개체를 지칭하거나 정의하는 것이 아니기 때문입니다, 이 경우 특별한 경우가 아니라면 무조건 추상화되어야 합니다.

 

해당 코드를 시각적으로 표현하면 다음과 같습니다.

 

 

추상화 그리고 모듈화

추상화와 모듈화, 우리가 객체 지향 문법을 생각할 때 가장 먼저 떠오르는 단어들입니다. 하지만 이러한 말과 반대로 추상적 구조는 논리적 구조와 다르게 입문할 때는 정확하게 이해하거나 활용하기 어려울 수 있습니다, 우리가 추상적인 말을 잘 이해하지 못하는 것처럼요.

 

따라서 우리는 이를 쉽게 이해하기 위해 효율적으로 여러 다양한 광고 유형들을 관리를 할 수 있는 코드 예시를 간단하게 살펴볼 필요가 있습니다.

abstract class Ad<T> {
    abstract load(...): Promise<T>;
}

class BannerAdData {
    constructor(public body: string) { ... }
}

class BannerAd extends Ad<BannerAdData> {
    async load(...): Promise<BannerAdData> {
        return new BannerAdData("hello world");
    }
}

 

해당 코드를 바라봤을 때 생각나는 것은, 그리 복잡한 경우를 요하지 않는다면 매우 이상적인 코드라는 것입니다.

해당 코드도 충분히 이상적이고 효율적이며 충분히 코드의 확장성을 가질 수 있도록 잘 구성되어 있습니다.

 

비슷한 기능을 수행하는 클래스를 여러개의 클래스로 잘게 쪼개서 나눈다, 이러한 방법을 사용하여 모듈화를 하는 경우가 있는 경우가 있으나 해당 경우는 객체 지향을 잘못 이해한 경우입니다. (사실 이러면 비슷한 기능을 수행하고 상호작용해야 되는데 불필요한 코드가 매우 많아지고 건드릴 수도 없는 상황까지 갈수도 있습니다.)

 

여기서 우리가 알아야 할 것은 과도한 객체 지향 구조가 후임 개발자 또는 기여자에게 높은 스트레스와 인지부조화를 일으킬 수 있다라는 사실을 인지하고 있어야 한다는 것입니다. (필요할 때 확장하고 필요 없다면 축소한다, 이는 대부분의 상황에서 쉽게 볼 수 있는 현상입니다.)

 

다만, 구조와 동작들이 많아지고 복잡해진다면 우리는 여기서 한 발짝 더 나아가야만 합니다.

객체들을 각각 부품화하여 객체들의 동작에 고유성을 부여하고(이는 모듈화라고 볼 수 있습니다) 개발자는 이로 인한 더 높은 확장성과 높은 내부 보수 용이성을 가질 수 있습니다.

 

해당 경우가 왜 이점이 되는 것인지, 조금 더 쉽게 이해하기 위해 컴퓨터로 예시를 들어보겠습니다.

컴퓨터는 CPU, GPU, RAM 등 다양한 부품으로 구성되어 있으며, 각 부품은 독립적으로 작동하면서 서로 협력하여 전체 시스템의 기능을 구현합니다.

 

컴퓨터가 만약에 부품화되어 있지 않고 하나의 프로세스에서 모든 연산과 기능을 수행한다면 이는 사용자에게나 개발자에게나 크나 큰 단점이 됩니다.

 

마찬가지로 객체 지향 프로그래밍에서도 각 객체는 독립적인 기능을 담당하며, 필요에 따라 다른 객체와 상호 작용하여 전체 시스템을 구성합니다. 이러한 모듈화는 코드의 유지보수성과 확장성을 높이는 데 크게 기여합니다.

 

컴퓨터의 부품을 교체하듯이, 객체 지향 프로그래밍에서도 객체를 다른 객체로 쉽게 교체하거나 확장할 수 있습니다. 이는 코드의 유연성을 높이고 새로운 기능을 추가하는 것을 용이하게 합니다.

 

그렇다면 또 다른 예시 코드를 한번 보겠습니다.

abstract class Ad<T> {
    abstract createAdRequest(): AdRequest<T>;
    
    async load(): Promise<T> {
    	return await this.createAdRequest().load();
    }
}

abstract class AdRequest<T> {
    abstract load(): Promise<T>;
}

class BannerAdData {
    constructor(public body: string) { ... }
}

class BannerAdRequest extends AdRequest<BannerAdData> {
    constructor(
        public id: string,
    ) {
        super();
    }

    async load(): Promise<BannerAdData> {
        return new BannerAdData("hello world");
    }
}

class BannerAd extends Ad<BannerAdData> {
    constructor(public id: string) {
        super();
    }

    createAdRequest(): AdRequest<BannerAdData> {
        return new BannerAdRequest(this.id);
    }
}

 

우선 첫 번째 코드에서와 다르게 클래스 하나에 모든 기능을 집중하고 있지 않고 분리함으로써 각각 특정 책임을 가지게 하고 있으며, 이를 통해 코드가 더 명확하게 구분되고 유지보수가 용이한 코드가 탄생했습니다, 이는 시스템이 확장되거나 변경될 때 유연성이 떨어질 수 있는 기존 첫 번째 코드에서와 달리 해당 문제를 충분히 해결할 수 있는 구조라고 볼 수 있습니다. (자세한 것은 퍼사드(Facade) 패턴 그리고 관심사 분리 원칙을 참고하세요!)

 

해당 코드를 그림으로 표현하면 다음과 같습니다.

 

 

상황에 따라 다르지만, 여러분들이 잘 이해했다면 두 번째 코드는 복잡한 광고 시스템을 구축하는 데 더 적합하다는 것을 깨달았을 것입니다. (첫 번째 코드는 나쁜 경우라는 것은 아니며 단순하고 직관적이며 작은 규모의 프로젝트나 간단한 광고 시스템에 충분히 적합한 구조입니다, 오해하는 경우는 없길 바랍니다.)

 

여기서 주의깊게 바라봐야 할 것은 Ad 클래스는 기본적으로 항상 자신에게 맞는 AdRequest를 생성하는 함수를 제공해야 한다는 것입니다.

 

해당 패턴을 팩토리 메서드(Factory Method)라고 합니다.

대충 주요 사용 이유는 다음과 같습니다.

 

  • 클라이언트 코드와의 결합도 감소: 클라이언트 코드에서 팩토리의 인터페이스를 통해 객체를 생성하므로 구체적인 클래스에 대한 의존성이 감소합니다. 이는 코드의 유지보수와 확장성을 높이는 데 도움이 됩니다.
  • 단일 책임 원칙 준수: 추상 팩토리 패턴은 단일 책임 원칙을 따르기 위한 좋은 방법 중 하나입니다. 팩토리는 객체 생성에 대한 책임만을 갖고 있으며, 이는 코드의 응집성을 높이고 유지보수를 쉽게 만듭니다.
  • 개방/폐쇄 원칙 준수: 기존 클라이언트 코드를 수정하지 않고 여러 새로운 기능을 도입할 수 있게 해줍니다, 이는 많은 사람들이 공유해서 사용하는 오픈소스 SDK 또는 오픈소스 라이브러리에서 매우 큰 장점이 됩니다.

 

하지만 이러한 말을 들어도 당연히 잘 이해할 수 없을 것입니다, 제대로 이해하지 않고 외우기만 한다면 무슨 의미가 있을까요. 따라서 서로 대비되는 여러 자세한 예시들을 한번 더 볼 필요가 있습니다.

 

class Listenable { ... }

abstract class ScrollController {
    abstract createState(initialValue: number): ScrollState;
}

abstract class ScrollState extends Listenble {
    abstract moveTo(at: number);
}

// 기본적으로 제공되는 코드

class AndroidScrollController extends ScrollController {
    createState(initialValue: number): ScrollState {
        return new AndroidScrollState(initialValue);
    }
}

class AndroidScrollState extends ScrollState {
    value: number;
    
    constructor(initialValue: number) {
        super();
    	this.value = initialValue;
    }

    moveTo(at: number) {
    	this.value = at;
        this.notifyListeners();
    }
}

 

해당 코드는 팩토리 메서드 패턴을 사용한 코드의 또 다른 예시 중 하나입니다.

이제 클라이언트 코드의 예시를 보며 해당 코드가 어떻게 뛰어난 확장성을 가질 수 있게 하는지 함께 보도록 하겠습니다.

 

// 클라이언트 측
class A extends Widget {
    build(...): Widget {
    	return isAndroid
            ? Scrollable(controller: new AndroidScrollController())
            : Scrollable(controller: new IOSScrollController());
    }
}

// 라이브러리 측
class Scrollable extends Widget {
    constructor(controller: ScrollController) {
    	this.state = controller.createState(this.cachedOffset ?? 0.0);
    }

    build(...) {
    	this.state.moveTo(100);
        // ... 자세한 내용 생략
    }
}

 

이와 같이 기존에 토대로(foundation) 제공되는 코드를 수정하지 않고 클라이언트에서 별도의 코드를 작성하여 기능을 확장하거나 변경할 수 있습니다.

 

슬슬 여러분들이 눈치채셨겠지만, 이러한 코드 구조는 오픈소스, 기본 토대를 구현하는 코드에 적합하다는 것을 깨달았을 것입니다. (꼭 그런 것은 아니지만 상황에 따른 중요도와 장점이 극대화된다는 뜻입니다.)

 

더보기

(참고: 다음 글에서는 MVC, MVP, MVVM 또는 빌더(Builder) 등등 여러 패턴에 대해서 자세히 알아보는 시간을 가지겠습니다.)

 

이런 다양한 패턴 하나하나 이해하려고 노력하면서 별 짓거리를 다 하는 것은 개인적으로 별로 중요하지 않습니다.

 

사실 막상 입문자가 직접 코드를 작성하게 되면 이러한 객체 지향 원칙과 패턴들을 알고 있는 상태라도 정작 필요한 곳에서 객체 지향 문법을 사용하지 않은 경우가 많습니다. 이러한 경우가 발생하는 이유는 바로 코드를 작성할 때 논리적 구조로 바라보는 관점 차이 때문입니다, 즉 습관적으로 코드를 논리적 구조(절차적)로 이해하고 작성하려고 하기 때문입니다.

 

따라서 지금부터 우리는 객체지향 문법을 잘 활용하기 위해 어떠한 관점을 가져야 하는지를 한 번 알아볼 예정입니다.

 

현실 세계에서의 경기장, 제스처들의 경쟁!

제스처 처리는 클라이언트 측에서 이루어지며, 보통 웹 프론트 개발에서는 객체지향보다는 모듈화에 더 중점을 두는 경우가 많습니다. 하지만 다양한 제스처를 커스텀하거나 여러 제스처 동작을 처리하려면 객체지향화를 통해 개발자의 인지부조화를 최소화해야 합니다. (정서 건강을 위해서)

여러 제스처가 동시에 발생하면 우선순위 및 기타 처리 방법을 통해 하나의 제스처 동작으로 결정하는 것을 제스처 명확화라고 합니다. 개발자의 인지 부조화의 가장 큰 원인은 이 제스처 명확화 로직에서 발생합니다. 절차지향적 방법으로 이러한 경쟁 시스템을 개발하면 다양한 변수로 인해 버그가 발생하기 쉽고, 개발자는 큰 스트레스를 받을 수 있습니다.

 

절차지향적 문법을 사용하면 비교적 간단한 문법과 짧은 코드 길이를 장점으로 가질 수 있지만, 복잡한 상호작용을 처리하기에는 어려움이 많습니다. 반면 객체지향적 방법을 사용하면 유지보수와 확장이 용이해집니다. 이제 절차지향적 코드와 객체지향적 코드를 비교해보겠습니다.

 

절차지향적 코드 예시를 보겠습니다. (Tap, Double Tap 만을 고려함)

const button = document.getElementsByTagName("button")[0];
const onTap = () => console.log("tap");
const onTapRejectable = () => console.log("tap is rejectable");
const onTapAccept = () => console.log("tap accept");
const onTapReject = () => console.log("tap reject");
const onDoubleTap = () => console.log("double tap");

let tapRejectable = false;
let tapCount = 0;
let timerIds = [];

button.onpointerdown = () => {
    if (tapCount == 0) {
        timerIds.push(setTimeout(() => {
            tapRejectable = true;
            onTapRejectable();
        }, 250));
    }
}

button.onpointerup = (e) => {
    if (++tapCount == 1) {
        if (tapRejectable) {
            onTapAccept();
            reset();
        } else {
            timerIds.push(setTimeout(() => { // is tap
                onTap();
                reset();
            }, 250)); // 250 ms
        }
    } else { // is double tap
        onDoubleTap();
        reset();
    }
}

button.onpointerleave = () => button.onpointercancel();
button.onpointercancel = () => {
    if (tapRejectable) {
        onTapReject();
        reset();
    }
}

function reset() {
    tapRejectable = false;
    tapCount = 0;
    timerIds.forEach(id => clearTimeout(id));
}

 

흠... 여러분들은 혹시 해당 코드를 보고 곧바로 이해하면서 해당 코드를 수정할 수 있나요?, 그렇다면 해당 코드를 보면서 어떠한 생각부터 들었나요?

 

그다지 좋지 못한 기분과 경험일 것입니다. 해당 코드를 이해하는데 당연히 사람이라면 애를 먹을 것입니다.

 

해당 코드는 동작들이 분리되어 있지 않으며, 여러 상호작용 중인 코드에 문제가 발생할 확률이 매우 높고 코드를 이해하고 수정하는 데 어려움을 겪을 수 있습니다.

 

그렇다면 반대로 객체 지향적 문법의 예시를 보겠습니다.

참고로 아래 예시 코드들은 모두 https://github.com/MTtankkeo/web_touch_ripple 에서 참고하여 공부할 수 있습니다.

 

// in https://github.com/MTtankkeo/web_touch_ripple/blob/main/src/gestures/gesture_arena.ts

export type GestureArenaOption = {
    // Whether to defer the gesture recognizer about to define accepting
    // or rejecting until a pointer event ends.
    isKeepAliveLastPointerUp: boolean,
}

/** 
 * Gesture Arena is a simple gesture competition system.
 * See also, this arena is based on the cycle.
 * 
 * Gestures are accepted and rejected according to the proper rules.
 */
export class GestureArena {
    constructor(
        public option?: GestureArenaOption
    ) {
        this.option = {
            ...{ isKeepAliveLastPointerUp: true }, // default
            ...this.option
        };
    }

    /** Just a items of factory functions for gesture recognizer. */
    builders: GestureRecognizerBuilder[] = [];

    /** A currently living gesture recognizers. */
    private recognizers: GestureRecognizer[] = [];

    /** Registers gesture recognizer factory builder */
    registerBuilder(builder: GestureRecognizerBuilder) {
        this.builders.push(builder);
    }

    /** Adds a given gesture recognizer in the Arena. */
    attach(recognizer: GestureRecognizer) {
        this.recognizers.push(recognizer);
    }

    /** Removes a given gesture recognizer in the Arena. */
    detach(recognizer: GestureRecognizer) {
        this.recognizers = this.recognizers.filter(r => r != recognizer);
    }

    /** Rejects a given recognizer on this arena. */
    rejectBy(target: GestureRecognizer) {
        this.detach(target);
        this.checkCycle();
    }

    /** Accepts all a recognizers except a given recognizer. */
    acceptBy(target: GestureRecognizer) {
        this.recognizers.forEach(r => r != target ? r.reject() : undefined);
        this.recognizers = [];
    }

    /** Resets builders and recognizers in this arena. */
    reset() {
        this.builders = [];
        this.recognizers = [];
    }

    createRecognizer(builder: GestureRecognizerBuilder): GestureRecognizer {
        const recognizer = builder();

        // Called when the gesture accepted or rejected.
        recognizer.listeners.push(result => {
            switch (result) {
                case GestureRecognizerResult.REJECT: this.rejectBy(recognizer); break;
                case GestureRecognizerResult.ACCEPT: this.acceptBy(recognizer); break;
                case GestureRecognizerResult.UPDATE: this.checkCycle(); break;
            }
        });

        return recognizer;
    }

    /**
     * Called when a recognizer is attached or detached or when
     * the others related state changes.
     */
    private checkCycle(type?: PointerType) {
        const isKeepAliveLastPointerUp = this.option.isKeepAliveLastPointerUp;

        // Accept a last recognizer that is un-holded survivor.
        if (isKeepAliveLastPointerUp && (type == PointerType.UP || type == null) && this.recognizers.length == 1) {
            const last = this.recognizers[0];

            if(!last.isHold) last.accept();
        }
    }

    /**
     * Handles a given pointer event properly.
     * 
     * When a pointer-down event occurs,
     * it is considered the beginning of a new gesture cycle.
     */
    handlePointer(event: PointerEvent, type: PointerType) {

        // When a pointer-down event occurs, creates recognizers by builder.
        if (type == PointerType.DOWN && this.recognizers.length == 0) {
            this.recognizers = this.builders.map(e => this.createRecognizer(e));
        }

        this.recognizers.forEach(r => r.handlePointer(event, type));
        this.checkCycle(type);
    }
}

 

제스처 아레나, 즉 이름 그대로 제스처들의 경기장 역할을 수행하는 클래스입니다, 제스처들은 해당 경기장에서 경쟁하게 되며 승리 또는 패배되는 구조입니다.

 

Tap 제스처를 구현하는 코드를 보겠습니다.

 

// in https://github.com/MTtankkeo/web_touch_ripple/blob/main/src/gestures/tap.ts

export class TapGestureRecognizer extends TouchRippleGestureRecogzier {
    timerIds: NodeJS.Timeout[] = [];

    /** Whether the gesture can be rejected in the middle. */
    isRejectable: boolean = false;
    
    constructor(
        public onTap: GestureEventCallback,
        public onTapRejectable: GestureEventCallback,
        public onTapAccept: GestureEventCallback,
        public onTapReject: GestureEventCallback,
        public rejectableDuration: number, // tap preview duration
        public tappableDuration: number,
    ) {
        super();
    }

    pointerDown(position: PointerPosition): void {
        const _handleRejectalbe = () => {
            this.isRejectable = true;
            this.onTapRejectable(position);
        }

        // about --tap-preview-duration
        this.timerIds.push(setTimeout(_handleRejectalbe, this.rejectableDuration));

        // about --tappable-duration
        if (this.tappableDuration != 0) {
            this.timerIds.push(setTimeout(this.reject.bind(this), this.tappableDuration));
        }
    }

    dispose(): void {
        this.timerIds.forEach(id => clearTimeout(id));
        this.timerIds = null;
    }

    onAccept(): void {
        if (this.isRejectable) {
            this.onTapAccept(this.position);
        } else {
            this.onTap(this.position);
        }
    }

    onReject(): void {
        if (this.isRejectable) this.onTapReject(this.position);
    }
}

 

객체지향 문법은 확실히 더 길어 보이지만, 코드를 수정하거나 이해하는 데 훨씬 용이하다는 것을 알 수 있습니다. 관심사 분리 원칙과 단일 책임 원칙을 준수하여 각 제스처의 동작만을 정의하는 것을 볼 수 있습니다, 따라서 해당 코드에서는 다른 제스처의 동작들을 제어하지 않습니다. 이를 통해 코드의 유지보수성과 확장성을 높이고, 개발자가 쉽게 코드에 기여 또는 확장 및 수정할 수 있도록 합니다. 

 

 

이제 여러분은 객체지향 프로그래밍이 절차지향 프로그래밍에 비해 더 구조적이고 유지보수하기 용이하다는 것을 이해했을 것입니다.

 

특히 제스처와 같은 복잡한 상호작용을 처리할 때 객체지향적 접근은 개발자의 인지부조화를 줄이고, 코드의 가독성과 확장성을 높이는 데 큰 도움이 된다는 것도 말이죠. 이러한 접근법을 통해 더 효율적이고 안정적인 코드를 작성할 수 있으며, 협업 환경에서도 더 나은 결과를 얻을 수 있을 것입니다.

 

저는 여러분들이 객체지향 프로그래밍을 통해 코딩의 즐거움을 느끼고, 더 나은 개발자가 되기를 기원합니다!

 

Happy coding in the another world!

 

 

https://mttankkeo.tistory.com/17