본문 바로가기
야놀자 테크스쿨

[패스트캠퍼스 X 야놀자: 백엔드 개발 부트캠프] JAVA 첫 번째 과제: 위치기반 장소 검색 JAVA 애플리케이션 개발 후기

by 노잼인간이라불립니다 2023. 8. 25.

0. 개요

 저는 현재 패스트 캠퍼스에서 백엔드 개발 부트캠프를 수강하고 있습니다. 개강하고 벌써 한달하고 보름정도가 지났네요. 별로 배운 것도 없는 것 같은데 벌써 과정의 20%가 지났다니 정말 시간이 빠른 것 같습니다.

 평화롭게 코딩을 하던 어느 날 오후, 저에게는 위치기반 장소 검색 Java 애플리케이션 개발이라는 과업이 주어졌습니다. 과제는 총 3일간 이루어졌으며, 나름대로 좋은 코드를 작성해보려고 노력했으나, 생각보다 쉽지 않았습니다. 그럼 본격적으로 제가 진행한 내용들에 대해서 설명하겠습니다.

 

1.  시작이 반일까? ==  설계가 반이다

저에게 주어진 과제는 아래와 같았습니다.

 

애플리케이션 소개

 특정위치(키워드) 주변의 지정된 반경 내에서 약국을 검색하는 JAVA 애플리케이션.

 console 입력에 기반하여 장소를 검색하는 키워드(예시: 경기도 용인시 영광아파트)와 검색 반경(예시: 2000)을 입력하게 되면 해당 반경안에 있는 약국에 대한 정보를 console 출력 해주는 프로그램.

 

제시된 개발 환경 및 조건

1. JAVA 언어로 개발할 것. (버전 8 이상)

2. 지도 API는 Kakao API를 사용할 것.

3. 필요한 의존성은 알아서 추가해서 사용할 것. (예시로 제시된 의존성은 HttpClient(apache)와 org.json였습니다.)

그리고 예시로 된 입출력화면을 제시해주었습니다.

 

개발환경

1. JAVA 17

2. HttpClient(apache) 4.5.13

3. lombok 1.18.12

4. gson 2.10.1

5. slf4j-api 2.0.5, logback-classic 1.4.7

6. junit 5.9.2

7. maven

8. intellij

 

개발 해야 할 기능 흐름 설명

1. 키보드로 특정 위치에 대한 키워드와 반경을 입력.

2. 입력된 키워드 기반으로 KakaoAPI를 호출하여 경도, 위도값을 받아온다.

3. 응답 받은 Json 데이터를 파싱하여 경도, 위도 값을 추출한다.

4. 경도, 위도, 반경 값을 이용하여 해당 위치 반경안에 있는 약국을 검색하도록 KakaoAPI를 호출한다.

5. 입력받은 약국 정보들을 파싱하여 콘솔창에 출력한다. (10개)

6. 출력된 약국 정보들 중 url을 복사하여 console 창에 다시 입력하면 브라우저가 열리면서 해당 페이지가 열린다.

7. exit을 입력하면 종료한다.

 

시작하고 나서..

 위에 제시된 대로 프로젝트 세팅을 마치고, 굉장히 막막했습니다. 왜냐하면 구조가 머릿속에 잘 떠오르지 않았기 때문이죠. 구조를 생각해내는 것에 상당한 시간이 소요되었고, 여러 클래스를 만들었다 지웠다를 반복한 끝에, 기존에 Spring 프레임워크를 이용하면서 자주 사용했던 MVC 모델을 조금 변형해서 적용하기로 결정했습니다.

 하지만 설계에 미숙했던 저는 역시 프로젝트를 진행하면서도 계속해서 프로젝트의 구조를 바꾸면서 진행하였고, 그 결과물은 아래의 그림과 같습니다. 역시 설계는 경험이 필요한 영역이라고 생각합니다!

 

 여차저차 기본 골격이 되는 클래스를 작성하고 나니 그 이후부터는 속도가 나기 시작했습니다. 먼저 MVC 중 Controller 부분을 Controller 패키지로 작성하였고, Model 부분은 enum, exception, medel, service, util 패키지로 구성하였습니다. View 부분은 Main 클래스를 변형한 Console 클래스로 대체하여 작성했습니다.

 그리고 테스트코드는 메인로직이라고 할 수 있는 KakaoAPI를 호출하는 클래스와 Service 클래스 총 2개의 테스트 클래스를 작성하여 테스트 코드 작성을 진행하였습니다.

 

