개발 가이드
X2BEE 개발 환경을 제공하기 위한 자료입니다.
1. 사용 라이브러리 정보
명칭 | 버전 | 용도 |
---|---|---|
JRE System Library | 1.8 | 웹 어플리케이션 구동에 필요한 자바 런타임 라이브러리 |
Spring Boot Framework | 5.3.9 | 서버 로직 전반을 담당하는 프레임워크 |
Mybatis-spring-boot-starter | 2.5.3 | 데이터베이스 연결 및 쿼리 처리를 위한 라이브러리 |
Logback | 로깅을 위한 라이브러리 | |
thymeleaf-spring | 3.0.12 | 컨트롤러가 전달하는 데이터를 VIEW에 표시해주는 라이브러리 |
lombok | 1.18.20 | 반복되는 메소드를 Annotation을 사용해서 자동으로 작성해주는 라이브러리 |
lucy-xss-servlet | 2.0.1 | XSS 방지 필터 |
2. 프로젝트 패키지 구조
1) src/main/java
프레임워크 공통 클래스 패키지: 예외처리, DB관련 속성, 보안, 유틸 클래스 포함
API 관련 비즈니스 로직 클래스 패키지: Controller, Service, Dao, Entity 클래스
프레임워크 관련 로직 클래스 패키지: RootController, User, Role 관련 설정 클래스
설정 관련 로직 클래스 패키지: 프로젝트 관련 설정 클래스
2) src/main/resources
프레임워크 설정 패캐지: dev/local 별 datasource 설정, 로깅 설정, 기타 설정 파일
API 관련 쿼리 패키지: 프로젝트 내에서 사용될 쿼리 파일
View 템플릿 패키지: Front-end html 파일과 error html 파일
기타 설정 패키지: 프로젝트 전체 appliation.yml 파일 및 설정 파일
3. 데이터베이스 연결, 다중 데이터베이스 연결
1) application.yml 파일 내 port 및 DB 관련 설정 추가
아래와 같이 application.yml 파일에서 서버 port번호와 데이터베이스 관련 설정 명시
단일연결
.... server: port: 8080 spring: config: activate: on-profile: local devtools: livereload: port: 3${server.port} datasource: url: jdbc:log4jdbc:postgresql://xxxxx.xxxxxx.xxx:55005/{DatabaseName}?currentSchema={SchemaName} driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy username: {userName} password: ****************** hikari: maximum-pool-size: 3 minimum-idle: 3 connection-timeout: 30000 validation-timeout: 5000 max-lifetime: 1800000 idle-timeout: 300000 session: store-type: none zipkin: enabled: false .... |
다중연결
.... server: port: 8097 servlet: context-path: /api/bo spring: config: activate: on-profile: local zipkin: enabled: false devtools: livereload: port: 3${server.port} displayrodb: datasource: url: jdbc:log4jdbc:postgresql://xxxxx.xxxxxx.xxx:55005/{DatabaseName}?currentSchema={SchemaName} driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy username: {userName} password: ****************** hikari: maximum-pool-size: 5 minimum-idle: 3 connection-timeout: 30000 validation-timeout: 5000 max-lifetime: 1800000 idle-timeout: 300000 displayrwdb: datasource: url: jdbc:log4jdbc:postgresql://xxxxx.xxxxxx.xxx:55005/{DatabaseName}?currentSchema={SchemaName} driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy username: {userName} password: ****************** hikari: maximum-pool-size: 5 minimum-idle: 3 connection-timeout: 30000 validation-timeout: 5000 max-lifetime: 1800000 idle-timeout: 300000 .... |
프로퍼티명 | 설명 |
---|---|
port | tomcat 포트 |
url | 데이터베이스 접속 URL |
username | 데이터베이스 사용자 아이디 |
password | 데이터베이스 사용자 비밀번호 |
driveClassName | 데이터베이스 드라이버 클래스 명 |
hikari | 기타 |
session | 스프링 session 설정 |
zipkin | MSA 환경에서 분산 트렌젝션의 추적 |
2) *******DatabaseConfig.java 파일 작성
/** * */ package com.x2bee.api.bo.base.config; import javax.sql.DataSource; import org.apache.ibatis.session.SqlSessionFactory; import org.mybatis.spring.SqlSessionFactoryBean; import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; @Configuration @MapperScan(value="com.x2bee.api.bo.app.repository.displayrodb", sqlSessionFactoryRef="displayRodbSqlSessionFactory") public class DisplayRodbDatabaseConfig { @Bean(name = "displayRodbDataSource") @ConfigurationProperties(prefix = "spring.displayrodb.datasource") public DataSource displayRodbDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "displayRodbSqlSessionFactory") public SqlSessionFactory displayRodbSqlSessionFactory(@Qualifier("displayRodbDataSource") DataSource displayRodbDataSource, ApplicationContext applicationContext) throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(displayRodbDataSource); sqlSessionFactoryBean.setTypeAliasesPackage("com.x2bee.api.bo.app"); sqlSessionFactoryBean.setMapperLocations(applicationContext.getResources("classpath:mapper/displayrodb/**/*.xml")); sqlSessionFactoryBean.setConfigLocation(applicationContext.getResource("classpath:mapper/mybatis-config.xml")); return sqlSessionFactoryBean.getObject(); } @Bean(name = "displayRodbSqlSessionTemplate") public SqlSessionTemplate displayRodbSqlSessionTemplate(SqlSessionFactory displayRodbSqlSessionFactory) throws Exception { return new SqlSessionTemplate(displayRodbSqlSessionFactory); } ... |
3) mybatis-config.xml 파일 작성
데이터베이스 데이터 마스킹, 암호화 관련 java 파일 연결
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true" /> </settings> <plugins> <plugin interceptor="com.x2bee.api.bo.base.masking.MybatisMaskingInterceptor"/> <plugin interceptor="com.x2bee.common.base.encrypt.MybatisEncryptInterceptor"/> </plugins> </configuration> |
4) 데이터베이스 데이터 마스킹, 암호화 관련 java파일 작성
package com.x2bee.api.bo.base.masking; import java.lang.reflect.Field; import java.sql.Statement; import java.util.ArrayList; import java.util.Objects; import java.util.Properties; import org.apache.ibatis.executor.resultset.ResultSetHandler; import org.apache.ibatis.plugin.Interceptor; import org.apache.ibatis.plugin.Intercepts; import org.apache.ibatis.plugin.Invocation; import org.apache.ibatis.plugin.Plugin; import org.apache.ibatis.plugin.Signature; import org.springframework.beans.factory.annotation.Autowired; import com.x2bee.api.bo.app.service.common.AdminCommonService; import com.x2bee.common.base.context.ApplicationContextWrapper; import com.x2bee.common.base.masking.MaskString; /** * Result interceptor */ @Intercepts(@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = { Statement.class })) public class MybatisMaskingInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { BoApiMaskingUtils maskingUtils = (BoApiMaskingUtils)ApplicationContextWrapper.getBean("boApiMaskingUtils"); Object result = invocation.proceed(); if (Objects.isNull(result)){ return null; } if (result instanceof ArrayList) { ArrayList<?> resultList = (ArrayList<?>) result; for (int i = 0; i < resultList.size(); i++) { Field[] fields = resultList.get(i).getClass().getDeclaredFields(); for (Field field : fields) { MaskString annotation = field.getAnnotation(MaskString.class); if(annotation!=null && field.getType() == String.class) { field.setAccessible(true); String val = maskingUtils.getValue(field.get(resultList.get(i))+"", annotation.type()); try { field.set(resultList.get(i), val); } catch (IllegalAccessException e) { System.out.println(e.getMessage()); } } } } }else { Field[] fields = result.getClass().getDeclaredFields(); for (Field field : fields) { MaskString annotation = field.getAnnotation(MaskString.class); if(annotation!=null && field.getType() == String.class) { field.setAccessible(true); String val = maskingUtils.getValue(field.get(result)+"", annotation.type()); try { field.set(result, val); }catch (IllegalAccessException e) { System.out.println(e.getMessage()); } } } } return result; } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } } |
/** * */ package com.x2bee.common.base.encrypt; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.sql.Statement; import java.util.ArrayList; import java.util.Objects; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.executor.resultset.ResultSetHandler; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.Interceptor; import org.apache.ibatis.plugin.Intercepts; import org.apache.ibatis.plugin.Invocation; import org.apache.ibatis.plugin.Signature; import lombok.extern.slf4j.Slf4j; /** * @author choiyh44 * @version 1.0 * @since 2021. 12. 6. * */ @Slf4j @Intercepts({ @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class }), @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = { Statement.class }) }) public class MybatisEncryptInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { String method = invocation.getMethod().getName(); if ("update".equals(method)) { return processUpdate(invocation); } else if ("handleResultSets".equals(method)) { return processQuery(invocation); } else { return invocation.proceed(); } } private Object processUpdate(Invocation invocation) throws InvocationTargetException, IllegalAccessException { Object[] args = invocation.getArgs(); Object param = args[1]; if (param != null) { Field[] fields = param.getClass().getDeclaredFields(); for (Field field : fields) { Encrypt annotation = field.getAnnotation(Encrypt.class); if(annotation!=null && field.getType() == String.class) { field.setAccessible(true); try { String val = EncryptUtils.getEncryptValue(field.get(param)+"", annotation.type()); log.info("EncryptValue: {}: {}", val.length(), val); field.set(param, val); } catch (Exception e) { log.warn(e.getMessage(), e); } } } } return invocation.proceed(); } private Object processQuery(Invocation invocation) throws InvocationTargetException, IllegalAccessException { Object result = invocation.proceed(); if (Objects.isNull(result)){ return null; } if (result instanceof ArrayList) { ArrayList<?> resultList = (ArrayList<?>) result; for (int i = 0; i < resultList.size(); i++) { Field[] fields = resultList.get(i).getClass().getDeclaredFields(); for (Field field : fields) { Encrypt annotation = field.getAnnotation(Encrypt.class); if(annotation!=null && field.getType() == String.class) { field.setAccessible(true); try { String val = EncryptUtils.getDecryptValue(field.get(resultList.get(i))+"", annotation.type()); field.set(resultList.get(i), val); } catch (Exception e) { System.out.println(e.getMessage()); } } } } }else { Field[] fields = result.getClass().getDeclaredFields(); for (Field field : fields) { Encrypt annotation = field.getAnnotation(Encrypt.class); if(annotation!=null && field.getType() == String.class) { field.setAccessible(true); try { String val = EncryptUtils.getDecryptValue(field.get(result)+"", annotation.type()); field.set(result, val); }catch (Exception e) { System.out.println(e.getMessage()); } } } } return result; } } |
5. 로깅 설정
1) 로깅 관련 프로퍼티 설정
로깅 파일 저장 위치 설정
로깅 레벨 설정
라이트 로깅 여부 설정 (최소한의 정보만 로깅)
로그에 컨트롤러 파라미터를 포함할지 여부 설정
로그에 서비스 파라미터를 포함할지 여부 설정
logging.destination=C://log logging.level=INFO logging.lightLogging=false logging.includeControllerParameter=true logging.includeServiceParameter=false |
2) logback 관련 설정
src/main/resources/{} 폴더 하위 loback.xml 파일 설정
consoleAppender: 시스템 콘솔에 찍히는 로그 정보
fileAppender: 파일에 쓰여질 로그 정보
asyncConsoleAppender: 로깅 작업이 성능에 많은 영향을 주는 경우, 비동기로 설정하여 로깅 부하를 최소화 (콘솔 로깅 비동기 처리 설정)
asyncFileAppender: 로깅 작업이 성능에 많은 영향을 주는 경우, 비동기로 설정하여 로깅 부하를 최소화 (파일 로깅 비동기 처리 설정)
<?xml version="1.0" encoding="UTF-8" ?> <configuration scan="true" scanPeriod="30 seconds"> <springProperty scope="context" name="activeProfile" source="spring.profiles.active"/> <property resource="config/dev/properties/logging.properties"/> <property name="loggingPattern" value="%d{HH:mm:ss.SSS} %-5level [%thread] %replace([RID:%X{rid}]){'(\\[RID:\\])', ''} %replace([SID:%X{sid}]){'(\\[SID:\\])', ''} %replace([USER:%X{user}]){'(\\[USER:\\])', ''} %logger{36} - %msg[END]%n" /> <!-- console log appender --> <appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender"> <layout class="ch.qos.logback.classic.PatternLayout"> <Pattern>${loggingPattern}</Pattern> </layout> </appender> <!-- log file appender --> <appender name="fileAppender" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${logging.destination}/debug.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- Daily Rollover --> <fileNamePattern>${logging.destination}/debug.%d{yyyy-MM-dd}_[%i].log</fileNamePattern> <!-- File Size 300Mb --> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>300MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> <!-- Keep 30 days --> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>${loggingPattern}</pattern> </encoder> </appender> <!-- 비동기로 console에 추가 로깅 --> <appender name="asyncConsoleAppender" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>500</queueSize> <appender-ref ref="consoleAppender" /> </appender> <!-- 비동기로 file에 추가 로깅 --> <appender name="asyncFileAppender" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>500</queueSize> <appender-ref ref="fileAppender" /> </appender> <logger name="jdbc" level="OFF"/> <logger name="jdbc.sqlonly" level="OFF"/> <logger name="jdbc.sqltiming" level="DEBUG"/> <logger name="jdbc.audit" level="OFF"/> <logger name="jdbc.resultset" level="OFF"/> <logger name="jdbc.resultsettable" level="OFF"/> <logger name="jdbc.connection" level="OFF"/> <root level="${logging.level}"> <appender-ref ref="consoleAppender" /> <appender-ref ref="fileAppender" /> <!-- 비동기 logging --> <!-- <appender-ref ref="asyncConsoleAppender" /> <appender-ref ref="asyncFileAppender" /> --> </root> </configuration> |
3) 로깅 추가 정보 설정
로깅에 추가로 보여주고자 하는 정보 설정 (src/main/java/com/plateer/base/log 폴더 하위 ExecLoggingFilter.java 파일 수정)
아래 가이드는 RequestID 와 SessionID 를 설정하는 예제
package com.plateer.base.filter; import java.io.IOException; import java.util.UUID; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import org.slf4j.MDC; import org.slf4j.MDC.MDCCloseable; import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; @Slf4j @Component public class ExecLoggingFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void destroy() { } @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { try { HttpServletRequest request = (HttpServletRequest) req; // request id setting MDC.put("rid", getRequestId(request)); // session id setting MDC.put("sid", getSessionId(request)); chain.doFilter(req, res); } finally { // mdc remove MDC.clear(); } } private String getRequestId(HttpServletRequest request) { return request.getRequestURI() + "-" + UUID.randomUUID().toString(); } private String getSessionId(HttpServletRequest request) { String sessionId = request.getRequestedSessionId(); return sessionId != null ? request.getRequestedSessionId() : "?????"; } } |
Request 시 ReqeustID, SessionID 를 생성하고 제거하며, MDC 를 활용하여 로그 출력
6. 쿼리 로깅 설정
log4jdbc 를 이용한 쿼리 로깅 방법
1) log4jdbc 설정 파일 추가
resource 폴더 하위 아래 파일 추가
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator log4jdbc.dump.sql.maxlinelength=0 |
2) 데이터베이스 연결 정보 수정
driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy 추가
url 수정 (log4jdbc 추가)
x2base.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy x2base.url=jdbc:log4jdbc:oracle:thin:@218.38.15.94:1521:ORAX2CO x2base.username=X2BASE x2base.password=ENC(cDiQFk2pHBC7aep3NKNDl4QteXJLwa5XOHfkm4oR+vEdo9SBz52xelxEio3snTwR) |
3) logback.xml 파일 수정
아래 설정 내용 추가
<!-- log4jdbc 옵션 설정 --> <logger name="jdbc" level="OFF"/> <!-- 커넥션 open close 이벤트 로그로 남김 --> <logger name="jdbc.connection" level="OFF"/> <!-- SQL문만을 로그로 남기며, PreparedStatement일 경우 관련된 argument 값으로 대체된 SQL문이 보여짐 --> <logger name="jdbc.sqlonly" level="OFF"/> <!-- SQL문과 해당 SQL을 실행시키는데 수행된 시간 정보(milliseconds)를 포함 --> <logger name="jdbc.sqltiming" level="DEBUG"/> <!-- ResultSet을 제외한 모든 JDBC 호출 정보를 로그로 남김. 방대한 양의 로그가 생성되므로 특별히 JDBC 문제를 추적해야 할 필요가 있는 경우를 제외하고는 사용을 권장하지 않음--> <logger name="jdbc.audit" level="OFF"/> <!-- ResultSet을 포함한 모든 JDBC 호출 정보를 로그로 남기므로 방대한 양의 로그가 생성됨 --> <logger name="jdbc.resultset" level="OFF"/> <!-- SQL 결과 조회된 데이터의 table을 로그로 남김 --> <logger name="jdbc.resultsettable" level="DEBUG"/> |
7. 클라이언트, 서버 간 통신 데이터 형식
Map 형태가 아닌 VO(Value Object) 로 데이터 통신하는 것을 기본으로 함
아래 사용자 정보 처리 관련 VO 예제 참고
package com.plateer.x2co.common.entity; import java.util.Date; import lombok.Data; @Data public class User { private String usrId; private String usrNm; private String usrGrp; private String useYn; private String mobileNo; private String phoneNo; private String email; private String pw; private Date pwModDt; private String mailReceivedYn; private String smsReceivedYn; private String pwMissCnt; private String pwInitYn; private Date lastLoginDt; private String lastLoginIp; private String accExpireYn; private String accLockYn; private String pwModRequireYn; } |
8. 서버 측 개발 방식
Controller, Service, Mapper 구조
서버는 Spring MVC 패턴 기반이므로 데이터 처리를 위하여 다음과 같이 Controller, Service, Mapper 구조로 진행됨
http request → (mapping) → Controller → Service → ServiceImpl → Mapper → query 호출 순서로 진행됨
1) VO 클래스 작성
src/main/java 하위 해당 업무 폴더에 entity 폴더 생성 후 아래와 같이 작업 진행
데이터 전달을 위하여 사용될 객체 사전 정의
package com.plateer.x2co.backoffice.sample.entity; import lombok.Data; @Data public class SampleVO{ String itemNo; String salePrc; String mbrNo; .... } |
2) Controller 클래스 작성
src/main/java 하위 해당 업무 폴더에 Controller 폴더 생성 후 아래와 같이 작업 진행
@RestController 어노테이션으로 Controller 임을 명시
@RequestMapping 어노테이션으로 해당 Controller 전체에 대한 Http Request 매핑 정보 명시
@Autowired 어노테이션으로 service 인터페이스 주입
package com.plateer.x2co.backoffice.sample.controller; @RestController @RequestMapping("/rest") public class SampleController { @Autowired private SampleService sampleService ; @PostMapping(value="/categories", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public ReponseEntity<List<SampleVO>> getPrDispCatList(@RequestBody String id) { List<SampleVO> listMap = prototypeServcie.getPrDispCatList(id); return sampleService.getPrDispCatList(id); } } |
3) Service 클래스 작성
src/main/java 하위 해당 업무 폴더에 Service 폴더 생성 후 아래와 같이 작업 진행
구현하고자 하는 업무 로직의 인터페이스 작성
package com.plateer.x2co.backoffice.sample.service; public interface SampleService { public ReponseEntity<List<SampleVO>> getPrDispCatList(String id); } |
위 인터페이스의 실제 구현체 작성
@Service 어노테이션으로 서비스 클래스 임을 명시
@Autowired 어노테이션으로 관련 Mapper 클래스 주입
package com.plateer.x2co.backoffice.sample.service; @Service public class SampleServiceImpl implements SampleService { @Autowired private SampleMapper sampleMapper ; @Override public ReponseEntity<List<SampleVO>> getPrDispCatList(String id) { return ResponseEntity.ok(sampleMapper.getPrDispCatList(id)); } } |
4) Mapper 클래스 및 Query 작성
src/main/java 하위 해당 업무 폴더에 dao 폴더 생성 후 아래와 같이 작업 진행
@Mapper 어노테이션을 이용하여 Mapper 임을 명시
package com.plateer.x2co.backoffice.sample.dao; @Mapper public interface SampleMapper { public List<SampleVO> getPrDispCatList(String id); } |
src/main/resource 하위에 해당 쿼리 작성
namespace 에 위 Mapper 클래스 명시
resultType 혹은 parameterType 에 데이터 객체 명시
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.plateer.x2co.api.backoffice.sample.dao.SampleMapper"> <!-- 카테고리 계층 목록 조회 --> <select id="getPrDispCatList" resultType="com.plateer.x2co.backoffice.sample.entity.SampleVO"> /* prDispCatBase.getPrDispCatList */ SELECT .... </select> |
위 작업 진행 시 최종 패키지 구조는 다음과 같음
9. 유효성 체크 방법
1) Bean Validation
데이터 통신을 VO 로 하므로, 다음과 같이 VO 객체에 javax.validation.constrains 패키지에서 제공하는 어노테이션을 이용
package com.plateer.x2co.api.prototype.entity; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import io.swagger.annotations.ApiParam; import lombok.Data; @Data public class Category { @NotEmpty private String catTpCd; @NotBlank private String siteNo; @NotEmpty private String dpmlNo; @NotEmpty private String dbLocaleLanguage; @NotEmpty private int maxLvl = 0; @NotEmpty private int minLvl = 0; } |
1-1) @Valid 를 이용한 @RequestBody 에 대한 유효성 검증
public class SampleController{ @PostMapping(value="categories") public ResponseEntity<?> getPrDispCatList(@Valid @RequestBody Category category) { .... } } |
1-2) @Validated 를 이용한 @PathVariable과 @RequestParam 에 대한 유효성 검증
@RestController @Validated public class SampleController{ @GetMapping("/users/{email}") public String getUserInfoByEmail(@PathVariable("email") @Email String email) { .... } } |
2) Custom Validator
javax.validation.constrains 패키지에서 제공되지 않는 validator 를 구현하고자 하는 경우, 본 방법으로 진행
2-1) Custom annotation 생성
import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; @Target({FIELD}) @Retention(RUNTIME) @Constraint(validatedBy = LocaleValidator.class) @Documented public @interface LocaleConstraint { String message() default "Invalid Locale"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } |
2-2) Custom annotation 을 처리할 Custom Validator 생성
public class LocaleValidator implements ConstraintValidator<LocaleConstraint, String>{ public static final List<String> locales= Arrays.asList("ko_KR", "en_US"); @Override public boolean isValid(String value, ConstraintValidatorContext context) { return value != null && locales.contains(value.toLowerCase()) ; } } |
2-3) Custom annotation @LocaleConstraint을 적용하여 유효성 검증
package com.plateer.x2co.api.prototype.entity; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import io.swagger.annotations.ApiParam; import lombok.Data; @Data public class Category { @NotEmpty private String catTpCd; @NotBlank private String siteNo; @NotEmpty private String dpmlNo; @NotEmpty @LocaleConstraint // 다음과 같이 어노테이션으로 적용 private String dbLocaleLanguage; @NotEmpty private int maxLvl = 0; @NotEmpty private int minLvl = 0; } |
10. 에러메시지 및 예외 처리
1) 에러 메시지 처리
X2Commerce 4.0 에서 사용될 에러 메시지 포맷
package com.plateer.base.exception; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import org.springframework.http.HttpStatus; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; @Data public class X2ApiError { /* * status: the HTTP status code * LocalDateTime: the local date time * message: the error message associated with exception * error: List of constructed error messages */ private HttpStatus status; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss") private LocalDateTime timestamp; private String message; private List<String> errors; public X2ApiError(HttpStatus status, String message, List<String> errors) { super(); this.status = status; this.message = message; this.timestamp = LocalDateTime.now(); this.errors = errors; } public X2ApiError(HttpStatus status, String message, String error) { super(); this.status = status; this.message = message; this.timestamp = LocalDateTime.now(); errors = Arrays.asList(error); } } |
오류 발생 시 아래와 같이 응답 (HTTP Response code 는 헤더에 포함되어 있기 때문에 추가로 전송하지 않음)
{ "status": "METHOD_NOT_ALLOWED", "timestamp": "2020-09-17 11:20:42", "message": "Request method 'PUT' not supported", "errors": [ "PUT method is not supported for this request. Supported methods are POST " ] } |
2) 예외 처리 기능 (com.plateer.base.exception 패키지 하위 X2ExceptionController 클래스 구현)
ResponseEntityExceptionHandler 확장 구현
Spring 에서 기본적으로 제공해주는 ResponseEntityExceptionHandler 를 상속 받아 Exception Handler 구현
@ControllerAdvice 어노테이션을 이용하여 Exception 을 일괄 처리
package com.plateer.base.exception; import java.util.ArrayList; import java.util.List; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @ControllerAdvice public class X2ExceptionController extends ResponseEntityExceptionHandler{ // 400 @Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { List<String> errors = new ArrayList<String>(); for (FieldError error : ex.getBindingResult().getFieldErrors()) { errors.add(error.getField() + ": " + error.getDefaultMessage()); } for (ObjectError error : ex.getBindingResult().getGlobalErrors()) { errors.add(error.getObjectName() + ": " + error.getDefaultMessage()); } X2ApiError x2ApiError = new X2ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors); return handleExceptionInternal(ex, x2ApiError, headers, x2ApiError.getStatus(), request); } // 400 @Override protected ResponseEntity<Object> handleMissingServletRequestParameter(MissingServletRequestParameterException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { String error = ex.getParameterName() + " parameter is missing"; X2ApiError x2ApiError = new X2ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 400 @ExceptionHandler({ ConstraintViolationException.class }) public ResponseEntity<Object> handleConstraintViolation(ConstraintViolationException ex, WebRequest request) { List<String> errors = new ArrayList<String>(); for (ConstraintViolation<?> violation : ex.getConstraintViolations()) { errors.add(violation.getRootBeanClass().getName() + " " + violation.getPropertyPath() + ": " + violation.getMessage()); } X2ApiError x2ApiError = new X2ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 400 @ExceptionHandler({ MethodArgumentTypeMismatchException.class }) public ResponseEntity<Object> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex, WebRequest request) { String error = ex.getName() + " should be of type " + ex.getRequiredType().getName(); X2ApiError x2ApiError = new X2ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 401 @ExceptionHandler({ AuthenticationException.class }) public ResponseEntity<Object> authenticationException(AuthenticationException ex, WebRequest request) { X2ApiError x2ApiError = new X2ApiError(HttpStatus.UNAUTHORIZED, ex.getLocalizedMessage(), "error occurred"); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 403 @ExceptionHandler({ AccessDeniedException.class }) public ResponseEntity<Object> handleAccessDeniedException(AccessDeniedException ex, WebRequest request) { X2ApiError x2ApiError = new X2ApiError(HttpStatus.FORBIDDEN, ex.getLocalizedMessage(), "error occurred"); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 404 @Override protected ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { String error = "No handler found for " + ex.getHttpMethod() + " " + ex.getRequestURL(); X2ApiError x2ApiError = new X2ApiError(HttpStatus.NOT_FOUND, ex.getLocalizedMessage(), error); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 405 @Override protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { StringBuilder builder = new StringBuilder(); builder.append(ex.getMethod()); builder.append(" method is not supported for this request. Supported methods are "); ex.getSupportedHttpMethods().forEach(t -> builder.append(t + " ")); X2ApiError x2ApiError = new X2ApiError(HttpStatus.METHOD_NOT_ALLOWED, ex.getLocalizedMessage(), builder.toString()); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 409 @ExceptionHandler({ DataIntegrityViolationException.class }) public ResponseEntity<Object> handleDataIntegrityViolationException(DataIntegrityViolationException ex, WebRequest request) { X2ApiError x2ApiError = new X2ApiError(HttpStatus.CONFLICT, ex.getLocalizedMessage(), "error occurred"); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 415 @Override protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { StringBuilder builder = new StringBuilder(); builder.append(ex.getContentType()); builder.append(" media type is not supported. Supported media types are "); ex.getSupportedMediaTypes().forEach(t -> builder.append(t + ", ")); X2ApiError x2ApiError = new X2ApiError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getLocalizedMessage(), builder.substring(0, builder.length() - 2)); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } // 500, Default Handler @ExceptionHandler({ Exception.class }) public ResponseEntity<Object> handleAll(Exception ex, WebRequest request) { X2ApiError x2ApiError = new X2ApiError(HttpStatus.INTERNAL_SERVER_ERROR, ex.getLocalizedMessage(), "error occurred"); return new ResponseEntity<Object>(x2ApiError, new HttpHeaders(), x2ApiError.getStatus()); } } |