728x90
반응형

서론

작년 3월, 사내 프로젝트에서 Spring Scheduler 개발을 진행했었습니다. 👉이전글 참고

그리고 사내 관리자용 웹 서버를 nodejs -> spring boot 서버로 이관하는 작업을 진행하면서
다시한번 Spring Scheduler 개발을 진행할 기회가 생겼습니다.

본 글을 통해 Spring Scheduler 개발을 이전보다 어떻게 더 세련되게(?) 진행했는지에 대해서 다뤄보고자 합니다.
이번 개발에는 java8 에서 제공하는 기능들을 적극 활용하였는데,
관련된 부분은 👉람다표현식과 함수형인터페이스글에서 확인 바랍니다.

개발목표

개발 목표는 비슷합니다.

  1. 관리자가 스케줄러의 동작을 조절할 수 있어야 함 (시작/중지/주기변경)
  2. 스케줄러 동작의 조절은 런타임환경에서도 가능해야 함.

어떻게 관리자가 스케줄러의 동작을 런타임 환경에서 조절할 수 있는지에 대해서는 👉이전글을 참고바랍니다.

그리고 추가된 목표는 코드의 중복을 최소화하고, 확장이 보다 유용한 형태로 구현하는 것이었습니다.
이를 위해 함수형 인터페이스에 대한 이해가 필요했습니다. 👉람다표현식과 함수형인터페이스글 참고

이전 scheduler

이전 Scheduler 코드에는 중대한 문제점이 있었습니다.

특정 기능을 위한 스케줄러가 추가될 때, 각각의 클래스로 구현을 진행했습니다.
그런데 이러한 구현은 많은 낭비를 낳게 됩니다.

스케줄러 하나가 추가될 때마다 별도의 클래스를 생성해야 했고, 해당 클래스에는 다른 스케줄러 클래스와의 중복 코드도 같이 작성해야 했습니다.
또한, 클래스를 추가할 뿐만 아니라 기존 스케줄러 관련 코드에도 많은 수정이 필요했습니다. 자세한 코드는 👉이전글을 참고해주세요.

새롭게 구현하는 Scheduler는 이러한 비효율을 만들고 싶지 않았습니다.

좀더 세련된 Scheduler 구현하기 (feat. 함수형 인터페이스)

새롭게 Scheduler를 구현할 수 있게된 계기는 👉람다표현식과 함수형인터페이스글에서도 확인할 수 있는 것처럼
java8 이후부턴 함수의 동작도 파라미터에 입력할 수 있는 변수로 관리할 수 있다는 것을 알게된 것이었습니다.