2. 아악 내 눈! 어디 내놓기 부끄러운 나의 코드

 코드를 나름 열심히 작성한다고 했지만, 제출하고 나서 같이 공부하던 친구에게 코드리뷰를 받아보니, 저의 부족한 코드가 눈에 띄게 되었습니다. 이 데이터가 머리속에서 사라지기전에 프로젝트에서 중요하다고 생각되는 부분들과 코드리뷰에서 지적받았던 부분을 기록하고자 합니다!


Controller

public class MapSearchController {

    /**
     * keyword와 radius을 입력하면 keyword 반경 radius 안에 있는 약국을 검색해서 return 해주는 메서드
     *
     * @param keyword 검색 할 키워드
     * @param radius 반경 (1km = 1000)
     * @return api 응답 결과
     */
    public ResponseKakao findPharmacy(String keyword, String radius) {
        MapSearchService mapSearchService = new MapSearchServiceImpl();
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius); // 약국 찾기 호출
        if(responseKakao!=null){
            return responseKakao;
        }
        return null;
    }
}

 

음 컨트롤러는 딱히 지적 받은 부분도 없고, 제가 생각하기에 개선할 부분이 눈에 띄지 않아보입니다. 로직도 굉장히 간단간단합니다. Service 객체를 선언하고 선언된 Service가 findPharmacy메서드를 실행하는 모습입니다. (Spring이었다면, 동적 바인딩을 통해 빈을 찾아 자동으로 DI 해주었을 것입니다.) 


Service

public interface MapSearchService {

    /**
     * 키워드 검색해서 반경 radius안에 있는 약국을 찾는 메서드
     * @param keyword 검색 키워드
     * @param radius 반경 거리
     * @return 응답 결과 객체
     */
    ResponseKakao findPharmacy(String keyword, String radius);


}

 

@Slf4j
public class MapSearchServiceImpl implements MapSearchService {
    /**
     * KakaoAPI를 이용하여 반경 내에 있는 약국을 찾는 메서드
     *
     * @param keyword 검색 키워드
     * @param radius  반경
     */
    @Override
    public ResponseKakao findPharmacy(String keyword, String radius) {
        KaKaoAPI kaKaoAPI = new KaKaoAPI();
        ResponseKakao responseKakao = kaKaoAPI.findByKeyword(keyword); // 키워드로 검색하기
        if (responseKakao != null) {

            String x = responseKakao.getFirstDocumentLongitude(); // 경도
            String y = responseKakao.getFirstDocumentLatitude(); // 위도

            if (x == null || x.isBlank() || y == null || y.isBlank()) {
                return null;
            }

            return kaKaoAPI.findByCategory(x, y, radius, "PM9"); // 카테고리로 검색하기 (약국 찾기)
        }
        return null;
    }
}

 개인적으로 서비스를 설계하는 것에 대해서 많은 고민을 했습니다. 어떻게 설계해야 확장성있는 설계가 될 것인가? 라는 질문을 가지고 고심한 끝에 Service가 실행 할 메서드에 대한 명세를 적어놓은 인터페이스와 구현클래스로 구성하기로 마음먹었습니다. 그러나 인터페이스와 구현클래스가 카카오API에 관한 서비스만 제공할 것이 아니라 네이버든 TMap이든 서비스에 가져와 사용할 수 있도록 하기 위해서, 카카오가 제공하는 API에 대한 기능을 선언해 놓은 클래스는 아래와 같이 별도로 만들어서 선언했습니다. 아래에 선언된 클래스는 Only 카카오가 제공하는 API에 대한 기능만 선언되어 있습니다.

 그리고 ServiceImpl 클래스에서 코드리뷰에서 피드백 받은 부분이 있는데, 캡슐화하는 부분이 조금 아쉽다는 리뷰를 받았습니다. if문을 이용해 x, y 값을 validation 하는 부분을 Service 단에서 로직을 구현할게 아니라 ResponseKakao 객체에서 Validation 하는 메서드를 하나 만들어서 거기서 진행 했다면 좀 더 코드가 깔끔했을 것 같다고 피드백을 받았습니다.

 생각해보니 그렇게 캡슐화를 진행한다면 좀 더 깔끔한 코드가 작성될 것 같다고 생각했습니다.


Kakao API

@Slf4j
public class KaKaoAPI {

