OAuth2.0
- 사용자 인증 및 권한 부여를 위한 표준 프로토콜이다.
- 주로 사용자 정보에 접근할 때 사용자 비밀번호를 직접적으로 요구하지 않고, 제3자 서비스(클라이언트)가 특정 사용자 데이터를 일정 기간 동안만 접근할 수 있게 권한을 부여하는 방식으로 작동한다.
- 이를 통해 보안성을 높이고 사용자 경험을 개선할 수 있다.
OAuth 2.0의 주요 개념
- Resource Owner(자원 소유자)
- 보통 서비스의 사용자로, 자신의 데이터에 접근할 수 있는 권한을 가진 사람이다. - Client(클라이언트)
- 사용자가 접근을 허용해주는 애플리케이션 또는 서비스를 말한다. - Authorization Server(인증 서버)
- 자원 소유자의 신원을 확인하고, 권한을 부여하는 서버이다.
- 이 서버는 자원 소유자가 허가한 범위내에서 접근 토큰을 클라이언트에게 제공한다. - Resource Server(자원 서버)
- 실제로 보호된 사용자 데이터가 있는 서버이다.
- 예를 들어, 구글의 API 서버가 Resource Server이다.
OAuth 2.0의 작동 과정
- 사용자 인증 요청
: 클라이언트가 Authorization Server로 사용자의 권한을 요청하면, 이때 사용자에게 로그인 화면이 보인다. - 사용자 동의
: 사용자가 로그인하고 특정 권한을 부여하면 Authorization Server는 클라이언트에게 인증 코드를 전달한다. - 토큰 발급 요청
: 클라이언트가 인증 코드를 가지고 Authorization Server에 접근해 Access Token(접근 토큰)을 요청한다. - 토큰 발급 및 자원 접근
: Authorization Server는 클라이언트에게 Access Token을 발급하고, 클라이언트는 이 토큰을 이용해 Resource Server에 접근할 수 있다. - 자원 사용
: 클라이언트는 Access Token을 이용하여 사용자의 데이터를 안전하게 접근하고 필요한 작업을 수행한다.
OAuth 2.0의 장점
- 보안성
- 비밀번호를 직접적으로 노출하지 않으므로 더 안전하다. - 사용 편의성
- 한 번 로그인하면 다양한 앱에서 편리하게 이용할 수 있다. - 토큰 갱신
- Access Token이 만료되면 Refresh Token을 통해 재발급받을 수 있다.
본격적으로 소셜 로그인을 하나씩 구현해보기위해서는
먼저 해야 할 작업은 각 소셜 로그인 공급자에서 OAuth 클라이언트를 등록해서 클라이언트 ID와 시크릿 키를 발급받는 것이다.
이 단계에서 발급받은 키들은 이후에 소셜 로그인 인증 과정에서 필요하다.
1. OAuth 클라이언트 등록하기
Kakao(카카오)
1. 카카오 개발자 사이트( https://developers.kakao.com )에 접속 후 로그인한다.
2. "내 애플리케이션"에서 애플리케이션 생성한다.
3. 생성 후 카카오 로그인 활성화하고, REST API 키와 리다이렉트 URI를 설정해준다.
- REST API 키는 프로젝트에서 카카오 API 요청 시 사용되는 고유 키로, 이 값을 복사해둔다.
Naver(네이버)
1. 네이버 개발자 사이트( https://developers.naver.com/main )에 접속 후 로그인한다.
2. "Application"에서 애플리케이션을 등록한다.
3. 애플리케이션 등록할 때,
- 어떤 환경에서 사용할지
- 사용할 환경의 서비스 URL과 Callback URL을 설정해준다.
4. 애플리케이션 등록 후, '내 애플리케이션'에서 클라이언트ID와 클라이언트 Secret을 확인해준다.
- 네이버에서는 REST API 키를 사용하는 카카오와 달리 클라이언트ID와 클라이언트 Secret을 사용하여 인증 과정을 진행한다.
Google(구글)
1. Google Cloud Console( https://console.cloud.google.com/welcome?pli=1&project=sixth-foundry-280506 )에 접속 후 로그인한다.
2. 상단의 프로젝트 선택 창에서 새 프로젝트를 만든다.
3. OAuth 동의 화면을 눌러서 단계별로 설정해준다.
3-1. 다음 단계에서 앱 관련 정보를 입력해준다.
3-2. 사용자가 소셜 로그인 시 제공하는 정보의 범위를 선택해준다.
3-3. 아직 개발단계이면 테스트를 같이 할 팀원분들을 '테스트 사용자'로 같이 등록해준다.
4. OAuth 동의 화면의 설정이 끝났으면, 사용자 인증 정보에서 "사용자 인증 정보 만들기"를 눌러준다.
- "웹 애플리케이션"으로 생성해줘야 리디렉션 URI를 설정할 수 있습니다..!
(저는 데스트콥 앱으로 생성해서 못찾아서 삭제하고 다시 만들었다는,,,)
GitHub(깃헙)
1. GitHub Developer Setting( https://github.com/settings/developers )에 접속 후 로그인하고, 우측 상단의 프로필 이미지를 클릭하고 Settings로 이동한다. 이동 후 왼쪽 메뉴에서 "Developer settings"를 선택한다.
2. OAuth Apps를 선택하여 앱을 등록해준다.
3. Register application 버튼을 눌러 등록을 완료한 후에 나머지 설정을 해준다. 그리고 중간에 있는 "Generate a new client secret"을 눌러주면 깃헙을 다시 로그인해 준 다음에 클라이언트 Secret이 생성된다.
이제 제가 소셜로그인을 구현한 코드를 공유하겠습니다.
참고로 저는 동일 사용자인지 이메일을 통해 확인한 후에 기본 계정과 연동하고 싶으면 비밀번호를 입력하라는 로직을 완성하고 싶었고, 지금 공유해드리는 코드에는 비밀번호 입력하는 로직만 빠져있습니다!
일단, 제가 작성했던 흐름대로 코드를 공유해드리도록 하겠습니다.
SocailLoginConfig
package com.sparta.doguin.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class SocialLoginConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
// 서버의 응답대기시간 최대 5분 설정
.setReadTimeout(Duration.ofMinutes(5))
// 서버에 연결 시도 시간 최대 5분 설정
.setConnectTimeout(Duration.ofMinutes(5))
.build();
}
}
SecurityConfig
적용하려고하는 프로젝트는 spring security를 사용하고 있어서 config에 밑의 코드를 추가해주었다.
.requestMatchers("/api/v1/auth/oauth2/authorize/**").permitAll()
application.yml
- .gitignore을 사용하여 환경변수를 .env파일에 연결중이기때문에 yml파일에 밑에 코드를 추가해주었다.
# Social Login config
social:
kakao:
client-id: ${KAKAO_CLIENT_ID}
redirect-uri: http://localhost:8080/api/v1/auth/oauth2/authorize/kakao
scope:
- profile_nickname
- account_email
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
redirect-uri: http://localhost:8080/api/v1/auth/oauth2/authorize/naver
scope: "profile"
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: http://localhost:8080/api/v1/auth/oauth2/authorize/google
scope:
- email
- profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
redirect-uri: http://localhost:8080/api/v1/auth/oauth2/authorize/github
scope: "user:email"
.env
# Servert
SERVER_PORT=8080
# MYSQL
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_DB_NAME=doguin
MYSQL_NAME=root
MYSQL_PASSWORD=123123
# JWT
JWT_SECRET_KEY=7JWI7KCE7ZWc7YKk7JuM65Oc65286rOg7IOd6rCB7ZWc7Iic6rCE64u57ZWY64qU6rKD7J2064uk
ACCESS_KEY=AKIA5CBDQZW3FIISR67I
SECRET_KEY=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
REGION_STATIC=ap-northeast-2
BUCKET=doguin
# Redis
REDIS_HOST=localhost
REDIS_PORT=6380
# 카카오
KAKAO_CLIENT_ID=4c20fa460462cdcce6dc2f520464610e
# 네이버
NAVER_CLIENT_ID=105NIEggD1Vv4xj2p90y
NAVER_CLIENT_SECRET=vwkaSXJMBm
# 구글
GOOGLE_CLIENT_ID=528619377683-a9ut4qmd1s1jm9s1mplvcbpnf63bb70r.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
# 깃허브
GITHUB_CLIENT_ID=Ov23liUpy59bQXa5zJZj
GITHUB_CLIENT_SECRET=d3c8d7c40d654bae62a975dfbe78171cc33021d1
# 디스코드 알람 URL
DISCORD_URL_REPORT=https://discord.com/api/webhooks/1301560956601503744/BXhXHSzcczr2YYikY5rCB0PMO_q6kzoni45Yf47I8r3yV01_H5u4krsJ0UsitNlNAdiC
DISCORD_URL_ERROR=https://discord.com/api/webhooks/1301759267333734470/sN-Q-qREoPg1YKhSUsw_wiNIEDh4s-f7HFj4Sq0BSjs8zaZsDZc2wNyUdik40c6LQnnY
SLACK_BOT_TOKEN=xoxb-7931443389792-7984372787920-YU71zG9O4Gpj4QdHUc674kMe
AuthController
// 소셜로그인
@GetMapping("/oauth2/authorize/{provider}")
public ResponseEntity<ApiResponse<String>> socialLogin(
@PathVariable("provider") String provider,
@RequestParam("code") String code, HttpServletResponse response) throws JsonProcessingException {
ApiResponse<String> apiResponse = socialLoginService.socialLogin(provider, code, response);
return ApiResponse.of(apiResponse);
}
User(entity)
- entity파일에 각 소셜플랫폼에서 받아오는 사용자 정보를 User 객체로 반환해주기 위해 밑의 메서드를 추가해주었다.
// 소셜 로그인 메서드
public void socialLogin(String email, String nickname) {
this.email = email;
this.nickname = nickname;
this.userRole = UserRole.ROLE_USER;
}
SocialLoginService
package com.sparta.doguin.domain.user.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.doguin.domain.common.exception.UserException;
import com.sparta.doguin.domain.common.response.ApiResponse;
import com.sparta.doguin.domain.common.response.ApiResponseUserEnum;
import com.sparta.doguin.domain.user.entity.User;
import com.sparta.doguin.domain.user.enums.UserRole;
import com.sparta.doguin.domain.user.enums.UserType;
import com.sparta.doguin.domain.user.repository.UserRepository;
import com.sparta.doguin.security.JwtUtil;
import com.sparta.doguin.security.dto.JwtUtilRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class SocialLoginService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final RestTemplate restTemplate;
private final PasswordEncoder passwordEncoder;
@Value("${social.kakao.client-id}")
private String kakaoClientId;
@Value("${social.naver.client-id}")
private String naverClientId;
@Value("${social.naver.client-secret}")
private String naverClientSecret;
@Value("${social.google.client-id}")
private String googleClientId;
@Value("${social.google.client-secret}")
private String googleClientSecret;
@Value("${social.github.client-id}")
private String githubClientId;
@Value("${social.github.client-secret}")
private String githubClientSecret;
/**
* 소셜 로그인 서비스를 통해 인증을 수행하는 메서드입니다.
*
* @param provider 소셜 로그인 제공자 이름 (kakao, naver, google, github)
* @param code 인증 코드
* @param response HTTP 응답 객체로, JWT 토큰을 포함하여 반환합니다.
* @return ApiResponse<String>으로 소셜 로그인 결과를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
* @author 황윤서
* @since 1.0
*/
public ApiResponse<String> socialLogin(String provider, String code, HttpServletResponse response) throws JsonProcessingException {
String accessToken = getAccessToken(provider, code);
// accessToken을 사용해 사용자 정보를 가져온 후 handleUserLogin에 전달
User userFromSocial = fetchUserInfoFromProvider(accessToken, provider);
return handleUserLogin(userFromSocial, response, null);
}
/**
* AccessToken을 발급받기 위한 공통 메서드입니다.
* 소셜 로그인 제공자의 토큰 엔드포인트와 클라이언트 자격 증명을 사용하여 AccessToken을 요청합니다.
*
* @param provider 소셜 로그인 제공자 이름 (kakao, naver, google, github)
* @param code 인증 코드
* @return 발급받은 AccessToken을 반환합니다.
* @throws UserException 잘못된 provider 또는 AccessToken 발급 실패 시 예외 발생
* @author 황윤서
* @since 1.0
*/
private String getAccessToken(String provider, String code) {
String redirectUri = "http://localhost:8080/api/v1/auth/oauth2/authorize/" + provider;
String url;
String clientId;
String clientSecret = null; // 필요한 경우에만 할당
// provider에 따른 URL, clientId, clientSecret 설정
switch (provider) {
case "kakao":
url = "https://kauth.kakao.com/oauth/token";
clientId = kakaoClientId;
break;
case "naver":
url = "https://nid.naver.com/oauth2.0/token";
clientId = naverClientId;
clientSecret = naverClientSecret;
break;
case "google":
url = "https://oauth2.googleapis.com/token";
clientId = googleClientId;
clientSecret = googleClientSecret;
break;
case "github":
url = "https://github.com/login/oauth/access_token";
clientId = githubClientId;
clientSecret = githubClientSecret;
break;
default:
throw new UserException(ApiResponseUserEnum.INVALID_SOCIAL_PROVIDER);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
if ("github".equals(provider)) {
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); // 깃허브 JSON 응답 설정
}
// 파라미터 설정
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", clientId);
params.add("redirect_uri", redirectUri);
params.add("code", code);
// clientSecret이 존재하는 경우에만 추가
if (clientSecret != null) {
params.add("client_secret", clientSecret);
}
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
Map<String, Object> responseBody = restTemplate.postForObject(url, request, Map.class);
if (responseBody == null || !responseBody.containsKey("access_token")) {
log.error("Failed to fetch access token from {} provider. Response: {}", provider, responseBody);
throw new UserException(ApiResponseUserEnum.FAILED_TO_FETCH_SOCIAL_ACCESS_TOKEN);
}
return (String) responseBody.get("access_token");
}
/**
* 소셜 로그인한 사용자 정보로 로그인/회원가입을 처리하는 메서드입니다.
*
* @param userFromSocial 소셜 로그인에서 가져온 사용자 정보
* @param response HTTP 응답 객체로, JWT 토큰을 포함하여 반환합니다.
* @param rawPassword 비밀번호 (기존 사용자인 경우에만 필요)
* @return ApiResponse<String>으로 로그인 처리 결과를 반환합니다.
* @author 황윤서
* @since 1.0
*/
public ApiResponse<String> handleUserLogin(User userFromSocial, HttpServletResponse response, String rawPassword) {
// 같은 이메일이 있는지 확인
Optional<User> existingUserOpt = userRepository.findByEmail(userFromSocial.getEmail());
if (existingUserOpt.isPresent()) {
// 동일 이메일이 있는 경우에만 비밀번호 입력 여부 확인 후 같은 사용자일 경우 기존 계정과 연동
if (rawPassword == null || rawPassword.isEmpty()) {
// 비밀번호가 입력되지 않았다면, 비밀번호 입력을 요구하는 메시지 반환
return ApiResponse.of(ApiResponseUserEnum.PASSWORD_REQUIRED);
}
// 비밀번호 확인 로직
if (!passwordEncoder.matches(rawPassword, existingUserOpt.get().getPassword())) {
// 비밀번호가 일치하지 않으면 오류 메시지 반환
return ApiResponse.of(ApiResponseUserEnum.INVALID_PASSWORD);
}
// 비밀번호가 일치하는 경우, 기존 계정과 연동하여 소셜 로그인 진행
User existingUser = existingUserOpt.get();
addJwtToResponse(existingUser, response);
return ApiResponse.of(ApiResponseUserEnum.USER_SOCIALOGIN_SUCCESS);
} else {
// 기존 사용자가 없는 경우 신규 사용자로 등록
User newUser = User.builder()
.email(userFromSocial.getEmail())
.nickname(userFromSocial.getNickname())
.password(null)
.userType(UserType.INDIVIDUAL)
.userRole(UserRole.ROLE_USER)
.build();
userRepository.save(newUser);
// JWT 생성 및 헤더 추가
addJwtToResponse(newUser, response);
return ApiResponse.of(ApiResponseUserEnum.USER_SOCIALOGIN_SUCCESS);
}
}
/**
* JWT 토큰을 생성하고, 응답 헤더에 추가하는 메서드입니다.
*
* @param user 토큰을 생성할 사용자 정보
* @param response HTTP 응답 객체로, JWT 토큰을 포함하여 반환합니다.
*/
private void addJwtToResponse(User user, HttpServletResponse response) {
JwtUtilRequest.CreateToken createToken = new JwtUtilRequest.CreateToken(
user.getId(),
user.getEmail(),
user.getNickname(),
user.getUserType(),
user.getUserRole()
);
String jwt = jwtUtil.createToken(createToken);
jwtUtil.addTokenToResponseHeader(jwt, response);
}
/**
* AccessToken을 사용하여 소셜 제공자의 사용자 정보를 가져오는 메서드입니다.
*
* @param accessToken 소셜 제공자에서 발급받은 액세스 토큰
* @param provider 소셜 로그인 제공자 이름
* @return User 객체로 사용자 정보를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
*/
private User fetchUserInfoFromProvider(String accessToken, String provider) throws JsonProcessingException {
switch (provider) {
case "kakao":
return fetchKakaoUserInfo(accessToken);
case "naver":
return fetchNaverUserInfo(accessToken);
case "google":
return fetchGoogleUserInfo(accessToken);
case "github":
return fetchGitHubUserInfo(accessToken);
default:
throw new UserException(ApiResponseUserEnum.INVALID_SOCIAL_PROVIDER);
}
}
/**
* 카카오 사용자 정보를 가져오는 메서드입니다.
*
* @param accessToken 카카오에서 발급받은 액세스 토큰
* @return User 객체로 사용자 정보를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
*/
private User fetchKakaoUserInfo(String accessToken) throws JsonProcessingException {
String url = "https://kapi.kakao.com/v2/user/me";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<String> request = new HttpEntity<>(headers);
String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody();
if (responseBody == null) {
log.error("Kakao API response body is null.");
throw new UserException(ApiResponseUserEnum.FAILED_TO_FETCH_SOCIAL_USER_INFO);
}
JsonNode jsonNode = new ObjectMapper().readTree(responseBody);
String email = jsonNode.get("kakao_account").get("email").asText();
String nickname = jsonNode.get("properties").get("nickname").asText();
User user = new User();
user.socialLogin(email, nickname);
return user;
}
/**
* 네이버 사용자 정보를 가져오는 메서드입니다.
*
* @param accessToken 네이버에서 발급받은 액세스 토큰
* @return User 객체로 사용자 정보를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
*/
private User fetchNaverUserInfo(String accessToken) throws JsonProcessingException {
String url = "https://openapi.naver.com/v1/nid/me";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> request = new HttpEntity<>(headers);
String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody();
if (responseBody == null) {
log.error("Naver API response body is null.");
throw new UserException(ApiResponseUserEnum.FAILED_TO_FETCH_SOCIAL_USER_INFO);
}
JsonNode jsonNode = new ObjectMapper().readTree(responseBody).get("response");
String email = jsonNode.get("email").asText();
String nickname = jsonNode.get("nickname").asText();
User user = new User();
user.socialLogin(email, nickname);
return user;
}
/**
* 구글 사용자 정보를 가져오는 메서드입니다.
*
* @param accessToken 구글에서 발급받은 액세스 토큰
* @return User 객체로 사용자 정보를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
*/
private User fetchGoogleUserInfo(String accessToken) throws JsonProcessingException {
String url = "https://www.googleapis.com/oauth2/v3/userinfo";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> request = new HttpEntity<>(headers);
String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody();
if (responseBody == null) {
log.error("Google API response body is null.");
throw new UserException(ApiResponseUserEnum.FAILED_TO_FETCH_SOCIAL_USER_INFO);
}
JsonNode jsonNode = new ObjectMapper().readTree(responseBody);
String email = jsonNode.get("email").asText();
String nickname = jsonNode.get("name").asText();
User user = new User();
user.socialLogin(email, nickname);
return user;
}
/**
* 깃헙 사용자 정보를 가져오는 메서드입니다.
*
* @param accessToken 깃헙에서 발급받은 액세스 토큰
* @return User 객체로 사용자 정보를 반환합니다.
* @throws JsonProcessingException JSON 처리에 실패할 경우 예외 발생
*/
private User fetchGitHubUserInfo(String accessToken) throws JsonProcessingException {
String url = "https://api.github.com/user";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> request = new HttpEntity<>(headers);
String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody();
if (responseBody == null) {
log.error("GitHub API response body is null.");
throw new UserException(ApiResponseUserEnum.FAILED_TO_FETCH_SOCIAL_USER_INFO);
}
JsonNode jsonNode = new ObjectMapper().readTree(responseBody);
String email = jsonNode.has("email") ? jsonNode.get("email").asText() : null;
String nickname = jsonNode.get("login").asText();
User user = new User();
user.socialLogin(email, nickname);
return user;
}
}
'TIL(Today I Learned)' 카테고리의 다른 글
[TIL] Local에서 Locust 설치 및 사용법 (2) | 2024.11.08 |
---|---|
[TIL] 마이페이지 성능 최적화 : 로컬 캐시(Ehcache) 적용 (0) | 2024.11.01 |
[Spring] 스프링에서 알아두면 좋은 어노테이션(Annotation) 모음 (0) | 2024.10.26 |
[Spring Boot] 포스트맨으로 테스트하기 쉽게 환경변수 설정과 'Barere 접두사'를 제거한 순수한 토큰 헤더로 받기 (0) | 2024.10.25 |
[Spring] QueryDSL 관련 코드 정리 (2) | 2024.10.10 |