Spring Security는 쉽게 말해 보안 담당자이다.

사이트에 누가 로그인했는지 확인하고, 접근해도 되는 페이지인지 판단해주는 경비원 역할을 한다. 

 

기본 기능만 사용해도 로그인/로그아웃, 세션 관리 등이 된다. 하지만 실무에서 사용하려면 100% 커스터마이징이 필수이다.

 

왜 커스터마이징이 필요할까?

요구사항 기본 Spring Security로 가능?  해결 방법
JWT 인증으로 로그인하고 싶다 커스텀 필터 필요
로그인 실패 시 로그 찍고 싶다 핸들러 오버라이드
/admin/** 경로는 관리자만 보게 하고 싶다 hasRole() 설정 필요
인증 실패 응답을 JSON으로 주고 싶다 AuthenticationEntryPoint 등록

 

즉, 기능은 있지만, 내가 원하는 방식은 아닐 것이다. 그래서 진짜 실무에선 필터, 핸들러, 서비스 다 바꿔서 쓰는게 기본이다.

 

 

실무에서 자주 쓰는 Spring Security 커스터마이징 TOP 5 에 대해서 알아보자

 

 

1. JWT 인증 필터 추가하기

Spring Security는 기본적으로 세션 기반인데, 요즘은 대부분 JWT 기반을 사용한다.

 

구현 흐름 : 

  1. UsernamePasswordAuthhenticationFilter 이전에 커스텀 필터 등록
  2. 요청 헤더에서 JWT 호출 → 유효성 검사 → 인증객체 생성
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) {
        String token = request.getHeader("Authorization");
        if (token != null && jwtUtil.validateToken(token)) {
            Authentication auth = jwtUtil.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }
}

 

등록하기

http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

 

 

 

2. 로그인 성공/실패 핸들러 커스터마이징

로그인 성공/실패 시, 특정 페이지로 리디렉션하거나 로그를 남기고 싶을 때 사용한다.

@Component
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        System.out.println("로그인 성공: " + authentication.getName());
        response.sendRedirect("/dashboard");
    }
}

 

등록은 .successHandler() / .failuerHandler() 에서 하면 된다.

 

 

 

3. URL 별로 접근 권한 다르게 설정

기본 설정만으로는 URL마다 세세한 권한 제어가 어렵다.

http.authorizeHttpRequests()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/user/**").authenticated()
    .anyRequest().permitAll();

 

  • hasRole("ADMIN") : 관리자만 접근 가능
  • authenticated() : 로그인한 사람만 가능
  • permitAll() : 아무나 접근 가능

 

 

4. 사용자 인증 커스터마이징

로그인 시 사용자를 어떻게 검증할지 우리가 직접 정할 수 있다.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        Member member = memberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));
        return new User(member.getEmail(), member.getPassword(),
                List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole())));
    }
}

 

등록은 Security 설정에서 이렇게 한다.

.auth.userDetailsService(customUserDetailsService)

 

 

 

 

5. 예외 응답을 JSON으로 주기

기본적으로 인증 실패하면 403 페이지가 뜬다. API 서버는 JSON으로 에러 응답을 줘야 한다.

 

* 인증 실패(401 Unauthorized)

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"인증이 필요합니다\"}");
    }
}

 

* 권한 없음(403 Forbidden)

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"접근 권한이 없습니다\"}");
    }
}

 

등록은 마찬가지로 Security 설정에서 한다.

http.exceptionHandling()
    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .accessDeniedHandler(jwtAccessDeniedHandler);

Bean이라는 말, 대체 뭘까?

Bean은 Spring이 대신 만들어주고 관리해주는 Java 객체(물건) 이다.

 

예를 들어보자.

 

엄마가 아침에 계란후라이를 만들어줬다고 가정해보자. 계란후라이는 내가 직접 만들 수도 있지만, 엄마가 미리 만들어 놓으면 내가 그냥 꺼내먹을 수 있을것이다.

 

Spring Bean도 똑같다. 

Java에서는 우리가 객체를 new 키워드를 사용해 직접 만들어야 하는데, Spring은 객체를 미리 만들어서 냉장고처럼 보관해놓는다.

 

 

그럼 Bean은 왜 필요할까?

프로그래밍에서는 많은 객체들이 서로 협력을 통해 작동을한다.

 

예로 커피 앱을 만들었다고 가정해보면

  • 커피를 내리는 객체
  • 주문을 받는 객체
  • 알림을 보내는 객체

등이 있을것이고 이 객체들을 하나하나 new로 만들고 연결하고 관리가 필요한데 이건 너무 복잡하고 비효율적일 것이다.

 

그래서 Spring이 "내가 다 해줄게~" 라고 해서 Bean을 대신 만들어주고, 서로 연결까지 해준다.

 

 

Bean은 어떻게 만들까?

방법1. @Component 스티커 붙이기

 

@Component
public class CoffeeMachine {
    public String make() {
        return "커피 나왔어요!";
    }
}

 

이렇게 @Component 라는 표시를 붙이면, Spring이 알아서 이 클래스를 Bean으로 만들어서 보관해준다.

 

 

방법2. @Bean 사용하기(조금 더 수동적)

@Configuration
public class AppConfig {
    @Bean
    public CoffeeMachine coffeeMachine() {
        return new CoffeeMachine();
    }
}

 

이건 프로그래머가 직접 등록하고 싶은 경우이다.

 

 

이렇게 생성한 Bean은 어떻게 사용할까?

Bean은 Spring이 만들어서 저장해 놓은 객체라고 했다. 우리는 그 Bean을 그냥 요청만 하면 된다.

@Component
public class Cafe {
    private final CoffeeMachine machine;

    public Cafe(CoffeeMachine machine) {
        this.machine = machine;
    }

    public void serve() {
        System.out.println(machine.make());
    }
}

 

Spring이 자동으로 CoffeeMachine을 Cafe에 딱 끼워 넣어준다. 이걸 의존성 주입(Dependency Injection)이라고 부른다.

 

 

Bean의 특징 3가지

특징 설명
자동으로 만들어진다 @Component@Bean 으로 표시만 하면 끝!
하나만 만들어진다 기본적으로 Bean은 하나만 만들어진다 (Singleton)
서로 연결이 가능하다 하나의 Bean이 다른 Bean을 사용할 수 있다

 

 

 

Bean이 없는 상황을 한번 살펴보면 편리함이 더 잘 느껴질것 같다.

public class App {
    public static void main(String[] args) {
        CoffeeMachine machine = new CoffeeMachine();
        Cafe cafe = new Cafe(machine);
        cafe.serve();
    }
}

 

이렇게 직접 new로 다 만들어야 한다. 코드가 길어지고, 테스트하기도 어렵고, 관리도 힘들어진다.

 

 

의존성? 의존성이 뭘까?

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

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

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

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

 

그렇다면 의존성 주입(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. 스프링이 자동으로 객체를 관리 ->  개발자가 직접 객체를 만들 필요가 없다.

 

회사에서 일을 하면서 실제로 대규모 트래픽을 경험해 볼 수는 없다. 끽해야 일일 접속자가 350명 정도..? 사내 서비스라 이용자가 너무 적다. 

 

여러 구인구직 공고를 보면 대부분 대규모 트래픽을 구축해보거나 경험해본 사람들을 우대하는 것을 보고 나 스스로 대규모 트래픽에 관해 여러 문서나 사례등을 바탕으로 대규모 트래픽을 처리하는 방법에 대해 많이 공부해야 된다고 느꼈다.

 

이 글 하나만 읽어도 대규모 트래픽 환경에서 어떻게 안정적인 시스템을 설계할 수 있는지 확실하게 정리하고자 한다.(나처럼 실제 트래픽을 경험해 본 적이 없더라도 말이다..)

 

 

1. 대규모 트래픽이란?

대규모 트래픽은 한꺼번에 수많은 사용자가 웹사이트나 애플리케이션에 접속해 데이터를 요청하는 상황을 말한다. 예를 들어, 쇼핑몰의 대규모 세일 기간이나, 로또 청약 신청, 공연 티켓 예매등 평소보다 엄청난 사용자가 몰리는 경우를 의미한다.

 

이런 상황에서는 제대로 준비가 되어있지 않다면 서버가 과부화되어 느려지거나 다운될 위험이 있기 때문에, 효율적인 시스템 설계가 매우매우 중요하다.

 

 

대규모 트래픽 환경에서는 다음과 같은 문제들이 발생할 수 있다.

 

2. 대규모 트래픽이 주는 도전 과제

  • 응답 속도 유지 : 수많은 요청이 동시에 들어와도 빠르게 응답해야 한다.
  • 서버 과부화 방지 : 한 서버에 모든 요청이 몰리면 서버가 다운될 수 있으므로, 부하를 적절히 분산시켜야 한다.
  • 데이터 일관성 유지 : 여러 서버가 동시에 데이터를 처리할 때, 데이터의 정확성과 일관성을 유지하는 것이 필수적이다.
  • 확장성 : 사용자 수가 급증해도 시스템이 원활하게 동작하도록 쉽게 확장할 수 있어야 한다.

 

그렇다면 위의 과제들을 해결하면서 어떻게 하면 효율적인 시스템을 설계할 수 있을까?

 

3. 효율적인 시스템 설계 전략

3-1. 수평 확장(Horizontal Scaling)

수평 확장의 개념은 한 서버의 성능을 높이는 대신, 여러 대의 서버를 추가하여 전체 트래픽을 분산시키는 방법이다. 비유하자면 한 교실에 너무 많은 학생이 모이면 선생님이 수업을 제대로 진행하기 어려우니, 여러 교실로 나누어 진행하는 것과 같다.

 

Spring Boot/Kotlin을 사용하는 백엔드 서버를 예시로 들어보자 (모든 예시는 Spring Boot 프레임워크와 Koltin을 사용하는 백엔드이다.)

 

수평 확장은 보통 클라우드 환경(AWS, GCP, Azure)이나 컨테이너 오케스트레이션(Kubernetes)과 함께 사용한다. Spring Boot 애플리케이션은 별도의 추가 설정 없이도 여러 인스턴스를 띄우면 수평 확장이 가능하다.

 

더보기

* 관련해서 더 알아보아야 하는 것 : Docker를 이용해 여러 개의 Spring Boot 컨테이너를 실행하고, Kubernetes를 사용하여 Auto Scaling 기능을 적용하는 방법을 찾아보자.

 

3-2. 부하 분산(Load Balancing)

부하 분산은 사용자 요청을 여러 서버에 골고루 분산시켜 한 서버에 부담이 집중되지 않도록 하는 기술이다. 음식점에서 한 웨이터가 모든 손님을 상대하기 어려우니, 여러 웨이터가 각 테이블을 나누어 케어하는 것과 같다.

 

Spring Boot 애플리케이션은 외부 로드 밸런서(Nginx, HAProxy, AWS ELB 등)와 함께 사용하여 부하 분산을 쉽게 구현할 수 있다.

직접 로드 밸런싱 코드를 작성하는게 아니라 로드 밸런서 설정 파일을 통해 서버 간 트래픽 분산을 관리한다.

 

Nginx 설정 일부 예시 :

upstream spring_backend {
    server 192.168.1.101:8080;
    server 192.168.1.102:8080;
    server 192.168.1.103:8080;
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://spring_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

 

 

3-3. 캐싱(Caching)

캐싱은 자주 사용되는 데이터를 미리 저장해 두어, 데이터베이스나 다른 서버에 매번 접근하지 않고 빠르게 응답하는 기술이다. 냉장고에 좋아하는 간식을 미리 저장해두면, 매번 마트에 가지 않고도 간식을 즐길 수 있는 걸 생각하면 비슷하다.

 

Spring Boot는 Redis와 같은 캐시 솔루션과 쉽게 통합할 수  있다.

 

Redis 캐시 설정 및 사용 예시 : 
1. build.gradle.kts에 Redis 의존성 추가

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    // ... 기타 의존성
}

 

2. applicaiton.properties에 Redis 설정 추가

# Redis 서버 설정 (로컬에서 Redis가 실행 중임을 가정합니다)
spring.redis.host=localhost
spring.redis.port=6379

# 기본 서버 포트 (8080번 포트로 실행)
server.port=8080

 

3. Kotlin 코드 예제 - 캐시 서비스 구현

 

CacheService.kt

package com.example.demo.service

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit

/**
 * CacheService 클래스는 Redis를 이용하여 캐시 기능을 제공하는 서비스이다.
 * 이 클래스에서는 데이터를 캐시에 저장(setCache)하고, 조회(getCache)하는 기능을 구현한다.
 */
