<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>코딩몽키졸업</title>
    <link>https://steadycode.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 18 Aug 2026 11:26:34 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>헝그리개발자</managingEditor>
    <item>
      <title>docker를 이용한 mysql Replication 서버 구축</title>
      <link>https://steadycode.tistory.com/92</link>
      <description>&lt;h1&gt;DB Replication이란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 서버를 복제하는 기술이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 애플리케이션은 읽기 작업 비중이 쓰기 작업 보다 월등히 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB Replication을 사용하면 쓰기 전용 소스 서버를 하나 두고 읽기 전용 레플리카 서버를 여러대 두어서 서버의 부하를 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 쓰기 전용 DB 서버를 master 서버라고 부르고 읽기 전용 서버를 slave 서버라고 부른다.&lt;/p&gt;
&lt;h1&gt;Replication Flow&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;repl.png&quot; data-origin-width=&quot;628&quot; data-origin-height=&quot;348&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEms5m/btr6sr6bM1G/OqCnXrDt9gbHCarGkUaKO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEms5m/btr6sr6bM1G/OqCnXrDt9gbHCarGkUaKO1/img.png&quot; data-alt=&quot;출처:&amp;amp;nbsp;https://jongmin92.github.io/2019/11/13/Database/mysql-replication/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEms5m/btr6sr6bM1G/OqCnXrDt9gbHCarGkUaKO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEms5m%2Fbtr6sr6bM1G%2FOqCnXrDt9gbHCarGkUaKO1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;628&quot; height=&quot;348&quot; data-filename=&quot;repl.png&quot; data-origin-width=&quot;628&quot; data-origin-height=&quot;348&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처:&amp;nbsp;https://jongmin92.github.io/2019/11/13/Database/mysql-replication/&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Master 서버에서 변경 사항을 Binary Log에 기록한다.&lt;/li&gt;
&lt;li&gt;Slave 서버에 변경이 발생함을 통지한다.&lt;/li&gt;
&lt;li&gt;Slave 서버는 I/O Thread를 이용해서 Binary Log를 읽는다.&lt;/li&gt;
&lt;li&gt;변경 사항을 Relay Log에 기록한다.&lt;/li&gt;
&lt;li&gt;SQL Thread를 이용해서 변경 사항을 반영한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Master Config 파일 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mysql 서버를 실행하기 전에 설정파일이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.cnf 파일은 MySQL 서버가 실행될 때 읽는 설정 파일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;default path는 /etc/my.cnf이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각의 경로에 .cnf 확장자로 설정 파일을 생성해주자&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# ~/Docker/mysql/master/config_file.cnf

[mysqld]
log-bin=mysql-bin
server-id = 1
binlog_cache_size = 5M
max_binlog_size = 512M
expire_logs_days = 30&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mysql은 쓰기 작업시 /var/lib/mysql/ 경로에 바이너리 로그 파일을 남긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;log-bin : 바이너리 로그 파일명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;server-id : Replication Group에서 서버를 식별하기 위한 id 값&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;binlog_cache_size : 바이너리 로그를 메모리에 버퍼링했다가 디스크로 기록된다. 해당 버퍼링용 메모리의 크기를 지정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;max_binlog_size : 바이너리 로그 파일 최대 크기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;expire_logs_days : 바이너리 로그를 보관할 날짜&lt;/p&gt;
&lt;h1&gt;Slave Config 파일 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Slave 서버는 Master 서버의 바이너리 로그를 읽어서 변경 내역을 Relay log에 기록하고 재실행하여 Master 서버와 동기화한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# ~/Docker/mysql/slave/config_file.cnf

[mysqld]
server-id = 2
relay_log = relay_log
relay_log_purge = TRUE
read_only&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;relay_log : CHANGE MASTER 명령을 실행하면 레플리카 서버는 기본 경로에 relay log를 생성한다. 이때 path를 지정하는 옵션&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;relay_log_purge : TRUE나 1로 설정하면 필요하지 않은 오래된 릴레이 로그를 자동으로 삭제한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;read_only : 읽기 전용으로 설정하는 변수&lt;/p&gt;
&lt;h1&gt;Docker란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커는 프로그램을 컨테이너라는 독립된 환경으로 실행하고 관리할 수 있는 오픈소스 기술이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커를 사용하면 쉽게 master/slave mysql container를 관리할 수 있다.&lt;/p&gt;
&lt;h1&gt;도커 버전 확인&lt;/h1&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;$ docker -V&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;도커 MySQL 이미지 다운로드&lt;/h1&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;$ docker pull mysql

$ docker pull mysql:8.0.22&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;도커 MySQL 이미지 컨테이너 생성 및 실행&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 명령어로 master/slave 컨테이너를 실행해준다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;docker run --name mysql-master -v ~/Docker/mysql/master:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD={password} -d mysql:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;docker run --name mysql-slave -v ~/Docker/mysql/slave:/etc/mysql/conf.d --link mysql-master -e MYSQL_ROOT_PASSWORD={password} -d mysql:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;name : 컨테이너명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-e : 환경 변수 수정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-d : detached 모드로 실행, 백그라운드 모드로 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-p : 포트포워딩 {hostPort}:{containerPort}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-v : {Volume명}:{container 내부 mount될 경로}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;link : container끼리 ip 주소가 아닌 host명으로 접근하기 위해서 사용, slave 서버는 master 서버와 container간 통신을 하기 위해 사용&lt;/p&gt;
&lt;h1&gt;실행중인 container 확인&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;master, slave 두 컨테이너가 띄워지면 성공&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;&amp;gt; docker ps
CONTAINER ID   IMAGE                 COMMAND                   CREATED          STATUS          PORTS                               NAMES
6f4d80001a14   mysql:latest          &quot;docker-entrypoint.s&amp;hellip;&quot;   34 minutes ago   Up 34 minutes   3306/tcp, 33060/tcp                 mysql-slave
ea5e5a9dd5cb   mysql:latest          &quot;docker-entrypoint.s&amp;hellip;&quot;   34 minutes ago   Up 34 minutes   3306/tcp, 33060/tcp                 mysql-master
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;master 서버 접속&lt;/h1&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;docker exec -it mysql-master bash

mysql -u root -p
Enter password:

mysql&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;master 서버 상태 확인&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 바이너리 로그를 확인하는 명령어이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 mysql-bin.000003 라는 이름의 바이너리 로그에 기록하고 있으며 현재까지 기록된 위치는 2179라는 것을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Position은 실제 바이트 수를 의미한다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;mysql&amp;gt; show master status;
+------------------+----------+--------------+------------------+-------------------+
| File             | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000003 |     2179 |              |                  |                   |
+------------------+----------+--------------+------------------+-------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Replication용 유저 생성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slave 서버가 master 서버로부터 바이너리 로그를 읽어오기 위해선 해당 권한을 가진 유저를 생성해야한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;mysql&amp;gt; CREATE USER 'replication_user'@'%' IDENTIFIED WITH mysql_native_password BY 'replication_pass';

mysql&amp;gt; GRANT REPLICATION SLAVE ON *.* TO 'replication_user'@'%';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IDENTIFIED WITH mysql_native_password BY :&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저 인증 방식을 mysql_native_password로 지정한다. mysql 8 버전 default 유저 인증 방식은 caching_sha2_password인데 패스워드를 sha2 방식으로 암호화하지 않으면 에러가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REPLICATION SLAVE : Replication용 유저는 해당 권한을 가져야 한다.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;mysql&amp;gt; use mysql;

mysql&amp;gt; select user, host, plugin from user;
+------------------+-----------+-----------------------+
| user             | host      | plugin                |
+------------------+-----------+-----------------------+
| replication_user | %         | mysql_native_password |
| root             | %         | caching_sha2_password |
| mysql.infoschema | localhost | caching_sha2_password |
| mysql.session    | localhost | caching_sha2_password |
| mysql.sys        | localhost | caching_sha2_password |
| root             | localhost | caching_sha2_password |
+------------------+-----------+-----------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Sample 데이터 입력&lt;/h1&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE DATABASE practice;

CREATE TABLE IF NOT EXISTS `practice`.`replication`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT,
    `name`     varchar(255) DEFAULT NULL,
    PRIMARY KEY (`id`)
) ENGINE = InnoDB;

USE practice;

INSERT INTO replication(name) VALUES('sixhustle');
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;Master 서버 백업&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master 서버의 현재까지 변경사항을 백업하고 이를 .sql 파일로 dump한다.&lt;/p&gt;
&lt;pre class=&quot;brainfuck&quot;&gt;&lt;code&gt;bash-4.4# mysqldump -u root -p --opt --single-transaction --hex-blob --master-data=2 --routines --triggers --all-databases &amp;gt; master-dump.sql;
Enter password:
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-u : 계정명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-p : 패스워드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;opt : 사람들이 가장 많이 사용하는 옵션을 모아 놓은 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;single-transaction : innodb 엔진을 사용할 경우 lock 없이 일관된 데이터 덤프를 받으려면 추가해줘야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;--hex-blob : BINARY, VARBINARY, BLOB, BIT 컬럼에 대해서 값을 명시할 때 hexadecimal 형태로 기술하게 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;--master-data : dump 파일 안에 어느 바이너리 로그 위치까지 사용할 것인지 CHANGE MASTER 절로 자동으로 작성해준다. 2는 주석형태로 작성한다는 의미&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;routines : stored routines 정보를 모두 포함시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;triggers : triggers 정보를 모두 포함시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;mdash;all-databases : 데이터베이스 전부를 dump&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덤프 파일을 host 서버에 copy 한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;docker cp mysql-master:/master-dump.sql .
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Master &amp;amp; Slave 서버 동기화&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카피된 덤프 파일을 slave container로 copy한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;docker cp master-dump.sql mysql-slave:/.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slave 컨테이너 shell 창에 접속한 뒤, 덤프파일을 import하여 DB 변경 내용을 동기화한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;bash-4.4# mysql -u root -p &amp;lt; master-dump.sql
Enter password:
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;Replication 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dump 파일을 생성할 때 --master-data 옵션으로 생성된 CHANGE MASTER 절을 복사한다.(주석으로 되어있다)&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;vi master-dump.sql

-- CHANGE MASTER TO MASTER_LOG_FILE='mysql-bin.000003', MASTER_LOG_POS=2179;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Slave 서버에 접속한 뒤, 복사한 CHANGE MASTER 절을 실행한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;mysql&amp;gt; CHANGE MASTER TO 
MASTER_HOST='mysql-master',
MASTER_USER='replication_user',
MASTER_PASSWORD='replication_pass',
MASTER_LOG_FILE='mysql-bin.000003',
MASTER_LOG_POS=2179;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slave thread를 시작한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;mysql&amp;gt; START SLAVE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slave 상태를 출력하여 정상적으로 연동되었는지 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Last_Errno, Last_Error, Last_IO_Errno, Last_IO_Error 가 비어있다면 정상 연동된 것이다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;mysql&amp;gt; SHOW SLAVE STATUS\\G;
*************************** 1. row ***************************
               Slave_IO_State: Waiting for source to send event
                  Master_Host: mysql-master
                  Master_User: replication_user
                  Master_Port: 3306
                Connect_Retry: 60
              Master_Log_File: mysql-bin.000003
          Read_Master_Log_Pos: 2179
               Relay_Log_File: relay_log.000002
                Relay_Log_Pos: 636
        Relay_Master_Log_File: mysql-bin.000003
             Slave_IO_Running: Yes
            Slave_SQL_Running: Yes
              Replicate_Do_DB:
          Replicate_Ignore_DB:
           Replicate_Do_Table:
       Replicate_Ignore_Table:
      Replicate_Wild_Do_Table:
  Replicate_Wild_Ignore_Table:
                   Last_Errno: 0
                   Last_Error:
                 Skip_Counter: 0
          Exec_Master_Log_Pos: 2179
              Relay_Log_Space: 840
              Until_Condition: None
               Until_Log_File:
                Until_Log_Pos: 0
           Master_SSL_Allowed: No
           Master_SSL_CA_File:
           Master_SSL_CA_Path:
              Master_SSL_Cert:
            Master_SSL_Cipher:
               Master_SSL_Key:
        Seconds_Behind_Master: 0
Master_SSL_Verify_Server_Cert: No
                Last_IO_Errno: 0
                Last_IO_Error:
               Last_SQL_Errno: 0
               Last_SQL_Error:
  Replicate_Ignore_Server_Ids:
             Master_Server_Id: 1
                  Master_UUID: a0e6d99b-cd31-11ed-b4a4-0242ac110003
             Master_Info_File: mysql.slave_master_info
                    SQL_Delay: 0
          SQL_Remaining_Delay: NULL
      Slave_SQL_Running_State: Replica has read all relay log; waiting for more updates
           Master_Retry_Count: 86400
                  Master_Bind:
      Last_IO_Error_Timestamp:
     Last_SQL_Error_Timestamp:
               Master_SSL_Crl:
           Master_SSL_Crlpath:
           Retrieved_Gtid_Set:
            Executed_Gtid_Set:
                Auto_Position: 0
         Replicate_Rewrite_DB:
                 Channel_Name:
           Master_TLS_Version:
       Master_public_key_path:
        Get_master_public_key: 0
            Network_Namespace:
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Docker를 이용해서 MySQL Replication 서버를 구축해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에선 애플리케이션단에서 DB Replication을 설정하는 방법에 대해서 다뤄보겠다.&lt;/p&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://poiemaweb.com/docker-mysql&quot;&gt;https://poiemaweb.com/docker-mysql&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jupiny.com/2017/11/07/docker-mysql-replicaiton/&quot;&gt;https://jupiny.com/2017/11/07/docker-mysql-replicaiton/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@sixhustle/mysql-replication&quot;&gt;https://velog.io/@sixhustle/mysql-replication&lt;/a&gt;&lt;/p&gt;</description>
      <category>Database</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/92</guid>
      <comments>https://steadycode.tistory.com/92#entry92comment</comments>
      <pubDate>Tue, 28 Mar 2023 18:08:03 +0900</pubDate>
    </item>
    <item>
      <title>Selenium 라이브러리로 웹 크롤러 제작하기</title>
      <link>https://steadycode.tistory.com/91</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Selenium 라이브러리를 이용해서 웹 크롤러를 제작한 과정을 공유해보려고 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;sel.png&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;845&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dB4uNu/btr3LIovGOj/5kVOKQNC6vlxbORWCncDr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dB4uNu/btr3LIovGOj/5kVOKQNC6vlxbORWCncDr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dB4uNu/btr3LIovGOj/5kVOKQNC6vlxbORWCncDr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdB4uNu%2Fbtr3LIovGOj%2F5kVOKQNC6vlxbORWCncDr1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1612&quot; height=&quot;845&quot; data-filename=&quot;sel.png&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;845&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;Selenium이란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 브라우저의 환경을 구현할 수 있는 오픈소스 라이브러리이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 브라우저 기반 End Test에 사용되지만 크롤링 같은 다양한 목적을 위해 사용할 수 있다.&lt;/p&gt;
