# AIBOT2.0 분석 - 최초 작성일: 2021년 07월 23일(금) ## 목차 [TOC] <!-- ## 목표 - AIBOT2.0 요구사항, 고도화 개발내용을 분석해보고, 100번 기반의 K-SOE 구조/기능에대한 인사이트 도출한다. --> ## 결론 - AIBOT2.0은 100번 대화엔진이 해주는 대화흐름제어를 '봇엔진' 내에서 대화흐름을 제어한다. 그리고 의도맵에따라 시나리오를 동작시킨다. K-SOE에서 Dialogue Manager 내에서 대화흐름 및 시나리오를 제어하는 것과 유사한 형상인 거 같다. (코드 분석 필요) - [AIBOT2.0 향후 고도화 과제](##AIBOT2.0-향후-고도화-과제) 내용은 K-SOE에도 반영이 되어야할 거 같다. - AIBOT2.0은 의도탐침은 의도 threshold에따라 미답변(fallback), 되묻기를 통해 의도 추천을 진행한다. - K-SOE NE 추론에서도 주소/인명/복합 NE 추론에 대한 검토 및 설계가 필요해 보인다. - AIBOT2.0은 confirm 의도를 solr를 활용하여 기존 시나리오 의도 추론과 같은 방식으로 confirm 의도를 추론한다. - AIBOT2.0 대화흐름제어에서 context 생성, 참조, 삭제 정책을 K-SOE에 녹이면 좋을 거 같다. - K-SOE에도 답변노드/슬롯필링노드 내 TTS 재생 기능이 들어가면 좋을 거 같다. - AIBOT 고도화 내용에도 3rd party NLU 연동을 위해 K-SOE AI adaptor 같은 서비스를 고려 중인 거 같다. - K-SOE에서도 B2B 고객에게 premade / preset template 제공 방안 검토가 필요해 보인다. - K-SOE의 Channel Manager Adaptor에서 AIBOT2.0 converter와 같은 기능을 제공하면 좋을 거 같다. ## 코드 분석 - AIBOT2.0의 가상상담 프로세스를 이해하기 위해 코드를 보며 flow를 따라가본다. ### 봇엔진 (모듈명: engine) - 시스템 구조 ![](https://i.imgur.com/8cA3Q05.png =600x600) - 학습/배포 - cms에서 redis의 engine이 참조하는 지식인 BotCache, RiveCache를 생성 - engine의 /learnig api를 호출하면 BotCache와 RiveCache를 참조하여, 자신의 메모리에 BotAgent 생성 - engine의 /deploy api를 호출하면, Active EngineBot과 StandBy EngineBot의 지식을 switching - 서비스 runtime - 상담이 시작되면 channel gateway가 engine의 /start api를 호출 - 봇 발화를 반환하기 위해 /start 호출 이후 /talk api를 호출 - 상담이 종료가 되면 /close api가 호출된다. - 대화 처리 흐름 ![](https://i.imgur.com/1Ye58Ls.png =370x1050) - Solr 구조 - engine은 3가지 종류의 profile을 필요로함. - 3종 perofile: ACTIVE, STANDBY, TEST - 학습(/learn)하면 STANDBY에 올라가고, 배포(/deploy)하면 ACTIVE <-> STANDBY 스위칭, 실제 가상상담 서비스는 ACTIVE profile에따라 서비스가 수행됨. - TEST는 학습 검증을 위한 profile - 시뮬레이터에서 테스트할 bot profile을 선택해서, 특정 profile 기준으로 서비스 테스트 ```java @PostMapping("/simulator/{profile}/start") public Mono<EngineResult> simulatorStart(@PathVariable("profile") final BotProfile profile, @RequestBody StartTalkRequest talkRequest) { ...생략... } @PostMapping("/simulator/{profile}/talk") public Mono<EngineResult> simulatorTalk(@PathVariable("profile") final BotProfile profile, @RequestBody EngineTalkRequest talkRequest) { ...생략... } ``` - 봇 유형 ```java public enum ChatBotType implements EnumModel { GENERAL("챗봇"), VOC("보이스봇"), } ``` - 봇 유형(챗봇, 보이스봇)에따라 분기를 태워 각 봇의 고유한 처리를 진행 - 노드 유형 - 노드 유형을 보고 switch-case로 분기를 쳐, 해당 노드 유형에 맞는 task 수행 ```java public enum ChatNodeType implements EnumModel { BASIC("답변노드"), SYNC_BASIC("Sync 답변노드"), FUNCTION("동적노드"), SLIDE("슬라이드 노드"), JUMP("JUMP 노드"), SLOT("Slot 노드"), SYNC_SLOT("Sync Slot 노드"), RETURN("Return 노드"), SPLIT("조건분기 노드"), API("API 노드"), CALLBACK("END 노드"), GOTO("GOTO 노드"), SMS("SMS 전송 노드"), COUNSEL("호전환 노드"), CALLEND("콜종료 노드") ; } ``` - 봇 구조 - Map<String, BotAgent> agents => botCode로 특정 BotAgent 선택 => BotAgent안에 Map<BotProfile, EngineBot>가 있음 => profile로 특정 EngineBot 선택 => EngineBot 안에 Map<String, String> botParams, Map<String, String> userParams, Map<String, String> scenarioParams 등이 있음 - 봇의 답변은 EngineBot에서 생성 - 주요 API 동작 프로세스 - cms에서 채널을 만들면, chatChannel 정보가 db에 저장되고, redis에 channelCache가 생성된다. - cms에서 bot을 만들면, 해당 bot 정보가 db에 저장되고, redis에 botCache가 생성된다. - cms에서 /learn을 하면 redis에 riveCache, channelCache 등이 만들어지고, /depoly를 하게 되면 STANDBY, ACTIVE rivecache 내용을 서로 바꾼다. - /learning API ![](https://i.imgur.com/ijCl63N.png =600x600) 1. botCache를 redis에서 가져옴. 2. riveCache를 redis에서 가져옴. 3. botCache, riveCache 정보를 기반으로 STANDBY profile을 가지는 EngineBot 인스턴스 생성하여 메모리에 올려놓음. - /deploy API ![](https://i.imgur.com/CjqbFPm.png =250x500) - STANDBY profile의 EngineBot과 ACTIVE profile의 EngineBot 인스턴스를 스위칭 - /start API ![](https://i.imgur.com/Re94U97.png =550x500) - 채널GW에서 engine이 참조하는 userSession이 만들어짐. 1. userSession을 EngineStartRequest의 sessionKey를 기준으로 redis에서 가져옴. - userSession이 없으면 exception 발생 2. redi에서 가져온 userSession을 InheritableThreadLocal인 UserSessionContextHolder에 저장 - thread 내에서 전역변수처럼 사용할 수 있어, redis를 반복 참조하지 않게함. 3. botCode로 botAgent를 선택, ACTIVE profile로 engineBot 인스턴스를 선택, 해당 engineBot에서 INTRO에 해당하는 engineReply를 가져옴. ```java // EngineBot.java EngineReply reply = null; switch (mode) { ...생략... case INTRO: reply = Optional.ofNullable(dialog.getIntroMessages()) .map(map -> map.get(session.getChannelCode())) .orElse(null); } ...생략... return reply; ``` 4. 가져온 engineReply의 노드 타입에따라 추가 작업 수행 - INTRO는 BASIC(답변노드)이므로, 추가 작업 수행하지 않음 ```java // EngineChatFacadeImpl.java ...생략... switch (engineReply.getNodeType()) { case BASIC: case SYNC_BASIC: case SLIDE: case CALLEND: return engineReply; } ...생략... ``` 5. engineReply를 가지고 TalkResponse 생성 6. UserSessionContextHolder 초기화 - /talk API - /talk API 요청 유형 ```java public enum TalkRequestType implements EnumModel { TALK("대화질의"), ACTION("사용자 Action"), FORM_DATA("Data 전송"), TYPING_ON("입력중"), TYPING_OFF("입력 종료"), MUTE("묵음(Voice)"), NOT_SYNC("동기화 확인되지 않음"), SYNC_START("동기화 완료/대화 시작"), ; } ``` 1. 요청이 form data인지 판단한 후, form data라면, form data를 세션에 저장하고, 세션 데이터를 이용하여 노드를 수행하여 응답 값 생성 - 끼어들기 유형 ```java public enum InterruptMode implements EnumModel { STANDBY("대기"), FREEZE_CONFIRM("끼어들기 확인 상태"), FREEZE("끼어들기 상태"), CONFIRM("돌아가기 확인 상태"), ; } ``` ![](https://i.imgur.com/41KRIvH.png =600x600) 2. form data가 아닌 경우, 끼어들기 여부 확인 / 끼어들기 복귀인지 끼어들 차례인지 확인하여 끼어들기 세션 feeze/thaw 진행 ![](https://i.imgur.com/uk1XQ85.png =600x600) 3. 봇 유형이 보이스봇이면 slot-filling이 아닌 경우일 때 묵음이면, fall back or retry 의도 발화 생성 ![](https://i.imgur.com/UIiuvEs.png) 4. 묵음이 아닌 경우, slot-filling 처리 / slot-filling은 단일 slot이 있고 멀티 slot이 있다. - slot은 solr의 NER을 사용하든지 정규표현식을 통해 slot이 채워졌는지를 판단한다. - 멀티 slot-filling은 순회하면서 slot-filling을 진행 / 전체 slot-filling이 끝나면 confirm을 통해 고객 확인을 받을 수 있음. ![](https://i.imgur.com/MOK0ffY.png) 5. 묵음이 아니고 slot-filling이 아니라면, 선택지 처리를 진행한다. 선택지는 postback에서 정의한 데이터와 고객 반환 데이터가 같은지를 판단하고, 같으면 선택지 내의 값을 통해 해당 노드를 실행한다. ![](https://i.imgur.com/jCl6mpT.png =600x600) 6. 묵음, slot-filling, 선택지가 아니면, 의도/NE을 추론하여 해당 의도에 매핑된 시나리오를 수행한다. - 의도/NE 추론은 solr를 이용한다. - 다음 노드가 있는 경우, 봇이 반환할 값이 생성될 때까지 재귀를 돌면서 연관된 노드를 전부 실행한다. - threshold보다 높은 의도가 추출됐을 때 의도 스케줄을 확인하고, 의도 스케줄 조건에 만족하지 못하면, 해당 의도는 실행되지 않는다. - 여러의도가 추론됐을 때는 멀티의도에 대한 선택지를 통해 처리한다. ![](https://i.imgur.com/Ok9EVLe.png =600x600) ![](https://i.imgur.com/PEy6iqi.png =600x600) ![](https://i.imgur.com/SQMqdeG.png =600x600) - /close API ![](https://i.imgur.com/UxGGJcT.png =550x500) - engineBot에서 close 시 reply 값을 가져온 후, reply를 처리 - 시스템 NE 추출 (SystemEntityUtils.java 참조) - K-SOE의 시스템 NE 설계/개발 시에 활용하면 좋을 거 같다. ```java public static void convertSystemEntity(List<NerEntity> nerEntities) { LocalDate entityDate = null; LocalTime entityTime = null; for (NerEntity nerEntity : nerEntities) { Matcher matcher = ChatConstants.ChatPatterns.ENTITY.matcher(nerEntity.getType()); String entityKey = nerEntity.getType(); if (matcher.find()) { entityKey = matcher.group(1); } if (NeType.DATE.name().equals(entityKey)) { entityDate = DateUtils.convertDate(nerEntity.getName(), DateUtils.DATE_FORMAT_SLUSH); nerEntity.setType(String.format("@{%s}", SystemEntityType.LOCAL_DATE.getCode())); nerEntity.setName(DateUtils.convertDate(entityDate)); } else if (NeType.TIME.name().equals(entityKey)) { try { entityTime = LocalTime.parse(nerEntity.getName()); nerEntity.setType(String.format("@{%s}", SystemEntityType.TIME.getCode())); nerEntity.setName(DateUtils.convertTime(entityTime)); } catch (CustomException e) { log.error("Exception, {}", e.getMessage()); } catch (Exception e) { log.error("Exception, {}", e.getMessage()); } } else if (NeType.NUMBER.name().equals(entityKey) && NumberUtils.isDigits(nerEntity.getName())) { nerEntity.setType(String.format("@{%s}", SystemEntityType.NUMBER.getCode())); } else if (NeType.CONFIRM.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.CONFIRM.getCode())); } else if (NeType.PHONE.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.PHONE.getCode())); } else if (NeType.ADULT_NO.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.NUMBER_ADULT.getCode())); } else if (NeType.CHILD_NO.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.NUMBER_KIDS.getCode())); } else if (NeType.CAR.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.CAR_NO.getCode())); } else if (NeType.CARD.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.CARD.getCode())); } else if (NeType.BIZ.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.BIZ_NO.getCode())); } else if (NeType.AM_PM.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.AM_PM.getCode())); } else if (NeType.STOCK.name().equals(entityKey)) { nerEntity.setType(String.format("@{%s}", SystemEntityType.STOCK.getCode())); } } } ```` - 최초 기동 시 in-memory에 인스턴스 생성 (ChatServiceImpl.java) 참조 - K-SOE에서도 redis를 rdb cache로 사용을 하고, 실제로 run time 시에 참조하는 데이터는 in-memory의 객체 인스턴스로 사용하면 좋을 거 같다. ```java private void initialize(BotCache botCache) { log.info("Create RSBotInstance, botCode={}, name={}", botCache.getId(), botCache.getName()); if (agents.containsKey(botCache.getId())) { log.info("Already exist rsBotInstance, code={}, name={}", botCache.getId(), botCache.getName()); return; } // 최초 기동 시 전체 봇에 대하여 Memory에 학습한다. synchronized (this) { try { BotAgent botAgent = new BotAgent(riveCacheService); agents.put(botCache.getId(), botAgent); // 1. Active this.learn(botCache.getId(), BotProfile.ACTIVE); // 2. Standby this.learn(botCache.getId(), BotProfile.STANDBY); // 3. TEST this.learn(botCache.getId(), BotProfile.TEST); } catch (CustomException e) { log.error("CustomException, {}", e.getMessage()); } catch (Exception e) { log.error("Exception, {}", e.getMessage()); } } } ``` - EngineReply(노드) 구성 ![](https://i.imgur.com/l9OWWws.png =600x600) - BotAgent 구성 - 파라미터 구조 ![](https://i.imgur.com/Kcnwfrs.png =600x600) - reply API reply API는 EngineBot의 ChatDialog가 저장하고 있는 의도와 상황에 맞는 응답 문구를 전달하는 API로 /talk API에서 사용자 발화에 따른 응답 문구를 출력하기 위한 API이다. - INTENT 모드 ![](https://i.imgur.com/OwCnhAW.png =600x600) - DIALOG 모드 ![](https://i.imgur.com/jDZdboa.png =600x600) - 그 외 모드 ![](https://i.imgur.com/kjJJTI8.png =600x600) 로직을 확인해보면 DIALOG 모드에서 발화에 대응하는 응답 메세지를 전달하고 나머지 모드는 봇이 설정으로 가지는 메시지를 전달하게 된다. - processReply 메소드 processReply 메소드는 현재 상태에서 들어온 발화에 따라 처리해야 하는 로직을 구현화한 메소드로 /talk API에서 호출하여 현재 노드 상태를 처리하고 EngineBot의 reply 메소드를 통해 봇발화를 전달한다. 호출은 내부에서 노드가 reply return 노드가 될 때까지 다음 노드를 재귀 호출하는 방식으로 진행한다. - 일반 노드 ![](https://i.imgur.com/qB7bIGE.png =600x600) - FUNCION, API, SPLIT 노드 ![](https://i.imgur.com/GQBsST0.png =600x600) - RETURN, COUNSEL, JUMP 노드 ![](https://i.imgur.com/32TCpd0.png =600x600) - SLOT 노드 ![](https://i.imgur.com/OKj6iDW.png =600x600) ![](https://i.imgur.com/5gd70Pd.png =600x600) ![](https://i.imgur.com/Wyk8ex3.png =600x600) 1. slot 노드가 multislot 인 경우 slot entity 마다 확인하여 상태가 호전환, 종료, 재질의 상태에 따라 처리 에러가 없으면 multislot이 다 채워져 있으면 다음 노드로 진행 2. multislot이 아닌 경우 빈 슬롯이 있는지 확인 후 빈 슬롯이 있으면 재질문, 다 채워져 있으면 다음 노드로 진행 - CALLBACK, GOTO 노드 ![](https://i.imgur.com/VIdD0XD.png =600x600) ## AIBOT2.0 구조 ### 메인 서비스 - 채널 GW (모듈명: gateway) - 다양한 채널 수용 - 봇엔진 (모듈명: engine) - 의도엔진 - solr 기반으로 의도추론, 개체식별, 자동완성 - 대화흐름제어 - 의도 흐름 제어 - 슬롯 필링 - 시나리오 흐름 제어 - 대화 context 관리 - 시나리오엔진 - 시나리오 수행 - CMS (모듈명: cms) - 봇관리 - 지식관리 - 사전관리 - 로그 관리 - 통계 관리 - NLU 학습 - NLU 검증 - 시뮬레이터 - 대화로그 관리 및 분석 (모듈명: scheduler) - 대화로그, 대화통계 등 ### 대화흐름제어 ![](https://i.imgur.com/i2vmVVt.png =600x600) - /start 때 context 생성 - /start에서 intro 의도를 통해 인사말 제공 - 매 /talk 요청마다 NLU 분석 - 고객의 발화의 의도 점수 threshold에 따른 되묻기/미답변/묵음 발화 처리 - /talk 중 context의 의도, NE, 대화상태 등 정보 갱신 - 시나리오 파라미터로 이동 후, context에 있는 객체는 초기화 - 의도맵 내 의도 스케줄에따라 의도 활성화 여부 체크, 활성화된 의도라면 의도에 매핑된 시나리오 수행 - 슬록 필링 단계에 끼어들기가 세팅되어 있다면, 새로운 의도가 식별되었을 때 새로운 시나리오로 이동 - 답변이 reconfirm이면 suggestion 정보를 context에 저장하고, reconfirm 진행 (정확히는 모르겠음. 코드 분석 필요) ### 대화엔진 구조 ![](https://i.imgur.com/8Qlxju3.png =600x600) ### 봇 구축 프로세스 - premade용 봇 생성 - 고객사 정보 등록 - 고객사용 봇 생성 - premade된 봇을 복사해 사용하거나 blank부터 생성해 사용 - 서비스할 채널 정보 등록 - 시나리오 저작 - 봇 학습 및 배포 - 서비스 개시 ## AIBOT2.0 추가된 기능 - 멀티 슬롯 필링 - Auto 슬롯 필링 지원, 한번에 여러개 슬롯 필링 지원 - Confirm 의도 기능 - 긍정, 부정, 중립에 대한 예시 문장을 등록하고 학습하는 형식으로, confirm 의도를 추론 - TTS 재생 기능 - 답변, 슬롯필링 노드 내 발화 문구 TTS 재생 - SMS, 콜종료, 호전환, GOTO 노드 추가 - 되묻기 및 끼어들기 기능 - 대화 진행 중 새로운 의도가 파악되면 새로운 시나리오 수행 - 원래 의도로 다시 돌아올 것인지, 아닌지 선택 가능 - 신규 의도 완료 후, 원래 의도로 다시 돌아올 것인지 되묻기 수행 - 시스템에서 사전에 정의한 개체 - 날짜, 시간, 인원, 숫자 등 17종 - Premade / Preset 템플릿 기능 - 특정 도메인에 대한 시나리오 샘플 - premade: 미리 특정 도메인에 대한 bot template을 복사하는 기능 - preset: 미리 정의된 시나리오 template을 복하는 기능 - SMS 노드에서 사용할 SMS 템플릿 제공 - 일정시간 동안 고객 묵음이 들어오면 호전환/콜종료 처리 기능 - 자유발화 기능 - 슬롯필링 노드에서 '길게 말하기'를 체크하면, 고객 답변을 기다리는 시간이 길어짐. - 일괄 대화 검증 - 질문 목록 엑셀을 업로드하면, 의도 및 score 값을 엑셀로 다시 내려 받을 수 있으 - 외부 api 연동 - CMS를 통해 open api meta 정보를 DB에 등록 - 신규 개발한 open api adapter를 통해 외부 open api 호출 - 의도 schedule에 따른 의도 활성화 - DTMF 추가 - TTS digit, pause 태그 추가 - CMS 봇 목록 노출 UI 개선 - Solr 품질 개선 - 구축형을 위한 패키징 - OS/MW 설치, 배포, config 설정 방법 가이드 정리 ## AIBOT2.0 향후 고도화 과제 - 의도 추론 고도화 - 끊어쳐진 의도 추론 - DL 기반 의도 추론 - 멀티 의도 처리 - 여러개의 의도가 파악되면, config에따라 1개의 의도 수행 후 다음 의도 이어서 수행 - NE 추론 고도화 - 복합/Set/Collection NE - 주소 개체명 추출 - 인명 개체 추출 정확도 향상 - 3rd party NLU 연동 - 의도 추론 - 개체인식 - 외부 api 테스트, 통계 기능/화면 개발 - API 실시간 테스트 - API 연동 이력 및 통계 화면 - 서비스 container화 - sync view (미정) - 보이스봇과 화면을 동시 제공 - 시나리오 저작도구에서 화면 설계 지원 ## AIBOT2.0 질문사항 및 2차 세미나 주제 ### 1차 세미나 후 작성 - START API에서 최초 봇 인사말 생성하는지? - Talk API호출 시, 발화와 의도맵 매핑 방법 - 대화흐름제어(탐침?의도컨펌? 시나리오점프??)와 시나리어 흐름제어 정의/방법/ 판단 조건 설정? - 시나리오 진행중일 때 대화흐름제어의 역할 - Redis에 저장되는 Context Data 구조 - 해당 Context를 이용하여 할수 있는 일? - NE변경하여 이전 시나리오 재진입이 되는가? - Context기반 요약 정보 생성 가능한가? - 재발화요청 수행? - 의도 파악 전에 발화/파악된 NE를 보관하고 시나리오 진입 시 jump하거나 컨펌할 수 있는지? - 대화흐름제어 - 의도추론 - 묵음시 되묻기 문장이, CMS에 설정된 재질문 처리문장이 나가지 않고, 진행중이던 슬롯필링 문장이 한번 더 나가는 식으로 구성 가능? - 식별한 개체가 시나리오답변과 다를 때의 여러 케이스(1. 다른의도로 jump, 2.개체를사용자가 잘못 말함 3. 상담사연결요청 ..) 가 보여준 flow내에서 모두 처리 가능한지? - 되묻기 탐침이 실행될 때, 추천의도를 자세히 발화할 순 없는지(낮은 컨피던스 이나 발화에서 추출된 경우. 발화한 lob를 연계한 의도) - Multi 의도/ 끊어치기 대화의도 추론 ==> Flow 고도화 필요함 - 대화흐름 제어 내에서 스코어링과 우선순위 설정 --> 로직 처리 필요 - 시나리오 진행시 ia 응답 delay 시 대화 처리 로직 - 제공하는 시스템 객체의종류는 무엇이 있는가 ? Pattern이 아닌 DL/ML기반으로 제공,확장 가능한가?다른 Global 에서는? - --> 날짜(일시/연도/월/일/요일/시간/날짜/시간/분), 고객명, 컨펌, 차량번호, 숫자, 전화번호, 카드번호, 사업자 등록번호, 피플카운트, 오전오후 - 긍정/부정/ 중립을 판단을 통해 Confirm 할때 ML 학습 데이터는?? - 모든 발화에 대해서 식별과정은 거침 - entity 구조는 hierarchy가 있는가? - vux 제어 흐름 - 픽스된 형태 ### 추가 작성 - AIBOT 2.0 B2B 챗봇 딜러버리 lessons learned - SaaS, 구축형, gcloud 등 - 레가시 타임아웃 시 처리 방식 - end에서 수행하는 후처리는 어떤 게 있는지? - 감성분석, 목소리인증 등의 event는 어떻게 처리되는 것인지? - 3rd party NLU 연동 시 자동완성 기능은 어떻게 처리되는 것인지? - confirm은 의도처럼 처리되는데, confirm NE는 어떤 패턴으로 추출되는 것인지? - context 정보를 이용하는 예시