728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

자바는 프로그램 실행 중에 발생할 수 있는 예상치 못한 상황, 즉 예외(Exception)를 처리하기 위한 메커니즘을 제공한다. 이는 프로그램의 안정성과 신뢰성을 높이는 데 중요한 역할을 한다.

 

자바의 예외 처리는 다음 키워드를 사용한다.

try, catch, finally, throw, throws

 

자바에서 제공하는 예외 계층도

 

자바에서 기본형을 제외한 모든 것은 객체다. 예외 또한 객체이다.

모든 객체의 최상위 부모는 Object이므로 예외의 최상위 부모도 Object.

 

Throwable은 최상위 예외이다. 하위에 Exception, Error가 있다.

 

Error는 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이다. 

애플리케이션 개발자는 이 예외를 잡으려 해서는 안된다.

 

Exception은 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.

Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단, RuntimeException과 그 하위 예외는 언체크 예외이다.

 

RuntimeException은 컴파일러가 체크하지 않는 언체크 예외이다.

RuntimeException과 그 자식 예외 모두 언체크 예외이다. 

언체크 예외는 RuntimeException의 이름을 따서 런타임 예외라고도 많이 부른다. 

 

체크 예외와 언체크 예외

체크 예외는 발생한 예외를 개발자가 명시적으로 처리해야 한다. 그렇지 않은 경우 컴파일 오류가 발생한다.

언체크 예외는 개발자가 발생한 예외를 명시적으로 처리하지 않아도 된다.

 

주의

상속 관계에서 부모 타입은 자식을 담을 수 있다. 이 개념이 예외 처리에도 적용되는데, 상위 예외를 catch로 잡으면 그 하위 예외까지 함께 잡는다. 따라서 애플리케이션 로직에서 Throwable 예외를 잡으면 안되는데, 앞서 이야기 한 잡으면 안되는 Error 예외도 함께 잡을 수 있기 때문이다. 애플리케이션 로직은 이런 이유로 Exception부터 필요한 예외로 생각하고 잡으면 된다.

 

예외 기본 규칙

예외는 발생하면 잡아서 처리하거나, 처리할 수 없다면 밖으로 던져야한다.

 

예외 처리

1. MainService를 호출한다.

2. ServiceClient를 호출한다.

3. Client에서 예외가 발생한다.

4. Client에서 예외를 처리하지 못하고 밖으로 던진다.

5. Service에게 예외가 전달된다. Service는 예외를 처리했다.

6. 정상 흐름을 반환한다.

 

 

예외 던짐

예외를 처리하지 못하면 자신을 호출한 곳으로 예외를 던져야 한다.

 

예외에 대해서는 2가지 기본 규칙을 기억하자.

1. 예외는 잡거나 던져야한다.

2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리할 수 있다.

  • 예를 들어 Exceptioncatch로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
  • 예를 들어 Exceptionthrows로 던지면 그 하위 예외들도 모두 던질 수 있다.
참고: 예외를 처리하지 못하고 계속 던지면 main() 밖으로 던지면 예외 로그를 출력하면서 시스템이 종료된다.

 

체크 예외

Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단, RuntimeException은 제외.

체크 예외는 잡아서 처리하거나, 밖으로 던져야한다. 그렇지 않으면 컴파일 오류가 발생한다.

 

체크 예외 만들기

예외 클래스를 만드려면 예외를 상속받으면 된다. 그 중, 체크 예외를 만드려면 체크 예외를 상속받으면 된다.

 

MyCheckedException

package exception.checked;

public class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
}

Exception을 상속받은 클래스는 체크 예외가 된다. 

예외가 제공하는 기본 기능이 있는데, 그 중에 오류 메시지를 보관하는 기능도 있다. 예제에서 보는 것 처럼 생성자를 통해서 해당 기능을 그대로 사용하면 편하다. super(message)로 전달한 메시지는 Throwable에 있는 detailMessage에 보관된다. getMessage()를 통해 조회할 수 있다.

 

 

Client

package exception.checked;

public class Client {
    public void call() throws MyCheckedException {
        throw new MyCheckedException("Ex");
    }
}

throw 예외라고 하면 새로운 예외를 발생시킬 수 있다. 예외도 객체이다. 그렇기에 객체를 먼저 new로 생성하고 예외를 발생시켜야 한다.

throws 예외는 발생시킨 예외를 메서드 밖으로 던질 때 사용하는 키워드이다. 

 

체크 예외는 잡지 않으면 반드시 던져야 한다. 체크 예외를 발생시킬 땐 throws 예외로 반드시 그 체크 예외가 발생된다는 것을 명시적으로 알려줘야 한다. 

 

Service

package exception.checked;

public class Service {
    Client client = new Client();

    public void callCatch() {
        try {
            client.call();
        } catch (MyCheckedException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
        }
    }

    public void callThrow() throws MyCheckedException {
        client.call();
    }
}

체크 예외는 잡거나 던지거나 둘 중 하나를 반드시 해야한다. 그래서 callCatch()는 받은 체크 예외를 잡는 메서드이고, callThrow()는 받은 체크 예외를 던지는 메서드이다. 그리고 체크 예외는 던질 때 반드시 throws 예외로 던진다고 선언해야 한다.

 

예외를 잡아서 처리하려면 try - catch를 사용해서 예외를 잡으면 된다.

  • try 코드 블럭 안에서 발생하는 예외를 잡아서 catch로 넘긴다.
  • 만약, try에서 잡은 예외가 catch의 대상에 없으면 예외를 잡을 수 없다. 이때는 예외를 밖으로 던져야 한다.
  • 여기서는 MyCheckedException 예외를 catch로 잡아서 처리한다.
  • catch는 해당 타입과 그 하위 타입을 모두 잡을 수 있다. 예를 들어, 저 코드에서 MyCheckedException 예외가 아니라 Exception으로 선언해도 MyCheckedException 예외가 터지면 잡을 수 있다. 왜냐하면 예외도 객체고 객체는 다형성이 적용되기 때문에 그 하위 타입 예외가 발생해도 저 블록 안으로 들어갈 수 있다.

 

 

예외를 잡아서 처리하는 callCatch()를 실행하는 Main

package exception.checked;

public class Main {
    public static void main(String[] args) {
        Service service = new Service();

        service.callCatch();
        System.out.println("정상 종료");
    }
}

예외를 잡아서 처리하는 callCatch()를 호출하는 메인을 실행해보자. 아무런 문제없이 잘 수행된다.

실행결과:

e.getMessage() = Ex
정상 종료

 

예외를 처리하지 않고 밖으로 던지는 callThrow()를 실행하는 Main

package exception.checked;

public class Main {
    public static void main(String[] args) throws MyCheckedException {
        Service service = new Service();

        service.callThrow();
        System.out.println("정상 종료");
    }
}

실행결과:

Exception in thread "main" exception.checked.MyCheckedException: Ex
	at exception.checked.Client.call(Client.java:5)
	at exception.checked.Service.callThrow(Service.java:15)
	at exception.checked.Main.main(Main.java:7)

 

어디서도 예외를 잡아주지 않았기 때문에 결국 main()까지 올라온 상태이다. 이 상태에서 main()에서도 예외를 잡지 않았기 때문에 결국 예외 정보와 스택 트레이스가 출력되고 프로그램이 종료된다. 참고로 예외가 터지면 그 다음 라인이 실행되지 않는다. 

 

체크 예외의 장단점

체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전장치다. 이를 통해 개발자는 어떤 체크 예외가 발생하는지 쉽게 파악할 수 있다.
  • 단점: 하지만 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번거로운 일이 된다. 크게 신경쓰지 않고 싶은 예외까지 모두 챙겨야 한다.

체크 예외 정리

체크 예외는 잡아서 직접 처리하거나 또는 밖으로 던지거나 둘 중 하나를 개발자가 직접 명시적으로 처리해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.

 

언체크 예외

RuntimeException과 그 하위 예외는 언체크 예외로 분류된다. 말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻이다. 

언체크 예외는 체크 예외와 기본적으로 동일하다. 차이가 있다면 예외를 던지는 throws를 선언하지 않고, 생략할 수 있다. 생략한 경우 자동으로 예외를 던진다.

 

  • 체크 예외: 예외를 잡아서 처리하지 않으면 항상 throws 키워드를 사용해서 던지는 예외를 선언해야 한다.
  • 언체크 예외: 예외를 잡아서 처리하지 않아도 throws 키워드를 생략할 수 있다.

언체크 예외 만들기

 

MyUncheckedException

package exception.checked;

public class MyUncheckedException extends RuntimeException {
    public MyUncheckedException(String message) {
        super(message);
    }
}

 

Client

package exception.checked;

public class Client {
    public void call() {
        throw new MyUncheckedException("Ex");
    }
}

언체크 예외는 발생해도 throws 예외 키워드를 사용하지 않아도 된다. 사용하지 않으면 자동으로 던진다.

 

Service

package exception.checked;

public class Service {
    Client client = new Client();

    public void callCatch() {
        try {
            client.call();
        } catch (MyUncheckedException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
        }
    }

    public void callThrow() {
        client.call();
    }
}