    /**
     * keyword로 장소 검색하기
     *
     * @param keyword 검색 할 키워드
     * @return
     */
    public ResponseKakao findByKeyword(String keyword) {
        // 유효 값 여부 처리
        if (keyword == null || keyword.isBlank()) {
            log.warn("findByKeyword 메서드: 입력 키워드가 공백입니다.");
            return null;
        }

        // 키워드 겁색시 사용하는 기본 url
        String apiUrl = "https://dapi.kakao.com/v2/local/search/keyword";

        // 카카오 API와 통신하는 로직
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) { // try이 문이 종료될 때 httpClient도 종료된다. (메모리 누수 방지)
            /*
             * 기본 url에 쿼리스트링을 추가하여 호출 시 사용할 완성된 url을 만듬
             */
            URI uri = new URIBuilder(apiUrl)
                    .setParameter("query", keyword)
                    .build();

            /*
             * get 요청을 할 객체를 생성하고 API-key, Content-Type과 같은 헤더를 추가함.
             */
            HttpGet httpGet = new HttpGet(uri);
            httpGet.addHeader("Authorization", Key.KAKAO.getKey());
            httpGet.addHeader("Content-Type", "application/json;charset=UTF-8");

            /*
             * 실제로 요청을 하고 그 응답을 받는다.
             */
            try (CloseableHttpResponse response = httpClient.execute(httpGet)) { // try 문이 종료될 때 response는 종료된다.
                HttpEntity entity = response.getEntity(); // body 만을 가져옴
                String responseBody = EntityUtils.toString(entity); // response body to String
                return GsonUtils.fromJson(responseBody, ResponseKakao.class); // ResponseKakao 객체로 역직렬화
            }
        } catch (Exception e) {
            log.error("findByKeyword 메서드에서 error 발생: {}", e);
        }

        return null;
    }


    /**
     * 카테고리로 장소 검색하기
     *
     * @param x                 경도
     * @param y                 위도
     * @param radius            반경
     * @param categoryGroupCode 검색할 카테고리 코드
     * @return api 응답 결과
     */
    public ResponseKakao findByCategory(String x, String y, String radius, String categoryGroupCode) {

        // 유효 값 체크
        if (x == null || x.isBlank() || y == null || y.isBlank() || radius == null || radius.isBlank()) {
            log.warn("findByCategory 메서드: x 좌표 또는 y 좌표 또는 카테고리 코드가 공백이거나 null 입니다.");
            return null;
        }
        // 유효 값 체크
        if (Integer.parseInt(radius) < 0 || Integer.parseInt(radius) > 20000) {
            log.warn("findByCategory 메서드: 유효값을 벗어난 radius입니다. (유효범위 0~20000)");
            return null;
        }

        /*
         * 카테고리로 검색할 때 사용하는 api 기본 url
         */
        String apiUrl = "https://dapi.kakao.com/v2/local/search/category.json";

        /*
         * kakao API를 호출하여 response body를 ResponseKakao객체로 역직렬화 하여 리턴.
         */
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) { // try이 문이 종료될 때 httpClient도 종료된다. (메모리 누수 방지)
            /*
             * 기본 url에 쿼리스트링을 추가하여 호출 시 사용할 완성된 url을 만듬
             */
            URI uri = new URIBuilder(apiUrl)
                    .setParameter("x", x)
                    .setParameter("y", y)
                    .setParameter("radius", radius)
                    .setParameter("category_group_code", categoryGroupCode)
                    .setParameter("size", "10")
                    .build();

            /*
             * get 요청을 할 객체를 생성 후 헤더 추가.
             */
            HttpGet httpGet = new HttpGet(uri);
            httpGet.addHeader("Authorization", Key.KAKAO.getKey());
            httpGet.addHeader("Content-Type", "application/json;charset=UTF-8");

            /*
             * 요청을 하고 그 응답을 받는다.
             */
            try (CloseableHttpResponse response = httpClient.execute(httpGet)) { // try 문이 종료될 때 response는 종료된다.
                HttpEntity entity = response.getEntity(); // body 만을 가져옴
                String responseBody = EntityUtils.toString(entity); // response body to String
                return GsonUtils.fromJson(responseBody, ResponseKakao.class); // ResponseKakao 객체로 역직렬화
            }
        } catch (Exception e) {
            log.error("findByKeyword 메서드에서 error 발생: {}", e);
        }
        return null;
    }
}

