본문 바로가기

후기/프로젝트

패스트캠퍼스X야놀자 부트캠프: Java 심화과제 1 (위치기반 장소 검색 Java 애플리케이션 개발) 후기

728x90

개요

드디어 패스트캠퍼스X야놀자 부트캠프에서 마주한 첫 과제이다. 솔직히 처음 예상했던 주제는 첫 과제인 만큼, 배열이나 변수 정도로 만드는 간단한 프로젝트일 줄 알았다. 하지만 예상과 달리 외부 API, 즉 카카오 맵을 통해 JSON으로 정보를 받아와서 파싱을 해야 했다. 지금 돌이켜보면 크게 어렵지 않은 로직이지만, 처음 이 과제를 봤을 때만 해도 JSON으로 정보를 주고받는 경험이 거의 전무하던 때라 약간 당황했다. 하지만 JSON은 웹 프로그래밍에서 필수로 다뤄야 하는 영역이고, 이 시점에 배우는 것도 좋은 거라는 마인드로 바꿨다. "어렵다는 건 단지 익숙하지 않다는 것이다"는 격언을 기억하며 과제를 시작했다.

과제 요구사항

크게 아래와 같이 4가지의 요구사항이 있다.

 

1. 검색하고 싶은 위치를 입력한다.

2. 검색 반경을 입력한다.

3. 검색 위치를 기준으로 가까운 거리부터 10개의 '주유소' 또는 '약국'을 출력한다.

4. 콘솔창에 해당 가게의 url를 입력하면 별도의 웹브라우저 창으로 카카오 맵이 열린다.

사용한 API

- kakao developers 에서 제공하는 지도/로컬 API

- JSON 외부 API

- HTTPURLConnection

진행사항

1.  로컬에서 생성한 gradle 프로젝트를 깃허브 원격 저장소와 연결하기

다시 말하지만, 외부 API를 사용해 본 적이 없어 낯설게 느껴졌다. 막막함 때문에 처음 코드를 작성하는 것 자체부터 어떻게 해야 할지 모르겠더라. 하지만 가장 첫 난관은 프로젝트 초기 설정이었다. 아니 처음부터..? 

 

이전에 깃허브를 이용해본 적이 있어서 원격 저장소를 만들거나, 로컬 프로젝트를 생성한 뒤에 해당 프로젝트를 원격 저장소에 올리는 과정 자체는 어렵게 느끼지 않았다. 하지만 예상치 못한 부분에서 가로막혔다. 바로 gradle 또는 maven 빌드 프로젝트를 생성하는 것이었다. 이게 무슨 말이냐... 지금까지 gradle 또는 maven 프로젝트를 생성하려면 인텔리제이의 '새 프로젝트 만들기'의 도움을 받아 손쉽게 빌드 관리 프로젝트를 만들 수 있었다. 하지만 이번 과제는 패스트캠퍼스의 깃허브 원격 저장소로부터 clone 해온 프로젝트로 시작해야만 했다. clone 해온 프로젝트는 이미 .git이 되어 있었지만, gradle 빌드 관리 툴이 설치되어 있지 않았고, 그렇다고 해당 프로젝트 디렉터리 내에 다시 새로운 gradle 프로젝트를 만드는 것도 이상하게 느껴졌다. 왜냐하면 디렉터리의 최상단은 해당 프로젝트의 끝이어야 하는데, 내부에 새로운 프로젝트가 있는 것 자체가 자연스럽지 않았다. 이 부분이 내가 마주한 첫 번째 관문이었다.

 

어떻게 해결할 수 있을지 검색과 동료들에게 조언을 구했다. 여러가지 시도를 해봤는데, 그중 하나가 gradle 프로그램을 설치한 다음, clone 해온 프로젝트 디렉터리 내에서 gradle init 명령어로 gradle 프로젝트를 생성하는 것이다. 하지만 src 디렉터리가 나오지 않는 등 원하는 방향으로 흘러가지 않았다.

 

결국 굉장히 간단한 방법으로 문제를 해결할 수 있었다. 바로 git remote add 명령어였다. 사실 해당 명령어는 알고 있었다. 그러나 "패스트캠퍼스 원격 저장소는 비공개일텐데 개인이 만든 프로젝트와 연결할 수 없을 것이다"란 막연한 전제가 문제 해결의 걸림돌이었던 것이다. 일단 인텔리제이에서 새 gradle 프로젝트를 생성하고, git remote add origin '패스트캠퍼스 원격 저장소 url'를 통해 연결을 시도한다. 이후 git pull origin main으로 원격 저장소의 내용을 가져온 뒤, 내 브랜치를 만들어 작업하면 끝!! 이제 본격적으로 과제를 시작할 수 있었다.

