이번 글에서는 Xplatform과 Spring 연동 템플릿에 대한 본격적인 설명에 앞서서 알아두어야 할 사전지식(?)에 대한 설명을 하고자 한다. 이 글에서 설명하는 내용을 미리 알아두면 앞으로의 글들을 이해하는데 조금은 도움이 될 것이라 생각한다.


우리가 웹프로그래밍을 하면서 서버와 클라이언트가 주고받는 데이터의 위치는 Request 및 Response의 header와 body를 모두 사용하지만 XPlatform과 서버로 데이터를 주고받을때는 body만 사용한다. 그렇기 때문에 Spring에서 사용하는 @RequestParam 어노테이션등을 이용해서 데이터를 받을 수가 없다(굳이 따지면 서버의 URL에 GET 방식으로 구성하면 받을수는 있지만 Xplatform의 VariableList가 더 사용하기에 편하기 때문에 굳이 이 방법을 사용할 일이 없다) body로 주고받을때는 xml로 주고받게 된다. 이 부분은 fiddler 프로그램을 통해 확인할 수 있다. 일례를 들어 다음의 xml은 템플릿에서 search 버튼 클릭시 Request의 body로 날라가는 내용이다.


<?xml version="1.0" encoding="utf-8"?>
<Root>
	<Parameters>
		<Parameter id="firstIndex" type="STRING">5</Parameter>
		<Parameter id="recordCountPerPage" type="STRING">20</Parameter>
		<Parameter id="id" type="STRING">100</Parameter>
		<Parameter id="regUser" type="STRING">chang</Parameter>
	</Parameters>
	<Dataset id="__DS_TRANS_INFO__">
		<ColumnInfo>
			<Column id="strSvcID" type="STRING" size="256"/>
			<Column id="strURL" type="STRING" size="256"/>
			<Column id="strInDatasets" type="STRING" size="256"/>
			<Column id="strOutDatasets" type="STRING" size="256"/>
		</ColumnInfo>
		<Rows>
			<Row>
				<Col id="strSvcID">selectSvc</Col>
				<Col id="strURL">egovSampleSelect.do?id=50&firstIndex=0&recordCountPerPage=10&regUser=terry</Col>
				<Col id="strInDatasets"/>
				<Col id="strOutDatasets">ds_output</Col>
			</Row>
		</Rows>
	</Dataset>
</Root>


Xplatform에서 데이터를 전달할때는 2가지의 데이터 모델을 사용한다. 첫번째는 DataSet 이라는 것으로 위에 있는 XML 중 Dataset 태그로 구성되어 있는 내용이 DataSet이다. DataSet은 row와 column 구조로 되어 있으며 각 column 에 대한 정보(이름, 타입, 크기 등)를 저장하고 있다. 그리고 row에서 해당 column과 매핑이 되는 데이터를 저장하고 있다. 형태만 놓고 보면 Database의 record와 같은 형태라 보면 좀더 이해하기 쉬울듯 하다. Xplatform에서는 이러한 DataSet을 여러개 주고 받을 수가 있는데 서버 측에서는 이렇게 여러개의 DataSet을 DataSetList 라는 개념으로 한번에 읽어들일 수 있다. 그리고 각 DataSet의 정해진 이름을 통해서 DataSetList에서 해당 DataSet을 가져올 수 있다. 두번째는 Variable 이라는 것으로 이것은 key와 value로 구성되는 변수 이름과 값의 형태를 가지고 있다. 위의 XML에서 parameter 태그로 표현할 것이 바로 그것이다. 서버에서는 Variable 클래스로 이를 받을 수 있으며 이러한 Variable이 여러개 모여 있는 것을 VariableList로 받아올 수 있다. Xplatform에서는 DataSet과 Variable을 해당 UI를 통해 직접 이름을 주어 만들고 매핑하며 프로그래밍하기 때문에 XML을 직접적으로 제어하는 일은 없다. XML로 만드는 것은 Xplatform이 자동으로 만들어서 해준다. Xplatform에서 서버로 통신하는 역할을 하는 메소드인 transaction 메소드에서 이러한 과정을 거치게 된다. Variable은 transaction 메소드에서 5번째 파라미터에 속하는 인자값으로 전달하며 a=b 형태로 설정한다. 이때 a는 Varaible의 key 에 해당하며 b는 Variable의 value에 해당된다. 이것또한 위에서 언급했듯이 공백을 구분자로 해서 여러개를 보내줄 수 있다.


Xplatform에서 사용하는 데이터형은 Java의 그것과는 약간 미묘하게 다른 부분이 있다. Xplatform의 javadoc 문서가 온라인으로 배포되는 것이 아니어서 링크를 걸어줄 수는 없지만 javadoc 문서를 혹 구하게 되면 DataTypes 라고 하는 클래스에 이에 대한 도표가 있다. 이 부분만 그림으로 캡춰해서 보여주면 아래와 같다



위의 표를 보면 알다시피 1대1로 매핑이 이루어지는 그런 구조가 아니다. 때문에 상황에 따라서 적절한 데이터형을 return 해주는 메소드를 호출해야 할 필요성이 있다. 나중에 설명되겠지만 HandlerArgumentResolver 개발시에 파라미터에 설정된 VO 클래스 안에 있는 멤버변수의 타입에 따라서 적절한 메소드를 호출해야 한다. DataSet 클래스에 대한 javadoc을 보면 row index와 column index를 주어 해당 값을 가져오는 메소드들이 데이터형별로 있기 때문에 상황에 따라 적절한 메소드를 호출하면 된다.


Xplatform에서 다루는 데이터 형태와 이에 대한 Java 타입에 대한 설명은 이정도로 마치고 다음으로는 이 Xplatform과 Spring 연동 템플릿을 개발하면서 참조했던 자료에 대해 얘기를 좀 하겠다. 사실 이 얘기는 이 템플릿을 이해하는데 있어서 중요한 것은 아니지만 이 템플릿을 수정할려고 할때는 알아두어야 할 사항이어서 언급해둔다. 또한 비단 Xplatform이 아니라 Miplatform이나 Nexacro 같은 투비소프트 솔루션 연동시 어떤 방법으로 연동해야 할 지 모르는 개발자들을 위해 남겨두는 자료의 성격으로 봐주었음 한다. 실제로 진행을 해본건 아니지만 지금 얘기하는 이 방법으로 Miplatform이나 Nexacro에 대한 연동 템플릿도 이번 연재로 소개하게 되는 Xplatform 연동 템플릿과 같은 형태의 템플릿으로 개발할 수 있을것으로 생각되기 때문이다. 이번 템플릿을 개발하면서 가장 많이 본 자료는 3개가 있다.


  1. Spring Javadoc & Reference
  2. Xplatform 클래스 Javadoc
  3. Xplatform에서 제공해주는 jsp와의 연동 샘플 코드


1번은 Spring을 연동하는 것이니 관련 Javadoc과 Reference 문서를 보는건 당연한거구, 2번의 경우는 Xplatform에서 제공하는 Java 클래스의 정보와 이에 대한 데이터 타입의 매칭관련으로 자주 보게 되었다. 그러나 구체적으로 어떻게 코딩으로 구현해야 하는지는 3번을 통해 하게 되었다. 아래에 보여주는 코드는 투비소프트에서 Xplatform을 공부할 수 있게 제공해주는 Xplatform 예제에서 같이 제공해주는 xp_base_sawon_sel.jsp의 코드 일부분이다.


PlatformData o_xpData = new PlatformData();
...
try {	
	...
	
		/********* Dataset 생성 ************/
	  DataSet ds = new DataSet("ds_sawon");
	  
	  ds.addColumn("sabun",DataTypes.STRING, (short)5);
	  ds.addColumn("name",DataTypes.STRING, (short)10);
	  ds.addColumn("dept",DataTypes.STRING, (short)2);
	  ds.addColumn("jikgup",DataTypes.STRING, (short)2);
	  ds.addColumn("ipsa_date",DataTypes.DATE, (short)8);
	  ds.addColumn("gender",DataTypes.STRING, (short)1);
	  ds.addColumn("marry",DataTypes.STRING, (short)1);
	  ds.addColumn("email",DataTypes.STRING, (short)100);
	  ds.addColumn("smemo",DataTypes.STRING, (short)500);
	  while(rs.next())
	  {
	  	int row = ds.newRow();
	  	
	  	ds.set(row, "sabun", rs.getString("sabun"));
	  	ds.set(row, "name", rs.getString("name"));
	  	ds.set(row, "dept", rs.getString("dept"));
	  	ds.set(row, "jikgup", rs.getString("jikgup"));
	  	ds.set(row, "ipsa_date", rs.getString("ipsa_date"));
	  	ds.set(row, "gender", rs.getString("gender"));
	  	ds.set(row, "marry", rs.getString("marry"));
	  	ds.set(row, "email", rs.getString("email"));
	  	ds.set(row, "smemo", rs.getString("smemo"));
	  }
	  	
		/********* 생성된 Dataset을 DatasetList에 추가 ************/
		o_xpData.addDataSet(ds);
	
		nErrorCode = 0;
		strErrorMsg = "SUCC";
		
	} catch (SQLException e) {
		
		nErrorCode = -1;
		strErrorMsg = e.getMessage();

	}	
	
	...
			
} catch (Throwable th) {
	nErrorCode = -1;
	strErrorMsg = th.getMessage();
}

VariableList varList = o_xpData.getVariableList();
		
varList.add("ErrorCode", nErrorCode);
varList.add("ErrorMsg", strErrorMsg);

HttpPlatformResponse pRes = new HttpPlatformResponse(response, PlatformType.CONTENT_TYPE_XML, "UTF-8");

pRes.setData(o_xpData);
pRes.sendData();


DB를 연동하는 부분과 같은 설명에서 불필요한 부분은 생략했다. 위의 코드를 통해 알 수 있는 것은 Xplatform 에서 데이터가 넘어오면 어떤 절차를 거쳐야 하는지, 그리고 조회된 결과를 어떠한 절차로 돌려줘야 하는지를 알 수 있게 된다. 이 부분을 Spring에서 구현해주면 되는 것이다. 즉 데이터가 넘어올때 해줘야 하는 부분은 Spring의 HandlerArgumentResovler 인터페이스를 구현한 클래스에서, 조회된 결과를 넘겨주는 부분은 View 인터페이스를 구현한 클래스에서 해주면 된다. 이후에 이것과 관련된 설명에서 구체적인 코드가 나올테니 일단 이런 샘플 코드를 Spring에서 해당 단계에 맞춰서 사용해주면 된다는 식만 알아두면 된다. 그러면 투비소프트의 기타 솔루션과의 연동도 큰 문제는 없으리라 생각한다.


 1. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (1) - 개요

 2. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (2) - 사전지식

 3. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (3) - HttpServletRequestWrapper

 4. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (4) - Spring Controller에서 하려는 것

 5. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (5) - HandlerMethodArgumentResolver 분석(1)

 6. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (6) - HandlerMethodArgumentResolver 분석(2)

 7. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (7) - HandlerMethodArgumentResolver 분석(3)

 8. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (8) - HandlerMethodArgumentResolver 분석(4)

 9. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (9) - XplatformView 분석

 10. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (10) - 예외처리


트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

