https://school.programmers.co.kr/learn/courses/30/lessons/181886

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

프로그래머스 기초레벨의 5명씩 문제를 한번 풀어봤다.

 

class Solution {
    fun solution(names: Array<String>): Array<String> {
        val result = mutableListOf<String>()
        for (i in names.indices step 5) {
            result.add(names[i])
        }
        return result.toTypedArray()
    }
}

 

코드 설명

  • names.indices step 5:  배열의 인덱스 범위를 5씩 건너뛰면서 반복합니다.
  • result.add(names[i]):   각 그룹의 첫 번째 이름(인덱스 i의 값)을 결과 리스트에 추가합니다.
  • result.toTypedArray(): 결과 리스트를 배열로 변환하여 반환합니다.
더보기

toTypedArray() 함수는 Kotlin의 컬렉션(예: List)을 배열(Array)로 변환해 주는 함수이다. 이 함수는 컬렉션에 있는 요소들을 기반으로 동일한 타입의 배열을 만들어 반환한다.

자세한 설명

  • 컬렉션과 배열의 차이:
    Kotlin에서는 List, Set, Map과 같은 컬렉션과, 고정 크기를 가지는 배열이 있다.
    컬렉션은 요소 추가나 삭제가 가능한 반면, 배열은 생성 후 크기가 고정된다
  • toTypedArray() 사용 이유:
    때때로 함수나 API가 배열 타입을 요구할 때, 컬렉션(List 등)으로 작업한 후 최종적으로 배열로 변환해야 할 필요가 있다. 이때 toTypedArray()를 사용하면 쉽게 변환할 수 있다.
  • 동작 방식:
    내부적으로 toTypedArray()는 컬렉션에 포함된 각 요소를 순회하며 동일한 타입의 배열을 만들어, 그 배열에 요소들을 채워 반환한다.

Q1: Spring Boot에서 의존성 주입(Dependency Injection)이란 무엇이며, 왜 중요한가요?

A1:
"의존성 주입은 객체 간의 의존 관계를 애플리케이션 외부에서 주입하여 관리하는 디자인 패턴입니다. Spring Boot에서는 IoC(Inversion of Control) 컨테이너가 이를 담당하며, @Autowired, @Component, @Service 등 애노테이션을 통해 Bean을 자동 등록 및 관리합니다. 이를 통해 결합도를 낮추고, 테스트 및 유지보수를 용이하게 할 수 있습니다."

 

 

Q2: Spring Boot의 Auto Configuration 기능에 대해 설명해 주세요.

A2:
"Spring Boot의 Auto Configuration은 클래스패스에 포함된 라이브러리와 설정 파일을 기반으로 적절한 Bean들을 자동으로 등록하여 개발자가 최소한의 설정만으로 애플리케이션을 구축할 수 있도록 도와줍니다. 예를 들어, 데이터베이스 관련 라이브러리가 포함되면 기본 DataSource를 자동으로 설정합니다. 또한, 필요에 따라 개발자가 설정을 오버라이드하여 커스터마이징할 수 있습니다."

 

 

Q3: application.properties와 application.yml 파일의 차이와 활용 방법을 설명해 주세요.

A3:
"application.properties는 key-value 쌍으로 단순하게 설정을 작성할 수 있는 반면, application.yml은 YAML 형식을 사용하여 계층적이고 구조적인 설정이 가능합니다. 복잡한 설정이나 여러 환경(개발, 테스트, 운영 등)에 따라 다른 설정을 적용할 때 yml 파일이 더 가독성이 좋습니다. 두 파일 모두 스프링 프로파일을 활용해 환경별로 다른 설정을 쉽게 관리할 수 있습니다."

 

 

Q4: Spring MVC 아키텍처의 주요 구성 요소와 역할에 대해 설명해 주세요.

A4:
"Spring MVC는 웹 요청을 처리하기 위한 아키텍처로, 주요 구성 요소로는 DispatcherServlet, Controller, Service, Repository 등이 있습니다. DispatcherServlet은 모든 요청을 중앙에서 받아 적절한 컨트롤러로 분배하며, 컨트롤러는 요청을 처리하고 결과를 반환합니다. Service는 비즈니스 로직을 수행하고, Repository는 데이터베이스와의 상호작용을 담당합니다. 이러한 계층적 구조는 코드의 재사용성과 유지보수를 용이하게 만듭니다."

 

 

Q5: RESTful API 설계의 기본 원칙과 고려해야 할 요소에 대해 설명해 주세요.

A5:
"RESTful API 설계에서는 리소스 중심의 URL 설계, HTTP 메서드(GET, POST, PUT, DELETE 등)의 올바른 사용, 그리고 HTTP 상태 코드의 적절한 사용이 중요합니다. 또한, 클라이언트와 서버 간의 상태 비저장을 유지하고, 버전 관리 및 API 문서화(Swagger 등)를 통해 API의 변경 사항을 명확하게 관리하는 것이 필요합니다."

'기술면접준비' 카테고리의 다른 글

