백엔드 데브코스 TIL 4기 - 06.06(화)

신재윤·2023년 6월 6일
0
post-thumbnail

💻 2주차 DAY 02

2주차 DAY 02 ! 드디어 오늘 부산 방이 빠졌다. pre 팀 기간 동안은 계산기 과제도 있고 여러 집중이 필요해서 서울에 방 구하러 올라가는 것은 pre 팀 기간 이후이지 않을까싶다. 오늘부터는 본격적으로 객체지향적인 설계를 위해 Java를 학습하는 시간이었다.


Interface

평소, 추상 클래스와 인터페이스에 관한 차이를 공부한 편이었는데 정작 인터페이스의 기능에 관하여 간략하게만 알고 있었어서, 오늘 강의를 통해 정리해보는 시간을 가지게 되었다.

내가 기존에 일던 인터페이스는 메뉴얼, 설명서의 느낌이었다. 인터페이스에 정의해둔 메서드를 구현하는 쪽에서 반드시 구현하도록 강제하는 것이다. 즉, 추상체에 정의된 메서드를 구상체에서 반드시 이것만은 구현해야 한다는 의미이다. 제작자가 인터페이스를 만들면서 제작자의 의도를 포함시켜 사용자에게 인지시켜준다는 의미로도 생각할 수 있다.


Interface 기능

  1. 구현을 강제
  2. 다형성 제공
  3. 결합도 낮추는 효과

예시를 통해 알아보자

가장 많이 사용하는 "로그인 기능"에 관하여 이야기 해보자.
예시 코드는 굉장히 간단한 수준이다.

  • 구상체에서 추상체인 인터페이스의 메서드를 구현해준 모습
// 추상체 
public interface Login {
	void login();
}

// 구상체
public class KakaoLogin implements Login {
	@Override
    public void login() {
    	System.out.println("카카오 로그인");
    }
}

// 구상체
public class NaverLogin implements Login {
	@Override
    public void login() {
    	System.out.println("네이버 로그인");
    }
}

그렇다면, 사용 쪽에서 고려할 것들을 생각해보자.

  1. 인스턴스 생성 시 구상체 타입이 아닌 추상체 타입으로 선언하자.
    • 카카오 로그인과 네이버 로그인에 강하게 결합된 것을 없애기 위해
  2. 팩토리 메서드 패턴을 이용하자.
    • 사용하는 곳의 구현부를 바꾸지 않고, 팩토리에 정의된 메서드만 수정하기 위해
  3. enum 타입도 고려해보자.
    • enum으로 빼고 팩토리 메서드 패턴에 주입하는 식으로
  4. ⭐️ 결정을 호스트 코드로 미루자. ( = 위임하자 )
    • 의존체를 외부로부터 주입받아서 최종적으로 동작을 결정하는 것

고려한 내용의 핵심은 인터페이스 타입(=추상체의 타입)을 사용하여, 인터페이스의 다형성을 이용하면 어떤 것이 들어오든 상관 없이 호스트 코드 쪽에서 로그인을 요청하게 만들 수 있다는 의미이다.

추상체인 Login 인터페이스를 KakaoLogin과 NaverLogin에서 이용하여 기능을 수행하고 있는 상태이기 때문에 Login에 의존하고 있는 상태이다. 여기에서 의존체를 외부로부터 주입받는다고 하였는데, 왜 주입 받는지 아래를 보자.

private Login login = new KakaoLogin();

직접 생성하는 형태를 이용한다면, KakaoLogin이라는 특정 기능을 수행하는 로그인 밖에 하지 못한다. 즉, 의존성을 외부에 맡김으로 인하여, 여러 로그인을 수행할 수 있는 능력을 탑재하는 것이다. 이를 의존도를 낮춘다라고 표현한다. 의존성을 주입받는다고 하여 이것을 의존성 주입, DI (Dependency Injection)라 한다.

구상체에 의존 => 강한 결합
추상체에 의존 + 의존성 주입받음 => 결합도 낮아짐


DIP

이 과정을 그림으로 살펴보자. 먼저, 의존성을 주입받지 않고 직접 의존하는 형태이다. 예를 들어, UserService에서 비즈니스 로직인 KakaoLogin, NaverLogin에 직접 의존하는 형태이다.

특정 기능을 하는 로그인에 강하게 결합되어있다. 만약 구글 로그인이 추가된다면 따로 GoogleLogin 클래스를 만들고 UserService에서 관련 코드를 추가해야한다. 벌써 머리 아프다.

