객체 지향 프로그래밍이란?

현실 세계에서 어떤 제품을 만들 때 부품을 먼저 개발하고 이 부품들을 하나씩 조립해서 완성된 제품을 만들 듯이 소프트웨어를 개발할 때도 부품에 해당하는 객체를 만들고 이 객체를 하나씩 조립해서 완성된 프로그램을 만드는 기법을 객체 지향 프로그래밍(OOP: Object Oriented Programming)이라고 합니다. 즉 객체 지향 프로그래밍은 프로그래밍에서 필요한 데이터를 추상화시켜 속성과 행위를 가진 객체를 만들고 그 객체들 간의 유기적인 상호작용을 통해 로직을 구성하는 프로그래밍 방법입니다.

객체 지향 프로그래밍의 장점

1. 유지보수성이 좋다.
    - 연관된 클래스만 코드를 변경하면 되며 절차지향에 비해 유지보수하기 편리합니다.
2. 재사용성이 좋다.
    - 만들어 둔 객체를 다른 곳에서도 쓸 수 있으며 외부에서 만든 객체를 가져다 쓰기도 편리합니다.
3. 협업이 가능하다.
    - 절차지향에 비해, 담당 파트를 정하기 편리합니다.

객체 지향 프로그래밍의 특징

추상화(Abstraction)

추상화란 복잡한 시스템으로부터 핵심적인 개념 또는 기능을 간추려내는 것을 의미합니다. 예들 들어 위 왼쪽 지도처럼 모든 정보를 표시하는 것이 아닌 오른쪽 지도처럼 필요한 정보들만 간추려서 표현하는 것을 말합니다.

캡슐화(Encapsulation)

캡슐화란 이름에서 알수 있듯이 객체의 필드, 메소드를 하나로 묶고 내용물을 감추는 것을 말합니다. 즉 외부 객체는 호출하고자 하는 객체의 구조는 알지 못하며 객체가 제공하는 필드와 메소드만 이용할 수 있습니다. 캡슐화를 하는 이유는 외부의 잘못으로 필드와 메소드가 손상을 입는 것을 방지하기 위해서 입니다.

상속(Inheritance)

객체 지향 프로그래밍은 부모 역할의 상위 객체와 자식 역할의 하위 객체가 있는데 하위 객체는 상속을 통해서 상위 객체의 필드와 메소드를 사용할수 있습니다. 상속을 사용하면 하위 객체를 쉽고 빠르게 설계할 수 있도록 도와주고 이미 잘 개발된 객체를 상속받아 사용하기 때문에 중복되는 코드를 줄여줍니다. 또한 상위 객체를 수정하면 상위객체를 상속받아 사용하는 하위객체들의 수정 효과도 가져오기 때문에 유지 보수에도 좋습니다.

다형성(Polymorphism)

다형성이란 객체 지향 프로그래밍에서 핵심이 되는 개념으로 컴포넌트를 쉽고 유연하게 변경하면서 개발할 수 있도록 도와줍니다. 즉 같은 타입이지만 실행 결과가 다양한 객체를 이용할 수 있는 성질을 말합니다. 예를 들면 자동차를 설계할 때 타이어 인터페이스 타입을 적용했다면 이 인터페이스를 구현한 실제 타이어들은 어떤 것을 사용하더라도 문제가 되지 않습니다.

 

다형성의 대표적엔 예로 오버로딩(Overloading)오버라이딩(Overriding)이 있습니다.

 

- 오버로딩(OverLoading) : 클래스 내에 같은 이름의 메소드를 여러 개 선언하는 것을 의미한다. 메소드 오버로딩의 조건은 매개 변수의 타입, 개수, 순서 중 하나가 달라야 한다.

 

- 오버라이딩(Overriding) : 오버라이딩은 상속에서 등장하는 개념으로 자식 클래스에서 상속받은 부모 클래스의 메소드를 자식 클래스에서 재정의하는 것을 의미한다.

 

오버로딩의 예

public class Calculator {
    // 정사각형의 넓이
    double areaRectangle(double width) {
    	return width * width;
    }
    
    // 직사각형의 넓이
    double areaRectangle(double width, double height) {
    	return width * height;
    }
}

위 코드에서 같은 이름을 가진 메소드를 선언하기 위해 매개변수의 수를 다르게 하여 선언 했습니다. 참고로 리턴 값은 같을 수 있습니다.

 

오버라이딩의 예

// 부모 클래스
public class Calculator {
    public double areaCircle(double r) {
    	System.out.println("Calculator 객체의 areaCircle() 실행");
        return Math.PI * r * r;
    }
}
// 자식 클래스
public class Computer extends Calculator {
    @Override
    public double areaCircle(double r) {
    	System.out.println("Computer 객체의 areaCircle() 실행");
        return Math.PI * r * r;
    }
}

위 코드에서는 부모 클래스인 Calculator 클래스를 자식 클래스인 Computer 클래스가 상속받아 areaCircle 메소드를 재정의하고 있습니다.

 

오버라이딩에는 다음과 같은 규칙이 있습니다.

  • 부모의 메소드와 동일한 시그니처(리턴 타입, 메소드 이름, 매개 변수 리스트)를 가져야 한다.
  • 접근 제한을 더 강하게 오버라이딩 할수 없다. ex) 부모(public) -> 자식(default나 private)로 수정 불가
  • 새로운 예외처리를 할 수 없다.

예시에서도 리턴 타임과 메소드 이름, 매개 변수 리스트를 동일하게 설정해 주었고 접근 제한자를 public으로 동일하게 설정해 주었습니다.

 