@Service
class CacheService(@Autowired val redisTemplate: RedisTemplate<String, String>) {

    /**
     * setCache 함수는 지정된 key와 value를 캐시에 저장합니다.
     * timeout은 캐시에 데이터가 유지될 시간(초)입니다.
     */
    fun setCache(key: String, value: String, timeout: Long = 60) {
        // opsForValue()는 단순한 key-value 캐싱에 사용됩니다.
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)
    }

    /**
     * getCache 함수는 지정된 key에 해당하는 캐시된 값을 반환합니다.
     * 만약 캐시에 값이 없으면 null을 반환합니다.
     */
    fun getCache(key: String): String? {
        return redisTemplate.opsForValue().get(key)
    }
}

 

AsyncService.kt

package com.example.demo.service

import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service

/**
 * AsyncService 클래스는 비동기 작업을 처리하기 위한 서비스이다.
 * @Async 어노테이션을 사용하여, 이 클래스의 메서드가 호출될 때 즉시 반환되고,
 * 별도의 스레드에서 작업을 수행하게 된다.
 */
@Service
class AsyncService {

    /**
     * doAsyncWork 함수는 3초 동안 대기한 후 "비동기 작업 완료!" 메시지를 콘솔에 출력한다.
     * 이 함수는 @Async 어노테이션 덕분에 호출 즉시 비동기적으로 실행된다.
     */
    @Async
    fun doAsyncWork() {
        // 3초간 대기하여, 긴 작업을 비동기적으로 처리하는 예제를 시뮬레이션한다.
        Thread.sleep(3000)
        println("비동기 작업 완료!")
    }
}

 

 

