JWT 토큰 활용한 프로젝트 - 미니 이커머스 프로젝트 진행 과정 정리
Springboot (패키지별로 정리)
•
config
◦
WebSecurityConfig
package com.kosta.config;
import java.util.Collections;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import com.kosta.repository.UserRepository;
import com.kosta.security.JwtAuthenticationFilter;
import com.kosta.security.JwtAuthenticationService;
import com.kosta.security.JwtProperties;
import com.kosta.security.JwtProvider;
import com.kosta.security.LoginCustomAuthenticationFilter;
import com.kosta.util.TokenUtils;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
	private final UserDetailsService userDetailsService;
	private final UserRepository userRepository;
	private final JwtProperties	jwtProperties;
	
	// JWT PROVIDER 생성자 호출
	private JwtProvider jwtProvider() {
		return new JwtProvider(userDetailsService, jwtProperties);
	}
	
	// TokenUtils 생성자 호출
	private TokenUtils tokenUtils() {
		return new TokenUtils(jwtProvider(), jwtProperties);
	}
	
	// JwtAuthenticationService 생성자 호출
	private JwtAuthenticationService jwtAuthenticationService() {
		return new JwtAuthenticationService(tokenUtils(), userRepository);
	}
	
	// 인증 관리자 (AuthenticationManager) 설정
	@Bean
	AuthenticationManager authenticationManager() {
		DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
		authProvider.setUserDetailsService(userDetailsService);
		authProvider.setPasswordEncoder(bCryptPasswordEncoder());
		return new ProviderManager(authProvider);
	}
	
	// 암호화 빈
	@Bean
	BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	// HTTP 요청에 따른 보안 구성
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		// 경로 권한 설정
		http.authorizeHttpRequests(auth ->
			// 특정 URL 경로에 대해서는 인증 없이 접근 가능
			auth.requestMatchers(
				new AntPathRequestMatcher("/api/join"), // 회원가입
				new AntPathRequestMatcher("/api/duplicate"), // 이메일 중복체크
				new AntPathRequestMatcher("/api/refresh"), // 토큰 재발급
				new AntPathRequestMatcher("/api/oauth/**") // 토큰 재발급
			).permitAll()
			// AuthController 중 나머지들은 "ADMIN"만 가능
			.requestMatchers(
				new AntPathRequestMatcher("/api/product", "POST"),
				new AntPathRequestMatcher("/api/product", "DELETE"),
				new AntPathRequestMatcher("/api/product", "PATCH")
			).hasRole("ADMIN")
			// 그 밖의 다른 요청들은 인증을 통과한(로그인한) 사용자라면 모두 접근할 수 있도록 한다.
			.anyRequest().authenticated()
		);
		
		// 무상태성 세션 관리
		http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
		
		// 특정 경로(로그인)에 대한 필터 추가 [authenticationManager, jwtAuthenticationService 생성자를 호출해 매개변수로 등록]
		http.addFilterBefore(new LoginCustomAuthenticationFilter(authenticationManager(), jwtAuthenticationService()), UsernamePasswordAuthenticationFilter.class);
		
		// (토큰을 통해 검증할 수 있도록) 필터 추가 [jwtProvider 생성자를 호출해 매개변수로 등록]
		http.addFilterAfter(new JwtAuthenticationFilter(jwtProvider()), UsernamePasswordAuthenticationFilter.class);
		
		// HTTP 기본 설정
		http.httpBasic(HttpBasicConfigurer::disable);
		// CSRF 비활성화
		http.csrf(AbstractHttpConfigurer::disable);
		// CORS 설정
		http.cors(corsConfig -> corsConfig.configurationSource(corsConfigurationSource()));
		
		return http.getOrBuild();
	}
	
	@Bean
	CorsConfigurationSource corsConfigurationSource() {
		return request -> {
			CorsConfiguration config = new CorsConfiguration();
			config.setAllowedHeaders(Collections.singletonList("*"));
			config.setAllowedMethods(Collections.singletonList("*"));
			config.setAllowedOriginPatterns(Collections.singletonList("http://localhost:3000"));
			config.setAllowCredentials(true);
			return config;
		};
	}
}
Java
복사
•
controller
◦
CommonController
package com.kosta.controller;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.JsonNode;
import com.kosta.domain.request.JoinUser;
import com.kosta.domain.response.ErrorResponse;
import com.kosta.domain.response.TokenResponse;
import com.kosta.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class CommonController {
	private final UserService us;
	
	// 이메일 중복체크 -> /api/duplicate [GET]
	// RequestParam {email}
	// Response 200 or 409
	@GetMapping("/duplicate")
	public ResponseEntity<?> checkEmailDuplicate(@RequestParam("email") String email){
		boolean exists = us.checkEmailExists(email);
		
		if (exists) {
			return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse("이미 사용 중인 이메일"));
		}
		return ResponseEntity.ok().build();
	}
	//	회원가입 -> /api/join [POST]
	// Request {name, email, password}
	// Response 201 {???} or 400
	@PostMapping("/join")
	public ResponseEntity<?> registerUser(@RequestBody JoinUser joinUser) {
		try {
			us.registerUser(joinUser);
			return ResponseEntity.status(HttpStatus.CREATED).build();
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("입력 값 오류"));
		}
	}
	
	// 토큰 재발급 -> /api/refresh [POST]
	// Response 200 {accessToken} or 401
	@PostMapping("/refresh")
	public ResponseEntity<?> refreshToken(HttpServletRequest request, HttpServletResponse response) {
		Map<String, String> newTokenMap = us.refreshToken(request, response);
		if (newTokenMap == null) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("리프레시 토큰 오류"));
		}
		TokenResponse tokenResponse = TokenResponse.builder().accessToken(newTokenMap.get("accessToken")).build();
		return ResponseEntity.ok(tokenResponse);
	}
	
	@GetMapping("/oauth/{company}")
	public ResponseEntity<?> google(@RequestParam("code") final String code, @PathVariable("company") final String company, HttpServletResponse response) {
		Map<String, String> tokenMap = us.oAuthLogin(code, company, response);
		if (tokenMap == null) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("로그인 오류"));
		}
		TokenResponse tokenResponse = TokenResponse.builder().accessToken(tokenMap.get("accessToken")).build();
		return ResponseEntity.ok(tokenResponse);
	}
}
Java
복사
◦
ProductController
package com.kosta.controller;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.kosta.domain.request.ProductAdd;
import com.kosta.domain.request.ProductEdit;
import com.kosta.domain.response.ErrorResponse;
import com.kosta.domain.response.ProductResponse;
import com.kosta.service.ProductService;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/product")
@RequiredArgsConstructor
public class ProductController {
	private final ProductService ps;
	
