개발하거나 알고리즘 문제 풀면서 객체를 생성하지 않고 변수나 메소드를 바로 사용하고 싶을때 static 키워드를 붙여서 사용하곤 했는데 정확한 이해없이 사용하고 있는 것 같아서 책보면서 정리해보려고 합니다.

 

static은 정적인, 고정된 이라는 뜻을 가지고 있다. 그렇기 때문에 static 변수나 메소드를 정적 변수, 정적 메소드라고도 부른다. 또한 정적 변수와 정적 메소드를 포함하는 정적 멤버의 경우 객체에 소속된 멤버가 아니라 클래스에 소속된 멤버이기 때문에 클래스 멤버라고 부른다.

정적 멤버 선언

정적 멤버 선언은 아래와 같이 static 키워드를 붙여서 선언할 수 있다.

public class 클래스명 {
    // 정적 변수
    public static 타입 변수명 [= 초기값];
    
    // 정적 메소드
    public static 리턴타입 메소드명( 매개변수 ) { ... }
}

정적 변수와 정적 메모리는 클래스에 고정된 멤버이므로 JVM의 클래스로더를 통해 메소드 영역에 적재될때 클래스별로 관리되어 객체를 생성하지 않고 클래스 로딩이 끝나면 바로 사용할 수 있다.

static 키워드 사용 상황

정적 변수를 선언해야 하는 상황은 객체마다 가지고 있어야 할 고유한 데이터의 경우 인스턴스 변수로 선언하고 공용적인 데이터라면 정적 변수로 선언하는 것이 좋다. 예를 들어 아래 Calculator 클래스를 보면 3.14159로 값이 고정되어있는 파이의 경우 공용적인 데이터이기 때문에 정적 변수로 선언해주었고 color의 경우 클래스마다 값이 다를 수 있기 때문에 인스턴스 변수로 선언해 주었다.

public class Calculator {
    public String color;
    public static double pi = 3.14159;
}

 

정적 메소드의 경우에는 만약 인스턴스 변수를 사용한다면 인스턴스 메소드로 선언하고 그렇지 않은 경우에는 정적 메소드로 선언하는 것이 좋다. 예를 들어 Calculator의 인스턴스 변수인 color 값을 초기화 하기 위해 setColor() 메소드를 선언해야 한다면 아래와 같이 인스턴스 메소드로 선언하고 plus 메소드의 경우에는 인스턴수 변수가 아닌 외부 매개변수 값을 사용하기 때문에 정적 메소드로 선언해 주었다.

public class Calculator {
    public String color;
    public static double pi = 3.14159;
    
    public void setColor(String color) {
    	this.color = color;
    }
    
    static int plus(int x, int y) {
        return x + y;
    }
}

정적 초기화 블록

정적 변수의 경우 아래와 같이 변수 선언과 동시에 초기값을 주는 것이 보통이지만 계산이 필요한 초기화 작업이 있을 수 있다.

public static double pi = 3.14159;

인스턴스 변수의 경우에는 생성자에서 초기화 작업을 수행할 수 있지만 정적 변수의 경우에는 객체 생성없이 정적 변수를 사용해야 하기 때문에 객체 생성시에 실행되는 생성자에서 초기화 작업을 수행할 수 없다. 대신 자바는 정적 필드의 초기화 작업을 수행할 수 있는 정적 블록이라는 기능을 제공해 준다.

 

정적 블록은 아래와 같은 형태로 사용되며 클래스가 메모리로 로딩될 떄 자동적으로 실행된다.

static { ... }

 

정적 블록은 여러 개가 선언될 수 있으며 선언된 순서대로 실행된다.

정적 블록 사용 예시

public class Television {
    public static String company = "SAMSUNG";
    public static String model = "LCD";
    public static String info;
    
    static {
    	info = company + "-" + model;
    }
}

static 키워드 사용시 주의할 점

정적 메소와 정적 블록을 선언할 때 인스턴스 필드나 인스턴스 메소드를 사용할 수 없다. 또한 객체 자신을 참조하는 this 키워드도 사용할 수 없다. 만약 사용하려고 하면 컴파일 에러가 발생한다. 또한 static 키워드가 선언된 정적 멤버가 JVM에 로드될때 static 영역에 할당되는데 이 static 영역은 Garbage Collector 관리 영역 밖에 있어 GC에 의해 메모리가 관리되지 않는다. 그렇기 때문에 너무 남발하게 되면 성능에 악영향을 줄 수 있다.

1. Docker Desktop 설치

먼저 도커 컨테이너를 이용해 Jenkins를 컨테이너화 하고 배포를 진행할 것이기 때문에 각자 운영체제에 맞춰서 Docker Desktop을 설치해줍니다.

https://www.docker.com/products/docker-desktop/

 

Download Docker Desktop | Docker

Docker Desktop is available to download for free on Mac, Windows, or Linux operating systems. Get started with Docker today!

www.docker.com

2. Docker에 젠킨스 설치

docker pull jenkins/jenkins:lts-jdk11

위 명령어를 이용해 도커에 젠킨스 이미지를 다운받습니다. 앞의 jenkins는 만들고자 하는 계정이고 뒤의 jenkins는 레포지토리의 이름을 의미하고 따로 태그를 작성해주지 않으면 자동으로 latest 버전을 설치해줍니다.

설치에 성공하신 분들은 docker desktop의 이미지 탭을 보시면 아래와 같이 잘 설치된 것을 확인할 수 있습니다.

 

설치에 성공했으면 이제 Jenkins 이미지를 가지고 컨테이너를 실행해야 합니다. 터미널에서 아래 명령어를 실행합니다.

docker run -d -v jenkins_home:/var/jenkins_home -p 8080:8080 -p 50000:50000 --restart=on-failure --name jenkins-server jenkins/jenkins:lts-jdk11

명령어를 간단하게 설명하면 run 명령어는 이미지가 설치되어있는지 확인하고 설치되어있다면 그 이미지를 가지고 컨테이너를 실행하는 명령어 입니다.

-d 옵션은 detach 모드로 현재 실행하고있는 터미널과 분리해서 실행하겠다는 의미입니다.

