카테고리 없음 2020. 1. 28. 15:32

 

 

 

다들 알고 있는 내용일 수 있겠지만, 이번에 개발하면서 알게된 Lombok 사용시 이슈 사항이다. 해당 이슈사항은 boolean, Boolean 타입을 사용하면서 prefix가 isXX인 필드를 사용할때이다.

 

1
2
3
4
5
6
7
8
@Data
public class Test {
    private boolean isBoolean;
    
    private void test() {
        this.isBoolean();
        this.setBoolean();
 
    }
}
cs

 

사실 네이밍이 잘못된 것 일수도 있다. 하지만 필자는 get가 isIsBoolean()으로 나올 것을 기대했지만, 위의 결과는 is를 생략한 isBoolean으로 getter를 생성한다. setter도 마찬가지이다. 이번에는 래퍼타입의 Boolean일 경우이다.

 

1
2
3
4
5
6
7
8
9
@Data
public class Test {
    private Boolean isBoolean;
 
    private void test() {
        this.getIsBoolean();
        this.setIsBoolean();
    }
}
cs

 

위의 결과는 첫번째 결과와 아예다른 네이밍으로 getter/setter를 생성한다. 그렇다면 마지막으로 Intellij에서 생성해주는 getter/setter의 경우를 보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test {
    private Boolean isBoolean;
 
    public Boolean getBoolean() {
        return isBoolean;
    }
 
    public void setBoolean(Boolean aBoolean) {
        isBoolean = aBoolean;
    }
}
 
public class Test {
    private boolean isBoolean;
 
    public boolean isBoolean() {
        return isBoolean;
    }
 
    public void setBoolean(boolean aBoolean) {
        isBoolean = aBoolean;
    }
}
cs

 

이처럼 boolean 필드에 isXX prefix를 넣은경우는 어떤 타입을 사용하냐, 혹은 IDEA의 자동완성을 쓰냐 혹은 Lombok을 쓰냐에 따라 getter/setter의 네이밍이 다르게 설정될 수 있다는 것을 유의하자. 필자는 이러한 점을 모르고 사용하였다가, Mapstruct로 오브젝트를 변환하는 코드에서 삽질을 무척했다.

 

 

posted by 여성게
:

 

크기가 일반적으로 작고 읽기 전용 작업이 변경 작업보다 훨씬 많을 때 사용하면 좋은 라이브러리이다. iteration 중, 스레드 간의 간섭이 없어야 할때 사용하기 좋다. 즉, 스레드 안전하다.

하지만 변경 작업 같은 경우(add, set, remove) snapshot(복제본)을 이용하여 변경작업을 하기 때문에 비용이 비싸다. 내부적으로 object lock, synchronized 등이 사용되기 때문에 읽기 작업이 많고 변경작업이 적은 경우에 사용하는 것이 좋다. 그리고 해당 라이브러리는 iteration 중 remove를 지원하지 않는다.

 

해당 라이브러리가 스레드 안전한 이유는 iteration을 사용할때, iteration을 새로 생성하지 않는 이상 내부적으로 가지고 있는 List의 스냅샷에 의존하기 때문에 여러 스레드에 안전하다.

 

즉, 변경 작업과 읽기 작업에 사용되는 오브젝트가 서로 다르다.(복제본)

posted by 여성게
:
Web/Spring 2019. 10. 12. 12:49

 

이번에 다루어볼 포스팅은 커스텀 어노테이션과 인터셉터를 이용하여 특정 컨트롤러의 매핑 메서드에 전처리(인증 등)를 하는 예제이다. 즉, 커스텀하게 어노테이션을 정의하고 컨트롤러 혹은 컨트롤러 메서드에 어노테이션을 붙여(마치 @RequestMapping과 같은) 해당 컨트롤러 진입전에 전처리(인증)을 하는 예제이다. 물론, Allow Ip 같은 것은 필터에서 처리하는 것이 더 맞을 수 있지만, 어떠한 API는 인증이 필요하고 어떠한 것은 필요없고 등의 인증,인가 처리는 인터셉터가 더 적당한 것 같다. 이번 포스팅을 이해하기 위해서는 Spring MVC의 구동 방식을 개념정도는 알고 하는 것이 좋을 듯하다. 아래 포스팅을 확인하자.

 

 

spring의 다양한 intercept 방법 (Servlet Filter, HandlerInterceptor, PreHandle, AOP)

1. 다양한 intercept 방법들과 주 사용처 Servlet Filter : 인코딩, 인증, 압축, 변환 등 HandlerInterceptor : 세션, 쿠키, 검증 등 AOP : 비즈니스단 로깅, 트랜잭션, 에러처리 등 2. Servlet Filter 와 Handler..

sjh836.tistory.com

 

개발 방향은 컨트롤러에 인증을 뜻하는 어노테이션을 커스텀하게 붙이고, 인터셉터에서 어노테이션의 유무를 판단하여 인증을 시도한다. 특별히 어려운 코드는 없어 간략한 설명만 하고 넘어가겠다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public enum Auth {
 
    NONE,AUTH
 
}
 
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsAuth {
 
    Auth isAuth() default Auth.NONE;
 
}
 
@Slf4j
@Component
public class CommonInterceptor implements HandlerInterceptor {
 
    private Map<String,User> userMap = new HashMap<>();
 
    @PostConstruct
    public void setup() {
        User user = new User("","yeoseong_gae",28);
        userMap.put("yeoseong-gae",user);
    }
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("interceptor");
        String userId = request.getParameter("userId");
 
        IsAuth annotation = getAnnotation((HandlerMethod)handler, IsAuth.class);
 
        Auth auth = null;
 
        if(!ObjectUtils.isEmpty(annotation)){
            auth = annotation.isAuth();
            //NONE이면 PASS
            if(auth == Auth.AUTH){
                if(ObjectUtils.isEmpty(userMap.get(userId))){
                    log.info("auth fail");
                    throw new AuthenticationException("유효한 사용자가 아닙니다.");
                }
            }
        }
        return true;
    }
 
    private <extends Annotation> A getAnnotation(HandlerMethod handlerMethod, Class<A> annotationType) {
        return Optional.ofNullable(handlerMethod.getMethodAnnotation(annotationType))
                .orElse(handlerMethod.getBeanType().getAnnotation(annotationType));
    }
 
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class User{
        private String id;
        private String name;
        private int age;
    }
}
 
