[회고] 2023 관광데이터 활용 공모전 우수상 'DIB' 백엔드 개발자입니다.

유아 Yooa·2023년 11월 14일
8

회고

목록 보기
5/6
post-thumbnail

시작하며..

2023년 4월부터 10월까지 진행된 공모전 프로젝트에 백엔드 개발자로 참여했다.

공모전 경험이 처음은 아니지만 거의 반년이라는 시간 동안 길게 진행하는 프로젝트를 처음이었기에 다양한 경험과 성장을 일궈냈다.
직접 부딪히며 배웠던 것들 그리고 소중한 팀원들과의 여러 가지 에피소드를 이야기해 보며 프로젝트를 회고해보자.

우리 서비스는 플레이 스토어에 등록되어 있으며 아래 링크를 통해 다운로드 받을 수 있다.
https://play.google.com/store/apps/details?id=com.oceans7.dib&pli=1


2023 관광 데이터 활용 공모전


한국관광공사와 Kakao가 주관으로, 한국관광공사의 TourAPI를 서비스 기능에 필수로 활용하여 앱, 웹, SW 등 다양한 형태의 신규 및 매쉬업 서비스를 개발하는 공모전. 관광 데이터를 활용해서 관광 사업을 홍보하는 목적인 듯하다. 대상 기준 상금이 1,500만 원이라고🤓.

링크 : http://www.2023tourapi.com/page/info.html


공모전에 참가하다.

왜 지원했어?

4월에 기존에 하던 프로젝트가 마무리되어 시간상으로 여유가 있었다. 또 이전에 진행한 공모전에서 결과물에 대한 아쉬움이 컸어서 완성도가 높은 개발 결과물을 보이고 싶다는 욕심이 있었다.

딱 그 시점에 관광 산업에 관심이 있던 기획자 지인이 함께 공모전을 나가보자고 제안했다.

한 번도 관광 도메인에 관심을 가져본 적이 없던 터라 주제 자체에서 흥미로움을 느끼기도 했고, 기간이 충분했으므로 완성도 높은 개발 결과물에 대한 욕구를 충족시킬 수 있을 것으로 생각했다. 빅데이터와 활용 기술에 대한 유망이 높은 지금 시대에 '데이터 활용' 프로젝트라서 더 매력적이라 느껴지기도 했다.

여담으로 기획자 지인이 데려오려는 다른 백엔드 개발자분이 어마어마한(?) 실력자라는 소문을 듣고 냉큼 수락해 버렸다.

그렇게 기획자 2명, 디자이너 1명, AOS 2명, 백 2명으로 구성된 팀을 모집하여 공모전에 참여하게 된다.


예선에 합격하다

아이데이션

접수 일정은 1달 정도로, 아이데이션을 거쳐서 사업소개서, IR자료까지 넉넉한 기간 동안 준비할 수 있다.

한국 관광 공사의 Tour API를 필수로 사용하면서 관광 산업을 홍보할 수 있는 아이템에 대해 고민했다.
아이데이션 회의에서 우리 팀이 집중한 포인트는 하기와 같다.

한국 관광 공사가 집중하는 포인트
2023 Travel Trend “일상이 여행이 되는

우리가 집중하는 포인트
"타겟층이 확실한" 서비스

여러 레퍼런스를 적극 참고하여 한국 관광 공사가 집중하는 2023 관광 산업의 포인트가 무엇인지를 조사했다.
그다음 최근 3년의 공모전 수상작을 살펴보며 공통점을 찾는 데 집중했다. 그에 따른 공통점은 타겟층이 명확했다는 것.

팀원 모두가 여러 아이디어를 던져보며 포인트에 부합하는지 어떤 장단점이 있을지를 고민했다.

그 결과 우리가 선정한 아이디어는 <다이버를 위한 정보 제공 및 커뮤니티 앱>

명확한 타겟층에 우선순위를 두어 주제를 결정하였다.

타 프로젝트를 해보면서 타겟층을 명확하게 선정하지 않아서 비즈니스 모델 설계가 불가능했던 경험이 가장 난처했었다. 이러한 경험을 근거 삼아서 타겟층의 중요성을 강력하게 어필했다.

공모전 1차 심사에서 요구하는 자료를 준비하여(이때부터 벌써 우리 팀 기획자와 디자이너를 맹신하게 되었다고..) 제출했고 당당히 합격했다.


DIB (Diving Information Base)

문제 인식 및 기획 배경

전 세계 스쿠버 다이빙 산업은 계속 성장하는 추세로, 스쿠버 다이버의 수도 매년 증가하고 있다. 그와 대비되어 코로나 시기 활성화되었던 국내 해양 관광 명소가 규제 완화 이후 관광객이 줄면서 약화하는 추세임을 파악했다. 우리 팀은 이러한 현상에 대한 대책 마련을 중심으로 서비스를 디벨롭했다.

스쿠버 다이빙 산업에 대한 이해를 위하여 실제 다이버를 대상으로 설문 조사해본 결과, 다이빙 관련 정보를 찾는 데 어려움을 겪는다는 답변이 53%에 달했다. 다이빙 관련 정보가 정리되어 있지 않아 소비자가 일일이 찾아봐야 하는 한계를 겪고 있다고.👀
또한 해양 레저 체험 구매 경로가 자체 홈페이지, 온라인 판매 채널, 현장 구매, 전화 예약 등 다양했고 그만큼 정보가 분산되어 있음을 확인할 수 있었다.

