> Hello World !!!

     

@syaku

#5 스프링 트랜잭션 - 스프링 프레임워크 게시판 : Spring Framework Transaction

반응형

written by Seok Kyun. Choi. 최석균


스프링 프레임워크 연재 포스팅

2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #1 STS 설치 및 스프링 프로젝트 만들기 : Spring Framework Hello, World!!!
2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #2 스프링 프로젝트 만들기 : Spring Framework Create Project
2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #3 스프링 MyBatis 설정하기 및 로그출력 : Spring Framework MyBatis Log4jdbc
2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #4 스프링 XML , 스프링 유효성검사 : Spring Framework Hibernate Validator XML Marshaller
2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #5 스프링 트랜잭션 : Spring Framework Transaction
2014/07/21 - [개발노트/Spring] - 스프링 프레임워크 게시판 #6 스프링 파일업로드 : Spring Framework FileUpload
2014/07/28 - [개발노트/Spring] - 스프링 프레임워크 게시판 #부록 스프링 검색 및 조회수 올리기 , 스프링 한글깨짐: Spring Framework Cookie


개발 환경

Mac OS X 10.9.4
JAVA 1.6
Apache Tomcat 7.x
MySQL 5.x
Spring 3.1.1
Spring Tool Suite 3.5.1
Maven 2.5.1
myBatis 3.2.7
jQuery 1.11.0

2014.07.19 Written by 최석균 (Syaku)


소스 파일 : source-5.zip

5. 스프링 트랜잭션

스프링 트랜잭션은 개발자의 편의를 최대한 존중해주는 작업이라 할 수 있다. 흔히 트랜잭션을 사용할때… try catch 문 사이에 커밋과 롤백을 이용하여 트랜잭션을 처리한다. 모든 소스에 동일한 트랜잭션 소스코드를 넣는 불편한 방식에도 의심조차하지 않았다. 하지만 스프링 프레임워크를 선택했다면 이런 반복적인 작업을 한번에 해결하고 개발자는 더 이상 트랜잭션을 신경쓰지 않아도 된다.

스프링에서는 두가지 방식의 트랜잭션을 사용한다. 하나는 선언에 의한 트랜잭션이고 또 하나는 프로그램에 의한 트랜잭션이다. 후자는 우리가 흔히 쓰던 방식이고 전자는 AOP를 사용하여 프로그램 외부에서 선언하는 방식이다. 프로그램에 의한 방식은 중복적인 트랜잭션 소스를 삽입해야 하므로 특별한 경우가 아니면 선언적인 방식을 사용하는 것이 바람직하다.

선언적인 트랜잭션에는 AOP를 이용한 방식과 어노테이션을 선언하는 방식이 있다. 두가지를 먼저 설명 후 프로그램에 의한 방식을 설명한다.

AOP를 이용한 선언적인 방식의 트랜잭션

AOP를 사용할때 서블릿 컨텍스트 설정을 어떻게 구분하였는 지에 따라 문제점이 다양하게 발생한다. 처음부터 스프링을 이해하고 개발하는 사람은 없을 것이다. 이문제는 이론으로 이해하기 보다 직접 문제점을 곁어보고 해결하는 것이 가장 좋을 것 같다.

context-datasource.xml 설정 파일을 열어 트랜잭션 설정을 추가한다.

@소스 context-datasource.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/aop
    http://www.springframework.org/schema/aop/spring-aop.xsd
    http://www.springframework.org/schema/tx
    http://www.springframework.org/schema/tx/spring-tx.xsd
    ">

   … 생략 …

    <!-- Transaction Manager -->
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource" />
    </bean>

    <tx:advice id="txAdvice" transaction-manager="transactionManager">
    <tx:attributes>
        <tx:method name="get*" read-only="true" />
        <tx:method name="delete*" />
    </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="transactionPointcut" expression="execution(* com.syaku.bbs.dao.BbsDao.*(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="transactionPointcut" />
    </aop:config>

    <bean id="bbsDao" class="com.syaku.bbs.dao.BbsDao"/>

</beans>

transactionManager 에는 dataSource 정의를 참조한다.
기존에 jdbc 로그를 출력하기 위해 생성한 dataSource 를 참조한다. 만약 로그가 없다면 연결을 위해 생성한 dataSource 를 참조하면 된다.

tx 는 스프링 트랜잭션을 담당한다. tx 를 사용하기 위해 상단에 네임스페이스를 추가하였다.
트랜잭션을 적용할 때 사용할 어드바스를 생성한다. <tx:advice> 어드바이스 속성과 값에 대한 설명은 아래와 같다.

<tx:method> 속성 설명

