상속 (inheritance) 이란?

기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것.

class Parent{
    int age;
}

class Child extends Parent{
    String name;
}

상속을 통해 클래스를 관리하면 적은 양의 코드로 새로운 클래스를 작성할 수 있으며 추가 및 변경이 용이해진다.

JAVA에서 상속을 구현하는 방법은 상속받고자 하는 클래스 뒤에 extends를 붙이기만 하면 된다.

 

상속하는 클래스와 받는 클래스를 다음과 같이 다양한 방법으로 부른다.

  • 조상 클래스, 부모(parent) 클래스, 상위(super) 클래스, 기반(base) 클래스
  • 자손 클래스, 자식(child) 클래스, 하위(sub) 클래스, 파생(derived) 클래스

 

JAVA에선 자식 클래스는 부모 클래스의 멤버 변수, 메소드를 상속받아 사용할 수 있다.

 

"전체 프로그램들을 구성하는 클래스들을 면밀히 설계 분석하여,

클래스간의 상속관계를 적절히 맺어 주는 것이 객체지향 프로그래밍에서 가장 중요한 부분이다."

라고 이야기한다.

 

객체간의 협력과 책임을 강조하는 내용인 것 같은데 꼭 상속관계여야만 할까? 라는 생각이 드는 부분이기도 하다.

 

상속관계 vs 포함관계?

class Point{
    int x;
    int y;
}

class Circle{
    Point center;
    int radius;
}

Class 간의 관계에는 상속관계가 아닌 포함관계 또한 존재한다.

ex) Circle 클래스가 Point 클래스를 변수로 가지고 있음

 

클래스간의 관계를 어떻게 결정할 것인가에 대해 책에서는 is a, has a 를 대입해보라고 설명한다.

  • 중심은 원이다 (x)
  • 원은 중심을 가지고 있다. (o)

하위 클래스 is a 상위클래스일 경우 상속 관계로

상위 클래스 has a 하위클래스일 경우 포함관계로 설정하라고 이야기한다.

 

그러나 "스프링 입문을 위한 자바 객체지향의 원리와 이해" 책에선 is a 관계를 부정적으로 본다.

Class Car{
  String color;
}

// is a 관계 성립
Car myCar = new Car();

내 차는 자동차이다. (o)

is a 관계를 객체와 인스턴스 관계로 오해하기 쉽기 때문에 is a kind of 관계로 하는 것이 명확하다고 이야기한다.

내 차는 자동차의 한 분류이다 (x)

  • 객체지향의 상속은 상위 클래스의 특성을 재사용하는 것이다.
  • 객체지향의 상속은 상위 클래스의 특성을 확장하는 것이다.
  • 객체지향의 상속은 is a kind of 관계를 만족해야 한다.

단일 상속

C++는 한 개 이상의 조상 클래스로 부터 상속을 받는 다중 상속이 가능하다.

그러나 JAVA는 오직 단일 상속만 허용한다.

class Father{
    String name = "Dad";
}

class Mother{
    String name = "Mom";
}

class Child extends Father, Mother{
}

Child c = new Child();
System.out.println(c.name); //Dad?? Mom??

 

만약 두 개 이상의 부모 클래스에서 같은 멤버를 상속할 경우 어떤 부모 클래스의 멤버를 참조해야할지 모르게 된다.

이와 같은 다중 상속의 단점 때문에 JAVA는 다중 상속을 과감하게 제거했다.

 

Object 클래스

Object 클래스는 모든 클래스 상속계층도의 최상위에 있는 조상 클래스이다.

Class를 생성할경우 컴파일러는 자동으로 Object 클래스를 상속받게 해준다.

 

따라서 Object 클래스의 toString(), equals()와 같은 메서드를 따로 정의하지 않아도 사용할 수 있으며

후술할 다형성 파트에서도 구현에 도움을 줄 수 있다.

 

오버라이딩이란?

class Parent{
  public void say(){
    System.out.println("hi");
  }
}
class Child extends Parent{
  public void say(){
    System.out.println("hello");
  }
}

Child c = new Child();
c.say(); // "hello"

부모 클래스로부터 상속받은 메서드의 내용을 변경하는 것이다.

자식 클래스에서 부모 클래스와 똑같은 이름의 say() 라는 함수를 재정의했다.

자식 클래스를 생성 후 say() 함수를 호출하면? 재정의된 함수가 실행된다.

 

조건 :선언부가 조상의 것과 완전히 일치해야함 (이름, 매개변수, 반환타입 모두 동일)

그러나 접근 제어자와 예외는 제한된 조건 하에서만 다르게 변경할 수 있다.

  • 접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경 불가능
  • 조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없다 → throws 시에 상위 클래스를 참조하는 메소드도 처리가 가능해야한다.
  • 인스턴스 메서드를 static으로 또는 그 반대로 변경할 수 없다.

super

자손 클래스에서 조상 클래스로부터 상속받은 멤버를 참조하는데 사용하는 참조 변수이다.

super.x; 와 같이 사용하며 super() 를 이용해서 부모 클래스의 생성자를 호출할 수 있다.

 

하위 클래스는 생성자의 첫 줄에서 반드시 상위 클래스의 생성자를 호출해야한다.

자식 클래스가 조상클래스의 멤버를 사용할 수도 있기 때문이다.

 

Object 클래스를 제외한 모든 클래스의 생성자에 this() 혹은 super()를 컴파일러는 생성해준다.

이때 컴파일러가 삽입하는 생성자는 기본 생성자이므로 부모 클래스에 기본 생성자 없으면 컴파일 오류가 발생한다.

class Parent{
    int x;
    public Parent(int x){
        this.x = x;
    }
}

class Child extends Parent{
    int y;
    public Child(int x, int y){ //오류 발생
        this.x = x;
        this.y = y;
    }
}

조상 클래스의 멤버변수는 조상클래스의 생성자에 의해 초기화되도록 하는 것이 좋다.

 

package란?

클래스의 묶음.

클래스 또는 인터페이스를 관련된것끼리 묶어놓아 효율적으로 관리할 수 있도록 한다.

 

같은 이름의 클래스여도 다른 패키지에 존재할 수 있으므로 라이브러리의 클래스와 이름 충돌 막아준다.

  • 클래스 : 물리적으로 하나의 클래스 파일로 존재 (.class)
  • 패키지 : 물리적으로 하나의 디렉토리로 존재

하나의 소스파일에는 첫번째 문장으로 단 하나의 패키지 선언만 허용하며

모든 클래스는 반드시 하나의 패키지에 속해야한다.

패키지를 지정하지 않을 경우 자바에서 기본적으로 제공하는 이름없는 패키지(unnamed package)에 포함된다.

 

패키지는 점을 구분자로 하는 계층구조로 구성 가능하며, 클래스 파일을 포함하는 하나의 디렉토리이다.

 

패키지의 선언

package 패키지명; 과 같이 선언한다.

패키지명은 대소문자 모두 허용하지만, 클래스명과 구분하기 위해 소문자로 하는 것이 원칙이다.

 

그러나 패키지 명에 패키지가 존재하는 디렉토리의 절대 경로를 모두 쓰기는 힘들다.

또한 컴파일, 로드시에 패키지위 위치를 찾기 힘드므로 CLASSPATH 라는 이름의 루트 디렉토리를 설정할 수 있다.

 

; 구분자를 이용해 여러개의 루트 디렉토리도 지정 가능하며 순차적으로 모두 탐색후 제일 먼저 찾은 파일을 사용한다.

JDK는 몇가지 CLASSPATH를 기본으로 제공한다.  (\jre\classes, \jre\lib\ext 등등)

 

import 문

컴파일러에게 소스파일에 사용된 클래스의 패키지에 대한 정보를 제공한다.

import 문은 프로그램 실행 성능에 영향을 끼치지 않는다. 컴파일 시간에만 아주 조금 영향을 끼친다.

 

import 패키지명; 과 같이 선언하며 package와 다르게 하나의 파일에서 여러번 선언이 가능하다.

같은 패키지 내의 클래스들은 import 없이도 사용이 가능하다.

 

static import 패키지명.클래스명 을 이용해 static 메소드 사용시 클래스 이름을 생략할 수 있다.

 

제어자

 

제어자는 클래스, 변수 또는 메서드의 선언부에 함께 사용하여 부가적 의미를 부여한다.

  • 접근 제어자 public, protected, default, private
  • 그 외 static, final, abstract, native, volatile 등등

접근 제어자와 접근 범위

  • private : 해당 클래스 안에서만 접근 가능
  • default : 해당 패키지 안에서만 접근 가능
  • protected : 동일 패키지 또는 상속받은 클래스에서 접근 가능
  • public : 어디서든 접근 가능

여러 제어자를 함께 사용 가능하나, 접근 제어자는 하나만 사용할 수 있다.

 

