포스트

[TIL] 스프링 숙련 - Bean 수동 등록과 인증

Spring에서 Bean을 수동 등록하는 상황과 자동 등록과의 차이를 정리한 TIL입니다.

For the English version of this post, see here.
[TIL] 스프링 숙련 - Bean 수동 등록과 인증

오늘 할 일

공부한 내용

Bean 수동으로 등록하기

Bean 수동 등록

  • Bean 자동 등록: @Component를 사용하면 @ComponentScan에 의해 자동으로 스캔되어 해당 클래스를 Bean으로 등록해줌
    • 일반적으로 위처럼 자동 등록함
    • 프로젝트의 규모가 커질 수록 등록할 Bean들이 만아지기 때문에 자동등록을 사용하면 편리함
    • 비즈니스 로직과 관련된 클래스들은 그 수가 많기 때문에 @Controller, @Service와 같은 어노테이션을 사용해 Bean으로 등록하고 관리하여 개발 생산성을 유리하게 함

Bean 수동 등록은 언제?

  • 기술적인 문제나 공통적인 관심사를 처리할 때 사용하는 객체들은 수동으로 등록하는 것이 좋음
    • 기술 지원 Bean: 공통 로그처리와 같은 비즈니스 로직을 지원하기 위한 부가적이고 공통적인 기능들
    • 비즈니스 로직 Bean보다는 그 수가 적기 때문에 수동으로 등록하기 부담스럽지 않음
    • 수동 등록된 Bean에서 문제가 발생했을 때, 해당 위치를 파악하기 쉬움

      Bean 수동 등록 방법

      1
      2
      3
      4
      5
      6
      7
      8
      
      @Configuration
      public class PasswordConfig {
      
      @Bean
      public PasswordEncoder passwordEncoder() {
          return new BCryptPasswordEncoder();
      }
      }
      
  • Bean으로 등록하고자하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정함
  • Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정함
  • Spring 서버가 뜰 때 Spring IoC 컨테이너에 ‘Bean’으로 저장됨
    • ‘Bean’ 이름: @Bean이 설정된 메서드명 ex) public PasswordEncoder passwordEncoder() {..} -> passwordEncoder

