개발하거나 알고리즘 문제 풀면서 객체를 생성하지 않고 변수나 메소드를 바로 사용하고 싶을때 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에 의해 메모리가 관리되지 않는다. 그렇기 때문에 너무 남발하게 되면 성능에 악영향을 줄 수 있다.
명령어를 간단하게 설명하면 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을 확인했을때 아래와 같이 빌드가 잘 됐다는 로그를 확인할 수 있습니다.
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;
}
SpringBoot와 Spring 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 매핑을 수행할 수 있고 동적쿼리작성도 보다 쉽게 작성할 수 있다.
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원 보다 비싼 아이템만 그룹화한다.
조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭(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();
프로젝트를 진행할 때 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());
...
}
@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() 메소드를 작성해 준다.
그런 다음 핵심 로직인 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 의존성을 추가해준다.
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 라이브러리를 추가해주어야 한다.
그런 다음 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를 이용한 방식보다는 성능이 좋지 않다.