이러한 java8의 특징을 적극 활용하여 Scheduler가 실행해야할 핵심적인 비즈니스 로직을 변수화시켜서,
Scheduler의 공통로직(ex. Scheduler 시작/중지/주기변경, 초기화, API 등)과 분리시켰습니다.

  • As-Was Scheduler 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class Sftp1Scheduler {

    // Spring Batch Bean
    private final JobLauncher jobLauncher;
    private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
    private final InterfaceFileReaderBatchJobConfig interfaceFileReaderBatchJobConfig;
    private final Sftp1Reader sftp1Reader;

    // Scheduler 설정 테이블 Jpa Repository
    private final SchedulerConfigRepository schedulerConfigRepository;

    // Scheduler 식별자
    private String schedulerIdx = "3";
    private String schedulerName = "Sftp1";

    // Scheduler 설정 값
    private String executionIp;
    private String executionYn;
    private String cron;

    // Scheduler가 실행시킬 task
    private ScheduledFuture<?> future;

    // 초기화
    @PostConstruct // AP가 실행되고 Spring 초기화 과정에서 메소드가 실행될 수 있도록 선언
    public void init() {
        try {
            if (!this.startScheduler()) {
                log.error();
            }
        }
        catch (Exception e) {
               log.error();
        }
    }

    // 스케줄러 시작
    public boolean startScheduler() throws Exception {
        if (this.future != null)
            return false;
        SchedulerConfig schedulerConfig = schedulerConfigRepository.findBySchedulerIdxAndSchedulerName(this.schedulerIdx, this.schedulerName);
        if (!validateExecution(schedulerConfig)) {
            return false;
        }
        this.executionIp = schedulerConfig.getExecutionIp();
        this.executionYn = schedulerConfig.getExecutionYn();

        this.cron = schedulerConfig.getCron();
        ScheduledFuture<?> future = this.threadPoolTaskScheduler.schedule(this.getRunnable(), this.getTrigger());
        this.future = future;
        return true;
    }

    // 스케줄러 종료
    public void stopScheduler() {
        if (this.future != null)
            this.future.cancel(true);
        this.future = null;
    }

    // 런타임 스케줄러 주기 변경
    public void changeCron(String cron) throws Exception {
        this.saveCron(cron);
        this.stopScheduler();
        this.cron = cron;
        this.startScheduler();
    }

    // 여기부터 클래스 내부에서만 쓸 메소드들
    private boolean validateExecution(SchedulerConfig schedulerConfig) throws UnknowsHostException {
        if (schedulerConfig == null) {
            return false;
        }
        String localIp = InetAddress.getLocalHost().getHostAddress();    // 현재 실행중인 서버 ip 조회

        if ("N".equals(schedulerConfig.getExecutionYn()) || !localIp.equals(schedulerConfig.getExecutionIp())) {
            return false;
        }
        return true;
    }

    // 스케줄러가 실행시킬 task
    private Runnable getRunnable() {
        return () -> {
            try {

                Step interfaceStep = interfaceFileReaderBatchJobConfig.interfaceStep(sftp1Reader.resultFlatFileItemReader);

                Map<String, JobParameter> confMap = new HashMap<>();
                confMap.put("time", new JobParameter(System.currentTimeMillis()));
                JobParameters jobParameters = new JobParameters(confMap);

                jobLauncher.run(interfaceFileReaderBatchJobConfig.interfaceJob(interfaceStep), jobParameters);

            }
            catch (JobExecutionAlreadyRunningException | JobInstanceAlreadyCompleteException | JobParametersInvalidException | JobRestartException e) {
                log.error();
            }
            catch (Exception e) {
                log.error();
            }
        }
    }

    // 스케줄러의 trigger
    private Trigger getTrigger() {
        return new CronTrigger(this.cron);
    }

    @Transactional
    private void saveCron(String cron) throws Exception {
        SchedulerConfig schedulerConfig = new SchedulerConfig();
        schedulerConfig.setSchedulerIdx(this.schedulerIdx);
        schedulerConfig.setSchedulerName(this.schedulerName);
        schedulerConfig.setCron(cron);
        schedulerConfig.setLastChgDt(new Date());
        schedulerConfig.setExecutionIp(this.executionIp);
        schedulerConfig.setExecutionYn(this.executionYn);
        schedulerConfigRepository.save(schedulerConfig);
    }

}
  • To-Be Scheduler 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class CommonScheduler {

    private final CommonSchedulerService commonSchedulerService;
    private final CommonSchedulerConfigRepository commonSchedulerConfigRepository;

    private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
    private Map<String, ScheduledFuture<?>> futureMap = new HashMap<>();
    private Map<String, Runnable> runnerbleMap = new HashMap<>();

    // 초기화 메서드
    @PostConstruct
    public void init() {
        try {

            List<CommonSchedulerConfig> commonSchedulerConfigList = commonSchedulerConfigRepository.findAll();
            for (CommonSchedulerConfig commonSchedulerConfig : commonSchedulerConfigList) {
                switch (commonSchedulerConfig.getSchedulerName()) {
                case "changeDateScheduler":
                    this.runnerbleMap.put("changeDateScheduler", commonSchedulerService::scheduleChangeDate);
                    if (!this.startScheduler("changeDateScheduler")) {
                        log.info("changeDateScheduler execution conditions not met");
                    }
                    break;
                case "autoSendMailScheduler":
                    this.runnerbleMap.put("autoSendMailScheduler", commonSchedulerService::scheduleAutoSendMail);
                    if (!this.startScheduler("autoSendMailScheduler")) {
                        log.info("autoSendMailScheduler execution conditions not met");
                    }
                    break;
                default:
                    break;
                }
            }

        } catch (Exception e) {
            log.error(e.getMessage());
            e.printStackTrace();
        }
    }

    // scheduler 시작 메서드
    public boolean startScheduler(String scheduleerName) throws Exception {
        CommonSchedulerConfig commonSchedulerConfig = commonSchedulerConfigRepository.findBySchedulerName(scheduleerName);
        if (this.futureMap.get(schedulerName) != null)
            return false;
        if (!this.validateExecution(commonSchedulerConfig, schedulerName)) {
            return false;
        }

        ScheduledFuture<?> future = this.threadPoolTaskScheduler.schedule(this.runnerbleMap.get(schedulerName), new CronTrigger(commonSchedulerConfig.getCron()));
        this.futureMap.put(schedulerName, future);
        return false;
    }

    public void stopScheduler(String schedulerName) {
        if (this.futureMap.get(schedulerName) != null)
            this.futureMap.get(schedulerName).cancel(true);
        this.futureMap.remove(schedulerName);
    }

    public void changeCron(String schedulerName, String cron) throws Exception {
        try {
            this.saveCron(schedulerName, cron);
            this.stopScheduler(schedulerName);
            this.startScheduler(schedulerName);
        } catch (Exception e) {
            log.error(e.getMessage());
            e.printStackTrace();
        }
    }

    private boolean validateExecution(CommonSchedulerConfig commonSchedulerConfig, String schedulerName) {
        if (commonSchedulerConfig == null) {
            log.info("no config data, scheduler : {}", schedulerName);
            return false;
        }
        if ("N".equals(commonSchedulerConfig.getExecutionYn())) {
            log.info("execution conditions not met, scheduler : {}", schedulerName);
            return false;
        }
        return true;
    }

    @Transsactional
    private void saveCron(String schedulerName, String cron) throws Exception {
        CommonSchedulerConfig commonSchedulerConfig = commonSchedulerConfigRepository.findBySchedulerName(scheduleerName);
        commonSchedulerConfig.updateCron(cron);
        commonSchedulerConfigRepository.save(commonSchedulerConfig);
    }
}



As-Was에서 별개의 클래스로 관리되던 Scheduler를 To-Be에서는 하나의 Scheduler 클래스로 통일하였고,
각각의 클래스에서 관리되던 멤버 변수들은 모두 DB로 관리될 수 있도록 하여,
필요할 때마다 DB에서 조회하여 사용할 수 있도록 수정하였습니다.