name : 트랜잭션이 적용될 메서드 이름을 명시하며 필수 속성이다. get*,delete*,* 설정이 가능하다.
propagation : 트랜잭션 동작 설정하며 기본값 REQUIRED 이다.
isolation : 트랜잭션의 격리 수준을 설정하며 기본값은 DEFAULT 이다.
timeout : 트랜잭션 시간 초과 값을 설정하며 기본값은 -1 이다. 초단위로 입력한다.
read-only : 읽기 전용 여부를 설정한다. 기본값은 false 이다.
rollback-for : 롤백을 할 예외를 설정한다. 여러개를 입력할 경우 , 로 구분한다. 기본값은 RuntimeException 이다. Exception, com.syaku.MyException 설정이 가능하다.
no-rollback-for : 롤백하지 않을 예외를 설정한다. 여러개를 입력할 경우 , 로 구분한다. Exception, com.syaku.MyException 설정이 가능하다.

propagation - 전파옵션 (기본값 : REQUIRED)

REQUIRED : 부모 트랜잭션 내에서 실행하며 부모 트랜잭션이 없을 경우 새로운 트랜잭션을 생성한다.
REQUIRES_NEW : 부모 트랜잭션을 무시하고 무조건 새로운 트랜잭션이 생성한다.
SUPPORT : 부모 트랜잭션 내에서 실행하며 부모 트랜잭션이 없을 경우 nontransactionally로 실행된다.
MANDATORY : 부모 트랜잭션 내에서 실행되며 부모 트랜잭션이 없을 경우 예외가 발생된다.
NOT_SUPPORT : nontransactionally로 실행하며 부모 트랜잭션 내에서 실행될 경우 일시 정지된다.
NEVER : nontransactionally로 실행되며 부모 트랜잭션이 존재한다면 예외가 발생한다.
NESTED : 해당 메서드가 부모 트랜잭션에서 진행될 경우 별개로 커밋되거나 롤백될 수 있다. 둘러싼 트랜잭션이 없을 경우 REQUIRED와 동일하게 작동한다.

isolation - 격리수준 (기본값 : DEFAULT)

DEFAULT : DB에서 설정된 기본 격리 수준을 따른다.
SERIALIZABLE : 가장 높은 격리수준을 가지며 사용시 성능 저하가 있을 수 있다.
READ_UNCOMMITTED : 커밋되지 않은 데이터에 대한 읽기를 허용한다.
READ_COMMITTED : 커밋된 트랜잭션에 대해 읽기를 허용한다.
REPEATABLE_READ : 동일한 필드에 대한 다중 접근 시 동일한 결과를 얻을 수 잇는 것을 보장한다.

자세한 설명은 아래 링크를 통해 참고한다.

전자정부프레임워크 트랜잭션
애니프레임 트랜잭션

설명을 토대로 위 소스를 해석한다면 get으로 시작하는 모든 메서드는 읽기전용으로 설정하고, delete으로 시작하는 모든 메서드는 RuntimeException이 발생했을 때 트랜잭션을 통해 롤백을 처리한다.

<aop:config> 에는 트랜잭션을 적용할 대생을 설정한다. 대상 범위는 Aspectj 표현식으로 입력한다. * com.syaku.bbs.dao.BbsDao.*(..)* 라고 입력한 부분을 Aspectj 표현식이라 한다.

Aspectj 표현식 예제

execution(접근자제어 리턴타입 패키지 메서드이름 (인자))
* 모두를 의미함.
.. 0개 이상을 의미함.

execution(public void set*(..)) public 지시자로 시작하며, 리턴 값이 없으며, set으로 시작하는 메서드 중에 인자값은 0개 이상이 메서드를 호출

execution(* com.syaku.bbs.*.*()) bbs 패키지에 인자값이 없는 모든 메서드 호출

execution(* com.syaku.bbs..*.*(..)) bbs 패키지와 하위 패키지에 있는 인자값이 0개 이상인 메서드 호출

기본적인 트랜잭션 설정은 마쳤지만, 이대로 실행할 경우 오류가 발생한다. 우선 빌드후 재시작하면 아래와 같은 유형의 오류가 발생한다.

aspectjweaver 관한 라이브러리가 없어 오류가 발생한다. Maven 빌더에 라이브러리를 추가한다.

오류 내용 : java.lang.NoClassDefFoundError: org/aspectj/weaver/reflect/ReflectionWorld$ReflectionWorldException

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>${org.aspectj-version}</version>
</dependency>

