인터셉터란?

Interceptor는 가로채는 것(요격기)을 의미합니다. 클라이언트(브라우저)에서 요청 Url을 Controller라는 표현계층에 전송을 하면 해당 요청 Url과 매핑되는 메소드를 실행되기 전 후에 어떤 작업을 수행하기 위해서 Url을 가로채는 주체라고 생각하면 됩니다.

대표적으로 Url Mapping 된 Controller를 거치는 전, 후 처리를 할 수 있도록 도와주는 요소를 말합니다. 주로 세션 검증, 로그 처리 같은 행위가 간단한 예시 입니다.

Interceptor는 Spring-WebMvc에 포함되어 있습니다. Spring Boot에서 gradle을 빌더로 사용할 때 build.gradle 파일에 dependency에 spring-boot-starter-web을 가져옴으로 해결할 수 있습니다.

dependencies{
  implementation'org.springframework.boot:spring-boot-starter-web'
}

Intercpetor 예제 코드

기본 인터페이스는 HandlerInterceptor 이고, 이를 구현해서 Interceptor class를 작성해도 되고, HandlerInterceptorAdapter를 구현할 수도 있습니다. 하지만 Java 1.8 이상부터는 HandlerInterceptor의 메소드가 default로 선언되어 있기 때문에 필요한 메소드만 선택해서 구현할 수 있기 때문에 굳이 HandlerInterceptorAdapter를 이용하지 않아도 됩니다.

@Component
public class HttpInterceptor extends HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        logger.info("================ Before Method");
        return true;
    }

    @Override
    public void postHandle( HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            ModelAndView modelAndView) {
        logger.info("================ Method Executed");
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response, 
                                Object handler, 
                                Exception ex) {
        logger.info("================ Method Completed");
    }
}
  • preHandle(): Controller의 메소드를 실행하기 전에 처리해주는 메소드입니다.
    주로 세션 검증에서 많이 사용합니다.

  • postHandle(): Controller의 메소드를 실행 후에 처리를 해주는 메소드입니다.
    세션 검증 후 로그인 인증이 되어있다면 해당 사용자 정보를 세션에 저장하는 로직을 넣을 수도 있습니다. 또한 view에 전달할 ModelAndView 객체를 이용해서 특정 작업을 수행도 할 수 있습니다.

  • afterCompletion(): Controller의 메소드를 실행 후 view를 클라이언트에게 전송 후에 처리를 해주는 메소드입니다.

Config

Interceptor를 등록하기 위해서 WebMvcConfigurer를 이용합니다. Interceptor를 등록한 후 적용할 경로, 제외할 경로를 지정해줄 수 있습니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    //HandlerInterceptor를 구현한 개발자가 작성한 Intercepotor 클래스
    private final JwtInterceptor jwtInterceptor;

    private static final String[] EXCLUDE_PATHS = {
            "/member/**",
            "/error/**"
    };

    // 개발자가 구현한 Interceptor를 regisry 객체를 통해서 Interceptor 추가
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);
    }
}

Spring Boot에서 WebMvcConfigurer는 자동 구성된 Spring MVC 구성을 큰 변경없이 추가적인 조작을 하기 위해서 구현합니다. 이를 통해서 자동 구성된 Spring MVC를 개발자가 완벽히 제어할 수 있습니다.

boot 2.0 + Java 1.8 + spring 5.0 이상의 버전을 사용하면서 WebMvcConfigurer의 대두분 메서드에 default로 선언 되었습니다. 덕분에 모든 메서드를 구현해야하는 강제력이 사라졌습니다. 필요한 메서드만 선택해서 오버라이드하면 됩니다.

Controller

@Slf4j
@Controller
public class HelloController {

    @RequestMapping("/memberInfo")
    public void memberInfo(Model model){
        log.info("postHandle() 메소드 수행 전");
        String id = "sa1341";
        model.addAttribute("userName", id);
    }
}

실행 결과

스크린샷 2019-12-07 오후 9 50 19

JWT 토큰을 사용자에게 발급하여 이 토큰을 통해서 사용자의 권한 및 API 호출을 제어할 수 있고 서버에서 세션을 관리할 필요가 없기 때문에 확장성 측면에서 많은 이점을 누릴 수 있기 때문에 JWT를 사용하기전에 토큰 기반의 인증 방식에 대한 내용과 JWT 기본 개념에 대해서 포스팅을 하였습니다.

토큰(Tocken)기반 인증에 대한 소개

토큰(Tocken)기반 인증은 모던 웹서비스에서 정말 많이 사용되고 있습니다. 웹 서비스를 개발한다면 토큰을 사용하여 유저들의 인증작업을 처리하는것이 가장 좋다고 합니다.

토큰은 Stateless 서버입니다. Stateful 서버와 다르게 세션 값으로 클라이언트 상태 유지를 하지 않습니다. 상태정보를 저장하지 않으면, 서버는 클라이언트 측에서 들어오는 요청으로만 작업을 처리합니다. 이렇게 상태가 없는 경우 클라이언트와 서버의 연결고리가 없기 때문에 서버의 확장성(Scalaability)이 높아집니다.

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

만약에 Android/iOS 모바일 어플리케이션을 개발 한다면, 안전한 API를 만들기 위해선 쿠키같은 인증시스템은 이상적이지 않습니다. 토큰 기반 인증을 도입한다면, 더욱 간단하게 이 번거로움을 해결 할 수 있습니다.

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

대표적인 예로 OAuth가 있습니다. 페이스북/구글 같은 소셜 계정들을 이용하여 다른 웹 서비스에서도 로그인 할 수 있게 할 수 있습니다.

보안

토큰 기반 인증 시스템을 사용하여 어플리케이션의 보안을 높일 수 있습니다. 단, 이 토큰 기반 인증을 사용한다고 해서 무조건 해킹의 위험에서 벗어나는건 아닙니다.

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

토큰 기반 인증 시스템이 어떻게 동작하고, 또 이로 인하여 얻을 수 있는 이득에 대하여 알아보기전에, 이 토큰 기반 인증 시스템이 나온 이유에 대해서 살펴보는게 가장 이해가 빠를거 같습니다.

스크린샷 2019-12-05 오후 7 38 57

서버 기반 인증

과거 인증 시스템은 서버측에서 유저들의 정보를 기억하고 있어야 합니다. 이 세션을 유지하기 위해서는 여러 가지 방법이 사용됩니다. 메모리 / 디스크 / 데이터베이스 시스템에 이를 담곤 했습니다.

서버 기반 인증 시스템 흐름

Untitled Diagram

하지만 이 방식은 서버를 확장하기가 어려워졌습니다.

서버 기반 인증의 문제점

세션

유저가 인증을 할 때, 서버는 이 기록을 서버에 저장을 해야합니다. 이를 세션이라고 부릅니다. 대부분의 경우엔 메모리에 이를 저장하는데, 로그인 중인 유저의 수가 늘어난다면 서버의 램에 많은 과부화가 걸리게됩니다. 이를 피하기 위해서는 세션을 데이터베이스 시스템에 저장하는 방식도 있지만, 이 또한 유저의 수가 많으면 데이터베이스의 성능에 무리를 줄 수 있습니다.

확장성

세션을 사용하면 서버를 확장하는 것이 어려워집니다. 여기서 서버의 확장이란, 단순히 서버의 사양을 업그레이드 하는것이 아니라, 더 많은 트래픽을 감당하기 위하여 여러개의 프로세스를 돌리거나, 여러대의 서버 컴퓨터를 추가 하는것을 의미합니다. 세션을 사용하려면 분산된 시스템을 설계하는건 불가능한것은 아니지만 과정이 매우 복잡해집니다.

토큰 기반 시스템의 작동 원리

토큰 기반 시스템은 Stateless 합니다. 즉 상태유지를 하지 않습니다. 이 시스템에서는 더 이상 유저의 인증 정보를 서버나 세션에 담아두지 않습니다. 이 개념 하나만으로도 위에서 서술한 서버에서 유저의 인증 정보를 서버측에 담아둠으로서 발생하는 많은 문제점들이 해소됩니다.

세션이 존재하지 않으니, 유저들이 로그인 되어있는지 안되어있는지 신경을 1도 쓰지 않기 때문에 서버를 손쉽게 확장 할 수 있습니다.

토큰 기반 시스템의 구현 방식은 시스템마다 크고 작은 차이가 있겠지만, 대략적으로 아래 절차와 같습니다.

  1. 유저가 아이디와 비밀번호를 입력하여 로그인을 합니다.

  2. 서버측에서 해당 계정정보를 검증합니다.

  3. 계정정보가 정확하다면, 서버측에서 유저에게 signed 토큰을 발급해줍니다.

    signed의 의미는 해당 토큰이 서버에서 정상적으로 발급된 토큰임을 증명하는 signature를 지니고 있다는 것을 의미합니다.

  4. 클라이언트 측에서 전달받은 토큰을 저장해두고, 서버에 요청을 할 때 마다, 해당 토큰을 함께 서버에 전달합니다.

  5. 서버는 토큰을 검증하고, 요청에 응답합니다.

토큰 기반 시스템 처리 과정

Untitled Diagram (1)

웹서버에서 토큰을 서버에 전달 할 때에는, HTTP Request 헤더에 토큰값을 포함시켜서 전달합니다.

토큰의 장점

무상태(stateless)이며 확장성(scalability)입니다.