static - 클래스의, 공통적인

  • static이 붙었을 경우 static 영역에 저장하여, 모든 인스턴스가 공유한다.
  • static 제어자가 붙은 메소드나 변수는 인스턴스 선언 없이도 사용이 가능하다.
  • 멤버변수, 메서드, 초기화 블럭 앞에 사용 가능하다.

 

final - 마지막, 변경할 수 없음

  • 변수에 사용시 변경 불가능한 상수로 만든다.
  • 메서드 사용시 해당 메서드는 오버라이딩이 불가능하다.
  • 클래스에 사용시 해당 클래스는 상속이 불가능하다. (String, Math)

 

abstract - 추상의, 미완성의

선언부만 작성하고, 실제 수행은 구현하지 않은 추상 메서드, 클래스 선언하는데 사용한다.

추상 클래스는 인스턴스 생성이 불가능하다.

차후 추상 클래스 파트에서 더 자세하게 설명한다.

 

 

접근제어자를 이용한 캡슐화?

 

데이터가 유효한 값을 유지하도록, 외부에서 변화 시키지 못하도록 제한하는 것을 data hiding이라고 하며

객체지향 원리의 캡슐화에 해당한다.

외부에는 불필요한 부분을 감추기 위해 접근 제어자를 사용한다.

 

생성자의 접근 제어자

생성자에 접근 제어자를 사용함으로서 인스턴스의 생성을 제한할 수 있다.

보통 생성자의 접근제어자는 클래스의 접근제어자와 동일하게 설정하나 다르게 설정하는 것도 가능하다.

 

private으로 설정하면 외부에서 인스턴스 생성을 막을 수 있으며 싱글톤 패턴을 구현할때 주로 사용한다.

생성자가 private일 경우 다른 클래스의 조상이 될 수 없으므로 super로 불러오기 불가능하며, 보통 final과 같이 사용한다.

 

다형성

“여러 형태를 가질 수 있는 능력”

한 타입의 참조 변수로 여러 타입의 객체를 참조 할 수 있도록 함으로서 구현한다.

class Parent{
  int var = 1;
  
  public String say(){
    return "hi";
  }
}

class Child extends Parent{
  int var = 2;
  
  public void say(){
    return "hello";
  }
}

Parent c = new Child(); // 가능!

JAVA에선 조상 클래스 타입의 참조변수로 자손 클래스의 인스턴스를 참조할 수 있다.

같은 타입의 참조변수로 참조하는 것과 조상 타입의 참조변수로 참조하는 것의 차이는 무엇일까?

 

그렇다면 위와 같은 상속관계의 클래스에서 다음과 같은 문장을 실행시키면 어떤 값이 나올까?

Parent a = new Child();
System.out.println(a.var); // 1
System.out.println(a.say()); // 2

Child b = new Child();
System.out.println(b.var); // 3
System.out.println(b.say()); // 4

 

정답은.... 다음과 같다.

 

 

그런데 생각해보면 이상한 부분이 있다. 왜 같은 Child 인스턴스를 만들었는데

  1. 변수는 참조변수의 타입에 따라 달라지며 (a는 1, b는 2)
  2. 메서드 함수는 달라지지 않는걸까 (a,b 둘다 hello)

그 이유는 클래스 상속시의 메모리 구조가 다음과 같기 때문이다.

Heap영역에 객체를 생성하면 부모 클래스와 자식 클래스의 인스턴스가 같이 생성된다고 개구리 책은 설명한다.

 

참조 변수의 타입에 따라 가리키는 인스턴스의 영역이 달라진다. 

부모타입의 참조변수가 하위 타입 인스턴스를 참조시에는 부모타입의 멤버만 사용 가능하다.

즉 같은 타입의 인스턴스지만 사용할 수 있는 멤버의 개수가 달라진다.

 

그러나 메소드를 오버라이딩 했을 경우 자식 클래스의 메소드가 부모 클래스의 메소드까지 덮어버린다.

정확히는 오버라이딩된 메소드가 부모 클래스의 메소드보다 더 높은 우선순위를 가지고 호출된다.

 

이것이 어떻게 작동하는 것일까?

V Table

짜잔~ 사실 힙 영역에는 메서드 함수가 저장되지 않는다.

이게 갑자기 뭔 소리냐 라고 이야기하겠지만 사실이다.

 

만약 힙 영역에 객체마다 메서드 함수에 대한 내용을 저장한다면,

100개의 인스턴스를 생성한다면 100개의 메서드가 힙에 올라갈것이다.

 

그런데 메서드가 인스턴스마다 다르게 동작하는가? 아니다. 모든 인스턴스는 똑같은 메서드를 실행시킨다.

그래서 jvm은 메소드의 구현 주소를 모은 vtable을 만들어 놓는다.

 

클래스가 로드될때 jvm은 그 클래스의 vtable을 만드는데 ,

부모 클래스의 메소드를 상속할 시 부모 클래스의 vtable 주소를 그대로 사용하며

자식 클래스가 오버라이딩 했을 시 메소드를 vtable에 추가한다.

 

그리고 힙 영역의 객체 맨 앞에는 vtable의 주소가 들어가 있다.

그래서 객체에서 메서드가 호출되었을 경우 해당 클래스의 vtable로 진입해 가장 가까운 메서드를 호출하는 것이다.

 

정확하게 vtable이 구현되어있는 방식은 JVM의 종류와 버전에 따라 다르다고 한다.

(오버라이딩 된 메서드가 vtable의 맨 밑으로 들어가는지, 기존 메서드의 주소를 대체하는지는 글마다 다르게 설명한다)

우리가 알아둘 것은 인스턴스 내부에 실제 메소드 구현에 대한 정보를 담고 있는 주소 테이블이 존재한다는 것이고

객체를 상속하고 오버라이딩 할 경우 자식 클래스의 인스턴스는 자식 클래스의 메소드 주소만 접근할 수 있다는 것이다.

 

https://dataonair.or.kr/db-tech-reference/d-lounge/technical-data/?mod=document&uid=235941

 

Runtime Data Areas

Runtime Data Areas ㈜엑셈 컨설팅본부 /APM팀 임 대호 Runtime Data Area 구조 Runtime Data Area 는 JVM 이 프로그램을 수행하기 위해 할당 받는 메모리 영역이라고 할 수 있다 . 실제 WAS 성능 문제에 직면했을 때

dataonair.or.kr

 

메소드의 경우는 이와 같은 참조 테이블의 존재 때문에 오버라이딩시 기존 부모 클래스의 메소드를 완전히 사용할 수 없게 된다.

그러나 멤버 변수의 경우, 힙 영역에 같이 존재하므로, 오버라이딩이 불가능하다.

부모 타입 참조변수로 호출 시 부모 타입 멤버 변수가 호출된다.

class Parent{
  public String say(){
    return "hi";
  }
}

class Child extends Parent{
  public void run(){
  	System.out.println("run run");
  }
}

Parent c = new Child(); // 가능!
c.run() // ?????

 

그렇다면 다음과 같은 코드에서 c.run() 을 호출한다면 어떤 결과가 나올까?

 

부모 타입의 참조 변수에서 자식 타입에만 구현된 함수를 호출했다.

  1. 참조변수만 부모타입일 뿐 인스턴스는 자식 타입이므로 vtable에는 자식 타입의 메소드가 존재한다. 따라서 그냥 호출된다
  2. 프로그램 동작 중 자식 타입의 메소드 호출 시 vtable에 자식 타입의 메소드를 호출 할 수 없어 런타임 에러를 발생시킨다.
  3. 부모타입의 참조변수가 자식 타입에만 구현된 메소드를 실행하므로 컴파일 에러가 발생한다.

정답은 3번이다.

JVM은 컴파일 시에 변수의 타입과, 메소드를 확인하여 이것이 올바른 메소드인지 검사한다.

Parent 타입의 참조변수에서 run() 메소드를 실행시키면

당연히 Parent 클래스에서는 찾을 수 없는 메소드이므로 컴파일 오류가 발생한다.

 

vtable, 메소드 오버라이딩에 관한 내용은 컴파일 이후 실행되었을때의 메모리 상황이고

다음과 같은 상황에선 그냥 컴파일 오류가 발생한다.

 

참조변수의 형변환

참조변수도 형 변환이 가능하다. 단 상속관계에 있는 클래스 사이에서만 가능하다.

자손타입 → 조상타입, 조상타입 →자손타입 모두 가능하다.

  • 자손 → 조상 (업캐스팅) : 형변환 생략이 가능
  • 조상 → 자손(다운캐스팅) : 형변환 생략 불가능

더 큰 범위에서 작은 범위로의 형변환은 생략이 가능하다.

형변환은 참조변수의 타입을 변환하는 것일 뿐, 인스턴스를 변환하지 않는다.

 