@Slf4j
@RestControllerAdvice
public class CtrlAdvice {
 
    @ExceptionHandler(value = {Exception.class})
    protected ResponseEntity<String> example(Exception exception,
                                             Object body,
                                             WebRequest request) throws JsonProcessingException {
        log.debug("RestCtrlAdvice");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("message:"+exception.getMessage());
    }
 
}
 
@RestController
public class AuthController {
 
    @IsAuth(isAuth = Auth.AUTH)
    @GetMapping("/auth")
    public boolean acceptUser(@RequestParam String userId){
        return true;
    }
 
    @IsAuth
    @GetMapping("/nonauth")
    public boolean acceptAll(@RequestParam String userId){
        return true;
    }
}
cs

 

마지막으로 인터셉터를 등록하기 위한 Config 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
@RequiredArgsConstructor
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
 
    private final CommonInterceptor commonInterceptor;
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(commonInterceptor);
    }
}
cs

 

커스텀한 어노테이션을 정의하고, 컨트롤러 매핑 메서드중 인증을 하고 싶은 곳에 어노테이션을 등록해준다. 그리고 인터셉터에서 현재 찾은 핸들러의 핸들러메서드에 우리가 등록한 커스텀한 어노테이션이 있는지 혹은 해당 핸들러메서드의 클래스에 붙어있는지 등을 리플렉션을 이용하여 확인한다. 만약 있다면 유효한 사용자인지 판단한다. 참고로 인터셉터에서는 디스패처 서블릿 더 뒷단에서 구동하기 때문에 현재 매핑된 핸들러 메서드의 접근이 가능하다.

 

마지막으로 인증이 실패한다면 예외를 발생시키고, 컨트롤러 어드바이스를 이용하여 예외를 처리한다. 참고로 인터셉터는 앞에서 말한 것과 같이 디스패처 서블릿 뒷단에서 구동되기때문에 ControllerAdvice 사용이 가능하다.

 

현재 다루고 있는 예제는 하나의 예시일 뿐이다. 사실 스프링 시큐리티를 사용한다면 굳이 필요없는 예제일수 있다. 하지만 인증뿐아니라 여러가지로 사용될수 있기에 응용하여 사용하면 될듯하다. 해당 코드는 절대 실무에선 사용될 수없는 로직을 갖고 있다.(예제를 위해 간단히 작성한 코드이기에) 컨셉을 갖고 더 완벽한 코드로 완성해야 한다. 만약 해당 코드가 필요하다면 밑의 깃헙에 소스를 올려놓았다.

 

 

 

yoonyeoseong/useAnnotationAndInterceptorAuth

Contribute to yoonyeoseong/useAnnotationAndInterceptorAuth development by creating an account on GitHub.

github.com

 

posted by 여성게
:

네이버의 d2 블로그에 Java Hashmap 동작에 대해 아주 상세히 설명한 자료가 있어 참조해보았다. 해시맵에 대해 아주 상세하게 작성한 글이라 알아두면 아주 좋을 것 같다.

 

참조 : https://d2.naver.com/helloworld/831311

 

Java HashMap은 어떻게 동작하는가?

이 글은 Java 7과 Java 8을 기준으로 HashMap이 어떻게 구현되어 있는지 설명합니다. HashMap 자체의 소스 코드는 Oracle JDK나 OpenJDK나 같기 때문에, 이 글이 설명하는 HashMap 구현 방식은 Oracle JDK와 OpenJDK 둘 모두에 해당한다고 할 수 있습니다. Java가 아닌 다른 언어를 주로 사용하는 개발자라 하더라도, Java의 HashMap이 현재 어떻게 구현되어 있고, 어떻게 발전되었는지 알면 라이브러리나 프레임워크 구현에 대한 혜안을 얻을 수 있을 것이라고 기대합니다.

HashMap은 Java Collections Framework에 속한 구현체 클래스입니다. Java Collections Framework는 1998년 12월에 발표한 Java 2에서 정식으로 선보였습니다. Map 인터페이스 자체는 Java 5에서 Generic이 적용된 것 외에 처음 선보인 이후 변화가 없지만, HashMap 구현체는 성능을 향상시키기 위해 지속적으로 변화해 왔습니다.

이 글에서는 어떤 방식으로 HashMap 구현체의 성능을 향상시켰는지 소개합니다. 구체적으로 다루는 내용은 Amortized Constant Time을 위하여 어떻게 해시 충돌(hash collision) 가능성을 줄이고 있는가에 대한 것입니다.

HashMap과 HashTable

이 글에서 말하는 HashMap과 HashTable은 Java의 API 이름이다. HashTable이란 JDK 1.0부터 있던 Java의 API이고, HashMap은 Java 2에서 처음 선보인 Java Collections Framework에 속한 API다. HashTable 또한 Map 인터페이스를 구현하고 있기 때문에 HashMap과 HashTable이 제공하는 기능은 같다. 다만 HashMap은 보조 해시 함수(Additional Hash Function)를 사용하기 때문에 보조 해시 함수를 사용하지 않는 HashTable에 비하여 해시 충돌(hash collision)이 덜 발생할 수 있어 상대으로 성능상 이점이 있다. 보조 해시 함수가 아니더라도, HashTable 구현에는 거의 변화가 없는 반면, HashMap은 지속적으로 개선되고 있다. HashTable의 현재 가치는 JRE 1.0, JRE 1.1 환경을 대상으로 구현한 Java 애플리케이션이 잘 동작할 수 있도록 하위 호환성을 제공하는 것에 있기 때문에, 이 둘 사이에 성능과 기능을 비교하는 것은 큰 의미가 없다고 할 수 있다.

HashMap과 HashTable을 정의한다면, '키에 대한 해시 값을 사용하여 값을 저장하고 조회하며, 키-값 쌍의 개수에 따라 동적으로 크기가 증가하는 associate array'라고 할 수 있다. 이 associate array를 지칭하는 다른 용어가 있는데, 대표적으로 Map, Dictionary, Symbol Table 등이다.

예제 1 HashTable과 HashMap의 선언부

