tag: 번역

데이터베이스에서 객체를 지연 로딩(lazy loading) 하기

2017년 6월 26일

최근 프로젝트에서 PDO를 사용해 작업하다보니 아무래도 ORM에 비해 아쉬운 점이 많아 ORM의 구현을 살펴보는 일이 잦아졌다. Giorgio Sironi의 글 Lazy loading of objects from database을 번역했다. 좀 오래된 글이긴 하지만 지연 로딩을 위해 프록시 패턴을 사용하는 방식을 설명하고 있다.

이 번역은 원 포스트의 명시와 같이 CC BY-NC-SA 3.0 US에 따른다.


데이터베이스에서 객체를 지연 로딩(lazy loading) 하기

지연 로딩(lazy loading)은 무엇인가? 객체/관계 맵핑에서는 전체 객체의 연결 관계를 메모리상에서 나타내는 방식이 관행이다. 모든 객체를 실제로 만드는 대신 환영을 만드는 방법을 이 글에서 살펴본다.

예시

PHP 애플리케이션에서 전형적인 UserGroup 객체가 있다고 생각해보자. 단지 PHP 코드 예제를 사용했을 뿐이지 Java/Hibernate 예제처럼 관계형 데이터베이스를 사용하는 언어라면 이 글의 내용은 유효할 것이다.

UserGroup은 전형적인 다대다 관계다. 사용자는 여러 그룹에 포함될 수 있고 그룹은 여러 사용자를 구성원으로 할 수 있다. 즉 데이터베이스에서 불러온 객체는 다음처럼 탐색할 수 있다.

$user = find(42); // id가 42인 사용자를 찾는다
echo $user->groups[3]->users[2]->groups[4]->name;

객체 그래프를 무한으로 탐색할 수 있는 경우는 좋은 관례가 아니다. 하지만 종종 다대다 관계에서는 이런 탐색이 필요한 경우가 있으며 단순한 API인데도 자원을 과도하게 사용하게 되는 접근법 중 하나다. 왜 자원을 과도하게 사용하는지 뒤에서 설명한다.

가장 요점인 문제는 모든 객체 그래프를 불러올 수 없다는 점인데 데이터베이스의 크기에 따라서 서버의 메모리보다 커질 수도 있고 객체로 전환하는 데 시간이 한참 걸릴 수도 있기 때문이다. 그렇다고 관계 일부만 불러올 수도 없는데 그룹과 사용자를 원하는 만큼 탐색하려면 모든 그래프가 필요하기 때문이다. 일부만 불러온 상황에서 그래프의 끝 단까지 간다면 객체가 있어야 할 위치에 null 값/null 포인터를 반환하게 되는 것은 문제가 된다.

해결책: 지연 로딩

프록시 패턴을 이 상황에 적용할 수 있다.

일반적으로 프록시는 다른 무언가와 이어지는 인터페이스의 역할을 하는 클래스이다. 프록시는 어떠한 것(이를테면 네트워크 연결, 메모리 안의 커다란 객체, 파일, 또 복제할 수 없거나 수요가 많은 리소스)과도 인터페이스의 임무를 수행할 수 있다.

첫 탐색에서는 첫 그룹의 하위 클래스인 프록시를 반환한다. 데이터 맵퍼는 추가적인 명령 없이도 해당 타입의 객체 그래프를 제공하게 된다.

var_dump($user); // User
var_dump($user->groups[3]); // Group_SomeOrmToolNameProxy
var_dump($user->groups[3] instanceof Group); // true

앞서 이야기한 것처럼 ORM은 프록시 클래스를 사용해서 원래의 클래스를 대체하는 방법으로 지연 로딩을 제공한다. 이 클래스를 위한 코드는 즉석에서 생성하며 대략 다음과 같은 형태가 된다.

class Group_SomeOrmToolNameProxy
{
    public function __construct(DataMapper $mapper, $identifier)
    {
        // 참조하는 필드를 인자 형태로 저장
    }

    private function _load()
    {
        $this->loader->load($this, $id);
    }

    public function sendMessageToAllUsers($text)
    {
        $this->_load();
        parent::sendMessageToAllUsers($text);
    }
}

새 클래스는 원래의 메소드를 대신해 호출하긴 하지만 호출하기 전에 _load() 메소드를 호출해서 객체를 사용할 수 있는 상태로 바꾼다. _load()를 호출하기 전이나 프록시 메소드를 호출하기 전에는 이 도메인 객체는 식별자 필드(id)만 내부 데이터 구조에 저장하고 있다.

이 코드를 사용할 때는 기존 Group과 같은 인터페이스를 제공하기 때문에 사용자 입장에서는 서버 자원에서 자유로운 Group 클래스를 사용한다는 점을 눈치채기도 어렵다.

무슨 뜻일까?

첫 단계의 객체는 완전히 불러오지만 두 번째 단계는 해당 객체를 불러올 수 있는 정보만 포함하는 플레이스홀더만 존재한다. 실제로 접근했을 때만 해당 필드를 데이터베이스에서 가져와 처리하게 된다.

$user = $em->find(42); // user 테이블에서 호출함
echo $user->groups[3]->name; // groups와 user_groups 테이블에서 호출함

이 패턴을 원하는 만큼 더 복잡한 환경에서도 적용할 수 있다.

  • join() 명령을 호출 객체에 정의하거나 ‘join’ 선택지를 데이터맵퍼의 메소드로 제공해서 최초 로딩에서 어느 깊이까지 객체를 불러올 것인가 지정할 수 있다. 최초에 사용자의 두 번째 단계 그래프까지 불러올 때 쿼리 한 번으로 불러오는 것이다. 물론 여전히 3번째 단계부터는 ($user->groups[3]->users[2]->role) $user를 다시 구성하지 않는 이상은 데이터베이스에 추가적인 요청을 보내 성능에 영향을 줄 것이다.
  • 지연 로딩을 켜거나 끌 수 있다. 또는 실행 과정을 기록해서 성능에 영향을 주는 지점을 찾을 수 있다.

Java의 Hibernate는 객체 프로퍼티와 관계의 지연 로딩 기능을 이 접근 방식으로 제공한다. Doctrine 1.x는 더 단순한 방식을 사용하는데 액티브 레코드를 사용하고 있고 Doctrine_Record라는 기반 클래스 상에서 모델을 구현하고 있기 때문이다.