언체크 예외 역시 잡거나 던지거나 둘 중 하나를 해야한다. 그래서 필요하다면 catch에서 언체크 예외를 잡을 수도 있고 아무것도 하지 않는 callThrow() 같은 경우엔 자동으로 던져진다. 물론 생략하지 않고 throws 예외 키워드를 사용할 수 있다.

public void callThrow() throws MyUncheckedException {
    client.call();
}

위처럼 언체크 예외여도 throws 예외로 예외를 던질 수 있는데 이렇게 하면 어떤 장점이 있냐면 개발자가 IDE의 도움을 받아 이 메서드가 어떤 언체크 예외를 던지는지 알 수 있는 정도의 장점이 있다. 보통은 생략하는게 일반적이다.

 

예외를 잡아서 처리하는 callCatch()를 호출하는 Main

package exception.checked;

public class Main {
    public static void main(String[] args) {
        Service service = new Service();

        service.callCatch();
        System.out.println("정상 종료");
    }
}

실행결과:

e.getMessage() = Ex
정상 종료

 

예외를 던지는 callThrow()를 호출하는 Main

package exception.checked;

public class Main {
    public static void main(String[] args) {
        Service service = new Service();

        service.callThrow();
        System.out.println("정상 종료");
    }
}

실행결과:

Exception in thread "main" exception.checked.MyUncheckedException: Ex
	at exception.checked.Client.call(Client.java:5)
	at exception.checked.Service.callThrow(Service.java:15)
	at exception.checked.Main.main(Main.java:7)

 

실행결과는 체크 예외와 완전히 동일하다.

 

언체크 예외의 장단점

언체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 생략할 수 있다. 이것 때문에 장점과 단점이 동시에 존재한다.

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 throws 예외를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다.
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누락을 잡아준다.

언체크 예외 정리

체크 예외와 언체크 예외의 차이는 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분에 있다. 이 부분을 필수로 선언해야 하는가 생략할 수 있는가의 차이이다.

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

제네릭  (0) 2024.05.08
예외 처리 3 (예외 처리 도입)  (0) 2024.04.23
예외처리 1 (예외처리가 필요한 이유)  (0) 2024.04.23
try-with-resources  (0) 2024.04.23
익명 클래스  (0) 2024.04.21
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

예외에 대한 내용을 정리한 게 있긴 한데 한번 더 정리해서 더 자세하게 예외 처리가 왜 필요한지?, 예외를 어떻게 처리하는 게 좋은 방법인지?를 알아보자.

 

다음과 같은 프로그램 구성도가 있다고 가정해보자.

 

프로그램의 흐름은 다음과 같다.

  1. 사용자가 데이터를 입력한다.
  2. 입력한 데이터를 NetworkService가 받아서 외부 서버에 전송한다.

여기서 외부 서버에 전송을 하려면, 외부 서버와 연결을 해주는 중간다리가 필요하다. 그 중간다리 역할을 NetworkClient가 해준다.

 

그래서 NetworkClient가 하는 역할은 다음과 같다.

  • 외부 서버와 연결한다.
  • 데이터를 외부 서버에 전송한다.
  • 외부 서버와 연결을 해제한다.

 

위 내용을 토대로 코드를 작성해보자.

NetworkClientV0

package exception.ex0;

public class NetworkClientV0 {

    private final String address;

    public NetworkClientV0(String address) {
        this.address = address;
    }

    public String connect() {
        System.out.println(address + " 서버 연결 성공");
        return "Success";
    }

    public String send(String data) {
        System.out.println(address + " 서버에 데이터 전송: " + data);
        return "Success";
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }
}
  • connect(): 외부 서버와 연결을 담당하는 메서드이다. 물론 실제로 연결하지 않지만 연결한다고 생각하자.
  • send(): 외부 서버에 데이터를 전송하는 메서드이다.
  • disconnect(): 외부 서버와의 연결을 해제하는 메서드이다.

NetworkServiceV0

package exception.ex0;

public class NetworkServiceV0 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV0 client = new NetworkClientV0(address);

        client.connect();
        client.send(data);
        client.disconnect();
    }
}

서비스 클래스에선 NetworkClient를 사용한다. 그래서 접속할 외부 서버의 주소를 전달하고, 연결하고 데이터를 전송하고 연결을 해제하는 메서드를 순차적으로 호출한다.

 

Main

package exception.ex0;

import java.util.Scanner;

public class MainV0 {
    public static void main(String[] args) {
        NetworkServiceV0 networkService = new NetworkServiceV0();

        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            networkService.sendMessage(input);
            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");

    }
}

 

이제 사용자와 상호작용하는 부분이다. "exit" 문자열이 들어오면 루프를 빠져나와 프로그램을 종료한다. 

사용자가 입력한 문자열이 데이터가 되어 외부 서버에 전송이 될 것이다.

 

실행결과:

전송할 문자: 
Hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: Hello
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

나의 코드가 원하는 대로 동작을 한다. 근데 원하는 대로 동작 안한다면? 세상에 아름다운 이야기만 있지는 않다.

예를 들어, 외부 서버와의 연결이 실패한다면? 연결은 성공했지만 데이터를 전송하는 과정에 어떤 문제로 인해 데이터를 전송하는데 실패한다면? 그런 상황을 생각해보기 위해 위 코드를 한번 수정해보자.

 

NetworkClientV1

package exception.ex1;

public class NetworkClientV1 {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV1(String address) {
        this.address = address;
    }

    public String connect() {
        if (connectError) {
            System.out.println(address + " 서버 연결 실패");
            return "connectError";
        }

        System.out.println(address + " 서버 연결 성공");
        return "Success";
    }

    public String send(String data) {
        if (sendError) {
            System.out.println(address + " 서버에 데이터 전송 실패: " + data);
            return "sendError";
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
        return "Success";
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }
}

 

이제 이 NetworkClientV1 클래스에는 connectError, sendError 두 개의 필드가 있다. 그리고 각 필드값이 true라면, connect() 메서드나 send() 메서드에서 에러 상황이 일어났다고 가정한다.

 

그리고 boolean 타입의 필드 기본값은 false이니까, 들어오는 데이터에 따라 그 값을 true로 변경하는 initError() 메서드를 만든다.

메서드 내용은 전달받은 파라미터 data(사용자 입력값)의 값이 "error1"을 포함하면 connectErrortrue로, "error2"를 포함하면 sendErrortrue로 변경한다.

 

NetworkServiceV1_1

package exception.ex1;


public class NetworkServiceV1_1 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + connectResult);
            return;
        }

        String sendResult = client.send(data);
        if (isError(sendResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + sendResult);
            return;
        }

        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("Success");
    }
}

이제 NetworkClientV1을 가져다가 사용하는 서비스를 보자.

우선 사용자로부터 입력받은 data에 대한 initError() 메서드를 먼저 실행한 후, connect(), send() 메서드를 각각 실행한다.

 

근데 connect() 또는 send() 메서드가 에러가 있는 경우 더이상 "Success"를 반환하지 않고 "connectError" 또는 "sendError"를 반환하기 때문에 그럴 경우 에러가 발생했다는 것을 알려주고 다음 단계를 중지해야 한다. 그도 그럴것이 앞 단계에서 에러가 났는데 다음 단계가 잘 될리 없다.

 

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 실패
[네트워크 오류 발생] 오류 코드: connectError

전송할 문자: 
error2
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송 실패: error2
[네트워크 오류 발생] 오류 코드: sendError

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 만약 에러가 발생하면 에러가 발생했다는 사실을 사용자에게 출력해주고, 다음 단계를 진행하지 않는다. 좋다. 근데 한가지 문제가 있다. 에러가 발생하면 자원을 반납하는 disconnect() 메서드가 호출되지 않고 있다. 

참고: 자바의 경우 GC가 있기 때문에 JVM 메모리에 있는 인스턴스는 자동으로 해제할 수 있다. 하지만 외부 연결과 같은 자바 외부의 자원은 자동으로 해제가 되지 않는다. 따라서 외부 자원을 사용한 후에는 연결을 해제해서 외부 자원을 반드시 반납해야 한다.

 

그럼, 이제 사용자에게 에러가 발생했다는 것도 알려주고, 에러가 나면 다음 단계를 진행하지 않는것 까지는 좋은데 에러가 발생하든 발생하지 않든 disconnect() 메서드는 호출되어야 하기 때문에 이 부분을 해결해야 한다.

 

NetworkServiceV1_3

package exception.ex1;


public class NetworkServiceV1_3 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + connectResult);
        } else {
            String sendResult = client.send(data);
            if (isError(sendResult)) {
                System.out.println("[네트워크 오류 발생] 오류 코드: " + sendResult);
            }
        }
        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("Success");
    }
}

해결하는 방법은 간단하다. if-else로 조건을 분기하고 에러가 발생해도 바로 return을 하지 않으면 된다.

이렇게 코드를 수정하면 연결에 실패하든 데이터 전송에 실패하든 disconnect() 메서드를 호출할 수 있다.

 

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 실패
[네트워크 오류 발생] 오류 코드: connectError
https://example.com 서버 연결 해제

