<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jang_mut_gae.log</title>
        <link>https://velog.io/</link>
        <description>잘 부탁드립니다!</description>
        <lastBuildDate>Tue, 20 Oct 2026 01:18:51 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jang_mut_gae.log</title>
            <url>https://velog.velcdn.com/images/jang_mut_gae/profile/31f542ab-6af3-4da8-b594-1ede690c1115/image.jfif</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jang_mut_gae.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jang_mut_gae" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[냉토리 프로젝트 고도화: 소셜 로그인부터 AWS 운영 환경까지]]></title>
            <link>https://velog.io/@jang_mut_gae/%EB%83%89%ED%86%A0%EB%A6%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B3%A0%EB%8F%84%ED%99%94-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%B6%80%ED%84%B0-AWS-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jang_mut_gae/%EB%83%89%ED%86%A0%EB%A6%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B3%A0%EB%8F%84%ED%99%94-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%B6%80%ED%84%B0-AWS-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 20 Oct 2026 01:18:51 GMT</pubDate>
            <description><![CDATA[<p>냉토리는 사용자가 냉장고에 보관 중인 식재료를 등록하면, 해당 재료로 만들 수 있는 레시피를 추천해주는 웹 서비스로 이번에 만들어본 개인 프로젝트이다.
냉토리 - 냉털(냉장고 털기)을 좀 귀엽게 바꿔보았다 ㅎㅎ...</p>
<p>처음 프로젝트를 만들었을 때는 로그인이나 회원 개념이 없었다. 모든 사용자가 하나의 냉장고 데이터를 공유했고, Spring Boot 애플리케이션도 EC2에 접속해서 java -jar 명령어로 직접 실행했다.
기능 자체는 동작했지만, 실제 서비스라고 보기에는 몇 가지 문제가 있었다.</p>
<ul>
<li>사용자마다 냉장고 식재료를 따로 저장할 수 없었다.</li>
<li>로그인과 권한 구분이 없었다.</li>
<li>EC2가 재부팅되면 애플리케이션을 직접 다시 실행해야 했다.</li>
<li>SSH 연결을 종료한 뒤 애플리케이션 상태를 관리하기 어려웠다.</li>
<li>서버 로그를 확인하려면 EC2에 직접 접속해야 했다.</li>
<li>로컬과 AWS 환경의 OAuth2 설정을 구분하지 않았다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/61650046-6c1e-4729-a06c-0eccfd4d5cde/image.png" alt=""></p>
<p>이번 고도화에서는 다음 내용을 구현했다.</p>
<ol>
<li>카카오·네이버 소셜 로그인</li>
<li>로그인한 사용자 정보 저장</li>
<li>사용자별 냉장고 식재료 분리</li>
<li>프로필 조회 및 닉네임 수정</li>
<li>레시피 검색 및 인기 레시피</li>
<li>systemd를 이용한 애플리케이션 자동 실행</li>
<li>CloudWatch를 이용한 로그 수집</li>
<li>운영 환경의 Secret 분리</li>
</ol>
<p>사용한 기술
<strong>Backend</strong></p>
<ul>
<li>Java 17</li>
<li>Spring Boot 4.1.1</li>
<li>Spring Security</li>
<li>OAuth2 Client</li>
<li>Spring Data JPA</li>
<li>Hibernate</li>
<li>Bean Validation</li>
<li>Flyway</li>
<li>Gradle</li>
</ul>
<p><strong>Database</strong></p>
<ul>
<li>PostgreSQL 17</li>
<li>Amazon RDS</li>
</ul>
<p><strong>Frontend</strong></p>
<ul>
<li>HTML</li>
<li>CSS</li>
<li>Vanilla JavaScript</li>
</ul>
<p><strong>Infrastructure</strong></p>
<ul>
<li>Amazon EC2</li>
<li>Amazon RDS</li>
<li>IAM</li>
<li>CloudWatch Logs</li>
<li>systemd</li>
</ul>
<ol>
<li>전체 아키텍처</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/733b62df-e922-4709-96ff-b37f0c58b148/image.png" alt=""></p>
<p>프론트엔드는 별도 서버에 배포하지 않았다.
Gradle의 processResources에서 프론트엔드 디렉터리를 Spring Boot의 정적 리소스에 포함하도록 설정했다.</p>
<pre><code>tasks.named(&#39;processResources&#39;) {
    from(&#39;../frontend&#39;) {
        into &#39;static&#39;
    }
}</code></pre><p>따라서 bootJar를 실행하면 백엔드와 프론트엔드가 하나의 JAR로 만들어진다.</p>
<blockquote>
<p>사용자
  ↓
EC2의 8080 포트
  ↓
Spring Boot
  ├─ REST API
  └─ index.html, CSS, JavaScript</p>
</blockquote>
<p>소규모 프로젝트이기 때문에 프론트엔드와 백엔드를 별도로 배포하지 않고 하나의 JAR로 관리하는 방식을 선택했다.</p>
<ol start="2">
<li>이메일이 아닌 Provider ID로 회원을 식별한 이유
소셜 로그인을 구현하기 전에는 이메일을 회원 식별자로 사용하면 되지 않을까 생각했다.
하지만 실제 카카오 개발자 콘솔을 설정하면서 이메일은 항상 받을 수 있는 값이 아니라는 것을 알게 됐다.
사용자가 이메일 제공에 동의하지 않을 수도 있고, 카카오 앱의 상태에 따라 이메일 권한 자체를 사용할 수 없는 경우도 있었다. 
<img src="https://velog.velcdn.com/images/jang_mut_gae/post/bf95f2f6-4cf2-4aa8-8287-acd255c17bd4/image.png" alt=""></li>
</ol>
<p>따라서 냉토리에서는 이메일이 아닌 다음 조합을 회원 식별 기준으로 사용했다.
provider + provider_user_id
예를 들면 다음과 같다.
KAKAO + 123456789
NAVER + abcdefg
provider_user_id는 각 OAuth Provider가 제공하는 고유 사용자 ID다.
이메일은 사용자 정보로 저장하지만, 회원을 찾는 핵심 키로 사용하지 않았다.
카카오와 네이버에서 동일한 이메일을 받더라도 자동으로 같은 계정으로 합치지 않았다. 서로 다른 소셜 계정을 하나로 연결하려면 추가적인 본인 확인 절차와 계정 연결 정책이 필요하기 때문이다.</p>
<ol start="3">
<li><p>회원 테이블 설계
회원 정보를 저장하기 위해 members 테이블을 추가했다.</p>
<pre><code class="language-mermaid">ERD
 MEMBERS ||--o{ FRIDGE_INGREDIENTS : owns
 INGREDIENTS ||--o{ FRIDGE_INGREDIENTS : contains
 RECIPES ||--o{ RECIPE_INGREDIENTS : requires
 INGREDIENTS ||--o{ RECIPE_INGREDIENTS : used_in

 MEMBERS {
     bigint id PK
     varchar provider
     varchar provider_user_id
     varchar email
     varchar nickname
     varchar profile_image_url
     varchar role
     timestamptz created_at
     timestamptz updated_at
     timestamptz last_login_at
 }

 FRIDGE_INGREDIENTS {
     bigint id PK
     bigint member_id FK
     bigint ingredient_id FK
 }

 INGREDIENTS {
     bigint id PK
     varchar name
 }

 RECIPES {
     bigint id PK
     varchar name
     bigint view_count
 }

 RECIPE_INGREDIENTS {
     bigint recipe_id FK
     bigint ingredient_id FK
 }</code></pre>
<p>members 테이블에는 다음 정보를 저장한다.</p>
</li>
</ol>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>id</code></td>
<td>냉토리 내부 회원 ID</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>소셜 로그인 제공자 (<code>KAKAO</code>, <code>NAVER</code>)</td>
</tr>
<tr>
<td><code>provider_user_id</code></td>
<td>소셜 서비스에서 제공한 고유 사용자 ID</td>
</tr>
<tr>
<td><code>email</code></td>
<td>소셜 서비스에서 제공받은 이메일</td>
</tr>
<tr>
<td><code>nickname</code></td>
<td>화면에 표시할 닉네임</td>
</tr>
<tr>
<td><code>profile_image_url</code></td>
<td>프로필 이미지 주소</td>
</tr>
<tr>
<td><code>role</code></td>
<td>회원 권한</td>
</tr>
<tr>
<td><code>created_at</code></td>
<td>최초 가입 시각</td>
</tr>
<tr>
<td><code>updated_at</code></td>
<td>회원 정보가 마지막으로 수정된 시각</td>
</tr>
<tr>
<td><code>last_login_at</code></td>
<td>최근 로그인 시각</td>
</tr>
</tbody></table>
<p>다음 조합에는 유니크 제약조건을 적용했다.
(provider, provider_user_id)
동일한 사용자가 다시 로그인했을 때 새로운 회원을 만들지 않고 기존 회원을 조회하기 위해서!</p>
<ol start="3">
<li><p>OAuth2 로그인 흐름
Spring Security의 OAuth2 Client를 사용해 로그인 기능을 구현했다.
전체 흐름은 다음과 같다.</p>
<pre><code class="language-mermaid">sequenceDiagram
 participant User as 사용자
 participant App as 냉토리
 participant Provider as 카카오·네이버
 participant DB as PostgreSQL

 User-&gt;&gt;App: 소셜 로그인 요청
 App-&gt;&gt;Provider: OAuth2 인증 요청
 Provider-&gt;&gt;User: 로그인 및 정보 제공 동의
 Provider-&gt;&gt;App: Authorization Code 전달
 App-&gt;&gt;Provider: Access Token 요청
 Provider-&gt;&gt;App: 사용자 정보 반환
 App-&gt;&gt;DB: provider + providerUserId 조회

 alt 기존 회원
     DB--&gt;&gt;App: 기존 회원 반환
     App-&gt;&gt;DB: 최근 로그인 시각 갱신
 else 신규 회원
     App-&gt;&gt;DB: 신규 회원 저장
 end

 App--&gt;&gt;User: 로그인 세션 생성</code></pre>
<p>카카오와 네이버는 사용자 정보 응답 구조가 다르다.
카카오 응답은 kakao_account, profile처럼 중첩된 구조를 사용하고, 네이버 응답은 response 내부에 사용자 정보가 들어 있다.
따라서 Provider별 Mapper를 분리했다.</p>
</li>
</ol>
<p>security/oauth2
├─ CustomOAuth2UserService
├─ KakaoOAuth2UserInfoMapper
├─ NaverOAuth2UserInfoMapper
├─ OAuth2UserInfo
└─ OAuth2Constants</p>
<p>각 Mapper는 서로 다른 JSON 응답을 공통 객체인 OAuth2UserInfo로 변환한다.</p>
<pre><code>public record OAuth2UserInfo(
        SocialProvider provider,
        String providerUserId,
        String email,
        String nickname,
        String profileImageUrl
) {
}
</code></pre><p>CustomOAuth2UserService는 변환된 정보를 MemberService에 전달한다.</p>
<pre><code>memberService.findOrCreateMember(
        userInfo.provider(),
        userInfo.providerUserId(),
        userInfo.email(),
        userInfo.nickname(),
        userInfo.profileImageUrl()
);</code></pre><p>기존 회원이면 조회해서 로그인 시각을 갱신하고, 없으면 새 회원을 저장한다. 결과적으로 조회 후 생성하는 Upsert와 비슷한 흐름이 됐다.</p>
<ol start="5">
<li>인증 API와 프론트엔드 연동</li>
</ol>
<p>소셜 로그인 이후 프론트엔드가 로그인 상태를 확인할 수 있도록 인증 API를 추가했다.</p>
<ul>
<li>GET /api/auth/me: 현재 로그인 회원 조회</li>
<li>PATCH /api/auth/me: 닉네임 수정</li>
<li>GET /api/auth/csrf: CSRF 토큰 조회</li>
<li>POST /api/auth/logout: 현재 세션 로그아웃</li>
</ul>
<p>프론트엔드에서는 로그인 상태에 따라 화면을 다르게 표시했다.
비로그인 사용자는 카카오·네이버 로그인 버튼을 볼 수 있고, 로그인한 사용자는 닉네임과 프로필 이미지, 프로필 편집 및 로그아웃 기능을 사용할 수 있다.
레시피 검색과 인기 레시피는 비로그인 사용자도 사용할 수 있지만, 개인 냉장고 관리는 로그인 사용자만 사용할 수 있도록 구분했다.</p>
<ol start="6">
<li>사용자별 냉장고 데이터 분리
초기 냉토리에는 회원 개념이 없었다.
fridge_ingredients 테이블에는 식재료 ID만 있었기 때문에 모든 사용자가 동일한 냉장고 데이터를 조회했다.
기존 구조는 다음과 같았다.</li>
</ol>
<p>fridge_ingredients
├─ id
└─ ingredient_id</p>
<p>이를 다음과 같이 변경했다.</p>
<p>fridge_ingredients
├─ id
├─ member_id
└─ ingredient_id</p>
<p>이후 냉장고 데이터를 조회할 때 현재 로그인한 회원 ID를 조건으로 사용한다.
WHERE member_id = 현재 로그인 회원 ID
다음 복합 유니크 제약조건도 추가했다.
UNIQUE(member_id, ingredient_id)</p>
<ol start="7">
<li>첫 번째 시행착오: Flyway V5 마이그레이션 실패
사용자별 냉장고 구조를 적용하기 위해 V5 Flyway 마이그레이션을 작성했다.
기존 냉장고 데이터를 삭제하고 사용자별 구조로 전환했다.<pre><code>ALTER TABLE fridge_ingredients
 ADD COLUMN member_id BIGINT;
</code></pre></li>
</ol>
<p>ALTER TABLE fridge_ingredients
    DROP CONSTRAINT uq_fridge_ingredients_ingredient;</p>
<p>DELETE FROM fridge_ingredients;</p>
<p>ALTER TABLE fridge_ingredients
    ALTER COLUMN member_id SET NOT NULL;</p>
<p>ALTER TABLE fridge_ingredients
    ADD CONSTRAINT fk_fridge_ingredients_member
        FOREIGN KEY (member_id) REFERENCES members (id),
    ADD CONSTRAINT uq_fridge_ingredients_member_ingredient
        UNIQUE (member_id, ingredient_id);</p>
<pre><code>레시피 400개와 전체 식재료 목록은 유지하고, 기존 공용 냉장고 데이터만 삭제했다.
이번 문제를 통해 마이그레이션에서는 새로운 테이블 구조뿐 아니라 기존 데이터가 새로운 제약조건을 만족할 수 있는지도 확인해야 한다는 것을 배웠다.

8. 레시피 검색과 인기 레시피
기존에는 냉장고 속 식재료를 기반으로 추천 레시피만 제공했다.
고도화 이후에는 다음 기능을 추가했다.
- 레시피 검색
- 인기 레시피 조회
- 레시피 상세 팝업
- 레시피 조회수 저장
사용자가 레시피 상세 정보를 열면 해당 레시피의 조회수를 증가시키고, 인기 레시피 API는 조회수가 높은 순서로 레시피를 반환한다.
비로그인 사용자도 검색과 인기 레시피를 사용할 수 있게 했다. 개인 데이터인 냉장고 기능과 공개 데이터인 레시피 기능의 접근 권한을 구분한 것이다.
기존에는 레시피 상세 내용이 페이지 아래쪽에 표시돼 사용자가 스크롤을 내려야 했다. 이를 팝업 형태로 변경해 현재 화면을 유지하면서 상세 내용을 확인할 수 있도록 개선했다.

9. 카카오 로그인 시행착오
KOE004: 카카오 로그인 비활성화
처음 카카오 로그인을 실행했을 때 다음 오류가 발생했다.

&gt; 앱 관리자 설정 오류 (KOE004)

코드나 Client ID 문제라고 생각했지만, 원인은 카카오 개발자 콘솔에서 로그인 기능을 활성화하지 않은 것이었다.
카카오 로그인 → 일반 → 활성화 ON
![](https://velog.velcdn.com/images/jang_mut_gae/post/a169672a-9655-49fb-87d3-74093d0fc0d0/image.png)
설정을 활성화한 후 인증 화면에 접근할 수 있었다.

KOE006: Redirect URI 불일치
로컬 로그인은 정상 작동했지만 AWS에 배포한 뒤 다음 오류가 발생했다.
앱 관리자 설정 오류 (KOE006)
로컬에서 등록한 Callback URI는 다음과 같았다.
http://localhost:8080/login/oauth2/code/kakao
하지만 EC2에서 실제로 사용하는 주소는 달랐다.
http://EC2-PUBLIC-DNS:8080/login/oauth2/code/kakao
OAuth2 Redirect URI는 프로토콜, 도메인, 포트, 경로가 모두 정확히 일치해야 한다.
또한 EC2에 IP 주소로 접속하면 Spring Security가 IP 기반 Callback URI를 생성하고, Public DNS로 접속하면 DNS 기반 Callback URI를 생성한다.
따라서 접속 주소도 하나로 통일해야 했다.

이 과정을 통해 로컬과 배포 환경의 OAuth2 설정을 분리해야 하고, 운영 환경에서는 변하지 않는 도메인과 HTTPS가 필요하다는 것을 알게 됐다.

10. EC2 SSH 접속이 멈췄던 문제
EC2를 다시 시작한 후 SSH로 접속하려 했지만 터미널이 다음 위치에서 멈췄다.
Connecting to EC2_ADDRESS port 22
처음에는 키 파일이 잘못됐다고 생각했다.
원인을 확인하기 위해 SSH 상세 로그를 활성화했다.
ssh -vvv -o ConnectTimeout=10 \
  -i naengtori-key-seoul.pem \
  ec2-user@EC2_ADDRESS
로그를 확인해보니 키 인증 단계까지 도달하지 못하고 22번 포트 연결 단계에서 멈추고 있었다.
현재 공인 IP를 조회했다.
curl https://checkip.amazonaws.com
보안 그룹에 등록된 IP와 현재 IP가 달랐다.
보안 그룹의 SSH 소스를 현재 IP로 변경한 후 접속할 수 있었다.
이번 문제를 통해 SSH 장애도 다음 단계로 나눠서 봐야 한다는 것을 배웠다.
&gt; DNS 조회
→ 포트 연결
→ 키 교환
→ 사용자 인증

11. java -jar 실행에서 systemd로
초기 배포에서는 EC2에 SSH로 접속한 뒤 애플리케이션을 직접 실행했다.
java -jar /home/ec2-user/naengtori.jar
이 방식은 간단하지만 운영에는 문제가 있었다.
- EC2 재부팅 후 다시 실행해야 한다.
- 프로세스가 비정상 종료돼도 자동 복구되지 않는다.
- 실행 환경변수를 매번 설정해야 한다.
- 로그 관리가 어렵다.
이를 해결하기 위해 애플리케이션을 systemd 서비스로 등록했다.

[Unit]
Description=Naengtori Spring Boot Application
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=ec2-user
Group=ec2-user
WorkingDirectory=/home/ec2-user
EnvironmentFile=/etc/naengtori/naengtori.env
ExecStart=/usr/bin/java -jar /home/ec2-user/naengtori.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=naengtori

[Install]
WantedBy=multi-user.target

주요 설정은 다음과 같다.
EnvironmentFile
DB 비밀번호와 OAuth Client Secret을 코드에 저장하지 않고 별도 파일에서 읽는다.
Restart=on-failure
애플리케이션이 오류로 종료되면 systemd가 10초 후 다시 실행한다.
StandardOutput=journal
Spring Boot의 표준 출력과 오류 로그를 journald에 저장한다.
WantedBy=multi-user.target
EC2가 부팅될 때 애플리케이션을 자동으로 실행한다.
등록 후 다음 명령으로 자동 실행을 활성화했다.</code></pre><p>sudo systemctl enable naengtori</p>
<pre><code>이제 SSH를 종료하더라도 애플리케이션은 계속 실행되고, EC2를 재부팅하면 자동으로 다시 시작된다.
단, systemd가 EC2 자체를 실행해주는 것은 아니다. EC2가 중지 상태라면 웹 서비스도 중지되고, EC2가 시작됐을 때 systemd가 애플리케이션을 자동 실행한다.

12. Secret을 코드에서 분리하기
DB 비밀번호와 OAuth Client Secret은 Git이나 application.yml에 저장하지 않았다.
EC2에 다음 환경변수 파일을 만들었다.
/etc/naengtori/naengtori.env
권한은 root만 읽고 쓸 수 있도록 제한했다.
sudo chmod 600 /etc/naengtori/naengtori.env
파일에는 다음 종류의 값이 들어간다.
**DB 비밀번호
RDS 연결 주소
카카오 Client ID와 Client Secret
네이버 Client ID와 Client Secret
운영 환경 JPA 설정**
운영 환경에서는 SQL이 CloudWatch에 과도하게 기록되지 않도록 다음 설정도 적용했다.
SPRING_JPA_SHOW_SQL=false
현재는 EC2의 root 전용 환경변수 파일을 사용하고 있다. 다음 단계에서는 AWS Systems Manager Parameter Store를 적용해 Secret을 EC2 파일에서도 분리할 계획이다.

13. CloudWatch 로그 수집 구조
EC2 내부 로그만으로는 장애가 발생할 때마다 SSH로 접속해야 한다.
이를 개선하기 위해 CloudWatch Agent를 설치했다.
로그 흐름은 다음과 같다.
Spring Boot
→ systemd
→ journald
→ CloudWatch Agent
→ CloudWatch Logs
EC2가 CloudWatch에 로그를 전송할 수 있도록 IAM 역할도 생성했다.
NaengtoriEc2Role
└─ CloudWatchAgentServerPolicy

EC2의 journald에는 애플리케이션 로그뿐 아니라 SSH, 네트워크, systemd 등 다양한 시스템 로그가 함께 저장된다. 모든 로그를 CloudWatch로 전송하면 필요한 로그를 찾기 어렵고 전송량과 저장 비용도 증가할 수 있다. 
따라서 CloudWatch Agent의 수집 대상을 **naengtori.service**로 제한해 냉토리 애플리케이션 로그만 전송하도록 구성했다.
</code></pre><p>{
  &quot;logs&quot;: {
    &quot;logs_collected&quot;: {
      &quot;journald&quot;: {
        &quot;collect_list&quot;: [
          {
            &quot;log_group_name&quot;: &quot;/naengtori/prod/application&quot;,
            &quot;log_stream_name&quot;: &quot;{instance_id}&quot;,
            &quot;retention_in_days&quot;: 7,
            &quot;units&quot;: [
              &quot;naengtori.service&quot;
            ]
          }
        ]
      }
    }
  }
}</p>
<p>```
CloudWatch 로그 그룹은 다음 이름을 사용했다.
/naengtori/prod/application
로그 보존 기간은 소규모 프로젝트의 비용을 고려해 7일로 지정했다.</p>
<ol>
<li><p>애플리케이션에서 로그가 생성됐는가?</p>
</li>
<li><p>Agent가 해당 로그를 수집했는가?</p>
</li>
<li><p>CloudWatch로 전송됐는가?</p>
</li>
<li><p>최종적으로 달라진 구조</p>
</li>
</ol>
<table>
<thead>
<tr>
<th>구분</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>사용자</td>
<td>사용자 개념 없음</td>
<td>카카오·네이버 회원</td>
</tr>
<tr>
<td>회원 식별</td>
<td>식별 기준 없음</td>
<td><code>Provider + Provider 사용자 ID</code></td>
</tr>
<tr>
<td>냉장고</td>
<td>모든 사용자가 공유</td>
<td>회원별 냉장고 데이터 분리</td>
</tr>
<tr>
<td>프로필</td>
<td>프로필 기능 없음</td>
<td>프로필 조회 및 닉네임 수정</td>
</tr>
<tr>
<td>권한</td>
<td>접근 권한 구분 없음</td>
<td>공개 기능과 회원 전용 기능 분리</td>
</tr>
<tr>
<td>레시피</td>
<td>냉장고 식재료 기반 추천</td>
<td>검색·인기 순위·상세 팝업</td>
</tr>
<tr>
<td>실행 방식</td>
<td>직접 <code>java -jar</code> 실행</td>
<td>systemd를 통한 자동 실행</td>
</tr>
<tr>
<td>장애 복구</td>
<td>장애 발생 시 수동 재실행</td>
<td>실패 시 systemd가 자동 재시작</td>
</tr>
<tr>
<td>로그</td>
<td>EC2에 접속해 직접 확인</td>
<td>journald 및 CloudWatch에서 확인</td>
</tr>
<tr>
<td>Secret</td>
<td>셸 환경변수로 직접 전달</td>
<td>root 전용 환경변수 파일 사용</td>
</tr>
<tr>
<td>DB 변경</td>
<td>수동으로 변경될 가능성</td>
<td>Flyway를 통한 버전 관리</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/e044ae43-c096-4056-b414-64447831d4e5/image.png" alt="">
<img src="https://velog.velcdn.com/images/jang_mut_gae/post/7f8bfb4b-c51e-49bf-9580-df26d3a14ea2/image.png" alt="">
<img src="https://velog.velcdn.com/images/jang_mut_gae/post/a0edec62-2824-41b9-b1f9-1a0640857b8c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/be1aa13d-b8c1-481f-80ff-6f5f45851c62/image.png" alt="">
<img src="https://velog.velcdn.com/images/jang_mut_gae/post/51935e57-6189-4e91-ac5d-d655d55bbb8b/image.png" alt="">
<img src="https://velog.velcdn.com/images/jang_mut_gae/post/d6faa2b6-a57b-4963-b4f0-3a15a2a0eab3/image.png" alt=""></p>
<ol start="16">
<li>이번 프로젝트에서 배운 점</li>
</ol>
<p>기능 구현보다 정책 결정이 먼저다
소셜 로그인 구현에서 가장 중요한 것은 OAuth2 API 호출 자체가 아니었다.</p>
<ul>
<li>어떤 값을 사용자 식별자로 사용할 것인가?</li>
<li>이메일이 없으면 어떻게 할 것인가?</li>
<li>카카오와 네이버 계정을 자동으로 합칠 것인가?</li>
<li>기존 냉장고 데이터의 소유자는 누구인가?
이러한 정책이 정해져야 엔티티와 DB 구조를 설계할 수 있었다.
배포 성공과 운영 가능은 다르다
JAR를 EC2에서 실행했다고 배포가 끝나는 것이 아니었다.
최소한 다음 항목이 필요했다.</li>
<li>자동 실행</li>
<li>장애 시 재시작</li>
<li>환경변수 관리</li>
<li>로그 수집</li>
<li>DB 마이그레이션</li>
<li>보안 그룹 관리
systemd와 CloudWatch를 적용하면서 애플리케이션을 실행하는 것과 운영하는 것은 다른 문제라는 점을 알게 됐다.</li>
</ul>
<ol start="17">
<li>앞으로 개선할 부분</li>
</ol>
<p><strong>HTTPS와 고정 도메인</strong>
현재는 EC2 Public DNS와 8080 포트로 접속한다.
EC2를 중지했다가 시작하면 Public IP와 DNS가 달라질 수 있어 OAuth Callback URI도 다시 등록해야 할 수 있다.
추후에는 고정 도메인과 HTTPS를 적용할 계획이다.
<strong>8080 포트 직접 공개 제거</strong>
현재는 Spring Boot의 8080 포트를 외부에 공개하고 있다.
향후 Nginx 또는 Application Load Balancer를 앞에 두고 외부에서는 80·443 포트만 접근할 수 있도록 변경할 예정이다.
<strong>Parameter Store 적용</strong>
현재 Secret은 EC2 내부의 root 전용 파일로 관리한다.
추후 Systems Manager Parameter Store의 SecureString을 사용해 EC2 파일에도 Secret을 직접 저장하지 않는 구조로 개선할 예정이다.
<strong>Session Manager 적용</strong>
현재 SSH 22번 포트는 내 공인 IP에만 열어두고 있다.
Session Manager를 적용하면 22번 포트를 완전히 닫고 AWS 콘솔과 IAM 권한을 통해 EC2에 접속할 수 있다.</p>
<p>CI/CD
현재 배포 과정은 다음과 같다.
맥에서 bootJar 빌드
→ scp로 EC2에 업로드
→ systemd 재시작
추후 GitHub Actions를 사용해 테스트, 빌드, 배포를 자동화할 계획이다.</p>
<p>CloudWatch 알람
현재는 로그 수집만 적용했다.
추후 다음 상황에 대한 알람도 추가할 수 있다.</p>
<ul>
<li>애플리케이션 오류 로그 발생</li>
<li>EC2 CPU 사용률 증가</li>
<li>RDS 연결 실패</li>
<li>애플리케이션 반복 재시작</li>
</ul>
<p>마무리</p>
<p>이번 고도화를 시작할 때의 목표는 단순히 카카오와 네이버 로그인 버튼을 추가하는 것이었다.
하지만 로그인을 추가하자 회원 식별 정책이 필요했고, 
회원이 생기자 냉장고 데이터를 사용자별로 분리해야 했다. 
DB 구조가 바뀌자 기존 데이터를 어떻게 마이그레이션할지 결정해야 했고, 
AWS에 배포하자 OAuth Redirect URI와 환경별 설정을 구분해야 했다.</p>
<p>또한 EC2에서 애플리케이션이 한 번 실행되는 것만으로는 충분하지 않았다. EC2 재부팅 후 자동 실행, 장애 시 재시작, Secret 관리, 로그 수집까지 적용한 뒤에야 운영 가능한 구조에 가까워졌다.</p>
<p>이번 작업을 통해 기능 하나를 추가하는 일이 애플리케이션 코드만의 문제가 아니라 DB 설계, 보안, 배포, 운영 구조 전체와 연결돼 있다는 것을 경험했다.</p>
<p>아직 HTTPS, 고정 도메인, Parameter Store, CI/CD 같은 작업이 남아 있다. 다음 고도화에서는 외부에 직접 노출된 8080 포트를 제거하고, 고정된 HTTPS 주소를 기반으로 OAuth2 로그인 환경을 안정화할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[한걸음씩 나아가보자#2 Docker로 직접 만든 HTML을 Nginx에 배포하기: Bind Mount부터 Docker Compose까지]]></title>
            <link>https://velog.io/@jang_mut_gae/%ED%95%9C%EA%B1%B8%EC%9D%8C%EC%94%A9-%EB%82%98%EC%95%84%EA%B0%80%EB%B3%B4%EC%9E%902-Docker%EB%A1%9C-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A0-HTML%EC%9D%84-Nginx%EC%97%90-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-Bind-Mount%EB%B6%80%ED%84%B0-Docker-Compose%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jang_mut_gae/%ED%95%9C%EA%B1%B8%EC%9D%8C%EC%94%A9-%EB%82%98%EC%95%84%EA%B0%80%EB%B3%B4%EC%9E%902-Docker%EB%A1%9C-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A0-HTML%EC%9D%84-Nginx%EC%97%90-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-Bind-Mount%EB%B6%80%ED%84%B0-Docker-Compose%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 13 Oct 2026 14:22:20 GMT</pubDate>
            <description><![CDATA[<p>Docker로 직접 만든 HTML을 Nginx에 배포하기: Bind Mount부터 Docker Compose까지
어제는 Docker 기본 명령어와 AWS EC2에서 Nginx를 실행하는 실습을 했다.</p>
<p>오늘은 로컬 Mac 환경에서 직접 만든 index.html을 Nginx로 띄우고, Bind Mount와 Dockerfile 방식의 차이를 확인했다. 마지막에는 Docker Compose를 사용해 이미지 빌드와 컨테이너 실행을 관리했다.</p>
<p>오늘의 목표</p>
<ul>
<li>직접 작성한 HTML을 Nginx로 실행하기</li>
<li>Bind Mount의 역할 이해하기</li>
<li>Dockerfile로 커스텀 이미지 만들기</li>
<li>이미지와 컨테이너의 차이 이해하기</li>
<li>컨테이너 실행·중지·삭제·재생성하기</li>
<li>Docker Compose로 서비스 관리하기</li>
</ul>
<ol>
<li>Docker 실행 상태 확인
먼저 Docker Desktop이 실행 중인지 확인했다.<pre><code>docker info</code></pre>처음에는 다음과 같은 오류가 발생했다.<pre><code>failed to connect to the docker API</code></pre>Docker 명령어는 설치되어 있지만 Docker Desktop이 실행되지 않아 Docker 엔진에 연결하지 못한 상태였다.
Docker Desktop을 실행한 후 다시 docker info를 실행하니 서버 정보가 정상적으로 출력됐다 촤하하 다음부턴 기억해놔야겠다
기존 컨테이너도 확인했다.<pre><code>docker ps</code></pre>어제 만든 my-nginx가 호스트의 8080번 포트를 사용 중이었기 때문에 오늘 실습에서는 8081번 포트를 사용하기로 했다.</li>
</ol>
<ol start="2">
<li>프로젝트 폴더와 HTML 작성
프로젝트 폴더를 만들었다.
디렉토리 하나 만들고, 거기다
index.html 파일을 작성했다.<pre><code>nano index.html</code></pre><pre><code>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;나의 Docker Nginx&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Docker로 만든 나의 Nginx 페이지&lt;/h1&gt;
&lt;p&gt;Bind Mount 실습에 성공했습니다!&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre></li>
<li>Bind Mount로 HTML 연결하기
작성한 HTML을 Nginx 컨테이너에 Bind Mount로 연결했다.<pre><code>docker run \
--name bind-nginx \
-d \
-p 8081:80 \
-v &quot;$(pwd)/index.html:/usr/share/nginx/html/index.html:ro&quot; \
nginx</code></pre>각 옵션의 의미는 다음과 같다.</li>
</ol>
<ul>
<li>--name bind-nginx: 컨테이너 이름 지정</li>
<li>-d: 컨테이너를 백그라운드에서 실행</li>
<li>-p 8081:80: Mac의 8081번 포트를 컨테이너의 80번 포트에 연결</li>
<li>-v: 로컬 파일과 컨테이너 내부 경로 연결</li>
<li>$(pwd): 현재 폴더의 절대 경로</li>
<li>ro: 컨테이너에서 읽기만 가능하도록 설정</li>
<li>nginx: 컨테이너 생성에 사용할 이미지</li>
</ul>
<p>브라우저에서 다음 주소로 접속했다.
<a href="http://localhost:8081">http://localhost:8081</a>
직접 작성한 페이지가 정상적으로 표시됐다.</p>
<p>Bind Mount란?
Bind Mount는 <strong>Mac에 있는 파일이나 폴더를 컨테이너 내부의 경로와 연결하는 기능</strong>이다.</p>
<p>Mac의 index.html
        ↕
컨테이너의 /usr/share/nginx/html/index.html</p>
<p>-v의 기본 구조는 다음과 같다.
<strong>-v 로컬경로:컨테이너경로:권한</strong>
(참고로 내가 위에 쓴 ro는 Read Only이다!)
Bind Mount는 Docker가 기본적으로 지원하는 기능이지만, docker run만 실행한다고 자동 적용되지는 않는다. -v 또는 --mount type=bind 옵션으로 직접 설정해야 한다.&#39;=
Mac의 index.html 내용을 수정하고 브라우저를 새로고침하니 변경 내용이 즉시 반영됐다! 
컨테이너에 파일을 복사한 것이 아니라 원본 파일을 연결했기 때문이다.</p>
<ol start="4">
<li>Bind Mount 컨테이너 정리
Bind Mount 실습이 끝난 후 컨테이너를 중지하고 삭제했다.<pre><code>docker stop bind-nginx
docker rm bind-nginx</code></pre></li>
<li>Dockerfile 작성
이번에는 Bind Mount를 사용하지 않고 HTML 파일을 이미지 안에 포함하기 위해 Dockerfile을 작성했다.
nano Dockerfile<pre><code>FROM nginx
COPY index.html /usr/share/nginx/html/index.html</code></pre>_FROM nginx_는 공식 Nginx 이미지를 기반 이미지로 사용한다는 의미다.
태그를 생략한 다음 표현은:<pre><code>FROM nginx</code></pre>이며 사실상 다음과 같다.<pre><code>FROM nginx:latest</code></pre></li>
</ol>
<p><em>COPY</em> 현재 프로젝트 폴더의 index.html을 빌드 중인 이미지 내부로 복사한다.
COPY 로컬파일 이미지내부경로</p>
<pre><code>index.html
→ /usr/share/nginx/html/index.html</code></pre><ol start="6">
<li>커스텀 Nginx 이미지 빌드
Dockerfile로 이미지를 빌드했다.
docker build -t custom-nginx-image:v1 .</li>
</ol>
<ul>
<li>docker build: Dockerfile로 이미지 생성</li>
<li>-t: 이미지 이름과 태그 지정</li>
<li>custom-nginx-image: 이미지 이름</li>
<li>v1: 이미지 태그</li>
<li>.: 현재 폴더를 빌드 재료로 사용
이미지 목록을 확인했다.<pre><code>docker images</code></pre></li>
</ul>
<ol start="7">
<li>커스텀 이미지로 컨테이너 실행
직접 만든 이미지로 컨테이너를 실행했다.<pre><code>docker run \
--name custom-nginx \
-d \
-p 8081:80 \
custom-nginx-image:v1</code></pre>브라우저에서 <a href="http://localhost:8081%EB%A1%9C">http://localhost:8081로</a> 접속하니 이미지에 포함한 HTML이 표시됐다.</li>
<li>Bind Mount와 이미지 방식 비교
두 방식의 차이를 확인하기 위해 로컬의 index.html을 다시 수정했다.
Bind Mount 방식에서는 수정 내용이 즉시 반영됐지만, 커스텀 이미지로 실행한 컨테이너에서는 내용이 바뀌지 않았다.</li>
</ol>
<p><strong>이유는 COPY가 이미지 빌드 시점의 파일을 이미지에 저장하기 때문이다.</strong></p>
<h4 id="_bind-mount">_Bind Mount</h4>
<p>로컬 파일 수정 → 즉시 반영</p>
<h4 id="_dockerfile의-copy">_Dockerfile의 COPY</h4>
<p>로컬 파일 수정 → 이미지 재빌드 필요</p>
<p>수정된 파일을 반영하기 위해 새 이미지를 빌드했다.</p>
<pre><code>docker build -t custom-nginx-image:v2 .</code></pre><p>기존 컨테이너를 중지하고 삭제했다.</p>
<pre><code>docker stop custom-nginx
docker rm custom-nginx</code></pre><p>새 이미지로 컨테이너를 다시 만들었다.</p>
<pre><code>docker run \
  --name custom-nginx \
  -d \
  -p 8081:80 \
  custom-nginx-image:v2</code></pre><p>이후 브라우저를 새로고침하니 수정된 내용이 표시됐다.
9. 이미지와 컨테이너의 차이
이미지는 <strong>컨테이너를 만들기 위한 실행 파일과 설정의 묶음</strong>이다. 컨테이너는 <strong>이미지를 바탕으로 실제 실행된 환경</strong>이다.
Dockerfile
   ↓ docker build
이미지
   ↓ docker run
컨테이너
이번 실습에서는 다음과 같이 연결된다.</p>
<p>Dockerfile + index.html
        ↓
custom-nginx-image:v2
        ↓
custom-nginx</p>
<p>비유하면 이미지는 붕어빵 틀이고, 컨테이너는 그 틀로 만든 붕어빵이다.
컨테이너를 삭제해도 이미지가 남아 있으면 같은 컨테이너를 다시 만들 수 있다.</p>
<p>docker stop custom-nginx
컨테이너를 중지하지만 삭제하지는 않는다.</p>
<p>docker rm custom-nginx
컨테이너를 삭제하지만 이미지는 남아 있다.</p>
<p>docker rmi custom-nginx-image:v2
이미지를 삭제한다.</p>
<ol start="10">
<li>Docker Compose 사용하기
긴 docker run 명령을 반복하지 않도록 compose.yaml을 작성했다.<pre><code>nano compose.yaml</code></pre><pre><code>services:
web:
build: .
image: custom-nginx-image:compose
container_name: compose-nginx
ports:
  - &quot;8081:80&quot;</code></pre>각 설정의 의미는 다음과 같다.</li>
</ol>
<ul>
<li>services: Compose가 관리할 서비스 목록</li>
<li>web: 서비스 이름</li>
<li>build: .: 현재 폴더의 Dockerfile로 이미지 빌드</li>
<li>image: 빌드 결과에 붙일 이미지 이름과 태그</li>
<li>container_name: 생성할 컨테이너 이름</li>
<li>ports: 호스트 포트와 컨테이너 포트 연결</li>
</ul>
<p>여기서 custom-nginx-image:compose는 기존에 있던 이미지 파일을 가리키는 경로가 아니다.</p>
<pre><code>build: .
image: custom-nginx-image:compose</code></pre><p>두 설정을 함께 읽으면 다음과 같은 의미가 된다.
현재 폴더의 Dockerfile로 이미지를 빌드하고, 결과 이미지에 custom-nginx-image:compose라는 이름을 붙인다.</p>
<p>내부적으로는 다음과 비슷한 빌드가 실행된다.
docker build -t custom-nginx-image:compose .
11. Compose로 빌드 및 실행
먼저 Compose 파일을 검사했다.
docker compose config
빌드와 실행을 한 번에 진행했다.
docker compose up -d --build</p>
<ul>
<li>up: 서비스 생성 및 실행</li>
<li>-d: 백그라운드 실행</li>
<li>--build: 실행 전에 이미지 빌드</li>
</ul>
<p>서비스 상태를 확인했다.</p>
<pre><code>docker compose ps</code></pre><p>Compose 서비스를 종료하고 관련 컨테이너와 네트워크를 정리했다.</p>
<pre><code>docker compose down</code></pre><p>docker compose down은 Compose가 만든 컨테이너와 네트워크를 삭제하지만, 기본적으로 빌드한 이미지까지 삭제하지 않는다.</p>
<p>실습하다 생긴 오류</p>
<ol>
<li>컨테이너 이름 충돌
컨테이너를 중지한 후 같은 이름으로 새 컨테이너를 만들려고 하니 오류가 발생했다.<pre><code>The container name &quot;/custom-nginx&quot; is already in use</code></pre>docker stop은 컨테이너를 중지할 뿐 삭제하지 않는다. 중지된 컨테이너도 이름을 계속 사용한다.
기존 컨테이너를 삭제한 후 다시 생성했다.</li>
<li>Compose YAML 오류
YAML 파일을 작성한 뒤 다음 명령으로 미리 검사하면 오류를 찾기 쉽다.
docker compose config</li>
</ol>
<p>오늘은 하나의 HTML 페이지를 두 가지 방식으로 Nginx에 배포했다.
<strong>Bind Mount 방식</strong>
Mac의 파일을 컨테이너에 연결
→ 로컬 파일 수정 시 즉시 반영
→ 개발 과정에서 편리
<strong>커스텀 이미지 방식</strong>
Dockerfile의 COPY로 파일을 이미지에 포함
→ 파일 수정 후 이미지 재빌드 필요
→ 동일한 결과물을 반복 배포하기 좋음
Docker Compose를 사용하면 이미지 빌드, 컨테이너 생성, 포트 연결 같은 설정을 compose.yaml에 기록할 수 있다.
docker compose up -d --build
docker compose down
긴 docker run 명령을 매번 다시 작성하지 않고 동일한 실행 환경을 재생성할 수 있다는 점이 가장 큰 장점이었다.</p>
<p>다음 실습에서는 Docker Volume과 컨테이너 네트워크를 학습하고, Nginx와 간단한 백엔드 서버를 Compose로 함께 실행해 볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[한걸음씩 나아가보자#1 Docker로 Nginx 실행하기: 로컬 실습부터 AWS EC2 배포까지]]></title>
            <link>https://velog.io/@jang_mut_gae/%ED%95%9C%EA%B1%B8%EC%9D%8C%EC%94%A9-%EB%82%98%EC%95%84%EA%B0%80%EB%B3%B4%EC%9E%901-Docker%EB%A1%9C-Nginx-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0-%EB%A1%9C%EC%BB%AC-%EC%8B%A4%EC%8A%B5%EB%B6%80%ED%84%B0-AWS-EC2-%EB%B0%B0%ED%8F%AC%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jang_mut_gae/%ED%95%9C%EA%B1%B8%EC%9D%8C%EC%94%A9-%EB%82%98%EC%95%84%EA%B0%80%EB%B3%B4%EC%9E%901-Docker%EB%A1%9C-Nginx-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0-%EB%A1%9C%EC%BB%AC-%EC%8B%A4%EC%8A%B5%EB%B6%80%ED%84%B0-AWS-EC2-%EB%B0%B0%ED%8F%AC%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 12 Oct 2026 13:07:18 GMT</pubDate>
            <description><![CDATA[<h2 id="1-오늘의-목표">1. 오늘의 목표</h2>
<ul>
<li>Docker로 Nginx 컨테이너 실행하기</li>
<li>포트 연결 구조 이해하기</li>
<li>컨테이너 중지·재시작과 로그 확인하기</li>
<li>AWS EC2에서도 같은 환경 구축하기</li>
</ul>
<h2 id="2-로컬-환경에서-nginx-실행">2. 로컬 환경에서 Nginx 실행</h2>
<p>Docker Desktop을 설치하고 다음 명령으로 Nginx 컨테이너를 실행했다.</p>
<p>docker run --name my-nginx -d -p 8080:80 nginx</p>
<p>브라우저에서 <code>http://localhost:8080</code>에 접속하니 Nginx 기본 페이지가 나타났다.</p>
<p>여기서 <code>8080:80</code>은 다음 의미다.</p>
<p>브라우저 → Mac의 8080 포트 → Docker → 컨테이너의 80 포트 → Nginx</p>
<h2 id="3-컨테이너-관리">3. 컨테이너 관리</h2>
<p>실행 중인 컨테이너 확인:</p>
<p>docker ps</p>
<p>컨테이너 중지:</p>
<p>docker stop my-nginx</p>
<p>기존 컨테이너 재시작:</p>
<p>docker start my-nginx</p>
<p><code>docker run</code>은 새 컨테이너를 생성하면서 실행하고, <code>docker start</code>는 이미 만들어진 컨테이너를 다시 실행한다.</p>
<h2 id="4-로그-확인">4. 로그 확인</h2>
<p>일반 로그 확인:</p>
<p>docker logs my-nginx</p>
<p>실시간 로그 확인:</p>
<p>docker logs -f my-nginx</p>
<p>로그에서 <code>GET /</code>는 브라우저가 기본 페이지를 요청했다는 의미다.</p>
<ul>
<li><code>200 OK</code>: 서버가 페이지 내용을 전송함</li>
<li><code>304 Not Modified</code>: 기존 페이지와 달라진 것이 없어 브라우저 캐시를 사용함</li>
</ul>
<h2 id="5-aws-ec2-서버-구축">5. AWS EC2 서버 구축</h2>
<p>AWS 서울 리전에 Amazon Linux 기반 EC2 인스턴스를 생성했다.</p>
<p>보안 그룹은 다음과 같이 설정했다.</p>
<ul>
<li>SSH 22번 포트: 내 IP만 허용</li>
<li>HTTP 80번 포트: 외부 인터넷 접속 허용</li>
</ul>
<p>SSH로 EC2에 접속한 뒤 Docker를 설치했다.</p>
<p>sudo dnf install -y docker
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user</p>
<p>권한 적용을 위해 SSH에서 나갔다가 다시 접속했다.</p>
<h2 id="6-ec2에서-nginx-실행">6. EC2에서 Nginx 실행</h2>
<p>docker run --name my-nginx -d -p 80:80 nginx</p>
<p>EC2의 퍼블릭 IP로 접속하니 Mac뿐 아니라 휴대폰에서도 Nginx 기본 페이지가 나타났다.</p>
<p>인터넷 → EC2의 80번 포트 → Docker → 컨테이너의 80번 포트 → Nginx</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/475a0191-df15-4e05-8a91-c8b558e6b851/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/e3b20ef7-8915-4a84-a633-33ccb9109914/image.jpeg" alt=""></p>
<p>로그도 확인해보았다.</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/3f3e320e-7293-4f9b-91f8-dbe0297f29b6/image.png" alt=""></p>
<h2 id="7-실습-중-알게-된-점">7. 실습 중 알게 된 점</h2>
<ul>
<li>Docker 이미지는 컨테이너를 만들기 위한 재료다.</li>
<li>컨테이너는 이미지를 기반으로 실제 실행되는 환경이다.</li>
<li>포트 연결은 Docker가 담당하고, Nginx는 컨테이너의 80번 포트에서 요청을 처리한다.</li>
<li>로컬 서버는 내 컴퓨터에서 실행되지만 EC2 서버는 AWS 데이터센터에서 실행된다.</li>
<li>HTTP 포트를 공개하면 다른 기기에서도 웹페이지에 접속할 수 있다.</li>
</ul>
<h2 id="8-비용-발생-자원-정리">8. 비용 발생 자원 정리</h2>
<p>실습 후 추가 비용이 발생하지 않도록 EC2 인스턴스를 종료했다.</p>
<p>EBS 볼륨도 함께 삭제된 것을 확인했다.</p>
<p><code>중지</code>는 서버를 잠시 끄는 것이고, <code>종료</code>는 서버를 영구 삭제하는 작업이다.</p>
<h2 id="9-마무리">9. 마무리</h2>
<p>이번 실습을 통해 로컬 Docker 환경과 AWS 클라우드 환경에서 동일한 Nginx 컨테이너를 실행해 보았다.</p>
<p>다음에는 Nginx 기본 페이지 대신 직접 만든 HTML 페이지를 컨테이너에서 제공해 볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mac 메시지에서 인증번호와 비밀번호를 자동으로 복사하는 앱 만들기]]></title>
            <link>https://velog.io/@jang_mut_gae/Mac-%EB%A9%94%EC%8B%9C%EC%A7%80%EC%97%90%EC%84%9C-%EC%9D%B8%EC%A6%9D%EB%B2%88%ED%98%B8%EC%99%80-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EB%A5%BC-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%B3%B5%EC%82%AC%ED%95%98%EB%8A%94-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jang_mut_gae/Mac-%EB%A9%94%EC%8B%9C%EC%A7%80%EC%97%90%EC%84%9C-%EC%9D%B8%EC%A6%9D%EB%B2%88%ED%98%B8%EC%99%80-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EB%A5%BC-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%B3%B5%EC%82%AC%ED%95%98%EB%8A%94-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 20 Aug 2026 04:24:36 GMT</pubDate>
            <description><![CDATA[<p>회사에서 인증번호나 서버 비밀번호를 문자로 받을 일이 많다.
그때마다 메시지 앱을 열고 문자를 찾아 인증번호를 복사하는 과정이 은근히 귀찮았다. 그래서 메시지에서 인증번호나 비밀번호를 자동으로 읽어와 Mac 상단 메뉴 막대에서 간편하게 복사할 수 있는 앱을 만들어보기로 했다.</p>
<h4 id="mac-메시지는-어디에-저장될까">Mac 메시지는 어디에 저장될까?</h4>
<p>Mac 메시지 앱의 대화 내용은 다음 경로의 SQLite 데이터베이스에 저장된다.</p>
<blockquote>
<p>~/Library/Messages/chat.db</p>
</blockquote>
<p>별도의 데이터베이스를 새로 만드는 것은 아니다. 메시지 앱이 이미 사용하는 chat.db를 읽기 전용으로 조회하고, 새로 수신된 메시지에서 필요한 값만 추출하는 방식이다.</p>
<p>예를 들어 다음 문자가 오면:</p>
<blockquote>
<p>[Web발신]
[인증번호:354873] 사용자 본인 인증번호를 입력해주세요.</p>
</blockquote>
<p>354873만 추출한다.</p>
<p>서버 비밀번호 문자도 처리할 수 있도록 했다.</p>
<blockquote>
<p>[Web발신]
[APPM] HOST : server01, ID : root, PW : example-password</p>
</blockquote>
<p>이 경우에는 PW : 뒤에 있는 값만 추출한다.</p>
<h4 id="kotlin을-선택한-이유">Kotlin을 선택한 이유</h4>
<p>언어는 Kotlin을 사용했다. 평소 IntelliJ IDEA를 사용하고 있어 프로젝트를 만들고 테스트하기 편했기 때문이다.</p>
<p>구성은 다음과 같다.</p>
<blockquote>
<p>Kotlin
Gradle
SQLite JDBC
Java AWT SystemTray
jpackage</p>
</blockquote>
<p>앱은 chat.db에서 실행 이후 추가된 메시지만 확인한다. 인증번호나 비밀번호가 발견되면 메뉴 막대의 K 아이콘을 클릭해 해당 값을 복사할 수 있다.
민감 정보이기 때문에 문자 원문이나 추출한 값을 파일과 로그에 저장하지 않도록 했다. 인증번호와 비밀번호는 메모리에만 잠시 유지하고 5분이 지나면 제거한다.</p>
<h4 id="전체-디스크-접근-권한-설정">전체 디스크 접근 권한 설정</h4>
<p>chat.db는 macOS의 개인정보 보호 기능으로 보호된다. IntelliJ에서 앱을 테스트하려면 IntelliJ에 전체 디스크 접근 권한을 허용해야 한다.</p>
<blockquote>
<p>시스템 설정
→ 개인정보 보호 및 보안
→ 전체 디스크 접근 권한
→ IntelliJ IDEA 활성화</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/f95ba17f-565c-40fd-96ce-b3b62d5d108f/image.png" alt=""></p>
<p>권한을 설정한 후에는 IntelliJ를 완전히 종료했다가 다시 실행해야 한다.
독립된 CodeClip.app으로 실행할 때는 IntelliJ가 아니라 CodeClip.app에 전체 디스크 접근 권한을 별도로 허용해야 한다.</p>
<h4 id="메뉴-막대에-인증번호를-바로-표시할-수-없을까">메뉴 막대에 인증번호를 바로 표시할 수 없을까?</h4>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/b8e528dd-6172-4fda-bcd3-6858a9a40574/image.png" alt="">
-&gt;현재는 이러한 방식인데 </p>
<p>나는 메뉴 막대에 다음과 같이 인증번호가 바로 나타나기를 원했다.</p>
<p>그러면 메뉴를 열 필요 없이 표시된 인증번호를 바로 클릭해서 복사할 수 있다.
하지만 Kotlin/JVM에서 사용한 Java SystemTray는 고정된 크기의 아이콘을 중심으로 설계돼 있다. 긴 숫자를 이미지로 만들어 넣더라도 지정된 아이콘 영역에 맞춰 축소되거나 잘릴 수 있다. 따라서 K 옆에 가변 길이 텍스트를 자연스럽게 표시하기 어렵다.
macOS 메뉴 막대에 가변 길이 텍스트를 표시하려면 Apple의 네이티브 API인 NSStatusItem을 사용하는 것이 적합하다.
가장 일반적이고 간단한 조합은 다음과 같다.</p>
<blockquote>
<p>Swift + Xcode + NSStatusItem</p>
</blockquote>
<p>Xcode는 Apple이 제공하는 macOS 및 iOS 앱 개발 도구다. IntelliJ가 Kotlin과 Java 개발에 사용된다면, Xcode는 Swift 기반 Apple 플랫폼 앱 개발에 사용된다고 이해하면 쉽다.
물론 Kotlin에서도 다른 방법은 있다.</p>
<ul>
<li>Kotlin/JVM에서 JNA 또는 JNI를 통해 NSStatusItem 호출</li>
<li>Kotlin/Native에서 macOS AppKit 호출</li>
<li>Objective-C로 메뉴 막대 부분만 구현</li>
<li>Swift로 작은 메뉴 막대 보조 프로그램을 만들고 Kotlin 앱과 통신</li>
</ul>
<p>하지만 결국 내부적으로 Apple의 AppKit과 <strong>NSStatusItem</strong>을 사용해야 한다. Kotlin/JVM에서 JNA나 JNI를 이용하면 구현이 복잡해지고 유지보수 부담도 커진다.</p>
<p>따라서 메뉴 막대에 K 354873처럼 인증번호를 바로 표시하려면 Swift와 Xcode로 구현하는 것이 가장 단순하고 안정적이다.
현재 버전은 Kotlin의 SystemTray를 사용하므로 K 아이콘을 클릭한 뒤 인증번호나 비밀번호 항목을 선택해 복사하는 방식으로 사용하고 있다.</p>
<h4 id="intellij-없이-실행하기">IntelliJ 없이 실행하기</h4>
<p>테스트를 마친 뒤 jpackage를 사용해 독립 실행용 macOS 앱으로 패키징했다.</p>
<p>앱을 /Applications에 설치하고 전체 디스크 접근 권한을 허용했다. 또한 macOS 로그인 항목에 등록해 Mac에 로그인하면 자동으로 실행되도록 설정했다.
이제 IntelliJ를 열거나 매번 실행 버튼을 누를 필요 없이 메뉴 막대에서 바로 사용할 수 있다.
사소한 불편에서 시작한 앱이지만 실제 업무에서는 꽤 편리하다. 특히 인증번호나 임시 서버 비밀번호를 자주 전달받는 환경이라면 작은 자동화만으로도 반복 작업을 많이 줄일 수 있다. 아주 만족스럽다 ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[식겁했던 상용서버 가디코어 재설치 사건]]></title>
            <link>https://velog.io/@jang_mut_gae/%EC%8B%9D%EA%B2%81%ED%96%88%EB%8D%98-%EC%83%81%EC%9A%A9%EC%84%9C%EB%B2%84-%EA%B0%80%EB%94%94%EC%BD%94%EC%96%B4-%EC%9E%AC%EC%84%A4%EC%B9%98-%EC%82%AC%EA%B1%B4</link>
            <guid>https://velog.io/@jang_mut_gae/%EC%8B%9D%EA%B2%81%ED%96%88%EB%8D%98-%EC%83%81%EC%9A%A9%EC%84%9C%EB%B2%84-%EA%B0%80%EB%94%94%EC%BD%94%EC%96%B4-%EC%9E%AC%EC%84%A4%EC%B9%98-%EC%82%AC%EA%B1%B4</guid>
            <pubDate>Thu, 13 Aug 2026 08:21:52 GMT</pubDate>
            <description><![CDATA[<p>회사에서 가디코어 일부 서버를 수동 설치해보라고 하셨다.</p>
<p>가디코어란?
서버 간 통신을 통제하는 보안 솔루션으로
서버 간 네트워크 흐름 수집, 허용 차단 정책 적용, 비정상 통신 탐지, 중앙 관리 서버와 통신, 서버 및 프로세스 정보 표시 등 의 역할을 한다. </p>
<p>이미 F/O된 서버에서 테스트를 하라고 하셔서 가이드대로 따라하고 있는데 
검수 서버가 아니라 상용 서버에서 테스트를 진행해버렸다...</p>
<p>F/O 된 서버이기도 하고 F&amp;Q를 봤을 때 Agent 오동작, 중지 또는 장애 발생 시 Agent 는 Fail - Open 방식으로 동작하여 트래픽을 bypass 하기에 서비스에 영향을 주지 않는다고 하였지만 어찌됐든 상용 서버는 매사 신중하게 해야 하니 너무 당황스러웠다. </p>
<p>이게 혹시 모를 상황을 대비해
agent 중지, 제거, 설치를 해보고 있었고 모르고 제거를 해버린 것이다</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/d9ef3630-2158-4780-8cbf-5c07a2b8bb98/image.png" alt=""></p>
<p>삭제 작업을 하는 도중 놀라서 Ctrl + C를 눌렀다...</p>
<p>gc-agent status 해보니 모듈 상태가 다 Stopped 였다</p>
<p>gc-agent start를 해봐도 </p>
<blockquote>
<p>Guardicore Agent service is already up.</p>
</blockquote>
<p>이라는데 상태는 자꾸만 Stopped이니 식은땀이 났다 </p>
<p>뭔가 혹시 모를 장애나 이슈가 생길까 너무 무서웠다. </p>
<p>일단 중지 -&gt; 재시작 루트로 가보려고</p>
<p>gc-agent stop </p>
<p>을 했다. 근데 자꾸 통신 오류로 실패하였다 하하</p>
<p>진정하고</p>
<pre><code>systemctl list-unit-files | grep -Ei &#39;guardicore|gc-agent&#39;</code></pre><p>를 사용해서 서비스명을 확인하였다. -&gt; gc-agent.service </p>
<pre><code>systemctl restart gc-agent.service
systemctl status gc-agent.service --no-pager
gc-agent status</code></pre><p>차례로 실행하였다. </p>
<p>해결완료...!</p>
<p>status가 모두 Running이었다 ㅠㅠ </p>
<pre><code>systemctl is-active gc-agent.service
systemctl is-enabled gc-agent.service</code></pre><p>도 active, enabled로 각각 나왔다.</p>
<p>진행 과정은 이렇다.</p>
<p>uninstall --purge는 enforcement를 종료하지 못한 채 Ctrl+C로 중단되어 완전 삭제되지 않았다!</p>
<p>에이전트 관리 프로세스와 내부 모듈 상태가 서로 어긋나 already up인데도 모듈들은 Stopped로 나왔다 
systemctl restart가 기존 서비스를 정리하고 gc-agent.service를 새로 시작했다.
약 30초의 종료 대기 후 네 모듈이 모두 다시 실행됐다.
관리 서버와의 443 - OK 연결도 복구됐다.
즉, 재설치하거나 삭제를 되돌린 게 아니라, 남아 있던 정상 설치본을 systemd로 재기동해서 상태 불일치를 해소한 것이다.</p>
<p>결론은</p>
<h3 id="상용인지-검수인지-똑바로-확인하자-ㅡㅡ">상용인지 검수인지 똑바로 확인하자 ㅡㅡ...</h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[PackageKit 업데이트 실패 원인: 방화벽이 아니라 Docker 네트워크 대역 충돌이었다]]></title>
            <link>https://velog.io/@jang_mut_gae/PackageKit-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%EC%9B%90%EC%9D%B8-%EB%B0%A9%ED%99%94%EB%B2%BD%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-Docker-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EB%8C%80%EC%97%AD-%EC%B6%A9%EB%8F%8C%EC%9D%B4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@jang_mut_gae/PackageKit-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%EC%9B%90%EC%9D%B8-%EB%B0%A9%ED%99%94%EB%B2%BD%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-Docker-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EB%8C%80%EC%97%AD-%EC%B6%A9%EB%8F%8C%EC%9D%B4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Thu, 06 Aug 2026 02:39:54 GMT</pubDate>
            <description><![CDATA[<p>PackageKit 업데이트 실패 원인: 방화벽이 아니라 Docker 네트워크 대역 충돌이었다
프록시 서버에서 PackageKit 버전 업데이트를 진행하던 중 계속 실패하는 문제가 발생했다.
처음 확인한 오류 메시지는 대략 다음과 같았다.</p>
<pre><code>Couldn&#39;t connect to server for http://xxx.xx.xx.xx/...
Failed to connect to xxx.xx.xx.xx port 80</code></pre><p>No route to host
Cannot download repomd.xml
패키지 저장소 서버에 연결하지 못한다는 내용이었다.
처음에는 방화벽 문제라고 생각했다. 패키지 저장소 서버의 IP와 포트를 기준으로 방화벽 신청도 진행했지만, 방화벽이 반영된 후에도 동일한 오류가 발생했다.
결론부터 말하면 원인은 방화벽이 아니었다.
사내 패키지 저장소의 IP 대역과 프록시 서버에서 사용하는 Docker 네트워크 대역이 겹치면서, 패킷이 엉뚱한 Docker 가상 네트워크로 전달되고 있었다.
패키지 저장소로 가는 경로 확인
먼저 서버가 패키지 저장소 IP를 어떤 경로로 보내고 있는지 확인했다.</p>
<pre><code>ip route get xxx.xx.xx.xx</code></pre><p>결과를 확인해 보니 다음과 같이 br-로 시작하는 인터페이스가 표시됐다.</p>
<pre><code>xxx.xx.xx.xx dev br-xxxxxxxxxxxx src xxx.xx.0.1</code></pre><p>br-xxxxxxxxxxxx는 일반적인 물리 네트워크 인터페이스가 아니라 Docker에서 생성한 브리지 인터페이스다.
즉, 패키지 저장소로 보내야 하는 트래픽이 실제 사내망이 아닌 Docker 내부 네트워크로 들어가고 있었다.
Docker 네트워크 확인
문제가 된 브리지와 연결된 Docker 네트워크를 확인했다.</p>
<pre><code>docker network ls</code></pre><p>br- 뒤에 표시된 값은 일반적으로 Docker 네트워크 ID의 앞부분과 대응한다. 해당 ID를 이용해 네트워크 설정을 조회했다.</p>
<pre><code>docker network inspect xxxxxxxxxxxx</code></pre><p>확인 결과 Harbor에서 사용하는 Docker 네트워크가 다음과 같이 설정돼 있었다.</p>
<pre><code>&quot;IPAM&quot;: {
    &quot;Config&quot;: [
        {
            &quot;Subnet&quot;: &quot;xxx.xx.0.0/16&quot;,
            &quot;Gateway&quot;: &quot;xxx.xx.0.1&quot;
        }
    ]
}</code></pre><p>문제는 xxx.xx.0.0/16이라는 범위였다.
/16 네트워크는 다음과 같이 상당히 넓은 주소 범위를 포함한다.
xxx.xx.0.0 ~ xxx.xx.255.255
사내망에 있는 패키지 저장소 IP 역시 이 범위에 포함돼 있었다.
정리하면 다음과 같은 상황이었다.
패키지 저장소 IP
        ↓
Docker 네트워크 대역에도 포함됨
        ↓
서버가 Docker 내부 주소라고 판단
        ↓
실제 사내망이 아닌 Docker 브리지로 전송
        ↓
저장소 연결 실패
왜 기존 사내망 경로가 선택되지 않았을까?
프록시 서버의 전체 라우팅 테이블을 확인했다.</p>
<pre><code>ip route show</code></pre><p>출력에는 이미 사내망을 위한 경로가 설정돼 있었다.</p>
<pre><code>xxx.xx.0.0/12 via xxx.xx.xx.1 dev ethX</code></pre><p>하지만 Docker가 다음과 같이 더 구체적인 경로를 추가한 상태였다.</p>
<pre><code>xxx.xx.0.0/16 dev br-xxxxxxxxxxxx</code></pre><p>목적지 IP가 두 경로에 모두 포함되면 리눅스는 더 구체적인 경로를 선택한다.
/16 → Docker 네트워크
/12 → 기존 사내망 경로
/16이 /12보다 구체적이므로 패키지 저장소 트래픽은 기존 사내망 경로가 아니라 Docker 브리지로 전달됐다.
방화벽을 열어도 문제가 해결되지 않았던 이유도 여기에 있었다. 패킷이 방화벽을 통과하기도 전에 잘못된 네트워크 인터페이스로 전달되고 있었던 것이다.
해결 방법
근본적인 해결 방법은 Harbor Docker 네트워크 대역을 사내망과 겹치지 않는 대역으로 변경하는 것이다.
기존 Harbor 네트워크: xxx.xx.0.0/16
변경할 네트워크: 사내에서 사용하지 않는 별도 대역
다만 네트워크 대역을 변경하려면 Harbor 컨테이너와 Docker 네트워크를 재생성해야 한다. 서비스 중단 가능성이 있어 즉시 적용하기는 어려웠다.
따라서 이번 작업에서는 <strong>패키지 저장소 IP 하나만 기존 사내망 경로로 보내는 임시 정적 라우트를 추가</strong>했다.
변경 전 라우팅 정보 백업
작업 전 현재 라우팅 설정을 파일로 남겼다.</p>
<pre><code>ip route show table all \
  &gt; /root/ip-route-before-$(date +%Y%m%d-%H%M%S).txt</code></pre><p>백업 파일을 확인했다.</p>
<pre><code>ls -l /root/ip-route-before-*.txt
cat /root/ip-route-before-*.txt</code></pre><p>이 파일은 자동 복구용이라기보다 작업 전 상태를 확인하고 변경 내용을 비교하기 위한 기록이다.
/32 임시 경로 추가
기존 라우팅 테이블에서 확인한 실제 사내망 게이트웨이와 인터페이스를 사용해 다음과 같이 경로를 추가했다.</p>
<pre><code>ip route add xxx.xx.xx.xx/32 via xxx.xx.xx.1 dev ethX</code></pre><p>각 항목의 의미는 다음과 같다.</p>
<pre><code>xxx.xx.xx.xx/32</code></pre><p>→ 패키지 저장소 IP 하나에만 적용</p>
<p>via xxx.xx.xx.1
→ 기존 사내망 라우팅에 설정된 게이트웨이</p>
<p>dev ethX
→ 기존 사내망 라우팅에 사용되는 인터페이스
여기서 /32는 특정 IP 하나만 의미한다.
라우팅 규칙은 더 구체적인 경로가 우선하므로 우선순위가 다음과 같이 변경된다.
사내망 경로 /12
Docker 경로 /16
패키지 저장소 예외 경로 /32 ← 가장 우선
따라서 다른 Harbor 통신에는 영향을 주지 않으면서 패키지 저장소 IP만 실제 사내망으로 전달할 수 있었다.
단, 게이트웨이와 인터페이스는 환경마다 다르다. 다른 서버의 값을 그대로 복사하지 말고 반드시 해당 서버의 ip route show 결과와 네트워크 구성을 확인해야 한다.
적용 결과 확인
경로를 추가한 후 다시 확인했다.</p>
<pre><code>ip route get xxx.xx.xx.xx</code></pre><p>기존에는 Docker 브리지가 표시됐지만, 변경 후에는 실제 사내망 인터페이스와 게이트웨이가 표시됐다.</p>
<pre><code>xxx.xx.xx.xx via xxx.xx.xx.1 dev ethX src xxx.xx.xx.xx</code></pre><p>저장소 연결도 확인했다.</p>
<pre><code>curl -v --connect-timeout 5 http://xxx.xx.xx.xx/</code></pre><p>이후 패키지 메타데이터를 갱신하고 업데이트를 재시도했다.</p>
<pre><code>dnf clean all</code></pre><pre><code>dnf makecache</code></pre><p>정적 라우트를 추가한 뒤에는 패키지 저장소에 정상적으로 연결됐고, PackageKit 업데이트도 성공했다.
작업 완료 후 원복
이번에 추가한 경로는 업데이트 작업을 위한 임시 조치였기 때문에 작업 완료 후 삭제했다.</p>
<pre><code>ip route del xxx.xx.xx.xx/32 via xxx.xx.xx.1 dev ethX</code></pre><p>삭제 후 다시 경로를 확인했다.</p>
<pre><code>ip route get xxx.xx.xx.xx</code></pre><p>Docker 브리지가 다시 표시되는 것을 확인해 변경 전 상태로 돌아왔음을 확인했다.
단, 원복하면 다음 업데이트 때 같은 문제가 다시 발생한다. 임시 라우트는 문제를 우회한 것이지 네트워크 대역 충돌 자체를 해결한 것은 아니다.
마무리
이번 문제는 오류 메시지만 보면 방화벽 문제처럼 보였다.
하지만 실제 원인은 다음과 같았다.
사내 패키지 저장소와 Harbor Docker 네트워크의 IP 대역이 겹치면서, 저장소 트래픽이 Docker 내부 가상망으로 잘못 전달된 문제</p>
<p>이번에는 /32 정적 라우트를 추가해 특정 저장소 IP만 실제 사내망으로 전달하는 방식으로 업데이트를 완료했다.
장기적으로는 Harbor Docker 네트워크를 사내망과 겹치지 않는 대역으로 변경해야 한다.
이번 작업을 통해 Couldn&#39;t connect, No route to host 같은 메시지가 나타난다고 해서 무조건 방화벽부터 의심할 것은 아니라는 점을 알게 됐다. 연결 실패 문제가 발생하면 다음 순서로 확인하는 것이 좋겠다.</p>
<h3 id="목적지로-실제-어떤-경로가-선택되는지-확인">목적지로 실제 어떤 경로가 선택되는지 확인</h3>
<pre><code>ip route get &lt;목적지 IP&gt;</code></pre><h3 id="전체-라우팅-테이블-확인">전체 라우팅 테이블 확인</h3>
<pre><code>ip route show</code></pre><h3 id="docker-네트워크-대역-확인">Docker 네트워크 대역 확인</h3>
<pre><code>docker network ls
docker network inspect &lt;네트워크 ID&gt;</code></pre><h3 id="실제-tcp-연결-확인">실제 TCP 연결 확인</h3>
<pre><code>curl -v --connect-timeout 5 http://&lt;목적지 IP&gt;/</code></pre><p>결국 중요한 것은 “방화벽이 열려 있는가?”뿐만 아니라
<strong>패킷이 애초에 올바른 길로 나가고 있는가?</strong>였다.</p>
<p>주변 사람한테 물어보지 않고 내 힘으로 이유를 알아서 뿌듯했다!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RHEL 8 비밀번호 재사용 방지 설정: pwhistory.conf가 없어서 시작된...]]></title>
            <link>https://velog.io/@jang_mut_gae/RHEL-8-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%9E%AC%EC%82%AC%EC%9A%A9-%EB%B0%A9%EC%A7%80-%EC%84%A4%EC%A0%95-pwhistory.conf%EA%B0%80-%EC%97%86%EC%96%B4%EC%84%9C-%EC%8B%9C%EC%9E%91%EB%90%9C</link>
            <guid>https://velog.io/@jang_mut_gae/RHEL-8-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%9E%AC%EC%82%AC%EC%9A%A9-%EB%B0%A9%EC%A7%80-%EC%84%A4%EC%A0%95-pwhistory.conf%EA%B0%80-%EC%97%86%EC%96%B4%EC%84%9C-%EC%8B%9C%EC%9E%91%EB%90%9C</guid>
            <pubDate>Mon, 03 Aug 2026 11:19:31 GMT</pubDate>
            <description><![CDATA[<p>오늘 진행한 작업은 RHEL 8 서버에서 다음 보안 정책을 적용하는 것이었다.</p>
<p><em>root 계정을 포함한 사용자 계정이 최근 사용한 비밀번호 4개를 다시 사용하지 못하도록 설정한다.</em></p>
<p>처음 받은 보안 조치 가이드에는 다음과 같이 적혀 있었다.</p>
<pre><code># /etc/security/pwhistory.conf

enforce_for_root
remember=4
file=/etc/security/opasswd</code></pre><p>그리고 /etc/pam.d/system-auth, /etc/pam.d/password-auth에 다음 PAM 모듈을 추가하라고 되어 있었다.</p>
<pre><code>
password    requisite    pam_pwhistory.so remember=4 use_authtok</code></pre><p>가이드만 보면 간단해 보였다. 하지만 실제 서버에는 /etc/security/pwhistory.conf가 없었고, 서버마다 authselect 관리 상태도 달랐다.
결국 이 작업은 단순히 파일 하나를 만드는 문제가 아니라 다음 세 가지를 구분하는 과정이었다.</p>
<blockquote>
<ol>
<li>현재 설치된 pam_pwhistory가 pwhistory.conf를 지원하는가?</li>
<li>/etc/security/opasswd와 pwhistory.conf는 어떤 차이가 있는가?</li>
<li>PAM 파일이 authselect에 의해 관리되고 있는가?</li>
</ol>
</blockquote>
<h3 id="1-첫-번째-문제-pwhistoryconf가-없다">1. 첫 번째 문제: pwhistory.conf가 없다</h3>
<p>먼저 /etc/security를 확인했다.</p>
<pre><code>cd /etc/security</code></pre><p>그런데 가이드에 나온 pwhistory.conf 파일이 없었다.</p>
<p>대신 다음 파일은 존재했다.</p>
<blockquote>
<p>/etc/security/opasswd</p>
</blockquote>
<p>처음에는 pwhistory.conf를 직접 만들어야 하는지 고민했다.</p>
<h4 id="pwhistoryconf와-opasswd의-차이">pwhistory.conf와 opasswd의 차이</h4>
<p>두 파일은 용도가 완전히 다르다.</p>
<blockquote>
<p>/etc/security/pwhistory.confpam</p>
</blockquote>
<ul>
<li>pwhistory의 설정값을 저장하는 설정 파일
모든 PAM 버전에서 지원되는 것은 아님</li>
</ul>
<blockquote>
<p>/etc/security/opasswd</p>
</blockquote>
<ul>
<li>이전 비밀번호의 해시를 실제로 저장하는 비밀번호 이력 파일
관리자가 내용을 직접 작성하는 파일이 아님</li>
</ul>
<p>즉, opasswd는 설정 파일이 아니라 데이터 파일이다.</p>
<h4 id="2-현재-pam-버전의-사용법-확인">2. 현재 PAM 버전의 사용법 확인</h4>
<p>인터넷의 가이드만 믿고 파일을 만들기 전에, 현재 서버에 설치된 모듈의 매뉴얼을 확인했다.</p>
<pre><code>man pam_pwhistory</code></pre><p>man은 매뉴얼 페이지를 보여주는 명령이다. 
설정을 변경하거나 모듈을 실행하는 명령이 아니다.
매뉴얼의 SYNOPSIS에는 다음과 같은 옵션이 나왔다.</p>
<pre><code>pam_pwhistory.so
    [debug]
    [use_authtok]
    [enforce_for_root]
    [remember=N]
    [retry=N]
    [authtok_type=STRING]</code></pre><p>특히 중요한 부분은 다음 두 옵션이었다.</p>
<pre><code>remember=N
enforce_for_root</code></pre><p>의미는 다음과 같다.</p>
<p>remember=4이전 비밀번호 4개를 기억하고 재사용을 차단한다.</p>
<p>enforce_for_rootroot 계정에도 같은 비밀번호 이력 정책을 적용한다.</p>
<p>use_authtok앞의 PAM 모듈에서 입력받은 새 비밀번호를 전달받아 검사한다.</p>
<p>그리고 매뉴얼 하단 FILES에는 다음 내용만 표시됐다.</p>
<pre><code>/etc/security/opasswd
    File with password history</code></pre><p>처음에는 File with password history라는 문장을 보고 pwhistory라는 파일을 만들어야 하는 것으로 착각했다...</p>
<p>하지만 이 문장의 정확한 의미는 다음과 같다.
/etc/security/opasswd는 비밀번호 이력을 저장하는 파일이다.</p>
<p>현재 서버의 매뉴얼 어디에도 /etc/security/pwhistory.conf가 나오지 않았다.</p>
<p>여기서 내린 결론
현재 서버의 pam_pwhistory 버전에서는 별도 pwhistory.conf를 사용하는 방식보다 <strong>PAM 설정 줄에 옵션을 직접 넣는 방식</strong>이 적절했다.</p>
<pre><code>password    requisite    pam_pwhistory.so use_authtok remember=4 enforce_for_root</code></pre><p>opasswd는 직접 만들거나 수정하지 않는다. PAM 모듈이 정상 적용된 후 사용자가 비밀번호를 변경하면 자동으로 이력이 기록된다.</p>
<h3 id="3-두-번째-문제-pam-파일을-직접-수정해도-되는가">3. 두 번째 문제: PAM 파일을 직접 수정해도 되는가?</h3>
<p>비밀번호 이력을 적용하려면 일반적으로 다음 파일을 확인한다.</p>
<blockquote>
<p>/etc/pam.d/system-auth
/etc/pam.d/password-auth</p>
</blockquote>
<p>그런데 파일 상단에서 다음 경고를 발견했다.</p>
<blockquote>
<p> #Generated by authselect
#Do not modify this file manually.</p>
</blockquote>
<p>또는 서버에 따라 다음과 같이 표시되기도 했다.</p>
<blockquote>
<p>#This file is auto-generated.
#User changes will be destroyed the next time authselect is run.</p>
</blockquote>
<p>이 문구가 있으면 무조건 수정하면 안 된다고 생각하기 쉽다. 
하지만 실제 관리 상태를 반드시 확인해야 한다.</p>
<pre><code>authselect current</code></pre><p>여기서 서버마다 결과가 달랐다.</p>
<h4 id="서버-유형-1-authselect로-관리되지-않는-서버">서버 유형 1: authselect로 관리되지 않는 서버</h4>
<p>첫 번째 서버에서는 다음 결과가 나왔다.</p>
<p>No existing configuration detected.
System was not configured with authselect</p>
<p>또한 두 PAM 파일은 심볼릭 링크가 아니라 일반 파일이었다.
ls -l /etc/pam.d/system-auth /etc/pam.d/password-auth
출력 예시:
-rw-r--r--. 1 root root ... /etc/pam.d/system-auth
-rw-r--r--. 1 root root ... /etc/pam.d/password-auth</p>
<p>파일에는 Generated by authselect라는 문구가 남아 있었지만, 현재는 authselect가 해당 구성을 관리하고 있지 않았다.
즉, 파일 상단의 문구만 보는 것이 아니라 다음 결과를 함께 판단해야 했다.
authselect current</p>
<pre><code>ls -l /etc/pam.d/system-auth /etc/pam.d/password-auth</code></pre><p>작업 전 백업
PAM 설정 오류는 로그인 장애로 이어질 수 있으므로 먼저 백업했다.</p>
<pre><code>cp -a /etc/pam.d/system-auth \
  /etc/pam.d/system-auth.bak</code></pre><pre><code>cp -a /etc/pam.d/password-auth \
  /etc/pam.d/password-auth.bak</code></pre><p>PAM 설정 추가
두 파일을 각각 열었다.</p>
<pre><code>vi /etc/pam.d/system-auth
vi /etc/pam.d/password-auth</code></pre><p>password 영역에서 pam_unix.so 바로 위에 다음 줄을 추가했다.</p>
<pre><code>password    requisite    pam_pwhistory.so use_authtok remember=4 enforce_for_root</code></pre><p>최종 순서는 다음과 같았다.</p>
<pre><code>password    requisite    pam_pwquality.so try_first_pass local_users_only retry=3 authtok_type=
password    requisite    pam_pwhistory.so use_authtok remember=4 enforce_for_root
password    sufficient   pam_unix.so sha512 shadow nullok try_first_pass use_authtok
password    required     pam_deny.so</code></pre><p>왜 pam_unix.so 위에 넣어야 하는가?</p>
<p>pam_pwhistory는 <strong>새 비밀번호가 실제로 적용되기 전에 이전 비밀번호와 비교해</strong>야 한다.</p>
<p>따라서 일반적인 순서는 다음과 같다.</p>
<p>pam_pwquality.so비밀번호 복잡도 검사</p>
<p>pam_pwhistory.so이전 비밀번호 재사용 여부 검사</p>
<p>pam_unix.so실제 로컬 비밀번호 변경</p>
<p>pam_deny.so앞의 모듈들이 처리하지 못한 경우 거부</p>
<p>설정 확인</p>
<pre><code>grep -nE &#39;^password.*(pam_pwquality|pam_pwhistory|pam_unix|pam_deny)&#39; \
  /etc/pam.d/system-auth \
  /etc/pam.d/password-auth</code></pre><p>pam_pwhistory의 줄 번호가 pam_unix보다 앞에 있으면 된다.</p>
<h4 id="서버-유형-2-authselect가-실제로-관리하는-서버">서버 유형 2: authselect가 실제로 관리하는 서버</h4>
<p>두 번째 서버에서는 상황이 달랐다.</p>
<pre><code>authselect current</code></pre><p>출력:</p>
<pre><code>Profile ID: sssd
Enabled features:
- with-fingerprint
- with-silent-lastlog</code></pre><p>그리고 PAM 파일 상단에는 다음 내용이 있었다.</p>
<blockquote>
<p>#Generated by authselect
#Do not modify this file manually.</p>
</blockquote>
<p>이 서버에서는 /etc/pam.d/system-auth와 /etc/pam.d/password-auth를 직접 수정하면 안 된다.
직접 수정하더라도 다음에 authselect가 실행되면 변경 내용이 사라질 수 있다.</p>
<ol start="4">
<li>authselect 사용자 정의 프로필 생성
기본 sssd 프로필을 기반으로 사용자 정의 프로필을 생성했다.<pre><code>authselect create-profile pwhistory -b sssd</code></pre>처음에는 다음 명령을 사용했다.<pre><code>authselect create-profile pwhistory -b sssd --symlink-meta</code></pre>생성된 프로필은 다음 경로에 저장됐다.<blockquote>
<p>/etc/authselect/custom/pwhistory</p>
</blockquote>
</li>
</ol>
<p>확인:</p>
<pre><code>ls -l /etc/authselect/custom/pwhistory</code></pre><ol start="5">
<li>사용자 정의 PAM 템플릿 수정
다음 두 파일을 수정했다.<pre><code>vi /etc/authselect/custom/pwhistory/system-auth
vi /etc/authselect/custom/pwhistory/password-auth</code></pre>authselect 템플릿은 일반 PAM 파일보다 복잡하게 보였다.<pre><code>{include if &quot;with-faillock&quot;}
{include if &quot;with-smartcard&quot;}
{if not &quot;without-nullok&quot;:nullok}</code></pre>이것들은 authselect의 기능 활성화 여부에 따라 최종 PAM 설정을 생성하기 위한 조건문이다.
기존 조건문과 pam_sss.so 설정은 수정하거나 삭제하지 않고, pam_unix.so 바로 위에 한 줄만 추가했다.<pre><code>password    requisite    pam_pwhistory.so use_authtok remember=4 enforce_for_root</code></pre>최종 구조는 다음과 같았다.<pre><code>password    requisite    pam_pwquality.so try_first_pass local_users_only
password    requisite    pam_pwhistory.so use_authtok remember=4 enforce_for_root
password    sufficient   pam_unix.so sha512 shadow try_first_pass use_authtok
password    sufficient   pam_sss.so use_authtok
password    required     pam_deny.so</code></pre>두 템플릿에 모두 들어갔는지 확인했다.<pre><code>grep -n pam_pwhistory \
/etc/authselect/custom/pwhistory/system-auth \
/etc/authselect/custom/pwhistory/password-auth</code></pre></li>
<li>생각보다 위험했던 오타
처음 작성한 옵션에는 다음과 같은 오타가 있었다.
enfore_for_root
정확한 옵션은 다음과 같다.
enforce_for_root
enforce에서 c가 빠지면 안 된다. 잘 보고 할 것...!</li>
</ol>
<p>PAM 옵션 오타는 단순히 해당 옵션만 무시되는 것으로 끝나지 않고, <strong>모듈 오류 또는 비밀번호 변경 실패로 이어질 수 있다.</strong></p>
<p>따라서 적용 전 반드시 확인했다.</p>
<pre><code>grep -n pam_pwhistory \
  /etc/authselect/custom/pwhistory/system-auth \
  /etc/authselect/custom/pwhistory/password-auth</code></pre><p>정상 설정:</p>
<pre><code>pam_pwhistory.so use_authtok remember=4 enforce_for_root</code></pre><h4 id="7-기존-authselect-기능을-그대로-유지해야-한다">7. 기존 authselect 기능을 그대로 유지해야 한다</h4>
<p>사용자 정의 프로필을 선택할 때 기존 기능도 모두 다시 지정해야 한다.
현재 설정을 한 줄로 확인했다.</p>
<pre><code>authselect current -r</code></pre><p>출력:</p>
<pre><code>sssd with-fingerprint with-silent-lastlog</code></pre><p>처음에는 다음처럼 with-faillock만 붙이면 되는 것으로 생각했다.</p>
<pre><code>authselect select custom/pwhistory with-faillock</code></pre><p>하지만 이렇게 하면 기존에 활성화되어 있던 다음 기능이 빠질 수 있다.</p>
<blockquote>
<p>with-fingerprint
with-silent-lastlog</p>
</blockquote>
<p>따라서 기존 출력에서 앞의 sssd만 custom/pwhistory로 바꾸고 나머지는 그대로 유지해야 한다.</p>
<pre><code>authselect select custom/pwhistory \
  with-fingerprint with-silent-lastlog</code></pre><ol start="8">
<li>unexpected content 오류
프로필을 선택했지만 다음 오류가 발생했다.<blockquote>
<p>/etc/authselect/system-auth has unexpected content
/etc/authselect/password-auth has unexpected content
Unexpected changes to the configuration were detected.
Refusing to activate profile unless those changes are removed
or overwrite is requested.</p>
</blockquote>
</li>
</ol>
<p>즉, authselect가 관리하는 파일에 예상하지 못한 기존 변경 사항이 있다는 의미였다.
이 상태에서는 새 프로필이 적용되지 않았다.
확인해 보니 활성 프로필은 여전히 기존 프로필이었다.</p>
<pre><code>authselect current</code></pre><p>출력:</p>
<pre><code>Profile ID: sssd</code></pre><p>실제 PAM 파일에서도 pam_pwhistory가 검색되지 않았다.</p>
<ol start="9">
<li>강제 적용 전에 백업</li>
</ol>
<p>--force는 기존 변경 내용을 덮어쓸 수 있으므로 먼저 실제 PAM 파일을 백업했다.</p>
<pre><code>cp -a /etc/pam.d/system-auth \
  /root/system-auth.before-pwhistory

cp -a /etc/pam.d/password-auth \
  /root/password-auth.before-pwhistory</code></pre><p>그 후 기존 기능을 유지한 채 사용자 정의 프로필을 강제로 적용했다.</p>
<pre><code>authselect select custom/pwhistory \
  with-fingerprint with-silent-lastlog --force</code></pre><p>결과:</p>
<pre><code>Profile &quot;custom/pwhistory&quot; was selected.</code></pre><p>authselect도 기존 구성을 다음과 같은 경로에 자동 백업했다.
/var/lib/authselect/backups/...
--force 사용 시 주의
--force는 단순히 경고를 무시하는 옵션이 아니다.
기존 PAM 및 authselect 관리 파일을 선택한 프로필 내용으로 덮어쓸 수 있다. 또한 출력에서 passwd, group, netgroup, automount, services 등의 NSS 맵 관련 경고가 나올 수도 있다.
따라서 다음 조건을 갖춘 뒤 사용해야 한다.
기존 PAM 파일 백업
현재 인증 방식 확인
활성 기능 목록 확인
사용자 정의 프로필 내용 검증
기존 SSH 세션 유지
문제 발생 시 복구 경로 확보</p>
<h4 id="10-적용-결과-확인">10. 적용 결과 확인</h4>
<p>프로필 상태를 확인했다.</p>
<pre><code>authselect current</code></pre><p>기대 결과:</p>
<blockquote>
<p>Profile ID: custom/pwhistory
Enabled features:</p>
</blockquote>
<ul>
<li>with-fingerprint</li>
<li>with-silent-lastlog</li>
</ul>
<p>실제 PAM 파일도 확인했다.</p>
<pre><code>grep -n pam_pwhistory \
  /etc/pam.d/system-auth \
  /etc/pam.d/password-auth</code></pre><p>두 파일 모두 다음 설정이 출력됐다.</p>
<blockquote>
<p>pam_pwhistory.so use_authtok remember=4 enforce_for_root</p>
</blockquote>
<p>SSSD 상태도 확인했다.</p>
<pre><code>systemctl is-active sssd</code></pre><p>정상 결과:</p>
<blockquote>
<p>active</p>
</blockquote>
<p>마지막으로 기존 SSH 연결을 유지한 상태에서 별도의 터미널로 새 SSH 로그인을 확인했다.
PAM 설정을 변경한 뒤에는 현재 세션에서 명령이 잘 실행된다는 것만으로 성공했다고 판단하면 안 된다. 현재 세션은 이미 인증을 마친 상태이기 때문이다.
반드시 새 로그인이나 su를 통해 인증 경로가 정상인지 확인해야 한다.</p>
<hr>
<p>이번 작업에서 확실하게 적용하려던 대상은 root를 포함한 로컬 계정이었다.
삽질 정리
삽질 1. 가이드에 파일이 있으니 직접 만들면 된다고 생각했다
가이드에는 /etc/security/pwhistory.conf가 있었지만 실제 서버의 매뉴얼에는 해당 파일이 없었다.
배운 점
<strong>현재 시스템에 설치된 모듈의 man 페이지와 패키지 버전을 우선 확인해야 한다.</strong></p>
<pre><code>man pam_pwhistory
rpm -q pam</code></pre><p>삽질 2. “File with password history”를 파일 생성 지시로 오해했다
매뉴얼의 다음 표현을 잘못 해석할 수 있었다.
/etc/security/opasswd
    File with password history
이 문장은 pwhistory라는 파일을 만들라는 뜻이 아니라 opasswd에 대한 설명이다.</p>
<p>삽질 3. 파일 상단 문구만 보고 authselect 관리 여부를 판단했다
Generated by authselect
라는 문구가 있어도 현재 authselect 관리 상태가 아닐 수 있었다.
배운 점
<strong>반드시 다음 명령으로 실제 상태를 확인해야 한다.</strong></p>
<pre><code>authselect current
authselect check</code></pre><p>삽질 4. 모든 서버에 같은 방법을 적용하려고 했다
한 서버는 authselect 미사용 상태였고, 다른 서버는 sssd 프로필로 정상 관리되고 있었다.
배운 점
<strong>서버마다 다음을 개별 확인해야 한다.</strong></p>
<pre><code>authselect current
authselect check
ls -l /etc/pam.d/system-auth /etc/pam.d/password-auth</code></pre><p>삽질 5. 기존 authselect 기능을 빼먹을 뻔했다
사용자 정의 프로필을 선택할 때 기존 기능을 다시 지정해야 한다는 점을 놓칠 뻔했다.
배운 점
<strong>다음 명령의 결과를 기준으로 첫 번째 프로필 이름만 교체한다.</strong>
authselect current -r
예시:
sssd with-fingerprint with-silent-lastlog
변경:
authselect select custom/pwhistory <br>  with-fingerprint with-silent-lastlog</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DBeaver에서 MySQL 연결 오류 해결 후 root 비밀번호 변경하기]]></title>
            <link>https://velog.io/@jang_mut_gae/DBeaver%EC%97%90%EC%84%9C-MySQL-%EC%97%B0%EA%B2%B0-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0-%ED%9B%84-root-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jang_mut_gae/DBeaver%EC%97%90%EC%84%9C-MySQL-%EC%97%B0%EA%B2%B0-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0-%ED%9B%84-root-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 31 Jul 2026 01:09:29 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트를 진행하다 DBeaver로 MySQL에 접속하려는데 다음과 같은 오류가 발생했다.</p>
<pre><code>Public Key Retrieval is not allowed</code></pre><p>처음에는 비밀번호가 틀린 줄 알았지만, MySQL 인증 과정에서 공개키를 가져오지 못해 발생한 연결 설정 문제였다.</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/029efa67-c0ed-4461-b9bb-c8fbc560e881/image.png" alt="">
 <img src="https://velog.velcdn.com/images/jang_mut_gae/post/adfcb089-c5e6-4220-a9d5-7083209de282/image.png" alt=""></p>
<h3 id="1-driver-properties-설정">1. Driver properties 설정</h3>
<p>DBeaver의 MySQL 연결 설정에서 Driver properties로 들어간 후 다음 항목을 설정했다.</p>
<pre><code>allowPublicKeyRetrieval = true
useSSL = false</code></pre><p>설정을 저장하고 Test Connection을 실행하니 정상적으로 연결됐다.
useSSL=false는 암호화되지 않은 연결을 사용한다. 
따라서 이 설정은 localhost와 같은 로컬 개발 환경에서만 사용하는 것이 안전하다. 
운영 환경에서는 SSL 인증서를 구성하고 sslMode=REQUIRED 또는 VERIFY_IDENTITY 등을 사용하는 것이 좋다.</p>
<h3 id="2-현재-인증된-계정-확인">2. 현재 인증된 계정 확인</h3>
<p>접속 후 다음 쿼리로 MySQL이 현재 세션을 어떤 계정으로 인증했는지 확인했다.</p>
<p>SELECT CURRENT_USER();</p>
<p>결과가 다음과 같았다.</p>
<p>root@localhost</p>
<pre><code>ALTER USER &#39;root&#39;@&#39;localhost&#39;
IDENTIFIED BY &#39;새로운_비밀번호&#39;;</code></pre><p>를 해주면 비밀번호 재설정이 된다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회사 첫 프로젝트에서 배운 것들: DB 설계부터 인증과 S3까지]]></title>
            <link>https://velog.io/@jang_mut_gae/%ED%9A%8C%EC%82%AC-%EC%B2%AB-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4-DB-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EC%9D%B8%EC%A6%9D%EA%B3%BC-S3%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jang_mut_gae/%ED%9A%8C%EC%82%AC-%EC%B2%AB-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4-DB-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EC%9D%B8%EC%A6%9D%EA%B3%BC-S3%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Wed, 29 Jul 2026 13:09:12 GMT</pubDate>
            <description><![CDATA[<p>올해 회사에서 첫 프로젝트를 해봤다. </p>
<p>오늘은 그 후기를 적어볼까 한다.</p>
<h3 id="db-설계는-한-번에-끝나지-않았다">DB 설계는 한 번에 끝나지 않았다</h3>
<p>나는 DB 설계가 어려운 게 아니라고 생각했다 그런데 회사에서 실제로 들어간 프로젝트는 DB설계를 했음에도 개발 도중에 부딪히거나 비효율적인 부분과 마주할 때가 많아서 종종 수정하기를 반복했다. </p>
<p>예시를 들자면 </p>
<p>상태값이 바뀌어 컬럼 길이를 늘리는 일 같은..!</p>
<p>컬럼 하나가 바뀌면 SQL, Entity, DTO, Mapper, 검증 로직, API 명세와 테스트 데이터까지 영향을 받을 수 있었다. DB 스키마는 단순한 저장 구조가 아니라 여러 계층이 함께 의존하는 계약에 가까웠다.
이 경험을 통해 처음부터 완벽한 설계를 만드는 것만큼 다음 사항이 중요하다는 것을 배웠다.
변경 가능성을 고려해 책임과 관계를 나누는 것
컬럼과 상태값의 의미를 명확하게 정의하는 것
스키마가 변경될 때 영향을 받는 범위를 확인하는 것
변경 이력과 데이터 마이그레이션 방법을 함께 준비하는 것</p>
<p>그리고 팀원의 코드 피드백이 큰 도움이 됐고 요구사항을 여러번 읽어보고 모르는 것을 그때그때 질문해야 한다는 점도 깨닫게 되었다. </p>
<p>사실 혼자 개발할 때는 스케일이 작았으니 인증도 크게 생각을 안 하고 진행했는데 이번에 진행한 프로젝트는 내가 여태 해오던 것보다 확실히 생각할 게 많았고</p>
<h3 id="여러-로그인-방식을-하나의-인증-서비스로-관리했다">여러 로그인 방식을 하나의 인증 서비스로 관리했다</h3>
<p>개인 프로젝트를 할 때는 규모가 작았기 때문에 인증 구조를 깊게 고민하지 않았다. </p>
<p>하지만 이번 프로젝트에는 서비스 사용자 로그인과 관리자 SSO 등 여러 로그인 방식이 존재했다.</p>
<p>각 서비스에서 로그인과 토큰 발급을 중복 구현하면 인증 정책이 서로 달라질 수 있고, 정책이 변경될 때 여러 서비스를 함께 수정해야 한다.</p>
<p>그래서 인증 정책과 토큰 발급 책임을 독립된 Auth 서비스에 모았다. </p>
<p>Auth 서비스가 로그인 방식과 토큰 발급을 담당하고, 나머지 서비스는 발급된 JWT를 검증하는 Resource Server 역할을 맡도록 구성했다.</p>
<p>이를 통해 인증 정책의 책임을 한곳으로 모으고 업무 API가 각자의 도메인 로직에 집중할 수 있도록 했다.</p>
<p>반면 인증 서비스의 장애가 여러 서비스에 영향을 줄 수 있기 때문에 가용성, 키 관리, 토큰 폐기 정책까지 함께 고려해야 한다는 점도 배웠다.</p>
<h3 id="반복되는-dto-변환에는-mapstruct를-사용했다">반복되는 DTO 변환에는 MapStruct를 사용했다</h3>
<p>API 요청·응답 DTO와 내부 DTO, Entity를 분리하면서 객체 간 변환 코드가 반복적으로 발생했다.
이를 줄이기 위해 MapStruct를 사용했다. MapStruct는 컴파일 시점에 매핑 코드를 생성하기 때문에 수동 변환 코드의 반복을 줄이고, 필드 변경으로 발생할 수 있는 일부 매핑 오류를 컴파일 단계에서 확인할 수 있었다.
다만 DTO를 지나치게 세분화하면 클래스와 매핑 코드가 오히려 늘어날 수 있었다. 따라서 모든 객체를 기계적으로 분리하기보다 외부 API 계약과 내부 모델을 분리할 필요가 있는지를 기준으로 판단하는 것이 중요했다.</p>
<h3 id="private-s3와-presigned-url로-파일-접근을-설계했다">Private S3와 presigned URL로 파일 접근을 설계했다</h3>
<p>파일 업로드와 다운로드에는 S3 presigned URL을 사용했다.</p>
<p>Presigned URL을 사용하면 사용자에게 AWS 자격 증명을 전달하지 않고도 특정 S3 객체에 대해 일정 시간 동안 GET 또는 PUT 요청을 수행할 권한을 제공할 수 있다.</p>
<p>파일을 API 서버가 직접 전달하지 않고 클라이언트와 S3가 직접 주고받도록 구성하면 대용량 파일이 API 서버를 통과하지 않는다. 이에 따라 서버의 네트워크, 메모리와 스레드 사용 부담을 줄일 수 있다.</p>
<p>S3 버킷은 Private으로 유지했다. 민감정보가 포함된 파일을 Public bucket이나 영구 URL로 공개하면 URL이 로그, 메신저 또는 브라우저 기록을 통해 유출됐을 때 접근을 통제하기 어렵기 때문이다.</p>
<p>Private bucket과 presigned URL은 같은 개념은 아니다.</p>
<p>Private bucket은 허가받지 않은 사용자의 객체 접근을 기본적으로 차단한다.</p>
<p>Presigned URL은 특정 객체와 작업에 대한 권한을 일정 시간 동안 위임하는 임시 URL이다.</p>
<p>API에서 사용자 인증·인가와 파일 소유권을 확인한 후 URL을 발급하면 사용자는 해당 URL로 S3에 직접 접근할 수 있다.</p>
<p>다만 presigned URL은 만료 전까지 URL을 가진 사람이 사용할 수 있다. 따라서 URL 자체를 임시 자격증명처럼 취급하고, 만료시간을 짧게 설정하며 발급 이력과 파일 접근 권한을 관리해야 한다.</p>
<table>
<thead>
<tr>
<th>Bucket 상태</th>
<th>Presigned URL</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Private</td>
<td>사용</td>
<td>가장 일반적. 임시로 특정 객체 접근 허용</td>
</tr>
<tr>
<td>Private</td>
<td>미사용</td>
<td>백엔드 서버, IAM 사용자·역할 등 권한 보유 주체만 접근</td>
</tr>
<tr>
<td>Public</td>
<td>사용</td>
<td>대체로 불필요. 서명 URL이 없어도 공개 접근 가능</td>
</tr>
<tr>
<td>Public</td>
<td>미사용</td>
<td>누구나 객체 URL로 접근 가능</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[authselect /etc/pam.d 경로 파일들이 뭘까?!]]></title>
            <link>https://velog.io/@jang_mut_gae/authselect-etcpam.d-%EA%B2%BD%EB%A1%9C-%ED%8C%8C%EC%9D%BC%EB%93%A4%EC%9D%B4-%EB%AD%98%EA%B9%8C</link>
            <guid>https://velog.io/@jang_mut_gae/authselect-etcpam.d-%EA%B2%BD%EB%A1%9C-%ED%8C%8C%EC%9D%BC%EB%93%A4%EC%9D%B4-%EB%AD%98%EA%B9%8C</guid>
            <pubDate>Mon, 27 Jul 2026 07:54:34 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요! 오늘은 취약점 조치를 다루다가 알게 된 것에 대해 설명하겠습니닷</p>
<p>/etc/pam.d/system-auth , /etc/pam.d/password-auth에 비밀번호 기억 설정을 하여야 했는데요</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/24b6d41f-ea74-4feb-a4cc-55d609b873d6/image.png" alt=""></p>
<p>무작정 vi할 수 없는 상태인 것이었습니다 -_-;;</p>
<p>authselect는 사용자 인증 설정을 관리해 주는 통합 중앙 통제 도구로 핵심 파일들을 직접 수정하지 못하도록 락을 건다고 합니다.</p>
<p>취약점 파일에 나와 있는 것처럼 /etc/pam.d/system-auth , /etc/pam.d/password-auth 파일을 조치하려면 </p>
<p>커스텀 프로필 새로 만들어서 /etc/authselect/custom/프로필이름 경로로 들어간 후 vi로 열어서 각각 수정 후 
시스템이 커스텀 설정을 바라보도록 강제 적용을 하면 된다고 합니다!</p>
<p>authselect create-profile secure-policy --base-on=sssd</p>
<p>이런식으로 커스텀 프로필 만들어주시고요!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[cron & crontab]]></title>
            <link>https://velog.io/@jang_mut_gae/%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%AA%85%EB%A0%B9%EC%96%B4-crontab-x2qsep2u</link>
            <guid>https://velog.io/@jang_mut_gae/%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%AA%85%EB%A0%B9%EC%96%B4-crontab-x2qsep2u</guid>
            <pubDate>Mon, 27 Jul 2026 07:40:23 GMT</pubDate>
            <description><![CDATA[<p>cron 서비스 - 특정 작업을 정해진 시간에 주기적이고 반복적으로 실행하기 위한 데몬 및 설정</p>
<p>crontab은 리눅스 시스템에서 특정 작업들을 정해진 시간이나 주기에 따라 자동으로 반복 실행하게 해주는 &#39;예약 작업&#39; 도구</p>
<p>쉽게 비유하자면, 매일 아침 8시에 알람이 울리도록 휴대폰에 시간을 설정해두는 것과 비슷하다</p>
<p>주요 기능: 서버에서 매일, 매주, 매달 혹은 특정 시간에 백업, 로그 정리, 데이터 처리 같은 작업들이 자동으로 돌아가도록 예약하는 역할을 한다.</p>
<p>권한 설정: 보안을 위해 이 기능을 아무나 쓰지 못하게 제한할 수 있는데, 그때 사용하는 파일은 cron.allow와 cron.deny이다.</p>
<p>cron.allow에 등록된 계정만 crontab 명령어를 사용할 수 있다.</p>
<p>cron.deny에 등록된 계정은 crontab 명령어를 사용할 수 없다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[sudo 와 su의 차이]]></title>
            <link>https://velog.io/@jang_mut_gae/sudo-%EC%99%80-su%EC%9D%98-%EC%B0%A8%EC%9D%B4</link>
            <guid>https://velog.io/@jang_mut_gae/sudo-%EC%99%80-su%EC%9D%98-%EC%B0%A8%EC%9D%B4</guid>
            <pubDate>Fri, 24 Jul 2026 03:52:23 GMT</pubDate>
            <description><![CDATA[<p>sudo란 명령어 한 줄만 관리자 권한으로 실행하고 바로 내 권한으로 돌아온다. (sudo가 들어간 명령어 한 줄)
su란 현재 쉘 세션 자체를 root로 완전히 교체</p>
<p>sudo su - -&gt; root 사용자로 전환</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DNS 와 Route 53의 역할]]></title>
            <link>https://velog.io/@jang_mut_gae/DNS-%EC%99%80-Route-53%EC%9D%98-%EC%97%AD%ED%95%A0</link>
            <guid>https://velog.io/@jang_mut_gae/DNS-%EC%99%80-Route-53%EC%9D%98-%EC%97%AD%ED%95%A0</guid>
            <pubDate>Thu, 23 Jul 2026 00:49:04 GMT</pubDate>
            <description><![CDATA[<p>아키텍처를 봤을 때 </p>
<p>DNS Resolver -&gt; Amazon Route 53 Authorization DNS Service 라고 되어 있다.</p>
<p>DNS Resolver은 도메인을 ip로 바꿔주는 역할을 하고
Route 53는 내 ip 목록을 알고 있어서 DNS Resolver가 Route 53에게 물어보고, Route 53이 대답해 준 IP를 다시 사용자에게 전달한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[정보처리기사 1회차 합격 후기]]></title>
            <link>https://velog.io/@jang_mut_gae/%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EA%B8%B0%EC%82%AC-1%ED%9A%8C%EC%B0%A8-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@jang_mut_gae/%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EA%B8%B0%EC%82%AC-1%ED%9A%8C%EC%B0%A8-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Mon, 06 Jul 2026 06:00:24 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요! 드디어 정보처리기사 1회차 최종 합격 소식을 들고 찾아왔습니다.</p>
<p>사실 저는 지난 2025년 1회차 시험에 응시했다가 55점으로 고배를 마신 경험이 있습니다. 당시 갑자기 높아진 프로그래밍 문제 난이도에 당황하기도 했고, 결정적으로 2문제 정도 실수를 하는 바람에 아쉽게 떨어져서 제 자신에게 실망도 많이 했었는데요.</p>
<p>올해 1회차 시험을 앞두고는 회사 일과 병행하려니 준비가 생각보다 쉽지 않았습니다. 변명처럼 들릴 수도 있겠지만, 절대적인 공부 시간이 부족해 결국 시험 직전 3일 동안 모든 에너지를 쏟아붓기로 결심했습니다. 토요일 시험이라 금요일에 연차를 쓰고, 목요일 퇴근 후부터 본격적인 &#39;3일 스파르타&#39; 공부에 돌입했습니다..</p>
<p>💡 저만의 합격 전략: &quot;이론은 무조건 다 맞힌다&quot;
작년 시험을 보며 프로그래밍 문제는 기출 수준보다 난이도가 올라갔고, 어떤 언어에서 까다롭게 나올지 예측하기 어렵다고 느꼈습니다. 그래서 이번에는 차라리 이론 점수를 확실하게 확보하자는 전략을 세웠습니다.</p>
<p>하지만 3일이라는 짧은 시간 동안 두꺼운 이론서를 처음부터 끝까지 읽는 것은 불가능했기에, 핵심 요약 PDF 교재를 구매해 달달 외우는 방식을 택했습니다. 제가 활용한 자료는 두 가지입니다.</p>
<p>감자 정처기 실기 요약 (<a href="https://jeongcheogi.edugamja.com/">링크</a>)</p>
<p>꿈꾸는 라이언 요약집 (저는 네이버 쇼핑을 통해 구매했는데, 참고하실 수 있도록 관련 링크를 첨부합니다. <a href="https://blog.naver.com/dreaming_ryan/223378483931">링크</a>)</p>
<p>프로그래밍 및 기출문제 팁</p>
<p>프로그래밍 문제는 2021년 이후 기출문제를 반복해서 풀며 감을 익혔습니다. 참고로 위에 언급한 감자 사이트에서 실기 기출문제를 직접 풀어볼 수 있는데요. 무료 열람 횟수가 제한되어 있으니 잘 활용해 보시기 바랍니다.</p>
<p>직장인이라도 전략을 잘 짜고 집중한다면 짧은 시간 안에도 충분히 합격할 수 있습니다. 정처기를 준비하시는 모든 분의 합격을 진심으로 응원합니다!</p>
<p><img src="https://velog.velcdn.com/images/jang_mut_gae/post/f516e8be-8a72-4de2-8ec1-dde822c6de6a/image.png" alt=""></p>
<p>덧붙여 저는 이번 달 말에 토익스피킹 시험을 앞두고 있습니다. 토스도 열심히 준비해서 조만간 좋은 후기로 다시 찾아오겠습니다. 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS/DevOps] 배치 배포부터 스케줄링까지]]></title>
            <link>https://velog.io/@jang_mut_gae/AWSDevOps-%EB%B0%B0%EC%B9%98-%EB%B0%B0%ED%8F%AC%EB%B6%80%ED%84%B0-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%A7%81%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jang_mut_gae/AWSDevOps-%EB%B0%B0%EC%B9%98-%EB%B0%B0%ED%8F%AC%EB%B6%80%ED%84%B0-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%A7%81%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 02 Jul 2026 02:17:12 GMT</pubDate>
            <description><![CDATA[<p><strong>도커를 통해 새로운 이미지가 생겼는지? (Build &amp; Deploy)</strong></p>
<p>가장 시작점이 되는 부분으로, 내가 코드를 고쳐서 깃허브에 올렸을 때 자동으로 일어나는 흐름입니다.</p>
<p>GitHub Actions (CI/CD): Spring Batch 코드를 수정하고 main 브랜치에 push하면 배포 파이프라인이 구동됩니다.</p>
<p>Docker 이미지 빌드: 가상 서버가 켜지면서 프로젝트의 자바 코드를 가지고 Docker 이미지를 빌드합니다.</p>
<p>AWS ECR(Elastic Container Registry) 업로드: 빌드된 따끈따끈한 새 이미지는 AWS의 도커 창고인 ECR로 업로드(push)됩니다.</p>
<p>ECS 태스크 정의(Task Definition) 업데이트: &quot;이제부터 이 새로운 버전의 도커 이미지를 바라보고 서버를 띄워라!&quot; 하고 ECS 최신 리비전(Revision) 버전을 갱신해 줍니다.</p>
<hr>
<p><strong>EventBridge Scheduler로 구동하고 ECS 태스크 떴는지? (Trigger &amp; Orchestration)</strong></p>
<p>배치가 매일 새벽 1시처럼 정해진 시간마다 알아서 켜지고 꺼지는 마법이 일어나는 구역!</p>
<p>Amazon EventBridge Scheduler: 내가 설정한 크론식(Cron expression, 예: 0 1 * * ? *)에 맞춰 새벽에 알람을 울립니다.</p>
<p>ECS RunTask 명령 전달: 알람이 울리면, EventBridge는 ECS에게 &quot;미리 정의된 Spring Batch 태스크 정의를 가지고 컨테이너(서버) 하나 딱 실행해(RunTask)&quot;라고 트리거를 보냅니다.</p>
<p>ECS에 태스크(Task) 등장 ➔ 실행 ➔ 종료:</p>
<p>ECS Fargate 환경에서 가상 서버 컴퓨터가 순식간에 프로비저닝(할당)됩니다.</p>
<p>&quot;태스크가 떴는지?&quot; 확인하는 순간이 바로 이때입니다. ECS 콘솔에서 PROVISIONING ➔ PENDING ➔ RUNNING 상태로 컨테이너가 정상적으로 실행됩니다.</p>
<p>컨테이너 안에서 Spring Batch App이 구동되면서 내가 짠 배치 로직을 수행합니다.</p>
<p>작업이 다 끝나면 Batch 프로세스가 정상 종료되고, ECS 태스크도 스스로 STOPPED 상태가 되며 서버 자원을 반납합니다. (이래서 비용이 엄청 아껴집니다)</p>
<hr>
<p><strong>CloudWatch에서 확인하기 (Monitoring &amp; Logs)</strong>
내 배치가 잘 돌았는지, 중간에 에러는 안 났는지 개발자가 추적하는 관제탑 영역입니다.</p>
<p>CloudWatch Logs: ECS 태스크 정의를 만들 때 로그 드라이버를 awslogs로 지정해 두면, Spring Batch가 실행되면서 콘솔에 찍는 System.out.println이나 log.info() 같은 모든 로그가 CloudWatch의 특정 로그 그룹(Log Group)으로 실시간 스트리밍됩니다.</p>
<blockquote>
<p>logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /ecs/프로젝트명 같은..
        awslogs-region: ap-northeast-2
        awslogs-stream-prefix: ecs</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 새로운 서버에 접속했을 때 가장 먼저 치는 명령어 (OS, CPU, RAM, Storage 스펙 확인)]]></title>
            <link>https://velog.io/@jang_mut_gae/Linux-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%84%9C%EB%B2%84%EC%97%90-%EC%A0%91%EC%86%8D%ED%96%88%EC%9D%84-%EB%95%8C-%EA%B0%80%EC%9E%A5-%EB%A8%BC%EC%A0%80-%EC%B9%98%EB%8A%94-%EB%AA%85%EB%A0%B9%EC%96%B4-OS-CPU-RAM-Storage-%EC%8A%A4%ED%8E%99-%ED%99%95%EC%9D%B8</link>
            <guid>https://velog.io/@jang_mut_gae/Linux-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%84%9C%EB%B2%84%EC%97%90-%EC%A0%91%EC%86%8D%ED%96%88%EC%9D%84-%EB%95%8C-%EA%B0%80%EC%9E%A5-%EB%A8%BC%EC%A0%80-%EC%B9%98%EB%8A%94-%EB%AA%85%EB%A0%B9%EC%96%B4-OS-CPU-RAM-Storage-%EC%8A%A4%ED%8E%99-%ED%99%95%EC%9D%B8</guid>
            <pubDate>Thu, 02 Jul 2026 02:06:31 GMT</pubDate>
            <description><![CDATA[<p>새로운 프로젝트에 투입되거나, AWS EC2/ECS 인프라를 세팅할 때, 혹은 CI/CD 배포 파이프라인(GitHub Actions 등) 로그에서 컨테이너 내부 환경을 점검해야 할 때가 있습니다.
이때 내가 지금 제어하고 있는 서버의 기본적인 스펙(OS, CPU, 메모리, 저장공간)을 한눈에 종합 선물 세트처럼 확인하기 위해 자주 쓰는 스크립트와 그 명령어들의 의미를 정리해 보았습니다.</p>
<p>1.종합 스크립트 한 번에 실행하기:
새로운 서버의 터미널에 접속한 뒤 아래 명령어를 한 줄로 묶어서 실행하면, 스펙이 구획별로 깔끔하게 출력됩니다.
echo &quot;=== OS ===&quot; &amp;&amp; cat /etc/os-release &amp;&amp; <br>echo &quot;=== CPU ===&quot; &amp;&amp; lscpu &amp;&amp; <br>echo &quot;=== Memory ===&quot; &amp;&amp; free -h &amp;&amp; <br>echo &quot;=== Storage ===&quot; &amp;&amp; df -h </p>
<p>2.OS 신분증 확인: 
/etc/os-release:운영체제 및 버전 정보.
리눅스는 Ubuntu, CentOS, Amazon Linux 등 종류가 다양합니다.
이 서버의 정확한 OS 이름과 버전을 알고 싶을 때 이 파일을 열어봅니다./etc 폴더는 리눅스에서 <strong>시스템의 중요 환경설정 파일(Editable Text Configuration)</strong>이 모여있는 방입니다.</p>
<p>cat /etc/os-release를 치면 NAME=&quot;Ubuntu&quot;, VERSION=&quot;22.04&quot; 같은 명확한 신분증 정보가 출력됩니다.</p>
<p>3.CPU 용량 체크: lscpu:CPU 아키텍처 및 코어 수.
lscpu 명령어는 현재 서버의 CPU 스펙을 상세히 보여줍니다. 
아키텍처가 x86_64 인지 아님 AWS Graviton 같은 arm64 인지, 그리고 CPU 코어(Core) 수가 몇 개인지 확인할 수 있어 애플리케이션의 멀티스레드 설정을 최적화할 때 요긴합니다.</p>
<p>4.메모리 잔량 체크:
free -h:RAM 사용량 모니터링.백엔드 개발자에게 가장 중요한 RAM 용량을 확인합니다. 
뒤에 붙는 -h 옵션은 Human-readable의 약자로, 바이트 단위의 복잡한 숫자를 G(Giga), M(Mega) 처럼 사람이 읽기 편한 단위로 변환해 줍니다.
배포 직후 힙 메모리 부족으로 인한 OOM(Out Of Memory) 에러가 의심될 때 가장 먼저 쳐봐야 하는 필수 명령어입니다.</p>
<p>5.창고 용량 체크:
df -h:Disk / Storage 남은 공간.df는 Disk Free의 약자로, 서버 하드디스크의 남은 용량을 리포트해 줍니다. 파일 업로드 기능(AWS S3 연동 전 임시 로컬 저장 등)이나 로그 파일이 쌓여 서버 가용 공간이 100%가 되면 서버가 뻗어버릴 수 있으므로, 주기적인 디스크 잔량 확인은 필수입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[필터 vs 인터셉터 역할 비교]]></title>
            <link>https://velog.io/@jang_mut_gae/%ED%95%84%ED%84%B0-vs-%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EC%97%AD%ED%95%A0-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@jang_mut_gae/%ED%95%84%ED%84%B0-vs-%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EC%97%AD%ED%95%A0-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Tue, 03 Feb 2026 00:58:38 GMT</pubDate>
            <description><![CDATA[<table>
<thead>
<tr>
<th>레이어</th>
<th>역할</th>
<th>수행 시점</th>
</tr>
</thead>
<tbody><tr>
<td>Filter</td>
<td>JWT 검증, DB 조회 후 사용자 정보 RequestAttribute에 저장</td>
<td>Spring Security FilterChain</td>
</tr>
<tr>
<td>Interceptor</td>
<td>RequestAttribute 존재 여부 확인 → 인증/권한 검증</td>
<td>Spring MVC Controller 직전</td>
</tr>
</tbody></table>
<blockquote>
<p>RequestAttribute란?
HttpServletRequest의 request 범위에 임시 데이터를 저장하는 공간.</p>
</blockquote>
<pre><code>request.setAttribute(&quot;userInfo&quot;, userDto);</code></pre><p>&quot;userInfo&quot; → key</p>
<p>userDto → UserSimpleDto, AdminDto 등 객체</p>
<p>request가 끝나면 사라짐 (request scope)</p>
<p>Filter에서 JWT 확인 + DB 조회 후 사용자 정보를 request에 저장</p>
<p>Interceptor 체크 예제</p>
<pre><code>UserSimpleDto userInfo = RequestAttributeUtils.getUserInfo(request);
if (userInfo == null) {
    throw new HttpException(ResultCode.UNAUTHORIZED);
}</code></pre><p>JWT 없거나 DB 조회 실패 → request에 userInfo 없음 → 401 Unauthorized</p>
<blockquote>
<p>Filter는 “누구인지 찾는 일”
Interceptor는 “찾았는지 확인하는 일”</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security 6.x User/Admin Filter & Interceptor 구조]]></title>
            <link>https://velog.io/@jang_mut_gae/Spring-Security-6.x-UserAdmin-Filter-Interceptor-%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@jang_mut_gae/Spring-Security-6.x-UserAdmin-Filter-Interceptor-%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Tue, 03 Feb 2026 00:51:07 GMT</pubDate>
            <description><![CDATA[<ol>
<li>전체 흐름</li>
</ol>
<p><strong>JWT → SecurityFilterChain → Custom Filter → Interceptor → Controller/Service</strong></p>
<ol start="2">
<li><p>user 구조</p>
<pre><code>@Component
public class UserInfoFilter extends OncePerRequestFilter {

 private final UserService userService;
 private final ObjectMapper objectMapper;

 public UserInfoFilter(UserService userService, ObjectMapper objectMapper) {
     this.userService = userService;
     this.objectMapper = objectMapper;
 }

 @Override
 protected void doFilterInternal(HttpServletRequest req,
                                 HttpServletResponse res,
                                 FilterChain chain)
         throws ServletException, IOException {
     try {
         Authentication auth = SecurityContextHolder.getContext().getAuthentication();
         if (auth instanceof JwtAuthenticationToken jwt &amp;&amp; jwt.getToken() != null) {
             Long userKey = jwt.getToken().getClaim(&quot;userKey&quot;);
             if (userKey == null) { writeError(res, ResultCode.UNAUTHORIZED); return; }
             var user = userService.getUserInfo(userKey);
             if (user == null) { writeError(res, ResultCode.UNAUTHORIZED); return; }
             req.setAttribute(&quot;userInfo&quot;, user);
         }
         chain.doFilter(req, res);
     } catch (Exception e) { writeError(res, ResultCode.INTERNAL_SERVER_ERROR); }
 }

 private void writeError(HttpServletResponse res, ResultCode code) throws IOException {
     if(res.isCommitted()) return;
     res.resetBuffer();
     res.setStatus(code.getHttpStatus().value());
     res.setContentType(&quot;application/json&quot;);
     objectMapper.writeValue(res.getWriter(), Map.of(&quot;code&quot;, code.getCode(), &quot;desc&quot;, code.getDescription()));
 }
}
</code></pre></li>
</ol>
<pre><code>
Interceptor</code></pre><p>@Component
public class UserInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
        if(RequestAttributeUtils.getUserInfo(req) == null)
            throw new HttpException(ResultCode.UNAUTHORIZED);
        return true;
    }
}</p>
<pre><code>
SecurityConfig</code></pre><p>@Configuration
public class SecurityConfig {</p>
<pre><code>private final UserInfoFilter userInfoFilter;

public SecurityConfig(UserInfoFilter userInfoFilter) {
    this.userInfoFilter = userInfoFilter;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable)
        .cors(cors -&gt; cors.disable())
        .authorizeHttpRequests(auth -&gt; auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -&gt; oauth2.jwt(Customizer.withDefaults()))
        .addFilterAfter(userInfoFilter, BearerTokenAuthenticationFilter.class);
    return http.build();
}</code></pre><p>}</p>
<pre><code>
3. Admin 구조
Filter</code></pre><p>@Component
public class AdminInfoFilter extends OncePerRequestFilter {</p>
<pre><code>private final AdminUserService service;
private final ObjectMapper mapper;

public AdminInfoFilter(AdminUserService service, ObjectMapper mapper) {
    this.service = service;
    this.mapper = mapper;
}

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
        throws ServletException, IOException {
    try {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwt &amp;&amp; jwt.getToken() != null) {
            Long key = jwt.getToken().getClaim(&quot;adminKey&quot;);
            if(key == null) { writeError(res, ResultCode.UNAUTHORIZED); return; }
            var admin = service.getAdminInfo(key);
            if(admin == null) { writeError(res, ResultCode.UNAUTHORIZED); return; }
            req.setAttribute(&quot;userInfo&quot;, admin);
        }
        chain.doFilter(req, res);
    } catch(Exception e){ writeError(res, ResultCode.INTERNAL_SERVER_ERROR); }
}

private void writeError(HttpServletResponse res, ResultCode code) throws IOException {
    if(res.isCommitted()) return;
    res.resetBuffer();
    res.setStatus(code.getHttpStatus().value());
    res.setContentType(&quot;application/json&quot;);
    mapper.writeValue(res.getWriter(), Map.of(&quot;code&quot;, code.getCode(), &quot;desc&quot;, code.getDescription()));
}</code></pre><p>}</p>
<pre><code>
Interceptor
</code></pre><p>@Component
public class AdminInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
        if(RequestAttributeUtils.getUserInfo(req) == null)
            throw new HttpException(ResultCode.UNAUTHORIZED);
        return true;
    }
}</p>
<pre><code>SecurityConfig
</code></pre><p>@Configuration
public class AdminSecurityConfig {</p>
<pre><code>private final AdminInfoFilter adminInfoFilter;

public AdminSecurityConfig(AdminInfoFilter filter) { this.adminInfoFilter = filter; }

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.securityMatcher(&quot;/admin/**&quot;)
        .csrf(AbstractHttpConfigurer::disable)
        .cors(cors -&gt; cors.disable())
        .authorizeHttpRequests(auth -&gt; auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -&gt; oauth2.jwt(Customizer.withDefaults()))
        .addFilterAfter(adminInfoFilter, BearerTokenAuthenticationFilter.class);
    return http.build();
}</code></pre><p>}</p>
<pre><code>| 항목                   | User                                                          | Admin                |
| -------------------- | ------------------------------------------------------------- | -------------------- |
| Filter               | UserInfoFilter                                                | AdminInfoFilter      |
| Interceptor          | UserInfoInterceptor                                           | AdminInfoInterceptor |
| RequestAttribute key | &quot;userInfo&quot;                                                    | &quot;userInfo&quot; (공용)      |
| SecurityFilterChain  | `/api/**`                                                     | `/admin/**`          |
| addFilter 위치         | `.addFilterAfter(..., BearerTokenAuthenticationFilter.class)` | 동일                   |


✅ Tip

.addFilter(filter, Class)는 6.x에서 제거됨 → .addFilterBefore / .addFilterAfter 사용

Interceptor는 단순 검증만, DB 조회는 Filter에서 처리

공용 RequestAttribute(&quot;userInfo&quot;)로 User/Admin 구분 가능


---

이렇게 UserInfoInterceptor / AdminInfoInterceptor 이 각각 존재하면

장점

User/Admin 인증 로직 분리 → 역할별로 세밀하게 커스터마이징 가능

유지보수 시, User쪽과 Admin쪽 로직 충돌 없음

단점

**_코드 중복_**: 지금 인터셉터는 사실 “RequestAttribute 존재 여부 확인”만 함

라는 장단점이 있다.

나는 코드중복을 좋아하지 않기 때문에 합치고 싶어 ai에게 물어봤다..

그렇게 

하나의 **공용 인터셉터**로 통합
</code></pre><p>@Component
public class InfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
        if(RequestAttributeUtils.getUserInfo(req) == null)
            throw new HttpException(ResultCode.UNAUTHORIZED);
        return true;
    }
}</p>
<pre><code>

장점

코드 단순화 → User/Admin 공용

단점

특정 경로(/admin/**, /api/**)에서만 적용하려면, WebMvcConfigurer에서 path별 등록 필요
</code></pre><p>@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(infoInterceptor)
            .addPathPatterns(&quot;/api/<strong>&quot;, &quot;/admin/</strong>&quot;);
}</p>
<pre><code>
필터에서 이미 User/Admin 구분해서 RequestAttribute 저장하므로, 인터셉터는 단순 체크만으로 충분

_결론_

필터에서 이미 JWT → DB 조회 → RequestAttribute 까지 처리하니까

인터셉터는 단순히 RequestAttribute 존재 여부만 체크하면 O

그래서 굳이 User/Admin 두 개 만들 필요는 없고, 공용 InfoInterceptor 하나로 충분


&gt; UserInfoFilter → JWT 확인 + DB 조회 → RequestAttribute
AdminInfoFilter → JWT 확인 + DB 조회 → RequestAttribute
InfoInterceptor → RequestAttribute 존재 여부 확인 (공용)


이런 구조가 되었다.
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[MapStruct로 DTO 매핑하기: Abstract Class와 Interface 활용법]]></title>
            <link>https://velog.io/@jang_mut_gae/MapStruct%EB%A1%9C-DTO-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0-Abstract-Class%EC%99%80-Interface-%ED%99%9C%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@jang_mut_gae/MapStruct%EB%A1%9C-DTO-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0-Abstract-Class%EC%99%80-Interface-%ED%99%9C%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Mon, 26 Jan 2026 05:11:14 GMT</pubDate>
            <description><![CDATA[<p>프로그래밍을 하다 보면, <strong>엔티티(Entity)와 DTO(Data Transfer Object)</strong>를 변환해야 하는 경우가 많습니다.
하지만 엔티티 구조가 복잡하거나, DTO에 필요한 필드가 엔티티와 다를 때 단순 매핑만으로는 부족합니다.</p>
<p>MapStruct를 활용해 이런 문제를 깔끔하게 해결하는 방법을 그림과 함께 알아보자!</p>
<h3 id="1-문제-상황">1. 문제 상황</h3>
<p>게시글과 첨부 파일을 관리하는 구조를 생각해봅시다.</p>
<p>게시글(ArticleEntity)에는 여러 첨부 파일이 있음</p>
<p>파일 타입은 IMAGE, VIDEO 등으로 구분</p>
<p>프론트엔드에 전달할 DTO(ArticleDTO)는</p>
<p>대표 파일(mainFile)</p>
<p>모든 이미지 목록(images)</p>
<p>단순 MapStruct 매핑만으로는 조건부 선택이나 리스트 필터링을 처리하기 어렵다.</p>
<h3 id="2-해결-전략">2. 해결 전략</h3>
<table>
<thead>
<tr>
<th>Mapper 타입</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Interface Mapper</strong></td>
<td>단순 필드 매핑용, 필드 이름 거의 동일할 때</td>
</tr>
<tr>
<td><strong>Abstract Class + @AfterMapping</strong></td>
<td>조건부 로직, 리스트 필터링 등 복잡한 매핑 처리</td>
</tr>
</tbody></table>
<h3 id="3-데이터-구조">3. 데이터 구조</h3>
<p>ArticleEntity
├─ articleId
├─ title
└─ List<ArticleFileEntity>
     ├─ fileId
     ├─ fileType (IMAGE, VIDEO)
     └─ fileUrl</p>
<p>  ArticleDTO
├─ id
├─ headline
├─ mainFile (대표 파일)
└─ List<ArticleFile> (이미지 목록)</p>
<h3 id="4-코드-예제">4. 코드 예제</h3>
<p>  1) Interface Mapper (단순 파일 변환)</p>
<pre><code>  @Mapper(componentModel = &quot;spring&quot;)
public interface ArticleFileMapper {
    @Mapping(target = &quot;id&quot;, source = &quot;fileId&quot;)
    @Mapping(target = &quot;type&quot;, source = &quot;fileType&quot;)
    @Mapping(target = &quot;url&quot;, source = &quot;fileUrl&quot;)
    ArticleFile mapToDto(ArticleFileEntity file);
}</code></pre><p>  2) Abstract Class Mapper (게시글 매핑 + 조건부 파일 처리)</p>
<pre><code>@Mapper(componentModel = &quot;spring&quot;, uses = ArticleFileMapper.class)
public abstract class ArticleMapper {

    @Autowired
    protected ArticleFileMapper fileMapper;

    @Mapping(target = &quot;id&quot;, source = &quot;articleId&quot;)
    @Mapping(target = &quot;headline&quot;, source = &quot;title&quot;)
    @Mapping(target = &quot;mainFile&quot;, ignore = true)
    @Mapping(target = &quot;images&quot;, ignore = true)
    public abstract ArticleDTO mapToDto(ArticleEntity entity);

    @AfterMapping
    protected void handleFiles(ArticleEntity entity, @MappingTarget ArticleDTO dto) {
        if (entity.getArticleFiles() == null) return;

        dto.setImages(
            entity.getArticleFiles().stream()
                  .filter(f -&gt; &quot;IMAGE&quot;.equals(f.getFileType()))
                  .map(fileMapper::mapToDto)
                  .toList()
        );

        dto.setMainFile(
            entity.getArticleFiles().stream()
                  .filter(f -&gt; &quot;VIDEO&quot;.equals(f.getFileType()))
                  .findFirst()
                  .map(fileMapper::mapToDto)
                  .orElse(null)
        );
    }
}
</code></pre><h3 id="5-매핑-흐름-그림">5. 매핑 흐름 그림</h3>
<p>  ArticleEntity
      │
      │ mapToDto (Abstract Class Mapper)
      ▼
 ArticleDTO
 ├─ id (매핑)
 ├─ headline (매핑)
 ├─ images  ──────────┐
 │   (IMAGE만 필터링)  │
 │                     ▼
 │               ArticleFileDTO
 │
 └─ mainFile ──────────┐
     (VIDEO/EXT_VIDEO) │
                        ▼
                  ArticleFileDTO</p>
<p>-Abstract Class Mapper에서 @AfterMapping을 이용해 조건부 필터링과 리스트 매핑을 처리
-Interface Mapper는 단순 필드 변환 담당</p>
<h3 id="정리">정리</h3>
<blockquote>
<p>Interface Mapper → 단순 매핑</p>
</blockquote>
<blockquote>
<p>Abstract Class Mapper → 조건부 로직, 리스트 필터링, 후처리</p>
</blockquote>
<blockquote>
<p>@AfterMapping → DTO 완성 후 추가 처리</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[이미지 띄우려다 Kubernetes 개념이 연결된 날]]></title>
            <link>https://velog.io/@jang_mut_gae/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%9D%84%EC%9A%B0%EB%A0%A4%EB%8B%A4-Kubernetes-%EA%B0%9C%EB%85%90%EC%9D%B4-%EC%97%B0%EA%B2%B0%EB%90%9C-%EB%82%A0</link>
            <guid>https://velog.io/@jang_mut_gae/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%9D%84%EC%9A%B0%EB%A0%A4%EB%8B%A4-Kubernetes-%EA%B0%9C%EB%85%90%EC%9D%B4-%EC%97%B0%EA%B2%B0%EB%90%9C-%EB%82%A0</guid>
            <pubDate>Thu, 22 Jan 2026 01:58:47 GMT</pubDate>
            <description><![CDATA[<p>Spring Boot는 환경변수만 바라보고,
Docker는 실행만 담당하고,
Kubernetes는 환경(ConfigMap/Secret)과 생명주기를 관리한다.</p>
<p>Spring Boot + Docker + Kubernetes 배포 흐름 정리
1️⃣ kubectl, k, kns 같은 명령어의 정체
🔹 k</p>
<p>kubectl의 alias</p>
<p>보통 alias k=kubectl 로 설정되어 있음</p>
<p>type k 또는 alias 명령으로 확인 가능</p>
<p>🔹 kns</p>
<p>namespace 전환용 스크립트/alias</p>
<p>내부적으로 kubectl config set-context --current --namespace=... 실행</p>
<p>그래서 kns로 바꾸면 이후 k get pods가 해당 namespace 기준으로 나옴</p>
<p>2️⃣ Pod / Deployment 관계 (핵심 개념)
🔹 Pod</p>
<p>컨테이너가 실제로 실행되는 최소 단위</p>
<p>죽여도 됨 → Deployment가 자동으로 새 Pod 생성</p>
<p>🔹 Deployment</p>
<p>“Pod를 몇 개, 어떤 이미지로 유지할지” 정의한 관리자</p>
<p>Deployment를 지우면 → Pod 재생성 안 됨</p>
<p>그래서 Deployment는 함부로 삭제 ❌</p>
<p>📌 정리</p>
<p>Pod = 일꾼
Deployment = 인사팀 (사람 죽으면 다시 뽑음)</p>
<p>3️⃣ Terminating 상태란?
STATUS: Terminating</p>
<p>Pod가 정상 종료 중</p>
<p>내부적으로:</p>
<p>SIGTERM 전달</p>
<p>애플리케이션 종료 대기</p>
<p>grace period 지나면 강제 종료</p>
<p>📌 보통 발생하는 경우</p>
<p>Pod 삭제</p>
<p>Deployment 업데이트 (Rolling Update)</p>
<p>이미지 변경</p>
<p>👉 정상적인 상태</p>
<p>4️⃣ ConfigMap / Secret의 역할
🔹 ConfigMap</p>
<p>일반 설정값</p>
<p>예: URL, 포트, 옵션 값</p>
<p>평문 OK</p>
<p>🔹 Secret</p>
<p>민감 정보</p>
<p>예: DB 비밀번호, 토큰, JASYPT 키</p>
<p>base64로 저장 (암호화 아님)</p>
<p>📌 둘 다 환경변수로 Pod에 주입됨</p>
<p>5️⃣ envFrom / env 개념
🔹 envFrom
envFrom:</p>
<ul>
<li>configMapRef:
  name: xxx</li>
<li>secretRef:
  name: yyy</li>
</ul>
<p>ConfigMap / Secret의 모든 key를 환경변수로 주입</p>
<p>Spring에서는 ${ENV_NAME}으로 바로 사용 가능</p>
<p>📌 그래서 key 이름이 중요</p>
<p>ConfigMap / Secret</p>
<p>application.yaml</p>
<p>코드
👉 이 3개가 정확히 매핑돼야 함</p>
<p>6️⃣ application.yaml의 역할 (중요)
❌ 하면 안 되는 것</p>
<p>실제 값(비밀번호, 토큰)을 직접 작성</p>
<p>✅ 해야 하는 것
spring:
  datasource:
    url: jdbc:mysql://${DB_WRITER_HOST}:${DB_WRITER_PORT}/ccare
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}</p>
<p>jasypt:
  encryptor:
    password: ${JASYPT_ENCRYPTOR_PASSWORD}</p>
<p>📌 application.yaml은</p>
<p>“환경변수 이름을 연결해주는 설계도”</p>
<p>7️⃣ 배포 흐름 전체 그림
① Docker 이미지 생성</p>
<p>Dockerfile로 이미지 빌드</p>
<p>Harbor 같은 레지스트리에 push</p>
<p>② Kubernetes 리소스 준비</p>
<p>Deployment.yaml</p>
<p>Service.yaml</p>
<p>ConfigMap.yaml</p>
<p>Secret.yaml</p>
<p>③ ConfigMap / Secret 먼저 적용
kubectl apply -f configmap.yaml
kubectl apply -f secret.yaml</p>
<p>④ Deployment 적용
kubectl apply -f deployment.yaml</p>
<p>👉 이 시점에:</p>
<p>Pod 생성</p>
<p>envFrom으로 환경변수 주입</p>
<p>Spring Boot 실행</p>
<p>8️⃣ 왜 ConfigMap / Secret 파일을 프로젝트에 안 올려도 되나?</p>
<p>환경마다 값이 다름</p>
<p>보안 이슈</p>
<p>서버에서만 관리하는 경우 많음</p>
<p>📌 그래서 보통:</p>
<p>템플릿만 공유</p>
<p>실제 값은 서버에서 직접 관리</p>
<p>9️⃣ 이미지 태그 (.Values.harbor.image.tag) 관련</p>
<p>Helm 사용 시:</p>
<p>values.yaml에서 관리</p>
<p>직접 바꾸는 게 아니라 배포 시 주입</p>
<p>사람이 yaml 직접 고쳐서 배포 ❌</p>
]]></description>
        </item>
    </channel>
</rss>