2.  gradle 의존성 주입

과거 프로젝트를 할 땐 gradle.build 파일 또는 pom.xml 파일의 dependencies 블록에 외부 API 관련 코드를 입력하는 이유에 대해 알지 못했다. 그냥 당연히 해야 하는 것으로 넘어 갔지만, 항상 궁금했었다. 이 지적 갈망을 이번 프로젝트에서 풀 수 있었다.

 

일반적으로 프로젝트를 할 땐 다양한 기능을 사용할 때가 많다. 그런데 모든 기능들을 내가 직접 만들어 쓸 순 없기 때문에 다른 사람 혹은 기관에서 만들어 놓은 기능, 즉 외부 API를 빌려 사용하는 게 훨씬 효율적이다. 예를 들어, 자바에서 JSON 관련 기능을 사용하고 싶다면 누군가 미리 만들어 놓은 코드를 가져와 사용하는 것이다. 그리고 이러한 라이브러리들을 모두 직접 다운, 설치하는 건 굉장히 번거로운 일이다. 따라서 사용자들의 편의를 위해 만들어진 도구가 바로 빌드 관리 툴인 gradle, maven, ant이다. 

 

쉽게 말해 gradle.build 파일의 dependencies 블록에 외부 API의 리소스 코드를 넣기만 하면, gradle 이 자동으로 해당 API 기능을 내 프로젝트에 추가해준다. 나는 이번 과제를 진행하면서 해당 메커니즘을 이해할 수 있었다. 그동안 막연하게 사용만 해오던 행위의 숨겨진 이유를 알게 되니 매우 즐거웠다.

 

이번 프로젝트에선 JSON을 통해 카카오 API와 정보를 주고 받기 때문에 org.json 패키지를 추가했다. 구글에서 만든 Gson 라이브러리도 있었지만, 처음 시도하는 만큼 조금 더 클래식(?)한 기능을 사용해보고 싶었다. 그리고 내 자바 프로그램이 카카오 서버에 연결할 수 있도록 HTTPURLConnection 클래스를 선택했다. 이것도 더 편리한 라이브러리들이 있겠지만, 자바에서 기본으로 제공하는 기능을 사용해 보면서 실제 원리를 더 탐구해 보기로 결정했다.

3.  검색에 기준이 되는 위치의 경도, 위도를 받아오자! 

카카오 맵 API에는 키워드를 입력하면 해당 위치의 위도(x)와 경도(y)를 JSON으로 반환하는 서비스가 있다. 해당 위치의 반경내에 서비스를 검색하려면 x, y 값을 저장해야 하니 MyLocation 클래스를 만들었다.

 

public class MyLocation {
    private String address;
    private String latitude;
    private String longitude;
}

 

생각해 보니 검색 위치의 x, y 값을 가져오는 API나, 정해진 반경 내의 상점들 정보를 가져오는 것이나 동일한 카카오 API를 사용하는 것이기 때문에 JSONArray 타입을 반환하는 메소드를 만들어 내부적으로 이용하고자 시도했다.

 

private JSONArray getJsonArray(URL url) throws IOException {

    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setRequestMethod("GET");
    connection.setRequestProperty("Authorization", "KakaoAK " + REST_API_KEY);

    BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()));
    StringBuilder sb = new StringBuilder();
    String line;
    while ((line = br.readLine()) != null) {
        sb.append(line);
    }
    br.close();

    JSONObject jsonResponse = new JSONObject(sb.toString());
    return jsonResponse.getJSONArray("documents");
}

 

getJsonArray() 메소드 내에서 HttpURLConnection 객체를 생성하여 실제 카카오 API와 접속을 시도한다. 매개변수로 받은 URL 타입의 값은 어떤 카카오 API를 사용할지 결정한다. 검색 키워드의 좌표를 반환받을 것인지, 특정 반경 내를 검색할 것인지는 외부에서 만들어진 URL에 따라 달라진다. 결국 해당 메소드의 역할은 매개변수로 받은 url을 가지고 카카오 API에 접속, JSON 결과를 반환하는 것이다.

1)  검색 위치의 좌표를 가져오는 메소드