참고로 자식 클래스의 @Override 메소드는 생략해도 되지만 Override 어노테이션을 선언해주면 컴파일 시점에서 체크를 해주기 때문에 개발자의 실수를 줄여줍니다. 예를 들어 @Override 선언해주었는데 메소드 이름이 틀리면 컴파일 에러가 발생합니다.

설계원칙

객체지향 프로그래밍을 설계할 때는 SOLID 원칙을 지켜주어야 합니다. SOLID 원칙은 각각 단일 책임 원칙(S), 개방-폐쇄 원칙(O), 리스코프 치환 원칙(L), 인터페이스 분리 원칙(I), 의존 역전 원칙(D)을 의미하며 각각의 특징은 아래와 같습니다.

단일 책임 원칙(SRP, Single Responsibility Principle)

  • 모든 클래스는 각각 하나의 책임만 가져야 한다.
  • 예를 들어 A라는 로직이 존재할때 특정 클래스는 A와 관련된 클래스여야 하고 이를 수정한다고 해도 A와 관련된 수정이어야 한다.

개방-폐쇄 원칙(OCP, Open Closed Principle)

  • 유지 보수 시 코드를 쉽게 확장할 수 있도록 하고 수정할 는 닫혀 있어야 한다.
  • 즉, 기존의 코드는 변경하지 않으면서도 확장은 쉽게 할 수 있어야 한다.

리스코프 치환 원칙(LSP, Liskov Substitution Principle)

  • 프로그램 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 즉, 상속 관계에 있는 부모, 자식 객체가 있을때 부모 객체에 자식 객체를 넣어도 시스템이 문제없이 돌아가게 만드는 것을 의미한다.

인터페이스 분리 원칙(ISP, Interface Segregation Principle)

  • 하나의 일반적인 인터페이스보다 구체적인 여러 개의 인터페이스를 만들어야 하는 원칙을 의미한다.

의존 역전 원칙(DIP, Dependency Inversion Principle)

  • 추상적인 것은 자신보다 구체적인 것에 의존하지 않고, 변화하기 쉬운 것에 의존해서는 안된다. 즉, 구현 클래스에 의존하지 말고, 인터페이스에 의존해야 하는 원칙을 의미한다.
  • 예를 들어 타이어를 갈아끼울 수 있는 틀을 만들어 놓은 후 다양한 타이어를 교체할 수 있어야 한다.
  • 상위 계층은 하위 계층의 변화에 대한 구현으로부터 독립되어야 한다.

객체 지향 프로그래밍의 특징과 원칙들을 지키며 코드를 작성하면, 시스템의 유지보수성과 확장성이 높아져 프로젝트가 성장하면서 발생하는 다양한 요구 변화에 유연하게 대응할 수 있습니다.

자바 가상 머신(JVM) 이란?

JVM(Java Virtual Machine)은 자바 프로그램을 다양한 운영체제에서 실행할 수 있게 해주는 가상 머신입니다. 자바 프로그램은 기계어 대신 바이트코드라는 중간단계 코드로 변환되는데, 운영체제가 이를 직접 실행할 수 없기 때문에 바이트코드를 해석하고 실행하는 역할을 하는 것이 바로 JVM입니다. 또한 JVM은 Thread 관리 및 Garbage Collection과 같은 메모리 작업도 수행합니다.

 

JVM은 특정 운영체제와 하드웨어 환경에 맞춘 기계어로 바이트코드를 변환하므로 운영체제에 종속적입니다. 따라서 자바 개발 키트(JDK)나 자바 런타임 환경(JRE)을 설치할 때 운영체제에 맞는 JVM이 함께 제공됩니다.

 

JVM 구조

 

JVM은 다음과 같은 구성 요소로 이루어져 있으며, 각 구성 요소는 JVM이 자바 프로그램을 효율적으로 실행하는 데 중요한 역할을 수행합니다.

1. 클래스 로더 (Class Loader)

클래스 로더는 자바 프로그램을 실행하기 위해 바이트코드를 메모리에 로딩하는 역할을 합니다. 클래스 파일을 찾아서 JVM에 로드하고, 필요한 의존성 클래스를 불러와 프로그램이 정상적으로 실행되도록 돕습니다.

 

2. 런타임 데이터 영역 (Runtime Data Area)

Runtime Data Area는 JVM이 OS으로부터 할당 받은 메모리 공간입니다. JVM은 힙, 스택, 메소드 영역으로 메모리를 분리하여 효율적으로 관리하며 이렇게 분리된 메모리 구조 덕분에 프로그램 실행 중 필요한 데이터나 객체를 효과적으로 저장하고 접근할 수 있습니다.

 

메소드(Method) 영역

메소드 영역은 코드에서 사용되는 클래스들을 클래스 로더로 읽어 클래스, 변수, Method, static 변수, 상수 정보 등으로 분류되어 저장되며 JVM이 시작할 때 생성되고 모든 스레드가 공유하는 영역입니다.

 

힙(Heap) 영역

힙 영역은 객체와 배열이 생성되는 영역으로 힙 영역에 생성된 객체와 배열은 JVM의 스택 영역의 변수나 다른 객체의 필드에서 참조합니다. 만약 참조하는 변수나 필드가 없다면 의미 없는 객체가 되기 때문에 GC(Garbage Collector)에 의해 제거됩니다. 모든 스레드가 공유하는 영역입니다.

 

스택(Stack) 영역

스택 영역은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 할당됩니다. 기본 타입(boolean, char, byte, short, int, long, float, double)의 값과 변수를 저장하고, 참조 타입의 변수와 힙 영역이나 메소드 영역의 객체 주소를 저장합니다.

 