associative array를 지칭하기 위하여 HashTable에서는 Dictionary라는 이름을 사용하고, HashMap에서는 그 명칭이 그대로 말하듯이 Map이라는 용어를 사용하고 있다. 

map(또는 mapping)은 원래 수학 함수에서의 대응 관계를 지칭하는 용어로, 경우에 따라서는 함수 자체를 의미하기도 한다. 즉 HashMap이란 이름에서 알 수 있듯이, HashMap은 키 집합인 정의역과 값 집합인 공역의 대응에 해시 함수를 이용한다. 

그림 1 함수에서의 사상(map)

해시 분포와 해시 충돌

동일하지 않은 어떤 객체 X와 Y가 있을 때, 즉 X.equals(Y)가 '거짓'일 때 X.hashCode() != Y.hashCode()가 같지 않다면, 이때 사용하는 해시 함수는 완전한 해시 함수(perfect hash functions)라고 한다(

: S는 모든 객체의 집합, h는 해시 함수). 

Boolean같이 서로 구별되는 객체의 종류가 적거나, Integer, Long, Double 같은 Number 객체는 객체가 나타내려는 값 자체를 해시 값으로 사용할 수 있기 때문에 완전한 해시 함수 대상으로 삼을 수 있다. 하지만 String이나 POJO(plain old java object)에 대하여 완전한 해시 함수를 제작하는 것은 사실상 불가능하다. 

적은 연산만으로 빠르게 동작할 수 있는 완전한 해시 함수가 있다고 하더라도, 그것을 HashMap에서 사용할 수 있는 것은 아니다. HashMap은 기본적으로 각 객체의 hashCode() 메서드가 반환하는 값을 사용하는 데, 결과 자료형은 int다. 32비트 정수 자료형으로는 완전한 자료 해시 함수를 만들 수 없다. 논리적으로 생성 가능한 객체의 수가 232보다 많을 수 있기 때문이며, 또한 모든 HashMap 객체에서 O(1)을 보장하기 위해 랜덤 접근이 가능하게 하려면 원소가 232인 배열을 모든 HashMap이 가지고 있어야 하기 때문이다. 

따라서 HashMap을 비롯한 많은 해시 함수를 이용하는 associative array 구현체에서는 메모리를 절약하기 위하여 실제 해시 함수의 표현 정수 범위 

보다 작은 M개의 원소가 있는 배열만을 사용한다. 따라서 다음과 같이 객체에 대한 해시 코드의 나머지 값을 해시 버킷 인덱스 값으로 사용한다. 

예제 2 해시를 사용하는 associative array 구현체에서 저장/조회할 해시 버킷을 계산하는 방법

이 코드와 같은 방식을 사용하면, 서로 다른 해시 코드를 가지는 서로 다른 객체가 1/M의 확률로 같은 해시 버킷을 사용하게 된다. 이는 해시 함수가 얼마나 해시 충돌을 회피하도록 잘 구현되었느냐에 상관없이 발생할 수 있는 또 다른 종류의 해시 충돌이다. 이렇게 해시 충돌이 발생하더라도 키-값 쌍 데이터를 잘 저장하고 조회할 수 있게 하는 방식에는 대표적으로 두 가지가 있는데, 하나는 Open Addressing이고, 다른 하나는 Separate Chaining이다. 이 둘 외에도 해시 충돌을 해결하기 위한 다양한 자료 구조가 있지만, 거의 모두 이 둘을 응용한 것이라고 할 수 있다. 

그림 2 Open Addressing과 Separate Chaining 구조

Open Addressing은 데이터를 삽입하려는 해시 버킷이 이미 사용 중인 경우 다른 해시 버킷에 해당 데이터를 삽입하는 방식이다. 데이터를 저장/조회할 해시 버킷을 찾을 때에는 Linear Probing, Quadratic Probing 등의 방법을 사용한다. 

Separate Chaining에서 각 배열의 인자는 인덱스가 같은 해시 버킷을 연결한 링크드 리스트의 첫 부분(head)이다. 

둘 모두 Worst Case O(M)이다. 하지만 Open Addressing은 연속된 공간에 데이터를 저장하기 때문에 Separate Chaining에 비하여 캐시 효율이 높다. 따라서 데이터 개수가 충분히 적다면 Open Addressing이 Separate Chaining보다 더 성능이 좋다. 하지만 배열의 크기가 커질수록(M 값이 커질수록) 캐시 효율이라는 Open Addressing의 장점은 사라진다. 배열의 크기가 커지면, L1, L2 캐시 적중률(hit ratio)이 낮아지기 때문이다. 

Java HashMap에서 사용하는 방식은 Separate Channing이다. Open Addressing은 데이터를 삭제할 때 처리가 효율적이기 어려운데, HashMap에서 remove() 메서드는 매우 빈번하게 호출될 수 있기 때문이다. 게다가 HashMap에 저장된 키-값 쌍 개수가 일정 개수 이상으로 많아지면, 일반적으로 Open Addressing은 Separate Chaining보다 느리다. Open Addressing의 경우 해시 버킷을 채운 밀도가 높아질수록 Worst Case 발생 빈도가 더 높아지기 때문이다. 반면 Separate Chaining 방식의 경우 해시 충돌이 잘 발생하지 않도록 '조정'할 수 있다면 Worst Case 또는 Worst Case에 가까운 일이 발생하는 것을 줄일 수 있다(여기에 대해서는 "보조 해시 함수"에서 설명하겠다).

예제 3 Java 7에서의 해시 버킷 관련 구현

Separate Chaining 방식을 사용하기 때문에, Java 7에서의 put() 메서드 구현은 예제 4에서 보는 것과 같다. 

예제 4 put() 메서드 구현

그러나 Java 8에서는 예제 4에서 볼 수 있는 것보다 더 발전된 방식을 사용한다.

Java 8 HashMap에서의 Separate Chaining

Java 2부터 Java 7까지의 HashMap에서 Separate Chaining 구현 코드는 조금씩 다르지만, 구현 알고리즘 자체는 같았다. 만약 객체의 해시 함수 값이 균등 분포(uniform distribution) 상태라고 할 때, get() 메서드 호출에 대한 기댓값은 

이다. 그러나 Java 8에서는 이보다 더 나은 