전송할 문자: 
error2
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송 실패: error2
[네트워크 오류 발생] 오류 코드: sendError
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 정상흐름일 땐 정상흐름답게, 예외 흐름일 땐 문제 없이 사용자에게 예외 발생을 알리고 disconnect() 메서드도 잘 호출한다. 

 

근데, 아직 해결되지 않은 문제가 있다. 

정상 흐름과 예외 흐름이 섞여있어서 코드 해석이 어려워졌고 예외 처리 흐름이 심지어 더 많은 코드 분량을 차지하고 있기 때문에 어디가 정상 흐름이고 어디가 예외 흐름인지 분석하기가 어렵다. 

그래서 이렇게 connect(), send() 메서드의 반환값을 통해서 예외 상황을 처리하는 방식은 해결할 수 없는 문제를 가지고 있다.

 

이런 문제를 해결하기 위해 바로 예외 처리 메커니즘이 존재한다. 예외 처리를 사용하면 정상 흐름과 예외 흐름을 명확하게 분리할 수 있다.

 

결론

자바에서 제공하는 예외 처리 메커니즘을 사용해야 하는 이유는 다음과 같다.

  • 세상엔 정상 흐름만 존재하지 않는다.
  • 예외 흐름을 반환값 같은 코드적으로 해결하려 해도 예외 흐름과 정상 흐름이 섞여 있어 코드를 분석하기 어렵다.
  • 코드적으로 해결하다보니 예외 흐름이 정상 흐름보다 코드 양이 더 많아진다.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

예외 처리 3 (예외 처리 도입)  (0) 2024.04.23
예외 처리 2 (예외 계층)  (0) 2024.04.23
try-with-resources  (0) 2024.04.23
익명 클래스  (0) 2024.04.21
지역 클래스  (0) 2024.04.21
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

try-with-resources 구문을 사용해서 사용한 자원을 반납하는데 효율적으로 반납해보자.

애플리케이션에서 외부 자원을 사용하는 경우 반드시 외부 자원을 해제해야 한다. (예: Database connection)

그래서 항상 try - finally 구문을 사용했다. 

try {
   //정상 흐름
} catch() {
   //예외 흐름
} finally {
   //반드시 호출해야 하는 마무리 흐름
}

이런 반복적인 구문을 사용하면서 편의 기능을 자바 7에서 도입했는데 그게 try-with-resources다.

이 구문을 사용하면 try가 끝나는 순간 자원을 알아서 반납해준다.

 

이 기능을 사용하려면 먼저 AutoClosable 인터페이스를 구현해야 한다.

public interface AutoClosable {
    void close() throws Exception;
}

 

이 인터페이스를 구현한 클래스의 인스턴스를 사용하면 try가 끝나는 시점에 close() 메서드가 자동으로 호출된다.

 

그리고 다음과 같이 try-with-resources 구문을 사용하면 된다.

try (Resource resource = new Resource()) {
    resource.xxx
    ...
}

 

직접 이 AutoClosable 인터페이스를 구현해서 try-with-resources를 사용해보자.

package exception.ex4;

import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;

public class NetworkClientV5 implements AutoCloseable {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV5(String address) {
        this.address = address;
    }

    public void connect() {
        if (connectError) {
            throw new ConnectExceptionV4(address, "서버 연결 실패");
        }

        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) {
        if (sendError) {
            // throw new RuntimeException("ex");
            throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }

    @Override
    public void close() {
        System.out.println("NetworkClientV5.close");
        disconnect();
    }
}

어떤 클래스던 AutoClosable 인터페이스를 구현하면, 반드시 구현해야 하는 메서드 close()가 있다. 이 close() 메서드에서 원하는 작업(리소스 정리)을 해주면 된다. 

 

NetworkClientV5 클래스를 사용하는 코드는 이렇게 생겼다.

package exception.ex4;

public class NetworkServiceV5 {

    public void sendMessage(String data) {
        String address = "https://example.com";

        try (NetworkClientV5 client = new NetworkClientV5(address)) {
            client.initError(data);
            client.connect();
            client.send(data);
        } catch (Exception e) {
            System.out.println("[에러 발생]: " + e.getMessage());
        }
    }
}

이렇게 try-with-resources 구문으로 인스턴스를 생성하고 try 구문 안에서 원하는 작업을 다 수행하면 try가 끝나는 시점에 바로 구현한 close() 메서드가 실행된다.

 

Main

package exception.ex4;

import exception.ex4.exception.SendExceptionV4;

import java.util.Scanner;

public class MainV4 {
    public static void main(String[] args) {
        NetworkServiceV4 networkService = new NetworkServiceV4();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            try {
                networkService.sendMessage(input);
            } catch (Exception e) {
                exceptionHandler(e);
            }

            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");
    }

    // 공통 예외 처리
    private static void exceptionHandler(Exception e) {
        System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");

        System.out.println("==개발자용 디버깅 메시지==");
        e.printStackTrace(System.out);
        // e.printStackTrace();

        //필요하면 예외 별로 별도의 추가 처리 가능
        if (e instanceof SendExceptionV4 sendExceptionV4) {
            System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
        }
    }
}

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 해제
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.
==개발자용 디버깅 메시지==
exception.ex4.exception.ConnectExceptionV4: 서버 연결 실패
	at exception.ex4.NetworkClientV4.connect(NetworkClientV4.java:18)
	at exception.ex4.NetworkServiceV4.sendMessage(NetworkServiceV4.java:11)
	at exception.ex4.MainV4.main(MainV4.java:21)

 

정상 흐름이나, 예외 흐름이나 모두 서버 연결 해제가 잘 호출된다. 그리고 예외 흐름일 때 호출 시점이 중요한데 보면 catch에 걸렸을 때 catch로 가기전 close()메서드가 호출됐음을 확인할 수 있다. 이렇듯 정말 try가 끝나는 즉시 close()메서드가 호출된다. 

 

try-with-resources 장점

  • 리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나 finally 블럭 안에서 자원 해제 코드를 누락하는 문제들을 예방한다.
  • 코드 간결성 및 가독성 향상: 명시적인 close() 메서드 호출이 필요 없어 코드가 더 간결하고 읽기 쉬워진다.
  • 스코프 범위 한정: 예를 들어 리소스로 사용되는 client 변수의 스코프가 try 블록 안으로 한정된다. 따라서 코드 유지보수가 더 쉬워진다. 
  • 조금 더 빠른 자원 해제: 기존에는 try - catch - finally 순으로 catch 이후에 자원을 반납하는데 try-with-resources 구문은 try 블록이 끝나면 바로 반납한다.
728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

익명 클래스는 마찬가지로 중첩 클래스의 일종이다.

익명 클래스는 이름 그대로 이름이 없는 클래스를 의미하는데, 저번 지역 클래스에서 사용했던 코드를 가져와서 익명 클래스로 변경해보자.

 

package nested.local;

import java.lang.reflect.Field;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        return new LocalClazz();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();

        Field[] declaredFields = localClazz.getClass().getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

 

이 소스에서 LocalClazzInterface를 구현한 지역 클래스 LocalClazz를 익명 클래스로 변경할 수 있다.

 

AnonymousClass

package nested.local;

import java.lang.reflect.Field;

public class AnonymousClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        LocalClazzInterface localClazz = new LocalClazzInterface() {
            private int localInstanceVal = 5;
            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        };

        System.out.println("localClazz.getClass() = " + localClazz.getClass());

