https://www.acmicpc.net/problem/17822

 

 

구현 문제

 

import java.io.*;
import java.util.Arrays;
import java.util.StringTokenizer;

public class Main{
    static int N, M;
    static int[][] disk;
    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());
        M = Integer.parseInt(st.nextToken());
        int T = Integer.parseInt(st.nextToken());

        disk = new int[N+1][M+1];

        for(int i=1; i<=N; i++){
            st = new StringTokenizer(br.readLine());
            for (int j=1; j<=M; j++){
                disk[i][j] = Integer.parseInt(st.nextToken());
            }
        }
        
        for (int i=0; i<T; i++){
            st = new StringTokenizer(br.readLine());
            int x = Integer.parseInt(st.nextToken()); 
            int d = Integer.parseInt(st.nextToken());
            int k = Integer.parseInt(st.nextToken());
            rotate(x, d, k);
        }

        int sum = 0;
        for(int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                if (disk[i][j] > 0) sum += disk[i][j];
            }
        }

        System.out.println(sum);
    }

    /*
    x: 번호가 x의 배수에 해당하는 디스크 회전
    d: 0 시계방향, 1 반시계방향
    k: 회전 칸 수. 1<=k<M
    */
    static void rotate(int x, int d, int k){
        // 12시 방향부터 시계방향으로 1, 2, ..., M번
        int realK = k;
        if (d==0) realK *= 1;
        else if (d==1) realK *= -1;

        for (int i=x; i<=N; i += x){
            int[] temp = new int[disk[i].length];

            for (int j=1; j<=M; j++){
                // 음수 방지를 위해 M를 더하고 나머지연산
                temp[(j-1+realK+M)%M + 1] = disk[i][j];
            }
            disk[i] = Arrays.copyOf(temp, temp.length);
        }
        processAfterRotation();
        // debugPrint();
    }

    static void processAfterRotation(){
        boolean[][] toRemove = new boolean[N+1][M+1];
        boolean flag = false;
        // 같은 원판 위에서 인접
        for (int i=1; i<=N; i++){
            if (disk[i][1] == disk[i][M] && disk[i][1] != -1) {
                toRemove[i][1] = toRemove[i][M] = true;
            }

            for (int j=1; j<M; j++){
                if (disk[i][j] == -1) continue;

                if (disk[i][j] == disk[i][j+1]) toRemove[i][j] = toRemove[i][j+1] = true;
            }
        }
        // 다른 원판 사이에서 인접
        for (int j=1; j<=M; j++){
            for (int i=1; i<N; i++){
                if (disk[i][j] == -1) continue;

                if (disk[i][j] == disk[i+1][j]) toRemove[i][j] = toRemove[i+1][j] = true;
            }
        }
        // 제거
        for (int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                if (toRemove[i][j]) {
                    disk[i][j] = -1;
                    flag = true;
                }
            }
        }

        // 해당없음
        if (!flag){
            int sum = 0;
            int count = 0;
            for (int i=1; i<=N; i++){
                for (int j=1; j<=M; j++){
                    int val = disk[i][j];
                    if (val >= 0){
                        count++;
                        sum += val;
                    } 
                }
            }
            double mean = count > 0 ? (double) sum / count : 0;
            for (int i=1; i<=N; i++){
                for (int j=1; j<=M; j++){
                    if (disk[i][j] < 0) continue;

                    if (disk[i][j] > mean) disk[i][j] -= 1;
                    else if (disk[i][j] < mean) disk[i][j] += 1;
                }
            }
        }
    }

    static void debugPrint(){
        StringBuilder sb = new StringBuilder();
        for (int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                sb.append(String.format("%4d", disk[i][j]));
            }
            sb.append("\n");
        }
        System.out.println(sb.toString());
    }
}

 

 

 

양 옆으로 인접한 값을 검사했었으나, N=2이나 M=2 경우에는 양 옆이 존재하지 않기 때문에 이에 대한 처리가 미흡했다.

더보기
import java.io.*;
import java.util.Arrays;
import java.util.StringTokenizer;