public MyLocation searchMyLocation(Scanner scan) {
    MyLocation myLocation = new MyLocation();

    while (true) {
        System.out.print("위치 키워드를 입력하세요: ");
        String address = scan.nextLine();
        String encodedAddress = URLEncoder.encode(address, StandardCharsets.UTF_8);  // 공백 에러를 없애기 위한 인코딩 작업

        try {
            URL url = new URL(KEYWORD_API_URL + encodedAddress);
            JSONArray documents = getJsonArray(url);

            if (!documents.isEmpty()) {
                JSONObject firstDocument = documents.getJSONObject(0);
                String latitude = firstDocument.getDouble("y") + "";
                String longitude = firstDocument.getDouble("x") + "";
                myLocation = new MyLocation(address, latitude, longitude);
            } else {
                System.out.println("주소를 찾을 수 없습니다.");
                continue;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        break;
    }
    return myLocation;
}

 

여기서 한 가지 문제가 발생했다. 검색 키워드에 공백을 입력하니 java.io.IOException이 발생한 것이다. 예를 들어 '서울시 강남대로"라고 입력하면, '서울시'와 '강남대로' 사이의 공백이 문제가 된다. 

 

ava.io.IOException: Server returned HTTP response code: 400 for URL: https://dapi.kakao.com/v2/local/search/address.json?query=서울시 강남대로
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1939)
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1525)
	at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:250)
	at org.fastcampus.model.MyLocation.searchMyLocation(MyLocation.java:30)
	at org.fastcampus.Application.searchMyLocations(Application.java:65)
	at org.fastcampus.Application.start(Application.java:24)
	at org.fastcampus.Application.main(Application.java:18)

 

이 문제는 카카오 url로 보낼 때 발생한다. 해결하기 위해서 java.net.URLEncoder 클래스를 사용해 해당 주소를 UTF-8로 인코딩한 뒤에 url에 포함시키면 된다.

 

String encodedAddress = URLEncoder.encode(address, StandardCharsets.UTF_8);  // 공백 에러를 없애기 위한 인코딩 작업

2)  특정 반경의 가게 10곳 검색하기

public JSONArray getServicesList(MyLocation myLocation, int radius, String category) {
    JSONArray documents = null;
    try {
        String latitude = myLocation.getLatitude();
        String longitude = myLocation.getLongitude();

        URL url = new URL(CATEGORY_API_URL + "sort=distance&" + "category_group_code=" + category + "&y=" + latitude + "&x=" + longitude + "&radius=" + radius + "&size=10");
        documents = getJsonArray(url);

    } catch (Exception e) {
        e.printStackTrace();
    }
    return documents;
}

 

해당 메소드는 위도와 경도 값을 url에 포함시켜 다시 카카오 API에 요청해서 JSONArray 타입으로 반환받는다.

 

두 메소드를 보면 모두 결괏값으로 JSONArray 타입을 받아온다. 이는 카카오에서 정한 JSON 포맷이다. 우리가 원하는 값들은 documents라는 JSON의 배열 형태로 저장되어 있다.

 

 

따라서 JSONArray 형태로 받은 반환값을 가지고 우리가 원하는 형태로 바꿔야 한다.

4.  서비스 선택 기능을 추가해보고 싶어. 

기본적으론 '주유소'와 '약국' 중 하나를 선택해서 프로젝트를 진행하면 됐지만, 왠지 욕심이 났다. 그래서 나는 사용자에게 원하는 서비스를 고르는 선택지를 주고 싶었다. 바로 떠오른 키워드는 switch였다. 1번을 누르면 '주유소'를 검색하고, 2번을 누르면 '약국'을 검색할 수 있는 것이다.

 

이를 위해 다형성을 도입해보고 싶었다. 이를 위해 주유소 또는 약국의 반경을 검색하는 핵심 로직을 가진 GasStationServicePharmacyService 클래스의 부모인 Service 클래스를 만들고, searchNearServices() 메소드를 오버라이딩을 시도했다. 그리고 실제 만들어진 자식 객체의 메소드가 실행되기 위해서 switch 문에서 선택한 번호에 따라 다른 자식 객체가 만들어지도록 했다.

 

System.out.println("위치 검색 프로그램입니다.");
System.out.println("원하는 업종 번호를 선택하세요.(예시: 2)");
System.out.println("[1] 주유소,  [2] 약국,  [3] 종료");

switch (num) {
    case 1:
        service = new GasStationService(kakaoSearchAPI);
        break;
    case 2:
        service = new PharmacyService(kakaoSearchAPI);
        break;
    case 3:
        System.out.println("프로그램 종료");
        scan.close();
        System.exit(0);
}

 

부모 Service 클래스를 인터페이스가 아닌 추상 클래스로 만든 이유는, 자식 객체들이 공통적으로 이용할 멤버변수(받아온 가게들을 저장할 자료구조)를 사용하고 싶었기 때문이다. storesList라는 변수명의 ArrayList는 제네릭으로 실제 만들어진 자식 객체의 타입을 가지게 된다.

 

public abstract class Service<T> {
    protected List<T> storesList;
    protected KakaoSearchAPI kakaoSearchAPI;

