<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ian_lee.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 24 Aug 2023 16:14:46 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ian_lee.log</title>
            <url>https://velog.velcdn.com/images/ian_lee/profile/6ace2618-cb1c-4e3d-b765-22b2a3b91fe7/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ian_lee.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ian_lee" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[119monitoring #6] 병원 상세 정보 열람]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-6-%EB%B3%91%EC%9B%90-%EC%83%81%EC%84%B8-%EC%A0%95%EB%B3%B4-%EC%97%B4%EB%9E%8C</link>
            <guid>https://velog.io/@ian_lee/119monitoring-6-%EB%B3%91%EC%9B%90-%EC%83%81%EC%84%B8-%EC%A0%95%EB%B3%B4-%EC%97%B4%EB%9E%8C</guid>
            <pubDate>Thu, 24 Aug 2023 16:14:46 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>HospitalDetail 데이터 가져오기</p>
</li>
<li><p>상세 정보 열람 기능 구현</p>
</li>
<li><p>검색, 필터링 기능 구현</p>
</li>
<li><p>메모</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 사용자가 목록 뷰에서 특정 병원의 상세 정보를 열람하고, 병원 이름으로 검색하거나 상세 정보로 필터링할 수 있는 기능을 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-hospitaldetail-데이터-가져오기">1. HospitalDetail 데이터 가져오기</h2>
<hr>
<p>우선 백엔드에서 RDS에 존재하는 병원의 상세 정보 테이블을 가져오기 위해 모델과 serializer를 구현하고, 프런트엔드에서 접근할 수 있도록 뷰와 URL을 설정해보겠습니다.</p>
<h3 id="modelspy">models.py</h3>
<pre><code class="language-py">...

class HospitalDetail(models.Model):
    hpid = models.CharField(max_length = 10, primary_key = True)
    post_cdn1 = models.CharField(max_length = 3)
    post_cdn2 = models.CharField(max_length = 3)
    hvec = models.IntegerField()
    hvoc = models.IntegerField()
    hvcc = models.IntegerField()
    hvncc = models.IntegerField()
    hvccc = models.IntegerField()
    hvicc = models.IntegerField()
    hvgc = models.IntegerField()
    duty_hayn = models.CharField(max_length = 1)
    duty_hano = models.IntegerField()
    duty_inf = models.CharField(max_length = 300)
    duty_map_img = models.CharField(max_length = 100)
    duty_eryn = models.CharField(max_length = 1)
    duty_time_1c = models.CharField(max_length = 4)
    duty_time_2c = models.CharField(max_length = 4)
    duty_time_3c = models.CharField(max_length = 4)
    duty_time_4c = models.CharField(max_length = 4)
    duty_time_5c = models.CharField(max_length = 4)
    duty_time_6c = models.CharField(max_length = 4)
    duty_time_7c = models.CharField(max_length = 4)
    duty_time_8c = models.CharField(max_length = 4)
    duty_time_1s = models.CharField(max_length = 4)
    duty_time_2s = models.CharField(max_length = 4)
    duty_time_3s = models.CharField(max_length = 4)
    duty_time_4s = models.CharField(max_length = 4)
    duty_time_5s = models.CharField(max_length = 4)
    duty_time_6s = models.CharField(max_length = 4)
    duty_time_7s = models.CharField(max_length = 4)
    duty_time_8s = models.CharField(max_length = 4)
    mkioskty25 = models.CharField(max_length = 1)
    mkioskty1 = models.CharField(max_length = 1)
    mkioskty2 = models.CharField(max_length = 1)
    mkioskty3 = models.CharField(max_length = 1)
    mkioskty4 = models.CharField(max_length = 1)
    mkioskty5 = models.CharField(max_length = 1)
    mkioskty6 = models.CharField(max_length = 1)
    mkioskty7 = models.CharField(max_length = 1)
    mkioskty8 = models.CharField(max_length = 1)
    mkioskty9 = models.CharField(max_length = 1)
    mkioskty10 = models.CharField(max_length = 1)
    mkioskty11 = models.CharField(max_length = 1)
    dgid_id_name = models.CharField(max_length = 50)
    hpbdn = models.IntegerField()
    hpccuyn = models.IntegerField()
    hpcuyn = models.IntegerField()
    hperyn = models.IntegerField()
    hpgryn = models.IntegerField()
    hpicuyn = models.IntegerField()
    hpnicuyn = models.IntegerField()
    hpopyn = models.IntegerField()

    class Meta:
        db_table = &quot;HOSPITAL_DETAIL_INFO&quot;</code></pre>
<p>사용자(응급구조사)가 필요로 할 수 있는 데이터는 모두 보여주는 것이 맞다고 생각해서 명세서 상에 존재하는 데이터를 모두 넣었기 때문에 컬럼이 상당히 많은 것을 확인할 수 있습니다.
<br></p>
<h3 id="serializerspy">serializers.py</h3>
<pre><code class="language-py">... 

class HospitalDetailSerializer(serializers.ModelSerializer):
    class Meta:
        model = HospitalDetail
        fields = &#39;__all__&#39;</code></pre>
<br>

<h3 id="viewspy">views.py</h3>
<pre><code class="language-py">...

class HospitalDetailView(generics.ListAPIView):
    queryset = HospitalDetail.objects.all()
    serializer_class = HospitalDetailSerializer</code></pre>
<br>

<h3 id="urlspy">urls.py</h3>
<pre><code class="language-py">...