이에 따라 DIB는 다이빙 정보의 불균형을 해결하고자 정보 창구를 마련하고, 위치 기반 기술을 활용하여 다이빙 허용 구역 정보를 가시적으로 확인할 수 있게 하며, 이를 통해 접근성을 향상시켜 사용자 편의성을 증가시키는 것을 목표로 했다. 그 과정에서 국내 해양 관광 명소에 대한 질 높은 정보를 함께 제공하며 국내 관광 활성화에 기여하려는 것.

또한 장기적인 목표로 해양 환경 보호에 기여 가능한 서비스로 성장하고자 했다.
우리나라 연안은 세계 최고 수준의 해양생물 다양성(32종가량)을 유지하고 있는데, 6년간 난류종 출현 비율이 기하급수적으로 상승하며(18% 증가) 해양 생태계가 위협받고 있다. 해양 오염의 심각성은 대두되고 있지만 해양 전문가의 인원 자체가 소수이다 보니 해양 생태계 변화를 충분히 모니터링하기에 어려움이 존재한다.

지난해 디프다제주 멤버들이 프리다이빙으로 바다 쓰레기 수거 작업을 진행했다고 한다.

'이러한 노력을 소수에 그치지 않고 다수의 다이버가 시행할 수 있다면 어떨까?'

서비스 내에서 다이버가 발견한 해양 생물의 정보를 기록하고 이를 공유하며, 해양 오염 등의 문제나 유해 해양 생물을 간편하게 신고할 수 있도록 했다.다이버의 기록 및 신고 행위를 통해 해양 생태계에 대한 모니터링 역할을 대신 수행할 수 있도록 설계했다.

서비스 핵심 기능

  • 자체 콘텐츠 제작 및 제공
  • 해양 생물 정보 제공
  • 유해 생물 정보 제공 및 신고
  • 다이빙 관광 정보 제공 (*Tour API 활용) + 다이빙 제한 지역 표시
  • 다이빙하기에 적합한 날씨인지 알려주는 기능 (*공공데이터 > 기상청 API, 바다누리 API 활용)

DIB에 무엇을 기여했을까

개발 외적으로 한명의 팀원으로서

개발 외적으로 내가 맡은 롤은,

  • 적극적으로 회의에 참여해서 의견을 공유하고
  • 기획과 디자이너의 산출물에 대하여 피드백 및 오탈자 검수
  • 결정한 MVP 기능을 구체적으로 기술 및 피드백
  • 활용하는 공공데이터 API에 대해 구체적 기술

특히 강조한건 문서화

다양한 사람들과 프로젝트를 진행하면서 느낀 건 명쾌한 소통이 참 어렵다는 것.

여러 직무에 속한 사람들과 소통하기 좋은 도구는 역시 이해하기 쉬운 언어로 가공해서 만든 문서임을 많이 배웠다.

내 머릿속에 둥둥 떠다니는 여러 정보를 글자로 만드는 작업은 꽤나 어렵다. 시간이 꽤나 소요되는 작업이기도 하다. 그러나 그 과정에서 이해 못 한 부분을 알아채서 보충하기도 하고, 글로 만든다는 것이 내 언어로 전환하는 과정이니 더 오래 기억할 수 있다.

아래는 실제 공모전 회의에서 나온 문서 중 일부. 코어 기능을 구현하기 위해 필요한 Open API에 대한 설명글이다. 타 팀과도 공유하기 위해 최대한 구체적이지만 간단하게 기술해 놓았다.

본격적인 프로젝트 개발과 함께 개인적인 사정으로 동료 백엔드 개발자가 3주 동안 참여가 어려웠다.

따라서 서비스 코어 기능인 Tour API 연동 및 활용하여 다이빙 명소 근처 관광 정보 제공를 내가 담당하여 첫 스프린트에 개발하기로 했다.

3주 뒤에 돌아올 동료 개발자의 이해를 돕기 위해 개발 중간중간 tour api 사용방법에 관해 기술해놓았다. 나중에 이 문서를 참고하여 회의록을 준비하는 등 요긴하게 써먹었다👀.

백엔드 개발자로서

이번 DIB 프로젝트는 백엔드 개발자를 준비하는 입장에서 엄청난 성장을 이뤘다.

이제부턴 기술 이야기가 많이 나오니 주의

외부 API 통신하기

앞서 언급했듯 내 첫번째 업무는 Tour API 연동 및 활용하여 다이빙 명소 근처 관광 정보 제공 기능 개발

필자는 외부 API 통신 구현 경험이 딱 1번이 있는데 학부 시절 안드로이드 토이 프로젝트 경험이올시다.

당시 전국의 화장실 좌표를 모두 가져와 지도에 표시하는 아주 어마무시(?)한 프로젝트를 기획했는데, 전국 좌표를 페이징 없이 조회해오니 1회 통신 > 반환이 30초나 걸리는 무지막지한 어플리케이션을 만들어냈다.

그런 아픔을 가진 토이 프로젝트가 큰 도움이 될리는 없었다.

어찌 되었던 나름 Open API 통신 경험이 있는 멋진 개발자(??)이므로 나에게 주어진 업무를 당당히 수행해내야 했다.

