[Java]추상 클래스를 왜 사용할까?

주운·2024년 6월 14일

추상화

추상클래스에 앞서 추상화에 대해 알아보자.

추상화는 객체 제향 프로그래밍의 대표 특징 중 하나다.

객체 지향 프로그래밍은 프로그램을 유연하고 재사용하기 쉽도록 하는 프로그래밍 설계방법이다.

객체 지향 프로그램의 4가지 특징으로 추상화, 상속, 다형성, 캡슐화가 있다.

추상화는 복잡한 모듈, 시스템에서 핵심적인 개념 혹은 기능을 단순히 나타내는것이라 할 수 있다.

추상 클래스를 이용하면 클래스 정의에서 불필요한 부분들을 생략하고, 객체 속성 중 중요한 것만 나타낼 수 있다.

하지만 추상 클래스만이 추상화 방법은 아니다.

객체 지향의 추상화는 동작의 절차나 방법을 숨기는 제어 추상화와 클래스의 공통 성질만 추출하는 데이터 추상화로 나눌 수 있다.

제어 추상화

제어 추상화는 어떤 메소드를 사용할 때 사용자에게 메소드의 작동방식과 같은 내부 로직을 숨긴다.

자바, 파이썬 등 프로그래밍 언어를 사용해 코딩을 하고 있다면 이미 제어 추상화 개념을 사용하고 있는 것이다.

자동차를 운전할 때 시동, 브레이크, 가속 등의 작동 과정을 모르더라도 사용자는 간단한 조작만으로 모든 기능을 사용할 수 있다.

프로그램도 마찬가지로 메소드의 입력 데이터, 반환 데이터, 메소드명만 알고 있다면 복잡한 내부 구조를 모르더라도 메소드를 사용할 수 있다.

이러한 메소드 구조 자체가 객체지향의 추상화 개념에 속한다.

아래는 자바의 문자열 메소드 중 하나인 join()의 코드다.

public static String join(CharSequence delimiter,
            Iterable<? extends CharSequence> elements) {
        Objects.requireNonNull(delimiter);
        Objects.requireNonNull(elements);
        var delim = delimiter.toString();
        var elems = new String[8];
        int si ze = 0;
        for (CharSequence cs: elements) {
            if (size >= elems.length) {
                elems = Arrays.copyOf(elems, elems.length << 1);
            }
            elems[size++] = String.valueOf(cs);
        }
        return join("", "", delim, elems, size);
    }

우리는 메서드의 구현 방법을 이해하지 못해도 메서드를 사용하는데는 아무런 문제가 없다.
"문자열 리스트를 입력하면 문자열들이 합쳐진 문자열이 반환된다."
라는 사용법만 알고있다면 아래와 같이 간편하게 메소드를 사용할 수 있다.

String dateString = String.join("", timeList);

데이터 추상화

데이터 추상화는 어떤 대상을 간단한 개념으로 일반화하는 과정을 말한다.

예를 들자면 삼각형, 사각형, 원에 대해 넓이, 선, 면 등 이들의 공통 특징만을 묶어 도형이라는 새로운 객체로 추상화 하는 것이 데이터 추상화라 할 수 있다.

상위 개념으로 추상화를 계속 할수록 객체의 상세 정보는 사라지고 공통된 특징만 남는다.

아이폰 객체를 추상화한다면 아이폰-> 휴대폰-> 통신기기-> 전자제품으로 추상화할 수 있을 것이다.

abstract class 전자제품 {
}

abstract class 통신기기 {
}

abstract class 휴대폰 {
}

class 아이폰 {
}

이러한 구조로 상속관계를 구현하면 추상화된 상위 요소부터 각 요소에 맞는 기능을 정의할 수 있다.

위 예시에서는 전자제품에 전원 기능, 통신기기에 통화기능, 휴대폰에 카메라 기능, 아이폰에 애플 연동 기능 등 각 객체에 적합한 기능을 구현할 수 있다.

// 전자제품 클래스: 전원 기능
abstract class 전자제품 {
    public void 전원켜기() {
        System.out.println("전원이 켜졌습니다.");
    }
    
