📌 공지

본 포스팅 시리즈는 '맛집 지도 만들기' 라는 토픽으로 연재하고 있었으나, 

개인적인 사정으로 더 이상 연재하지 않습니다. 

(변명을 좀 하자면... 

최근에 부서이동을 하였는데, 현 부서에선 JAVA, Spring을 사용하지 않아 다른 언어나 기술 공부에 시간을 써야할 것 같습니다. 

그리고... 바빠서 허둥대는 사이에 Vue2에서 Vue3로 버전업이 되면서 본 프로젝트 진행 메리트가 사라지게 되었습니다...)

'맛집 지도 만들기' 프로젝트는 CRUD + 파일 첨부 + 페이징 + 검색 + 지도 기능 구현을 끝으로 마무리 되었습니다.

읽어주셔서 감사합니다. 질문과 피드백은 언제나 환영입니다. 😇

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 페이징을 구현하였다.

 

이번 포스팅에선 리뷰 검색 기능을 구현해 볼 것이다.

 

개선된 코드가 많아 포스팅에서 다소 빠진 부분이 있을 수 있다.
모든 소스코드는 깃헙에서 확인할 수 있다!

🍜 리뷰 검색 UI 구현

리뷰를 검색하기 위한 검색창을 구현해보자.
리뷰 목록 상단에 input 창을 추가해줄 것이다.

        <div class="header-area">
            <!-- ... -->
            <BInput
                v-model="searchInput"
                class="search-input ml-2"
                @keydown.enter="searchReview"
            />
            <BButton
                class="search-btn"
                @click="searchReview"
            >
                <FontAwesomeIcon icon="search" />
            </BButton>
        </div>

// script
    data() {
        return {
            // ...
            searchInput: undefined,
        };
    },
    methods: {
        searchReview() {
            console.log(this.searchInput);
        },
        // ...
    }

// css

    > .header-area {
        padding: 10px;
        flex-shrink: 0;
        display: flex;

        > .header-btn { // 버튼들에 클래스 추가
            flex-shrink: 0;
        }

        > .search-input {
            flex: 1;
            background-color: transparent;
            border-width: 0 0 1px 0;
            border-style: none none solid none;
            border-color: transparent transparent white transparent;
            color: white;

            &:focus {
                box-shadow: unset;
            }
        }

        > .search-btn {
            flex-shrink: 0;
            background-color: transparent;
            color: white;
            border: none;
        }
    }

frontend/src/components/ReviewList.vue

 

검색창 추가!

버튼 옆에 input 창을 만들어줬다!

@keydown:enter 로 input 에 값 입력을 하고 엔터를 쳤을 때 바로 searchReview 메소드가 실행될 수 있게 했다.
물론 돋보기 모양 버튼을 클릭했을 때도 searchReview 가 실행된다.

🍜 리뷰 검색 기능 구현 방법 모색