면접 예상 질문과 답변  (0) 2025.04.28
WAS와 WS  (1) 2023.12.20
기술면접준비(1)  (0) 2023.12.19

의존성? 의존성이 뭘까?

어떤 물건을 만들거나 사용할 때 다른 것이 꼭 필요할때 우리는 그것을 의존한다고 한다.

예를 들어 자동차는 엔진이 꼭 필요하다.

  • 자동차는 혼자서는 움직일 수 없다.
  • 반드시 엔진이 있어야 움직일 수 있다.
  • 자동차는 엔진에 의존하고 있다고 말할 수 있다.

이처럼 어떤 객체가 다른 객체를 필요로 할 때, 의존성이 있다고 말한다.

 

그렇다면 의존성 주입(Dependency Injection) 이란 무엇일까?

 

만약 자동차가 엔진을 직접 만들 필요 없이, 공장에서 엔진을 가져와 조립할 수 있다면 더 편리하지 않을까?? 

>>> 이것이 의존성 주입니다.

 

쉽게 말해

  • 자동차(클래스)는 엔진을 직접 만들지 않음
  • 필요한 엔진을 공장에서(스프링)  가져옴
  • 스프링이 자동차에 엔진을 자동으로 넣어줌(의존성 주입)!

 

오호라..스프링이 있으면 편리해 보인다. 그럼 만약에 의존성 주입이 없다면 어떤 문제가 생길까???

class Car {
    private Engine engine = new Engine();  // 자동차가 직접 엔진을 생성
    
    public void start() {
        engine.run();
    }
}

 

이런 식으로 코드를 작성할 경우

  1. 자동차(Car)가 직접 엔진을 생성하고 있다.
  2. 나중에 엔진을 바꾸고 싶다면 자동차 코드를 직접 수정해야 한다.
  3. 코드가 유연하지 않고, 새로운 기능 추가도 어려워진다.

즉, 자동차가 직접 엔진을 만들게되면, 나중에 다른 엔진(전기 엔진, 하이브리드 엔진)으로 바꾸기 어려워진다.

 

 

그렇다면 의존성 주입을 사용하려면 어떻게 해야할까???

 

Spring Boot에서는 자동으로 필요한 객체(엔진)를 주입해줄 수 있다.

class Car {
    private Engine engine;

    public Car(Engine engine) {  // 외부에서 엔진을 넣어줌
        this.engine = engine;
    }

    public void start() {
        engine.run();
    }
}

 

이렇게 되면 자동차는 아주 쉽게 엔진을 교체할 수  있다.

  • Engine engine = new GasEngine();  (가솔린 엔진)
  • Engine engine = new ElectricEngine(); (전기 엔진)
  • Engine engine = new HybridEngine(); (하이브리드 엔진)

즉, 자동차 스스로 엔진을 직접 만들 필요 없이, 외부에서 받아서 사용할 수 있다.

 

 

너무 신기하다 근데 스프링은 어떻게 의존성을 주입하는 걸까?

 

스프링은 자동으로 필요한 객체를 찾아 주입(Injection) 해준다.

@Component
class Engine {
    public void run() {
        System.out.println("엔진이 가동됩니다.");
    }
}

@Component
class Car {
    private final Engine engine;

    @Autowired  // 스프링이 자동으로 Engine을 넣어줌!
    public Car(Engine engine) {
        this.engine = engine;
    }

    public void start() {
        engine.run();
    }
}

 

@Autowired를 사용하면 스프링이 알아서 엔진을 찾아서(Car에) 넣어준다.

이제 Car 객체를 만들 때 자동으로 Engine이 들어간다.

 

 

의존성 주입 3가지 방법

 

1. 생성자 주입(추천)

class Car {
    private final Engine engine;

    @Autowired
    public Car(Engine engine) {  // 생성자를 통해 의존성 주입
        this.engine = engine;
    }
}

 

장점 : 

  • 반드시 필요한 값이 주입된다 (final 사용 가능)
  • 테스트하기 쉽고 유지보수도 편리하다

 

2. 필드 주입(사용 지양)

class Car {
    @Autowired
    private Engine engine;  // 필드에 직접 주입
}

 

단점 : 

  • 필수값이 빠질 수 있다.
  • 테스트하기 어렵다
  • Spring Context 없이 사용할 수 없다.

 

3. Setter 주입

class Car {
    private Engine engine;

    @Autowired
    public void setEngine(Engine engine) {  // Setter를 통해 주입
        this.engine = engine;
    }
}

 

장점 : 필요할 때 객체를 바꿀 수 있다.

단점 : 의존성이 필수가 아닐 수도 있음(Setter를 호출하지 않으면 값이 없다)

 

 

 

의존성에 대해 알아보았는데 의존성 주입에 대해 요약하자면

 

중요한 이유 !

  1. 코드의 재사용성이 높아짐 -> Car 클래스를 수정하지 않고 다양한 Engine을 사용가능
  2. 유지보수가 쉬워짐 -> Engine을 변경할 때 Car 클래스를 수정할 필요가 없다.
  3. 테스트하기 쉬움 -> 테스트할 때 가자(Mock) 객체를 쉽게 주입할 수 있다.
  4. 스프링이 자동으로 객체를 관리 ->  개발자가 직접 객체를 만들 필요가 없다.

 

