<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>동오</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 14 Jul 2025 13:05:44 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>동오</title>
            <url>https://velog.velcdn.com/images/kdo_1999/profile/59cf31d2-b8c7-4aca-bfd0-e809dd978016/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 동오. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/kdo_1999" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[SQLD] ERD 절차]]></title>
            <link>https://velog.io/@kdo_1999/SQLD-ERD-%EC%A0%88%EC%B0%A8</link>
            <guid>https://velog.io/@kdo_1999/SQLD-ERD-%EC%A0%88%EC%B0%A8</guid>
            <pubDate>Mon, 14 Jul 2025 13:05:44 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>ERD 작성 절차</p>
</blockquote>
<ol>
<li>엔티티 도출</li>
<li>엔티티 배치</li>
<li>엔티티 관계 설정</li>
<li>관계명 서술</li>
<li>관계 참여도 표현</li>
<li>관계의 필수 여부 (하나의 회원은 하나의 계좌를 가져야 한다 등의 필수 여부 표현)</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SQLD] 데이터 모델링 관점]]></title>
            <link>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81-%EA%B4%80%EC%A0%90</link>
            <guid>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81-%EA%B4%80%EC%A0%90</guid>
            <pubDate>Mon, 14 Jul 2025 13:04:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>데이터</p>
</blockquote>
<ul>
<li>비즈니스 프로세서에서 사용되는 데이터를 의미한다.</li>
<li>구조 분석, 정적 분석</li>
</ul>
<blockquote>
<p>프로세스</p>
</blockquote>
<ul>
<li>비즈니스 프로세스에서 수해하는 작업을 의미한다.</li>
<li>시나리오 분석, 도메인 분석, 동적 분석</li>
</ul>
<blockquote>
<p>데이터와 프로세스</p>
</blockquote>
<ul>
<li>프로세스와 데이터 간의 관계를 의미한다.</li>
<li>CRUD 분석</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SQLD] 데이터 모델링의 단계]]></title>
            <link>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81%EC%9D%98-%EB%8B%A8%EA%B3%84</link>
            <guid>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81%EC%9D%98-%EB%8B%A8%EA%B3%84</guid>
            <pubDate>Mon, 14 Jul 2025 13:02:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>개념적 모델링</p>
</blockquote>
<ul>
<li>전시적 관점에서 기업의 데이터를 모델링</li>
<li>추상화 수준이 가장 높은 수준의 모델링이다.</li>
<li>계층형 데이터 모델, 네트워크 모델, 관계형 모델에 관계없이 업무 측면에서 모델링</li>
</ul>
<blockquote>
<p>논리적 모델링</p>
</blockquote>
<ul>
<li>특정 데이터베이스 모델에 종속한다.</li>
<li>식별자를 정의하고 관계, 속성 등을 모두 표현한다.</li>
<li>정규화를 통해서 재사용성을 높인다.</li>
</ul>
<blockquote>
<p>물리적 모델링</p>
</blockquote>
<ul>
<li>구축할 데이터베이스 관리 시스템에 테이블, 인덱스 등을 생성한다.</li>
<li>성능, 보안, 가용성 등을 고려하여 구축한다.</li>
</ul>
<hr>
<blockquote>
<p>정규화(Normalization)는 데이터베이스 설계에서 중복을 최소화하고 데이터의 일관성을 유지하기 위해 데이터를 구조화하는 과정입니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SQLD] 데이터 모델링 특징]]></title>
            <link>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81-%ED%8A%B9%EC%A7%95</link>
            <guid>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81-%ED%8A%B9%EC%A7%95</guid>
            <pubDate>Mon, 14 Jul 2025 12:59:20 GMT</pubDate>
            <description><![CDATA[<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>추상화</td>
<td>현실 세계를 간략하게 표현한다.</td>
</tr>
<tr>
<td>단순화</td>
<td>누구나 쉽게 이해할 수 있도록 표현한다.</td>
</tr>
<tr>
<td>명확성</td>
<td>명확하게 의미가 해석되어야 하고 한 가지의 의미를 가져야 한다.</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SQLD] 데이터 모델링]]></title>
            <link>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</link>
            <guid>https://velog.io/@kdo_1999/SQLD-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%AA%A8%EB%8D%B8%EB%A7%81</guid>
            <pubDate>Mon, 14 Jul 2025 12:57:45 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>데이터 모델링</p>