이제 검색 API를 구현해 볼 것이다.
사실 검색은 기존에 구현했던 무한스크롤 API를 수정하여 쉽게 구현이 가능하다.
searchInput(검색창에 입력한 값) 을 getReviewsByKeySet API의 파라미터로 넘겨 where 조건에 like 문법을 추가해주면된다.

    <select id="getReviewsByKeySet" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type,
            DATE_FORMAT(f.update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        <where>
            <if test="reviewUpdateDate != null and reviewId != null">
                r.update_date <![CDATA[<=]]> #{reviewUpdateDate}
                and
                r.id <![CDATA[<]]> #{reviewId}
            </if>
            <if test="searchInput != null and searchInput != ''">
                and (
                    r.title like CONCAT('%', #{searchInput}, '%')
                    or
                    r.address like CONCAT('%', #{searchInput}, '%')
                )
            </if>
        </where>
        order by r.update_date, r.id desc
        limit 10
    </select>

하지만 like를 사용하면 Index 를 활용할 수 없기 때문에 RDBMS 가 full table scan 을 하게 된다.
즉, 모든 데이터를 한번씩 훑는다는 것.
성능 상 매우 좋지 않다.

💡 full table scan
테이블에 존재하는 모든 데이터를 읽어 가면서 조건에 맞으면 결과로서 추출하고 조건에 맞지 않으면 버리는 방식

 

🍥 Index 를 활용하지 못하는 경우

like 문법을 쓰면 index 를 활용하지 못한다고 했는데,
그 이유가 무엇인지, 그리고 like 와 같이 인덱스를 활용하지 못하는 경우가 언제인지 알아보자.

 

Index를 활용하지 못하는 경우는 크게 5가지가 있다.

  • like 검색 시 좌변에 %를 붙일 경우
  • IS NULL 이나 IS NOT NULL을 사용하는 경우
  • 인덱스로 지정된 칼럼이 가공되거나 변형된 경우
  • 부정연산자(!=, <>, NOT) 를 사용하는 경우
  • 암시적인 형변환

🧀 like 검색 시 좌변에 %를 붙일 경우

where name like '김%' // 인덱스 사용 가능
where name like '%진' // 인덱스 사용 불가

like 를 사용하면 무조건 인덱스를 활용하지 못하는 것이 아니라,
좌변에 %를 붙일 경우에만 인덱스를 참조하지 못한다.

 

인덱스는 목차와도 같아서, 시작 값을 알면 쉽게 찾을 수 있지만 중간이나 끝값으로는 인덱스를 찾아갈 수 없다.

 

마치 영어 사전에서 A로 시작하는 단어를 찾으라하면 목차를 보고 쉽게 찾을 수 있지만,
A로 끝나는 단어를 찾으라 하면 찾기 어려운 것과 같은 논리이다.

🧀 IS NULL 이나 IS NOT NULL을 사용하는 경우

NULL 값은 인덱스에 저장되지 않기 때문에
인덱스로부터 NULL을 찾을 수 없어 full table scan을 해야한다.

🧀 인덱스로 지정된 칼럼이 가공되거나 변형된 경우

WHERE SUBSTR(CODE, 2,3)
WHERE COL1*2 > 200

위와 같이 SUBSTR 을 이용한 경우 문자열의 중간 부분을 떼어내어 인덱스와 비교해야하기 때문에
LIKE 와 같은 이유로 불가능하다.

두번째 경우는, 칼럼의 값이 변경되었기 때문에 인덱스에 저장된 컬럼 값을 찾지 못하게 되어 full table scan 을 해야한다.

🧀 부정연산자(!=, <>, NOT) 를 사용하는 경우

부정 연산자의 경우 무조건 인덱스를 타지 않는게 아니라,
부정 조건에 해당하는 데이터 비율이 크다면 DBMS가 full table scan 이 효율적이라 생각하여 인덱스를 타지 않을 수 있다.

 

마찬가지로 IN 이나 EXSIT 경우에도
DBMS 가 조건에 해당하는 데이터의 비율이 높아 full table scan을 하는 것이 맞다고 판단한다면
인덱스를 타지 않게 된다.

🧀 암시적인 형변환

숫자와 문자를 비교하거나,
날짜와 문자를 비교하는 등
현재 컬럼 타입과 다른 타입의 값을 비교해야할 때,
유저가 TO_CHAR 같은 문법으로 형변환을 시켜주거나,
DBMS 내부적으로 자동 형변환이 일어나게 된다.
이때 값이 가공되기 때문에 인덱스를 사용할 수 없게 된다.

🍥 Like 를 대체할 수 있는 방법

그렇다면 Like 를 사용하지 않고 어떻게 리뷰를 검색할 수 있을까?

바로, full text search 방법을 사용하는 것이다.

 

full text search 는 말 그대로 '문자를 검색하는 방법'이다.
전체 문자열의 일부분에 인덱스를 생성하여 검색한다. (full text index)

 

일반적인 인덱스가 row(=tuple) 에 대해 목차를 설정한 것이라면,
full text index 는 해당 컬럼의 값에 들어있는 "단어" 들에 목차를 건 것이라고 보면 된다.

 

예를 들어,
문자 타입의 컬럼 값이 "아버지가 방에 들어가십니다."라는 문장일 때,
공백을 기준으로 분리하여 "아버지가", "방에", "들어가십니다" 에 각각 full text index 를 생성하여 검색하는 것이다.

 

full text search 는 크게 두가지 방법으로 구현이 가능하다.
방금 설명한 대로 MYSQL 과 같은 RDBMS에 내장 full text index 를 생성하는 것과,
Apache Solr, ElasticSearch, Sphinx Search 같은 특수 인덱싱 솔루션(검색 엔진) 사용하는 방법이 있다.

 

검색엔진은 웹앱에서 원하는 정보를 찾을 수 있도록 검색을 도와주는 프로그램이다.
검색 엔진은 사용하기 전에 미리 정보를 수집하여 색인(Index)를 만들어 놓고,
사용자가 찾고자 하는 키워드를 입력하면 미리 만들어 놓은 색인 중에서 해당하는 정보를 찾아 보여준다.
즉, 미리 수집한 정보에 대해 full text search 를 한다는 것이다.

 

두 방법 모두 full text search 를 사용하지만
하나는 DBMS 가 직접 색인을 매겨야하고,
하나는 검색엔진이라는 서드파티 프로그램을 사용한다는 차이점이 있다.

 

두 방법은 모두 장단점이 확실해서,
어떤 걸 사용하는 것이 정답이다! 라고 말할 수 없다.

  RDBMS 내장 인덱싱 검색 엔진
장점 인덱스가 최신데이터와 동기화되어 자동 업데이트, 쉬운 유지 관리 가능 빠르고 정교한 검색 지원
단점 RDBMS에 과부하가 와 인덱스를 설정했음에도 검색 느림 인덱스를 업데이트 해줘야하고 설정이 복잡함

나는 두 방법 모두 공부해보고 싶기 때문에 브랜치를 파서 각각 구현해 줄 것이다.
이 중 최종적으로 사용될 방법은 검색 엔진이다. 

이유는 DBMS full text seach 기능을 사용했다가 성능 면에서 낭패를 본 웹앱을 직접 목격했기 때문이다.

 

 

콜리라는 일러스트 판매 플랫폼인데, 
오픈한 지 얼마 안됐을 때, 검색이 정말정말 느렸다. 
너무 안떠서 새로고침을 수시로 눌러봐야했을 정도다. 
그런데 어느 순간 엄청나게 빨라진걸 체감했다. 


콜리 개발자 피셜로는, PostgreSQL 의 full text seach 를 사용했다가, 검색엔진으로 바꿨기 때문이라고 한다.

내 프로젝트에 많은 데이터가 쌓여 검색이 느려질 일은 없을 것 같지만...
검색엔진을 사용해보고 싶어서 구현하기로 결정했다. 

 

🚨 참고

공부하다보니 검색 엔진이 제게 다소 어려워 적용까지 시간이 조금 걸릴 것 같습니다 😂

아, 검색엔진이 이런 것이구나~ 하는 정도로만 봐주시면 감사하겠습니다.

포스팅과 코드에서 언제 검색엔진이 적용될지 미지수이니 제 포스팅을 따라오고 계셨던 분들은 참고 부탁드립니다 ㅎㅎ

🍜 like 로 검색 기능 구현

like 를 사용한 검색도 구현해보긴했다.
like-search-demo 라는 브랜치를 새로 파서 구현했다.

git branch -b like-search-demo

단순히 searchInput 만 파라미터로 추가하고 where 절에 like 만 구현하면 되는 것이기 때문에,
포스팅에 코드를 첨부하진 않았다.
그래도 주의했던 부분만 짚고 넘어가보자.

        searchReview() {
            this.$refs.scrollArea.scrollTop = 0;
            this.reviewUpdateDate = undefined;
            this.reviewId = undefined;
            this.getReviews();
        },

frontend/src/components/ReviewList.vue

 

searchReview 메소드 부분이다.

서치를 했을 때 기존에 보던 스크롤을 초기화시켜주기 위해
ref 를 통해 스크롤을 최상단으로 올리고,
reviewUpdateDatereviewId를 초기화 시켜줬다.

 

성공적으로 like 검색이 되는 걸 확인할 수 있었다! 🎉

🍜 MYSQL full text search 로 검색 기능 구현

like 와 마찬가지로 RDBMS 의 full text search 도 다른 브랜치를 파서 구현해 볼 것이다.
브랜치는 rdbms-full-text-search-demo 라고 팠다.

git checkout -b rdbms-full-text-search-demo

🍥 full text index

full text search 를 위해 full text index 를 생성해보자.

full text index
긴 문자의 텍스트 데이터를 빠르게 검색하기 위한 MYSQL 의 부가적인 기능이다.
문자열에서 공백으로 단어를 구분해 단어마다 인덱스를 매긴다.
💡 참고
full text search 는 대/소문자를 구분하지 않는다.

 

full text index 생성 방법

CREATE TABLE 테이블이름(
  …,
  열이름 데이터형식,
  …,
  FULLTEXT 인덱스이름 (열이름)
);
CREATE FULLTEXT INDEX 인덱스이름 ON 테이블이름 (열이름);
ALTER TABLE 테이블이름 ADD FULLTEXT (열이름);

나는 idx_review_text라는 인덱스를 생성해줬다.

CREATE FULLTEXT INDEX idx_review_text ON tbl_review (review);

full text index 삭제 방법

ALTER TABLE 테이블이름 DROP INDEX FULLTEXT (열이름);
drop index 인덱스이름 on 테이블이름;

full text index 확인 방법

SHOW INDEX FROM 테이블이름;

show index from tbl_review

show index from tbl_review
index_typefulltext 인 인덱스가 추가된 것을 확인할 수 있었다.

 

💡 참고

저장된 인덱스 단어는 아래 명령어로 확인할 수 있다.

// 테이블 단위 조회는 innodb_ft_aux_table 변수에 대상 테이블 명시 필요.
SET GLOBAL innodb_ft_aux_table = 'DB명/테이블명';

// 테이블 최적화 시 테이블 크기에 따라 오래걸릴 수 있기 때문에 fulltext에 관련한 최적화만 실행될 수 있도록 설정
SET GLOBAL innodb_optimize_fulltext_only=ON;

// optimize를 통해 저장된 index 목록은 INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE 에서 확인 할 수 있다.
SELECT WORD, DOC_COUNT, DOC_ID, POSITION FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE;

🍥 full text search

full text search 를 위해선 where 절에서 like 를 쓰는 게 아니라,
MATCH()AGAINST() 라는 MYSQL 문법을 써야한다.

 

Syntax

MATCH (col1,col2,...) AGAINST (expr [search_modifier])

Example

select * from tbl_review where match(review) against('test');

review 컬럼에서 test 라는 단어를 포함하는 row 를 찾고 싶을 때,
위와 같이 명령을 실행시켜주면 된다.

자연어 검색

하지만 이렇게 검색하면, 자연어 검색 을 하게되어
내가 생각한 결과가 나오지 않을 수 도 있다.

자연어 검색은 단어가 정확한 것만 검색하는 것을 뜻한다. 


즉, 'test' 를 검색했을 때, 'test1', 'test2'는 결과에 포함되지 않는다.
오로지 공백으로 분리된 온전한 'test' 만 검색이 가능하다.

참고로, 자연어 검색은 원래 아래 명령어와 같고, in natural language mode 는 생략이 가능하다.

select * from tbl_review where match(review) against('test' in natural language mode);

불린 모드 검색

자연어 검색 문제를 해결하기 위해 LIKE 연산자에서 %를 쓰듯이 불린 모드 검색을 해줄 수 있다.

불린 모드 검색은 단어나 문장이 정확히 일치하지 않아도 검색하는 것을 의미한다.
in boolean mode 옵션을 붙여주면 적용된다.

select * from tbl_review where match(review) against('test' in boolean mode);

불린 모드는 % 처럼 문자열 앞뒤에 붙여 사용 가능한 문법이 존재한다.

 

검색 필수

// 영화를 찾되 반드시 액션이 들어가 있는 열
SELECT * FROM newspaperWHERE MATCH(article) AGAINST('영화 +액션' IN BOOLEAN MODE); 

검색 제외

// 영화를 찾되 액션은 안들어가있는 열
SELECT * FROM newspaperWHERE MATCH(article) AGAINST('영화 -액션' IN BOOLEAN MODE);

검색 부정(- 보다 부드러운 방식)

//‘영화’를 찾되 ‘액션’이 없는 열보다 ‘액션’이 있는 열이 아래 순위
SELECT * FROM newspaperWHERE MATCH(article) AGAINST('영화 ~액션' IN BOOLEAN MODE);

부분 검색

// ‘영화를’, ‘영화가’, ‘영화는’ 등
SELECT * FROM newspaperWHERE MATCH(article) AGAINST('영화*' IN BOOLEAN MODE);

// '여자' 또는 '남자' 가 있는 경우 검색
SELECT * FROM newspaperWHERE MATCH(article) AGAINST('남자* 여자*' IN BOOLEAN MODE);

부분 검색 "" 안에 있는 구문과 정확히 동일한 철자의 구문

// “재밌는 영화”, “재밌는 영화가” 등
// “재밌는 한국 영화”, “재밌는 할리우드 영화” 불가
SELECT * FROM newspaperWHERE MATCH(article) AGAINST("재밌는 영화" IN BOOLEAN MODE);

리뷰 검색에선 이렇게 디테일한 검색 조건이 필요하진 않을 것 같고,
부분 검색인 별표 문자만 쓰면 될 것 같다.

select * from tbl_review where match(review) against('review*' in boolean mode);

하지만 위와 같이 검색했을 때 아무것도 뜨지 않았다.

나는 별표가 '부분 검색'이라길래 저런식으로 써주면 'review'를 포함하는 모든 값이 검색될 거라 예상했다.
하지만 별표는 별표 연산자 에 오는 단어로 시작하는 단어를 찾는 것이기 때문에,
'99reivew'와 같이 'review' 로 끝나는 값이 대부분인 현재 DB에선 검색되지 않았다.

 

만약 이런식으로 공백이 없는 문장에서 첫 단어가 아닌 단어를 검색하고 싶을 땐 어떻게 해야할까?
바로 Ngram을 사용해야한다.

Ngram Parser

Ngram 파서는 일련의 텍스트를 n개의 문자로 구성된 연속된 시퀀스로 토큰화할 수 있는 MySQL 의 Built-in Parser이다.
다른 기본 제공 서버 플러그인과 마찬가지로 서버가 시작될 때 자동으로 로드된다.
InnoDB 및 MyISAM 엔진을 지원하며 중국어, 일본어, 한국어를 지원한다.

 

쉽게 설명하자면,
Ngram 은 문자열을 'n개의 문자로 구성된 단어'로 쪼개주는 MySQL 내장 파서(Parser)이다.

 

예를 들어, 토큰 사이즈(n)가 2라고 할 때,
"철학은 어떻게 삶의 무기가 되는가" 라는 문장을
["철학", "학은", "어떻", "떻게", "삶의", "무기", "기가", "되는", "는가"]로 토큰화한다.

기본(default) 토큰 사이즈는 2이다.
이 값은 SHOW GLOBAL VARIABLES LIKE "ngram_token_size"; 로 확인할 수 있다.

 

Ngram 사용법은 간단하다.
text index 생성하던 SQL에 WITH PARSER ngram 만 붙여주된다.
기존에 생성했던 text index를 지우고 WITH PARSER ngram를 붙여 다시 생성해주자.

drop index idx_review_text on tbl_review;
CREATE FULLTEXT INDEX idx_review_text ON tbl_review (review) WITH PARSER ngram;

다시 아래 명령어로 리뷰를 검색해보면 올바른 결과가 나오는 것을 확인할 수 있다.

select * from tbl_review where match(review) against('review');

'review' 단어를 포함한 리뷰가 검색된 모습

만약 기본 토큰 사이즈인 2보다 작은 길이의 값으로 검색을 하고 싶다면 불린 모드의 부분 검색을 뜻하는 * 를 사용하면 된다.

select * from tbl_review where match(review) against('e*' in boolean mode);

그런데 이상한 점이,
검색이 되는 알파벳이 있었고 안되는 알파벳도 있었다.
예를 들면,
아래와 같이 "i"를 포함한 문자를 검색하면 결과가 나오지 않았다.

select * from tbl_review where match(review) against('i*' in boolean mode);

이유는 Ngram의 Stopwords 때문이다.
Ngram 은 Stopwords 로 정의된 토큰은 인덱스에서 제외시켜버린다.
정의된 Stopwords 는 아래 명령어로 확인해볼 수 있다.

SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD;

이 문제를 해결하기 위해서 Stopwords 를 비활성화 시키거나, Stopwords 테이블을 만들어서 사용해 줄 수 있다.

 

Stopwords 비활성화

mysqld --innodb-ft-enable-stopword=OFF

Stopwords 테이블 만들어 관리

// stopwords 테이블 생성
CREATE TABLE stopwords(value VARCHAR(30)) ENGINE = INNODB;

// 생성한 stopwords 테이블에 innodb_ft_server_stopword_table 옵션 set
SET GLOBAL innodb_ft_server_stopword_table = 'good_restaurant/stopwords';

// 기존 인덱스 삭제 후 재생성
DROP index idx_review_text on tbl_review;
CREATE FULLTEXT INDEX idx_review_text ON tbl_review (review) WITH PARSER ngram;

🚨 주의
DBMS 를 재 시작할 경우, innodb_ft_server_stopword_table 옵션이 초기화된다.
이를 방지하기 위해 mysql.cnf 에 설정해주자.

[mysqld]
innodb_ft_server_stopword_table='test/stopwords'

나는 stopwords 테이블을 만들어줬다.
다시 select * from tbl_review where match(review) against('i*' in boolean mode); 로 검색하자,
제대로 검색되는 것을 확인할 수 있었다! 🎉

🍥 API 구현 with cherrypick

full text search 도 인덱스만 생성해주면,
like 와 같이 getReviewsByKeySet 의 where 절만 수정해주면 된다. 

따라서 상단에서 구현했던 like-search-demo 브랜치를 복사해서
현재 브랜치에 붙이고, where 절만 수정해줄 것이다.

이를 git 의 체리픽이라고 한다.

cherrypick

다른 브랜치에 있는 커밋을 선택적으로 내 브랜치에 적용시킬 때 사용하는 Git 명령어이다.

git cherry-pick <commit_hash>

커밋을 붙여넣고 싶은 브랜치에서 위 명령어를 실행시켜주면 된다.
여기서 commit_hash는 복사 대상이다.


즉, 나는 rdbms-full-text-search-demo 브랜치에 위치하고 있어야하고,
commit-hashlike-search-demo 의 커밋을 써주면 된다.

commit-hashlike-search-demo 브랜치에서 git log 명령어로 확인할 수 있다.

git cherry-pick 5b06667d9cc596b7b34ecc5af933a8e811640eb7

체리픽 하여 커밋이 복붙된 모습

 

커밋이 정상적으로 복붙된 걸 확인할 수 있었다! 🎉

CREATE FULLTEXT INDEX idx_address_text ON tbl_review (address) WITH PARSER ngram;
CREATE FULLTEXT INDEX idx_title_text ON tbl_review (title) WITH PARSER ngram;

tbl_review 테이블의 titleaddress 에 text index 를 생성해주고,
where 절을 아래와 같이 수정했다.

        <where>
            <if test="reviewUpdateDate != null and reviewId != null">
                r.update_date <![CDATA[<=]]> #{reviewUpdateDate}
                and
                r.id <![CDATA[<]]> #{reviewId}
            </if>
            <if test="searchInput != null and searchInput != ''">
                and (
                    match(r.title) against(CONCAT(#{searchInput}, '*') in boolean mode)
                    or
                    match(r.address) against(CONCAT(#{searchInput}, '*') in boolean mode)
                )
            </if>
        </where>

 

full text search 로 검색이 되는 걸 확인할 수 있었다! 🎉


이번 포스팅에선 검색 기능을 구현해보았다. 

댓글, 하트, 피드백은 언제나 환영입니다 🥰

 

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 파일 업로드 기능을 구현하였다.

 

이번 포스팅에선 주니어 개발자의 필수코스... 페이징 및 무한 스크롤을 이해하고 구현해볼 것이다.
사이드 바에 페이징 또는 무한스크롤로 구현된 리뷰 목록을 출력할 것이다.

 

개선된 코드가 많아 포스팅에서 다소 빠진 부분이 있을 수 있다.
모든 소스코드는 깃헙에서 확인할 수 있다!

🍜 기능 및 코드 보완

본격적인 구현에 앞서, 기존에 구현했던 코드를 살짝 수정했다.

🍥 메소드 명 변경

setInputState 라는 Vuex mutaion 메소드 명을 setIsDisabledInput 으로 수정했다.
isDisabledInput 상태를 bool 데이터로 set 해주는 메소드 였는데,
InputState 라는 말이 좀 헷갈려서 직관적으로... setIsDisabledInput라고 수정했다.

🍥 snake case 를 camel case 로 수정

💡 참고
snake case: aa_bb_cc
camel case: AaBbCc

 

DB 컬럼명을 snake case 로 지었었는데,
그 컬럼명을 그대로 가져오다보니,
대부분 camel case 로 구현되어 있던 코드에 뜬금없는 snake case 가 굉장히 이질적이었다.

 

사실 이를 해결하기 위해 기존에 MyBatis/MySQL 을 연동할 때
mybatis-config.xml 에 설정을 아래 코드처럼 추가했었다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
        <setting name="callSettersOnNulls" value="true"/>
    </settings>
</configuration>

src/main/resources/mybatis-config.xml

 

여기서 mapUnderscoreToCamelCase 가 mybatis 로 가져온 값을 snake case를 camel case 로 바꿔주는 설정이다.
그런데 값을 가져와보니 설정은 무시되고 snake case 만 가져오고 있었다.

 

이유를 확인해보니,
mapUnderscoreToCamelCase 은 resultType 이 map 인 값엔 적용되지 않는다고 한다.
그래서 따로 추가적인 설정을 진행해줬다.

💡 Map
Key, value 로 구성된 자료형

 

 

package com.map.restaurant.good.config;

import org.springframework.jdbc.support.JdbcUtils;

import java.util.HashMap;

public class LowerHashMap extends HashMap {
    @Override
    public Object put(Object key, Object value) {
        return super.put(JdbcUtils.convertUnderscoreNameToPropertyName((String) key), value);
    }

src/main/java/com/map/restaurant/good/config/LowerHashMap.java

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
        <setting name="callSettersOnNulls" value="true"/>
    </settings>

    <typeAliases>
        <typeAlias alias="camelMap" type="com.map.restaurant.good.config.LowerHashMap"/> <!-- 추가! -->
    </typeAliases>
</configuration>

src/main/resources/mybatis-config.xml

 

config 패키지에 LowerHashMap 이라는 클래스를 파고,
위와 같이 작성해줬다.


convertUnderscoreNameToPropertyName 는 말 그대로 snake case를 camel case 로 변환해주는 메소드이다.
만들어준 타입을 camelMap 이라고 이름짓고 config 에 정의해줬다.

<select id="getReviews" resultType="camelMap">

이제 MyBatis 에서 hash resultType 을 쓸 일이 생겼을 때,
이런식으로 사용해주면 결과의 키가 snake case 가 아닌 camel case 로 나타나게 될 것이다.

🍥 리뷰 업데이트 시간 컬럼 추가

alter table tbl_review add column update_date timestamp not null default (utc_timestamp);
alter table tbl_file_info add column update_date timestamp not null default (utc_timestamp);

리뷰 목록이 추가되고나면 정렬이 필요할 것 같아서 리뷰나 파일을 추가한 시간을 나타내는 컬럼을 추가해줬다.

 

DB엔 반드시 UTC 표준 시간이 저장되어야한다.
개인 환경에 잘못된 로컬 시간이 설정되어 있을 수도 있고,
어떤 위치와 환경에서든 표준시간을 기준으로 계산된 시간이 나타나야하기 때문이다.


따라서 insert 될 때 utc_timestamp() 가 들어가도록 했다.

    <insert id="saveReview">
        insert into tbl_review (
            id,
            title,
            address,
            review,
            grade,
            lon,
            lat,
            update_date
        )
        values (
            #{id},
            #{title},
            #{address},
            #{review},
            #{grade},
            #{lon},
            #{lat},
            UTC_TIMESTAMP()
        ) on duplicate key
        update
            title = #{title},
            address = #{address},
            review = #{review},
            grade = #{grade},
            lon = #{lon},
            lat = #{lat},
            update_date = UTC_TIMESTAMP()
    </insert>

src/main/resources/mappers/ReviewMapper.xml

    <insert id="saveFile">
        insert into tbl_file_info (file_id, review_id, file_name, file_size, content_type, update_date)
        values (#{fileId}, #{reviewId}, #{fileName}, #{fileSize}, #{contentType}, UTC_TIMESTAMP())
    </insert>

src/main/resources/mappers/FileMapper.xml

 


    <select id="getImages" resultType="com.map.restaurant.good.dto.FileDTO">
        select
            review_id,
            file_id,
            file_name,
            file_size,
            content_type,
            DATE_FORMAT(update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from tbl_file_info
        where review_id = #{reviewId}
    </select>

src/main/resources/mappers/FileMapper.xml

 

DB에 저장된 UTC_TIMESTAMP()를 클라이언트로 불러오기 위해
TIMESTAMP 타입을 MYSQL DATE_FORMAT 문법으로 YYYYMMDDhhmmss 형식의 문자열로 변환시켰다.

 

DTO도 String 타입으로 선언해주면 된다. 나는 각각 reviewUpDateStr, fileUpDateStr 라고 선언해줬다.

    <select id="getReviews" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        order by r.update_date
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

수정하면서,  getReviews 에서 리뷰 목록의 대표 이미지를 위해 tbl_file_info 파일 중 하나를 가져오게 했다.

 

 

이렇게 가져온 YYYYMMDDhhmmss 문자열은 클라이언트에서
로컬 시간으로 변환한 뒤, UI에 YYYY/MM/DD hh:mm:ss 형식으로 뿌려줄 것이다.


이를 위해 common 폴더에 DateUtil.js 를 파줬다.

export function utcDateStrToVisualLocalDateStr(utcDateStr)
{
    return dateToVisualDateStr(utcDateStrToLocalDate(utcDateStr));
}

export function utcDateStrToLocalDate(utcDateStr)
{
    const date = dateStrToDate(utcDateStr);
    date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
    return date;
}

// YYYYMMDDHHMMSS
export function dateStrToDate(dateStr)
{
    return new Date(
        parseInt(dateStr.substring(0, 4)),
        parseInt(dateStr.substring(4, 6)) - 1,
        parseInt(dateStr.substring(6, 8)),
        parseInt(dateStr.substring(8, 10)),
        parseInt(dateStr.substring(10, 12)),
        parseInt(dateStr.substring(12, 14)));
}

export function dateToVisualDateStr(date)
{
    return `
        ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} 
        ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}
    `
}

frontend/src/common/DateUtil.js

 

이제 utcDateStrToVisualLocalDateStr() 메소드만 갖다쓰면 YYYYMMDDhhmmss 형식의 UTC 문자열이
포맷된 로컬 시간으로 보이게 될 것이다.

🍥 ESLint/Prettier 적용

좀 더 깔끔한 코드를 작성하고 싶어서 ESLint/Prettier 를 적용하기로 했다.
ESLint/Prettier 적용기는 내용이 길어서 따로 포스팅했다.
이 글을 참고!

🍜 페이징 UX 선택

지도 아이콘만으로는 등록된 리뷰를 한눈에 파악하기 어렵다고 생각되어서,
사이드바에 리뷰 목록을 출력해줄 것이다.

 

리뷰가 많아진다면 사이드 바에 한번에 리뷰 목록을 불러오기에 부담이 될 수 있다.
그래서 나는 페이지네이션 또는 무한 스크롤 기법을 적용하여 리뷰 목록을 구현하기로 했다.

🍥 페이지네이션이란?

Pagination.
많은 콘텐츠를 여러 페이지로 분할하여 UI 에 보여주는 기법이다.
한번에 많은 콘텐츠를 가져오는 것이아니라, 한번에 분할된 한 페이지만 보여주기 때문에
과부하를 방지할 수 있다.
하단에 페이지 버튼을 출력/클릭하여 페이지를 이동할 수 있다.

예) 구글의 페이지네이션

🍥 무한스크롤이란?

Infinite Scroll.
페이지네이션과 유사하지만 스크롤을 내려 페이지를 이동하는 기법이다.
페이지네이션과는 다르게 원하는 페이지로 단번에 이동이 불가능하다.
스크롤을 내렸을 때 페이지의 최하단에 도달한다면,
다음 페이지를 로딩해오는 방식이다.

트위터의 무한 스크롤

결론적으로 나는 '무한 스크롤' 기법을 사용할 것이다.
이유는....

  1. 사이드 바에 버튼 목록이 있는 것 보단 스크롤을 내리는 게 편할 것 같아서
  2. 리뷰는 검색하면 되고, 딱히 원하는 '페이지'를 찾아 갈 일은 없을 것 같아서

하지만 페이징도 공부할겸 브랜치를 따로파서 페이징도 구현해보기로 했다.

🍜 리뷰 목록 UI 구현

지금은 사이드바에 하나의 리뷰를 수정하거나 등록하는 UI 만 구현되어 있다.

리뷰 리스트도 보여져야하기 때문에 이런 등록/수정 폼을 따로 컴포넌트로 빼주고,
리뷰 리스트 컴포넌트를 구현하여 각 컴포넌트가 화면에 나타났다 사라지게 해줄 것이다.
나는 각 컴포넌트를 ReviewForm.vue, ReviewList.vue 로 생성했다.

<template>
    <div class="side-bar-wrapper">
        <!-- ... -->
            <ReviewList v-if="isVisibleReviewList" />
            <ReviewForm v-else />
        <!-- ... -->
    </div>
</template>

// ...

    computed: {
        ...mapState({
            // ...
            isVisibleReviewList: (state) => state.isVisibleReviewList,
        }),
    },

frontend/src/components/SideBar.vue

 

이제 사이드바 코드는 이런식으로 간략화될 것이다.

 

isVisibleReviewListisDisabledInput 처럼 Vuex의 state와 mutation 에 선언해주고 가져온 값이다.
말 그대로 리뷰 리스트가 보이는지 아닌지를 구분하는 상태이다.

export default new Vuex.Store({
    state: {
        isVisibleReviewList: true,
    },
    mutations: {
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
            setIsVisibleReviewList(state, false);
        },
        setIsVisibleReviewList: (state, bool) => {
            setIsVisibleReviewList(state, bool);
        }
    }
});

function setIsVisibleReviewList(state, bool) {
    state.isVisibleReviewList = bool;
}

frontend/src/store/index.js

 

setCurReviewId 에도 isVisibleReviewList 가 set 되도록 추가해 주었다.
아이콘을 클릭했을 땐 리뷰 리스트가 보이지 않게 한다.

🍥 ReviewForm.vue

component 폴더에 ReviewForm.vue 파일을 파주고, 기존의 폼을 그대로 옮겨왔다.
그리고 상단에 리뷰 리스트로 이동이 가능한 버튼을 추가해줬다.

        <div class="title-area">
            <div class="goto-review-list-btn">
                <span @click="goToReviewList">
                    <FontAwesomeIcon icon="angle-left" />
                    리뷰 목록
                </span>
            </div>
            <!-- ... -->
        <div>

            // methods 
            goToReviewList() {
                this.$store.commit('setIsVisibleReviewList', true);
                this.$store.commit('setIsDisabledInput', true);
            },

            // css
            > .goto-review-list-btn {
                  color: white;
                  width: fit-content;

                  &:hover {
                      cursor: pointer;
                      text-decoration: underline;
                  }
              }

frontend/src/components/ReviewForm.vue

 

꺽쇠 기호를 위해 faAngleLeft 아이콘도 Icon.js 에 추가했다.


리뷰 목록이 보일 때는 '리뷰 수정 중'인 상태가 아닐 테니 isDisabledInputtrue 로 줬다.

리뷰 목록 버튼이 추가된 모습

🍥 ReviewList.vue

이제 리뷰 리스트를 구현해보자.

구현 결과는 아래 이미지와 같다.

리뷰 목록 UI

UI만 구현한거라 딱히 어려운 점이 없어 설명할게 많지 않다. 

일단 냅다 코드 전문을 첨부해보겠다. 

<template>
    <div class="review-list-wrapper">
        <div class="header-area">
            <BButton
                size="sm"
                variant="warning"
                @click="$store.commit('registerReview')"
            >
                리뷰 작성
            </BButton>
            <BButton
                v-if="!isEditMode"
                :disabled="!reviews.length"
                class="ml-2"
                size="sm"
                variant="info"
                @click="toggleEditMode"
            >
                편집
            </BButton>
            <BButton
                v-if="isEditMode"
                class="ml-2"
                size="sm"
                variant="info"
                @click="toggleEditMode"
            >
                편집 종료
            </BButton>
            <BButton
                v-if="isEditMode"
                class="ml-2"
                size="sm"
                variant="danger"
                @click="deleteCheckedReviews"
            >
                선택 삭제
            </BButton>
        </div>
        <div class="review-list-area">
            <BCheckbox
                v-if="isEditMode"
                v-model="isAllSelected"
                class="ml-4"
                @change="checkAllReviews"
                >전체 선택
            </BCheckbox>
            <ul v-if="reviews.length > 0">
                <li
                    v-for="review in reviews"
                    :key="review.id"
                >
                    <div class="review-item">
                        <div
                            v-if="isEditMode"
                            class="checkbox-area"
                        >
                            <BCheckbox
                                v-model="checkedReviewIds"
                                :value="review.id"
                                @change="checkReview"
                            />
                        </div>
                        <div
                            class="image-area"
                            @click="goToReview(review)"
                        >
                            <BImgLazy
                                :alt="review.title"
                                :src="`${imgDirPath}/${review.id}/${review.fileName}`"
                                blank
                                blank-color="grey"
                                class="review-image"
                                rounded
                            />
                        </div>
                        <div
                            class="review-info-area"
                            @click="goToReview(review)"
                        >
                            <div class="review-title">
                                {{ review.title }}
                            </div>
                            <div class="review-address">
                                {{ review.address }}
                            </div>
                            <div class="review-update-date">
                                {{ getReviewUpdateDateStr(review.reviewUpDateStr) }}
                            </div>
                            <BFormRating
                                v-model="review.grade"
                                class="review-rating"
                            />
                        </div>
                    </div>
                </li>
            </ul>
            <div
                v-else
                class="no-review-notice"
            >
                등록된 리뷰가 없습니다.
            </div>
        </div>
    </div>
</template>

<script>
import { IMG_DIR_PATH } from '@/common/Config.js';
import { utcDateStrToVisualLocalDateStr } from '@/common/DateUtil.js';
import { process } from '@/common/Api.js';
import { confirm, ok } from '@/common/Dialog.js';
import axios from 'axios';

export default {
    name: 'ReviewList',
    data() {
        return {
            imgDirPath: IMG_DIR_PATH,
            isEditMode: false,
            checkedReviewIds: [],
            isAllSelected: false,
        };
    },
    computed: {
        reviews() {
            return this.$store.state.reviews;
        },
    },
    methods: {
        checkAllReviews() {
            this.checkedReviewIds = [];
            if (this.isAllSelected) this.checkedReviewIds = this.reviews.map((re) => re.id);
        },
        checkReview() {
            this.isAllSelected = false;
        },
        deleteCheckedReviews() {
            process(this, async () => {
                const isConfirmed = await confirm(this, '선택한 리뷰를 삭제하시겠습니까?');
                if (!isConfirmed) return;

                await axios.delete('/api/review/deleteReviews', {
                    data: {
                        reviewIds: this.checkedReviewIds,
                    },
                });

                await ok(this, '삭제되었습니다.');
                await this.$store.dispatch('setReviews', this);
                this.toggleEditMode();
            });
        },
        toggleEditMode() {
            this.isAllSelected = false;
            this.checkedReviewIds = [];
            this.isEditMode = !this.isEditMode;
        },
        getReviewUpdateDateStr(reviewUpDateStr) {
            return utcDateStrToVisualLocalDateStr(reviewUpDateStr);
        },
        goToReview(review) {
            this.$store.commit('setReview', review);
            this.$store.commit('setIsVisibleReviewList', false);
            this.$store.dispatch('setFileList', this);
        },
    },
};
</script>

<style lang="scss" scoped>
.review-list-wrapper {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    padding: 10px;
    display: flex;
    flex-direction: column;

    > .header-area {
        padding: 10px;
        flex-shrink: 0;
    }

    > .review-list-area {
        overflow-y: auto;
        flex: 1;

        > ul {
            list-style: none;
            padding: 10px;
            margin: 0;

            > li {
                padding: 10px;
                background-color: rgba(0, 0, 0, 0.4);
                border-radius: 5px;
                margin-bottom: 10px;

                &:hover {
                    cursor: pointer;
                    background-color: rgba(0, 0, 0, 0.5);
                }

                > .review-item {
                    display: flex;

                    > .checkbox-area {
                        margin: 10px;
                        display: flex;
                        justify-content: center;
                        align-items: center;
                    }

                    > .image-area {
                        margin-right: 20px;

                        > .review-image {
                            width: 120px;
                            height: 100px;
                            object-fit: cover;
                        }
                    }

                    > .review-info-area {
                        flex: 1;
                        display: flex;
                        flex-direction: column;

                        > .review-title {
                            display: flex;
                            font-size: 25px;
                            flex: 1;
                        }

                        > .review-address {
                            display: flex;
                            align-items: center;
                            font-size: 12px;
                            flex: 1;
                            padding-bottom: 5px;
                        }

                        > .review-update-date {
                            display: flex;
                            align-items: center;
                            font-size: 11px;
                            flex: 1;
                            opacity: 0.5;
                            padding-bottom: 5px;
                        }

                        > .review-rating {
                            flex: 1;
                            width: 100px;
                            font-size: 15px;
                            background-color: transparent;
                            border: none;
                            padding: 0;
                            margin: 0;
                            color: #ffdd00;
                            height: unset;
                        }
                    }
                }
            }
        }

        > .no-review-notice {
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            font-size: 25px;
        }
    }
}
</style>

frontend/src/components/ReviewList.vue

 

신경 쓴 부분에 대해 언급하자면...
앞서 구현했던 utcDateStrToVisualLocalDateStr() 메소드를 활용했다.
그리고 Vuex 에 registerReview 라는 mutation 을 새로 하나 파서 사용했다.

    mutations: {
        registerReview(state) {
            setIsVisibleReviewList(state, false);
            setIsDisabledInput(state, false);
            setReview(state);
        }
        // ...
    }

frontend/src/store/index.js

 

'리뷰 작성' 버튼을 클릭했을 때 발생하는 동작이다.


일단 리뷰 목록 컴포넌트가 사라지고 리뷰 폼 컴포넌트가 보여야해서 isVisibleReviewListfalse 로 set하게헸고,
현재 리뷰 정보를 set해주는 로직이 동작하게 했다.

 

리뷰 작성/리뷰 목록 버튼으로 컴포넌트가 여닫히는 모습

목록에서 여러 리뷰를 삭제하면 좋을 것 같아서,
편집 버튼도 추가해줬다.


편집 버튼을 누르면 체크박스가 보이게 되고, 체크된 항목의 id를 배열에 담는다.
API는 기존의 deleteReview를 deleteReviews 로 수정하고 id 배열을 파라미터로 넘겨줬다.

 

편집 버튼으로 편집 모드를 활성화할 수 있다.

 

리뷰가 아무것도 없을 땐 '등록된 리뷰가 없습니다'라는 문구가 뜨게하고,
편집 버튼을 비활성화 시켰다.

리뷰가 아무것도 없을 때의 UI

🍜 페이징 기법 종류

대충 리뷰 목록 UI 틀을 잡았으니, 본격적으로 페이징을 구현해보자.


본 포스팅의 상단에서 UI 측면에서의 페이징 기법에 대해 소개하였다. (페이지네이션/무한스크롤)
DBMS 측면에서도 페이징 기법을 크게 두가지로 나눌 수 있다.


바로 offset/limit 기법과 keyset 기법이다.

🍥 offset/limit

말 그대로 페이징을 위한 쿼리문에서 offset/limit 문법을 사용하는 것을 말한다.

 

LIMIT
원하는 행 수만큼만 데이터를 출력한다.

 

OFFSET
원하는 행 수만큼 건너뛰고 그 이후의 행부터 검색이 가능하다.

select
    *
from
    tbl_review;

select
    *
from
    tbl_review
limit 1 offset 2;

예를들어, 한 페이지에 10 rows 씩 보이는 화면이 있다고 할 때 offset/limit을 활용한다면 ,
1페이지의 내용을 가져오는 쿼리는 아래와같이 작성해줄 수 있다.

select
    *
from
    tbl_review
limit 10 offset 0;

3페이지의 내용을 가져오는 쿼리는 아래와 같을 것이다.

select
    *
from
    tbl_review
limit 10 offset 20;

🍥 keyset

Seek Method 라고도 불린다.


offset/limit 기법과 비슷하게 limit 을 사용하긴 하지만,
다른 점은 key 로 페이징을 한다는 것이다.
여기서 key는 어렵게 생각할 것 없이 DB 컬럼이라고 보면된다.


어떤 컬럼의 값으로 다음 페이지를 찾기 때문에 반드시 해당 컬럼을 기준으로 '정렬'이 되어 있어야한다.
그리고 해당 컬럼의 값이 중복된다면 누락되는 페이지가 나올 수 있다.

따라서 key(컬럼)는 Unique(고유한) 값을 가지고 있어야한다.

 

결론적으로 key는 정렬된 기본키인 것이다.

select
    *
from
    tbl_review
order by id;

select
    *
from
    tbl_review
where id > '6410e4d2-4f20-46eb-9431-7e8d0a1fc2db'
order by id 
limit 2;

Primary key인 id로 정렬을 하고
id의 값을 기준으로 다음 rows를 찾는 방식이다.

🍥 UI별 적절한 DB 페이징 기법

무한 스크롤 기법엔 keyset 방법이 적절하다.
키의 '값'으로 다음 페이지를 찾기 때문에,
마지막 rows 의 데이터로 다음 페이지를 불러오는 무한 스크롤에 적합한 방법임을 알 수 있다.


물론 offset/limit 방법으로도 무한 스크롤 구현이 가능하다.
하지만 성능상 굳이...? offset/limit 으로 구현할 필요가 없다.
(각 기법의 성능은 아래에서 설명한다.)

 

페이지네이션 기법엔 offset/limit 방법이 적절하다.
keyset 기법 사용시 키의 값으로 다음 페이지를 찾아야하는데,
저 멀리있는 페이지 rows 의 값을 알 방법이 없기 때문이다.


예를 들어 ketset을 사용한다면 6페이지 값을 알기 위해 5페이지의 마지막 값을 알아야하는데,
값을 알아내기 위해 모든 rows를 가지고 올 수는 없기 때문이다. (이러면 페이징 하는 의미가 없다.)

 

그렇다고 페이지네이션 기법에 keyset을 완전 사용이 불가능한 것은 아니다.
개인적으로 조금 이상한 UI라고 생각되지만 아래와 같이 구현해줄 수 있겠다.
반드시 그 페이지를 로드하여 마지막 값을 알아내고 다음 페이지를 구해오는 UI이다.

아직 가져오지 못한 페이지가 ...으로 표현된 모습

🍥 offset/limit VS keyset

offset/limit과 keyset 에 대한 개념을 알아보았으니,
이번엔 각 기법의 성능에 대해 공부해보자.

 

지금 근무하고 있는 팀에선 모든 페이징에 대해 keyset 방식을 사용하고 있다.
UI는 모두 무한스크롤로 되어 있다.
그리고 offset/limit 방식을 철저히 배제하고 있다.

 

이유는 성능 때문이다.
우리팀 뿐만아니라 조금만 서치해봐도 절대적으로 keyset의 성능이 우수하다는 글을 많이 찾을 수 있다.

 

offset/limit 을 사용하게 되면,
DB의 첫번째 행부터 offset 행까지 전부 스캔을하고 limit 한 값만큼 행을 가져온다.

 

예를들어, 아래와 같은 쿼리를 실행한다고 해보자.

select
    *
from
    tbl_review 
limit 25 
OFFSET 200024;

200024번째 행을 찾기위해 DBMS는 첫번째 행부터 200024행까지 스캔을 하고,
해당 행부터 25개의 행을 가져오기 위해 한번 더 스캔을 한다.

즉, 2000024 + 25 = 200049 행을 스캔해야하는 것이다.

아주 뒷편에 있는 페이지를 클릭하면 성능이 좋지 못한 쿼리를 돌려야하는 셈이다. 

 

그럼 페이지네이션(limit/offset)은 성능이 좋지 못함에도 사용되는 이유가 무엇일까?

그건 일반적으로 사용자가 페이지 번호가 5 페이지 이상인 것을 거의 읽지 않기 떄문이다. 

1000페이지를 읽는 데 5초가 걸리더라도 1페이지를 읽는 데 0.01 초밖에 걸리지 않는다면,

사용자들은 그렇게 느리다고 느끼지 않게 된다. 

이러한 특성을 이용해 게시물을  offset/limit으로 가져올 수 있는 것이다. 

 

그렇다면 keyset은 어떤가? offset/limit 과 성능적으로 큰 차이가 있을까?
답은, "그렇다!"


왜냐하면 keyset은 key를 사용하기 때문이다.

key는 고유한 값을 가진 컬럼, 즉, 기본키를 사용한다.
이는 인덱스(목차) 역할을 하여 rows를 전부 스캔할 필요 없이 빠르게 해당 row를 찾을 수 있다.

 

keyset 기법은 offset/limit 처럼 DBMS가 한줄.. 한줄 스캔해서 아.. 이게 몇번째 행이구나...하지 않고
어떤 값인 키를 쏵 바로 찾아서 그 다음 값 limit 만큼만 후딱 잘라올 수 있는 것이다.

 

🚀 참고) Primary key 와 Index 의 차이점

PRIMARY KEY

기본키. 이하 PK.
일반적인 DBMS에서 PK는 자동으로 Index 가 적용된다.
PK는 개념적인 값이다. 실제 값이 존재하지만 PK라고 따로 물리적으로 저장되지 않는다.
PK는 여러 Tuple(= row = 행) 중 유일한 Tuple 임을 보장한다.

 

Index

Tuple 들의 유일성을 보장하지 않는다.
Index 는 단지 테이블에서 Tuple을 보다 빨리 찾기 위해 사용된다.
Index를 걸면 Index를 거는 컬럼을 기준으로 새로운 자료구조(B-tree 등)를 생성하여 별도의 디스크 공간에 저장된다.

아래 명령으로 생성해 줄 수 있다. 

    CREATE INDEX <인덱스명> ON <테이블명> ( 칼럼명1, 칼럼명2, ... );

🍜 Pagination 구현

본격적으로 페이징을 구현해보자.

프로젝트엔 무한스크롤을 적용할 것이지만,
페이지네이션도 공부해보고 싶기 때문에!
따로 브랜치를 파서 페이지네이션 방식을 구현해볼 것이다.

git checkout -b pagination-demo

checkout은 브랜치를 이동한다는 git 명령어이고
-b 옵션을 붙이면 브랜치를 생성과 동시에 이동이 가능하다.
나는 pagination-demo 라는 브랜치를 파줬다.

🍥 테스트 프로시저 구현

페이징 구현을 위한 리뷰를 추가해보자.
UI에서 일일이 입력하기엔 좀 귀찮아서 테스트 리뷰를 추가해줄 프로시저를 짜보았다.

delimiter //
create procedure insertReviews(num int)
begin
    declare i INT default 1;
while (i <= num) DO
    insert
    into
    tbl_review(id,
    title,
    review,
    address,
    grade,
    lon,
    lat,
    update_date)
values
    (
        uuid(),
        CONCAT(CAST(i AS CHAR), 'title'),
        CONCAT(CAST(i AS CHAR), 'review'),
        CONCAT(CAST(i AS CHAR), 'address'),
        3,
        127 + (0.01 * i),
        37 + (0.01 * i),
        utc_timestamp()
    );
    set
i = i + 1;
end while;
end //;
delimiter ;
call insertReviews(100);

입력한 숫자만큼 반복을 돌면서 테스트 리뷰를 insert 하는 프로시저이다.

나는 사실 이전에 프로시저를 거의 사용해본 적 없기 때문에...
생소한 부분의 개념만 살짝 짚고 넘어가보겠다.

 

프로시저 는 DBMS의 function 이라고 생각하면된다.
선언하고 실행해주면서 함수처럼 사용이 가능하다.
함수와 다른 점은 결과값을 반환하지 않는다는 점이다.

create, begin, end 로 생성해주고 call 로 호출 할 수 있다.

 

delimiter
Delimiter는 직역하면 '구문 문자'로,
문법의 끝을 나타낸다.
함수의 블럭 {}이라고 생각하면된다.
즉, 프로시저의 스코프를 나타내는 것이다.

 

여기서 delimiter는 // 로 선언되어 사용하고 있다.
$$으로 선언해줘도 상관없다.
이렇게 특수문자로 따로 선언해주는 이유는,
프로시저 내부에 세미콜론이라는 문법의 끝을 나타내는 또다른 기호를 사용 중이기 때문이다.

 

시작 부분에 // 로 delimiter 선언해주고,
프로시저의 끝에 한번더 언급하여
이 영역 내부에 세미콜론이 있지만 이건 모두 하나의 프로시저 내부에 존재하는 것이기 때문에
세미콜론이 프로시저의 끝부분이 아님을 선언해줄 수 있다.
(마지막에 기호 없이 delimiter 만 써주는 건 delimiter의 초기화라고 생각하면 된다.)

 

declare
declare는 직역하면 '선언하다'라는 뜻으로,
함수에서 변수를 선언하듯이 사용해줄 수 있다.
나는 반복을 위해 default 값이 1인 int i를 선언해줬다.

프로시저 실행 후 모습

테스트 리뷰 데이터가 정상적으로 insert 된 것을 확인할 수 있었다! 🎉

🍥 Pagination UI 구현

사실 페이징 UI는 부트스트랩이 굉장히 잘 되어 있기 때문에,
부트스트랩 HTML 태그의 프로퍼티만 잘 제공하면 크게 어려운 부분이 없지만...
개념정도는 알고 있어야할 것 같아서 파악하고 넘어갈 것이다.

 

페이징 UI를 구현하기 위해선 크게 여섯가지 정보를 구해야한다.
여기서 괄호의 네이밍은 수정해줘도 상관 없다.

  1. 한 페이지에 출력될 게시물 수 (countList)
  2. 총 페이지 수 (totalPage)
  3. 한 화면에 출력될 페이지 수 (pageCnt)
  4. 현재 페이지 번호 (curPage)
  5. 화면에 보이는 시작 페이지 (startPage)
  6. 화면에 보이는 끝 페이지 (endPage)

1. 한 페이지에 출력될 게시물 수 (countList)
나는 countList 를 10으로 잡을 것이다,
즉, 한 페이지에 출력될 게시물 수가 10개라는 것이다.
이 값은 처음 정해주고 나선 변할 일이 없다.

 

2. 총 페이지 수 (totalPage)
이렇게 잡은 countList 로부터 우린 총 몇 페이지(totalPage)가 나오는지 구할 수 있다.
리뷰 100개를 insert 했으니
100 / 10 을 하여 totalPage가 총 10 페이지가 나온다는 것을 알 수 있다.
만약 내가 101개의 리뷰를 추가했다면 총 11페이지가 나오게 될 것이다.
totalPage 를 구하는 코드는 아래처럼 해줄 수 있겠다.

int totalCount = 100; // select count(*) from tbl_review; 로 가져온 값
int countList = 10; // 내가 정해준 값
int totalPage = totalCount / countList;
if (totalCount % countList > 0) {
    totalPage++;
}

3. 한 화면에 출력될 페이지 수 (pageCnt)
나는 pageCnt 를 5으로 잡을 것이다,
즉, 한 화면에 출력될 페이지 수가 5개라는 것이다.

한 화면에 출력된 5개의 페이지

4. 현재 페이지 번호 (curPage)
curPage 의 초기 값은 1일 것이고,
이 후엔 유저가 클릭하는 대로 유동적으로 변경될 것이다.

 

5. 화면에 보이는 시작 페이지 (startPage)
pageCnt가 5이기 때문에,
1에서 5사이의 수를 선택한다면
startPage 는 1일 것이다.
5이상 10 이하 페이지를 선택한다면
startPage 는 6일 것이다.

이 startPage 를 찾는 공식은 아래처럼 구현할 수 있겠다.

int curPage = 3;
int pageCnt = 5;
int startPage = parseInt((curPage - 1) / pageCnt) * pageCnt + 1;

사실 나는 이 계산식을 이해하고 구하는 데에 꽤 오랜 시간이 걸렸다... ㅋㅋ
이 계산식 말고도 더 좋은 로직이 있을 수도 있다. (있다면 댓글 부탁드립니다! )
만약 이해가 가지 않는다면 그대로 외워서 활용해도 괜찮다.
그래도 이해를 돕기 위해 차근차근 예제로 설명해보겠다.

 

현재 페이지(curPage)가 3이고,
한 화면에 출력될 페이지 수(pageCnt)가 5일 때,
startPage는 1이 되어야한다.

pageCnt 가 5이기 때문에 화면엔 1,2,3,4,5 또는 6,7,8,9,10 이런식으로 다섯개의 수로 구성된 뭉탱이가 보여질 것이다.
이 뭉탱이를 편의상 '섹션'이라고 칭하겠다.


curPage 가 3이라는 것은 0 섹션이 화면에 출력된 상태란 뜻이다. (인덱스 개념으로... 0부터 섹션을 세었다)
만약 curPage 가 7이라면 1 섹션인 것이다.
즉, 첫번째로 해야할 짓은 curPage 값이 포함된 섹션 구하기 이다.

 

섹션을 구하는 건 쉽다. curPage 를 pageCnt 로 나눠보면된다.
현재 페이지가 3일 땐, 3 / 5 하여 0.6 이고,
현재 페이지가 7일 떈, 7 / 5 하여 1.4 이다.
여기서 소수점은 필요가 없으므로 parseInt 로 버려준다.
그러면
현재 페이지가 3일 땐, 0섹션,
현재 페이지가 7일 땐, 1섹션이 된다.

 

이렇게 섹션을 구하고 난 다음엔 이 섹션의 첫번째 값을 구해야한다.
0 섹션의 첫번째 값은 1
1 섹션의 첫번째 값은 6 이다.
이를 구해주기 위해 아래와 같은 계산을 해줄 수 있다.

섹션 * pageCnt + 1

0 섹션일 때, pageCnt 인 5를 곱해주면 0이된다.
우린 페이지를 1부터 시작하도록 구현했기 때문에 1을 더해줘서 첫번째 값을 찾을 수 있다.

마찬가지로,
1 섹션일 때, pageCnt 인 5를 곱해주면 5가 된다.
여기서 1을 더해주면 1 섹션의 첫번째 값인 6을 구할 수 있다.

int startPage = parseInt((curPage - 1) / pageCnt) * pageCnt + 1;

그렇다면 위 공식에서 1은 대체 왜 빼주는 걸까?
바로 현재 페이지가 섹션의 "끝 값"일 경우를 대비해서이다.

 

현재 페이지가 0 섹션의 끝 값인 5라고 가정해보자.
위 과정대로 5 / 5 를 하면 0섹션이 아닌 1섹션이 나오는 것을 확인할 수 있다.
마찬가지로,
현재 페이지가 1 섹션의 끝 값인 10일 때,
10 / 5를 하면 2가 되어 1섹션이 아닌 2섹션이 되는 것을 볼 수 있다.


이러한 예외를 방지하기 위해 curPage에서 1을 빼주는 것이다.

이는 모두 페이지가 0부터 시작하는게 아니라 1부터 시작해서 생기는 예외이다.
0으로 시작하게 구하고 화면에 출력할 때 1을 더해주던가 하는 다른 방법도 있겠지만,
헷갈려서 나는 이렇게 1을 더하거나 빼도록 했다. @_ @

 

6. 화면에 보이는 끝 페이지 (endPage)

int curPage = 3;
int pageCnt = 5;
int startPage = parseInt(curPage/pageCnt) + 1;
int endPage = startPage + pageCnt - 1;

endPage 를 구하는 법은 쉽다.
startPage 를 구하고, pageCnt(한 화면에 출력될 페이지 수)를 더하고, 1을 빼주면 끝.

 

현재 페이지(curPage)가 3이고,
한 화면에 출력될 페이지 수(pageCnt)가 5일 때,
startPage는 1이 된다.

startPage 에 pageCnt 를 더하고 1을 빼면
endPage 는 1 + 5 - 1을 하여 5가 된다.

 

https://codepen.io/doozi316/pen/oNdMxRm

 

Pagination

...

codepen.io

위 개념을 활용하여 간단한 페이징 UI를 구현해보았다.
startPage 구하는 로직을 이해하고 나선 크게 어렵지 않았다. 😎

 

지금까지 구현해본 페이징이 Bootstrap 에 자동화 되어있다.
Bootstrap 으로 페이징을 구현해보자.

    <div class="review-list-wrapper">
        <!-- ... -->
        <div class="pagination-area">
            <BPagination
                v-model="curPage"
                :total-rows="rows"
                class="pagination"
                hide-ellipsis
                per-page="10"
                size="sm"
                @change="changePage"
            />
        </div>
    </div>

    // script
    data() {
        return {
            // pagination
            rows: 100,
            curPage: 1,
        };
    },
    methods : {
        chagnePage(e) {
            console.log(e);
        }
    }

    // css
    > .pagination-area {
        > .pagination {
            background-color: transparent;
            padding-top: 10px;
            margin: 0;
            display: flex;
            justify-content: center;

            ::v-deep .page-link {
                background-color: transparent;
                color: white;
                border-color: white;
                box-shadow: unset !important;
            }

            ::v-deep .page-item.active {
                box-shadow: unset !important;
                background-color: $warning;
                border-color: $warning !important;
            }
        }
    }

frontend/src/components/ReviewList.vue

 

임의로 페이지 번호를 매기고 UI를 그려보았다.
스타일은 버튼들이랑 색을 맞추면 좋을 것 같아서 부트스트랩의 $warning 색을 사용했다.

 

 

부트스트랩으로 쉽게 페이징 UI를 개발할 수 있었다! 🎉

🍥 Pagination API 구현

UI를 구현했으니 페이지 내용을 가져오는 API를 구현해보자.

limit, offset 방법으로 페이지를 구해올 것이다. 


예를 들어,
현재 페이지가 3페이지이고, 한 페이지당 10개의 리뷰 목록을 불러온다고 가정해보자.
3페이지이니, 20번째 부터 10개의 리뷰를 가져오면 된다.

위 이미지는 id로 정렬된 tbl_review 이다.


여기서 1부터 10 row 까지는 1페이지
11부터 20까지는 2페이지
21부터 30까지 3페이지가 되어야한다.


이를 구하는 쿼리문은 아래와 같이 해줄 수 있다.

select
    *
from
    tbl_review
order by id
limit #{countList}
offset (#{curPage} - 1) * #{countList};

참고로, MYSQL 은 아래와 같이 offset 을 생략해줄 수 있다.

select
    *
from
    tbl_review
order by id
limit (#{curPage} - 1) * #{countList},  #{countList}

현재 페이지가 3일 때
21 row부터 30 row 까지 출력되는 걸 확인할 수 있었다! 🎉

 

이제 이 쿼리문을 활용하여 API를 짜 줄 것이다.
그런데 지금 기존에 getReviews 라는 모든 리뷰를 통째로 가져오는 API가 구현되어 있다.
이 쿼리문은 지도에 뿌려질 위도, 경도 정보도 가져오기 때문에
이 API를 수정하기 보다는, 기존의 getReviews을 삭제하고
지도에 필요한 정보를 가져오는 getReviewsForMap API와,
페이징 기법으로 일부 리뷰 목록을 가져오는 getReviewsByLimit API를 새로 작성해줄 것이다.


getReviewsByLimit를 위해선 리뷰의 totalCnt 가 필요하니, getReviewsCnt API도 만들어주자.

그리고 원래 지도의 아이콘을 클릭하면,
가져온 모든 리뷰 중에 클릭한 현재 리뷰 정보를 사이드바에 보여줬었는데,
이젠 클릭한 아이콘이 리뷰 리스트에 존재하지 않을 수도 있으니
클릭했을 때 클릭한 현재 리뷰 정보만 가져오는 getReview API도 새로 파주기로 한다.

🍤 getReviewsForMap API

Controller

@GetMapping("/getReviewsForMap")
    public List<ReviewDTO> getReviewsForMap() {
        return reviewService.getReviewsForMap();
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

Service

public List<ReviewDTO> getReviewsForMap() {
        return reviewDAO.getReviewsForMap();
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java
DAO

List<ReviewDTO> getReviewsForMap();

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsForMap" resultType="camelMap">
        select
            r.id,
            r.title,
            r.grade,
            r.lon,
            r.lat
        from
            tbl_review r
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

Map 에 뿌리기 위한 필요 정보만 가져온다.

🍤 getReviewsByLimit API

Controller

    @GetMapping("/getReviewsByLimit")
    public List<ReviewDTO> getReviewsByLimit(@RequestParam Integer curPage,
                                             @RequestParam Integer countList) {
        return reviewService.getReviewsByLimit(curPage, countList);
    }

src/main/resources/mappers/ReviewMapper.xml

Service

public List<ReviewDTO> getReviewsByLimit(Integer curPage, Integer countList) {
        Integer offset = (curPage - 1) * countList;
        return reviewDAO.getReviewsByLimit(offset, countList);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

List<ReviewDTO> getReviewsByLimit(@Param("offset") Integer offset,
                                      @Param("countList") Integer countList);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsByLimit" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type,
            DATE_FORMAT(f.update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        order by r.update_date, r.id
        limit #{countList}
        offset #{offset}
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

🍤 getReviewsCnt API

Controller

    @GetMapping("/getReviewsCnt")
    public int getReviewsCnt() {
        return reviewService.getReviewsCnt();
    }

src/main/resources/mappers/ReviewMapper.xml

Service

    public int getReviewsCnt() {
        return reviewDAO.getReviewsCnt();
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

int getReviewsCnt();

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsCnt" resultType="int">
        select
            count(*)
        from
            tbl_review
    </select>

src/main/resources/mappers/ReviewMapper.xml

🍤 getReview API

Controller

@GetMapping("/getReview")
    public ReviewDTO getReview(@RequestParam String reviewId) {
        return reviewService.getReview(reviewId);
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

Service

public ReviewDTO getReview(String reviewId) {
        return reviewDAO.getReview(reviewId);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

ReviewDTO getReview(@Param("reviewId") String reviewId);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReview" resultType="com.map.restaurant.good.dto.ReviewDTO">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str
        from
            tbl_review r
        where
            r.id = #{reviewId}
        order by r.update_date
    </select>

src/main/resources/mappers/ReviewMapper.xml

🍥 Pagination API 적용 및 기존 코드 정리

API 가 여러 개 추가되면서 기존 코드가 자잘하게 여러군데 많이 바뀌었는데,
다 언급하기엔 사소해서 주의했던 부분만 짚고 넘어갈 것이다.
자세한 건 github의 커밋 코드를 참고!

 

action 수정 및 추가

기존의 setReviews action 과 mutation 을 지우고,

setReviewsByLimitsetReviesForMap 을 추가했다.


지도에 뿌려질 아이콘 정보를 담기 위한 reviewsForMap state도 추가해줬다.
지도의 아이콘을 클릭했을 때, 해당 리뷰를 사이드 바에 뿌려줘야하기 때문에,
setReview action, mutation 도 추가해줬다.


파일도 보여주기위해선, setReview 은 내부에 setFileList 가 호출되어야한다.

Store 전문은 아래와 같다.

import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
import { process } from '@/common/Api.js';

Vue.use(Vuex);

export default new Vuex.Store({
    state: {
        reviews: [],
        reviewsForMap: [],
        curLon: undefined,
        curLat: undefined,
        curReviewId: undefined,
        curAddress: undefined,
        curTitle: undefined,
        curGrade: undefined,
        curReview: undefined,
        isDisabledInput: true,
        curFileList: [],
        isVisibleReviewList: true,
    },
    mutations: {
        setIsDisabledInput: (state, bool) => {
            setIsDisabledInput(state, bool);
        },
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
            setIsVisibleReviewList(state, false);
        },
        setCurTitle: (state, title) => {
            state.curTitle = title;
        },
        setCurAddress: (state, address) => {
            state.curAddress = address;
        },
        setCurGrade: (state, grade) => {
            state.curGrade = grade;
        },
        setCurReview: (state, review) => {
            state.curReview = review;
        },
        setReviewsByLimit: (state, reviews) => {
            state.reviews = reviews;
            setIsVisibleReviewList(state, true);
        },
        setReviewsForMap: (state, reviews) => {
            state.reviewsForMap = reviews;
        },
        setReview: (state, review) => {
            setReview(state, review);
        },
        setCurFileList: (state, images) => {
            state.curFileList = images;
        },
        setLonLat: (state, { lon, lat }) => {
            state.curLon = lon;
            state.curLat = lat;
        },
        setIsVisibleReviewList: (state, bool) => {
            setIsVisibleReviewList(state, bool);
        },
        registerReview(state) {
            setIsVisibleReviewList(state, false);
            setIsDisabledInput(state, false);
            setReview(state);
        },
    },
    actions: {
        async setReview({ state, dispatch }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReview', {
                    params: {
                        reviewId: state.curReviewId,
                    },
                });
                setReview(state, result.data);
                dispatch('setFileList');
            });
        },
        async setReviewsForMap({ commit }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsForMap');
                await commit('setReviewsForMap', result.data);
            });
        },
        async setReviewsByLimit({ commit }, { that, curPage, countList }) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsByLimit', {
                    params: {
                        curPage: curPage || 1,
                        countList: countList || 10,
                    },
                });
                await commit('setReviewsByLimit', result.data);
            });
        },
        async setFileList({ commit, state }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/file/getImages', {
                    params: { reviewId: state.curReviewId },
                });
                await commit('setCurFileList', result.data);
            });
        },
    },
    modules: {},
});