참고로 기본 타입 변수는 변수와 값을 전부 스택영역에서 갖고 있지만 참조 타입 변수의 경우에는 변수(name, hobby) 스택 영역에서, ("key", "독서") 영역에서 갖고 스택 영역의 변수가 영역의 값의 메모리 주소를 갖고 참조합니다.

// 기본 타입 변수
int age = 25;
double price = 100.5;

// 참조 타입 변수
String name = "key";
String hobby = "독서";

 

PC register

PC register는 각 Thread가 시작될 때 생성되며 Thread마다 하나씩 존재합니다. 현재 실행중인 부분의 상태 정보와 주소를 저장하는 영역입니다. 

 

Native Method Stack

Native Method Stack은 Java가 아닌 다른 언어로 작성된 코드를 위한 영역으로 바이트코드가 아닌 실제 실행할 수 있는 기계어로 작성된 프로그램을 실행시키는 영역입니다. 마찬가지로 Thread 별로 생성된다는 특징이 있습니다.

 

3. 실행 엔진(Execution Engine)

실행 엔진은 실제로 바이트코드를 해석하고 실행하는 엔진입니다. 인터프리터와 JIT 컴파일러로 이루어져 있으며, 때문에 자바는 컴파일 언어이면서도 인터프리터 언어의 특성을 함께 가지고 있습니다.

  • 인터프리터: 바이트코드를 한 줄씩 해석하고 실행합니다. 속도가 느리다는 단점이 있습니다.
  • JIT 컴파일러: 인터프리터의 단점을 보완하기 위해 자주 호출되는 메서드를 기계어로 미리 변환하여 실행 속도를 높입니다.

 

4. 가비지 컬렉터 (Garbage Collector)

가비지 컬렉터, GC는 사용하지 않는 메모리를 자동으로 회수하여 메모리 누수(Memory Leak)를 방지합니다. 개발자가 직접 메모리 관리를 해줘야하는 C 언어와 달리 자바에서는 가비지 컬렉터가 주기적으로 힙 영역에서 불필요한 객체를 제거합니다. 이 기능 덕분에 개발자는 메모리 관리에 집중하지 않고 애플리케이션 로직에 집중할 수 있습니다.

 

JVM 동작 순서

1. 어플리케이션이 실행되면 JVM이 OS로부터 메모리를 할당 받습니다.

2. JAVA 컴파일러(javac.exe)가 자바 소스 파일(.java)을 바이트 코드(.class)로 변환합니다.

3. 변환된 바이트 코드가 클래스 로더를 통해 JVM으로 로드됩니다.

4. 로드된 바이트코드는 Execution Engine(실행엔진)에 의해 해석됩니다.

5. 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 실행됩니다.

상속이란?

현실에서 상속이란 부모가 자식에게 물려주는 행위를 말한다. 객체 지향 프로그래밍에서도 상속이 존재하는데 자식 클래스가 부모클래스를 상속받음으로써 부모클래스가 갖고 있는 필드나 메서드를 사용할 수 있다. 이러한 특성 덕분에 상속을 사용하면 코드의 중복을 줄여 개발 시간을 단축시키므로 매우 효율적이다.

 

부모클래스를 상속받는다고 해서 모든 필드와 메서드를 사용할 수 있는 것은 아니다. 부모클래스에서 접근제어자가 private인 필드나 메서드는 상속 대상에서 제외되며 부모클래스와 자식클래스가 다른 패키지에 존재하는 경우 default 접근제어자도 상속대상에서 제외된다.

클래스 상속

상속을 사용하는 방법은 아래와 같이 자식클래스의 extends 뒤에 부모클래스를 적으면 된다. 자바에서는 다중 상속을 허용하지 않는다. 그렇기 때문에 extends 뒤에 여러 부모클래스를 선언할 수 없다.

 

class 자식클래스 extends 부모클래스 {
    // 필드
    // 생성자
    // 메소드
}

부모클래스 필드와 메서드 사용

public class 부모클래스 {
    // 필드
    String parents
    
    // 생성자
    
    // 메서드
    void parentsMethod() {
    	...
    };
}

 

위와 같은 부모클래스를 상속받아 사용한다고 했을 때 부모클래스의 필드와 메서드를 사용하려면 아래와 같이 자식 객체를 생성한 후 필드나 메서드를 호출하면 된다.

 

public static void main(String[] args) {
    자식클래스 child = new 자식클래스();
    
    System.out.println(child.parents);
    child.parentsMethod();
}

부모 생성자 호출

부모클래스를 호출하기 위해서는 super()를 사용하면 된다. 자바에서는 자식 객체를 생성하기 위해서는 항상 부모 객체를 먼저 생성해야 한다. 만약 자식클래스의 기본 생성자가 선언되어 있지 않다면 컴파일 과정에서 컴파일러가 자식클래스에 아래와 같은 기본 생성자를 선언한다.

public 자식클래스() {
    super();
}

 

아래와 같은 부모클래스와 자식클래스가 있다고 가정해보자.

public class 부모클래스 {
    String parents1;
    String parents2;
    
    public 부모클래스(String parents1, String parents2) {
    	this.parents1 = parents1;
        this.parents2 = parents2;
    }
}
public class 자식클래스 {
    String child;
    
    public 자식클래스(String parents1, String parents2, String child) {
    	super(parents1, parents2);
        this.child = child;
    }
}

만약 자식클래스 생성자 부분에 super(parents1, parents2)가 없다면 컴파일러는 컴파일 단계여서 부모클래스의 기본생성자를 생성하는 super()를 추가한다. 하지만 부모클래스에는 기본생성자가 없기 때문에 컴파일 단계에서 에러가 발생한다. 부모클래스에 기본생성자가 없고 매개변수가 있는 생성자만 있다면 자식클래스 생성자에서 매개변수가 있는 부모클래스 생성자를 호출해주어야 한다.