오늘 Doctrine 2의 ORM\Proxy네임스페이스에 코드를 기여했다. 이 컴포넌트는 프록시 클래스와 객체를 기존 클래스의 메타데이터를 기반으로 생성해준다. 지연 로딩을 기존 코드 변경 없이도 바로 사용할 수 있을 것이다.

왜 클래스죠?

2017년 5월 10일

최근 아키텍처에 관한 책을 읽고 있는데 레퍼런스로 나온 글 중 하나로 Hadi Hariri의 글 Refactoring to Functional–Why Class?을 번역했다. 이 글은 함수형으로 리펙토링하기라는 코틀린 연재 중 일부라서 그다지 공정한 느낌으로 쓰여진 글은 아니지만 객체지향이라는 패러다임에서 논쟁점이 되는 여러 부분을 잘 보여주고 있어 옮겨봤다.


함수형으로 리팩토링하기 – 왜 클래스죠?

대학에서

교수: 우린 실제 세계에서 객체로 둘러쌓여 있습니다. 이 객체는 자동차, 집, 기타 등등이 될 수 있죠. 그런 이유에서 객체 지향 프로그래밍에서 클래스를 통해 실제 세계에 존재하는 객체를 연결하는 방식이 매우 쉬운 이유입니다.

2주 후

제이크: 저 이 객체와 문제가 좀 있는데요. 도와주시겠어요?

교수: 물론이죠. 객체를 만드는데 도움이 되는 여러 일반적인 방법이 있는데 요약하자면 명사를 찾아요. 그리고 동사를 찾으면 클래스에서 사용할 수 있는 메소드가 될 수 있어요. 말하는 그대로죠.

제이크: 어 말씀한 내용이 합당하네요. 감사합니다.

신입 제이크

: 제이크 씨, 당신이 작성한 클래스를 확인했습니다. 좀 크기가 큰 것 같은데요.

제이크: 죄송합니다. 어떤 부분이 문제죠?

: 음… 너무 많은 책임을 갖는 게 문제에요. 너무 많은 일을 합니다.

제이크: 그리고요?

: 잘 생각해보세요. 하나에 너무 많은 책임이 있으면 이 부분 하나가 시스템의 많은 부분과 연결되어 있다는 뜻이에요. 즉 이 클래스를 변경할 가능성도 상당히 높다는 뜻이고 그건 무언가를 고장내게 될 가능성 또한 높다는 의미죠. 거기다 단일 클래스를 1000줄이 넘도록 작성하면 물론 30줄 짜리 코드에 비해 이해하기 어려울 것이고요.

제이크: 맞는 말이네요.

: 이 코드를 작은 클래스로 나누세요. 각각의 클래스는 한 가지 일만 하고 그 클래스 홀로 쓰여야 합니다.

1년 후

메리: 제이크 씨, 방금 당신이 작성한 클래스를 확인했는데요. 그다지 행동(behavior)이 많이 들어있지 않네요.

제이크: 네, 동작이 Customer 클래스에 속하는지 Accounts 클래스에 포함해야 하는지 확신이 없어서 CustomerService라는 클래스를 별도로 만들어 거기에 넣었습니다.

메리: 네, 적당한 방법이네요. 하지만 Customer 클래스를 더 이상 클래스라고 보기 어려워졌어요. DTO에 더 가까워요.

제이크: DTO요?

메리: 네, 데이터 전달 객체(Data Transfer Object)요. 클래스와 비슷하긴 하지만 행동이 없는 경우에요.

제이크: 음, 그럼 구조체나 레코드에 가깝다는 말씀이시죠?

메리: 네, 그런 느낌이에요. 클래스를 만들 때는 행동이 있어야 해요. 그러지 않고서는 클래스라고 하기 어려워요. DTO죠.

제이크: 알겠습니다.

2년 후

메튜: 제이크 씨, 이 클래스를 봤는데 특정 구현과 결합(coupled)이 상당히 강하군요.

제이크: 네?

메튜: 음, 지금 RepositoryController 내에서 생성하고 있어요. 이 부분은 어떻게 테스트하시겠어요.

제이크: 음… 시험용 데이터베이스를 사용하면 되지 않을까요?

메튜: 아뇨. 가장 먼저 해야 하는 부분은 프로그램을 클래스가 아닌 인터페이스를 사용하도록 하는 겁니다. 이 접근 방식이 특정 구현에 매여 있지 않은 코드를 장성하는 방법이에요. 그런 후에 의존성 주입을 사용해서 특정 구현을 전달해 사용하도록 하는겁니다. 그러면 구현을 언제든지 필요할 때 변경할 수 있게 되는 거죠.

제이크: 그렇군요.

메튜: 실무에서는 IoC 컨테이너를 사용해서 다른 클래스의 인스턴스를 연결하는 것이 가능할겁니다.

3년 후

프랜시스: 제이크 씨, 이 클래스에 너무 많은 의존성을 집어넣고 있군요.

제이크: 네, 그래도 IoC 컨테이너가 다 처리할겁니다.

프랜시스: 네, 저도 알고 있습니다. 하지만 가능하다고 해서 옳은 방법이라고 말하기는 어렵네요. 이 클래스는 여러 종류의 구현체를 사용할 수 있다고 하더라도 여전히 너무 많은 다른 클래스에 의존하고 있어요. 하나에서 최대 3개로 유지하도록 해요.

제이크: 네, 알겠습니다. 감사합니다.

4년 후

안나: 제이크 씨, 이 클래스 이름은 왜 Utils인가요?

제이크: 음. 그 코드는 정말 어디에 놔야 할 지 알 수 없어서 그렇게 이름 붙였어요.

안나: 그래요. 이미 그런 코드를 위한 클래스가 있어요. RandomStuff라는 이름이에요.

맥주 마시며

제이크: 피터, 내가 생각해봤는데 말이지. 학교에서 배울 땐 객체로 생각하고 명사를 분석하라는 등 기법을 얘기했는데 말야. 그러고 나서는 이름을 잘 붙였는지, 작게 작성했는지, 단일 책임으로 작성했는지, 너무 많은 의존성을 주입하고 있는 것은 아닌지 생각해야 한단 말이야. 이제 와서는 동시성에 좋지 않다고 상태를 갖지 않는 코드를 작성해야 한다고 말하지. 처음부터 궁금했는데 이럴거면 도대체 왜 클래스를 사용하는 걸까?