을 보장한다. 데이터의 개수가 많아지면, Separate Chaining에서 링크드 리스트 대신 트리를 사용하기 때문이다.

데이터의 개수가 많아지면 

 

의 차이는 무시할 수 없다. 게다가 실제 해시 값은 균등 분포가 아닐뿐더러, 설사 균등 분포를 따른다고 하더라도 birthday problem이 설명하듯 일부 해시 버킷 몇 개에 데이터가 집중될 수 있다. 그래서 데이터의 개수가 일정 이상일 때에는 링크드 리스트 대신 트리를 사용하는 것이 성능상 이점이 있다.

링크드 리스트를 사용할 것인가 트리를 사용할 것인가에 대한 기준은 하나의 해시 버킷에 할당된 키-값 쌍의 개수이다. 예제 5에서 보듯 Java 8 HashMap에서는 상수 형태로 기준을 정하고 있다. 즉 하나의 해시 버킷에 8개의 키-값 쌍이 모이면 링크드 리스트를 트리로 변경한다. 만약 해당 버킷에 있는 데이터를 삭제하여 개수가 6개에 이르면 다시 링크드 리스트로 변경한다. 트리는 링크드 리스트보다 메모리 사용량이 많고, 데이터의 개수가 적을 때 트리와 링크드 리스트의 Worst Case 수행 시간 차이 비교는 의미가 없기 때문이다. 8과 6으로 2 이상의 차이를 둔 것은, 만약 차이가 1이라면 어떤 한 키-값 쌍이 반복되어 삽입/삭제되는 경우 불필요하게 트리와 링크드 리스트를 변경하는 일이 반복되어 성능 저하가 발생할 수 있기 때문이다.

예제 5 Java 8 HashMap의 TREEIFY_THRESHOLD와 UNTREEIFY_THRESHOLD

Java 8 HashMap에서는 Entry 클래스 대신 Node 클래스를 사용한다. Node 클래스 자체는 사실상 Java 7의 Entry 클래스와 내용이 같지만, 링크드 리스트 대신 트리를 사용할 수 있도록 하위 클래스인 TreeNode가 있다는 것이 Java 7 HashMap과 다르다.

이때 사용하는 트리는 Red-Black Tree인데, Java Collections Framework의 TreeMap과 구현이 거의 같다. 트리 순회 시 사용하는 대소 판단 기준은 해시 함수 값이다. 해시 값을 대소 판단 기준으로 사용하면 Total Ordering에 문제가 생기는데, Java 8 HashMap에서는 이를 tieBreakOrder() 메서드로 해결한다.

예제 6 Java 8 HashMap의 Node 클래스

해시 버킷 동적 확장

해시 버킷의 개수가 적다면 메모리 사용을 아낄 수 있지만 해시 충돌로 인해 성능상 손실이 발생한다. 그래서 HashMap은 키-값 쌍 데이터 개수가 일정 개수 이상이 되면, 해시 버킷의 개수를 두 배로 늘린다. 이렇게 해시 버킷 개수를 늘리면 

값도 작아져, 해시 충돌로 인한 성능 손실 문제를 어느 정도 해결할 수 있다.

해시 버킷 개수의 기본값은 16이고, 데이터의 개수가 임계점에 이를 때마다 해시 버킷 개수의 크기를 두 배씩 증가시킨다. 버킷의 최대 개수는 230개다. 그런데 이렇게 버킷 개수가 두 배로 증가할 때마다, 모든 키-값 데이터를 읽어 새로운 Separate Chaining을 구성해야 하는 문제가 있다. HashMap 생성자의 인자로 초기 해시 버킷 개수를 지정할 수 있으므로, 해당 HashMap 객체에 저장될 데이터의 개수가 어느 정도인지 예측 가능한 경우에는 이를 생성자의 인자로 지정하면 불필요하게 Separate Chaining을 재구성하지 않게 할 수 있다.

예제 7 Java 7 HashMap에서의 해시 버킷 확장

해시 버킷 크기를 두 배로 확장하는 임계점은 현재의 데이터 개수가 'load factor * 현재의 해시 버킷 개수'에 이를 때이다. 이 load factor는 0.75 즉 3/4이다. 이 load factor 또한 HashMap의 생성자에서 지정할 수 있다.

임계점에 이르면 항상 해시 버킷 크기를 두 배로 확장하기 때문에, N개의 데이터를 삽입했을 때의 키-값 쌍 접근 횟수는 다음과 같이 분석할 수 있다.

즉 기본 생성자로로 생성한 HashMap을 이용하여 많은 양의 데이터를 삽입할 때에는, 최적의 해시 버킷 개수를 지정한 것보다 약 2.5배 많이 키-값 쌍 데이터에 접근해야 한다. 이는 곧 수행 시간이 2.5배 길어진다고 할 수 있다. 따라서 성능을 높이려면, HashMap 객체를 생성할 때 적정한 해시 버킷 개수를 지정해야 한다.

그런데 이렇게 해시 버킷 크기를 두 배로 확장하는 것에는 결정적인 문제가 있다. 해시 버킷의 개수 M이 2a 형태가 되기 때문에, index = X.hashCode() % M을 계산할 때 X.hashCode()의 하위 a개의 비트만 사용하게 된다는 것이다. 즉 해시 함수가 32비트 영역을 고르게 사용하도록 만들었다 하더라도 해시 값을 2의 승수로 나누면 해시 충돌이 쉽게 발생할 수 있다.

이 때문에 보조 해시 함수가 필요하다.

보조 해시 함수

index = X.hashCode() % M을 계산할 때 사용하는 M 값은 소수일 때 index 값 분포가 가장 균등할 수 있다. 그러나 M 값이 소수가 아니기 때문에 별도의 보조 해시 함수를 이용하여 index 값 분포가 가급적 균등할 수 있도록 해야 한다. 

보조 해시 함수(supplement hash function)의 목적은 '키'의 해시 값을 변형하여, 해시 충돌 가능성을 줄이는 것이다. 이 보조 해시 함수는 JDK 1.4에 처음 등장했다. Java 5 ~ Java 7은 같은 방식의 보조 해시 함수를 사용하고, Java 8부터는 다시 새로운 방식의 보조 해시 함수를 사용하고 있다. 

예제 8 Java 7 HashMap에서의 보조 해시 함수

