OAuth2가 무엇인가?

OAuth2는 로그인을 대신해주는 표준 방식이다. 사용자가 비밀번호를 입력하지 않고, 구글/카카오 계정으로 로그인하는 것을 가능하게 한다.

 

기본 흐름(단순화)를 살펴보면 

  1. 사용자가 구글 로그인 버튼 클릭
  2. Spring Security → 구글 서버로 리다이렉트
  3. 구글 로그인 → 우리 서버로 code 반환
  4. Spring Security가 code 로 access token 요청
  5. access token으로 사용자 정보 요청
  6. 사용자 정보 기반으로 인증 처리 완료

 

Spring Security OAuth2 구조를 이해해보자

Spring Security는 OAuth2 로그인을 처리하기 위해 다음 컴포넌트를 사용한다.

컴포넌트 역할
OAuth2LoginAuthenticationFilter 로그인 요청 가로채기
DefaultOauth2UserService 사용자 정보 조회
OAuth2User 조회된 사용자 정보 모델
AuthenticationSuccessHandler 로그인 성공 후 처리
ClientReistration 클라이언트 정보(google, kakao 등)

 

 

카카오 / 구글 연동을 위한 기본 설정 예시

// application.yml 예시

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: <GOOGLE_CLIENT_ID>
            client-secret: <GOOGLE_CLIENT_SECRET>
            scope: profile, email
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: Google
          kakao:
            client-id: <KAKAO_REST_API_KEY>
            client-secret: <KAKAO_CLIENT_SECRET>
            scope: profile_nickname, account_email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: Kakao
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

 

 

 

핵심 커스터마이징 포인트

 

1. 사용자 정보 가공 - CustomOAuth2UserService

 

구글 / 카카오에서 가져오는 사용자 정보는 정형화돼 있지 않다. 서비스마다 반환 형식도 다르고, 내가 원하는 도메인 정보와 맞지 않을 수도 있다.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); // google, kakao
        String userNameAttr = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        Map<String, Object> attributes = oAuth2User.getAttributes();

        OAuthAttributes mappedAttributes = OAuthAttributes.of(registrationId, userNameAttr, attributes);

        // DB에 사용자 저장
        Member member = saveOrUpdate(mappedAttributes);

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                mappedAttributes.getAttributes(),
                mappedAttributes.getNameAttributeKey());
    }
}

 

 

2. 최초 로그인 시 회원가입 처리

private Member saveOrUpdate(OAuthAttributes attributes) {
    Optional<Member> user = memberRepository.findByEmail(attributes.getEmail());

    if (user.isEmpty()) {
        return memberRepository.save(attributes.toEntity());
    }

    return user.get(); // 기존 사용자
}

 

 

3. 사용자 도메인 모델 매핑

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;

    public static OAuthAttributes of(String provider, String userNameAttributeName, Map<String, Object> attributes) {
        if ("kakao".equals(provider)) {
            return ofKakao(userNameAttributeName, attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return new OAuthAttributes(attributes, userNameAttributeName,
                (String) attributes.get("name"),
                (String) attributes.get("email"));
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        return new OAuthAttributes(attributes, userNameAttributeName,
                (String) profile.get("nickname"),
                (String) kakaoAccount.get("email"));
    }

    public Member toEntity() {
        return new Member(name, email, Role.USER);
    }
}

 

 

 

4. JWT 발급 연동

OAuth2 로그인 성공 후 JWT를 발급하고 응답에 실어주는 구조도 자주 사용한다.

public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;

    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");

        String token = jwtUtil.createToken(email);

        response.setHeader("Authorization", "Bearer " + token);
    }
}

 

 

 

전체 흐름을 요약해보면

 

  1. 사용자가 /oauth2/authorication/kakao 클릭 
  2. 카카오 로그인
  3. code 반환
  4. Spring이 token 요청
  5. 사용자 정보 조회
  6. CustomOAuth2UserService 실행
  7. DB 회원가입 처리
  8. SecurityContext 인증 저장
  9. SuccessHandler에서 JWT 발급
  10. 클라이언트로 응답

 