그리고 Scheduler가 실행시킬 비즈니스 로직을 해당 클래스의 메서드 형태로 관리하고 있었는데,
이를 변수화 시켜서 Map 자료구조로 Scheduler의 이름과 동작 변수를 매핑시켜서
통합 Scheduler의 멤버변수로 관리하였습니다.

해당 멤버변수의 초기화는 통합 Scheduler 클래스의 초기화 과정이 진행될 때, 모든 Scheduler 목록을 DB로부터 가져오고
사전 작성된 Switch문에서 Scheduler의 이름과 동작을 매핑시키는 과정에서 이루어졌습니다.
이러한 구현에는 () -> void 형태의 함수형 인터페이스 Runnable의 특징을 활용하였습니다.

이렇게 코드를 구성함으로써 스케줄러가 추가될 때마다 Scheduler가 실행시켜야할 비즈니스 로직을 별도 클래스로 구현하고,
통합 Scheduler 클래스의 초기화 메서드에서 Switch 조건만 추가하면 될수 있도록 구현되었습니다.
또한, 스케줄러 동작을 제어하는 API 서비스와 같은 Scheduler 관련 코드도 수정하지 않아도 될 정도로 유지보수의 효율성을 증대시켰습니다.

  • As-Was SchedulerController 코드
@Controller
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/scheduler")
public class SchedulerController {

    // 스케줄러 Bean 주입
    private final Sftp1Scheduler sftp1Scheduler;
    private final Sftp2Scheduler sftp2Scheduler;

    // sheduler 작업 시작
    @PostMapping("/start")
    @ResponseBody
    public ResponseEntity<?> requestStartScheduler(@RequestBody RequestSchedulerDto requestSchedulerDto) throws InterruptedException {
        ResponseSchedulerDto responseSchedulerDto = new ResponseSchedulerDto();
        String schedulerIdx = responseSchedulerDto.getSchedulerIdx();
        String schedulerName = responseSchedulerDto.getSchedulerName();
        boolean returnFlag = true;

        if (schedulerIdx == null || schedulerName == null) {
            responseSchedulerDto.setRsltCd("E1");
            responseSchedulerDto.setErrMsg("no request data");
        }

        try {
            if ("1".equals(schedulerIdx) && "InterfaceResult".equals(schedulerName)) {
                returnFlag = sftp1Scheduler.startScheduler();
            }
            else if ("2".equals(schedulerIdx) && "InterfaceResult2".equals(schedulerName)) {
                returnFlag = sftp2Scheduler.startScheduler();
            }
            else {
                log.error("no target scheduler");
                responseSchedulerDto.setRsltCd("E4");
                responseSchedulerDto.setErrMsg("no target scheduler");
                return new ResponseEntity<>(responseSchedulerDto, HttpStatus.OK);
            }

            if (returnFlag == false) {
                responseSchedulerDto.setRsltCd("E3");
                responseSchedulerDto.setErrMsg("already started scheduler or execution conditions not met");
            }
            responseSchedulerDto.setRsltCd("S");
            responseSchedulerDto.setErrMsg("");
        }
        catch (Exception e) {
            log.error(e.getMessage());
            responseSchedulerDto.setRsltCd("E2");
            responseSchedulerDto.setErrMsg("internal server error");
        }
        return new ResponseEntity<>(responseSchedulerDto, HttpStatus.OK);
    }

    ... 코드 생략 ...

}
  • To-Be ReleaseSchedulerConfigService
@Service
@Slf4j
@RequiredArgsConstructor
public class CommonSchedulerConfigService {

    private final CommonScheduler commonScheduler;

    public CommonResponse<String> requestStartScheduler(CommonSchedulerConfigReq commonSchedulerConfigReq) {
        CommonResponse<String> response = new CommonResponse<>();

        if (commonSchedulerConfigReq.getSchedulerName().isEmpty()) {
            response.setSuccess(false);
            response.setData("필수 파라미터가 누락되었습니다.");
            return response;
        }

        try {

            if (commonScheduler.startScheduler(commonSchedulerConfigReq.getSchedulerName())) {
                log.error("{}, already started or execution conditions not met", commonSchedulerConfigReq.getSchedulerName());
                response.setSuccess(false);
                response.setData(commonSchedulerConfigReq.getSchedulerName() + ", already started or execution conditions not met");
            }

        } catch (Exception e) {
            response.setSuccess(false);
            response.setData(e.toString());
            e.printStackTrace();
        }
        return response;
    }

    ... 코드 생략 ...

}



위 코드들은 http 요청으로 런타임환경의 Scheduler 동작을 제어할 수 있게하는 Service 로직입니다.
To-Be 코드에서 요청 파라미터로 받은 Scheduler의 이름을 통합 Scheduler 클래스의 파라미터로 입력하여 제어할 수 있도록 하였습니다.

이를 통해, 해당 코드에서도 마찬가지로 Scheduler가 추가될 때에도 해당 로직들을 수정하지 않아도 됩니다.

반응형
728x90
반응형





본 글은 모던자바인액션 책을 참고하여 작성한 글입니다.
👉🏻이전글에 이어 본 글에서는 람다표현식이 함수형 인터페이스에서 어떻게 활용되는지 알아보고,
람다표현식의 참조 기법과 디폴트 메서드에 대해 알아보겠습니다.

1. 표준 함수형 인터페이스에서의 람다표현식 사용

표준 함수형 인터페이스