같은 타입의 Bean이 2개라면

  • Chicken
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    package com.sparta.springauth.food;
    
    import org.springframework.stereotype.Component;
    
    @Component
    @Primary
    public class Chicken implements Food {
        @Override
        public void eat() {
            System.out.println("치킨을 먹습니다.");
        }
    }
    
    • @Primary: 같은 타입의 Bean이 여러 개 있더라도 우선 @Primary가 설정된 Bean 객체를 주입해줌
  • Pizza
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    package com.sparta.springauth.food;
    
    import org.springframework.stereotype.Component;
    
    @Component
    @Qualifier("pizza")
    
    public class Pizza implements Food {
        @Override
        public void eat() {
            System.out.println("피자를 먹습니다.");
        }
    }
    
    • Pizza 클래스에 @Qualifier("pizza")를 추가함
    • 주입하고자 하는 필드에도(test의 필드) `@Qualifier(“pizza”)를 추가해주면 해당 Bean 객체가 주입됨
  • Food Interface
    1
    2
    3
    4
    5
    
    package com.sparta.springauth.food;
    
    public interface Food {
        void eat();
    }
    
    • Food 타입의 Bean 객체 Chicken, Pizza를 등록함
  • test 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    @SpringBootTest
    public class BeanTest {
    
        @Autowired
        @Qualifier("pizza")
        Food food;
    
        @Test
        @DisplayName("Primary 와 Qualifier 우선순위 확인")
        void test1() {
            // 현재 Chicken 은 Primary 가 적용된 상태
            // Pizza는 Qualifier 가 추가된 상태입니다.
            food.eat();
        }
    }
    
    • @Autowired Food food; 처럼 사용한다면 food 필드에 Bean을 주입해줘야하는데 같은 타입의 Bean 객체가 하나 이상(chicken, pizza)이기 때문에 어떤 Bean을 등록해줘야할지 몰라 오류가 발생함
      • 등록된 Bean 이름 명시하여 해결 가능 @Autowired Food pizza;, @Autowired Food chicken;
        • @Autowired가 기본적으로 Bean Type(Food)로 DI를 지원하며, 연결이 되지 않을 경우 Bean Name(chicken, pizza)으로 찾는다는 것을 알 수 있음
    • 같은 타입의 Bean들에 Qualifier와 Primary가 동시에 적용되어있다면 Qualifier의 우선순위가 더 높음
    • 하지만, Qualifier는 적용하기 위해 주입 받고자하는 곳에 해당 Qualifier를 반드시 추가해야함 -> 따라서 같은 타입의 Bean이 여러 개 있을 때는 범용적으로 사용되는 Bean 객체에는 Primary를 설정하고 지엽적으로 사용되는 Bean 객체에는 Qualifier를 사용하는 것이 좋음

인증과 인가

인증 (Authentication)
해당 유저가 실제 유저인지 인증하는 개념 인가 (Authorization)
해당 유저가 특정 리소스에 접근이 가능한지 허가를 확인하는 개념

웹 어플리케이션 인증의 특수성

Velog image

  1. 일반적으로 서버-클라이언트 구조로 되어있고, 실제로 이 두가지 요소는 아주 멀리 떨어져있음
  2. Http라는 프로토콜을 이용해여 통신하는데, 그 통신은 비연결성(Connectionless) 무상태(Stateless)로 이루어짐
    • 비연결성 (Connectionless): 서버와 클라이언트가 연결되어 있지 않다는 것
    • 리소스를 절약하기 위해
    • 서버와 클라이언트가 실제로 계속 연결되어있다면, 서버의 비용이 기하급수적으로 늘어남 -> 그래서 서버는 실제로 하나의 응답을 내면, 연결을 끊어버림 - 무상태 (Stateless): 서버가 클라이언트의 상태를 저장하지 않음
    • 기존의 상태를 저장하는 것들도 마찬가지로 서버의 비용과 부담을 증가시키는 것이기 때문에, 기존의 상태가 없다고 가정하는 프로토콜을 이용해 구현되어있음 -> 실제로 서버는 클라이언트가 전에 어떤 요청을 보냈는지 전혀 알지 못함

인증의 방식

  • 일반적으로 웹 어플리케이션은 두 가지 방법을 통해 인증을 처리함
    1. 쿠키-세션 방식의 인증
      • 서버가 ‘특정 유저가 로그인 되었다’는 상태를 저장하는 방식 Velog image
    2. 사용자가 로그인 요청을 보냄
    3. 서버는 DB의 유저 테이블에서 아이디 비밀번호를 대조해봄
    4. 실제 유저테이블의 정보와 일치한다면, 인증을 통과한 것으로 보고 “세션 저장소”에 해당 유저가 로그인 되었다는 정보를 넣음
    5. 세션 저장소에서는 유저의 정보와는 관련 없는 난수인 session-id를 발급함
    6. 서버는 로그인 요청의 응답으로 seesion-id를 내어줌
    7. 클라이언트는 그 session-id를 쿠키라는 저장소에 보관하고 앞으로의 요청마다 세션 아이디를 같이 보냄 (주로 HTTP header에 담아 보냄)
    8. 클라이언트의 요청에서 쿠키를 발견했다면, 서버는 세션 저장소에서 쿠키를 검증함
    9. 만약 유저 정보를 받아왔다면 -> 이 사용자는 로그인이 되어있는 사용자
    10. 이후에는 로그인 된 유저에 따른 응답을 내어줌
    11. JWT 기반 인증
      • JWT(JSON Web Token): 인증에 필요한 정보들을 암호화시킨 토큰을 의미함
      • JWT 기반 인증은 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별함 Velog image
    12. 사용자가 로그인 요청을 보냄
    13. 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조해봄
    14. 실제 유저테이블의 정보와 일치한다면, 인증을 통과한 것으로 보고 유저의 정보를 JWT로 암호화해서 내보냄
    15. 서버는 로그인 요청의 응답으로 jwt 토큰을 내어줌
    16. 클라이언트는 그 토큰을 저장소에 보관하고 앞으로의 요청마다 토큰을 같이 보냄
    17. 클라이언트의 요청에서 토큰을 발견했다면 서버는 토큰을 검증함
    18. 이후에는 로그인 된 유저에 따른 응답을 내줌

쿠키와 세션

  • 쿠키와 세션 모두 HTTP에 상태 정보를 유지(Stateful)하기 위해 사용됨
  • 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게됨
    • 쿠키
      • 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일
      • 개발자도구 > Application > Storage > Cookies
      • 구성요소:
        • Name (이름): 쿠키를 구별하는 데 사용되는 키
        • Value (값): 쿠키의 값
        • Domain (도메인): 쿠키가 저장된 도메인
        • Path (경로): 쿠키가 사용되는 경로
        • Expires (만료기한): 쿠키의 만료기한 (만료기한이 지나면 삭제됨)
    • 세션
      • 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용됨
      • 서버에서 클라이언트 별로 유일무이한 ‘세션 ID’를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장함
      • 서버에서 생성한 ‘세션 ID’는 클라이언트의 쿠키값(‘세션 쿠키’ 라고 부름)으로 저장되어 클라이언트 식별에 사용됨
      • 세션 동작 방식 Velog image
        1. 클라이언트가 서버에 1번 요청
        2. 서버가 세션ID를 생성하고, 쿠키에 담아 응답 헤더에 전달 - 세션 ID 형태: “SESSIONID = 12345”
        3. 클라이언트가 쿠키에 세션ID를 저장 (‘세션 쿠키’)
        4. 클라이언트가 서버에 2번 요청 - 쿠키값 (세션 ID) 포함하여 요청
        5. 서버가 세션ID를 확인하고, 1번 요청과 같은 클라이언트임을 인지

쿠키 다루기

  • 쿠키 생성
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
      public static void addCookie(String cookieValue, HttpServletResponse res) {
          try {
              cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
              // cookieValue 값에 있는 공백을 없애주는 코드
    
              Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
              cookie.setPath("/");
              cookie.setMaxAge(30 * 60);
    
              // Response 객체에 Cookie 추가
              res.addCookie(cookie);
          } catch (UnsupportedEncodingException e) {
              throw new RuntimeException(e.getMessage());
          }
      }
    
  • new Cookie(AUTHORIZATION_HEADER, cookieValue);: Cookie에 저장될 Name과 Value를 생성자로 받는 Cookie 객체를 생성
  • setPath("/"), setMaxAge(30 * 60): Path와 만료시간을 지정함
  • HttpServletResponse 객체에 생성한 Cookie 객체를 추가하여 브라우저로 반환함
    • 이렇게 반환한 Cookie는 브라우저의 Cookie 저장소에 저장됨
  • Cookie 생성은 범용적으로 사용될 수 있기 때문에 static 메서드로 선언함

  • 쿠키 읽기
    1
    2
    3
    4
    5
    6
    
    @GetMapping("/get-cookie")
    public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
      System.out.println("value = " + value);
    
      return "getCookie : " + value;
    }
    
  • @CookieValue("Cookie의 Name"): Cookie의 Name 정보를 전달해주면 해당 정보를 토대로 Cookie의 Value를 가져옴

세션 다루기

  • Servlet에서는 유일무이한 ‘세션 ID’를 간편하게 만들 수 있는 HttpSession을 제공해줌
  • HttpSession 생성
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @GetMapping("/create-session")
    public String createSession(HttpServletRequest req) {
      // 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
      HttpSession session = req.getSession(true);
    
      // 세션에 저장될 정보 Name - Value 를 추가합니다.
      session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");
    
      return "createSession";
    }
    
  • HttpServletRequest를 사용하여 세션을 생성 및 반환할 수 있음
  • req.getSession(true): 세션이 존재할 경우 세션을 반환하고 없을 경우 새로운 세션을 생성함
  • 세션에 저장할 정보를 Name-Value 형식으로 추가함
    • 세션은 빈 공간일 뿐이기 때문에, 우리가 원하는 정보(로그인 정보, 사용자 정보 등)은 직접 넣어줘야함
    • session = req.getSession(true)는 요청 보낸 사용자에 대해 그 사용자에 대한 세션이 존재하는 지 확인하고, session.setAttribute(AUTHORIZATION_HEADER, “Robbie Auth”)은 이전 코드로 인해 새로 생성되거나 이미 존재하는 세션 안에 데이터를 넣어주는 것
  • 반환된 세션은 브라우저 Cookie 저장소에 ‘JSESSIONID’라는 Name으로 Value에 저장됨
  • HttpSession 읽기
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @GetMapping("/get-session")
    public String getSession(HttpServletRequest req) {
      // 세션이 존재할 경우 세션 반환, 없을 경우 null 반환
      HttpSession session = req.getSession(false);
    
      String value = (String) session.getAttribute(AUTHORIZATION_HEADER); // 가져온 세션에 저장된 Value 를 Name 을 사용하여 가져옵니다.
      System.out.println("value = " + value);
    
      return "getSession : " + value;
    }
    
  • req.getSession(false): 세션이 존재할 경우 세션을 반환하고 없을 경우 null을 반환함
  • session.getAttribute("세션에 저장된 정보 Name"): Name을 사용하여 세션에 저장된 Value를 가져옴

문제 & 오류

  • 문제: 컨트롤러까지 요청이 도달하지 못함
  • 원인: implementation 'org.springframework.boot:spring-boot-starter-security' 의존성
    • 위 의존성으로 인해 Spring Security가 동작
    • 모든 요청 보호, 로그인 안 되어 있으면 인증 요구, /login 기본 로그인 페이지 제공 -> 따라서 위 요청을 하면, Spring Security 필터가 먼저 인증이 됐는지 확인 -> 안 되어있으면 /login으로 리다이렉트 -> 기본 로그인 페이지 표시됨
  • 해결: 내가 가진 Spring Boot 4 내부에서는 exclude = SecurityAutoConfiguration.class를 통해서 시큐리티 자동설정의 상태를 끄려고 해도 그 종류가 많기 때문에, 위 코드처럼 하나만 제외해도 나머지는 살아있어서 오류가 발생 -> 따라서, main > java > config > SecurityConfig.java에 ``` java package com.sparta.springauth.config;

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain;

@Configuration public class SecurityConfig {

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .csrf(csrf -> csrf.disable())
            .formLogin(form -> form.disable())
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(
                            "/api/create-cookie",
                            "/api/get-cookie",
                            "/api/create-session",
                            "/api/get-session"
                    ).permitAll()
                    .anyRequest().authenticated()
            );

    return http.build();
} } ``` 위 코드를 통해서 해당 경로로 이동할 때, 기본 로그인 페이지(`/login`)를 안 뜨게 하고, 쿠키 테스트할 때 방해되는 csrf도 끔

내일 할 일