function setReview(state, review) {
    state.curReviewId = review ? review.id : review;
    state.curLat = review ? review.lat : review;
    state.curLon = review ? review.lon : review;
    state.curTitle = review ? review.title : review;
    state.curGrade = review ? review.grade : review;
    state.curAddress = review ? review.address : review;
    state.curReview = review ? review.review : review;
}

function setIsVisibleReviewList(state, bool) {
    state.isVisibleReviewList = bool;
}

function setIsDisabledInput(state, bool) {
    state.isDisabledInput = bool;
}

frontend/src/store/index.js

 

리뷰 클릭시 지도 Zoom

기존엔 리뷰목록이 없었기 때문에 사이드 바에서 리뷰를 클릭한다는 개념이 없었지만,
이젠 리뷰 목록이 생겼기 때문에 리뷰 목록을 클릭했을 때 지도에서 아이콘을 zoom 하는 기능을 추가할 것이다.

        goToReview(review) {
            this.$store.commit('setCurReviewId', review.id);
            this.$store.commit('setIsVisibleReviewList', false);
            this.$store.dispatch('setReview', this);
        },

frontend/src/components/ReviewList.vue

 

위 코드는 리뷰 목록을 클릭했을 때 발생하는 goToReview 메소드이다.

요약하자면,
현재 클릭한 리뷰의 id를 set하고
리뷰 목록을 가리고 (리뷰 작성 폼을 출력)
현재 클릭한 리뷰 정보를 가져오는 내용이다.

 

알 수 있듯이, setReview 로 인해 review 상태 및 정보가 변경된다.
이를 활용하여 리뷰가 선택되었다는 상태를 인지하게하고 zoom 기능을 추가해주면 된다.
MainMap.vue 에 아래와 같이 작성했다.

    computed: {
        ...mapState({
            // ...
            curReview: (state) => state.curReview,
            curLon: (state) => state.curLon,
            curLat: (state) => state.curLat,
        }),
    },
    watch: {
        // ...
        curReview() {
            if (!this.curReview || !this.curLon || !this.curLat) return;
            const coordinate = [this.curLon, this.curLat];
            this.olMap.getView().setZoom(18);
            this.olMap.getView().setCenter(this.coordi4326To3857(coordinate));
        },
    },

curReview 가 set되면,
현재 위, 경도 정보로 coordinate 를 선언해주고,
해당 위치를 센터로 맞추고 zoom 을 땡기는 기능이다.


여기서 18은 내가 임의로 정해준 값이다.
더 확대하고 싶다면 이 보다 큰 값을 써주면 된다.

 

여기서 curReview 로 리뷰의 변경을 판단해주는 이유는,
curLon이나 curLat은 리뷰를 클릭했을 때 뿐만아니라 지도를 클릭했을 때도 set되는 상태여서 안되고,
curReviewIdcurReviewId 가 set되고 난 이후에 curLon, curLat 정보를 업데이트할 수 있기 때문에
sync 가 맞지 않아 쓸 수 없었다.

 

OpenLayers 는 getView, setZoom, setCenter 메소드를 지원한다.

getView는 지도와 관련된 화면(View) 를 가져오는 메소드로, 해상도나 center 속성을 관리하고 있다.
setZoom 이나 setCenter를 사용하기 위해 함께 써주면된다.
setZoomsetCenter 는 읽으면 바로 어떤 기능인지 알 수 있을 것이다.

 

리뷰 클릭시 Zoom!

 

성공적으로 Zoom 되는걸 확인할 수 있었다! 🎉

 

v-if 대신 v-show 사용

            <ReviewList v-show="isVisibleReviewList" />
            <ReviewForm v-show="!isVisibleReviewList" />

frontend/src/components/SideBar.vue

 

이건 사이드 바 컴포넌트의 코드인데,
기존엔 v-if 로 리뷰 목록과 리뷰 폼을 조건적으로 출력되게 했었다.
이를 v-show 로 출력되도록 수정해주었다.

    async created() {
        await this.setReviewsCnt();
        await this.setReviews();
    },

frontend/src/components/ReviewList.vue

 

이유는 위 코드 때문이다.
리뷰 목록 컴포넌트 코드인데, 컴포넌트가 created 되면서 리뷰를 가져오는 동작을 수행하고 있다.


v-if 는 조건에 따라 컴포넌트를 화면에 출력시킬 때
컴포넌트를 완전히 destroy(파괴) 시킨 뒤 새로 create 해온다.

 

때문에 리뷰 목록이 화면에서 사라지고 보일때마다 created의 메소드들이 수행된다.
이러면 리뷰 목록을 새로 볼 때 마다 매번 첫번째 페이지만 가져오게 되어버린다.

그래서 v-show 로 수정해주었다.


v-show 는 컴포넌트를 완전히 destory 시키는게 아니라
출력하지 않는 상황엔 컴포넌트를 hide 를 시킨다.


즉, 화면에 컴포넌트 자체가 존재하긴 하나 잠깐 눈속임하듯이 가려놓기만 한 것이다.
때문에 사라졌다가 생성될 때 created 가 수행되지 않고,
기존 상태 그대로 유지된다.

 

 

결과적으로 페이지네이션이 정상 작동하도록 구현했다! 🎉

🍜 무한 스크롤 구현

이제 무한 스크롤을 구현해볼 것이다!


구현한 페이지네이션은 push 하고 다시 master 브랜치로 돌아와 구현해보자.

git checkout master

무한 스크롤 역시 라이브러리를 활용할 생각이었는데,
코드 양이 딱히 줄어드는 것도 아니고...
라이브러리라는 게 편하자고 사용하는 것인데 오히려 코드 구현할 때 번거롭고 신경쓰이는 부분이 많아서 직접 구현하기로 했다.
참고로, 무한 스크롤 UI 라이브러리는 Vue-infinite-loading 를 사용했었다.

(이 라이브러리로 구현완료 했었는데, 코드가 지저분해보여서 그냥 날렸다.)

 

프로젝트에 직접 구현하기 전에,
무한 스크롤에 대해 제대로 이해해보고자 데모 무한 스크롤을 구현해보았다.

https://codepen.io/doozi316/pen/bGMxjZW

 

Infinite Scroll

...

codepen.io

<div id="hello-vue" class="demo">
  <ul @scroll="loadMore">
    <li v-for="i in itemCnt" key="i">
      Item{{i}}
    </li>
    <li class="loading">
      Loading...
    </li>
  </ul>
</div>

@scroll 이벤트를 활용하여 스크롤을 하단으로 내렸을 때
데이터를 채워넣는 기능을 구현했다.

그리고 리스트 하단에 Loading 요소를 구현하고 스크롤을 내렸을 때 로딩이 된다는 걸 표시해줬다.

const HelloVueApp = {
  data() {
    return {
      itemCnt: 10
    };
  },
  created() {
    this.loadMore();
  },
  methods: {
    onScroll(e) {
      let { scrollTop, clientHeight, scrollHeight } = e.target;
      if (scrollTop + clientHeight >= scrollHeight) {
        this.loadMore();
      }
    },
    loadMore() {
      setTimeout(() => {
        this.itemCnt += 10;
      }, 1500)
    }
  }
};

onScroll 메소드를 보면,
이벤트의 target 속성의 scrollTop, cientHeight, scrollHeight 를 가져온다.

  • scrollTop
    • 요소의 콘텐츠가 세로로 스크롤되는 픽셀 수. 요소의 상단에서 가장 상단에 보이는 콘텐츠까지의 거리를 나타낸다.
  • clientHeight
    • 요소의 내부 높이. 패딩 값은 포함되며, 스크롤바, 테두리, 마진은 제외된다.
  • scrollHeight
    • 요소에 들어있는 컨텐츠의 전체 높이. 패딩과 테두리가 포함된다. 마진은 제외된다.

target 속성 정보

scrollTopclientHeight를 더한 값이 scrollHeight 보다 같거나 크다면 데이터를 더 가져온다.
즉, 스크롤을 가장 하단으로 내렸을 때를 의미한다.

 

데이터를 가져오는 loadMore 메소드는 실제론 axios 로 DB의 데이터를 가져와야한다.
일단 임의로 1.5초 후에 10씩 row 수를 증가시키는 것으로 구현했다.

 

무한스크롤이 제대로 동작하는 것을 확인할 수 있었다! 🎉

🍥 무한 스크롤 UI 구현

데모 코드로 무한 스크롤의 동작을 이해하였으니,
프로젝트에도 적용해보자.


페이지네이션 구현 때 수정했던 부분과 일치하는 코드가 많아 무한 스크롤 코드의 핵심만 짚고 넘어가 보겠다.
코드 전문은 깃헙에서 확인할 수 있다.

        <div
            class="review-list-area"
            @scroll="onScroll"
        >
            <!-- ... -->
                <li
                    v-if="processingCount > 0"
                    class="progress-list"
                >
                    <ProgressSpinner />
                </li>
            </ul>
        </div>

// script

import ProgressSpinner from '@/components/ProgressSpinner.vue';

// ...
    data() {
        return {
            processingCount: 0,

        };        

// css
            > .progress-list {
                position: relative;
                padding: 50px 0;
            }

frontend/src/components/ReviewList.vue

 

먼저 ProgressSpinner를 import 해와서, 리스트 최하단에 구현해준다.
데모에서 구현했던 loading... 부분 인 것이다.

    created() {
        this.getReviews();
    },

    // methods
        async onScroll(e) {
            if (this.isEndOfList) return;
            let { scrollTop, clientHeight, scrollHeight } = e.target;
            if (scrollTop + clientHeight >= scrollHeight && this.processingCount === 0) {
                await this.getReviews();
            }
        },
        async getReviews() {
            const params = {
                that: this,
                reviewUpdateDate: this.reviewUpdateDate,
                reviewId: this.reviewId,
            };
            await this.$store.dispatch('setReviewsByKeySet', params);
            if (this.reviews.length > 0) {
                const lastReview = this.reviews[this.reviews.length - 1];
                this.reviewUpdateDate = lastReview.reviewUpDateStr;
                this.reviewId = lastReview.id;
            }
        },

frontend/src/components/ReviewList.vue

 

onScroll 메소드 역시 데모와 똑같다.
스크롤이 최하단에 도달했을 때 getReviews 메소드가 실행되도록 구현했다.


여기서 isEndOfList 는 리스트를 전부 가지고 왔을 때를 의미한다.
vuex state 를 새로 파줘 활용한 것으로, 아래에서 한 번 더 언급할 것이다.

 

getReviews 메소드에선 setReviewsByKeySet vuex 액션이 실행되도록 했다.
이름 그대로, keyset 방식으로 리뷰를 가져오게 한 것이다.

 

스크롤 이벤트는 스크롤을 움직일 때마다 발생하기 때문에, 

getReviews가 의도치않게 여러번 실행될 수도 있다.

이런 상황을 방지해 processingCount 가 0일 때, 

즉, 아무런 API가 돌고 있지 않을 때 getReviews 가 동작할 수 있게 this.processingCount === 0 을 추가했다.

//state
    isEndOfList: false,

// mutations
        setReviewsByKeySet: (state, reviews) => {
            state.reviews = reviews;
            setIsVisibleReviewList(state, true);
        },
        addReviewsByKeySet: (state, reviews) => {
            state.reviews.push(...reviews);
        },
        setReviewsForMap: (state, reviews) => {
            state.reviewsForMap = reviews;
        },

// actions
        async setReviewsByKeySet({ commit }, { that, reviewUpdateDate, reviewId }) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsByKeySet', {
                    params: {
                        reviewUpdateDate: reviewUpdateDate,
                        reviewId: reviewId,
                    },
                });
                if (!reviewUpdateDate && !reviewId) commit('setReviewsByKeySet', result.data);
                else commit('addReviewsByKeySet', result.data);
                if (!result.data.length) commit('setIsEndOfList', true);
                else commit('setIsEndOfList', false);
            });
        },

frontend/src/store/index.js

 

setReviewsByKeySet 액션을 살펴 보자.
process 로부터 스피너를 돌리기 위한 that,
정렬과 key 로 활용하기 위한 reviewUpdateDate, 와 reviewId 을 파라미터로 받는다.

받는 파라미터는 getReviewsByKeySet API로 가져오게 했다. (아래에서 자세히 설명!)

 

만약 reviewUpdateDatereviewId 가 제공되지 않았다면,
즉, 스크롤이 마지막에 도달한 적이 없는 첫 리스트 데이터라면,
setReviewsByKeySet mutation 을 실행시킨다.

setReviewsByKeySet 는 결과 데이터를 reviews state에 정의하는 메소드이다.

 

만약 reviewUpdateDatereviewId 가 제공되었다면,
즉, 스크롤이 마지막에 도달하여 마지막 날짜와 ID를 가져온 상태라면,
addReviewsByKeySet mutation 을 실행시킨다.


addReviewsByKeySet 는 결과 데이터를 reviews state에 추가하는 메소드이다.
스크롤을 내렸을 때 데이터를 추가 하는 것이지 갈아치우면 안되기 때문에
setReviewsByKeySetaddReviewsByKeySet를 각각 따로 두었다.

🍥 무한 스크롤 API구현