	// 상품 등록 -> /api/product [POST] ★관리자만
	// Request {name, price}
	// Response 201 {id, name, price} or 401 or 403
	@PostMapping("")
	public ResponseEntity<?> addProduct(@RequestBody ProductAdd productAdd) {
		try {			
			ProductResponse productReponse = ps.addProduct(productAdd);
			return ResponseEntity.status(HttpStatus.CREATED).body(productReponse);
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("상품 등록 실패"));
		}
	}
	
	// 상품 수정 -> /api/product [PATCH] ★관리자만
	// Request {id, name, price}
	// Response 200 {id, name, price} or 401 or 403
	@PatchMapping("")
	public ResponseEntity<?> updateProduct(@RequestBody ProductEdit productEdit) {
		try {
			ProductResponse productReponse = ps.updateProduct(productEdit);
			return ResponseEntity.ok().body(productReponse);			
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("상품 수정 실패"));
		}
	}
	
	// 상품 삭제 -> /api/product?id=~ [DELETE] ★관리자만
	// RequestParam {id}
	// Response 200 {id, name, price} or 401 or 403
	@DeleteMapping("")
	public ResponseEntity<?> deleteProduct(@RequestParam("id") Long id) {
		try {
			ProductResponse productReponse = ps.deleteProduct(id);
			return ResponseEntity.ok().body(productReponse);			
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("상품 삭제 실패"));
		}
	}
	
	@GetMapping("")
	public ResponseEntity<?> getProduct(@RequestParam(name = "id", required = false) Long id) {
		try {
			if (id == null) {
				//	상품 전체 목록 -> /api/product [GET] ☆로그인해야만	
				// Response 200 List<id, name, price> or 401 or 403
				List<ProductResponse> allProducts = ps.getAllProducts();
				return ResponseEntity.ok().body(allProducts);		
			}
			//	상품 디테일 -> /api/product?id=~ [GET] ☆로그인해야만	RequestParam {id}			Response 200 {id, name, price} or 401 or 403
			ProductResponse productReponse = ps.getProductById(id);
			return ResponseEntity.ok().body(productReponse);
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("상품 조회 실패"));
		}
	}
}
Java
복사
•
domain
◦
request
▪
JoinUser
package com.kosta.domain.request;
import lombok.Getter;
@Getter
public class JoinUser {
	private String name, email, password;
}
Java
복사
▪
LoginUser
package com.kosta.domain.request;
import lombok.Getter;
@Getter
public class LoginUser {
	private String email, password;
}
Java
복사
▪
ProductAdd
package com.kosta.domain.request;
import lombok.Getter;
@Getter
public class ProductAdd {
	private String name;
	private int price;
}
Java
복사
▪
ProductEdit
package com.kosta.domain.request;
import lombok.Getter;
@Getter
public class ProductEdit {
	private Long id;
	private String name;
	private int price;
}
Java
복사
◦
response
▪
ErrorResponse
package com.kosta.domain.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class ErrorResponse {
	private String msg; 
}
Java
복사
▪
ProductResponse
package com.kosta.domain.response;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ProductResponse {
	private Long id;
	private String name;
	private int price;
}
Java
복사
▪
TokenResponse
package com.kosta.domain.response;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class TokenResponse {
	private String accessToken;
}
Java
복사
•
entity
◦
Product
package com.kosta.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(updatable = false)
	private Long id;
	
	@Column(nullable = false)
	private String name;
	
	@Column(nullable = false)
	private int price;
}
Java
복사
◦
User
package com.kosta.entity;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import com.kosta.domain.RoleEnum;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(updatable = false)
	private Long id;
	
	@Column(nullable = false, unique = true)
	private String email;
	
	@Column(nullable = false)
	private String name;
	
	@Column(nullable = true)
	private String password;
	@Column(nullable = false)
	private boolean oAuth = false;
	
	@Column(nullable = false)
	@Enumerated(EnumType.STRING)
	@Builder.Default
	private RoleEnum role = RoleEnum.ROLE_USER; // 권한 컬럼 추가 (기본값 ROLE_USER)
	@Column(name="refresh_token", nullable = true)
	private String refreshToken;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// 권한 목록 반환
		return List.of(new SimpleGrantedAuthority(role.name()));
	}
	@Override
	public String getUsername() {
		// 로그인할 사용자 명을 이메일로 대체
		return email;
	}
}
Java
복사
•
repository
◦
ProductRepository
package com.kosta.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.kosta.entity.Product;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long>{
}
Java
복사
◦
UserRepository
package com.kosta.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.kosta.entity.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long>{
	Optional<User> findByEmail(String email);
	boolean existsByEmail(String email);
}
Java
복사
•
security
◦
JwtAuthenticationFilter
package com.kosta.security;
import java.io.IOException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
	private final JwtProvider jwtProvider;
	private final static String HEADER_AUTHORIZATION = "Authorization";
	private final static String TOKEN_PREFIX = "Bearer ";
	
	// HTTP 요청이 들어올 때마다 실행되는 필터
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		String header = request.getHeader(HEADER_AUTHORIZATION);
		String token = getAccessToken(header);
		if (token != null && jwtProvider.validateToken(token)) {
			// 유효한 토큰인 경우
			Authentication authentication = jwtProvider.getAuthenticationByToken(token);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		// 그 다음 요청 처리 체인을 이어서 진행
		filterChain.doFilter(request, response);
	}
	
	private String getAccessToken(String header) {
		log.info("[getAccessToken] 토큰 값 추출, {}", header);
		if (header != null && header.startsWith(TOKEN_PREFIX)) {
			return header.substring(TOKEN_PREFIX.length());
		}
		return null;
	}
}
Java
복사
◦
JwtAuthenticationService
package com.kosta.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import com.kosta.domain.response.TokenResponse;
import com.kosta.entity.User;
import com.kosta.repository.UserRepository;
import com.kosta.util.TokenUtils;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class JwtAuthenticationService {
	private final TokenUtils tokenUtils;
	private final UserRepository userRepository;
	
	void successAuthentication(HttpServletResponse response, Authentication authResult) throws IOException {
		User user = (User) authResult.getPrincipal();	
		Map<String, String> tokenMap = tokenUtils.generateToken(user);
		String accessToken = tokenMap.get("accessToken");
		String refreshToken = tokenMap.get("refreshToken");
		// 리프레시 토큰을 DB에 저장!
		user.setRefreshToken(refreshToken);
		userRepository.save(user);
		
		// 생성된 리프레시 토큰을 cookie에 담아 응답
		tokenUtils.setRefreshTokenCookie(response, refreshToken);
		
		// 생성된 액세스 토큰을 LoginResponse에 담아 응답
		TokenResponse loginResponse = TokenResponse.builder().accessToken(accessToken).build();
		tokenUtils.writeResponse(response, loginResponse);
	}
}
Java
복사
◦
JwtProperties
package com.kosta.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Component
@ConfigurationProperties("jwt") // application.yml에 설정한 jwt.issuer, jwt.secret_key,...이 매핑된다.
public class JwtProperties {
	private String issuer;
	private String secretKey;
	private int accessDuration;
	private int refreshDuration;
}
Java
복사
◦
JwtProvider
package com.kosta.security;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import com.kosta.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtProvider {
	private final UserDetailsService userDetailsService;
	// JWT 관련 설정 정보 객체 주입
	private final JwtProperties jwtProperties;
	
	// JWT Access 토큰 생성 메소드
	public String generateAccessToken(User user) {
		log.info("[generateAccessToken] 액세스 토큰을 생성합니다.");
		Date now = new Date();
		Date expiredDate = new Date(now.getTime() + jwtProperties.getAccessDuration());
		return makeToken(user, expiredDate);
	}
	
	// JWT Refresh 토큰 생성 메소드
	public String generateRefreshToken(User user) {
		log.info("[generateRefreshToken] 리프레시 토큰을 생성합니다.");
		Date now = new Date();
		Date expiredDate = new Date(now.getTime() + jwtProperties.getRefreshDuration());
		return makeToken(user, expiredDate);
	}
	
	// 토큰 생성 공통 메소드 (실제로 JWT 토큰 생성)
	private String makeToken(User user, Date expiredDate) {
		String token = Jwts.builder()
			.header().add("typ", "JWT") // JWT 타입을 명시
			.and()
			.issuer(jwtProperties.getIssuer()) // 발행자 정보 설정
			.issuedAt(new Date()) // 발행일시 설정
			.expiration(expiredDate) // 만료 시간 설정
			.subject(user.getEmail()) // 토큰의 주제(Subject) 설정 _ 사용자 이메일
			.claim("id", user.getId())
			.claim("role", user.getRole().name())
			.signWith(getSecretKey(), Jwts.SIG.HS256) // 비밀키와 해시 알고리즘 사용하여 토큰 설명값 설정
			.compact(); // 토큰 정보들을 최종적으로 압축해서 문자열로 반환
		log.info("[makeToken] 완성된 토큰 : {}", token);
		return token;
	}	
	
	// 비밀키 만드는 메소드
	private SecretKey getSecretKey() {
		return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes());
	}
	
	// 토큰이 유효한지 검증 메소드
	public boolean validateToken(String token) {
		log.info("[validateToken] 토큰 검증을 시작합니다.");
		try {
			Jwts.parser()
				.verifyWith(getSecretKey()) // 비밀키로 서명 검증
				.build()
				.parseSignedClaims(token); // 서명된 클레임을 파싱..
			log.info("토큰 검증 통과");
			return true;
		} catch (Exception e) {
			e.printStackTrace();
		}
		log.info("토큰 검증 실패");
		return false;
	}
	
	// 토큰에서 정보(Claim) 추출 메소드
	private Claims getClaims(String token) {
		return Jwts.parser()
			.verifyWith(getSecretKey()) // 비밀키로 서명 검증
			.build()
			.parseSignedClaims(token) // 서명된 클레임을 파싱..
			.getPayload(); // 파싱된 클레임에서 페이로드(실제 클레임)를 반환
	}
	
	// 토큰에서 인증 정보 반환하는 메소드
	public Authentication getAuthenticationByToken(String token) {
		log.info("[getAuthenticationByToken] 토큰 인증 정보 조회");
		String userEmail = getUserEmailByToken(token);
		User user = (User) userDetailsService.loadUserByUsername(userEmail);
		Authentication authentication = new UsernamePasswordAuthenticationToken(
			user, token, user.getAuthorities()
		);
		return authentication;
	}
	
	// 토큰에서 사용자 이메일만 추출하는 메소드
	public String getUserEmailByToken(String token) {
		log.info("[getUserEmailByToken] 토큰 기반 회원 식별 정보 추출");
		Claims claims = getClaims(token);
		String email = claims.get("sub", String.class);
		return email;
	}
}
Java
복사
◦
LoginCustomAuthenticationFilter
package com.kosta.security;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kosta.domain.request.LoginUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LoginCustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	private JwtAuthenticationService jwtAuthenticationService;
	
	// 로그인되는 경로 및 메소드 타입 지정
	private static final AntPathRequestMatcher LOGIN_PATH = new AntPathRequestMatcher("/api/login", "POST");
	public LoginCustomAuthenticationFilter(AuthenticationManager authenticationManager, JwtAuthenticationService jwtAuthenticationService) {
		super(LOGIN_PATH);
		setAuthenticationManager(authenticationManager);
		this.jwtAuthenticationService = jwtAuthenticationService;
	}
	
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException {
		// POST, /api/login 에 요청이 들어오면 진행
		LoginUser loginUser= null;
		// 1. Body에 있는 로그인 정보 ({"email": "~~", "password: "~~" } 를 가져오기
		try {
			log.info("[attemptAuthentication] 로그인 정보 가져오기");
			ObjectMapper objectMapper = new ObjectMapper();
			loginUser = objectMapper.readValue(request.getInputStream(), LoginUser.class);
		} catch (IOException e) {
			throw new RuntimeException("로그인 요청 파라미터 이름 확인 필요 (로그인 불가)");
		}
		
		// 2. email과 password를 기반으로 AuthenticationToken 생성!
		log.info("[attemptAuthentication] AuthenticationToken 생성");
		UsernamePasswordAuthenticationToken uPAT = 
				new UsernamePasswordAuthenticationToken(loginUser.getEmail(), loginUser.getPassword());
		
		// 3. 인증 시작 (AuthenticationManager의 authenticate 메소드가 동작할 때 -> loadUserByUsername)
		log.info("[attemptAuthentication] 인증 시작");
		Authentication authenticate = getAuthenticationManager().authenticate(uPAT);
		return authenticate;
	}
	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		log.info("[successfulAuthentication] 로그인 성공 -> 토큰 생성 시작");
		jwtAuthenticationService.successAuthentication(response, authResult);
	}
	@Override
	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException failed) throws IOException, ServletException {
		log.info("[unsuccessfulAuthentication] 로그인 실패");
		response.setStatus(HttpStatus.UNAUTHORIZED.value());
	}
}
Java
복사
•
service
◦
ProductService
package com.kosta.service;
import java.util.List;
import com.kosta.domain.request.ProductAdd;
import com.kosta.domain.request.ProductEdit;
import com.kosta.domain.response.ProductResponse;
public interface ProductService {
	ProductResponse addProduct(ProductAdd productAdd) throws Exception;
	ProductResponse updateProduct(ProductEdit productEdit) throws Exception;
	ProductResponse deleteProduct(Long id) throws Exception;
	List<ProductResponse> getAllProducts() throws Exception;
	ProductResponse getProductById(Long id) throws Exception;
}
Java
복사
◦
UserService
package com.kosta.service;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import com.fasterxml.jackson.databind.JsonNode;
import com.kosta.domain.request.JoinUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public interface UserService {
	boolean checkEmailExists(String email);
	void registerUser(JoinUser joinUser) throws Exception;
	Map<String, String> refreshToken(HttpServletRequest request, HttpServletResponse response);
	Map<String, String> oAuthLogin(String code, String company, HttpServletResponse response);
}
Java
복사
◦
Impl
▪
ProductServiceImpl
package com.kosta.service.impl;
import java.util.List;
import org.springframework.stereotype.Service;
import com.kosta.domain.request.ProductAdd;
import com.kosta.domain.request.ProductEdit;
import com.kosta.domain.response.ProductResponse;
import com.kosta.entity.Product;
import com.kosta.repository.ProductRepository;
import com.kosta.service.ProductService;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
	private final ProductRepository pr;
	
	@Override
	public ProductResponse addProduct(ProductAdd productAdd) throws Exception {
		Product product = Product.builder()
				.name(productAdd.getName())
				.price(productAdd.getPrice())
				.build();
		Product savedProduct = pr.save(product);
		
		return ProductResponse.builder()
				.id(savedProduct.getId())
				.name(savedProduct.getName())
				.price(savedProduct.getPrice())
				.build();
	}
	@Override
	public ProductResponse updateProduct(ProductEdit productEdit) throws Exception {
		Product product = pr.findById(productEdit.getId()).orElseThrow(() -> new Exception());
		if (!product.getName().equals(productEdit.getName()))
			product.setName(productEdit.getName());
		if (product.getPrice() != (productEdit.getPrice()))
			product.setPrice(productEdit.getPrice());
		
		Product updatedProduct = pr.save(product);
		return ProductResponse.builder()
				.id(updatedProduct.getId())
				.name(updatedProduct.getName())
				.price(updatedProduct.getPrice())
				.build();
	}
	@Override
	public ProductResponse deleteProduct(Long id) throws Exception {
		Product product = pr.findById(id).orElseThrow(() -> new Exception());
		pr.delete(product);
		return ProductResponse.builder()
				.id(product.getId())
				.name(product.getName())
				.price(product.getPrice())
				.build();
	}
	@Override
	public List<ProductResponse> getAllProducts() throws Exception {
		return pr.findAll().stream().map(
				product -> ProductResponse.builder()
					.id(product.getId())
					.name(product.getName())
					.price(product.getPrice())
					.build()
				).toList();
	}
	@Override
	public ProductResponse getProductById(Long id) throws Exception {
		Product product = pr.findById(id).orElseThrow(() -> new Exception());
		return ProductResponse.builder()
				.id(product.getId())
				.name(product.getName())
				.price(product.getPrice())
				.build();
	}
}
Java
복사
▪
UserDetailsServiceImpl
package com.kosta.service.impl;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.kosta.entity.User;
import com.kosta.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
	private final UserRepository ur;
	
	@Override
	public User loadUserByUsername(String email) throws UsernameNotFoundException {
		User user = ur.findByEmail(email).orElse(null);
		return user;
	}
}
Java
복사
▪
UserServiceImpl
package com.kosta.service.impl;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.JsonNode;
import com.kosta.domain.request.JoinUser;
import com.kosta.entity.User;
import com.kosta.repository.UserRepository;
import com.kosta.security.JwtProvider;
import com.kosta.service.UserService;
import com.kosta.util.TokenUtils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
	private final UserRepository ur;
	private final BCryptPasswordEncoder bCryptPasswordEncoder;
	private final TokenUtils tokenUtils;
	private final JwtProvider jwtProvider;
	
	@Override
	public boolean checkEmailExists(String email) {
		return ur.existsByEmail(email);
	}
	@Override
	public void registerUser(JoinUser joinUser) throws Exception {
		String encodedPassword = bCryptPasswordEncoder.encode(joinUser.getPassword());
		User user = User.builder()
			.name(joinUser.getName())
			.email(joinUser.getEmail())
			.password(encodedPassword)
			.build();
		ur.save(user);
	}
	@Override
	public Map<String, String> refreshToken(HttpServletRequest request, HttpServletResponse response) {
		String refreshToken = extractRefreshTokenFromCookie(request);
		if (refreshToken != null && jwtProvider.validateToken(refreshToken)) {
			String userEmail = jwtProvider.getUserEmailByToken(refreshToken);
			User user = ur.findByEmail(userEmail).orElse(null);
			if (user != null && user.getRefreshToken().equals(refreshToken)) {
				Map<String, String> tokenMap = tokenUtils.generateToken(user);
				user.setRefreshToken(tokenMap.get("refreshToken"));
				ur.save(user);
				tokenUtils.setRefreshTokenCookie(response, tokenMap.get("refreshToken"));
				return tokenMap;
			}
		}
		return null;
	}
	
	private String extractRefreshTokenFromCookie(HttpServletRequest req) {
		Cookie[] cookies = req.getCookies();
		if (cookies != null) {
			for (Cookie c: cookies) {
				if (c.getName().equals("refreshToken")) {
					return c.getValue();
				}
			}
		}
		return null;
	}
	@Override
	public Map<String, String> oAuthLogin(String code, String company, HttpServletResponse response) {
		// 클라이언트부터 가져온 정보를 통해 해당 SNS 회사에 accessToken 요청 
		String accessToken = getAccessToken(code, company);
		
		// accsssToken 정보를 통해 사용자 정보 생성
		User user = generateOAuthUser(accessToken);
		
		// 사용자 로그인 진행
		Map<String, String> tokenMap = tokenUtils.generateToken(user);
		user.setRefreshToken(tokenMap.get("refreshToken"));
		ur.save(user);
		tokenUtils.setRefreshTokenCookie(response, tokenMap.get("refreshToken"));
		return tokenMap;
	}
	
	private String getAccessToken(String code, String company) {
		// 코드 변환
		String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8);
        
		// 헤더 설정
		HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);        
        String clientId = "1059700581094-jkoubuj3925vsp7fs5g2qncs8d7norem.apps.googleusercontent.com";
        String clientSecret = "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";
        headers.setBasicAuth(clientId, clientSecret);
        
        MultiValueMap<String, String> parameterMap = new LinkedMultiValueMap<>();
        parameterMap.add("code", decodedCode);
        parameterMap.add("grant_type", "authorization_code");
        parameterMap.add("redirect_uri", "http://localhost:3000/oauth");
        
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameterMap, headers);
        
        // https://oauth2.googleapis.com/token
        RestTemplate restTemplate = new RestTemplate();
        
        ResponseEntity<Map> response = restTemplate.postForEntity("https://oauth2.googleapis.com/token", requestEntity, Map.class);
        String accessToken = (String) response.getBody().get("access_token");
        return accessToken;
	}
	
	private User generateOAuthUser(String accessToken) {
		String GOOGLE_USERINFO_REQUEST_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        HttpEntity<?> entity = new HttpEntity<>(headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<JsonNode> response = restTemplate.exchange(
                GOOGLE_USERINFO_REQUEST_URL,
                HttpMethod.GET,
                entity,
                JsonNode.class
        );
        if (response.getStatusCode().is2xxSuccessful()) {
            JsonNode responseBody = response.getBody();
            log.info("SNS 로그인 계정 정보 : {}", responseBody);
            if (responseBody == null) {
                throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Response body is null");
            }
            if (responseBody.has("email") && responseBody.has("name")) {
            	String email = responseBody.get("email").asText();
            	String name = responseBody.get("name").asText();
            	
                Optional<User> optUser = ur.findByEmail(email);
                User user = null;
                if (optUser.isEmpty()) {
                	log.info("없는 사용자 -> 새로 가입합니다");
                	// 가입 처리
    				user = User.builder()
    					.email(email)
    					.name(name)
    					.oAuth(true)
    					.build();
    				ur.save(user);
                } else {
                	user = optUser.get();
                	if (!user.isOAuth()) {
                		user.setOAuth(true);
                		ur.save(user);
                	}
                }
                return user;
            }
        }
        throw new RuntimeException("SNS 로그인 에러");
	}
}
Java
복사
•
util
◦
TokenUtils
package com.kosta.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kosta.domain.response.TokenResponse;
import com.kosta.entity.User;
import com.kosta.security.JwtProperties;
import com.kosta.security.JwtProvider;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class TokenUtils {
	private final JwtProvider jwtProvider;
	private final JwtProperties jwtProperties;
	
	// 토큰 생성
	public Map<String, String> generateToken(User user) {
		String accessToken = jwtProvider.generateAccessToken(user);
		String refreshToken = jwtProvider.generateRefreshToken(user);
		
		Map<String, String> tokenMap = new HashMap<String, String>();
		tokenMap.put("accessToken", accessToken);
		tokenMap.put("refreshToken", refreshToken);		
		return tokenMap;
	}
	
	// JSON 응답 전송
	public void writeResponse(HttpServletResponse response, TokenResponse loginResponse) throws IOException {		
		ObjectMapper objectMapper = new ObjectMapper();
		String jsonResponse = objectMapper.writeValueAsString(loginResponse);
		
		response.setContentType("application/json");
		response.setCharacterEncoding("UTF-8");
		response.getWriter().write(jsonResponse);
	}
	public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
		System.out.println(jwtProperties.getRefreshDuration());
		Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
		refreshTokenCookie.setHttpOnly(true); // JavaScript에서 변경 불가
		refreshTokenCookie.setSecure(false); // HTTPS가 아니여도 사용 가능! (지금은)
		refreshTokenCookie.setPath("/");
		refreshTokenCookie.setMaxAge(jwtProperties.getRefreshDuration() / 1000); // Token 유효기간 1일
		response.addCookie(refreshTokenCookie);
	}
}
Java
복사
React
•
패키지 설치
yarn install
yarn create react-app .
yarn add styled-components
yarn add react-router-dom
yarn add @mui/material @emotion/react @emotion/styled @emotion/css
yarn add jwt-decode
yarn add react-cookie
yarn add install sweetalert2
Shell
복사
•
폴더별로 정리
◦
components
▪
Navbar.jsx
import { AppBar, Button, Stack, Switch, Toolbar, Typography } from '@mui/material';
import { Link, useNavigate } from 'react-router-dom';
import { useCookies } from 'react-cookie';
import { useEffect, useState } from 'react';
import { jwtDecode } from 'jwt-decode';
const Navbar = ({ toggleDarkMode, isDarkMode }) => {
    const [ cookies, , removeCookie ] = useCookies(['accessToken']);
    const navigate = useNavigate();
    const [role, setRole] = useState();
    const handleLogout = () => {
        removeCookie('accessToken', { path: '/' });
        navigate('/');
    };
    useEffect(() => {
        if (cookies.accessToken) {
            const role = jwtDecode(cookies.accessToken).role;
            setRole(role);
        } else {
            setRole(null);
        }
    }, [cookies.accessToken]);
    return (
        <AppBar position='static'>
            <Toolbar>
                <Typography variant='h6' sx={{ flexGrow: 1}}>
                    {
                        role === "ROLE_USER" || role === "ROLE_ADMIN" && 
                        <Button color="inherit" LinkComponent={Link} to="/products">상품</Button>
                    }
                    {
                        role === "ROLE_ADMIN" && 
                        <Button color="inherit" LinkComponent={Link} to="/admin">관리자</Button>
                    }
                    { !role &&
                        <Button color="inherit" LinkComponent={Link} to="/join">회원가입</Button>
                    }    
                    { role && 
                        <Button color="inherit" onClick={handleLogout}>
                            로그아웃
                        </Button>
                    }
                </Typography>
                <Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
                    <Typography>Light</Typography>
                    <Switch onChange={toggleDarkMode} defaultChecked={isDarkMode} />
                    <Typography>Dark</Typography>
                </Stack>
            </Toolbar>
        </AppBar>
    );
}
 