적어도 우리나라에서 Web 개발에 참여를 해본 경험이 있는 사람이라면 Xplatform, Miplatform, Nexacro, Gauce, TrustForm, WebSquare 란 단어들 중 하나 정도는 들어본 경험이 있을것이다. 이 프로그램들은 흔히 X-Internet Tool로 통칭되는 Ria Tool들이다. 과거 Adobe에서 Flex를 출시하면서 이러한 Ria Tool의 유행을 불러일으켰지만 지금은 특정 분야를 제외하고는 사장되고 있다. 그 이유는 이런저런 업체들이 여러 프로그램들을 내놓았지만 이들 프로그램들이 전세계의 표준에 채택되지도 그렇다고 표준을 만족하지도 않기 때문이라 본다. 표준이 없이 이런저런 회사에서 만든 프로그램들로 줄구난방이 되면 개발자 입장에서도 모든 개발툴들의 문법및 동작 원리를 꿰차야 한다는건데 그게 어디 쉬운일인가? 물론 실제 이들 프로그램들에 대한 교육을 받아보면 정말 쉽다고는 느껴질수 있다. 그러나 그 교육이란게 정해진 교육용 템플릿을 가지고 하는 것이다보니 그 템플릿의 범주를 벗어난 개발을 해야 하는 상황에서는 개발자가 이를 풀어나갈 방법이 없다.  그렇다고 개발사에게 문의해보면 지원하지 않는다는 식의 성의없는 답변을 얻는 경우도 있다. 개인적으로 이 RIA Tool들은 사내 인트라넷 차원에서의 사무용 프로그램 개발로는 괜찮은 UI를 제공해주지만 대민서비스용 UI로는 부적합하다는 느낌이 강했다. 이러한 느낌도 이런 프로그램들의 확장에 제한이 되는 요인이 될꺼라 생각한다.


이 정도로 RIA Tool에 대한 썰을 고만 풀고 이제 이번 강좌의 개요에 대해 설명하겠다. 아는 지인이 이번에 Xplatform과 Spring 연동 프로젝트에 참여하게 되었는데 내게 템플릿 샘플을 요청하게 되었다. 개인적으로 공부차원에서 만든 어느분이 만든 Miplatform 연동 템플릿을 분석하고 공부해서 만든 것이 있긴 했지만 워낙 오래되어서 내 스스로 다시 분석해야 할 정도였다. 그래서 다시 분석해보니 줄 수가 없었다. 주기에는 범용성이 너무 떨어지는 느낌이 있었다. 그래서 전자정부 프레임워크에서 사용하는 Xplatform 연동 템플릿을 보게 되었는데 이것도 범용성이 떨어지기는 마찬가지였다. 범용성이란 단어가 두 차례나 언급되었는데 이 부분에 대한 언급을 조금은 해야 할 듯 하다.


우리가 Controller 에서 URL과 매핑이 되는 메소드는 다음과 같은 형태를 가지고 있다.


@RequestMapping("/list")
public void list(Model model, SampleVO sampleVO, @RequestParam(value="pageno") int pageno, HttpServletRequest httpServletRequest) {
	...
}


SpringFramework로 MVC Web 개발을 해본 사람이라면 이 코드의 의미는 알 수 있다고 생각한다. 이 메소드에서 넘겨진 파라미터를 보면 Model 객체를 받았고, Request로 넘어온 요청을 분석해서 SampleVO 클래스 객체로 만든 변수인 sampleVO,  pageno 란 파라미터 이름으로 넘어온 값을 int 형으로 받은 pageno 변수, 마지막으로 HttpServletRequest 객체인 httpServletRequest 변수가 있다. SampleVO란 클래스는 자바에서 제공되는 클래스가 아닌 VO 성격으로 만든 클래스이다. 하지만 VO기 때문에 별도의 라이브러리를 요구하지 않는 순수 POJO 스타일의 자바 클래스이다. 그래서 여기까지만 놓고 보면 이 메소드 정의는 Spring을 사용하고 있는 상황에서는 언제 어떠한 경우에서든 적용할 수 있다. 그러면 이제 다음의 코드를 보자


@RequestMapping("/list")
public void list(Model model, VariableList variableList) {
	...
}


위에서 보여준 코드와는 차이가 있다. 파라미터 갯수가 줄었어요..머 이걸 듣자고 차이가 있다는 얘길 하는건 아니다 ㅎㅎ..여기서 보면 VariableList 란 클래스가 있다. 처음보는데 어디서 제공되는 클래스지 라고 생각하는 사람들이 있을텐데 이 클래스는 Xplatform 프로그래밍을 할때 정의해놓은 변수명과 그 값들이 들어가게끔 되어 있는 클래스이다. 즉 사용자가 Xplatform에서 서버에 전달하기 위한 변수를 선언하고 여기에 값을 설정하면 VariableList 클래스 객체에서 이를 받아서 처리할 수 있게 된다. 여기까지 머 느낀거 없을까? 머 Xplatform 연동하는 프로젝트에서 하는 방식이니까 당연 이렇게 코딩하는게 맞지 않을까? 하고 생각하는 사람들이 있을것이다. 그러나 개인적으로는 이렇게 생각하는건 잘못된거라 생각한다. Xplatform같은 RIA 툴은 전적으로 클라이언트 UI 화면을 제작하는 툴이다. 클라이언트 화면이란 여러가지 다양한 존재가 있다. 우리가 흔히 아는 HTML 화면도 있을수 있고, 또 VisualBasic 같은 C/S 프로그램 화면도 존재할 수 있다. 지금 현재는 Xplatform을 사용할 수는 있겠지만 차후에 Xplatform을 사용하지 않을수도 있다. Xplatform이 아닌 다른 RIA 툴을 사용할 수도 있다. 그러면 그때 서버가 방금 위에서 보여준 코드 예와 같이 클라이언트 환경에 종속이 되는 설계를 하게 된다면 어떤일이 벌어질까? 서버쪽도 같이 수정해야 하는 상황이 발생하게 된다. Xplatform을 사용하지 않게 되었기 때문에 VariableList 클래스 객체는 빼고 다르게 구현해야 한다. 그러기 때문에 메소드 내부의 코딩도 바뀌게 된다. 이렇게 클라이언트의 환경에 종속적인 설계를 하게 되면 범용성은 떨어지게 되는 것이다. 그리고 이 점은 전자정부프레임워크에서도 이런 구조로 되어 있다. 여러가지 RIA 툴을 붙이기 땜에 중간에 어댑터 계증을 넣어서 공통화시킨 부분은 있지만 Controller에 DTO로 받는 클래스의 내부 코드를 보면 Xplatform 클래스를 그대로 사용하고 있다. 


그래서 이번 연재를 통해 Xplatform과 Spring 연동시 이런 Xplatform 의존적인 설계가 아닌 POJO 기반의 범용적인 설계를 목표로 한 템플릿 제작에 대해 얘기를 해볼까 한다. 관련 소스는 github에 올려져 있기 때문에 자신이 사용하는 IDE에서 받아서 적용하면 된다. 그리고 개인적으는 Xplatform 개발자가 아니어서 Xplatform의 이런 저런 상황에 모두 다 대응되는 소스는 아닐수도 있겠지만 그래도 상황이 닥쳤을때 그걸 풀어가는 방법에 대한 설명은 진행할 것이기 때문에 이런 방법을 알아두면 풀어가는데 도움이 될 수 있으리라 생각한다.


 1. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (1) - 개요

 2. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (2) - 사전지식

 3. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (3) - HttpServletRequestWrapper

 4. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (4) - Spring Controller에서 하려는 것

 5. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (5) - HandlerMethodArgumentResolver 분석(1)

 6. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (6) - HandlerMethodArgumentResolver 분석(2)

 7. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (7) - HandlerMethodArgumentResolver 분석(3)

 8. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (8) - HandlerMethodArgumentResolver 분석(4)

 9. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (9) - XplatformView 분석

 10. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (10) - 예외처리




트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

자가용을 산 지가 6년이 되어가고 있지만 실제 차를 운전하는 일이 그리 있지 않다보니 하이패스에 대한 필요성을 그리 갖지 못했다. 그런것도 있었고..또 개인적으로는 하이패스로 인해 톨게이트에서 일하시는 분들의 일자리가 줄어드는 것에 대한 거부감도 있어서..암튼 이런 저런 이유로 하이패스를 갖고 있지 않았다. 그러나 간간히 인천으로 오가는 상황이 많다보니..그럴때마다 중앙에 있는 하이패스 차선을 피해서 일반 톨게이트 구간으로 들어가는 번거로움이 있었고 톨게이트를 나오면서 합류하는 과정에서 하이패스 차선을 나오는 차량과 조심해야 하는 상황이 번거로운지라 하이패스를 알아보게 되었다. 예전에 하이패스를 살려고 알아본 적이 있었다. 그때도 요즘처럼 저렴하게 나오는 행복단말기가 있긴 했지만 그때는 IR 방식의 하이패스만 있던지라 내가 사고자 했던 RF 방식이 있지 않았다. 근데 요근래 다시 알아보니 행복단말기로 RF 방식의 하이패스가 출시 된데다가 SIM 타입의 하이패스 카드도 사용 가능한 모델들이 나오고 있었다. 그래서 이번에 이 글에서 소개하고자 하는 행복단말기 AP700 을 구매하게 되었다. 이 제품은 우리가 흔히 아는 오픈마켓(옥션, 지마켓, 11번가 등)에서 모두 판매하고 있기 때문에 구매하는데는 큰 어려움이 없다. 가장 싼 곳을 찾아 구매하면 된다.


구매를 하고서 다음날 바로 왔다. 제품은 다음과 같은 소형 박스로 되어 있다. 왼쪽에 있는 것은 제품 박스이고 오른쪽에 있는건 자가용의 퓨즈 박스에 연결하는 전원선이다. 이것은 구매할때 옵션으로 별도로 구매할 수 있어서 옵션으로 추가 신청해서 구매했다. 하이패스는 상시전원으로 연결할 필요가 없기 때문에(블랙박스의 경우는 차량의 시동이 꺼져도 동작해야 되기 때문에 상시전원에 연결해야 하지만 하이패스의 경우는 시동이 꺼져도 동작해야 할 필요가 없다) 굳이 상시전원에 연결하는 개념으로 전선을 구매할 필요는 없다. 지금은 퓨즈 박스에 전원을 연결하지는 않지만 나중에 그럴 작업을 대비해서 일단은 구매했다. 가격도 얼마 안했다. 한 5000원 더 추가했던걸로..



위의 사진을 봐도 알겠지만 제품 박스가 작은 편이다. 이 사진을 찍기 전에 박스의 크기를 자로 재어 보았는데 박스의 크기는 대략 가로 13 센치, 세로 12 센치, 높이 5 센치 정도의 크기이다. 박스를 열어보면 다음과 같이 들어있다.




위의 사진에서 보면 구성품이 들어있는 가운데 SIM카드 하이패스 사용 주의사항이 있는 종이가 그 위를 덮고 있다. 개봉하면 위의 사진과 같은 상태로 들어있으며 주의사항 종이를 꺼내면 아래와 같이 구성품이 들어있다. 맨 위에 선이 뭉쳐 있는 것은 차량용 시가잭과 연결할 수 있는 전원선이며 왼쪽 아래에 있는 것은 뽁뽁이로 둘러싼 하이패스 기기이다. 그리고 오른쪽 아래에 있는 것은 차량 정면 유리에 붙일 수 있는 거치대이고 맨 밑에 깔려 있는건 제품 보증서가 같이 있는 제품 설명서이다. 제품 설명서에 보면 사용자가 직접 하이패스를 등록하는데 사용되는 USB 케이블이 있다고 되어 있지만 실제로는 들어있지 않았다. 그러나 본인이 직접 등록하려고 한다면 컴퓨터와 연결하는 USB 케이블이 있기 때문에 포함되어 있지 않아도 등록하는데 지장은 없다(이 부분에 대해 부가설명을 하면 제품을 판매하는 오픈마켓에 입점한 판매점에서 차량에 대한 정보(차량 번호, 차주 핸드폰 번호 등)를 전달해주면 판매점에서 하이패스를 등록해주며 하이패스 카드 발급 신청까지도 해준다. 그러나 나의 경우는 내가 직접 그걸 할려고 그런 과정을 거치지 않았다) 제품을 꺼내면 아래 사진과 같다.