DemoController.kt

package com.example.demo.controller

import com.example.demo.service.AsyncService
import com.example.demo.service.CacheService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * DemoController는 REST API를 제공하는 컨트롤러이다.
 * 이 컨트롤러는 캐시 기능과 비동기 작업 기능을 테스트하기 위한 API를 제공한다.
 */
@RestController
class DemoController(
    val cacheService: CacheService,  // CacheService를 주입받아 캐시 기능을 사용한다.
    val asyncService: AsyncService   // AsyncService를 주입받아 비동기 작업을 수행한다.
) {

    /**
     * /ping API는 캐시에서 "greeting" 키의 값을 조회하고,
     * 값이 없다면 "pong"을 캐시에 저장한다.
     * 동시에 비동기 작업을 실행하고, "pong"을 응답한다.
     */
    @GetMapping("/ping")
    fun ping(): String {
        val key = "greeting"
        // 캐시에서 key "greeting"의 값을 가져온다.
        var value = cacheService.getCache(key)
        // 만약 캐시에 값이 없으면,
        if (value == null) {
            value = "pong"
            // 캐시에 60초 동안 "pong" 값을 저장한다.
            cacheService.setCache(key, value, 60)
        }
        // 비동기 작업을 실행한다.
        // 이 작업은 백그라운드에서 3초 후 완료된다.
        asyncService.doAsyncWork()
        // "pong"을 응답으로 반환한다.
        return value
    }
}

 

 