public class Main{
    static int N, M;
    static int[][] disk;
    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());
        M = Integer.parseInt(st.nextToken());
        int T = Integer.parseInt(st.nextToken());

        disk = new int[N+1][M+1];

        for(int i=1; i<=N; i++){
            st = new StringTokenizer(br.readLine());
            for (int j=1; j<=M; j++){
                disk[i][j] = Integer.parseInt(st.nextToken());
            }
        }
        
        for (int i=0; i<T; i++){
            st = new StringTokenizer(br.readLine());
            int x = Integer.parseInt(st.nextToken()); 
            int d = Integer.parseInt(st.nextToken());
            int k = Integer.parseInt(st.nextToken());
            rotate(x, d, k);
        }

        int sum = 0;
        for(int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                if (disk[i][j] > 0) sum += disk[i][j];
            }
        }

        System.out.println(sum);
    }

    /*
    x: 번호가 x의 배수에 해당하는 디스크 회전
    d: 0 시계방향, 1 반시계방향
    k: 회전 칸 수. 1<=k<M
    */
    static void rotate(int x, int d, int k){
        // 12시 방향부터 시계방향으로 1, 2, ..., M번
        int realK = k;
        if (d==0) realK *= 1;
        else if (d==1) realK *= -1;

        for (int i=x; i<=N; i += x){
            int[] temp = new int[disk[i].length];

            for (int j=1; j<=M; j++){
                // 음수 방지를 위해 M를 더하고 나머지연산
                temp[(j-1+realK+M)%M + 1] = disk[i][j];
            }
            disk[i] = Arrays.copyOf(temp, temp.length);
        }
        processAfterRotation();
        // debugPrint();
    }

    static void processAfterRotation(){
        boolean[][] toRemove = new boolean[N+1][M+1];
        boolean flag = false;
        // 같은 원판 위에서 인접
        for (int i=1; i<=N; i++){
            if (disk[i][1] == disk[i][M] && disk[i][1] != -1) {
                toRemove[i][1] = toRemove[i][M] = true;
            }

            for (int j=2; j<M; j++){
                if (disk[i][j] == -1) continue;

                if (disk[i][j] == disk[i][j-1]) toRemove[i][j] = toRemove[i][j-1] = true;
                if (disk[i][j] == disk[i][j+1]) toRemove[i][j] = toRemove[i][j+1] = true;
            }
        }
        // 다른 원판 사이에서 인접
        for (int j=1; j<=M; j++){
            for (int i=2; i<N; i++){
                if (disk[i][j] == -1) continue;

                if (disk[i][j] == disk[i-1][j]) toRemove[i][j] = toRemove[i-1][j] = true;
                if (disk[i][j] == disk[i+1][j]) toRemove[i][j] = toRemove[i+1][j] = true;
            }
        }
        // 제거
        for (int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                if (toRemove[i][j]) {
                    disk[i][j] = -1;
                    flag = true;
                }
            }
        }

        // 해당없음
        if (!flag){
            int sum = 0;
            int count = 0;
            for (int i=1; i<=N; i++){
                for (int j=1; j<=M; j++){
                    int val = disk[i][j];
                    if (val >= 0){
                        count++;
                        sum += val;
                    } 
                }
            }
            double mean = count > 0 ? (double) sum / count : 0;
            for (int i=1; i<=N; i++){
                for (int j=1; j<=M; j++){
                    if (disk[i][j] < 0) continue;

                    if (disk[i][j] > mean) disk[i][j] -= 1;
                    else if (disk[i][j] < mean) disk[i][j] += 1;
                }
            }
        }
    }

    static void debugPrint(){
        StringBuilder sb = new StringBuilder();
        for (int i=1; i<=N; i++){
            for (int j=1; j<=M; j++){
                sb.append(String.format("%4d", disk[i][j]));
            }
            sb.append("\n");
        }
        System.out.println(sb.toString());
    }
}

 

'Study > Coding test' 카테고리의 다른 글

[JAVA] 백준 17779 - 게리맨더링 2  (0) 2026.03.25

 

https://www.acmicpc.net/problem/17779

 

단순구현 + 브루트 포스

 

구역을 5개로 나누는데

중앙 + 좌상단, 우상단, 좌하단, 우하단으로 나눈다.

 

 

먼저 중앙 구역을 나누고

처음에는 2중 루프로 맵을 순회하면서

중앙 이외의 구역을 사각형 모양으로 순회하면서, 중앙 구역인 부분은 제외하도록 설계했었으나, (주석처리된 부분)

긴 직사각형 형태의 경우, 네 구역의 경계가 겹치면서 의도와 다르게 동작하였다.

 

 