    public Service(KakaoSearchAPI kakaoSearchAPI) {
        this.kakaoSearchAPI = kakaoSearchAPI;
        storesList = new ArrayList<>();
    }

    public abstract void searchNearServices(MyLocation myLocation, int radius);
    protected abstract void makeStoreInstancesAndSaveToList(JSONArray jsonArray);

    public void printAllList() {
        for (T store : storesList) {
            System.out.print(store.toString());
        }
        System.out.println();
    }
}

 

자식 클래스 GasStationServicePharmacyService의 차이는, 카카오 API에 보낼 url에 들어갈 카테고리 코드이다. 

 

public class GasStationService extends Service {
    private final String CATEGORY_CODE = "OL7";

    public GasStationService(KakaoSearchAPI kakaoSearchAPI) {
        super(kakaoSearchAPI);
    }

    @Override
    public void searchNearServices(MyLocation myLocation, int radius) {
        System.out.println("**주유소 검색 결과**");
        JSONArray documents = kakaoSearchAPI.getServicesList(myLocation, radius, CATEGORY_CODE);
        makeStoreInstancesAndSaveToList(documents);
    }

    @Override
    protected void makeStoreInstancesAndSaveToList(JSONArray jsonArray) {
        for (Object jsonStore : jsonArray) {
            JSONObject storeObject = (JSONObject) jsonStore;
            storesList.add(new GasStation((String) storeObject.get("place_url"), (String) storeObject.get("place_name"), (String) storeObject.get("road_address_name"), (String) storeObject.get("phone"), (String) storeObject.get("distance")));
        }
    }
}
public class PharmacyService extends Service {
    private final String CATEGORY_CODE = "PM9";

    public PharmacyService(KakaoSearchAPI kakaoSearchAPI) {
        super(kakaoSearchAPI);
    }

    @Override
    public void searchNearServices(MyLocation myLocation, int radius) {
        System.out.println("**약국 검색 결과**");
        JSONArray documents = kakaoSearchAPI.getServicesList(myLocation, radius, CATEGORY_CODE);
        makeStoreInstancesAndSaveToList(documents);
    }

    @Override
    protected void makeStoreInstancesAndSaveToList(JSONArray jsonArray) {
        for (Object jsonStore : jsonArray) {
            JSONObject storeObject = (JSONObject) jsonStore;
            storesList.add(new Pharmacy((String) storeObject.get("place_url"), (String) storeObject.get("place_name"), (String) storeObject.get("road_address_name"), (String) storeObject.get("phone"), (String) storeObject.get("distance")));
        }
    }
}

 

JSONArray로 받아온 값은 makeStoreInstancesAndSaveToLIst() 메소드에 의해 VO로 만들어져 해당 클래스의 ArrayLIst에 저장된다.

 

그리고 마지막으로 해당 ArrayList의 모든 원소를 출력하면 끝난다.

5.  느낀 점

다형성 개념도 써보고 나름 객체지향적으로 작성해 보려고 노력했지만 역시 부족한 내공에 어쩔 수 없이 아쉬움이 많이 남는다. 만약 카카오 맵이 아니라 네이버 혹은 구글 맵으로 외부 API를 변경한다면 내 코드는 유지 보수에 좋은가란 의문에 선뜻 답을 내놓기 힘들다.

 

또 각 클래스의 역할과 책임을 분산하는 게 낯설었다. 그래도 exception, model, service, util, view 패키지로 나누어 클래스를 분리했지만, 아직도 처음 Application 클래스에 console 화면을 찍는 역할이 있는 등 제대로 나누는 것에 어려움을 느꼈다. 그리고 예외 처리에 있어 현재는 Application 클래스 내 try-catch 문으로 처리하지만, 이 부분도 깔끔한 역할과 책임의 분리에서 먼 느낌을 받았다.

 

마지막으로 테스트 코드를 짜지 못한 부분도 아쉽다.

 

그럼에도 불구하고, 기능 구현을 위해 최선을 다한 점은 높게 사고 싶다. 과거의 나였다면 처음부터 완벽하게 작성하지 않으면 안 된다는 강박을 가지고 코드를 짜는 것조차 힘겨웠을 테다. 하지만 이젠 그러한 실수를 반복하지 않기로 했다. 좋은 코드는 리팩토링을 통해 완성된다고 믿는다. 처음부터 완벽하게 짜려고 노력하는 건 나 자신을 옭아매는 위험이 있다. 그리고 성장은 여러 시행착오를 겪으며 서서히 완성된다고 생각한다. 그런 면에서 이번 과제의 1차 완성엔 스스로 잘했다고 칭찬하고 싶다. 이제 코드 리뷰를 통해 리팩토링을 해볼 시간이다.