스프링 숙련 주차
Updated:
Bean
Bean을 수동으로 등록하는 방법
@Component를 사용하면 @ComponentScan에 의해서 해당 클래스를 Bean으로 자동 등록해준다. 비즈니스 로직과 관련된 클래스들은 그 수가 많기 때문에 @Controller, @Service와 같은 애너테이션들을 사용해서 Bean으로 등록하고 관리하면 개발 생산성에 유리하다. 하지만 기술적인 문제나 공통적인 관심사를 처리할 때 사용하는 객체들은 수동으로 등록하는 것이 좋다.
@Component
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- Bean으로 등록하고자 하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정
- Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정
위 메서드를 테스트 해본다.
@SpringBootTest
class PasswordEncoderTest {
@Autowired
PasswordEncoder passwordEncoder;
@Test
@DisplayName("수동 등록한 passwordEncoder를 주입 받아와 문자열 암호화")
void test1() {
String password = "password";
// 암호화
String encodedPassword = passwordEncoder.encode(password);
System.out.println("encodedPassword = " + encodedPassword);
String inputPassword = "password123";
// 복화하를 통해 암호화된 비밀번호화 비교
boolean matches = passwordEncoder.matches(inputPassword, encodedPassword);
System.out.println("matches = " + matches); // false
}
}
같은 타입의 Bean이 2개일 때
public interface Food {
void eat();
}
위 interface를 상속받는 두 class를 작성한다.
@Component
public class Chicken implements Food {
@Override
public void eat() {
System.out.println("치킨을 먹습니다.");
}
}
@Component
public class Pizza implements Food {
@Override
public void eat() {
System.out.println("피자를 먹습니다.");
}
}
테스트 코드를 작성해본다.
@SpringBootTest
public class BaenTest {
@Autowired
Food food;
}
위 코드를 보면 Could not autowire. There is more than one bean of 'Food' type.Beans:chicken (Chicken.java)pizza (Pizza.java) 과 같은 error가 발생한다. 아는 fodd 필드에 Bean을 주입해줘야 하는데 같은 타입의 Bean 객체가 하나 이상 있기 때문에 어떤 Bean을 등록해줘야할지 몰라 오류가 발생한 것이다. 이는 다음과 같이 해결할 수 있다.
- 등록된 Bean 이름 명시
@SpringBootTest public class BaenTest { @Autowired Food pizza; @Autowired Food chicken; } - @Primary 사용하기
@Component @Primary public class Chicken implements Food { @Override public void eat() { System.out.println("치킨을 먹습니다."); } }- @Primary가 추가되면 같은 타입의 Bean이 여러 개 있더라도 우선 주입 해준다.
- @Qualifier 사용하기
@Component @Qualifier("pizza") public class Pizza implements Food { @Override public void eat() { System.out.println("피자를 먹습니다."); } }@SpringBootTest public class BaenTest { @Autowired @Qualifier("pizza") Food food; }
같은 타입의 Bean들에 Quailfer와 Primary가 동시에 적용되어있다면 Qualifier의 우선순위가 높다. 범용적으로 사용되는 Bean 객체는 Primary를 설정하고 지엽적으로 사용되는 Bean 객체에는 Qualifer를 사용하는 것이 좋다.
인증과 인가
- 인증(Authentication): 해당 유저가 실제 유저인지 인증하는 개념
- 인가(Authorication): 특정 리소스에 접근이 가능한지 허가를 확인하는 개념
웹 애플리케이션은 서버-클라이언트 구조로 되어있고 Http 프로토콜을 이용하여 비연결성(Connectionless) 무상태(Stateless)로 통신한다. 일반적으로 웹 애플리케이션은 두 가지 방법을 통해서 인증을 처리한다.
- 쿠기-세션 방식의 인증

- 서버가 특정 유저가 로그인 되었다는 상태를 저장하는 방식
- 인증과 관련된 최소한의 정보만 저장해서 로그인을 유지
- JWT 기반 인증

- JWT(JSON Web Token): 인증에 필요한 정보들을 암호화시킨 토큰
- JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별
쿠키와 세션
쿠키와 세션 모두 HTTP에 상태 정보를 유지(Stateful)하기 위해 사용된다. 즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 된다.
- 쿠키: 클라리언트에 저장될 목적으로 생성한 작은 정보를 담은 파일