-p 옵션은 publisher 옵션으로 컨테이너 내부에 있는 포트를 컨테이너 바깥쪽에 있는 환경에서 어떻게 접속해서 사용할것인지를 나타내는 설정입니다. 앞의 포트가 호스트 포트(바깥쪽 포트)이고 뒤의 포트가 컨테이너 포트입니다.

-v 옵션은 볼륨으로 도커가 실행되고있는 환경과 도커 내부 환경을 마운트 하는 설정입니다. 도커 내부에서 생성된 데이터는 도커가 삭제되면 해당 데이터도 함께 삭제되기 때문에 따로 데이터를 보관하고자 설정해주었습니다.

--name 옵션은 실행할 컨테이너에 이름을 부여하는 설정입니다. 이름을 따로 설정하지 않으면 도커가 이름을 랜덤하게 생성합니다. 

 

위 명령어를 실행하고 터미널에 docker ps 명령어를 실행하면 현재 실행되고 있는 컨테이너 정보를 얻을 수 있습니다. 현재 jenkins 컨테이너가 잘 실행되고 있는 것을 확인할 수 있네요

3. 젠킨스 접속하기

컨테이너 실행까지 하고 나서 로그를 출력해 보면 초기 비밀번호를 얻을 수 있습니다. 

docker logs CONTAINER ID

docker ps 명령어로 얻은 CONTAINER ID 를 가지고 위 명령어를 실행해 보면 초기 비밀번호를 확인할 수 있습니다. 따로 안전한 곳에 저장해 놓는 것을 추천드립니다.

 

그런 다음 localhost:8080으로 접속하셔서 초기비밀번호를 입력하시면 다음과 같은 화면이 나오는데 모든 Plugin을 설치할 것인지 선택해서 설치할 것인지 묻는 것으로 모든 플러그인 설치를 선택해줍니다.

선택해주고 나서 페이지 순서대로 계정을 생성하고 Jenkins 접속 url을 설정해주고 나면 Jenkins에 접속하실 수 있습니다.

4. 첫번째 Item(Project) 생성

Item은 Jenkins에서 사용하고 있는 작업의 최소 단위를 의미합니다. 접속하신 Jenkins 화면의 Dashboard에서 새로운 Item의 + 버튼을 눌러서 생성해보겠습니다.

Item의 이름을 작성하고 Freestyle project를 선택해줍니다. Item이 생성되면 나오는 화면에서 아래로 내리면 Build Steps이라는 항목이 보이는데 여기서 Execute shell을 선택해줍니다. 지금 만든 아이템에서 이 아이템을 실행하면 지정한 쉘 스크립트가 실행되도록 하기 위함입니다.

 

일단은 지금까지 한 작업이 잘 수행됐는지 확인하기 위해 아래와 같은 스크립트 문장을 작성해 주겠습니다. 

echo "Welcome to my first project using Jenkins"
javac -version

 

스크립트를 작성하고 Apply를 누른뒤 저장하면 아래와 같은 화면이 나올겁니다. 이 화면에서 왼쪽 사이드 탭에서 지금 빌드를 선택해줍니다.

 

 

빌드하고 나면 사이드 바 아래에 위와 같은 탭이 하나 생성되는데 #1 에 마우스를 올렸을때 나오는 메뉴에서 Consol Output을 클릭하면 아래와 같은 화면이 나오고 저희가 작성한 스크립트도 잘 출력된 것을 확인할 수 있습니다. 

5. GitHub 플러그인 확인

이제 GitHub Repository에 있는 Gradle 프로젝트로 빌드를 진행해 보겠습니다. 먼저 젠킨스 페이지에서 플러그인 관리로 들어가서 GitHub plugin 이 설치되어있는지 확인해줍니다.

6. GitHub 설정

Global Tool Configuration 페이지에서 아래와 같이 GitHub 설정을 추가한 후 Apply하고 저장해줍니다.

7. Gradle 플러그인 확인

GitHub와 마찬가지로 플러그인 관리에서 Gradle 플러그인이 설치되어 있는지 확인해줍니다. 만약 빌드 툴로 Maven을 사용하신다면 Maven Integration plugin을 설치하신 후 새로운 Item을 생성할때 Maven Project로 생성해주시면 됩니다.

8. Gradle 설정

Global Tool Configuration 페이지에서 아래와 같이 Gradle 설정을 추가한후 Apply, Save를 수행해 줍니다.

Gradle 버전은 실제 배포할 프로젝트의 버전으로 gradle-wrapper.properties 파일에 명시되어 있습니다.

9. 새로운 아이템 생성

Item 이름을 지정하고 Freestyle project로 프로젝트를 생성해 줍니다.

간단하게 프로젝트 설명을 작성하고 소스 코드 관리 탭에서 Git Repository 정보를 추가해줍니다.

마지막으로 Build Steps 항목에서 Invoke Gradle script를 선택한후 아래 사진처럼 Use Gradle Wrapper를 선택하고 Tasks를 작성해 줍니다. clean build -x text의 뜻은 빌드를 기존에 한 것이 있다면 빌드한 파일을 지운 후 다시 빌드하라는 내용이고 -x test는 테스트를 진행하지 않겠다는 뜻입니다.

 

위 설정을 모두 해주신후 Apply, Save를 수행하고 지금 빌드를 클릭해줍니다.

만약 빌드가 잘됐다면 Console Output을 확인했을때 아래와 같이 빌드가 잘 됐다는 로그를 확인할 수 있습니다.

'Server' 카테고리의 다른 글

[Spring] 마이크로서비스 아키텍쳐 (Micro Service Architecture, MSA)  (1) 2023.02.28
도커  (0) 2023.01.03

BooleanBuilder

BooleanBuilder 클래스를 이용하여 파라미터가 null이 아닌 경우에만 and() 메소드로 조건을 추가하고 where 절에 builder 객체를 기입하여 사용한다.

List<Member> result = searchMember1(usernameParam, ageParam);

...

private List<Member> searchMember1(String usernameCond, Integer ageCond) {

    BooleanBuilder builder = new BooleanBuilder();
    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }

    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
    }

Where 다중 파라미터

이 방법을 사용하면 리턴값이 null인 경우에는 where절에서는 무시된다.

BooleanBuilder를 사용한 방법보다 코드의 가독성이 좋고 사용한 메서드를 다른 쿼리에서도 사용할 수 있다는 장점이 있다.