다음으로는 Kakao API의 기능을 구현해놓은 코드입니다. 이 부분에서도 마찬가지로 피드백이 있었는데요. Spring이나 SpringBoot로 개발할 경우 RestTemplate이나 WebClient로 HTTP 통신을 하게 됩니다.

 여기서 잠깐! 이 피드백을 받으면서 이전 회사에서 개발할 때 팀장님께서 세팅해주신 RestTemplate이 머릿속에 떠올랐습니다. 그 코드에서는 Restemplate이라는 Configuration을 작성해놓고, Configuration 안에 RestTemplate에 대한 여러가지 설정 및 최종적으로는 Bean으로 띄우게 끔 메서드를 작성하고, 필요할 때마다 DI를 받아서 사용했던 기억이 있습니다.

 다시 돌아와서 이 애플리케이션에서는 apache에서 제공하는 HttpClient를 DI받아서 사용했는데요. 이 통신하는 부분을 RestTemplate과 마찬가지로 Static으로 만들어놓고 사용하면 좀 더 코드가 깔끔하지 않았겠느냐는 피드백을 받았습니다. 확실히 위에서 Bean으로 만들어서 싱글톤으로 사용하는 것처럼 Static으로 올려놓고 필요할 때마다 필요한 파라미터를 받아 연결하여 사용하는 것이 좀 더 효율적이고, 코드도 더 깔끔해졌을 것이라고 깨달았습니다.

 이게 이 클래스의 마지막 피드백이라고 생각했던 순간! 두 번째 피드백의 역습이 시작됐습니다. 두 번째 피드백은 역시 캡슐화와 관련된 피드백 이었습니다. 메서드의 파라미터와 메서드 첫 부분에 Validation 체크 하는 부분인데요. 파라미터가 많은 경우에는 나열해서 사용할 것이 아니라 하나의 객체로 묶어서 전달하는게 코드도 깔끔하고 관리도 쉬울 것이라고 1차적인 피드백을 받았구요.

 2차로는 Validation 체크도 그 새로 만든 객체안에 메서드로 선언해서 체크했다면 좀 더 깔끔한 코드가 되었을 것이라고 피드백을 받았습니다. 제가 생각해도 그 방법이 훨씬 더 코드가 간결하고 깔끔해보입니다. 그리고 만약 같은 파라미터를 받는 기능이 여러개라면 코드의 재사용성도 올라가겠죠?

 


Utils

public class GsonUtils {
    /**
     * 인스턴스 하나를 생성 해 놓고 필요할 때마다 사용하는 것이 편리하여 static final로 선언.
     */
    private static final Gson gson =
            new GsonBuilder().setPrettyPrinting()
                    .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();

    /**
     * 인스턴스를 Json 문자열로 변환
     *
     * @param obj 직렬화할 object
     * @return 직렬화된 json 문자열
     */
    public static String toJson(Object obj) {
        return gson.toJson(obj);
    }

    /**
     * Json 문자열을 인스턴스로 변환
     *
     * @param json json 문자열
     * @param classOfT object type class
     * @return json 문자열의 정보를 포함하고 있는 매개변수로 넣은 class 타입의 인스턴스
     * @param <T>
     */
    public static <T> T fromJson(String json, Class<T> classOfT) {
        return gson.fromJson(json, classOfT);
    }

}

이 부분은 딱히 피드백 받은 내용이 없습니다. 직렬화와 역직렬화를 조금 더 편리하게 이용하기 위해서 Google에서 개발한 Gson 라이브러리를 사용했습니다. 그리고 Static 메서드로 선언하여 필요할 때 마다 가져다 사용했습니다.


Kakao 응답 결과 관련 객체들

@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
public class ResponseKakao {

    /**
     * 응답 결과 (실제 필요한 데이터)
     */
    private List<Document> documents = new ArrayList<>();

    /**
     * 응답 관련 정보 (페이지 번호, 총 응답 갯수)
     */
    private Meta meta;

    /**
     * 응답 결과의 제일 첫 번째 문서를 찾아 x좌표(경도)를 리턴해주는 메서드
     *
     * @return x(경도) 좌표
     */
    public String getFirstDocumentLongitude() {
        Document firstDocument = getFirstDocument();
        if (firstDocument != null) {
            return firstDocument.getX();
        }
        log.warn("위치 검색결과가 없습니다. (x좌표가 null 입니다.)");
        return null;
    }

    /**
     * 응답 결과의 제일 첫 번째 문서를 찾아 y좌표(위도)를 리턴해주는 메서드
     *
     * @return y(위도) 좌표
     */
    public String getFirstDocumentLatitude() {
        Document firstDocument = getFirstDocument();
        if (firstDocument != null) {
            return firstDocument.getY();
        }
        log.warn("위치 검색결과가 없습니다. (y좌표가 null 입니다.)");
        return null;
    }

    /**
     * 응답 결과의 제일 첫 번째 문서를 찾아서 리턴해주는 메서드
     *
     * @return 제일 첫 번째 document
     */
    public Document getFirstDocument() {
        if (!documents.isEmpty()) {
            return documents.get(0);
        }
        log.warn("위치 검색결과가 없습니다. (document가 null입니다.)");
        return null;
    }
}

 이 부분도 딱이 크게 피드백 받은 것은 없습니다. 캡슐화를 통해 좀 더 코드를 간결하게 하고 싶어서 메서드를 몇 개 더 추가했습니다.

 