Java8부터는 표준 함수형 인터페이스를 제공합니다.
매번 함수형 인터페이스를 정의하여 사용하는 번거로운 작업을 최소화시키기 위해서죠.
아래 사진은 대표적인 표준 함수형 인터페이스를 나타냅니다.




대표 case

위 사진 속 함수형 인터페이스 중 세 가지 정도만 알아보겠습니다.

Predicate

Predicate 인터페이스는 test라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 boolean을 반환하는 함수형 인터페이스입니다.
주로 특정 조건에 관한 판단기준을 정의할 때 활용됩니다.

  • Predicate 예제
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}
public <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> results = new ArrayList<>();
    for (T t : list) {
        if (p.test(t)) {
            results.add(t);
        }
    }
    return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);



Consumer

Consumer 인터페이스는 accept 라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 void를 반환하는 함수형 인터페이스입니다.
특정 동작을 인수로 받아서 실행시키고 싶을 때 주로 활용됩니다.

  • Consumer 예제
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

public <T> void forEach(List<T> list, Consumer<T> c) {
    for (T t : list) {
        c.accept(t);
    }
}
forEach( Arrays.asList(1, 2, 3, 4, 5), (Interger i) -> System.out.println(i) );



Function

Function 인터페이스는 apply 라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 제네릭 형식 R객체를 반환하는 함수형 인터페이스입니다.
특정 동작을 인수로 받아서 실행시킨 후에 반환해야 하는 값이 있을 때 주로 활용됩니다.

  • Function 예제
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

public <T, R> List<R> map(List<T> list, Function<T, R> f) {
    List<R> result = new ArrayList<>();
    for (T t : list) {
        result.add(f.apply(t));
    }
    return result;
}

// [7, 2, 6]
List<Integer> l = map(
    Arrays.asList("lambdas", "in", "action"),
    (String s) -> s.length()
);




이 밖에도 ComparatorSupplier와 같은 함수형 인터페이스들이 있고,
Function 인터페이스에서 두 개의 인수를 받고싶다면, BiFunction 인터페이스를 활용할 수 있는 것처럼
다양한 표준 함수형 인터페이스들이 있습니다.


기본형 특화 모델

함수형 인터페이스는 제네릭 형식을 활용하고 있어서 기본형 타입을 사용하는 데 제약이 있습니다.
그래서 기본형 타입도 활용할 수 있는 기본형 특화 함수형 인터페이스가 있습니다.
해당 인터페이스는 제네릭 형식이 아닌 타입을 명시하고 있어서 기본형 타입을 활용할 수가 있습니다.

  • 오토박싱 예제
List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++) {
    list.add(i);
}




물론 위 코드와 같이 기본형 타입을 제네릭 활용 함수형 인터페이스의 추상 메서드의 인수로 줘도 실행이 됩니다.
왜냐하면 기본형 타입이 참조형 타입으로 변환되는 오토박싱기능이 제공되기 때문입니다.

  • 박싱 : 기본형을 참조형으로 변환하는 기능
  • 언박싱 : 참조형을 기본형으로 변환하는 기능
  • 오토박싱 : 박싱과 언박싱이 자동으로 이루어지는 기능




하지만 이러한 변환 과정은 메모리 공간을 추가로 소비하고, 기본형 타입을 가져오기 위해 메모리를 탐색하는 추가적인 과정이 생기기 때문에
비용이 소모됩니다. 그렇기 때문에 비용이 소모되는 오토박싱동작을 피할 수 있도록 기본형 특화 함수형 인터페이스가 제공됩니다.

  • 제네릭 활용 함수형 인터페이스
public interface Predicate<T> {
    boolean test(T t);
}
Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
oddNumbers.test(100);



  • 기본형 특화 함수형 인터페이스
public interface IntPredicate {
    boolean test(int i);
}
IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000);



함수형 인터페이스 람다 매핑 예제

  • 람다 예제 : (List<String> list) -> list.isEmpty(), 함수형 인터페이스 : Predicate<List<String>>
  • 람다 예제 : () -> new Apple(10), 함수형 인터페이스 : Supplier<Apple>
  • 람다 예제 : (Apple a) -> System.out.println(a.getWeight()), 함수형 인터페이스 : Consumer<Apple>
  • 람다 예제 : (String s) -> s.length(), 함수형 인터페이스 : Function<String, Integer> or ToIntFunction<String>
  • 람다 예제 : (int a, int b) -> a * b, 함수형 인터페이스 : IntBinaryOperator
  • 람다 예제 : (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()), 함수형 인터페이스 : Comparator<Apple> or BiFunction<Apple, Apple, Integer> or ToIntBiFunction<Apple, Apple>



2. 람다 표현식 컴파일 과정

람다 표현식의 컴파일 과정을 통해 람다 표현식이 어떻게 함수형 인터페이스에 매핑이 되는지 알 수 있습니다.

  • 대상 형식 : 어떤 컨텍스트(ex. 람다 표현식이 파라미터로 입력되는 메서드 등)에서 기대되는 람다 표현식의 형식



형식 검사

람다 표현식은 형식 검사라는 컴파일 과정을 거칩니다.
특정 메서드에 람다표현식을 입력했을 때, 해당 메서드에서 파라미터로 선언한 함수형 인터페이스와 람다표현식이 호환되는지 확인하는 과정이라고 볼 수 있습니다. 예제를 보며 형식 검사가 어떻게 이루어지는지 확인해보겠습니다.



  • 예제
