안드로이드 프로젝트를 진행하던 중 로그인 테스트 과정에서 더미 데이터가 아닌 진짜 데이터를 넣어서 테스트했었는데 실수로 그대로 커밋해버렸다. 레포지토리를 삭제하고 다시 생성하기엔 너무 멀리 온 것 같아서 이 파일에 대한 히스토리만 삭제할 수 없을까 구글링을 해서 방법을 찾았다.

 

 

 

특정 파일의 history 삭제

 commit, push된 특정 파일의 히스토리를 모두 삭제할 것이다. 프로젝트 폴더에서 우측 클릭, git Bash를 실행한다.

 

 

git filter-branch -f --index-filter 'git rm --cached --ignore-unmatch {path or fileName}' --prune-empty -- --all

 cmd에서 위 명령어를 실행하는데 {path or fileName} 부분을 삭제하고자 하는 파일 이름으로 치환한다. 

 

 

 잘 모르겠다면 GitHub에서 삭제하고자 하는 파일까지 찾아서 들어간 뒤 우측 버튼을 누르면 경로가 복사되니 그대로 붙여넣어 사용하면 된다. 개인적으로 경로를 입력하는 부분에서 삽질을 많이 했는데 잘 모르겠으면 복붙하는게 제일 확실하게 먹히는 것 같다.

 

 

 모자이크 투성이지만 git이 익숙하다면 어렵지 않게 알아볼 수 있을 것이다. 삭제할 파일 경로와 함께 명령어를 실행하면 경고 문구와 함께 삭제가 진행된다. 

 

 

git push origin master --force

 삭제가 완료되면 변경 내용을 강제로 원격 저장소에 push 해주면 된다.

 

 

 이제 GitHub에 들어가서 해당 파일이 존재하는지 확인한다. 파일이 삭제되어 있다면 성공한 것이다. 이제 불필요한 데이터를 제외한 후 다시 commit, push 해주면 된다.

'Git' 카테고리의 다른 글

[Git] 개인 액세스 토큰 발급받기  (0) 2022.01.26
[Git] 이클립스에서 프로젝트와 Git 연동하기  (0) 2022.01.23

 Spinner를 꾸미기 위해서 background속성에 color를 설정하거나 drawable을 작성하여 적용하면 우측 끝에 보이던 화살표(▼)가 사라지는 걸 볼 수 있는데, 이는 기존에 background 속성에 설정된 리소스가 화살표 이미지를 포함하고 있었기 때문에 발생하는 문제다. 이 화살표는 사용자에게 "여기를 누르면 아래 방향으로 메뉴를 펼칠 수 있다"는 것을 알려주는 직관적이고 중요한 요소라고 생각했기에 화살표를 살려보기로 했다.

 

 여러 가지를 시도해 본 결과 화살표를 배치하는 방법은 크게 두 가지가 있었다. 첫 번째는 우격다짐식 해결법이지만 Spinner를 LinearLayout, ConstraintLayout 등과 같은 ViewGroup의 하위에 두고 ViewGroup의 background를 변경하는 것이다. 이 때 Spinner는 하단 라인이나 기본 테두리가 없는 테마를 적용한 상태여야 한다.

 

 

 다른 방법은 Spinner에 적용할 리소스(drawable)에 화살표 이미지를 포함시키는 것이다.

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <stroke
                android:width="3dp"
                android:color="@color/baltic_sea"/>
            <corners
                android:radius="10dp" />
        </shape>
    </item>

    <item
        android:gravity="right|center_vertical"
        android:width="20dp"
        android:height="20dp"
        android:end="10dp"
        android:drawable="@drawable/baseline_arrow_drop_down_24">
    </item>
</layer-list>

 위와 같이 작성한 xml을 Spinner의 background로 지정해준다. 화살표는 안드로이드 스튜디오 [File - New - Vector Asset]을 통해 생성했기에 같은 이름으로 검색하면 바로 찾을 수 있을 것이다.

 View에 그림자를 넣는 방법은 크게 두 가지가 있다. 하나는 xml에서 그림자 효과를 주려는 뷰에 elevation 속성을 사용하는 것이고 다른 하나는 직접 그림자 효과를 주는 xml을 작성하는 것이다. elevation을 사용하는 방법은 속성 하나만으로 그림자 효과를 낼 수 있어 별도의 구현이 필요없다는 장점이 있지만 그림자 방향이 아래로 고정되어 있으며 방향 변경이 불가능하다는 단점이 있다. 반면 xml로 작성하는 방법은 조금 번거롭더라도 원하는데로 그림자를 커스텀할 수 있다는 장점이 있다.

 

 구현하려는 디자인에 따라 아래로만 그림자를 줘도 충분한 경우가 있는가 하면 그렇지 않은 경우도 있다. 그림자가 퍼지는 방향을 조절하거나 사방으로 퍼지게 하기, 내부 그림자 만들기 등 자유롭게 커스텀하고 싶은 경우 직접 레이아웃을 작성해야 한다. 이 포스팅에서는 그림자를 넣는 방법, 관련 속성, 몇 가지 예제를 다룰 것이다. 

 

 그림자를 적용할 때 주의해야 할 것은 이 리소스가 적용되는 View의 Parent View는 투명하지 않아야 한다는 것이다. 또한 그림자가 바깥을 향할 경우 View의 Parent View는 그림자의 색을 식별할 수 있을 정도로 밝아야 한다. 특히나 프로젝트에 적용한 테마의 배경색이 블랙 계열일 경우 그림자 적용 여부를 육안으로 확인할 수 없으므로 주의해야 한다. 나는 처음 그림자를 적용할 때 검은 배경 위에서 View의 그림자를 적용하려는 멍청한 짓을 해서 한참을 삽질한 경험이 있다.

 

 

 