        return localClazz;
    }

    public static void main(String[] args) {
        AnonymousClass outerClass = new AnonymousClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();

        Field[] declaredFields = localClazz.getClass().getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

실행결과:

localClazz.getClass() = class nested.local.AnonymousClass$1
localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100
declaredField = private int nested.local.AnonymousClass$1.localInstanceVal
declaredField = final int nested.local.AnonymousClass$1.val$localVal
declaredField = final int nested.local.AnonymousClass$1.val$paramVar
declaredField = final nested.local.AnonymousClass nested.local.AnonymousClass$1.this$0

 

process() 메서드 내부를 보면, LocalClazzInterface 라는 인터페이스에 new라고 새로 생성하는 것처럼 보이는데 인터페이스를 인스턴스로 만드는 게 아니라 인터페이스를 구현하는 구현체를 만들어내는 것이다. 근데 따로 클래스를 정의해서 구현하는게 아니고 바로 생성해내는 방식을 익명클래스라고 한다.

 

그러니까 쉽게 말해 지역클래스에 대한 선언과 생성을 한번에 한다고 보면 된다.

 

익명 클래스 특징

  • 익명 클래스는 이름 없는 지역 클래스를 선언하면서 동시에 생성한다.
  • 익명 클래스는 부모 클래스를 상속 받거나, 또는 인터페이스를 구현해야 한다. 익명 클래스를 사용할 때는 상위 클래스나 인터페이스가 필요하다.
  • 익명 클래스는 말 그대로 이름이 없다. 이름을 가지지 않으므로 생성자를 가질 수 없다. (기본 생성자만 사용됨)
  • 익명 클래스는 위 실행결과의 AnonymousClass$1 과 같이 자바 내부에서 바깥 클래스 이름 + $ + 숫자로 정의된다. 익명 클래스가 여러개면 $1, $2, $3 으로 숫자가 증가하면서 구분된다.

익명 클래스의 장점

익명 클래스를 사용하면 클래스를 별도로 정의하지 않아도 인터페이스나 추상 클래스를 즉석에서 구현할 수 있어 코드가 더 간결해진다. 하지만 복잡하거나 재사용이 필요한 경우에는 별도의 클래스를 정의하는 것이 좋다.

 

익명 클래스를 사용할 수 없을 때

익명 클래스는 단 한 번만 인스턴스를 생성할 수 있다. 다음과 같이 여러 번 생성이 필요하다면 익명 클래스를 사용할 수 없다. 대신에 지역 클래스를 선언하고 사용하면 된다. 

LocalClazz localClazz1 = new LocalClazz();
localClazz1.print();

LocalClazz localClazz2 = new LocalClazz();
localClazz2.print();

 

익명 클래스 정리

  • 익명 클래스는 이름이 없는 지역 클래스이다.
  • 특정 부모 클래스(인터페이스)를 상속 받고 바로 생성하는 경우 사용한다.
  • 지역 클래스가 일회성으로 사용되는 경우나 간단한 구현을 제공할 때 사용한다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

 

지역 클래스는 내부 클래스의 특별한 종류 중 하나이다. 그렇기에 내부 클래스의 특징을 그대로 가진다.

예를 들어, 지역 클래스도 내부 클래스이므로 바깥 클래스의 인스턴스 멤버에 접근할 수 있다.

 

지역 클래스는 지역 변수와 같이 코드 블럭 안에서 정의된다.

지역 클래스 예시

class Outer {
    
    public void process() { 
        int localVar = 0; //지역 변수
        
        class Local {...} //지역 클래스
        
        Local local = new Local();
    }
}

 

 

그래서 지역 클래스가 접근 가능한 것에는 다음과 같은 것들이 있다.

  • 지역 변수
  • 바깥 클래스의 인스턴스 변수
  • 바깥 클래스의 클래스 변수 (사실 이건 당연한 것. 클래스 변수는 어디서나 접근이 가능하기 때문에. 근데! private으로 선언해도 접근이 가능)

 

지역 클래스를 직접 만들어 보면 다음과 같다.

package nested.local;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public void process(int paramVar) {
        int localVal = 2;

        class LocalClazz {
            private int localInstanceVal = 5;

            public void localClazzMethod() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        LocalClazz localClazz = new LocalClazz();
        localClazz.localClazzMethod();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        outerClass.process(1);
    }
}
  • 지역 클래스는 선언한 블록 안에서만 접근이 가능하다. 
    • 그렇기 때문에 process() 메서드 안에서 지역 클래스를 생성하고 지역 클래스의 인스턴스로부터 메서드를 호출할 수 있다.
  • 지역 클래스는 접근 제어자는 붙일 수 없다.
    • 지역 변수에 접근 제어자를 못 붙이는거랑 같은 맥락으로 생각하면 된다.

 

모든 중첩 클래스들은 일반 클래스처럼 인터페이스를 구현하거나 부모 클래스를 상속할 수 있다.

여기서 모든 중첩 클래스는(정적 중첩 클래스, 내부 클래스, 지역 클래스, 익명 클래스)를 말한다.

 

LocalClazzInterface

package nested.local;

public interface LocalClazzInterface {
    void print();
}

인터페이스를 상속받는 지역 클래스 예시

package nested.local;

public class OuterClass {

    ...

    public void process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            public void localClazzMethod() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }

            @Override
            public void print() {
                System.out.println("Hi");
            }
        }
		...
    }

    ...
}

 

지역 클래스 - 지역 변수 캡처

지역 변수 캡처라는 내용을 이해하기 전 변수의 생명 주기에 대해서 먼저 짚고 넘어가보자.

변수는 크게 세가지가 있다.

  • 클래스 변수
  • 인스턴스 변수
  • 지역 변수

변수의 생명 주기

  • 클래스 변수: 생명 주기가 가장 길다. 왜냐하면 이 클래스 변수는 프로그램 종료까지 살아 있다. 메모리 구조 내 메서드 영역에 있는 변수이기 때문에 이 프로그램에 종료되기까지 살아 있게 된다. 
  • 인스턴스 변수: 생명 주기는 인스턴스가 살아 있는 동안까지이다. 인스턴스는 힙 영역에 자리하고 있다. 인스턴스 변수는 본인이 소속된 인스턴스가 GC되기 전까지 존재한다. 생존 주기가 긴 편이다.
  • 지역 변수: 메서드 호출이 끝나면 사라진다. 지역 변수는 스택 영역에서 스택 프레임 안에 존재한다. 따라서 메서드가 호출될 때 생성되고 메서드 호출이 종료되면 스택 프레임이 제거되면서 그 안에 있는 지역 변수도 모두 제거된다. 생존 주기가 제일 짧다. 매개변수도 지역변수다. 

이 내용을 머리속에 넣은 상태에서 다음 코드를 보자.

package nested.local;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        return new LocalClazz();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();
    }
}

 

이전에 사용한 코드를 조금 수정한 내용이다.

우선 process() 메서드는 LocalClazzInterface 타입을 반환한다. 그래서 LocalClazzInterface를 구현한 구현체인 LocalClazzprocess() 메서드가 반환한다. 

 

main() 메서드에서 반환받은 LocalClazzInterface 타입의 변수 localClazzprint() 메서드를 실행한다.

실행 결과는 다음과 같다.

localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100

 

결론은 잘 찍힌다. 근데 의아한 부분이 하나 있다. 우선 하나씩 생각해보자.

내부 클래스로 만든 인스턴스도 인스턴스다. 그렇기 때문에 힙 영역에 존재하게 된다. 그리고 내부 클래스를 생성하면 내부 클래스는 바깥 클래스를 참조하고 있다. 

 

그럼 main() 메서드에서 process() 메서드를 호출해서 받은 내부 클래스의 인스턴스는 main() 메서드가 종료될 때 까지 살아 있는게 당연하다. 어디선가(main()) 이 인스턴스를 참조하고 있기 떄문에 GC에 해당하지 않는다. 그 말은 내부 클래스가 살아있으면 바깥 클래스도 여전히 살아 있게 된다는 말이다(내부 클래스가 바깥 클래스의 참조값을 가지고 있으니까).

 

그럼 내부 클래스의 변수인 localInstanceVal, 바깥 클래스의 인스턴스 변수인 outerInstanceVar, 바깥 클래스의 클래스 변수인 outerClassVar는 저 시점에 접근이 가능한게 맞다. 그러니까 아직 저 변수 세 개는 살아 있는게 맞다.

 

근데? process() 메서드의 지역 변수(매개변수 포함)인 localVal, paramVar는? 얘네는 process()가 호출될 때 스택 영역에 스택 프레임에 쌓이는 순간 생성되고 process() 메서드가 종료될 때 스택 프레임에서 제거되므로 저 변수 두 개도 삭제가 된다.

 

변수 두 개가 삭제가 되는데 여전히 실행을 하면 저 두개의 값이 잘 찍히고 있다. 어떻게 이게 가능할까?

여기서 나오는 해결책이 바로 지역 변수 캡처이다.

 

사실, 저렇게 실행결과를 찍을 때 지역 변수를 찾아서 가져오는 게 아니다. 지역 클래스에서 필요한 지역 변수가 있을 때 그 값을 지역 클래스의 필드로 몰래 만들어 낸다.

참고로, 모든 지역 변수를 캡처하는 게 아니다. 접근이 필요한 지역 변수만 캡쳐한다.

 

그러니까 코드로 보면 다음과 같다.

package nested.local;

public class OuterClass {

	...
    
    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            // 이렇게 클래스 내부에 필드로 선언을 몰래 해버린다.
            int localVal = 2;
            int paramVar = ...;
        }

        return new LocalClazz();
    }
	...
}

 

접근이 필요한 지역 변수 localVal, paramVar를 클래스 내부에 필드로 몰래 선언을 한다. 이렇게 해놓으니까 process() 메서드가 종료되면서 날라가는 스택 프레임에 같이 있던 지역 변수가 삭제되더라도 지역 클래스에서 저 변수를 출력해낼 수 있는 것.

 

실제로 그런지 코드로도 확인해보자. 어떻게 확인할 수 있냐면 getClass() 메서드를 통해 가지고 있는 필드를 조회해보자.

public static void main(String[] args) {
    OuterClass outerClass = new OuterClass();
    LocalClazzInterface localClazz = outerClass.process(1);

    localClazz.print();

    Field[] declaredFields = localClazz.getClass().getDeclaredFields();
    for (Field declaredField : declaredFields) {
        System.out.println("declaredField = " + declaredField);
    }
}

실행 결과:

localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100
declaredField = private int nested.local.OuterClass$1LocalClazz.localInstanceVal
declaredField = final int nested.local.OuterClass$1LocalClazz.val$localVal
declaredField = final int nested.local.OuterClass$1LocalClazz.val$paramVar
declaredField = final nested.local.OuterClass nested.local.OuterClass$1LocalClazz.this$0

 

지역 클래스가 가지고 있는 localInstanceVal 외에 localVal, paramVar가 보인다. 정말로 우리 몰래 필드를 클래스에 넣어버렸다.