그리고 소스 마지막 줄에 <bean id="bbsDao" class="com.syaku.bbs.dao.BbsDao"/> 의해 오류가 발생한다.
인터페이스 없이 트랜잭션을 사용할 경우 오류가 발생하게 된다. 그래서 CGLib 라이브러리를 사용해야 한다. CGLib는 동적으로 자바 클래스의 프록시를 생성해주는 기능을 제공한다. Maven 빌더에 CGLib를 추가한다.

오류 내용 : ‘Initialization of bean failed; nested exception is org.springframework.aop.framework.AopConfigException: Cannot proxy target class because CGLIB2 is not available. Add CGLIB to the class path or specify proxy interfaces.’

<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.1</version>
<type>jar</type>
<scope>compile</scope>
</dependency>

필요한 라이브러리도 모두 설치했다만 트랜잭션을 테스트해보기로 한다.
트랜잭션 대상인 BbsDao 클래스 메서드인 delete 에 RuntimeException 을 강제로 발생시켜본다.

@소스 BbsDao.java

public void delete(int idx) {
    this.bbsMapper.delete(idx);
    throw new RuntimeException("강제로 오류를 발생시켜봄!!");
}

그리고 컨트롤러 delete 메서드에 예외를 받아서 처리하는 작업을 추가한다.

@소스 ViewController.java

try {
     this.bbsDao.delete(idx);
     xml.setMessage("삭제되었습니다.");
     xml.setError(false);
} catch (Exception e) {
     xml.setMessage(e.toString());
     xml.setError(true);
}

목록 페이지에서 삭제 버튼을 눌러본다. 예외는 발생하지만 트랜잭션에 의해 롤백되지 않고 게시물이 삭제되는 문제가 발생한다.
서블릿 컨텍스트에서 컴포넌트 스캔을 할때 <context:component-scan base-package="com.syaku.bbs" /> 설정되어 있어 제대로 처리할 수 없는 것이다.

기본적으로 스프링에서 생성되는 컨텍스트는 RootContext 와 ServletContext 두가지가 있다. Root에서 생성한 빈은 모든 컨텍스트에서 공유되지만 Servlet 에서 생성한 빈은 다른 컨텍스트와 공유되지 않는 다. 그래서 ServletContext 에서 컴포넌트 스캔을 했기때문에 다른 컨텍스트(datasource 컨텍스트)에서 bbsDao 를 찾지 못해서 발생하는 문제점이다.
또한 RootContext 와 ServletContext 두 컨텍스트 모두 동일한 빈이 존재하면 ServletContext 가 우선권을 가지기 때문에 다른 컨텍스트에서 빈을 찾지못한다.

그래서 Controller 계층만 ServletContext 에서 스캔되도록 아래와 같이 수정한다.

이것과 관련된 자료는 아래의 링크를 참조한다.

http://www.mungchung.com/xe/spring/21220
http://toby.epril.com/?p=934
http://bumsgy-innori.tistory.com/205

@소스 root-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:context="http://www.springframework.org/schema/context"
     xsi:schemaLocation="
     http://www.springframework.org/schema/beans
     http://www.springframework.org/schema/beans/spring-beans.xsd
     http://www.springframework.org/schema/context
     http://www.springframework.org/schema/context/spring-context.xsd
     ">

     <context:component-scan base-package="com.syaku.bbs" use-default-filters="false">
     <context:exclude-filter expression="org.springframework.stereotype.Controller" type="annotation" />
     </context:component-scan>

     <!-- Root Context: defines shared resources visible to all other web components -->
     <import resource="classpath:config/spring/context/context-datasource.xml"/>
    <import resource="classpath:config/spring/context/context-mybatis.xml" />
</beans>

component-scan 이 추가하고, 컨트롤러 어노테이션을 스캔에서 제외하였다.
use-default-filters="false" 은 @Component, @Service, @Repository와 같은 어노테이션을 자동으로 탐지되지 않게 하겠다는 의미이다.

@소스 servlet-context.xml

<!-- <context:component-scan base-package="com.syaku.bbs" /> -->
<context:component-scan base-package="com.syaku.bbs" use-default-filters="false">
<context:include-filter expression="org.springframework.stereotype.Controller" type="annotation" />
</context:component-scan>

기존에 있던 컴포넌트 스캔을 제거하고 컨트롤러 어노테이션만 스캔되도록 수정하였다.
다시 게시물을 삭제하면 익셉션에 의해 롤백되어 삭제되지 않는 것을 확인할 수 있다.

context-datasource.xml


BbsDao.java


STS 에서 소스를 확인해보면 왼쪽 번호줄에 빨간색 마킹이 보일것이다. 트랜잭션이 적용된 곳을 표시하고 있다.