그런데 Java 8에서는 Java 7보다 훨씬 더 단순한 형태의 보조 해시 함수를 사용한다. 

예제 9 Java 8 HashMap에서의 보조 해시 함수

예제 9에서 볼 수 있는 것처럼, Java 8 HashMap 보조 해시 함수는 상위 16비트 값을 XOR 연산하는 매우 단순한 형태의 보조 해시 함수를 사용한다. 이유로는 두 가지가 있는데, 첫 번째는 Java 8에서는 해시 충돌이 많이 발생하면 링크드 리스트 대신 트리를 사용하므로 해시 충돌 시 발생할 수 있는 성능 문제가 완화되었기 때문이다. 두 번째로는 최근의 해시 함수는 균등 분포가 잘 되게 만들어지는 경향이 많아, Java 7까지 사용했던 보조 해시 함수의 효과가 크지 않기 때문이다. 두 번째 이유가 좀 더 결정적인 원인이 되어 Java 8에서는 보조 해시 함수의 구현을 바꾸었다. 

개념상 해시 버킷 인덱스를 계산할 때에는 index = X.hashCode() % M처럼 나머지 연산을 사용하는 것이 맞지만, M값이 2a일 때는 해시 함수의 하위 a비트 만을 취한 것과 값이 같다. 따라서 나머지 연산 대신 '1 << a – 1' 와 비트 논리곱(AND, &) 연산을 사용하면 수행이 훨씬 더 빠르다. 

String 객체에 대한 해시 함수

String 객체에 대한 해시 함수 수행 시간은 문자열 길이에 비례한다. 

때문에 JDK 1.1에서는 String 객체에 대해서 빠르게 해시 함수를 수행하기 위해, 일정 간격의 문자에 대한 해시를 누적한 값을 문자열에 대한 해시 함수로 사용했다. 

예제 10 JDK 1.1에서의 String 클래스 해시 함수

예제 10에서 볼 수 있듯이 모든 문자에 대한 해시 함수를 계산하는 게 아니라, 문자열의 길이가 16을 넘으면 최소 하나의 문자를 건너가며 해시 함수를 계산했다. 

그러나 이런 방식은 심각한 문제를 야기했다. 웹상의 URL은 길이가 수십 글자에 이르면서 앞 부분은 동일하게 구성되는 경우가 많다. 이 경우 서로 다른 URL의 해시 값이 같아지는 빈도가 매우 높아질 수 있다는 문제가 있다. 따라서 이런 방식은 곧 폐기되었고, 예제 11에서 보는 방식을 현재의 Java 8까지도 계속 사용하고 있다. 

예제 11 Java String 클래스 해시 함수

예제 11은 Horner's method를 구현한 것이다. Horner's method는 다항식을 계산하기 쉽도록 단항식으로 이루어진 식으로 표현하는 것이다. 즉 예제 11에서 계산하고자 하는 해시 값 h는 다음과 같다. 

이렇게 단항식을 재귀적으로 사용하여 다항식 연산을 표현할 수 있다. 

String 객체 해시 함수에서 31을 사용하는 이유는, 31이 소수이며 또한 어떤 수에 31을 곱하는 것은 빠르게 계산할 수 있기 때문이다. 31N=32N-N인데, 32는 25이니 어떤 수에 대한 32를 곱한 값은 shift 연산으로 쉽게 구현할 수 있다. 따라서 N에 31을 곱한 값은, (N << 5) – N과 같다. 31을 곱하는 연산은 이렇게 최적화된 머신 코드로 생성할 수 있기 때문에, String 클래스에서 해시 값을 계산할 때에는 31을 승수로 사용한다. 

Java 7에서 String 객체에 대한 별도의 해시 함수

JDK 7u6부터 JDK 7u25까지는 HashMap에 저장된 키-값 쌍이 일정 개수 이상이면 String 객체에 한하여 별도의 해시 함수를 사용할 수 있게 하는 기능이 있다. 이 기능은 JDK 7u40부터는 삭제되었고, 당연히 Java 8에도 해당 기능은 없다. 여기서 말하는 '일정 개수 이상'이나 '별도의 해시 함수 사용 여부 지정'은 JVM을 가동할 때 옵션으로 지정할 수 있다. 

예제 12 Java 7의 String에 대한 hash32() 메서드

JDK 7u6부터 JDK 7u25까지는 jdk.map.althashing.threshold 옵션을 지정하면, HashMap에 저장된 키-값 쌍이 일정 개수 이상일 때 String 객체에 String 클래스의 hashCode() 메서드 대신 sun.misc.Hashing.stringHash32() 메서드를 사용할 수 있게 했다. sun.misc.Hashing.stringHash32() 메서드는 String 클래스의 hash32() 메서드를 호출하게 한 것이고, hash32() 메서드는 MurMur 해시를 구현한 것이다. 이 MurMur 해시를 이용하여 String 객체에 대한 해시 충돌을 매우 낮출 수 있었다고 한다. 

그러나 부작용도 있다. MurMur 해시는 hash seed를 필요로 하는데, 이를 위한 것이 sun.misc.Hashing.randomHashSeed() 메서드다. 이 메서드에서는 Random.nextInt() 메서드를 사용한다. Random.nextInt() 메서드는 compare and swap 연산(이하 CAS 연산)을 사용하는 AtomicLong을 사용하는데, CAS 연산은 코어가 많을수록 성능이 떨어진다. 즉 JDK 7u6부터 등장한 String 객체에 대한 별도의 해시 함수는 멀티 코어 환경에서는 성능이 하락했고, 이런 문제로 인해 JDK 7u40부터는 해당 기능을 사용하지 않는다. 당연히 Java 8도 사용하지 않는다.

posted by 여성게
:

초심으로 돌아갈 때가 된 것 같다. 오늘 포스팅할 내용은 JVM(Java Virtual Machine)이다. 사실 지금까지는 스킬 베이스의 공부만 해왔었다. 하지만 점점 개발을 하다보니 성능이라는 것에 굉장히 큰 관심이 생겼다. Java의 성능에 핵심인 JVM 튜닝을 다루어 보기 전에 우선 JVM이 무엇인지 알아보자.

 

JVM이란?