</blockquote>
<ul>
<li>현실 세계를 데이터베이스로 표현하기위해 추상화</li>
<li>고객과의 의사소통을 통해 고객의 업무 프로세스를 이해</li>
<li>업무 프로세스를 이해한 후 데이터 모델링 표기법을 사용해 모델링</li>
<li>고객이 쉽게 이해할 수 있도록 복잡하지 않게 모델링</li>
<li>고객의 업무 프로세스를 추상화하고 소프트웨어를 분석 설계하면서 구체화</li>
<li>비즈니스 프로세스를 이해하고 비즈니스 프로세스의 규칙을 정의</li>
<li>정의된 비즈니스 규칙을 데이터 모델로 표현</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS IAM이란?]]></title>
            <link>https://velog.io/@kdo_1999/AWS-IAM%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@kdo_1999/AWS-IAM%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Fri, 14 Mar 2025 07:01:59 GMT</pubDate>
            <description><![CDATA[<h3 id="iam이란">IAM이란?</h3>
<p>AWS를 처음 가입하고나면 root 계정으로 시작하게 되는데 이 root 계정은 모든 액세스 권한을 다 가지고 있습니다.</p>
<p>그렇다면 root 계정이 노출된다면 AWS의 모든 서비스, 결제 내역 등에 액세스 할 수 있다는 뜻이며 보안적으로 굉장히 위험하게 될 것 입니다.</p>
<p>이것을 방지하기 위해 root 계정으로 IAM 계정을 생성하고 권한을 지정 해줄 수 있으며 그렇게 된다면 root 계정은 책임자 한명만 관리하고 나머지 팀원들이나 직원들은 IAM 계정의 권한을 설정해주어 이용할 수 있게 할 수 있습니다.</p>
<h3 id="장점은">장점은?</h3>
<p>노출이 되면 될수록 탈취의 위험이 존재한다고 판단되는데 IAM을 사용하면 굳이 root 계정을 사용하지 않아도 AWS 서비스를 이용하고 관리하는데는 문제가 없어 탈취의 위험을 줄일 수 있습니다.</p>
<p>또한 root 계정의 활동은 추적과 감사가 힘들지만 IAM은 추적과 감사가 가능하여 비정상적인 서비스 이용을 좀 더 빠르게 차단할 수 있습니다.</p>
<h3 id="iam-계정-만드는-방법">IAM 계정 만드는 방법</h3>
<ol>
<li><p>root 계정으로 로그인 한 후에 좌측 상단에 IAM을 검색하여 들어갑니다.
<img src="https://velog.velcdn.com/images/kdo_1999/post/0a804943-1dc6-4b84-93b9-1dc6cfe70a79/image.png" alt="IAM 검색"></p>
</li>
<li><p>좌측 메뉴에 사용자 클릭
<img src="https://velog.velcdn.com/images/kdo_1999/post/31717989-3f48-448d-af1f-bba5cec33b4b/image.png" alt="사용자 메뉴 선택"></p>
</li>
<li><p>사용자 생성 버튼 클릭
<img src="https://velog.velcdn.com/images/kdo_1999/post/2b16e24d-8161-4933-a7ff-5dc12a912568/image.png" alt="사용자 생성 버튼 클릭"></p>
</li>
<li><p>사용자 이름에 원하는 이름을 넣어줍니다.</p>
</li>
</ol>
<ul>
<li>IAM 사용하를 생성하고 싶음 선택</li>
<li>비밀번호를 지정해서 공유할 것이기 때문에 사용자 지정 암호 선택 후 암호 입력</li>
<li>사용자는 다음 로그인시 새 암호를 생성해야 합니다. 체크 해제<ul>
<li>사용자에게 공유해준후 개인적으로 관리하게 할 것이 아니라면 굳이 체크할 필요 X</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/571a39c0-1425-499c-8dde-6919a3388182/image.png" alt="사용자 생성"></p>
<ol start="5">
<li>권한 설정
직접 정책 연결을 선택하여 사용자에게 부여할 정책을 선택합니다.<blockquote>
<h4 id="정책이란">정책이란?</h4>
<p>정책은 권한의 집합을 뜻하며 하나의 정책 안에 여러 권한이 들어가 있습니다.</p>
</blockquote>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/fb35cd18-112c-45f3-a828-8ad893cf3021/image.png" alt="권한 설정"></p>
<ol start="6">
<li><p>생성
권한을 선택한 후 다음 화면에서 사용자 생성 버튼을 눌러 생성해줍니다.
<img src="https://velog.velcdn.com/images/kdo_1999/post/4fdc0bb3-b885-47bd-8e7f-c568efa26621/image.png" alt="사용자 생성"></p>
</li>
<li><p>별칭 설정
root 계정은 유니크한데 IAM 사용자 이름도 당연히 유니크해야 할 것입니다.
근데 현재 상황을 보면 admin2의 사용자 이름은 AWS 내에 무수히 많을 것입니다.
그럼 이거를 어떻게 구별을 하는가?
별칭으로 구별을 합니다.
<br>우측 계정 별칭 아래 생성 버튼을 클릭합니다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/879e474d-67a1-4e2c-bb03-266d3e1d5f62/image.png" alt="별칭 생성"></p>
<ol start="8">
<li><p>별칭 입력 후 생성
<img src="https://velog.velcdn.com/images/kdo_1999/post/972bf808-eab9-49c2-a764-039038173a97/image.png" alt="별칭 입력"></p>
</li>
<li><p>IAM 계정으로 로그인</p>
</li>
</ol>
<p>Account Id or alias -&gt; 8번에 선택한 별명
IAM username -&gt; 4번에 입력한 username
Password -&gt; 비밀번호
<img src="https://velog.velcdn.com/images/kdo_1999/post/ff08ac37-e50e-4c9e-af72-ca84be865ab8/image.png" alt="로그인"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS 서비스 종류]]></title>
            <link>https://velog.io/@kdo_1999/AWS-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A2%85%EB%A5%98</link>
            <guid>https://velog.io/@kdo_1999/AWS-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A2%85%EB%A5%98</guid>
            <pubDate>Fri, 14 Mar 2025 06:23:42 GMT</pubDate>
            <description><![CDATA[<p>IAM : 사용자 계정 및 권한 관리 서비스</p>
<p>EC2 : 가상 서버 인스턴스 제공 서비스</p>
<p>S3 : 확장성 있는 객체 스토리지 서비스</p>
<p>RDS : 관계형 데이터베이스 서비스</p>
<p>Lambda : 서버리스 컴퓨팅 서비스</p>
<p>DynamoDB : NoSQL 데이터베이스 서비스</p>
<p>CloudFront : 콘텐츠 전송 네트워크(CDN) 서비스</p>
<p>VPC : 가상 사설 클라우드 네트워크 서비스</p>
<p>Route 53 : DNS 웹 서비스</p>
<p>CloudWatch : 모니터링 및 관찰 서비스</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform이란?]]></title>
            <link>https://velog.io/@kdo_1999/Terraform%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@kdo_1999/Terraform%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Fri, 14 Mar 2025 06:23:18 GMT</pubDate>
            <description><![CDATA[<h3 id="테라폼이란">테라폼이란?</h3>
<p>테라폼은 선언적 구성 파일로 AWS 자원을 생성, 관리, 업데이트를 할 수 있게 해주는 라이브러리 입니다.</p>
<h3 id="테라폼을-쓰지-않는다면">테라폼을 쓰지 않는다면?</h3>
<p>매번 AWS 홈페이지에 접근해서 로그인 후 자원을 만들었다가 삭제하고의 반복 작업이 될 것 입니다.</p>
<h3 id="테라폼을-쓰면-좋은-점은">테라폼을 쓰면 좋은 점은?</h3>
<ul>
<li>버전 관리가 가능하며 이를 추적하고 협업 할 수 있습니다.</li>
<li>코드로 인프라를 관리하기 때문에 보다 쉽게 생성하고 삭제 등의 반복 작업을 할 수 있으며 모두가 일관된 환경을 사용할 수 있습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[리눅스 한국 시간으로 변경]]></title>
            <link>https://velog.io/@kdo_1999/%EB%A6%AC%EB%88%85%EC%8A%A4-%ED%95%9C%EA%B5%AD-%EC%8B%9C%EA%B0%84%EC%9C%BC%EB%A1%9C-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@kdo_1999/%EB%A6%AC%EB%88%85%EC%8A%A4-%ED%95%9C%EA%B5%AD-%EC%8B%9C%EA%B0%84%EC%9C%BC%EB%A1%9C-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Sat, 08 Mar 2025 12:12:48 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-bash"># 현재 시간 확인
date

# 현재 TimeZone 설정 확인
timedatectl

# Asia/Seoul로 시간대 변경
sudo timedatectl set-timezone Asia/Seoul</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker Mysql 데이터 백업 자동화]]></title>
            <link>https://velog.io/@kdo_1999/Docker-Mysql-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B0%B1%EC%97%85-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@kdo_1999/Docker-Mysql-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B0%B1%EC%97%85-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Fri, 07 Mar 2025 08:52:57 GMT</pubDate>
            <description><![CDATA[<h1 id="🛠-트러블슈팅-기록">🛠 트러블슈팅 기록</h1>
<h2 id="1-문제-요약">1. 문제 요약</h2>
<p><strong>발생 일시:</strong> 2025/03/06</p>
<p><strong>증상: Oracle Cloud 인스턴스에 실행중인 Mysql 강제 종료 현상</strong></p>
<ul>
<li>강제 종료도 문제지만 데이터 백업이 제대로 이루어지지 않아서 강제 종료 이후엔 DB가 다 날라가는 문제 발생</li>
</ul>
<h2 id="2-원인-분석">2. 원인 분석</h2>
<blockquote>
<p>Mysql 컨테이너 로그 기록 </p>
</blockquote>
<ul>
<li>메모리 부족 및 최대 연결 수 설정 문제로 유추
<img src="https://velog.velcdn.com/images/kdo_1999/post/a3985943-063c-45a1-91ed-426a52ea5d46/image.png" alt="mysql 컨테이너 로그"></li>
</ul>
<h2 id="3-해결-방법">3. 해결 방법</h2>
<ul>
<li><p>Mysql 컨테이너에 bash로 접속해서 my.cnf 파일에 아래 설정 추가</p>
<ul>
<li><p><a href="https://manage.accuwebhosting.com/knowledgebase/2320/How-to-Fix-Error-Forcing-close-of-thread-310-user-andsharp039rootandsharp039-in-MySQL.html">참고 URL</a></p>
</li>
<li><p>메모리 부족 및 연결 수 설정 문제일 가능성 발견</p>
<pre><code class="language-bash">innodb_buffer_pool_size=512M  # InnoDB 버퍼 풀 크기 설정
max_connections=100  #</code></pre>
<pre><code class="language-bash"># 컨테이너 터미널 접속
docker exec -it 컨테이너명 bash

# 설정 파일 출력
cat my.cnf

# 출력 결과 복사 후 위에 설정 추가해서 덮어쓰기
echo -e &quot;
# For advice on how to change settings please see
# http://dev.mysql.com/doc/refman/9.2/en/server-configuration-defaults.html

[mysqld]
innodb_buffer_pool_size=512M  # InnoDB 버퍼 풀 크기 설정
max_connections=100  #
# Remove leading # and set to the amount of RAM for the most important data
# cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
# innodb_buffer_pool_size = 128M
#
# Remove leading # to turn on a very important data integrity option: logging
# changes to the binary log between backups.
# log_bin
#
# Remove leading # to set options mainly useful for reporting servers.
# The server defaults are faster for transactions and fast SELECTs.
# Adjust sizes as needed, experiment to find the optimal values.
# join_buffer_size = 128M
# sort_buffer_size = 2M
# read_rnd_buffer_size = 2M

host-cache-size=0
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql

pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock&quot; &gt; my.cnf

# 추가한 설정 들어갔는지 확인
cat my.cnf

exit

# 컨테이너 재시작
docker stop 컨테이너명
docker start 컨테이너명</code></pre>
</li>
</ul>
</li>
<li><p>DB 자동 백업 설정으로 추후 다시 발생하더라도 복구 가능하게 조치</p>
<ul>
<li><p>쉘 스크립트 작성</p>
<pre><code class="language-bash">vi db_backup/backup.sh
# 1. I 눌러서 Insert mode에서 아래 내용 복사
# 2. :wq + Enter로 저장</code></pre>
<pre><code class="language-bash">DEV_FILE_NAME=dev_backup_`date +&quot;%Y%m%d%H%M%S&quot;`
TESTDB_FILE_NAME=testdb_backup_`date +&quot;%Y%m%d%H%M%S&quot;`

# mysql db 데이터 백업
docker exec mysql-server mysqldump -u root -ptest1 dev &gt; $DEV_FILE_NAME.sql;
docker exec mysql-server mysqldump -u root -ptest1 testdb &gt; $TESTDB_FILE_NAME.sql;

# 백업 디렉토리에서 백업 파일들 중 가장 최신 3개를 제외하고 나머지 삭제
ls -t testdb_backup_*.sql | tail -n +4 | xargs rm -ff
ls -t dev_backup_*.sql | tail -n +4 | xargs rm -f</code></pre>
</li>
<li><p>자동 실행 설정</p>
<pre><code class="language-bash">vi /etc/crontab
# 1. I 눌러서 Insert mode에서 아래 내용 복사
# 2. :wq + Enter로 저장
</code></pre>
<p>```bash</p>
<h1 id="1시간마다-자동-실행">1시간마다 자동 실행</h1>
<h1 id="60-----는-cron-표현법이고-따로-찾아보시길-바랍니다">*/60 * * * * 는 cron 표현법이고 따로 찾아보시길 바랍니다.</h1>
<h1 id="root---사용자">root -&gt; 사용자</h1>
<h1 id="varlibdockervolumesmysql-data-voldb_backupbackupsh---쉘-스크립트-경로">/var/lib/docker/volumes/mysql-data-vol/db_backup/backup.sh -&gt; 쉘 스크립트 경로</h1>
</li>
<li><p>/60 * * * *    root    /var/lib/docker/volumes/mysql-data-vol/db_backup/backup.sh</p>
<pre><code>
</code></pre></li>
</ul>
</li>
</ul>
<h2 id="4-결과-및-추가-조치">4. 결과 및 추가 조치</h2>
<ul>
<li>현재까지는 서버 다운 없이 정상 가동중</li>
</ul>
<h2 id="5-회고-및-예방-조치">5. 회고 및 예방 조치</h2>
<ul>
<li><p>실제 운영 서버라면 당연히 무슨 일이 일어날지 모르기 때문에 DB는 자동으로 백업 조치를 해주어야하는데 볼륨 설정만 해두고 백업을 하지 않았어서 발생했던 거 같다.
추후에는 컨테이너 띄우면서 초기 백업 작업까지 해주어야겠다.</p>
</li>
<li><p>최대 커넥션 수랑 InnoDB 버퍼 풀 사이즈는 정확히 어떻게 동작하는지 또 설정 값을 어느정도로 두어야 하는지는 아직 확인해보지 못해서 추후 학습해볼 예정이다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JENKINS 설치 방법]]></title>
            <link>https://velog.io/@kdo_1999/JENKINS</link>
            <guid>https://velog.io/@kdo_1999/JENKINS</guid>
            <pubDate>Sun, 02 Mar 2025 09:34:40 GMT</pubDate>
            <description><![CDATA[<p>젠킨스 설치</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt upgrade -y
wget -q -O - https://pkg.jenkins.io/ci.jenkins.io.key | sudo tee /etc/apt/trusted.gpg.d/jenkins.asc
echo &quot;deb http://pkg.jenkins.io/debian/ stable main&quot; | sudo tee /etc/apt/sources.list.d/jenkins.list
sudo apt update
sudo apt install jenkins -y

#젠킨스 시작
sudo systemctl start jenkins

#젠킨스 자동 시작 설정
sudo systemctl enable jenkins

</code></pre>
<pre><code class="language-bash">#1번
java -version

#2번
sudo vi /etc/default/jenkins

#3번
JAVA_HOME=1번에서 나온 값 입력
HTTP_PORT=변경할 포트

#만약 3번까지 했는데 적용 된다면 끝이고 안 됐다면 이어서 계속 진행
#작성자는 포트 번호가 변경이 안돼서 포트 번호만 jenkins.service에서 수정 했습니다.

#4번
sudo vi /etc/systemd/system/jenkins.service

#위에 명령어 했을 때 없다면 아래 명렁어 실행
sudo vi /usr/lib/systemd/system/jenkins.service

#5번
Environment=&quot;JENKINS_PORT=원하는 포트&quot;

#6번
sudo systemctl daemon-reload
sudo systemctl restart jenkins

#7번
sudo ufw allow 젠킨스포트</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Oracle Cloud 백엔드 젠킨스 배포]]></title>
            <link>https://velog.io/@kdo_1999/Dockerfile-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B9%8C%EB%93%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%8C%EB%93%9C</link>
            <guid>https://velog.io/@kdo_1999/Dockerfile-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B9%8C%EB%93%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%8C%EB%93%9C</guid>
            <pubDate>Thu, 27 Feb 2025 03:23:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>추후 docker-compose까지 만들고 다시 수정할 예정 입니다.</p>
</blockquote>
<p>도커 파일</p>
<pre><code class="language-docker">FROM eclipse-temurin:21-jre-alpine as builder
WORKDIR extracted
ADD ./build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre-alpine
WORKDIR application
COPY --from=builder extracted/dependencies/ ./
COPY --from=builder extracted/spring-boot-loader/ ./
COPY --from=builder extracted/snapshot-dependencies/ ./
COPY --from=builder extracted/application/ ./
EXPOSE 8080
ENTRYPOINT [&quot;java&quot;, &quot;org.springframework.boot.loader.launch.JarLauncher&quot;]</code></pre>
<p>젠킨스 파이프라인</p>
<pre><code class="language-bash">pipeline {
    agent any

    environment {
        DOCKER_IMAGE = &quot;ghcr.io/kdo1999/ci-cd-test/ci-cd-backend&quot;
        DOCKER_TAG = &quot;:latest&quot;
        CONTAINER_NAME = &quot;ci-cd-backend&quot;
        GITHUB_CREDENTIALS = credentials(&#39;ci-cd-github&#39;)
    }

    stages {
        stage(&#39;Login to GitHub Container Registry&#39;) {
            steps {
                sh &#39;echo $GITHUB_CREDENTIALS_PSW | docker login ghcr.io -u $GITHUB_CREDENTIALS_USR --password-stdin&#39;
            }
        }

        stage(&#39;Pull Docker Image&#39;) {
            steps {
                sh &#39;docker pull ${DOCKER_IMAGE}${DOCKER_TAG}&#39;
            }
        }

        stage(&#39;Deploy&#39;) {
            steps {
                sh &#39;&#39;&#39;
                docker stop ${CONTAINER_NAME} || true
                docker rm ${CONTAINER_NAME} || true
                docker rmi $(docker images | grep &quot;${DOCKER_IMAGE}&quot; | grep &#39;&lt;none&gt;&#39; | awk &#39;{print $3}&#39;) || true

                docker run -it -d --name ${CONTAINER_NAME} \
                  --restart unless-stopped \
                  -p 8080:8080 \
                  -v ./logs:/application/logs \
                  ${DOCKER_IMAGE}
                &#39;&#39;&#39;
            }
        }
    }

    post {
        always {
            sh &#39;docker logout ghcr.io&#39;
        }
        success {
            withCredentials([string(credentialsId: &#39;Discord-Webhook&#39;, variable: &#39;DISCORD&#39;)]) {
                        discordSend description: &quot;&quot;&quot;
                        제목 : ${currentBuild.displayName}
                        결과 : ${currentBuild.result}
                        실행 시간 : ${currentBuild.duration / 1000}s
                        &quot;&quot;&quot;,
                        link: env.BUILD_URL, result: currentBuild.currentResult, 
                        title: &quot;${env.JOB_NAME} : ${currentBuild.displayName} 성공&quot;, 
                        webhookURL: &quot;$DISCORD&quot;
            }
        }
        failure {
            withCredentials([string(credentialsId: &#39;Discord-Webhook&#39;, variable: &#39;DISCORD&#39;)]) {
                        discordSend description: &quot;&quot;&quot;
                        제목 : ${currentBuild.displayName}
                        결과 : ${currentBuild.result}
                        실행 시간 : ${currentBuild.duration / 1000}s
                        &quot;&quot;&quot;,
                        link: env.BUILD_URL, result: currentBuild.currentResult, 
                        title: &quot;${env.JOB_NAME} : ${currentBuild.displayName} 실패&quot;, 
                        webhookURL: &quot;$DISCORD&quot;
            }
        }
    }
}</code></pre>
<p>Github 액션</p>
<pre><code class="language-yml">name: Build and Push Docker Image

on:
  push:
    branches: [ main, develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up JDK 21
      uses: actions/setup-java@v3
      with:
        java-version: &#39;21&#39;
        distribution: &#39;temurin&#39;
        cache: gradle

    - name: Build with Gradle
      run: |
        cd backend
        ./gradlew bootJar

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2

    - name: Login to GitHub Container Registry
      uses: docker/login-action@v2
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.REPOSITORY_TOKEN }}

    - name: Extract metadata for Docker
      id: meta
      uses: docker/metadata-action@v4
      with:
        images: ghcr.io/${{ github.repository }}/ci-cd-backend
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=sha,format=long
          latest

    - name: Build and push Docker image
      uses: docker/build-push-action@v4
      with:
        context: ./backend
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        platforms: linux/amd64,linux/arm64
    - name: jenkins deploy
      if: success()
      uses: appleboy/jenkins-action@master
      with:
        url: ${{ secrets.JENKINS_URL }}
        user: ${{ secrets.JENKINS_USER }}
        token: ${{ secrets.JENKINS_DEPLOY_TOKEN }}
        job: ${{ secrets.JENKINS_JOB }}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Dockerfile 빌드시 최적화 방법]]></title>
            <link>https://velog.io/@kdo_1999/Dockerfile-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@kdo_1999/Dockerfile-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 26 Feb 2025 07:48:17 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-bash"># 기존 방식
echo -e &#39;
# 기본 이미지 설정
FROM nginx:latest

# index.html 파일 복사
COPY source/web /web
COPY source/etc/nginx/conf.d/vhost.conf /etc/nginx/conf.d/vhost.conf
&#39; &gt; Dockerfile</code></pre>
<p><strong>해당 예제에서는 vhost.conf는 설정 파일이라고 가정하고 /web에 들어가는 파일들은 애플리케이션 코드라고 가정해보자.</strong></p>
<p>Docker image 빌드 시 캐시를 사용을 하게 되어있다.</p>
<p>위의 Dockerfile로 한 번 빌드를 하고 다시 빌드를 한다면?</p>
<ul>
<li><p>도커는 처음 빌드 때 해당 파일들의 해시 값을 기억해뒀다가 두 번째 빌드 때 
해시 값과 일치한지 판단 후에 같다면 다시 실행시키지 않는다.</p>
</li>
<li><p>좀 더 풀어서 설명하자면 만약 web/index.html 파일만 수정을 하고 다시 빌드한다면 
아래에 <code>COPY source/web /web</code> 라인부터 명령어를 실행할 것이다.</p>
</li>
</ul>
<p>그럼 만약 vhost.conf만 수정을 했다면?</p>
<ul>
<li><code>COPY source/etc/nginx/conf.d/vhost.conf /etc/nginx/conf.d/vhost.conf</code>
해당 라인부터 다시 명령어를 실행하고 그 위에 <code>COPY source/web /web</code> 이 부분은 
캐시에 있고 파일 변경이 없어서 실행하지 않는다.</li>
</ul>
<hr>
<p>우리는 vhost.conf는 설정 파일이고 /web 하위의 디렉토리와 파일들은 애플리케이션 코드라고 가정한다고 했으니 한 번 생각해보자.</p>
<p>설정 파일의 변경사항이 많을까?</p>
<ul>
<li>보통은 설정 파일의 변동보다는 애플리케이션 코드 변경이 더 잦게 일어날 것이다.</li>
</ul>
<p>그럼 지금까지 작성한 토대로 생각을 해보면 아래와 같이 Dockerfile이 수정되는게 맞을 것이다.</p>
<p><strong>총 정리</strong></p>
<ul>
<li>설정 파일보다는 애플리케이션 코드 수정이 더 많을 것이다.</li>
<li>도커 빌드시 캐시를 사용한다고 하였는데 맨 윗줄부터 내려오다가 해시 값이 캐시와 다른 파일이 있다면 그 라인부터는 명령이 실행 될 것이다.</li>
<li>그럼 Dockerfile의 명령 순서를 가장 변경이 없는 라인들을 위로 보내주고 많은 부분들을 아래로 보내줘야 불필요한 명령을 실행하지 않게 될 것이다.</li>
</ul>
<pre><code class="language-bash">echo -e &#39;
# 기본 이미지 설정
FROM nginx:latest

# 설정 파일의 COPY가 위로가고 애플리케이션 디렉토리의 COPY가 아래로 가도록 수정
COPY source/etc/nginx/conf.d/vhost.conf /etc/nginx/conf.d/vhost.conf
COPY source/web /web
&#39; &gt; Dockerfile</code></pre>
<blockquote>
<p>** 작성자가 복습 차원에서 간단한 개념 정리 용도로 작성하는 글 입니다. **
혹시나 틀린 부분이 있다면 댓글 부탁드리겠습니다!**</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Dockerfile로 이미지 빌드시 왜 원본 디렉토리를 만든 후 COPY를 사용하는지?]]></title>
            <link>https://velog.io/@kdo_1999/Dockerfile%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%8C%EB%93%9C%EC%8B%9C-%EC%99%9C-%EC%9B%90%EB%B3%B8-%EB%94%94%EB%A0%89%ED%86%A0%EB%A6%AC%EB%A5%BC-%EB%A7%8C%EB%93%A0-%ED%9B%84-COPY%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EC%A7%80</link>
            <guid>https://velog.io/@kdo_1999/Dockerfile%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%8C%EB%93%9C%EC%8B%9C-%EC%99%9C-%EC%9B%90%EB%B3%B8-%EB%94%94%EB%A0%89%ED%86%A0%EB%A6%AC%EB%A5%BC-%EB%A7%8C%EB%93%A0-%ED%9B%84-COPY%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EC%A7%80</guid>
            <pubDate>Wed, 26 Feb 2025 07:42:24 GMT</pubDate>
            <description><![CDATA[<h2 id="dockerfile로-빌드시-왜-원본-디렉토리를-만들어서-사용하는지">Dockerfile로 빌드시 왜 원본 디렉토리를 만들어서 사용하는지?</h2>
<h3 id="현재-dockerfile로-이미지-빌드하는-방법">현재 Dockerfile로 이미지 빌드하는 방법</h3>
<p>기본 디렉토리 및 파일 생성</p>
<pre><code class="language-bash">mkdir -p ~/testDockerProjects/exam/source
cd ~/testDockerProjects/exam
mkdir -p source/web/site1
echo &quot;&lt;h1&gt;Site 1&lt;/h1&gt;&quot; &gt; source/web/site1/index.html</code></pre>
<p>Dockerfile 생성</p>
<pre><code class="language-bash">echo -e &#39;
# 기본 이미지 설정
FROM nginx:latest

# index.html 파일 복사
COPY source/web /web
&#39; &gt; Dockerfile</code></pre>
<p><br><br></p>
<hr>
<h3 id="위에처럼-원본을-만들지-않고-dockerfile에-아래처럼-작성하면-되지-않을까">위에처럼 원본을 만들지 않고 Dockerfile에 아래처럼 작성하면 되지 않을까?</h3>
<pre><code>echo -e &#39;
# 기본 이미지 설정
FROM nginx:latest

RUN echo &quot;&lt;h1&gt;Site 1&lt;/h1&gt;&quot; &gt; /web/site1/index.html</code></pre><p>도커 파일에 RUN을 이용해서 만든 후에 뭔가 변경 사항이 생겨서 index.html에 아래와 같이 변경해야 된다고 생각해보자.</p>
<pre><code>&lt;h1&gt;Site 1, Hello&lt;/h1&gt;</code></pre><p>그럼 Dockerfile 자체를 다시 수정을 해야되는데 지금이야 파일이 1개지만 만약 여러가지라면 오히려 복잡해질 가능성이 크다.</p>
<p>처음과 같은 방식으로 사용했다면 로컬에 있는 source/web/site1/index.html를 수정해주고 다시 이미지 빌드만 시켜주면 수정 사항이 반영이 된다.</p>
<h3 id="정리">정리</h3>
<p>Dockerfile에 RUN으로 파일을 만들어 줄 수는 있지만 꼭 필요한 경우가 아닌 이상은 
로컬의 디렉토리에서 생성 후 COPY로 파일을 만들어 주는게 이후에 수정 사항이 생겨도 반영하기가 편하다.</p>
<blockquote>
<p>** 작성자가 복습 차원에서 간단한 개념 정리 용도로 작성하는 글 입니다. **
혹시나 틀린 부분이 있다면 댓글 부탁드리겠습니다!**</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS] Region이란?]]></title>
            <link>https://velog.io/@kdo_1999/AWS-Region%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@kdo_1999/AWS-Region%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Tue, 25 Feb 2025 15:13:00 GMT</pubDate>
            <description><![CDATA[<h2 id="region이란">Region이란?</h2>
<p>EC2는 우리가 컴퓨터를 빌려서 원격으로 접속해 사용하는 서비스인데 이걸 서비스 해주는 데이터 센터가 있는 위치라고 생각하면 된다.
<br></p>
<hr>
<h2 id="region의-특징">Region의 특징</h2>
<p>전 세계적으로 분포해있다.</p>
<br>

<hr>
<h2 id="region은-어떤-기준으로-선택할까">Region은 어떤 기준으로 선택할까?</h2>
<p>우리가 운영하는 서비스의 사용자들이 많은 곳을 기준으로 선택하면 된다.
네트워크를 통해 통신하기 때문에 사용자의 위치와 서비스를 운영하는 서버의 위치가 멀어질수록 당연히 속도가 저하될 수 밖에 없다.</p>
<br>

<hr>
<h2 id="많이하는-실수">많이하는 실수</h2>
<p>AWS에서는 각 Region마다 인스턴스를 관리하게 되는데 만약 미국 동부(버지니아 북부)에 인스턴스를 생성해두었는데 아시아 태평양(서울)을 선택하고 인스턴스가 없어졌다고 당황하는 경우도 있다.</p>
<p>** 각 Resion마다 별개로 인스턴스들이 관리되기 때문에 이 점은 꼭 알아두자 **</p>
<p><br><br><br><br><br><br></p>
<blockquote>
<p>** 작성자가 복습 차원에서 간단한 개념 정리 용도로 작성하는 글 입니다. **
혹시나 틀린 부분이 있다면 댓글 부탁드리겠습니다!**</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[배포(Deployment)란?]]></title>
            <link>https://velog.io/@kdo_1999/%EB%B0%B0%ED%8F%ACDeployment%EB%9E%80</link>
            <guid>https://velog.io/@kdo_1999/%EB%B0%B0%ED%8F%ACDeployment%EB%9E%80</guid>
            <pubDate>Tue, 25 Feb 2025 15:03:41 GMT</pubDate>
            <description><![CDATA[<h2 id="배포란-deployment">배포란? (Deployment)</h2>
<p>개발을 하고난 뒤 실제 다른 사용자가 인터넷을 통해 사용할 수 있게 하는 것을 뜻한다.
우리가 개발을 할 때에는 localhost로 접근을 하여 테스트를 하고 개발을 한다.
하지만 다른 사용자들은 localhost로 접근이 불가능하다.</p>
<p>즉 우리가 만든 코드를 빌드한 결과물을 서버에 올리는 것을 뜻한다.
<br><br><br><br><br><br></p>
<blockquote>
<p>** 작성자가 복습 차원에서 간단한 개념 정리 용도로 작성하는 글 입니다. **
혹시나 틀린 부분이 있다면 댓글 부탁드리겠습니다!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[오라클 클라우드에 Nginx Virtualhost 설정 방법]]></title>
            <link>https://velog.io/@kdo_1999/%EC%98%A4%EB%9D%BC%ED%81%B4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%97%90-Nginx-Virtualhost-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@kdo_1999/%EC%98%A4%EB%9D%BC%ED%81%B4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%97%90-Nginx-Virtualhost-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 25 Feb 2025 14:56:22 GMT</pubDate>
            <description><![CDATA[<h2 id="❓ufw-방화벽-포트를-열어주지-않은-이유">❓ufw 방화벽 포트를 열어주지 않은 이유?</h2>
<ul>
<li><code>Docker</code>가 자동으로 <code>iptables</code> 규칙을 설정해준다.<ul>
<li><code>Docker 데몬(dockerd)</code>은 컨테이너가 실행될 때 자동으로 <code>iptables</code>에 필요한 규칙을 추가해준다.</li>
</ul>
</li>
<li>그럼 오라클 클라우드에 수신 규칙은 설정하지 않아도 되나?<ul>
<li><code>Docker</code>는 <strong>운영 체제의 규칙만 추가</strong>해줄뿐 <strong>오라클 클라우드</strong>의 <strong>수신 규칙은 별개이기 때문에 반드시 설정해주어야 접근</strong>이 가능하다.</li>
</ul>
</li>
</ul>
<h2 id="1️⃣-nginx-도커-컨테이너-띄우기">1️⃣ Nginx 도커 컨테이너 띄우기</h2>
<pre><code class="language-bash">docker run \
  --rm \
  -d \
  -p 8080:8080 \
  -p 8081:8081 \
  -p 8082:8082 \
  --name nginx-1 \
  nginx</code></pre>
<br>

<hr>
<h2 id="2️⃣-nginx-1-bash로-접속">2️⃣ nginx-1 bash로 접속</h2>
<pre><code class="language-bash">docker exec -it nginx-1 bash</code></pre>
<br>

<hr>
<h2 id="3️⃣-각-포트별로-띄워줄-indexhtml-생성">3️⃣ 각 포트별로 띄워줄 index.html 생성</h2>
<pre><code class="language-bash">mkdir -p /web/site1
mkdir -p /web/site2
mkdir -p /web/site3

echo &quot;&lt;h1&gt;Site 1&lt;/h1&gt;&quot; &gt; /web/site1/index.html
echo &quot;&lt;h1&gt;Site 2&lt;/h1&gt;&quot; &gt; /web/site2/index.html
echo &quot;&lt;h1&gt;Site 3&lt;/h1&gt;&quot; &gt; /web/site3/index.html</code></pre>
<br>

<hr>
<h2 id="4️⃣-virtualhost-설정-vhostconf">4️⃣ Virtualhost 설정 (vhost.conf)</h2>
<pre><code class="language-bash">#/etc/nginx/conf.d/경로에 vhost.conf 파일 생성
echo -e &quot;
server {
    listen 8080;
    root /web/site1;
}

server {
    listen 8081;
    root /web/site2;
}

server {
    listen 8082;
    root /web/site3;
}
&quot; &gt; /etc/nginx/conf.d/vhost.conf

#새로운 설정 파일 적용
nginx -s reload</code></pre>
<br>

<hr>
<h3 id="❓-vhostconf를-따로-만든-이유">❓ vhost.conf를 따로 만든 이유</h3>
<p>기본 설정 파일 맨 아래에 보면 <code>include /etc/nginx/conf.d/*.conf</code> 라고 적힌 부분이 있다.</p>
<p>이 뜻은 <code>/etc/nginx/conf.d/</code> 하위에 있는 <code>*.conf</code> 모든 conf 확장자의 파일을 불러온다는 뜻이다.</p>
<p>하나의 파일에 설정을 몰아두는 거 보다는 위와 같이 Virtualhost를 설정하는 부분은 따로 파일로 분리해주면 해당 설정을 수정하거나 확인하기에 좀 더 효율적일 것이다.</p>
<pre><code class="language-bash">#nginx 기본 설정 파일 경로 확인
find -name nginx.conf 2&gt;&amp;1 | grep -v &quot;Permission denied&quot;

vi ./etc/nginx/nginx.conf</code></pre>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/aee792b1-3c1c-4ccd-8d61-35c990f0252d/image.png" alt="nginx.conf">
<br></p>
<hr>
<h2 id="✅-브라우저에서-접속-확인">✅ 브라우저에서 접속 확인</h2>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/2c9ec6ed-8f0e-4675-a6a4-976ce7f4978d/image.png" alt="브라우저에서 접속 확인"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[사람인 API 데이터 저장하는 로직 성능 개선]]></title>
            <link>https://velog.io/@kdo_1999/%EC%82%AC%EB%9E%8C%EC%9D%B8-API-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5%ED%95%98%EB%8A%94-%EB%A1%9C%EC%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@kdo_1999/%EC%82%AC%EB%9E%8C%EC%9D%B8-API-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5%ED%95%98%EB%8A%94-%EB%A1%9C%EC%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Wed, 19 Feb 2025 07:54:57 GMT</pubDate>
            <description><![CDATA[<h1 id="📌-스케줄러-성능-문제-분석-및-개선-방향">📌 스케줄러 성능 문제 분석 및 개선 방향</h1>
<h2 id="🚀-성능-개선을-하게된-이유">🚀 성능 개선을 하게된 이유</h2>
<p>서버를 세팅할 때 처음에 사람인에서 채용공고를 가져와야 했다.</p>
<p>총 12,000개 가량의 데이터가 있었고 스케줄러에 기존 로직으로 저장을 했더니 평균 20분 정도 소요가 됐었다.</p>
<p>우리는 채용 공고를 전날 새로 올라온 데이터를 00시에 갱신해오기 때문에 채용 공고는 만아야 500개 안쪽이라 약 1분~2분 정도면 실시간성이 중요한 부분이 아닌지라 그렇게 사용자가 불편함을 겪을 수치는 아니라고 생각은 들었으나 그래도 발생하는 쿼리의 양이 만만치 않다고 느껴져서 리팩토링을 진행하게 되었다.</p>
<h2 id="🏎️-캐시를-사용한-이유">🏎️ 캐시를 사용한 이유</h2>
<ul>
<li>사람인 채용 공고에는 직무 스킬 정보가 포함되어 있음.</li>
<li>하지만, 사람인 API 페이지에 올라와 있는 공식 직무 스킬 코드 외에도 응답에 추가 정보가 포함됨.</li>
<li>직무 스킬 코드와 이름의 순서가 뒤섞여 있어 임의로 추가하는 것은 리스크가 존재.</li>
<li>따라서 공식 직무 스킬 코드만 DB에 저장하고, 없는 데이터는 저장하지 않도록 결정.</li>
<li>DB 데이터를 캐싱 저장소(Redis)에 저장하면 변동이 거의 없을 것이고,</li>
<li>260개의 데이터는 Redis에서 부담되지 않는 수준이라 판단하여 사용.</li>
</ul>
<h2 id="✅-기존-flow">✅ 기존 Flow</h2>
<ol>
<li><strong>사람인 API 호출</strong> → <code>JobPosting</code> 엔티티 변환</li>
<li><strong>JobPosting 전체 저장</strong> (<strong>병목 발생 가능</strong>)</li>
<li><strong>응답받은 Job 데이터를 Map에 저장</strong> (<code>key: Job.id</code>, <code>value: Job 데이터</code>)</li>
<li><strong>저장된 JobPosting을 순회하면서 추가 처리</strong><ul>
<li><code>JobPosting.jobId</code>와 일치하는 Job을 <code>Map</code>에서 가져옴</li>
<li>Job에서 <code>jobCode</code>를 가져와 <code>&quot;,&quot;</code>로 분리</li>
<li>분리된 <code>jobCode</code>를 순회하며:<ul>
<li><strong><code>Job_Skill</code> 테이블에서 jobCode 조회</strong> (<strong>병목 발생 가능</strong>)</li>
<li>조회된 데이터를 <code>JobPostingJobSkillList</code>에 추가 후 저장 (<strong>병목 발생 가능</strong>)</li>
</ul>
</li>
</ul>
</li>
<li><strong>전체 데이터 처리 후 남은 데이터가 있으면 재귀 호출</strong></li>
</ol>
<hr>
<h2 id="⚠️-주요-병목-지점">⚠️ 주요 병목 지점</h2>
<ol>
<li><strong>JobPosting 전체 저장</strong> → 대량의 <code>INSERT</code> 쿼리 발생</li>
<li><strong>Job_Skill 테이블에서 jobCode 조회</strong> → N번의 <code>SELECT</code> 쿼리 발생</li>
<li><strong>JobPostingJobSkillList에 추가 후 저장</strong> → 더티 체킹으로 인해 추가적인 <code>UPDATE</code> 쿼리 발생</li>
</ol>
<hr>
<h2 id="🔍-원인-분석">🔍 원인 분석</h2>
<ul>
<li><strong>쿼리 호출 횟수가 많음</strong><ul>
<li><code>JobPosting</code> 저장 시 1회 <code>INSERT</code></li>
<li><code>JobCode</code> 조회 시 최소 1회, 최대 <code>jobCodeArray.length</code> 만큼 추가 <code>SELECT</code></li>
<li><code>더티 체킹</code>으로 인한 추가 <code>UPDATE</code> 발생</li>
</ul>
</li>
<li><strong>예상 쿼리 호출량 (1,100개 기준)</strong><ul>
<li>최소 <strong>3,300번</strong>, jobCode가 5개씩 있는 경우 <strong>7,700번</strong> 발생 가능</li>
</ul>
</li>
</ul>
<hr>
<h2 id="🚀-성능-개선-방향">🚀 성능 개선 방향</h2>
<ul>
<li>✅ 전체 저장시 JobSkill까지 초기화 후 저장 (더티체킹 발생하지 않게 수정)</li>
<li>✅ <code>Redis</code>에 <code>JobSkill</code>을 저장해서 캐싱 처리</li>
</ul>
<p>개선 전 -&gt; 1,100개 - 87초<br>
전체 저장을 마지막으로 보냈을 때 -&gt; 1,100개 - 80초 / 7초 개선<br>
개선 후 -&gt; 1,100개 -&gt; 11초 / 개선 전보다 76초 개선 <br></p>
<h2 id="결과">결과</h2>
<p>개선 후 ** 87% ** 성능 개선이 되었다.</p>
<h3 id="기존-코드">기존 코드</h3>
<ul>
<li>기존 코드 호출 결과 (87초)
<img src="https://velog.velcdn.com/images/kdo_1999/post/93d02c36-4405-424f-bbb2-6de6de375e7a/image.png" alt="기존 코드"></li>
</ul>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {

    private final JobSkillRepository jobSkillRepository;
    private final JobPostingRepository jobPostingRepository;
    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;
    private final RetryTemplate retryTemplate;


    // URI로 조합할 OPEN API URL
    private final String API_URL = &quot;https://oapi.saramin.co.kr/job-search&quot;;

    // URI로 조합할 apiKey
    @Value(&quot;${api.key}&quot;)
    private String apiKey;

    // URI로 조합할 한 페이지당 가져올데이터 수
    @Value(&quot;${api.count}&quot;)
    private Integer count;

    /**
     * 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
     * &lt;p&gt;
     * - retryTemplate.execute(context -&gt; { ... }) -&gt; API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
     * 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -&gt; API에서 채용 공고 데이터를 가져와
     * 데이터베이스에 저장하는 핵심 로직을 실행합니다.
     */
    @Scheduled(cron = &quot;0 0 0 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
    @Transactional
    public void savePublicData() {
        retryTemplate.execute(context -&gt; {
            int pageNumber = 0;
            int totalCount = 0;
               int totalJobs = 1100; //1. 1,100개 기준 성능 측정

            LocalDateTime start = LocalDateTime.now();
            processJobPostings(totalCount, totalJobs, pageNumber);
            LocalDateTime end = LocalDateTime.now();

            // 시간 차이 계산
            Duration duration = Duration.between(start, end);

            // 결과값 출력
            log.info(&quot;작업 실행 시간: {} 밀리초&quot;, duration.toMillis());
            log.info(&quot;작업 실행 시간: {} 초&quot;, duration.getSeconds());

            return null;
        });
    }

    /**
     * - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List&lt;JobPosting&gt;,
     * List&lt;JobSkill&gt; 로 변환하여, 저장하도록 하는 메서드이다.
     * - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된 공고수(totalCount) 크면 callBack 함수가 멈춘다.
     *
     * @param totalCount 현재 저장된 공고수
     * @param totalJobs  오늘 총 공고 수
     * @param pageNumber 현재 페이지 번호
     */
    public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
        Jobs jobs = fetchJobPostings(pageNumber, count);

        // JobPosting 클래스로 담기
        List&lt;JobPosting&gt; jobPostingList = jobs.getJobsDetail().getJobList().stream()
            .map(Job::toEntity)
            .toList();

        // 전체 저장
        List&lt;JobPosting&gt; savedJobPostingList = saveNewJobs(jobPostingList);

        //JSON 응답 파싱
        List&lt;Job&gt; jobList = jobs.getJobsDetail().getJobList();
        Map&lt;Long, Job&gt; jobMap = jobList.stream()
            .collect(Collectors.toMap(job -&gt; Long.parseLong(job.getId()), job -&gt; job));

        for (JobPosting jobPosting : savedJobPostingList) {

            //채용 공고랑 jobPosting이랑 일치하는 애 찾는 if문
            // 한 페이지에 해당하는 110개의 데이터를 방금 저장한 공고들인 jobPosting과 비교하여, 손수 job-code의 code를 꺼내기 위한 작업.
            Job findJob = jobMap.get(jobPosting.getJobId());
            String jobCode = findJob.getPositionDto().getJobCode().getCode();

            //여러개면 , 기준으로 짜르기
            String[] jobCodeArray = jobCode.split(&quot;,&quot;);

            for (String s : jobCodeArray) {
                // db에 저장된 jobSkill, code로 조회
                Optional&lt;JobSkill&gt; jobSkillOptional = jobSkillRepository.findByCode(
                    Integer.parseInt(s.trim()));

                //jobSkill DB에 없다면
                if (jobSkillOptional.isEmpty()) {
                    continue;
                } else {
                    JobSkill jobSkill = jobSkillOptional.get();

                    //JobPosting에 jobskill 설정
                    //더티 체킹으로 인해 업데이트 쿼리 자동 발생
                    jobPosting.getJobPostingJobSkillList().add(
                        JobPostingJobSkill.builder()
                            .jobPosting(jobPosting)
                            .jobSkill(jobSkill)
                            .build());
                }
            }
        }

        //총 가져와야되는 개수 초기화
        if (totalJobs == Integer.MAX_VALUE) {
            totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
        }

        totalCount += jobPostingList.size();

        if (totalCount &lt; totalJobs) {
            processJobPostings(totalCount, totalJobs, ++pageNumber);
        }
    }

    /**
     * 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
     * &lt;p&gt;
     * - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
     * 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
     *
     * @param pageNumber 현재 페이지 번호
     * @param count      가져올 데이터 개수
     */
    private Jobs fetchJobPostings(int pageNumber, int count) {

        URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
            .queryParam(&quot;access-key&quot;, apiKey)
            //.queryParam(&quot;published&quot;, getPublishedDate())
            .queryParam(&quot;job_mid_cd&quot;, &quot;2&quot;)
            .queryParam(&quot;start&quot;, pageNumber) // 현재 페이지숫자
            .queryParam(&quot;count&quot;, count)
            .queryParam(&quot;fields&quot;, &quot;count&quot;)//한 번 호출시 가지고 오는 데이터 양
            .build()
            .encode()
            .toUri();

        try {
            String jsonResponse = restTemplate.getForObject(uri, String.class);

            Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);

            if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
                .isEmpty()) {
                log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
                throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
            }

            return dataResponse;

        } catch (JsonProcessingException e) {
            log.error(&quot;JSON 파싱 실패&quot;, e);
            throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
        }

    }

    /**
     * scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
     */
    private String getPublishedDate() {
        // 전날데이터
        LocalDate today = LocalDate.now().minusDays(1);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd&quot;);
        return today.format(formatter);
    }

    /**
     * JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
     *
     * @param newJobs 가공된 JobPosting 데이터 리스트
     */
    private List&lt;JobPosting&gt; saveNewJobs(List&lt;JobPosting&gt; newJobs) {
        try {
            List&lt;JobPosting&gt; savedJobPostingList = jobPostingRepository.saveAll(newJobs);
            log.info(&quot;총 {}개의 공고를 저장했습니다.&quot;, savedJobPostingList.size());
            return savedJobPostingList;
        } catch (Exception e) {
            log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
            throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
        }
    }


}</code></pre>
<h3 id="저장-메서드-위치-수정">저장 메서드 위치 수정</h3>
<ul>
<li>위치 수정 후 결과 (80초)
<img src="https://velog.velcdn.com/images/kdo_1999/post/b2c56033-5db3-49f8-bee6-260411ac0ea3/image.png" alt="저장 메서드 위치 변경 후"></li>
</ul>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {

    private final JobSkillRepository jobSkillRepository;
    private final JobPostingRepository jobPostingRepository;
    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;
    private final RetryTemplate retryTemplate;


    // URI로 조합할 OPEN API URL
    private final String API_URL = &quot;https://oapi.saramin.co.kr/job-search&quot;;

    // URI로 조합할 apiKey
    @Value(&quot;${api.key}&quot;)
    private String apiKey;

    // URI로 조합할 한 페이지당 가져올데이터 수
    @Value(&quot;${api.count}&quot;)
    private Integer count;

    /**
     * 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
     * &lt;p&gt;
     * - retryTemplate.execute(context -&gt; { ... }) -&gt; API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
     * 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -&gt; API에서 채용 공고 데이터를 가져와
     * 데이터베이스에 저장하는 핵심 로직을 실행합니다.
     */
    @Scheduled(cron = &quot;0 0 0 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
    @Transactional
    public void savePublicData() {
        retryTemplate.execute(context -&gt; {
            int pageNumber = 0;
            int totalCount = 0;
            int totalJobs = 1100; //1. 1100개 기준 성능 측정

            LocalDateTime start = LocalDateTime.now();
            processJobPostings(totalCount, totalJobs, pageNumber);
            LocalDateTime end = LocalDateTime.now();

            // 시간 차이 계산
            Duration duration = Duration.between(start, end);

            // 결과값 출력
            log.info(&quot;작업 실행 시간: {} 밀리초&quot;, duration.toMillis());
            log.info(&quot;작업 실행 시간: {} 초&quot;, duration.getSeconds());

            return null;
        });
    }

    /**
     * - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List&lt;JobPosting&gt;,
     * List&lt;JobSkill&gt; 로 변환하여, 저장하도록 하는 메서드이다.
     * - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된 공고수(totalCount) 크면 callBack 함수가 멈춘다.
     *
     * @param totalCount 현재 저장된 공고수
     * @param totalJobs  오늘 총 공고 수
     * @param pageNumber 현재 페이지 번호
     */
    public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
        Jobs jobs = fetchJobPostings(pageNumber, count);

        // JobPosting 클래스로 담기
        List&lt;JobPosting&gt; jobPostingList = jobs.getJobsDetail().getJobList().stream()
            .map(Job::toEntity)
            .toList();

        //JSON 응답 파싱
        List&lt;Job&gt; jobList = jobs.getJobsDetail().getJobList();
        Map&lt;Long, Job&gt; jobMap = jobList.stream()
            .collect(Collectors.toMap(job -&gt; Long.parseLong(job.getId()), job -&gt; job));

        for (JobPosting jobPosting : jobPostingList) {

            //채용 공고랑 jobPosting이랑 일치하는 애 찾는 if문
            // 한 페이지에 해당하는 110개의 데이터를 방금 저장한 공고들인 jobPosting과 비교하여, 손수 job-code의 code를 꺼내기 위한 작업.
            Job findJob = jobMap.get(jobPosting.getJobId());
            String jobCode = findJob.getPositionDto().getJobCode().getCode();

            //여러개면 , 기준으로 짜르기
            String[] jobCodeArray = jobCode.split(&quot;,&quot;);

            for (String s : jobCodeArray) {
                // db에 저장된 jobSkill, code로 조회
                Optional&lt;JobSkill&gt; jobSkillOptional = jobSkillRepository.findByCode(
                    Integer.parseInt(s.trim()));

                //jobSkill DB에 없다면
                if (jobSkillOptional.isEmpty()) {
                    continue;
                } else {
                    JobSkill jobSkill = jobSkillOptional.get();

                    //JobPosting에 jobskill 설정
                    jobPosting.getJobPostingJobSkillList().add(
                        JobPostingJobSkill.builder()
                            .jobPosting(jobPosting)
                            .jobSkill(jobSkill)
                            .build());
                }
            }
        }

        // 전체 저장 (위치 변경)
        List&lt;JobPosting&gt; savedJobPostingList = saveNewJobs(jobPostingList);

        //총 가져와야되는 개수 초기화
        if (totalJobs == Integer.MAX_VALUE) {
            totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
        }

        totalCount += jobPostingList.size();

        if (totalCount &lt; totalJobs) {
            processJobPostings(totalCount, totalJobs, ++pageNumber);
        }
    }

    /**
     * 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
     * &lt;p&gt;
     * - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
     * 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
     *
     * @param pageNumber 현재 페이지 번호
     * @param count      가져올 데이터 개수
     */
    private Jobs fetchJobPostings(int pageNumber, int count) {

        URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
            .queryParam(&quot;access-key&quot;, apiKey)
            // .queryParam(&quot;published&quot;, getPublishedDate())
            .queryParam(&quot;job_mid_cd&quot;, &quot;2&quot;)
            .queryParam(&quot;start&quot;, pageNumber) // 현재 페이지숫자
            .queryParam(&quot;count&quot;, count)
            .queryParam(&quot;fields&quot;, &quot;count&quot;)//한 번 호출시 가지고 오는 데이터 양
            .build()
            .encode()
            .toUri();

        try {
            String jsonResponse = restTemplate.getForObject(uri, String.class);

            Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);

            if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
                .isEmpty()) {
                log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
                throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
            }

            return dataResponse;

        } catch (JsonProcessingException e) {
            log.error(&quot;JSON 파싱 실패&quot;, e);
            throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
        }

    }

    /**
     * scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
     */
    private String getPublishedDate() {
        // 전날데이터
        LocalDate today = LocalDate.now().minusDays(1);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd&quot;);
        return today.format(formatter);
    }

    /**
     * JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
     *
     * @param newJobs 가공된 JobPosting 데이터 리스트
     */
    private List&lt;JobPosting&gt; saveNewJobs(List&lt;JobPosting&gt; newJobs) {
        try {
            List&lt;JobPosting&gt; savedJobPostingList = jobPostingRepository.saveAll(newJobs);
            log.info(&quot;총 {}개의 공고를 저장했습니다.&quot;, savedJobPostingList.size());
            return savedJobPostingList;
        } catch (Exception e) {
            log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
            throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
        }
    }
}</code></pre>
<h3 id="redis-캐시-도입">Redis 캐시 도입</h3>
<ul>
<li>Redis 캐시 도입 후 결과 (11초)
<img src="https://velog.velcdn.com/images/kdo_1999/post/3df1da12-5e73-4626-b90a-460e55507dfe/image.png" alt="Redis 캐시 도입 후 결과"></li>
</ul>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {

    private final JobSkillRepository jobSkillRepository;
    private final JobPostingRepository jobPostingRepository;
    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;
    private final RetryTemplate retryTemplate;
    private final RedisRepository redisRepository;


    // URI로 조합할 OPEN API URL
    private final String API_URL = &quot;https://oapi.saramin.co.kr/job-search&quot;;

    // URI로 조합할 apiKey
    @Value(&quot;${api.key}&quot;)
    private String apiKey;

    // URI로 조합할 한 페이지당 가져올데이터 수
    @Value(&quot;${api.count}&quot;)
    private Integer count;

    /**
     * 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
     * &lt;p&gt;
     * - retryTemplate.execute(context -&gt; { ... }) -&gt; API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
     * 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -&gt; API에서 채용 공고 데이터를 가져와
     * 데이터베이스에 저장하는 핵심 로직을 실행합니다.
     */
    @Scheduled(cron = &quot;0 0 0 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
    @Transactional
    public void savePublicData() {
        retryTemplate.execute(context -&gt; {
            int pageNumber = 0;
            int totalCount = 0;
//            int totalJobs = Integer.MAX_VALUE;
            int totalJobs = 1000; //1. 1000개 기준 성능 측정
//            int totalJobs = 10000; //2. 10000개 기준 성능 측정

            LocalDateTime start = LocalDateTime.now();
            processJobPostings(totalCount, totalJobs, pageNumber);
            LocalDateTime end = LocalDateTime.now();

            // 시간 차이 계산
            Duration duration = Duration.between(start, end);

            // 결과값 출력
            log.info(&quot;작업 실행 시간: {} 밀리초&quot;, duration.toMillis());
            log.info(&quot;작업 실행 시간: {} 초&quot;, duration.getSeconds());

            return null;

        });
    }

    /**
     * - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List&lt;JobPosting&gt;,
     * List&lt;JobSkill&gt; 로 변환하여, 저장하도록 하는 메서드이다. - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된
     * 공고수(totalCount) 크면 callBack 함수가 멈춘다.
     *
     * @param totalCount 현재 저장된 공고수
     * @param totalJobs  오늘 총 공고 수
     * @param pageNumber 현재 페이지 번호
     */
    public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
        Jobs jobs = fetchJobPostings(pageNumber, count);

        // JobPosting 클래스로 담기
        List&lt;JobPosting&gt; jobPostingList = jobs.getJobsDetail().getJobList().stream()
            .map(Job::toEntity)
            .toList();

        //JSON 응답 파싱
        List&lt;Job&gt; jobList = jobs.getJobsDetail().getJobList();
        Map&lt;Long, Job&gt; jobMap = jobList.stream()
            .collect(Collectors.toMap(job -&gt; Long.parseLong(job.getId()), job -&gt; job));

        for (JobPosting jobPosting : jobPostingList) {
            // JobId로 분류된 JobMap에서 Job 꺼내기
            Job findJob = jobMap.get(jobPosting.getJobId());

            //꺼내온 Job 안에 JobCode 꺼내기
            String jobCode = findJob.getPositionDto().getJobCode().getCode();

            //여러개면 , 기준으로 짜르기
            String[] jobCodeArray = jobCode.split(&quot;,&quot;);

            for (String s : jobCodeArray) {
                String key = JobSkillConstant.JOB_SKILL_REDIS_KEY.getKey() + s;

                //Redis에서 KEY값이 있는지 없는지 조회
                //exists
                boolean hasKeyResult = redisRepository.hasKey(key);

                //만약 있다면 Redis에서 VALUE 조회해서 jobSkill 객체 생성
                if (hasKeyResult) {
                    //JobSkillId 가져오는 로직
                    Long jobSkillId = Long.valueOf(redisRepository.get(key).toString());

                    //JobSkill 생성
                    JobSkill jobSkill = JobSkill.builder()
                        .id(jobSkillId)
                        .build();

                    jobPosting.getJobPostingJobSkillList().add(
                        JobPostingJobSkill.builder()
                            .jobPosting(jobPosting)
                            .jobSkill(jobSkill)
                            .build());
                }
            }
        }

        // 전체 저장
        List&lt;JobPosting&gt; savedJobPostingList = saveNewJobs(jobPostingList);

        //총 가져와야되는 개수 초기화
        if (totalJobs == Integer.MAX_VALUE) {
            totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
        }

        totalCount += savedJobPostingList.size();

        if (totalCount &lt; totalJobs) {
            processJobPostings(totalCount, totalJobs, ++pageNumber);
        }
    }

    /**
     * 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
     * &lt;p&gt;
     * - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
     * 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
     *
     * @param pageNumber 현재 페이지 번호
     * @param count      가져올 데이터 개수
     */
    private Jobs fetchJobPostings(int pageNumber, int count) {

        URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
            .queryParam(&quot;access-key&quot;, apiKey)
            // .queryParam(&quot;published&quot;, getPublishedDate())
            .queryParam(&quot;job_mid_cd&quot;, &quot;2&quot;)
            .queryParam(&quot;start&quot;, pageNumber) // 현재 페이지숫자
            .queryParam(&quot;count&quot;, count)
            .queryParam(&quot;fields&quot;, &quot;count&quot;)//한 번 호출시 가지고 오는 데이터 양
            .build()
            .encode()
            .toUri();

        try {
            String jsonResponse = restTemplate.getForObject(uri, String.class);

            Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);

            if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
                .isEmpty()) {
                log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
                throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
            }

            return dataResponse;

        } catch (JsonProcessingException e) {
            log.error(&quot;JSON 파싱 실패&quot;, e);
            throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
        }

    }

    /**
     * scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
     */
    private String getPublishedDate() {
        // 전날데이터
        LocalDate today = LocalDate.now().minusDays(1);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd&quot;);
        return today.format(formatter);
    }

    /**
     * JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
     *
     * @param newJobs 가공된 JobPosting 데이터 리스트
     */
    private List&lt;JobPosting&gt; saveNewJobs(List&lt;JobPosting&gt; newJobs) {
        try {
            List&lt;JobPosting&gt; savedJobPostingList = jobPostingRepository.saveAll(newJobs);
            log.info(&quot;총 {}개의 공고를 저장했습니다.&quot;, savedJobPostingList.size());
            return savedJobPostingList;
        } catch (Exception e) {
            log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
            throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[6~7주차 회고록]]></title>
            <link>https://velog.io/@kdo_1999/67%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@kdo_1999/67%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Sun, 05 Jan 2025 11:06:48 GMT</pubDate>
            <description><![CDATA[<h3 id="12월-23일--01월-05일">12월 23일 ~ 01월 05일</h3>
<p>이번에는 중간에 12월 25일부터 1월 1일까지 쉬어서 같이 작성했다.
23~24일은 RestApi방식으로 변경을 마무리하고 jacoco 적용했고 JWT와 시큐리티 적용해서 로그인, 회원가입 로직을 마무리 했고 25일부터 쉬는거였지만 시간이 모자랄 거 같아 리액트 공부를 좀 하고서 기존에 변경한 백엔드 로직이랑 연동을 시작했다.</p>
<h4 id="sbb2">Sbb2</h4>
<blockquote>
<p><strong>[SBB레포] (<a href="https://github.com/kdo1999/sbb2">https://github.com/kdo1999/sbb2</a>)</strong>
<strong>[SBB Front] (<a href="https://github.com/kdo1999/sbb2-front">https://github.com/kdo1999/sbb2-front</a>)</strong>
<strong>[점프 투 스프링부트] (<a href="https://wikidocs.net/book/7601">https://wikidocs.net/book/7601</a>)</strong></p>
</blockquote>
<p>기능 개선을 위한 리팩토링과 RestApi방식으로 변환을 다 끝내고서 계속 리액트에 집중했었던 기간이었다.</p>
<p>타입스크립트도 사용해보고 싶었으나 마무리 해야되는 시간을 생각하면 너무 촉박할 거 같아서 타입 스크립트는 다음 번에 사용하기로 결정하고 리액트, Next.js로 구현을 시작했다.</p>
<p>좀 처음에는 욕심이 많았는데 생각을 해보니 내 목표는 일단 프론트 개발자는 아니기 때문에 까막눈을 벗어나는 정도로만 마음을 먹었다.</p>
<p>이래저래 컴포넌트 분리해서 깔끔하게하면 더 좋겠지만 아무래도 백엔드에 비중을 더 두는게 좋겠다고 판단했고 깃허브 코파일럿을 활용해서 점차 구현해갔다.</p>
<p>잘 모르는 부분은 거의 코파일럿을 활용했고 한 페이지가 구현이 끝나면 그 페이지에 잘 몰랐던거나 의문이 드는거는 따로 구글에 서칭하면서 조금씩 익혀나가는 방식으로 했었는데 확실히 학습하는 측면에서 도움이 많이 됐던 거 같다.</p>
<p>물론 코파일럿이 그렇게 똑똑하게 코드를 짜준다는 느낌이 있지는 않았기에 너무 믿기보다는 의문을 가지면서 진행했던 부분이 많았다.</p>
<p>어쨋든 프론트는 질문, 답변, 추천 CRUD, 회원가입, 로그인까지는 완성을 시킨 상태고 다음 주 부터는 추가 기능 구현에 들어갈 예정이다.</p>
<p>원래 1월 1일까지 완료했어야 됐는데 10일까지로 미뤄주신 덕분에 조금의 여유는 생겼는데 그래도 추가 기능을 1개 만들 때 프론트도 구현을 해줘야되니 또 긴 시간은 아니라고 느껴져서 못해도 백엔드, 프론트 1개 기능은 최소로 구현하고 가능하다면 2개 이상은 구현하도록 노력할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[5주차 회고록]]></title>
            <link>https://velog.io/@kdo_1999/5%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@kdo_1999/5%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Sat, 21 Dec 2024 15:32:06 GMT</pubDate>
            <description><![CDATA[<h3 id="12월-16일--12월-20일">12월 16일 ~ 12월 20일</h3>
<p>이번 주는 다른건 신경쓸 여유가 없었고 오로지 Sbb를 다시 구현하는데만 집중했었다.
성능 부분에서도 이전과 비교했을 때 많이 개선됐고 테스트 커버리지 또한 의도하진 않았지만 높은 결과를 만들어 냈었다.</p>
<p>지금까지 데브코스 진행하면서 체득했던게 많았던 주가 아니었나 생각이 든다.</p>
<h4 id="sbb2">Sbb2</h4>
<blockquote>
<p><strong>[SBB레포] (<a href="https://github.com/kdo1999/sbb2">https://github.com/kdo1999/sbb2</a>)</strong>
<strong>[점프 투 스프링부트] (<a href="https://wikidocs.net/book/7601">https://wikidocs.net/book/7601</a>)</strong></p>
</blockquote>
<p>지난 주에 진행했던 SBB의 기능개선을 진행 후 추가 기능 구현을 할 계획을 잡았었고 주말간 그에 맞춰서 진행을 했었으나 문제가 발생했었다.</p>
<p>이미 로직이 다 구현된 상태에서 뭐 하나 고치려고하면 모든걸 뜯어고쳐야되는 문제가 발생했다.
또한 책으로 기능을 다 구현해둔 상태에서 리팩토링을 할라하니 이만저만 보통일이 아니기도 했었고 실제 서비스중인 상태라면 당연히 현재보다 로직이 훨씬 많고 복잡하기에 다시 만드는 것보다는 수정을 하는게 좋을거고 또한 설계를 잘 해뒀다면 리팩토링하는데 여러곳에서 문제가 발생하지는 않을테니 리팩토링하는게 맞는 방안이지만 현재는 로직이 그렇게 복잡하지는 않기에 다시 만드는건 크게 어려움이 없을거라고 판단해서 그냥 다 날리고 책을 보지 않고 구현을 시작했다.</p>
<p>TDD로 개발을 진행했고 생각보다 적응이 좀 됐는지 예전에 사이드 프로젝트 할 때 보다는 좀 더 기능 구현 속도도 빨라진 거 같고 좀 더 꼼꼼하게 로직을 구현할 수 있다는 걸 조금은 깨달은 거 같다.</p>
<p>또한 쿼리가 많이나갔던 기존의 Sbb의 문제점을 많이 개선시켰고 querydsl을 사용하여 조회 로직의 성능을 많이 개선시켰다.</p>
<p>기존에는 조회시 최소 5개씩 나가던 쿼리를 이제는 최소 1개에서 많으면 2개로 줄였다.
만약 100명의 유저가 동시에 조회를 한다면 500개의 쿼리가 나가던걸 100~200개로 줄였다는거고 더 많은 유저가 있다면 그 차이는 더 많이 날 것이다.</p>
<p>일주일간 다시 구현하느라고 좀 힘들기도 했고 오랜만에 다시 querydsl을 사용해서 버벅이던 시간도 많았지만 결과적으로는 얻어간게 더 많았더 시간이지 않았나 싶다.</p>
<p>Querydsl, TDD의 장점, fetch join, 페이징 등등 sbb2를 만들면서 사용했던 모든 것들을 조금씩 찾아보면서 했다보니 그만큼 체득도 많이 된 거 같고 물론 사이드 프로젝트를 하면서도 체득을 어느정도 했지만 이거 하면서 고민도 제일 많이했었고 여러 방안들을 떠올려보며 비교해보고 이랬던 시간이 많았어서 많은걸 얻었던 한 주 였다.</p>
<h4 id="테스트-커버리지">테스트 커버리지</h4>
<p>테스트 커버리지는 예전에 찾아봤었는데 지금까지 이걸 신경쓰면서 프로젝트, 학습을 진행한 적이 한 번도 없었다.</p>
<p>심지어 사이드 프로젝트는 결과치를 내보지는 않았지만 대략적으로 50%도 안 됐던 거 같다.
사실 이번에 TDD를 하면서 테스트 커버리지를 신경쓰지는 않았는데 오늘 로직 작성하고서 궁금해서 결과를 뽑아봤는데 생각보다 훨씬 높은 결과가 나와서 뿌듯했었다.</p>
<p>하면서 깨달은건 TDD로 구현을 하니까 다양한 시나리오를 작성해서 테스트를 만들다보니 자연스럽게 커버리지가 올라갔던거 같고 또 테스트 커버리지가 높다는건 코드가 원하지 않는 동작을 하는지 안 하는지 검증을 해봤다는 의미로도 볼 수 있을 거 같다.</p>
<p>브랜치 커버리지는 이번에 처음 알았는데 찾아보니 조건문을 검증한 수치를 나타내는거였다.
진행하면서 예외 발생하는 부분을 given으로 throw를 통해 예외를 터트리는 방식으로 작성했는데 실질적으로 조건문 안에서 예외가 터지게 한다는건 전혀 생각하지 못했던 부분이라서 이 부분도 시간될 때 계속 개선해 나갈 예정이다.</p>
<p>그리고 테스트 커버리지를 관련해서 좀 더 찾아보고 정리해서 게시글을 하나 작성해볼 예정이다.</p>
<p><img src="https://velog.velcdn.com/images/kdo_1999/post/d21e5e45-0fe8-41d9-b49f-b54615db18db/image.png" alt="테스트 커버리지 결과"></p>
]]></description>
        </item>
    </channel>
</rss>