[BOJ]7579.앱

문제

우리는 스마트폰을 사용하면서 여러 가지 앱(App)을 실행하게 된다. 대개의 경우 화면에 보이는 ‘실행 중’인 앱은 하나뿐이지만 보이지 않는 상태로 많은 앱이 '활성화'되어 있다. 앱들이 활성화 되어 있다는 것은 화면에 보이지 않더라도 메인 메모리에 직전의 상태가 기록되어 있는 것을 말한다. 현재 실행 중이 아니더라도 이렇게 메모리에 남겨두는 이유는 사용자가 이전에 실행하던 앱을 다시 불러올 때에 직전의 상태를 메인 메모리로부터 읽어 들여 실행 준비를 빠르게 마치기 위해서이다.

하지만 스마트폰의 메모리는 제한적이기 때문에 한번이라도 실행했던 모든 앱을 활성화된 채로 메인 메모리에 남겨두다 보면 메모리 부족 상태가 오기 쉽다. 새로운 앱을 실행시키기 위해 필요한 메모리가 부족해지면 스마트폰의 운영체제는 활성화 되어 있는 앱들 중 몇 개를 선택하여 메모리로부터 삭제하는 수밖에 없다. 이러한 과정을 앱의 ‘비활성화’라고 한다.

메모리 부족 상황에서 활성화 되어 있는 앱들을 무작위로 필요한 메모리만큼 비활성화 하는 것은 좋은 방법이 아니다. 비활성화된 앱들을 재실행할 경우 그만큼 시간이 더 필요하기 때문이다. 여러분은 이러한 앱의 비활성화 문제를 스마트하게 해결하기 위한 프로그램을 작성해야 한다

현재 N개의 앱, A1, ..., AN이 활성화 되어 있다 고 가정하자. 이들 앱 Ai는 각각 mi 바이트만큼의 메모리를 사용 하고 있다. 또한, 앱 Ai를 비활성화한 후에 다시 실행하고자 할 경우, 추가적으로 들어가는 비용(시간 등)을 수치화 한 것을 ci 라고 하자. 이러한 상황에서 사용자가 새로운 앱 B를 실행하고자 하여, 추가로 M 바이트의 메모리가 필요하다고 하자. 즉, 현재 활성화 되어 있는 앱 A1, ..., AN 중에서 몇 개를 비활성화 하여 M 바이트 이상의 메모리를 추가로 확보해야 하는 것이다. 여러분은 그 중에서 비활성화 했을 경우의 비용 ci의 합을 최소화하여 필요한 메모리 M 바이트를 확보하는 방법 을 찾아야 한다.

입력

입력은 3줄로 이루어져 있다. 첫 줄에는 정수 N과 M이 공백문자로 구분되어 주어지며, 둘째 줄과 셋째 줄에는 각각 N개의 정수가 공백문자로 구분되어 주어진다. 둘째 줄의 N개의 정수는 현재 활성화 되어 있는 앱 A1, ..., AN이 사용 중인 메모리의 바이트 수인 m1, ..., mN을 의미하며, 셋째 줄의 정수는 각 앱을 비활성화 했을 경우의 비용 c1, ..., cN을 의미한다

단, 1 ≤ N ≤ 100, 1 ≤ M ≤ 10,000,000이며, 1 ≤ m1, ..., mN ≤ 10,000,000을 만족한다. 또한, 0 ≤ c1, ..., cN ≤ 100이고, M ≤ m1 + m2 + ... + mN이다.

출력

필요한 메모리 M 바이트를 확보하기 위한 앱 비활성화의 최소의 비용을 계산하여 한 줄에 출력해야 한다.


예제 입력

5 60
30 10 20 35 40
3 0 3 5 4

예제 출력

6

접근 방법

제한된 메모리에서 필요한 메모리를 확보하기 위해 앱을 비활성화할 때, 필요한 메모리를 확보하면서 비활성화 비용을 최소화 하는 방법을 찾아야 하는 문제입니다. 특정 용량 내에서 최대 가치를 찾는 문제인 배낭 문제와 비슷한 유형입니다. N의 최대값이 100이므로 각 앱을 비활성화할지 말지를 완전탐색으로 푼다면 시간 복잡도는 O(2^N) 이 됩니다. 따라서 완전탐색으로 풀 수 없는 문제입니다.
문제를 분석해보면, 최적 부분 구조 가 있다는 것을 알 수 있습니다. 최적 부분 구조란 큰 문제의 해답이 작은 문제들의 해답으로 이루어진다는 성질입니다. 예를 들어, 앱 1,3을 비활성화했을 때 필요한 최소 비용을 구한다고 합시다. 이제 앱 4를 추가해서 비활성화할 때 앱 1,3을 비활성화한 상태에서 비용과 메모리를 그대로 활용할 수 있습니다. 즉, 이전 계산 결과를 재활용할 수 있습니다.
같은 문제를 반복해서 계산할 필요가 없다는 점에서 DP 를 사용하여 문제를 풀 수 있고, DP 테이블은 배낭 문제에서 dp[무게]를 정의하는 것처럼 dp[비용]으로 정의할 수 있겠다는 생각이 들었습니다.


DP 테이블 정의

dp[c]를 비용 c를 사용하여 확보할 수 있는 최대 메모리라고 정의합니다.


초기화

비용이 0일 때 확보할 수 있는 메모리는 0입니다. dp 배열의 초기값을 0으로 만들어줍니다.


점화식

각 앱 i에 대해 비용이 c일 때, 현재 i번 앱을 비활성화하면 이전 상태에서 추가로 메모리를 확보할 수 있습니다.

dp[j] = Math.max(dp[j], dp[j - costs[i]] + bytes[i]);
  1. 현재 앱을 비활성화 하지 않는 경우
    이전 단계에서 얻은 비용 dp[j]는 그대로 유지됩니다.
  2. 현재 앱 i를 비활성화 하는 경우
    비용 j 중에서 비활성화 비용 costs[i] 만큼 사용하게 됩니다. 즉, 비용 j에서 costs[i]를 뺀 비용으로 확보한 메모리에 현재 앱이 사용하고 있는 메모리 bytes[i]를 추가로 확보하게 됩니다.

같은 비용이라면 더 큰 메모리를 확보할 수 있는 경우를 선택합니다. 즉, 이 두 값 중 더 큰 값을 선택합니다.


시간 복잡도

N개의 앱에 대해 최대 sum(costs) 만큼의 비용에 대해 계산합니다. 각 앱에 대해 비용을 갱신하므로 시간 복잡도는 O(N * sumCosts) 가 됩니다. sumCosts는 각 앱의 비활성화 비용의 합입니다. N은 최대 100까지 가능하고 각 앱의 비활성화 비용은 최대 100이므로 최악의 경우 sumCosts는 10,000이 될 수 있습니다.
따라서, N * sumCosts = 100 * 10,000 = 1,000,000이 되어 제한 시간 1초 안에 해결할 수 있습니다.