1. 그리디 알고리즘의 정의

그리디 알고리즘은 문제를 해결할 때 현재 상황에서 가장 최선의 선택을 하여 최종 해답을 도출하는 방법이다. 이 때, 각 단계마다 선택하는 것이 최적해를 보장하는 경우에만 효과적이다.

 

 

2. 국부 최적 선택(Local Optimal Choice) 이란??

국부 최적선택이란 각 단계에서 당장 가장 이득이 큰 선택을 하는 것을 의미한다. 예를 들어, 쇼핑할 때 할인율이 가장 높은 상품을 먼저 고르는 것과 비슷하다.

 

 

3. 그리디 알고리즘의 장단점

장점 : 

  • 구현이 단순하고 빠르며, 계산 비용이 적다.
  • 문제의 크기가 커져도 각 단계의 선택만 고려하면 되므로 효율적이다.

단점 : 

  • 매 순간 최선의 선택이 전체 최적해를 보장하지 않을 수 있다.
  • 문제에 따라 그리디 알고리즘이 최적해를 찾지 못하는 경우가 있다.

 

4. 대표 문제 : 동전 거스름돈 문제

 - 문제 설명 : 정당한 화폐 단위 (예: 500원, 100원, 50원, 10원)가 주어지고, 거슬러 줘야 하는 금액이 주어졌을 때, 가장 적은 수의 동전으로 거스름돈을 지급하는 방법을 구하는 문제다.

 - 힌트 : 그리디로 접근하면 거슬러 줄 금액이 있을 때, 가장 큰 단위의 동전을 최대한 많이 사용한다. 그 후, 남은 금액에 대해 같은 방식으로 반복한다.

 - 조건 : 이 접근법은 화폐 단위가 "정당한 화폐 단위"일 때 최적해를 보장한다.

 

 

Kotlin을 이용한 동전 거스름돈 문제 구현

fun coinChange(amount: Int, coins: Array<Int>): Int {
    var remaining = amount
    var count = 0

    // 동전 배열은 큰 단위부터 정렬되어 있다고 가정합니다.
    for (coin in coins) {
        if (remaining >= coin) {
            val numCoins = remaining / coin  // 해당 동전으로 몇 개를 사용할 수 있는지 계산
            count += numCoins                 // 총 동전 개수에 더합니다.
            remaining %= coin                 // 남은 금액을 계산합니다.
            println("동전 $coin원: 사용 개수 = $numCoins, 남은 금액 = $remaining")
        }
    }
    return count
}

fun main() {
    // 예제: 거스름돈 1260원, 화폐 단위는 500, 100, 50, 10원
    val amount = 1260
    val coins = arrayOf(500, 100, 50, 10)
    println("총 동전 개수: ${coinChange(amount, coins)}")
}

 

  • 초기 변수 설정:
    • remaining 변수는 아직 거슬러 줘야 하는 금액을 저장한다.
    • count 변수는 사용한 동전의 총 개수를 저장한다.
  • 동전 단위 순회:
    • 배열 coins는 큰 단위부터 정렬되어 있으므로, 500원부터 차례로 처리한다.
    • 만약 remaining 금액이 현재 동전보다 크거나 같다면,
      remaining / coin으로 해당 동전이 몇 개 필요한지 계산한다.
  • 동전 개수 갱신 및 남은 금액 계산:
    • 계산한 동전 개수를 count에 더하고,
    • remaining %= coin으로 남은 금액을 갱신한다.
    • 각 단계에서 현재 동전 단위, 사용 개수, 남은 금액을 출력하여 진행 과정을 확인할 수 있다.
  • 최종 결과 반환:
    • 모든 동전 단위를 처리한 후, count를 반환한다.

 

실행결과

동전 500원: 사용 개수 = 2, 남은 금액 = 260
동전 100원: 사용 개수 = 2, 남은 금액 = 60
동전 50원: 사용 개수 = 1, 남은 금액 = 10
동전 10원: 사용 개수 = 1, 남은 금액 = 0
총 동전 개수: 6

 

그래프(Graph)란 무엇일까?

그래프는 정점(Vertex)와 간선(Edge)으로 구성된 자료구조이다.

 

  • 정점(Vertex) : 그래프의 구성요소로, 사람이나 도시처럼 개별 요소를 나타낸다.
  • 간선(Edge) : 정점들을 연결하는 선이다. 두 정점 사이의 관계(예:도로, 친구 관계)를 나타낸다.
  • 예시 : 학교에서 여러 학생(정점)들이 친구 관계(관선)를 맺고 있는 것을 그래프로 생각할 수 있다.

 

그래프의 표현 방법

그래프를 컴퓨터에서 표현하는 방법에는 여러 가지가 있다. 가장 일반적인 두 가지 방법은 인접 리스트인접 행렬이다.

  • 인접 리스트(Adjacency List) : 각 정점에 연결된 정접들의 리스트를 저장한다.

       예시

