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

 

문제

아기 상어가 성장해 청소년 상어가 되었다.

4×4크기의 공간이 있고, 크기가 1×1인 정사각형 칸으로 나누어져 있다. 공간의 각 칸은 (x, y)와 같이 표현하며, x는 행의 번호, y는 열의 번호이다. 한 칸에는 물고기가 한 마리 존재한다. 각 물고기는 번호와 방향을 가지고 있다. 번호는 1보다 크거나 같고, 16보다 작거나 같은 자연수이며, 두 물고기가 같은 번호를 갖는 경우는 없다. 방향은 8가지 방향(상하좌우, 대각선) 중 하나이다.

오늘은 청소년 상어가 이 공간에 들어가 물고기를 먹으려고 한다. 청소년 상어는 (0, 0)에 있는 물고기를 먹고, (0, 0)에 들어가게 된다. 상어의 방향은 (0, 0)에 있던 물고기의 방향과 같다. 이후 물고기가 이동한다.

물고기는 번호가 작은 물고기부터 순서대로 이동한다. 물고기는 한 칸을 이동할 수 있고, 이동할 수 있는 칸은 빈 칸과 다른 물고기가 있는 칸, 이동할 수 없는 칸은 상어가 있거나, 공간의 경계를 넘는 칸이다. 각 물고기는 방향이 이동할 수 있는 칸을 향할 때까지 방향을 45도 반시계 회전시킨다. 만약, 이동할 수 있는 칸이 없으면 이동을 하지 않는다. 그 외의 경우에는 그 칸으로 이동을 한다. 물고기가 다른 물고기가 있는 칸으로 이동할 때는 서로의 위치를 바꾸는 방식으로 이동한다.

물고기의 이동이 모두 끝나면 상어가 이동한다. 상어는 방향에 있는 칸으로 이동할 수 있는데, 한 번에 여러 개의 칸을 이동할 수 있다. 상어가 물고기가 있는 칸으로 이동했다면, 그 칸에 있는 물고기를 먹고, 그 물고기의 방향을 가지게 된다. 이동하는 중에 지나가는 칸에 있는 물고기는 먹지 않는다. 물고기가 없는 칸으로는 이동할 수 없다. 상어가 이동할 수 있는 칸이 없으면 공간에서 벗어나 집으로 간다. 상어가 이동한 후에는 다시 물고기가 이동하며, 이후 이 과정이 계속해서 반복된다.

...

상어가 먹을 수 있는 물고기 번호의 합의 최댓값을 구해보자.

입력

첫째 줄부터 4개의 줄에 각 칸의 들어있는 물고기의 정보가 1번 행부터 순서대로 주어진다. 물고기의 정보는 두 정수 ai, bi로 이루어져 있고, ai는 물고기의 번호, bi는 방향을 의미한다. 방향 bi는 8보다 작거나 같은 자연수를 의미하고, 1부터 순서대로 ↑, ↖, ←, ↙, ↓, ↘, →, ↗ 를 의미한다.

출력

상어가 먹을 수 있는 물고기 번호의 합의 최댓값을 출력한다.


방향을 지정하는 방법

  • 방향의 경우, direction 을 x 와 y 로 각각 나누어서 저장하자.
static int[][] directions = { //  ↑, ↖, ←, ↙, ↓, ↘, →, ↗
        {-1, -1, 0, 1, 1, 1, 0, -1},
        {0, -1, -1, -1, 0, 1, 1, 1},
};

static String[] directions_string =
        {"↑", "↖", "←", "↙", "↓", "↘", "→", "↗"};

 

참고로, directions_string 배열은 내가 코딩하면서 직관적으로 논리를 펼칠 수 있게끔 출력하려고 만든거다. (문제에서 필요없다)

 

이런식으로 방향을 지정해놓으면, 다음과 같이 위치 변경을 이뤄낼 수 있다.

// 기존 Position 인 fishPos
// 이동한 Position 인 chgPos

// 기존 Position 인 fishPos 을 이용하여 특정 direction 으로 이동해보자.

int x = fishPos.x + directions[0][fish.dir];
int y = fishPos.y + directions[1][fish.dir];

Position chgPos = new Position(x, y);

 


물고기의 입력을 어떻게 저장할까?

먼저, 어떤 상태가 필요할까?

  • 본인의 number
  • 방향

그 다음으로 어떤 동작이 필요할까?

  • 방향의 전환(반시계 방향으로 45도 회전)
  • (추후 추가) 객체 간 깊은 복사를 위해서 static clone 메서드를 추가하였다.

물고기의 이동과 관련된 현재 위치는 어떻게 계산하지? (순차대로 이동해야 하므로)

  • 1번 물고기부터 차례대로 어느 위치에 있는지 기록하는 순차 List<Fish> FishPos 가 필요하겠다.
  • `순차대로` 에 꽂혀서 List 라고 생각했는데, 다시 잘.. 생각해보니, Map<Integer, Position> fishPosMap 으로 저장해서, 특정 Fish 의 특정 num 으로 식별하고, 해당 num 의 Fish 의 Position 값을 가져오는게 나을 것 같아, 변경하였다.

물고기가 잡아먹히면 현재 위치도 사라지고, 존재하지 않는다는 것을 표현해야 한다.

  • 현재 위치를 나타내는 리스트에 위치값을 -1로 변경한다던지의 방법도 있지만, 좀더 직관적이게 boolean list 인 isEaten 를 가져가도록 하자.
  • 라고 생각했는데.. 이것도 필요없어졌다. Map 으로 물고기의 존재 여부를 가져가게 되면, Map<Integer, Position> fishPosMap 에 없는 물고기는 먹혀 없는 물고기로 판별하면 된다.
// 물고기 객체
class Fish {
    int num;
    int dir;

    public Fish(int num, int dir) {
        this.num = num;
        this.dir = dir;
    }

    public static Fish clone(Fish fish) {
        return new Fish(fish.num, fish.dir);
    }

    public void rotate() {
        this.dir = (this.dir + 1) % 8;
    }

    @Override
    public String toString() {
        return String.format("%02d", num) +"(" + dir +") ";
    }
}

 


이제, 물고기 한마리가 이동하려고 한다고 해보자. 

물고기를 회전하려면.. 어떤 상태 정보들이 바뀔까?

  • Fish[][] map : 어떤 공간에 어떤 물고기가 있는지 판별하기 위한, 어항 맵
  • Map<Integer, Position> fishPosMap : 특정 number 의 물고기가 어느 Position 에 있는지 판별하는 Map 이자, 특정 물고기가 잡아먹히면 이 Map 에서 사라진다.
  • Fish fish : 현재 이동하려고 하는 물고기. 현재 물고기의 num(번호) 및 dir(방향) 정보가 담겨있다.