전체 로직

  1. 각 앱의 메모리 사용량과 비활성화 비용을 입력 받습니다.
  2. dp 배열을 초기화하고 비용을 0에서 시작합니다.
  3. 각 앱에 대해 비용을 역순으로 처리하면서 현재 앱을 비활성화하는 경우와 그렇지 않은 경우를 고려해 DP 테이블을 갱신합니다.
  4. dp 배열에서 확보한 메모리가 M 이상인 최소 비용을 찾습니다.
  5. 최소 비용을 출력합니다.

역순 탐색 이유

한 번의 반복에서 갱신된 dp 값을 다시 참조하는 것을 방지하기 위함입니다. 예를 들어, 순차적으로 탐색할 경우 dp[j]를 갱신한 후, 그 값이 다음 dp[j+1]을 갱신하는 데 사용되면 같은 앱을 여러 번 비활성화 하는 것 과 같은 문제가 발생합니다. 역순 탐색을 하면 이전 상태에서만 계산이 이루어지기 때문에 같은 앱을 중복으로 비활성화하는 일이 생기지 않습니다.


예를 들어, 다음과 같은 입력이 주어진다고 가정합니다.
앱의 개수 N = 3
확보해야 하는 메모리 M = 6
각 앱이 사용하는 메모리 : 4MB, 2MB, 3MB
각 앱의 비활성화 비용 : 3, 1, 2


1번 앱(4MB, 비용 3) 처리 (순차 탐색):
dp[3] = max(dp[3], dp[0] + 4) → dp[3] = max(0, 0 + 4) = 4
dp[4] = max(dp[4], dp[1] + 4) → dp[4] = max(0, 0 + 4) = 4
dp[5] = max(dp[5], dp[2] + 4) → dp[5] = max(0, 0 + 4) = 4
dp[6] = max(dp[6], dp[3] + 4) → dp[6] = max(0, 4 + 4) = 8


dp[6]을 갱신할 때 이미 갱신된 dp[3]의 값을 참조하고 있습니다. 즉, 1번 앱을 비활성화해서 4MB를 확보한 상태에서 또다시 d[3]을 참조해 중복으로 앱 1을 비활성화한 것처럼 계산됩니다.
역순 탐색은 이미 갱신된 값을 다음 계산에 사용하지 않도록 보장합니다.


1번 앱(4MB, 비용 3) 처리 (역순 탐색):
dp[6] = max(dp[6], dp[6 - 3] + 4) → dp[6] = max(0, 0 + 4) = 4
dp[5] = max(dp[5], dp[5 - 3] + 4) → dp[5] = max(0, 0 + 4) = 4
dp[4] = max(dp[4], dp[4 - 3] + 4) → dp[4] = max(0, 0 + 4) = 4
dp[3] = max(dp[3], dp[3 - 3] + 4) → dp[3] = max(0, 0 + 4) = 4


예시

다음과 같은 입력이 주어진다고 가정합니다.

앱의 개수 N = 3
확보해야 하는 메모리 M = 6
각 앱이 사용하는 메모리 : 4MB, 2MB, 3MB
각 앱의 비활성화 비용 : 3, 1, 2
이때 모든 앱을 비활성화할 때 발생할 수 있는 총 비용을 계산합니다. 여기서는 sumCosts = 3 + 1 + 2 = 6 입니다.


각 앱을 비활성화할지 말지 결정하면서 DP 테이블을 갱신합니다.
이때, 각 앱을 순차적으로 처리하고 비용을 역순으로 처리하여 중복 계산을 방지합니다.


1번 앱 처리
dp[3] 부터 dp[sumCosts] 까지 갱신합니다.
j = 6일 때: dp[6] = max(dp[6], dp[6 - 3] + 4) = max(0, 0 + 4) = 4
j = 5일 때: dp[5] = max(dp[5], dp[5 - 3] + 4) = max(0, 0 + 4) = 4
j = 4일 때: dp[4] = max(dp[4], dp[4 - 3] + 4) = max(0, 0 + 4) = 4
j = 3일 때: dp[3] = max(dp[3], dp[3 - 3] + 4) = max(0, 0 + 4) = 4

결과: dp = [0, 0, 0, 4, 4, 4, 4]


2번 앱 처리
dp[6]부터 dp[1]까지 역순으로 갱신합니다.
j = 6일 때: dp[6] = max(dp[6], dp[6 - 1] + 2) = max(4, 4 + 2) = 6
j = 5일 때: dp[5] = max(dp[5], dp[5 - 1] + 2) = max(4, 4 + 2) = 6
j = 4일 때: dp[4] = max(dp[4], dp[4 - 1] + 2) = max(4, 4 + 2) = 6
j = 3일 때: dp[3] = max(dp[3], dp[3 - 1] + 2) = max(4, 0 + 2) = 4
j = 2일 때: dp[2] = max(dp[2], dp[2 - 1] + 2) = max(0, 0 + 2) = 2
j = 1일 때: dp[1] = max(dp[1], dp[1 - 1] + 2) = max(0, 0 + 2) = 2

결과: dp = [0, 2, 2, 4, 6, 6, 6]


3번 앱 처리
dp[6]부터 dp[2]까지 역순으로 갱신합니다.
j = 6일 때: dp[6] = max(dp[6], dp[6 - 2] + 3) = max(6, 6 + 3) = 9
j = 5일 때: dp[5] = max(dp[5], dp[5 - 2] + 3) = max(6, 4 + 3) = 7
j = 4일 때: dp[4] = max(dp[4], dp[4 - 2] + 3) = max(6, 2 + 3) = 6
j = 3일 때: dp[3] = max(dp[3], dp[3 - 2] + 3) = max(4, 2 + 3) = 5

결과: dp = [0, 2, 2, 5, 6, 7, 9]


확보해야 하는 메모리 6 이상인 최소 비용은 dp[4]입니다. 이 예시의 출력은 4가 됩니다.

코드

import java.io.*;

public class BJ_G3_7579 {

    static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    static int N; // 앱의 개수
    static int M; // 필요한 메모리
    static int[] bytes; // 앱이 사용하고 있는 메모리
    static int[] costs; // 해당 앱을 비활성화 할 경우의 비용
    static int sumCosts;
    static int[] dp; // i만큼의 비용으로 확보할 수 있는 최대 메모리 크기

    public static void main(String[] args) throws Exception {
        init();
        solve();
        output();
    }

