[TOC]
[ ←전편보기 / 1편, 3편, 4편, 5편, 6편 / 다음편보기→ ]
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403
1. 개요
지난 1차 레포트에 이은 스터디 내용 정리.
이번 편에서는 백엔드 (Java - WAS) 어플리케이션을 작성해 볼거다
아무것도 없는 상태에서 (from-scratch
) 시작해서 (그래도 java
와 gradle
은 가지고 있어야 한다.)
불필요한 기능들을 최소화 하고. 완전히 구동되는 Springboot-Application
을 만들어 보는것을 목표로 한다.
Java-17 : https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html
Gradle-8.8 : https://gradle.org/next-steps/?version=8.8&format=bin
2. Springboot Application 기초 프로젝트 생성
먼저 설치된 gradle
을 사용해 아래와 같이 gradle init --type basic --dsl kotlin
을 실행한다
(필자의 경우 D:\Applications\gradle-8.8
에 gradle이 설치되어 있었다)
> D:\Applications\gradle-8.8\bin\gradle init --type basic --dsl kotlin
Project name (default: mywas):
Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no]
> Task :init
To learn more about Gradle by exploring our Samples at https://docs.gradle.org/8.2.1/samples
BUILD SUCCESSFUL in 5s
2 actionable tasks: 2 executed
> mkdir libs
> mkdir src\main\java
> mkdir src\main\resources
> mkdir src\test\java
> mkdir src\test\resources
> mkdir src\main\java\my\was\mywas
build.gradle.kts
파일을 수정하여 필요한 라이브러리 들의 의존설정을 수행한다.
plugins {
id("java")
id("war")
/** spring 관련 */
id("org.springframework.boot") version "3.2.4"
id("io.spring.dependency-management") version "1.1.4"
id("io.freefair.lombok") version "8.1.0"
}
group = "my.was"
version = "0.0.1"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
all {
exclude("commons-logging:commons-logging")
exclude("org.apache.tomcat.embed:tomcat-embed-el")
exclude("org.apache.tomcat.embed:tomcat-embed-websocket")
}
}
repositories {
mavenCentral()
maven(url = "https://repo.spring.io/milestone")
maven(url = uri("https://nexus.ntiple.com/repository/maven-public/")).isAllowInsecureProtocol = true
}
dependencies {
/** lombok 관련 */
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
/** spring 베이스 */
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
/** 기타 필요사항들 */
implementation("commons-codec:commons-codec:1.15")
implementation("com.ntiple:ntiple-utils:0.0.2-10")
implementation("javax.validation:validation-api:2.0.1.Final")
implementation("org.apache.httpcomponents:httpclient:4.5.14")
implementation("org.apache.httpcomponents:httpmime:4.5.14")
/** OpenAPI (swagger) */
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")
/** 런타임 및 개발관련 */
developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("org.springframework.boot:spring-boot-starter-tomcat")
runtimeOnly("com.h2database:h2")
/** jwt */
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
/** yml, json */
implementation("org.yaml:snakeyaml:2.2")
implementation("org.json:json:20230618")
/** 이메일 */
implementation("com.sun.mail:jakarta.mail:2.0.1")
/** 로그 */
implementation("org.logback-extensions:logback-ext-spring:0.1.5")
/** 기타 libs 폴더에 있는 jar 파일들 */
implementation (fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
/** 테스트관련 */
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
testImplementation("com.ntiple:ntiple-utils:0.0.2-9")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
task("prebuildHook") {
dependsOn(tasks.named("build"))
dependsOn(tasks.named("bootRun"))
}
tasks {
withType<JavaExec> {
var profile = System.getProperty("spring.profiles.active")
if (profile == null || "".equals(profile)) { profile = "local" }
System.setProperty("spring.profiles.active", profile);
systemProperty("spring.profiles.active", profile)
}
named<Test>("test") {
useJUnitPlatform()
}
named<JavaExec>("bootRun") {
var profile = System.getProperty("spring.profiles.active")
println("================================================================================")
println("데모 API")
println("PROFILE:" + profile)
println("================================================================================")
}
}
src/main/resources/application.yml
을 생성한다.
debug: false
server:
port: 8080
forward-headers-strategy: FRAMEWORK
spring:
application:
name: demo
mvc:
static-path-pattern: "/**"
web:
resources:
static-locations: "classpath:/static/"
servlet:
multipart:
maxFileSize: "1GB"
maxRequestSize: "1GB"
datasource:
driverClassName: org.h2.Driver
## 메모리 전용 DB 사용시 아래 설정 사용
# url: jdbc:h2:mem:test
url: jdbc:h2:./demo-app.db
username: sa
password:
jpa:
defer-datasource-initialization: true
generate-ddl: true
show-sql: false
properties:
hibernate:
dialect: ""
ddl-auto: update
format-sql: false
mapping-resources:
## H2 Console 설정
h2:
console:
enabled: true
path: /h2-console
settings:
web-allow-others: true
security:
jwt:
## 액세스토큰
access:
algorhithm: HmacSHA512
## 액세스토큰 발급용 키 (TODO: 추후 keygen 사용)
secret: "t28wa4Pak6PAuQ1Ps2ycKRoBROybGBFzzqki3zGjk6TgTFhPutuQB3nSosLuIw4Yyuv7eSAdCfTVz0kfYi+Myw=="
## 1000 * 60 * 30 = 30분
expire: 1802000
## 리프레시토큰
refresh:
algorhithm: HmacSHA512
## 리프레시토큰 발급용 키 (TODO: 추후 keygen 사용)
secret: "v1MzR2oTSvn7Gio5TkUO/rd2teJlwkRUWh0mlkBrPLlxBso/U6ZOSz4Y5QMGa23aIB66ykZIqn1DFJodhPwB1w=="
## 1000 * 60 * 60 * 24 = 1일
expire: 86402000
key:
aes:
secret: "Fce2YOFk/TVcJfAFVZqnL9SWHfkxQEA47g0nk2GxpMM="
rsa:
privatekey: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMhfVP50PfMYry6YsMYpEdTPjWRHflvUNDhzhrgyO5gEiTIj2lK6S95XRggsU/NGTbRqRouBmFlV4+KHL9LLKhXszqlDRDWf1jZJBNdI4/GpRlQU/MkByji+2lv9894gJPXpExDLO/bM+PTZ16HMAMyZJ6DNw5hD/GMEudZk6U1lAgMBAAECgYAD/17nOrN3s6DfGZ3BPlWEPOXRv9lmBJxMGgXwi9QDiuefz/ZNmzjjRTN4+0Vrf5YSSOKCawH6mkuTG+ZY2sPKpiIBSX4SXewMIKRhbxOj19iwrNp1gBDK3s/kpHy9+x8b7tuEIITNYreuadYAvVgSMlaJVPdh7uUm39sMnn/aqwJBANRWQBpaOfw3iQ+g7jBrBJz+e5QbmsNZeNUPrk9WbMuhKQkLUP2E4U5noFKV8SZFGo48YPKsC32DxFF3e8kw2fsCQQDxk0ALLlepzdpqVkm6YQ4KtTEweug+L/RdjU5apGZLZI+AGfYIE1JIzyzgdPke92ePnw1wPqFgi0PyzyJn8DgfAkAq5M2IRUfHapSWgqT7RPMmn8XpEnZ+Ffnx2HwW7NeHfyPh/tY6kHhPNWHOrRmM6JLHvuy6uQSNM2waJO/toZ+3AkEAo4WzUl46RNztPhHOsnTEFod0Fob78ixv02u1YDHsdJhLcsEgA3NgvZxPmlhT0ZxS46scY6BhiIJ8qj1/4q9+rQJAbZj4vsQB5SrKcYNR8VVYMP9EYXnFax66QgSjGI3zPJhjAcbdyhDVshFKHcsI3R1dxegLFTbvjYKejzWfHBcBUQ=="
publickey: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIX1T+dD3zGK8umLDGKRHUz41kR35b1DQ4c4a4MjuYBIkyI9pSukveV0YILFPzRk20akaLgZhZVePihy/SyyoV7M6pQ0Q1n9Y2SQTXSOPxqUZUFPzJAco4vtpb/fPeICT16RMQyzv2zPj02dehzADMmSegzcOYQ/xjBLnWZOlNZQIDAQAB"
## OPEN-API 설정
springdoc:
packages-to-scan: my.was.mywas
default-consumes-media-type: application/json; charset=UTF-8
default-produces-media-type: application/json; charset=UTF-8
swagger-ui:
path: /swagger/
disable-swagger-default-url: true
display-request-duration: true
operations-sorter: alpha
logging:
level:
org.springframework.boot: INFO
src/test/resources/logback.xml
을 생성한다. (JUNIT 테스트용 로그설정 / 필요없는경우 생략)
<configuration scan="true" scanPeriod="10 seconds">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>[%date %level] \(%file:%line\) %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
src/main/java/my/was/mywas/MyWasApplication.java
을 생성한다. (스프링부트 실행 클래스)
package my.was.mywas;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyWasApplication {
public static void main(String[] args) {
String profile = System.getProperty("spring.profiles.active");
if (profile == null || "".equals(profile)) {
System.setProperty("spring.profiles.active", "local");
}
SpringApplication.run(MyWasApplication.class, args);
}
}
src/main/java/my/was/mywas/ServletInitializer.java
을 생성한다. (tomcat 등 was container 에서 실행할 경우 필요)
package my.was.mywas;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
public class ServletInitializer extends SpringBootServletInitializer {
@Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(MyWasApplication.class);
}
}
src/main/java/my/was/mywas/configs/SecurityConfig.java
을 생성한다 (Spring Security)
package my.was.mywas.configs;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT;
... 중략 ...
@Slf4j @Configuration
@EnableWebSecurity @EnableMethodSecurity
public class SecurityConfig {
@Autowired private CorsFilter corsFilter;
@Autowired private AuthFilter authFilter;
@Autowired private JwtAuthenticationEntryPoint authPoint;
@Autowired private JwtAccessDeniedHandler authHandler;
/** URL 패턴매칭 */
private static AntPathRequestMatcher matcher(HttpMethod m, String path) {
if (m != null) {
return AntPathRequestMatcher.antMatcher(m, path);
} else {
return AntPathRequestMatcher.antMatcher(path);
}
}
@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
/** 전체공개 (인증없이 접근 가능한 목록) */
List<RequestMatcher> reqPubLst = new ArrayList<>();
/** 회원 사용자만 허용 */
List<RequestMatcher> reqMbrLst = new ArrayList<>();
/** 웹리소스 */
List<RequestMatcher> reqWebLst = new ArrayList<>();
reqPubLst.addAll(List.of(
/** GET /api/cmn/* (공용API) */
matcher(GET, "/api/cmn/**"),
/** GET /api/usr/usr01001a01/** (마이페이지) */
matcher(GET, "/api/usr/usr01001/**"),
/** PUT /api/usr/** (회원가입) */
matcher(PUT, "/api/usr/**"),
/** POST /api/lgn/ (로그인) */
matcher(POST, "/api/lgn/**"),
/** POST /api/atc/atc01001/ (게시물 검색) */
matcher(POST, "/api/atc/atc01001"),
/** POST /api/atc/atc01001/ (게시물 상세조회) */
matcher(GET, "/api/atc/atc01001/**"),
/** H2DB웹콘솔 */
matcher(null, "/h2-console/**"),
/** 스웨거(OPENAPI) */
matcher(null, "/swagger/swagger-ui/**"),
matcher(null, "/swagger/swagger-resources/**"),
matcher(null, "/swagger/v3/api-docs/**")
));
/** 기타 /api 로 시작되는 모든 리퀘스트 들은 권한 필요 */
reqMbrLst.addAll(List.of(
matcher(null, "/api/**")
));
reqWebLst.addAll(List.of(
/** 기타 GET 메소드로 접근하는 모든 웹 리소스 URL */
matcher(GET, "/**")
));
final RequestMatcher[] reqPub = reqPubLst.toArray(new RequestMatcher[]{ });
final RequestMatcher[] reqMbr = reqMbrLst.toArray(new RequestMatcher[]{ });
final RequestMatcher[] reqWeb = reqWebLst.toArray(new RequestMatcher[]{ });
log.debug("PUBLIC-ALLOWED:{}{}", "", reqPub);
http
/** token을 사용하는 방식이므로 csrf disable */
.csrf(csrf -> csrf.disable())
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exh -> exh
/** 인증 실패 핸들링 */
.authenticationEntryPoint(authPoint)
/** 권한인가실패 핸들링 */
.accessDeniedHandler(authHandler)
)
.headers(hdr ->
hdr.frameOptions(frm -> frm.sameOrigin())
/** 동일 사이트 referer */
.referrerPolicy(ref -> ref.policy(ReferrerPolicy.SAME_ORIGIN))
/** xss 보호 */
.xssProtection(xss -> xss.disable())
)
/** 세션 사용하지 않음 */
.sessionManagement(mng -> mng
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
/** URI별 인가설정 */
.authorizeHttpRequests(req -> req
// .anyRequest().permitAll()
/** 전체공개 (인증없이 접근 가능한 목록) */
.requestMatchers(reqPub).permitAll()
/** 회원 사용자만 허용 */
.requestMatchers(reqMbr).hasAnyAuthority("ROLE_USER")
/** 웹리소스 */
.requestMatchers(reqWeb).permitAll()
.anyRequest()
.hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
)
/** 폼 로그인 불가 */
.formLogin(login -> login.disable())
/** 폼 로그아웃 불가 */
.logout(logout -> logout.disable())
/** 임의유저 불가 */
.anonymous(anon -> anon.disable())
;
SecurityFilterChain ret = http.build();
return ret;
}
@Component public static class AuthFilter extends GenericFilterBean {
@Override public void doFilter(ServletRequest sreq, ServletResponse sres, FilterChain chain)
throws IOException, ServletException {
/** 추후 Response 객체를 Request 에서 읽어올 수 있도록 저장 */
HttpServletRequest req = (HttpServletRequest) sreq;
req.setAttribute(HttpServletResponse.class.getName(), sres);
/** TODO: 토큰유무 및 인증정보 확인 */
log.debug("TODO: 토큰유무 및 인증정보 확인");
chain.doFilter(sreq, sres);
}
}
/** 인증 실패 핸들링 */
@Component public static class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override public void commence(HttpServletRequest req, HttpServletResponse res,
AuthenticationException e) throws IOException, ServletException {
log.debug("AUTH-ERR:{} {}", req.getRequestURI(), e.getMessage());
res.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.name());
}
}
/** 권한인가실패 핸들링 */
@Component public static class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override public void handle(HttpServletRequest req, HttpServletResponse res,
AccessDeniedException e) throws IOException, ServletException {
log.debug("ACCESS-DENIED:{}", e.getMessage());
res.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.name());
}
}
/** Cross Origin Resource Sharing 필터링 (일단은 전부허용) */
@Configuration public static class CorsFilterConfig {
@Bean public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowCredentials(true);
cfg.addAllowedOriginPattern("*");
cfg.addAllowedHeader("*");
cfg.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", cfg);
return new CorsFilter(source);
}
}
}
src/main/java/my/was/mywas/configs/OpenAPIConfig.java
를 생성한다. (SWAGGER)
package my.was.mywas.configs;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.servers.Server;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
@OpenAPIDefinition(servers = { @Server(url = "/", description = "기본URL") })
@Configuration public class OpenAPIConfig {
@Bean public OpenAPI customOpenAPI() {
final String secureScheme = "인증전송자(Bearer)";
return new OpenAPI()
.addSecurityItem(new SecurityRequirement().addList(secureScheme))
.components(new Components()
.addSecuritySchemes(secureScheme, new SecurityScheme()
.name(secureScheme)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.info(new Info().title("데모 프로그램"));
}
}
- 여기까지 수행하면 아래와 같은 구조가 만들어진다.
├── bin [dir]
├── build [dir]
├── gradle [dir]
├── build.gradle.kts
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
├── main
│ ├── java
│ │ └── my
│ │ └── was
│ │ └── mywas
│ │ ├── configs
│ │ │ ├── OpenAPIConfig.java
│ │ │ └── SecurityConfig.java
│ │ ├── MyWasApplication.java
│ │ └── ServletInitializer.java
│ └── resources
│ └── application.yml
└── test
├── java
└── resources
└── logback.xml
- 실행은
sh gradlew bootRun
으로 실행해 볼 수 있다
> gradlew bootRun
> Configure project :
================================================================================
데모 API
PROFILE:local
================================================================================
> Task :bootRun
Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts
Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.4)
... 중략 ...
erAwareRequestFilter@53ef09f, org.springframework.security.web.session.SessionManagementFilter@11a02d20, org.springframework.security.web.access.ExceptionTranslationFilter@4a136d3a, org.springframework.security.web.access.intercept.AuthorizationFilter@477d5d3f
2024-06-16T02:57:56.836+09:00 INFO 34665 --- [demo] [ restartedMain] o.s.b.d.a.OptionalLiveReloadServer : LiveReload server is running on port 35729
2024-06-16T02:57:56.870+09:00 INFO 34665 --- [demo] [ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path ''
2024-06-16T02:57:56.883+09:00 INFO 34665 --- [demo] [ restartedMain] my.was.mywas.MyWasApplication : Started MyWasApplication in 3.23 seconds (process running for 3.583)
<==========---> 83% EXECUTING [49s]
> :bootRun
- 브라우저로 실행해 보면 아래와 같은 화면이 출력된다
OPEN-API (SWAGGER) : http://localhost:8080/swagger/swagger-ui/index.html
H2-DB CONSOLE : http://localhost:8080/h2-console
- 일단 이로서 기초 공사는 마쳤다!고 할수 있겠다 이제 각종 공통 요소부터 구현한다.
3. 공통 요소들 작성
본격적으로 비즈니스로직을 작성하기 이전에 로직을 간편화 시켜줄 수 있는 공통 유틸리티 들을 작성하여 사용한다.
보통 프로젝트에서 이 구간에 공을 얼마나 들이냐에 따라 프로젝트 복잡도 / 난이도가 꽤나 차이나게 된다.
프레임 워크 위의 또다른 프레임 워크 라고 보면 이해가 빠를 듯 하다.
따라서 이 부분은 프로젝트별로 천차만별 이며, 알기 힘든 부분도 많다.
일단은 필자가 아는 내용대로(내맘대루) 작성하겠다.
3-1. 공통상수 (Constants.java
)
- 공통으로 사용하는 상수들을 정의한다
package my.was.mywas.commons;
public interface Constants {
static final String AUTH = "auth";
static final String AUTHORIZATION = "Authorization";
static final String BEARER = "Bearer";
static final String CONTENT_DISPOSITION = "Content-disposition";
static final String CONTENT_TYPE = "Content-type";
static final String CTYPE_FILE = " application/octet-stream";
static final String CTYPE_FORM = "application/x-www-form-urlencoded";
static final String CTYPE_HTML = "text/html";
static final String CTYPE_JSON = "application/json";
static final String CTYPE_MULTIPART = "multipart/form-data";
static final String CTYPE_TEXT = "text/plain";
static final String EXTRA_INFO = "extraInfo";
static final String PROF_DEV = "dev";
static final String PROF_LOCAL = "local";
static final String PROF_MY = "my";
static final String PROF_PROD = "prod";
static final String REFERER = "Referer";
static final String ROLE_ADMIN = "ROLE_ADMIN";
static final String ROLE_USER = "ROLE_USER";
static final String STATIC_ACCESS = "static-access";
static final String TOK_TYP_ACC = "ACC";
static final String TOK_TYP_REF = "REF";
static final String X_FORWARDED_FOR = "X-Forwarded-For";
static final String KOKR = "ko-KR";
static final String RESCD_OK = "0000";
static final String RESCD_FAIL = "9999";
static final String NOT_PERMITTED_USER = "NOT PERMITTED USER";
static final String ISO88591 = "ISO8859-1";
}
3-2. 공통Exception (ApiException.java
)
- 공통 Exception 을 작성한다
package my.was.mywas.commons;
import static com.ntiple.commons.ConvertUtil.cat;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder
public class ApiException extends RuntimeException {
public HttpStatus status;
public Integer errcd;
public String errmsg;
public ApiException(Integer errcd, String errmsg) {
super(cat("[", errcd, "]", errmsg, ""));
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
this.errcd = errcd;
this.errmsg = errmsg;
}
public ApiException(Integer errcd, String errmsg, HttpStatus status) {
super(cat("[", errcd, "]", errmsg, "/", status));
this.status = status;
this.errcd = errcd;
this.errmsg = errmsg;
}
}
3-3. 공통설정 (SystemSettings.java
)
- 공통 설정 UTIL 을 작성한다 (암호화 키 등을 공통에서 사용.)
package my.was.mywas.commons;
import static com.ntiple.commons.ConvertUtil.cast;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
@Slf4j @Component @Getter @Setter
public class SystemSettings implements ApplicationContextAware {
private static SystemSettings instance;
private boolean alive;
private Map<String, Object> gprops;
private ApplicationContext applicationContext;
@Value("${spring.profiles.active:local}") private String profile;
/** aes 암호화 키 */
@Value("${security.key.aes.secret:}") private String keySecret;
/** rsa 암호화 개인키 */
@Value("${security.key.rsa.privatekey:}") private String keyPrivate;
/** rsa 암호화 공개키 */
@Value("${security.key.rsa.publickey:}") private String keyPublic;
/** access token security */
@Value("${security.jwt.access.secret:}") private String secretAcc;
/** refresh token security */
@Value("${security.jwt.refresh.secret:}") private String secretRef;
/** access token expiry */
@Value("${security.jwt.access.expire:0}") private Long exprAcc;
/** refresh token expiry */
@Value("${security.jwt.refresh.expire:0}") private Long exprRef;
@PostConstruct public void init() {
log.trace("INIT:{}", SystemSettings.class);
instance = this;
alive = true;
gprops = new LinkedHashMap<>();
}
public static final SystemSettings getInstance() {
if (instance == null) {
instance = new SystemSettings();
instance.init();
}
return instance;
}
@Override @SuppressWarnings("null")
public void setApplicationContext(ApplicationContext appctx) throws BeansException {
this.applicationContext = appctx;
}
public static final boolean containsPropertyKey(String key) {
return instance.gprops.containsKey(key);
}
public static final String getProperty(String key) {
return cast(instance.gprops.get(key), "");
}
public static final void setProperty(String key, String val) {
instance.gprops.put(key, val);
}
}
3-4. 공통DTO Entity (CommonEntity.java
)
- 공통적으로 사용할 DTO Entity 를 정의한다 (검색등에서 공통으로 사용)
package my.was.mywas.commons;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Date;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CommonEntity {
@Target({METHOD, FIELD, TYPE})
@Retention(RetentionPolicy.RUNTIME)
public static @interface SecureOut { }
@Target({METHOD, FIELD, TYPE})
@Retention(RetentionPolicy.RUNTIME)
public static @interface IgnoreOut { }
@Schema(title = "로그인 요청 파라메터 (Login)")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder
public static class Login {
@Schema(title = "사용자ID")
private String userId;
@Schema(title = "비밀번호")
private String passwd;
}
@AllArgsConstructor @NoArgsConstructor
@Getter @Setter @ToString @Builder
public static class AuthInfo {
private String ipAddr;
private String userNm;
private Boolean isAdmin;
}
@Schema(title = "로그인 응답 타입 (AuthResult)")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder @SecureOut
public static class AuthResult {
@Schema(title = "사용자 ID", hidden = true)
@SecureOut private String userId;
@Schema(title = "사용자 이름", hidden = true)
@SecureOut private String userNm;
@Schema(title = "응답코드")
private String rescd;
@Schema(title = "응답타입")
private String restyp;
@Schema(title = "액세스 토큰", hidden = true)
@SecureOut private String accessToken;
@Schema(title = "리프레시 토큰", hidden = true)
@SecureOut private String refreshToken;
}
@Schema(title = "검색결과 (SearchEntity)")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder
public static class SearchEntity<T> {
@Schema(title = "검색타입")
private String searchType;
@Schema(title = "검색키워드")
private String keyword;
@Schema(title = "검색시작")
private Integer rowStart;
@Schema(title = "검색수량")
private Integer rowCount;
@Schema(title = "한화면에 표기할 페이지갯수")
private Integer pagePerScreen;
@Schema(title = "정렬타입")
private String orderType;
@Schema(title = "결과리스트총갯수")
private int rowTotal;
@NotBlank @Schema(title = "결과리스트")
private List<T> list;
public Pageable getPageable() {
int page = 0;
int rowStart = this.rowStart;
int rowCount = this.rowCount;
if (rowStart > 0 && rowCount > 0) { page = rowStart / rowCount; }
if (rowCount < 1) { rowCount = 1; }
if (page < 1) { page = 0; }
log.debug("PAGE:{} / COUNT:{} / START:{} / COUNT:{}", page, rowCount, this.rowStart, this.rowCount);
Pageable ret = PageRequest.of(page, rowCount);
return ret;
}
}
@Schema(title = "공통 REST 결과타입 (Result)")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder
public static class Result {
@NotBlank @Size(min = 1, max = 20)
@Schema(title = "결과코드")
private String rescd;
@Schema(title = "오류코드")
private String errcd;
@Schema(title = "오류메시지")
private String msg;
@Schema(title = "결과데이터")
private Object data;
}
@Schema(title = "공통 환경변수 결과타입")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter @ToString @Builder
public static class InitObj {
@Schema(title = "서버현재시간 (Unix-Time / 시간동기화 용)")
private Date current;
@Schema(title = "지원언어 (KO_KR / EN_US)")
private String locale;
@Schema(title = "서버 인코딩(UTF-8)")
private String encoding;
@Schema(title = "액세스토큰 만료기간")
private Long expirecon;
@Schema(title = "암호동기화용 토큰")
private String check;
}
@Getter @Setter @ToString
public static class AuthDetail {
private AuthExtra extraInfo;
}
@Getter @Setter @ToString
public static class AuthExtra {
private String ipAddr;
private String userNm;
}
}
3-5. 공통 WEB-UTIL (WebUtils.java
)
- 공통으로 사용할 WEB-UTIL 을 작성한다 (원격지IP 등
request
분석정보,response
가공 등의 역할)
package my.was.mywas.commons;
import static com.ntiple.commons.ConvertUtil.capitalize;
import static com.ntiple.commons.ConvertUtil.cast;
import static com.ntiple.commons.ConvertUtil.cat;
import static com.ntiple.commons.ConvertUtil.EMPTY_CLS;
import static com.ntiple.commons.ConvertUtil.EMPTY_OBJ;
import static com.ntiple.commons.ConvertUtil.isPrimeType;
import static my.was.mywas.commons.Constants.AUTHORIZATION;
import static my.was.mywas.commons.Constants.BEARER;
import static my.was.mywas.commons.Constants.REFERER;
import static my.was.mywas.commons.Constants.X_FORWARDED_FOR;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.SecureOut;
@Slf4j
public class WebUtils {
static final String PTN_SCHEM_HTTP = "^http[s]{0,1}[:][/][/]";
/** 토큰정보 읽어오기 */
public static String getAuthToken() { return getAuthToken(curRequest()); }
public static String getAuthToken(HttpServletRequest req) {
String hval = req.getHeader(AUTHORIZATION);
log.trace("AUTH-HEADER:{}", hval);
if (hval != null && hval.startsWith(BEARER) && hval.length() > BEARER.length() + 2) {
return hval.substring(BEARER.length() + 1);
}
return null;
}
/** 현재 Request 객체 읽어오기 */
public static HttpServletRequest curRequest() {
return (cast(RequestContextHolder.getRequestAttributes(), ServletRequestAttributes.class))
.getRequest();
}
/** 현재 Response 객체 읽어오기 (SecurityConfig.AuthFilter 에서 저장된 Response) */
public static HttpServletResponse curResponse() { return curResponse(curRequest()); }
public static HttpServletResponse curResponse(HttpServletRequest request) {
if (request != null) {
try {
Object obj = request.getAttribute(HttpServletResponse.class.getName());
if (obj != null) {
if (obj instanceof HttpServletResponse) {
return cast(obj, HttpServletResponse.class);
}
}
} catch (Exception ignore) { }
}
return null;
}
/** 원격지 IP주소 읽어오기 (앞단에 웹서버Proxy 가 있을수 있으므로 X-Forward-For 헤더까지 읽어본다) */
public static String remoteAddr() { return remoteAddr(curRequest()); }
public static String remoteAddr(HttpServletRequest req) {
String ret = null;
ret = req.getHeader(X_FORWARDED_FOR);
if (ret == null) { ret = req.getHeader("Proxy-Client-IP"); }
if (ret == null) { ret = req.getHeader("WL-Proxy-Client-IP"); }
if (ret == null) { ret = req.getHeader("HTTP_CLIENT_IP"); }
if (ret == null) { ret = req.getHeader("HTTP_X_FORWARDED_FOR"); }
if (ret == null) { ret = req.getRemoteAddr(); }
return ret;
}
/** 호출지 (Referer) 읽어오기 */
public static String referer() { return referer(curRequest()); }
public static String referer(HttpServletRequest req) {
String ret = null;
ret = req.getHeader(REFERER);
return ret;
}
/** Requet URL 에서 URI 분리 */
public static String getUri(String urlStr, List<String> hostNames) {
String ret = "";
if (urlStr == null || "".equals(urlStr)) { return ret; }
urlStr = urlStr.trim().replaceAll(PTN_SCHEM_HTTP, "").trim();
if (hostNames != null) {
LOOP:
for (String hostName : hostNames) {
if (urlStr.startsWith(hostName)) {
ret = urlStr.substring(hostName.length());
break LOOP;
}
}
}
return ret;
}
/** @SecureOut 으로 어노테이션 된 필드는 RestResponse 에서 삭제되도록 처리 */
public static <T> T secureOut(T prm) {
if (prm == null) { return prm; }
T ret = prm;
Class<?> cls = prm.getClass();
if (isPrimeType(cls)) { return prm; }
log.trace("CHECK1:{} : {}", cls.getSimpleName(), cls.isAnnotationPresent(SecureOut.class));
if (cls.isAnnotationPresent(SecureOut.class)) {
Field[] fields = cls.getDeclaredFields();
for (Field field : fields) {
log.trace("CHECK2:{}.{} : {}", cls.getSimpleName(), field.getName(), field.isAnnotationPresent(SecureOut.class));
if (field.isAnnotationPresent(SecureOut.class)) {
try {
Method setter = cls.getDeclaredMethod(cat("set", capitalize(field.getName())), field.getType());
if (setter != null) {
setter.invoke(prm, new Object[]{ null });
}
} catch (Exception ignore) { }
} else {
Object val = null;
Method mtd = null;
try {
mtd = cls.getMethod(cat("get", capitalize(field.getName())), EMPTY_CLS);
if (mtd != null) { val = mtd.invoke(prm, EMPTY_OBJ); }
} catch (Exception e) {
log.debug("E:{}", e);
}
if (val != null) {
if (val instanceof List) {
List<?> list = cast(val, list = null);
for (Object itm : list) {
secureOut(itm);
}
} else if (val instanceof Map) {
Map<String, Object> map = cast(val, map = null);
for (String key : map.keySet()) {
secureOut(map.get(key));
}
} else {
secureOut(val);
}
}
}
}
}
return ret;
}
}
3-6. 공통 Response (RestResponse.java
)
- REST-RESPONSE 를 간편하게 사용할 수 있도록 해 주는 유틸을 작성한다
package my.was.mywas.commons;
import static com.ntiple.commons.ConvertUtil.cast;
import static com.ntiple.commons.ConvertUtil.cat;
import static my.was.mywas.commons.Constants.AUTHORIZATION;
import static my.was.mywas.commons.Constants.BEARER;
import static my.was.mywas.commons.Constants.CONTENT_TYPE;
import static my.was.mywas.commons.Constants.CTYPE_HTML;
import static my.was.mywas.commons.WebUtils.curRequest;
import static my.was.mywas.commons.WebUtils.remoteAddr;
import static my.was.mywas.commons.WebUtils.secureOut;
import java.util.Map;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import com.ntiple.commons.CryptoUtil.AES;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import my.was.mywas.commons.CommonEntity.AuthResult;
import my.was.mywas.commons.CommonEntity.InitObj;
@Slf4j @Component
public class RestResponse {
private static RestResponse instance;
@Autowired SystemSettings settings;
@PostConstruct public void init() {
log.trace("INIT:{}", RestResponse.class);
instance = this;
}
public static <T> ResponseEntity<T> response(Callable<T> exec) {
HttpHeaders hdrs = new HttpHeaders();
HttpStatus status = HttpStatus.OK;
SystemSettings settings = instance.settings;
Object res = null;
try {
HttpServletRequest req = curRequest();
String ipaddr = remoteAddr(req);
String uri = req.getRequestURI();
log.trace("IP:{} / URI:{} / IS-ADMIN:{} / USER-ID:{} / EXTRA:{}", ipaddr, uri);
res = exec.call();
log.trace("RES:{}", res);
if (res instanceof String) {
/** 문자열 결과인 경우 바로 리턴한다. */
hdrs.add(CONTENT_TYPE, CTYPE_HTML);
ResponseEntity<T> ret = cast(
new ResponseEntity<>(res, hdrs, status), ret = null);
return ret;
} else if (res instanceof AuthResult) {
AuthResult ares = cast(res, ares = null);
Long exptAcc = settings.getExprAcc();
Long exptRef = settings.getExprRef();
String encstr = "";
log.debug("ARES:{}", ares);
if (ares.getAccessToken() != null) {
if (ares.getRefreshToken() != null) {
try {
/** 응답헤더에 사용자ID, 토큰정보등을 암호화 하여 내려보낸다. (공백구분) */
encstr = AES.encrypt(settings.getKeySecret(), cat(
ares.getUserId(), " ", ares.getUserNm(), " ",
ares.getAccessToken(), " ", ares.getRefreshToken(), " ",
exptAcc, " ", exptRef, " "
));
} catch (Exception e) {
log.debug("E:{}", e.getMessage());
}
hdrs.add(AUTHORIZATION, cat(BEARER, " ", encstr));
} else {
try {
/** 토큰리프레시 인 경우 액세스토큰만 발급해서 내려보낸다 */
encstr = AES.encrypt(settings.getKeySecret(), cat(
ares.getUserId(), " ", ares.getUserNm(), " ",
ares.getAccessToken(), " ", exptAcc)
);
} catch (Exception e) {
log.debug("E:{}", e.getMessage());
}
hdrs.add(AUTHORIZATION, cat(BEARER, " ", encstr));
}
}
} else if (res instanceof InitObj) {
InitObj ires = cast(res, ires = null);
log.trace("CHECK:{}", ires.getCheck());
}
} catch (Exception e) {
if (e instanceof ApiException) {
// log.debug("ERROR:{}{}{}", e.getMessage(), C.CRNL, errstack(e));
ApiException ee = cast(e, ApiException.class);
if (ee.status != null) {
status = ee.status;
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
res = Map.of("message", ee.errmsg);
} else {
log.debug("ERROR:", e);
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
}
ResponseEntity<T> ret = cast(new ResponseEntity<>(secureOut(res), hdrs, status), ret = null);
log.debug("RESULT-OK:{}", hdrs);
return ret;
}
public static void log(String format, Object... args) {
log.debug(format, args);
}
}
3-7. JWT 토큰 발급기 (TokenProvider.java
)
- JWT 토큰 발급기를 작성한다 (추후 SecurityConfig.java 에서 호출)
package my.was.mywas.commons;
import static com.ntiple.commons.ConvertUtil.cast;
import static com.ntiple.commons.ConvertUtil.cat;
import static com.ntiple.commons.ConvertUtil.EMPTY_OBJ;
import static my.was.mywas.commons.Constants.AUTH;
import static my.was.mywas.commons.Constants.EXTRA_INFO;
import static my.was.mywas.commons.Constants.TOK_TYP_REF;
... 중략 ...
@Slf4j @Component
public class TokenProvider {
@Autowired SystemSettings settings;
/** 엑세스토큰 */
Key keyAcc;
/** 리프레시토큰 */
Key keyRef;
@PostConstruct public void init () {
this.keyAcc = Keys.hmacShaKeyFor(Decoders.BASE64.decode(settings.getSecretAcc()));
this.keyRef = Keys.hmacShaKeyFor(Decoders.BASE64.decode(settings.getSecretRef()));
}
/** 토큰생성 (토큰타입이 액세스/리프레시 인지 여부에 따라 다른 KEY 를 사용한다) */
public String createToken(String tokenType, Authentication auth, Object extraInfo) throws Exception {
/** Authentication -> Token */
Key key = keyAcc;
long expire = settings.getExprAcc();
switch (tokenType) {
case TOK_TYP_REF: {
key = keyRef;
expire = settings.getExprRef();
} break;
default: break;
}
return createToken(key, expire, auth, extraInfo);
}
public String createToken(Key key, long expire, Authentication auth, Object extraInfo) throws Exception {
String authorities = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.setSubject(auth.getName())
.claim(AUTH, authorities)
.claim(EXTRA_INFO, extraInfo)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
}
/** 인증정보읽어오기 */
public Authentication getAuth(String tokenType, String token, HttpServletRequest req) {
/** Token -> Authentication */
Claims claims = null;
claims = parseToken(tokenType, token, req);
log.debug("EXPIRE-LEFT:{}s", (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000);
return getAuth(claims);
}
public Authentication getAuth(Claims claims) {
Collection<SimpleGrantedAuthority> authorities = null;
User principal = null;
if (claims != null) {
/**
* 디비를 거치지 않고 토큰에서 값을 꺼내 바로 시큐리티 유저 객체를
* 만들어 Authentication을 만들어 반환하기에 유저네임, 권한 외 정보는 알 수 없다.
*/
authorities =
Arrays.stream(claims.get(AUTH).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
principal = new User(claims.getSubject(), "", authorities);
} else {
principal = new User(null, "", null);
}
UsernamePasswordAuthenticationToken ret =
new UsernamePasswordAuthenticationToken(principal, "", authorities);
ret.setDetails(claims);
return ret;
}
/** 토큰 복호화 분석 */
public Claims parseToken(String tokenType, String token, HttpServletRequest req) {
Claims ret = null;
Object o = null;
String att = cat(tokenType, token);
if ((o = req.getAttribute(att)) == null) {
try {
Key key = keyAcc;
if (tokenType == TOK_TYP_REF) { key = keyRef; }
JwtParser parser = Jwts.parserBuilder().setSigningKey(key).build();
Jws<Claims> jws = parser.parseClaimsJws(token);
ret = jws.getBody();
req.setAttribute(att, ret);
} catch (Exception e) {
/** 토큰리프레시의 경우 토큰타입이 다르기때문에 항상 실패함 */
log.trace("FAIL TO PARSE:{} / {} / {}", e.getMessage(), tokenType, token);
req.setAttribute(att, EMPTY_OBJ);
}
log.trace("JWT-PARSE:{}", ret);
} else if (o != EMPTY_OBJ) {
ret = cast(o, ret);
}
return ret;
}
}
- 지금까지 추가된 공통 파일들이다
src/main/java/commons [dir]
├── ApiException.java (공통 Exception)
├── CommonEntity.java (공통 DTO Entity)
├── Constants.java (공통 상수)
├── RestResponse.java (공통 RestResponse 유틸)
├── SystemSettings.java (공통 설정 유틸)
├── TokenProvider.java (토큰발급기)
└── WebUtils.java (공통 WEB-UTIL)
- 이제 작성된 공통유틸을 사용해 아래와 같이 코딩이 가능하다 (아래는 컨트롤러 예시)
... 중략 ...
import static kr.kcdf.commons.RestResponse.response;
... 중략 ...
@Slf4j @RestController
@RequestMapping("/api/user")
public class Cmn01001Controller {
static final String CONTROLLER_TAG = "사용자 API";
... 중략 ...
/** OPEN-API 용 어노테이션 */
@Operation(summary = "사용자검색", tags = { CONTROLLER_TAG })
@PostMapping("/searchUser")
public ResponseEntity<List<User>> searchUser(SearchEntity<?> prm) {
/**
* RestResponse.response 를 사용시
* - 오류가 발생하는 경우 자동으로 오류처리 하여 메시지를 출력한다.
* - 리턴값이 @SecureOut 이 지정되어있고 @SecureOut 으로 지정된 필드가 있다면 삭제되어 출력된다 (개인정보 출력제한 등)
**/
return response(() -> service.findCode(prm));
}
}
4. 이번편 마무리 및 레포트 분량 재분배.....
이번편에 비즈니스로직까지 완료하려 했으나 분량조절 실패다....
(스터디때도 분량조절 실패하더만. 여전히..... 쿨럭..!)
최초 목적은 정말 가볍게 정리할 생각이었는데....
아무래도 욕심이 과했던 듯 하다.
그래서 백엔드 레포트 는 2편 정도로,
프론트엔드 레포트는 3편 정도로 분량을 재분배 할 계획이다.
고로 다음편은 비즈니스 로직 정리하고 백엔드 (WAS) 마무리 예정이다.
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403