인프런에서 querydsl에 관한 강의를 듣고 토이 프로젝트에 적용해보고 싶은 생각이 들었다. 그래서 한 번 해보기로 함~~!!

 

 

먼저 gradle 설정

plugins {
    ...
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    ...
}
...
dependencies {
    ...
    implementation 'com.querydsl:querydsl-jpa'
    ...
}
...
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

 

 

 

 

queryDSL을 기존의 spring data JPA repository를 사용하던 곳에 적용해 보려고 했다.

사용자 정의 Repository

복잡한 기능을 추가하거나, 사용자 정의 함수를 추가할 때 사용한다.

Spring data JPA는 인터페이스로 동작하기 때문에, 내가 원하는 코드를 넣으려면 사용자 정의 repository라는 것을 구현해야 한다.

출처: https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84

 

사용자 정의 repository의 사용법은 아래 단계들과 같다.

1. 사용자 정의 인터페이스 작성

2. 사용자 정의 인터페이스 구현

3. 스프링 데이터 레포지토리에 사용자 정의 인터페이스 상속

 

여기서 주의할 점은 기존에 있던 JpaRepository를 상속하는 repository 인터페이스와 같은 이름의 Impl을 구현해야 한다는 점이다.

 

이 방법 외에도 특정한 기능인 경우에는 따로 repository를 만들 수도 있다.

현재 구현하는 애플리케이션의 경우, 게시판에만 search & filter 기능을 사용하기 때문에 후자의 방법을 이용하여 구현해 보기로 했다.

 

 

 

기존 코드: stream filter, map, collect 등의 기능을 이용했었음

1. 모든 게시판 가져오면서 숨김처리한 게시물 필터링

2. 조건에 맞추어 필터링

3. 조건에 맞추어 정렬

(정렬 조건 중 까다로운 점은 현재까지 모임 신청이 approved된 인원을 뺀 remain 에 대한 정렬을 구현하는 것이었다)

여기서 나는 같은 stream을 여러번 읽어서 필터링 하고, 이 필터링 된 코드를 또 stream으로 바꾸어 sorting하는 등이 문제라고 생각했다.

private List<Board> sortBoardList(SortingType sorting, List<Address> addresses, List<Category> categories, User user) {
	List<Board> boards = filterBoardList(addresses, categories, user);

	if (sorting == SortingType.REMAIN) {
		return boards.stream()
				.sorted(Comparator.comparing(Board::getRemainRecruitNumber, Comparator.reverseOrder()))
				.collect(Collectors.toList());
	} else if (sorting == SortingType.DEADLINE) {
		return boards.stream()
				.sorted(Comparator.comparing(Board::getStartsAt, Comparator.naturalOrder()))
				.collect(Collectors.toList());
	}
	return boards.stream()
			.sorted(Comparator.comparing(BaseEntity::getCreatedAt, Comparator.reverseOrder()))
			.collect(Collectors.toList());
}

private List<Board> filterBoardList(List<Address> addresses, List<Category> categories, User user) {
	return findAllBoards(user).stream()
			.filter(board -> addresses.contains(board.getAddress()))
			.filter(board -> categories.contains(board.getCategory()))
			.collect(Collectors.toList());
}

private List<Board> findAllBoards(User user) {
	Set<Board> hiddenBoards = user.getUserHiddenBoard().stream()
			.map(HiddenBoard::getBoard).collect(Collectors.toSet());

	return boardRepository.findAll().stream()
			.filter(board -> board.getStatus().getCode() != BoardStatus.CANCELED.getCode())
			.filter(board -> !hiddenBoards.contains(board))
			.collect(Collectors.toList());
}

이러고 나서 마지막에 페이징 처리를 했음...

 

 

바뀐 코드: 

queryDSL로 바꾸기 위해서 일단 필터링 조건 들을 나누었다.

BooleanBuilder를 사용하면 훨씬 쉽게 할 수 있었지만,

where절의 조건으로 쓰면 가독성이 좋은 BooleanExpression으로 구현하고 싶었다.

BooleanExpression을 사용할 때 주의할 점은 null 에 대한 부분을 생각해 주어야 한다는 점이다.

private BooleanExpression isFilteredCategories(List<Long> categories) {
	return categories != null ? Expressions.anyOf(categories.stream().map(this::isFilteredCategory).toArray(BooleanExpression[]::new)) : null;
}

private BooleanExpression isFilteredCategory(Long categoryId) {
	return board.category.id.eq(categoryId);
}

private BooleanExpression isFilteredCities(List<Long> cities) {
	return cities != null ? Expressions.anyOf(cities.stream().map(this::isFilteredCity).toArray(BooleanExpression[]::new)) : null;
}

private BooleanExpression isFilteredCity(Long cityId) {
	return board.address.id.eq(cityId);
}

private BooleanExpression isSearchedKeywords(List<String> keywords) {
	return Expressions.allOf(keywords.stream().map(this::isSearchedKeyword).toArray(BooleanExpression[]::new));
}

private BooleanExpression isSearchedKeyword(String keyword) {
	return board.content.containsIgnoreCase(keyword);
}

private BooleanExpression isDeletedBoard() {
	return board.status.ne(BoardStatus.CANCELED);
}

BooleanExpression을 쓰면서 어려웠던 점은, 단일 파라미터에 대한 예제는 많이 나와있었지만,

여러가지 필터링을 하기 위해 List 값에 대한 BooleanExpression을 만드는 것들은 잘 나오지 않아 시간을 좀 많이 썼다...

 

 

그 다음은 이 조건들을 적용하기 전에 먼저 querydsl로 짤 쿼리문을 직접 짜 보았다.

SELECT HIDDEN_FILTERED.*, COALESCE(APPROVED_USER.approved_number, 0) as approved_number, (HIDDEN_FILTERED.recruit_count - COALESCE(APPROVED_USER.approved_number, 0)) as remain_number
FROM (SELECT b.* FROM board as b left join hidden_board as hb on (b.id = hb.board_id and hb.user_id=1) where hb.user_id is null) as HIDDEN_FILTERED
left join (select board_id, count(user_id) as approved_number from applied_user where status='APPROVED' group by board_id) as APPROVED_USER
on HIDDEN_FILTERED.id = APPROVED_USER.board_id
order by approved_number DESC;

첫번째 시도는 위와 같이 mysql에서 직접 쿼리문을 작성할 경우 결과값이 아주아주~~ 잘 나왔다.

 

 

하지만, querydsl에서는 서브쿼리를 from문에서 사용하지 못한다는것을 몰랐다... 그래서 아래와 같이 left join을 3번 해보기로 했다.

SELECT b.*, (b.recruit_count - COALESCE(count(au.user_id), 0)) as remain_count
FROM (board as b left join hidden_board as hb on b.id = hb.board_id and hb.user_id=1) left join applied_user as au on b.id = au.board_id and au.status = 'APPROVED'
where hb.user_id is null
group by b.id
order by remain_count DESC;

 

 

 

쿼리 테스트를 해 본 후, querydsl에 해당 쿼리문을 적용해 보았다.

여기서 어려웠던 점은, 정렬 조건 중 remain 인원에 대한 것이었다.

모집 인원에 대한 인원 수는 db 필드로 있었으나, remain 인원에 대한 것은 applied user의 상태를 보고 세어야 했다.

이것은 자바 함수를 쓰면 매우 간단해서 이전 방법을 사용할 때는 깨닫지 못했지만, querydsl로 바꾼 후 시간이 많이 걸리게 된 원인이다ㅠ

 

인프런에 관련 함수를 쓸 수 없을지 질문을 올렸는데, 아래는 그 답변이었다.

---

querydsl의 결과는 결국 JPQL이 만들어지고, JPQL은 또 SQL로 번역되기 때문에 SQL로 할 수 있는 로직만 실행할 수 있습니다.

