<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>io_</title>
        <link>https://velog.io/</link>
        <description>병아리 개발자</description>
        <lastBuildDate>Tue, 16 Jul 2024 12:53:16 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. io_. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/io_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[개인용 로컬 AI 서버 만들기 (feat. Docker Compose)]]></title>
            <link>https://velog.io/@io_/%EA%B0%9C%EC%9D%B8-%EB%A1%9C%EC%BB%AC-AI-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-feat.-Docker-Compose</link>
            <guid>https://velog.io/@io_/%EA%B0%9C%EC%9D%B8-%EB%A1%9C%EC%BB%AC-AI-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-feat.-Docker-Compose</guid>
            <pubDate>Tue, 16 Jul 2024 12:53:16 GMT</pubDate>
            <description><![CDATA[<h1 id="🎯-목표">🎯 목표</h1>
<ol>
<li>로컬 LLM 서버 구축</li>
<li>ollama-webui 를 사용하여 ChatGPT 와 같은 GUI 환경 구축<h2 id="⚙️-환경-정보">⚙️ 환경 정보</h2>
내 개인 홈서버 환경은 다음과 같다.</li>
</ol>
<table>
<thead>
<tr>
<th>정보</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>CPU</td>
<td>Intel(R) Core(TM) i7-4578U CPU @ 3.00GHz</td>
</tr>
<tr>
<td>RAM</td>
<td>16GB</td>
</tr>
<tr>
<td>OS</td>
<td>Ubuntu 24.04 LTS</td>
</tr>
</tbody></table>
<p>내 환경은 맥미니 2014 버전에 OS 만 우분투로 돌린다.
거대 언어 모델을 사용하기에는 굉장히 힘들다.
외장그래픽 카드도 없어, CPU로만 돌려볼려고 한다.
어쨌든 돌아가는 것에 의의를 두고 진행해보자.</p>
<h2 id="🤷-사전-조건">🤷 사전 조건</h2>
<ol>
<li>Linux 환경</li>
<li>Docker 설치</li>
<li>Git 설치<h1 id="✨-1-git-clone">✨ 1. Git Clone</h1>
작업을 시작하기 전, 도커 컴포즈 파일이 필요하다.
<code>valiantlynx</code> 라는 github 유저가 미리 만들어 둔 것이 있으니, 사용해보도록 하자.
다만, GPU 환경이라면 최초 환경 구축 방법이 약간 다르다.
아래 깃허브 주소 내, README 에 있는 GPU 지원 방법을 따라가면 된다.
<a href="https://github.com/valiantlynx/ollama-docker">valiantlynx/ollama-docker</a></li>
</ol>
<h3 id="📌-1-리눅스에서-작업할-부모-디렉토리-위치로-이동">📌 1. 리눅스에서 작업할 부모 디렉토리 위치로 이동</h3>
<pre><code class="language-shell">cd &lt;git clone 할 디렉토리 위치&gt;

ex) cd /docker</code></pre>
<h3 id="📌-2-git-clone">📌 2. Git Clone</h3>
<pre><code class="language-shell">git clone https://github.com/valiantlynx/ollama-docker.git</code></pre>
<h3 id="📌-3-clone-한-디렉토리-내부로-이동">📌 3. clone 한 디렉토리 내부로 이동</h3>
<pre><code class="language-shell">cd ./ollama-docker</code></pre>
<h1 id="✨-2-docker-composeyml-수정">✨ 2. docker-Compose.yml 수정</h1>
<p>다른 Docker 가 올라간 것이 없다면, 이 단계를 건너뛰어도 상관 없다.</p>
<h3 id="📌-1-편집기를-연다">📌 1. 편집기를 연다.</h3>
<p>CPU 환경과 GPU 환경이 각기 다르니 파일명에 주의해서 열자.</p>
<pre><code class="language-shell"># CPU 환경으로 진행할 경우
vi ./docker-compose.yml

# GPU 환경으로 진행할 경우
vi ./docker-compose-ollama-gpu.yaml

# i 를 눌러 수정모드 진입</code></pre>
<h3 id="📌-2-docker-network-bridge를-기존에-사용하고-있다면-수정해주자">📌 2. Docker network Bridge를 기존에 사용하고 있다면 수정해주자.</h3>
<p>해당 내용을 모르거나, 기존에 사용하지 않았다면 넘어가자.
모든 network 환경 값을 다음과 같이 변경한다.
service가 3개니, 3번 변경해주어야 한다. (<code>app</code>, <code>ollama</code>, <code>ollama-webui</code>)</p>
<pre><code class="language-yml">networks:
    - &lt;당신이 사용하는 브릿지명&gt;

ex)
networks:
    - npm</code></pre>
<pre><code class="language-yml">networks:
  &lt;당신이 사용하는 브릿지명&gt;:
    external:
      name: &lt;당신이 사용하는 브릿지명&gt;

ex)
networks:
  npm:
    external:
      name: npm</code></pre>
<h3 id="📌-3-ollama-webui-서비스-외부포트-변경">📌 3. ollama-webui 서비스 외부포트 변경</h3>
<p>이 항목은 필요한 사람만 진행하면 된다.
ollama-webui 는 ChatGPT 와 비슷한 GUI 환경을 제공해준다.
해당 서비스의 외부포트는 현재 8080 을 사용 중이라, 다른 서비스가 올라가 있다면 포트 충돌이 발생할 수 있다.</p>
<pre><code class="language-yml"># 변경해야 될 것
version: &#39;3.8&#39;
services:
    ollama-webui:
        ports:
            - 8080:8080