그리고 또 하나, 마지막에 출력된 이거.

declaredField = final nested.local.OuterClass nested.local.OuterClass$1LocalClazz.this$0

이게 바로 바깥 클래스의 참조값이다. 이렇게 지역 클래스는 바깥 클래스의 참조값도 보관하고 있음을 알 수 있다. 

 

결론은,

이러한 지역 변수 캡쳐를 통해서 스택 영역에 쌓인 스택 프레임이 삭제되면서 지역 변수가 같이 삭제되더라도 힙 영역에 존재하는 지역 클래스는 문제 없이 지역 변수에 접근이 가능하다. 사실 지역 변수에 접근이 가능하단 말도 정확한게 아니고 본인의 캡쳐된 변수에 접근한다.

 

근데.. 결론일까? 그럼 여기서 의문이 하나 더 생긴다.

그럼 지역 변수를 캡처해서 가지고 있는것 까지 알았는데 이 지역 변수의 값을 바꾸면 어떻게 되는거지?!

지역 클래스가 접근하는 지역 변수는 절대로 중간에 값이 변하면 안된다.

이건 자바 규칙이고 문법이다. 그래서 final로 선언하거나, 사실상 final이어야 한다.

사실상 finaleffectively final을 번역한 것이고 final로 선언하진 않았지만 선언 이후 값을 변경하지 않는 지역 변수를 의미한다.

 

그럼 왜 final 또는 사실상 final이어야 하고? 왜 중간에 값이 변하면 안될까?

지역 클래스가 접근하는 지역 변수는 지역 변수 캡쳐를 하기 때문이다.

 

중간에 변경하려고 하면 다음과 같이 컴파일 에러가 발생한다.

 

지역 클래스가 사용하는 지역 변수는 지역 변수 캡처가 일어나는데 중간에 지역 변수 값을 바꿔버리면 동기화 문제가 발생한다. 그런 이유로 인해 자바에서 아예 지역 변수를 변경하는 것 자체를 막아둔 것.

 

그래서 진짜 결론은 다음과 같다.

 

결론

지역 클래스는 접근하는 지역 변수를 지역 변수 캡처를 통해 가지고 있게 된다. 그래서 스택 영역에 쌓인 스택 프레임이 삭제되면서 같이 삭제되는 지역 변수에 접근하지 않아도 지역 변수 캡처값을 가지고 접근이 가능해진다. 그리고 이 지역 변수 캡처를 하기 때문에 지역 클래스로부터 접근이 되는 지역 변수는 중간에 값이 변경될 수 없다. 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

try-with-resources  (0) 2024.04.23
익명 클래스  (0) 2024.04.21
중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
728x90
반응형
SMALL

가장 먼저 작업할 내용은 AndroidDriver를 초기화해야 한다.

AndroidDriver는 어떤 역할을 하냐면, 연결된 Real Device를 PC에서 컨트롤할 수 있게 해주는 말 그대로 드라이버이다.

 

그리고 이 Driver는 모든 테스트를 돌리면서 딱 한개만 있으면 된다. 그래서, 싱글톤 패턴으로 드라이버를 초기화하는 클래스가 필요하다.

프로젝트 구조

└── src
    ├── main
    │   ├── java
    │   │   └── kro
    │   │       └── kr
    │   │           └── tbell
    │   └── resources
    └── test
        ├── java
        │   ├── AppiumSampleTest.java
        │   └── kro
        │       └── kr
        │           └── tbell
        │               ├── AppiumConnector.java
        │               ├── Constants.java
        │               ├── cucumber
        │               │   └── CucumberRunner.java
        │               └── stepdefinitions
        │                   └── PocFeatureStepDefs.java
        └── resources
            ├── env.yaml
            └── features
                └── poc.feature

 

자바 프로젝트를 만들면, src폴더 내부에 main, test 두 개의 폴더가 기본으로 생성된다.

여기서 test 폴더에서 우리가 원하는 구조를 만들어 나갈 것.

AppiumConnector 클래스

AppiumConnector

package kro.kr.tbell;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;

@Slf4j
public class AppiumConnector {
    private static AndroidDriver androidDriver;

    private static final Yaml yaml = new Yaml();

    private AppiumConnector() {}

    private static AndroidDriver getAndroidDriver() {
        if (androidDriver == null) {
            Map<String, Object> env = initializeYaml();

            String udid = (String) env.get("udid");
            String url = (String) env.get("url");

            try {
                URL appiumServer = URI.create(url).toURL();
                UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

                androidDriver = new AndroidDriver(appiumServer, options);
            } catch (MalformedURLException e) {
                log.error("[getAndroidDriver]: MalformedURLException", e);
                throw new RuntimeException(e);
            }
        }
        return androidDriver;
    }

    private static Map<String, Object> initializeYaml() {
        InputStream inputStream = AppiumConnector.class
                .getClassLoader()
                .getResourceAsStream("env.yaml");

        return yaml.load(inputStream);
    }

    public static WebElement getElementById(String id) {
        return getAndroidDriver().findElement(AppiumBy.id(id));
    }
}

 

이 클래스에서 핵심은 AndroidDriver 타입의 드라이버를 한번만 초기화하고, 해당 드라이버를 통해 Real Device에 접근하는 것이다.

외부에서 직접 접근하지 못하도록 private으로 선언한 두 개의 필드.

private static AndroidDriver androidDriver;
private static final Yaml yaml = new Yaml();

 

AndroidDriver 타입의 androidDriver 필드는 Real Device와 통신하기 위한 드라이버이다.

Yaml 타입의 yaml 필드는 resources 폴더 내 .yaml 파일에서 설정한 변수들을 가져오기 위해 필요한 필드이다. 

 

그래서 드라이버를 초기화하거나, 이전에 초기화했다면 기존 드라이버를 반환하는 메서드 getAndroidDriver().

private static AndroidDriver getAndroidDriver() {
    if (androidDriver == null) {
        Map<String, Object> env = initializeYaml();

        String udid = (String) env.get("udid");
        String url = (String) env.get("url");

        try {
            URL appiumServer = URI.create(url).toURL();
            UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

            androidDriver = new AndroidDriver(appiumServer, options);
        } catch (MalformedURLException e) {
            log.error("[getAndroidDriver]: MalformedURLException", e);
            throw new RuntimeException(e);
        }
    }
    return androidDriver;
}

일단은 이 메서드는 private으로 만들었다. 외부에서 사용하지 않아도 될 것 같아서.

외부에서 사용안해도 되는 이유는 이후에 차차 만들면서 이해할 수 있다.

 

가장 첫번째는, 이 클래스의 클래스 변수인 androidDriver가 초기화 되지 않았는지를 판단한다. 초기화 되지 않았다면 초기화해야 한다.

initializeYaml() 메서드는 .yaml 파일을 읽어오기 위해 필요한 작업을 하는 메서드이다.

private static Map<String, Object> initializeYaml() {
    InputStream inputStream = AppiumConnector.class
            .getClassLoader()
            .getResourceAsStream("env.yaml");

    return yaml.load(inputStream);
}

 

내용은 간단하다. env.yaml 파일을 읽어서 InputStream으로 넣고 yaml이 해당 스트림을 읽으면 끝.

# env.yaml
url: http://0.0.0.0:4723
udid: HVA1FG23

 

yaml 변수에 저장된 값 중 udid, url 값을 읽어온다. 읽어오면 두가지 작업을 한다.

1. URL 타입으로 변환

2. UiAutomator2Options로 Capabilities를 설정할 수 있는데 여기서는 딱 하나 Udid만 설정했다.

 

AndroidDriver 인스턴스를 만들어낸 후 반환한다. 

 

이제 다음 메서드를 보자.

public static WebElement getElementById(String id) {
    return getAndroidDriver().findElement(AppiumBy.id(id));
}

이 메서드는 드라이버를 통해 Real Device와 연결해서 특정 Resource ID를 통해 UI 요소를 가져오는 메서드이다.

앞으로 이 메서드처럼 WebElement 자체를 반환하는 메서드만 public으로 만들어서 외부에서 사용하면 될 것 같아 드라이버를 받는 getAndroidDriver() 메서드는 private으로 선언했다.

 

코드 리팩토링

지금 상태에서 코드의 개선이 많이 필요해 보인다. 코드를 개선해보자.

 

첫번째, getAndroidDriver() 메서드는 멀티쓰레드 환경에서 안전하지 않다.

동시에 여러 쓰레드가 접근할 때 인스턴스가 여러번 생성될 수 있다. 이 부분을 해결해보자.

 

◾️ synchronized 키워드와 Double-Check Locking.

private static AndroidDriver getAndroidDriver() {
    if (androidDriver == null) {
        synchronized (AppiumConnector.class) {
            if (androidDriver == null) {
               // androidDriver 인스턴스 초기화 
            }
        }
    }
}

synchronized 키워드는 메서드나 코드 블럭에 대한 동시 접근을 제한해서 한 시점에 하나의 스레드만이 그 영역을 실행할 수 있게 하는 역할을 한다. 이를 사용해서 여러 스레드가 공유하는 데이터의 동시성 문제를 해결할 수 있다.

 

근데 If (androidDriver == null) 두번 체크한다.