OAuth2 로그인은 단순해 보이지만 실제 서비를 만들 때는 사용자 정보 가공, JWT 발급, 회원가입 처리 등 커스터마이징이 꼭 필요한 포인트들이 많다.

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로 다 만들어야 한다. 코드가 길어지고, 테스트하기도 어렵고, 관리도 힘들어진다.

 

 

대부분의 웹 애플리케이션은 DB Connection Pool을 사용한다. DB 커넥션 풀 덕분에 동시 사용자 요청을 빠르게 처리할 수 있다.

 

하지만 어느 순간, 예상치 못한 상황이 발생한다. 

 

그 순간중 하나가 바로 커넥션 풀 고갈(Connection Exhaustion) 이다.

 

  • DB 연결 대기 시간이 급격히 증가한다
  • 요청이 지연된다
  • 최악의 경우 애플리케이션 전체가 다운된다.

 

그렇다면 DB Connection Pool에 대해서 알아보자.

 

DB Connection Pool 이란?

DB 연결(Connection)을 매번 새로 열고 닫으면 비용이 크기 때문에, 일정 수의 커넥션을 미리 생성해 풀(pool)에 담아두고 재사용하는 구조이다.

 

특징 설명
커넥션 생성 비용 절감 연결이 미리 준비되어 빠른 응답 가능
커넥션 수 제한 가능 DB에 과한 부하를 주지 않음
대기시간 단축 필요시 즉시 커넥션 제공 가능

 

인기 있는 커넥션 풀 라이브러리는 다음과 같다.

  • HikariCP (스프링 부트 기본)
  • Tomcat JDBC Pool
  • DBCP2 (Apache)

그렇다면 커넥션 풀 고갈이란 무엇일까?

 

 

커넥션 풀 고갈(Connection Pool Exhaustion) 이란?

커넥션 풀 안의 커넥션이 모두 사용 중이어서, 새로운 요청에 빌려줄 커넥션이 없는 상태를 말한다.

 

발생 과정을 간략하게 살펴보면

  1. 사용자는 요청을 보낸다.
  2. 서버는 DB 작업을 하려고 커넥션을 풀에서 꺼낸다.
  3. 풀에 남은 커넥션이 없다.
  4. 요청은 대기(wait) 하거나, 시간 초과(timeout) 되며 실패한다.

커넥션 풀이 고갈되면

현상 설명
서버 응답 지연 커넥션을 얻을 때까지 기다리면서 전체 응답이 느려진다
타임아웃 발생 일정 시간 안에 커넥션을 못 얻으면 에러가 발생한다
DB 부하 급증 또는 다운 일부 트랜잭션이 커넥션을 오래 점유하면 DB에 과부화
서버 과부화/장애 요청이 몰리면서 스레드/큐가 포화, 시스템 전반 문제 발생

 

이런 현상들이 발생할 수 있다.

 

스프링부트 + HikariCP 에서 고갈상황을 억지로 만들어 보자.

spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 30000

 

위 설정을 추가해

  • 최대 10개의 커넥션만 풀에 존재시키고
  • 커넥션을 못 빌리면 최대 30초 동안 대기 후 타임아웃이 발생

만약 동시에 요청이 50개 들어오고, 각각 DB 작업을 오래 잡고 있으면 어떻게 될까?

 

결과 : 

  • 처음 10개는 커넥션 얻고 정상 처리
  • 나머지 40개는 커넥션 풀에서 대기
  • 일부 요청은 30초 대기하다 타임아웃 발생
  • 서버 전체가 느려지고 장애 징후가 보인다.

 

이런 커넥션 풀 고갈의 주요 원인은 다음과 같은 것들이 있다.

 

1. 커넥션 미반납(Conneciton Lock)

val conn = dataSource.connection
// ... 쿼리 수행
// conn.close() 빠뜨림!!

 

close()를 호출하지 않으면, 커넥션 풀로 반환되지 않는다. 시간이 지나면 커넥션이 모두 소비되고 고갈된다.