부모 메서드 재정의

상속 받은 부모클래스의 메서드를 재정의 할 수 있다. 이를 오버라이드(Override)라고 한다.

// 부모클래스
public class Calculator {
    double areaCircle(double r) {
    	System.out.println("Calculator 객체의 areaCircle() 실행");
        return 3.141592 * r * r;
    }
}
// 자식클래스
public class Computer extends Calculator {
    
    @Override
    double areaCircle(double r) {
    	System.out.println("Computer 객체의 areaCircle() 실행");
        return Math.PI * r * r;
    }
}

자식클래스은 Computer 클래스가 Calculator 클래스를 상속받아 areaCircle() 메서드를 재정의(Override)했다. 이제 Computer 클래스 객체를 생성한 후 areaCircle() 메서드를 호출하면 자식 클래스에서 재정의한 메서드가 실행된다.

 

참고로 @Override 어노테이션은 재정의하려는 부모클래스의 메서드와 자식클래스의 메서드의 메서드명, 리턴 타입, 매개 변수 등을 컴파일 단계에서 체크하여 개발자의 실수를 줄여준다. 다를 경우 컴파일 에러가 발생한다.

알고리즘 문제 풀면서 그래프를 구현할 때 Input 값에 따라 인접행렬과 인접리스트 중 어떤 걸 사용할지 정했었는데 시간복잡도와 메모리 측면에서 정확하게 어떤 상황에서 어떤 방법을 사용해야 하는지 알아보기 위해 구현방법과 개념을 정리하려고 합니다.

인접행렬

인접 행렬(Adjacency Matrix)방식은 2차원 배열에 각 노드가 연결된 형태를 기록하는 방식으로 자바에서는 아래와 같이 구현하여 사용할 수 있다.

/*
5 7
1 2
1 3
1 4
2 3
2 5
3 4
4 5
*/
public class 인접행렬 {

    public static StringTokenizer st;
    
    public static int[][] graph;
    
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        st = new StringTokenizer(br.readLine());
        
        int V = Integer.parseInt(st.nextToken());	// 노드의 수
        int E = Integer.parseInt(st.nextToken());	// 간선의 수
        
        graph = new int[V + 1][V + 1];

        for (int i = 0; i < E; i++) {
            st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());	// 시작 노드
            int e = Integer.parseInt(st.nextToken());	// 끝 노드
            graph[s][e] = 1;
        }

        for (int[] ints : graph) {
            System.out.println(Arrays.toString(ints));
        }
    }
}

 

인접행렬 방식은 연결되어 있지 않은 노드들간의 관계도 0으로 표시하기 때문에 노드 개수가 많을 수록 메모리가 불필요하게 낭비된다. 하지만 특정한 두 노드가 연결되어 있는지에 대한 정보를 얻는 속도가 빠르다는 장점이 있다. 예를 들어 1번 노드와 5번노드와의 관계를 확인하려면 graph[1][5]만 확인하면 된다.

인접리스트

인접 리스트(Adjacency List)방식은 리스트에 각 노드가 연결된 형태를 기록하는 방식으로 자바에서는 아래와 같이 이차원 리스트로 구현할 수 있다.

/*
5 7
1 2
1 3
1 4
2 3
2 5
3 4
4 5
*/
public class 인접리스트 {

    public static StringTokenizer st;
    
    public static List<List<Integer>> graph = new ArrayList<>();

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        st = new StringTokenizer(br.readLine());

        int V = Integer.parseInt(st.nextToken());    // 노드의 수
        int E = Integer.parseInt(st.nextToken());    // 간선의 수

        // 노드의 수만큼 리스트 추가
        for (int i = 0; i <= V; i++) {
            graph.add(new ArrayList<>());
        }

        for (int i = 0; i < E; i++) {
            st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());   // 시작 노드
            int e = Integer.parseInt(st.nextToken());   // 끝 노드
            graph.get(s).add(e);
        }

        System.out.println(graph);
    }
}

 

인접리스트 방식은 서로 연결된 노드들의 정보만 리스트에 저장하기 때문에 메모리를 효율적으로 사용한다. 하지만 1번 노드에 연결된 노드를 확인하기 위해서는 graph.get(1) 리스트를 앞에서부터 차례대로 확인해야 하기 때문에 특정한 두 노드의 연결상태 정보를 얻는 속도가 느리다는 단점이 있다.

정리

  인접 행렬 인접 리스트
시간복잡도 O(N^2)     정점 N * N만큼 필요 O(N)      N : 간선의 개수
두 정점의 연결 여부 graph[x][y] 의 값으로 한번에 확인 graph<x> 의 원소에서 y가 나올때까지 탐색
인접 노드 파악 여부 N * N만큼 반복문을 돌아 확인 각 리스트에 담겨있는 원소를 확인

특정한 노드들 간의 연결 여부만 확인하면 된다면 인접행렬을 사용하는게 좋다.

특정한 노드와 연결된 모든 인접 노드를 순회해야 하는 경우에는 인접리스트를 사용하는 것이 좋다.

개발하거나 알고리즘 문제 풀면서 객체를 생성하지 않고 변수나 메소드를 바로 사용하고 싶을때 static 키워드를 붙여서 사용하곤 했는데 정확한 이해없이 사용하고 있는 것 같아서 책보면서 정리해보려고 합니다.

 

static은 정적인, 고정된 이라는 뜻을 가지고 있다. 그렇기 때문에 static 변수나 메소드를 정적 변수, 정적 메소드라고도 부른다. 또한 정적 변수와 정적 메소드를 포함하는 정적 멤버의 경우 객체에 소속된 멤버가 아니라 클래스에 소속된 멤버이기 때문에 클래스 멤버라고 부른다.

