diff --git a/.github/workflows/ci-cd-test.yml b/.github/workflows/ci-cd-test.yml
deleted file mode 100644
index 95dfb9ba..00000000
--- a/.github/workflows/ci-cd-test.yml
+++ /dev/null
@@ -1,78 +0,0 @@
-name: Java CI/CD with Gradle
-
-on:
- push:
- branches: [ "main" ]
-
-permissions:
- contents: read
-
-jobs:
- build-docker-image:
- runs-on: ubuntu-latest
-
- steps:
- # 소스코드 체크아웃
- - uses: actions/checkout@v3
-
- # JDK 17 설치
- - name: Set up JDK 17
- uses: actions/setup-java@v3
- with:
- java-version: '17'
- distribution: 'temurin'
-
- # Gradle 실행 권한 부여
- - name: Grant Execute Permission For Gradlew
- run: chmod +x gradlew
-
- # Gradle 캐싱
- - name: Gradle Caching
- uses: actions/cache@v3
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- # Gradle 빌드 (WAR 파일 생성)
- - name: Build With Gradle
- run: ./gradlew clean build -x test
-
- # Docker 이미지 빌드 (WAR → Tomcat 이미지에 복사)
- - name: docker image build
- run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/dolfin-main .
-
- # Docker Hub 로그인
- - name: docker login
- uses: docker/login-action@v2
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_PASSWORD }}
-
- # Docker Hub로 이미지 푸시
- - name: docker Hub push
- run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/dolfin-main
-
- # AWS 자격 증명 주입
- - name: Configure AWS credentials
- uses: aws-actions/configure-aws-credentials@v1
- with:
- aws-access-key-id: ${{ secrets.ACCESS_KEY }}
- aws-secret-access-key: ${{ secrets.SECRET_KEY }}
- aws-region: ${{ secrets.REGION }}
-
- # S3로 Docker Compose 업로드
- - name: Upload Docker Compose to S3
- run: |
- aws s3 cp docker-compose.yml s3://${{ secrets.BUCKET_NAME }}/docker-compose.yml --region ${{ secrets.REGION }}
-
- # 오토스케일링 그룹 롤링 재배포
- - name: Trigger ASG Rolling Update
- run: |
- aws autoscaling start-instance-refresh \
- --auto-scaling-group-name public-test \
- --region ${{ secrets.REGION }} \
- --preferences '{"MinHealthyPercentage": 50}'
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 00000000..9a295ca7
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index 6e060a2f..17b95c9c 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -25,5 +25,17 @@
$ProjectFileDir$
+
+ mysql.8
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://dolfin-database.cf400cwq8ly6.ap-northeast-2.rds.amazonaws.com:3306/dolfin
+
+
+
+
+
+ $ProjectFileDir$
+
\ No newline at end of file
diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml
new file mode 100644
index 00000000..2b63946d
--- /dev/null
+++ b/.idea/uiDesigner.xml
@@ -0,0 +1,124 @@
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index e3320713..4d0d4c6f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -34,6 +34,7 @@ ext {
log4j2Version = '2.20.0'
jacksonVersion = '2.15.3'
aspectjVersion = '1.9.20'
+ springKafkaVersion = '2.9.10'
}
java {
@@ -74,7 +75,7 @@ dependencies {
implementation "org.apache.logging.log4j:log4j-api:${log4j2Version}"
implementation "org.apache.logging.log4j:log4j-core:${log4j2Version}"
implementation "org.apache.logging.log4j:log4j-slf4j-impl:${log4j2Version}"
- implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4:1.16'
+ implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
// xml내 한글 처리
implementation 'xerces:xercesImpl:2.12.2'
@@ -113,6 +114,27 @@ dependencies {
implementation 'org.flywaydb:flyway-core:10.19.0'
implementation 'org.flywaydb:flyway-mysql:10.19.0' // MySQL 8.4 지원
+ //KAFKA
+ implementation "org.springframework.kafka:spring-kafka:${springKafkaVersion}"
+
+ // 스프링 시큐리티
+ implementation 'org.springframework.security:spring-security-config:5.8.10'
+ implementation 'org.springframework.security:spring-security-web:5.8.10'
+ implementation 'org.springframework.security:spring-security-core:5.8.10'
+
+ // JWT (JJWT)
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
+ // Redis (Lettuce 기반 클라이언트)
+ implementation 'org.springframework.data:spring-data-redis:2.7.18'
+ implementation 'io.lettuce:lettuce-core:6.3.1.RELEASE'
+
+ // Bean Validation (JSR-380)
+ implementation 'javax.validation:validation-api:2.0.1.Final'
+ implementation 'org.hibernate.validator:hibernate-validator:6.0.20.Final'
+ implementation 'org.glassfish:javax.el:3.0.0'
}
diff --git a/src/main/java/org/scoula/domain/codef/controller/CodefController.java b/src/main/java/org/scoula/domain/codef/controller/CodefController.java
new file mode 100644
index 00000000..78d3ff76
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/controller/CodefController.java
@@ -0,0 +1,45 @@
+package org.scoula.domain.codef.controller;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.codef.dto.request.StayExpirationRequest;
+import org.scoula.domain.codef.dto.response.StayExpirationResponse;
+import org.scoula.domain.codef.service.CodefApiClient;
+import org.scoula.domain.codef.service.CodefAuthService;
+import org.scoula.global.response.SuccessResponse;
+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.RestController;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+
+@Api(tags = "Codef API")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/codef")
+public class CodefController {
+
+ private final CodefAuthService codefAuthService;
+ private final CodefApiClient codefApiClient;
+
+ @ApiOperation(value = "Codef 토큰 발급 API", notes = "Codef 토큰을 발급한 후 레디스에 저장합니다.")
+ @PostMapping("/token")
+ public SuccessResponse getCodefToken(HttpServletRequest request) {
+ codefAuthService.issueCodefToken(request);
+ return SuccessResponse.noContent();
+ }
+
+ @ApiOperation(value = "Codef 체류 기간 조회 API", notes = "체류 기간을 조회합니다.")
+ @PostMapping("/stay")
+ public SuccessResponse getCodef(
+ @RequestBody StayExpirationRequest stayExpirationRequest, HttpServletRequest request) throws
+ JsonProcessingException {
+ StayExpirationResponse stayExpiration = codefApiClient.getStayExpiration(stayExpirationRequest, request);
+ return SuccessResponse.ok(stayExpiration);
+ }
+}
diff --git a/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java b/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java
new file mode 100644
index 00000000..13285e5f
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java
@@ -0,0 +1,13 @@
+package org.scoula.domain.codef.dto.common;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+@ApiModel(description = "Codef API 공통 응답 구조")
+public record CodefCommonResponse(
+ @ApiModelProperty(notes = "API 호출 결과 상세", required = true)
+ CodefResult result,
+ @ApiModelProperty(notes = "API 상품별 데이터 페이로드")
+ T data
+) {
+}
diff --git a/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java b/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java
new file mode 100644
index 00000000..fe6cc522
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java
@@ -0,0 +1,17 @@
+package org.scoula.domain.codef.dto.common;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+@ApiModel(description = "Codef API 공통 결과 응답 객체")
+public record CodefResult(
+ @ApiModelProperty(notes = "API 호출 결과 코드", example = "CF-00000", required = true)
+ String code,
+ @ApiModelProperty(notes = "API 호출 결과 메시지", example = "성공", required = true)
+ String message,
+ @ApiModelProperty(notes = "API 호출 추가 메시지 (선택 사항)", example = "passportNo", required = false)
+ String extraMessage,
+ @ApiModelProperty(notes = "API 호출 트랜잭션 아이디", example = "f41d087dd6314", required = true)
+ String transactionId
+) {
+}
diff --git a/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java b/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java
new file mode 100644
index 00000000..8ebc87a2
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java
@@ -0,0 +1,23 @@
+package org.scoula.domain.codef.dto.request;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Builder;
+
+@ApiModel(description = "체류 만료 기간 조회 요청 객체")
+@Builder
+public record StayExpirationRequest(
+ @ApiModelProperty(notes = "기관코드 (고정 값 '0001')", example = "0001", required = true)
+ String organization,
+ @ApiModelProperty(notes = "여권번호", example = "E12345678", required = true)
+ String passportNo,
+ @ApiModelProperty(notes = "국적 코드 (0:러시아, 1:몽골, ..., 99:기타)", example = "2", required = true)
+ String nationality,
+ @ApiModelProperty(notes = "국적 '99'일 때 국가명", example = "가나", required = false)
+ String country,
+ @ApiModelProperty(notes = "생년월일 (YYYYMMDD)", example = "19900101", required = true)
+ String birthDate
+) {
+}
+
+
diff --git a/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java b/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java
new file mode 100644
index 00000000..c5131ea3
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java
@@ -0,0 +1,21 @@
+package org.scoula.domain.codef.dto.response;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+@ApiModel(description = "Codef Access Token 응답 객체")
+@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record CodefTokenResponse(
+ @ApiModelProperty(notes = "Access Token", example = "eyJhbGciOiJIUzI1Ni...", required = true)
+ String accessToken,
+ @ApiModelProperty(notes = "토큰 타입", example = "bearer", required = true)
+ String tokenType,
+ @ApiModelProperty(notes = "만료 시간", example = "3600", required = true)
+ Integer expiresIn,
+ @ApiModelProperty(notes = "스코프", example = "read", required = true)
+ String scope
+) {
+}
diff --git a/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java b/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java
new file mode 100644
index 00000000..26526841
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java
@@ -0,0 +1,24 @@
+package org.scoula.domain.codef.dto.response;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+@ApiModel(description = "체류 만료 기간 조회 응답 데이터 객체")
+public record StayExpirationResponse(
+ @ApiModelProperty(notes = "진위 확인 (0: false, 1: true)", example = "1", required = true)
+ String resAuthenticity,
+ @ApiModelProperty(notes = "진위 확인 내용 (resAuthenticity = '0'일 때 제공)", example = "현재 체류중인 외국인이 아닙니다.", required = false)
+ String resAuthenticityDesc,
+ @ApiModelProperty(notes = "여권번호 (resAuthenticity = '1'일 때 제공)", example = "E12345678")
+ String resPassportNo,
+ @ApiModelProperty(notes = "국적 (resAuthenticity = '1'일 때 제공)", example = "2")
+ String resNationality,
+ @ApiModelProperty(notes = "생년월일 (YYYYMMDD, resAuthenticity = '1'일 때 제공)", example = "19900101")
+ String commBirthDate,
+ @ApiModelProperty(notes = "상태 (resAuthenticity = '1'일 때 제공)", example = "VALID")
+ String resStatus,
+ @ApiModelProperty(notes = "만료일 (YYYYMMDD, resAuthenticity = '1'일 때 제공)", example = "20251231")
+ String resExpirationDate
+) {
+}
+
diff --git a/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java b/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java
new file mode 100644
index 00000000..9106addf
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java
@@ -0,0 +1,21 @@
+package org.scoula.domain.codef.exception;
+
+import org.scoula.global.exception.errorCode.ErrorCode;
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum CodefErrorCode implements ErrorCode {
+ CODEF_TOKEN_API_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C-001", "Codef 인증 API 연동에 실패했습니다."),
+ CODEF_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "C-002", "Codef 토큰이 없습니다."),
+ CODEF_STAY_EXPIRATION_API_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C-003", "체류기간 API 연동에 실패했습니다."),
+ CODEF_REQUIRED_PARAMETER_MISSING(HttpStatus.BAD_REQUEST, "C-004", "필수 파라미터가 누락되었습니다."),
+ STAY_AUTHENTICITY_FAILED(HttpStatus.BAD_REQUEST, "C-005", "현재 체류중이 아닙니다."),
+ ;
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java
new file mode 100644
index 00000000..be721a33
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java
@@ -0,0 +1,108 @@
+package org.scoula.domain.codef.service;
+
+import static org.scoula.domain.codef.exception.CodefErrorCode.*;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.codef.dto.common.CodefCommonResponse;
+import org.scoula.domain.codef.dto.request.StayExpirationRequest;
+import org.scoula.domain.codef.dto.response.StayExpirationResponse;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.redis.util.RedisUtil;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class CodefApiClient {
+
+ private static final String REDIS_ACCESS_TOKEN_KEY = "codef_access_token";
+ private final static String STAY_EXPIRATION_URL = "https://development.codef.io/v1/kr/public/mj/hi-korea/stay-expiration-date";
+
+ private final RedisUtil redisUtil;
+ private final RestTemplate restTemplate;
+ private final ObjectMapper objectMapper;
+
+ public StayExpirationResponse getStayExpiration(StayExpirationRequest stayExpirationRequest,
+ HttpServletRequest request) throws
+ JsonProcessingException {
+ String cachedAccessToken = redisUtil.get(REDIS_ACCESS_TOKEN_KEY).toString();
+
+ Common common = Common.builder()
+ .srcIp(request.getRemoteAddr())
+ .apiMethod(request.getMethod())
+ .callApiPath(request.getRequestURI())
+ .deviceInfo(request.getHeader("user-agent"))
+ .build();
+
+ // TODO jwt 만료 로직 추가
+ if (cachedAccessToken == null) {
+ throw new CustomException(CODEF_TOKEN_NOT_FOUND, LogLevel.INFO, null, common);
+ }
+
+ HttpEntity requestEntity = getRequestHttpEntity(stayExpirationRequest,
+ cachedAccessToken);
+
+ CodefCommonResponse response = getCodefCommonResponse(requestEntity, common);
+
+ String resultCode = response.result().code();
+
+ if (resultCode.equals("CF-00000")) {
+ if (response.data().resAuthenticity().equals("0")) {
+ // throw new CustomException(STAY_AUTHENTICITY_FAILED, LogLevel.WARNING, null, common);
+ return new StayExpirationResponse(
+ "0", // resAuthenticity
+ "현재 체류중인 외국인이 아닙니다.", // resAuthenticityDesc
+ null, // resPassportNo
+ null, // resNationality
+ null, // commBirthDate
+ null, // resStatus
+ LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) // resExpirationDate
+ );
+ }
+ return response.data();
+ } else if (resultCode.equals("CF-00001")) {
+ throw new CustomException(CODEF_REQUIRED_PARAMETER_MISSING, LogLevel.WARNING, null, common);
+ } else {
+ throw new CustomException(CODEF_STAY_EXPIRATION_API_FAILED, LogLevel.ERROR, null, common);
+ }
+ }
+
+ private HttpEntity getRequestHttpEntity(
+ StayExpirationRequest stayExpirationRequest, String cachedAccessToken) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBearerAuth(cachedAccessToken);
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ return new HttpEntity<>(stayExpirationRequest, headers);
+ }
+
+ private CodefCommonResponse getCodefCommonResponse(
+ HttpEntity requestEntity, Common common) throws JsonProcessingException {
+ String rawResponse = restTemplate.postForObject(STAY_EXPIRATION_URL, requestEntity, String.class);
+
+ if (rawResponse == null) {
+ throw new CustomException(CODEF_STAY_EXPIRATION_API_FAILED, LogLevel.ERROR, null, common);
+ }
+ String decoded = URLDecoder.decode(rawResponse, StandardCharsets.UTF_8);
+
+ return objectMapper.readValue(decoded, new TypeReference<>() {
+ });
+ }
+}
diff --git a/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java b/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java
new file mode 100644
index 00000000..bb887b88
--- /dev/null
+++ b/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java
@@ -0,0 +1,84 @@
+package org.scoula.domain.codef.service;
+
+import static org.scoula.domain.codef.exception.CodefErrorCode.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.codef.dto.response.CodefTokenResponse;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.redis.util.RedisUtil;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class CodefAuthService {
+
+ private static final String OAUTH_ENDPOINT = "https://oauth.codef.io/oauth/token";
+ private static final String REDIS_ACCESS_TOKEN_KEY = "codef_access_token";
+ private final RestTemplate restTemplate;
+ private final RedisUtil redisUtil;
+ @Value("${codef.client.id}")
+ private String clientId;
+ @Value("${codef.client.secret}")
+ private String clientSecret;
+
+ public void issueCodefToken(HttpServletRequest request) {
+ HttpEntity> requestEntity = getRequestEntity();
+
+ ResponseEntity responseEntity = restTemplate.postForEntity(
+ OAUTH_ENDPOINT, requestEntity, CodefTokenResponse.class);
+
+ CodefTokenResponse response = responseEntity.getBody();
+ String codefToken = response.accessToken();
+ Integer expiresIn = response.expiresIn();
+
+ Common common = Common.builder()
+ .srcIp(request.getRemoteAddr())
+ .apiMethod(request.getMethod())
+ .callApiPath(request.getRequestURI())
+ .deviceInfo(request.getHeader("user-agent"))
+ .build();
+
+ if (codefToken == null || expiresIn == null) {
+ throw new CustomException(CODEF_TOKEN_NOT_FOUND, LogLevel.INFO, null, common);
+ }
+
+ if (responseEntity.getStatusCode().is2xxSuccessful()) {
+ redisUtil.set(REDIS_ACCESS_TOKEN_KEY, codefToken, expiresIn);
+ } else {
+ throw new CustomException(CODEF_TOKEN_API_FAILED, LogLevel.ERROR, null, common);
+ }
+ }
+
+ private HttpEntity> getRequestEntity() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ String auth = clientId + ":" + clientSecret;
+ String authStringEnc = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
+ headers.set("Authorization", "Basic " + authStringEnc);
+
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ params.add("grant_type", "client_credentials");
+ params.add("scope", "read");
+
+ return new HttpEntity<>(params, headers);
+ }
+}
diff --git a/src/main/java/org/scoula/domain/controller/HealthCheckController.java b/src/main/java/org/scoula/domain/controller/HealthCheckController.java
index 7dad8fc0..6cc73e39 100644
--- a/src/main/java/org/scoula/domain/controller/HealthCheckController.java
+++ b/src/main/java/org/scoula/domain/controller/HealthCheckController.java
@@ -1,15 +1,22 @@
-
package org.scoula.domain.controller;
-import io.swagger.annotations.Api;
-import io.swagger.annotations.ApiOperation;
-import lombok.extern.log4j.Log4j2;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.exception.errorCode.CommonErrorCode;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.response.SuccessResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
-import java.util.HashMap;
-import java.util.Map;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.log4j.Log4j2;
@Api(tags = "헬스 체크 API")
@RestController
@@ -17,30 +24,41 @@
@Log4j2
public class HealthCheckController {
- @GetMapping
- @ApiOperation("서버 상태 확인")
- public Map healthCheck() {
- log.info("Health check called");
-
- Map response = new HashMap<>();
- response.put("status", "동작 중");
- response.put("timestamp", System.currentTimeMillis());
- response.put("message", "Server is running");
-
- return response;
- }
-
- @GetMapping("/swagger")
- @ApiOperation("Swagger 연동 테스트")
- public Map swaggerTest() {
- log.info("Swagger test called");
-
- Map response = new HashMap<>();
- response.put("swagger", "enabled");
- response.put("version", "2.0");
-
- return response;
- }
+ @GetMapping
+ @ApiOperation("서버 상태 확인")
+ public Map healthCheck() {
+ log.info("Health check called");
+
+ Map response = new HashMap<>();
+ response.put("status", "동작 중");
+ response.put("timestamp", System.currentTimeMillis());
+ response.put("message", "Server is running");
+
+ return response;
+ }
+
+ @GetMapping("/swagger")
+ @ApiOperation("Swagger 연동 테스트")
+ public Map swaggerTest() {
+ log.info("Swagger test called");
+
+ Map response = new HashMap<>();
+ response.put("swagger", "enabled");
+ response.put("version", "2.0");
+
+ return response;
+ }
+ @GetMapping("/error")
+ @ApiOperation("에러 로그 전송 테스트")
+ public SuccessResponse error(HttpServletRequest request) {
+ throw new CustomException(CommonErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR, null,
+ Common.builder()
+ .srcIp(request.getRemoteAddr())
+ .apiMethod(request.getMethod())
+ .callApiPath(request.getRequestURI())
+ .deviceInfo(request.getHeader("user-agent"))
+ .build());
+ }
}
\ No newline at end of file
diff --git a/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java b/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java
new file mode 100644
index 00000000..3a10e973
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java
@@ -0,0 +1,59 @@
+package org.scoula.domain.exchange.controller;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.exchange.dto.request.ExchangeBankRequest;
+import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest;
+import org.scoula.domain.exchange.dto.response.ExchangeBankResponse;
+import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse;
+import org.scoula.domain.exchange.service.ExchangeRateService;
+import org.springframework.http.ResponseEntity;
+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.RestController;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/exchange")
+@Api(tags = "환율 API")
+public class ExchangeRateController {
+
+ private final ExchangeRateService exchangeRateService;
+
+ @ApiOperation(
+ value = "환율 계산 및 비교",
+ notes = "KRW 기준으로 목표 통화에 대한 5개 은행의 환율을 비교하고 최적 환율을 제공합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(code = 200, message = "성공적으로 요청이 처리되었습니다.", response = ExchangeBankResponse.class),
+ @ApiResponse(code = 400, message = "잘못된 요청입니다."),
+ @ApiResponse(code = 500, message = "서버에서 오류가 발생했습니다.")
+ })
+ @PostMapping("/calculate/bank-rates")
+ public ResponseEntity calculateExchangeRateBank(
+ @RequestBody ExchangeBankRequest requestBody,
+ HttpServletRequest httpServletRequest) {
+ ExchangeBankResponse response = exchangeRateService.calculateExchangeBank(requestBody, httpServletRequest);
+
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping("/calculate/quick-rate")
+ public ResponseEntity calculateExchangeQuickNormal(
+ @RequestBody ExchangeQuickRequest requestBody,
+ HttpServletRequest httpServletRequest) {
+ ExchangeQuickResponse response = exchangeRateService.calculateExchangeQuick(requestBody, httpServletRequest);
+
+ return ResponseEntity.ok(response);
+ }
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java
new file mode 100644
index 00000000..69026123
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java
@@ -0,0 +1,36 @@
+package org.scoula.domain.exchange.dto.request;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+@ApiModel(description = "환율 계산 요청 정보")
+public class ExchangeBankRequest {
+
+ @ApiModelProperty(
+ value = "환전하려는 원화(KRW) 금액",
+ example = "1000000",
+ required = true,
+ position = 1
+ )
+ private BigDecimal amountInKRW;
+
+ @ApiModelProperty(
+ value = "목표 통화 코드",
+ example = "VND",
+ required = true,
+ allowableValues = "USD, JPY, EUR, GBP, CAD, HKD, CNY, THB, IDR, VND, RUB, MYR",
+ position = 2
+ )
+ private String targetCurrency;
+
+ @ApiModelProperty(
+ value = "환율 타입",
+ example = "GETCASH",
+ required = true,
+ allowableValues = "GETCASH, SELLCASH, SEND, RECEIVE, BASE",
+ position = 3
+ )
+ private String type;
+}
diff --git a/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java
new file mode 100644
index 00000000..bd6dd2ff
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java
@@ -0,0 +1,30 @@
+package org.scoula.domain.exchange.dto.request;
+
+import java.math.BigDecimal;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(description = "환율 계산 요청 정보")
+public class ExchangeQuickRequest {
+
+ @ApiModelProperty(
+ value = "환전하려는 원화(KRW) 금액",
+ example = "1000000",
+ required = true,
+ position = 1
+ )
+ private BigDecimal amountInKRW;
+
+ @ApiModelProperty(
+ value = "목표 통화 코드",
+ example = "VND",
+ required = true,
+ allowableValues = "USD, JPY, EUR, GBP, CAD, HKD, CNY, THB, IDR, VND, RUB, MYR",
+ position = 2
+ )
+ private String targetCurrency;
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java b/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java
new file mode 100644
index 00000000..e2d12470
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java
@@ -0,0 +1,30 @@
+package org.scoula.domain.exchange.dto.response;
+
+import java.math.BigDecimal;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@ApiModel(description = "은행별 환율 정보")
+public class BankRateInfo {
+
+ @ApiModelProperty(value = "은행명", example = "KB국민은행")
+ private String bankName;
+
+ @ApiModelProperty(value = "환율 (1 목표통화당 KRW)", example = "0.053")
+ private BigDecimal exchangeRate;
+
+ @ApiModelProperty(value = "환율 표시 문자열", example = "1 VND(100) = 5.3 KRW")
+ private String rateDisplay;
+
+ @ApiModelProperty(value = "환전 후 받게 될 목표 통화 금액", example = "18867924.53")
+ private BigDecimal totalAmount;
+
+ @ApiModelProperty(value = "환전 후 금액 표시 문자열", example = "18,867,925 VND")
+ private String totalAmountDisplay;
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java
new file mode 100644
index 00000000..7ae309ef
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java
@@ -0,0 +1,27 @@
+package org.scoula.domain.exchange.dto.response;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Builder;
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+@Builder
+@ApiModel(description = "환율 계산 응답 정보")
+public class ExchangeBankResponse {
+
+ @ApiModelProperty(value = "요청한 원화 금액", example = "1000000")
+ private BigDecimal requestedAmountKRW;
+
+ @ApiModelProperty(value = "목표 통화 코드", example = "VND")
+ private String targetCurrency;
+
+ @ApiModelProperty(value = "환율 타입", example = "SELLCASH")
+ private String exchangeType;
+
+ @ApiModelProperty(value = "모든 은행의 환율 정보 (환율이 유리한 순서로 정렬)")
+ private List allBanks;
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java
new file mode 100644
index 00000000..70a4b3e3
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java
@@ -0,0 +1,28 @@
+package org.scoula.domain.exchange.dto.response;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@ApiModel(description = "환율 계산 응답 정보")
+public class ExchangeQuickResponse {
+
+ @ApiModelProperty(value = "요청한 원화 금액", example = "1000000")
+ private BigDecimal requestedAmountKRW;
+
+ @ApiModelProperty(value = "목표 통화 코드", example = "VND")
+ private String targetCurrency;
+
+ @ApiModelProperty(value = "5.3", example = "5.3")
+ private BigDecimal ResultRate;
+
+ @ApiModelProperty(value = "5.3 VND(100)", example = "5.3 VND")
+ private String ResultRateDisplay;
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java b/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java
index 1a5026fc..e37b97ba 100644
--- a/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java
+++ b/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java
@@ -7,14 +7,18 @@
import org.scoula.global.entity.BaseEntity;
import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
/**
* 환율
*/
@Getter
+@Setter // MyBatis는 setter가 필요합니다!
@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
public class ExchangeRate extends BaseEntity {
private Long exchangeId;
@@ -23,6 +27,5 @@ public class ExchangeRate extends BaseEntity {
private BigDecimal exchangeValue; // 환율 값
private String bankName; // 은행 이름
private LocalDate ExchangeDate; // 환율 적용 날짜
- private Type type; // 환전 타입 / GETCASH(현금 인출), SELFCASH(현금 입금), SEND(송금), RECEIVE(수취), BASE(기준 환율)
-
+ private Type type; // 환전 타입 / SELLCASH(현금 송금), GETCASH(현금 수취), SEND(송금), RECEIVE(수취), BASE(기준 환율)
}
diff --git a/src/main/java/org/scoula/domain/exchange/entity/Type.java b/src/main/java/org/scoula/domain/exchange/entity/Type.java
index b7a3c7e3..edc78a25 100644
--- a/src/main/java/org/scoula/domain/exchange/entity/Type.java
+++ b/src/main/java/org/scoula/domain/exchange/entity/Type.java
@@ -1,5 +1,5 @@
package org.scoula.domain.exchange.entity;
public enum Type {
- GETCASH, SELFCASH, SEND, RECEIVE, BASE
+ GETCASH, SELLCASH, SEND, RECEIVE, BASE
}
diff --git a/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java b/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java
new file mode 100644
index 00000000..aebe4d6d
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java
@@ -0,0 +1,20 @@
+package org.scoula.domain.exchange.exception;
+
+import org.scoula.global.exception.errorCode.ErrorCode;
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum ExchangeErrorCode implements ErrorCode {
+
+ // 400
+ EXCHANGE_NOT_FOUND(HttpStatus.NOT_FOUND, "M-001", "존재하지 않는 화폐 입니다."),
+ EXCHANGE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java b/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java
new file mode 100644
index 00000000..90330898
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java
@@ -0,0 +1,22 @@
+package org.scoula.domain.exchange.mapper;
+
+import org.scoula.domain.exchange.entity.ExchangeRate;
+import org.apache.ibatis.annotations.Param;
+
+
+public interface ExchangeRateMapper {
+
+
+ /**
+ * 최신 환율 날짜 조회
+ * @return 가장 최근의 환율 날짜
+ */
+ ExchangeRate findLatestExchangeRate(
+ @Param("bankName") String bankName,
+ @Param("type") String type,
+ @Param("targetCurrency") String targetCurrency
+ );
+
+
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java
new file mode 100644
index 00000000..67055081
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java
@@ -0,0 +1,16 @@
+package org.scoula.domain.exchange.service;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.exchange.dto.request.ExchangeBankRequest;
+import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest;
+import org.scoula.domain.exchange.dto.response.ExchangeBankResponse;
+import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse;
+
+public interface ExchangeRateService {
+
+ ExchangeBankResponse calculateExchangeBank(ExchangeBankRequest request, HttpServletRequest httpServletRequest);
+
+ ExchangeQuickResponse calculateExchangeQuick(ExchangeQuickRequest request, HttpServletRequest httpServletRequest);
+
+}
diff --git a/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java
new file mode 100644
index 00000000..de348db9
--- /dev/null
+++ b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java
@@ -0,0 +1,159 @@
+package org.scoula.domain.exchange.service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest;
+import org.scoula.domain.exchange.dto.response.BankRateInfo;
+import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse;
+import org.scoula.domain.exchange.exception.ExchangeErrorCode;
+import org.scoula.domain.exchange.mapper.ExchangeRateMapper;
+import org.scoula.domain.exchange.dto.request.ExchangeBankRequest;
+import org.scoula.domain.exchange.dto.response.ExchangeBankResponse;
+import org.scoula.domain.exchange.entity.ExchangeRate;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class ExchangeRateServiceImpl implements ExchangeRateService {
+
+ private final ExchangeRateMapper exchangeRateMapper;
+ private final DecimalFormat currencyFormatter = new DecimalFormat("#,###.##");
+ private final DecimalFormat rateFormatter = new DecimalFormat("#,##0.####");
+
+ @Override
+ public ExchangeBankResponse calculateExchangeBank(ExchangeBankRequest request,
+ HttpServletRequest httpServletRequest) {
+
+ List banks = List.of("KB국민은행", "하나은행", "신한은행", "우리은행", "기업은행");
+ List rates = new ArrayList<>();
+
+ // 각 은행별 환율 정보 조회
+ for (String bank : banks) {
+ ExchangeRate latestExchangeRate = exchangeRateMapper
+ .findLatestExchangeRate(bank, request.getType(), request.getTargetCurrency());
+ if (latestExchangeRate != null) {
+ log.info("은행: {}, 환율: {}", bank, latestExchangeRate.getExchangeValue());
+ BankRateInfo bankRateInfo = calculateBankRate(latestExchangeRate, request.getAmountInKRW());
+ rates.add(bankRateInfo);
+ }
+
+ }
+
+ if (rates.isEmpty()) {
+ throw new CustomException(
+ ExchangeErrorCode.EXCHANGE_NOT_FOUND, LogLevel.ERROR, null, Common.builder().build()
+ );
+ }
+
+ // 환율 타입에 따른 정렬
+ List sortedRates = sortByExchangeType(rates, request.getType());
+
+ return ExchangeBankResponse.builder()
+ .requestedAmountKRW(request.getAmountInKRW())
+ .targetCurrency(request.getTargetCurrency())
+ .exchangeType(request.getType())
+ .allBanks(sortedRates)
+ .build();
+ }
+
+ @Override
+ public ExchangeQuickResponse calculateExchangeQuick(ExchangeQuickRequest request,
+ HttpServletRequest httpServletRequest) {
+ return calculateExchangeQuickNormal(request);
+
+ }
+
+ private List sortByExchangeType(List rates, String type) {
+ Comparator comparator;
+
+ switch (type) {
+ case "SELLCASH": // 현찰 팔 때 - 낮은 환율이 유리
+ case "SEND": // 송금 보낼 때 - 낮은 환율이 유리
+ case "BASE": // 기준율 - 낮은 순으로 정렬
+ comparator = Comparator.comparing(BankRateInfo::getExchangeRate);
+ break;
+
+ case "GETCASH": // 현찰 살 때 - 높은 환율이 유리
+ case "RECEIVE": // 송금 받을 때 - 높은 환율이 유리
+ comparator = Comparator.comparing(BankRateInfo::getExchangeRate).reversed();
+ break;
+
+ default:
+ comparator = Comparator.comparing(BankRateInfo::getExchangeRate);
+ }
+
+ return rates.stream()
+ .sorted(comparator)
+ .collect(Collectors.toList());
+ }
+
+ private BankRateInfo calculateBankRate(ExchangeRate rate, BigDecimal amountInKRW) {
+ // 환전 후 받게 될 목표 통화 금액 계산
+ BigDecimal targetAmount;
+ String rateDisplay;
+
+ // KRW -> 목표통화: 원화금액 / 환율
+ targetAmount = amountInKRW.divide(rate.getExchangeValue(), 2, RoundingMode.HALF_UP);
+
+ // 환율 표시 (1 목표통화 = X KRW)
+ rateDisplay = String.format("1 %s = %s KRW",
+ rate.getTargetExchange(),
+ rateFormatter.format(rate.getExchangeValue()));
+
+
+ return BankRateInfo.builder()
+ .bankName(rate.getBankName())
+ .exchangeRate(rate.getExchangeValue())
+ .rateDisplay(rateDisplay)
+ .totalAmount(targetAmount)
+ .totalAmountDisplay(String.format("%s %s",
+ currencyFormatter.format(targetAmount.setScale(2, RoundingMode.HALF_UP)),
+ rate.getTargetExchange()))
+ .build();
+ }
+
+ private ExchangeQuickResponse calculateExchangeQuickNormal(ExchangeQuickRequest request) {
+
+ // 기준 통화(KRW)에서 목표 통화로의 환율 조회
+ ExchangeRate baseRate = exchangeRateMapper.findLatestExchangeRate(
+ "KB국민은행", "BASE", request.getTargetCurrency());
+
+ if (baseRate == null) {
+ throw new CustomException(
+ ExchangeErrorCode.EXCHANGE_NOT_FOUND, LogLevel.ERROR, null, Common.builder().build()
+ );
+ }
+
+ BigDecimal targetAmount = request.getAmountInKRW().divide(
+ baseRate.getExchangeValue(), 2, RoundingMode.HALF_UP);
+
+ String rateDisplay = String.format("%s %s",
+ currencyFormatter.format(targetAmount.setScale(2, RoundingMode.HALF_UP)),
+ request.getTargetCurrency());
+
+ return ExchangeQuickResponse.builder()
+ .requestedAmountKRW(request.getAmountInKRW())
+ .targetCurrency(request.getTargetCurrency())
+ .ResultRate(targetAmount)
+ .ResultRateDisplay(rateDisplay)
+ .build();
+ }
+
+}
diff --git a/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java b/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java
index b7e83e50..416a8999 100644
--- a/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java
+++ b/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java
@@ -7,23 +7,25 @@
import java.math.BigDecimal;
+import org.scoula.global.entity.BaseEntity;
+
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
-public class DepositSaving {
- private Long depositSavingId; // 아이디 (PK)
- private Long financialCompanyId; // 금융회사 아이디 (FK)
- private DepositSavingType type; // 금융 상품 종류 / DEPOSIT(예금), SAVING(적금)
- private String name; // 금융 상품명
- private String joinWay; // 가입 방법
- private String interestDescription; // 만기 후 이자율 설명
- private String spdCondition; // 우대조건
- private String etcNote; // 기타 유의사항
- private String maxLimit; // 최고 한도
- private Integer saveMonth; // 저축 기간(개월)
- private BigDecimal interestRate; // 기본 금리
- private BigDecimal maxInterestRate; // 우대 최고 금리
- private String interestRateType; // 금리 유형
- private String reserveType; // 적립 유형
+public class DepositSaving extends BaseEntity {
+ private Long depositSavingId; // 아이디 (PK)
+ private Long financialCompanyId; // 금융회사 아이디 (FK)
+ private DepositSavingType type; // 금융 상품 종류 / DEPOSIT(예금), SAVING(적금)
+ private String name; // 금융 상품명
+ private String joinWay; // 가입 방법
+ private String interestDescription; // 만기 후 이자율 설명
+ private String spdCondition; // 우대조건
+ private String etcNote; // 기타 유의사항
+ private String maxLimit; // 최고 한도
+ private Integer saveMonth; // 저축 기간(개월)
+ private BigDecimal interestRate; // 기본 금리
+ private BigDecimal maxInterestRate; // 우대 최고 금리
+ private String interestRateType; // 금리 유형
+ private String reserveType; // 적립 유형
}
diff --git a/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java b/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java
index e321dd88..0a5a91ce 100644
--- a/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java
+++ b/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java
@@ -1,6 +1,8 @@
package org.scoula.domain.financialproduct.financialcompany.entity;
+import org.scoula.global.entity.BaseEntity;
+
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -10,7 +12,7 @@
@NoArgsConstructor
@AllArgsConstructor
@Builder
-public class FinancialCompany {
+public class FinancialCompany extends BaseEntity {
private Long financialCompanyId;
private String name;
private String code;
diff --git a/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java b/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java
index 2d2c3299..a26ffd2f 100644
--- a/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java
+++ b/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java
@@ -7,6 +7,8 @@
import java.math.BigDecimal;
+import org.scoula.global.entity.BaseEntity;
+
/**
* 전세자금대출
*/
@@ -14,10 +16,10 @@
@NoArgsConstructor
@AllArgsConstructor
@Builder
-public class JeonseLoan {
+public class JeonseLoan extends BaseEntity {
private Long jeonseLoanId; // 전세자금대출 아이디
private Long financialCompanyId; // 금융회사 아이디
- private String name; // 금융상품명
+ private String name; // 금융상품명
private String joinWay; // 가입 방법
private String loanExpensive; // 대출 부대비용
private String erlyFee; // 중도상환 수수료
diff --git a/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java b/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java
index b5079647..4224b002 100644
--- a/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java
+++ b/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java
@@ -7,6 +7,8 @@
import java.math.BigDecimal;
+import org.scoula.global.entity.BaseEntity;
+
/**
* 개인신용대출
*/
@@ -14,7 +16,7 @@
@AllArgsConstructor
@NoArgsConstructor
@Builder
-public class PersonalCreditLoan {
+public class PersonalCreditLoan extends BaseEntity {
private Long personalCreditLoanId; // 개인신용대출 아이디 (PK)
private Long financialCompanyId; // 금융회사 아이디 (FK)
diff --git a/src/main/java/org/scoula/domain/location/entity/Location.java b/src/main/java/org/scoula/domain/location/entity/Location.java
index 191e4d27..908ba812 100644
--- a/src/main/java/org/scoula/domain/location/entity/Location.java
+++ b/src/main/java/org/scoula/domain/location/entity/Location.java
@@ -1,5 +1,7 @@
package org.scoula.domain.location.entity;
+import org.scoula.global.entity.BaseEntity;
+
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -9,12 +11,12 @@
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class Location {
- private Long locationId; // 위치 ID
- private String locationName; // 위치 이름
- private String address; // 주소
- private Point point; // 위도, 경도
- private String locationNumber; // 전화번호
+public class Location extends BaseEntity {
+ private Long locationId; // 위치 ID
+ private String locationName; // 위치 이름
+ private String address; // 주소
+ private Point point; // 위도, 경도
+ private String locationNumber; // 전화번호
private LocationType locationType; // 장소 타입 CENTER(은행), CONSULT(외국인 센터), BANK(은행)
}
diff --git a/src/main/java/org/scoula/domain/member/controller/AuthController.java b/src/main/java/org/scoula/domain/member/controller/AuthController.java
new file mode 100644
index 00000000..f3bc5f3b
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/controller/AuthController.java
@@ -0,0 +1,69 @@
+package org.scoula.domain.member.controller;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+
+import org.scoula.domain.member.dto.request.ReissueTokenRequestDto;
+import org.scoula.domain.member.dto.request.SignInRequestDto;
+import org.scoula.domain.member.dto.response.RefreshResponseDto;
+import org.scoula.domain.member.dto.response.SignInResponseDto;
+import org.scoula.domain.member.service.MemberAuthService;
+import org.scoula.global.response.SuccessResponse;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.GetMapping;
+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.RestController;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/auth")
+@Api(tags = "인증 API", description = "로그인, 로그아웃, 토큰 재발급 등의 인증 관련 API")
+public class AuthController {
+
+ private final MemberAuthService memberAuthService;
+
+ @ApiOperation(value = "로그인", notes = "아이디와 비밀번호를 이용해 로그인을 수행합니다.")
+ @ApiResponses({
+ @ApiResponse(code = 401, message = "아이디 또는 비밀번호가 일치하지 않습니다.")
+ })
+ @PostMapping("/signin")
+ public SuccessResponse signIn(@RequestBody @Valid SignInRequestDto requestDto,
+ HttpServletResponse response, HttpServletRequest request) {
+ SignInResponseDto signInResponseDto = memberAuthService.signIn(requestDto, request, response);
+ return SuccessResponse.ok(signInResponseDto);
+ }
+
+ @ApiOperation(value = "토큰 재발급", notes = "Refresh Token을 이용하여 Access Token을 재발급합니다. "
+ + "로그인한 회원이 뭐든 api를 호출해 사용 중에 토큰 재발급이 필요합니다. 라는 메세지와 401 코드가 반환되면 토큰 현재 refresh api를 호출해 토큰 재발급 시키면 됩니다.\"\n"
+ + "\n 만약 리프레시를 시도했는데, 로그인이 필요한 서비스입니다. 라는 메세지와 400 코드가 반환되면 로그인 페이지로 넘기면 됩니다. ")
+ @ApiResponses({
+ @ApiResponse(code = 400, message = "로그인이 필요한 서비스입니다.")
+ })
+ @PostMapping("/refresh")
+ public SuccessResponse refreshToken(@RequestBody @Valid ReissueTokenRequestDto requestDto,
+ @ApiParam(value = "Refresh Token (쿠키)", required = true)
+ @CookieValue(name = "refreshToken", required = false) String refreshToken,
+ HttpServletRequest request, HttpServletResponse response) {
+ return SuccessResponse.ok(memberAuthService.reissueToken(requestDto, refreshToken, request, response));
+ }
+
+ @ApiOperation(value = "로그아웃", notes = "클라이언트에서 보유한 Refresh Token을 만료시키고 로그아웃합니다.")
+ @ApiResponses({
+ @ApiResponse(code = 204, message = "로그아웃 성공")
+ })
+ @GetMapping("/signout")
+ public SuccessResponse signOut(HttpServletResponse response) {
+ memberAuthService.signOut(response);
+ return SuccessResponse.noContent();
+ }
+}
diff --git a/src/main/java/org/scoula/domain/member/controller/JoinController.java b/src/main/java/org/scoula/domain/member/controller/JoinController.java
new file mode 100644
index 00000000..e56093d1
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/controller/JoinController.java
@@ -0,0 +1,48 @@
+package org.scoula.domain.member.controller;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+
+import org.scoula.domain.member.dto.request.JoinRequest;
+import org.scoula.domain.member.service.JoinService;
+import org.scoula.global.response.SuccessResponse;
+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.RestController;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.RequiredArgsConstructor;
+
+@Api(tags = "회원 가입 API")
+@RestController
+@RequestMapping("/auth")
+@RequiredArgsConstructor
+public class JoinController {
+ private final JoinService joinService;
+
+ @ApiOperation(value = "회원 가입", notes = "사용자 정보를 받아 회원가입을 처리합니다.")
+ @PostMapping("/join")
+ public SuccessResponse joinMember(@RequestBody @Valid JoinRequest joinRequest,
+ HttpServletRequest request) throws
+ JsonProcessingException {
+ joinService.joinMember(joinRequest, request);
+ return SuccessResponse.noContent();
+ }
+
+ @ApiOperation(value = "아이디 중복 확인", notes = "입력한 아이디의 중복 여부를 확인합니다.")
+ @PostMapping("/check-id")
+ public SuccessResponse verifyLoginId(@ApiParam(value = "확인할 로그인 아이디")
+ @RequestBody @NotBlank(message = "아이디를 입력해주세요.") String loginId) {
+ joinService.checkLoginId(loginId);
+ return SuccessResponse.ok(null);
+ }
+
+ // TODO 핸드폰 인증
+
+}
diff --git a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java
index e78a7cf3..06eb1454 100644
--- a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java
+++ b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java
@@ -1,5 +1,12 @@
package org.scoula.domain.member.dto;
+import java.time.LocalDate;
+
+import org.scoula.domain.member.entity.Member;
+import org.scoula.global.constants.Currency;
+import org.scoula.global.constants.NationalityCode;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
@@ -7,10 +14,6 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
-import org.scoula.domain.member.entity.Member;
-
-import java.time.LocalDate;
-
@Getter
@NoArgsConstructor
@AllArgsConstructor
@@ -31,7 +34,7 @@ public class MemberDTO {
private String passportNumber;
@ApiModelProperty(value = "국적", example = "KOR")
- private String nationality;
+ private NationalityCode nationality;
@ApiModelProperty(value = "생년월일")
private LocalDate birth;
@@ -46,7 +49,7 @@ public class MemberDTO {
private LocalDate remainTime;
@ApiModelProperty(value = "설정 통화", example = "USD")
- private String currency;
+ private Currency currency;
// Entity -> DTO 변환
public static MemberDTO from(Member member) {
@@ -80,6 +83,10 @@ public Member toEntity() {
.build();
}
+ public void PasswordEncrypt(PasswordEncoder passwordEncoder) {
+ password = passwordEncoder.encode(this.password);
+ }
+
public void changePassword(String newPassword) {
this.password = newPassword;
}
diff --git a/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java
new file mode 100644
index 00000000..ae3617f6
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java
@@ -0,0 +1,44 @@
+package org.scoula.domain.member.dto.request;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+import org.scoula.global.constants.NationalityCode;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+@ApiModel(description = "회원가입 요청 데이터")
+public record JoinRequest(
+ @ApiModelProperty(value = "로그인 아이디", example = "user123", required = true)
+ @NotBlank(message = "로그인 아이디는 필수 입력 값입니다.")
+ String loginId,
+
+ @ApiModelProperty(value = "비밀번호", example = "password123!", required = true)
+ @NotBlank(message = "비밀번호는 필수 입력 값입니다.")
+ String password,
+
+ @ApiModelProperty(value = "휴대폰 번호", example = "010-1234-5678", required = true)
+ @NotBlank(message = "휴대폰 번호는 필수 입력 값입니다.")
+ String phoneNumber,
+
+ @ApiModelProperty(value = "이름", example = "홍길동", required = true)
+ @NotBlank(message = "이름은 필수 입력 값입니다.")
+ String name,
+
+ @ApiModelProperty(value = "생년월일", example = "20000101", required = true)
+ @NotNull(message = "생년월일은 필수 입력 값입니다.")
+ String birth,
+
+ @ApiModelProperty(value = "여권 번호", example = "M12345678", required = true)
+ @NotBlank(message = "여권 번호는 필수 입력 값입니다.")
+ String passportNumber,
+
+ @ApiModelProperty(value = "국적", example = "USA", required = true)
+ @NotNull(message = "국적은 필수 입력 값입니다.")
+ NationalityCode nationality,
+
+ @ApiModelProperty(value = "나라", example = "덴마크 - nationality가 OTHER일 때의 나라명", required = false)
+ String country
+) {
+}
diff --git a/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java
new file mode 100644
index 00000000..5783244a
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java
@@ -0,0 +1,17 @@
+package org.scoula.domain.member.dto.request;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+import io.swagger.annotations.ApiModelProperty;
+
+public record ReissueTokenRequestDto(
+ @ApiModelProperty(value = "회원 고유 id", example = "1")
+ @NotNull(message = "멤버 고유 아이디는 필수 입니다.")
+ Long memberId,
+
+ @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622")
+ @NotBlank(message = "로그인 아이디는 필수 입니다.")
+ String loginId
+) {
+}
diff --git a/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java
new file mode 100644
index 00000000..e685ace9
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java
@@ -0,0 +1,15 @@
+package org.scoula.domain.member.dto.request;
+
+import javax.validation.constraints.NotBlank;
+
+import io.swagger.annotations.ApiModelProperty;
+
+public record SignInRequestDto(
+ @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622")
+ @NotBlank(message = "아이디는 필수 입력값입니다.")
+ String loginId,
+ @ApiModelProperty(value = "회원 비밀번호", example = "password")
+ @NotBlank(message = "비밀번호는 필수 입력값입니다.")
+ String password
+) {
+}
diff --git a/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java b/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java
new file mode 100644
index 00000000..fda29e9f
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java
@@ -0,0 +1,6 @@
+package org.scoula.domain.member.dto.response;
+
+public record RefreshResponseDto(
+ String accessToken
+) {
+}
diff --git a/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java
new file mode 100644
index 00000000..19343be6
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java
@@ -0,0 +1,38 @@
+package org.scoula.domain.member.dto.response;
+
+import java.time.LocalDate;
+
+import org.scoula.domain.member.dto.MemberDTO;
+import org.scoula.global.constants.Currency;
+import org.scoula.global.constants.NationalityCode;
+import org.scoula.global.security.dto.JwtToken;
+
+import io.swagger.annotations.ApiModelProperty;
+
+public record SignInResponseDto(
+ String grantType,
+ String accessToken,
+ @ApiModelProperty(value = "회원 ID", example = "1")
+ Long memberId,
+ @ApiModelProperty(value = "여권번호", example = "M12345678")
+ String passportNumber,
+ @ApiModelProperty(value = "국적", example = "KOR")
+ NationalityCode nationality,
+ @ApiModelProperty(value = "생년월일")
+ LocalDate birth,
+ @ApiModelProperty(value = "성명", example = "홍길동", required = true)
+ String name,
+ @ApiModelProperty(value = "전화번호", example = "010-1234-5678")
+ String phoneNumber,
+ @ApiModelProperty(value = "잔여 체류기간")
+ LocalDate remainTime,
+ @ApiModelProperty(value = "설정 통화", example = "USD")
+ Currency currency) {
+ public static SignInResponseDto from(JwtToken token, MemberDTO member) {
+
+ return new SignInResponseDto(token.getGrantType(),
+ token.getAccessToken(),
+ member.getMemberId(), member.getPassportNumber(), member.getNationality(), member.getBirth(),
+ member.getName(), member.getPhoneNumber(), member.getRemainTime(), member.getCurrency());
+ }
+}
diff --git a/src/main/java/org/scoula/domain/member/entity/Member.java b/src/main/java/org/scoula/domain/member/entity/Member.java
index de5372dc..84bfa24e 100644
--- a/src/main/java/org/scoula/domain/member/entity/Member.java
+++ b/src/main/java/org/scoula/domain/member/entity/Member.java
@@ -2,7 +2,8 @@
import java.time.LocalDate;
-import org.apache.xerces.impl.XMLEntityManager;
+import org.scoula.global.constants.Currency;
+import org.scoula.global.constants.NationalityCode;
import org.scoula.global.entity.BaseEntity;
import lombok.AllArgsConstructor;
@@ -22,11 +23,12 @@ public class Member extends BaseEntity {
private String loginId; // 로그인 아이디
private String password; // 비밀번호
private String passportNumber; // 여권번호
- private String nationality; // 국적
+ private NationalityCode nationality; // 국적
+ private String country; // 나라
private LocalDate birth; // 생년월일
private String name; // 성명
private String phoneNumber; // 전화번호
private LocalDate remainTime; // 잔여 체류기간
- private String currency; // 설정 통화
+ private Currency currency; // 설정 통화
}
diff --git a/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java b/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java
index 3b7f6a28..24b84e13 100644
--- a/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java
+++ b/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java
@@ -12,7 +12,9 @@ public enum MemberErrorCode implements ErrorCode {
// 400
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M-001", "존재하지 않는 회원입니다."),
- MEMBER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다.");
+ MEMBER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다."),
+ LOGIN_ID_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-003", "이미 존재하는 회원 아이디 입니다."),
+ STAY_EXPIRATION(HttpStatus.BAD_REQUEST, "M-004", "체류 기간이 만료되었습니다. 가입이 불가능합니다.");
private final HttpStatus httpStatus;
private final String code;
diff --git a/src/main/java/org/scoula/domain/member/service/JoinService.java b/src/main/java/org/scoula/domain/member/service/JoinService.java
new file mode 100644
index 00000000..76b01714
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/service/JoinService.java
@@ -0,0 +1,14 @@
+package org.scoula.domain.member.service;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.member.dto.request.JoinRequest;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+public interface JoinService {
+
+ void joinMember(JoinRequest joinRequest, HttpServletRequest request) throws JsonProcessingException;
+
+ void checkLoginId(String loginId);
+}
diff --git a/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java
new file mode 100644
index 00000000..69789a51
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java
@@ -0,0 +1,95 @@
+package org.scoula.domain.member.service;
+
+import static org.scoula.domain.member.exception.MemberErrorCode.*;
+import static org.scoula.global.constants.Currency.*;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.scoula.domain.codef.dto.request.StayExpirationRequest;
+import org.scoula.domain.codef.dto.response.StayExpirationResponse;
+import org.scoula.domain.codef.service.CodefApiClient;
+import org.scoula.domain.member.dto.request.JoinRequest;
+import org.scoula.domain.member.entity.Member;
+import org.scoula.domain.member.mapper.MemberMapper;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+@Service
+public class JoinServiceImpl implements JoinService {
+
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private final MemberMapper memberMapper;
+ private final CodefApiClient codefApiClient;
+ private final PasswordEncoder passwordEncoder;
+
+ @Transactional
+ @Override
+ public void joinMember(JoinRequest joinRequest, HttpServletRequest request) throws JsonProcessingException {
+ checkLoginId(joinRequest.loginId());
+
+ StayExpirationRequest stayExpirationRequest = StayExpirationRequest.builder()
+ .organization("0001") // 고정값 0001
+ .birthDate(joinRequest.birth())
+ .passportNo(joinRequest.passportNumber())
+ .nationality(joinRequest.nationality().getCode())
+ .country(joinRequest.country())
+ .build();
+ LocalDate stayExpiration = getStayExpiration(stayExpirationRequest, request);
+
+ Member member = Member.builder()
+ .loginId(joinRequest.loginId())
+ .password(encodePassword(joinRequest.password()))
+ .passportNumber(joinRequest.passportNumber())
+ .name(joinRequest.name())
+ .birth(LocalDate.parse(joinRequest.birth(), DATE_TIME_FORMATTER))
+ .nationality(joinRequest.nationality())
+ .phoneNumber(joinRequest.phoneNumber())
+ .currency(USD) // default USD
+ .remainTime(stayExpiration)
+ .build();
+
+ memberMapper.insertMember(member);
+ }
+
+ @Override
+ public void checkLoginId(String loginId) {
+ if (checkLoginIdDuplicate(loginId)) {
+ throw new CustomException(LOGIN_ID_ALREADY_EXISTS, LogLevel.INFO, null, Common.builder().build());
+ }
+ }
+
+ private LocalDate getStayExpiration(StayExpirationRequest stayExpirationRequest, HttpServletRequest request) throws
+ JsonProcessingException {
+ StayExpirationResponse stayExpirationResponse = codefApiClient.getStayExpiration(stayExpirationRequest,
+ request);
+ String expirationDate = stayExpirationResponse.resExpirationDate();
+
+ if (stayExpirationResponse.resAuthenticity().equals("0") && expirationDate == null) {
+ // throw new CustomException(STAY_EXPIRATION, LogLevel.WARNING, null, null);
+ return null;
+ }
+ return LocalDate.parse(expirationDate, DATE_TIME_FORMATTER);
+ }
+
+ private boolean checkLoginIdDuplicate(String loginId) {
+ return memberMapper.checkLoginIdDuplicate(loginId) > 0;
+ }
+
+ private String encodePassword(String password) {
+ return passwordEncoder.encode(password);
+ }
+
+}
diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthService.java b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java
new file mode 100644
index 00000000..02b064d2
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java
@@ -0,0 +1,19 @@
+package org.scoula.domain.member.service;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.scoula.domain.member.dto.request.ReissueTokenRequestDto;
+import org.scoula.domain.member.dto.request.SignInRequestDto;
+import org.scoula.domain.member.dto.response.RefreshResponseDto;
+import org.scoula.domain.member.dto.response.SignInResponseDto;
+
+public interface MemberAuthService {
+ SignInResponseDto signIn(SignInRequestDto signInRequestDto, HttpServletRequest request,
+ HttpServletResponse response);
+
+ void signOut(HttpServletResponse response);
+
+ public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken,
+ HttpServletRequest request, HttpServletResponse response);
+}
diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java
new file mode 100644
index 00000000..d2ae2ea8
--- /dev/null
+++ b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java
@@ -0,0 +1,86 @@
+package org.scoula.domain.member.service;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.scoula.domain.member.dto.MemberDTO;
+import org.scoula.domain.member.dto.request.ReissueTokenRequestDto;
+import org.scoula.domain.member.dto.request.SignInRequestDto;
+import org.scoula.domain.member.dto.response.RefreshResponseDto;
+import org.scoula.domain.member.dto.response.SignInResponseDto;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.redis.util.RedisUtil;
+import org.scoula.global.security.dto.JwtToken;
+import org.scoula.global.security.exception.JwtErrorMessage;
+import org.scoula.global.security.util.CookieUtil;
+import org.scoula.global.security.util.JwtTokenProvider;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class MemberAuthServiceImpl implements MemberAuthService {
+
+ private final MemberService memberService;
+ private final AuthenticationManagerBuilder authenticationManagerBuilder;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final RedisUtil redisUtil;
+
+ @Override
+ public SignInResponseDto signIn(SignInRequestDto signInRequestDto, HttpServletRequest request,
+ HttpServletResponse response) {
+
+ UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
+ signInRequestDto.loginId(),
+ signInRequestDto.password());
+
+ authenticationManagerBuilder.getObject().authenticate(authenticationToken);
+
+ // authenticate 메서드 내부에서 이미 존재하는지 검증이 되기 때문에 한 번 더 검증을 해주지 않았음
+ // authenticate 에서 조회한 멤버 정보를 가져올 방법을 생각해 쿼리를 한 번 줄이면 좋을 듯
+ MemberDTO member = memberService.getMemberByLoginId(signInRequestDto.loginId());
+
+ JwtToken jwtToken = jwtTokenProvider.generateToken(member.getMemberId(), member.getLoginId());
+ CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken());
+
+ return SignInResponseDto.from(jwtToken, member);
+
+ }
+
+ @Override
+ public void signOut(HttpServletResponse response) {
+ CookieUtil.deleteCookie(response, "refreshToken");
+ }
+
+ @Override
+ public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken,
+ HttpServletRequest request, HttpServletResponse response) {
+ String refreshTokenByRedis = redisUtil.get(reissueTokenRequestDto.loginId());
+ log.info("레디스 저장 토큰: {}", refreshTokenByRedis);
+ log.info("사용자 토큰: {}", refreshToken);
+ if (!refreshTokenByRedis.equals(refreshToken)) {
+ throw new CustomException(JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN, LogLevel.WARNING,
+ request.getHeader("txId"), Common.builder()
+ .srcIp(request.getRemoteAddr())
+ .callApiPath(request.getRequestURI())
+ .deviceInfo(request.getHeader("user-agent"))
+ .retryCount(0)
+ .build());
+ }
+
+ JwtToken jwtToken = jwtTokenProvider.generateToken(reissueTokenRequestDto.memberId(),
+ reissueTokenRequestDto.loginId());
+ CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken());
+ return new RefreshResponseDto(jwtToken.getAccessToken());
+ }
+
+}
diff --git a/src/main/java/org/scoula/domain/member/service/MemberService.java b/src/main/java/org/scoula/domain/member/service/MemberService.java
index f2df1969..4dde2ab1 100644
--- a/src/main/java/org/scoula/domain/member/service/MemberService.java
+++ b/src/main/java/org/scoula/domain/member/service/MemberService.java
@@ -1,9 +1,9 @@
package org.scoula.domain.member.service;
-import org.scoula.domain.member.dto.MemberDTO;
-
import java.util.List;
+import org.scoula.domain.member.dto.MemberDTO;
+
public interface MemberService {
// 회원 전체 조회
@@ -15,14 +15,9 @@ public interface MemberService {
// 로그인 ID로 회원 조회
MemberDTO getMemberByLoginId(String loginId);
- // 회원 등록
- MemberDTO createMember(MemberDTO memberDTO);
-
MemberDTO updateMember(Long memberId, MemberDTO memberDTO);
+
// 회원 삭제
void deleteMember(Long memberId);
- // 로그인 ID 중복 체크
- boolean checkLoginIdDuplicate(String loginId);
-
}
diff --git a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java
index 1e0a280e..90d25802 100644
--- a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java
+++ b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java
@@ -1,19 +1,22 @@
package org.scoula.domain.member.service;
import static org.scoula.domain.member.exception.MemberErrorCode.*;
-import static org.scoula.global.exception.errorCode.CommonErrorCode.*;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import java.util.List;
+import java.util.stream.Collectors;
+
import org.scoula.domain.member.dto.MemberDTO;
import org.scoula.domain.member.entity.Member;
import org.scoula.domain.member.mapper.MemberMapper;
import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.util.List;
-import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
@Service
@RequiredArgsConstructor
@@ -21,91 +24,69 @@
@Transactional(readOnly = true)
public class MemberServiceImpl implements MemberService {
- private final MemberMapper memberMapper;
-
- @Override
- public List getAllMembers() {
- log.info("getAllMembers");
- return memberMapper.selectAllMembers().stream()
- .map(MemberDTO::from)
- .collect(Collectors.toList());
- }
-
- @Override
- public MemberDTO getMemberById(Long memberId) {
- log.info("getMemberById: {}", memberId);
- Member member = memberMapper.selectMemberById(memberId);
-
- if (member == null) {
- throw new CustomException(MEMBER_NOT_FOUND);
- }
- return MemberDTO.from(member);
- }
-
- @Override
- public MemberDTO getMemberByLoginId(String loginId) {
- log.info("getMemberByLoginId: {}", loginId);
- Member member = memberMapper.selectMemberByLoginId(loginId);
- if (member == null) {
- throw new CustomException(MEMBER_NOT_FOUND);
- }
- return MemberDTO.from(member);
- }
-
- @Override
- @Transactional
- public MemberDTO createMember(MemberDTO memberDTO) {
- log.info("createMember: {}", memberDTO.getLoginId());
-
- // 로그인 ID 중복 체크
- if (checkLoginIdDuplicate(memberDTO.getLoginId())) {
- throw new CustomException(MEMBER_ALREADY_EXISTS);
- }
-
- // 임시로 비밀번호를 그대로 저장 (나중에 암호화 추가)
- Member member = memberDTO.toEntity();
- memberMapper.insertMember(member);
-
- return MemberDTO.from(member);
- }
- @Override
- @Transactional
- public MemberDTO updateMember(Long memberId, MemberDTO memberDTO) {
- log.info("updateMember: {}", memberId);
-
- // 기존 회원 정보 조회
- Member existingMember = memberMapper.selectMemberById(memberId);
- if (existingMember == null) {
- throw new CustomException(MEMBER_NOT_FOUND);
- }
-
- // 수정할 정보 설정
- memberDTO.changePassword(existingMember.getPassword());
-
- Member member = memberDTO.toEntity();
- memberMapper.updateMember(member);
-
- return MemberDTO.from(member);
- }
-
-
- @Override
- @Transactional
- public void deleteMember(Long memberId) {
- log.info("deleteMember: {}", memberId);
-
- Member member = memberMapper.selectMemberById(memberId);
- if (member == null) {
- throw new CustomException(MEMBER_NOT_FOUND);
- }
-
- memberMapper.deleteMember(memberId);
- }
-
- @Override
- public boolean checkLoginIdDuplicate(String loginId) {
- log.info("checkLoginIdDuplicate: {}", loginId);
- return memberMapper.checkLoginIdDuplicate(loginId) > 0;
- }
+ private final MemberMapper memberMapper;
+ private final PasswordEncoder passwordEncoder;
+
+ @Override
+ public List getAllMembers() {
+ log.info("getAllMembers");
+ return memberMapper.selectAllMembers().stream()
+ .map(MemberDTO::from)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public MemberDTO getMemberById(Long memberId) {
+ log.info("getMemberById: {}", memberId);
+ Member member = memberMapper.selectMemberById(memberId);
+
+ if (member == null) {
+ throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build());
+ }
+ return MemberDTO.from(member);
+ }
+
+ @Override
+ public MemberDTO getMemberByLoginId(String loginId) {
+ log.info("getMemberByLoginId: {}", loginId);
+ Member member = memberMapper.selectMemberByLoginId(loginId);
+ if (member == null) {
+ throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build());
+ }
+ return MemberDTO.from(member);
+ }
+
+ @Override
+ @Transactional
+ public MemberDTO updateMember(Long memberId, MemberDTO memberDTO) {
+ log.info("updateMember: {}", memberId);
+
+ // 기존 회원 정보 조회
+ Member existingMember = memberMapper.selectMemberById(memberId);
+ if (existingMember == null) {
+ throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build());
+ }
+
+ // 수정할 정보 설정
+ memberDTO.changePassword(existingMember.getPassword());
+
+ Member member = memberDTO.toEntity();
+ memberMapper.updateMember(member);
+
+ return MemberDTO.from(member);
+ }
+
+ @Override
+ @Transactional
+ public void deleteMember(Long memberId) {
+ log.info("deleteMember: {}", memberId);
+
+ Member member = memberMapper.selectMemberById(memberId);
+ if (member == null) {
+ throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build());
+ }
+
+ memberMapper.deleteMember(memberId);
+ }
}
diff --git a/src/main/java/org/scoula/global/common/config/AppConfig.java b/src/main/java/org/scoula/global/common/config/AppConfig.java
new file mode 100644
index 00000000..347415c3
--- /dev/null
+++ b/src/main/java/org/scoula/global/common/config/AppConfig.java
@@ -0,0 +1,26 @@
+package org.scoula.global.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.web.client.RestTemplate;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+@Configuration
+@PropertySource("classpath:application.properties")
+@ComponentScan(basePackages = "org.scoula")
+public class AppConfig {
+
+ @Bean
+ public RestTemplate restTemplate() {
+ return new RestTemplate();
+ }
+
+ @Bean
+ public ObjectMapper objectMapper() {
+ return new ObjectMapper();
+ }
+}
+
diff --git a/src/main/java/org/scoula/global/common/config/RootConfig.java b/src/main/java/org/scoula/global/common/config/RootConfig.java
index 1547dff2..f9a57cb4 100644
--- a/src/main/java/org/scoula/global/common/config/RootConfig.java
+++ b/src/main/java/org/scoula/global/common/config/RootConfig.java
@@ -1,9 +1,7 @@
package org.scoula.global.common.config;
-import java.util.Map;
+import javax.sql.DataSource;
-import com.zaxxer.hikari.HikariConfig;
-import com.zaxxer.hikari.HikariDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.flywaydb.core.Flyway;
import org.mybatis.spring.SqlSessionFactoryBean;
@@ -16,77 +14,103 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
-import javax.sql.DataSource;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+/**
+ * RootConfig: 비즈니스 로직 (Service, Mapper, DataSource)
+ * 와일드카드 패턴 사용 (**)
+ *
+ * org.scoula.domain.**.mapper - 모든 도메인의 mapper 자동 스캔
+ * org.scoula.domain.**.service - 모든 도메인의 service 자동 스캔
+ * org.scoula.domain.**.controller - 모든 도메인의 controller 자동 스캔
+ */
@Configuration
@PropertySource({"classpath:/application.properties"})
-@MapperScan(basePackages = {"org.scoula.domain.member.mapper"}) // 게시판과 회원 도메인 매퍼 스캔
+@MapperScan(basePackages = {"org.scoula.domain.**.mapper"}) // 게시판과 회원 도메인 매퍼 스캔
@ComponentScan(basePackages = {
- // "org.scoula.service",
- "org.scoula.domain.member.service", // 도메인 객체를 포함하기 위해 추가
- "org.scoula.global.swagger.config" // Swagger 설정을 포함하기 위해 추가
+ // "org.scoula.service",
+ "org.scoula.domain.**.service", // 도메인 객체를 포함하기 위해 추가
+ "org.scoula.global.swagger.config", // Swagger 설정을 포함하기 위해 추가
+ "org.scoula.global.kafka", // kafka 설정 포함
+ "org.scoula.global.exception", // exception handler 등록
+ "org.scoula.global.redis", // 공통 설정 포함
+ "org.scoula.global.security", // security 설정 포함
})
public class RootConfig {
- @Value("${jdbc.driver}") String driver;
- @Value("${jdbc.url}") String url;
- @Value("${jdbc.username}") String username;
- @Value("${jdbc.password}") String password;
-
- @Bean
- public DataSource dataSource() {
- HikariConfig config = new HikariConfig();
- config.setDriverClassName(driver);
- config.setJdbcUrl(url);
- config.setUsername(username);
- config.setPassword(password);
- config.setMaximumPoolSize(50);
- config.setMinimumIdle(5);
- config.setConnectionTimeout(30000);
- config.setIdleTimeout(600000);
- config.setMaxLifetime(1800000);
- HikariDataSource dataSource = new HikariDataSource(config);
- return dataSource;
- }
-
+ @Value("${jdbc.driver}")
+ String driver;
+ @Value("${jdbc.url}")
+ String url;
+ @Value("${jdbc.username}")
+ String username;
+ @Value("${jdbc.password}")
+ String password;
- @Autowired
- ApplicationContext applicationContext;
+ @Bean
+ public DataSource dataSource() {
+ HikariConfig config = new HikariConfig();
+ config.setDriverClassName(driver);
+ config.setJdbcUrl(url);
+ config.setUsername(username);
+ config.setPassword(password);
+ config.setMaximumPoolSize(50);
+ config.setMinimumIdle(5);
+ config.setConnectionTimeout(30000);
+ config.setIdleTimeout(600000);
+ config.setMaxLifetime(1800000);
+ HikariDataSource dataSource = new HikariDataSource(config);
+ return dataSource;
+ }
- @Bean
- public Flyway flyway(DataSource dataSource) {
- Flyway flyway = Flyway.configure()
- .dataSource(dataSource)
- .locations("classpath:db/migration")
- .baselineVersion("0.0") // 초기 마이그레이션 버전 설정
- .baselineOnMigrate(true)
- .encoding("UTF-8")
- .cleanDisabled(true) // 안전을 위해 clean 비활성화
- .validateMigrationNaming(true)
- .load();
- // Flyway 설정
+ @Autowired
+ ApplicationContext applicationContext;
+ @Bean
+ public Flyway flyway(DataSource dataSource) {
+ Flyway flyway = Flyway.configure()
+ .dataSource(dataSource)
+ .locations("classpath:db/migration")
+ .baselineVersion("0.0") // 초기 마이그레이션 버전 설정
+ .baselineOnMigrate(true)
+ .encoding("UTF-8")
+ .cleanDisabled(true) // 안전을 위해 clean 비활성화
+ .validateMigrationNaming(true)
+ .load();
+ // Flyway 설정
- // 수동으로 migrate 호출
- flyway.migrate();
+ // 수동으로 migrate 호출
+ flyway.migrate();
- return flyway;
- }
+ return flyway;
+ }
- @Bean
- public SqlSessionFactory sqlSessionFactory() throws Exception {
- SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
- sqlSessionFactory.setConfigLocation(
- applicationContext.getResource("classpath:/mybatis-config.xml")
- );
- sqlSessionFactory.setDataSource(dataSource());
- return (SqlSessionFactory) sqlSessionFactory.getObject();
- }
+ @Bean
+ public SqlSessionFactory sqlSessionFactory() throws Exception {
+ SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
+ sqlSessionFactory.setConfigLocation(
+ applicationContext.getResource("classpath:/mybatis-config.xml")
+ );
+ sqlSessionFactory.setDataSource(dataSource());
+ return sqlSessionFactory.getObject();
+ }
- @Bean
- public DataSourceTransactionManager transactionManager() {
- return new DataSourceTransactionManager(dataSource());
- }
+ @Bean
+ public DataSourceTransactionManager transactionManager() {
+ return new DataSourceTransactionManager(dataSource());
+ }
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+ @Bean
+ public HandlerMappingIntrospector mvcHandlerMappingIntrospector() {
+ return new HandlerMappingIntrospector();
+ }
}
diff --git a/src/main/java/org/scoula/global/common/config/ServletConfig.java b/src/main/java/org/scoula/global/common/config/ServletConfig.java
index 097e125d..4850dbdb 100644
--- a/src/main/java/org/scoula/global/common/config/ServletConfig.java
+++ b/src/main/java/org/scoula/global/common/config/ServletConfig.java
@@ -11,38 +11,42 @@
@Configuration
@EnableWebMvc
-@ComponentScan(basePackages = {"org.scoula.domain.controller"})
+@ComponentScan(basePackages = {
+ "org.scoula.domain.controller",
+ "org.scoula.domain.**.controller",
+ // "org.scoula.domain.codef.controller",
+})
public class ServletConfig implements WebMvcConfigurer {
- @Override
- public void addResourceHandlers(ResourceHandlerRegistry registry) {
- registry
- .addResourceHandler("/resources/**") // url이 /resources/로 시작하는 모든 경로
- .addResourceLocations("/resources/"); // webapp/resources/경로로 매핑
-
- // Swagger UI 리소스를 위한 핸들러 설정
- registry.addResourceHandler("/swagger-ui.html")
- .addResourceLocations("classpath:/META-INF/resources/");
-
- // Swagger WebJar 리소스 설정
- registry.addResourceHandler("/webjars/**")
- .addResourceLocations("classpath:/META-INF/resources/webjars/");
-
- // Swagger 리소스 설정
- registry.addResourceHandler("/swagger-resources/**")
- .addResourceLocations("classpath:/META-INF/resources/");
-
- registry.addResourceHandler("/v2/api-docs")
- .addResourceLocations("classpath:/META-INF/resources/");
- }
-
- // jsp view resolver 설정
- @Override
- public void configureViewResolvers(ViewResolverRegistry registry) {
- InternalResourceViewResolver bean = new InternalResourceViewResolver();
- bean.setViewClass(JstlView.class);
- bean.setPrefix("/WEB-INF/views/");
- bean.setSuffix(".jsp");
- registry.viewResolver(bean);
- }
-}
\ No newline at end of file
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ registry
+ .addResourceHandler("/resources/**") // url이 /resources/로 시작하는 모든 경로
+ .addResourceLocations("/resources/"); // webapp/resources/경로로 매핑
+
+ // Swagger UI 리소스를 위한 핸들러 설정
+ registry.addResourceHandler("/swagger-ui.html")
+ .addResourceLocations("classpath:/META-INF/resources/");
+
+ // Swagger WebJar 리소스 설정
+ registry.addResourceHandler("/webjars/**")
+ .addResourceLocations("classpath:/META-INF/resources/webjars/");
+
+ // Swagger 리소스 설정
+ registry.addResourceHandler("/swagger-resources/**")
+ .addResourceLocations("classpath:/META-INF/resources/");
+
+ registry.addResourceHandler("/v2/api-docs")
+ .addResourceLocations("classpath:/META-INF/resources/");
+ }
+
+ // jsp view resolver 설정
+ @Override
+ public void configureViewResolvers(ViewResolverRegistry registry) {
+ InternalResourceViewResolver bean = new InternalResourceViewResolver();
+ bean.setViewClass(JstlView.class);
+ bean.setPrefix("/WEB-INF/views/");
+ bean.setSuffix(".jsp");
+ registry.viewResolver(bean);
+ }
+}
diff --git a/src/main/java/org/scoula/global/common/config/WebConfig.java b/src/main/java/org/scoula/global/common/config/WebConfig.java
index 6296f660..afaab018 100644
--- a/src/main/java/org/scoula/global/common/config/WebConfig.java
+++ b/src/main/java/org/scoula/global/common/config/WebConfig.java
@@ -1,36 +1,45 @@
package org.scoula.global.common.config;
+import javax.servlet.Filter;
+
import org.scoula.global.swagger.config.SwaggerConfig;
+import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;
+import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
-import javax.servlet.Filter;
-
public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class>[] getRootConfigClasses() {
- return new Class[] { RootConfig.class };
+ return new Class[] {RootConfig.class, AppConfig.class};
}
- @Override
- protected Class>[] getServletConfigClasses() {
- return new Class[] { ServletConfig.class, SwaggerConfig.class };
- }
+ @Override
+ protected Class>[] getServletConfigClasses() {
+ return new Class[] {ServletConfig.class, SwaggerConfig.class};
+ }
- // 스프링의 FrontController인 DispatcherServlet이 담당할 Url 매핑 패턴, / : 모든 요청에 대해 매핑
- @Override
- protected String[] getServletMappings() {
- return new String[]{
- "/"
- };
- }
+ // 스프링의 FrontController인 DispatcherServlet이 담당할 Url 매핑 패턴, / : 모든 요청에 대해 매핑
+ @Override
+ protected String[] getServletMappings() {
+ return new String[] {
+ "/"
+ };
+ }
- @Override
- protected Filter[] getServletFilters() {
- CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
- characterEncodingFilter.setEncoding("UTF-8");
- characterEncodingFilter.setForceEncoding(true);
- return new Filter[] { characterEncodingFilter };
- }
-}
\ No newline at end of file
+ @Override
+ protected Filter[] getServletFilters() {
+ CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
+ characterEncodingFilter.setEncoding("UTF-8");
+ characterEncodingFilter.setForceEncoding(true);
+ return new Filter[] {characterEncodingFilter};
+ }
+
+ @Override
+ protected DispatcherServlet createDispatcherServlet(WebApplicationContext servletAppContext) {
+ DispatcherServlet dispatcherServlet = new DispatcherServlet(servletAppContext);
+ dispatcherServlet.setThrowExceptionIfNoHandlerFound(true);
+ return dispatcherServlet;
+ }
+}
diff --git a/src/main/java/org/scoula/global/constants/Currency.java b/src/main/java/org/scoula/global/constants/Currency.java
new file mode 100644
index 00000000..3d8d4fc3
--- /dev/null
+++ b/src/main/java/org/scoula/global/constants/Currency.java
@@ -0,0 +1,27 @@
+package org.scoula.global.constants;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum Currency {
+ USD("미국", "달러"),
+ JPY("일본", "엔"),
+ EUR("유럽연합", "유로"),
+ GBP("영국", "파운드"),
+ CAD("캐나다", "달러"),
+ KRW("한국", "원"),
+ HKD("홍콩", "달러"),
+ CNY("중국", "위안"),
+ THB("태국", "바트"),
+ IDR("인도네시아", "루피아"),
+ VND("베트남", "동"),
+ RUB("러시아", "루블"),
+ BDT("방글라데시", "타카"),
+ ;
+
+ private final String country;
+ private final String unit;
+
+}
diff --git a/src/main/java/org/scoula/global/constants/NationalityCode.java b/src/main/java/org/scoula/global/constants/NationalityCode.java
new file mode 100644
index 00000000..f5b69355
--- /dev/null
+++ b/src/main/java/org/scoula/global/constants/NationalityCode.java
@@ -0,0 +1,36 @@
+package org.scoula.global.constants;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public enum NationalityCode {
+ RUSSIA("0", "러시아"),
+ MONGOLIA("1", "몽골"),
+ USA("2", "미국"),
+ VIETNAM("3", "베트남"),
+ INDIA("4", "인도"),
+ INDONESIA("5", "인도네시아"),
+ JAPAN("6", "일본"),
+ CHINA("7", "중국"),
+ THAILAND("8", "타이"),
+ PHILIPPINES("9", "필리핀"),
+ KOREAN_RUSSIAN("10", "한국계 러시아인"),
+ KOREAN_CHINESE("11", "한국계 중국인"),
+ OTHER("99", "기타");
+
+ private static final Map BY_CODE =
+ Arrays.stream(values()).collect(Collectors.toMap(NationalityCode::getCode, Function.identity()));
+ private final String code;
+ private final String description;
+
+ public static NationalityCode fromCode(String code) {
+ return BY_CODE.getOrDefault(code, OTHER);
+ }
+}
diff --git a/src/main/java/org/scoula/global/exception/CustomException.java b/src/main/java/org/scoula/global/exception/CustomException.java
index b065fa5e..891e19e9 100644
--- a/src/main/java/org/scoula/global/exception/CustomException.java
+++ b/src/main/java/org/scoula/global/exception/CustomException.java
@@ -1,25 +1,39 @@
package org.scoula.global.exception;
import org.scoula.global.exception.errorCode.ErrorCode;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
import lombok.Getter;
@Getter
-public class CustomException extends RuntimeException{
+public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
+ private final LogLevel logLevel;
+ private final String txId;
+ private final Common common;
- protected CustomException(ErrorCode errorCode, String message) {
+ protected CustomException(ErrorCode errorCode, String message, LogLevel logLevel, String txId, Common common) {
super(errorCode.getMessage() + ": " + message);
this.errorCode = errorCode;
+ this.logLevel = logLevel;
+ this.txId = txId;
+ this.common = common;
}
- public CustomException(ErrorCode errorCode) {
+ public CustomException(ErrorCode errorCode, LogLevel logLevel, String txId, Common common) {
super(errorCode.getMessage());
this.errorCode = errorCode;
+ this.logLevel = logLevel;
+ this.txId = txId;
+ this.common = common;
}
- public CustomException(ErrorCode errorCode, Object... args) {
+ public CustomException(ErrorCode errorCode, LogLevel logLevel, String txId, Common common, Object... args) {
super(String.format(errorCode.getMessage(), args));
this.errorCode = errorCode;
+ this.logLevel = logLevel;
+ this.txId = txId;
+ this.common = common;
}
}
diff --git a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java
index fccb810d..b89bfd6b 100644
--- a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java
+++ b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java
@@ -5,6 +5,10 @@
import org.scoula.global.exception.errorCode.CommonErrorCode;
import org.scoula.global.exception.errorCode.ErrorCode;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.kafka.producer.KafkaProducer;
+import org.scoula.global.kafka.util.LogMessageMapper;
import org.scoula.global.response.ErrorResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
@@ -13,16 +17,33 @@
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
@RestControllerAdvice
+@RequiredArgsConstructor
+@Slf4j
public class GlobalExceptionHandler {
+ private final KafkaProducer kafkaProducer;
+
/**
* 커스텀 예외
*/
@ExceptionHandler(value = CustomException.class)
public ResponseEntity> handleCustomException(CustomException e) {
ErrorCode errorCode = e.getErrorCode();
+
+ kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage(
+ e.getLogLevel(),
+ e.getTxId(),
+ null,
+ e.getCommon(),
+ e.getMessage()
+ ));
+
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.error(errorCode.getCode(), e.getMessage()));
}
@@ -41,11 +62,19 @@ protected ResponseEntity>> handleMethodArgumen
}
ErrorCode errorCode = CommonErrorCode.INVALID_VALUE;
+
+ kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage(
+ LogLevel.WARNING,
+ null,
+ errorCode.getMessage(),
+ Common.builder().build(),
+ e.getMessage()
+ ));
+
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.error(errors, errorCode.getCode(), errorCode.getMessage()));
}
-
/**
* 필수 요청 파라미터 누락 예외
*/
@@ -53,7 +82,53 @@ protected ResponseEntity>> handleMethodArgumen
public ResponseEntity> handleMissingServletRequestParameterException(
MissingServletRequestParameterException e) {
ErrorCode errorCode = CommonErrorCode.MISSING_PARAMETER;
+
+ kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage(
+ LogLevel.WARNING,
+ null,
+ errorCode.getMessage(),
+ Common.builder().build(),
+ e.getMessage()
+ ));
+
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.error(errorCode.getCode(), errorCode.getMessage()));
}
+
+ @ExceptionHandler(NoHandlerFoundException.class)
+ public ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException e) {
+ ErrorCode errorCode = CommonErrorCode.NOT_FOUND;
+
+ kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage(
+ LogLevel.WARNING,
+ null,
+ errorCode.getMessage(),
+ Common.builder().build(),
+ e.getMessage()
+ ));
+
+ return ResponseEntity.status(errorCode.getHttpStatus())
+ .body(ErrorResponse.error(errorCode.getCode(), errorCode.getMessage()));
+ }
+
+ /**
+ * 예상하지 못한 모든 예외 처리
+ */
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleAllExceptions(Exception e) {
+ ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
+
+ kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage(
+ LogLevel.ERROR,
+ null,
+ errorCode.getMessage(),
+ Common.builder().build(),
+ e.getMessage()
+ ));
+
+ log.error(e.getMessage(), e);
+
+ return ResponseEntity.status(errorCode.getHttpStatus())
+ .body(ErrorResponse.error(errorCode.getCode(), String.valueOf(e)));
+ }
}
diff --git a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java
index 66d781a3..bc926180 100644
--- a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java
+++ b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java
@@ -1,8 +1,9 @@
package org.scoula.global.exception.errorCode;
+import org.springframework.http.HttpStatus;
+
import lombok.AllArgsConstructor;
import lombok.Getter;
-import org.springframework.http.HttpStatus;
@Getter
@AllArgsConstructor
@@ -11,8 +12,9 @@ public enum CommonErrorCode implements ErrorCode {
// 400
INVALID_VALUE(HttpStatus.BAD_REQUEST, "C-001", "입력값이 올바르지 않습니다."),
INVALID_ARGUMENT(HttpStatus.BAD_REQUEST, "C-002", "잘못된 인자입니다."),
- MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "C-005", "필수 파라미터가 누락되었습니다.")
- ;
+ MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "C-005", "필수 파라미터가 누락되었습니다."),
+ NOT_FOUND(HttpStatus.valueOf(404), "C-003", "존재하지 않는 경로입니다."),
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C-006", "내부 에러 메세지 사용 예정");
private final HttpStatus httpStatus;
private final String code;
diff --git a/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java b/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java
new file mode 100644
index 00000000..ebb960e7
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java
@@ -0,0 +1,36 @@
+package org.scoula.global.kafka.config;
+
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+@PropertySource("classpath:application.properties")
+public class KafkaProducerConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Bean
+ public ProducerFactory producerFactory() {
+ Map configProps = new HashMap<>();
+ configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ return new DefaultKafkaProducerFactory<>(configProps);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate() {
+ return new KafkaTemplate<>(producerFactory());
+ }
+}
diff --git a/src/main/java/org/scoula/global/kafka/dto/Common.java b/src/main/java/org/scoula/global/kafka/dto/Common.java
new file mode 100644
index 00000000..f47eddbf
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/dto/Common.java
@@ -0,0 +1,17 @@
+package org.scoula.global.kafka.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class Common {
+ private String callApiPath;
+ private String apiMethod;
+ private String srcIp;
+ private String deviceInfo;
+ private String memberId;
+ private String ledgerCode;
+ private String transactionGroupId;
+ private Integer retryCount;
+}
diff --git a/src/main/java/org/scoula/global/kafka/dto/LogLevel.java b/src/main/java/org/scoula/global/kafka/dto/LogLevel.java
new file mode 100644
index 00000000..b7a80274
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/dto/LogLevel.java
@@ -0,0 +1,5 @@
+package org.scoula.global.kafka.dto;
+
+public enum LogLevel {
+ ERROR, WARNING, INFO
+}
diff --git a/src/main/java/org/scoula/global/kafka/dto/LogMessage.java b/src/main/java/org/scoula/global/kafka/dto/LogMessage.java
new file mode 100644
index 00000000..aeff97c9
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/dto/LogMessage.java
@@ -0,0 +1,15 @@
+package org.scoula.global.kafka.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class LogMessage {
+ private String logLevel;
+ private String timestamp;
+ private String txId;
+ private String message;
+ private Common data;
+ private String errorMessage;
+}
diff --git a/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java
new file mode 100644
index 00000000..6c5bb60b
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java
@@ -0,0 +1,35 @@
+package org.scoula.global.kafka.producer;
+
+import org.scoula.global.kafka.dto.LogMessage;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Component
+@RequiredArgsConstructor
+@PropertySource("classpath:application.properties")
+@Slf4j
+public class KafkaProducer {
+
+ @Value("${spring.kafka.log-topic}")
+ private String logTopic;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private final KafkaTemplate kafkaTemplate;
+
+ public void sendToLogTopic(LogMessage logMessage) {
+ try {
+ String message = objectMapper.writeValueAsString(logMessage);
+ kafkaTemplate.send(logTopic, message);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java b/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java
new file mode 100644
index 00000000..6297c8d9
--- /dev/null
+++ b/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java
@@ -0,0 +1,80 @@
+package org.scoula.global.kafka.util;
+
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.kafka.dto.LogMessage;
+import org.springframework.stereotype.Component;
+
+@Component
+public class LogMessageMapper {
+
+ public static LogMessage buildLogMessage(
+ /* 로그 레벨 ERROR, WARNING, INFO 존재하며,
+ ERROR는 아주 심각한 에러로 바로 슬랙에 알림이 가는 심각한 에러,
+ WARNING은 비정상적인 시도 접근 등을 의미 1초간 10회 이상 로그가 찍힐 경우 바로 알림 발생,
+ INFO는 정보 전달을 위한 로그
+ */
+ LogLevel logLevel,
+ // 고유 트랜잭션 아이디를 의미 한 번에
+ // api call 내부에서 고유한 트랜잭션 아이디를 보유
+ // aop를 통해 api가 호출될 때 로그를 발생하고 tx id를 발급할 예정
+ // 이후 발급된 tx id를 이용해 계속 로그를 작성하면 됨
+ String txId,
+ String message,
+ // api 경로 의미, 마찬가지로 컨트롤러에서 받아서 로그 작성하면 됨
+ String apiPath,
+ // GET, POST, PATCH, DELETE 등등,,
+ String methodType,
+ // 사용자 src ip 의미 헤더에서 추출 가능
+ String srcIp,
+ // 사용자 디바이스 정보 헤더의 host-Agent를 통해 호출 가능
+ String deviceInfo,
+ // 거래가 이뤄질 경우 해당 멤버 아이디 기록
+ String memberId,
+ // 거래가 이뤄질 경우 해당 거래의 회계 코드 기록
+ String ledgerCode,
+ // 거래의 고유 tx id 기록
+ String transactionGroupId,
+ // 에러가 발생했을 경우 에러 메세지 기록
+ String errorMessage
+ ) {
+ Common common = Common.builder()
+ .callApiPath(apiPath)
+ .apiMethod(methodType)
+ .srcIp(srcIp)
+ .deviceInfo(deviceInfo)
+ .memberId(memberId)
+ .ledgerCode(ledgerCode)
+ .transactionGroupId(transactionGroupId)
+ .retryCount(0)
+ .build();
+
+ return LogMessage.builder()
+ .logLevel(logLevel.name())
+ .timestamp(String.valueOf(System.currentTimeMillis()))
+ .txId(txId)
+ .message(message)
+ .data(common)
+ .errorMessage(errorMessage)
+ .build();
+ }
+
+ public static LogMessage buildLogMessage(
+ LogLevel logLevel,
+ String txId,
+ String message,
+ Common common,
+ String errorMessage
+ ) {
+ common.setRetryCount(0);
+ return LogMessage.builder()
+ .logLevel(logLevel.name())
+ .timestamp(String.valueOf(System.currentTimeMillis()))
+ .txId(txId)
+ .message(message)
+ .data(common)
+ .errorMessage(errorMessage)
+ .build();
+ }
+}
+
diff --git a/src/main/java/org/scoula/global/redis/config/RedisConfig.java b/src/main/java/org/scoula/global/redis/config/RedisConfig.java
new file mode 100644
index 00000000..8546bfd1
--- /dev/null
+++ b/src/main/java/org/scoula/global/redis/config/RedisConfig.java
@@ -0,0 +1,63 @@
+package org.scoula.global.redis.config;
+
+import java.time.Duration;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import io.lettuce.core.resource.ClientResources;
+import io.lettuce.core.resource.DefaultClientResources;
+
+@Configuration
+@PropertySource("classpath:application.properties")
+public class RedisConfig {
+
+ @Value("${spring.redis.host}")
+ private String redisHost;
+
+ @Value("${spring.redis.port}")
+ private int redisPort;
+
+ @Value("${spring.redis.password}")
+ private String redisPassword;
+
+ @Bean(destroyMethod = "shutdown")
+ public ClientResources clientResources() {
+ return DefaultClientResources.create();
+ }
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory(ClientResources clientResources) {
+ RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
+ config.setHostName(redisHost);
+ config.setPort(redisPort);
+
+ LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
+ .clientResources(clientResources)
+ .commandTimeout(Duration.ofSeconds(10))
+ .shutdownTimeout(Duration.ZERO)
+ .useSsl() // tls 연결 활성화
+ .build();
+
+ return new LettuceConnectionFactory(config, clientConfig);
+ }
+
+ @Bean
+ public RedisTemplate redisTemplate() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory(clientResources()));
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new StringRedisSerializer());
+
+ return redisTemplate;
+ }
+}
+
diff --git a/src/main/java/org/scoula/global/redis/util/RedisUtil.java b/src/main/java/org/scoula/global/redis/util/RedisUtil.java
new file mode 100644
index 00000000..e4a26ef7
--- /dev/null
+++ b/src/main/java/org/scoula/global/redis/util/RedisUtil.java
@@ -0,0 +1,69 @@
+package org.scoula.global.redis.util;
+
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class RedisUtil {
+
+ private final RedisTemplate redisTemplate;
+
+ public void set(String key, String o, int minutes) {
+ redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
+ }
+
+ public String get(String key) {
+ return redisTemplate.opsForValue().get(key);
+ }
+
+ public boolean delete(String key) {
+ return Boolean.TRUE.equals(redisTemplate.delete(key));
+ }
+
+ public boolean hasKey(String key) {
+ return Boolean.TRUE.equals(redisTemplate.hasKey(key));
+ }
+
+ public Long checkExpired(String key) {
+ Long ttl = redisTemplate.getExpire(key);
+ return ttl;
+ }
+
+ public long countResumeKeysWithPrefix() {
+ ScanOptions options = ScanOptions.scanOptions()
+ .match("resume:temp:" + "*")
+ .count(1000) // 한 번에 검색할 키 수
+ .build();
+
+ long count = 0;
+ try (Cursor cursor = redisTemplate.getConnectionFactory()
+ .getConnection()
+ .scan(options)) {
+
+ while (cursor.hasNext()) {
+ cursor.next();
+ count++;
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Redis scan error", e);
+ }
+
+ return count;
+ }
+
+ // prefix 기반 키 삭제
+ public void deleteByPrefix(String prefix) {
+ var keys = redisTemplate.keys(prefix + "*");
+ if (keys != null && !keys.isEmpty()) {
+ redisTemplate.delete(keys);
+ }
+ }
+}
+
diff --git a/src/main/java/org/scoula/global/response/ApiResponse.java b/src/main/java/org/scoula/global/response/ApiResponse.java
deleted file mode 100644
index a22abd95..00000000
--- a/src/main/java/org/scoula/global/response/ApiResponse.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.scoula.global.response;
-
-import lombok.Getter;
-
-@Getter
-public class ApiResponse {
- private final T data;
- private final int status;
- private final String message;
-
- private ApiResponse(T data, int status, String message) {
- this.data = data;
- this.status = status;
- this.message = message;
- }
-
- public static ApiResponse ok(T data) {
- return new ApiResponse<>(data, 200, "OK");
- }
-
- public static ApiResponse created(T data) {
- return new ApiResponse<>(data, 201, "CREATED");
- }
-
- public static ApiResponse noContent() {
- return new ApiResponse<>(null, 204, "NO_CONTENT");
- }
-}
diff --git a/src/main/java/org/scoula/global/response/SuccessResponse.java b/src/main/java/org/scoula/global/response/SuccessResponse.java
new file mode 100644
index 00000000..be56ebf2
--- /dev/null
+++ b/src/main/java/org/scoula/global/response/SuccessResponse.java
@@ -0,0 +1,28 @@
+package org.scoula.global.response;
+
+import lombok.Getter;
+
+@Getter
+public class SuccessResponse {
+ private final T data;
+ private final int status;
+ private final String message;
+
+ private SuccessResponse(T data, int status, String message) {
+ this.data = data;
+ this.status = status;
+ this.message = message;
+ }
+
+ public static SuccessResponse ok(T data) {
+ return new SuccessResponse<>(data, 200, "OK");
+ }
+
+ public static SuccessResponse created(T data) {
+ return new SuccessResponse<>(data, 201, "CREATED");
+ }
+
+ public static SuccessResponse noContent() {
+ return new SuccessResponse<>(null, 204, "NO_CONTENT");
+ }
+}
diff --git a/src/main/java/org/scoula/global/security/config/SecurityConfig.java b/src/main/java/org/scoula/global/security/config/SecurityConfig.java
new file mode 100644
index 00000000..61f58151
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/config/SecurityConfig.java
@@ -0,0 +1,90 @@
+package org.scoula.global.security.config;
+
+import java.util.List;
+
+import org.scoula.global.security.exception.CustomAuthenticationEntryPoint;
+import org.scoula.global.security.filter.JwtAuthenticationFilter;
+import org.scoula.global.security.util.JwtTokenProvider;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+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.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
+ private final CustomAuthenticationEntryPoint customAuthenticationExceptionHandler;
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration corsConfiguration = new CorsConfiguration();
+
+ corsConfiguration.setAllowedOriginPatterns(
+ List.of("http://localhost:5173", "https://localhost:5173"));
+
+ corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"));
+ corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type"));
+ corsConfiguration.setExposedHeaders(List.of("Set-Cookie"));
+ corsConfiguration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", corsConfiguration);
+
+ return source;
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+
+ http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(
+ corsConfigurationSource()));
+
+ http.csrf(AbstractHttpConfigurer::disable);
+
+ http.sessionManagement((session) -> session
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
+
+ http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
+ authorizationManagerRequestMatcherRegistry
+
+ // 로그인 관련 api
+ .requestMatchers("/auth/**").permitAll()
+ // codef 관련 api
+ .requestMatchers("/codef/**").permitAll()
+ // 스웨거 사용을 위한 허용
+ .requestMatchers(
+ "/v2/api-docs/**",
+ "/swagger-resources/**",
+ "/swagger-ui.html/**",
+ "/swagger-ui/**",
+ "/webjars/**",
+ "/configuration/**"
+ ).permitAll()
+ .anyRequest().authenticated()
+ );
+
+ http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, customAuthenticationExceptionHandler),
+ UsernamePasswordAuthenticationFilter.class);
+
+ http.formLogin().disable();
+
+ http.exceptionHandling()
+ .authenticationEntryPoint(customAuthenticationEntryPoint);
+
+ return http.build();
+ }
+
+}
diff --git a/src/main/java/org/scoula/global/security/config/SecurityInitializer.java b/src/main/java/org/scoula/global/security/config/SecurityInitializer.java
new file mode 100644
index 00000000..635b127e
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/config/SecurityInitializer.java
@@ -0,0 +1,6 @@
+package org.scoula.global.security.config;
+
+import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;
+
+public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
+}
diff --git a/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java b/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java
new file mode 100644
index 00000000..e0fa74c6
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java
@@ -0,0 +1,58 @@
+package org.scoula.global.security.dto;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.scoula.domain.member.entity.Member;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+public class CustomUserDetails implements UserDetails {
+
+ private final Member member;
+
+ public CustomUserDetails(Member member) {
+ this.member = member;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ String roleName = "ROLE_" + "MEMBER";
+ return List.of(new SimpleGrantedAuthority(roleName));
+ }
+
+ @Override
+ public String getPassword() {
+ return member.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return member.getLoginId();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ public Member getMember() {
+ return member;
+ }
+}
diff --git a/src/main/java/org/scoula/global/security/dto/JwtToken.java b/src/main/java/org/scoula/global/security/dto/JwtToken.java
new file mode 100644
index 00000000..e46a0d41
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/dto/JwtToken.java
@@ -0,0 +1,14 @@
+package org.scoula.global.security.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+@Builder
+public class JwtToken {
+ private String grantType;
+ private String accessToken;
+ private String refreshToken;
+}
diff --git a/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java b/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java
new file mode 100644
index 00000000..f57a2f8d
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java
@@ -0,0 +1,66 @@
+package org.scoula.global.security.exception;
+
+import static org.scoula.global.security.exception.JwtErrorMessage.*;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.scoula.global.response.ErrorResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+@Component
+public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+
+ if (request.getAttribute("signOutException") != null) {
+ setResponse(response, SIGN_OUT_USER.getHttpStatus(), SIGN_OUT_USER.getCode(), SIGN_OUT_USER.getMessage());
+ } else if (request.getAttribute("cannotUseRefreshToken") != null) {
+ setResponse(response, CANNOT_USE_REFRESH_TOKEN.getHttpStatus(), CANNOT_USE_REFRESH_TOKEN.getCode(),
+ CANNOT_USE_REFRESH_TOKEN.getMessage());
+ } else if (request.getAttribute("invalidJwtToken") != null) {
+ setResponse(response, INVALID_JWT_TOKEN.getHttpStatus(), INVALID_JWT_TOKEN.getCode(),
+ INVALID_JWT_TOKEN.getMessage());
+ } else if (request.getAttribute("expiredJwtToken") != null) {
+ setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getHttpStatus(), NEED_ACCESS_TOKEN_REFRESH.getCode(),
+ NEED_ACCESS_TOKEN_REFRESH.getMessage());
+ } else if (request.getAttribute("notExistAccessToken") != null) {
+ setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getHttpStatus(), NEED_ACCESS_TOKEN_REFRESH.getCode(),
+ NEED_ACCESS_TOKEN_REFRESH.getMessage());
+ } else if (request.getAttribute("unsupportedJwtToken") != null) {
+ setResponse(response, UNSUPPORTED_JWT_TOKEN.getHttpStatus(), UNSUPPORTED_JWT_TOKEN.getCode(),
+ UNSUPPORTED_JWT_TOKEN.getMessage());
+ } else if (request.getAttribute("cannotFindMemberIDInJWTToken") != null) {
+ setResponse(response, IN_JWT_NOT_CONTAINS_USER_INFO.getHttpStatus(),
+ IN_JWT_NOT_CONTAINS_USER_INFO.getCode(),
+ IN_JWT_NOT_CONTAINS_USER_INFO.getMessage());
+ } else {
+ // 나머지 잘못된 요청에 대한 기본 예외 처리
+ setResponse(response, HttpStatus.UNAUTHORIZED, "F-001",
+ "허용되지 않은 권한 또는 허용되지 않은 경로입니다."); // 기본적으로 401 상태 코드와 에러 메시지 반환
+ }
+
+ }
+
+ // 발생한 예외에 맞게 status를 설정하고 message를 반환
+ private void setResponse(HttpServletResponse response, HttpStatus status, String code, String message) throws
+ IOException {
+ response.setStatus(status.value());
+ response.setContentType("application/json");
+ response.setCharacterEncoding("UTF-8");
+
+ String json = new ObjectMapper().writeValueAsString(ErrorResponse.error(code, message));
+ response.getWriter().write(json);
+ }
+
+}
diff --git a/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java b/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java
new file mode 100644
index 00000000..f09f6671
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java
@@ -0,0 +1,10 @@
+package org.scoula.global.security.exception;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class JwtAuthenticationException extends AuthenticationException {
+ public JwtAuthenticationException(String msg) {
+ super(msg);
+ }
+}
+
diff --git a/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java b/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java
new file mode 100644
index 00000000..388b940d
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java
@@ -0,0 +1,26 @@
+package org.scoula.global.security.exception;
+
+import org.scoula.global.exception.errorCode.ErrorCode;
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum JwtErrorMessage implements ErrorCode {
+
+ INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "J-001", "유효하지 않은 JWT 토큰 입니다."),
+ NEED_ACCESS_TOKEN_REFRESH(HttpStatus.UNAUTHORIZED, "J-002", "토큰 재발급이 필요합니다."),
+ CANNOT_USE_REFRESH_TOKEN(HttpStatus.valueOf(400), "J-003", "리프레쉬 토큰은 사용할 수 없습니다."),
+ UNSUPPORTED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "J-004", "지원하지 않는 유형의 JWT 토큰입니다."),
+ NOT_MATCH_REFRESH_TOKEN(HttpStatus.valueOf(400), "J-005", "로그인이 필요한 서비스입니다."),
+ SIGN_OUT_USER(HttpStatus.valueOf(400), "J-006", "이미 로그아웃한 사용자 입니다."),
+ CLAIMS_IS_EMPTY(HttpStatus.UNAUTHORIZED, "J-007", "Claims 내부에 값이 들어있지 않습니다."),
+ IN_JWT_NOT_CONTAINS_USER_INFO(HttpStatus.UNAUTHORIZED, "J-008", "토큰에 유저 정보가 담겨있지 않습니다."),
+ SIGN_IN_FAIL(HttpStatus.valueOf(401), "J-009", "아이디 또는 비밀번호가 일치하지 않습니다.");
+
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java
new file mode 100644
index 00000000..c0e5ee5a
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java
@@ -0,0 +1,75 @@
+package org.scoula.global.security.filter;
+
+import java.io.IOException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.scoula.global.security.exception.CustomAuthenticationEntryPoint;
+import org.scoula.global.security.exception.JwtAuthenticationException;
+import org.scoula.global.security.util.JwtTokenProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ // 1. Request Header에서 JWT 토큰 추출
+ String token = resolveToken(request);
+
+ // 2. 새로고침 시 메모리에 저장된 액세스 토큰이 사라졌을 시 발생시키는 에러
+ if (token == null) {
+ request.setAttribute("notExistAccessToken", 401);
+ customAuthenticationEntryPoint.commence(request, response,
+ new JwtAuthenticationException("notExistAccessToken"));
+ return;
+ }
+
+ // 3. validateToken으로 토큰 유효성 검사
+ try {
+ if (jwtTokenProvider.validateToken(request, response, token)) {
+ // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
+ Authentication authentication = jwtTokenProvider.getAuthentication(request, response, token);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ } catch (JwtAuthenticationException e) {
+ customAuthenticationEntryPoint.commence(request, response, e);
+ return;
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String requestURI = request.getRequestURI();
+ // 해당 토큰 검증 필터가 적용되지 않게 하려는 api 경로
+ return requestURI.startsWith("/auth") ||
+ requestURI.startsWith("/swagger") ||
+ requestURI.startsWith("/v2") ||
+ requestURI.startsWith("/webjars") ||
+ requestURI.startsWith("/configuration") ||
+ requestURI.startsWith("/codef");
+ }
+
+ // Request Header에서 토큰 정보 추출
+ private String resolveToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/org/scoula/global/security/util/CookieUtil.java b/src/main/java/org/scoula/global/security/util/CookieUtil.java
new file mode 100644
index 00000000..a38d3cdf
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/util/CookieUtil.java
@@ -0,0 +1,36 @@
+package org.scoula.global.security.util;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.http.ResponseCookie;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CookieUtil {
+
+ private static final int MAX_AGE_OF_COOKIE = 3 * 24 * 60 * 60;
+
+ public static void setCookie(HttpServletResponse response, String name, String value) {
+ String cookieString = name + "=" + value +
+ "; Max-Age=" + MAX_AGE_OF_COOKIE +
+ "; Path=/" +
+ "; Secure" +
+ "; HttpOnly" +
+ "; SameSite=None" +
+ "; Partitioned";
+
+ response.setHeader("Set-Cookie", cookieString);
+ }
+
+ public static void deleteCookie(HttpServletResponse response, String name) {
+ ResponseCookie cookie = ResponseCookie.from(name, "value")
+ .maxAge(0)
+ .path("/")
+ .secure(true)
+ .httpOnly(true)
+ .sameSite("None")
+ .build();
+
+ response.setHeader("Set-Cookie", cookie.toString());
+ }
+}
diff --git a/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java b/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java
new file mode 100644
index 00000000..117238ba
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java
@@ -0,0 +1,153 @@
+package org.scoula.global.security.util;
+
+import java.io.IOException;
+import java.security.Key;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.scoula.domain.member.dto.MemberDTO;
+import org.scoula.domain.member.service.MemberService;
+import org.scoula.global.redis.util.RedisUtil;
+import org.scoula.global.security.dto.CustomUserDetails;
+import org.scoula.global.security.dto.JwtToken;
+import org.scoula.global.security.exception.JwtAuthenticationException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.stereotype.Component;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Component
+public class JwtTokenProvider {
+
+ private static final long ACCESS_TOKEN_MAX_AGE = 1000L * 60 * 30; // 30분
+ private static final long REFRESH_TOKEN_MAX_AGE = 1000L * 60 * 60 * 24 * 3; // 3일
+ private static final int REDIS_REFRESH_TOKEN_MAX_AGE = 60 * 24 * 3; // 3일 minute 단위
+
+ private final Key key;
+ private final MemberService memberService;
+ private final RedisUtil redisUtil;
+
+ public JwtTokenProvider(@Value("${JWT_SECRET}") String secretKey, MemberService memberService,
+ RedisUtil redisUtil) {
+ this.memberService = memberService;
+ byte[] keyBytes = Decoders.BASE64.decode(secretKey);
+ this.key = Keys.hmacShaKeyFor(keyBytes);
+ this.redisUtil = redisUtil;
+ }
+
+ // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
+ public JwtToken generateToken(Long memberId, String loginId) {
+
+ long now = (new Date()).getTime();
+
+ // Access Token 생성
+ Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_MAX_AGE);
+ String accessToken = Jwts.builder()
+ .setSubject(String.valueOf(memberId))
+ .claim("auth", "ROLE_" + "MEMBER")
+ .setExpiration(accessTokenExpiresIn)
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+
+ // Refresh Token 생성
+ String refreshToken = Jwts.builder()
+ .setExpiration(new Date(now + REFRESH_TOKEN_MAX_AGE))
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+
+ redisUtil.set(loginId, refreshToken, REDIS_REFRESH_TOKEN_MAX_AGE);
+
+ return JwtToken.builder()
+ .grantType("Bearer")
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+
+ // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
+ public Authentication getAuthentication(HttpServletRequest request, HttpServletResponse response,
+ String accessToken) throws
+ ServletException,
+ IOException, JwtAuthenticationException {
+ // Jwt 토큰 복호화
+ Claims claims = parseClaims(accessToken);
+
+ if (claims.get("auth") == null) {
+ request.setAttribute("cannotUseRefreshToken", 400);
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ }
+
+ // 클레임에서 권한 정보 가져오기
+ Collection authorities = new ArrayList<>();
+ authorities.add(new SimpleGrantedAuthority((String)claims.get("auth")));
+
+ // CustomUserDetails 객체를 만들어서 Authentication return
+ MemberDTO member = memberService.getMemberById(Long.valueOf(claims.getSubject()));
+ if (member == null) {
+ request.setAttribute("cannotFindMemberIDInJWTToken", 400);
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ }
+ CustomUserDetails userDetails = new CustomUserDetails(member.toEntity());
+
+ // 3) AuthenticationToken 에 principal 로 CustomUserDetails 넣기
+ return new UsernamePasswordAuthenticationToken(
+ userDetails, // <- CustomUserDetails
+ accessToken, // credentials 필드에 토큰을 넣어도 되고, null 여도 됩니다
+ userDetails.getAuthorities() // 권한
+ );
+ }
+
+ // 토큰 정보를 검증하는 메서드
+ public boolean validateToken(HttpServletRequest request, HttpServletResponse response, String token) throws
+ JwtAuthenticationException {
+ try {
+ Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token);
+ return true;
+ } catch (SecurityException | MalformedJwtException e) {
+ request.setAttribute("invalidJwtToken", 401);
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ } catch (ExpiredJwtException e) {
+ request.setAttribute("expiredJwtToken", 401);
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ } catch (UnsupportedJwtException e) {
+ request.setAttribute("unsupportedJwtToken", 401);
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ } catch (Exception e) {
+ throw new JwtAuthenticationException("cannotUseRefreshToken");
+ }
+ }
+
+ // accessToken
+ private Claims parseClaims(String accessToken) {
+ try {
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(accessToken)
+ .getBody();
+ } catch (ExpiredJwtException e) {
+ return e.getClaims();
+ }
+ }
+
+}
diff --git a/src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java b/src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java
new file mode 100644
index 00000000..7a24371f
--- /dev/null
+++ b/src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java
@@ -0,0 +1,71 @@
+package org.scoula.global.security.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.scoula.domain.member.dto.MemberDTO;
+import org.scoula.domain.member.service.MemberService;
+import org.scoula.global.exception.CustomException;
+import org.scoula.global.kafka.dto.Common;
+import org.scoula.global.kafka.dto.LogLevel;
+import org.scoula.global.security.exception.JwtErrorMessage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+@Component
+public class UsernamePwdAuthenticationProvider implements AuthenticationProvider {
+
+ @Autowired
+ private MemberService memberService;
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
+ @Override
+ public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+
+ String username = authentication.getName();
+ String pwd = authentication.getCredentials().toString();
+ Optional customer = Optional.ofNullable(memberService.getMemberByLoginId(username));
+
+ if (customer.isPresent()) {
+ if (passwordEncoder.matches(pwd, customer.get().getPassword())) {
+ List authorities = new ArrayList<>();
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + "MEMBER"));
+ return new UsernamePasswordAuthenticationToken(username, pwd, authorities);
+
+ } else {
+ throw new CustomException(JwtErrorMessage.SIGN_IN_FAIL, LogLevel.WARNING, null,
+ Common.builder()
+ .callApiPath("/auth/signin")
+ .apiMethod("POST")
+ .memberId(String.valueOf(customer.get().getMemberId()))
+ .retryCount(0)
+ .build()
+ );
+ }
+ } else {
+ throw new CustomException(JwtErrorMessage.SIGN_IN_FAIL, LogLevel.WARNING, null,
+ Common.builder()
+ .callApiPath("/auth/signin")
+ .apiMethod("POST")
+ .memberId(String.valueOf(customer.get().getMemberId()))
+ .retryCount(0)
+ .build()
+ );
+ }
+
+ }
+
+ @Override
+ public boolean supports(Class> authentication) {
+ return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 14d41348..e930313e 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,4 +1,12 @@
jdbc.driver=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
-jdbc.url= ${DB_URL}
-jdbc.username= ${DB_USERNAME}
-jdbc.password= ${DB_PASSWORD}
\ No newline at end of file
+jdbc.url=${DB_URL}
+jdbc.username=${DB_USERNAME}
+jdbc.password=${DB_PASSWORD}
+spring.kafka.bootstrap-servers=${KAFKA_BROKER_SERVER}
+spring.kafka.log-topic=${KAFKA_LOG_TOPIC}
+spring.redis.host=${REDIS_HOST}
+spring.redis.port=${REDIS_PORT}
+spring.redis.password=
+jwt.secret=${JWT_SECRET}
+codef.client.id=${CODEF_CLIENT_ID}
+codef.client.secret=${CODEF_CLIENT_SECRET}
diff --git a/src/main/resources/db/migration/V3__edit_member.sql b/src/main/resources/db/migration/V3__edit_member.sql
new file mode 100644
index 00000000..ff9dacb1
--- /dev/null
+++ b/src/main/resources/db/migration/V3__edit_member.sql
@@ -0,0 +1,8 @@
+ALTER TABLE member
+ ADD COLUMN country VARCHAR(255);
+
+ALTER TABLE member
+ MODIFY COLUMN nationality ENUM('RUSSIA', 'MONGOLIA', 'USA', 'VIETNAM', 'INDIA', 'INDONESIA', 'JAPAN', 'CHINA', 'THAILAND', 'PHILIPPINES', 'KOREAN_RUSSIAN', 'KOREAN_CHINESE', 'OTHER') NOT NULL;
+
+ALTER TABLE member
+ MODIFY COLUMN currency ENUM('USD', 'JPY', 'EUR', 'GBP', 'CAD', 'KRW', 'HKD', 'CNY', 'THB', 'IDR', 'VND', 'RUB', 'BDT');
diff --git a/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml b/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml
new file mode 100644
index 00000000..57718790
--- /dev/null
+++ b/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml
index a908bfec..35c6751e 100644
--- a/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml
+++ b/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml
@@ -3,7 +3,7 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-
+
diff --git a/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml
index 265ef649..fe7e1bee 100644
--- a/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml
+++ b/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml
@@ -3,7 +3,7 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-
+
diff --git a/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml
index bbe7d9da..71caa2e2 100644
--- a/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml
+++ b/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml
@@ -3,7 +3,7 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-
+