    public static void init() throws Exception {
        String[] inputs = br.readLine().split(" ");
        N = Integer.parseInt(inputs[0]);
        M = Integer.parseInt(inputs[1]);

        bytes = new int[N + 1];
        costs = new int[N + 1];

        String[] byteInputs = br.readLine().split(" ");
        String[] costInputs = br.readLine().split(" ");
        for (int i = 1; i <= N; i++) {
            bytes[i] = Integer.parseInt(byteInputs[i - 1]);
            costs[i] = Integer.parseInt(costInputs[i - 1]);
            sumCosts += costs[i];
        }

        dp = new int[sumCosts + 1];
    }

    public static void solve() {
        for (int i = 1; i <= N; i++) {
            for (int j = sumCosts; j >= costs[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - costs[i]] + bytes[i]);
            }
        }
    }

    public static void output() {
        for (int i = 0; i < dp.length; i++) {
            if (dp[i] >= M) {
                System.out.println(i);
                break;
            }
        }
    }
}

'algorithm' 카테고리의 다른 글

[BOJ] 10971. 외판원 순회2  (4) 2024.09.03

[BOJ]10971. 외판원 순회2

문제

외판원 순회 문제는 영어로 Traveling Salesman problem (TSP) 라고 불리는 문제로 computer science 분야에서 가장 중요하게 취급되는 문제 중 하나이다. 여러 가지 변종 문제가 있으나, 여기서는 가장 일반적인 형태의 문제를 살펴보자.

1번부터 N번까지 번호가 매겨져 있는 도시들이 있고, 도시들 사이에는 길이 있다. (길이 없을 수도 있다) 이제 한 외판원이 어느 한 도시에서 출발해 N개의 도시를 모두 거쳐 다시 원래의 도시로 돌아오는 순회 여행 경로를 계획하려고 한다. 단, 한 번 갔던 도시로는 다시 갈 수 없다. (맨 마지막에 여행을 출발했던 도시로 돌아오는 것은 예외) 이런 여행 경로는 여러 가지가 있을 수 있는데, 가장 적은 비용을 들이는 여행 계획을 세우고자 한다.

각 도시간에 이동하는데 드는 비용은 행렬 W[i][j]형태로 주어진다. W[i][j]는 도시 i에서 도시 j로 가기 위한 비용을 나타낸다. 비용은 대칭적이지 않다. 즉, W[i][j] 는 W[j][i]와 다를 수 있다. 모든 도시간의 비용은 양의 정수이다. W[i][i]는 항상 0이다. 경우에 따라서 도시 i에서 도시 j로 갈 수 없는 경우도 있으며 이럴 경우 W[i][j]=0이라고 하자.

N과 비용 행렬이 주어졌을 때, 가장 적은 비용을 들이는 외판원의 순회 여행 경로를 구하는 프로그램을 작성하시오.

입력

첫째 줄에 도시의 수 N이 주어진다. (2 ≤ N ≤ 10) 다음 N개의 줄에는 비용 행렬이 주어진다. 각 행렬의 성분은 1,000,000 이하의 양의 정수이며, 갈 수 없는 경우는 0이 주어진다. W[i][j]는 도시 i에서 j로 가기 위한 비용을 나타낸다.

출력

첫째 줄에 외판원의 순회에 필요한 최소 비용을 출력한다.


예제 입력

4
0 10 15 20
5 0 9 10
6 13 0 12
8 8 9 0

예제 출력

35


접근 방법

  1. 양의 가중치를 가지는 그래프에서 최소 비용으로 여행을 간다는 점을 보고 다익스트라를 먼저 떠올렸습니다.
  2. 그러나 다익스트라는 하나의 출발지에서 다른 모든 도시까지의 최단 경로를 찾을 때에는 적합하지만, 모든 도시를 반드시 순회해야 하는 TSP 문제에는 적합하지 않습니다.
  3. 따라서 모든 도시를 거치는 dfs와 모든 경로를 탐색해야 하므로 백트래킹을 사용하는 방법을 생각했습니다.
  4. N의 크기가 최대 10까지이므로 완전탐색으로 문제를 풀 수 있을 것이라 판단했습니다.
    인접 행렬로 표현된 그래프의 DFS 시간복잡도 : O(V^2) (V: 정점의 개수)

주요 로직

1. 인접행렬로 그래프 표현

public static void init() throws Exception {
    N = Integer.parseInt(br.readLine());

    costs = new int[N][N];
    visit = new boolean[N];
    result = Integer.MAX_VALUE;

    for (int i = 0; i < N; i++) {
        String[] inputs = br.readLine().split(" ");
        for (int j = 0; j < N; j++) {
            costs[i][j] = Integer.parseInt(inputs[j]);
        }
    }
}

모든 도시를 순회하며 최소 비용 경로를 찾아야 하므로 이동 비용이 자주 조회될 것이라 생각하여 인접행렬로 그래프를 표현했습니다.


2. dfs + 백트래킹

public static void dfs(int node, int cnt, int sum, int start) {
    if(cnt == N) {

        // 순회가 되지 않는다면 패스
        if(costs[node][start] == 0) {
            return;
        }

        result = Math.min(result, sum + costs[node][start]);
        return;
    }

    visit[node] = true;

    for (int i = 0; i < costs[node].length; i++) {
        if (visit[i] || costs[node][i] == 0) {
            continue;
        }

        dfs(i, cnt + 1, sum + costs[node][i], start);
    }
    visit[node] = false;
}

도시를 DFS로 순회하며 경로를 탐색하고, 각 경로의 비용을 계산합니다. 경로가 완료되면 출발 도시로 돌아가는 비용을 더해 최소 비용을 갱신하고 백트래킹으로 모든 경로를 탐색합니다.

예를 들어, 0, 1, 2, 3 노드가 모두 각각의 노드에게 간선이 연결되어 있다고 가정해봅시다. 처음에는 0 → 1 → 2 → 3 -> 0 순으로 방문할 것이고 다음에는 0 → 1→ 3→ 2 -> 0 순으로 방문할 것입니다.

만약 cnt가 도시의 총 개수(N)와 같다면, 이는 모든 도시를 방문한 것이므로, 마지막 노드에서 출발 노드로 돌아가는 비용을 더해 결과를 갱신합니다. 이때 순회 경로가 만들어지지 않는다면 해당 경로는 무시합니다.



최종 코드

  • 메모리 : 14996 kb
  • 시간: 280 ms
import java.io.*;

public class BJ_S1_10971 {

    static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    static int N;
    static int[][] costs;
    static boolean[] visit;
    static int result;

    public static void main(String[] args) throws Exception {
        init();
        solve();
        output();
    }

    public static void output() {
        System.out.println(result);
    }