해당 코드는 자바 함수이기 때문에 JPQL이나 SQL에서 사용하는 것이 불가능합니다.

대신에 JPQL이나 SQL에서 다음과 같은 방식은 사용할 수 있습니다.

order by item.count2 - item.count1 desc

querydsl에서 사용하려면 다음 코드를 참고해주세요.

---

 

그래서 결국 applied user를 직접 구해서 뺀 값에 대한 정렬을 하도록 바꾸었다.

public List<Board> filter(BoardFilterCondition boardFilterCondition, Pageable pageable) {
	return jpaQueryFactory
			.select(board)
			.from(board)
			.leftJoin(hiddenBoard).on(board.id.eq(hiddenBoard.board.id).and(hiddenBoard.user.id.eq(boardFilterCondition.getUserId())))
			.leftJoin(appliedUser).on(board.id.eq(appliedUser.board.id).and(appliedUser.status.eq(AppliedStatus.APPROVED)))
			.where(
					hiddenBoard.user.id.isNull(),
					isFilteredCategories(boardFilterCondition.getCategory()),
					isFilteredCities(boardFilterCondition.getCity()),
					isDeletedBoard()
			)
			.groupBy(board.id)
			.orderBy(orderType(boardFilterCondition.getSorting()))
			.offset(pageable.getOffset())
			.limit(pageable.getPageSize())
			.fetch();
}

private OrderSpecifier<?> orderType(SortingType sortingType) {
	if(sortingType == SortingType.REMAIN){
		return board.recruitCount.subtract(appliedUser.user.id.count().coalesce(0L)).desc();
	}
	if (sortingType == SortingType.DEADLINE) {
		return board.startsAt.asc();
	}
	return board.createdAt.desc();
}

여러번 stream으로 필터링을 해야 했던 코드가 비교적 가독성 있어졌다...!! 그리고 페이징 처리도 repository 내에서 다 할 수 있었다.

 

 

 

마찬가지로 검색 쿼리도 구현해 보았다. 검색 쿼리는 필터링 보다 정렬도 까다롭지 않아 더 쉬운 편이었다.

public List<Board> search(BoardSearchCondition boardSearchCondition, Pageable pageable) {
	return jpaQueryFactory.select(board)
			.from(board).leftJoin(hiddenBoard)
			.on(board.id.eq(hiddenBoard.board.id).and(hiddenBoard.user.id.eq(boardSearchCondition.getUserId())))
			.where(
					hiddenBoard.user.id.isNull(),
					isSearchedKeywords(boardSearchCondition.getKeywords()),
					isDeletedBoard()
			)
			.orderBy(board.createdAt.desc()) // 최신순 정렬
			.offset(pageable.getOffset())
			.limit(pageable.getPageSize())
			.fetch();
}

 

 

 

느낀점

인강만 듣고 쉬운 예제들을 봤을 땐 금방 적용하겠지~ 하고 생각했는데,

실제 구현할 부분에 적용하려고 하니 생각보다 어려웠다. 그리고 가장 중요한 것은 sql 자체를 짜는 능력이라고 생각했다.

sql 자체를 제대로 짜면 그 것을 querydsl로 옮기는 것은 쉬운 일이라고 느꼈다. 성능은 또 다른 문제지만...ㅜㅠ

 

구현한 repository 전체 코드는 아래와 같다.

github.com/Yapp-17th/Android_2_Backend/blob/develop/common-module/src/main/java/com/yapp/crew/domain/repository/BoardSearchAndFilterRepository.java

 

Yapp-17th/Android_2_Backend

Backend Repository for Android Team 2. Contribute to Yapp-17th/Android_2_Backend development by creating an account on GitHub.

github.com

 

이번 yapp 어플리케이션 백엔드 프로젝트에서는 유저 인증 시 token 기반 인증을 구현하기로 했다.

token기반의 인증은 stateless api를 활용할 수 있다는 장점이 있다.

또한, 토큰이 탈취된다 하더라도, 유저의 기본 정보가 노출되지 않는다는 점이 장점이라고 할 수 있다.

뿐만 아니라, 프론트가 안드로이드 애플리케이션인데, 모바일 애플리케이션에서 jwt 기반 인증을 사용하는 이유는 다음과 같다.

모바일 트렌드 중 하나는 로그아웃을 하지 않는 이상 로그인을 유지하는 것입니다.
일반적으로는 손쉽게 Session을 이용해서 클라이언트와 서버 통신 중 Stateless의 단점을 보완할 수 있었지만, 모바일의 특성상 자주 끊길 소지가 있습니다.

세션과 비슷한 역할을 하되, 계속해서 유지될 수 있는 기술을 찾다 보니 Token을 이용한 방식이 있었고, 그중 JWT를 사용하게 되었습니다.

 

 

암튼 게시판을 구현하다가, 게시판 기능에서 요청 헤더에서 토큰을 가져오고,

이 토큰에 들어있는 user_id를 검증 해야하는 모든 controller에 공통된 로직이 있었다.

 

 

 

Interceptor

Interceptor란 컨트롤러에 들어오는 요청 HttpRequest와 컨트롤러가 응답하는 HttpResponse를 가로채는 역할을 한다.

따라서 게시판 기능으로 들어오는 모든 request를 가로채서 토큰의 검증에 사용해 보려 한다.

 

spring security를 사용해서도 토큰 기반 인증을 구현할 수 있지만, interceptor를 사용해서 가볍게 구현해 보았다.

 

 

 

  • preHandle: 컨트롤러가 호출되기 전에 실행된다.
  • postHandle: 컨트롤러가 실행된 후에 호출된다.
  • afterComplete: 뷰에서 최종 결과가 생성하는 일을 포함한 모든 일이 완료 되었을 때 실행된다.

 

Interceptor를 구현하기 위해서는 먼저 HandlerInterceptorAdapter를 구현한 클래스를 만든다.

@Component
@NoArgsConstructor
public class AuthInterceptor extends HandlerInterceptorAdapter {

	private UserRepository userRepository;
	private Auth auth;

	@Autowired
	public AuthInterceptor(UserRepository userRepository, Auth auth) {
		this.userRepository = userRepository;
		this.auth = auth;
	}

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
		String token = request.getHeader(HttpHeaders.AUTHORIZATION);
		verifyToken(token);

		Long userId = auth.parseUserIdFromToken(token);
		log.info("[Request User ID] " + userId);
		User user = findUserById(userId)
				.orElseThrow(() -> new UserNotFoundException("[INTERCEPTOR Exception] user not found"));

		checkUserStatus(user.getStatus());
		return super.preHandle(request, response, handler);
	}

	private void checkUserStatus(UserStatus userStatus) {
		if (userStatus == UserStatus.INACTIVE) {
			throw new InactiveUserException("inactive user");
		} else if (userStatus == UserStatus.SUSPENDED) {
			throw new SuspendedUserException("suspended user");
		}
	}

	private Optional<User> findUserById(Long userId) {
		return userRepository.findUserById(userId);
	}

	private void verifyToken(String token) {
		auth.verifyToken(token);
	}
}

prehandle에서는 auth 클래스에서 구현한 토큰 검증이 진행된다.

이후, claim에 들어있는 userId를 가져와서 데이터베이스에서 user를 조회 및 user status를 확인한다.

 

 

그 다음으로는 adapter를 애플리케이션에 붙이기 위해서 WebConfiguration에 붙여준다.

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

	private AuthInterceptor authInterceptor;

	@Autowired
	public WebConfiguration(AuthInterceptor authInterceptor) {
		this.authInterceptor = authInterceptor;
	}

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(authInterceptor)
				.addPathPatterns("/**");
	}
}

첨에는 authInterceptor를 autowired 안해줬어서 제대로 실행되지 않았었다ㅠㅠ

 

 