항상 try-with-resources 또는 finally 블록에서 명시적으로 close 해야 한다.

 

 

2. 긴 트랜잭션(Long Transaction)

트랜잭션 안에서 오래 걸리는 로직(슬로우 쿼리, 외부 API 호출 등)이 있으면, 커넥션을 장시간 점유하게 된다. 이렇게 되면

커넥션을 회수하지 못하고 대기열만 쌓임 → 고갈 상태가 된다.

 

 

3. 예상 외 트래픽 폭주

대규모 이벤트, 외부 봇 공격, 잘못된 반복 호출 등으로 서버가 과도하게 많은 요청을 동시에 처리하게 되면, 커넥션 풀 한계를 초과할 수 있다.

 

 

4. 커넥션 풀 설정 오류

  • maximumPoolSize를 너무 작게 설정
  • connectionTimeout을 너무 짧거나 길게 잡은경우

위와 같은 설정 문제도 고갈을 부추길 수 있다.

 

 

 

고갈 대응 방법

방법 설명
커넥션 풀 크기 조정 CPU 코어 수 x 2 또는 예상 TPS에 맞춰 조정
커넥션 타임아웃 설정 connectionTimeout을 적절하게 조절(ex: 5초)
커넥션 반납 철저 관리 close 누락 방지(try-with-resources 적극 사용)
트랜잭션 최소화 DB 점유 시간을 줄인다(슬로우 쿼리 개선)
커넥션 풀 모니터링 HikariCP Metrics, JMX를 통해 실시간 감지
슬로우 쿼리 탐지 및 개선 DB 쿼리 튜닝, 인덱스 최적화
백프레셔(Backpressure) 적용 서버에 요청이 몰릴 때, 내부적으로 대기시키거나 제한
이중화 및 읽기 전용 Replica DB 활용 부하 분산

Kotlin을 공부중인데 val에 대해서 글을 읽던 중 엥? 이게 무슨 말이지 하는 부분이 있었다.

 

분명 "val 로 선언하면 값을 바꿀 수 없다" 라고 봤는데, val로 선언했는데 객체 내부 값이 변하는걸 목격했다.

 

분명히 고정된 값(val)인데 왜 내부 데이터는 바뀌는건지 혼란스러웠다.

 

이건 Kotlin이 값을 불변하게 만든다고 오해한 데서 시작된 착각이라는 것을 알았다.

 

진짜로 불변(Immutable)한 것은 "참조(reference)" 이지, "객체의 상태(state)" 가 아니다.

 

 

1. Kotlin의 val 과 var 기본 개념

먼저, Kotlin의 val 과 var 는 변수 선언 방식이다.

키워드 의미
val 읽기 전용(Read-only) 참조
var 읽기/쓰기(Read-Write) 참조

 

  • val 은 참조를 변경할 수 없다.
  • var 은 참조를 변경할 수 있다.

즉, val 은 "이 참조가 다른 객체를 가리키지 않도록 고정"하는 것이다. "참조하는 객체 재부"는 건들 수 있다.

 

val list = mutableListOf(1, 2, 3)
list.add(4)      //  가능
list.remove(2)   //  가능
// list = mutableListOf(5, 6, 7)  // ❌ 에러! 참조 변경 불가

 

위 코드에서 알아볼 수 있다.

  • list 가 가리키는 리스트 객체는 수정 가능하다.
  • 그러나 list 자체를 다른 리스트로 바꿀 수 없다.

 

var 은 참조 변경이 가능하다

var list = mutableListOf(1, 2, 3)
list = mutableListOf(10, 20, 30)  // 가능

 

 

오호..이제 알겠다. 근데 왜 이런 설계를 했을까???

 

2. val 설계 이유

Kotlin이 val 을 참조 고정만 보장하고, 객체 불변성을 강제하지 않는 이유는 다음과 같다.

 

이유 1 : 유연성과 기능

  • 객체를 통째로 새로 생성하는 것보다, 기존 객체를 수정하는 것이 빠를 때가 많다.
  • 특히 컬렉션 같은 경우 "수정 가능한 컬렉션 (mutableListOf)" 은 효율적이다.

