점프투자바

SUADI·2022년 5월 26일

(4) 예외처리(Exception)

프로그램을 만들다보면 수많은 오류가 생기는데 오류가 발생하는 이유는 프로그램이 정상적으로 작동하게 하기 위한 자바의 배려이다. 오류가 날때 그에 맞는 적절한 처리를 하는 경우도 있고 무시할 수도 있다. 이를 예외처리라 한다.

ㄱ) 오류의 종류

  • FileNotFoundException
import java.io.BufferedReader;
import java.io.FileReader;

public class Sample {
    public static void main(String[] args) {
        BufferedReader bufferedReader = new BufferedReader(new FileReader("없는파일"));
        bufferedReader.readLine();
        bufferedReader.close();
    }
}

존재하지 않는 파일을 불러올 경우에 오류가 발생한다.

  • ArithmeticException
public class Sample {
    public static void main(String[] args) {
        int a = 4 / 0;
    }
}

계산할 수 없는 값을 계산하려고 하면 오류가 발생한다.

  • ArrayIndexOutOfBoundsException
public class Sample {
    public static void main(String[] args) {
        int[] a = {1,2,3};
        System.out.println(a[3]);
    }
}

배열에 존재하지 않는 인덱스를 불러오려고 할때 오류가 발생한다.

ㄴ) try, catch 문

try {
	시도할 문장
    ...
} catch (exception 종류) {
	예외 발생 시 처리 방법
} catch () {

}...
  • 프로그램에서 발생한 오류가 생겼을 때 그 오류를 예외적으로 처리하는 방법 중에 하나는 try, catch문을 이용하는 것이다. try문에 수행할 문장을 집어넣고, 문제가 없다면 catch문을 건너 뛰게 되고, 예외가 생긴다면 catch문에 지정된 예외에 해당하는 구문을 수행한다. 예를 들어
int c;
try {
	c = 4 / 0;
} catch (ArithmeticException e) {
	c = -1;
}
  • try문에서 수행할 문장이 4를 0으로 나누어서 c에 저장하는 구문이다. 0으로 나누는 행위는 ArithmeticException에 해당하므로 catch문을 수행하게 된다. 여기서 e는 ArithmeticException의 객체이다. 클래스에서 객체를 생성할 수 있는 것처럼 자바에 내장되어 있는 수많은 Excption 클래스도 객체를 생성할 수 있다. 이 오류 객체를 통해 해당 예외의 메소드를 호출할 수 있다.

ㄷ) finally

try문에 수행할 문장들이 있을 때 예외가 생기는 문장이 생기면 그 뒤의 문장은 수행되지 않고 곧바로 해당 catch문을 수행하게 된다. 혹시 예외가 생긴 문장 뒤에 꼭 수행해야만 하는 문장이 있다면 이럴 때 finally 문을 사용하게 된다.

public class Sample {
    public void ShouldBeRun() {
        System.out.println("hi, there");
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        int c;
        try {
            c = 4 / 0;
            sample.ShouldBeRun(); // 수행 안됨.
        } catch (ArithmeticException e) {
            c = -1;
        }
    }
}

sample.ShouldBeRun 문을 수행하기 위해서 finally문을 이용하면

public class Sample {
    public void ShouldBeRun() {
        System.out.println("hi, there");
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        int c;
        try {
            c = 4 / 0;
        } catch (ArithmeticException e) {
            c = -1;
        } finally {
            sample.ShouldBeRun();
        }
    }
}
hi, there // 출력

ㄹ) RuntimeException과 Exception

Exception에는 크게 Checked Exception(RuntimeException)과 Unchecked Exception(Exception)이 있다. Checked Exception은 컴파일 단계에서 확인이 가능한 예외이며 try,catch문으로 꼭 예외처리를 해주어야 한다. Unchecked Exception은 실행 단계에서 확인이 가능한 예외이며 예외처리를 강제하지 않는다.(무슨 내용인지 100% 이해하지는 못했다.) 닉네임을 출력해주는 프로그램으로 예를 들면

