| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- restTemplate
- 컴퓨터 구조
- spring boot
- QueryDSL
- 연관관계
- 백엔드공부
- JWT
- 개발자
- 핵심가이드
- 스프링부트
- ORM
- TestCode
- queue
- error
- 자료구조
- SpringBoot
- 백엔드스쿨
- Java
- SpringSecurity
- JPA
- 백엔드
- LinkedList
- 프로그래머스
- 백준
- 제로베이스
- 서버
- array
- spring
- 컴퓨터 공학
- actuator
Archives
- Today
- Total
be_better
[Springboot] 서비스와 인증과 권한 부여 - 스프링 시큐리티와 JWT 적용 본문
목차
스프링 시큐리티와 JWT 적용
SpringSecurity 의존성
프로젝트 생성후 의존성을 추가
// gradle
dependencies {
// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt 토큰
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
// maven
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
UserDetails와 UserDetailsService 구현
data/entity/User.java
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, unique = true)
private String uid;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default // 필드값이 초기값을 갖고 있을 때 사용
private List<String> roles = new ArrayList<>();
/*
계정이 가지고 있는 권한 목록 리턴
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
/*
계정의 이름을 리턴
일반적으로 아이디(이메일)를 리턴
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
/*
계정이 만료되었는지를 리턴
true는 만료되지 않았음을 의미
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
/*
계정이 잠겨있는지 리턴
true는 잠겨있지 않음을 의미
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
/*
비밀번호가 만료되었는지 리턴
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/*
계정이 활성화되어 있는지 리턴
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails는
UserDetailsService 를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할
- getAuthorities(): 계정이 가지고 있는 권한 목록 리턴
- getPassword(): 계정의 비밀번호 리턴
- getUsername(): 계정의 이름 리턴, 일반적으로 아이디 리턴
- isAccountNonExpired(): 계정이 만료되었는지 리턴. true -> 만료 x
- isAccountNonLocked(): 계정이 잠겨있는지 리턴. true -> unLock
- isCredentialNonExpired(): 비밀번호가 만료되었는지 리턴. true -> 만료 x
- isEnabled(): 계정이 활성화 되어 있는지 리턴. true -> 활성화 상태
UserRepository 구현 (data/repository/UserRepository.java)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
UserDetailsServiceImpl 구현 (service/impl/UserDetailsService.java)
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
/*
Username(uid) 로 User 조회
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("[loadUserByUsername] loadUserByUsername 수행, username : {}", username);
return userRepository.getByUid(username);
}
}
UserDetailsService
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetails
- 스프링 시큐리티에서 제공하는 개념.
- UserDetails의 username은 각 사용자를 구분하는 ID(email, id, 등..)
- UserDetails의 구현체로 User 엔티티를 생성했기 때문에
UserDetailsServiceImpl에서는User 객체를 리턴하게끔 구현한 것
JwtTokenProvider 구현
이제 UserDetails에서 JWT 토큰 생성에 필요한 정보를 얻을 수 있게 되었다.
JWT 토큰을 생성하는 JwtTokenProvider 생성 (config/security/JwtTokenProvider.java)
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final UserDetailsService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60;
@PostConstruct
protected void init() {
log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
secretKey = Base64
.getEncoder()
.encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
public String createToken(String userUid, List<String> roles) {
log.info("[createToken] 토큰 생성 시작");
Claims claims = Jwts.claims()
.setSubject(userUid);
claims.put("roles", roles); // 일종의 map
Date now = new Date(System.currentTimeMillis());
String token = Jwts.builder() // 토큰 생성
.setClaims(claims)
// 시작 시간 : 현재 시간기준으로 만들어짐
.setIssuedAt(now)
// 끝나는 시간 : 지금 시간 + 유지할 시간(입력받아옴)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.info("[createToken] 토큰 생성 완료");
return token;
}
/*
토큰 인증 정보 조회
*/
public Authentication getAuthentication(String token) {
log.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails =
userDetailsService.loadUserByUsername(this.getUsername(token));
log.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails Username : {}", userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(
userDetails, "", userDetails.getAuthorities()
);
}
/*
token 으로 uid 추출
*/
private String getUsername(String token) {
log.info("[getUsername] 토큰 기반 회원 email 추출");
String info = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token) // Jws<Claims>
.getBody() // Claims
.getSubject(); // uid 값
log.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
/*
헤더로부터 token 추출
*/
public String resolveToken(HttpServletRequest request) {
log.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
/*
토큰 유효성 체크
*/
public boolean validateToken(String token) {
log.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims =
Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
log.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
application.yml 파일에 @Value 값 정의
springboot:
jwt:
secret: "asdfasdfqwer"
yml 파일에서 값을 가져올 수 없다면 직접 지정해준 'secretKey' 값을 사용한다.
init()
- @PostConstruct - 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 지칭
- secretKey를 Base64 형식으로 인코딩
createToken()
- JWT 토큰의 payload 에 값을 넣기 위해 Claims 사용.
- User의 uid 값을 setSubject() 를 통해 sub 속성에 추가.
- claims.put("key", "value") 로 role 값을 별개로 추가.
- Jwts.builder()를 사용해 토큰 생성
getAuthentication()
- 필터에서 인증 성공 -> SecurityContextHolder에 저장할 Authentication 생성
- UsernamePasswordAuthenticationToken 사용해 Authentication 구현
UsernamePasswordAuthenticationToken 구조

Authentication, CredentialContainer 인터페이스를 구현하는
AbstractAuthenticationToken를 상속하는 구조
getUsername()
Jwts.parser()를 통해 secretKey 값을 확인 후 sub 값 추출
resolveToken()
- 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴
- 클라이언트가 헤더를 통해 JWT 토큰 값을 전달해야 추출 가능
- 헤더의 이름은 임의로 변경 가능
validateToken()
클레임의 유효기간 체크 후 boolean 타입의 값 리턴
JwtAuthenticationFilter 구현
JwtAuthenticationFilter
- JWT 토큰을 인증하고 SecurityContextHolder에 추가하는 필터 설정 클래스
JwtAuthenticationFilter (config/security/JwtAuthenticationFilter.java)
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
log.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
log.info("[doFilterInternal] token 값 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(request, response);
}
}
doFilterInternal()
- 마지막 줄 doFilter()
- 서블릿 실행 메서드.
- doFilter() 앞에 작성한 코드는 서블릿이 실행되기 전에 실행
- 뒤에 작성된 코드는 서블릿이 실행된 후 실행
- 토큰이 유효하다면 Authentication 객체를 생성해 SecurityContextHolder에 추가
SeuciryConfiguration 구현
스프링 시큐리티 관련 설정.
WebSecurityConfigureAdapter를 상속받는 Configuration 클래스 구현 (대표적인 방법)
SecurityConfiguration 클래스 (config/security/SecurityConfiguration.java)
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
log.info("[configure] Security configure start");
httpSecurity.httpBasic().disable()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
.and()
.authorizeRequests()
.antMatchers("/sign-api/sign-up", "/sign-api/sign-in",
"/sign-api/exception").permitAll()
.antMatchers(HttpMethod.GET, "/product/**").permitAll()
// 그 외
.antMatchers("**exception**").permitAll()
.anyRequest().hasRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers(
"/v2/api-docs",
"/swagger-resources/**",
"/swagger-ui.html",
"/webjars/**",
"/swagger/**",
"/sign-api/exception"
);
}
}
HttpSecurity configure()
대표적인 기능
- 리소스 접근 권한 설정
- 인증 실패 시 발생하는 예외 처리
- 인증 로직 커스터마이징
- csrf, cors 등의 스프링 시큐리티 설정
세부 로직
- httpBasic().disable()
- 기본값인 UI 사용 설정 비활성화
- csrf().disable()
- CSRF는 Cross-Site Request Forgery의 줄임말 - '사이트 간 요청 위조'를 의미
- REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화
- csrf() 메서드는 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰 검증
- 브라우저 사용 환경이 아니라면 비활성화해도 크게 문제가 되지 않음
사이트 간 요청 위조
웹 애플리케이션의 취약점 중 하나로서
사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써
특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 하는 공격 방법
- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
- REST API 기반 애플리케이션의 동작 방식 설정
- Session 대신 JWT 토큰으로 인증 처리 하기 때문에 STATELESS 로 설정
- authorizeRequest()
- 애플리케이션에 들어오는 요청에 대한 사용 권한 체크
- antMatchers() 메서드는 antPattern을 통해 권한 설정
- permitAll(): 모두에게 허용
- '/product'로 시작하는 GET 요청 모두 허용
- 'exception' 단어가 들어간 경로는 모두 허용
- 기타 요청은 인증된 권한을 가진 사용자에게 허용
- exceptionHandling().accessDeniedHandler()
- 권한 확인 과정에서 예외 발생할 경우 예외 전달
- exceptionHandling().authenticationEntryPoint()
- 인증 과정에서 예외 발생할 경우 예외 전달
- addFilterBefore()
- 커스텀 필터를 어느 필터 앞에 추가할 것인지 설정할 것인지 결정
- UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter를 추가한다는 의미
WebSecurity configure()
WebSecurity는 HttpSecurity 앞단에 적용됨.
인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용됨.
커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현
CustomAccessDeniedHandler 클래스 (config/secutiry/CustomAccessDeniedHandler.java)
@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
log.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
- AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외.
- 이 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용됨.
CustomAuthenticationEntryPoint 클래스 (config/secutiry/CustomAuthenticationEntryPoint.java)
@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
log.info("[commence] 인증 실패로 response.sendError 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
EntryPointErrorResponse 클래스 (data/dto/EntryPointErrorResponse.java)
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
회원가입과 로그인 구현
SignUpResultDto 클래스 (data/dto/SignUpResultDto.java)
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String ms;
}
SignInResultDto 클래스 (data/dto/SignInResultDto.java)
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto{
private String token;
@Builder
public SignInResultDto(boolean success, int code, String msg, String token) {
super(success, code, msg);
this.token = token;
}
}
SignService 인터페이스 (service/SignService.java)
public interface SignService {
SignUpResultDto signUp(String id, String password, String name, String role);
SignInResultDto signIn(String id, String password) throws RuntimeException;
}
SignServiceImpl 클래스 (service/impl/SignServiceImpl.java)
@Slf4j
@Service
@RequiredArgsConstructor
public class SignServiceImpl implements SignService {
public UserRepository userRepository;
public JwtTokenProvider jwtTokenProvider;
public PasswordEncoder passwordEncoder;
@Autowired
public SignServiceImpl(UserRepository userRepository,
JwtTokenProvider jwtTokenProvider,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.jwtTokenProvider = jwtTokenProvider;
this.passwordEncoder = passwordEncoder;
}
@Override
public SignUpResultDto signUp(String id, String password, String name, String role) {
log.info("[getSignUpResult] 회원 가입 정보 전달");
User user;
if (role.equalsIgnoreCase("admin")) {
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList("ROLE_ADMIN"))
.build();
} else {
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList("ROLE_USER"))
.build();
}
User savedUser = userRepository.save(user);
SignUpResultDto signUpResultDto = new SignUpResultDto();
log.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
if (!savedUser.getName().isEmpty()) {
log.info("[getSignUpResult] 정상 처리 완료");
setSuccessResult(signUpResultDto);
} else {
log.info("[getSignUpResult] 실패 처리 완료");
setFailResult(signUpResultDto);
}
return signUpResultDto;
}
@Override
public SignInResultDto signIn(String id, String password) {
log.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
User user = userRepository.getByUid(id);
log.info("[getSignInResult] id : {}", id);
log.info("[getSignInResult] 패스워드 비교");
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new RuntimeException();
}
log.info("[getSignInResult] 패스워드 일치");
log.info("[getSignInResult] SignInResultDto 객체 생성");
SignInResultDto signInResultDto =
SignInResultDto.builder()
.token(jwtTokenProvider.createToken(
String.valueOf(user.getUid()),
user.getRoles()
)
)
.build();
log.info("[getSignInResult] SignInResultDto 객체에 값 주입");
setSuccessResult(signInResultDto);
return signInResultDto;
}
private void setSuccessResult(SignUpResultDto result) {
result.setSuccess(true);
result.setCode(CommonResponse.SUCCESS.getCode());
result.setMsg(CommonResponse.SUCCESS.getMsg());
}
private void setFailResult(SignUpResultDto result) {
result.setSuccess(false);
result.setCode(CommonResponse.FAIL.getCode());
result.setMsg(CommonResponse.FAIL.getMsg());
}
}
PasswordEncoder 활용을 위해 Bean으로 등록해야 함.
PasswordEncoderConfiguration 클래스 (config/PasswordEncoderConfiguration.java)
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
SignServiceImpl 내부 로직 동작 과정
- id를 기반으로 UserRepository에서 User 엔티티를 가져옴.
- PasswordEncoder를 사용해 db에 저장되어있는 password 와 입력받은 password 일치 여부 확인
- password가 일치해서 인증 통과 -> JwtTokenProvider를 통해 id와 role 값으로 토큰 생성 후 전달
CommonResponse 클래스 (common/CommonResponse.java)
public enum CommonResponse {
SUCCESS(0, "Success"), FAIL(-1, "Fail");
int code;
String msg;
CommonResponse(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
SignController 전체 코드 (controller/SignController.java)
@Slf4j
@RestController
@RequestMapping("/sign-api")
public class SignController {
private final SignService signService;
@Autowired
public SignController(SignService signService) {
this.signService = signService;
}
@PostMapping(value = "/sign-in")
public SignInResultDto signIn(
@ApiParam(value = "ID", required = true) @RequestParam String id,
@ApiParam(value = "Password", required = true) @RequestParam String password)
throws RuntimeException {
log.info("[signIn] 로그인 시도. id : {}, pw : ****", id);
SignInResultDto signInResultDto = signService.signIn(id, password);
if (signInResultDto.getCode() == 0) {
log.info("[signIn] 정상적으로 로그인되었습니다. id : {}, token : {}", id, signInResultDto.getToken());
}
return signInResultDto;
}
@PostMapping(value = "/sign-up")
public SignUpResultDto signUp(
@ApiParam(value = "ID", required = true) @RequestParam String id,
@ApiParam(value = "비밀번호", required = true) @RequestParam String password,
@ApiParam(value = "이름", required = true) @RequestParam String name,
@ApiParam(value = "권한", required = true) @RequestParam String role) {
log.info("[signUp] 회원가입을 수행합니다. id : {}, password: ****, name : {}, role : {}", id, name, role);
SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);
log.info("[signUp] 회원가입을 완료했습니다. id : {]", id);
return signUpResultDto;
}
@PostMapping(value = "exception")
public void exceptionTest() throws RuntimeException {
throw new RuntimeException("접근이 금지되었습니다.");
}
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
HttpHeaders responseHeaders = new HttpHeaders();
// responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
log.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", "에러 발생");
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
}'Springboot > 이론' 카테고리의 다른 글
| [Springboot] 서비스의 인증과 권한 부여 - JWT (1) | 2023.12.06 |
|---|---|
| [Springboot] Spring Security 필터 동작 (0) | 2023.12.06 |
| [Springboot] 서비스의 인증과 권한 부여 - SpringSecurity 소개 (0) | 2023.12.06 |
| [Springboot] 서버 간 통신 - WebClient (0) | 2023.11.29 |
| [Springboot] 서버 간 통신 - RestTemplate 사용해보기 (0) | 2023.11.29 |