&lt;h1&gt;Selenium을 사용한 이유?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롤러를 개발할 때 처음에는 jsoup 라이브러리를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 jsoup는 치명적인 단점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jsoup 라이브러리는 uri 접속시 최초 로딩되는 페이지만 읽어 들일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘의 웹 페이지는 SPA 방식으로 최초에 index 페이지를 로딩하고나서 유저의 동적인 action에 의해 동적으로 페이지를 랜더링하는 방식을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML, CSS, JavaScript를 사용하는 기본적인 PWA 페이지도 최초 웹 페이지 로딩 시간을 줄이기 위해 root 페이지를 경량화하고 유저의 action에 의해 추가적으로 페이지를 동적으로 로딩하는 방식을 채택하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 jsoup는 적합하지 않고 서버에서 브라우저 환경을 똑같이 구현할 수 있는 Selenium 라이브러리를 사용했다.&lt;/p&gt;
&lt;h1&gt;WebDriver&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selenium의 핵심 코어가되는 인터페이스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chrome/Chromium, Firefox, Internet Explorer, Edge, Safari 같은 다양한 공급사의 브라우저를 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebDriver를 통해 실제 사용자가 브라우저를 조작하는 것처럼 브라우저를 제어할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트를 입력하거나, 링크를 클릭하고, 마우스를 이동하는 등의 action을 제공한다.&lt;/p&gt;
&lt;h1&gt;Wait&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 웹 페이지는 동적으로 생성되기 때문에 완전히 로드되기까지 일정 시간이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selenium은 대기 기능을 지원하고 일정 대기 시간동안 element를 찾지 못하면 WebDriverException을 발생시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음의 세 가지 wait를 지원한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;implicit wait&lt;/li&gt;
&lt;li&gt;explicit wait&lt;/li&gt;
&lt;li&gt;fluent wait&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Implicit Wait(비추천)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암시적 대기는 모든 findElement() 함수에 대해서 최대 대기 시간을 지정하는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드의 중복을 줄일 수 있다는 장점이 있지만 개인적으로 추천하지 않는다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예시)&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  Implicit Wait을 추천하지 않는 이유&lt;br /&gt;Implicit Wait은 브라우저의 페이지를 자주 변경해야할 때 findElement()가 동작하기 전에 페이지가 변경되어버리는 경우가 생기기 때문에 StaleElementReferenceException 가 자주 발생하게 된다.&lt;br /&gt;따라서 필자의 경우 Explicit Wait을 사용하여 element가 완전히 로드될 때까지 기다리고 element를 읽은 뒤에 페이지가 정상적으로 이동되도록 구현했다.&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Explicit Wait(추천)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명시적 대기는 특정 element에 대해서만 최대 대기 시간을 지정하는 방법이다. Selenium에서 지원하는 조건들은 아래의 공식 문서에서 찾을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&quot;&gt;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1678774032170&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;ExpectedConditions&quot; data-og-description=&quot;An expectation for checking whether the given frame is available to switch to. If the frame is available it switches the given driver to the specified web element.&quot; data-og-host=&quot;www.selenium.dev&quot; data-og-source-url=&quot;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&quot; data-og-url=&quot;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.selenium.dev/selenium/docs/api/java/org/openqa/selenium/support/ui/ExpectedConditions.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ExpectedConditions&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;An expectation for checking whether the given frame is available to switch to. If the frame is available it switches the given driver to the specified web element.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.selenium.dev&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예시)&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;new WebDriverWait(driver, Duration.ofSeconds(3)).until(ExpectedConditions.elementToBeClickable(By.xpath(&quot;//a/h3&quot;)));
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  Explicit wait 와 Implicit wait 를 혼용해서 사용하면 안되는 이유&lt;br /&gt;두가지 유형을 동시에 사용하면 충돌이 발생할 수 있다. &lt;br /&gt;예를 들어 요소를 클릭할 수 있을 때까지 기다리는 명시적 대기와 암시적 대기가 동시에 사용된다면, 요소가 실제 클릭할 수 있게 되기 전에 암시적 대기에 의해 스크립트를 계속 실행할 수도 있다.&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Fluent wait&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유연한 대기는 Explicit Wait의 확장된 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 Explicit Wait 기능을 지원하고 추가적으로 polling 빈도 수를 지정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Implicit Wait와 Explicit Wait의 default polling period는 500ms이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;polling은 리얼 타임 웹을 구현하기 위해 서버와 클라이언트간에 송수신 주기를 조절하는 방식을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;polling 주기가 짧을수록 서버 부하가 커지고 실시간성이 높아진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;gr.png&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;1076&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDhBVw/btr3IDVBkMA/YWfH5elJL2lBQZ8sg6a8eK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDhBVw/btr3IDVBkMA/YWfH5elJL2lBQZ8sg6a8eK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDhBVw/btr3IDVBkMA/YWfH5elJL2lBQZ8sg6a8eK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDhBVw%2Fbtr3IDVBkMA%2FYWfH5elJL2lBQZ8sg6a8eK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;543&quot; height=&quot;425&quot; data-filename=&quot;gr.png&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;1076&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selenium에서 말하는 polling은 조건을 만족하는 element를 찾는 일종의 동기화하는 행위를 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pollingEvery() 함수는 polling 빈도수를 조절하는 함수이고 ignoring() 함수는 polling 중 무시할 exception을 정의할 때 사용한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Wait wait = new FluentWait(WebDriver reference)
.withTimeout(Duration.ofSeconds(SECONDS))
.pollingEvery(Duration.ofSeconds(SECONDS))
.ignoring(Exception.class);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  thread.sleep()를 사용을 지양해야 하는 이유&lt;br /&gt;Selenium의 wait을 사용하지 말고 그냥 java에서 지원하는 thread.sleep() 함수를 사용하면 되지 않냐고 생각할 수 있을 것이다.&lt;br /&gt;하지만 wait을 사용하면 thread.sleep()을 사용할 때보다 더 적은 대기 시간으로 로직을 구현할 수 있다.&lt;br /&gt;2초만에 로딩되는 element를 찾기 위해 thread.sleep()를 이용해서 5초 동안 대기한다고 가정해보자.&lt;br /&gt;결과적으로 그 차이인 3초만큼 실행 시간이 늘어나게 된다. &lt;br /&gt;Selenium의 wait은 최대 대기 시간 전에 조건에 해당하는 element를 찾으면 그 즉시 대기를 종료하기 때문에 더 효율적이다.&lt;/blockquote&gt;
&lt;h1&gt;Selenium 사용하기&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;의존성 추가&lt;/li&gt;
&lt;li&gt;드라이버 설치&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 의존성 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle 파일에 다음 행을 추가해주자&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'org.seleniumhq.selenium:selenium-java:4.8.1'
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 드라이버 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebDriver를 사용하기 위해선 사용할 브라우저에 해당하는 드라이버를 설치해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chrome을 사용할 경우 아래의 사이트에 접속해서 Driver를 다운로드 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chromedriver.chromium.org/downloads&quot;&gt;https://chromedriver.chromium.org/downloads&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1678774029915&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;ChromeDriver - WebDriver for Chrome - Downloads&quot; data-og-description=&quot;Current Releases If you are using Chrome version 111, please download ChromeDriver 111.0.5563.64 If you are using Chrome version 110, please download ChromeDriver 110.0.5481.77 If you are using Chrome version 109, please download ChromeDriver 109.0.5414.74&quot; data-og-host=&quot;chromedriver.chromium.org&quot; data-og-source-url=&quot;https://chromedriver.chromium.org/downloads&quot; data-og-url=&quot;https://chromedriver.chromium.org/downloads&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mc8S5/hyRVdNiQkl/XVOaE6cEKaIk8PEVKOkm50/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000&quot;&gt;&lt;a href=&quot;https://chromedriver.chromium.org/downloads&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chromedriver.chromium.org/downloads&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mc8S5/hyRVdNiQkl/XVOaE6cEKaIk8PEVKOkm50/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ChromeDriver - WebDriver for Chrome - Downloads&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Current Releases If you are using Chrome version 111, please download ChromeDriver 111.0.5563.64 If you are using Chrome version 110, please download ChromeDriver 110.0.5481.77 If you are using Chrome version 109, please download ChromeDriver 109.0.5414.74&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chromedriver.chromium.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치된 zip 파일의 압축을 풀고 /usr/local/bin 경로로 드라이버를 복붙한다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;&amp;gt; cp chromedriver /usr/local/bin
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 자바단에서 설치된 드라이버 경로를 명시해주면 된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;System.setProperty(&quot;webdriver.chrome.driver&quot;, &quot;/usr/local/bin/chromedriver&quot;);

WebDriver driver = new ChromeDriver();  // success!!
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;예제 코드&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제 코드는 '네이버'와 '카카오' 두 회사에서 채용 공고 데이터를 크롤링하는 과정을 다뤄보았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CrawlerController&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롤러가 최초로 호출되는 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Collection Injection을 통해 JobCrawler 인터페이스를 구현하는 모든 구현체들을 DI하도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Task Scheduler를 사용하여 일정한 시간마다 동작하도록 구현했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RestController
public class CrawlerController {

    private final List&amp;lt;JobCrawler&amp;gt; jobCrawlers;

    public CrawlerController(List&amp;lt;JobCrawler&amp;gt; jobCrawlers) {
        this.jobCrawlers = jobCrawlers;
    }

    @Scheduled(cron = &quot;0 0 0 1/1 * ? *&quot;)
    public void crawling() {
        for (JobCrawler jobCrawler : jobCrawlers) {
            jobCrawler.crawling();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cron expression은 cron trigger를 설정할 때 사용하는 표현식으로 아래의 포스팅을 참고하면 쉽게 이해할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://sjparkk-dev1og.tistory.com/32&quot;&gt;https://sjparkk-dev1og.tistory.com/32&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1678774068211&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Spring - Scheduler 설정 방법 및 사용방법&quot; data-og-description=&quot;Spring - Scheduler 설정 방법 및 사용방법 일정 시간마다 실행해줘야하는 기능이 있어서 찾아보니 스프링에서 제공해주는 스프링 스케줄러를 이용하기로 했다. 스프링 스케줄러는 일정한 시간간격 &quot; data-og-host=&quot;sjparkk-dev1og.tistory.com&quot; data-og-source-url=&quot;https://sjparkk-dev1og.tistory.com/32&quot; data-og-url=&quot;https://sjparkk-dev1og.tistory.com/32&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/OGVxx/hyRVfdh8Tb/az47JRCxE6hCCYaB4fZPy1/img.png?width=800&amp;amp;height=199&amp;amp;face=0_0_800_199,https://scrap.kakaocdn.net/dn/wQbGQ/hyRWshLHsC/osJ6JXtElcHsdcFj3LiB4k/img.png?width=800&amp;amp;height=199&amp;amp;face=0_0_800_199,https://scrap.kakaocdn.net/dn/cIOzeX/hyRWoTYrmL/WKn0jKVUYFgEjrEhXj1nCK/img.png?width=673&amp;amp;height=429&amp;amp;face=0_0_673_429&quot;&gt;&lt;a href=&quot;https://sjparkk-dev1og.tistory.com/32&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://sjparkk-dev1og.tistory.com/32&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/OGVxx/hyRVfdh8Tb/az47JRCxE6hCCYaB4fZPy1/img.png?width=800&amp;amp;height=199&amp;amp;face=0_0_800_199,https://scrap.kakaocdn.net/dn/wQbGQ/hyRWshLHsC/osJ6JXtElcHsdcFj3LiB4k/img.png?width=800&amp;amp;height=199&amp;amp;face=0_0_800_199,https://scrap.kakaocdn.net/dn/cIOzeX/hyRWoTYrmL/WKn0jKVUYFgEjrEhXj1nCK/img.png?width=673&amp;amp;height=429&amp;amp;face=0_0_673_429');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring - Scheduler 설정 방법 및 사용방법&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Spring - Scheduler 설정 방법 및 사용방법 일정 시간마다 실행해줘야하는 기능이 있어서 찾아보니 스프링에서 제공해주는 스프링 스케줄러를 이용하기로 했다. 스프링 스케줄러는 일정한 시간간격&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;sjparkk-dev1og.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TaskSchedulerConfig&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션에서 @Scheduled 어노테이션을 사용하기 위해선 @EnableScheduling 어노테이션을 추가해줘야한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@EnableScheduling
public class TaskSchedulerConfig {

}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JobCrawler&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;crawling 추상 메서드를 포함하는 인터페이스이다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface JobCrawler {

    void crawling();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NaverRecruitCrawler&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이버 채용 홈페이지를 크롤링하는 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JobCrawler 인터페이스를 구현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;crawling 메서드를 오버라이딩하는데 다음의 과정을 수행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Chrome Web Driver 실행&lt;/li&gt;
&lt;li&gt;상세 공고 링크 리스트에 담기
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네이버 채용 홈페이지는 무한 스크롤 페이지이기 때문에 전체 데이터가 랜더링될 때까지 scroll down하는 action을 실행한다.&lt;/li&gt;
&lt;li&gt;네이버 채용홈페이지는 공고마다 annoId를 부여한다.&lt;/li&gt;
&lt;li&gt;메인 페이지의 모든 annoId를 찾아서 리스트에 담는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;상세 공고 페이지 리스트를 순회하여 Recruit 타입 오브젝트로 파싱
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상세 공고 페이지 url : &lt;a href=&quot;https://recruit.navercorp.com/rcrt/view.do?annoId=&quot;&gt;https://recruit.navercorp.com/rcrt/view.do?annoId=&lt;/a&gt;&lt;a href=&quot;https://recruit.navercorp.com/rcrt/view.do?annoId=%7BannoId%7D&quot;&gt;{annoId}&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;DB에 저장
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;upsert 하여 중복되어 데이터가 저장되는 것을 방지한다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver.png&quot; data-origin-width=&quot;3364&quot; data-origin-height=&quot;2008&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnwpcl/btr3GRts7rH/CxMKOGaK8W8ndtjpiKxzu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnwpcl/btr3GRts7rH/CxMKOGaK8W8ndtjpiKxzu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnwpcl/btr3GRts7rH/CxMKOGaK8W8ndtjpiKxzu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbnwpcl%2Fbtr3GRts7rH%2FCxMKOGaK8W8ndtjpiKxzu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;478&quot; data-filename=&quot;naver.png&quot; data-origin-width=&quot;3364&quot; data-origin-height=&quot;2008&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 파싱 중에 예외가 발생하면 InvalidUrlException, ElementParseException 같은 커스텀 예외가 발생하고 인터셉터가 이를 가로채서 예외 처리 작업을 수행한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
@Slf4j
public class NaverRecruitCrawler implements JobCrawler {

    private final RecruitMapper recruitMapper;
    private static final String NAVER_URL = &quot;&amp;lt;https://recruit.navercorp.com/rcrt/list.do?subJobCdArr=1010001%2C1010002%2C1010003%2C1010004%2C1010005%2C1010006%2C1010007%2C1010008%2C1010020%2C1020001%2C1030001%2C1030002%2C1040001%2C1050001%2C1050002%2C1060001&amp;amp;sysCompanyCdArr=&amp;amp;empTypeCdArr=&amp;amp;entTypeCdArr=&amp;amp;workAreaCdArr=&amp;amp;sw=&amp;amp;subJobCdData=1010001&amp;amp;subJobCdData=1010002&amp;amp;subJobCdData=1010003&amp;amp;subJobCdData=1010004&amp;amp;subJobCdData=1010005&amp;amp;subJobCdData=1010006&amp;amp;subJobCdData=1010007&amp;amp;subJobCdData=1010008&amp;amp;subJobCdData=1010020&amp;amp;subJobCdData=1020001&amp;amp;subJobCdData=1030001&amp;amp;subJobCdData=1030002&amp;amp;subJobCdData=1040001&amp;amp;subJobCdData=1050001&amp;amp;subJobCdData=1050002&amp;amp;subJobCdData=1060001&amp;gt;&quot;;
    private static final String NAVER_DETAIL_URL = &quot;&amp;lt;https://recruit.navercorp.com/rcrt/view.do?annoId=&amp;gt;&quot;;
    private static final long SCROLL_PAUSE_TIME = 3000L;

    public NaverRecruitCrawler(RecruitMapper recruitMapper) {
        this.recruitMapper = recruitMapper;
    }

    @Override
    @Transactional
    public void crawling() {
        log.info(&quot;================== Naver Recruits Crawling Start ===================&quot;);

        WebDriver driver = runWebDriver();

        List recruitDetailLinks = getRecruitDetailLinks(driver);

        List crawledRecruits = parseLinkToRecruit(driver, recruitDetailLinks);

        for (Recruit crawledRecruit : crawledRecruits) {
            recruitMapper.upsertRecruits(crawledRecruit);
        }

        driver.quit();

        log.info(&quot;================== Naver Recruits Crawling End =====================&quot;);
    }

    private WebDriver runWebDriver() {
        // WebDriver 경로 설정
        System.setProperty(&quot;webdriver.chrome.driver&quot;, &quot;/usr/local/bin/chromedriver&quot;);

        // WebDriver option
        ChromeOptions options = new ChromeOptions();
        options.addArguments(&quot;--start-maximized&quot;);          // 최대크기로
        options.addArguments(&quot;--headless&quot;);                 // Browser를 띄우지 않음
        options.addArguments(&quot;--disable-gpu&quot;);              // GPU를 사용하지 않음, Linux에서 headless를 사용하는 경우 필요함.
        options.addArguments(&quot;--no-sandbox&quot;);               // Sandbox 프로세스를 사용하지 않음, Linux에서 headless를 사용하는 경우 필요함.
        options.addArguments(&quot;--disable-popup-blocking&quot;);    // 팝업 무시
        options.addArguments(&quot;--disable-default-apps&quot;);     // 기본앱 사용안함

        return new ChromeDriver(options);
    }

    private List getRecruitDetailLinks(WebDriver webDriver) {
        List result = new ArrayList&amp;lt;&amp;gt;();

        WebDriverWait wait = new WebDriverWait(webDriver, 10);

        JavascriptExecutor javascriptExecutor = (JavascriptExecutor) webDriver;

        try {
            webDriver.get(NAVER_URL);

            Long beforeHeight = (Long) javascriptExecutor.executeScript(
                &quot;return document.body.scrollHeight;&quot;);
            while (true) {
                javascriptExecutor.executeScript(&quot;window.scrollTo(0, document.body.scrollHeight);&quot;);
                Thread.sleep(SCROLL_PAUSE_TIME);
                Long afterHeight = (Long) javascriptExecutor.executeScript(
                    &quot;return document.body.scrollHeight;&quot;);
                if (beforeHeight.equals(afterHeight)) {
                    break;
                } else {
                    beforeHeight = afterHeight;
                }
            }

            List webElements = wait.until(
                ExpectedConditions.visibilityOfAllElementsLocatedBy(
                    By.xpath(&quot;//*[@class='card_link']&quot;)));

            for (WebElement e : webElements) {
                String annoId = e.getAttribute(&quot;onclick&quot;).replaceAll(&quot;[^0-9]&quot;, &quot;&quot;);
                String link = String.format(&quot;%s%s&quot;, NAVER_DETAIL_URL, annoId);
                result.add(link);
            }

        } catch (InvalidArgumentException e) {
            webDriver.quit();
            throw new InvalidUrlException();
        } catch (InterruptedException e) {

        }

        return result;
    }

    private List parseLinkToRecruit(WebDriver webDriver, List recruitDetailLinks) {
        List result = new ArrayList&amp;lt;&amp;gt;();

        WebDriverWait wait = new WebDriverWait(webDriver, 10);

        for (String link : recruitDetailLinks) {
            try {
                webDriver.get(link);

                String title = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//h4[@class='card_title']&quot;))).getText();

                String career = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='card_info']/dd[4]&quot;))).getText();

                String workerType = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='card_info']/dd[5]&quot;))).getText();

                String duration = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='card_info']/dd[6]&quot;))).getText();
                String dueDate = null;
                if (duration.contains(&quot;~&quot;)) {
                    String[] temp = duration.split(&quot; ~ &quot;);
                    dueDate = temp[1];
                }

                result.add(new Recruit.Builder(title, CompanyType.NAVER.getValue(), link)
                    .career(career)
                    .dueDate(dueDate)
                    .workerType(workerType)
                    .build());
            } catch (InvalidArgumentException e) {
                webDriver.quit();
                throw new InvalidUrlException();
            } catch (WebDriverException e) {
                webDriver.quit();
                throw new ElementParseException();
            }
        }

        return result;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  xpath란?&lt;br /&gt;DOM(Document Object Model)문서 내의 모든 요소를 정의하고 해당 요소에 접근하기 위한 경로&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;KakaoRecruitCrawler&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 채용 홈페이지를 크롤링하는 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JobCrawler 인터페이스를 구현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;crawling 메서드를 오버라이딩하는데 다음의 과정을 수행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Chrome WebDriver 실행&lt;/li&gt;