public class Sample {
    public void sayNick(String nick) {
        if ("fool".equals(nick)) {
            return;
        }
        System.out.println("당신의 별명은 " + nick + " 입니다.");
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        sample.sayNick("fool");
        sample.sayNick("genious");
    }
}
  • fool이라고 별명을 지으면 아무것도 리턴하지 않게끔 프로그램을 짰다. 이 프로그램을 RuntimeException을 이용해서 짜면
class FoolException extends RuntimeException {
}

public class Sample {
    public void sayNick(String nick) {
        if ("fool".equals(nick)) {
            throw new FoolException();
        }
        System.out.println("당신의 별명은 " + nick + " 입니다.");
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        sample.sayNick("fool");
        sample.sayNick("genious");
    }
}
  • FoolException 클래스를 생성한 후 RuntimeException을 상속했다. 그 다음 sayNick 메소드에서 fool을 nick으로 받을 경우 FoolException을 던지도록 했다. 이 코드는 컴파일 에러가 나게 되는데 예외를 main 메소드(sayNick 메소드를 호출할 메소드)로 던지고 나서 해결이 되었어야 하는데 해결이 되지 않았다. 다음은 Exception을 이용해 보면
class FoolException extends Exception {
}

public class Sample {
    public void sayNick(String nick) {
        try {
            if ("fool".equals(nick)) {
                throw new FoolException();
            }
            System.out.println("당신의 별명은 " + nick + " 입니다.");
        } catch (FoolException e) {
            System.err.println("FoolException이 발생하였습니다.");
        }
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        sample.sayNick("fool");
        sample.sayNick("genious");
    }
}
FoolException이 발생하였습니다.
당신의 별명은 genious입니다.

ㅁ) throw

이번엔 sayNick 메소드에서 예외처리를 하는 것이 아닌 sayNick을 호출할 곳(main 메소드)에서 예외처리를 해보자

class FoolException extends Exception {
}

public class Sample {
    public void sayNick(String nick) throws FoolException {
        if ("fool".equals(nick)) {
            throw new FoolException();
        }
        System.out.println("당신의 별명은 " + nick + " 입니다.");
    }


    public static void main(String[] args) {
        Sample sample = new Sample();
        try {
            sample.sayNick("fool");
            sample.sayNick("genious"); // 수행 안됨
        } catch (FoolException e) {
            System.err.println("FoolException 발생");
        }
    }
}
  • sayNick 메소드 옆에 FoolException을 던져서 sayNick 메소드를 호출할 곳에서 예외처리를 하게끔 했다. 즉, main메소드에서 예외처리를 해야하므로 try,catch 문을 이용하였다.

  • sayNick 메소드에서 예외처리를 했던 것과 가장 큰 차이점은 sample.sayNick("genious")문이 수행되지 않는 다는 점이다. 왜냐하면 sample.sayNick("fool")문에서 이미 예외가 발생되어 catch문으로 넘어가기 때문이다.

ㅂ) 트랜잭션(Transaction)

트랜잭션이란 하나의 작업단위를 뜻한다. 쇼핑몰에서 상품발송하는 것을 수도코드(일반적인 언어로 코드를 흉내내어 알고리즘을 대략적으로 모델링한 코드)를 보며 예로 들어 설명하면

상품발송() {
	포장();
    영수증발행();
    발송();
}

포장() {
	...
}

영수증발행() {
	...
}

발송() {
	...
}

상품발송을 위해서는 포장,영수증발행,발송 모두를 만족해야지만 상품 발송을 한다고 하자. 하나라도 예외가 발생하면 상품발송이 롤백(roll-back,모두 취소하고 되돌아간다는 의미)이 된다고 하면, 예외처리를 상품발송에 해야할까? 포장,영수증발행,발송에 각각 해야할까? 후자를 선택하면 어떤건 만족하는데 어떤건 예외가 발생하는 경우가 생길 수 있다.

상품발송() {
	try {
		포장();
    	영수증발행();
    	발송();    	
    } catch (예외) {
    	모두 취소
    }
}

포장() throw 예외{
	...
}

영수증발행() throw 예외{
	...
}

발송() throw 예외{
	...
}

(5) 쓰레드(Thread)

ㄱ) 쓰레드