🍤 getReviewsByKeyset Controller

    @GetMapping("/getReviewsByKeySet")
    public List<ReviewDTO> getReviewsByKeySet(
        @RequestParam(value = "reviewUpdateDate", required = false) String reviewUpdateDate,
        @RequestParam(value = "reviewId", required = false) String reviewId) {
        return reviewService.getReviewsByKeySet(reviewUpdateDate, reviewId);
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

🍤 getReviewsByKeyset DAO

    List<ReviewDTO> getReviewsByKeySet(@Param("reviewUpdateDate") String reviewUpdateDate,
                                      @Param("reviewId") String reviewId);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

🍤 getReviewsByKeyset Service

    public List<ReviewDTO> getReviewsByKeySet(String reviewUpdateDate, String reviewId) {
        return reviewDAO.getReviewsByKeySet(reviewUpdateDate, reviewId);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

🍤 getReviewsByKeyset Query

    <select id="getReviewsByKeySet" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type,
            DATE_FORMAT(f.update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        <where>
            <if test="reviewUpdateDate != null and reviewId != null">
                r.update_date <![CDATA[<=]]> #{reviewUpdateDate}
                and
                r.id <![CDATA[<]]> #{reviewId}
            </if>
        </where>
        order by r.update_date, r.id desc
        limit 10
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

기존의 getReviews 와 유사하지만 where 부분이 추가되었다.
받아온 reviewUpdateDatereviewId 로 정렬을 했다.
desc 로 정렬했기 때문에
파라미터인 마지막 row의 id 보다 작아야 다음 값을 가져올 수 있다.

 

여기서 주목해야할 점은 reviewUpdateDate 조건이다.
reviewId 와 달리 "작거나 같다"를 조건으로 걸었는데,
그 이유는,
reviewId는 유일한 값이지만 날짜는 중복될 수 있는 데이터이기 때문이다.
중복이 있을 수 있는 값이기 때문에 날짜 만으로는 키가 될 수 없었겠지만,
id가 있기 때문에 작거나 같다는 조건을 걸 수 있게 되었다.

 

그리고 반드시 key 로 잡은 컬럼이 여러개라면 and 조건으로 묶어줘야한다.
중복 없이 유일해야하는 키값이기 때문이다.

💡 CDATA
Character DATA.
즉 문자형 데이터를 말한다.
CDATA로 감싼 문자는 문자 그대로 표시되게 된다.
CDATA는 부등호 처럼 마이바티스가 태그로 인식하는 특수 기호를 쓸 때 사용한다.
CDATA로 감싸 부등호를 사용하면 마이바티스가 이를 태그로 인식하지 않고 부등호 문자 자체를 출력하게 된다.

 

getReviewgetReviewsForMap 역시 구현하였는데,
페이지네이션과 동일하기 때문에 더 언급하지 않겠다.

주의해야할 것은 getReviewsCnt 는 구현할 필요가 없다는 것이다.

(페이지를 표시할 일이 없기 때문에!)

 

 

성공적으로 무한스크롤이 되는 것을 확인할 수 있었다! 🎉

 


이번 포스팅에선 페이지네이션과 무한 스크롤을 구현해보았다.

다음 포스팅에선 리뷰 검색 기능을 구현해 보도록 하겠다! 

(검색 때 like는 쓰기 싫고... 검색 엔진을 사용해 구현해보고 싶은데 할 수 있을 지 모르겠다 @_@ )

 

댓글, 하트, 피드백은 언제나 환영입니다! 🤓

 

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.


지난 포스팅에선 CRUD API를 적용하고,
지도에 리뷰를 출력하고 오버레이를 추가해 보았다.

 

이번 포스팅에선 사진 파일 업로드/다운로드(화면에 출력) 기능을 구현해볼 것이다.

참고로, 디테일한 부분은 포스팅에서 다소 빠졌을 수도 있다...

자세한 건 깃헙코드를 참고!

 

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 파일 저장 위치 선정

이미지 파일을 저장하는데엔 크게 두가지 방법이 있다.

  • 파일 시스템 스토리지(storage) 에 업로드 하는 방법. DB엔 해당 파일 관련 정보(크기, 이름, 업로드 시간 등)를 저장.
  • DB에 바이너리 형식으로 파일 자체를 저장하는 것.

어떤 방법을 선택해야할까 고민하다가 좋은 레퍼런스를 찾았다.

 

파일 저장 방식은 그 파일을 어떤 용도로 사용하느냐에 따라 다르게 저장하면 된다고 한다.

 

웹 페이지용 이미지를 저장하는 경우 파일 시스템에 저장하는 것이 가장 좋다.
웹 서버에서 이미지 파일을 빠르게 찾아 방문자에게 파일을 보낼 수 있기 때문이다.

 

이미지 파일을 DB에 저장하는 경우, 이 이미지가 도달하는 단계가 크게 증가하므로 이미지 다운로드 속도가 느려진다.

또한, 더 많은 서버 리소스를 사용한다.

 

DB에 파일을 저장하는 경우는, 외부에서 사용되지 않을 직원 도는 고객의 얼굴 사진과 같은 민감한 이미지일 때라고 한다.

 

우리 팀에서도 파일 업로드/다운로드 기능을 구현할 때 blob으로 DB에 바로 저장되도록 구현했었는데,
그 이유가 이미지파일이 아니고, 보안이 필요한 파일이라 그랬던 것 같다.

 

결론적으로 나는, 파일 스토리지에 이미지를 저장하고, 이미지 정보만 DB에 저장시키기로 했다!

🍜 드래그 앤 드롭을 통한 파일 업로드 기능 구현

이미지 파일 업로드 UI

기존에 구현했던 UI에 맞춰 파일 업로드 기능을 구현해보자.
파일 업로드 부분을 이렇게 크게 만든 이유는,
드래그 엔 드롭 (drag and drop) 으로도 파일 업로드가 가능하게 하고 싶어서였다.

 

드래그 엔 드롭을 구현하기 위해 HTML DOM 이벤트인 drop 을 사용할 것이다.
마우스로 끌고 클릭을 뗄 때 발생하는 이벤트이다.


이 이벤트는 draggable 하다는 전제 하에 동작하기 때문에, dragover 이벤트와 함께 사용해야한다.
사진 업로드 부분을 아래와 같이 수정해주자!

<div class="image-area" v-if="!isDisabledInput">
    <div class="file-input-wrapper"
         @dragover="onDragOver"
         @drop="onDrop"
    >
        사진을 업로드 해주세요
    </div>
</div>

// ...data
fileList: [],

// ... methods
onDrop(e) {
    e.preventDefault();
    this.fileList.push(...e.target.files);
    console.log(this.fileList);
},
onDragOver(e) {
    e.preventDefault();
},

frontend/src/components/SideBar.vue

 

dragOver은 해당 영역에서 드래그를 하고 있을 때 발생하는 이벤트인데,
구현할 기능이 없으니 prevent(기존 기능 막기) 해준다.

 

drop 도 마찬가지로 prevent 해준다.
사실 처음엔 prevent 해주지 않았었는데, 그러자 change 이벤트가 같이 발생되어 기능이 두번 수행되는 버그가 생겼다.


별개의 이벤트로 동작해야하기 때문에 drop 이벤트가 change와 함께 동작하지 않도록 prevent 해 줄 것이다.

drop 에선 fileList 라는 상태데이터에 이벤트로부터 받은 파일리스트를 push 해주었다.

파일을 드래그, 드롭하자 fileList 가 file 정보로 채워졌다

콘솔을 찍어 확인해보자 드래그 앤 드롭으로 파일 정보를 가져오는 것을 확인할 수 있었다! 🎉

🍜 클릭을 통한 파일 업로드 기능 구현

드래그 앤 드롭으로 파일을 충분히 업로드 할 수 있을 듯 하니,
이젠 클릭으로도 파일 업로드가 가능하게 해보자.


영역을 클릭했을 때 File Selector 화면이 출력되게 해야한다.
이를 위해선 <input type="file" /> 을 써야한다.
따라서 영역 내에 input을 꽉 채우고 투명하게 만들어서 div 만 존재하는 것 처럼 보이게 할 것이다.

<div class="image-area" v-if="!isDisabledInput">
    <div class="file-input-wrapper"
         @dragover="onDragOver"
         @drop="onDrop"
    >
        <input
            class="file-input"
            type="file"
            accept="image/*"
            @change="onChangeFiles"
            multiple
        />
        사진을 업로드 해주세요
    </div>
</div>

// ... methods
onChangeFiles(e) {
    this.fileList.push(...e.target.files);
    console.log(this.fileList);
},

// ...style

> .image-area {
    // ...

    > .file-input-wrapper {
        position: relative;
        // ...

        > .file-input {
            cursor: pointer;
            position: absolute;
            right: 0;
            top: 0;
            bottom: 0;
            left: 0;
            opacity: 0;
        }
    }
}

frontend/src/components/SideBar.vue

 

부모 요소에 position: relative 를 주고,
자식 요소를 position: absolute 로 설정하고 모든 범위를 0으로 주면
부모 요소에 딱 맞게 자식 요소를 가득 채울 수 있다.

 

input 을 click 했을 때가 아니라, File Selector 창에서 '확인'을 눌렀을 때 선택한 파일 정보를 가져오기 위해
@chage 이벤트를 활용해 파일 정보 fileListpush 해줬다.

FileSelector 로 fileList 가 file 정보로 채워졌다.

콘솔 확인해보니 파일 정보를 정상적으로 가져오는 걸 확인할 수 있었다! 🎉

🍜 파일 업로드 시 UI 변경

fileList 를 UI에서 확인하고, 선택한 파일을 삭제할 수 있도록 해보자.

 

먼저 삭제용 엑스 아이콘을 추가해줄것이다.
나는 추가용 플러스 아이콘도 추가해주었다.

import Vue from 'vue';

// 0. 편의를 위해 아이콘은 알파벳 순서대로 추가하자.
// 1. 설치했던 fontawesome-svg-core 와 vue-fontawesome
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';

// 2. 설치했던 아이콘 파일에서 원하는 아이콘 불러오기
import {
    faAngleLeft,
    faAngleRight,
    faLocationDot,
    faTimes,
    faPlus
} from "@fortawesome/free-solid-svg-icons";

// 3. 불러온 아이콘을 라이브러리에 담기
library.add(faAngleLeft);
library.add(faAngleRight);
library.add(faLocationDot);
library.add(faTimes);
library.add(faPlus);

// 4. fontawesome 아이콘을 Vue 템플릿에서 사용할 수 있도록 등록
Vue.component("FontAwesomeIcon", FontAwesomeIcon);

src/common/Icons.js

 

faTimesfaPlus 아이콘을 추가해줬다.
이제 아래에서 이 아이콘을 활용해보자.

<div class="image-area" v-if="!isDisabledInput">
    <div class="file-input-wrapper"
         @dragover="onDragOver"
         @drop="onDrop"
         v-if="!fileList.length"
    >
        <input
            class="file-input"
            type="file"
            accept="image/*"
            @change="onChangeFiles"
            multiple
        />
        사진을 업로드 해주세요
    </div>
    <div class="file-list" v-else>
        <ul v-if="fileList.length > 0">
            <li
                v-for="(file, idx) in fileList"
                :key="idx"
            >
                {{file.name}}
                <FontAwesomeIcon
                    class="delete-file-icon"
                    icon="times"
                    @click="deleteFile(idx)"
                />
            </li>
        </ul>
        <ul>
            <li class="file-btn-area">
                <BButton
                    size="sm"
                    @click="deleteAllFile"
                    class="file-delete-btn"
                >
                    전체 삭제
                    <FontAwesomeIcon icon="times" />
                </BButton>
                <BButton
                    size="sm"
                    class="file-add-btn"
                >
                    추가
                    <FontAwesomeIcon icon="plus" />
                    <input
                        class="file-input"
                        type="file"
                        accept="image/*"
                        @change="onChangeFiles"
                        multiple
                    />
                </BButton>
            </li>
        </ul>
    </div>
</div>
// ... methods
deleteFile(idx) {
    this.fileList.splice(idx, 1);
},
deleteAllFile() {
    this.fileList = [];
},
onChangeFiles(e) {
    this.fileList.push(...e.target.files);
},
onDrop(e) {
    this.fileList.push(...e.dataTransfer.files);
},

// ... style
> .image-area {
    padding: 0 10px;

    > .file-input-wrapper, .file-list {
        position: relative;
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 1.3rem;
        border: 5px dashed rgb(255, 255, 255, 0.5);
        border-radius: 10px;
        height: 250px;
        background-color: rgb(255, 255, 255, 0.5);
        overflow-y: auto;
        flex-direction: column;

        > ul {
            padding: 0 10px;

            > li {
                list-style: none;
                font-size: 1rem;
                word-break: break-all;

                > .delete-file-icon {
                    cursor: pointer;
                    padding: 10px 0 0 5px;
                }
            }

            > .file-btn-area {
                display: flex;
                padding-top: 10px;

                > .file-delete-btn {
                    display: flex;
                    align-items: center;
                    font-size: 0.7rem;
                    margin-right: 5px;
                }

                > .file-add-btn {
                    display: flex;
                    align-items: center;
                    font-size: 0.7rem;
                    position: relative;

                    > .file-input {
                        cursor: pointer;
                        position: absolute;
                        right: 0;
                        top: 0;
                        bottom: 0;
                        left: 0;
                        opacity: 0;
                    }
                }
            }
        }

        > .file-input {
            cursor: pointer;
            position: absolute;
            right: 0;
            top: 0;
            bottom: 0;
            left: 0;
            opacity: 0;
        }
    }
}

fileList 가 채워진 상태라면,
'사진을 업로드 해주세요' 문구가 아닌,
파일 제목 리스트를 보여주게했고, 파일 제목 옆엔 times 아이콘을 배치했다.

 

times 아이콘을 클릭하면 splicefileList 의 해당 인덱스 파일이 삭제되도록 구현해보았다.

 

파일을 여러개 업로드했을 때 하나하나 지우면 굉장히 귀찮을 것 같아서,
전체 삭제 버튼도 구현해보았다.

 

추가한 plus 버튼은 fileList 에 또다른 파일을 추가하고 싶을 경우에 쓸 '추가' 버튼 옆에 배치해줬다.

파일리스트가 출력되고 정상적으로 추가/삭제되는 걸 확인할 수 있었다! 🎉

 

CSS는 딱히 크게 설명할 부분이 없는 듯 하지만,
신경 쓴 부분만 짚고 넘어가자면...

 

일단 파일 제목이 긴 경우를 대비해 liword-break: break-all;를 추가해

파일이름이 영역 밖으로 넘어가는 상황을 방지했다.


그리고 첨부된 파일이 많을 때를 대비해 file-input-wrapper 클래스에 overflow-y: auto;를 추가했다.
파일 수가 많아 영역 밖으로 글자가 넘치는 경우 스크롤이 생길 것이다.


스크롤은 지난 포스팅에서 CSS로 스타일을 보정해준 적이 있는데,
어플리케이션 전체가 아닌 textarea 부분에만 적용되도록 구현되어 있었다.
그래서 스크롤 스타일 CSS를 App.vue 로 옮겨 주었다.

#app {
// ...
    /* width */
    ::-webkit-scrollbar {
        width: 10px;
    }

    /* Track */
    ::-webkit-scrollbar-track {
        background: #f1f1f1;
    }

    /* Handle */
    ::-webkit-scrollbar-thumb {
        background: #888;
    }

    /* Handle on hover */
    ::-webkit-scrollbar-thumb:hover {
        background: #555;
    }
}

frontend/src/App.vue

 

🍜 파일 저장 API 구현하기

🍥 Spring Boot Server

리뷰가 저장될 때, 첨부된 파일도 같이 저장되도록 구현해보자.

구현 전에 로직(이라기 보단 주의사항...)을 간단히 정리해보았다.

  1. '/api/review/saveReview' API 가 동작할 때 파일도 함께 Param 데이터에 붙어야한다.
  2. 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 param은 List 형식이어야한다.
  3. 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 시스템에 이름이 "리뷰 ID"인 디렉토리 하위에 첨부된 파일이 저장되어야한다.
  4. 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 정보를 저장하는 DB를 따로 CREATE 해야한다.
  5. 한 리뷰당 여러 파일이 첨부될 수 있기 때문에, 파일 정보를 저장하는 DB는 "리뷰 ID"를 외래키로 가지고 있어야한다.
  6. 각 파일의 삭제를 위해 파일 DB엔 PRIMARY 키도 존재해야한다.

그리고 스프링 부트 가이드 에 파일 업로드 기능을 구현하는 튜토리얼이 있길래, 참고 했다.

 

먼저, 주의사항 1번을 고려하여 ReviewDTOfiles를 추가했다.

public class ReviewDTO {
    // ...
    List<MultipartFile> files;

    // ...
    public List<MultipartFile> getFiles() {
        return files;
    }

    public void setFiles(List<MultipartFile> files) {
        this.files = files;
    }
}

src/main/java/com/map/restaurant/good/dto/ReviewDTO.java

 

일단 가이드를 따라 파일 타입을 MultipartFile 이라고 선언했는데...
MultipartFile 이란 무엇일까?

🚀 MultipartFile 이란?

스프링 공식문서 에 따르면,

MultipartFile 이란 인터페이스로,
Multipart 요청에서 수신된 업로드된 파일의 표현이다.
파일 내용을 메모리에 저장되거나 일시적으로 디스크에 저장된다.
이 임시 저장소는 요청 처리가 끝나면 지워진다.
..라고 설명하고 있다.

 

대충 메모리 등에 임시 저장되는 Multipart request(요청) 로 보내진 파일 타입이라고 이해하면 될 것 같다.

 

여기서 Multipart 란 무엇일까?
Multipart란,
직역하면 Multi(여러) part(부분)이라는 뜻으로,
하나 이상의 서로 다른 데이터 집합이 하나의 body 에 결합되어 있는 것을 말한다.

여기서 말한 body 는 HTTP 의 body 부분을 의미한다.

HTTP 요청/응답 구조

풀어 설명하자면...
UI 부분에서 구현했듯이 파일 첨부를 위해서 우린 <input type="file" > HTML 태그를 사용했다.
HTML input 요소는 보통 form 요소와 함께 사용된다.
form 요소는 input 데이터 집합을 서버에 submit(전송) 할때 주로 사용된다.
여기서 input 의 타입은 문자열이 될 수도 있고, 파일이 될 수도 있고, 숫자가 될 수도 있다.
즉, 여러 타입의 데이터가 form 에 의해 결합되어 전송된다는 것인데,
이를 Multipart 라고 부른다.

HTTP 로 전송할 때 Body 에 Multipart 타입의 데이터들을 넣고
HTTP Header Content-type 필드에 multipart/form-data 라고 명시해 요청/응답을 주고받을 수 있다.(자세히)

 

결론적으로, MultipartFile 은 Multipart HTTP Request 로 보내진, 파일용 Spring 참조 타입이다.

 

이제 MultipartFile 에 대해 이해했으니,
본격적으로 컨트롤러를 구현해보자.

@RestController
@RequestMapping("/api/review")
public class ReviewCtrl {
    @Autowired
    private ReviewDAO reviewDAO;

    @Autowired
    private ReviewService reviewService;

    @Autowired
    private FileService fileService;

    @PostMapping("/saveReview")
    public void saveReview(ReviewDTO reviewDTO) {
        reviewService.saveReview(reviewDTO);
        fileService.deleteFiles(reviewDTO);
        fileService.saveFiles(reviewDTO);
    }

    @GetMapping("/getReviews")
    public List<ReviewDTO> getReviews() {
        return reviewService.getReviews();
    }

    @DeleteMapping("/deleteReview")
    public void deleteReview(@RequestBody ReviewDTO reviewDTO) {
        reviewService.deleteReview(reviewDTO);
    }
}

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

 

극단적으로 간단해진 코드를 볼 수 있다ㅋㅋ
컨트롤러에선 요청/응답을 처리하기만하고 주요 기능은 서비스에 구현하는 아키텍처가 좋다고 하길래
파일 첨부 로직을 추가하면서 리뷰와 파일 로직 각각을 Service 로 분리해 구현했다.

 

여기서 주목해야할 점은, saveReview API의 @RequestBody 가 사라졌다는 점이다.

클라이언트에서 API를 호출하는 부분에서 설명할 테지만,

파일 데이터를 전송하기 위해 formData 로 파라미터를 감싸 전송할 예정이다.

 

@RequestBody 는 HTTP 요청의 BODY 에 담긴 JSON 파라미터를 JAVA 객체로 파싱할 때 사용 가능하기 때문에,

formData 형식은 인지할 수 없다.

 

이 부분에서 삽질을 엄청나게 많이 했는데, 그냥 @RequestBody 를 제거하면 된다.

처음엔 엥? 어노테이션 제거하고도 파라미터가 전달이되나? 했는데,

정확히는 @ModelAttribute 를 사용하는 것이다. 그리고 이 @ModelAttribute 가 생략이 가능하기 때문에 

어노테이션 없이 사용이 가능한 것이다.

 

@ModelAttribute 는 formData 같은 객체 형태로 넘어온 파라미터를 JAVA 객체로 매핑 시켜주는 역할을 한다.

때문에 DTO에 반드시 setter가 있어야한다.

 

참고로 @RequestBody는 reflection(반사, 객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법)으로 setter 없이 JSON을 JAVA 객체로 파싱시켜줄 수 있다. 

 

🚀 Service 와 ServiceImpl

서비스 기능 구현에 앞서, 궁금한 점이 생겼다.
공식 가이드에선 서비스 "인터페이스"가 구현되어 있었다.
공식 가이드 외에도 여러 Spring Boot 예제를 보면 서비스와 서비스 인터페이스 구조로 구현된 코드를 자주 발견할 수 있다.
여기서 든 의문점이 Service, ServiceImpl 구조를 꼭 써야할까? 였다.

 

지금 근무 중인 팀에는 Service 를 구현하지 않고, 바로 컨트롤러에 기능을 구현하고 있다.
내용이 길고 복잡한 경우엔 Service를 따로 두고 구현하지만,
보통은 mapper interface 로 값을 바로 가져오는 CRUD 기능이기 때문에 Service 를 많이 찾을 수 없다.
그리고 Service 의 인터페이스는 더더욱 찾을 수 없다. 거의 전무하다.

 

뭐가 정답인지 알 수 없어 구글링해보았다.
일단 Service 를 쓰는 이유에 대해서 stackoverflow 에 나와있었다.

 

요약하자면,

단순 CRUD 에서 항상 Service 를 사용할 필요는 없지만,
단일 책임 원칙을 준수하기 위해선,
컨트롤러는 단순히 요청만 처리해야하고,
서비스 계층은 컨트롤러가 수신한 테이터에 대한 모든 기능을 수행해야한다고한다.

 

ServiceImpl(서비스 인테페이스 구조)을 꼭 써야하는지에 대한 좋은 글도 많이 찾을 수 있었다.

 

요약하자면,
비즈니스 로직은 요청사항에 따라 얼마든지 변경될 수 있고,
이를 대응하기 위해 인터페이스를 만들어두면 다양한 구현체를 둘 수 있어 유연한 개발을 할 수 있다.
하지만 개발을 하다보면 절차지향적인 코드가 개발되기도 하고,
보통 구현체를 여러개 두지 않고 일대일로 구현되는 경우가 많기 때문에
인터페이스가 반드시 필요한 것은 아니다.

💡절차 지향적 코드(프로그래밍)

순차적인 처리가 중요시 되며 프로그램 전체가 유기적으로 연결되도록 만드는 프로그래밍 기법.
컴퓨터의 처리구조와 유사해 실행속도가 빠름.
유지보수가 어려움.
실행 순서가 정해져 있으므로 코드의 순서가 바뀌면 동일한 결과를 보장하기 어려움.

 

그래서 나는 서비스는 두되, 인터페이스는 구현하지 않는 방향으로 진행하기로 했다!

 

service 패키지를 파고 Review.java 파일을 파준 뒤,
아래와 같이 구현했다.

@Service
public class ReviewService {
    @Autowired
    private ReviewDAO reviewDAO;

    @Transactional
    public void saveReview(ReviewDTO reviewDTO) {
        String id = reviewDTO.getId();
        if (id == null || id.isEmpty()) {
            String uuidStr = UUID.randomUUID().toString();
            reviewDTO.setId(uuidStr);
        }
        reviewDAO.saveReview(reviewDTO);
    }

    public List<ReviewDTO> getReviews() {
        return reviewDAO.getReviews();
    }

    @Transactional
    public void deleteReview(ReviewDTO reviewDTO) {
        String id = reviewDTO.getId();
        reviewDAO.deleteReview(id);
    }
}

src/main/java/com/map/restaurant/good/service/ReviewService.java

 

기존 Controller 에 구현된 내용을 Service 로 옮겼을 뿐이다.


그 중 신경 쓴 부분은 @Transactional 어노테이션이다.

 

🚀 트랙잭션

  • DB에 접근하는 작업의 최소 단위

🚀 트랜잭션의 특징

  • 원자성
    • 트랙잭션이 DB에 모두 반영되던가, 아니면 전혀 반영되지 않아야한다.
  • 일관성
    • 트랜잭션의 작업 처리 결과가 항상 일관성 있어야한다.
  • 독립성
    • 어떤 트랜잭션도, 다른 트랜잭션의 연산에 끼어들 수 없다.
  • 지속성
    • 트랜잭션이 성공적으로 완료되었을 경우, 결과는 영구적으로 반영되어야한다.

saveReview API에 리뷰를 저장하고 파일 정보를 DB에 저장하는 로직이 함께 수행된다.
각 기능 중 하나에 문제가 발생했다고 해보자.
리뷰를 DB에 저장하는 데에는 성공 했으나,
파일 정보를 DB에 저장하려고 하니 DB 컬럼이 존재하지 않는 에러가 발생했다면?
리뷰는 DB에 잘 저장되었겠지만 해당 리뷰의 이미지 파일은 오류로 인해 저장되지 않는다.
파일 또한 리뷰의 한 구성이기 때문에 오류가 생긴다면 리뷰 역시 저장되지 않는 것이 맞다.

이럴 때 트랜잭션을 사용할 수 있다.
트랜잭션을 사용하면 원자성 덕분에
두 로직 중 하나라도 실패한다면 롤백되게 할 수 있다.

 

@Transactional 어노테이션은 클래스, 메소드 상단에 붙여줄 수 있다.
직접 객체를 만들 필요 없이 선언만으로 관리를 용이하게 해주기 때문에 선언적 트랜잭션이라고도 한다.

 

추가로,
@Transactional 어노테이션 외에 트랜잭션을 선언할 수 있는 방법이 있다.
TransactionTemplate 을 활용하는 것이다.


내가 근무 하고 있는 팀에선 @Transactional 이 아닌 TransactionTemplate 을 사용하고 있다.
이유는 아마 Service 를 사용하고 있지 않기 때문인 듯 하다ㅋㅋ

서비스가 아닌 컨트롤러에서 @Transactional 을 쓰는 건 권장하지 않는 구조라 그런게 아닐까 싶다.(자세히)

 


TransactionTemplate은 아래와 같이 사용해줄 수 있다.

@Component                                                                                 
class Sample {                                                                             
    private final TransactionTemplate transactionTemplate;                                 

    public Sample(TransactionTemplate transactionTemplate) {                               
        this.transactionTemplate = transactionTemplate;                                    
    }                                                                                      

    void execute() {                                                                       
        String result = transactionTemplate.execute(new TransactionCallback<String>() {    
            @Override                                                                      
            public String doInTransaction(TransactionStatus status) {                      
                return "";                                                                 
            }                                                                              
        });                                                                                
    }                                                                                      
}

반환값이 없는 메소드라면, TransactionCallback 이 아닌 TransactionCallbackWithoutResult을 사용해주면된다.

 

TransactionTemplate
@Transactional의 메서드 내에 지정이 불가하다는 단점과
self invocation에서 트랙션이 불가하다는 단점을 해결해줄 수 있지만,
오래된 추상클래스이기 때문에 람다 표현식을 사용할 수 없다는 단점을 가지고 있다.

💡 self invocation
A 메소드에서 같은 객체의 B 메소드를 호출하는 것

 

ReviewService.java 와 마찬가지로 FileService.javaservice 패키지에 생성하고 아래와 같이 구현했다.

@Service
public class FileService {
    private Path imgDirPath;

    @Value("${file.storage.imgDir}")
    private String imgDir;

    @PostConstruct
    public void init() {
        this.imgDirPath = Paths.get(imgDir);
    }

    @Autowired
    private FileDAO fileDAO;

    @Transactional
    public void saveFiles(ReviewDTO reviewDTO) {
        List<MultipartFile> files = reviewDTO.getFiles();
        String reviewId = reviewDTO.getId();
        if (files == null || files.isEmpty()) {
            return;
        }

        try {
            if (! Files.exists(imgDirPath)) {
                Files.createDirectories(imgDirPath);
            }

            Path reviewImgDirPath = imgDirPath.resolve(
                Paths.get(reviewId)).normalize().toAbsolutePath();

            if (! Files.exists(reviewImgDirPath)) {
                Files.createDirectories(reviewImgDirPath);
            }
        } catch (IOException e) {
            throw new ApiException("Failed to make index dir", e);
        }

        for (MultipartFile file : files) {
            saveFile(file, reviewId);
        }
    }

    @Transactional
    public void saveFile(MultipartFile file, String reviewId) {
        try {
            String originFilename = file.getOriginalFilename();
            long fileSize = file.getSize();
            String contentType = file.getContentType();
            if (file.isEmpty() || originFilename == null) {
                throw new ApiException("Failed to store empty file.");
            }

            Path destinationFile = imgDirPath
                .resolve(Paths.get(reviewId))
                .resolve(Paths.get(originFilename))
                .normalize()
                .toAbsolutePath();

            try (InputStream inputStream = file.getInputStream()) {
                Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
            }

            String fileId = UUID.randomUUID().toString();
            fileDAO.saveFile(fileId, reviewId, originFilename, fileSize, contentType);
        } catch (IOException e) {
            throw new ApiException("Failed to store file", e);
        }
    }
}

src/main/java/com/map/restaurant/good/service/FileService.java

 

먼저 Path 타입인 imgDirPath 를 선언해준다.

이것은 init() 부분에서 application.propertiese에서 선언된 file.store.imgDir 값으로 채워질 것이다.

file.storage.imgDir=src/main/resources/static/upload-dir

src/main/resources/application.properties

 

file.store.imgDir의 값은 이미지 파일이 저장될 디렉토리 경로이다.
나는 upload-dir 라고 정의해줬다. (스프링 공식 튜토리얼에서 이 이름으로 정의해 줬길래 따라했다.)

 

참고로 서버의 이미지 자원을 사용하기 위해선 resources/static 에 이미지가 저장되어 있어야한다.
(다른 디렉토리에도 넣어봤는데 404만 뜨더라...)


그런데 나중에 배포를 하게 되면 클라이언트 코드가 빌드되면서 resources 폴더가 clear 될 것이기 때문에
서버의 resources/static 폴더에 유동적인 이미지를 저장하는 것은 옳지 않다.
그렇다고 로컬 디렉토리에 저장하려니 브라우저 보안상 제약되는 부분이 많았다.


때문에 나는 개발 환경에서만 서버에 저장하고 배포 시 경로를 변경해주기로 했다.


🚨 참고

FileSizeLimitExceededException 에러가 발생하면
application.properties 에 아래 내용을 선언하자!

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB

설정된 파일 크기를 초과하면 발생하는 에러로, 스프링 기본 설정이 1MB이다.
이 크기를 늘려주는 내용이다.


Paths.get 메소드를 활용해서 imgDirPath 값을 구한다.
Paths.get 에 인자로 경로 문자열을 넘기면되는데,

나는 달랑 디렉토리 이름을 넘겼기 때문에 상대 경로값이 채워지게 된다. (절대 경로로 구할 수도 있다.)

 

여기서 주의해야할 부분은 @PostConstruct 어노테이션이다.
사실 스프링 부트 공식 튜토리얼에선 application.properties 에 사용된 값을 쓰기 위해 @ConfigurationProperties 를 사용했다.


나는 굳이 @ConfigurationProperties 를 사용할 필요가 있을까? 싶어서 @Value 를 쓰고 싶었다.
왜냐면 @ConfigurationProperties 사용 시, 따로 getter, setter를 구현해줘야하고,
prefix 로 어떤 application.properties의 속성을 여러개 가져와 사용 가능한 기능이기때문에,
단순히 file.store.imgDir 만 필요했던 지금 상황에선 키로만 속성값을 가져오는 @Value 가 적절하다고 생각했다.

 

🚀 @Value vs @ConfigurationProperties

@Service
public class FileService {
    private final Path imgDirPath;

    @Value("${file.storage.imgDir}")
    private String imgDir;

    public FileService() {
        this.imgDirPath = Paths.get(imgDir);
    }
// ...

처음엔 @Value를 쓰기 위해 위와 같이 구현해 줬었는데,
NullPointerException이 발생했다.


에러 로그를 보니 초기화를 해주지 않았다고 하는데,

@Value 를 붙여 줬으니 값이 있어야하는 상태여야하지 않나? 라고 생각했다.

 

원인은 타이밍 때문이다.
stackoverflow 답변에 따르면,
@Value는 객체가 생성되고 난 후에 주입된다고 한다.


즉, FileService 가 생성되고 난 뒤에! imgDir 값이 채워진다.
이 때문에 생성자 부분에서 Paths.get(imgDir)를 하면 null값으로 Paths.get을 하게 되는 것이다.

 

따라서 아래와 같이 수정해줘야한다.
바로 생성자! 에서 @Value 값을 주입하는 것이다.

@Service
public class FileService {
    private final Path imgDirPath;

    public FileService(@Value("${file.storage.imgDir}") String imgDir) {
        this.imgDirPath = Paths.get(imgDir);
    }
// ...

사실 이 방법을 알아내기까지 꽤 삽질을 많이 했는데,
그 과정에서 친구가 @PostConstruct를 사용하는 방법도 있다고 알려줬다.

@Service
public class FileService {
    private Path imgDirPath;

    @Value("${file.storage.imgDir}")
    private String imgDir;

    @PostConstruct
    public void init() {
        this.imgDirPath = Paths.get(imgDir);
    }
// ...

@PostConsturct 는 말 그대로 '생성 이후' 라는 뜻이다.
즉, 이 어노테이션이 붙은 메소드는 생성자 이후에 냅다 실행이 된다는 것.


따라서 생성이 된 이후에 실행될테니 @Value 값이 주입된 상태이고,
NullPointerException 없이 imgDirPath 가 채워질 것이다!

 

saveFiles 코드를 하나하나 뜯어보자.

    @Transactional
    public void saveFiles(ReviewDTO reviewDTO) {
        List<MultipartFile> files = reviewDTO.getFiles();
        String reviewId = reviewDTO.getId();
        if (files == null || files.isEmpty()) {
            return;
        }

        try {
            if (! Files.exists(imgDirPath)) {
                Files.createDirectories(imgDirPath);
            }

            Path reviewImgDirPath = imgDirPath.resolve(
                Paths.get(reviewId)).normalize().toAbsolutePath();

            if (! Files.exists(reviewImgDirPath)) {
                Files.createDirectories(reviewImgDirPath);
            }
        } catch (IOException e) {
            throw new ApiException("Failed to make index dir", e);
        }

        for (MultipartFile file : files) {
            saveFile(file, reviewId);
        }
    }

먼저, reviewId를 이름으로 갖는 디렉토리 생성을 위해, reviewDTO.getId() 해온다.
첨부된 파일이 없는 경우엔 그래도 메소드를 빠져나오게 했다.

 

try 부분에서,
imgDirPath 즉, 이미지 파일 전체를 보관하는 upload-dir 폴더가 없다면 Files.createDirectories 로 생성해준다.


resolve 메소드는 경로를 합해주는 메소드이다. imgDirPathreviewId 를 합하여 현재 리뷰의 파일들이 저장될 경로를 설정한다.
여기서 normalize() 는 경로를 표준화해주는 메소드이다. 잘못된 문자가 있는지 확인하고 공백 등을 제거해준다.
toAbsolutePath() 는 말 그대로 절대 경로로 변환해주는 메소드 이다.

 

reviewId 를 포함한 경로 역시 존재하지 않는다면 Files.createDirectories 로 생성해준다.

 

여기서 예외처리 로직도 추가해줬다.
담고 싶은 메시지를 exception 에 추가할 수 있게 한다.
exception 패키지를 파고 ApiException이라는 클래스를 파서 아래와 같이 구현했다.

public class ApiException extends RuntimeException {
    public ApiException(String message) {
        super(message);
    }

    public ApiException(String message, Throwable cause) {
        super(message, cause);
    }
}

src/main/java/com/map/restaurant/good/exception/ApiException.java

 

공식 가이드에서 에러를 다루는 방법이기도 하고,
현직 우리팀에서도 이런 식으로 에러 메시지를 관리하고 있다.

 

폴더 생성이 완료되면, saveFile 메소드가 반복되어 실행된다.

    @Transactional
    public void saveFile(MultipartFile file, String reviewId) {
        try {
            String originFilename = file.getOriginalFilename();
            long fileSize = file.getSize();
            String contentType = file.getContentType();
            if (file.isEmpty() || originFilename == null) {
                throw new ApiException("Failed to store empty file.");
            }

            Path destinationFile = imgDirPath
                .resolve(Paths.get(reviewId))
                .resolve(Paths.get(originFilename))
                .normalize()
                .toAbsolutePath();

            try (InputStream inputStream = file.getInputStream()) {
                Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
            }

            String fileId = UUID.randomUUID().toString();
            fileDAO.saveFile(fileId, reviewId, originFilename, fileSize, contentType);
        } catch (IOException e) {
            throw new ApiException("Failed to store file", e);
        }
    }

file로 부터 originFilename, fileSize, contentType 을 get 해온다.
이 정보들을 DB에 저장할 것이다.


reviewId 디렉토리를 포함한느 경로와 파일 이름을 합한 경로를 destinationFile(목적파일경로) 로 선언한다.


Files.copy로 목적경로에 파일을 생성해준다. 존재한다면 대체한다는 StandardCopyOption.REPLACE_EXISTING 옵션도 추가해줬다.


참고로 파일 컨텐트를 얻는데에 InputStream 타입을 이용하고 있다.
이를 try()의 괄호 부분에 선언해주면
Stream 타입의 클래스들이 동작후 필요로하는 close() 메소드가 자동실행된다.

 

마지막으로,
파일의 키(UUID)를 생성해 saveFile 쿼리문을 실행시켰다!

CREATE TABLE tbl_file_info
(
    file_id varchar(36) not null primary key,
    review_id varchar(36) not null,  
    content_type varchar(50),
    file_name varchar(100),
    file_size long,
    FOREIGN KEY (review_id)
    REFERENCES tbl_review(id) ON UPDATE cascade ON DELETE cascade 
);

파일 정보는 tbl_file_info 테이블에 저장될 것이다.
리뷰가 삭제될 때 하위 이미지 파일들도 함께 삭제 되어야하기 때문에 cascade 제약조건을 넣었다.

 

DAO

public interface FileDAO {
    void saveFile(
        @Param("fileId") String fileId,
        @Param("reviewId") String reviewId,
        @Param("fileName") String fileName,
        @Param("fileSize") long fileSize,
        @Param("contentType") String contentType
    );
}

src/main/java/com/map/restaurant/good/dao/FileDAO.java

Mapper

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.FileDAO">
    <insert id="saveFile">
        insert into tbl_file_info (file_id, review_id, file_name, file_size, content_type)
        values (#{fileId}, #{reviewId}, #{fileName}, #{fileSize}, #{contentType})
    </insert>
</mapper>

src/main/resources/mappers/FileMapper.xml

🍥 Vue Client

이제 클라이언트에서 API를 호출하는 부분을 구현해보자.


파일 데이터를 Param으로 넘길 때, form 요소가 필요하다고 위에서 언급했었다. 
Vue의 동적 상태 데이터를 사용하고 있기 때문에 따로 form 요소는 구현할 필요 없고,
formData를 활용할 것이다.

 

formDataform 요소와 같은 기능을 하지만 key, value 로 구성된 인터페이스 형태로 되어 있을 뿐이다.

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                        id: this.reviewId,
                        title: this.title,
                        address: this.address,
                        grade: this.grade,
                        review: this.review,
                        lon: this.$store.state.curLon,
                        lat: this.$store.state.curLat,
                        files: this.fileList
                    },
                    {
                        transformRequest: function (data) {
                            const formData = new FormData();
                            for (let key in data) {
                                const value = data[key];

                                if (! value)
                                    continue;

                                if (key === 'files')
                                    value.forEach(file => {
                                        formData.append(key, file);
                                    })
                                else
                                    formData.append(key, value)
                            }
                            return formData
                        }
                    })
                await ok(this, '저장 완료되었습니다.');
                this.fileList = [];
                await this.$store.dispatch('setReviews', this);
                this.$store.commit('setInputState', true);
            })
        }

frontend/src/components/SideBar.vue

 

saveReview API의 파라미터에 files 를 추가해줬고,
transformRequest 라는 설정을 추가했다.
formData 에 요청 파라미터들을 key, value 형태로 append 해준다.

 

💡 tansformRequest

요청 데이터를 서버로 전송하기 전에 변경할 수 있게 해주는 axios 기능.
'PUT', 'POST', 'PATCH', 'DELETE' 메소드에서만 적용 가능.
마지막 함수는 Buffer, ArrayBuffer, FormData 또는 Stream의 인스턴스 또는 문자열을 반환해야 한다.
헤더 객체를 수정할 수 있다.

 

이미지와 함께 리뷰를 저장!
tbl_file_info

정상적으로 리뷰와 함께 이미지 파일이 저장되는 걸 확인할 수 있었다! 🎉

🍜 저장된 파일 불러오기 API 구현

🍥 Spring Boot Server

저장된 파일을 불러와보자.

처음엔 getReviews API에 파일 목록도 같이 붙여 response 할까 했지만...
파일이나 리뷰가 많으면 부하가 올 수 있을 것 같아서

  1. 지도에서 아이콘을 클릭했을 때, 즉, 현재 리뷰가 사이드 바에 set된 경우
  2. 리뷰를 저장하고 나서 갓 저장된 현재 리뷰를 사이드 바에 보여줄 때

위 두 경우에만 이미지 파일 목록을 불러오기로 했다.
즉, 한 리뷰에 대한 파일 목록만 불러오는 것이다.

 

Controller

@RestController
@RequestMapping("/api/file")
public class FileCtrl {
    @Autowired
    private FileDAO fileDAO;

    @GetMapping("/getImages")
    public List<FileDTO> getImages(@RequestParam String reviewId) {
        return fileDAO.getImages(reviewId);
    }
}

src/main/java/com/map/restaurant/good/controller/FileCtrl.java

 

나름 restfull 하게 file 컨트롤러도 파줬다...

 

Service

    public List<FileDTO> getImages(String reviewId) {
        return fileDAO.getImages(reviewId);
    }

src/main/java/com/map/restaurant/good/service/FileService.java

 

 

 

DAO

List<FileDTO> getImages(@Param("reviewId") String reviewId);

src/main/java/com/map/restaurant/good/dao/FileDAO.java

 

 

Mapper

    <select id="getImages" resultType="hashMap">
        select *
        from tbl_file_info
        where review_id = #{reviewId}
    </select>

src/main/resources/mappers/FileMapper.xml

 

🍥 Vue Client

사진 파일들은 SideBar에만 표시되긴하지만,
지도를 클릭했을 때, SideBar 에 표시가 되어야한다.
지도와 사이드바 컴포넌트 사이에 상호작용이 있어야하기 때문에,
불러온 파일 목록을 Vuex 상태에 담을 것이다.

export default new Vuex.Store({
    state: {
        // ...
        curFileList: [],
    },
    mutations: {
        // ...
        setCurFileList: (state, images) => {
            state.curFileList = images;
        }
    },
    actions: {
        // ...
        async setFileList({commit, state}, that) {
            await process(that, async () => {
                const result = await axios.get('/api/file/getImages', {
                    params: { reviewId: state.curReviewId }
                });
                await commit('setCurFileList', result.data);
            })
        }
    },
    modules: {}
})

frontend/src/store/index.js

 

curFileList 라는 state를 선언하고,
mutations 부분엔 setter를, actions 부분엔 API로 부터 가져온 파일 정보를 담는 메소드를 선언한다.

 

사이드 바의 fileList 와 다르게 curFileList 는 오로지 DB에 저장된 파일 정보만 담고 있게 될 것이다.

 

setFileList는 리뷰 아이콘을 클릭했을 때와, 리뷰가 저장되었을 때 동작해야하기 때문에
각각 아래 위치에 추가해주자.

        this.olMap.on('click', async (e) => {
            const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                this.$store.commit('setCurTitle', feature.get('title'));
                this.$store.commit('setCurAddress', feature.get('address'));
                this.$store.commit('setCurGrade', feature.get('grade'));
                this.$store.commit('setCurReview', feature.get('review'));
                this.$store.commit('setCurReviewId', feature.get('reviewId'));
                this.$store.commit('setInputState', true);
                this.$store.dispatch('setFileList', this); // 추가!
                return true;
            })

frontend/src/components/MainMap.vue

 

반드시 리뷰 상태데이터가 set되고 나서 실행되어야하기 때문에 가장 마지막에 추가해준다.
왜냐하면 getImages API의 파라미터가 curReviewId이기 때문이다.

saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                         // ...
                    })
                await ok(this, '저장 완료되었습니다.');
                this.fileList = [];
                await this.$store.dispatch('setReviews', this);
                await this.$store.dispatch('setFileList', this); // 추가!
                this.$store.commit('setInputState', true);
            })
        }