List<Apple> heavierThan150g = filter(inventory, (Apple apple) -> apple.getWeight() > 150);



  • 형식 검사 과정
  1. filter 메서드의 선언을 확인합니다.
  2. filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대합니다.
  3. Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스입니다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사합니다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 합니다.




컴파일러가 위 형식 검사 과정 중 예외를 던지지 않는다면, 형식 검사를 마치고 다음 단계로 넘어가게 됩니다.

형식 추론

형식 추론을 통해 우리는 람다표현식에서의 코드를 더욱 단순화할 수 있습니다.
형식 검사 과정을 통해 우리는 파라미터로 입력된 람다표현식과 관련된 함수형 인터페이스를 추론할 수 있고,
해당 함수디스크립터를 알 수 있으므로 컴파일러가 람다의 시그니처도 추론할 수 있습니다.

즉, 람다 표현식에서 파라미터 형식을 명시하지 않아도 컴파일러가 해당 형식을 추론하여 컴파일을 진행할 수 있습니다.

Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

위 코드의 두 표현은 같은 표현입니다.

제약

람다 표현식을 사용하는 데 있어서 변수 사용에 제약이 있습니다.

지역변수의 제약

  • 자유변수 : 람다의 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수
  • 람다캡처링 : 람다에서 자유변수를 활용하는 것

람다에서는 인스턴스 변수와 정적 변수를 자유롭게 캡처하여 사용할 수 있지만, 해당 변수는 명시적으로 final로 선언되어야 하거나 실직적으로 final처럼 사용되어야 합니다. 아래 예제는 컴파일 과정에서 에러를 반환하는 코드입니다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;

portNumber 자유변수의 람다 캡처링 및 활용 후, 해당 변수에 값을 새로 할당하고 있습니다. final처럼 사용해야하는 규칙을 위반했으므로 에러를 반환하게 됩니다.

제약이 필요한 이유

내부적으로 인스턴스 변수와 지역 변수가 있는데, 인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치합니다.
그리고 이 때, 람다에서 지역 변수에 직접 접근할 수 있다고 가정한다면(원래는 접근에 제약을 걸어둠), 람다가 다른 스레드에서 실행되고 지역 변수를 선언한 스레드가 사라졌을 때 람다를 실행하는 다른 스레드에서 지역 변수에 다시 접근하려고 할 때 에러가 발생할 여지가 있습니다.




그렇기 때문에 람다에서 지역변수 직접 접근에 제약을 걸었고, 람다에서 지역 변수를 사용해야 한다면 람다캡처링 과정을 통해 해당 지역 변수의 복사본을 사용하게 됩니다. 즉, 해당 지역변수의 값은 람다에서 활용하기 때문에 바뀌면 안되고, final로 선언하거나 final처럼 값이 한 번만 할당될 수 있도록 사용해야 하는 것입니다.



3. 메서드 참조

메서드 참조란?

메서드 참조는 기존에 정의된 메서드를 람다처럼 전달할 수 있는 기법입니다.
메서드명 앞에 구분자 ::를 붙이는 방식으로 활용할 수 있습니다.
Apple::getWeight
위 메서드 참조는 람다 표현식 (Apple a) -> a.getWeight()를 축약한 표현입니다.



  • 기존코드
inventory.sort(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 메서드 참조 활용 (java.util.)
inventory.sort(comparing(Apple::getWeight));

이렇게 메서드 참조를 활용하면 코드의 가독성을 높일 수 있습니다.



메서드 참조 생성 case

메서드 참조를 활용하는 case는 대표적으로 아래 3가지 case가 있습니다.

정적 메서드 참조

정적 메서드 참조는 말그대로 정적메서드를 참조하는 case입니다.
Integer의 정적메서드인 parseInt는 아래와 같이 표현할 수 있습니다.
Integer::parseInt

다양한 형식의 인스턴스 메서드 참조

정적 메서드가 아닌 특정 형식의 인스턴스 메서드를 참조하는 case입니다.
String의 인스턴스 메서드인 length는 아래와 같이 표현할 수 있습니다.
String::length

  • 활용 예제
(String s) -> s.toUpperCase();
String::toUpperCase




위 람다표현식을 아래 메서드 참조 표현으로 바꿔서 활용할 수 있습니다.

기존 객체의 인스턴스 메서드 참조

정적 메서드가 아니고 람다 파라미터도 아닌 지역 변수의 인스턴스 메서드를 참조하는 case입니다.
Transaction을 할당받은 람다의 외부객체 expensiveTransaction의 인스턴스 메서드인 getValue는 아래와 같이 표현할 수 있습니다.
expensiveTransaction::getValue

  • 활용 예제
() -> expensiveTransaction.getValue();
expensiveTransaction::getValue

위 람다표현식을 아래 메서드 참조 표현으로 바꿔서 활용할 수 있습니다.

메서드 참조 예제

메서드 참조의 예시를 더 알아보겠습니다.
아래 예제 코드에서 sort 메서드는 인수로 Comparator를 기대하고 있고, 함수 디스크립터로 (T, T) -> int를 갖습니다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));




아래와 같이 메서드 참조 표현을 활용하면 위 코드와 같이 Comparator를 새로 구현하지 않고 기존 메서드를 참조시킬 수 있습니다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort(String::compareToIgnoreCase);