피터: 헛소리 하지 마 제이크. 만약 클래스가 없다면 어디에 함수를 선언할 수 있겠어? 맥주나 한 잔 더 마실래?

다음 시간에 계속.

서비스 로케이터는 안티패턴입니다

디자인패턴인 서비스 로케이터를 사용하면 안되는 이유에 대해, 번역.

2017년 4월 20일

새로 옮긴 회사에서 열심히 레거시를 정리하고 있다. 기존 코드는 관리가 전혀 되지 않는 인하우스 프레임워크를 사용하고 있어서 전반적으로 구조를 개편하기 위해 고심하고 있다. 이 포스트는 Mark SeemannService Locator is an Anti-Pattern를 번역한 글로 최근 읽었던 포스트 중 이 글을 레퍼런스로 하는 경우를 자주 봐서 번역하게 되었다.


서비스 로케이터는 안티패턴입니다.

서비스 로케이터는 마틴 파울러가 설명한 이후로 잘 알려진 패턴이니까 분명 좋은 패턴일 것입니다.

아쉽게도 실제로는 그렇지 않습니다! 이 패턴은 안티 패턴으로 되도록 피해야 하는 방식입니다.

왜 안티 패턴인지 더 살펴보도록 합시다. 서비스 로케이터를 사용했을 때 나타나는 문제는 클래스의 의존성을 숨긴다는 점입니다. 다시 말해 컴파일 중에는 오류가 나타나지 않았지만 런타임에서는 오류가 발생할 여지가 있다는 이야기입니다. 이전에 작성한 코드와 호환이 되지 않는 방식으로 코드를 변경했다고 가정해봅시다. 어느 클래스가 어떤 클래스에 의존하고 있는지 명확하게 들어나지 않고 있는 상황에서는 그 변경이 어느 클래스에 영향을 미치는지 확인하기 어렵습니다. 그로 인해서 새로운 코드를 작성할 때마다 어디가 고장나는지 정확히 알 수 없어서 유지보수가 더 어려워질 수 밖에 없습니다.

OrderProcessor 예제

예제로 요즘 의존성 주입에서 가장 이슈라고 볼 수 있는 OrderProcessor를 살펴봅시다. 주문을 진행하기 위해서는 OrderProcessor에서 주문을 검증하고, 검증 결과에 문제가 없으면 배송을 처리합니다. 정적 서비스 로케이터의 예제는 다음과 같습니다.