이 개념은 토큰 기반 인증 시스템의 중요한 속성입니다. 토큰은 클라이언트 사이드에 저장하기 때문에 완전히 stateless하며, 서버를 확장하기에 매우 적합한 환경을 제공합니다. 만약에 세션을 서버측에 저장하고 있고, 서버를 여러대를 사용하여 요청을 분산하였다면, 어떤 유저가 로그인 했을땐, 그 유저는 처음 로그인했었던 해당 서버에만 요청을 보내도록 설정을 해야합니다. 하지만 토큰을 사용한다면 어떤 서버로 요청이 들어가던 상관이 없습니다.

보안성

클라이언트가 서버에 요청을 보낼 때, 더 이상 쿠키를 전달하지 않음으로 쿠키를 사용함으로 인해 발생하는 취약점이 사라집니다. 토큰을 사용하는 환경에서도 취약점이 존재할 수있으니 언제나 취약점에 대비해야합니다.

확장성

토큰을 사용하여 다른 서비스에서도 권한을 공유 할 수 있습니다. 예를 들어서, 스타트업 구인구직 웹서비스인 로켓펀치에서는 Facebook, LinkedIn, GitHub, Google 계정으로 로그인을 할 수 있습니다. 토큰 기반 시스템에서는, 토큰에 선택적인 권한만 부여하여 발급을 할 수 있습니다. (예를들어서 로켓펀치에서 페이스북 계정으로 로그인을 했다면, 프로필 정보를 가져오는 권한은 있어도, 포스트를 작성 할 수 있는 권한은 없습니다.)

여러 플랫폼 및 도메인

서버 기반 인증 시스템의 문제점을 다룰때 어플리케이션과 서비스 규모가 커지면, 우리는 여러 디바이스를 호환 시키고, 더 많은 종류의 서비스를 제공하게 됩니다. 토큰을 사용한다면, 그 어떤 디바이스나 도메인에서도 토큰만 유효하다면 요청이 정상적으로 처리됩니다. 서버측에서 어플리케이션의 응답 부분에 다음 헤더만 포함시켜주면 됩니다.

Access-Control-Allow-Origin: *

이런 구조라면, assets 파일들(이미지, css, js, html 파일 등)은 모두 CDN에서 제공을 하도록 하고, 서버측에서는 오직 API만 다루도록 하도록 설계 할 수도 있습니다.

JWT는 웹 표준 RFC 7519에 등록이 되어있습니다. 따라서 여러 환경에서 지원이 되며 수많은 회사의 인프라스트럭쳐에서 사용 되고 있습니다.

JWT(JSON Web Token)을 이용한 API 인증

JWT는 Claim 기반의 토큰입니다. Claim이라는 사용자에 대한 프로퍼티나 속성을 이야기 합니다. 토큰자체가 정보를 가지고 있는 방식인데, JWT는 이 Claim을 JSON을 이용해서 정의합니다. 다음은 Claim을 JSON으로 서술한 예입니다. JSON 자체를 토큰으로 사용하는 것이 Claim 기반의 토큰 방식입니다.

ex) Claim 기반의 토큰 정보

{
    "id":"junyoung",
    "role":["admin", "user"],
    "company":"pepsi"
}

이러한 Claim 방식의 토큰의 장점은 토큰을 이용해서 요청을 받는 서버나 서비스 입장에서는 이 서비스를 호출한 사용자에 대한 추가 정보는 이미 토큰안에 다 들어가 있기 때문에 다른 곳에서 가져올 필요가 없다는 것입니다.

"사용자 관리" 라는 API 서비스가 있다고 가정합니다.
아 API는 관리자(admin) 권한을 가지고 있는 사용자만이 접근이 가능하며, "관리자" 권한을 가지고 있는 사용자는 그 관리자가 속해 있는 회사(company)의 사용자 정보만 관리할 수 있다고 정의해봅시다. 이 시나리오에 대해서 일반적인 스트링 기반의 토큰과 JWT와 같은 Claim 기반의 토큰이 어떤 차이를 가질 수 있는지 알아보겠습니다.

OAuth 토큰의 경우

Untitled Diagram (2)

  1. API 클라이언트가 Authorization Server(토큰 발급서버)로 토큰을 요청합니다.
    이때, 토큰 발급을 요청하는 사용자의 계정과 비밀번호를 넘기고, 이와 함께 토큰의 권한(용도)를 요청합니다. 여기서는 일반 사용자 권한(basic)과 관리자 권한(admin)을 같이 요청하였습니다.

  2. 토큰 생성 요청을 받은 Authorization Server는 사용자 계정을 확인한 후, 이 사용자에게 요청된 권한을 부여해도 되는지 계정 시스템등에 물어본 후, 사용자에게 해당 토큰을 발급이 가능하면 토큰을 발급하고, 토큰에 대한 정보를 내부(토큰 저장소)에 저장해놓습니다.

  3. 이렇게 생성된 토큰은 API 클라이언트로 저장됩니다.

  4. API 클라이언트는 API를 호출할때 이 토큰을 이용해서 Resource Server(API 서버)에 있는 API를 호출합니다.

  5. 이때 호출되는 API는 관리자 권한을 가지고 있어야 사용할 수 있기 때문에, Resource Server가 토큰 저장소에서 토큰에 관련된 사용자 계정, 권한 등의 정보를 가지고 옵니다. 이 토큰에 (관리자)admin 권한이 부여되어 있기 때문에, API 호출을 허용합니다. 위에 정의한 시나리오에서는 그 사용자가 속한 “회사”의 사용자 정보만 조회할 수 있습니다. 라는 전제 조건을 가지고 있기 때문에, API 서버는 추가로 사용자 데이타 베이스에서 이 사용자가 속한 “회사” 정보를 찾아와야합니다.

  6. API서버는 응답을 보낸다.

JWT와 같은 Claim 기반의 토큰 흐름

Untitled Diagram

  1. 토큰을 생성 요청하는 방식은 동일합니다. 마찬가지로 사용자를 인증한다음에, 토큰을 생성합니다.

  2. 다른 점은 생성된 토큰에 관련된 정보를 별도로 저장하지 않는다는 것입니다. 토큰에 연관되는 사용자 정보나 권한등을 토큰 자체에 넣어서 저장합니다.

  3. API를 호출하는 방식도 동일합니다.

  4. Resource Server (API 서버)는 토큰 내에 들어 있는 사용자 정보를 가지고 권한 인가 처리를 하고 결과를 리턴합니다.

결과적으로 차이점은 토큰을 생성하는 단계에서는 생성된 토큰을 별도로 서버에서 유지할 필요가 없으며 토큰을 사용하는 API 서버 입장에서는 API 요청을 검증하기 위해서 토큰을 가지고 사용자 정보를 별도로 계정 시스템 등에서 조회할 필요가 없다는 것입니다.

JWT에 대한 소개

Claim 기반의 토큰에 대한 개념을 대략적으로 이해했다면, 그러면 실제로 JWT가 어떻게 구성되는지에 대해서 살펴보겠습니다.

Claim (메시지) 정의
JWT는 Claim을 JSON 형태로 표현하는 것인데, JSON은 "\n" 등 개행문자가 있기 때문에, REST API 호출 시 HTTP Header등에 넣기가 매우 불편합니다. 그래서 이 Claim JSON 문자열을 BASE64 인코딩을 통해서 하나의 문자열로 변환합니다.

{
    "id":"junyoung",
    "role":["admin", "user"],
    "company":"pepsi"
}

문자열을 BASE64로 인코딩 한 결과

ew0KICAiaWQiOiJ0ZXJyeSINCiAgLCJyb2xlIjpbImFkbWluIiwidXNlciJdDQogICwiY29tcGFueSI6InBlcHNpIg0KfQ0K

BASE64 인코딩이란?

  • 2진 데이터를 ASCII 형태의 텍스트로 표현 가능
  • 웹 인증 중 기본인증에 사용
  • 끝 부분의 Padding(==)으로 식별 가능
  • 64개의 문자 사용 (영문 대, 소문자, 숫자 , + , / )
  • 데이터를 6bit 단위로 표현

변조 방지

위의 Claim 기반의 토큰을 봤다면, 첫번째 들 수 있는 의문이 토큰을 받은 다음에 누군가 토큰을 변조해서 사용한다면 어떻게 막느냐 입니다. 이렇게 메시지가 변조 되지 않았음을 증명하는 것을 무결성(Integrity)라고 하는데, 무결성을 보장하는 방법 중 많이 사용되는 방법이 서명(Signature)이나 HMAC 사용하는 방식입니다. 즉 원본 메세지에서 해쉬값을 추출한 후, 이를 비밀 키를 이용해서 복호화 시켜서 토큰의 뒤에 붙입니다. 이게 HMAC 방식인데, 누군가 이 메세지를 변조했다면, 변조된 메시지에서 생성한 해쉬값과 토큰뒤에 붙어있는 해쉬값이 다르기 때문에 메세지가 변조되었음을 알 수 있습니다. 다른 누군가가 메세지를 변조한 후에, 새롭게 HMAC 값을 만들어내려고 하더라도, HMAC은 앞의 비밀키를 이용해서 복호화 되었기 때문에, 이 비밀키를 알 수 없는 이상 HMAC를 만들어 낼 수 없습니다.

앞의 JSON 메세지에 대해서 SHA-256이라는 알고리즘을 이용해서 비밀키를 “secret” 이라고 하고, HMAC을 생성하면 결과는 다음과 같습니다.

i22mRxfSB5gt0rLbtrogxbKj5aZmpYh7lA82HO1Di0E

JWT의 기본 구조

Header . Payload . Signature

Header

JWT 웹 토큰의 헤더 정보

  • typ: 토큰의 타입, JWT만 존재
  • alg: 해싱 알고리즘.(HMAC SHA256 or RSA) 헤더를 암호화 하는게 아닙니다. 토큰 검증시 사용합니다.
{
    "alg" : "HS256",
    "typ" : "JWT"
}