그림자 그리기에 사용 가능한 속성

 그림자를 직접 그릴 때 그림자 효과를 주는 옵션은 존재하지 않는다. 그림자는 그라데이션 효과를 통해 그려지는 것이고 그려질 방향과 크기까지 모두 직접 설정해야 한다.

  • item : 레이어 역할을 하는 옵션이다. 이 옵션 안에 표시되는 것들은 하나의 레이어에 그려진다고 보면 된다. 먼저 선언한 속성이 가장 아래로, 나중에 선언한 속성이 가장 위에 표시된다.
  • angle : 그라데이션이 그려질 각도를 지정한다. (90 = to top, 180 = to start,  270 = to bottom, 360 = to end)
  • startColor : 그라데이션의 시작 색상을 지정한다.
  • centerColor : 그라데이션의 중간 색상을 지정한다.
  • endColor : 그라데이션의 끝 색상을 지정한다.
  • centerX : 좌, 우에서 그라데이션을 어디까지 그릴지 결정한다. (0.1 ~ 1.0이 들어갈 수 있으며 0.1 = 10%라고 생각하면 된다.)
  • centerY : 상, 하에서 그라데이션을 어디까지 그릴지 결정한다.

 포토샵을 사용해본 사람이라면 item 속성을 레이어라고 이해하면 된다. 배경을 그리거나 특정 방향에서 뻗어 나오는 그림자를 그릴 때마다 item 속성이 하나씩 사용될 것이다.

 

 그림자처럼 보일 그라데이션을 그리는데 사용할 속성은 startColor, centerColor, endColor다. 시작, 중간, 마지막 색상을 각각 검정, 회색, 배경색으로 지정하여 그라데이션을 그려준다.

 

 그림자를 어느 방향으로 그릴지 결정하는 것은 angle이다. top, bottom, left, right 방향으로 그리는 것이 일반적이다.

 

 그림자를 얼마나 그릴지를 결정하는 것은 centerY, centerX다. 이 값은 말로 설명하기 보다 0.01~0.99 까지 값을 바꿔 넣어가며 결과를 눈으로 확인하는 것이 빠를 것다.

 

 만약 생성하려는 리소스가 둥근 모서리, 즉 rdius 옵션을 갖는다면 사용하는 모든 item 옵션에 dadius를 설정해야 한다.

 

 

 

Examples

 내부 그림자 넣기

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:top="1dp"
        android:left="1dp"
        android:right="1dp"
        android:bottom="1dp">
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="8dp"/>
        </shape>

    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="180"
                android:centerColor="#00FF0000"
                android:centerX="0.9"
                android:endColor="#5C000000"
                android:startColor="#00FF0000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="360"
                android:centerColor="#00FF0000"
                android:centerX="0.9"
                android:endColor="#5C000000"
                android:startColor="#00FF0000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="270"
                android:centerColor="#00FF0000"
                android:centerY="0.1"
                android:endColor="#00FF0000"
                android:startColor="#6B000000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="90"
                android:centerColor="#00FF0000"
                android:centerY="0.1"
                android:endColor="#00FF0000"
                android:startColor="#6B000000" />
            <corners android:radius="8dp" />
        </shape>
    </item>
</layer-list>

 

 내부 그림자를 가지면서 모서리가 둥근 배경을 만드는 샘플이다. 배경이 될 첫 번째 item, 두 번째 item부터는 사방의 그림자를 그리는 역할을 하며 첫 번째 item을 제외한 나머지 item들을 모두 투명한 배경으로 설정하였다.

 

 앞서 말했듯 그림자는 그라데이션을 그려서 그림자처럼 보이게 만드는 것이다. 처음은 검정색, 중간은 회색, 마지막은 흰색(배경색)으로 그라데이션을 그려 그림자가 그린 것을 볼 수 있다.

"res/values" 아래에 "arrays.xml" 파일을 생성한다.

 

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="title_array">
        <item>@string/title1</item>
        <item>@string/title2</item>
        <item>@string/title3</item>
        <item>@string/title4</item>
    </string-array>

    <string-array name="icon_array">
        <item>@drawable/ic_1</item>
        <item>@drawable/ic_2</item>
        <item>@drawable/ic_3</item>
        <item>@drawable/ic_4</item>
    </string-array
    
    <string-array name="color_array">
        <item>@color/black</item>
        <item>@color/white</item>
        <item>@color/red</item>
    </string-array>
</resources>

 파일 내에 필요한 데이터를 작성한다. 문자열이나 이미지, 색상 리소스가 들어갈 수 있으며 값을 그대로 작성해도 되고 strings.xml, colors.xml에 작성한 값을 읽어와도 된다.

 

 

리소스 읽어 오기

 리소스를 읽어올 때는 name 값을 사용한다.

 

 문자열 읽기

String[] arr = getResources().getStringArray(R.array.title_array);

 

 

이미지 리소스 읽기

TypedArray typeArr = getResources().obtainTypedArray(R.array.icon_array);

for(int i = 0; i < typeArr.length(); i++)
	typeArr.getDrawable(i);

 

 

색상 코드 읽기

int[] colorArr = getResources().getIntArray(R.array.color_array);