참조 변수는 인스턴스에 아무런 영향을 끼치지 않는다.

참조하는 인스턴스에서 사용할 수 있는 멤버의 범위만 조절하는 것이다.

 

instanceof 연산자

class Car{
  String name;
}

class FireCar extends Car{
  String color = "red";
}

Car myCar = new Car();
Car myFireCar = new FireCar();

System.out.println(myCar instanceof Car); //true
System.out.println(myFireCar instanceof FireCar); //true

System.out.println(myCar instanceof FireCar); //false
System.out.println(myFireCar instanceof Car); //true

참조변수가 참조하는 인스턴스의 실제 타입을 알아보는 연산자이다.

인스턴스의 실제 타입 뿐 아니라 어떤 클래스를 상속받는지도 확인해 볼 수 있다.

 

true가 나온다면 검사한 타입으로 형변환이 가능하다는 것을 의미하기도 한다.

(null이 들어가면 false가 반환된다.)

 

개구리 책에서는 instanceof 연산자가 LSP 원칙(리스코프 치환 원칙)을 어기는 코드에서 자주 등장하는 연산자이기에

instanceof 연산자가 나타난다면 냄새 나는 코드가 아닌지(리팩터링의 대상이 아닌지) 다시 확인해 보라는 이야기를 한다.

 

참조변수와 인스턴스의 연결

조상과 같은 이름의 인스턴스 변수를 중복 정의한다면 조상 타입.변수, 자손타입.변수를 참조할 때 다른 결과가 나온다.

또한 참조변수의 타입에 따라서도 값이 달라진다.

public class Main
{
	public static void main(String[] args) {
		Parent c = new Child();
		
		System.out.println(c.x); // 1
	}
}

class Parent{
    int x = 1;
}

class Child extends Parent{
    int x = 2;
}

클래스 안에서는 super와 this를 사용해 구분이 가능하다.

 

매개변수의 다형성

public class Main
{
	public static void main(String[] args) {
		Book b = new Book("hi");
		Car c = new Car("Hello");
		
		printPrice(b); //2000
		printPrice(c); //10000
	}
	
	public static void printPrice(Product p){
	    System.out.println(p.getPrice());
	}
}

class Product{
    int price = 1000;
    
    public int getPrice(){
        return this.price;
    }
}

class Book extends Product{
    String name;
    
    public Book(String name){
        this.price = 2000;
        this.name = name;
    }
}

class Car extends Product{
    String name;
    
    public Car(String name){
        this.price = 10000;
        this.name = name;
    }
}

메서드의 매개변수에 다형성을 적용하면, 여러 타입의 인스턴스를 하나의 메서드로 받아올 수 있다.

 

여러 종류의 객체를 배열로 다루기

조상타입 참조변수로 자손타입 객체를 참조하는 것 가능하므로

조상타입의 참조변수 배열로 여러개의 자손타입 인스턴스를 묶어서 다를 수 있다.

(Object 배열을 생성할 경우 모든 타입의 객체를 받을 수 있다.)

public class Main
{
	public static void main(String[] args) {
		Product[] products = new Product[3];
		
		products[0] = new Book("book1");
		products[1] = new Book("book2");
		products[2] = new Car("Car1");
		
		for (Product p : products){
		    printPrice(p);
		}
		
	}
	
	public static void printPrice(Product p){
	    System.out.println(p.getPrice());
	}
}

 

추상 클래스

책에서는 클래스가 설계도라면 추상 클래스는 미완성 설계도라고 비유한다.

클래스 내에 미완성 메서드를 포함한다는 의미이며

추상클래스로는 인스턴스를 생성하는 것이 불가능하다.

(그러나 추상 클래스도 생성자, 멤버변수와 메서드를 가질 수 있다.)

 

추상 메서드

선언부만 작성, 구현부는 작성하지 않은 메서드.

추상클래스를 상속받는 자손 클래스는 오버라이딩을 통해 반드시 추상 메서드를 모두 구현해주어야한다.

 

"상속이 자손클래스를 만드는데 조상 클래스를 사용하는 것이라면,

추상화는 기존의 클래스의 공통부분을 뽑아내서 조상 클래스를 만드는 것" 이라고 이야기한다.

 

인터페이스란

interface Car{
    public void run();
}

class MyCar implements {
    public void run(){
        System.out.println("부릉부릉");
    }
}

MyCar myCar = new MyCar();
myCar.run(); // 부릉부릉

일종의 추상 클래스, 그러나 추상화 정도가 더 높다.

인터페이스는 일반 메서드 또는 멤버변수를 가질 수 없다 (추상메서드와 상수만을 멤버로 가진다)

 

미완성 설계도도 아닌 기본 설계도라고 비유한다.

모든 멤버변수는 public static final이어야하며, 생략시 컴파일러가 추가해준다.

 

모든 매서드는 public abstract이며 생략이 가능하다.

(static 메서드와 defauslt 메서드는 JDK1.8부터 예외로 추가되었다.) 

 

인터페이스의 구현

implements라는 키워드를 사용한다.

인터페이스의 모든 추상 메서드를 작성해야한다. 작성하지 않을 시 abstract를 붙여서 추상 클래스로 만들어야한다.

 

접근 제어자는 상속 시 더 넓은 범위만 지정 가능하다.

인터페이스의 기본 접근 지정자는 public abstract이므로,

접근 지정자를 지정하지 않은 인터페이스의 메소드를 구현할 경우 반드시 public으로 구현해야한다.

 

인터페이스의 상속과 다중상속

인터페이스는 인터페이스만 상속 가능하며, 다중 상속도 가능하다.

 

그렇다면 인터페이스는 왜 다중 상속이 가능하냐?

인터페이스의 상수는 static이므로 사용시 앞에 클래스 명을 붙이고,

추상 메서드는 어차피 오버라이딩해야하므로 똑같은 이름의 메서드를 중복 상속해도 문제될 것이 없다.

 

인터페이스를 이용한 다형성

interface Car{
    public void run();
}

class MyCar implements {
    public void run(){
        System.out.println("부릉부릉");
    }
}

Car c = new MyCar();
c.run(); // 부릉부릉

인터페이스 역시 인터페이스 타입의 참조변수로 구현 클래스의 인스턴스를 참조하는 것이 가능하다.

인터페이스 타입으로 형변환 역시 가능하다.

 

인터페이스 타입의 참조변수는 메서드 호출시 구현한 인스턴스를 제공한다.

리턴타입이 인터페이스일 경우 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다.

 

인터페이스의 디폴트 메서드와 static 메서드

JDK 1.8 이전엔 허용 안됐다.

(java.util.Collection 관련된 static 메서드가 별도의 Collections 클래스에들어간 것을 볼 수 있다.)

 

인터페이스에 추상 메서드를 추가할경우 상속받는 모든 클래스에 구현을 추가해줘야한다.

하지만 디폴트 메서드를 추가한다면 모든 클래스에 구현을 추가해줄 필요가 없어진다.

대신 다중 상속시 문제가 생기고 다음과 같이 신경써야한다.

  • 여러 인터페이스가 같은 디폴트 메서드 구현 : 구현 클래스에서 오버라이딩 해야한다.
  • 디폴트 메서드와 조상 클래스 메서드간의 충돌 : 디폴트 메서드 무시

내부 클래스 (inner class)

클래스 안에 선언되는 클래스이다.

두 클래스가 서로 긴밀한 관계에 있는 경우 사용한다.

 

서로 쉽게 접근이 가능하며, 외부엔 불필요한 클래스 감추기 가능하다.

 

종류와 특징

  1. 인스턴스 클래스 : 멤버 변수 선언위치에 선언, 인스턴스 멤버처럼 다루어진다.
  2. 스태틱 클래스 : 외부 클래스의 static멤버처럼 사용된다. static 메서드에서 주로 사용한다.
  3. 지역 클래스 : 메서드나 초기화 블럭에서 선언, 선언된 영역에서만 사용된다.
  4. 익명 클래스 : 선언과 생성을 동시에 하는 이름없는 클래스, 일회용으로 사용된다.

내부 클래스는 선언위치에따라 선원위치의 변수와 동일한 스코프와 접근성을 가진다.

내부 클래스와 외부 클래스에 선언된 변수 이름이 같으면 this, 외부 클래스명.this로 구별이 가능하다.

 

static 내부 클래스만 외부의 static 멤버를 사용할 수 있다.

그리고 static 내부 클래스는 외부 클래스의 인스턴스 멤버를 사용하는 것이 불가능하다.

 

지역 클래스는 지역변수도 사용이 가능하지만 final이 붙은 지역변수만 사용 가능하다.

(메서드가 수행 마친 후에도 지역 클래스가 소멸된 지역변수 참조 가능하기 때문)

 

