데이터 베이스 버전 컨트롤 Flyway

web/Spring|2019. 9. 28. 13:43

Spring에서 초기 테이블과 데이터 관리를 위해서 data.sql과 schema.sql을 사용하였다. 하지만 테이블 스키마가 변경되거나 필수로 초기에 들어가야하는 데이터들이 추가되거나 수정되었을 때 히스토리 관리가 잘 되지 않았다. 

특히 서로 교류가 잘 되지 않은 경우에서는 컬럼이 추가되거나 무엇이 변경되었는지 알지 못해서 문제를 유발할 수 있기에 이를 관리 할 수 있는 무언가가 필요했다.

그래서 Redgate에서 제공하는 Flyway를 사용해보기로 했다. 우선 내 개인 프로젝트인 timeline에 적용시켜봤다.

 

데이터베이스 버전관리 Flyway

https://flywaydb.org/

동작 방식

Flyway가 버전관리를 하기위해서 테이블이 생성된다. Flyway가 버전관리는 이 테이블에 데이터베이스의 상태를 기록하면서 진행한다. 

Flyway가 시작되면 파일시스템 또는 마이그레이션 대상의 classpath를 스캔해서 Sql 또는 Java로 쓰여진 파일을 찾는다. 이 마이그레이션 작업은 파일에 적혀있는 version number대로 순서대로 진행된다. 그리고 현재 마이그레이션 해야할 파일의 버전과 테이블에 기록된 버전을 확인해보고 같으면 넘어간다.

Flyway에서 사용하는 테이블은 flyway_schema_history로 아래와 같이 구성되어있다.

