본문 바로가기

📌 공지

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

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

(변명을 좀 하자면... 

최근에 부서이동을 하였는데, 현 부서에선 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 로 검색이 되는 걸 확인할 수 있었다! 🎉


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

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

Seize the day!

Spring MVC | Spring Boot | Spring Security | Mysql | Oracle | PostgreSQL | Vue.js | Nuxt.js | React.js | TypeScript | JSP | Frontend | Backend | Full Stack | 자기계발 | 미라클 모닝 | 일상