마지막으로 @ControllerAdvice를 사용해서 컨트롤러에서 발생하는 exception을 모두 잡도록 했다.

아마존 리눅스로 인스턴스를 만들어준다.

 

EC2 연결 후, docker 설치

sudo yum install docker 
sudo service docker start
sudo usermod -a -G docker ec2-user // ec2-user에게 권한부여

 

Docker Hub에 있는 Mysql 이미지 띄우기

EC2의 9876 포트와 mysql 기본포트인 3306을 연결해준다.

mysql의 비밀번호를 "password"로 설정해주고, msyql5.6을 (dockerhub 로 부터 다운로드 받아서)실행한다.

docker run -d \
-p 3306:3306 \
--name mysql-container \
-e MYSQL_DATABASE=explanet_dev \
-e MYSQL_ROOT_PASSWORD=password \
mysql:latest

 // EC2 인스턴스나 Docker 컨테이너를 종료시켜도 데이터가 사라지는 것을 방지하기 위해 저장할 폴더 지정

 

Spring Boot에서 빌드

spring boot 터미널에서 base-api 모듈을 클린하고 jar 파일로 만든다

./gradlew :base-api:clean :base-api:bootJar

 

 

 

 

도커파일 빌드

마찬가지로 스프링 부트 터미널에서 libs 폴더에 있는 jar을 로컬 도커 환경에서 빌드한다

아래는 작성한 도커파일이고,

FROM adoptopenjdk/openjdk11:alpine-jre
COPY build/libs/base-api-0.0.1-SNAPSHOT.jar base-api.jar
ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "base-api.jar"]

 

아래 커맨드로 도커 파일을 빌드할 수 있다.

 

Tag는 일종의 Alias 같은 역할을 하는데, 특히 latest의 경우는 사용자가 Pull을 할 때 Tag를 지정하지 않았을 때 사용된다.

docker build --tag=sypark9646/yapp-android2:1.0.0-beta --force-rm=true .

 

아래 커맨드를 사용하면 만들어진 이미지를 볼 수 있다.

docker images

 

도커 허브에 Push

만든 이미지를 도커 허브에 push한다.

docker push sypark9646/yapp-android2

 

 

AWS 터미널에서 spring boot jar 파일 run

aws 터미널에서 아까 Push 했던 이미지를 가져온다

docker pull sypark9646/yapp-android2:1.0.0-beta

스프링 파일을 run 시키기 -d를 쓰면 백그라운드로 돌아가도록 설정할 수 있다.

docker run -d -e "SPRING_PROFILES_ACTIVE=dev" -p 8080:8080 -t sypark9646/yapp-android2:1.0.0-beta

 

 


별개로 전체 삭제 커맨드는 아래와 같다

 

container 전체 삭제

docker stop $(docker ps -a -q)
docker rm $(docker ps -a -q)

이미지 전체 삭제

docker rmi $(docker images -q) 

토큰 기반 인증을 사용하는 이유

1. Stateless 서버

  • Stateful 서버는 클라이언트에게서 요청을 받을 때 마다, 클라이언트의 상태를 계속해서 유지하고, 이 정보를 서비스 제공에 이용한다.
  • ex) 세션을 유지하는 웹서버: 유저가 로그인을 하면, 세션에 로그인이 되었다고 저장을 해 두고, 서비스를 제공 할 때에 그 데이터를 사용한다. 세션은 서버 컴퓨터의 메모리나 데이터베이스에 저장한다.
  • Stateless 서버는 반대로, 상태를 유지 하지 않는다. 상태정보를 저장하지 않으면, 서버는 클라이언트측에서 들어오는 요청만으로만 작업을 처리한다. => 클라이언트와 서버의 연결고리가 없기 때문에 서버의 확장성 (Scalability) 이 높아진다.

2. 모바일 어플리케이션에 적합하다

모바일 어플리케이션에서 안전한 API 를 만들기 위해선 쿠키같은 인증시스템은 이상적이지 않다고 한다. (쿠키 컨테이너를 사용해야 함). 토큰 기반 인증을 도입한다면 쉽게 구현 가능

 

3. 인증정보를 다른 어플리케이션으로 전달

대표적인 예제로는, OAuth 가 있다. 소셜 계정들을 이용하여 다른 웹서비스에서도 로그인 할 수 있게 하는 것이다.

 

4. 보안

토큰 기반 인증 시스템을 사용하여 어플리케이션의 보안을 높일 수 있다. 무조건은 아니지만,,,

 

 

 

JWT란?

jwt.io/introduction/

JWT 는 . 을 구분자로 3가지의 문자열로 되어있다. 구조는 아래와 같다.

 xxxxx.yyyyy.zzzzz 

 

1. 헤더

  • typ: 토큰의 타입을 지정
  • alg: 해싱 알고리즘을 지정. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용된다.
{
  "typ": "JWT",
  "alg": "HS256" // HMAC SHA256 이 해싱 알고리즘으로 사용
}

 

 

2. Payload

토큰에 담을 정보가 들어있다. 여기에 담는 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 name / value 의 한 쌍으로 이뤄져있다. 토큰에는 여러개의 클레임 들을 넣을 수 있다. 클레임은 3가지 종류가 있다.

  • Registered claims: 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기위하여 이름이 이미 정해진 클레임들로, optional
  • Public claims: 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.
  • Private claims: 위의 두 경우가 아닌 모든 경우를 말한다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들로, 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의해야한다.

3. 서명

헤더의 인코딩값과, 정보의 인코딩값을 합친후 주어진 비밀키로 해싱을 해서 생성한다. 그 결과를 base64 형태로 나타낸다. (문자열을 인코딩 하는게 아닌 hex  base64 인코딩을 해야함)

 

 

Spring Security란?

세션-쿠키방식으로 인증, id - password 방식

  • 스프링 기반의 어플리케이션에서 보안을 위해 인증 권한 부여를 사용하여 접근을 제어하는 프레임워크
  • 커스터마이징 가능
  • filter 기반으로 동작하기 때문에 Spring MVC와 분리되어 관리 및 동작
  • 허용되지 않은 페이지에 사용자가 접근할 경우 스프링 시큐리티는 페이지 호출 전에 인증이 되어있는지를 체크하고 페이지에 접근할 수 있는 권한이 있는지 체크 -> 허용 / 차단
  • 크리덴셜 기반 인증 사용

 

 

webfirewood.tistory.com/m/115?category=694472

스프링 시큐리티는 주로 서블릿 필터와 이들로 구성된 필터체인을 사용하고 있다. 아래는 동작 플로우이다.

1. 사용자가 로그인 정보와 함께 인증 요청(Http Request)

2. AuthenticationFilter가 이 요청을 가로챕니다. 이 때 가로챈 정보를 통해 UsernamePasswordAuthenticationToken이라는 인증용 객체를 생성합니다.

3. AuthenticationManager의 구현체인 ProviderManager에게 UsernamePasswordAuthenticationToken 객체를 전달합니다.

5. 실제 데이터베이스에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보(아이디)를 넘겨줍니다.

6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만듭니다. 이 때 UserDetails 는 인증용 객체와 도메인용 객체를 분리하지 않고 인증용 객체에 상속해서 사용하기도 합니다.

7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교합니다.

8. 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환합니다.

9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환됩니다.

10. Authentication 객체를 SecurityContext에 저장합니다.

 

최종적으로 SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다. 세션에 사용자정보를 저장한다는 것은 스프링 시큐리티가 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.

스프링 시큐리티는 기본적으로 약 10개의 스프링 시큐리티 필터가 자동으로 설정되는데, 훨씬 다양한 필터체인을 사용하여 커스터마이징을 할 수 있다.

더보기

spring security는 필터 기반으로 동작하며 spring security에서 실행되는 필터만 20개가 넘는다