    public void 전원끄기() {
        System.out.println("전원이 꺼졌습니다.");
    }
}

// 통신기기 클래스: 통화 기능
abstract class 통신기기 extends 전자제품 {
    public void 전화걸기(String 전화번호) {
        System.out.println(전화번호 + "로 전화를 겁니다.");
    }

    public void 전화받기() {
        System.out.println("전화를 받습니다.");
    }
}

// 휴대폰 클래스: 카메라
abstract class 휴대폰 extends 통신기기 {
    public void 사진찍기() {
        System.out.println("사진을 찍습니다.");
    }
}

// 아이폰 클래스: 애플 앱 연동 기능
class 아이폰 extends 휴대폰 {
    public void 애플앱연동() {
        System.out.println("애플 앱에 연동합니다.");
    }

상속 관계(extends)를 통해 하위 클래스는 상위 클래스가 가진 요소를 포함할 수 있다.

공통된 기능은 상위 클래스에서 미리 구현하기 때문에 여러 종류의 휴대폰을 구현할 때 각 폰의 고유한 기능 구현에만 집중할 수 있다.

위와 같이 여러 단계로 추상화하여 구조를 분리하면 아이폰 하나만 만들 때는 비효율적으로 보일 수 있지만, 제품 종류가 늘어날 수록 개발된 공통 기능을 상속하여 빠르게 구조를 확장할 수 있다.

이미지

추상 클래스

객체 지향 프로그래밍의 특징 중 하나인 추상화를 클래스 개념에 접목시킨 개념이 추상 클래스다.

추상클래스는 클래스 사이의 공통적인 특징을 묶어 공통되는 필드, 메소드를 가진 상위 클래스를 만들어낸다.

추상클래스는 상속(extends)을 통해 상위 클래스의 메서드와 필드를 하위 클래스가 사용할 수 있다는 특징을 가진다.

추상 클래스의 용도

추상 클래스는 클래스에 추상화를 접목 시켜 프로그램에 많은 장점을 부여한다.

  • 구조적인 객체를 설계
  • 프로그램의 유지보수성 상승
  • 기능 수정 / 추가에 대해 유연성 부여

이러한 효과를 통해 퀄리티 높은 프로그램과 솔루션을 개발할 수 있게 해준다. 실제로 추상 클래스는 개별 프로젝트 보다는 범용 라이브러리나 프레임워크 시스템을 설계하는데 유용하게 사용된다.

추상클래스의 용도를 구체적으로 알아보자.

1. 중복 제거

여기 Bird, Dog, Fish 클래스가 정의되어 있다. eat() 메서드의 내용이 중복된다.

class Bird extends Animal {
	public void eat() {
        System.out.println("This animal is eating.");
    }
    public void bark() {
        System.out.println("The bird chirps.");
    }
}

// Dog 클래스
class Dog extends Animal {
	public void eat() {
        System.out.println("This animal is eating.");
    }
    public void bark() {
        System.out.println("The dog barks.");
    }
}

// Fish 클래스
class Fish extends Animal {
	public void eat() {
        System.out.println("This animal is eating.");
    }
    public void bark() {
        System.out.println("The fish makes bubbles.");
    }
}

상위 클래스인 Animal 추상 클래스를 정의하여 중복되는 eat() 메서드를 구현하여 중복 코드를 줄일 수 있다.

상속(extends)을 통해 상위 클래스의 요소를 하위클래스에서 그대로 물려 받아 코드의 중복 제거, 코드 재사용성 증대 효과를 얻을 수 있다.

abstract class Animal {
    public void eat() {
        System.out.println("This animal is eating.");
    }