class Main {
    public static void main(String[] args) {
        Animal dog = new Animal() {
            public String bark(){
                return "멍멍";
            }
        };
        System.out.println(dog.bark()); //멍멍
    }
}

class Animal{
    public String bark(){
        return "동물 울음소리";
    }
}

익명 클래스는 클래스의 선언, 객체의 생성을 동시에, 한번에 하나만 생성하는 일회용 클래스이다.

생성자를 가질 수 없으며 하나의 클래스만 상속받거나, 하나의 인터페이스만 구현이 가능하다

"너무 객체지향개념에 얽매여서 고민하기보다, 일단 프로그램을 기능적으로 완성한 다음 어떻게 객체지향적으로 개선할 수 있을지 고민하여 점차 개선해나가는 것이 좋다.

처음부터 이론을 많이 안다고 좋은 설계를 할 수 있는 것은 아니다."

 

위의 글로 시작하는 자바의 정석 6장의 제목은 사실 '객체지향 프로그래밍 I' 이다.

그런데 읽고 보니 객체지향의 원리와 이론적인 내용보다는

그냥 JAVA의 Class에 대한 내용이 중점적인 것 같아서 제목을 바꿨다.

 

클래스와 객체

클래스와 객체가 무엇인지는 다들 알고 있을 듯 해서 깊게 짚고 넘어가진 않겠다.

책에선 다음과 같이 클래스와 객체를 정의한다.

  • 클래스란 '객체를 정의해놓은 것', '객체의 설계도 또는 틀'
  • 객체는 '실제로 존재하는 것, 사물 또는 개념' , 프로그래밍에선 '클래스의 정의된 내용대로 메모리에 생성된 것'

보통 이 관계를 설명할때 붕어빵과 붕어빵틀이라는 비유가 많이 나오는데

우리에게 개구리 책으로 유명한 '스프링 입문을 위한 자바 객체 지향의 원리와 이해' 는 이 비유를 신나게 깐다.

//붕어빵틀 비유
붕어빵틀 붕어빵 = new 붕어빵틀();

//개념 - 실체 관계
사람 파란옷을입은사람 = new 사람();
사람 한국인 = new 사람();
사람 안경을낀사람 = new 사람();
사람 남자 = new 사람();

 

객체지향의 원리에 대해 깊게 파고드는 책은 아니라고 생각해서,

쉽게 설명하기 위해 썼다고 생각한다.

 

또 객체지향적인 설계의 관점에서 보는 것이 아니라 JAVA라는 언어의 사용법에 대해 공부할땐,

Class를 설계도 또는 틀이라고 이해하고 가도 문제가 없지 않을까?

 

객체와 인스턴스

클래스로부터 객체를 만드는 과정을 인스턴스화라고 이야기하고

만들어진 객체 인스턴스라고 한다.

 

인스턴스 = 객체?

책상 나무책상 = new 책상();

 

책상 클래스로부터 만들어진 객체를 인스턴스라고 한다.

결국 같은 의미이지만 인스턴스는 어떤 클래스로부터 만들어졌는지 강조하는 보다 구체적인 의미를 지닌다.

  • 나무책상은 객체다
  • 나무책상은 책상 클래스의 인스턴스다.

라고 이야기하는 것이 자연스럽다.

 

JAVA 객체의 생성

Tv t;
t = new Tv();
  1. Tv 클래스 타입의 참조변수 t 선언
  2. new 연산자에의해 Tv 클래스 인스턴스가 힙 영역에 생성
  3. 생성자에 의해 인스턴스 초기화
  4. t 변수에 힙 영역의 주소 할당

와 같은 순서로 객체를 생성하고 초기화한다.

 

객체 배열

Tv tvArray[3];

tvArray[0] = new Tv();
tvArray[1] = new Tv();
tvArray[2] = new Tv();

 

참조변수들을 하나로 묶은 참조 변수 배열이다.

여러 종류의 객체로 배열을 만들 수는 없을까? -> 나중에 다형성 파트에서 나온다고 한다.

 

클래스의 또 다른 정의

  1. 변수
  2. 배열 (같은 타입의 변수들의 집합)
  3. 구조체 (다른 타입의 변수들도 묶음)
  4. 클래스 (서고 관련있는 변수와 함수)

순으로 데이터 저장형태가 발전했다.

 

클래스는 여러 변수와 함수를 하나의 클래스로 정의하여 관계가 깊은 변수들을 하나로 다룰 수 있도록 해준다.

또 사용자정의 타입의 역할도 할 수 있다.

 

메서드

클래스 내에서 정의한 함수이다.

잘 사용하면 높은 재사용성, 중복 코드 제거, 프로그램의 구조화 등의 장점이 있다.

 

매개변수(parameter)와 인자(Argument)? 

  • 매개변수(parameter) : 함수에서 사용하기 위해 정의 부분에 나열된 변수들
  • 인자(Argument) : 함수를 호출할 때 전달되는 실제 값

메서드에 전달되는 인자는 파라미터로 선언된 타입과 같거나 자동 형변환이 가능해야만 한다.

또한 메서드의 반환값 역시 반환 타입으로 선언된 타입과 같거나 자동 형변환이 가능해야만 한다.

 

호출스택(call stack) 이란?

메서드 작업에 필요한 메모리 공간을 할당하는 곳이다.

메서드를 수행하는 동안 지역변수, 연산의 중간 결과등을 저장하는데 사용한다.

메서드 작업이 마치면 할당된 메모리 공간은 반환되어 비워진다.

 

call stack 영역에선 메서드 작업 중 다른 메서드 호출 시

다음 공간에 새로운 메서드를 위한 공간을 만들고 진행하던 메소드는 중단한다.

다음 메소드를 진행한 후 완료되면 다시 전의 메소드로 돌아와 이어서 진행한다.

예전에 JVM의 메모리 구조를 설명하려고 만들었던 그림인데

왜 메소드 실행 영역의 이름이 stack인지 이제야 알아버렸다.

 

Stack Area 부분의 그림을 좀 더 정확하게 그리면 이렇게 그리는게 맞을 것 같다.

 

기본형 매개변수, 참조형 매개변수

public class Main{
    
	public static void main(String [] args){
        
        Data data = new Data();
        data.a = 1;
        
    	change(data.a); //기본형
    	System.out.println(data.a); //1
    	
    	change(data); //참조형
    	System.out.println(data.a); //2
	}
	
	public static void change(int a) {
	    a = 2;
	}
	public static void change(Data data){
    	data.a = 2;
	}
}

class Data{
    int a;
}

 

매개변수 타입이 primitive일 경우 값을 복사해서 넘긴다.

따라서 함수 내에서 원본 값을 바꾸는 것이 불가능하다.

 

매개변수 타입이 참조형일경우 주소를 복사해서 넘기므로 원본 변경이 가능하다.

임시적으로 주소 복사를 원할 시 길이가 1인 배열 선언하면 된다고 한다.

String Pool

위의 예시를 공부하다가 String Constant Pool에 대해서 새롭게 알게 됐다.

JAVA에선 String을 선언하는 방법이 2가지가 있다.

  • 리터럴 방식
  • new 연산자 방식
String a = "stringA";
String b = new String("stringB");

 

JVM에선 리터럴 방식으로 선언된 문자열을 독립적으로 모아 저장하는데 이 영역이 String Constant Pool 이다.

String은 불변 객체이기 때문에 문자열 생성 시 String Constant Pool에 저장된 리터럴을 재사용한다.

 

즉 같은 내용의 문자열을 재선언 할 시 새로운 메모리 영역에 문자열을 생성하는 것이 아닌

기존의 문자열의 주소만 가져와 전달해준다는 것이다.

 

그러나 new 연산자 방식은 Heap영역에 항상 새로운 문자열 객체를 생성한 후 반환한다.

따라서 성능 및 메모리 최적화를 위해선 문자열은 항상 리터럴 방식으로 선언하자.

 

https://deveric.tistory.com/123

 

[Java] 많이 헷갈려하는 String constant pool과 Runtime Constant pool, Class file constant pool

String Constant Pool과 Constant Pool 이 두 가지는 완전히 다른 개념입니다. 용어가 비슷한 형태이기 때문에 이 두 가지를 혼용하여 헷갈리는 경우가 많습니다만, 저장되는 위치부터 저장하는 데이터의

deveric.tistory.com

https://junhyunny.github.io/java/java-string-pool/

 

Java String Pool

<br /><br />

junhyunny.github.io

이와 관련된 공식문서를 참고한 더 자세히 파고들어간 블로그들이 있어 조금 정리해 봤다.

 

리터럴 방식으로 선언한 문자열은 컴파일 시 컴파일러에 의해 string constant pool 저장 대상으로 표시된다.

클래스 파일의 상수 풀(constant pool)에 'CONSTANT_String' 타입으로 저장되며,