3-4. 비동기 처리와 큐

사용자의 요청을 즉시 처리하지 않고, 큐에 저장한 후 차례대로 처리하는 방식이다. (인터파크에서 앞에 몇명이 남았다고 알려주는게 큐를 사용해서 그런게 아닐까?) 놀이공원에서 사람들이 대기열에 서 있다가 순서대로 놀이기구를 타는 것처럼, 요청을들 순차적으로 처리한다.

 

@Async 어노테이션을 사용하면 쉽게 비동기 작업을 구현할 수 있다. 

간단한 예제를 보면

// AsyncService.kt
package com.example.demo.service

import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service

/**
 * AsyncService는 긴 작업을 비동기적으로 처리하는 서비스이다.
 * @Async 어노테이션을 사용하여, 이 메서드가 호출되면 별도의 스레드에서 실행된다.
 */
@Service
class AsyncService {

    /**
     * doAsyncWork 함수는 3초간 대기 후 콘솔에 "비동기 작업 완료!" 메시지를 출력한다.
     */
    @Async
    fun doAsyncWork() {
        // 3000 밀리초 (3초) 동안 대기한다.
        Thread.sleep(3000)
        println("비동기 작업 완료!")
    }
}

 

 

비동기에 대해서 감이 안올수 있다. Controller에서 비동기를 호출 후 처리하는 간단한 예제를 살펴보자

// DemoController.kt
package com.example.demo.controller

import com.example.demo.service.AsyncService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * DemoController는 간단한 REST API를 제공하여, 비동기 작업을 테스트할 수 있게 한다.
 */
@RestController
class DemoController(val asyncService: AsyncService) {

    /**
     * /asyncTest 경로를 호출하면, 비동기 작업이 실행되고 즉시 응답을 반환
     */
    @GetMapping("/asyncTest")
    fun asyncTest(): String {
        asyncService.doAsyncWork()  // 비동기 작업 실행
        return "비동기 작업이 시작되었습니다!"
    }
}

 

위 코드가 어떻게 동작할까? 사용자가 /asyncTest API를 호출하면, 서버는 즉시 "비동기 작업이 시작되었습니다!" 라는 응답을 반환하고, 백그라운드에서 3초 후 "비동기 작업 완료!" 메세지를 콘솔에 출력한다. 순서대로 실행되는게 아니다.

 

Spring Boot와 Kotlin에서 간단한 메시지 큐를 구현해보자(RabbitMQ 사용)

 

1. build.gradle.kts에 의존성 추가

dependencies {
    // RabbitMQ와 Spring Boot 연동을 위한 의존성
    implementation("org.springframework.boot:spring-boot-starter-amqp")
    // 기타 의존성...
}

 

2. application.properties에 RabbitMQ 설정

# RabbitMQ 서버 설정 (기본적으로 로컬에서 실행 중이라고 가정)
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672

 

메세지 큐 서비스 구현

// MessageQueueService.kt
package com.example.demo.service

import org.springframework.amqp.rabbit.annotation.RabbitListener
import org.springframework.amqp.rabbit.core.RabbitTemplate
import org.springframework.stereotype.Service

/**
 * MessageQueueService는 RabbitMQ를 사용하여 메시지 큐를 통한 비동기 처리를 구현한 서비스
 */
@Service
class MessageQueueService(val rabbitTemplate: RabbitTemplate) {

    /**
     * sendMessage 함수는 지정한 메시지를 "queue.tetris" 큐로 전송한다.
     */
    fun sendMessage(message: String) {
        rabbitTemplate.convertAndSend("queue.tetris", message)
    }

    /**
     * receiveMessage 함수는 "queue.tetris" 큐를 구독하여 메시지를 수신한다.
     * 메시지가 수신되면 콘솔에 출력하고, 필요한 추가 처리를 수행할 수 있다.
     */
    @RabbitListener(queues = ["queue.tetris"])
    fun receiveMessage(message: String) {
        println("메시지 수신: $message")
        // 메시지 처리 로직을 여기에 작성할 수 있다.
    }
}

 

위에서 만든 메시지 큐를 활용하는 Controller 를 만들어보자.

// QueueController.kt
package com.example.demo.controller

import com.example.demo.service.MessageQueueService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

/**
 * QueueController는 메시지 큐를 통해 작업을 비동기적으로 처리하는 API를 제공
 */
@RestController
class QueueController(val messageQueueService: MessageQueueService) {

    /**
     * /queueTest 경로를 호출하면, "테트리스 블럭 이동"과 같은 작업 메시지를 큐에 전송한다.
     */
    @GetMapping("/queueTest")
    fun queueTest(): String {
        // 메시지 큐에 메시지를 전송한다.
        messageQueueService.sendMessage("테트리스 블럭 이동")
        return "메시지가 큐에 전송되었습니다!"
    }
}

 