사진으로 보면 크게 보이지만 실제로 보면 상당히 작다. 제품의 크기를 자로 재어보았는데 대략 가로 5.5 센치, 세로 5.5 센치, 높이 1 센치 정도의 크기이다. 사각의 모서리를 라운딩 처리를 했기 때문에 부드러운 인상을 준다. ARS 신청 전화번호가 적혀 있는 종이가 SIM 타입 하이패스 카드를 꽃아 넣을 구멍에 꽃혀 있기 때문에 이 종이를 빼고 하이패스 카드를 구멍에 꽃아 구멍안으로 밀어 넣으면 된다. 하이패스를 꽃아서 밀어 넣으면 딸깍 하고 걸리며 카드를 뺄때도 구멍에 꽃혀 있는 카드를 구멍 안으로 밀면 딸각 하고 소리가 나며 걸리는게 빠지게 된다. 이때 손으로 카드를 빼면 된다. 설명서에 보면 하이패스 카드 넣는 방법이 있으니 먼저 이걸 보고 하면 된다.


위에서 잠깐 언급했지만 나는 내가 직접 하이패스를 등록할려고 했다. 신한카드를 사용하고 있기 때문에 매장에서 발급 해주는 하나은행 하이패스 카드는 좀 그러해서 그런것도 있고 이런거는 본인이 직접 해야 나중에 문제가 생길 경우도 대처하기가 편하기 때문에 내가 직접 하기로 했다. 이제 이것을 해보도록 하겠다. 인터넷 익스플로러 웹브라우저를 실행시킨뒤 http://www.e-hipassplus.co.kr 로 접속하면 다음의 화면을 볼 수가 있다



왼쪽은 하이패스를 파는 판매점이나 고속도로 영업소에서 하이패스를 등록할때 클릭해서 들어가는 것이고 오른쪽은 일반인이 자신이 구매한 하이패스를 등록할때 클릭해서 들어가면 된다. 오른쪽의 일반고객용을 클릭하면 다음의 화면을 볼 수 있다.(처음 방문할 경우엔 아래의 사진에 나오는 화면이 아니라 접속시 반드시 AGENT를 실행하셔야 합니다 란 화면이 나오는데 이때는 AGENT실행(메인화면) 버튼을 클릭하면 아래 나오는 화면을 볼 수 있다)



여기 보이는 화면을 보면 왼쪽 상단에 단말기 등록 이란 문구가 있는 사각 버튼이 있는데 이것을 클릭하면 다음의 화면이 나온다.



화면이 한번에 캡춰하기엔 좀 길어서 화면을 두번에 걸쳐서 캡춰했다. 이 화면은 컴퓨터에 연결되어 있는 USB 케이블에 하이패스 단말기를 연결하지 않은 상태에서 보게 되는 화면이다. AP700 의 경우는 다음과 같이 하면 컴퓨터와 연결이 된다.


  • 하이패스 단말기를 USB 케이블에 연결한다
  • 하이패스 단말기의 VOL 버튼을 길게 (약 5초 이상) 누른다. 그러면 "USB 자가발급 설정 모드로 설정되었습니다" 란 안내음성이 나온다. 이 음성이 나온 뒤에 다음 단계를 진행 할 수 있게 된다


위에 언급한 과정을 거치고 다시 단말기연결 재확인 버튼을 클릭하면 다음과 같이 나온다.




화면이 길어서 3개로 나누었다. 맨 위의 화면을 보면 차량과 차주에 대한 정보를 입력하는 화면이다. 차주 이름과 차량 번호를 입력하고 자동차 등록확인 버튼을 클릭하면 해당 차량에 대한 등록이 가능한지에 대한 결과가 나온다. 현재 등록되어 있지 않기 때문에 등록 가능하다는 식의 문구가 나오는 것이 정상이다. 그리고서 아래에 있는 고객 정보를 입력한다. 연락처에 휴대폰 번호를 입력하고 휴대폰 인증하기 버튼을 클릭해서 본인 인증을 진행한뒤 이메일 주소와 정보수신 여부를 체크한다. 


그리고 아래를 보면 약관 동의와 관련된 항목이 있는데 여기서는 약관에 동의하지 않아도 되는 항목이 있어서 이와 관련된 항목만 보여주기로 했다. 여기서 두번째 그림에서 보여지고 있는 수집 이용에 관한 사항이 나오는데 모든걸 다 동의함을 체크하지 않아도 된다. 자세히 보면 선택 이란 항목이 있는데 선택 항목의 내용을 읽어보면 휴대폰번호를 활용한 교통한전 관련 문자 수신으로 되어 있다. 이런거 체크하면 쓸데없이 문자만 자주 올 수도 있기 때문에 이런것은 동의안함 으로 체크해둔다. 그리고 하이패스 교통정보 항목을 보여주는 부분이 있다. 이것은 동의안함 을 해주어도 된다. 이게 내용을 읽어보면 영구 보관이 아니고 2일 이내 보관하는 것이어서 결국 소멸되는 자료이기 때문에 딱히 개인적으로 중요한 자료도 아닐뿐더러 정보를 개인적으로 보관하는 기간이 짧더라도 보관 자체를 싫어하는지라 동의를 하지 않았다. 이 항목을 동의안함으로 하더라도 등록에는 문제가 없다. 위에서 언급한 2가지 항목에 대해서는 동의안함이 가능하다.  이런 약관 동의를 체크한뒤 단말기 등록 버튼을 클릭하면 모든 등록을 마치게 된다.  





트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

사는 얘기/나의 흔적 카테고리의 포스트 목록을 보여줍니다

전자정부 프레임워크를 이용하든, Spring 또는 Spring Boot를 이용하든 Maven 기반으로 war파일이 나오도록 했다면 Jenkins에 작업을 만들어서 이를 Maven을 통해 빌드한뒤 결과물인 war 파일을 Jenkins에 배포할 수 있다. 배포 방법 자체도 여러개가 있는데 여기서는 Jenkins가 배포용 pom.xml을 만들어서 Wildfly에 배포하는 방법을 이야기하도록 하겠다. 기본적인 내용은 여기에서 참조했다. 설명하는 내용은 내가 참조한 사이트에서 설명이 없어서 언급이 안되어 있는 부분에 대해 좀더 디테일하게 설명하도록 하려 한다.


내가 참조한 사이트에서 사용한 방법의 핵심은 Jenkins의 build step에서 배포용 pom.xml을 만드는 shell script를 실행한뒤 maven을 통해 이렇게 만든 배포용 pom.xml을 이용하여 배포하는 것이다. 먼저 그것을 일단 기억해두고 따라왔음 한다. Jenkins에서 작업을 만들때 Git이나 SVN 같은 저장소를 설정하는 부분은 이미 했을테니 이 부분은 따로 설명 하지 않고 빌드 작업에 대해서만 설명하도록 하겠다. 작업을 만들때 Build 항목으로 이동(화면 상단의 탭 메뉴에서 Build를 클릭하면 Build 항목으로 이동한다)한뒤 Add Build Step 콤보박스에서 Invoke top-level Maven targets을 선택하면 다음과 같이 나온다.



Maven Version 콤보박스에서 Jenkins에 등록한 Maven 설정 이름을 지정할 수 있다(Jenkins에서 Maven 등록은 Jenkins 메인 화면의 왼쪽 메뉴에서 Jenkins 관리 -> Global Tool Configuration 에 가서 Maven 항목에서 등록할 수 있다. Jenkins 에서 Maven 을 설치하거나 또는 기존에 Maven이 설치되어 있으면 그걸 이용할 수 있다) Goals로는 현재 저장소에 등록된 소스의 pom.xml을 이용해서 진행하게 될 Maven goal을 설정하는 것으로 기존 빌드한 것을 지우고 새로 빌드하기 위해 clean package를 설정했다. package 를 거치면 war 파일이 만들어지게 된다. 이와 같은 설정을 마치면 Add Build Step 콤보박스에서 Execute Shell을 선택한뒤 Command 항목에 다음과 같이 입력한다.



그림으로는 잘 안보이기 때문에 별도로 스크립트 소스를 옮겨 놓으면 다음과 같이 된다.


#!/bin/bash
wildfly_plugin_version=1.2.1.Final

# 서버 접속 정보 예시
wildfly_hostname=172.100.0.3
wildfly_port=9990
wildfly_username=admin
wildfly_password=admin

pushd target
shopt -s nullglob
for file in *.war; do
  war_filename="${file}"
  break
done
popd

cat << EOF > deploy.xml
<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>example.com</groupId>
  <artifactId>wildfly-deploy</artifactId>
  <version>1</version>
  <build>
    <plugins>
      <plugin>
        <groupId>org.wildfly.plugins</groupId>
          <artifactId>wildfly-maven-plugin</artifactId>
          <version>${wildfly_plugin_version}</version>
          <configuration>
            <filename>target/${war_filename}</filename>
            <hostname>${wildfly_hostname}</hostname>
            <port>${wildfly_port}</port>
            <username>${wildfly_username}</username>
            <password>${wildfly_password}</password>
            <targetDir>.</targetDir>
            <force>true</force>
          </configuration>
        </plugin>
      </plugins>
  </build>
</project>
EOF

exec mvn -f deploy.xml wildfly:deploy-only


위의 Shell Script 소스를 보면 자신의 환경에 맞춰서 지정해야 할 변수가 몇개 있다. 그런것들을 포함해서 이 소스에서 사용되고 있는 변수에 대한 설명을 하도록 하겠다.


변수 wildfly_plugin_version 은 Wildfly 배포에 사용하기 위해 이용하는 Maven Plug-In인 wildfly-maven-plugin의 버전을 명시한다. 2018년 3월 9일자로 wildfly-maven-plugin 버전은 1.2.1.Final 이어서 1.2.1.Final로 지정했다.


Wildfly에 배포하는 방법으로 Wildfly의 Management 기능을 이용해서 하기 때문에 배포 대상이 되는 Wildfly의 정보를 저장해야 할 변수들이 필요하게 되는데 그 기능을 하는 변수들이 wildfly_hostname, 

wildfly_port, wildfly_username, wildfly_password 이 4개이다. wildfly_hostname 변수엔 Wildfly가 운영되는 서버의 도메인 또는 IP 주소가 설정되어 있어야 한다. wildfly_port는 Wildfly가 사용하는 Management Port 번호를 설정해야 한다. 일반적으로 Wildfly의 Management가 사용하는 Port는 9990이며 기본적으로 설정된 포트를 사용하고 있어서 9990을 썼다(만약 다른 Port를 사용하게 되면 다른 Port 번호를 써야 한다) wildfly_username은 Wildfly Management 기능을 이용하기 위한 관리자 ID를 설정해야 한다. wildfly_password는 방금 언급한 관리자 ID와 매핑되는 비밀번호를 설정한다. 이 Wildfly의 Management 관리자는 Wildfly 디렉토리/bin에 있는 add_user.sh(Windows일 경우 add_user.bat)을 이용해서 등록할 수 있다. 이렇게 등록할 때 사용된 관리자 ID와 비밀번호를 wildfly_username과 wildfly_password 에 설정한다


pushd target에서 target 부분은 jenkins가 maven 을 통해 빌드하면 만들어지는 결과물(war 파일, 컴파일된 class 파일등)이 들어 있는 디렉토리이다. 이 디렉토리의 기준은 Jenkins가 설치되어 있는 디렉토리/workspace/등록된 작업 이름 디렉토리이다.(구체적인 예를 들자면 Centos에서 yum으로 Jenkins를 설치할 경우 /var/lib/jenkins 에 설치된다. Jenkins에 samplesvn으로 작업을 등록했다면 이 작업의 기준 디렉토리는 /var/lib/jenkins/workspace/samplesvn 이 된다) 이 기준이 되는 디렉토리 안에 SVN 같은 Source 저장소에 저장되어 있는 source 들이 저장되며 이 상태에서 maven이 target 디렉토리를 만들어 빌드 중간 산출물과 결과물인 war 파일을 넣게 된다. target 디렉토리에 이러한 결과물들이 들어가기 때문에 target으로 설정하게 된다. 그리고 이 디렉토리 안의 파일들을 조사해서 war 파일을 발견하면 war_filename 이란 변수에 war 파일 이름을 설정하게 된다.