@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class Document {

    /**
     * 장소 ID
     */
    @SerializedName("id")
    private String id;

    /**
     * 장소명, 업체명
     */
    @SerializedName("place_name")
    private String placeName;

    /**
     * 카테고리 이름
     */
    @SerializedName("category_name")
    private String categoryName;

    /**
     * 중요 카테고리만 그룹핑한 카테고리 그룹 코드
     */
    @SerializedName("category_group_code")
    private String categoryGroupCode;

    /**
     * 중요 카테고리만 그룹핑한 카테고리 그룹 코드
     */
    @SerializedName("category_group_name")
    private String categoryGroupName;

    /**
     * 전화번호
     */
    @SerializedName("phone")
    private String phone;

    /**
     * 전체 지번 주소
     */
    @SerializedName("address_name")
    private String addressName;

    /**
     * 전체 도로명 주소
     */
    @SerializedName("road_address_name")
    private String roadAddressName;

    /**
     * X 좌표 혹은 경도(longitude)
     */
    @SerializedName("x")
    private String x;

    /**
     * Y 좌표 혹은 위도(latitude)
     */
    @SerializedName("y")
    private String y;

    /**
     * 카카오맵 url 주소
     */
    @SerializedName("place_url")
    private String placeUrl;

    /**
     * 거리
     */
    @SerializedName("distance")
    private String distance;

    /**
     * meter를 km로 변경해주는 메서드
     *
     * @return km 문자열
     */
    public String meterToKm() {
        if (!this.distance.isBlank()) {
            return String.valueOf(String.format("%.3fkm", Integer.parseInt(this.distance) * 0.001));
        }
        return null;
    }

}
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class Meta {

    /**
     * 	검색된 문서 수
     */
    @SerializedName("total_count")
    private Integer totalCount;

    /**
     * total_count 중 노출 가능 문서 수 (최대값: 45)
     */
    @SerializedName("pageable_count")
    private Integer pageableCount;

    /**
     * 현재 페이지가 마지막 페이지인지 여부
     * 값이 false면 다음 요청 시 page 값을 증가시켜 다음 페이지 요청 가능
     */
    @SerializedName("is_end")
    private boolean isEnd;

    /**
     * 질의어의 지역 및 키워드 분석 정보
     */
    @SerializedName("same_name")
    private SameName sameName;


}
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class SameName {
    /**
     * 질의어에서 인식된 지역의 리스트
     * (예: '중앙로 맛집' 에서 '중앙로'에 해당하는 지역 리스트)
     */
    @SerializedName("region")
    private List<String> region;

    /**
     * 질의어에서 지역 정보를 제외한 키워드
     * (예: '중앙로 맛집' 에서 '맛집')
     */
    @SerializedName("keyword")
    private String keyword;

    /**
     * 인식된 지역 리스트 중 현재 검색에 사용된 지역 정보
     */
    @SerializedName("selected_region")
    private String selectedRegion;

}

위 객체들은 Kakao API에서 제공하는 응답 구조에 따라 설계한 클래스들입니다. 이 부분도 데이터를 담는 역할을 주로 하는 클래스들이기 때문에 특별히 피드백 받은 내용은 없습니다.


Exception

public class BlankException extends Exception {
    public BlankException(String message) {
        super(message);
    }
}

 이번 프로젝트에서는 console 입력값의 자유도가 상당히 높아서 그에 따른 예외처리를 하기 위해서 선언한 클래스이지만, 결과적으로는 예외처리 부분은 null을 이용해서 처리했고, 정작 만든 Exception 클래스는 사용하지 못했습니다.

 이 부분도 피드백 받은 부분이 있는데 친구는 @ControllerAdvice와 @ExceptionHandler에 관한 이야기를 꺼냈습니다. 분명 같이 공부할 때 이야기 나눈적이 있다고 하는데, 제 머릿속에 그러한 데이터는 존재하지 않았습니다..(당황) 역시 한번 만들어보면서 적용해야 머릿속에도 오래 남는 모양입니다. 다음 번에 Spring을 통해 프로젝트를 진행하게 된다면 저 2가지 어노테이션을 이용하여 Global Exception을 처리하는 것을 공부해야 하겠습니다!


Console

@Slf4j
public class Console {
    private static final BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