export default Navbar;
JavaScript
복사
▪
ProtectedRoutes.js
import { jwtDecode } from "jwt-decode";
import { useCookies } from "react-cookie";
import { Navigate } from 'react-router-dom';
export const LoginUserRoute = ({ comp: Comp }) => {
    const [ cookies ] = useCookies(["accessToken"]);
    const token = cookies.accessToken;
    if(!token) {
        return <Navigate to="/" />
    }
    return <Comp />
}
export const AdminRoute = ({ comp: Comp }) => {
    const [ cookies ] = useCookies(["accessToken"]);
    const token = cookies.accessToken;
    if(!token) {
        return <Navigate to="/" />
    }
    const decodeToken = jwtDecode(token);
    if (decodeToken.role !== "ROLE_ADMIN") {
        return <Navigate to="/" />
    }
    
    return <Comp />
}
JavaScript
복사
◦
hooks
▪
useDarkMode.js
import { useEffect, useState } from "react";
export const useDarkMode = () => {
    const [isDarkMode, setIsDarkMode] = useState(() => {
        const savedMode = localStorage.getItem('darkMode');
        return savedMode === "true" ? true : false;
    });
    const toggleDarkMode = () => setIsDarkMode(mode => !mode);
    useEffect(() => {
        localStorage.setItem('darkMode', isDarkMode);
    }, [isDarkMode]);
    return { isDarkMode, toggleDarkMode }
}
JavaScript
복사
◦
pages
▪
AdminPage.jsx
import { useForm } from 'react-hook-form';
import { Container, TextField, Button, Typography, List, ListItem, ListItemText } from '@mui/material';
import { useEffect, useState } from 'react';
import { productAPI } from '../services/product';
const AdminPage = () => {
    const { register, handleSubmit, reset, setValue } = useForm();
    const [products, setProducts] = useState([]);
    const [editingProduct, setEditingProduct] = useState(null);
    const getAllProducts = async () => {
        try {
            const response = await productAPI.getAll();
            setProducts(response.data);
        } catch (error) {
            console.error('상품 가져오기 실패', error);
        }
    };
    useEffect(() => {
        getAllProducts();
    }, []);
    const onSubmit = async (data) => {
        try {
          if (editingProduct) {
            await productAPI.updateProduct({ ...data, id: editingProduct.id });
            setEditingProduct(null);
          } else {
            await productAPI.addProduct(data);
          }
          reset();
          getAllProducts();
        } catch (error) {
          console.error('상품 등록 또는 수정 실패', error);
        }
    };
    const handleEdit = (product) => {
        setEditingProduct(product);
        setValue('name', product.name);
        setValue('price', product.price)
    };
    const handleDelete = async (id) => {
        try {
            await productAPI.deletePost(id);
            getAllProducts();
        } catch (error) {
        console.error('삭제 실패', error);
        }
    };
    return (
        <Container>
            <Typography variant="h4" gutterBottom>
                Admin - Product Management(상품 관리)
            </Typography>
            <form onSubmit={handleSubmit(onSubmit)}>
                <TextField
                    fullWidth
                    margin="normal"
                    label="Product Name"
                    {...register('name', { required: true })}
                />
                <TextField
                    fullWidth
                    margin="normal"
                    label="Price"
                    type="number"
                    {...register('price', { required: true })}
                />
                <Button type="submit" variant="contained" color="primary" sx={{ mt: 2 }}>
                    {editingProduct ? 'Update Product' : 'Add Product'}
                </Button>
            </form>
            <Typography variant="h5" gutterBottom sx={{ mt: 4 }}>
                Product List
            </Typography>
            <List>
                {products.map((product) => (
                <ListItem key={product.id} divider>
                    <ListItemText primary={`${product.name} - $${product.price}`} />
                    <Button onClick={() => handleEdit(product)} color="primary">Edit</Button>
                    <Button onClick={() => handleDelete(product.id)} color="secondary">Delete</Button>
                </ListItem>
                ))}
            </List>
        </Container>
    );
}
 