이렇게 빌드한 결과물인 war 파일 이름을 알게 되면 이 war 파일을 Wildfly에 배포하는 pom.xml을 deploy.xml 이란 파일 이름으로 저장하게 된다. groupId 와 artifactId 태그는 임의의 값으로 설정하고(아무 값이나 설정해도 된다는 뜻이다) build 태그의 하위 태그에서 shell script에서 우리가 값을 저장하기 위해 사용했던 변수들을 사용하게 된다. 여기서 filename 태그에서는 war 파일 이름을 war 파일이 있는 위치와 같이 지정해야 한다. 이렇게 만들어지는 deploy.xml은 위에서 언급한 기준이 되는 디렉토리에 만들어지기 때문에 war 파일은 target 디렉토리 안에 있게 되므로 target/${war_filename} 로 지정하게 된다. 상대경로로 지정하는 것이어서 target 앞에 /를 붙이지 않게 된다(/을 붙이면 /target이 되어 절대경로 개념이 된다)


이렇게 배포용 Maven 빌드 설정 파일인 deploy.xml 이 만들어진 뒤에 이 deploy.xml 을 Maven을 통해서 배포하게 되는데 이때 사용하는 것이 mvn 명령이 된다. 파일 이름이 pom.xml 이 아닌 다른 파일 이름을 사용할 땐 -f 옵션을 사용한뒤 파일명을 지정하고 작업하기 위한 goal로 wildfly:deploy-only 를 사용하는 것이다. 이렇게 빌드함으로써 Wildfly에 배포작업이 이루어진다. 그리고 Maven Plug-In 방식을 이용한 배포이기 때문에 deploy.xml 의 내용을 바꾸어서 Tomcat Maven Plug-In을 통한 배포 작업도 할 수 있다.

트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

IntelliJ를 이용해서 Spring Boot와 Docker에서 실행중인 Wildfly를 연동해서 개발하는 방법에 대한 내용을 적어보기로 한다


먼저 IntelliJ에 Docker에서 실행중인 Wildfly container를 연동하는 개발방법에 대해서는 여기에 언급이 되어 있어서 별도의 설명은 하지 않도록 한다. 다만 링크되어 있는 글에 대한 방법을 따라 하면서 내가 시행착오(?)를 겪어본 일들에 대해서만 설명하도록 하겠다.


위의 링크를 읽어보면 wildfly를 자신의 PC에 설치되어 있는 wildfly를 이용하는 것이 아니라 docker wildfly 이미지를 별도로 만들어서 하고 있다. 이 부분때문에 docker에 대한 이해가 필요한 부분이 있다. 나의 경우는 docker hub에 올린 내가 만든 image인 furywolf/centoslocal_wildfly를 사용해서 연동했다. docker image에 대한 container를 foreground로 실행하는 경우(docker run 또는 docker-compose up 명령 사용시 -d 옵션을 사용하지 않는 경우를 의미한다) 해당 command 창에서 wildfly 로그를 볼 수 있다. 그러나 IntelliJ 안에서는 로그를 볼 수가 없다. 이 부분은 개발할때 불편함으로 다가오는 부분이 있다.


그리고 eclipse와 docker wildfly container를 연동할때와 같이 개발하는 PC에 같은 버전의 Wildfly가 설치되어 있어야 한다(eclipse의 경우는 docker wildfly container와 연동하는 방법이 2가지가 제공되는데 Management 기능을 사용할 경우엔 개발하는 PC에 같은 버전의 Wildfly가 설치되어 있지 않아도 된다) 이것은 remote 서버와의 연동으로 인해 발생한 사항이다. 이것을 꼭 합리적이지 않다고 볼수는 없다. 가령 개발할때는 자신의 local에 설치된 wildfly 서버를 사용하고 remote 서버의 디버깅 용도로 연동할때 자신의 local에 설치된 wildfly 서버를 사용할 수도 있기 때문이다. 그러나 이것에 대해 거부감이 느껴진다면 그냥 local에 설치된 wildfly 서버에서 개발해도 된다.


개발을 하다보면 수정사항이 발생하여 서버를 재기동 하지 않은 상태에서 자신의 수정 사항이 반영되길 바라는 상황이 존재한다. 이렇게 할려면 IntelliJ에서 설정된 wildfly를 실행시킬때 Run이 아닌 Debug로 실행해야 한다. Run으로 실행하게 되면 Wildfly서버를 재기동시키면서 반영하게 되는 Redeploy 기능만 제공되지만 Debug로 실행시키면 Widlfly 서버를 재기동 하지않으면서 변경된 클래스가 교체되는 식으로 반영이 되는 Hot Swap Classes와 Redeploy 모두 제공하기 때문에 개발할때는 Debug 모드에서 개발하면 편리한 점이 있게 된다.


이렇게 연동된 wildfly에 Spring Boot Project를 실행하게 되면 해당 Context Root가 Spring Boot Project가 생성하는 war 파일 이름으로 잡히게 된다(예를 들어 Spring Boot Project가 최종적으로 mysample.war 파일을 만들게 되면 Context Root는 /mysample로 설정이 된다) 그래서 Conext Root를 /로 설정하려면 WEB-INF 디렉토리에 jboss-web.xml이 다음의 내용으로 있으면 된다


<?xml version="1.0" encoding="UTF-8"?>
<jboss-web xmlns="http://www.jboss.com/xml/ns/javaee"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="
               http://www.jboss.com/xml/ns/javaee
               http://www.jboss.org/j2ee/schema/jboss-web_5_1.xsd">
    <context-root>/</context-root>
</jboss-web>


<context-root> 태그의 값으로 /를 설정함으로써 Context Root를 /로 설정이 된다. 근데 여기서 주의해야 할 점이 있다. 바로 jboss-web.xml을 Spring Boot Project에서 두어야 할 위치이다. 위에서 언급했을때 이 jboss-web.xml의 위치는 WEB-INF 디렉토리에 있어야 한다고 했다. 그러면 우리가 알고 있는 Spring Boot Project의 구조에서 이 WEB-INF 디렉토리에 파일을 설정해 둘 수 있는가? 그럴수 없다. 어 왜? 우리가 src/main/java 에서 만든 java package 및 class 들, src/main/resources 에서 만든 각종 properties 파일들 모두 WEB-INF에 들어가잖아? 맞다. WEB-INF에 들어간다. 근데 엄밀하게 말하면 WEB-INF에 들어가는 것이 아니라 WEB-INF/classes 디렉토리에 들어가게 된다. 즉 WEB-INF 디렉토리의 하위 디렉토리인 classes 디렉토리에서부터 들어가지기 때문에 기존의 프로젝트 구조에 jboss-web.xml을 설정하게 되면 WEB-INF/classes 디렉토리를 기준으로 해서 들어가지기 때문에 Wildfly에서 인식을 하지 못하게 된다. 그래서 IntelliJ에서 다음과 같이 디렉토리를 만들어서 jboss-web.xml을 넣어두어야 한다. 다음의 그림을 보도록 하자.



노란색 박스가 되어 있는 부분을 보자. 원래 Spring Boot Project 구조에서는 webapp 폴더가 없는데 src/main에 webapp 디렉토리를 만들고 그렇게 만든 webapp 디렉토리 밑에 WEB-INF 디렉토리를 만들고 이렇게 만든 디렉토리에 위에서 언급한 jboss-web.xml을 넣게 된다. 이렇게 하면 Wiildfly에서 Context Root를 /로 설정된 상태에서 내가 만든 Project가 Wildfly에 실행되게 된다

 

트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

지난 글에서는 Spring-Mybatis 연동 작업에서 SQL문이 실행시 오류가 발생할 경우 이를 예외에 포함시켜 던지기 위해 사전적으로 이해해야 할 내용인 SqlSessionTemplate 클래스에서 예외가 발생시 처리되는 과정에 대한 설명을 진행했다. 이번에는 이러한 이해를 기반으로 우리가 나아갈 방향인 오류가 발생한 SQL문을 넣어 이를 예외에 던지는 구체적인 작업에 대해 진행하도록 하겠다.


저번글에서도 잠깐 예기했지만 Spring-Mybatis 연동 작업에서 SQL 관련 오류로 인해 예외가 발생하면 DataAccessException 클래스 객체(정확하게는 DataAccessException 클래스를 상속받은 PersistenceException 클래스 객체이다)가 생성되어 던져지게 된다. 이 구조를 건드리지 않고 유지하면서 작업을 진행하겠다. 우리가 만들어야 할 예외 클래스는 사실 별거 없다. SQL 문을 저장할 수 있는 DataAccessException 클래스를 상속받은 새로운 예외 클래스를 만들면 된다. 다음의 코드를 보자


import org.springframework.dao.DataAccessException;

public class CustomDataAccessException extends DataAccessException {
    String query;
    public CustomDataAccessException(String msg){
        super(msg);
    }
    public CustomDataAccessException(String msg, String query){
        this(msg);
        this.query = query;
    }
    public CustomDataAccessException(String msg, Throwable cause){
        super(msg,cause);
    }
    public CustomDataAccessException(String msg, Throwable cause, String query){
        this(msg,cause);
        this.query = query;
    }
    public String getQuery() {
        return query;
    }
    public void setQuery(String query) {
        this.query = query;
    }
}

위에서 보여주는 코드는 DataAccessException 클래스를 상속받은 CustomDataAccessException 클래스 소스이다. 이 클래스의 멤버변수로 String 타입 변수인 query가 있다. 이 변수에 우리가 저장하고자 하는 SQL문이 들어가게 된다. 생성자는 DataAccessException 클래스의 생성자를 그대로 사용한것과 여기에 추가로 SQL문을 생성자에서 바로 저장할 수 있게끔 SQL문을 파라미터로 전달하는 생성자를 추가로 설정했으며, query 변수의 getter, setter 메소드를 추가했다. 코드를 보면 알 수 있겠지만, 외부에서 SQL문을 받았다고 가정할 경우 이 SQL문을 CustomDataAccessException 클래스의 생성자나 setter 메소드를 통해 SQL문을 넣을수 있고 getter 메소드를 통해 SQL문을 가져올 수 있다.


그러면 이 예외 객체를 어떻게 생성할까? 이전 글에서 SqlSessionTemplate 클래스를 설명할때 Proxy를 설명하는 과정에서 SqlSession 인터페이스가 구현된 클래스인 DefaultSqlSession 클래스 객체를 대신 실행하는 SqlSessionInterceptor 클래스에 대해 설명했었다. 이 클래스가 DefaultSqlSession 클래스 객체를 대신 실행할때 이 과정에서 예외가 발생하면 DataAccessException 클래스 객체를 상속받은 클래스 객체가 생성되어 예외가 던져지게 된다. 이 DataAccessException 클래스 객체를 상속받은 클래스 객체를 위에서 우리가 만든 CustomDataAccessException 클래스 객체로 바꿔서 생성한뒤 여기에 오류가 발생한 SQL문을 넣어서 예외를 던지면 되는 것이다. 다음의 코드를 보자.


public class CustomSqlSessionTemplate extends SqlSessionTemplate {