    public static void main(String[] args) {

        /*
         * 위치 키워드 입력
         */
        String keyword = inputKeyword(br);

        /*
         * 검색 반경 입력
         */
        String radius = inputRadius(br);

        /*
         * 검색 결과 출력
         */
        System.out.println("입력한 위치 키워드:" + keyword);
        System.out.println("검색반경:" + String.format("%.1fkm", Integer.parseInt(radius) * 0.001));
        System.out.println();

        /*
         * 결과 출력
         */
        MapSearchController mapSearchController = new MapSearchController(); // controller 생성
        ResponseKakao responseKakao;
        List<Document> documents;
        responseKakao = mapSearchController.findPharmacy(keyword, radius); // 약국 찾는 메서드 호출

        // 키워드로 검색하고, 장소가 결과로 나오지 않았을 경우 유효한 장소가 나올 때까지 input값을 다시 받는다.
        while (checkResponseKakao(responseKakao)) {
            System.out.println("조회된 결과가 없습니다. 주소와 반경을 다시 입력해 주세요!");
            keyword = inputKeyword(br);
            radius = inputRadius(br);
            responseKakao = mapSearchController.findPharmacy(keyword, radius);
        }
        documents = responseKakao.getDocuments(); // 장소 정보 가져오기


        /*
         * 검색 결과 출력
         */
        System.out.println();
        System.out.println("**약국 검색결과**");
        for (Document document : documents) {
            System.out.println("장소 URL(지도 위치):" + document.getPlaceUrl());
            System.out.println("상호명:" + document.getPlaceName());
            System.out.println("주소:" + document.getAddressName());
            System.out.println("전화번호:" + document.getPhone());
            System.out.println("거리:" + document.meterToKm());
            System.out.println("----------------------------------------------");
        }


        /*
         * url 입력 및 브라우저 오픈
         */
        try {
            String url;
            while (true) {
                System.out.print("kakaomap URL(장소 URL):");
                url = br.readLine();
                if (url.equals("exit")) {
                    break;
                }

                if (!isValidUrl(url)) {
                    log.warn("유효한 url 주소가 아닙니다. 다시 입력해 주세요!");
                    continue;
                }
                openBrowser(url);
            }
            System.out.println("프로그램 종료");
        } catch (Exception e) {
            log.error("장소 URL 입력 error: {}", e);
        }

    }

    public static boolean checkResponseKakao(ResponseKakao responseKakao) {
        return responseKakao == null || responseKakao.getDocuments().isEmpty();
    }

    /**
     * url이 유효한지 확인해주는 메서드
     *
     * @param url input값으로 입력된 url 주소
     * @return 유효여부 T/F
     */
    public static boolean isValidUrl(String url) {
        Pattern urlPattern = Pattern.compile("^https?://(www\\.)?([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}(/.*)?$");

        Matcher matcher = urlPattern.matcher(url);
        return matcher.matches();
    }

    /**
     * 위치 키워드 입력을 재 사용하기 위해서 만든 메서드
     *
     * @param br inputData 읽기용 버퍼
     * @return 위치 키워드
     */
    public static String inputKeyword(BufferedReader br) {
        System.out.print("위치 키워드를 입력하세요:");
        String keyword = "";
        try {
            keyword = br.readLine();
            while (isBlank(keyword)) {
                System.out.print("위치 키워드를 입력하세요:");
                keyword = br.readLine();
            }
            System.out.println();

        } catch (Exception e) {
            log.error("위치 키워드 입력 error: {}", e);
        }
        return keyword;
    }

    /**
     * 검색 반경 입력을 재 사용하기 위해서 만든 메서드
     *
     * @param br inputData 읽기용 버퍼
     * @return 검색 반경
     */
    public static String inputRadius(BufferedReader br) {
        System.out.print("검색 반경을 입력하세요(1000:1km):");
        String radius = "";
        try {
            radius = br.readLine();
            while (isBlank(radius) || !isNumeric(radius)) {
                System.out.print("검색 반경을 입력하세요(1000:1km):");
                radius = br.readLine();
            }
            System.out.println();
        } catch (Exception e) {
            log.error("검색 반경 입력 error: {}", e);
        }
        return radius;
    }

    /**
     * url을 받아 브라우저를 열어주는 메서드
     *
     * @param url url 주소
     */
    public static void openBrowser(String url) {
        try {
            if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
                Desktop.getDesktop().browse(new URI(url));
            } else {
                log.warn("브라우저를 여는 것이 지원되지 않습니다.");
            }
        } catch (Exception e) {
            log.error("openBrowser error: {}", e);
        }
    }

    /**
     * 입력된 문자열이 공백인지 아닌지 체크하는 메서드
     *
     * @param input 입력 문자열
     * @return 공백 여부 T/F
     */
    public static boolean isBlank(String input) {
        if (input.isBlank()) {
            log.warn("input 값이 공백입니다. 다시 입력 해주세요.");
            return true;
        }
        return false;
    }

    /**
     * 입력된 문자열이 숫자인지 아닌지 확인하는 메서드
     *
     * @param input 입력 문자열
     * @return 숫자인지 아닌지 여부 T/F
     */
    public static boolean isNumeric(String input) {
        if (!input.matches("\\d+")) {
            log.warn("input 값이 숫자가 아닙니다. 다시 입력 해주세요.");
            return false;
        }
        return true;
    }

}

 콘솔코드는 필요하다고 생각하는 것들은 메서드화 시켜서 코드를 간결하게 하려고 노력은 했지만, 전반적으로 리팩토링이 부족해보이는 코드라고 피드백 받았습니다. 실행 부의 코드를 딱 봤을 때 많이 어지럽긴합니다..