0: [1, 2]
1: [0, 3]
2: [0, 3]
3: [1, 2]

 

  • 인접 행렬(Adjacency Matrix) : 정점 간의 연결 여부를 2차원 배열 형태로 저장한다.

        예시

matrix[0][1] = 1

 

 

현실 세계에서의 그래프 예시로는 

  • 학교 네트워크 : 학생들을 정점으로 하고, 친구 관계를 간선으로 표현
  • 지도와 도로 : 도시를 정점, 도로를 간선으로 표현하여 경로 탐색 문제 해결
  • 인터넷 : 웹 페이지를 정점으로 하고, 하이퍼링크를 간선으로 표현하여 검색 알고리즘 등에 응용

등이 있다.

 

 

 

깊이 우선 탐색(DFS, Depth First Search)

1. DFS 의 기본 원리

DFS는 시작 정점에서 출발하여 한 방향으로 끝까지 탐색한 후, 더 이상 진행할 수 없으면 마지막 분기점으로 돌아가 다른 경로를 탐색하는 방식이다. 이러한 과정은 재귀 호출을 통해 자연스럽게 구현된다.

 

2. DFS의 동작 방식과 재귀 호출

동작 방식 :

  1. 시작 정점을 방문하고, 방문 목록에 추가한다.
  2. 현재 정점에서 인접한 정점 중 아직 방문하지 않은 정점을 선택한다.
  3. 선택한 정점으로 이동하여 같은 과정을 반복한다.
  4. 더 이상 방문할 정점이 없으면, 이전 정점으로 되돌아가 (Backtracking) 다른 경로를 탐색한다.

재귀 호출 : DFS는 재귀적으로 자신을 호출하면서, 깊은 경로를 먼저 탐색한다.

 

 

3. DFS의 장단점

 

장점 :

  • 구현이 간단하고 직관적이다.
  • 경로 탐색, 사이클 검출 등 다양한 문제에 응용할 수 있다.

단점 : 

  • 재귀 호출로 인해 스택 오버플로우가 발생할 수 있다.
  • 최악의 경우 모든 정점을 방문하므로, 시간 복잡도가 높을 수 있다.

 

4. DFS 구현 예제(Kotlin)

// DFS를 구현하기 위해, 방문한 정점을 기록할 집합과 재귀 함수를 사용합니다.
fun dfs(node: Int, visited: MutableSet<Int>, graph: Map<Int, List<Int>>) {
    // 현재 정점을 방문 목록에 추가하고, 출력합니다.
    visited.add(node)
    println("DFS 방문: $node")

    // 현재 정점과 연결된 모든 정점을 순회합니다.
    for (neighbor in graph[node] ?: emptyList()) {
        // 아직 방문하지 않은 정점이면 재귀 호출합니다.
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited, graph)
        }
    }
}

fun mainDFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2), // 0번 정점은 1번,2번 정점에 간선을 갖는다.
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )
    
    val visited = mutableSetOf<Int>()
    println("=== DFS 탐색 결과 ===")
    dfs(0, visited, graph)
}

 

위에 코드에서 인접 리스트로 표현된 그래프를 실제로 살펴보면

인접리스트 예시

위 그림에서 방향성을 나타내는 화살표를 빼고 안쪽에 있는 간선 3개를 빼면 위의 코드의 그래프이다.

 

해당 코드를 실행해보면 시작 정점 0에서 시작하여 0 > 1 > 3> 2 순으로 방문을 진행하고, 각 단계에서 재귀 호출이 이루어지고, 되돌아가며 다른 경로를 탐색하는 모습은 미로에서 한 길로 쭉 들어갔따가 다시 돌아가는 것과 비슷하다.

 

 

 

 

너비 우선 탐색(BFS, Breadth First Search)

1. BFS의 기본 원리

BFS는 시작 정점에서부터 인접한 모든 정점을 먼저 방문하고, 그 다음 단계의 정점을 방문하는 방식이다. 즉, "가까운 것부터 넓게" 탐색하는 방법이다.

 

 

2. BFS의 동작 방식과 큐의 역할

동작 방식 :

  1. 시작 정점을 방문하고, 큐에 추가한다.
  2. 큐에서 정점을 꺼내 그 정점과 인접한 모든 정점을 방문한다.
  3. 방문한 정점을 큐에 추가하면서, 순차적으로 탐색한다.

큐의 역할 :  BFS에서는 먼저 들어온 정점을 먼저 처리하는 FIFO(선입선출) 자료구조인 큐를 사용한다.

 

 

3. BFS의 장단점

장점 : 

  • 최단 경로를 찾는 데 효과적이다.
  • 큐를 사용하므로 DFS보다 스택 오버플로우 위험이 적다.

단점 : 

  • 모든 인접 정점을 저장해야 하므로 메모리 사용량이 많아질 수 있다.
  • 그래프의 모든 정점을 한 번씩 방문하므로, 큰 그래프에서는 시간이 오래 걸릴 수 있다.

 