export default AdminPage;
JavaScript
복사
▪
JoinPage.jsx
import { userAPI } from '../services/user';
import { useForm } from 'react-hook-form';
import { TextField, Button, Container, Typography, Box } from '@mui/material';
import { useNavigate } from 'react-router-dom';
const JoinPage = () => {
    const { register, handleSubmit, formState: { errors } } = useForm();
    const navigate = useNavigate();
    const onSubmit = async (data) => {
        try {
            await userAPI.join(data);
            navigate('/');
        } catch (error) {
            console.error('Join failed:', error);
        }
    };
    return (
        <Container maxWidth="xs">
            <Box mt={5}>
                <Typography variant="h4" align="center">Sign Up</Typography>
                <form onSubmit={handleSubmit(onSubmit)}>
                <TextField
                    fullWidth
                    margin="normal"
                    label="Name"
                    variant="outlined"
                    {...register('name', { required: 'Name is required' })}
                    error={!!errors.name}
                    helperText={errors.name?.message}
                />
                <TextField
                    fullWidth
                    margin="normal"
                    label="Email"
                    variant="outlined"
                    {...register('email', { required: 'Email is required' })}
                    error={!!errors.email}
                    helperText={errors.email?.message}
                />
                <TextField
                    fullWidth
                    margin="normal"
                    label="Password"
                    type="password"
                    variant="outlined"
                    {...register('password', { required: 'Password is required' })}
                    error={!!errors.password}
                    helperText={errors.password?.message}
                />
                <Button
                    type="submit"
                    variant="contained"
                    color="primary"
                    fullWidth
                    sx={{ mt: 2 }}
                >
                    Sign Up
                </Button>
                </form>
            </Box>
        </Container>
    );
}
 