그렇게 처음으로 구현한 방법은 (안드 토이 프로젝트에서도 사용했던) 공공 데이터 포털에서 제공하는 샘플 코드와 다른 개발자분들의 레퍼런스를 참고하며 작성한 HttpURLConnection을 활용한 동기적 통신 방법이었다.

이 구현 방법은 치명적인 단점이 2가지가 있는데

  1. 동기 통신이므로 병렬 수행이 불가하다 (= 응답 시간 지연을 초래한다.)
  2. Java 8 기준의 통신 방법 (= 즉 겁나 오래된 구린 코드(?))

요즘 트랜드에도 맞지 않고 성능적으로도 안타까움을 자아내는 해당 코드를 열심히 구현했고 (당시에는 몰랐다) 테스트를 해보면서 응답 시간이 상당히 지연됨을 발견했다.

예를 들어, 관광지 상세 정보 조회를 요청하면 총 4개의 api를 통신하여 데이터를 가져와야 하는데 약 10초가 걸렸다.
➡️ 앞 순서에서 발생한 지연 시간이 뒷 순서의 커넥션에도 영향을 미치는 Head-Of-Line Blocking 현상 발생

(안드로이드 화장실 좌표의 지옥이 떠오른다.)

분명 개선 방법이 있을것이라 확신하고 자바, 스프링부트 기반의 외부 API 통신 방법을 깊게 알아봤다. 그리하여 WebFlux 모듈 기반 리팩토링을 진행했다.

WebFlux 모듈을 처음으로 다루어보았는데 생각보다 학습량이 상당했다. 하지만 그게 치명적인 단점을 회피할 변명은 되지 못했다.

WebFlux 모듈 중 WebClient를 활용하면 앞선 두 가지의 치명적 단점을 개선할 수 있다.

  1. 동기 통신이므로 병렬 수행이 불가하다 (= 응답 시간 지연을 초래한다.)
    ➡️ WebClient는 Non-Blocking 방식으로 호출하기에 제어권을 가지고 로직을 병렬적으로 수행할 수 있다. (= 속도 향상을 기대할 수 있다.)
  1. Java 8 기준의 통신 방법 (= 즉 겁나 오래된 구린 코드(?))
    ➡️ HttpURLConnection 보다 REST API를 받아오는 더 추상화된 명세를 스프링에서 제공하고 있었다.(RestTemplate)
    그러나 RestTemplate 문서를 뜯어보면 결국 곧 HttpURLConnection과 성능 차이는 없고 그러한 이유 때문에 deprecated 될 것인지 스프링 5.0부터는 WebClient를 사용 권장하고 있다. (= 공식 문서에서도 권장하는 기술이 있다고?!)

우선 기존 로직을 삭제하고 차근차근 코드를 작성하며 도입했다. 그 과정에서 만난 수많은 오류들이 정말 그리울 것이다.(거짓)

여기서 내가 놓친 부분이 하나 있는데, 바로 Http Interface을 이용한 선언적 HTTP는 동기적으로 통신을 수행한다는 것이다.

WebClient의 Mono 구현체를 사용해서 비동기적으로 데이터를 처리하며 Non-Blocking의 이점을 취한다.
그러나 내가 구현한 방식은 기껏 비동기 가능한 기술 가져다 놓고 Mono 등의 구현체 형태로 데이터를 받아오지 않아서 결국은 동기적으로 데이터를 받아오고 있던 것이다. WebClient를 사용하면 당연히 비동기 방식으로 동작을 한다 생각한 내 잘못이다..

그런데 이 문제를 글쓰는 현재 시점에 발견한 것이 더 우습다.. 일주일 안에 리팩토링 글로 돌아오겠다..

테스트에 공들이기

그동안 프로젝트에서 테스트의 중요성을 크게 느껴본 적이 없었다. 아마 실제 운영을 해보지 않았기에 여러 변수를 대응할 필요가 없었기 때문인 것 같다.

하지만 이번 프로젝트는 실제 배포와 운영이 필수이므로 테스트 코드를 꼼꼼하게 단위 테스트를 했다.

이 과정에서 처음으로 알게 된 것은 외부 API 통신 로직을 테스트할 때는 mock 객체를 활용해야 한다는 것!

@MockBean
private DataGoKrApi dataGoKrApi;
private ObjectMapper objectMapper;

@BeforeEach
void before() {
	objectMapper = new ObjectMapper();
}

@Test
@DisplayName("반복 정보 조회 API 통신 테스트")
public void callDetailInfoAPITest() throws JsonProcessingException {
	//given
	Long contentId = (long) 2946230;
	String contentTypeId = String.valueOf(ContentType.TOURIST_SPOT.getCode());

	ResponseWrapper detailInfoAPIRes = MockResponse.testDetailInfoRes();
	String apiResponse = objectMapper.writeValueAsString(detailInfoAPIRes);
	when(dataGoKrApi.getTourInfo(serviceKey, mobileOS, mobileApp, dataType,contentId, contentTypeId))
    	.thenReturn(apiResponse);

	// when
	DetailInfoListResponse list = dataGoKrAPIService.getInfoApi(contentId, contentTypeId);
	DetailInfoItemResponse item = list.getDetailInfoItemResponses().get(0);

	// then
	assertThat(item.getContentId()).isEqualTo(contentId);
    assertThat(item.getContentTypeId()).isEqualTo(ContentType.TOURIST_SPOT.getCode());
    assertThat(item.getInfoName()).isEqualTo("화장실");
    assertThat(item.getInfoText()).isEqualTo("있음");
}