List<Member> result = searchMember2(usernameParam, ageParam);

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameCond), ageEq(ageCond))
            .fetch();
}

private Predicate usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private Predicate ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

usernameEq() 메서드와  ageEq() 메서드를 조합하기

리턴 타입으로 BooleanExpression을 사용하면 여러가지 조건 메서드를 조합해서 사용할 수 있다.

List<Member> result = searchMember2(usernameParam, ageParam);

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(usernameCond, ageCond))
            .fetch();
}

private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

private BooleanExpression allEq(String usernameCond, Integer ageCond) {
	return usernameEq(usernameCond).and(ageEq(ageCond));
}

Spring Data JPA와 Querydsl 동적쿼리

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    List<Member> findByUsername(String username);
}

MemberRepositoryCustom

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}

MemberRepositoryImpl

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        member.team.id.as("teamId"),
                        member.team.name.as("teamName")
                ))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoeEq(condition.getAgeGoe()),
                        ageLoeEq(condition.getAgeLoe())
                )
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoeEq(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoeEq(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}

'JPA' 카테고리의 다른 글

[JPA] 벌크성 수정 쿼리  (0) 2023.04.02
[JPA] 고급 매핑 (상속 관계 매핑, @MappedSuperclass)  (0) 2023.03.13
[Querydsl] 프로젝션  (0) 2023.01.17
[Querydsl] Querydsl 적용하기  (0) 2023.01.15
[JPA] N + 1 문제와 해결방법  (0) 2022.10.19

프로젝션이란?

select 절에 어떤 것을 가져올지 결정하는 것을 프로젝션이라고 한다.

 

- 프로젝션 대상이 하나인 경우 타입을 명확하게 정할 수 있다.

// String 값인 member.username 하나만 조회해 올때
List<String> result = queryFactory
          .select(member.username)
          .from(member)
          .fetch();

 

- 프로젝션 대상이 둘 이상인 경우 튜플이나 DTO로 조회해온다.

List<Tuple> result = queryFactory
          .select(member.username, member.age)
          .from(member)
          .fetch();

이때 Tuple은 Querydsl에서 제공하는 Tuple이기 때문에 repository 계층에서 사용하는 것은 괜찮지만 service나 controller 계층에서 사용하는 것은 좋은 설계가 아니다. 그렇기 때문에 앞단으로 넘길때는 Tuple보다는 DTO로 변환해서 넘기는 것을 권장한다.

DTO 조회

순수 JPA에서 DTO 조회

List<MemberDto> result = em.createQuery(
          "select new study.querydsl.dto.MemberDto(m.username, m.age) " +
                  "from Member m", MemberDto.class)
          .getResultList();

순수 JPA에서 DTO 조회를 수행할 때는 new 명령어를 사용한다. 이 방법은 DTO의 패키지명까지 전부 적어줘야 하고 생성자 방식만 지원한다는 단점이 있다. 하지만 Querydsl은 이런 단점을 총 3가지 방법을 지원해주면서 해결할 수 있다.

 

Querydsl에서 DTO조회

1. 프로퍼티 접근 - setter

DTO 클래스에 setter가 필요하다.

List<MemberDto> result = queryFactory
                .select(Projections.bean(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

 

2. 필드 직접 접근

DTO 클래스 필드에 직접 값을 기입하기 때문에 setter가 필요없다.

List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

엔티티의 컬럼명과 DTO의 필드 이름이 다른경우 as를 이용하여 별칭을 지정해주어야 한다.

List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,
                        member.username.as("name"),
                        member.age))
                .from(member)
                .fetch();

 

3. 생성자 사용

생성자 방식은 DTO 생성자의 파라미터와 순서를 맞춰주어야 한다.

List<MemberDto> result = queryFactory
                .select(Projections.constructor(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

@QueryProjection

DTO 클래스도 Q타입으로 생성하는 방법으로 DTO 생성자에 @QueryProjection 어노테이션을 붙여서 사용한다. 이 방법은 컴파일 시점에 타입체크를 해주기 때문에 안전한 방법이지만 DTO 클래스에서 Querydsl 어노테이션을 사용하기 때문에 순수했던 DTO 클래스가 Querydsl에 의존성을 갖게 된다는 점과 DTO 클래스까지 Q타입으로 생성해주어야 한다는 단점이 존재한다.

 

@QueryProjection
public MemberDto(String username, int age) {
    this.username = username;
    this.age = age;
}

 

List<MemberDto> result = queryFactory
                .select(new memberDto(
                        member.username,
                        member.age
                        )
                )
                .from(member)
                .fetch();

Querydsl 사용이유

SpringBootSpring Data JPA의 조합으로 개발자가 직접 SQL 문을 작성할 필요없이 메소드로 동일한 기능을 제공해주기 때문에 매우 편리해졌지만 아직도 복잡한 쿼리와 동적 쿼리를 작성하는데는 한계가 있다.

 

Querydsl은 위와 같은 문제점을 해결해주고 여러가지 편리한 기능들을 제공해준다.

 

아래 코드는 member1이라는 username을 갖는 Member를 조회하는 기능을 JPQL과 Querydsl을 이용하여 테스트한 코드이다. 먼저 JPQL의 경우 sql문을 String 타입으로 작성했지만 Querydsl은 자바코드로 작성됐다. 그렇기 때문에 JPQL은 해당 기능을 사용하기 전에는 무엇이 잘못되었는지 확인할 수 없지만(런타임 에러) Querydsl은 컴파일 시점에 문법을 체크할 수 있고 자동완성 기능까지 제공해주기 때문에 JPQL을 이용한 방법보다 편리하다. 

@Test
public void startJPQL() {
    // member1을 찾아라
    String qlString = "select m from Member m " +
            "where m.username = :username";

    Member findMember = em.createQuery(qlString, Member.class)
            .setParameter("username", "member1")
            .getSingleResult();

    assertThat(findMember.getUsername()).isEqualTo("member1");
}
@Test
public void startQuerydsl() {
    QMember m = new QMember("m");

    Member findMember = queryFactory
            .select(m)
            .from(m)
            .where(m.username.eq("member1"))
            .fetchOne();

    assertThat(findMember.getUsername()).isEqualTo("member1");
}

뿐만 아니라 JPQL을 사용하면 DTO 매핑시 패키지 명까지 작성해주어야 하지만 Querydsl을 사용하면 프로젝션을 이용해 쉽게 DTO 매핑을 수행할 수 있고 동적쿼리작성도 보다 쉽게 작성할 수 있다.

Querydsl 기본 문법

기본 Q-Type 활용

  1. QMember member = new QMember("m")
  2. QMember member = QMember.member
  3. Static import 사용 
import static study.querydsl.entity.QMember.member;

...

Member findMember = queryFactory
        .select(member)
        .from(member)
        .where(member.username.eq("member1"))
        .fetchOne();

같은 테이블을 조인해서 조회해야 하는 경우 직접 Q타입 객체를 생성해서 사용한다.

검색 조건 쿼리

  • member.username.eq("member1") : username이 member1인 것만 조회
  • member.username.ne("member1") : username이 member1이 아닌것만 조회
  • member.username.eq("member1").not() : username이 member1이 아닌것만 조회
  • member.username.isNotNull() : username이 null이 아닌것만 조회
  • member.age.in(10, 20) : 나이가 10이거나 20인 것만 조회
  • member.age.notIn(10, 20) : 나이가 10이거나 20이 아닌것만 조회
  • member.age.between(10,30) : 나이가 10에서 30 사이인 것만 조회
  • member.age.goe(30) : 나이가 30보다 크거나 같은 것만 조회
  • member.age.gt(30) : 나이가 30보다 큰것만 조회
  • member.age.loe(30) : 나이가 30보다 작거나 같은 것만 조회
  • member.age.lt(30) : 나이가 30보다 작은것만 조회
  • member.username.like("member%") : username이 member로 시작하는 것만 조회
  • member.username.contains("member") : username이 member를 포함하고 있는 것만 조회
  • member.username.startsWith("member") : username이 member로 시작하는 것만 조회

and(), or() 메서드 체인으로 연결하여 여러 검색조건을 추가할 수 있으며 and()만 있는 경우 콤마(,)를 사용해 연결할 수 있다.

Member findMember = queryFactory
        .select(member)
        .from(member)
        .where(
                member.username.eq("member1"),
                member.age.eq(10)
        )
        .fetchOne();

결과 조회

  • fetch() : 리스트 조회, 데이터가 없으면 빈 리스트를 리턴한다.
  • fetchOne() : 단건 조회, 데이터가 없으면 null을 리턴하고 데이터가 두 개 이상이면 NonUniqueResultException 에러가 발생한다.
  • fetchFirst() : 조회된 데이터의 첫 한건만 리턴해준다. limit(1).fetchOne()
//List
List<Member> memberList = queryFactory
        .selectFrom(member)
        .fetch();

//단 건
Member findMember1 = queryFactory
        .selectFrom(member)
        .fetchOne();

//처음 한 건 조회
Member findMember2 = queryFactory
        .selectFrom(member)
        .fetchFirst();

fetchResults()와 fetchCount()는 deprecated 됨

정렬

  • orderBy() 메소드를 이용해 데이터를 정렬한다.
  • 콤마(,)를 이용해 정렬기준 추가 가능
  • asc() : 오름차순
  • desc() : 내림차순
  • nullsFirst() : null일 경우 처음에 출력
  • nullsLast() : null일 경우 마지막에 출력
List<Member> result = queryFactory
        .selectFrom(member)
        .where(member.age.eq(100))
        .orderBy(member.age.desc(), member.username.asc().nullsLast())
        .fetch();

페이징

  • offset(1) : 1번 인덱스부터 조회, 0번부터 시작
  • limit(2) : 최대 2건 조회
List<Member> result = queryFactory
        .selectFrom(member)
        .orderBy(member.username.desc())
        .offset(1)
        .limit(2)
        .fetch();

집합

  • select로 여러가지 데이터를 조회올 때 Querydsl이 제공하는 튜플로 조회를 해온다.
  • groupBy() : 데이터를 그룹화하여 조회해온다.
  • having() : groupBy()로 그룹화된 데이터를 제한할 떄 사용한다.
    • ex) having(item.price.gt(1000)) => 가격이 1000원 보다 비싼 아이템만 그룹화한다.
List<Tuple> result = queryFactory
        .select(team.name, member.age.avg())
        .from(member)
        .join(member.team, team)
        .groupBy(team.name)
        .fetch();
        
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);

assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);