urlpatterns = [
    path(&#39;hospitals/&#39;, HospitalList.as_view()),
    path(&#39;hospital_details/&#39;, HospitalDetailView.as_view()),
]</code></pre>
<br>
<br>

<p>아래 사진과 같이 서버를 실행한 뒤 shell_plus로 총 522개의 상세 정보 객체를 잘 읽어왔고, Postman을 사용해 각각의 객체가 컬럼에 해당하는 값을 잘 가지고 있음을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/c045f5dd-8278-4a9d-acea-87ae944739e0/image.png" alt=""></p>
<p><img src = "https://velog.velcdn.com/images/ian_lee/post/3a170bb8-3eb7-4df4-885a-0e19e7a5c43e/image.png" width = 1000px></img>
<br></p>
<h2 id="2-상세-정보-열람-기능-구현">2. 상세 정보 열람 기능 구현</h2>
<hr>
<p>기존에는 지도 뷰에서 특정 병원에 대한 오버레이 내에 상세 정보 열람 버튼을 만들어서 사용자가 이를 클릭하면 별도의 오버레이 창으로 보여주고자 했습니다. 다만, 회의를 거친 결과 출력해야 하는 정보가 너무 많아서 지도의 대부분을 가린다면 지도를 사용한 의미가 퇴색되기 때문에 정보를 보여주는 것은 목록 뷰에서 전담하는 것으로 로직을 수정했습니다.</p>
<h3 id="hospitallistjs">HospitalList.js</h3>
<pre><code class="language-js">function HospitalList() {
    const [hospitals, setHospitals] = useState([]);
    const [hospitalDetails, setHospitalDetails] = useState([]);

    ...

    useEffect(() =&gt; {
        /...

        // 백엔드 API에서 병원 상세 데이터 fetch
        axios.get(&#39;http://localhost:8000/api/hospital_details/&#39;)
            .then(response =&gt; {
                setHospitalDetails(response.data);
            })
            .catch(error =&gt; {
                console.error(&#39;상세 데이터를 가져오는 데 실패했습니다:&#39;, error);
            });

      ...</code></pre>
<p>우선, 병원 기본 정보와 마찬가지로 axios 통신을 사용해 백엔드에서 데이터를 가져옵니다.
<br></p>
<h3 id="hospitallistviewjs">HospitalListView.js</h3>
<pre><code class="language-js">...

const [expandedDetail, setExpandedDetail] = useState(null);

const toggleDetailExpansion = (hospital) =&gt; {
        if (expandedDetail === hospital || !activeList.includes(hospital)) {
            setExpandedDetail(null);
        } else {
            setExpandedDetail(hospital);
        }
    };

...

return (
        &lt;div&gt;
            &lt;div className=&#39;search-filter-container&#39;&gt;

            &lt;/div&gt;

            &lt;div className=&#39;list-container&#39;&gt;
                {currentHospitals.map(hospital =&gt; {
                    // 선택된 병원의 상세 정보
                    const detailData = hospitalDetails.find(detail =&gt; detail.hpid === hospital.hpid);

                    return (
                    &lt;div key={hospital.hpid} className={`list-item ${expandedDetail === hospital ? &#39;expanded&#39; : &#39;&#39;}`}&gt;
                        &lt;div className=&#39;basic-info&#39;&gt;
                            ...

                            {expandedDetail === hospital &amp;&amp; (
                                &lt;div className=&#39;info-title&#39;&gt;
                                    &lt;h3&gt;상세 정보&lt;/h3&gt;
                                    &lt;p&gt;진료과목: {detailData.dgid_id_name}&lt;/p&gt;

                                    &lt;table className=&#39;info-table&#39;&gt;
                                        &lt;tbody&gt;
                                            &lt;tr&gt;
                                                &lt;td&gt;응급실&lt;/td&gt;
                                                &lt;td&gt;{detailData.hvec}&lt;/td&gt;

                                                &lt;td&gt;응급실&lt;/td&gt;
                                                &lt;td&gt;{detailData.mkioskty25}&lt;/td&gt;

                                                &lt;td&gt;신생아&lt;/td&gt;
                                                &lt;td&gt;{detailData.mkioskty10}&lt;/td&gt;
                                            &lt;/tr&gt;

                                            ...

                                            &lt;tr&gt;
                                                &lt;td&gt;입원실가용여부&lt;/td&gt;
                                                &lt;td&gt;{detailData.duty_hayn === &#39;1&#39; ? &#39;Y&#39; : &#39;N&#39;}&lt;/td&gt;

                                                &lt;td&gt;응급투석&lt;/td&gt;
                                                &lt;td&gt;{detailData.mkioskty7}&lt;/td&gt;

                                                &lt;td&gt;일반중환자실&lt;/td&gt;
                                                &lt;td&gt;{detailData.hpicuyn}&lt;/td&gt;
                                            &lt;/tr&gt;

                                            ...
                                        &lt;/tbody&gt;
                                    &lt;/table&gt;
                                &lt;/div&gt;
                            )}
                        &lt;/div&gt;

                        &lt;div className=&#39;item-link&#39;&gt;
                            &lt;button onClick={() =&gt; onViewChange(&#39;map&#39;, hospital.wgs_84_lat, hospital.wgs_84_lon)}&gt;지도에서 보기&lt;/button&gt;
                            &lt;button className=&#39;detail-button&#39; onClick={() =&gt; toggleDetailExpansion(hospital)}&gt;
                                {expandedDetail === hospital ? &#39;상세정보 닫기&#39; : &#39;상세정보 보기&#39;}
                            &lt;/button&gt;
                            &lt;/button&gt;
                        &lt;/div&gt;
                    &lt;/div&gt;
                    );
                })}
            &lt;/div&gt;</code></pre>
<p>사용자가 &quot;상세정보 보기&quot; 버튼을 누르면 창이 확장되어 정보가 출력되고, 버튼을 한 번 더 누르면 창이 축소되는 토글 기능을 구현하기 위해 expandedDetail이라는 state를 사용했습니다. 출력해야하는 데이터가 많기 때문에 3 * 10 형태의 테이블로 구현했으며, 입원실가용여부, 응급실가용여부 필드의 값이 0 또는 1로 들어와서 조건식을 사용해 Y 또는 N을 출력했습니다.
<br></p>
<p>아래 사진과 같이 상세정보 버튼을 누를 경우 창이 확장되면서 정보가 출력되고, 버튼을 다시 누를 경우 창이 축소되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/378c8751-f632-4b01-821c-1cf2f31e9c5d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/567086f8-c2e6-49da-93e8-a4da8d183b43/image.png" alt="">
<br></p>
<h2 id="3-검색-필터링-기능-구현">3. 검색, 필터링 기능 구현</h2>
<hr>
<p>이제 검색, 필터링 기능을 구현해보겠습니다. 구현 전에는 form 태그와 제출 버튼을 통해 검색, 필터링된 결과를 반환하려고 했는데, 사용자가 긴박한 상황에서 검색 결과를 빠르게 확인할 수 있도록 Javasript의 Array filter를 사용해 구현했습니다. 또한, Array의 find 메서드를 사용해 prop으로 받은 hospitalDetails에서 hospitals와 매칭되는 키값(hpid)을 갖는 객체를 찾아 필터링했습니다.</p>
<h3 id="hospitallistviewjs-1">HospitalListView.js</h3>
<pre><code class="language-js">const [searchQuery, setSearchQuery] = useState(&#39;&#39;);
const [selectedCondition, setSelectedCondition] = useState(&#39;&#39;);
const [isFiltered, setIsFiltered] = useState(false);

// 검색, 필터 조건 변경 시 1페이지로 이동
useEffect(() =&gt; {
    setCurrentPage(1);
}, [isFiltered, searchQuery, selectedCondition]);

// 검색, 필터링
const filteredHospitals = hospitals.filter(hospital =&gt; {
    const nameMatches = hospital.duty_name.includes(searchQuery);
    const detail = hospitalDetails.find(detail =&gt; detail.hpid === hospital.hpid);
    const conditionMatches = detail[selectedCondition] === &#39;Y&#39; || detail[selectedCondition] &gt; 0 || selectedCondition === &#39;&#39;;

    return nameMatches &amp;&amp; conditionMatches;
});

// 검색, 필터링 조건이 적용된 경우 filteredHospitals를, 적용되지 않은 경우 hospitals를 사용
const activeList = isFiltered ? filteredHospitals : hospitals;
const totalPages = Math.ceil(activeList.length / itemsPerPage);

const lastIndex = currentPage * itemsPerPage;
const firstIndex = lastIndex - itemsPerPage;
const currentHospitals = activeList.slice(firstIndex, lastIndex);

// 사용자로부터 검색어, 필터링 조건 받아서 설정
const handleSearchFilter = (query, condition) =&gt; {
    setSearchQuery(query);
    setSelectedCondition(condition);
    setIsFiltered(query || condition !== null);
};

return (
        &lt;div&gt;
            &lt;div className=&#39;search-filter-container&#39;&gt;
                &lt;input
                    type=&#39;text&#39;
                    placeholder=&#39;병원 이름을 입력하세요.&#39;
                    value={searchQuery}
                    onChange={(e) =&gt; handleSearchFilter(e.target.value, selectedCondition)}
                /&gt;

                &lt;select
                    value={selectedCondition}
                    onChange={(e) =&gt; handleSearchFilter(searchQuery, e.target.value)}
                &gt;
                    &lt;option value=&#39;&#39;&gt;상세정보 필터링&lt;/option&gt;
                    &lt;option value=&#39;duty_eryn&#39;&gt;응급실&lt;/option&gt;
                    &lt;option value=&#39;hvoc&#39;&gt;수술실&lt;/option&gt;
                    &lt;option value=&#39;hvcc&#39;&gt;신경중환자&lt;/option&gt;
                    &lt;option value=&#39;hvncc&#39;&gt;신생중환자&lt;/option&gt;
                    &lt;option value=&#39;hvccc&#39;&gt;흉부중환자&lt;/option&gt;
                    &lt;option value=&#39;hvicc&#39;&gt;일반중환자&lt;/option&gt;
                    &lt;option value=&#39;duty_hayn&#39;&gt;입원실&lt;/option&gt;
                    &lt;option value=&#39;duty_hano&#39;&gt;병상수&lt;/option&gt;
                    &lt;option value=&#39;mkioskty1&#39;&gt;뇌출혈수술&lt;/option&gt;
                    &lt;option value=&#39;mkioskty2&#39;&gt;뇌경색의재관류&lt;/option&gt;
                    &lt;option value=&#39;mkioskty3&#39;&gt;심근경색의재관류&lt;/option&gt;
                    &lt;option value=&#39;mkioskty4&#39;&gt;복부손상의수술&lt;/option&gt;
                    &lt;option value=&#39;mkioskty5&#39;&gt;사지접합의수술&lt;/option&gt;
                    &lt;option value=&#39;mkioskty6&#39;&gt;응급내시경&lt;/option&gt;
                    &lt;option value=&#39;mkioskty7&#39;&gt;응급투석&lt;/option&gt;
                    &lt;option value=&#39;mkioskty8&#39;&gt;조산산모&lt;/option&gt;
                    &lt;option value=&#39;mkioskty9&#39;&gt;정신질환자&lt;/option&gt;
                    &lt;option value=&#39;mkioskty10&#39;&gt;신생아&lt;/option&gt;
                    &lt;option value=&#39;mkioskty11&#39;&gt;중증화상&lt;/option&gt;
                    &lt;option value=&#39;hpccuyn&#39;&gt;흉부중환자실&lt;/option&gt;
                    &lt;option value=&#39;hpcuyn&#39;&gt;신경중환자실&lt;/option&gt;
                    &lt;option value=&#39;hpicuyn&#39;&gt;일반중환자실&lt;/option&gt;
                    &lt;option value=&#39;hpnicuyn&#39;&gt;신생아중환자실&lt;/option&gt;
                &lt;/select&gt;
            &lt;/div&gt;

            &lt;div className=&#39;list-container&#39;&gt;
                {currentHospitals.map(hospital =&gt; {
                     ...</code></pre>
<p>처음 구현했을 시에는 검색, 필터링된 결과가 앞에서부터 보여지는 것이 아니라, 병원 목록의 각 페이지에서 검색, 필터링된 결과를 보여주어 의도한대로 동작하지 않았습니다. (ex. 특정 병원 이름을 입력했을 때 1페이지에 3개의 결과를 보여주고, 2페이지에는 0개, 3페이지는 2개, ...). 따라서, isFiltered라는 state를 추가해 필터링(혹은 검색)이 적용되었을 경우, activeList 상수를 filteredHospital로, 적용되지 않았을 경우 hospitals를 그대로 사용해 목록과 페이지네이션을 동적으로 구현했습니다. searchQuery, selectedCondition state를 사용해 사용자로부터 검색어와 조건을 입력받았으며, 해당 값들이 변경될 경우 1페이지로 이동하도록 구현했습니다.</p>
<p>병원 이름을 검색받기 위해 input 태그를, 드롭다운 형태로 필터링 조건을 검색받기 위해 select 태그를 사용했습니다. 둘 모두 handleSearchFilter()라는 메소드로 관리하고, e.target.value를 prop으로 전달해 두 기능이 동시에 적용되도록 구현했습니다. 검색은 비교적 쉽게 구현했지만, 필터링의 경우 약 30개의 필드를 일일히 추가해줘야 했습니다. 테이블에는 모든 정보를 표현하기 위해 공공데이터 API에서 받은 데이터를 모두 기입했지만, 필터링 시에는 혼동을 방지하기 위해 중복되는 필드는 제거하고, &quot;XXX가능여부&quot;라는 필드가 있다면 이를 우선적으로 사용했습니다.
<br>
<br></p>
<p>아래의 사진들과 같이 검색, 필터링 조건이 잘 적용됨을 확인할 수 있습니다.</p>
<h3 id="검색-필터링-적용-전">검색, 필터링 적용 전</h3>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/baa03e99-dd70-4b3c-a315-48229c1d1687/image.png" alt="">
<br></p>
<h3 id="건국-검색어-적용">&quot;건국&quot; 검색어 적용</h3>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/5873e11d-970b-4012-b6e0-445c232e2d89/image.png" alt="">
<br></p>
<h3 id="흉부중환자-필터링-조건-적용">&quot;흉부중환자&quot; 필터링 조건 적용</h3>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/dfde3e10-d128-46c6-9f29-f02d299a368f/image.png" alt="">
<br></p>
<h3 id="메모">메모</h3>
<hr>
<ul>
<li><p>코드가 복잡해질수록 괄호가 많아지고, 괄호를 닫는 위치에 따라서 코드가 의도한대로 동작하지 않을 수 있으니 잘 확인해야 함</p>
<br>
</li>
<li><p>input, select 태그 둘 모두 width:100% 설정되었는데 화면에는 다른 크기로 출력됨</p>
<ul>
<li>다른 항목의 CSS 혹은 브라우저의 기본 스타일로 인해 생긴 문제일 수 있으며, box-sizing: border-box로 설정해 같은 너비를 같도록 설정함</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[119monitoring #5] 병원 목록 뷰, CSS]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-5-%EB%B3%91%EC%9B%90-%EB%AA%A9%EB%A1%9D-%EB%B7%B0-CSS-cez9dqvb</link>
            <guid>https://velog.io/@ian_lee/119monitoring-5-%EB%B3%91%EC%9B%90-%EB%AA%A9%EB%A1%9D-%EB%B7%B0-CSS-cez9dqvb</guid>
            <pubDate>Sun, 20 Aug 2023 13:48:08 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>사이드바 구현</p>
</li>
<li><p>목록 뷰 구현</p>
</li>
<li><p>목록 뷰에서 병원 선택 시 지도 뷰에서 해당 위치로 이동</p>
</li>
<li><p>Geolocation API</p>
</li>
<li><p>메모</p>
</li>
</ol>
<br>
<br>

<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 사용자가 병원 목록과 병원에 대한 상세정보를 확인할 수 있도록 별도의 목록 뷰(페이지)를 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-사이드바-구현">1. 사이드바 구현</h2>
<hr>
<p>기존 HospitalList 컴포넌트의 경우, 단순히 백엔드에서 병원 데이터를 불러와서 MapComponent를 반환하는 역할을 했습니다. 지금부터는 사용자가 사이드바에서 지도 뷰와 목록 뷰를 선택할 수 있도록 Sidebar 컴포넌트를 구현하고, state를 사용해 사용자가 선택한 뷰를 출력하도록 HospitalList를 수정해보겠습니다.</p>
<h3 id="sidebarjs">Sidebar.js</h3>
<pre><code class="language-js">...

function Sidebar({ onViewChange }) {
    return (
        &lt;div className=&quot;sidebar&quot;&gt;
            &lt;button className=&quot;sidebar-button&quot; onClick={() =&gt; onViewChange(&#39;map&#39;)}&gt;지도&lt;/button&gt;
            &lt;button className=&quot;sidebar-button&quot; onClick={() =&gt; onViewChange(&#39;list&#39;)}&gt;목록&lt;/button&gt;
        &lt;/div&gt;
    );
}

export default Sidebar;</code></pre>
<p>두 개의 버튼을 생성한 뒤, prop으로 받은 onViewCahnge 메서드를 onClick으로 등록합니다.</p>
<h3 id="hospitallistjs">HospitalList.js</h3>
<pre><code class="language-js">...

function HospitalList() {
      ...
    const [selectedView, setSelectedView] = useState(&#39;map&#39;);

      ...

    const handleViewChange = (view) =&gt; {
        setSelectedView(view);
    };

    return (
        &lt;div className=&quot;sidebar-container&quot;&gt;
            &lt;Sidebar onViewChange={handleViewChange} /&gt;

            &lt;div className=&quot;select-view-container&quot;&gt;
                {selectedView === &#39;map&#39; ? (
                    &lt;MapComponent hospitals={hospitals} selectedHospital={selectedHospital}/&gt;
                ) : (
                    &lt;HospitalListView hospitals={hospitals}/&gt;
                )}
            &lt;/div&gt;
        &lt;/div&gt;
    );
}

export default HospitalList;</code></pre>
<p>HospitalList 컴포넌트가 백엔드에서 정보를 받아와서 MapComponent, HospitalListView 컴포넌트를 map이라는 state를 사용해 선택적으로 렌더링합니다. 이와 같은 구조를 통해 백엔드에서 정보를 가져오는 로직을 중앙화함으로써 유지보수성을 증가시키고, 두 컴포넌트가 동일한 데이터를 사용한다는 것을 보장할 수 있습니다.
<br></p>
<p>위의 두 코드를 통해 사용자가 &quot;지도&quot; 버튼을 누르면 지도 뷰를, &quot;목록&quot; 버튼을 누르면 목록 뷰를 보여주는 기능은 구현했지만, Sidebar 컴포넌트가 이름과는 다르게 화면 상단에 출력됩니다. 이를 해결하기 위해 아래와 같이 CSS 코드를 작성했습니다.</p>
<h3 id="hospitallistcss">HospitalList.css</h3>
<pre><code class="language-css">.sidebar-container {
    display: flex;
    flex-direction: row;
    height: 100vh;
}

.select-view-container {
    flex: 1;
    padding: 20px;
}
</code></pre>
<br>
<br>

<p>결과적으로 아래 사진과 같이 사이드바가 화면 좌측에 배치되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/80e01dd9-8fd6-4263-b89f-31ae1c9c4897/image.png" alt="">
<br></p>
<h2 id="2-목록-뷰-구현">2. 목록 뷰 구현</h2>
<hr>
<p>이제 prop으로 받은 병원 목록을 화면에 보여주는 목록 뷰를 구현해보겠습니다.</p>
<h3 id="hospitallistviewjs">HospitalListView.js</h3>
<pre><code class="language-js">import React, { useState } from &#39;react&#39;;
import &#39;./HospitalListView.css&#39;;
import MapComponent from &#39;./MapComponent&#39;;

function HospitalListView({ hospitals }) {
    return (
        &lt;div&gt;
            &lt;div className=&#39;list-container&#39;&gt;
                {currentHospitals.map(hospital =&gt; (
                    &lt;div key={hospital.hpid} className=&#39;list-item&#39;&gt;
                        &lt;div className=&#39;item-info&#39;&gt;
                            &lt;h3&gt;{hospital.duty_name} {hospital.center_type === 0 ? &quot;(응급)&quot; : &quot;(외상)&quot;} &lt;/h3&gt;
                            &lt;p&gt;{hospital.duty_addr}&lt;/p&gt;
                            &lt;p&gt;대표: {hospital.duty_tel1}&lt;/p&gt;
                            &lt;p&gt;응급실: {hospital.duty_tel3}&lt;/p&gt;
                        &lt;/div&gt;
                    &lt;/div&gt;
                ))}
            &lt;/div&gt;
        &lt;/div&gt;
    );
}
export default HospitalListView;</code></pre>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/8e20d8fb-422f-4310-b070-26b35f427e37/image.png" alt="">
위 사진과 같이 병원 목록을 보여줄 수 있지만, 500개가 넘는 병원 정보를 한 페이지에 보여줘서 정보를 확인하기 어렵다는 문제점이 있어 페이지네이션을 적용해보겠습니다.
<br></p>
<h3 id="hospitallistviewjs-1">HospitalListView.js</h3>
<pre><code class="language-js">const itemsPerPage = 10;
const maxVisiblePages = 10;
const [currentPage, setCurrentPage] = useState(1);
const [expandedHospital, setExpandedHospital] = useState(null);

const lastIndex = currentPage * itemsPerPage;
const firstIndex = lastIndex - itemsPerPage;
const currentHospitals = hospitals.slice(firstIndex, lastIndex);

const handlePageChange = (page) =&gt; {
        setCurrentPage(page);
    };

    const handleFirstPage = () =&gt; {
        setCurrentPage(1);
    };

    const handleLastPage = () =&gt; {
        setCurrentPage(totalPages);
    };

    const getPageRange = () =&gt; {
        const halfVisiblePages = Math.floor(maxVisiblePages / 2);
        const startPage = Math.max(currentPage - halfVisiblePages, 1);
        const endPage = Math.min(startPage + maxVisiblePages - 1, totalPages);
        return Array.from({ length: endPage - startPage + 1 }, (_, index) =&gt; startPage + index);
    };

return (
        &lt;div&gt;
            ...

            &lt;div className=&#39;list-container&#39;&gt;
                {currentHospitals.map(hospital =&gt; {
                    ...
            &lt;/div&gt;

            &lt;div className=&#39;pagination&#39;&gt;
                &lt;button onClick={handleFirstPage}&gt;First&lt;/button&gt;
                {getPageRange().map((pageNumber, index) =&gt; (
                    &lt;button
                        key={index}
                        className={pageNumber === currentPage ? &#39;active&#39; : &#39;&#39;}
                        onClick={() =&gt; handlePageChange(pageNumber)}
                    &gt;
                        {pageNumber}
                    &lt;/button&gt;
                ))}
                &lt;button onClick={handleLastPage}&gt;Last&lt;/button&gt;
            &lt;/div&gt;
        &lt;/div&gt;</code></pre>
<p>한 페이지 당 10개의 항목을 보여주도록 설정했고, 첫 번째 페이지와 마지막 페이지로 이동하는 버튼을 만들었으며, getPageRange() 메서드를 사용해 아래 사진과 같이 현재 페이지 번호에 따라 페이지 목록이 동적으로 변하도록 구현했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/cc52584b-023f-4278-bab7-13a1f4b5112b/image.png" alt="">
<br></p>
<h2 id="3-목록-뷰에서-병원-선택-시-지도-뷰에서-해당-위치로-이동">3. 목록 뷰에서 병원 선택 시 지도 뷰에서 해당 위치로 이동</h2>
<hr>
<p>사용자가 목록 뷰에서 병원에 대한 정보를 확인한 뒤 지도 뷰에서 해당 병원의 위치를 확인할 수 있는 기능을 구현해보겠습니다. HospitalList가 백엔드에서 데이터를 가져와서 MapComponent와 HospitalListView 컴포넌트를 호출하는 현재 구조에서, HospitalListView가 MapComponent를 호출할 경우 순환 호출이 되는 문제가 발생합니다. 따라서, hospitals를 prop으로 넘긴 것과 비슷하게 HospitalListView 호출 시 handleViewChange()라는 메서드를 넘겨주었습니다.</p>
<h3 id="hospitallistjs-1">HospitalList.js</h3>
<pre><code class="language-js">...
const handleViewChange = (view, latitude, longitude) =&gt; {
        setSelectedView(view);
        setSelectedHospital({ latitude, longitude })
};

return (
        &lt;div className=&quot;sidebar-container&quot;&gt;
            ...

            &lt;div className=&quot;select-view-container&quot;&gt;
                {selectedView === &#39;map&#39; ? (
                    &lt;MapComponent hospitals={hospitals} selectedHospital={selectedHospital} /&gt;
                ) : (
                    &lt;HospitalListView hospitals={hospitals} hospitalDetails={hospitalDetails} onViewChange={handleViewChange}/&gt;
                )}
            &lt;/div&gt;
        &lt;/div&gt;
);</code></pre>
<br>

<h3 id="hospitallistviewjs-2">HospitalListView.js</h3>
<pre><code class="language-js">&lt;div className=&#39;list-container&#39;&gt;
    {currentHospitals.map(hospital =&gt; {
        // 현재 병원의 상세 정보
        const detailData = hospitalDetails.find(detail =&gt; detail.hpid === hospital.hpid);

        return (
          &lt;div key={hospital.hpid}&gt;
              &lt;div className=&#39;item-info&#39;&gt;
              ...
              &lt;/div&gt;

              &lt;div className=&#39;item-link&#39;&gt;
                  &lt;button onClick={() =&gt; onViewChange(&#39;map&#39;, hospital.wgs_84_lat, hospital.wgs_84_lon)}&gt;지도에서 보기&lt;/button&gt;
              &lt;/div&gt;
          &lt;/div&gt;</code></pre>
<p>사용자가 &quot;지도에서 보기&quot;라는 버튼을 클릭하면, 해당 병원의 경도와 위도를 매개변수로 사용해 onViewChange()를 호출합니다. 첫 번째 매개변수로 &quot;map&quot;이라는 문자열이 주어젔으므로, HospitalList에서 MapComponent를 호출합니다.
<br></p>
<h3 id="mapcomponentjs">MapComponent.js</h3>
<pre><code class="language-js">...

const MapComponent = ({ hospitals = [], selectedHospital }) =&gt; {
    useEffect(() =&gt; {
        ...

        script.onload = () =&gt; {
            ...
                // 오버레이 닫기 기능 추가
                window.kakao.maps.event.addListener(marker, &#39;click&#39;, function(){
                    overlay.setMap(map);

                    const tempDiv = document.createElement(&#39;div&#39;);
                    tempDiv.innerHTML = content;

                    const closeBtn = tempDiv.querySelector(&#39;.close&#39;);
                    closeBtn.addEventListener(&#39;click&#39;, () =&gt; {
                        overlay.setMap(null);
                    });

                    overlay.setContent(tempDiv);
                });

                // 목록 뷰에서 선택된 병원의 위치로 지도 이동
                // 선택된 병원의 오버레이는 마커 클릭 없이도 자동으로 출력(닫기 기능도 별도로 구현함)
                if (selectedHospital &amp;&amp; selectedHospital.latitude === hospital.wgs_84_lat &amp;&amp; selectedHospital.longitude === hospital.wgs_84_lon) {
                    const centerPosition = new window.kakao.maps.LatLng(selectedHospital.latitude, selectedHospital.longitude);
                    map.setCenter(centerPosition);
                    overlay.setMap(map);

                    const tempDiv = document.createElement(&#39;div&#39;);
                    tempDiv.innerHTML = content;

                    const closeBtn = tempDiv.querySelector(&#39;.close&#39;);
                    closeBtn.addEventListener(&#39;click&#39;, () =&gt; {
                        overlay.setMap(null);
                    });

                    overlay.setContent(tempDiv);
                }

            });

            ...
        };
    }, [hospitals, selectedHospital]);

    return &lt;div id=&quot;kakao-map&quot; /&gt;;
};

export default MapComponent;</code></pre>
<p>selectedHospital은 사용자가 선택한 병원의 경도, 위도 정보입니다. selectedHospital이 존재할 경우 목록 뷰에서 지도 뷰로 넘어온 것이고, 존재하지 않는다면 사이드바에서 선택돼서 렌더링된 것입니다. selectedHospital이 존재할 경우 지도의 중심 좌표로 설정하고, 해당 병원의 정보를 나타내기 위해 오버레이를 출력합니다. 이때 오버레이의 경우, 마커를 클릭해서 실행된 것이 아니기 때문에 window.kakao.maps.event.addListener 구문에서 닫기 기능을 추가했어도 동작하지 않습니다. 따라서, 동일한 로직을 사용해서 별도로 닫기 기능을 추가했습니다.
<br></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/b71adff6-bc45-4901-8689-22e78b5f410a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/8d38c416-fb2f-498b-8026-ab544792da6a/image.png" alt=""></p>
<p>결과적으로 위 사진들과 같이 &quot;지도에서 보기&quot; 버튼을 클릭하면 지도에서 해당 병원의 위치를 잘 표시하는 것을 확인할 수 있습니다.
<br>
<br></p>
<h2 id="4-geolocation-api">4. Geolocation API</h2>
<hr>
<p>사용자가 처음 웹사이트에 접속했을 때 지도 뷰를 사용자 위치로 설정하기 위해 Geolocation API 사용을 시도해봤습니다. Geolocation API를 사용해 위치를 받아오기까지 시간이 좀 걸리기 때문에 아래 코드와 같이 geolocationResolved라는 state를 사용해 위치를 받아올 때까지 기다린 다음 카카오맵 API를 연동했습니다. </p>
<pre><code class="language-js">const MapComponent = ({ hospitals = [], selectedHospital }) =&gt; {
    const [geolocationResolved, setGeolocationResolved] = useState(false);

    useEffect(() =&gt; {
        var centerLat = 37.5665;
        var centerLon = 126.9780;

        if (navigator.geolocation) {
            navigator.geolocation.getCurrentPosition(function(position) {
                centerLat = position.coords.latitude;
                centerLon = position.coords.longitude;
                setGeolocationResolved(true);
            });
        }

        if (geolocationResolved) {
            console.log(centerLat, centerLon);

            // 카카오맵 API 연동
            ...

            script.onload = () =&gt; {
                window.kakao.maps.load(() =&gt; {
                const container = document.getElementById(&#39;kakao-map&#39;);

                // 중심좌표(위도, 경도), 확대 정도 설정
                const options = {
                    center: new window.kakao.maps.LatLng(centerLat, centerLon),
                    level: 5,
                };

                ...</code></pre>
<br>

<p>지도를 렌더링하기까지 대략 4~5초가 소요되었고, 좌표는 잘 받아와짐에도 10번 중에 1번 꼴로 지도가 사용자 위치로 잘 설정되고, 대부분의 경우 전혀 다른 위치로 설정되었습니다. 두 경우 출력되는 좌표값은 동일했기 때문에 카카오맵 API와 호환성이 안 좋아서 발생하는 문제인 것 같습니다.</p>
<p>또한, Geolocation API는 브라우저가 제공하는 기능이기 때문에 웨일의 경우 사용자 위치로 잘 설정되는 경우에도 지하철 2정거장 정도의 꽤 큰 차이가 있던 반면, 크롬의 경우 아주 정확한 위치로 설정되었습니다.</p>
<p>결과적으로 렌더링 시간이 지연되는 것에 비해 정확도가 떨어지기 때문에 일단은 사용을 보류했습니다. 필수적인 기능을 모두 구현해 1차 배포를 한 뒤에 확장을 고려 중입니다.
<br>
<br></p>
<h2 id="5-메모">5. 메모</h2>
<hr>
<ul>
<li><p>React에서 컨텐츠의 높이를 브라우저에 맞추기 위해서는 <code>height: 100vh;</code>로 설정</p>
<ul>
<li><p><code>height: 100%;</code> 혹은 <code>height: auto;</code>로 설정해도 변화 X</p>
</li>
<li><p>vh는 높이값의 1/100에 해당하기 때문에 100vh로 설정하면 브라우저가 늘어나거나 줄어들어도 유동적으로 전체 높이로 설정됨</p>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[119monitoring #4] 카카오맵 API 마커, 오버레이 생성하기]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-4-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-API-%EB%A7%88%EC%BB%A4-%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ian_lee/119monitoring-4-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-API-%EB%A7%88%EC%BB%A4-%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 14 Aug 2023 09:58:33 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>axios 통신</p>
</li>
<li><p>마커로 병원 위치 표시하기</p>
</li>
<li><p>오버레이로 병원 정보 출력하기</p>
</li>
<li><p>메모</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 카카오맵 API를 사용해 백엔드에서 받아온 병원 위치를 마커로 표시하고, 오버레이로 정보를 출력하는 기능을 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-axios-통신">1. axios 통신</h2>
<hr>
<p>우선 백엔드에서 프런트엔드로 병원 정보를 가져와야 합니다. React에서는 API와 통신하기 위해 fetch API, axios 등의 HTTP Client 라이브러리를 주로 사용합니다. fetch는 Javascript 내장 라이브러리이기 때문에 별도의 설치가 필요 없다는 장점이 있지만, JSON으로 변환하는 별도의 로직을 구현해야 하고, 지원하지 않는 브라우저가 있다는 단점도 있습니다. 반면 axios는 별도로 설치해야하고 fetch에 비해 느리지만, 보안성과 브라우저 호환성이 더 좋기 때문에 이번 프로젝트에서는 axios를 사용했습니다.</p>
<h3 id="hospitallistjs">HospitalList.js</h3>
<pre><code class="language-js">import React, { useState, useEffect } from &#39;react&#39;;
import axios from &#39;axios&#39;;
import MapComponent from &#39;./MapComponent&#39;;

function HospitalList() {
    const [hospitals, setHospitals] = useState([]);
    console.log(&quot;entered1&quot;)

    useEffect(() =&gt; {
        // 백엔드 API에서 병원 목록 데이터 fetch
        axios.get(&#39;http://localhost:8000/api/hospitals/&#39;)
            .then(response =&gt; {
                setHospitals(response.data);
            })
            .catch(error =&gt; {
                console.error(&#39;데이터를 가져오는 데 실패했습니다:&#39;, error);
            });
    }, []);

    return (
        &lt;div&gt;
            &lt;MapComponent hospitals={hospitals} /&gt;
        &lt;/div&gt;
    );
}</code></pre>
<p>위 코드와 같이 React Hook(useState)에서 axios 통신으로 백엔드의 데이터를 가져와서 hospitals라는 상수에 저장합니다. 이후에 hospitals를 매개변수(prop)로 사용해 MapComponent를 호출합니다.
<br></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/c462d23a-c41a-4577-973e-8b25f5c899e1/image.png" width=800px></img></p>
<p>axios.get(&#39;/api/hospitals/&#39;)과 같이 사용할 경우, 백엔드의 포트 번호 8000이 아닌 react 서버의 포트 3000을 가리키기 때문에 위 사진과 같이 <span style="color: indianred">HTTP 404 에러</span>가 발생합니다. 따라서, axios.get(&#39;<a href="http://localhost:8000/api/hospitals/%E2%80%98)%EA%B3%BC">http://localhost:8000/api/hospitals/‘)과</a> 같이 URL을 전부 명시해서 사용했습니다.
<br></p>
<h3 id="corscross-origin-resource-sharing">CORS(Cross-Origin Resource Sharing)</h3>
<p>위의 코드를 사용해 axios로 백엔드에 정보를 요청하면, 아래 사진과 같이 <span style="color: indianred">CORS 정책으로 인해 요청이 거부되었다는 에러</span> 메시지가 출력됩니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/e2720cc3-8ccd-4330-b99f-c031fcf53140/image.png" width=800px></img></p>
<p>CORS를 한국말로 직역하면 &quot;교차 출처 자원 공유&quot;라는 뜻인데, 말 그대로 여러 개의 출처에서 자원을 공유하기 때문에 발생하는 문제입니다. 여기서 출처(origin)은 아래 사진과 같이 URL에서 프로토콜, 호스트, 그리고 포트 번호까지의 조합을 의미합니다.
<img src="https://velog.velcdn.com/images/ian_lee/post/e9a333c3-6162-4be8-ac9e-6bd4ea262a15/image.png" width=1000px></img></p>
<p>현재 프로젝트에서 백엔드(서버 사이드)의 포트는 8000번이고, 프런트엔드(클라이언트 사이드)의 포트는 3000번으로 둘의 포트가 다르기 때문에 다른 출처로 취급되어 브라우저에서 해당 요청을 거부한 것입니다. Django 측에서 CORS 관련 설정을 추가해 해당 문제를 해결해보겠습니다.
<br>
<br></p>
<p>우선, <code>poetry add django-cors-headers</code> 명령어로 라이브러리를 설치합니다. 그 후, 아래 코드와 같이 settings.py의 INSTALLED_APPS에 해당 라이브러리를 추가합니다.</p>
<pre><code class="language-py">INSTALLED_APPS = [
    ...
    &#39;corsheaders&#39;,
    ...
]</code></pre>
<br>

<p>다음으로는 아래와 같이 settings.py의 MIDDLEWARE에 corsheader와 관련된 미들웨어를 추가합니다. 목록 중 최상단에 써야한다는 의견도 있고, <code>django.middleware.common.CommonMiddleware</code> 이후에 써야한다는 의견도 있었는데 아래와 같이 최하단에 써도 잘 동작하는 것을 확인했습니다.</p>
<pre><code class="language-py">MIDDLEWARE = [
    ...
    &#39;corsheaders.middleware.CorsMiddleware&#39;,
]</code></pre>
<br>

<p>마지막으로 settings.py에 아래와 같이 모든 origin에 대해 요청을 허가하는 설정을 추가합니다. 해당 설정은 개발 환경에서는 아래와 같이 단순하게 정의해도 무관하지만, 프로덕션 환경에서는 특정 origin에 대한 요청만 허가하도록 수정해야합니다.</p>
<pre><code class="language-py"># Production 환경에서 수정
CORS_ALLOW_ALL_ORIGINS = True</code></pre>
<br>

<p>위의 설정들을 추가하면 CORS 에러가 해결되어 프런트엔드에서 백엔드로 성공적으로 요청을 보낼 수 있습니다.</p>
<br>

<h3 id="appjs">App.js</h3>
<pre><code class="language-js">import React from &#39;react&#39;;
import HospitalList  from &#39;./components/HospitalList&#39;;

function App() {
  return (
      &lt;div className=&quot;App&quot;&gt;
        &lt;HospitalList /&gt;
      &lt;/div&gt;
  );
}

export default App;</code></pre>
<p>npm start로 서버 실행 시 실행되는 App.js에서 HospitalList를 호출하면, HospitalList에서 백엔드의 데이터를 불러와서 MapComponent에 전달해 지도에 병원 위치를 출력하는 구조입니다.
<br>
<br></p>
<h2 id="2-마커로-병원-위치-표시하기">2. 마커로 병원 위치 표시하기</h2>
<hr>
<p><a href="https://apis.map.kakao.com/web/sample/basicMarker/">카카오맵 API 명세서 - 마커 생성하기</a> 페이지를 보면, 지도에 마커를 표시하는 샘플 코드를 확인할 수 있습니다. 이를 프로젝트 상황에 맞게 수정해서 사용했습니다.</p>
<pre><code class="language-js">...
const MapComponent = ({ hospitals = [] }) =&gt; {
    useEffect(() =&gt; {
    ...

      script.onload = () =&gt; {
          ...

          const map = new window.kakao.maps.Map(container, options);

          // 병원 위치 마커로 표시
          hospitals.forEach(hospital =&gt; {
              const position = new window.kakao.maps.LatLng(hospital.wgs_84_lat, hospital.wgs_84_lon)

              const marker = new window.kakao.maps.Marker({position: position});
              marker.setMap(map);

              ...
              });

          });

          ...
      };
    }, [hospitals]);

    return &lt;div id=&quot;kakao-map&quot; style={{ width: &#39;100%&#39;, height: &#39;400px&#39; }} /&gt;;
};

export default MapComponent;</code></pre>
<p>각 병원에 대한 정보를 담은 hospitals 리스트를 prop으로 전달받으면, foreach문을 사용해 각 병원의 위치를 카카오맵에서 사용하는 형태로 변환한 뒤, 마커의 position으로 설정했습니다. 그 후 marker.setMap() 메서드를 사용해 표시할 맵을 설정하면, 아래 사진과 같이 지도에 총 522개의 병원 위치가 마커로 잘 표시되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/58f4b3ea-8e1e-4efd-afbf-8bb09267f2f9/image.png" alt="">
<br></p>
<h2 id="3-오버레이로-병원-정보-출력하기">3. 오버레이로 병원 정보 출력하기</h2>
<hr>
<p>단순히 마커로 병원 위치만 표시할 경우 해당 병원의 정확한 위치와 정보를 확인하기 어려울 수 있습니다. 따라서 카카오맵 API에서 제공하는 커스텀 오버레이를 사용해 마커를 클릭할 경우 병원에 대한 정보를 새로운 창으로 출력하고, 닫기 버튼을 누를 경우 창을 닫는 기능을 추가해보겠습니다. 해당 기능에 대한 샘플 코드는 <a href="https://apis.map.kakao.com/web/sample/removableCustomOverlay/">카카오맵 API - 닫기가 가능한 커스텀 오버레이</a>에서 확인할 수 있습니다.</p>
<pre><code class="language-js">...

const MapComponent = ({ hospitals = [], selectedHospital }) =&gt; {
    useEffect(() =&gt; {
        ...

        script.onload = () =&gt; {
            window.kakao.maps.load(() =&gt; {
            ...

            // 병원 위치 마커로 표시
            hospitals.forEach(hospital =&gt; {
                ...

                // 마커 클릭 시 오버레이로 병원 정보 표시
                const content = `&lt;div class=&quot;custom-overlay&quot;&gt;` +
                &#39;    &lt;div class=&quot;info&quot;&gt;&#39; + 
                &#39;       &lt;div class=&quot;header&quot;&gt;&#39; + 
                &#39;           &lt;div class=&quot;title&quot;&gt;&#39; + 
                `               ${hospital.duty_name}` + 
                `               ${hospital.center_type === &quot;0&quot; ? &quot;(응급)&quot; : &quot;(외상)&quot;}` +
                &#39;           &lt;/div&gt;&#39; + 
                `            &lt;button class=&quot;close&quot; title=&quot;닫기&quot;&gt;X&lt;/button&gt;` + 
                &#39;        &lt;/div&gt;&#39; +
                &#39;        &lt;div class=&quot;body&quot;&gt;&#39; +
                &#39;            &lt;div class=&quot;desc&quot;&gt;&#39; + 
                `                &lt;div class=&quot;address&quot;&gt;${hospital.duty_addr}&lt;/div&gt;` + 
                `                &lt;div class=&quot;representitive-tel&quot;&gt;대표: ${hospital.duty_tel1}&lt;/div&gt;` + 
                `                &lt;div class=&quot;er-tel&quot;&gt;응급실: ${hospital.duty_tel3}&lt;/div&gt;` +
                &#39;            &lt;/div&gt;&#39; + 
                &#39;        &lt;/div&gt;&#39; + 
                &#39;    &lt;/div&gt;&#39; +
                &#39;&lt;/div&gt;&#39;;

                const overlay = new window.kakao.maps.CustomOverlay({
                    position: position,
                    content: content,
                    yAnchor: 1.35
                });

                // 오버레이 닫기 기능 추가
                window.kakao.maps.event.addListener(marker, &#39;click&#39;, function(){
                    overlay.setMap(map);

                    const tempDiv = document.createElement(&#39;div&#39;);
                    tempDiv.innerHTML = content;

                    const closeBtn = tempDiv.querySelector(&#39;.close&#39;);
                    closeBtn.addEventListener(&#39;click&#39;, () =&gt; {
                        overlay.setMap(null);
                    });

                    overlay.setContent(tempDiv);
                });
            });

            ...
            });
        };
    }, [hospitals]);

    return &lt;div id=&quot;kakao-map&quot; /&gt;;
};

export default MapComponent;</code></pre>
<p>각 마커에 대해 오버레이를 생성하기 위해 foreach문 안에서 content를 정의한 뒤, 카카오맵의 커스텀 오버레이를 생성했습니다. 그 후, 마커를 클릭하면 오버레이가 켜지고 닫기 버튼을 누르면 오버레이가 꺼지도록 event listener를 등록했습니다. 닫기 기능의 경우, 명세서에서는 closeOverlay()라는 함수를 정의한 뒤 content 내에서 button의 onclick으로 등록했지만, 저의 경우 foreach문 내부에서 오버레이를 생성하기 때문에 foreach문 외부에 있는 closeOverlay() 함수를 인식하지 못했습니다.</p>
<p>overlay.getContent() 메서드를 사용하면 content는 알 수 있지만, element가 아닌 문자열 형식으로 반환하기 때문에 버튼 element를 찾는 것이 어려웠습니다. 따라서, tempDiv라는 임시 div를 생성해 원본 content를 복사하고, tempDiv에서 &quot;.close&quot;라는 이름을 갖는 element(button)를 찾아서 닫는 이벤트를 등록한 뒤 오버레이의 content를 tempDiv로 덮어씌우는 방법으로 해결했습니다.</p>
<p>content 내부에서 템플릿 리터럴을 사용해 hospital.center_type의 값이 0이면 &quot;(응급)&quot;을, 그 외의 값이면 &quot;(외상)&quot;을 출력했는데, <code>hospital.center_type === &quot;0&quot;</code> 대신 <code>hospital.center_type === 0</code>을 사용했더니 항상 &quot;(외상)&quot;만 출력되었습니다. 이는 Javascript의 ==은 Equal Operator이고, ===은 Strict Equal Operator 즉, 엄격한 Equal Operator로 타입이 다를 경우 다른 값으로 취급하기 때문에 발생한 문제였습니다. <code>hospital.center_type == 0</code>과 같이 ==을 사용하면 타입이 다르더라도 의도대로 동작하지만, Javascript에서는 == 대신 ===을 사용할 것을 권장하기 때문에 위와 같이 코드를 작성했습니다.
<br></p>
<p>서버를 실행해 마커를 클릭하면 아래 사진과 같이 해당 병원의 정보가 오버레이로 출력되고, 닫기 버튼을 누르면 오버레이가 잘 닫히는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/9a270581-ad39-4578-a0e6-7b1dfdeddf84/image.png" alt=""></p>
<br>
<br>

<h2 id="4-메모">4. 메모</h2>
<hr>
<ul>
<li><p>.toml, package.json과 같이 패키지 관리 파일의 경우, hash값 등의 정보를 포함하기 때문에 Github에 올리지 않는 것이 좋음</p>
<ul>
<li>가상환경(venv)를 같이 올리는 것이 아니라면 어차피 clone해도 실행하지 못하며, 용량만 잡아먹음<br>
</li>
</ul>
</li>
<li><p>기존에는 프로젝트의 데이터 파이프라인 관련 코드와 웹 구현 코드를 하나의 Repo에 같이 저장했는데, 관리의 용이성을 위해 별도의 Repo로 분리함</p>
<ul>
<li><p>이 과정에서 브랜치를 삭제한 뒤 <code>git init</code> 다시하고 remote 제거해도 git 연결이 초기화되지 않음 </p>
<ul>
<li><p>로컬 폴더를 새로 생성해 파일 복사한 뒤 새로운 repo를 remote으로 설정해서 해결</p>
<ul>
<li>복사하는 과정에서 venv가 깨져서 PYTHONPATH를 인식하지 못해 venv 다시 잡고 poetry update package로 패키지 다시 설치함<br></li>
<li><code>git-filter-branch</code>라는 기능을 사용하면 Repo를 쉽게 분리할 수 있음</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[119monitoring #3] 백엔드 초기 설정 및 RDS 연결]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-3-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-RDS-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@ian_lee/119monitoring-3-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-RDS-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Fri, 11 Aug 2023 17:52:03 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>백엔드 초기 설정</p>
</li>
<li><p>Amazon RDS 연결</p>
</li>
<li><p>RDS에서 데이터 가져오기</p>
</li>
<li><p>병원 목록 뷰 구현</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 백엔드에서 필요한 초기 설정을 진행하고, Amazon RDS와 연결해서 데이터를 가져온 뒤 출력해보겠습니다.</p>
<br>
<br>

<h2 id="1-백엔드-초기-설정">1. 백엔드 초기 설정</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/3b3e2e8d-c788-4682-a5a5-75f3e0710dd8/image.png" alt=""></p>
<p>위 사진과 같은 프로젝트 구조에서 백엔드 초기 설정을 하기 위해 <code>poetry new projectname</code> 명령어를 사용할 경우, 아래 사진과 같이 backend 폴더 내에 backend 폴더가 중복으로 생성됩니다. 이러한 문제를 해결하기 위해 <code>cd backend</code> 명령어로 backend 폴더로 이동하고, <code>poetry init</code> 명령어를 사용해 프로젝트를 초기화(생성)했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/c5f14dad-6aec-4bd0-860f-9f83de9e794b/image.png" alt="">
<br></p>
<p>poetry로 프로젝트를 생성할 경우, 프로젝트의 의존성을 관리하는 역할의 poetry.lock과 pyproject.toml 파일이 생성됩니다. 의존성을 맞추기 위해 Github에 같이 커밋되어야하므로, .gitignore에 걸려있지 않은지 확인해야 합니다.</p>
<p><code>poetry add django</code>, <code>poetry add djangorestframework</code>,<code>poetry add django-extensions</code> 명령어를 사용해 필요한 라이브러리를 추가하면, 아래 사진과 같이 poetry.lock의 dependencies에 이들이 추가되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/c5ed11ef-0f61-4939-b90a-fea83c2b96e8/image.png" alt="">
<br>
<br></p>
<p>이제 Django 프로젝트를 생성해보겠습니다.일반적으로 Django 프로젝트를 생성할 때 <code>django-admin startproject projectname</code> 명령어를 많이 사용합니다. 이 경우, projectname 폴더 안에 projectname이라는 폴더가 중복해서 생성되므로 관리하기 어려운 구조가 됩니다. 따라서 현재 위치에 프로젝트를 생성하는 <code>django-admin startproject projectname .</code> 명령어를 대신 사용했습니다. 저의 경우 백엔드에서 필요한 설정을 관리한다는 의미로 프로젝트 이름을 configs로 지었으며, 필요한 기능은 별도의 django app을 생성해 구현할 예정입니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/96458e43-d93d-4629-b74f-2d2445a3ee61/image.png" alt="">
<br>
<br></p>
<h2 id="2-amazon-rds-연결">2. Amazon RDS 연결</h2>
<p>이전 글에서 프런트엔드 초기 설정할 때와 마찬가지로, DB 연결과 관련된 민감한 정보를 외부로부터 숨기기 위해 .env 파일을 아래와 같이 작성했습니다.</p>
<pre><code>SECRET_KEY=XXX

DB_ENGINE=XXX
DB_NAME=XXX
DB_USER=XXX
DB_PASSWORD=XXX
DB_HOST=XXX
DB_PORT=XXX</code></pre><br>

<p>이제 해당 정보들을 settings.py에서 불러와서 RDS와 연결해야 하는데, 이를 위해서 django-environ이라는 라이브러리를 사용했습니다.</p>
<pre><code class="language-py">from pathlib import Path
import os, environ

...

# 환경변수 사용
env = environ.Env(DEBUG = (bool, False))
environ.Env.read_env(os.path.join(BASE_DIR, &#39;.env&#39;))

SECRET_KEY = env(&#39;SECRET_KEY&#39;)

# DB 연결
DATABASES = {
    &#39;default&#39;: {
        &#39;ENGINE&#39;: env(&#39;DB_ENGINE&#39;),
        &#39;NAME&#39;: env(&#39;DB_NAME&#39;),
        &#39;USER&#39;: env(&#39;DB_USER&#39;),
        &#39;PASSWORD&#39;: env(&#39;DB_PASSWORD&#39;),
        &#39;HOST&#39;: env(&#39;DB_HOST&#39;),
        &#39;PORT&#39;: env(&#39;DB_PORT&#39;)
    }
}

...</code></pre>
<br>
<br>

<p><img src="https://velog.velcdn.com/images/ian_lee/post/019ee3d0-490c-4664-9e33-e352469ace55/image.png" alt=""></p>
<p>여기서 분명히 <code>poetry add django-environ</code> 명령어로 라이브러리를 설치했고, 코드도 알맞게 작성했는데 위 사진처럼 environ 모듈을 찾을 수 없다는 <span style="color: indianred">ModuleNotFound 에러</span>가 발생했습니다. 다른 라이브러리는 문제 없이 불러오기 때문에 environ 라이브러리만의 문제라고 생각해 dotenv라는 라이브러리로 대체해봤음에도 동일한 문제가 발생했고, poetry의 문제라고 생각해서 PYTHONPATH도 수정해봤지만 해결되지 않았습니다. </p>
<p>많은 시행착오를 겪은 끝에 결론적으로 가상환경을 deactivate한 뒤 다시 실행해서 문제를 해결했습니다. 아마도 가상환경을 실행 중이지 않은 상태에서 라이브러리를 추가했기 때문에 인식하지 못해서 발생한 문제인 것 같습니다.
<br></p>
<p>이후에는 MySQL DB를 사용하기 위해 <code>poetry add mysqlclient</code> 명령어로 라이브러리 설치를 시도했을 때, <span style="color: indianred">Exception: Can not find valid pkg-config name. Specify MYSQLCLIENT_CFLAGS and MYSQLCLIENT_LDFLAGS env vars manually</span> 에러가 발생했습니다. 해당 문제도 해결하기 위해 여러 시행착오를 겪었지만, 결론적으로 MySQL이 설치되어있지 않아서 인식하지 못해 발생한 문제로, <code>brew install mysql</code>로 MySQL 설치 이후에 다시 시도하니 에러가 발생하지 않았습니다.
<br>
<br></p>
<h2 id="3-rds에서-데이터-가져오기">3. RDS에서 데이터 가져오기</h2>
<p>Amazon RDS(MySQL)에 이미 데이터가 다 적재된 상태이고, 이를 백엔드에서 가져오기만 하면 되는 상황이기 때문에 모델을 정의하는 것이 맞는지에 대해 고민했습니다. 그 결과, Django ORM의 쿼리, 필터링 기능을 사용할 수 있고, 데이터를 더 일관된 형태로 사용할 수 있기 때문에 모델을 정의했습니다. 향후에라도 데이터가 수정될 가능성이 있다면 migration을 진행하는 것이 맞지만, 저희 프로젝트에서는 필요하지 않을 것 같아서 일단 보류했습니다.</p>
<pre><code class="language-py">from django.db import models

# Create your models here.
class HospitalBasic(models.Model):
    hpid = models.CharField(max_length = 10, primary_key = True)
    ...

    class Meta:
        db_table = &quot;HOSPITAL_BASIC_INFO&quot;</code></pre>
<p>여기서 MySQL은 테이블명의 대소문자를 구분하기 때문에 db_table을 MySQL에서 사용하는 것과 동일하게 정의해야 하고, primary key를 지정하지 않을 경우 Django가 자동으로 id 필드를 생성하기 때문에 RDS의 테이블 형식과 달라질 수 있다는 것을 주의해야 합니다.
<br>
<br></p>
<p>RDS도 연결되었고, 테이블에 맞는 모델도 정의했으므로 shell_plus을 사용해 모든 HospitalBasic 객체를 가져오면, 아래 사진과 같이 총 522개의 데이터를 잘 가져오는 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/ian_lee/post/306b7b4b-4fb4-43ca-9cbf-d3d51e85e7fe/image.png" alt=""></p>
<br>
<br>

<h2 id="4-병원-목록-뷰-구현">4. 병원 목록 뷰 구현</h2>
<hr>
<p>이제 RDS에서 받아온 병원 목록을 브라우저에 출력할 수 있도록 뷰를 구현해보겠습니다. 우선, Django 모델을 JSON 형태의 데이터로 변환할 수 있도록 모델에 맞는 serializer를 다음과 같이 정의했습니다.</p>
<pre><code class="language-py">from rest_framework import serializers
from .models import HospitalBasic, HospitalDetail

class HospitalBasicSerializer(serializers.ModelSerializer):
    class Meta:
        model = HospitalBasic
        fields = &#39;__all__&#39;</code></pre>
<br>

<p>그 다음, generics의 ListAPIView를 상속해 병원 목록을 보여주는 HospitalList 뷰를 정의했습니다.</p>
<pre><code class="language-py">from rest_framework import generics
from .models import HospitalBasic 
from .serializers import HospitalBasicSerializer

class HospitalList(generics.ListAPIView):
    queryset = HospitalBasic.objects.all()
    serializer_class = HospitalBasicSerializer</code></pre>
<br>

<p>이제 외부에서 접근할 수 있도록 아래와 같이 URL을 매핑해준 뒤 Postman을 사용해 해당 URL로 접속해보면 아래 사진과 같이 병원 목록 데이터를 JSON 형태로 잘 출력하는 것을 확인할 수 있습니다.</p>
<pre><code class="language-py">from django.urls import path
from .views import *

urlpatterns = [
    path(&#39;hospitals/&#39;, HospitalList.as_view()),
]</code></pre>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/cef73fce-751c-45fd-b015-a1bc0db14c04/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[119monitoring #2] 프런트엔드 초기 설정 및 카카오맵 API 연동]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-2-%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-API-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@ian_lee/119monitoring-2-%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%A7%B5-API-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Thu, 10 Aug 2023 17:06:49 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>기술 스택</p>
</li>
<li><p>프런트엔드 초기 설정</p>
</li>
<li><p>카카오맵 API 연동</p>
</li>
<li><p>메모</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 프로젝트의 프런트엔드에 필요한 초기 설정을 진행하고, 카카오맵 API를 연동해보겠습니다.
<br>
<br></p>
<h2 id="1-기술-스택">1. 기술 스택</h2>
<hr>
<p>사용자가 병원의 위치를 지도에서 확인할 수 있도록 React.js로 프런트엔드를 구성하고, 카카오맵 API를 연동할 예정입니다. 네이버맵 API와 카카오맵 API 중 고민한 결과, 지원하는 기능은 거의 비슷하지만 클라우드 없이 단독으로 사용할 수 있고 장소 검색 기능을 제공하는 카카오맵 API를 사용했습니다. 또한, 일부 기능을 서버 사이드 렌더링하기 위해 Next.js 프레임워크의 사용을 고려하고 있습니다. </p>
<p>Django와 DRF를 사용해 REST API를 개발하고, AWS의 EC2로 배포할 예정입니다. 또한, Production DB로 AWS의 RDS(MySQL)를, Data Lake와 Data Warehouse로 각각 S3, Redshift를 활용합니다.
<br></p>
<h3 id="라이브러리">라이브러리</h3>
<ul>
<li><p>poetry: 라이브러리 의존성 관리</p>
</li>
<li><p>django-filter: 사용자가 특정 조건으로 병원을 검색(필터링)할 수 있는 기능 구현</p>
</li>
<li><p>django-extensions: runserver_plus, shell_plus 등의 기능 사용</p>
</li>
<li><p>django-environ: DB 연결 관련 민감한 정보 환경변수 처리</p>
</li>
<li><p>drf-spectacular: DRF로 개발한 REST API 자동 문서화</p>
</li>
<li><p>mysqlclient: MySQL(RDS) 연결</p>
<br>
<br>

</li>
</ul>
<h2 id="2-프런트엔드-초기-설정">2. 프런트엔드 초기 설정</h2>
<hr>
<p>아래 사진과 같이 프런트엔드와 백엔드를 나눠서 구현하기 위해 frontend와 backend 폴더를 생성했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/4fdeb165-2221-4d16-ba22-dae4b19d0358/image.png" alt=""></p>
<p><code>cd frontend</code> 명령어를 사용해 frontend 폴더로 이동하고, <code>npx create-react-app .</code> 명령어를 사용해 현재 위치에서 react app을 생성하면 아래 사진과 같이 자동으로 디렉토리를 구성하고 각종 파일들을 생성하는데, 해당 파일들은 당장 필요하지 않더라도 만약을 위해서 남겨두었습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/00b57a02-d642-4301-8628-5c3939ab7397/image.png" alt=""></p>
<p>백엔드 라이브러리는 backend 폴더 내에서, 프런트엔드 라이브러리는 frontend 라이브러리에서 각각 관리하기 위해 package.json과 package-lock.json 파일을 프로젝트의 루트 디렉토리가 아닌 frontend 폴더 내에 배치했습니다. 반면, <code>create-react-app</code> 명령어 실행 시 자동으로 생성되는 .gitignore 파일은 관리의 편의성을 위해 루트 디렉토리에 배치했습니다.
<br>
<br></p>
<h2 id="3-카카오맵-api-연동">3. 카카오맵 API 연동</h2>
<hr>
<p>다음으로 프런트엔드에 카카오맵 API를 연동해보겠습니다. 우선, 아래 사진과 같이 <a href="https://developers.kakao.com/">Kakao Developers 페이지</a>에 로그인한 뒤 애플리케이션을 등록해야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/8a164302-0712-4b01-8bf8-e32c250f52ad/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/57d44bcf-ab1d-4f91-a234-1404c6c996df/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/07e4ee38-cd2c-4c4b-8df1-f9610f510799/image.png" alt="">
<br>
<br></p>
<p>등록을 완료했으면 아래 사진과 같이 애플리케이션을 클릭해 상세 정보 페이지로 이동할 수 있고, 해당 페이지에서 애플리케이션에서 사용할 키를 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/efa5dd33-0abc-4dac-a21e-5c4e3b457299/image.png" alt=""></p>
<p>이제 앱 설정 &gt; 플랫폼으로 이동해서 플랫폼 정보를 입력해줘야 합니다. 저희 팀의 경우 웹서비스를 제공할 것이고, 일단 배포 전 로컬 환경에서 개발하고 테스트하기 위해 localhost:3000을 도메인으로 등록했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/e4b5061a-1c49-4334-8919-00583cd0f5f4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/589d30e6-6491-4439-9f77-f5b3ee4f3ad1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/ee24dd8c-91e0-405f-a865-fa7b50ce2344/image.png" alt="">
<br>
<br></p>
<p>위의 과정을 통해 애플리케이션 등록과 설정을 완료했으니, 이제 코드에서 카카오맵 API를 호출하고 사용해보겠습니다. 우선, 아래 사진과 같이 src/components 안에 MapComponent.js 파일을 생성한 뒤 다음의 코드를 작성합니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/ec7f2fe8-3fa4-4d8d-aef5-f92e1ec5b493/image.png" alt=""></p>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-js">import React, { useEffect } from &#39;react&#39;;

// const { kakao } = window;

const MapComponent = () =&gt; {
    useEffect(() =&gt; {
    // 카카오맵 API 연동
    const script = document.createElement(&#39;script&#39;);
    script.async = true;
    script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_API_KEY}&amp;autoload=false&amp;libraries=services,clusterer,drawing`;
    document.head.appendChild(script);

    script.onload = () =&gt; {
        window.kakao.maps.load(() =&gt; {
        const container = document.getElementById(&#39;kakao-map&#39;);

        // 중심좌표(위도, 경도), 확대 정도 설정
        const options = {
            center: new window.kakao.maps.LatLng(37.5665, 126.9780),
            level: 3,
        };

        const map = new window.kakao.maps.Map(container, options);

        // 지도 타입 컨트롤, 줌 컨트롤 추가
        const mapTypeControl = new window.kakao.maps.MapTypeControl();
        const zoomControl = new window.kakao.maps.ZoomControl();
        map.addControl(mapTypeControl, window.kakao.maps.ControlPosition.TOPRIGHT);
        map.addControl(zoomControl, window.kakao.maps.ControlPosition.RIGHT);
        });
    };
    }, []);

    return &lt;div id=&quot;kakao-map&quot; style={{ width: &#39;100%&#39;, height: &#39;400px&#39; }} /&gt;;
};

export default MapComponent;</code></pre>
<br>

<h3 id="line-1--11">line 1 ~ 11</h3>
<pre><code class="language-js">import React, { useEffect } from &#39;react&#39;;

// const { kakao } = window;

const MapComponent = () =&gt; {
    useEffect(() =&gt; {
    // 카카오맵 API 연동
    const script = document.createElement(&#39;script&#39;);
    script.async = true;
    script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_API_KEY}&amp;autoload=false&amp;libraries=services,clusterer,drawing`;
    document.head.appendChild(script);</code></pre>
<p>위의 코드는 React에서 카카오맵 API를 불러오고, 연결하는 코드입니다. script.src를 보면, API 키가 일반적인 문자열의 형태가 아닌 것을 확인할 수 있습니다. Github에 코드를 업로드할 때 API Key 등의 중요 정보가 노출되는 것을 방지하기 위해 아래 사진과 같이 .env 파일에 환경 변수를 정의한 뒤, 템플릿 문법을 사용해 불러온 것입니다. 이때 주의할 점은, React에서 사용되는 환경변수는 REACT_APP으로 시작해야 하며, <code>변수명=값</code> 형태 즉, 공백 없이 입력해야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/d67a5016-d00f-4cf1-b2ec-539e97aef272/image.png" alt="">
<br></p>
<h3 id="line-13-">line 13 ~</h3>
<pre><code class="language-js">script.onload = () =&gt; {
        window.kakao.maps.load(() =&gt; {
        const container = document.getElementById(&#39;kakao-map&#39;);

        // 중심좌표(위도, 경도), 확대 정도 설정
        const options = {
            center: new window.kakao.maps.LatLng(37.5665, 126.9780),
            level: 3,
        };

        const map = new window.kakao.maps.Map(container, options);

        // 지도 타입 컨트롤, 줌 컨트롤 추가
        const mapTypeControl = new window.kakao.maps.MapTypeControl();
        const zoomControl = new window.kakao.maps.ZoomControl();
        map.addControl(mapTypeControl, window.kakao.maps.ControlPosition.TOPRIGHT);
        map.addControl(zoomControl, window.kakao.maps.ControlPosition.RIGHT);
        });
    };
    }, []);

    return &lt;div id=&quot;kakao-map&quot; style={{ width: &#39;100%&#39;, height: &#39;400px&#39; }} /&gt;;
};

export default MapComponent;</code></pre>
<p>해당 코드는 카카오맵을 불러올 container를 정의해 옵션을 설정하고, 지도 타입 컨트롤과 줌 컨트롤을 추가한 MapComponent를 외부로 반환하는 코드입니다. 공식 문서에서는 kakao.maps와 같이 window라는 키워드를 붙이지 않고 사용하는데, 이 경우 <span style="color: indianred">Cannot read properties of undefined (reading &#39;maps’) 에러</span>가 발생합니다.</p>
<p>이는 스크립트로 카카오맵 API를 가져오면 이는 window 전역 객체로 들어가는데, 함수형 컴포넌트에서 이를 바로 인식할 수 없기 때문에 발생한 문제입니다. <code>const {kakao} = window;</code>라는 코드를 상단에 추가하면 해결이 가능하지만, 서버를 새로 시작할 때마다 주석 처리했다가 다시 풀어줘야 적용이 되는 점이 불편해서 kakao.maps 대산 window.kakao.maps와 같이 매번 window 키워드를 붙여서 사용했습니다.</p>
<p><code>npm start</code> 명령어로 React 서버를 실행하면, 아래 사진과 같이 카카오맵 API가 연동되어 지도 타입 컨트롤, 줌 컨트롤이 추가된 지도를 잘 출력하는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/65d7c831-92fa-4a29-8905-12fe93e462b7/image.png" alt="">
<br></p>
<h2 id="4-메모">4. 메모</h2>
<hr>
<ul>
<li><p>코드 작성 시 가독성뿐만 아니라 의도하지 않은 동작을 방지하기 위해 들여쓰기 신경쓸 것</p>
</li>
<li><p>템플릿 문법 사용 시 &#39;&#39;(따옴표)가 아닌 ``(백틱)으로 감싸야하는 것 주의</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[119monitoring #1] 기능 도출 및 데이터 모델링]]></title>
            <link>https://velog.io/@ian_lee/119monitoring-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%B6%9C-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</link>
            <guid>https://velog.io/@ian_lee/119monitoring-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%B6%9C-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</guid>
            <pubDate>Wed, 09 Aug 2023 16:28:46 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>요구사항 분석 및 기능 도출</p>
</li>
<li><p>아키텍처 설계</p>
</li>
<li><p>데이터 모델링</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>한 <a href="https://medigatenews.com/news/3802606837">기사</a>를 통해 응급실 현황을 알 수 있는 시스템이 존재하지 않아서 구급대원이 일일히 병원에 전화해서 치료 가능 여부를 확인해야 하고, 이로 인해 혼선이 생기거나 불행한 사건이 생기는 경우가 있다는 사실을 접했습니다. </p>
<p>저희 팀은 이러한 문제를 해결할 수 있는 방법에 대해 고민하고 조사한 결과, 공공데이터포털에서 제공하는 <a href="https://www.data.go.kr/data/15000563/openapi.do">전국 응급의료기관 정보 조회 서비스 Open API</a>를 발견했습니다. 이를 활용해 <code>실시간 응급실 API를 활용한 응급의료시설 웹서비스</code>를 개발해보겠습니다.</p>
<p>우선은 웹서비스의 형태로 제작한 뒤에, 향후에 사용성을 고려해 어플리케이션의 형태로도 제작하는 것을 계획하고 있습니다.
<br>
<br></p>
<h2 id="1-요구사항-분석-및-기능-도출">1. 요구사항 분석 및 기능 도출</h2>
<hr>
<h3 id="요구사항-분석사용자">요구사항 분석(사용자)</h3>
<ul>
<li><p>지도에서 현재 위치 주변의 응급의료시설의 위치를 확인할 수 있다.</p>
</li>
<li><p>지도에서 각 응급의료시설의 정보를 확인할 수 있다.</p>
</li>
<li><p>지역, 시설명으로 응급의료시설을 검색할 수 있다.</p>
</li>
<li><p>특정 시술 가능 여부, 가용 병상 수 등의 조건을 사용해 필터링할 수 있다.</p>
</li>
<li><p>잘못된 시설 정보가 있을 경우 정보 수정을 제안할 수 있다.</p>
</li>
<li><p>특정 응급의료시설을 북마크할 수 있다.</p>
<br>

</li>
</ul>
<h3 id="기능-도출">기능 도출</h3>
<ul>
<li><p>응급의료시설 위치 표시 기능</p>
</li>
<li><p>응급의료시설 정보 출력 기능</p>
</li>
<li><p>응급의료시설 검색, 필터링 기능</p>
</li>
<li><p>응급의료시설 북마크 기능</p>
</li>
<li><p>정보 수정 제안 처리 기능</p>
<br>
<br>

</li>
</ul>
<h2 id="2-아키텍처-설계">2. 아키텍처 설계</h2>
<hr>
<p>위와 같은 요구사항, 기능을 구현하기 위해 아래 사진과 같이 아키텍처를 설계했습니다. 프로젝트를 진행하며 요구사항의 변화, 추가적인 기능 구현 등의 요인으로 인해 아키텍처는 변경될 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/e82a6e4f-9440-4b9e-b87a-9be2b0d82971/image.png" alt=""></p>
<ul>
<li><p>공공데이터포털에서 제공하는 5개 API의 정보는 Airflow를 사용해 일 배치로 RDS(Production DB)에 저장합니다.</p>
</li>
<li><p><code>응급실 실시간 가용병상정보 조회 API</code>는 Kafka를 사용해 실시간으로 데이터를 받아와서, S3(Data Lake)에 저장된 제일 최근의 데이터와 비교해 차이가 있을 경우 S3에 저장합니다.</p>
</li>
<li><p>Redshift(Data Warehouse)에는 S3와 RDS의 데이터를 저장하고, 저장된 데이터를 Tableau를 사용해 시각화합니다.</p>
</li>
<li><p>EC2로 구동 예정인 웹서비스의 백엔드에서는 <code>응급의료기관 위치정보 조회 API</code>와 <code>외상센터 위치정보 조회 API</code>에 요청을 보내 사용자 위치 주변의 응급의료시설 데이터를 가져옵니다.</p>
<ul>
<li>또한, S3에 저장된 실시간 가용병상정보 데이터를 가공해 사용자에게 제공합니다.<br>
<br>

</li>
</ul>
</li>
</ul>
<h2 id="3-데이터-모델링">3. 데이터 모델링</h2>
<hr>
<p>다음으로는 아래 사진과 같이 RDS와 S3에 저장될 데이터의 구조를 정의했습니다. 아키텍처와 마찬가지로 프로젝트를 진행하며 변경될 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/28784850-0ca3-461f-ac56-1cdefcbaa696/image.png" alt=""></p>
<p>각 의료기관의 지역 정보를 나타내기 위해 행정구역시도시군구(LOCATIONS)라는 별도의 테이블을 생성했고, 의료기관기본(HOSPITAL_BASIC_INFO), 의료기관상세(HOSPITAL_DETAIL_INFO) 테이블은 RDS에, 가용병상정보(AVAILABLE_BED_INFO) 테이블은 S3에 저장합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #9] 채용공고 일정 추가 (2)]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-9-%EC%B1%84%EC%9A%A9%EA%B3%B5%EA%B3%A0-%EC%9D%BC%EC%A0%95-%EC%B6%94%EA%B0%80-2</link>
            <guid>https://velog.io/@ian_lee/PlanTo-9-%EC%B1%84%EC%9A%A9%EA%B3%B5%EA%B3%A0-%EC%9D%BC%EC%A0%95-%EC%B6%94%EA%B0%80-2</guid>
            <pubDate>Wed, 02 Aug 2023 16:17:19 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>API Key 숨기기</p>
</li>
<li><p>location code 크롤러 제작</p>
</li>
<li><p>industry code 크롤러 제작</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>저번 글에서 구현했던 채용공고 뷰(JobList)의 코드를 살펴보면, 아래와 같이 사람인 API에 요청을 보내기 위해 base URL과 API Key를 사용합니다. </p>
<pre><code class="language-py">class JobList(APIView):
  # 채용공고 목록 출력
  def get(self, request, format = None):
      base_url = &quot;https://oapi.saramin.co.kr/job-search?access-key=&quot;
      api_key = &quot;XXX&quot;

      base_url += api_key</code></pre>
<br>

<p>API Key를 깃허브에 그대로 커밋할 경우 악의적으로 사용되는 등의 문제가 발생할 수 있습니다. 따라서 이번 글에서는 환경 변수를 사용해 API Key 등의 민감한 정보를 처리하는 방법에 대해 알아보겠습니다.
<br>
<br></p>
<h2 id="1-api-key-숨기기">1. API Key 숨기기</h2>
<hr>
<p>민감한 정보 혹은 코드를 외부로부터 숨기는 방법은 여러가지가 있지만, 그 중에서 config.ini 파일과 configparser 모듈을 사용해보겠습니다. 우선, 아래 사진과 같이 job_announcement app 안에 환경설정을 위한 config.ini 파일을 생성하고 API_KEY라는 섹션 아래 SARAMIN_API_KEY라는 환경변수를 작성해줬습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/1927b88a-d436-49a9-af5c-abc90505c71e/image.png" alt=""></p>
<pre><code>[API_KEYS]
SARAMIN_API_KEY = my_api_key</code></pre><br>
<br>

<p>다음으로, 해당 환경변수를 사용할 JobList 뷰에서 다음과 같이 configparser 라이브러리를 사용해 API Key를 호출해 사용했습니다.</p>
<pre><code class="language-py">class JobList(APIView):
    # 채용공고 목록 출력
    def get(self, request, format = None):
        base_url = &quot;https://oapi.saramin.co.kr/job-search?access-key=&quot;

        config = configparser.ConfigParser()
        config_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), &quot;config.ini&quot;)
        config.read(config_file_path)

        api_key = config.get(&quot;API_KEYS&quot;, &quot;SARAMIN_API_KEY&quot;)
        base_url += api_key

        ...</code></pre>
<br>

<p>만약 config.ini 파일이 깃허브에 올라간다면 위의 과정이 아무 의미 없게 될 것입니다. 따라서 .gitignore 파일에 config.ini를 추가했습니다.</p>
<p>코드 수정 이후에 Postman을 사용해 /jobs로 요청을 보내면, 아래 사진과 같이 수정 이전과 동일하게 잘 동작하는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/9e0045c1-b217-4a7b-b11a-8aa0ff169d1c/image.png" alt="">
<br>
<br></p>
<h2 id="2-location-code-크롤러-제작">2. location code 크롤러 제작</h2>
<hr>
<p>JobList 뷰의 get() 메서드에서 사용자가 지역, 직무, 키워드를 사용해 채용공고를 검색할 수 있도록 아래와 같이 query parameter들을 정의했습니다. </p>
<pre><code class="language-py">class JobList(APIView):
    # 채용공고 목록 출력
    def get(self, request, format = None):
        ...

        # 사용자가 지역, 업종, 키워드를 검색 조건으로 사용 가능
        query_parameters = [
            &quot;location&quot;,
            &quot;industry&quot;,
            &quot;keywords&quot;
        ]</code></pre>
<br>

<p><a href="https://oapi.saramin.co.kr/guide/job-search">사람인 API의 명세서</a>의 요청 변수(Request Parameters)를 살펴보면, 이들 중 키워드는 문자열 형태로 바로 입력받아 사용 가능하지만, 지역과 업종의 경우, 코드이기 때문에 바로 사용이 불가능하다는 것을 확인할 수 있습니다.</p>
<p>따라서 사용자가 브라우저에서 원하는 지역, 업종을 선택하면, 해당 선택지에 해당하는 코드 값을 요청 변수로 변환해 사람인 API에 요청을 보내는 기능을 구현해보겠습니다. 이를 위해서 지역, 업종의 각 항목별 코드를 저장해놔야하므로 크롤러를 제작해보겠습니다.
<br>
<br></p>
<h3 id="beautifulsoup-vs-selenium">BeautifulSoup vs Selenium</h3>
<p>Python으로 웹 크롤링을 하는 방법은 두 가지인데, 그 중 하나는 requests 라이브러리로 요청을 보낸 뒤 응답을 BeautifuleSoup으로 파싱하는 것이고, 다른 하나는 Selenium 라이브러리로 웹 브라우저에서 정보를 가져오는 것입니다.</p>
<p>Selenium의 경우 클릭 등의 웹 컨트롤을 할 수 있는 장점이 있는만큼 무겁고 느리기 때문에 동적 크롤링이 필요하지 않은 경우라면 requests와 BeautifulSoup을 활용하는 것이 효과적입니다. 단순히 지역, 업종 항목과 해당하는 코드 두 가지만 필요하기 때문에, 이번 글에서는 requests와 BeautifulSoup을 사용해보겠습니다.
<br>
<br></p>
<h3 id="크롤러-제작">크롤러 제작</h3>
<p>크롤러를 제작하기 위해 우선 필요한 라이브러리들을 아래와 같이 설치해줬습니다.</p>
<pre><code>poetry add requests
poetry add beautifulsoup4</code></pre><br>

<p>크롤링할 대상인 <a href="https://oapi.saramin.co.kr/guide/code-table2">사람인 API 명세서 - 근무지/지역 코드표</a> 페이지를 보면, 아래 사진과 같이 테이블에 4개의 컬럼이 있는 것을 확인할 수 있습니다. 이 중 지역코드와 지역명에 해당하는 첫 번째, 두 번째 컬럼만 추출해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/bccef88d-ffac-45dd-b2f5-541af72335bd/image.png" alt="">
아래와 같이 requests와 BeautifulSoup라이브러리를 사용해 지역 코드를 가져오는 fetch_location_codes() 메서드와, 이를 csv 파일에 저장하는 save_to_csv() 메서드를 작성했습니다.</p>
<pre><code class="language-py">import requests
import csv
from bs4 import BeautifulSoup

def fetch_location_codes():
    url = &quot;https://oapi.saramin.co.kr/guide/code-table2&quot;
    response = requests.get(url)

    if response.status_code == 200:
        soup = BeautifulSoup(response.content, &quot;html.parser&quot;)
        location_codes = {}

        # 헤더인 첫 번째 줄은 건너뛰고 두 번째 줄부터 반복
        for row in soup.select(&quot;table tr&quot;)[1:]:
            if row.select(&quot;td&quot;):

                # 앞에서 2개 항목(컬럼)만 추출
                code, name = row.select(&quot;td&quot;)[:2]
                location_codes[name.text.strip()] = code.text

        return location_codes

    else:
        print(f&quot;지역 코드를 가져오는 데 실패했습니다.(상태 코드: {response.status_code})&quot;)
        return None

def save_to_csv(location_codes):
    if not location_codes:
        return

    with open(&quot;location_codes.csv&quot;, &quot;w&quot;, newline = &quot;&quot;, encoding = &quot;utf-8&quot;) as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow([&quot;지역명&quot;, &quot;지역코드&quot;])

        for name, code in location_codes.items():
            writer.writerow([name, code])

if __name__ == &quot;__main__&quot;:
    location_codes = fetch_location_codes()

    if location_codes:
        save_to_csv(location_codes)
        print(&quot;지역 코드가 location_codes.csv에 저장되었습니다.&quot;)

    else:
        print(&quot;지역 코드를 저장하는 데 실패했습니다.&quot;)</code></pre>
<br>

<p>작성한 크롤러를 실행하면, 아래 사진과 같이 지역명과 지역코드만 추출한 csv 파일이 잘 생성되었음을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/ian_lee/post/d694da02-c496-4121-908f-bf02bb7856c3/image.png" width = 800px/>
<img src="https://velog.velcdn.com/images/ian_lee/post/108f5f98-0954-4f3b-90da-0216df38a589/image.png" alt="">
<br>
<br></p>
<h2 id="3-industry-code-크롤러-제작">3. industry code 크롤러 제작</h2>
<hr>
<p>위에서 location code 크롤러를 제작한 것과 마찬가지로, industry code 크롤러도 제작해보겠습니다.</p>
<p>우선 크롤링할 대상 URL을 알아야하는데, <a href="https://oapi.saramin.co.kr/guide/code-table3">사람인 API 명세서 - 산업/업종 코드표</a>를 보면, 페이지가 세 개의 항목으로 나눠져있는데 브라우저에는 모두 같은 URL로 표시되어 있었습니다. 따라서 개발자 도구(검사)를 사용해 아래 사진과 같이 해당하는 항목에 걸려있는 a태그를 찾아서 세부 URL을 알아냈고, 해당 URL로 접속 시 첫 번째 항목(상위 산업/업종 코드표)이 아닌 대상 항목(업종 키워드 코드표)으로 잘 접속되는 것을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/b40a73e3-ae91-4003-990e-4cb1bcb858c9/image.png" alt="">
<br></p>
<p>URL을 알아낸 뒤에는 location code 크롤러와 마찬가지로 fetch_location_codes() 메서드, save_to_csv() 메서드를 작성했습니다. 위 사진의 테이블에서 첫 번째, 두 번째 컬럼만 추출했습니다.</p>
<pre><code class="language-py">import requests
import csv
from bs4 import BeautifulSoup

def fetch_industry_codes():
    # 대상 항목 URL
    url = &quot;https://oapi.saramin.co.kr/guide/code-table3#lasKeywordCodelist&quot;
    response = requests.get(url)

    if response.status_code == 200:
        soup = BeautifulSoup(response.content, &quot;html.parser&quot;)
        industry_codes = {}

        for row in soup.select(&quot;table tr&quot;)[1:]:
            if row.select(&quot;td&quot;):
                code, name = row.select(&quot;td&quot;)[:2]
                industry_codes[name.text.strip()] = code.text

        return industry_codes

    else:
        print(f&quot;업종 코드를 가져오는 데 실패했습니다.(상태 코드: {response.status_code})&quot;)
        return None

def save_to_csv(industry_codes):
    if not industry_codes:
        return

    with open(&quot;industry_codes.csv&quot;, &quot;w&quot;, newline = &quot;&quot;, encoding = &quot;utf-8&quot;) as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow([&quot;업종명&quot;, &quot;업종코드&quot;])

        for name, code in industry_codes.items():
            writer.writerow([name, code])

if __name__ == &quot;__main__&quot;:
    industry_codes = fetch_industry_codes()

    if industry_codes:
        save_to_csv(industry_codes)
        print(&quot;업종 코드가 industry_codes.csv에 저장되었습니다.&quot;)

    else:
        print(&quot;업종 코드를 저장하는 데 실패했습니다.&quot;)</code></pre>
<br>

<p>작성한 크롤러를 실행하면, 아래 사진과 같이 업종명과 업종코드만 추출한 csv 파일이 잘 생성되었음을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/ian_lee/post/ea15b5ec-b7d5-4258-8e19-70ad6cc4e216/image.png" width = 800px/></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/94d3aac0-5e92-4b81-b7cb-2dacca0c9881/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #8] 채용공고 일정 추가]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-8-%EC%B1%84%EC%9A%A9%EA%B3%B5%EA%B3%A0-%EC%9D%BC%EC%A0%95-%EC%B6%94%EA%B0%80</link>
            <guid>https://velog.io/@ian_lee/PlanTo-8-%EC%B1%84%EC%9A%A9%EA%B3%B5%EA%B3%A0-%EC%9D%BC%EC%A0%95-%EC%B6%94%EA%B0%80</guid>
            <pubDate>Sat, 29 Jul 2023 12:02:15 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>app 생성</p>
</li>
<li><p>채용공고(Job) 모델 구현</p>
</li>
<li><p>채용공고 뷰 구현</p>
</li>
<li><p>테스트</p>
<br>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 사람인 open API를 활용해 사용자에게 채용공고를 보여주고, 사용자가 선택한 채용공고를 일정(Task)으로 변환해 추가하는 기능을 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-app-생성">1. app 생성</h2>
<hr>
<p>채용공고와 관련된 기능을 전담하는 job_announcement라는 app을 생성해 settings.py에 등록해보겠습니다.
<br></p>
<p><code>python manage.py startapp job_announcement</code>
<br></p>
<h3 id="projectnamesettingspy">projectname/settings.py</h3>
<pre><code class="language-py">  INSTALLED_APPS = [
      ...
      &#39;job_announcement.apps.JobAnnouncementConfig&#39;,
  ]</code></pre>
<ul>
<li>app 이름에 언더바가 들어가서 Job_AnnouncementConfig와 JobAnnouncementConfig 둘 중에 어느 것이 맞는지 헷갈렸는데, apps.py에 정의된 config 클래스를 등록하는 것이기 때문에 후자가 맞음<br>

</li>
</ul>
<ul>
<li><p>INSTALLED_APPS에 app 이름을 바로 등록할 수도 있고 위와 같이 config 클래스를 등록할 수도 있음</p>
<ul>
<li>app 이름을 바로 등록하는 것이 더 직관적이고 편하지만, app의 출력 이름이나 동작 등을 커스터마이징할 수 있어 더 유연한 config 클래스를 등록했음<br>
<br>

</li>
</ul>
</li>
</ul>
<h2 id="2-채용공고job-모델-구현">2. 채용공고(Job) 모델 구현</h2>
<hr>
<p>사람인 API에서 받아온 채용공고 데이터를 DB에 저장하기 위한 Job 모델을 구현해보겠습니다.</p>
<h3 id="job_announcementmodelspy">job_announcement/models.py</h3>
<pre><code class="language-py">  from django.db import models
  from django.conf import settings

  class Job(models.Model):
      owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name = &quot;jobs&quot;, on_delete = models.CASCADE, null = True)
      title = models.CharField(max_length = 255)
      description = models.TextField(null = True)
      company = models.CharField(max_length = 255)
      position = models.CharField(max_length = 255)
      due_date = models.DateField()
      location = models.CharField(max_length = 255, null = True)
      salary = models.IntegerField(null = True)

      class Meta:
          db_table = &quot;job&quot;</code></pre>
<ul>
<li><p>사용자가 선택한 채용공고의 목록을 프로필 페이지에서 열람할 수 있도록, User 모델을 ForeignKey로 연결</p>
<br>
</li>
<li><p>due_date: 채용공고 마감일</p>
<br>

</li>
</ul>
<h3 id="job_announcementserializerspy">job_announcement/serializers.py</h3>
<pre><code class="language-py">  from rest_framework import serializers
  from .models import Job

  class JobSerializer(serializers.ModelSerializer):
      class Meta:
          model = Job
          fields = [&quot;title&quot;, &quot;description&quot;, &quot;company&quot;, &quot;position&quot;, &quot;due_date&quot;, &quot;location&quot;, &quot;salary&quot;]</code></pre>
  <br>  

<p>UserSerializer에서도 User모델과 Job 모델을 연결해줬습니다.</p>
<h3 id="authenticationserializerspy">authentication/serializers.py</h3>
<pre><code class="language-py">  ...

  class UserSerializer(serializers.ModelSerializer):
      ...

      # User가 소유한 Task 모델(일정)과 Job 모델(채용공고)을 PrimaryKeyRelatedField로 연결
      tasks = serializers.PrimaryKeyRelatedField(many = True, queryset = Task.objects.all())
      jobs = serializers.PrimaryKeyRelatedField(many = True, queryset = Job.objects.all())

      ...</code></pre>
  <br>
  <br>

<h2 id="3-채용공고-뷰-구현">3. 채용공고 뷰 구현</h2>
<hr>
<p>이제 사용자가 채용공고 화면을 볼 수 있도록 뷰를 구현해보겠습니다. JobList 뷰에서 사용자가 마음에 드는 채용공고를 선택하면, 해당하는 데이터를 JSON 형태로 받아서 Deserialize한 뒤 Job 모델과 Task 모델로 변환해 저장합니다.</p>
<h3 id="job_announcementviewspy">job_announcement/views.py</h3>
<pre><code class="language-py">  from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from .serializers import JobSerializer
from todo.serializers import TaskSerializer
from datetime import datetime
import requests

class JobList(APIView):
    # 채용공고 목록 출력
    def get(self, request, format = None):
        base_url = &quot;https://oapi.saramin.co.kr/job-search?access-key=&quot;
        api_key = &quot;dF3WMih4YXpczw7vEtUXuGZCZefkMkcK2qA1cIyZ9eqC3zecTVq&quot;

        base_url += api_key

        # 사용자가 지역, 직무, 검색어를 검색 조건으로 사용 가능
        query_parameters = [
            &quot;location&quot;,
            &quot;position&quot;,
            &quot;keywords&quot;
        ]

        query_params = {}

        # 사용자가 보낸 요청에 검색 조건이 있을 경우, 해당 조건을 사람인 API로 보낼 요청의 query_params로 사용
        for parameter in query_parameters:
            parameter_value = request.query_params.get(parameter, &quot;&quot;)

            if parameter_value:
                query_params[parameter] = parameter_value

        # 검색 결과 수 10개로 지정
        query_params[&quot;count&quot;] = 10

        response = requests.get(base_url, params = query_params)

        # 응답 코드가 HTTP 200인 경우, JSON 형태로 데이터를 반환하고, 다른 값일 경우 해당 응답 코드와 에러 메시지 반환
        if response.status_code == 200:
            data = response.json()

            return Response(data)

        else:
            return Response({&quot;error&quot;: &quot;채용공고를 불러오는 데 실패했습니다.&quot;}, status = response.status_code)

    # 사용자가 채용공고 일정에 추가
    def post(self, request, format = None):
        try:
            data = request.data

            # Unix timestamp 형태로 전달되는 데이터를 datetime으로 변환
            due_date = data.get(&quot;due_date&quot;)
            if due_date:
                due_date = datetime.utcfromtimestamp(int(due_date)).date()
                data[&quot;due_date&quot;] = due_date

            # 데이터 Deserialize
            job_serializer = JobSerializer(data = data)
            task_serializer = TaskSerializer(data = data)

            # 데이터가 유효한 경우, 채용공고를 사용자 모델에 추가 및 일정 모델로 변환
            if job_serializer.is_valid() and task_serializer.is_valid():
                job_serializer.save(owner = request.user)
                task_serializer.save(owner = request.user)

                return Response({&quot;message&quot;: &quot;채용공고가 추가되었습니다.&quot;}, status = status.HTTP_201_CREATED)

            return Response(job_serializer.errors, status = status.HTTP_400_BAD_REQUEST)

        except Exception as e:
            return Response({&quot;error&quot;: str(e)}, status = status.HTTP_400_BAD_REQUEST)
</code></pre>
<ul>
<li><p>get(): 사람인 API에 요청을 보내서 받은 채용공고 목록을 출력하는 역할</p>
</li>
<li><p>post(): 사용자가 마음에 드는 채용공고를 일정에 추가하는 역할</p>
<br>

</li>
</ul>
<h3 id="job_announcementurlspy">job_announcement/urls.py</h3>
<pre><code class="language-py">  from django.urls import path
  from .views import *

  urlpatterns = [
      path(&#39;&#39;, JobList.as_view()),
  ]</code></pre>
<br>
<br>

<h2 id="4-테스트">4. 테스트</h2>
<hr>
<p>위에서 구현한 뷰를 Postman을 사용해서 테스트해보겠습니다. 우선 GET 요청을 보내 채용공고 목록을 확인해보겠습니다.
<br></p>
<h3 id="채용공고-목록-테스트">채용공고 목록 테스트</h3>
<p>  <img src="https://velog.velcdn.com/images/ian_lee/post/46e49536-20c8-4342-880b-ff38f91c241d/image.png" alt=""></p>
<p>위 사진과 같이 요청을 보내면 아래 사진과 같이 사람인 API가 채용공고 목록을 JSON 형태로 반환해주는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/6b88a111-206a-45cb-9e32-7ded6a216eba/image.png" alt="">
<br></p>
<p>해당 응답을 자세히 살펴보면 아래와 같이 expiration-date 즉, 마감일이 일반적인 날짜 형식이 아닌 Unix timestamp 형식으로 출력되는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/1947c951-3f85-4c32-893e-20daea7e08c9/image.png" alt=""></p>
<p>따라서 채용공고를 추가하는 post() 메서드에 아래와 같이 due_date 필드를 날짜 형식으로 변환해 저장하는 로직을 추가했습니다.</p>
<pre><code class="language-py"># Unix timestamp 형태로 전달되는 데이터를 datetime으로 변환
            due_date = data.get(&quot;due_date&quot;)
            if due_date:
                due_date = datetime.fromtimestamp(int(due_date))
                data[&quot;due_date&quot;] = due_date</code></pre>
<br>
<br>

<p>또한, 아래 사진과 같이 연봉 정보가 정수가 아닌 문자열 형태로 반환되고, 경우의 수가 너무 많기 때문에 Job 모델의 salary 필드를 IntegerField가 아닌 CharField로 변경해줬습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/63cffb92-7ab6-4fd2-9b46-f44b0a6a3408/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/b3b120dd-388e-4dce-b335-5a122eaf884e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/18afccc1-b5f9-4410-8270-3f875a92017a/image.png" alt=""></p>
<pre><code class="language-py">class Job(models.Model):
    ...
    salary = models.CharField(max_length = 255, null = True)

    class Meta:
        db_table = &quot;job&quot;</code></pre>
<br>
<br>

<h3 id="채용공고-추가-테스트">채용공고 추가 테스트</h3>
<p>사용자가 채용공고에서 마음에 드는 항목을 찾아 버튼을 눌렀을 때, 서버에 아래 사진과 같이 JSON 형태의 응답이 반환된다고 가정해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/f7a9417f-0429-4ef2-9cac-6a1d2c9f801f/image.png" alt=""><br></p>
<ul>
<li><p><span style="color: indianred">APPEND_SLASH 에러</span></p>
<p>해당 요청을 보낼 경우 아래 사진과 같이 APPEND_SLASH와 관련된 에러가 발생하는 것을 확인할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/57952688-f835-4993-9224-afa9f0159c3a/image.png" alt=""></p>
<p>APPEND_SLASH란 django에서 브라우저가 요청한 URL이 urls.py에 정의돼있지 않은 경우해당 URL에 자동으로 슬래시를 붙인 URL은 있는지 확인하는 기능입니다.슬래시를 붙였을 때 일치하는 URL이 있으면 해당 항목으로 redirect하고, 없으면 404 에러를 반환합니다. </p>
<p>위 상황의 경우, 에러 메시지에서 말한 것과 같이 POST 데이터를 유지한 채 슬래시를 추가한 URL로 redirect를 못하기 때문에 에러가 발생합니다. 이를 해결하기 위해서 urls.py에서 슬래시를 제외한 URL로 등록하거나, 브라우저 슬래시를 포함한 URL로 요청을 주는 방법을 사용할 수 있습니다. </p>
<p>전자의 경우 어떤 URL에는 슬래시가 붙어있고 어떤 URL에는 붙어있지 않아서 urlpattern이 일관되지 않습니다. 따라서 프런트엔드에서 요청을 보낼 때 슬래시를 붙여서 보내는 방식으로 구현할 예정입니다.</p>
<br>
<br>

</li>
</ul>
<p>동일한 JSON 데이터를 슬래시를 붙인 URL을 사용해 POST 요청으로 보낼 경우, 아래 사진과 같이 HTTP 201과 함께 채용공고가 추가되었다는 메시지를 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/3c8e42ac-0a5e-4331-a9b7-d861570a4028/image.png" alt="">
<br></p>
<p>이제 admin 페이지에서 Job, Task 모델을 보면, 아래의 두 사진과 같이 모델이 DB에 잘 저장되었음을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/3cf14f4d-70ef-4e52-b562-72e70978972c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/3ca1490c-58c3-47cd-a625-aed1f48a46e3/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #7] 프로젝트 구조 개편]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-7-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EA%B0%9C%ED%8E%B8</link>
            <guid>https://velog.io/@ian_lee/PlanTo-7-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EA%B0%9C%ED%8E%B8</guid>
            <pubDate>Fri, 28 Jul 2023 03:41:06 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>프로젝트 구조 개편</p>
</li>
<li><p>프로젝트 구조도</p>
</li>
<li><p>회고</p>
<br>


</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>해당 프로젝트의 구조는 아래 사진과 같이 사용자 인증을 위한 authentication, 플래너 기능 구현을 위한 todo, 그리고 프로젝트를 관리하기 위한 planto 총 세 개의 django app으로 이루어져있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/a9d12130-e549-43e8-bba6-c5a725aadaec/image.png" alt=""></p>
<p>Django와 DRF를 사용해 REST API를 만들기 위해 serializer.py를 구현했는데, 기존에는 아래 사진과 같이 todo app의 serializer.py에 사용자 관련 기능을 포함해 해당 프로젝트의 모든 기능을 구현하고자 했습니다. </p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/ca389a4a-13a2-4a01-b74c-e88bc9a5b142/image.png" alt=""></p>
<p>이 경우 정상적으로 동작은 하겠지만, 관심사를 분리하기 위해 여러 개의 django app으로 프로젝트를 분리해놓고, 하나의 app에 모든 serializer를 구현하는 것이 상반된다는 것을 뒤늦게 깨달았습니다. 또한, app 간의 결합도가 높아지기 때문에 유지보수성이 떨어진다는 단점이 존재합니다.</p>
<p>따라서 이번 글에서는 각각의 app에서 serializer를 구현하는 방식으로 프로젝트 구조를 개편해보겠습니다.
<br></p>
<h2 id="1-프로젝트-구조-개편">1. 프로젝트 구조 개편</h2>
<hr>
<p>우선, 사용자 인증 관련 로직을 authentication/serializers.py 로 옮겨보겠습니다. 각 모듈을 import할 때, 라이브러리의 경우 문제가 없겠지만 각 app의 models.py, serializers.py, views.py는 경로가 꼬일 수도 있으므로 이 부분을 신경써서 수정했습니다.
<br></p>
<ul>
<li><p>authentication/serializers.py</p>
<pre><code class="language-py">from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed
from django.contrib.auth import get_user_model, authenticate
from django.contrib.auth.password_validation import validate_password
from .models import User
from todo.models import Task

class UserSerializer(serializers.ModelSerializer):
    ...

class RegistrationSerializer(serializers.ModelSerializer):
    ...

class LoginSerializer(serializers.ModelSerializer):
    ...</code></pre>
<br>

</li>
</ul>
<p>serializer를 app별로 분리한 것과 마찬가지로, 사용자와 관련된 뷰도 authentication app에서 처리하도록 views.py도 옮겨주었습니다.
<br></p>
<ul>
<li><p>authentication/views.py</p>
<pre><code class="language-py">from rest_framework import generics, status
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from .models import User
from .serializers import *
from .renderers import UserJsonRenderer

class UserList(generics.ListAPIView):
    ...

class UserDetail(generics.RetrieveUpdateAPIView):
    ...

class Registration(generics.CreateAPIView):
    ...

class Login(APIView):
    ...</code></pre>
<br>

</li>
</ul>
<p>이제 views.py를 옮겨주었으니 url을 제대로 매핑하기 위해 urls.py를 수정해보겠습니다.
<br></p>
<ul>
<li><p>authentication/urls.py</p>
<pre><code class="language-py">from django.urls import path
from .views import *

urlpatterns = [
    path(&#39;&#39;, UserList.as_view()),
    path(&#39;/&lt;int:pk&gt;&#39;, UserDetail.as_view()),
    path(&#39;register&#39;, Registration.as_view()),
    path(&#39;login&#39;, Login.as_view()),
]</code></pre>
<br>
</li>
<li><p>projectname/urls.py</p>
<pre><code class="language-py">...

urlpatterns = [
    ...
    path(&#39;users&#39;, include(&#39;authentication.urls&#39;)),
]
</code></pre>
<br>

</li>
</ul>
<h3 id="url-접속-테스트">URL 접속 테스트</h3>
<p>이제 postman을 사용해 수정된 각각의 뷰에 접속이 가능한지 테스트해보겠습니다.
<br></p>
<ul>
<li><p>UserList(/users)
<img src="https://velog.velcdn.com/images/ian_lee/post/5f696af4-c473-41ea-a577-3d29cd3fd3e8/image.png" alt=""><br></p>
</li>
<li><p>UserDetail(/users/<a href="int:pk">int:pk</a>)
<img src="https://velog.velcdn.com/images/ian_lee/post/447b2854-83c0-454a-9b98-acd27301b181/image.png" alt=""><br></p>
</li>
<li><p>Registration(/users/register)
<img src="https://velog.velcdn.com/images/ian_lee/post/97ba6f6e-781c-4e7e-a16d-108d73bf8d0c/image.png" alt=""><br></p>
</li>
</ul>
<ul>
<li>Login(/users/login)
<img src="https://velog.velcdn.com/images/ian_lee/post/ed0372ab-584b-48df-a69d-e742380610a9/image.png" alt=""><br></li>
</ul>
<p>위의 4장의 사진과 같이 각각의 뷰에 접속이 잘 되는 것을 확인할 수 있습니다.
<br></p>
<h2 id="2-프로젝트-구조도">2. 프로젝트 구조도</h2>
<hr>
<p>향후에 구조 관련 문제를 방지하기 위해, 프로젝트의 구조를 명확하게 파악할 필요성을 느껴 아래와 같이 구조도를 작성했습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/fe3474a0-f8aa-45f4-b4eb-eabe57b44ae5/image.png" alt=""></p>
<p>React로 프런트엔드 서버를, Amazon EC2로 authentication, job_announcement, todo 세 개의 django app으로 구성된 REST API 서버를 구동할 예정이며 , 사람인 open API를 사용하고 MySQL에 데이터를 저장할 예정입니다.</p>
<p>프로젝트를 진행하며 추가적인 기능 구현 혹은 Docker, AWS 서비스 도입에 따라 구조도가 변경될 수 있습니다.</p>
<br>

<h2 id="3-회고">3. 회고</h2>
<hr>
<p>이번 글에서 진행한 구조 개편의 경우, 다행히 프로젝트 초기이기 때문에 수정사항이 많지 않아서 시간이 오래 걸리거나 크게 어렵지 않았습니다. 만약 기능을 많이 구현한 뒤에 진행했으면 훨씬 어렵고 복잡했을 것 같습니다.
<br></p>
<p>기존에는 아키텍처와 성능에 대해 크게 고려하지 않은 채 배운 내용이나 검색을 통해 찾은 내용을 따라하거나 개선하는 방식으로 개발했습니다. 이후부터는 이와 같은 경험을 바탕으로 아키텍처와 더 좋은 성능을 염두에 두고 개발하는 습관을 들이려고 노력해보겠습니다.</p>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #6] 사용자 정보 갱신]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-6-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%EC%88%98%EC%A0%95</link>
            <guid>https://velog.io/@ian_lee/PlanTo-6-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%EC%88%98%EC%A0%95</guid>
            <pubDate>Fri, 21 Jul 2023 15:32:55 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>serializers.py</p>
</li>
<li><p>views.py</p>
</li>
<li><p>backends.py</p>
</li>
<li><p>테스트</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 사용자 정보를 갱신하는 기능을 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-serializerspy">1. serializers.py</h2>
<hr>
<p>기존에 구현했던 UserSerializer의 경우, 단순히 조회 기능만을 구현했습니다. 여기에 update() 메서드를 추가해 정보 갱신 기능을 구현해보겠습니다.
<br></p>
<ul>
<li><p>기존</p>
<pre><code class="language-py">  ...

  class UserSerializer(serializers.ModelSerializer):
      # User의 Task 객체를 PrimaryKeyRelatedField로 연결
      tasks = serializers.PrimaryKeyRelatedField(many = True, queryset = Task.objects.all())

      class Meta:
          model = get_user_model()
          fields = [&quot;username&quot;, &quot;email&quot;, &quot;tasks&quot;]</code></pre>
  <br>
</li>
<li><p>수정</p>
<pre><code class="language-py">...

class UserSerializer(serializers.ModelSerializer):
    password = serializers.CharField(max_length = 128, write_only = True, required = True, validators = [validate_password])

    # User가 소유한 Task 모델(일정)을 PrimaryKeyRelatedField로 연결
    tasks = serializers.PrimaryKeyRelatedField(many = True, queryset = Task.objects.all())

    def update(self, instance, validated_data):
        password = validated_data.pop(&quot;password&quot;, None)

        if password is not None:
            instance.set_password(password)

        for key, value in validated_data.items():
            setattr(instance, key, value)

        instance.save()

        return instance

    class Meta:
        model = get_user_model()
        fields = [&quot;username&quot;, &quot;email&quot;, &quot;password&quot;, &quot;token&quot;, &quot;tasks&quot;]</code></pre>
<ul>
<li><p>password는 보안상의 이유로 출력하지 않기 위해 write_only</p>
</li>
<li><p>password는 보안상의 이유로 validated_data에서 추출해 별도로 처리한 뒤, setattr() 메서드를 사용해 나머지 속성 설정</p>
<br>
<br>

</li>
</ul>
</li>
</ul>
<h2 id="2-viewspy">2. views.py</h2>
<hr>
<p>위에서 구현한 serializer를 기반으로 뷰를 구현해보겠습니다. 기존에 구현했던 UserDetail 클래스는 단순히 사용자의 정보를 보여주는 역할만 했기 때문에 generics.RetrieveAPIView를 상속했다면, 정보를 갱신하는 역할을 추가적으로 부여하기 위해 generics.RetrieveUpdateAPIView를 상속하는 방식으로 수정했습니다.
<br></p>
<ul>
<li><p>기존</p>
<pre><code class="language-py">class UserDetail(generics.RetrieveAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer</code></pre>
<br>
</li>
<li><p>수정</p>
<pre><code class="language-py">class UserDetail(generics.RetrieveUpdateAPIView):
    queryset = User.objects.all()
    permission_classes = (IsAuthenticated, )
    serializer_class = UserSerializer
    renderer_classes = (UserJsonRenderer, )</code></pre>
<ul>
<li><code>permission_classes = (IsAuthenticated, )</code>: 인증된 사용자만 접근이 가능하도록 설정<br>
<br>

</li>
</ul>
</li>
</ul>
<h2 id="3-backendspy">3. backends.py</h2>
<hr>
<p>urls.py는 수정사항이 없기 때문에, Postman을 사용해 UserDetail 뷰에 접속해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/2875cdc8-a30f-4feb-9705-79a3b1d8fbf7/image.png" alt=""></p>
<p>위 사진과 같이 PK(ID)가 1인 사용자의 정보를 보기 위해 GET 요청을 보내면, 아래 사진과 같이 HTTP 403 에러와 &quot;Authentication credentials were not provided&quot;라는 에러 메시지를 반환하는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/bd67dcb0-bdd8-4e15-8003-6def05a6bb3d/image.png" alt=""></p>
<p>이는 UserDetail 뷰에 permission_classes를 IsAuthenticated로 설정해줬고, 로그인을 하지 않은 상태로 url에 접속했기 때문에 발생한 에러입니다. 따라서 Postman에서 사용자의 JWT token을 사용해 인증했을 때 url에 접속이 가능한지 테스트해보겠습니다.
<br></p>
<h3 id="postman-token-테스트">Postman Token 테스트</h3>
<p>아래 사진과 같이 요청의 헤더에 Authorization Key값으로 &quot;token {사용자의 JWT token값}&quot;을 부여하는 방식으로 Postman에서 사용자 인증이 가능합니다. 사용자의 token값은 shell_plus 명령어를 사용하거나, 테스트에만 사용할 용도로 permission class를 설정하지 않은 뷰를 임시로 사용해 확인할 수 있습니다.</p>
<p align = "center"><img src = "https://velog.velcdn.com/images/ian_lee/post/56b4f6db-5fa1-4358-896c-006dcc18a896/image.png" width = "800px" /></p>

<p>다만, 아래 사진과 같이 token 값을 주기 전과 마찬가지로 HTTP 403 에러와 에러 메시지를 반환하는 것을 확인할 수 있습니다. 이는 Django가 기본적으로 JWT 인증을 제공하지 않아서 생긴 문제로, 이를 해결하기 위해 별도의 설정이 필요합니다. </p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/bd67dcb0-bdd8-4e15-8003-6def05a6bb3d/image.png" alt=""></p>
<p>DRF의 simplejwt라는 라이브러리를 사용할 경우 좀 더 간편하게 설정할 수 있지만, 확장성이 더 좋은 pyjwt를 사용했기 때문에 backends.py라는 파일을 생성 후, JWT 인증을 처리하는 JWTAuthentication 클래스를 구현해보겠습니다.
<br></p>
<pre><code class="language-py">import jwt

from django.conf import settings
from rest_framework import authentication, exceptions

from .models import User

class JWTAuthentication(authentication.BaseAuthentication):
    authentication_header_prefix = &#39;Token&#39;

    &quot;&quot;&quot;
        모든 요청에서 호출되는 인증 메서드

        특정 요청의 헤더에 &quot;token&quot;이라는 문자열이 포함되지 않은 경우, None 반환(인증 실패)
        인증에 성공한 경우 _authenticate_credentials() 메서드 호출

    &quot;&quot;&quot;

    def authenticate(self, request):

        # auth_header는 &quot;token {사용자의 JWT token값}&quot;의 형식
        auth_header = authentication.get_authorization_header(request).split()
        auth_header_prefix = self.authentication_header_prefix.lower()

        # auth_header의 &quot;token&quot;, {사용자의 JWT token값}을 decode
        prefix = auth_header[0].decode(&#39;utf-8&#39;)
        token = auth_header[1].decode(&#39;utf-8&#39;)

        # auth_header가 위의 형식에서 벗어나는 경우, None 반환
        if not auth_header:
            return None

        if len(auth_header) != 2:
            return None

        if prefix.lower() != auth_header_prefix:
            return None

        return self._authenticate_credentials(request, token)

    &quot;&quot;&quot;
        authenticate() 과정을 통과한 사용자에 대해 추가적인 인증 과정을 거친 후, 접근을 허가하는 메서드

        인증에 성공한 경우 (user, token) 반환
        에러 발생 시 AuthenticationFailed Exception 반환

    &quot;&quot;&quot;

    def _authenticate_credentials(self, request, token):

        # JWT token decode 가능 여부 확인
        try:
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms = [&#39;HS256&#39;])

        except:
            msg = &#39;Invalid authentication. Could not decode token.&#39;
            raise exceptions.AuthenticationFailed(msg)

        # 사용자 존재 여부 확인
        try:
            user = User.objects.get(pk = payload[&#39;id&#39;])

        except User.DoesNotExist:
            msg = &#39;No user matching this token was found.&#39;
            raise exceptions.AuthenticationFailed(msg)

        # 사용자 비활성화 여부 확인
        if not user.is_active:
            msg = &#39;This user has been deactivated.&#39;
            raise exceptions.AuthenticationFailed(msg)

        return (user, token)</code></pre>
<br>

<p>이후에는 해당 JWTAuthentication 클래스로 인증이 가능하도록 settings.py에 등록했습니다.</p>
<pre><code class="language-py">REST_FRAMEWORK = {
    &#39;DEFAULT_AUTHENTICATION_CLASSES&#39;: (
        &#39;authentication.backends.JWTAuthentication&#39;,
    ),
}</code></pre>
<br>
<br>

<h2 id="4-테스트">4. 테스트</h2>
<hr>
<p>이제 JWT 인증이 적용되어 UserDetail 뷰에 접속이 가능하고, 정보 갱신이 제대로 이루어지는지 테스트해보겠습니다.
<br></p>
<h3 id="접속-테스트">접속 테스트</h3>
<p align = "center"><img src = "https://velog.velcdn.com/images/ian_lee/post/56b4f6db-5fa1-4358-896c-006dcc18a896/image.png" width = "800px" /></p>

<p>위 사진과 같이 동일한 url에 접속을 시도했을 때, 아래 사진과 같이 1번 사용자의 정보가 응답으로 잘 출력되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/40598a8b-14fb-46c7-801f-d86895ac62ea/image.png" alt="">
<br></p>
<h3 id="사용자-정보-갱신-테스트">사용자 정보 갱신 테스트</h3>
<p>아래 사진과 같이 정보 갱신을 위해 GET이 아닌 PATCH로 email값을 수정하기 위한 요청을 보내보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/aa0c3d90-3c97-463d-bd8f-3e7cc28f5d39/image.png" alt=""></p>
<p>그 결과 아래 사진과 같이 기존 admin 사용자의 email값이 &quot;<a href="mailto:admin@test.com">admin@test.com</a>&quot;에서 요청으로 보낸 값인 &quot;<a href="mailto:adminUser@test.com">adminUser@test.com</a>&quot;으로 잘 변경되는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/a3b38afb-4f78-4785-977c-fb1071368b7a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #5] 사용자 로그인]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-5-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8</link>
            <guid>https://velog.io/@ian_lee/PlanTo-5-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8</guid>
            <pubDate>Wed, 19 Jul 2023 14:42:13 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>serializers.py</p>
</li>
<li><p>views.py</p>
</li>
<li><p>urls.py</p>
</li>
<li><p>회고</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 사용자 로그인 기능을 구현해보겠습니다.
<br>
<br></p>
<h2 id="1-serializerspy">1. serializers.py</h2>
<hr>
<p>사용자로부터 이메일 주소, 비밀번호를 입력받아 이를 검증하고, 정보가 올바르다면 로그인하는 LoginSerializer를 구현해보겠습니다.
<br></p>
<pre><code class="language-py">from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed
from .models import *
from django.contrib.auth import get_user_model, authenticate
from django.contrib.auth.password_validation import validate_password
from authentication.models import User

...

class LoginSerializer(serializers.ModelSerializer):
    username = serializers.CharField(max_length = 255, read_only = True)
    email = serializers.EmailField()
    password = serializers.CharField(max_length = 128, write_only = True)

    def validate(self, data):
        email = data.get(&quot;email&quot;, None)
        password = data.get(&quot;password&quot;, None)

        # 검증 과정에서 에러 발생 시 ValidationError 반환
        if email is None:
            raise serializers.ValidationError(&quot;이메일 주소를 입력해주십시오.&quot;)

        if password is None:
            raise serializers.ValidationError(&quot;비밀번호를 입력해주십시오.&quot;)

        user = authenticate(username = email, password = password)

        # 인증 과정에서 에러 발생 시 AuthenticationFailed 반환
        if user is None:
            raise AuthenticationFailed(&quot;이메일 주소 혹은 비밀번호가 잘못 입력되었습니다.&quot;)

        if not user.is_active:
            raise AuthenticationFailed(&quot;유효하지 않은 계정입니다.&quot;)

        return {&quot;email&quot;: user.email, &quot;username&quot;: user.username}

    class Meta:
        model = get_user_model()
        fields = [&quot;username&quot;, &quot;email&quot;, &quot;password&quot;]</code></pre>
<ul>
<li><p>username 대신 email을 사용해 로그인하기 때문에 username은 read_only</p>
</li>
<li><p>password는 보안상의 이유로 출력하지 않기 위해 write_only</p>
</li>
<li><p>email 혹은 password가 없을 경우, 검증 과정에서 ValidationError 반환</p>
</li>
<li><p>authenticate() 메서드는 email, password의 조합이 DB에 매칭되는 결과가 존재하는지 확인하는 역할</p>
</li>
<li><p>인증(authenticate) 과정에서 에러 발생 시 AuthenticationFailed exception 반환</p>
</li>
<li><p>로그인 성공 시 email, username 정보 반환</p>
<br>
<br>

</li>
</ul>
<h2 id="2-viewspy">2. views.py</h2>
<hr>
<p>위에서 구현한 serializer를 기반으로 사용자에게 보여줄 뷰를 구현해보겠습니다. 클래스 기반의 뷰를 구현할 때, generics 클래스를 상속하면 편리하기 때문에 이를 시도해봤으나, 다른 뷰들과 달리 Login 뷰는 불가능한 것 같아서 APIView를 상속해 구현했습니다.
<br></p>
<pre><code class="language-py">from rest_framework import generics, status
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .models import *
from .serializers import *
from authentication.models import User
from authentication.renderers import UserJsonRenderer

...

class Login(APIView):
    permission_classes = (AllowAny, )
    serializer_class = LoginSerializer
    renderer_classes = (UserJsonRenderer, )

    def post(self, request):
        user = request.data

        serializer = self.serializer_class(data = user)
        serializer.is_valid(raise_exception = True)

        return Response(serializer.data, status = status.HTTP_200_OK)</code></pre>
<ul>
<li><p>모든 사용자가 로그인을 시도할 수 있도록 permission은 AllowAny</p>
</li>
<li><p>사용자로부터 데이터를 입력받기 위해 post() 메서드 구현</p>
</li>
<li><p>입력받은 데이터가 유효한 경우, serializer의 데이터와 HTTP 200(OK) 반환(로그인 처리)</p>
<br>
<br>

</li>
</ul>
<h2 id="3-urlspy">3. urls.py</h2>
<hr>
<p>위에서 구현한 뷰를 웹 상에서 접근이 가능하도록 urls.py에 등록해보겠습니다.
<br></p>
<pre><code class="language-py">from django.urls import path
from .views import *

urlpatterns = [
    ...
    path(&#39;login&#39;, Login.as_view()),
]
</code></pre>
<br>

<h3 id="로그인-성공-테스트">로그인 성공 테스트</h3>
<p>Postman을 사용해 올바른 이메일 주소와 비밀번호 쌍을 입력하면 로그인이 성공하는지 테스트해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/b7c0a80f-6766-4325-acc4-40df77db9c38/image.png" alt="">
<img src="https://velog.velcdn.com/images/ian_lee/post/f102fe2a-e04f-493b-9e17-3661d0b02f91/image.png" alt=""></p>
<p>위와 같이 로그인에 성공해 HTTP 200과 사용자 정보를 반환하는 것을 확인할 수 있습니다.
<br></p>
<h3 id="로그인-실패-테스트">로그인 실패 테스트</h3>
<p>이번에는 Postman을 사용해 올바르지 않은 이메일 주소와 비밀번호 쌍을 입력하면 로그인이 실패하는지 테스트해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/1e7645c7-01a9-46b1-a761-072daaf11ee9/image.png" alt="">
<img src="https://velog.velcdn.com/images/ian_lee/post/988d4407-0ce6-41f7-820a-e888baf0eadf/image.png" alt=""></p>
<p>위와 같이 로그인에 실패해 HTTP 403과 함께 에러 메시지를 반환하는 것을 확인할 수 있습니다.
<br>
<br></p>
<h2 id="4-회고">4. 회고</h2>
<hr>
<p>chatGPT에 물어봐가며 LoginSerializer를 구현했는데, 모델에 직접적으로 연관된 serializer가 아니기 때문에 Meta 클래스가 없어도 괜찮다는 답변을 받아서 이를 바탕으로 수정했을 때 Meta 클래스를 찾을 수 없다는 에러가 발생했습니다. 이후에는 model 파라미터가 누락된 Meta 클래스를 제시해 다른 에러가 발생하는 등, 지속적으로 틀린 답변을 받아서 문제 해결에 어려움을 겪었습니다.</p>
<p>결국에는 구글링과 여러 번의 시도 끝에 문제를 해결했고, chatGPT 등의 대화형 AI가 편리한 것은 부정할 수 없지만, 틀린 답변을 줄 수 있다는 것을 유의하며 사용해야 한다는 것을 실감했습니다. 또한, 결국 개발 과정에서 문제를 해결하기 위해서는 코드와 프로젝트의 구조를 스스로 잘 파악하고 있어야 한다는 것을 느꼈습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo #4] 회원가입 및 검증]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-4-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EA%B2%80%EC%A6%9D</link>
            <guid>https://velog.io/@ian_lee/PlanTo-4-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EA%B2%80%EC%A6%9D</guid>
            <pubDate>Mon, 17 Jul 2023 08:01:44 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>serializers.py</p>
</li>
<li><p>views.py</p>
</li>
<li><p>urls.py</p>
</li>
<li><p>renderers.py</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 지난 글에서 구현했던 커스텀 User Model을 기반으로 회원가입 기능과 검증 로직을 구현해보겠습니다.
<br></p>
<h2 id="1-serializerspy">1. serializers.py</h2>
<hr>
<p>serializer(직렬화)는 데이터의 구조를 유지한 채 다른 컴퓨팅 환경에서 사용 가능한 포맷으로 변환하는 것을 의미합니다. 즉, 파이썬 문법으로 작성된 클래스(High-Level)를 다른 환경에서도 사용할 수 있도록 JSON(Low-Level)의 형태로 변환하는 역할을 합니다. </p>
<p>Django는 풀스택 프레임워크이기 때문에 HTML form으로 데이터를 전달하므로 serializer 모듈의 기능이 약하지만, DRF는 이를 보완해 강력한 기능의 serializer 모듈을 지원합니다.
<br></p>
<pre><code class="language-py">  from rest_framework import serializers
  from .models import *
  from django.contrib.auth import get_user_model
  from django.contrib.auth.password_validation import validate_password
  from authentication.models import User

  class UserSerializer(serializers.ModelSerializer):
      # User의 Task 객체를 PrimaryKeyRelatedField로 연결
      tasks = serializers.PrimaryKeyRelatedField(many = True, queryset = Task.objects.all())

      class Meta:
          model = get_user_model()
          fields = [&quot;username&quot;, &quot;email&quot;, &quot;tasks&quot;]

  class RegistrationSerializer(serializers.ModelSerializer):
      # django의 password validation 사용해 비밀번호 검증
      password = serializers.CharField(write_only = True, required = True, validators = [validate_password])
      password2 = serializers.CharField(write_only = True, required = True)

      token = serializers.CharField(max_length = 128, read_only = True)

      # 확인용 비밀번호 일치 여부 검증
      def validate(self, attrs):
          if attrs[&quot;password&quot;] != attrs[&quot;password2&quot;]:
              raise serializers.ValidationError({&quot;password&quot;: &quot;두 비밀번호가 일치하지 않습니다.&quot;})

          return attrs

      def create(self, validated_data):
          return User.objects.create_user(**validated_data)

      class Meta:
          model = get_user_model()
          fields = [&quot;username&quot;, &quot;email&quot;, &quot;password&quot;, &quot;password2&quot;, &quot;token&quot;]</code></pre>
<ul>
<li><p>serializer.ModelSerializer를 상속할 경우, 별도로 create(), update() 메서드를 정의하지 않고 Meta 클래스에 모델과 필드만을 명시해 간단하게 구현 가능</p>
<ul>
<li>create(), method()를 정의하지 않을 경우 DRF의 기본 create(), update() 메서드를 사용<br>
</li>
</ul>
</li>
<li><p>UserSerializer: 사용자 목록과 상세 페이지를 구현하기 위한 serializer</p>
<br>
</li>
<li><p>RegistrationSerializer: 사용자가 입력한 데이터를 검증하고, 이를 기반으로 회원가입 기능을 하는 serializer</p>
<ul>
<li><p>password는 create(), update()에만 사용하고 serialize에는 배제하기 위해 write_only로 처리</p>
</li>
<li><p>django auth의 password validator를 사용해 password를 1차 검증 후, 확인용 비밀번호(password2)를 입력받아 2차 검증</p>
<br>
<br>

</li>
</ul>
</li>
</ul>
<h2 id="2-viewspy">2. views.py</h2>
<hr>
<p>위에서 구현한 serializer를 기반으로 사용자에게 보여줄 뷰를 구현해보겠습니다.
<br></p>
<pre><code class="language-py">  from rest_framework import generics
  from .models import *
  from .serializers import *
  from authentication.models import User
  from authentication.renderers import UserJsonRenderer

  class UserList(generics.ListAPIView):
      queryset = User.objects.all()
      serializer_class = UserSerializer

  class UserDetail(generics.RetrieveAPIView):
      queryset = User.objects.all()
      serializer_class = UserSerializer

  class Registration(generics.CreateAPIView):
      queryset = User.objects.all()
      serializer_class = RegistrationSerializer</code></pre>
<ul>
<li><p>generic 클래스를 사용해 간결하게 클래스 기반 뷰 구현</p>
<ul>
<li>get(), post() 등의 메서드를 구현하지 않아도 됨<br>
</li>
</ul>
</li>
<li><p>UserList: 사용자 목록 뷰</p>
<br>
</li>
<li><p>UserDetail: 사용자 상세 정보 뷰</p>
<br>
</li>
<li><p>Registration: 회원가입 뷰</p>
<br>
<br>

</li>
</ul>
<h2 id="3-urlspy">3. urls.py</h2>
<hr>
<p>위에서 구현한 뷰를 웹 상에서 접근이 가능하도록 urls.py에 등록해보겠습니다.
<br></p>
<pre><code class="language-py">  from django.urls import path
  from .views import *

  urlpatterns = [
      path(&#39;users&#39;, UserList.as_view()),
      path(&#39;users/&lt;int:pk&gt;&#39;, UserDetail.as_view()),
      path(&#39;register&#39;, Registration.as_view()),
  ]</code></pre>
<ul>
<li><p>views.py에 정의했던 뷰 클래스의 as_view() 메서드를 사용해 구현</p>
<br>
</li>
<li><p>&#39;users/<a href="int:pk">int:pk</a>&#39;: 사용자 상세 정보 url</p>
<ul>
<li>User 모델의 int 타입 기본키(pk)를 사용<br>

</li>
</ul>
</li>
</ul>
<h3 id="회원가입-테스트">회원가입 테스트</h3>
<p>이제 url을 사용해 뷰에 접근이 가능하므로, Postman이라는 API 플랫폼을 사용해 기능을 테스트해보겠습니다. Postman은 서버에 HTTP 요청을 보내고, 응답을 받아 보여주는 기능을 제공해 개발한 API를 쉽게 테스트할 수 있도록 도와줍니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/a59d9d75-df6c-46f7-9dc9-74190a27c045/image.png" alt=""></p>
<p>서버를 실행한 뒤, 위의 사진과 같이 localhost:8000/register에 JSON 형식으로 POST 요청을 보냅니다.</p>
<p>그러면 아래와 같이 JSON으로 보낸 username과 email 사용하고, token을 만들어 새로운 사용자가 생성되는 것을 응답으로 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/50267611-7b99-464c-95a7-3741957a3828/image.png" alt=""></p>
<h3 id="검증-테스트">검증 테스트</h3>
<p>입력받은 데이터에 문제가 있을 경우에는 어떤 응답을 보내는지 확인해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/5ad72a8c-9ff2-4daa-a49a-ffbf3fe6d14b/image.png" alt=""></p>
<p>위의 사진과 같이 password를 특수문자 없이 &quot;qwer1234&quot;라는 흔한 문자열로 입력할 경우, 아래와 같이 비밀번호가 너무 흔하다는 경고를 출력하고 사용자를 생성하지 않는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/f88a0e11-ad3e-45f1-9306-5f1907aca0f6/image.png" alt="">
<br></p>
<h2 id="4-rendererspy">4. renderers.py</h2>
<hr>
<p>위에서 회원가입 요청에 대한 응답을 받을 때, 어떤 모델에 대한 데이터인지 확인하기 어렵다는 불편한 점이 있었고, 이를 renderer로 해결해보겠습니다. renderer는 응답 데이터를 원하는 형식으로 rendering하는 역할을 합니다.
<br></p>
<pre><code class="language-py">  import json

  from rest_framework.renderers import JSONRenderer

  class UserJsonRenderer(JSONRenderer):
      charset = &quot;utf-8&quot;

      def render(self, data, media_type = None, renderer_context = None):
          # data 내의 token은 Byte 타입으로 존재
          token = data.get(&quot;token&quot;, None)

          # Byte는 직렬화(Serialize)하지 못하기 때문에 rendering 전에 decode
          if token is not None and isinstance(token, bytes):
              data[&quot;token&quot;] = token.decode(&quot;utf-8&quot;)

          # data를 &quot;user&quot;로 감싼 뒤, json 타입으로 render
          return json.dumps(
              {&quot;user&quot;: data}
          )</code></pre>
<ul>
<li>serializer는 High-Level 형식의 데이터를 Low-Level의 형식으로 변환하는 것인데, Byte가 json보다 Low-Level이기 때문에 serialize가 불가능하므로 rendering 전에 decode<br>

</li>
</ul>
<p>이제 구현한 renderer를 views.py에 적용해보겠습니다.
<br></p>
<ul>
<li><p>todo/views.py</p>
<pre><code class="language-py">...

class Registration(generics.CreateAPIView):
    ...
    renderer_classes = (UserJsonRenderer, )</code></pre>
<ul>
<li><p>renderer_classes에 구현한 renderer 클래스 지정</p>
<br>
</li>
<li><p><code>renderer_classes = UserJsonRenderer</code>와 같은 형식으로 지정할 경우, 아래와 같이 내부적으로 type 객체에 len() 함수를 사용해 에러가 발생
<img src="https://velog.velcdn.com/images/ian_lee/post/0e5381a3-8967-47e9-8c19-86aa15530f9f/image.png" alt=""></p>
</li>
</ul>
</li>
</ul>
<h3 id="renderer-테스트">Renderer 테스트</h3>
<p>위와 같이 renderer를 사용할 경우, 아래 사진과 같이 기존 데이터가 &quot;user&quot;라는 블럭으로 둘러쌓여 반환되므로 어떤 모델의 데이터인지 쉽게 식별할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/271df51a-7598-478b-a3f1-683ec0a0913e/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo # 3] User Model 커스텀]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-3-todo-REST-API</link>
            <guid>https://velog.io/@ian_lee/PlanTo-3-todo-REST-API</guid>
            <pubDate>Thu, 13 Jul 2023 14:11:15 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>커스텀 User Model</p>
</li>
<li><p>Django Admin User</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 Django에서 제공하는 기본 User Model을 상속해 커스텀 User Model을 구현하고, Django Admin User를 생성해 admin 페이지를 사용해보겠습니다.
<br></p>
<h2 id="1-커스텀-user-model">1. 커스텀 User Model</h2>
<hr>
<p>username 대신 email을 ID로 사용해 로그인하고, JWT(JSON Web Token)을 사용해 인증하기 위해 커스텀 User Model을 구현해보겠습니다.</p>
<p>Django에서 제공하는 기본 User Model을 사용하다가 향후에 변경하려고 할 경우 DB 스키마를 변경해야하고, 이에 따라 코드도 수정해야하기 때문에 프로젝트 초기에 설정하는 것이 좋습니다.</p>
<h3 id="app-생성">app 생성</h3>
<ul>
<li><p><code>python manage.py startapp authentication</code>: 사용자를 인증하는 기능의 app 생성</p>
<br>
</li>
<li><p>projectname/settings.py</p>
<pre><code class="language-py">INSTALLED_APPS = [
    ...
    &#39;authentication.apps.AuthenticationConfig&#39;,
]</code></pre>
<ul>
<li>settings.py에 authentication app 등록<br>

</li>
</ul>
</li>
</ul>
<h3 id="usermanager-구현">UserManager 구현</h3>
<p>커스텀 User Model을 만들기에 앞서, User Model을 관리하는 역할을 하는 UserManager를 커스텀해보겠습니다.</p>
<p>BaseUserManager 클래스의 일반 사용자를 생성하는 create_user(), 관리자를 생성하는 create_superuser() 메서드를 재정의합니다.</p>
<ul>
<li><p>authentication/managers.py</p>
<pre><code class="language-py">from django.contrib.auth.models import BaseUserManager

class UserManager(BaseUserManager):
    def create_user(self, username, email, password = None, **extra_fields):
        if username is None:
            raise TypeError(&quot;이름을 입력해주십시오.&quot;)

        if email is None:
            raise TypeError(&quot;이메일 주소를 입력해주십시오.&quot;)

        if password is None:
            raise TypeError(&quot;비밀번호를 입력해주십시오.&quot;)

        user = self.model(username = username, email = self.normalize_email(email), **extra_fields)
        user.setpassword(password)
        user.save()

        return user

    def create_superuser(self, username, email, password, **extra_fields):

        # 사용자 생성 후 관리자로 지정
        user = self.create_user(username, email, password, **extra_fields)
        user.is_superuser = True
        user.is_staff = True
        user.save()

        return user</code></pre>
<ul>
<li><code>**extra_fields</code>: username, email, password 를 제외한 나머지 필드<br>

</li>
</ul>
</li>
</ul>
<h3 id="커스텀-user-model-구현">커스텀 User Model 구현</h3>
<p>커스텀 User Model을 만드는 여러 가지 방법 중, 자유도가 가장 높은 AbstractBaseUser를 상속받는 방법을 사용하겠습니다.
<br></p>
<ul>
<li><p>authentication/models.py</p>
<pre><code class="language-py">from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db.models.fields import BooleanField
from .managers import UserManager

class User(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(max_length = 255, unique = True)
    email = models.EmailField(db_index = True, unique = True)
    is_active = BooleanField(default = True)
    is_staff = BooleanField(default = False)

    # username 대신 email을 로그인 ID로 사용
    USERNAME_FIELD = &quot;email&quot;

    REQUIRED_FIELDS = [&quot;username&quot;]

    # 커스텀 UserManager 등록
    objects = UserManager()

    def __str__(self):
        return self.email</code></pre>
<ul>
<li>PermissionsMixin: 그룹과 사용자의 권한을 관리하는 역할<br>

</li>
</ul>
</li>
</ul>
<h3 id="커스텀-user-model에-jwt-적용">커스텀 User Model에 JWT 적용</h3>
<p>JWT를 적용하기 위해 pyjwt 라이브러리를, 토큰 생성 시 시간을 지정하기 위해 datetime 라이브러리를 사용합니다.</p>
<ul>
<li><p>authentication/models.py</p>
<pre><code class="language-py">from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db.models.fields import BooleanField
from .managers import UserManager

import jwt
from datetime import datetime, timedelta
from django.conf import settings

class User(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(max_length = 255, unique = True)
    email = models.EmailField(db_index = True, unique = True)
    is_active = BooleanField(default = True)
    is_staff = BooleanField(default = False)

    # username 대신 email을 로그인 ID로 사용
    USERNAME_FIELD = &quot;email&quot;

    REQUIRED_FIELDS = [&quot;username&quot;]

    # 커스텀 UserManager 등록
    objects = UserManager()

    def __str__(self):
        return self.email

    # 사용자의 jwt token을 쉽게 확인하기 위한 함수
    @property
    def token(self):
        return self.generate_jwt_token()

    def generate_jwt_token(self):
        # token의 기간 설정
        exp_date = datetime.now() + timedelta(days = 60)

        # token 생성
        token = jwt.encode(
            {&quot;id&quot;: self.pk, &quot;exp&quot;: exp_date.utcfromtimestamp(exp_date.timestamp)},
            settings.SECRET_KEY, algorithm = &quot;HS256&quot;
        )

        return token</code></pre>
<ul>
<li><p><code>generate_jwt_token()</code>: jwt 토큰을 생성하는 메서드</p>
</li>
<li><p><code>token()</code>: generate_jwt_token() 메서드를 통해 생성된 토큰을 단순히 확인하기 위한 메서드</p>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="외래키로-커스텀-user-model-불러오기">외래키로 커스텀 User Model 불러오기</h3>
<p>커스텀 User Model 구현 이후에는 외부 app의 models.py에서 외래 키로 해당 모델을 호출해 사용합니다.</p>
<p>이때 크게 두 가지 방법을 사용할 수 있는데, 하나는 커스텀 User Model을 직접 지정하는 것이고, 다른 하나는 Django settings.py에 AUTH_USER_MODEL을 설정해 사용하는 것입니다.
<br></p>
<ol>
<li><p>모델 직접 지정</p>
<pre><code class="language-py">class Task(models.Model):
   ...

   owner = models.ForeignKey(&quot;authentication.User&quot;, ...)</code></pre>
<br>
</li>
<li><p>settings.AUTH_USER_MODEL</p>
<ul>
<li><p>projectname/settings.py</p>
<pre><code class="language-py">...

# 사용할 User Model 변경
AUTH_USER_MODEL = &#39;authentication.User&#39;</code></pre>
<br>
</li>
<li><p>todo/models.py </p>
<pre><code class="language-py">class Task(models.Model):
   ...

   owner = models.ForeignKey(&quot;settings.AUTH_USER_MODEL&quot;, ...)</code></pre>
<br>

</li>
</ul>
</li>
</ol>
<p>첫 번째 방법의 경우, authentication이라는 app의 이름 혹은 User라는 모델의 이름이 변경될 경우, 프로젝트 전체에서 이를 수정해야 합니다.</p>
<p>두 번째 방법의 경우, settings.py에서만 수정하면 되기 때문에 유연성과 유지보수성이 높아서 두 번째 방법을 사용하는 것을 권장합니다.</p>
<br>

<h2 id="2-django-admin-user">2. Django Admin User</h2>
<hr>
<p>이제 커스텀 User Model을 기반으로 Admin User를 생성하고, DB에 데이터를 쉽게 CRUD할 수 있는 admin 페이지를 활용해보겠습니다.
<br></p>
<ul>
<li><p><code>python manage.py createsuperuser</code>: Admin User 생성</p>
<ul>
<li>ID, PW 설정<br>
</li>
</ul>
</li>
<li><p>admin 페이지에서 사용할 모델 등록(admin.py)</p>
<pre><code class="language-py">from django.contrib import admin
from .models import *

# Register your models here.
admin.site.register(Task)
admin.site.register(Tag)
admin.site.register(Alarm)</code></pre>
<br>
</li>
<li><p>생성한 Admin User를 확인하기 위해 shell_plus에서 <code>User.objects.all()</code>을 실행하니, <span style="color: indianred">django.db.utils.OperationalError: no such table: authentication_user 에러 발생</span></p>
<ul>
<li><p>Django ORM은 기본적으로 테이블 저장 시 appname_modelname의 형태로 저장하기 때문에, Meta 클래스에서 db_table 파라미터를 수정하는 것이 권장됨</p>
</li>
<li><p>User 모델에 아래와 같이 <span style="color: indianred">테이블 이름을 &quot;user&quot;로 지정해 해결</span></p>
<pre><code class="language-py">class User(AbstractBaseUser, PermissionsMixin):
  ...

  class Meta:
      db_table = &quot;user&quot;</code></pre>
<br>
</li>
</ul>
</li>
<li><p>shell_plus에서 User.objects.first().token()으로 Admin User의 jwt 토큰을 확인해보면, 아래와 같이 잘 출력됨을 확인 가능
<img src="https://velog.velcdn.com/images/ian_lee/post/f9681947-304a-4245-979c-5f803a7fa8c4/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p>서버 실행 후 <code>127.0.0.1/admin</code> 으로 admin 페이지 접속</p>
<ul>
<li><p>아래와 같이 사용자 Group을 관리하고, admin.py에 등록했던 모델들을 CRUD할 수 있음</p>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/a93b4307-33e6-452c-817f-cbc1e349f521/image.png" alt=""></p>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="모델-수정">모델 수정</h3>
<p>admin 페이지에서 아래와 같이 필드가 비어있을 경우 의도와는 다르게 일정 모델(Task) 생성이 불가능해서 모델을 수정했습니다.
<img src="https://velog.velcdn.com/images/ian_lee/post/fb4710c5-b5e1-4d77-a24a-42edea968f80/image.png" alt=""></p>
<ul>
<li><p>todo/models.py</p>
<pre><code class="language-py">class Task(models.Model):
    owner = models.ForeignKey(&quot;settings.AUTH_USER_MODEL&quot;, related_name = &quot;tasks&quot;, on_delete = models.CASCADE, null = True)
    title = models.CharField(max_length = 255)
    description = models.TextField(null = True, blank = True)
    due_date = models.DateField(default = timezone.now().date())
    priority = models.IntegerField(null = True, blank = True)
    status = models.CharField(choices = statusType.choices, max_length = 32, null = True, blank = True)
    memo = models.CharField(max_length = 255, null = True, blank = True)
    ...</code></pre>
<ul>
<li><p>owner: Task를 소유하는 사용자</p>
<ul>
<li>한 명의 사용자가 여러 개의 Task를 가질 수 있으며, 사용자 삭제 시 Task도 삭제<br>
</li>
</ul>
</li>
<li><p><code>null = True</code>: DB에 NULL값이 저장되는 것을 허용</p>
</li>
<li><p><code>blank = True</code>: 입력 폼이 빈 칸으로 넘어오는 것을 허용</p>
</li>
<li><p>due_date의 경우, timezone 모듈을 사용해 오늘 날짜를 default로 지정</p>
</li>
</ul>
</li>
</ul>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo # 2] 기능 도출 및 데이터 모델링]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-2-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%B6%9C-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</link>
            <guid>https://velog.io/@ian_lee/PlanTo-2-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%B6%9C-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</guid>
            <pubDate>Wed, 05 Jul 2023 14:45:09 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>요구사항 분석 및 기능 도출</p>
</li>
<li><p>데이터 모델링</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이번 글에서는 요구사항을 분석해 서비스에 필요한 기능을 도출하고, 백엔드 개발의 첫 단계라고 할 수 있는 데이터 모델링을 진행해보겠습니다.
<br></p>
<h2 id="1-요구사항-분석-및-기능-도출">1. 요구사항 분석 및 기능 도출</h2>
<hr>
<h3 id="요구사항-분석사용자">요구사항 분석(사용자)</h3>
<ul>
<li><p>일정(To-do)을 생성, 열람, 갱신, 삭제할 수 있다.</p>
</li>
<li><p>일정의 순서를 변경할 수 있다.</p>
</li>
<li><p>일정에 태그(or 라벨)을 부여해 그룹을 만들 수 있다.</p>
</li>
<li><p>일정을 검색하거나, 필터링할 수 있다.</p>
</li>
<li><p>일정에 알람 기능을 설정할 수 있다.</p>
</li>
<li><p>일정에 메모를 남길 수 있다.</p>
</li>
<li><p>채용공고를 일정에 추가할 수 있다.</p>
</li>
<li><p>자신의 개인정보를 변경할 수 있다.</p>
<br>

</li>
</ul>
<h3 id="기능-도출">기능 도출</h3>
<ul>
<li><p>사용자 회원가입 및 인증 / 인가</p>
</li>
<li><p>일정 CRUD</p>
</li>
<li><p>일정 검색 기능</p>
</li>
<li><p>일정 알람 기능</p>
</li>
<li><p>채용공고 열람 기능</p>
</li>
<li><p>채용공고 일정 추가 기능</p>
<br>

</li>
</ul>
<h2 id="2-데이터-모델링">2. 데이터 모델링</h2>
<hr>
<h3 id="erd">ERD</h3>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/4ede3d0d-1936-45df-8be1-503fd01a05d9/image.png" alt=""></p>
<ul>
<li><p>User: 서비스 사용자</p>
<br>
</li>
<li><p>Task: 사용자가 등록한 일정</p>
<ul>
<li><p>priority: 화면에 출력될 순서</p>
</li>
<li><p>status: [미진행 / 진행 중 / 완료]</p>
<br>
</li>
</ul>
</li>
<li><p>Tag: 일정에 부여되는 태그</p>
<br>
</li>
<li><p>TaskTagRelation: 일정과 태그 간의 매개 테이블</p>
<br>
</li>
<li><p>Alarm: 일정에 설정되는 알람</p>
<br>
</li>
<li><p>Job: 사람인 API를 활용해 가져올 채용공고</p>
<ul>
<li>별도의 로직을 통해 일정(Task)으로 변환 예정<br>

</li>
</ul>
</li>
</ul>
<h3 id="django-app-생성">Django App 생성</h3>
<ul>
<li><p><code>python manage.py startapp todo</code>: todo 리스트 기능을 제공하는 app 생성</p>
<br>
</li>
<li><p>url 등록</p>
<ul>
<li><p>projectname/urls.py</p>
<pre><code class="language-python">from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path(&#39;admin/&#39;, admin.site.urls),
    path(&#39;todo/&#39;, include(&#39;todo.urls&#39;)),
]</code></pre>
<br>

<ul>
<li><p>todo/urls.py</p>
<pre><code class="language-python">from django.urls import path
from . import views

urlpatterns = [
   path(&#39;&#39;, views.index, name = &quot;index&quot;),
]</code></pre>
<br>
</li>
<li><p>&quot;todo/&quot;로 들어오는 요청은 todo app의 urls.py에서 처리</p>
<br>


</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="django-모델링">Django 모델링</h3>
<ul>
<li><p>todo/models.py</p>
<pre><code class="language-python">from django.db import models
from django.contrib.auth.models import User

class Task(models.Model):
    class statusType(models.TextChoices):
        INCOMPLETE = &quot;incomplete&quot;, &quot;미진행&quot;
        ONGOING = &quot;ongoing&quot;, &quot;진행 중&quot;        
        COMPLETE = &quot;complete&quot;, &quot;완료&quot;

    title = models.CharField(max_length = 255)
    description = models.TextField()
    due_date = models.DateField()
    priority = models.IntegerField()
    status = models.CharField(choices = statusType.choices, max_length = 32)
    memo = models.CharField(max_length = 255)
    created_at = models.DateTimeField(auto_now_add = True)
    updated_at = models.DateTimeField(auto_now = True)

    def __str__(self):
        return self.title

    class Meta:
        db_table = &quot;task&quot;

class Tag(models.Model):
    task_set = models.ManyToManyField(to = &quot;Task&quot;, through = &quot;TaskTagRelation&quot;)
    name = models.CharField(max_length = 255)

    class Meta:
        db_table = &quot;tag&quot;

class TaskTagRelation(models.Model):
    task = models.ForeignKey(to = &quot;Task&quot;, null = False, on_delete = models.CASCADE)
    tag = models.ForeignKey(to = &quot;Tag&quot;, null = False, on_delete = models.CASCADE)

    class Meta:
        db_table = &quot;task_tag_relation&quot;

class Alarm(models.Model):
    task = models.ForeignKey(to = &quot;Task&quot;, null = False, on_delete = models.CASCADE)
    alarm_datetime = models.DateTimeField()</code></pre>
<br>
</li>
<li><p>projectname/settings.py</p>
<pre><code class="language-py">INSTALLED_APPS = [
    ...
    &#39;todo.apps.TodoConfig&#39;,
]</code></pre>
<ul>
<li>settings.py에 todo app 등록</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PlanTo # 1] 프로젝트 초기 설정]]></title>
            <link>https://velog.io/@ian_lee/PlanTo-1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</link>
            <guid>https://velog.io/@ian_lee/PlanTo-1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</guid>
            <pubDate>Tue, 04 Jul 2023 12:21:16 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<hr>
<ol start="0">
<li><p>서론</p>
</li>
<li><p>기술 스택</p>
</li>
<li><p>프로젝트 초기 설정</p>
<br>

</li>
</ol>
<h2 id="0-서론">0. 서론</h2>
<hr>
<p>이전에 진행했던 팀프로젝트에서 REST API를 완성했음에도 팀원들 간 의사소통과 분업과 관련된 문제가 발생해 별다른 결과물을 남기지 못한 경험이 있습니다.</p>
<p>이와 비슷한 문제가 재발하는 것을 방지하기 위해 백엔드를 담당했다고 이와 관련된 것만을 공부하는 것 대신, 문제 해결과 협업 능력을 기르기 위해 프런트엔드를 경험해볼 필요성을 느꼈습니다.</p>
<p>따라서 이번 프로젝트에서는 템플릿 엔진을 사용하지 않고 프런트엔드와 백엔드 모두 직접 구현해 채용공고를 쉽게 일정에 추가할 수 있는 <code>일정관리 웹서비스 - PlanTo</code>를 개발해보겠습니다.
<br></p>
<h2 id="1-기술-스택">1. 기술 스택</h2>
<hr>
<p>사용자가 일정(To-do)을 옮길 때의 모션을 구현하기 위해 프런트엔드는 Typescript와 React.js를 사용하며, 일부 기능을 서버 사이드 렌더링하기 위해 Next.js 프레임워크를 활용해 개발할 예정입니다.</p>
<p>Django와 DRF를 사용해 REST API를 구현하고, DB는 PostgreSQL을 사용하며, 향후에 서비스를 배포한다면 Docker와 AWS를 활용할 예정입니다.</p>
<h3 id="라이브러리">라이브러리</h3>
<ul>
<li><p>poetry: requirements.txt로 라이브러리를 관리했을 때 의존성 관련 문제를 해결하는 데 어려움을 겪어서 poetry로 대체</p>
</li>
<li><p>mypy: Python의 동적 타입으로 인해 발생할 수 있는 문제를 방지하고자 타입 제약 추가</p>
</li>
<li><p>django-filter: 사용자가 일정을 검색(필터링)할 수 있는 기능 구현 </p>
</li>
<li><p>django-extensions: runserver_plus, shell_plus, create_command 등 유용한 기능 제공</p>
</li>
<li><p>drf-spectacular: DRF로 개발한 REST API 자동 문서화</p>
<br>

</li>
</ul>
<h2 id="2-프로젝트-초기-설정">2. 프로젝트 초기 설정</h2>
<hr>
<ul>
<li><p>Python 3.11.4</p>
<ul>
<li><p><code>pyenv install 3.11.4</code>로 <span style="color: indianred">Python 설치 시도 시 에러 발생</span></p>
<pre><code>xcrun: error: invalid active developer path (/Library/Developer/CommandLineTools), missing xcrun at: /Library/Developer/CommandLineTools/usr/bin/xcrun</code></pre><ul>
<li>이는 MacOS 업데이트 후 자주 발생하는 문제로, <code>xcode-select --install</code> 명령어로 CommandLineTools를 설치해 해결 가능<br>
</li>
</ul>
</li>
<li><p><code>pyenv global 3.11.4</code>로 로컬 Python 버전 설정</p>
<br>
</li>
</ul>
</li>
<li><p>프로젝트 생성</p>
<ul>
<li><p>poetry 라이브러리를 사용해 프로젝트 생성 및 의존성 관리</p>
<ul>
<li><code>curl -sSL https://install.python-poetry.org | python3 -</code>을 사용해 <span style="color: indianred">poetry 설치 시도 시 에러 발생<span><pre><code>urllib.error.URLError: &lt;urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)&gt;</code></pre></li>
</ul>
<br>

<ul>
<li>아래와 같이 python과 python3의 경로가 다르게 매핑되어 발생한 문제로, <code>export PATH=/opt/homebrew/bin:$PATH</code>로 경로 설정해 해결
<img src = 'https://velog.velcdn.com/images/ian_lee/post/ea27096c-5fc9-4efa-b88f-fd351066b2f1/image.png'><img src = 'https://velog.velcdn.com/images/ian_lee/post/b089981e-8f5a-4f44-8e09-62afccf47fbb/image.png'><br></li>
</ul>
</li>
</ul>
<ul>
<li><p><code>poetry new project_name</code>: 프로젝트 생성</p>
</li>
<li><p><code>poetry shell</code>: vscode 터미널에서 해당 명령어로 가상환경 실행</p>
<br>
</li>
</ul>
</li>
<li><p>.gitignore 작성</p>
<ul>
<li><a href="https://www.toptal.com/developers/gitignore/">https://www.toptal.com/developers/gitignore/</a> 에서 django 프로젝트 gitignore 파일 작성<br>
</li>
</ul>
</li>
<li><p>라이브러리 추가</p>
<ul>
<li><p><code>poetry add django</code>
<img src="https://velog.velcdn.com/images/ian_lee/post/65b8bae7-159e-4970-a675-3a36e22fb56d/image.png" alt=""></p>
<ul>
<li><p>위와 같이 pyproject.toml 파일의 [tool.poetry.dependencies]에 django가 추가됨</p>
</li>
<li><p>이때 poetry.lock이라는 프로젝트의 의존성을 관리하는 역할을 하는 파일이 함께 생성되는데, 의존성을 맞추기 위해 github에 commit해야하므로 .gitignore에 걸려있지 않은지 확인해야 함</p>
<br>
</li>
</ul>
</li>
<li><p><code>poetry add djangorestframework</code></p>
</li>
<li><p><code>poetry add mypy</code></p>
</li>
<li><p><code>poetry add django-filter</code></p>
</li>
<li><p><code>poetry add django-extensions</code></p>
</li>
<li><p><code>poetry add drf-spectacular</code></p>
<br>
</li>
</ul>
</li>
<li><p><code>django-admin startproject project_name</code>: django 프로젝트 생성</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Airflow의 다양한 고급 기능과 CI / CD 환경에 대해 학습 (4)]]></title>
            <link>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-4</link>
            <guid>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-4</guid>
            <pubDate>Mon, 03 Jul 2023 13:35:29 GMT</pubDate>
            <description><![CDATA[<h3 id="학습내용">학습내용</h3>
<hr>
<ol>
<li><p>프로덕션을 위한 Airflow 환경설정</p>
</li>
<li><p>Airflow 로그 파일 삭제</p>
</li>
<li><p>Airflow 메타데이터 백업</p>
</li>
<li><p>Airflow 대안</p>
<br>

</li>
</ol>
<h3 id="1-프로덕션을-위한-airflow-환경설정">1. 프로덕션을 위한 Airflow 환경설정</h3>
<hr>
<ul>
<li><p>DB sqlite에서 PostgreSQL or MySQL로 변경</p>
<ul>
<li><p>주기적으로 백업되어야 함</p>
</li>
<li><p>airflow.cfg의 core section에 sql_alchemy_conn 사용</p>
<br>
</li>
</ul>
</li>
<li><p>Authenticaion 활성화 및 비밀번호 강화</p>
<ul>
<li><p>Airflow 2.0부터는 Authentication default로 활성화</p>
</li>
<li><p>VPN 뒤에 위치시키는 것을 권장</p>
<br>
</li>
</ul>
</li>
<li><p>로그와 로컬 데이터 사용을 위해 disk volume 확장</p>
<br>
</li>
<li><p>주기적으로 데이터 cleanup</p>
<ul>
<li>Shell Operator 기반 DAG 사용 가능<br>
</li>
</ul>
</li>
<li><p>Scale Up에서 Scale Out으로 변경</p>
<ul>
<li>Cloud Composer(GCP), MWAA(AWS)와 같은 클라우드 Airflow 사용 혹은 Docker / Kubernetes 사용<br>
</li>
</ul>
</li>
<li><p>Airflow 메타데이터 DB 백업</p>
<ul>
<li>Variable, Connection 백업<br>
</li>
</ul>
</li>
<li><p>Health-check 모니터링 추가</p>
</li>
</ul>
<br>

<h3 id="2-airflow-로그-파일-삭제">2. Airflow 로그 파일 삭제</h3>
<hr>
<ul>
<li><p>로그 파일의 크기가 작지 않기 때문에 주기적으로 삭제하거나 S3 등에 백업해야 함</p>
<br>
</li>
<li><p>/var/lib/airflow/logs, /var/lib/airflow/logs/scheduler에 각기 다른 로그가 기록됨</p>
<br>
</li>
<li><p>docker-compose로 실행한 경우, logs 폴더가 host volume 형태로 유지</p>
<pre><code>volumes:
    - ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs</code></pre></li>
</ul>
<br>

<h3 id="3-airflow-메타데이터-백업">3. Airflow 메타데이터 백업</h3>
<hr>
<ul>
<li><p>메타데이터도 주기적으로 백업 권장</p>
</li>
<li><p>AWS RDS와 같이 외부에 존재하는 DB의 경우, 해당 위치에서 주기적으로 백업</p>
</li>
<li><p>Airflow와 같은 서버에 존재하는 DB의 경우, DAG를 사용해 S3 등에 주기적으로 백업</p>
</li>
</ul>
<br>

<h3 id="4-airflow-대안">4. Airflow 대안</h3>
<hr>
<ul>
<li><p>Prefect</p>
<ul>
<li><p>Open Source</p>
</li>
<li><p>Airflow와 상당히 흡사하며 좀 더 가벼움</p>
</li>
<li><p>데이터 파이프라인을 동적으로 생성할 수 있는 장점</p>
<br>
</li>
</ul>
</li>
<li><p>Dagster</p>
<ul>
<li><p>Open Source</p>
</li>
<li><p>데이터 파이프라인과 데이터를 동시 관리 가능</p>
<br>
</li>
</ul>
</li>
<li><p>Airbyte</p>
<ul>
<li><p>Open Source</p>
</li>
<li><p>코딩 툴보다는 Low-code 툴에 가까움</p>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="메모">메모</h3>
<hr>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Airflow의 다양한 고급 기능과 CI / CD 환경에 대해 학습 (3)]]></title>
            <link>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-3</link>
            <guid>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-3</guid>
            <pubDate>Thu, 29 Jun 2023 09:54:21 GMT</pubDate>
            <description><![CDATA[<h3 id="학습내용">학습내용</h3>
<hr>
<ol>
<li><p>DAG Dependencies</p>
</li>
<li><p>Task Grouping</p>
</li>
<li><p>Dynamic DAGs</p>
<br>

</li>
</ol>
<h3 id="1-dag-dependencies">1. DAG Dependencies</h3>
<hr>
<ul>
<li><p>DAG 실행 방법</p>
<ul>
<li><p>schedule로 지정해 주기적으로 실행</p>
<br>
</li>
<li><p>다른 DAG에 의해 트리거</p>
<ul>
<li><p>Explicit Trigger: DAG가 다른 DAG를 명시적으로 트리거</p>
<ul>
<li>TriggerDagRunOperator<br>

</li>
</ul>
<ul>
<li><p>Reactive Trigger: DAG B가 DAG A의 태스크 끝날 때까지 대기</p>
<ul>
<li><p>DAG A는 해당 사실을 모름</p>
</li>
<li><p>ExternalTaskSensor</p>
<br>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>기타</p>
<ul>
<li><p>BranchPythonOperator: 조건에 따라 뒤에 실행될 태스크를 동적으로 결정</p>
<ul>
<li>TriggerDagOperator 앞에 사용하기도 함<br>

</li>
</ul>
<ul>
<li>LatestOnlyOperator: 과거 데이터 Backfill 시 Time-sensitive한 태스크들이 실행되는 것을 막음</li>
<li>현재 시간이 지금 태스크의 execution_date보다 미래이고 다음 execution_date보다는 과거일 경우에만 뒤로 실행을 이어가고, 아닐 경우 중단<br>

</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-python">  from airflow.operators.latest_only import LatestOnlyOperator
  from airflow.operators.empty import EmptyOperator

  with DAG(
      dag_id = &quot;latest_only_example&quot;,
      schedule = timedelta(hours = 48), # 매 48시간마다 실행되는 DAG로 설정

      start_date = datetime(2023, 6, 14),
      catchup = True) as dag:

      t1 = EmptyOperator(task_id = &quot;task1&quot;)
      t2 = LatestOnlyOperator(task_id = &quot;latest_only&quot;)
      t3 = EmptyOperator(task_id = &quot;task3&quot;)
      t4 = EmptyOperator(task_id = &quot;task4&quot;)

      t1 &gt;&gt; t2 &gt;&gt; [t3, t4]</code></pre>
<br>
</li>
<li><p>TriggerDagRunOperator</p>
<pre><code class="language-python">from airflow.operators.trigger_dagrun import TriggerDagRunOperator

trigger_B = TriggerDagRunOpertor(
    task_id = &quot;trigger_B&quot;,
    trigger_dag_id = &quot;트리거하려는 DAG 이름&quot;,
    conf = {&#39;path&#39;: &#39;/opt/ml/conf&#39;},
    execution_date = &quot;{{ds}}&quot;
    reset_dag_run = True,
    wait_for_completion = True
)</code></pre>
<ul>
<li><p>conf: DAG B에 넘기려는 정보</p>
</li>
<li><p>execution_date: Jinja Template을 사용해 DAG A의 execution_date 전달</p>
</li>
<li><p>reset_dag_run: True일 경우, 해당 날짜가 이미 실행되었더라도 재실행</p>
</li>
<li><p>wait_for_completion: DAG B가 끝날 때까지 기다릴지 여부 설정(default: False)</p>
<br>
</li>
</ul>
</li>
<li><p>Jinja Template</p>
<ul>
<li><p>Python에서 널리 사용되는 템플릿 엔진</p>
<ul>
<li>Django의 템플릿 엔진에서 영감<br>
</li>
</ul>
</li>
<li><p>프레젠테이션 로직과 애플리케이션 로직을 분리해 동적으로 HTML 생성</p>
</li>
<li><p>{{variable_name}}: 변수 사용</p>
</li>
<li><p>{% %}: 제어문</p>
<br>
</li>
<li><p>Airflow에서 사용 시 작업 이름, 파라미터 또는 SQL 쿼리와 같은 작업 매개변수를 템플릿화된 문자열로 정의 가능</p>
<ul>
<li>재사용 가능, 사용자 정의 워크플로우 생성 가능<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Sensor</p>
<ul>
<li><p>특정 조건이 충족될 때까지 대기하는 Operator</p>
<br>
</li>
<li><p>외부 리소스의 가용성이나 특정 조건의 완료와 같은 상황 동기화에 유용</p>
<br>
</li>
<li><p>Airflow가 제공하는 Sensor</p>
<ul>
<li><p>FileSensor: 지정한 위치에 파일이 생길 때까지 대기</p>
<ul>
<li><p>HttpSensor: HTTP 요청을 수행하고 지정된 응답이 올 때까지 대기</p>
</li>
<li><p>SqlSensor: DB에서 특정 조건을 충족할 때까지 대기</p>
</li>
<li><p>TimeSensor: 특정 시간까지 워크플로우 일시 중지</p>
</li>
<li><p>ExternalTaskSensor: 다른 Airflow DAG의 특정 작업 완료까지 대기</p>
<br>

</li>
</ul>
</li>
</ul>
<ul>
<li><p>주기적으로 poke하는 방식으로 동작</p>
</li>
<li><p>mode: worker를 하나 붙잡고 poke 중에 sleep할지 혹은 worker를 릴리스하고 다시 잡아서 poke할지 결정하는 파라미터</p>
<ul>
<li>mode = [poke / reschedule]<br>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>ExternalTaskSensor</p>
<ul>
<li><p>DAG B의 ExternalTaskSensor 태스크가 DAG A의 특정 태스크가 끝났는지 확인</p>
<ul>
<li><p>두 태스크의 Execution Date, frequency가 동일해야 함</p>
</li>
<li><p>두 DAG의 schedule interval이 다를 경우 execution_delta 사용</p>
<br>

</li>
</ul>
</li>
</ul>
<pre><code class="language-python">from airflow.sensors.external_task import ExternalTaskSensor

waiting_for_end_of_dag_a = ExternalTaskSensor(
    task_id = &quot;waiting_for_end_of_dag_a&quot;,
    external_dag_id = &quot;DAG_name&quot;,
    external_task_id = &quot;end&quot;,
    timeout = 5 * 60,
    mode = &quot;reschedule&quot;,
    execution_delta = timedelta(minutes = 5)
)</code></pre>
<br>
</li>
<li><p>Trigger Rules</p>
<ul>
<li><p>Upstream 태스크의 성공 여부에 따라 이후 태스크의 실행 여부 결정</p>
<br>
</li>
<li><p>Operator에 trigger_rule이란 파라미터로 설정</p>
<ul>
<li><p>ALL_SUCCESS(default)</p>
</li>
<li><p>ALL_FAILED</p>
</li>
<li><p>ALL_DONE</p>
</li>
<li><p>ONE_SUCESS</p>
</li>
<li><p>ONE_FAILED</p>
</li>
<li><p>NONE_FAILED</p>
</li>
<li><p>NONE_FAILED_MIN_ONE_SUCESS</p>
<br>

</li>
</ul>
</li>
</ul>
<pre><code class="language-python">from airflow.utils.trigger_rule import TriggerRule

with DAG(&quot;trigger_rules&quot;, default_args = default_args, schedule = timedelta(1)) as dag:
    t1 = BashOperator(task_id = &quot;print_date&quot;, bash_command=&quot;date&quot;)
    t2 = BashOperator(task_id = &quot;sleep&quot;, bash_command = &quot;sleep 5&quot;)
    t3 = BashOperator(task_id = &quot;exit&quot;, bash_command = &quot;exit 1&quot;)

    t4 = BashOperator(
        task_id = &quot;final_task&quot;,
        bash_command = &quot;echo DONE!&quot;,
        trigger_rule = TriggerRule.ALL_DONE
    )

    [t1, t2, t3] &gt;&gt; t4</code></pre>
</li>
</ul>
<br>

<h3 id="2-task-grouping">2. Task Grouping</h3>
<hr>
<ul>
<li><p>태스크 수가 너무 많은 DAG라면 태스크들의 성격에 따라 관리하기 위해 태스크 그룹핑이 필요</p>
<ul>
<li>다수의 파일을 처리하는 DAG는 파일 다운로드 태스크, 파일 체크 태스크, 데이터 처리 태스크로 구성<br>

</li>
</ul>
<pre><code class="language-python">from airflow.utils.task_group import TaskGroup

start = EmptyOperator(task_id = &quot;start&quot;)

with TaskGroup(&quot;Download&quot;, tooltip = &quot;Tasks for downloading data&quot;) as section_1:
    task_1 = EmptyOperator(task_id = &quot;task_1&quot;)
    task_2 = BashOperator(task_id = &quot;task_2&quot;, bash_command = &quot;echo 1&quot;)
    task_3 = EmptyOperator(task_id = &quot;task_3&quot;)

    task_1 &gt;&gt; [task_2, task_3]

start &gt;&gt; section_1</code></pre>
</li>
</ul>
<br>

<h3 id="3-dynamic-dags">3. Dynamic DAGs</h3>
<hr>
<ul>
<li><p>Jinja를 기반으로 DAG 자체의 템플릿을 디자인하고 YAML을 통해 앞서 만든 템플릿에 파라미터 제공</p>
<br>
</li>
<li><p>DAG를 계속해서 매뉴얼하게 개발하는 것 방지</p>
<br>
</li>
<li><p>DAG를 계속 만드는 것과 DAG 내에서 태스크를 늘리는 것 사이의 밸런싱 필요</p>
<ul>
<li>오너가 다르거나 태스크의 수가 너무 많아지는 경우 DAG를 복제하는 것이 더 좋음</li>
</ul>
</li>
</ul>
<br>

<h3 id="메모">메모</h3>
<hr>
<ul>
<li><p>config API를 사용하기 위해 docker-compose.yaml에 아래와 같이 expose_config를 True로 설정</p>
<pre><code class="language-yaml">x-airflow-common:
    &amp;airflow-common
    ...
    environment:
        &amp;airflow-common-env
        ...
        AIRFLOW_WEBSERVER_EXPOSE_CONFIG: &#39;true&#39;</code></pre>
</li>
</ul>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Airflow의 다양한 고급 기능과 CI / CD 환경에 대해 학습 (2)]]></title>
            <link>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-2</link>
            <guid>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-2</guid>
            <pubDate>Tue, 27 Jun 2023 16:35:08 GMT</pubDate>
            <description><![CDATA[<h3 id="학습내용">학습내용</h3>
<hr>
<ol>
<li><p>구글 시트 Redshift 테이블로 복사하기</p>
</li>
<li><p>SQL 결과 구글 시트로 복사하기</p>
</li>
<li><p>API &amp; Airflow 모니터링</p>
<br>

</li>
</ol>
<h3 id="1-구글-시트-redshift-테이블로-복사하기">1. 구글 시트 Redshift 테이블로 복사하기</h3>
<hr>
<img src = 'https://velog.velcdn.com/images/ian_lee/post/39054200-18ed-46a3-b611-87cf1c651e8f/image.png'>
<br>

<ul>
<li><p>스프레드시트 API를 활성화하고, 구글 서비스 어카운트를 생성해 그 내용을 JSON으로 다운로드</p>
<ul>
<li><p>해당 JSON 파일의 내용을 google_sheet_access_token이란 이름의 Variable로 등록</p>
</li>
<li><p>해당 JSON 파일 내의 이메일 주소를 스프레드시트 파일에 공유</p>
<br>
</li>
</ul>
</li>
<li><p>Airflow DAG쪽에서 해당 JSON 파일로 인증하고, 시트 조작</p>
<br>

</li>
</ul>
<h3 id="2-sql-결과-구글-시트로-복사하기">2. SQL 결과 구글 시트로 복사하기</h3>
<hr>
<ul>
<li><p>gcp_conn_id라는 이름으로 Google Cloud Connection을 하나 생성</p>
<ul>
<li>Admin → Connections → + 버튼<br>
</li>
</ul>
</li>
<li><p>앞서 서비스 어카운트 JSON 파일의 내용을 Keyfile JSON에 복사</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ian_lee/post/a5c12959-b168-4aa3-9460-680c23f79951/image.png" alt=""></p>
<br>

<h3 id="3-api--airflow-모니터링">3. API &amp; Airflow 모니터링</h3>
<hr>
<ul>
<li><p>Airflow API 활성화</p>
<ul>
<li><p>airflow.cfg의 api 섹션에서 auth_backend의 값을 아래와 같이 변경</p>
<pre><code class="language-python">  [api]
  auth_backend = airflow.api.auth.basic_auth</code></pre>
  <br>
</li>
<li><p>Airflow 웹 UI에서 새로운 API 사용자 추가</p>
<ul>
<li>Security → List Users → + 버튼<br>
</li>
</ul>
</li>
<li><p>Health API 호출</p>
<ul>
<li><p><code>curl -X GET --user “monitor:MonitorUser1” http://localhost:8080/health</code></p>
<br>
</li>
<li><p>정상 응답</p>
<pre><code class="language-python">  {
       &quot;metadatabase&quot;: {
           &quot;status&quot;: &quot;healthy&quot;
       },
       &quot;scheduler&quot;: {
           &quot;status&quot;: &quot;healthy&quot;,
           &quot;latest_scheduler_heartbeat&quot;: &quot;2022-03-12T06:02:38.067178+00:00&quot;
       }
  }</code></pre>
  <br>


</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li><p>API 사용 예시</p>
<ul>
<li><p>특정 DAG를 API로 트리거</p>
<ul>
<li><code>curl -X POST --user “airflow:airflow” -H ‘Content-Type: application/json’ -d ‘{”execution_date”:”2023-05-24T00:00:00Z”}’ “http://localhost:8080/api/v1/HelloWorld/dagRuns”</code><br>
</li>
</ul>
</li>
<li><p>모든 DAG 리스트</p>
<ul>
<li><code>curl -X GET --user “airflow:airflow” http://localhost:8080/api/v1/dags</code><br>
</li>
</ul>
</li>
<li><p>모든 Variable 리스트</p>
<ul>
<li><code>curl -X GET --user “airflow:airflow” http://localhost:8080/api/v1/variables</code><br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Variables[Connections] Import[Export]</p>
<ul>
<li><code>airflow variables[connections] import[export] variables.json[connections.json]</code></li>
</ul>
</li>
</ul>
<br>

<h3 id="메모">메모</h3>
<hr>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Airflow의 다양한 고급 기능과 CI / CD 환경에 대해 학습 (1)]]></title>
            <link>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-1</link>
            <guid>https://velog.io/@ian_lee/TIL-Airflow%EC%9D%98-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B3%A0%EA%B8%89-%EA%B8%B0%EB%8A%A5%EA%B3%BC-CI-CD-%ED%99%98%EA%B2%BD%EC%97%90-%EB%8C%80%ED%95%B4-%ED%95%99%EC%8A%B5-1</guid>
            <pubDate>Mon, 19 Jun 2023 17:04:46 GMT</pubDate>
            <description><![CDATA[<h3 id="학습내용">학습내용</h3>
<hr>
<ol>
<li><p>Airflow Docker 환경설정</p>
</li>
<li><p>Summary 테이블 구현</p>
</li>
<li><p>Slack 연동하기</p>
<br>

</li>
</ol>
<h3 id="1-airflow-docker-환경설정">1. Airflow Docker 환경설정</h3>
<hr>
<ul>
<li><p>Docker 사용해 Airflow 실행 시 Variables, Connections 등의 환경설정 값은 docker-compose.yml에서 아래와 같이 정의</p>
<pre><code class="language-yaml">x-airflow-common:
    &amp;airlfow-common
    ...

    environment:
        &amp;airflow-common-env
        AIRFLOW_VAR_DATA_DIR: /opt/airflow/data
        AIRFLOW_CONN_TEST_ID: test_connection</code></pre>
<br>
</li>
<li><p>DAG 코드도 Airflow Image로 만들어 관리하는 것이 깔끔함</p>
<ul>
<li>개발 / 테스트 시에는 docker-compose에서 host volume 형태로 설정 가능</li>
</ul>
</li>
</ul>
<br>

<h3 id="2-summary-테이블-구현">2. Summary 테이블 구현</h3>
<hr>
<ul>
<li><p>CTAS 부분을 별도의 환경설정 파일로 분리 가능</p>
<ul>
<li><p>config 폴더 생성 후 Summary 테이블별로 하나의 환경설정 파일 생성</p>
<br>
</li>
<li><p>Python dictionary 형태로 저장하기 위해 .py 확장자로 생성</p>
<pre><code class="language-python">{
  &#39;table&#39;: &#39;mau_summary&#39;,
  &#39;schema&#39;: &#39;XXX&#39;,
  &#39;main_sql&#39;: &quot;&quot;&quot;SELECT ...;&quot;&quot;&quot;,
  &#39;input_check&#39;: [],
  &#39;output_check&#39;: [],
}</code></pre>
<br>

</li>
</ul>
</li>
</ul>
<br>

<h3 id="3-slack-연동하기">3. Slack 연동하기</h3>
<hr>
<ul>
<li><p>DAG 실행 중 에러 발생 시 슬랙 워크스페이스 채널로 메시지 전송</p>
<ul>
<li><p>해당 워크스페이스에 Incoming Webhooks App 생성</p>
<ul>
<li><a href="https://api.slack.com/messaging/webhooks">https://api.slack.com/messaging/webhooks</a><br>
</li>
</ul>
</li>
<li><p>연동을 위한 함수(plugins/slack.py)를 생성하고, 이를 태스크에 적용되는 dafault_args의 on_failure_callback에 지정  </p>
<pre><code class="language-python">from plugins import slack

...
default_args = {
  &#39;on_failure_callback&#39;: slack.on_failure_callback,
}</code></pre>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="메모">메모</h3>
<hr>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 개발환경 구축을 위한 Docker와 K8S 실습 (5)]]></title>
            <link>https://velog.io/@ian_lee/TIL-%EA%B0%9C%EB%B0%9C%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%EC%9D%84-%EC%9C%84%ED%95%9C-Docker%EC%99%80-K8S-%EC%8B%A4%EC%8A%B5-5</link>
            <guid>https://velog.io/@ian_lee/TIL-%EA%B0%9C%EB%B0%9C%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%EC%9D%84-%EC%9C%84%ED%95%9C-Docker%EC%99%80-K8S-%EC%8B%A4%EC%8A%B5-5</guid>
            <pubDate>Fri, 16 Jun 2023 08:37:18 GMT</pubDate>
            <description><![CDATA[<h3 id="학습내용">학습내용</h3>
<hr>
<ol>
<li><p>서버 관리의 어려움</p>
</li>
<li><p>Container Orchestration 소개</p>
</li>
<li><p>Kubernetes 소개</p>
</li>
<li><p>Kubernetes 아키텍처</p>
<br>

</li>
</ol>
<h3 id="1-서버-관리의-어려움">1. 서버 관리의 어려움</h3>
<hr>
<ul>
<li><p>관리해야하는 서버의 수가 늘어날 경우, 어느 서버 혹은 서비스가 어떤 문제를 파악하기 어렵고, 문제들의 가짓수가 늘어나는 등의 어려움이 있음</p>
<br>
</li>
<li><p>해결방안 1: 문서화</p>
<ul>
<li><p>현재 서비스의 상황과 설정 방법, 문제 해결 방법을 문서화</p>
</li>
<li><p>상황에 따라 의미가 없는 경우가 많고, 지속적으로 업데이트하기 번거로우며 다수의 서버를 문서를 확인하고 일일히 관리하는 것은 거의 불가능하다는 단점 존재</p>
<br>
</li>
</ul>
</li>
<li><p>해결방안 2: 코드로 관리</p>
<ul>
<li><p>DevOps 엔지니어가 반드시 알아야하는 IaaS 기술</p>
</li>
<li><p>대화형 명령보다는 자동화된 스크립트로 다수의 서버에 명령</p>
</li>
<li><p>Chef, Puppet, Ansible, Terraform 등 다양한 툴 존재</p>
</li>
<li><p>Learning Curve가 높으며, 소프트웨어 충돌 문제 해결에 큰 도움이 되지 않는다는 단점 존재</p>
<br>
</li>
</ul>
</li>
<li><p>해결방안 3: Virtual Machine 도입</p>
<ul>
<li><p>하나의 물리적 서버에 다수의 VM을 올리고, 서비스별로 하나씩 할당</p>
</li>
<li><p>VM은 리소스를 많이 소비하고 느리며, 특정 VM 벤더 혹은 클라우드에 종속된다는 단점 존재</p>
<br>
</li>
</ul>
</li>
<li><p>해결방안 4: Docker 도입</p>
<ul>
<li><p>모든 소프트웨어를 Docker Image로 만들면, 어디서든 동작함</p>
<ul>
<li><p>기본적으로 리눅스 환경에 최적화됨</p>
</li>
<li><p>Image를 사용해 버전을 관리하고 배포하며, 문제 발생 시 롤백 용이</p>
<br>
</li>
</ul>
</li>
<li><p>VM에 비해 리소스 낭비도 적고, 실행 시간도 빠름</p>
<br>
</li>
<li><p>오픈소스이기 때문에 특정 클라우드 벤더에 독립적</p>
<br>
</li>
<li><p>사용 언어, 환경에 따른 관리 방법에 차이가 없음</p>
<ul>
<li><p>개발, 빌드, 등록, 실행 절차가 일관되게 진행(Dev, Test, Production)</p>
<img src = 'https://velog.velcdn.com/images/ian_lee/post/2a39b77a-a4cc-4dbb-a631-734421edcc22/image.png' width = 500px>
<br>
</li>
</ul>
</li>
<li><p>모든 서비스를 Docker Image로 만드는 트렌드이기 때문에, 다량의 Container를 효율적으로 관리할 도구가 필요</p>
<ul>
<li><p>다수의 Container 동시 관리</p>
</li>
<li><p>놀고 있는 서버, 바쁜 서버 파악</p>
</li>
<li><p>운영되는 서비스 파악</p>
</li>
<li><p>모니터링</p>
</li>
<li><p>Container 수의 탄력적 조절</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="2-container-orchestration-소개">2. Container Orchestration 소개</h3>
<hr>
<ul>
<li><p>다량의 Container를 효율적으로 관리하는 기법을 Container Orchestration이라고 부름</p>
<br>
</li>
<li><p>한 클러스터 내에 DB, 웹서비스, 백엔드 등 다양한 서비스들이 공존</p>
<ul>
<li><p>자원을 요청하면, 마스터가 자원을 할당</p>
<img src = 'https://velog.velcdn.com/images/ian_lee/post/12c9e2d6-3e7f-479d-aa66-fe45034aaa3c/image.png' width = 700px>
<br>
</li>
</ul>
</li>
<li><p>기능</p>
<ul>
<li><p>소프트웨어 배포</p>
<ul>
<li><p>서비스 Image를 Container로 배포</p>
<br>
</li>
<li><p>이상 감지 시 이전의 안정된 버전으로 롤백</p>
<ul>
<li>Container의 수가 증가할수록 중요한 기능<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>스케일링</p>
<ul>
<li>서버의 사용률을 고려해 특정 서비스의 Container 수를 늘이고 줄이는 것<br>
</li>
</ul>
</li>
<li><p>네트워크</p>
<ul>
<li><p>서비스가 다수의 Container로 나눠지며 이들을 대표하는 Load Balancer를 만들어줘야 함</p>
</li>
<li><p>서비스들 간 서로를 쉽게 찾을 수 있어야 함</p>
<br>
</li>
</ul>
</li>
<li><p>인사이트</p>
<ul>
<li><p>노드 / Container에서 문제 발생 시 해결</p>
</li>
<li><p>플러그인을 통한 로그, 분석 등의 기능 제공</p>
<ul>
<li>서비스, 문제 분석 및 시각화<br>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Mesos, Marathon, Docker Swarm 등 다양한 툴들이 있지만 대부분 Kubernetes(K8S) 사용</p>
</li>
</ul>
<br>

<h3 id="3-kubernetes-소개">3. Kubernetes 소개</h3>
<hr>
<ul>
<li><p>컨테이너 기반 서비스 배포 / 스케일 / 관리를 자동화해주는 오픈소스 프레임워크</p>
<ul>
<li><p>주로 Docker Container를 대상으로 하지만, 모든 컨테이너 사용 가능</p>
</li>
<li><p>클라우드, 온프레미스 환경에서 모두 잘 동작</p>
<br>
</li>
</ul>
</li>
<li><p>가장 많이 사용되는 Container Orchestration 시스템</p>
<ul>
<li><p>사용 회사와 커뮤니티가 많고 활발</p>
</li>
<li><p>Kubernetes에 기능을 추가한 툴들이 등장</p>
</li>
<li><p>모든 글로벌 클라우드가 EKS, AKS, GKE 등의 서비스로 제공</p>
<br>
</li>
</ul>
</li>
<li><p>확장성이 좋아서 ML, CI / CD, Service Mesh, Serverless 등 다양한 환경에서 활용됨</p>
<br>
</li>
<li><p>다수의 서버에 컨테이너 기반 프로그램(Docker Container)을 실행하고 관리</p>
</li>
</ul>
<br>

<h3 id="4-kubernetes-아키텍처">4. Kubernetes 아키텍처</h3>
<hr>
<ul>
<li><p>마스터 - 노드</p>
<img src = 'https://velog.velcdn.com/images/ian_lee/post/6e7a6758-460c-49d4-bb7c-2d4ac405cd8d/image.png' width = 500px>


</li>
</ul>
<pre><code>- 각 노드는 물리 혹은 가상 서버

- 클러스터는 1 + 노드의 집합

- 마스터는 클러스터를 관리하는 역할
&lt;br&gt;</code></pre><ul>
<li><p>Kubernetes 프로세스</p>
<ul>
<li><p>마스터 내에서 여러 프로세스가 수행</p>
<ul>
<li><p>API Server</p>
<ul>
<li><p>Container로 동작</p>
</li>
<li><p>클러스터의 진입점</p>
</li>
<li><p>웹 UI, CLI, API 등</p>
<br>
</li>
</ul>
</li>
<li><p>Scheduler</p>
<ul>
<li>노드의 상황을 고려해 Pods 생성 및 할당<br>
</li>
</ul>
</li>
<li><p>Controller Manager</p>
<ul>
<li>전체 상황을 모니터링하고 장애 대응 시스템 제공<br>
</li>
</ul>
</li>
<li><p>etcd</p>
<ul>
<li>Kubernetes 환경 설정 정보가 저장되는 key / value 스토어로 백업됨<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Controller runtime</p>
<ul>
<li>대부분 Docker가 사용됨<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Kubectl: 커맨드라인 툴</p>
<ul>
<li><p><code>kubectl run image_name</code>: Pod 내의 Image 생성 및 실행</p>
</li>
<li><p><code>kubectl cluster-info</code>: 클러스터 세부 정보 조회</p>
</li>
<li><p><code>kubectl get node</code>: 특정 노드 조회</p>
<br>
</li>
</ul>
</li>
<li><p>Pod</p>
<ul>
<li><p>Kubernetes는 컨테이너를 바로 다루지 않음</p>
<br>
</li>
<li><p>Pod: Kubernetes 사용자가 사용하는 가장 작은 빌딩 블록</p>
<ul>
<li>네트워크 주소를 갖는 self-contained server<br>
</li>
</ul>
</li>
<li><p>보통 1Pod = 1Container로 구성</p>
<ul>
<li><p>하나보다 많은 경우, 보통 helper container를 같이 사용</p>
</li>
<li><p>같은 Pod 내에서는 디스크와 네트워크 공유</p>
</li>
<li><p>Fail-over를 위해 replica를 지정하고, 다양한 방법으로 복제본 유지</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="메모">메모</h3>
<hr>
<ul>
<li><p>Docker를 사용한 Production 환경에서 유의할 점</p>
<ul>
<li><p>Production 시에는 named volume을 사용</p>
<br>
</li>
<li><p>Docker Container는 read-only로 사용</p>
<ul>
<li><p>내용 수정이 필요할 경우 실행 중인 Container를 수정하지 않고, Image를 새로 빌드</p>
<ul>
<li>CI / CD 환경이 중요<br>
</li>
</ul>
</li>
</ul>
</li>
<li><p>용량 문제와 장애에 대응하기 위해 다수의 Container를 다수의 호스트에서 실행</p>
<br>
</li>
</ul>
</li>
<li><p>개인 생산성 향상을 위한 Docker</p>
<ul>
<li><p>개발시 필요한 모듈을 Docker Image로 받아와서 Container로 실행</p>
</li>
<li><p>여러 개의 소프트웨어를 연동해서 개발시 docker-compose를 활용</p>
</li>
<li><p>일관된 방식으로, 충돌 없이 소프트웨어 설치 가능</p>
</li>
</ul>
</li>
</ul>
<br>

]]></description>
        </item>
    </channel>
</rss>