외부 서버는 우리가 제어할 수 있는 대상이 아니기에 그 서버를 mocking 하고 우리 서비스의 로직은 정확히 동작한다는 것을 테스트로 검증해야 한다.

당시에는 굳이 왜 Mock 객체로 만들어서 수행하는걸까? 테스트란 그 기능이 돌아가는지를 확인하는 과정이 아닌가? api 연동이 제대로 되는지를 테스트의 주안점으로 두어야 하는거 아님? 등 많은 의문점이 들었는데 사실 이건 필자가 단위 테스트에 대한 오해와 착각으로 인해 벌어진 일이었다.

예를 들어, Controller 단위 테스트에서 Service를 Mocking하고 테스트를 진행하는 이유는 비즈니스 로직만을 철저하게 테스트하기 위함이다.
같은 맥락으로 외부 서버도 Mocking하여 우리의 비즈니스 로직만을 테스트하고자 하는 것!

단위 테스트와 mocking에 대해서도 학습할 수 있었던 귀중한 기회였다.

클린 코드를 신경쓰고 리팩토링하기

DIB 프로젝트 진행하며 Clean Code 도서를 공부했다. 아는만큼 보인다고. clean code를 공부하면 할수록 내 코드를 가만두기가 어려웠다. 참 clean하지 않은 코드였다.

개발자는 코드로 기능을 기술해야 한다. 앞에서 말한 문서화의 중요성처럼 개발자에게 소통의 도구인 문서란 즉 코드이다.

내 코드에 주저리주저리 주석이 많다면?
내 코드리뷰에 '이렇게 짠 이유가 뭔가요?'가 많다면?
clean하지 않은 코드일 확률이 매.우.높.다

그래서 간단하게 변수 네이밍을 통일하는 작업부터 시작하여 메소드별 추상화 레벨 통일, 다형성을 활용하여 Factory 패턴 적용, API 조회에서 사용되는 필터링 옵션을 class 상수 및 enum화 하는 작업을 진행했다.