export default JoinPage;
JavaScript
복사
▪
LoginPage.jsx
import { useForm } from 'react-hook-form';
import { TextField, Button, Container, Typography, Box } from '@mui/material';
import { userAPI } from '../services/user';
import { useCookies } from 'react-cookie';
import { useNavigate } from 'react-router-dom';
const LoginPage = () => {
    const { register, handleSubmit, formState: { errors }, setError } = useForm();
    const [, setCookie] = useCookies(['accessToken']);
    const navigate = useNavigate();
    const onSubmit = async (data) => {
        try {
            const response = await userAPI.login(data);
            setCookie('accessToken', response.data.accessToken, { path: '/' });
            navigate('/products');
        } catch (error) {
            if (error.status === 401) {
                setError("email", { type: "manual", message: "로그인 실패! 아이디를 확인해주세요" });
                setError("password", { type: "manual", message: "로그인 실패! 비밀번호를 확인해주세요" });
            }
        }
    };
    
    return (
        <Container maxWidth="xs">
            <Box mt={5}>
                <Typography variant="h4" align="center">Login</Typography>
                <form onSubmit={handleSubmit(onSubmit)}>
                <TextField
                    fullWidth
                    margin="normal"
                    label="Email"
                    variant="outlined"
                    {...register('email', { required: 'Email is required' })}
                    error={!!errors.email}
                    helperText={errors.email?.message}
                />
                <TextField
                    fullWidth
                    margin="normal"
                    label="Password"
                    type="password"
                    variant="outlined"
                    {...register('password', { required: 'Password is required' })}
                    error={!!errors.password}
                    helperText={errors.password?.message}
                />
                <Button
                    type="submit"
                    variant="contained"
                    color="primary"
                    fullWidth
                    sx={{ mt: 2 }}
                >
                    Login
                </Button>
                </form>
                <GoogleLogin></GoogleLogin>
            </Box>
        </Container>
    );
}
const GoogleLogin = () => {
    const GOOGLE_URL = `https://accounts.google.com/o/oauth2/v2/auth?scope=email%20profile&response_type=code&redirect_uri=${process.env.REACT_APP_GOOGLE_REDIRECT_URL}&client_id=${process.env.REACT_APP_GOOGLE_ID}`;
    return (
        <a href={GOOGLE_URL}>
            구글
        </a>
    )
}
export default LoginPage;
JavaScript
복사
▪
ProductPage.jsx
import { Container, Typography, List, ListItem, ListItemText } from '@mui/material';
import { productAPI } from '../services/product';
import { useEffect, useState } from 'react';
const ProductPage = () => {
    const [products, setProducts] = useState([]);
    useEffect(() => {
        const getAllProducts = async () => {
            try {
                const response = await productAPI.getAll();
                setProducts(response.data);
            } catch (error) {
                console.error('상품 가져오기 실패', error);
            }
        };
        getAllProducts();
    }, []);
    return (
        <Container>
            <Typography variant="h4" gutterBottom>
                Product List
            </Typography>
            <List>
                {products.map((product) => (
                <ListItem key={product.id}>
                    <ListItemText primary={`${product.name} - $${product.price}`} />
                </ListItem>
                ))}
            </List>
        </Container>
    );
}
 