추상체에 의존하면서 외부로부터 의존성을 주입받아보자.

인터페이스를 통하게 되면서, 특정 기능을 수행하는 로그인 측의 화살표 방향이 바뀌게 된 것을 볼 수 있다. 의존성을 외부로부터 주입받는 형태가 되어 이것이 의존성의 역전, DIP (Dependency Inversion Principle)이다.


DIP (Dependency Inversion Principle)

  • 의존관계 역전 원칙
  • 상위 레벨의 모듈은 절대 하위 레벨 모듈에 의존 ❌
    - 둘다 추상화에 의존해야 한다.
  • 클래스 간 결합을 느슨하게 하기 위함
  • 클래스 변경에 따른 클래스들의 영향을 최소화하기 위함

default 메서드

인터페이스에 정의된 추상 메서드는 반드시 구현해야 한다는 엄격함으로 인하여, Java 8 이후부터 default 메서드가 등장하게 되었다. 이는 간단하게 인터페이스가 구현체를 가질 수 있게 된 것이다.

default 메서드가 없던 시절을 예시로 떠올려서 생각해보자. 인터페이스에 3가지 추상메서드가 정의되어 있지만, 나는 단 1가지만 사용하고 싶은 경우, 무조건 3가지를 반드시 구현해야한다.

이러한 경우 Adapter (어댑터)를 이용하는 방식을 고려할 수 있다.

public class MyAdapter implements MyInterface {
    @Override
    public void method1() {}
    
    @Override
    public void method2() {}
    
    @Override
    public void method3() {}
}

class HelloClass extends MyAdapter {
    @Override
    public void method1() {
        System.out.println("Hello World!");
    }
}
  • 어댑터에서 인터페이스를 구현한 이후 사용처에서 어댑터를 상속받도록 !
  • 그러나, Java는 다중상속을 지원하지 않기 때문에 안되는 상황 발생

default 메서드 등장으로 인하여 이러한 Adapter 역할을 하게 되었다. default 메서드에 구현하게 되면, 인터페이스 추가만으로 기능을 확장할 수 있다는 장점도 가지게 되었다. 이러한 특징을 이용하면 인터페이스 분리 원칙인 ISP (Interface segregation principle)를 이용하여 객체지향적인 설계도 가능해진다.


추상 클래스 vs 인터페이스

그러면, default 메서드의 등장으로 추상 메서드를 포함하던 추상 클래스와 추상 메서드 만으로 이루어져 있던 인터페이스의 경계가 모호해진 것 같다. 하지만, Java에서 추상 클래스와 인터페이스를 구분하게 된 이유가 사용 목적에 있기 때문에, 이를 고려하면 좋다.

맨 위에서 언급했듯이 인터페이스는 메뉴얼, 설계도의 느낌이다. 인터페이스를 구현하는 측에서 같은 동작을 한다는 것을 보장하기 위함이다. KakaoLogin 이든 NaverLogin이든 결국 "로그인"이라는 동작을 하는 것이 보장된다. DB에 접근하는 기술 관련 repository도 살펴보자. JDBC, MyBatis, JPA 무엇을 이용하든 결국 DB에 접근한다는 동작은 보장된다.

그렇다면 추상 클래스는 어떠한가. 추상 클래스는 상속하여 공통된 기능을 만들고, 그를 기반으로 확장해 나가는 느낌이다. 설계도를 통하여 공통된 동작을 보장하는 느낌과는 다르게 추상 클래스는 부모를 기반으로 자식을 복제하고 거기에서 확장해가는 것이다.


함수형 인터페이스

인터페이스에서 default 메서드가 추가되면서, 인터페이스는 static 메서드도 가질 수 있게 되었다. static 메서드는 JVM에 올라갈 때 맨 처음에 로드되므로, 객체 생성 없이 인터페이스의 static 메서드를 호출할 수 있게 되면서, 인터페이스 자체가 함수 제공자가 되었다는 의미이다. 정의만이 아닌 구현을 통해 기능 자체를 제공할 수 있게된 것이다.

잠깐, 왜 메서드가 아니라 함수?

클래스에 종속된 함수를 메서드라고 하는데, 인터페이스에서 static 메서드가 추가됨으로 종속되지 않은 상태에서 사용 가능하여 함수라고 불리게 되었다. 즉, 함수형 인터페이스 기능이 추가된 것이다.


이러한 배경을 바탕으로, 모던 자바에는 함수형 인터페이스가 추가되었다. 함수형 인터페이스는 추상 메서드가 단, 1개만 존재하는 인터페이스이다. 편의를 위하여 추가된 함수형 인터페이스는 java.util.function 에 정의되어 있다. 오라클 공식 문서나 hudi blog - 함수형 인터페이스를 참고하도록 하자.