사용자가 /queueTest API를 호출하면 메시지 "테트리스 블록 이동"이 RabbitMQ 큐에 전송되고, 해당 큐를 구독 중인 receiveMessage  메서드가 메시지를 수신하여 처리한다.

 

 

전체 아키텍쳐 다이어그램을 살펴보면

 

이런 식으로 표현할 수 있다. 사용자의 요청이 로드 밸런서를 통해 여러 서버에 분산되고, 각 서버는 캐싱, 비동기 처리. 메시지 큐, 데이터베이스를 활용해 안정적으로 응답을 제공하는 구조를 보여준다.

 

뭔가 부족한것 같다. 앞으로 각각의 자세한 내용을 더 공부해야겠다.

우선 로그란 컴퓨터 프로그램이 실핸되는 동안 일어나는 사건이나 상태를 기록한 기록부라고 생각하면 된다.

예를 들어, 사람이 일기를 쓰고 나중에 일기장으로 그날 하루를 돌아보듯 프로그램도 "어떤 일이 일어났는지"를 기록한다. 

 

로그 관리는 문제를 빠르게 찾아내거나, 시스템. 상태를 확인할 때 매우 중요하다.

 

Gradle을 이용한 프로젝트 설정 (Gradle로 개발을 하고 있기 때문에 Gradle에 대해서 작성)

더보기
더보기

Gradle에 대해 모르거나 까먹었을 수 있다. 

Gradle은 프로젝트를 빌드(컴파일, 테스트, 배포 등) 하는 도구이다.  그렇다면 또 빌드에 대해서 헷갈릴 수 있다. 빌드란? 소스코드 파일을 컴퓨터에서 실행할 수 있는 독립적인 형태로 변환하는 과정과 결과를 말한다. 즉, 개발자가 작성한 소스코드, 각각의 파일 자원 ( .xml, .jpa, .jpg, .properties)을 jvm이나 톰캣 같은 WAS가 인식할 수 있도록 패키징하는 과정 및 결과물을 빌드라고 한다.

 

다시 돌아와서 Gradle은 스프링 부트와 안드로이드에서 사용되며 빌드 속도가 Maven에 비해 10 ~ 100배 정도 빠르며, Java, C/C++, Python 등을 지원한다.

 

Gradle의 특징으로는 

1. 가독성이 좋다.

2. 재사용에 용이

3. 구조적인 장점

4. 편리함

5. 멀티 프로젝트

등이 있다. 

 

로그 관리를 위해서는 Spring Boot의 로깅 기능(기본적으로 Logback)을 사용한다.

build.gradle 파일에 아래와 같이 의존성을 추가한다.