String의 인스턴스 메서드 compareToIgnoreCase는 시그니처로 (T, T) -> int를 가지기 때문에 인수로 기대하는 Comparator와 호환됩니다.

4. 생성자 참조

정적 메서드 참조를 만드는 것처럼 new 키워드를 활용하여 생성자 참조를 만들 수 있습니다.
ClassName::new

아래 예제는 () -> T 시그니처를 갖는 Supplier를 활용한 생성자 참조 예제입니다.

  • 기존 코드
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();
  • 생성자 참조 활용
Supplier<Apple> c1 = Apple::new; // Apple() 생성자 참조
Apple a1 = c1.get();




아래 예제는 (T) -> T 시그니처를 갖는 Function을 활용한 생성자 참조 예제입니다.

  • 기존 코드
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);
  • 생성자 참조 활용
Function<Integer, Apple> c2 = Apple::new; // Apple(Integer weight) 생성자 참조
Apple a2 = c2.apply(110);




최종 예제로 아래와 같이 사용할 수 있습니다.

List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new);
public List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
    List<Apple> result = new ArrayList<>();
    for (Integer i : list) {
        result.add(f.apply(i));
    }
    return result;
}



5. 람다/메서드참조 활용 예제

사과 리스트를 다양한 정렬 기법으로 정렬하는 예제로 람다활용과 메서드참조를 최종적으로 정리해보겠습니다.

코드 전달

정렬 메서드 sort에 정렬기법을 아래 코드와 같이 전달합니다.
동작 파라미터화된 sort메서드는 파라미터로 Comparator를 기대하고 있기 때문에 Comparator를 재정의한 AppleComparator를 인수로 전달할 수 있습니다.

public class AppleComparator implements Comparator<Apple> {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight());
    }
}
inventory.sort(new AppleComparator());



익명 클래스 사용

코드를 구현하지 않고 직접 파라미터에 구현코드를 전달하는 방식인 익명 클래스 활용 방식을 이용할 수 있습니다.

inventory.sort(new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight());
    }
});



람다 표현식 사용

지금까지 우리는 람다표현식이 어떻게 함수형 인터페이스에 매핑되는지 알아봤습니다.

람다 표현식을 활용하여 익명 클래스를 사용한 장황한 코드를 간단하게 정리할 수 있습니다.
람다표현식은 함수형 인터페이스를 기대하는 어느 메서드에서나 사용될 수 있습니다.

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));




자바 컴파일러는 사용된 콘텍스트를 통해 람다의 파라미터 형식도 추론할 수 있습니다. 그리고 아래와 같이 더 간단하게 정리할 수 있습니다.

inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));




ComparatorFunction을 인수로 받아 Comparator 객체로 반환하는 comparing이라는 정적 메서드(디폴트 메서드라고도 불림)를 포함합니다. 디폴트 메서드는 아래에서 짚고 넘어가겠습니다.

Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
위 표현을 활용하여 아래와 같이 코드를 더 간소화 시킬 수 있습니다.


import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));



메서드 참조 사용

메서드 참조를 사용하면 람다 표현식을 더 깔끔하게 전달할 수 있습니다.
(apple) -> apple.getWeight(); 람다 표현식은 Apple::getWeight로 표현할 수 있습니다.

inventory.sort(comparing(Apple::getWeight));



6. 람다 표현식 조합을 위한 디폴트 메서드

함수형 인터페이스의 정적메서드(디폴트 메서드)를 활용하면 여러개의 람다 표현식을 조합하여 복잡한 람다 표현식을 만들 수 있습니다.
해당 메서드의 예제로 위에서 언급한 Comparator.comparing메서드가 있습니다.

디폴트 메서드란?

디폴트 메서드는 인터페이스를 구현한 클래스에서 구현하지 않아도 되는 메서드를 말합니다.
디폴트 메서드는 아래 코드와 같이 활용됩니다. 아래 코드는 실제 Comparator 선언문의 일부를 가져온 코드입니다.

@FunctionalInterface
public interface Comparator<T> {

    // 유일 추상 메서드
    int compare(T o1, T o2);

    // 최상위 Object 클래스의 추상메서드 (유일 추상 메서드의 함수형 인터페이스 규칙을 위반하지 않음)
    boolean equals(Object obj);

    // 디폴트 메서드
    default Comparator<T> reversed() {
        return Collections.reverseOrder(this);
    }

}




디폴트 메서드는 선언문 앞에 java8에서 새로 추가된 default 키워드가 붙은 메서드입니다.

등장배경

java8 등장이전에는 인터페이스에 메서드가 추가될 때마다 해당 인터페이스를 구현한 클래스에서 해당 메서드를 모두 구현해야 하는 불편함이 있었습니다.
이러한 불편함을 보완하기 위해 java8에서는 구현 클래스에서 구현하지 않아도 되는 디폴트 메서드를 만들어 인터페이스를 쉽게 바꿀 수 있도록 하였습니다.