위의 내용을 BASE64로 인코딩합니다. => eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
BASE64는 암호화된 문자열이 아닙니다. 같은 무자열에 대해서는 항상 같은 인코딩 문자열을 반환합니다.

Payload

실제 토큰으로 사용하려는 데이터가 담기는 부분. 각 데이터를 Claim이라고 하며 다음과 같이 3가지 종류가 있습니다.

  • Reserved claims: 이미 예약된 Claim. 필수는 아니지만 사용하길 권장합니다. key는 모두 3자리 String입니다.

    • iss(String): issuer, 토큰 발행자 정보

    • exp(Number): expiration time, 먼료일

    • sub(String): subject, 제목

    • and(String): audience

  • Public claims: 사용자 정의 Claim
    • Public이라는 이름처럼 공개용 정보
    • 충돌 방지를 위해 URI 포맷을 이용해 저장합니다.
  • Private Claims: 사용자 정의 Claim

    • Public claims과 다르게 사용자가 임의로 정한 정보

    • 아래와 같이 일반 정보를 저장합니다.

        {
            "name" : "hak",
            "age"  : 26
        }

Signature

Header와 Payload의 데이터 무결성과 변조 방지를 위한 서명
Header + Payload를 합친 후, Secret 키와 함께 Header의 해싱 알고리즘으로 인코딩

HMACSHA256( 
    base64UrlEncode(header) + "." + 
    base64UrlEncode(payload), 
    secret)

JWT 구조

JWT는 [Header Payload Signature] 각각 JSON 형태의 데이터를 base 64 인코딩 후 합칩니다.
아래와 같은 순서로 . 을 이용해 합칩니다.
최종적으로 만들어진 토큰은 HTTP 통신 간 이용되며, Authorization 이라는 key의 value로서 사용됩니다.

250AC0505861FCE02E

JWT 인증 과정

2268544E5861FD0F13

JWT의 단점 & 도입시 고려사항

  • Self-contained: 토큰 자체에 정보가 있다는 사실은 양날의 검이 될수 있습니다.

    • 토큰 길이: 토큰 자체 payload에 Claim set을 저장하기 때문에 정보가 많아질수록 토큰의 길이가 늘어나 네트워크에 부하를 줄 수있습니다.

    • payload 암호화: payload 자체는 암호화 되지 않고 base64로 인코딩한 데이터입니다. 중간에 payload를 탈취하면 디코딩을 통해 데이터를 볼 수 있습니다.
      JWE를 통해 암호화하거나, payload에 중요 데이터를 넣지 않아야 합니다.

    • Stateless: 무상태성이 때론 불편할 수 있습니다. 토큰은 한번 만들면 서버에서 제어가 불가능합니다. 토큰을 임의로 삭제할 수 있는 방법이 없기 때문에 토큰 만료시간을 꼭 넣어주는게 좋습니다.

    • tore Token: 토큰은 클라이언트 side에서 관리해야하기 때문에 토큰을 저장해야 합니다.

참고 사이트 : https://velopert.com/2350, https://bcho.tistory.com/999, https://sanghaklee.tistory.com/47

mapXXX() 메소드

mapXXX() 메소드는 요소를 대체하는 요소로 구성된 새로운 스트림을 리턴합니다.
아래 예제 코드를 통해서 이름, 점수를 인스턴스 변수로 가지고 있는 Student 객체를 타입 파라미터로 가지고 있는 리스트 객체에서 학생의 점수를 요소로 하는 새토룬 스트림을 생성하고 점수를 순차적으로 콘솔에 출력해보겠습니다.

import java.util.Arrays;
import java.util.List;

public class MapExample {

    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
                new Student("홍길동", 90),
                new Student("신용권", 40),
                new Student("유미선", 50)
        );


        students.stream()
                .map(Student::getScore)
                .forEach(System.out :: println);
    }
}

asDoubleStream(), asLongStream(), boxed() 메소드

asDoubleStream() 메소드는 IntStream의 int 요소 또는 LongStream의 long 요소를 double 요소로 타입 변환해서 DoubleStream을 생성합니다. 마찬가지로 asLongStream() 메소드는 IntStream의 int 요소를 long 요소로 타입 변환해서 LongStream을 생성합니다. boxed() 메소드는 int, long, double 요소를 Integer, Long, Double 요소로 박싱해서 Stream을 생성합니다.

import java.util.Arrays;
import java.util.stream.IntStream;

public class AsDoubleStreamBoxedExample {
    public static void main(String[] args) {
        int[] intArray = {1, 2, 3, 4, 5};

        IntStream intStream = Arrays.stream(intArray);

        intStream
                .asDoubleStream()
                .forEach(System.out :: println);

        System.out.println();

        // 스트림은 한번 사용후에 재사용이 불가능 하기 때문에 다시 사용하려면 스트림 객체를 생성해줘야 합니다.
        intStream = Arrays.stream(intArray);

        intStream
                .boxed()
                .forEach(obj -> System.out.println(obj.intValue()));

    }
}

정렬(sorted())

스트림은 요소가 최종 처리되기 전에 중간 단계에서 요소를 정렬해서 최종 처리 순서를 변경할 수 있습니다.

객체 요소일 경우에는 클래스가 Comparable을 구현하지 않으면 sorted() 메소드를 호출했을때 ClassCastException이 발생하기 때문에 Comparable을 구현한 요소에만 sorted() 메소드를 호출해야 합니다. 다음은 점수를 기준으로 Student 요소를 오름차순으로 정렬하기 위해 Comparable을 구현해야 합니다.

public class Student implements Comparable<Student> {

    private String name;
    private int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getScore() {
        return score;
    }

    public void setScore(int score) {
        this.score = score;
    }

    @Override
    public int compareTo(Student obj) {
        return Integer.compare(score, obj.score);
    }
}

객체 요소가 Comparable을 구현한 상태에서 기본 비교방법으로 정렬하고 싶다면 다음 세가지 방법 중 하나를 선택해서 sorted()를 호출하면 됩니다.

sorted();
sorted((a,b) -> a.compareTo(b));
sorted( Comparator.naturalOrder());

만약 객체 요소가 Comparable을 구현하고 있지만, 기본 비교 방법과 정반대 방법으로 정렬하고 싶다면 다음과 같이 sorted()를 호출하면 됩니다.

sorted( (a,b) -> b.compareTo(a));
sorted( Comparator.reverseOrder());

중괄호 {} 안에는 a와 b를 비교해서 a가 작으면 음수, 같으면 0, a가 크면 양수를 리턴하는 코드를 작성하면 됩니다.

// 정렬
public class SortingExample {

    public static void main(String[] args) {

        IntStream intStream = Arrays.stream(new int[] {5, 3, 2, 1, 4});

        //오름 차순으로 정렬하는 중간 스트림 생성 후 최종처리에서 출력합니다.
        intStream
                .sorted()
                .forEach(n -> System.out.println(n + ","));
        System.out.println();

        List<Student> studentList = Arrays.asList(
                new Student("홍길동", 30),
                new Student("신용권", 10),
                new Student("유미선", 20)
        );

        // 정수를 기준으로 오름차순으로 Student 정렬
        studentList.stream()
                .sorted()
                .forEach(s -> System.out.print(s.getScore() + ","));

        System.out.println();

        // 정수를 기준으로 내림차순으로 Student 정렬
        studentList.stream()
                .sorted(Comparator.reverseOrder())
                .forEach(s -> System.out.print(s.getScore() + ","));

    }
}

루핑(peek(), forEach())

루핑은 요소 전체를 반복하는 것을 말합니다. 루핑하는 메소드에는 peek(), forEach()가 있습니다. 이 두 메소드는 루핑한다는 기능에서는 동일하지만, 동작 방식은 다릅니다. peek()은 중간 처리 메소드이고, forEach()는 최종 처리 메소드입니다.

peek()는 중간 처리 단계에서 전체 요소를 루핑하면서 추가적인 작업을 하기 위해 사용합니다. 최종 처리 메소드가 실행되지 않으면 지연되기 때문에 반드시 최종 처리 메소드가 호출되어야 동작합니다.

예를 들어 필터링 후 어떤 요소만 남아있는지 확인하기 위해 다음과 같이 peek()를 마지막에서 호출할 경우, 스트림은 전혀 동작하지 않습니다.

요소 처리의 최종 단계가 합을 구하는 것이라면, peek() 메소드 호출 후 sum()을 호출해야만 peek()가 정상적으로 동작합니다.

intStream
    .filter(a -> a%2 == 0)
    .peek(a -> Systemp.out.println(a))
    .sum()

하지만 forEach()는 최종 처리 메소드이기 때문에 파이프라인 마지막에 루핑하면서 요소를 하나씩 처리합니다. forEach()는 요소를 소비하는 최종 처리 메소드이므로 이후에 sum()과 같은 다른 최종 메소드를 호출하면 안됩니다.

public class LoopingExample {
    public static void main(String[] args) {

        int[] intArr = {1, 2, 3, 4, 5};

        System.out.println("[peek()를 마지막에 호출한 경우]");

        Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .peek(n -> System.out.println(n)); // 동작하지 않습니다.


        System.out.println("[최종처리 메소드를 마지막에 호출한 경우]");

        int total = Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .peek(n -> System.out.println(n)) // 동작함
                .sum();                           // 최종 메소드

        System.out.println("총합: " + total);



        Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .forEach(n -> System.out.println(n)); // 최종 메소드로 동작합니다.

    }
}

매칭(allMatch(), anyMatch(), noneMatch())