이 부분은 시간을 많이 투입하여 이것 저것 고려해보면서 리팩토링하는 시간을 가졌어야 했지만, 아쉽게도 개발시간이 3일밖에 주어지지 않았고, 이 외에도 여러과업들이 존재했기에 마음속으로 "그래 이정도면 됐지" 라고 스스로 타협을 했던 것 같습니다. 역시 소프트웨어 개발에는 장인정신이 필요합니다!

 


콘솔 입출력 실행 모습

 전반적으로 과제에서 요구했던 기능들을 모두 개발했다고 생각합니다. 그 기능의 흐름은 위에 기능 흐름에 적어 놓았습니다. 그래도 간단하게 다시 한번 설명해드리자면,

1. 입력키워드와 검색반경을 입력합니다.(입력 키워드 같은 경우는 제가 초등학교 때 까지 거주했던 곳을 적어보았습니다.)

2.입력 키워드와 검색 반경을 입력하게 되면 해당 검색 반경안에 있는 10개의 약국들의 정보가 콘솔에 출력됩니다.

3.콘솔에 출력된 정보 중 url을 다시 입력하게 되면, 해당 시스템의 기본 브라우저로 적용되어 있는 브라우저를 통해 해당 url 주소의 페이지가 열리게 됩니다. (계속해서 url을 입력하면 계속 해서 해당 페이지가 열립니다.) 

4. exit을 입력하게 되면 프로그램이 종료됩니다.

 

이렇게 입출력의 흐름이 진행이 됩니다.


테스트 코드

@Slf4j
class KaKaoAPITest {

    /**
     * findByKeyword(String keyword)
     * tc1 : keyword가 null일경우 null값이 리턴되어야 한다.
     */
    @Test
    void findByKeywordTC1() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String keyword = null;

        //when
        ResponseKakao responseKakao = kakaoAPI.findByKeyword(keyword);

        //then
        Assertions.assertEquals(null, responseKakao);
    }

    /**
     * findByKeyword(String keyword)
     * tc2 : keyword가 공백일경우 null값이 리턴되어야 한다.
     */
    @Test
    void findByKeywordTC2() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String keyword = " ";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByKeyword(keyword);

        //then
        Assertions.assertEquals(null, responseKakao);
    }

    /**
     * findByKeyword(String keyword)
     * tc3 : keyword가 문자열 일 경우 doucument가 1개 이상 리턴되어야 한다.
     */
    @Test
    void findByKeywordTC3() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String keyword = "광주 서구 종원아파트";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByKeyword(keyword);

        //then
        responseKakao.getDocuments().stream().forEach(o -> System.out.println(o.getX()+"/"+o.getY()));
        Assertions.assertEquals(true, responseKakao.getDocuments().size()>0);
    }





    /**
     * findByCategory(String x, String y, String radius, String categoryGroupCode)
     * tc1 : radius가 0 미만이면 null을 리턴
     */
    @Test
    void findByCategoryTC1() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String x="126.8527773663502";
        String y="35.12999610435208";
        String radius="-1";
        String categoryGroupCode="PM9";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByCategory(x,y,radius,categoryGroupCode);

        //then
        Assertions.assertEquals(null, responseKakao);
    }

    /**
     * findByCategory(String x, String y, String radius, String categoryGroupCode)
     * tc2 : radius가 20000 초과이면 null을 리턴
     */
    @Test
    void findByCategoryTC2() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String x="126.8527773663502";
        String y="35.12999610435208";
        String radius="20001";
        String categoryGroupCode="PM9";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByCategory(x,y,radius,categoryGroupCode);

        //then
        Assertions.assertEquals(null, responseKakao);
    }

    /**
     * findByCategory(String x, String y, String radius, String categoryGroupCode)
     * tc3 : radius가 0~20000 이면 responseKakao는 null이 아니다.
     */
    @Test
    void findByCategoryTC3() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String x="126.8527773663502";
        String y="35.12999610435208";
        String radius="0";
        String categoryGroupCode="PM9";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByCategory(x,y,radius,categoryGroupCode);

        //then
        Assertions.assertEquals(true, responseKakao != null);
    }

    /**
     * findByCategory(String x, String y, String radius, String categoryGroupCode)
     * tc4 : x값이 "" 일 경우 null을 리턴한다.
     */
    @Test
    void findByCategoryTC4() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String x="";
        String y="35.12999610435208";
        String radius="0";
        String categoryGroupCode="PM9";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByCategory(x,y,radius,categoryGroupCode);

        //then
        Assertions.assertEquals(true, responseKakao == null);
    }

    /**
     * findByCategory(String x, String y, String radius, String categoryGroupCode)
     * tc5 : y값이 "" 일 경우 null을 리턴한다.
     */
    @Test
    void findByCategoryTC5() {
        // given
        KaKaoAPI kakaoAPI = new KaKaoAPI();
        String x="126.8527773663502";
        String y="";
        String radius="0";
        String categoryGroupCode="PM9";

        //when
        ResponseKakao responseKakao = kakaoAPI.findByCategory(x,y,radius,categoryGroupCode);

        //then
        Assertions.assertEquals(true, responseKakao == null);
    }



}
@Slf4j
class MapSearchServiceImplTest {