하지만 java의 클래스는 여러 인터페이스를 구현할 수 있고, 여러 인터페이스에 다중 디폴트 메서드가 있다면 다중 상속을 어느정도 허용한다는 의미라고 볼 수도 있습니다. 원래는 인터페이스에서 메서드를 구현한 일이 없기 때문에 어떤 메서드가 어떤 인터페이스의 메서드인지 알 필요가 없어서 다중상속 문제는 java에서 논외의 문제였는데, 추상메서드가 등장하고 인터페이스에서 메서드가 구현될 수 있게되어 다중상속의 대표적인 문제인 다이아몬드 상속 문제가 발생할 수 있습니다. 해당 문제를 보완하는 방법은 다음 글에서 다뤄보도록 하겠습니다.

  • 다이아몬드 상속 문제 : 동일한 메서드 시그니처(함수명 동일, 파라미터 동일, 반환 타입 동일)의 메서드를 가지는 여러 인터페이스를 상속 받은 클래스에서 어떤 인터페이스의 메서드를 실행해야할 지 결정하지 못하는 문제

람다 표현식 조합 예제

  • Comparator.comparing

comparing 메서드를 활용하면 Comparator 함수형 인터페이스를 람다 표현식으로 일일히 작성하는 것이 아닌
비교할 키를 추출하는 Fucntion을 인수로 받아서 Comparator를 반환할 수 있습니다.

Comparator<Apple> c1 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
Comparator<Apple> c2 = Comparator.comparing(Apple::getWeight);



  • Comparator.reverse, Comparator.thenComparing

reverse 메서드를 활용하면, 주어진 비교자의 순서를 뒤 바꾸는 역정렬을 수행할 수 있고
thenComparing 메서드를 활용하면, 두 번째 비교자를 추가할 수 있습니다.

아래 코드는 첫 번째 비교자인 무게를 기준으로 사과를 내림차순하고, 동일 무게의 사과일 때는 원산지(Country)순으로 오름차순 정렬하는 코드입니다.

inventory.sort(comparing(Apple::getWeight)
            .reverse()
            .thenComparing(Apple::getCountry));



  • Predicate.negate, Predicate.and, Predicate.or

negate 메서드를 활용하면, 기존 Predicate 객체의 결과를 반전시키는 결과를 반환합니다.
and 메서드를 활용하면, and 조건으로 여러 Predicate를 조합할 수 있고,
or 메서드를 활용하면, or 조건으로 여러 Predicate를 조합할 수 있습니다.


Predicate<Apple> notRedApple = redApple.negate(); // redApple 프레디케이트의 결과를 반전 시킴

Predicate<Apple> redAndHeavyApple = redApple.and(apple -> apple.getWeight() > 150); // 빨갛고 무게가 150 초과하는 사과

Predicate<Apple> redAndHeavyAppleOrGreen = redApple.and(apple -> apple.getWeight() > 150)
                                                    .or(apple -> GREEN.equals(apple.getColor())); // 빨갛고 무게가 150을 초과하거나 초록색인 사과



Reference

반응형
728x90
반응형

본 글은 모던자바인액션 책을 참고하여 작성한 글입니다.

1. 람다 표현식

(Apple apple) -> RED.equals(apple.getColor())
👉🏻이전글에서 언급한 것처럼
람다표현식은 특정 동작을 파라미터로 전달할 때, 코드의 복잡성을 간소화하기 위해 Java8에서 도입된 기술입니다.

정의

람다 표현식은 메서드로 전달할 수 있는 익명함수를 단순화한 것이라고 볼 수 있습니다.

// 인터페이스 선언
public interface ApplePredicate {
    boolean test (Apple apple);
}

// 전달받을 메서드 선언
public static List<Apple> filterApple(List<Apple> inventory, ApplePredicate p) {
    List<Apple> result = new ArrayList<>()
    for (Apple apple : inventory) {
        if ( p.test(apple) ) {
            result.add(apple);
        }
    }
    return result;
}

// 람다 표현식을 파라미터로 전달
List<Apple> result = filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));



특징

  • 익명 : 보통의 메서드와는 달리 이름이 없어서 가독성에 관한 걱정거리를 줄일 수 있습니다.
  • 함수 : 특정 클래스에 종속되지 않는다는 점에서 함수라고 부를 수 있습니다. 하지만 파라미터 및 반환형식은 맞춰줘야 합니다.
  • 전달 : 메서드의 인수로 전달하거나 변수로 저장할 수 있습니다.
  • 간결성 : 자질구레한 코드를 구현할 필요가 없습니다. 가장 핵심이라고 할 수 있는 특징입니다.

람다 문법

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

  • (Apple a1, Apple a2) : 파라미터 리스트
  • -> : 화살표, 파라미터 리스트와 바디를 구분
  • a1.getWeight().compareTo(a2.getWeight()); : 람다 바디, 실제 구현 및 반환이 이루어지는 부분

스타일

  • 표현식 스타일 : (parameters) -> expression
  • 블록 스타일 : (parameters) -> { statements; }

활용

👉🏻이전글과 위 예시코드에서 언급한 것처럼
람다표현식을 함수형 인터페이스와 관련 메서드를 직접 선언한 후, 선언된 메서드에 전달하는 방식으로도 활용할 수 있지만,
Java8의 java.util.function패키지에서 선언된 함수형 인터페이스(ex. Comparator)를 사용할 때에도 활용될 수 있습니다.

그리고 해당 패키지의 함수형 인터페이스는 Java8의 다양한 라이브러리(Collection의 List)에서 사용되고 있는데,
먼저 함수형 인터페이스가 뭔지에 대해서 짚고 넘어가겠습니다.

2. 함수형 인터페이스



정의

함수형 인터페이스는 추상 메서드가 오직 하나인 인터페이스입니다.

public interface Predicate<T> {
    boolean test(T t);
}