스트림 클래스는 최종 처리 단계에서 요소들이 특정 조건에 만족하는지 조사할 수 있도록 세가지 메소드를 제공하고 있습니다. allMatch() 메소드는 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하는지 조사하고, anyMatch() 메소드는 최소한 한 개의 요소가 매개 값으로 주어진 Predicate의 조건을 만족하는지 조사합니다. 그리고 noneMatch()는 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하지 않는지 조사합니다.

스크린샷 2019-12-15 오후 11 25 28

public class MatchExample {
    public static void main(String[] args) {

        int[] intArr = {2, 4, 6};

        boolean result = Arrays.stream(intArr)
                .allMatch(a -> a%2==0);

        System.out.println("모두 2의 배수인가?"+ result);

        result = Arrays.stream(intArr)
                .anyMatch(a -> a%3==0);
        System.out.println("하나라도 3의 배수가 있는가? " + result);

        result = Arrays.stream(intArr)
                .noneMatch(a -> a%3==0);
        System.out.println("3의 배수가 없는가? " + result);

    }
}

기본 집계(sum(), count(), average(), max(), min())

집계(Aggregate)는 최종 처리 기능으로 요소들을 처리해서 카운팅, 합계, 평균값, 최대값, 최소값 등과 같이 하나의 값으로 산출하는 것을 말합니다. 집계는 대량의 데이터들을 가공해서 축소하는 리덕션이라고 볼 수 있습니다.

스트림이 제공하는 기본 집계

스크린샷 2019-12-15 오후 11 25 39

이 집계 메소드에서 리턴하는 OptionalXXX는 자바 8에서 추가한 java.util 패키지의 Optional, OptionalDouble, OptionalInt, OptionalLong 클래스 타입을 말합니다. 이들은 값을 저장하는 값 기반 클래스들입니다. 이 객체에서 값을 얻기 위해서는 get(), getAsDouble(), getAsInt(), getAsLong()를 호출하면 됩니다.

public class AggregateExample {
    public static void main(String[] args) {
        long count = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .count();

        System.out.println("2의 배수 개수: " + count);

        long sum = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .sum();

        System.out.println("2의 배수의 합: " + sum);


        double avg = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .average()
                .getAsDouble();

        System.out.println("2의 배수의 평균: " + avg);


        int max = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .max()
                .getAsInt();

        System.out.println("2의 배수의 최대 값: " + max);


        int min = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .min()
                .getAsInt();

        System.out.println("2의 배수의 최소 값: " + min);


        int first = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 3 == 0)
                .findFirst()
                .getAsInt();


        System.out.println("첫번째 3의 배수: " + first);
    }
}

실행 결과

스크린샷 2019-12-04 오후 11 05 00

Optional 클래스

Optional, OptionalDouble, OptionalInt, OptionalLong 클래스에 대해서 좀 더 알아보기로 하자. 이 클래스들은 저장하는 값의 타입만 다를 뿐 제공하는 기능은 거의 동일합니다. Optional 클래스는 단순히 집계 값만 저장하는 것이 아니라, 집계 값이 존재하지 않을 경우 디폴트 값을 설정할 수도 있고, 집계 값을 처리하는 Consumer도 등록할 수 있습니다. 다음은 Optional 클래스들이 제공하는 메소드들입니다.

스크린샷 2019-12-15 오후 11 25 52

컬렉션의 요소는 동적으로 추가되는 경우가 많습니다. 만약 컬렉션의 요소가 추가되지 않아 저장된 요소가 없을 경우 요소가 없기 때문에 평균값도 있을 수 없습니다.

List<Integer> list = new ArrayList<>();

double avg = list.stream()
                .mapToInt(Integer::intValue)
                .average()
                .getAsDouble();
        System.out.println("평균: " + avg);

그렇기 때문에 NoSuchElementException 예외가 발생합니다. 요소가 없을 경우 예외를 피하는 세 가지 방법이 있습니다. 첫 번째는 Optional 객체를 얻어 isPresent()메소드로 평균값 여부를 확인하는 것입니다. isPresent() 메소드가 true를 리턴할 때만 getAsDouble() 메소드로 평균값을 얻으면 됩니다.

OptionalDouble optional = list.stream()
                .mapToInt(Integer::intValue)
                .average();

    if(optional.isPresent()){
        System.out.println("평균: " + optional.getAsDouble());
    }else {
        System.out.println("평균: 0.0");
    }

두 번째 방법은 orElse() 메소드로 디폴트 값을 정해 놓습니다. 평균값을 구할 수 없는 경우에는 orElse()의 매개값이 디폴트 값이 됩니다.

double avg = list.stream()
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0.0);
System.out.println("평균: " + avg);

세 번째 방법은 ifPresent() 메소드로 평균값이 있을 경우에만 값을 이용하는 람다식을 실행합니다.

list.stream()
        .mapToInt(Integer::intValue)
        .average()
        .ifPresent(a -> System.out.println("평군: " + a));

수집(collect())

스트림은 요소들을 필터링 또는 매핑한 후 요소들을 수집하는 최종 처리 메소드인 collect()를 제공하고 있습니다. 이 메소드를 이용하면 필요한 요소만 컬렉션으로 담을 수 있고, 요소들을 그룹핑한 후 집계할 수 있습니다.

필터링한 요소 수집

Stream의 collect(Collector<T,A,R> collector) 메소드는 필터링 또는 매핑된 요소들을 새로운 컬렉션에 수집하고, 이 컬렉션을 리턴합니다.

스크린샷 2019-12-15 오후 11 26 03

매개값인 Collector(수집기)는 어떤 요소를 어떤 컬렉션에 수집할 것인지를 결정합니다. Collector의 타입 파라미터 T는 요소이고 A는 누적기이고, 그리고 R은 요소가 저장될 컬렉션입니다. 풀어서 해석하면 T요소를 A누적기가 R에 저장한다는 의미입니다. Collector의 구현 객체는 다음과 같이 Collectors 클래스의 다양한 정적 메소드를 이용해서 얻을 수 있습니다.

스크린샷 2019-12-15 오후 11 26 14

리턴값인 Collector를 보면 A(누적기)가 ?로 되어 있는데, 이것은 Collector가 R(컬렉션)에 T(요소)를 저장하는 방법을 알고 있어 A(누적기)가 필요 없기 때문입니다.
Map과 ConcurrentMap의 차이점은 Map는 스레드에 안전하지 않고, ConcurrentMap은 스레드에 안전합니다. 멀티 스레드 환경에서 사용하려면 ConcurrentMap을 얻는 것이 좋습니다. 다음 코드는 전체 학생 중에서 남학생들만 필터링해서 별도의 List로 생성합니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
//남학생만 필터링해서 Stream을 얻습니다.
Stream<Student> maleStream = totalStream.filter(s -> s.getSex() == Student.Sex.MALE);
//List에 Student를 수집하는 Collector를 얻습니다.
Collector<Student, ?, List<Student>> collector = Collectors.toList();
// Stream에서 collect() 메소드로 Student를 수집해 새로운 List를 얻습니다.
List<Student> maleList = maleStream.collect(collector);

위의 코드를 아래와 같이 간단하게 작성할 수 있습니다.

List<Student> maleList = totalList.stream()
        .filter(s -> s.getSex() == Student.Sex.MALE)
        .collect(Collectors.toList());

다음 코드는 전체 학생 중에서 여학생들만 필터링해서 별도의 HashSet으로 생성합니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// 여학생만 필터링해서 Stream을 얻습니다.
Stream<Student> femaleStream = totalStream.filter(s -> s.getSex()== Student.Sex.FEMALE);
// 새로운 HashSet을 공급하는 Supplier를 얻습니다. 
Supplier<HashSet<Student>> supplier = HashSet :: new;
// Supplier가 공급하는 HashSet에 Student를 수집하는 Collector를 얻습니다.
Collector<Student, ? , HashSet<Student>> collector = 
Collectors.toCollection(supplier);
// Stream에서 collect() 메소드로 Student를 수집해서 새로운 HashSet을 얻습니다.
Set<Student> femaleSet = femaleStream.collect(collector);

위의 코드도 마찬가지로 아래처럼 간단하게 작성이 가능합니다.

Set<Student> femaleSet = totalList.stream()
        .filter(s -> s.getSex() == Student.Sex.FEMALE)
        .collect(Collectors.toCollection(HashSet :: new));
// 필터링해서 새로운 컬렉션 생성 예제
public class StudyMember {

    public enum Sex { MALE, FEMALE }
    public enum City { Seoul, Pusan}


    private String name;
    private int score;
    private Sex sex;
    private City city;


    public StudyMember(String name, int score, Sex sex) {
        this.name = name;
        this.score = score;
        this.sex = sex;
    }

