<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>JangWanJung's development blog</title>
    <link>https://jangwanjung.tistory.com/</link>
    <description>email : tjdrj530@naver.com

전화번호 : 010-9985-8941</description>
    <language>ko</language>
    <pubDate>Wed, 3 Jun 2026 02:01:54 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>JangWanJung</managingEditor>
    <item>
      <title>JWT 시크릿키가 유출되면 생기는 일들</title>
      <link>https://jangwanjung.tistory.com/78</link>
      <description>&lt;h1&gt;JWT_SECRET이 유출되면 서비스는 끝난다&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;서명 키 하나 유출된 것뿐인데 뭐가 그렇게 심각한가요?&quot;&lt;br /&gt;만약 이렇게 생각하신다면 이 글을 꼭 읽어보세요.&lt;br /&gt;JWT_SECRET은 단순한 설정값이 아니라, &lt;b&gt;당신 서비스의 마스터 키&lt;/b&gt;입니다.&lt;br /&gt;유출 시 공격자가 어떤 일까지 할 수 있는지, 실제 공격 시나리오로 보여드립니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  문제점 &amp;mdash; JWT_SECRET의 진짜 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서 JWT 기반 인증을 구현할 때 흔히 이런 코드를 작성합니다:&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Component
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value(&quot;${jwt.secret}&quot;) String secret) {
        this.secretKey = new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8), 
            Jwts.SIG.HS256.key().build().getAlgorithm()
        );
    }

    public String createJwt(String username, String role) {
        return Jwts.builder()
                .claim(&quot;username&quot;, username)
                .claim(&quot;role&quot;, role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + 86400000L))
                .signWith(secretKey)   // &amp;larr; 이 키로 서명
                .compact();
    }

    public String getUsername(String token) {
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token).getPayload()
                .get(&quot;username&quot;, String.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;jwt.secret&lt;/code&gt;이 &lt;b&gt;JWT_SECRET&lt;/b&gt;입니다. 대부분 환경변수나 &lt;code&gt;application.yml&lt;/code&gt;에 저장하죠.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT_SECRET은 &quot;신분증 발급 도장&quot;입니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 &quot;서명된 신분증&quot;입니다. 서버는 사용자가 제출한 토큰의 서명을 &lt;code&gt;JWT_SECRET&lt;/code&gt;으로 검증해서 &lt;b&gt;&quot;내가 발급한 진짜 토큰&quot;인지&lt;/b&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; ️ &lt;b&gt;토큰 발급 시 서명&lt;/b&gt;: &quot;이 토큰은 내가 만든 것이다&quot;라고 도장 찍기&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;토큰 검증 시 대조&lt;/b&gt;: 제출된 토큰의 도장이 내 도장과 같은지 확인&lt;/li&gt;
&lt;li&gt;&lt;i&gt;HS256(대칭키) 방식이면 서명 키 = 검증 키*&lt;/i&gt;입니다. 이 키 하나만 유출되면 공격자도 &lt;b&gt;진짜와 구별 불가능한 토큰&lt;/b&gt;을 무제한으로 발급할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유출 경로는 생각보다 다양합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 직접 겪거나 뉴스에서 본 JWT_SECRET 유출 경로들:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  &lt;b&gt;Git 실수 커밋&lt;/b&gt; &amp;mdash; &lt;code&gt;application.yml&lt;/code&gt;에 하드코딩한 채 푸시. git 히스토리는 영원히 남습니다.&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;환경변수 덤프&lt;/b&gt; &amp;mdash; &lt;code&gt;.pem&lt;/code&gt; 키 탈취 공격자가 &lt;code&gt;/proc/{PID}/environ&lt;/code&gt; 읽기&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;로그 파일&lt;/b&gt; &amp;mdash; &lt;code&gt;log.info(&quot;JWT 설정: {}&quot;, jwtSecret)&lt;/code&gt; 같은 실수&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;CI/CD 로그 노출&lt;/b&gt; &amp;mdash; GitHub Actions 로그에서 mask 처리 누락&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;Actuator 엔드포인트 노출&lt;/b&gt; &amp;mdash; &lt;code&gt;/actuator/env&lt;/code&gt;가 열려 있으면 환경변수 전체가 외부에 노출&lt;/li&gt;
&lt;li&gt;&lt;i&gt;한 번 유출되면 &quot;JWT_SECRET을 바꾸지 않는 한 영원히 유효&quot;*&lt;/i&gt;하다는 점이 가장 무섭습니다. 그리고 대부분의 팀은 유출 사실 자체를 인지하지 못합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실제 해킹 시나리오 &amp;mdash; JWT_SECRET이 유출됐을 때&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 운영 중인 서비스를 예로 들어 공격자 관점에서 전체 공격 사슬을 재구성해보겠습니다. 제 서비스는 간단히 말하면 &lt;b&gt;외부 API 키를 사용자별로 저장하고, 그 키로 자동화 작업을 수행하는 백엔드&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;공격 사슬 전체 그림&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[Stage 0] JWT_SECRET 획득
    &amp;darr;
[Stage 1] 유효한 username 수집
    &amp;darr;
[Stage 2] 임의 사용자의 JWT 위조
    &amp;darr;
[Stage 3] 관리자 권한 탈취
    &amp;darr;
[Stage 4] 타겟 사용자의 민감 데이터 접근
    &amp;darr;
[Stage 5] 자금/자산 유출
    &amp;darr;
[Stage 6] 흔적 지우기&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 0 &amp;mdash; JWT_SECRET 획득&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자 김해커 씨가 어떤 경로로든 JWT_SECRET을 손에 넣었다고 가정합시다. GitHub에서 우연히 발견했을 수도 있고, 내부자에게서 구매했을 수도 있습니다. 이 시점에서 공격자는 &lt;b&gt;서비스의 모든 사용자로 행세할 수 있는 능력&lt;/b&gt;을 갖춘 셈입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 1 &amp;mdash; 정찰: 유효한 username 수집&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자는 JWT를 위조할 수 있지만 &quot;누구 행세를 할지&quot; 정해야 합니다. 타겟의 username을 알아내는 경로는 여러 가지가 있습니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;경로 A &amp;mdash; 회원가입 응답 분석&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# 중복 체크 허용 여부 확인
curl -X POST https://target.com/api/user \
     -H &quot;Content-Type: application/json&quot; \
     -d '{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;test&quot;,&quot;authCode&quot;:&quot;test&quot;}'
# &amp;rarr; &quot;이미 사용중인 아이디&quot; 응답이 나오면 admin 계정 존재 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;경로 B &amp;mdash; 로그인 응답 시간 차이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UserNotFoundException&lt;/code&gt;과 비밀번호 불일치의 응답 시간이 미묘하게 다르면, 타이밍 공격으로 username 존재 여부 판별 가능.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;경로 C &amp;mdash; OSINT&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타겟 기업의 개발자 GitHub/LinkedIn/블로그에서 자주 쓰는 username 유추. 보통 &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;{회사명}_admin&lt;/code&gt;, 창업자 실명, 개발자 실명 등이 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 2 &amp;mdash; JWT 위조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;secret과 username이 있으면 위조는 Python 몇 줄이면 됩니다:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import jwt
import time

SECRET = &quot;유출된_JWT_SECRET_값&quot;

# 관리자 권한 토큰 위조
payload = {
    &quot;username&quot;: &quot;admin&quot;,
    &quot;role&quot;: &quot;ADMIN&quot;,
    &quot;iat&quot;: int(time.time()),
    &quot;exp&quot;: int(time.time()) + 86400
}

forged_token = jwt.encode(payload, SECRET, algorithm=&quot;HS256&quot;)
print(forged_token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vy...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버는 이 토큰을 진짜와 구별할 방법이 없습니다.&lt;/b&gt; 서명 검증이 통과되니까요.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 위조 토큰으로 즉시 접근
curl -b &quot;Authorization=eyJhbGc...&quot; https://target.com/user
# &amp;rarr; admin의 사용자 페이지가 정상적으로 반환됨&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 3 &amp;mdash; 권한 확장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 서비스에서 JWT의 &lt;code&gt;role&lt;/code&gt; claim을 그대로 권한 판단에 쓰기 때문에, 공격자는 JWT에 &lt;code&gt;role: &quot;ADMIN&quot;&lt;/code&gt;을 넣어 즉시 관리자 행세가 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잘 만들어진 서비스는 JWT의 role을 신뢰하지 않고 DB에서 다시 읽어오는데, 그마저도 &lt;b&gt;공격자가 username을 &quot;admin&quot;으로 위조&lt;/b&gt;하면 DB에서 실제 관리자 정보를 로드해버리므로 무력화됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 4 &amp;mdash; 타겟 사용자의 민감 데이터 접근&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 공격자는 누구든 원하는 사용자로 로그인 가능합니다. 특정 타겟의 JWT를 만들어:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# 자산이 많은 타겟 사용자로 위장
victim_token = jwt.encode({
    &quot;username&quot;: &quot;victim_user&quot;,
    &quot;role&quot;: &quot;USER&quot;,
    &quot;exp&quot;: int(time.time()) + 3600
}, SECRET, algorithm=&quot;HS256&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# 피해자의 계좌 잔고 조회
curl -b &quot;Authorization=$victim_token&quot; \
     https://target.com/api/exchange/accounts
# &amp;rarr; 피해자의 전체 자산 현황이 반환됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;피해자의 API 키는 DB에 AES-256-GCM으로 암호화되어 있습니다.&lt;/b&gt; 훌륭한 방어책이지만, &lt;b&gt;공격자는 키 원문을 알 필요가 없습니다.&lt;/b&gt; 피해자로 로그인된 상태이므로 서버가 알아서 복호화해서 API를 호출해주니까요:&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# 피해자 명의로 주문 실행
curl -X POST -b &quot;Authorization=$victim_token&quot; \
     -d 'market=KRW-BTC&amp;amp;side=ask&amp;amp;order_type=market&amp;amp;volume=0.5' \
     https://target.com/api/exchange/orders
# &amp;rarr; 피해자의 BTC가 시장가로 매도됨&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 5 &amp;mdash; API 키 교체 (더 교활한 변형)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 즉시 대규모 거래를 일으키면 피해자가 눈치챕니다. 더 스마트한 공격은:&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;# 피해자 계정의 API 키를 공격자 것으로 교체
curl -X POST -b &quot;Authorization=$victim_token&quot; \
     -H &quot;Content-Type: application/json&quot; \
     -d '{&quot;exchangeType&quot;:&quot;BITHUMB&quot;,&quot;apiKey&quot;:&quot;공격자_KEY&quot;,&quot;apiSecretKey&quot;:&quot;공격자_SECRET&quot;}' \
     https://target.com/api/exchange/key&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 피해자의 서비스가 &lt;b&gt;공격자의 외부 계정에 대고 작업&lt;/b&gt;을 합니다. 피해자는 자기 봇이 정상 돌고 있다고 믿지만, 실제 수익은 공격자 쪽으로 쌓입니다. &lt;b&gt;며칠, 몇 주 조용히 털어갈 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 6 &amp;mdash; 흔적 지우기 (JWT의 치명적 약점)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 세션 방식이면 DB에 &quot;누가 언제 로그인했다&quot;는 기록이 남습니다. 그래서 침해 후 포렌식이 가능하죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JWT는 stateless&lt;/b&gt; &amp;mdash; 서버가 로그인 기록을 따로 남기지 않습니다. 공격자가 언제 어떤 토큰을 썼는지 추적할 방법이 없습니다. 로그에는 &quot;정상적인 API 호출&quot;로만 찍힐 뿐입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이게 결정적인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 웹 취약점들(CSRF, XSS, SQL Injection 등)은 &lt;b&gt;피해자의 행동&lt;/b&gt;이나 &lt;b&gt;특정 입력&lt;/b&gt;이 있어야 성립합니다. 하지만 JWT_SECRET 유출은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;피해자와 상호작용이 전혀 필요 없음&lt;/b&gt; &amp;mdash; 공격자 혼자서 완결 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대상을 고를 수 있음&lt;/b&gt; &amp;mdash; 자산이 많은 사용자만 선별 공격&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시에 모든 계정 탈취 가능&lt;/b&gt; &amp;mdash; 한 명이 아니라 전체 사용자가 타겟&lt;/li&gt;
&lt;li&gt;&lt;b&gt;탐지가 사실상 불가능&lt;/b&gt; &amp;mdash; 정상 요청과 구별 안 됨&lt;br /&gt;한 마디로 &lt;b&gt;&quot;서비스의 루트 권한을 훔쳐간 것&quot;&lt;/b&gt;과 같은 수준의 사고입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT_SECRET은 단순한 설정값이 아닙니다. &lt;b&gt;서비스 전체의 신뢰를 지탱하는 루트 키&lt;/b&gt;입니다. 이 키 하나의 유출이 &lt;b&gt;수만 명의 사용자 자산을 위협&lt;/b&gt;할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 개발자들이 JWT를 &quot;편리한 stateless 인증&quot; 정도로만 인식하지만, 그 편리함의 뒷면에는 &lt;b&gt;&quot;서명 키가 곧 모든 권한&quot;&lt;/b&gt;이라는 무거운 책임이 있습니다. 로그인 기록을 남기지 않는 특성 때문에 &lt;b&gt;사고 발생 시 추적도 어렵습니다.&lt;/b&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;b&gt;JWT_SECRET은 절대 환경변수에 평문으로 두지 마세요.&lt;/b&gt; Parameter Store나 Secrets Manager로 옮기세요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유출됐다고 의심되면 즉시 로테이션하세요.&lt;/b&gt; &quot;아직 공격이 없으니 괜찮겠지&quot;라는 판단이 가장 위험합니다.&lt;br /&gt;보안은 한 번 뚫리면 되돌릴 수 없습니다. 특히 &lt;b&gt;돈과 자산이 걸린 서비스&lt;/b&gt;라면 더더욱 그렇습니다. 오늘 30분만 투자해서 시크릿 관리를 점검해보시길 강력히 권합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; ️ 태그: #JWT #보안 #SpringBoot #AWS #ParameterStore #백엔드 #인증 #DevSecOps #시크릿관리 #해킹방어&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>트레이드마인/보안</category>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/78</guid>
      <comments>https://jangwanjung.tistory.com/78#entry78comment</comments>
      <pubDate>Thu, 16 Apr 2026 23:55:43 +0900</pubDate>
    </item>
    <item>
      <title>SSH Google Authenticator 2FA 적용 후 GitHub Actions 배포가 막혔을 때 &amp;mdash; 전용 배포 사용자로 해결하기</title>
      <link>https://jangwanjung.tistory.com/77</link>
      <description>&lt;h1&gt;SSH&amp;nbsp;Google&amp;nbsp;Authenticator&amp;nbsp;2FA&amp;nbsp;적용&amp;nbsp;후&amp;nbsp;GitHub&amp;nbsp;Actions&amp;nbsp;배포가&amp;nbsp;막혔을&amp;nbsp;때&amp;nbsp;&amp;mdash;&amp;nbsp;전용&amp;nbsp;배포&amp;nbsp;사용자로&amp;nbsp;해결하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안을 강화했더니 CI/CD 파이프라인이 망가졌습니다.&lt;br /&gt;&lt;code&gt;ssh: handshake failed: unable to authenticate&lt;/code&gt; 에러를 마주한 개발자라면 이 글이 도움이 될 겁니다.&lt;br /&gt;보안도 지키고 자동 배포도 유지하는 현실적인 해결법을 정리했습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  문제점 &amp;mdash; 2FA가 자동화를 막아버렸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서 EC2 서버에 Google Authenticator 기반 SSH 2차 인증을 적용했습니다. &lt;code&gt;.pem&lt;/code&gt; 키가 유출되어도 OTP 없이는 접속 불가능한, 아주 튼튼한 방어막이 생겼죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 다음날 아침, 코드를 푸시하자 GitHub Actions에서 이런 에러가 뜨기 시작했습니다:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;2026/04/16 12:24:02 error copy file to dest: ***, error message: 
ssh: handshake failed: ssh: unable to authenticate, 
attempted methods [none publickey], no supported methods remain
drone-scp error: error copy file to dest: ***, error message: 
ssh: handshake failed: ssh: unable to authenticate, 
attempted methods [none publickey], no supported methods remain&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배포 파이프라인이 완전히 막혔습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이런 일이 발생했을까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서 &lt;code&gt;sshd_config&lt;/code&gt;에 아래 설정을 넣었습니다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;AuthenticationMethods publickey,keyboard-interactive&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 &lt;b&gt;&quot;모든 SSH 접속에 &lt;code&gt;.pem&lt;/code&gt; 키 AND OTP를 요구&quot;&lt;/b&gt;합니다. 쉼표가 AND의 의미라 둘 다 통과해야 접속이 허용되죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람이 직접 접속할 때는 문제 없습니다. 핸드폰 꺼내서 OTP 6자리 입력하면 되니까요. 하지만 &lt;b&gt;GitHub Actions는 사람이 아닙니다&lt;/b&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;OTP 앱을 열 수 없고&lt;/li&gt;
&lt;li&gt;6자리 코드를 입력할 방법이 없습니다&lt;br /&gt;에러 메시지를 해석하면 이해가 쉽습니다:&lt;/li&gt;
&lt;li&gt;&lt;code&gt;attempted methods [none publickey]&lt;/code&gt;: GitHub Actions가 공개키로만 시도함&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no supported methods remain&lt;/code&gt;: 서버가 keyboard-interactive(OTP 단계)도 요구하는데 GitHub Actions가 그걸 처리할 수 없어서 포기&lt;/li&gt;
&lt;li&gt;&lt;i&gt;보안을 올렸더니 자동화가 끊긴 고전적인 딜레마*&lt;/i&gt;입니다.&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;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 해결 방법 &amp;mdash; 전용 배포 사용자 + 제한된 권한&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;올바른 해결책의 핵심 아이디어는:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;사람용 계정은 강한 2FA를 유지하고, 자동화용 계정은 제한된 권한으로만 동작하게 한다&quot;&lt;br /&gt;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;code&gt;ubuntu&lt;/code&gt; 같은 일반 사용자는 2FA 필수 유지, &lt;b&gt;새로 만든 &lt;code&gt;deploy&lt;/code&gt; 전용 사용자만&lt;/b&gt; &lt;code&gt;.pem&lt;/code&gt; 키로 통과시키되 &lt;b&gt;그 사용자가 할 수 있는 일을 배포 작업에만 제한&lt;/b&gt;하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[ubuntu 계정]   &amp;mdash; 개발자 SSH 접속용 &amp;mdash; .pem + OTP 필수 (기존 유지)
[deploy 계정]   &amp;mdash; GitHub Actions 전용 &amp;mdash; .pem만 허용, 하지만 권한은 배포에만 한정&lt;/code&gt;&lt;/pre&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 Actions는 OTP 없이 배포 가능 ✅&lt;/li&gt;
&lt;li&gt;사람의 접속은 여전히 2FA 보호 ✅&lt;/li&gt;
&lt;li&gt;deploy 키가 유출되어도 &lt;b&gt;배포 작업 외의 침해는 불가능&lt;/b&gt; ✅Step 1 &amp;mdash; 배포 전용 사용자 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# 비밀번호 없이 deploy 사용자 생성
sudo adduser --disabled-password --gecos &quot;&quot; deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;--disabled-password&lt;/code&gt; 옵션으로 비밀번호 로그인 자체를 차단합니다. 이 사용자는 SSH 키로만 접속 가능합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 2 &amp;mdash; GitHub Actions 전용 SSH 키 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 &lt;code&gt;.pem&lt;/code&gt; 키와 분리된 새 키&lt;/b&gt;를 만드는 게 중요합니다. 한 키가 여러 용도에 쓰이면 유출 시 피해가 커지니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 PC에서:&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;ssh-keygen -t ed25519 -f deploy_key -C &quot;github-actions-deploy&quot; -N &quot;&quot;&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;&lt;code&gt;-t ed25519&lt;/code&gt;: 최신 타원곡선 알고리즘 (RSA보다 짧고 안전)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-N &quot;&quot;&lt;/code&gt;: passphrase 없음 (자동화용이라 필요)&lt;br /&gt;실행하면 두 파일이 생성됩니다:&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deploy_key&lt;/code&gt; &amp;mdash; 개인키 (GitHub Secrets에 저장할 것)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deploy_key.pub&lt;/code&gt; &amp;mdash; 공개키 (서버에 등록할 것)Step 3 &amp;mdash; 공개키를 서버에 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;# 서버에서
sudo nano /home/deploy/.ssh/authorized_keys&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;deploy_key.pub&lt;/code&gt; 내용을 복사해서 붙여넣습니다. 저장 후 권한 설정:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한이 중요합니다. SSH는 &lt;code&gt;authorized_keys&lt;/code&gt;의 권한이 너무 열려 있으면 &lt;b&gt;보안상 키를 무시&lt;/b&gt;해버립니다. &lt;code&gt;600&lt;/code&gt;과 소유자 &lt;code&gt;deploy&lt;/code&gt;가 정확해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 4 &amp;mdash; PAM에서 deploy 사용자만 OTP 예외 처리&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo nano /etc/pam.d/sshd&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 위의 설정을 다음과 같이 수정합니다:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 기존 (전체 사용자 OTP 요구)
# auth required pam_google_authenticator.so nullok

# 변경 &amp;mdash; deploy 사용자는 OTP 건너뛰기
auth [success=1 default=ignore] pam_succeed_if.so user = deploy
auth required pam_google_authenticator.so nullok&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pam_succeed_if.so&lt;/code&gt;가 &lt;b&gt;&quot;현재 사용자가 &lt;code&gt;deploy&lt;/code&gt;라면 다음 한 줄을 건너뛰어라&quot;&lt;/b&gt;라는 의미입니다. 즉 &lt;code&gt;deploy&lt;/code&gt; 사용자만 OTP 단계를 생략하게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 5 &amp;mdash; SSH 설정에서 deploy 사용자만 인증 방식 완화&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sudo nano /etc/ssh/sshd_config.d/99-2fa.conf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 기본: 모두 publickey + OTP
UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
PasswordAuthentication no
PermitRootLogin no

# deploy 사용자만 publickey만으로 통과
Match User deploy
    AuthenticationMethods publickey
    PasswordAuthentication no&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의&lt;/b&gt;: &lt;code&gt;Match User&lt;/code&gt; 블록은 &lt;b&gt;파일의 가장 마지막&lt;/b&gt;에 있어야 합니다. 그 아래에 다른 전역 설정이 또 있으면 그것까지 deploy 전용 설정으로 잡아먹혀 버립니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 6 &amp;mdash; deploy 사용자의 권한 최소화 ⭐ 가장 중요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기가 &lt;b&gt;이 방법의 핵심&lt;/b&gt;입니다. &lt;code&gt;deploy&lt;/code&gt; 사용자가 접속은 가능하지만, &lt;b&gt;배포 작업 외에는 아무것도 못 하게&lt;/b&gt; 만들어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 배포 대상 디렉터리의 소유권을 deploy에게 부여:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;sudo chown -R deploy:deploy /opt/app&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 앱 재시작에만 필요한 명령을 &lt;code&gt;sudo&lt;/code&gt; 없이 실행할 수 있게 합니다. 개별 명령마다 sudoers에 등록하는 건 문법 에러도 자주 나고 관리도 힘드니, &lt;b&gt;배포용 스크립트 하나로 묶는 방식&lt;/b&gt;이 깔끔합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 스크립트 작성:&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;sudo nano /opt/app/restart-app.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용:&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/bin/bash
set -e

echo &quot;&amp;gt;&amp;gt;&amp;gt; 기존 프로세스 종료&quot;
PID=$(/usr/bin/lsof -t -i:8080 || true)
if [ -n &quot;$PID&quot; ]; then
    /bin/kill -15 $PID
    sleep 5
    if /bin/kill -0 $PID 2&amp;gt;/dev/null; then
        /bin/kill -9 $PID
    fi
    sleep 2
fi

echo &quot;&amp;gt;&amp;gt;&amp;gt; 애플리케이션 재시작&quot;
/bin/systemctl restart myapp
/bin/systemctl status myapp --no-pager&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;소유자를 root로 고정&lt;/b&gt;하는 것이 중요합니다 (deploy가 수정하면 권한 탈취 가능):&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;sudo chmod 755 /opt/app/restart-app.sh
sudo chown root:root /opt/app/restart-app.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 스크립트 하나만 &lt;code&gt;sudo&lt;/code&gt; 예외 처리:&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo visudo -f /etc/sudoers.d/deploy&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;deploy ALL=(ALL) NOPASSWD: /opt/app/restart-app.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;visudo&lt;/code&gt;는 저장 시 문법 검사를 자동으로 해줍니다. 에러가 나면 저장을 거부해서 시스템이 망가지는 걸 방지합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 7 &amp;mdash; 설정 검증&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# SSH 설정 문법 검사
sudo sshd -t

# deploy 사용자에게 어떤 인증 방식이 요구되는지 확인
sudo sshd -T -C user=deploy | grep -i authenticationmethods
# &amp;rarr; &quot;authenticationmethods publickey&quot; 가 나와야 정상

# ubuntu 사용자는 여전히 2FA 요구하는지 확인
sudo sshd -T -C user=ubuntu | grep -i authenticationmethods
# &amp;rarr; &quot;authenticationmethods publickey,keyboard-interactive&quot; 가 나와야 정상&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 사용자의 인증 요구사항이 다르게 나오면 설정이 제대로 적용된 겁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 8 &amp;mdash; SSH 재시작 및 로컬 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;반드시 기존 SSH 세션을 열어둔 채로&lt;/b&gt; 재시작하세요.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;sudo systemctl restart ssh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;새 터미널&lt;/b&gt;에서 deploy 사용자로 로컬 테스트:&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;ssh -i deploy_key deploy@{서버주소}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OTP 입력 없이 바로 deploy 쉘로 들어가면 성공입니다. 이걸 확인해야 GitHub Actions도 작동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 막히면 &lt;code&gt;-v&lt;/code&gt; 옵션으로 디버깅:&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;ssh -i deploy_key -v deploy@{서버주소}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 9 &amp;mdash; GitHub Secrets 교체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리포지토리의 &lt;b&gt;Settings &amp;rarr; Secrets and variables &amp;rarr; Actions&lt;/b&gt; 에서:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret 이름&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EC2_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deploy&lt;/code&gt; (기존: &lt;code&gt;ubuntu&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EC2_SSH_PRIVATE_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deploy_key&lt;/code&gt; 파일 전체 내용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;code&gt;-----BEGIN OPENSSH PRIVATE KEY-----&lt;/code&gt; 부터&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-----END OPENSSH PRIVATE KEY-----&lt;/code&gt; 까지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모든 줄바꿈 유지&lt;/b&gt;한 채 전체 복사Step 10 &amp;mdash; 배포 스크립트 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;deploy.sh&lt;/code&gt; (혹은 &lt;code&gt;.github/workflows/*.yml&lt;/code&gt;) 에서 재시작 부분을 수정:&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;# JAR 파일 교체 (deploy가 /opt/app 소유자라 sudo 불필요)
cp /tmp/app.jar /opt/app/app.jar

# 애플리케이션 재시작 (sudo로 스크립트 실행)
sudo /opt/app/restart-app.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;로컬의 &lt;code&gt;deploy_key&lt;/code&gt; 파일은 GitHub Secrets 등록 후 즉시 삭제&lt;/b&gt;하세요. 어디에도 남겨두지 마세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚠️ 주의사항 &amp;mdash; 꼭 챙길 것들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 설정 변경 전 기존 세션 유지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSH 설정을 잘못 저장하면 새 접속이 막힙니다. 작업 중인 터미널은 절대 닫지 말고, 새 터미널로 검증한 뒤에 닫으세요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;code&gt;Match User&lt;/code&gt; 블록의 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sshd_config&lt;/code&gt;에서 &lt;code&gt;Match&lt;/code&gt; 블록 이후의 설정은 모두 해당 조건에 속해버립니다. 반드시 &lt;b&gt;파일의 맨 마지막&lt;/b&gt;에 배치하세요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 배포 스크립트 소유자는 root&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/opt/app/restart-app.sh&lt;/code&gt;의 소유자가 &lt;code&gt;deploy&lt;/code&gt;면 공격자가 스크립트 내용을 조작해서 root 권한으로 임의 명령을 실행할 수 있습니다. &lt;b&gt;반드시 &lt;code&gt;root:root&lt;/code&gt; 소유&lt;/b&gt;여야 합니다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;ls -la /opt/app/restart-app.sh
# -rwxr-xr-x 1 root root ... restart-app.sh  &amp;larr; 이렇게 나와야 함&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. deploy 키는 한 번만 로컬에 존재&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;deploy_key&lt;/code&gt;를 GitHub Secrets에 등록한 후 &lt;b&gt;로컬 파일은 즉시 삭제&lt;/b&gt;하세요. 나중에 다시 필요하면 새로 생성하는 게 낫습니다. 키 복사본이 여러 곳에 남을수록 유출 위험이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  최종 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용 전후 변화&lt;/h3&gt;
&lt;table style=&quot;height: 133px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 23px;&quot;&gt;
&lt;th style=&quot;height: 23px;&quot;&gt;항목&lt;/th&gt;
&lt;th style=&quot;height: 23px;&quot;&gt;적용 전&lt;/th&gt;
&lt;th style=&quot;height: 23px;&quot;&gt;적용 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;SSH 2FA&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;전체 사용자 적용, CI/CD 막힘&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;사람은 2FA, 자동화는 제한 권한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;GitHub Actions 배포&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;❌ 실패&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;✅ 정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;개발자 접속 보안&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;✅ 2FA 보호&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;✅ 2FA 보호 (변화 없음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;배포 키 유출 시 피해&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;(기존: 전체 서버 침해)&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;앱 재시작 범위로 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;최소 권한 원칙&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; ️ 태그: #AWS #EC2 #SSH #GitHubActions #CICD #2FA #보안 #DevOps #최소권한원칙 #PAM #sudoers&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>트레이드마인/보안</category>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/77</guid>
      <comments>https://jangwanjung.tistory.com/77#entry77comment</comments>
      <pubDate>Thu, 16 Apr 2026 23:43:16 +0900</pubDate>
    </item>
    <item>
      <title>EC2 서버에 Google Authenticator로 SSH 2차 인증 구축하기 &amp;mdash; .pem 키 탈취 방어 실전</title>
      <link>https://jangwanjung.tistory.com/76</link>
      <description>&lt;h1&gt;EC2 서버에 Google Authenticator로 SSH 2차 인증 구축하기 &amp;mdash; &lt;code&gt;.pem&lt;/code&gt; 키 탈취 방어 실전&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS EC2 운영 중 &lt;code&gt;.pem&lt;/code&gt; 키가 유출되면 서버에 그대로 뚫린다는 사실, 알고 계셨나요?&lt;br /&gt;이 글에서는 &lt;b&gt;실제로 제가 운영 중인 암호화폐 거래봇 서버&lt;/b&gt;에 Google Authenticator 기반 2차 인증을 적용한 전 과정을 정리합니다.&lt;br /&gt;문제 인식 &amp;rarr; 실제 해킹 시나리오 &amp;rarr; 해결 방법 &amp;rarr; 검증까지 단계별로 다룹니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  문제점 &amp;mdash; &lt;code&gt;.pem&lt;/code&gt; 키 단일 인증의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS EC2의 기본 SSH 접속 방식은 &lt;b&gt;공개키 인증 하나만&lt;/b&gt;으로 이루어집니다. &lt;code&gt;.pem&lt;/code&gt; 파일만 있으면 누구나 서버에 접속할 수 있다는 뜻이죠.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 제 서버 구조의 취약점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 운영 중인 서비스는 Spring Boot 기반 백엔드가 EC2에서 돌아가고, 배포 시 환경변수로 중요 설정값(DB 접속정보, API 인증키, 서명 키 등)을 주입합니다. 문제는 이 환경변수들이 &lt;b&gt;실행 중인 프로세스의 메모리 공간에 평문으로 존재&lt;/b&gt;한다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 공격자가 &lt;code&gt;.pem&lt;/code&gt; 키를 탈취하면:&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. SSH 접속
ssh -i stolen-key.pem ubuntu@{서버주소}

# 2. 애플리케이션 PID 확인
ps aux | grep java

# 3. 프로세스 환경변수 덤프
sudo cat /proc/{PID}/environ | tr '\0' '\n'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 줄이면 &lt;b&gt;서비스의 모든 민감 정보가 평문으로 노출&lt;/b&gt;됩니다. DB 비밀번호부터 외부 서비스 API 키까지 전부요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;.pem&lt;/code&gt; 키는 생각보다 쉽게 유출됩니다&lt;/h3&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;  &lt;code&gt;.pem&lt;/code&gt; 파일을 깜빡하고 Git 저장소에 커밋&lt;/li&gt;
&lt;li&gt;  이메일이나 메신저로 팀원과 키 공유&lt;/li&gt;
&lt;li&gt;☁️ 클라우드 드라이브 동기화 폴더에 보관&lt;/li&gt;
&lt;li&gt;  퇴사자의 로컬 백업에 남아있는 키&lt;br /&gt;그래서 &lt;b&gt;&lt;code&gt;.pem&lt;/code&gt; 키 유출을 &quot;가능한 시나리오&quot;가 아니라 &quot;언젠가 발생할 사건&quot;으로 가정&lt;/b&gt;하고 방어선을 추가해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실제 해킹 시나리오 &amp;mdash; 한 줄씩 따라가 보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 제 서비스가 &lt;code&gt;.pem&lt;/code&gt; 키 유출로 공격받는다면 어떤 일이 벌어질지, 공격자 관점에서 재구성해보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 0 &amp;mdash; &lt;code&gt;.pem&lt;/code&gt; 키 획득&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자 김해커 씨가 팀원 A의 노트북을 카페에서 잠시 훔쳐봤거나, A가 실수로 GitHub에 올린 &lt;code&gt;.pem&lt;/code&gt; 키를 자동 스캐너로 수집했다고 가정합시다. 이 시점에서 공격자는 이미 &lt;b&gt;정당한 사용자와 구별 불가능한 접근권한&lt;/b&gt;을 손에 넣었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 1 &amp;mdash; SSH 접속 성공&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ ssh -i stolen.pem ubuntu@ec2-xx-xx-xx-xx.ap-northeast-2.compute.amazonaws.com
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.14.0-aws aarch64)

ubuntu@ip-xxx-xx-xx-xx:~$ &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가 인증 없이 바로 쉘 프롬프트가 뜹니다. 현재 구조에서는 &lt;b&gt;이 순간이 사실상 게임 오버&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 2 &amp;mdash; 실행 중인 프로세스 확인&lt;/h3&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;ubuntu@ip-xxx:~$ ps aux | grep java
ubuntu    12345  2.1 15.3 {...} java -jar -Dserver.port=8080 /opt/app/app.jar&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 프로세스의 PID를 찾아냈습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 3 &amp;mdash; 환경변수 덤프로 시크릿 탈취&lt;/h3&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;ubuntu@ip-xxx:~$ sudo cat /proc/12345/environ | tr '\0' '\n'
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
DB_USERNAME=admin
DB_PASSWORD=MyStr0ngP@ssw0rd!
JWT_SECRET=my-super-secret-signing-key-do-not-share
EXTERNAL_API_KEY=sk-live-xxxxxxxxxxxxxxxx
EXTERNAL_API_SECRET=xxxxxxxxxxxxxxxxxxxxxx
...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;모든 민감 정보가 평문으로 덤프됩니다.&lt;/b&gt; 이제 공격자는:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stage 4 &amp;mdash; 2차 피해 확산&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;DB 직접 접속&lt;/b&gt;: &lt;code&gt;DB_PASSWORD&lt;/code&gt;로 데이터베이스에 접속해서 사용자 데이터 전체 유출&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서비스 사칭&lt;/b&gt;: &lt;code&gt;JWT_SECRET&lt;/code&gt;으로 임의 사용자의 토큰을 위조 &amp;rarr; 관리자 권한으로 로그인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 API 남용&lt;/b&gt;: &lt;code&gt;EXTERNAL_API_KEY&lt;/code&gt;로 유료 서비스 크레딧 소진, 비용 청구서 폭발&lt;/li&gt;
&lt;li&gt;&lt;b&gt;흔적 삭제&lt;/b&gt;: &lt;code&gt;sudo&lt;/code&gt; 권한으로 로그 파일 조작 &amp;rarr; 침해 사실 은폐Stage 5 &amp;mdash; 완전 침해 (최악의 경우)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 얻은 시크릿을 바탕으로 &lt;b&gt;정상적인 사용자로 위장&lt;/b&gt;하여 지속 공격을 수행합니다. &lt;code&gt;.pem&lt;/code&gt; 키만 있으면 서버에 자유롭게 드나들 수 있으니, 백도어 설치 &amp;rarr; 장기 잠복 &amp;rarr; 적절한 타이밍에 대규모 피해 실행의 수순이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 모든 과정이 몇 분 안에 끝납니다.&lt;/b&gt; 그리고 서버 로그에는 &quot;정상적인 ubuntu 사용자의 접속&quot;으로만 찍힙니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 해결 방법 &amp;mdash; Google Authenticator로 2차 방어선 구축&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 간단합니다. &lt;b&gt;&lt;code&gt;.pem&lt;/code&gt; 키(Something you HAVE)&lt;/b&gt; 에 더해, &lt;b&gt;OTP 코드(Something you KNOW at this moment)&lt;/b&gt; 라는 &lt;b&gt;완전히 다른 차원의 인증 요소&lt;/b&gt;를 요구하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;기존: .pem 키 통과 &amp;rarr; 쉘 접속 ✅
개선: .pem 키 통과 &amp;rarr; OTP 6자리 통과 &amp;rarr; 쉘 접속 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 &lt;code&gt;.pem&lt;/code&gt; 키를 얻어도 &lt;b&gt;제 핸드폰의 OTP 앱이 없으면&lt;/b&gt; 접속이 불가능해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 1 &amp;mdash; Google Authenticator PAM 모듈 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ubuntu 기준:&lt;/p&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install -y libpam-google-authenticator&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Amazon Linux 2 / 2023이라면:&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;sudo dnf install -y google-authenticator&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 2 &amp;mdash; 사용자 계정에서 OTP 시크릿 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSH로 접속한 상태에서 &lt;b&gt;본인 사용자 계정으로&lt;/b&gt; 실행합니다:&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;google-authenticator&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대화형 질문이 7개 정도 나옵니다. 권장 답변과 그 이유를 정리하면:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문&lt;/th&gt;
&lt;th&gt;답&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time-based tokens?&lt;/td&gt;
&lt;td&gt;&lt;b&gt;y&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;대부분의 OTP 앱이 표준으로 지원하는 TOTP 방식&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update &lt;code&gt;.google_authenticator&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;b&gt;y&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;설정을 파일에 저장해서 재부팅 후에도 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disallow multiple uses?&lt;/td&gt;
&lt;td&gt;&lt;b&gt;y&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;같은 코드 재사용 금지 &amp;rarr; 네트워크 도청 공격(MITM) 방어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Increase window to 4:00?&lt;/td&gt;
&lt;td&gt;&lt;b&gt;n&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기본 1:30이면 충분. 창이 넓을수록 공격 기회 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate-limit (3 tries/30s)?&lt;/td&gt;
&lt;td&gt;&lt;b&gt;y&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;브루트포스 공격 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;터미널에 표시되는 QR 코드를 스마트폰의 Google Authenticator / Authy / 1Password 앱으로 스캔&lt;/b&gt;하세요. 그리고 &lt;b&gt;Scratch Code(비상 복구 코드)&lt;/b&gt; 5개는 반드시 비밀번호 관리자나 안전한 오프라인 장소에 보관합니다. 핸드폰을 잃어버리면 이게 유일한 복구 수단입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 3 &amp;mdash; PAM 설정 수정&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo nano /etc/pam.d/sshd&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 &lt;b&gt;맨 위&lt;/b&gt;에 추가:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;auth required pam_google_authenticator.so nullok&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 같은 파일에서 아래 줄을 찾아 &lt;b&gt;주석 처리&lt;/b&gt;합니다 (Ubuntu 기준):&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;#@include common-auth&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;common-auth&lt;/code&gt;가 살아있으면 비밀번호 인증이 먼저 통과되어 OTP 단계까지 가지 않을 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;nullok&lt;/code&gt; 옵션은 &lt;code&gt;.google_authenticator&lt;/code&gt; 파일이 없는 사용자는 OTP 없이 통과시킵니다. 모든 사용자가 설정을 마쳤다면 이 옵션을 제거해 강제하는 것이 더 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 4 &amp;mdash; SSH 설정 수정&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo nano /etc/ssh/sshd_config&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 Ubuntu라면 보통 아래 방식이 더 안전합니다 (&lt;code&gt;sshd_config.d/&lt;/code&gt; 디렉터리의 다른 설정 파일과 충돌 방지):&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sudo nano /etc/ssh/sshd_config.d/99-2fa.conf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 내용 추가:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
PasswordAuthentication no
PermitRootLogin no&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심은 &lt;code&gt;AuthenticationMethods publickey,keyboard-interactive&lt;/code&gt;&lt;/b&gt;입니다. 쉼표가 AND의 의미이므로 &lt;b&gt;&quot;공개키 통과 AND OTP 통과&quot;&lt;/b&gt; 둘 다 요구하게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 5 &amp;mdash; 문법 검사 후 SSH 재시작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;설정을 잘못 저장하고 재시작하면 &lt;code&gt;.pem&lt;/code&gt; 키로도 접속이 불가능해집니다.&lt;/b&gt; 반드시 문법 검사부터 하세요.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;# 문법 검사 (아무 출력 없으면 OK)
sudo sshd -t

# 적용된 설정 확인
sudo sshd -T | grep -iE &quot;authenticationmethods|kbdinter|passwordauth&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기대 출력:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;passwordauthentication no
kbdinteractiveauthentication yes
authenticationmethods publickey,keyboard-interactive&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 없으면 재시작:&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# Ubuntu
sudo systemctl restart ssh

# Amazon Linux 등
sudo systemctl restart sshd&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 6 &amp;mdash; 반드시 현재 세션을 유지한 채 새 터미널에서 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 SSH 세션은 절대 닫지 마세요.&lt;/b&gt; 만약 설정에 문제가 있어서 새 접속이 막히면, 기존 세션에서만 롤백이 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 터미널 창에서:&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ssh -i your-key.pem ubuntu@{서버주소}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 흐름:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;Authenticated with partial success.
Verification code: ______   &amp;larr; 여기서 OTP 앱의 6자리 입력
Last login: ...
ubuntu@ip-xxx:~$&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Verification code:&lt;/code&gt; 프롬프트가 뜨고, 올바른 OTP를 입력했을 때 쉘이 열리면 &lt;b&gt;성공&lt;/b&gt;입니다. 이걸 확인한 후에야 기존 세션을 닫으세요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  해킹 시나리오 재검증 &amp;mdash; 같은 공격을 다시 시도하면?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2차 인증 적용 후 공격자 입장에서 같은 공격을 시도해봅시다:&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;$ ssh -i stolen.pem ubuntu@ec2-xx-xx-xx-xx.ap-northeast-2.compute.amazonaws.com
Authenticated with partial success.
Verification code: &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자는 여기서 멈춥니다. OTP 6자리를 모르니까요. 100만 가지 경우의 수를 시도해도:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;30초마다 코드가 바뀌므로 &lt;b&gt;브루트포스 불가능&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Rate limit(30초당 3회)로 &lt;b&gt;자동화 공격 차단&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;코드 재사용 금지로 &lt;b&gt;가로챈 코드 재사용 불가&lt;/b&gt;&lt;br /&gt;공격자가 접속하려면 &lt;b&gt;제 핸드폰까지 물리적으로 탈취&lt;/b&gt;해야 합니다. 난이도가 차원이 다르게 올라가죠.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방어 효과 비교&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;공격자가 가진 것&lt;/th&gt;
&lt;th&gt;이전&lt;/th&gt;
&lt;th&gt;이후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.pem&lt;/code&gt; 키만&lt;/td&gt;
&lt;td&gt;❌ 즉시 침해&lt;/td&gt;
&lt;td&gt;✅ OTP 단계에서 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OTP만 (코드 유출)&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;td&gt;✅ publickey 단계에서 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.pem&lt;/code&gt; + OTP 앱 핸드폰&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;td&gt;✅ 접속 가능 (정상 사용자)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚠️ 주의사항 &amp;mdash; 꼭 챙겨야 할 것들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Scratch Code 백업은 생명줄입니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;google-authenticator&lt;/code&gt; 실행 시 출력된 8자리 복구 코드 5개를 &lt;b&gt;지금 당장&lt;/b&gt; 안전한 곳에 저장하세요. 핸드폰 분실/초기화/OTP 앱 삭제 시 이것 없이는 영영 접속 불가입니다.&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;1Password, Bitwarden 등 비밀번호 관리자의 Secure Note&lt;/li&gt;
&lt;li&gt;물리적 종이로 안전한 서랍/금고&lt;br /&gt;권장하지 않는 위치:&lt;/li&gt;
&lt;li&gt;이메일 (유출 시 위험)&lt;/li&gt;
&lt;li&gt;평문 파일 (해킹 시 같이 노출)&lt;/li&gt;
&lt;li&gt;클라우드 드라이브 (동기화 사고 가능)2. 백업 접속 경로 확보&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 서버 시간 동기화 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TOTP는 서버와 클라이언트의 시각이 일치해야 동작합니다. 시간이 30초 이상 어긋나면 OTP가 계속 거부됩니다:&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;timedatectl status
# System clock synchronized: yes 가 떠야 함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 여러 서버가 있다면 각각 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 인스턴스별로 독립적인 리눅스 환경입니다. Staging과 Production이 분리되어 있다면 &lt;b&gt;두 서버 모두&lt;/b&gt; 같은 작업을 반복해야 합니다. OTP 앱에는 서버별로 별개 항목으로 등록하는 것을 추천합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  최종 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용 전후 변화&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;적용 전&lt;/th&gt;
&lt;th&gt;적용 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH 인증 요소&lt;/td&gt;
&lt;td&gt;1개 (&lt;code&gt;.pem&lt;/code&gt; 키)&lt;/td&gt;
&lt;td&gt;2개 (&lt;code&gt;.pem&lt;/code&gt; + OTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.pem&lt;/code&gt; 탈취 시&lt;/td&gt;
&lt;td&gt;즉시 완전 침해&lt;/td&gt;
&lt;td&gt;OTP 단계에서 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;브루트포스 공격&lt;/td&gt;
&lt;td&gt;키 파일만 얻으면 시도 불필요&lt;/td&gt;
&lt;td&gt;Rate limit으로 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크 도청&lt;/td&gt;
&lt;td&gt;OTP 재사용 가능&lt;/td&gt;
&lt;td&gt;한 번 쓴 코드 즉시 무효화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공격 난이도&lt;/td&gt;
&lt;td&gt;중 (키 하나만 털면 됨)&lt;/td&gt;
&lt;td&gt;높음 (핸드폰까지 털어야)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안은 &lt;b&gt;하나의 완벽한 방어막&lt;/b&gt;이 아니라 &lt;b&gt;여러 겹의 방어선&lt;/b&gt;을 쌓는 일입니다. SSH 2FA는 그 중 가장 효과적이고 가성비 좋은 한 겹입니다. 30분의 설정 시간으로 &lt;code&gt;.pem&lt;/code&gt; 키 유출의 파급력을 극적으로 줄일 수 있으니, 아직 적용하지 않으셨다면 오늘 바로 적용해보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;b&gt;서비스에 금전적 가치가 걸려 있거나, 사용자 데이터를 다루는 서버&lt;/b&gt;라면 선택이 아닌 필수입니다. &lt;code&gt;.pem&lt;/code&gt; 키는 언젠가 새어 나간다고 가정해야 하고, 그때 2차 방어선이 있는지 없는지가 회사의 존망을 가를 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; ️ 태그: #AWS #EC2 #SSH #2FA #GoogleAuthenticator #보안 #DevOps #Ubuntu #서버보안 #PAM&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>트레이드마인/보안</category>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/76</guid>
      <comments>https://jangwanjung.tistory.com/76#entry76comment</comments>
      <pubDate>Thu, 16 Apr 2026 23:19:47 +0900</pubDate>
    </item>
    <item>
      <title>코딩테스트를 위한 파이썬</title>
      <link>https://jangwanjung.tistory.com/65</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741667806330&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트에서 중복제거하기

#순서가 중요하지 않을때
lst = [1, 2, 2, 3, 4, 4, 5]
unique_lst = list(set(lst))
print(unique_lst)  # 예시 출력: [1, 2, 3, 4, 5] (순서는 랜덤)

#순서가 중요할 때
lst = [1, 2, 2, 3, 4, 4, 5]
unique_lst = list(dict.fromkeys(lst))
print(unique_lst)  # 출력: [1, 2, 3, 4, 5]&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1741515570698&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트에있는 문자열을 사전순으로 정렬
fruits = [&quot;banana&quot;, &quot;apple&quot;, &quot;cherry&quot;, &quot;date&quot;]

# 리스트를 사전순으로 정렬
fruits.sort()

print(fruits)  # ['apple', 'banana', 'cherry', 'date']&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1741515385717&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트 뒤집기
numbers = [1, 2, 3, 4, 5]
numbers.reverse()
print(numbers)  # [5, 4, 3, 2, 1]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741515442725&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트 합치기
list1 = [1, 2, 3]
list2 = [4, 5, 6]

# 두 리스트 합치기
merged_list = list1 + list2
print(merged_list)  # [1, 2, 3, 4, 5, 6]&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1741515490376&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트를 합쳐 문자열로만들기
fruits = [&quot;apple&quot;, &quot;banana&quot;, &quot;cherry&quot;]

# 요소를 공백으로 구분하여 문자열로 결합
result = &quot; &quot;.join(fruits)
print(result)  # 'apple banana cherry'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741515695998&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#리스트에 있는 문자열을 오름차순정렬
fruits = [&quot;banana&quot;, &quot;apple&quot;, &quot;cherry&quot;, &quot;date&quot;]

# 리스트를 사전순으로 정렬
fruits.sort()

print(fruits)  # ['apple', 'banana', 'cherry', 'date']

#내림차순 정렬
fruits = [&quot;banana&quot;, &quot;apple&quot;, &quot;cherry&quot;, &quot;date&quot;]

# 내림차순으로 정렬
fruits.sort(reverse=True)

print(fruits)  # ['date', 'cherry', 'banana', 'apple']&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sort와 sorted의 차이점은 sort는 반환되지않고 sorted는 반환된다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741516092720&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#print보다 빠른입력

import sys
st = sys.stdin.readline() 

#특징은 한줄씩 입력받고 기본적으로 마지막에는 &quot;\n&quot;이 추가된다
#즉

st = sys.stdin.readline().rstrip()
#이렇게 입력받으면 &quot;\n&quot;이 추가되지않는다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741519212799&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#절댓값으로 변환
# 음수의 절댓값
num1 = -5
print(abs(num1))  # 5&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1741520412474&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#아스키코드 사용법

# 문자 'A'의 아스키 코드 값을 찾기
ascii_value = ord('A')
print(ascii_value)  # 65

# 아스키 코드 65에 해당하는 문자 찾기
character = chr(65)
print(character)  # 'A'&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742190729577&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#재귀함수가 깊어질때 

import sys
sys.setrecursionlimit(10**5) #100000깊이 까지 가능&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742468288437&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# N*M 이차원배열을 일차원배열로 받는방법
input = sys.stdin.readline
arr = []
for i in range(n):
    arr.extend(map(int,input().split()))&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1742468727427&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#배열을 복사하는방법

#얇은 복사(메모리주소가 동일하게 복사됨)
import copy

original = [1, 2, 3]
copy_list = copy.copy(original)

copy_list[0] = 10
print(original)   # [1, 2, 3] (원본은 변경되지 않음)
print(copy_list)  # [10, 2, 3]

#깊은복사(메모리주소가 다르게 복사됨)
import copy

original = [[1, 2], [3, 4]]
deep_copy = copy.deepcopy(original)

deep_copy[0][0] = 10
print(original)   # [[1, 2], [3, 4]] (원본은 변경되지 않음)
print(deep_copy)  # [[10, 2], [3, 4]]&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/65</guid>
      <comments>https://jangwanjung.tistory.com/65#entry65comment</comments>
      <pubDate>Sun, 9 Mar 2025 19:28:44 +0900</pubDate>
    </item>
    <item>
      <title>[java] 배열의 합을 구하는 코드</title>
      <link>https://jangwanjung.tistory.com/64</link>
      <description>&lt;pre id=&quot;code_1735908946652&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;int sum = Arrays.stream(arr).sum();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/64</guid>
      <comments>https://jangwanjung.tistory.com/64#entry64comment</comments>
      <pubDate>Fri, 3 Jan 2025 21:55:44 +0900</pubDate>
    </item>
    <item>
      <title>[java] 2차원배열에서 조건부 정렬</title>
      <link>https://jangwanjung.tistory.com/63</link>
      <description>&lt;pre id=&quot;code_1735880536656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Arrays.sort(arr, (a, b) -&amp;gt; Integer.compare(a[0], b[0]));  //첫번째 열을 기준으로 정렬
Arrays.sort(arr, (a, b) -&amp;gt; Integer.compare(a[1], b[1]));  //두번째 열을 기준으로 정렬&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/63</guid>
      <comments>https://jangwanjung.tistory.com/63#entry63comment</comments>
      <pubDate>Fri, 3 Jan 2025 14:02:21 +0900</pubDate>
    </item>
    <item>
      <title>[java] 백트래킹으로 부분집합 구하기</title>
      <link>https://jangwanjung.tistory.com/62</link>
      <description>&lt;pre id=&quot;code_1735793512785&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.*;
import java.util.*;

public class Main {
    static int n;
    static int [] arr;
    static int [] nArr;
    static boolean [] visited;

    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        n = Integer.parseInt(st.nextToken());

        arr = new int[n];
        visited = new boolean[n];

        for (int i = 1; i &amp;lt;= n; i++) {
            nArr = new int[i];
            backtracking(0,i,0);
        }
    }
    public static void backtracking(int depth, int len,int p) {
        if (len==depth) {
            for(int i: nArr){
                System.out.print(i+&quot; &quot;);
            }
            System.out.println();
        }
        else{
            for (int i=p; i&amp;lt;n ;i++){
                if(!visited[i]){
                    nArr[depth] = i+1;
                    visited[i] = true;
                    backtracking(depth+1,len,i+1);
                    visited[i] = false;
                }
            }
        }
    }
}​&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735793491029&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;입력:4 

출력:
1 
2 
3 
4 
1 2 
1 3 
1 4 
2 3 
2 4 
3 4 
1 2 3 
1 2 4 
1 3 4 
2 3 4 
1 2 3 4&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/62</guid>
      <comments>https://jangwanjung.tistory.com/62#entry62comment</comments>
      <pubDate>Thu, 2 Jan 2025 13:52:00 +0900</pubDate>
    </item>
    <item>
      <title>[java] 백트래킹 중복제거 코드</title>
      <link>https://jangwanjung.tistory.com/61</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2618&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8grHl/btsLzoPAVSX/F5SZt2KK05dg4U4jos1Ld1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8grHl/btsLzoPAVSX/F5SZt2KK05dg4U4jos1Ld1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8grHl/btsLzoPAVSX/F5SZt2KK05dg4U4jos1Ld1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8grHl%2FbtsLzoPAVSX%2FF5SZt2KK05dg4U4jos1Ld1%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;2618&quot; height=&quot;618&quot; data-origin-width=&quot;2618&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1735540494824&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.*;
import java.util.*;

public class Main{
    static int arr[];
    static boolean visit[];
    static int a,b;
    static StringBuilder sb;
    public static void main(String[] args) throws IOException {
        BufferedReader br=new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st=new StringTokenizer(br.readLine());
        a=Integer.parseInt(st.nextToken());
        b=Integer.parseInt(st.nextToken());
        arr=new int[b];
        visit=new boolean[a];
        sb=new StringBuilder();
        DFS(0,0);
        System.out.println(sb);


    }
    private static void DFS(int k,int depth) {
        if(depth==b) {
            for(int date:arr) {
                sb.append(date+&quot; &quot;);
            }
            sb.append(&quot;\n&quot;);
            return;
        }
        for(int i=k;i&amp;lt;a;i++) {

            arr[depth]=i+1;
            DFS(i+1,depth+1);
            
        }

    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/61</guid>
      <comments>https://jangwanjung.tistory.com/61#entry61comment</comments>
      <pubDate>Mon, 30 Dec 2024 15:34:59 +0900</pubDate>
    </item>
    <item>
      <title>[java] 백트래킹 기본구현코드</title>
      <link>https://jangwanjung.tistory.com/60</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1677&quot; data-origin-height=&quot;607&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beFyKd/btsLwt4bbJD/01tWCdSo1VLP2J3ByZvcm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beFyKd/btsLwt4bbJD/01tWCdSo1VLP2J3ByZvcm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beFyKd/btsLwt4bbJD/01tWCdSo1VLP2J3ByZvcm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeFyKd%2FbtsLwt4bbJD%2F01tWCdSo1VLP2J3ByZvcm0%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;1677&quot; height=&quot;607&quot; data-origin-width=&quot;1677&quot; data-origin-height=&quot;607&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2606&quot; data-origin-height=&quot;830&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbIwff/btsLAVsqrl5/Z9Mer72YR2Fveeo2uuNKu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbIwff/btsLAVsqrl5/Z9Mer72YR2Fveeo2uuNKu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbIwff/btsLAVsqrl5/Z9Mer72YR2Fveeo2uuNKu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbIwff%2FbtsLAVsqrl5%2FZ9Mer72YR2Fveeo2uuNKu0%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;2606&quot; height=&quot;830&quot; data-origin-width=&quot;2606&quot; data-origin-height=&quot;830&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1735540416197&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.*;
import java.util.*;

public class Main{
	static int arr[];
	static boolean visit[];
	static int a,b;
	static StringBuilder sb;
	public static void main(String[] args) throws IOException {
		BufferedReader br=new BufferedReader(new InputStreamReader(System.in));
		StringTokenizer st=new StringTokenizer(br.readLine());
		a=Integer.parseInt(st.nextToken());
		b=Integer.parseInt(st.nextToken());
		arr=new int[b];
		visit=new boolean[a];
		sb=new StringBuilder();
		DFS(0);
		System.out.println(sb);
		
		
	}
	private static void DFS(int depth) {
		if(depth==b) {
			for(int date:arr) {
				sb.append(date+&quot; &quot;);
			}
			sb.append(&quot;\n&quot;);
			return;
		}
		for(int i=0;i&amp;lt;a;i++) {
			if(!visit[i]) {
				visit[i]=true;
				arr[depth]=i+1;
				DFS(depth+1);
				visit[i]=false;
			}
		}
		
	}
}&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/60</guid>
      <comments>https://jangwanjung.tistory.com/60#entry60comment</comments>
      <pubDate>Wed, 25 Dec 2024 13:30:26 +0900</pubDate>
    </item>
    <item>
      <title>[java]이진트리 구현 코드</title>
      <link>https://jangwanjung.tistory.com/59</link>
      <description>&lt;pre id=&quot;code_1735095402951&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.*;
import java.util.*;

class Node {
    char value;
    Node left;
    Node right;

    public Node(char value) {
        this.value = value;
        this.left = null;
        this.right = null;
    }
}
public class Test {
    static Node[] tree;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());
        tree = new Node[N + 1]; // 노드 배열 생성

        for (int i = 0; i &amp;lt; N; i++) {
            StringTokenizer st = new StringTokenizer(br.readLine()); // 띄어쓰기 기준으로 문자열 분리
            char parentValue = st.nextToken().charAt(0); // nextToken() 메서드로 토큰을 하나씩 꺼내오면 StringTokenizer객체에는 해당 토큰이 사라진다
            char leftValue = st.nextToken().charAt(0);
            char rightValue = st.nextToken().charAt(0);

            // Java에서 char 데이터 타입은 내부적으로 ASCII 코드를 사용
            if (tree[parentValue - 'A'] == null) { // 부모 노드가 아직 생성되지 않은 경우. 'A'는 문자 'A'의 ASCII 값
                tree[parentValue - 'A'] = new Node(parentValue); // 부모 노드를 생성
            }
            if (leftValue != '.') { // 왼쪽 자식이 존재할 경우
                tree[leftValue - 'A'] = new Node(leftValue); // 왼쪽 자식 노드를 생성
                tree[parentValue - 'A'].left = tree[leftValue - 'A']; // 부모 노드와 연결
            }
            if (rightValue != '.') { // 오른쪽 자식이 존재할 경우
                tree[rightValue - 'A'] = new Node(rightValue); // 오른쪽 자식 노드를 생성
                tree[parentValue - 'A'].right = tree[rightValue - 'A']; // 부모 노드와 연결
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <author>JangWanJung</author>
      <guid isPermaLink="true">https://jangwanjung.tistory.com/59</guid>
      <comments>https://jangwanjung.tistory.com/59#entry59comment</comments>
      <pubDate>Wed, 25 Dec 2024 11:56:50 +0900</pubDate>
    </item>
  </channel>
</rss>