Resources

  • anim : 화면 전환 등에서 애니메이션 효과를 주는 리소스
  • drawable : 정적인 이미지 파일 혹은 View에 적용할 리소스
  • layout : Activity, Fragment, Dialog, Custom View 등의 레이아웃 리소스
  • menu : 리본 메뉴 등을 눌렀을 때 표시될 메뉴 목록을 구성하는 리소스.(문자열 외에 항목마다 표시될 이미지나 옵션을 지정할 수 있음)
  • mipmap : 어플 런처 아이콘 이미지 리소스
  • values : 문자열이나 값(dp, sp, etc)과 관련한 리소스 파일을 모아놓은 디렉터리. 모든 값들이 key-value 형태로 이루어져 있다는 특징이 있으며 여기서 설정한 key값을 통해 자바 코드에서 value 값을 추출해 사용한다.
    - strings.xml : 단순 문자열을 관리
    - arrays.xml : 관련있는 문자열의 집합을 관리
    - colors.xml : 색상값 관리
    - dimens.xml : 공통적, 자주 사용되는 폰트 사이즈나 View의 widh, height 사이즈 등을 관리 
  • values/themes : 어플리케이션에서 사용할 테마 리소스를 관리하는 디렉터리
    - themes.xml : 사용자 시스템 모드가 기본 모드(라이트 모드)일 경우 적용할 테마

    - themes.xml (night) : 사용자 시스템 모드가 다크 모드일 경우 적용할 테마

 

주요 Resource 파일

strings.xml, arrays.xml

string.xml에 기본적으로 등록된 리소스

 어플 내에서 사용되는 문자열 관련 리소스를 관리한다. 여기서 key-value 형태로 문자열 리소스들을 작성한 뒤 문자열이 필요할 때 key값을 통해 문자열을 추출한다. 사실 과제물 정도의 작은 프로젝트를 하고 있다면 이 작업은 매우 귀찮은 작업으로 보일 수 밖에 없다. 이 파일을 활용하지 않아도 문자열을 출력할 수 있음에도  굳이 이 파일을 써야 하는 이유는 무엇일까?

 

 프로젝트를 하다 보면 어플리케이션에서 사용하는 리소스 중 서버에서 받아오는 데이터가 있는가 하면 어플 이름, 비상 연락망(119, 112 등) 등과 같이 좀처럼 바뀔 일이 없기에 처음부터 정해져 있는 데이터도 있다. string.xml의 존재를 모른다면 이러한 데이터들은 대부분 java 코드 내에 하드 코딩되기 마련이다. 안드로이드에서는 이러한 하드 코딩을 권장하지 않고 있으며 이러한 정적인 데이터들은 리소스 파일(*.xml)에 작성한 뒤 자바 코드에서 추출해 사용하는 것을 권장하고 있는데 그 이유는 다음과 같다.

 

  • 지역화(Localization) : 지역화(또는 글로벌화)란 사용자의 언어 설정에 맞게 어플리케이션이 다양한 언어를 제공하는 것을 말한다. 즉 UI에 표시될 리소스(문자열 등)들이 사용자의 언어 설정에 따라 다르게 표시된다는 것인데 이를 가능하게 하려면 하드 코딩이 아닌 각 언어에 맞는 xml 파일을 생성하여 유동적으로 데이터를 읽어와야 한다.
  • 자원의 재사용 : 하드 코딩된 데이터가 여러 곳에서 공통적으로 사용되어야 하는 경우를 생각해보자. 배열을 멤버 변수로 지정한 뒤 접근 제한자를 public으로 하거나 정적 자원(static)으로 설정하여 자원을 공유하는 방법과 같은 코드를 두 개 이상의 클래스에 복사해 각각 사용하는 방법을 떠올릴 수 있다. 전자는 코드를 복잡하게 만들며 후자는 중복된 코드를 생성하기에 그다지 좋은 방법으로 볼 수 없다. 반면 xml에 문자열을 등록한 뒤 추출하여 사용하면 여러 곳에서 자원을 공유할 수 있다.
  • 관리의 용이성 : 하드 코딩된 데이터를 수정해야 한다고 생각해보자. 프로젝트의 규모가 크면 클수록 수정할 데이터를 가진 클래스를 찾기 어려울 것이고 작성한지 시간이 지난 프로젝트일수록 그 난이도는 올라갈 것이다. 반면 xml에서 모든 리소스를 관리하게 되면 UI에서 본 단어를 검색하는 것으로 빠르게 리소스를 찾고 수정할 수 있다.

 

 문자열 리소스 파일을 사용해야 하는 이유를 세 가지 나열했지만 강제성을 발휘하는 것은 지역화 뿐이다. 지역화만큼은 언어 별로 리소스를 따로 관리하지 않으면 불가능하기 때문이다. 다른 두 가지는 만들고 있는 프로젝트의 규모가 작으면 작을 수록 메리트가 없다고 느끼겠지만 프로젝트의 규모가 커질 수록 큰 메리트로 작용한다.

 

 

 

colors.xml

검은색

 안드로이드에서 색상을 사용하려면 #과 함께 16진수 색상 코드 6자리를 포함한 총 7자리를 입력해야 한다.

 

55% 투명도가 적용된 검은색

 여기에 추가로 투명도를 주게 되면 # 다음 2자리 코드가 추가되기 때문에 총 9자리를 사용해야 하는데 아무리 자주 사용하는 색상의 코드라 해도 이를 모두 외우기는 어렵다. 또한 특정 색상이 여러 곳에서 공통적으로 사용되는 경우 매번 코드를 복사/붙여넣기 해야 하고 어플의 색상 컨셉을 바꾼다고 하면 이 코드가 사용된 모든 코드를 찾아 변경해줘야 하는 불편함이 있을 수 밖에 없다.

 