frontend/src/components/SideBar.vue

 

이 역시 현재 리뷰가 set 된 다음에 파일 리스트가 채워져야하기 떄문에
await this.$store.dispatch('setReviews', this); 이후에 추가한다.

🍜 불러온 이미지 파일 UI에 출력

채워진 curFileList 가 사용될 곳은 두 군데이다.

  1. 리뷰가 읽기 모드일 때 슬라이드 형식으로 보여지기
  2. 리뷰를 수정할 때 파일 목록에 리스트로 출력

🍥 Carousel 형태로 이미지 출력

Bootstrap Carousel로 이미지를 출력할 것이다.
슬라이드 형태로 보이는 이미지이다.

                <div class="image-area" v-if="!isDisabledInput">
                </div>
                <div class="slide-image-area" v-else>
                    <BCarousel
                        controls
                        indicators
                        v-if="curFileList.length > 0"
                    >
                        <BCarouselSlide
                            v-for="(fileInfo, idx) in curFileList"
                            :key="idx"
                            :img-src="`${imgDirPath}/${fileInfo.review_id}/${fileInfo.file_name}`"
                            reloadable
                        />
                    </BCarousel>
                    <div class="no-image-text" v-else>
                        <span>등록된 사진이 없습니다.</span>
                    </div>

                // css
            > .slide-image-area {
                padding: 0 10px;

                ::v-deep .img-fluid {
                    height: 250px !important;
                    object-fit: cover;
                }

                > .no-image-text {
                    display: flex;
                    justify-content: center;
                    align-items: center;
                    font-size: 1.3rem;
                    border: 5px dashed rgb(255, 255, 255, 0.5);
                    border-radius: 10px;
                    height: 250px;
                    background-color: rgb(255, 255, 255, 0.5);
                }
            }

frontend/src/components/SideBar.vue

 

슬라이드 이미지는 isDisabledInput 상태, 즉, 읽기 모드 일때 보여져야한다.


v-if/else로 수정 시 나타나는 이미지 목록과 이미지 슬라이드를 보이고, 사라지게 했다.
curFileList가 빈 배열일 때 역시 v-if/else 로 "등록된 사진이 없습니다" 문장이 출력되도록 했다.

 

BCarouselcontrols 를 추가하면 좌우에 이동이 가능한 화살표가 생기고
indicators를 추가하면 하단에 몇번째 페이지인지 알려주는 점이 생긴다.

 

여기서 주목해야할 부분은 img-src이다.
imgDirPathreviewId, fileName을 조합하여 경로를 구현했다.

처음엔 파일경로를 통째로 DB에 저장해서 가져올까 생각했는데,
만약 파일 위치가 변경된다면 곤란할 것 같아서 이렇게 구현했다.

 

imgDirPathapplication.properties 부분에서도 설명했지만 추후에 변경될 예정이다.
개발 환경에서는 클라이언트와 서버의 포트가 따로 돌고 있기 때문에 서버에 저장된 파일을 가져와야한다.


즉, 클라이언트와 서버의 이미지 path를 불러오는 방법이 각각 다르고,
추후에 수정될 수 있다는 점을 고려해서
common 폴더에 Config.js 라는 파일을 구현해서 서버처럼 application.properties 역할을 하게했다.

export const IMG_DIR_PATH = 'http://localhost:8083/upload-dir';

frontend/src/common/Config.js

 

Spring Boot 서버의 디렉토리를 바라보게 했다.

 

슬라이드 이미지

퍼온 사진이라 화질구지이지만 ^^... 서버의 resource 이미지가 UI에 출력되는 것을 확인할 수 있었다!🎉

🍥 수정용 이미지 목록 출력

                <div class="image-area" v-if="!isDisabledInput">
                    <div class="file-input-wrapper"
                         @dragover="onDragOver"
                         @drop="onDrop"
                         v-if="!fileList.length && !curFileList.length"
                    >
                        <input
                            class="file-input"
                            type="file"
                            accept="image/*"
                            @change="onChangeFiles"
                            multiple
                        />
                        사진을 업로드 해주세요
                    </div>
                    <div class="file-list" v-else>
                        <ul v-if="fileList.length > 0">
                            <li
                                v-for="(file, idx) in fileList"
                                :key="idx"
                            >
                                {{file.name}}
                                <FontAwesomeIcon
                                    class="delete-file-icon"
                                    icon="times"
                                    @click="deleteFile(idx)"
                                />
                            </li>
                        </ul>
                        <ul v-if="curFileList.length > 0">
                            <li
                                v-for="(file, idx) in curFileList"
                                :key="idx"
                            >
                                {{file.file_name}}
                                <FontAwesomeIcon
                                    class="delete-file-icon"
                                    icon="times"
                                    @click="addDeletedFileId(idx)"
                                />
                            </li>
                        </ul>
                        <ul>
                            <li class="file-btn-area">
                                <BButton
                                    size="sm"
                                    @click="deleteAllFile"
                                    class="file-delete-btn"
                                >
                                    전체 삭제
                                    <FontAwesomeIcon icon="times" />
                                </BButton>
                                <BButton
                                    size="sm"
                                    class="file-add-btn"
                                >
                                    추가
                                    <FontAwesomeIcon icon="plus" />
                                    <input
                                        class="file-input"
                                        type="file"
                                        accept="image/*"
                                        @change="onChangeFiles"
                                        multiple
                                    />
                                </BButton>
                            </li>
                        </ul>
                    </div>
                </div>
                <div class="slide-image-area" v-else>
                  // ...
                </div>


        // methods
        deleteFile(idx) {
            this.fileList.splice(idx, 1);
        },
        deleteAllFile() {
            this.curFileList.forEach((file, idx) => {
                this.addDeletedFileId(idx);
            })
            this.$store.commit('setCurFileList', []);
            this.fileList = [];
        },
        addDeletedFileId(idx) {
            this.deletedFileIds.push(this.curFileList[idx].file_id);
            const newCurFileList = this.curFileList.reduce((arr, item, i) => {
                if (i !== idx)
                    arr.push(item);
                return arr;
            }, [])
            this.$store.commit('setCurFileList', newCurFileList);
        },

frontend/src/components/SideBar.vue

 

fileList 와 동일하게 curFileList 도 출력시킨다.
fileListcurFileList의 차이점은 fileList 는 File 객체를 가지고 있지만,
curFileList는 DB에서 가져온 정보만을 가지고 있기 때문에 서로의 속성/키(attr)가 다르다는 것이다.

여기서 주목해야할 점은 삭제 로직이다.
fileList 는 파일을 삭제했을 때 배열의 요소를 비우면 되지만,
curFileList는 DB에 있는 파일 정보를 삭제해야한다.


deletedFileIds 라는 데이터를 하나 파고, 삭제하겠다고 클릭한 파일들의 ID 값을 채워 넣자.
수정 역시 saveReview API에서 진행되기 때문에 saveReview 파라미터에
fileIds 를 추가한다.

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                        id: this.reviewId,
                        title: this.title,
                        address: this.address,
                        grade: this.grade,
                        review: this.review,
                        lon: this.$store.state.curLon,
                        lat: this.$store.state.curLat,
                        files: this.fileList,
                        fileIds: this.deletedFileIds // 추가!
                    })
                    // ...
            })
        }

frontend/src/components/SideBar.vue

 

 

DTO

public class ReviewDTO {
    //...
    List<String> fileIds;

    // ...
    public List<String> getFileIds() {
        return fileIds;
    }

    public void setFileIds(List<String> fileIds) {
        this.fileIds = fileIds;
    }
}

src/main/java/com/map/restaurant/good/dto/ReviewDTO.java

 

 

Controller

    @PostMapping("/saveReview")
    public void saveReview(ReviewDTO reviewDTO) {
        reviewService.saveReview(reviewDTO);
        fileService.deleteFiles(reviewDTO);
        fileService.saveFiles(reviewDTO);
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

 

 

Service

    @Transactional
    public void deleteFiles(ReviewDTO reviewDTO) {
        List<String> fileIds = reviewDTO.getFileIds();
        if (fileIds == null || fileIds.isEmpty()) {
            return;
        }
        fileDAO.deleteFiles(fileIds);
    }

src/main/java/com/map/restaurant/good/service/FileService.java

 

 

DAO

void deleteFiles(@Param("fileIds") List<String> fileIds);

src/main/java/com/map/restaurant/good/dao/FileDAO.java

 

 

Mapper

     <delete id="deleteFiles">
        delete
        from tbl_file_info
        <where>
            <foreach collection="fileIds" item="fileId" separator="or">
                file_id = #{fileId}
            </foreach>
        </where>
    </delete>

src/main/resources/mappers/FileMapper.xml

 

 

MyBatis의 forEach 를 사용하면 리스트 데이터를 반복시킬 수 있다.

 

파일 삭제 후 저장

 

DB에 저장된 이미지가 정상적으로 삭제되는 걸 확인할 수 있었다!🎉


이번 포스팅에선 이미지 파일을 업로드하고 UI에 출력시켜보았다.
사실 파일 업로드 기능은 배포때까지 완성된 게 아니다!
배포된 환경의 location에 파일이 저장되는지, src로 불러와지는 지 확인해야한다.
배포까지 열심히 달려보자 🤓

 

다음 포스팅에선 사이드 바에 무한 스크롤, 또는 페이징 방식으로 리뷰 목록을 불러와 보겠다.
시간이 남는다면 Prettier 도 적용하고 전반적으로 코드를 개선해보자.

 

댓글, 하트, 피드백은 언제나 환영입니다!🥰

 

 

[참조]

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.
지난 포스팅에선 CRUD API를 구현해보았다.

이번 포스팅에선 구현했던 API를 적용하고
OpenLayers의 overlay 기능을 활용해 지도의 아이콘에 hover 기능을 추가할 것이다.

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 API 기능 모듈화

지도에 맛집 리뷰들을 출력시켜볼 것이다.
이를 위해 지난 포스팅에서 구현했던 /api/review/getReviews API를 활용하려한다.

그런데 활용하려니, 저번에 구현했던 try, catch 부터 스피너용 processingCount 까지 증가하는 코드를 반복적으로 구현해야하는게 보기 좋지 않은 것 같았다.
그래서 나는 이 부분을 모듈화해주기로 했다.

 

frontend/src/common 경로에 Api.js 라는 파일을 하나 파 주고 아래와 같이 구현했다.

export async function process(that, func) {
    that.processingCount++;
    try {
        return await func();
    } catch (err) {
        console.log(err.message);
        await that.$bvModal.msgBoxOk(err.message, {
            hideHeader: true,
            okTitle: '확인',
            noFade: false,
            size: 'sm',
            buttonSize: 'sm',
            okVariant: 'danger',
            headerClass: 'p-2 border-bottom-0',
            footerClass: 'p-2 border-top-0',
        })
        return await Promise.reject(err);
    } finally {
        that.processingCount--;
    }

}

frontend/src/common/Api.js

        async saveReview () {
            this.processingCount++;
            try {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await this.$bvModal.msgBoxOk('저장 완료되었습니다.', {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'success',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
            } catch (e) {
                console.log(e.message);
                await this.$bvModal.msgBoxOk(e.message, {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'danger',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
                return await Promise.reject(e);
            } finally {
                this.processingCount--;
            }
        }

frontend/src/components/SideBar.vue

 

위 코드는 지난 포스팅에서 구현한 saveReview 메소드이다.
반복적으로 사용되는 processingCount 증감 부분과, catch 내용을 Api.js에서 모듈화 시켜줬다.
모듈화된 코드를 활용하면 saveReview는 아래와 같이 고쳐줄 수 있다.

import { process } from '@/common/Api.js';

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await this.$bvModal.msgBoxOk('저장 완료되었습니다.', {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'success',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
            })
        }

frontend/src/components/SideBar.vue

훨씬 깔끔해졌다! 🎉

bootStrap 모달 옵션 부분도 너무 길어서 모듈화 시켜주자.
저장, 삭제, 수정 시 완료 모달이 뜰 테니 모듈화 시켜주면 좋을 것같다.

frontend/src/common 경로에 Dialog.js 파일을 하나 파고 아래와 같이 작성한다.

export function confirm(that, message) {
    return that.$bvModal.msgBoxConfirm(message, {
        hideHeader: true,
        okTitle: '확인',
        cancelTitle: '취소',
        noFade: false,
        size: 'sm',
        buttonSize: 'sm',
        okVariant: 'warning',
        cancelVariant: 'secondary',
        headerClass: 'p-2 border-bottom-0',
        footerClass: 'p-2 border-top-0',
    });
}

export function ok(that, message) {
    return that.$bvModal.msgBoxOk(message, {
        hideHeader: true,
        okTitle: '확인',
        noFade: false,
        size: 'sm',
        buttonSize: 'sm',
        okVariant: 'success',
        headerClass: 'p-2 border-bottom-0',
        footerClass: 'p-2 border-top-0',
    });
}

frontend/src/common/Dialog.js

okconfirm 메소드를 하나 파줬다.
ok 메소드는 saveReview에서 구현했던 것과 동일하다. 이제 message 매개변수만 넘기면 한 줄로 모달을 구현할 수 있게된다.
confirm 은 삭제처럼 한 번 더 유저에서 확인용 팝업을 띄우고 싶은 상황에 사용하고자 구현했다.
ok 와 다른 점은 취소 버튼이 추가되었다는 점이다.

 

모듈화된 코드를 사용하여 saveReview 를 수정해줬다.

import { process } from '@/common/Api.js';
import { ok } from '@/common/Dialog.js';

// ...
        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await ok(this, '저장 완료되었습니다.');
            })
        }

완전 깔끔해졌다! 🎉
이제 반복적인 코드 사용이 줄게 될것이다 ㅎㅎ

🍜 Vuex 추가

이제 본격적으로 리뷰를 지도에 뿌려주려니, 고민거리가 생겼다.
지금 당장은 지도에만 리뷰를 출력시키려고 하는 것이지만,
나중엔 SideBar 부분에 리뷰 목록을 불러오게 구현할 수도 있을 것이고 (아직 구체적으로 설계해보진 않았지만)
지도에 출력된 리뷰 정보(아이콘)를 클릭하여 상세 정보를 사이드에서 볼 것 같았다. (아래에서 구현함)
아무튼 이래저래 사이드바와 지도 사이의 데이터 교환이 굉장히 많이 일어날 것 같았다.

<template>
  <div id="app">
    <MainMap/>
    <SideBar class="side-bar"/>
  </div>
</template>

frontend/src/App.vue

 

App.vue 코드를 보면 알 수 있듯이,
사이드 바와 지도가 서로 형제 컴포넌트이기 때문에 $eventBus에 의존해야하는데,
이것으로만 데이터를 주고 받으려니 코드가 더러워질 것 같았다.

그리고 무엇보다 새로운 걸 써보고 싶은 마음에 ㅎㅎ Vuex 를 도입하기로 했다.
Vuex는 예전에 포스팅하면서 공부해본 적이 있다.
Vuex란? 개념과 예제
☝️☝️☝️
이 전 포스팅을 더듬어가며 Vuex 를 추가해보았다.

🍥 Vuex 설치

Vue CLI를 활용하고 있기 때문에 vue add 명령어로 Vuex를 설치해줬다.

vue add vuex

설치가 다 되고 나면 package.json에 vuex@vue/cli-plugin-vuex가 추가된 것을 확인할 수 있다.

설치하고 보니, 따로 추가해주지 않았는데도 frontend/src/store/index.js 가 추가되었다.
이 파일에 대한 설명은 Vuex 포스팅에서 다룬 적 있다. 참고!

🍥 Store 작성

store 가 필요한 부분은 지금 상황에선 일단 크게 두 개 일 것 같다.

  • 사이드 바와 지도 컴포넌트 사이의 리뷰 정보 공유
  • 사이드 바와 지도 컴포넌트 사이의 현재 선택된 위도, 경도 공유

위 상황을 고려하여 frontend/src/store/index.js 를 아래와 같이 작성해줬다.

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        reviews: undefined,
        curLon: undefined,
        curLat: undefined,
        curReviewId: undefined,
        curTitle: undefined,
        curAddress: undefined,
        curGrade: undefined,
        curReview: undefined,
    },
    mutations: {
        setReviews: (state, reviews) => {
            state.reviews = reviews;
        },
        setLonLat: (state, { lon, lat }) => {
            state.curLon = lon;
            state.curLat = lat;
        }
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
        },
        setCurTitle: (state, title) => {
            state.curTitle = title;
        },
        setCurAddress: (state, address) => {
            state.curAddress = address;
        },
        setCurGrade: (state, grade) => {
            state.curGrade = grade;
        },
        setCurReview: (state, review) => {
            state.curReview = review;
        },
    },
    actions: {},
    modules: {}
})

frontend/src/store/index.js

statereviews 와 SideBar의 Input에 해당하는 내용(curTitle, curReview 등)을 선언하고,
mutation에 각 state 값을 채워넣는 메소드를 작성해줬다.

 

이제 commit을 사용해 이 메소드들을 활용해보자!

🍜 위도, 경도 컬럼 추가

본격적으로 Vuex를 활용해 기능을 구현할 것이다.
그런데... 아뿔싸ㅠ

지난 포스팅에서 테이블을 생성할 때 주소명만 저장하고 위도, 경도 정보를 저장할 컬럼을 만들어주지 않았다 ㅋㅋㅋ
지도에 뿌려줄 위치를 찾지 못하게 되니 큰일이다.
얼른 컬럼을 추가해주자.
컬럼을 추가해주는 김에, address 컬럼의 타입크기도 살짝 늘려주자
(일부 지역의 주소가 너무 길어 에러가 뜨는 현상을 발견했다.)

alter table tbl_review add column lon double not null;
alter table tbl_review add column lat double not null;
alter table tbl_review modify address varchar(100);

추가해준 컬럼에 맞춰 DTO도 수정해줬다.

public class ReviewDTO {
    // ...
    Double lon;
    Double lat;

    // ...

    public Double getLon() {
        return lon;
    }

    public void setLon(Double lon) {
        this.lon = lon;
    }

    public Double getLat() {
        return lat;
    }

    public void setLat(Double lat) {
        this.lat = lat;
    }
}

src/main/java/com/map/restaurant/good/dto/ReviewDTO.java

API 부분도 수정해주자.

// ...
methods: {
    // ...
    saveReview () {
        process(this, async () => {
            await axios.post('/api/review/saveReview', {
                title: this.title,
                address: this.address,
                grade: this.grade,
                review: this.review,
                lon: this.$store.state.curLon, // 추가
                lat: this.$store.state.curLat // 추가
            });
            await ok(this, '저장 완료되었습니다.');
        })
    }
}
// ...

frontend/src/components/SideBar.vue

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.ReviewDAO">
    <insert id="saveReview">
        insert into tbl_review (id, title, address, review, grade, lon, lat)
        values (#{id}, #{title}, #{address}, #{review}, #{grade}, #{lon}, #{lat})
        on duplicate key
        update
            title = #{title},
            address = #{address},
            review = #{review},
            grade = #{grade},
            lon = #{lon},
            lat = #{lat}
    </insert>

    <select id="getReviews" resultType="hashMap">
        select *
        from tbl_review
    </select>

    <delete id="deleteReview">
        delete
        from tbl_review
        where id = #{id}
    </delete>
</mapper>

src/main/resources/mappers/ReviewMapper.xml

🍜 리뷰 불러오기

이제 저장된 리뷰들을 지도에 뿌려보자.
DB에서 맛집의 위도, 경도정보를 가져와 그 위치에 아이콘을 그려줄 것이다.
지난 시간에 구현한 /getReviews API를 사용하자.


불러온 리뷰들은 statereviews에 저장할 것이다.
비동기 api 를 불러오는 과정이기 때문에 Vuex 의 actions 부분에 api 호출 메소드를 작성하자.

import { process } from '@/common/Api.js';

// ...

    actions: {
        async setReviews({commit}, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviews');
                await commit('setReviews', result.data);
            })
        }
    }

frontend/src/store/index.js

불러온 api 결과물은 commit으로 위에서 mutations 에 정의한 setReviews 를 불러와 set해줬다.

    mounted() {
        // ...
        this.olMap = new OlMap({
            // ...
        });
        await this.$store.dispatch('setReviews', this);
        // ...
    }

frontend/src/components/MainMap.vue

지도가 그려지고 난 뒤에 리뷰가 뿌려져야하니,
지도 선언 바로 다음에 dispatchsetReviews 를 호출해오자.


이제, 지도에 아이콘으로 불러온 리뷰들을 출력할 것이다.
무료 아이콘 사이트에서 적당한 스팟 아이콘을 다운로드해왔다.
사실 썩 맘에 들진 않는데 (색이 마음에 안듬...) 나중에 바꿔주던가 해야겠다 ㅋㅋ

다운 받은 이미지 파일을 frontend/src/assets/images/spot.png로 저장한다. (public 에 저장해줘도 상관 없다.)

다운로드한 spot 아이콘

이제 이 아이콘을 지도에 그리는 메소드를 작성할 것이다.
지난 포스팅에서 클릭했을 때 Feature 를 추가하는 방법과 동일하게 구현할 것인데,
클릭시 Feature 가 그려지는 레이어와는 또 다른 레이어를 추가할 것이다.

같은 레이어에 리뷰 Feature 를 추가해줘도 상관 없긴 하지만,
클릭시 발생하는 아이콘과 리뷰 목록 아이콘 둘 다 지웠다 그려지는 경우가 많다.
지워질 때 어떤 아이콘인지 반복을 돌며 찾아내서 지우는 것 보단
레이어 자체를 비우고 다시 그리는 게 더 좋을 것 같았다.

    data() {
        return {
            // ...
            iconsSource: undefined
        }
    },
    methods: {
        drawFeatures() {
            if (this.iconsSource)
                this.iconsSource.clear();

            this.iconsSource = new OlVectorSource(EPSG_3857);
            const iconsLayer = new OlVectorLayer({
                source: this.iconsSource
            });
            const style = new OlStyle({
                image: new OlIcon({
                    scale: 0.8,
                    src: require('../assets/images/spot.png')
                })
            });
            const features = this.reviews.map(review => {
                const point = this.coordi4326To3857([review.lon, review.lat]);
                const feature = new OlFeature({
                    geometry: new OlPoint(point)
                });
                feature.set('title', review.title);
                feature.set('grade', review.grade);
                feature.set('address', review.address);
                feature.set('review', review.review);
                feature.set('reviewId', review.id);
                feature.setStyle(style);

                return feature;
            })
            this.iconsSource.addFeatures(features);

            this.olMap.addLayer(iconsLayer);
        }
    }

frontend/src/components/MainMap.vue

 

iconSource 를 전역 변수로 선언해 그려진 내용을 기억하게 만들고 clear() 할 수 있게 했다.
clear()는 OpenLayers 에서 제공하는 것으로 source 의 모든 요소를 비운다.

 

지난 번에 구현했던 지도 클릭시 Feature 를 그리는 방법과 동일하게 구현했다.
다른 점은 addFeature 가 아니고 addFeatures(복수형)을 사용했다는 점이다.

 

생성한 feature엔 리뷰 정보들을 set해준다.
그래야 리뷰 아이콘을 클릭했을 때, 사이드바에 해당 정보를 넘겨줄 수 있다.

 

마지막엔 OpenLayers의 addLayer 를 사용하여 생성한 리뷰 아이콘용 레이어를 지도에 추가!

지도에 출력된 리뷰 아이콘

지도에 아이콘이 제대로 출력되는 걸 확인할 수 있었다! 🎉

🍜 리뷰 아이콘 클릭 시 내용 보여주기

지도에 보이는 리뷰 아이콘을 클릭했을 때, 좌측 사이드바에 해당 내용이 보여지도록 할 것이다.
아이콘 클릭 이벤트는 지도 화면을 클릭했을 때 현재 위치에 아이콘을 하나 찍는 용도로 지난 포스팅에서 구현했었다.
그 이벤트 내부에 Feature 를 클릭했을 때 다르게 동작하는 로직을 추가해줄 것이다.

mutations: {
    setReview: (state, review) => {
        state.curReviewId = review ? review.id : review;
        state.curLat = review ? review.lat : review;
        state.curLon = review ? review.lon : review;
        state.curTitle = review ? review.title : review;
        state.curGrade = review ? review.grade : review;
        state.curAddress = review ? review.address : review;
        state.curReview = review ? review.review : review;
    }
}

frontend/src/store/index.js

// ...
    this.olMap.on('click', async (e) => {
            this.vectorSource.clear();
            geocoder.getSource().clear();
            const [lon, lat] = toLonLat(e.coordinate)
            const addressInfo = await that.getAddress(lon, lat)

            this.$store.commit('setReview', undefined);
            this.$store.commit('setCurAddress', that.getUiAddress(addressInfo.data.display_name));
            that.$store.commit('setLonLat', {lon, lat});

            const point = that.coordi4326To3857([lon, lat]);
            const feature = new OlFeature({
                geometry: new OlPoint(point)
            })
            feature.setStyle(new OlStyle({
                image: new OlIcon({
                    scale: 0.7,
                    src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
                })
            }))

            const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                this.$store.commit('setCurTitle', feature.get('title'));
                this.$store.commit('setCurAddress', feature.get('address'));
                this.$store.commit('setCurGrade', feature.get('grade'));
                this.$store.commit('setCurReview', feature.get('review'));
                this.$store.commit('setCurReviewId', feature.get('reviewId'));
                this.$store.commit('setInputState', true);
                return true;
            })

            if (!existFeature)
                this.vectorSource.addFeature(feature);
        })
// ...

methods: {
    // ...
    getUiAddress(str) {
        return str.split(', ').reverse().join(' ');
    },
}

frontend/src/components/MainMap.vue

vectorSource 역시 전역 변수로 변경해줬다.
mounted 밖에서도 clear() 시킬 일이 있기 때문이다.
(예를 들면 리뷰를 저장하고 새 리뷰 목록을 불러 왔을 때)

 

setReview 라는 mutations 메소드를 하나 파고 현재 선택된 리뷰 정보 전체를 set하는 메소드를 만든다.
그리고 this.$store.commit('setReview', undefined);로 현재 store 에 저장된 내용을 초기화 시킨다.
(지도에 그려진 리뷰 아이콘을 클릭했을 때 store 에 현재 리뷰 정보가 담기게 될 것이다.
클릭 이 후에 다시 아이콘이 없는 지도 아무 부분이나 클릭한다면 선택된 리뷰가 없는 것이니 초기화 시켜주는 것)

 

기존의 that.setUiAddress(addressInfo.data.display_name);을 지우고
getUiAddress 메소드를 새로 하나 판다.

 

forEachFeatureAtPixel()은 현재 클릭된 Feature에 대한 이벤트를 구현할 수 있도록 한다.
클릭된 feature 의 정보를 store 에 commit 으로 저장시켰다.

 

그리고 현재 클릭된 feature 가 없다면,
즉, 지도의 빈 곳을 클릭한 상태라면 vectorSource의 feature 인 '현재 클릭된 곳 표시용 아이콘'을 addFeauture 한다.

이제 지도의 아이콘을 클릭했을 때 현재 선택된 리뷰의 정보를 store 에 담게 했으니,
이를 사이드바에 불러오게 할 것이다.

data () {
        return {
            // ...
            title: undefined,
            address: undefined,
            grade: undefined,
            review: undefined,
        }
    },

frontend/src/components/SideBar.vue

지난 포스팅에서 사이드 바의 input 값을 넣어줄 data()를 선언했었다.
이는 사이드 바 내부에서만 주고 받는 데이터였기 때문에,
지도와 공유하기 위해선 생성해준 store 의 state 로 변경해줘야한다.

