디자인 패턴 이해하기: Factory, Builder 패턴을 활용한 컴퓨터 구성 요소 설계 및 테스트

3
post-thumbnail

소프트웨어 디자인 패턴을 이해하고 적용하는 것은 소프트웨어 개발에서 매우 중요한 일입니다. 이번 포스트에서는 '팩토리 패턴'과 '빌더 패턴'을 중점적으로 다루어 보겠습니다. 이 패턴들을 이해하면 코드의 구조를 더 명확하고 관리하기 쉽게 만들 수 있습니다. 자, 그럼 시작해 볼까요?

디자인 패턴

디자인 패턴은 소프트웨어 디자인에서 흔히 발생하는 문제를 해결하기 위한 미리 만들어진 청사진입니다.

패턴은 특정 코드가 아니라 문제를 해결하기 위한 일반적인 개념입니다.

프로그래머로 일하면서 하나의 패턴도 모른 채 일할 수도 있고, 자신도 모르게 패턴을 사용할 수도 있습니다. 그렇다면 패턴을 배우는 이유는 무엇일까요?

  1. 디자인 패턴은 소프트웨어 디자인에서 흔히 발생하는 문제에 대한 검증된 솔루션으로 구성된 툴킷으로, 이러한 문제에 직면하지 않더라도 패턴을 아는것은 object-oriented design에 대한 이해를 높이고, 더 나은 코드를 작성할 수 있게 도와줍니다.
  2. 디자인 패턴은 팀원들과의 의사소통을 돕습니다. 디자인 패턴은 특정한 이름을 가지고 있고, 이러한 이름을 통해 팀원들과 의사소통을 할 수 있습니다.

팩토리 패턴

출처: refactoring guru 사이트

팩토리 패턴은 객체 생성 로직을 별도의 팩토리 클래스로 분리하는 디자인 패턴입니다. 이를 통해 코드의 유연성과 재사용성을 크게 향상시킬 수 있습니다.

팩토리 패턴 예제

먼저, CPU라는 인터페이스를 정의해 보겠습니다. 이 인터페이스에는 process라는 추상 메서드가 포함되어 있습니다.

from abc import ABC, abstractmethod

class CPU(ABC):
    @abstractmethod
    def process(self, tasks: list[int]) -> list[list[int]]:
        pass

CPU를 구현하는 두 가지 클래스, SingleCoreCPU와 DoubleCoreCPU를 만들어 보겠습니다.

class SingleCoreCPU(CPU):
    def process(self, tasks: list[int]) -> list[list[int]]:
        return [tasks]
        
class DoubleCoreCPU(CPU):
    def process(self, tasks: list[int]) -> list[list[int]]:
        return [tasks[::2], tasks[1::2]]
  • SingleCoreCPU: 한번에 한개의 task만 처리할 수 있는 순서를 return 해주는 class 입니다.
  • DoubleCoreCPU: 동시에 두개의 task들을 처리해주는 class 입니다.

이제 팩토리 클래스를 만들어서 CPU 객체를 생성해 보겠습니다.

class CPUFactory:
    @staticmethod
    def make_cpu(type: str) -> CPU:
        if type == "single":
            return SingleCoreCPU()
        elif type == "dual":
            return DoubleCoreCPU()
        else:
            raise ValueError(f"CPU type {type} not supported")

CPUFactory를 사용하여 CPU 객체를 생성할 수 있습니다. 예를 들어, SingleCoreCPU를 생성하려면 다음과 같이 작성합니다.

cpu = CPUFactory.make_cpu("single")
print(cpu.process([1, 2, 3, 4]))  # 결과: [[1, 2, 3, 4]]

추상 팩토리 패턴

추상 팩토리 패턴은 관련된 객체들의 집합을 생성하는 인터페이스를 정의하는 패턴입니다. 팩토리 패턴의 확장형이라 할 수 있습니다.

추상 팩토리 패턴 예제

RAM과 ROM이라는 추상 클래스를 정의해 보겠습니다.

from abc import ABC, abstractmethod
from pydantic import BaseModel