    public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) throws IllegalAccessException{
        this(sqlSessionFactory, sqlSessionFactory.getConfiguration().getDefaultExecutorType());
    }

    public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType) throws IllegalAccessException{
        this(sqlSessionFactory, executorType,
                new MyBatisExceptionTranslator(
                        sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), true));
    }

    public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
                                    PersistenceExceptionTranslator exceptionTranslator) throws IllegalAccessException{

        super(sqlSessionFactory, executorType, exceptionTranslator);

        Field field = ReflectionUtils.findField(this.getClass().getSuperclass(), "sqlSessionProxy");
        field.setAccessible(true);
        field.set(this, (SqlSession) newProxyInstance(
                SqlSessionFactory.class.getClassLoader(),
                new Class[] { SqlSession.class },
                new CustomSqlSessionInterceptor()));
        field.setAccessible(false);

    }

    private class CustomSqlSessionInterceptor implements InvocationHandler {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
            ExecutorType executorType = getExecutorType();
            PersistenceExceptionTranslator exceptionTranslator = getPersistenceExceptionTranslator();

            SqlSession sqlSession = getSqlSession(
                    sqlSessionFactory,
                    executorType,
                    exceptionTranslator);
            String sqlQuery = "";
            try {
                Object result = method.invoke(sqlSession, args);
                if (!isSqlSessionTransactional(sqlSession, sqlSessionFactory)) {
                    sqlSession.commit(true);
                }
                return result;
            } catch (Throwable t) {
                sqlQuery = getQuery(sqlSession, (String)args[0], args[1]);
                Throwable unwrapped = unwrapThrowable(t);
                if (exceptionTranslator != null && unwrapped instanceof PersistenceException) {
                    // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
                    closeSqlSession(sqlSession, sqlSessionFactory);
                    sqlSession = null;
                    Throwable translated = exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
                    if (translated != null) {
                        unwrapped = translated;
                    }
                }
                CustomDataAccessException cdae = new CustomDataAccessException(unwrapped.getMessage(), unwrapped.getCause(), sqlQuery);
                throw cdae;
            } finally {
                if (sqlSession != null) {
                    closeSqlSession(sqlSession, sqlSessionFactory);
                }
            }
        }
    }

    /**
     * Mybatis Query를 파라미터가 Bounding 된 상태의 쿼리를 보고자 할때 사용
     * @param sqlSession
     * @param queryId
     * @param sqlParam
     * @return
     */
    private String getQuery(SqlSession sqlSession, String queryId , Object sqlParam) throws NoSuchFieldException, IllegalAccessException{

        BoundSql boundSql = sqlSession.getConfiguration().getMappedStatement(queryId).getBoundSql(sqlParam);
        String sql = boundSql.getSql();

        if(sqlParam == null){
            sql = sql.replaceFirst("\\?", "''");
        }else{
            if(sqlParam instanceof Number){ // Integer, Long, Float, Double 등의 숫자형 데이터 클래스는 Number 클래스를 상속받기 때문에 Number로 체크한다
                sql = sql.replaceFirst("\\?", sqlParam.toString());
            }else if(sqlParam instanceof String){	// 해당 파라미터의 클래스가 String 일 경우(이 경우는 앞뒤에 '(홑따옴표)를 붙여야해서 별도 처리
                sql = sql.replaceFirst("\\?", "'" + sqlParam + "'");
            }else if(sqlParam instanceof Map) {        // 해당 파라미터가 Map 일 경우

        	/*
        	 * 쿼리의 ?와 매핑되는 실제 값들의 정보가 들어있는 ParameterMapping 객체가 들어간 List 객체로 return이 된다.
        	 * 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	 * 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	 */
                List paramMapping = boundSql.getParameterMappings();

                for (ParameterMapping mapping : paramMapping) {
                    String propValue = mapping.getProperty();        // 파라미터로 넘긴 Map의 key 값이 들어오게 된다
                    Object value = ((Map) sqlParam).get(propValue);    // 넘겨받은 key 값을 이용해 실제 값을 꺼낸다
                    if (value instanceof String) {            // SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
                        sql = sql.replaceFirst("\\?", "'" + value + "'");
                    } else {
                        sql = sql.replaceFirst("\\?", value.toString());
                    }

                }
            }else{					// 해당 파라미터가 사용자 정의 클래스일 경우

        	/*
        	 * 쿼리의 ?와 매핑되는 실제 값들이 List 객체로 return이 된다.
        	 * 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	 * 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	 */
                List paramMapping = boundSql.getParameterMappings();

                Class paramClass = sqlParam.getClass();
                // logger.debug("paramClass.getName() : {}", paramClass.getName());
                for(ParameterMapping mapping : paramMapping){

                    String propValue = mapping.getProperty();                   // 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수명
                    Field field = doDeclaredField(paramClass, propValue);       // 관련 멤버변수 Field 객체 얻어옴
                    if(field == null) {                                         // 최상위 클래스까지 갔는데도 필드를 찾지 못할경우엔 null이 return 되기 때문에 null을 return 하게 되면 NoSuchFieldException 예외를 던진다
                        throw new NoSuchFieldException();
                    }
                    field.setAccessible(true);                    // 멤버변수의 접근자가 private일 경우 reflection을 이용하여 값을 해당 멤버변수의 값을 가져오기 위해 별도로 셋팅
                    Class javaType = mapping.getJavaType();            // 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수의 타입

                    if (String.class == javaType) {                // SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
                        sql = sql.replaceFirst("\\?", "'" + field.get(sqlParam) + "'");
                    } else {
                        sql = sql.replaceFirst("\\?", field.get(sqlParam).toString());
                    }
                }
            }
        }
        return sql;
    }

    /**
     * 클래스 필드 검색 재귀함수
     * @param paramClass
     * @param propValue
     * @return
     */
    private Field doDeclaredField(Class paramClass, String propValue){
        Field field = null;
        try {
            /*
            * 해당 객체의 필드를 검색 한다.
            * 존재 하지 않을 경우 NoSuchFieldException 발생
            */
            field = paramClass.getDeclaredField(propValue);

        } catch ( NoSuchFieldException e ){
            // NoSuchFieldException 발생 할경우 상위 클래스를 검색 한다.
            field = doDeclaredField(paramClass.getSuperclass(), propValue);
        }

        return field;
    }

}


위에서 보여주는 코드는 SqlSessionTemplate 클래스를 상속받은 클래스인 CustomSqlSessionTemplate 클래스 소스이다. 이 코드에 있는 메소드인 getQuery 메소드와 doDeclareField 메소드는 Mybatis의 XML에 저장되어 있는 SQL문에 사용자가 넘긴 객체의 값을 바인딩하는데 사용되는 메소드이다. 이 메소드에 대해서는 메소드 소스 안에 주석이 있기 때문에(블로그에 있는 소스가 보기 힘들면 github에 올려놓은 소스를 봐도 된다. 거기에도 주석이 있다) 주석을 보면 이해하는데 큰 문제가 없어서 이 메소드들에 대해서는 설명하지 않도록 하겠다. 여기서는 이 두 메소드를 통해 SQL문을 구했다는 가정을 갖고 이 SQL문을 넣은 예외를 생성하는 방법에 대해 설명하도록 하겠다. 


위에서 설명했다시피 CustomSqlSessionTemplate은 SqlSessionTemplate 클래스를 상속받은 클래스라고 얘기했다. 그렇기때문에 CustomSqlSessionTemplate은 SqlSessionTemplate 클래스가 제공하는 생성자를 사용해야 한다. 이전 글에서 SqlSessionTemplate 클래스의 생성자에 대해 설명한 것을 보면 알 수 있겠지만 SqlSessionTemplate 클래스에서의 첫번째 생성자는 두번째 생성자에 특정 값을 파라미터로 주어 두번째 생성자를 재활용하고 두번째 생성자는 세번째 생성자에 특정 값을 파라미터로 주어 세번째 생성자를 재활용 하는 방식으로 구현하고 있다. 여기에서도 이 방식을 그대로 따라간다. 그래서 CustomSqlSessionTemplate 클래스의 첫번째와 두번째 생성자도 각각 두번째 생성자와 세번째 생성자에 특정 값을 파라미터로 주어 재활용 하고 있다.


public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) throws IllegalAccessException{
	this(sqlSessionFactory, sqlSessionFactory.getConfiguration().getDefaultExecutorType());
}

public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType) throws IllegalAccessException{
	this(sqlSessionFactory
		, executorType
		, new MyBatisExceptionTranslator(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), true)
	);
}


이러한 구조이기 때문에 결국 세번째 생성자가 실질적인 작업을 하는 코드라 보면 된다. 그러면 CustomSqlSessionTemplate 또한 SqlSessionTemplate 클래스의 세번째 생성자를 그대로 사용하면 되지 않을까? 이렇게 생각할 수 있는데 이렇게 진행 할 수가 없다. 이해를 돕기 위해 기존 SqlSessionTemplate 클래스의 세번째 생성자의 소스 부분과 여기에 사용되는 멤버변수들을 소스로 보여주겠다.


private final SqlSessionFactory sqlSessionFactory;
private final ExecutorType executorType;
private final SqlSession sqlSessionProxy;
private final PersistenceExceptionTranslator exceptionTranslator;
  
public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {

    notNull(sqlSessionFactory, "Property 'sqlSessionFactory' is required");
    notNull(executorType, "Property 'executorType' is required");

    this.sqlSessionFactory = sqlSessionFactory;
    this.executorType = executorType;
    this.exceptionTranslator = exceptionTranslator;
    this.sqlSessionProxy = (SqlSession) newProxyInstance(
        SqlSessionFactory.class.getClassLoader(),
        new Class[] { SqlSession.class },
        new SqlSessionInterceptor());
}


이 생성자는 SqlSessionTemplate에 정의된 4개의 멤버변수에 대해 초기화하고 있다. 4개의 멤버변수중 sqlSessionFactory, executorType, exceptionTranslator 변수는 파라미터로 전달된 값을 설정하기 때문에 SqlSessionTemplate 클래스를 상속받은 CustomSqlSessionTemplate 클래스에서도 생성자에 파파라미터로 넘긴뒤 super를 이용해서 넘겨주는 방식으로 설정할 수 있다. 그러면 남은 것은 SqlSession 타입  멤버변수인 sqlSessionProxy 변수 이거 하나이다. 문제는 이 멤버변수는 외부에서 파라미터로 값을 넘겨받아 설정하는 것이 아니라 생성자 안에서 초기화를 하고 있기 때문에 CustomSqlSessionTemplate 클래스 생성자에서도 이 멤버변수를 생성자 안에서 초기화 해야 한다.  그러면 우리는 CustomSqlSessionTemplate 클래스 생성자 안에서 부모클래스인 SqlSessionTemplate 클래스 멤버변수인 sqlSessionProxy를 초기화 할 수 있을까? 그럴수가 없다. 이유는 sqlSessionProxy가 private final 로 정의되어 있기 때문이다. final 이기 때문에 생성자 외에는 값을 설정할 수가 없다. 이때문에 sqlSessionProxy 변수에 대한 setter 메소드가 존재하지 않는다. setter 메소드가 존재한다면 super를 사용한 setter 메소드를 이용해서 설정할 수 있지만 setter 메소드가 존재하지 않기 때문에 이 방법을 사용할 수 없다. 그러면 이 sqlSessionProxy 변수를 자식클래스인 CustomSqlSessionTemplate 클래스에서 어떻게 초기화 시킬수 있을까? Java의 Reflection을 이용해서 이 멤버변수에 값을 설정할 수 있다. 다음의 CustomSqlSessionTemplate 클래스 생성자 소스를 보자. 이 생성자 소스를 보면 Reflection을 이용해서 부모 클래스의 멤버변수를 초기화 하고 있다.


public CustomSqlSessionTemplate(SqlSessionFactory sqlSessionFactory
								, ExecutorType executorType
								, PersistenceExceptionTranslator exceptionTranslator
								) throws IllegalAccessException {

	super(sqlSessionFactory, executorType, exceptionTranslator);

	Field field = ReflectionUtils.findField(this.getClass().getSuperclass(), "sqlSessionProxy");
    field.setAccessible(true);
    field.set(this
			, (SqlSession) newProxyInstance(
						SqlSessionFactory.class.getClassLoader(),
						new Class[] { SqlSession.class },
						new CustomSqlSessionInterceptor()
			  )
			);
    field.setAccessible(false);
}