color.xml
color.xml에 등록된 색상 사용

 위와 같은 이유로 우리는 color.xml 파일을 사용하여 색상 코드와 관련된 리소스를 관리한다. 각 색상 코드에 대해 이름(key)을 부여한 뒤 실제로 사용할 땐 코드가 아닌 이름을 사용하는 것으로 코드를 사용할 때보다 훨씬 직관적이고 편하게 사용할 수 있다.

 

 

 

themes.xml

 이 파일에는 어플 전체의 테마나 각 View에 대한 커스텀 테마를 생성할 수 있다. 예를 들어 어플의 모든 버튼에 하늘색 배경과 빨간색 폰트를 적용하고 싶다면 적용이 필요한 View를 가진 모든 레이아웃 파일을 수정하고 다녀야 할 것이다.

 

theme.xml
button에 style 적용

 이러한 경우 테마를 활용하면 일종의 템플릿을 만들어 두고 모든 View에 한 번에 적용하는 것이 가능해진다. 여러 뷰에서 공통적으로 사용하는 속성을 테마에 작성한 뒤 각 뷰의 style로 지정해주면 중복되는 코드들을 없앨 수 있고 수정/관리 측면에서 매우 효율적이다.

 

 테마를 다루는데 있어서 중요한 것 중 하나는 라이트 모드와 다크 모드의 테마 파일을 구분하는 것이다. View에 스타일을 적용할 때 현재 테스트 중인 기기의 기본 설정이 다크 모드일 경우 아래 파일을, 라이트 모드일 경우 위 파일에서 불러오게 된다. 한쪽에만 작성한다고 해서 오류가 나진 않지만 작성하지 않은 모드에서는 스타일이 적용되지 않으므로 주의해야 한다.

 

 


 

 

 나는 안드로이드를 시작했을 무렵 이 파일들을 쓰지 않았다. 구글링을 통해 이 파일들이 어떤 파일인지는 정도는 인지하고 있었지만 과제물같은 작은 프로젝트를 만드는데 이런 작업은 불필요하다고 생각했기 때문이다. 하지만 포트폴리오와 관련된 프로젝트들을 만들면서 파일의 수가 점점 늘어나게 되니 리소스 관리는 꼭 필요하다고 느끼고 있고 지금은 최대한 활용해보려 노력하고 있다.

 Open API는 네이버나 날씨, 책 정보 등 여러 API들을 사용해봐서 꽤나 익숙하다고 생각했는데 이번에 공공 데이터 포털에서 제공하는 데이터를 사용한 안드로이드 프로젝트를 만들면서 벽에 자주 부딫혔던 것 같다.

 

 

 

응답 실패 메시지

com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 1 path $

 

 이번에 프로젝트를 만들며 가장 오래 해결하지 못한 것인 "응답 실패 메시지"다. 내가 사용하려던 API는 응답 메시지 포멧으로 JSON, XML을 지원했고 나는 여러 측면에서 JSON이 더 낫다고 판단해 JSON으로 응답을 받고 있었다. 테스트 과정에서 정상적인 응답을 받는데에는 아무 문제가 없었으나 실패 메시지를 받으려 하면 위와 같은 에러 메시지를 출력했다.

 

 에러 내용을 보면 알 수 있듯 JSON 구문 오류 예외가 발생한 것인데, 응답 성공 시에는 정상적으로 데이터를 파싱하여 객체에 바인딩하였음에도 불구하고 실패 시에만 이런 에러가 뜨니 인지 부조화가 올 것만 같았다. 

 

 

응답 성공 메시지

{
  "header": {
    "resultCode":"00",
    "resultMsg":"NORMAL SERVICE"
  },
  "body":{
    "items":[
      { 
        ..
      }
      ]
      ,"numOfRows":3
      ,"pageNo":2
      ,"totalCount":135
   }
}

 공공 데이터 포털에서 제공하는 API 문서를 보면 응답 메시지는 header와 body로 나뉘어져 있고 header에는 요청에 대한 결과 코드와 메시지가, body에는 요청한 데이터가 들어있음을 볼 수 있다. 성공했을 경우 header의 resultCode에 00이 보내져 오기 때문에 당연히 오류가 났을 때에도 resultCode에 에러 코드가 들어있을 것이라 생각했다.

 

 

 

문제 원인

retrofit = new Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(getOkHttpClient())
        .addConverterFactory(ScalarsConverterFactory.create())
        .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
        .build();

 

 어느 정도 시간을 날리고 에러를 해결하지 못한 상태에서 든 해결법은 직접 파싱이었다. 컨버터가 제대로 동작하지 않는 것 같으니 내가 원시 데이터를 받아서 직접 파싱하자는 생각으로 JSON 데이터를 자동으로 파싱, 바인딩해주는 GSON 컨버터를 지우고 문자열 그대로 데이터를 읽어 주는 Scalar 컨버터를 추가했다.

 

실패 메시지 로그

 그리고 결과를 로그에 출력해보니 위와 같은 메시지가 출력되었는데 나는 그토록 보고 싶었던 에러 메시지 내용이나 에러 코드보다 구문에 눈길이 갔다. 나는 줄곧 JSON으로 통신을 하고 있다고 생각했는데 전달받은 실패 메시지는 XML 형식이었다. 분명히 JSON으로 데이터를 요청했고 요청에 성공했을 때 데이터 형식은 JSON이었지만 실패했을 땐 XML로 데이터가 반환된 것이다.

 

 내가 사용해 본 API들은 모두 성공, 실패 시 응답 메시지 형식이 같았기에 정말 상상도 못한 원인이었다. 이게 무슨 일인가 싶어서 공공 데이터 포털에 문의했고 일주일 만에 받은 답변은 "요청 실패 시, 응답 메시지의 형식은 무조건 XML"이라는 것이었다. 응답 성공 시 반환하는 메시지는 Header의 결과 코드와 결과 메시지 파라미터를 따로 뒀음에도 불구하고 실패 메시지는 그 형식을 따르지 않고 XML을 사용하고 있다는게 이해가 가질 않았다. 그럼 성공 메시지에서 header는 반드시 성공 메시지, 코드만 보내온다는 것인데 그럼 header를 두는게 의미가 없지 않은가..

 

 이런 일이 자주 있는 것인지는 모르겠으나 API 문서에는 이런 말이 일언반구도 없었기에... 좀 허탈했던 것 같다. 어쨌거나 API를 쓰려면 문제를 해결해야 했고 조금 고민한 결과 Scalar로 원시 데이터를 받아 직접 파싱하여 문제를 해결했다.

 

 


 

 

 공공 데이터 포털이 분명 좋은 사이트인 것은 맞는 것 같은데 오탈자나 오기, 사용자에게 불친절한 문서들이 너무 많다. 특히 서비스를 시작한 날짜가 오래되면 오래 될수록 더 심해진다. 그리고 궁금증이 생겼을 때 데이터 포털 자체에 문의하면 하루나 이틀 안에 답해주지만 각 API 관련 단체로 오류 문의를 넣으면 답장을 받기까지 일주일에서 열흘은 걸려서 너무 답답하다.

 

 제일 화나는 것은 복붙을 한 것인지, 데이터 형식에 지원되지 않는 것을 적어놓거나 응답 파라미터에 잘못된 것을 적어 놓은 것들이다. 설마 API 문서에 오류가 있겠어 하고 철썩같이 믿고 했다가 엿을 먹은 적이 한 두 번이 아니었다. 제공하는 문서나 데이터에 좀 더 신뢰성이 있었으면 좋겠다.

 파싱에 사용된 OpenAPI : 국가 자격 종목 목록 정보

 

 공공 데이터 포털에서 제공하는 서비스는 오래되면 오래 될수록 JSON을 지원하지 않는 경우가 많은데 내가 사용하려는 API 역시 XML밖에 지원하지 않았다. 나는 통신에 Retrofit 라이브러리를 사용했기에 XML 관련 컨버터를 찾아본 결과 SimpleXml과 TikXml 두 가지가 있었다. SimpleXML은 예전에도 사용해봤었기에 익숙했지만 Deprecated된 상태였고 TikXml은 현 시점에서 관련 라이브러리가 다운로드되질 않아 사용할 수 없었다. 그래서 결국 Scalar 컨버터를 통해 원시 데이터를 받고 직접 XML을 파싱하기로 했다.

 

body 내 첫 데이터 이후는 생략

 먼저 응답 메시지 구조를 보면 위와 같다. Response 내에 Header와 Body가 존재하며 header에는 결과 코드와 결과 메시지가, body에는 요청한 데이터를 담고 있다. 나는 이 중에서 헤더와 종목 코드(jmcd), 종목 이름(jmfldnm)만을 사용할 것이기에 예제에서는 body에서 두 개의 데이터만 추출할 것이다.

 

 이 예제는 Retrofit의 기본 조작까지는 다루지 않기 때문에 기본적인 지식은 가지고 보는 것을 추천한다.

 

 

DOM4J 구현

build.gradle

dependencies {
    ..
    implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
    implementation 'io.reactivex.rxjava3:rxjava:3.0.7'

    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
    
    implementation 'dom4j:dom4j:1.6.1'
}
  • 1, 2 : 비동기 처리를 위해 RxJava 관련 의존성을 추가한다.
  • 3 : 통신 라이브러리 Retrofit 의존성을 추가한다.
  • 4 : XML을 원시 데이터로 받을 수 있도록 Scalar 컨버터를 추가한다. converter-scalars는 응답을 문자열로 받을 수 있도록 해준다.
  • 5 : Retrofit 통신 결과를 RxJava로 핸들링해주기 위해 관련 의존성을 추가한다.
  • 6 : XML 파싱을 위한 DOM4J 의존성을 추가한다.

 코드가 간결해지는 것을 선호하기도 하고 이제는 RxJava에 더 익숙해져서 RxJava를 사용하지만 RxJava를 잘 모른다면 Call으로 결과를 핸들링해도 상관없다.

 

 

 AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />

<application
    ..
    android:usesCleartextTraffic="true">

 매니페스트 파일에서 인터넷 권한과 HTTP 통신을 허용해준다. 공공 데이터 포털의 API는 HTTPS가 아닌 HTTP를 사용한다.

 

 

MyClient.java

public class MyClient {
    private static Retrofit retrofit;
    public static final String BASE_URL = "http://testapi.q-net.or.kr/api/service/rest/
InquiryListNationalQualifcationSVC";
    public static final String SERVICE_KEY = "";

    public static Retrofit getRetrofit() {
        if(retrofit == null) {
            retrofit = new Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .client(new OkHttpClient.Builder().build())
                    .addConverterFactory(ScalarsConverterFactory.create()) 
                    .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
                    .build();
        }

        return retrofit;
    }
}

 API 상세 페이지에서 제공하는 서비스 URL과 개인으로 발급받은 서비스 키를 변수에 초기화한다. 코드는 최소한으로 구성하였으니 Scalar 컨버터 추가, RxJava Adapter를 추가하는 부분만 눈여겨 보면 된다.

 

 

MyAPI.java

public interface MyAPI {
    @GET("/getList")
    Observable<String> getList(@Query("serviceKey") String serviceKey);
}

 API 인터페이스를 작성한다. RxJava를 사용하기에 리턴 타입이 Call이 아닌 Observerble인 것을 볼 수 있다. 호출하려는 API의 URI와 파라미터를 작성한다.

 

 

Item.java

public class Item {
    private String jmCode;
    private String jmName;

    public Item(String jmCode, String jmName) {
        this.jmCode = jmCode;
        this.jmName = jmName;
    }

    public String getJmCode() {
        return jmCode;
    }

    public String getJmName() {
        return jmName;
    }
}

 데이터 클래스를 작성한다. 자동으로 파싱될 때와 달리 클래스, 멤버 변수, 메서드 레벨에 어노테이션을 사용하지 않아도 된다. 또한 API에서 사용하는 태그 이름을 맞출 필요도 없다. 종목 코드를 기존 "jmcd"에서 "jmCode"로, 종목 이름을 "jmfldnm"에서 "jmName"으로 좀 더 직관적으로 변경해주었다.

 

 필요에 따라 생성자를 작성하고 getter를 작성해준다.

 

 

XmlParser.java

public class XmlParser {
    private String resultCode;
    protected Element itemsElement;

    private final String SUCCESSFUL_CODE = "00";
    private final String TAG = "TAG_XmlParser";

    protected XmlParser(String xml) {
        parseXml(xml);
    }

    private void parseXml(String xml){
        if(xml == null || xml.equals(""))
            return;
            
        try {
            Document document = DocumentHelper.parseText(xml);
            Element responseElement = document.getRootElement(); // header, body
            Element headerElement = responseElement.element("header"); // code, msg
            Element bodyElement = responseElement.element("body"); // items
            resultCode = headerElement.element("resultCode").getText();
            itemsElement = bodyElement.element("items");
        } catch(Exception e) {
            e.printStackTrace();
        }
    }

    public boolean isSuccessful() {
        if(resultCode.equals(SUCCESSFUL_CODE))
            return true;
        return false;
    }
    
    public String getResultCode() {
        return resultCode;
    }
}

 내 경우 프로젝트에 사용하기로 한 API들이 모두 같은 단체에서 제공되고 있었기에 모두 같은 구조를 띄고 있었다. 루트 태그인 <response> 하위에 <header>와 <body>가, header 하위에는 결과 코드와 결과 메시지를, body 하위에는 실제 요청한 데이터를 가진 <items>가 존재한다. 이 클래스에서는 실제 데이터가 들어 있는 items를 제외한 태그들을 파싱하고 items는 이 클래스를 상속받는 클래스들이 처리하도록 하여 중복 코드를 줄일 것이다. 이렇게 하면 이 클래스를 상속받는 하위 클래스들은 item 태그를 파싱하는 코드만 구현하면 된다.

 

 클래스 구조를 살펴보면 이 클래스가 객체로 초기화될 때 생성자 파라미터로 String 형태의 XML, 즉 원시 데이터를 받고 parseXml 메서드를 호출하여 계층별로 데이터를 파싱하는 것을 볼 수 있다. 먼저 response의 header와 body를 분리한 뒤 헤더에서는 성공 여부를 판단할 결과 코드를 파싱하고 body는 API마다 가진 데이터가 다르기 때문에 여기서는 파싱하지 않고 멤버 변수에 초기화해둔다. 상속받는 자식 클래스에서 사용할 수 있도록 접근 제한자를 protected로 지정한다.

 

 isSuccessful 메서드는 응답 메시지에 담겨 있던 결과 코드를 토대로 호출 성공 여부를 확인하는 일을 한다. 멤버 변수로 성공 코드 "00"을 미리 작성해두었는데 파싱한 결과 코드와 이 값을 비교하여 성공 여부를 판단할 것이다. 내가 사용하는 API들은 모두 성공 코드로 "00"를 사용했지만 다른 API는 성공 코드가 다를 수 있으니 제공하는 API 문서를 확인하는 것이 좋다.

 

 요청이 실패할 가능성도 있기에 resultCode에 대한 getter를 작성하여 isSuccessful이 false를 반환했을 때 resultCode를 확인할 수 있도록 했다.

 

 

ItemXmlParser.java

public class ItemXmlParser extends XmlParser {
    private final String TAG = "TAG_ItemXmlParser";

    public ItemXmlParser(String xml) {
        super(xml);
    }

    public List<Item> getItemList() {
        if(itemsElement == null || itemsElement.getText().equals(""))
            return null;
            
        List<Element> elementList = itemsElement.elements();
        List<Item> itemList = new ArrayList<>(elementList.size());

        for(Element e : elementList)
            itemList.add(new Item(
                    e.element("jmcd").getText(),
                    e.element("jmfldnm").getText()));

        return itemList;
    }
}

 위에서 만든 XmlParser를 상속받는 클래스다. 이 클래스는 객체화되는 시점에서 <items>를 제외한 모든 태그의 파싱이 끝난 상태이므로 items 태그를 파싱하는 메서드 getItemList만 작성한다. <items> 하위 태그(item)를 순회하며 데이터를 추출하고 리스트를 반환한다.

 

 

MainActivity.java

public class MainActivity extends AppCompatActivity {
    private final String TAG = "TAG_MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        MyAPI myAPI = MyClient.getRetrofit().create(MyAPI.class);
        Disposable disposable = myAPI.getJmList(MyClient.SERVICE_KEY)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeOn(Schedulers.io())
                .subscribe(xml -> {
                    ItemXmlParser xmlParser = new ItemXmlParser(xml);

                    if(xmlParser.isSuccessful()) {
                        List<Item> list = xmlParser.getItemList();
                    } else {
                        // API 호출 실패
                    }
                }, t -> {
                    Log.e(TAG, "통신 실패(" + t.getMessage() + ")");
                    t.printStackTrace();
                });
    }
}

 사용은 매우 간단하다. ItemXmlParser 클래스를 생성하면서 원시 데이터인 API 요청 결과를 생성자에 넘겨주고 getItemList를 호출하여 데이터를 확인한다.

 안드로이드 어플리케이션에서 하단에 네비게이션을 두고 화면을 이동할 수 있는 BottomNavigationView를 구현하는 방법에 대한 기록 포스팅이다. 스크롤의 유지 여부를 확인하기 위해서 각 프래그먼트에는 컬러 TextView가 배치되어 있다.

 

 

 

1. 프로젝트 생성

 프로젝트를 생성할 때 보면 메뉴에 "Bottom Navigation Views Activity"가 존재하는데 버전이나 언어에 따라 다를 수 있겠지만 나는 이걸로 생성하면 오류가 정말 많이 났다. 처음에 바텀 네비게이션을 구현할 때 이걸로 프로젝트를 만들어 기본 코드를 분석하려 했는데 기본 코드임에도 불구하고 갖은 오류가 발생해 빌드조차 되지 않았었다. 그래서 나는 빈 프로젝트에 바텀 네비게이션을 구현하는 것에 더 익숙하기에 빈 프로젝트로 예제를 시작할 것이다.

 

 

 

2. build.gradle에 의존성 주입 확인

dependencies {
    ..
    implementation 'com.google.android.material:material:1.9.0'
    implementation 'androidx.navigation:navigation-fragment:2.6.0'
    implementation 'androidx.navigation:navigation-ui:2.6.0'
    ..
}

 "com.google.android.material"의 경우 어느 시점부터 프로젝트를 만들면 자동으로 등록되기 시작했기에 버전에 따라 기본적으로 있을 수도 있고 없을 수도 있다. 없을 경우 직접 추가해주면 된다.

 

 추가로 아래 "androidx.navigation*" 두 개를 추가해준다. material은 바텀 네비게이션 자체를 사용하기 위함이었다면 아래 두 개는 바텀 네비게이션으로 프래그먼트를 제어하기 위해 추가한 것이다.

 

 

 

3. menu 생성하기

 바텀 네비게이션의 각 항목(이하 메뉴)에 표시할 이름 리소스 파일을 작성한다.

 

 먼저 [res 폴더 우클릭 → New → Android Resource Directory] 에서 "menu"를 입력해 디렉터리를 생성한다. 그리고 [menu 폴더 우클릭 → New → Menu Resource File]을 통해 메뉴를 작성할 파일을 생성한다.

 

 참고로 "res/navigation" 폴더를 생성하여 만드는 방법도 있는데 그 경우 버전이 높은 navigation에 대한 의존성을 따로 주입해줘야 한다. 최신 버전이긴 하지만 그에 따라 컴파일 버전도 손을 봐야하니 잘 모르면 그냥 넘어가는 것이 좋다.

 

 

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_main_bottom_home"
        android:enabled="true"
        android:icon="@drawable/baseline_home_24"
        android:title="홈"/>
    <item
        android:id="@+id/menu_main_bottom_chat"
        android:enabled="true"
        android:icon="@drawable/baseline_mode_comment_24"
        android:title="대화"/>
    <item
        android:id="@+id/menu_main_bottom_search"
        android:enabled="true"
        android:icon="@drawable/baseline_search_24"
        android:title="검색"/>
    <item
        android:id="@+id/menu_main_bottom_setting"
        android:enabled="true"
        android:icon="@drawable/baseline_settings_24"
        android:title="설정"/>
</menu>

 이 파일은 루트 태그로 <menu>를 가지며 <item> 태그로 각 항목을 생성한다. 각 아이템에는 식별을 위한 id, 메뉴 활성화 여부, 표시할 아이콘, 표시할 텍스트 등을 지정할 수 있다.

 

 여기서 사용된 이미지 아이콘은 모두 안드로이드 스튜디오의 Vector Asset을 통해 생성한 것을 사용하였다.

 

 

4. *.xml 작성하기

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/activity_main_container_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/activity_main_bottom_navigation_view"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/activity_main_bottom_navigation_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:labelVisibilityMode="labeled"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@id/activity_main_container_view"
        app:menu="@menu/main_bottom_nav" />

</androidx.constraintlayout.widget.ConstraintLayout>

 바텀 네비게이션을 적용할 layout xml을 작성한다. 화면에는 단 두 개의 뷰만 존재하는 것을 볼 수 있는데 하나는 Fragment를 교체하면서 보여줄 FragmentContainerView이고 나머지 하나는 표시할 Fragment를 결정할 BottomNavigationView다. 

 

 BottomNavigationView에 사용된 속성을 보면 menu 속성을 통해 앞서 생성한 리소스를 적용한 것을 볼 수 있다. 또 사용된 옵션 중에는 labelVisibilityMode가 있는데 이 옵션은 네비게이션에 표시되는 항목에 대해 이미지만 보일 것인지, 텍스트(Label)도 같이 표시할 것인지 등을 지정할 수 있다. 

 

labelVisibilityMode에 사용 가능한 값

  • auto : 항목이 3개 이하일 경우 labbeled, 4개 이상일 경우 selected
  • labeled : 텍스트를 항상 표시
  • selected : 선택된 항목에 대해서만 텍스트를 표시
  • unlabeled : 텍스트를 항상 숨김

 

 

 