assertThat(teamB.get(team.name)).isEqualTo("teamB");
assertThat(teamB.get(member.age.avg())).isEqualTo(35);

조인

기본조인

조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭(alias)으로 사용할 Q 타입을 지정하면 된다.

// member.team = 조인 대상
// team = 별칭으로 사용할 Q타입 객체
List<Member> result = queryFactory
        .selectFrom(member)
        .join(member.team, team)
        .where(team.name.eq("teamA"))
        .fetch();

세타조인 == 막조인

  • 연관관계가 없는 필드로 조인하는 방법으로 from 절에 여러 엔티티를 나열하여 사용한다.
  • 원래는 외부조인이 불가능하지만 on 을 사용하면 외부조인도 가능하다.
List<Member> result = queryFactory
        .select(member)
        .from(member, team)
        .where(member.username.eq(team.name))
        .fetch();

on 절

  • 조인 대상을 필터링할때 사용한다.
  • 연관관계가 없는 엔티티를 외부조인할때 사용한다.
  • on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면, where 절에서 필터링 하는 것과 기능이 동일하다. => 내부조인을 사용하면 on 절 보다는 익숙한 where 절을 사용하자
// 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
List<Tuple> result = queryFactory
        .select(member, team)
        .from(member)
        .leftJoin(member.team, team).on(team.name.eq("teamA"))
        .fetch();

연관관계 없는 엔티티 외부 조인

일반조인과 다르게 on 절을 사용한 연관관계 없는 엔티티 외부 조인에서는 join 파라미터에 엔티티가 하나만 들어간다.

// 예) 회원의 이름과 팀의 이름이 같은 대상 외부 조인
List<Tuple> result = queryFactory
        .select(member, team)
        .from(member)
        .leftJoin(team).on(member.username.eq(team.name))
        .fetch();