아래는 리팩토링 전 나의 코드다.
관광 타입에 따라 다른 반환 타입이 필요하기 때문에 if문을 통해 처리했고, 각 관광 타입마다 다른 정보들(이용시간, 전화번호, 휴일, 시설 정보 등)을 뽑아 가공하고 있다. 결과적으로 읽기가 너무 힘든 코드다.

 /**
* content type에 따라 intro item을 설정한다.
*/
private DetailPlaceInformationResponseDto handleApiResponse(DetailIntroResponse introApiResponse, List<DetailInfoItemResponse> infoItems, DetailPlaceInformationResponseDto response) {
        String useTime = null;
        String tel = null;
        String restDate = null;
        String reservationUrl = null;
        String eventDate = null;
        List<DetailPlaceInformationResponseDto.FacilityInfo> facilityInfo = new ArrayList<>();

        if (introApiResponse instanceof SpotIntroResponse) {
            SpotItemResponse spotItem = ((SpotIntroResponse) introApiResponse).getSpotItemResponses().get(0);

            useTime = TextManipulatorUtil.replaceBrWithNewLine(spotItem.getUseTime());
            tel = TextManipulatorUtil.extractTel(spotItem.getInfoCenter());
            restDate = TextManipulatorUtil.replaceBrWithNewLine(spotItem.getRestDate());

            facilityInfo.add(FacilityInfo.of(FacilityType.BABY_CARRIAGE, ValidatorUtil.checkAvailability(spotItem.getCheckBabyCarriage())));
            facilityInfo.add(FacilityInfo.of(FacilityType.CREDIT_CARD, ValidatorUtil.checkAvailability(spotItem.getCheckCreditCard())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PET, ValidatorUtil.checkAvailability(spotItem.getCheckPet())));

            if(ValidatorUtil.isNotEmpty(infoItems)) {
                boolean flagOfRestroom = false;
                boolean flagOfDisable = false;

                for(DetailInfoItemResponse item : infoItems) {
                    if(item.getInfoName().contains("화장실")) {
                        flagOfRestroom = true;
                    }
                    if(item.getInfoName().contains("장애인 편의시설")) {
                        flagOfDisable = true;
                    }
                }

                facilityInfo.add(FacilityInfo.of(FacilityType.RESTROOM, flagOfRestroom));
                facilityInfo.add(FacilityInfo.of(FacilityType.DISABLED_PERSON_FACILITY, flagOfDisable));
            }
        } else if (introApiResponse instanceof CultureIntroResponse) {
            CultureItemResponse cultureItem = ((CultureIntroResponse) introApiResponse).getCultureItemResponse().get(0);

            useTime = TextManipulatorUtil.replaceBrWithNewLine(cultureItem.getUseTime());
            tel = TextManipulatorUtil.extractTel(cultureItem.getInfoCenter());
            restDate = TextManipulatorUtil.replaceBrWithNewLine(cultureItem.getRestDate());

            facilityInfo.add(FacilityInfo.of(FacilityType.BABY_CARRIAGE, ValidatorUtil.checkAvailability(cultureItem.getCheckBabyCarriage())));
            facilityInfo.add(FacilityInfo.of(FacilityType.CREDIT_CARD, ValidatorUtil.checkAvailability(cultureItem.getCheckCreditCard())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PARKING, ValidatorUtil.checkAvailability(cultureItem.getCheckParking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PET, ValidatorUtil.checkAvailability(cultureItem.getCheckPet())));

        } else if (introApiResponse instanceof EventIntroResponse) {
            EventItemResponse eventItem = ((EventIntroResponse) introApiResponse).getEventItemResponse().get(0);

            useTime = TextManipulatorUtil.prefix("공연 시간 : ", eventItem.getPlayTime());
            tel = TextManipulatorUtil.extractTel(eventItem.getSponsor1Tel());
            reservationUrl = eventItem.getBookingPlace();
            eventDate = TextManipulatorUtil.convertDateRangeFormat(eventItem.getEventStartDate(), eventItem.getEventEndDate());

        } else if (introApiResponse instanceof LeportsIntroResponse) {
            LeportsItemResponse leportsItem = ((LeportsIntroResponse) introApiResponse).getLeportsItemResponse().get(0);

            useTime = TextManipulatorUtil.replaceBrWithNewLine(leportsItem.getUseTime());
            tel = TextManipulatorUtil.extractTel(leportsItem.getInfoCenter());
            restDate = TextManipulatorUtil.replaceBrWithNewLine(leportsItem.getRestDate());

            facilityInfo.add(FacilityInfo.of(FacilityType.BABY_CARRIAGE, ValidatorUtil.checkAvailability(leportsItem.getCheckBabyCarriage())));
            facilityInfo.add(FacilityInfo.of(FacilityType.CREDIT_CARD, ValidatorUtil.checkAvailability(leportsItem.getCheckCreditCard())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PARKING, ValidatorUtil.checkAvailability(leportsItem.getCheckParking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PET, ValidatorUtil.checkAvailability(leportsItem.getCheckPet())));

        } else if (introApiResponse instanceof AccommodationIntroResponse) {
            AccommodationItemResponse accommodationItem = ((AccommodationIntroResponse) introApiResponse).getAccommodationItemResponse().get(0);

            useTime = TextManipulatorUtil.concatenateStrings(
                    accommodationItem.getCheckInTime(),
                    accommodationItem.getCheckOutTime(), "체크인 : ", ", 체크아웃 : ");
            tel = accommodationItem.getInfoCenter();
            reservationUrl = accommodationItem.getReservationUrl();

            facilityInfo.add(FacilityInfo.of(FacilityType.BARBECUE, ValidatorUtil.checkAvailability(accommodationItem.getCheckBarbecue())));
            facilityInfo.add(FacilityInfo.of(FacilityType.BEVERAGE, ValidatorUtil.checkAvailability(accommodationItem.getCheckBeverage())));
            facilityInfo.add(FacilityInfo.of(FacilityType.COOKING, ValidatorUtil.checkAvailability(accommodationItem.getCheckCooking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PARKING, ValidatorUtil.checkAvailability(accommodationItem.getCheckParking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PICK_UP_SERVICE, ValidatorUtil.checkAvailability(accommodationItem.getCheckPickup())));
            facilityInfo.add(FacilityInfo.of(FacilityType.SAUNA, ValidatorUtil.checkAvailability(accommodationItem.getCheckSauna())));

        } else if (introApiResponse instanceof ShoppingIntroResponse) {
            ShoppingItemResponse shoppingItem = ((ShoppingIntroResponse) introApiResponse).getShoppingItemResponse().get(0);

            useTime = TextManipulatorUtil.replaceBrWithNewLine(shoppingItem.getOpenTime());
            tel = TextManipulatorUtil.extractTel(shoppingItem.getInfoCenter());
            restDate = TextManipulatorUtil.replaceBrWithNewLine(shoppingItem.getRestDate());

            facilityInfo.add(FacilityInfo.of(FacilityType.BABY_CARRIAGE, ValidatorUtil.checkAvailability(shoppingItem.getCheckBabyCarriage())));
            facilityInfo.add(FacilityInfo.of(FacilityType.CREDIT_CARD, ValidatorUtil.checkAvailability(shoppingItem.getCheckCreditCard())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PARKING, ValidatorUtil.checkAvailability(shoppingItem.getCheckParking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PET, ValidatorUtil.checkAvailability(shoppingItem.getCheckPet())));

        } else if (introApiResponse instanceof RestaurantIntroResponse) {
            RestaurantItemResponse restaurantItem = ((RestaurantIntroResponse) introApiResponse).getRestaurantItemResponse().get(0);

            useTime = TextManipulatorUtil.replaceBrWithNewLine(restaurantItem.getOpenTime());
            tel = TextManipulatorUtil.extractTel(restaurantItem.getInfoCenter());
            restDate = TextManipulatorUtil.replaceBrWithNewLine(restaurantItem.getRestDate());

            facilityInfo.add(FacilityInfo.of(FacilityType.CREDIT_CARD, ValidatorUtil.checkAvailability(restaurantItem.getCheckCreditCard())));
            facilityInfo.add(FacilityInfo.of(FacilityType.PARKING, ValidatorUtil.checkAvailability(restaurantItem.getCheckParking())));
            facilityInfo.add(FacilityInfo.of(FacilityType.KIDS_FACILITY, ValidatorUtil.checkAvailability(restaurantItem.getCheckKidsFacility())));
            facilityInfo.add(FacilityInfo.of(FacilityType.SMOKING, ValidatorUtil.checkAvailability(restaurantItem.getCheckSmoking())));
        }

        response.updateItem(useTime, tel, restDate, reservationUrl, eventDate, facilityInfo);

        return response;
    }

우선 단계적으로 리팩토링을 거쳤다.
1. if문을 숨긴다. 어떻게? 다형성을 활용해서.

@Component
public class DetailIntroItemFactoryImpl implements DetailIntroItemFactory {

    @Override
    public <T extends DetailIntroResponse> Class<T> getClassType(ContentType type) throws ApplicationException {
        switch(type) {
            case TOURIST_SPOT -> { return (Class<T>) DetailIntroResponse.SpotIntroResponse.class; }
            case CULTURAL_SITE -> { return (Class<T>) DetailIntroResponse.CultureIntroResponse.class; }
            case EVENT -> { return (Class<T>) DetailIntroResponse.EventIntroResponse.class; }
            case LEPORTS -> { return (Class<T>) DetailIntroResponse.LeportsIntroResponse.class; }
            case ACCOMMODATION -> { return (Class<T>) DetailIntroResponse.AccommodationIntroResponse.class; }
            case SHOPPING -> { return (Class<T>) DetailIntroResponse.ShoppingIntroResponse.class; }
            case RESTAURANT -> { return (Class<T>) DetailIntroResponse.RestaurantIntroResponse.class; }
            default -> throw new ApplicationException(ErrorCode.INVALID_CONTENT_TYPE);
        }
    }

    @Override
    public DetailIntroItemResponse getIntroItem(ContentType type, DetailIntroResponse introApiResponse) {
        switch(type) {
            case TOURIST_SPOT -> { return ((DetailIntroResponse.SpotIntroResponse) introApiResponse).getSpotItemResponse(); }
            case CULTURAL_SITE -> { return ((DetailIntroResponse.CultureIntroResponse) introApiResponse).getCultureItemResponse(); }
            case EVENT -> { return ((DetailIntroResponse.EventIntroResponse) introApiResponse).getEventItemResponse(); }
            case LEPORTS -> { return ((DetailIntroResponse.LeportsIntroResponse) introApiResponse).getLeportsItemResponse(); }
            case ACCOMMODATION -> { return ((DetailIntroResponse.AccommodationIntroResponse) introApiResponse).getAccommodationItemResponse(); }
            case SHOPPING -> { return ((DetailIntroResponse.ShoppingIntroResponse) introApiResponse).getShoppingItemResponse(); }
            case RESTAURANT -> { return ((DetailIntroResponse.RestaurantIntroResponse) introApiResponse).getRestaurantItemResponse(); }
            default -> throw new ApplicationException(ErrorCode.INVALID_CONTENT_TYPE);
        }
    }
}

DetailIntroItemFactory를 사용해서 일종의 팩토리 패턴을 적용시켰다. getIntroItem 메서드를 보면 if문 조건에 존재한 인스턴스 유형(관광 타입)을 확인하는 작업과 이에 맞는 item을 추출하는 작업을 메서드 안에 숨겨 비즈니스 로직에서 분리해냈다.

    /**
     * 소개 정보 Item 가져오기
     * @return DetailIntroItemResponse 타입
     */
    private DetailIntroItemResponse getIntroItem(Long contentId, ContentType contentType) {
        DetailIntroResponse introAPIResponse = fetchIntroAPI(contentId, String.valueOf(contentType.getCode()));

        return detailIntroItemFactory.getIntroItem(contentType, introAPIResponse);
    }

좀 더 깔끔한 코드로 변했다.

  1. 각 메소드 역할 분명하게 하기
/**
     * 관광 정보 상세 조회
     */
    public DetailPlaceInformationResponseDto getPlaceDetail(GetPlaceDetailRequestDto request) {
        Long contentId = request.getContentId();
        ContentType contentType = request.getContentType();

        DetailCommonItemResponse commonAPIItem = getCommonItem(contentId, contentType);
        DetailIntroItemResponse introAPIItem = getIntroItem(contentId, contentType);
        List<String> imageUrlList = transformImageUrlToString(getImageItemList(contentId));
        List<DetailInfoItemResponse> infoAPIResponse = getInfoItemList(contentId, contentType);

        List<FacilityInfo> facilityInfoList = getFacilityInfo(introAPIItem, infoAPIResponse);

        return DetailPlaceInformationResponseDto.of(
                ...
        );
    }

API를 통신하는 코드의 추상화 단계를 통일시켜서 코드 한 줄로 명확한 설명이 가능하게끔 리팩토링했다.

클린 코드를 위한 리팩토링에서 중요하게 생각한 포인트는 '리뷰어가 한번에 이해할 수 있도록' 이었다. 시간은 더 걸리지만 코드를 깨끗하게 작성해놓으니 유지하기가 훨씬 수월했다.

내 코드를 나중에 다시 봐도 이해하는게 어렵지 않으니 유지 보수 및 기능 추가에도 훨씬 수월하다. 클린 코드의 중요성을 체감하게 된 경험이었다.

클라우드를 센스있게 활용하기

이번 프로젝트를 하면서 또 많이 배웠던 점은 클라우드 서비스를 적재적소에 어떻게 활용할 것인가 였던 것 같다.

DIB에서는 이미지 파일을 업로드하고 조회하는 기능이 필요했다.

먼저 떠오른 건 AWS SDK에서 제공하는 s3 업로드 인터페이스를 빈으로 등록하여, 요청한 첨부파일에 대해 Stream 데이터로 s3에 직접 전송하는 방식이다.

  • 이러한 Stream 업로드 방식은 파일을 업로드 할 때 Spring Boot 어플리케이션을 실행하는 서버의 디스크나 힙 메모리에 파일 바이너리 전체를 저장하지 않는다.

두번째로 흔히 보이는 방식인 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다.

  • 보편적으로 학생 개발자가 s3 업로드를 구현하려고 할 때 흔하게 접하는 레퍼런스이기도 하다.
  • 클라이언트가 파일을 업로드하면 톰캣이 임시 디렉터리(Servelt Container Disk)에 저장되었다가 요청 처리가 끝나면 저장 파일이 삭제된다.
  • 임시 디렉터리에 저장된 파일을 MultipartFile 변수에 매핑함으로써 업로드된 파일의 바이너리를 힙 메모리에 할당하지 않고 해당 콘텐츠 메타데이터에 액세스할 수 있다.

이러한 Stream 업로드 및 MultipartFile 업로드 방식은 메모리에 큰 영향이 없어서 대용량 업로드에 적합한 방법으로 보일 수 있다. 하지만 전혀 그렇지 않다.

  • s3 업로드에 Spring boot 서버를 거치기 때문에 클라이언트 네트워크 환경, 클라우드 인스턴스 유형에 따라 업로드 속도 편차가 크다.
  • 대용량 파일을 업로드 중 오류가 발생했을 때 전체 파일을 처음부터 다시 업로드해야하기 때문에 시간과 대역폭이 낭비될 수 있다.
  • 다수의 사용자가 동시에 요청할 경우 서버 스레드가 빠르게 소진될 위험이 있다.
  • 파일 업로드 중 서버에 장애가 발생했을 때 삭제 작업을 변도로 해주는 등 여러가지 관리가 필요하다는 단점을 갖고 있다.

DIB 서비스의 경우, 추후 커뮤니티 기능 확장을 염두에 두고 개발을 진행하고 있었다.
게시글에 대용량의 파일이 첨부될 수 있다고 가정한다면 위 두 개의 구현 방식으로 인해 어떠한 장애나 이슈가 발생될지 모른다는 우려가 있다.

아니 그럼 어떡하라고..?

이때 동료 백엔드 개발자가 제안한 기술적 대안은 AWS에서 지원하는 Pre-signed URL을 활용한 업로드 방식이다.

  • 서버의 역할을 파일 업로드 ➡️ 업로드를 위한 AWS의 서명된 URL을 발급으로 축소시켜주는 것.
  • 발급받은 Pre-signed URL에 PUT 메소드에 바이너리를 실어서 요청한다. 이때 용량은 클라이언트에게 결정권을 위임한다.
  • AWS S3는 멀티파트 업로드로 수락하여 객체를 생성하게 된다.

업로드할 파일의 바이너리가 Spring Boot를 거치지 않고 클라이언트에서 S3에 다이렉트로 업로드되기 때문에 서버의 부하나 장애를 고려하지 않아도 된다는 큰 이점을 가진다.

추가로 업로드한 이미지 파일을 조금 더 빠르게 조회하기 위해서 CloudFront를 이용한 캐싱 및 On-The-Fly 리사이징 방식을 택했다.
(*이와 관련해서는 자세한 설명을 생략하고 본문 하단 레퍼런스를 남겨놓도록 하겠다.)

이 외에도 AWS Elastic Beanstalk을 이용해 쉽게 CI/CD를 구축하여 초반 구축 작업 시간을 크게 단축할 수 있었다.

이전까지는 클라우드 서비스에 대해서는 AWS EC2나 RDS 등 가상 서버 및 데이터베이스 용도로만 사용을 해봤어서 시야가 매우 좁은 상태였다.
클라우드 시대에 본인이 원하는 서비스를 적재적소로 활용하는 능력은 개발자에게 매우 중요하다. 실제 비즈니스에서는 '나 열심히 구현했어!'보다 '데드라인 내에 기능을 구현함'에 더욱 집중해야 하기에 기술적인 시야를 넓히고 경험을 마련해놓는 자세는 반드시 필요하다.

동료 백엔드 개발자가 클라우드 서비스에 대해 여러가지 인사이트를 제공해준 덕에 쉽고 간편하게 또 성능상으로도 더 이점을 주는 서비스를 활용해볼 수 있는 기회가 되어 감사하다.

자바 플랫폼을 깊게 이해하려 노력하기

Java 언어를 갖고 개발을 해오면서 변수나 메서드, 반복문 따위를 작성할 줄 알면 다 개발자가 되는 줄 알았다.

??? : (ㅋㅋ) 이게 별찍기 입니다~ 자바 이것만 알아도 절반은 알았어요~!

이번 프로젝트를 수행해보며 불변, 스레드, 동시성, 예외 처리와 리소스, GC 등 그동안 몰랐던(혹은 흐린눈 했던..) 자바 플랫폼이 지닌 특징을 마주할 수 있었다.

아직도 그와 관련해서는 배움에 부족함이 있다 생각한다. 그러니 생각보다 행동해야함을 느꼈고 Effective Java 도서 스터디를 개설하여 현재진행중이다.


DIB 우수상을 받았어요.

2023 관광데이터 활용 공모전은 시상팀 수가 총 80팀으로 많은 축에 속한다. 우리팀은 우수상을 수상했다.

대상과 최우수상 결과물을 한번씩 살펴봤는데 AI 기술을 융합한 서비스이거나 창업팀 위주로 뽑힌 듯 했다..!
확실하게 UI는 심사 기준에서 비중이 작은 듯.

이번 공모전은 호흡이 길었던 만큼 지속할 수 있는 동기가 굉장히 중요했다. 우리가 창업 팀이거나 스타트업이었다면 물질적 이익이 동기부여의 역할을 했을 것이다. 실제로 대상과 최우수상 대부분이 창업팀, 스타트업이기도 하다.

우리팀은 그런 것 없이 순수하게 프로젝트를 하고 싶어서 그리고 팀 분위기가 미치도록 좋아서라는 단순한 이유로 약 5개월간의 프로젝트를 지속했다. 중간중간 루즈해질 수 있음에도 늘 높은 텐션을 유지했고 각자의 역할에 최선을 다했다. 그러한 측면에서 우수상은 놀라울 정도로 높은 결과라고 생각한다!🥰

우리팀 오션즈🌊 그리고 나

'팀 분위기'는 중요하다. 개인 역량이 아무리 뛰어나도 팀워크가 없다면 무너지기 마련이다. 좋은 조직 문화 아래에서 일하는 구성원은 더 높은 성과를 보인다.

어떤 팀에서는 성향이 맞지 않아 시너지 발휘가 어렵기도 했고, 또 다른 팀에서는 개인 역량 차이나 책임감의 무게가 달라서 감정적인 이슈로 번지기도 했다. 경직된 팀 분위기는 팀워크를 망친다는 것은 확실하게 검증됐다.

그래서 프로젝트에 임할 때마다 경직된 분위기를 풀어보려고 스스로 개그 캐릭터를 자처하거나 나를 낮추며 상대방을 칭찬하는 경우가 많았다. 그런 모습이 단순히 시시덕거리는 모습으로, 업무를 진중하게 대하지 않는 것처럼 보일 수 있다는 불안감도 있었지만 그럼에도 불구하고 팀 분위기에 우선순위를 두고 행동해 왔던 것 같다.

이번 DIB 프로젝트는 나의 치명적이고 고질적인 문제점인 스스로를 낮추는 행위를 멈추려는 도전이기도 했다. 누군가는 이걸 겸손이라 표현할 수 있지만 누구도 궁금하지 않은 겸손은 결국 자기 비하에 불과하다. 그게 반복되면 자기 비하는 현실이 되어 버리고, 타인에게는 우스운 애로만 남겨진다.
(* '자기충족적 예언(Self-fulfilling Prophecy'라는 심리학 용어도 존재한다.)

팀 분위기를 위해 나를 낮추는 희생을 끊어내고 싶었기에 업무에 있어서 내뱉는 말과 행동에 근거와 책임감을 가지려고 노력했다. 그래서 공사 구분을 더 확실히 하며 일할 땐 일하고 놀 땐 잘 노는 그런 이상적인 모습이 되려고 시도했던 것 같다.

놀랍게도 나를 낮추지 않아도, 자기비하적 개그를 하지 않아도 좋은 팀 분위기는 형성되었다. 서로에게 격려와 칭찬을 아까지 않고, 부족한 부분은 솔직하게 인정하며 앞으로 나아갈 줄 알고, 모두가 프로젝트 책임자라는 마인드로 임하는 팀원들과 함께하니 자연스레 팀 분위기는 최상이 되었다.


얼마나 팀 분위기가 좋았냐면.. 공모전 기간동안 강릉 여행을 가기도 하고, 파티룸을 빌려서 해커톤을 하기도 하고, 노래방에서 땀 흘릴때까지 춤추며 놀기도 하고 심지어는 방탈출도 하러 다녔다😂.

나는 아직 개발 측면에서도, 소프트 스킬적으로도 부족한 부분이 많은 사람이다. 그동안 부족함을 약점이라 생각해와서 숨기기 급했다.
오션즈와 함께 일을 하다보니 배운건 사람이 부족함을 느끼는건 당연한 현상이며 부족한 부분은 채우면 그만라는 것. 완벽해지고 싶어서, 인정받고 싶어서 느끼는 부족함으로 스스로를 힘들게 만들 이유는 전혀 없다.

멘탈과 기술 모두 성장할 수 있었던 DIB 프로젝트의 회고를 마치겠다.
이 경험을 양분삼아 부족함을 인정하고 성장에 더욱 집착하는 사람이 될 것이다. 당당한 성장 호소인(?)이 되어 가는 모습을 지켜보도록🤓.

profile
기록이 주는 즐거움

0개의 댓글