반응형

1. 개요

  • CSRF의 정의
  • Spring Boot에서의 CSRF Filter 처리 방식

 

2. CSRF란?

 사이트 간 요청 위조(Cross-site request forgery, CSRF)는 웹사이트 취약점 공격의 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 말한다. - 위키백과

 

 

  • 필자는 처음 CSRF라는 용어를 접할 때 정의는 이해가 갔으나 실제 어떤 원리를 이용해 '공격'이라는 행위를 하는지 전혀 이해가 가지 않았다... 나와 같은 사람들의 이해를 돕기 위해 CSRF 공격에 대한 예를 스토리 텔링 기법으로 들어보도록 하겠다. (기억에 잘 남는다고 함...)
안녕하세요. 저는 아무개 커뮤니티의 회원 "talmobil"이라고 합니다.
유머게시판에 게시글 하나를 썼는데 좋아요가 너무 없어서 자괴감이 들었습니다. 아니, 열받더라구요. 제 글이 정말 재밌는데, 사람들이 일부러 좋아요를 눌러주지 않는 것 같거든요. -ㅅ-
그러던 중 좋은 방법이 하나 떠올랐습니다. 다른 사람이 제 게시글을 보면 좋아요를 자동으로 누르게 하는거죠!
방법은 이래요.
커뮤니티 사이트에서 게시글에 좋아요를 누르니  "http://community.com/like?post=[게시글 id]"이더군요.
제 게시글 ID를 확인해보니 9566 이구요. 그래서 게시글 안에 이미지 태그를 하나 삽입하고 src 값에 "http://community.com/like?post=9566"을 넣어봤습니다. 그랬더니 사람들이 제 게시글을 조회하게 되면 이미지 태그의 URL인 "http://community.com/like?post=9566" 가 자동으로 호출되었습니다! @_@.. 자동으로 제 게시글의 좋아요 수가 올라갔어요. 후후.. 다른 커뮤니티 사이트에도 어그로 게시글을 올리고 마찬가지로 src 태그를 넣었더니 더 빨리 오르더군요!
사용자들은 아무것도 모르고 제가 의도한대로 요청을 하고있는겁니다! 이제는 좋아요가 아닌 다른걸 해봐야겠어요 흐ㅏㅎ하
  • 아무것도 모르는 사용자들은 공격자가 의도한 행위를 사이트간 위조 요청으로 하게 되었다. 이런 공격이 바로 CSRF 이다. 그렇다면 이러한 공격을 막는 방법이 있을까? 당연히 있다. 서버에서 요청에 대한 검증을 하는 것이다. 이를테면 토큰 값으로 말이다. 이것도 예를 들어보겠다.
안녕하세요. 아무개 커뮤니티 담당자 "ㅅㄱ"입니다.
제가 집에 우환이 생겼습니다. 웃음이 필요해서 유머 게시판을 쓱 둘러봤는데, 개노잼 글이 하나 있더군요. 뭐지 하고 넘어가려는데 이 게시글의 좋아요 수가 무려 1만이 넘어갔습니다. 그리고 다시 들어가보니 누르지도 않은 좋아요가 눌러져있더라구요. 이게 말이되나? 싶었습니다.
 서버 로그를 확인해보니 제가 좋아요를 누른 기록도 있었습니다. 뭔가 이상해서 게시글 내용을 살펴봤는데 이미지 태그의 src에 좋아요 처리를 하는 URL이 들어가 있었습니다. 해당 URL을 구글링 해보니 이미 다른 웹사이트 게시글에도 포함되어 있더군요. 말로만 듣던 CSRF 공격이었습니다.
 막을 방법은 클라이언트마다 토큰을 발급하는 겁니다. 서버는 토큰 값을 검증하고요. 프로세스는 다음과 같습니다.
1. 저희 커뮤니티를 접근하면 특정 토큰을 클라이언트에게 발급함과 동시에 저희 서버 세션 안에 넣습니다.
    > A 클라이언트에 대해 A 토큰을, B클라이언트에 대해 B 토큰을 이렇게 각각 발급하는 겁니다.
2. 클라이언트는 모든 API를 호출할 때 필수적으로 이 토큰 값을 헤더에 넣어 보냅니다.
3. 서버에서는 요청을 수행하기전 Filter 레벨에서 세션 안에 들어있는 토큰 값과 요청 토큰 값을 비교합니다.
4. 토큰 값이 불일치할 경우 비정상적인 요청으로 판단하고 Access Denied 시킵니다.

토큰 검증을 성공하려면 요청 시 CSRF Token 값을 헤더에 넣어줘야하는데, 공격자는 사용자마다 각각 발급된 토큰 값을 알 수 없기때문에 막힐겁니다.
추가적으로, 이러한 방식을 스프링 시큐리티에서 기본적으로 지원하고 있더라구요!
  •  이처럼 서버에서 토큰을 발급 및 검증하고 클라이언트에서는 발급받은 토큰을 요청 값에 포함시켜 보내는 방식으로 CSRF 공격을 막을 수 있다. 스프링 시큐리티 의존성을 추가하면 이와 같은 방식을 제공하는 CSRF Filter가 자동으로 추가된다. csrf().disable() 설정을 통해 해제도 가능하다. 그럼 Spring Security에서 제공하는 CSRF Filter는 요청을 어떻게 처리하는지 알아보자.

 

3. CSRF Filter

CsrfFilter.java

  • 빨간색 표시된 부분이 중요한 부분이다. 하나하나 설명해보겠다.

 

  3.1. tokenRepository.loadToken(request)

  • 요청 세션에서 CSRF Token 객체를 조회한다. key는 HttpSessionCsrfTokenRepository.CSRF_TOKEN 이다.

tokenRepository 구현체

 

  3.2. tokenRepository.generateToken(request)

  • CSRF Token이 없을 경우 DefaultCsrfToken 생성자를 통해 CSRF Token을 발급한다.
  • 생성자 마지막 파라미터로 createNewToken() 리턴 값을 넣고있는데, token 값으로 사용할 랜덤 값을 생성한다.

createNewToken 메서드
DefaultCsrfToken 생성자

  • 생성자 메서드 호출 후 CsrfToken 객체를 생성하면 다음과 같은 형태의 CSRF Token 객체가 생성된다.
CsrfToken
token Random UUID
parameterName _csrf
headerName X-CSRF-TOKEN

 

  3.3. tokenRepository.saveToken()

  •  세션 내에 key = HttpSessionCsrfTokenRepository.CSRF_TOKEN, value = 생성한 CsrfToken 객체를 생성한다.

 

  3.4. request.setAttribute(CsrfToken.class.getName(), csrfToken)

  • HttpServletRequest 를 통해 csrfToken 값을 조회할 수 있도록 설정해주는 부분이다.

 

  3.5. requireCsrfProtectionMatcher.matches(request)

  • Csrf 검증 메서드를 체크한다. 기본적으로 GET, HEAD, TRACE, OPTIONS를 제외한 모든 메서드에 대해서는 CsrfToken을 검증한다. 만약 GET으로 요청이 들어왔다면 검증 없이 다음 Filter로 넘어간다.

 

  3.6. request.getHeader(csrfToken.getHeaderName()) , getParameter(csrfToken.getParameterName());

  • 요청 헤더에 X-CSRF-TOKEN 값이 있는지 확인하고, 없을 경우 요청 바디에서 _csrf 값이 있는지 확인한다.
  • 클라이언트는 요청 헤더의 X-CSRF-TOKEN 혹은 요청 바디의 _csrf 값 둘 중 하나로 CsrfToken 값을 보내면 된다.

 

  3.7. equalsConstantTime(csrfToken.getToken(), actualToken)

  • 세션에 저장(혹은 생성)한 토큰값과 클라이언트에서 보낸 토큰 값을 비교하여 일치할 경우 다음 필터를 호출하고 불일치할 경우 accessDeniedHandler 메서드를 호출하여 예외 처리한다.

 

4. 테스트

   4.1. CSRF 기능 활성화

  • 기본적인 Spring Security 설정 후 csrfFilter가 활성화 되도록 csrf().disable 메서드를 주석처리 한 후 post 메서드를 호출하였다. 그 결과 필자가 Custom한 AccessDeniedHandler 메서드가 호출되어 /denied 페이지로 이동되었다.
  • 예외가 발생한 이유는 요청 헤더 혹은 바디에 csrf 토큰이 없기 때문이다. 

SecurityConfig.java

 

post 메서드 호출

 

  4.2. 화면단에 CSRF 토큰 추가

  • form 태그 안에 아래와 같은 코드를 추가하거나,  스프링 시큐리티 taglib 추가 후 <s:csrfInput/> 태그를 추가하면 발급받은 _csrf 토큰을 자동으로 set 해준다.
  • API 호출 시 정상적으로 처리되는 것을 확인할 수 있다.

csrf 구문 추가
자동 추가된 csrf 값
정상 처리된 post 메서드

 

 

5. 회고

  • 스프링 시큐리티를 설정 시 갑자기 발생한 403 에러에 당황했던 적이 많다. 찾아보면  csrf().disable() 한줄을  추가 하면 됐고, 간단한 설정처럼 보였던 이 CSRF가 뭔지는 크게 궁금하지 않았다. 개념정도만 짚고 넘어갔다. 그런데 나중에 똑같은 에러에 똑같이 당황하게 됐고, 똑같이 구글링하고 개념만 쓱 보게 되더라. 그때도 머릿속에 개념이 잘 잡히지 않았는데, 프로세스 정리를 쭉 하니 확실하게 이해하게 된 것 같다.
반응형
반응형

1. 개요

 - Interceptor와 WHOIS OpenAPI를 사용하여 해외에서 접근하는 IP를 차단해보자.

 


2. 환경

 - SpringBoot

 - JDK 1.8

 


3. 구현

 - 핵심 로직은 크게 2가지이다. Client의 요청을 Controller 앞단에서 처리되게 할 Interceptor, WHOIS API 통신을 위한 CloseableHttpClient.

 

 1) IPCheckInterceptor.java

@Component
public class IPCheckInterceptor implements HandlerInterceptor, Constants {
	
	@Autowired
	private WSOpenAPIService WSService;

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
	
		String clientIp = request.getHeader("X-Forwarded-For");
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("WL-Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_CLIENT_IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getRemoteAddr();
	    }
		
	    //로컬 테스트 시 주석 해제해주세요. 미국 IP입니다.
	    //clientIp = "54.211.120.28";
	    
	    if(!LOCAL_HOST.equals(clientIp)) {
	    	Map<String,String> clientInfo = WSService.getClientInfoByIPAddress(clientIp);
	    	
	    	if(clientInfo == null) {
	    		logger.error("IP에 대한 클라이언트 정보 조회에 실패하였습니다.");
	    		return false;
	    	}
	    	
	    	String country = clientInfo.get(WHO_IS_COUNTRY_CODE);

	    	if(!KOREA_COUNTRY_CODE.equals(country)) {
	    		logger.error("해외 IP가 감지되었습니다. 접근을 차단합니다. IP : {}, Country : {}", clientIp, country);
	    		return false;
	    	}
	    }
	    
	    return true;
	}
}

 - 'Client IP 추출 > WHOIS Service 메서드 실행 > 국적 코드 확인 > 접근 차단' 로직을 수행한다.

 

 - preHandler Method : Controller에 접근하기 이전에 수행되는 메서드이다. 메서드 내부에는 웹서버나 프록시를 거쳐 들어온 클라이언트의 IP를 request Header에서 추출하고, WHOIS API를 호출하는 로직이 포함되어있다.

 

 - !LOCAL_HOST.equals(clientIp) : LOCAL_HOST는 127.0.0.1 값에 대한 상수으로 인터페이스에 정의해두었다. 로컬 테스트 시 API를 태울 필요가 없어 추가하였다.

 

 - WSService.getClientInfoByIpAddress(String clientIp) : WHOIS API에 대한 서비스 클래스이다.

 

 - WHO_IS_COUNTRY_CODE, KOREA_COUNTRY_CODE : 각각 countryCode, KR 문자열에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

 

2) WSOpenAPIService.java

@Service
public class WSOpenAPIService implements Constants{
	
	@Value("${whois.api.key}")
	private String apiKey; 
	
	@Value("${whois.api.uri}")
	private String apiUri; 
	
	@Autowired
	private CloseableHttpClient closeableHttpClient;
	
	@Autowired
	private RequestConfig requestConfig;
	
	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@SuppressWarnings("unchecked")
	public Map<String,String> getClientInfoByIPAddress(String ip) {
		
		ObjectMapper objectMapper = null;

		try {
			List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
			
			nameValuePairs.add(new BasicNameValuePair("query",ip));
			nameValuePairs.add(new BasicNameValuePair("key",apiKey));
			nameValuePairs.add(new BasicNameValuePair("answer","json"));
			
			HttpGet httpGet = new HttpGet(apiUri);
			httpGet.setConfig(requestConfig);
			httpGet.addHeader("Content-type", "application/json");
			
			URI uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();

			httpGet.setURI(uri);
			
			CloseableHttpResponse response = closeableHttpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				logger.info("WHO IS API Response json : "+json);
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);

				return map.get(WHO_IS);
				
			}
			return null;
		} catch (ClientProtocolException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (URISyntaxException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (IOException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		}
	}
	
}

 - ConnectionPoolHttpClient를 사용하여 WHOIS API 서버로 통신 및 응답 값을 추출하는 로직을 수행한다.

 - 통신에 성공할 경우 ObjectMapper를 사용하여 whois 값을 Map 형태로 변환한다.

 - WHO_IS : whois 값에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

3) HttpClientConfig.java

@Configuration
public class HttpClientConfig {

	private static final int MAX_CONNECTION_PER_ROUTE = 20;
	private static final int MAX_CONNECTION_TOTAL = 200;
	private static final int CONNECTION_TIMEOUT = 10;
	private static final int SOCKET_TIMEOUT = 5;
	private static final int CONNECTION_REQUEST_TIMEOUT = 5;
	
	@Bean
	public CloseableHttpClient closeableHttpClient() {
		CloseableHttpClient closeableHttpClient = 
				HttpClients.custom().setConnectionManager(poolingHttpClientConnectionManager()).build();
		
		return closeableHttpClient;
		 
	}
	
	private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
		PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
		connectionManager.setDefaultMaxPerRoute(MAX_CONNECTION_PER_ROUTE);
		connectionManager.setMaxTotal(MAX_CONNECTION_TOTAL);
		return connectionManager;
	}
	
	@Bean
	public RequestConfig requestConfig() {
		RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(SOCKET_TIMEOUT * 1000)
                .setConnectTimeout(CONNECTION_TIMEOUT * 1000)
                .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT * 1000)
                .build();
		
		return requestConfig;
	}
}

 - ConnectionPool HttpClient 에 대한 config 클래스이다.

 - requestConfig, closeableHttpClient를 Bean으로 등록해 사용한다.

 - closeableHttpClient Bean 생성 시 커스텀한 PoolingHttpClientConnectionManager 객체를 주입시킨다.

 

 - 각 상수에 대한 설명은 다음과 같다.

상수 의미
MAX_CONNECTION_PER_ROUTE  CONNECTION 하나의 ROUTE에 연결 가능한 최대 CONNECTION 수
MAX_CONNECTION_TOTAL CONNECTION POOL에 저장될 수 있는 최대 CONNECTION 수
CONNECTION_TIMEOUT 커넥션 (3-HAND)을 맺는 시간에 대한 TIMEOUT
 SOCKET_TIMEOUT 커넥션을 맺은 후 응답을 받는 시간에 대한 TIMEOUT
CONNECTION_REQUEST_TIMEOUT CONNECTION POOL에서 CONNECTION을 꺼내오는 시간에 대한 TIMEOUT

 

4) WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private IPCheckInterceptor ipCheckInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(ipCheckInterceptor) .addPathPatterns("/**")
		.excludePathPatterns("/js/**", "/css/**", "/img/**","/assets/**");

	}

	
}

 - js, css, img, assets 과 같은 정적 페이지 요청을 제외한 모든 요청에 대해 ipCheckInterceptor를 적용시킨다.

 

 

5) IP V4 설정

 - 클라이언트로부터 추출되는 IP가 IPv6 형식이라면 IPv4 형식으로 변경해야한다. STS 환경이라면 'Run Configurations /  Spring Boot App / Arguments' 설정의 VM arguments 값에 '-Djava.net.preferIPv4Stack=true' 값을 넣어준다.

VM arguments 설정

 - 만약 외부 tomcat에서 운용된다면 catalina.sh 파일에 JVM 설정을 추가해준다.

catalina.sh 설정


4. 테스트

 - 로컬에서 테스트 시 IP가 127.0.0.1로 들어오기 때문에 IPCheckInterceptor에서 IP 값을 임의의 미국 IP로 변경한 후 테스트를 진행하였으며, 다음과 같이 API 응답 값을 얻고 접근을 차단하였다. 실제 클라이언트에서는 다음과 같이 빈 화면이 조회되게 된다.

INFO  2022-02-18 01:29:24[http-nio-8088-exec-1] [WSOpenAPIService:87] - WHO IS API Response json : {"whois":{"query":"54.211.120.28","queryType":"IPv4","registry":"ARIN","countryCode":"US"}}
ERROR 2022-02-18 01:29:24[http-nio-8088-exec-1] [IPCheckInterceptor:66] - 해외 IP가 감지되었습니다. 접근을 차단합니다. IP : 54.211.120.28, Country : US

요청이 차단된 화면

 


5. 마치며

 API 통신을 하여 국가코드를 조회하는 방식을 구현하고 나니, 동접자가 많아져 Connection이 모두 사용될 경우 서비스 속도가 느려질수 있겠다라는 생각이 들었고 DB 기반의 geoIp를 사용하는 이유도 이해가 갔다. 

 

 혹시 이 글을 보시는 분들 중 geoIp를 사용해보신 분이 있다면 댓글로 후기를 남겨주셨으면 좋겠다 ㅎㅎ;

(Help...)

반응형
반응형

1. 개요

 이번 동계 올림픽 하이라이트를 보니 '쭝' 얘기가 많더라. 그러고보니 한창 배그에 빠져있을 때였나... 그날도 회사갔다가 친구들이랑 배그를 하려고 접속했더니 XING, MING 으로 시작하는 '쭝' 사람들과 필자의 아이디가 4인 스쿼드를 돌리고 있더라. 나쁜 X끼들.

 

 본론으로 넘어가겠다. 서비스 오픈 후 국가별 서비스 접근 내역을 확인했다. 우리나라가 대부분이나 타국(중국, 미국, 러시아, 체코 기타등등...)에서의 접근도 있었다.

 타국 사용자를 타겟으로 한 서비스가 아니었기에 신경을 쓰지 않았으나, 필자의 서버는 호스팅 서버에 묶여있었고, 트래픽에 따른 추가비용 결제가 걸려있는 호스팅 서버의 특성 상 불필요한 접근을 막아야 했다. 타국에서의 접근은 불순한 의도일 확률이 높기 때문에 보안을 위해서도 해외 IP를 차단해야했다는 생각이 들었다.

 해외 IP를 차단하는 방법에 대해 서치를해보니 GeoIP를 사용하는 방법이 많던데, 몇천건 이상으로는 유료라는 썰이 있어 KISA의 무료 오픈 API인 WHOIS를 채택하게 되었다.


2. WHOIS API

https://whois.kisa.or.kr/kor/openkey/keyCre.do

 

KISA 후이즈검색 whois.kisa.or.kr

한국인터넷진흥원 인터넷주소자원 검색(후이즈검색) 서비스 입니다.

xn--c79as89aj0e29b77z.xn--3e0b707e

 

 WHOIS API는 KISA에서 제공하는 무료 Open API로 IP에 대한 국가코드 값을 얻을 수 있다. OpenAPI 사용 안내를 보면 다음과 같이 IP주소/AS 번호에 대한 국가코드를 요청하는 API를 확인할 수 있다.

 무료라는 큰 장점이 있지만, API를 위해 서버 내에서 HTTP 통신을 한번 태워야한다는 단점이 있다.

 * 만약, 더 좋은 방법이 있다면 댓글로 공유부탁해요!

WHOIS API

 

WHOIS API 응답값


3. 키 발급

 API 사용을 위해서는 먼저 키를 발급받아야 한다. WHOIS API 사이트에 접속 후 전자우편을 통해 발급받는다.

키 발급

 필자의 경우 네이버 메일로 받았으며, 다음과 같이 메일에 키 값이 포함되어 온다. @_@. 준비는 끝났다. (벌써)

WHOIS API 인증 키 메일


4. 구현

 필자는 단순 API 테스트를 위해 JUnit으로 단순하게 로직을 구현하였으며, 응답받은 Json String 값을 객체로 변환하기 위해 ObjectMapper를 사용하였다.

@Test
	public void whoisAPI() {

		String ip = "[IP]";
		String apiKey = "[API KEY]";
		String apiUri = "http://whois.kisa.or.kr/openapi/ipascc.jsp";
		
		List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
		
		nameValuePairs.add(new BasicNameValuePair("query",ip));
		nameValuePairs.add(new BasicNameValuePair("key",apiKey));
		nameValuePairs.add(new BasicNameValuePair("answer","json"));
		
		HttpGet httpGet = new HttpGet(apiUri);
		
		URI uri = null;
		ObjectMapper objectMapper = null;
		
		try {
			uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();
			
			httpGet.setURI(uri);
			
			CloseableHttpClient httpClient = HttpClientBuilder.create().build();
			CloseableHttpResponse response = httpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);
				Map<String,String> whois = map.get("whois");
				
				System.out.println("response : "+ map.toString());
				System.out.println("contryCode :"+whois.get("countryCode"));
			}
		} catch (URISyntaxException e) {
			e.printStackTrace();
		} catch (ClientProtocolException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} 
	}

 


5. 실행 결과

 실행 결과 key,value 형태로 IP 및 국가코드가 들어있는 걸 확인할 수 있다. 너무 간단하쥬?

실행결과


6. 마치며

 다음 포스팅에서는 인터셉터를 활용하여 해외 IP일 경우 페이지 접근을 차단시키는 로직을 구현해보도록 하겠다. HttpClient 또한 ConnectionPool 한 HttpClient로 커스텀하여 사용해보도록 하겠다.

 

아참, KISA 홈페이지를 둘러보다 우연히 발견한건데, 이 API가 개방된지 얼마 되지않았더라. 많이 쓰세요 여러분.

될놈될...헤헤...

 

반응형
반응형

1. 개요

  • 토이 프로젝트 진행 중 관리자 권한에 따른 기능 분리가 필요하여 기존 Gradle 프로젝트에 SpringSecurity 인증을 끼얹어보았다.
  • 스프링 시큐리티 온라인 강의를 들은 적이 있다. 이해를 할때마다 고개를 끄덕거리며 만족해하던 내 자신이 기억났다. '생겨버렸다. 자신감'. 하지만 정작 기억해야할 건 전혀 기억하지 못하더라... 자신감은 잠시 버려두고 강의노트를 펼쳤다.

 


2. 환경

  • SpringBoot
  • Gradle
  • Mybatis

3. 스프링 시큐리티 인증 절차

  • 스프링 시큐리티를 사용하면 여러 인증 및 인가 필터를 거친다. 필터를 거칠때마다 이와 관련된 여러 인터페이스 구현체를 거치게 되는데, 이걸 우리의 서비스에 맞게 커스텀하면 된다. 그러려면? 내부적으로 어떤 클래스(절차)들을 거치는지 알아볼 필요가 있다. 
    SpringSecurity Form 인증 절차
     
  • UsernamePasswordAuthenticationFilter > AuthenticationManager > AuthenticationProvider > UserDetailsService > Repository(DB)
    1. UsernamePasswordAuthenticationFilter에서 요청값에 대한 Authentication 객체를 생성한다.
    2. 생성한 Authentication 객체를 AuthenticationManager에게 전달한다.
    3. AuthenticationManager는 실제 인증 처리를 하지 않고, 적절한 인증 처리 클래스에게 인증 처리를 위임하는, 말 그대로 매니저 역할을 하는 클래스이다. 폼 인증 요청에 대한 적절한 인증 처리 클래스인 AuthenticationProvider에게 Authentication 객체를 전달하여 인증 처리를 위임한다.
    4. AuthenticationProvider는 들어온 Authentication 객체의 username을 값을 추출하여 UserDetailsService의 loadUserByUsername 메서드 호출에 사용한다. 말 그대로 유저 ID에 대한 유저 정보를 조회한다.
    5. UserDetailsService는 username 값을 통해 DB에서 유저 상세 정보(아이디, 비밀번호, 권한 등)를  UserDetails 타입으로 조회한다.
    6. 조회한 UserDetilas 타입의 객체를 AuthenticationProvider에게 return한다.
    7. AuthenticationProvider는 DB에서 조회한 비밀번호와 요청으로 들어온 비밀번호를 체크한다. 만약 암호화 된 비밀번호라면 암호화 클래스를 사용해 체크한다.
    8. 비밀번호가 일치할 경우 인증 토큰에 들어갈 권한 객체인 authorities를 생성 후 원하는 권한을 넣어준다.
    9. AuthenticationProvider는 유저 정보 + authorities를 담은 Authentication 객체를 생성하여 AuthenticationManager에게 return한다.
    10. AuthenticationManager는 Authentication 객체를 UsernamePasswordAuthenticationFilter에게 return 한다.
    11. UsernamePasswordAuthenticationFilter는 Authentication 객체를 SecurityContext에 저장한다.

4. 구현

  • 필자가 정리한 위 인증 절차에서 커스텀해야할 녀석은 누구일까? 당연히 실질적인 인증처리를 하고있는 녀석들을 커스텀해야한다. AuthenticationProvider와 UserDetailsService가 핵심이다. 둘 다 인터페이스이므로 구현체를 생성하여 주입시켜주기만 하면 된다. SecurityConfig 부터 시작하자.

 

1) SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	private AuthenticationSuccessHandler authenticationSuccessHandler;
	
	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http
			.csrf().disable(); //일반 사용자에 대해 Session을 저장하지 않으므로 csrf을 disable 처리함.
		
		http
			.authorizeRequests()
			.antMatchers("/admin").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/swagger-ui.html").hasRole("ADMIN")
			.antMatchers("/schedule/insert","/schedule/update","/schedule/delete").hasRole("ADMIN")
			.antMatchers("/comment/insert","/comment/update","/comment/delete").hasRole("ADMIN")
			.anyRequest().permitAll();
		
		http
			.formLogin()
			.loginPage("/admin")
			.loginProcessingUrl("/admin/login")
			.usernameParameter("username")
			.passwordParameter("password")
			.successHandler(authenticationSuccessHandler)
			.failureHandler(authenticationFailureHandler);
			
		http
			.logout()
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
			.logoutSuccessUrl("/admin")
			.invalidateHttpSession(true);
	
	}	
}

 - csrf().disable()

  •  먼저 csrf는 Cross Site Request Forgery의 약자로 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게하는 공격을 말한다.
  •  예를들어 은행 홈페이지에 특정 사용자에게 송금을 시키는 API(test.com/transMoney?money={송금할 금액}&to={송금받을 유저})가 있다고 하자.  A라는 공격자는 해당 홈페이지를 사용하는 유저들에게 다음과 같은 이미지 태그를 포함한 메일을 전송한다.
<img src="test.com/transMoney?money=100000&to=A />
  • 이 메일을 열어본 사용자는 src에 기입된 API가 호출되게 된다. 만약 해당 홈페이지에 로그인하여 세션이 브라우저에 남아있는 상태라면 공격자 A에게 100000원이 송금되는 것이다. 이런 공격을 csrf 공격이라고 한다.
  • http.csrf() 구문을 사용하면 클라이언트가 서버 통신 시 csrf 토큰 값을 함께 전달하고, 서버는 토큰에 대한 유효성을 체크하여 일치하지 않을 경우 요청을 차단하게 된다. 위 API를 호출한다 해도 csrf 토큰 값이 없으므로 서버에서는 401 에러가 발생할것이다. 하지만 내가 만든 서비스에서는 이러한 방식에 대한 필요성을 느끼지 못해 disable 처리를 하였다.

 

 - authorizeRequest() ~ anyRequest().permitAll()

  • 요청에 대한 인가를 설정한다. 현재 필자의 토이프로젝트 권한에 맞게 설정하였으므로 참고만 하고 넘어가면 된다.

 

 - formLogin()

  • formLogin 인증 방식을 사용한다는 의미이다. 

 

 - usernameParameter("username")와 passwordParameter("password")

  • 클라이언트가 전송한 폼 데이터 중 "username"과 "password"라는 name을 가진 값을 스프링 시큐리티에서 username, password로 사용한다. 즉, UsernamePasswordAuthenticationFilter 에서 Authentication 객체를 생성할때 각각의 변수 값을 사용한다.
<form action="/admin/login" id="form" method="post">
	<input type="password" name="password" id="adminPassword" />
    <input type="hidden" name="username" id="adminId" />
    <input type="submit" class="loginBtn" value="로그인">
</form>

 

 - loginPage()

  • 로그인할 페이지의 주소를 입력한다.

 

 - loginProcessingUrl()

  • 로그인을 처리할 Url을 입력한다.

 

 - successHandler()

  • 인증이 성공한 후 호출되는 핸들러 클래스이다.

  

 - failureHandler()

  • 인증이 실패한 후 호출되는 핸들러 클래스이다.

 

 - logout() ~ invalidateHttpSession()

  • /logout 을 호출하면 /admin 페이지로 이동하며 로그아웃이 되며, 이와 동시에 session이 무효화(invaildate)된다.

 

2) CustomAuthenticationSuccessHandler.java

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		
		logger.info("admin login Success !!");
		response.sendRedirect("/");
	}

}

 

  • AuthenticationSuccessHandler 인터페이스의 구현체이다.
  • 로그인이 성공할 경우 메인 페이지("/")로 리다이렉트 되도록 하였다.

 

3) CustomAuthenticationFailureHandler.java

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		logger.error("admin login Failed !!");
		response.sendRedirect("/admin?auth=fail");
	}

}
  • AuthenticationFailureHandler 인터페이스의 구현체이다.
  • 로그인이 실패할 경우 쿼리 스트링을 포함하여 "/admin"로 이동시켰다.
  • 참고로 /admin 페이지는 관리자 로그인을 시도하는 페이지이며, 클라이언트단에서 auth값에 따른 알림 메시지를 출력하기 위해 쿼리스트링을 포함시켰다.

 

4) CustomAuthenticationProvider.java

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{

	@Autowired
	private UserDetailsService userDetailsService; //CustomUserDetails Class Autowired.
	
	@Autowired
	private PasswordEncoder passwordEncoder; //BCryptPasswordEncoder Class Autowired.
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		CustomUserDetails customUserDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
	
		if(!passwordEncoder.matches(password, customUserDetails.getPassword())) {
			throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
		}
				
		List<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
		
		return new UsernamePasswordAuthenticationToken(username,password,authorities);

	}

	
	@Override
	public boolean supports(Class<?> authentication) {
		return authentication.equals(UsernamePasswordAuthenticationToken.class);
	}

}
  • AuthenticationProvider 인터페이스의 구현체이다.
  • 요청에 대한 ID와 PW 값이 포함된 Authentication이라는 객체가 요청 파라미터 값으로 들어온다. 인증절차의 4번째 단계이다.
  • authentication 객체에서 username 값을 사용하여 userDetailsService.loadUserByUsername 메서드를 호출한다.
  • 참고로 userDetailsService 구현체 내에서는 username을 가진 admin 계정의 정보를 조회하는 기능을 한다.
  • 비밀번호가 일치하지 않을 경우 BadCredentialsException을, 일치할 경우 GrantedAuthority 리스트 내에 ROLE_ADMIN 권한을 추가시킨 후 UsernamePasswordAuthenticationToken 생성자를 호출해 return한다. 이 생성자를 호출할 경우 내부적으로 AbstractAuthenticationToken 객체를 생성하는데, 이 객체는 Authentication 객체의 구현체이다. 즉, 인증절차의 9번째 단계이다.
  • supprotes 메서드는 authenticate 메서드의 동작 여부를 결정한다. false를 return하면 동작하지 않는다. 위 코드에서는 Form 인증에 대한 authentication 객체. 즉 UsernamePasswordAuthenticationToken 자료형으로 요청이 들어올 경우에만 인증 절차를 진행한다.

* 필자의 경우 DB에서 계정에 대한 권한 정보를 조회하여 부여해주는 방식이 아닌, 비밀번호 일치 시 ADMIN 권한을 주도록 로직이 짜여져있다. 이 부분은 전자와 같이 동작하도록 로직을 수정이 필요함을 느꼈다. 만약 독자분들도 전자와 같이 구현하고자한다면 loadUserByUsername 메서드를 통해 권한 정보도 조회하도록 쿼리 및 CustomUserDetails 클래스 수정 후 "ROLE_ADMIN" 부분에 customUserDetails의 get 메서드로 권한 정보를 가져와서 넣어주면 될 것이다. 

 

5) CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService{

	@Autowired
	private AdminRepository adminRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		UserDetails userDetails = adminRepository.getUserDetails(username);
		
		if(userDetails == null) {
			throw new UsernameNotFoundException("유효하지 않는 로그인 정보입니다.");
		}
		
		return userDetails;
	}

}
  • UserDetailsService 인터페이스의 구현체이다.
  • AdminRepository는 Mybatis에 대한 Mapper 인터페이스이다.
  • loadUserByUsername 메서드를 오버라이드한 후 AdminRepository 인터페이스를 사용해 admin 계정에 대한 id, pw 정보를 UserDetails 자료형으로 조회 후 리턴한다.

 

6) CustomUserDetails

@Component
public class CustomUserDetails implements UserDetails{

	private static final long serialVersionUID = 1L;

	private String username;
	private String password;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public String getPassword() {
		return this.password;
	}

	@Override
	public String getUsername() {
		return this.username;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}
}
  • UserDetails 인터페이스의 구현체이다.

 

 

7) AdminRepository

@Repository
public interface AdminRepository {

	public UserDetails getUserDetails(String username);
}


// AdminMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.cds.repository.AdminRepository">

    <select id = "getUserDetails" resultType = "CustomUserDetails">
    	SELECT
    		id AS username
    		,password
    	FROM
    		TB_ADMIN
    	WHERE
    		id = #{username}
    		AND use_yn = 'Y'
    </select>
</mapper>
  • AdminRepository 및 AdminMapper 쿼리이다.
  • returnType의 CustomUserDetails은 typeAlias를 사용해 네이밍을 간소화시켰다.

5. 테스트

1) 로그인 실패 시 

로그인 실패 시 로그
로그인 실패 시 리다이렉트

2) 로그인 성공 시

로그인 실패 시 로그

 

로그인 성공 시 리다이렉트


6. 마치며

  • 스프링 시큐리티 폼 인증 절차에 대해 개념을 다시 정리할 수 있었던 좋은 기회였다. 또한 내가 작성한 로직들을 포스팅을 통해 재확인해보니 수정할 곳도 몇몇 보였다. 한동안 이핑계 저핑계로 포스팅을 하지 않아 쌓여있는 내용이 엄청 많아졌다. 천천히 조금씩 정리해나가야겠다.
반응형
반응형

1. 개요

  • Connection을 생성한 상태에서 Controller, Service, Repository, Mapper 구조를 통해 DB 데이터를 Select 하자
  • Controller, Service, Repository 클래스에 대한 자세한 설명은 하지 않겠다.

2. 준비

  • postgreSQL DB의 Select 테이블
  • mybatis 연동 경험

3. Controller 생성

  • 먼저 클라이언트의 요청을 받을 Controller 클래스를 생성한다.
  • 필자는 각각 Controller, Service, Repository 모두 각각의 패키지를 만든 후 생성했다.
  • test-select로 오는 get 요청에 대해 testService 인터페이스 구현체 응답값을 리턴하도록 하였다.
@Controller
public class TestController {

	@Autowired
	private TestService testService;
	
	@GetMapping("/test-select")
	@ResponseBody
	public List<BoardDto.Info> testSelect(){
		
		return testService.testSelect();
	}
}

4. ServiceInterface 생성

  • 서비스 인터페이스를 생성한다.
public interface TestService {

	public List<BoardDto.Info> testSelect();
}

5. Service 구현체 생성

  • TestService Interface의 구현체 클래스를 생성한다.
  • TestRepository를 Autowired 한다.
@Service
public class TestServiceImpl implements TestService{

	@Autowired
	private TestRepository testRepository;
	
	@Override
	public List<Info> testSelect() {

		return testRepository.testSelect();
	}

}

6. TestRepository 인터페이스 생성

  • 리포지토리 인터페이스를 생성한다.
  • 이 클래스는 인터페이스이기때문에 @Repository만 입력 시 구현체가 없으므로 Service 클래스에서 TestRepository를 주입에 실패했다는 에러가 발생한다. @Mapper 어노테이션은 이러한 인터페이스를 mybatis의 매퍼로 등록해주기 위해 사용된다. 즉 Mapper Bean이 되는것이다.
@Repository
@Mapper
public interface TestRepository {

	public List<Info> testSelect();
}

 

  • 하지만 이렇게 Mapper 어노테이션을 명시적으로 선언하게 되면 생성되는 모든 Repository에 다 넣어줘야한다. 이게 귀찮다면 DatabaseConfig 클래스에 @MapperScan("패키지 경로") 어노테이션을 선언해주자. 그럼 패키지 경로에 포함된 인터페이스에 대해 @Mapper 어노테이션을 선언한 효과를 얻을 수 있다.
@Configuration
@MapperScan("com.modu.repository")
public class DatabaseConfig {

	...
	
}

7. TestMapper 생성

  • Mapper.xml을 생성한다.
  • 필자의 경우 Dto 클래스를 static inner class 형식으로 사용하기 때문에 resultType에 $가 포함되어 있다. 만약, static inner class를 사용하지 않는다면 패키지 경로를 넣어주면 된다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.modu.repository.TestRepository">

	<select id="testSelect" resultType ="com.modu.dto.BoardDto$Info">
		SELECT
			board_seq
			,cat_id
			,title
			,content
			,writer
			,create_date
			,update_date
		FROM
			tbl_board
	</select>
</mapper>

 


8. 테스트

응답 값

 


 

9. 마치며

  • hikariCP와 mybatis, postgreSQL을 연동해보았는데 그저 머릿속에 있는 DB 통신에 대한 패턴을 별 생각없이 구현했다. DB 설정 클래스의 bean들은 각각 역할을 하는지, Mapper.xml 파일에 등록한 쿼리는 내부적으로 어떻게 생성되어 실제 쿼리가 처리되는지, Repository 인터페이스와 Mapper의 id가 어떻게 매핑되는지가 궁금해졌다. 다음 포스팅에서는 이런 설정 하나하나가 시스템적으로 어떻게 돌아가는지 알아봐야겠다.
반응형
반응형

1. 개요

 - SpringBoot 환경에서 hikariCP, mybatis, PostgreSQL DB를 연동해보자!


2. 환경

 - SpringBoot

 - Gradle

 - PostgreSQL

 - mybatis

 - JDK 1.8


3. hikariCP란?

  • hikari : '빛'의 일본어, CP : ConnectionPool, 뭐다? 빛처럼 빠른 Connection Pool !(?) 이라는 의도로 히카리라고 지었는지는 모르겠지만, JDBC ConnectionPool 중 하나이다. (실제로 tomcat ConnectionPool 보다 성능이 좋다)
  • ConnectionPool이란, 서버 시작 시 DB와의 Connection을 일정 개수 생성하여 Pool에 저장시켜 놓는 기술이다. 그 후 DB에 접근할 일이 생기면 Pool에서 놀고있는 Connection을 가져다 쓰고, 사용이 끝나면 다시 반환시켜 놓는다. ConnectionPool을 사용하지 않으면 트랜잭션 요청이 들어올때마다 Connection을 맺는 작업을 해야하는데, 접속이 몰리게 되면 서버에 부하를 가져올 수 있다. 그래서 일반적으로 ConnectionPool 방식을 많이 사용한다.
  • 결론은 hikariCP란 JDBC ConnectionPool이며, 스프링 부트 2 버전에서는 jdbc 의존성 주입 시 기본적으로 요녀석을 제공할 만큼 똑똑하고 빠른놈이란걸 알 수 있다.

 4. 의존성 주입

    •  build.gradle에 postgreSQL, jdbc, mybatis에 대한 의존성을 추가해주자.
//postgreSQL
runtimeOnly 'org.postgresql:postgresql'

//jdbc (hikari)
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

//mybatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'

5. hikari 설정

  • hikari 설정은 크게 DB 접속 정보Connection Pool 설정으로 구성된다. application.properties 파일에 다음과 같이 추가하도록 하자.
# DB 접속정보
spring.datasource.hikari.driver-class-name=org.postgresql.Driver
spring.datasource.hikari.jdbc-url=jdbc:postgresql://[URL]:5432/[DB명]
spring.datasource.hikari.username=[접속 ID]
spring.datasource.hikari.password=[접속 PW]
spring.datasource.hikari.pool-name=[PoolName - 임의]

# CP Setting
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=30
spring.datasource.hikari.idle-timeout=60000
  • DB 접속정보와 ConnectionPool 셋팅 정보를 입력해주었다. CP Setting 정보는 다음과 같다.
  • connection-timeout : 클라이언트가 Pool에 Connection을 요청했을 때 기다리는 시간(ms)이다. 만약 최대 50개의 Connection을 생성해둔 상태에서 50개의 요청이 한번에 들어올 경우 51번째 클라이언트는 텅텅 비어있는 Pool에서 유휴 Connection을 기다릴 수밖에 없다. 그 참을성에 대한 시간이다. 30초동안은 기다린다는 뜻이며, 만약 30초가 지날 경우 ConnectionTimeoutException이 throw된다. 이 경우 connection-timeout 값을 늘려주거나, maximum-pool-size를 늘려줘야한다.
  • maximum-pool-size : Pool에 저장할수 있는 Connection의 최대 개수이다.
  • minimum-idle : Pool에서 저장시켜야 할 Connection의 최소 개수이다. 서버 최초 기동 시 Pool에 Connection을 생성하는데 minimum-idle을 설정할 경우 maximum-pool-size만큼 생성하지 않고 minimum-idle개수만큼 생성한다. 30개는 유휴상태로 유지시키는 것이다. 만약 DB 통신 중 에러가 발생하여 한개의 Connection이 폐기되어 유휴 커넥션이 29개가 되면 이 개수를 맞추기 위해 1개의 Connection을 생성하게 된다.
  • idle-timeout : minimum-idle 개수를 넘어가게 되면 Connection을 사용하고 Pool에 반환하는게 아닌 폐기시킨다. 앞서 말했듯이 30개의 Connection만 유휴상태로 유지시키기 때문이다. 그런데 요청이 계속 들어오면 오히려 폐기하는 것보다는 유휴상태로 유지시키는 것이 효율적인 상황이다. minimum-idle 개수를 넘어간 상황에서 Connection 추가 생성 후 해당 커넥션을 일정 시간 유휴상태로 유지시키는 설정이 idle-timeout이다. 이 설정을 넣지 않으면 바로 폐기가 될까?라고 생각할 수 있지만 그것도 아니다. default 값이 60000이기 때문에 1분동안은 유지되다가 더이상 사용되지 않을 경우 폐기된다.

6. DatabaseConfig 클래스 생성

  • 의존성 주입 및 application.properties에 CP 설정을 마친 상태에서 서버를 기동하면 다음과 같은 에러가 발생한다.
    Consider the following:
    	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
    	If you have database settings to be loaded from a particular profile you may need to activate it (the profiles oauth,prod are currently active).
     
  • H2, HSQL db에 대한 classpath를 추가해달란 것인데, 이 오류가 뜨는 이유는 앞서 설정했던 설정값들이 현재 어플리케이션에 적용이 되지 않아 default DB인 H2, HSQL로 셋팅이 되고, 실제 application.properties에는 이에 대한 설정값이 없기 때문에 발생하는 에러이다. DB 설정하는 Configuration 클래스를 만들면 해결이 된다.

 

  • DatabaseConfig.Class
package com.modu.config;


import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class DatabaseConfig {

	@Bean
	@ConfigurationProperties(prefix = "spring.datasource.hikari")
	public HikariConfig hikariConfig() {
		return new HikariConfig();
	}
	
	@Bean
	public DataSource dataSource() {
		return new HikariDataSource(hikariConfig());
	}
	
	@Bean
	public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception{
		final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
		sessionFactory.setDataSource(dataSource);
		PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
		sessionFactory.setMapperLocations(resolver.getResources("mapper/*.xml")); 	//mapper 파일 로드
		sessionFactory.setConfigLocation(resolver.getResource("mybatis-config.xml"));//mybatis-config 로드
		return sessionFactory.getObject();
	}
	
	@Bean
	public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) throws Exception{
		final SqlSessionTemplate sqlSessionTemplate = new SqlSessionTemplate(sqlSessionFactory);
		return sqlSessionTemplate;
	}
}
  • ConfigurationProperties(prefix = "spring.datasource.hikari") 구문은 prefix로 시작하는 properties 값들을 추출하여 특정 객체에 바인딩 시키는 어노테이션이다. 즉, hikari 관련 설정들을 HikariConfig Bean 객체 생성 시 바인딩을 시키는 것이다.  바인딩 시 완화된 규칙이 적용되어 있어 jdbcUrl이라는 변수에 바인딩 시 jdbc-url, jdbc_url도 정상적으로 바인딩된다. 실제로 bean 객체를 Autowired 하여 debug 해보면 properties에 설정한 데이터들이 객체화되어 들어가고 있음을 알 수 있다. 나머지 값들을 HikariConfig Bean 생성 시 기본으로 주입된 값들이다.

hikariConfig

  • DataSource를 생성할 때 위에서 만든 객체를 HikariDataSource의 생성자로 주입하면 히카리 CP에 대한 Datasource 객체가 생성된다.
  • mybatis 설정파일 및 mapper 파일을 로드하는 설정에 맞게 resource 경로에 mybatis-config.xml 파일과 mapper 폴더를 생성해준다.
  • 서버를 기동하면 정상적으로 기동됨을 확인할 수 있다. 추가적으로 로그가 설정되어 있다면 debug 레벨에 다음과 같이 Connection이 생성되었다는 로그를 확인할 수 있다.

Connection 생성 완료

 

실제 DB에서 데이터를 조회해보는 것은 다음 게시글에 포스팅하도록 하겠다.

반응형
반응형

1. 개요

 - 스프링 부트 환경에서 기본으로 제공하는 LogBack을 사용하여 로그를 남겨보자

 - spring.profiles.active를 사용하여 운영, 개발 환경에 따라 로그 설정을 분기하여 적용해보자

 [참고 : https://goddaehee.tistory.com/206 깃대희의 작은공간]

 

2. LogBack이란?

 - LogBack이란 Log4j를 만든 개발자가 Log4j를 기반으로 속도와 메모리 점유율을 개선하여 만든 로깅 프레임워크이다.

 - org.slf4j.Logger 인터페이스의 구현체이다.

   >> 코드 작성 시 이 인터페이스를 임포트해주면 된다.

 

3. LogBack 특징

 - Level : 로그 레벨을 설정할 수 있다.

 - Appender : 출력 방법을 선택할 수 있다. ex) Console, RollingFile 등

 - Logger : 로그마다 다른 설정을 적용시킬 수 있다.

 - Authmatic Reloading Configuration File : 특정 시간마다 별도의 스레드를 통해 설정 파일 변경 여부 파악 및 적용이 가능하다. > logback 설정 변경 시 서버 재시작이 필요없다.

 

4. 프로젝트 세팅

 1) Controller - 로그를 찍기 위한 클래스

 2) application.properties - spring.profiles.active 설정 추가

 3) logback-spring.xml - logback 설정

 4) logback-{운용환경}.properties - logback 설정파일에서 로드할 상수 (로그파일, 레벨 등)

 

 4.1. Controller

  /api/log 요청 시 trace부터 error 까지의 로그를 쌓는 메서드를 작성한다.

@Controller
@RequestMapping("/api")
public class FileController {

	private final Logger logger = LoggerFactory.getLogger(this.getClass());
	
	@GetMapping("/log")
	@ResponseBody
	public ResponseDTO main() {
		logger.trace("trace Log");
		logger.debug("debug Log");
		logger.info("info Log");
		logger.warn("warn Log");
		logger.error("error Log");
		
		return null;
	}
}

 

 4.2. application.properties

  spring.profiles.active=dev 를 추가하여 개발 및 운영 환경을 정의해준다.

#서버포트
server.port=9090

#운용환경 : 개발
spring.profiles.active=dev

 

 4.3. logback-spring.xml

resource 경로에 logback-spring.xml 을 생성한 후 logback 설정을 정의한다.

* resource 경로에 logback-spring.xml 파일이 있으면 서버 기동 시 자동으로 로드한다.

<?xml version="1.0" encoding="UTF-8"?>

<!-- 10초마다 파일 변화를 체크하여 갱신시킨다. -->
<configuration scan="true" scanPeriod="10 seconds">

	<!-- spring.profile에 따른 설정파일 분기 -->
	<springProfile name = "prod">
		<property resource = "logback-prod.properties"/>
	</springProfile>
	
	<springProfile name = "dev">
		<property resource = "logback-dev.properties"/>
	</springProfile>
	
	
	<!-- 루트 로그 레벨 -->
	<property name ="LOG_LEVEL" value = "${log.config.level}"/>
	
	<!-- 로그 파일 경로 -->
	<property name ="LOG_PATH" value = "${log.config.path}"/>
	
	<!-- 로그 파일 명 -->
	<property name ="LOG_FILE_NAME" value = "${log.config.filename}"/>
	<property name ="ERR_LOG_FILE_NAME" value = "${log.config.filename}_error"/>
	
	<!-- 로그 파일 패턴 -->
	<property name ="LOG_PATTERN" value = "%-5level %d{yyyy-MM-dd HH:mm:ss}[%thread] [%logger{0}:%line] - %msg%n"/>
	
	
	
	<!-- 콘솔 Appender 설정 -->
	<appender name ="CONSOLE" class ="ch.qos.logback.core.ConsoleAppender">
		<encoder class ="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>${LOG_PATTERN}</pattern>
		</encoder>
	</appender>
	
	<!-- 파일 Appender 설정 -->
	<appender name="FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender">
		<!-- 파일 경로 설정 -->
		<file>${LOG_PATH}/${LOG_FILE_NAME}.log</file>
		
		<!-- 로그 패턴 설정 -->
		<encoder class = "ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>${LOG_PATTERN}</pattern>
		</encoder>
		
		<!-- 롤링 정책 -->
		<rollingPolicy class = "ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
			<!-- gz, zip 등을 넣을 경우 자동 로그파일 압축 -->
			<fileNamePattern>${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE_NAME}_%i.log</fileNamePattern>
			
			<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
				<!-- 파일당 최고 용량 -->
				<maxFileSize>10MB</maxFileSize>
			</timeBasedFileNamingAndTriggeringPolicy>
			
			<!-- 로그파일 최대 보관주기 -->
			<maxHistory>30</maxHistory>
		</rollingPolicy>
	</appender>
	
	
	<appender name = "ERROR" class ="ch.qos.logback.core.rolling.RollingFileAppender">
		<filter class ="ch.qos.logback.classic.filter.LevelFilter">
			<level>error</level>
			<onMatch>ACCEPT</onMatch>
			<onMismatch>DENY</onMismatch>
		</filter>
		<file>${LOG_PATH}/${ERR_LOG_FILE_NAME}.log</file>
		
		<!-- 로그 패턴 설정 -->
		<encoder class = "ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>${LOG_PATTERN}</pattern>
		</encoder>
		
		<!-- 롤링 정책 -->
		<rollingPolicy class = "ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
			<!-- gz, zip 등을 넣을 경우 자동 로그파일 압축 -->
			<fileNamePattern>${LOG_PATH}/%d{yyyy-MM-dd}/${ERR_LOG_FILE_NAME}_%i.log</fileNamePattern>
			
			<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
				<!-- 파일당 최고 용량 -->
				<maxFileSize>10MB</maxFileSize>
			</timeBasedFileNamingAndTriggeringPolicy>
			
			<!-- 로그파일 최대 보관주기 -->
			<maxHistory>30</maxHistory>
		</rollingPolicy>
	</appender>
	
	<root level = "${LOG_LEVEL}">
		<appender-ref ref="CONSOLE"/>
		<appender-ref ref="FILE"/>
		<appender-ref ref="ERROR"/>
	</root>
	
	<logger name="org.apache.ibatis" level = "DEBUG" additivity = "false">
		<appender-ref ref="CONSOLE"/>
		<appender-ref ref="FILE"/>
		<appender-ref ref="ERROR"/>
	</logger>
</configuration>

 - springProfile 태그를 사용하여 시스템 변수의 spring.profile.active 값을 조회할 수 있다. 이 값에 따라 logback-prod, logback-dev 파일 중 하나를 로드하여 설정파일의 프로퍼티로 사용한다.

 

 4.4. logback-dev.properties

 - 개발환경에서는 로그레벨을 debug로 설정한다.

#로그 레벨
log.config.level=debug

#로그파일 경로
log.config.path=/logs

#로그파일 명
log.config.filename=reckey

 

 4.5. logback-prod.properties

 - 운영 환경에서는 로그레벨을 info로 설정한다.

#로그 레벨
log.config.level=info

#로그파일 경로
log.config.path=/logs

#로그파일 명
log.config.filename=reckey

 

5. 테스트

 spring.profile.active를 prod 설정하면 로그레벨이 INFO로 잡혀있어 불필요한 로그는 조회되지 않지만 dev로 설정할 경우 서버 기동 시 발생되는 로그, DB 로그 등이 중구난방으로 나오게 된다.

 원인은 dev로 설정할 경우 logback 설정의 루트 로그 레벨이(root level) 값이 debug로 설정되어 어플리케이션 내 모든 클래스의 debug 로그가 찍히기 때문이다.

 이런 로그들은 특수한 목적이 없는 한 debug보단 info로 올리는 것이 좋다. 이를 위해서는 logger 태그를 사용하여 클래스별 로그 레벨 분기처리를 해야한다.

DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [AnonymousAuthenticationFilter:96] - Set SecurityContextHolder to anonymous SecurityContext
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [FilterSecurityInterceptor:210] - Authorized filter invocation [GET /api/log] with attributes [permitAll]
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [FilterChainProxy:323] - Secured GET /api/log
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [DispatcherServlet:91] - GET "/api/log", parameters={}
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [PropertySourcedRequestMappingHandlerMapping:108] - looking up handler for path: /api/log
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [RequestMappingHandlerMapping:522] - Mapped to com.reckey.controller.FileController#main()
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [FileController:24] - debug Log
INFO  2021-10-07 02:11:23[http-nio-9090-exec-1] [FileController:25] - info Log
WARN  2021-10-07 02:11:23[http-nio-9090-exec-1] [FileController:26] - warn Log
ERROR 2021-10-07 02:11:23[http-nio-9090-exec-1] [FileController:27] - error Log
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [RequestResponseBodyMethodProcessor:268] - Using 'application/json;q=0.8', given [text/html, application/xhtml+xml, image/avif, image/webp, image/apng, application/xml;q=0.9, application/signed-exchange;v=b3;q=0.9, */*;q=0.8] and supported [application/json, application/*+json, application/json, application/*+json]
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [RequestResponseBodyMethodProcessor:298] - Nothing to write: null body
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [DispatcherServlet:1131] - Completed 200 OK
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [HttpSessionSecurityContextRepository:346] - Did not store anonymous SecurityContext
DEBUG 2021-10-07 02:11:23[http-nio-9090-exec-1] [SecurityContextPersistenceFilter:118] - Cleared SecurityContextHolder to complete request
DEBUG 2021-10-07 02:11:25[reckeyPool housekeeper] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:11:25[reckeyPool housekeeper] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.

 

 5.1. 특정 클래스 분기처리 (로그레벨 INFO로 격상)

  일단 분기처리할 클래스의 경로를 확인해보자. 현재 로그 설정으로는 클래스 명만 나오고 있기 때문에 클래스 경로를 파악하기 힘들다. 로그 패턴에 클래스 경로 패턴인 %C를 다음과 같이 추가해보자.  자동 리로드 설정이 있으므로 서버 재시작은 하지 않아도 된다.

<property name ="LOG_PATTERN" value = "%-5level %d{yyyy-MM-dd HH:mm:ss}[%thread] [%C] [%logger{0}:%line] - %msg%n"/>

 그리고 다시 요청을 보내면 다음과 같이 클래스 경로가 함께 조회된다.

INFO  2021-10-07 02:14:37[restartedMain] [org.springframework.boot.StartupInfoLogger] [ReckeyApplication:61] - Started ReckeyApplication in 0.703 seconds (JVM running for 3806.024)
DEBUG 2021-10-07 02:14:37[restartedMain] [org.springframework.boot.availability.ApplicationAvailabilityBean] [ApplicationAvailabilityBean:77] - Application availability state LivenessState changed to CORRECT
INFO  2021-10-07 02:14:37[restartedMain] [org.springframework.boot.devtools.autoconfigure.ConditionEvaluationDeltaLoggingListener] [ConditionEvaluationDeltaLoggingListener:63] - Condition evaluation unchanged
DEBUG 2021-10-07 02:14:37[restartedMain] [org.springframework.boot.availability.ApplicationAvailabilityBean] [ApplicationAvailabilityBean:77] - Application availability state ReadinessState changed to ACCEPTING_TRAFFIC
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@36e9f17b
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@73eb69c2
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@439a7476
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@42521e57
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@3bc95d4f
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@13c4968d
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool$PoolEntryCreator] [HikariPool:728] - reckeyPool - Added connection org.postgresql.jdbc.PgConnection@36d6947
DEBUG 2021-10-07 02:14:37[reckeyPool connection adder] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - After adding stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:15:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:15:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.
DEBUG 2021-10-07 02:15:37[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:15:37[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.
DEBUG 2021-10-07 02:16:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:16:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.
DEBUG 2021-10-07 02:16:37[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:16:37[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.
DEBUG 2021-10-07 02:17:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:421] - reckeyPool - Pool stats (total=10, active=0, idle=10, waiting=0)
DEBUG 2021-10-07 02:17:07[reckeyPool housekeeper] [com.zaxxer.hikari.pool.HikariPool] [HikariPool:517] - reckeyPool - Fill pool skipped, pool is at sufficient level.

 org.springframework경로의 여러 클래스와 com.zaxxer.hikari로 시작하는 여러 클래스에서 DEBUG레벨로 로그들이 찍히고 있다. (히카리 로그는 DB연결로 인함입니다. 신경쓰지않으셔도됩니다.) 이 클래스들만 DEBUG레벨에서 INFO 레벨로 올린다면 이 문제가 해결될 것이다.

logback-spring.xml 파일의 logger 태그에 분기처리할 클래스를 다음과 같이 추가한다.

name에 들어가는 경로의 하위 모든 클래스에서 발생하는 로그를 INFO 레벨로 올리게 된다.

	<logger name="org.springframework" level = "INFO" additivity = "false">
		<appender-ref ref="CONSOLE"/>
		<appender-ref ref="FILE"/>
		<appender-ref ref="ERROR"/>
	</logger>
	
	<logger name="com.zaxxer.hikari" level = "INFO" additivity = "false">
		<appender-ref ref="CONSOLE"/>
		<appender-ref ref="FILE"/>
		<appender-ref ref="ERROR"/>
	</logger>

 

이제 실제 요청을 보내면 다음과 같이 불필요한 debug 레벨의 로그들을 쌓지 않게 된다.

DEBUG 2021-10-07 02:26:21[http-nio-9090-exec-1] [com.reckey.controller.FileController] [FileController:24] - debug Log
INFO  2021-10-07 02:26:21[http-nio-9090-exec-1] [com.reckey.controller.FileController] [FileController:25] - info Log
WARN  2021-10-07 02:26:21[http-nio-9090-exec-1] [com.reckey.controller.FileController] [FileController:26] - warn Log
ERROR 2021-10-07 02:26:21[http-nio-9090-exec-1] [com.reckey.controller.FileController] [FileController:27] - error Log

 

6. 마치며

 스프링 부트에서 로그백을 사용하여 로그를 남겨본 것은 처음이다. 생각보다 되게 간단하고, log4j 같은 경우 로그파일 리로드 설정을 외부 설정파일에서 해줘야하는데 logback은 리로드되는 기능이 설정 파일 안에 있어서 좋은것 같다.

반응형
반응형

1. 개요

 이전 포스팅에서 테스트 코드를 통해 두 클래스를 비교한 결과 다음과 같은 정보를 얻을 수 있었다.

  DefaultHttpClient CloseableHttpClient
생성 new DefaultHttpClient() HttpClients.createDefault()
close 메서드 존재 여부 X O
HTTP 통신 횟수 1 ConnectionPool 설정에 따라 다름
ConnectionPool X O

오늘 알아볼 것은

첫째, 생성한 DefaultHttpClient에서 execute를 2번 이상 실행했을 때 즉, HTTP 통신을 2번 이상 요청했을 때 예외가 발생한 원인과 생명주기.

둘째, CloseableHttpClient의 ConnectionPool 설정 정보를 jar 파일을 통해 알아보겠다.


2. DefaultHttpClient

jar파일과 예외 로그를 기반으로 코드를 찾아가보니 다음과 같은 부분에서 예외가 발생함을 확인하였다.

execute 메서드를 따라가다.

conn이라는 값이 null이 아닐 때 위 에러가 발생한다. conn은 ManagedClientConnectionImpl 형의 멤버필드였으며, 어디선가 주입이 된것같은데... 결론은 찾을 수가 없었다. 멍청한자식.

 

서치를 통해 얻은 정보를 정리한 결과 예외 발생 원인은 다음과 같았다.

DefaultHttpClient 객체를 생성하면 내부적으로 basicClientConnectionManager 인스턴스가 주입된다. 이 인스턴스는  HTTP 통신에 대한 커넥션 정보를 저장하고 있다. 단, 하나의 최초 연결한 하나의 커넥션 정보만 저장한다.

두 개의 커넥션을 연결하려 했기때문에 예외가 발생했으며, 실제 에러 로그를 확인해보니 basicClientConnectionManager 클래스의 메서드 안에서 발생함을 확인할 수 있었다.

아래 로그의 Asserts.java:34가 위 코드 사진의 첫번째 빨간 블럭부분이었다. (기존 커넥션 정보가 있기때문에 발생)

 

정리하면 DefaultHttpClient 클래스는 하나의 HTTP 통신만을 처리할 수 있도록 내부적으로 구현되어져 있다.

만약 이 인스턴스를 사용해 두번의 통신을 처리하고싶다면 두개의 인스턴스를 생성해야한다.


3. CloseableHttpClient

생성자를 찾아가보니 다음과 같은 코드가 있었다. httpClientBuilder.create().build(). 요녀석을 파헤쳐보자

createDefault()

create().build() 메서드 확인 결과, PoolingHttpClientConnectionManager를 생성한 후 connectionManager로 사용하는 것이 보인다.

create().build()

 

생성된 ConnectionPool의 Default maxTotal, maxConPerRoute는 다음과 같이 2, 20임을 확인할 수 있다.

CPool

변수
maxTotal 최대 커넥션 개수
maxConPerRoute 라우트당 최대 커넥션 개수(ip:port 별 최대 커넥션 개수)

 

커스텀을 하지 않고 사용할 경우 최대 커넥션 개수가 2개이기때문에 실제 서비스를 운영하기엔 문제가 있다.

PollingHttpClientConnectionManager 은 커스텀이 가능하다. 때문에 상황에 맞게 커스텀하여 CloseableHttpClient를 구현한다면 많은 HTTP 통신 요청을 필요로 하는 서비스에 적절하게 사용될 수 있다.


4. 마치며

DefaultHttpClient와 CloseableHttpClient에 대한 아~주 미세한 차이에 대해서도 몰랐었지만, 이번 스터디를 통해 차이점은 물론이며, 실제 서비스에 왜 저 클래스를 사용했는지도 이해하게 되었다.

DefaultHttpClient의 생명주기, 언제 connection이 끊어지는지에 대해서는 확인하지 못해 뭔가 깨림직한 기분이지만, 오늘하루도 잘 보냈음에 위안을 삼는다!

반응형
반응형

1. 개요

 예전에 서비스 내에서 HTTP을 사용해 HR 시스템에서 정보를 가져오는 로직에 문제가 발생한 적이 있었다.

 원인은 사용하는 HttpClient객체가 static으로 선언되어 있어 멀티 쓰레드 환경에서 통신이 꼬여버린 것이다.

 해결방안으로 HttpClientBuilder를 사용해 PoolingHttpClientconnectionManager, requestConfig 객체를 주입받은   CloseableHttpClient 객체를 싱글톤으로 등록 후 호출할때마다 재사용하는 방식을 사용했다.

 

 그런데 다른 프로젝트의 SM업무를 맡던 도중 HTTP 통신할때마다 DefaultHttpClient 객체를 생성하고 있었다. 또한 인스턴스를 통신할때마다 생성하는 부분은 있지만 close시키는 부분이 없어서 DefaultHttpClient가 어떤녀석인지, 그리고 CloseableHttpClient와 무슨 차이점이 있는지 궁금해졌다.


2. CloseableHttpClient와 DefaultHttpClient

  이 두 클래스 모두 HttpClient 인터페이스의 구현클래스이다. 하지만 이녀석들의 차이에 대해 상세하게 정리된 내용을 찾지못해 실제 테스트를 통해 알아보기로 했다.


2.1. DefaultHttpClient, CloseableHttpClient 테스트코드 1.

 각각의 클래스에 DefaultHttpClient, CloseableHttpClient 인스턴스 생성 후 HTTP GET 통신을 하는 코드를 작성하였다.


2.2. CloseableHttpClient

DefaultHttpClient를 사용하여 HTTP GET 통신을 하는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class UseHttpClient {
 
    private static final String URL = "http://www.naver.com";
    
    public static void main(String[] args) {
        HttpClient httpClient = null;
        try {
            httpClient = new DefaultHttpClient(); // httpClient 4.3버전 이후 deprecated 처리.
            
            HttpGet httpGet = new HttpGet(URL);
            
            HttpResponse response = httpClient.execute(httpGet);
            
            System.out.println(":: DefaultHttpClient Response ::");
            System.out.println(":: response 1 result code : "+response.getStatusLine().getStatusCode());
 
            BufferedReader reader= new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String inputLine;
            StringBuffer responseBuf = new StringBuffer();
            
            while((inputLine = reader.readLine()) != null) {
                responseBuf.append(inputLine);
            }
 
            reader.close();
        } catch (ClientProtocolException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}
cs

17번째 라인부터는 응답 데이터를 읽어오는 부분인데 네이버 홈페이지 코드가 방대하게 나와서 굳이 출력하진 않았다.

중요한건 DefaultHttpClient 인스턴스를 사용해 HTTP 통신을 한번 했다는 점이다.


2.2. CloseableHttpClient

 CloseableHttpClient를 사용하여 HTTP GET 통신을 하는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class UseCloseableHttpClient {
 
    private static final String URL = "http://www.naver.com";
    
    public static void main(String[] args) {
        CloseableHttpClient httpClient = null;
        try {
            httpClient = HttpClients.createDefault();
            HttpGet httpGet = new HttpGet(URL);
            
            CloseableHttpResponse response = httpClient.execute(httpGet);
            
            System.out.println(":: CloseableHttpResponse ::");
            System.out.println(":: response 1 result code : "+response.getStatusLine().getStatusCode());
            
            BufferedReader reader= new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String inputLine;
            StringBuffer responseBuf = new StringBuffer();
            
            while((inputLine = reader.readLine()) != null) {
                responseBuf.append(inputLine);
            }
 
            reader.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            if(httpClient != null) {
                try {
                    httpClient.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    }
}
cs

마찬가지로 16번째 라인부터는 응답 데이터를 읽어오는 부분이다.

여기서도 중요한건 CloseableHttpClient 인스턴스를 사용해 한번 통신했다는 점이다.


2.3. 첫번째 테스트로 인해 알게된 차이점

 첫번째 테스트로 인해 알게된 이 두 클래스의 차이점을 정리해보았다. 생성방식이 달랐고, CloseableHttpClient 클래스는 close 메서드가 있었다. 왜 있지?? 아직까지는 큰 차이를 느끼진 못해 다음 테스트를 진행하였다.

  DefaultHttpClient CloseableHttpClient
생성 new DefaultHttpClient() HttpClients.createDefault()
close 여부 X O

3.1. DefaultHttpClient, CloseableHttpClient 테스트코드 2.

 close라는 부분이 눈에 밟혀 각각의 인스턴스에서 HTTP 통신 메서드인 execute를 두번씩 호출해보았다.


3.2. DefaultHttpClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class UseHttpClient {
 
    private static final String URL = "http://www.naver.com";
    
    public static void main(String[] args) {
        HttpClient httpClient = null;
        try {
            httpClient = new DefaultHttpClient(); // httpClient 4.3버전 이후 deprecated 처리.
            
            HttpGet httpGet = new HttpGet(URL);
            
            HttpResponse response = httpClient.execute(httpGet);
            HttpResponse response2 = httpClient.execute(httpGet); // 추가한 코드
            
            System.out.println(":: DefaultHttpClient Response ::");
            System.out.println(":: response 1 result code : "+response.getStatusLine().getStatusCode());
            System.out.println(":: response 2 result code : "+response2.getStatusLine().getStatusCode()); // 추가한 코드
 
            BufferedReader reader= new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String inputLine;
            StringBuffer responseBuf = new StringBuffer();
            
            while((inputLine = reader.readLine()) != null) {
                responseBuf.append(inputLine);
            }
 
            reader.close();
        } catch (ClientProtocolException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}
cs

13번 라인과 17번 라인에 호출 및 응답 코드를 출력하는 코드를 추가하였다.

실행 결과 다음과 같은 Exception이 발생하였다.

13번 라인에서 Exception 발생

예외 메시지는 "연결이 여전히 할당되어 있습니다. 다른 연결을 할당하기 전에 연결을 해제해야 합니다." 라는 뜻이다.

12번 라인에서 수행된 연결이 아직 끊기지 않은 상태에서 13번 라인의 execute 코드가 실행되어 그런 것같다. 그렇다면 과연 언제 끊기는 걸까? 생명주기가 궁금해졌지만 이는 더 파고들어야 알수있을 것 같다. 일단 여기서 확인된 점은 DefaultHttpClient 클래스는 생명주기가 끝나기 전까지 한번의 HTTP 요청을 수행할 수 있다는 것이다.


3.3. CloseableHttpClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class UseCloseableHttpClient {
 
    private static final String URL = "http://www.naver.com";
    public static void main(String[] args) {
        CloseableHttpClient httpClient = null;
        try {
            httpClient = HttpClients.createDefault();
            HttpGet httpGet = new HttpGet(URL);
            
            CloseableHttpResponse response = httpClient.execute(httpGet);
            CloseableHttpResponse response2 = httpClient.execute(httpGet); // 추가한 코드
            
            System.out.println(":: CloseableHttpResponse ::");
            System.out.println(":: response 1 result code : "+response.getStatusLine().getStatusCode());
            System.out.println(":: response 2 result code : "+response2.getStatusLine().getStatusCode()); // 추가한 코드
            
            BufferedReader reader= new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String inputLine;
            StringBuffer responseBuf = new StringBuffer();
            
            while((inputLine = reader.readLine()) != null) {
                responseBuf.append(inputLine);
            }
 
            reader.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            if(httpClient != null) {
                try {
                    httpClient.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    }
}
cs

마찬가지로 11, 15번 라인에 통신 및 응답코드를 출력하는 코드를 추가하였다.

실행 결과, 예외가 발생하지 않으며, 두 요청 모두 response Code 200을 응답받았다.

그렇다면 이 CloseableHttpClient의 생명주기는 어떻게 될지가 궁금해졌다. 마침 close 메서드도 있으니 추가 테스트를 진행해보았다. 그런데 예상치 못한 예외 코드가 출력되었다.


3.4. CloseableHttpClient close()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class UseCloseableHttpClient {
 
    private static final String URL = "http://www.naver.com";
    public static void main(String[] args) {
        CloseableHttpClient httpClient = null;
        try {
            httpClient = HttpClients.createDefault();
            HttpGet httpGet = new HttpGet(URL);
            
            CloseableHttpResponse response = httpClient.execute(httpGet);
            httpClient.close(); // 추가한 코드
            CloseableHttpResponse response2 = httpClient.execute(httpGet);
            
            System.out.println(":: CloseableHttpResponse ::");
            System.out.println(":: response 1 result code : "+response.getStatusLine().getStatusCode());
            System.out.println(":: response 2 result code : "+response2.getStatusLine().getStatusCode());
            
            BufferedReader reader= new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String inputLine;
            StringBuffer responseBuf = new StringBuffer();
            
            while((inputLine = reader.readLine()) != null) {
                responseBuf.append(inputLine);
            }
 
            reader.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            if(httpClient != null) {
                try {
                    httpClient.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    }
}
cs

 

11번 라인에서 Exception 발생

커넥션 풀이 종료됐다는 예외였다!

아. 생성한 CloseableHttpClient 인스턴스는 기본적으로 커넥션 풀을 지원한다는 것을 알게되었고, close 메서드는 이 인스턴스에 할당된 커넥션 풀을 close 시키는 것이었다.

3.2 테스트에서 2번을 연속으로 호출했을 때 예외가 발생하지 않았던 이유도 요청을 커넥션 풀을 통해 요청이 처리되기 때문이었다.


3.4. 두번째 테스트로 인해 알게된 차이점

  DefaultHttpClient CloseableHttpClient
생성 new DefaultHttpClient() HttpClients.createDefault()
close 여부 X O
HTTP 통신 횟수 1 ConnectionPool 설정에 따라 다름
ConnectionPool X O

 

이제 DefaultHttpClient에서 발생했던 예외의 원인과 생명주기는 무엇이고, CloseableHttpClient의 커넥션 풀이 어디서 설정되는지 확인해야한다. 이는 생성부분의 내부 코드를 확인해야한다.

 

이는 다음 포스팅에 정리하도록 하겠다.

 

- 혹, 글의 내용 중 맞지 않는 부분이나 수정할 사항이 있다면 꼭 댓글 부탁드립니다. 정말 감사히 받아드리겠습니다!

반응형
반응형
반응형

1. 개요

 - 업무를 하던 도중 특정 로직에서 Enhancer, MethodInterceptor이라는 객체를 사용하고 있었다. 로직을 분석해봐도 이해가 잘 가지 않았던 구조였기 때문에 공부의 필요성을 느껴 공부 후 내용을 정리한다.


2. Enhancer란?

 - Enhancer의 사전적 의미는 기능을 높이는 것, 증진시키는 것을 의미한다. 이를 프록시에 대입하여 생각해보니 프록시 객체는 다양한 객체를 호출할 수 있기때문에 기능이 증진된다는 것과 비슷한 맥락을 갖는 것 같다.

 - 로직적으로 이 객체는 프록시 객체를 생성하는 역할을 한다.


3. Enhancer를 사용한 프록시 객체 생성

 - Enhancer 객체를 사용하여 프록시 객체를 생성하는 예제이다.

 

3.1) EnhancerTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.ssk.simpany.test.methodInterceptorTest;
 
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.NoOp;
 
public class EnhancerTest {
 
    public static void main(String[] args) {
        
        Enhancer enhancer = new Enhancer(); //Enhancer 객체 생성
        
        enhancer.setSuperclass(MyService.class); // 타켓 클래스 지정
        enhancer.setCallback(NoOp.INSTANCE);      // 옵션  (NoOp, MethodIntetceptor 등)
        Object targetObj = enhancer.create();     // 프록시 객체 생성
        
        if(targetObj instanceof MyService){           // targetObj 객체가 MyService로 형 변환이 가능한지
            //형변환 가능.
            MyService myService = (MyService)targetObj; //형변환
            myService.myServiceMethod("test");
        }
    }
}
 
cs

 

3.2) MyService.java

1
2
3
4
5
6
7
8
9
package com.ssk.simpany.test.methodInterceptorTest;
 
public class MyService {
 
    public String myServiceMethod(String a){
        System.out.println("call my ServiceMethod is "+a);
        return a;
    }
}
cs

 

- EnhancerTest 클래스에서 프록시 객체를 생성할 수 있는 Enhancer 객체를 생성하고 setSuperclass 메서드를 사용하여 타겟 클래스를 지정한다.

- setCallback 메서드에는 NoOp.INSTANCE(NoOption)을 입력했는데, 말 그대로 옵션이 없는 프록시객체로 설정하기 위함이다.

- NoOp로 설정하면 단순히 프록시 객체를 통해 타겟 클래스만 호출하는 것이고, MethodInterceptor로 설정하면 타겟 클래스 호출 전 후로 로직을 넣거나 매개변수를 변경하는 등의 작업이 가능하다. 후자의 경우도 다뤄보도록 할 예정이다.

- enhancer.create() 메서드를 호출하면 마침내 프록시 객체를 생성하게 된다.

- Object 형으로 반환되게 되지만 내부적으로는 앞서 설정한 타켓 클래스이기 때문에 instanceof를 사용하여 형 변환 또한 가능함을 확인할 수 있다. 결과적으로 enhance 에서 생성한 프록시 객체를 통해 MyService객체를 호출한다.

 

3.3) 결과

프록시 객체를 통한 MyService 호출 결과

 

  사실 위 예제를 직접 작성했을땐 '그냥 객체 생성하고 호출하면 될것이지 왜 프록시까지 굳이 생성해서 호출하는걸까?' 라는 생각과 함께 비지니스 로직에 의해 동적으로 호출되어야 할 객체가 있다면 이를 사용했을 때 어느정도 효과를 볼 수 있지 않을까라는 생각도 들었다. 타겟 객체를 생성하는 게 아닌 호출하는 것이므로 메모리적으로도 좋지않을까? 혹시 누군가 알고계시다면 댓글로 달아주시길 부탁드리와요..


4. MethodInterceptor란?

 프록시 객체의 콜백 기능 중 하나로, 프록시 객체에서 타겟 객체를 호출하기 전, 후로 비지니스 로직을 추가하거나, 타겟 클래스가 아닌 다른 객체의 메소드를 호출하거나, 인자 값을 변경할 수 있다.

 구현 방법은 커스텀한 클래스에 MethodInterceptor 인터페이스를 상속받은 후 intercept 메서드를 오버라이드 한다.

 

4.1) MethodInterceptorTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.ssk.simpany.test.methodInterceptorTest;
 
import java.lang.reflect.Method;
 
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
 
public class MethodInterceptorTest implements MethodInterceptor{
 
    @Override
    public Object intercept(Object object, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
        // TODO Auto-generated method stub
        System.out.println("before target Proxy");
        Object returnValue = methodProxy.invokeSuper(object, args);
        System.out.println("after target Proxy");
        return returnValue;
    }
}
cs

 

4.2) Main.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.ssk.simpany.test.methodInterceptorTest;
 
import org.springframework.cglib.proxy.Enhancer;
 
public class Main {
 
    public static void main(String[] args) {
        
        Enhancer enhancer = new Enhancer();     // enhancer 객체 생성
        enhancer.setSuperclass(MyService.class);// 타겟 클래스 지정
        enhancer.setCallback(new MethodInterceptorTest()); //콜백으로 MethodInterceptorTest 객체 설정
        Object targetObj = enhancer.create();    //프록시 생성
        MyService myService = (MyService)targetObj; //형변환
        myService.myServiceMethod("test"); //프록시 객체로 메서드 호출
    }
}
 
cs

- MethodInterceptor를 사용하여 아주 간단한 프록시 객체를 구현했다. 기존과 다른 건 MethodInterceptorTest 클래스를 구현한 후 callback 으로 설정했다는 점이다.

- 이 설정으로 인해 프록시 객체로 메서드를 호출하면 MethodInterceptorTest 클래스의 intercept 메서드가 호출되게 되어 before target Proxy 문자열 호출 후 타겟 클래스가 실행됨을 확인할 수 있다.

참고로 methodProxy.invokeSuper의 리턴 값은 타겟 객체의 리턴 값이다.

 

4.3) 결과

MethodInterceptor를 적용한 프록시 객체 호출 결과


5. 마치며

 지금은 단순히 enhancer와 MethodInterceptor를 이용해 프록시를 구현하는 것만 해보아서 그런지 느낌만 알 뿐, 아직도 머릿속에서 정리가 되지 않은 느낌이다. 또한 어떻게 활용되는지도 크게 와닿지 않는다. 다음 게시글로 이 방식을 활용하여 다양한 예제를 만들어보도록 하겠다.

반응형

+ Recent posts