- Name: 쿠키를 구별하는 데 사용되는 키(중복 X)
- Value: 쿠키의 값
- Domain: 쿠키가 저장된 도메인
- Path: 쿠키가 사용되는 경로
- Expires: 쿠키의 만료기한
- 세션: 서버에서 일정 시간동안 클라이언트 상태를 유지하기 위해 사용
- 서버에서 클리이언트 별로 유일무이한 세션 ID를 부여한 후 클라리언트 별 필요한 정보를 서버에 저장
- 서버에서 생성한 세션 ID는 클라이언트의 쿠키값(세션 쿠키)으로 저장되어 클라이언트 식별에 사용
이제 쿠키를 Spring에서 다뤄보자.
- 쿠키 생성
public static void addCookie(String cookieValue, HttpServletResponse res) { try { cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행 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를 생성자로 받는 객체 생성
- setPath(“/”), setMaxAge(30*60): Path와 만료시간 지정
- HttpServletResponse 객체에 생성한 Cookie 객체를 추가하여 부라우저로 반환(Cookie는 브라우저 Cookie 저장소에 저장)
- Cookie 생성은 범용적으로 사용될 수 있기 때문에 static 메서드로 선언
- 쿠키 읽기
@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를 가져옴
이번에는 세션을 다뤄보자. Servelt에서는 유일무이한 세션 ID를 간편하게 만들수 있는 HttpSession을 제공해준다.
- HttpSession 생성
@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): 세션이 존재할 경우 세션을 반환하고 없을 경우 새로운 세션을 생성
- 반환된 세션은 Cookie 저장소에 ‘JSESSIONID’라는 Name으로 Value에 저장됨
- HttpSession 읽기
@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를 가져옴
JWT
JWT는 JSON 포맷을 이용한 토큰의 한 종류로 쿠키 저장소를 사용하여 JWT를 저장한다.
- 장점
- 동시 접속자가 많을 때 서버 측 부하 낮춤
- Client, Server가 다른 도메인을 사용할 때
- 단점
- 구현의 복잡도 증가
- JWT에 담는 내용이 커질 수록 네트워크 비용 증가
- 기 생성된 JWT를 일부만 만료시킬 방법이 없음
- Secret key 유출 시 JWT 조작 가능
이제 이 JWT를 다뤄보자. 먼저 JWT dependency를 와 application.properties에 secret key를 추가한다.
// build.gradle
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// application.properties
jwt.secret.key=...
JwtUtil 클래스를 먼저 생성한다. 여기서 Util 클래스는 특정 파라미터에 대한 작업을 수행하는 메서드들이 존재하는 클래스를 뜻한다. JWT와 관련된 기능으로는 JWT 생성, 생성된 JWT를 Cookie에 저장, Cookie에 들어있던 JWT 토큰을 Substring, JWT 검증, JWT에서 사용자 정보 보기가 있다. JWT는 ResponseHeader에 담아서 보낼 수도 있고 Cookie 객체에 토큰을 담아 Cookie를 Response 객체에 담는 방법이 있다. 먼저 Cookie를 직접 만들어 보내는 방법을 알아보자.
- 토큰 생성에 필요한 데이터
// Header KEY 값 (Cookie의 Name 값) public static final String AUTHORIZATION_HEADER = "Authorization"; // 사용자 권한 값의 KEY public static final String AUTHORIZATION_KEY = "auth"; // Token 식별자 public static final String BEARER_PREFIX = "Bearer "; // JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시 // 토큰 만료시간 private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분 @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey private String secretKey; private Key key; private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 로그 설정 public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그"); @PostConstruct public void init() { byte[] bytes = Base64.getDecoder().decode(secretKey); key = Keys.hmacShaKeyFor(bytes); }- Base64로 Encode된 Secret Key를 properties에 작성하고 @Value를 통해 가져온다.
- JWT를 생성할 때 가져온 Secret Key로 암호화한다.
- 이때 Encode 된 Secret Key를 Decode 해서 사용
- key는 Decode 된 Secret Key를 담는 객체
- @PostConstruct는 딱 한 번만 받아오면 되는 값을 사용 할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용
- 암호화 알고리즘은 HS256 사용
- 로깅: 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것
- 여기서는 Logback 로깅 프레임워크를 사용
- @slf4j를 사용하는 방법도 있음
- 사용자의 권한의 종류를 Enum을 사용해 관리
package com.sparta.springauth.entity; public enum UserRoleEnum { USER(Authority.USER), // 사용자 권한 ADMIN(Authority.ADMIN); // 관리자 권한 private final String authority; UserRoleEnum(String authority) { this.authority = authority; } public String getAuthority() { return this.authority; } public static class Authority { public static final String USER = "ROLE_USER"; public static final String ADMIN = "ROLE_ADMIN"; } }- JWT를 생성할 때 사용자의 정보로 해당 사용자의 권한을 넣어줄 때 사용
- JWT 생성
// 토큰 생성 public String createToken(String username, UserRoleEnum role) { Date date = new Date(); return BEARER_PREFIX + Jwts.builder() .setSubject(username) // 사용자 식별자값(ID) .claim(AUTHORIZATION_KEY, role) // 사용자 권한 .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 .setIssuedAt(date) // 발급일 .signWith(key, signatureAlgorithm) // 암호화 알고리즘 .compact(); }- 토큰을 builder를 통해 생성한다.
- 경우에 따라 필요한 값들만 넣어도 된다.
- JWT Cookie에 저장
// 생성된 JWT를 Cookie에 저장 public void addJwtToCookie(String token, HttpServletResponse res) { try { token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행 Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value cookie.setPath("/"); // Response 객체에 Cookie 추가 res.addCookie(cookie); } catch (UnsupportedEncodingException e) { logger.error(e.getMessage()); } } - 받아온 Cookie의 Value인 JWT 토큰 Substring
// Cookie에 들어있던 JWT 토큰을 Substring public String substringToken(String tokenValue) { if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) { return tokenValue.substring(7); } logger.error("Not Found Token"); throw new NullPointerException("Not Found Token"); }- StringUtils.hasText를 사용하여 공백, null을 확인하고 startWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인하고 맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라낸다.
- JWT 검증
// JWT 검증 public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (SecurityException | MalformedJwtException | SignatureException e) { logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다."); } catch (ExpiredJwtException e) { logger.error("Expired JWT token, 만료된 JWT token 입니다."); } catch (UnsupportedJwtException e) { logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다."); } catch (IllegalArgumentException e) { logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다."); } return false; }- Jwts.parerBuilder()를 사용하여 JWT를 파싱할 수 있음
- JWT가 위변조되지 않았는지 secretKey(key)값을 넣어 확인
- JWT에서 사용자 정보 가져오기
// JWT에서 사용자 정보 가져오기 public Claims getUserInfoFromToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); }- JWT의 구조 중 Payload 부분에는 토큰이 담긴 정보가 들어 있고 여기에 담긴 정보의 한 조각을 클레임(claim)이라 부르고, 이는 key-value 의 한 쌍으로 이뤄져있음
- 토큰에는 여러 개의 클레임들을 넣을 수 있음
- Jwts.parserBuilder()와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자 정보를 사용
이제 JWT를 테스트 해보기위해 AuthController에 다음을 추가하고 JwtUtil Bean을 가져오기 위해 @RequiredArgsConstructor와 private final JwtUtil jwtUtil;을 추가한다.
@GetMapping("/create-jwt")
public String createJwt(HttpServletResponse res) {
// Jwt 생성
String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);
// Jwt 쿠키 저장
jwtUtil.addJwtToCookie(token, res);
return "createJwt : " + token;
}
@GetMapping("/get-jwt")
public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if(!jwtUtil.validateToken(token)){
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);
return "getJwt : " + username + ", " + authority;
}
사용자 관리하기
회원가입, 로그인 구현
회원가입을 구현하기 전에 build.gradle에 JPA와 MySQL을 추가하고 Database를 연동한다. 페이지 Controller와 html 파일들을 넣어준다.
- HomeController
@Controller public class HomeController { @GetMapping("/") public String home(Model model) { model.addAttribute("username", "username"); return "index"; } }@Controller @RequestMapping("/api") public class UserController { @GetMapping("/user/login-page") public String loginPage() { return "login"; } @GetMapping("/user/signup") public String signupPage() { return "signup"; } }