런타임시에 스캔되어 string constant pool에 저장되는 방식이다.

 

상수 풀(constant pool)은 뭘까?

컴파일 시 클래스 파일에 존재하는 영역으로 클래스 로더에 의해 JVM에 로드 될때 메모리에 로드한다.

주로 클래스의 구성요소를 저장하고 있다.

 

클래스 파일의 constant pool은 런타임시 Runtime Constant Pool에 저장된다.

JDK 7 이하는 Perm 영역, 이후부터는 Metaspace 영역에 저장된다.

 

 

 

클래스 메서드와 인스턴스 메서드

모든 인스턴스에 공용으로 사용되는 메서드는 static으로 정의하자.

static 변수는 인스턴스를 생성하지 않아도 사용이 가능하다.

static 메서드에서는 인스턴스 변수 사용를 사용할 수 없다.

 

메서드 내에서 인스턴스 변수를 사용하지 않으면 static을 붙이는 것을 고려한다. 라고 책에선 이야기한다.

 

그러나 static 메서드는 다음과 같은 단점을 가진다.

  • 클래스 로드 시점부터 종료시까지 메모리상에 존재한다.
  • 따로 객체를 생성하지 않고도 사용가능하다는 점이 객체지향 원칙을 위반할 수 있다.
  • static 메서드는 오버라이딩이 불가능하다.

다음과 같은 이유로 여러개의 무작정 static 메소드를 사용하는 것 보단, 적절하게 사용할 필요성이 있어 보인다.

인스턴스를 생성하지 않고 호출할 필요가 있는 경우에만 static을 사용하는 것이 적절해보인다.

 

오버로딩

int max(int a, int b) { ... }
float max(float a, float b) { ... }

 

같은 이름의 메서드를 여러 개 정의하는 것을 이야기한다.

메서드 이름이 같고 매개변수의 개수나 타입이 반드시 달라야한다.

(반환 타입은 아무 영향을 주지 못함)

 

가변인자와 오버로딩

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

	test(1,2); //가능
        test(1,2, "hello"); //가능
        test(1,2, "hello", "hi"); //가능
        test(1,2, new String[] {"hello", "hi"}); //가능
	}
	
	public static String test(int a, int b, String... stringArr){ //불가능
	    return "hi";
	}
}

가변인자를 사용하면 매개변수의 개수를 동적으로 지정 가능하다.

Object… args 와 같은 식으로 사용한다.

 

컴파일러가 가변인자를 만나면, 크기에 맞는 배열로 전환해준다.

따라서 가변인자 자리에 배열을 넣어줘도 되며, 아예 넣지 않아도 된다.

 

가변인자 외에도 매개변수가 더 존재한다면 가변인자를 제일 마지막에 선언해줘야한다.

 

생성자

생성자는 인스턴스 생성 메서드가 아닌 초기화 메서드이다.

생성자는 단순히 초기화에만 사용되며 인스턴스 생성 메서드는 new이다.

 

생성자의 이름은 클래스와 같으며, 리턴 값이 없다.

컴파일러는 사용자지정 생성자가 없을 시 기본 생성자를 제공한다.

 

생성자에서 안에서 다른 생성자를 호출할 시 클래스 이름이 아닌 this 메소드를 사용한다.

또 생성자 안에서 다른 생성자 호출시에는 반드시 첫줄에서만 사용해야한다.

 

멤버 변수의 초기화 방법

  • 명시적 초기화 - 선언과 동시에 초기화
  • 초기화 블럭
  • 생성자

멤버 변수의 초기화 방법은 위의 3가지가 존재한다.

초기화 블럭은 다시 2가지로 나뉘는데

  1. 클래스 초기화 블럭 : static{}, 클래스 로딩될때 한 번 만 수행된다.
  2. 인스턴스 초기화 블럭 : {}, 인스턴스 생성할때마다 생성자보다 먼저 수행됨, 모든 생성자에서 공통으로 수행되어야 하는 코드를 넣는데 사용한다.

기본값 -> 명시적초기화 -> 초기화블럭 -> 생성자 순으로 진행되며

뒤에서 다시 초기화하면 뒤에 값으로 지정된다.

 

 

그런데 메소드 변수는 항상 사용하기 전에 초기화해야하며 하지 않을 시 컴파일 오류가 발생하는데

왜 인스턴스 변수와 스태틱 변수는 기본값이 존재할까?

라는 의문이 들어 찾아봤다.

 

  1. 지역변수는 메소드 내에서 계산을 위해서만 쓰이는데 초기화되지 않으면 예기치 않은 값 발생 가능
  2. 메소드 호출은 빈번한데 그때마다 변수를 찾아 초기화 시키는 것은 비효율적

등의 이유가 나오던데 사실 명쾌한 설명이 없는 것 같다.

그냥 자바에서 그렇게 하라고 해서.. 라고 밖에 못하겠는데 더 공부해봐야겠다.

 

 

 

배열이란?

같은 타입의 여러 변수를 연속된 유한한 하나의 묶음으로 다루는 자료 구조.

 

배열의 사용

int[] score1; // 배열 선언 1 (추천)
int score2[]; // 배열 선언 2

score1 = new int[5];//배열 생성

int[] score3 = new int[] {1,2,3,4,5}; //선언과 동시에 초기화
int[] score4 = {1,2,3,4,5}; // 선언과 동시에 초기화시 생성 명령어 생략 가능

score2 = {1,2,3,4,5}; // 선언 이후에 생성 명령어 생략 -> 불가능

배열을 선언하는 방법은

  1. 타입[] 변수이름
  2. 타입 변수이름[] 

두가지 방법이 있으며 1번의 방식이 일반적으로 사용된다.

 

배열은 선언시에 메모리에 공간이 할당되는것이 아니다.

선언시에는 배열의 맨 처음 요소의 주소가 참조될 공간이 변수로 할당된다.

 

배열의 생성시에는 new 메소드를 사용한다.

생성시에는 길이를 반드시 지정해줘야하며 길이가 0인 배열도 생성할 수 있다.

 

배열을 선언과 동시에 초기화 할때는 {}를 사용해 편하게 할 수 있다.

이 경우 길이를 지정하지 않아도 되며

new 생성 메소드를 생략도 가능하다.

 

당연하게도 선언 이후에 생성 메소드를 생략하고 초기화 하려 할 경우 오류가 발생한다.

함수의 매개변수로 배열을 사용할때 주의하자.

 

배열의 인덱스와 길이

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

System.out.println(score[0]); // 1
System.out.println(score[5]); // ArrayIndexOutOfBoundsException! (index는 0부터 4까지)

System.out.println(score.length); // 5

 

배열이름 뒤의 대괄호[] 안에 정수를 넣어 배열내 요소에 접근 가능하다.

들어가는 정수를 index라고 하며, 범위는 0부터 시작해 배열 길이 -1까지이다.

 

유효하지 않은 범위의 숫자를 넣을경우 런타임에러 ArrayIndexOutOfBoundsException가 발생한다.

JVM은 모든 배열의 길이를 별도로 관리해 배열이름.length로 길이 값을 받을 수 있다. (함수가 아님에 주의하자)

배열의 길이는 변경하는 것이 불가능한 상수이다.

 

배열의 복사

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

int tmp = new int[10];

for (int i; i < score.length; i++){
	tmp[i] = score[i];
}

score = tmp;
//score의 기존 배열 주소값 위에 tmp의 주소 복사

만약 기존 배열의 길이가 부족해서 길이를 두 배로 늘려야한다면?

한 번 선언한 배열의 길이는 바꿀 수 없다.

 

길이가 두배인 배열을 새로 생성 한 후 기존 내용을 복사한다.

이후 기존 배열의 변수에 새로운 배열을 복사하면 주소값만 바뀌게 된다.

 

이때 더 이상 참조하지 않는 쓸모없어진 기존 배열은 가비지 컬렉터가 자동으로 제거한다.

 

String 배열

String[] name = new String[3];
//참조형 변수의 기본값 = null → null이 3개 들어있는 배열 생성

String[] name = {”kim”, “park”, “lee”};
// 이 경우 실제로는 String 객체의 주소값 3개가 담긴 배열이 생성됨

String을 포함한 객체 배열도 선언해 사용할 수 있다.

참조형 변수로 객체를 만들면 객체 안에는 참조할 곳의 주소가 들어간다.

 

char 배열과 String 클래스?

String 클래스가 char 배열에 기능 추가해 확장한 것이다.

String 객체는 내용을 변경할 수 없다.

(변경시 새로운 문자열 생성후 반환한것이다.)

 

char 배열은 내용 변경이 가능하다.

String.toCharArray(), new String(char[]) 이용해서 서로 변환이 가능하다.

 