import { mapState } from 'vuex'

// ...
    computed: {
        ...mapState({
            reviewId: state => state.curReviewId,
            curAddress: state => state.curAddress,
            curGrade: state => state.curGrade,
            curReview: state => state.curReview,
            curTitle: state => state.curTitle
        }),
        address: {
            get() {
                return this.curAddress;
            },
            set(newVal) {
                this.$store.commit('setCurAddress', newVal);
            }
        },
        grade: {
            get() {
                return this.curGrade
            },
            set(newVal) {
                this.$store.commit('setCurGrade', newVal);
            }
        },
        review: {
            get() {
                return this.curReview
            },
            set(newVal) {
                this.$store.commit('setCurReview', newVal);
            }
        },
        title: {
            get() {
                return this.curTitle
            },
            set(newVal) {
                this.$store.commit('setCurTitle', newVal);
            }
        }
    },

frontend/src/components/SideBar.vue

data()에 선언된 내용을 지우고, 위와 같이 입력해준다.
mapStateVuex 포스팅에서 공부한적 있다.
state를 여러개 가져올 때 쓰는 것!

 

사이드 바의 input 들은 store에 저장된 내용을 출력하기도 하지만
유저가 직접 입력해주는 경우도 있다.(추가/수정)
때문에 사용자의 입력값을 store 에 저장해주는 get(), set() 도 선언한다.

 

아이콘 클릭시 사이드 바에 리뷰 내용이 출력된다!


성공적으로 사이드바에 데이터가 불려오는 것을 확인할 수 있었다! 🎉

🍜 리뷰 수정, 삭제 구현하기

지도 아이콘 클릭으로 불려온 사이드 바 데이터들을 수정할 수 있게 해보자.

사이드 바에 출력된 데이터 들은 처음엔 수정(입력)할 수 없게 하고, (저장 버튼도 숨기기! 읽기 전용!)
'수정하기' 버튼을 클릭했을 때 저장버튼도 출력되고 수정(입력)도 가능하게 할 것이다.

읽기 전용으로 만드는 state(isDisabledInput)를 하나 구현해주자.
사이드 바 내 전역 변수가 아니라 store의 state 로 저장해 주는 이유는,
아래 항목들을 고려했기 때문이다.

  1. 지도 아이콘을 클릭 했을 때 isDisabledInput 가 true가 되어야한다.
  2. 아이콘이 존재하지 않는 지도 영역을 클릭했을 떄 isDisabledInput가 false 가 되어야한다.
  3. 수정하기 버튼을 눌렀을 때 isDisabledInput가 false 가 되어야한다.
  4. 수정 후 저장하기 버튼을 눌렀을 때 isDisabledInput가 true 가 되어야한다.
  5. 선택한 항목을 삭제 한 후(새 리뷰 목록을 불러올 때) isDisabledInput가 false 가 되어야한다.
// ...
state: {
        // ...
        isDisabledInput: undefined
    },
    mutations: {
        setInputState: (state, bool) => {
            state.isDisabledInput = bool;
        },
    }
// ...

frontend/src/store/index.js


위 항목 번호를 따라 진행해보겠다.
1번, 2번 항목을 구현하기 위해 MainMap.vue의 클릭 이벤트 부분에 아래와 같이 구현한다.

        this.olMap.on('click', async (e) => {
            this.vectorSource.clear();
            geocoder.getSource().clear();
            const [lon, lat] = toLonLat(e.coordinate)
            const addressInfo = await that.getAddress(lon, lat)

            this.$store.commit('setReview', undefined);
            this.$store.commit('setInputState', false); // 추가!
            this.$store.commit('setCurAddress', that.getUiAddress(addressInfo.data.display_name));
            that.$store.commit('setLonLat', {lon, lat});

            const point = that.coordi4326To3857([lon, lat]);
            const feature = new OlFeature({
                geometry: new OlPoint(point)
            })
            feature.setStyle(new OlStyle({
                image: new OlIcon({
                    scale: 0.7,
                    src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
                })
            }))

            const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                this.$store.commit('setCurTitle', feature.get('title'));
                this.$store.commit('setCurAddress', feature.get('address'));
                this.$store.commit('setCurGrade', feature.get('grade'));
                this.$store.commit('setCurReview', feature.get('review'));
                this.$store.commit('setCurReviewId', feature.get('reviewId'));
                this.$store.commit('setInputState', true); // 추가!
                return true;
            })

            if (!existFeature)
                this.vectorSource.addFeature(feature);
        })

frontend/src/components/MainMap.vue

 

1번 항목을 위해 forEachFeatureAtPixel 내부에 this.$store.commit('setInputState', true);를 추가해줬고,
2번 항목을 위해 forEachFeatureAtPixel 외부에 this.$store.commit('setInputState', false);를 추가해줬다.

                <div class="bottom-btn-area">
                    <BButton
                        class="save-btn"
                        @click="saveReview"
                        v-if="!isDisabledInput"
                    >
                        저장
                    </BButton>
                    <BButton
                        class="mr-2"
                        variant="success"
                        @click="$store.commit('setInputState', false)"
                        v-if="isDisabledInput"
                    >
                        수정하기
                    </BButton>
                    <BButton
                        variant="danger"
                        @click="removeReview"
                        v-if="isDisabledInput"
                    >
                        삭제하기
                    </BButton>
                </div>

frontend/src/components/SideBar.vue

 

3, 4, 5번 항목을 위해 사이드 바에 버튼을 추가해줬다.
각 항목은 isDisabledInput에 따라 v-if 로 가려지게 했다.
3번 항목을 위해 수정하기를 누르면 this.$store.commit('setInputState', false)가 되게 했다.

 

수정하기, 삭제하기, 저장 버튼이 적절하게 나타났다 사라진다

적절하게 버튼이 생겼다 사라지는 걸 확인할 수 있었다!🎉

이제 removeReviewsaveReview(수정)를 구현해보자.

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    id: this.reviewId, // 추가!
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review,
                    lon: this.$store.state.curLon,
                    lat: this.$store.state.curLat
                });
                await ok(this, '저장 완료되었습니다.');

                await this.$store.dispatch('setReviews', this); // 추가!
                this.$store.commit('setInputState', true); // 추가!
            })
        }

frontend/src/components/SideBar.vue

    mutations: {
    // ...
        setReviews: (state, reviews) => {
            if (state.reviews &&
                reviews &&
                state.reviews.length !== reviews.length) {
                const ids = state.reviews.map(re => re.id);
                const curReview = reviews.find(review => !ids.includes(review.id));
                if (curReview)
                    state.curReviewId = curReview.id;
            }
            state.reviews = reviews;
            state.isDisabledInput = false;

            const review = reviews.find(review =>
                review.id === state.curReviewId
            );

            setReview(state, review);
        },
        setReview: (state, review) => {
            setReview(state, review);
        },
       }

//...
function setReview(state, review) {
    state.curReviewId = review ? review.id : review;
    state.curLat = review ? review.lat : review;
    state.curLon = review ? review.lon : review;
    state.curTitle = review ? review.title : review;
    state.curGrade = review ? review.grade : review;
    state.curAddress = review ? review.address : review;
    state.curReview = review ? review.review : review;
}

frontend/src/store/index.js

 

기존에 구현되어있던 saveReview 에 저장 후 리뷰를 불러오는 로직과
4번 항목인 this.$store.commit('setInputState', true); 를 추가해줬다.

 

추가/수정 후 리뷰를 불러왔을 때 현재 추가/수정한 리뷰를 보여줘야할 것 같아서,
mutations의 setReviews를 수정해줬다.

if (state.reviews &&
    reviews &&
    state.reviews.length !== reviews.length)

store에 저장된 reviews가 존재하고(이미 리뷰가 불러와져있는 경우)
인자로 reviews 를 받아왔을 때(새로 불러온 리뷰),
인자의 reviews의 길이와 state.reviews의 길이가 같은지 확인한다.


다르다면, 새로운 항목이 추가되거나 삭제된 경우이다.
추가되었다면 해당 리뷰를 사이드바에 보여줘야하니, findid를 찾아 리뷰를 set해준다.


각 리뷰 항목을 set하는 과정은 setReview와 중복되는 부분이 있는 것 같아서,
setReview라는 function 을 하나 파줬다.


만약 setReview에 받아온 review인자가 없다면(undefined) 리뷰항목들을 초기화 시켜주게 될것이다.
이는 리뷰가 삭제되었을 때의 상황이다.

import { confirm, ok } from '@/common/Dialog.js';

//...
    methods: {
    // ...
        removeReview() {
            process(this, async () => {
                const isConfirmed = await confirm(this, `'${this.title}' 리뷰를 삭제하시겠습니까?`);
                if (! isConfirmed) return;

                await axios.delete('/api/review/deleteReview', {
                    data: {
                        id: this.reviewId
                    }
                });

                await ok(this, '삭제되었습니다.');

                await this.$store.dispatch('setReviews', this);
            })
        }
    }

frontend/src/components/SideBar.vue

리뷰 삭제는 삭제 전에 한번 더 확인하는 게 좋을 것 같아서, 앞전에 구현했었던 Dialog.jsconfirm()을 활용한다.
삭제하고 난 위에 await this.$store.dispatch('setReviews', this);


수정, 삭제한 것이 지도에도 반영이 되어야하기 때문에,
아래 내용을 MainMap.vue 에 추가한다.

    computed: {
        reviews() {
            return this.$store.state.reviews;
        }
    },
    watch: {
        async reviews() {
            if (this.vectorSource)
                this.vectorSource.clear();
            this.drawFeatures();
        }
    },

frontend/src/components/MainMap.vue

새로운 리뷰 목록이 불러와졌을 때,
vecotSource, 즉, 현재 선택된 지도 영역에 아이콘을 표시하는 vectorSourceclear 시켜준다.
그리고 drawFeatures 로 새로 불러온 리뷰 목록을 지도에 출력!

🍜 지도 아이콘 hover 기능 구현

지도 아이콘을 클릭했을 때 사이드바에서 리뷰 정보를 확인할 수 있지만,
일일이 누르고 API가 동작하는 것까지 기다리기엔 조금 답답한 것 같아서
지도 아이콘에 hover 기능을 추가해 간단한 정보를 지도에서 바로 확인할 수있게 할 것이다.

<template>
    <div class="main-map" ref="map">
        <div
            class="overlay-tooltip"
            ref="overlay"
            v-show="isShowOverlay"
        >
            <div class="overlay-content">
                {{ selectedOverlayText }}
                <BFormRating class="rating" v-model="selectedOverlayRating" readonly />
            </div>
        </div>
    </div>
</template>


// ...
  data() {
      return {
          //...
          overlay: undefined,
          isShowOverlay: false,
          selectedOverlayText: undefined,
          selectedOverlayRating: undefined,
      }
  }

//...
<style lang="scss" scoped>
// ...
    .overlay-tooltip {
        border-radius: 5px;
        background-color: rgba(0, 0, 0, 0.5);
        padding: 5px 10px;
        color: white;
        text-align: center;

        > .overlay-content::after {

            content: "";
            position: absolute;

            top: 100%;
            left: 50%;
            margin-left: -5px;

            border-width: 5px;
            border-style: solid;
            border-color: rgba(0, 0, 0, 0.5) transparent transparent transparent;


        }

        ::v-deep.rating {
            font-size: 15px;
            background-color: transparent;
            border: none;
            padding: 0;
            margin: 0;
            color: #ffdd00;
            height: unset;
        }
    }
</style>

frontend/src/components/MainMap.vue

OpenLayers 는 Overlay 라는 기능을 제공한다.
Overlay 는 직역하면 '위에 깔다'라는 뜻이다.
말그대로 지도 위에 팝업이나 위젯따위를 그릴 때 많이 사용된다.

나는 지도 위 아이콘을 hover 했을 때 맛집의 제목가 별점이 보였으면 좋겠어서,
위와 같이 구현해줬다.


selectedOverlayText 는 맛집 제목이고,
selectedOverlayRating 는 별점이다.
그리고 isShowOverlay로 hover 될때마다 출력되게 할 것이다.


오버레이는 말풍선 모양처럼 보이게 스타일을 줬다.

import Overlay from 'ol/Overlay.js';
// ...
    mounted: {
        // ...
        this.olMap.on('pointermove', (e) => {
            that.olMap.getTargetElement().style.cursor = '';
            that.isShowOverlay = false;
            that.olMap.removeOverlay(that.overlay);

            that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                if (feature.get('title') !== undefined) {
                    that.isShowOverlay = true;
                    that.selectedOverlayText = feature.get('title');
                    that.selectedOverlayRating = feature.get('grade');

                    const overlay = that.$refs.overlay;

                    that.overlay = new Overlay({
                        element: overlay,
                        position: feature.getGeometry().getCoordinates(),
                        positioning: 'bottom-center',
                        offset: [0, -10]
                    })
                    that.olMap.addOverlay(that.overlay);
                    that.olMap.getTargetElement().style.cursor = 'pointer';
                }
            })
        })
    }

frontend/src/components/MainMap.vue

pointermove 부분을 위와 같이 수정한다.

that.olMap.getTargetElement().style.cursor = '';
that.olMap.getTargetElement().style.cursor = 'pointer';

위 코드는 아이콘 위에 커서를 올렸을 때
클릭이 가능하다는 느낌을 주기위해
손가락 포인터 커서 모양으로 바꿔주는 코드이다.

아이콘에 마우스가 올려졌을 때만 오버레이가 보여야하기때문에,
forEachFeatureAtPixel 외부엔 removeOverlay로 오버레이를 지워주고,
isShowOverlayfalse 로 변경하여 가린다.

 

forEachFeatureAtPixel 내부에서 feature의 title이 존재한다면,
selectedOverlayText, selectedOverlayRating를 해당 feature 의 정보로 채워넣는다.

 

new Overlay 로 오버레이를 생성한다.
positioningoffset으로 feature 위에 표시될 위치를 잡는다.

 

hover 시 오버레이 말풍선이 출력되는 모습

hover 시 오버레이 말풍선이 적절히 출력되는 것을 확인할 수 있었다!🎉


이번 시간엔 Vuex 를 적용시키고
OpenLayers 의 오버레이 기능을 추가해보았다.

수정된 부분이 많아 포스팅에서 다소 빠뜨린 부분이 있을 수 있다.
전체 코드는 깃헙을 참고!

다음 포스팅에선 첨부파일로 이미지를 저장해보겠다.

댓글, 하트, 피드백은 언제나 환영입니다🥰

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 클릭이벤트로 주소를 입력 받거나 출력하는 기능을 구현하였다.

이번 포스팅에선 본격적으로 백엔드 기능을 구현해볼 것이다.
Spring Boot + MyBatis + MySQL을 연동하고
주소, 후기 및 별점을 저장하거나 불러오는 API를 만들어볼 것이다.

사진 파일 업로드까지는 이 포스팅에서 다루지 않는다.
파일을 다루는 방식이나 (ex. blob) Vue 의 slot 기능을 구체적으로 알아보기 위해 다음 포스팅에서 자세히 다뤄볼 예정이다.

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 Spring Boot, Mybatis, MySQL 연동

이 프로젝트의 첫번째 포스팅에서 언급했던 것처럼 MySQL 이 설치된 상태로 가정하고 진행한다.

MySQL 설치가 된 상태라면, 데이터베이스를 만들어주자.
명령 프롬프트 등을 이용해 MySQL 에 로그인 한 뒤 create database 명령어를 사용하면 된다.
다른 DB 툴을 사용해도 상관 없다.

DB 관리 도구로는 DBeaver를 강추한다.
UI 가 알아보기 쉽고 PRO 버전이 아니라면 무료로 사용 가능한데, 웬만한 기능은 무료로 사용 가능하다.
그리고 무엇보다 비버 아이콘이 아주 귀엽다🥰

데이터 베이스 이름은 자유롭게 하면 된다.
나는 good_restaurant 라고 해줬다.

💡 mysql 로그인

> mysql -u [유저 아이디] -p

설치 시 별다를 설정을 해주지 않았다면 id는 root고 비번은 mysql 일 것이다.
-u 는 유저 아이디를 의미하고 -p는 패스워드를 의미하는데,
패스워드는 위 명령어 입력 후 엔터를 치면 타이핑할 수 있다.

💡 데이터베이스 생성

mysql > create good_restaurant;

Spring Boot, Mybatis, MySQL 연동 방법은 다른 포스팅에서 다룬 적이 있다.

Spring Boot + MyBatis + MySQL 연동 방법
☝️☝️☝️
이 글을 보고 Srpring Boot, MyBatis, MySQL 을 연동하자.
테스트코드까지 있어서 어렵지 않게 연동할 수 있을 것이다.

연동 후 디렉토리 상태

포스팅을 따라 잘 따라했다면,
디렉토리 상태가 위와 같을 것이다.

이제 본격적으로 리뷰와 별점을 저장하는 API를 구현해보자.

🍜 Axios 설치

저장 API를 구현해보기에 앞서...
다시 보니 저장버튼을 빠뜨렸다;;
사이드바 하단에 저장 버튼을 추가해주자.

// template

<div class="side-bar">
    // ...
    <div class="bottom-btn-area">
        <BButton class="save-btn">
            저장
        </BButton>
    </div>
</div>

// style
> .bottom-btn-area {
    text-align: right;
    padding-right: 10px;

    > .save-btn {
        color: #fff;
        font-weight: bold;
          background-color: #ee9e06;
    }
}

/frontend/src/components/SideBar.vue

저장 버튼 추가!

 

우리가 저장해야할 항목은 맛집 이름, 위치정보, 별점, 후기가 있다. (추후 이미지 추가)
inputv-model 을 달아주자
나는 각각 title, address, grade, review로 달아줬다.

<template>
  <div class="side-bar-wrapper">
    <VueResizable
        class="resizable-side-bar"
        :width="500"
        :min-width="500"
        :max-width="Infinity"
        :active="['r']"
        v-if="isVisibleSideBar"
    >
      <div class="side-bar">
        <div class="title-area">
          <BInput v-model="title" placeholder="맛집 이름을 입력해주세요."/>
        </div>
        <div class="image-area">
          <div class="iw-file-input">
            사진을 업로드 해주세요
          </div>
        </div>
        <div class="location-info-area">
          <FontAwesomeIcon icon="location-dot" />
          <BInput
              placeholder="위치 정보 직접 입력하기"
              v-model="address"
          />
        </div>
        <div class="rate-area">
          <BFormRating v-model="grade" />
        </div>
        <div class="review-area">
          <BFormTextarea
              ref="textarea"
              placeholder="후기를 입력해주세요."
              v-model="review"
          />
        </div>
        <div class="bottom-btn-area">
          <BButton class="save-btn">
            저장
          </BButton>
        </div>
      </div>
    </VueResizable>
    <BButton
        class="side-bar-active-btn"
        size="sm"
        @click="showSideBar"
    >
      <FontAwesomeIcon :icon="isVisibleSideBar ? 'angle-left' : 'angle-right'" />
    </BButton>
  </div>
</template>

<script>
import VueResizable from 'vue-resizable';

export default {
  name: 'SideBar',
  components: {
    VueResizable
  },
  data() {
    return {
      isVisibleSideBar: true,
      title: undefined,
      address: undefined,
      grade: undefined,
      review: undefined
    }
  }
}
</script>

frontend/src/components/SideBar.vue

이제 input 에 내용을 입력하면 v-model 로 set해준 data에 담긴다.
우리는 이것을 API 에 params로 전달해 줄 것이다.

저장 버튼을 누르면 API가 실행되도록 구현해보자.
API 구현엔 Axios 를 이용할 것이다.
공식 문서에 따르면,
Axios는 브라우저, Node.js 를 위한 Promise API를 활용하는 HTTP 비동기 통신 라이브러리라고 설명되어 있다.
쉽게 말하자면 백엔드와 프론트엔드의 통신을 쉽게하는 라이브러리이다.
이미 자바스크립트에는 fetch api가 있지만 프레임워크에서 ajax를 구현할 땐 axios를 쓰는 편이라고 보면된다.

axios fetch
third party 라이브러리(제 3자가 만든 라이브러리)로, 따로 설치가 필요하다. 현대 브라우저에 빌트인이라 설치 필요 없음
XSRF(사이트 간 request 위조) 보호를 해준다. 별도 보호 없음
data 속성을 사용 body 속성을 사용
data는 object를 포함한다. body는 문자열화 되어있다.
status가 200이고 statusText가 OK이면 성공이다 응답 객체가 ok 속성을 포함하면 성공이다
자동으로 JSON데이터 형식으로 변환된다. .json() 메소드를 사용해야한다.
요청을 취소할 수 있고 타임아웃을 걸 수 있다. 지원 X
HTTP 요청을 가로챌 수 있음 지원 X
download 진행에 대해 기본적인 지원을 함 지원하지 않음
좀더 많은 브라우저에 지원됨 한정적인 브라우저에 지원됨

여러모로 Axios가 fetch보다 더 좋은 것 같다...

나는 npm으로 axios 를 설치해줬다.

npm install axios

설치 후 packagke.json에 axios 가 설치된 걸 확인할 수 있었다.

  "dependencies": {
    // ...
    "axios": "^0.26.1",
    // ...
  },

우리가 params에 담을 데이터들(맛집 제목, 리뷰 등)은 대부분 문자열이라 HTTP의 GET 메소드를 써도 될 것 같지만,
숫자형인 별점과 엄청난 맛집이어서 후기가 아주 길어질 경우를 생각해 POST 메소드를 활용할 것이다.

<template>
    // ...
    <BButton
        class="save-btn"
        @click="saveReview"
    >
        저장
    </BButton>
    // ...
</template>

<script>
import axios from 'axios';