<http>태그의 auto-config속성이 true로 설정이 되면 10개 정도의 필터가 체인으로 만들어져서 동작한다.

      - SecurityContextPersistenceFilter
      - LogoutFilter
      - UsernamePasswordAuthenticationFilter
      - DefaultLoginPageGeneratingFilter
      - BasicAuthenticationFilter
      - RequestCacheAwareFilter
      - SecurityContextHolderAwareRequestFilter
      - AnonymousAuthenticationFilter
      - SessionManagementFilter
      - ExceptionTranslationFilter
      - FilterSecurityInterceptor

 

 

서블릿 필터? 필터 체인?

 

필터

컨테이너에 전달된 요청이 서블릿을 호출하기 전에 이 필터를 거치게 된다.

필터에서 서블릿의 service 메소드를 호출할 때 request, response 객체를 인자로 넘겨주게 된다. (servlet의 response 객체는 OuputStreamWriter 객체를 참조하고 있고 servlet 에서는 이 객체를 통해 직접 응답을 클라이언트로 전달)

문제는 필터에서 response 객체에 접근하고 싶을 때 발생한다. service 메소드가 종료된 시점에서 이미 response 가 클라이언트로 전달 된 다음이기 때문에 필터에서는 response 객체에 접근할 수가 없다. 이 때 wrapper 클래스를 사용하여 필터에서 wrapper 클래스를 만들어 진짜 response객체 대신 래퍼객체를 보내면 된다. 그렇게 되면 servlet에서는 실제 outputstream이 아닌 래퍼의 outputstream에 응답을 작성하게 되고 필터에서 이 응답에 접근할 수 있게 된다.

 

 

 

Spring Security oauth2란?

 

 

 

 

 

 

 

 

 

 

momentjin.tistory.com/144

momentjin.tistory.com/146

 

 

'Java (+ Spring)' 카테고리의 다른 글

Mock객체로 테스트 하기: Mockito  (0) 2021.02.10
Java Unit Test: Junit5  (0) 2021.02.10
Spring boot: JPA 설정 방법 및 주의할 점  (1) 2020.07.31
Spring: Spring MVC 설정 하기  (0) 2020.07.26
Android: tutorial page  (0) 2020.07.18

Cache란?

나중에 할 요청 결과를 미리 저장 해 두었다가 빠르게 서비스 해 주는 것

  • Look aside Cache(일반적으로 많이 쓰는 방식)
    • DB를 가기 전에 Cache에 먼저 데이터가 있는지 확인 해본다.
    • 만약 Cache에 데이터가 있다면 캐시 데이터를 읽고, 없다면 DB를 읽은 후 Cache에 저장 & 결과 리턴
  • Write Back(로그를 DB에 저장하는 경우, 쓰기 연산이 많은 경우)
    • 캐시에 먼저 저장했다가, 특정 시점마다 DB에 저장하는 방식
    • ex. 걸리는 시간(배치 작업) - DB insert 쿼리를 1번씩 * 500번 >> insert쿼리 500개 합친 것 * 1번
    • 단점: 장애가 생기면 데이터가 사라질 위험이 있다. 왜냐면 리부팅 되면 메모리는 사라지기 때문

 

캐시의 대상이 되는 정보들은 아래와 같다.

 

  • 단순한, 또는 단순한 구조의 정보를 -> 정보의 단순성 
  • 반복적으로 동일하게 제공해야 하거나 -> 빈번한 동일요청의  반복
  • 정보의 변경주기가 빈번하지 않고, 단위처리 시간이 오래걸리는 정보이고 -> 높은 단위처리비용
  • 정보의 최신화가 반드시 실시간으로 이뤄지지 않아도 서비스 품질에 영향을 거의 주지 않는 정보 
  • 예) 실시간 검색어, 방문자수/조회수/추천수, 1회성 인증정보, 공지사항/Q&A 등

 

 

Redis란?

REDIS(REmote Dictionary Server)는 메모리 기반의 “키-값” 구조 데이터 관리 시스템이며, 모든 데이터를 메모리에 저장하고 조회하기에 빠른 Read, Write 속도를 보장하는 비 관계형 데이터베이스이다.

Redis가 타 캐시 시스템(ex. MemCache 등)과 다른 특징은 아래와 같다.

  1. Redis는 List, Set, Sorted Set, Hash 등과 같은 Collection을 지원합니다.
  2. Race condition에 빠질 수 있는 것을 방지함
    • Redis는 Single Thread
    • 따라서 Atomic 보장
  3. persistence를 지원하여 서버가 꺼지더라도 다시 데이터를 불러들일 수 있습니다.

 

Embedded Redis를 이용하여 @Service 메소드를 캐시 함수로 사용하기

프로그램 구조

 

1. gradle 추가

implementation 'org.apache.commons:commons-lang3:3.4'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'it.ozimov:embedded-redis:0.7.2'

 

2. config class 추가 - 주의

spring:
  profiles:
    active: local
  cache:
    type: redis
  redis:
    host: localhost
    port: 6375
public class CacheKey {

  public static final int DEFAULT_EXPIRE_SEC = 600; // 10 minutes
  public static final String STOCK = "stock";
  public static final int STOCK_EXPIRE_DAY = 1;
}
@Profile("local")
@Configuration
public class EmbeddedRedisConfig {

  @Value("${spring.redis.port}")
  private int redisPort;

  private RedisServer redisServer;

  @PostConstruct
  public void redisServer() {
    redisServer = new RedisServer(redisPort);
    redisServer.start();
  }

  @PreDestroy
  public void stopRedis() {
    if (redisServer != null) {
      redisServer.stop();
    }
  }
}
@RequiredArgsConstructor
@EnableCaching
@Configuration
public class RedisConfig {

  private final ObjectMapper objectMapper;

  @Autowired
  public RedisConfig() {
    this.objectMapper = new ObjectMapper();
  }

  @Bean(name = "cacheManager")
  public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    var jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(StockResult.class);
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);


    RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
        .disableCachingNullValues()
        .entryTtl(Duration.ofSeconds(CacheKey.DEFAULT_EXPIRE_SEC))
        .computePrefixWith(CacheKeyPrefix.simple())
        .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(
            jackson2JsonRedisSerializer
        ));

    Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
    cacheConfigurations.put(CacheKey.STOCK, RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofDays(CacheKey.STOCK_EXPIRE_DAY)));

    return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory).cacheDefaults(configuration)
        .withInitialCacheConfigurations(cacheConfigurations).build();
  }
}

 

여기서 주의해야 할 점은 나는 레디스에 저장 할 값이 객체이기 때문에 Serializer를레디스에서 제공해 주는 jackson2JsonRedisSerializer를 사용했다. 이 때, 아래와 같이 ObjectMapper가 StockResult라는 객체를 Serialize 해 줄 수 있도록 코드를 추가한다.

var jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(StockResult.class);
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

 

3. @Service 메소드에 @Cacheable 추가하기

@Cacheable(value = CacheKey.STOCK, key = "#stockRequestDto.stockSymbol+#stockRequestDto.dateToday", unless = "#result == null")
  public StockResult calculateStockResult(StockRequestDto stockRequestDto) {
    ...
  }

옵션의 value에는 저장시 키값을, key에는 키 생성시 추가로 덧붙일 파라미터 정보를 선언한다.

나의 경우 캐시키는 stock::AAPL2020-09-27과 같은 형태로 생성된다.

unless = “#result == null”는 메서드 결과가 null이 아닌 경우에만 캐싱하도록 하는 옵션이다.

 

4. 메인 Application에 @EnableCaching 어노테이션 추가하기

@EnableCaching
@SpringBootApplication
public class StockApplication {

  public static void main(String[] args) {
    SpringApplication.run(StockApplication.class, args);
  }

}

 

이렇게 만들어 본 후 포스트맨으로 테스트 해 보니,