class Memory(BaseModel, ABC):
    data: list[int]

    @property
    def size(self) -> int:
        return len(self.data)

    @abstractmethod
    def read(self, idx: int) -> int:
        pass

    @abstractmethod
    def write(self, idx: int, value: int):
        pass

이 추상 클래스를 상속받아 RAM과 ROM 클래스를 구현합니다.

class Ram(Memory):

    def read(self, idx: int) -> int:
        return self.data[idx]

    def write(self, idx: int, value: int):
        self.data[idx] = value

class Rom(Memory):

    def read(self, idx: int) -> int:
        return self.data[idx]

    def write(self, idx: int, value: int):
        raise ValueError("ROM is read-only")

RAM (Random Access Memory) 과 ROM (Read-Only Memory)의 가장 큰 차이는 RAM은 메모리에 write 작업이 가능하지만 ROM은 write작업이 불가능하다는 점입니다.

RAM과 ROM을 생성하는 팩토리 클래스를 만들어 보겠습니다.

class MemoryFactory(ABC):
    @staticmethod
    @abstractmethod
    def make_memory(size: int = 0, data: list[int] = []) -> Memory:
        pass

class RamFactory(MemoryFactory):
    @staticmethod
    def make_memory(size: int = 0, data: list[int] = []) -> Ram:
        if data:
            raise ValueError("RAM cannot be initialized with data")
        return Ram(data=[0] * size)

class RomFactory(MemoryFactory):
    @staticmethod
    def make_memory(size: int = 0, data: list[int] = []) -> Rom:
        if size > 0:
            raise ValueError("ROM cannot be initialized with size")
        return Rom(data=data)

빌더 패턴

빌더 패턴은 복잡한 객체를 단계별로 생성하는 패턴입니다. 이를 통해 객체 생성 과정을 명확하고 유연하게 관리할 수 있습니다.

빌더 패턴 예제

먼저 Computer라는 추상 클래스를 정의해 봅시다. 이 클래스는 CPU, RAM, ROM을 포함하며, bootstrap이라는 추상 메서드를 가지고 있습니다.

from abc import ABC, abstractmethod
from pydantic import BaseModel, ConfigDict

class Computer(BaseModel, ABC):
    model_config = ConfigDict(arbitrary_types_allowed=True)

    cpu: CPU
    ram: Ram
    rom: Rom

    @abstractmethod
    def bootstrap(self) -> dict[str, list[int] | list[list[int]]]:
        pass

Laptop과 Desktop이라는 두 가지 구체적인 Computer 클래스를 구현합니다.

class Laptop(Computer):
    def bootstrap(self) -> dict[str, list[int] | list[list[int]]]:
        state = {}
        state["cpu_processed"] = self.cpu.process(tasks=[1, 2, 3, 4])
        state["ram_data"] = self.ram.data
        state["rom_data"] = self.rom.data
        return state

class Desktop(Computer):
    def bootstrap(self) -> dict[str, list[int] | list[list[int]]]:
        state = {}
        state["cpu_processed"] = self.cpu.process(tasks=[1, 2, 3, 4, 5, 6, 7, 8])
        state["ram_data"] = self.ram.data
        state["rom_data"] = self.rom.data
        return state

마지막으로 ComputerBuilder 클래스를 만들어서 Laptop과 Desktop를 생성해 보겠습니다.

이때 Laptop의 경우는 SingleCPU에 RAM size 8, ROM data [1,2,3,4] Desktop 경우에는 DualCPU에 RAM size 16, ROM data [1,2,3,4,5,6,7,8]로 하겠습니다.

class ComputerBuilder:
    @staticmethod
    def build_computer(type: str) -> Computer:
        if type == "laptop":
            cpu = CPUFactory.make_cpu("single")
            ram = RamFactory.make_memory(size=8)
            rom = RomFactory.make_memory(data=[1, 2, 3, 4])
            return Laptop(cpu=cpu, ram=ram, rom=rom)
        elif type == "desktop":
            cpu = CPUFactory.make_cpu("dual")
            ram = RamFactory.make_memory(size=16)
            rom = RomFactory.make_memory(data=[1, 2, 3, 4, 5, 6, 7, 8])
            return Desktop(cpu=cpu, ram=ram, rom=rom)
        else:
            raise ValueError(f"Computer type {type} not supported")