methods: {
// ...
    saveReview() {
        axios.post('/api/review/saveReview', {
            title: this.title,
            address: this.address,
            grade: this.grade,
            review: this.review
    }
}
// ...
</script>

/frontend/src/components/SideBar.vue

아까 만들어줬던 저장 버튼에 saveReview 라는 method를 만들어줬다.
API의 url은 /api/review/saveReview 로 지정해줬고,
params 데이터로는 title, address, grade, review를 넣어줬다.

🍜 리뷰 저장(추가) API 구현

/api/review/saveReview라고 url을 정해줬으니 이에 대한 API를 구현해보자.

🍥 DTO 구현

먼저, DTO를 만들 것이다.
DTO는 Data Transfer Object의 약자로, 계층 간 데이터 교환을 하기 위해 사용하는 객체이다.

💡 참고
DTO vs VO

VO는 Value Object의 약자로, DTO처럼 그냥 객체를 의미한다.
DTO와 VO 둘 다 단순 객체라는 점은 같지만,VO는 read-only이고 DTO는 setter를 가지고 있어 값이 변할 수 있다.

 

/src/main/java/패키지/dto 경로에 ReviewDTO.java를 생성한다.
패키지 명은 프로젝트에 맞춰 자유롭게 하면된다.
나는 com.map.restaurant.good 라고 해줬다. (정확히는 dto까지 패키지임)
(패키지는 보통 도메인을 거꾸로 뒤집은 형태로 짓는다고 한다.)

package com.map.restaurant.good.dto;

public class ReviewDTO {
    String id;
    String title;
    String address;
    Integer grade;
    String review;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public Integer getGrade() {
        return grade;
    }

    public void setGrade(Integer grade) {
        this.grade = grade;
    }

    public String getReview() {
        return review;
    }

    public void setReview(String review) {
        this.review = review;
    }
}

/src/main/java/com/map/restaurant/good/dto/ReviewDTO.java

📌 Intellij 꿀팁 (Windows 기준)

String title;
String address;
Integer grade;
String review;

class 안에 위 코드 처럼 변수만 선언해주고 alt + Insert 를 누르면 getter, setter를 자동 생성할 수 있다!
(MacOS에선 코맨드키 + N 라고 하는데, 안해봐서 되는지는 모르겠다.)

전체 선택 후 OK!

🍥 테이블 생성

DTO에 맞춰 넘겨받을 파라미터들을 저장할 테이블을 아래 SQL로 생성해줬다.

create table tbl_review (
    id varchar(36) not null primary key,  
    title varchar(20) not null,
    review varchar(500) not null,
    address varchar(50) not null,
    grade int
);

id 로는 정수형이 아닌 UUID를 사용할 것이다.

UUID는 128비트의 숫자이며, 32자리의 16진수로 표현된다.
여기에 8-4-4-4-12 글자마다 하이픈을 집어넣어 5개의 그룹으로 구분한다.

예)

550e8400-e29b-41d4-a716-446655440000

즉, 나는 id에 32자리의 16진수와 4개의 하이픈이 더해진 varchar(36) 을 할당해줬다.

📌 int형 대신 UUID를 사용하는 이유

  • 현재 근무 중인 직장에서 대부분의 프로젝트에 UUID를 사용하고 있기 때문에 내게 친근하다 ㅎㅎ
  • UUID는 데이터에 대한 정보를 노출하지 않기 때문에 보안상 안전하다.
  • int 값은 그 테이블에서만 고유하지만, UUID는 여러 데이터베이스에서도 고유한 값이다.
  • int 값은 insert를 하기 전 데이터베이스를 조회해 전 pk값을 알아와야하지만, uuid는 stateless하기 때문에 함수를 사용하여 키를 생성할 수 있어 그럴 필요 없다.

UUID의 단점으로는 int 값보다 더 많은 양의 저장 장소가 필요하다는 것인데,
이 프로젝트에선 딱히 저장 장소에 대해 신경 쓰지 않아도 될 것 같아 UUID를 사용하게 됐다.

🍥 DAO 구현

DAO 는 Data Access Object의 약자로, DB에 접근하기 위한 객체(인터페이스)이다.
(추상적인 이 인터페이스의 알맹이는 Mybatis로 선언된 sql라고 생각하면 된다.)

package com.map.restaurant.good.dao;

import com.map.restaurant.good.dto.ReviewDTO;

public interface ReviewDAO {
    void saveReview(ReviewDTO reviewDTO);
}

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

저장만 할 것이기 때문에 아무것도 반환하지 않는(void) saveReview 메소드를 선언해준다.
요청으로 받아온 데이터를 DB에 넘겨야하기 때문에 매개변수엔 DTO를 넣어준다.

🍥 Mapper 구현

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.ReviewDAO">
    <insert id="saveReview">
        insert into tbl_review (id, title, address, review, grade)
        values (#{id}, #{title}, #{address}, #{review}, #{grade})
    </insert>
</mapper>

🍥 Controller 구현

Controller 는 MVC(Model View Controller)패턴에서의 그 C가 맞다!
Controller 는 사용자가 요청이 진입하는 지점이며, 요청에 따라 어떤 처리를 할지 결정해준다.
우리의 경우, controller에 /api/review/saveReview 요청에 대한 기능이 구현되면 된다.

@RestController
@RequestMapping("/api/review")
public class ReviewCtrl {
    @Autowired
    private ReviewDAO reviewDAO;

    @PostMapping("/saveReview")
    public void saveReview(@RequestBody ReviewDTO reviewDTO) {
        String id = UUID.randomUUID().toString();
        reviewDTO.setId(id);
        reviewDAO.saveReview(reviewDTO);
    }
}

src/main/java/com/map/restaurant/good/ReviewCtrl.java

파라미터로 ReviewDTO를 받고 saveReview 기능이 동작하는 API를 짜줬다.

자, 이제 리뷰가 저장이 되는 지 확인해보자!
최근에 먹은 맛집에 대한 리뷰를 작성해보았다.

확신의 등촌샤브칼국수
tbl_review

DB에 정상적으로 저장되는 걸 확인할 수 있었다!🎉

🍜 Spinner 생성

API 동작 시 주의해야할 점은, 유저가 API가 돌고 있을 동안 다른 동작을 하지 못하도록 막는 것이다.
뭔가 저장, 수정, 삭제하는 API가 돌고 있을 때 유저가 같은 데이터에 대한 다른 API 기능을 해버렸을 때,
혼란을 야기할 수 있다.

때문에 Spinner를 추가해주기로 했다.
이 역시 Bootstrap을 활용한다.

frontend/src/components/ 경로에 ProgressSpinner.vue를 생성하고 아래와 같이 입력한다.

<template functional>
    <div class="progress-spinner">
        <BSpinner class="progress-spinner-icon" />
    </div>
</template>

<script>
export default {
    name: 'ProgressSpinner',
    functional: true
}
</script>

<style lang="scss" scoped>
.progress-spinner {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    cursor: progress;
    z-index: 777;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: rgba(0, 0, 0, 0.3);
}
</style>

frontend/src/components/ProgressSpinner.vue

단순히 Spinner가 화면 가운데서 돌아가는 컴포넌트이다.
UI 확인을 위해 SideBar.vue 컴포넌트에 import 해와서 띄워보겠다.

<template>
    <div class="side-bar-wrapper">
    // ...
        <ProgressSpinner />
    </div>
</template>

<script>
    // ...
    import ProgressSpinner from '@/components/ProgressSpinner.vue'

    export default {
        name: 'SideBar',
        components: {
            ProgressSpinner,
            VueResizable
        },
        // ...
    }
</script>

frontend/src/components/SideBar.vue

빙글빙글 가운데서 잘 도는 것을 확인했다!🎉
z-index를 크게 잡아 SideBarr.vue 화면보다 스피너 화면이 위에 위치하고 있어 마우스 클릭도 되지 않는 걸 확인했다.

잠시 스피너 구현 코드에 대해 살펴보자.
template 부분과 data 부분에 functional이란 걸 선언해줬다.
이는 Vue2 의 함수형 컴포넌트를 의미한다.

🍥 함수형 컴포넌트

함수형 컴포넌트란,
상태(data)와 인스턴스가 존재하지 않는(this 컨텍스트가 없음) 컴포넌트를 말한다.
ProgressSpinner.vue 코드에도 data가 없고, this 컨텍스트가 사용되지 않았다.

🍥 함수형 컴포넌트는 왜 사용하는가?

일반 컴포넌트에 비해 가볍고 성능이 좋아 렌더링이 빨리된다.
딱히 메서드나 데이터가 필요 없는 컴포넌트는 함수형 컴포넌트로 선언해 성능을 높일 수 있다.
ProgressSpinner.vue 역시 메소드나 데이터가 딱히 필요없는 컴포넌트였기 때문에 함수형으로 선언해줬다.

🍥 함수형 컴포넌트 구현방법

ProgressSpinner.vue 처럼
templatefunctional을 붙이고,
functional: true 속성을 사용하면 된다.

💡참고
Vue 3의 함수형 컴포넌트는 Vue2의 선언법과 다르다.
Vue 3에선 함수형 컴포넌트를 일반 함수로만 만들 수 있다.
자세한 건 공식 문서 참고

 

이제, API가 동작할 때만 ProgreeSpinner가 돌도록 구현해보자.

<template>
    // ...
    <ProgressSpinner v-if="processingCount > 0" />
    // ...
</template>
<script>
// ...
    data() {
        return {
            // ...
            processingCount: 0
        }
    },
    methods: {
        // ...
        async saveReview () {
            this.processingCount++;
            try {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await this.$bvModal.msgBoxOk('저장 완료되었습니다.', {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'success',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
            } catch (e) {
                console.log(e.message);
                await this.$bvModal.msgBoxOk(e.message, {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'danger',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
                return await Promise.reject(e);
            } finally {
                this.processingCount--;
            }
        }
    }
</script>

frontend/src/components/SideBar.vue

data()processingCount를 선언하고, api 가 돌기 전에 수를 증가시키고, 완료되고 난 후엔 감소시켰다.
그리고, v-ifprocessingCount 가 0이상일 떄 ProgreeeSpinner가 화면에 보이게 하여
API가 동작할 떄만 스피너가 돌도록 했다.

추가로, API가 완료되었을 때 저장이 완료되었다는 Bootstrap 확인 팝업과 에러 상황일 때 에러를 UI에서 인지할 수 있도록 에러 팝업을 추가해줬다.

적절하게 스피너와 팝업이 뜨는 것을 확인할 수 있었다! 🎉

🍜 리뷰 가져오기 API 구현

저장 API 와 같은 방법으로 리뷰를 가져오는 API를 구현볼 것이다.
아직 가져온 데이터를 어떤식으로 UI 에 출력해야할지 몰라서, 단순히 API 만 구현해본다.

🍥 Controller

@RestController
@RequestMapping("/api/review")
public class ReviewCtrl {

    // ...

    @GetMapping("/getReviews")
    public List<ReviewDTO> getReviews() { return reviewDAO.getReviews(); }
}

src/main/java/com/map/restaurant/good/ReviewCtrl.java

🍥 DAO

public interface ReviewDAO {
    // ...
    List<ReviewDTO> getReviews();
}

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

🍥 Mapper

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.ReviewDAO">

    <!-- ... -->

    <select id="getReviews" resultType="hashMap">
        select
            *
        from
             tbl_review
    </select>
</mapper>

src/main/resources/mappers/ReviewMapper.xml

간단히 설명하자면 tbl_review 테이블 내 모든 데이터를 가져오게하는 것이다.
resultTypehasMap으로 설정하면 list에 여러 hashMap이 담겨 오게 된다.
GET Method로 가져오게 했다.

UI가 구현되어 있지 않아 PostMan으로 확인해봤다.

/api/review/getReviews

모든 리뷰를 잘 가져오는 걸 확인할 수 있었다! 🎉

🍜 리뷰 수정 API 구현

리뷰 추가 시에 addReviewinsertReview 라고 메소드를 짓지 않고,
saveReview로 지어준 이유는 리뷰 수정을 위해서 였다.

나는 따로 리뷰수정 API를 구현하지 않고,
saveReview API를 수정해서 리뷰 수정을 할 수 있도록 해줬다.
(이유는 on duplicate key를 써보고 싶었고, 수정 API를 구현하기 귀찮았기 때문이다ㅎㅎ;)

🍥 Controller

    @PostMapping("/saveReview")
    public void saveReview(@RequestBody ReviewDTO reviewDTO) {
        String id = reviewDTO.getId();
        if (id == null) {
            String uuidStr = UUID.randomUUID().toString();
            reviewDTO.setId(uuidStr);
        }
        reviewDAO.saveReview(reviewDTO);
    }

src/main/java/com/map/restaurant/good/ReviewCtrl.java

saveReview를 위와 같이 수정했다.
넘겨 받은 값에 id가 없으면 생성해주고, id가 있으면 그대로 DAO로 넘긴다.

🍥 Mapper

    <insert id="saveReview">
        insert into tbl_review (id, title, address, review, grade)
        values (#{id}, #{title}, #{address}, #{review}, #{grade})
        on duplicate key
        update
            title = #{title},
            address = #{address},
            review = #{review},
            grade = #{grade}
    </insert>

src/main/resources/mappers/ReviewMapper.xml

saveReview Mapper 역시 수정한다.
중복되는 key가 존재하면, 해당 키의 row 값을 담아온 값으로 수정한다.

수정 기능 역시 UI가 구현되어 있지 않아 Postman으로 확인해보았다.
동일한 Id에 값만 다르게 해서 돌려보았다.

수정 전 tbl_review
DB에 저장된 id 값과 함께 /api/review/saveReview
수정 후 tbl_review

값이 추가되거나 하지 않고 올바르게 수정된 걸 확인할 수 있었다!🎉

🍜 리뷰 삭제 API 구현

🍥 Controller

    @DeleteMapping("/deleteReview")
    public void deleteReview(@RequestParam(value = "id", required = true) UUID id) {
        reviewDAO.deleteReview(id);
    }

src/main/java/com/map/restaurant/good/ReviewCtrl.java

HTTP DELETE METHOD(@DeleteMapping)으로 구현해줬다.
id값을 받고 넘기면 삭제 기능을 하도록 했다.

잠깐 TMI 를 풀어보자면,
내가 일하고 있는 팀에선 HTTP DELETE METHOD를 사용하지 않는다.
팀장님의 지침 때문이다.

왜 사용하지 않냐고 여쭤봤었는데, 두 번 생각하는게 불편하다고 답변해주셨다.
GETPOST 만으로도 충분히 원하는 로직을 구현할 수 있고,
API 명으로도 충분히 어떤 기능을 할 수 있는지 나타낼 수 있기 떄문에 굳이 쓰지 않고 싶다고 하셨다.

스택 오버 플로우에 관련된 토론장이 펼쳐져 흥미롭게 읽었다.
팀장님처럼 요즘 DELETEPUT이 유용하지 않다는 사람도 있었고,
RESTFUL 서비스를 위해선 DELETEPUT을 사용하는 게 좋다고 하는 사람도 있다.

팀 영향으로 내 API 코드들이 완전히 RESTFUL 하진 않은 같지만,
자주 써보지 않은 걸 구현해보고 싶어서 나는 DELETE를 쓰기로 했다 ㅎㅎ

🍥 DAO

public interface ReviewDAO {
    // ...
    void deleteReview(@Param("id") String id);
}

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

🍥 Mapper

    <delete id="deleteReview">
        delete
        from tbl_review
        where id = #{id}
    </delete>

src/main/resources/mappers/ReviewMapper.xml

이 역시 PostMan으로 확인해봤더니,
id 값에 해당하는 데이터가 삭제된 것을 확인할 수 있었다!


이번 포스팅에선 CRUD API를 구현해보았다.

다음 포스팅에선 불러온 값들을 UI에 그려보도록하겠다!

댓글, 하트, 피드백은 언제나 환영입니다🥰

 

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

 

지난 포스팅에선 글꼴, 아이콘 등 구체적인 UI를 구현했었다.
이번 포스팅에선 본격적으로 기능을 구현해볼 것이다.
지도를 클릭했을 때 주소를 입력 받을 수 있게하거나
주소를 입력하면 지도에 위치가 표시되도록 하는 기능을 구현할 것이다.

 

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 지도 클릭 이벤트

Openlayers는 지도 클릭 이벤트를 지원한다.
지난 포스팅에서 만들었던 olMap에 클릭 이벤트 기능을 추가해주는 방식으로 구현 가능하다.

 

일단 본격적인 기능 구현에 앞서, 지도에서 클릭한 부분의 위도, 경도를 받아와지는지 확인하자.

olMap 을 생성해준 mounted 부분에, 아래 코드를 추가해보자.

// ...
import {fromLonLat, toLonLat} from 'ol/proj.js'
// ...

mounted() {
    this.olMap = new OlMap({
        // ...
    })

    this.olMap.on('click', (e) => {
      console.log(toLonLat(e.coordinate));
    })
  }

MainMap.vue

 

Openlayers 의 클릭 이벤트는 coordinate 라는 것을 제공한다.
이는 직역하면 좌표계 라는 뜻이다.
ol/proj.js엔 이 제공받은 좌표를 위도 경도로 변환시키는 메소드가 존재한다. (toLonLat)
지난 시간에 사용했던 fromLonLat 과 같은 방법으로 impoort 해서 사용하자.

💡 참고
fromLonLat : 위도, 경도 => 좌표계
toLonLat : 좌표계 => 위도, 경도

 

이제 지도를 클릭하고 콘솔을 확인해보면 위도, 경도 값을 담은 배열이 콘솔에 찍히는 것을 확인할 수 있다.

지도 클릭 후 콘솔 결과

🍜 위도, 경도 정보로 주소 가져오기

이제 이 위도, 경도를 주소로 변환시킬 것이다.
주소 변환 API는 네이버, 카카오, 구글, 행정안전부 등 여러 곳에서 제공하지만
KEY를 받아 사용해야한다는 귀찮음이 있어서 나는 Nominatim이라는 오픈소스를 활용하기로 했다.

Nominatim은 OpenStreeMap 데이터를 사용하여 주소를 가져온다.

💡 참고
지오코딩 (geocoding) : 주소 -> 위도, 경도
리버스 지오코딩 (reverse geocoding) : 위도, 경도 -> 주소

 

Nominatim이 제대로 작동하는지 확인해보기 위해 나는 Postman을 사용해봤다.

API 사용 결과

http://nominatim.openstreetmap.org/reverse 온라인 GET API에 format과 아까 콘솔로 본 위도 경도를 lon, lat params 로 담아 보내보았다.
format의 값으로 json을 입력했더니, 결과가 json으로 온 것을 확인할 수 있었다.
대충 아무 곳이나 찍었었는데 치킨 집이 나왔다...

아무튼 무사히 리버스 지오코딩이 된 것을 확인할 수 있었다! 🎉

 

이제 본격적으로 화면에 주소를 출력시켜보자.

API통신을 위해서 Axios 를 사용해 줄 것이다.
Axios는 브라우저와 node.js에서 사용할 수 있는 Promise 기반 HTTP 클라이언트 라이브러리이다.

나는 npm으로 설치해줬다.

npm install axios

설치 후, package.json에 axios가 생긴 걸 확인할 수 있었다.

"dependencies": {
    // ...
    "axios": "^0.26.1"
    // ...
}

 

package.json

 

methods 내에 getAddress()라는 메소드를 파고 아래와 같이 입력해준다.

import axios from 'axios'

// ...
methods: {
    getAddress (lon, lat) {
      return axios.get(
          'https://nominatim.openstreetmap.org/reverse',
          {
            params: {
              format: 'json',
              lon: lon,
              lat: lat
            }
          })
    }
  }

MainMap.vue

 

axiosget 메소드에 api url과 파라미터(format, lon, lat)를 담아 전송한다.
결과 값은 Postman에서 확인한 값과 같을 것이다.

🍜 받아온 주소 UI 출력

이제 getAddress를 통해 받아온 주소를 사이드바에 뿌려주는 일을 할 것이다.

 

주소가 출력된 부분

사이드바에서 주소가 표시되었으면 하는 부분은 바로 이 부분이다.


SideBar.vuedata()address를 추가하고 출력하고자하는 Inputv-model을 추가해줬다.
adress에 값이 들어가면 Input에 출력될 것이다.

// ....

<div class="location-info-area">
    <FontAwesomeIcon icon="location-dot" />
      <BInput
          placeholder="위치 정보 직접 입력하기"
          v-model="address"
    />
</div>

// ...
data() {
    return {
      // ...
      address: undefined,
    }
  },

SideBar.vue

 

본격적으로 UI 에 표현하기에 앞서, 짚고 넘어가야할 포인트가 있다.
바로, 주소 값을 가지고 있는 지도 컴포넌트와 사이드바 컴포넌트가 서로 형제 컴포넌트라는 점이다.

<div id="app">
    <MainMap/>
    <SideBar class="side-bar"/>
  </div>

App.vue

 

Vue 컴포넌트 간 데이터 공유 방법

부모 자식간의 컴포넌트는 $emit, $refs 등으로 데이터를 주고 받을 수 있지만,
형제 컴포넌트 들은 바로 전달하지 못하고 eventBus$root 등을 사용해야한다.

때문에 나는 $root.$refs를 사용했다.

 

먼저, 데이터를 보내고 싶은 곳(목적지) 컴포넌트를 $root.$refs에 등록한다.
쉽게 설명하면, $root 라는 최상위 컴포넌트에 $refs 로 목적지 컴포넌트를 등록하여
어떤 컴포넌트든 해당 $refs 컴포넌트 값이나 메소드에 접근할 수 있도록 하는 것이다.

 

나의 목적지 컴포넌트는 SideBar.vue 이기 때문에 created 부분에서 아래와 같이 등록해줬다.

created() {
    this.$root.$refs.sideBar = this;
  },

SideBar.vue

 

이제 sideBar라는 이름의 $refs 컴포넌트에 $root를 통해 누구나 접근할 수 있게 되었다.
바로 아래처럼 말이다.

this.olMap.on('click', async (e) => {
      const lonLatArr = toLonLat(e.coordinate)
      const lon = lonLatArr[0]
      const lat = lonLatArr[1]
      const addressInfo = await that.getAddress(lon, lat)
      this.$root.$refs.sideBar.address = addressInfo.data.display_name.split(", ").reverse().join(" ");
    })

MainMap.vue

 

getAddress로 받아온 결과값의 data.display_name은 콤마로 구분된 문자열이다.
그래서 콤마로 split을 해줬고, 작은 범위부터 시작되던 결과 값을 한국 주소에 맞춰 reverse 시켜줬다.
그리고 공백으로 join!

 

이제 지도를 클릭하면 해당 주소가 사이드바에 표시된다!

 

🍜 주소로 위도, 경도 정보 가져오기

이젠 반대로 주소로 위도, 경도 정보를 가져와 UI에 표시해볼 것이다.

 

찾아보니 행안부에서 제공하는 도로명 주소 API가 있었지만, 이 역시 KEY를 발급받아야하기 때문에 귀찮아서 다른 방법을 찾아보았다.
Openlyaers의 확장 라이브러리인 ol-geocoder를 쓰기로 했다.
이 라이브러리를 추가하면 Openlyaers에 검색 input 창이 만들어지게 되고, 이것으로 주소를 검색하고 그에 대한 정보를 얻을 수 있다.

사용법은 README 에 아주 잘 나와 있었다.

 

먼저 npm으로 install 한다.

npm install ol-geocoder

그리고 바로 다른 ol 라이브러리 처럼 사용 가능하다.
나는 olMap 을 선언했을 때와 같이 mounted 부분에 아래 코드를 입력해줬다. (+ css)

// ...
import Geocoder from 'ol-geocoder'
// ...

mounted() {
// ...
  const geocoder = new Geocoder('nominatim', {
      provider: 'osm',
      lang: 'kr',
      placeholder: '주소 검색',
      limit: 5, // 자동 완성 결과 최대 개수
      autoComplete: true,
      keepOpen: true
    });
    this.olMap.addControl(geocoder);

    geocoder.on('addresschosen', (evt) => {
      that.setUiAddress(evt.address.details.name);
    });
// ...

methods: {
// ... 
   setUiAddress(str) {
      this.$root.$refs.sideBar.address = str.split(', ').reverse().join(' ');
   },
// ...
}

// ...
<style lang="scss" scoped>
.main-map {
// ...
  ::v-deep.ol-geocoder {
    position: absolute;
    right: 0;
    padding: 10px;

    button {
      display: none;
    }

    input::placeholder {
      color: white;
      opacity: 0.7;
    }

    input, ul {
      border-style: none;
      width: 200px;
      background-color: rgba(0, 0, 0, 0.5);
      border-radius: 5px;
      border-color: unset;
      padding: 0 5px;
      color: white;
    }

    ul {
      margin-top: 5px;
      padding: 0;
      list-style: none;

      li:hover {
        background-color: rgba(0, 0, 0, 0.3);
      }

      li {
        padding: 5px 10px;
        font-size: 13px;

        a {
          text-decoration: none;

          .gcd-road {
            color: white;
          }
        }
      }
    }
  }
// ...
}
</style>

MainMap.vue

 

new Geocoderinstall 해온 ol-geocoder를 생성한다.

여기서 nominatim은 라틴어로 '이름'이라는 뜻이고,
osm 자료의 이름과 주소를 검색하고, 위경도 좌표로 부터 주소를 생성하는 도구를 말한다.


그 뒤로, 옵션을 설정해줄 수 있다. 더 많은 옵션은 여기서 확인 가능하다.

 

생성해준 geocoder 를 지난 포스팅에서 만들어 준 this.olMapaddControl 해준다.
addControl은 Openlayers 에서 제공하는 메소드로, 커스텀 컨트롤(이벤트)를 주입할 수 있게 한다.

 

다음으로 주소가 선택되었을 때 발생하는 이벤트를 설정해준다. (이는 geocoder에서 제공하는 기능이다.)
나는 사이드바의 주소 입력란에 해당 주소가 포맷되어 보여지도록 설정해줬다.
앞서 구현했던 코드와 같은 내용이어서, 메소드화 해줬다. (setUiAddress)

검색 결과가 사이드바 주소 입력란에 표시되는 것을 볼 수 있다.

성공적으로 사이드바에 주소가 뜨는 것을 확인할 수 있었다!🎉

 

다만, 이 라이브러리의 단점은...

지도에 표시되는 주소만 검색이 가능하다는 것이다. 한 건물 내에 많은 음식점이 있지만,

지도는 간추려서 대표되는 몇군데의 지도만 표시하고 있다.

우편번호 등 상세 주소로 검색 자체는 가능하겠지만, 가게 명으로 검색을 하는 데엔 약간의 한계가 있었다.

이럴 땐 어쩔 수 없이 근처에서 내가 위치를 클릭해서 입력하는 수 밖에 없겠다 😂

🍜 지도 클릭 위치 표시

주소가 출력되는 건 좋지만, 뭔가 지도 클릭한 곳도 UI 표시되는 게 좋을 것 같아서
클릭한 곳에 아이콘을 추가해주기로 했다.

 

아이콘을 추가하기 위해선 OpenLayers의 Feature, Source, Layer, Projection 에 대해서 알아야한다.

이전 포스팅에서 Layer는 화면에 종이 한장을 얹는 느낌이라고 말했었다.
그리고 그 종이엔 Tile 형태의 OSM 지도가 그려져 있다.

 

우린 아이콘을 위한 Layer를 한 장 더 얹을 것이다.
일러스트를 그려본 사람들은 알 것이다. 레이어를 나누지 않고 한 레이어에 그림을 냅다 그리면 대참사가 난다는 것을...
아무튼 이건 단순 비유였고 ㅋㅋ

 

Source에 대해서도 알아보자.

SourceLayer의 알맹이라고 생각하면 되겠다.
LayerSource로 구성되어 있다.

Map을 선언해준 부분을 보면 layersource로 이루어진 것을 확인할 수 있다.

 this.olMap = new OlMap({
      // ...
      layers: [
          new OlLayerTile({
            source: new OSM()
          })
      ],
      // ...
    })

MainMap.vue

 

그리고 이 SourceFeature로 구성되어 있다.

Feature는 쉽게 말하면 지도위에 그려지는 요소들이다.
동그라미가 될수도, 선이 될 수도, 아이콘이 될 수도 있다.

 

범위를 정리하자면 이런식으로 되겠다.

Feature < Source < Layer

 

이제 마지막으로 Projection에 대해 알아보자.
Prjoection은 해석하면 지도 투영법 이라는 뜻이다.


즉, 위선과 경선으로 이우러진 지구상의 가상적 좌표를 평면상에 옯기는 방법을 가리킨다.

지구는 구체이기 때문에, 아무리 작은 공간의 지도를 작성한다 할 지라도 그 왜곡을 피할수 없다.
따라서 투영법은 이 왜곡을 처리하는 방법이라고 정의할 수 있다.

 

Projection 은 좌표(coordinate)를 어떤 식으로 표현하느냐에 따라 종류를 나눌 수 있다.
대표적으로 EPSG:4326, EPSG:3857, EPSG:5179 등이 있다.

 

이 전 포스팅에서 지도의 첫 화면을 경기도 성남시로 맞춰뒀었는데,
그때 사용한 좌표가 [127.1388684, 37.4449168] 이었다.
이는 EPSG:4326 표현 방식이다.

 

그런데 OSM 지도는 EPSG:3857로 표현된다.
따라서 EPSG:4326 좌표계를 EPSG:3857 방식으로 수정해야한다.


이를 OpenLayers api transform, toLonLat, fromLonLat로 제공하고 있다.
toLonLat, fromLonLat 은 default 반환값이 EPSG:3857 방식이기 때문에 우린 이를 사용해줄 것이다.

 

자, 이제 본격적으로 지도를 클릭했을 때 아이콘을 그려보자.
아이콘을 그려줄 layer를 추가해 줄 것이다.

olMap 선언 부분을 아래와 같이 수정해주자.

import OlVectorSource from 'ol/source/Vector.js'
import OlVectorLayer from 'ol/layer/Vector.js'

const EPSG_3857 = 'EPSG:3857';

//...

    mounted() {
        const vectorSource = new OlVectorSource(EPSG_3857);
        const vectorLayer = new OlVectorLayer({
            source: vectorSource
        })
        this.olMap = new OlMap({
            target: this.$refs.map,
            controls: defaults({
                attribution: false,
                zoom: false,
                rotate: false,
            }),
            layers: [
                new OlLayerTile({
                    source: new OSM()
                }),
                vectorLayer
            ],
            view: new OlView({
                center: fromLonLat([127.1388684, 37.4449168]), // 경기도 성남
                zoom: 10,
                projection: EPSG_3857 // 생략 가능
            })
        })
    }

MainMap.vue

 

layersvectorLayer를 추가해주었다.
vectorLayervectorSource로 이루어진 OlVectorLayer이다.
그리고 이 vectorSource는 'EPSG:3857' 방식으로 이루어져있다.

 

요약하면, 'EPSG:3857' 방식의 알맹이(source)로 이루어진 layer 를 추가해준 것이다.
여기서 Vector라는 말이 뭔가 싶을 수도 있는데, 어렵게 생각할 필요 없이 작은 점이 모여 그려진 화면(또는 화면을 그리는 방식)이라고 생각하면 된다.

 

이제 추가한 layer에 아이콘을 그려볼 것이다.
지도 클릭 이벤트를 아래와 같이 수정해주자.

    // ...
    mounted() {
            this.olMap.on('click', async (e) => {
            await addUiAddress();
            drawMapIcon();

            async function addUiAddress() {
                const lonLatArr = toLonLat(e.coordinate)
                const lon = lonLatArr[0]
                const lat = lonLatArr[1]
                const addressInfo = await that.getAddress(lon, lat)
                that.setUiAddress(addressInfo.data.display_name);
            }

            function drawMapIcon() {
                vectorSource.clear();
                geocoder.getSource().clear();
                const feature = new OlFeature({
                    geometry: new OlPoint(e.coordinate)
                })
                feature.setStyle(new OlStyle({
                    image: new OlIcon({
                        scale: 0.7,
                        src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
                    })
                }))
                vectorSource.addFeature(feature);    
            }
        })
    }

addUiAddress() 부분은 위에서 구현 했던 내용으고,
drawMapIcon() 부분을 추가해 줬다.
(기능별로 function으로 묶어 코드를 정리해줬다.)

 

drawMapIcon() 을 살펴보자.

vectorSource.clear();
geocoder.getSource().clear();

클릭했을 떄 기존에 그려진 아이콘을 제거하는 로직이다.
vectorSource에 그려진 아이콘을 clear하고,
주소 검색 라이브러리의 source (geocoder.getSource()) 에 그려진 아이콘을 clear 해준다.

import OlFeature from 'ol/Feature.js';
import OlPoint from 'ol/geom/Point';
import OlStyle from 'ol/style/Style.js'
import OlIcon from 'ol/style/Icon.js'

// ...

const feature = new OlFeature({
    geometry: new OlPoint(e.coordinate)
})
feature.setStyle(new OlStyle({
        image: new OlIcon({
        scale: 0.7,
        src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
    })
}))
vectorSource.addFeature(feature);

Feature를 선언 부분이다.
geometry는 직역하면 '상대적 위치'라는 뜻인데, Feature가 그려질 위치를 말한다.
이는 클릭이벤트에서 받아온 좌표계(e.coordinate)로 넣어주면 된다.

 

다음으로 Feature의 스타일을 입혀준다.
나는 이미지로 아이콘을 그려줬다.
이미지는 Geocoder 라이브러리에서 사용된 아이콘을 그대로 가져왔다.

 

마지막으로, sourceaddFeature를 해 주면
VectorLayerFeature가 그려지게 된다

 

 

성공적으로 아이콘이 그려지는 걸 확인할 수 있었다!🎉


이번 포스팅에선 클릭이벤트 또는 검색을 통해 주소를 입력받는 기능을 구현해보았다.

다음 포스팅에선 사진을 업로드하고 저장하는 기능을 구현해보겠다.

(드디어 백엔드!)

 

댓글, 하트, 피드백은 언제나 환영입니다!😇

📢 들어가며

그동안 게을러져서 포스팅이 살짝 늦었다... 다시 화이팅해보자!

 

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 화면에 지도를 띄우고 대략적인 UI 틀을 잡았었다.
이번 포스팅에선 구체적인 UI 를 구현해볼 것이다.

 

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 UI 설계

이번에도 대충 그려 설계를 해보았다.
지도에서 특정 위치를 선택하고 나면 좌측 사이드바가 활성화되고 위 이미지와 같은 정보를 입력할 수 있게 된다.

사이드 바에선 음식점의 이름, 이미지, 위치정보, 별점, 평가를 보여준다.

🍜 Bootstrap 설치

본격적인 UI 구현을 위해서 Bootstrap을 사용할 것이다.
Bootstrap 을 설치해보자.

vue add bootstrap-vue

Vue CLI3로 설치를 했기 때문에 Vue CLI 플러그인을 활용해 Bootstrap을 설치해주는 것이다.
별다른 설정없이 bootstrap을 주입할 수 있다.

🍜 사이드바 UI 구현

이제 버튼으로 사이드바를 펼쳤다 접었다하고,
접혀진 상태에서 지도를 클릭했을 때 사이드바가 활성화 되도록 구현해 볼 것이다.

Bootstrap 을 설치하고 나면 <BButton> 같은 Bootstrap에서 제공하는 Components들을 쓸 수 있다.
사이드 바 옆에 사이드바를 활성화할 수 있는 버튼을 만들어 보자.

<template>
  <div class="side-bar-wrapper">
    <VueResizable
        class="resizable-side-bar"
        :width="500"
        :min-width="500"
        :max-width="Infinity"
        :active="['r']"
        v-if="isVisibleSideBar"
    >
      <div class="side-bar">
      </div>
    </VueResizable>
    <BButton
        class="side-bar-active-btn"
        size="sm"
        @click="showSideBar"
    >
      {{ isVisibleSideBar ? '닫힘' : '열림' }}
    </BButton>
  </div>
</template>

<script>
import VueResizable from 'vue-resizable';

export default {
  name: 'SideBar',
  components: {
    VueResizable
  },
  data() {
    return {
      isVisibleSideBar: true
    }
  },
  methods: {
    showSideBar() {
      this.isVisibleSideBar = !this.isVisibleSideBar;
    }
  }
}
</script>

<style lang="scss" scoped>
.side-bar-wrapper {
  display: flex;

  > .resizable-side-bar {
    > .side-bar {
      background-color: rgba(0, 0, 0, 0.5);
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
    }
  }

  > .side-bar-active-btn {
    flex-shrink: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: #000000;
    padding: 0;
    border: none;
    border-radius: unset;
    color: #fff;
    opacity: 0.5;
    width: 40px;
    height: 40px;
  }
}
</style>

frontend/src/components/SideBar.vue

결과화면

버튼 위치를 잡기 위해 flex 를 쓰고, Side Bar와 똑같이 opacity를 줘서 구현했다.
showSideBar() 메소드로 사이드바가 열고 닫힐 수 있도록 하였고, 열고 닫을 때 버튼의 글씨가 바뀌게 했다.

 

이제 사이드바 내 UI 를 구현해보자

<template>
  <div class="side-bar-wrapper">
    <VueResizable
        class="resizable-side-bar"
        :width="500"
        :min-width="500"
        :max-width="Infinity"
        :active="['r']"
        v-if="isVisibleSideBar"
    >
      <div class="side-bar">
        <div class="title-area">
          <BInput placeholder="맛집 이름을 입력해주세요."/>
        </div>
        <div class="image-area">
          <div class="iw-file-input">
            사진을 업로드 해주세요
          </div>
        </div>
        <div class="location-info-area">
          <BInput placeholder="위치 정보 직접 입력하기"/>
        </div>
        <div class="rate-area">
          <BFormRating />
        </div>
        <div class="review-area">
          <BFormTextarea
              ref="textarea"
              placeholder="후기를 입력해주세요."
          />
        </div>
      </div>
    </VueResizable>
    <BButton
        class="side-bar-active-btn"
        size="sm"
        @click="showSideBar"
    >
      {{ isVisibleSideBar ? '닫힘' : '열림' }}
    </BButton>
  </div>
</template>

<script>
import VueResizable from 'vue-resizable';

export default {
  name: 'SideBar',
  components: {
    VueResizable
  },
  data() {
    return {
      isVisibleSideBar: true
    }
  },
  methods: {
    showSideBar() {
      this.isVisibleSideBar = !this.isVisibleSideBar;
    }
  }
}
</script>

<style lang="scss" scoped>
.side-bar-wrapper {
  display: flex;
  color: #fff;

  > .resizable-side-bar {
    > .side-bar {
      background-color: rgba(0, 0, 0, 0.5);
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      padding: 10px;

      > .title-area {
        padding: 20px 10px;

        input, input::placeholder, input:focus {
          font-size: 2rem;
          font-weight: bold;
          color: #fff;
          box-shadow: none;
          background: none;
          border: none;
        }
      }

      > .image-area {
        padding: 0 10px;

        > .iw-file-input {
          display: flex;
          justify-content: center;
          align-items: center;
          font-size: 1.3rem;
          border: 5px dashed rgb(255, 255, 255, 0.5);
          border-radius: 10px;
          height: 250px;
          background-color: rgb(255, 255, 255, 0.5);
        }
      }

      > .location-info-area {
        padding: 10px;

        input, input::placeholder, input:focus {
          font-size: 1rem;
          color: #fff;
          box-shadow: none;
          background: none;
          border: none;
        }
      }

      > .rate-area {
        padding: 0 20px;
        text-align: center;

        output {
          font-size: 2rem;
          color: #ffdd00;
          background: none;
          border: none;
          box-shadow: none;
        }
      }

      > .review-area {
        padding: 20px 10px;

        textarea, textarea::placeholder {
          min-height: 300px;
          resize: none;
          color: #fff;
          background: none;
          border: none;
          box-shadow: none;
        }

        /* width */
        ::-webkit-scrollbar {
          width: 10px;
        }

        /* Track */
        ::-webkit-scrollbar-track {
          background: #f1f1f1;
        }

        /* Handle */
        ::-webkit-scrollbar-thumb {
          background: #888;
        }

        /* Handle on hover */
        ::-webkit-scrollbar-thumb:hover {
          background: #555;
        }
      }
    }
  }

  > .side-bar-active-btn {
    flex-shrink: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: #000000;
    padding: 0;
    border: none;
    border-radius: unset;
    color: #fff;
    opacity: 0.5;
    width: 40px;
    height: 40px;
  }
}
</style>

frontend/src/components/SideBar.vue

 

사이드바 UI 구현 결과

설계했던 이미지 대로 UI를 구현해보았다. 대부분 Bootstrap을 활용했다.
input 은 네모 박스가 아니라 글씨만 보이도록 구현했고,
파일 업로드 부분은 아직 잘 모르겠어서 일단 UI 틀만 잡아놨다.

 

UI 구현 부분은 딱히 설명할 것이 없어보이지만,
신경 쓴 부분에 대해서만 살짝 언급해보자면...

 

textarea에 스크롤이 생긴 모습

개인적으로 위 그림과 같은 스크롤바 디자인이 굉장히 보기 싫었다.
브라우저마다 스크롤바가 다르게 보이는 것도 맘에 들지 않아,

W3School 를 참고하여 스크롤바 디자인을 바꿔 주었다.

/* width */
::-webkit-scrollbar {
  width: 10px;
}

/* Track */
::-webkit-scrollbar-track {
  background: #f1f1f1;
}

/* Handle */
::-webkit-scrollbar-thumb {
  background: #888;
}

/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
  background: #555;
}

frontend/src/components/SideBar.vue

 

스크롤바 디자인을 바꿔준 모습

🍜 사이드바 UI 디테일 살리기

뭔가 지금 UI는 허전해보여서, 아이콘을 추가하고 글꼴을 바꿔주기로 했다.

🍬 Font Awesome 아이콘 추가

Bootstrap에서 Icon을 제공해주긴 하지만, 나는 Font Awesome Icon 이 좀 더 익숙해서 이를 사용하기로 했다.

설치

$ npm i @fortawesome/vue-fontawesome
$ npm i @fortawesome/fontawesome-svg-core
$ npm i @fortawesome/free-solid-svg-icons
  • @fortawesome/fontawesome-svg-core
    • Fontawesome의 SVG파일을 던져주는 역할을 한다.
    • 반드시 필요!
  • @fortawesome/vue-fontawesome
    • 던져준 SVG파일을 Vue에서 사용할 수 있게 해주게 한다.
    • 반드시 필요!
  • @fortawesome/free-solid-svg-icons
    • 아이콘 모음이라고 생각하면 된다.
    • 종류(무료)에는 solid, regular, brands 가 있다.
    • solid 는 색이 채워진? 두꺼운? 느낌의 아이콘 모음이다.

설치 후 package.jsondependecies를 보면 잘 설치된 것을 확인할 수 있다.

 

package.json

설치된 FontAwesome Icon을 관리하는 파일을 만들어줄 것이다.

src 디렉토리 아래에 common 폴더를 파고 Icons.js 파일을 생성하자.

import Vue from 'vue';

// 0. 편의를 위해 아이콘은 알파벳 순서대로 추가하자.
// 1. 설치했던 fontawesome-svg-core 와 vue-fontawesome
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';

// 2. 설치했던 아이콘 파일에서 원하는 아이콘 불러오기
import {
  faAngleLeft,
  faAngleRight,
  faLocationDot,
} from "@fortawesome/free-solid-svg-icons";

// 3. 불러온 아이콘을 라이브러리에 담기
library.add(faAngleLeft);
library.add(faAngleRight);
library.add(faLocationDot);

// 4. fontawesome 아이콘을 Vue 템플릿에서 사용할 수 있도록 등록
Vue.component("FontAwesomeIcon", FontAwesomeIcon);

frontend/src/common/Icons.js

 

생성한 Icons.js 를 전역에서 사용할 수 있도록 main.js 에 아래 코드를 추가하면 끝.

import '@/common/Icons.js';

frontend/src/main.js

 

이제 아이콘 사용할 일이 있으면 /src/common/Icons.js 에서 원하는 아이콘을 import 해오고, libraryadd 시켜주기만 하면된다.
Vue.component("FontAwesomeIcon", FontAwesomeIcon); 를 통해 Vue component로 등록해줬기 때문에 <FontawesomeIcon icon="location-dot" /> 과 같이 구현할 수 있다.

 

이제 아이콘을 적용해보자.
열림, 닫힘 버튼을 화살표 아이콘으로 변경하고,
위치 정보 입력란 앞쪽에 아이콘을 추가해줄 것이다.

// ...
    <div class="location-info-area">
      <FontAwesomeIcon icon="location-dot" />
      <BInput placeholder="위치 정보 직접 입력하기"/>
    </div>
// ...
    <BButton
        class="side-bar-active-btn"
        size="sm"
        @click="showSideBar"
    >
      <FontAwesomeIcon :icon="isVisibleSideBar ? 'angle-left' : 'angle-right'" />
    </BButton>
// ... 

// css
    > .location-info-area {
        padding: 10px 20px;
        display: flex;
        align-items: center;
    // ...
    }

frontend/src/components/SideBar.vue

 

아이콘 적용 후 결과

확실히 아이콘을 추가하는 편이 더 예뻐보인다!

🍬 Font 적용

Font를 적용하기 위한 방법에는 3가지가 있다

 

1. 로컬PC에 설치하기

2. font 파일들을 보관하여 불러오기

3. Web CDN

인터넷이 안되는 환경에서도 동작하기 위해, 그리고 로컬PC에 설치할 수 없는 경우를 대비하여 font 파일들을 따로 보관하여 불러오는 방식으로 진행했다.

 

src/assets 폴더 하위에 fonts 폴더를 파고 원하는 폰트를 다운로드하여 추가한다.

frontend/src/assets/fonts

나는 '나눔스퀘어'와 '나눔바른고딕'을 추가했다.

frontend/src/assets/scss/vendors/bootstrap-vue

frontend/src/assets/scss/vendors/bootstrap-vue 를 보면 _custom.scss 라는 파일이 보일 것이다.
이는 부트스트랩으로부터 추가된 커스텀 css 파일로, bootstrap 이 외의 css를 전역적으로 추가해주고 싶을 때 사용할 수 있다.
아래 코드를 추가하여 추가한 폰트를 사용할 수 있게 하자

//======================= CUSTOM ==================================
// 글꼴
@font-face {
  font-family: 'Nanum Square';
  src: url('~@/assets/fonts/NanumSquare_acEB.ttf') format('truetype');
}
@font-face {
  font-family: 'Nanum Barun Gothic';
  src: url('~@/assets/fonts/NanumBarunGothic.ttf') format('truetype');
}

frontend/src/assets/scss/vendors/bootstrap-vue/_custom.scss

 

'나눔스퀘어'는 제목부분의 폰트를 변경해주는 데에 사용할 것이고,

'나눔바른고딕'은 전역적으로 선언해줄 것이다.

// ...
    > .title-area {
        // ...

        input, input::placeholder, input:focus {
          font-family: 'Nanum Square', serif;
// ...
        }
    }

frontend/src/components/SideBar.vue

 

// ...
<style lang="scss">
@import "~@/assets/scss/vendors/bootstrap-vue/index";
#app {
// ...

frontend/src/App.vue

 

font 추가 후 결과 화면

글꼴이 제대로 적용된 것을 확인할 수 있었다!🎉

 


 

이번 포스팅에선 사이드 바의 UI를 구현해보았다.

다음 포스팅에선 이번 포스팅을 통해 알게된 _custom.scss 파일로 재사용가능한 기능을 구현해보고,
지도를 클릭하여 위치 정보를 입력할 수 있는 기능을 구현해보도록하겠다.

 

댓글, 하트, 피드백은 언제나 환영입니다!😇

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 개발 환경 설정을 했었다.
이번 포스팅에선 UI 틀을 잡고 OpenLayers를 활용해 지도를 띄워 볼 것이다.

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 UI 구성

대략적인 UI를 설계해보았다.

UI 틀 설계

  • 전체 화면에 꽉 차는 느낌으로 지도를 띄운다.
  • 좌측엔 맛집에 대한 정보를 기록/출력하는 사이드 바가 있다.
  • 사이드 바는 드래그로 크기를 늘렸다 키울 수 있다.
  • 사이드 바는 버튼으로 최소/최대화가 가능하다.
  • 사이드 바는 지도 위에 띄우고 Opacity(투명도)를 두어 지도 위에 띄운다는 느낌으로 구현한다.

사이드 바 안에 들어갈 구체적인 내용은 추후 생각해볼 예정이다.
원하는 대로 가능할지는 모르겠지만 대략적인 UI 틀은 이렇다.

🍜 OpenLayers 설치

OpenLayers웹 앱에 동적 지도를 띄우도록 해주는 라이브러리이다.

 

OpenLayers를 설치해보자.

CDN으로 설치해줄 수도 있고, npm 등으로 install 해줄 수 있다.
나는 npm을 활용했다.

 

CDN

<script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.9.0/build/ol.js"></script>

NPM

npm i ol

🍜 OpenLayers 지도 출력하기

지난 포스팅의 설정 상태 그대로라면 frontend/src/App.vue 라는 파일이 있을 것이다.
App.vue 파일은 가장 초기 화면이 되는 파일이다.
이는 main.js에 설정되어 있다.

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

App.vue 파일을 import 해온 뒤 렌더링 시키고, 파일 내의 id가 'app'인 element를 사용하겠다는 뜻이다.

 

App.vue 파일이 가득차게 지도를 띄울 것이다.

 

MainMap.vue 생성

frontend/src/components 폴더에 MainMap.vue 파일을 파주었다.
그리고 App.vue 파일에 MainMap.vueimport 해오고 아래와 같이 입력해주었다.

<template>
  <div id="app">
    <MainMap/>
  </div>
</template>

<script>
import MainMap from '@/components/MainMap'

export default {
  name: 'App',
  components: {
    MainMap
  }
}
</script>

<style>
#app {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}
</style>

frontend/src/App.vue

 

화면을 가득 차게 구현하고 싶기 때문에 positionabsolute로 주고 상하좌우를 모두 0으로 세팅했다.

 

이제 MainMap.vue를 구현하여 지도를 띄워보자.
OpenLayers 공식 문서가 굉장히 잘 되어 있었다.
공식문서를 따라 아래와 같이 입력해주었다.

<template>
  <div class="main-map" ref="map">
  </div>
</template>

<script>
import OlLayerTile from 'ol/layer/Tile.js';
import OlView from 'ol/View.js';
import OlMap from 'ol/Map.js';
import OSM from 'ol/source/OSM';
import {fromLonLat} from 'ol/proj.js'

export default {
  name: 'MainMap',
  data() {
    return {
      olMap: undefined,
    }
  },
  mounted() {
    this.olMap = new OlMap({
      target: this.$refs.map,
      layers: [
          new OlLayerTile({
            source: new OSM()
          })
      ],
      view: new OlView({
        center: fromLonLat([127.1388684, 37.4449168]), // 경기도 성남
        zoom: 10
      })
    })
  }

}
</script>

<style scoped>
.main-map {
  width: 100%;
  height: 100%;
}

</style>

frontend/src/components/MainMap.vue

 

먼저 화면 꽉 차게 지도를 띄우기 위해 widthheight를 모드 100%로 주었다.

 

olMap이라는 data()를 정의하고 OlMap을 생성해 저장한다.
지도와 관련된 모듈은 node_modules에 위치해있는 ol 폴더에 있는 기능 중 필요한 것을 import 해와서 사용했다.

 

OlMap을 생성하는 데에 필요한 옵션은 크게 target, layers, view 가 있다.
target은 이 지도를 띄울 element를 찾아 정의하는 것인데, 공식문서는 id를 활용했으나,
나는 Vue를 사용하기 때문에 ref를 활용했다.


💡 mounted 에 지도를 생성, 정의하는 이유
처음엔 created에 지도 생성 코드를 넣어 줬었는데, 아무 것도 뜨지 않았다.
위처럼 지도가 그려질 위치를 찾는 데에 ref를 사용했다.
ref는 하위 컴포넌트(여기선 <div ref="main-map">의 요소를 사용하기 위해 쓰는 Vue 속성이다.
때문에 하위 컴포넌트가 완전히 렌더링 된 후에 ref로 참조할 수 있어서,
created 에선 ref로 해당 target을 찾을 수 없었던 것이다.
때문에 지도 생성은 mounted에서 진행해야한다.


layers 는 말 그대로 레이어를 의미하는데, 화면에 종이 한장을 얹는다는 의미로 생각하면 되겠다.
이 종이엔 OlLayerTile라는 Tile 형태를 통해 생성된 지도가 그려지게 된다.
지도는 OSM(Open Street Map)이라는 오픈소스를 통해 그린다.

 

view는 사용자 화면에 보여질 위치를 지정하는 옵션이다.
나는 경기도 성남시의 위도 경도를 찾아 입력해줬다.

 

여기서 fromLonLat 은 위도, 경도를 좌표계로 변환시키는 Openlayers 의 api이다.

OpenLayer는 위도, 경도가 아닌 좌표계(coordinate)로 위치가 표현된다. 

default 좌표계 종류는 'EPSG:3857'이다.

 

'EPSG:3857'? 생소한 말처럼 들릴 수 있다 (내가 그랬음...)

해당 용어나 좌표계에 대한 설명은 주소를 검색/입력 받는 기능을 구현하는 다음 포스팅에서 좀 더 자세히 다뤄보겠다.

 

결과화면

정상적으로 성남시쪽 지도가 뜬 것을 확인할 수 있었다!🎉

그런데 좌측 위를 보면 보기 싫은..? 버튼들과 copyright 문구가 보인다.

나중에 추가하는 일이 있더라도, 지금은 이를 없애보자.

 

https://openlayers.org/en/latest/apidoc/module-ol_Map-Map.html

공식 new Map 속성 관련한 공식 문서를 보면, controls라는 옵션이 있는 걸 볼 수 있다.
해석해보면, controls라는 옵션을 따로 추가하지 않으면 defaults 가 사용된다는 것이다.
detaults 가 바로 화면에 보이는 못생긴 버튼과 copyright이다.

 

import 를 추가하고 defaults를 없애주기 위해 controls 옵션을 추가 시켜주자.

// ...
import {defaults} from 'ol/control.js';
// ...
    this.olMap = new OlMap({
      target: this.$refs.map,
      controls : defaults({
        attribution : false,
        zoom : false,
        rotate: false,
      }),
      layers: [
          new OlLayerTile({
            source: new OSM()
          })
      ],
      view: new OlView({
        center: fromLonLat([127.1388684, 37.4449168]), // 경기도 성남
        zoom: 11
      })
    })

//...

frontend/src/components/MainMap.vue

 

attribution, zoom, rotate는 각 detaults에 정의된 각 버튼 및 속성을 의미한다.
전부 false 로 주어 비활성화 시켜줬다.

 

결과화면

버튼과 copyright가 깔끔하게 없어진 것을 확인할 수 있었다.

🍜 사이드바 UI 틀 잡기

지도를 띄웠으니, 이제 설계했던 사이드바 UI를 구현해볼 것이다.

resizable하게 구현하기 전에, 먼저 대략 위치정도만 잡아보자.


frontend/src/componentsSideBar.vue 파일을 추가해주고
App.vue에 import 해주자.
그리고 css로 위치와 크기를 잡는다.

<template>
  <div id="app">
    <MainMap/>
    <SideBar class="side-bar"/>
  </div>
</template>

<script>
import MainMap from '@/components/MainMap'
import SideBar from '@/components/SideBar'

export default {
  name: 'App',
  components: {
    SideBar,
    MainMap
  }
}
</script>

<style lang="scss">
#app {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;

  > .side-bar {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
  }
}
</style>

frontend/src/App.vue

<template>
  <div class="side-bar-wrapper">
    <div class="side-bar">

    </div>
  </div>
</template>

<script>
export default {
  name: 'SideBar',
  data() {
    return {

    }
  },
  methods: {

  }
}
</script>

<style lang="scss" scoped>
.side-bar-wrapper {
  > .side-bar {
    background-color: #000000;
    opacity: 0.5;
    width: 500px;
    height: 100%;
  }
}

</style>

frontend/src/components/SideBar.vue

 

결과화면

설계했던대로 opacity도 주고, 좌측에 위치 시켰다.
지금은 사이드 바의 width가 500px이지만, 이것은 resizable해야한다.
나는 이를 위해 vue-resizable 라이브러리를 사용하기로 했다.

 

vue-resizable 설치

npm i vue-resizable

vue-resiazble을 import 해 온 후, 컴포넌트화 한다.
컴포넌트 해 온 vue-resiazble 하위에 <div class="side-bar">를 넣어보자.

<template>
  <div class="side-bar-wrapper">
    <VueResizable
        class="resizable-side-bar"
        :width="500"
        :min-width="500"
        :max-width="Infinity"
        :active="['r']"
    >
      <div class="side-bar">

      </div>
    </VueResizable>
  </div>
</template>

<script>
import VueResizable from 'vue-resizable';

export default {
  name: 'SideBar',
  components: {
    VueResizable
  },
  data() {
    return {

    }
  },
  methods: {

  }
}
</script>

<style lang="scss" scoped>
.side-bar-wrapper {
  > .resizable-side-bar {
    > .side-bar {
      background-color: #000000;
      opacity: 0.5;
      width: 100%;
      height: 100%;
    }
  }
}
</style>

VueResizble의 옵션엔 여러가지가 있는데 나는 그 중 width, min-width, max-width, active를 추가해줬다.
width, min-width, max-width는 보기만 해도 무슨 뜻인지 짐작이 갈 것이다.
active=['r']r. 즉, 오른쪽(right)의 resize화를 활성화한다는 뜻이다.

 

결과화면

정상적으로 Resize되는 것을 확인할 수 있었다!🎉

 

🚨 참고

TypeError: (0 , i.openBlock) is not a function 에러가 뜨면서 vue-resizable 이 적용이 되지 않는다면 

vue-resizable 을 다운그레이드하면 된다. 

아직 공식적으로 해당 이슈 관련하여 vue-resizable 측의 답변이 달리진 않았으나,

vue2 와 vue-resizable 특정 버전이 서로 호환이 되지 않는 것으로 보인다. 

(관련 이슈 페이지)

 

현재 나는 vue는 v2.6.14, vue-resizable은 v2.0.5 버전을 사용 중인데, 에러 없이 잘 되고 있다.

vue 2 를 쓰고 있다면, vue-resizable v2.0.5, v1.3.2, v2.1.3 버전으로 install 해 보는 것을 추천한다!

 

참고로 어떤 버전이 존재하는 지 잘 모르겠다면,

아래 명령으로 역대 버전들을 확인할 수 있다.

npm info vue-resizable versions

 


이번 포스팅에선 OpenLayers로 지도를 띄우고 UI틀을 잡는 작업을 진행했다.
다음 포스팅에선 사이드바 내에 들어갈 UI를 구현해보겠다. (시간이 남으면 약간의 기능도...)


댓글/하트, 피드백은 언제나 환영입니다! 😇

📢 들어가며

이번 포스팅에선 맛집 지도를 개발해볼 것이다! 🤤
작성한 코드는 github에 업로드 해 두었다.

 

스펙은 다음과 같다.

  • IDE
    • Intellij IDEA Ultimate
  • FrontEnd Framework
    • Vue.js
  • BackEnd Framework
    • Spring Boot
  • DB
    • MySQL(MyBatis)
  • 주요 라이브러리
    • OepnLayers
    • Bootstrap
  • OS
    • Windows 10

🚨 참고

본 포스팅 프로젝트는 미완된 채 연재 마무리되었습니다. 

웬만한 주요 기능은 대충 구현되어 있으나 배포까진 다루지 않습니다. 

🍜 개발 환경 설정

본격적인 개발에 앞서, 개발 환경을 설정해보자.
(JAVA(v11), MYSQL(v8), Node.js(v14.4.0)은 설치되어 있는 상태에서 진행했다.)

Spring Boot 설치

가장 먼저 Spring Boot를 설치해야한다.

 

Intellij를 활용한 Spring Boot 설치는 이전 포스팅에서 다룬 적이 있으니 이 포스팅을 참고.
Nuxt.js와 Spring Boot를 연동 방법에 대한 포스팅이지만 Spring Boot 설치에 대해서도 다루고 있다.

 

Intellij를 사용하고 있지 않다면 스프링 공식 홈페이지를 통해 스프링을 설치할 수 있다.
공식 문서가 굉장히 잘 나와 있다. 👍

Vue.js 설치

Vue CLI를 활용하여 Vue.js 설치를 할 것이다.

💡 Vue CLI
Command Line Interface(CLI) 기반의 Vue 프로젝트 생성 도구
Vue 어플리케이션을 개발할 때 기본적인 폴더 구조, 라이브러리 등을 설정해준다.
자세히

 

Intellij 에서 Terminal(alt + f12)을 열고 아래와 같이 입력 후 실행해준다.

npm install -g @vue/cli

npm으로 Vue-cli를 global(-g)하게 설치한다는 뜻이다.
글로벌하게 Vue-cli를 설치해두면 이 프로젝트 외 다른 프로젝트에서도 Vue-cli를 사용할 수 있다.

 

설치가 제대로 완료 되었는지는 버전 확인으로 알 수 있다.
아래 명령어로 버전이 뜬다면 제대로 설치된 것이다.

vue --version

🚨 참고

vue 명령어가 아래 에러가 뜨면서 vue : 이 시스템에서 스크립트를 실행할 수 없으므로... 라는 에러가 뜨면서 실행되지 않는다면,

Windows PowerShell의 권한이 없기 때문인 것이다. 아래 방법으로 해결할 수 있다.

  1. Intellij 관리자 권한으로 실행하기
  2. Terminal에서 get-executionpolicy 명령어로 실행정책 확인하기(나는 "Restricted(제한된)"로 되어 있었다)
  3. RemoteSigned(Windows server 컴퓨터에 대 한 기본 실행 정책)로 되어 있지 않다면 set-executionpolicy remotesigned 명령어로 실행정책을 변경한다.
  4. 다시 get-executionpolicy 명령어로 형재 실행정책을 확인하고 RemoteSigned로 변경되어 있다면 다시 vue 명령어를 실행해 본다.

 


설치가 완료되고 나면 Vue 프로젝트를 만들어 줄 것이다.
Spring Boot로 만들어진 프로젝트는 백엔드 프로젝트가 되는 것이고,
Vue.js는 프론트엔드 프로젝트이기 때문에 그냥 간단히 "frontend"라고 프로젝트를 생성해주었다.

vue create frontend

설치 시에 Vue 버전과 npm을 쓸건지 yarn을 쓸건지 물어볼 것인데, 나는 Vue2 / npm 으로 선택했다.

 

설치 완료

설치가 완료되고 나면 위와 같은 문구가 뜰 것이다.
Vue 프로젝트를 시작하려면 frontend 폴더로 이동 후 npm run serve 명령어로 Vue 서버를 가동시키라는 뜻이다.
그대로 따라 해보면 서버가 정상적으로 작동하는 것을 확인할 수 있다.

 

서버 가동

npm run serve라는 명령어는 Vue-cli를 통해 서버를 실행시키는 명령어이다.
이는 /frontend/package.json 파일로 확인해볼 수 있다.

 

/frontend/package.json

package.json에 scripts라는 부분이 보일 것이다.
scripts 를 통해 npm run으로 실행시킬 수 있는 명령어를 정의/설정해줄 수 있다.


기본적으로 serve, build, lint 가 존재하는데,
각각 서버를 실행, 빌드, lint(소스코드 검사) 실행을 의미한다.


이 곳에 정의된 명령어는 수정해줄 수 있다.

 

'--open' 추가

나는 서버를 켤때 해당 서버에 대한 브라우저가 같이 열렸으면 좋겠어서 --open 옵션을 추가해줘봤다.

 

npm run serve 결과

npm run serve 와 동시에 브라우저가 함께 열린 것을 확인할 수 있었다.

 

이렇게 유저가 명령어를 재정의해 줄 수 있다.

npm-run-script와 관련된 내용은 공식 홈페이지에서 더 자세히 확인할 수 있다.

Spring Boot + Vue.js 연동

Spring Boot 와 Vue.js 를 모두 설치 했으니, 이제 이 둘을 연동시켜볼 것이다.


💡 백엔드 서버와 프론트엔드 서버를 연동해야하는 이유

 

👉 Spring Boot와 Vue.js를 서로 연동하지 않으면,
Vue.js를 이용해 만든 클라이언트 쪽 페이지의 구성을 바꿀 때 마다 매번 build를 하고
build결과물을 Spring Boot 쪽의 resouces/static으로 이동시켜줘야한다. 매우 번거롭다.

 

👉 개발 환경에선 Spring Boot 서버도 켜주고, Vue.js 서버도 켜서 port 두개를 두고 진행하게 될 테지만,
배포 시엔 서버를 두개나 두기엔 곤란할 수 있다.
때문에 실 배포환경에선 연동을 통해 Vue.js의 빌드 결과물의 목적지를 Spring boot의 resources/static으로 맞추고, 실 서버는 Spring Boot 서버 하나만 두게 할 것이다.

 


연동은 Proxy 서버라는 것을 활용한다.
여기서 Proxy란 직역하면 '대리', '대리인'인데, 여기선 정확히 '중계', '중계인'이라고 표현하면 될 것같다.
말 그대로, 서로 연결점이 없거나 보안상의 이유로 직접 통신할 수 없는 외부 네트워크들을 간접적으로 연결시키는 중계인 역할을 한다.

즉, Spring Boot(백엔드 서버)와 Vue.js(프론트엔드 서버)를 연결하기 위해 중계인인 Proxy가 필요하다는 뜻.

 

사용자가 프론트엔드 서버로 접근해서 리소스를 요청하면,
프록시는 이 요청을 백엔드로 연결시켜 요청을 전달한다.

 

본격적으로 Spring Boot와 Vue.js를 연동시켜 보자!

 

연동을 위해선 Spring Boot 서버의 포트와 Vue.js 서버의 포트를 알아야한다.
이는 두 서버를 가동시키면 로그 창에서 확인할 수 있다.
아마 둘 다 8080 으로 되어 있을 것인데, 이러면 충돌이 나서 아래와 같은 화면을 내뱉는다.

 

포트 8080 서버 동시 실행시 발생하는 화면

이를 방지하기 위해 나는 Spring Boot 서버 포트를 8081 로 바꿔 줄 것이다.
/src/main/resources 폴더 내 application.properties 파일에 다음과 같이 입력해 준다.

(이 파일이 없으면 만들어 주면 됨)

server.port = 8081

Spring Boot 설치 부분에서 언급했던 참고 포스팅에서 이미 나온 내용이긴 하지만 다시 간단히 설명하고 넘어가자면,
application.properties 파일은 외부 설정 파일로, 프로젝트에서 사용하는 여러가지 설정 값들을
키, 값 형식으로 저장해두면 프로젝트의 모든 곳에서 참조해 쓸 수 있다.
application.properties 파일은 Spring boot가 어플리케이션을 구동할 때마다 자동으로 로딩하는 파일이다.

 

Run/Debug Configurations

application.properties 를 사용한 방법 외에도 intellij만을 활용하여 포트 번호를 바꿔줄 수도 있다.


intellij 우측 상단의 Edit Configuration을 클릭하거나 alt + shift + f10 단축키를 통해 Run/DebufgConfigurations 창을 열고 ,

위 이미지와 같이 Environment variables 입력칸에 server.port = 8081 을 입력해주면 된다.

 

8080 포트 사용을 서로 겹치치만 않게 하면 되는 것이라서,
사용하지 않는 포트 번호 어떤 것을 써 줘도 상관 없고,
Spring Boot의 포트 말고 Vue.js 의 포트를 바꿔줘도 된다.

 

Vue.js의 포트 번호를 바꿔주려면 vue.config.js 파일을 활용해 줄 수 있다.
/frontend 폴더에 vue.config.js라는 파일을 생성해준다.
이는 Vue.js 설정 파일이다.
생성 후, 아래와 같이 server 코드를 추가해주면 된다.

module.exports = {
  server: {
    port: 3001, // 바꿀 포트번호 입력
  },
};

vue.config.js 파일을 활용하는 방법 외에도,
package.json의 npm 명령어에 옵션만 추가해주는 방법을 쓸 수도 있다.

 "scripts": {
    "serve": "vue-cli-service serve --open --port 3001",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },

이제 바꿔준 포트 번호로, 프록시 설정을 할 것이다.
현재 내 포트는 Spring Boot는 8081, Vue.js는 8080이다.


나처럼 vue.config.js 파일을 활용해 Vue.js의 포트를 바꾼게 아니라 Spring Boot 의 포트를 바꾼 상태라면,
vue.config.js이 없는 상태일 것이다.
/frontend 폴더에 vue.config.js(Vue 설정 파일) 파일을 생성해주고 아래와 같이 입력해주자.

module.exports = {
  outputDir: "../src/main/resources/static",  // 빌드 타겟 디렉토리
  devServer: {
    proxy: {
      '/api': {
        // '/api' 로 들어오면 포트 8081(스프링 서버)로 보낸다
        target: 'http://localhost:8081',
        changeOrigin: true // cross origin 허용
      }
    }
  }
};
  • outputDir
    • Vue.js 빌드 결과물을 Spring Boot 서버 쪽의 ../src/main/resouces/static 에 만들도록 설정한 것이다.
  • devServer
    • 개발 환경에서의 서버를 설정한다.
    • 앞서 설명한대로, 개발 환경에선 프록시로 데이터를 Vue.js 서버에서 Spring Boot로 넘겨주는 것이고,
      실제 빌드/배포 시엔 설정해준 outputDir 를 통해 Vue.js 빌드 결과물을 Spring Boot 쪽 경로로 내보내고 Spring Boot 서버 하나만 배포한다.
    • proxy
      • /api라는 경로로 접근하면, target(Spring Boot 서버)으로 요청을 넘긴다는 뜻이다.
      • changeOrigin : "Cross Origin"을 허용한다는 뜻으로, 말 그대로 "교차 출처"를 허용한다는 말이다.
        "교차 출처" 라는 말이 다소 생소할 수 있는데, 쉽게 풀어 말하면 "다른 출처"이다.
        여기서 출처는 서로 다른 포트를 의미한다.
        즉, "서로 다른 출처의 리소스를 공유한다". 우린 이것을 CORS(Cross-Origin Resource Sharing)이라고 부른다.

이제 프록시 설정이 모두 끝났다.


클라이언트는 Vue 화면인 8080 포트로 접속할 테지만, /api 라는 경로로 시작하게 되면 8081 포트로 연결되게 될 것이다.


테스트를 위해 Api 를 하나 파보자.

/src/main/java/패키지 경로에 HelloWorldCtrl.java 파일을 파고 아래와 같이 입력해줬다.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class HelloWorldCtrl {

    @GetMapping("/hello")
    public String helloWorld() {
        return "hello!";
    }
}

 

 

localhost:8080/api/hello 로 접속하면 "hello!" 를 반환하게 하는 코드이다.

 

해당 경로로 접속해보자.
Vue.js 포트인 8080 서버이지만 /api 로 시작하는 경로이기 때문에 Spring 컨트롤러에 정의한 helloWorld() 가 실행 될 것이다.

 

localhost:8080/api/hello

localhost:8080/api/hello 로 접속하자 "hello!" 라는 문구가 뜬걸 확인할 수 있었다.
제대로 Spring Boot 와 Vue.js 가 연동된 것이다! 🥳

 

SCSS 설치

나는 CSS를 좀더 편하게 사용하기 위해 SCSS를 따로 설치해줬다.

(현재 내가 사용 중인 vue-cli v4.5는 sass의 최신 버전과 호환이 되지 않아서

sass-loader의 버전을 10으로하여 설치해줬다.)

npm install --save-dev node-sass sass-loader@^10

 

이번 포스팅에선 Spring Boot와 Vue.js를 설치하고 연동하는 법에 대해 알아보았다.
다음 포스팅에선 Bootstrap으로 UI 틀을 잡고 OpenLayers 를 활용해 화면에 지도를 띄워보도록 하겠다.


댓글/하트, 피드백은 언제나 환영입니다! 😇

+ Recent posts