public class OrderProcessor : IOrderProcessor
{
    public void Process(Order order)
    {
        var validator = Locator.Resolve<IOrderValidator>();
        if (validator.Validate(order))
        {
            var shipper = Locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
}

위 코드에서 new 오퍼레이터를 대체하려고 서비스 로케이터를 사용했습니다. Locator는 다음처럼 구현되어 있습니다.

public static class Locator
{
    private readonly static Dictionary<Type, Func<object>>
        services = new Dictionary<Type, Func<object>>();

    public static void Register<T>(Func<T> resolver)
    {
        Locator.services[typeof(T)] = () => resolver();
    }

    public static T Resolve<T>()
    {
        return (T) Locator.services[typeof(T)]();
    }

    public static void Reset()
    {
        Locator.services.Clear();
    }
}

LocatorRegister 메소드를 사용해서 설정할 수 있습니다. 물론 ‘실제’ 서비스 로케이터는 위 코드보다 훨씬 진보된 방식으로 구현되어 있지만 여기서는 이 간단한 예제 코드로도 충분히 문제를 확인할 수 있습니다.

이 로케이터는 확장이 가능하도록 유연하게 구현되었습니다. 또한 테스트 더블(Test Doubles)의 역할도 수행할 수 있어서 서비스를 테스트하는 것도 가능합니다.

이렇게 좋은 점이 많은데 어떤 부분이 문제가 될 수 있을까요?

API 사용 문제

단순하게 OrderProcessor 클래스를 사용한다고 가정해봅시다. 서드파티에서 제공한 어셈블리라면 우리가 코드를 직접 작성하지 않았기 때문에 Reflector를 사용해서 구현을 확인해야 할 것입니다.

비주얼 스튜디오의 인텔리센스는 다음 그림처럼 동작합니다.

image 3

자동완성을 보면 클래스가 기본 생성자를 포함하고 있습니다. 다시 말하면 이 클래스로 새 인스턴스를 생성한 다음에야 Process 메소드를 올바르게 실행할 수 있다는 뜻입니다.

var order = new Order();
var sut = new OrderProcessor();
sut.Process(order);

이 코드를 실행하면 예상하지 못한 KeyNotFoundException이 발생하는데 IOrderValidatorLocator에 등록되지 않았기 때문입니다. 심지어 소스 코드에 접근할 수 없는 라이브러리나 패키지라면 어떤 부분으로 오류가 발생한 것인지 정확하게 판단하기 어렵게 됩니다.

소스 코드를 (또는 Reflector를 사용해서) 찬찬히 들여다 보거나, 문서를 참고해서 결국 IOrderValidator 인스턴스를 전혀 관련 없어 보이는 정적 클래스 Locator에 등록해야 한다는 사실을 아마도 발견할 수도 있을 겁니다.

유닛 테스트에서는 다음처럼 작성할 수 있습니다.

var validatorStub = new Mock<IOrderValidator>();
validatorStub.Setup(v => v.Validate(order)).Returns(false);
Locator.Register(() => validatorStub.Object);

Locator의 내부 저장소도 정적이라서 테스트를 작성하는 과정도 번거롭습니다. 매 유닛 테스트가 끝나는 순간마다 Reset 메소드를 실행해야 하기 때문인데요. 이 경우는 유닛 테스트의 경우에만 주로 해당되는 문제긴 합니다.

여기까지 살펴본 내용으로도 이 방식의 API는 긍정적인 개발 경험을 제공한다고 말하기엔 어렵다고 말할 수 있습니다.

관리 문제

사용자 관점에서도 서비스 로케이터를 사용하는 일이 문제가 가득하다는 것을 확인했지만 이 관점은 유지보수하는 개발자에게도 쉽게 영향을 미치게 됩니다.

OrderProcessor의 동작을 확장해서 IOrderCollector.Collect 메소드를 호출한다고 가정해봅시다. 쉽게 기능을 추가할 수 있을…까요?

public void Process(Order order)
{
    var validator = Locator.Resolve<IOrderValidator>();
    if (validator.Validate(order))
    {
        var collector = Locator.Resolve<IOrderCollector>();
        collector.Collect(order);
        var shipper = Locator.Resolve<IOrderShipper>();
        shipper.Ship(order);
    }
}

단순하게 본다면 매우 간단한 구현입니다. 그저 Locator.Resolve를 한 번 더 호출하고 IOrderCollector.Collect를 실행하는 코드로 끝납니다.

여기서 질문이 있습니다. 이 새로운 기능은 변경 전의 코드와 호환이 될까요?

놀랍게도 이 질문은 답변하기 어렵습니다. 일단 컴파일에서는 문제가 생기지 않지만 유닛 테스트는 실패하게 됩니다. 실제 프로그램에서도 문제가 발생했을까요? IOrderCollector 인터페이스가 다른 컴포넌트에서 사용되어서 이미 서비스 로케이터에 등록되어 있는 상황이라면 이 코드는 문제 없이 동작하게 됩니다. 그렇다면 정 반대의 상황도 가정해볼 수 있을 겁니다. 테스트는 통과하면서 실제로는 오류가 나는 경우도 완전 없다고 말하기는 어렵습니다.

결론적으로 서비스 로케이터를 사용하면 지금 변경한 코드가 문제를 만드는 변경인지 아닌지 판단하기 더욱 어려워지게 됩니다. 코드를 수정하거나 작성하기 위해서는 서비스 로케이터를 사용하는 어플리케이션 전체를 모두 이해해야만 합니다. 이 상황에서는 컴파일러도 도움을 줄 수 없겠죠.

변형: 구체적인 서비스 로케이터

이 문제를 해결할 방법은 없을까요?

문제를 해결할 방법을 찾아봅시다. 정적 클래스가 아닌 구체적인(구상, concreate) 클래스로 변경하면 가능할 것 같습니다.

public void Process(Order order)
{
    var locator = new Locator();
    var validator = locator.Resolve<IOrderValidator>();
    if (validator.Validate(order))
    {
        var shipper = locator.Resolve<IOrderShipper>();
        shipper.Ship(order);
    }
}

하지만 여전히 설정이 필요해서 다음과 같은 정적 필드(services)를 활용하게 됩니다.

public class Locator
{
    private readonly static Dictionary<Type, Func<object>>
        services = new Dictionary<Type, Func<object>>();

    public static void Register<T>(Func<T> resolver)
    {
        Locator.services[typeof(T)] = () => resolver();
    }

    public T Resolve<T>()
    {
        return (T) Locator.services[typeof(T)]();
    }

    public static void Reset()
    {
        Locator.services.Clear();
    }
}

정리하면, 구체적인 클래스로 정의한 서비스 로케이터는 앞에서 작성한 정적 구현과 크게 구조적인 차이가 없습니다. 즉, 여전히 동일한 문제가 나타납니다.

변형: 추상 서비스 로케이터

다른 변형은 실제 의존성 주입이 동작하는 방식과 비슷합니다. 서비스 로케이터는 다음 IServiceLocator 인터페이스를 구현합니다.

public interface IServiceLocator
{
    T Resolve<T>();
}

public class Locator : IServiceLocator
{
    private readonly Dictionary<Type, Func<object>> services;

    public Locator()
    {
        this.services = new Dictionary<Type, Func<object>>();
    }

    public void Register<T>(Func<T> resolver)
    {
        this.services[typeof(T)] = () => resolver();
    }

    public T Resolve<T>()
    {
        return (T) this.services[typeof(T)]();
    }
}

이 변형은 결과적으로 서비스 로케이터를 필요로 하는 곳에 직접 주입하는 방식으로 동작합니다. **생성자 주입(Constructor Injection)**은 의존성 주입에서 좋은 방식이기 때문인데요. 이제 OrderProcessor를 다음처럼 변경해서 서비스 로케이터를 OrderProcessor 내에서 활용할 수 있게 됩니다.

public class OrderProcessor : IOrderProcessor
{
    private readonly IServiceLocator locator;

    public OrderProcessor(IServiceLocator locator)
    {
        if (locator == null)
        {
            throw new ArgumentNullException("locator");
        }

        this.locator = locator;
    }

    public void Process(Order order)
    {
        var valiator =
            this.locator.Resolve<IOrderValidator>();

        if (validator.Validate(order))
        {
            var shipper =
                this.locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
}

그럼 이제 충분한가요?

개발자 관점으로는 이제 인텔리센스에서 알려주는 정보가 조금 더 많아졌습니다.

image 6

인텔리센스가 OrderProcessor.OrderProcessor(IServiceLocator locator)를 보여줌

이 정보가 유익한가요? 사실, 별 영양가가 없습니다. OrderProcessorServiceLocator를 필요로 한다는 정보는 조금 더 알 수 있겠지만 실제로 이 서비스 로케이터에서 무슨 서비스를 꺼내 사용하고 있는지는 알 수 없습니다. 다음 코드는 컴파일이 가능하지만 코드를 실행하면 앞서 나타났던 KeyNotFoundException가 동일하게 발생하게 됩니다.

var order = new Order();
var locator = new Locator();
var sut = new OrderProcessor(locator);
sut.Process(order);

유지보수를 하는 개발자 입장에서도 향상된 부분이 딱히 없습니다. 다른 서비스에 의존적인 코드를 추가하더라도 문제가 발생하는지 안하는지 확답해서 말하기 여전히 어렵습니다.

정리

서비스 로케이터 문제는 특정 서비스 로케이터 구현을 사용한다고 해서 발생 유무가 달라지는 문제가 아닙니다. 이 패턴을 사용하면 언제든 문제가 나타나는, 진정한 안티-패턴입니다. (물론 특정 구현에 더 문제가 있는 경우는 얼마든지 있습니다.) 이 패턴은 API를 소비하는 모든 사용자에게 끔찍한 개발 경험을 제공합니다. 유지보수를 하는 개발자 입장에서는 변경 하나 하나를 만들 때마다 두뇌를 풀가동해서 변경이 미치는 모든 영향을 파악해야 하므로 더욱 고통스러워질 것입니다.

생성자 주입을 사용한다면 컴파일러는 코드를 소비하는 사람과 생산하는 사람 모두에게 도움이 됩니다. 서비스 로케이터에 의존하는 API라면 이런 도움을 전혀 받을 수 없습니다.

  • 의존성 주입 패턴과 안티 패턴에 대해 더 알고 싶다면 제 책에서 확인할 수 있습니다.
  • 또한 서비스 로케이터는 SOLID 원칙을 위반한다는 점에서 부정적입니다.
  • 서비스 로케이터의 근본적인 문제는 캡슐화 위반에서 나타납니다.

첫 도서 번역에서 배운 점

2016년 12월 8일

2016년 들어온 지 얼마 되지 않은 것 같은데 벌써 시간이 이렇게 지났다. 상투적이지만 어째 한해 한해 더 빠르게 지나가는 기분이다. 올해는 바쁘다는 핑계로 경험을 글로 정리하지 못했는데 아무래도 저지른 일이 많다보니 한번에 풀어내기 쉽지 않은 탓도 있는 것 같다. 이상한모임에서도 올해 배운 것이라는 주제로 대림절 달력이 진행되고 있는데 달력에 매일 올라오는 글을 보면서 더 미루지 말고 조금이라도 정리해보자는 마음을 먹게 되었다.

올해 했던 일 중 하나가 도서 번역이었다. 이 글은 번역 과정에서 겪었던 경험과 생각을 정리한 포스트다. 번역을 하기 전에 막연하게 생각했던 부분도 실제 과정에서 겪기도 했고 생각과 전혀 달랐던 부분도 있었다. 출판이나 번역 쪽에 오래 계셨던 분이 읽기에는 너무 사소한 이야기일지도 모르겠지만 막 시작하는 입장에서 겪고 생각했던 부분을 정리해보려고 한다.

진행 과정

올해 2월에 출판사에서 페이스북을 통해 연락을 받았다. Practical Vim이라는 도서 번역 의뢰였다. 2015년 말에 올렸던 글이 널리 퍼진 일이 있었는데 그때 블로그를 보고 연락을 주셨다고 했다. 전문적으로 번역을 하는 것은 아니었지만 블로그에 작게나마 글을 번역해서 자주 올리고 있어서 제안이 너무 반가웠다. 특히 Vim은 늘 사용하지만 제대로 사용한다는 자신이 없었는데 이 책을 번역하면 그 가려움도 긁을 수 있지 않을까 생각이 들었다.

하지만 내 번역에 대한 질이나 속도를 정확하게 모르기도 했고 시간이 얼마나 걸리게 될지 예측하기가 어려웠다. 또한 안해본 일에 대한 두려움도 있었고 제대로 번역되지 않은 책에 대해서 뒷얘기도 많이 들었던 터라 쉽게 결정을 내릴 수 없었다. 블로그야 번역 글이 잘못되었다면 수정하거나 다듬거나 모든 일이 내 마음대로 되는데 책은 전혀 그러지 못하니 겁이 앞설 수 밖에 없었다. 그래도 안하면 이런 기회가 언제 다시 올까 싶어서 하겠다는 의사를 전했다.

출판사에서도 계약 전에 일정 분량을 번역해서 보내달라고 했다. 번역 품질이나 속도를 가늠하기 위한, 일종의 면접이였다. 그렇게 여러 장 분량을 번역해서 며칠 후 메일로 발송했다. 몇 차례 추가적으로 다듬는 과정을 거친 후에 큰 문제가 없을 것 같다는 얘기를 듣고 계약서를 작성하게 되었다.

초벌 번역은 6월에 끝났고 첫 교정은 9월에, 두 번째는 11월 초에 마무리했다. 초반에 번역했던 부분과 후반에 번역한 부분을 비교하면 확실히 후반부가 자연스러웠다. 진행하는 동안 문장력이 상승했는지 어쩐건지 모르겠지만 품질이 일관적이지 않았던 탓에 편집자님이 고생을 많이 하셨다.

어려웠던 부분과 배운 점

모든 과정이 도구와의 씨름이었다. 초벌 번역에서는 Vim을 사용해서 작성했지만 원문의 다양한 양식을 반영하는데 쉽지 않았다. 첫 교정에서는 구글독스를 쓰려고 했는데 페이지 분량이 조금만 많아도 속도가 너무 느려서 윈도 노트북을 장만하고 MS 오피스 워드로 전환했다. 워드도 분량이 많아지면 상당히 느려지고 굳을 때가 생각보다 많았다. 작업 과정 중 도구 때문에 고민 안했던 적이 없었는데 디자인으로 옮겨진 후에 PDF 상에서 교정을 볼 때 가장 편하게 느껴졌다.

용어 번역이 쉽지 않았다. 사전적 의미로 옮기는 방식은 가장 쉽지만 기능의 의미를 쉽게 이해할 수 있는지, 오히려 한번 더 생각해봐야 하는 용어는 아닌지 계속 고민할 수 밖에 없었다. 두루 사용되는 용어로 번역해야 할 지, 아니면 음차로 표기를 할 지 단어 하나하나 넘어가기 쉽지 않았다. Vim에서만 사용되는 용어는 참고할 곳도 마땅치 않아서 어려웠다.

번역에 있어 일관성을 유지하는 일도 생각보다 품이 많이 든다. 키워드는 동일한 용어로 번역해야 하는데 영어다보니 이게 키워드로 사용한 것인지 아니면 일반 표현으로 작성한 것인지 모호한 경우도 있었다. 번역을 진행하면서 용어 사전을 만드는게 좋다는 이야기를 들었는데 책 자체가 팁이 나열된 방식이고 각 팁마다 연결된 팁이 언급되어 있어서 그때그때 키워드를 찾는 과정이 그다지 어렵지 않았다. 하지만 나중에 교정에서 많이 후회했다. 생각보다 번역 기간은 길어졌고 그 기간동안 기억이 많이 희미해져서 오가며 찾아보는 과정에 시간을 많이 소비했다.

앞에서도 이야기했지만 긴 글을 번역해본 경험이 없어서 시간 배분이 쉽지 않았다. 각 장, 파트마다 “대략적인” 기간을 산정했는데 너무 안일했다. 시작하기 전에 분량과 내용의 난이도를 판단해서 일별로 작업량을 명확하게 정리할 필요가 있다. 전업 번역가가 아니라면 이 과정이 정말 중요한 것 같다. 책에서 나눠진 팁 단위로 목표를 잡고 진행했는데 각 장이나 팁마다 분량이 각각이라 생각보다 목표에 맞춰 진행하기 어려웠다. 쪽으로 나눠서 하는게 훨씬 편했고 작업 분량 측정이나 목표 달성하는데 훨씬 쉬웠다.

번역에 신경썼던 점

블로그에 번역글을 올리는 것과는 확실히 무게감에서 차이가 있어서 교정에 노력을 많이 했다. 볼 때마다 말도 안되는 문장이 자꾸 보여서 겁이 날 지경이다. 번역과 교정에서 지키려고 노력한 몇 가지 규칙이 있었다. 사소한 부분이긴 하지만 내 스스로 역서를 읽을 때 불편하게 느꼈던 표현이라서 계속 염두하고 진행했다.

  • 피동/사동 표현은 최소로 사용하려고 했다. 피동이 아니면 정말 어색한 경우 외에는 전부 능동으로 적었다. “비주얼 모드에서 영역 선택 기능이 제공된다.” 보다는 “비주얼 모드에서 영역 선택 기능을 제공한다.” 처럼 작성했다.
  • 대명사는 좀 더 명확하게 하고 싶었다. It, this 같은 대명사가 반복되는데 “이 플러그인은”, “이 기능은” 식으로 옮겼다.
  • 복수형 표현에 주의했다. Some of Vim’s commands are 식은 “Vim의 명령들 중에는” 보다 “Vim의 명령 중에는” 처럼 작성했다.

번역 과정 중에 번역에 관한 책을 몇 권 알게 되서 읽어보려고 했는데 번역 사이사이 베타리딩, 이상한모임 행사, 회사일, 호주 체류 관련 업무 등등 수많은 일이 있어 도저히 읽을 여유가 없었다. 위시리스트에 있는 책은 다음과 같다. 각각 읽은 분들 후기를 보면 읽고 나서 번역했으면 얼마나 좋았을까 생각하며 적어뒀다.

  • 내 문장이 그렇게 이상한가요?
  • 번역의 탄생
  • 번역자를 위한 우리말 공부
  • 갈등하는 번역
  • 문장 기술

읽지도 않은 책 목록을 올리는게 이상한 기분이 들긴 하지만 혹시나 해서 넣었다. 연말 휴가 기간 동안에 마련할 수 있는 책은 찾아서 읽어볼 생각이다.


기대보다는 걱정이 더 많이 되는 것이 사실이다. 트위터에서 번역 질이 안좋은 책은 어떤 혹독한 대우를 받고 사는지 자주 봐서 이 책으로 불로장생을 실현하게 될까 걱정이 된다. 더군다나 책에 역자로 내 이름이 올라간다고 해서 나만의 책인 것이 아니라 교정부터 디자인, 출력 등 내가 알기도 모르기도 하는 수많은 손이 함께 한다는 생각이 문득 문득 들어 밥이 넘어가지 않을 때가 있다. 이미 원고가 내 손을 떠나 어떻게 할 수 없는 상황에서도 그런 기분이 든다. 책을 실물로 보면 좀 홀가분해질지, 그때가 되어봐야 알 것 같다.

그리고 한편 뿌듯했던 부분은 블로그에 계속 올린 번역을 보고 연락을 받았다는 점이다. 기술적인 내용이나 유익한 글은 번역하며 꼼꼼하게 보고 더 오래 기억하고 싶기도 했고 같이 읽고 싶다는 생각에 계속 번역을 올렸었다. 이런 번역이 일종의 포트폴리오가 되리라고는 생각을 해보진 못했다. 앞으로 어떻게 될 지 잘 모르겠지만 그래도 예전보다는 조금 더 꼼꼼하게 글을 올리게 될 것 같다.

파이썬 @memoize 고립된 환경에서 사용하기

함수의 호출 결과를 캐시로 만드는 @memoize 데코레이터 설명. 캐시 상황에서 문제 해결하기, 번역.

2016년 9월 26일

파이썬에서 데코레이터를 정말 자주 사용하고 있지만 다양한 용례는 접해보지 못했었다. Ned Batchelder의 글 Isolated @memoize은 @memoize 데코레이터에 대한 이야기인데 같이 곁들여진 설명과 각 링크가 유익해서 번역했다. 파이썬 데코레이터 모음 위키 페이지도 살펴보면 좋겠다.


파이썬 @memoize 고립된 환경에서 사용하기

실행 비용이 비싼 함수를 호출한다고 생각해보자. 동일한 입력을 했을 때 동일한 결과를 반환하는 함수인 경우에는 사람들 대부분은 @memoize 데코레이터를 사용하는 것을 선호한다. 이 데코레이터는 이전에 실행한 적이 있는 경우에는 동일한 결과를 빠르게 내놓을 수 있도록 캐시해둔다. 다음은 @memoize 구현 모음을 차용해서 만든 간단한 코드다.

def memoize(func):
    cache = {}

    def memoizer(*args, **kwargs):
        key = str(args) + str(kwargs)
        if key not in cache:
            cache[key] = func(*args, **kwargs)
        return cache[key]

    return memoizer

@memoize
def expensive_fn(a, b):
    return a + b        # 물론 이 함수는 그렇게 비싼 연산이 아니다!

이 코드는 원하는 동작을 제대로 수행하는 좋은 코드다. expresive_fn 함수를 동일한 인자로 반복해서 호출하면 실제로 함수를 호출하지 않고 캐시된 값을 사용할 수 있다.

하지만 여기에는 잠재적인 위험이 있다. 캐시 딕셔너리가 전역이라는 점이다. 물론 이 말을 문자 그대로 전역이라고 생각하는 잘못을 범하지 말자. global 키워드를 사용하지 않았고 모듈 단위의 변수인 것도 아니다. 하지만 이 딕셔너리는 프로세스 전체에서 expensive_fn를 대상으로 오직 하나의 캐시 딕셔너리를 갖고 있기 때문에 이 관점에서는 전역 변수라고 말할 수 있을 것이다.

전역은 잘 짜여진 테스트를 방해할 수 있다. 자동화된 테스트에서 가장 이상적인 동작 방식은 각 테스트가 서로 영향을 미치지 않도록 고립된 형태로 테스트를 수행하는 것이다. test1에서 어떤 일이 일어나든지 test99에는 영향이 없어야 한다. 하지만 여기서 test1부터 test99까지 expensive_fn을 (1, 2) 인자를 사용해서 호출했다면 test1은 함수를 호출하지만 test99는 캐시에 저장된 값을 사용한다. 더 나쁜 부분은 전체 테스트를 호출하면 test99는 캐시에 저장된 값을 사용하게 될 것인 반면 test99만 실행하면 함수를 실제로 실행하게 된다는 점이다.

만약 expensive_fn이 정말로 부작용 없는 순수 함수라면 이런 특징이 문제가 되지 않을 것이다. 하지만 때로는 문제가 되는 경우도 있다.

내가 관리하게 된 프로젝트 중에 고정된 데이터를 가져오기 위해 @memoize를 사용하는 웹사이트가 있었다. 자료를 가져올 때 단 한 번만 가져왔기 때문에 @memoize는 적절했고 프로그램을 사용하는데 전혀 문제가 되질 않았다. 테스트는 Betamax를 사용해서 네트워크 접근을 모조로 만들었다.

Betamax는 대단한 라이브러리다. 각 테스트 케이스를 구동할 때 각 테스트에서의 네트워크 접근을 확인한 후, “카세트”에 요청과 반환을 JSON 양식으로 저장한다. 다시 테스트를 수행하면 카세트에 저장되어 있는 정보를 사용해서 네트워크 접근을 모조해서 처리해준다.

문제는 test1의 카세트에서 캐시로 저장될 자원을 네트워크로 요청하게 되고 test99는 @memoize로 인해 네트워크를 통해 데이터를 요청할 필요가 없어졌기 때문에 test99의 카세트가 제대로 생성이 되지 않는다. 이제 테스트를 test99만 구동했을 때 카세트에 저장된 정보가 없기 때문에 테스트가 실패하게 된다. test1과 test99는 각각 고립되서 실행된다고 볼 수 없다. 저장된 캐시를 통해서 전역적으로 값을 공유하기 때문이다.

내 해결책은 @memoize를 사용했을 때 테스트 사이에 캐시 내용을 지우는 방식이다. 이 코드를 직접 작성하기 보다는 functools에 포함되어 있는 lru_cache 데코레이터를 사용할 수 있다. (여전히 2.7 버전의 파이썬을 사용하고 있다면 functools32을 찾아보자.) 이 데코레이터는 전역 캐시의 모든 값을 지울 수 있는 .cache_clear 함수를 제공한다. 이 함수는 각 데코레이터를 사용한 함수에 있기 때문에 사용한 함수를 목록으로 갖고 있어야 한다.

import functools

# memoize를 사용한 함수 목록을 저장. 그런 후
# `clear_memoized_values`로 일괄 비우기를 수행.
_memoized_functions = []

def memoize(func):
    """함수를 호출해서 반환한 값을 캐시로 저장함"""
    func = functools.lru_cache()(func)
    _memoized_functions.append(func)
    return func

def clear_memoized_values():
    """@memoize에 저장된 모든 값을 비워서 각 테스트가 고립된 환경으로 동작할 수 있도록 함"""
    for func in _memoized_functions:
        func.cache_clear()

이제 각 테스트 전에 캐시를 비우기 위해 py.test의 픽스쳐에서, 또는 테스트 케이스의 setUp() 메서드에서 clear_memoized_values() 함수를 사용할 수 있다.

# py.test를 사용하는 경우

@pytest.fixture(autouse=True)
def reset_all_memoized_functions():
    """@memoize에 캐시로 저장된 값을 매 테스트 전에 비움"""
    clear_memoized_values()

# unittest를 사용하는 경우

class MyTestCaseBase(unittest.TestCase):
    def setUp(self):
        super().setUp()
        clear_memoized_values()

사실 @memoize를 사용하는 다양한 이유를 보여주는 것이 더 나을지도 모른다. 순수 함수는 모든 테스트에서 캐시를 사용해서 같은 값을 반환해도 문제가 없을 것이다. 연산이 필요한 경우라면 누가 이런 문제를 신경 쓸까? 하지만 다른 경우에서는 확실히 고립된 환경을 만들어서 사용해야 한다. @memoize는 마술이 아니다. 이 코드가 어떤 일을 하는지, 어떤 상황에서 더 제어가 필요한지 잘 알아야 한다.


오현석(enshahar)님 피드백을 받아 번역을 개선했습니다. 감사 말씀 드립니다.

파이썬 리스트 vs. 튜플

2016년 9월 20일

파이썬을 처음 공부할 때 리스트와 튜플에 대해 비슷한 의문을 가진 적이 있었다. 이 둘을 비교하고 설명하는 Ned Batchelder의 Lists vs. Tuples 글을 번역했다. 특별한 내용은 아니지만 기술적인 차이와 문화적 차이로 구분해서 접근하는 방식이 독특하게 느껴진다.


Python에 입문하는 사람들이 흔하게 하는 질문이 있다. 리스트(list)와 튜플(tuple)의 차이는 무엇인가?

이 질문의 답변은 이렇다. 이 두 타입은 각각 상호작용에 있어 두 가지 다른 차이점이 존재한다. 바로 기술적인 차이와 문화적인 차이다.

먼저 두 타입의 공통점을 확인하자. 리스트와 튜플은 둘 다 컨테이너로 일련의 객체를 저장하는데 사용한다.

>>> my_list = [1, 2, 3]
>>> type(my_list)
<class 'list'>
>>> my_tuple = (1, 2, 3)
>>> type(my_tuple)
<class 'tuple'>

둘 다 타입과 상관 없이 일련의 요소(element)를 갖을 수 있다. 두 타입 모두 요소의 순서를 관리한다. (세트(set)나 딕셔너리(dict)와 다르게 말이다.)

이제 차이점을 보자. 리스트와 튜플의 기술적 차이점은 불변성에 있다. 리스트는 가변적(mutable, 변경 가능)이며 튜플은 불변적(immutable, 변경 불가)이다. 이 특징이 파이썬 언어에서 둘을 구분하는 유일한 차이점이다.

>>> my_list[1] = "two"
>>> my_list
[1, 'two', 3]
>>> my_tuple[1] = "two"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

이 특징은 리스트와 튜플을 구분하는 유일한 기술적 차이점이지만 이 특징이 나타나는 부분은 여럿 존재한다. 예를 들면 리스트에는 .append() 메소드를 사용해서 새로운 요소를 추가할 수 있지만 튜플은 불가능하다.

>>> my_list.append("four")
>>> my_list
[1, 'two', 3, 'four']
>>> my_tuple.append("four")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

튜플은 .append() 메소드가 필요하지 않다. 튜플은 수정할 수 없기 때문이다.

문화적인 차이점을 살펴보자. 리스트와 튜플을 어떻게 사용하는지에 따른 차이점이 있다. 리스트는 단일 종류의 요소를 갖고 있고 그 일련의 요소가 몇 개나 들어 있는지 명확하지 않은 경우에 주로 사용한다. 튜플은 들어 있는 요소의 수를 사전에 정확히 알고 있을 경우에 사용한다. 동일한 요소가 들어있는 리스트와 달리 튜플에서는 각 요소의 위치가 큰 의미를 갖고 있기 때문이다.

디렉토리 내에 있는 파일 중 *.py로 끝나는 파일을 찾는 함수를 작성한다고 가정해보자. 이 함수를 사용했을 때는 파일을 몇 개나 찾게 될 지 알 수 없다. 그리고 동일한 규칙으로 찾은 파일이기 때문에 항목 하나 하나가 의미상 동일하다. 그러므로 이 함수는 리스트를 반환할 것이다.

>>> find_files("*.py")
["control.py", "config.py", "cmdline.py", "backward.py"]

다른 예를 확인한다. 기상 관측소의 5가지 정보, 식별번호, 도시, 주, 경도와 위도를 저장한다고 생각해보자. 이런 상황에서는 리스트보다 튜플을 사용하는 것이 적합하다.

>>> denver = (44, "Denver", "CO", 40, 105)
>>> denver[1]
'Denver'

(지금은 클래스를 사용하는 것에 대해서 이야기하지 않을 것이다.) 이 튜플에서 첫 요소는 식별번호, 두 번째는 도시… 순으로 작성했다. 튜플에서의 위치가 담긴 내용이 어떤 정보인지를 나타낸다.

C 언어에서 이 문화적 차이를 대입해보면 목록은 배열(array) 같고 튜플은 구조체(struct)와 비슷할 것이다.

파이썬은 네임드튜플(namedtuple)을 제공하는데 이 네임드튜플을 사용하면 튜플에서 각 위치의 의미를 명시적으로 작성할 수 있다.

>>> from collections import namedtuple
>>> Station = namedtuple("Station", "id, city, state, lat, long")
>>> denver = Station(44, "Denver", "CO", 40, 105)
>>> denver
Station(id=44, city='Denver', state='CO', lat=40, long=105)
>>> denver.city
'Denver'
>>> denver[1]
'Denver'

튜플과 리스트의 문화적 차이를 영악하게 정리한다면 튜플은 네임드튜플에서 이름이 없는 것이라고 할 수 있다.

기술적 차이점과 문화적 차이점을 연계해서 생각하기란 쉽지 않은데 종종 이 차이점이 이상할 때가 있기 때문이다. 왜 단일 종류의 일련 데이터는 가변적이고 여러 종류의 일련 데이터는 불변이어야 하는 것일까? 예를 들면 앞에서 저장했던 기상관측소의 데이터는 수정할 수 없다. 네임드 튜플은 튜플이고 튜플은 불변이기 때문이다.

>>> denver.lat = 39.7392
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

때때로 기술적인 고려가 문화적 고려를 덮어쓰는 경우가 있다. 리스트를 딕셔너리에서 키로 사용할 수 없다. 불변 값만 해시를 만들 수 있기 때문에 키에 불변 값만 사용 가능하다. 대신 리스트를 키로 사용하고 싶다면 다음 예처럼 리스트를 튜플로 변경했을 때 사용할 수 있다.

>>> d = {}
>>> nums = [1, 2, 3]
>>> d[nums] = "hello"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> d[tuple(nums)] = "hello"
>>> d
{(1, 2, 3): 'hello'}

기술과 문화가 충돌하는 또 다른 예가 있다. 파이썬에서도 리스트가 더 적합한 상황에서 튜플을 사용하는 경우가 있다. *args를 함수에서 정의했을 때, args로 전달되는 인자는 튜플을 사용한다. 함수를 호출할 때 사용한 인자의 순서가 크게 중요하지 않더라도 말이다. 튜플은 불변이고 전달된 값은 변경할 수 없기 때문에 이렇게 구현되었다고 말할 수 있겠지만 그건 문화적 차이보다 기술적 차이에 더 가치를 두고 설명하는 방식이라 볼 수 있다.

물론 *args에서 위치는 매우 큰 의미를 갖는다. 매개변수는 그 위치에 따라 의미가 크게 달라지기 때문이다. 하지만 함수는 *args를 전달 받고 다른 함수에 전달해준다고만 봤을 때 *args는 단순히 인자 목록이고 각 인자는 별 다른 의미적 차이가 없다고 할 수 있다. 그리고 각 함수에서 함수로 이동할 때마다 그 목록의 길이는 가변적인 것으로 볼 수 있다.

파이썬이 여기서 튜플을 사용하는 이유는 리스트에 비해서 조금 더 공간 효율적이기 때문이다. 리스트는 요소를 추가하는 동작을 빠르게 수행할 수 있도록 더 많은 공간을 저장해둔다. 이 특징은 파이썬의 실용주의적 측면을 나타낸다. 이런 상황처럼 *args를 두고 리스트인지 튜플인지 언급하기 어려운 애매할 때는 그냥 상황을 쉽게 설명할 수 있도록 자료 구조(data structure)라는 표현을 쓰면 될 것이다.

대부분의 경우에 리스트를 사용할지, 튜플을 사용할지는 문화적 차이에 기반해서 선택하게 될 것이다. 어떤 의미의 데이터인지 생각해보자. 만약 프로그램이 실제로 다루는 자료가 다른 길이의 데이터를 갖는다면 분명 리스트를 써야 할 것이다. 작성한 코드에서 세 번째 요소에 의미가 있는 경우라면 분명 튜플을 사용해야 할 상황이다.

반면 함수형 프로그래밍에서는 코드를 어렵게 만들 수 있는 부작용을 피하기 위해서 불변 데이터 구조를 사용하라고 강조한다. 만약 함수형 프로그래밍의 팬이라면 튜플이 제공하는 불변성 때문에라도 분명 튜플을 좋아하게 될 것이다.

자, 다시 질문해보자. 튜플을 써야 할까, 리스트를 사용해야 할까? 이 질문의 답변은 항상 간단하지 않다.