JVM(Java Virtual Machine)을 어떻게 정의할 것인가? 우선 원어를 먼저 알아보자. 처음의 "Java"는 프로그램 언어인 Java를 의미한다. 그리고 Virtual Machine은 물리적인 형태가 아닌 소프트웨어로서의 개념을 의미한다. 즉, 소프트웨어적으로 Java 언어를 실행시키기 위한 Machine을 말하는 것이다. 결국 JVM은 정의된 스펙(벤더사마다 다른 JVM 구현을 가진다. 하지만 표준은 존재.)을 구현한 하나의 독자적인 프로세스 형태로 구동되는 Runtime Instance라고 할 수 있다. 따라서 JVM의 역할은 개발자들이 작성한 Java 프로그램을 실행시키는 하나의 데몬이라고 볼 수 있는 것이다.

 

아래그림은 JVM의 내부구조를 보여준다.

 

 

  • Java Source : 사용자가 작성한 Java 코드이다(.java)
  • Java Compiler : Java Source 파일을 JVM이 해석할 수 있는 Java Byte Code로 변경한다.
  • Java Byte Code : Java Compiler에 의해 컴파일된 결과물이다.(.class)
  • Class Loader : JVM 내로 .class 파일들을 로드하여 Runtime Data Areas에 배치한다.
  • Execution Engine : 로드된 클래스의 Bytecode를 해석(Interpret)한다.
  • Runtime Data Area : JVM이라는 프로세스가 프로그램을 수행하기 위해 OS에서 할당 받은 메모리 공간이다.

 

 

Java코드를 JVM으로 실행시키는 과정을 간단하게 설명하면, 자바 컴파일러에 의해 자바코드가 클래스 파일로 컴파일된 후 클래스로더로 해당 클래스 파일들을 로드 시킨다. 이후 로딩된 바이트코드를 실행엔진을 이용하여 해석(Interpret)하여 런타임 데이터 영역에 배치시킨다. 하지만 매번 인터프리터하여 한줄씩 해석하는 데에는 시간이 오래 걸리기 때문에 JIT(Just In Time) 컴파일러를 이용한다. JIT 컴파일러를 이용하지 않는다면 바이트코드를 사용할 때마다 인터프리터해서 사용해야 하기 때문에 실행시간이 굉장히 느리다. 하지만 적절한 시점에 JIT 컴파일러가 바이트코드를 컴파일하여 네이트브 코드로 캐싱해놓는 것이다. 여기서 컴파일한다는 것은 인터프리터처럼 한줄 단위로 컴파일하는 것이 아니라 실행하는 전체 코드를 컴파일하고 캐시하므로 이 또한 시간이 꽤 걸린다. 하지만 한번 컴파일된 이후 캐싱되니 그 이후로는 빠른 실행을 보장할 것이다.(한번 실행되고 말 것들은 인터프리터 하고 주기적으로 자주 수행되는 것들은 체킹을 통해 일정 횟수가 넘으면 JIT 컴파일러로 전체 컴파일한다.) 이렇게 해석된 녀석들은 Runtime Data Areas에 배치한다.

 

그렇다면 Runtime Data Areas는 어떠한 메모리 구조를 갖고 있을까? 한번 살펴보자!

 

 

  • Method Areas : 클래스, 변수, Method, static 변수, 상수 정보 등이 저장되는 영역이다.(모든 Thread가 공유한다.)
  • Heap Area : new 명령어로 생성된 인스턴스와 객체가 저장되는 구역이다.(Garbage Collection 이슈는 이 영역에서 일어나며, 모든 Thread가 공유한다.)
  • Stack Area : Java Method 내에서 사용되는 값들(매개변수, 지역변수, 리턴값 등)이 저장되는 구역으로 메소드가 호출될 때 LIFO로 하나씩 생성되고, 메소드 실행이 완료되면 LIFO로 하나씩 지워진다.(각 Thread별로 하나씩 생성된다.)
  • PC Register : CPU의 Register와 역할이 비슷하다. 현재 수행 중인 JVM 명령의 주소값이 저장된다.(각 Thread별로 하나씩 생성된다.)
  • Native Method Stack : 다른 언어(C/C++ 등)의 메소드 호출을 위해 할당되는 구역으로 언어에 맞게 Stack이 형성되는 구역이다.

Java Heap(Hotspot JVM의 Heap)

Java의 메모리 구조는 Heap이 전부는 아니다. Thread 공유의 정보는 Stack에 저장이 되고 Class나 Method 정보, Bytecode등은 Method Areas에 저장된다. Java Heap은 Instance, Array 객체 두 가지 종류만 저장되는 공간이다. 모든 Thread들에 의해 공유되는 영역이다. 같은 애플리케이션을 사용하는 Thread 사이에서는 공유된 Heap Data를 이용할 때 동기화 이슈가 발생할 수 있다.

JVM은 Java Heap에 메모리를 할당하는 메소드(ex.new 연산)만 존재하고 메모리 해제를 위한 어떤 Java Code를 직접적으로 프로그래머가 명시하지 않는다.(물론 있긴하지만 사용하지 않는 편이 낫다.) Java Heap의 메모리 해제는 오로지 Garbage Collection을 통해서만 수행한다.

 

지금 다루어볼 Heap의 구조는 Hotspot JVM의 Heap 구조를 다루어 볼 것이다. 하지만 Hotspot JVM Heap 구조는 Java 8 버전 전후로 구조가 바뀌었다. 해당 내용은 기술 면접에서도 나올 만한 내용이다. 왜냐? 지금은 대부분 Java 1.8 이상을 사용하고 있기 때문이다. 아래 그림을 참조해보자. 

 

Java 1.7 이전

 

Java 1.8 이후

 

달라진 부분이 Perm 영역이다. Metaspace 영역으로 대체된 것이 보인다. 이것은 뒤에서 설명할 것이다.

 

Hotspot JVM은 크게 Young Generation과 Old Generation으로 나누어져 있다. Young Generation은 Eden 영역과 Suvivor 영역으로 구성되는데 Eden 영역은  Object가 Heap에 최초로 할당되는 장소이며 Eden 영역이 꽉 차게 되면 Object의 참조 여부를 따져 만약 참조가 되어 있는 Live Object이면 Suvivor 영역으로 넘기고, 참조가 끊어진 Garbage Object 이면 그냥 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 모두 청소한다.

 