코드를 살펴보자. 처음엔 super를 이용해서 파라미터 3개를 사용하는 부모클래스 생성자를 사용한다. 이 생성자를 사용하면 부모클래스인 SqlSessionTemplate 클래스의 sqlSessionProxy 변수가 초기화가 된다. 어 초기화 된다고? 그럼 이걸 이대로 사용하면 되자너? 이렇게 생각할 수 있다. 사실 맞다. sqlSessionProxy 변수가 초기화가 이루어진다. 근데 이 초기화된 값을 사용하면 안된다. SqlSessionTemplate 클래스 소스에서 이 변수가 초기화될 때 코드를 살펴보면 Proxy 객체를 실행하는 주체의 역할을 하는 클래스인 SqlSessionInterceptor 클래스 객체를 설정하는 부분이 있다. 이 클래스가 하는 역할은 이전 글에서도 설명했지만 예외가 발생할 경우 Spring 에서 제공하는 예외로 변환해서 이를 던지는 기능이 있다. 그러나 우리는 이 기능을 곧이 곧대로 사용하면 안된다. 왜냐면 위에서 만든 예외인 CustomDataAccessException 클래스 객체가 던져지게끔 설정해야 하기 때문이다. 그래서 SqlSessionTemplate 클래스의 내부 클래스인 SqlSessionInterceptor 클래스를 사용하면 안된다(실제로 이 클래스는 SqlSessionTemplate 클래스에서 private으로 설정되어 있기 때문에 참조도 할 수가 없다) 그래서 이러한 역할을 하는 클래스를 새로이 만들어야 한다. 그것이 CustomSqlSessionInterceptor 클래스이다. 부모클래스인 SqlSessionTemplate 클래스의 멤버변수인 sqlSessionProxy에 CustomSqlSessionInterceptor 클래스 객체를 설정해서 CustomDataAccessException 클래스 객체를 예외로 던질수 있게끔 설정하는 것이다. 그래서 Java의 Reflection 기능을 이용해서 이미 한번은 초기화되어 있는 sqlSessionProxy 멤버변수를 받아서 이를 다시 재설정하고 있다. 말이 나온 김에 CustomSqlSessionInterceptor 클래스 소스만 따로 살펴보자.


private class CustomSqlSessionInterceptor implements InvocationHandler {
	@Override
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
        ExecutorType executorType = getExecutorType();
        PersistenceExceptionTranslator exceptionTranslator = getPersistenceExceptionTranslator();

        SqlSession sqlSession = getSqlSession(
                sqlSessionFactory,
                executorType,
                exceptionTranslator);
        String sqlQuery = "";
        try {
            Object result = method.invoke(sqlSession, args);
            if (!isSqlSessionTransactional(sqlSession, sqlSessionFactory)) {
                sqlSession.commit(true);
            }
            return result;
        } catch (Throwable t) {
            sqlQuery = getQuery(sqlSession, (String)args[0], args[1]);
            Throwable unwrapped = unwrapThrowable(t);
            if (exceptionTranslator != null && unwrapped instanceof PersistenceException) {
                // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
                closeSqlSession(sqlSession, sqlSessionFactory);
                sqlSession = null;
                Throwable translated = exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
                if (translated != null) {
                    unwrapped = translated;
                }
            }
            CustomDataAccessException cdae = new CustomDataAccessException(unwrapped.getMessage(), unwrapped.getCause(), sqlQuery);
            throw cdae;
        } finally {
            if (sqlSession != null) {
                closeSqlSession(sqlSession, sqlSessionFactory);
            }
        }
    }
}


CustomSqlSessionInterceptor 클래스 또한 CustomSqlSessionTemplate 클래스 안에서 private 으로 선언한 내부 클래스이다. 기존 SqlSessionTemplate 클래스에 선언된 SqlSessionInterceptor 클래스와 비교해보면 큰 차이는 없다. 다만 catch 부분에서 차이가 발생한다. catch 부분을 진행한다는건 예외가 던져지는 상황이 발생한 것이므로 이 시점에 예외가 발생한 SQL문을 구해야 한다. 그 역할을 하는것이 getQuery 메소드이다. invoke 메소드의 두번째 파라미터로 Object 클래스 배열이 넘어오게 되는데 getQuery 매소드에서는 이 배열의 첫번째 값과 두번째 값을 사용하고 있다. 첫번째 값으로 넘어오는 것은 Mybatis의 SQL문 id이다. 우리가 XML로 Mybatis에서 사용할 SQL문을 정의할때 해당 SQL문에 대한 id를 설정하게 되는데 이 id 값이 넘어온다는 의미이다. 배열의 두번째 값으로 넘어오는 것은 그 SQL문에서 사용될 바인딩되는 값이 넘어오게 된다. 그러나 대부분 특정 클래스의 객체이거나 Map 또는 List 객체가 넘어가게 될 것이다. 아무튼 SqlSession 인터페이스가 구현된 객체와 이 2개의 파라미터를 같이 넘겨줌으로써 실제 사용된 값이 바인딩된 SQL문을 얻어오게 된다. 그 후로 진행되는 것은 기존 SqlSessionInterceptor 클래스와 동일하게 진행되다가 우리가 목표로 한 CustomDataAccessException 클래스 객체를 생성해서 이를 예외로 던지게 된다. 이 객체를 생성할때 Spring에서 사용되는 예외로 변환된 예외 클래스 객체부분과 SQL문을 같이 설정함으로써 이 예외를 받는 쪽에서는 해당 예외에 대한 내용과 관련 SQL문을 가져올 수 있게 된다.


이제 이 글의 마무리를 하는 시점이 왔다. 이렇게 기능을 구현했는데 이 기능이 동작을 하는지 확인을 할 시간인 것이다. github에 올린 Source에서 controller package 에 속한 클래스로 TestController와 TestControllerAdvice 이렇게 2개의 클래스가 있다. 먼저 TestController는 다음과 같다


@RestController
public class TestController {

    @Autowired
    BoardService service;

    @RequestMapping(value="/errortest")
    public Map<String, String> sendError(){
        Map<String, String> result = new HashMap<String,String>();
        Board board = new Board(0, "title123456789012345678901234567890", "contents1", 0);
        service.insertBoard(board);
        return result;
    }
}


이 Controller는 Rest API를 제공하는 목적으로 설계한 클래스이다. 즉 작업을 마치고 작업에 대한 결과나 내용을 Map에 담아 return 하는 방식으로 설계했다. 기본적인 컨셉은 이렇게 설계했지만 동작하는 과정에서는 일부러 에러가 발생하도록 구현했다. Source를 보면 Board 클래스 객체를 만들어서 거기에 테이블에 넣고자하는 값들을 생성자를 통해 설정한뒤에 insertBoard 메소드를 실행시켜 테이블에 값을 insert 하는 작업을 진행한다. 이 과정에서 에러가 발생하게 되는데 제목으로 입력되고 있는 값인 title123456789012345678901234567890 이 테이블에 설정되어 있는 크기보다 크게 입력되어서 에러가 발생한다. 에러가 발생되면 예외가 던져지게 되고 그 예외를 이제 받아서 처리해야 한다. 그 역할을 하는 클래스가 TestControllerAdvice 이다.


@RestControllerAdvice
public class TestControllerAdvice {
    @ExceptionHandler(CustomDataAccessException.class)
    public String processCustomDataAccessException(CustomDataAccessException cdae){
        return cdae.getQuery();
    }
}


Spring에서는 Controller가 예외를 던질경우 이를 처리하는 클래스에 대한 정의를 할 수 있는데, @Controller 일 경우엔 @ControllerAdvice 어노테이션이 붙은 클래스가, @RestController일 경우엔 @RestControllerAdvice 어노테이션이 붙은 클래스가 해당 작업을 하게 된다. 메소드에 @ExceptionHandler 어노테이션을 붙이고 거기에 해당 예외 클래스를 지정함으로써 그 예외 클래스를 처리하는 메소드로 지정하는 방식이다. SQL문 처리시 문제가 발생하면 관련 SQL문이 들어있는 CustomDataAccessException 클래스 객체가 던져지게 되므로 @ExceptionHandler 어노테이션에 CustomDataAccessException 클래스를 설정해서 이 예외를 처리하게끔 했다. 메소드엔 파라미터로 CustomDataAccessException 클래스 객체를 받게끔 되어 있고, 이 객체에서 getQuery 메소드를 호출해서 관련 SQL문을 받게 된뒤 이 SQL문을 return 하게 된다. 웹브라우저에서 http://localhost:8080/errortest 로 접속하면 이 SQL문을 볼 수 있는것은 TestController와 TestControllerAdvice 클래스에서 이러한 동작을 하기 때문에 볼 수 있는 것이다.



트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

Spring와 Mybatis를 연동하는 어플리케이션을 개발하면서 SQL문 실행시 오류가 발생할 경우 이 SQL문 자체를 얻고 싶을때가 있다. 어플리케이션을 개발할때가 가장 대표적인 상황일것이고, 개발중인 상황이 아니라하더라도 운영중에서 사용자가 입력한 데이터가 쿼리와 결합해서 어떻게 실행이 되길래 오류를 일으키게 되는건지 그 이유를 알기 위해 요구될 수도 있다. 운영중이든 개발중이든 SQL문에 대한 요구는 항상 있게 마련이고, 그래서 이러한 요구에 대한 반영 형태중 가장 많이 사용되는 예가 아마 관련 SQL문을 log로 남기는 형태일 것이다. log로 남기는 것은 개발자가 별도로 구현을 하지 않아도 log4jdbc 같은 좋은 log 라이브러리가 나와있어서 이를 이용하면 원하는 결과를 얻어낼 수 있다.


그러나 log로 남겨야 하는 경우가 아닐 경우는 어떻게 할까? 예를 들어 SQL문 실행시 오류가 발생하면 관리자에게 이메일로 관련된 SQL문을 보내야 한다고 한다면...? log4jdbc 라이브러리에 SQL을 얻어오는 getter 메소드가 있다면 좋겠는데 API 문서를 봐도 찾을수가 없었다. 그리고 설사 이러한 메소드가 있다 해도 이 요구사항을 만족시키기 위해 log4jdbc를 사용하는 것도 설득력이 떨어지는 부분이 있다. 만약 요구사항에 log로도 남겨야 한다면 log4jdbc는 탁월한 선택이 될 수 있겠지만 log로 남겨야 하는 요구사항이 없을 경우 단지 SQL문을 얻기 위해 log4jdbc 라이브러리를 사용하는 것은 좀 그러했다. 그리고 log4jdbc 라이브러리를 사용한 사람들은 알고 있겠지만 기존의 사용하는 DataSource가 아닌 log4jdbc에서 제공하는 DataSource 클래스로 기존의 DataSource를 한번 감싸서 진행하게 된다. 이걸 한번 더 감싼다고 퍼포먼스에 문제가 있을거라 보지는 않지만 간혹 프로젝트에 나가 일하다보면 이런걸로도 문제를 삼는 개발자가 있기 마련이다. 그래서 이런 꼬투리(?)를 잡히지 않게 하기 위해서라도 조금은 고민해볼 필요가 있었다. 그래서 이번에 총 2개의 글로 이 주제에 대해 다루려고 한다. 글은 SQL 실행 오류가 발생했을 경우에 대해서만 다루려고 하지만 사용되는 기술은 SQL 실행 오류가 발생하지 않더라도 사용이 가능하기 때문에 응용의 폭은 넓다고 볼 수 있다. 이번 글에서는 Spring과 Mybatis 연동시 예외가 발생되는 원리에 대해 설명을 하고 다음 글에서 이러한 예외가 발생했을때 발생되는 예외에 SQL문을 같이 넣어서 보내는 방법에 대해 설명하도록 하겠다. 이 2개의 글에서 인용되는 Source는 github에 올라와 있으니 참조할 사람은 전체 소스를 참조하려면 github에서 끌어오면 된다.