# 위에서 왼쪽 8080을 아래와 같이 변경한다.
ports:
    - &lt;원하는 외부포트 번호&gt;:8080

ex)
ports:
    - 9040:8080</code></pre>
<h3 id="📌-4-저장하고-나오기">📌 4. 저장하고 나오기</h3>
<pre><code># 1. esc 를 누르고
# 2. :wq 를 입력하고 엔터</code></pre><h1 id="✨-3-docker-compose-실행">✨ 3. Docker Compose 실행</h1>
<p>이제 Docker 컨테이너를 생성해보자</p>
<pre><code class="language-shell"># CPU 환경으로 진행할 경우
sudo docker compose -f ./docker-compose.yml up -d

# GPU 환경으로 진행할 경우
sudo docker compose -f ./docker-compose-ollama-gpu.yml up -d</code></pre>
<p>아래처럼 서비스 3개가 <code>Started</code>로 나온다면 정상이다.</p>
<pre><code class="language-shell">$ /docker/ollama-docker$ sudo docker compose -f ./docker-compose.yml up -d
WARN[0000] network npm: network.external.name is deprecated. Please set network.name with external: true
[+] Running 1/1
 ✔ ollama Pulled                                                                                                                                                                                                              2.1s
[+] Building 0.0s (0/0)
[+] Running 3/3
 ✔ Container ollama               Started                                                                                                                                                                                     0.4s
 ✔ Container ollama-webui         Started                                                                                                                                                                                     0.7s
 ✔ Container ollama-docker-app-1  Started   </code></pre>
<p>문제가 생겨서 Docker 컨테이너를 내리고 싶다면 아래처럼 하면 된다.</p>
<pre><code class="language-shell"># CPU 환경으로 진행할 경우
sudo docker compose -f ./docker-compose.yml down