맨 처음에 캐시에 데이터가 없는 경우를 제외하고는 결과 값을 바르게 바로바로 리턴하는 것을 확인할 수 있었다.


참고

https://www.youtube.com/watch?v=mPB2CZiAkKM

https://daddyprogrammer.org/post/3870/spring-rest-api-redis-caching/

 

SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리

이번 장에서는 지금까지 개발한 api에 캐시를 적용해 보도록 하겠습니다. 캐시란 자주 사용되는 데이터를 메모리에 저장하고 반환하여 하드디스크의 원본데이터를 거치지 않게 함으로서 리소스

daddyprogrammer.org

https://sabarada.tistory.com/103

 

[Redis] 캐시(Cache)와 Redis

[Redis] 캐시(Cache)와 Redis [Redis] Redis의 기본 명령어 [Java + Redis] Spring Data Redis로 Redis와 연동하기 - RedisTemplate 편 [Java + Redis] Spring Data Redis로 Redis와 연동하기 - RedisRepository..

sabarada.tistory.com

https://javaengine.tistory.com/entry/SpringBoot2%EB%A1%9C-Rest-api-%EB%A7%8C%EB%93%A4%EA%B8%B015-%E2%80%93-Redis%EB%A1%9C-api-%EA%B2%B0%EA%B3%BC-%EC%BA%90%EC%8B%B1Caching%EC%B2%98%EB%A6%AC

 

SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리

이번 장에서는 지금까지 개발한 api에 캐시를 적용해 보도록 하겠습니다. 캐시란 자주 사용되는 데이터를 메모리에 저장하고 반환하여 하드디스크의 원본데이터를 거치지 않게 함으로서 리소스

javaengine.tistory.com

 

원래 풀이는 for문을 돌며 쿼리에 매칭되는 word의 수를 하나하나 구하도록 했다.

시간 복잡도는 O(쿼리 길이 X 쿼리 갯수 X 워드 갯수)로 효율성 테스트 1, 2, 3 을 통과하지 못했다. 

import java.util.*;

public class Main {
	public static void main(String[] args) {
		String words[] = { "frodo", "front", "frost", "frozen", "frame", "kakao" };
		String queries[] = { "fro??", "????o", "fr???", "fro???", "pro?" };
		Solution solution = new Solution();
		int[] answer = solution.solution(words, queries);
		for (int i = 0; i < answer.length; i++) {
			System.out.println(answer[i]);
		}
	}
}

class Solution {
	public static char WILD_CARD = '?';

	public int[] solution(String[] words, String[] queries) {
		int queryLength = queries.length;
		int[] answer = new int[queryLength];

		for (int i = 0; i < queryLength; i++) {
			answer[i] = matchedQuery(queries[i], words);
		}

		return answer;
	}

	public int matchedQuery(String query, String[] words) {
		int answer = 0;
		for (int i = 0; i < words.length; i++) {
			if (isMatched(query, words[i])) {
				answer++;
			}
		}

		return answer;
	}

	public boolean isMatched(String query, String word) {
		if (query.length() != word.length()) {
			return false;
		}

		for (int i = 0; i < query.length(); i++) {
			if (query.charAt(i) != word.charAt(i)) {
				if (query.charAt(i) != WILD_CARD && word.charAt(i) != WILD_CARD) {
					return false;
				}
			}
		}
		return true;
	}
}

 


이 문제는 특수한 자료구조를 사용하여 문제를 풀어야 한다.

바로바로~~ trie

 

[Trie 자료구조]

Trie 자료구조란?

  • 텍스트 자동 완성 기능과 같이 문자열을 저장하고 탐색하는데 유용한 자료구조이다.

Trie 자료구조의 형태는?

  • 각 노드는 <Key, Value> 맵을 가지고 있다. Key는 하나의 알파벳이 되고, Value는 그 Key에 해당하는 자식 노드가 된다.
  • 다음은 DEV, DEAR, PIE, POP, POW라는 단어가 들어있는 Trie 자료구조를 도식화한 것이다. 휴대폰 전화번호부에서 검색을 하거나 사전에서 단어를 찾는 것과 같다.
  • 예를 들어, 아래 그림에서 'DEV’라는 문자열을 찾으려면 루트 노드에서부터 순차적으로 [D] -> [E] -> [V] 를 탐색한다.
  • 그림에서 볼 수 있듯이 루트 노드는 특정 문자를 의미하지 않고, 자식 노드만 가지고 있다.
  • 유의할 점은 '부모 노드’나 '자신이 어떤 알파벳(Key)에 해당하는 노드(Value)'인지를 가지고 있는게 아니라는 점
  • 즉, 루트 노드는 [D], [P]라고 하는 알파벳을 Key로 하는 자식 노드들을 가지고 있고, [D]는 [E]를 Key로 하는 자식 노드, [P]는 [I]와 [O]를 Key로 하는 자식 노드들을 가지고 있는 것이다.
  • 또 루트 노드를 제외한 노드의 자손들은 해당 노드와 공통 접두어를 가진다는 특징이 있다. 즉, ‘DE’ 노드의 자손인 'DEAR’와 'DEV’는 'DE-'를 공통 접두어로 가지며, 'P’의 자손인 'POW’와 'PIE’는 'P-'를 공통 접두어로 가진다.

 

 

Java에는 Trie가 라이브러리로 없기 때문에 직접 구현해줘야 한다.

 

이때 쿼리가 "??odo", "fro??", "?????" 등의 형태이므로

1. front에서 검색 / back에서 검색할 수 있도록 구현해야 한다.

2. 그리고 단어의 길이에 따라 알맞은 단어를 검색 할 수 있도록 해야 한다.

 

import java.util.*;

public class Main {
	public static void main(String[] args) {
		String words[] = { "frodo", "front", "frost", "frozen", "frame", "kakao" };
		String queries[] = { "fro??", "????o", "fr???", "fro???", "pro?" };
		Solution solution = new Solution();
		int[] answer = solution.solution(words, queries);
		for (int i = 0; i < answer.length; i++) {
			System.out.print(answer[i] + " ");
		}
	}
}

class Solution {

	public int[] solution(String[] words, String[] queries) {

		Trie[] tries = new Trie[100001];
		for (String word : words) {
			int wordLength = word.length();
			if (tries[wordLength] == null) {
				tries[wordLength] = new Trie();
			}

			tries[wordLength].insert(word);
		}

		int[] answer = new int[queries.length];
		for (int i = 0; i < queries.length; i++) {
			int len = queries[i].length();
			if (tries[len] == null) {
				answer[i] = 0;
			} else {
				answer[i] = tries[len].getCount(queries[i]);
			}
		}
		return answer;
	}
}

class Trie {
	public static char WILD_CARD = '?';

	private Node frontRootNode;
	private Node backRootNode;

	Trie() {
		frontRootNode = new Node();
		backRootNode = new Node();
	}

	public void insert(String word) {
		insertFront(word);
		insertBack(word);
	}

	private void insertFront(String word) {
		Node node = frontRootNode;
		for (int i = 0; i < word.length(); i++) {
			node.count++;
			// word.charAt(i) 가 children에 없는 경우 새로운 Node를 만든다.
			node = node.children.computeIfAbsent(word.charAt(i), c -> new Node());
		}
	}

	private void insertBack(String word) {
		Node node = backRootNode;
		for (int i = word.length() - 1; i >= 0; i--) {
			node.count++;
			node = node.children.computeIfAbsent(word.charAt(i), c -> new Node());
		}
	}

	public int getCount(String query) {
		if (query.charAt(0) == WILD_CARD) {
			return getCountFromBack(query);
		}

		return getCountFromFront(query);
	}