이유 2: Kotlin의 철학

  • Kotlin은 "개발자에게 선택권을 준다" 는 철학을 지향한다.
  • 불변 객체를 원하면, 개발자가 immutable한 자료구조를 선택해야 한다.

Kotlin은 "불면(immutable)"을 강제하지 않는다. 읽기 전용 참조(read-only reference)만 보장한다.

 

 

3. Mutable 과 Immutable 객체

mutable = 내부 상태를 바꿀 수 있다.

immutable = 내부 상태를 바꿀 수 없다.

구분 설명 예시
Mutable 객체 객체 내부 데이터 변경 가능 mutableListOf, HashMap 등
Immutable 객체 객체 내부 데이터 변경 불가 listOf, Map (읽기 전용 View)

 

Kotlin의 listOf() 로 만든 리스트도 사실은 완전한 immutable은 아니다. 완전히 불변한 컬렉션을 원하면 별도로 관리해야 한다.

예: Collecitons.unmodifiableList(), 또는 직접 만드는 데이터 클래스

 

 

즉 비유를 하자면

 

val 은 집주소를 고정하는 것이다. 집 주소를 못 바꾸지만, 집안 가구 배치나 이런건 마음대로 바꿀 수 있지 않은가.

val 집 = MyHouse()
집.소파 = "새 소파로 교체" // OK
// 집 = 다른집() // ❌ 참조 변경은 불가

 

 

 

4. 데이터 클래스와 불변성

Kotlin에서는 데이터 클래스를 많이 사용한다.

data class Person(var name: String, var age: Int)

 

val 로 선언해도 name, age는 변경 가능하다.

 val person = Person("Alice", 25)
person.age = 26  // 내부 필드 변경 가능

 

  • person 이라는 참조는 고정된다
  • 하지만 person 객체 내부 필드(age)는 변경 가능하다.

 

정말 진짜 불변성을 원한다면 다음과 같이 만들 수 있다.

 

1. data class를 val 프로퍼티로만 만든다.

data class ImmutablePerson(val name: String, val age: Int)

 

이제 name, age를 바꿀 수 없다.

 

2. 불변 컬렉션을 사용한다.

val list = listOf(1, 2, 3)
// list.add(4)  // ❌ 컴파일 에러

 

 

 

주의할 점

상황 주의해야 할 점
API 리턴 타입을 val로 선언했지만 내부 객체가 Mutable인 경우 외부에서 상태가 변조될 수 있다.
글로벌 상태를 val로만 선언하고 안심하는 경우 Thread-safety는 별개 문제이다.

 

  • val 은 객체 상태를 보호해주지 않는다.
  • 불변성을 원하면 객체 설계 자체를 immutable하게 헤야 한다.

의존성? 의존성이 뭘까?

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

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

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

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

 

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

 

실시간 통신은 채팅, 알림, 게임. 금융 거래 등 다양한 분야에서 핵심 기술로 사용된다. 예를 들어, 소셜 미디어에서 친구가 게시물을 올릴 때 실시간 알림이 전송되는 경우, 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>

Java 기반 프로젝트를 배포할 때 자주 사용하는 Jar 파일과 War 파일이 있다. 누군가는 War 파일로 달라고 하고, 누군가는 Jar 파일로 달라고 한다. 두 파일의 차이점은 무엇이고 어떨때 활용하면 좋을까?

 

Jar, War 모두 패키징이다.

 

여기서 패키징은 단순히 파일을 압축하는 것이 아니라, 실제 애플리케이션을 실행 가능한 형태로 만들어 주는 매우 중요한 단계이다.

 

연인에게 줄 선물을 박스째로 주는 것보다 예쁘게 포장해서 주면 더 좋은것과 마찬가지이다.

 

소프트웨어도 Jar 파일이나 War 파일과 같은 포맷으로 잘 포장되어 있어야, 다른 개발자나 운영자가 쉽게 사용하고 배포할 수 있다.

 

Jar 파일(Java Archive)