다차원 배열

  • 타입[][] 변수이름
  • 타입 변수이름[][]
  • 타입[] 변수이름[]

3가지 방법으로 모두 선언이 가능하다.

 

2차원 배열의 경우 1차원 배열의 주소를 담고있는 배열을 생성한다.

2차원 이상의 배열은 주소만 저장하는 배열로 구성된다.

따라서 가변적인 배열 생성이 가능하다.

 

코드의 실행흐름은 무조건 위에서 아래로 한 문장씩 진행된다.

하지만 때로는 조건에 따라 문장을 건너뛰거나, 같은 문장을 반복해서 수행해야할 때가 있다.

이럴때 사용하는 프로그램의 흐름을 바꾸는 역할을 하는 문장을 제어문이라고한다,

 

제어문에는 조건문과 반복문이 있다.

 

조건문 -  if, switch

 

1. if문

class HelloWorld {
    public static void main(String[] args) {
        if (5 > 3){
            System.out.println("hello world");
        }
    }
}

가장 기본적인 조건문.

조건식 + {} 괄호 로 구성되어 있다.

 

if (5 > 3) // 가능
if (a == 1) // 가능
if (1) // 에러
if (int b = 1) // 에러
if ("hi") // 에러

파이썬, js와는 다르게 조건식의 결과는 반드시 boolean 형식이어야한다.

보통 비교연산자 혹은 논리연산자로 구성된다.

 

class HelloWorld {
    public static void main(String[] args) {
        char grade = 'A';
        
        if(grade == 'A') System.out.println("GRADE : A");
        else if (grade == 'B') System.out.println("GRADE : B");
        else System.out.println("GRADE : F");
    }
}

만약 블럭 내의 문장이 하나일 경우, {} 괄호를 생략할 수 있다.

if - else, if - else if  문 등으로 조건을 추가할 수 있다.

 

2. switch문

if 문의 조건식은 결과가 참 혹은 거짓 두가지 밖에 없다.

경우의 수가 많아질 수록 조건식이 많아지고 복잡해진다.

class HelloWorld {
    public static void main(String[] args) {
        char grade = 'A';
        
        switch (grade){
            case 'A' :
                System.out.println("GRADE : A");
                break;
            case 'B' :
                System.out.println("GRADE : B");
                break;
            default :
                System.out.println("GRADE : F");
        }
    }
}

switch문은 단 하나의 조건식을 제시하고 그 결과와 일치하는 case 문으로 이동한다.

이동한 case문 아래의 문장들을 수행하며, break문을 만나면 전체 switch 문을 빠져나가게 된다.

 

    final int A = 5;
    int a = 1;
    
    case 'A': // 가능
    case  1: // 가능
    case "hello": // 가능
    
    case A // 가능
    case a // 불가능
    case 1.0 // 불가능

switch문의 조건식은 결과값이 반드시 정수 또는 문자열이어야한다.

case문의 값은 정수 상수 혹은 문자열 리터럴이어야만한다. (변수, 실수 불가능)

 

반복문 - for, while, do-while

1. for문

반복 횟수를 알고 있을때 적합.

 

for문의 구조와 수행순서

for (초기화; 조건식; 증감식){
    수행문
}
  1. 초기화
  2. 조건식
  3. 수행문
  4. 증감식
  5. 2-4 반복
for(;;){
	// 무한 반복문
}
       
int[] arr = {1,2,3,4,5};

for (int n : arr){
	System.out.print(n);
	//12345
}

for(;;)시 무한 반복문

for ( 타입 변수 : 배열 또는 컬렉션) -> 파이썬 in처럼 동작

배열의 모든 요소를 한 번씩 가져옴

 

2. while 문

class HelloWorld {
    public static void main(String[] args) {
        int i = 0;
        while (i < 5){
            System.out.println("loop");
            i ++;
        }
        
        while (true) //무한반복문
        
	//do - while문
        do {
            System.out.println("loop");
            i ++;
        }
        while (i < 5);
    }
}

조건식 + {} 구조

조건식이 참이면 {}안의 문장을 반복한다.

 

for문과 달리 조건식을 생략할 수 없다.

무한 반복문을 만드려면 whilie(true)를 사용해야한다.

 

3. do-while 문

while문과 똑같은데 do 뒤의 문장 먼저 시작하고 조건문 확인

최소 1번은 실행을 보장하는 반복문

 

break : 가장 가까운 반복문을 벗어난다.

continue : 다음 반복으로 넘어간다. (증감식, 다음 조건식)

 

 

 

인증 로직

  1. 아이디 & 비밀번호 담아서 서버에 요청
  2. 서버에서 검증
    • 올바르다면 AccessToken, Refresh Token 생성 후 유저에게 반환
    • Refresh Token은 Redis에 따로 저장
    • 올바르지 않다면 에러 메세지 반환

기존 Spring Security의 인증로직이다.

 

기존 form Login 방식의 인증용 필터인 UsernameAuthenticationFilter는 

유저 아이디와 비밀번호를 요청으로 받아서 확인한 후

Authentication 객체를 만들어서 securityContext에 넣어 사용한다.

그 후 응답시에 HttpSession에 Authentication 객체를 넣은 후 세션 ID를 쿠키로 설정해둔다.

 

이 인증로직을 재활용하려면 인증용 필터를 새롭게 만들어 줘야 하는데

우리가 해야할 일은 HttpBody로 유저 아이디, 비밀번호 받아서

Access Token, Refresh Token을 만든 후 HttpBody에 넣어 반환하는 로직이다.

 

Form 방식이 아니므로 필터를 상속받는것 보다 RestController를 새로 만드는 것이 더 간단해보인다.

LoginController를 만들자.

@Slf4j
@RestController
@RequiredArgsConstructor
public class LoginController{
    
    private final UserServiceImpl userService;
    private final JwtProvider jwtProvider;
    
    //인증 로직
    @PostMapping("/v1/login")
    public ResponseEntity<LoginResponse> loginUser(@Valid @RequestBody LoginRequest request){ 
        User loginUser = userService.signIn(request.getAccountId(), request.getPassword());

        String accessToken = jwtProvider.createAccessToken(loginUser);
        
        String refreshToken = jwtProvider.createRefreshToken(loginUser);
        
        LoginResponse response = new LoginResponse(accessToken, refreshToken);

        return ResponseEntity.ok(response);
    }
	
    //토큰 재발급 로직
    @PostMapping("/v1/refresh")
    public ResponseEntity<String> refreshToken(@RequestHeader("Refresh-Token") String refreshToken){
        
        if(jwtProvider.validateRefreshToken(refreshToken)){
            
            String accountId = getUserAccounId(refreshToken);
            
            User user = userService.findByAccountId(accountId);
            
            String accessToken = jwtProvider.createAccessToken(user);
            
            return ResponseEntity.ok(accessToken);
        }
        return ResponseEntity.ok("Unvalid Refresh-Token");
    }
    
    private String getUserAccounId(String refreshToken){
        refreshToken = refreshToken.replace("Bearer ", "");
        
        return jwtProvider.parseClaims(refreshToken)
                .get("accountId")
                .toString();
    }
}

 

그리고 JWT 관련된 로직을 수행할 JwtProvider를 만들자.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    //만료시간 : 30분
    private final Long ACCESS_EXP_TIME = 1000L * 60 * 30;
    
    //만료 시간 : 하루
    private final Long REFRESH_EXP_TIME = 1000L * 60 * 60 * 24;
    
    private final RedisService redisService;
    
    @Value("${jwt.secret}")
    private String salt;
    
    private Key secretKey;
    
    @PostConstruct
    protected void init(){
        secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }
    
    //Access Token 생성
    public String createAccessToken(User user){
        
        String accountId = user.getAccountId();

        String authorities = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
        
        Date expiration = new Date(System.currentTimeMillis() + ACCESS_EXP_TIME);

        return Jwts.builder()
                .claim("accountId", accountId)
                .claim("authorities", authorities)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }
    
    //Refresh Token 생성
    public String createRefreshToken(User user){
        String accountId = user.getAccountId();
        
        Date expiration = new Date(System.currentTimeMillis() + REFRESH_EXP_TIME);
        
        String refreshToken = Jwts.builder()
                                .claim("accountId", accountId)
                                .setExpiration(expiration)
                                .signWith(SignatureAlgorithm.HS256, secretKey)
                                .compact();
        
        //redis 유효기간 설정
        redisService.setValues(accountId, refreshToken);
        redisService.setExpiration(accountId, REFRESH_EXP_TIME);
        
        return refreshToken;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
   
}

 

 

RefreshToken은 Redis에 저장할 것이므로, RedisService를 만들어서 연결하자.

@Service
@RequiredArgsConstructor
public class RedisService{
    
    private final RedisTemplate redisTemplate;
    
    public String getValues(String key){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }
    