페치조인

  • 페치조인은 N + 1 문제를 해결하기 위해 연관된 엔티티를 SQL 한번에 조회하는 기능으로 주로 성능 최적화에 사용하는 방법이다.
  • join 기능 뒤에 fetchJoin()을 붙여서 사용한다.
Member findMember = queryFactory
        .selectFrom(member)
        .join(member.team, team).fetchJoin()
        .where(member.username.eq("member1"))
        .fetchOne();

서브쿼리

JPAExpressions를 사용하여 구현할 수 있다.

 

where 절에서의 서브쿼리

// alias가 바깥에 있는 member와 겹치면 안되기 떄문에 직접 QMember 객체를 생성해서 사용한다.
// 나이가 가장 많은 회원 조회
QMember memberSub = new QMember("memberSub");

List<Member> result = queryFactory
        .selectFrom(member)
        .where(member.age.eq(
                JPAExpressions
                        .select(memberSub.age.max())
                        .from(memberSub)
        ))
        .fetch();

 

select 절에서의 서브쿼리

// 나이가 평균 나이 이상인 회원
QMember memberSub = new QMember("memberSub");

List<Tuple> result = queryFactory
        .select(member.username,
                JPAExpressions
                        .select(memberSub.age.avg())
                        .from(memberSub)
        )
        .from(member)
        .fetch();

from 절에서는 서브쿼리를 사용할 수 없다. from 절에서 서브쿼리를 사용해야하는 경우 서브쿼리를 join으로 변경하거나 애플리케이션에서 쿼리를 2번 분리해서 사용할 수 있다. 두 가지 방법 모두 적용하기 힘든 경우에는 nativeSQL을 사용해야 한다.

CASE 문

단순한 조건

List<String> result = queryFactory
        .select(member.age
                .when(10).then("열살")
                .when(20).then("스무살")
                .otherwise("기타"))
        .from(member)
        .fetch();

복잡한 조건

복잡한 조건의 경우 CaseBuilder()를 사용하여 구현할 수 있다.

List<String> result = queryFactory
        .select(new CaseBuilder()
                .when(member.age.between(0, 20)).then("0~20살")
                .when(member.age.between(21, 30)).then("21~30살")
                .otherwise("기타"))
        .from(member)
        .fetch();

상수

상수를 함께 조회해야 하는 경우 Expressions.constant()를 사용하면 쉽게 조회할 수 있다.

List<Tuple> result = queryFactory
        .select(member.username, Expressions.constant("A"))
        .from(member)
        .fetch();

문자 더하기

여러 필드의 값을 문자열로 더해서 조회해야하는 경우 stringValue()를 사용하여 문자열로 변환한 후 더한다.

List<String> result = queryFactory
        .select(member.username.concat("_").concat(member.age.stringValue()))
        .from(member)
        .fetch();

'JPA' 카테고리의 다른 글

[Querydsl] 동적쿼리 작성하기  (0) 2023.01.18
[Querydsl] 프로젝션  (0) 2023.01.17
[JPA] N + 1 문제와 해결방법  (0) 2022.10.19
[JPA] 객체지향 쿼리  (0) 2022.10.10
[JPA] 영속성 전이 (CASCADE), 고아객체 제거 (ORPHAN)  (0) 2022.10.05

문제상황

@PostMapping()
public ResponseEntity<? extends BaseResponseBody> createConference(@AuthenticationPrincipal SsafyUserDetails userDetails) {
    conferenceService.createConference(userDetails.getUser().getUserId());
    return ResponseEntity.status(201).body(BaseResponseBody.of(201, "Success"));
}

프로젝트를 진행할 때 SpringSecurity와 JWT Token 기술이 적용된 스켈레톤 코드를 받아서 진행하는데 늘 사용하던 @AuthenticationPrincipal 어노테이션을 사용하니 UserDetails 인터페이스를 구현한 클래스가 계속 null 값이 떴다. 

문제원인

사용자 요청이 들어오면 AuthenticationFilter가 요청을 가로챈 후 getAuthentication() 메서드로 UsernamePasswordAuthenticationToken 객체를 생성하는데 이때 토큰의 파라미터로 String 값인 userId를 넣어준 것이 원인이었다.

/**
 * 요청 헤더에 jwt 토큰이 있는 경우, 토큰 검증 및 인증 처리 로직 정의.
 */
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
    ...

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        ...

        try {
            Authentication authentication = getAuthentication(request);
            // jwt 토큰으로 부터 획득한 인증 정보(authentication) 설정.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } 

        ...
    }

    @Transactional(readOnly = true)
    public Authentication getAuthentication(HttpServletRequest request) throws Exception {
        ...
        
        // 식별된 정상 유저인 경우, 요청 context 내에서 참조 가능한 인증 정보(jwtAuthentication) 생성.
        SsafyUserDetails userDetails = new SsafyUserDetails(user);
        UsernamePasswordAuthenticationToken jwtAuthentication = new UsernamePasswordAuthenticationToken(userId,
                null, userDetails.getAuthorities());
                
        ...
    }
}

해결방법

아래와 같이 첫번째 파라미터인 userId를 UserDetails 인터페이스를 구현한 클래스로 바꿔주었더니 UserDetails 구현클래스가 잘 넘어온 것을 확인할 수 있었다.

@Transactional(readOnly = true)
public Authentication getAuthentication(HttpServletRequest request) throws Exception {
    ...

    // 식별된 정상 유저인 경우, 요청 context 내에서 참조 가능한 인증 정보(jwtAuthentication) 생성.
    SsafyUserDetails userDetails = new SsafyUserDetails(user);
    UsernamePasswordAuthenticationToken jwtAuthentication = new UsernamePasswordAuthenticationToken(userDetails,
            null, userDetails.getAuthorities());

    ...
}

 

컨테이너

컨테이너란 호스트 OS상에 논리적인 구획(컨테이너)을 만들고, 애플리케이션을 작동시키기 위해 필요한 라이브러리나 애플리케이션 등을 하나로 모아, 마치 별도의 서버인 것처럼 사용할 수 있게 만든 것

도커

Docker(도커)는 애플리케이션의 실행에 필요한 환경을 하나의 이미지로 모아두고, 그 이미지를 사용하여 다양한 환경에서 애플리케이션 실행 환경을 구축 및 운용하기 위한 오픈소스 플랫폼. 내부에서 컨테이너 기술을 사용하고 있는 것이 특징

 