    /**
     * findPharmacy(String keyword, String radius)
     * tc1: keyword가 null일 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC1() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = null;
        String radius = "20000";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(null, responseKakao);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc2: keyword가 문자열 경우 검색 결과 값 리턴
     */
    @Test
    void findPharmacyTC2() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "광주 서구 종원아파트";
        String radius = "20000";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao.getDocuments().size()>0);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc3: keyword가 공백 일 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC3() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "";
        String radius = "20000";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao==null);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc4: keyword가 공백 일 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC4() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "";
        String radius = "20000";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao==null);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc5: radius가 공백 일 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC5() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "광주 서구";
        String radius = "";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao==null);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc6: radius가 0보다 작을 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC6() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "광주 서구";
        String radius = "-1";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao==null);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc7: radius가 20000보다 클 경우 null 값 리턴
     */
    @Test
    void findPharmacyTC7() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "광주 서구";
        String radius = "20001";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao==null);
    }

    /**
     * findPharmacy(String keyword, String radius)
     * tc8: radius가 0~2000 일경우 정상 값 리턴
     */
    @Test
    void findPharmacyTC8() {
        // given
        MapSearchServiceImpl mapSearchService = new MapSearchServiceImpl();
        String keyword = "광주 서구";
        String radius = "15000";

        // when
        ResponseKakao responseKakao = mapSearchService.findPharmacy(keyword, radius);

        // then
        Assertions.assertEquals(true, responseKakao!=null);
    }




}

 테스트 코드 입니다. 테스트 코드는 코어부분이라고 할 수 있는 KakaoAPI 클래스와 Service 클래스 2가지 클래스 기준으로 테스트코드를 작성하였습니다. 적은 시간 내에 머릿속에 많은 것이 떠오르지는 않았지만, 자유도가 높은 console에서 어떤 입력값이 들어올까? 라는 것을 생각하면서 테스트 코드를 작성했습니다.


3.  마무으리...

 3일이라는 시간 동안 설계에 대한 고민도 해보고(물론 테이블 설계는 아니지만..), 확장성에 대한 고민도 해보고, 여러가지를 고민해 볼 수 있는 값진 시간이었던 것 같습니다. 역시 개발자는 개발을 하면서 뭔가 만들어내고, 거기서 나오는 문제들과 고민거리는 통해 경험을 하게되고, 경험을 통해 성장하는 것 같습니다.

 그리고 다른 사람이 같은 주제를 가지고 어떻게 개발했는지를 보는 것도 하나의 재미 였습니다. 이전에는 남의 코드를 보는 것이 별로 재미없었던 것 같은데 같은 주제를 가지고 다른 사람이 개발한 것을 보면서 "어? 저 사람은 git commit 메시지를 저렇게 작성하네? 나도 한번 찾아서 적용해봐야지~!", "음 저 사람은 프로젝트 구조를 저렇게 짰구나~" 등등의 다른사람들이 짜놓은 코드를 보면서 제가 미처 인지하고 있지 못한 것들을 알아차리거나, 새로운 것을 검색하면서 시간 가는 줄 모르고 코드를 살펴봤던 적도 있습니다.

 또한 앞으로 개발하면서 지도 API를 사용할 일이 있을 수도 있는데, 이번 과제를 통해 Kakao API를 사용하면서 API의 기능들을 살펴볼 수 있었던 것도 값진 경험이었다고 생각합니다.

 전반적으로 저의 실력을 다시 한번 되돌아 볼 수 있는 시간이었던 것 같습니다. 아직 저의 수준은 갓 걸음마를 땐 아기와 같다고 볼 수 있을 것 같습니다. 앞으로도 꾸준한 자기개발을 통해 끊임없이 성장하는 개발자가 되어야 겠습니다!!