@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 search가 된다고 해서 그걸 믿고 검색을 붙이지 맙시다... 검색은 역시 검색엔진에 맡기기로 ㅠㅠ db에서 검색 분리하고 elasticsearch로 옮긴지 하루째 db 로드 변화가 아주 체감되네요 💦 pic.twitter.com/tkDjDDxUHM
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 로 index_type 이 fulltext 인 인덱스가 추가된 것을 확인할 수 있었다.
💡 참고
저장된 인덱스 단어는 아래 명령어로 확인할 수 있다.
// 테이블 단위 조회는 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');
만약 기본 토큰 사이즈인 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 에 설정해주자.
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 테이블의 title 과 address 에 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>
이번 포스팅에선 주니어 개발자의 필수코스... 페이징 및 무한 스크롤을 이해하고 구현해볼 것이다. 사이드 바에 페이징 또는 무한스크롤로 구현된 리뷰 목록을 출력할 것이다.
개선된 코드가 많아 포스팅에서 다소 빠진 부분이 있을 수 있다. 모든 소스코드는 깃헙에서 확인할 수 있다!
🍜 기능 및 코드 보완
본격적인 구현에 앞서, 기존에 구현했던 코드를 살짝 수정했다.
🍥 메소드 명 변경
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 에 설정을 아래 코드처럼 추가했었다.
<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 타입을 MYSQLDATE_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. 페이지네이션과 유사하지만 스크롤을 내려 페이지를 이동하는 기법이다. 페이지네이션과는 다르게 원하는 페이지로 단번에 이동이 불가능하다. 스크롤을 내렸을 때 페이지의 최하단에 도달한다면, 다음 페이지를 로딩해오는 방식이다.
결론적으로 나는 '무한 스크롤' 기법을 사용할 것이다. 이유는....
사이드 바에 버튼 목록이 있는 것 보단 스크롤을 내리는 게 편할 것 같아서
리뷰는 검색하면 되고, 딱히 원하는 '페이지'를 찾아 갈 일은 없을 것 같아서
하지만 페이징도 공부할겸 브랜치를 따로파서 페이징도 구현해보기로 했다.
🍜 리뷰 목록 UI 구현
지금은 사이드바에 하나의 리뷰를 수정하거나 등록하는 UI 만 구현되어 있다.
리뷰 리스트도 보여져야하기 때문에 이런 등록/수정 폼을 따로 컴포넌트로 빼주고, 리뷰 리스트 컴포넌트를 구현하여 각 컴포넌트가 화면에 나타났다 사라지게 해줄 것이다. 나는 각 컴포넌트를 ReviewForm.vue, ReviewList.vue 로 생성했다.
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를 구현하기 위해선 크게 여섯가지 정보를 구해야한다. 여기서 괄호의 네이밍은 수정해줘도 상관 없다.
한 페이지에 출력될 게시물 수 (countList)
총 페이지 수 (totalPage)
한 화면에 출력될 페이지 수 (pageCnt)
현재 페이지 번호 (curPage)
화면에 보이는 시작 페이지 (startPage)
화면에 보이는 끝 페이지 (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개라는 것이다.
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을 구할 수 있다.
그렇다면 위 공식에서 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가 된다.
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();
}
<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();
}
<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 을 지우고,
setReviewsByLimit 과 setReviesForMap 을 추가했다.
지도에 뿌려질 아이콘 정보를 담기 위한 reviewsForMap state도 추가해줬다. 지도의 아이콘을 클릭했을 때, 해당 리뷰를 사이드 바에 뿌려줘야하기 때문에, setReview action, mutation 도 추가해줬다.
파일도 보여주기위해선, setReview 은 내부에 setFileList 가 호출되어야한다.
curReview 가 set되면, 현재 위, 경도 정보로 coordinate 를 선언해주고, 해당 위치를 센터로 맞추고 zoom 을 땡기는 기능이다.
여기서 18은 내가 임의로 정해준 값이다. 더 확대하고 싶다면 이 보다 큰 값을 써주면 된다.
여기서 curReview 로 리뷰의 변경을 판단해주는 이유는, curLon이나 curLat은 리뷰를 클릭했을 때 뿐만아니라 지도를 클릭했을 때도 set되는 상태여서 안되고, curReviewId는 curReviewId 가 set되고 난 이후에 curLon, curLat 정보를 업데이트할 수 있기 때문에 sync 가 맞지 않아 쓸 수 없었다.
getView는 지도와 관련된 화면(View) 를 가져오는 메소드로, 해상도나 center 속성을 관리하고 있다. setZoom 이나 setCenter를 사용하기 위해 함께 써주면된다. setZoom 과 setCenter 는 읽으면 바로 어떤 기능인지 알 수 있을 것이다.
이유는 위 코드 때문이다. 리뷰 목록 컴포넌트 코드인데, 컴포넌트가 created 되면서 리뷰를 가져오는 동작을 수행하고 있다.
v-if 는 조건에 따라 컴포넌트를 화면에 출력시킬 때 컴포넌트를 완전히 destroy(파괴) 시킨 뒤 새로 create 해온다.
때문에 리뷰 목록이 화면에서 사라지고 보일때마다 created의 메소드들이 수행된다. 이러면 리뷰 목록을 새로 볼 때 마다 매번 첫번째 페이지만 가져오게 되어버린다.
그래서 v-show 로 수정해주었다.
v-show 는 컴포넌트를 완전히 destory 시키는게 아니라 출력하지 않는 상황엔 컴포넌트를 hide 를 시킨다.
즉, 화면에 컴포넌트 자체가 존재하긴 하나 잠깐 눈속임하듯이 가려놓기만 한 것이다. 때문에 사라졌다가 생성될 때 created 가 수행되지 않고, 기존 상태 그대로 유지된다.
결과적으로 페이지네이션이 정상 작동하도록 구현했다! 🎉
🍜 무한 스크롤 구현
이제 무한 스크롤을 구현해볼 것이다!
구현한 페이지네이션은 push 하고 다시 master 브랜치로 돌아와 구현해보자.
git checkout master
무한 스크롤 역시 라이브러리를 활용할 생각이었는데, 코드 양이 딱히 줄어드는 것도 아니고... 라이브러리라는 게 편하자고 사용하는 것인데 오히려 코드 구현할 때 번거롭고 신경쓰이는 부분이 많아서 직접 구현하기로 했다. 참고로, 무한 스크롤 UI 라이브러리는 Vue-infinite-loading 를 사용했었다.
(이 라이브러리로 구현완료 했었는데, 코드가 지저분해보여서 그냥 날렸다.)
프로젝트에 직접 구현하기 전에, 무한 스크롤에 대해 제대로 이해해보고자 데모 무한 스크롤을 구현해보았다.
<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 부분이 추가되었다. 받아온 reviewUpdateDate와 reviewId 로 정렬을 했다. desc 로 정렬했기 때문에 파라미터인 마지막 row의 id 보다 작아야 다음 값을 가져올 수 있다.
여기서 주목해야할 점은 reviewUpdateDate 조건이다. reviewId 와 달리 "작거나 같다"를 조건으로 걸었는데, 그 이유는, reviewId는 유일한 값이지만 날짜는 중복될 수 있는 데이터이기 때문이다. 중복이 있을 수 있는 값이기 때문에 날짜 만으로는 키가 될 수 없었겠지만, id가 있기 때문에 작거나 같다는 조건을 걸 수 있게 되었다.
그리고 반드시 key 로 잡은 컬럼이 여러개라면 and 조건으로 묶어줘야한다. 중복 없이 유일해야하는 키값이기 때문이다.
💡 CDATA Character DATA. 즉 문자형 데이터를 말한다. CDATA로 감싼 문자는 문자 그대로 표시되게 된다. CDATA는 부등호 처럼 마이바티스가 태그로 인식하는 특수 기호를 쓸 때 사용한다. CDATA로 감싸 부등호를 사용하면 마이바티스가 이를 태그로 인식하지 않고 부등호 문자 자체를 출력하게 된다.
getReview 나 getReviewsForMap 역시 구현하였는데, 페이지네이션과 동일하기 때문에 더 언급하지 않겠다.
주의해야할 것은 getReviewsCnt 는 구현할 필요가 없다는 것이다.
(페이지를 표시할 일이 없기 때문에!)
성공적으로 무한스크롤이 되는 것을 확인할 수 있었다! 🎉
이번 포스팅에선 페이지네이션과 무한 스크롤을 구현해보았다.
다음 포스팅에선 리뷰 검색 기능을 구현해 보도록 하겠다!
(검색 때 like는 쓰기 싫고... 검색 엔진을 사용해 구현해보고 싶은데 할 수 있을 지 모르겠다 @_@ )
MultipartFile 이란 인터페이스로, Multipart 요청에서 수신된 업로드된 파일의 표현이다. 파일 내용을 메모리에 저장되거나 일시적으로 디스크에 저장된다. 이 임시 저장소는 요청 처리가 끝나면 지워진다. ..라고 설명하고 있다.
대충 메모리 등에 임시 저장되는 Multipart request(요청) 로 보내진 파일 타입이라고 이해하면 될 것 같다.
여기서 Multipart 란 무엇일까? Multipart란, 직역하면 Multi(여러) part(부분)이라는 뜻으로, 하나 이상의 서로 다른 데이터 집합이 하나의 body 에 결합되어 있는 것을 말한다.
여기서 말한 body 는 HTTP 의 body 부분을 의미한다.
풀어 설명하자면... 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);
}
}
극단적으로 간단해진 코드를 볼 수 있다ㅋㅋ 컨트롤러에선 요청/응답을 처리하기만하고 주요 기능은 서비스에 구현하는 아키텍처가 좋다고 하길래 파일 첨부 로직을 추가하면서 리뷰와 파일 로직 각각을 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);
}
}
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.java 도 service 패키지에 생성하고 아래와 같이 구현했다.
@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);
}
}
}
file.store.imgDir의 값은 이미지 파일이 저장될 디렉토리 경로이다. 나는 upload-dir 라고 정의해줬다. (스프링 공식 튜토리얼에서 이 이름으로 정의해 줬길래 따라했다.)
참고로 서버의 이미지 자원을 사용하기 위해선 resources/static 에 이미지가 저장되어 있어야한다. (다른 디렉토리에도 넣어봤는데 404만 뜨더라...)
그런데 나중에 배포를 하게 되면 클라이언트 코드가 빌드되면서 resources 폴더가 clear 될 것이기 때문에 서버의 resources/static 폴더에 유동적인 이미지를 저장하는 것은 옳지 않다. 그렇다고 로컬 디렉토리에 저장하려니 브라우저 보안상 제약되는 부분이 많았다.
때문에 나는 개발 환경에서만 서버에 저장하고 배포 시 경로를 변경해주기로 했다.
🚨 참고
FileSizeLimitExceededException 에러가 발생하면 application.properties 에 아래 내용을 선언하자!
설정된 파일 크기를 초과하면 발생하는 에러로, 스프링 기본 설정이 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 메소드는 경로를 합해주는 메소드이다. imgDirPath 와 reviewId 를 합하여 현재 리뷰의 파일들이 저장될 경로를 설정한다. 여기서 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);
}
}
saveReview API의 파라미터에 files 를 추가해줬고, transformRequest 라는 설정을 추가했다. formData 에 요청 파라미터들을 key, value 형태로 append 해준다.
💡 tansformRequest
요청 데이터를 서버로 전송하기 전에 변경할 수 있게 해주는 axios 기능. 'PUT', 'POST', 'PATCH', 'DELETE' 메소드에서만 적용 가능. 마지막 함수는 Buffer, ArrayBuffer, FormData 또는 Stream의 인스턴스 또는 문자열을 반환해야 한다. 헤더 객체를 수정할 수 있다.
정상적으로 리뷰와 함께 이미지 파일이 저장되는 걸 확인할 수 있었다! 🎉
🍜 저장된 파일 불러오기 API 구현
🍥 Spring Boot Server
저장된 파일을 불러와보자.
처음엔 getReviews API에 파일 목록도 같이 붙여 response 할까 했지만... 파일이나 리뷰가 많으면 부하가 올 수 있을 것 같아서
지도에서 아이콘을 클릭했을 때, 즉, 현재 리뷰가 사이드 바에 set된 경우
리뷰를 저장하고 나서 갓 저장된 현재 리뷰를 사이드 바에 보여줄 때
위 두 경우에만 이미지 파일 목록을 불러오기로 했다. 즉, 한 리뷰에 대한 파일 목록만 불러오는 것이다.
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);
}
}
fileList 와 동일하게 curFileList 도 출력시킨다. fileList와 curFileList의 차이점은 fileList 는 File 객체를 가지고 있지만, curFileList는 DB에서 가져온 정보만을 가지고 있기 때문에 서로의 속성/키(attr)가 다르다는 것이다.
여기서 주목해야할 점은 삭제 로직이다. fileList 는 파일을 삭제했을 때 배열의 요소를 비우면 되지만, curFileList는 DB에 있는 파일 정보를 삭제해야한다.
deletedFileIds 라는 데이터를 하나 파고, 삭제하겠다고 클릭한 파일들의 ID 값을 채워 넣자. 수정 역시 saveReview API에서 진행되기 때문에 saveReview 파라미터에 fileIds 를 추가한다.
ok와 confirm 메소드를 하나 파줬다. 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 부분에 리뷰 목록을 불러오게 구현할 수도 있을 것이고 (아직 구체적으로 설계해보진 않았지만) 지도에 출력된 리뷰 정보(아이콘)를 클릭하여 상세 정보를 사이드에서 볼 것 같았다. (아래에서 구현함) 아무튼 이래저래 사이드바와 지도 사이의 데이터 교환이 굉장히 많이 일어날 것 같았다.
state에 reviews 와 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;
}
}
지도가 그려지고 난 뒤에 리뷰가 뿌려져야하니, 지도 선언 바로 다음에 dispatch로 setReviews 를 호출해오자.
이제, 지도에 아이콘으로 불러온 리뷰들을 출력할 것이다. 무료 아이콘 사이트에서 적당한 스팟 아이콘을 다운로드해왔다. 사실 썩 맘에 들진 않는데 (색이 마음에 안듬...) 나중에 바꿔주던가 해야겠다 ㅋㅋ
다운 받은 이미지 파일을 frontend/src/assets/images/spot.png로 저장한다. (public 에 저장해줘도 상관 없다.)
이제 이 아이콘을 지도에 그리는 메소드를 작성할 것이다. 지난 포스팅에서 클릭했을 때 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 를 클릭했을 때 다르게 동작하는 로직을 추가해줄 것이다.
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 에 담게 했으니, 이를 사이드바에 불러오게 할 것이다.
1번 항목을 위해 forEachFeatureAtPixel 내부에 this.$store.commit('setInputState', true);를 추가해줬고, 2번 항목을 위해 forEachFeatureAtPixel 외부에 this.$store.commit('setInputState', false);를 추가해줬다.
이제 input 에 내용을 입력하면 v-model 로 set해준 data에 담긴다. 우리는 이것을 API 에 params로 전달해 줄 것이다.
저장 버튼을 누르면 API가 실행되도록 구현해보자. API 구현엔 Axios 를 이용할 것이다. 공식 문서에 따르면, Axios는 브라우저, Node.js 를 위한 Promise API를 활용하는 HTTP 비동기 통신 라이브러리라고 설명되어 있다. 쉽게 말하자면 백엔드와 프론트엔드의 통신을 쉽게하는 라이브러리이다. 이미 자바스크립트에는 fetch api가 있지만 프레임워크에서 ajax를 구현할 땐 axios를 쓰는 편이라고 보면된다.
아까 만들어줬던 저장 버튼에 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;
}
}
class 안에 위 코드 처럼 변수만 선언해주고 alt + Insert 를 누르면 getter, setter를 자동 생성할 수 있다! (MacOS에선 코맨드키 + N 라고 하는데, 안해봐서 되는지는 모르겠다.)
🍥 테이블 생성
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라고 생각하면 된다.)
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);
}
}
data() 에 processingCount를 선언하고, api 가 돌기 전에 수를 증가시키고, 완료되고 난 후엔 감소시켰다. 그리고, v-if로 processingCount 가 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(); }
}
Openlayers 의 클릭 이벤트는 coordinate 라는 것을 제공한다. 이는 직역하면 좌표계 라는 뜻이다. ol/proj.js엔 이 제공받은 좌표를 위도 경도로 변환시키는 메소드가 존재한다. (toLonLat) 지난 시간에 사용했던 fromLonLat 과 같은 방법으로 impoort 해서 사용하자.
이제 지도를 클릭하고 콘솔을 확인해보면 위도, 경도 값을 담은 배열이 콘솔에 찍히는 것을 확인할 수 있다.
🍜 위도, 경도 정보로 주소 가져오기
이제 이 위도, 경도를 주소로 변환시킬 것이다. 주소 변환 API는 네이버, 카카오, 구글, 행정안전부 등 여러 곳에서 제공하지만 KEY를 받아 사용해야한다는 귀찮음이 있어서 나는 Nominatim이라는 오픈소스를 활용하기로 했다.
Nominatim은 OpenStreeMap 데이터를 사용하여 주소를 가져온다.
💡 참고 지오코딩 (geocoding) : 주소 -> 위도, 경도 리버스 지오코딩 (reverse geocoding) : 위도, 경도 -> 주소
Nominatim이 제대로 작동하는지 확인해보기 위해 나는 Postman을 사용해봤다.
http://nominatim.openstreetmap.org/reverse 온라인 GET API에 format과 아까 콘솔로 본 위도 경도를 lon, lat params 로 담아 보내보았다. format의 값으로 json을 입력했더니, 결과가 json으로 온 것을 확인할 수 있었다. 대충 아무 곳이나 찍었었는데 치킨 집이 나왔다...
아무튼 무사히 리버스 지오코딩이 된 것을 확인할 수 있었다! 🎉
이제 본격적으로 화면에 주소를 출력시켜보자.
API통신을 위해서 Axios 를 사용해 줄 것이다. Axios는 브라우저와 node.js에서 사용할 수 있는 Promise 기반 HTTP 클라이언트 라이브러리이다.
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)
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.json의 dependecies를 보면 잘 설치된 것을 확인할 수 있다.
설치된 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 해오고, library에 add 시켜주기만 하면된다. Vue.component("FontAwesomeIcon", FontAwesomeIcon); 를 통해 Vue component로 등록해줬기 때문에 <FontawesomeIcon icon="location-dot" /> 과 같이 구현할 수 있다.
이제 아이콘을 적용해보자. 열림, 닫힘 버튼을 화살표 아이콘으로 변경하고, 위치 정보 입력란 앞쪽에 아이콘을 추가해줄 것이다.
인터넷이 안되는 환경에서도 동작하기 위해, 그리고 로컬PC에 설치할 수 없는 경우를 대비하여 font 파일들을 따로 보관하여 불러오는 방식으로 진행했다.
src/assets 폴더 하위에 fonts 폴더를 파고 원하는 폰트를 다운로드하여 추가한다.
나는 '나눔스퀘어'와 '나눔바른고딕'을 추가했다.
frontend/src/assets/scss/vendors/bootstrap-vue 를 보면 _custom.scss 라는 파일이 보일 것이다. 이는 부트스트랩으로부터 추가된 커스텀 css 파일로, bootstrap 이 외의 css를 전역적으로 추가해주고 싶을 때 사용할 수 있다. 아래 코드를 추가하여 추가한 폰트를 사용할 수 있게 하자
화면을 가득 차게 구현하고 싶기 때문에 position을 absolute로 주고 상하좌우를 모두 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
먼저 화면 꽉 차게 지도를 띄우기 위해 width와 height를 모드 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 문구가 보인다.
나중에 추가하는 일이 있더라도, 지금은 이를 없애보자.
공식 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/components 에 SideBar.vue 파일을 추가해주고 App.vue에 import 해주자. 그리고 css로 위치와 크기를 잡는다.
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 측의 답변이 달리진 않았으나,
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 으로 되어 있을 것인데, 이러면 충돌이 나서 아래와 같은 화면을 내뱉는다.
이를 방지하기 위해 나는 Spring Boot 서버 포트를 8081 로 바꿔 줄 것이다. /src/main/resources 폴더 내 application.properties 파일에 다음과 같이 입력해 준다.
(이 파일이 없으면 만들어 주면 됨)
server.port = 8081
Spring Boot 설치 부분에서 언급했던 참고 포스팅에서 이미 나온 내용이긴 하지만 다시 간단히 설명하고 넘어가자면, application.properties 파일은 외부 설정 파일로, 프로젝트에서 사용하는 여러가지 설정 값들을 키, 값 형식으로 저장해두면 프로젝트의 모든 곳에서 참조해 쓸 수 있다. application.properties 파일은 Spring boot가 어플리케이션을 구동할 때마다 자동으로 로딩하는 파일이다.
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 명령어에 옵션만 추가해주는 방법을 쓸 수도 있다.
이제 바꿔준 포트 번호로, 프록시 설정을 할 것이다. 현재 내 포트는 Spring Boot는 8081, Vue.js는 8080이다.
나처럼 vue.config.js 파일을 활용해 Vue.js의 포트를 바꾼게 아니라 Spring Boot 의 포트를 바꾼 상태라면, vue.config.js이 없는 상태일 것이다. /frontend 폴더에 vue.config.js(Vue 설정 파일) 파일을 생성해주고 아래와 같이 입력해주자.
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 로 접속하자 "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 를 활용해 화면에 지도를 띄워보도록 하겠다.