    public static void solve() {
        for (int i = 0; i < N; i++) {
            dfs(i, 1, 0, i);
        }
    }

    public static void dfs(int node, int cnt, int sum, int start) {
        if(cnt == N) {

            // 순회가 되지 않는다면 패스
            if(costs[node][start] == 0) {
                return;
            }

            result = Math.min(result, sum + costs[node][start]);
            return;
        }

        visit[node] = true;

        for (int i = 0; i < costs[node].length; i++) {
            if (visit[i] || costs[node][i] == 0) {
                continue;
            }

            dfs(i, cnt + 1, sum + costs[node][i], start);
        }
        visit[node] = false;
    }

    public static void init() throws Exception {
        N = Integer.parseInt(br.readLine());

        costs = new int[N][N];
        visit = new boolean[N];
        result = Integer.MAX_VALUE;

        for (int i = 0; i < N; i++) {
            String[] inputs = br.readLine().split(" ");
            for (int j = 0; j < N; j++) {
                costs[i][j] = Integer.parseInt(inputs[j]);
            }
        }
    }
}

'algorithm' 카테고리의 다른 글

[BOJ] 7579. 앱  (0) 2024.10.02

JDK(Java Development Kit)

JDK는 Java Development Kit의 약자로 자바 개발자들이 개발할 때 필요한 도구 모임입니다. JDK에는 대표적으로 javac(자바 컴파일러, 자바 소스 코드를 바이트 코드로 컴파일), JRE(Java Runtime Environment, jdb(Java 디버거), javadoc(문서 생성기, 자바 소스 코드에서 API문서를 생성), jar(Java 아카이브 도구)가 있습니다.

즉, JDK는 자바 애플리케이션을 개발하고 실행하는 데 필요한 모든 도구를 제공합니다. Oracle, OpenJDK, Amazon Corretto 등 여러 배포판이 존재합니다.

JRE(Java Runtime Environment)

JRE는 Java Runtime Environment의 약자로 자바 런타임 환경을 의미하며 자바 애플리케이션을 실행하기 위한 환경을 제공합니다. JRE에는 JVM, 클래스 라이브러리(자바 기본 클래스 라이브러리), 구성 파일이 포함됩니다.
자바 애플리케이션을 개발하기 위해서는 JDK가 필요하지만, 이미 개발된 자바 애플리케이션을 실행하려면 JRE만으로 충분히 가능합니다. JRE는 Oracle, OpenJDK 등 여러 배포판이 존재합니다.

JVM(Java Virtual Machine)

JVM은 Java virtual Machine의 약자로 자바 가상 머신을 의미합니다. JVM은 자바 프로그램을 실행하기 위한 가상화된 컴퓨터 시스템으로 OS와 프로그램 사이에 하나의 추상 계층이 추가된 것입니다. 기존에는 OS 또는 하드웨어에 종속적으로 프로그래밍해야 했는데, JVM을 통해 한 번의 프로그래밍으로도 여러 환경에 독립적으로 실행할 수 있게 만들었습니다. 즉, 프로그래머가 더 중요한 것에 집중할 수 있는 환경을 제공해 준 가상 머신입니다.
JVM에는 Class Loader(자바 바이트 코드를 Runtime data area에 로딩하는 스레드), 실행 엔진(인터프리터, JIT 컴파일러), GC 등이 포함되어 있습니다.
JVM에 관해서는 다음 글에서 더 자세히 설명하겠습니다.

참고

☕-JDK-JRE-JVM-개념-구성-원리-💯-완벽-총정리

'java|spring' 카테고리의 다른 글

Scanner vs. BufferedReader  (0) 2024.07.12

백준에서 입력 속도 테스트를 했을 때 BufferedReader 로 입력을 받으면 0.6585s가 걸리고, Scanner로 입력을 받으면 4.8448s가 걸립니다. 왜 이렇게 속도 차이가 나는지 알아보았습니다.

Scanner

사용법

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        // System.in을 입력 스트림으로 하는 Scanner 생성
        Scanner scanner = new Scanner(System.in);

        // 사용자로부터 입력받기
        System.out.println("Enter some text:");
        while (scanner.hasNext()) {
            String input = scanner.next();
            System.out.println("You entered: " + input);
        }

        // Scanner 닫기
        scanner.close();
    }
}

내부

...
/**
 * Constructs a new {@code Scanner} that produces values scanned
 * from the specified input stream. Bytes from the stream are converted
 * into characters using the
 * {@linkplain Charset#defaultCharset() default charset}.
 *
 * @param  source An input stream to be scanned
 * @see Charset#defaultCharset()
 */
public Scanner(InputStream source) {
    this(new InputStreamReader(source), WHITESPACE_PATTERN);
}
...

특징

java.util 패키지에 속한 클래스로 입력을 바로 int, short, float, String 등으로 바로 변환하여 읽을 수 있습니다. 또한, 버퍼 크기가 1KB으로 키보드의 입력 작업(I/O)이 발생할 때마다 Scanner로 바로 전달됩니다. Scanner로 전달한 값을 읽는 동안 내부에서 정규 표현식 적용, 입력값 분할, 파싱 과정 등을 거칩니다. 이 특징 때문에 Scanner가 BufferedReader보다 더 느린 성능을 가지게 된 것입니다.

더해서, System.in을 파라미터로 Scanner 생성 시 내부에서 try-catch를 하고 클래스 생성자에서 InputStreamReader를 생성하여 사용하기 때문에 개발자가 직접 해당 작업들을 처리하지 않아도 됩니다.

System.in : 사용자(키보드) 입력 받을 수 있게 하는 정적 변수, 바이트 스트림(InputStream) 형태로 입력값을 읽습니다.

 

BufferedReader

사용법

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

public class SimpleBufferedReaderExample {
    public static void main(String[] args) {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));

        System.out.println("Enter a line of text:");

        try {
            String inputLine = bufferedReader.readLine();

            System.out.println("You entered: " + inputLine);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                // BufferedReader 닫기
                bufferedReader.close();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }
}

내부

...
/**
 * Creates a buffering character-input stream that uses a default-sized
 * input buffer.
 *
 * @param  in   A Reader
 */
public BufferedReader(Reader in) {
    this(in, defaultCharBufferSize);
}
...

특징

java.io 패키지에 속한 클래스로 데이터를 파싱하지 않고 String 으로 값을 받습니다. 또한 버퍼 크기가 8KB이며 키보드의 입력이 있을 때마다 한 문자씩 버퍼로 전송합니다. 버퍼가 가득 차거나 개행 문자가 나타나면 버퍼의 내용을 한 번에 전송하는 방식으로 Scanner보다 I/O 작업이 줄어들기 때문에 성능이 더 좋습니다. 내부적으로 처리된 것이 없기 때문에 개발자가 직접 IOException 처리와 InputStreamReader를 생성하여 매개변수로 전달해줘야 사용 가능합니다.