메모리를 할당받아 실행중인 프로그램을 프로세스(Process)라 한다. 하나의 프로세스 내부에서 독립적으로 실행되는 하나의 작업단위를 쓰레드라고 한다. JVM에 의해 하나의 프로세스가 발생하고, main 메소드 안의 실행문들이 하나의 쓰레드이다. main 메소드 이외의 또 다른 쓰레드를 만들기 위해서는 두가지 방법이 있는데 하나는 Thread 클래스를 상속(extends)하는 방법이고 또 하나는 Runnable 인터페이스를 구현(implements)하는 방법이다. 첫번째 예로 하나의 쓰레드만 실행하는 코드를 구현해보면

public class Sample extends Thread {
    public void run() {
        System.out.println("hello");
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        sample.start();
    }
}
  • Sample 클래스가 Thread 클래스를 상속했다. Thread 클래스 내의 run 메소드를 Sample 클래스가 오버라이딩(overriding)하여 run 메소드를 원하고자 하는 문구를 넣는다. main 메소드에서 쓰레드를 실행하는데 필요한 키워드는 start()이다. Thread 클래스의 start 메소드를 호출하면 run 메소드를 실행한다.
    두번째 예로 여러개의 쓰레드를 실행하는 코드를 짜보면
public class Sample extends Thread {
    int seq;
    public Sample(int seq) {
        this.seq = seq;
    }

    public void run() {
        System.out.println(this.seq + " Thread start.");
        try {
            Thread.sleep(500);
        } catch (Exception e) {
        }
        System.out.println(this.seq + " Thread end.");
    }

    public static void main(String[] args) {
        for (int i=0; i<10; i++) {
            Thread t = new Sample(i);
            t.start();
        }
        System.out.println("main end");
    }
}
  • 전체적인 코드의 내용은 원하는 문장을 10번 출력하고 조금있다가 다시 출력하는 코드이다.

  • seq를 객체변수로 선언하고 Sample 클래스의 생성자를 만들어서 객체를 생성할때마다 순번을 붙여주도록 했다. run 메소드에는 thread 시작을 알리는 문구를 10번 출력하고, 0.5초 뒤에 끝을 알리는 문구를 10번 출력하도록 했다. main 메소드에서 쓰레드를 실행시키는 방법은 Thread 자료형의 Sample 클래스 객체를 10번 생성해서 각각의 객체로 run 메소드를 호출하게끔 했다. 그 후에 메인메소드를 종료하는 문구를 출력하도록 했다.

  • 출력 결과물에서 알 수 있는 첫번째는 쓰레드가 출력되는 순서가 랜덤이라는 것을 알 수 있다. 두번째는 쓰레드가 끝나기도 전에 main 메소드가 종료된 것을 알 수 있다. 두번째 문제를 해결하기 위해 join이라는 키워드를 사용한다.

ㄴ) 조인

모든 쓰레드가 종료된 후에 main 메소드를 종료시키기 위해 join 메소드를 사용해 보겠다.

import java.util.ArrayList;

public class Sample extends Thread {
    int seq;
    public Sample(int seq) {
        this.seq = seq;
    }

    public void run() {
        System.out.println(this.seq + " Thread start.");
        try {
            Thread.sleep(500);
        } catch (Exception e) {
        }
        System.out.println(this.seq + " Thread end.");
    }

    public static void main(String[] args) {
        ArrayList<Thread> threads = new ArrayList<>();
        for (int i=0; i<10; i++) {
            Thread t = new Sample(i);
            t.start();
            threads.add(t);
        }

        for (int i=0; i<threads.size(); i++) {
            Thread t = threads.get(i);
            try {
                t.join();
            } catch (Exception e) {}
        }
        System.out.println("main end");
    }
}
  • 쓰레드가 먼저 종료됐던 코드와 다른 점은 ArrayList를 생성해서 생성된 Sample 클래스의 객체를 ArrayList에 저장한 후, 각각의 객체를 또다른 for문으로 join 함수를 호출하도록 했다. 이렇게 하면 main end 문장이 제일 마지막에 출력되게 된다. 이해가 될듯 말듯한데 새로운 예제를 푼다고 생각하면 못할 것 같은 답답한 기분이 든다..

ㄷ) 러너블