이후 네 영역을 따로 순회하면서, 중앙 구역 경계를 만나면 다음 행으로 넘어가도록 수정했다.

 

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main{
    static int N;
    static int[][] map;

    public static void main(String[] args)throws IOException{
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st;

        N = Integer.parseInt(br.readLine());
        map = new int[N+1][N+1];
        for (int i=1; i<=N; i++){
            st = new StringTokenizer(br.readLine());
            for (int j=1; j<=N; j++){
                map[i][j] = Integer.parseInt(st.nextToken());
            }
        }

        int MIN = Integer.MAX_VALUE;
        for (int r=1; r<=N; r++){
            for (int c=1; c<=N; c++){
                // d1, d2 ≥ 1, 
                // 1 ≤ x < x+d1+d2 ≤ N
                // 1 ≤ y-d1 < y < y+d2 ≤ N
                for (int d1=1; d1<N; d1++){
                    for (int d2=1; d2<N; d2++){
                        if (r+d1+d2 > N || c-d1 < 1 || c+d2 > N) continue;
                        int val = calc(r, c, d1, d2);
                        if (val != -1) MIN = Math.min(MIN, val);
                    }
                }
            }
        }

        System.out.println(MIN);
    }

    static int calc(int x, int y, int d1, int d2){
        int[] area = new int[]{0, 0, 0, 0, 0};
        boolean[][] district5 = new boolean[N+1][N+1];

        int left, right;
        left = right = y;
        for(int i=x; i<=x+d1+d2; i++){
            for (int j=left; j<=right; j++){
                district5[i][j] = true;
                area[4] += map[i][j];
            }
            if (i < x+d1) left--;
            else left++;
            if (i < x+d2) right++;
            else right--;
        }

        // 중복된 영역 발생
        // for (int r=1; r<=N; r++){
        //     for (int c=1; c<=N; c++){
        //         if (district5[r][c]) continue;
        //         // 1번 선거구: 1 ≤ r < x+d1, 1 ≤ c ≤ y, 1번 경계선의 왼쪽 위. 아래경계 제외.
        //         // 2번 선거구: 1 ≤ r ≤ x+d2, y < c ≤ N, 2번 경계선의 오른쪽 위. 왼쪽경계 제외.
        //         // 3번 선거구: x+d1 ≤ r ≤ N, 1 ≤ c < y-d1+d2, 3번 경계선의 왼쪽 아래. 오른쪽경계 제외.
        //         // 4번 선거구: x+d2 < r ≤ N, y-d1+d2 ≤ c ≤ N, 4번 경계선의 오른쪽 아래. 위쪽경계 제외.
        //         if (1 <= r && r < x + d1 && 1 <= c && c <= y) area[0] += map[r][c];
        //         else if (1 <= r && r <= x + d2 && y < c && c <= N) area[1] += map[r][c];
        //         else if (x+d1 <= r && r <= N && 1 <= c && c < y-d1+d2) area[2] += map[r][c];
        //         else if (x+d2 < r && r <= N && y-d1+d2 <= c && c <= N) area[3] += map[r][c];
        //     }
        // }

        // 왼쪽 위
        for (int r=1; r<x+d1; r++){
            for (int c=1; c<=y; c++){
                if (district5[r][c]) break;
                area[0] += map[r][c];
            }
        }
        // 오른쪽 위
        for (int r=1; r<=x+d2; r++){
            for (int c=N; c>y; c--){
                if (district5[r][c]) break;
                area[1] += map[r][c];
            }
        }

        // 왼쪽 아래
        for (int r=x+d1; r<=N; r++){
            for (int c=1; c<y-d1+d2; c++){
                if (district5[r][c]) break;
                area[2] += map[r][c];
            }
        }
        // 오른쪽 아래
        for (int r=x+d2+1; r<=N; r++){
            for (int c=N; c>=y-d1+d2; c--){
                if (district5[r][c]) break;
                area[3] += map[r][c];
            }
        }

        // MAX - MIN
        int min = Integer.MAX_VALUE, max = Integer.MIN_VALUE;
        for (int i=0; i<5; i++){
            if (area[i] == 0) return -1;
            max = Math.max(max, area[i]);
            min = Math.min(min, area[i]);
        }
        return max - min;
    }

    static void debugPrint(boolean[][] arr){
        StringBuilder sb = new StringBuilder();
        for (int i=0; i<arr.length; i++){
            for (int j=0; j<arr[0].length; j++){
                sb.append(arr[i][j]);
            }
            sb.append("\n");
        }
    }
}

'Study > Coding test' 카테고리의 다른 글

[JAVA] 백준 17822 - 원판 돌리기  (0) 2026.03.26
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

@Component
@Slf4j
public class CookieManager {

  public final String ACCESS_TOKEN_NAME = "accessToken";
  public final String REFRESH_TOKEN_NAME = "refreshToken";

  /**
   * HttpOnly, Secure 쿠키 추가
   * 
   * @param response HttpServletResponse
   * @param name 쿠키 이름
   * @param value 쿠키 값
   * @param maxAge 만료 시간(초)
   */
  public void addCookie(
      HttpServletResponse response,
      String name,
      String value,
      long maxAge) {
    if (response == null || name == null || value == null) {
      log.warn("쿠키 추가 실패: null 파라미터");
      return;
    }
    
    ResponseCookie cookie = ResponseCookie.from(name, value)
        .path("/")
        .httpOnly(true)
        .secure(true) // HTTPS 환경에서만 사용
        .sameSite("Lax") // 같은 도메인 + 외부 링크에서 접속하는 경우에만 사용 가능
        .maxAge(maxAge)
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    log.debug("쿠키 추가: name={}, maxAge={}초", name, maxAge);
  }