4. BFS 구현 예제(Kotlin)

import java.util.LinkedList
import java.util.Queue

fun bfs(start: Int, graph: Map<Int, List<Int>>) {
    val visited = mutableSetOf<Int>()
    val queue: Queue<Int> = LinkedList()

    // 시작 정점을 방문 처리하고 큐에 추가합니다.
    visited.add(start)
    queue.offer(start)

    while (queue.isNotEmpty()) {
        val node = queue.poll()
        println("BFS 방문: $node")
        
        // 현재 정점과 연결된 모든 정점을 순회하며 방문하지 않은 정점을 큐에 추가합니다.
        for (neighbor in graph[node] ?: emptyList()) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor)
                queue.offer(neighbor)
            }
        }
    }
}

fun mainBFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2),
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )

    println("=== BFS 탐색 결과 ===")
    bfs(0, graph)
}

 

코드의 실행에 대해서 생각해보면 시작 정점 0에서 시작하여, 먼저 0에 인접한 1과 2를 방문하고, 그 후 1과 2의 인접 정점(3 등)을 방문하는 순서로 진행된다.

 

 

 

DFS와 BFS의 비교

1. 탐색 방식의 차이

  • DFS : 한 방향으로 깊게 들어간 후, 더 이상 진행할 수 없으면 되돌아가는 방식 -> "깊이 우선"으로 탐색한다.
  • BFS : 시작 정점에서 가까운 정점을 모두 방문한 후, 그 다음 레벨로 넘어가는 방식 -> "너비 우선"으로 탐색한다.

 

2. 시간 복잡도와 메모리 사용 비교

  • 시간 복잡도 : 두 알고리즘 모두 그래프의 정점 수(V)와 간선 수 (E)에 따라 O(V+E)의 시간 복잡도를 가진다.
  • 메모리 사용 : DFS는 재귀 호출(또는 스택)을 사용하므로 깊이에 따라 메모리 사용량이 결정된다.                                                                         / BFS는 큐에 모든 인접 정점을 저장하므로, 그래프가 넓을 경우 메모리 사용량이 증가할 수 있다.

 

언제 각각 사용해야 할까?

  • DFS 사용 경우 : 경로 깊이가 중요한 경우 / 모든 경로를 탐색해야 하는 문제(예: 순열, 조합, 백트래킹 문제)
  • BFS 사용 경우 : 최단 경로 문제 / 너비 우선 탐색을 통해 빠르게 탐색할 수 있는 경우

 

위의 DFS와 BFS는 문제를 더 풀어보면서 Kotlin 을 연습해봐야겠다.

 

브라우저 캐시는 단순히 프론트엔드의 영역이 아니라, 실제 서비스의 성능과 트래픽 비용, 심지어 버그 발생 가능성까지 영향을 미치는 아주 중요한 요소이다.

 

브라우저 캐시란?

브라우저 캐시는 클라이언트(사용자의 웹 브라우저)가 자주 사용하는 데이터를 저장해두고, 다음에 다시 요청할 때 빠르게 보여주기 위한 기술이다. 

 

예 : 어떤 쇼핑몰 사이트에 방문시, 그 사이트의 로고 이미지가 매번 서버에서 오면 느릴것이다. 그래서 브라우저는 이걸 한 번 다운로드한 뒤, 다음부터는 자기 컴퓨터에 저장된걸 보여준다.

 

 

브라우저 캐시의 핵심 메커니즘 

1. Cache-Control

서버가 클라이언트에게 이 리소스를 어떻게 캐시해야 할지 알려주는 HTTP 헤더이다.

  • max-age=3600 -> 1시간 동안 캐시해라
  • public -> 누구나 캐시해도 된다

2. ETag / Last-Modified

  • 캐시된 파일이 변경됐는지 확인하는 데 사용한다.
  • 변경이 없으면 304 Not Modified로 응답해서 데이터를 아예 보내지 않는다.

 

 

3. 실무에서 사용 가능한 전략

리소스 타입 캐시 전략
이미지, JS, CSS long-term 캐시 + 파일명에 해시
HTML 페이지 no-cache 또는 must-revalidate
API 응답(GET) short max-age 또는 no-store

 

 

4. CDN과의 캐시 연계

클라우드플레어, AWS CloudFront 같은 CDN은 브라우저 뿐 아니라 서버 앞단에서 전 세계에 캐시를 제공한다. 이를 통해 TTFB(Time to First Byte)를 극적으로 낮출 수 있다. 

 

서버 -> CDN -> 브라우저 순으로 캐시 계층을 구성하면 성능 최적화에 큰 효과가 있다.

 

 

5. 디버깅 방법

브라우저 개발 도구에서 Network 탭을 열고, 리소스를 확인하면 아래 정보들을 볼 수 있다.

  • Status : 200 OK vs 304 Not Modified
  • Cache-Control 헤더
  • ETag / Last-Modified 여부

실제로 문제를 겪을 때 이 도구를 통해 빠르게 원인을 추적할 수 있다.