	private int getCountFromFront(String query) {
		Node node = frontRootNode;

		for (int i = 0; i < query.length(); i++) {
			if (query.charAt(i) == WILD_CARD) {
				break;
			}
			if (!node.children.containsKey(query.charAt(i))) {
				return 0;
			}
			node = node.children.get(query.charAt(i));
		}
		return node.count;
	}

	private int getCountFromBack(String query) {
		Node node = backRootNode;

		for (int i = query.length() - 1; i >= 0; i--) {
			if (query.charAt(i) == WILD_CARD) {
				break;
			}
			if (!node.children.containsKey(query.charAt(i))) {
				return 0;
			}
			node = node.children.get(query.charAt(i));
		}
		return node.count;
	}

}

class Node {
	Map<Character, Node> children;
	int count;

	Node() {
		this.children = new HashMap<>();
		this.count = 0;
	}
}

 

 

참고

https://woovictory.github.io/2020/04/22/Java-Trie/

 

[Java] 트라이(Trie) 자료구조 개념

Trie 자료구조란? 일반 트리 자료구조 중 하나로, Digital Tree, Radix Tree, Prefix Tree라고도 불린다. 텍스트 자동 완성 기능과 같이 문자열을 저장하고 탐색하는데 유용한 자료구조이다.

woovictory.github.io

https://leveloper.tistory.com/99

 

[프로그래머스] 2020 KAKAO BLIND RECRUITMENT 가사 검색 (Java)

프로그래머스 2020 KAKAO BLIND RECRUITMENT 가사 검색 : https://programmers.co.kr/learn/courses/30/lessons/60060 코딩테스트 연습 - 가사 검색 | 프로그래머스 programmers.co.kr 예전에 못 풀었다가 다시 한..

leveloper.tistory.com

 

https://programmers.co.kr/learn/challenges?tab=sql_practice_kit

 

코딩테스트 연습

기초부터 차근차근, 직접 코드를 작성해 보세요.

programmers.co.kr

 

* SELECT

SELECT * FROM ANIMAL_INS ORDER BY ANIMAL_ID ASC; # 모든 레코드 조회

c.f. ORDER BY 1: 테이블 첫번째 컬럼 순서 번호에 따라 (오름차순)정렬

 

만약 ORDER BY를 생략하는 경우 DB에서 row에 접근하는 방식인 cursor의 스펙이 반영된다. (mysql의 경우는 PK 중심으로 정렬)

https://dba.stackexchange.com/questions/6051/what-is-the-default-order-of-records-for-a-select-statement-in-mysql

 

What is the default order of records for a SELECT statement in MySQL?

Suppose you have the following table and data: create table t ( k int, v int, index k(k) ) engine=memory; insert into t (k, v) values (10, 1), (10, 2), (10, 3); When

dba.stackexchange.com

SELECT NAME, DATETIME from ANIMAL_INS ORDER BY ANIMAL_ID DESC; # 역순 정렬
SELECT ANIMAL_ID, NAME 
FROM ANIMAL_INS WHERE INTAKE_CONDITION='Sick' # 아픈 동물 찾기
ORDER BY ANIMAL_ID ASC;
SELECT ANIMAL_ID, NAME 
FROM ANIMAL_INS 
WHERE INTAKE_CONDITION != 'Aged' # 어린 동물 찾기
ORDER BY ANIMAL_ID ASC;
SELECT ANIMAL_ID, NAME, DATETIME 
FROM ANIMAL_INS 
ORDER BY NAME ASC, # 오름차순 정렬
DATETIME DESC; # 내림차순 정렬
SELECT NAME from ANIMAL_INS ORDER BY DATETIME ASC LIMIT 1; # 상위 N개 레코드

 

* SUM, MAX, MIN

SELECT DATETIME from ANIMAL_INS ORDER BY DATETIME DESC limit 1; # 최댓값 구하기
SELECT max(datetime) from ANIMAL_INS # 최댓값 구하기
SELECT count(*) AS count FROM ANIMAL_INS; # 동물의 수 구하기
SELECT COUNT(*) 
FROM (SELECT DISTINCT NAME FROM ANIMAL_INS WHERE NAME IS NOT NULL) AS A; # 중복 제거

SELECT COUNT(DISTINCT NAME)
FROM ANIMAL_INS

여기서 두번째 풀이는 COUNT 안에 컬럼이 들어갔기 때문에 NULL은 자동으로 제외되므로 따로 IS NOT NULL을 해줄 필요 없음

 

* GROUP BY

SELECT ANIMAL_TYPE, COUNT(ANIMAL_TYPE) AS COUNT
FROM ANIMAL_INS
GROUP BY ANIMAL_TYPE
ORDER BY ANIMAL_TYPE ASC
SELECT NAME, COUNT(NAME) AS COUNT 
FROM ANIMAL_INS 
GROUP BY NAME HAVING COUNT(NAME)>1 
ORDER BY NAME ASC;

c.f. COUNT(*)와 COUNT(NAME)의 차이:

https://dev.mysql.com/doc/refman/8.0/en/group-by-functions.html#function_count

 

MySQL :: MySQL 8.0 Reference Manual

 

dev.mysql.com

* 의 경우 특정 column이 null값인 경우도 포함한다. 이 문제에서는 NAME column이 NULL인 경우도 COUNT하게 된다.
하지만, COUNT(column)은 해당 column이 NULL값을 갖고 있는 row의 경우 COUNT하지 않는다.

 

c.f. 조건절 HAVING과 WHERE 차이

HAVING: SELECT 문에서 사용된 그룹이나 aggregate function(AVG, SUM, MIN, MAX, VAR,COUNT, GROUPING, STDEV 등)에 조건을 부여해서 결과를 세분화한다. SELECT 문과 함께만 사용할 수 있으며, 주로 GROUP BY와 함께 쓰인다. 그룹화된 결과에만 적용될 수 있다.

 

즉, where 같은 경우는 모든 필드에 대해 우선적으로 조건을 주고
having은 group by 된 이후 그룹화되어진 새로운 테이블에 조건을 줄 수 있다는 차이점이 있다.

SELECT hour(datetime) AS hour, count(*) AS count 
FROM animal_outs
ORDER BY hour

c.f. HOUR(time) : 해당날짜의 시간을 반환한다. 결과값은 0 에서 23 이다.

http://happycgi.com/community/bbs_detail.php?bbs_num=43&tb=board_man_story

 

[MYSQL] 시간 관련 함수 정리 - happycgi

MySQL - 문서 제목 : MySQL 날짜 관련 함수- DAYOFWEEK(date) : 해당 날짜의 요일을 숫자로 반환한다. 일요일은 1, 토요일은 7 이다.- 예 : select DAYOFWEEK('1998-02-03');- WEEKDAY(date) : 해당 날짜에 대한 요일을 반환

happycgi.com

WITH RECURSIVE rgen (n)
  AS (SELECT 0 # 0부터 22개 생성(0~23까지)
       UNION ALL
      SELECT n+1
        FROM rgen
       WHERE n < 23)
SELECT TIME.n AS HOUR, IF(RESULT.COUNT IS NULL , 0, RESULT.COUNT) AS COUNT
FROM(SELECT HOUR(DATETIME) AS HOUR, COUNT(*) AS COUNT 
        FROM ANIMAL_OUTS AS OUTS
        GROUP BY HOUR
        ORDER BY HOUR) AS RESULT RIGHT OUTER JOIN rgen AS TIME
ON RESULT.HOUR = TIME.n

이 문제가 어려웠던 이유는 이렇게 count가 0인 값도 나타내주어야 했는데,

그냥 count를 쓸 경우 0인 값이 나타나지 않았다. (행이 없으면 row가 만들어지지 않는다.)

 

따라서 count==0이면 0으로 반환하도록 하고싶을 때 뭔가 outer join이 필요하다.

 

위 풀이는 WITH RECURSIVE 구문을 이용하여 리스트 데이터를 생성하는 방법이다.