도커는 인프라 환경을 컨테이너로 관리한다. 애플리케이션의 실행에 필요한 모든 파일 및 디렉토리들을 컨테이너로서 모아버린다. 컨터이너의 바탕이 되는 Docker 이미지를 Docker Hub와 같은 리포지토리에서 공유한다.

도커의 특징 - 확장성/이식성

  • 도커가 설치되어 있다면 어디서든 컨테이너를 실행할 수 있음
  • 특정 회사나 서비스에 종속적이지 않음
  • 쉽게 개발 서버를 만들 수 있고 테스트 서버 생성도 간편함

도커의 특징 - 표준성

  • 컨테이너라는 표준으로 서버를 배포하므로 모든 서비스들의 배포과정이 동일해짐

도커의 특징 - 이미지

  • 이미지에서 컨테이너를 생성하기 때문에 반드시 이미지를 만드는 과정이 필요
  • Dockerfile을 이용하여 이미지를 만들고 처음부터 재현 가능
  • 빌드 서버에서 이미지를 만들면 해당 이미지를 이미지 저장소에 저장하고 운영서버에서 이미지를 불러옴

도커의 특징 - 환경변수

  • 설정은 보통 환경변수로 제어함
  • MYSQL_PASS = password와 같이 컨테이너를 띄울 떄 환경변수를 같이 지정
  • 하나의 이미지로 환경변수에 따라 동적으로 설정파일 생성 가능

도커의 특징 - 자원관리

  • 컨테이너는 삭제 후 새로 만들면 모든 데이터가 초기화됨
  • 업로드 파일을 외부 스토리지와 링크하여 사용하거나 S3같은 별도의 저장소가 필요
  • 세션이나 캐시를 memcashed나 redis와 같은 외부로 분리

스케줄링

  • 컨테이너를 적당한 서버에 배포해 주는 작업
  • 여러 대의 서버 중 가장 할일 없는 서버에 배포하거나 그냥 차례대로 배포 또는 아예 랜덤하게 배포
  • 컨테이너 개수를 여러 개로 늘리면 적당히 나눠서 배포하고 서버가 죽으면 실행 중이던 컨테이너를 다른 서버에 띄워줌

클러스터링

  • 여러 개의 서버를 하나의 서버처럼 사용
  • 여기저기 흩어져 있는 컨테이너도 가상 네트워크를 이용하여 마치 같은 서버에 있는 것처럼 쉽게 통신

서비스 디스커버리

  • 서비스를 찾아주는 기능
  • 클러스터 환경에서 컨테이너는 어느 서버에 생성될지 알 수 없고 다른 서버로 이동할 수도 있음 따라서 컨테이너와 통신을 하기 위해서 어느 서버에서 실행중인지 알아야 하고 컨테이너가 생성되고 중지될 때 어딘가에 IP와 Port같은 정보를 업데이트해줘야 함
  • 키-벨류 스토리지에 정보를 저장할 수도 있고 내부 DNS 서버를 이용

도커 컨테이너 명령어

docker run

도커 이미지를 사용하여 컨테이너를 만드는 동시에 컨테이너를 실행한다.

docker run [OPTIONS] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]

옵션 설명
-d 컨테이너를 백그라운드로 실행하며, 실행할 때 container Id를 출력한다.
-h 호스트 이름을 설정한다.
-p 컨테이너 내부 포트와 외부 포트를 매핑하여 포트 포워딩을 한다.
-v 호스트와 컨테이너의 디렉토리를 연결
-e 환경 변수를 설정한다.
--name 컨테이너 이름을 설정한다.
--restart 컨테이너가 있으면 재시작한다.
--rm 컨테이너를 종료한 후 자동으로 컨테이너를 삭제한다.
-it -i 와 -t를 동시에 사용한 것으로 터미널 입력을 위한 옵션
--network 네트워크 연결

docker ps [OPTIONS]

실행 중인 컨테이너 리스트를 출력한다.

-a 옵션을 추가하면 종료 상태의 컨테이너 리스트까지 출력한다.

docker stop [CONTAINER ID or NAME]

실행 중인 컨테이너를 종료 상태로 만든다.

docker start [CONTAINER ID or NAME]

종료 상태인 컨테이너를 실행 상태로 만든다.

docker kill [CONTAINER ID or NAME]

컨테이너를 강제 종료한다.

docker rm [CONTAINER ID or NAME]

컨테이너를 지운다.

docker exec -it [CONTAINER ID or NAME] bash

실행 중인 도커 컨테이너에 접속한다.

도커 이미지 관련 명령어

docker images

도커 호스트 머신에 있는 도커 이미지 리스트들을 확인한다.

docker rmi [option] IMAGE [image ID]

로컬 호스트 머신에 있는 도커 이미지를 삭제한다. 삭제할 도커 이미지 ID를 인자로 입력하고 만약 삭제하려는 이미지가 다른 이미지에 사용되고 있다면 삭제할 수 없다.

-force 옵션으로 강제로 삭제 가능

docker pull [image name]:[tag name]

도커 이미지 저장소에서 이미지 이름, 태그와 매칭 되는 도커 이미지를 로컬 호스트 머신으로 복사한다.

만약 tag name을 생략하면 자동으로 최신(latest) 태그로 복사한다.

문제 상황

Stock.class

@Entity
@NoArgsConstructor
@Getter
public class Stock {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease(Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("재고 수량 부족");
        }

        this.quantity -= quantity;
    }

}

StockService

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

 

이커머스 서비스를 개발 중 위 코드를 아래와 같이 멀티 쓰레드 환경에서 테스트 했을 때 테스트에 실패했다. productId가 1인 상품을 100개 생성한 뒤 1번 상품을 1개씩 100번 감소 한다고 가정하고 테스트 했을때 남은 수량이 0일 것이라고 예상했지만 결과는 남은 수량이 96개로 예상했던 결과와 다르게 나왔다.

 

이유는 경쟁 상태(race condition)가 발생했기 때문인데 경쟁 상태란 각 프로세스, 스레드가 동시에 접근할 수 있는 공유 자원(shared resource)에 두 개 이상의 프로세스나 스레드가 동시에 읽거나 쓰는 상황을 말하며 공유 자원 접근 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 의미한다.

 