plugins {
    id("org.springframework.boot") version "2.7.5" // Spring Boot 버전
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot 기본 기능 (웹, 로깅 등)
    implementation("org.springframework.boot:spring-boot-starter-web")
    
    // Spring Boot AOP, 로깅 관련 기능 (Logback은 기본 포함)
    implementation("org.springframework.boot:spring-boot-starter-aop")
    
    // Kotlin 관련 의존성
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

 

 

로그 저장 : Logback과 파일 관리

Logback과 SLF4J에 대해 먼저 알아보자

  • SLF4J는 여러 로깅 라이브러리(Logback, Log4 J 등)를 추상화하여 사용하는 인터페이스이다.
  • Logback 은 Spring Boot에서 기본적으로 사용하는 로깅 프레임워크이다. Logback은 로그를 파일에 저장하거나 콘솔에 출력하는 등의 기능을 제공한다.

 

로그 설정 파일 : logback-spring.xml

Spring Boot 에서는 src/main/resource 폴더 안에 logbak-spring.xml 파일을 만들면, 로깅의 형식, 저장 위치, 파일 회전(일정 시간마다 새 파일로 저장)등을 설정할 수 있다.

<configuration>
    <!-- FILE appender: 로그를 파일에 저장 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 로그 파일 저장 위치 -->
        <file>logs/app.log</file>
        <!-- 로그 파일 회전 정책: 매일 새 파일 생성하고, 30일간 보관 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <!-- 로그 출력 형식: 날짜, 스레드, 로그 레벨, 로거 이름, 메시지 -->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 콘솔에도 로그 출력 (옵션) -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 루트 로거 설정: 기본 로그 레벨을 INFO로 설정 -->
    <root level="INFO">
        <appender-ref ref="FILE" />
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

 

위 설정은 

  • logs/app.log 라는 파일에 로그를 기록한다.
  • 매일 새로운 로그 파일을 만들고, 30일 지난 로그는 삭제한다
  • 콘솔에도 로그가 출력되어, 개발 중에 쉽게 확인할 수 있다.

이렇게 이해하면 된다.

 

 

위와 같이 저장한 로그 파일에서 로그를 조회하기 위한 방법은 몇 가지가 있다.

(1) 파일 탐색기나 텍스트 에디터 사용

  • 로그 파일을 텍스트 에디터(VS Code, Notepad++ 등)로 열어 직접 내용을 확인하는 방법

(2) 터미널 명령어 사용

  • tail : 최신 로그 및 몇 줄을 확인할때 사용한다. ( 예 : tail -f app.log )
  • grep : 특정 키워드(ERROR, 특정 메서드 이름)를 검색할 때 사용한다. ( 예 : grep "ERROR" logs/app.log )

 

 

중앙 집중식 로그 관리 시스템 

ELK 스택 : Elasticsearch, Logstash, Kibana 를 사용하면, 웹 인터페이스에서 로그를 필터링 하고 조회할 수 있따.

이 방법은 여러 서버의 로그를 한 곳에서 모아 볼 때 유용하다.

 

ELK 스택의 구성

  • Elasticsearch: 로그 데이터를 저장하고, 빠르게 검색할 수 있는 데이터베이스 역할을 합니다.
  • Logstash/Filebeat: 여러 서버에서 로그 파일을 수집하여 Elasticsearch로 전송합니다.
  • Kibana: Elasticsearch에 저장된 로그 데이터를 시각화하고, 웹 인터페이스에서 검색할 수 있도록 해 줍니다.

ELK 스택의 장점

  • 중앙 집중식 조회: 모든 서버의 로그를 한 곳에서 볼 수 있습니다.
  • 실시간 검색 및 필터링: 원하는 키워드나 시간대에 맞춰 로그를 빠르게 검색할 수 있습니다.
  • 대시보드: 시각화 도구를 사용해 서버 상태를 모니터링하고 문제를 빠르게 파악할 수 있습니다.

 

추후에 중앙 집중식 로그 관리 시스템에 대해서 정리가 필요할 것 같다.

AOP란 무엇일까?

AOP(Aspect-Oriented Programming)는 관점 지향 프로그래밍이라고 부른다. 너무 어려운데 풀어서 설명하면 프로그램의 핵심 로직과 공통 기능(예 : 로깅, 보안, 트랜잭션 관리)을 분리하여 작성할 수 있도록 도와주는 것이라고 보면 된다.

 

즉, 핵심 비즈니스 로직에 영향을 주지 않으면서도 여러 곳에서 반복되는 기능을 한 곳에 모아서 관리할 수 있게 해준다(너무 좋은것 아닌가?)

 

더보기
더보기

더 쉽게 예를 들어 학교에서 학생들이 공부하는 주요 수업(핵심 로직)이 있다고 해보자. 그런데, 시험 감독, 출석 체크, 학부모 연락 등과 같은 공통 작업도 있는데, 이 작업들은 모든 수업에 걸쳐 반복된다. 

AOP는 이처럼 "공통 작업(예 : 시험 감독)" 을 한 곳에서 관리하고, 각 수업(비즈니스 로직)에서는 신경 쓰지 않도록 분리하는 역할을 한다.

 

AOP의 주요 개념

  1.  Aspect (관점) : AOP의 핵심 모듈로, 여러 곳에 적용할 공통 기능을 모아둔 단위이다. 예를 들어, 로깅 Aspect는 애플리케이션 전반에 걸쳐 로그를 기록하는 기능을 한 곳에 모아서 관리한다.
  2.  Join Point (조인 포인트) : Aspect가 적용될 수 있는 지점을 의미한다. 메서드 호출, 예외 발생 등이 Join Point가 될 수 있다.
  3.  Pointcut (포인트컷) : 어떤 Join Poin에 Aspect(Advice)를 적용할 것인지 정하는 조건이다. "어떤 메서드가 호출될 때마다 로그를 남겨라"와 같은 조건을 정의한다.
  4.  Advice (어드바이스) : 실제로 수행되는 작업이다. 
  5.  Weaving (위빙) : Aspect를 실제 코드에 적용하는 과정이다. Srping AOP는 런타임(실행 시)에 동적으로 위빙을 수행하여, 개발자가 별도로 공통 기능 코드를 삽입하지 않아도 자동으로 적용된다.

 

Spring Boot 에서 Spring AOP 설정

별도의 XML 설정 없이, spring-boot-stater-aop 의존성을 추가하기만 하면 AOP 기능을 사용할 수 있다.

build.gradle 예시

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter'
}

 

 

간단한 로깅 Aspect 예시

메서드 실행 전후에 로그를 남기는 간단한 예시

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component

@Aspect
@Component
class LoggingAspect {
    private val logger = LoggerFactory.getLogger(this.javaClass)

    // 모든 Service 패키지 내의 모든 메서드 실행 시점에 적용
    @Around("execution(* com.example.service.*.*(..))")
    fun logAround(joinPoint: ProceedingJoinPoint): Any? {
        val methodName = joinPoint.signature.name
        
        // 로그 출력 예시: "메서드 시작: getUserById"
        logger.info("메서드 시작: $methodName")  // 예시 로그: [INFO] 2025-02-26 12:34:56 - 메서드 시작: getUserById
        
        try {
            val result = joinPoint.proceed() // 실제 메서드 실행
            
            // 로그 출력 예시: "메서드 종료: getUserById"
            logger.info("메서드 종료: $methodName")  // 예시 로그: [INFO] 2025-02-26 12:34:57 - 메서드 종료: getUserById
            
            return result
        } catch (e: Throwable) {
            // 로그 출력 예시: "메서드 오류: getUserById, 예외: NullPointerException"
            logger.error("메서드 오류: $methodName, 예외: ${e.message}")  // 예시 로그: [ERROR] 2025-02-26 12:34:57 - 메서드 오류: getUserById, 예외: NullPointerException
            throw e
        }
    }
}

 

  • @Aspect: 이 클래스가 Aspect임을 선언한다.
  • @Component: Spring의 빈으로 등록하여 자동으로 관리한다.
  • @Around: 지정한 Pointcut(여기서는 com.example.service 패키지의 모든 메서드)에 대해, 메서드 실행 전후로 Advice를 실행한다.
  • joinPoint.proceed(): 실제 메서드 호출을 진행하는 부분으로, 이 전후로 로그를 남겨 전체 실행 흐름을 모니터링한다.