WITH RECURSIVE 구문을 이용하여 만든 테이블은 아래와 같이 생겼다.

 n

---

 0

 1

 ...

23

 

다른 방법)

사용자 정의 변수는 @를 앞에 붙이고 사용한다. (초기화 하지 않으면 디폴트 값은 NULL이다.)

SET 또는 SELECT 문을 사용하여 변수를 초기화 할 수 있다.

SET @HOUR = -1;
SELECT @HOUR := @HOUR + 1 AS HOUR, (
    select count(*) 
    from animal_outs 
    where hour(datetime) = @hour) AS COUNT 
FROM ANIMAL_OUTS 
GROUP BY HOUR 
HAVING HOUR BETWEEN 0 AND 23

 

* IS NULL

SELECT ANIMAL_ID FROM ANIMAL_INS WHERE NAME IS NULL ORDER BY ANIMAL_ID;

c.f. is null과 = null의 차이

https://stackoverflow.com/questions/9581745/sql-is-null-and-null

 

SQL is null and = null

Possible Duplicate: what is “=null” and “ IS NULL” Is there any difference between IS NULL and =NULL What is the difference between where x is null and where x = ...

stackoverflow.com

NULL은 'unknown' 값이므로 NULL과 비교한 쿼리의 결과는 항상 unknown이다.

(null is special value which does not equal anything --> 따라서 항상 false 리턴)

따라서 NULL인지 확인하는 IS (NOT) NULL 구문을 사용하도록 한다.

SELECT ANIMAL_ID FROM ANIMAL_INS WHERE NAME IS NOT NULL ORDER BY ANIMAL_ID;
SELECT ANIMAL_TYPE, IFNULL(NAME, 'No name'), SEX_UPON_INTAKE FROM ANIMAL_INS;

c.f. IFNULL: 해당 필드의 값이 NULL을 반환할때 다른 값으로 출력할 수 있도록 하는 함수

다른 방법으로는 case when 조건 then 결과 문을 사용할 수 있다.

SELECT ANIMAL_TYPE, 
    case when NAME is null 
    then 'No name' 
    else NAME 
    end, 
    SEX_UPON_INTAKE 
FROM ANIMAL_INS;

 

* JOIN

c.f. join의 종류

1. INNER JOIN: default join 방식, 두 테이블 모두 있는 경우
2. OUTER JOIN: left 또는 right로 지정된 한쪽 테이블의 모든 결과를 보여줌
3. CROSS JOIN: 두개의 테이블에서 가능한 모든 조합을 찾는다. --> ON 구문을 사용할 수 없다
4. FULL OUTER JOIN: LEFT OUTER JOIN + RIGHT OUTER JOIN 후 중복 제거
5. SELF JOIN: 자기 자신과의 JOIN (Self join이라는 명령어가 따로 있는 것은 아니고, outer join이던 inner join이던 자기 자신과 조인할 경우는 self join이 된다)

SELECT OUTS.ANIMAL_ID, OUTS.NAME
FROM ANIMAL_OUTS AS OUTS
LEFT OUTER JOIN ANIMAL_INS AS INS
ON OUTS.ANIMAL_ID = INS.ANIMAL_ID
WHERE INS.ANIMAL_ID IS NULL;
SELECT INS.ANIMAL_ID, INS.NAME
FROM ANIMAL_INS AS INS JOIN ANIMAL_OUTS AS OUTS
ON INS.ANIMAL_ID = OUTS.ANIMAL_ID
WHERE INS.DATETIME > OUTS.DATETIME
ORDER BY INS.DATETIME;
SELECT INS.NAME, INS.DATETIME
FROM ANIMAL_INS AS INS LEFT OUTER JOIN ANIMAL_OUTS AS OUTS
ON INS.ANIMAL_ID = OUTS.ANIMAL_ID
WHERE OUTS.ANIMAL_ID IS NULL
ORDER BY INS.DATETIME ASC
LIMIT 3;
SELECT OUTS.ANIMAL_ID, OUTS.ANIMAL_TYPE, OUTS.NAME
FROM ANIMAL_INS AS INS INNER JOIN ANIMAL_OUTS AS OUTS
ON INS.ANIMAL_ID = OUTS.ANIMAL_ID
WHERE INS.SEX_UPON_INTAKE != OUTS.SEX_UPON_OUTCOME AND INS.SEX_UPON_INTAKE LIKE '%Intact%'

c.f. LIKE 구문 사용법

https://support.microsoft.com/ko-kr/office/like-%EC%A1%B0%EA%B1%B4%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B0%BE%EA%B8%B0-65b07c8a-b314-435a-8b48-2b911856d4f9

 

LIKE 조건을 사용하여 데이터 찾기

LIKE 조건을 사용하여 특정 패턴과 일치하는 데이터를 찾을 수 있습니다.

support.microsoft.com

 

* String, Date

SELECT ANIMAL_ID, NAME, SEX_UPON_INTAKE 
FROM ANIMAL_INS 
WHERE NAME IN ('Lucy', 'Ella', 'Pickle', 'Rogan', 'Sabrina', 'Mitty') 
ORDER BY ANIMAL_ID;
SELECT ANIMAL_ID, NAME 
FROM ANIMAL_INS 
WHERE ANIMAL_TYPE='Dog' AND (NAME LIKE '%EL%' OR NAME LIKE '%el%') 
ORDER BY NAME;
SELECT ANIMAL_ID, NAME, IF(SEX_UPON_INTAKE LIKE '%Neutered%' OR SEX_UPON_INTAKE LIKE '%Spayed%', 'O', 'X') AS 중성화
FROM ANIMAL_INS
ORDER BY ANIMAL_ID


SELECT  ANIMAL_ID,
        NAME,
        IF(SEX_UPON_INTAKE REGEXP 'Neutered|Spayed', 'O' , 'X') AS 중성화
FROM    ANIMAL_INS

 

SELECT OUTS.ANIMAL_ID, OUTS.NAME
FROM ANIMAL_INS AS INS , ANIMAL_OUTS AS OUTS
WHERE INS.ANIMAL_ID = OUTS.ANIMAL_ID
ORDER BY DATEDIFF(OUTS.DATETIME, INS.DATETIME) DESC
LIMIT 2;

c.f. 두 날짜 간 차이를 계산하는 함수 DATEDIFF vs TIMESTAMPDIFF vs TIMEDIFF

DATEDIFF: 단순한 날짜 차이

TIMEDIFF: 두 시간의 차이 계산

TIMESTAMPDIFF: 차이의 단위(년도, 분기, 월, 주, 일, 시, 분, 초)를 지정하여 계산

  • SECOND : 초
  • MINUTE : 분
  • HOUR : 시
  • DAY : 일
  • WEEK : 주
  • MONTH : 월
  • QUARTER : 분기
  • YEAR : 년도
SELECT ANIMAL_ID, NAME, DATE_FORMAT(DATETIME ,'%Y-%m-%d' ) as 날짜
FROM ANIMAL_INS ORDER BY ANIMAL_ID

c.f. DATE_FORMAT: 시간을 원하는 형태로 반환하는 함수, 대소문자 주의

 


쿼리 처리 순서

1. 구문 분석(Parsing)

해당 쿼리가 문법적으로 틀리지 않은지 확인. 해당 구문을 SQL 서버가 이해할 수 있는 단위들로 분해하는 과정.

만약 구문이 부정확하다면 여기서 처리를 중단. 이 문장이 일괄 처리(batch) 내에 있다면 일괄 처리 전체를 중단.(Batch abort : Batch 중 하나라도 syntax error가 있다면 전체 batch가 실행되지 않는다.)

2. 표준화(Standardization)

실제로 필요없는 부분들이 제거. 표준화된 쿼리 트리(Standard Query Tree)가 만들어 진다.