동기화가 가능하며 Thread Safe하다는 특징을 가집니다.

InputStreamReader: 입력 장치(키보드 등)으로부터 받은 입력 값을 자바 응용 프로그램으로 전달하는 객체, 바이트 스트림(InputStream) 을 문자 스트림(Reader)로 로 변환해주는 브릿지(bridge) 클래스입니다.

 

참고

https://dlee0129.tistory.com/238

'java|spring' 카테고리의 다른 글

JDK, JRE, JVM  (0) 2024.07.16

1. Commit, Branch, PR 등 컨벤션을 정합니다.

양식이 통일되어야 원활하게 확인할 수 있기 때문에 컨벤션을 미리 정하고 협업을 하는 것이 권장됩니다.

1) Commit Message Convention

많이 사용하는 commit convention은 아래와 같습니다.

type: subject

body

footer

필수

  • type : 변경 사항의 유형, 소문자
  • subject : 간결한 변경 사항 설명, 첫 글자는 소문자로 작성

선택

  • body : 변경 사항에 대한 자세한 설명
  • footer : 추가적인 메타데이터, 관련 이슈 참조 등

타입

  • feat : 새로운 기능 추가
  • fix : 버그 수정
  • docs : 문서 수정
  • style : 코드의 의미에 영향을 미치지 않는 변경사항
  • refactor : 코드 리팩토링(기능은 그대로지만 코드 재구성)
  • test : 테스트 추가 및 수정
  • chore : 빌드, 보조 도구, 라이브러리 등의 변경사항

2) Branch Strategy

많이 사용하는 Git 브랜치 전략에는 대표적으로 Git Flow 가 있습니다.

git_flow

  • main : 안정적인 프로덕션 코드 저장
  • develop : 기능을 통합하는 브랜치
  • feature : 새로운 기능 개발, dev 브랜치에서 파생
    • 명명 예 : feature/sign-up
  • release : 배포 준비를 위해 버그 수정 및 최종 조정
    • 명명 예: release/1.0.0
  • hotfix : 프로덕션에서 발생한 긴급 버그 수정, main에서 파생
    • 명명 예: hotfix/login-bug

3. Pull Request(PR) 템플릿

PR을 명확하게 작성하고 팀의 코드 리뷰 프로세스를 체계적으로 유지하기 위해 사용합니다. 팀마다 다르지만 일반적으로 다음과 같은 섹션을 포함합니다.

1. 제목
2. 설명
3. 체크리스트
4. 관련 이슈
5. 스크린 샷(선택)
6. 주석
## 제목
<!-- 
간결하고 명확한 PR 제목을 작성하세요. 
예시: "Add OAuth 2.0 support for user authentication" 
-->

## 설명
<!-- 
변경 사항에 대한 자세한 설명을 작성하세요.
무엇이 변경되었는지, 왜 변경되었는지, 어떻게 변경되었는지를 설명합니다.
-->

## 체크리스트
<!-- 
PR을 제출하기 전에 다음 항목들을 확인하세요.
-->
- [ ] PR 제목은 적절하고 명확합니다.
- [ ] 코드가 의도한 대로 동작합니다.
- [ ] 새로운 기능이나 수정된 기능에 대한 테스트를 추가했습니다.
- [ ] 문서(README 등)를 업데이트했습니다.
- [ ] 로컬 환경에서 모든 테스트를 통과했습니다.

## 관련 이슈
<!-- 
관련된 이슈 번호를 참조합니다.
예시: "Closes #123" 
-->
Closes #

## 스크린샷
<!-- 
UI 변경 사항이 있는 경우 스크린샷을 추가하세요.
-->
![스크린샷 설명](URL)

## 주석
<!-- 
리뷰어에게 도움이 될 추가 정보나 주석을 작성하세요.
-->

PR 템플릿 사용 방법

Github Repository에서 PR 템플릿을 추가하려면 .github 디렉토리에 PULL_REQUEST_TEMPATE.md 파일을 생성하면 됩니다.

새로운 PR을 만들 때 자동으로 템플릿 내용이 표준화되기 때문에 코드 리뷰가 체계적으로 이루어질 수 있습니다.


2. 초기 설정

팀장이 Repository를 만들고(Organization으로 만들면 팀의 관심사만 분리되기 때문에 사용하는 것이 좋습니다) 팀원들을 초대합니다.

Github Repository의 main 브랜치에서 develop 브랜치를 만듭니다.


3. 기능 개발

1) 팀원들은 Github Repository를 로컬로 clone 합니다.

git clone {Github Repository Https 주소}

2) 가져온 저장소에서 develop 브랜치로 이동하고 새로운 기능 브랜치를 생성합니다.

// 현재 브랜치(dev)에서 새로운 기능 브랜치 생성
git switch -c {feature/기능}
  • 이때, 만약 develop 브랜치에 갱신된 코드가 있다면 develop에서 pull을 받습니다.
  • 작업하는 중인 코드가 존재한다면 develop로 브랜치를 이동할 수 없기 때문에 잡시 stash 영역(임시 저장 영)에 넣어둡니다.
  • git stash
  • dev 브랜치에서 pull 받은 코드를 현재 기능 구현하고 있는 브랜치에 병합합니다. 그러기 위해서는 작업하고 있는 브랜치로 이동해야 합니다.
  • // 브랜치 이동 git switch {작업하고 있는 브랜치} // dev 브랜치 -> 작업하고 있는 브랜치 병합 git merge develop // 작업하던 기능 stash 영역에서 가져오기 git stash pop

3) 기능을 구현합니다.

4) 완료한 기능을 저장소에 반영합니다.

  • add

      git add {파일} // 파일명이 아닌 . 을 입력하면 변경된 모든 파일이 add 됩니다.
  • git의 staging 영역에 파일을 추가합니다.

  • commit

      git commit -m {commit message}

    -m : commit message 를 인라인으로 작성하라는 옵션입니다.

  • 스테이징 영역에 추가된 변경 사항을 영구적으로 로컬에 저장하는 명령어입니다.

  • pushremote에 로컬 브랜치와 같은 이름의 브랜치를 만들면서 코드를 push하라는 명령어입니다.

  • git push origin {로컬 브랜치와 같은 브랜치명}