# GPU 환경으로 진행할 경우
sudo docker compose -f ./docker-compose-ollama-gpu.yml down</code></pre>
<blockquote>
<h3 id="번외-docker-compose-와-docker-compose-차이">번외) docker-compose 와 docker compose 차이</h3>
<p>기존 python 으로 작성되었던 docker-compose 에 기능을 추가하여 go 언어로 작성한 것이 docker compose 이다.</p>
<p>결론만 본다면 다음과 같다.
docker-compose 는 V1, 즉 이전 버전의 Docker Compose
docker compose 는 V2, 신규 버전이다.</p>
<p>Compose V1과 달리 Compose V2는 Docker CLI 플랫폼에 통합되었으며,
2023년 7월부터 Compose V1은 지원이 종료되었다.
<a href="https://docs.docker.com/compose/migrate/">docker document</a></p>
<p>따라서 앞으로는 docker compose 를 사용하도록 하자.</p>
</blockquote>
<h1 id="✨-4-언어모델-선택">✨ 4. 언어모델 선택</h1>
<p>언어모델을 설치하기 전, 아래 주소로 들어가 사용할 언어모델을 선택하자.
<a href="https://ollama.com/library">https://ollama.com/library</a></p>
<p>개인 환경에 따라 선택하면 된다.
내 환경에서는 <code>qwen2</code>의 에서 <code>1.5b</code> 가 그나마 제일 팬 소음이 덜한 것 같다.
CPU로 돌릴거면 <code>parameters</code> 항목이 <code>2.0b</code> 이하인 것으로 하는게 좋을 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/9df0f02f-1d0c-4152-bd0e-c3d20beefa59/image.png" alt=""></p>
<p>사용할 모델을 결정했다면 아래 사진에 표시해둔 버튼을 누르고 다른 곳에 붙여넣기 해두자.
나중에 언어모델 설치 시 사용할 것이다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/352fcef8-d60c-4b0a-815c-d85d3c38ba4f/image.png" alt=""></p>
<h1 id="✨-5-언어모델-설치">✨ 5. 언어모델 설치</h1>
<p>언어모델은 ollama 도커 컨테이너 내부에 설치한다.
이전 compose.yml 파일에서 ollama 서비스의 컨테이너명을 ollama로 지정해두었다.
컨테이너 내부로 진입해 <code>ollama</code> 명령어를 사용해 모델을 설치한다.</p>
<h3 id="📌-1-bash를-사용해-컨테이너-내부로-진입">📌 1. bash를 사용해 컨테이너 내부로 진입</h3>
<pre><code class="language-shell">docker exec -it ollama /bin/bash</code></pre>
<h3 id="📌-2-컨테이너-내부에서-ollama-명령어로-언어모델-설치">📌 2. 컨테이너 내부에서 ollama 명령어로 언어모델 설치</h3>
<p><code>4</code>번에서 복사해둔 텍스트를 붙여넣기 하면 된다.</p>
<pre><code class="language-shell">ollama run qwen2:1.5b</code></pre>
<p>ollama run 은 언어모델을 실행하는데, 언어모델이 없으면 자동으로 받는다.
언어모델이 다 설치되면 이후 자동으로 실행한다.</p>
<p>이것 말고 단순 설치만 하려면 아래처럼 하면 된다.</p>
<pre><code class="language-shell">ollama pull qwen2:1.5b</code></pre>
<h3 id="📌-3-언어모델-설치-대기">📌 3. 언어모델 설치 대기</h3>
<p>인터넷 환경에 따라 설치가 오래될 수 있다.
아래처럼 success 가 뜨면 성공이다.</p>
<pre><code class="language-shell">root@6b2e5bb34042:/# ollama run qwen2:1.5b
pulling manifest
pulling 405b56374e02... 100% ▕███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 934 MB
pulling 62fbfd9ed093... 100% ▕███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏  182 B
pulling c156170b718e... 100% ▕███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏  11 KB
pulling f02dd72bb242... 100% ▕███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏   59 B
pulling c9f5e9ffbc5f... 100% ▕███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏  485 B
verifying sha256 digest
writing manifest
removing any unused layers
success
&gt;&gt;&gt;</code></pre>
<h1 id="✨-6-cli로-언어모델-사용하기">✨ 6. CLI로 언어모델 사용하기</h1>
<p>여기까지 왔으면 목표의 대부분은 성공한 것이다.</p>
<p>아래처럼 <code>&gt;&gt;&gt;</code> 이 표시되면 CLI 입력모드 상태이다.
이제 타이핑하여 언어모델에게 물어보면 된다.</p>
<pre><code class="language-shell">root@6b2e5bb34042:/# ollama run qwen2:1.5b
&gt;&gt;&gt; </code></pre>
<p>hello! 라고 입력해보았다.</p>
<pre><code class="language-shell">root@6b2e5bb34042:/# ollama run qwen2:1.5b
&gt;&gt;&gt; hello!
Hello! How can I help you today? Are you looking for information or assistance with a particular topic? Please let me know and I&#39;ll do my best to assist you.</code></pre>
<p>이어서 안녕! 이라고 입력해보았다.</p>
<pre><code class="language-shell">&gt;&gt;&gt; 안녕!
안녕하세요! 어떻게 도와드릴까요?</code></pre>
<p>입력 모드를 종료하려면 <code>Ctrl</code> + <code>D</code> 를 누르자.</p>
<p>그리고 다음 단계 진행을 위해, ollama 컨테이너 바깥으로 나오도록 하자.</p>
<h1 id="✨-7-gui로-언어모델-사용하기">✨ 7. GUI로 언어모델 사용하기</h1>
<p>내부 IP 주소, 혹은 외부 IP 주소는 당연히 알고 있을 것이라 생각한다.</p>
<h4 id="📌-1-홈서버-로컬-ip">📌 1. 홈서버 로컬 IP</h4>
<p>로컬 IP가 기억이 나지 않는다면, 아래 명령어를 입력한다.</p>
<pre><code class="language-shell">ip -4 addr show | grep -oP &#39;(?&lt;=inet\s)\d+(\.\d+){3}&#39; | grep -v &#39;127.0.0.1&#39;</code></pre>
<p>외부 IP로 접속하려면 공유기 포트포워딩을 하던지, Nginx 등으로 리버스프록시 환경 구성을 해야한다.
외부 IP 부분은 생략.</p>
<h4 id="📌-2-ollama-webui-서비스-포트-번호">📌 2. ollama-webui 서비스 포트 번호</h4>
<p>위에서 <code>✨ 2. Docker Compose 파일 수정</code> 항목을 수정할 때로 되돌아가 보자.
ollama-webui 서비스의 외부 포트 변경을 하라고 했던 것이 기억 날 것이다.</p>
<pre><code class="language-yml">ports:
    - &lt;원하는 외부포트 번호&gt;:8080</code></pre>
<h4 id="📌-3-브라우저-접속">📌 3. 브라우저 접속</h4>
<p>이제 브라우저 주소창에 다음과 같이 입력한다.
다른 PC에서 작업한다면, 꼭 홈서버와 WIFI 나 LAN 연결이 되어있어야 한다.</p>
<pre><code>http://&lt;당신의 서버 로컬서버 IP&gt;:&lt;당신이 입력했던 외부포트 번호&gt;</code></pre><p>이제 다음과 같은 채팅 UI가 우리를 반겨준다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/fce28b95-4625-4b85-8048-0504fc0eeb87/image.png" alt=""></p>
<p>사실 레퍼 찾아보면서 webui 처음 접속하면 사용자 등록부터 해야한다고 다른 글에서 봤다.
근데 <code>valiantlynx</code> 가 그 작업은 안하도록 사용자 등록까지 다 끝내놓은 것 같다.</p>
<h4 id="📌-4-언어모델-가져오기">📌 4. 언어모델 가져오기</h4>
<p>이제, 방금 전에 ollma 내부 컨테이너에 설치했던 언어모델을 가져오도록 하자.</p>
<p>좌측 하단의 <code>User</code>를 눌러 컨텍스트 메뉴를 연다.
<code>설정</code> 버튼을 눌러 팝업창을 호출하자.
<img src="https://velog.velcdn.com/images/io_/post/3b2c7ff8-6b1e-4f0f-b7bc-697f50766cd3/image.png" alt=""></p>
<p>팝업창이 열리면 좌측 메뉴 중 <code>관리자 설정</code> 항목을 누른다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/39f38aad-3c55-48d2-a7ff-01c560f47838/image.png" alt=""></p>
<p>이제 <code>관리자 패널</code> 이 열리면 <code>연결</code> 탭을 누른다.
지금은 스크린샷 찍으려고 좌우 폭을 줄여놔서 상단에 메뉴탭이 보이는데,
정상적인 상태면 좌측에 메뉴탭이 나올 것이다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/37d099cb-9729-48db-b4ca-242794ee7a31/image.png" alt=""></p>
<p>Ollama API 항목을 수정한다.
해당 항목에 넣을 값은 다시 docker-compose.yml 로 돌아가 확인할 수 있다.</p>
<pre><code class="language-yml">...
version: &#39;3.8&#39;
    services:
        ...
        ollama-webui:
            ...
            environment:
                - OLLAMA_BASE_URLS=http://host.docker.internal:7869
                ...</code></pre>