@FunctionalInterface
public interface MyRunnable {
    void run();
}

public class Main {
	public static void main(String[] args) {
    	
        // 오류 !
    	MyRunnable my = new MyRunnable();
        my.run();
    }
}
  • 그러나, 인터페이스는 인스턴스 생성해서 만들 수 없으니 위 코드는 오류 발생
  • 추상 메서드 1개만 있어서 동작이 예측 가능한데.. 방법이 없을까?해서 나온게 익명 클래스

익명 클래스와 람다 표현식

익명 클래스의 등장으로 인터페이스의 인스턴스를 생성하고 구현을 바로 정의하게 되었다. 위의 코드를 익명 클래스를 사용하여 수정하면 아래와 같다.

@FunctionalInterface
public interface MyRunnable {
    void run();
}

public class Main {
	public static void main(String[] args) {
    	
		MyRunnable my = new MyRunnable() {
              @Override
              public void run() {
                  System.out.println("안뇽 난 익명클래스");
              }
        };
    }
}

하지만, new를 사용하여 인스턴스를 생성, @Override를 이용한 메서드 오버라이딩 등 너무나 당연한 코드가 중복되는 것을 없애고자 Lambda 표현식이 나오게 되었다.

// 익명 클래스
MyRunnable my = new MyRunnable() {
	@Override
	public void run() {
		System.out.println("안뇽 난 익명클래스");
	}
};

// 람다 표현식
MyRunnable my = () -> System.out.println("안뇽 난 익명클래스");

메서드 레퍼런스

람다식을 사용하다보면, 인텔리제이에서 노란색 줄이 그어지면서 "제발 나 좀 바꿔줘!"라고 소리쳤다. 그래서 바꿔보면 기괴한 형식이 나왔다.

// 람다식
MyMapper<String, Integer> m = (str) -> str.length();
// 메서드 레퍼런스
MyMapper<String, Integer> m = String::length;

// 람다식
MyMapper<Integer, String> m3 = i -> Integer.toHexString(i);
// 메서드 레퍼런스
MyMapper<Integer, String> m3 = Integer::toHexString;

이는, 람다식에서 입력되는 값을 변경없이 바로 사용하는 경우에는, 자명하기 때문에 더 간단한 형태로 바꿔주는 방식이다. 최종으로 적용될 메서드의 레퍼런스를 지정해주는 표현 방식으로써, 메서드 레퍼런스라고 한다.

메서드 레퍼런스 사용 시 장점이 있는데, 값의 변경이 확실하게 없다고 보장할 수 있다는 의미이다. 좀 확대해서 표현하면 입력 값을 변경하지 말라라는 표현 방식이기도 하다. 코드를 유지보수 해야하는 상황에 메서드 레퍼런스로 작성된 것을 보고 결과값을 마음대로 바꾸지 못하게 하여 안정성을 얻을 수 있다는 장점이 있다.


모던 자바 사용법

java.util.function에서 제공해주는 함수형 인터페이스와 람다 표현식을 잘 이용하면 재밌게 코드를 작성할 수 있다.

import java.util.function.Consumer;
import java.util.function.Predicate;

public class Main {
	// 여기가 호스트 코드 !
    public static void main(String[] args) {
        new Main().filteredNumbers(
        		50,
                i -> i % 5 == 0,
                System.out::println
        );
    }

    void filteredNumbers(int max, Predicate<Integer> predicate, Consumer<Integer> consumer) {
        for (int i = 0; i < max; i++) {
            if (predicate.test(i)) consumer.accept(i);
        }
    }

    void loop(int n, MyConsumer<Integer> consumer) {
        for (int i = 0; i < n; i++) {
            consumer.consume(i);
        }
    }
}
  • 함수형 인터페이스인 Consumer를 이용하여 입력은 있지만, 출력은 따로 없어도 되는 상태를 만들었다.
  • 수행해야 할 것을 만들어놓고 구체적인 상황을 직접 처리하는 것이 아닌 호스트 코드로 미룬 상태이다. 위에서 의존성을 주입받던 상황과 닮지 않았는가?
  • 결과적으로 실질적인 구현부는 하나도 바꾸지 않으면서 호출하는 쪽에서 기능을 수행하도록 만들 수 있는 것이다. 👍

profile
백엔드 데브코스 TIL (Today I Learned)

0개의 댓글