최근 사용자 UI의 중심에는 다양한 종류의 모바일 기기가 존재한다. 과거의 데스크탑 환경처럼 단순히 여러 해상도를 고려하는 앱을 개발하는 시대는 이미 오래 전의 이야기이다. 앱 개발자는 더 많은 사용자 확보를 위해 다양한 기기와의 호환성을 갖춘 앱을 제작하길 희망한다. 이를 위해 해상도는 물론 dpi(dots per Inch) 그리고 터치 스크린과 같은 입력 장치 등을 고려하여 앱을 설계, 제작해야 한다.

호환성을 갖춘 앱 제작에 있어서 비교적 어려운 문제 중 하나는 다양한 스크린 환경과 입력 장치에 대응하는 스케일러블한 UI를 구현하는데 있다. 스케일러블 UI(Scalable UI)란 다양한 해상도 및 크기의 화면에 대응하는 UI를 의미한다. 모바일 기기부터 데스크탑, TV까지 다양한 크기의 화면에서 모두 동작하는 호환성을 갖춘 UI를 표현하기 위해 앱 개발자는 스케일러블 UI를 구성하길 원한다. 하지만 앱 개발자는 전혀 예상치 못한 환경의 디바이스 장치에서 무참히 무너지는 앱의 UI를 보고 좌절하기 쉽상이다. 더 많은 환경의 기기에서 테스트를 해야 하며 문제를 발견할 때마다 이에 대응하는 코드를 추가해야만 한다.

UI 프레임워크는 앱 개발자들이 이러한 문제를 보다 쉽고 단순하게 대응하기 위한 발판을 마련해 줘야한다. 스케일러블 UI의 원칙을 정의하고 이를 위한 견고한 인터페이스와 가이드를 제공하여 앱 디자이너는 물론 개발자들이 보다 직관적이고 단순한 설계로 호환성을 갖춘 UI를 구현할 수 있도록 도와줘야 한다.

이번 장에서는 스케일러블 UI를 구현하기 위한 개념과 메커니즘에 대해 이해하고 UI 프레임워크에서 갖춰야할 기능들이 무엇인지 살펴보도록 하자.


1. 이번 장 목표

