Coderifleman's blog

frontend development stories.

  • 플럭스 아키텍처를 이용해 애플리케이션을 개발할 때 가장 먼저 스토어를 설계한다. 그 이유는 스토어가 무엇보다 중요한 데이터를 관리하는 객체라고 해석하기 때문이다.

    스토어는 모델이나 엔티티 등의 데이터를 관리하기 위한 공간이 아니다. 그저 표현을 위한 상태를 관리하는 객체일 뿐이다. 예를 들어 데이터를 필터링하기 위한 기준값이 있다고 하자. 이 값이 스토어에 존재한다고 해서 실제 필터링에 사용되지 않는다. 스토어에 존재하는 이유는 이 값을 어딘가에 표현해야 하기 때문이다.

    표현 계층은 표현을 위한 상태가 필요하다. 그리고 애플리케이션 비즈니스는 필연적으로 도메인 데이터가 필요하다. 만약 두 가지의 책임을 스토어가 갖게 된다면 애플리케이션 내에서 무엇보다 중요한 거대한 객체가 탄생하게 된다.

    스토어의 의존 관계
    <그림 1. 스토어의 의존 관계>

    첫 번째 다이어그램을 보자. 스토어에서 표현을 위한 상태와 도메인 데이터를 모두 관리하고 있다. 이 경우 표현 계층과 애플리케이션 비즈니스 계층 모두 스토어를 의존한다. 애플리케이션 비즈니스는 도메인 데이터에 접근할 수 있어야 하므로 스토어에 겟터(Getter) 메서드도 작성해야 한다.

    반면 두 번째 다이어그램에서는 데이터의 관리 책임을 분리했다. 자연스럽게 의존 관계는 좌측에서 우측으로 이뤄진다. 스토어에서 겟터를 제공할 필요도 없다. 데이터를 관리하는 계층에서 적절한 수단을 제공하면 된다. 스토어는 상대적으로 단순하고 각 계층을 테스트하기도 쉽다.

    플럭스 아키텍처 의존 관계
    <그림 2. 플럭스 아키텍처 의존 관계>

    필자는 스토어를 표현 계층의 한 요소로 해석한다. 스토어에서 도메인 데이터를 분리해야만 클린 아키텍처에서 말하는 의존 규칙을 지킬 수 있다.

    플럭스 아키텍처를 이용해 애플리케이션을 설계하더라도 모델 계층은 언제나 필요하다. 즉, 애플리케이션을 개발할 때 가장 먼저 시작해야 할 지점은 스토어가 아니라 바로 모델이다.

  • 보통 애플리케이션 개발은 외부 자원에 접근할 수 있는 API가 필요하다. 그리고 대개, API를 개발하는 백엔드 개발자와 애플리케이션 인터페이스를 개발하는 프런트엔드 개발자로 나눠 협업한다. 이때 두 직군 간 협업 과정에서 병목이 발생하기 쉽다.

    프런트엔드 개발자는 동작하는 API를 기다리고 백엔드 개발자는 프런트엔드 요구에 맞춰 API, 또는 비즈니스 로직까지 변경해야 하는 일이 생긴다. 그러다 보니 교착 상태에 빠지거나 시간이 흐를수록 커뮤니케이션이 힘들어지기도 한다.

    만약 당신이 이러한 문제를 겪고 있다면 지금부터 소개하는 두 가지 방법이 도움이 될 수 있다.

    API 인터페이스 협의

    가장 이상적인 방법은 API 인터페이스를 협의하는 과정을 갖는 것이다. 필요한 API는 요구사항 분석 과정에서 알 수 있다. 예를 들어 복수의 피드를 선택해 구독하는 기능을 떠올려보자. 자연스럽게 복수 피드에 대해 구독 요청을 할 수 있는 API가 필요함을 알 수 있다.

    이어서 백엔드 개발자와 함께 구독 요청 API 인터페이스를 협의한다. API 인터페이스란 어떤 메서드와 URL로 요청 해야 하고 응답 형식은 무엇인가에 대한 약속이다. 이를 신뢰하고 각자 개발을 진행한다. 동작하는 API를 기다리지 않아도 된다.

    Node.js에 익숙하다면 Express를 이용해 목서버를 만들 수 있다. 만약 테스트를 작성한다면 Sinon.JSFake XHR을 이용한다.

    test('collect()', async () => {
      // Given
      server.respondWith('POST', /\/api\/feeds\/collect/, xhr => {
        if (JSON.parse(xhr.requestBody).productIds !== '1,2,3') {
          return false;
        }
        xhr.respond(200, {'Content-Type': 'application/json'}, JSON.stringify({collectId: 'ab8d7ee'}));
      });
      // When
      const collectId = await feeds.collect([1, 2, 3]);
      // Then
      expect(collectId).to.equal('ab8d7ee');
    });

    또는, 목서버 없이 개발 환경에서만 Fake XHR을 애플리케이션 문맥에 불러올 수도 있다.

    팩토리를 이용한 개발

    어떠한 API가 필요하고 또, 구현 가능한지 불확실할 때는 협의 미팅이 도움 되지만, 구현할 기능이 단순하고 필요한 API에 관해 대략적인 정보를 공유하고 있다면 오히려 비용일 수 있다. 그렇지만 API 응답 형태를 모르는 상태에서 예측으로 코드를 작성하면 통합 단계에서 애플리케이션 전체의 코드 변경이 필요할 수 있다.

    친구 목록을 가져와 출력해주는 기능을 상상해보자. 이 요구사항에서 모델을 디자인할 수 있다.

    class FriendEntity {
      public readonly id: number;
      public readonly name: string;
      public readonly tel: string;
    
      constructor(data: Friend) {
        this.id = data.id;
        this.name = data.name;
        this.tel = data.tel;
      }
    }

    이 모델을 의존해 기능을 개발한다. API의 응답은 몰라도 된다. 이후 동작하는 API를 전달받았는데 응답 형태가 앞서 작성한 모델과 다르다고 가정해보자.

    interface FriendPayload {
      userNo: number;
      name: string;
      phoneNumber: string;
    }

    만약 모델을 바꾸면 애플리케이션에 영향이 생긴다. 그리고 외부 데이터가 모델의 변경을 유발한다는 것도 논리적이지 않다. 이때, 우리는 팩토리 객체를 이용할 수 있다.

    class FriendFactory {
      public static create(data: FriendPayload) {
        return new FriendEntity({
          id: data.userNo,
          name: data.name,
          tel: data.phoneNumber
        });
      }
    }
    
    me.friends().then((data: FriendPayload[]) => {
      const friends = data.map(d => FriendFactory.create(d));
      friendListView.render(friends);
    });

    friendListViewFriendEntity에 의존한다. API의 응답 형태로 인해 friendListView가 영향받지 않는다. 이처럼 모델에서 시작하고, 모델을 의존하면 API 없이 개발을 시작할 수 있다. API를 전달받으면 팩토리 객체를 이용해 변경을 최소화하고 통합하면 된다.

    여기까지 백엔드 개발자와 협업하는 두 가지 방법을 간단히 소개했다. 어떠한 API가 필요하고 또, 구현 가능한지 불확실하다면 API 인터페이스 협의 과정을 갖길 바란다. 하지만 기능이 단순하고 필요한 API도 어느 정도 공유된 상황에서 협의 미팅은 피곤할 수 있다. 그땐 팩토리 객체를 이용하자. 애플리케이션의 변경을 최소화해 개발할 수 있다.

  • 읽기전에...

    이 문서는 일본어 「クリーンアーキテクチャ(The Clean Architecture翻訳)」을 중역한 글입니다. 원글은 「The Clean Architecture」로 번역 시 참고했습니다. 오역 감수는 프런트엔드 개발자 이두용님께서 수고해주셨습니다.

    로버트 C. 마틴(Robert Martin, a.k.a 엉클 아저씨)이 공개한 「The Clean Architecture」를 번역한 글입니다. 관점이 비슷한 「Hexagonal Architecture(일본어)」도 번역했으니 참고해주세요.

    해당 글을 번역하겠다고 「8th Light, inc」에 이야기했으며 현재까지는 별다른 제재가 없었습니다.

    The Clean Architecture의 다이어그램
    <그림. The Clean Architecture의 다이어그램>

    지난 몇 년 동안 우리는 시스템 아키텍처에 대한 실로 다양한 아이디어를 봐왔다. 예를들어 다음과 같은 것이 포함된다.

    위 아키텍처의 세세한 부분은 모두 다르지만 매우 비슷하기도 하다. 이들은 모두 같은 목적을 갖고 있는데 바로 관심사의 분리다. 소프트웨어를 계층으로 나눔으로써 관심사를 분리한다. 그리고 모두 비즈니스 규칙을 위한 최소 하나 이상의 계층과 인터페이스를 위한 또 다른 계층을 두고 있다.

    위 아키텍처 모두는 다음과 같은 시스템을 생성한다.

    1. 프레임워크 독립적, 이들 아키텍처는 소프트웨어 라이브러리 존재 여부에 의존하지 않는다. 이는 시스템을 프레임워크의 한정된 제약에 억지로 집어넣는 대신 도구로써 사용하는 것을 가능하게 한다.
    2. 테스트 용이함, 비즈니스 규칙은 UI, 데이터베이스, 웹 서버, 기타 외부 요인없이 테스트 가능하다.
    3. UI 독립적, 시스템의 나머지 부분을 변경할 필요 없이 UI를 쉽게 변경할 수 있다. 예를들면 웹 UI는 비즈니스 규칙 변경 없이 콘솔 UI와 치환된다.
    4. 데이터베이스 독립적, 오라클 또는 SQL Server를 몽고, 빅테이블, 카우치 DB 등으로 바꿀 수 있다. 비즈니스 규칙은 데이터베이스에 얽매이지 않는다.
    5. 외부 기능 독립적, 실제로 비즈니스 규칙은 외부 세계에 대해 아무것도 모른다.

    이 문서의 말머리에 소개한 그림은 이러한 아키텍처를 단일 개념으로 통합하고자 하는 시도다.

    의존 규칙

    이들 동심원은 소프트웨어의 각기 다른 영역을 나타내고 있다. 대개, 바깥쪽으로 향할수록 고수준의 소프트웨어가 된다. 바깥쪽의 원은 메커니즘(Mechanism)이고 안쪽의 원은 정책(Policy)이다.

    이 아키텍처를 기능하게 하는 중요한 규칙이 바로 의존 규칙이다. 이 규칙에 의해서 소스 코드는 안쪽을 향해서만 의존할 수 있다. 안쪽의 원은 바깥쪽의 원에 대해 전혀 알지 못한다. 특히, 바깥쪽의 원에서 선언된 어떠한 이름을 안쪽 원에서 참조해서는 안된다. 이는 함수, 클래스, 변수 등 이름이 붙은 소프트웨어의 엔티티 모든 것에 해당한다.

    마찬가지로 바깥쪽의 원에서 사용하고 있는 데이터 포맷을 안쪽의 원에서 사용하지 않아야 한다. 특히 그러한 포맷이 바깥쪽 원에서 프레임워크에 의해 생성되고 있다면 바깥쪽 원의 어떠한 것도 안쪽의 원에 영향을 줘선 안된다.

    엔티티

    엔티티는 대규모 프로젝트 레벨의 비즈니스 규칙을 캡슐화 한다. 엔티티는 메서드를 갖는 객체 일 수도 있지만 데이터 구조와 함수의 집합일 수도 있다. 엔티티가 대규모 프로젝트 내의 다양한 애플리케이션에서 사용된다고 하더라도 문제될 것은 없다.

    대규모 프로젝트가 아닌 단지 하나의 애플리케이션을 작성할 뿐이라면 엔티티는 그 애플리케이션의 비즈니스 객체가 된다. 엔티티는 가장 일반적이면서 고수준의 규칙을 캡슐화한다. 그리고 바깥쪽에서 무엇이 변경되더라도 바뀌지 않는다. 예를들어 엔티티 객체는 페이지 내비게이션의 변경이나 보안 사항으로 부터 영향을 받지 않을 것을 기대할 수 있다. 애플리케이션의 동작에 관한 변경이 엔티티 계층에 영향을 주어선 안된다.

    유스케이스

    이 계층의 소프트웨어는 애플리케이션 고유 비즈니스 규칙을 포함하며 시스템의 모든 유스케이스를 캡슐화하고 구현한다. 이들 유스케이스는 엔티티로 부터의 혹은 엔티티에서의 데이터 흐름을 조합한다. 그리고 엔티티 즉, 프로젝트 레벨의 비즈니스 규칙을 사용해 유스케이스의 목적을 달성하도록 지휘한다.

    이 계층의 변경이 엔티티에 영향을 주지 않을 것을 기대하며 데이터베이스, UI 또는 공통의 프레임워크의 변경으로부터 영향 받지 않을 것도 기대한다. 이 계층은 그러한 관심에서 격리된다.

    하지만, 애플리케이션의 조작에 대한 변경은 유스케이스에 영향이 있고 따라서 이 계층의 소프트웨어에 영향이 있을 것을 기대한다. 유스케이스의 상세가 바뀐다면 이 계층의 코드는 확실히 영향을 받는다.

    인터페이스 어댑터

    이 계층의 소프트웨어는 어댑터의 집합이다. 이는 유스케이스와 엔티티에 있어 용이한 형식으로부터 데이터베이스나 웹 등 외부의 기능에 용이한 형식으로 데이터를 변환한다. 예를들어 이 레이어는 GUI의 MVC 아키텍처를 완전히 내포한다. 프리젠터, 뷰, 컨트롤러는 모두 여기에 속한다. 모델은 컨트롤러에서 유스케이스로 전달되고 이어 유스케이스에서 프리젠터나 뷰로 되돌아가는 그저 단순한 데이터 구조일 가능성이 높다.

    마찬가지로 이 계층에서 데이터는, 엔티티나 유스케이스에 용이한 형에서, 사용하고 있는 프레임워크에 용이한 형으로 변환된다. 예를들어 데이터베이스를 들 수 있다. 이 계층에 해당하는 원보다 안쪽에 존재하는 코드는 데이터베이스에 관해 아는 것이 없어야 한다. 만약 데이터베이스가 SQL 데이터베이스라면 어떤 SQL이든 이 계층에 제한돼야 하며 특히, 이 계층 내의 데이터베이스와 관련 있는 부분에 제한돼야 된다.

    또, 이 계층에는 외부의 어떠한(외부 서비스) 형식에서 유스케이스와 엔티티에서 사용될 수 있는 내부 형식으로 데이터를 변환하기 위해 필요한 기타 모든 어댑터도 둘 수 있다.

    프레임워크와 드라이버

    가장 바깥쪽의 계층은 데이터베이스나 웹 프레임워크 등 일반적으로 프레임워크나 도구로 구성된다. 대개, 이 계층에는 안쪽의 원과 통신할 연결 코드 이외에는 별다른 코드를 작성하지 않는다.

    이 계층에는 상세한 정보가 무엇이든 여기에 둔다. 웹은 상세하다. 그리고 데이터베이스도 상세하다. 이러한 것으로 인해 악영향을 주지 않도록 밖에 유지한다.

    4개의 원이 아니면 안되는가

    아니다. 이 원은 컨셉을 전하기 위한 수단이다. 이 4가지 이외에도 무엇인가 필요할 가능성이 있다. 정확히 4가지가 아니면 안된다는 규칙은 없다. 하지만 의존 규칙은 항상 적용된다. 소스 코드의 의존성은 항상 안쪽으로 향해야 한다. 안쪽으로 향해감에 따라 추상화 수준은 올라간다. 가장 바깥쪽의 원은 저수준의 구체적인 상세 정보를 담는다. 안쪽으로 이동해가면서 소프트웨어는 추상화 되고 고수준의 정책을 캡슐화한다. 가장 안쪽에 있는 원은 무엇보다 일반성이 있다.

    교차경계

    위 다이어그램의 오른쪽 아래의 그림은 어떤식으로 원의 경계가 교차하는지 보여주는 예다. 이것은 컨트롤러와 프리젠터가 다음 계층인 유스케이스와 어떻게 대화하는지 보여준다. 제어의 흐름에 주의하길 바란다. 컨트롤러에서 시작해 유스케이스를 거쳐 프리젠터에서 실행됨을 알 수 있다. 소스 코드의 의존성에도 주의한다. 모두 안쪽의 유스케이스를 향하고 있다.

    우리는 이 분명한 모순을 의존 관계 역전의 원칙(Dependency Inversion Principle)으로 해결하는 경우가 많다. 예를들어 Java와 같은 언어에서는 인터페이스와 상속 관계를 조합해 소스 코드의 의존성이 경계를 걸치고 있는 오른쪽 지점에서 제어 흐름이 반대하도록 한다.

    다시 예를들어 유스케이스가 프리젠터를 호출할 필요가 있는 경우를 생각해보자. 하지만 이때의 호출은 직접 이뤄질 수 없다. 왜냐하면 “의존성 규칙 : 바깥쪽의 이름을 안쪽에서 언급할 수 없다”를 위반하기 때문이다. 때문에 유스케이스에는 안쪽 원에 있는 인터페이스(Use Case Output Port라고 적혀있는)를 호출한다. 그리고 원 바깥쪽의 프리젠터는 이 인터페이스를 구현한다.

    이와 똑같은 테크닉이 아키텍처의 경계가 교차되는 곳에서 사용된다. 동적인 다형성의 이점을 이용해 소스 코드의 의존성이 제어 흐름의 반대가 되도록 한다. 이렇게 하면 제어의 흐름이 어떤 방향으로 진행되든지 상관없이 의존성 규칙을 지킬 수 있다.

    어떤 데이터가 경계를 교차하는가

    대개, 경계를 넘나드는 데이터는 단순한 구조의 데이터다. 기본적인 구조체나 단순한 데이터 전송 객체(Data Transter Object)를 취향에 맞게 사용할 수 있다. 혹은 데이터는 단순히 함수의 인수라 해도 좋다. 또는, 그것을 해시맵으로 해도 좋고 객체로 구성해도 좋다. 중요한 것은 격리된 단순한 구조의 데이터가 경계를 넘어간다는 점이다. 우리는 꾀를 부려 엔티티나 데이터베이스의 행을 전달하지 않는다. 데이터 구조가 의존성 규칙을 위반하는 모든 종류의 원인을 갖지 않길 바란다.

    예를 들어 여러 데이터베이스 프레임워크는 쿼리에 응답하여 편리한 데이터 포맷을 반환한다. 이것을 행 구조(RowStructure)라고 부르자. 이 행 구조를, 경계를 넘어 안쪽으로 전달하지 않기를 원한다. 이는 의존성 규칙을 위반한다. 왜냐하면 바깥쪽 원에 관한 무언가를 안쪽의 원이 알도록 강제하기 때문이다.

    때문에 경계를 넘어 데이터를 전달할 때엔 항상 안쪽의 원이 다루기 쉬운 데이터 형식이어야 한다.

    결론

    이런 간단한 규칙을 따르는 것은 어렵지 않다. 그리고 머리가 아프지 않도록 도와 줄 것이다. 소프트웨어를 계층으로 나누고 의존성 규칙을 따름으로써 본질적으로 테스트하기 쉬운 시스템을 만들 수 있고 의존성 규칙이 가져오는 이점도 얻을 수 있다. 시스템의 외부 부품(데이터베이스나 웹 프레임워크 등)이 낡았다면 그러한 부분도 최소한의 노력으로 바꿀 수 있다.

  • Tabnabbing

    Tabnabbing이란 HTML 문서 내에서 링크(target이 _blank인 Anchor 태그)를 클릭 했을 때 새롭게 열린 탭(또는 페이지)에서 기존의 문서의 location을 피싱 사이트로 변경해 정보를 탈취하는 공격 기술을 뜻한다. 이 공격은 메일이나 오픈 커뮤니티에서 쉽게 사용될 수 있다.

    Tabnabbing 공격 흐름
    <그림 1. Tabnabbing 공격 흐름(출처)>

    공격 절차는 다음과 같다.

    1. 사용자가 cgm.example.com에 접속한다.
    2. 해당 사이트에서 happy.example.com으로 갈 수 있는 외부 링크를 클릭한다.
    3. 새탭으로 happy.example.com가 열린다.
      • happy.example.com에는 window.opener 속성이 존재한다.
      • 자바스크립트를 사용해 opener의 location을 피싱 목적의 cgn.example.com/login 으로 변경한다.
    4. 사용자는 다시 본래의 탭으로 돌아온다.
    5. 로그인이 풀렸다고 생각하고 아이디와 비밀번호를 입력한다.
      • cgn.example.com은 사용자가 입력한 계정 정보를 탈취한 후 다시 본래의 사이트로 리다이렉트한다.

    시나리오를 하나 그려보자. 공격자가 네이버 계정을 탈취할 목적으로 여러분에게 바겐세일 정보를 담은 메일을 보냈다. 그 메일에는 [자세히 보기]라는 외부 링크가 포함돼 있다.

    물론 이 바겐세일 정보는 가짜지만 공격자에겐 중요하지 않다. 메일을 읽는 사람이 유혹에 빠져 링크를 클릭하면 그만이다.

    NAVER 메일을 이용한 Tabnabbing 데모
    <그림 2. NAVER 메일을 이용한 Tabnabbing 데모>

    국내에서 가장 유명한 포털 회사인 NAVER가 이러한 공격에 다소 미흡한 점은 못내 아쉽다. NAVER 뿐만 아니라 DAUM도 마찬가지이며 아마 카페 서비스도 동일하게 재현할 수 있지 않을까 생각한다.

    하지만 Gmail은 이 공격이 통하지 않는다. Gmail은 이러한 공격을 막기 위해 Anchor 태그에 data-saferedirecturl 속성을 부여해 안전하게 리다이렉트 한다. Twitter도 동일한 방법으로 대응하고 있다.

    Gmail의 소스 코드
    <그림 3. Gmail의 소스 코드>

    rel=noopener 속성

    이러한 공격의 취약점을 극복하고자 noopener 속성이 추가 됐다.

    rel=noopener 속성이 부여된 링크를 통해 열린 페이지는 location 변경과 같은 자바스크립트 요청을 거부한다. 정확히 말해서 Uncaught TypeError 에러를 발생시킨다(크롬 기준).

    새탭의 콘솔 결과
    <그림 4. 새탭의 콘솔 결과>

    이 속성은 Window Opener Demo 페이지를 통해 테스트해볼 수 있다.

    크롬은 버전 49, 파이어폭스 52부터 지원한다. 파이어폭스 52가 2017년 3월에 릴리즈 된 것을 감안하면 이 속성 만으로 안심하긴 힘들 것 같다. 자세한 지원 여부는 Link types를 참고한다.

    따라서 이러한 공격이 우려스러운 서비스(메일, 커뮤니티, 댓글 시스템 등)라면 blankshield와 같은 라이브러리를 사용하자.

    noopener 속성은 보안적 측면 외에도 성능 상의 이점도 취할 수 있다.

    _blank 속성으로 열린 탭(페이지)는 언제든지 opener를 참조할 수 있다. 그래서 부모 탭과 같은 스레드에서 페이지가 동작한다. 이때 새탭의 페이지가 리소스를 많이 사용한다면 덩달아 부모 탭도 함께 느려진다.

    noopener 속성을 사용해 열린 탭은 부모를 호출할 일이 없다. 따라서 같은 스레드 일 필요 없으며 새로운 페이지가 느리다고 부모 탭까지 느려질 일도 없다.

    rel="noopener" prevents window.opener, so there's no cross-window access. Chromium browsers optimise for this and open the new page in its own process.

    자세한 내용은 The performance benefits of rel=noopener을 참고하자.

    참고자료

  • 프런트엔드 엔지니어를 위한 베지에 곡선(Bézier Curves) - 2편」을 포스팅한 후 시간이 꽤 지났다. 어디까지 이야기했더라? 기억도 가물가물하다. 최근에 강의를 시작하면서 여유가 없었다는 핑계를 대보지만, 뭐가 됐든 게을러서 그렇다. 3편을 기다리신 분이 있었다면 죄송할 따름이다.

    글과 관련 없는 얘기는 이쯤 하자. 2편에서는 에버리징과 블렌딩 그리고 인터폴레이션(Interpolation)이라고 부르는 보간을 소개했다. 베지에 곡선을 이해하기 위한 기초 지식이었으며 이 개념만 이해하고 있으면 나머지는 쉽게 이해할 수 있다.

    1차 베지에 곡선

    우리는 이미 1차 베지에 곡선(Linear Bezier Cuvers)을 경험했다. 2편의 복합 데이터 블렌딩보간 절에서 보여준 예제가 바로 1차 베지에 곡선이다. 차이점이 있다면 단순히 평면상에서 곡선을 그리는 게 아니라 직교좌표계 상에서 그린다는 것이다.

    1차 베지에 곡선
    <그림 1. 1차 베지에 곡선>

    1차 베지에 곡선은 조절점(Control point) 두 개로 그린다. 아주 간단하지만, 굴곡이 없는 선형이다(직선도 곡선에 포함된다는 사실을 잊지 말자).

    2차 베지에 곡선

    그럼 2차 베지에 곡선(Quadratic Bézier Curves)을 그려보자. 2차 베지에 곡선은 조절점 3개를 이용해 그린 곡선을 말한다.

    2차 베지에 곡선
    <그림 2. 2차 베지에 곡선>

    3개의 조절점 A, B, C를 이용해 그린 두 개의 직선 즉, 두 개의 1차 베지에 곡선이 있다. 그리고 이 곡선에서 보간되는 점 E와 F도 있다. 이때 점 E와 F를 이용해 또 다른 직선을 그릴 수 있고 이 직선에서 보간되는 점 P도 추가할 수 있다. 이제 점 E와 F 그리고 P를 보간하면 P의 행적이 곡선을 만들어 낸다(이해가 되지 않는다면 「중학생도 알 수 있는 베지에 곡선」을 참고한다).

    그럼 이제 2차 베지에 곡선을 직접 그려보도록 하자. 선분에서 블렌딩 되는 점 P를 구하는 공식은 다음과 같다(자세한 내용은 「프런트엔드 엔지니어를 위한 베지에 곡선(Bézier Curves) - 2편」을 참고). 이때 s = 1 - t다.

    P = (s * A) + (t * B)

    그림 2를 보면 알 수 있듯이 2차 베지에 곡선을 그리기 위해서는 점 E와 F 그리고 P를 보간해야 한다. 점 E는 조절점 A와 B를 이용해 구할 수 있고, 점 F는 조절점 B와 C를 이용해 구할 수 있다. 그리고 점 P는 다시 점 E와 F를 이용해 구할 수 있다.

    E = (s * A) + (t * B)
    F = (s * B) + (t * C)
    P = (s * E) + (t * F)

    이 공식을 자바스크립트 코드로 옮겨보자. 여기에서는 구현에 있어 몇 가지 중요한 함수만 소개한다. 전체 코드는 코드펜(CodePen)에 작성해 놓은 예제를 참고한다.

    먼저 blender()는 점 A와 점 B 그리고 가중치 t를 전달받아 블랜딩한 결괏값을 반환하는 함수다.

    function blender(A, B, t) {
      if (t === 0) {
        return A;
      }
    
      if (t === 1) {
        return B;
      }
    
      return ((1 - t) * A) + (t * B); // or A + t * (B - A)
    }

    이때 blender()는 좌표 하나에 대한 연산만 책임지므로 x, y 좌표를 연산하기 위해 blend()를 작성한다.

    function blend(x1, x2, y1, y2, t) {
      const x = blender(x1, x2, t);
      const y = blender(y1, y2, t);
    
      return {x, y};
    }

    다음으로 blend()를 이용해 점 A와 점 B의 좌표를 전달해 점 E의 좌푯값을 구하고 점 B와 점 C의 좌표를 전달해 점 F의 좌표를 구한다. 그리고 다시 점 E와 점 F의 좌표를 전달해 점 P의 좌표를 구하는 방식으로 공식을 구현한다.

    interpolateBtn.addEventListener('click', function() {
      // Start the interpolation.
      raf(function(t) {
        const posE = blend(posA.x, posB.x, posA.y, posB.y, t);
        const posF = blend(posB.x, posC.x, posB.y, posC.y, t);
        const posP = blend(posE.x, posF.x, posE.y, posF.y, t);
        ...
      }, 1000);
    });

    아래 데모를 실행해 보자. 점 P가 보간되면서 그려진 곡선을 2차 베지에 곡선이라고 한다.

    See the Pen qrpYwj by Uyeong Ju (@uyeong) on CodePen.

    수식 정리

    우리가 2차 베지에 곡선을 위해 사용한 수식은 다음과 같다.

    E = (s * A) + (t * B)
    F = (s * B) + (t * C)
    P = (s * E) + (t * F)

    하지만 이 수식은 조금 장황하며 자바스크립트 코드상에서도 함수 호출이 빈번한 상태다. 이 수식을 방정식으로 좀더 간결하고 효율적으로 표현할 수 있다. 일단 연산식에 있는 괄호를 없애고 좀더 간략하게 수식을 표현한다.

    E(t) = sA + tB
    F(t) = sB + tC
    P(t) = sE(t) + tF(t)

    이번엔 중학생 때 배워본 몇 가지 곱셈 공식 사용하여 세 줄로 표현한 수식을 한 줄로 작성하고 이차방정식으로 정리한다.

    P(t) = s(sA + tB) + t(sB + tC)
    P(t) = (s²)A + (st)B + (st)B + (t²)C
    P(t) = (s²)A + 2(st)B + (t²)C

    자, 몇 가지 규칙을 더 추가하자. t가 0이라면 P는 항상 A와 같으며 다음과 같이 증명할 수 있다.

    P(t) = (s²)A + 2(st)B + (t²)C
    P(t) = (1²)A + 2(1 * 0)B + (0²)C
    P(t) = (1)A + 2(0)B + (0)C
    P(t) = (1)A
    P(t) = A

    다시 t가 1이라면 P는 항상 C와 같으며 다음과 같이 증명할 수 있다.

    P(t) = (s²)A + 2(st)B + (t²)C
    P(t) = (0²)A + 2(0 * 1)B + (1²)C
    P(t) = (0)A + 2(0)B + (1)C
    P(t) = (1)C
    P(t) = C

    이제 정리한 수식을 자바스크립트로 작성해보자. 함수명은 quadBezier로 짓고 2차 베지에 곡선임을 나타낸다.

    function quadBezier(A, B, C, t) {
      if (t === 0) {
        return A;
      }
      
      if (t === 1) {
        return C;
      }
      
      const s = 1 - t;
      
      // (s²)A + 2(st)B + (t²)C
      return Math.pow(s, 2) * A + 2 * (s * t) * B + Math.pow(t, 2) * C;
    }

    이렇게 작성한 함수는 다음과 같이 사용할 수 있다.

    interpolateBtn.addEventListener('click', function() {
      // Start the interpolation.
      raf(function(t) {
        const x = quadBezier(posA.x, posB.x, posC.x, t);
        const y = quadBezier(posA.y, posB.y, posC.y, t);
        ...
      }, 1000);
    });

    3차 베지에 곡선

    이제 3차 베지에 곡선(Cubic Bézier Curves)을 그려보자. 2차 베지에 곡선은 3개의 조절점을 이용해 그린 곡선을 말하듯 3차 베지에 곡선은 4개의 조절점을 이용해 그린 곡선을 말한다. 더 정확히는 두 개의 2차 베지에 곡선을 이용해 그린 곡선을 말한다.

    3차 베지에 곡선
    <그림 3. 3차 베지에 곡선>

    조절점 A, B, C를 이용해 그린 2차 베지에 곡선과 조절점 B, C, D를 이용해 그린 2차 베지에 곡선이 있다. 그리고 각 2차 베지에 곡선에서 보간되는 점 Q와 R이 있다. 이때 점 Q와 R를 이용해 또 다른 직선을 그릴 수 있고 이 직선에서 보간되는 점 P도 추가할 수 있다. 이제 점 Q와 R 그리고 P를 보간하면 P의 행적이 곡선을 만들어 낸다.

    이제 3차 베지에 곡선을 직접 그려보자. 3차 베지에 곡선을 그리기 위해서는 보간되는 점 Q와 R 그리고 P를 구해야 한다. 점 Q는 다음과 같이 구할 수 있다.

    E = (s * A) + (t * B)
    F = (s * B) + (t * C)
    Q = (s * E) + (t * F)

    다시 점 R은 다음과 같이 구할 수 있다.

    F = (s * B) + (t * C)
    G = (s * C) + (t * D)
    R = (s * F) + (t * G)

    이제 점 Q와 R을 이용해 점 P를 구할 수 있다.

    P = (s * Q) + (t * R)

    이제 2차 베지에 곡선을 그릴 때 작성한 blend, blender 함수를 활용해 3차 베지에 곡선을 그려보자. 간단하게 새로운 점을 추가한 후 위에서 설명한 것처럼 점 Q, R, P를 구해 보간하면 된다.

    아래 데모를 실행해 보자. 점 P가 보간되면서 그려진 곡선을 3차 베지에 곡선이라고 한다.

    See the Pen EmOPJr by Uyeong Ju (@uyeong) on CodePen.

    수식정리

    점 Q와 P는 각각 2차 베지에 곡선에서 구해지는 점이므로 2차 베지에 곡선의 수식으로 표현할 수 있다.

    Q(t) = (s²)A + 2(st)B + (t²)C
    R(t) = (s²)B + 2(st)C + (t²)D
    P(t) = sQ(t) + tR(t)

    위 수식을 조금 더 풀어서 다음과 같이 정리할 수 있다.

    P(t) = s((s²)A + 2(st)B + (t²)C) + t((s²)B + 2(st)C + (t²)D)
    P(t) = s³A + 2(s²t)B + st²C + s²tB + 2(st²)C + t³D
    P(t) = s³A + 3(s²t)B + 3(st²)C + t³D

    이제 정리한 수식을 자바스크립트로 작성해보자. 함수명은 cubicBezier로 짓고 3차 베지에 곡선임을 나타낸다.

    function cubicBezier(A, B, C, D, t) {
      if (t === 0) {
        return A;
      }
      
      if( t === 1) {
        return D;
      }
      
      const s = 1 - t;
      
      // P = s³A + 3(s²t)B + 3(st²)C + t³D  
      return (
        Math.pow(s, 3) * A + 
        3 * (Math.pow(s, 2) * t) * B + 
        3 * (s * Math.pow(t, 2)) * C + 
        Math.pow(t, 3) * D
      );
    }

    이렇게 작성한 함수는 다음과 같이 사용할 수 있다.

    interpolateBtn.addEventListener('click', function() {
      // Start the interpolation.
      raf(function(t) {
        const x = cubicBezier(posA.x, posB.x, posC.x, posD.x, t);
        const y = cubicBezier(posA.y, posB.y, posC.y, posD.y, t);
        ...
      }, 1000);
    });

    애니메이션

    실제 동작하는 데모에서 requestAnimationFrame()을 사용해 일정한 시간마다 점을 찍는 것으로 3차 베지에 곡선을 그리고 있다. 그런데 곡선의 각 점이 찍히는 구간 즉, 보폭이 일정하지 않다.

    3차 베지에 곡선의 보폭
    <그림 4. 3차 베지에 곡선의 보폭>

    이러한 특징을 애니메이션 처리에 활용할 수 있다. 보폭이 크면 시간이 빠르게 흐르고 보폭이 작으면 시간이 천천히 흐르도록 표현하여 객체의 움직임에 역동성을 부여할 수 있다.

    Easing Functions 치트시트
    <그림 5. Easing Functions 치트시트>

    이제 CSS의 transition-timing-functioncubic-bezier(Bx, By, Cx, Cy)가 의미하는 바가 무엇인지 또 어떻게 객체의 움직임에 활력을 불어넣을 수 있게 되는지도 이해할 수 있을 것이다.

    끝으로

    여기까지 베지에 곡선에 대한 연재 글을 모두 마친다. 처음엔 필자 역시 단순 호기심으로 시작했지만, 점점 원리를 알아가는 과정에서 상당한 재미를 느꼈다. 연재 글을 읽는 독자분들 역시 재미있는 경험이었길 바란다.

    참고

  • 베지에 곡선과 관련된 수학적 증명 방법과 알고리즘은 1959년 프랑스의 자동차 업체 시트로엥(citroen)에서 근무하던 물리학자이자 수학자인 파울 드 카스텔조(Paul de Casteljau)가 최초 고안했다. 다른 말로 카스텔조 곡선(Casteljau curve)이라고 부른다.

    하지만 시트로엥의 정책으로 인해 카스텔조가 얻은 성과가 1974년까지 발표되지 못했고 1962년에 프랑스 자동차 회사 르노(Renault)에서 근무하던 엔지니어 피에르 베지에(Pierre Bézier)가 자동차를 디자인하는 과정에서 이 곡선을 독자적으로 개발해 사용하면서 그의 이름으로 널리 알려지게 된다.

    정의

    베지에 곡선은 간단히 말해 복수의 조절점(Control point)을 이용해 매끄러운 곡선을 그릴 수 있는 가장 일반적인 매개 변수 곡선(Parametric curve) 이다. 매개 변수 곡선이란 매개 변수를 사용해 함수를 일반화하여 곡선을 그려내는 방법을 말한다.

    베지에 곡선의 조절점과 가이드 포인트
    <그림 1. 베지에 곡선의 조절점과 가이드 포인트>

    조절점이란 곡선의 모양을 결정하는 데 사용되는 점의 집합 또는 구성원을 뜻하며 가이드 포인트는 곡선의 모양을 변경시킬 수 있는 조절 가능한 점을 뜻한다. 그림 1의 점을 왼쪽부터 차례대로 P0, P1, P2, P3라고 할 때 보통 P0와 P3는 고정해두고 P1과 P2를 조절해 곡선을 변형한다. 이때 이 P1과 P2를 가이드 포인트라 한다.

    베지에 곡선엔 차수가 붙는데 이 차수는 조절점의 개수에 따라 정해진다. 간단히 말해 N 개의 조절점으로 그려진 곡선을 N - 1차 베지에 곡선이라 한다. 예를 들어 그림 1은 조절점이 4개이므로 4 - 1 즉, 3차 베지에 곡선이다.

    베지에 곡선이 그려지는 개괄적인 원리는 이전에 포스팅했던 「중학생도 알 수 있는 베지에 곡선(Bézier Curves)」을 참고하자. GIF 애니메이션을 이용해 이해하기 쉽게 설명돼 있다.

    이번 편에서는 블렌딩(Blending)과 보간(Interpolcation)을 소개한다. 이 지식만 습득하면 나머지 n 차 베지에 곡선은 자연스럽게 이해할 수 있다.

    에버리징과 블렌딩

    점 A와 점 B의 중앙
    <그림 2. 점 A와 점 B의 중앙>

    자, 위 그림 2와 같이 서로 떨어져 있는 점 A와 점 B가 있다고 해보자. 이때 점 P를 이 두 점 사이의 평균 즉, 선분의 중앙에 두고 싶다면 어떻게 해야 할까?

    P = (A + B) / 2

    위처럼 간단히 평균을 구해 중앙에 둘 수 있다. 이 수식을 조금 다르게 전개해보자.

    P = (A + B) / 2
      = (A + B) * ½
      = ½A + ½B
      = .5A + .5B
      = (.5 * A) + (.5 * B)

    위 수식을 이용한 연산을 블렌딩(Blending)이라고 표현한다. 지정된 각각의 비율에 맞춰 적절히 혼합하는 것이다. 자, 이제 같은 값이 아닌 가중치(Weights)를 줘서 블렌딩해보자.

    점 A와 점 B의 블렌딩 비율 조절
    <그림 3. 점 A와 점 B의 블렌딩 비율 조절>
    P = (.35 * A) + (.65 * B)

    이번엔 A는 0.35(35%), B는 0.65(65%)로 비율을 조정해 블렌딩했다. 이때 두 비율의 합은 당연하겠지만 1(100%)이 돼야 한다. 이것은 반드시 지켜져야 할 필수 조건이다. 이어서 비율 값을 다음과 같이 일반화해보자.

    P = (s * A) + (t * B)

    s는 A의 비율을, t는 B의 비율을 나타낸다. 만약 s가 높으면 t는 낮아지고 반대로 t가 높으면 s는 낮아질 것이다. 잠깐, s와 t는 서로 영향을 주며 두 수의 합은 항상 1이 돼야 한다. 그렇다면 s는 1 - t와 같다고 할 수 있다.

    P = ((1-t) * A) + (t * B)

    이제 변수 t 하나만으로 비율을 조정해 블렌딩할 수 있다. 이 수식은 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = (1 - t) * A + t * B
      = A - tA + tB
      = A + t(-A + B)
      = A + t(B - A)

    이 글에서는 수식 P = (s * A) + (t * B)를 사용해 설명을 이어가겠다. 다시 한번 말하지만 s = 1 - t다. 여기에 몇 가지 규칙이 추가된다. 만약 변수 t가 0이라면 P는 항상 A와 같으므로 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = ((1 - 0) * A) + (0 * B)
      = (1 * A) + (0 * B)
      = A

    또 변수 t가 1이라면 P는 항상 B와 같으므로 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = ((1 - 1) * A) + (1 * B)
      = (0 * A) + (1 * B)
      = A + B - A
      = B

    이제 수식과 몇 가지 규칙을 참고하여 블렌딩하는 자바스크립트 함수를 작성해보자.

    const A = 20;
    const B = 198;
    
    function blender(A, B, t) {
        if (t === 0) {
            return A;
        }
    
        if (t === 1) {
            return B;
        }
    
        return ((1 - t) * A) + (t * B); // or A + t * (B - A)
    }
    
    const blend = blender.bind(null, A, B);
    
    console.log(blend(.0)); // 20
    console.log(blend(.2)); // 55.6
    console.log(blend(.4)); // 91.2
    console.log(blend(.6)); // 126.8
    console.log(blend(.8)); // 162.4
    console.log(blend(1));  // 198

    See the Pen qRBdvb by Uyeong Ju (@uyeong) on CodePen.

    복합 데이터 블렌딩

    이번에는 「에버리징과 블렌딩」 절에서 이해한 수식을 이용해 복합 데이터(Compound data)를 블렌딩해보자. 블렌딩 수식만 잘 활용하면 2차원 또는 3차원 같은 복합적인 데이터도 쉽게 블렌딩할 수 있다.

    Px = (s * Ax) + (t * Bx)
    Py = (s * Ay) + (t * By)
    Pz = (s * Az) + (t * Bz)

    3차원인 경우 위처럼 개별적으로 블렌딩한 후 연산된 값을 조합해 사용한다.

    2차원에서 점 A와 점 B의 중앙
    <그림 4. 2차원에서 점 A와 점 B의 중앙>

    전 절에서는 점 A와 점 B가 동일 선상에 놓여있는 1차원적 상황이었지만 이번엔 그림 4 처럼 2차원 상황에서 P를 구해보자. 2차원에서는 x와 y 좌표로 점의 위치가 결정된다. 따라서 x와 y를 개별적으로 블렌딩한 후 구해진 값을 조합하면 P의 위치를 구할 수 있다.

    2차원에서 점 P의 위치 구하기
    <그림 5. 2차원에서 점 P의 위치 구하기>
    Px = (s * Ax) + (t * Bx)
    Py = (s * Ay) + (t * By)
    P = {Px, Py}

    위 수식은 자바스크립트 코드로 다음과 같이 표현할 수 있다.

    const Ax = 20;
    const Ay = 144;
    const Bx = 198;
    const By = 72;
    
    const blendX = blender.bind(null, Ax, Bx);
    const blendY = blender.bind(null, Ay, By);
    
    // t = .5
    // P = { blendX(t), blendY(t) }

    See the Pen EZxVVV by Uyeong Ju (@uyeong) on CodePen.

    보간

    마지막으로 보간(Interpolation)의 개념을 짤막하게 소개한다. 러핑(Lerping)이라고도 부르는 보간은 시간이 지남에 따라 블랜드 가중치를 변경하여 블렌딩을 수행하는 것을 말한다. 시간은 멈춰있지 않고 지속해서 흐르는 특징이 있으며 블랜드 가중치는 이 흐르는 시간에 의해 결정된다.

    쉽게 말해 특정 값으로 블렌딩하는 게 아닌 지속해서 흐르는 시간에 근거해 블렌딩 하는 것이다. 이러한 과정은 대개 update()로 표현된다. 아래 자바스크립트 코드를 보자.

    function interpolator(Ax, Bx, Ay, By, duration) {
      return function(update) {
      	 // x, y 블랜드 함수 준비
        const blendX = blender.bind(null, Ax, Bx);
        const blendY = blender.bind(null, Ay, By);
        
        ... 중략 ...
        
        function step(timestamp) {
          
          ... 중략 ...
          
          // 현재 시간에 해당하는 진행 값 즉, t 값 연산
          const pastTime = timestamp - startTime;
          let progress = pastTime / duration;
    
    	   // t 값을 이용해 블렌딩하고 update 콜백 함수 호출
          update(blendX(progress), blendY(progress)); // Blending...
    
          ... 중략 ...
          
          requestAnimationFrame(step);
        }
        
        requestAnimationFrame(step);
      }
    }
    
    const interpolate = interpolator(Ax, Bx, Ay, By, 1000);
    
    interpolate(function(nx, ny) {
    	// 1초간 Interpolating.
    	// P = {nx, ny}
    });

    우선 requestAnimationFrame()를 사용해 지정한 시간 만큼 흐르게 한다. 그리고 현재 시각에 해당하는 진행 값 즉, t를 구한 후 이 값을 근거해 블렌딩한다.

    See the Pen dNbgQp by Uyeong Ju (@uyeong) on CodePen.

    보간은 페이드인, 아웃 같은 애니메이션 처리나 3D 게임에서의 객체 움직임 그리고 오디오 크로스페이드 처리 등에 유용하게 사용된다.

    여기까지 2편을 마치고, 다음 편에서 이 지식을 바탕으로 1, 2차 베지에 곡선을 소개하겠다.

    참고

  • 퇴근 후 여느 때와 마찬가지로 PlayStation 4 전원에 손이 향하는 순간, 오랫동안 관리하지 못한 react-preloader-icon 컴포넌트가 돌연 떠올랐다. react-preloader-icon은 SVG Loaders의 아이콘을 React 컴포넌트로 옮기고 있는 작은 사이즈의 프로젝트다.

    SVG Loaders에 디자인된 아이콘은 12개밖에 안되지만 게으른 나머지 아직 2개밖에 옮기지 못했다. 그래서 게임은 잠시 제쳐두고 Spinning과 Puff 아이콘을 한번 React 컴포넌트로 옮겨보기로 했다.

    Spinning과 Puff
    <그림 1. Spinning과 Puff>

    SVG와 3차 베지에 곡선

    Spinning 아이콘은 손쉽게 옮겼지만, Puff 아이콘은 좀 달랐다. 이전에 옮긴 아이콘 모두 애니메이션이 선형적(linear) 이기 때문에 신경 쓸 게 없었다. 하지만 Puff 아이콘의 SVG는 조금 다른 방식으로 만들어져 있다.

    <svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
        <g fill="none" fill-rule="evenodd" stroke-width="2">
            <circle cx="22" cy="22" r="1">
                <animate attributeName="r"
                    begin="0s" dur="1.8s"
                    calcMode="spline"
                    values="1; 20"
                    keyTimes="0; 1"
                    keySplines="0.165, 0.84, 0.44, 1"
                    repeatCount="indefinite" />
                    ... 중략 ...
            </circle>
        </g>
    </svg>

    위 코드에서 animate 엘리먼트를 살펴보자. 이 엘리먼트는 calcMode, values, keyTimes, keySplines 속성을 갖고 있다. 일단 이 속성에 관한 지식이 전혀 없기 때문에 우선 SMIL 스펙 문서를 살펴봤다.

    스펙 문서를 통해 calcMode값의 타이밍을 제어할 함수를 선택하는 속성임을 알 수 있다. 속성 값으로 “discrete”, “linear”, “paced”, “spline” 중 하나를 지정할 수 있다.

    위 코드의 calcMode 속성에는 “spline”이 지정돼 있는데 “spline”으로 지정하면 values, keyTimes, keySplines 속성과 함께 “3차 베지에 곡선“으로 값을 제어할 수 있다.

    잠깐, “3차 베지에 곡선”이라고? 다들 CSS로 애니메이션을 처리할 때 cubic-bezier라는 애니메이션 타이밍 함수를 한 번쯤 사용해본 적이 있을 것이다. cubic-bezier… 그렇다. 3차 베지에라는 뜻이다.

    CSS와 3차 베지에 곡선

    cubic-bezier 애니메이션 타이밍 함수는 x, y, x`, y` 즉, 4개의 값을 인자로 전달받아 에니메이션의 타이밍을 조절한다. x, y는 첫 번째 가이드 포인트(Guide Point)의 좌표, x`, y`은 두 번째 가이드 포인트의 좌표다. 가이드 포인트란 곡선의 형태에 영향을 주는 조절 가능한 점을 뜻한다.

    3차 베지에 곡선의 가이드 포인트
    <그림 2. 3차 베지에 곡선의 가이드 포인트>

    3차 베지에 곡선이란 이 두 가이드 포인트의 위치에 따라 그려지는 곡선을 말한다. cubic-bezier는 이 곡선을 이용해 에니메이션의 타이밍을 조절한다(이 속성은 cubic-bezier.com, CSS3 Bezier Curve Tester, desmos에서 간단히 테스트해 볼 수 있다).

    cubic-bezier를 이용해 선언한 Easing 함수 셋
    <그림 3. cubic-bezier를 이용해 선언한 Easing 함수 셋>

    우리가 알고 있는 easeInSine, easeInQuad 등과 같은 Easing 함수(참고)는 모두 이 베지에 곡선을 이용해 미리 만든 일종의 셋이다.

    한걸음 더…

    자, 본래 이야기로 다시 돌아와서…

    SVG의 calcMode="spline"의 의미를 살펴봤으니 bezier-easing같은 npm 모듈을 사용해 Puff 아이콘을 React 컴포넌트로 옮기면 된다. 하지만 커밋을 완료하고 따듯한 이불 속에서 편안한 마음으로 잠자리에 들기엔 모르는 것이 너무나 많다.

    왜 베지에 곡선이라 부를까? 무엇을 근거로 1차, 2차, 3차라고 나눌까? 또, 곡선이 그려지는 원리와 공식은 무엇일까? 몇 가지 물음이 잠 못 이루게 했고 결국 이 주제로 글을 작성하게 됐다.

    그럼 다음 편부터 본격적으로 베지에 곡선에 관해서 연재하도록 하겠다.

    참고

  • 읽기전에...

    이 문서는 일본어 「中学生でもわかるベジェ曲線」를 번역한 글입니다.

    “베지에 곡선”을 이용해 렌더링하게 되면 꽤 재미있고 편안하게 그림을 그릴 수 있다. 오늘은 이를 사용하는 누구라도 그 원리를 이해할 수 있도록 설명하고자 한다.

    “베지에 곡선”이라는 것은 매끄러운 곡선을 그리기 위한 것이지만 설명은 우선 단순한 직선부터 시작하겠다. 아래 그림 1 처럼 직선에서의 점의 움직임이 모든 “베지에 곡선”의 기본이 되기 때문이다.

    1차 베지에 곡선
    <그림 1. 1차 베지에 곡선>

    하나의 직선이 있고 그 위를 점 M이 일정 속도로 이동하고 있다. 이 점 M의 궤적은 당연하지만 단순한 직선으로 그려진다. 좋다. t선분 위를 비율적으로 얼마나 나아갔는지를 나타내는 수치다.

    여기에 선을 하나 더 추가하고 그 위에 M처럼 이동하는 점을 놓아보자. 그리고 원래의 점 MM0로, 새로운 점을 M1으로 부르자. M0M1이 움직이는 규칙은 이전과 같다. M1이라는 점이 하나 더 늘었다 하더라도 특별히 복잡해질 것은 없다.

    2차 베지에 곡선
    <그림 2. 2차 베지에 곡선>

    자, 여기에서 M0M1을 잇는 선을 하나 더 그을 수 있다. 그 선은 M0M1이 이동하면 자연스럽게 함께 움직이게 된다. 이제 그 선에 주목해보자. 그 선 위에 M0M1처럼 일정 속도로 이동하는 점을 놓을 수 있다. 그 점을 B라고 하자. 그리고 점 B가 그리는 궤적을 살펴보자. 그렇다. 점 B가 그리는 궤적을 2차 베지에 곡선(Quadratic Bezier Curve)이라고 한다.

    P0, P1 등 을 조절점(Control Point)이라고 한다. 이제 조절점을 하나 더 늘린 “베지에 곡선”을 상상해보자.

    아래 그림 3을 보면 새로운 점 P3가 추가돼 있고 P2P3를 선으로 잇고 있다. 그리고 M0M1과 같이 그 선 위를 이동하는 점을 생각해 볼 수 있다. 그 점의 이름은 M2라 하자.

    3차 베지에 곡선
    <그림 3. 3차 베지에 곡선>

    자, 2차 베이제 곡선에서는 P0, P1, P2의 조합으로 점 B의 위치를 정할 수 있었다. 그렇다면 마찬가지로 P1, P2, P3의 조합으로도 비슷하게 점의 위치를 정할 수 있을 것이다.

    이전에 부르던 점 BB0라고 부르기로 하고 P1, P2, P3의 조합으로 정해지는 새로운 점을 B1이라고 부르기로 하자. 그렇게 하면 이전과 마찬가지로 점 B0와 점 B1을 잇는 선과 그 선 위를 일정한 속도로 움직이는 점을 다시 생각해 볼 수 있는데 이때 그 점이 그리는 궤적을 3차 베지에 곡선(Cubic Bezier Curve)이라고 한다.

    여기에서 끝이 아니다. 조절점을 한 개 더 늘려 P0, P1, P2, P3 조합과 P1, P2, P3, P4 조합으로 결정되는 선과 그 선을 일정 속도로 이동하는 점 B2를 추가하고 B1B2를 잇는 선을 놓고 그 위를 지나가는 또 다른 점을 추가한 후 그 점이 그리는 궤적을 살펴볼 수 있다. 이런 식으로 곡선은 얼마든지 복잡하고 다양하게 만들 수 있다. P0부터 P4로 정해지는 곡선은 4차 베지에 곡선(Quartic Bezier Curve)이라고 부른다. 하지만 조절점을 5개 이상 늘려도 실용적인 측면에서 특별한 이점이 없으므로 일반적으로 3차 베지에 곡선까지 사용된다.

    여기까지 간단하게 “베지에 곡선”에 관해 이야기했다. 한마디로 “베지에 곡선”이란 선분 위를 일정 속도로 움직이는 점과 그러한 점과 점을 잇는 또 다른 선분, 그리고 그 위를 일정 속도로 이동하는 또 다른 점 등을 조합해 최종적으로 특정 점이 그리는 궤적을 이용해 곡선을 그려내는 방법을 뜻한다. 이해하는 데 도움이 됐으리라 기대한다.

  • 얼마전에 기능 개발을 하다가 애니메이션을 다룰 일이 생겼다. 처음엔 CSS를 이용했지만, IE8을 지원하고자 “raf“라는 폴리필 라이브러리를 이용해 requestAnimationFrame(이하 raf)으로 개발했다. raf의 대략적인 형태는 다음과 같다(API의 자세한 설명은 MDN의 window.requestAnimationFrame()을 참고).

    var start = null;
    
    function step(timestamp) {
      if (!start) {
        start = timestamp;
      }
    
      var progress = timestamp - start;
    
      // Use progress to do something.
    
      if (progress < 1500) {
        window.requestAnimationFrame(step);
      }
    }
    
    window.requestAnimationFrame(step);

    코드를 보면 알겠지만 그렇게 직관적인 편은 아니다. raf를 쓸 때마다 이런 식으로 코드를 작성하긴 싫었다. 그래서 사용하기 편하게 raf를 랩핑한 객체 하나를 만들었다(여기서 시작한 작은 프로젝트가 있다 - StepperJS).

    class Stepper {
      start(options) {
        const {
          duration = 0,
          easing = linear, // is easing function.
          step = () => {}
        } = options;
    
        let startTime = 0;
    
        const stepping = (timestamp) => {
          if (!startTime) {
            startTime = timestamp;
          }
    
          const pastTime = timestamp - startTime;
          const progress = pastTime / duration;
    
          if (pastTime >= duration) {
            step(1);
            return;
          }
    
          step(easing(progress));
    
          window.requestAnimationFrame(stepping);
        };
    
        window.requestAnimationFrame(stepping);
      }
    }

    이렇게 작성한 Stepper 객체는 다음과 같이 사용할 수 있다.

    const stepper = new Stepper();
    
    stepper.start({
      duration: 1500,
      step: (n) => {
        element.style.left = `${150 * n}px`
      }
    });

    See the Pen yVpNLo by Uyeong Ju (@uyeong) on CodePen.

    개인적으로 생각했을 때 raf를 곧바로 사용하는 것보다 더 직관적이고 편해 보인다(물론 다른 의견을 가진 사람이 있을 수도 있다). 이제 Stepper 객체를 어떻게 테스트할 수 있을지 살펴보자.

    Stepper는 지정한 durationeasing에 따라 현재 시점에 해당하는 n 즉, progress 값을 콜백 함수에 전달하는 단순한 역할을 담당한다. 그렇다면 다음과 같이 테스트 케이스를 작성할 수 있을 것 같다.

    should call step callback with the current progress by duration and easing.

    음, 확실히 유닛 테스트로써는 손색없지만 필자는 사용자 관점에서 서술하는 걸 좋아하니 이렇게 고쳐보자.

    The user should be able to know the current progress through the start method of Stepper

    이를 어떻게 검증할 수 있을까? 필요한 값을 설정하고 start 메서드를 호출한 후 특정 시간으로 옮긴(tick) 다음 “n”이 기대하는 값과 일치하는지 확인하면 될 것 같다. 한번 테스트 코드로 옮겨보자.

    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
      // Given
      const stepper = new Stepper();
      const duration = 300;
      const easing = linear;
      let progress;
      
      // When
      stepper.start({
        duration,
        easing,
        step: (n) => progress = n
      });
      
      // Then
      assert(progress === ???);
    });

    다른 조건들은 어려울 게 없지만, 특정 시간으로 옮기는 행위는 그렇지 않다. 함수를 호출하는 순간 시간은 흘러 버리므로 특정 시간에 해당하는 progress 값을 비교할 수 없다.

    이처럼 테스트 환경에서 시간을 조작하고 싶을 때 사용할 수 있는 테스트 더블 라이브러리가 있다. 바로 “sinon“이다. sinon의 FakeTimersetTimeoutDate 객체 등을 덮어써서 동기적으로 시간을 조작할 수 있는 수단을 제공한다. 이것을 사용해보자.

    const clock = sinon.useFakeTimers();
    
    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
        // Given
        const stepper = new Stepper();
        const duration = 300;
        const easing = linear;
        const step = sinon.spy();
    
        // When
        stepper.start({
            duration,
            easing,
            step
        });
    
        clock.tick(0);
        clock.tick(250);
    
        // Then
        assert(step.args[1][0].toFixed(2) === linear(250 / 300).toFixed(2));
    });

    See the Pen PbEaMp by Uyeong Ju (@uyeong) on CodePen.

    당연한 얘기겠지만 TypeError가 발생한다. raf는 시간이 아닌 repaint 시점을 기준으로 호출되며 독자적으로 타임스템프를 계산해 콜백에 전달하므로 sinon의 FakeTimer로 조작할 수 없다. 따라서 동기적으로 호출한 args 프로퍼티에 쌓인 값이 없으므로 에러가 발생하는 것이다.

    그렇다면 어떻게 해야 할까. 고맙게도 누군가 raf를 Stub한 “raf-stub“을 개발해 배포해놨다. 이 Stub을 사용해 테스트를 다시 작성해보자.

    const stub = createStub();
    
    sinon.stub(window, 'requestAnimationFrame', stub.add);
    
    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
        // Given
        const stepper = new Stepper();
        const duration = 300;
        const easing = linear;
        const step = sinon.spy();
    
        // When
        stepper.start({
            duration,
            easing,
            step
        });
    
        stub.step(1, 0);
        stub.step(1, 250);
    
        // Then
        assert(step.args[1][0].toFixed(2) === linear(250 / 300).toFixed(2));
    });

    See the Pen LbeJZz by Uyeong Ju (@uyeong) on CodePen.

    테스트가 통과한다. raf를 사용해 작성한 코드를 테스트하려고 할 때 다소 막막할 수 있다. 하지만 sinon과 Stub을 적절히 사용한다면 손쉽게 테스트할 수 있다.

    여기까지 raf를 테스트하는 방법을 소개했다. 비슷한 고민을 하는 사람에게 작은 팁으로나마 도움이 되길 바라며 예제 코드는 UYEONG/request-animation-frame-test에 올려놓았으니 참고하길 바란다.

  • 특정 목록 페이지에 접근할 때 사용자가 마지막으로 클릭했던 아이템이 보이도록 자동 스크롤링 해달라는 요청이 들어왔다. 개인적으로 onload 타임에 특정 목록으로 자동 스크롤링하는 기능은 지양해 왔기 때문에 웹이 가진 한계점을 설명하면서 간단히 프로토타이핑해본 후 판단하자고 의견을 냈다.

    그런데 프로토타이핑하던 도중 예전엔 경험하지 못했던 이상한 현상이 발견됐다. 아래는 사용자가 마지막으로 클릭한 아이템이 “product30” 이라고 보고 해당하는 엘리먼트의 위치로 스크롤링하는 간단한 코드다.

    window.addEventListener('load', () => {
        console.log('onloaded');
    
        const product30 = document.getElementsByClassName('product-item')[29];
        const top = product30.getBoundingClientRect().top;
    
        console.log(`scroll to ${top}(product30)`);
    
        window.scrollTo(0, top);
    });

    생각한 대로라면 지정한 위치로 스크롤링 되어야 하지만 동작하지 않았다. 아래 “그림 1”을 보면 분명히 9970.875 즉, “product30”의 위치로 스크롤링을 지시했음에도 문서는 여전히 최상단에 있음을 알 수 있다.

    동작하지 않는 scrollTo()
    <그림 1. 동작하지 않는 scrollTo()>

    간단한 코드로 재현하긴 힘들지만, 실제 서비스 페이지에서는 setTimeout(() => ..., 0)으로 지정해도 금세 원래의 위치로 크롬이 재보정한다.

    이렇게 동작하는 이유는 크롬이 사용자가 보고 있던 스크롤 위치를 저장하고 있다가 해당 페이지에 다시 접근하면 브라우저 레벨에서 자동으로 스크롤링하기 때문인데 어떻게 해야 이를 회피할 수 있을지 고민됐다. 이때 찬욱님에게 여쭤보니 아래와 같은 코드로 이를 무력화할 수 있다는 답변을 받았다.

    if ('scrollRestoration' in history) {
        // Back off, browser, I got this...
        history.scrollRestoration = 'manual';
    }

    위 코드를 삽입하고 다시 페이지에 접근하니 정확히 의도한 대로 동작했다.

    제대로 동작하는 scrollTo()
    <그림 2. 제대로 동작하는 scrollTo()>

    프로토타이핑은 무사히 완료했고 의사 결정하는데 큰 역할을 했다. 하지만 이렇게 이야기를 마무리하기엔 찜찜하다. scrollRestoration 속성을 좀 이해하고 넘어가야 할 것 같다.

    필자는 이번에 scrollRestoration이라는 속성을 처음 봤다. 그도 그럴 것이 scrollRestoration은 실험적(Experimental) API 에다가 아직 MDN에 페이지도 없고(참고), 2015년 10월에 배포된 크롬 46에서야 추가된 API다.

    scrollRestoration은 히스토리 네비게이션의 스크롤 복원 기능을 명시적으로 지정할 수 있는 속성이다. 속성값은 ‘auto’와 ‘manual’이 전부. SPA 환경과 관련이 있어 보인다. 이 글을 읽는 사람 중 대다수는 목록 페이지에서 특정 아이템을 클릭해 엔드 페이지로 갔다가 다시 되돌아오면 스크롤을 처음부터 다시 해야 하는 경험을 한 적 있을 것 같다. 그래서 스크롤 포지션 값을 LocalStorage에 저장했다가 다시 목록 페이지에 접근하면 억지로 스크롤 위치를 잡아주는 기능을 구현한다.

    대표적으로 네이버 주식 모바일 웹이 그런 방식으로 구현돼 있는데 토론 목록 페이지에서 토론 페이지로 접근하면 scrollY 값을 기억해 뒀다가, 뒤로 가기 하여 토론 목록 페이지로 되돌아오면 이 값을 이용해 스크롤 위치를 조절한다.

    보던 목록으로 스크롤링하기 위해 scrollY 값을 기억한다
    <그림 3. 사용자가 보던 곳으로 보정하기 위해 scrollY 값 저장>

    위와 같은 구현 방식은 사용자가 정확히 어느 경로를 통해 목록 페이지로 접근하는지 알기 어려워 자칫 잘못된 경험을 제공(검색을 통해 접근했는데 스크롤 위치를 조절하는 등)하기도 하는데 scrollRestoration은 history navigation을 기반으로 동작하기 때문에 이러한 부분을 해소할 수 있을 것으로 보인다. 하지만 처음에 이야기한 것처럼 자동 스크롤링 기능이 오히려 특정 기능 구현에 방해가 될 수도 있는데 세심하게 개발자에게 조절할 수 있도록 속성을 열어줘서 고마울 따름.

    테스트 코드는 UYEONG/scroll-restoration-test에 올려놓았으니 참고하길 바란다.

    참고

  • 인생을 살다 보면 괴롭지만 꼭 해야만 하는 일을 만나게 된다. 프런트엔드개발자에겐 그런 일 중 하나가 바로 UI 테스트가 아닐까 싶은데, 이 고통스러운 일을 조금이나마 덜어줄 잘 만들어진 도구나 프레임워크를 찾지만 쉽지 않다. 처음엔 좋아 보여도 실제 테스트를 작성하다 보면 금세 그 도구가 가진 한계점을 만나게 된다. 그래서 그런지 다른 일보다도 더욱 도구에 의존하게 되고 개선된 또 다른 도구를 찾게 되는 것 같다.

    TestCafe는 자바스크립트 소식을 매주 정리해 공유하는 사이트인 JSer.info2016년 10월 24일 자 소식을 통해 알게 됐다. 해당 문서에 링크된 TestCafe로 브라우저 자동 테스트(일본어)를 읽어보았는데 생각보다 느낌이 좋아서 한번 리뷰해보자는 결론을 내렸다.

    TestCafe 소개

    TestCafe는 DevExpress가 개발한 E2E 테스트 프레임워크다. InfoQ에 TestCafe와 관련된 인터뷰 글(
    TestCafe with Smart Script Injection, Traffic and Markup Analysis Tools
    )이 있으니 관심 있는 사람은 참고하길 바란다. 같은 이름의 웹 서비스 및 클라이언트 앱도 서비스 중인데 이 서비스는 셀레니움 IDE 처럼 GUI로 조작하고 행위를 기록하여 재생할 수 있다.

    TestCafe는 webdriber.io나이트왓치와는 다르게 테스트 관련 스크립트를 주입해 동작하는 셀레니움 RC(Selenium RC)와 흡사한 방식으로 개발됐다. 사실 셀레니움 RC가 가진 한계를 극복하고자 셀레니움 웹드라이버(Selenium WebDriver)를 개발했는데 다시 셀레니움 RC와 비슷한 구조로 테스트 프레임워크를 만들었다고 해서 “그렇다면 과거에 경험했던 한계를 그대로 답습하는 게 아닌가?”하고 조금 의아했다.

    TestCafe의 개발자 이반 니쿨린(Ivan Nikulin)Why not use Selenium?에서 그 이유를 밝혔는데 간단히 말해서 테스트 환경에 대한 복잡한 설정 없이 실행할 수 있고, 모바일 기기에서도 원격 접속해 테스트할 수 있는 도구를 만들고 싶어 했던 거 같다. 또, 웹드라이버의 호환성 문제를 회피하기 위한 목적도 있는 것 같다.

    셀레니움은 분명 훌륭한 도구지만 설정이 복잡하고 웹드라이버 자체의 버그로 인해 테스트 작성에 종종 걸림돌이 되는 경우가 있다. 또 테스트 코드 자체를 디버깅하기가 까다로워 복잡한 테스트 케이스를 작성하는데 어려운 면도 가지고 있다. 과거 셀레니움 RC 방식에 한계가 있어 셀레니움을 만들었지만 새로운 문제들이 나타났다. 이러한 상황에서 TestCafe의 지향점이 좋은 해결책이 될 수 있을까?

    좋은 인상

    필자는 유료 웹툰을 서비스하고 있는 레진(Lezhin)을 이용해 로그인 테스트를 작성해 봤다. 예제 코드는 저장소 UYEONG/demo-testcafe를 참고한다. 이번 절에서는 이 예제를 이용해 필자가 받은 몇 가지 좋은 인상을 소개하겠다.

    test('사용자는 GNB 메뉴에서 로그인할 수 있다.', async (t) => {
        // Given
        const popupAttendanceLogin = new PopupAttendanceLogin(t);
    
        if (await popupAttendanceLogin.exist()) {
            await popupAttendanceLogin.close();
        }
    
        await t
            .click('#main-menu-toggle')
            .typeText('#login-email', ACCOUNT.USER_NAME)
            .typeText('#login-password', ACCOUNT.PASSWORD);
    
        // When
        await t
            .click('form.login-form button[type=submit]')
            .wait(1000);
    
        // Then
        await t.click('#main-menu-toggle');
    
        const email = await getElement('sidenav-email');
    
        assert(email.visible);
        assert(email.innerText === ACCOUNT.USER_NAME);
    });

    위는 레진에서 GNB 메뉴를 이용해 로그인이 정상적으로 이뤄지는지 테스트하는 코드다. 그리고 이 코드는 await/async를 이용해 비동기적 절차를 동기적으로 표현하고 있다. TestCafe는 바벨(Babel)을 내장하고 있어 별도의 설정 없이 최신 사양을 이용할 수 있다. 최신 사양으로 코드를 작성하고자 할 때 복잡한 세팅을 해줘야 하는 기존의 테스트 프레임워크와는 다른 부분이다.

    TestCafe에서 디버깅하기
    <그림 1. TestCafe에서 디버깅하기>

    또, await/async 방식으로 테스트 코드를 작성하면 디버깅이 쉽다는 장점이 있는데 체이닝을 펼치기 쉬우므로 각 액션을 단계별로 관찰할 수 있다. 나이트왓치는 파이프라인 방식으로 디자인돼 있어 디버깅이 다소 까다롭다.

    그럼 이제 실행을 해보자. 해당 저장소를 클론하고 다음 명령어를 입력하면 바로 테스트할 수 있다.

    $ git clone [email protected]:UYEONG/demo-testcafe.git
    $ npm install
    $ npm run test

    npm scripts에 등록한 test 명령은 다음과 같다.

    $ testcafe chrome tests/

    뭔가 추가적인 설정이 없으니 오히려 불안하다. 하지만 그 안락함에 금방 익숙해진다. 이것저것 세팅해줘야 했던 셀레니움 기반 프레임워크(참고)와는 사뭇 다른 경험이다.

    로그인 테스트 실행 결과
    <그림 2. 로그인 테스트 실행 결과>

    그림 2는 로그인 테스트가 진행되는 모습이다. 이 테스트의 진행 절차는 다음과 같다.

    1. www.lezhin.com 페이지에 접근한다.
    2. 최초에 출력된 팝업이 있다면 닫는다.
    3. 우측 상단의 메뉴 버튼을 클릭한다.
    4. 이메일 / 패스워드를 입력하고 로그인 버튼을 선택한다.
    5. 페이지가 갱신되면 다시 우측 상단의 메뉴 버튼을 클릭한다.
    6. 로그인이 정상적으로 완료 됐는지 확인한다.

    특정 절차에서 다음 절차로 넘어가기 위해선 지연 시간(Delay time)이 필요하다. 예를 들어 최초 페이지에 접근할 때는 콘텐츠가 모두 출력되는 시점을 기다려야 하고 팝업을 닫을 때는 애니메이션(FadeOut)이 종료되는 시간을 기다려야 한다. 나이트왓치에서는 이런 지연 시간을 직접 명시해줘야 한다.

    // 페이지에 최초 접근 시 body 엘리먼트가 보일 때까지 5000ms 기다린다.
    this._header
        .navigate()
        .waitForElementVisible('body', 5000);
        
    // 팝업을 닫을때 애니메이션 시간을 고려해 500ms 기다린다.
    this.click('@close');
    this.api.pause(500);

    하지만 TestCafe를 이용할 땐 지연 시간을 직접 입력할 일이 상대적으로 적다. TestCafe는 지연 시간을 직접 계산하고 관리한다. 실제로 위 로그인 테스트 코드를 보면 지연 시간을 명시한 지점은 로그인 버튼을 클릭한 시점 즉, 폼을 서브밋하고 갱신되기를 기다리는 딱 한 곳뿐이다.

    await t
        .click('form.login-form button[type=submit]')
        .wait(1000);

    지연 시간이라고 해도 거의 대충 시간을 짐작해 입력하는 일에 불과하다. 물론 비기능적 요구사항도 테스트에 포함돼야 하지만 애니메이션 종료 시점까지 일일이 명시해야 한다는 것은 분명 귀찮은 일이다.

    그리고 이벤트 지점을 커서로 표현해주거나 실제 타이핑을 하는 느낌을 살려 텍스트를 입력하는 부분도 인상적이다. 셀레니움 기반 테스트 프레임워크는 이런 자연스러운 느낌이 상대적으로 적다.

    TestCafe의 에러 리포팅
    <그림 3. TestCafe의 에러 리포팅>

    마지막으로 에러 리포팅도 상당히 깔끔한 편인데 어느 지점에서 어떠한 에러가 낫는지 알기 쉽게 출력해준다. 그림 3을 보면 24번째 행의 코드에 문제가 있음을 쉽게 알 수 있다.

    구구절절 설명했지만 TestCafe를 리뷰하면서 좋은 인상을 받은 점을 간단히 정리하면 다음과 같다.

    • 바벨을 내장하고 있어서 특별한 설정 없이 ES6+ 사양을 사용할 수 있다.
    • 테스트 코드 디버깅이 상대적으로 쉽다.
    • 특별한 설정 없이 커멘드 라인 명령으로 바로 테스트할 수 있다.
    • 특정 조작에 대한 지연 시간을 자동으로 관리한다.
    • 테스트 실패 및 에러 리포팅이 깔끔한 편이다.

    아쉬운 점

    분명 기존의 E2E 테스트 프레임워크보다 몇 가지 좋은 인상을 가지고 있는건 분명하다. 하지만 아쉬운 점도 있다. 일단 다양한 상황을 테스트하기엔 액션 셋과 API가 부족하다.

    TestCafe와 그 외 프레임워크의 API 목록
    <그림 4. TestCafe와 그 외 프레임워크의 API 목록>

    왼쪽 부터 차례대로 TestCafe, 나이트왓치, webdriver.io 가 제공하고 있는 액션 및 API 목록이다. 기본적인 액션은 제공하지만, 모바일에 특화된 액션이나 스크립트 권한 밖의 액션 등은 이용하기 힘들다. 시간이 지나면서 제공될 수 있는 API도 있지만 TestCafe가 가지고 있는 구조적 한계로 인해 아예 불가능한 API도 있다.

    또, E2E 테스트를 할 때 좋은 패턴들이 있는데 그중 하나가 PageObject다. 테스트에 필요한 반복적인 행위나 엘리먼트 셀렉터 등을 밖으로 노출 시키지 않고 페이지 단위(혹은 컴포넌트 단위)로 추상화해 제공할 수 있다(참고). PageObject는 테스트 코드의 가독성이나 유지 보수 측면에서 훌륭한 패턴이지만 TestCafe에서는 제공하지 않는다.

    // page-objects/popup-attendance-login.js
    import {Selector} from 'testcafe';
     
    const querySelector = Selector(q => document.querySelector(q));
     
    class PopupAttendanceLogin {
        elements = {
            wrapper: '#popup-attendance-login',
            closeBtn: '#popup-attendance-login .attlogin__close'
        };
    
        constructor(testController) {
            this.t = testController;
        }
    
        async exist() {
            const wrapper = await querySelector(this.elements.wrapper);
            return wrapper.visible;
        }
    
        async close() {
            const closeBtn = await querySelector(this.elements.closeBtn);
            await this.t.click(closeBtn);
        }
    }
     
    export default PopupAttendanceLogin;
    
    // tests/signin-test.js
    import PopupAttendanceLogin from '../page-objects/popup-attendance-login';
    
    test('사용자는 GNB 메뉴에서 로그인할 수 있다.', async (t) => {
        // Given
        const popupAttendanceLogin = new PopupAttendanceLogin(t);
    
        if (await popupAttendanceLogin.exist()) {
            await popupAttendanceLogin.close();
        }
        // ... 생략 ...

    그래서 필자는 위 코드처럼 직접 PageObject와 비슷한 객체를 직접 만들고 테스트 코드를 작성했다. 만약 프레임워크 자체에서 이 개념을 제공한다면 조금 더 편리하게 코드를 작성할 수 있을 것 같다.

    끝으로

    여기까지 TestCafe를 소개하고 필자가 느낀 좋은 인상과 아쉬운 점을 함께 이야기했다. 이 도구가 우리의 UI 테스트 환경의 답이 돼 줄 것이라 생각하지 않는다. 분명 실제로 테스트를 작성하기 시작하면 온갖 버그와 미흡한 점을 만나게 될 것이다. 하지만 아직 시작된 지 얼마 안 된 프로젝트라는 점을 미루어 볼 때 차차 개선될 것이라고 긍정적으로 생각할 수 있다.

    중요한 건 그들이 어떤 문제를 해결하고 싶어 하고 어디에 지향점을 두고 있느냐다. 그것이 내 앞에 놓인 문제 혹은 환경과 맞아떨어진다면 더할 나위 없는 좋은 도구가 될 것이다.

  • 나는 소프트웨어 엔지니어다. 이 일로 돈을 벌고 가족을 책임지지만 여전히 엔지니어링 즉, 공학이란 무엇이냐는 단순한 질문에도 쉽게 대답하지 못한다. 나뿐만 아니다. 세상에는(적어도 내가 만난 엔지니어 중에는) 과학이나 수학의 세계를 공학과 혼동하는 사람도 있고 과학과 수학이 공학보다 더 우위에 있다고 말하는 사람도 있다. 모두 공학을 잘 이해하지 못해 일어나는 일이다.

    공학의 본질적 목표는 무엇이며, 엔지니어가 정확히 무슨 일을 하는 사람을 일컫는지 알지 못하면서 소프트웨어 공학 관련 직업에 종사하고 있다는 사실이 아이러니하게 느껴지기도 한다. 그래서 그런지 요샌 스킬-업보단 공학이란 무엇인가에 좀 더 관심을 두고 「공학 학교에서 배운 101가지」나 「맨발의 엔지니어들」 같은 조금은 덜 전문적이지만 딱딱하지 않은 책을 읽는데 시간을 내고 있다.

    이번에(사실 포스팅으로는 처음) 소개할 책은 「공학기술과 사회」다. 공학에 대한 여러 가지 잡생각을 하며 퇴근하던 중 강남 영풍문고에 들르게 됐는데, 진열장에 꽂혀 있는 이 책이 나의 눈길을 사로잡았다.

    공학 기술과 사회 책표지
    <그림 1. 공학 기술과 사회>

    책 표지나 내용 전개 방식을 보면 다소 대학 교재 같은 느낌이 난다. 실제로 한국공학교육센터에서 공학소양 교과목 DB구축사업의 목적으로 연구비를 받아 집필됐다고 한다. 교재 같은 점만 제외하면 책 내용 자체는 제법 훌륭한 편이다.

    엔지니어가 전공을 통하여 사회에 공헌하기 위해서는 자기가 하는 일이 사회와 어떤 관계가 있으며, 기술이 전반적으로 사회와 서로 어떤 영향을 주고 받는지 이해할 필요가 있다.

    소프트웨어 엔지니어는 현재에 집중하여 좀 더 나은 미래를 만들기 위해 살아가지만, 사회에 있어 의미 있는 무엇인가를 만들기 위해선 사회와 기술의 관계를 이해할 필요가 있다. 눈앞에 놓여있는 기술적 문제 해결에만 집중하기보단 조금 더 넓은 시야를 가지고 다양한 사회적 문제를 어떻게 해결할 수 있을지, 사회 속에 엔지니어와 기술의 책임은 무엇일지 고민해야만 엔지니어로서 가장 만족할 수 있는 성과를 달성할 수 있지 않을까.

    이때 이 책이 그러한 시작점으로써 좋은 지적 재료가 될 수 있다. 공학의 정의부터 엔지니어적 가치, 기술의 역사, 기술결정론이나 기술 사회적 구성론 같은 기술사회론도 일반인이 이해하고 받아드리기 쉽게 작성돼 있다.

    앞으로도 공학의 본질적인 측면과 사회와의 관계를 이해하고 엔지니어로서의 책임 범위와 중요한 덕목을 갖춘 그런 훌륭한 엔지니어가 되기 위해 끊임없이 정진해야 할 것 같다. 먼 훗날 한 사람의 엔지니어로서 훌륭한 인생을 살았다고 스스로 자신 있게 말할 수 있도록 말이다.

    그나저나 모두 베이컨을 아는가? 먹는 베이컨이 아니라 “아는 것이 힘이다”라고 말한 철학자 프랜시스 베이컨(Francis Bacon) 말이다(최근에 FRP 라이브러리로도 등장했다 -Bacon.js-). 이 책을 통해서 알게 된 사실인데 베이컨은 공학적 측면에서도 상당히 멋진 분인 걸 알 수 있었다.

    근데 초 서양에서 기술의 가치를 높게 평가하고 기술 진보를 인류의 역사에서 매우 중요한 과제로 제시함으로써 플라톤과 아리스토텔레스 이후 지속돼왔던 “기술에 대한 천대”를 종식시키려 노력했던 대표적 인물이 바로 프랜시스 베이컨(Francis Bacon)이다. … 중략 … 베이컨은 발명을 통한 기술적 진보가 사회 발전에 필수적인 요소임을 강조함으로서 과학은 고상하지만 기술은 비천하다는 편견을 없애려 했다.

    우리 모두 자부심을 가지고 유용한 제품을 만들어내는 훌륭한 공학자의 인생을 살아나가자. 기술은 절대 천하지 않고 공학은 위대하다! 끝.