&lt;li&gt;카카오 채용 메인 페이지에 접속해서 채용 상세 페이지 링크 List에 담기
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카카오 채용 메인 페이지는 네이버와 다르게 페이지 테이블 형식으로 구현되어 있다.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://careers.kakao.com/jobs?page=&quot;&gt;https://careers.kakao.com/jobs?page=&lt;/a&gt;&lt;a href=&quot;https://careers.kakao.com/jobs?page=%7BcurrentPage%7D&quot;&gt;{currentPage}&lt;/a&gt; 경로로 조회할 수 있는 모든 상세 공고 url 읽기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;List를 순회하여 Recruit 타입 오브젝트로 파싱&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상세 공고 페이지 url : &lt;a href=&quot;https://careers.kakao.com/jobs/P-13108&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://careers.kakao.com/jobs/{id}&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;DB 데이터와 크롤링한 데이터 중복 제거
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HashSet 자료구조를 사용해서 O(N) 시간 복잡도로 구현&lt;/li&gt;
&lt;li&gt;크롤러 중복 제거시 Collection의 removeAll() 함수를 사용하고 removeAll()은 내부적으로 contains() 와 remove() 함수를 호출한다. Contains()와 remove() 함수의 시간복잡도가 O(1)인 HashSet 자료구조를 사용해서 결과적으로 O(N)의 시간 복잡도로 구현했다.&lt;/li&gt;
&lt;li&gt;HashSet 객체의 contains()는 내부적으로 hashCode() 호출한다. 따라서 Recruit 객체의 동등성과 동일성을 보장하기 위해 HashCode()와 equals()를 overriding 해줬다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;DB에 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;kakao.png&quot; data-origin-width=&quot;3014&quot; data-origin-height=&quot;1832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uCYOs/btr3GU4NiwS/9bJQbnHIQ9kljdXkiYW6HK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uCYOs/btr3GU4NiwS/9bJQbnHIQ9kljdXkiYW6HK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uCYOs/btr3GU4NiwS/9bJQbnHIQ9kljdXkiYW6HK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuCYOs%2Fbtr3GU4NiwS%2F9bJQbnHIQ9kljdXkiYW6HK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3014&quot; height=&quot;1832&quot; data-filename=&quot;kakao.png&quot; data-origin-width=&quot;3014&quot; data-origin-height=&quot;1832&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class KakaoRecruitCrawler implements JobCrawler {

    private final RecruitMapper recruitMapper;
    private static final String KAKAO_URL = &quot;&amp;lt;https://careers.kakao.com/jobs&amp;gt;&quot;;

    public KakaoRecruitCrawler(RecruitMapper recruitMapper) {
        this.recruitMapper = recruitMapper;
    }

    @Override
    @Transactional
    public void crawling() {
        log.info(&quot;================== Kakao Recruits Crawling Start ===================&quot;);

        WebDriver webDriver = runWebDriver();

        List recruitDetailLinks = getRecruitLinks(webDriver);

        List recruits = parseLinkToRecruit(webDriver, recruitDetailLinks);

        List recruitsToAdd = deduplicate(recruits);

        for (Recruit recruit : recruitsToAdd) {
            recruitMapper.save(recruit);
        }

        webDriver.quit();

        log.info(&quot;================== Kakao Recruits Crawling End =====================&quot;);
    }

    private WebDriver runWebDriver() {
        // WebDriver 경로 설정
        System.setProperty(&quot;webdriver.chrome.driver&quot;, &quot;/usr/local/bin/chromedriver&quot;);

        // WebDriver option
        ChromeOptions options = new ChromeOptions();
        options.addArguments(&quot;--start-maximized&quot;);          // 최대크기로
        options.addArguments(&quot;--headless&quot;);                 // Browser를 띄우지 않음
        options.addArguments(&quot;--disable-gpu&quot;);              // GPU를 사용하지 않음, Linux에서 headless를 사용하는 경우 필요함.
        options.addArguments(&quot;--no-sandbox&quot;);               // Sandbox 프로세스를 사용하지 않음, Linux에서 headless를 사용하는 경우 필요함.
        options.addArguments(&quot;--disable-popup-blocking&quot;);    // 팝업 무시
        options.addArguments(&quot;--disable-default-apps&quot;);     // 기본앱 사용안함

        return new ChromeDriver(options);
    }

    private List deduplicate(List recruits) {
        List findRecruits = recruitMapper.findByCompany(CompanyType.KAKAO.getValue());

        Set recruitsSet = new HashSet&amp;lt;&amp;gt;(recruits);
        Set findRecruitsSet = new HashSet&amp;lt;&amp;gt;(findRecruits);

        recruitsSet.removeAll(findRecruitsSet);

        return new ArrayList&amp;lt;&amp;gt;(recruitsSet);
    }

    private List parseLinkToRecruit(WebDriver webDriver, List recruitDetailLinks) {
        List result = new ArrayList&amp;lt;&amp;gt;();

        WebDriverWait wait = new WebDriverWait(webDriver, 20);

        for (String link : recruitDetailLinks) {

            try {
                webDriver.get(link);

                String title = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//strong[@class='tit_jobs']&quot;))).getText();

                String career = null;
                if (title.contains(&quot;신입&quot;) &amp;amp;&amp;amp; title.contains(&quot;경력&quot;)) {
                    career = &quot;경력무관&quot;;
                } else if (title.contains(&quot;경력&quot;)) {
                    career = &quot;경력&quot;;
                } else if (title.contains(&quot;신입&quot;)) {
                    career = &quot;신입&quot;;
                }

                String workerType = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='list_info']/dd[2]&quot;))).getText();

                String dueDate = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='list_info']/dd[3]&quot;))).getText();

                String address = wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//dl[@class='list_info']/dd[4]&quot;))).getText();

                result.add(new Builder(title, CompanyType.KAKAO.getValue(), link)
                    .career(career)
                    .dueDate(dueDate)
                    .workerType(workerType)
                    .address(address)
                    .build());
            } catch (InvalidArgumentException e) {
                webDriver.quit();
                throw new InvalidUrlException();
            } catch (WebDriverException e) {
                webDriver.quit();
                throw new ElementParseException();
            }
        }

        return result;
    }

    private List getRecruitLinks(WebDriver webDriver) {
        List result = new ArrayList&amp;lt;&amp;gt;();

        int currentPage = 1;

        WebDriverWait wait = new WebDriverWait(webDriver, 20);

        while (true) {
            try {
                webDriver.get(String.format(&quot;%s?page=%d&quot;, KAKAO_URL, currentPage));

                wait.until(ExpectedConditions.visibilityOfElementLocated(
                    By.xpath(&quot;//*[@id=\\&quot;mArticle\\&quot;]/div/ul[@class='list_jobs']&quot;)));
            } catch (InvalidArgumentException e) {
                webDriver.quit();
                throw new InvalidUrlException();
            } catch (WebDriverException e) {  // 마지막 페이지
                break;
            }

            try {
                List webElements = wait.until(
                    ExpectedConditions.visibilityOfAllElementsLocatedBy(
                        By.xpath(&quot;//ul[@class='list_jobs']/child::a&quot;)));

                for (WebElement e : webElements) {
                    String href = e.getAttribute(&quot;href&quot;);
                    if (href == null) {
                        webDriver.quit();
                        throw new ElementParseException();
                    }
                    result.add(String.format(&quot;%s&quot;, href));
                }
            } catch (WebDriverException e) {
                webDriver.quit();
                throw new ElementParseException();
            }

            currentPage += 1;
        }

        return result;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Recruit&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채용 공고 엔티티 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음의 이유로 빌더 패턴을 적용했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;객체 생성시 필수 인자와 선택 인자를 구별&lt;/li&gt;
&lt;li&gt;객체 생성시 가독성 향상&lt;/li&gt;
&lt;li&gt;객체의 immutable함 보장&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@Alias(&quot;recruit&quot;)
@ToString
public class Recruit {

    private Long id;
    private String title;
    private String career;
    private String dueDate;
    private String company;
    private String address;
    private String workerType;
    private String link;
    private LocalDateTime addDateTime;
    private LocalDateTime updateDateTime;

    public Recruit() {
    }

    protected Recruit(Long id, String title, String career, String dueDate, String company,
        String address, String workerType, String link, LocalDateTime addDateTime,
        LocalDateTime updateDateTime) {
        this.id = id;
        this.title = title;
        this.career = career;
        this.dueDate = dueDate;
        this.company = company;
        this.address = address;
        this.workerType = workerType;
        this.link = link;
        this.addDateTime = addDateTime;
        this.updateDateTime = updateDateTime;
    }

    private Recruit(Builder builder) {
        id = builder.id;
        title = builder.title;
        career = builder.career;
        dueDate = builder.dueDate;
        company = builder.company;
        address = builder.address;
        workerType = builder.workerType;
        link = builder.link;
        addDateTime = builder.addDateTime;
        updateDateTime = builder.updateDateTime;
    }

    @Override
    public int hashCode() {
        return Objects.hash(getTitle(), getCompany(), getLink());
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Recruit)) {
            return false;
        }
        Recruit recruit = (Recruit) o;
        return getTitle().equals(recruit.getTitle()) &amp;amp;&amp;amp; getCompany().equals(recruit.getCompany())
            &amp;amp;&amp;amp; getLink().equals(recruit.getLink());
    }

    public static class Builder {

        // 필수 매개변수
        private final String title;
        private final String company;
        private final String link;

        // 선택 매개변수
        private Long id = 0L;
        private String career = null;
        private String dueDate = null;
        private String address = null;
        private String workerType = null;
        private LocalDateTime addDateTime = null;
        private LocalDateTime updateDateTime = null;

        public Builder(String title, String company, String link) {
            this.title = title;
            this.company = company;
            this.link = link;
        }

        public Builder id(Long id) {
            this.id = id;
            return this;
        }

        public Builder career(String career) {
            this.career = career;
            return this;
        }

        public Builder dueDate(String dueDate) {
            this.dueDate = dueDate;
            return this;
        }

        public Builder address(String address) {
            this.address = address;
            return this;
        }

        public Builder workerType(String workerType) {
            this.workerType = workerType;
            return this;
        }

        public Builder addDateTime(LocalDateTime addDateTime) {
            this.addDateTime = addDateTime;
            return this;
        }

        public Builder updateDateTime(LocalDateTime updateDateTime) {
            this.updateDateTime = updateDateTime;
            return this;
        }

        public Recruit build() {
            return new Recruit(this);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GlobalExceptionHandler&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interceptor를 통해서 예외 처리 로직을 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롤러 동작시 InvalidUrlException, ElementParseException 같은 JobCrawlerException 타입 런타임 예외가 발생하면 인터셉터가 이를 가로채서 일괄 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 에러 발생시 개발자에게 메일로 공지하는 방식으로 구현했다.&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Value(&quot;${admin.email}&quot;)
    private String adminEmail;

    private final MailSendManager mailSendManager;

    public GlobalExceptionHandler(MailSendManager mailSender) {
        this.mailSendManager = mailSender;
    }

    @ExceptionHandler(value = {JobCrawlerException.class})
    protected ResponseEntity&amp;lt;ErrorResponse&amp;gt; handleJobCrawlerException(JobCrawlerException e)
        throws MessagingException {
        log.error(&quot;JobCrawlerException : {}&quot;, e.getErrorCode());
        mailSendManager.sendMail(adminEmail, &quot;Job Crawler RuntimeException 발생&quot;, String.format(
            &quot;exception name : %s\\nerror code : %s\\nerror message : %s&quot;, e.getClass().toString(),
            e.getErrorCode(), e.getErrorCode().getDetail()));
        return ErrorResponse.toResponseEntity(e.getErrorCode());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;코드 참조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot;&gt;https://github.com/KIM-KYOUNG-OH/ChatBot&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1678774434082&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - KIM-KYOUNG-OH/ChatBot: 금일 업데이트된 채용 공고 및 뉴스 기사를 제공하는 카카오톡 챗봇입니&quot; data-og-description=&quot;금일 업데이트된 채용 공고 및 뉴스 기사를 제공하는 카카오톡 챗봇입니다. Contribute to KIM-KYOUNG-OH/ChatBot development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot; data-og-url=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/oAxrs/hyRWrb5ZVU/uumHMH7nYZm18xOSNCJNE0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/oAxrs/hyRWrb5ZVU/uumHMH7nYZm18xOSNCJNE0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - KIM-KYOUNG-OH/ChatBot: 금일 업데이트된 채용 공고 및 뉴스 기사를 제공하는 카카오톡 챗봇입니&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;금일 업데이트된 채용 공고 및 뉴스 기사를 제공하는 카카오톡 챗봇입니다. Contribute to KIM-KYOUNG-OH/ChatBot development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Selenium 라이브러리를 사용해서 웹 크롤러를 제작한 과정을 공유해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 크롤러를 개발하면서 고도화하거나 부족한 부분이 많은 것 같은데 다음 포스팅에서는 아래의 키워드에 대해서 다뤄볼 계획이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배치 서버를 통해 크롤링 부하 분산&lt;/li&gt;
&lt;li&gt;DB side 중복 제거 vs java side 중복 제거&lt;/li&gt;
&lt;li&gt;InterruptedException 처리&lt;/li&gt;
&lt;li&gt;에러 발생시 Logging &amp;amp; Notification 전략&lt;/li&gt;
&lt;li&gt;E2E 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.selenium.dev/documentation/&quot;&gt;https://www.selenium.dev/documentation/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://bonigarcia.dev/webdrivermanager/&quot;&gt;https://bonigarcia.dev/webdrivermanager/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.guru99.com/implicit-explicit-waits-selenium.html#fluent-wait&quot;&gt;https://www.guru99.com/implicit-explicit-waits-selenium.html#fluent-wait&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.tcpschool.com/xml/xml_xpath_pathExpression&quot;&gt;https://www.tcpschool.com/xml/xml_xpath_pathExpression&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://deonggi.tistory.com/162&quot;&gt;https://deonggi.tistory.com/162&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;http://happinessoncode.com/2017/10/09/java-thread-interrupt/&quot;&gt;http://happinessoncode.com/2017/10/09/java-thread-interrupt/&lt;/a&gt;&lt;/p&gt;</description>
      <category>crawler</category>
      <category>Explicit Wait</category>
      <category>Implicit Wait</category>
      <category>Interceptor</category>
      <category>selenium</category>
      <category>중복 제거 알고리즘</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/91</guid>
      <comments>https://steadycode.tistory.com/91#entry91comment</comments>
      <pubDate>Tue, 14 Mar 2023 15:38:20 +0900</pubDate>
    </item>
    <item>
      <title>[Design Pattern] 과도한 if 절 타입 분기 코드 개선하기</title>
      <link>https://steadycode.tistory.com/90</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 if 절이 과도하게 사용되어 오염된 비즈니스 레이어 결합도를 객체지향 패턴을 통해 낮추는 방법에 대해서 다뤄보려고 한다.&lt;/p&gt;
&lt;h1&gt;문제 분석&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 이전 포스팅에서 다루었던 코드이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://steadycode.tistory.com/89&quot;&gt;https://steadycode.tistory.com/89&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1676485259868&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;카카오 챗봇 응답 템플릿 작성하기&quot; data-og-description=&quot;이번 포스팅에선 &amp;lsquo;채용공고 크롤러&amp;rsquo; 사이드 프로젝트를 진행하면서 카카오 챗봇을 구현한 내용을 상세히 기록하고자 한다. 카카오 챗봇 카카오 봇 시스템은 유저로부터 스킬 요청을 받고 스&quot; data-og-host=&quot;steadycode.tistory.com&quot; data-og-source-url=&quot;https://steadycode.tistory.com/89&quot; data-og-url=&quot;https://steadycode.tistory.com/89&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cSqax4/hyRDVk5q4S/hjrQQxwKblmntQnPMYo4rk/img.png?width=800&amp;amp;height=283&amp;amp;face=0_0_800_283,https://scrap.kakaocdn.net/dn/bNKBzs/hyRD5nI92A/LKnGdn7FkiAzxur4TXUNO1/img.png?width=800&amp;amp;height=283&amp;amp;face=0_0_800_283,https://scrap.kakaocdn.net/dn/duG7gX/hyRDUNf94j/kxzlNJdKWFO2hevXWsjff1/img.png?width=1468&amp;amp;height=520&amp;amp;face=0_0_1468_520&quot;&gt;&lt;a href=&quot;https://steadycode.tistory.com/89&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://steadycode.tistory.com/89&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cSqax4/hyRDVk5q4S/hjrQQxwKblmntQnPMYo4rk/img.png?width=800&amp;amp;height=283&amp;amp;face=0_0_800_283,https://scrap.kakaocdn.net/dn/bNKBzs/hyRD5nI92A/LKnGdn7FkiAzxur4TXUNO1/img.png?width=800&amp;amp;height=283&amp;amp;face=0_0_800_283,https://scrap.kakaocdn.net/dn/duG7gX/hyRDUNf94j/kxzlNJdKWFO2hevXWsjff1/img.png?width=1468&amp;amp;height=520&amp;amp;face=0_0_1468_520');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;카카오 챗봇 응답 템플릿 작성하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 &amp;lsquo;채용공고 크롤러&amp;rsquo; 사이드 프로젝트를 진행하면서 카카오 챗봇을 구현한 내용을 상세히 기록하고자 한다. 카카오 챗봇 카카오 봇 시스템은 유저로부터 스킬 요청을 받고 스&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;steadycode.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
public class RecruitController {

    private final RecruitService recruitService;
    private final Map&amp;lt;String, String&amp;gt; companies = Map.of(&quot;kakao&quot;, &quot;카카오&quot;, &quot;naver&quot;, &quot;네이버&quot;);

    public RecruitController(RecruitService recruitService, ObjectMapper objectMapper) {
        this.recruitService = recruitService;
        this.objectMapper = objectMapper;
    }

    @PostMapping(&quot;/api/new-recruits/{company}&quot;)  // company : {kakao, naver, all}
    public CommonResponse getNewRecruitsAfterYesterdayByListCard(@PathVariable(name = &quot;company&quot;) String company) {
        List&amp;lt;Recruit&amp;gt; findRecruits;
        if(company.equals(&quot;all&quot;)) {
            findRecruits = recruitService.getAllRecruitsAfterYesterday();
        } else {
            findRecruits = recruitService.getRecruitsByCompanyAfterYesterday(companies.get(company));
        }

        CommonResponse result;
        if(findRecruits.size() != 0) {
            result = ListCardResponse.of(findRecruits);
        } else {
            result = SimpleTextResponse.empty();
        }

        return result;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 코드의 문제점은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;과도한 타입 분기로 인한 높은 결합도&lt;/li&gt;
&lt;li&gt;낮은 유지보수성&lt;/li&gt;
&lt;li&gt;가독성 감소&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 company 타입이 &amp;ldquo;kakao&amp;rdquo;, &amp;ldquo;naver&amp;rdquo;, &amp;ldquo;all&amp;rdquo; 밖에 없지만 시스템이 고도화되어서 타입 변수가 1000개로 늘어났다고 가정해본다면 else if 절이 1000줄 추가로 작성되게 될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능이 추가되거나 변경될 때마다 business layer 코드를 건드려야하고 그 변경의 여파가 어디까지 퍼질지 예측할 수 없는 위험이 있다.&lt;/p&gt;
&lt;h1&gt;해결 방안&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결합도를 낮춰서 Controller 의 과도한 책임을 분산시킬 필요가 있어 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결합도는 캡슐화를 통해 낮출 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캡슐화는 객체지향 디자인 패턴의 핵심 원리중 하나로서 내부 구현을 감추는 것을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;business layer에선 추상화된 인터페이스에만 의존하도록 하고 해당 인터페이스를 구현하는 구현체들을 생성하여 런타임에 주입되도록 설계한다.&lt;/p&gt;
&lt;h1&gt;개선된 코드&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;company 타입을 추상화하는 인터페이스 CompanyCondition을 두고 이를 구현하는 EntireCompany, Kakao, Naver 객체를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 business layer 에선 CompanyCondition에만 의존하고 구현부를 캡슐화한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CompanyCondition&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface CompanyCondition {
    public Boolean isSatisfiedBy(CompanyType companyType);

    List&amp;lt;Recruit&amp;gt; getRecruitsByCondition();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;EntireCompany&lt;/h2&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class EntireCompany implements CompanyCondition {

    private final RecruitMapper recruitMapper;

    public EntireCompany(RecruitMapper recruitMapper) {
        this.recruitMapper = recruitMapper;
    }

    @Override
    public Boolean isSatisfiedBy(CompanyType companyType) {
        return companyType == CompanyType.ALL;
    }

    @Override
    public List&amp;lt;Recruit&amp;gt; getRecruitsByCondition() {
        LocalDateTime yesterday = LocalDateTime.now().minusDays(1);

        return recruitMapper.findAll().stream()
                .filter(d -&amp;gt; d.getAddDateTime().isAfter(yesterday))
                .collect(Collectors.toList());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kakao&lt;/h2&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class Kakao implements CompanyCondition {

    private final RecruitMapper recruitMapper;

    public Kakao(RecruitMapper recruitMapper) {
        this.recruitMapper = recruitMapper;
    }

    @Override
    public Boolean isSatisfiedBy(CompanyType companyType) {
        return companyType == CompanyType.KAKAO;
    }

    @Override
    public List&amp;lt;Recruit&amp;gt; getRecruitsByCondition() {
        LocalDateTime yesterday = LocalDateTime.now().minusDays(1000);

        List&amp;lt;Recruit&amp;gt; find = recruitMapper.findByCompany(CompanyType.KAKAO.getValue());
        return find.stream()
                .filter(d -&amp;gt; d.getAddDateTime().isAfter(yesterday))
                .collect(Collectors.toList());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Naver&lt;/h2&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class Naver implements CompanyCondition {

    private final RecruitMapper recruitMapper;

    public Naver(RecruitMapper recruitMapper) {
        this.recruitMapper = recruitMapper;
    }

    @Override
    public Boolean isSatisfiedBy(CompanyType companyType) {
        return companyType == CompanyType.NAVER;
    }

    @Override
    public List&amp;lt;Recruit&amp;gt; getRecruitsByCondition() {
        LocalDateTime yesterday = LocalDateTime.now().minusDays(1);

        return recruitMapper.findByCompany(CompanyType.NAVER.getValue()).stream()
                .filter(d -&amp;gt; d.getAddDateTime().isAfter(yesterday))
                .collect(Collectors.toList());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RecruitController&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 if 문을 Service 내부로 캡슐화했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 Controller에 부여된 과한 책임을 분리했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
@Slf4j
public class RecruitController {

    private final RecruitService recruitService;

    public RecruitController(RecruitService recruitService) {
        this.recruitService = recruitService;
    }

    @PostMapping(&quot;/api/new-recruits/{company}&quot;)
    public CommonResponse getNewRecruitsAfterYesterdayByListCard(@PathVariable(name = &quot;company&quot;) String company) {
        CompanyType companyType = CompanyTypeConverter.from(company);  // string to enum
        List&amp;lt;Recruit&amp;gt; findRecruits = recruitService.getRecruitsByCompany(companyType);
        return parseToChatBotTemplate(findRecruits);
    }

    private CommonResponse parseToChatBotTemplate(List&amp;lt;Recruit&amp;gt; findRecruits) {
        if(findRecruits.size() != 0) {
            return ListCardResponse.of(findRecruits);
        } else {
            return SimpleTextResponse.empty();
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RecruitService&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Collection Injection 을 통해 구현체 리스트를 멤버로 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 객체는 단지 인터페이스의 추상 메서드를 실행할 뿐이고 런타임에 조건에 만족하는 구현체가 동작한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
public class RecruitService {

    private final List&amp;lt;CompanyCondition&amp;gt; companyConditions;

    public RecruitService(List&amp;lt;CompanyCondition&amp;gt; companyConditions) {
        this.companyConditions = companyConditions;
    }

    public List&amp;lt;Recruit&amp;gt; getRecruitsByCompany(CompanyType companyType) {
        return companyConditions.stream()
                .filter(companyCondition -&amp;gt; companyCondition.isSatisfiedBy(companyType))
                .findFirst().get().getRecruitsByCondition();
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 기능을 확장하고자 할 때 더이상 service layer를 건드는 위험을 감수하지 않아도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;company 타입을 추가하려면 단순히 CompanyCondition 을 implements하는 구현체만 생성하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 추가할 때 인터페이스의 하위 구현체를 생성하는 방식으로 기존 코드에 영향을 주지 않으면서 개발할 수 있는 원칙을 OCP 원칙이라고 한다.&lt;/p&gt;
&lt;h1&gt;코드 참조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://github.com/KIM-KYOUNG-OH/ChatBot&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체 지향 디자인 패턴을 이용해서 기존의 코드를 개선해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대규모 시스템으로 갈수록 코드가 복잡해지고 유지보수가 어려워지기 때문에 이를 위한 해결책으로 객체지향 패턴을 고려해볼 수 있을 것 같다.&lt;/p&gt;</description>
      <category>Design Pattern</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/90</guid>
      <comments>https://steadycode.tistory.com/90#entry90comment</comments>
      <pubDate>Thu, 16 Feb 2023 03:23:39 +0900</pubDate>
    </item>
    <item>
      <title>카카오 챗봇 응답 템플릿 작성하기</title>
      <link>https://steadycode.tistory.com/89</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 &amp;lsquo;채용공고 크롤러&amp;rsquo; 사이드 프로젝트를 진행하면서 카카오 챗봇을 구현한 내용을 상세히 기록하고자 한다.&lt;/p&gt;
&lt;h1&gt;카카오 챗봇&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NWIK7/btrZddAoGSB/Gl2cPWiGxWjPkMXuE9hXlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NWIK7/btrZddAoGSB/Gl2cPWiGxWjPkMXuE9hXlk/img.png&quot; data-alt=&quot;출처: https://i.kakao.com/docs/skill-build#스킬-서버-이해하기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NWIK7/btrZddAoGSB/Gl2cPWiGxWjPkMXuE9hXlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNWIK7%2FbtrZddAoGSB%2FGl2cPWiGxWjPkMXuE9hXlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1468&quot; height=&quot;520&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://i.kakao.com/docs/skill-build#스킬-서버-이해하기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 봇 시스템은 유저로부터 스킬 요청을 받고 스킬 서버와 통신하여 알맞은 데이터를 응답해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 스킬이란 카카오 챗봇 시스템내에서 사용하는 동적 데이터 요청 기능 단위를 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬은 스킬 서버 API와 1대1 매핑된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음의 순서로 개발해보도록 하겠다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 생성&lt;/li&gt;
&lt;li&gt;스킬 등록&lt;/li&gt;
&lt;li&gt;블록에 매핑&lt;/li&gt;
&lt;li&gt;테스트&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;1. API 생성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬 타입으로 ListCard와 SimpleText를 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ListCard는 한번의 요청으로 데이터를 간략한 블록 리스트 형태로 제공할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번의 요청으로 여러 데이터를 응답해야하므로 ListCard 타입을 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 반환할 데이터가 없다면 SimpleText로 반환할 데이터가 없다는 알림을 출력했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lWyKY/btrZjAgJCyg/Ek1NfGabhbRP7hvCFUgqQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lWyKY/btrZjAgJCyg/Ek1NfGabhbRP7hvCFUgqQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lWyKY/btrZjAgJCyg/Ek1NfGabhbRP7hvCFUgqQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlWyKY%2FbtrZjAgJCyg%2FEk1NfGabhbRP7hvCFUgqQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;570&quot; height=&quot;404&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;820&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cf54uS/btrZj938a0B/SgCBpOWj0IBoNw1ZZ2WZbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cf54uS/btrZj938a0B/SgCBpOWj0IBoNw1ZZ2WZbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cf54uS/btrZj938a0B/SgCBpOWj0IBoNw1ZZ2WZbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcf54uS%2FbtrZj938a0B%2FSgCBpOWj0IBoNw1ZZ2WZbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;566&quot; height=&quot;316&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;820&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 JSON 포맷은 아래처럼 공식 문서에 정의된 형식에 맞춰서 응답해줘야 한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;version&quot;: &quot;2.0&quot;,
  &quot;template&quot;: {
    &quot;outputs&quot;: [
      {
        &quot;listCard&quot;: {
          &quot;header&quot;: {
            &quot;title&quot;: &quot;챗봇 관리자센터를 소개합니다.&quot;
          },
          &quot;items&quot;: [
            {
              &quot;title&quot;: &quot;챗봇 관리자센터&quot;,
              &quot;description&quot;: &quot;새로운 AI의 내일과 일상의 변화&quot;,
              &quot;imageUrl&quot;: &quot;&amp;lt;https://t1.kakaocdn.net/openbuilder/sample/img_001.jpg&amp;gt;&quot;,
              &quot;link&quot;: {
                &quot;web&quot;: &quot;&amp;lt;https://namu.wiki/w/%EB%9D%BC%EC%9D%B4%EC%96%B8(%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%94%84%EB%A0%8C%EC%A6%88)&amp;gt;&quot;
              }
            },
            {
              &quot;title&quot;: &quot;챗봇 관리자센터&quot;,
              &quot;description&quot;: &quot;카카오톡 채널 챗봇 만들기&quot;,
              &quot;imageUrl&quot;: &quot;&amp;lt;https://t1.kakaocdn.net/openbuilder/sample/img_002.jpg&amp;gt;&quot;,
              &quot;action&quot;: &quot;block&quot;,
              &quot;blockId&quot;: &quot;62654c249ac8ed78441532de&quot;,
              &quot;extra&quot;: {
                &quot;key1&quot;: &quot;value1&quot;,
                &quot;key2&quot;: &quot;value2&quot;
              }
            },
            {
              &quot;title&quot;: &quot;Kakao i Voice Service&quot;,
              &quot;description&quot;: &quot;보이스봇 / KVS 제휴 신청하기&quot;,
              &quot;imageUrl&quot;: &quot;&amp;lt;https://t1.kakaocdn.net/openbuilder/sample/img_003.jpg&amp;gt;&quot;,
              &quot;action&quot;: &quot;message&quot;,
              &quot;messageText&quot;: &quot;Kakao i Voice Service&quot;,
              &quot;extra&quot;: {
                &quot;key1&quot;: &quot;value1&quot;,
                &quot;key2&quot;: &quot;value2&quot;
              }
            }
          ],
          &quot;buttons&quot;: [
            {
              &quot;label&quot;: &quot;구경가기&quot;,
              &quot;action&quot;: &quot;block&quot;,
              &quot;blockId&quot;: &quot;62654c249ac8ed78441532de&quot;,
              &quot;extra&quot;: {
                &quot;key1&quot;: &quot;value1&quot;,
                &quot;key2&quot;: &quot;value2&quot;
              }
            }
          ]
        }
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
    &quot;version&quot;: &quot;2.0&quot;,
    &quot;template&quot;: {
        &quot;outputs&quot;: [
            {
                &quot;simpleText&quot;: {
                    &quot;text&quot;: &quot;간단한 텍스트 요소입니다.&quot;
                }
            }
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;예제 코드&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Recruit&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채용공고 엔티티 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mybatis는 reflection을 통해 필드값에 접근하기 때문에 기본 생성자를 생성해준다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
public class Recruit {
    private Long id;
    private String title;
    private String career;
    private String dueDate;
    private String company;
    private String address;
    private String workerType;
    private String link;
    private LocalDateTime addDateTime;
    private LocalDateTime updateDateTime;
    
    public Recruit() {}
    
    private Recruit(Builder builder) {
        id = builder.id;
        title = builder.title;
        career = builder.career;
        dueDate = builder.dueDate;
        company = builder.company;
        address = builder.address;
        workerType = builder.workerType;
        link = builder.link;
        addDateTime = builder.addDateTime;
        updateDateTime = builder.updateDateTime;
    }

    public static class Builder {
        // 필수 매개변수
        private final String title;
        private final String company;
        private final String link;

        // 선택 매개변수
        private Long id = 0L;
        private String career = null;
        private String dueDate = null;
        private String address = null;
        private String workerType = null;
        private LocalDateTime addDateTime = null;
        private LocalDateTime updateDateTime = null;

        public Builder(String title, String company, String link) {
            this.title = title;
            this.company = company;
            this.link = link;
        }

        public Builder id(Long id) {
            this.id = id;
            return this;
        }

        public Builder career(String career) {
            this.career = career;
            return this;
        }

        public Builder dueDate(String dueDate) {
            this.dueDate = dueDate;
            return this;
        }

        public Builder address(String address) {
            this.address = address;
            return this;
        }

        public Builder workerType(String workerType) {
            this.workerType = workerType;
            return this;
        }

        public Builder addDateTime(LocalDateTime addDateTime) {
            this.addDateTime = addDateTime;
            return this;
        }

        public Builder updateDateTime(LocalDateTime updateDateTime) {
            this.updateDateTime = updateDateTime;
            return this;
        }

        public Recruit build() {
            return new Recruit(this);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ListCardResponse&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ListCard 타입 응답 템플릿 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에서 조회한 채용 공고 리스트를 파라미터로 넘겨주면 인스턴스를 생성하는 static 메서드를 정의한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ListCard는 최대 15개의 데이터만 표시할 수 있고 3개의 블록(말풍선)으로 나누어 출력한다는 제약사항이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 loop을 돌면서 5개씩 묶어서 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티 데이터는 title, description, imageUrl 필드에 매핑된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;{
    &quot;version&quot;: &quot;2.0&quot;,
    &quot;template&quot;: {
        &quot;outputs&quot;: [
            {
                &quot;listCard&quot;: {
                    &quot;header&quot;: {
                        &quot;title&quot;: &quot;6월 21일 신규 채용 공고 (1/1)&quot;
                    },
                    &quot;items&quot;: [
                        {
                            &quot;title&quot;: &quot;[Up-data!] 2022 kakao Data 경력 개발자 영입&quot;,
                            &quot;description&quot;: &quot;카카오/경력/정규직/2022년 7월 8일 까지/판교&quot;,
                            &quot;imageUrl&quot;: &quot;&amp;lt;https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png&amp;gt;&quot;,
                            &quot;link&quot;: {
                                &quot;web&quot;: &quot;&amp;lt;https://careers.kakao.com/jobs/P-12703?page=1&amp;gt;&quot;
                            }
                        },
                        {
                            &quot;title&quot;: &quot;카카오의 사회공헌 서비스를 만들어갈 서버∙백엔드 개발자 모집&quot;,
                            &quot;description&quot;: &quot;카카오/정규직/영입종료시/판교&quot;,
                            &quot;imageUrl&quot;: &quot;&amp;lt;https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png&amp;gt;&quot;,
                            &quot;link&quot;: {
                                &quot;web&quot;: &quot;&amp;lt;https://careers.kakao.com/jobs/P-12691?page=1&amp;gt;&quot;
                            }
                        }
                    ]
                }
            }
        ]
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@Slf4j
public class ListCardResponse implements CommonResponse {
    private final String version;
    private final Template template;
    private static final Map&amp;lt;string, string=&quot;&quot;&amp;gt; logoUrlMap = Map.of(&quot;카카오&quot;, &quot;&amp;lt;https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png&amp;gt;&quot;, &quot;네이버&quot;, &quot;&amp;lt;https://blog.kakaocdn.net/dn/XEAGa/btqB10c0bQ1/RZk86MYViwrP4OaBdpfhn0/img.png&amp;gt;&quot;);

    private ListCardResponse(String version, Template template) {
        this.version = version;
        this.template = template;
    }

    public static ListCardResponse of(List recruits) {
        String now = LocalDate.now().getMonthValue() + &quot;월 &quot; + LocalDate.now().getDayOfMonth() + &quot;일&quot;;
        List outputs = new ArrayList&amp;lt;&amp;gt;();
        int totalCount = recruits.size();  // 공고 개수
        int totalPage = totalCount % 5 == 0 ? totalCount / 5 : totalCount / 5 + 1;  // 블록 수
        int currentPage = 1;  // 현재 블록 페이지
        int currentIndex = 0;  // 블록 첫 인덱스

        while(currentIndex &amp;lt; totalCount) {
            List items = new ArrayList&amp;lt;&amp;gt;();
            for(int i = currentIndex; i &amp;lt; totalCount; i++) {
                Recruit recruit = recruits.get(i);
                StringBuilder sb = new StringBuilder();
                sb.append(recruit.getCompany());

                if(recruit.getCareer() != null) {
                    sb.append(&quot;/&quot;).append(recruit.getCareer());
                }

                if(recruit.getWorkerType() != null) {
                    sb.append(&quot;/&quot;).append(recruit.getWorkerType());
                }

                if(recruit.getDueDate() != null) {
                    sb.append(&quot;/&quot;).append(recruit.getDueDate());
                }

                if(recruit.getAddress() != null) {
                    sb.append(&quot;/&quot;).append(recruit.getAddress());
                }

                Item item = new Item(recruit.getTitle(), sb.toString(), logoUrlMap.get(recruit.getCompany()), new Link(recruit.getLink()));
                items.add(item);
                if(i == totalCount - 1 || i - currentIndex == 4) {
                    outputs.add(new Output(new ListCard(new Header(now + &quot; 신규 채용 공고 (&quot; + currentPage++ + &quot;/&quot; + totalPage + &quot;)&quot;), items)));
                    currentIndex = i + 1;
                    break;
                }
            }
        }

        return new ListCardResponse(&quot;2.0&quot;, new Template(outputs));
    }

    @Getter
    private static class Template{
        private final List outputs;

        private Template(List outputs) {
            this.outputs = outputs;
        }
    }

    @Getter
    private static class Output{
        private final ListCard listCard;

        private Output(ListCard listCard) {
            this.listCard = listCard;
        }
    }

    @Getter
    private static class ListCard {
        private final Header header;
        private final List items;

        private ListCard(Header header, List items) {
            this.header = header;
            this.items = items;
        }
    }

    @Getter
    private static class Header {
        private final String title;

        private Header(String title) {
            this.title = title;
        }
    }

    @Getter
    private static class Item {
        private final String title;
        private final String description;
        private final String imageUrl;
        private final Link link;

        public Item(String title, String description, String imageUrl, Link link) {
            this.title = title;
            this.description = description;
            this.imageUrl = imageUrl;
            this.link = link;
        }
    }

    @Getter
    private static class Link {
        private final String web;

        private Link(String web) {
            this.web = web;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SimpleTextResponse&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SimpleText 타입 응답 템플릿 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회된 데이터가 없다면 알림문구를 띄우기 위해 정의한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
    &quot;version&quot;: &quot;2.0&quot;,
    &quot;template&quot;: {
        &quot;outputs&quot;: [
            {
                &quot;simpleText&quot;: {
                    &quot;text&quot;: &quot;X월 X일 신규 추가된 채용공고가 존재하지 않습니다.\\n신규 채용정보는 매일 오전 8시에 업데이트됩니다.&quot;
                }
            }
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Getter
public class SimpleTextResponse implements CommonResponse{
    private final String version;
    private final Template template;

    private SimpleTextResponse(String version, Template template) {
        this.version = version;
        this.template = template;
    }

    public static SimpleTextResponse empty() {
        String now = LocalDate.now().getMonthValue() + &quot;월 &quot; + LocalDate.now().getDayOfMonth() + &quot;일&quot;;
        List&amp;lt;Output&amp;gt; outputs = new ArrayList&amp;lt;&amp;gt;();
        outputs.add(new Output(new SimpleText(now + &quot; 신규 추가된 채용공고가 존재하지 않습니다.\\n신규 채용정보는 매일 오전 8시에 업데이트됩니다.&quot;)));

        return new SimpleTextResponse(&quot;2.0&quot;, new Template(outputs));
    }

    @Getter
    private static class Template{
        private final List&amp;lt;Output&amp;gt; outputs;

        private Template(List&amp;lt;Output&amp;gt; outputs) {
            this.outputs = outputs;
        }
    }

    @Getter
    private static class Output{
        private final SimpleText simpleText;

        private Output(SimpleText simpleText) {
            this.simpleText = simpleText;
        }
    }

    @Getter
    private static class SimpleText {
        private final String text;

        private SimpleText(String text) {
            this.text = text;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CommonResponse&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ListCardResponse 객체와 SimpleTextResponse를 추상화한 인터페이스이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface CommonResponse {}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RecruitController&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬 서버의 채용 공고 조회 API를 정의한 Controller 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;if 절에 의한 과도한 타입 분기는 유지보수에 취약한 구조를 가져올 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 관한 부분은 다음 포스팅에서 다루겠다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
public class RecruitController {

    private final RecruitService recruitService;
    private final Map&amp;lt;String, String&amp;gt; companies = Map.of(&quot;kakao&quot;, &quot;카카오&quot;, &quot;naver&quot;, &quot;네이버&quot;);

    public RecruitController(RecruitService recruitService, ObjectMapper objectMapper) {
        this.recruitService = recruitService;
        this.objectMapper = objectMapper;
    }

    @PostMapping(&quot;/api/new-recruits/{company}&quot;)  // company : {kakao, naver, all}
    public CommonResponse getNewRecruitsAfterYesterdayByListCard(@PathVariable(name = &quot;company&quot;) String company) {
        List&amp;lt;Recruit&amp;gt; findRecruits;
        if(company.equals(&quot;all&quot;)) {
            findRecruits = recruitService.getAllRecruitsAfterYesterday();
        } else {
            findRecruits = recruitService.getRecruitsByCompanyAfterYesterday(companies.get(company));
        }

        CommonResponse result;
        if(findRecruits.size() != 0) {
            result = ListCardResponse.of(findRecruits);
        } else {
            result = SimpleTextResponse.empty();
        }

        return result;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;2. 스킬 등록&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 챗봇 관리자 센터에서 스킬 서버로 요청할 스킬을 등록해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTcaQc/btrZiWxSFfm/ZVrk2b7wgPGQ4eH8s5wyOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTcaQc/btrZiWxSFfm/ZVrk2b7wgPGQ4eH8s5wyOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTcaQc/btrZiWxSFfm/ZVrk2b7wgPGQ4eH8s5wyOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTcaQc%2FbtrZiWxSFfm%2FZVrk2b7wgPGQ4eH8s5wyOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3550&quot; height=&quot;1660&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1660&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;3. 블록에 매핑&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 짜놓은 시나리오에 맞춰 출력할 데이터 블록을 스킬과 매핑시켜준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1738&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HL1QH/btrZhRXSEa1/Gw8bvkWm0DLkSbrSy1zm61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HL1QH/btrZhRXSEa1/Gw8bvkWm0DLkSbrSy1zm61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HL1QH/btrZhRXSEa1/Gw8bvkWm0DLkSbrSy1zm61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHL1QH%2FbtrZhRXSEa1%2FGw8bvkWm0DLkSbrSy1zm61%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3550&quot; height=&quot;1738&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1738&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;4. 테스트&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 동적 데이터가 정상 출력되는 것을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;942&quot; data-origin-height=&quot;1448&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biCOIS/btrZjYPc6pF/i0MxDqOkrB33MOV85kkFv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biCOIS/btrZjYPc6pF/i0MxDqOkrB33MOV85kkFv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biCOIS/btrZjYPc6pF/i0MxDqOkrB33MOV85kkFv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiCOIS%2FbtrZjYPc6pF%2Fi0MxDqOkrB33MOV85kkFv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;420&quot; height=&quot;646&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;942&quot; data-origin-height=&quot;1448&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 카카오 챗봇을 이용해서 동적 데이터를 출력하는 방법에 대해서 정리해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 작성하면서 비즈니스 레이어가 구현체에 너무 많이 의존되어서 변경에 취약한 구조를 띄는 것을 발견했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에선 이를 어떻게 하면 개선할 수 있을지 다뤄보겠다.&lt;/p&gt;</description>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/89</guid>
      <comments>https://steadycode.tistory.com/89#entry89comment</comments>
      <pubDate>Tue, 14 Feb 2023 15:49:25 +0900</pubDate>
    </item>
    <item>
      <title>[Jenkins] 원격 Jenkins 서버로 CI/CD 환경 구현하기</title>
      <link>https://steadycode.tistory.com/88</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 jenkins pipeline 생성 방법과 jenkinsfile script를 작성하여 애플리케이션을 배포하는 방법에 대해서 다뤄보고자 한다.&lt;/p&gt;
&lt;h1&gt;CI/CD Flow&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;github로부터 소스코드 pull&lt;/li&gt;
&lt;li&gt;build &amp;amp; test&lt;/li&gt;
&lt;li&gt;빌드된 jar 파일 배포 서버로 전송&lt;/li&gt;
&lt;li&gt;애플리케이션 서버에 접속&lt;/li&gt;
&lt;li&gt;전송된 jar 파일 실행하여 애플리케이션 배포&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;Jenkins Global Configuration 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JDK, Git, Gradle 등의 설정을 미리 생성해놓고 여러 Jenkins Project에서 참조하여 사용할 수 있도록 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mbPBE/btrYI5VIgRE/0MFSLKUAbBXlursAt28TRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mbPBE/btrYI5VIgRE/0MFSLKUAbBXlursAt28TRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mbPBE/btrYI5VIgRE/0MFSLKUAbBXlursAt28TRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmbPBE%2FbtrYI5VIgRE%2F0MFSLKUAbBXlursAt28TRK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3552&quot; height=&quot;1806&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;JDK 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JAVA_HOME 값을 출력하여 jenkins console에 등록한다.&lt;/p&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;$ echo $JAVA_HOME&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1162&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ALhjh/btrYH1zLb7l/0XUGxQEK7yHgseN6Je5ao0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ALhjh/btrYH1zLb7l/0XUGxQEK7yHgseN6Je5ao0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ALhjh/btrYH1zLb7l/0XUGxQEK7yHgseN6Je5ao0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FALhjh%2FbtrYH1zLb7l%2F0XUGxQEK7yHgseN6Je5ao0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2450&quot; height=&quot;1162&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1162&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;Git 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;git 커멘드 경로도 등록해준다.&lt;/p&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;$ which git&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1006&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tLwUm/btrYI5VItMR/5kADEbZFYH5DKZHW8jZva1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tLwUm/btrYI5VItMR/5kADEbZFYH5DKZHW8jZva1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tLwUm/btrYI5VItMR/5kADEbZFYH5DKZHW8jZva1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtLwUm%2FbtrYI5VItMR%2F5kADEbZFYH5DKZHW8jZva1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2450&quot; height=&quot;1006&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1006&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;Jenkins Pipeline 생성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins Pipeline은 코드를 통해서 지속적인 배포 환경을 구축할 수 있는 Jenkins 모듈이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E6wPb/btrYI5BouSL/xgLe926ktXyp2rNwRCjY1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E6wPb/btrYI5BouSL/xgLe926ktXyp2rNwRCjY1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E6wPb/btrYI5BouSL/xgLe926ktXyp2rNwRCjY1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE6wPb%2FbtrYI5BouSL%2FxgLe926ktXyp2rNwRCjY1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3552&quot; height=&quot;1806&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbzXVd/btrYGuoHiax/xZa66QFGg1KZfepyyUjfrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbzXVd/btrYGuoHiax/xZa66QFGg1KZfepyyUjfrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbzXVd/btrYGuoHiax/xZa66QFGg1KZfepyyUjfrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbzXVd%2FbtrYGuoHiax%2FxZa66QFGg1KZfepyyUjfrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3552&quot; height=&quot;1806&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;1806&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins와 Github 연동시 SSH 연동 방식을 사용하는데 Credentials에 미리 생성한 Credential을 선택해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins와 Github 연동 방법은 이전 포스팅에서 다뤘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://steadycode.tistory.com/87&quot;&gt;https://steadycode.tistory.com/87&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1675924636186&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Jenkins] Jenkins Github 연동하기&quot; data-og-description=&quot;이번 포스팅에선 Jenkins와 Github간 연동 방법에 대해서 다뤄 보겠다. 공개키/비밀키 생성 실행중인 젠킨스 사용자를 확인한다. ps aux | grep jenkins 현재 사용자를 jenkins로 변경하고 shell command 실행 권&quot; data-og-host=&quot;steadycode.tistory.com&quot; data-og-source-url=&quot;https://steadycode.tistory.com/87&quot; data-og-url=&quot;https://steadycode.tistory.com/87&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bjd1iC/hyRzrjFbvG/URzoMnXLGrKtAXuu3P2ziK/img.png?width=800&amp;amp;height=71&amp;amp;face=0_0_800_71,https://scrap.kakaocdn.net/dn/cN5XSb/hyRzCeqbM3/a1KXyOR3VnYvyKVfundABK/img.png?width=800&amp;amp;height=71&amp;amp;face=0_0_800_71,https://scrap.kakaocdn.net/dn/bcb3nf/hyRzCS0C5x/bEkIayPpvgGbKRdmk4j9uK/img.png?width=1760&amp;amp;height=1812&amp;amp;face=0_0_1760_1812&quot;&gt;&lt;a href=&quot;https://steadycode.tistory.com/87&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://steadycode.tistory.com/87&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bjd1iC/hyRzrjFbvG/URzoMnXLGrKtAXuu3P2ziK/img.png?width=800&amp;amp;height=71&amp;amp;face=0_0_800_71,https://scrap.kakaocdn.net/dn/cN5XSb/hyRzCeqbM3/a1KXyOR3VnYvyKVfundABK/img.png?width=800&amp;amp;height=71&amp;amp;face=0_0_800_71,https://scrap.kakaocdn.net/dn/bcb3nf/hyRzCS0C5x/bEkIayPpvgGbKRdmk4j9uK/img.png?width=1760&amp;amp;height=1812&amp;amp;face=0_0_1760_1812');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Jenkins] Jenkins Github 연동하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Jenkins와 Github간 연동 방법에 대해서 다뤄 보겠다. 공개키/비밀키 생성 실행중인 젠킨스 사용자를 확인한다. ps aux | grep jenkins 현재 사용자를 jenkins로 변경하고 shell command 실행 권&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;steadycode.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Jenkinsfile Script 경로 지정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jenkins 서버가 빌드시 jenkinsfile에 정의된 내용대로 동작하는데 해당 script 파일의 경로를 지정해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;792&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKAki0/btrYITVwcLA/UCyUgxKzegGE2owchldIp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKAki0/btrYITVwcLA/UCyUgxKzegGE2owchldIp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKAki0/btrYITVwcLA/UCyUgxKzegGE2owchldIp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKAki0%2FbtrYITVwcLA%2FUCyUgxKzegGE2owchldIp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3552&quot; height=&quot;792&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3552&quot; data-origin-height=&quot;792&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;민감 데이터 등록&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;민감 데이터는 Github에 공개되어선 안되기 때문에 Jenkins Credential에 등록하고 Script에서 참조하도록 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Jenkins Credential 등록&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BOEEG/btrYDfSTzH4/A7SnJCPYYablXhhDLTSwvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BOEEG/btrYDfSTzH4/A7SnJCPYYablXhhDLTSwvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BOEEG/btrYDfSTzH4/A7SnJCPYYablXhhDLTSwvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBOEEG%2FbtrYDfSTzH4%2FA7SnJCPYYablXhhDLTSwvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3584&quot; height=&quot;1814&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/deRx5W/btrYIdGZVH4/usgFtxB8B8vZfDx03m2gu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/deRx5W/btrYIdGZVH4/usgFtxB8B8vZfDx03m2gu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/deRx5W/btrYIdGZVH4/usgFtxB8B8vZfDx03m2gu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdeRx5W%2FbtrYIdGZVH4%2FusgFtxB8B8vZfDx03m2gu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3584&quot; height=&quot;1814&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZlQTz/btrYBWMOCMO/nXi2zrkddnikanKtJLPNsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZlQTz/btrYBWMOCMO/nXi2zrkddnikanKtJLPNsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZlQTz/btrYBWMOCMO/nXi2zrkddnikanKtJLPNsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZlQTz%2FbtrYBWMOCMO%2FnXi2zrkddnikanKtJLPNsk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3584&quot; height=&quot;1814&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rJPju/btrYBzK5lwe/bKkY6ybmKBwGopLdduU6r0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rJPju/btrYBzK5lwe/bKkY6ybmKBwGopLdduU6r0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rJPju/btrYBzK5lwe/bKkY6ybmKBwGopLdduU6r0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrJPju%2FbtrYBzK5lwe%2FbKkY6ybmKBwGopLdduU6r0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3584&quot; height=&quot;1814&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lsquo;Secret text&amp;rsquo; 타입을 선택하고 key-value 형태로 민감 데이터를 credential로 등록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예시 처럼 Script 단에서 &lt;code&gt;credential(id)&lt;/code&gt; 형태로 전역 변수에 할당하고 &lt;code&gt;$variable&lt;/code&gt; 변수로 매핑한다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;pipeline {
    agent any
    environment {
        DEPLOY_SERVER_ID = credentials('deployServerId')
        DEPLOY_SERVER_PASSWORD = credentials('deployServerPassword')
    }

        stage('Send Jar') {
        steps {
            sh 'sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 rm -rf build/fire_inform-0.0.1-SNAPSHOT.jar'
            sh 'sshpass -p $DEPLOY_SERVER_PASSWORD scp -P 12308 -o StrictHostKeyChecking=no /var/lib/jenkins/workspace/pipeline/build/libs/fire_inform-0.0.1-SNAPSHOT.jar $DEPLOY_SERVER_ID@106.10.59.248:build'
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Jenkinsfile 작성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적으로 script를 작성해보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 경로에 Jenkinsfile을 생성한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;514&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bexHBx/btrYIqsBxMb/OJOKILKHsnTJkBNkxAqSc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bexHBx/btrYIqsBxMb/OJOKILKHsnTJkBNkxAqSc0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bexHBx/btrYIqsBxMb/OJOKILKHsnTJkBNkxAqSc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbexHBx%2FbtrYIqsBxMb%2FOJOKILKHsnTJkBNkxAqSc0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;297&quot; height=&quot;422&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;514&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins Pipeline Script에는 &amp;lsquo;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;Scripted Pipeline&lt;/span&gt;&amp;rsquo;과 &amp;lsquo;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;Declarative Pipeline&lt;/span&gt;&amp;rsquo; 두 가지 syntax가 존재하는데 &amp;lsquo;Declarative Pipeline&amp;rsquo;를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Declarative Pipeline는 지원하지 않는 플러그인이 존재하여 개발자의 자유도를 낮출 수 있지만 단순하고 가독성이 높다는 특징이 있다.&lt;/p&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;pipeline {
    agent any
    environment {
        DEPLOY_SERVER_ID = credentials(&quot;deployServerId&quot;)
        DEPLOY_SERVER_PASSWORD = credentials(&quot;deployServerPassword&quot;)
        SECRET_KEY = credentials('SECRET_KEY')
        DB_USERNAME = credentials('DB_USERNAME')
        DB_PASSWORD = credentials('DB_PASSWORD')
        DB_URL = credentials('DB_URL')
        DB_PORT = credentials('DB_PORT')
    }

    stages {
        stage(&quot;Clean Up&quot;) {
            steps {
                sh &quot;pwd&quot;
                sh &quot;rm -rf *&quot;
            }
        }

        stage(&quot;Checkout&quot;) {
            steps {
                git url: &quot;&amp;lt;https://github.com/f-lab-edu/ChatBot.git&amp;gt;&quot;, branch: &quot;main&quot;,
                credentialsId: &quot;cbf50f14-c18f-4bc0-a792-668780641040&quot;
            }
        }

        stage(&quot;Build&quot;) {
            steps {
                sh &quot;./gradlew clean build&quot;
            }
        }

        stage(&quot;Send Jar&quot;) {
            steps {
                sh 'sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 rm -rf build/fire_inform-0.0.1-SNAPSHOT.jar'
                sh 'sshpass -p $DEPLOY_SERVER_PASSWORD scp -P 12308 -o StrictHostKeyChecking=no /var/lib/jenkins/workspace/pipeline/build/libs/fire_inform-0.0.1-SNAPSHOT.jar $DEPLOY_SERVER_ID@106.10.59.248:build'
            }
        }

        stage(&quot;Send Shell Script&quot;) {
            steps {
                sh 'sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 rm -rf build/deploy.sh'
                sh 'sshpass -p $DEPLOY_SERVER_PASSWORD scp -P 12308 -o StrictHostKeyChecking=no /var/lib/jenkins/workspace/pipeline/deploy.sh $DEPLOY_SERVER_ID@106.10.59.248:build'
            }
        }

        stage(&quot;Connect Deploy Server&quot;) {
            steps {
                sh 'sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 sh build/deploy.sh'
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Clean Up 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스코드를 clone 받기 전에 비워주는 단계&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Checkout 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Github Repository에서 소스코드를 clone 받는 단계이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;credentialsId&lt;/code&gt;에 Github 연동 암호키 정보를 담은 Credential의 ID값을 입력해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DRCIL/btrYDfFn0ZE/i8er6d5Gb6G2c4NjshXOz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DRCIL/btrYDfFn0ZE/i8er6d5Gb6G2c4NjshXOz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DRCIL/btrYDfFn0ZE/i8er6d5Gb6G2c4NjshXOz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDRCIL%2FbtrYDfFn0ZE%2Fi8er6d5Gb6G2c4NjshXOz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3584&quot; height=&quot;1814&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Build 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;gradle wrapper를 이용해서 빌드한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Send Jar 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 jenkins 서버와 애플리케이션 서버를 별도로 배포했기 때문에 빌드된 jar 파일을 애플리케이션 서버로 전송한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Checkout&lt;/code&gt; 단계에서 생성된 jar의 파일 경로는 &lt;code&gt;/var/lib/jenkins/workspace/flab/build/libs/fire\_inform-0.0.1-SNAPSHOT.jar&lt;/code&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sshpass&lt;/code&gt; 는 원격 컴퓨터에 보안 접속할 때 사용하는 명령어이다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;sshpass -p 'ssh접속비밀번호' ssh 'ssh접속계정'@'ssh접속주소' '명령어'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 전송시 SCP 프로토콜을 이용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SCP(Secure Copy)는 원격 호스트 간에 파일을 전송하는 프로토콜이며 SSH기반이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;scp로 전송할 배포 서버 경로
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;-P&lt;/code&gt; : 호스트 포트 번호&lt;/li&gt;
&lt;li&gt;build : 디렉토리명&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-o StrictHostKeyChecking=no&lt;/code&gt; : 호스트간 키 인증 과정 스킵&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 이전 파일 삭제
sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 rm -rf build/fire_inform-0.0.1-SNAPSHOT.jar

# jar 파일 애플리케이션 서버로 전송
sshpass -p $DEPLOY_SERVER_PASSWORD scp -P 12308 -o StrictHostKeyChecking=no /var/lib/jenkins/workspace/pipeline/build/libs/fire_inform-0.0.1-SNAPSHOT.jar $DEPLOY_SERVER_ID@106.10.59.248:build&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Send Shell Script 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 서버에서 실행할 동작을 정의해 놓은 Shell 스크립트를 전송한다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;# 이전 파일 삭제
sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 rm -rf build/deploy.sh

# deploy.sh script 파일 전송
sshpass -p $DEPLOY_SERVER_PASSWORD scp -P 12308 -o StrictHostKeyChecking=no /var/lib/jenkins/workspace/pipeline/deploy.sh $DEPLOY_SERVER_ID@106.10.59.248:build&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전송할 script의 내용은 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;java 이름으로 동작하는 프로세스의 pid를 찾고 있다면 해당 프로세스를 종료한다.&lt;/li&gt;
&lt;li&gt;백그라운 스레드로 애플리케이션을 실행한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/bin/sh

pid=`pgrep -f java`
if [ $pid ]
then
    kill -9 $pid;
    echo '$pid process kill complete'
else
    echo 'pid is empty'
fi

sleep 5

nohup java -jar build/fire_inform-0.0.1-SNAPSHOT.jar &amp;amp;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Connect Deploy Server 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sh&lt;/code&gt; : .sh 확장자의 파일 실행&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sshpass -p $DEPLOY_SERVER_PASSWORD ssh -p 12308 $DEPLOY_SERVER_ID@106.10.59.248 sh build/deploy.sh&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins를 통해 소스 코드를 빌드하고 원격 서버에서 애플리케이션을 배포하는 방법에 대해서 알아보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;github actions 를 이용하면 git merge 시 자동으로 테스트 및 배포하는 기능도 지원하는데 추후 학습해보겠다.&lt;/p&gt;
&lt;h1&gt;소스 코드&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/ChatBot&quot;&gt;https://github.com/KIM-KYOUNG-OH/ChatBot&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jenkins.io/doc/book/pipeline/&quot;&gt;https://www.jenkins.io/doc/book/pipeline/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hwannny.tistory.com/89&quot;&gt;https://hwannny.tistory.com/89&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/442&quot;&gt;https://jojoldu.tistory.com/442&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Jenkins</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/88</guid>
      <comments>https://steadycode.tistory.com/88#entry88comment</comments>
      <pubDate>Thu, 9 Feb 2023 15:42:59 +0900</pubDate>
    </item>
    <item>
      <title>[Jenkins] Jenkins Github 연동하기</title>
      <link>https://steadycode.tistory.com/87</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Jenkins와 Github간 SSH 연동 방법에 대해서 다뤄 보겠다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSH란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Secure SHell은 네트워크 상의 원격 컴퓨터에 접속하여 명령을 실행할 수 있도록 해주는 프로토콜이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 Telnet 방식을 사용했는데 암호화를 제공하지 않기 때문에 보안상 취약하다는 단점을 보완한 방법이 SSH이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호화 방식에는 대칭키 방식과 비대칭키 방식 두가지를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비대칭키 방식은 클라이언트와 서버간 인증시 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대칭키 방식은 클라이언트와 서버간 데이터를 주고받을 때 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비대칭키 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비대칭 키 방식은 두 호스트 간에 공개키와 개인키 쌍으로 인증하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공개키는 .pub, 개인키는 .pem의 형식을 띈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공개키는 유출되어도 크게 문제가 되지 않는 키이며 공개키로 암호화된 데이터는 개인키로만 복호화할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인키는 발행한 쪽만 알고 있는 키이며 개인키로 암호화한 데이터는 공개키로만 복호화할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용자 인증 Flow&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3 way handshaking 과정으로 사용자를 인증한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;655&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XzhyI/btrYBW0P4wI/bK2exGaeg7czbJRTA9PNo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XzhyI/btrYBW0P4wI/bK2exGaeg7czbJRTA9PNo1/img.png&quot; data-alt=&quot;출처: https://medium.com/@labcloud/ssh-암호화-원리-및-aws-ssh-접속-실습-33a08fa76596&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XzhyI/btrYBW0P4wI/bK2exGaeg7czbJRTA9PNo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXzhyI%2FbtrYBW0P4wI%2FbK2exGaeg7czbJRTA9PNo1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1400&quot; height=&quot;655&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;655&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://medium.com/@labcloud/ssh-암호화-원리-및-aws-ssh-접속-실습-33a08fa76596&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트(Jenkins)가 공개키와 개인키 발행후 공개키를 서버에 전달&lt;/li&gt;
&lt;li&gt;공개키를 서버에 파일로 저장&lt;/li&gt;
&lt;li&gt;서버는 난수의 해시값을 생성&lt;/li&gt;
&lt;li&gt;공개키로 암호화한다.&lt;/li&gt;
&lt;li&gt;클라이언트에게 다시 전달한다.&lt;/li&gt;
&lt;li&gt;클라이언트는 해당 데이터를 개인키로 복호화한다.&lt;/li&gt;
&lt;li&gt;복호화한 값을 서버로 보낸다.&lt;/li&gt;
&lt;li&gt;서버는 처음 값과 응답받은 값을 비교하여 두 같이 같다면 두 호스트간의 인증이 완료된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 인증 Flow&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 과정에서 클라이언트와 서버 역할만 바꾸면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;대칭키 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대칭키 방식은 두 호스트 간에 정보를 주고받을 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대칭키 방식은 비대칭키 방식과 다르게 한 개의 키만 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대칭키로 암호화된 데이터는 대칭키로만 복호화할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 호스트간 데이터 전송 Flow&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트 인증과 서버 인증이 끝나면 내부적으로 대칭키(세션키)를 생성하여 주고 받게 된다.&lt;/li&gt;
&lt;li&gt;데이터를 대칭키로 암호화하여 상대측에 전송한다.&lt;/li&gt;
&lt;li&gt;데이터를 수신한 측은 데이터를 대칭키로 복호화한다.&lt;/li&gt;
&lt;li&gt;데이터 송신이 끝나면 대칭키를 폐기하고 다시 접속시마다 새로운 대칭키를 생성한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;공개키/개인키 생성&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행중인 젠킨스 사용자를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;ps aux | grep jenkins&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;temp1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKeZVO/btrYzKr9BF8/kf62GQkyD8kNBC7Rlo2PZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKeZVO/btrYzKr9BF8/kf62GQkyD8kNBC7Rlo2PZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKeZVO/btrYzKr9BF8/kf62GQkyD8kNBC7Rlo2PZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKeZVO%2FbtrYzKr9BF8%2Fkf62GQkyD8kNBC7Rlo2PZk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1772&quot; height=&quot;158&quot; data-filename=&quot;temp1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 사용자를 jenkins로 변경하고 shell command 실행 권한을 준다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo -u jenkins /bin/bash&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYfaHV/btrYARqQVtJ/vQcvS1XoecKm0xHx6w5tF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYfaHV/btrYARqQVtJ/vQcvS1XoecKm0xHx6w5tF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYfaHV/btrYARqQVtJ/vQcvS1XoecKm0xHx6w5tF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYfaHV%2FbtrYARqQVtJ%2FvQcvS1XoecKm0xHx6w5tF0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1772&quot; height=&quot;110&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;110&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.ssh 디렉토리를 생성한다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;mkdir /var/lib/jenkins/.ssh
cd /var/lib/jenkins/.ssh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ssh 키를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 명령어를 실행하면 공개키와 비밀키를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ssh-keygen&lt;/code&gt; 은 Linux SSH 패키지에 포함되어 있는 프로그램이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;-t&lt;/code&gt; : key 암호화 알고리즘 타입&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;-f&lt;/code&gt; : key file의 filename&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령을 실행하면 다음의 두 키가 생성된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공개키 : github_ansible-in-action.pub&lt;/li&gt;
&lt;li&gt;개인키 : github_ansible-in-action&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;ssh-keygen -t rsa -f /var/lib/jenkins/.ssh/github_ansible-in-action&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r10AA/btrYEiBdYTs/M4vHUpwuOkZRhkoyMb3IO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r10AA/btrYEiBdYTs/M4vHUpwuOkZRhkoyMb3IO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r10AA/btrYEiBdYTs/M4vHUpwuOkZRhkoyMb3IO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr10AA%2FbtrYEiBdYTs%2FM4vHUpwuOkZRhkoyMb3IO0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1772&quot; height=&quot;844&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;844&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;Github repository에 공개키 등록&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 공개키 코드값을 복사해서 github repository setting으로 이동 후 공개키를 등록한다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;cat /var/lib/jenkins/.ssh/github_ansible-in-action.pub&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;1438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mMQRY/btrYH1MxTLt/ijl0Ip3H93gvgBSqGlb9i1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mMQRY/btrYH1MxTLt/ijl0Ip3H93gvgBSqGlb9i1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mMQRY/btrYH1MxTLt/ijl0Ip3H93gvgBSqGlb9i1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmMQRY%2FbtrYH1MxTLt%2Fijl0Ip3H93gvgBSqGlb9i1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1468&quot; height=&quot;1438&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;1438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;jenkins 서버에 비밀키 등록&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 개인키 값을 jenkins credential 로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;credential 은 jenkins 서버 프로세스에서 사용할 민감 데이터를 의미한다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;cat /var/lib/jenkins/.ssh/github_ansible-in-action&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/camPbv/btrYCihcg1x/3kbpeI8KePoV7OykmZvkU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/camPbv/btrYCihcg1x/3kbpeI8KePoV7OykmZvkU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/camPbv/btrYCihcg1x/3kbpeI8KePoV7OykmZvkU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcamPbv%2FbtrYCihcg1x%2F3kbpeI8KePoV7OykmZvkU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1760&quot; height=&quot;1812&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kind
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;credential 타입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Username
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 Jenkins Job에서 보여줄 인증키 이름&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Private Key
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;타겟 value&lt;/li&gt;
&lt;li&gt;jenkins 서버에서 생성한 비밀키를 붙여넣는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;연동 테스트&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션 테스트용도로 간단한 Jenkins Item을 생성한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5TeX2/btrYzkm16gs/ZdYikRn765E9yxX2QYKb5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5TeX2/btrYzkm16gs/ZdYikRn765E9yxX2QYKb5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5TeX2/btrYzkm16gs/ZdYikRn765E9yxX2QYKb5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5TeX2%2FbtrYzkm16gs%2FZdYikRn765E9yxX2QYKb5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1760&quot; height=&quot;1812&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 내용을 입력한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;github repository url&lt;/li&gt;
&lt;li&gt;Credentials로 등록한 private key&lt;/li&gt;
&lt;li&gt;branch 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bE4AHw/btrYEiBffHs/PgoQOi5Qk2g567MXcavA01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bE4AHw/btrYEiBffHs/PgoQOi5Qk2g567MXcavA01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bE4AHw/btrYEiBffHs/PgoQOi5Qk2g567MXcavA01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbE4AHw%2FbtrYEiBffHs%2FPgoQOi5Qk2g567MXcavA01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1760&quot; height=&quot;1812&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 Build 되면 연동 성공이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;805&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yPvJR/btrYz0Iu50W/EtouccRVFWXl7d8ECfJn91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yPvJR/btrYz0Iu50W/EtouccRVFWXl7d8ECfJn91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yPvJR/btrYz0Iu50W/EtouccRVFWXl7d8ECfJn91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyPvJR%2FbtrYz0Iu50W%2FEtouccRVFWXl7d8ECfJn91%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;805&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;805&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘솔로 결과를 확인하고 싶으면 /var/lib/jenkins/workspace 로 이동해서 확인하면 된다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;$ cd /var/lib/jenkins/workspace&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 Github과 jenkins를 연동하는 방법을 알아보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에서 jenkins script를 직접 작성하여 구체적인 CI/CD 동작을 구현해보겠다.&lt;/p&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://medium.com/@labcloud/ssh-암호화-원리-및-aws-ssh-접속-실습-33a08fa76596&quot;&gt;https://medium.com/@labcloud/ssh-암호화-원리-및-aws-ssh-접속-실습-33a08fa76596&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://library.gabia.com/contents/infrahosting/9002/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://library.gabia.com/contents/infrahosting/9002/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/442&quot;&gt;https://jojoldu.tistory.com/442&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Jenkins</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/87</guid>
      <comments>https://steadycode.tistory.com/87#entry87comment</comments>
      <pubDate>Thu, 9 Feb 2023 13:15:12 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] JWT을 이용한 소셜 로그인 구현</title>
      <link>https://steadycode.tistory.com/86</link>
      <description>&lt;h1&gt;JWT이란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Json Web Token의 약자로서 암호화된 데이터를 전송하기 위한 Json 기반의 인터넷 표준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저 인증시 사용하는 토큰으로, 유저의 인증 정보를 서버가 아닌 클라이언트가 들고 있기 때문에 서버의 부담을 덜 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 유저 요청시 토큰이 유효한지만 판별하면 된다.&lt;/p&gt;
&lt;h1&gt;JWT을 쓰는 이유?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서 다룬 Session-Cookie 인증 방식은 다음과 같은 단점이 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;휘발성 메모리 영역에 저장된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;세션은 결국 메모리 위에서 동작하기 때문에 만에 하나 서버가 종료되면 세션이 초기화되고 모든 유저는 다시 로그인해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;분산 서버 환경에서 문제가 될 수 있다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;분산 서버 환경에선 서버의 상태를 stateless하게 설계해야하는데 세션&amp;amp;쿠키 인증 방식은 필연적으로 서버가 유저의 상태 정보를 유지해야한다.&lt;/li&gt;
&lt;li&gt;분산 서버 환경에서 유저가 로그인한 이력이 있더라도 다른 세션 저장소를 가진 서버가 요청을 받았기 때문에 다시 로그인해야하는 문제가 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 인증 방식은 서버가 유저 상태 정보를 들고 있지 않기 때문에 분산 서버 환경에서 유리하기 때문에 사용한다.&lt;/p&gt;
&lt;h1&gt;JWT의 구조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jwt은 header, payload, signature로 이루어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 HMAC 암호화 방식을 이용한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMAC은 임의의 시크릿 키를 포함한 암호화 방식이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;jwt.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;1432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqGaVr/btrXN2mNd81/1moEVG776hNcxzXKByyEL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqGaVr/btrXN2mNd81/1moEVG776hNcxzXKByyEL0/img.png&quot; data-alt=&quot;출처: https://jwt.io/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqGaVr/btrXN2mNd81/1moEVG776hNcxzXKByyEL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqGaVr%2FbtrXN2mNd81%2F1moEVG776hNcxzXKByyEL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;627&quot; height=&quot;507&quot; data-filename=&quot;jwt.png&quot; data-origin-width=&quot;1772&quot; data-origin-height=&quot;1432&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://jwt.io/&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;header에는 사용할 토큰 타입과 알고리즘의 종류가 담긴다.&lt;/li&gt;
&lt;li&gt;payload에는 유저 이름과 권한 정보와 같은 Claim 정보가 담긴다.&lt;/li&gt;
&lt;li&gt;signature에는 header에서 선정한 알고리즘으로 header + payload + 서버에서 생성한 secret key 를 암호화한 데이터가 담긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각은 Base64 알고리즘으로 인코딩되어있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JWT을 이용한 인증 Flow&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;jwt2.png&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;1144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c69usU/btrXOupzYlq/owrNYXjNoLhD1sG3K9nEbK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c69usU/btrXOupzYlq/owrNYXjNoLhD1sG3K9nEbK/img.png&quot; data-alt=&quot;출처: https://inpa.tistory.com/entry/WEB- -JWTjson-web-token-란- -정리#Token_인증&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c69usU/btrXOupzYlq/owrNYXjNoLhD1sG3K9nEbK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc69usU%2FbtrXOupzYlq%2FowrNYXjNoLhD1sG3K9nEbK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;590&quot; height=&quot;515&quot; data-filename=&quot;jwt2.png&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;1144&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://inpa.tistory.com/entry/WEB- -JWTjson-web-token-란- -정리#Token_인증&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트는 id, password를 이용해서 로그인한다.&lt;/li&gt;
&lt;li&gt;서버는 header, payload, signature 세가지 정보로 토큰을 생성함
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;중요한 것은 signature로, 유저가 정상 인증된 시점에 서버가 서명을 했다는 증명을 나타낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;만들어진 토큰을 클라이언트에게 전달하고 브라우저의 스토리지 영역에 토큰을 저장한다.&lt;/li&gt;
&lt;li&gt;클라이언트는 다음 요청때마다 토큰을 같이 보낸다.&lt;/li&gt;
&lt;li&gt;서버는 토큰이 유효한지 검증해보고 유효하다면 올바른 요청으로 인식하고 애플리케이션 프로세스를 진행한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 유효성 검증시 서버가 가진 시크릿 키로 signature를 다시 생성해서 요청과 일치하는지만 확인하면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;JWT의 한계&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 다음의 한계를 가진다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 데이터 길이가 길어서 네트워크 부하가 발생&lt;/li&gt;
&lt;li&gt;토큰 탈취 문제&lt;/li&gt;
&lt;li&gt;payload에 민감한 데이터를 담을 수 없다.&lt;/li&gt;
&lt;li&gt;서버쪽에서 유저의 접속 유무를 알 수 없다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Access Token &amp;amp; Refresh Token 방식&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 탈취의 위험성을 줄이기 위해 토큰의 수명을 관리하기 위해 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Access Token&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 정보가 담긴 토큰이며 수명이 30분 정도로 매우 짧다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Refresh Token&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만료된 Access Token을 재발급하기 위한 토큰으로 Access Token보다 수명이 길고 DB에서 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 결국 Refresh Token도 탈취 위험이 있기 때문에 http only, secure http, ssl을 적용해야한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h1&gt;인증 Flow&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;jwt3.png&quot; data-origin-width=&quot;2422&quot; data-origin-height=&quot;1506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cMPJoH/btrXM4kXwU7/jkzcijXTIUM85HaKGYbzlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cMPJoH/btrXM4kXwU7/jkzcijXTIUM85HaKGYbzlk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cMPJoH/btrXM4kXwU7/jkzcijXTIUM85HaKGYbzlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcMPJoH%2FbtrXM4kXwU7%2FjkzcijXTIUM85HaKGYbzlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1042&quot; height=&quot;648&quot; data-filename=&quot;jwt3.png&quot; data-origin-width=&quot;2422&quot; data-origin-height=&quot;1506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;예제 코드&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;build.gradle&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;jjwt 라이브러리를 사용했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;application.yml&lt;/h2&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;server:
  port: 8080 # 사용 포트
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  datasource:
    url: {url}
    username: {username}
    password: {password}
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update #create update none
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
      show-sql: true

  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: {id}
            client-secret: {secret}
            scope:
              - name
              - email
            client-name: Naver # 클라이언트 네임은 구글 페이스북도 대문자로 시작하더라.
            authorization-grant-type: authorization_code  # 코드를 부여받는 방식
            redirect-uri: 

        # 네이버는 OAuth2.0 공식 지원대상이 아니라서 별도로 &quot;naver&quot;라는 provider를 등록해줘야함
        # provider 등록을 안하면 &quot;clientRegistrationRepository&quot;에 &quot;naver&quot;라는 id로 등록된 provider가 없다고 에러남
        # 요청주소도 다르고, 응답 데이터도 다르기 때문이다.
        provider:
          naver:
            authorization-uri: &amp;lt;https://nid.naver.com/oauth2.0/authorize&amp;gt;
            token-uri: &amp;lt;https://nid.naver.com/oauth2.0/token&amp;gt;
            user-info-uri: &amp;lt;https://openapi.naver.com/v1/nid/me&amp;gt;
            user-name-attribute: response # 회원정보를 json의 response 키값으로 리턴해줌.

jwt:
  access-token-key: {access-token-key}
  refresh-token-key: {refresh-token-key}

app:
  oauth2:
    authorized-redirect-uri:   # 인증후 리다이렉트 주소
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;access-token-key: Access Token을 발급하기 위한 private key&lt;/li&gt;
&lt;li&gt;refresh-token-key: Refresh Token을 발급하기 위한 private key&lt;/li&gt;
&lt;li&gt;authorized-redirect-uri
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인증 이후 리다이렉트될 uri를 가리킴&lt;/li&gt;
&lt;li&gt;SPA의 경우 서버 URI와 미들웨어 URI가 달라서 CORS 이슈가 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SecurityConfig&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)  // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
@RequiredArgsConstructor
public class SecurityConfig {

    private final PrincipalOauth2UserService principalOauth2UserService;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
                        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                .antMatchers(&quot;/user/**&quot;).authenticated()
                .antMatchers(&quot;/manager/**&quot;).access(String.format(&quot;hasRole('%s') or hasRole('%s')&quot;, UserRole.ROLE_MANAGER.getRole(), UserRole.ROLE_ADMIN.getRole()))
                .antMatchers(&quot;/admin/**&quot;).access(String.format(&quot;hasRole('%s')&quot;, UserRole.ROLE_ADMIN.getRole()))
                .anyRequest().permitAll();

        http.formLogin();

        http.oauth2Login()
                .authorizationEndpoint()
                    .baseUri(&quot;/oauth2/authorization&quot;)
                    .authorizationRequestRepository(cookieAuthorizationRequestRepository)
                .and()
                .userInfoEndpoint()
                    .userService(principalOauth2UserService)
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler)
                .failureHandler(oAuth2AuthenticationFailureHandler)
                .and()
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버는 세션을 유지하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;oauth2Login()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authorizationEndpoint()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;소셜 로그인 제공사 서버에 보내는 요청에 관련된 설정이다.&lt;/li&gt;
&lt;li&gt;authorizationRequestRepository()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인가 처리 동안 OAuth2AuthorizationRequest를 유지하는 레포지토리를 설정한다.&lt;/li&gt;
&lt;li&gt;디폴트 구현체는 HttpSessionOAuth2AuthorizationRequestRepository 이지만 세션이 아닌 쿠키에 저장할 것이기 때문에 커스텀 구현체를 주입한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;userInfoEndpoint()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;userService()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성공적으로 인증한 로그인 정보를 가지고 비스니스 로직을 처리할 커스텀 구현체를 주입한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;successHandler()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인증 성공시 동작할 구현체 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;failureHandler()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인증 실패시 동작할 구현체 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;addFilterBefore()
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 요청마다 인가 작업을 처리할 filter 구현체 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;User&lt;/h2&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
@Getter
@Builder
@ToString
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;
    private String email;

    @Enumerated(EnumType.STRING)
    private UserRole UserRole;

    @Enumerated(EnumType.STRING)
    private AuthProvider authProvider;

    private String refreshToken;
    private Timestamp createDate;
    private Timestamp updateDate;

    public User() {}

    public User(Long id, String username, String password, String email, UserRole UserRole, AuthProvider authProvider, String refreshToken, Timestamp createDate, Timestamp updateDate) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.UserRole = UserRole;
        this.authProvider = authProvider;
        this.refreshToken = refreshToken;
        this.createDate = createDate;
        this.updateDate = updateDate;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CookieAuthorizationRequestRepository&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository&amp;lt;OAuth2AuthorizationRequest&amp;gt; {
    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = &quot;oauth2_auth_request&quot;;
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = &quot;redirect_uri&quot;;
    private static final int COOKIE_EXPIRE_SECONDS = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -&amp;gt; CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);

        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.loadAuthorizationRequest(request);
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        return AuthorizationRequestRepository.super.removeAuthorizationRequest(request, response);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;oauth2_auth_request 쿠키에는 Authorization Code가 Authentication Request 형태로 저장되어 있다.&lt;/li&gt;
&lt;li&gt;redirect_uri는 인증 완료후 리다이렉트할 주소 정보가 담겨있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PrincipalOAuth2UserService&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    private final UserService userService;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        UserJoinRequest userJoinRequest = parseToUserJoinRequest(userRequest, oAuth2User);

        User find = userService.findByUsername(userJoinRequest.getUsername()).orElseGet(() -&amp;gt; saveOAuthUser(userJoinRequest));

        return new PrincipalDetails(find, oAuth2User.getAttributes());
    }

    private UserJoinRequest parseToUserJoinRequest(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
        log.info(&quot;======global login======&quot;);
        log.info(&quot;getClientRegistration : {}&quot;, userRequest.getClientRegistration());
        log.info(&quot;getAccessToken : {}&quot;, userRequest.getAccessToken().getTokenValue());
        log.info(&quot;getAdditionalParameters : {}&quot;, userRequest.getAdditionalParameters());
        log.info(&quot;getAttributes : {}&quot;, oAuth2User.getAttributes());
        log.info(&quot;getAuthorities : {}&quot;, oAuth2User.getAuthorities());

        AuthProvider authProvider = AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
        Map&amp;lt;String, Object&amp;gt; attributes = oAuth2User.getAttributes();
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, attributes);
        String username = String.format(&quot;%s_%s&quot;, authProvider.getProviderId(), oAuth2UserInfo.getId());
        String password = &quot;defaultPassword&quot;;
        String email = oAuth2UserInfo.getEmail();
        return UserJoinRequest.builder()
                .username(username)
                .password(password)
                .email(email)
                .userRole(UserRole.ROLE_USER)
                .authProvider(authProvider)
                .build();
    }

    private User saveOAuthUser(UserJoinRequest userJoinRequest) {
log.info(&quot;최초 소셜 로그인이 감지되어 회원가입합니다.&quot;);
        return userService.saveUser(userJoinRequest);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Access Token의 username 정보를 바탕으로 유저 정보를 db로부터 가져온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;OAuth2AuthenticationSuccessHandler&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Value(&quot;${app.oauth2.authorized-redirect-uri}&quot;)
    private String redirectUri;
    private final TokenProvider tokenProvider;
    private final CookieAuthorizationRequestRepository authorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            log.debug(&quot;Response has already been committed&quot;);
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional&amp;lt;String&amp;gt; redirectUri = CookieUtil.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if (redirectUri.isPresent() &amp;amp;&amp;amp; !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new BadRequestException(&quot;redirect URIs are not matched&quot;);
        }
        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        // JWT 생성
        String accessToken = tokenProvider.createAccessToken(authentication);
        tokenProvider.createAndSaveRefreshToken(authentication, response);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam(&quot;accessToken&quot;, accessToken)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequest(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);
        URI authorizedUri = URI.create(redirectUri);

        return authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                &amp;amp;&amp;amp; authorizedUri.getPort() == clientRedirectUri.getPort();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Token Provider에게 토큰 생성 처리를 위임하고 생성된 토큰을 Client에게 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;OAuth2AuthenticationFailureHandler&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String targetUrl = CookieUtil.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(&quot;/&quot;);

        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam(&quot;error&quot;, exception.getLocalizedMessage().replaceAll(&quot;[\\\\[\\\\]]&quot;, &quot;&quot;))
                .build().toUriString();

        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OAuth 로그인 실패시 쿠키를 삭제하고 에러를 담아 Client에게 보낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TokenProvider&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class TokenProvider {

    private final Key accessTokenKey;
    private final Key refreshTokenKey;
    private static final LongACCESS_TOKEN_EXPIRE_TIME= 1000L * 60 * 60;
    private static final LongREFRESH_TOKEN_EXPIRE_TIME= 1000L * 60 * 60 * 24 * 7;
    private final UserRepository userRepository;

    public TokenProvider(@Value(&quot;${jwt.access-token-key}&quot;) String SECRET_KEY, @Value(&quot;${jwt.refresh-token-key}&quot;) String REFRESH_TOKEN_KEY, UserRepository userRepository) {
        this.userRepository = userRepository;
        byte[] encodedAccessTokenKey = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()).getBytes();
        this.accessTokenKey = Keys.hmacShaKeyFor(encodedAccessTokenKey);
        byte[] encodedRefreshTokenKey = Base64.getEncoder().encodeToString(REFRESH_TOKEN_KEY.getBytes()).getBytes();
        this.refreshTokenKey = Keys.hmacShaKeyFor(encodedRefreshTokenKey);
    }

    public String createAccessToken(Authentication authentication) {
        Date now = new Date();
        Date expiredDate = new Date(now.getTime() +ACCESS_TOKEN_EXPIRE_TIME);

        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();

        String username = userDetails.getUsername();
        String role = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(&quot;,&quot;));

        return Jwts.builder()
                .setSubject(username)
                .claim(&quot;role&quot;, role)
                .setIssuedAt(now)
                .setExpiration(expiredDate)
                .signWith(accessTokenKey)
                .compact();
    }

    public void createAndSaveRefreshToken(Authentication authentication, HttpServletResponse response) {
        Date now = new Date();
        Date expiredDate = new Date(now.getTime() +REFRESH_TOKEN_EXPIRE_TIME);

        String refreshToken = Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(expiredDate)
                .signWith(refreshTokenKey)
                .compact();

        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
        String username = userDetails.getUsername();
        userRepository.updateRefreshTokenByUsername(username, refreshToken);

        CookieUtil.addSameSiteCookie(response, &quot;refresh_token&quot;, refreshToken, (int) (REFRESH_TOKEN_EXPIRE_TIME/ 1000));
    }

    // Access Token 을 검사하고 얻은 정보로 Authentication 객체 생성
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);
        User find = userRepository.findByUsername(claims.getSubject()).orElseThrow(() -&amp;gt; new UsernameNotFoundException(&quot;username is not found&quot;));

        Collection&amp;lt;? extends GrantedAuthority&amp;gt; authorities = Arrays.stream(claims.get(&quot;role&quot;).toString().split(&quot;,&quot;))
                        .map(SimpleGrantedAuthority::new).collect(Collectors.toList());

        PrincipalDetails principal = new PrincipalDetails(find);

        return new UsernamePasswordAuthenticationToken(principal, &quot;&quot;, authorities);
    }

    public Boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(accessTokenKey)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
			log.info(&quot;만료된 JWT 토큰입니다.&quot;);
        } catch (UnsupportedJwtException e) {
			log.info(&quot;지원되지 않는 JWT 토큰입니다.&quot;);
        } catch (IllegalStateException e) {
			log.info(&quot;JWT 토큰이 잘못되었습니다&quot;);
        }
        return false;
    }

    // Access Token 만료시 갱신때 사용할 정보를 얻기 위해 Claim 리턴
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(accessTokenKey)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Access Token과 Refresh Token을 생성 및 검증한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/Education-Lab&quot;&gt;https://github.com/KIM-KYOUNG-OH/Education-Lab&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Reference&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=cv6syIv-8eo&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=13&quot;&gt;https://www.youtube.com/watch?v=cv6syIv-8eo&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/codingspecialist/Springboot-Security-JWT-Easy&quot;&gt;https://github.com/codingspecialist/Springboot-Security-JWT-Easy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://inpa.tistory.com/entry/WEB-&quot;&gt;https://inpa.tistory.com/entry/WEB-&lt;/a&gt;&lt;a href=&quot;https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC#Token_%EC%9D%B8%EC%A6%9D&quot;&gt; -JWTjson-web-token-란- -정리#Token_인증&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-프로젝트-구조&quot;&gt;https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-프로젝트-구조&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jwtk/jjwt&quot;&gt;https://github.com/jwtk/jjwt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://velog.io/@max9106/OAuth3&quot;&gt;https://velog.io/@max9106/OAuth3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/&quot;&gt;https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/86</guid>
      <comments>https://steadycode.tistory.com/86#entry86comment</comments>
      <pubDate>Wed, 1 Feb 2023 22:35:46 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] 소셜 로그인 구현하기(OAuth2.0, naver)</title>
      <link>https://steadycode.tistory.com/85</link>
      <description>&lt;h1&gt;OAuth란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OAuth는 유저가 따로 아이디나 비밀번호를 제공하지 않고 구글이나 네이버, 페이스북 같은 타사 인증을 대신 사용하는 표준화한 인증방식이다.&lt;/p&gt;
&lt;h1&gt;OAuth를 왜 쓸까?&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;과거 제공사별로 상이했던 인증 절차를 OAuth 프로토콜로 표준화함&lt;/li&gt;
&lt;li&gt;유저 인증은 타사에 맡기고 AccessToken만 받아서 개인 정보를 받아서 사용함(개인 정보를 DB로 관리하지 않아도 됌)&lt;/li&gt;
&lt;li&gt;유저가 별도의 아이디나 비밀번호를 기억할 필요 없음&lt;/li&gt;
&lt;li&gt;유저의 가입 부담을 줄여줌(주로 스타트업 서비스에서 유저의 진입 장벽을 낮춰줌)&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;OAuth Flow&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;oauth flow.png&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;700&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dzQLjS/btrWqP8KLlJ/mEtwNvfzHLl7jOHhfz7RBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dzQLjS/btrWqP8KLlJ/mEtwNvfzHLl7jOHhfz7RBk/img.png&quot; data-alt=&quot;출처:&amp;amp;amp;nbsp; https://www.rfc-editor.org/rfc/rfc6749#page-8&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dzQLjS/btrWqP8KLlJ/mEtwNvfzHLl7jOHhfz7RBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdzQLjS%2FbtrWqP8KLlJ%2FmEtwNvfzHLl7jOHhfz7RBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;519&quot; height=&quot;328&quot; data-filename=&quot;oauth flow.png&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;700&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처:&amp;amp;nbsp; https://www.rfc-editor.org/rfc/rfc6749#page-8&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 인증 서버에 Authorization Code 요청&lt;/li&gt;
&lt;li&gt;Authorization Code 반환&lt;/li&gt;
&lt;li&gt;Authorization Code 로 토큰 서버에 Access Token 요청&lt;/li&gt;
&lt;li&gt;Access Token 반환&lt;/li&gt;
&lt;li&gt;Access Token 으로 리소스 서버 API 호출&lt;/li&gt;
&lt;li&gt;타겟 리소스 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;Spring Security + OAuth2 사용하기&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;소셜 로그인 제공사에서 오픈 API 등록 신청&lt;/li&gt;
&lt;li&gt;oauth 의존성 추가&lt;/li&gt;
&lt;li&gt;application.yml 작성&lt;/li&gt;
&lt;li&gt;security 설정 빈 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 소셜 로그인 제공사에서 오픈 API 등록 신청&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver1.png&quot; data-origin-width=&quot;2430&quot; data-origin-height=&quot;1446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czX7gA/btrWmms0Zc3/9W4EcErCYGbmnwG2elKmLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czX7gA/btrWmms0Zc3/9W4EcErCYGbmnwG2elKmLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czX7gA/btrWmms0Zc3/9W4EcErCYGbmnwG2elKmLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczX7gA%2FbtrWmms0Zc3%2F9W4EcErCYGbmnwG2elKmLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;546&quot; height=&quot;325&quot; data-filename=&quot;naver1.png&quot; data-origin-width=&quot;2430&quot; data-origin-height=&quot;1446&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 네이버 오픈 API를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;링크 : &lt;a href=&quot;https://developers.naver.com/apps/#/register?api=nvlogin&quot;&gt;https://developers.naver.com/apps/#/register?api=nvlogin&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록이 완료후 client-id와 client-secret을 받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 Redirect URL을 등록해주어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redirect URL이란 OAuth 로그인 인증이 완료되면 다시 base URL로 돌아와야하는데 그 url 주소를 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링에서 제공하는 default redirect url template은 {baseUrl}/login/oauth2/code/{registrationId} 이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver2.png&quot; data-origin-width=&quot;1782&quot; data-origin-height=&quot;1222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zNjh1/btrWjkI5PUR/5WkXpKNQPxTKEDsOdCtuI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zNjh1/btrWjkI5PUR/5WkXpKNQPxTKEDsOdCtuI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zNjh1/btrWjkI5PUR/5WkXpKNQPxTKEDsOdCtuI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzNjh1%2FbtrWjkI5PUR%2F5WkXpKNQPxTKEDsOdCtuI0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;561&quot; height=&quot;385&quot; data-filename=&quot;naver2.png&quot; data-origin-width=&quot;1782&quot; data-origin-height=&quot;1222&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. oauth 의존성 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;dependencies {
		implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. application.yml 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이버는 OAuth2.0 공식 지원대상이 아니라서 &quot;naver&quot;라는 id로 custom provider를 등록해줘야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;provider 등록을 안하면 &quot;clientRegistrationRepository&quot;에 &quot;naver&quot;라는 id로 등록된 provider가 없다고 에러가 발생한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;security:
    oauth2:
      client:
        registration:
          naver:  # 네이버 소셜 로그인 registration id
            client-id: {client-id}
            client-secret: {client-secret}
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code  # 코드를 부여받는 방식
            redirect-uri: 

        # 네이버는 OAuth2.0 공식 지원대상이 아니라서 별도로 &quot;naver&quot;라는 provider를 등록해줘야함
        # provider 등록을 안하면 &quot;clientRegistrationRepository&quot;에 &quot;naver&quot;라는 id로 등록된 provider가 없다고 에러남
        provider:
          naver:
            authorization-uri: &amp;lt;https://nid.naver.com/oauth2.0/authorize&amp;gt;
            token-uri: &amp;lt;https://nid.naver.com/oauth2.0/token&amp;gt;
            user-info-uri: &amp;lt;https://openapi.naver.com/v1/nid/me&amp;gt;
            user-name-attribute: response # 회원정보를 json의 response 키값으로 리턴해줌.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. security 설정 빈 생성&lt;/h2&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)  // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
public class SecurityConfig {

    private final PrincipalOauth2UserService principalOauth2UserService;

    public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService) {
        this.principalOauth2UserService = principalOauth2UserService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()  // csrf 보안 설정 여부
                .authorizeRequests()  // url에 대한 인증 설정
                .antMatchers(&quot;/user/**&quot;).authenticated()  // 인증되어야만 url 정상 처리
                .antMatchers(&quot;/manager/**&quot;).access(&quot;hasRole('MANAGER') or hasRole('ADMIN')&quot;)  // 특정 권한이 있어야만 url 정상 처리
                .antMatchers(&quot;/admin/**&quot;).access(&quot;hasRole('ADMIN')&quot;)
                .anyRequest().permitAll()  // 그외는 인증을 요구하지 않음
                .and()
                .formLogin()  // spring security에서 제공하는 로그인 form을 사용함
                .and()
                .oauth2Login()  // OAuth2 인증을 사용함
                .userInfoEndpoint()  // OAuth login 성공 이후 동작 정의함
                .userService(principalOauth2UserService);  // principalOauth2UserService 커스텀 서비스에게 처리 위임
        return http.build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PrincipalOauth2UserService는 OAuth2 인증시 사용하는 커스텀 객체인데 아래 예제 코드에서 설명하겠다.&lt;/p&gt;
&lt;h1&gt;소셜 로그인 동작 Flow&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;http://{baseUrl}/oauth2/authorization/{registrationId}&amp;rdquo;을 호출하면 Spring Security Oauth2 Client는 타사에서 제공하는 로그인 페이지를 요청한다.&lt;/li&gt;
&lt;li&gt;제공사 인증 서버는 로그인이 완료되면 authorization code를 애플리케이션 서버로 전송함&lt;/li&gt;
&lt;li&gt;Authorization Code 로 Access Token 을 발급&lt;/li&gt;
&lt;li&gt;Access Token으로 제공사 리소스 서버에 사용자 정보를 요청&lt;/li&gt;
&lt;li&gt;spring security는 서버 세션안의 스프링만 별도로 사용하는 영역(Security ContextHolder)에 Access Token과 User 정보를 저장한다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;User 정보는 Authentication 타입 객체안에 OAuth2User 타입 객체에 저장된다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;클라이언트는 Access Token id 를 쿠키로 저장한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;예제 코드&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PrincipalDetails&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;자체 로그인시 UserDetails타입 객체를 사용하고 OAuth 로그인시 OAuth2User 타입 객체를 사용한다.&lt;/li&gt;
&lt;li&gt;아래 코드에선 UserDetails와 OAuth2User 두 가지 타입 모두 다중 상속 받도록 작성했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {

    private final User user;
    private Map&amp;lt;String, Object&amp;gt; attributes;

    // 로컬 로그인시 동작
    public PrincipalDetails(User user) {
        this.user = user;
    }

    // oauth 로그인시 동작
    public PrincipalDetails(User user, Map&amp;lt;String, Object&amp;gt; attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
        Collection&amp;lt;GrantedAuthority&amp;gt; collect = new ArrayList&amp;lt;&amp;gt;();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Map&amp;lt;String, Object&amp;gt; getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PrincipalOauth2UserService&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제공사 API로부터 가져온 유저 정보를 핸들링하는 Service Layer 커스텀 객체이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저 정보를 OAuth2User 타입 객체에 담아서 리턴하는 loadUser() 함수를 정의해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최초 소셜 로그인시 유저 정보를 DB에 저장하는 코드를 추가했다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    private final UserService userService;

    // &quot;/login/oauth2/code/{providerId}&quot; 요청시 자동으로 동작
    // 소셜 로그인시 결과가 OAuth2User 타입 객체에 담김
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        UserJoinRequest userJoinRequest = parseToUserJoinRequest(userRequest, oAuth2User);

        User find = userService.findByUsername(userJoinRequest.getUsername()).orElseGet(() -&amp;gt; saveOAuthUser(userJoinRequest));

        return new PrincipalDetails(find, oAuth2User.getAttributes());
    }

    private UserJoinRequest parseToUserJoinRequest(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
        log.info(&quot;getClientRegistration : {}&quot;, userRequest.getClientRegistration());
        log.info(&quot;getAccessToken : {}&quot;, userRequest.getAccessToken().getTokenValue());
        log.info(&quot;getAdditionalParameters : {}&quot;, userRequest.getAdditionalParameters());

        log.info(&quot;getAttributes : {}&quot;, oAuth2User.getAttributes());
        log.info(&quot;getAuthorities : {}&quot;, oAuth2User.getAuthorities());

        String providerId = userRequest.getClientRegistration().getRegistrationId();
        Map&amp;lt;String, Object&amp;gt; response = oAuth2User.getAttribute(&quot;response&quot;);
        String userId = (String) response.get(&quot;id&quot;);
        String username = String.format(&quot;%s_%s&quot;, providerId, userId);
        String password = &quot;defaultPassword&quot;;
        String email = (String) response.get(&quot;email&quot;);
        String role = &quot;USER&quot;;
        return UserJoinRequest.builder()
                .providerId(providerId)
                .username(username)
                .password(password)
                .email(email)
                .role(role)
                .build();
    }

    private User saveOAuthUser(UserJoinRequest userJoinRequest) {
        log.info(&quot;최초 소셜 로그인이 감지되어 회원가입합니다.&quot;);
        return userService.saveUser(userJoinRequest);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 소셜 로그인 성공시 로그에 찍히는 내용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 코드는 네이버 API만 사용했지만 제공사마다 리소스 요청 API 응답 포맷이 다르기 때문에 추상화할 필요가 있어보인다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;getClientRegistration : ClientRegistration{registrationId='naver', clientId='clientId', clientSecret='clientSecret', clientAuthenticationMethod=org.springframework.security.oauth2.core.ClientAuthenticationMethod@4fcef9d3, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='&amp;lt;http://localhost:8080/login/oauth2/code/naver&amp;gt;', scopes=[name, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@86135fc3, clientName='Naver'}
getAccessToken : {accessTokenValue}
getAdditionalParameters : {}
getAttributes : {resultcode=00, message=success, response={id=jO_Dgs23LK3fkkopR21-Asdfqw2341F, email=hong@naver.com, name=홍길동}}
getAuthorities : [ROLE_USER, SCOPE_email, SCOPE_name]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;로그인 동작 테스트&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;social2.png&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;1144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crg5ns/btrWjkoMSJu/WeVIRCpeTaSTSn7cENsv4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crg5ns/btrWjkoMSJu/WeVIRCpeTaSTSn7cENsv4K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crg5ns/btrWjkoMSJu/WeVIRCpeTaSTSn7cENsv4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcrg5ns%2FbtrWjkoMSJu%2FWeVIRCpeTaSTSn7cENsv4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;548&quot; height=&quot;387&quot; data-filename=&quot;social2.png&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;1144&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;social3.png&quot; data-origin-width=&quot;1776&quot; data-origin-height=&quot;1436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bViSUD/btrWeX8T78n/ioE0pdYx6IKFnuT1rvTNj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bViSUD/btrWeX8T78n/ioE0pdYx6IKFnuT1rvTNj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bViSUD/btrWeX8T78n/ioE0pdYx6IKFnuT1rvTNj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbViSUD%2FbtrWeX8T78n%2FioE0pdYx6IKFnuT1rvTNj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;528&quot; height=&quot;427&quot; data-filename=&quot;social3.png&quot; data-origin-width=&quot;1776&quot; data-origin-height=&quot;1436&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;social4.png&quot; data-origin-width=&quot;2326&quot; data-origin-height=&quot;826&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/luyS3/btrWppv4kC5/MuNZI6kkyYvkLCNRnFwWk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/luyS3/btrWppv4kC5/MuNZI6kkyYvkLCNRnFwWk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/luyS3/btrWppv4kC5/MuNZI6kkyYvkLCNRnFwWk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FluyS3%2FbtrWppv4kC5%2FMuNZI6kkyYvkLCNRnFwWk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;806&quot; height=&quot;286&quot; data-filename=&quot;social4.png&quot; data-origin-width=&quot;2326&quot; data-origin-height=&quot;826&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Session안에 Authentication 객체를 로그에 찍어보면 아래 처럼 정상적으로 저장된 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;/test/login==========
authentication : User(id=9, username=naver_jO_Dgs23LK3fkkopR21-Asdfqw2341F, password=$2a$10$ZqmKiZ2RDDsjGPqw89yke.btVJNgd7HScimR7weoI.UqlseYLrVZ6, email=hong@naver.com, role=USER, providerId=naver, createDate=null)
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에선 OAuth와 Spring Security를 이용한 소셜 로그인 구현에 대해서 알아보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에선 JWT에 대해서 알아보겠다.&lt;/p&gt;
&lt;h1&gt;코드 참조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/Education-Lab&quot;&gt;https://github.com/KIM-KYOUNG-OH/Education-Lab&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;refer&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://jyami.tistory.com/121&quot;&gt;https://jyami.tistory.com/121&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html&quot;&gt;https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6749#page-8&quot;&gt;https://www.rfc-editor.org/rfc/rfc6749#page-8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=6n4k8Van_HI&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=3&quot;&gt;https://www.youtube.com/watch?v=6n4k8Van_HI&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=3&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/85</guid>
      <comments>https://steadycode.tistory.com/85#entry85comment</comments>
      <pubDate>Mon, 16 Jan 2023 14:52:20 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] 웹사이트 자체 로그인 구현하기</title>
      <link>https://steadycode.tistory.com/84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 애플리케이션에 대한 인증, 권한 부여 등의 보안 기능을 제공하는 java 프레임워크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 이유로 Spring Security를 사용해보았다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대략적인 애플리케이션 수준의 보안 아키텍처를 검증된 프레임워크인 Spring Security를 통해서 간접 학습&lt;/li&gt;
&lt;li&gt;OAuth2.0 이나 JWT 같은 기술에 대한 라이브러리를 포함하고 있어서 개발 비용 절약&lt;/li&gt;
&lt;li&gt;ioc 컨테이너를 이용하여 외부 리소스와 비즈니스 로직 분리&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Spring Security란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션에 대한 인증, 권한 부여 등의 보안 기능을 제공하는 프레임워크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring security는 다음과 같은 특징을 가진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;세션-쿠키 인증 방식을 지원한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 세션 저장소에 유저에 대한 정보를 저장하고 클라이언트는 session id를 쿠키(JSESSIONID)로 들고있다가 요청할 때마다 Http Request에 쿠키를 담아 보내서 서버가 이를 검증한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Filter의 흐름에 따라 처리한다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DispatcherServlet과 Controller 사이에서 동작하는 Interceptor와 다르게 Filter는 Dispatcher Servlet으로 도달하기 전에 동작한다.&lt;/p&gt;
&lt;h1&gt;Spring Security Architecture&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a.png&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;615&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbrZjf/btrV5pwG4oR/kL1DMaZ4O1kiSUexm6pJ0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbrZjf/btrV5pwG4oR/kL1DMaZ4O1kiSUexm6pJ0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbrZjf/btrV5pwG4oR/kL1DMaZ4O1kiSUexm6pJ0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbrZjf%2FbtrV5pwG4oR%2FkL1DMaZ4O1kiSUexm6pJ0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;615&quot; data-filename=&quot;a.png&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;615&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AuthenticationFilter가 http request을 받음&lt;/li&gt;
&lt;li&gt;요청에 담긴 아이디 비밀번호를 바탕으로 UsernamePasswordAuthenticationToken을 발급
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;username과 password의 유효성 검사 같이 Filter단에서 추가 로직을 추가하려면 UsernamePasswordAuthenticationFilter 를 상속 받는 custom 객체를 생성해야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;생성된 token을 AuthenticationManager에게 전달&lt;/li&gt;
&lt;li&gt;AuthenticationManager는 AuthenticationProvider들 중 해당하는 Provider에게 실제 인증 처리를 위임한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AuthenticationProvider를 상속 받는 구현체는 authenticate 함수를 overriding해야함&lt;/li&gt;
&lt;li&gt;db에서 유저 정보를 조회해서 비밀번호가 일치하는지 확인함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;UserDetailsService가 아이디를 이용하여 DB로부터 유저 정보를 가져온다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UserDetailsService는 인터페이스이기 때문에 이를 implements 하는 클래스를 작성해야함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;UserDetailsService가 DB를 통해 조회한 데이터는 UserDetails 타입 객체에 담긴다.&lt;/li&gt;
&lt;li&gt;데이터를 Provider에게 반환&lt;/li&gt;
&lt;li&gt;Provider가 UserDetailsService로부터 반환받은 비밀번호와 Token안의 비밀번호를 비교하여 일치하지 않는 경우 BadCredentialsException을 발생시킨다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성공시 Authentication 객체를 생성하여 결과를 반환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;인증 완료된 Token을 AuthenticationFilter에게 반환하고 LoginSuccessHandler에게 전달한다.&lt;/li&gt;
&lt;li&gt;LoginSuccessHandler는 Authentication 객체를 SpringContextHolder안에 저장한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SpringContext는 spring security만을 위해 할당된 서버 session 영역이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Spring Security 사용하기&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;spring security 의존성 추가&lt;/li&gt;
&lt;li&gt;Spring Security Configuration 설정&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. spring security 의존성 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;dependencies {
		implementation 'org.springframework.boot:spring-boot-starter-security'
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Spring Security Configuration 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring security 3.2부터는 자바 빈 설정을 지원한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)  // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()  // csrf 보안 설정 여부
                .authorizeRequests()  // url에 대한 인증 설정
                .antMatchers(&quot;/user/**&quot;).authenticated()  // 인증되어야만 url 정상 처리
                .antMatchers(&quot;/manager/**&quot;).access(&quot;hasRole('MANAGER') or hasRole('ADMIN')&quot;)  // 특정 권한이 있어야만 url 정상 처리
                .antMatchers(&quot;/admin/**&quot;).access(&quot;hasRole('ADMIN')&quot;)
                .anyRequest().permitAll()  // 그외는 인증을 요구하지 않음
                .and()
                .formLogin();  // spring security에서 제공하는 로그인 form을 사용함
        return http.build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;예제 코드&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PrincipalDetails&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;자체 로그인시 UserDetails타입 객체를 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;import com.project.educationLab.domain.user.entity.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Getter
public class PrincipalDetails implements UserDetails {
    
    private final User user;

    public PrincipalUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection&amp;lt;? extends GrantedAuthority&amp;gt; getAuthorities() {
        Collection&amp;lt;GrantedAuthority&amp;gt; collect = new ArrayList&amp;lt;&amp;gt;();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PrincipalDetailsService&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자체 로그인시 동작하는 UserDetailsService타입 서비스 객체이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;loadUserByUsername() 메서드를 오버라이딩한다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;package com.project.educationLab.domain.auth.service;

import com.project.educationLab.domain.auth.dto.PrincipalDetails;
import com.project.educationLab.domain.user.entity.User;
import com.project.educationLab.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    
    private final UserRepository userRepository;

    // &quot;/login&quot; 요청시 자동으로 동작
    // 로컬 로그인시 결과가 UserDetails 타입 객체에 담김
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User find = userRepository.findByUsername(username).orElseThrow(() -&amp;gt;
                new UsernameNotFoundException(&quot;username is not found&quot;));
        return new PrincipalDetails(find);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 세션 정보 조회 방법&lt;/h2&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Controller
@RequiredArgsConstructor
@Slf4j
public class AuthController {

    @GetMapping(&quot;/test/login&quot;)
    public @ResponseBody String testLogin(Authentication authentication) {
        log.info(&quot;/test/login==========&quot;);
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        log.info(&quot;authentication : {}&quot;, principalDetails.getUser());
        return &quot;세션 정보 확인하기&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 방법으로 서버 세션안의 Authentication안의 UserDetails 타입 객체의 유저 정보를 조회할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;gt;&amp;gt; /test/login==========
&amp;gt;&amp;gt; authentication : User(id=12, username=testUser, password=$2a$10$BW1qmL3FW5nuRs2dHYoEIOZxgM9goOGoSa.vJlyFNfu4z7mIztC.6, email=test@naver.com, role=USER, providerId=null, createDate=null)&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;로그인 동작 테스트&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;b.png&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqFgHx/btrV1HSENEf/ApwtK2O2k5Vt9Z55VIrwn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqFgHx/btrV1HSENEf/ApwtK2O2k5Vt9Z55VIrwn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqFgHx/btrV1HSENEf/ApwtK2O2k5Vt9Z55VIrwn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqFgHx%2FbtrV1HSENEf%2FApwtK2O2k5Vt9Z55VIrwn1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;508&quot; height=&quot;322&quot; data-filename=&quot;b.png&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 로그인후 브라우저의 메모리 영역에 JSESSIONID라는 이름의 쿠키가 저장된 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;c.png&quot; data-origin-width=&quot;2766&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Rr9SY/btrV7egx8Is/kBpTuqyxSFDWXg6A2h7Id1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Rr9SY/btrV7egx8Is/kBpTuqyxSFDWXg6A2h7Id1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Rr9SY/btrV7egx8Is/kBpTuqyxSFDWXg6A2h7Id1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRr9SY%2FbtrV7egx8Is%2FkBpTuqyxSFDWXg6A2h7Id1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2766&quot; height=&quot;808&quot; data-filename=&quot;c.png&quot; data-origin-width=&quot;2766&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에선 Spring Security와 자체 로그인을 구현하는 법을 간단히 알아보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에선 OAuth2를 이용한 소셜 로그인 구현 방법에 대해서 다뤄보겠다.&lt;/p&gt;
&lt;h1&gt;코드 참조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/KIM-KYOUNG-OH/Education-Lab&quot;&gt;https://github.com/KIM-KYOUNG-OH/Education-Lab&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=GEv_hw0VOxE&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=1&quot;&gt;https://www.youtube.com/watch?v=GEv_hw0VOxE&amp;amp;list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&amp;amp;index=1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=aEk-7RjBKwQ&quot;&gt;https://www.youtube.com/watch?v=aEk-7RjBKwQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mangkyu.tistory.com/76&quot;&gt;https://mangkyu.tistory.com/76&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sjh836.tistory.com/165&quot;&gt;https://sjh836.tistory.com/165&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/84</guid>
      <comments>https://steadycode.tistory.com/84#entry84comment</comments>
      <pubDate>Thu, 12 Jan 2023 18:30:57 +0900</pubDate>
    </item>
    <item>
      <title>[Java] CentOS 환경에서 Java 설치</title>
      <link>https://steadycode.tistory.com/83</link>
      <description>&lt;h1&gt;yum 업데이트&lt;/h1&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;$ yum update
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;java 설치&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JRE와 JDK는 별도의 패키지이다&lt;/li&gt;
&lt;li&gt;java-버전-openjdk는 JRE&lt;/li&gt;
&lt;li&gt;java-버전-openjdk-devel은 JDK&lt;/li&gt;
&lt;li&gt;JDK안에 JRE가 포함되있으므로 JDK를 받자&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JRE 설치 확인&lt;/h2&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;$ java -version
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;$ yum list installed java*openjdk
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JDK 설치 확인&lt;/h2&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;$ javac -version
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;$ yum list installed java*jdk-devel
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설치할 JDK 찾기&lt;/h2&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;$ yum list java*jdk-devel
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JDK 설치&lt;/h2&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;$ yum install java-X.X.X-openjdk-devel.x86_64
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;$JAVA_HOME 환경변수 설정&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;$JAVA_HOME 환경 변수에 사용할 JDK의 경로를 담는다&lt;/li&gt;
&lt;li&gt;$JAVA_HOME을 설정해주지 않으면 javac를 호출할 때마다 앞에 JDK의 경로를 입력해주어야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;$JAVA_HOME: javac 명령어를 사용할 때 여러 JDK 중에 어떤 것을 사용할 것인지 경로를 지정해주는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;$JAVA_HOME 값 확인&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;echo 명령어는 특정 text를 출력하는 명령어이다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;$ echo $JAVA_HOME
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;javac 위치 확인&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;which는 특정 명령어의 경로를 찾는 명령어이다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;$ which javac
/usr/bin/javac
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;readlink는 심볼릭 링크에 연결된 파일 경로를 찾는 명령어이다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;심볼릭 링크는 바로가기이므로 여러 depth로 연결되있다&lt;/li&gt;
&lt;li&gt;-f 플래그로 재귀적으로 탐색해서 원본 파일 경로를 찾는다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ readlink -f /usr/bin/javac
/usr/lib/jvm/java-11-openjdk-11.0.14.1.1-1.el7_9.x86_64/bin/javac
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;$JAVA_HOME 환경변수 설정&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/etc/profile: root 계정(모든 사용자)에 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ vi /etc/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JAVA_HOME 변수에 jdk의 경로를 지정하여 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-11.0.14.1.1-1.el7_9.x86_64
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;source 명령어로 변경 내용 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ source /etc/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;$JAVA_HOME 동작 확인&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ echo $JAVA_HOME
/usr/lib/jvm/java-11-openjdk-11.0.14.1.1-1.el7_9.x86_64
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ $JAVA_HOME/bin/javac -version
javac 11.0.14.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;$PATH 설정&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;$PATH: 명령어를 실행할 수 있는 경로들의 집합&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ vi /etc/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;$JAVA_HOME/bin 경로를 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;PATH=$PATH:$JAVA_HOME/bin
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;source 명령어로 변경 내용 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ source /etc/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;$PATH 동작 확인&lt;/h2&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin:/usr/lib/jvm/java-11-openjdk-11.0.14.1.1-1.el7_9.x86_64/bin
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ javac -version
javac 11.0.14.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://zetawiki.com/wiki/CentOS_JDK_설치&quot;&gt;https://zetawiki.com/wiki/CentOS_JDK_설치&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Java</category>
      <author>헝그리개발자</author>
      <guid isPermaLink="true">https://steadycode.tistory.com/83</guid>
      <comments>https://steadycode.tistory.com/83#entry83comment</comments>
      <pubDate>Tue, 19 Apr 2022 19:15:40 +0900</pubDate>
    </item>
  </channel>
</rss>