그런데 context-datasource.xml 설정은 추후 다른 Dao가 추가될 경우 매번 설정을 추가해줘야하는 불편함이 있다. 그래서 아래와 같이 유동성있게 소스를 수정한다.

@소스 context-datasource.xml

<aop:config>
     <aop:pointcut id="transactionPointcut" expression="execution(* com.syaku.bbs..*Dao.*(..))"/>
     <aop:advisor advice-ref="txAdvice" pointcut-ref="transactionPointcut" />
</aop:config>

<!-- <bean id="bbsDao" class="com.syaku.bbs.dao.BbsDao"/> -->

expression Aspectj 표현식을 수정하고, bbsDao bean 을 제거한다.

@소스 root-context.xml

<context:component-scan base-package="com.syaku..." use-default-filters="false">
    <context:include-filter expression="org.springframework.stereotype.Service" type="annotation" />
    <context:include-filter expression="org.springframework.stereotype.Repository" type="annotation" />
</context:component-scan>

ROOT 컨텍스트에 컨트롤러 어노테이션을 제외한 어노테이션을 추가한다. 그리고 모든 컨텍스트 설정파일에 컴포넌트 스캔 베이스패킷을 context:component-scan base-package="com.syaku…" 로 변경한다. (servlet-context.xml 파일의 베이스패킷을 변경한다.)

@어노테이션에 의한 트랜잭션

AOP를 통한 트랜잭션보다 더 간결하게 처리할 수 있는 것이 어노테이션 방식이다. 이전에 AOP를 이용한 선언적인 방식의 트랜잭션 설정을 모두 제거하거나 주석처리한다. 단 bean transactionManager 는 남겨둔다. 완전한 소스는 아래와 같다.

@소스 context-datasource.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:aop="http://www.springframework.org/schema/aop"
     xmlns:tx="http://www.springframework.org/schema/tx"
     xsi:schemaLocation="
     http://www.springframework.org/schema/beans
     http://www.springframework.org/schema/beans/spring-beans.xsd
     http://www.springframework.org/schema/tx
     http://www.springframework.org/schema/tx/spring-tx.xsd
     ">

      <bean id="jdbcProp" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
      <property name="location" value="classpath:jdbc.properties" />
      </bean>

      <bean id="dataSourceSpied" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
           <property name="driverClassName" value="${jdbc.driver}" />
           <property name="url" value="${jdbc.url}" />
           <property name="username" value="${jdbc.username}" />
           <property name="password" value="${jdbc.password}" />
     </bean>

     <bean id="dataSource" class="net.sf.log4jdbc.Log4jdbcProxyDataSource">
      <constructor-arg ref="dataSourceSpied" />
      <property name="logFormatter">
       <bean class="net.sf.log4jdbc.tools.Log4JdbcCustomFormatter">
        <property name="loggingType" value="MULTI_LINE" />
        <property name="sqlPrefix" value="SQL:::" />
       </bean>
      </property>
     </bean>

     <!-- Transaction Manager -->
     <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
          <property name="dataSource" ref="dataSource" />
     </bean>

     <tx:annotation-driven transaction-manager="transactionManager" />

</beans>

[주의] datasourceSpid 와 dataSource 그리고 transactionManager 의 dataSource 이름은 위 소스 내용과 일치해야 한다. 하나라도 틀리면 트랜잭션이 안되거나 로그가 출력되지 않는 다.

위 소스를 수정하고 서비스 재시작후 테스트로 게시글을 삭제한다.
예외는 발생하지만 게시글이 삭제된다. 당연히 어노테이션 트랜잭션을 설정하지 않았기 때문이다.
소스를 수정하기전에 트랜잭션에 사용되는 어노테이션에 대한설명은 아래와 같이 참고한다. 기능 설명은 이전에 했기때문에 소스 코딩만 표시하였다.