  /**
   * 쿠키 만료 처리 (삭제)
   * 
   * @param response HttpServletResponse
   * @param name 쿠키 이름
   */
  public void expireCookie(
      HttpServletResponse response,
      String name) {
    if (response == null || name == null) {
      log.warn("쿠키 만료 실패: null 파라미터");
      return;
    }
    
    ResponseCookie cookie = ResponseCookie.from(name, "")
        .path("/") // 생성했을 때와 동일한 경로
        .httpOnly(true) // 생성 옵션과 동일하게
        .secure(true)
        .sameSite("Lax")
        .maxAge(0) // 즉시 만료
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    log.debug("쿠키 만료: name={}", name);
  }

  /**
   * 요청에서 특정 이름의 쿠키 객체 가져오기
   * 
   * @param request HttpServletRequest
   * @param name 쿠키 이름
   * @return Cookie 객체 (없으면 null)
   */
  public Cookie getCookie(
      HttpServletRequest request,
      String name) {
    if (request == null || name == null) {
      log.warn("쿠키 조회 실패: null 파라미터");
      return null;
    }
    
    Cookie cookie = WebUtils.getCookie(request, name);
    if (cookie != null) {
      log.debug("쿠키 조회 성공: name={}", name);
    } else {
      log.debug("쿠키 없음: name={}", name);
    }
    
    return cookie;
  }
  
}

 


프론트와 백엔드가 HTTP 통신하는 상황이며,
JWT 토큰을 httpOnly = true 속성값을 가지는 웹브라우저 쿠키에 저장하여 관리하고 있다.

 


웹브라우저 저장소에 JWT를 저장 vs 쿠키에 JWT를 저장

 


두 가지 방식이 있으나,
전자의 경우 XSS 공격 취약점을 이용해 삽입한 JS 코드가 실행되어
공격자의 서버로 피해자의 JWT를 전송할 우려가 있다.
반면 httpOnly 속성의 쿠키는 브라우저로 읽을 수 없도록 보호된다 (JS로 값을 꺼내볼 수 없다). > XSS 방어



다만 쿠키를 사용하기 때문에, CSRF 공격 중 세션 라이딩 방식의 공격에 취약해질 수 있다.

 


공격자가 준비한 악성 웹사이트에 피해자가 접속하면,
악성 웹사이트에서 백엔드 서버에 특정한 DELETE 요청을 보내도록 코드를 숨겨놨다고 가정하자.
웹브라우저가 요청을 보낼 때, 저장된 쿠키도 자동으로 첨부해서 보내기 때문에,

 


별도의 처리가 없는 한, DELETE 요청을 보낼 때, 피해자의 쿠키또한 첨부되어서 보내질 것이다.
따라서 피해자의 JWT로 인증을 수행하여 공격자가 서버를 공격할 수 있게 된다.

 


해당 공격을 방어하기 위한 여러 대응 중 일부는 다음과 같다.
1. 쿠키가 아닌 헤더에 인증 토큰을 실어서 인증한다 (CSRF 토큰).
2. 위험한 요청에 대해서는 브라우저가 쿠키 없이 요청을 전송한다 (SameSite 정책)


여기서는 SameSite에 대해 정리하고자 한다.

 

 


웹브라우저를 통해 프론트(www.myservice.com)에서 백엔드(api.myservice.com)로 JWT를 쿠키로 포함하여 요청을 보낼 경우 다음과 같은 일이 일어난다.


1. 프론트에서 백엔드로 요청 (SameSite 검사 및 출발)
프론트엔드가 withCredentials: true 설정을 켜서 API 요청을 보낼 때, 브라우저는 먼저 두 도메인의 eTLD+1(최상위 도메인+1, 즉 myservice.com)이 같다는 것을 확인한다. 같으면 Same-Site, 다르면 Cross-Site로 간주한다.

  • (SameSite 통과): Same-Site이므로 브라우저는 쿠키(JWT)를 빼지 않고 요청에 안전하게 동봉한다.


2. 백엔드에서 서버 응답

백엔드 서버는 쿠키가 잘 담긴 요청을 정상적으로 처리하고 응답을 보낸다.



[용어 정리]

  • Same-Site vs Cross-Site: 도메인의 eTLD+1(예: myservice.com)이 같으면 Same-Site, 다르면 Cross-Site.
  • Same-Origin vs Cross-Origin: 프로토콜 + 도메인(서브도메인 포함) + 포트 3가지가 모두 같아야 Same-Origin, 하나라도 다르면 Cross-Origin.

[SameSite 속성에 따른 동작 시나리오]
① www.myservice.com에서 api.myservice.com으로 HTTP(API) 요청을 보낼 때
(두 주소는 Same-Site 이므로)

  • Strict: 쿠키 전송 허용
  • Lax: 쿠키 전송 허용
  • None: 쿠키 전송 허용


