//... 생략
FROM TOMS_CO_M CM
) T1
WHERE 1 = 1
<if test='param.srchCorpCd != null and param.srchCorpCd != ""'>
AND CORP_CD = #{param.srchCorpCd}
</if>
<if test='param.srchCoCrtDtmFr != null and param.srchCoCrtDtmFr != ""'>
AND CO_CRT_DTM BETWEEN TO_DATE(#{param.srchCoCrtDtmFr}, 'yyyyMMdd') AND TO_DATE('99991231', 'yyyyMMdd')
</if>
참 성의 없는 코드다. 왜냐 ? 제목에서 처럼 1=1 를 써놨기때문이다. 물론 우리가 급식시절에 대충 게시판 하나 만들면서 CRUD 구현에 목숨걸고 있을시절에 자주 쓰고했다. 어? 그래 맞다 현재 SI 개발자들도 아니 그 흔한 고수급 스타트업 개발자들도 이런 코드를 자주 쓰더라.
그럼 오늘 이 where 1=1를 제거 해보자 케이스는 2가지가 있다.(내가 아니는 한도내에서, 또 다른 방법들이 있겠지만...)
이 쿼리의 문제 점을 생각해봐라 : 만일 corp_cd , co_crt_dtm 이런 값들이 모두 null 로 들어온다고하면? 그리고 KISA 같은 금융사같은 회사들을 대상으로 코드점검을 나오면 과대료 대상이다.
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@SpringBootConfiguration
public class WebGlobalConfig {
@Bean
public CorsFilter corsFilter() {
//CorsConfiguration 개체 생성 후 설정 추가
CorsConfiguration config = new CorsConfiguration();
//내보낼 원본 도메인 설정
config.addAllowedOrigin("*");
//원본 요청 헤더 정보 내보내기
config.addAllowedHeader("*");
//header 의 노출 정보
config.addExposedHeader("*");
//허용할 요청 항목들
config.addAllowedMethod("GET"); //get
config.addAllowedMethod("PUT"); //put
config.addAllowedMethod("POST"); //post
config.addAllowedMethod("DELETE"); //delete
//corsConfig.addAllowedMethod("*"); //모두허용
// Cookie 전송여부
config.setAllowCredentials(true);
//2. 매핑 경로 추가
UrlBasedCorsConfigurationSource corsConfigurationSource =
new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**", config);
return new CorsFilter(corsConfigurationSource);
}
}
SpringBoot2.4.4 이후 버전사용시 아래 에러 나올수 있음.
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead. at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:453) ~[spring-web-5.3.6.jar:5.3.6]
allow Credentials가 true일 때 alloed Origins는 이 값을 'Access-Control-Allow-Origin' 응답 헤드에서 설정할 수 없기 때문에 특수 값 '*'를 포함할 수 없음. 인증 정보 액세스를 허용하려면 소스를 명시적으로 나열하거나 'Allowed Origin Patterns'로 변경.
해결방법: config.addAllowedOrigin("*"); --> config.addAllowedOriginPattern("*"); 로 변경.
WebMvcConfigurer 재정의하는 방법
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@SpringBootConfiguration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
//매핑경로 추가
registry.addMapping("/**")
//Cookie 전송여부
.allowCredentials(true)
//내보낼 원본 도메인 설정 SpringBoot2.4.4 이하 버전은 .allowedOrigins("*") 사용.
.allowedOriginPatterns("*")
//요청 방식을 허용
.allowedMethods(new String[]{"GET", "POST", "PUT", "DELETE"})
//.allowedMethods("*") //요청 방식을 전부 허용
//원본 요청 헤더 정보 내보내기
.allowedHeaders("*")
//원본 요청 헤더 정보 노출
.exposedHeaders("*");
}
}
@CrossOrigin 어노테이션으로 부분적용
@Controller
@RequestMapping("/crostest")
@CrossOrigin(originPatterns = "*", methods = {RequestMethod.GET, RequestMethod.POST})
public class ShopController {
@GetMapping("/")
@ResponseBody
public Map<String, Object> findAll() {
return DataSchool.getStudents();
}
}
혹은 사용되는 각각의 메소드에 붙여줘도 된다.
@Controller
@RequestMapping("/crostest")
public class ShopController {
@GetMapping("/")
@ResponseBody
@CrossOrigin(originPatterns = "http://localhost:8080")
public Map<String, Object> findAll() {
return DataSchool.getStudents();
}
실제 프로젝트 개발 과정에서 DTO 데이터 전송 객체 및 데이터 객체 DO와 같은 소스 객체의 속성 정보를 변경하지 않고 소스 객체의 속성 정보를 기반으로 후속 작업을 수행하기 위해 서로 다른 두 객체 인스턴스를 속성으로 복사해야 하는 경우가 많으며, DO 객체의 속성을 DTO로 복사해야 하지만 객체 형식이 다르기 때문에 객체의 속성 값을 한 유형에서 다른 유형으로 변환하기 위해 매핑 코드를 작성해야 했었다.
Object 의 copy
두 Bean Utils를 구체적으로 소개하기 전에 몇 가지 기본적인 부분을 짚어넘어가 보자.두 가지 도구는 본질적으로 객체 복사 도구이고 deep coyp & shallow copy로 나뉜다.
shallow copy : 기본 데이터 유형에 대한 값 전달, 인용 데이터 유형에 대한 인용 전달과 같은 카피
deep coyp : 기본 데이터 유형에 대한 값 전달, 참조 데이터 유형에 대한 새로운 개체 생성 및 내용 복사
org.apache.commons.beanutils.BeanUtils 은 얕은 복사(shallow copy) 다. 때문에 많은 코딩규칙 Tool 혹은 Plugin 에서는 해당 클래스 사용을 권장하지 않고 있다.
commons-beantutils는 객체를 copy시 validation 과정을 거치는 데, 타입전환 을 비롯하여 객체가 속한 클래스의 접근성까지 체크하기 때문에, 상당히 복잡하다. 바로 이런 이유때문에 형편없는 성능을 보여준다.
public void copyProperties(final Object dest, final Object orig)
throws IllegalAccessException, InvocationTargetException {
// Validate existence of the specified beans
if (dest == null) {
thrownew IllegalArgumentException
("No destination bean specified");
}
if (orig == null) {
thrownew IllegalArgumentException("No origin bean specified");
}
if (log.isDebugEnabled()) {
log.debug("BeanUtils.copyProperties(" + dest + ", " +
orig + ")");
}
// Copy the properties, converting as necessary
if (orig instanceof DynaBean) {
final DynaProperty[] origDescriptors =
((DynaBean) orig).getDynaClass().getDynaProperties();
for (DynaProperty origDescriptor : origDescriptors) {
final String name = origDescriptor.getName();
// Need to check isReadable() for WrapDynaBean
// (see Jira issue# BEANUTILS-61)
if (getPropertyUtils().isReadable(orig, name) &&
getPropertyUtils().isWriteable(dest, name)) {
final Object value = ((DynaBean) orig).get(name);
copyProperty(dest, name, value);
}
}
} elseif (orig instanceof Map) {
@SuppressWarnings("unchecked")
final
// Map properties are always of type <String, Object>
Map<String, Object> propMap = (Map<String, Object>) orig;
for (final Map.Entry<String, Object> entry : propMap.entrySet()) {
final String name = entry.getKey();
if (getPropertyUtils().isWriteable(dest, name)) {
copyProperty(dest, name, entry.getValue());
}
}
} else/* if (orig is a standard JavaBean) */ {
final PropertyDescriptor[] origDescriptors =
getPropertyUtils().getPropertyDescriptors(orig);
for (PropertyDescriptor origDescriptor : origDescriptors) {
final String name = origDescriptor.getName();
if ("class".equals(name)) {
continue; // No point in trying to set an object's class
}
if (getPropertyUtils().isReadable(orig, name) &&
getPropertyUtils().isWriteable(dest, name)) {
try {
final Object value =
getPropertyUtils().getSimpleProperty(orig, name);
copyProperty(dest, name, value);
} catch (final NoSuchMethodException e) {
// Should not happen
}
}
}
}
}
대충 봐도 복잡하고 어렵다.
Spring 의 BeanUtils
아래와 같은 사용 예제코드를 보자
publicclass TestSpringBeanUtils {
public static void main(String[] args) throws InvocationTargetException, IllegalAccessException {
PersonSource personSource = new PersonSource(1, "pjmike", "12345", 21);
PersonDest personDest = new PersonDest();
BeanUtils.copyProperties(personSource,personDest);
System.out.println("persondest: "+personDest);
}
}
Spring에 내장된 BeanUtils도 copyProperties 방법을 사용하여 복사하지만 구현 방식은 매우 간단하며 두 객체의 동일한 이름의 속성에 대한 간단한 get/set을 수행하여 속성의 접근성만 확인한다. 구체적 코드를 살펴보면 다음과 같다.
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,
@Nullable String... ignoreProperties) throws BeansException {
Assert.notNull(source, "Source must not be null");
Assert.notNull(target, "Target must not be null");
Class<?> actualEditable = target.getClass();
if (editable != null) {
if (!editable.isInstance(target)) {
throw new IllegalArgumentException("Target class [" + target.getClass().getName() +
"] not assignable to Editable class [" + editable.getName() + "]");
}
actualEditable = editable;
}
PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);
List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);
for (PropertyDescriptor targetPd : targetPds) {
Method writeMethod = targetPd.getWriteMethod();
if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
if (sourcePd != null) {
Method readMethod = sourcePd.getReadMethod();
if (readMethod != null &&
ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {
try {
if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
readMethod.setAccessible(true);
}
Object value = readMethod.invoke(source);
if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
writeMethod.setAccessible(true);
}
writeMethod.invoke(target, value);
}
catch (Throwable ex) {
throw new FatalBeanException(
"Could not copy property '" + targetPd.getName() + "' from source to target", ex);
}
}
}
}
}
}
코드에서 알수 있듯이 멤버 변수 할당은 대상 객체의 멤버 목록을 기반으로 하며, ignore를 건너뛰고 원본 객체에 존재하지 않기 때문에 이 방법은 안전하다고 볼수 있다. 두 객체 간의 구조적 차이로 인한 오류는 발생하지 않지만, 같은 이름의 두 멤버 변수 유형이 동일해야 한다.
마치면서
오늘 위의 두 가지 BeanUtils를 간략히 분석하였는데 Apache에 있는 BeanUtils는 성능이 좋지 않기 때문에 권장하지 않으며, Spring의 BeanUtils를 사용하거나, Dozer, ModelMapper 등과 같은 다른 copy library를 고민해볼수도 있겠다.
우리는 개발 소트코드는 git ,svn 등 형상관리툴을 이용하여 pull, commit 등을 매일 수백번씩하면서 관리해간다.
하지만 db의 버전은 어떻게 관리해왔는가? 물론 지금 말하는 db 버전은 mysql 5.7 , mysql 8.0 oracle 10g 이런 버전이 아니고 개발도중에 자주 바뀌는 스키마, 기초 테스트 데이터 를 얘기한다. 팀 동료가 스키마가 바뀐상태로 개발소스들을 commit 하였다면 pull 내려 받은 나는 코드들이 제대로 작동하지 않을수 있고 반대로 내가 변경한 데이터들이 바뀌면 개발팀원 전체에 영향을 줄수 있다.
이런 문제들을 flyway라는 마이그레이션 툴을 사용하여 database를 형상관리해보자!
공식사이트에 기재되어 있는 flyway 작동방식을 설명해주는 많이 보았을 이미지 이다. 미안하지만 그대로 가지고와 봤다. 뭐 설명을 하려고 하니 딱히 설명할것도 없을것 같다. 우리가 이미 code 형상 관리에 대하여 어느정도 이해가 있으니 그림만 봐도 대충 이해가 될것 같다. 즉 형상관리를 시작하는 시점의 데이터가 있고 추후 변경되는 부분의 데이터들을 감지하여 변경된 부분만 엡데이트 해주는 방식이다.
flyway를 잘 사용하면 이렇게 Axel 과 Christian 라는 개발자 각자 필요한 DDL 을 만들고 배포할수 잇다. 공식사이트 소개를 예로 들자면 gradle, maven, CLI, java api 를 통하여 flyway 를 실행할수 있다고 하는데 이글에서 springboot 으로 실행하는 방법을 알아보자.
src/main/resources 폴더에 db 폴더 ,그리고 그안에 migration 폴더를 만든다.
migration 폴더에 V1__Base_version.sql 파일을 생성
DROP TABLE IF EXISTS user ;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'pk',
`name` varchar(20) NOT NULL COMMENT '이름',
`age` int(5) DEFAULT NULL COMMENT '나이',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
주의 : .sql 경로를 혹시 자기가 원하는 폴더에 위치하고 싶으면 spring.flyway.locations 설정을 하면 된다.
User 객체 생성
@Data
@NoArgsConstructor
public class User {
private Long id;
private String name;
private Integer age;
}
Interface 작성
public interface UserService {
int create(String name, Integer age);
List<User> getByName(String name);
int deleteByName(String name);
int getAllUsers();
int deleteAllUsers();
}
@Service
public class UserServiceImpl implements UserService {
private JdbcTemplate jdbcTemplate;
UserServiceImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public int create(String name, Integer age) {
return jdbcTemplate.update("insert into USER(NAME, AGE) values(?, ?)", name, age);
}
@Override
public List<User> getByName(String name) {
List<User> users = jdbcTemplate.query("select * from USER where NAME = ?", (resultSet, i) -> {
User user = new User();
user.setId(resultSet.getLong("ID"));
user.setName(resultSet.getString("NAME"));
user.setAge(resultSet.getInt("AGE"));
return user;
}, name);
return users;
}
@Override
public int deleteByName(String name) {
return jdbcTemplate.update("delete from USER where NAME = ?", name);
}
@Override
public int getAllUsers() {
return jdbcTemplate.queryForObject("select count(1) from USER", Integer.class);
}
@Override
public int deleteAllUsers() {
return jdbcTemplate.update("delete from USER");
}
}
테스트 코드 작성
@Slf4j
@SpringBootTest
public class FlywayDemoApplicationTests {
@Autowired
private UserService userSerivce;
@Test
public void test() throws Exception {
userSerivce.deleteAllUsers();
userSerivce.create("Tom", 10);
userSerivce.create("Mike", 11);
userSerivce.create("Didispace", 30);
userSerivce.create("Oscar", 21);
userSerivce.create("Linda", 17);
// Oscar 라는 이름을 가진 사용자 조회 , 나이 가 정확한지 확인.
List<User> userList = userSerivce.getByName("Oscar");
Assertions.assertEquals(21, userList.get(0).getAge().intValue());
// 5명 있음.
Assertions.assertEquals(5, userSerivce.getAllUsers());
// 2명 삭제.
userSerivce.deleteByName("Tom");
userSerivce.deleteByName("Mike");
// 아직 5명 있을걸?
Assertions.assertEquals(3, userSerivce.getAllUsers());
}
}
테스트 코드 실행 결과
디비테이블 확인 2개 추가 된걸 확인 할수 있다.
user 현재 테이블
flyway_schema_history : flyway가 관리하고 있는 테이블. 해당 테이블에 수행된 .sql 스크립트 내역들을 기재하고 있다.
위 내용을 이서 진행해보자. 만일 특정 개발자가 address 라는 컬럼을 테이블에 추가했다면 어떻게 될까?
ALTER TABLE `user` ADD COLUMN `address` VARCHAR(20) DEFAULT NULL;
팁! 스크립트 파일 명명 규칙은 버전번호_쿼리에 대한 설명 .sql 이다.
다시 테스트 코드를 돌리면 아래와 같은 수행완료 로그를 볼수 있다.
2022-12-26 16:58:12.025 INFO 37330 --- [ main] o.f.c.i.database.base.DatabaseType : Database: jdbc:mysql://localhost:3306/test (MySQL 8.0)
2022-12-26 16:58:12.063 INFO 37330 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 2 migrations (execution time 00:00.020s)
2022-12-26 16:58:12.075 INFO 37330 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema `test`: 1
2022-12-26 16:58:12.082 INFO 37330 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema `test` to version "1.1 - alter table user"
2022-12-26 16:58:12.113 INFO 37330 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema `test` (execution time 00:00.045s)
테이블을 확인해보자
마찬가리로 history 테이블에도 아래와 같이 내역이 추가되었다.
어떤가 어제는 더이상 누가 스키마를 바꿨는지 팀원들한테 일일이 확인 안해봐도 되지 않은가?