앞에서 잠깐 바로 언급했지만 오류를 일으키는 SQL문을 예외를 통해서 받을려고 한다. 이렇게 처리한 이유는 Spring으로 작업한 사람들은 알겠지만 오류가 발생할 경우 예외를 던지게 하고 이러한 예외만 전담해서 처리하는 클래스를 별도로 두어서 하는 방식을 택하는지라 기존의 Spring이 구현하고 있는 컨셉을 바꾸고 싶지는 않았다. 이것에 대한 구체적인 내용은 다음 글에서 언급하도록 하기로 하고 일단 큰 구조는 Mybatis 작업시 SQL문 처리 관련으로 예외가 던져질때 문제의 SQL문 자체를 같이 넣어서 예외를 던진다고 이해하고 넘어가자.


던져지는 예외를 통해 SQL문을 전달한다고 얘기했기 때문에 이 시점에서 Spring와 Mybatis 연동시 예외가 던져지는 구조에 대해 이해할 필요가 있다. Spring과 Mybatis 연동시 SQL문을 이용한 작업의 핵심이 되는 클래스는 SqlSessionTemplate 클래스이다. Mybatis가 SQL문을 이용한 작업을 할때 SqlSession 인터페이스가 구현된 클래스로 작업을 하게 되는데 Mybatis는 SqlSession 인터페이스가 구현된 클래스로 DefaultSqlSession 클래스를 제공해준다. 그러나 이 클래스는 thread-safe한 구조가 아니기 때문에 multi thread 환경에서는 사용시 문제가 있는 클래스이다. 이에 반해 SqlSessionTemplate 클래스는 SqlSession 인터페이스가 구현되어 있고 Spring에 의해 관리되어지며 thread-safe한 구조이기 때문에 multi thread 환경에서도 안심하고 작업할 수 있는 클래스이다. Spring에 의해 관리되어진다는 것은 이 클래스 객체의 생성부터 소멸까지 Spring이 관리한다는 것을 의미하며 Spring에 의해 관리되어지기 때문에 트랜잭션 또한 Spring이 제어하는 트랜잭션 하에서 움직임을 뜻한다. 


이렇게 SqlSessionTemplate 클래스가 차지하는 비중은 크다고 볼 수 있는데 이 클래스 소스를 보면 우리가 흔히 Mybatis 작업을 하면서 사용하게 되는 select, insert, delete, update 메소드를 볼 수 있다.그도 그럴것이 이 메소드들은 SqlSession 인터페이스에 있는 메소드이기 때문에 SqlSessionTemplate 클래스 입장에서는 이 메소드들을 구현해야 하는 상황이다. 근데 이 메소드들이 구현된 소스를 보면 한결같이 사용되는 멤버변수가 있다. 다음은 SqlSessionTemplate의 insert 메소드 소스이다.


public int insert(String statement, Object parameter) {
    return this.sqlSessionProxy.insert(statement, parameter);
  }


우리가 insert문을 사용할때 보면 해당 Insert문이 정의되어 있는 statsment id와 insert문에 값으로 들어갈 값들이 들어있는 클래스 객체를 파라미터로 넘겨야 한다. 그걸로 유추해보면 String statement 파라미터는 statement id, Object parameter는 insert문에 값으로 들어갈 값들이 들어있는 클래스 객체임을 알 수 있다. 그리고 this는 SqlSessionTemplate 클래스 자체를 의미하기 때문에 위에서 말한 멤버변수는 sqlSessionProxy임을 알 수 있다. 그리고 sqlSessionProxy가 다시 insert 메소드를 사용하고 있다. 그러면 sqlSessionProxy 멤버변수가 SqlSessionTemplate 클래스 안에서 어떻게 초기화되고 있는지를 보자. 다음은 SqlSessionTemplate 클래스에서 sqlSessionProxy 멤버변수가 선언되어 있는 부분과 이를 초기화하는 생성자 부분의 코드이다.


private final SqlSession sqlSessionProxy;

public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {

    notNull(sqlSessionFactory, "Property 'sqlSessionFactory' is required");
    notNull(executorType, "Property 'executorType' is required");

    this.sqlSessionFactory = sqlSessionFactory;
    this.executorType = executorType;
    this.exceptionTranslator = exceptionTranslator;
    this.sqlSessionProxy = (SqlSession) newProxyInstance(
        SqlSessionFactory.class.getClassLoader(),
        new Class[] { SqlSession.class },
        new SqlSessionInterceptor());
}


위의 코드에서 보다시피 sqlSessionProxy 멤버변수는 SqlSession 인터페이스 타입으로 final이 걸려있다(이 부분은 지금 당장은 기억할 필요는 없지만 두번째 글에서 실제 구현하는 내용을 설명할때 알아두어야 할 필요가 있다)  SqlSessionTemplate 클래스에서는 3개의 생성자가 있는데 첫번재 생성자는 두번째 생성자에 default 값을 파라미터로 넘기면서 사용하고 있고, 두번째 생성자는 세번째 생성자에 default 값을 파라미터로 넘기면서 사용하고 있기 때문에 실제적인 작업은 세번째 생성자를 보면 된다. 여기서 봐야 할 부분은 sqlSessionProxy 변수를 설정하는 부분이다. java에서 제공하는 Proxy 클래스의 newProxyInstance 메소드를 이용해서 프록시 형태의 객체를 생성하고 있다. 파라미터로 받는 것중 SqlSessionFactory 타입 파라미터는 Spring과 Mybatis 연동시 Bean으로 만들어야 하는 SqlSessionFacotory  Bean이 설정된다. 이 부분은 SqlSessionTemplate을 Bean으로 만들때 보면 생성자로 SqlSessionFactory Bean을 받아서 넣는 부분이 있는데 이 SqlSessionFactory Bean 하나만 넣는 생성자가 위에서 잠깐 언급했던 SqlSessionTemplate 클래스의 첫번째 생성자이다. 이 sqlSessionProxy 변수를 생성할때 기억해야 할 것이 SqlSessionInterceptor 클래스 객체를 넣는 부분이다. 이 클래스가 바로 우리가 알아두어야 할 핵심부분이기 때문이다. SqlSessionInterceptor 클래스에 대한 설명은 밑에서 할 것이기 때문에 지금은 일단 이 클래스가 알아두어야 할 목표라는 정도만 이 시점에서 기억해두자. 이렇게 생성자를 거치면 sqlSessionProxy 변수는 Proxy 형태이지만 실질적으로는 DefaultSqlSession 클래스 객체가 설정된다. 그래서 위에서 insert 메소드를 실행하는것은 바꿔말하면 프록시 형태로 생성된 DefaultSqlSession 클래스 객체의 insert 메소드가 실행된다고 보면 된다.


자 지금부터 약간은 생각밖의 전개가 벌어지게 된다. 위에서 언급했지만 sqlSessionProxy 변수는 프록시 형태로 생성된다고 했다. 여기서 두루뭉실하게 프록시 형태라고 했지만 조금 더 구체적으로 얘기하면 이 객체는 SqlSessionFactory 클래스가 만들어준 SqlSession 인터페이스가 구현된 객체이며 그 객체의 메소드에 대한 실행은 SqlSessionInterceptor 클래스 객체가 대신 실행하는 그런 객체라고 보면 된다. 즉 객체의 생성과 실행이 직접적인 생성과 실행이 아닌 간접적인 생성과 실행이라고 이해하면 된다. 그래서 SqlSessionFactory 타입 bean이 만든 DefaultSqlSession 클래스 객체를 SqlSessionInterceptor 클래스 객체가 대신 실행해주는 그런 상황이 벌어진다고 보면 된다. sqlSessionProxy가 내부적으로는 DefaultSqlSession 객체를 가지고 있지만 그 실행은 다른곳에서 이루어진다고 이해하면 DefaultSqlSession 클래스의 insert 메소드가 살제 이루어지는 곳은 SqlSessionInterceptor 클래스에서 실행된다고 보면 된다. 이제 SqlSessionInterceptor 클래스 소스를 보자. 이 클래스는 SqlSessionTemplate의 내부 클래스로 선언되어 있다.


private class SqlSessionInterceptor implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      SqlSession sqlSession = getSqlSession(
          SqlSessionTemplate.this.sqlSessionFactory,
          SqlSessionTemplate.this.executorType,
          SqlSessionTemplate.this.exceptionTranslator);
      try {
        Object result = method.invoke(sqlSession, args);
        if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
          // force commit even on non-dirty sessions because some databases require
          // a commit/rollback before calling close()
          sqlSession.commit(true);
        }
        return result;
      } catch (Throwable t) {
        Throwable unwrapped = unwrapThrowable(t);
        if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
          // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
          closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
          sqlSession = null;
          Throwable translated = SqlSessionTemplate.this.exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
          if (translated != null) {
            unwrapped = translated;
          }
        }
        throw unwrapped;
      } finally {
        if (sqlSession != null) {
          closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
        }
      }
    }
  }


SqlSessionInterceptor 클래스는 InvocationHandler 인터페이스를 구현하고 있는데 이것은 Proxy 객체를 실행하는 클래스를 만들때 반드시 따라야 한다. InvocationHandler 인터페이스에 있는 invoke 메소드가 실행이 되면서 invoke 메소드의 파라미터로 넘어간 Proxy 객체와 메소드 정보, 그리고 파라미터 정보를 이용해서 Proxy 객체의 메소드가 실행이 된다. 그러나 여거 소스를 보면 Proxy 객체로 넘어간 SqlSession 인퍼테이스가 구현되어 있는 DefaultSqlSession 객체의 Proxy 객체를 이용해서 메소드를 실행하는게 아니라 getSqlSession 메소드를 이용해서 다시 SqlSession 인터페이스를 구현한 객체를 구하고 있다. 그리고  파라미터로 받은 Method 클래스 객체를 통해서 SqlSession 인터페이스에 선언된 메소드를 실행하고 있다. 이 시점에서 SQL에 대한 실행이 발생하게 되며 여기서 문제가 생기면 예외가 발생하게 된다. 그리고 실행에 대해 문제가 없으면 SqlSession이 별도 트랜잭션 관리를 받는지 확인을 해서 별도 트랜잭션 관리를 받지 않으면 commit하게 된다. 여기서는 Spring이 트랜잭션을 관리하기 때문에 SqlSession이 별도 트랜잭션 관리를 받으므로 commit을 SqlSessionInterceptor 클래스에서 하지 않는다. 그리고 메소드 실행 결과를 return 하게 된다.