정적 멤버 선언

정적 멤버 선언은 아래와 같이 static 키워드를 붙여서 선언할 수 있다.

public class 클래스명 {
    // 정적 변수
    public static 타입 변수명 [= 초기값];
    
    // 정적 메소드
    public static 리턴타입 메소드명( 매개변수 ) { ... }
}

정적 변수와 정적 메모리는 클래스에 고정된 멤버이므로 JVM의 클래스로더를 통해 메소드 영역에 적재될때 클래스별로 관리되어 객체를 생성하지 않고 클래스 로딩이 끝나면 바로 사용할 수 있다.

static 키워드 사용 상황

정적 변수를 선언해야 하는 상황은 객체마다 가지고 있어야 할 고유한 데이터의 경우 인스턴스 변수로 선언하고 공용적인 데이터라면 정적 변수로 선언하는 것이 좋다. 예를 들어 아래 Calculator 클래스를 보면 3.14159로 값이 고정되어있는 파이의 경우 공용적인 데이터이기 때문에 정적 변수로 선언해주었고 color의 경우 클래스마다 값이 다를 수 있기 때문에 인스턴스 변수로 선언해 주었다.

public class Calculator {
    public String color;
    public static double pi = 3.14159;
}

 

정적 메소드의 경우에는 만약 인스턴스 변수를 사용한다면 인스턴스 메소드로 선언하고 그렇지 않은 경우에는 정적 메소드로 선언하는 것이 좋다. 예를 들어 Calculator의 인스턴스 변수인 color 값을 초기화 하기 위해 setColor() 메소드를 선언해야 한다면 아래와 같이 인스턴스 메소드로 선언하고 plus 메소드의 경우에는 인스턴수 변수가 아닌 외부 매개변수 값을 사용하기 때문에 정적 메소드로 선언해 주었다.

public class Calculator {
    public String color;
    public static double pi = 3.14159;
    
    public void setColor(String color) {
    	this.color = color;
    }
    
    static int plus(int x, int y) {
        return x + y;
    }
}

정적 초기화 블록

정적 변수의 경우 아래와 같이 변수 선언과 동시에 초기값을 주는 것이 보통이지만 계산이 필요한 초기화 작업이 있을 수 있다.

public static double pi = 3.14159;

인스턴스 변수의 경우에는 생성자에서 초기화 작업을 수행할 수 있지만 정적 변수의 경우에는 객체 생성없이 정적 변수를 사용해야 하기 때문에 객체 생성시에 실행되는 생성자에서 초기화 작업을 수행할 수 없다. 대신 자바는 정적 필드의 초기화 작업을 수행할 수 있는 정적 블록이라는 기능을 제공해 준다.

 

정적 블록은 아래와 같은 형태로 사용되며 클래스가 메모리로 로딩될 떄 자동적으로 실행된다.

static { ... }

 

정적 블록은 여러 개가 선언될 수 있으며 선언된 순서대로 실행된다.

정적 블록 사용 예시

public class Television {
    public static String company = "SAMSUNG";
    public static String model = "LCD";
    public static String info;
    
    static {
    	info = company + "-" + model;
    }
}

static 키워드 사용시 주의할 점

정적 메소와 정적 블록을 선언할 때 인스턴스 필드나 인스턴스 메소드를 사용할 수 없다. 또한 객체 자신을 참조하는 this 키워드도 사용할 수 없다. 만약 사용하려고 하면 컴파일 에러가 발생한다. 또한 static 키워드가 선언된 정적 멤버가 JVM에 로드될때 static 영역에 할당되는데 이 static 영역은 Garbage Collector 관리 영역 밖에 있어 GC에 의해 메모리가 관리되지 않는다. 그렇기 때문에 너무 남발하게 되면 성능에 악영향을 줄 수 있다.

IO 패키지란?

프로그램에서는 데이터를 외부에서 읽고 다시 외부로 출력하는 작업이 빈번히 일어나는데 자바의 기본적인 데이터 입출력 API는 java.io 패키지에서 제공하고 있다. 이때 프로그램이 출발지냐 또는 도착지냐에 따라서 입출력 스트림의 종류가 결정되는데 프로그램이 데이터를 입력받을 때는 입력 스트림이라고 부르고 프로그램이 데이터를 보낼 때에는 출력 스트림이라고 부른다.

 

java.io 패키지에서 제공하는 스트림의 종류는 크게 두 가지로 구분된다. 하나는 바이트 기반 스트림이고 다른하나는 문자 기반 스트림이다. 바이트 기반 스트림은 그림, 멀티미디어, 문자 등 모든 종류의 데이터를 주고 받을 수 있으나 문자 기반 스트림은 오로지 문자만 주고 받을 수 있다.

 

바이트 기반 스트림과 문자 기반 스트림은 최상위 클래스에 따라서 다음과 같이 구분된다.

구분 바이트 기반 스트림 문자 기반 스트림
입력 스트림 출력 스트림 입력 스트림 출력 스트림
최상위 클래스 InputStream OutputStream Reader Writer
하위 클래스 XXXInputStream
(Ex : FileInputStream)
XXXOutputStream
(Ex : FileOutputStream)
XXXReader
(Ex : FileReader)
XXXWriter
(EX : FileWriter)

파일 입출력

파일 생성하고 목록 출력하기

package chap01;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;

public class FileExample {
	