Jar 파일은 Java 애플리케이션을 패키징하는데 사용되는 파일 형식이다. 여러 개의 클래스 파일, 리소스 파일, 메타데이터를 하나의 압축 파일로 묶은 것이다. 특히 Spring Boot와 같은 프레임워크에서는 애플리케이션 전체가 Jar 파일 하나로 패키징된다.

 

 

Jar 파일의 특징

  • 독립적 실행 가능 : 내장된 Tomcat, Jetty 등 웹 서버를 함께 포함하여 별도의 웹 서버 설치 없이 실행할 수 있다.
  • 실행 방식 간편 : 명령어 한 줄로 간편하게 실행할 수 있다.
  • 클라우드 및 마이크로서비스 환경 적합 : 컨테이너화된 환경에서 효율적으로 관리된다.
  • 실행 가능한 Jar 파일은 main() 메서드를 포함하고 있어,  java - jar filename.jar   명령어로 실행할 수 있다.

 

활용법 

  • 애플리케이션 배포 : 독립 실행형 애플리케이션으로 배포할 때 사용한다.
  • 라이브러리 제공 : 다른 프로젝트에서 공통적으로 사용할 수 있는 기능을 포함한 라이브러리를 Jar 파일로 제공할 수 있다.

 

 

 

War 파일(Web Application Archive)

War 파일은 웹 애플리케이션을 Tomcat, JBoss, WebLogic 등 외부 웹 서버에 배포할 때 사용되는 형식이다.

 

War 파일의 특징

  • 외부 서버에 의존적 : 별도의 외부 웹 서버가 반드시 필요하다.
  • 복잡한 설정 지원 가능 : 다양한 서버에서 다수의 애플리케이션 관리 시 효율적이다.
  • 전통적인 배포 방식 : 기존 웹 서버 기반 인프라에 적합하다.

어떤 것을 선택할지?

  • Jar : 빠른 배포가 중요하거나 클라우드 환경에 배포한다면 Jar 파일이 좋다.
  • War : 복잡한 서버 환경을 관리하거나 기존 웹 서버를 활용해야 하는 환경이라면 War 파일이 유리하다.

 

두 파일의 차이점을 정리해보자 

 

Jar와 War 파일의 차이점

1. 구조와 구성 요소 비교

 

Jar 파일:

  • 주로 클래스 파일과 리소스 파일, 메타데이터가 포함됩니다.
  • 실행 가능한 Jar 파일은 main() 메서드를 포함하여 독립적으로 실행됩니다.

War 파일:

  • 웹 애플리케이션에 필요한 모든 파일(HTML, CSS, JS, 서블릿, JSP, WEB-INF 폴더 등)이 포함됩니다.
  • War 파일은 서블릿 컨테이너(예: Tomcat)에 배포되어 실행됩니다.

 

 

2. 실행 환경과 용도

 

Jar 파일:

  • 용도: 독립 실행형 애플리케이션, 라이브러리 제공
  • 실행 환경: JVM이 설치된 모든 환경에서 실행 가능
  • 실행 방법: java -jar myapp.jar

War 파일:

  • 용도: 웹 애플리케이션, 동적 웹 사이트
  • 실행 환경: 서블릿 컨테이너(예: Tomcat, Jetty)가 필요
  • 배포 방법: War 파일을 서버의 webapps 폴더에 복사하여 자동 배포

 

실제 개발 사례와 활용 전략

제가 백엔드 개발자로 일하면서 느낀 점은, Jar 파일과 War 파일을 어떻게 활용하느냐가 프로젝트의 성격에 따라 달라진다는 것이다.

 

1. 소규모 프로젝트 vs 대규모 웹 서비스

  • 소규모 프로젝트:
    독립 실행형 애플리케이션이나 명령줄 도구 등은 Jar 파일로 배포하는 것이 편리합니다.
  • 대규모 웹 서비스:
    웹 애플리케이션은 War 파일로 패키징하여 Tomcat 같은 서버에 배포합니다.

 

 

2. 마이크로서비스 아키텍처에서의 활용