    public StudyMember(String name, int score, Sex sex, City city) {
        this.name = name;
        this.score = score;
        this.sex = sex;
        this.city = city;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public Sex getSex() {
        return sex;
    }

    public City getCity() {
        return city;
    }
}

public class ToListExample {
    public static void main(String[] args) {
        List<StudyMember> totalList = Arrays.asList(
                new StudyMember("홍길동", 10, StudyMember.Sex.MALE),
                new StudyMember("김수애", 6, StudyMember.Sex.FEMALE),
                new StudyMember("신용권", 10, StudyMember.Sex.MALE),
                new StudyMember("박수미", 6, StudyMember.Sex.FEMALE)
        );

        //남학생들만 묶어 List 생성
        List<StudyMember> maleList = totalList.stream()
                .filter(s -> s.getSex() == StudyMember.Sex.MALE)
                .collect(Collectors.toList());

        maleList.stream()
                .forEach(s -> System.out.println(s.getName()));



        // 여학생들만 묶어 HashSet 생성
        Set<StudyMember> femaleList = totalList.stream()
                .filter(s -> s.getSex() == StudyMember.Sex.FEMALE)
                .collect(Collectors.toCollection(HashSet::new));
        femaleList.forEach(s -> System.out.println(s.getName()));
    }
}

요소를 그룹핑해서 수집

collect() 메소드는 단순히 요소를 수집하는 기능 이외에 컬렉션의 요소들을 그룹핑해서 Map객체를 생성하는 기능도 제공합니다. collect()를 호출할 때 Collectors의 groupingBy() 또는 groupingByConcurrent()가 리턴하는 Collector를 매개값으로 대입하면 됩니다. groupingBy()는 스레드에 안전하지 않는 Map을 생성하지만, groupingByConcurrent()는 스레드에 안전한 ConcurrentMap을 생성합니다.

아래 코드는 학생들을 성별로 그룹핑하고 나서, 같은 그룹에 속하는 학생 List를 생성한 후, 성별을 키로, 학생 List를 값으로 갖는 Map을 생성합니다. collect()의 매개값으로 groupingBy(Function<T,K> classifier)를 사용하였습니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// Student를 Student.Sex로 매핑하는 Function을 얻습니다.
Function<Student, Student.Sex> classifier = Student :: getSex;
// Student.Sex가 키가 되고,  그룹핑된 List<Student>가 값인 Map을 생성하는 Collector를 얻습니다.
Collector<Student, ?, Map<Student.Sex, List<Student>>> collector = Collectors.groupingBy(classifier);
// Stream의 collect() 메소드로 Student를 Student.Sex 별로 그룹핑해서 Map을 얻습니다.
Map<Student.Sex, List<Student>> mapBySex = totalStream.collect(Collector);

또 다른 코드는 학생들을 거주 도시별로 그룹핑하고 나서, 같은 그룹에 속하는 학생들의 이름 List를 생성한 후, 거주 도시를 키로, 이름 List를 값으로 갖는 Map을 생성합니다. collect()의 매개값으로 groupingBy(Function<T,K> classifier, Collector<T,A,D> collector)를 사용하였습니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// Student를 Student.City로 매핑하는 Function을 얻습니다.
Function<Student, Student.City> classifier = Student :: getCity;

// Student의 이름을 List에 수집하는 Collector를 얻습니다.
Function<Student, String> mapper = Student :: getName;

Collector<String, ?, List<String>> collector1 = Collectors.toList();
// Collectors의 mapping() 메소드로 Student를 이름으로 매핑하고 이름을 List에 수집하는 Collector를 얻습니다.
Collector<Student, ?, List<String>> collector2 = Collectors.mapping(mapper, collector1);
// Student.City가 키이고, 그룹핑된 이름 List가 값인 Map을 생성하는 Collector를 얻습니다.
Collector<Student, ?, Map<Student.City, List<String>>> collector3 = Collectors.groupingBy(classifier, collector2);
// Stream의 collect() 메소드로 Student를 Student.City별로 그룹핑해서 Map을 얻습니다. 
Map<Student.City, List<String>> mapByCity = totalStream.collect(collector3);

위의 상기 코드에서 변수를 생략하면 다음과 같이 간단하게 작성이 가능합니다.

Map<Student.City, List<String>> mapByCity = totalList.stream()
        .collect(Collectors.groupingBy(
            Student :: getCity,
            Collectors.mapping(Student::getName, Collectors.toList())
        )
);

다음 코드는 위와 동일하지만, TreeMap 객체를 생성하도록 groupingBy(Function<T, K> classifier, Supplier<Map<K,D>> mapFactory, Collector<T,A,D> collector)를 사용했습니다.

Map<Student.City, List<String>> mapByCity = totalList.stream()
        .collect(Collectors.groupingBy(
            Student :: getCity,
            TreeMap :: new,
            Collectors.mapping(Student::getName, Collectors.toList())
        )    
);
//성별로 그룹핑하기
public class GroupingByExample {
    public static void main(String[] args) {
        List<StudyMember> totalList = Arrays.asList(
                new StudyMember("홍길동", 10, StudyMember.Sex.MALE, StudyMember.City.Seoul),
                new StudyMember("김수애", 6, StudyMember.Sex.FEMALE, StudyMember.City.Pusan),
                new StudyMember("신용권", 10, StudyMember.Sex.MALE, StudyMember.City.Pusan),
                new StudyMember("박수미", 6, StudyMember.Sex.FEMALE, StudyMember.City.Seoul)
        );

        Map<StudyMember.Sex, List<StudyMember>> mapBySex = totalList.stream()
                                                                    .collect(
                                                                            Collectors.groupingBy(
                                                                                    StudyMember :: getSex
                                                                            )
                                                                    );
        System.out.print("[남학생] ");
        mapBySex.get(StudyMember.Sex.MALE).stream()
                .forEach(s -> System.out.print(s.getName() + " "));

        System.out.print("\n[여학생] ");
        mapBySex.get(StudyMember.Sex.FEMALE).stream()
                .forEach(s -> System.out.print(s.getName() + " "));


        System.out.println();

        Map<StudyMember.City, List<String>> mapByCity = totalList.stream()
                                                                 .collect(
                                                                        Collectors.groupingBy(
                                                                            StudyMember :: getCity,
                                                                            Collectors.mapping(StudyMember :: getName, Collectors.toList())
                                                                        )
                                                                 );

        System.out.print("\n[서울] ");
        mapByCity.get(StudyMember.City.Seoul).stream()
                .forEach(s -> System.out.print(s + " "));
        System.out.print("\n[부산] ");
        mapByCity.get(StudyMember.City.Pusan).stream()
                .forEach(s -> System.out.print(s + " "));
    }
}

실행 결과

스크린샷 2019-12-05 오전 3 00 26

참조: 이것이 자바다

'SpringFramework > JAVA' 카테고리의 다른 글

객체지향 프로그래밍  (0) 2020.01.01
멀티 스레드의 개념  (0) 2019.12.30
스트림 처리 메소드 1편  (0) 2019.12.04
람다식을 통한 메소드 참조  (0) 2019.12.03
제네릭을 사용하는 이유?  (1) 2019.12.03

내부 반복자를 사용하므로 병철 처리가 쉽다

외부 반복자란 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴을 말합니다. index를 이용하는 for문 그리고 Iterator를 이용하는 while문은 모두 외부 반복자를 이용하는 것입니다. 반면에 내부 반복자는 컬렉션 내부에서 요소들을 반복시키고, 개발자는 요소당 처리해야 할 코드만 제공하는 코드 패턴을 말합니다. 아래 그림을 보면 알수 있습니다.

외부 반복자와 내부반복자 그림

스크린샷 2019-12-03 오후 7 54 31

내부 반복자를 사용해서 얻는 이점은 컬렉션 내부에서 어떻게 요소를 반복시킬 것인가는 컬렉션에 맡겨두고, 개발자는 요소 처리 코드에만 집중할 수 있다는 것입니다. 내부 반복자는 요소들의 반복 순서를 변경하거나, 멀티 코어 CPU를 최대한 활용하기 위해 요소들을 분배시켜 병렬 작업을 할 수 있게 도와주기 때문에 하나씩 처리하는 순차적 외부 반복자보다는 효율적으로 요소를 반복시킬 수 있습니다.

병렬 처리란?

병렬 처리란 한 가지 작업을 서브 작업으로 나누고, 서브 작업들을 분리된 스레드에서 병렬적으로 처리하는 것을 말합니다. 병렬 처리 스트림을 이용하면 런타임 시 하나의 작업을 서브 작업으로 자동으로 나누고, 서브 작업의 결과를 자동으로 결합해서 최종 결과물을 생성합니다. 예를들어 컬렉션 요소 총합을 구할 때 순차 처리 스트림은 하나의 스레드가 요소들을 순차적으로 읽어 합을 구하지만, 병렬 처리 스트림을 이용하면 여러 개의 스레드가 요소들을 부분적으로 합하고 이 부분합을 최종 결합해서 전체 합을 생성합니다.

아래 코드는 순차 처리 스트림과 병렬 처리 스트림을 이용할 경우, 사용된 스레드의 이름이 무엇인지 콘솔에서 출력하는 예제입니다. 실행결과를 보면 병렬 처리 스트림은 main 스레드를 포함해서 ForkJoinPool(스레드 풀)의 작업 스레드들이 병렬적으로 요소를 처리하는 것을 볼 수 있습니다.

//병렬처리
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class ParallelExample {

    public static void main(String[] args) {
        List<String> list = Arrays.asList(
                "홍길동", "신용권", "김자바"
                ,"람다식","박병렬"
        );
        // 순차 처리
        Stream<String> stream = list.stream();
        stream.forEach(ParallelExample :: print);
        System.out.println();

        // 병렬 처리
        Stream<String> parallelStream = list.parallelStream();
        parallelStream.forEach(ParallelExample::print);

    }

    private static void print(String str) {
        System.out.println(str + " : " + Thread.currentThread().getName());
    }
}

실행결과

스크린샷 2019-12-03 오후 8 34 16

스트림은 중간 처리와 최종 처리를 할 수 있습니다.

스트림은 컬렉션 요소에 대해 중간 처리와 최종 처리를 수행할 수 있는데, 중간 처리에서는 매핑, 필터링, 정렬을 수행하고 최종 처리에서는 반복, 카운팅, 평균, 총합 등의 집계
처리를 수행합니다.

아래 예제는 List에 저장되어 있는 Student 객체를 중간 처리에서 score 필드 값으로 매핑하고, 최종 처리에서 score 평균값을 산출합니다.

import java.util.Arrays;
import java.util.List;

public class MapAndReduceExample {
    public static void main(String[] args) {

        List<Student> students = Arrays.asList(
                new Student("임준영",100),
                new Student("배성탑",90),
                new Student("임광빈",80),
                new Student("양아름",75)
        );

        double avg = students.stream()
                .mapToInt(Student::getScore) // 중간처리(학생 객체를 점수로 매핑)
                .average()
                .getAsDouble();

        System.out.println("평균 점수: " + avg);
    }
}

스트림의 종류

자바 8부터 새로 추가된 java.util.stream 패키지에는 스트림 API들이 포진하고 있습니다. 패키지 내용을 보면 BaseStream 인터페이스를 부모로 해서 자식 인터페이스들이 아래 이미지처럼 상속 관계를 이루고 있습니다.

스크린샷 2019-12-03 오후 10 00 15

BaseStream 인터페이스에는 모든 스트림에서 사용할 수 있는 공통 메소드들이 정의되어 있을 뿐 코드에서 직접적으로 사용되지는 않습니다. 하위 스트림인 Stream, IntStream,
LongStream, DoubleStream이 직접적으로 이용되는 스트림인데, Stream은 객체 요소를 처리하는 스트림이고, IntStream,LongStream, DoubleStream은 각각 기본 타입인 int, long, double 요소를 처리하는 스트림입니다. 이 스트림 인터페이스의 구현 객체는 다양한 소스로부터 얻을 수 있습니다. 주로 컬렉션과 배열에서 얻지만, 다음과 같은 소스로부터 스트림 구현 객체를 얻을 수도 있습니다.

스크린샷 2019-12-15 오후 11 31 37

컬렉션으로부터 스트림 얻기

다음 예제는 List 컬렉션에서 Stream를 얻어내고 요소를 콘솔에 출력합니다.

import java.util.Arrays;
import java.util.List;

public class FromCollectionExample {
    public static void main(String[] args) {

        List<Student> students = Arrays.asList(
                new Student("임준영",100),
                new Student("배성탑",90),
                new Student("임광빈",80)
        );

        students.forEach(s -> System.out.println(s.getName()));

    }
}


package stream;

public class Student {