	public static void main(String[] args) throws Exception {
		// 경로 상에 있는 파일의 객체 생성
		File dir = new File("/파일경로/Dir");
		File file1 = new File("/파일경로/Dir/file1.txt");
		File file2 = new File("/파일경로/Dir/file2.txt");
		File file3 = new File("/파일경로/Dir/file3.txt");
		
		// 경로상의 파일이 존재하지 않을 시 파일 생성
		if (dir.exists() == false) {
			dir.mkdir();
		}
		if (file1.exists() == false) {
			file1.createNewFile();
		}
		if (file2.exists() == false) {
			file2.createNewFile();
		}
		if (file3.exists() == false) {
			file3.createNewFile();
		}
		
		// 아래 File 객체 상에 존재하는 모든 파일 출력
		File temp = new File("/파일경로/Dir");
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd    a    HH:mm");
		File[] contents = temp.listFiles();
		
		System.out.println("날짜            시간            형태            크기            이름");
		System.out.println("--------------------------------------------------------------");
		for (File file : contents) {
			System.out.print(sdf.format(new Date(file.lastModified())));
			if (file.isDirectory()) {
				System.out.print("\t<Dir>\t\t\t" + file.getName());
			} else {
				System.out.print("\t\t\t" + file.length() + "\t" + file.getName());
			}
			System.out.println();
		}
	}
	
}

위 코드를 실행하면 아래와 같이 파일이 생성되고 파일의 정보가 출력된다.

FileInputStream

FileInputStream 클래스는 파일로부터 바이트 단위로 읽어들일 때 사용하는 바이트 기반 입력 스트림이다. 바이트 단위로 읽기 때문에 그림, 오디오, 비디오, 텍스트 파일 등 모든 종류의 파일을 읽을 수 있다.

아래는 FileInputStreamExample.java 소스 파일을 읽고 출력하는 예시이다.

public class FileInputStreamExample {