요즘은 많은 기업들이 마이크로서비스 아키텍처를 채택하고 있다고 한다.
각 서비스는 독립적으로 개발되고 배포되는데, 이때

  • 각 서비스는 Jar 파일로 배포될 수도 있고,
  • 혹은 각각의 웹 애플리케이션으로 War 파일로 패키징되어 별도의 서블릿 컨테이너에서 실행될 수도 있습니다.

 

 

3. 실제 적용 예: Spring Boot 애플리케이션

Spring Boot는 기본적으로 Jar 파일로 배포할 수 있도록 설계되어 있지만, 설정을 통해 War 파일로도 패키징할 수 있습니다.

 

Jar 파일로 배포하는 경우

장점:

  • 독립 실행형으로 배포하기 쉽고, 내장 Tomcat을 포함하여 추가 설정이 필요 없습니다.

예제

   java -jar myapplication.jar    

 

 

War 파일로 배포하는 경우

Spring Boot 애플리케이션을 War 파일로 빌드하려면, 메인 클래스에 SpringBootServletInitializer 를 상속받는 설정이 필요하다.

 

예제 코드를 한번 살펴보자

package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.builder.SpringApplicationBuilder
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer

@SpringBootApplication
class DemoApplication

/**
 * War 파일로 패키징하기 위한 설정 클래스
 */
class ServletInitializer : SpringBootServletInitializer() {
    override fun configure(application: SpringApplicationBuilder): SpringApplicationBuilder {
        return application.sources(DemoApplication::class.java)
    }
}

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

 

빌드 후 생성된 War 파일을 Tomcat의 webapps 폴더에 복사하면 자동으로 배포된다.

 

 

Jar 파일 구조 다이어그램

 

Jar 파일은 Java 클래스, 리소스, 그리고 메타데이터가 하나의 압축 파일에 담겨 있는 구조이다.

 

 

War 파일 구조 다이어그램

 

War 파일은 웹 애플리케이션을 위한 모든 파일이 포함되어 있으며, WEB-INF 폴더 아래에 클래스와 라이브러리들이 존재한다.

회사에서 일을 하면서 실제로 대규모 트래픽을 경험해 볼 수는 없다. 끽해야 일일 접속자가 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  메서드가 메시지를 수신하여 처리한다.

 

 

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

 

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

 

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

앞서 로그와 로그를 관리하는게 얼마나 중요한지 알아보았는데 중앙 집중식 로그 관리에 대해 알아두면 좋을것 같아서 정리를 했다.

 

중앙 집중식 로그 관리의 필요성

1. 문제 진단과 모니터링

서버에 문제가 발생했을 때, 로그를 통해 문제의 원인을 빠르게 파악할 수 있다. 중앙 집중식 로그 관리 시스템은 모든 로그를 실시간으로 모니터링하고, 이상 징후가 보이면 즉시 알림을 보내어 빠른 대응이 가능하게 해준다.

 

2. 보안 및 규정 준수

로그는 보안 사건을 추적하는 데도 유용하다. 로그를 중앙에서 관리하면, 보안 침해나 이상 행동을 쉽게 감지할 수 있고, 법정 규정을 준수하기 위한 증거 자료로 활용할 수도 있다.

 

3. 성능 분석과 최적화

로그 데이터를 분석하면 애플리케이션의 성능 병목이나 자원 사용 패턴을 파악할 수 있다. 이 정보를 바탕으로 시스템의 성능을 최적화하는 전략을 수립할 수 있다.

 

 

중앙 집중식 로그 관리 시스템의 구성 요소

1. 로그 수집기 (Filebeat, Logstash 등)

  • Filebeat : 각 서버에서 로그 파일을 읽어 들여 중앙 서버(Elasticsearch 등)로 전송하는 가벼운 에이전트이다.
  • Logstash : 수집된 로그 데이터를 필터링, 변환, 그리고 적절한 포맷으로 재가공하여 저장소에 전달하는 역할을 한다.

 