1. 성능 최적화: 첫번째 if로 체크해서 null이 아니라면 synchronized 블록을 비롯한 추가적인 처리 없이 바로 반환된다. 이는 대부분의 호출에서 락을 획득하는 비용을 줄일 수 있다.

2. 스레드 안정성 보장: 만약, 첫번째 검사에서 null임이 판명되면, synchronized 블록으로 진입한다. 이 블록 내에서 한번 더 검사하는 이유는 두번째 스레드 이상이 동시에 블록 안으로 들어와 대기하고 있는 경우, 첫번째 스레드가 이미 객체를 생성했을 가능성을 다시 한번 검사하기 위함이다. 즉, 이중 검사를 통해 객체가 중복 생성되는 것을 방지한다.

 

◾️ try-with-resourceInputStream 자원 해제 및 에러 처리

private static Map<String, Object> initializeYaml() {
    try (InputStream inputStream = AppiumConnector.class
            .getClassLoader()
            .getResourceAsStream("env.yaml")) {

        if (inputStream == null) {
            throw new IllegalStateException("env.yaml not found.");
        }

        return yaml.load(inputStream);
    } catch (IOException e) {
        log.error("[initializeYaml]: Error occurred when loading yaml file ", e);
        throw new RuntimeException(e);
    }
}

 

1. InputStream은 사용 후 반드시 닫아야 한다. 닫는 코드를 작성하거나 아예 try-with-resource 구문으로 사용후 끝나면 자동으로 닫아주는 방식을 택해 자원 해제를 해준다.

2. 찾고자 하는 yaml 파일이 없는 경우를 대비해 if (inputStream == null) 체크를 한다.

3. yaml 파일을 읽어들이는 중 발생하는 에러를 catch 구문으로 처리한다.

 

 

◾️공개 메서드인 경우 명확한 문서화

/**
 * 주어진 ID를 가진 웹 요소를 찾아 반환합니다.
 * 이 메서드는 안드로이드 드라이버를 사용하여 애플리케이션에서 해당 ID를 가진 요소를 검색합니다.
 *
 * @param id 찾고자 하는 웹 요소의 resource id 입니다.
 * @return WebElement 객체를 반환합니다. ID에 해당하는 요소가 없는 경우 {@code null}을 반환할 수 있습니다.
 * @throws org.openqa.selenium.NoSuchElementException 요소를 찾을 수 없는 경우 발생합니다.
 * @throws IllegalStateException AndroidDriver가 초기화되지 않았거나 접근할 수 없는 경우 발생합니다.
 * @throws org.openqa.selenium.WebDriverException 드라이버와의 통신 중 문제가 발생한 경우 발생합니다.
 * */
public static WebElement getElementById(String id) {
    return getAndroidDriver().findElement(AppiumBy.id(id));
}

외부에서 이 메서드를 가져다가 사용하는 클라이언트 코드 쪽(여러 서비스)에서 어떤 메서드인지 명확히 이해할 수 있게 JavaDoc을 활용하자. 어떤 파라미터를 줘야 하는지, null을 반환할수 있는지 없는지, 어떤 에러를 던질 수 있는지 등을 말이다.

 

리팩토링 후 최종 코드

AppiumConnector

package kro.kr.tbell;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;
import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;

@Slf4j
public class AppiumConnector {
    private static AndroidDriver androidDriver;

    private static final Yaml yaml = new Yaml();

    private AppiumConnector() {}

    private static AndroidDriver getAndroidDriver() {
        if (androidDriver == null) {
            synchronized (AppiumConnector.class) {
                if (androidDriver == null) {
                    Map<String, Object> env = initializeYaml();

                    String udid = (String) env.get("udid");
                    String url = (String) env.get("url");

                    try {
                        URL appiumServer = URI.create(url).toURL();
                        UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

                        androidDriver = new AndroidDriver(appiumServer, options);
                    } catch (MalformedURLException e) {
                        log.error("[getAndroidDriver]: MalformedURLException", e);
                        throw new RuntimeException(e);
                    }
                }
            }
        }
        return androidDriver;
    }