이렇게 작성한 Aspect는 지정된 패키지 내의 모든 메서드 실행 시 자동으로 로그를 남긴다. 

 

서비스 클래스 예시

package com.example.service

import org.springframework.stereotype.Service

@Service
class UserService {
    fun getUserById(id: Long): User {
        // 사용자 조회 로직...
        return User(id, "Alice")
    }
}

 

이때 getUserById 메서드가 호출되면, Aspect의 joinPoint.signature.name은 "getUserById"를 반환하게 되고, 로그에도 "메서드 시작: getUserById"와 같이 출력된다.

 

하지만 이런 AOP를 사용시 주의할 점이 있다.

 

  • 과도한 사용 주의 : 너무 많이 사용하면 코드의 흐름을 파악하기 어려울 수 있다.
  • 디버깅 어려움 : AOP에 의해 동적으로 적용된 로직은 디버깅 시 실제 코드 흐름을 파악하기 어렵게 만들 수 있다.
  • 성능 고려 : Aspect가 많은 경우, 메서드 호출 전후에 추가적인 처리 시간이 발생할 수 있으니 성능에 미치는 영향을 고려해야한다.

 

 

Spring Boot는 빠르게 프로토타입을 만글고, 반복적인 설정 작업 없이 비즈니스 로직에 집중할 수 있게

Auto Configuration (자동 구성) 기능을 제공한다.

 

한번 Spring Boot Auto Configuration 기본 개념과 동작 원리, 활용예제에 대해서 알아보자.

 

Auto Configuration이란 무엇인가?

Auto Configuration은 개발자가 매번 복잡한 설정 파일이나 보일러플레이트 코드를 작성하지 않아도, 애플리케이션이 실행될 때 필요한 설정을 자동으로 구성해주는 기능이다. Spring Boot는 클래스패스에 포함된 라이브러리와 프로젝트 설정을 기반으로, 애플리케이션에 필요한(Bean)들을 자동으로 등록한다.

더보기

비유를 들어보자면 새로운 스마트폰을 구입했다고 생각해보자, 별도의 복잡한 설정 없이 기본 앱들과 기능들이 이미 준비되어 있는 상태일것이다. Spring Boot 의 Auto Configuration은 바로 그런 스마트폰과 같이, 개발자가 직접 설정하지 않아도 "기본값"으로 모든 것이 준비되도록 도와준다.

 

왜 Auto Configuration이 필요할까?

전통적인 Spring 애플리케이션은 XML이나 자바 기반의 설정 파일을 통해 수많은 설정을 해야 한다.( 정말 정말 복잡하고 귀찮은 작업이다..) 이러한 작업은 시간도 오래걸리고, 실수로 인한 오류가 발생하기 쉽다.

그렇기 때문에  Spring Boot 의 Auto Configuration 은 장점을 갖는다.

 

  • 개발 속도 향상: 복잡한 설정을 자동으로 처리하여 개발자가 비즈니스 로직에 집중할 수 있다.
  • 일관성 있는 설정: 기본적인 설정이 표준화되어 있어, 팀 내에서 일관된 개발 환경을 유지할 수 있다.
  • 쉬운 시작: 처음 프로젝트를 시작할 때 최소한의 설정만으로도 애플리케이션을 실행할 수 있다.

 

Auto Configuration의 동작 원리

Spring Boot의 Auto Configuration은 내부적으로 여러 가지 메커니즘과 어노테이션을 사용해 동작한다.

 

@EnableAutoConfiguration / @SpringBootApplication

Spring Boot 애플리케이션의 진입점에는 보통 @SpringBootApplication 어노테이션이 붙는다. 이 어노테이션은 

@EnableAutoConfiguration, @ComponentScan, @Configuration 등 여러 어노테이션을 합친 축약 표현이다.

@SpringBootApplication
class MyApplication

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

 

@EnableAutoConfiguration : 해당 어노테이션은 Spring Boot에게 애플리케이션 시작 시 자동 구성을 활성화하라고 지시한다.

 

 

Auto Configuration은 클래스패스에 있는 라이브러리와 설정 파일을 확인한 후, 필요한 빈들을 등록한다.

이를 위해 조건부(Conditional) 어노테이션들이 사용된다. 대표적으로 @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty 등이 있다.

 

예를 들어, H2 데이터베이스 라이브러리가 클래스패스에 있다면, Spring Boot는 자동으로 H2 데이터베이스 관련 빈을 등록한다.

만약 사용자가 직접 데이터 소스(DataSource)를 정의했다면, @ConditionalOnMissingBean 어노테이션 덕분에 자동 구성은 이를 무시하고 사용자가 정의한 설정을 우선시한다.

