🦁 JWT를 활용한 로그인 기능 구현
💻 인증 필터
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
@Autowired
private JwtTokenProvider jwtTokenProvider;
public AuthorizationHeaderFilter() {
super(Config.class);
}
public static class Config {
// application.yml 파일에서 지정한 filer의 Argument값을 받는 부분
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String token = exchange.getRequest().getHeaders().get("Authorization").get(0).substring(7); // 헤더의 토큰 파싱 (Bearer 제거)
String userId = jwtTokenProvider.getUserId(token);
addAuthorizationHeaders(exchange.getRequest(), userId);
return chain.filter(exchange);
};
}
// 성공적으로 검증이 되었기 때문에 인증된 헤더로 요청을 변경해준다. 서비스는 해당 헤더에서 아이디를 가져와 사용한다.
private void addAuthorizationHeaders(ServerHttpRequest request, String userId) {
request.mutate()
.header("X-Authorization-Id", userId)
.build();
}
// 토큰 검증 요청을 실행하는 도중 예외가 발생했을 때 예외처리하는 핸들러
@Bean
public ErrorWebExceptionHandler tokenValidation() {
return new JwtTokenExceptionHandler();
}
// 실제 토큰이 null, 만료 등 예외 상황에 따른 예외처리
public class JwtTokenExceptionHandler implements ErrorWebExceptionHandler {
private String getErrorCode(int errorCode) {
return "{\"errorCode\":" + errorCode + "}";
}
@Override
public Mono<Void> handle(
ServerWebExchange exchange, Throwable ex) {
int errorCode = 500;
if (ex.getClass() == NullPointerException.class) {
errorCode = 100;
} else if (ex.getClass() == ExpiredJwtException.class) {
errorCode = 200;
}
byte[] bytes = getErrorCode(errorCode).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Flux.just(buffer));
}
}
}
💻 JWT 토큰 발급 클래스
@Component
public class JwtTokenProvider {
@Value("${jwt.secret-key}")
private String secretKey;
public Key getSignKey(String secretKey) {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
// 사용자 id 가져오는 메서드
public String getUserId(String token) {
return extractAllClaims(token).get("id").toString();
}
// 토근에서 정보를 가져오는 코드가 계속 중복되어 사용되기 때문에 별도의 메서드로 만들어서 사용하기 위한 것
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignKey(secretKey))
.build()
.parseClaimsJws(token)
.getBody();
}
}
1) Web Adapter로 로그인 요청이 들어온다.
// 요청 Dto
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class LoginMemberReq {
private String email;
private String password;
}
// 검증용 Dto
@Getter
@Setter
@Builder
public class LoginMemberCommand {
@NotNull
private final String email;
@NotNull
private final String password;
public LoginMemberCommand(String email, String password) {
this.email = email;
this.password = password;
}
}
// Web Adapter
@RestController
@RequiredArgsConstructor
@WebAdapter
public class LoginMemberController {
private final LoginMemberUseCase loginMemberUseCase;
@RequestMapping(method = RequestMethod.POST, value = "/member/login")
public ResponseEntity login(@RequestBody LoginMemberReq loginMemberReq) {
LoginMemberCommand loginMemberCommand = LoginMemberCommand.builder()
.email(loginMemberReq.getEmail())
.password(loginMemberReq.getPassword())
.build();
return ResponseEntity.ok().body(loginMemberUseCase.loginMember(loginMemberCommand));
}
}
2) Input Port(UseCase) 를 통해 서비스를 호출한다.
// Input Port
public interface LoginMemberUseCase {
JwtToken loginMember(LoginMemberCommand loginMemberCommand);
}
// Jwt 도메인
@Getter
@AllArgsConstructor
public class JwtToken {
private Long Id;
private String accessToken;
private String refreshToken;
public static JwtToken generateJetToken(Long id,String accessToken, String refreshToken){
return new JwtToken(id, accessToken, refreshToken);
}
}
// 로그인 서비스
@Service
@RequiredArgsConstructor
public class LoginMemberService implements LoginMemberUseCase {
private final EmailPasswordCheckPort emailPasswordCheckPort;
private final CreateJwtPort createJwtPort;
@Override
public JwtToken loginMember(LoginMemberCommand loginMemberCommand) {
Member member = Member.builder()
.email(loginMemberCommand.getEmail())
.password(loginMemberCommand.getPassword())
.build();
// DB에 저장된 회원의 패스워드와 일치하는지 확인
MemberJpaEntity memberJpaEntity = emailPasswordCheckPort.emailPasswordCheck(member);
// 만약 일치한다면, 아래에서 jwt 토큰 발급 후 반환
if(memberJpaEntity != null) {
Member loginMember = Member.builder()
.id(memberJpaEntity.getId())
.email(memberJpaEntity.getEmail())
.nickname(memberJpaEntity.getNickname())
.build();
String accessToken = createJwtPort.generateAccessToken(loginMember);
String refreshToken = createJwtPort.generateRefreshToken(loginMember);
return JwtToken.generateJetToken(loginMember.getId(), accessToken, refreshToken);
}
return null;
}
}
3) 사용자의 아이디와 패스워드가 일치하는지 DB에서 확인하기 위해 OutPut Port를 통해
영속성 어댑터를 호출한다.
// OutPut Port
public interface EmailPasswordCheckPort {
MemberJpaEntity emailPasswordCheck(Member member);
}
// 영속성 어댑터
@Component
@RequiredArgsConstructor
@PersistenceAdapter
public class MemberPersistenceAdapter implements RegisterMemberPort, ModifyMemberPort, ModifyMemberStatusPort, EmailPasswordCheckPort {
private final MemberJpaRepository memberJpaRepository;
@Override
public MemberJpaEntity createMember(Member member) {
MemberJpaEntity memberJpaEntity = MemberJpaEntity.builder()
.email(member.getEmail())
.nickname(member.getNickname())
.password(member.getPassword())
.status(member.getStatus())
.build();
memberJpaRepository.save(memberJpaEntity);
System.out.println(memberJpaEntity);
return memberJpaEntity;
}
@Override
public MemberJpaEntity modifyMember(Member member) {
Optional<MemberJpaEntity> result = memberJpaRepository.findById(member.getId());
if(result.isPresent()) {
MemberJpaEntity memberJpaEntity = result.get();
memberJpaEntity.update(member.getEmail(), member.getPassword(), member.getNickname(), member.getStatus());
memberJpaRepository.save(memberJpaEntity);
return memberJpaEntity;
} else {
return null;
}
}
@Override
public Boolean modifyMemberStatus(Member member) {
Optional<MemberJpaEntity> result = memberJpaRepository.findByEmail(member.getEmail());
MemberJpaEntity memberJpaEntity = result.get();
memberJpaEntity.setStatus(true);
memberJpaRepository.save(memberJpaEntity);
return true;
}
// 여기부터 추가한 이메일, 패스워드 확인 메서드
@Override
public MemberJpaEntity emailPasswordCheck(Member member) {
Optional<MemberJpaEntity> result = memberJpaRepository.findByEmail(member.getEmail());
if(result.isPresent()) {
MemberJpaEntity memberJpaEntity = result.get();
if(memberJpaEntity.getPassword().equals(member.getPassword()) && memberJpaEntity.getStatus()) {
return memberJpaEntity;
}
}
return null;
}
}
4) 이메일, 패스워드 확인이 정상적으로 끝나면 JWT 토큰 발급을 위해 OutPut Port를 통해서
토큰 발급 어댑터로 이동한다.
// OutPut Port
public interface CreateJwtPort {
String generateAccessToken(Member member);
String generateRefreshToken(Member member);
boolean validateToken(String token);
String parseMemberIdFromToken(String token);
}
// 토큰 발급 어댑터
@WebAdapter
@RequiredArgsConstructor
public class createJwtAdapter implements CreateJwtPort {
@Value("${jwt.secret-key}")
private String secretKey;
@Value("${jwt.token.access-expired-time-ms}")
private Long accessTokenExpiredTimeMs;
@Value("${jwt.token.refresh-expired-time-ms}")
private Long refreshTokenExpiredTimeMs;
@Override
public String generateAccessToken(Member member) {
Claims claims = Jwts.claims();
claims.put("id", member.getId());
claims.put("email", member.getEmail());
claims.put("nickname", member.getNickname());
byte[] secretBytes = secretKey.getBytes();
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiredTimeMs))
.signWith(Keys.hmacShaKeyFor(secretBytes), SignatureAlgorithm.HS256)
.compact();
return accessToken;
}
@Override
public String generateRefreshToken(Member member) {
Claims claims = Jwts.claims();
claims.put("id", member.getId());
claims.put("email", member.getEmail());
claims.put("nickname", member.getNickname());
byte[] secretBytes = secretKey.getBytes();
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiredTimeMs))
.signWith(Keys.hmacShaKeyFor(secretBytes), SignatureAlgorithm.HS256)
.compact();
return refreshToken;
}
}
여기까지 하면 회원이 로그인할 때 토큰 발급이 완료된다.
다음으로는 상품을 등록할 때 상품의 이미지를 AWS에 업로드 하는 내용이다. MSA는
"상품 MSA" 와 "상품 이미지 MSA" 가 있다.
1) 상품 등록 요청이 상품 MSA의 Web Adapter로 들어온다.
// 요청 Dto
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CreateProductReq {
private String name;
private Integer price;
}
// 검증용 Dto
@Getter
@Builder
public class CreateProductCommand {
@NotNull
private final String name;
@NotNull
private final Integer price;
@NotNull
private final Long brandId;
@NotNull
private final MultipartFile[] files;
public CreateProductCommand(String name, Integer price, Long brandId, @NotNull MultipartFile[] files) {
this.name = name;
this.price = price;
this.brandId = brandId;
this.files = files;
}
}
// Web Adapter
@RestController
@WebAdapter
@RequiredArgsConstructor
public class CreateProductController {
private final CreateProductUseCase createProductUseCase;
@RequestMapping(method = RequestMethod.POST, value = "/product/create")
public ResponseEntity create(
@RequestHeader(value = "X-Authorization-Id", required = true) Long id,
@RequestPart(value = "product") CreateProductReq createProductReq,
@RequestPart(value = "files") MultipartFile[] files) {
CreateProductCommand createProductCommand = CreateProductCommand.builder()
.brandId(id)
.name(createProductReq.getName())
.price(createProductReq.getPrice())
.files(files)
.build();
return ResponseEntity.ok().body(createProductUseCase.createProduct(createProductCommand));
}
}
➡ @RequestPart
어노테이션은 "value" 의 해당하는 이름으로 데이터를 보내면 해당
value 값에 요청이 매핑되도록 해준다.
➡ 위에서는 X-Authorization-id
는 발급 받은 토큰에 있는 정보이고, 이것은 만들어 놓은
커스텀 필터를 통과하면서 자연스럽게 값이 들어가게 된다.
➡ 다음으로 "product" 이름으로 Dto 객체의 내용을 JSON 형식으로 보내주고,
"files" 이름으로 상품 사진 파일들을 넣어서 요청을 보내면 된다.
2) Web Adapter가 Input Port(UseCase)를 통해 서비스를 호출한다.
// Input Port
public interface CreateProductUseCase {
Product createProduct(CreateProductCommand createProductCommand);
}
// 상품 도메인
@Getter
@AllArgsConstructor
@Builder
public class Product {
private final Long id;
private final Long brandId;
private final String name;
private final Integer price;
}
// 상품 이미지 도메인
@Builder
@Getter
@AllArgsConstructor
public class ProductImages {
private final Long productId;
private final MultipartFile[] files;
}
// 상품 서비스
@UseCase
@RequiredArgsConstructor
public class ProductService implements CreateProductUseCase, GetProductUseCase {
private final CreateProductPort createProductPort;
private final UploadProductImagePort uploadProductImagePort;
private final GetProductPort getProductPort;
@Override
public Product createProduct(CreateProductCommand createProductCommand) {
Product product = Product.builder()
.brandId(createProductCommand.getBrandId())
.name(createProductCommand.getName())
.price(createProductCommand.getPrice())
.build();
// 상품 정보를 DB에 저장하기 위한 작업
product = createProductPort.createProduct(product);
ProductImages productImages = ProductImages.builder()
.productId(product.getId())
.files(createProductCommand.getFiles())
.build();
// 상품 이미지 업로드를 위한 작업
uploadProductImagePort.uploadProductImagePort(productImages);
return product;
}
}
3) 먼저 상품 데이터를 DB에 저장하기 위해 OutPut Port를 통해 영속성 어댑터를 호출한다.
// Output Port
public interface CreateProductPort {
Product createProduct(Product product);
}
// 상품 엔티티
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
public class ProductJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long brandId;
private String name;
private Integer price;
}
// 상품 레포지터리
@Repository
public interface ProductJpaRepository extends JpaRepository<ProductJpaEntity, Long> {
}
// 영속성 어댑터
@Component
@RequiredArgsConstructor
@PersistenceAdapter
public class ProductPersistenceAdapter implements CreateProductPort, GetProductPort {
private final ProductJpaRepository productJpaRepository;
@Override
public Product createProduct(Product product) {
ProductJpaEntity productJpaEntity = ProductJpaEntity.builder()
.brandId(product.getBrandId())
.name(product.getName())
.price(product.getPrice())
.build();
productJpaEntity = productJpaRepository.save(productJpaEntity);
return Product.builder()
.id(productJpaEntity.getId())
.brandId(productJpaEntity.getBrandId())
.name(productJpaEntity.getName())
.price(productJpaEntity.getPrice())
.build();
}
}
4) 다음으로 상품 이미지 업로드를 위해 이미지 업로드 어댑터를 호출한다.
// OutPut Port
public interface UploadProductImagePort {
void uploadProductImagePort(ProductImages productImages);
}
// 상품 이미지 업로드 어댑터
@WebAdapter
@RequiredArgsConstructor
public class UploadProductImageServiceAdapter implements UploadProductImagePort {
private final OpenFeignUploadProductImage openFeignUploadProductImage;
@Override
public void uploadProductImagePort(ProductImages productImages) {
try {
openFeignUploadProductImage.call(productImages.getProductId(), productImages.getFiles());
System.gc();
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 상품 이미지 MSA로 동기 요청을 보내기 위한 인터페이스
@FeignClient(name = "ProductImage", url = "http://localhost:8084/productimage")
public interface OpenFeignUploadProductImage {
@PostMapping(value = "/upload/{productId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
void call(@PathVariable Long productId, @RequestPart("files") MultipartFile[] files);
}
5) 상품 이미지 MSA에서 요청을 받기 위해 Web Adapter를 만들어준다.
// 요청 Dto
@Data
@Builder
public class RegisterProductImageRequest {
private final MultipartFile[] files;
}
// 검증용 Dto
@Builder
@Data
public class RegisterProductCommand {
private final Long productId;
private final MultipartFile[] files;
}
// Web Adapter
@WebAdapter
@RestController
@RequiredArgsConstructor
public class RegisterProductController {
private final ProductImageUseCase productInputPort;
@RequestMapping(method = RequestMethod.POST, value = "/productimage/upload/{productId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity registerProductImage(@PathVariable Long productId, @RequestPart("files") MultipartFile[] files) {
RegisterProductCommand command = RegisterProductCommand.builder()
.productId(productId)
.files(files)
.build();
return ResponseEntity.ok().body(productInputPort.registerProductImage(command));
}
}
6) InputPort(UseCase)를 통해 서비스를 호출한다.
// InputPort
public interface ProductImageUseCase {
List<ProductImage> registerProductImage(RegisterProductCommand command);
}
// 상품 이미지 도메인
@Builder
@Getter
@AllArgsConstructor
public class ProductImage {
private final Long id;
private final Long productId;
private final String imagePath;
}
// 상품 이미지 서비스
@Service
@RequiredArgsConstructor
public class ProductImageService implements ProductImageUseCase, GetProductImageUseCase {
private final ProductImagePort productImagePort;
private final ProductImageUploadPort productImageUploadPort;
private final GetProductImagePort getProductImagePort;
@Override
public List<ProductImage> registerProductImage(RegisterProductCommand command) {
List<ProductImage> productImages = new ArrayList<>();
for (MultipartFile file : command.getFiles()) {
// 상품 이미지 업로드용 어댑터
String imagePath = productImageUploadPort.uploadProductImage(file);
ProductImage productImage = ProductImage.builder()
.productId(command.getProductId())
.imagePath(imagePath)
.build();
// 상품 이미지 DB 저장용 어댑터
productImage = productImagePort.registerProductImage(productImage);
productImages.add(productImage);
}
return productImages;
}
}
7) 먼저 상품 이미지 업로드를 위한 어댑터를 호출한다.
// Output Port
public interface ProductImageUploadPort {
String uploadProductImage(MultipartFile file);
}
// 어댑터
@ExternalSystemAdapter
@RequiredArgsConstructor
public class ProductImageUploadAdapter implements ProductImageUploadPort {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 s3;
@Override
public String uploadProductImage(MultipartFile file) {
String imagePath = uplopadFile(file);
return imagePath;
}
public String makeFolder() {
String str = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String folderPath = str.replace("/", File.separator);
return folderPath;
}
public String uplopadFile(MultipartFile file) {
String originalName = file.getOriginalFilename();
String folderPath = makeFolder();
String uuid = UUID.randomUUID().toString();
String saveFileName = folderPath + File.separator + uuid + "_" + originalName;
InputStream input = null;
try {
input = file.getInputStream();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
s3.putObject(bucket, saveFileName.replace(File.separator, "/"), input, metadata);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
input.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return s3.getUrl(bucket, saveFileName.replace(File.separator, "/")).toString();
}
}
8) 다음으로 상품 이미지 정보를 DB에 저장하기 위한 어댑터를 호출한다.
// Output Port
public interface ProductImagePort {
ProductImage registerProductImage(ProductImage product);
}
// 상품 이미지 엔티티
@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductImageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private String imagePath;
}
// 상품 이미지 레포지터리
public interface ProductImageJpaRepository extends JpaRepository<ProductImageJpaEntity,Long> {
List<ProductImageJpaEntity> findAllByProductId(Long productId);
}
// 영속성 어댑터
@PersistenceAdapter
@RequiredArgsConstructor
public class ProductImagePersistenceAdapter implements ProductImagePort, GetProductImagePort {
private final ProductImageJpaRepository productImageJpaRepository;
@Override
public ProductImage registerProductImage(ProductImage product) {
ProductImageJpaEntity productImageJpaEntity = ProductImageJpaEntity.builder()
.productId(product.getProductId())
.imagePath(product.getImagePath())
.build();
productImageJpaEntity = productImageJpaRepository.save(productImageJpaEntity);
return ProductImage.builder()
.id(productImageJpaEntity.getId())
.productId(productImageJpaEntity.getProductId())
.imagePath(productImageJpaEntity.getImagePath())
.build();
}
}
서킷 브레이커 란❓
MSA를 도입하면서 단일 서비스 컴포넌트는 여러개로 쪼개져 서로 호출하는/호출당하는 관계를 가진다. 이런 경우 대두되는 문제중 하나가 서비스 간 장애 전파 이다.
하나의 서비스 컴포넌트에 장애가 발생하면 그걸 호출하는 또다른 컴포넌트까지 장애를 전파받는다.
Service 1 -- 호출 --> Service 2 ✅
위와 같은 관계에 놓였을 때 Service 2 의 응답속도가 매우 느려졌다고 가정해보겠다.
이때 Service 1 의 모든 쓰레드가 2 의 응답을 기다리고만 있다면 다른 요청을 처리할 수 없게 되니 상태는 더 악화된다.
이런 식으로 Service 2 의 상태가 Service 1 에 영향을 주는 경우, "서비스 간 장애가 전파되었다" 고 표현한다.
Service 1 과 같이, 2 를 호출하는 또다른 서비스 컴포넌트가 존재한다면 해당 서비스도 장애 전파가 불가피하다. 이런 상황은 자칫 전체 시스템에 영향을 줄 수도 있다.
🐶 Circuit Breaker 패턴
이런 문제를 해결하는 디자인 패턴 중 하나이다.
Service 1 --> |Circuit Breaker| --> Service 2 ✅
기본적으로 Service 1 -> Service 2 호출 사이에 Circuit Breaker 를 설치하는 개념이다.
Service 2 로의 모든 호출은 이 Circuit Breaker 를 통하게되고,
Service 2 가 정상적인 상황에서는 트래픽이 문제없이 통과한다.
하지만 Circuit Breaker 측에서 Service 2 에 문제가 생김을 감지했다면 무슨 일이 발생할까?
Service 2 로의 호출을 강제로 끊어서 Service 1 의 쓰레드들이 더이상 요청에 대한 응답을 기다리지 않도록, 장애가 전파되는걸 방지한다.
🐼 프로젝트에 서킷 브레이커 적용하기
pom.xml
파일에 라이브러리 추가 <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
<version>2.1.7</version>
</dependency>
@WebAdapter
@RequiredArgsConstructor
public class GetProductServiceAdapter implements GetProductPort {
private final OpenFeignGetProduct openFeignGetProduct;
// ✅ 서킷 브레이커 적용을 위한 서킷브레이커 팩토리 의존성 주입
private final CircuitBreakerFactory circuitBreakerFactory;
// ✅ 서킷 브레이커 적용
@Override
public Product getProductPort(Long id) {
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("getProduct");
Product product = circuitBreaker.run(() ->
openFeignGetProduct.call(id)
, throwable -> null
);
return product;
}
}
이렇게 하면, 상품 이미지 MSA가 에러가 발생했을 때, 상품의 정보는 그대로 웹페이지에 출력되도록 되고, 상품의 이미지만 안나오게 된다.
만약, 서킷 브레이커 적용을 안해준다면, 상품 이미지 MSA 가 에러 터지면, 상품 MSA도 영향을 받게 되어 모든 서비스가 작동을 안하게 되는 것이다.
따라서, MSA로 구현을 할 때 서비스와 서비스 간 동기 방식으로 HTTP 요청/응답을 보내는 부분이라면, 서킷 브레이커를 적용시켜주는 것이 서비스를 운영하는데 효율적일 것이다.