CREATE TABLE `flyway_schema_history` (
  `installed_rank` int(11) NOT NULL,
  `version` varchar(50) DEFAULT NULL,
  `description` varchar(200) NOT NULL,
  `type` varchar(20) NOT NULL,
  `script` varchar(1000) NOT NULL,
  `checksum` int(11) DEFAULT NULL,
  `installed_by` varchar(100) NOT NULL,
  `installed_on` timestamp NOT NULL DEFAULT current_timestamp(),
  `execution_time` int(11) NOT NULL,
  `success` tinyint(1) NOT NULL,
  PRIMARY KEY (`installed_rank`),
  KEY `flyway_schema_history_s_idx` (`success`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
installed_rank 인덱스
version 버전명 (V나 R 뒤에 붙는 숫자)
description 설명
type SQL 또는 JDBC 
script 스크립트 이름 V1__kdjlkdf.sql
checksum checksum
installed_by 실행 주최자
installed_on 설치된 시간
execution_time 총 실행시간
success 성공여부

 

간단히 말해 변경된 데이터나 테이블 스키마를 적용하기 위해서는 마지막 버전보다 높은 파일을 만들어서 애플리케이션을 구동하면 된다.

 

애플리케이션에 적용

그럼 flyway를 적용하기 위해 gradle에 라이브러리부터 추가해보자.

dependency {
	compile group: "org.flywaydb", name: "flyway-core", version: '5.2.4'
}

그리고 application.yml을 설정하자.

spring:
  flyway:
    enabled: true
    baselineOnMigrate: true
    encoding: UTF-8

그리고 테이블과 데이터를 넣을 sql을 만들자. 

그리고 Springboot 애플리케이션을 실행시키면 해당 테이블에 버전 히스토리가 기록된다.

 

버전관리하기에 좋은거 같다.

댓글()

Mysql 묵시적 형변환

데이터베이스/mysql|2018. 10. 3. 23:47

묵시적 형변환
조건절의 데이터 타입이 다를 때 우선순위가 높은 타입으로 형이 내부적으로 변환 되는 것. 
정수 > 문자열 순이며 만약 정수와 문자열이 비교가 되는 경우에는 둘중에 우선순위가 낮은 것이 변경된다. 

우리는 이렇게 자동으로 형변환 해주는 경우에 익숙해져 있다. 자바에서도 Integer와 int 두 개의 변수의 값을 묵시적으로 형변환 시켜주지만 이는 이펙티브 자바 책에서도 볼 수 있지만 성능저하의 원인이 된다고 한다.

Mysql도 예외가 아닌 것 같다. 

예를 들어 보자 아래와 같은 테이블을 생성 후 데이터를 삽입한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 테이블 생성 
create table chagne_data (
    id int unsigned not null auto_increment,
    sub_id int unsigned not null,
    val varchar(64not null,
    date_d datetime not null,
    primary key(id)
);
 
# 랜덤 데이터 삽
insert INTO 
chagne_data (
        sub_id,
        val,
        date_d
    )
values
    (
        crc32(rand()),
        crc32(rand()) * 12345,
        date_add(now(), interval - crc32(rand()) / 5 second)
    );
INSERT INTO test.chagne_data(sub_id, val, date_d) SELECT sub_id, val, date_d FROM test.chagne_data;
cs

인덱스를 생성하고 
정수형 컬럼에 문자열 조건을 주어서 실행계획을 확인해보자.

1
2
3
4
5
# 인덱스 생성     
CREATE INDEX int_index ON test.chagne_data(sub_id);    
 
# 정수형에 문자열형 조건으로 추가 (정수가 더 우선순위가 높으므로 문제 없음)     
SELECT * FROM test.chagne_data where sub_id = '3689107608';
cs


별 문제 없다. 왜냐면 정수형 데이터가 우선순위가 더 높기 때문에 우측의 문자열 데이터가 변경되었기 때문에 인덱스를 정상적으로 사용했기 때문이다.


그렇다면 문자열 컬럼을 정수형 데이터로 조건을 주어서 데이터를 추출한다면 어떨까?

우선 정상적인 경우의 실행계획을 살펴보자.

1
2
3
4
5
# 인덱스 생성 
CREATE INDEX int_index ON test.chagne_data(val);    
 
# 문자열에 문자열로 조건을 주고 실행계획 확인 
SELECT * FROM test.chagne_data WHERE val = '10227816402120';
cs

이번에는 문자열 컬럼에 정수 데이터를 넣고 조회해보자. 

1
2
# 문자열에 정수형 조건 추가 (묵시적 형변환 발생)
SELECT * FROM test.chagne_data WHERE val = 10227816402120;
cs


인덱스 사용을 못하고 문제가 되는 것을 확인 할 수 있다.

특히 이런 문제가 발생하는 대표적인 부분이 mybatis에서 데이터를 #{}형태로 넣어서 사용할 때 문제 없이 실행되기 때문에 잘 몰라서 문제소지를 일으킬 수 있다.

항상 조심하자.


댓글()

Mysql 실행계획 설명

데이터베이스/mysql|2018. 10. 3. 23:43

프로그램의 성능을 높히기 위해서는 DB튜닝이 필요하다. 
Mysql에서 튜닝을 하기 위해서 제공하는 쿼리의 실행 계획에 대해 정리해보자.



Mysql의 데이터 처리 방식

우선 Mysql의 데이터 처리방식에 대해 정리해보자.

- Mysql은 단일 코어로 데이터를 처리하기 때문에 멀티코어로 scale out을 진행하는 것 보다 cpu 자체의 성능을 높히는 scale up을 하는 것이 더 효율적이다. 
- Oracle과 달리 mysql은 nested loop join 알고리즘만 사용한다.  
- Nested Loop Join은 선행 테이블의 검색 결과 값 하나하나 테이블 B와 조인하는 방식이다. 그래서 데이터 양이 적을 때는 상관이 없으나 데이터가 많은 테이블끼리 조인할 시 성능에 문제가 있을 수 있다. 그래서 내부적으로 join buffer를 사용하여 드라이빙 테이블에서 조인에 사용될 데이터를 찾아 join buffer에 채우고 조인 버퍼에서 조인 대상 B 테이블의 데이터를 스캔하면서 풀, 인덱스 스캔, 인덱스 범위 스캔등을 사용하여 테이블에 데이터와 조인한다. 



Mysql 쿼리 성능 진단 (for 최적화)
성능 진단을 위해서 사용하는 방법은 Explain을 사용하는 것이다.  

Explain을 사용해서 쿼리 실행계획을 살펴보면 하단에 그림과 같이 출력된다.


각 필드에 대한 설명은 다음과 같다. 
ID : Select 아이디 
Select_type : 참조 타입 
Table : 참조하는 테이블 
Type : 조인 타입 
Possible_keys : 데이터를 조회할 때 DB에서 사용할 수 있는 인덱스 리스트 
Key : 실제로 사용할 인덱스 
Key_len : 실제로 사용할 인덱스 길이 
Ref : Key 안의 인덱스와 비교하는 컬럼(상수) 
Rows : 쿼리 실행 시 조사하는 행 수 
Extra : 추가 정보 

이 필드중에 Select_type, type, Extra에 대해서만 잘 확인하면 좋은 쿼리를 작성할 수 있다.


Select_type 종류

구분
설명
예시
SIMPLE
UNION이나 서브쿼리가 없는 단순 SELECT를 의미한다. 
SELECT * FROM USER;
PRIMARY 
서브쿼리가 있을 때 가장 바깥쪽에 있는 SELECT 
SELECT * FROM (SELECT * FROM USER) t; 
DERIVED 
FROM절 안의 서브쿼리 
SELECT * FROM (SELECT * FROM USER) t; 
DEPENDENT SUBQUERY 
외부 쿼리와 상호 연관된 서브쿼리 
SELECT * FROM user u1 WHERE EXISTS ( 
    SELECT * FROM user u2 WHERE u1.user_id = u2.user_id 
);


Type
Type에는 system, const, ref... 등등 많이 있지만 성능상 문제가 되는 부분은 index, all이 두가지 타입이 문제다.
구분
설명
index 
인덱스를 처음부터 끝까지 찾아서 검색하는 경우로, 일반적으로 인덱스 풀스캔이라고 지칭 
all 
테이블 풀스캔으로 모든 부분을 스캔하는 것


Extra
쿼리 실행에 대한 추가적인 정보를 보여준다. 
하단의 대표적인 설명인 4가지중에서 특히 FileSort와 Using Temporary의 경우에는 쿼리 튜닝이 필요한 상태
구분
설명
Using Index 
인덱스를 이용해서 데이터를 추출
Using Where 
Where 조건으로 데이터를 추출.  (Type에서 All과 Index와 마찬가지로 성능에 문제) 
Using Filesort 
데이터의 정렬이 필요한 경우로써 데이터 양이 많을수록 성능에 직접적인 영향을 끼친다. 
Using Temporary 
내부적으로 Temporary Table을 사용하는 경우


Join 최적화 포인트
- Nested Loop 조인으로 되어있기 때문에 기준 테이블에서 조회되는 데이터양에 따라 연관 테이블의 데이터양이 결정되기 때문에 기준 테이블(왼쪽)의 데이터양을 줄이는 것이 관건. 
- Outer Join은 지양한다. 꼭 필요한 경우만 사용한다. 
- join시 조합 경우의 수를 줄이기 위해 복합 컬럼 index를 사용.


댓글()

데이터 저장 구조 및 I/O 메커니즘

데이터베이스는 디스크로 구성되어있는 데이터베이스이기 때문에 SQL 튜닝은 곧 I/O 튜닝이다. 그렇기에 기본적인 데이터의 저장 구조 및 디스크 또는 메모리를 읽는 메커니즘에 대한 정리를 먼저 해보자.


SQL 실행이 느려지는 이유

I/O가 처리되는 동안 다른 프로세스는 놀게된다. 그렇기 때문에 효율적인 프로세스 활용이 되지 못해 SQL이 느린 것이다. 왜냐하면 디스크에 접근하는 로직이 느린 경우 다른 프로세스는 계속 놀게되고 디스크 경합이 심해지기 때문이다.


데이터베이스 저장 구조

데이터베이스를 저장하려면 먼저 테이블 스페이스를 만들어야 한다. 테이블 스페이스는 테이블, 인덱스, 파티션, LOB등 여러 세그먼트를 담는 컨테이너로써 여러 개의 데이터파일로 구성된다.

각 세그먼트는 데이터 저장공간이 필요한 오브젝트이다. 그리고 그 세그먼트는 여러 익스텐트로 구성된다.익스텐트는 블록으로 구성되어 있는데, 테이블 또는 인덱스와 같은 데이터를 저장하다 공간이 부족하면 테이블 스페이스에게 요청하여 추가적으로 블록을 할당한다. 하나의 블록은 하나의 테이블이 독점한다. 즉 한 블록에 저장된 레코드는 모두 같은 테이블 레코드이다.


정리하면 이런 순서로 구성된다.

테이블 스페이스 > 세그먼트 > 익스텐트 > 블록

-> 각 블록은 한 테이블이 독점 (다중 테이블 클러스터일 경우 제외)

세그먼트 공간이 부족해졌을 때 새로운 익스텐트를 할당받는다고 했는데 그러면 그말은  익스텐트에 쓰다가 데이터 공간이 부족하면 새로운 익스텐트에 작성을 한다는 뜻으로 서로 다른 위치에 데이터가 저장된다는 뜻이다. 그렇기 때문에 이럴 경우 서로 다른 데이터 파일에 존재할 확률이 커진다. 왜냐면 테이블  스페이스는 데이터 파일로 구성되어 있는데 이는 DBMS가 파일 경합을 위해 분산시켜 놓기 때문이다.



결과적으로 그림에서 보면 알겠지만 테이블 스페이스는 크게보면 익스텐트들의 집합이다. 익스텐트들은 데이터 파일로써 분산되어 저장이된다. 그러기 때문에 익스텐트는 서로 붙어있게 만들어져있어서 세그먼트를 이루지만 데이터는 연속적인 인스텐트에 저장되는 것이 아니라는 것을 알수있다.


이런 세그먼트들에 할당되어 있는 익스텐트 목록을 조회하는 쿼리는 다음과 같다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
select
    segment_type,
    tablespace_name,
    extent_id,
    file_id,
    block_id,
    blocks
from
    dba_extents
where
    owner = USER
order by
    extent_id;
 
cs




실행 결과를 살펴보면 익스텐트별 데이터 파일 블록 아이디를 확인 할 수 있는데, 익스텐트가 연속되어서 저장되지는 않는다는 것을 알 수 있다. 서로 다른 블록에 저장된다.


정리하면

블록 : 데이터를 읽고 쓰는 단위

익스텐트 : 공간을 확장하는 단위, 연속된 블록 집합

세그먼트 : 데이터 저장공간이 필요한 오브젝트(테이블, 인덱스, 파티션, LOB 등)

테이블 스페이스 : 세그먼트를 담는 컨테이너

데이터파일  : 디스크 상의 물리적 OS 파일 (테이블 스페이스는 여러개의 데이터파일로 존재)




※  DBA (Data Block Address)

  • 데이터가 몇번째 블록 어디에 위치해있는지 알려주는 주소를 의미

  • 각 테이블에 레코드에 값을 읽을 때는 ROWID를 사용하는데 ROWID는 DBA + 블록내 순번을 의미

  • 테이블을 스캔할 때는 각 세그먼트 헤더에 저장된 익스텐트 맵을 통해 필요한 블록의 위치로 이동한다.



댓글()

바인드 변수를 이용한 오라클 SQL 튜닝 소개

저번 시간에 내부 프로시저를 재사용해야 쿼리 수행시 비용이 감소한다고 공부하였다. 그렇게 재사용성을 높이기 위해서 어떻게 해야하는지 알아보자.


바인드 변수 사용

사용자 정의  함수/프로시저, 트리거등은 별도의 이름이 있어 생성하여 계속해서 재사용할 수있다. 하지만 SQL은 이름이 없어서 내부 프로시저에 저장하여 사용한다. 그렇듯 SQL은 별도의 이름이 아닌 그 자체가 이름처럼 고유의 값으로 사용된다.


그럼 공백이나 대,소문자가 달라도 다른 객체인가? 아래 쿼리를보자.


1
2
3
4
5
6
7
8
select * from t where empno = 7695;
select * from t where empno = 7695 ; 
select * from T where empno = 7695;
select * from t WHERE empno = 7695;
select * from scott.emp where empno = 7695;
select /* comment */ * from emp where empno = 7695;
select /*+ first_rows */ * from emp where empno = 7695;
 
cs


위의 쿼리들은 서로 다른 쿼리로서 내부 프로시저에서 공유해서 사용하지 않고 하드파싱이 일어난다. 그렇기에 프로그램에서 과도하게 변경되는 쿼리를 자주사용할 경우 과도한 I/O가 발생하여 리소스를 엄청 소모하게 된다.

이런문제를 해결하기 위해서 쿼리에 바인드 변수를 사용하면 된다.

다음과 같이 쿼리를 변경한다면 한번의 하드파싱이 발생하고 내부적으로 프로시저를 공유하게 되기 때문에 성능이 엄청 빨라진다.


1
select /*+ first_rows */ * from emp where empno = :1;
cs


댓글()

SQL 최적화 과정과 옵티마이저 소개 및 역할안내

SQL 최적화 과정


오라클을 기준으로 SQL의 최적화 과정은 다음과 같다.


1. SQL 파싱

-> 파싱트리 생성

-> 문법적 오류 확인

-> 의미상 오류 확인 (없는 컬럼, 테이블 접근)

2. SQL 최적화

-> 옵티마이저가 미리 수집한 시스템 및 오브젝트 통계정보를 바탕으로 가장 최적의 실행경로로 선정


3. 로우 소스 생성

-> 선정된 실행경로를 실제 실행 가능 코드로 변경 (Row-Source Generator)가 역할 수행



그럼 이런 최적화를 진행하는 옵티마이저는 어떤것이고 또 어떻게 진행이되는가?


SQL 옵티마이저란?

- 옵티마이저는 사용자의 작업을 가장 효율적으로 수행할 수 있는 최적의 데이터 액세스 경로를 선택해주는 DBMS 엔진을 말한다.


최적화 단계

- 쿼리를 수행할 실행계획 찾기

- Data Dictionary에 수집한 오브젝트 통계 및 시스템 통계정보를 이용해 각 실행계획의 예산비용 산정.

- 최저 비용 선택




그럼 옵티마이저를 활용하여 SQL을 튜닝해보자.


먼저 튜닝에 사용할 테이블을 만들어보자.

1
create table t as select d.no, e.* from scott.emp e, (select rownum no from dual connect by level <= 1000) d;
cs

그리고 옵티마이저에 사용할 인덱스도 만들어보자.

1
2
3
create index t_x01 on t (deptno, no);
create index t_x02 on t (deptno ,job, no);
 
cs


마지막으로 옵티마이저가 참고할 통계정보도 만들자.

1
exec dbms_stats.gather_table_stats(user, 't');
cs


그럼 이를 이용해서 쿼리의 실행계획을 확인해보자. 실행할 쿼리와 화면은 아래와같다.

1
select * from t where deptno = 10 and no = 1;
cs

결과를 보면 인덱스01이 선택된것을 확인할 수 있다. 왜 인덱스 01이 선택되었는가?

COST 컬럼을 보면 인덱스 01의 경우 비용이 2이다. 그러면 인덱스 02는 비용이 어떨까? 힌트를 줘서 인덱스 02를 사용하도록 지정해보자.

1
2
select /*+ index(t t_x02) */ * from t where deptno = 10 and no = 1;
 
cs

비용이 11인것을 확인할 수 있다. 그래서 인덱스 01이 선택된 것이다.


비용은 쿼리를 수행하면서 발생하는 I/O횟수와 소요시간을 표현한 것이다.

-> 정확한것은 아니라 예상치이다. 그렇기 때문에 옵티마이저는 자신이 알고있는 통계정보를 이용해서 예상치를 만든다. 그렇기 때문에 정확치 않을 수 있기에 그때는 위에서 사용한 방법으로 힌트를 줘서 수행한다.


그럼 Full Scan도 힌트를 줘서 확인해보자.

1
select /*+ full(t) */ * from t where deptno = 10 and no = 1;
cs


이번시간은 SQL 옵티마이저에 대한 개념과 옵티마이저를 이용한 간단한 튜닝을 진행했다.

다음시간에는 우선 옵티마이저에게 힌트를 주는법을 알아보고 하드파싱과 소프트 파싱에 대해 알아보자.



.


댓글()

Mybatis의 동적 SQL

web/Spring|2016. 12. 27. 00:30

Mybatis 동적 SQL


Mybatis 가지는 표현식은 다음과 같다.

 

If

Choose(when, otherwise)

Trim(where, set)

Foreach

 

기능

사용

설명

if

<if test="title != null">

AND title like #{title}

</if>

코드로 작성할 때의 if 구문에 대한 처리

-> 거짓을 구별해서 사용할 처리한다.

Choose,

When,

otherwise

<choose>

<when test="title != null">

AND title like #{title""

</when>

<when test="author != null and author.name != null">

AND author_name like #{author.name}

</when>

<otherwise>

AND featured = 1

</otherwise>

</choose>

switch 같은 상황에 대한 처리

Trim,

Where,set

<trim prefix="where" prefixOverrides="AND/OR">

</trim>

로직을 처리하면서 필요한 구문을 변경

foreach

<foreach item="item" index="index" collection="list" open="(" separator=," close=""> #{item} </foreach>

컬렉션에 대한 순환처리

 

Mybatis에서 XML 처리하는 것이 많은 이유는 SQL 동적 사용 때문이다.

 

동적 SQL 이용한 개발은 다음과 같은 단계로 진행한다.

-> 동적 SQL 적용이 필요한 메소드의 설정

-> XML Mapper 이용한 SQL 처리

-> 동적 SQL문의 생성 확인 테스트

 

<select id="listSearch" resultType="BoardVO">

<![CDATA[

select *

from tbl_board

where bno > 0

order by bno desc

limit #{pageStart}, #{perPageNum}

]]>

<if test="searchType != null" > <!-- if test boolean 으로 나오는 결과야 한다. -->

<if test="searchType == 't'.toString()">

and title like CONCAT('%', #{keyword}, '%')

</if>

<if test="searchType == 'c'.toString()">

and content like CONCAT('%', #{keyword}, '%')

</if>

<if test="searchType == 'w'.toString()">

and writer like CONCAT('%', #{keyword}, '%') <!-- like로 구분되어야 한다.  -->

</if>

<if test="searchType == 'tc'.toString()">

and (title like CONCAT('%', #{keyword}, '%') OR content like CONCAT('%', #{keyword}, '%'))

</if>

<if test="searchType == 'cw'.toString()">

and (content like CONCAT('%', #{keyword}, '%') OR writer like CONCAT('%', #{keyword}, '%'))

</if>

<if test="searchType == 'tcw'.toString()">

and ( title like CONCAT('%', #{keyword}, '%') OR content like CONCAT('%', #{keyword}, '%') or

writer like CONCAT('%', #{keyword}, '%'))

</if>

</if>

<![CDATA[

order by bno desc limit #{pageStart}, #{perPageNum}

]]>

</select>

 

SQL 조각을 붙히는 방법 include

-> SQL 여러조각으로 나눈다음 원하는 부분에 include 사용하여 추가 있다.

<select id="listSearch" resultType="BoardVO">

<![CDATA[

select *

from tbl_board

where bno > 0

order by bno desc

limit #{pageStart}, #{perPageNum}

]]>

<include refid="search"></include> <!--  include 해서 조각난 sql 합칠 있다. -->

<![CDATA[

order by bno desc limit #{pageStart}, #{perPageNum}

]]>

</select>

<sql id="search">

<if test="searchType != null" > <!-- if test boolean 으로 나오는 결과야 한다. -->

<if test="searchType == 't'.toString()">

and title like CONCAT('%', #{keyword}, '%')

</if>

<if test="searchType == 'c'.toString()">

and content like CONCAT('%', #{keyword}, '%')

</if>

<if test="searchType == 'w'.toString()">

and writer like CONCAT('%', #{keyword}, '%') <!-- like로 구분되어야 한다.  -->

</if>

<if test="searchType == 'tc'.toString()">

and (title like CONCAT('%', #{keyword}, '%') OR content like CONCAT('%', #{keyword}, '%'))

</if>

<if test="searchType == 'cw'.toString()">

and (content like CONCAT('%', #{keyword}, '%') OR writer like CONCAT('%', #{keyword}, '%'))

</if>

<if test="searchType == 'tcw'.toString()">

and ( title like CONCAT('%', #{keyword}, '%') OR content like CONCAT('%', #{keyword}, '%') or

writer like CONCAT('%', #{keyword}, '%'))

</if>

</if>

</sql>

 


출처 : 코드로 배우는 스프링 웹 프로젝트 : 남가람북스

 

'web > Spring' 카테고리의 다른 글

spring에서 List 또는 Array 데이터를 Controller에서 받기  (0) 2018.05.27
UriComponents 클래스  (0) 2016.12.27
Mybatis의 동적 SQL  (0) 2016.12.27
STS의 github 연동  (0) 2016.12.21
Mybatis의 #{} 문법 사용방법  (0) 2016.12.21
Spring의 UTF-8 처리 필터 등록  (0) 2016.12.21

댓글()