지금까지는 정상적인 실행 과정에 대해 설명을 했고 이제 위에서 잠깐 언급했던 예외가 발생된 상황에 대해 설명하겠다. 예외가 발생되면 catch 블럭에 가게 된다. 이때 발생되는 예외는 SQL 관련 예외(ex : SQLException)가 발생되는게 아니라 Proxy로 인해서 발생되는 예외이기 때문에 InvocationTargetException이 발생한다. 즉 예외 또한 Proxy 같이 한번 감싸진 형태로 예외가 던져진다. 그래서 실제 SQL 관련 예외 객체를 얻어야 하는 작업을 거쳐야 하는데 그 작업을 해주는 메소드가 unwrapThrowable 메소드이다. 이 메소드가 InvocationTargetException 클래스 객체 안에 있는 실제 예외 객체를 return 해준다. SqlSession 인터페이스의 insert 메소드를 실행하는 과정에서 예외가 발생했다면 이 unwrapThrowable 메소드를 통해 return 되는 예외 클래스 객체는 mybatis에서 정의된 PersistenceException 클래스 객체이다. 이렇게 받은 mybatis 예외를 이용해서 하는 작업이 있는데 바로 Spring에서 정의된 예외 클래스로 변환하는 작업이다. Spring 입장에서는 mybatis 뿐만 아니라 Database 작업과 관련된 라이브러리를 사용할 수 있다. Native JDBC를 사용할 수도 있고 Hibernate, Eclipselink 등의 ORM과도 연동될 수 있다. 이럴때 똑같은 insert 작없을 하는데도 Native JDBC에서는 SQLException을 받아서 처리하고, Hibernate나 Eclipselink 에서는 거기에서 정의된 예외를 던지게끔 하면 Spring을 이용해서 개발할때 연동되는 라이브러리에 따라 예외를 받는 catch문을 다르게 써야 하기 때문에 개발하는데 있어서 불편함이 있게 된다. 무엇을 연동하든 간에 Spring에서는 동일한 예외 클래스 객체를 던지게끔 해준다면 개발자는 그만큼 개발하기 편한 면이 있다. 그래서 Spring에서는 각 Database 연동 관련 라이브러리가 던지는 예외들을 Spring이 정의한 예외로 변환하는 일종의 변환기 역할을 하는 클래스가 있다. 이러한 클래스를 Spring 또는 Spring과 연동하는 Database 관련 라이브러리가 제공하는데 이 클래스는 Spring의 PersistenceExceptionTranslator 인터페이스를 구현해야 한다. 우리는 mybatis를 연동하고 있으니 mybatis 입장에서 보자면 이 변환기 역할을 위해 mybatis가 제공하는 클래스인 MybatisExceptionTranslator 클래스 객체가 설정이 된다. 다시 코드로 돌아가서 이런 변환기가 설정되어 있는지, 그리고 현재 받은 예외가 mybatis의 PersistenceException 클래스 또는 그것의 하위 클래스인지를 체크해서 이를 만족하면 SqlSession 인터페이스를 구현한 객체를 닫고(close), 객체를 null 값으로 설정한뒤 설정되어 있는 예외 변환기를 이용해 Mybatis 예외를 Spring 예외로 바꾼다. 이 시점에서 Spring의 DataAccessException 클래스의 하위 클래스 예외 객체가 생성이 된다. 그런 뒤에 이 변환된 예외로 던져지게 된다. 만약 변환기가 설정이 안되어 있으면 위에서 언급했던 InvocationTargetException 예외 클래스 객체에서 꺼냈던 mybatis 예외 객체를 던져주게 된다.


지금까지 우리가 아무 생각없이 SqlSesion 인터페이스에서 정의한 select, insert, delete, update  등의 메소드를 실행할때 SqlSessionTemplate 클래스 객체에서 어떤 과정으로 실행이 되는지, 그리고 예외가 발생했을때 이를 어떻게 처리하는지에 대해 설명했다. 앞으로 우리가 다룰 내용과 비교하면 지금까지의 내용이 너무 길수도 있는데 이러한 지식을 알아야 앞으로 우리가 작업할 내용을 하는데 있어 도움이 될꺼란 생각으로 설명했다. 다음에는 실제로 기존에 이렇게 구현되어 있는 부분을 SQL문이 포함된 예외가 던져지게끔 작업하는 내용에 대해 설명하겠다.











트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring 카테고리의 포스트 목록을 보여줍니다

요즘 집에서 컴퓨터 게임을 할때 키보드/마우스로 하는 게임보다는 게임 패드를 이용해서 하는 게임 비중이 더 올라갔다. 그래서 집에 있는 PlayStation2(언제 샀더라..까마득하다..) 패드를 PC에 연결하는 중간 컨버터를 이용해서 게임을 하고 있었는데, 점점 불편함이 느껴졌다. 일단 패드 자체가 10년도 넘어가다보니 패드 자체가 좀 그러했고 반응이 약긴 딜레이 되는 느낌이 있었다(이건 아마도 중간에 컨버터가 신호를 변환하는 과정땜에 그런게 아닌가 추측된다) 그래서 결국 PC 전용 패드를 사야겠다고 맘먹고 검색하게 되었는데 사람들의 추천 PC용 패드는 한결같었다. 엑박패드. 예전에 국내에 엑스박스가 첨 출시되던 시점에 내가 다녔던 회사에서 게임 개발한다고 엑스박스를 산적이 있었다. 그때 첨 접해봤는데 그 당시엔 PlayStation2 패드와 별 차이가 없었다. 그러나 패드란게 계속 발전하다보니 확실히 이번에 산 엑스박스 패드는 PlayStation2 패드와는 차별화가 되어있었다. 물론 10여년도 더 된 패드와 최신 패드와의 단순 비교는 문제가 있다. 그러나 이러한 비교에 방점을 찍지 말고 그만큼 엑스박스 패드가 사용자 친화적으로 변화했다는 것에 의미를 가졌음 한다. 그래서 이번글엔 얼마전에 산 엑스박스 One S 블루투스 컨트롤러(이하 패드 라고 하겠다)에 대한 개봉 및 약간의 사용기를 적고자한다. 늘 그렇듯이 내 블로그에서 리뷰하는 하드웨어는 항상 내 돈을 지불하고 쓴 사용기임을 밝혀둔다. 그런 의미에서 구매내역을 올린다.



배송된 제품은 양호한 상태로 왔다. 배송박스 안에 뽁뽁이로 몇겹을 둘러싼 제품박스가 들어있었다



뽁뽁이를 제거하면 이렇게 제품 박스가 모습을 드러낸다. 검정색 패드를 샀기 땜에 당연 제품박스도 검정색 패드 사진이 있다.



박스를 열면 아래 사진과 같이 정말 만지고만 싶은 패드가 모습을 드러낸다



검정색 제품인데다가 그림자가 있는 상태에서 찍다보니 사진이 잘 나오지 않았지만 외형상 제품은 딱히 흠잡을만한 부분이 없었다. 패드를 꺼내면 다음과 같이 나온다.


 

안에는 패드에 넣는 AA 사이즈 건전지가 2개가 들어있다. 블루투스 패드이기 때문에 무선으로 연결이 되며 이로 인해 전원이 별도로 필요하다(부가적으로 진동 기능도 지원되기 때문에 이로인한 전원의 필요성도 있다) 건전지를 꺼낸뒤 패드 바닥을 받쳐주었던 종이 부분을 위로 젖히면 다음과 같이 나온다.



엑스박스 패드의 일반 메뉴얼과 간략한 메뉴얼이 있다. 그러나 이 2개 모두 이용하기엔 부실한 면이 있다. 개인적으로 이 제품의 유일한 단점이라고 생각되는 부분이 이 메뉴얼 부분이다. 블루투스 연결이라든가 각 부분에 대한 설명이 없거나 빈약해서 메뉴얼로는 좀 부족한 면이 있었다. 


제품의 포장에 대해서는 이쯤으로 설명을 마치고 이제 본격적으로 제품에 대해 얘기해보겠다. 패드를 위에서 보면 다음과 같다



빨간색 박스안에 있는 엑스박스 로고 버튼을 누르면 버튼에 햐안 불이 들어오면서 패드에 전원이 들어온다. 블루투스 장치(여기서는 장치가 PC가 되겠다)와 페어링이 이루어지지 않은 상태인 경우 하얀 불이 깜박이는 점멸 상태로 이루어진다. 그러나 일단 페어링이 이루어지면 패드 전원이 올라갈때 깜박이지 않고 하얀 불이 계속 들어있는 상태로 바뀌게 된다. 개인적으로 가지고 있는 블루투스 헤드셋이 전원 버튼을 길게 누르면 블루투스 연결 장치를 찾는 상태로 바뀌다보니 전원을 길게 눌러서 페어링 과정을 하는 걸로 생각했는데 이 버튼은 전원버튼의 역할만 하기 때문에 오해없기를 바란다(페어링 하는 버튼은 따로 있다. 이에 대해선 밑에서 다시 언급하겠다) 다음의 사진은 전원이 들어와있는 상태의 패드이다



아날로그 스틱은 머리 부분이 움풀 파여 있어서 손가락으로 아날로그 스틱을 움직일때 손가락과 밀착되는 느낌이 있다(이 부분이 PlayStation2 패드와 차이점이 있는데 PlayStation2 패드는 머리가 위로 볼록 나와 있기 때문에 손가락과의 접점이 상대적으로 좁아지는 측면이 있다) 아날로그 스틱이 중앙으로 돌아오는 복귀력도 만족스러웠다. 기타 버튼에 대한 설명은 게임 플레이와 관련된 버튼이어서 특별히 설명할 부분이 없다. 이제 패드의 앞부분을 보자.


양왼쪽과 오른쪽에 각각 2개씩 있는 트리거 버튼 상태도 양호했다. 위쪽에 있는 트리거 버튼은 딸각 거리는 느낌이 강했고 아래쪽에 있는 트리거 버튼은 약간 뻑뻑한 느낌이 있었다. 그러나 개인적으로 이 빡빡한 느낌이 오히려 좋았다. 왜냐면 트리거 버튼은 의외로 힘이 많이 들어가는 부분이기도 한데 이렇게 빡빡하게 함으로써 이 힘이 들어가는 것을 어느정도 견뎌줄것이라는 생각이 들었다. 위에 페어링과 관련된 내용을 잠깐 언급했는데 빨간색 원으로 그려진 버튼을 누르면 페어링이 이루어진다. 위에서 페어링이 이루어지지 않은 경우 전원 버튼이 하얀 불이 깜박이는데 이 페어링 버튼을 길게 누르면 페어링이 이루어지면서 하얀 불이 깜박이는 상태가 그냥 하얀 불이 계속 들어오는 상태로 바뀌게 된다. 노란색 박스로 표시된 부분은 마이크로 usb 충전 포트이다. 이 패드엔 배터리팩 이라고 하는 악세사리가 있다. 건전지가 들어가는 부분에 배터리팩을 끼고 마이크로 USB 케이블 이용해서 충전을 할 수 있다. 그러나 이러한 충전은 배터리팩을 이용했을때만 가능하고 AA 사이즈 충전지를 끼웠을 경우엔 충전이 되지 않는다. 이제 패드의 밑부분을 보자.



빨간색 사각박스로 표시한 덮개를 열면 건전지를 넣을수 있는 공간이 나타난다. 배터리팩이 있으면 이 부분에 배터리팩을 넣으면 된다. 노란색 박스로 표시된 것은 엑스박스 헤드셋과 연결하는 포트이고 그 옆에 있는 구멍은 3.5mm 오디오 포트이다. 그러나 PC와 연결할때는 이 두 부분은 해당사항이 없다. 



Windows 10의 경우엔 애니버서리 업데이트 버전(빌드번호 1607) 이상에서만 블루투스로 연결이 가능하다. Windows 7/8.1의 경우는 블루투스 연결은 지원되지 않고 USB 케이블로의 연결만 가능하다.(물론 Windows 10도 USB 케이블로의 연결이 가능하다) Windows 10과 블루투스로 페어링이 이루어지면 Windows 10의 설정 -> Bluetooth 및 기타 디바이스 에 들어가면 빨간색 박스에 표시된 것과 같이 Xbox Wireless Controller 항목이 생기게 된다.


구매하고 지금까지 한 이틀 이용해봤는데 정말 사람들이 왜 그리 엑스박스 패드를 추천하는지를 알게 되었다. PlayStation2 패드와는 달리 머랄까 손에 착착 감기는 느낌이 정말 강했다. 다만 나 같이 PlayStation2 패드 쓰다가 엑박 패드를 사용할 경우 트리거 버튼 부분이 적응이 안될수 있다. 패드가 모양을 보면 아래에서 위쪽으로 좁아지는 형태이다보니 상대적으로 트리거 버튼이 중간쪽으로 조금 더 이동한 형태여서 이 부분에 대한 적응이 조금 필요할 수 있다. 그러나 그걸 감안하더라도 정말 괜찮은 제품임에는 분명하다. PC용 패드 제품으로는 비싼 제품군이지만 그 가격 이상의 성능과 만족감을 주는 제품이다. 다른 사람들에게도 적극 추천해주고 싶은 제품이다.


트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

사는 얘기/나의 흔적 카테고리의 포스트 목록을 보여줍니다