요즘 인스타 광고글이나 여러 블로그 포스팅등등 여러 곳에서 쿠버네티스라는 용어를 자주 접했다. 저게 뭘까...? 처음엔 네카라쿠배인줄.. 

 

쿠버네티스 그게 도대체 뭐야?

 

쿠버네티스란?

쿠버네티스는 컨테이너화된 애플리케이션을 여러 대의 컴퓨터(서버)에 효율적으로 배포하고 관리할 수 있도록 돕는 도구이다.

 

컨테이너?는 뭘까?

컨테이너는 애플리케이션과 그에 필요한 라이브러리, 설정 파일 등을 한데 묶어 어디서든 똑같이 실행할 수 있도록 만든 작은 가상화 기술이다.

 

쿠버네티스에서는 하나 이상의 컨테이너를 묶어서 Pod라고 부른다. 쉽게 말해, Pod는 애플리케이션의 한 부분으로, 내 앱이 실제로 살아있는 단위라고 생각하면 된다.

 

네트워크적 관점에서 쿠버네티스는 Pod가 서로 통신할 수 있는 가상 네트워크 환경을 제공하며, 외부 사용자도 이 네트워크를 통해 내 애플리케이션에 접근할 수 있도록 해준다.

 

 

쿠버네티스 네트워크의 기본 구조

쿠버네티스 클러스터(여러 서버가 모여 만든 하나의 큰 시스템) 안에는 여러 가지 네트워크 구성 요소가 있다. 크게 세 가지로 나눠볼 수 있는데

  • Pod 네트워크 : 모든 Pod는 서로 IP 주소를 부여받아 같은 네트워크 상에서 직접 연결된다. 마치 같은 동네에 사는 사람들이 서로 쉽게 연락할 수 있는 것과 비슷하다.
  • 서비스(Service) : 여러 Pod가 모여 있는 애플리케이션에는 '서비스'라는 추상화 계층을 두어, 외부 사용자나 다른 Pod들이 하나의 고정된 주소(예: 클러스터IP)를 통해 애플리케이션에 접근할 수 있도록 한다.
  • 인그레스 / 이그레스 : 외부 네트워크(인터넷)와 내부 네트워크(클러스터)의 경계 역할을 담당한다. 인그레스는 외부에서 들어오는 요청을, 이그레스는 내부에서 외부로 나가는 요청을 다룬다.

또 쿠버네티스는 CNI(Container Network Interface)라는 표준을 사용해서 네트워크를 구성한다. 대표적인 CNI 플러그인으로 Calio. Flannel, Cilium 등이 있는데, 이들은 각각 특색 있는 기능을 제공해 네트워크 성능이나 보안을 강화해 준다.

 

 

쿠버네티스 구조

 

위 다이어그램을 살펴보면

  1. 쿠버네티스 클러스터 : 여러 대의 서버(노드)로 구성된 전체 시스템을 의미한다.
  2. 마스터 노드 : 클러스터를 관리하는 두뇌 역할을 한다. 여기에서는 API 서버, 컨트롤러, 스케줄러 등이 포함되어 있어서, 워커 노드에 어떤 작업을 언제 할당할지 결정한다.
  3. 워커 노드들 : 실제 애플리케이션이 실행되는 서버들이다. 워커 노드에는 하나 이상의 Pod가 실행된다.
  4. Pod : 하나 이상의 컨테이너가 묶인 가장 작은 배포 단위이다. 하나의 Pod 내에 있는 컨테이너들은 같은 IP를 공유하며 함께 동작한다.

 

결론적으로 쿠버네티스는 컨테이너화된 애플리케이션이 제대로 실행되고, 필요한 경우 자동으로 확장 및 축소되도록 도와주는 도구이다.

 

'네트워크' 카테고리의 다른 글

서버-클라이언트 간 데이터 전송 성능 극대화를 위한 프로토콜 분석 (TCP vs UDP vs QUIC)  (0) 2025.06.02
기본 인증  (0) 2024.08.15
클라이언트 식별과 쿠키  (0) 2024.08.09
캐시  (0) 2024.08.07
웹 서버  (1) 2024.08.06

동적 계획법(Dynamic Programming, DP) - DP

DP란 무엇일까? 동적 계획법은 큰 문제를 여러 개의 작은 문제로 나누어 해결한 후, 그 결과를 저장하여 전체 문제를 효율적으로 해결하는 방법이다. 예를 들어, 큰 퍼즐을 맞출 때, 각 조각을 따로 기억해둔다면 다시 같은 조각을 찾지 않아도 된다.

 

 

DP의 핵심 개념

  • 부분 문제(Overlapping Subproblems) : 큰 문제를 해결하는 과정에서 동일한 작은 문제가 여러 번 반복되는 경우가 있다.
  • 최적 부분 구조(Optimal Substructure) : 문제의 최적해가 그 하위 문제들의 최적해로 구성될 수 있을 때, DP를 적용할 수 있다.

 

 

