diff --git a/build.gradle.kts b/build.gradle.kts index 0efa8c89..fd1f5116 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") compileOnly("org.projectlombok:lombok") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") diff --git a/src/main/java/com/back/BackApplication.java b/src/main/java/com/back/BackApplication.java index e5e900c6..530c84fc 100644 --- a/src/main/java/com/back/BackApplication.java +++ b/src/main/java/com/back/BackApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class BackApplication { public static void main(String[] args) { diff --git a/src/main/java/com/back/global/appConfig/AppConfig.java b/src/main/java/com/back/global/appConfig/AppConfig.java new file mode 100644 index 00000000..218f981a --- /dev/null +++ b/src/main/java/com/back/global/appConfig/AppConfig.java @@ -0,0 +1,8 @@ +package com.back.global.appConfig; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/back/global/appConfig/JpaAuditingConfig.java b/src/main/java/com/back/global/appConfig/JpaAuditingConfig.java new file mode 100644 index 00000000..1709bec7 --- /dev/null +++ b/src/main/java/com/back/global/appConfig/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.back.global.appConfig; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/back/global/appConfig/SwaggerConfig.java b/src/main/java/com/back/global/appConfig/SwaggerConfig.java new file mode 100644 index 00000000..1e6f5442 --- /dev/null +++ b/src/main/java/com/back/global/appConfig/SwaggerConfig.java @@ -0,0 +1,19 @@ +package com.back.global.appConfig; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("HaeDokCoding API") + .description("HaeDokCoding Backend API Documentation") + .version("v1.0.0")); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/aspect/ResponseAspect.java b/src/main/java/com/back/global/aspect/ResponseAspect.java new file mode 100644 index 00000000..11475ca3 --- /dev/null +++ b/src/main/java/com/back/global/aspect/ResponseAspect.java @@ -0,0 +1,43 @@ +package com.back.global.aspect; + +import com.back.global.rsData.RsData; +import jakarta.servlet.http.HttpServletResponse; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +public class ResponseAspect { + + @Around(""" + execution(public com.back.global.rsData.RsData *(..)) && + ( + within(@org.springframework.stereotype.Controller *) || + within(@org.springframework.web.bind.annotation.RestController *) + ) && + ( + @annotation(org.springframework.web.bind.annotation.GetMapping) || + @annotation(org.springframework.web.bind.annotation.PostMapping) || + @annotation(org.springframework.web.bind.annotation.PutMapping) || + @annotation(org.springframework.web.bind.annotation.DeleteMapping) || + @annotation(org.springframework.web.bind.annotation.RequestMapping) + ) + """) + public Object handleResponse(ProceedingJoinPoint joinPoint) throws Throwable { + Object proceed = joinPoint.proceed(); + + RsData rsData = (RsData) proceed; + HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); + if (response != null) { + response.setStatus(rsData.code()); + } + + return proceed; + } +} + + diff --git a/src/main/java/com/back/global/controller/HomeController.java b/src/main/java/com/back/global/controller/HomeController.java new file mode 100644 index 00000000..14eeb2f8 --- /dev/null +++ b/src/main/java/com/back/global/controller/HomeController.java @@ -0,0 +1,13 @@ +package com.back.global.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @GetMapping("/") + public String redirectToSwagger() { + return "redirect:/swagger-ui/index.html"; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/exception/ServiceException.java b/src/main/java/com/back/global/exception/ServiceException.java new file mode 100644 index 00000000..f9b19fe0 --- /dev/null +++ b/src/main/java/com/back/global/exception/ServiceException.java @@ -0,0 +1,25 @@ +package com.back.global.exception; + + +import com.back.global.rsData.RsData; + +/** + * 서비스 예외를 나타내는 클래스 + * 서비스 계층에서 발생하는 오류를 처리하기 위해 사용 + * @param code 오류 코드 + * @param msg 오류 메시지 + */ + +public class ServiceException extends RuntimeException { + private final int code; + private final String msg; + + public ServiceException(int code, String msg) { + super(code + " : " + msg); + this.code = code; + this.msg = msg; + } + public RsData getRsData() { + return RsData.of(code,msg); + } +} diff --git a/src/main/java/com/back/global/globalExceptionHandler/GlobalExceptionHandler.java b/src/main/java/com/back/global/globalExceptionHandler/GlobalExceptionHandler.java new file mode 100644 index 00000000..edc77bfa --- /dev/null +++ b/src/main/java/com/back/global/globalExceptionHandler/GlobalExceptionHandler.java @@ -0,0 +1,159 @@ +package com.back.global.globalExceptionHandler; + + +import com.back.global.exception.ServiceException; +import com.back.global.rsData.RsData; +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.io.IOException; +import java.util.Comparator; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +/** + * 글로벌 예외 핸들러 클래스 + * 각 예외에 대한 적절한 HTTP 상태 코드와 메시지를 포함한 응답 반환 + * 400: Bad Request + * 404: Not Found + * 500: Internal Server Error + */ + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // ServiceException: 서비스 계층에서 발생하는 커스텀 예외 + @ExceptionHandler(ServiceException.class) + public ResponseEntity> handle(ServiceException ex) { + RsData rsData = ex.getRsData(); + int statusCode = rsData.code(); + + HttpStatus status = HttpStatus.resolve(statusCode); + if( status == null) { + status = HttpStatus.INTERNAL_SERVER_ERROR; // 기본값 설정 + } + return ResponseEntity.status(status).body(rsData); + + } + + // NoSuchElementException: 데이터 없을떄 예외 + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity> handle(NoSuchElementException ex) { + return new ResponseEntity<>( + RsData.of( + 404, + "해당 데이터가 존재하지 않습니다" + ), + NOT_FOUND + ); + } + + //ConstraintViolationException: 제약 조건(@NotNull, @Size 등)을 어겼을 때 발생예외 + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handle(ConstraintViolationException ex) { + //메세지 형식 : <필드명>-<검증어노테이션명>-<검증실패메시지> + String message = ex.getConstraintViolations() + .stream() + .map( + violation -> { + String path = violation.getPropertyPath().toString(); + String field = path.contains(".") ? path.split("\\.",2)[1]: path; + String[] messageTemplateBits = violation.getMessageTemplate() + .split("\\."); + String code = messageTemplateBits.length >= 2 + ? messageTemplateBits[messageTemplateBits.length -2] : "Unknown"; + + String _message = violation.getMessage(); + + return "%s-%s-%s".formatted(field, code, _message); + }) + .sorted() + .collect(Collectors.joining("\n")); + + return new ResponseEntity<>( + RsData.of( + 400, + message + ), + BAD_REQUEST + ); + } + + + // MethodArgumentNotValidException: @Valid 어노테이션을 사용한 유효성 검사 실패시 발생하는 예외 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handle(MethodArgumentNotValidException ex) { + //메세지 형식 : <필드명>-<검증어노테이션명>-<검증실패메시지> + String message = ex.getBindingResult() + .getAllErrors() + .stream() + .filter(error -> error instanceof FieldError) + .map(error -> (FieldError) error) + .map(error -> error.getField() + "-" + error.getCode() + "-" + error.getDefaultMessage()) + .sorted(Comparator.comparing(String::toString)) + .collect(Collectors.joining("\n")); + + return new ResponseEntity<>( + RsData.of( + 400, + message + ) , + BAD_REQUEST + ); + } + + // HttpMessageNotReadableException : 요청 본문이 올바르지 않을 때 발생하는 예외 + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handle(HttpMessageNotReadableException ex) { + return new ResponseEntity<>( + RsData.of( + 400, + "요청 본문이 올바르지 않습니다." + ), + BAD_REQUEST + ); + } + + // MissingRequestHeaderException : 필수 요청 헤더가 누락되었을 때 발생하는 예외 + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> handle(MissingRequestHeaderException ex) { + // 메세지 형식 : <필드명>-<검증어노테이션명>-<검증실패메시지> + String message = "%s-%s-%s".formatted( + ex.getHeaderName(), + "NotBlank", + ex.getLocalizedMessage() + ); + + return new ResponseEntity<>( + RsData.of( + 400, + message + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(JsonProcessingException.class) + public ResponseEntity> handleJsonProcessingException(JsonProcessingException e) { + return ResponseEntity.badRequest() + .body(RsData.of(400, "JSON 파싱 오류가 발생했습니다.", null)); + } + + @ExceptionHandler(IOException.class) + public ResponseEntity> handleIOException(IOException e) { + return ResponseEntity.internalServerError() + .body(RsData.of(500, "서버 내부 오류가 발생했습니다.", null)); + } + +} diff --git a/src/main/java/com/back/global/init/DevInitData.java b/src/main/java/com/back/global/init/DevInitData.java new file mode 100644 index 00000000..e2061d1a --- /dev/null +++ b/src/main/java/com/back/global/init/DevInitData.java @@ -0,0 +1,35 @@ +package com.back.global.init; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("dev") +@RequiredArgsConstructor +public class DevInitData { + @Autowired + @Lazy + private DevInitData self; + + + @Bean + ApplicationRunner devInitDataApplicationRunner() { + return args -> { +// self.memberInit(); + + }; + } + +// @Transactional +// public void memberInit() { +// if (memberService.count() > 0) { +// return; +// } +// } + +} diff --git a/src/main/java/com/back/global/init/TestInitData.java b/src/main/java/com/back/global/init/TestInitData.java new file mode 100644 index 00000000..3e04220d --- /dev/null +++ b/src/main/java/com/back/global/init/TestInitData.java @@ -0,0 +1,42 @@ +package com.back.global.init; + + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("test") +@RequiredArgsConstructor +public class TestInitData { + @Autowired + @Lazy + private TestInitData self; + + @Bean + ApplicationRunner testInitDataApplicationRunner() { + return args -> { +// self.memberInit(); + + }; + } + +// @Transactional +// public void memberInit() { +// if (memberService.count() > 0) { +// return; +// } +// +// memberService.join("system","12345678", "system@gmail.com"); +// memberService.join("admin","12345678", "admin@gmail.com"); +// memberService.join("user1","12345678", "user1@gmail.com"); +// memberService.join("user2","12345678", "user2@gmail.com"); +// memberService.join("user3","12345678", "user3@gmail.com"); +// memberService.join("user4","12345678", "user4@gmail.com"); +// } + +} diff --git a/src/main/java/com/back/global/rsData/RsData.java b/src/main/java/com/back/global/rsData/RsData.java new file mode 100644 index 00000000..67ae53c5 --- /dev/null +++ b/src/main/java/com/back/global/rsData/RsData.java @@ -0,0 +1,27 @@ +package com.back.global.rsData; + +public record RsData(int code, String message, T data) { + + public static RsData of(int code, String message, T data) { + return new RsData<>(code, message, data); + } + public static RsData of(int code, String message) { + return new RsData<>(code,message, null); + } + + //성공 편의 메소드 + public static RsData successOf(T data) { + return of(200,"success", data); + } + + //실패 편의 메소드 + public static RsData failOf(T data) { + return of(500,"fail",data); + } + + // 실패 편의 메소드 (메시지 포함) + public static RsData failOf(String message) { + return of(500, message, null); + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..a1d1a3f5 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,38 @@ +spring: + # H2 Database 설정 + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:./db_dev;MODE=MySQL + username: sa + password: + + # H2 Console 설정 + h2: + console: # H2 DB를 웹에서 관리할 수 있는 기능 + enabled: true # H2 Console 사용 여부 + path: /h2-console # H2 Console 접속 주소 + + # JPA 설정 + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop # 개발용: 시작할 때 테이블 생성, 종료할 때 삭제 + properties: + hibernate: + format_sql: true + show_sql: true + +# Swagger 설정 +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + operationsSorter: method + +# # AI 설정 +# ai: +# openai: +# chat: +# options: +# model: "gemini-2.0-flash" diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..b71450d6 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:h2:mem:db_test;MODE=MySQL + username: sa + password: + driver-class-name: org.h2.Driver + + # JPA 설정 + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop # 테스트용: 시작할 때 테이블 생성, 종료할 때 삭제 + properties: + hibernate: + format_sql: true + show_sql: false # 테스트에서는 SQL 로그 비활성화 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index beb1ad82..4f08d528 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,20 @@ spring: - application: - name: back + profiles: + active: dev + +logging: + level: + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.orm.jdbc.extract: TRACE + org.springframework.transaction.interceptor: TRACE + com.back: DEBUG + +server: + address: 0.0.0.0 + port: 8080 + forward-headers-strategy: native + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true \ No newline at end of file