@Configuration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource() {
        // 기본 H2 데이터베이스 설정을 반환
        return new EmbeddedDatabaseBuilder()
                    .setType(EmbeddedDatabaseType.H2)
                    .build();
    }
}

 

 

 

Auto Configuration 파일의 구조

Spring Boot의 Auto Configuration 설정은 보통 spring.factories 파일에 정의되어 있다. 이 파일은 각 라이브러리별로 어떤 Auto Configuration 클래스를 적용할지 명시해 두며, 애플리케이션이 시작될 때 해당 클래스들이 로드된다.

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.autoconfig.DataSourceAutoConfiguration,\
com.example.autoconfig.WebMvcAutoConfiguration

 

 

이렇게 장점만 있을것 같은 Auto Configuration도 한계가 있다.

 

  • 숨겨진 동작:
    자동으로 이루어지는 설정들이 때로는 개발자가 의도하지 않은 방식으로 동작할 수 있으므로, 내부 동작을 잘 이해해야 한다.
  • 디버깅의 어려움:
    Auto Configuration이 복잡하게 작동하는 경우, 문제가 발생하면 원인을 파악하기 어려울 수 있다.
  • 학습 곡선:
    초보자가 Auto Configuration의 내부 메커니즘을 완벽하게 이해하기까지는 시간이 걸릴 수 있다.

이러한 한계에도 불구하고, Auto Configuration은 대부분의 애플리케이션 개발에 있어 생산성과 유지보수성을 크게 향상시키는 도구임에는 분명하다.

리소스를 추가하기 위해 사용되는 API

@PostMapping : POST API를 제작하기 위해 사용되는 어노테이션 @RequestMapping + POST method의 조합이다.

일반적으로 추가 하고자 하는 Resouce를 http body에 추가하여 서버에 요청한다. 그렇기 때문에 @RequestBody를 이용하여 body에 담겨있는 값을 받아야 한다.

 

// http://localhost:8080/api/test/vi/post-api/member
// 해당 URL로 member 데이터를 넘겨주면 해당 데이터의 키값과 value값을 return 하는 예제
@PostMapping(value="/member")
public String postMember(@RequestBody Map<String, Object> postData) {
	StringBuilder sb = new StringBuilder();
    
    postData.entrySet().forEach(map -> {
    	sb.append(map.getKey() + ":" + map.getValue() + "\n");
    });
    
    return sb.toString();
}





// DTO를 사용하는 방식
// key와 value가 정해져있지만, 받아야할 파라미터가 많을 경우 DTO객체를 사용한다.
// GetMapping 과 다른점은 @RequestBody를 꼭 붙여야 한다는 것이다.
@PostMapping(value-"/member2")
public String postMemberDto(@RequestBody MemberDto memberDto) {
	return memberDto.toString();
}

'Back-End > Spring Boot + Kotlin' 카테고리의 다른 글

REST 와 RESTful  (0) 2024.02.07
Swagger  (1) 2023.11.08
Spring Boot 기초  (0) 2023.11.01
REST API  (2) 2023.10.30
Maven 과 Gradle  (0) 2023.10.29

컨트롤러 (Controller)

모델(Model)과 뷰(View) 사이에서 브릿지 역할을 수행한다. 앱의 사용자로부터 입력에 대한 응답으로 모델 및 뷰를 업데이트 하는 로직을 포함하여 사용자의 요청은 모두 컨트롤러를 통해 진행되어야 한다. 컨트롤러로 들어온 요청은 모델이 어떻게 처리할지 결정하여 모델로 요청을 전달한다.

 

@RestController

  • Spring Framework 4 버전부터 사용가능한 어노테이션
  • @Controller에 @ResponseBody가 결합된 어노테이션
  • 컨트롤러 클래스 하위 메소드에 @ResponseBody 어노테이션을 붙이지 않아도 문자열과 JSON 등을 전송할 수 있음
  • View를 거치지 않고 HTTP ResponseBody에 직접 Return 값을 담아 보내게 된다.

 

 

@RquestMapping

MVC의 핸들러 매핑(Handler Mappin)을 위해서 DefaultAnnotationHandlerMapping을 사용한다. DefaultAnnotationHandlerMapping 매핑정보로 @RequestMapping 어노테이션을 활용 클래스와 메소드의 RequestMapping을 통해 URL을 매핑하여 경로를 설정하여 해당 메소드에서 처리한다.

 

value : url 설정

method : GET, POST, DELETE, PUT, PATCH 등

 

스프링 4.3버전 부터 메소드를 지정하는 방식보다 간단하게 사용할 수 있는 어노테이션을 사용할 수 있음

  • @GetMapping
  • @PostMapping
  • @DeleteMapping
  • @PutMapping
  • @PatchMapping

 

 

 

 

'Back-End > Spring Boot + Kotlin' 카테고리의 다른 글

REST 와 RESTful  (0) 2024.02.07
Swagger  (1) 2023.11.08
Post API  (0) 2023.11.02
REST API  (2) 2023.10.30
Maven 과 Gradle  (0) 2023.10.29

+ Recent posts