    public abstract void bark();
}

// Bird 클래스
class Bird extends Animal {
    @Override
    public void bark() {
        System.out.println("The bird chirps.");
    }
}

// Dog 클래스
class Dog extends Animal {
    @Override
    public void bark() {
        System.out.println("The dog barks.");
    }
}

// Fish 클래스
class Fish extends Animal {
    @Override
    public void bark() {
        System.out.println("The fish makes bubbles.");
    }
}

2. 구현의 강제성을 통한 기능 보장

이 장점은 객체지향의 중요한 개념인 다형성에 대한 내용이기도 하다.
다형성은 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있다는 특징이다.

공통 메서드인 eat()이 구현된 일반 클래스인 Animal클래스를 상속받아 여러 종류의 동물들을 구현하는 상황을 생각해보자.

class Animal {
    // 일반 메서드: eat
    public void eat() {
        System.out.println("This animal is eating.");
    }
}

Animal 클래스를 상속받아 개, 물고기, 새, 토끼 등 다양한 동물을 구현할 수 있을 것이다.

공통 메서드인 eat()이 구현되어 있어 일반 클래스로도 중복 코드를 줄일 수 있다.

class Animal {
    public void eat() {
        System.out.println("This animal is eating.");
    }
}

// Dog 클래스
class Dog extends Animal {
}

// Fish 클래스
class Fish extends Animal {
}

// Bird 클래스
class Bird extends Animal {
}

하지만 움직임을 나타내는 메서드를 추가해야 한다면 어떨까?

개는 뛰고 물고기는 헤엄치고 새는 날는 방식으로 움직인다. 구현에 대한 가이드가 없다면 모두 이동을 표현하는 메서드임에도 불구하고 각각 다른 이름으로 구현될 수도 있을 것이다.

ex) 개-run(), 물고기-swim(), 새-fly(), 토끼-jump()

이러한 문제점을 추상 클래스를 통해 해결할 수 있다.

추상 클래스를 사용하면 상속받는 클래스가 추상클래스의 필드와 메서드명을 변경할 수 없다.

또한 추상 클래스를 상속받는 하위 클래스는 반드시 추상 메서드를 구현해야 한다.

이를 지키지 않을 시 컴파일 에러가 발생하기 때문에 구현 실수로 인한 문제를 방지할 수 있다.

abstract class Animal {
    public void eat() {
        System.out.println("This animal is eating.");
    }

    // 추상 메서드
    public abstract void move();
}

// Bird 클래스
class Bird extends Animal {

    @Override
    public void move() {
        System.out.println("The bird flies.");
    }
}

// Dog 클래스
class Dog extends Animal {

    @Override
    public void move() {
        System.out.println("The dog runs.");
    }
}

// Fish 클래스
class Fish extends Animal {
    @Override
    public void move() {
        System.out.println("The fish swims.");
    }
}

추상 클래스에 move() 추상메서드가 정의되어있기에 Animal을 상속받는 클래스들은 모두 move()를 반드시 구현해야 한다.

이렇든 추상클래스는 정해진 설계 규칙에 따라 클래스를 구현하여 코드의 유지보수성을 높인다.

만약 메서드, 필드명이 달라지는 등 정해진 규격과 다르게 클래스가 구현되면 객체를 유연하게 변경할 수 없다.

아래와 같이 동물들을 경주시키는 경주 시스템을 생각해보자.

public class Main {
    // 경주 메서드
    public static void race(Animal animal) {
        animal.move();
    }

    public static void main(String[] args) {
        Animal myBird = new Bird();
        Animal myDog = new Dog();
        Animal myFish = new Fish();

        race(myBird);

        race(myDog);

        race(myFish);
    }

자바는 부모 클래스에 모든 자식 객체를 대입할 수 있도록 자동 타입 변환을 지원하여 다형성을 구현한다.
부모클래스 타입 변수 = new 자식클래스();
명시적인 타입 변환 없이도 부모 타입 변수가 자식 클래스의 타입을 대입 받을 수 있다.

race()메서드는 Bird, Dog, Fish 객체를 Animal 타입으로 받아 Animal 클래스의 메서드인 move()를 실행한다.

동물의 이동 메서드명이 move()로 같다면 경주 시스템에 모든 동물을 넣어 작동시킬수 있을것이다.

반면 메서드명이 통일되지 않았을 때는 한 종류의 동물에만 사용할 수 있는 딱딱한 시스템이 되어버리는 것이다.

스프링 시큐리티 코드 살펴보기

추상화는 특히 라이브러리, 프레임워크와 같이 거대하고 확장성이 필요한 설계에 특히 많이 사용된다고 한다.

스프링 시큐리티 프레임워크를 사용하며 기능을 구현하기 위해 추상 클래스를 상속받아야 했다.

프레임워크의 추상 클래스를 구현하여 커스텀 기능을 추가하면, 다형성을 통해 직접 구현한 클래스도 프레임워크의 작동 방식에 잘 포함되어 동작하게 된다.