대표 문제 : 계단 오르기

 - 문제 설명 : 계단이 총 n개 있고, 한 번에 1계단 또는 2계단씩 오를 수 있을 때, 계단을 모두 오르는 방법의 수를 구하시오.

 - 예시 : n = 3일때, 가능한 경우는

  1.      1,1,1
  2.      1,2
  3.      2,1

   총 3가지 방법

 

 

 - 문제 해결을 위한 아이디어

  • 계단 오르기 문제는 작은 문제(예 : n-1 계단을 오르는 방법, n-2 계단을 오르는 방법)로 나누어 생각할 수 있다.
  • 점화식 : n 번째 계단에 도달하는 방법의 수는 dp[n] = dp[n-1] + dp[n-2] 이다. 왜냐하면 마지막에 한 계단을 오르는 경우와 두 계단을 오르는 경우로 나눌 수 있기 때문이다.
  • 기본 조건 : dp[0] = 1 (아무 계단도 오르지 않은 경우를 1가지 방법으로 간주) / dp[1] = 1 (첫 번째 계단까지 오르는 방법은 1가지)

 

1. 반복적(Bottom-Up) 방식으로 DP 구현하기

fun climbStairs(n: Int): Int {
    // n이 0 또는 1인 경우 바로 반환
    if (n <= 1) return 1

    // dp 배열 생성: dp[i]는 i번째 계단까지 도달하는 방법의 수
    val dp = IntArray(n + 1)
    dp[0] = 1
    dp[1] = 1

    // 2번째 계단부터 n번째 계단까지 방법의 수를 구함
    for (i in 2..n) {
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    return dp[n]
}

fun main() {
    val n = 5  // 예를 들어 계단이 5개인 경우
    println("계단 $n개를 오르는 방법의 수: ${climbStairs(n)}")
    // 출력 예시: 계단 5개를 오르는 방법의 수: 8
}

 

 

2. 재귀와 메모이제이션을 활용한 방식

fun climbStairsRecursive(n: Int, memo: MutableMap<Int, Int> = mutableMapOf()): Int {
    // n이 0 또는 1이면 바로 반환
    if (n <= 1) return 1
    // 이미 계산된 값이면 바로 반환
    if (memo.containsKey(n)) return memo[n]!!

    // 재귀 호출과 메모이제이션
    memo[n] = climbStairsRecursive(n - 1, memo) + climbStairsRecursive(n - 2, memo)
    return memo[n]!!
}

fun main() {
    val n = 5
    println("재귀적 접근: 계단 $n개를 오르는 방법의 수: ${climbStairsRecursive(n)}")
    // 출력 예시: 재귀적 접근: 계단 5개를 오르는 방법의 수: 8
}

// 같은 계산을 반복하지 않도록, 한 번 계산한 결과를 memo에 저장한다. 이렇게 하면, 재귀 호출의 중복을 피할 수 있다.

대부분의 테이블에 날짜 데이터가 등록된 컬럼이 있을 것이다. 날짜별로 그룹화 하여 조회하려면 어떻게 해야할까??

 

1. TRUNC 함수 사용

TRUNC 함수를 사용하면 날짜별로 잘라낼 수 있다.

 

* 월 단위로 자르는 예시

SELECT TRUNC(REG_DT, 'MM') AS month_start,
       COUNT(*) AS cnt
FROM your_table
GROUP BY TRUNC(REG_DT, 'MM')
ORDER BY month_start;

이 쿼리는 REG_DT의 월의 첫 날(예: 2024-08-01) 기준으로 그룹화하여 각 월에 해당하는 데이터 건수를 보여준다.

 

2. TO_CHAR 함수 사용 

날짜를 원하는 형식(예: 'YYYY-MM')으로 변환하여 그룹화할 수도 있다.

SELECT TO_CHAR(REG_DT, 'YYYY-MM') AS month,
       COUNT(*) AS cnt
FROM your_table
GROUP BY TO_CHAR(REG_DT, 'YYYY-MM')
ORDER BY month;

 

* 만약 REG_DT 컬럼이 문자열 형식이라면, 먼저 TO_DATE 또는 TO_TIMESTAMP 함수를 사용하여 날짜로 변환해야 한다.

실시간 통신은 채팅, 알림, 게임. 금융 거래 등 다양한 분야에서 핵심 기술로 사용된다. 예를 들어, 소셜 미디어에서 친구가 게시물을 올릴 때 실시간 알림이 전송되는 경우, WebSocket을 통해 효율적으로 구현할 수 있다. 추후에 어떤 서비스를 구현해 볼지 모르기 때문에 한번 관련된 내용을 정리해두자

 

기본 개념을 먼저 이해해보자 

 

1. HTTP 프로토콜

HTTP(HyperText Transfer Protocol)는 웹의 기본 통신 프로토콜이다. 

  • 요청-응답 구조 : 클라이언트가 요청하면 서버가 응답
  • 단발성 연결 : 요청 후 연결 종료
  • 비상태성 : 각 요청이 독립적

위처럼 HTTP의 한계는 실시간 상호작용에 적합하지 않다는 점이다. 실시간 데이터 전송이나 서버의 지속적인 이벤트 알림은 HTTP만으로 구현하기 어렵다.

 

 

2. WebSocket이란?

WebSocket은 HTTP와 달리 양방향, 지속적인 연결을 제공하는 프로토콜이다. 

  • 지속 연결 : 클라이언트와 서버가 한 번 연결되면 계속해서 데이터를 주고받을 수 있다.
  • 양방향 통신 : 서버가 클라이언트에게 자유롭게 메시지를 전송할 수 있다.
  • 실시간성 : 낮은 지연시간으로 실시간 데이터를 처리할 수 있다.

 

HTTP와 WebSocket의 차이점을 표로 만들어보자

 

구분 HTTP WebSocket
연결 방식 요청-응답 후 연결 종료 연결 후 지속적으로 열린 상태 유지
통신 방향 단방향(클라이언트 요청에 의존) 양방향(서버와 클라이언트 모두 자유롭게 전송)
프로토콜 오버헤드 매 요청마다 헤더 정보 전송 초기 핸드쉐이크 후 최소한의 오버헤드
실시간성 제한적(폴링 방식 필요) 매우 우수 (즉시 데이터 전달)

 

실시간 애플리케이션에서 왜 WebSocket을 선호하는지 알 것 같다. 그러면 WebSocket의 동작 원리에 대해서 알아보자

 

 

3. WebSocket의 동작 원리

3.1 핸드쉐이크 과정

WebSocket 통신은 HTTP 핸드쉐이크로 시작한다.

  1. 클라이언트 요청 : 클라이언트는 HTTP 업그레이드 헤더를 포함하여 서버에 연결 요청을 보낸다.
  2. 서버 응답 : 서버는 요청을 수락하고, 프로토콜을 WebSocket으로 전환하는 응답을 보낸다.
  3. 연결 수립 : 이후 연결은 TCP기반의 지속 연결로 전황되어 데이터를 주고받는다.

이 과정을 통해 기존 HTTP 환경에서도 WebSocket을 사용할 수 있는 유연성을 제공한다.

 

3.2 연결 유지 및 메시지 교환

연결이 성립되면 클라이언트와 서버는 프레임 단위로 메시지를 주고받는다.

  • 텍스트 프레임 : 일반 텍스트 메시지 전송
  • 바이너리 프레임 : 이미지, 파일 등 이진 데이터 전송
  • 컨트롤 프레임 : 연결 종료, 핑/퐁 등 관리 메시지 전송

3.3 연결 종료 메커니즘

연결 종료 시에는 양측에서 종료 요청을 보내고, 지정된 절차에 따라 연결을 정상적으로 마감한다. 이 과정을 예기치 않은 연결 종료를 방지하고, 자원 누수를 최소화한다.

 

 

Spring Boot를 활용해서 간단한 서비스를 만들어보자

 

Spring Boot에서는 기본적으로 WebSocket을 지원하고, 모듈도 제공해준다.

  • spring-boot-starter-websocket : WebSocket 관련 의존성 자동 구성
  • STOMP(Simple Text Oriented Messaging Protocol) : 메시지 브로커와의 통신 지원

Spring Boot에서 WebSocket을 사용하려면 Gradle 또는 Maven을 통해 의존성을 추가해준다.

 

*gradle 예시

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

 

간단한 실시간 알림 서비스 

  • 사용자가 실시간으로 이벤트를 감지
  • 서버는 이벤트 발생 시 즉시 모든 관련 클라이언트에 알림 전달
  • 연결 상태를 지속적으로 유지하여 지연 없이 메시지 전송

 

WebSocket 설정 클래스

@Configuration
@EnableWebSocket
class WebSocketConfig : WebSocketConfigurer {
    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(NotificationHandler(), "/ws/notifications")
            .setAllowedOrigins("*")
    }
}

 

WebSocket 핸들러 클래스

class NotificationHandler : TextWebSocketHandler() {

    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        val receivedText = message.payload
        val response = TextMessage("알림: $receivedText")
        session.sendMessage(response)
    }

    override fun afterConnectionEstablished(session: WebSocketSession) {
        println("클라이언트 연결됨: ${session.id}")
    }

    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        println("클라이언트 연결 종료: ${session.id}")
    }
}

 

실행 클래스

@SpringBootApplication
class WebSocketApplication

fun main(args: Array<String>) {
    runApplication<WebSocketApplication>(*args)
}

 

 

화면

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Kotlin WebSocket 테스트</title>
</head>
<body>
    <h2>실시간 알림 테스트</h2>
    <input id="input" type="text" placeholder="메시지 입력">
    <button onclick="send()">전송</button>
    <div id="log"></div>

    <script>
        const ws = new WebSocket("ws://localhost:8080/ws/notifications");
        ws.onmessage = (event) => {
            const log = document.getElementById('log');
            log.innerHTML += `<p>${event.data}</p>`;
        };

        function send() {
            const input = document.getElementById('input');
            ws.send(input.value);
            input.value = '';
        }
    </script>
</body>
</html>

+ Recent posts