main 메소드 이외의 또다른 쓰레들를 만들기 위한 방법으로 쓰레드를 상속하는 방법 외에 Runnable 인터페이스를 implements 하는 방법이 있다고 했다. 쓰레드 클래스를 상속하는 방법과 가장 큰 차이점은 쓰레드 클래스는 상속을 하면 이중상속이 안되는 규칙 탓에 다른 클래스를 상속하는 것이 어려워진다. 하지만 Runnable은 인터페이스이므로 다른 클래스 상속을 할 수 있다.

import java.util.ArrayList;

public class Sample implements Runnable {
    int seq;
    public Sample(int seq) {
        this.seq = seq;
    }

    public void run() {
        System.out.println(this.seq + " Thread start.");
        try {
            Thread.sleep(500);
        } catch (Exception e) {
        }
        System.out.println(this.seq + " Thread end.");
    }

    public static void main(String[] args) {
        ArrayList<Thread> threads = new ArrayList<>();
        for (int i=0; i<10; i++) {
            Thread t = new Thread(new Sample(i));
            t.start();
            threads.add(t);
        }

        for (int i=0; i<threads.size(); i++) {
            Thread t = threads.get(i);
            try {
                t.join();
            } catch (Exception e) {}
        }
        System.out.println("main end");
    }
}
  • Thread를 상속하던 것에서 Runnable을 implement하도록 바꿨고 main 메소드의 for문에서 객체 생성하는 구문을
Thread t - new Tread(new Sample(i));

로 변경하였다. 쓰레드의 생성자로 Runnable 인터페이스의 객체(Sample가 Runnable을 implement했으므로 Sample의 객체)를 입력인자로 사용할 수 있는데 이 방법을 이용한 것이다. 쓰레드를 상속했을 때와 동일한 결과를 내지만 이중상속이 안되는걸 감안하면 Runnable을 implement한 것이 다소 유연한 코드라고 할 수 있다.

(6) 함수형 프로그래밍

자바8부터 함수형 프로그래밍을 지원하기 위해 람다와 스트림을 도입했다. 람다와 스트림을 이용하면 코드를 더욱 간결하고 가독성을 좋게 한다는 장점이 있다.

ㄱ) 람다

람다는 익명 함수(Anonymous Function)을 의미한다. 말그대로 이름이 없는 함수이다. 클래스를 생성하여 함수를 만들었던 기존의 방법에서 단 몇줄의 코드로 함수의 이름없이 코드를 짤 수 있다. 계산기를 예로 들면

interface Cal {
    int sum(int a, int b);
}

class MyCal implements Cal {
    public int sum(int a, int b) {
        return a+b;
    }
}

public class Sample {
    public static void main(String[] args) {
        MyCal myCal = new MyCal();
        System.out.println(myCal.sum(3,4));
    }
}
  • MyCal이라는 클래스를 생성하여 두 수를 더하는 함수를 만들었다. 람다를 사용하면 MyCal 클래스 내의 함수 생성없이 간결하게 코드를 짤 수 있다.
interface Cal {
    int sum(int a, int b);
}

public class Sample {
    public static void main(String[] args) {
        Cal cal = (int a, int b) -> a+b; // 람다 함수
        System.out.println(cal.sum(3,4));
    }
}
  • MyCal 클래스를 제거하고 Cal 인터페이스의 객체를 생성할 때 람다함수를 이용하였다. 괄호 안의 변수가 입력항목이고 화살표 뒤가 리턴값이다. Cal 인터페이스 내의 sum 메소드에 이미 입력항목의 자료형을 선언했기 때문에 자료형 생략이 가능하다.
		Cal cal = (a, b) -> a+b;
  • 여기서 더 생략할 수 있는 방법이 있다. 메소드 레퍼런스(Method Reference)를 사용하면 된다. (a,b) -> a+b와 Interger.sum(int a, int b)는 동일한 결과를 내기 때문에 밑의 코드로 더 깔끔하게 쓸 수 있다. 여기서 ::(메소드 레퍼런스)로 클래스와 메소드를 구분하여 표기한다.
Cal cal = Integer::sum;
  • 인터페이스를 직접 생성하는 대신 자바에서 제공하는 함수형 인터페이스를 이용해서 코드를 더 축약할 수도 있다.