/**
 * 이동 가능성이 보장되어 있는 Fish 에 대해서, 이동(swipe) 한다.
 * @param map 각 공간에 어떤 특정 물고기가 배치되어 있는지 알 수 있는 2중 array (얕은 복사로 물고기 위치 변경)
 * @param fishPosMap 각 물고기들이 num(번호)로 식별되어 살아있는지 확인하는 map (얕은 복사로 물고기의 Position 변경)
 * @param fish 현재 이동하려는 물고기 (이동 가능성 보장)
 */
static void swipeFish(Fish[][] map, Map<Integer, Position> fishPosMap, Fish fish) {
    Position fishPos = fishPosMap.get(fish.num);

    int x = fishPos.x + directions[0][fish.dir];
    int y = fishPos.y + directions[1][fish.dir];

    Fish newFish = map[x][y] != null ?
            new Fish(map[x][y].num, map[x][y].dir) : null;
    Position newFishPos = new Position(x, y);

    if (newFish == null) {
        map[x][y] = Fish.clone(fish);
        map[fishPos.x][fishPos.y] = null;

        fishPosMap.put(fish.num, newFishPos);
    }
    else {
        map[x][y] = Fish.clone(fish);
        map[fishPos.x][fishPos.y] = Fish.clone(newFish);

        fishPosMap.put(fish.num, newFishPos);
        fishPosMap.put(newFish.num, fishPos);
    }
}

 

기본적으로, Fish[][] map 과 Map<Interger, Position> fishPosMap 은 자료 구조이다. 이러한 자료 구조는 깊은 복사를 하지 않으면 얕은 복사가 되기때문에, 나는 이걸 역 이용할거다.

 

역 이용해서, 해당 swipeFish 메서드에서 두 객체의 내부 정보를 바꾸면, 이 메서드를 호출한 상위 메서드에서도 파라미터로 넣은 두 객체의 내부 정보가 바뀔 것이다. 

 

-> 이 관련 정보는 Java의 객체 참조 방식call by value + reference semantics 를 검색해보자.

 


이제 상어의 정보는 어떻게 저장할까?

 

여기부턴 나중에 이뒤 내용부터는 검증이 안됏습니당... .. 고민중

 

물고기와 같이 Fish 객체로 구성해야할까? 물고기와 비슷하게, 방향의 전환을 할 수도 있는데 ...

 

상어에게 필요한 상태

  • 현재 먹은 물고기들의 총합 number
  • 물고기를 먹을 때마다 바뀌는 방향
  • 현재 본인의 위치

 

아니면 위 세가지의 상태를 모두 넣은 Fish 객체를 만들고, 물고기와 상어를 모두 상태관리하는 것이다.

그러면 물고기의 경우, 현재 본인의 위치와 자신의 정보(number, direction) 등도 함께 저장해서 관리 가능하고,

순차리스트를 만들때도 Fish 자체를 저장해놓은 다음에, ArrayList 로 먹힐때마다 해당 Fish 객체를 사라지게 하면 되지 않을까???

 

그렇게 되면 원래 만드려고 했던 boolean 리스트인 isEaten 을 가져갈 필요도 없어진다.


물고기가 이동하는 것은 구현, 상어가 이동하는것은 DFS

이제 상어가 여러 물고기를 잡아먹을 수 있다는 사실에 주목해보자.

 

 

 

1. 요구사항

  • 사용자는 별도의 회원가입 없이 이름만 입력해서 로그인할 수 있어야 한다.
  • 로그인된 사용자는 채팅방을 생성할 수 있어야 한다.
  • 채팅방 목록은 실시간으로 동기화되어야 한다.
  • 초기 진입 시에는 기존 채팅방 목록을 REST API로 불러와야 한다.

2. API 명세서

🔐 POST /api/login

  • 사용자 이름으로 로그인 (회원가입 없음)

Request

  • Content-Type: application/x-www-form-urlencoded
  • Parameter:
    • name: string (required)

Response (200 OK)

{
  "success": true,
  "code": 200,
  "message": "로그인 성공",
  "data": "mj"
}

인증 방식

  • 세션 쿠키(JSESSIONID) 기반
  • 서버는 HttpSessionSPRING_SECURITY_CONTEXT 저장하여 인증 유지

📋 GET /api/chat/rooms

  • 전체 채팅방 목록 조회

Request

  • Headers: Cookie: JSESSIONID=...

Response (200 OK)

{
  "success": true,
  "code": 200,
  "message": "요청 성공",
  "data": [
    {
      "id": 1,
      "name": "테스트 채팅룸",
      "createdAt": "2025-04-06 19:31:51"
    },
    {
      "id": 2,
      "name": "백엔드 공부방",
      "createdAt": "2025-04-06 19:31:54"
    }
  ]
}

인증 필요

  • 로그인 후에만 호출 가능

🔐 POST /api/chat/rooms

  • 새로운 채팅방 생성

Request

  • Parameter:
    • name: string (required)
  • Headers: Cookie: JSESSIONID=...

Response (200 OK)

{
  "success": true,
  "code": 200,
  "message": "요청 성공",
  "data": {
    "id": 2,
    "name": "testChatRoom",
    "createdAt": "2025-04-06 19:31:54"
  }
}

실시간 처리

  • 생성 성공 시 STOMP로 /sub/chat/rooms 채널에 broadcast 됨

3. 문제 해결 (Troubleshooting)

① MvcRequestMatcher permitAll 이슈

  • 문제: .requestMatchers(mvc.pattern("/login")).permitAll()이 동작하지 않음
  • 원인: MvcMatcher가 DispatcherServlet과 서블릿 경로(/)를 정확히 판단하지 못함
  • 해결: AntPathRequestMatcher/login을 허용하자 즉시 해결됨

관련 포스팅: https://jinn-o.tistory.com/386

 

[SpringSecurity6.1] requestMatchers에서 오류가 나기 시작했다

0. 서론최근 Spring Boot 3.x + Spring Security 6.x로 프로젝트를 시작하면서 기존에 잘 사용하던 `.requestMatchers("/login", "/api/**")` 구문이 갑자기 다음과 같은 에러를 발생시키기 시작했다!This method cannot decid

jinn-o.tistory.com

 

② 인증 상태 유지 실패

  • 문제: 로그인 후 인증이 유지되지 않아 /chat/rooms 호출 시 403 발생
  • 원인: 인증 정보를 SecurityContextHolder에는 저장했지만, HttpSession에 저장하지 않음
  • 해결: 로그인 시 HttpSessionSPRING_SECURITY_CONTEXT 수동 저장하여 해결

 

4. 결론 및 추후 개발 사항