    public void setValues(String key, String value){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key,value);
    }
    
    public void setExpiration(String key, Long time){
        int second = time.intValue();
        
        redisTemplate.expire(key, second, TimeUnit.SECONDS);
    }
    
    public void deleteByKey(String key){
        redisTemplate.delete(key);
    }
}

 

RefreshToken을 Redis에 저장하는 이유와 저장하는 방법?

Access Token의 탈취를 대비하기 위해서 Refresh 토큰을 만들어 사용하지만

Refresh Token 역시 탈취당할 가능성이 존재한다.

 

Refresh Token은 유효기간이 상대적으로 긴 편이므로

탈취당했을 경우, 악성 사용자가 긴 시간동안 Access Token을 재발급하며 사용할 수 있다.

 

따라서 Refresh Token 발급시에 토큰에 대한 정보를 저장소에 저장해 놓은 후

Refresh 토큰이 탈취된 사실을 알거나, 이상한 접근이 감지될 경우 저장소에서 삭제하는 등 대처가 가능하도록 한다.

 

이번 프로젝트에선 접근 속도가 빠른 redis를 사용하기로 했다.

 

유저 아이디(식별자)를 키로, 토큰을 값으로 설정하여 저장하기로 했는데

이렇게 한다면 만약 secret key가 유출되어 리프레쉬 토큰이 위조될 경우

  1. 토큰 검증
  2. 이상 없을 경우 토큰에서 유저 아이디 확인
  3. redis에서 유저 아이디로 기존 토큰과 비교 -> 위조 여부 한번 확인 가능

다음과 같은 로직으로 토큰 위조 여부를 확인하기 쉬워진다.

 

또한 보안이 중요한 서비스라면 사용자의 최근 요청 IP등을 같이 저장해

다른 IP에서 요청이 들어올 경우 메일을 보내거나,

Refresh Token을 삭제하는 등의 대처가 가능하도록 할 수 있겠다.

 

인가 로직

  1. 자원 접근 요청시 Autorization 헤더에 Access Token 담아서 요청
  2. 서버에서 Access Token 검증
    • 유효한 토큰이면 정상 응답 반환
    • 올바르지 않다면 에러 메세지 반환
  3. Access Token 만료시, Refresh Token을 헤더에 담아 서버에 재발급 요청
  4. Redis에 저장된 Refresh 토큰과 비교 후, 올바른 Refresh Token일시 Access Token 재발급

 

JWT 방식은 인증이 필요한 매 요청마다 Autorization 헤더에서 AccessToken을 찾아 확인해야한다.

이 과정은 Filter를 새로 만들어서 적용한다.

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{

    private final JwtProvider jwtProvider;
    
    @Autowired
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
                                 JwtProvider jwtProvider){
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        
        String accessToken = request.getHeader("Authorization");
        
        if (accessToken == null){
            chain.doFilter(request, response);
            return;
        }
        
        if (jwtProvider.validateAccessToken(accessToken)){
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);    
        }
        
        chain.doFilter(request, response);
    }
}