    private String name;
    private int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

}

숫자 범위부터 스트림 얻기

아래 예제 코드는 1부터 100까지의 합을 구하기 위해 IntStream의 rangeClosed() 메소드를 이용하였습니다. rangeClosed()는 첫 번째 매개값에서부터 두 번째 매개값까지 순차적으로 제공하는 IntStream을 리턴한다. IntStream의 또 다른 range() 메소드도 동일한 IntStream을 리턴하는데, 두 번째 매개값은 포함하지 않습니다.

import java.util.stream.IntStream;

public class FromIntRangeExample {

    public static int sum;

    public static void main(String[] args) {

        IntStream intStream = IntStream.rangeClosed(1,100);
        intStream.forEach(a -> sum += a);
        System.out.println("총합 :" +  sum);

    }
}

파일로부터 스트림 얻기

아래 예제 코드는 Files의 정적 메소드인 lines()와 BufferedReader의 lines() 메소드를 이용하여 문자 파일의 내용을 스트림을 통해 행 단위로 읽고 콘솔에 출력합니다.

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class FromFileContentExample {

    public static void main(String[] args) throws IOException {

        // 파일의 경로 정보를 가지고 있는 Path 객체 생성
        Path path = Paths.get("/Users/limjun-young/workspace/privacy/linedata.txt");
        Stream<String> stream;

        //Files.Line() 메소드 이용
        stream = Files.lines(path, Charset.defaultCharset());
        stream.forEach(System.out :: println);
        System.out.println();

        //BufferedReader의 lines() 메소드 이용
        File file = path.toFile();
        FileReader fileReader = new FileReader(file);
        BufferedReader bufferedReader = new BufferedReader(fileReader);
        stream = bufferedReader.lines();
        stream.forEach(System.out :: println);

    }

}

실행 결과

스크린샷 2019-12-04 오전 12 19 35

디렉토리부터 스트림 얻기

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class FromDirectoryExample {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("/Users/limjun-young/workspace/privacy");
        Stream<Path> stream = Files.list(path);
        stream.forEach(p -> System.out.println(p.getFileName()));

    }
}

실행 결과

스크린샷 2019-12-04 오전 12 27 44

스트림 파이프라인

대량의 데이터를 가공해서 축소하는 것을 일반적으로 리덕션이라고 합니다. 데이의 합계, 평균값, 카운팅, 최대값, 최소값 등이 대표적인 리덕션의 결과물이라고 볼 수 있습니다. 그러나 컬렉션의 요소를 리덕션의 결과물로 바로 집계할 수 없을 경우에는 집계하기 좋도록 필터링, 매핑, 정렬, 그룹핑 등의 중간 처리가 필요합니다.

스크린샷 2019-12-04 오전 12 42 08

중간 스트림이 생성될 때 요소들이 바로 중간처리(필터링, 매핑, 정렬)되는 것이 아니라 최종 처리가 시작되기 전까지 중간 처리는 지연 됩니다. 최종 처리가 시작되면 비로소 컬렉션 요소가 하나씩 중간 스트림에서 처리되고 최종 처리까지 오게 됩니다.

Stream 인터페이스에는 필터링, 매핑, 정렬 등의 많은 중간 처리 메소드가 있습니다. 이 메소드들은 중간 처리된 스트림을 리턴합니다. 그리고 이 스트림에서 다시 중간 처리 메소드를 호출해서 파이프라인을 형성하게 됩니다. 예를 들어 회원 컬렉션에서 남자만 필터링하는 중간 스트림을 연결하고, 다시 남자의 나이로 매핑하는 스트림을 연결한 후, 최종 남자 평균 나이를 집계한 다면 다음 그림처럼 파이프라인이 형성됩니다.

스크린샷 2019-12-04 오전 12 50 08

// 스트림 파이프 라인
import java.util.Arrays;
import java.util.List;

public class StreamPipelinesExample {

    public static void main(String[] args) {
        List<Member> members = Arrays.asList(
                new Member("홍길동", Member.MALE, 30),
                new Member("김나리", Member.FEMALE, 20),
                new Member("신용권", Member.MALE, 45),
                new Member("박수미", Member.FEMALE, 27)
        );

        double ageAvg = members.stream()  
                .filter(m -> m.getSex() == Member.MALE) 
                .mapToInt(Member::getAge)
                .average()
                .getAsDouble();

        System.out.println("남자 평균 나이: " + ageAvg);
    }
}


// 회원 클래스
public class Member {

    public static int MALE = 0;
    public static int FEMALE = 1;

    private String name;
    private int sex;
    private int age;

    public Member(String name, int sex, int age) {
        this.name = name;
        this.sex = sex;
        this.age = age;
    }

    public int getSex() {
        return sex;
    }

    public int getAge() {
        return age;
    }
}
double ageAvg = members.stream() <- 오리지날 스트림
 .filter(m -> m.getSex() == Member.MALE)  <- 중간 처리 스트림
               .mapToInt(Member::getAge)  <- 중간 처리 스트림
               .average()
               .getAsDouble();            <- 최종 처리 

filter(m -> m.getSex() == Member.MALE)는 남자 Member 객체를 요소로 하는 새로운 스트림을 생성합니다. mapToInt(Member :: getAge())는 Member 객체를 age 값으로 매핑해서 age를 요소로 하는 새로운 스트림을 생성합니다. average() 메소드는 age 요소들의 평균을 OptionalDuble에 저장합니다. OptionalDouble에서 저장된 평균값을 읽으려면 getAsDouble() 메소드를 호출하면 됩니다.

중간 처리 메소드와 최종 처리 메소드를 쉽게 구분하는 방법은 리턴 타입을 보면 됩니다. 리턴 타입이 스트림이면 중간 처리 메소드이고, 기본 타입이거나 OptionalXXX라면 최종 처리 메소드 입니다. 소속된 인터페이스에서 공통의 의미는 Stream, IntStream, LongStream, DoubleStream에서 모두 제공된다는 뜻입니다.

필터링(distinct(), filter())

필터링은 중간 처리 기능으로 요소를 걸러내는 역할을 합니다. 필터링 메소드인 distinct()와 filter()메소드는 모든 스트림이 가지고 있는 공통 메소드 입니다.

distinct() 메소드는 중복을 제거하는데, Stream의 경우 Object.equals(Object)가 true이면 동일한 객체로 판단하고 중복을 제거합니다. IntStream, LongStream, DoubleStream은 동일값일 경우 중복을 제거합니다.

아래 예제는 이름 List에서 중복된 이름을 제거하고 출력합니다. 그리고 성이 "신"인 이름만 필터링해 서 출력합니다.

import java.util.Arrays;
import java.util.List;

public class FilteringExample {

    public static void main(String[] args) {
        List<String> names = Arrays.asList(
                "홍길동", "신용권", "김자바", "신용권", "신민철"
        );

        names.stream()
                .distinct()
                .filter(s -> s.startsWith("신"))
                .forEach(System.out::println);
    }

}

매핑(flatMapXXX(), mapXXX(), asXXXStream(), boxed())

매핑은 중간 처리 기능으로 스트림의 요소를 다른 요소로 대체하는 작업을 말합니다.
스트림에서 제공하는 매핑 메소드는 flatXXX()와 mapXXX(),그리고 asDoubleStream(),asLongStream(), boxed()가 있습니다.

flatMapXXX() 메소드

flatMapXXX() 메소드는 요소를 대체하는 복수 개의 요소들로 구성된 새로운 스트림을 리턴합니다.

Untitled Diagram