② 사용자가 구글/네이버에서 검색하다가 www.myservice.com 링크를 클릭해서 들어올 때

(Cross-Site 이동이지만, URL이 바뀌는 최상위 탐색(Top-Level Navigation) + GET 요청)

  • Strict: 쿠키 전송 차단 (사용자는 로그아웃된 상태처럼 보이며 새로고침하면 쿠키가 적용됨)
  • Lax: 쿠키 전송 허용 (안전한 이동이므로 로그인 상태 유지됨)
  • None: 쿠키 전송 허용


③ 악성 피싱 사이트 myservices.com에서 api.myservice.com으로 몰래 POST 요청을 보낼 때

(Cross-Site이며 최상위 탐색이 아님)

  • Strict: 쿠키 전송 차단
  • Lax: 쿠키 전송 차단
  • None: 쿠키 전송 허용 (CSRF 공격에 노출됨)

❗주의
공격자가 공격할 서버와 같은 도메인을 탈취해서 공격하는 경우 Same-Site 조치는 무력화된다.
구형 웹브라우저의 경우 SameSite 속성을 지원하지 않기도 하며,
서비스 특성상 외부 도메인과 필수적으로 통신하는 경우(결제 위젯 등) SameSite=None을 사용해야 한다.

따라서 SameSite 설정만으로는 안전하다고 보기 어렵다.

'Study > Spring boot' 카테고리의 다른 글

SpringBoot | EC2 - RDS SSH Tunneling (RSA / ED25519)  (0) 2025.07.31

RDS에 Public IP 를 할당하면 RDS에 바로 연결가능하지만 Public IP 사용 요금이 청구된다.

EC2 인스턴스와 RDS 인스턴스를 동일한 VPC 내에 두고,

EC2 인스턴스에서 RDS 인스턴스로 접속하게끔 구성하면 둘 사이의 통신에 AWS 내부 망을 사용하므로
Public IP 할당없이 사용 가능하다.

 

EC2를 백엔드 서버로 사용하고, 여기서 RDS에 접속하도록 하는 것이 이상적이지만,

개발 중에는 편의상 로컬에서 Spring boot 서버를 빌드하고, EC2를 단순히 RDS 에 접속하기 위한 경유지로 사용하고자 한다.

 

따라서 전체적인 네트워크 흐름을 정리하면 다음과 같다.

Localhost:forwardedPort (Spring boot 프로젝트 server) ↔ EC2 (Jump server) ↔ RDS (DB)

 

EC2에는 SSH로 연결하기 때문에 암호화 특성상 네트워킹 성능 이슈가 존재한다.

 

0. 환경


로컬PC

  - OS : Windows


EC2

  - OS : Amazon Linux 2023

 

RDS

  - DBMS : MySQL

 

Spring boot

  - 빌드툴 : Gradle

  - JPA

 

RSA 키 (.pem) 사용

1. Dependency 추가


dependencies {
	// (...)
	// JPA
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    // SSH
    implementation("com.jcraft:jsch:0.1.55")
    // DB driver
    implementation("mysql:mysql-connector-java:8.0.32")
}

build.gradle.kts

 

2. 코드 작성


spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

## localhost:[forwardedPort] -> host:port (EC2) -> remote_host:remote_port (RDS) ##

## Local ##
# forwardedPort is assigned by SshTunnelingInitializer in DataSourceSshTunnelingConfig
spring.datasource.url=jdbc:mysql://localhost:[forwardedPort]/{**DB스키마 이름**}
spring.datasource.username=***
spring.datasource.password=***


## Jump server ##
ssh.host=***
# port 기본값
ssh.port=22
# AWS Linux 기본 유저명
ssh.user=ec2-user
# RSA 키 경로. PEM or PPK
ssh.private_key= ***


## Remote server ##
# Jump server -> Remote server (DB)
ssh.remote_host=***
# DB 기본 port
ssh.remote_port= 3306

application.properties

@Component
@ConfigurationProperties(prefix = "ssh") // application.properties 값 사용
@Validated
@Setter
public class SshTunnelingInitializer {

    // ** SSH host **
    @NotNull
    private String host; // application.properties 의 ssh.host 값과 자동 매핑
    @NotNull
    private int port;
    @NotNull
    private String user;
    @NotNull
    private String privateKey;

    // ** Remote host **
    @NotNull
    private String remoteHost;
    @NotNull
    private int remotePort;

    private Session session;

    private final Logger logger = LoggerFactory.getLogger(SshTunnelingInitializer.class);

    @PreDestroy
    public void closeSSH() {
        if (session.isConnected()) {
            session.disconnect();
        }
    }

    public Integer buildSshConnection() {
        Integer forwardedPort = null;

	// Jsch 동작 로그를 출력
        JSch.setLogger(new com.jcraft.jsch.Logger() {
            public boolean isEnabled(int level) { return true; }
            public void log(int level, String message) {
                System.out.println("JSch: " + message);
            }
        });


        try {
            logger.info("Ssh tunneling start");
            logger.info("{}@{} -> {}@{}", host, port, remoteHost, remotePort);

            JSch jsch = new JSch();
            logger.info("creating ssh session");

            if (!new File(privateKey).exists()) {
                throw new IllegalStateException("비공개 키 파일을 찾을 수 없습니다: " + privateKey);
            }else{
                logger.info("private key : {}", privateKey);
            }

            jsch.addIdentity(privateKey);

            KeyPair kp = KeyPair.load(jsch, privateKey);
            if (kp == null) {
                logger.error("❌ 키 로드 실패");
            } else {
                logger.info("✅ 키 로드 성공: 타입 = {}", kp.getKeyType());
            }

            logger.info("{}@{}:{}", user, host, port);
            session = jsch.getSession(user, host, port);
            Properties config = new Properties();
            // 최초 SSH 접속 시 서버의 호스트 키 신뢰
            config.put("StrictHostKeyChecking", "no");

            logger.info("setting config : {}", config.toString());
            session.setConfig(config);

            logger.info("connecting ssh session");
            session.connect(10000);


            logger.info("successfully connected");
            logger.info("port forwarding start");
            // localhost@(auto-assigned port)
            // -> SSH server (AWS EC2): ssh.host@ssh.port
            // -> Remote server (AWS RDS): ssh.remoteHost@ssh.remotePort
            forwardedPort = session.setPortForwardingL(0, remoteHost, remotePort);
            logger.info("port forwarding end");
        } catch (Exception e) {
            logger.error("SSH Tunneling Error");
            e.printStackTrace();
            this.closeSSH();
            exit(1);
        }
        return forwardedPort;
    }
}

SshTunnelingInitialzer.java

SSH 연결 초기화

@Configuration
@RequiredArgsConstructor
public class DataSourceSshTunnelingConfig {
    private final SshTunnelingInitializer sshTunnelingInitializer;
    Logger logger = org.slf4j.LoggerFactory.getLogger(this.getClass());

    @Bean("dataSource")
    @Primary
    public DataSource dataSource(DataSourceProperties properties){
        Integer forwardedPort = sshTunnelingInitializer.buildSshConnection();
        String url = properties.getUrl().replace("[forwardedPort]", forwardedPort.toString());
        properties.setUrl(url);
        logger.info("DataSource url : {}", url);
        return properties.initializeDataSourceBuilder().build();
    }
}

DataSourceTunnelingConfig.java

 

3. EC2 인스턴스 접속 및 설정 변경


0) 공통

 

 

키페어는 AWS에서 생성하면

생성 직후 로컬 PC에 다운로드 받아지는 것이 개인키,

EC2 인스턴스 생성 시 할당하여 인스턴스 내에 보관되는 것이 퍼블릭키이다.

RSA와 ED25519 둘 다 .pem 파일로 생성하여 시도했다.

 

다운받은 개인키는 파일 권한이 많이 열려있기 때문에 그대로 SSH 연결에 사용할 경우

키 권한이 너무 열려있다는 에러메시지와 함께 실패한다.

권한을 400으로 두어 제한하면 해결된다.

 

$ chmod 400 {~.pem}

 

> icacls {~.pem} /reset
> icacls {~.pem} /inheritance:r
> icacls {~.pem} /grant:r "%USERNAME%\%USERNAME%":R
> icacls {~.pem} /grant:r "SYSTEM":R

 

윈도우에서 chmod 400과 동일한 효과

SYSTEM에도 권한을 줘야 windows openSSH가 키파일에 접근가능.

 

방법 1) RSA (jcraft:jsch - deprecated)

    implementation("com.jcraft:jsch:0.1.55")
    // ED25519 support
    // implementation("com.github.mwiede:jsch:2.27.2")

 

jcraft:jsch 는 2018년도 0.1.55버전이 가장 최신 버전이다. 좀 오래된 편. 

 

해당 Jsch 라이브러리에서는 SSH 연결시 인증을 위해 사용되는 RSA키는 ssh-rsa(SHA-1)라는 알고리즘을 사용한다.

해당 알고리즘은 보안상 취약점 때문에 OpenSSH 8.8 이상에서는 ssh-rsa을 기본적으로 지원하지 않도록 변경되었다.

 

따라서 EC2 인스턴스에 직접 접속한 뒤,

암호화 정책(Crypto-policies) 레벨을 DEFAULT 에서 LEGACY로 변경하여 ssh-rsa를 지원하도록 설정을 바꾸었다.

 

아니면 OpenSSH 버전을 낮추는 것도 방법이긴 하겠다만 거기까지 직접 시도는 해보지 않았다.

 

SSH 연결을 요청하는 Spring boot 프로젝트 단에서는 Jsch 로그를 남겨도 인증 실패 (Auth fail) 원인을 알기 어렵기에

연결 요청을 받아들이는 쪽(EC2 인스턴스)에서 로그를 뒤져 대략적인 원인을 알아내야 했다.

 

EC2 인스턴스에 접속 후 CLI에 다음을 입력하면 SSH 접속 로그 최근기록 10개를 볼 수 있다.

$sudo journalctl -u sshd -n 10

 

 


뒤져서 찾아본 실패 로그내역은 위와 같았다.

PubkeyAcceptedAlgorithms 목록에 ssh-rsa를 추가하고,

ssh-rsa를 지원하도록 보안정책도 LEFACY로 변경했다.

 

+)
후술하겠지만 jcraft:jsch 라이브러리를 버리고
보다 최신 라이브러리로 변경하면 RSA나 ED255나 보안정책을 건드리지 않아도 된다.

$sudo vi /etc/ssh/sshd_config

'''
sshd_config 파일에 아래 한 줄 추가
PubkeyAcceptedAlgorithms +ssh-rsa
'''

# 변경사항 적용
$sudo systemctl restart sshd

# 보안 정책 변경
# 기본값은 DEFAULT
$ sudo dnf install crypto-policies-scripts
$ sudo update-crypto-policies --set LEGACY

 

아래는 EC2에서 기본적으로 지원하는 공개키 알고리즘 목록이다.

물론 이 중에서 ssh-rsa 는 방금 새로 추가한 것이다.

[***@***]$ ssh -Q PubkeyAcceptedAlgorithms
ssh-ed25519
ssh-ed25519-cert-v01@openssh.com
sk-ssh-ed25519@openssh.com
sk-ssh-ed25519-cert-v01@openssh.com
ssh-rsa
rsa-sha2-256
rsa-sha2-512
ssh-dss
ecdsa-sha2-nistp256
ecdsa-sha2-nistp384
ecdsa-sha2-nistp521
sk-ecdsa-sha2-nistp256@openssh.com
webauthn-sk-ecdsa-sha2-nistp256@openssh.com
ssh-rsa-cert-v01@openssh.com
rsa-sha2-256-cert-v01@openssh.com
rsa-sha2-512-cert-v01@openssh.com
ssh-dss-cert-v01@openssh.com
ecdsa-sha2-nistp256-cert-v01@openssh.com
ecdsa-sha2-nistp384-cert-v01@openssh.com
ecdsa-sha2-nistp521-cert-v01@openssh.com
sk-ecdsa-sha2-nistp256-cert-v01@openssh.com

 

 

방법 2 - 추천) ED25519 (mwiede:jsch)

보안상 RSA 방식보다 ED25519 키가 어쩌구저쩌구 더 좋다.

 

jcraft:jsch가 오래되었다보니 ED25519 방식을 지원하지 않아서

그냥 오래된 라이브러리를 버리고 해당 방식을 지원하는 라이브러리로 바꿔야했다.

//    implementation("com.jcraft:jsch:0.1.55") // SSH tunneling
    // ED25519 support
    implementation("com.github.mwiede:jsch:2.27.2")


+)
변경된 라이브러리로 RSA키를 사용해 보았을 때,
SHA-1가 아닌 SHA-256이나 SHA-512해시로 서명해서 인증하므로
1)번에서의 복잡한 과정을 거치지 않아도 되었다.

 


 

+ MySQL Workbench

 


Standard TCP/IP over SSH

MySQL Workbench가 연결하는 SSH 터널링 경로를 통해 DB에 연결

설정값은 코드 부분에서 작성한 값을 그대로 사용

 

 

+ 참조

https://letsmakemyselfprogrammer.tistory.com/123

 

[SpringBoot] 8. 하이버네이트 원격서버 암호화 연결 (SSH tunneling 설정)

[배경] 개발 서버를 따로 운영하고 있다. 따로 로컬에서 db 작업하고 다시 개발서버로 적용하는 일이 번거로워서 애초에 개발서버에서 db 작업 후 바로 적용한다. 기존에는 개발용 데이터베이스

letsmakemyselfprogrammer.tistory.com

 

https://minseok-study.tistory.com/entry/userauthpubkey-key-type-ssh-rsa-not-in-PubkeyAcceptedAlgorithms-preauth-OpenSSH-ssh-rsa-deprecated

 

userauth_pubkey: key type ssh-rsa not in PubkeyAcceptedAlgorithms [preauth] (OpenSSH ssh-rsa deprecated)

기존 서버는 Amazon linux 2를 사용하였으나 node version upgrade를 위하여 Amazon linux 2023으로 변경하였다. 다만, 기존에 외부 종속성 중에서 DB 접속이 필요하던 서비스가 있었는데 잘 되던 ssh 터널링의

minseok-study.tistory.com

 

 

 

Reference.