<p>바로 <code>OLLAMA_BASE_URLS</code> 키 값을 입력해주면 된다.</p>
<p>입력하고, 좌측 하단의 저장 버튼을 누르자.</p>
<p>이제 <code>모델</code> 탭으로 넘어간다.
이전 탭에서 저장까지 눌렀다면 Ollama 모델 관리 항목이 입력했던 키값으로 나올 것이다.
<code>http://host.docker.internal:7869</code></p>
<p>다음으로, <code>Ollama.com에서 모델 가져오기(pull)</code> 항목에 언어모델명을 입력하면, 설치했던 언어모델을 webui 로 가져올 수 있다.</p>
<p>작성한대로 동일하게 따라왔다면 <code>qwen2:1.5b</code> 를 입력하고 우측의 다운로드 버튼을 눌러 가져온다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/d0d37e73-7637-44f1-93da-3a55e0694495/image.png" alt=""></p>
<h4 id="📌-5-언어모델-기본값-설정">📌 5. 언어모델 기본값 설정</h4>
<p>좌측 메뉴 중 <code>새 채팅</code> 버튼을 눌러 메인 화면으로 돌아오자.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/4d6f9616-881a-45af-a005-16197289869e/image.png" alt=""></p>
<p>나는 지금 언어모델 기본 값을 설정해놔서 언어모델명이 바로 화면상에 표시된다.
우선 아래 화살표 버튼을 눌러 사용할 언어모델을 선택해두자.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/62efe9a8-ba22-41a7-ab70-34d32e3369ae/image.png" alt=""></p>
<h4 id="📌-6-언어모델-사용">📌 6. 언어모델 사용</h4>
<p>이제 하단의 <code>메시지 보내기</code> 입력창에 ChatGPT를 사용하는 것처럼 LLM 과 대화하면 된다.</p>
<h1 id="✨-8-gui-사용-예시">✨ 8. GUI 사용 예시</h1>
<p><img src="https://velog.velcdn.com/images/io_/post/3f0e315c-1a43-4128-98fc-0beb8f8e9ccc/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Toast UI Editor 이미지 업로드]]></title>
            <link>https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</link>
            <guid>https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</guid>
            <pubDate>Sun, 24 Jul 2022 14:55:12 GMT</pubDate>
            <description><![CDATA[<h1 id="🎯-목표">🎯 목표</h1>
<p>Spring Boots를 사용해 Toast UI Editor에 이미지 업로드</p>
<h1 id="🔪-시작">🔪 시작</h1>
<p>어느날 미래에서 삽질하고 있을 나를 위해...</p>
<p>현재 국비지원 4개월차이고 팀 프로젝트를 시작하게 되었습니다.
CRUD 기능이 들어가려면 에디터가 필요할거 같았습니다.
하지만 에디터의 기능 구현까지는 힘들거같았고,
API를 사용하기로 하고 검색 많은 검색 끝에 두 가지로 압축되었습니다.</p>
<blockquote>
<ul>
<li>첫 번째 : 🔗<a href="https://github.com/naver/smarteditor2">네이버 스마트 에디터 Github</a></li>
</ul>
</blockquote>
<ul>
<li>두 번째 : 🔗<a href="https://ui.toast.com/tui-editor">TOAST UI Editor Github</a></li>
</ul>
<p>결국에는 TOAST UI Editor로 사용하기로 했는데, 그 이유는 다음과 같습니다.</p>
<ol>
<li>네이버 에디터 2.0은 22년 11월 30일에 서비스 종료 (참고 : <a href="https://m.blog.naver.com/blogpeople/222824126104">네이버블로그</a>) 한다는 것.</li>
<li>벨로그에 글을 올리다보니 마크다운 문법이 편해진 점. (네이버는 마크다운 지원 안함)</li>
</ol>
<h1 id="📖-toast-ui-editor">📖 Toast UI Editor</h1>
<h2 id="📌-에디터-기능-구현하기">📌 에디터 기능 구현하기</h2>
<p>기능 구현을 하기 위해 TOAST UI Editor 공식 홈페이지에서 제공하는 예제를 참고했습니다.</p>
<blockquote>
<p>🔗<a href="https://nhn.github.io/tui.editor/latest/tutorial-example01-editor-basic">Basic Editor Example</a></p>
</blockquote>
<p>먼저 CDN으로 Editor를 구성하는 <code>JS</code>와 <code>CSS</code>를 가져와줍니다.</p>
<pre><code class="language-js">&lt;!-- TOAST UI Editor CDN(JS) --&gt;
&lt;script src=&quot;https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js&quot;&gt;&lt;/script&gt;
&lt;!-- TOAST UI Editor CDN(CSS) --&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://uicdn.toast.com/editor/latest/toastui-editor.min.css&quot; /&gt;</code></pre>
<p>다음은 HTML로 Editor로 변경할 컨테이너 요소를 선언해줍니다.</p>
<pre><code class="language-html">&lt;div id=&quot;editor&quot;&gt;&lt;/div&gt;</code></pre>
<p>마지막은 JavaScript로 Editor에 옵션을 추가합니다.</p>
<pre><code class="language-js">const editor = new toastui.Editor({
    el: document.querySelector(&#39;#editor&#39;),
    previewStyle: &#39;vertical&#39;,
    height: &#39;500px&#39;,
    initialValue: content
});</code></pre>
<h3 id="😀-실행해보면-다음과-같이-에디터가-출력됩니다">😀 실행해보면 다음과 같이 에디터가 출력됩니다.</h3>
<p><img src="https://velog.velcdn.com/images/io_/post/667867b8-253e-4331-9cf7-9a3087cf6d5e/image.png" alt=""></p>
<hr>
<h2 id="📌-에디터의-옵션">📌 에디터의 옵션</h2>
<p>JavaScript의 에디터 초기 옵션들을 확인해 봅시다.
아래 공식문서의 내용들을 참고했습니다.</p>
<blockquote>
<p>🔗<a href="https://nhn.github.io/tui.editor/latest/ToastUIEditorCore">Toast UI Editor Core Options</a></p>
</blockquote>
<ul>
<li><code>EL</code><ul>
<li>컨테이너 요소 선택자.</li>
<li>Javascript로 불러온 에디터의 옵션을 컨테이너 요소로 선언된 <code>&lt;div&gt;</code> 태그에 부여합니다.</li>
</ul>
</li>
<li><code>previewStyle</code><ul>
<li>에디터의 세로의 크기를 지정합니다.</li>
</ul>
</li>
<li><code>height</code><ul>
<li>에디터의 높이 크기를 지정합니다.</li>
</ul>
</li>
<li><code>initialValue</code><ul>
<li>에디터의 초기 입력 값을 지정합니다.</li>
</ul>
</li>
</ul>
<p>개인적으로는 에디터에서 글 작성시 형광펜처럼 작성 위치가 표시되는 것이 싫어서 아래 옵션까지 추가해주겠습니다.</p>
<ul>
<li><code>previewHighlight</code><ul>
<li>에디터의 커서 위치에 해당하는 항목 미리보기 요소 강조 표시</li>
</ul>
</li>
</ul>
<p>해당 옵션이 헷갈리면 아래 사진을 참고하세요.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/5b2d4487-fe43-4e55-9220-66ac4796defa/image.png" alt=""></p>
<hr>
<h2 id="📌-여기까지의-코드">📌 여기까지의 코드</h2>
<p>이클립스 IDE 에서 JSP 파일로 작성했습니다.</p>
<blockquote>
<h3 id="eclipse-ide-환경정보">Eclipse IDE 환경정보</h3>
<p>Version: 2022-03 (4.23.0)
Build id: 20220310-1457</p>
</blockquote>
<pre><code class="language-html">&lt;%@ page language=&quot;java&quot; contentType=&quot;text/html; charset=UTF-8&quot;
    pageEncoding=&quot;UTF-8&quot;%&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;title&gt;Insert title here&lt;/title&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://uicdn.toast.com/editor/latest/toastui-editor.min.css&quot; /&gt;
&lt;script src=&quot;https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;editor&quot;&gt;&lt;/div&gt;
&lt;script&gt;
const editor = new toastui.Editor({
    el: document.querySelector(&#39;#editor&#39;),
    previewStyle: &#39;vertical&#39;,
    previewHighlight: false,
    height: &#39;500px&#39;,
    initialValue: &#39;&#39;
});
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre>
<hr>
<h1 id="📖-toast-ui-editor-content-출력하기">📖 Toast UI Editor content 출력하기</h1>
<p>공식 문서의 인스턴스 메서드 항목에서 확인할 수 있습니다.</p>
<blockquote>
<p>🔗<a href="https://nhn.github.io/tui.editor/latest/ToastUIEditorCore#getHTML">Toast UI Editor Core Instance Methods</a></p>
</blockquote>
<pre><code class="language-js">editor.getHTML();</code></pre>
<h2 id="📌-주의점">📌 주의점</h2>
<p>여기서 주의점이 있습니다.
Editor 3.x 버전과 Editor 2.x 버전의 <code>getHTML()</code> 메서드 사용 방법이 틀립니다.</p>
<ul>
<li><p>🔗<a href="https://solbel.tistory.com/2116">Toast UI Editor getHTML/setHTML is not defined 에러 해결 방법
</a></p>
</li>
<li><p>Editor 3.x 버전 : getHTML()</p>
</li>
<li><p>Editor 2.x 버전 : getHtml()</p>
</li>
</ul>
<p>초반에 공식문서를 확인 안하고 구글링으로 가져온 코드를 적용했을때 <code>getHtml is not defined</code> 가 발생해서 당황했는데 저런 문제였습니다...</p>
<hr>
<h2 id="📌-브라우저에서-content-값-확인하기">📌 브라우저에서 content 값 확인하기</h2>
<p>방법은 두 가지가 있습니다.</p>
<ol>
<li>div 태그를 추가하고 선택자를 통해 해당 태그에 값을 입력하는 방법.</li>
</ol>
<pre><code class="language-js">document.querySelector(&#39;#contents&#39;).insertAdjacentHTML(&#39;afterbegin&#39;, editor.getHTML());</code></pre>
<ol start="2">
<li>콘솔창에 출력하는 방법<pre><code class="language-js">console.log(editor.getHTML());</code></pre>
</li>
</ol>
<hr>
<h1 id="📖-toast-ui-editor-이미지-업로드">📖 Toast UI Editor 이미지 업로드</h1>
<p>드디어 이번 포스팅의 목표인 Toast UI Editor를 사용해 이미지를 업로드 해보겠습니다.
기본적으로 Toast UI Editor는 이미지를 업로드기능을 제공합니다.
단, 별다른 설정을 해주지 않으면 base64 형식으로 에디터에 입력됩니다.</p>
<h2 id="📌-base64-의-단점">📌 Base64 의 단점?</h2>
<p>문제는 base64 형식은 해상도가 올라갈수록 글자수가 어마어마하게 늘어난다는 점인데요.</p>
<p>아래 참고사진을 확인해보세요.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/378291fb-2d55-48c9-b43d-5923c65d3c72/image.png" alt=""></p>
<p>단순히 이미지 하나만 넣었을 뿐인데 벌써 스크롤이 어마무시하게 생겼습니다.
글자는 몇 자인지 글자수세기 사이트에서 확인해보면?</p>
<p><img src="https://velog.velcdn.com/images/io_/post/dc581a3a-b632-4a0c-91b9-f373787a72b5/image.png" alt=""></p>
<p>고작 311 x 162 사진이 7536 자 입니다.</p>
<p>1920 x 1080 사진을 업로드하면 15만자 가까이 나오기도 합니다.
이러면 사진을 여러 장 업로드하면 DB에 다 업로드되지 않겠죠.</p>
<hr>
<h2 id="📌-hooks-옵션-사용하기">📌 hooks 옵션 사용하기</h2>
<p>이럴 때를 대비해 hooks 옵션을 사용합니다.</p>
<blockquote>
<p>🔗<a href="https://nhn.github.io/tui.editor/latest/ToastUIEditorCore">Toast UI Editor Core Options</a></p>
</blockquote>
<ul>
<li><code>hooks</code><ul>
<li><code>addImageBlobHook</code> 를 속성으로 가지고 있습니다.</li>
<li>에디터에 업로드되는 이미지를 잠시 가져가 처리를 하고 다시 리턴해줄 수 있습니다.</li>
</ul>
</li>
</ul>
<p>목표 : Base64 ➡ URL로 바꿔 에디터에 표시하기</p>
<hr>
<h2 id="📌-순서도">📌 순서도</h2>
<p>정확하지는 않겠지만 제가 이해한 바는 아래와 같습니다.
<img src="https://velog.velcdn.com/images/io_/post/7e3a620b-2a35-441f-ad6d-96a448dd150d/image.png" alt=""></p>
<hr>
<h2 id="📌-프로그램-구조">📌 프로그램 구조</h2>
<p>프로그램의 구조는 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/io_/post/cb0516df-4c67-4885-a8a7-fcf166250479/image.png" alt=""></p>
<hr>
<h2 id="📌-코드-살펴보기">📌 코드 살펴보기</h2>
<h3 id="✔-toast-ui-editor---jsp">✔ Toast UI Editor - jsp</h3>
<pre><code class="language-html">&lt;%@ page language=&quot;java&quot; contentType=&quot;text/html; charset=UTF-8&quot;
    pageEncoding=&quot;UTF-8&quot;%&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
    &lt;meta charset=&#39;utf-8&#39;&gt;
    &lt;meta http-equiv=&#39;X-UA-Compatible&#39; content=&#39;IE=edge&#39;&gt;
    &lt;title&gt;Page Title&lt;/title&gt;
    &lt;meta name=&#39;viewport&#39; content=&#39;width=device-width, initial-scale=1&#39;&gt;

    &lt;!-- 디자인 수정용 CSS 추가 --&gt;
    &lt;style&gt;
        #editor {
            /* border : 1px solid; */
            width: 70%;
            margin: 0 auto;
        }
        /* editor content 받을 div태그 스타일 추가. */
        #contents {
            width:50%;
            height: 100px;
            margin: 30px auto;
            border: 1px solid;
        }
        #accordion {
            width: 70%;
            margin: 0 auto;
        }
    &lt;/style&gt;
     &lt;!-- jQuery CDN --&gt;
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js&quot;&gt;&lt;/script&gt;
    &lt;!-- jQuery UI CDN --&gt;
    &lt;script src=&quot;https://code.jquery.com/jquery-3.6.0.min.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://code.jquery.com/ui/1.13.2/jquery-ui.min.js&quot;&gt;&lt;/script&gt;
    &lt;!-- jQuery UI CSS CDN --&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.css&quot;/&gt;
    &lt;!-- codemirror CDN URL --&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.48.4/codemirror.min.css&quot;/&gt;
    &lt;!-- TOAST UI Editor CDN URL(CSS) --&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://uicdn.toast.com/editor/latest/toastui-editor.min.css&quot; /&gt;
    &lt;!-- TOAST UI Editor CDN URL(JS) --&gt;
    &lt;script src=&quot;https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js&quot;&gt;&lt;/script&gt;

    &lt;script&gt;
        $(function() {
            $(&#39;#accordion&#39;).accordion({
                // jQuery UI accordion 본문 축소기능 활성화
                collapsible: true,
                active: false
            });
        });
    &lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;h1&gt; TOAST UI Editor 만들기 &lt;/h1&gt;

    &lt;!-- Markdown을 설명할 accordion이 들어갈 div태그 --&gt;
    &lt;div id=&quot;accordion&quot;&gt;
        &lt;h3&gt;마크다운 편집기가 처음이신가요?&lt;/h3&gt;
        &lt;div&gt;
            &lt;p&gt;다음 내용을 따라오세요.&lt;br&gt;&lt;br&gt;&lt;strong&gt;목차입니다.&lt;/strong&gt;&lt;/p&gt;
            &lt;ol&gt;
                &lt;li&gt;문단 제목&lt;/li&gt;
                &lt;li&gt;굵은 글씨&lt;/li&gt;
                &lt;li&gt;기울이기&lt;/li&gt;
                &lt;li&gt;취소선&lt;/li&gt;
                &lt;li&gt;수평 가로선 생성&lt;/li&gt;
                &lt;li&gt;인용문&lt;/li&gt;
                &lt;li&gt;순서 없는 목차&lt;/li&gt;
                &lt;li&gt;순서 있는 목차&lt;/li&gt;
            &lt;/ol&gt;
        &lt;/div&gt;
    &lt;/div&gt;

    &lt;!-- TOAST UI Editor가 들어갈 div태그 --&gt;
    &lt;div id=&quot;editor&quot;&gt;&lt;/div&gt;

    &lt;!-- TOAST UI Editor 생성 JavaScript 코드 --&gt;
    &lt;script&gt;
        const editor = new toastui.Editor({
            el: document.querySelector(&#39;#editor&#39;),
            previewStyle: &#39;vertical&#39;,
            previewHighlight: false,
            height: &#39;700px&#39;,
            // 사전입력 항목
            initialValue: &#39;# 안녕하세요. 제목입니다.\n### 사전입력 테스트\n본문본문본문\n\n&#39;,
            // 이미지가 Base64 형식으로 입력되는 것 가로채주는 옵션
            hooks: {
                addImageBlobHook: (blob, callback) =&gt; {
                    // blob : Java Script 파일 객체
                    //console.log(blob);

                    const formData = new FormData();
                    formData.append(&#39;image&#39;, blob);

                    let url = &#39;/images/&#39;;
                       $.ajax({
                           type: &#39;POST&#39;,
                           enctype: &#39;multipart/form-data&#39;,
                           url: &#39;/writeTest.do&#39;,
                           data: formData,
                           dataType: &#39;json&#39;,
                           processData: false,
                           contentType: false,
                           cache: false,
                           timeout: 600000,
                           success: function(data) {
                               //console.log(&#39;ajax 이미지 업로드 성공&#39;);
                               url += data.filename;

                               // callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
                            // 형식 : ![대체 텍스트](주소)
                               callback(url, &#39;사진 대체 텍스트 입력&#39;);
                           },
                           error: function(e) {
                               //console.log(&#39;ajax 이미지 업로드 실패&#39;);
                               //console.log(e.abort([statusText]));

                               callback(&#39;image_load_fail&#39;, &#39;사진 대체 텍스트 입력&#39;);
                           }
                       });
                }
            }
        });

        // editor.getHtml()을 사용해서 에디터 내용 수신
        //document.querySelector(&#39;#contents&#39;).insertAdjacentHTML(&#39;afterbegin&#39; ,editor.getHTML());
        // 콘솔창에 표시
        //console.log(editor.getHTML());

    &lt;/script&gt;   
&lt;/body&gt;
&lt;/html&gt;</code></pre>
<h3 id="✔-view-and-file-save-controller---java">✔ View and File Save Controller - java</h3>
<pre><code class="language-java">package com.bookha.controller;

import java.io.File;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;

import com.bookha.imageSave.FileNameModel;

@RestController
public class WriteEditorTestController {

    private String path = &quot;d:/imageSaveStorage/&quot;;

    @RequestMapping(value = &quot;/writeTest.do&quot;, method = RequestMethod.GET)
    public ModelAndView writeTestGet(HttpServletRequest request, HttpServletResponse response) {
        ModelAndView mv = new ModelAndView();
        mv.setViewName(&quot;toast_UI_writer3&quot;);
        return mv;
    }

    @RequestMapping(value = &quot;/writeTest.do&quot;, method = RequestMethod.POST)
    public ModelAndView writeTestPost(@RequestParam(&quot;image&quot;) MultipartFile multi, HttpServletRequest request, HttpServletResponse response) {

        String url = null;
        ModelAndView mv = new ModelAndView();

        try {
            String uploadPath = path;
            String originFilename = multi.getOriginalFilename();
            String extName = originFilename.substring(originFilename.lastIndexOf(&quot;.&quot;), originFilename.length());
            long size = multi.getSize();
            FileNameModel fileNameModel = new FileNameModel();
            String saveFileName = fileNameModel.GenSaveFileName(extName);

            if(!multi.isEmpty()) {
                File file = new File(uploadPath, saveFileName);
                multi.transferTo(file);

                mv.addObject(&quot;filename&quot;, saveFileName);
                mv.addObject(&quot;uploadPath&quot;, file.getAbsolutePath());
                mv.addObject(&quot;url&quot;, uploadPath+saveFileName);
                System.out.println(&quot;url : &quot; + uploadPath+saveFileName);

                mv.setViewName(&quot;image_Url_Json&quot;);
            } else {
                mv.setViewName(&quot;toast_UI_writer3&quot;);
            }
        } catch (Exception e) {
            // TODO: handle exception
            System.out.println(&quot;[Error] &quot; + e.getMessage());
        }
        return mv;
    }
}</code></pre>
<h3 id="✔-file-name-model---java">✔ File Name Model - java</h3>
<pre><code class="language-java">package com.bookha.imageSave;

import java.util.Calendar;

public class FileNameModel {

    public FileNameModel() {
        // TODO Auto-generated constructor stub
    }

    // 메서드 사용 시간 기준으로 파일 이름 생성
    public String GenSaveFileName(String extName) {
        // TODO Auto-generated constructor stub
        String fileName = &quot;&quot;;

        Calendar calendar = Calendar.getInstance();
        fileName += calendar.get(Calendar.YEAR);
        fileName += calendar.get(Calendar.MONTH);
        fileName += calendar.get(Calendar.DATE);
        fileName += calendar.get(Calendar.HOUR);
        fileName += calendar.get(Calendar.MINUTE);
        fileName += calendar.get(Calendar.SECOND);
        fileName += calendar.get(Calendar.MILLISECOND);
        fileName += extName;

        return fileName;
    }
}</code></pre>
<h3 id="✔-image-controller---java">✔ Image Controller - java</h3>
<pre><code class="language-java">package com.bookha.controller;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@RestController
public class ImageController implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // TODO Auto-generated method stub
        registry
            // 이미지 파일의 요청 경로를 지정한다.
            .addResourceHandler(&quot;/images/**&quot;)
            // 이미지 파일을 불러올 로컬 저장소의 위치를 지정한다.
            .addResourceLocations(&quot;file:/d:/imageSaveStorage/&quot;);
    }
}</code></pre>
<h3 id="✔-image-url-json---jsp">✔ image URL json - jsp</h3>
<pre><code class="language-js">&lt;%@ page language=&quot;java&quot; contentType=&quot;text/plain; charset=UTF-8&quot;
    pageEncoding=&quot;UTF-8&quot; trimDirectiveWhitespaces=&quot;true&quot;%&gt;

&lt;%
    request.setCharacterEncoding(&quot;UTF-8&quot;);

    String url = (String)request.getAttribute(&quot;url&quot;);
    String filename = (String)request.getAttribute(&quot;filename&quot;);

    StringBuilder sbHtml = new StringBuilder();

    sbHtml.append(&quot;{&quot;);
    sbHtml.append(&quot;\&quot;url\&quot; : \&quot;&quot;+url+&quot;\&quot;,&quot;);
    sbHtml.append(&quot;\&quot;filename\&quot; : \&quot;&quot;+filename+&quot;\&quot;&quot;);
    sbHtml.append(&quot;}&quot;);

    out.println(sbHtml);
%&gt;</code></pre>
<hr>
<h1 id="🧐-배운-점">🧐 배운 점</h1>
<ul>
<li>동기 / 비동기</li>
<li>File 객체를 전송할 때는 FormData에 append하여 전송한다.</li>
<li>ajax 비동기 처리</li>
<li>callback</li>
<li>promise</li>
<li>async await</li>
</ul>
<hr>
<h1 id="🤔-아쉬운-점">🤔 아쉬운 점</h1>
<ul>
<li><p><code>addImageBlobHook</code> 의 callback을 리턴할 때 ajax는 함수화 시켜서 사용해보고자 했는데 실패했다.
정확히는 ajax를 외부에 함수를 만들어 이미지 URL을 가져오려는데 콘솔에는 정상적으로 출력되는데, 에디터에는 Object Promise로만 리턴되서 결국에는 그냥 hooks 옵션함수 내부에 ajax를 작성했다.
(new Promise, async await 사용해봐도 Object Promise로만 리턴됨...)
나중에 기회가 되면 ajax를 함수로해서 깔끔하게 코드작성 해봐야겠다.</p>
</li>
<li><p>이미지를 업로드하고 가져오는 방법을 몰라 구글링했을때, 블로그에 올라온 글들이 모두 React로 처리하는 방법만 포스팅을 해서 무슨 말인지 알 수가 없었다.
자바스크립트로만 처리하는 방법을 찾으려고 거의 대부분의 시간을 사용한거 같다.
리액트도 공부해야봐겠다...</p>
</li>
<li><p>깃허브로 코드 올리면 포스팅도 조금 더 깔끔해지기도 하고 전체 코드를 올릴 수도 있을텐데 아쉽다.
벨로그가 인용문 접기 기능좀 지원해줬으면 좋겠다...!
몇 일 전에 깃허브 계정은 생성했는데 깃허브도 공부해야봐겠다.</p>
</li>
<li><p>이미지 업로드 하고 삭제했을때 조치는 어떻게 해야할까?
글 수정하면서 사용하지 않는 이미지 파일들(쓰레기 파일들)이 생성되는데 이거 조치하는 방법도 생각해봐야겠다.</p>
</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>