import java.util.function.BiFunction;

public class Sample {
    public static void main(String[] args) {
        BiFunction<Integer,Integer,Integer> biFunction = (a,b) -> a+b;
        System.out.println(biFunction.apply(3,4));
    }
}
  • 다음 코드는 BiFuction이라는 자바에 내장되어있는 인터페이스를 임포트해서 코드를 짠 것이다. BiFuction의 객체를 생성할 때 꺽쇠에는 람다함수의 입력항목과 리턴값의 자료형을 표기한다. 그리고 BuFunction 인터페이스 내에 더하는 메소드인 apply 메소드를 이용해 결과를 출력한다.

  • 입력항목과 리턴값의 자료형이 모두 동일하므로 BynaryOperator 인터페이스를 불러와서 자료형을 한번만 쓰도록 할 수도 있다.

import java.util.function.BinaryOperator;

public class Sample {
    public static void main(String[] args) {
        BinaryOperator<Integer> binaryOperator = (a, b) -> a+b;
        System.out.println(binaryOperator.apply(3,4));
    }
}
  • 람다함수를 사용 시 주의할 점은 interface에 메소드가 두개 이상이면 컴파일 에러가 나서 람다함수를 이용할 수 없게 된다. 이런 상황을 방지하기 위해 어노테이션(anotation, 주석)을 이용하는 것이 좋다. 어노테이션을 통해 데이터의 유효성 검사를 쉽게 알 수 있고, 코드가 깔끔해진다. 여기서는 람다함수에 대한 함수형 인터페이스를 지정하는 어노테이션인 @FunctionalInterface를 사용한다.
@FunctionalInterface
interface Cal {
    int sum(int a, int b);
    int min(int c, int d); // 컴파일 에러
}
  • 이렇게 메소드가 두개 이상인 경우 어노테이션에 빨간줄이 그어지면서 컴파일 에러가 난다는 것을 알려준다.

ㄴ) 스트림(Stream)

스트림이란 데이터가 물결(stream)처럼 흘러가면서 필터링 과정을 통해 여러번 변경되는 코드를 뜻한다. 예를 들어 배열에 여러 수 중에서 짝수만을 뽑아서, 중복되는 원소를 제거하고, 역순으로 재배열하는 코드를 짠다고 해보자.

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;

public class Sample {
    public static void main(String[] args) {
        int[] data = {5,6,4,2,3,1,1,2,2,4,8};

        // 1. 짝수만 뽑기
        ArrayList<Integer> even = new ArrayList<>();
        for (int i : data) {
            if (i %2 == 0) {
                even.add(i);
            }
        }

        // 2. 중복 제거
        HashSet<Integer> duplicates = new HashSet<>(odd);

        // 3. 역순
        ArrayList<Integer> reverse = new ArrayList<>(duplicates);
        reverse.sort(Comparator.reverseOrder());

        // 4. ArrayList -> int[]
        int[] result = new int[reverse.size()];
        for (int i=0; i< reverse.size(); i++) {
            result[i] = reverse.get(i);
        }
    }
}
  • 첫번째로 int 배열의 data를 ArrayList로 바꿔서 짝수만을 저장한다. 두번째로 중복되는 원소가 제거되는 HashSet의 특성을 이용한다. 세번째로 다시 ArrayList로 변경해서 reverseOrer 메소드를 사용한다. 네번째로 원래대로 int[] 자료형으로 바꾼다. 이렇게 물결처럼 변해가는 과정을 스트림을 이용하면 더 간결하고 가독성이 좋아진다.
import java.util.Arrays;
import java.util.Comparator;

public class Sample {
    public static void main(String[] args) {
        int[] data = {5,6,4,2,3,1,1,2,2,4,8};

        int[] result = Arrays.stream(data) // IntStream 생성
                .boxed() // IntStream -> Stream<Integer>
                .filter((a) -> a % 2 == 0) // 짝수만 뽑기
                .distinct() // 중복 제거
                .sorted(Comparator.reverseOrder()) // 역순
                .mapToInt(Integer::intValue) // Stream<Integer> -> IntStream
                .toArray() // IntStream -> int[]
        ;

    }
}

0개의 댓글