이번 장을 통해 다음 사항을 학습해 보자.

  • 스케일러블 UI을 위한 UI의 핵심 원칙에 대해 이해한다.
  • 스케일러블 UI를 위한 절대 좌표계, 정규 좌표계, 상대 좌표계, 크기 제약에 대해 이해한다.
  • 컨테이너의 기본 개념을 이해하고 정렬과 가중치 그리고 사용 방식을 살펴본다.
  • 더 높은 호환성 UI를 위한 고급 기법들을 이해한다.


  • 2. 좌표계 이해

    스케일러블 UI를 위한 가장 기본적인 방법으로는 종횡비(Aspect Ratio)를 유지하는 UI가 있으며 상황에 따라서는 레이아웃을 재배치하는 방법까지 스케일러블 UI을 위해 동원할 수 있는 방법은 여러가지가 존재한다. 스케일러블 UI을 지원하기 위해서 우리는 먼저 UI의 좌표계 및 좌표 단위를 이해할 필요가 있다.


    2.1 절대 좌표계

    절대 좌표계(Absolute Coordinates)는 원점(origin)으로부터 지정한 거리만큼 떨어진 위치를 가리키는 방식이다. 일반적으로 앱에서 원점은 앱 화면의 좌측 상단에 해당되며 UI 시스템 특성에 따라 화면의 좌측 하단 또는 화면 중심이 원점이 될 수도 있다. 이 때 거리의 단위는 일반적으로 픽셀에 해당되며 서브픽셀 정밀도(sub-pixel precision) 가 가능한 UI 시스템인 경우 소수점 이하로도 좌표 지정이 가능하다.

    다음 예제는 UI 앱 개발의 기본 이해에서 보았던 버튼을 구현하는 코드이며 절대 좌표(픽셀 단위)를 이용한 방식을 보여주었다.

    UIButton myBtn = new UIButton();    //버튼 생성
    …
    myBtn.move(50, 50);                 //버튼 위치 (단위는 픽셀이다.)
    myBtn.resize(100, 100);             //버튼 크기 (단위는 픽셀이다.)
    코드 1: 버튼의 위치 및 크기 설정

    버튼의 위치와 크기를 지정하는 위 구현은 매우 자연스러워 보인다. 코드 1로부터 앱 개발자는 x, y 좌표 50, 50 픽셀 위치로부터 가로, 세로 크기가 100x100 픽셀인 버튼이 화면 상에 나타나길 기대한다. 당장의 동작에는 큰 문제는 없어 보이지만 호환성 측면에서는 어떠할까? 데스크탑에서는 하나의 앱이 사용자에 의해 다양한 크기의 윈도우로 구동될 수 있으며 스마트폰의 경우에는 기기마다 해상도가 제각기 다르다. 단위가 픽셀인 까닭에 버튼은 앱 화면의 원점을 기준으로 부터 (50, 50) 떨어진 픽셀 위치에서 100x100 픽셀 크기만큼 배치된다. 하지만, 앱이 보여지는 화면의 크기(해상도)는 가변적이기 때문에 이러한 픽셀 단위의 좌표와 크기는 일부 상황에서는 유용하지 않을 수도 있다.

    그림 1: 화면 크기별 절대 좌표의 출력 결과

    그림 1를 확인해 보면, 300x300 화면 크기는 그래도 봐줄만 하지만 100x100의 경우에는 용납할 수 없는 출력 결과이다. 원점이 화면의 중심이면 더 나을뻔도 했지만 결과적으로 다른 문제는 발생할 것이다. 변화가 없는 매우 특수한 환경의 전용 앱을 개발하는 것이 아니라면, 앱의 호환성을 위해 앱 개발자는 보다 나은 방법을 이용하여 UI 객체를 배치해야 한다.


    2.2 정규 좌표계

    UI 객체를 배치할 때 사용할 수 있는 다른 방법으로는 정규 좌표계(Normalized Coordinates)를 이용하는 방법이 있다. 정규 좌표계는 좌표 단위가 픽셀이 아닌, 정규화된 범위의 공간 내에 UI 객체를 배치하는 방식이다. 예를 들면, 화면 크기 가로, 세로의 범위가 각각 0 ~ 1 사이의 범위로 정규화되었다고 가정하자. 이 때 좌표 (0, 0)은 화면의 맨끝 좌측 상단에 해당되며 (0.5, 0.5)는 화면의 중앙, (1, 1)은 화면의 맨끝 우측 하단에 해당된다. 정규 좌표를 이용하면 앱 개발자는 임의의 화면 크기에 대응하는 UI 객체를 배치할 수 있으며 앞선 절대 좌표계의 픽셀 단위로 UI 객체를 배치할 때의 문제점을 피할 수가 있다. 정규 좌표계를 사용하면 가변적인 앱의 화면 크기에 대응하는 UI를 구성할 수 있다는 측면에서 절대 좌표계보다 조금 더 유리하다.

    정규 좌표값을 구하는 식은 매우 단순하다. 배치하고자 하는 UI 객체의 가로, 세로 픽셀 위치를 앱의 화면 크기로 나눠주면 정규화된 좌표값을 구할 수 있다.

    그림 2: 정규 좌표계식. (nx: 화면 가로 크기,ny: 화면 세로 크기)

    다음 예제는 정규 좌표를 이용한 버튼의 위치 및 크기를 지정하는 예이다.

    myBtn.relative1(0.25, 0.25);           //버튼의 좌측 상단 위치 (50/200, 50/200)
    myBtn.relative2(0.75, 0.75);           //버튼의 우측 하단 위치 (150/200, 150/200)
    
    코드 2: 정규 좌표를 이용한 버튼 배치

    그림 3: 정규 좌표를 이용한 버튼 배치

    정규 좌표를 이용하면 최종 화면 크기에 비례하여 버튼의 크기 역시 조정이 된다. 화면이 큰 환경에서는 상대적으로 버튼 크기가 커질 것이고 작은 환경에서는 버튼 크기도 작아질 것이다. 만약 앱의 화면 크기가 200x200인 경우, 버튼의 위치는 (50,50), 크기는 100x100이 되며 앱의 화면 크기가 300x300인 경우, 버튼의 위치는 (75, 75), 크기는 150x150이 된다. 화면의 크기가 어떻든 간에 버튼은 그 화면 영역 (0.25, 0.25) 위치로부터 (0.75, 0.75) 위치의 영역 내에 배치될 것이다.

    그림 4: 화면 크기에 따른 정규 좌표계 버튼 출력 결과

    확장성을 갖춘 UI를 구현하기 위해서 정규 좌표계를 무조건 사용하는 것이 만사는 아니다. 대체로 위치나 레이아웃의 영역을 지정하는 방면으로 정규 좌표계가 유용하며 콘텐츠의 크기의 경우는 픽셀 단위의 크기가 더 많이 요구된다. 예로, 다음 그림과 같이 정규 좌표계를 이용하여 콘텐츠 크기를 지정한 경우 콘텐츠가 훼손되는 경우가 발생할 수도 있다

    그림 5: 정규 좌표계 오용 예

    그림 5와 같이 UI 컨트롤의 종횡비가 유지되거나 크기가 고정되어야 하는 경우가 있다. 앱 개발자가 가이드를 완전히 무시한 경우가 아니라면, UI 컨트롤은 어떠한 경우라도 외양이 훼손되지 않도록 동작 컨셉을 갖추고 있어야 하며 앱 개발자는 이러한 문제로부터 자유롭게 사용할 수 있어야 한다. UI 컨트롤을 직접 디자인하고 구현하는 측면에서는 이러한 부분을 염두해야 한다.

    정규 좌표계를 이용하는 경우, UI 엔진이 해야할 일은 물론 조금 더 많아진다. 렌더링을 수행하기 전, UI 엔진은 사용자가 지정한 UI 컨트롤의 정규 좌표를 최신 앱 화면 크기를 기준으로 픽셀 단위의 좌표로 역산해야 한다. 하지만, 일반적으로 앱의 화면 크기는 빈번히 바뀌지 않기 때문에 한번 계산한 픽셀 좌표는 캐싱하여 재사용하는 것도 좋은 방법이다.

    /*
     * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당된다.
     * UI 컨트롤의 기본 동작 및 속성을 구현한다.
    */
    UIObject
    {
        Geometry geom = {0, 0, 0, 0};      //오브젝트의 지오메트리(위치 및 크기)
        Point relative1 = {0, 0};          //relative1 속성
        Point relative2 = {0, 0};          //relative2 속성
        Bool updateGeom = false;           //true인 경우 지오메트리를 새로 갱신한다.
        ...
    
        /*
         * relative1의 좌표 지정.
         * x, y의 타입은 float/double 모두 가능하다.
        */
        relative1(x, y)
        {
            //이전 값과 동일한 좌표가 넘어오면 바로 종료한다.
            if (x == relative1.x && y == relative1.y) return;
    
            //새로운 좌표를 저장한다.
            relative1.x = x;
            relative1.y = y;
    
            //지오메트리가 새로 갱신되어야 함을 기록한다.
            updateGeom = true;
            ...   
        }
    
        /*
         * relative2의 좌표 지정.
         * 핵심은 relative1()과 완전히 동일하다.
        */
        relative2(x, y)
        {
            if (x == relative2.x && y == relative2.y) return;
            relative2.x = x;
            relative2.y = y;
            updateGeom = true;
            ...    
        }
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 매 프레임마다 UI 엔진에 의해 호출된다.
        */
        update(...)
        {
            //지오메트리가 새로 갱신되어야 할 경우,
            if (updateGeom == true || output.changed)
            {
                /* output은 오브젝트가 출력되는 영역이다. update()의 인자로 전달되었거나,
                   메서드 자체적으로 얻어왔다고 가정하자. 정규 좌표계식을 역으로 계산하여
                   지오메트리 픽셀 값을 구한다. 만약 output 크기 자체가 변경되어도
                   지오메트리는 새로 갱신되어야 한다. */
                geom.x = output.w * relative1.x;
                geom.y = output.h * relative1.y;
                geom.w = (output.w * relative2.x) - geom.x;
                geom.h = (output.h * relative2.y) - geom.y;
    
                //더 이상 지오메트리가 갱신될 필요가 없다.
                updateGeom = false;
            }        
            ...
        }
    }
    
    코드 3: 정규 좌표로부터 픽셀 좌표값 구하기


    2.3 상대 좌표계

    상대 좌표계(Relative Coordinates)는 원점이 기준이 아닌 다른 UI 객체를 기준으로 좌표를 지정하는 방식이다. 특정 객체의 위치와 크기에 의존하는 경우, 상대 좌표계를 이용하면 매우 편리하다. 상대 좌표를 지정하기 위해서는 상대 좌표의 대상 객체를 지정하는 인터페이스도 같이 제공되어야 한다.

    //이미지 생성. 여기서 좌표는 화면을 기준으로 한다.
    UIImage myImg = new UIImage();         
    myImg.open(“./res/star.png”);
    myImg.relative1(0.0, 0.0);
    myImg.relative2(0.25, 0.25);
    myImg.show();
    
    //이미지 생성. myImg2의 좌표 공간은 myImg을 기준으로 한다.
    UIImage myImg2 = new UIImage();         
    myImg2.open(“./res/star.png”);
    myImg2.relativeTo(myImg);           //상대 좌표 대상 지정
    myImg2.relative1(1.0, 0.0);
    myImg2.relative2(2.0, 1.0);
    myImg2.show();
    
    코드 4: 상대 좌표 지정

    그림 6: 코드 4 출력 결과 도식화

    다양한 유스케이스를 만족하기 위해 relativeTo()를 좀 더 세분화하여 relative1과 relative2에 대해 각각 상대 좌표 대상을 지정할 수 있는 인터페이스를 제공하는 것도 고려해볼만 하다.

    myObj.relative1To(target1);           //좌측 상단 위치의 상대 좌표 대상 지정.
    myObj.relative1(1.0, 1.0);            //target1의 우측 하단 꼭지점을 가리킨다.
    myObj.relative2To(target2);           //좌측 상단 위치의 상대 좌표 대상 지정.
    myObj.relative2(0.0, 0.0);            //target2의 좌측 상단 꼭지점을 가리킨다.
    
    코드 5: 특정 대상을 기준으로 하는 상대 좌표

    그림 7: 코드 5 출력 결과 도식화

    특히나 구현 시점에 대상 컨트롤의 크기 및 위치가 결정되지 않는 경우에 상대 좌표는 더욱 필수적이다. 예를 들면, 길이가 가변적인 텍스트의 우측에 어떤 아이콘을 배치한다고 가정해 보자. 구현 시점에 텍스트의 길이를 알 수 없으므로 아이콘의 위치 또한 결정하기 어렵다. 이 경우, 상대 좌표는 반드시 필요하다.

    그림 8: 상대 좌표 필요 예


    3. 크기 제약

    2.2 정규 좌표계 절에서 UI 컨트롤은 어떠한 경우라도 외양이 훼손되지 않도록 동작 컨셉을 갖추고 있어야 한다고 언급했었다. 이 문제를 좀 더 자세히 짚어보기 위해 코드 2의 정규좌표계를 이용한 버튼 예제를 다시 한번 살펴보고자 한다. 다만 이번엔 화면 크기를 100x100으로 축소해서 그 결과물을 확인해 보자.

    그림 9: 100x100 화면 크기에서의 상대 좌표를 이용한 버튼 출력 결과

    그림 9에서 확인할 수 있듯이, 100x100 크기의 화면에서 또 다른 문제점이 드러났다. 상대 좌표를 이용하여 버튼의 크기는 줄어들었지만 버튼 안의 텍스트는 줄어들지 않아서 텍스트가 버튼 영역을 벗어나 버렸기 때문이다. 사실 위의 문제는 버튼의 텍스트를 … 과 같이 생략하여 글자의 길이를 줄임으로써 이 문제를 회피할 수도 있다. 일반적으로 UI 프레임워크에서는 텍스트 일립시스(ellipsis) 기능을 제공하여 출력 영역이 부족할 경우 텍스트를 자동으로 생략하는 기능을 수행할 수 있게끔 도와준다.

    myBtn.setTextEllipsis(true);       //텍스트 일립시스 기능 사용
    
    코드 6: 텍스트 일립시스 사용

    그림 10: 텍스트 일립시스 적용 결과

    텍스트 일립시스는 이 문제를 해결할 수 있는 가장 쉽고 단순한 방식처럼 보이지만 더 깊이 생각해 보면, UI 컨트롤 안의 내용물이 텍스트만 존재한다고 가정할 수도 없을 뿐더러, 글자 길이를 줄이는 방식이 항상 옳은 것도 아니다. 사실 그림 10의 버튼만 보더라도 버튼의 텍스트가 무얼 전달하고자 하는지 사용자는 이해하기 어렵다.

    그림 11: 텍스트 일립시스의 문제점

    많이 엉성하지만, 그림 11은 온라인상에서 물건 구매를 위한 결제 승인을 요청하는 화면이라고 가정한다. 금액이 지불될 수 있다는 점에서 사용자는 다소 신중한 선택이 필요할 수도 있다. 하지만 오른쪽 이미지의 경우 화면 영역의 부족으로 화면 하단의 세 버튼의 텍스트가 일립시스 처리되었다. 사용자는 어떤 버튼을 눌러야 구매 취소가 되는지 사전에 눌러보지 않고서는 알기가 애매해다. 이 시나리오는 조금 극단적이지만 유사한 시나리오는 충분히 있을 수 있다.

    우리는 이 문제를 보다 근본적으로 해결하기 위해 UI 컨트롤의 크기 제약에 대해 이해해 보고자 한다. 이해를 돕기 위해 이번엔 다른 테마의 버튼을 도입했다.

    그림 12: 버튼 UI

    앞서 보았던 버튼과 비교하면 그림 12 버튼의 경우 아이콘과 텍스트 두 보조 콘텐츠를 제공할 뿐만 아니라 버튼의 모서리가 라운드 처리되어서 보다 부드러운 느낌을 제공한다. 버튼 내부의 아이콘 및 길이가 가변적인 텍스트는 일단 제외하더라도, 만약 화면 영역의 변화에 따라 버튼의 크기도 비례하여 변한다면 어떨까?

    그림 13: 크기 변화로 인해 훼손된 외양

    그림 13과 같이 크기가 가변적이라고 해서 무조건 크기를 상대적으로 변경했더니 버튼 외곽 라운드 외양에 문제가 생겼다. 우리는 위 예제를 통해 변경이 가능한 가변 영역(Resizable Area)과 변경이 되면 안되는 고정 영역(Fixed Area)이 필요함을 알 수 있다.

    그림 14: 버튼의 가변 영역과 고정 영역

    고정 영역은 크기 조정이 불가능한 영역이다. 고정 영역에 변화가 발생하면 UI 컨트롤의 외양이 훼손되거나 사용에 문제가 발생하기 때문에 절대적 크기를 보장해야만 한다. 다시 말하면, 컨트롤의 크기에 상관없이 항상 일정한 크기를 보장해야 한다.

    이미지 보더(Image Border)는 그림 13과 같은 문제를 회피하기 위해서 한 장의 이미지에서 테두리 영역을 구분하는 개념이다. 이미지 보더를 이용하면 이미지를 스케일링(Scaling)하는 과정에서 보더 영역을 제외한 가변 영역만 스케일링 작업을 수행하여 이미지가 훼손되는 것을 방지할 수 있다. 그림 14의 고정 영역이 바로 보더 영역과 일치한다. 이미지 보더 대신 나인패치(9-patch) 방식을 이용하는 방식도 존재하지만 나인패치는 하나의 완성된 이미지를 위해 9장의 이미지 리소스를 따로 준비한 후, 구현 단계에서 이들을 조합해서 사용해야 한다는 측면에서 다소 작업량도 많고 구현도 복잡하다.

    그림 15: 나인패치 이미지

    그에 비해 이미지 보더는 한 장의 이미지에 보더 영역을 지정해 주면 UI 엔진에서 이미지를 스케일링하는 과정에서 보더 영역은 제외하고 스케일링을 수행하여 이미지 훼손을 방지한다. 이미지 보더를 지정하기 위해서는 한 장의 이미지에 좌, 우, 상, 하 영역에 대한 보더 크기를 지정할 수 있다.

    UIImage myImg = new UIImage();
    myImg.path(“./res/button.png”);
    myImg.setBorderArea(w1, w2, h1, h2);    //이미지 보더 영역 지정(좌, 우, 상, 하)
    myImg.resize(100, 100);                 //보더 영역은 영향을 받지 않는다.
    myImg.show();  
    
    코드 7: 이미지 보더 지정

    안드로이드 시스템의 경우 이미지 자체의 좌측과 상단에 가변 영역(Stretchable area)을 블랙 라인으로 표시함으로써 보더 영역을 구분한다. 별도의 코드 작성없이 이미지 보더를 적용할 수 있다는 점에서 개발과 디자인 작업의 의존성을 제거할 수 있다. 하지만, 이미지를 불러오는 과정에서 블랙 라인의 길이를 통해 보더 영역을 구분하고 이미지 스케일링 과정에서 엔진이 별도로 처리한다는 사실은 동일하다.

    그림 16: 안드로이드 이미지 보더

    UI 컨트롤을 디자인할 시, 컨트롤 특성에 맞게 크기가 고정인 영역을 정의해야 하며 하나의 컨트롤에 존재하는 고정 영역의 합은 결과적으로 해당 컨트롤의 최소 크기라고 정의할 수 있다.

    그림 17: 버튼의 최소 크기

    버튼에 포함된 아이콘의 경우 종횡비를 유지하는 선에서 버튼 크기에 따라 크기 조절이 가능하지만 디자인 컨셉에 따라 크기를 고정시킬 수도 있다. 텍스트 출력 영역의 경우 기본적으로 가변적이지만 이 역시 컨트롤의 최소 크기에 영향을 미친다. 만약 사용자가 그림 11과 같은 문제로 인해 텍스트 일립시스 기능을 사용하고 싶지 않은 경우에는 텍스트 출력 길이가 컨트롤의 최소 크기에 포함되어야 한다. 그렇지 않으면, 그림 9처럼 텍스트가 버튼 외곽 테두리를 벗어날 것이다. 설사, 텍스트 일립시스가 동작하더라도 최소한 … 의 출력은 컨트롤의 최소 영역에 포함시켜야 한다.

    그림 18: 아이콘, 텍스트가 포함된 버튼의 최소 크기

    기본적으로 UI 컨트롤은 각 컨트롤마다의 기본 동작 컨셉을 기반으로 가변 영역과 고정 영역을 잘 정의하고 구현해야 한다. 앱 개발자는 이들에 대한 자세한 사항을 모를지라도 UI 컨트롤은 최종 환경에서 사용자로 하여금 사용에 문제가 없도록 자체적으로 스마트한 동작을 보장해야 한다. UI 컨트롤을 직접 구현하는 관점에서 우리는 버튼의 UI를 다음과 같은 방식으로 구현해 볼 수 있다.

    /*
     * 그림 18의 버튼의 UI를 구성하는 함수(혹은 메서드)
     * w1 = 10, w2 = 25, w3 = 가변, w4 = 10, h1 = 10, h2 = 10, h3 = 25, h4 = 가변
     * 다소 코드가 복잡하게 느껴진다면, 그림을 그려가면서 이해해 보자.
    */
    composeButtonUI()
    {
       //버튼 배경 이미지
       UIImage bg = new UIImage();
       bg.path(“./res/button.png”);
       bg.setBorderArea(10, 10, 10, 10);   //이미지 보더 영역 지정 (좌, 우, 상, 하)
       bg.show(); 
    
       //버튼 아이콘
       UIImage icon = new UIImage();
       icon.margin(10, 0, 0, 0);    //좌, 우, 상, 하 마진 설정 (w1, 0, 0, 0)
       icon.relativeTo(bg);
       icon.align(0, 0.5);   //아이콘 원점을 bg의 좌측 중심으로 변경한다.
       icon.resize(25, 25);                  
       icon.path(“...”);     //실제로는 앱 개발자가 요청한 이미지 리소스를 명시해야 한다.
       icon.show();
    
       //버튼 텍스트 (크기는 텍스트 출력 결과에 의존한다.)
       UIText text = new UIText();
       text.margin(35, 10, 0, 0);  //좌, 우, 상, 하 마진 설정 (w1+w2, w4, 0, 0)
       text.relativeTo(bg);
       text.align(0, 0.5);         //텍스트 원점을 bg의 좌측 중심으로 변경한다.
       text.fontName(“Sans”);      //폰트 이름
       text.fontSize(20);          //폰트 크기
       text.text(“...”);           //실제로는 앱 개발자가 요청한 문자열을 명시해야 한다.
       text.show();
    
       //주의! 버튼 배경 이미지의 크기는 버튼 구성 요소에 상대적으로 변화한다.
       bg.relative1To(icon);
       bg.relative1(0, 0);
       bg.relative2To(text);
       bg.relative2(1, 1);
    
       ...
    }
    
    코드 8: 버튼 컨트롤 UI 구현

    버튼과 마찬가지로 대부분의 UI 컨트롤이 크기 제약을 가지고 있다면, 앱 개발자는 그 크기를 벗어난 크기를 지정할 수가 없을 것이다. 하지만, 이러한 사실을 앱 개발자는 앱 구현 시점에 어떻게 알 수 있을까? 물론 UI 프레임워크에서는 UI 컨트롤의 최소/최대 크기를 앱 개발자가 알 수 있도록 인터페이스를 제공할 수 있겠지만, 사실 앱 개발자는 그러한 정보까지 고려하면서 UI를 구현하고 싶지 않을 것이다. 그렇다면, 앱 개발자는 UI 컨트롤의 크기를 어떻게 결정해야 할지 조금은 난해할 것이다. UI 컨트롤의 크기 제약을 모르기 때문에 뭔가 더 좋은 방법이 필요하다.


    4. 컨테이너

    컨테이너(Container)는 앱 화면을 구성하기 위한 레이아웃의 틀을 제공한다. 일반적으로 UI 프레임워크는 다양한 컨테이너를 제공하며 컨테이너마다 서로 다른 레이아웃 구성 특성을 가지고 있다. 사용자는 컨테이너의 특성을 이해하고 다양한 컨테이너를 조합하여 앱 화면을 적절히 구성할 수가 있다. 특히, 앱이 스케일러블 UI를 지원하기 위해서는 UI 컨트롤을 직접 화면에 배치하는 것보다 컨테이너를 활용하는 것이 보다 쉽고 안전하다. 최종 사용자의 디바이스의 스크린 환경은 물론, 각 컨트롤마다 그 특성이 다르므로 상황에 따라 컨트롤이 어떻게 화면에 나타날지 앱 개발자가 모두 이해하기 어렵기 때문이다. 게다가 2.4절에서 살펴본 컨트롤의 크기 제약을 앱 개발자가 알기 어렵기 때문에 호환성 높은 앱을 개발하기 위해서는 컨테이너를 활용하는 것은 필수에 가깝다. 컨테이너는 이러한 UI 컨트롤의 특성을 이해하고 최적의 화면 구성을 보장해 준다.

    기본적으로 UI 프레임워크는 비컨테이너(Non-container)와 컨테이너(Container) 두 부류의 UI 컨트롤을 제공한다.

  • 비컨테이너: 앞서 살펴본 버튼처럼, 앱의 UI를 구성하는데 있어서 시각적 외양을 통해 사용자와 상호작용을 수행하는 UI 컨트롤
  • 컨테이너: 비컨테이너 컨트롤을 효율적으로 화면에 배치하기 위한 레이아웃 정보를 제공한다. 일반적으로 컨테이너 컨트롤은 시각적 외양이 없으며 스케일러블 UI에 대응하기 위한 레이아웃 특성을 제공한다.
  • 그림 19: 컨테이너 종류 예시

    앱 개발자의 개발 편의를 위해 제공되는 컨테이너의 종류도 다양하다. 그 종류 및 기능은 UI 프레임워크마다 다르지만, 핵심 기능상 리니어 레이아웃(그림 19의 좌측 상단)과 유사한 기능을 수행하는 컨테이너는 대부분 존재한다.

    리니어 레이아웃의 동작 방식을 한번 살펴보자. 리니어 레이아웃의 사용 예를 통해 우리는 컨테이너의 사용 및 동작 방식의 핵심을 이해할 수 있을 것이다. 다음은 우리가 구현하고자 하는 화면 구성이다.

    그림 20: 리니어 컨테이너 사용 예

    /* 수직 리니어 레이아웃 생성. 레이아웃은 화면에 가득 출력된다고 가정한다. 
       myWnd는 윈도우 객체이다. */
    UIVerticalLinearLayout myLayout = new UIVerticalLinearLayout(myWnd);
    myLayout.show();
    
    //버튼1 생성
    UIButton myBtn1 = new UIButton();
    myBtn1.text(“Button 1”);
    myBtn1.show();
    myLayout.push(myBtn1);       //레이아웃에 버튼1 추가
    
    //버튼2 생성
    UIButton myBtn2 = new UIButton();
    myBtn2.text(“Button 2”);
    myBtn2.show();
    myLayout.push(myBtn2);       //레이아웃에 버튼2 추가
    
    //버튼3 생성
    UIButton myBtn3 = new UIButton();
    myBtn3.text(“Button 3”);
    myBtn3.show();
    myLayout.push(myBtn3);       //레이아웃에 버튼3 추가
    
    코드 9: 리니어 레이아웃 사용 예

    코드 9은 수직 리니어 레이아웃을 하나 생성한 후, 세 개의 버튼을 순차적으로 레이아웃에 추가한다. 이 코드에서는 레이아웃 자체에 대한 영역 정의는 정확하게 보여주지 않는다. 다만 레이아웃 객체를 생성시 윈도우 객체를 전달하면서 레이아웃은 윈도우와 사이즈가 동일시된다고 가정하자.

    수직 리니어 레이아웃은 윈도우 크기에 맞춰 공간을 확보하며 레이아웃에 추가된 세 개의 버튼에 대해서 동일한 공간을 균등히 분할해서 버튼을 배치하는 작업을 수행한다. 버튼은 할당받은 공간에 대해 크기를 조정하여 최종적으로 화면에 출력된다. 결국, 위 코드의 세 버튼은 레이아웃 영역을 삼등분하여 동일한 크기로 출력될 수 있다. 수평 리니어 레이아웃이라면, 추가한 버튼은 수평 방향으로 추가될 수 있다.

    하지만, 여기서 우리는 다음과 같은 질문을 던져볼 수 있는데, 만약 앱 개발자가 버튼의 크기를 다르게 조정하고 싶다면 어떻게 해야할까? 이에 대한 해답으로 UI 프레임워크는 가중치(weight)와 정렬(align)이라는 개념을 추가로 제공할 수 있다.

    가중치는 UI 객체가 할당받을 수 있는 공간에 대한 가중치로서 동작한다. 만약 세로 공간의 크기가 200인 수직 리니어 레이아웃에 두 개의 버튼을 추가한다고 가정하자. 여기서 추가하는 두 버튼의 가중치가 모두 1.0이라면 두 버튼이 할당받는 공간의 높이는 각각 100이 된다. 하지만 이 중 하나는 0.5, 다른 하나는 1.0이라면 50과 150의 공간으로 재조정될 수 있다. 만약 세 버튼이 공존하며 하나는 0.5 다른 두 버튼은 1.0이라면 50, 125, 125의 크기로 분할될 수 있다.

    그림 21: 가중치 0.5, 1.0로 배치된 두 버튼의 예

    리니어 레이아웃에 있어서 가중치 설정의 핵심은 컨테이너의 공간을 차지하는 UI 컨트롤 간의 영역 싸움이라고도 볼 수 있다. UI 프레임워크는 가중치 설정을 위한 방법으로 다음과 같은 인터페이스를 제공할 것이다.
    /* 넓이(1.0), 높이(0.5)에 대한 가중치를 설정한다. 가중치의 값이 0.0의 경우 버튼의 최소 크기를 보장해야 한다. */
    myBtn.weight(1.0, 0.5);
    
    코드 10: 가중치 설정 예

    반면, 리니어 레이아웃은 자신의 영역에 추가된 컨트롤의 크기를 결정하기 위해 다음과 같은 핵심 로직을 구현한다.

    /*
     * 수직으로 UI 오브젝트를 담는 컨테이너 기능을 수행한다.
     * UIVerticalLayout는 UILayout을 상속받는다.
    */
    UIVerticalLayout extends UILayout
    {
        //사실 아래 두 멤버 변수는 부모 클래스에 정의되어 있을 것이다.
    
        List contents;          //레이아웃에 추가된 콘텐츠 리스트
        Geometry geom;          //레이아웃의 지오메트리 정보
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 레이아웃이 보유한 콘텐츠의 크기 및 위치를 결정한다.
         * 여기서는 쉬운 이해를 위해서 크기를 구하는 기본 로직만 구현하며 그 외 
         * 여러 경우의 로직은 모두 무시한다.
        */
        updateContentSize()
        {
            ...
    
            Size totalSize;                       //Layout의 최종 크기
            Size totalWeight;                     //콘텐츠가 요구하는 가중치 축적치
            Size contentSize[contents.count];     //Layout에 추가된 콘텐츠의 크기
            Bool calcSize = true;                 //콘텐츠 계산 완료 여부
     
            while (calcSize)
            {
                totalSize = {0, 0};
                totalWeight = {0, 0};
                foreach(contents, content, idx)
                {
                    Size weight = content.weight;         //콘텐츠 가중치
    
                    /* 가중치는 0 ~ 1 사이의 값만 허용한다.
                       이해를 돕기 위해 추가한 예외 코드. */
                    if (weight.w < 0) weight.w = 0;
                    if (weight.w > 1) weight.w = 1;
                    if (weight.h < 0) weight.h = 0;
                    if (weight.h > 1) weight.h = 1;
    
                    //콘텐츠 가중치를 기반으로 크기를 결정한다.
                    contentSize[idx].w = (geom.w / contents.count) * weight.w;
                    contentSize[idx].h = (geom.h / contents.count) * weight.h;
    
                    //콘텐츠 크기를 최신으로 갱신한다.
                    content.updateContentSize();
    
                    //콘텐츠의 최소 크기를 보장한다.
                    if (content.minW > contentSize[idx].w)
                    {
                       contentSize[idx].w = content.minW;
                    }
                    if (content.minH > contentSize[idx].h)
                    {
                       contentSize[idx].h = content.minH;
                    }
    
                    //Layout의 최종 크기를 구한다.
                    totalSize.w += contentSize[idx].w;
                    totalSize.h += contentSize[idx].h;
    
                    totalWeight.w += weight.w;
                    totalWeight.h += weight.h;
    
                    ++idx;
                }
    
                 /* Layout의 최종 크기가 현재 크기보다 더 크면 최종 크기를 기준으로 다시 
                    계산을 시도한다. */
                 if (totalSize.w > geom.w)
                 {
                     geom.w = totalSize.w;
                     calcSize = false;
                 }
                 if (totalSize.h > geom.h)
                 {
                     geom.h = totalSize.h;
                     calcSize = false;
                 }
             }
             
            /* 실제 레이아웃 크기 대비 콘텐츠가 요구하는 크기의 차를 구한다. 만약 
               레이아웃의 공간에 여분이 존재하면 남은 공간에 대해서 재분배를 시행한다. */
            Size diff = {geom.w - totalSize.w, geom.h - totalSize.h};
    
            foreach(contents, content, idx)
            {
                if (diff.w > 0)
                {          
                    contentSize[idx].w += diff.w * (totalWeight.w/content.weight.w);
                }
    
                if (diff.h > 0)
                {
                    contentSize[idx].h += diff.h * (totalWeight.h/content.weight.h);
                }
    
                //계산한 크기를 콘텐츠의 크기로 지정한다.
                content.resize(contentSize[idx].w, contentSize[idx].h);
    
                ++idx;
            }
            ...    
        }
    }
    
    코드 11: 가중치를 기반으로 콘텐츠 크기 계산 로직

    정렬은 컨테이너로부터 할당받은 공간에 대해 UI 객체가 어떤 방향에 위치할 것인지를 결정한다. 좌우, 상하, 가운데 중 어느 방향으로 정렬될 것인지, 또는 그림 20처럼 할당받은 공간을 가득 채우는 동작을 수행할 수 있다.

    그림 22: 컨테이너 공간 내에 UI 컨트롤 정렬 예

    myBtn.align(0, 0);                              //좌측, 상단
    myBtn.align(1, 1);                              //우측, 하단
    myBtn.align(0.5, 0.5);                          //가운데 정렬
    myBtn.align(UIObject.FILL, UIObject.FILL);      //할당받은 공간을 가득 채운다.
    
    코드 12: 정렬 설정 예

    /*
     * 수평으로 UI 오브젝트를 담는 컨테이너 기능을 수행한다.
     * UIHorizontalLayout는 UILayout을 상속받는다.
     * 코드 11의 UIVerticalLayout와 동일하며 정렬 구현을 추가로 보여준다.
    */
    UIHorizontalLayout extends UILayout
    {
        //사실 아래 두 멤버 변수는 부모 클래스에 정의되어 있을 것이다.
    
        List contents;          //레이아웃에 추가된 콘텐츠 리스트
        Geometry geom;          //레이아웃의 지오메트리 정보
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 레이아웃이 보유한 콘텐츠의 크기 및 위치를 결정한다.
         * 여기서는 쉬운 이해를 위해서 크기를 구하는 기본 로직만 구현하며 그 외 
         * 여러 경우의 로직은 모두 무시한다.
        */
        updateContentSize()
        {
            ...
    
            Size totalSize;                       //Layout의 최종 크기
            Size totalWeight;                     //콘텐츠가 요구하는 가중치 축적치
            Size contentSize[contents.count];     //Layout에 추가된 콘텐츠의 크기
            Bool calcSize = true;                 //콘텐츠 계산 완료 여부
     
            while (calcSize)
            {
               //코드 11과 동일
               ...
            }
             
            /* 실제 레이아웃 크기 대비 콘텐츠가 요구하는 크기의 차를 구한다. 만약 
               레이아웃의 공간에 여분이 존재하면 남은 공간에 대해서 재분배를 시행한다. */
            Size diff = {geom.w - totalSize.w, geom.h - totalSize.h};
    
            /* 레이아웃에 추가될 콘텐츠의 가로 위치 값. 레이아웃 위치가 기준이다. 
               VerticalLinearLayout의 경우 세로 위치 값이 필요하다. */
            var contentPosDiff = geom.x;
    
            foreach(contents, content, idx)
            {
                if (diff.w > 0)
                {          
                    contentSize[idx].w += diff.w * (totalWeight.w/content.weight.w);
                }
    
                if (diff.h > 0)
                {
                    contentSize[idx].h += diff.h * (totalWeight.h/content.weight.h);
                }
    
                //콘텐츠의 정렬이 Fill인 경우 계산한 크기를 콘텐츠의 크기로 지정한다.
                if (content.align.w == UIObject.Fill)
                {
                    content.resizeW(contentSize[idx].w);
                    content.moveX(contentPosDiff);
                }
                //콘텐츠의 가로 위치를 결정한다.
                else
                {
                     var x = (contentSize[idx].w - content.size.w) * content.align.w;
                     content.moveX(x + contentPosDiff);
                }
    
                if (content.align.h == UIObject.Fill)
                {
                    content.resizeH(contentSize[idx].h);
                    content.moveY(geom.y);
                }
                //콘텐츠의 세로 위치를 결정한다.
                else
                {
                    var h = (contentSize[idx].h - content.size.h) * content.align.h;
                    content.moveY(h + geom.y);
                }
    
                 //다음 콘텐츠의 시작 가로 위치를 결정한다.
                 contentPosDiff += contentSize[idx].w;
    
                ++idx;
            }
            ...    
        }
    }
    
    코드 13: 가중치과 정렬 계산 로직

    다음은 코드 9을 수정하여 정렬 기능을 활용한다.

    /* 수평 리니어 레이아웃 생성. 레이아웃은 화면에 가득 출력된다고 가정한다.
       myWnd는 윈도우 객체이다. */
    UIHorizontalLinearLayout myLayout = new UIHorizontalLinearLayout(myWnd);
    myLayout.show();
    
    //버튼1 생성
    UIButton myBtn1 = new UIButton();
    myBtn1.text(“Button 1”);
    myBtn1.show();
    myLayout.push(myBtn1);
    
    //버튼2 생성
    UIButton myBtn2 = new UIButton();
    myBtn2.text(“Button 2”);
    myBtn2.align(0.5, 0.5);            //중앙 정렬한다.
    myBtn2.relative1(0.25, 0.25);      //상대 좌표를 이용하여 위치를 지정한다.
    myBtn2.relative2(0.75, 0.75);      //상대 좌표를 이용하여 크기를 지정한다.
    myBtn2.show();
    myLayout.push(myBtn2);
    
    //버튼3 생성
    UIButton myBtn3 = new UIButton();
    myBtn3.text(“Button 3”);
    myBtn3.align(1, 0.5);        //우측 정렬한다.
    myBtn3.show();
    myLayout.push(myBtn3);
    
    코드 14: 정렬을 이용한 리니어 레이아웃 배치

    정렬을 지정하지 않으면 기본적으로 할당받은 레이아웃 영역에 UI 컨트롤이 가득 채우는 동작을 수행할 수 있다. 코드 14의 버튼1은 정렬을 지정하지 않았으므로 할당받은 공간을 가득 채우고 버튼2와 버튼3은 요청받은 대로 정렬을 수행한다. 이 때 크기를 지정하지 않은 버튼의 크기는 기본 크기(최소 크기)로 출력된다.

    그림 23: 정렬을 이용한 리니어 레이아웃 배치

    가중치와 정렬을 이용하면 앱 개발자는 컨테이너 정책의 범위 내에서 UI 컨트롤을 원하는대로 배치할 수 있다. 물론 가중치와 정렬은 하나의 컨셉일 뿐 UI 프레임워크마다 가중치와 정렬의 개념과 동작 방식이 다를 수는 있으며 아예 다른 방식으로 그 기능이 제공될 수도 있다. 하지만, 앞서 살펴 본 예제처럼 컨테이너에 UI 컨트롤을 배정할 때 위치 및 크기를 결정할 수 있는 메커니즘은 반드시 필요하며 컨테이너는 사용자가 고려하지 못한 경우에 대해서도 스케일러블한 UI를 보장하고 UI 컨트롤이 최소 크기 이하로 작아지는 등의 훼손 문제를 방지해야 한다.

    리니어 레이아웃 외로 테이블(Table) 컨테이너는 임의의 행과 열을 예약한 후, 각 행과 열이 가리키는 셀(Cell)마다 UI 컨트롤을 배치할 수 있는 컨테이너이다. 일반적으로 엑셀(Excel) 또는 스프레드 시트(SpreadSheet)와 같은 레이아웃을 앱이 구성할 수 있도록 UI 프레임워크는 테이블 컨테이너를 제공할 수 있다.

    앱 개발자는 UI 프레임워크에서 제공하는 다양한 컨테이너를 적재적소에 사용할 수 있어야 하며 컨테이너 안에 컨테이너를 배치하는 식으로 컨테이너를 조합하면 보다 효율적인 화면 구성도 가능하다.

    그림 24: 컨테이너를 조합한 화면 구성


    5. 더 높은 완성도를 위해

    앱의 뷰를 구성하는 콘텐츠의 최소 크기의 합이 앱 출력 화면 크기보다 더 큰 경우도 있지 않을까? 일반적인 경우는 아니지만 다양한 크기의 디바이스를 고려해 본다면 충분히 발생할 수 있는 문제이기도 하다. 극단적이긴 하지만 데스크탑에서 앱의 윈도우의 크기를 매우 작게 줄여보면 짐작할 수 있는 문제이기도 하다. 사실 이 경우 콘텐츠의 최소 크기보다 윈도우 크기가 줄어드는 것을 방지하는 정책이 필요할 수도 있다. 하지만 미처 고려하지 못한 소형의 모바일 기기에 앱을 구동하는 경우라면 디스플레이 크기의 제약으로 부득이하게 이 문제를 피할 수 없을 것이다. 호환성이 높은 앱이라면 다양한 화면 크기를 고려하여 각 해상도에 최적화된 뷰를 따로 디자인하여 해상도별 뷰를 출력하는 것도 가능하다. 하지만 이 경우 앱의 개발 비용과 난이도도 그만큼 상승할 것이며 모든 앱이 모두 대응한다고 보장할 수도 없다. 실용성은 떨어지지만 최소한 사용이 불가능한 앱은 되지 않도록 UI 프레임워크에서 도움을 주면 더 좋다.


    5.1 디바이스 독립적인 픽셀

    dpi(dots per inch)란 1인치의 범위 내에 존재하는 물리적 점의 개수를 의미하는데 원래는 프린터 출력 성능을 가리키는 용어 중 하나이다. dpi가 높을수록 1인치의 범위 내에 더 많은 점을 찍을 수 있으며 정교한 출력이 가능하다. 비슷한 용어 중 ppi(pixels per inch)는 디스플레이 장치에서 1인치 범위 내에 출력하는 픽셀의 수를 의미하며 PPI 역시 수치가 높을수록 더 정교한 출력이 가능해진다. 일반적으로 디스플레이 장치에서는 ppi 뿐만 아니라 dpi 개념 역시 통용된다.

    일반적으로 dpi/ppi가 높다는 것은 그만큼 고해상도 출력이 가능하다는 것을 의미하는데 예를 들면 물리적으로 동일한 크기의 출력 장치이지만 dpi/ppi 수치가 다르다면 두 출력 장치간의 해상도의 차이가 존재한다고도 볼 수 있다.

    그림 25: dpi에 따른 물리적 출력 크기 차이


    상대 좌표를 사용하는 경우는 해당되지 않지만, 절대 좌표를 이용한 위치 또는 UI 객체의 크기를 지정하기 위해서 사용하는 픽셀 단위의 수치는 그림 25과 같은 실제 출력의 차이를 발생시키며 이는 사용성에도 영향을 미칠 수 있다. dpi와 상관없이 사용자는 물리적으로 동일한 크기의 UI를 사용할 수 있어야 한다.

    이 문제를 해결하기 위해서는 앱 개발자가 지정한 UI 객체의 위치 및 크기의 단위는 디바이스에 독립적으로 동작하여야 한다. 이러한 개념을 디바이스 독립적인 픽셀(Device-Independent-Pixel) 또는 밀도 독립적인 픽셀(Density-Independent-Pixel) 이라고도 하는데 UI 프레임워크에서는 이러한 기능을 제공하여 앱 개발자로 하여금 보다 호환성이 높은 UI를 구성할 수 있도록 도움을 줄 수 있다. 실제로 안드로이드에서도 이러한 개념을 기본 구현 사항으로서 제공하고 있다.

    //디바이스 독립적인 픽셀을 최종 픽셀로 변환해주는 함수
    public static int dpToPx(int dp) {
        /* 안드로이드 시스템에서 정한 기준 dpi는 160이므로 160dpi에서 반환되는 density의 
           값은 1이다. */
        float density = Context.getResource().getDisplayMetrics().density;
        return Math.round((float) dp * density);
    }
    
    ...
    
    //dpToPx()을 이용한 크기 지정 예
    LinearLayout layout = new LinearLayout(this);
    LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) layout.getLayoutParams();
    layoutParams.height = dpToPx(400);
    layoutParams.width = dptoPx(400);
    layout.setLayoutParams(layoutParams);
    
    코드 15: 안드로이드의 디바이스 독립적인 픽셀 지정 방법 예

    안드로이드 시스템은 160으로 기준 dpi로 지정하였기 1dip는 160dpi에서 1픽셀과 매칭되며 이때 반환되는 디스플레이 density의 값은 1이 된다. 160보다 높은 dpi에서는 반환되는 density의 값도 높아진다.

    그림 26: dpi별 크기 차이

    그림 27: dip에 따른 물리적 출력 크기 차이


    5.2 스케일 팩터

    스케일 팩터(Scale Factor)는 앱 화면의 해상도를 변경하는 앱 단위 해상도 조정 변수 정도로 해석해 볼 수 있다. UI 프레임워크는 앱 화면을 구성하는 UI 콘텐츠의 최소 합이 윈도우 크기보다 큰 경우 스케일 팩터를 조정하여 앱 화면을 구성하는 UI 콘텐츠의 크기를 재조정할 수 있다.

    그림 28: 스케일 팩터를 이용한 콘텐츠 크기 변경

    얼핏 보기엔 상대좌표를 이용한 구현 결과물과 크게 다를바 없어 보일 수도 있지만, 스케일 팩터는 상대좌표에 영향을 받지 않는 폰트 크기에 영향을 주는 것은 물론, 콘텐츠의 고정 영역 및 최소 크기에도 똑같이 크기 영향을 줘야 한다. 그렇기 때문에 출력 영역의 크기에 상관없이 앱의 뷰 구성은 완전히 동일하게 출력될 수 있다.

    UI 엔진은 각 앱마다 다른 스케일 팩터값을 이용하여 앱의 UI 크기의 단위를 조정할 수 있다. 스케일 팩터는 앱의 UI 화면 구성을 보존할 수 있기 때문에 본래의 디자인을 보장한다는 장점이 있다. 하지만 터치스크린 환경에서 무작정 스케일을 낮추다 보면 사용자가 선택하기 어려울 정도로 UI 컨트롤이 작아질 수도 있다. 그림 28의 스케일 팩터가 3.3인 경우가 그러하다. 사용자의 입력을 받는 UI 컨트롤의 경우 사용자가 입력이 가능한 수준의 크기는 최소한으로 보장해야 한다. 이를 위해 핑거 사이즈(Finger Size)의 개념을 도입할 수 있으며 터치스크린 환경에서 사용자 입력을 받는 UI 컨트롤은 핑거 사이즈 이상의 크기를 절대적으로 보장해야만 한다. 핑거 사이즈는 UI 엔진 내에서 시스템 환경 변수를 통해 얻어올 수 있으며 UI 컨트롤의 크기를 계산할 때 핑거 사이즈보다 작은지만 추가로 고려해주면 된다. 실제로 Enlightenment Foundation Libraries UI 프레임워크는 이러한 기능을 구현하고 있다.

    /*
     * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당된다.
     * UI 컨트롤의 기본 동작 및 속성을 구현한다.
     * 코드 3을 기반으로 추가 작성한다. 핵심은 31라인을 확인하면 된다.
    */
    UIObject
    {
        Geometry geom = {0, 0, 0, 0};      //오브젝트의 지오메트리(위치 및 크기)
        Point relative1 = {0, 0};          //relative1 속성
        Point relative2 = {0, 0};          //relative2 속성
        Bool updateGeom = false;           //true인 경우 지오메트리를 새로 갱신한다.
        ...
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 매 프레임마다 UI 엔진에 의해 호출된다.
        */
        update(...)
        {
            //지오메트리가 새로 갱신되어야 할 경우,
            if (updateGeom == true || output.changed) {
                /* output은 오브젝트가 출력되는 영역이다. update의 인자로 전달되었거나,
                   메서드 자체적으로 얻어왔다고 가정하자. 정규 좌표계식을 역으로 계산하여
                   지오메트리 픽셀 값을 구한다. 만약 output 크기 자체가 변경되어도
                   지오메트리는 새로 갱신되어야 한다. 최종적으로 스케일 팩터를 통해 크기를       
                   재조정 한다. */
                geom.x = (output.w * relative1.x) * UIConfig.scaleFactor;
                geom.y = (output.h * relative1.y) * UIConfig.scaleFactor;
                geom.w = (output.w * relative2.x) * UIConfig.scaleFactor - geom.x;
                geom.h = (output.h * relative2.y) * UIConfig.scaleFactor - geom.y;
    
                //핑거 사이즈보다 작으면 크기를 강제로 키운다.
                if (geom.w < UIConfig.fingerSize) {
                   geom.w = UIConfig.fingerSize;
                }
                if (geom.h < UIConfig.fingerSize) {
                   geom.h = UIConfig.fingerSize;
                }
    
                //더 이상 지오메트리가 갱신될 필요가 없다.
                updateGeom = false;
            }
            
            ...
        }
    }
    
    코드 16: 핑거 사이즈를 고려한 UI 컨트롤 크기 결정

    사실, dpi에 대응하여 스케일 팩터를 조정한다면 앞서 살펴본 디바이스 독립적인 픽셀과 동일한 해결책을 제공할 수도 있다. 실제로 Enlightenment Foundation Libraries에서는 동일한 물리적 크기를 보장하기 위해 스케일 팩터를 사용하기도 한다. 조금 더 고민해 본다면 UI 프레임워크 내부적으로 시스템 구동 환경(dpi)에 따라 scaleFactor를 조정한다면 앱 개발자에게 보다 편리한 개발 환경을 제공할 수도 있을 것이다.


    5.3 자동 스크롤

    앱 화면 출력 영역이 콘텐츠의 최소 크기보다 작은 경우 스케일 팩터를 이용하여 크기를 조정할 수도 있지만 앱 화면에 스크롤 기능을 활성화하면 이러한 문제를 피할 수도 있다. 실제로 앱 개발시 비교적 출력 내용이 많은 주 콘텐츠 영역에는 스크롤 기능이 활용되지만, 앱 개발자가 이를 미처 고려하지 못한 경우를 대비하여 UI 프레임워크가 자동으로 스크롤 기능을 추가해 줄 수도 있다. 스케일 팩터로 앱의 UI 스케일을 조정할지라도 최소한 핑거 사이즈는 보장은 되어야 하므로 자동 스크롤은 최악의 경우에도 대안책으로 사용될 수 있다.

    그림 29: 스크롤 기능 활성

    UI 엔진에서는 현재 앱 화면을 구성하는 컨텐츠의 최종 크기를 계산한 후, 컨텐츠의 크기가 윈도우 출력 영역보다 큰 경우 스크롤 기능을 앱 화면 전체 또는 주 컨텐츠를 대상으로 추가해 주면 된다. 사용자 관점에서 사용성이 좋지는 않겠지만 최소한 사용자는 앱 화면을 스크롤하면서 콘텐츠를 이용할 수 있다.


    5.4 어댑티브 UI

    스케일러블 UI는 다양한 화면 크기의 디바이스에서 앱의 동일한 경험을 사용자에게 제공하기 위한 현실적인 방법 중 하나이지만 사용자에게 최고의 경험을 제공할 수 있는 방법은 결코 아니다. 예를 들어, 휴대폰을 위해 디자인한 앱 UI는 디바이스의 특성상 컴팩트한 UI를 고려해야 하는 반면, 데스크탑과 같은 비교적 화면 공간의 여유가 있는 앱은 좀 더 다양한 기능을 사용자들이 동시에 사용할 수 있도록 UI를 구성할 수 있다. 달리 말하면, 휴대폰을 위한 UI는 데스크탑에서는 최선의 디자인이라고 하긴 어렵다. 이를 위해 앱은 어댑티브 UI 방식을 적용하여 해상도 또는 디바이스마다 다른 UI를 구성할 수 있다. 앱은 해상도에 특정 구간을 지정하고 해당 구간의 해상도에서 출력할 UI 레이아웃을 미리 구현할 수 있다. 또는 디바이스 종류에 따라 출력할 UI를 따로 구현할 수 있다. 모바일 기기에서는 단순히 세로 모드(Portrait), 가로 모드(Landscape)에 따라 다른 UI를 구현하기도 한다. 이러한 어댑티브 UI를 이용하는 방식은 사용자에게 화면에 최적화된 UI를 제공하는 것은 물론, 최소한 디바이스 종류마다 프로젝트를 다르게 구성하고 패키지를 제공하는 것보다는 관리 차원에서도 더 효율적이다.

    그림 30: 자마린(Xamarin Forms)의 어댑티브 UI 예

    //디바이스 타입에 따라 앱 요구사항에 맞는 레이아웃을 구성한다.
    switch (UIConfig.deviceProfile)
    {
       //데스크탑 환경의 UI를 구현한다.
        case DeviceProfile.DESKTOP:
            ... 
            break;
        //모바일 환경의 UI를 구현한다.
        case DeviceProfile.MOBILE:
            ...
            break;
        //태블릿 환경의 UI를 구현한다.
        case DeviceProfile.TABLET:
            ...
            break;    
    }
    
    코드 17: 프로파일별 어댑티브 UI 구현 예

    //해상도에 따라 앱 요구사항에 맞는 레이아웃을 구성한다.
    switch (UIConfig.screenResolution)
    {
       case ScreenResolution.WVGA:
       case ScreenResolution.WSVGA:
       case ScreenResolution.HD:
            ... 
            break;
       case ScreenResolution.1080P:
       case ScreenResolution.WUXGA:
            ...
            break;
       case ScreenResolution.2K:
       case ScreenResolution.UWHD:
       case ScreenResolution.WQHD:
            ...
            break;
       default:
            ...
            break;    
    }
    
    코드 18: 해상도별 어댑티브 UI 구현 예

    코드 17, 18처럼 실제로 코드 상에서 디바이스, 해상도별 경우를 따지며 UI 레이아웃을 구성하는 방법도 존재하지만 앱이 해상도 또는 디바이스별로 UI 리소스를 보유하되 이를 폴더별 또는 파일명으로 구분하는 방식도 가능하다. 이 경우 프레임워크에서는 현재 구동 중인 디바이스의 환경에 가장 근접한 리소스를 불러와 화면에 출력할 수 있다. 이 경우 시스템이 컴파일된 앱 바이너리를 실행하여 앱 UI를 구성하는 방식이 아닌 런타임시에 앱 UI를 구성하는 텍스트 정보를 실시간으로 해석하여 구성하는 별도의 리소스(XML, XMAL, JavaScript, JSON과 같은 스크립트 기반의 UI 구성 정보)를 앱이 작성할 수 있도록 UI 프레임워크에서는 기반을 제공해 주어야 한다. 이러한 구현 방식에 대해선 앱과 UI 프레임워크간의 사전 약속이 필요하지만 앱 개발자는 디바이스, 해상도별 경우의 수를 고려한 코드를 직접 작성하지 않아도 되므로 어댑티브 UI 구현이 비교적 안정적일 수 있다. 보다 구체적인 스크립트 기반의 UI 구성 방안은 이후에 따로 언급하도록 한다.


    6. 정리하기

    이상으로 우리는 앱 호환성을 위한 스케일러블 UI의 개념과 구현 방식에 대해서 살펴보았다. 기본적으로 앱 UI를 구성하기 위해서는 절대 좌표계를 이용할 수 있으며 보다 높은 호환성을 위해 정규 좌표계와 상대 좌표계를 사용할 수 있었다. 정규 좌표계는 UI의 위치와 크기를 디바이스 출력 장치에 비례하여 출력할 수 있는 기본적인 방법이며 상대 좌표계는 다른 UI 컨트롤의 크기와 위치에 비례하여 출력할 수 있는 방법이다. 정규, 상대 좌표계의 제약 사항을 보완하기 위해 UI의 크기 제약의 개념도 살펴보았다. UI의 크기 제약을 이용하면 UI의 최소/최대 크기를 보장할 수 있으며 UI가 외양 측면에서 손상되는 상황을 방지할 수 있었다.

    다소 복잡한 UI 레이아웃을 보다 쉽고 안전하게 구성하기 위해 UI 프레임워크에서 제공하는 컨테이너의 개념과 이들의 특성을 살펴보았다. 컨테이너는 다양한 컨셉과 특성을 제공하며 여러 컨테이너를 조합하면 다양한 형태의 레이아웃을 구성할 수 있음을 알 수 있었다. 뿐만 아니라, 컨테이너는 앱 개발자가 실수할 수 있는 여지를 프레임워크 단에서 방지해 주는 장점을 가지고 있었다. 컨테이너에 배치하는 UI 컨트롤의 크기와 위치를 지정하기 위해 가중치와 정렬 개념도 함께 살펴보았다.

    추가로 보다 완성도 높은 앱을 개발하기 위한 고급 기법들을 살펴보았으며 여기에는 디바이스 독립적인 픽셀을 이용하여 dpi에 영향을 받지 않고 동일한 물리적 크기를 출력하는 방식부터 UI의 크기를 동적으로 조정할 수 있는 스케일팩터와 핑거 사이즈 그리고 화면 공간의 부족으로 사용이 불가능한 문제를 해결하기 위한 자동 스크롤과 같은 기능이 있음을 알 수 있었다. 마지막으로 어댑티브 UI를 통해 디바이스에 최적화된 UI 레이아웃을 동적으로 구성할 수 있음을 알 수 있었다.


    저작자 표시 비영리 변경 금지
    신고


    Terra Brandford.
    Material: 4B&H pencil, Sketchbook
    저작자 표시
    신고

    늦가을 밤의 추위는 매서웠다. 그날따라 바닷가의 바람이 거세게 불었다. 새하얀 거품을 일으키며 부산히 몰려오는 작은 파도의 무리는 공허함을 깨는 메아리처럼 아우성쳤다. 싸늘하고 적막하기까지 한 이곳 해변을 찾는 이는 아무도 없을 터, 하지만 갈 곳을 잃은 한 중년의 남자가 마침내 이곳 해변의 모래사장에 도달했다. 모래사장 너머 새롭게 단장한 커다란 여관은 야광을 비추고 있었다. 그는 그 야광을 등지고 공허한 모래밭 위에 서서 일렁이는 암흑 바다를 마주하였다. 파도는 마치 깊은 심연 속에서 아지랑이처럼 피어났고 그에게 점진적으로 다가왔다. 그는 한동안 춤추는 파도의 유희를 말없이 바라보았다. 파도의 파장은 춤추듯 출렁이며 그를 향해 몰려왔지만, 그의 발끝에 도달하기 전에는 모두 소진되고 없어져 버렸다. 그는 그것마저 아쉬운 표정을 보였다.


    여기에 오기 직전 마셨던 벌꿀주의 술기운에 그의 몸은 뜨겁게 달아올랐다. 바닷바람은 뜨거운 그의 몸을 식혀주었다. 그는 기분이 좋았다. 하고 싶은 말이 많았지만, 그의 주변엔 아무도 없었다. 그는 검은 바다 위에 눈에 띄게 대조적인 파도의 하얀 거품을 바라보며 싸늘한 바닷바람을 만끽하였다.


    태어나자마자 온 힘을 다해 그를 향해 달려온 후 아무것도 남기지 않고 사라져 버리는 바다 위의 무수히 많은 파도 조각들. 뜨거운 불은 재라도 남기지만, 저 파도는 무엇을 위해 열정적으로 한순간을 살고 사라져 버리는 걸까. 만일 그들에게도 삶이 존재한다면 일 분도 채 안 되는, 하루살이보다 더 짧은 조각 같은 인생일지라. 만약 파도 같은 생이 그에게 주어진다면 과연 그는 그 순간 무얼 하고 떠날 것인가? 조금은 바보 같은 가정인 줄 알면서도 그는 눈앞에 보이는 파도와 같은 생을 사는 생각에 잠겨 있었다. 하지만, 생각할수록 그에겐 그 어떤 것도 의미가 없을 것만 같았다. 인연? 소유? 명예? 깨달음? 그토록 짧은 생에선 그 어떤 것도 이루지 못할 터. 세상에 의미 없는 흔적을 남길 바엔 그냥 저 파도처럼 아무런 흔적없이 사라져 버리는 것이 가장 나을 것 같았다. 


    그는 성자가 되기에는 인간에게 주어진 시간은 저 파도처럼 너무 짧다고 느꼈다. 인간은 결코 깨달음을 얻을 수 없을 지라. 얻는다 할지언정 미완으로서 죽음을 맞이할 것이다. 하지만, 세상의 성자들이 그러한 미완의 깨달음을 마치 진리인 양 세상에 전파하고 다닌다는 사실에 그는 치가 떨렸다. 그는 결국 인간이 한평생 살지언정 깨달음을 얻진 못한다고 생각했다. 도리어 저 파도처럼 아무것도 남기지 않고 그냥 아무 흔적이 사라지는 것이 어쩌면 성자로서 최선이라는 확신이 생겼다. 간혹, 세속을 피해 아무도 없는 곳으로 떠났다는 어느 성자들의 이야기가 이제는 존경스럽기까지 했다. 그들처럼 되지 못한 자신이 원망스러웠다. 그는 과거에 저지른 자신의 어리석은 잘못을 떨쳐내고 싶었다. 그는 무의식적으로 자신을 향해 몰려오는 파도를 향해 한 걸음씩 다가가기 시작했다. 그의 발이 서서히 바닷물에 잠겼다. 뼛속까지 잠식해오는 냉온의 아찔함을 견디며 그는 그대로 직진했다. 그는 자신의 괴로움과 고통을 눈앞의 검은 바닷속에 모두 내던져야만 했다. 그는 끊임없이 몰려오는 파도에 저항하며 조금씩 전진하였고 어느덧 그의 몸은 검은 바다의 심연 속으로 완전히 잠기고 말았다.


    에드문드는 한때 미덕이 확고한 사람이었다. 그가 성인이 되던 날, 아버지의 부름을 받고 숭고한 이들 앞에서 정의로운 삶을 살기로 맹세했다. 그들 앞에 무릎을 꿇고 기도를 드렸을 때 그의 영혼은 그들로부터 영성을 부여받았다. 신성한 영혼은 그의 정신을 맑게 했다. 그가 이전에 바라보았던 세상은 모두 사라졌으며 모든 만물이 새로운 가치와 희망으로 반짝이는 것을 확인할 수 있었다. 그는 그 빛을 더욱 밝게 비추리라 결의에 가득 찬 뜨거운 가슴을 가지고 세상을 향해 길을 떠났다. 


    영성을 부여받은 그는 성자의 길을 가야만 했다. 영성을 위한 기본 자질은 동정, 희생, 정직, 그리고 숭고였다. 그는 선대 위인들의 숭고한 정신을 따르기 위한 미덕을 한순간도 잊지 않으려고 애썼다. 그는 길을 떠나면서 어렵고 힘든 이들을 동정하고 그들에게 도움을 주기 위해 자기 자신을 희생하는 것도 거리낌 없이 행하였다. 그는 수행을 지속하며 깨달음을 얻었으며 이를 전파하였다. 선행도 지속하였다. 그러자, 어느 순간부터 사람들은 그를 위대한 선지자라고 칭하였다. 그는 십 년을 쉬지 않고 떠돌면서 세상에 자신의 도움의 손길이 필요한 곳은 어디든 찾아갔다.


    그러던 그는 수도 중에 우연히 한 작은 시골 마을에 다다랐다. 그 마을은 서쪽 깊은 산골짜기에 있었는데 외부에는 잘 알려지지 않은 곳이었다. 마을은 겨우 스무 가구가 살 만큼 매우 작았다. 이곳에 막 도달했을 때 에드문드는 떠들썩한 거리의 광경을 목격하였다. 주홍 머리칼의 한 젊은 여인이 거리에서 주민들로 보이는 이들에게 몰매질을 당하고 있었던 것이다. 몰매는 한동안 지속되었다. 사람들은 그 여인을 증오하고 경멸하였다. 이유인즉슨, 그녀가 외도했다는 것이었다. 그것은 그 마을에선 도저히 용납할 수 없는 처사였다. 사람들은 그녀의 인성이 그릇됐다고 하면서 그녀가 돈을 벌러 간 남편에게 배신과 돌이킬 수 없는 상처를 남겼다고 하며 그녀를 마을에서 쫓아내려고 하였다. 여인은 옷이 여기저기 찢기고 온몸이 피멍이 든 채 거리에 쓰러져 흐느껴 울고 있었다. 남자는 그 여인을 그냥 지나칠 수가 없었다. 사람들을 제치고 나선 그는 자신이 에드문드 수도승이라고 하였다. 사람들은 에드문드라는 이름에 모두 놀라 했다. 그 외딴 마을마저 이미 이 존경스러운 선지자의 이름이 알려져 있었다. 그는 그녀를 업고 그 마을을 벗어났다. 


    마을 인근에 버려진 작은 오두막에 그녀를 데려온 남자는 그녀를 잘 보살펴 주었다. 그녀에게 물과 음식을 주었고 흙과 함께 피부에 말라 굳어버린 피를 닦아주었다. 찢어진 그녀의 피부에 약을 바른 후, 약초와 헝겊을 덧대 상처가 아물도록 도와주었다. 그녀는 정신적, 육체적으로 큰 상처를 받은 것처럼 보였고 종일 아무 말도 없었다. 남자는 그녀에게 어떠한 질문도 하지 않았고 그저 옆에서 그녀를 지켜보았다.


    자정을 지날 무렵, 어느 정도 안정을 되찾은 그녀가 살며시 입을 열었다. 선잠에 빠져있었던 에드문드는 바로 잠에서 깨어나 그녀의 말에 귀를 기울일 수 있었다.


    "남편이 돈을 벌기 위해 북쪽의 금광으로 떠난 후, 다른 남자와 눈이 맞았어요. 안데라스라는 청년이었죠. 황금빛 머리칼을 가진 그는 햇살처럼 따사로운 남자였어요. 특히 눈이 예뻤고 미소는 달콤했죠. 어느 화창한 봄날의 토요일 오후, 마을의 호수에서 그와 처음 마주쳤어요. 그는 호숫가에 앉아서 따스한 봄날의 정취를 홀로 만끽하고 있었죠. 저는 한눈에 그가 이방인이라는 것을 알 수 있었어요. 저희 마을은 매우 작기 때문에 마을 사람들은 서로를 잘 알거든요. 그는 떠돌이였는데 우연히 이곳을 찾아왔다고 했어요. 마치 선생님처럼요. 저를 마주한 그는 저에게 마을에 대해 이것저것 물어보았죠. 때마침 홀로 산책에 나섰던 저는 말동무가 생겨서 좋았어요. 그와 함께 호숫가를 거닐며 이야기를 나누었죠. 우리 마을에 대해서 특별히 해줄 이야기는 없었지만, 오히려 그는 저에게 다양한 경험을 공유해 주었어요. 그가 다년간 여행을 다니면서 겪었던 이야기는 저에겐 매우 놀랍고 황홀하기만 했죠. 제가 가보지 못했던 지역의 문화와 음식 그리고 유적지에 대한 이야기는 제가 책에서 읽었던 것과는 아주 달랐어요. 그의 이야기는 훨씬 더 생동감이 넘쳤고 마치 제가 그곳에 간 것과 같은 경험을 선사해 주었죠. 그의 말솜씨는 재능있었어요. 같이 이야기를 나누는 동안 시간가는 줄 몰랐죠."


    아늑하게 타오르는 촛불과 그녀의 붉은 머리칼은 이질감이 없을 정도로 자연스럽게 동화되어 있었다. 그녀의 초췌한 얼굴마저 수줍은 듯 붉게 물들어 있었다.


    "그는 홀로 이곳에 왔기 때문에 결국 저녁에 같이 식사를 하게 됐어요. 그는 저와 더 이야기하면서 시간을 보내고 싶다고 했죠. 아뇨, 사실 제가 먼저 그를 집으로 초대했어요. 사실, 그러면 안 된다는 것을 알고 있었어요. 하지만, 제 몸은 어쩔 수가 없었어요. 저는 이미 알게 되었죠. 제가 이미 그의 매력에 빠져버렸다는 것을. 모르겠어요. 그냥 그는 너무 매력적이었어요. 그의 외모는 물론, 그에게서 풍기는 표현하기 어려운 분위기를 뭐라 표현해야 할지. 선생님께서는 제가 더러운 여자라고 생각하고 있으시겠죠. 하지만, 본능을 제어하는 것이 왜 그렇게 어려웠을까요? 사실 그와 더 같이 있고 싶었고 어두운 밤 그를 저의 집으로 초대하는 것은 제 남편을 기만한 행동이라는 것도 알고 있었죠. 남편은 저 먼 곳에서 하루하루 힘겹게 금광을 캐고 있었겠죠. 하지만, 제가 남편을 떠나겠다고 다짐한 것은 아니었어요. 그저 단 하루, 마치 족쇄에 꽁꽁 묶여버린 듯한 저의 말라버린 영혼에 자유를 주고 싶었어요. 가여운 제 본능에 자유를 허락하고 싶었어요. 제 인생에서 잊어버린 뜨거움을 다시 느껴보고 싶었어요. 차라리 그날 죽어버릴지라도 후회 안 할 만큼 저는 그 갈망에 허덕이었죠. 결국, 그날 밤 그에게 제 몸을 허락하였고 우리는 잊지 못할 한순간을 같이 보냈어요."


    남자는 그녀의 이야기에 귀 기울였고 그녀는 잠깐 말이 없었다. 그녀는 남자를 물끄러미 쳐다보다가 다시 말을 이어갔다.


    "다음날 안데라스는 마을을 떠났어요. 멀리 아무도 모르는 곳으로 말이죠. 정말 그는 바람 같은 인생을 사는 자유로운 영혼의 소유자 같지 않나요? 정착만 해 온 저에게는 조금은 부러울 따름입니다. 이제 저희는 다시는 만날 일은 없겠죠. 그럴지언정, 저는 그를 비난하지 않고 오히려 감사해요. 남편에게는 비밀이지만 저는 제 인생을 살다가 간혹 그날을 회상할지 모르죠. 그와 단 하루, 뜨거운 사랑을 속삭였던 그 순간 말이죠. 일 년 전, 집을 떠난 제 남편을 하루하루 기다리면서 지쳐 말라버린 저의 정신과 육체에 그는 맑은 샘물과도 같았어요. 제 얼굴을 마주하던 그의 부드러운 미소, 어린아이처럼 달콤한 입술. 그리고 따스한 체온. 하지만, 그것보다는 그의 뜨거운 심장 고동 소리가 메아리처럼 퍼져 저의 가슴에 도달하는 순간, 저는 그 요동에 심장이 터질 듯 미쳐버리는 줄 알았죠. 남편을 처음 만난 이래로 잊고 있었던 사랑의 달콤함을 느꼈어요. 정말 오랜만이었죠."


    "정말 아이러니해요. 인간이라는 존재는 육체적 사랑을 갈망하면서도 우리는 그것을 구속해야만 하죠. 그리고 그날 하루, 우리는 우리 둘의 육체와 영혼이 불꽃처럼 타올랐고 마치 전소하듯 사라져 버렸죠. 그리곤 타고 날아가 버린 연기처럼 아무것도 남지 않은 채, 그렇게 우리 둘의 관계는 끝이 났어요."


    그녀의 말이 끝나기가 무섭게 남자가 말했다.


    "여인, 사랑은 고귀하고 육체적 욕망은 신이 주신 자연의 섭리지. 그것을 부정할 순 없다오. 여인의 그 욕망을 그 누구도 비난할 순 없지. 세상 모든 사람도 마찬가지오. 그들은 겉으로 표현하지 않지만, 때로는 내면엔 그러한 욕망을 주최하지 못하고 괴로워한다오. 나라고 다를 바 없지 않소. 하지만, 잘 들어보시오. 이 일이 당신과 그 안데라스라는 청년 둘 사이의 관계로 끝날 수 있다고 생각하시오? 여인의 남편이 이 사실을 알게 된다면 그가 받을 상처는 아무것도 아니란 말이오? 신은 인간에게 육체적 욕망을 부여하기 전에 그것을 조절할 수 있는 정신과 감정의 능력을 주었소. 여인처럼 모두가 그걸 제대로 조절하지 못하면 이 세상은 결코 용납될 수 있는 사회가 되고 말 것이오. 보시오, 이 일로 인해 결국 여인은 마을에서 쫓겨났고 큰 상처를 받지 않았소?"


    "선생님 말씀이 맞아요. 그럴지언정, 저는 인생에서 단 하루, 잊지 못할만한 경험을 했다는 사실은 변함이 없어요. 제게 그렇게 후회할만한 일인지 곰곰이 생각해 보았어요. 어두운 방에서 제 옆에 있지 않은 남편을 기다리며 혼자 보내는 그 시련은 그 어떤 고통과도 바꿀 수 없었어요. 제 꽃은 시들었고 남편을 미워하기 시작하였죠. 제 마음의 상처는 커져만 갔어요. 오히려 지조를 지키는 동안 제 영혼과 육체는 병들어 가는 듯했어요. 지독한 고독의 시련은 제 영혼의 공간에서 독처럼 검게 퍼져나갔죠. 그 날, 안데라스를 호숫가에서 외면했다면, 저는 분명 그토록 달콤한 경험을 제 인생에서 해보지 못했을 거예요. 하지만, 그러지 않았기 때문에 저는 잊지 못할 보석 같은 경험을 하였고 그 날 이후 저는 새로운 하루를 맞이한 듯했어요. 그날을 후회하지 않아요. 제 인생의 한 공간에는 분명 안드레스가 존재할 테니깐."


    여인은 깊은 한숨을 내뱉은 후 계속 말을 이어나갔다.


    "제 남편은 이 사실을 모르고 있겠죠. 그리고 그렇게 영원히 모를지도 몰라요. 정말 그랬으면 좋겠어요. 저 때문에 남편이 힘들고 괴로워하는 것을 저 역시 원치 않기 때문이죠. 하지만 이 세상이 제가 희망한 대로 흘러가는 건 아니겠죠? 물론 그러기 전에 저는 남편에게 편지를 보낸 후, 이 마을에서 멀리 벗어나 남편과 새롭게 시작할 거예요. 마을 사람들의 손이 닿지 않는 곳으로 말이죠. 남편이 이 사실을 모르게 하고 싶어요."


    여인이 남편을 속이고 싶어 한다는 말은 에드문드에게 가시거리처럼 들렸다. 에드문드에겐 거짓이란 있을 수 없었다. 그는 성자의 길을 걷는 동안, 무수히 많은 일을 겪었다. 거짓과 속임수는 결국 세상을 어지럽힌다는 진리는 틀리지 않았었다. 진실은 선하다는 사실을 그는 절대적으로 신뢰하였다. 에드문드는 고개를 가로저었다. 


    "선생님, 저는 제가 무얼 저질렀는지 잘 알고 있어요. 지조가 없다고 말씀하실 수도 있겠죠. 하지만, 그렇게라도 하고 싶었어요. 가질 수 없다는 것을 알면서도. 그 일을 피하지 못했던 저 자신을 원망하면서도 저는 한편으로는 기뻐요. 기쁘다고 표현한다면, 분명 선생님은 저를 이해하지 못하실지도 몰라요. 하지만..."


    "여인, 죄를 지은 자가 처벌을 받아 마땅한 것처럼, 불의에 대한 정의가 없으면 사회는 파괴되고 말 것이오. 마을 사람들이 여인을 손가락질하고 마을에서 쫓아낸 것도 나는 이해할 수 있을 듯하오. 여인이 기쁘다는 사실도 진실이라고 믿소. 하지만 여인의 그 거짓된 욕망이 결국엔 여인을 막다른 골목으로 몰고 갈 것이오. 남을 배신하고 거짓으로 그것을 감추려고 하는 자세는 옳지 않소. 진실은 선하다는 진리를 잊지 마시오. 진실을 구하되 용서로서 상처를 치유해야만 하오. 여인은 고해하고 죄를 뉘우친 후, 남편과 마을 사람들에게 용서를 구해야 하오. 그것이 사람으로서 할 도리라오."


    여인은 고개를 끄덕이다가, 고개를 바닥에 떨구었다. 그녀의 얼굴에서 눈물이 바닥으로 떨어졌다.


    "선생님마저 그렇게 말씀하신다면, 그렇게 하겠어요. 마을 사람들에게 무릎을 꿇고 용서를 빌겠어요. 남편이 돌아오는 날, 그에게 진실을 밝히고 용서를 구하겠어요."


    다음날 이른 새벽, 여인은 성당을 찾아가 고해를 하고 마을 사람들에게 용서를 구하기 위해 마을로 향하였고 에드문드는 오랜 여정으로 인한 피로로 그녀가 오두막에서 떠났는지도 모른 채 깊은 잠에 빠져있었다.


    해는 이미 중천에 떠 있었다. 에드문드는 깊은 잠에서 깨어났다. 방 한구석에 잠을 청했던 여인은 없었다. 그는 그녀가 이미 마을로 향했을 것이라고 짐작했다. 간단한 채비를 마친 그 역시 마을로 천천히 발걸음을 옮겼다.


    마을의 성당을 향해 발걸음을 옮기던 에드문드는 또 다른 부산한 광경을 목격하게 되었다. 사람들이 모여있었는데, 이번에는 뭔가 더 큰 일이 벌어진 것이 틀림없다고 에드문드는 직감했다. 그는 성당 앞에 버려진 듯 놓여있는 피투성이가 된 싸늘한 여인의 시신을 발견하였다. 어젯밤 그와 이야기를 나누던 바로 그 여인이었다.


    "아..."


    그녀를 못마땅하던 주민들이 결국 홧김의 동조로 그녀를 죽음으로 몰고 간 것이다. 에드문드는 전신에 힘이 빠지듯 휘청거렸다. 마치 이 일의 경위가 자신의 책임인 양, 보이지 않는 육중한 무게가 자신의 몸을 짓누르는 듯했다. 거기엔 특정한 살인자도 없었다. 하지만 곧, 그는 이 일의 결말은 이미 예정되어 있었던 것이라고 뒤늦게 깨달았다. 그녀는 죄를 지었으며, 그에 대해 대가를 받은 것이다. 그녀는 경솔했으며 그녀의 이기적인 마음이 이 일을 좌초한 것이었다. 이 결말은 그녀의 잘못에 대한 처벌이었다. 안쓰러웠지만, 이제 돌이킬 수도 없었다.


    에드문드는 그녀의 시신을 그녀가 안드레스를 만났다던 그 호수가 인근에 묻어주었다. 에드문드는 그 마을을 황급히 떠나고 싶었다. 하지만 그것은 성자로서 해야 할 도리가 아니었다. 그는 마을 사람들로부터 새로운 가치를 발견하였고 그의 영성을 사람들에게 실천해야만 했다. 이후, 그는 마을 인근의 버려진 그 오두막을 손질하고 그곳에 머물면서 주민들에게 삶의 지혜와 깨달음을 전파하는데 몰두하는 나날을 보냈다.


    그가 그 마을에 머문 지 반년이라는 시간이 흘렀다. 죽은 여인의 묘지에는 어느덧 풀이 무성했고 호숫가의 버드나무 이파리가 산들거리는 어느 봄날인 무렵, 어느 한 건장한 남자가 마을에 다다랐다. 그는 죽은 여인의 남편이었다. 그 남자는 그의 아내가 죽었는지도 모른 채, 한때 아름답고 사랑스러웠던 자신 아내의 모습만을 머릿속에 떠올리며 한가득 보따리를 매고 즐거운 귀향길을 걸어왔을 것이다. 그가 마을에 도착했을 땐, 마을 사람들은 그를 외면했다. 그의 아내가 자신들에게 죽임을 당했다고 차마 말할 수가 없었다. 그는 영문도 모른 채 한동안 버려져 있었던 자신의 집을 찾아갔으며 곧 그녀가 이 마을에 더는 존재하지 않는다는 사실을 알 수 있었다. 한동안 아내로부터 편지가 오지 않았다는 사실도 이해가 되었고 그는 허망하기 그지없었다.


    한 편, 농사일하고 있었던 에드문드는 죽은 그녀의 남편 복귀 소식을 듣고 곧장 그녀의 집으로 향했다. 에드문드가 집에 도달했을 땐, 남자는 허망한 표정을 지으며 슬픔에 잠겨 있었다. 에드문드는 그에게 그녀의 죽음을 알려주었다. 그는 마치 짐작이라도 하고 있었다는 듯이, 그녀의 죽음은 도대체 무엇을 위한 것이었는지 에드문드에게 바로 되물었다. 에드문드는 그를 그의 아내의 무덤으로 데리고 갔다. 그리고 그녀에게 있었던 일을 그에게 상세히 설명해 주었다. 그는 망연자실한 채 그녀의 무덤 앞에 무릎을 꿇고 한동안 아무 말도 하지 못했다. 


    해가 질 무렵, 호숫가에는 핏빛 석양이 물들어 있었다. 에드문드와 남자는 여전히 여인의 무덤 앞에 서 있었다. 남자는 혼잣말을 하듯 성자에게 말을 했다. 


    "얼마나 그녀가 외로웠을까. 그녀가 그날 어느 젊은 청년과 눈이 맞았다고 했지만, 그녀는 그를 따라 이 마을을 떠나지 않았단 말이오. 그녀는 계속 나를 기다렸을 것이오. 그녀가 그날 외도를 했건 말건, 난 그러한 그녀의 모습조차 사랑할 수 있는 그녀의 남편이오. 그녀를 탓할 것은 아무것도 없었소. 애초에 그녀를 두고 간 건 나였소. 되레 그녀를 지켜주지 못한 내가 죄인이오."


    두 눈에 눈물이 뚝뚝 떨어지는 그 건장한 남자의 두 눈동자에는 괴로움으로 가득 찼다. 에드문드는 깊은 후회와 절망에 빠진 그를 가만히 바라볼 수밖에 없었다.


    그 날 저녁 자정을 넘은 시각, 마을은 고요히 잠들어 있었다. 여인들의 빨래터이자 작은 야외 회의장이 있는 마을의 중심지의 우물가엔 한 남자의 무거운 노랫소리가 구슬프게 울려 퍼졌다.



    그녀가 있는 곳까진 그리 멀지가 않네


    눈을 감은 당신은 먼저 나를 떠나갔고


    어두운 그림자 후회로 흐느끼네


    당신은 멀리서 나를 기다리네


    나는 신께 맹세하오


    지금 나 어둠과 함께 걸어갈 테니


    그녀를 볼 수 있게 해달라고


    촛불이 불타듯 내 마음도 간절하니


    이제 그곳에서 너무 슬퍼하지 마시오


    내 몸은 가벼우니 


    한숨을 쉰 후, 지금 당장 발걸음을 옮긴다



    곧이어 그 작은 마을에는 뜨거운 불길이 치솟기 시작했다. 남자는 마을을 뛰어다니며 미리 준비한 기름과 불통을 불이 탈 만한 곳 여기저기에 마구 던졌다. 헛간의 볏짚에 던졌으며 지붕 위로 던졌다. 집이 겨우 스무 채 되는 이 작은 마을을 지워버리는 일은, 미쳐 실성한 한 남자에게 있어서 결코 어려운 일이 아니었다. 곧이어 마을의 모든 집은 불길에 휩싸였으며 주민들의 비명, 통곡 소리, 울부짖는 소리가 여기저기서 울려 퍼지기 시작했다.


    한 편, 마을의 밖 오두막집에서 잠에서 뒤척이던 에드문드는 멀리 마을 위 붉게 번뜩이는 저녁 밤하늘에 결국 잠에서 깨어났다. 그는 오두막에서 나와 마을을 향해 바라보았다. 삼키듯 타오르던 마을의 화염은 마치 악마 같은 연기로 서서히 뒤덮이고 있었다. 그는 불타는 그 마을의 광경에 눈을 뗄 수가 없었다. 그는 충격에 휩싸였으며 그의 심장은 두려움에 요동을 쳤다. 그는 눈앞의 그 공포를 받아들이기 너무 힘들었다. 불타는 마을에는 죽은 여인의 환영과 절망에 빠진 남편의 환영이 같이 일렁거렸다. 남자는 화염의 도시를 등지고 어두운 산자락 속으로 엉거주춤하며 도망치기 시작했다. 그 무엇도 생각할 수 없었다. 하지만 화염의 불길로부터 도망치는 동안, 그는 그동안 그가 걸어온 성자의 인생이야말로 경솔했고 거짓이었다는 사실을 알아차릴 수 있었다. 진실이 선하다는 그의 신념은 산산이 조각나며 완전히 무너져버렸다. 그 진실은 결국 파멸을 몰고 왔다. 그곳엔 모두를 위한 세상은 결코 없었다. 그의 눈앞엔 어떠한 선도 없었으며 그곳은 증오와 희생만이 존재했다. 그의 신념은 한 작은 마을에 파멸만 몰고 왔을 뿐이었다. 그를 믿던 사람들은 그렇게 화염의 불길 속으로 모두 사라져버렸다. 현실을 받아들이기 어려웠던 에드문드는 혼돈의 나락 속에서 살아남기 위해 발버둥 치듯 그렇게 어둠 속으로 사라졌다.



    저작자 표시
    신고