2. 로그 저장소 (Elasticsearch 등) 

  • Elasticsearch : 로그 데이터를 저장하고, 빠른 검색과 분석이 가능하도록 하는 분산형 데이터베이스이다. 수백만 건의 로그도 실시간으로 검색할 수 있다.

 

3. 로그 조회 및 시각화 도구 (Kibana 등)

  • Kibana : Elasticsearch에 저장된 로그 데이터를 시각화하고, 대시보드 형태로 모니터링 할 수 있는 웹 인터페이스 도구이다. 사용자는 Kibana를 통해 로그 검색, 필터링, 시각화 작업을 쉽게 수행할 수 있다.

 

 

중앙 집중식 로그 관리 아키텍쳐

중앙 집중식 로그 관리 시스템은 다음과 같은 흐름으로 구성됩니다:

  1. 로그 생성:
    각 애플리케이션 서버에서 로그가 생성됩니다.
  2. 로그 수집:
    Filebeat나 Logstash 같은 에이전트가 로그 파일을 읽어 옵니다.
  3. 로그 전송:
    수집된 로그 데이터는 네트워크를 통해 중앙 로그 저장소(Elasticsearch)로 전송됩니다.
  4. 로그 저장:
    Elasticsearch는 로그 데이터를 색인(index)하여 저장하고, 빠른 검색이 가능하도록 합니다.
  5. 로그 조회 및 분석:
    Kibana를 통해 사용자가 로그 데이터를 검색하고 시각화하여 분석할 수 있습니다.

 

데이터 흐름과 처리 과정

  • 생성 단계:
    각 서버에서는 Logback이나 Log4J와 같은 로깅 프레임워크가 로그 메시지를 파일에 기록합니다.
  • 수집 단계:
    Filebeat가 정해진 경로의 로그 파일을 지속적으로 모니터링하고, 새로운 로그가 생성되면 이를 읽어들입니다.
  • 전송 단계:
    Filebeat는 읽어들인 로그 데이터를 JSON 등 표준 형식으로 변환해 Elasticsearch 클러스터로 보냅니다.
  • 저장 단계:
    Elasticsearch는 전송된 로그 데이터를 색인하고, 사용자가 빠르게 검색할 수 있도록 합니다.
  • 조회 단계:
    Kibana 대시보드에서 사용자는 검색 쿼리를 입력하거나 필터를 적용해 원하는 로그 메시지를 찾을 수 있습니다.

 

 

로그 전송을 위한 Filebeat 설정 예

 

Filebeat를 우선 설치하고, 각 서버에 다음과 같은 설정 파일 filebeat.yml  을 사용하여 로그를 중앙 서버로 전송한다.

filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /path/to/your/app/logs/*.log

output.elasticsearch:
  hosts: ["http://elasticsearch_server:9200"]

 

위 설정은 Filebeat가 /path/to/your/app/log/ 폴더에 있는 모든 로그 파일을 모니터링하고, 새로운 로그가 생기면 Elasticsearch로 전송하도록 한다.

 

 

 

중앙 집중식 로그 관리 시스템 구축 시 고려사항

1. 보안 및 개인정보 보호

  • 로그에는 민감한 정보가 포함될 수 있으므로, 로그 데이터에 접근할 수 있는 권한을 제한해야 합니다.
  • 로그 전송 시 암호화와 인증을 적용해, 로그 데이터가 중간에 탈취되지 않도록 해야 합니다.

2. 성능 및 확장성 고려

  • 로그 수집 에이전트(Filebeat 등)가 과도한 자원을 사용하지 않도록 최적화해야 합니다.
  • Elasticsearch 클러스터는 로그 데이터의 양과 검색 빈도에 맞춰 확장할 수 있어야 합니다.

3. 장애 대응 및 복원 전략

  • 중앙 집중식 로그 관리 시스템 자체가 장애가 발생할 경우, 로그를 잃을 수 있으므로 이중화(High Availability)를 고려해야 합니다.
  • 백업 및 복원 정책을 수립해, 중요한 로그 데이터를 안전하게 보관해야 합니다.

+ Recent posts