아래 예제는 입력된 데이터들이 List에 저장되어 있다고 가정하고, 요소별로 단어를 뽑아 단어 스트림으로 재생성합니다. 만약 입력된 데이터들이 숫자라면 숫자를 뽑아 숫자 스트림으로 재생성합니다.

import java.util.Arrays;
import java.util.List;

public class FlatMapExample {
    public static void main(String[] args) {
        List<String> inputList1 = Arrays.asList("java8 lamda", "stream mapping");


        inputList1.stream()
                  .flatMap(data -> Arrays.stream(data.split(" ")))
                  .forEach(System.out :: println);


        List<String> inputList2 = Arrays.asList("10, 20, 30", "40, 50, 60");

        inputList2.stream()
                  .flatMapToInt(data -> {
                      String[] strArr = data.split(",");
                      int[] intArr = new int[strArr.length];
                      for (int i = 0; i < strArr.length; i++) {
                          intArr[i] = Integer.parseInt(strArr[i].trim());
                      }
                      return Arrays.stream(intArr);
                  }).forEach(number -> System.out.println(number));
    }

}

스트림 메소드 2편

참조: 이것이 자바다

'SpringFramework > JAVA' 카테고리의 다른 글

멀티 스레드의 개념  (0) 2019.12.30
스트림 메소드 2편  (0) 2019.12.05
람다식을 통한 메소드 참조  (0) 2019.12.03
제네릭을 사용하는 이유?  (1) 2019.12.03
프록시 패턴  (0) 2019.11.20

람다식을 이용한 메소드 참조

메소드의 참조는 말 그대로 메소드를 참조해서 매개 변수의 정보 및 리턴 타입을 알아내어, 람다식에서 불필요한 매개 변수를 제거하는 것이 목적입니다. 람다식은 종종 기존 메소드를 단순히 호출만 하는 경우가 많습니다. 예를 들어 두 개의 값을 받아 큰 수를 리턴하는 Math 클래스의 max() 정적 메소드를 호출하는 람다식은 다음과 같습니다.

(left, right) -> Math.max(left, right);

람다식은 단순히 두개의 값을 Math.max() 메소드의 매개값으로 전달하는 역할만 하기 때문에 다소 불편해 보입니다. 이 경우 다음과 같이 메소드 참조를 이용하면 매우 깔끔하게 처리할 수 있습니다.

Math :: max; <- 메소드 참조

메소드의 참조도 람다식과 마찬가지로 인터페이스의 익명 구현 객체로 생성되므로 타겟 타입인 인터페이스의 추상 메소드가 어떤 매개 변수를 가지고, 리턴 타입이 무엇인가에 따라 달라집니다. IntBinaryOperator 인터페이스는 두 개의 int 매개값을 받아 int 값을 리턴하므로 Math :: max 메소드 참조를 대입 할 수 있습니다.

// 메소드 참조 
IntBinaryOperator operator = Math :: max;
System.out.println(operator.applyAsInt(1, 3));

메소드 참조는 정적 또는 인스턴스 메소드를 참조할 수 있고, 생성자 참조도 가능합니다.

정적 메소드와 인스턴스 메소드 참조

클래스 :: 메소드

인스턴스 메소드일 경우에는 먼저 객체를 생성한 다음 참조 변수 뒤에 :: 기호를 붙이고 인스턴스 메소드 이름을 기술하면 됩니다.

참조변수 :: 메소드

아래 예제는 Calculator의 정적 및 인스턴스 메소드를 참조합니다. 람다식이 메소드 참조로 대체되는 것을 기억해야 합니다.

public class ReferenceMethod {

    public static void main(String[] args) {

        IntBinaryOperator operator;

        // 정적 메소드 참조
        operator = (x, y) -> Calculator.staticMethod(x,y);
        System.out.println("결과1: " + operator.applyAsInt(2,3));

        // 함수적 인터페이스의 추상 메소드와 스펙이 같아야만 메소드 참조를 통해서 익명구현 객체 생성이 가능합니다.
        operator = Calculator ::staticMethod;
        System.out.println("결과2: " + operator.applyAsInt(2,4));

        // 인스턴스 메소드 참조
        Calculator calculator = new Calculator();
        operator = (x, y) -> calculator.instanceMethod(x, y);
        System.out.println("결과3: " + operator.applyAsInt(3, 6));

        operator = calculator :: instanceMethod;
        System.out.println("결과4: " + operator.applyAsInt(1,9));
    }
}

매개 변수의 메소드 참조

메소드는 람다식 외부의 클래스 맴버일 수도 있고, 람다식에서 제공되는 매개 변수의 맴버일 수도 있습니다. 이전 예제에서는 람다식 외부의 클래스 멤버인 메소드를 호출 하였습니다. 그러나 다음 람다식에서 제공되는 a 매개 변수의 메소드를 호출해서 b 매개 변수를 매개값으로 사용하는 경우도 있습니다.

(a,b) -> { a.instanceMethod(b); }

이것을 메소드 참조로 표현하면 아래 코드와 같습니다.

클래스 :: instanceMethod;

아래 예제는 두 문자열이 대소문자와 상관없이 동일한 알파벳으로 구성되어 있는지 비교합니다. 비교를 위해 사용된 메소드는 String의 인스턴스 메소드인 compareToIgnoreCase() 입니다. a.compareToIgnoreCase(b)를 호출될 때 사전 순으로 a가 b보다 먼저 오면 음수를, 동일하면 0을, 나중에 오면 음수를 리턴합니다. 사용된 함수적 인터페이스는 두 String 매개값을 받고 int 값을 리턴하는 ToBiFunction<String, String> 입니다.

import java.util.function.ToIntBiFunction;

public class ArgumentMethodReferencesExample {

    public static void main(String[] args) {

        ToIntBiFunction<String, String> function;

        function = (a, b) -> a.compareToIgnoreCase(b);
        print(function.applyAsInt("Java8", "JAVA8"));

        function = String::compareToIgnoreCase;
        print(function.applyAsInt("Java8", "JAVA8"));
    }

    public static void print(int order){
        if(order < 0){
            System.out.println("사전순으로 먼저 옵니다.");
        }else if(order == 0){
            System.out.println("동일한 문자열입니다.");
        }else{
            System.out.println("사전순으로 나중에 옵니다.");
        }
    }
}

생성자 참조

메소드 참조는 생성자 참조도 포함합니다. 생성자를 참조한다는 것은 객체 생성을 의미합니다. 단순히 메소드 호출로 구성된 람다식을 메소드 참조로 대치할 수 있듯이, 단순히 객체를 생성하고 리턴하도록 구성된 람다식은 생성자 참조로 대치할 수 있습니다. 아래 코드를 보면 람다식은 단순히 객체 생성 후 리턴만 합니다.

(a,b) -> { return new 클래스(a,b); }

이 경우, 생성자 참조로 표현하면 다음과 같습니다. 클래스 이름 뒤에 :: 기호를 붙이고 new 연산자를 기술하면 됩니다. 생성자가 오버로딩 되어 여러 개가 있을 경우, 컴파일러는 함수적 인터페이스의 추상 메소드와 동일한 매개 변수 타입과 개수를 가지고 있는 생성자를 찾아 실행합니다. 만약 해당 생성자가 존재하지 않으면 컴파일 오류가 발생합니다.

클래스 :: new

다음 예제는 생성자 참조를 이용해서 두가지 방법으로 Member 객체를 생성합니다. 하나는 Function<String, Member> 함수적 인터페이스의 Member apply(String) 메소드를 이용해서 Member 객체를 생성하였고, 다른 하나는 BiFunction<String, String, Member> 함수적 인터페이스의 Member apply(String, String) 메소드를 이용해서 Member 객체를 생성하였습니다. 생성자 참조는 두 가지 방법 모두 동일하지만,실행되는 Member 생성자가 다름을 볼 수 있습니다.

import java.util.function.BiFunction;
import java.util.function.Function;

public class ConstructorReferencesExample {

    public static void main(String[] args) {

        // 생성자 참조
        Function<String, Member> function1 = Member::new;
        // 매개 값 1개
        Member member1 = function1.apply("angel");

        BiFunction<String, String, Member> function2 = Member::new;
        Member member2 = function2.apply("신천사", "angerl");


        System.out.println(member1.getId());
        System.out.println(member2.getId());
    }
}

실행 결과

스크린샷 2019-12-03 오전 3 31 06

참조: 이것이 자바다

'SpringFramework > JAVA' 카테고리의 다른 글

스트림 메소드 2편  (0) 2019.12.05
스트림 처리 메소드 1편  (0) 2019.12.04
제네릭을 사용하는 이유?  (1) 2019.12.03
프록시 패턴  (0) 2019.11.20
Object- 1장 객체, 설계  (0) 2019.11.04

제네릭이란?

자바 5부터 제네릭(Generic) 타입이 새롭게 추가 되었는데, 제네릭 타입을 이용함으로써 잘못된 타입이 사용 될 수 있는 문제를 컴파일 과정에서 제거할 수 있습니다. 사실 API 문서를 볼때마다 제네릭 표현이 많기 때문에 제네릭을 이해하지 못하면 API 도큐먼트를 정확히 이해할 수 없습니다. 이러한 이유로 제네릭 타입에 대해서 알아보고 간단한 예제코드를 작성하였습니다.

제네릭은 클래스와 인터페이스, 그리고 메소드를 정의할 때 타입(type)파라미터(parameter)로 사용할 수 있도록 합니다. 타입 파라미터는 코드 작성 시 구체적인 타입으로 대체되어 다양한 코드를 생성하도록 해줍니다.