5. Fragment 생성하기

 바텀 네비게이션을 통해 ContainerView에 표시할 Fragment를 네비게이션 항목 수에 맞게 생성한다. 나는 4개의 항목을 생성했으므로 총 4개를 생성했는데 예제인 만큼 구분이 편하도록 이름은 넘버링을 붙여 생성했다.

 

public class FirstFragment extends Fragment {

    public static FirstFragment newInstance() {
        return new FirstFragment();
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_first, container, false);
    }
}

 구현할 기능은 딱히 없으므로 객체를 생성하는 newInstance 메서드만 존재한다.

 

 

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".FirstFragment">

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="300dp"
            android:background="@color/yellow"
            android:gravity="center"
            android:text="Fragment 1-1"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/turquoise"
            android:gravity="center"
            android:text="Fragment 1-2"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/silver"
            android:gravity="center"
            android:text="Fragment 1-3"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/turquoise"
            android:gravity="center"
            android:text="Fragment 1-4"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/yellow"
            android:gravity="center"
            android:text="Fragment 1-5"
            android:textColor="@color/black" />

    </androidx.appcompat.widget.LinearLayoutCompat>

</ScrollView>

 스크롤을 확인하기 위해 ScrollView를 배치하고 내부에 TextView를 5개 배치하였다. 화면이 이동되는 와중에 스크롤이 유지되는지 확인하기 위함이다. 프래그먼트 4개의 레이아웃은 모두 같으며 TextView의 text만 다르다.

 

 

 

Fragment 띄우기

public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;

    private FirstFragment fragment1;
    private SecondFragment fragment2;
    private ThirdFragment fragment3;
    private FourthFragment fragment4;

    private final String TAG = "TAG_MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        setupBottomNavigation();
    }

    private void setupBottomNavigation() {
        BottomNavigationView bottomNav = binding.activityMainBottomNavigationView;
        addFragment(fragment1 = FirstFragment.newInstance());

        bottomNav.setOnItemSelectedListener(item -> {
            int selectedItemId = item.getItemId();

            if (selectedItemId == R.id.menu_main_bottom_chat) {
                if (fragment2 == null)
                    addFragment(fragment2 = SecondFragment.newInstance());
            } else if (selectedItemId == R.id.menu_main_bottom_search) {
                if (fragment3 == null)
                    addFragment(fragment3 = ThirdFragment.newInstance());
            } else if (selectedItemId == R.id.menu_main_bottom_setting) {
                if (fragment4 == null)
                    addFragment(fragment4 = FourthFragment.newInstance());
            }

            replaceFragment(selectedItemId);
            return true;
        });
    }

    private void addFragment(Fragment fragment) {
        getSupportFragmentManager()
                .beginTransaction()
                .add(binding.activityMainContainerView.getId(), fragment)
                .commit();
    }

    private void replaceFragment(int selectedMenuId) {
        FragmentTransaction fragmentTransaction = getSupportFragmentManager()
                .beginTransaction();

        if (selectedMenuId == R.id.menu_main_bottom_home)
            fragmentTransaction.show(fragment1);
        else
            fragmentTransaction.hide(fragment1);

        if (selectedMenuId == R.id.menu_main_bottom_chat)
            fragmentTransaction.show(fragment2);
        else if (fragment2 != null)
            fragmentTransaction.hide(fragment2);

        if (selectedMenuId == R.id.menu_main_bottom_search)
            fragmentTransaction.show(fragment3);
        else if (fragment3 != null)
            fragmentTransaction.hide(fragment3);

        if (selectedMenuId == R.id.menu_main_bottom_setting)
            fragmentTransaction.show(fragment4);
        else if (fragment4 != null)
            fragmentTransaction.hide(fragment4);

        fragmentTransaction.commit();
    }
}

 Fragment를 표시할 Container View를 가진 MainActivity다.

 

 

 Fragment들은 모두 멤버 변수로 선언하였다. 메뉴의 항목이 눌릴 때 마다 매번 새로운 프래그먼트를 만들어줄 것이 아니라면 굳이 멤버 변수로 생성하지 않을 이유가 없다.(배열로 관리해도 된다.)

 

 

 화면이 초기화되는 시점에서는 컨테이너에 최초로 표시되는 프래그먼트가 있어야 하므로 FirstFragment를 컨테이너에 추가한다. 그리고 네비게이션의 각 항목이 선택될 때마다 이벤트를 처리할 리스너를 작성한다. 특정 항목이 선택되면 먼저 선택된 항목이 최초로 선택된 것인지 확인(== null)하여 최초 선택이 아닐 경우 객체를 초기화하면서 컨테이너에 프래그먼트를 추가한다.

 

 조건문까지는 모든 프래그먼트를 최초 1회 실행시켜주는 구문이었다. 그럼 이제 프래그먼트가 재차 눌렸을 때 프래그먼트를 다시 표시하는, 즉 컨테이너의 프래그먼트를 전환해야 하는데 조건문 이후 호출되는 replaceFragment 메서드가 그 역할을 한다.

 

 replaceFragment에서 볼 코드는 show/hide 메서드를 호출하는 부분이다. 컨테이너에 모든 프래그먼트가 추가된 상태에서 특정 프래그먼트만을 보여주기 위해 나머지 모든 프래그먼트를 hide 상태로 만든다.

 

 

 결과를 확인하면 프래그먼트 이동 시 이전 프래그먼트에서 발생한 스크롤이 그대로 유지되어 있는 것을 볼 수 있다.

+ Recent posts