	public static void main(String[] args) {
    
		// 경로 상에 파일이 존재하지 않을때 FileNotFoundException을 발생시키므로 try-catch 문으로 예외 처리 해줌
		try {
			File file = new File(
				"/파일경로/FileInputStreamExample.java"
			);
			FileInputStream fis = new FileInputStream(file);
			int data;
            
			// 1byte씩 읽고 콘솔에 출력
			while ((data = fis.read()) != -1) {
				System.out.write(data);
			}
            
			// 파일의 내용을 모두 읽은 후에는 close() 메소드로 파일을 닫아줌
			fis.close();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
}

위 코드를 실행 시 아래와 같이 콘솔에 코드가 출력된다.

FileOutputStream

FileOutputStream은 바이트 단위로 데이터를 파일에 저장할 때 사용하는 바이트 기반 출력 스트림이다. FileInputStream과 마찬가지로 바이트 단위로 저장하기 때문에 그림, 오디오, 비디오, 텍스트 등 모든 종류의 데이터를 파일로 저장할 수 있다.

아래는 originalFileName String 객체의 경로에 존재하는 파일을 targetFileName 객체의 경로에 복사하는 예시이다.

이미 Dir 폴더에 해당 파일이 존재하는 경우 내용을 덮어 쓰기 때문에 FileOutputStream 을 생성할때 두번째 인자로 true를 주면 뒤에 내용이 추가된다.

public class FileOutputStreamExample {

	public static void main(String[] args) throws Exception {
		String originalFileName = "/파일경로/favicon2.png";
		String targetFileName = "/파일경로/Dir/favicon2.png";
		
		FileInputStream fis = new FileInputStream(originalFileName);
		FileOutputStream fos = new FileOutputStream(targetFileName);
		
		int readByteNo;
		byte[] readBytes = new byte[100];
		
		while ((readByteNo = fis.read(readBytes)) != -1) {
			fos.write(readBytes, 0, readByteNo);
		}
		
		fos.flush();
		fos.close();
		fis.close();
		
		System.out.println("복사가 잘 되었습니다.");
	}
	
}

위 코드를 실행하면 아래와 같이 Dir 폴더에 favicon2.png 파일이 복사된다.

FileReader

FileReader 클래스는 FileInputStream과 다르게 텍스트 파일을 프로그램으로 읽어들일 때 사용하는 문자 기반 스트림이다. 문자 단위로 읽기 때문에 텍스트가 아닌 그림, 오디오, 비디오 등의 파일은 읽을 수 없다.

아래는 FileReader를 이용해 FileReaderExample.java 소스 파일을 읽고 출력하는 예시이다.

public class FileReaderExample {

	public static void main(String[] args) {
		try {
			File file = new File(
					"/Users/kimkiyun/Desktop/thisisjava/chap01/src/chap01/FileReaderExample.java"
			);
			FileReader fr = new FileReader(file);
			
			int readCharNo;
			char[] cbuf = new char[100];
			while ((readCharNo = fr.read(cbuf)) != -1) {
				String data = new String(cbuf, 0, readCharNo);
				System.out.print(data);
			}
			fr.close();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
}

위 코드를 실행 시 아래와 같이 콘솔에 코드가 출력된다.

FileWriter

FileWriter는 FileOutputStream과 다르게 텍스트 데이터를 파일에 저장할 때 사용하는 문자 기반 스트림이다. 문자 단위로 저장하기 때문에 텍스트가 아닌 그림, 오디오, 비디오 등의 데이터를 파일로 저장할 수 없다.

아래는 FileWriter를 이용해 문자열 데이터를 맨 처음 생성했던 file1.txt 파일에 저장하는 예시이다.

public class FileWriterExample {

	public static void main(String[] args) throws Exception {
		File file = new File("/Users/kimkiyun/Desktop/Dir/file1.txt");
		FileWriter fw = new FileWriter(file, true);
		fw.write("FileWriter는 한글로된 " + "\r\n");
		fw.write("문자열을 바로 출력할 수 있다." + "\r\n");
		fw.flush();
		fw.close();
		System.out.println("파일에 저장되었습니다.");
	}
	
}

위 코드를 실행 시 아래와 같이 비어있던 file1.txt 파일에 문자열 데이터가 저장된다.

 

운영체제

운영체제란 하드웨어와 소프트웨어를 관리하는 일꾼으로 아래와 같은 역할을 한다.

  1. CPU 스케줄링과 프로세스 관리: CPU 소유권을 어떤 프로세스에 할당할지, 프로세스의 생성과 삭제, 자원 할당 및 반환을 관리
  2. 메모리 관리 : 한정된 메모리를 어떤 프로세스에 얼마큼 할당해야 하는지 관리
  3. 디스크 파일 관리 : 디스크 파일을 어떠한 방법으로 보관할지 관리
  4. I/O 디바이스 관리 : 마우스, 키보드 같은 I/O 디바이스들과 컴퓨터 간에 데이터를 주고받는 것을 관리

 

멀티 프로세스란?

사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데 운영체제에서는 실행 중인 하나의 애플리케이션을 프로세스라고 부른다. 멀티 프로세스는 애플리케이션 단위의 멀티 태스킹을 의미한다.

 

멀티 프로세스들은 운영체제로부터 메모리를 할당 받아 서로 독립적으로 실행되기 때문에 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않는다. 또한 멀티 프로세스는 프로세스끼리 데이터를 주고받고 공유 데이터를 관리하는 IPC(Inter Process Communication)가 가능하다.

 

멀티 스레드란?

스레드는 운영체제로부터 메모리를 할당받아 실행되는 프로세스와 달리 프로세스로부터 자원을 할당받아 실행되는 작업의 흐름을 지칭한다. 멀티 프로세스가 애플리케이션 단위의 멀티 태스킹이라면 멀티 스레드는 애플리케이션 내부에서의 멀티 태스킹을 의미한다.

 

멀티 스레드는 하나의 프로세스 내부에서 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에게 영향을 미칠 수 있다. 마지막으로 멀티 스레드는 스택 영역을 제외한 모든 영역을 한 프로세스 내에서 공유하기 때문에 IPC를 이용해 데이터를 공유하는 멀티 프로세스보다 빠르다.

 

멀티 프로세스와 멀티 스레드의 장단점

멀티 프로세스

장점

  • 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않는다.

단점

  • 독립된 메모리 영역이기 때문에 작업량이 많을수록(Context Switching이 자주 일어나서 주소 공간의 공유가 잦을 경우) 오버헤드가 발생하여 성능저하가 발생 할 수 있다.
  • Context Switching 과정에서 캐시 메모리 초기화 등 무거운 작업이 진행되고 시간이 소모되는 등 오버헤드가 발생한다.

Context Switching이란?

  • 한 프로세스에 할당된 시간이 끝나거나 인터럽트에 의해 발생하며 프로세스에 대한 메타데이터를 저장한 데이터인 PCB를 교환하는 과정을 말한다.
  • 동작 중인 프로세스가 대기를 하면서 해당 프로세스의 상태(Context)를 PCB(Process Control Block)에 보관하고, 대기하고 있던 다음 순서의 프로세스가 동작하면서 이전에 보관했던 프로세스의 상태를 복구하는 작업을 말한다.
  • CPU 시점에 개의 프로그램만 실행하는데 컴퓨터가 많은 프로그램을 동시에 실행하는 것처럼 보이는 이유는 다른 프로세스와의 Context Switching 매우 빠른 속도로 실행되기 때문이다.

멀티 스레드

장점

  • 멀티 프로세스에 비해 메모리 자원소모가 줄어든다.
  • 힙 영역을 통해서 스레드간 통신이 가능하기 때문에 프로세스간 통신보다 간단하다.
  • 스레드의 컨텍스트 스위칭은 프로세스의 컨텍스트 스위칭보다 비용이 더 적고 빠르다.

단점

  • 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에게 영향을 미칠 수 있다.
  • 데이터, 코드, 힙 영역을 공유하기 때문에 동기화 문제가 발생할 수 있다.
  • 동기화를 해결하기 위해 락을 과도하게 사용하면 성능이 저하될 수 있다.

 

작업 스레드 생성과 실행

멀티 스레드로 실행하는 애플리케이션을 개발할때 몇 개의 작업을 병렬로 실행할지 결정하고 메인 스레드를 제외한 추가적인 병렬 작업의 수만큼 스레드를 생성해주면 된다.

스레드 생성 방법

1. java.lang.Thread 클래스를 직접 객체화 해서 생성하는 방법

직접 생성하려면 아래와 같이 Runnable을 매개값으로 갖는 생성자를 호출해야 한다. Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어서 대입하여야 한다.

Thread thread = new Thread(Runnable target);

멀티 스레드 사용 예시로 비프음을 발생시키면서 프린트까지 하는 작업을 코딩해보겠다.

import java.awt.Toolkit;

public class BeepTask implements Runnable {
	@Override
	public void run() {
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for (int i = 0; i < 5; i++) {
			toolkit.beep();
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
}
public class BeepPrintExample1 {

	public static void main(String[] args) {
		Runnable beepTask = new BeepTask();
		Thread thread = new Thread(beepTask);
		thread.start();
		
		for (int i = 0; i < 5; i++) {
			System.out.println("띵");
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
	
}

다음과 같이 코딩한 다음 실행해 보면 비프음과 프린팅이 동시에 발생하는 것을 확인할 수 있다.

 

비교를 위한 코드

import java.awt.Toolkit;

public class BeepPrintExample2 {
	public static void main(String[] args) {
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for (int i = 0; i < 5; i++) {
			toolkit.beep();
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
		
		for (int i = 0; i < 5; i++) {
			System.out.println("띵");
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
}

위와 같이 Runnable 인터페이스를 구현한 객체를 생성하지 않고 스레드 생성없이 실행하면 비프음이 5번 들리고 나서 프린팅이 시작된다. 즉 비프음을 발생시키는 작업과 "띵"을 프린트 하는 작업이 동시에 처리가 안됨

 

2. Thread를 상속하는 하위 클래스를 만들어서 생성하는 방법

import java.awt.Toolkit;

public class BeepThread extends Thread {

	@Override
	public void run() {
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for (int i = 0; i < 5; i++) {
			toolkit.beep();
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
	
}
public class BeepPrintExample3 {
	
	public static void main(String[] args) {
		Thread thread = new BeepThread();
		thread.start();
		
		for (int i = 0; i < 5; i++) {
			System.out.println("띵");
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}

}

이런식으로 Runnable 인터페이스를 상속받은 구현 클래스가 아닌 Thread 클래스를 상속 받는 하위 클래스를 생성하여 메인 스레드에서 바로 Thread 생성 후 실행해도 같은 결과가 나온다.

'Java' 카테고리의 다른 글

[Java] 이것이 자바다 - 상속  (1) 2023.07.04
[Java] 인접행렬, 인접리스트  (0) 2023.02.28
[Java] 정적 멤버와 static  (0) 2023.02.23
[Java] 이것이 자바다 - IO 패키지  (2) 2022.06.20
[Java] CSV 파일 데이터 불러오기  (0) 2022.05.26

백엔드 개발에서 초기 데이터를 설정할 , 하드코딩으로 값을 입력하는 경우가 많습니다. 하지만 데이터가 많아질수록 코드가 복잡해지고 관리가 어려워지죠. 이번 포스팅에서는 하드코딩 대신 CSV 파일 활용하여 데이터를 초기화하는 방법과, 과정에서 발생한 문제를 어떻게 해결했는지 소개합니다.

 

1. 하드코딩 방식의 문제점

처음에는 아래와 같이 데이터를 직접 코드에 하드코딩하여 초기화했습니다.

@Component
@Order(1)
public class TestDataRunner implements ApplicationRunner {

    @Autowired
    ProductRepository productRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        Product product1 = createProduct((long) 768848, "[STANLEY] GO CERAMIVAC 진공 텀블러/보틀 3종", 21000, 45);
        Product product2 = createProduct((long) 748943, "디오디너리 데일리 세트 (Daily set)", 19000, 89);
        ...
        
        productRepository.save(product1);
        productRepository.save(product2);
        ...
    }

    public Product createProduct(Long productNumber, String productName, int productPrice, int productStock) {
        Product product = Product.builder()
                .productNumber(productNumber)
                .productName(productName)
                .productPrice(productPrice)
                .productStock(productStock)
                .build();
        return product;
    }
}

하드 코딩 방식에는 아래와 같은 문제점이 있었습니다.

  • 데이터 수정의 어려움: 데이터를 변경할 때마다 코드를 수정하고 재배포해야 했습니다.
  • 가독성 저하: 데이터가 많아질수록 코드가 복잡해지고 유지보수가 어려워졌습니다.

2. CSV 파일을 활용한 데이터 입력

문제를 해결하기 위해 데이터를 CSV 파일로 관리하고, 애플리케이션 실행 해당 파일을 읽어 데이터베이스에 저장하도록 변경했습니다.

CSV 파일

 

@Component
@Order(1)
public class TestDataRunner implements ApplicationRunner {

    @Autowired
    ProductRepository productRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        File file = new File("/Users/kimkiyun/Downloads/items_.csv");
        BufferedReader br = null;
        String line = "";

        try {
            br = new BufferedReader(new FileReader(file));
            while ((line = br.readLine()) != null) {
                List<String> aLine = new ArrayList<>();
                String[] lineArr = line.split(",");
				
                Product product = Product.builder()
                    .productNumber((long) Integer.parseInt(lineArr[0]))
                    .productName(lineArr[1])
                    .productPrice(Integer.parseInt(lineArr[2]))
                    .productStock(Integer.parseInt(lineArr[3]))
                    .build();
	            productRepository.save(product);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (br == null) {
                    br.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

 

3. 문제 해결 과정

CSV 데이터를 읽어오면서 두 가지 문제가 발생했습니다.

문제 1. 첫 번째 행(컬럼명) 처리 문제

  • CSV 파일의 첫 번째 행은 데이터가 아니라 컬럼명이므로, 이를 무시해야 했습니다.
  • 해결 방법: 데이터를 저장하기 전에 번째 행을 건너뛰도록 for문을 수정했습니다.
for (int i = 1; i < csvList.size(); i++) { // 첫 번째 행 제외
    // 데이터 저장 로직
}

 

문제 2. 데이터 내 포함된 , 처리 문제

  • CSV 파일은 ","로 행을 구분하는데 CSV 데이터에서 ""로 감싸진 값 내부에 ','가 포함된 경우, split(",")으로 제대로 나뉘지 않아 오류가 발생했습니다.
  • 해결 방법: 정규식을 활용해 CSV 파일의 구조를 정확히 파싱했습니다.
String[] lineArr = line.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)", -1);

 

정규식은 큰따옴표 내부의 쉼표 무시하고 데이터를 분리할 있도록 합니다.

 

전체코드

애플리케이션을 실행하고 상품 데이터를 조회해오는 메서드를 실행하면 다음과 같이 잘 들어간걸 확인할 수 있습니다.

+ Recent posts