Suvivor 영역은 말 그대로 Eden 영역에서 살아남은 Object들이 잠시 머무르는 곳이다. 이 Suvivor 영역은 두 개로 구성되는데 Live Object를 대피시킬 때는 하나의 Survivor 영역만 사용하게 된다. 이러한 전반의 과정을 Minor GC라고 한다.

 

Young Generation에서 Live Object로 오래 살아남아 성숙된 Object는 Old Generation으로 이동하게 된다. 여기서 성숙된 Object란 의미는 애플리케이션에서 특정 회수 이상 참조되어 기준 Age를 초과한 Object를 말한다. 즉, Old Generation 영역은 새로 Heap에 할당되는 Object가 들어오는 것이 아니라, 비교적 오랫동안 참조되어 이용되고 있고 앞으로도 계속 사용될 확률이 높은 Object들이 저장되는 영역이다. 이러한 Promotion 과정 중 Old Generation의 메모리도 충분하지 않으면 해당 영역에서도 GC가 발생하는데 이를 Full GC(Major GC)라고 한다.

 

Java 1.7 기준의 Perm 영역은 보통 Class의 Meta 정보나 Method의 Meta 정보, Static 변수와 상수 정보들이 저장되는 공간이다. 이 영역은 Java 1.8부터는 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다.(다만, 기존 Perm 영역에 존재하던 Static Object는 Heap 영역으로 옮겨져 최대한 GC 대상이 될 수 있도록 하였다.)

 

조금 더 1.7과 1.8 메모리 구조의 변경에 대해 설명하자면, Java 1.8에서 JVM 메모리의 구조적인 개선 사항으로 Perm 영역이 Metaspace 영역으로 전환되고 기존 Perm 영역은 사라지게 되었다. Metaspace 영역은 Heap이 아닌 Native 메모리 영역으로 취급하게 된다. (이 말은 기존의 Perm은 Heap 안에 존재하는 것으로 알 수 있다.) Native 메모리는 Heap과 달리 OS레벨에서 관리 하는 영역이다.

 

Perm과 Metaspace를 표로 구분해 보았다.

구분 상세 구분 Perm Metaspace
저장 정보 클래스의 메타 정보 저장 저장
메소드의 메타 정보 저장 저장
Static 변수, 상수 저장 Heap 영역으로 이동
관리 포인트 메모리 관리(튜닝)

Heap 영역 튜닝

Perm 영역도 별도로 튜닝해야함

Heap 영역 튜닝

Native 영역 동적 조정

(별도 옵션으로 조절 가능)

GC 측면 GC 수행 대상 Full GC 수행 대상 Full GC 수행 대상
메모리 측면 메모리 크기(옵션)

-XX:PermSize

-XX:MaxPermSize

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

 

Java 1.8로 넘어오면서 주의해야 할 점이 있다. 아래 명령어 결과를 보자.

Metaspace가 잡고 있는 Max Size가 약 16Exabyte이다. 이 수치는 64bit 프로세서가 취급할 수 있는 메모리 상한치라 할 수 있다. Metaspace 영역은 Native 메모리로 다루기 때문에 기본적으로 JVM에 의해 크기가 강제되지 않고 프로세스가 이용할 수 있는 메모리 자원을 최대한 활용할 수 있다고 본다. 만약 Classloader에 메모리 누수가 의심되는 경우 -XX:MaxMetaspaceSize를 지정할 필요성이 있다. 왜냐하면  최대 값을 정해놓지 않는다면 Metaspace 영역의 메모리가 계속해서 커질 것이고 결국은 서버가 뻑나는 경우가 생길 수 있다.

 

오늘은 여기까지 간단히 JVM 구조에 대해 다루어보았다. 다음 포스팅은 Heap에서 이루어지는 GC에 대한 포스팅을 할 예정이다. 자바 성능에 아주 중요한 요인중 하나인 GC는 튜닝 관점에서 아주 중요한 관리 포인트이기도 하다.

posted by 여성게
:

 

RestTemplate으로 다른 API를 호출하고 특정 객체 타입으로 JSON을 parsing 하는 상황이었다. 그런데 해당 특정 객체는 내부적으로 Inner Class를 가지고 있는 상황이었는데, 아래와 같은 예외가 발생하였다.

 

예외:can only instantiate non-static inner class by using default no-argument constructor

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Getter
@Setter
@ToString
public class Outer {
    
    @Getter
    @Setter
    @ToString
    @NoArgsConstructor
    public class Inner1{
        
        private String[] a;
        private double[] b;
    }
    
    @Getter
    @Setter
    @ToString
    @NoArgsConstructor
    public class Inner2{
        
        private String[] a;
    }
    
    private long id;
    private Inner1[] a;
    private Inner2[] b;
    private int status;
    
}
cs

 

무슨 일 일까? 분명 매개변수 없는 생성자로 생성하였는데, 위와 같은 예외가 발생했다니... 원인은 바로 Inner Class가 static(정적)으로 선언되지 않는 한 단독(Outer 클래스를 참조하지 않고)으로 Inner Class의 디폴트 생성자를 호출해 인스턴스를 생성할 수 없는 것이다. 즉, 위와 같은 예외를 피하려면 Inner Class를 별도의 클래스로 생성하던가, 아니면 static Inner Class로 선언해주어야 한다. 

posted by 여성게
:

Java - Collections.rotate() 란?



만약 List 객체에 [1,2,3,4,5] 요소들이 들어있다고 생각해보자. 

여기에서 Collections.rotate(list,2) 메소드를 호출한다면 맨뒤의 요소를 하나씩 두번꺼내서 맨앞 요소자리에 넣고

다른 요소들은 뒤로 한칸씩밀리게 되는 것이다.


이것을 Step으로 표현하면


1
2
3
4
5
6
7
8
9
10
@Test
public void CollectionsRotate(){
    List<Integer> list = new ArrayList<>();
    list.addAll(Arrays.asList(new Integer[] {1,2,3,4,5}));
    
    Collections.rotate(list, 1);
    System.out.println(Arrays.toString(list.toArray()));
    Collections.rotate(list, 1);
    System.out.println(Arrays.toString(list.toArray()));
}
cs