isolation : @Transactional(isolation=Isolation.DEFAULT)
noRollbackFor : @Transactional(noRollbackFor=NoRoleBackTx.class)
noRollbackForClassName :  @Transactional(noRollbackForClassName="NoRoleBackTx”)
propagation : @Transactional(propagation=Propagation.REQUIRED)
readOnly : @Transactional(readOnly = true)
rollbackFor : @Transactional(rollbackFor=RoleBackTx.class)
rollbackForClassName : @Transactional(rollbackForClassName="RoleBackTx”)
timeout : @Transactional(timeout=10)

BbsDao 에 트랜잭션을 위한 어노테이션 추가한다.

@소스 BbsDao.java

package com.syaku.bbs.dao;

import java.util.List;
import java.util.Map;

import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service(value = "bbsDao")
@Transactional(readOnly=true)
public class BbsDao {
    @Resource(name = "bbsMapper")
    private BbsMapper bbsMapper;

    public List<BbsVo> getSelect(Map map) {
        return this.bbsMapper.select(map);
    }

    public BbsVo getSelectOne(int idx) {
        return this.bbsMapper.selectOne(idx);
    }

    public void insert(BbsVo bbsVo) {
         this.bbsMapper.insert(bbsVo);
    }

    public void update(BbsVo bbsVo) {
         this.bbsMapper.update(bbsVo);
    }

    @Transactional
    public void delete(int idx) {
         this.bbsMapper.delete(idx);
         throw new RuntimeException("강제로 오류를 발생시켜봄!!");
    }

    public void updateReadCount(int idx) {
         this.bbsMapper.updateReadCount(idx);
    }
}

클래스의 모든 메서드에 readOnly 읽기전용을 적용하였고 delete 메서드에 트랜잭션을 적용하였다. 테스트로 게시글을 삭제해보면 삭제되지 않는 다.

readOnly 기본값은 false 이다
스프링 트랜잭션은 서비스계층(@Service)에 적용하는 것이 바람직하다.

트랜잭션 참고 자료

스프링 2.x 한글 메뉴얼 : http://openframework.or.kr/framework_reference/spring/ver2.x/html/transaction.html
전자정부프레임워크 트랜잭션 : http://www.egovframe.go.kr/wiki/doku.php?id=egovframework:rte:psl:transaction:declarative_transaction_management
애니프레임 트랜잭션 : http://dev.anyframejava.org/docs/anyframe/plugin/foundation/4.6.1/reference/html/ch08.html

프로그램에 의한 트랜잭션

어쩔수없이 프로그램에 의한 트랜잭션을 사용해야할 경우를 제외하고, 최대한 선언적인 트랜잭션 방식으로 프로그램을 설계해야 한다. 그리고 트랜잭션 방식을 꼭 한가지만 사용해야 하는 것은 아니다. 설명한 3가지 방식 모두 설정해서 사용해도 된다. 하지만 프로그램의 통일성을 갖는 것이 좋을 것이다.

프로그램에 의한 트랜잭션을 사용하려면 context-datasource.xml 에 아래의 소스만 추가되면 된다.

<!-- Transaction Manager -->
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource" />
</bean>

그리고 트랜잭션을 적용할 대상에 아래 소스처럼 수정한다. 이번에는 게시글이 저장되는 insert 메서드에 트랜잭션을 적용하였다.

@소스 BbsDao.java

@Resource(name = "transactionManager")
protected DataSourceTransactionManager txManager;

public void insert(BbsVo bbsVo) {
    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    TransactionStatus txStatus= txManager.getTransaction(def);

    try {
         this.bbsMapper.insert(bbsVo);
         int a = 1 / 0;
         txManager.commit(txStatus);
    } catch(Exception e) {
         txManager.rollback(txStatus);
    }
}

context-datasource.xml에 transactionManager를 BbsoDao에 선언하였다. try catch문 사이에 txManager 객체를 이용하여 커밋과 롤백을 사용하면 된다.
강제적인 예외를 사용할 수 없기때문에 정수에 소스를 넣어 예외를 발생시켰다.
테스트로 게시물을 등록한다. 메세지는 추가되었습니다 라고 경고창이 출력되지만 실제로는 저장되지 않는 다. 그래서 오류 메세지를 출력할 수 있게 아래와 같이 수정한다.

@소스 BbsDao.java

public void insert(BbsVo bbsVo) throws Exception{
     DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    TransactionStatus txStatus= txManager.getTransaction(def);

    try {
         this.bbsMapper.insert(bbsVo);
         int a = 0 / 1;
         txManager.commit(txStatus);
    } catch(Exception e) {
         txManager.rollback(txStatus);
         throw new Exception(e.getMessage());
    }
}

상위 클래스에서 예외를 받을수 있게 수정하였다. 그리고 ViewController 를 수정한다.

@소스 ViewController.java

try {
     if (idx == null || idx == 0) {
          this.bbsDao.insert(bbsVo);
          xml.setMessage("추가되었습니다.");
          xml.setError(false);
     } else {
          this.bbsDao.update(bbsVo);
          xml.setMessage("수정되었습니다.");
          xml.setError(false);
     }
} catch(Exception e) {
     xml.setMessage(e.getMessage());
     xml.setError(true);
}

procBbsWrite 메서드에 저장 및 수정부분을 위와 가티 수정하면 된다. 그리고 테스트로 게시글을 등록해본다.




posted syaku blog

Syaku Blog by Seok Kyun. Choi. 최석균.

http://syaku.tistory.com


반응형