이제 ComputerBuilder를 사용하여 컴퓨터 객체를 생성하고 부트스트랩을 해보겠습니다.

computer = ComputerBuilder.build_computer(type="laptop")
state = computer.bootstrap()
print(state)
# 결과: {'cpu_processed': [[1, 2, 3, 4]], 'ram_data': [0, 0, 0, 0, 0, 0, 0, 0], 'rom_data': [1, 2, 3, 4]}

테스트

코드가 제대로 동작하는지 확인하려면 테스트가 필수적입니다. pytest를 사용하여 위의 클래스를 테스트해 볼 수 있습니다. 다음 코드를 통해 다양한 경우를 테스트할 수 있습니다.

import pytest

from computer import ComputerBuilder
from memory import RamFactory, RomFactory
from cpu import CPUFactory

@pytest.mark.computer
class TestComputer:
    def test_laptop(self):
        computer = ComputerBuilder.build_computer(type="laptop")
        state = computer.bootstrap()
        assert state["cpu_processed"] == [[1, 2, 3, 4]]
        assert state["ram_data"] == [0] * 8
        assert state["rom_data"] == [1, 2, 3, 4]

    def test_desktop(self):
        computer = ComputerBuilder.build_computer(type="desktop")
        state = computer.bootstrap()
        assert state["cpu_processed"] == [[1, 3, 5, 7], [2, 4, 6, 8]]
        assert state["ram_data"] == [0] * 16
        assert state["rom_data"] == [1, 2, 3, 4, 5, 6, 7, 8]

    def test_invalid_computer(self):
        with pytest.raises(ValueError):
            ComputerBuilder.build_computer(type="invalid")

@pytest.mark.memory
class TestMemory:
    def test_ram(self):
        memory = RamFactory.make_memory(size=8)
        assert memory.size == 8
        assert memory.read(0) == 0
        assert memory.read(7) == 0
        memory.write(0, 1)
        assert memory.read(0) == 1
        memory.write(7, 2)
        assert memory.read(7) == 2

    def test_rom(self):
        memory = RomFactory.make_memory(data=[1, 2, 3, 4])
        assert memory.size == 4
        assert memory.read(0) == 1
        assert memory.read(3) == 4
        with pytest.raises(ValueError):
            memory.write(0, 1)
        with pytest.raises(ValueError):
            memory.write(3, 4)

    def test_invalid_memory(self):
        with pytest.raises(ValueError):
            RamFactory.make_memory(data=[1, 2, 3, 4])
        with pytest.raises(ValueError):
            RomFactory.make_memory(size=8)

@pytest.mark.cpu
class TestCPU:
    def test_single_core(self):
        cpu = CPUFactory.make_cpu(type="single")
        assert cpu.process([1, 2, 3, 4]) == [[1, 2, 3, 4]]

    def test_dual_core(self):
        cpu = CPUFactory.make_cpu(type="dual")
        assert cpu.process([1, 2, 3, 4]) == [[1, 3], [2, 4]]

    def test_invalid_cpu(self):
        with pytest.raises(ValueError):
            CPUFactory.make_cpu(type="invalid")

테스트를 실행하면 우리가 만든 코드가 예상대로 동작하는지 확인할 수 있습니다.

마무리

이번 포스트에서는 팩토리 패턴과 빌더 패턴을 사용하여 CPU, RAM, ROM으로 구성된 컴퓨터 객체를 만들어보았습니다. 이러한 디자인 패턴을 사용하면 전체 코드의 구조가 명확해지고, 유지 보수와 확장이 용이해집니다. 디자인 패턴을 더 깊이 이해하려면 다양한 예제를 통해 계속해서 학습하는 것이 중요합니다.

🔥 해당 포스팅은 Dev.POST 도움을 받아 작성되었습니다.

profile
🔥 코드 과정 중 자연스레 쌓인 경험과 지식을 기술 블로그로 작성해줍니다.

0개의 댓글