JwtProvider에 인증 관련 로직을 추가한다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    ...
    
    // Access Token 검증
    public boolean validateAccessToken(String accessToken){
        
        if (!accessToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        accessToken = accessToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(accessToken);
            
            if (claims.get("accountId") != null && claims.get("authorities") != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    //Access Token -> Authentication 
    public Authentication getAuthentication(String accessToken){
        
        accessToken = accessToken.replace("Bearer ", "");
        
        Claims claims = parseClaims(accessToken);
        
        Collection<? extends GrantedAuthority> authorities = getAuthorityList(claims);
        
        UserDetails principal = new User(claims.get("accountId").toString(), "", "");
        
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }
    
    // Refresh Token 검증
    public boolean validateRefreshToken(String refreshToken){
        
        if (!refreshToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        refreshToken = refreshToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(refreshToken);
            
            String accountId = claims.get("accountId").toString();
            
            if (accountId != null && redisService.getValues(accountId) != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
    
    private List<GrantedAuthority> getAuthorityList(Claims claims){
        return Arrays.stream(claims.get("authorities").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
    }
}

 

필터 제작 후 Security Config에 FIlter를 적용할 범위를 설정한다.

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Autowired
    JwtProvider jwtProvider;

    //JWT
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
            .authorizeRequests()
            .antMatchers("/v1/login").permitAll()
            .antMatchers("/v1/refresh").permitAll()
            .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
            .anyRequest().authenticated();
    }

}

 

 

여기서 Refresh 요청마다 Refresh Token도 재발급하는

Refresh Token Rotation 방식을 통해 보안성을 더 높일 수도 있다.

 

https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

다음 글에선 악성 사용자들의 공격이 어떤식으로 이루어지는지,

Spring Security에선 어떤 식으로 방어하는 기능을 제공하는지 더 자세히 알아본 후 정리해봐야겠다.

JWT란?

JWT(Json Web Token) 은 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격

사용자의 인증, 인가 정보를 주고 받기 위해 사용한다.

 

주로 Authorization 헤더에 Bearer + token 형태로 사용된다.

Base64로 표현 되어 있고 인코딩, 디코딩이 쉽다.

 

Base64란?

64진법 이라는 뜻.

8비트 이진 데이터를 ASCII 영역의 문자로 바꾸는 인코딩 방식.

 

구성 방식

토큰의 구조는 다음과 같다.

  1. Header : 토큰의 타입이나 어떤 알고리즘으로 생성되었는지 저장한다. 
  2. Payload : 사용자, 토큰에 대한 정보를 저장한다. (표준은 키를 3글자로 지정한다.)
  3. Signature : Header와 Payload 값을 서버에 있는 개인 키를 활용해 암호화해 저장한다. (위, 변조 방지용)

 

작동 방식

  1. 최초 로그인 시 서버에서 토큰을 사용자에게 발급
  2. 사용자는 다음 요청부터 Authorization 헤더에 발급받은 토큰을 넣어 요청을 보냄
  3. 서버는 해당 토큰을 검증(사용자 정보, 권한, 만료기간 등)을 확인하여 토큰이 유효하면 접근을 허가함

 

왜 사용하는가?

세션&쿠키 방식은 이용자가 늘어나면 메모리 사용량이 그만큼 늘어나게 되며,

매 요청마다 세션 저장소를 조회해야한다는 단점이 있었다.

 

JWT는 토큰 자체에 사용자 정보를 저장한다.

따라서 서버는 요청이 들어오면 토큰을 확인하는 로직만 수행하면 되므로

이용자가 늘어나도 session 방식과 다르게 서버에 부하가 적다.

 

그러나 단점도 존재하는데

  1. 쿠키&세션 방식보다 데이터 전달량이 많다
  2. Payload는 암호화되어있지 않으므로, 디코딩이 매우 쉽다.
  3. 발급한 토큰을 서버에서 관리할 수 없으므로 토큰 탈취시 대처가 어렵다.

따라서 토큰 생성시 민감한 정보는 절대로 넣으면 안되며

Secure, Http Only 등의 설정 잘 해줘야한다.

 

또한 발급한 토큰은 더 이상 서버에서 관리 못한다는 단점 때문에 로그아웃, 접근 제한 등이 어렵다.

이러한 문제를 해결하기 위해

  • 블랙리스트 방식
  • 리프레쉬 토큰 방식

을 주로 사용한다.

 

블랙리스트 방식

db, 혹은 서버 메모리에 허용하지 않을 토큰 명단을 생성후

해당 토큰이 들어오면 허가를 하지 않는 방식.

 

그러나 JWT의 장점인 stateless 하다, 서버 자원 사용이 적다는 장점이 사라지게 된다.

 

리프레쉬 토큰 방식

토큰의 탈취 가능성을 생각해 토큰의 유효기간을 매우 짧게 설정한 후 (엑세스 토큰)

유효기간이 긴 리프레쉬 토큰을 같이 발급해, 리프레쉬 토큰이 유효할 경우 토큰을 재발급 하는 방식.

 

리프레쉬 토큰은 발급 후 redis 같은 곳에 저장해서 관리한다.

(리프레쉬 토큰이 탈취당했을 경우 db에서 삭제 등으로 대응 가능)

 

액세스 토큰이 들어올 경우 확인만 하면 되므로 기존 JWT 장점 살리며

토큰이 탈취돼어도 길게 사용하지 못하도록 해 보안성을 높인다.

 

리프레쉬 토큰을 저장하고 사용하면 세션방식과 다를게 뭔가? 라는 생각이 들었지만

세션 방식은 매 요청마다 저장소를 확인하지만, 리프레쉬 토큰은 액세스 토큰이 만료되었을때만 확인하면 된다.

 

보통은 리프레쉬 토큰 방식을 사용하여 관리하며

보안이 중요한 경우 블랙리스트 방식을 같이 사용한다.

 

JWT 방식을 반드시 사용해야할까?

공부하면서 느낀점은 사실 JWT를 사용할 필요가 있을까 싶다.

요청이 몰리는 경우가 많은 서비스 등의 경우가 아닌 이상

그냥 세션 방식 써도 될 것 같다.

 

기존 세션 방식의 문제점인 서버 자원 사용량 증가 등의 문제는

Redis를 이용한 세션 클러스터링등으로 해결이 가능해진 것 같다.

 

은탄환은 없다라는 유명한 말이 있다.

상황에 맞춰서 적절한 방식을 사용하자.

 

Spring Security를 사용해 JWT 방식을 적용한 코드는 다음 글에서 써보겠다.

연산자 : 연산을 수행하는 기호

연산을 수행하기 위해서는 대상이 있어야 하는데, 이 대상을 피연산자라고 한다.

3 + 5 의 연산을 수행할 때 3,5는 피연산자, +가 연산자가 된다.

연산자 종류

 

1. 단항 연산자

  • 증감 연산자 ++, --
  • 부호 연산자 +, -

2. 산술 연산자

  • 사칙 연산자 +, -, *, /
  • 나머지 연산자 %

3. 비교 연산자

  • 대소비교 연산자 >, <, >=, <=
  • 등가비교 연산자 ==, !=

4. 논리 연산자

  • 논리 연산자 &&, ||, !
  • 비트 연산자 &, |, ^, ~, <<, >>

5. 그 외의 연산자

  • 조건 연산자 ? :
  • 대입 연산자 =, op=

 

연산자 우선 순위

우선순위 연산자 연산방향
0 () 괄호 속의 연산자 ->
1 증감(++, --), 부호(+, -), 비트 (~), 논리 (!) <-
2 산술 연산자(*, /, %),  산술 연산자 (+, -) ->
3 시프트 연산자 (<<, >>) ->
4 비교 연산자(==, !=) ->
5 비트 연산자 (&, ^, |), 논리 연산자(&&), 논리 연산자(||) ->
6 조건 연산자 ? : ->
7 대입 연산자 (=, +=, -=, *=, /=, %=, &=, ^=, |=, <<=, >>=) <-

굵은 글씨는 왼쪽부터 우선순위가 높다.

 

논리 연산자 &&가 우선순위 더 높음 -> ||랑 섞어쓸 시 괄호 잘 사용해야한다.

단항, 대입 연산자만 오른쪽에서 왼쪽으로 연산 진행된다.

 

리터럴 연산은 컴파일 시에 미리 계산해준다.

따라서 코드 실행 성능에는 영향을 주지 않으므로 가독성을 생각해서 잘 사용하자.

ex) int day = 60 * 60 * 24;

      int day = 86400; (컴파일시 미리 계산되어 변환됨)

 

int / int 연산은 int로 수행됨 -> 나머지가 생력된다.

int / float 연산 -> int가 자동으로 float으로 형변환 -> 소수점 나머지 구현된다.

 

문자열을 비교 시에는 비교 연산자 아닌 equals() 사용해야한다.

(== 연산자는 완전히 동일한 리터럴 혹은 객체여야함)

 

 

 

 

 

변수란?

데이터를 저장하기 위해 프로그램에 의해 이름을 할당받은 메모리 공간.

데이터를 저장할 수 있는 메모리 공간이며 그 값은 변경될 수 있다.

 

JAVA의 변수 종류

1. 기본형 - Primitive Type (원시 타입)

실제 연산에 사용되는 자료형.

메모리 공간에 직접 저장됨.

 

자바는 다음과 같은 8개 종류의 기본 자료형을 제공한다.

- 정수형 : byte, short, int, long

- 실수형 : float, double

- 문자형 : char

- 논리형 : boolean

자료형 이름 메모리 기본값
정수형 byte 1byte 0
정수형 short 2byte 0
정수형 int 4byte 0
정수형 Long 8byte 0L
실수형 float 4byte 0.0F
실수형 double 8byte 0
문자형 char 2byte  '\u0000' 
논리형 boolean 1byte false

기본형 변수는 기본 값이 존재해 null값을 가질 수 없다.

 

2. 참조형 - Reference Type

기본형을 제외한 나머지 모든 타입은 참조 타입이다.

class, interface, String 등이 있다.

 

메모리의 힙 영역에 값을 저장한 후 주소를 참조하는 형식으로 저장된다.

기본값이 없어 null 값을 부여할 수 있다.

 

상수와 리터럴?

상수 : 한 번 저장하면 값 변경 불가능한 변수. final 키워드로 선언하며, 선언과 동시에 초기화해야한다.

리터럴 : 그 자체로 값을 의미하는 것, 12, 3.14, 'A' 등등

 

변수 선언과 초기화

변수는 생성 되는 시점과 메모리 공간 등에 따라 3가지 종류로 구분할 수 있다.

  1. 전역 변수
    • 클래스 내에서 static 키워드로 선언한다.
    • Method Area(static) 영역에 생성된다.
    • 동일한 클래스의 모든 객체들과 공유된다.
  2. 인스턴스 변수
    • 클래스의 속성으로 선언된 변수
    • 객체가 생성될때 Heap Area에 생성된다.
    • 해당 객체 인스턴스 전체에서 사용된다.
  3. 지역 변수
    • 특정 스코프 {} 내에서 생성되어 그 안에서만 사용 가능하다.
    • Stack Area 에 생성된다.
    • 메소드 함수, 반복문 등에서 사용되며 외부와 공유되지 않는다.

지역 변수는 생성 후 사용하기 위해선 반드시 초기화해줘야한다.

전역, 인스턴스 변수는 생성 시에 기본값으로 초기화된다.

 

상수(final) 변수 선언시에는 무조건 선언과 동시에 초기화 해줘야한다.

 

public class Main{
    public static void main(String[] args){
    	Hello hello = new Hello();
        hello.hi();
    }
}

class Hello{
    static int staticV;
    private int heapV;
    
    public void hi(){
        int stackV = 1;
    }
}

 

위의 코드를 실행했을 때의 메모리 결과를 T 메모리 구조로 나타낸 결과다.

  1. Class Loader가 프로그램을 실행하는데 필요한 java.lang과 같은 필수 클래스들을 로딩한다.
  2. Main.class 를 로딩한다.
  3. public static void main 함수를 찾아 실행시킨다.
  4. main 함수에서 Hello.class를 사용해야 하므로 Hello.class를 로딩하고 static 변수를 기본값으로 초기화한다..
  5. 힙 영역에 hello 객체를 생성하고 인스턴스 변수를 기본값으로 초기화한다.
  6. hello 객체의 hi 메소드를 수행하고 지역 변수를 생성한 후 초기화한다.

메소드 함수의 static 키워드

static 메서드에서는 static하지 않은 변수(인스턴스 변수) 사용 불가능하지만

그냥 메서드에서 static 변수 사용은 가능하다.

 

static 영역에 클래스가 로딩될때 static 변수는 초기화해서 만들지만

인스턴스 변수는 객체가 생성될때 Heap영역에 만들어지기 때문.

static 메소드에서 인스턴스 변수 사용은 불가능 (어떤 인스턴스의 객체를 참조해야할지 모름)

 

형변환, 캐스팅

특정 타입의 변수를 다른 타입으로 변환해서 사용할 수 있다.

float a = 123.456F;
int aa = (int) a;
System.out.println(aa); //123

() 괄호를 이용해서 변경할 타입을 앞에 붙여주기만 하면 된다.

기본형 변수는 boolean을 제외한 나머지 타입끼리 서로 형변환이 가능하다.

 

그러나 같은 자료형이여도 타입에 따라 값을 저장하는 방식이 다르기 때문에 형변환시 데이터 손실이 발생할 수 있다.

예를 들어 다음과 같이 float 타입을 int 타입으로 변환시 소수점 이하 값을 버린다.

int 타입을 float 타입으로 변환하면, 정밀도 제한으로 인해 큰 수의 경우 오차가 발생할 수 있다. (double을 최대한 사용하자)

 

자동 형변환

자바는 경우에 따라 형변환을 생략해도 자동으로 형변환을 적용해준다.

저장범위가 큰 타입에 작은 타입의 변수를 할당하면 자동으로 타입이 변환된다.

(여기서 말하는 저장범위는 메모리 크기가 아닌 수를 표현할 수 있는 범위이다.)

 

화살표 방향으로는 자동 형변환이 가능하다.

반대 방향으로 변환하기 위해선 반드시 () 괄호를 사용해 형변환을 명시해줘야만 한다.

 

또한 String 객체와 + 연산을 사용하면 모든 타입을 String 타입으로 자동으로 변환해준다.

(String + any type = String + String = String)

 

+ Recent posts