    private static Map<String, Object> initializeYaml() {
        try (InputStream inputStream = AppiumConnector.class
                .getClassLoader()
                .getResourceAsStream("env.yaml")) {

            if (inputStream == null) {
                throw new IllegalStateException("env.yaml not found.");
            }

            return yaml.load(inputStream);
        } catch (IOException e) {
            log.error("[initializeYaml]: Error occurred when loading yaml file ", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * 주어진 ID를 가진 웹 요소를 찾아 반환합니다.
     * 이 메서드는 안드로이드 드라이버를 사용하여 애플리케이션에서 해당 ID를 가진 요소를 검색합니다.
     *
     * @param id 찾고자 하는 웹 요소의 resource id 입니다.
     * @return WebElement 객체를 반환합니다. ID에 해당하는 요소가 없는 경우 {@code null}을 반환할 수 있습니다.
     * @throws org.openqa.selenium.NoSuchElementException 요소를 찾을 수 없는 경우 발생합니다.
     * @throws IllegalStateException AndroidDriver가 초기화되지 않았거나 접근할 수 없는 경우 발생합니다.
     * @throws org.openqa.selenium.WebDriverException 드라이버와의 통신 중 문제가 발생한 경우 발생합니다.
     * */
    public static WebElement getElementById(String id) {
        return getAndroidDriver().findElement(AppiumBy.id(id));
    }
}

 

Runner 클래스 만들기

BDD 방법론에 맞게 Gherkin 문법으로 테스트 시나리오를 작성을 하고, 그 시나리오를 수행하려면 Cucumber의 도움을 받아야 한다.

Cucumber가 Gherkin 테스트 시나리오를 수행할 수 있게 해주는 도구이다.

 

CucumberRunner

package kro.kr.tbell.cucumber;

import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(
        features = {"src/test/kro/kr/tbell/features"},
        glue = "stepDefs"
)
public class CucumberRunner {

}

 

@RunWith(Cucumber.class) 애노테이션은 JUnit 프레임워크에서 테스트를 실행할 때 사용되는 실행자(runner)를 지정한다.

여기서 Cucumber.class를 사용함으로써, JUnit은 표준 테스트 실행자 대신 Cucumber 테스트 실행자를 사용하게 됩니다.

 

이렇게 하면 다음과 같은 작업을 한다.

  • Feature 파일 파싱: features 옵션에서 지정된 경로 내의 .feature 파일들을 찾아서 파싱한다. 이 파일들은 Gherkin 언어로 작성된 사용자 스토리나 비즈니스 요구사항을 담은 테스트 시나리오들이라고 보면 된다.
  • 스텝 정의와 연결: 실행자는 각 스텝(Given, When, Then)이 구현된 자바 메서드와 .feature 파일 내의 스텝을 연결한다. 이 연결은 glue 옵션에서 지정된 패키지 내에서 이루어진다.
  • 테스트 실행: 연결된 스텝 정의를 사용하여 실제 테스트를 실행하고 결과를 보고한다.

결과적으로 @RunWtih(Cucumber.class)는 Cucumber를 사용하여 BDD 접근 방식으로 정의된 테스트를 JUnit 환경에서 실행할 수 있도록 설정한다. 


Feature 파일 만들기

poc.feature

Feature: POC Feature

  Scenario: 테스트 케이스 1번
    When 개발 HTTPS 서버 버튼 클릭
    Then 간편 비밀번호 입력 문구가 노출된다

 

BDD 접근 방식으로 정의된 Gherkin 문법으로 만들어진 테스트 시나리오를 가진 poc.feature 파일이다.

.feature 파일은 대분류 Feature가 있고 소분류 Scenario가 있다.

 

Feature는 이 .feature 파일이 어떤 부분을 커버하는지를 어떤 화면도 좋다. 어떤 파트도 좋다. 큰 분류를 담당한다.

Scenario는 그 파트에서 커버되어야 할 시나리오들을 쭉 작성하는데 하나하나의 시나리오를 말한다.

 

그래서 Given - When - Then 키워드로 시나리오를 작성한다.

StepDefinition 파일 만들기

저 Feature 파일에 정의한 각각의 스텝들(Given, When, Then)에 대한 코드를 작성하는 부분이다.

PocFeatureStepDefs

package kro.kr.tbell.stepDefs;

import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import kro.kr.tbell.AppiumConnector;
import kro.kr.tbell.Constants;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;

@Slf4j
public class PocFeatureStepDefs {

    @When("개발 HTTPS 서버 버튼 클릭")
    public void clickDevHttpsServerBtn() {
        WebElement devHttpsServerBtn =
                AppiumConnector.getElementById(Constants.DEV_HTTPS_SERVER_BUTTON_ID);

        devHttpsServerBtn.click();
    }


    @Then("간편 비밀번호 입력 문구가 노출된다")
    public void assertSimplePasswordText() {
        AppiumConnector
                .getElementById(Constants.SIMPLE_PASSWORD_TEXT_ID)
                .isDisplayed();
    }
}

 

이렇게 @When, @Then 애노테이션으로 어떤 스텝인지 명확하게 알 수 있어 가시성이 뛰어나다.

그리고 그 안에서 위에서 만든 싱글톤 패턴의 AppiumConnector 클래스의 클래스 변수 AndroidDriver를 가져와서 원하는 요소를 찾아내고 요소에 대해 어떤 행위를 한다. @Then에서 isDisplayed()는 보이지 않으면 그 자체로 에러를 반환하기 때문에 따로 Assertion이 필요가 없다. 

 

파라미터로 넘겨주는 값은 상수값으로 따로 정의를 했다.

Constants

package kro.kr.tbell;

public interface Constants {
    String DEV_HTTPS_SERVER_BUTTON_ID = "id-button-1"; //개발 HTTPS 서버 버튼 Resource ID
    String SIMPLE_PASSWORD_TEXT_ID = "id-title-1"; // 간편 비밀번호를 입력해주세요 문구 Resource ID
}

저 Resource ID는 Appium Inspector를 통해 알 수 있다. [아래 사진 참고]

테스트 실행

이제 제일 중요한 테스트를 직접 실행해보는 시간이다. 테스트 실행은 매우 간단하게 IDE에서 할 수 있다.

.feature 파일을 보면 좌측에 테스트 실행 버튼이 있고 Scenario 옆에 있는 버튼은 해당 시나리오만, Feature 옆에 있는 버튼은 해당 .feature 파일의 모든 시나리오를 수행한다.

 

실행해보면 JUnit으로 테스트하듯 똑같이 테스트가 진행된다. 

 

결과

 

테스트 실행을 IDE로 해봤지만 Jekins Pipeline을 사용하려면 IDE로 실행하는 법만 알아선 안된다.

 

Gradle + Cucumber 테스트 실행 (CLI)

우선, 커맨드라인으로 테스트를 실행하기 앞서, Gradle을 사용할땐 자주 사용되는 실행명령어가 있으면 그것을 task로 만들어 낼 수가 있다. 그래서 이 cucumber 실행 테스트를 task로 만들어보자.

 

build.gradle

configurations {
    cucumberRuntime {
        extendsFrom testImplementation
    }
}

tasks.register('cucumberRun') {
    dependsOn assemble, testClasses
    doLast {
        javaexec {

            main = 'io.cucumber.core.cli.Main'
            classpath = configurations.cucumberRuntime + sourceSets.main.output + sourceSets.test.output
            args = ['--glue', 'kro.kr.tbell.stepdefinitions',
                    'classpath:features',
                    '--plugin', 'pretty',
                    '--plugin', 'html:build/cucumber-report.html'
            ]
        }
    }
}

이 코드는 cucumber test를 위해 build.gradle에 추가적으로 설정해야 하는 코드이다.

 

configurations {
    cucumberRuntime {
        extendsFrom testImplementation
    }
}

Gradle Configuration은 빌드 과정에서 사용되는 종속성들을 관리하는 일종의 컨테이너 역할을 한다.

 

cucumberRuntime 이라는 configuration을 만들고, extendsFrom testImplementationcucumberRuntimetestImplementation으로 선언된 모든 종속성을 상속받는다는 의미이다. 즉,  testImplementation으로 선언된 모든 라이브러리 및 파일들이 cucumberRuntime에도 속하게 된다.

 

이렇게 설정하면, Cucumber와 관련된 테스트 실행 시 필요한 모든 종속성을 cucumberRuntime에 포함시킬 수 있으며, 추가적인 종속성을 cucumberRuntime에만 지정하여 관리할 수도 있다.

 

tasks.register('cucumberRun') {
    dependsOn assemble, testClasses
    doLast {
        javaexec {

            main = 'io.cucumber.core.cli.Main'
            classpath = configurations.cucumberRuntime + sourceSets.main.output + sourceSets.test.output
            args = ['--glue', 'kro.kr.tbell.stepdefinitions',
                    'classpath:features',
                    '--plugin', 'pretty',
                    '--plugin', 'html:build/cucumber-report.html'
            ]
        }
    }
}

cucumberRun 이라는 이름의 task를 만든다.

dependsOnGradle 빌드 스크립트에서 사용되는 키워드로, 하나의 Task가 다른 하나 또는 여러 Task의 실행 결과에 의존한다는 의미이다. 즉, 지정된 Task가 실행되기 전에 의존하는 Task들이 먼저 완료되어야 한다는 것을 의미한다.

 

그럼 저기서는 assemble, testClasses 두 개의 Task들이 먼저 완료가 되어야 한다는 것을 말한다.

  • assemble: 이 Task는 일반적으로 프로젝트의 모든 메인 소스 세트를 컴파일하고, 필요한 모든 리소스를 처리하며, 실행 가능한 Artifact(예: JAR파일)를 빌드하는 데 사용된다. assemble task는 메인 소스 코드가 변경되었을 때 변경사항을 반영하여 새로운 아티팩트를 만들어낸다.
  • testClasses: 이 Task는 프로젝트의 테스트 소스 코드를 컴파일한다. 테스트를 실행하기 전에 테스트 소스 코드가 최신 상태인지 확인하고 필요한 경우 컴파일을 수행하여 테스트 실행 준비를 마친다. testClasses Task는 테스트 코드에 대한 변경사항이 있을 때마다 테스트를 다시 컴파일하여 최신 상태로 유지한다.

 

그 다음 doLast는 Gradle 태스크의 생명주기 중 '실행'단계가 끝난 후 실행할 작업을 추가하는 메서드다. 태스크가 주 작업을 완료한 후 실행되어야 하는 추가적인 작업들을 정의할 때 사용된다. 그래서 do'Last'이다. 보통은 그래서 정리 작업, 로깅, 조건부 추가 작업등을 위해 사용된다. 

 

그 안에서 javaexec가 있는데 이건 Gradle에서 Java 프로그램을 실행하기 위한 built-in 함수다. 이 메서드를 통해 Java 애플리케이션을 실행하거나, Java 기반의 커맨드라인 도구를 호출할 수 있다. javaexec 블록 내에서는 실행할 Java 클래스, 클래스패스, 프로그램 인자 등을 설정할 수 있다.

 

  • main: javaexec에서 실행할 메인 클래스를 지정한다. 여기서는 Cucumber의 커맨드라인 인터페이스인 'io.cucumber.core.cli.Main'이 지정됐다. 이 클래스는 Cucumber 테스트를 실행하는 엔트리 포인트이다.
  • classpath: 실행 시 클래스패스를 지정한다. configuration.cucumberRuntime + sourceSets.main.output + sourceSets.test.output을 통해 Cucumber 실행에 필요한 모든 종속성과 컴파일된 클래스 파일들이 포함된 클래스패스를 구성했다. 'sourceSets.main.output' 이 녀석은 프로젝트의 메인 소스 셋 (src/main/java및 src/main/resources에 위치한 소스들)이 컴파일된 후 생성된 모든 클래스 및 리소스 파일들의 출력 위치를 가리킨다. 'sourceSets.test.output' 이 녀석은 테스트 소스 셋(src/test/javasrc/test/resources)이 컴파일 된 후 생성된 모든 클래스 및 리소스 파일들의 출력 위치를 나타낸다.
  • args: 프로그램 실행 시 건네 줄 인자를 작성한다.
    • --glue: Cucumber가 스텝 정의를 찾을 수 있는 패키지 경로
    • classpath:features: Cucumber가 feature 파일들을 찾을 경로 classpath에 src/test/resources가 정의되어 있으니 그 안에 features 폴더에서 .feature 파일을 찾겠다는 의미가 된다.
    • --plugin pretty: Cucumber 실행 결과를 사람이 읽기 좋게 출력하는 플러그인을 활성화
    • --plugin html:build/cucumber-report.html: 테스트 결과를 HTML 형식으로 build/cucumber-report.html에 저장

 

이렇게 설정한 Task를 커맨드라인을 통해 실행만 하면 된다.

./gradlew cucumberRun // gradlew 또는 gradle로 실행하면 된다.

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

Appium, OpenCV를 활용한 Visual Testing  (0) 2024.04.25
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17
728x90
반응형
SMALL

IntelliJ 프로젝트 만들기

이제 IntelliJ로 프로젝트 환경 설정을 해보자. 우선, Gradle 프로젝트를 하나 만들자.

이름과 경로는 적절하게 설정을 해준다.

Build system은 Gradle로 설정하자. 난 Gradle을 좋아하니까.

 

JDK버전은 11이상이면 좋은데 난 가장 최신 버전인 21을 사용하겠다.

그 외 나머지는 기본 설정으로하고 'Create' 클릭

 

build.gradle

가장 먼저 확인할 파일은 역시 build.gradle 파일이다. 

plugins {
    id 'java'
}

group = 'kro.kr.tbell'
version = '1.0.0'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.10.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
    useJUnitPlatform()
}

 

가장 간단한 상태이다. 여기서 appium 관련 라이브러리를 추가해야한다.

Appium java client 추가하기

dependencies {
    ...
    testImplementation 'io.appium:java-client:9.2.2'
    implementation 'io.appium:java-client:9.2.2'
    ...
}

저렇게 두 개를 해놔야 테스트 파일이 아닌곳에서도 Appium을 사용할 수 있고 내가 원하는 구조 또한 그렇다. 그래서 testImplementation, implementation 모두 추가해주자.

참고로 버전은 9.2.2가 가장 최신버전이다. (2024년 4월 17일 기준)

 

 

추가하고 빌드를 하면 이렇게 External Libraries에 매우 많은 것들이 추가된다.

io.appium.java-client를 내려받기 위해 필요한 sub-dependencies가 이렇게나 많다.

Cucumber 라이브러리 추가하기

Cucumber는 BDD 개발 방법론에 맞게 작성된 Gherkin 테스트 시나리오를 실제로 실행할 수 있도록 해주는 툴이라고 했다.

그래서 이 툴 역시 내려받아야 한다.

build.gradle

dependencies {
   ...
   implementation 'io.cucumber:cucumber-java:7.4.1'
   implementation 'io.cucumber:cucumber-junit:7.4.1'
   ...
}

 

그 외 유용한 라이브러리 추가하기

Lombok, Slf4j, SnakeYAML을 설치한다.

// Lombok
compileOnly 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
testCompileOnly 'org.projectlombok:lombok:1.18.30'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.30'

//SLF4J API 모듈
implementation 'org.slf4j:slf4j-api:2.0.9'
testImplementation 'org.slf4j:slf4j-api:2.0.9'

// Logback Classic 구현 (SLF4J의 구현체)
implementation 'ch.qos.logback:logback-classic:1.4.14'
testImplementation 'ch.qos.logback:logback-classic:1.4.14'

// SnakeYAML
implementation 'org.yaml:snakeyaml:2.2'
testImplementation 'org.yaml:snakeyaml:2.2'

 

SnakeYAML은 .yaml 파일에 내가 정의한 key/value를 넣었을 때, 원하는 key에 해당하는 value를 읽어들이는 방법이다.

이렇게까지 라이브러리를 다운받으면 지금 당장 필요한 모든 라이브러리는 다 받았다. 이제 작업을 해보자.

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

Appium, OpenCV를 활용한 Visual Testing  (0) 2024.04.25
6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17
728x90
반응형
SMALL

이제 Appium Inspector를 사용해서 UI 테스트를 하고자하는 App과 연결해보자.

 

UiAutomator2 Driver 설치

실행 하기전 "UiAutomator2" Driver를 설치해야 한다.

appium driver install uiautomator2

성공적으로 설치가 되면 다음과 같은 화면이 나오면 된다.

Appim Server 실행

이제 appium을 실행한다.

appium

이러한 화면이 나오면 성공!

Appium Inspector 실행

이제 Appium Inspector를 실행한다.

 

  • Remote Host: 0.0.0.0
  • Remote Port: 4723
  • Remote Path: /
  • Capability Builder
    • appium:udid: 연결할 Device udid
    • platformName: Android
    • appium:automationName: UiAutomator2
    • appium:skipUnlock: false
    • appium:autoGrantPermissions: true

여기서 Capability Builder 옵션은 그때그때 다를 수 있다. 나는 이렇게 해도 무방하기에 이렇게 설정했다.

이 옵션들이 어떤 내용인지 더 자세히 알고 싶다면 다음 링크를 참조하자.

 

GitHub - appium/appium-uiautomator2-driver: Appium driver for Android UIAutomator2

Appium driver for Android UIAutomator2. Contribute to appium/appium-uiautomator2-driver development by creating an account on GitHub.

github.com

 

우측 하단 Start Session 버튼을 클릭하면 연결된다.

이렇게 내가 연결한 Device의 화면을 캡쳐해서 각 요소들에 대한 정보들을 뽑아낼 수 있다.

 

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
5. 프로젝트 환경 설정  (2) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17
1. BDD, Gherkin, Cucumber  (0) 2024.04.17
728x90
반응형
SMALL

App 테스트를 하려면 App을 설치할 Device가 필요하다.

이 때 두가지 경우로 나눌 수 있다.

  • Emulator: Android Virtual Device (AVD)
  • Real Device

Emulator를 사용하는 경우에는 Android Studio를 사용해서 AVD를 설치하면 된다. 이거는 이 포스팅 범주에서 벗어난 범위이기 때문에 여기에 작성하지는 않겠다.

 

Real Device를 사용하는 경우에는 USB Debugging을 활성화 시키면 된다. 나의 경우 Real Device를 사용할 것.

PC에 Real Device를 연결하고, USB Debugging 활성화가 됐다고 가정하고 시작한다.

 

Real Device에 APK 설치

우선, 내 APK 경로는 다음 경로에 있다.

/Users/choichiwon/apk/apkfile.apk

 

Real Device에 APK를 설치하는 방법은 다양한데 CLI를 통해 설치할 것.

CLI를 통해 설치하려면 Android SDK가 설치되어 있어야 한다. (물론 설치를 떠나서 Appium과 Andorid App을 사용해서 테스트 하려면 무조건 필요하다).

Android SDK 설치 방법
가장 쉬운 방법은 Android Studio를 설치하고, 이 경로에서 원하는 버전으로 설치하면 된다.
Settings -> Appearance & Behavior -> System Settings -> Android SDK

설치할 때 이 두가지를 설치하면 된다. 
- Android SDK Platform
- Android SDK Platform-Tools

 

설치가 다 끝나면 Platform-tools가 위치한 경로가 있다. 나의 경로는 다음과 같다.

/Users/choichiwon/Library/Android/sdk/platform-tools


여기에 "adb"라는 도구가 있다. 이건 Android Debug Bridge의 약자로 이 도구는 개발자가 Android 기기와 상호작용할 수 있도록 하는 명령줄 도구인데 이 도구를 이용해서 현재 연결된 디바이스 리스트를 가져올 수 있다.

./adb devices

 

연결된 디바이스 하나가 보인다. 이렇게 디바이스가 나오면 "adb"를 통해서 이 디바이스와 통신을 할 수 있다.

"adb" 명령어를 이용해서 apk를 설치해보자.

./adb install -r /Users/choichiwon/apk/apkfile.apk

이 명령어가 "adb"를 이용해서 apk를 설치하는 명령어이다. 경로는 당연히 apk가 있는 경로를 지정해주면 되고, 디바이스가 하나만 연결되어 있으면 디바이스 정보를 주지 않아도 된다. "./adb devices" 를 입력했을 때 여러 디바이스가 나오면 어떤 디바이스에 설치할지 명시해줘야 한다. 아래가 그 예시이다.

./adb -s HVA1FG23 install -r "apk file path"

 

옵션 정보는 다음과 같다.

  • -r: replace. 이미 설치되어 있으면 지우고 재설치, 없으면 그냥 설치

입력하면 이러한 문구가 나온다.

Success가 나오면 정상 설치가 된 것.

 

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
2. Appium  (0) 2024.04.17
1. BDD, Gherkin, Cucumber  (0) 2024.04.17
728x90
반응형
SMALL

멀티 플랫폼(Web, Mobile, Desktop, ...)을 대상으로 UI 자동화 테스트를 할 수 있게 해주는 오픈 소스 프로젝트인 Appium.

이 Appium을 사용해서 Mobile App 자동화 테스트를 진행해 볼 생각이다.

 

Appium Install

우선, Appium을 사용하려면 설치를 해야한다.

 

Install Appium - Appium Documentation

Install Appium You can install Appium globally using npm: Note Other package managers are not currently supported. After installation, you should be able to run Appium from the command line: You should see some output that starts with a line like this: [Ap

appium.io

이 링크에서 Appium을 설치할 수 있다. 간단하게는 NPM이 설치되어 있다면 다음 명령어를 실행하자.

npm i -g appium

 

설치가 다 되면 다음 명령어를 실행해서 정상적으로 설치됐는지 확인해보자.

appium -v

Upgrade Appium version

Appium이 설치되어 있는 상태인데, 버전이 Outdate 상태라면 다음 명령어로 업데이트해주자.

npm update -g appium

 

Appium Inspector

Appium을 사용하면 반드시 같이 사용할 수 밖에 없는 GUI Inspector tool.

좋은점은 모든 종류의 Mobile App을 지원한다는 것이고 무료이다.

 

요래 생긴 툴이다.

 

Install

 

Releases · appium/appium-inspector

A GUI inspector for mobile apps and more, powered by a (separately installed) Appium server - appium/appium-inspector

github.com

이 링크에서 OS별, 버전별로 설치가 가능하다. 가장 최신의 버전을 선택해서 내려받으면 된다.

나의 경우 MacOS이기 때문에 .dmg 파일을 내려받아서 설치했다.

 

그리고 설치하고 실행하면 이 경고 문구가 나온다.

이제 다음 명령어를 수행하면 된다. 

xattr -cr "/Applications/Appium Inspector.app"

 

요 명령어를 수행하면 정상적으로 실행이 될 것이다. 아니면 System Settings > Privacy & Security 탭에 가서 하단에 Security 쪽에 보면 "Appium Inspector가 검사되지 않은 앱인데 실행을 할거냐?" 뭐 이렇게 물어보는데 [Open Anyway] 버튼 클릭하면 된다.

 

실행한 화면은 다음과 비슷하면 된다.

 

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

6. Appium과 Cucumber를 이용해 UI Automation Testing  (0) 2024.04.17
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
1. BDD, Gherkin, Cucumber  (0) 2024.04.17

+ Recent posts