제네릭을 사용하는 이유?

  • 컴파일 시 강한 타입 체크를 할 수 있습니다.
    자바 컴파일러 코드에서 잘못 사용된 타입 때문에 발생하는 문제점을 제거하기 위해 제네릭 코드에 대한 강한 타입 체크를 합니다. 실행 시 타입 에러가 나는 것보다는 컴파일 시에 미리 타입을 강하게 체크해서 에러를 사전에 방지하는 것이 좋습니다.

  • 타입 변환을 제거합니다.
    비제네릭 코드는 불필요한 타입 변환을 하기 때문에 프로그램 성능에 악영향을 미칩니다. 다음 아래 코드를 보면 List에 문자열 요소를 저장했지만, 요소를 찾아올 때는 반드시 String으로 타입 변환을 해야합니다.

List list = new ArrayList();
list.add("hello");
String str = (String) list.get(0);

다음과 같이 제네릭 코드로 수정하면 List에 저장되는 요소를 String 타입으로 국한하기 때문에 요소를 찾아올 때 타입 변환을 할 필요가 없어 프로그램 성능이 향상됩니다.

List<String> list = new ArrayList<String>();
list.add("hello");
String str = list.get(0); // 타입 변환을 하지 않습니다.

제네릭 타입(class, interface)

제네릭 타입은 타입을 파라미터로 가지는 클래스와 인터페이스를 말합니다. 제네릭 타입은 클래스 또는 인터페이스 이름 뒤에 "<>"부호가 붙고, 사이에 타입 파라미터가 위치합니다.

ex)

public class 클래스명<T> {...}
public interface 인터페이스명<T> {...}

타입 파라미터는 변수명과 동일한 규칙에 따라 작성할 수 있지만, 일반적으로 대문자 알파벳 한 글자로 표현합니다. 제네릭 타입을 실제 코드에서 사용하려면 타입 파라미터 구체적인 타입을 지정해야 합니다.

제네릭을 이용한 Box 클래스 예제

public class Box<T>{
    // 클래스 뒤에 <T> 타입 파라미터를 명시했기 때문에 변수의 타입으로 사용 가능합니다.
    private T t; 
    public T get() { return t; }
    public void set(T t){ this.t = t; }
}

이제 구체적인 타입으로 변경하는 코드를 작성하겠습니다.

// 타입 파라미터를 String 타입으로 변경
Box<String> box = new Box<String>();

// 타입 파라미터 T는 String 타입으로 변경되어 Box 클래스의 내부는 다음과 같이 자동으로 재구성 됩니다.
public class Box<String>{
    private String t;
    public String get() { return t; }
    public void set(String t) { this.t = t; }
}

타입 파라미터 T를 Integer 타입으로 변경한다고 하면 마찬가지로 같습니다.

Box<Integer> box = new Box<Integer>();
box.set(6) // 자동 Boxing
int value = box.get(); // 자동 UnBoxing

이와 같이 제네릭은 클래스를 설계할 때 구체적인 타입을 명시하지 않고, 타입 파라미터로 대체했다가 실제 클래스가 사용될 때 구체적인 타입을 지정함으로써 타입 변환을 최소화 시킵니다.

멀티 타입 파라미터(class<K,V,...>, interface<K,V,...>)

제네릭 타입은 두개 이상의 멀티 타입 파라미터를 사용할 수 있습니다. 이 경우 각 타입 파라미터를 콤마로 구분합니다.

ex) 멀티 타입 파라미터를 가진 제네릭 타입을 정의하고 호출하는 예제

public class Product<T, M> {

    private T t;
    private M m;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }

    public M getM() {
        return m;
    }

    public void setM(M m) {
        this.m = m;
    }
}

public class GenericExample {
    public static void main(String[] args) {
        Product<Tv, String> product1 = new Product<>();

        product1.setT(new Tv("삼성전자Tv"));
        product1.setM("디젤");

        Tv tv = product1.getT();
        String name = product1.getM();

        System.out.println(tv.getName() +" " + name);
    }
}

제네릭 메소드(<T,R> R method(T t))

제네릭 메소드는 매개 타입과 리턴 타입으로 파라미터를 갖는 메소드를 말합니다. 제네릭 메소드를 선언하는 방법은 리턴 타입 앞에 <>기호를 추가하고 타입 파라미터를 기술한 다음, 리턴 타입과 매개 타입으로 타입 파라미터를 사용하면 됩니다.

public <타입 파라미터,...> 리턴타입 메소드명(매개변수,...){...}

다음 boxing() 제네릭 메소드 <> 기호 안에 타입 파라미터 T를 기술한 뒤, 매개 변수 타입으로 T를 사용했고, 리턴 타입으로 제네릭 타입 Box를 사용했습니다.

public <T> Box<T> boxing(T t) {...}

제네릭 메소드는 두가지 방식으로 호출할 수 있습니다. 코드에서 타입 파라미터의 구체적인 타입을 명시적으로 지정해도 되고, 컴파일러가 매개값의 타입을 보고 구체적인 타입을 추정하도록 할 수 도 있습니다.

리턴타입 변수 = <구체적인 타입> 메소드명(매개 값);
리턴타입 변수 = 메소드명(매개 값);

ex) 제네릭 메소드 호출 예제

public class Util {

    //제네릭 메소드 선언방법: 리턴 타입 앞에 타입파라미터 기술 후에 리턴 타입과, 매개타입으로 타입 파라미터를 사용하면 됩니다.
    public static <T> Box<T> boxing(T t){
        Box<T> box = new Box<>();
        box.set(t);
        return box;
    }
}

public class BoxingMethodExample{
    public static void main(String[] args){
        // 매개 값의 타입으로 자바 컴파일러에서 타입을 추정합니다.
        Box<Integer> box = Util.boxing(100);
        int initValue = box.get();
        System.out.println(initValue);
    }
}

다음 예제는 Util 클래스에 정적 제네릭 메소드로 compare()를 정의하고 CompareMethodExample 클래스에서 호출했습니다. 타입 파라미터는 K,V로 선언되었는데, 제네릭 타입 Pair가 K와 V를 가지고 있기 때문입니다. compare() 메소드는 두 개의 Pair을 매개값으로 받아 K와 V 값이 동일한지 검사하고 boolean 값을 리턴한다.

public class Util{
    public static <K,V> boolean compare(Pair<K,V> p1, Pair<K,V> p2){

        boolean keyCompare = p1.getKey().equals(p2.getKey());
        boolean valueCompare = p1.getValue().equals(p2.getValue());
        return KeyCompare && valueCompare;
    }
}

public class Pair<K,V> {


    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public void setKey(K key) {
        this.key = key;
    }

    public void setValue(V value) {
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }

}

public class CompareMethodExample{
    public static void main(String[] args){
          Pair<Integer, String> p1 = new Pair<Integer, String>(1, "사과");
          Pair<Integer, String> p2 = new Pair<Integer, String>(1, "사과");

          // 구체적 타입을 명시적으로 지정합니다.
          boolean result1 = Util.<Integer, String>compare(p1,p2);
          if(result1){
               System.out.println("논리적으로 등등한 객체입니다.");
          }else{
              System.out.println("논리적으로 등등하지 않는 객체입니다.");
          }

          Pair<String, String> pair = new Pair<>("user1","홍길동");
          Pair<String, String> pair = new Pair<>("user2","홍길동");
          // 구체적인 타입을 주정합니다.
          boolean result2 = Util.compare(p3,p4);

          f(result2){
               System.out.println("논리적으로 등등한 객체입니다.");
          }else{
              System.out.println("논리적으로 등등하지 않는 객체입니다.");
          }

    }
}

제한된 타입 파라미터(<T extends 최상위타입>)

타입 파라미터에 지정되는 구체적인 타입을 제한할 필요가 종종 있습니다. 예를 들어 숫자를 연산하는 제네릭 메소드는 매개값으로 Number 타입 또는 하위 클래스 타입(Byte, Short, Double, Long, Integer)의 인스턴스만 가져와야 합니다. 이것이 제한된 타입 파라미터가 필요한 이유입니다.

ex)

// 제한된 타입 파라미터 정의
public <T extends 상위타입> 리턴타입 메소드(매개변수,...){...}

타입 파라미터에 지정되는 구체적인 타입은 상위 타입이거나 상위 타입의 하위 또는 구현 클래스만 가능합니다.

주의할점은 메소드의 중괄호 {} 안에서 타입 파라미터 변수로 사용 가능 한 것은 상위 타입의 맴버(필드, 메소드)로 제한됩니다. 하위 타입에만 있는 필드와 메소드는 사용할 수 없습니다. 아래 코드는 숫자 타입만 구체적인 타입으로 갖는 제네릭 메소드 compare() 입니다. 두 개의 숫자 타입을 매개 값으로 받아 차이를 리턴합니다.

    // 제한된 타입 파라미터 정의 구체적인 타입을 제한하기 위해 사용합니다.
    public static <T extends Number> int compare(T t1, T t2){

        double v1 = t1.doubleValue();
        double v2 = t2.doubleValue();

        return Double.compare(v1,v2);
    }

doubleValue() 메소드는 Number 클래스에 정의되어 있는 메소드로 숫자를 double 타입으로 변환합니다. Double.compare() 메소드는 첫 번째 매개값이 작으면 -1을, 같으면 0을, 크면 1을 리턴합니다.

'SpringFramework > JAVA' 카테고리의 다른 글

스트림 처리 메소드 1편  (0) 2019.12.04
람다식을 통한 메소드 참조  (0) 2019.12.03
프록시 패턴  (0) 2019.11.20
Object- 1장 객체, 설계  (0) 2019.11.04
Stream(스트림)  (0) 2019.10.05

+ Recent posts