5) Github에 push한 브랜치에서 develop 브랜치로 Pull Request를 보냅니다.

  • 팀원들은 해당 PR에 코멘트를 작성합니다.
    • 만약 출돌이 없으면 merge 합니다.
  • 만약 충돌이 있으면 PR을 close 하고 코드를 수정합니다.
  • 같은 PR에서 reopen 한 후 ‘수정완료’ 와 같은 코멘트를 작성합니다.
  • 팀원들의 확인 후에 아무 문제가 없으면 merge 합니다.

4. 개발 환경에 배포

develop 브랜치에 각 기능 개발이 완료된 코드를 병합 완료하면 release 브랜치에 pr 및 merge를 합니다.

이때 배포 환경에서 문제가 있다면 hotfix 브랜치를 새로 만들어 버그를 고칩니다.


5. 운영 환경에 배포

release 브랜치의 코드가 정상 작동한다면 안정적으로 운영하는 main 브랜치에 release 브랜치의 코드를 머지합니다.

Jazz Meet 프로젝트를 진행하면서 관리자 계정을 어떻게 구현해야 할지 고민한 내용입니다.

⭐ 첫 번째 고민: Cookie & Session vs. JWT

🛠️ Cookie & Session 기반 인증

Cookie와 Session 기반 인증 로직은 다음과 같습니다.

  1. 사용자 로그인 요청
  2. 서버에서 인증 처리
    1) 받은 아이디와 비밀번호 검증
    2) 인증이 성공하면, 서버는 이 사용자에 대한 세션 생성
  3. 서버는 생성된 세션 ID를 사용자의 웹 브라우저에 쿠키 형태로 전송
  4. 브라우저가 서버에 요청을 보낼 때마다 세션 ID를 쿠키로 같이 전송
  5. 사용자가 로그아웃을 요청하면, 서버는 해당 사용자의 세션을 종료(삭제)하고, 사용자의 브라우저에 저장된 쿠키(세션ID)를 무효화
    1) 이후 사용자가 다시 인증이 필요한 페이지에 접근하려고 하면, 서버는 유효한 세션 ID가 쿠키에 없기 때문에 사용자를 비인증 상태로 판단

Sesstion & Cookie 기반 인증의 장단점

장점
서버가 사용자의 로그인 상태를 쉽게 관리할 수 있고, 사용자는 여러 페이지를 이동하면서도 로그인 상태를 유지할 수 있습니다.

단점

  1. 쿠키를 사용하기 때문에 보안상 주의가 필요
  2. 서버에서 세션 저장소를 사용하므로 요청이 많아질 경우 서버에 부하가 심해짐
    세션 데이터를 저장할 때 기본적으로 서버의 메모리에 저장된다. 각 사용자별로 고유한 세션을 생성하고 유지해야 하기 때문에 사용자의 수가 많아질 수록 서버 메모리 사용량도 증가한다.

보완

  1. HTTPS를 통해 암호화되어 전송해야 하며, 쿠키에 저장된 정보는 민감한 정보를 직접 포함하지 않아야 합니다. 세션 하이재킹 공격에 대비하기 위해 쿠키에 플래그를 설정 하여 보호 조취를 취하는 것이 좋습니다.
    • 세션 하이재킹: 공격자가 사용자의 세션 토큰이나 세션 쿠키를 가로채 그 사용자로서 서버에 접근하는 보안 공격
    • 방어 플래그: Secure 플래그, HttpOnly 플래그, SameSite 플래그
  2. 서버 부하 방지
    • 세션 스토리지 외부화
      세션 데이터를 서버 메모리 대신 외부 데이터 스토리지 시스템(Redis 등)에 저장하여, 서버의 메모리 부담을 줄이고, 서버 간에 세션 정보를 공유할 수 있는 기능을 제공
    • 스테이트리스 설계 고려
      JWT와 같은 토큰 기반 인증 방식으로 서버 측에서 사용자 상태를 저장할 필요가 없어 서버 부하를 줄일 수 있음

🛠️ JWT 기반 인증

JSON Web Token, 인증에 필요한 정보들을 암호화 시킨 토큰을 의미합니다. JWT 토큰을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.

JWT의 구조
{Header} . {Payload} . {Signature}

  • Header : 토큰의 유형(typ, 보통 JWT)과 해싱 알고리즘(alg, 예: HMAC SHA256 또는 RSA)이 포함된 JSON 객체, 헤더는 Base64Url 방식으로 인코딩
  • Payload : 토큰에 담을 클레임(claim)이 포함된 JSON 객체, Base64Url 방식으로 인코딩
  • Signature : 헤더의 인코딩된 값, 페이로드의 인코딩된 값, 비밀 키를 합친 후 헤더에 명시된 알고리즘을 사용하여 생성, 메시지가 중간에 변경되지 않았음을 검증하는 데 사용

JWT 기반 인증 로직은 다음과 같습니다.

  1. 클라이언트 로그인 요청이 들어오면, 서버는 검증 후 클라이언트 고유ID 등의 정보를 Payload 에 저장
  2. 암호화할 비밀키를 사용해 토큰 발급
  3. 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청할 때마다 토큰을 요청 헤더 Authorization 에 포함시켜 함께 전달
  4. 서버는 토큰의 Signature을 비밀키로 복호화한 다음, 위변조 여부 및 유효 기간 등을 확인
  5. 유효한 토큰이라면 요청에 응답

JWT 기반 인증의 장단점

장점

  • Header와 Payload를 가지고 Signature를 생성하므로 데이터 위변조를 막을 수 있습니다.
  • 인증 정보에 대한 별도의 저장소가 필요 없습니다.
  • 확장성이 우수합니다.
    상태 비저장
    분산 시스템이나 MSA에서 서비스 간 인증 용이

단점

  • JWT는 토큰의 길이가 길어, 인증 요청이 많아질 수록 네트워크 부하가 심해집니다.
  • Payload 자체는 암호화되지 않기 때문에 유저의 중요한 정보는 담을 수 없습니다.
  • 토큰을 탈취당하면 대처하기 어렵습니다.
    유효기간이 만료될 때까지 계속 사용이 가능하기 때문

보완

  1. 짧은 만료 기한 설정
    토큰이 탈취되더라도 빠르게 만료되기 때문에 피해를 최소화할 수 있습니다.
    → 그러나 사용자가 자주 로그인해야 하는 불편함

  2. Refresh Token
    1) 클라이언트가 로그인 요청을 보내면 서버는 Access Token 및 그보다 긴 만료 시간을 가진 Refresh Token을 발급
    2) 클라이언트는 Access Token이 만료되었을 때 Refresh Token을 사용하여 Access Token의 재발급을 요청
    3) 서버는 DB에 저장된 Refresh Token과 비교하여 유효한 경우 새로운 Access Token을 발급하고, 만료된 경우 사용자에게 로그인을 요구

→ Access Token의 만료 기한을 짧게 설정할 수 있으며, 사용자가 자주 로그인할 필요가 없습니다.
→ 검증을 위해 서버는 Refresh Token을 별도의 storage에 저장해야 합니다. 추가적인 I/O 작업이 일어나기 때문에 JWT의 장점을 완벽하게 누릴 수 없습니다. 클라이언트도 탈취 방지를 통해 Refresh Token을 보안이 유지되는 공간에 저장해야 합니다.


🛠️ 선택

세션보다 확장성이 우수한 JWT 방식을 도입하고, 토큰을 Access Token과 Refresh Token으로 나눠서 보안을 강화하기로 결정했습니다.


⭐ 두 번째 고민: Refresh Token을 서버에서 어떻게 관리해야 하는가?

처음에는 MySQL의 유저 테이블에 그대로 저장했으나, Refresh Token이 만료되었는지 주기적으로 확인해야 하는 로직(ex. 스케줄러 사용)이 별도로 필요했습니다. Access Token의 만료 시간이 짧은 만큼 Refresh Token의 db 접근이 많아지기 때문에 MySQL보다 더 빠르고 값에 만료 시간을 줄 수 있는 Redis를 사용하는 것이 좋을 것이라 판단했습니다.

따라서 Refresh Token의 저장소를 MySQL에서 Redis로 변경했습니다.


⭐ 세 번째 고민: Refresh Token 은 브라우저에서 어떻게 관리해야 하는가?

Refresh Token을 탈취 당할 경우 Access Token을 발급하여 사용자인 척 서비스에 접근을 할 수 있기 때문에 접근하지 못하게 쿠키에 다음 플래그를 적용하였습니다.

  • secure(true) : HTTPS 환경에서만 사용
  • httpOnly(true) : 자바스크립트로 접근 불가능
  • sameSite(lax) : 쿠키가 동일한 사이트의 요청 또는 일부 안전한 크로스 사이트에서 쿠키를 받을 수 있음
    특히 완전히 다른 도메인으로 등록해두었던 프론트 어드민을 백엔드 어드민의 하위로 변경하여 same site 정책에서 동일한 도메인으로 인식되도록 만들었습니다.

⭐ 네번째 고민: Redis를 사용하여 로그아웃한 유저를 매번 식별한다면 세션 저장소로 Redis를 사용하는 것과 동일하지 않나?

기존 로직은 다음과 같습니다.

  1. 로그아웃 시 redis 에 access token을 저장(블랙리스트)하고 다른 요청이 들어올 경우 이 redis에 저장된 토큰인지 확인
    만약 redis에 저장된 토큰이라면 예외처리

그런데 서버 부하를 줄이기 위해 JWT를 사용했는데, JWT의 단점인 탈취 시의 취약점을 상쇄하고자 Redis를 사용한다면, 기존 세션과 다를 바가 있는가? 라는 의문이 들었습니다.

이렇게 블랙리스트를 도입한다면 가볍다는 JWT의 장점도 제대로 활용 못하고 세션을 사용하는 것보다 보안이 떨어지는 것 같다고 생각했습니다.

따라서 블랙리스트를 삭제하기로 결정했습니다. Access Token은 1시간의 짧은 유효기간을 가지고 있고 이를 재발급하기 위한 Refresh Token은 httpOnly 쿠키로 보호되어 있기 때문에, Access Token을 자주 재발급해도 Refresh Token을 탈취당할 위험도 적으면서 Access Token을 탈취당해도 시간상 한계가 있어 공격 당할 위험이 적다고 판단했기 때문입니다.


⭐ 느낀 점

서버 안전성과 사용자 경험은 Trade-Off 관계를 가집니다. 개발/운영 중인 서비스의 주어진 환경에 맞춰 개발자의 적절한 선택이 필요함을 깨달았습니다. 최근 많은 기업이 MSA 방식으로 서비스를 만들기 때문에 확장성이 좋은 JWT 방식을 도입했지만 정작 저희 서비스는 단일 서버를 사용하기 때문에 JWT의 장점을 온전히 느끼지 못한 것 같습니다. 이후 프로젝트에서는 두 개 이상의 서버를 사용해봐야 겠다는 생각이 들었습니다.


참고

인증 방식 : Cookie & Session vs JWT
Redis를 이용한 토큰 탈취 대응 시나리오(feat. Refresh Token Rotation)
Access Token의 문제점과 Refresh Token
Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

'project' 카테고리의 다른 글

[JazzMeet] 도메인 간 쿠키 공유 되지 않는 문제 해결  (0) 2024.05.12

❓문제

Refresh Token을 http only 쿠키로 사용하려고 다음과 같이 서버 코드를 작성해서 배포했습니다.

/**
 * 관리자 로그인 API
 */
@PostMapping("/api/admins/login")
public ResponseEntity<LoginAdminResponse> login(@RequestBody @Valid LoginAdminRequest loginAdminRequest) {
    Jwt jwt = adminService.login(loginAdminRequest);

    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE, getRefreshToken(jwt).toString())
        .body(AdminMapper.INSTANCE.toLoginAdminResponse(jwt));
}

private ResponseCookie getRefreshToken(Jwt jwt) {
    return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
        .maxAge(jwtProperties.getRefreshTokenExpiration())
        .path("/")
        .secure(true)
        .httpOnly(true)
        .build();
}

프론트 어드민 'jazzmeet-admin.site'에서 서버 'jazzmeet.site'로 로그인 API 요청을 보낼 때 HTTP Response 에는 쿠키가 포함돼서 나가지만 브라우저의 쿠키에는 저장되지 않는 문제가 발생했습니다.

 

⁉️ 해결 과정

1. 프론트의 요청 코드에 설정 추가

프론트의 로그인 요청 코드에 credentials:include 옵션을 추가했습니다. 이때 CORS 문제가 발생해서 백엔드에서도 cors 설정에 프론트 어드민을 allowedOrigins로 설정하고 allowCredentials(true) 옵션을 추가했습니다.

@Override
public void addCorsMappings(CorsRegistry registry) {
  registry.addMapping("/api/**")
      .allowedOrigins("https://www.jazzmeet-admin.site", "https://jazzmeet.site")
      .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
      .allowCredentials(true)
      .allowedHeaders("*");
}
  • addMapping : /api로 시작하는 모든 URL에 대해 CORS 정책이 적용됩니다.
  • allowedOrigins : 지정된 출처(origin)의 요청만 서버가 수락하도록 허용합니다.
    • 여기에 구체적인 출처를 명시하면 스프링은 Access-Control-Allow-Origin 헤더를 자동으로 만들어줍니다. 브라우저의 보안 정책에 따라, allowCredentials(true) 설정을 사용하면 Access-Control-Allow-Origin 헤더에 구체적인 출처(와일드카드 * X)를 요구합니다.
  • allowedMethods : 허용할 HTTP 메서드를 지정합니다.
    • OPTIONS: 서버가 지원하는 메서드를 조회할 때 사용되며, CORS preflight 요청에 대응하기 위해 필요합니다.
  • allowCredentials : 크로스 오리진 요청 시 쿠키와 같은 인증 정보를 포함할 수 있도록 허용합니다.
  • allowedHeaders : 설정한 HTTP 헤더를 요청에서 허용하겠다는 것을 의미합니다.

CORS 문제는 해결됐지만 여전히 쿠키가 저장되지 않았습니다.

 

2. 쿠키 생성 시 옵션 추가

쿠키 생성 시 sameSite("None"), secure(true) 옵션을 추가해주었습니다.
크롬의 20년도 업데이트에서 same site 속성의 기본 값이 None에서 Lax로 변경되었을 알 수 있었습니다. 이 속성은 웹 사이트 간 요청 위조(CSRF, Cross-Site Request Forgery) 공격을 방지하는 데 도움을 줍니다. 이는 다른 도메인 간 요청에서 사용자의 데이터 보호를 강화합니다.

same site 속성에는 세 가지 설정이 있습니다.

  • Strict: 쿠키는 오직 같은 사이트에서 발생한 요청에만 전송됩니다. 이는 가장 제한적인 옵션입니다.
  • Lax: 더 유연하며, 사용자가 다른 사이트에서 링크를 클릭해 사이트에 접근했을 때 쿠키를 전송할 수 있습니다.
  • None: 모든 크로스 사이트 요청에 쿠키를 전송하도록 합니다. 이 옵션을 사용하려면 반드시 쿠키를 Secure로도 설정해야 합니다, 즉 HTTPS를 통해서만 쿠키가 전송됩니다.

프론트 어드민 jazzmeet-admin.site 와 서버 어드민 jazzmeet.site는 아무런 연관이 없는 cross domain 이기 때문에 크로스 사이트 요청에도 쿠키를 요청할 수 있는 sameSite("None") 과 secure(true) 옵션을 추가해주었습니다.

그럼에도 여전히 브라우저의 쿠키에 저장이 되지 않고 있었습니다.

사실 여기서부터 조금 멘탈이 흔들리기 시작했습니다. 무슨 문제인지 알 수가 없어 몇 번 시도를 했지만 결국 문제는 2번과 5번의 시도로 해결할 수 있었습니다. 3, 4번은 이 문제 해결에 직접적인 영향을 주지 않았지만 일단 기록으로 남겼습니다.

 

3. 쿠키 생성 시 프론트 도메인을 명시

기본으로 옵션이 들어가는 도메인이 프론트 도메인과 달라 쿠키가 저장이 안 되는가 싶어서 쿠키 생성 시 domain("jazzmeet-admin.site") 옵션을 추가해주었습니다.

private ResponseCookie getRefreshToken(Jwt jwt) {
    return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
        .maxAge(jwtProperties.getRefreshTokenExpiration())
        .path("/")
        .secure(true)
        .httpOnly(true)
        .domain("jazzmeet-admin.site")
        .build();
}

그러나 여전히 프론트 어드민 브라우저에는 쿠키가 저장되지 않았습니다.

 

4. 프론트 어드민을 서버의 서브 도메인으로 변경

서버 도메인과 프론트 어드민 도메인이 완전히 다른 cross domain이라 쿠키가 저장되는 않는 것일 수도 있겠다고 생각하여 프론트 어드민 도메인을 서버의 서브 도메인 admin.jazzmeet.site로 변경했습니다.

이때 우리의 프론트 어드민은 Amplify에 배포되어 있었기 때문에 Amplify의 도메인 관리에서 jazzmeet.site를 루트 도메인으로 설정하고, 서브 도메인으로 admin.jazzmeet.site를 추가한 후 www로 redirect 되는 옵션을 껐습니다. Amlify에서 발급된 키를 가비아의 jazzmeet.site DNS 관리에서 추가해주었고, admin.jazzmeet.site에 배포된 프론트 어드민 주소를 저장해주었습니다.

더해서 jazzmeet.site를 포함한 서브 도메인들에서도 쿠키를 공유할 수 있도록 하기 위해 domain(".jazzmeet.site") 으로 옵션을 수정해주었습니다.

private ResponseCookie getRefreshToken(Jwt jwt) {
  return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
    .maxAge(jwtProperties.getRefreshTokenExpiration())
    .path("/")
    .secure(true)
    .httpOnly(true)
    .domain(".jazzmeet.site")
    .build();
}

여전히 쿠키는 브라우저에 저장되지 않았습니다.

 

5. 프론트의 로그인 요청에 옵션 추가

cross domain 상태라도 sameSite("None")과 secure(true) 옵션을 설정해주었다면 프론트 어드민을 서버의 서브 도메인으로 변경하지 않아도 정상적으로 쿠키가 공유되어야 했습니다. 그럼에도 계속해서 쿠키 저장이 되지 않아 프론트 코드를 살펴보았습니다.

여기서, 토큰 재발급 로직에만 credentials:'include' 옵션이 설정되어 있었고 로그인 요청 로직에는 설정되지 않았습니다. 이 옵션이 프론트에서 서버로 쿠키를 보낼 때에만 사용되는 줄 알았는데 서버에서 보낸 쿠키를 브라우저에 저장하기 위해서도 필요한 옵션이었습니다.

따라서 프론트 로그인 요청에 credentials:'include' 옵션을 추가해주어 성공적으로 쿠키를 공유할 수 있게 되었습니다.

 

❗정리

  1. cross domain 이라면 sameSite("None")secure(true) 옵션을 주고 프론트에서 쿠키를 보내거나 받는 요청에 'credentials:'include'`옵션을 설정하면 쿠키 공유가 가능합니다.
  2. 같은 도메인이라면 same site는 기본 설정(Lax)으로 두고 프론트 코드에 'credentials:'indluce'` 옵션을 추가하면 쿠키 공유가 가능합니다.

 

⭐ 배운 점

브라우저의 업데이트는 관심을 가지고 꾸준히 살펴봐야 제때 대응할 수 있다는 점을 깨달았습니다. 또한 문제를 마주했을 때 담당한 분야 뿐만 아니라 다른 분야도 살펴봐야 문제를 빨리 해결할 수 있다는 것을 배웠습니다.

 

📜 참고

새로운 SameSite=None; Secure 쿠키 설정에 대비

'project' 카테고리의 다른 글

[JazzMeet]Cookie & Session vs. JWT  (0) 2024.05.16

+ Recent posts