사용자의 정보를 넣을 User Entity를 작성한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
}
- @Enumerated(value = EnumType.String) : EnumType을 DB 컬럼에 저장할 때 사용하는 애너테이션
회원 등록 시 비밀번호는 사용자가 입력한 문자 그대로 DB에 등록하면 안되고 암호화(Encryption)가 의무이다. 이때 암호화는 복호화가 불가능한 단방향 암호 알고리즘을 사용해야 한다.
이제 회원가입 API를 구현해 본다.
- SignupRequestDto
@Getter @Setter public class SignupRequestDto { private String username; private String password; private String email; private boolean admin = false; private String adminToken = ""; } - UserService
@Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } // ADMIN_TOKEN private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC"; public void signup(SignupRequestDto requestDto) { String username = requestDto.getUsername(); String password = passwordEncoder.encode(requestDto.getPassword()); // 회원 중복 확인 Optional<User> checkUsername = userRepository.findByUsername(username); if (checkUsername.isPresent()) { throw new IllegalArgumentException("중복된 사용자가 존재합니다."); } // email 중복확인 String email = requestDto.getEmail(); Optional<User> checkEmail = userRepository.findByEmail(email); if (checkEmail.isPresent()) { throw new IllegalArgumentException("중복된 Email 입니다."); } // 사용자 ROLE 확인 UserRoleEnum role = UserRoleEnum.USER; if (requestDto.isAdmin()) { if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) { throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다."); } role = UserRoleEnum.ADMIN; } // 사용자 등록 User user = new User(username, password, email, role); userRepository.save(user); } }- ADMIN_TOKEN: 관리자 토큰(현업에서는 이렇게 간단하게 하지 않음)
이번엔 JWT를 이용해 로그인을 구현해본다.
@Setter
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
// ...
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new IllegalArgumentException("등록된 사용자가 없습니다.")
);
// 비밀번호 확인
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
// JWT 생성 및 쿠키에 저장 후 Response 객체에 추가
String token = jwtUtil.createToken(user.getUsername(), user.getRole());
jwtUtil.addJwtToCookie(token, res);
}
}
필터

Filter는 Web 애플리케이션에서 관리되는 영역으로 Client로 부터 오는 요청과 응답에 대해 최초, 최종 단계의 위치이며 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가할 수 있다. 주로 범용적으로 처리해야 하는 작업들, 예를들어 로깅 및 보안 처리에 활용된다. 또한 인증, 인가와 관려된 로직들을 처리할 수도 있다. 또한 Filter는 한 개만 존재하는 것이 아니라 여러 개가 Chain 형식으로 묶여서 처리될 수 있다.
댓글남기기