결론

  • V1에서는 "비회원 이름 기반 로그인 + 실시간 채팅방 생성 + REST 조회"까지 구현 완료
  • WebSocket(STOMP) 기반 실시간 브로드캐스트 구조를 잡음
  • Spring Security + 수동 인증 구조도 직접 구성하며 인증 흐름에 대한 이해를 높임

추후 개발 계획

  • 실시간 채팅 메시지 송수신 기능 구현
  • JWT 기반 인증 방식 도입하여 무상태 Stateless 구조 실험
  • Redis Pub/Sub 연동 → 서버 다중화 대응
  • Swagger 또는 REST Docs 기반 자동 API 명세화

이 문서는 2025년 4월 기준 CloudTalk 프로젝트 V1 개발 현황을 기준으로 작성되었습니다.

0. 서론

최근 Spring Boot 3.x + Spring Security 6.x로 프로젝트를 시작하면서 기존에 잘 사용하던 `.requestMatchers("/login", "/api/**")` 구문이 갑자기 다음과 같은 에러를 발생시키기 시작했다!

This method cannot decide whether these patterns are Spring MVC patterns or not.
If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); otherwise, please use requestMatchers(AntPathRequestMatcher).

This is because there is more than one mappable servlet in your servlet context:
{org.h2.server.web.JakartaWebServlet=[/h2-console/*], org.springframework.web.servlet.DispatcherServlet=[/]}.

 

해석해보자면...

 

SpringMVC 패턴인지 아닌지 알수없다.

만약 Spring MVC 엔드포인트라면 MvcRequestMatcher를 사용하라. 아니면, AntPathRequesMatcher를 사용해주세요.

 

서블릿 컨텍스트 안에 하나의 서블릿보다 더 많은 매칭가능한 서블릿이 존재합니다:

JakartaWebServlet 과 DispatcherServlet.

 

1. SpringSecurity6.x 버전부터는 requestMatcher를 명시해주어야 한다.

URL 경로는 이제 MVC 기반인지, 서블릿 기반인지 정확히 구분해서 써줘!
- SpringSecurity팀

 

SpringSecurity5.x까지도 그냥 requestMatchers("/", "/login") 이런 식으로 막 사용해도 작동했다. 그이유는 SpringSecurity가 알아서 `아~ MVC 패턴 경로겠지` 하고 추측해서 매칭해주었기 때문이다.

 

SpringSecurity5.x 시절,

http
  .authorizeHttpRequests()
  .requestMatchers("/login", "/user/**").permitAll()

 

이렇게만 작성해줘도,

  • 내부에서 AntPathRequestMatcher를 자동으로 생성했다.
  • 경로가 Spring MVC 컨트롤러의 매핑이든 정적 리소스든 크게 구분하지 않고 느슨히 모두 허용했다.

 

2. 그런데 왜 이제와서 문제가 되었을까?

 

예를들어, SpringSecurity 서비스에서 h2-console 데이터베이스를 사용한다고 했을때,

http
  .authorizeHttpRequests()
  .requestMatchers("/h2-console/**").permitAll()

 

스프링 시큐리티는 헷갈리게 된다.

“이 요청이 DispatcherServlet을 타는 거야?
아니면 H2 콘솔 서블릿으로 바로 가는 거야?”

 

이때, DispatcherServlet은 Spring MVC의 핸들러 매핑(컨트롤러)으로 연결되는 서블릿이고,
H2 콘솔 서블릿은 Spring과 무관하게 직접 HTML UI를 서블릿에서 처리하는 완전 별개 서블릿이다.

 

DispatcherServlet의 경우,

@GetMapping("/chat")
public String chatPage() {
    return "chat";
}

 

DispatcherServlet은 요청이 들어오면, 어떤 @Controller 메서드에 보낼지 결정하는 역할을 한다.
위 코드와 같은 Controller 의 요청을 받아 처리한다. 즉, Spring MVC 의 핵심 서블릿이다.

 

처리 흐름은 다음과 같다.

요청 → DispatcherServlet → HandlerMapping → @Controller

 

 

H2 콘솔 서블릿의 경우,

자체적으로 html 기반 웹 ui를 띄워주는 Spring의 외부 라이브러리이다,

Spring MVC 구조와 전혀 무관하다. 왜냐하면 Controller도, ViewResolver도 사용하지 않는다.

 

처리 흐름은 다음과 같다.

요청 → org.h2.server.web.WebServlet → 내부 HTML 처리

 

3. 그러니까, 헷갈리는 건 알겠는데, 5.x버전에서는 문제가 없었잖아.

실은 보안상 구멍이 존재했다. 스프링 시큐리티팀은 이 구멍을 발견하고 6.x버전부터 업데이트했다.

 

스프링5.x에서는, 모두 AntPathRequestMatcher로 처리되었다.

// 이런 requestMatchers 가 등록되면,
.requestMatchers("/chat", "/h2-console/**")

// 내부에서는 아래와 같이 변환함
new AntPathRequestMatcher("/chat")
new AntPathRequestMatcher("/h2-console/**")

 

  • 이 matcher는 단순히 URL 경로만 보고 매칭했다.
  • 어떤 서블릿이 받는지는 아예 고려하지 않았다.
  • 그래서 DispatcherServlet이든 H2든 그냥 URL만 일치하면 통과시킨것이다 !!!

 

AntPathRequestMatcher 의 경우,

단순히 요청 URI 가 매칭되면 전부 다 ! 배칭된다. 해당 경로에 컨트롤러가 존재하든 말든!!!

그러니까, 존재하지 않는 경로도 인증 필터 체인을 타게되는 것이다.

 

예를들어, 현재 내 서비스에 /api/v1/users 라는 getMapping 만 지정해놓았다고 해보자.

requestMatchers 에다가 /api/** 이렇게 등록을 해놓으면,

/api/v1/users 라는 uri 뿐만이 아니라, /api/foo/bar 와 같이 전혀 등록하지 않은 의미없는 경로에 대해서도 인증 필터를 타게 되는 것이다!

 

실은 탈필요없다, 왜냐하면 만들어놓은 컨트롤러가 아니니까!!

 

그러나 MvcRequestMatcher 의 경우,

Spring MVC 에 실제 매핑된 컨트롤러의 경로와 일치해야만 매칭이 된다.

심지어, PathVariable 이 존재하는 컨트롤러 경로까지 추가할 수 있다.

없는 경로는 인증 필터 자체가 타지 않는다~!

 

이제 /api/foo/bar 와 같이 존재하지 않는 컨트롤러 경로에 대해서는 인증 필터가 반응하지 않게 될 것이다.

 

 

4. SpringSecurity 팀이 6.1버전부터 서블릿 종류를 명시하도록 한 이유.

 

  • 실제 존재하는 컨트롤러에만 보안 필터를 적용하도록
  • 보안 설정 범위와 실제 API 경로가 100% 일치하는 구조로 유지하도록
  • 필터에서 토큰 검증 등 무거운 처리를 피하도록

 

예시

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   HandlerMappingIntrospector introspector) throws Exception {
        // MvcRequestMatcher builder 생성 + DispatcherServlet 경로 명시
        MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector)
                .servletPath("/"); // Spring MVC 기준으로 필터링

        http
            .csrf().disable()
            .headers().frameOptions().disable() // H2 콘솔 띄우기 위함
            .authorizeHttpRequests(auth -> auth

                // Spring MVC 컨트롤러 경로 - MvcRequestMatcher
                .requestMatchers(mvc.pattern("/chat")).permitAll()
                .requestMatchers(mvc.pattern("/api/v1/users")).authenticated()

                // 정적 자원이나 외부 서블릿 경로 - AntPathRequestMatcher
                .requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/css/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/js/**")).permitAll()

                // 그 외는 인증 필요
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public HandlerMappingIntrospector handlerMappingIntrospector() {
        return new HandlerMappingIntrospector();
    }
}

 

 

 

결론.

MvcRequestMatcher: Spring MVC 핸들러(@Controller, @RestController)에 매핑된 경로를 보호한다.

AntPathRequestMatcher: 정적 리소스, 외부 서블릿(H2 등) 경로를 허용한다.

 

혼합해서 매칭 방식을 달리 사용해야한다.

경로마다 처리 대상이 다르기 때문이다.

0. 도입 : HTTP 에서의 양방향 통신의 한계

먼저 우리는 양방향 통신에 대해서 고민해보아야 합니다. http는 전형적인 단방향 통신을 거치는 프로토콜입니다. 통신이 필요하다면, 클라이언트가 요청하고 서버가 응답해야 합니다.

 

만약 http 에서 양방향 통신이 필요하다면, 다음과 같은 방식들을 사용해야합니다.

 

Polling : 클라이언트가 주기적으로 서버에 요청을 보내 응답을 기다리는 방식

Long Polling : 요청을 보내고, 서버에서 데이터가 생길 때까지 기다린 후 응답을 보내는 방식

Streaming : 응답 연결을 확인받지 않고 계속해서 데이터를 전송만 하는 방식. (UDP와 유사한 비신뢰적 느낌)

 

하지만 이러한 방식들은 한계가 있습니다.

누구와 어떤 메시지를 어떻게 실시간으로 통신할 것인가? 에 대한 체계가 잘 잡혀있지않죠.

 

그래서 나온 것이 웹소켓입니다.

 

1. WebSocket(웹 소켓)의 등장

WebSocket은 HTTP 위에서 동작하면서 양방향 통신을 가능하게 하는 프로토콜입니다. 처음 한 번의 핸드셰이크(handshake)를 통해 연결을 맺고 나면, 서버와 클라이언트는 끊김 없는 상태에서 자유롭게 메시지를 주고받을 수 있습니다. 즉, 웹 소켓을 사용하면 보내는 이와 메시지를 담아서 실시간으로 통신이 가능하죠. 이로 인해 실시간성이 필요한 서비스(채팅, 게임 등)에서 WebSocket은 필수 기술입니다.

 

그러나 WebSocke 자체도 완전한 해결책이 될 수는 없습니다.

 

메시지 전송 방식, 수신자 식별, 채널 분리, 메시지 라우팅 등에 대한 구체적인 규칙(컨벤션)이 부족합니다.

 

구체적인 규칙이 부족하면 발생하는 문제는 무엇일까요?

누가 누구에게 보낸건지 알 수 없습니다.

어떤 메시지 형식으로 데이터가 전송된 것인지 파악하고 파싱하기 어렵습니다.

 

비유하자면, 랜덤 채팅이라고 생각해봅시다. 어떤 언어를, 누가, 어떤 방식으로 보내올지 알수 없습니다. 그러나 우리는 교내/사내 혹은 친구들끼리만, 같은 언어로, 예상 가능한 형식의 메시지를 전송받고 싶은 겁니다. 누군가의 메시지에는 날짜가 포함되어 있고, 누군가의 메시지에는 채팅 제목이 없고, 식의 알수없는 프로토콜은 안됩니다.

 

2. 그래서 등장한 STOMP

STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 경량 메시지 전송 프로토콜로, 구조화된 메시지 송수신 체계를 제공합니다. STOMP를 사용하면 다음과 같은 이점을 얻을 수 있습니다.

 

1. Topic(채널) 기반의 Pub/Sub

메시지는 destination 경로에 따라 전달되고, 해당 토픽(채널)을 구독(Sub: subscribe)한 클라이언트만 메시지를 수신할 수 있습니다! 즉 `누구와` 혹은 `누구의` 메시지를 주고받을지 지정할 수 있습니다.

 

2. 명령이 구조화되어 있다!

SEND, SUBSCRIBE, UNSUBSCRIBE, CONNECT, DISCONNECT 등의 명령이 존재해, 메시지 송수신 흐름이 명확하게 컨트롤됩니다.

 

3. url 경로를 통한 라우팅

예시를 들자면, /app/chat.sendMessage, /chat/public 등으로, 특정 채팅방, 시스템 메시지, 알림 등을 분류하기 쉽습니다. 즉, 목적별로 메시지를 분류할 수 있게 된겁니다!

 

4. 외부 라이브러리 연동 가능

보안(Spring Security) 적용이나 외부 메시지 브로커(Redis, RabbitMQ) 연동에도 적합합니다~

 

 

정리

WebSocket은 실시간 통신을 가능하게 하지만, 누가 어떤 메시지를 어떻게 주고받는지에 대한 구조가 부족합니다. 그래서 STOMP를 도입하면 topic 기반으로 메시지를 구분하고, 브로커를 통해 메시지를 전달해줄 수 있어 확장성과 메시지 라우팅이 수월해집니다.

 

특히 Spring에서 STOMP 구조를 사용하면 REST API처럼 각 메시지의 흐름을 URL 기반으로 정의하고, 컨트롤러 메서드로 처리할 수 있어 구조적입니다. 메시지 브로커를 통해 구독자에게 실시간 메시지를 푸시하는 방식은 REST의 GET 요청을 이벤트 기반으로 대체한 개념이라 볼 수 있고, REST의 경량 실시간 버전이라 할 수 있을 정도로, 구조, 처리 방식, 라우팅이 매우 유사하고 친숙합니다.

 

 

참고 자료

Blue-Green 배포가 필요한 이유 : 무중단 배포(Zero Downtime Deployment)

무중단 배포란, 서비스를 사용하는 사용자가, 서비스의 새로운 버전으로 교체되는 와중에도 서비스의 중단을 경험하지 않는 것을 말한다. 새로운 기능을 배포할 때마다 기존 사용자 경험을 해치게 된다면 사용자의 서비스에 대한 신뢰도도 떨어지고 자꾸만 끊기는 서비스 가용성에 불편함을 느낄 것이다. 그러니까 우리의 목표는, 서비스의 구동이 끊기지 않으면서 안정적으로 서비스를 전환하는 것이다.

 

왜 Blue-Green 일까?

먼저 블루, 그린이란 각각 두 개의 운영 환경을 말한다. 그렇다. 블루와 그린 둘다 운영 환경이다. 다만 중요한 것은 두 환경은 정확히 동일하다는 것이다.

 

완벽히 동일한 환경이 일란성 쌍둥이처럼 두 개로 나뉘어져 있다고 이해하면 된다.

  • Blue (현재 운영 중인 버전 ex.v1 배포전 기존 환경) : 현재 사용자가 접근하고 있는 환경
  • Green (새로운 배포 버전 ex. v2) : 새로운 버전을 배포하고 테스트할 수 있는 환경

 

배포 과정에 대하여

  1. 새로운 버전을 Green 환경에 배포한다. (현재 Blue 환경이 운영중이다.)
  2. Green  환경에서 새로운 버전을 배포 후 , 충분히 테스트 해본다.
  3. 테스트가 실패하면 롤백한다. Green 을 이전 버전으로 돌린다. (다운타임없이 빠른 롤백 가능)
  4. 테스트가 성공하면 트래픽을 Blue 환경에서 Green 환경으로 전환한다.

 

GitLab 에서 무중단 배포를 실현하는 방법.

 

1. 먼저 Nginx 를 이용한 Reverse Proxy 서버가 필요하다.

upstream backend {
    server backend-v1:8083 backup;
    server backend-v2:8084;
}

upstream frontend {
    server frontend-v1:3000 backup;
    server frontend-v2:3001;
}

server {
    location /api/ {
        proxy_pass http://backend;
    }
    location / {
        proxy_pass http://frontend;
    }
}

 

참고로 예시로 든 환경은, 백엔드 서버와 프론트 서버가 각각 두개씩 구동중인 서비스이다.

 

 

2.GitLab CI/CD 파이프라인 설정

stages:
  - build
  - deploy-blue
  - deploy-green
  - switch-traffic

# 애플리케이션 빌드
build:
  stage: build
  script:
    - echo "Building application..."
    - ./gradlew build  # Spring Boot 빌드 예제
    - npm run build    # React 빌드 예제
  artifacts:
    paths:
      - build/

# Green 환경에 배포
deploy-green:
  stage: deploy-green
  script:
    - echo "Deploying to Green environment..."
    - ./deploy_backend.sh --green
    - ./deploy_frontend.sh --green
  only:
    - main

# 테스트 단계 (선택 사항)
test-green:
  stage: test
  script:
    - echo "Running tests in Green environment..."
    - ./run_tests.sh
  only:
    - main

# 트래픽 전환
switch-traffic:
  stage: switch-traffic
  script:
    - echo "Switching traffic from Blue to Green..."
    - ./switch_traffic.sh --green  # Nginx 설정 변경
  only:
    - main

 

Nginx 리버스 프록시를 설정한 후, GitLab CI/CD를 활용하여 Blue-Green 배포를 자동화할 수 있다.
GitLab에서는 .gitlab-ci.yml을 이용하여 여러 개의 스테이지를 정의하고, 배포 프로세스를 구성할 수 있다.

 

3. Nginx 에서 트래픽 전환 방법.

#!/bin/bash
echo "Switching traffic to Green..."

# 기존 Nginx 설정 백업
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak

# Nginx 설정 변경 (Green 환경으로 트래픽 전환)
sed -i 's/backend-v1:8083 backup/backend-v1:8083;/' /etc/nginx/nginx.conf
sed -i 's/backend-v2:8084;/backend-v2:8084 backup;/' /etc/nginx/nginx.conf

# Nginx 재시작
nginx -s reload

echo "Traffic successfully switched to Green!"

 

sed -i 는 텍스트 파일을 직접 수정하는 리눅스 명령어이다.

sed -i 를 사용하면 Nginx 설정 파일(nginx.conf) 에서 특정 내용을 자동으로 변경할 수 있다.

s/찾을문자열/바꿀문자열/ 패턴을 사용하여 특정 내용을 변경한다.

 

Blue-Green 배포를 적용해야 하는 이유.

무중단 배포(Zero Downtime Deployment) 실현 → 사용자가 서비스 중단을 경험하지 않는다
빠른 롤백 가능 → 문제가 생기면 즉시 이전 버전(Blue)으로 되돌릴 수 있다.
실제 운영 환경에서 미리 테스트 가능 → Green 환경에서 충분히 테스트 후 트래픽을 전환할 수 있다.
부하 분산 및 확장 가능 → 여러 개의 인스턴스를 운영하며 부하를 나눌 수 있다.

 

 

그렇다고 무중단 배포가 무조건 정답은 아니다.

무중단 배포는 인프라의 복잡성을 증가시킬 수 있다. 예를 들어, 로드 밸런서, Nginx 리버스 프록시, 배포 자동화 도구 등의 설정이 필요하다. 이러한 인프라가 없거나, 배포 다운타임이 길지 않은 경우, 무중단 배포를 도입하는 것이 오히려 복잡한 문제가 될 수 있다.

 

서버 비용도 증가한다. Blue-Green 배포를 위해서 두 개의 운영 환경을 유지해야 하므로 인프라 비용이 두 배로 들 가능성이 있다. 스타트업이나 소규모 프로젝트에서는 유지 비용이 부담이 될 수 있다.

 

마지막으로 실은, 완벽한 무중단 배포는 거의 불가능하다. 프론트, 백, 디비까지 모두 신경써야하고 구버전 환경으로 롤백될 수 있는 가능성까지 전부 고려한 예외처리도 필요하기 때문이다.

'DevOps > CICD' 카테고리의 다른 글

[GitLab] 내장 CI/CD 사용방법  (1) 2025.02.01
[GitLab] 깃허브와 다른점이 뭘까?  (0) 2025.02.01

 

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

 

문제

N×N크기의 땅이 있고, 땅은 1×1개의 칸으로 나누어져 있다. 각각의 땅에는 나라가 하나씩 존재하며, r행 c열에 있는 나라에는 A[r][c]명이 살고 있다. 인접한 나라 사이에는 국경선이 존재한다. 모든 나라는 1×1 크기이기 때문에, 모든 국경선은 정사각형 형태이다.

오늘부터 인구 이동이 시작되는 날이다.

인구 이동은 하루 동안 다음과 같이 진행되고, 더 이상 아래 방법에 의해 인구 이동이 없을 때까지 지속된다.

  • 국경선을 공유하는 두 나라의 인구 차이가 L명 이상, R명 이하라면, 두 나라가 공유하는 국경선을 오늘 하루 동안 연다.
  • 위의 조건에 의해 열어야하는 국경선이 모두 열렸다면, 인구 이동을 시작한다.
  • 국경선이 열려있어 인접한 칸만을 이용해 이동할 수 있으면, 그 나라를 오늘 하루 동안은 연합이라고 한다.
  • 연합을 이루고 있는 각 칸의 인구수는 (연합의 인구수) / (연합을 이루고 있는 칸의 개수)가 된다. 편의상 소수점은 버린다.
  • 연합을 해체하고, 모든 국경선을 닫는다.

각 나라의 인구수가 주어졌을 때, 인구 이동이 며칠 동안 발생하는지 구하는 프로그램을 작성하시오.

입력

첫째 줄에 N, L, R이 주어진다. (1 ≤ N ≤ 50, 1 ≤ L ≤ R ≤ 100)

둘째 줄부터 N개의 줄에 각 나라의 인구수가 주어진다. r행 c열에 주어지는 정수는 A[r][c]의 값이다. (0 ≤ A[r][c] ≤ 100)

인구 이동이 발생하는 일수가 2,000번 보다 작거나 같은 입력만 주어진다.

출력

인구 이동이 며칠 동안 발생하는지 첫째 줄에 출력한다.

 


문제에서 느껴지는 그래프의 향기.. BFS

그것도 `인접한` 국가끼리의 관계가 중요하므로, DFS 보다는 BFS 를 활용해야 할 것이다.

 

import java.io.*;
import java.util.*;

public class Main {

    static boolean[][] visited = null;
    static int[][] MAP = null;
    static int N = -1;
    static int L = -1;
    static int R = -1;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int[] line = parseIntArr(br.readLine().split(" "));

        N = line[0];
        L = line[1];
        R = line[2];

        MAP = new int[N][N];
        for (int i=0; i<N; i++) {
            int[] rows = parseIntArr(br.readLine().split(" "));
            MAP[i] = Arrays.copyOf(rows, N);
        }

        int result = 0;
        while (true) {
            visited = new boolean[N][N];
            if (isChanged()) result ++;
            else break;
        }

        System.out.println(result);
    }

    private static boolean isChanged() {
        int changed = 0;
        for (int i=0; i<N; i++) {
            for (int j=0; j<N; j++) {
                if (!visited[i][j] && bfs(i, j))
                    changed ++;
            }
        }
        return (changed != 0);
    }

    private static boolean bfs(int r, int c) {
        Queue<int[]> q = new LinkedList<>();
        Queue<int[]> unions = new LinkedList<>();

        int totalPopulation = MAP[r][c];
        int numOfCountry = 1;

        q.add(new int[] {r, c});
        unions.add(new int[] {r, c});
        visited[r][c] = true;
        while (!q.isEmpty()) {
            int[] country = q.poll();
            int row = country[0];
            int col = country[1];

            if (0 < row && !visited[row-1][col] && canOpen(row, col, row-1, col)) {
                int[] newCountry = new int[] {row-1, col};
                q.add(newCountry);
                unions.add(newCountry);
                visited[row-1][col] = true;

                totalPopulation += MAP[row-1][col];
                numOfCountry ++;
            }

            if (0 < col && !visited[row][col-1] && canOpen(row, col, row, col - 1)) {
                int[] newCountry = new int[] {row, col-1};
                q.add(newCountry);
                unions.add(newCountry);
                visited[row][col-1] = true;

                totalPopulation += MAP[row][col-1];
                numOfCountry ++;
            }

            if (row < N-1 && !visited[row+1][col] && canOpen(row, col, row + 1, col)) {
                int[] newCountry = new int[] {row+1, col};
                q.add(newCountry);
                unions.add(newCountry);
                visited[row+1][col] = true;

                totalPopulation += MAP[row+1][col];
                numOfCountry ++;
            }

            if (col < N-1 && !visited[row][col+1] && canOpen(row, col, row, col+1)) {
                int[] newCountry = new int[] {row, col+1};
                q.add(newCountry);
                unions.add(newCountry);
                visited[row][col+1] = true;

                totalPopulation += MAP[row][col+1];
                numOfCountry ++;
            }
        }

		// 서로간에 국경이 열리고 이동한 도시가 없다면 false 반환
        if (numOfCountry == 1) return false;

        // 연합의 인구수 / 총 연합수
        int result = totalPopulation / numOfCountry;
        for (int[] country : unions) {
            int row = country[0];
            int col = country[1];
            MAP[row][col] = result;
        }

        return true;
    }

    private static boolean canOpen(int a_r, int a_c, int b_r, int b_c) {
        int pop_a = MAP[a_r][a_c];
        int pop_b = MAP[b_r][b_c];

        int pop_abs = Math.abs(pop_a - pop_b);
        return (L <= pop_abs) && (pop_abs <= R);
    }

    private static void printMap() {
        for (int[] rows : MAP) {
            for (int col : rows) {
                System.out.print(col + " ");
            }
            System.out.println();
        }
        System.out.println();
    }

    private static int[] parseIntArr(String[] strArr) {
        return Arrays.stream(strArr).mapToInt(Integer::parseInt).toArray();
    }
}

 

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

 

문제

1부터 N까지의 수를 오름차순으로 쓴 수열 1 2 3 ... N을 생각하자.

그리고 '+'나 '-', 또는 ' '(공백)을 숫자 사이에 삽입하자(+는 더하기, -는 빼기, 공백은 숫자를 이어 붙이는 것을 뜻한다). 이렇게 만든 수식의 값을 계산하고 그 결과가 0이 될 수 있는지를 살피자.

N이 주어졌을 때 수식의 결과가 0이 되는 모든 수식을 찾는 프로그램을 작성하라.

입력

첫 번째 줄에 테스트 케이스의 개수가 주어진다(<10).

각 테스트 케이스엔 자연수 N이 주어진다(3 <= N <= 9).

출력

각 테스트 케이스에 대해 ASCII 순서에 따라 결과가 0이 되는 모든 수식을 출력한다. 각 테스트 케이스의 결과는 한 줄을 띄워 구분한다.

 


 

범위값이 9까지인 것을 본 순간 : 아 브루트포스네.

어짜피 9까지 밖에 연산이 없다. 연산자는 단 세 개 뿐. 플러스, 마이너스, 공백.

그렇다면 대충 계산해봐도 한 경우의 숫자 연산 라인을 계산할 때, 3의 8승, 즉 6561개이다.

 

1. DPS 재귀함수를 이용해서 공백 -> 플러스 -> 마이너스 순으로 조회한다.

(그 이유는 ASCII 순이기 때문이다. ASCII 코드로 공백(32), 더하기(43), 마이너스(45), 숫자1~9(49~57) 이다.)

2. 재귀함수가 다 돌 때마다 도출된 식을 계산한다.

3. 계산한 식이 0 이면 출력한다.

 

import java.io.*;
import java.util.*;

public class Main {

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

        int T = Integer.parseInt(br.readLine());
        while (T-- > 0) {
            int N = Integer.parseInt(br.readLine());
            backTracking(1, N, "1");

            System.out.println();
        }
    }

    static void backTracking(int current, int n, String answer) {
        if (current == n) {
            if (getTotal(answer) == 0)
                System.out.println(answer);
            return;
        }

        int next = current + 1;
        backTracking(next, n, answer.concat(" " + next));
        backTracking(next, n, answer.concat("+" + next));
        backTracking(next, n, answer.concat("-" + next));
    }

    private static int getTotal(String mathLine) {
        int result = 0;
        String[] plus = mathLine.split("\\+");
        int[] plusResults = new int[plus.length];
        for (int i=0; i< plus.length; i++) {
            plusResults[i] = calculateLine(parseIntArr(plus[i].split("-")), "-");
        }
        return calculateLine(plusResults, "+");
    }

    private static int calculateLine(int[] operator, String separator) {
        int result = 0;
        if (operator.length > 0) result = operator[0];
        for (int i=1; i< operator.length; i++) {
            if (separator.equals("-")) {
                result -= operator[i];
            } else {
                result += operator[i];
            }
        }

        return result;
    }

    private static int[] parseIntArr(String[] strArr) {
        return Arrays.stream(strArr).map(str -> str.replaceAll(" ", "")).mapToInt(Integer::parseInt).toArray();
    }
}

 

구현에서 좀 고민이 필요했다.

특정 식이 문자열만으로 주어졌을 때 어떻게 처리할 것인지에 대한것.

 

내일부터는 골드4로 업그레이드 할 예정이다.

 

 

CI/CD ?

  • CI : Continous Integration (지속적 통합)
  • CD : Continuous Deployment/Delivery (지속적 배포)

개발자가 코드를 더 빠르고 안정적으로 빌드, 테스트, 배포할 수 있도록 도와주는 소프트웨어 개발 프로세스 자동화 기법을 말한다. 개발자가 코드를 Push(서버에 올리면) 자동적으로 해당 코드를 빌드하고 테스트 해주는 과정을 일컫는다.

 

또한, 작은 코드의 변경 사항을 주기적으로 통합해서, 버그를 초기에 미리 잡고 품질을 유지하는 것을 목표로 한다. 다른 개발자의 코드와 나의 코드가 원활히 병합되는 것을 지켜보고, 만약 실패한다면 개발자에게 알림을 보내는 것이다.

 

이때 CD 의 개념이 두가지로 나뉘는데,

 

첫번째, Continuous Delivery 로서는

코드가 안정적으로 지속적이게 배포되는 것을 Pipeline 자동화라고 하는데, CI 이후에 배포 준비까지 완료를 하지만, 운영 서버의 경우 개발자의 승인(Approve)해야 프로덕션 배포가 진행된다.

 

두번째, Continous Deployment 로서는

CI/CD 의 완전 자동화 버전이라고 하는데, 모든 변경 사항이 자동으로 프로덕션(운영 서버에) 배포되는 것을 말한다. 개발자의 별도 승인 과정 없이 코드가 Push 되자마자 배포까지 자동적으로 일어난다. 다만, 이 경우 서버에 올린 코드의 테스트가 잘 되었어야 할 것이다.

 

즉, 코드를 빠르고 안전하게 빌드, 테스트, 배포하는 자동화 프로세스를 위해서 만들어진 개념이다.

CI/CD 파이프라인으로 GitHub Actions, GitLab CI/CD, Jenkins 등이 존재한다.


다음으로 구성 요소에 대해서 알아보자.

GitLab Runner

GitLab CI/CD 파이프라인을 실행하는 프로그램이다. GitLab 의 경우 직접 CI/CD 를 실행시키지 않고, GitLab Runner 가 독립적으로 작업을 수행한다.

 

GitLab 은 '.gitlab-ci.yml' 파일을 올려놓으면, 해당 스크립트를 실행해서 CI/CD 파이프라인을 실행하는데, 이 설정 파일을 실행하는 역할을 하는 것이 바로 GitLab Runner 이다.

 

GitLab 에는 Runner 가 기본적으로 내장되어 제공되지만, 자체 서버에 GitLab Runner 를 설치하여 운영할 수도 있다. 그 설치 방법은 다음과 같다.

// Bash

// GitLab Runner 설치
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install gitlab-runner

// GitLab Runner 등록
sudo gitlab-runner register

// GitLab Runner 실행
sudo gitlab-runner start

 

Pipeline

Git 저장소에 Push 될 때(코드가 변경되었을 때) 자동으로 실행되는 자동화 프로세스, 즉 작업 흐름을 말한다.

 

코드가 Push 되거나 Merge Request(MR)가 발생하면 실행된다. 하나의 파이프라인은 여러 개의 스테이지들(Stages)과 작업들(Jobs)로 이루어진다.

 

Stage

파이프라인의 작업 단계를 의미한다.

일반적으로 build(빌드) - test(테스트) - deploy(배포) 의 단계로 순차적으로 실행된다.

이전 스테이지가 성공해야 다음 스테이가 실행된다.

 

Job

특정 스테이지에서 실행되는 개별 작업을 의미한다.

각 작업들은 특정 스크립트를 실행하고 결과를 반환한다.

잡들은 같은 스테이지 내에서 병렬적으로 수행된다.

 

예시

stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "🔨 빌드 실행 중..."
    - mvn package

test-job:
  stage: test
  script:
    - echo "🛠️ 테스트 실행 중..."
    - mvn test

deploy-job:
  stage: deploy
  script:
    - echo "🚀 배포 중..."

 

이제 본격적으로 어떻게 GitLab 에서 CI/CD 를 구현할 수 있을지 단계적으로 알아보자.

 

GitLab Runner 등록 (gitlab-runner register)

1. GitLab Instance URL 입력

이는 깃랩이 어디에서 운영되고 있느냐에 따른 서버 주소를 지정해주는 것이다.

 

GitLab 공식 사이트를 사용할 경우는 https://gitlab.com 을 작성해주면 되고,

기업/조직에서 자체적인 서버에서 호스팅할 경우에는 해당 도메인을 작성해주면 된다.

 

즉, GitLab 이 실행되고 있는 서버의 URL 을 입력하면 된다.

 

2. 등록 토큰(Registration Token) 부여

이는 GitLab Runner 가 특정 GitLab 프로젝트 또는 그룹에 등록되려면 필요한 인증 정보같은 개념이다. 해당 토큰이 등록되어야 Runner 가 특정 프로젝트에 연결될 수 있다.

 

.gitlab-ci.yml 파일 추가

이 파일이 가장 핵심적이라고 할 수 있다. 이 설정 파일에 작성된 대로 파이프라인이 실행되기 때문이다.

 

다음과 같이 브랜치를 분리하여, 개발/운영 서버가 다른 브랜치에서 실행하도록 설정할 수도 있다.

 

다음 코드는

build 작업은 main 브랜치에서 운영 서버 1 - aws(Runner) 에서만,

deploy 작업은 main 브랜치에서 운영 서버 2 - 특정 서버(Runner) 에서만,

test 작업은 dev 브랜치에서 개발 서버 - 특정 서(Runner) 에서만 실행되도록 설정한 코드이다.

stages:
  - build
  - deploy

build-job:
  stage: build
  script:
    - echo "빌드 실행 중..."
  only:
    - main
  tags:
    - aws  # 운영 서버에서 실행

deploy-job:
  stage: deploy
  script:
    - echo "AWS 운영 서버에 배포 중..."
  only:
    - main
  tags:
    - production  # 운영 서버에서 실행

test-job:
  stage: test
  script:
    - echo "개발 서버에서 테스트 실행 중..."
  only:
    - dev
  tags:
    - dev  # 개발 서버에서 실행

 

 

실행 로그 확인

생각보다 단순하쥬?

'DevOps > CICD' 카테고리의 다른 글

[GitLab CI/CD] Blue-Green 배포 방식  (0) 2025.03.10
[GitLab] 깃허브와 다른점이 뭘까?  (0) 2025.02.01

GitHub, 전 세계적으로 인기있는 소스 코드 VCS

VCS는 Version Control System; 버전 관리 시스템을 말한다.

전 세계 대부분의 개발자들이 이 저장소를 가지고 있고, 협업 시에 활용하곤 한다.

Fork 와 FullRequest 가 주된 협업 방식으로 보여지는 플랫폼이다.

 

그렇다면 GitLab은 뭘까?

보안 이슈, 클라우드보다는 온-프레미스로 관리하고 싶어.

GitLab 은 특히 기업에서 많이 쓰인다. GitHub 에 비해 폐쇄적인 느낌이 있다.

 

GitHub 는 중앙 저장소를 두고, 클라우드 기반으로 운영된다. 그러나 기업이나 정부 기관의 경우는 공개된 클라우드에 회사의 자산을 함부로 저장하기에는 망설여지는 부분이 많았을 것이다. 이러한 요구에 맞춰서 나타난게 GitLab 이다. GitLab 은 회사가 소유한 서버에 저장소를 운영할 수 있다. 

자체적인 CI/CD 제공

GitLab 은 CI/CD 가 내장되어 있다. GitHub 의 경우에도 GitHub Actions 가 존재하지만, 따로 설정이 필요하다. 그러나 GitLab 은 애초에 처음부터 CI/CD 가 가능한 것이다. 마치 SpringBoot 에 톰캣 서버가 내장되어 있는 느낌인가..

 

버전관리 + CI/CD + 셀프 호스팅이 가능한 엔터프라이즈 DevOps 플랫폼

GitHub 가 버전관리와 오픈소스 협업에 있어서 정체성을 가지고 있다면,

GitLab 은 좀더 기업적이고 보안적인 관점이 크다.

'DevOps > CICD' 카테고리의 다른 글

[GitLab CI/CD] Blue-Green 배포 방식  (0) 2025.03.10
[GitLab] 내장 CI/CD 사용방법  (1) 2025.02.01

0. 준비작업

이전 포스팅

 

1편 - IdP 구성

https://jinn-o.tistory.com/321

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정

0. 준비작업먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0] Security Assertion Markup LanguageSAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들

jinn-o.tistory.com

 

2편 - SP 구성 : SpringBoot편

https://jinn-o.tistory.com/322

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (2) - SP 설정 : SpringBoot 편

0. 준비작업이전 포스팅https://jinn-o.tistory.com/321 [SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0

jinn-o.tistory.com

 

 

먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

해당 포스팅에서 SP-React를 구현해 놓은 코드는 다음 Github 에 올려놓았다.

 

https://github.com/SMJin/React-SAML

 

GitHub - SMJin/React-SAML: React + Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축

React + Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축 - SMJin/React-SAML

github.com

 

 

 

 

본격적으로 시작해보자.

1. 사전정보

Keycloak 서버 http://localhost:8081
SpringBoot 서버 http://localhost:8080
React 서버 http://localhost:3000

 

 

2. React Project 만들기

npx create-react-app saml-auth-app
cd ./saml-auth-app
npm start // 서버실행

 

webvitals 에러가 뜨면 깔끔하게 해당 코드 태그와 js 파일을 지워준다.

 

 

3. 로그인 버튼 컴포넌트 생성

const LoginButton = () => {
    const handleLogin = () => {
      window.location.href = "http://localhost:8080/saml/login"; // Spring Boot의 로그인 API 호출
    };
  
    return <button onClick={handleLogin}>Login with SAML</button>;
  };
  
  export default LoginButton;

 

로그인 컴포넌트에 로그인 버튼은 Spring Boot 의 SAML 로그인 API 를 호출하도록 연결해둔다.

 

 

3. 사용자 프로필 보여줄 컴포넌트 생성

import { useEffect, useState } from "react";

const UserProfile = () => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("http://localhost:8080/saml/user", {
      credentials: "include", // 세션 정보 유지
    })
      .then((res) => res.json())
      .then((data) => setUser(data))
      .catch((err) => console.error(err));
  }, []);

  return (
    <div>
      {user ? (
        <div>
          <h2>Welcome, {user.username}!</h2>
          <p>Email: {user.email}</p>
        </div>
      ) : (
        <p>Not logged in</p>
      )}
    </div>
  );
};

export default UserProfile;

 

사용자 프로필을 보여주는 화면에서는,

세션 서버에서 사용자 정보를 가져오는 SpringBoot API 를 연결해둔다.

또한, 세션 정보가 유지되어야 하므로, credentials: include 를 헤더에 추가해주는 것을 잊지말자!

 

 

4. 메인 App.js 에 생성한 컴포넌트들을 추가해준다.

import './App.css';
import LoginButton from './components/LoginButton';
import UserProfile from './components/UserProfile';

function App() {
  return (
    <div className="App">
      <h1>SAML Authentication with Keycloak</h1>
      <LoginButton />
      <UserProfile />
    </div>
  );
}

export default App;

 

 

5. 와 .. ~ 로그인 연동 성공

+ Recent posts