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를 호출하여 데이터를 확인한다.

+ Recent posts