https://wikidocs.net/115055

 

17-02 버트(Bidirectional Encoder Representations from Transformers, BERT)

* 트랜스포머 챕터에 대한 사전 이해가 필요합니다. ![](https://wikidocs.net/images/page/35594/BERT.PNG) BERT(Bidire…

wikidocs.net

 

BERT

 

기본적인 구조는 바로 이전 포스팅 ( https://rgb234.tistory.com/54 ) 에서 언급한

트랜스포머 Transformer 의 Encoder layer 를 여러 개 쌓은 구조이다.

 

각 층은 서브 층 두 개로 구성되어 있으며, 순서대로 Multi-head Self Attention 와 FFNN (Fead-forward Neural Network)이다.

위 그림에서는 seq_len x $D_{\text{model}}$ 의 행렬에서 설명을 위해 seq_len = 4, $D_{\text{model}}$ = 4로 표현되고 있다.

 

 

 

BERT 에 문장이 입력되기 전 임베딩 레이어는 총 세 개이다.

 

  • WordPiece Embedding : 실질적인 입력이 되는 워드 임베딩. 임베딩 벡터의 종류는 단어 집합의 크기로 30,522개.
  • Position Embedding : 위치 정보를 학습하기 위한 임베딩. 임베딩 벡터의 종류는 문장의 최대 길이인 512개.
  • Segment Embedding : 두 개의 문장을 구분하기 위한 임베딩. 임베딩 벡터의 종류는 문장의 최대 개수인 2개.

[CLS] 토큰은 BERT 가 분류 문제를 풀기 위해 있는 패딩 토큰이며, [SEP] 은 문장을 구분하기 위한 패딩 토큰이다.

사실 말이 문장이지 여러 문장을 하나로 묶은 문서가 되기도 한다.

 

패딩 토큰들도 어탠션할 필요는 없으므로 이를 위한 어탠션 마스크도 존재한다.

 

대부분의 언어 모델이 그렇듯이 BERT 도 누군가가 Pre-training 한 모델을 가져와서 특정 문제에 맞게 파인 튜닝하여 사용한다.

 

BERT Pre-training 과정

1. Masked Language Model (MLM)

 

전체 입력 단어 중 15% 는 학습에 사용된다. 단어 일부를 [MASK] 로 대체하거나, 다른 임의의 단어로 변경하거나, 그대로 두고 원래 단어가 무엇이었는지 맞추게 된다. Masked Language model 이라 하려 MLM 이라 한다.

전부 [MASK] 토큰으로 대체할 경우, [MASK] 토큰은 파인 튜닝 때에는 사용하지 않으므로 사전 학습 때와 불일치가 발생한다는 점을 고려한 방식이다.

 

2. Next Sentence Prediction, NSP

 

SEP 를 기준으로 분리된 두 문장이 이어지는 문장인지, 아닌지 분류하는 문제로,

CLS 토큰 위치의 출력층에서 이진 분류 문제를 풀게 한다. 

MLM 외에도 NSP 문제로 사전 학습하는 이유는, 파인 튜닝 때 QA(Question answering) 문제나 NLI(Natural Language Inference)문제처럼 두 문장의 관계를 이해하는 문제를 풀기위해서이다.

 

BERT Fine-tuning 과정

BERT Fine-tuning 태스크 종류

 

1. 하나의 텍스트에 대하여 분류 (single text classification)

 

2. Tagging

 

3. 텍스트의 쌍에 대한 분류 또는 회귀 문제(Text Pair Classification or Regression)

NLI 문제와 같이 두 문장의 관계를 추론 (모순, 함의 중립 등)

 

4. QA ( Question answering )

 

 

질문과 본문을 입력받고 본문의 일부에서 질문에 대한 정답을 추출하는 방식으로 파인튜닝

 

 

SBERT (sentence BERT)

사실상 BERT 를 가지고 문장 임베딩 성능을 우수하게끔 파인튜닝한 것.

BERT 의 출력층을 mean pooling 혹은 max pooling 등으로 pooling 한 벡터를 문장 임베딩으로 사용하거나,

혹은 BERT 의 [CLS] 토큰 위치에 있는 출력층의 벡터를 문장 임베딩으로 사용한다.

 

1. 문장 쌍 분류

대표적으로 NLI 문제.

 

 

2. 문장 쌍 회귀

 

두 문장의 유사도를 가지고 회귀 문제를 푼다.

문장 유사도는 보통 0-5 범위의 값으로, 0은 완전 불일치, 5는 완전 일치로 매긴다.

 

아래는 https://aclanthology.org/S17-2001 의 SemEval-2017, Semantic textual similarity 태스크에 관한 점수 기준표다.

여러 명의 "사람"이 합의 하에 문장 쌍의 유사도에 대해 점수를 매겨 벤치마크 데이터셋을 만든다.

 

+ Recent posts