위 상황을 예로 들면 재고 수량이 100개 인 상태에서 동시에 2개의 스레드가 decrease 메소드를 테스트 하면 2개가 차감되어야 하지만 두 스레드 모두 재고 수량이 100개인 상태에서 접근하여 수량을 1개씩 차감하기 때문에 결과값이 모두 99개로 1개만 차감한 것이 된다. 이런 문제를 동시성 이슈라고 한다.

StockServiceTest

@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void before() {
        Stock stock = new Stock(1L, 100L);

        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 동시에_100개의_요청() throws InterruptedException {
        int threadCount = 100;

        // 비동기로 실행하는 작업을 단순화 하여 사용할 수 있게 도와주는 자바의 API
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        // 다른 스레드에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Stock findStock = stockRepository.findById(1L).orElseThrow();
        assertThat(findStock.getQuantity()).isEqualTo(0L);
    }

}

강의에서는 동시성 문제를 해결하기 위한 방법으로 synchronized 선언, 데이터베이스를 이용한 락(Pessimistic Lock, Optimistic Lock, Named Lock), redis를 이용한 락(Lettuce Lock, Redisson Lock)을 소개하고 있다.

Synchronized 선언

synchronized는 멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주는 자바에서 제공하는 키워드로 선언 방법은 아래 코드와 같이 문제가 되는 메소드 선언부에 synchronized 선언만 해주면 된다.

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public synchronized void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

다시 테스트해보면 synchronized를 선언했음에도 아래와 같이 테스트가 실패한 것을 확인할 수 있다. 이는 decrease() 메소드가 종료된 시점과 트랜잭션이 종료되는 시점 사이에 다른 스레드가 접근하게 되면 이 스레드는 변경사항이 디비에 반영되기 전의 재고 수량을 조회하여 decrease() 메소드를 호출하기 때문에 이와 같은 문제가 발생하는 것이다.

@Transactional 어노테이션을 지우고 테스트 하면 다음과 같이 테스트가 성공하는 것을 확인할 수 있다.

하지만 synchronized는 하나의 프로세스 안에서만 보장이 된다. 즉 서버가 한 대일 때는 decrease() 메소드에 하나의 스레드만 접근하는 것을 보장하지만 서버가 여러대일 때는 synchronized를 선언해도 동시에 여러 스레드가 접근할 수 있기 때문에 문제를 해결할 수 없다.

DB Lock

Pessimistic Lock

실제로 데이터에 락을 걸어서 정합성을 맞추는 방법, exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 이전에 데이터를 가져갈 수 없다.

  • exclusive lock : 쓰기 잠금(write lock)이라고도 불린다. 현재 트랜잭션이 완료될 때까지 다른 트랜잭션에서 lock이 걸린 테이블이나 레코드에 읽거나 쓰지 못한다.
  • shared lock : 읽기 잠금(read lock)이라고도 불린다. 읽기는 가능하지만 쓰기는 불가능하다.

장점

  • 충돌이 빈번하게 일어난다면 Optimistic Lock 보다 성능이 좋을 수 있다.
  • Lock을 통해 update를 제어하기 때문에 데이터 정합성이 어느정도 보장된다.

단점

  • 별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.
  • 데드락이 걸릴수 있기 때문에 주의해서 사용해야 한다.

데드락이란?

데드락(교착 상태)은 두 개 이상의 프로세스들이 서로가 가진 자원을 기다리며 중단된 상태를 말한다.

 

사용 방법

Spring Data JPA에서는 아래와 같이 @Lock 어노테이션을 이용해 쉽게 Pessimistic Lock을 구현할 수 있다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id=:id")
    Stock findByIdWithPessimisticLock(@Param("id") Long id);
}

위와 같이 메소드를 구현하고 Pessimistic Lock 을 테스트해보기 위해 Service 클래스와 테스트 클래스를 아래와 같이 생성한 후 테스트 해보면 테스트에 성공한 것을 확인할 수 있다.

@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithPessimisticLock(id);

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
@SpringBootTest
class StockServiceTest {

    @Autowired
    private PessimisticLockStockService stockService;

    ...
    // 나머지 코드는 맨 위의 테스트 코드와 같습니다.

}

Optimistic Lock

실제로 락을 이용하지 않고 Version을 이용함으로써 정합성을 맞추는 방법, 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하여 update를 한다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후에 작업을 수행해야 한다.

 

장점

  • 별도의 락을 잡지 않으므로 Pessimistic Lock 보다 성능상의 이점이 있다.

단점

  • Update에 실패했을 때를 대비하여 재시도 로직을 개발자가 따로 작성해 주어야 한다.
  • 충돌이 빈번하게 일어나는 경우 Pessimistic Lock을 사용하는 것이 더 낫다.

사용 방법

Version을 확인하기 위해 아래와 같이 Stock Entity에 Version 컬럼을 추가하고 @Version 어노테이션을 붙여준다.

@Entity
@NoArgsConstructor
@Getter
public class Stock {
    
    ...

    @Version
    private Long version;

    ...

}

그런 다음 Pessimistic Lock과 마찬가지로 @Lock 어노테이션을 이용해 Optimistic Lock을 이용한 메소드를 구현해주고 이 메소드를 이용해 재고 감소 로직을 작성해준다. 그리고 Optimistic Lock 의 경우 실패했을 때 재시도를 해주어야 하므로 Facade 패턴을 이용해 아래와 같이 실패했을때 50 millisecond 있다가 재시도 해주는 클래스를 생성해 준다.

@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService optimisticLockStockService;

    public void decrease(Long id, Long quantity) throws InterruptedException {
        while (true) {
            try {
                optimisticLockStockService.decrease(id, quantity);

                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}

그리고 나서 아래와 같이 Facade 패턴으로 구현한 Service의 decrease 메소드를 테스트해보면 테스트에 성공한 것을 확인할 수 있다.

@SpringBootTest
class OptimisticLockStockFacadeTest {

    @Autowired
    private OptimisticLockStockFacade stockService;

    ...
    // 나머지 코드는 맨 위의 테스트 코드와 같습니다.

}

Named Lock

이름을 가진 metadata locking 이다. 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 락을 획득할 수 없다. 주의할 점으로는 transaction이 종료될 때 lock이 자동으로 해제되지 않는다. 그렇기 때문에 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다. Mysql에서는 getLock() 메소드를 통해 Lock을 획득할 수 있고 releaseLock() 메소드를 통해 락을 해제할 수 있다.

 

사용 방법

테스트 환경에서는 JPA의 Native Query를 활용하고 동일한 DataSource를 활용했지만 실무에서는 DataSource를 분리해서 사용하는 방법을 추천한다. 그 이유는 동일한 DataSource를 사용하게 되면 커넥션 풀이 부족하게 되는 현상이 발생하여 다른 서비스에도 영향을 끼칠 수 있기 때문이다.

 

먼저 LockRepository를 생성한 후 getLock() 메소드와 releaseLock() 메소드를 작성해 준다.

public interface LockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(@Param("key") String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(@Param("key") String key);
}

그런 다음 핵심 로직인 decrease() 메소드 호출 전후로 getLock()과 releaseLock() 메소드를 실행해 주어야 하기 때문에 아래와 같이 Facade 클래스를 생성해준다.

@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {

    private final LockRepository lockRepository;

    private final StockService stockService;

    @Transactional
    public void decrease(Long id, Long quantity) {
        try {
            lockRepository.getLock(id.toString());
            stockService.decrease(id, quantity);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}

그리고 StockService는 부모의 Transaction과 별도로 실행이 되어야 하기 때문에 StockService의 decrease() 메소드에 작성되어있는 @Transactional 어노테이션에 아래와 같이 propagation 속성을 추가해준다.

public class StockService {

    private final StockRepository stockRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Long id, Long quantity) {
        ...
    }
}

마지막으로 NamedLockStockFacade 클래스의 decrease() 메소드를 테스트해보면 아래와 같이 성공한 것을 확인할 수 있다.

@SpringBootTest
class NamedLockStockFacadeTest {

    @Autowired
    private NamedLockStockFacade stockService;

    ...
    // 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}

Redis Lock

Redis를 이용해서 Lock을 걸기 위해서는 먼저 build.gradle에 spring-data-redis 의존성을 추가해준다.

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

Lettuce Lock

Lettuce Lock은 setnx 명령어를 활용하여 분산 락을 구현할 수 있다. setnx 명령어는 Key와 Value를 set할때 기존의 값이 없을 때만 set 하는 명령어이다. setnx를 활용하는 방식은 spin-lock 방식이므로 retry 로직을 개발자가 따로 작성해주어야 한다. 

 

spin-lock 방식이란?

Lock을 획득하려는 쓰레드가 Lock을 사용할 수 있는 지 반복적으로 확인하면서 Lock 획득을 시도하는 방식이다.

 

장점

  • 구현이 간단하다.
  • spring-data-redis 를 이용하면 기본이 Lettuce 방식을 사용하기 때문에 별도의 라이브러리를 사용하지 않아도 된다.

단점

  • spin-lock 방식을 사용하기 때문에 동시에 많은 쓰레드가 Lock을 획득하기 위해 대기 중이라면 Redis가 과부화가 걸릴 수도 있다.

사용 방법

먼저 RedisLockRepository 클래스를 생성한 뒤 Lock을 거는 lock 메소드와 로직이 끝났을때 Lock을 해제하는 unLock 메소드를 작성해 준다.

@Component
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unLock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }

}

그런 다음 아래와 같이 Lock을 획득할 수 있을 때까지 RedisLockRepository의 lock 메소드를 반복 실행해주는 Facade 클래스를 생성해 준다.

@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;

    private final StockService stockService;

    @Transactional
    public void decrease(Long id, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(id)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(id, quantity);
        } finally {
            redisLockRepository.unLock(id);
        }
    }
}

마지막으로 LettuceLockStockFacade 클래스의 decrease() 메소드를 테스트해주는 테스트 클래스를 작성한 후 테스트해보면 성공한 것을 확인할 수 있다.

@SpringBootTest
class LettuceLockStockFacadeTest {

    @Autowired
    private LettuceLockStockFacade stockService;

    ...
    // 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}

Redisson Lock

pubsub 기반의 락 구현, redisson의 경우 락 관련된 클래스를 제공해주기 때문에 별도의 Repository를 작성해 주지 않아도 된다.

 

pubsub 기반이란?

채널을 하나 만들고 Lock을 점유중인 쓰레드가 Lock을 획득하려고 대기 중인 쓰레드에게 해제를 알려주면 안내를 받은 쓰레드가 Lock 획득을 시도를 하는 방식이다.

 

장점

  • 별도의 retry 로직을 작성하지 않아도 된다.
  • pub-sub 방식을 사용하기 때문에 Lettuce와 비교했을 때 redis에 부하가 덜 간다.

단점

  • 별도의 라이브러리를 사용해야 한다.
  • Lock을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.

사용방법

먼저 Redisson 방식을 사용하기 위해서는 아래와 같이 redisson 라이브러리를 추가해주어야 한다.

// https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.19.0'

그런 다음 Redisson 방식은 별도의 Repository를 생성해 주지 않아도 되기 때문에 바로 Lock 획득을 시도하고 StockService의 decrease 메소드를 호출해주는 Facade 클래스를 생성해 준 뒤 같은 로직으로 테스트 해보면 성공한 것을 확인할 수 있다.

@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;

    private final StockService stockService;

    @Transactional
    public void decrease(Long id, Long quantity) {
        RLock lock = redissonClient.getLock(id.toString());

        try {
            boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }

            stockService.decrease(id, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
@SpringBootTest
class RedissonLockStockFacadeTest {
    @Autowired
    private RedissonLockStockFacade stockService;

    ...
    // 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}

DB Lock vs Redis Lock

Mysql

  • Mysql을 이미 사용하고 있다면 별도의 비용없이 사용이 가능하다.
  • 어느정도의 트래픽까지는 문제없이 활용이 가능하지만 그래도 Redis를 이용한 방식보다는 성능이 좋지 않다.

Redis

  • Mysql 방식보다 성능이 뛰어나다.
  • Redis를 사용하는 중이 아니었다면 별도의 구축비용과 인프라 관리비용이 발생한다.

+ Recent posts