3. 최적화(Optimization)

통계나 조각 정보 등을 바탕으로 실행 계획을 만들어 낸다. 쿼리처리에서 매우 중요한 단계

① 쿼리 분석 : 검색 제한자(SARG)인지 조인 조건인지 판단.

② 인덱스 선택 : 분포 통계 정보를 이용하여 인덱스검색이나 테이블 스캔 중의 하나를 선택. 여러 인덱스 중 가장 효율적인 인덱스를 선택

③ 조인 처리 : JOIN, UNION, GROUP BY, ORDER BY 절을 가지고 있는지 확인하여 적절한 작업 순서를 선택

이 단계의 출력은 실행 계획(Execution Plan) 이다.

4. 컴파일(Compilation)

컴파일을 하면 이진 코드가 생성된다. 일반적인 경우에는 컴파일하고 나면 .exe, .dll 등의 이진 파일이 만들어 지는데, SQL Server에서는 그냥 메모리(프로시저 캐시)에만 올린다. 그래서 컴파일 속도가 매우 빠르다.

5. 실행(Execute)

엑세스 루틴으로 가서 실제 처리를 하고 결과를 돌려준다.

 


SELECT 실행 순서

-문법 순서-

SELECT                            - 1

FROM                               - 2

WHERE                             - 3

GROUP BY                        - 4

HAVING                            - 5

ORDER BY                        - 6

 

-실행 순서-

FROM                               - 1 해당 데이터가 있는 곳을 찾아가서

WHERE                             - 2 조건에 맞는 데이터만 가져와서

GROUP BY                        - 3 원하는 데이터로 가공

HAVING                             - 4 가공한 데이터에서 조건에 맞는 것만

SELECT                            - 5 뽑아내서

ORDER BY                         - 6 정렬



출처: https://police84.tistory.com/69

 

쿼리 처리 과정 / SELECT 실행순서

쿼리가 처리되는 과정은 다음과 같다. 1. 구문 분석(Parsing) 해당 쿼리가 문법적으로 틀리지 않은지 확인. 해당 구문을 SQL 서버가 이해할 수 있는 단위들로 분해하는 과정. 만약 구문이 부정확하다

police84.tistory.com

 

인턴을 했을 때 JPA 를 쓰긴 썼었는데 안 쓴거나 마찬가지였던 것 같다ㅠㅠㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

이전에는 아래와 같이 객체를 테이블에 맞춰서 모델링 했었음...

class Job{
    @Id
    private String jobId
    ...
}
class Step{
    @Id
    private String stepId;
    private String jobId;
    ...
}

이렇게 했을 때는 객체를 테이블에 저장하거나 조회할 때는 쉽고 편하다.

하지만 job에서 step을 조회할때, 또는 step에서 job을 조회하는 경우 참조된 객체를 가져올 수 없다.

물론 이 때는 데이터베이스에 저장이 주 기능이어서 딱히 상관없었을 수도 있다,,,

 

이번 토이 프로젝트에서는 JPA를 이용하여 객체지향 모델링을 해 볼건데

이번에는 객체 참조를 통해 관계를 맺도록 해볼것이다.

 

  • build.gradle dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

server:
  address: localhost
  port: 8080

spring:
  jpa:
    show-sql: true # API 호출시, SQL 문을 콘솔에 출력
    generate-ddl: true # DDL 정의시 데이터베이스의 고유 기능을 사용 ex) 테이블 생성, 삭제 등
    database: mysql
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
  datasource:
    url: jdbc:mysql://localhost:3306/ewhaianDB?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver

 

  • Entity class 생성,,,을 하기 전에 저번에 정의했던 연관관계를 다시 보면 (객체는 참조가 있는 방향으로만 조회가 가능하다.)

    • 회원-글/시간표/강평/댓글: 일대 다 관계

    • 글-댓글: 일대 다 관계

    • 글/댓글-사진: 일대 다 관계

    • 강의-강의평가: 일대 다 관계

    • 시간표-강의:다대 다 관계

      • 시간표에 다른 강의 여러개 넣을 수 있고

      • 같은 강의가 여러 시간표에 들어갈 수 있다

      • --> 다대 다 관계는 관계형 데이터베이스 뿐만 아니라 엔티티에서도 거의 사용하지 않는다고 함

      • --> 따라서 연결 엔티티 강의시간표를 추가해서 일대다 관계+다대일 관계로 만들도록 한다.

      • 강의 시간표에는 담은 강의의 학점 합도 포함하도록 한다.

    • 추가) 즐겨찾기의 경우도 다대다 관계이므로 관계를 쪼개도록 한다.

 

 

 

 

 

 


  • JPA를 사용할 때 양방향 관계에서 주의할 점

@Entity
@Getter @Setter
public class BookStore{
    @Id @GeneratedValue
    private Integer id;
    
    private String name;
    
    @OneToMany(mappedBy="bookStore")
    private Set<Book> books=new HashSet<>();
    
    void add(Book book){
        //book.setBookStore(this); 이 부분을 꼭 설정해주도록
        this.books.add(book);
    }
}
@Entity
@Getter @Setter
public class Book{
    @Id @GeneratedValue
    private Integer id;
    private Sring isbn;
    private String title;
    
    @ManyToOne
    private BookStore bookStore;
}

위와 같이 객체를 정의해두고 테스트 코드를 아래와 같이 돌렸을 때 결과를 확인하면

@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoJpaTestApplicationTests{
    @Autowired
    BookStoreRepository bookStoreRepository
    
    @Autowired
    BookRepository bookRepository
    
    @Test
    public void contextLoads(){
        BookStore bookStore=new BookStore();
        bookStore.setName("시애틀 책방");
        bookStoreRepository.save(bookStore);
        
        Book book=new Book();
        book.setTitle("JPA 공부 좀 하면서 쓰세요.");
        bookStore.add(book);
        bookRepository.save(book);
    }
}

--> book DB를 확인하면 bookStore id가 없다. (연관 관계 맵핑이 안되어 있다.)

 

mappedBy가 없을 때에는 Book과 BookStore은 양방향 관계가 아니고 서로 다른 단방향 관계 2개이다.

BookStore에서 Book을 참조하는 OneToMany 관계는 기본적으로 join 테이블(book_store_id - book_id)이 생성된다.

 

여기서 mappedBy="BookStore"를 써주면 양방향 관계가 된다. (OneToMany와 ManyToOne을 하나로 묶어준다.)

--> 이 때 관계의 주인을 Book(Many에 해당하는 쪽)으로 설정한다.

이 경우에는 join테이블이 생기는 것이 아니라 Book과 BookStore 테이블 2개만 생기고,

foreign key를 Book쪽에서 가지고 있게 된다. (Book 테이블에 book_store_id가 있음)

 

즉, mappedBy는 관계의 주인을 알려준다.

= 관계의 주인이 Book이고, Book에서 자기 자신을 BookStore라고 참조하고 있다는 뜻

= 이 경우 관계의 주인인 쪽에 관계가 설정 되어야 데이터베이스에 반영된다.

= add를 할 때 관계의 주인인 Book에 관계를 설정해야 sync가 된다.

 

출처

https://www.youtube.com/watch?v=brE0tYOV9jQ

https://victorydntmd.tistory.com/208

https://velog.io/@conatuseus/%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EA%B8%B0%EC%B4%88-1-i3k0xuve9i

 

'Java (+ Spring)' 카테고리의 다른 글

Java Unit Test: Junit5  (0) 2021.02.10
Spring Boot: Spring Security & 카카오 로그인 API(...ing)  (0) 2020.10.06
Spring: Spring MVC 설정 하기  (0) 2020.07.26
Android: tutorial page  (0) 2020.07.18
Android: Launch screen  (0) 2020.07.16

+ Recent posts