함수형 인터페이스에는 아래와 같이 추상 메서드 이외에 디폴트 메서드가 선언될 수 있는데,
디폴트 메서드에 대해서는 (다음글)에서 짚고 넘어가겠습니다.

@FunctionalInterface
public interface Comparator<T> {

    // 추상 메서드 선언
    int compare(T o1, T o2);

    // 추상 메서드2 선언 (equals는 최상위 클래스인 Object의 메서드이므로 추상 메서드 Count에서 제외된다.)
    boolean equals(Object obj);

    // 디폴트 메서드 선언
    default Comparator<T> reversed() {
        return Collections.reverseOrder(this);
    }

}




함수형 인터페이스@FunctionalInterface 어노테이션을 태깅할 수 있는데,
해당 어노테이션이 태깅된 인터페이스가 컴파일 과정에서 함수형 인터페이스라고 판단되지 않는 경우,
에러를 반환합니다.

Why? 함수형 인터페이스

함수형 인터페이스는 동작 파라미터화 기능을 사용할 때 사용될 수 있습니다.

👉🏻이전글에서 언급했던 것처럼
특정 동작을 메서드의 파라미터로 전달할 때(동작 파라미터화 기능), 익명클래스를 전달하는 방식이 있고 람다표현식을 전달하는 방식이 있습니다.
그 중 람다표현식을 전달하는 방식은 사실 함수형 인터페이스를 활용한다고도 볼 수 있는데, 이에 대해서는 람다 표현식의 컴파일 과정을 보면 알 수 있습니다. 해당 부분은 (다음글)에서 짚고 넘어가겠습니다.

3. 람다의 활용



시그니처와 함수 디스크립터



메서드 시그니처란?

Java에서 메서드 시그니처는 메서드의 이름과 파라미터의 순서 및 타입, 갯수를 의미합니다.
아래 예시처럼 메서드 시그니처를 표현할 수 있습니다.

  • 메서드
public int Multiply(int a, int b) {
    return a * b;
}



  • 시그니처 : Multiply(int a, int b)

람다표현식 시그니처

람다표현식 또한 위와 같은 시그니처를 가질 수 있습니다. 람다표현식의 시그니처는 아래와 같이 표현됩니다.
(Apple, Apple) -> int

위의 시그니처는 두 개의 Apple을 인수로 받아 int를 반환하는 람다표현식을 가리키는 시그니처입니다.
이러한 시그니처 표현을 살펴보는 이유는 람다표현식과 함수형 인터페이스의 시그니처가 일치하는지 판단할 수 있어야하기 때문입니다.

함수 디스크립터란?

함수 디스크립터는 위에서 언급했던 함수형 인터페이스의 시그니처입니다.
앞으로 함수형 인터페이스의 시그니처는 함수 디스크립터로 표현할 것입니다.

예시

  • Ex1)
// 함수형 인터페이스 파라미터를 입력으로 받는 메서드 선언
public void execute(Runnable r) {
    r.run();
}

// 람다표현식 입력
execute(() -> {});




위 함수형 인터페이스 파라미터의 추상메서드 run의 시그니처, 함수 디스크립터는 () -> void이고,
입력한 람다표현식의 시그니처도 () -> void로 일치하므로 유효한 문법입니다.

  • Ex2)
// 함수형 인터페이스를 반환하는 메서드 선언
public Callable<String> fetch() {
    return () -> "Tricky example"; // 람다표현식 반환
}




위 메서드에서 반환되는 함수형 인터페이스 Callable의 추상메서드 call의 함수 디스크립터는 () -> String이고,
return 문에서 반환한 람다표현식의 시그니처도 () -> String으로 일치하므로 유효한 문법입니다.

실행 어라운드 패턴

람다표현식을 실질적으로 활용하는 패턴 중 대표적인 패턴으로 실행 어라운드 패턴이 있습니다.
실행 어라운드 패턴은 실제 처리되는 작업 이외에 해당 작업을 진행하기 위한 준비단계, 작업이 끝난 후의 마무리 단계를 공통 모듈로 사전 구현한 후에
실제 진행되는 작업만 파라미터로 입력받아 실행시키는 패턴입니다.

예를 들면 파일 처리 작업이 있는데, 해당 작업은 파일이라는 자원을 열고 처리한 후에 자원을 닫는 순서로 이루어집니다.
여기서 자원을 열고 닫는 과정은 공통적으로 반복되는 작업이고, 이 작업들로 실제 처리하는 작업을 둘러싸는 형태의 패턴을 구성할 수 있습니다.
그리고 실제 처리하는 작업은 동작 파라미터화 기능을 통해 패턴의 파라미터로 입력하여 처리할 수 있습니다.

  • 실행 어라운드 패턴 예제
// 함수형 인터페이스 선언
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}

// 실행 어라운드 패턴 적용 메서드 선언
public String processFile(BufferedReaderProcessor p) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return p.process(br);
    }
}

// 파일 처리 람다함수 입력
String oneLine = processFile((BufferedReader br) -> br.readLine()); // 한 줄 반환
String twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine()); // 두 줄 반환



이어서

다음 글에서 람다표현식이 함수형 인터페이스에 어떻게 활용되는지 좀 더 자세히 알아보고,
메서드/생성자 참조 기법과 디폴트 메서드에 대해 알아보겠습니다.

반응형

+ Recent posts