Collections.rotate(list,2)를 step을 두단계로 나누어서 결과를 출력하기 위해 Collections.rotate(list,1)을 두번 출력했다.

이것들의 결과는

>[5,1,2,3,4]

>[4,5,1,2,3]


이라는 결과를 출력하게된다. 이것은 쉽게 말하면 마지막 요소를 꺼내서 맨앞 요소자리로 넣는 것이고, 나머지 요소들은 방금꺼낸 요소의

자리까지 한칸 밀리게 되는 것이다.

posted by 여성게
:

Java - ThreadLocal 이란? 쓰레드로컬 사용법!



ThreadLocal(쓰레드로컬)이란?





쓰레드로컬이란 간단히 얘기하면 하나의 스레드의 작업 흐름동안에 전역변수처럼 무엇인가를 저장하여 사용할수 있다.
일반 변수의 수명은 특정 코드 블록(예, 메서드 범위, for 블록 범위 등) 범위 내에서만 유효하다.

{
    int a = 10;
    ...
   // 블록 내에서 a 변수 사용 가능
}
// 변수 a는 위 코드 블록이 끝나면 더 이상 유효하지 않다. (즉, 수명을 다한다.)


반면에 ThreadLocal을 이용하면 쓰레드 영역에 변수를 설정할 수 있기 때문에, 특정 쓰레드가 실행하는 모든 코드에서 그 쓰레드에 설정된 변수 값을 사용할 수 있게 된다. 아래 그림은 쓰레드 로컬 변수가 어떻게 동작하는 지를 간단하게 보여주고 있다. 



위 그림에서 주목할 점은 동일한 코드를 실행하는 데, 쓰레드1에서 실행할 경우 관련 값이 쓰레드1에 저장되고 쓰레드2에서 실행할 경우 쓰레드2에 저장된다는 점이다.



ThreadLocal의 기본 사용법

ThreadLocal의 사용방법은 너무 쉽다. 단지 다음의 네 가지만 해 주면 된다.
  1. ThreadLocal 객체를 생성한다.
  2. ThreadLocal.set() 메서드를 이용해서 현재 쓰레드의 로컬 변수에 값을 저장한다.
  3. ThreadLocal.get() 메서드를 이용해서 현재 쓰레드의 로컬 변수 값을 읽어온다.
  4. ThreadLocal.remove() 메서드를 이용해서 현재 쓰레드의 로컬 변수 값을 삭제한다.

아래 코드는 ThreadLocal의 기본적인 사용방법을 보여주고 있다.

// 현재 쓰레드와 관련된 로컬 변수를 하나 생성한다.
ThreadLocal<UserInfo> local = new ThreadLocal<UserInfo>();

// 로컬 변수에 값 할당
local.set(currentUser);

// 이후 실행되는 코드는 쓰레드 로컬 변수 값을 사용
UserInfo userInfo = local.get();


위 코드만으로는 ThreadLocal이 어떻게 동작하는 지 잘 이해가 되지 않을테니, 구체적인 예제를 이용해서 ThreadLocal의 동작 방식을 살펴보도록 하겠다. 먼저 ThreadLocal 타입의 static 필드를 갖는 클래스를 하나 작성해보자.

public class Context {
    public static ThreadLocal<Date> local = new ThreadLocal<Date>();
}

이제 Context 클래스를 사용해서 쓰레드 로컬 변수를 설정하고 사용하는 코드를 작성할 차례이다. 아래는 코드의 예이다.

class A {
    public void a() {
        Context.local.set(new Date());
        
        B b = new B();
        b.b();

        Context.local.remove();
    }
}

class B {
    public void b() {
        Date date = Context.local.get();

        C c = new C();
        c.c();
    }
}

class C {
    public void c() {
        Date date = Context.local.get();
    }
}

위 코드를 보면 A, B, C 세 개의 클래스가 존재하는데, A.a() 메서드를 호출하면 다음 그림과 같은 순서로 메서드가 실행된다.


위 그림에서 1~10은 모두 하나의 쓰레드에서 실행된다. ThreadLocal과 관련된 부분을 정리하면 다음과 같다.

  • 2 - A.a() 메서드에서 현재 쓰레드의 로컬 변수에 Date 객체를 저장한다.
  • 4 - B.b() 메서드에서 현재 쓰레드의 로컬 변수에 저장된 Date 객체를 읽어와 사용한다.
  • 6 - C.c() 메서드에서 현재 쓰레드의 로컬 변수에 저장된 Date 객체를 읽어와 사용한다.
  • 9 - A.a() 메서드에서 현재 쓰레드의 로컬 변수를 삭제한다.

위 코드에서 중요한 건 A.a()에서 생성한 Date 객체를 B.b() 메서드나 C.c() 메서드에 파라미터로 전달하지 않는다는 것이다. 즉, 파라미터로 객체를 전달하지 않아도 한 쓰레드로 실행되는 코드가 동일한 객체를 참조할 수 있게 된다.



ThreadLocal의 활용

ThreadLocal은 한 쓰레드에서 실행되는 코드가 동일한 객체를 사용할 수 있도록 해 주기 때문에 쓰레드와 관련된 코드에서 파라미터를 사용하지 않고 객체를 전파하기 위한 용도로 주로 사용되며, 주요 용도는 다음과 같다.

  • 사용자 인증정보 전파 - Spring Security에서는 ThreadLocal을 이용해서 사용자 인증 정보를 전파한다.
  • 트랜잭션 컨텍스트 전파 - 트랜잭션 매니저는 트랜잭션 컨텍스트를 전파하는 데 ThreadLocal을 사용한다.
  • 쓰레드에 안전해야 하는 데이터 보관

이 외에도 쓰레드 기준으로 동작해야 하는 기능을 구현할 때 ThreadLocal을 유용하게 사용할 수 있다.

ThreadLocal 사용시 주의 사항


쓰레드 풀 환경에서 ThreadLocal을 사용하는 경우 ThreadLocal 변수에 보관된 데이터의 사용이 끝나면 반드시 해당 데이터를 삭제해 주어야 한다. 그렇지 않을 경우 재사용되는 쓰레드가 올바르지 않은 데이터를 참조할 수 있다.


▶︎▶︎▶︎자바캔


posted by 여성게
: