React 는 굉장히 훌륭한 DOM 을 포함한 UI 프레임워크입니다. (A JavaScript library for building user interfaces - https://reactjs.org/


하지만 React 는 그대로 쓸수가 없습니다.. react 가 es6 기반으로 되어있기때문입니다.


물론 es5 를 쓸수도 있습니다(https://reactjs.org/docs/react-without-es6.html)


헌데 그건 문서에서도 볼 수 있듯이 굉장히 귀찮은 일이죠...



그래서 webpack 을 쓰는게 좋습니다 기존의 gulp, grant 둘다 좋지만 webpack 써본결과 훨씬좋습니다.


많은분들이 webpack 을 쓰고계시고, 저 역시 쓰고있습니다. (HMR 은 진짜 신세계입니다)





근데 이놈이 보통 어려운게 아닙니다... ㅠㅠ



특히나 Spring 을 이용하신 분들은 더 어려워 하시는 것 같더라고요 (제 기준입니다)


그래서 샘플을 하나 만들었습니다.


spring-boot + webpack + react 입니다. 부가적으로 typescript, scss 도 들어있습니다~


typescript, scss 는 정말 좋습니다 정말로요.. 안써보셨다면 꼭 써보세요~ 괜히 구글이 angular 에 typescript 를 도입한게 아니더라고요


일단 왜 webpack 이 좋은지 왜 HMR 을 써야 하는지 궁금하시다면 영상을 하나 보시죠~




https://youtu.be/hFdFXei-5m8


코드도 올려놨으니 한번 둘러보시고 적용도 해보세요~ 


https://github.com/okihouse/spring-boot-webpack-react-typescript



정말 오랜만의 포스팅입니다.


사실 너무 바빴습니다..


현재 회사 서비스의 Backend, Infra(AWS), Frontend 모두 담당하고 있고..


거기다가 친구와 같이하는  서비스의 Backend 작업하고 있다보니 굉장히 바쁩니다...ㅠㅠ


그래서 앞으로 언제 또 포스팅을 쓸지 기약이 없습니다...




사설은 그만하고 오늘 쓸 포스팅은 스프링 시큐리티를 사용하여 remember-me 기능을 구현하고


추가로, was 를 재기동(재시작) 하더라도 자동 로그인을 가능하게 하는 샘플 프로젝트입니다.



다음과 같은 샘플코드를 작성하게 된 경위는


보통 인프라를 구성할때 AWS 를 사용하든 안하든 결국 L4 또는 L7과 같은 스위치 및 그와 비슷한 기능을 하는 것들을 사용할 수 밖에 없습니다.


저는 AWS 의 ELB를 사용하기도 하고 nginx 의 upstream 기능을 사용하기도 합니다.


서버 이중화는 선택이 아닌 필수니까요.


보통 Backend의 restful 서비스의 경우는 stateless(session-less) 이기 때문에 별로 상관이 없습니다.


무중단 배포가 생각보다 쉽게 됩니다. 


그러나 웹이라면 좀 달라집니다. 생각외로 많은 웹 서비스들은 session 에 많이 의존하고있고, 


was 가 재기동을 하게되면 session 정보를 잊어버리면서 다시 로그인해야 하는 상황이 벌어집니다. 



물론 S/W 방식으로 구현하는 방식이 있고, H/W 방식으로 구현하는것도 있죠 (예를들면 Session 을 외부 Storage 에 저장한다던가? redis ??)


한가지 확실한것은 둘다 까다롭기는 마찬가지입니다. 



본론으로 들어가면, 직접 구현하는것은 비추합니다. Spring Security 같은 굉장한 솔루션을 두고도 직접 구현하는건... (바퀴를 다시 만들지 마세요)



핵심만 바로 들어갑니다.



@Service
public class PersistTokenRepository implements PersistentTokenRepository {

	private static final Logger logger = LoggerFactory.getLogger(PersistTokenRepository.class);

	@Autowired
	private StringRedisTemplate stringRedisTemplate;

	@Override
	public void createNewToken(PersistentRememberMeToken token) {
		RememberToken rememberToken =
				new RememberToken(token.getUsername(), token.getSeries(), token.getTokenValue(), token.getDate());
		stringRedisTemplate.opsForValue().set(token.getSeries(), JsonUtils.toJson(rememberToken), 30, TimeUnit.DAYS);
	}

	@Override
	public void updateToken(String series, String tokenValue, Date lastUsed) {
		String payload = stringRedisTemplate.opsForValue().get(series);
		try {
			RememberToken rememberToken = JsonUtils.fromJson(payload, RememberToken.class);
			rememberToken.setTokenValue(tokenValue);
			rememberToken.setDate(lastUsed);
			stringRedisTemplate.opsForValue().set(series, JsonUtils.toJson(rememberToken), 30, TimeUnit.DAYS);
			logger.debug("Remember me token is updated. seried={}", series);
		} catch (JSONException e) {
			logger.error("Persistent token is not valid. payload={}, error={}", payload, e);
		}
	}

	@Override
	public PersistentRememberMeToken getTokenForSeries(String seriesId) {
		String payload = stringRedisTemplate.opsForValue().get(seriesId);
		if(payload == null) return null;
		try {
			RememberToken rememberToken = JsonUtils.fromJson(payload, RememberToken.class);
			PersistentRememberMeToken token = new PersistentRememberMeToken(
					rememberToken.getUsername()
					,seriesId
					,rememberToken.getTokenValue()
					,rememberToken.getDate());
			return token;
		} catch (JSONException e) {
			logger.error("Persistent token is not valid. payload={}, error={}", payload, e);
			return null;
		}
	}

	@Override
	public void removeUserTokens(String username) {
		// Skip this scenario, because redis set only unique key.
	}

}



4가지만 기억하면 됩니다.


토큰을 발급하고 > 발급한 토큰을 조회하고 > 사용한 토큰을 다시 사용할 수 없게 수정하고 > 혹시나 탈취되거나 만료된 경우는 삭제한다.


PersistentTokenRepository 을 사용하기 위해서는 어쩔수없이 외부의 저장소가 있어야 합니다. 

(스프링의 경우 In-memory 또는  JDBC 방식을 제안합니다.)


하지만 JDBC로 하게되면 Entity(Table) 도 만들어야 하고.. 이래저래 좀 그렇죠 사전작업도 해야되니까요


요새는 워낙에 캐싱을 많이 쓰니 memcached 또는 redis 는 한번쯤 접해보셨을 거라고 생각합니다.


그래서 redis 를 사용해서 구현하였고요, 생각보다 쉬우니 


자동로그인을 구현해야 하는데 서버는 이중화 되어있고, 


무중단 배포를 하고싶은데 사용자들이 로그아웃 되게 하고 싶지 않은경우에는 한번 살펴보세요~


생각보다 쉽게 해결될 수도 있으니까요



https://github.com/okihouse/spring-boot-security-with-redis-jwt-for-restapi-webapp

오랜만의 포스팅입니다~ 


오늘은 querydsl 에 대한 내용을 적어보려고 합니다~


저의 포스팅은 거의 그렇듯 아래와 같이 이루어집니다.




기존코드     >     문제발견     >     개선코드

 



짧게 설명을 하자면...


개선된 코드만 보는 습관을 들이게 되면 과거를 놓치게 되고 똑같은 실수를 저지르게 됩니다.


사실 우리나라의 거의 모든 스프링 프레임워크를 쓰는 개발자들이 스프링의 위대함을 얼마나 느낄지 모르겠으나...


스프링이 없던 시절을 생각해 보면.... 정말 암울합니다...


현재는 서비스 코드만 집중하는 시대에 살고 있으니까요... 스프링이 나머지를 다 해주니.. (비단 자바진영뿐만 아니고 나머지도 마찬가지죠)




여하튼~ 


JPA 를 쓰다보면 누구나 하나둘 문제에 부딪히게 됩니다... 한번도 문제를 만난적이 없으시다면 당신은 초고수 


오늘 포스팅은 조인에 대한 이야기 입니다. 거창하지 않고 간단하게 할겁니다.


먼저 엔티티부터 볼까요??



@Entity
@Table(name = "user")
@Data
public class User {

	@Id
	@GeneratedValue
	@Column(name = "uno")
	private Long userNo;

	@Column(name = "user_type")
	@Enumerated(EnumType.STRING)
	private USER_TYPE type;
	
	@OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
	private UserInfo userInfo;
	
	public enum USER_TYPE {USER, ADMIN}
	
}

@Entity
@Table(name = "userinfo")
@Data
public class UserInfo {

	@Id
	private Long userNo;

	@Column(name = "EMAIL")
	private String email;

	@MapsId
	@OneToOne(cascade = CascadeType.ALL)
	@JoinColumn(name = "UNO")
	private User user;
	
}




굉장히 심플한 2개의 엔티티 입니다~ 


1:1 관계를 갖고있으며, 컬럼도 많이 없습니다. 


저 두개를 한번 조인해 볼까요??



@Query(value = "select new com.boot.jpa.vo.ResultVO(" + "u.userNo," + "u.type," + "ui.email) " + "from User u inner join u.userInfo ui " + "where u.type = :type" ) List<ResultVO> findByType(@Param("type") USER_TYPE user);



보시다 시피 좀 지저분하죠...?? 하지만 누군가에게는 굉장히 익숙한 방법입니다~


직접 쿼리를 쓰는(사용하는) 분들에게는 모두 익숙하고 가독성도 나쁘지 않을겁니다.


그러니까 전혀 문제가 없다고 생각 할 수도 있습니다.. 



그러나 위 코드는 몇몇개의 문제가 존재합니다. 바로 Dynamic Query 를 작성해야 할때가 그 중 하나입니다.


만약에 type 값이 있을때만 where 구문을 하고 싶다면?? 


select 쪽에 if 또는 when 을 넣고싶다면??? 



그렇기에 저 코드는 조금 문제가 있습니다. 물론 아예 방법이 없는 것은 아니지만... 그리 좋은방법도 아니기에~


또한 JPA 의 철학?? 에 빗대어 보자면 우리는 객체지향언어 개발자들이니... 쿼리도 객체지향적으로 다뤄야 겠지요~ (직접 쿼리를 쓰는건 넘나 안되는것)




이미 많은 개발자분들 및 선배님들이 이 문제를 알고 대응을 하셨습니다. 


JPA책을 보신 분들이라면 아시겠지만 QueryDSL 말고도 여러 방법이 있습니다. 


하지만 오늘은 간단하게 QueryDSL 만 하려고요~ 이게 제일 좋아보여서요 ㅋㅋ



각설하고 코드부터 볼까요??


@PersistenceContext private EntityManager entityManager; private final QUser qUser = QUser.user; private final QUserInfo qUserInfo = QUserInfo.userInfo; @Override public List<ResultVO> findByType(USER_TYPE user) { JPAQuery query = new JPAQuery(entityManager); return query.from(qUser) .innerJoin(qUser.userInfo, qUserInfo) .where(qUser.type.eq(User.USER_TYPE.USER)) .list(Projections.bean(ResultVO.class, qUser.userNo, qUser.type, qUserInfo.email)); }



선언부를 다 제외하고 return 부분만 보시면 됩니다. 


위에서 보여드린 쿼리랑 동일한 동작을 하는 코드입니다. 


객체지향적으로 작성되었지만 보시는대로 쿼리랑 다를바가 없습니다. (굳이 설명하지 않아도 바로 이해되는코드... 정말 대단하지 않나요???)



기존코드의 문제점은 이제 모두 해결됩니다. 예를들어 Dynamic Query?? 문제가 되지 않습니다. 평소 우리가 하던대로 if 넣으면 됩니다. 



QueryDSL을 안쓰면(직접 쿼리를 쓰면) 나쁜코드를 작성하는 사람이냐?? 제 관점에서는 아닙니다.


하지만 JPA 관점에서는 그렇게 보일 수도 있습니다. 스프링의 관점에서도 마찬가지 일 것 이라고 추측합니다. 


스프링이 XML 과 작별을 한 것 처럼 JPA는 쿼리와(DB Schema) 작별하려고 합니다. 종속되지 않으려고 하는거죠


기본전제는 단순합니다. 너는 객체지향 개발자이다. 객체지향적으로 코드를 작성하라!! 




샘플코드를 github 에 올려놓았습니다. 


직접 코드를 실행시켜 보시고 jpa 와 한발 더 친해져 보세요~ (샘플코드에는 pageable 에 대한 내용도 나와있습니다~)


https://github.com/okihouse/spring-jpa-querydsl-sample





늦었지만~ 새해 복 많이 받으세요 ^^


오늘 포스팅은 JPA 와 Testing 에 관한 것 입니다.



최근들어 JPA가 많이 사용되는 것 같습니다~ 제가 속한곳의 프로젝트도 JPA로 작성하고 있구요~


그래서~ 오늘은 JPA 로 구현된 코드를 어떻게 테스트 할 것인지에 대한 내용을 포스팅 하려고 합니다.


이전 포스팅에서도 예고했듯이 오늘의 핵심은 바로 MockMvc & Transaction Testing 입니다.


먼저 일반적인 시나리오를 하나 볼까요?? (사용자 로그인 시나리오)

1. 사용자가 이메일 + 비밀번호를 입력하여 로그인 합니다.

2. 사용자의 정보가 DB에 존재한다면 정상 응답을 받을것이고 (2xx ok)

3. 사용자의 정보가 DB에 존재하지 않는다면 비정상 응답을 받을 것 입니다.(4xx client error) - e.g 가입되지 않은 사용자입니다 등등.. 


굉장히 심플하고 실제로 운영코드에도 사용되는 지극히 정상적인 시나리오 입니다 ㅎㅎ



 경우의 수 에 대한 생각을 한번 해볼게요~

1. 요청 정보가 제대로 되지 않은경우(일부 파라미터가 누락되었다거나... 요청방식이 잘못되었다거나....)

2. 사용자의 정보가 DB에 존재 및 존재하지 않는경우.

3. 기타 등등 


뭐 딱히 복잡한게 없습니다... 에러처리할 것도 별로 없고요~ 다음으로 넘어가볼까요?



기존 테스트 방식은 어떨까요?? 

글세요 이것은 사람마다 너무 달라서.... 제 방식으로만 설명드리자면... 크게 2가지 일텐데


- 로직검증 > 모듈검증 > 실제 환경을 수행하는 메소드나 클래스 단위로 검증

- 요청검증 > 실제로 요청 하여 DB 값 또는 반환값으로 검증


로직검증같은 경우에는 Mockito 를 많이 써서 임의의 값을 받고 넘겨주는 작업을 했으며(예를 들어서 MockHttpServletRequest, MockHttpSession 등등...)


요청검증의 경우에는 실제로 요청을 해보고 DB값을 갖고와서 Assert 에서 비교를 하거나, 반환값들을 Assert 에서 비교를 하곤 했지요~



오늘 여기서 다룰 것은~ 바로 2번째 요청검증에 대한 내용입니다.



먼저, 앞서 설명했던 사용자 로그인 시나리오를 볼까요??


일단 사용자로부터 이메일 + 비밀번호를 받아야 겠네요~


두번째로... DB에 사용자 정보가 있는지 없는지를 검증하구요!! 


마지막으로 반환값을 검증해야 합니다. 


근데!! 여기서 문제가 하나 있습니다... 로컬에서 테스트하고... 개발서버에서도 테스트하고.. 문제 없는데 만약에...


실제 운영서버에서 테스트하려면?? 

>> 임의의 사용자를 DB에 넣 그 사용자가 있는경우가 없는경우를 테스트 해야되나요?? DB에 더미값이 쌓이게 됩니다.



즉, 핵심은 이겁니다!! DB에 테스트 전 임시 사용자 값을 넣고 테스트가 종료되면 자동적으로 롤백되도록 하는거죠~



테스트 코드부터 보시죠


# Test Case 1. 회원이 존재하지 않는경우


@Test         public void test_user_not_exist() throws Exception {     // parameter     String email = "test@test.com";     // request get     mockMvc.perform( get("/user/login") .param("email", email)                 .contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)) .andDo(print()) .andExpect(status().is5xxServerError()) .andExpect(jsonPath("$.message").value("user is not exist")); }


------------------------------------------------------------------------------------------------------------------------------------------------------


# Test Case 2. 회원이 존재하는경우


	@Test
	public void test_user_exist() throws Exception {
		// parameter
		String email = "user@test.com";
		
		// insert user
		User user = new User();
		user.setType(User.USER_TYPE.USER);
		
		UserInfo userInfo = new UserInfo();
		userInfo.setEmail(email);
		userInfo.setUser(user);
		
		user.setUserInfo(userInfo);
		
                // persist
		userRepository.save(user);
		
		// request get
		mockMvc.perform(
				get("/user/login")
				.param("email", email)
				.contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
					.andDo(print())
					.andExpect(status().is2xxSuccessful())
					.andExpect(jsonPath("$.message").value("user exist"));
	}



두번째 케이스만 보시면 될 것 같습니다~


파라미터는 패스워드를 임의로 누락시킨거니 신경쓰지 마시구요 ㅎㅎ 


중요한건 Insert user 부분입니다~ 실제로 DB에 JPA 를 이용하여 사용자 정보를 입력합니다. 


그리고 나서?? Controller 를 호출하는거죠~ 


마지막으로 Assert 작업을 합니다. 2xx OK 가 되었는지~ 결과값은 정상적인지요 


테스트가 끝나면?  아래와 같이 롤백됩니다~ 걱정하지 마세요 ^^



실제 바로 테스트 할 수 있는 코드를 Github에 올려놓았습니다.


테스트 해보시고~ 실제 작업에도 연동시켜 보세요~ 깔끔한 테스트 코드가 탄생할 지도 모르자나요? ~



https://github.com/okihouse/spring-jpa-test











제목을 영어로 쓰면 뭔가 거창해 보이죠....ㅋㅋㅋ


하지만 사실상 별건없습니다.


영어로 쓴 이유는 영어를 잘해서도 아니고, 한글로 쓰기에는 뭔가 양이 많아보여서... 즉, 난 어휘력이 약하다는것;;



대다수의 직장인이 그렇겠지만 특히 개발자라면 일정과 항상 씨름하게 됩니다..


그러므로 우리는 TDD  즉, 테스트 주도 설계가 사실상 불가능하죠 ㅋㅋㅋㅋ


TDD 의 핵심은 테스트코드를 선행 작성하는건데, 

대부분이 먼저 코드를 만들고 그걸 검증하기 위해 테스트 코드를 작성합니다.


근데 이것조차 힘든게 바로 대한민국 IT 업계의 현실 아닐까요? ㅋㅋㅋ



개인적으로 나는 개발자가 꼼꼼해야 된다고 보는편인데, 그 이유는 로컬에서야 뭐 이런저런 실수가 나올 수 있지만


Product 에서 사소한 실수는 치명적인 사고로 이어질 수도 있으므로... 

그렇기에 본인이 작성한 코드에 대해 책임을 져야 한다고 봅니다..



즉, 하고싶은말은 결국.... ㅋㅋ 테스트 코드 작성을 생활화 하자 입니다 ㅎㅎ (안하는 사람들이 워낙많아서 ㅠㅠ 힘듬)



어느순간부터 포스팅을 쓰기전에 사담을 늘어놓는데... 글세요 요새 제 의견과 일치하지 않는 환경에서 일하다 보니 영향을 받은것일지도...



본론으로 들어와서, 현재 프로젝트는 Spring Boot 로 개발하고 있습니다. 버전은 거의 최신버전인 3점대로 하고있고요


테스트 코드도 작성하고 있는데.. 


Spring Boot 에서는 테스트 코드가 정말 작성하고 싶어지게 할 만큼.. 매력적입니다...



늘상 그렇듯 테스트 코드 작성하고 로컬에서 Unit Test 코드를 돌리고... 성공이 떨어지면 


CI 서버에서 빌드를 진행하고.... 배포를 하게되죵~



뭐 항상 하던작업이긴 한데... Spring Boot 로 변경하고 나니... 배포 프로세스를 변경하고 싶어집니다... 


빌드하기전 Unit Test 코드실행 후 성공해야 배포하는 것으로요~


(사실 예전에는 빌드하고 배포한 후에 테스트 코드를 돌렸었는데..... >> 아직 많은 곳에서 이렇게 하시죠?)



근데 문제는 그겁니다... 


로컬에서 테스트를 한다고 하면 설정파일을 로컬로 바꿔놓고 하면되는데 ~ (하드코딩으로 한다거나??)


젠킨스 부터는 코드에 손을 못댑니다.. 더군다나 Product 이라면?? 


테스트 코드는 똑같은데 실행환경만 Profile 에 맞게 변경하고 싶다면??? 


사실상 예전에는 생각해 보지 않았던 작업이라서 처음에는 막막했습니다.


그 이유는.... 사실 이전에도 이런 작업을 한 적이 있지만 Quality 검증이었습니다. 


예를들면, PMD , Coverage 등등등... 실제 서비스 로직과는 별개의 그런...


하지만 Spring 은 실제 소스 검증을 가능하게 해줍니다. 


Spring boot 로 접어들면서 embedded tomcat 이 생겨났고, 그러므로 유닛코드를 동작시키면 톰캣이 동작하는것처럼 보입니다.


그리고 나서 실제 요청주소로 테스트 코드에서 작성한 내용들을 검증하게 되죵~


실제 결과부터 볼까요??


사진은 PC 환경에서 클릭할 경우 크게 볼 수 있습니다.



- 먼저 Jenkins 빌드 Console 화면을 볼까요?? 잘 보시면 Result 부분에 Test 결과가 나타나 있습니다. 


모든 테스트가 실패나 에러없이 전부 통과했네요~~ 




- 두번째로 빌드 요약입니다. 보시면 아시겠지만 빌드는 성공했고 아래 실패가 없습니다 라는 문구로 비추어 볼때 테스트도 성공입니다~

아~ 참고로 새벽 2시 아닙니다... AWS 를 사용하고 있는데 기본 시간 설정이 GMT 이다 보니 + 9시간 해야 합니다. 오전 11시에 한거에요 ㅋㅋ




- 젠킨스는 참 이런점이 좋다고 생각되는데~ 테스트 결과를 좀 더 그럴싸 하게 보여줍니다~


또한, 테스트 결과를 외부로 전송할 수도 있습니다. 


나중에는 협업툴인 Slack 으로 테스트 결과를 어떻게 보내는지도 포스팅 할 예정입니다~~



다시 본론으로 돌아와서~ ㅎㅎ 자 그럼 어떻게 이렇게 할 것인지 한번 생각해 봅시다. 



- 위에서 본 것 처럼 로컬에서 테스트 할 경우는 환경설정을 그때그때마다 변경해 주면 되지만


젠킨스에서는 환경설정을 맘대로 바꿀 수 없으니 외부에서 무언가 값을 받아와야 합니다. 


그런면에서~ Spring Boot 는 아주 좋은 해결책을 제시하고 있죠~ 바로 System Environment Variables 입니다. 


args 에다가 -Dname=value 이런식으로 사용합니다 아래 그림을 보시죠~




요런식으로다가 사용할 수 있다는겁니다... 


근데 이것만 있느냐? 아니죠 ㅎㅎ 바로 Maven 실행환경에서도 값을 넘길 수 있다는겁니다.


바로 요런식으로요~~




위 사진은 jenkins 에서 빌드할 때 사용하는 구문입니다. Goals 에는 작업 명령어를 작성하는데 Options 도 작성할 수 있죠


저는 2가지를 썼네요~~ 하나는 -Dgit.commit 이고.. 또 다른 하나는 -Dprofile 인데요 


사실 지금 포스팅과는 상관없긴 하지만 Dgit.commit 은 살짝만 맛보고 넘어가자면


이런 용도로 씁니다~ 


Jenkins 에서 빌드가 끝난 후 배포를 진행하고~ 최종버전으로 잘 배포 되었는지를 판단하는 값으로 쓰입니다. 


위 처럼 -Dgit.commit=${GIT_COMMIT} 이라고 쓰고 빌드&배포를 하게되면 META_INF 에 다음과 같이 나타나게 됩니다. 




요놈을 모니터링 또는 관리자용 사이트에서 Json 형태로 호출해오기만 하면 되는거죠~ 요렇게요





자세한 내용은 이전 포스팅에서 확인해보세요 >> 요기



자 그러면 핵심인 -Dprofile=local 부분을 보겠습니다~ 해당 부분은 maven option 으로 써 POM 파일쪽에 뭔가 값으로 넘길 수 있습니다. 


이 값을 받으려면 Plugin 이 필요하게되는데~ 저는 Maven Surefire Plugin 요걸 사용하기로 했습니다~ 

Maven Surefire Plugin 은 Test 를 위해 만들어진 Plugin 이라고 해도 과언이 아닙니다.. 원하는대로 테스트를 할 수도 있고 


테스트를 건너뛸 수도 있으며, 테스트 결과를 여러 형태로 받아볼 수도 있습니다. 이 외에도 여러가지 Test 관련 기능들이 있죠~ 


직접 들어가셔서 확인해 보세요~~ 요기 >> Maven Surefire Plugin


해당 Plugin 이 제공하는 기능이 여러가지 있지만 그 중에 저희가 사용할 것은 바로 Using System Properties  입니다~


해당 기능을 사용하면 위에서 설명했던 외부 변수를 POM 파일로 받을 수 있습니다. 


그 중에서도 2번째로 설명했던 Maven 빌드 시 Option 을 받아오는걸 사용해 보도록 하겠습니다~


저는 POM 파일에 이렇게 세팅을 해봤습니다~ (정석은 오른쪽입니다~)







profile 이라는 이름으로 ${profile} 를 갖고올 수 있게 해놨네요~ 


그러고 보니 위에서 본 기억이 나지 않으시나요?? 


다시 사진을 볼까요?? 



네 맞습니다 -Dprofile=local 부분이죠~ 이 부분으로 저 값이 세팅됩니다. 


자 그렇다면!!!! 저 값을 어떻게 갖고올것이며, 저 값을 어떻게 Spring Test 에 적용할 것이냐?? 이 부분을 보겠습니다~


먼저 살펴봐야 할 문서가 있는데요 ㅎㅎ 귀찮지만 꼭 봐야됩니다.


바로 Spring Boot Test 관련 문서입니다. 링크 >> ActiveProfilesResolver


Description 부분만 살짝 옮겨왔습니다~ 해석하자면 ... 음... 네 바로 그겁니다...


To resolve active bean definition profiles programmatically, simply implement a custom ActiveProfilesResolver and register it via the resolver attribute of@ActiveProfiles. The following example demonstrates how to implement and register a custom OperatingSystemActiveProfilesResolver. For further information, refer to the corresponding javadocs. 


저는 이렇게 적용했습니다. 


먼저 세팅하는 부분입니다. 

package com.rest.base; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.test.context.ActiveProfilesResolver; public class DnbbTestActiveProfilesResolver implements ActiveProfilesResolver { private static final Logger logger = LoggerFactory.getLogger(DnbbTestActiveProfilesResolver.class); @Override public String[] resolve(Class<?> testClass) { String profile = System.getProperty("profile"); if (profile == null) { profile = "local"; logger.warn("Default spring profiles active is null. use {} profile.", profile); } return new String[] {profile}; } }




다음으로 실제 적용하는 부분입니다~

package com.rest.dnbb;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import com.rest.DnbbApplication;
import com.rest.base.DnbbTestActiveProfilesResolver;
import com.rest.base.DnbbTestContextInitializer;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = DnbbApplication.class, initializers = DnbbTestContextInitializer.class)
@WebAppConfiguration
@ActiveProfiles(inheritProfiles = false, resolver = DnbbTestActiveProfilesResolver.class)
public class DnbbUserTest {
	
	@Autowired
	private WebApplicationContext webApplicationContext;
	
	private MockMvc mockMvc;
	
	@Before
	public void before(){
		this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build();
	}
	
	@Test
	public void testUserDuplicateEmail() throws Exception {
		mockMvc.perform(
				get("/user/duplicate/email")				
				.param("email", "test@test.com")
				.contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
					.andDo(print())
					.andExpect(status().isOk())
					.andExpect(jsonPath("$.message").value("success"));
	}
}


특별히 중요히 보셔야 될 부분은들 하이라이트 처리를 해놨습니다.


위 분홍색으로 하이라이트 처리한 부분이 실제 적용하는 부분입니다~



사실 실제로 작업하는 양도 적고... 뭐랄까 그리 어려운 내용도 아니지만 어떻게 해야될지 모르고.... 어떤 Plugin 및 Spring 이 지원하는지 몰라서 못하시는 분들을 위해


최대한 자세히 쓰려고 했습니다~ 



이번기회에 테스트 코드를 작성해보시고~ 테스트 프로세스를 변경해 보시는건 어떠세요?? 


어쩌면 dev, stage, product 에서 발생 할 중대한 에러를 발견해 내고 뿌듯해 하실지도 모릅니다!! 당장 시작 하시죠 ^^


다음 포스팅 주제는... 역시 테스트와 관련되어 있는데요~ 


MockMvc & Transaction Testing 입니다.




이번에 spring boot 로 완전히 옮겨타면서 신세계를 경험하고 있는데~


여러가지를 덧붙여 사용하면서 lombok 을 접하게 되었다~


개인적으로 getter, setter 만드는 작업이 굉장히 짜증났는데 (물론 IDE 가 자동으로 만들어 주지만)


그 이유는 변수가 변경, 추가, 삭제될때마다 getter, setter 역시 변경해 줘야 되기 때문에...


lombok 을 사용하면서 그런 고충이 덜었다~ 


이전 프로젝트에 시험삼아 lombok 을 넣었는데...... (spring 3.x / xml configuration)


jenkins 에서 빌드가 안되는상황이 발생!!!!!


하나하나 조목조목 찾아봤더니... 


뭔가 setter 호출이 안되는 상황.... 뭐지....



(그림은 클릭하면 커집니다~)


분명히 intelliJ 에서는 잘 동작했는데.... 뭐여



일단 분명한것은 Compile 에러라는것!! 


구글링을 무지하게 하면서 결국 해결했는데 결론은.... 


maven compile plugin 이 너무 구 버전이었다....


2.3.2 버전을 사용하고 있었는데 2.4 버전부터 동작하는걸 보니 2.4 버전에 뭔가 업데이트 되었다고 판단!! 찾아봤다~


https://github.com/rzwitserloot/lombok/issues/877

참조 사이트에 보면 해당 내용이 존재한다.


 I had the same issue with maven 3 and lombok 1.16.x but simply updating maven-compiler-plugin to 3.3 (from 2.3.2 which does not support forceJavacCompilerUse) fixed it for me (no need for forceJavacCompilerUser, maybe because I'm still using Java 6?)


위 사람은 나랑 똑같은 상황에서 3.3으로 버전을 올려버린다... 뭐 물론 3.3이 현재 최신 버전이기도 하고 lombok 에서 추천하는 것 같다.


http://awhitford.github.io/lombok.maven/plugin-updates-report.html


위 사이트를 참조해 보면 maven compile plugin 3.3 버전의 상태가 나타나 있다. 


근데 문제는 2.3.2버전에서는 문제가 있는데 2.4에서 없으니 분명히 뭔가 수정된게 맞는데... 


릴리즈 노트에서는 아래 내용밖에 없다.... 흠...

Bug

  • [MCOMPILER-64] - "mvn clean install" crashes with java.lang.OutOfMemoryError: PermGen space after update to java 1.6.0_04
  • [MCOMPILER-94] - compiler sets artifact file to target/classes, even if nothing is compiled
  • [MCOMPILER-99] - Spaces in for external executable are not accepted
  • [MCOMPILER-120] - Javac compiler plugin doesn't support -Werror
  • [MCOMPILER-129] - unable to pass in javac -J option
  • [MCOMPILER-130] - compilerArgument option doesn't work with maxerrs option, compilerArguments does
  • [MCOMPILER-135] - Passing multiple parameters to Java 6 annotation processors with javac does not work
  • [MCOMPILER-136] - The description of the skip parameter of the testCompile mojo is incorrect
  • [MCOMPILER-148] - Misleading documentation on <configuration><encoding>
  • [MCOMPILER-149] - Java compiler warning is masking a javac exception, which the compiler plugin doesn't know how to parse
  • [MCOMPILER-167] - Incorrect default for generatedTestSourcesDirectory

Improvement

  • [MCOMPILER-137] - Plugin documentation refers to "Maven 2"
  • [MCOMPILER-147] - The usage page should use pluginManagement for configuring the plugin
  • [MCOMPILER-166] - Use plexus-compiler 1.8.6 for performance improvement

 


해당문제를 겪으시는 분은 간단히 버전업으로 해결할 수 있습니다~


참조로 릴리즈 노트부분도 링크 걸건데.. 혹시 알아내신 분은 알려주세요... 업무가 바빠서 일일이 보기가 힘드네요 ㅠㅠ


개인적으로는 아래 문제가 아닐까 판단됨....


  • [MCOMPILER-135] - Passing multiple parameters to Java 6 annotation processors with javac does not work


https://issues.apache.org/jira/secure/ReleaseNote.jspa?projectId=12317225&version=12330427





'Environment' 카테고리의 다른 글

[Maven-Lombok] cannot find symbol  (2) 2016.01.13
  1. 조우진 2016.01.26 17:01 신고

    제 삽질 3시간을 살려주셨네요...
    정말 감사합니다. :)

드디어 [Redis - Spring boot] Auto Complete 구현(추천단어검색) 마지막 포스팅을 쓰려고 합니다.


이전 포스팅에서 나타났던 것 처럼 여러번의 시도를 해봤으나, 많은 실패를 하고 설계에 대한 고민을 많이 했습니다.


그래서 Key 설계를 다시 하게 되었구요, 


Key 설계의 내용은 대략 이렇습니다. 


기존 Key : servicename:prefix

변경 Key : servicename:prefix:length


변경된 Key 에는 Length 가 들어갑니다. 


우리가 얻을 수 있는 이점은 다음과 같습니다. 


String 으로 검색이 불가능 하므로 length 별로 쪼개서 담아놓으면 Length 별로 담긴 데이터만 검색할 수 있다.


이 말 뜻을 조금 더 풀어볼까요??


A 부터 A 로 시작하는 30 의 길이를 가진 단어가 있다고 가정할 경우 


기존 방식은 A ~ A(30) 까지 전체검색을 하였다면, 변경된 후에는 ( 괄호안은 해당문자열의 길이 )


A(5) 가 들어왔다면 A(5) ~ A(30) 까지만 검색을 합니다. 


그렇다면, A(1) ~ A(30) 이라고 한다면 결국 전체검색이 아니냐? 라고 생각하실 수 있습니다. 


결과부터 얘기한다면 아닙니다~


그 이유는 다음과 같습니다. 인스타그램을 예로 들어볼까요?? 


인스타그램에서 love 를 검색해보시기 바랍니다~ love 로 시작하는 단어가 굉장히 많을겁니다~ 그러나 제한이 있습니다.


제가 세어보지는 않았지만 인스타 그램은 특정 개수 이하면 전부 표시하고, 특정 개수 이상을 넘어갈 경우 정해놓은 개수만 표시합니다. (페이징은 하지 않습니다.)


실제로 모든 결과를 나타낼 필요도 없습니다. 왜냐면 이건 말 그대로 추천단어니까요... 너에게 추천할만한 것만 보여줄거야 


라는 뜻으로 쓰는것이니까 굳이 전체를 보여주는 것 보다는 상위 30개만 보여주는게 더 낫다는 겁니다. 

(덧붙이자면 사실 검색은 사용자가 이용하는 시간이 굉장히 짧습니다. 검색에서 소요하는 시간이 짧다는 겁니다)


그러므로 우리는 이런 계산이 가능합니다. 





갑자기 수학공식같은게 나오니까 놀라셨나요?? 네 저도놀램요 ㅋㅋ 그래도 어디서 많이 보지 않았나요? 네 바로 수열입니다 ㅎ


N 은 사용자에게 보여줄 최대 게시물 개수입니다. 작은 n 의 경우 입력가능한 최대 글자 수 입니다.





좀 더 짧게 표현해봤습니다. 더 깔끔하게 한눈에 보이죠~ k 는 최대 글자 수, m 은 최대 게시물 개수 입니다.


제가 왜 수열공식을 썼을까요?? 그리고 잘 보시면 k 값이 무한이 아니라는 조건이 있을 경우 무한급수를 성립할 수도 없죠. 즉, 검색의 범위를 정할 수 있다는겁니다.


우리는 여기서 K 값을 정할 필요가 있습니다. 바로 글자수죠 



다시 인스타그램의 예를 들어볼까요?? 


저는 사실 인스타그램을 잘 안씁니다만... 언뜻보기에 사용가능한 글자수는 30글자 정도 되어보입니다. 


그래서 K 값에 30을 넣겠습니다. 


두번쨰로, M 값을 살펴볼까요? 인스타그램은 언뜻보기에 사용자에게 보여주는 최대단어의 개수가 30개 정도로 되어보입니다. 


그래서 M 값에도 30을 넣겠습니다. 


자 그러면 우리는 S 값을 구할 수 있습니다. 바로 900 이라는 값입니다. 


그러나!!! 여기서 우리가 놓치고 가는게 하나 있습니다. 


** 인스타그램을 예로 들고 있으니 인스타그램을 좀 더 살펴봅시다!!!


인스타 그램은 한글자를 넣을 경우 어떻게 동작할까요?? 


많이 써보신 분들은 아시겠지만 추천단어 검색을 하지 않습니다. 그냥 그 단어로 시작하는 게시물의 갯수가 몇개 있어~ 라고 보여줄 뿐이죠


그렇다면 N 값의 시작은 2가 되겠네요~ 좀 더 범위가 줄어들 수도 있겠습니다.


뭐 어쨋든 저는 적용하지 않았습니다. 하지만 저렇게 범위를 줄일 수 있다는것은 알아두세요~



정리를 한번해볼까요?? 


Key 설계 시 Length 를 넣었습니다. 이점은 바로 전체검색을 하지 않아도 된다는 것과 


마치 전체검색을 할 것 같지만 실제로는 K 값과 M 값에 따라 달라진다였습니다. 


위에서 예를 든 경우는 900개에서만 검색을 하니 실제로 전체 검색은 아닙니다. 



--------------------------------------------------------------------------------------



실제로 그런지 테스트를 해봐야겠죠


이전 포스팅에서는 대략 A로 시작하는 단어 7만여개를 넣었습니다. 


하지만 조건을 좀 더 가혹하게 해보죠~


이번엔 1171353 개를 넣었습니다.


놀라실 것도 없습니다. 인스타그램에서는 love 로 시작하는 단어의 게시물 갯수가 몇억개이니까요 


물론 저는 억단위로 테스트는 하지 않을겁니다. 사실 할 필요도 없습니다.

(레디스의 검색 시간복잡도가 O(log(N)+M) 이니까요~ M 값은 최대 100 이하입니다. 속도야 보장되어있는 사실이죠... 

아 물론 서버 성능은 반드시 고려해야겠네요~ 억단위로 저장하려면 공간이 많이 필요할테니까요)


참고로 제가 말씀드리지 않는게 있는데 정렬된 셋은 기본적으로 Expired 되지 않습니다.





레디스의 명령어 중 TTL 이라는 것으로 key 검색을 해보면 -1 값이 나타나는 것을 알 수 있는데 이건 expired 되지 않는다는 뜻입니다. 


그러므로 잘 생각하셔야 할 것이 expired 되지 않는 값들이 억단위로 쌓일경우에는 어떻게 할까? 라는 고민이 필요합니다.


참조를 보시면 정렬된 셋의 경우 1GB 당 8백만개의 라인을 소비한다고 나와있습니다.

참조 : http://autocomplete.redis.io/


점점 사설이 길어지네요~


슬슬 테스트 결과를 봐야할 때 입니다.


먼저 A 로 검색을 해볼까요??


A 로 시작하는 단어의 저장 갯수 : 1,171,353 개


요청 문자열 : a

소요시간 : 46ms




요청 문자열 : aaz

소요시간 : 72ms



보이시나요 저 놀라운 속도가? 


기존 포스팅을 계속해서 보신 분들은 아시겠지만 A 로 검색 시 최선의 경우에도 가장 빠른게 100ms 가 넘었습니다.


이제는 요청 시 100ms 를 거의 넘지 않습니다.


사실 이번 추천단어검색 부분을 구현하면서 개발팀 내부에서 정한것은 100ms 이내로 해결하자 였는데 결국 성공한 듯 보입니다.


이제는 통합 테스트와 자잘한 버그들을 잡는 일들이 남았지요 ...


지금은 이렇게 블로그 포스팅용으로 글을 남기지만 이런 내용들은 좀 더 깔끔하게 정리해서 


Slideshare 나 기타 도움이 될 만한 곳에 올릴 생각도 하고 있습니다. 



또한, 첫 포스팅부터 약속드렸던 github 페이지도 공개합니다.


많이 와서 봐주시고 issue 가 있다면 해결해 나가면서, 기능들을 추가해 나가는 형식으로 가꿔갔으면 합니다. 


긴 포스팅을 읽어주셔서 감사드립니다.


Github repository

https://github.com/okihouse/spring_boot_redis_auto_complete







이전 포스팅에서 


[Redis - Spring boot] Auto Complete 구현(추천단어검색)


라는 제목으로 여러 이야기를 꺼냈는데... 사실 제가 코딩을 하면서 쓰는내용이라 중간에 변경되는 부분이 많습니다.


그래서 이번 포스팅에서는 조금 변경된 부분과 하지 못헀던 이야기들을 써보도록 하겠습니다.


하지만 이게 끝이 아닙니다... 이번 포스팅도 사실상 실패한 내용을 서술할 겁니다~


다음 3번째 포스팅에서 종합적으로 적용가능한 코드 및 github 페이지를 공개할 예정입니다.




먼저 성능에 관한 이야기를 좀 해볼까 합니다.


# Performance


그렇습니다... 성능... 서버개발자들에게는 항상 따라다니는 수식어죠

(서버 개느려요... 어쩌라고.. - 돈만 준비하세요 우리에게는 AWS auto scale 이 있습니다 후훗)


기본적으로 우리가 사용할 기능은 4가지 이지만 실질적으로 사용가능한건 2가지라고 보시면 됩니다.


rangeByScoreWithScores(key, min, max) , rangeByScore(key, min, max)


요 두놈가지고 지지고 볶고 해야됩니다. 


물론 다른 기능들도 있는데 실질적으로 사용이 거의 불가능 하다고 판단했습니다. 


그 이유는 아래에 더 자세히 쓰겠습니다. 지금은 성능에 대한 얘기니까요 (좀 길어진다 싶으면 의식의 흐름으로 개소리나옴)


첫째로, 레디스에 데이터를 약 4백만건 넣었습니다. 서버 성능은.... 

CPU(s)   : 12

MemTotal: 7903568 kB


개발서버 치고는 성능이 좀 좋은듯?? 


일단, 테스트 데이터도 넣었겠다 게시물을 한 2개만 검색해 볼까요?? 


조건 : 모든 데이터 검색조건 rangeWithScores(key, 0, -1);



총 3번 요청 해봤고 elapsed time 은 다음과 같습니다.

총 Data 수 (3958448 개)

 시도

 elapsed time

 1

 277ms

 2

 118ms

 3

 95ms


나름 나쁘지 않은데요?? 


자 그러면 진짜 많이 있는 단어를 한번 입력해 볼까요? 


저는 테스트용 벌크 데이터를 알파벳 + 숫자로 넣었기 때문에 아마 a 로 검색하면 많이 나올 것 같아서 a 로 검색해봤습니다.


조건 : A로 시작하는 모든 데이터 검색조건 rangeWithScores(key, 0, -1);



ㅋㅋㅋ 10초 정도 걸렸네요 


아무래도 Full Scan 으로 검색하다 보니 그런 것 같네요 


그래서 Score 를 줘보기로 합니다.



조건 : Score 가 1인 A 로 시작하는 데이터만 검색 rangeByScoreWithScores(key, 1, 1); 





총 Data : 3,958,448 개

Score 가 1인 Data : 35,339개

elapsed time : 96 ms (서버 처리시간)


위에 보시는 것 같지 Score 라는 필터를 주게되면 소요시간이 급격하게 줄어듭니다. 


Score 라는 필터를 사용해서 검색을 하는편이 성능에 좋을 것 같은 생각이 드네요~


------------------------------------------------------------------------------------------------


사실 가장 좋은거는 역시 Score 와 prefix 또는 contains 로 검색하는게 가장 좋은 방법이긴 합니다. 


하지만 레디스는 그런 기능을 제공해 주지 않습니다. 


zscan 이라는 기능이 있지만 기존 포스팅의 전제조건인 "Score 점수까지 알아내야 한다", 에는 부합되지 않으므로 적합하지 않습니다.


ZRANGEBYLEX 라는 기능도 있지만 안타깝게도 StringRedisTemplate 에서는 지원되지 않는 것 같네요 (이것도 score 는 포함되지 않음)


아래 URL 참조를 보시면 아직 open issue 가 존재합니다.. 언제 반영될지는 미지수 이구요~

참조 : http://stackoverflow.com/questions/33862511/looking-for-examples-on-how-to-use-spring-data-rediszsetcommands-zrangebylex/33865770#33865770


------------------------------------------------------------------------------------------------


위에서 테스트해본 결과 score 로 필터링을 하면 성능이 좋아진다는걸 알아냈습니다. 


그러나, 생각을 해보니 같은 score 는 모두 검색을 하는 문제가 있네요~ 


조금 더 쉽게 설명해보면 a 로 시작하는 단어 중에 score 가 1인 데이터의 갯수가 1만개라고 가정하고, b 로 시작하는 단어중에 score 가 1인 데이터의 갯수가 1만개라고 한다면 


score 만으로 검색을 할경우 우선 2만개를 갖고옵니다. 나머지는 우리가 필터링 해야되는거죠 ~ 


별거 아닐수도 있지만~ 나중에 같은 score 의 갯수가 몇백만개가 된다면 앞서 우리가 전체검색했던 것과 다를바가 없는 상황이

나타날겁니다.... 


경험을 한번 해봤으니 같은 실수는 범하지 말아야 겠죠 


그래서 KEY 설계가 중요합니다. 

> 레디스 및 NOSQL 관련 서적들을 보면 Key 설계가 핵심이다 라는 표현이 자주 등장하는데 백번 맞는말입니다. 


그래서 Key 를 세분화 하기로 합니다. 



저는 이렇게 하기로 했습니다~ (모든건 완벽하지 않습니다. 그러니 참고만 하세요)


전제조건을 생각해 봅시다. A 로 검색어를 입력하면 A로 시작하는 단어가 전부 검색되어야 합니다. 

AB를 입력해도 일단 A 로 시작하니 A > AB 를 포함하고 있을겁니다. 


그러니 Key 에 요청된 word 의 최초글자를(첫글자) 넣기로 설계합니다.


자 그렇다면~ Key 설계는 끝났고요 프로세서를 볼까요?


> 검색 요청

> Key 를 생성합니다. (있는경우 pass)

  (StringRedisTemplate 의 경우 기존키가 있으면 덮어씌웁니다 - XX : Only update elements that already exist. Never add elements. 그러므로 매번 키를 생성하는건 비효율적이지요~)

> Key 에 검색단어를 데이터로 입력 (score 0)

> 종료


> 검색

> Key 를 생성

> 생성된 Key 에서 Score 가 설정한 값을 조회 

> 조회된 데이터 중에서 우리가 원하는 값만 filtering 

> filtering 된 값 반환


이렇게 프로세서가 나뉠 수 있겠네요


우리가 얻을 수 있는 이점은 숫자, 알파벳, 한글까지 해서 최초 한자리만 따지고 봤을때

(특수문자나 공백은 처리하지 않습니다.)


숫자 (0 ~ 9) : 10개

알파벳 : 26개

한글(자음 * 모음 * 받침) :  10,773개 (이론적으로 생성가능 글자 수는 11,172개 입니다)


그렇게 모든 경우의 수를 합치면 10,809개가 나옵니다. 넉넉잡아서 11,000개의 키가 생성되는 겁니다.


11,000개의 키 하위에 가능한 글자수가 생성될 터이니 키 별로 저장되는 Data 의 수가 나뉘게 될겁니다.


즉, 검색의 폭이 줄어들게 될겁니다~


자 그러면 변경된 시나리오로 다시 한번 대량의 데이터를 넣어놓고 테스트를 해봐야겠지요~


거의 동일한 데이터는 넣지 못했습니다. 71080 개의 데이터를 넣었습니다. (A 로 시작하는 단어만)


테스트 조건을 동일하게 체크해야 되나, 어차피 최악의 경우만 테스트 할 예정이라 한 단어로만 많은 값을 넣었습니다.


위 시나리오대로 그대로 진행했으며, 결과는 Best Case 및 Worst Case 로 나뉩니다.




먼저 Best Case 부터 볼까요?


요청 문자열 : a

소요시간 : 123ms



다음은 Worst Case 입니다.


요청 문자열 : aake

소요시간 : 33791ms



소요시간만 보더라도 문제가 있는 설계라는 것을 바로 알 수 있습니다. 


왜 이런문제가 발생했을까요?? 키 설계를 변경했는데도 말이죠~



일단은 문제부터 살펴보도록 합니다. 그래야 해결할 수 있으니까 말이죠~~


내가 원하는 값을 얻기위해 사용한 명령어는 다음과 같습니다. 


Set<TypedTuple<String>> result = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, min, max);


해당 명령어는 위에 설명했다 시피 score 로만 필터링을 합니다. 스코어가 높냐? 낮냐만 보겠다는거죠


그렇다 보니 A 로 시작하는 모든단어는 갖고있지만 Score 로만 갖고올 수 있다는겁니다. 


위에서 제가 테스트를 하기 위해 A 로 시작하는 더미값을 71080개를 넣었습니다. 


Best case 에서 a 로 검색할때는 a 로 시작하는 것들 중에서 Score 가 높은 순서로 갖고왔으니 속도가 당연히 빨랐겠지요~


Worst case 에서는 a 로 시작하지만 뒤에 여러개의 단어가 있기 때문에 내가 원하는 prefix 에 맞추기 위해 검색범위가 더 넓어진 경우입니다. 진짜 최악의 경우는 모든 검색 후 못찾은 경우인데 그 경우에는 대략 40초 정도 소요됐습니다.


사실 저는 여기서 멘붕이 왔었습니다. 


아니 왜!!! 레디스는 Score 검색과 Lex 검색을 섞어놓지 않은것인가??


하지만 여기서 멈출 수 없지요 ~





다음 포스팅에서는 실제로 저희가 적용할 소스(github) 와 테스트코드 및 테스트 결과에 대한내용을 올립니다.










이번 어플에 추천단어 검색기능이 들어갑니다...


그렇습니다 만만치 않군요 


Mysql 을 쓸까? 라는 생각은 일찌감치 접어뒀습니다. 


대신에 redis 를 사용하기로 하면서 재밌는 포스팅을 발견했습니다.


http://oldblog.antirez.com/post/autocomplete-with-redis.html


해당 포스팅에서 힌트를 얻어 작성을 해보기로 합니다.



< Precondition >

1. 한글 및 영어 및 숫자 자동완성이 가능해야 함. (그 외 문자는 생각안하기로 함)

2. 사용자가 검색한 검색어가 저장되어야 함

3. 한글 자음 자동완성은 제외(e.g ㄱ 을 입력하면  가 , 가족, 가수.... 등등)

4. 혹시 모르니 페이징도 가능해야함 (현 버전에서는 불가능 ㅠㅠ)

5. 우선수위 처리가 되어야함...(사용자들이 많이 검색한 단어가 우선순위로 노출)

6. 단어만 노출되는게 아니라 얼마나 조회되었는지 카운트도 표시되어야 함.... ㅎㄷㄷ


뭐 기획팀에서 나온게 이 정도 입니다... 


가장 문제는 역시 사용자가 검색한 것도 저장되어야 하니 얼마나 많은 단어가 저장될지는 모르는거고 


1억건이 될 수도 있는거고.... 성능이슈가 있으면 안된다는건데.... 



일단 레디스를 사용하기로 했으니 믿고 작성해 봅니다.



# 단어 자르기


가장 우선적으로 한 것은 단어 자르기 입니다. 


예를들어서 사람, 사랑, 사과 등등이 있다고 했을때 


사용자가 "사" 를 입력하게되면 사과 , 사람, 사랑 이 자동완성으로 표시되어야 하죠 


그래서 단어를 자르기 시작합니다. 


코드부터 볼까요??


	public void addWord(final String word) {
		stringRedisTemplate.opsForZSet().add(AUTO_COMPLETE_NAMESPACE, word + "*", 0);
		for (int index = 1; index < word.length(); index++) {
		   stringRedisTemplate.opsForZSet().add(AUTO_COMPLETE_NAMESPACE, word.substring(0, index - 1), 0);
		}
	}

먼저 사용자가 입력한 단어를 저장합니다. 있는 그대로 저장하면서, Wild card 를 하나 넣어줍니다. (*)
그 이유는 실제로 사용자가 입력한 단어를 찾아내기 위함입니다.
두번째로 각 글자를 한글자씩 잘라내어 저장합니다. 
또한, 그냥 입력이 아닙니다. zadd 라는방식을 사용했습니다.(정렬된 셋 방식)

참조 : http://redis.io/commands/zadd




왜 이렇게 하냐면 코드를 쓰기 전 설명처럼 해당 단어의 첫 글자만 입력해도 자동완성 되기 위해서입니다.



# 추천단어 검색


두번째로 할 일은 추천단어 검색입니다.


레디스에 적절하게 값을 입력을 했으니 적절하게 값을 가져오기만 하면 끝나는거죠~


코드를 볼까요??


public List complete(final String prefix, final int count) {
	List results = new ArrayList();
	int prefixLength = prefix.length();
	if (null == prefix || prefixLength == 0) return results;
	
	long start = stringRedisTemplate.opsForZSet().rank(AUTO_COMPLETE_NAMESPACE, prefix);
	if (start < 0) return results;
	

	
	Set> rangeResultsWithScore = stringRedisTemplate.opsForZSet().rangeWithScores(AUTO_COMPLETE_NAMESPACE, start, -1);
	if (rangeResultsWithScore.isEmpty()) return results;

	for (TypedTuple typedTuple : rangeResultsWithScore) {
		String value = typedTuple.getValue();		
		int minLength = Math.min(value.length(), prefixLength);
		if (value.endsWith("*") && value.startsWith(prefix.substring(0, minLength))) {
			results.add(new HashVO(value.replace("*", ""), typedTuple.getScore().intValue()));
		} 
	}
	return results;
}

뭐 나쁘지 않습니다.


HashVO 는 value 와 score 로 이루어진 VO 객체니까 신경안쓰셔도 됩니다~


실제 잘 나오는지 볼까요??




a 로 시작하는걸로 검색을 해봤더니 잘 나옵니다. 


대략 잘라진 단어까지 2만단어가 좀 안되게 있을텐데 속도가 그럭저럭 잘 나오는것 같습니다.



그러나 위 코드에는 심각한 문제가 하나 있습니다. 바로 시간복잡도입니다. 


그렇습니다 사실 평범한 개발자들은 시간복잡도?? 저도 잘 생각 안하고 짭니다... 


바빠 죽겠는데 일정맞추려면 구현이 우선이다... (아주 잘못된 선택이죠... 시스템이 바뀌어야 되는데 ㅠㅠ)


시간복잡도로 말할것 같으면... 빅오표기법으로 나타내며... 그 뭐랄까...





먼저, 해당 함수 내에 있는 반복문의 경우 O(n) 으로 처리되는데, 이 경우는 어쩔 수 없는경우이므로 생략하고요


일단 다른건 생각하지 말고 레디스 쪽만 생각해 봅시다 


위에서 사용한 명령어는 2가지로 나타나는데요, 


먼저 ZRANK 를 볼까요? (참조 : http://redis.io/commands/zrank)


ZRANK 의 경우 시간복잡도는 O(log(N)) 입니다. 제가 알기론... redis 는 정렬된 set 방식의 경우 이진탐색을 합니다

(직접 찾아보세요 ㅎㄷㄷㄷ )


O(log(N)) 의 경우 전체를 뒤지지 않습니다. 그러므로 데이터 양이 늘어나는 만큼 검색 시간도 소요되지만 일정 수준이 되면 

평행한 곡선을 이룹니다. 


자세한 내용은 이쪽을 보세요 (참조 : http://bigocheatsheet.com/)


일단 ZRANK 의 경우 뭐 별로 볼게 없네요 코드상에 문제가 전혀 없습니다. 검색 시간도 짧습니다. 왜냐면 index 번호만 갖고오기 때문이죠~


두번째로 ZRANGEBYSCORE 를 볼까요?? (참조 : http://redis.io/commands/zrangebyscore)


ZRANGEBYSCORE 의 경우 시간복잡도는 O(log(N)+M) 입니다. 뭐여 M 은.. 일단 딱 봐도 M 이 늘어나는 만큼 수행시간이 길어질게 뻔해보입니다.


그렇다면 M 이 무엇이냐?? 


reference 문서에서 보여주듯 M the number of elements being returned 입니다. 


기본적으로 redis 는 정렬된 셋을 돌려줄때 Limit 이 걸립니다. 그래서 항상 최초 indexing 부터 10개를 보여줍니다. 


그러나 위의 코드상에서 보면 a 로 시작하는 모든걸 반환하므로 시간이 곱절로 늘어날 겁니다.. 


a 로 시작하는 단어가 만약에 천만개라면?? 수행시간은 상상할 수도 없겠죠 


그래서 레디스는 M 값을 최소한으로 줄일 것을 권장합니다. 



여러분들은 초고수이기 때문에 아이디어가 샘솟으시겠지만 저는 아니므로 ㅋㅋ 


첫번째 시도는 시작점과 끝지점을 정하도록 합니다. 


예를들어서 인덱싱이 이렇게 있다고 생각해 봅시당



 INDEX

VALUE 

 0

 사람

 1

 사랑해

 2

 사귀자

 3

 사장나오셈


사용자가 "사" 라는 글자를 입력했습니다. 그러면 위의 모든 글자가 나타나야 정상이지요~


그런데 예를들어서 시작지점과 끝 지점을 0 ~ 2 라고 정하면 "사장나오셈" 글자는 사용자에게 반환되지 않습니다.


일단 최대반환갯수는 알아낼 수 있습니다. 그러나 나타나야 될 글자가 반환안되는 문제가 있네요??


좋은아이디어 일 줄 알았는데 역시 저는 초보입니다... 사수한테 혼나야 정신차리지?? 



두번째 시도는 score 를 최대로 활용해 봅니다. 


앞서 설명한 적은 없지만 지금 코드는 스프링 부트에서 사용되고 있습니다. 


stringRedisTemplate 라는 것을 이용해서 작업을 하고 있는데 여기에는 여러가지의 명령어들이 있습니다.


먼저 우리는 정렬된 셋 명령어를 사용할 예정이니 opsForZSet 기능을 사용합니다.


range 명령어에는 크게 4가지의 명령어가 있습니다. 


첫번째로 range(key, start, end) 기능을 살펴볼까요??


별거 없습니다. 첫번째 아이디어인 index 의 범위를 정하는 것 입니다. 


두번째로 rangeWithScores(key, start, end) 입니다. 


첫번째 소개드린 것과 같은 기능이지만 score 정보가 담긴 Tuple 을 제공해 줍니다.


세번째로 rangeByScore(key, min, max) , rangeByScoreWithScores(key, min, max) 기능을 살펴볼까요?


여기서 min , max 는 score 의 점수를 나타냅니다. 예를들어서 min = 0 , max = 5 라고 한다면


score 가 0 ~ 5 범위의 값들만 추출됩니다. 


두개의 다른점은 rangeByScore 는 value 만 반환되고, rangeByScoreWithScores 는 Tuple 이라고 해서 해당 value 의 score 까지 알 수 있습니다.


네번째로 rangeByScore(key, min, max, offset, count) , rangeByScoreWithScores(key, min, max, offset, count) 입니다.


아주 흥미롭게도 offset 과 count 가 존재합니다. 저게 과연 무엇일까요?? 


맞습니다 여러분이 생각하시는 Limit 입니다. 저것을 이용해서 paging 기능을 이용할 수 있습니다. 


첫번째와 두번째 방법에는 한계가 존재합니다 그러니 제외하고 세번째와 네번째 방법중에 선택을 해야 합니다.


일단 저는 3번째 방법을 선택하기로 합니다. 사실 4번째 기능을 이용해 페이징을 할 수 도 있지만 전제조건이 좀 필요합니다. 



** 반드시 기억하세요

rangeByScore 로 시작하는 명령어들은 해당 score 범위안의 모든 값을 전달합니다. 


다시 그 표를 볼까요?? 


 INDEX

VALUE 

Score 

 0

 사람

 1

 1

 사랑해

 1

 2

 사귀자

 3

 3

 사장나오셈

 2


이번에는 Score 까지 표현해봤습니다. 


예를들어서 rangeByScore(key, 1, 2, 0, 2) 를 사용했다고 한다면 Score 가 1 ~ 2 사이의 모든 값을 갖고오되 


2개만 갖고오길 원합니다. 그러면 사용자가 "사" 를 입력했을 경우 


사람, 사랑해만 나타나게 될겁니다. 사장나오셈은 안나온다는 얘깁니다. 


이게 무엇이 문제냐면 사용자가 "사장" 이라고 검색을 할 경우 갖고오는 게시물이 사람, 사랑해 이므로 


사장나오셈은 절대로 첫 페이지에 못가져옵니다. offset 과 count 를 2, 4로 설정해 줘야 갖고온다는 말이 됩니다. 


그러니 페이징은 신중하게 사용하세요~


------------------------------------------------------------------------------------------------



다시 돌아가서 3번째 방법에 대한 이야기를 해볼까요?


우리는 정해야 하는 규칙이 생깁니다. 바로 Score 에 대한 이야기죠~


검색 범위를 정하는 매우 중요한 요소입니다. 


서비스 초반에는 사용자들이 많이 없을테지만 사용자가 늘어난다면 기준이 되는 Score 값이 변화해야 겠죠 


일단 우리는 3이라고 기준범위를 정해놓아볼까요? 


3 미만의 정렬된 셋 데이터는 갖고오지 않습니다. 일단 범위를 줄였다는 얘기는 성능이 좋아짐을 뜻합니다. 


해당 값은 기획팀 및 운영에서 수시로 값을 변경할 수 있으니 객체지향적으로 변화에 유연하게 코드를 짜야합니다.


자꾸 이야기가 새어나가네요 


바로 코드를 볼까요? 이렇게 score 범위를 3으로 지정해 버렸으므로 3이하의 모든 정렬된 셋은 검색에서 제외됩니다.

stringRedisTemplate.opsForZSet().rangeByScoreWithScores(AUTO_COMPLETE_NAMESPACE, 3, 3);



스코어가 3인 값만 딱 나왔네요 ㅎㅎ 흐뭇


범위를 높게 잡으면 높게잡을수록 성능은 좋아질겁니다. 하지만 트레이드 오프로 사용자에게 보여질 자동완성 문자는 줄어들 수 있겠죠 ~ 이것은 실제로 개발자 및 기획자가 정해야 하는 값이니 꼭 참고하시기 바랍니다.



글이 굉장히 길어지고 있습니다. 


저도 당황스럽습니다 이게 아닌데요 


일단 참고 주소를 몇개 드립니다.


http://redis.io/topics/indexes

http://autocomplete.redis.io/


다음번에는 샘플소스인 github 페이지를 공개하도록 하겠습니다. 


또한, 이번에 다루지 않았던 것들을 다룰겁니다. 예를들어서 score 값을 늘리고 줄이는 법등요 


그리고 쓸모없는 값들이 생기지 않을까? 라는 것에 대한 고민도 좀 해볼생각입니다.


어떤거냐구요?? 예를들어서 score 가 1~2개 정도인 값들이 넘쳐날 경우에 기준이 3이게 되면 


이런 값들은 절대 사용자에게 나타나지 않을겁니다, 그리고 기준값이 더 높아지게 되면 노출빈도가 더 낮아지겠지요 


어쩌면 그런생각을 할 지도 모릅니다.. 이거 쓰레기값이자나...?? 메모리만 차지하는데 없애버려?


뭐 어쨋듯 여러가지 생각이 나올 수 있겠지요 또한, 여러가지 아이디어도 반영될 수 있을거고요



개인적인 바램은 해당 구현체를 github 에서 가꿔나가볼까 합니다. 


많은사람들이 참여해서 좀 더 깔끔하게 소스를 다듬고 한다면 어쩌면 새로운 API 가 탄생할 수도 있겠네요












  1. 몽구스 2018.02.07 09:26 신고

    안녕하세요! 레디스로 자동완성을 만들어 보려는 학생입니다! 포스팅 덕분에 큰 도움이 되었어요 감사합니다 ^_^
    하나 여쭤볼 것이 있는데, 레디스를 보니 중복되는 값의 score를 올리는 명령어는 없더라고요, 혹시 일단 레디스에 멤버가 있는지 조회하여 있으면 score를 +1 하고 아니면 score와 멤버를 추가하는 식으로 하신건가요?

    • 몽구스 2018.02.07 09:30 신고

      아, 방금 확인해 봤는데 입력시
      ZADD가 아닌
      ZINCRBY를 이용하면 upsert(update+insert) 같은 기능을 할 수 있네요 !! 혹시 이렇게 하셨나요? 제가 스프링을 해보지 않아서 여쭤봅니다 !! ^-^

    • OKIHOUSE 2018.02.07 10:15 신고

      넵 현재 코드에서는 opsForZSet().incrementScore(value) 함수를 사용하고 있습니다~ (ZINCRBY)

      https://github.com/okihouse/spring-boot-redis-auto-complete

    • 몽구스 2018.02.07 13:11 신고

      답변 정말 감사드려요!!!! 날이 많이 추운데 따뜻하고 행복한 하루 보내셔요 ^-^!



    • 몽구스 2018.02.07 13:24 신고

      질문 하나만 더 드리겠습니다 !!

      먼저 사용자가 입력한 단어를 저장합니다. 있는 그대로 저장하면서, Wild card 를 하나 넣어줍니다. (*)
      그 이유는 실제로 사용자가 입력한 단어를 찾아내기 위함입니다.

      이 부분에서 와일드카드를 넣는 이유가 실제로 사용자가 입력한 단어를 찾아내기 위함이라고 하셨는데, 왜 실제로 사용자가 입력한 단어를 구분해서 저장해야 하는지 이유를 알 수 있을까요?

    • OKIHOUSE 2018.02.07 13:47 신고

      레디스에 직접 해당 구문을 이용하여 값을 넣어보시면 아시겠지만 다음과 같이 값이 들어갈겁니다.

      입력 단어 : 선생님
      레디스 저장 단어:
      선생님*

      선생
      선생님

      이렇게 총 4개의 단어로 저장됩니다.(스코어는 이해를 위해 제외되었습니다)

      사용자에게 보여지는 자동완성 단어는
      선생님* 로 보여지고, 마지막 *는
      view 에서는 삭제되어 보여집니다.

      위와같이 하는 가장 큰 이유는 바로 prefix 검색 기능때문입니다.
      "선" 이라는 단어만 입력해도 "선생님"을 찾기 위함이죠,
      사실 단어 형태소 검색을 구현하기 힘든 구조라 위와 같은 형태만 지원하게 되었고,
      그렇기 때문에 위와 같이 값이 들어갑니다.

      저는 일부 설명만 드린 것이고 핵심내용은 다음 사이트를 참조해보세요~
      http://oldblog.antirez.com/post/autocomplete-with-redis.html

    • 몽구스 2018.02.07 14:51 신고

      정말정말 감사합니다 !! 예까지 들어주시고!!! 감사드려요 ^-^

    • OKIHOUSE 2018.02.07 16:18 신고

      질문글에 중요한 점을 말씀을 안드렸네요~

      왜 실제로 사용자가 입력한 단어를 구분해서 저장해야 하는지 이유를 알 수 있을까요?

      해당 질문에 대한 답변에서 제가 빼먹은 부분이 있네요

      입력 단어 : 선생님
      레디스 저장 단어:
      선생님*

      선생
      선생님

      이 부분에서
      "선생님*" 과 "선생님" 이렇게 2개를 굳이 입력해야 하는 이유를 말씀 안드렸어요~

      그 이유는
      "선생님*" 부분에만 스코어 점수가 입력됩니다
      다른 부분들은 단순히 검색을 위한 부분입니다.

      와일드카드가 포함된 단어가 입력되는 이유입니다.

      이해가 되셨으면 좋겠네요~

      참고로 모든 글을 보시면 더 이해가 쉬우실거에요~

      http://okihouse.tistory.com/search/auto%20complete

    • 몽구스 2018.02.08 12:47 신고

      아.. 글이 더 있었군요 ㅠ.ㅠ 몰랐습니다 감사드려요 !! ^^

현재 서비스하고 있는 (본 업무는 아니지만 ㅎㄷㄷ)


어플 backend tomcat server 에 다음과 같은 로그들이 대량 발생했다



15-Sep-2015 03:47:53.778 WARNING [http-nio-8080-exec-4] org.apache.catalina.realm.LockOutRealm.authenticate An att
empt was made to authenticate the locked user "manager"
15-Sep-2015 03:47:54.225 WARNING [http-nio-8080-exec-6] org.apache.catalina.realm.LockOutRealm.authenticate An att
empt was made to authenticate the locked user "manager"
15-Sep-2015 03:47:54.658 WARNING [http-nio-8080-exec-8] org.apache.catalina.realm.LockOutRealm.authenticate An att
empt was made to authenticate the locked user "manager"


실제 서버 (Product) 에서는 Tomcat manager 기능을 사용하지 않지만 그래도 좀 찝찝하다 


그래서 처리를 해보기로 했다~


톰캣은 위와 관련해서 제공하는 Class 가 존재한다. (아래주소 확인)

https://tomcat.apache.org/tomcat-8.0-doc/api/org/apache/catalina/realm/LockOutRealm.html


메소드가 여러개 있지만 간단하게 보고 지나가면 대충 뭐하는건지는 이름만 봐도 알 수 있다.



그래도 이해가 안되는분들을 위해서 Tomcat 은 자세한 설명도 적어놓았다. (아래주소확인)

https://tomcat.apache.org/tomcat-8.0-doc/realm-howto.html#LockOutRealm



**간단하게 정리하자면 

누군가가 불법적으로 접근을 시도할 경우 실패 Count 를 체크하여 접근불가 상태로 만든다는 것



실패 Count 를 체크하기 위해서는 뭔가 저장소가 필요하기 때문에 실제 소스를 들여다 보면 

http://svn.apache.org/repos/asf/tomcat/tc8.0.x/trunk/java/org/apache/catalina/realm/LockOutRealm.java


     /**
     * Users whose last authentication attempt failed. Entries will be ordered
     * in access order from least recent to most recent.
     */
    protected Map<String,LockRecord> failedUsers = null;


Map 에다가 실패한 사용자 정보를 담는것을 볼 수 있다.. 


사실 좀 더 살펴보면 저장소는 여러가지 방법들이 존재하게되는데 아래 링크에서 확인할 수 있다.. 

https://tomcat.apache.org/tomcat-8.0-doc/realm-howto.html#Standard_Realm_Implementations



뭐뭐... 다 쓸데없는 소리같고 나는 빨리 어서 적용시키고 싶다 하시는분들을 위해


<Realm className="org.apache.catalina.realm.LockOutRealm" failureCount="1" lockOutTime="86400" cacheRemovalWarningTime="3600"> 


대충 위 처럼 server.xml 에다가 적용하면 된다.. 


옵션을 그래도 알고 써야되는데 하나하나 적어보면

failureCount = 몇번 실패하면 접근불가 상태로 만들것인가?

lockOutTime = 접근불가로 유지시키는 시간(초)

cacheSize = 이건 간단하게 얼마나 많은 사용자를(접근 수) 저장할 것인가 라는 개념(default 1000)

cacheRemovalWarningTime = 캐쉬값을 무한정 가져갈 수 없기때문에 cacheSize 가 넘어서게되면 기존값을 삭제하게 되는데, 삭제할 때 가장 오래된 실패정보의 시간이 설정한 값(초) 보다 작으면 경고메시지를 나타낸다..

내가 쓰고도 어리둥절 ㅋㅋㅋ

간단하게 쓰자면 캐쉬값을 삭제할건데 삭제되는 값이 설정된 시간보다 오래되지 않았으면 경고메시지 나타낸다는 소리


짜증난다 ㅋㅋ 이해가 잘 안되시죠

예를들어서 1~1000번까지 쌓이자나요 그러면 1001번이 들어올때 1번을 삭제하거든요..

근데 그 1번이 들어온 시간이 오늘 오후 1시라고 칩시다 근데 1001번이 들어온 시간이 오후 1시 10분이에요

10분만에 1000개 넘게 쌓인거자나요

근데 만약에내가 저 값을 (cacheRemovalWarningTime) 60초로 설정해놨다 그러면 경고메시지 안뜨는거고요 

3600초(1시간) 으로 설정해놨다 고 하면 1번값이 삭제될때 경고메시지가 뜬다는 얘깁니다 하아........ 힘들 ㅎㄷㄷ



하여튼 이 구문을 넣으면 끝난다는 얘깁니다...





'Server' 카테고리의 다른 글

Tomcat LockOutRealm 설정  (0) 2015.09.16

+ Recent posts