  1. 추상화를 통해 하위 개념을 빠르게 구현한다.(상위 클래스에 공통 기능 구현)
  2. 다형성을 통해 유연성을 늘린다(여러가지 구현체를 사용 가능)
  3. 메서드 통일(필수 메소드 보장)

스프링 시큐리티


시큐리티 필터는 필터를 기반으로 동작한다.

사용자의 HTTP 요청이 필터체인에 전달되고, 필터 체인의 필터들을 거쳐가며 데이터 전처리, 인증 등 필요한 작업들이 수행된다.

사용자는 직접 필요한 기능을 구현한 커스텀 필터를 체인에 추가하여 원하는 작업을 수행할 수 있다.

아래의 코드는 ???를 참고하여 작성되었다.

jwt 필터 구현

jwt 토큰 검증을 구현하기 위해 Http 요청당 한 번만 실행하는 필터인 OncePerRequestFilter라는 추상화 클래스를 상속받아 커스텀 필터 클래스를 구현했다.

필터가 수행할 작업을 구현하는 doFilterInternal() 추상 메서드를 구현하여 JWT 인증을 위한 코드를 작성한다.

JWT 토큰을 추출, 검증하고 유효한 토큰을 통해 검증된 사용자에 대한 인증을 스프링 시큐리티에 등록하여 로그인 절차를 생략한다.

public class JwtFilter extends OncePerRequestFilter {

   private final JwtUtil jwtUtil;

   public JwtFilter(JwtUtil jwtUtil){
       this.jwtUtil=jwtUtil;
   }
   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
   
       //jwt토큰 검증
       
       //검증된 유저에 대한 스프링 시큐리티 인증 토큰 등록

       filterChain.doFilter(request, response);

   }
   

기능을 구현하기 위해 작성해야 하는 메서드는 doFilterInternal() 하나다. 필터의 구현 방법, 작동 로직을 구현하지 않아도 된다.

"jwt 토큰을 검증하고 인증을 등록하기"라는 핵심 기능만 직접 구현하면 된다.

필터 동작에 필요한 부분은 상속받고 있는 OncePerRequestFilter을 통해 구현이 되어 있기 떄문이다.

public abstract class OncePerRequestFilter extends GenericFilterBean {
	@Override
	public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
            //최초 실행시 필터가 실행됐음을 표시
            //필터 작업 실행
	}

	...
    ...
    ...

	//필터의 기능을 구현하는 doFilterInternal 추상 메서드
	protected abstract void doFilterInternal(
			HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException;


}

OncePerRequestFilter를 상속받는 클래스들은 프레임워크에 이렇게나 많다.

doFilter()와 같이 필터의 동작을 위해 필요한 기타 복잡한 로직들은 상위 클래스에 구현되어있기 때문에 편리하게 클래스를 구현할 수 있고, 코드의 중복도 줄일 수 있다.

필터들은 각각 수단계의 복잡한 추상화를 거치고, 여러 인터페이스가 적용되어 각각의 용도에 따라 구현된다.

그러나 모든 필터들은 최상위에 Filter 인터페이스를 상속받기에 doFilter(), init(), destroy()라는 기본 메서드 구현이 보장된다.

그렇기에 모든 필터가 필터체인에서 Filter타입으로 업캐스팅 되어 체인의 일부로 동작할 수 있다.

출처

[JAVA] 자바 추상클래스란?

[Java] 타입 변환과 필드의 다형성

OOP-객체-지향-프로그래밍-개념과-추상화-설계의-이해

추상-클래스Abstract-용도-완벽-이해하기

0개의 댓글