export default ProductPage;
JavaScript
복사
◦
services
▪
api.js
import axios from "axios";
import { getCookie, removeCookie, setCookie } from "../utils/cookieUtil";
const api = axios.create({
    baseURL: `${process.env.REACT_APP_REST_SERVER}`,
    withCredentials: true // HttpOnly 쿠키 속성으로 저장된 refreshToken 전송한다.
});
api.interceptors.request.use(
    (config) => {
        const token = getCookie("accessToken");
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        } else {
            delete config.headers.Authorization;
        }
        return config;
    },
    (err) => {
        return Promise.reject(err);
    }
);
api.interceptors.response.use(
    (res) => {
        return res;
    },
    async (err) => {        
        // 원래 403으로 실패했던 요청
        const originalReq = err.config;
        // 만약에 권한이 없다는 에러가 나오고 무한루프에 빠져있다면
        if (err.response.status == 403 && !originalReq._retry) {
            originalReq._retry = true; // 플래그 설정
            try {
                // 토큰 재발급 해주도록 할 것이다.
                const response = await refreshTokenHandler();
                // 정상 재발급 시
                if (response.status === 200) {
                    // token값 로컬스토리지에 저장
                    // localStorage.setItem("token", response.data.accessToken);
                    // token값 쿠키에 저장
                    setCookie("accessToken", response.data.accessToken);
                    // 헤더에 새로운 token 추가해서
                    originalReq.headers.Authorization = `Bearer ${response.data.accessToken}`;
                    // 실패했던 요청 다시 보내기
                    return api.request(originalReq);
                }
            } catch (error) {
                console.log("토큰 재발급 실패");
            }
        }
        removeCookie("accessToken");
        return Promise.reject(err);
    }
);
const refreshTokenHandler = async () => {
    try {
        if (getCookie("accessToken")) {
            const response = await api.post("/refresh");
            return response;
        }
    } catch (error) {
        throw error;
    }
}
export default api;
JavaScript
복사
▪
product.js
import api from './api';
export const productAPI = {
    getAll : () => api.get("/product"),
    getProduct : (id) => api.get('/product', { params : {id} }),
    addProduct : (data) => api.post("/product", data),
    updateProduct : (data) => api.patch("/product", data),
    deletePost : (id) => api.delete(`/product`, { params : {id} })
}
JavaScript
복사
▪
user.js
import api from './api';
export const userAPI = {
    login: (data) => api.post("/login", data),
    join : (data) => api.post("/join", data),
    duplicate: (email) => api.get("/duplicate", { params : {email} }),
}
JavaScript
복사
◦
theme
▪
theme.js
import { createTheme } from "@mui/material";
export const lightTheme = createTheme({
    palette: {
        mode: 'light',
        primary: {
            main: '#1976d2',
        },
        background: {
            default: '#f5f5f5'
        }
    }
});
export const darkTheme = createTheme({
    palette: {
        mode: 'dark',
        primary: {
            main: '#90caf9',
        },
        background: {
            default: '#121212'
        }
    }
});
JavaScript
복사
◦
utils
▪
cookieUtil.js
import { Cookies } from "react-cookie";
const cookies = new Cookies();
export const setCookie = (name, value, options) => {
    return cookies.set(name, value, {...options});
}
export const getCookie = (name) => {
    return cookies.get(name);
}
export const removeCookie = (name) => {
    return cookies.remove(name);
}
JavaScript
복사
◦
App.js
import { CssBaseline, ThemeProvider } from '@mui/material';
import './App.css';
import { useDarkMode } from './hooks/useDarkMode';
import { lightTheme, darkTheme } from './theme/theme';
import { useCookies } from 'react-cookie';
import { jwtDecode } from 'jwt-decode';
import { useEffect } from 'react';
import Navbar from './components/Navbar';
import { Route, BrowserRouter, Routes, useParams, useSearchParams, useLocation } from 'react-router-dom';
import LoginPage from './pages/LoginPage.jsx';
import JoinPage from './pages/JoinPage';
import ProductPage from './pages/ProductPage';
import AdminPage from './pages/AdminPage';
import { LoginUserRoute, AdminRoute } from './components/ProtectedRoutes';
import axios from 'axios';
function App() {
  const { isDarkMode, toggleDarkMode } = useDarkMode();
  const [ cookies, setCookie, removeCookie ] = useCookies(['accessToken']);
  useEffect(() => {
    if (cookies.accessToken) {
      const decodeToken = jwtDecode(cookies.accessToken);
      console.log("로그인한 사용자 정보", decodeToken);
    }
  }, [cookies.accessToken]);
  return (
    <ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
      <CssBaseline />
        <BrowserRouter>
          <Navbar toggleDarkMode={toggleDarkMode} isDarkMode={isDarkMode} />
          <Routes>
            <Route path="" element={<LoginPage />} />
            <Route path="/join" element={<JoinPage />} />
            <Route path="/products" element={<LoginUserRoute comp={ProductPage} />} />
            <Route path="/admin" element={<AdminRoute comp={AdminPage} />} />
            <Route path="/oauth" element={<GoogleRedirect />} />
          </Routes>
        </BrowserRouter>
    </ThemeProvider>
  );
}
const GoogleRedirect = () => {
  const code = new URLSearchParams(window.location.search).get("code");
  useEffect(()=> {
    axios.get(`${process.env.REACT_APP_REST_SERVER}/oauth/google?code=${code}`)
  })
}
export default App;
JavaScript
복사

