Type Safety, 말 그대로 "타입에 안정적인 것"이라는 뜻이다. findViewById의 문제점으로 꼽히는 것 중 Type Safety하지 못하다는 것이 있는데 타입에 불안정하다는 것은 코드 작성 중에 참조 변수에 잘못된 타입을 초기화했으나 컴파일 과정에서 발견하지 못해 Runtime 중에 오류가 발생할 수 있다는 것을 의미한다. 어떤 까닭으로 findViewById가 Type safety하지 못하다고 하는지 그 이유에 대해서 알아보자.

 

 

 findViewById 메서드를 살펴보면 위와 같다. 다른 것을 제외하고 반환형을 보면 View 클래스를 상속받은 클래스 타입을 반환하도록 되어 있다. 여기서 View는 화면을 구성할 때 사용하는 모든 클래스의 최상위 클래스로 화면을 구성하는 TextView, EditText, ImageView 등 모든 클래스가 View 클래스를 상속받는다. 위와 같은 구조는 다음과 같은 오류를 발생시킬 수 있다.

 

 

EditText editText = findViewById(R.id.main_edit_text
ImageView imageView = findViewById(R.id.main_edit_text);

 activity_main.xml에 EditText view를 작성하고 아이디 "main_edit_text"를 할당했다. MainActivity.java에서 EditText 객체와 ImageView 객체를 선언하고 findViewById를 통해 레이아웃에서 생성한 EditText view를 초기화해보자. 첫 번째 코드는 정상적이기 때문에 문제가 없다. 하지만 두 번째 코드는 타입이 ImageView인 참조 변수에 EditText를 초기화하고 있으니 문제가 발생할 것 같지만 이 코드는 문제가 없는 정상적인 코드다. 왜냐하면 ImageView 클래스는 View 클래스를 상속받고 있기 때문에 findViewById 반환형에 적합하기 때문이다. 즉 코드 문맥상 문제가 없기 때문에 위 코드는 초기화하는 것만으로는 에러로 인식되지 않는다.

 

 문제는 imageView 객체에 참조하는 순간 발생한다. 빠르면 어플을 실행하면서 Activity가 초기화되는 과정에서 발견될 것이고 아니라면 어플을 사용하는 과정에서 에러를 발생시킬 것이다. 이렇게 컴파일 과정에서 잘못된 타입을 체크하지 못해서 런타임 중에 에러가 발생하는 것을 "타입에 불안정하다"라고 한다.

 

 

 findViewById가 Type Safety하지 못한 이유에 대해서 알아봤다. 그렇다면 바인딩 방식은 findViewById와 무엇이 다르기에 Type Safety한 것인지 보자. 위 코드를 보면 바인딩 객체에서 mainEditText에 접근할 경우 <T extends View>와 같은 타입이 아닌 정확히 AppCompatEditText 객체를 반환해주고 있음을 볼 수 있다. 또 findViewById를 초기화할 땐 코드 문맥상 문제가 없기 때문에 에러를 잡지 못하지만 바인딩 객체는 정확한 타입의 객체를 반환함으로써 초기화려하는 객체의 타입과 반환하려는 객체의 타입이 다르다는 에러 메시지를 출력하고 있음을 볼 수 있다.

 안드로이드에서는 레이아웃(*.xml)에 작성한 View를 참조할 때 사용하는 가장 기초적인 방법으로 findViewById 메서드를 제공한다. 안드로이드 초기부터 있었던 방법이지만 여러 가지 문제점이 거론되기 시작하면서 다른 방법들로 대체되어 왔고 현재는 DataBinding과 ViewBidning을 가장 많이 사용한다. findViewById를 사용되지 않는 이유 중 하나로 "Null Safety 하지 않다"는 것이 있는데 앞서 말한 두 방식은 이 문제점을 보완하고 있다. Null safety는 무엇인지, findViewById에서 이 문제가 생기는 이유는 무엇인지, 바인딩 방식은 이 문제를 어떻게 보완했는지에 대해서 알아보자.

 

 

 Null Safety(널 안전성)란 참조하려는 객체가 null일 경우 발생할 수 있는 Null Pointer Exception(이하 NPE)으로부터 안전한 코드라는 의미를 가진다. Null Safety의 의미와 앞서 말한 "findViewById가 Null safety하지 않다"는 말의 의미를 다시 해석해보면 "findViewById의 수행 결과로 얻은 객체는 Null로부터 안전하지 않다"고 바꿔말할 수 있을 것이다.

 

 

 findViewById는 "R.id."과 같은 방식으로 현재 프로젝트에 있는 거의 모든 id에 접근할 수 있으며 파라미터로 전달한 id에 대한 view 객체를 반환한다. 이 방식은 말 그대로 모든 id에 접근할 수 있기 때문에 현재 레이아웃에 존재하지 않는, 즉 다른 레이아웃의 view에도 접근할 수 있다.

 

 

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
}

 Activity를 생성하면 기본적으로 콜백 메서드인 onCreate가 주어지는데 두 번째 라인에서 setContentView를 호출하며 레이아웃 id를 파라미터로 전달한다. 이 과정은 Activity의 레이아웃을 객체화하는 과정으로서 이 과정을 정상적으로 거쳐야 화면이 그려짐은 물론, findViewById로 객체에 대한 참조를 얻어와 사용할 때 NPE가 발생하지 않는다. 하지만 아직 생성되지 않은 레이아웃의 View를 호출하면 어떻게 될까?

 

 

TextView tv = findViewById(R.id.sub_activity_text_view);

 MainActivity에서 SubActivity에 있는 textView에 접근해보자. SubActivity는 아직 메모리에 올라가있지 않기 때문에 NPE가 발생해야 할 것이다. 하지만 초기화만 해서는 빌드 단계에서 에러가 잡히지 않는다.

 

 

TextView tv = findViewById(R.id.sub_activity_text_view);
tv.setText("Hello");

 문제는 위와 같이 참조한 객체를 사용할 때 발생한다. setText를 하기 위해서 TextView 객체를 참조했지만 해당 View는 아직 생성되지 않았기 때문에 NPE가 발생한다. 이 문제가 빌드 단계에서 잡히거나 테스트 단계에서 발견될 경우 에러를 초기에 해결할 수 있겠지만 만약 테스트 과정에서도 발견되지 않고 넘어갈 경우 사용자에게 있어서 어플이 강제 종료될 수도 있는 잠재적인 위협이 된다.

 

 

 이것이 findViewById가 Null safety하지 못한 이유다. 반면 데이터 바인딩은 setContentView로 생성한 레이아웃에 있는 View들에 밖에 접근할 수 없다. findViewById와 다르게 접근 범위를 setContentView를 호출하며 전달한 레이아웃(id)으로 제한한 것이다. 

 메모리에 이미지들에 대한 정보(URL, Bitmap, File, etc..)를 가지고 있다가 ImageView의 좌측 영역을 터치하면 이전 이미지를, 우측 영역을 터치하면 다음 이미지를 보여 주는 이른바 이미지 뷰어 기능을 구현하고 있었다. 구현 자체는 매우 간단했지만 현재 이미지에서 다른 이미지를 불러올 때 로딩하는 시간 때문인지 약 0.5초 정도 빈 화면이 표시된 뒤 이미지를 제대로 불러왔다. 걸리는 시간도 문제였지만 화면이 단색이었기 때문에 전환하는 과정에서 화면이 깜빡거리는 느낌을 줘서 보기 불편했다는 점이다.

 

 서버에서 받아오는 이미지의 용량이 문제인가 싶어서 100kb 이하 크기의 이미지를 여러 개 준비하여 로드해보기도 하고 차이를 보기 위해서 5mb부터 최대 20mb 크기의 이미지를 로드해보기도 했는데 용량에 따른 로딩 속도 차이는 조금 있었지만 화면의 깜빡임은 그대로였다. 이미지를 URL로 바로 로드하는 기존 방식에서 Bitmap 객체에 로드하고 그대로 불러오는 방법, 캐시 메모리에 다운로드 했다가 로드하는 방법 등을 모두 시도해봤지만 깜빡거리는 현상은 해결되지 않았다.

 

 이제껏 사용한 어플들에는 이미지 용량이 커서 로딩이 느린 적은 있어도 이렇게 깜빡거리는 현상은 본 적이 없었기에 굉장한 이질감과 불편함을 느꼈다. 용량에 따른 딜레이는 존재하더라도 최소한 이미지 전환에 있어서는 스무스하게 바뀌어야 사용자 입장에서 불편하다고 느끼지 않을 것이라 생각했기 때문에 방법을 찾다가 CustomTarget 클래스를 사용하는 방법을 찾았다. CustomTarget를 사용하는 방식은 into에 이미지를 표시할 객체에 대한 참조를 주는 게 아닌 CustomTarget 클래스를 구현하여 이미지를 로딩한다. 예전에 SimpleTarget 클래스(지금은 사용이 중지된 듯)를 사용해서 구현한 적이 있었지만 바로 view에 대한 참조를 넣는 것과 타겟 클래스를 사용하는 것의 차이를 몰랐기 때문에 잊어버리고 있었던 것 같다. 

 

 


 

 

 

 깜빡거리는 현상이 발생하는 코드(간략화한 예시)

Glide.with(this)
        .load(url)
        .into(binding.imageView);

 

 

 CustomTarget 적용

 URL 로딩

Glide.with(this)
        .asBitmap()
        .load(your_url)
        .into(new CustomTarget<Bitmap>() {
            @Override
            public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
                binding.imageView.setImageBitmap(resource);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {
                //
            }
        });

 

 

 내부 저장소, 혹은 캐시 저장소에 있는 파일 로딩

Glide.with(this)
        .asFile()
        .load(file.getPath())
        .into(new CustomTarget<File>() {
            @Override
            public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
                Bitmap bitmap = BitmapFactory.decodeFile(resource.getPath());
                binding.imageView.setImageBitmap(bitmap);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {

            }
        });

 

 

 용량 문제

 서버에서 받아오는 이미지의 용량이 문제인가 싶어서 100kb 이하 크기의 이미지를 여러 개 준비하여 로드해보기도 하고 차이를 보기 위해서 5mb 크기의 이미지를 로드해보기도 했다. 먼저 20mb 크기의 고용량 이미지를 구해서 로드했더니 약 1초 정도의 로딩 시간이 발생하고 10mb는 0.5초 정도, 5mb는 딜레이가 거의 없었다. CoustomTarget을 사용하지 않고 다이렉트로 view의 참조를 줬을 때랑 로딩 속도는 차이가 없었지만 깜빡임이 사라진 것을 확인할 수 있었다. 이미지 용량이 커서 늦게 로딩될 때는 기존 이미지를 표시하고 있는 상태에서 비동기로 로드가 완료되면 이미지가 전환되었다. 이미지가 아예 사라졌다가 다음 이미지를 불러오는 이전과는 확연히 달랐다.

 


 

 Gif 로딩에 관한 기록

Glide.with(this)
        .asGif()
        .load(url)
        .into(new CustomTarget<GifDrawable>() {
            @Override
            public void onResourceReady(@NonNull GifDrawable resource, @Nullable Transition<? super GifDrawable> transition) {
               	binding.imageView.setImageDrawable(resource);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {

            }
        });

 서버에서 이미지를 로드할 때 여러 포맷을 테스트하였는데 JPG, JPEG, BMP, PNG 등은 문제없이 로드 되었지만 GIF만큼은 제대로 로드되지 않았다. 정확히는 into에 다이렉트로 view의 참조를 줄 경우 Gif의 특성인 애니메이션이 원활하게 동작했지만 이미지를 전환할 때 깜빡거리는 현상은 여전했고 이를 해결하기 위해서 CustomTarget을 사용하면 애니메이션이 동작하지 않았다. 테스트에 사용한 Gif 이미지들의 용량은 500kb~3mb이며 코드는 위와 같다.

 

 이미지 전환에 있어서 매끄럽고 빠르게 Gif를 로딩하려면 ImageView+Glide의 조합으로는 무리라는 생각이 들었다. 다른 이미지 형식의 경우 왠만큼 용량이 높아도 메모리 상에(Bitmap 객체) 저장하고 불러오거나 캐시 디렉터리에 저장한 후 불러오면 스무스하게 로드되었다. 하지만 Gif는 Bitmap으로 저장하면 애니메이션이 제대로 동작하지 않고 이를 저장하기 위한 클래스 역시 GifDrawable 정도 뿐이었다. GifDrawable 타입의 리스트를 생성, 서버에서 읽은 gif 파일을 저장한 뒤 불러오기하면 Bitmap 객체 때와 마찬가지로 애니메이션이 동작하지 않았고 파일로 저장하고 이미지 전환을 시도하면 깜빡임을 없앨 수 없었다.

 

 2개 이상의 Gif를 화면에 표시하려면 깜빡이는 문제를 감안하더라도 ImageView+Glide를 사용하거나 ViewPager2를 사용하는 것좋을 듯 하다. 나중에 ImageView+Glide로 스무스하고 빠른 Gif 이미지 전환 방법을 알게 되면 이 파트는 따로 빼내서 포스팅할 예정이다. 

[maxLines] 최대 라인 수 지정하기

 기존 singleLine 속성이 deprecated 됨에 따라 maxLines 속성을 사용한다. 값으로 1을 주면 singleLine 속성을 지정한 것처럼 사용할 수 있다.

<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:maxLines="1"
android:text="Hello world"/>

 

 

[ellipsize] 뷰에 표시된 텍스트에 생략 부호 "..." 넣기

 텍스트 뷰에서 정해 놓은 라인 수 혹은 뷰의 크기를 초과할 경우 생략 표시를 추가하기 위해서는 ellipsize 속성을 사용한다. 사용할 수 있는 옵션은 start, middle, end, marquee, none 등이 있다. "..."이 표시되는 위치를 지정하는 값으로서 end를 사용하면 우리에게 가장 익숙한 "안녕하세요. 이 이야기는..."과 같이 문장의 끝 부분이 생략된다. 값의 이름이 직관적이기 때문에 end 이외 설명은 생략한다.

<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:maxLines="1"
android:ellipsize="end"/>

 

 

[gravity] 뷰 내에서 텍스트 위치 지정하기

 뷰 내에서 텍스트의 위치를 지정하려면 gravity 옵션을 사용한다. 기본값은 "start | top"으로 좌측 상단으로 되어 있으며 텍스트의 위치는 2가지 이상의 값을 조합해서 만들 수 있다. 예를 들어 텍스트를 뷰의 수직의 중간에 정렬하면서 우측으로 붙이고 싶다면 "end | center_vertical"을 사용할 수 있다. 논리 연산 기호인 OR의 문자 혹은 파이프 문자인 " | "를 사용하여 2개 이상의 값을 중첩한다.

 

 참고로 덧붙이자면 right와 start, left와 end는 같은 값이며 파이프 문자로 두 개 이상의 값을 동시에 사용할 때 파이프라인 앞뒤로 공백을 넣어선 안된다.

 

 속성에 사용할 수 있는 값

  • top, bottom, start(right), end(left)
  • center, center_vertical, center_horizontal
<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="start|center_vertical"
android:text="Hello world"/>

 

 집에 스프링으로 서버를 만들어 놓고 안드로이드로 접속하는 형태로 쓰고 있었는데 간만에 서버를 켜서 접속하니 타임 아웃을 발생시키며 접속이 되질 않았다. 서버를 킨 컴퓨터에서 크롬으로 접속하니 문제가 없는 걸로 보아 서버에 문제는 없어 보이고 외부에서 접속이 안되는 것 같았다. 혹시나 하여 방화벽을 일시적으로 해제하고 안드로이드에서 접속을 시도했더니 접속되었다. 하지만 개인 컴퓨터에 편의상 서버를 열어놓고 혼자 쓰는 터라 방화벽을 마냥 열어 놓을 수는 없었다.

 

 이 문제를 해결하기 위해서 시도한 방법은 아래 두 가지다.

 

  1. 인바운드 규칙에서 서버로 사용 중인 포트 번호 허용시켜주기
  2. Java(TM) Platform SE binary 연결 허용하기

 

 

 

 포트 번호 허용하기

 [제어판 → Window Defender  고급 설정] 으로 들어간다. 제어판은 시작을 누른 뒤 바로 "control"을 입력하거나 실행에서 "control"을 입력하면 들어갈 수 있다.

 

 

 인바운드 규칙에서 우클릭하여 [새 규칙] 선택.

 

 

규칙 종류에서 포트 선택.

 

 

 서버에 사용 중인(혹은 사용 예정인) 포트 번호 입력.

 

 

 연결 허용(기본값)을 선택 후 다음.

 

 

 네트워크 환경에 맞게 선택한다.

 

 

 규칙을 식별하기 위해서 인바운드 규칙의 이름을 지정한다. 설명은 굳이 적지 않아도 되지만 코드의 주석처럼 나중에 이 규칙을 왜 생성했는지 혹은 규칙에 대한 정보를 기록하는 용도로 사용할 수 있다.

 

 

 이름 좌측에 초록색 체크 아이콘이 있다면 규칙이 적용된 것이고 만약 체크 아이콘이 없다면 우클릭하여 규칙 사용을 눌러주면 된다.

 

 결과는 실패다. 몇 년 전에 서버를 연습하면서 지금과 같은 문제를 해결한 적이 있어서 시도해보았는데 이번엔 이게 문제가 아니었나보다.

 

 

 

 Java(TM) Platform SE binary 연결 허용하기

 다시 인바운드 규칙으로 돌아온다. 이름 순으로 정렬하여 J로 시작하는 규칙 중에서 "Java(TM) Platform SE binary"를 찾는다. 설정을 따로 건드린 적이 없기 때문에 아마도 기본값이라고 생각되는데 TCP 프로토콜에 해당하는 두 규칙에 차단 아이콘이 표시되어 있는 것을 볼 수 있다. 두 규칙에서 차이는 적용할 프로필 정도로 보이는데 공용과 개인으로 나누어져 있다. 네트워크 환경에 따라 하나 혹은 두 규칙 모두 허용해 줄 것이다. 필자의 경우 소규모 개인 서버이기 때문에 4번 째 개인에 해당하는 규칙을 허용해 줄 것이다.

 

 사진 상에 4번째에 해당하는 프로필이 개인, 프로토콜이 TCP인 값을 우클릭하여 속성에 들어간다.

 

 

 아래 작업 란을 보면 연결 차단으로 지정되어 있는 것을 볼 수 있다. 연결 허용으로 바꾸어 준 뒤 확인을 누른다.

 

 

 초록색 체크 아이콘이 즉, 규칙이 활성화된 것을 확인했다면 외부에서 접속을 시도해보자. 필자는 이걸로 해결되었다.

 

 


 

 

 갑자기 외부 접속이 막힌 이유를 추측하건데 며칠 전에 게임 때문에 설치한 유료 VPN이 무슨 설정을 건드린게 아닌가 싶다. 서버를 공부하고 사용한 햇수만 5년 정도가 되었는데 포트 규칙 추가 말고는 단 한 번도 방화벽을 건드린 적이 없었기 때문에 이번 문제를 해결하는데 시간이 좀 걸린 것 같다.

JOptionPane

 자바 Swing에서 경고창 혹은 의사 결정(확인/취소)을 위한 확인창으로 JOptionPane이 존재한다. 위 캡쳐에 나온 화면은 ErrorMessage 아이콘을 사용한 것인데 이 아이콘을 커스텀해보자.

 

 

 

 위는 자바에서 기본적으로 제공하는 아이콘이다. 나는 개인적으로 쓰는 프로그램에 경고창을 띄우던 중 기본 아이콘들이 너무 옛날 느낌을 주기도 하고 상황에 맞게 사용할 수 있는 아이콘이 매우 제한적이라는 생각이 들어 커스텀을 찾아보게 되었다. 

 

 

import java.awt.Image;

import javax.swing.ImageIcon;
import javax.swing.JOptionPane;

public class JOptionPaneTest {
	public static void main(String[] args) {
		ImageIcon originIcon = new ImageIcon("res//arrow_cycle.png");
		Image resizeImg = originIcon.getImage().getScaledInstance(30, 30, Image.SCALE_SMOOTH);
		ImageIcon icon = new ImageIcon(resizeImg);
		JOptionPane.showConfirmDialog(null, "다시 실행하시겠습니까?", "확인", JOptionPane.YES_NO_OPTION, JOptionPane.PLAIN_MESSAGE, icon);
	}
}

 메시지 창을 띄우기 위해 굳이 JFrame까지 사용할 필요는 없다. 위처럼 해주기만 해도 메시지 창을 띄울 수 있다. 테스트에 사용된 이미지 파일은 프로젝트 폴더에 "res" 폴더를 생성한 뒤 안에 넣어주었다.

 

 

 ImageIcon 객체를 생성하고 프로젝트 폴더에 추가한 이미지 파일을 불러온다. 예제에 사용한 이미지 파일은 메시지 창에 쓰기에는 꽤나 크기 때문에 그대로 사용하면 이미지 크기가 들어가는 만큼 메시지 창이 컵져 버리기 때문에 조절이 필요하다. 아이콘으로 쓰기 적당한 파일 크기는 30x30 ~ 50x50이기 때문에 적절히 값을 바꾸어 가며 맞는 값을 찾으면 된다. 코드에서 리사이즈 작업을 해줘도 되지만 그림판, 포토샵 등으로 이미지 파일의 자체 크기를 조정해줘도 된다.

 

 아이콘을 설정했으면 JOptionPane에서 원하는 타입의 다이얼로그(메서드)를 선택한 다음, Icon 객체를 파라미터로 받는 (오버로딩된)메서드를 선택하고 인자로 ImageIcon 객체를 넘겨준다. 그리고 프로그램을 실행하면 아래와 같은 결과를 얻을 수 있다.

 

 

 

 

 JTable에 있는 값을 더블 클릭했을 때 해당하는 값을 복사해보자. 위 gif에서는 커서가 보이지 않기 때문에 무엇을 클릭하는지 자세히 보이지 않을 수 있는데 자세히 보면 클릭되는 셀의 테두리가 굵게 되는 것을 볼 수 있다.

 

 기본적으로 JFrame, JTable을 다룰 줄 알아야 하고 MouseListener 혹은 MouseAdapter를 사용할 줄 알면 좋다. 전체 코드를 포스팅에 포함시킬 예정이지만 테이블 생성에 관한 기본적인 설명은 하지 않는다.

 

 MouseListener는 인터페이스이기 때문에 마우스의 핵심적인 이벤트(드래그, 이동, 클릭 전후) 메서드를 강제적으로 구현해야 하지만 아답터를 쓰면 필요한 메서드만을 오버라이드하여 사용할 수 있다. 현재 구현에 있어서는 클릭한 후 이벤트만 필요하기 때문에 리스너가 아닌 아답터를 사용하여 구현할 것이다. 아답터를 사용할 줄 모른다면 리스너로 구현해도 문제없다.

 

 테이블에서 선택된 값, 문자열을 복사하면 클립보드(Clipboard)에 저장된다. 클립보드에 대해 깊게 알 필요는 없고 우리가 복사한 값이 클립보드에 저장되고 복사한 값을 읽어올 때에는 붙여넣기(혹은 Ctrl+V)를 통해 클립보드에서 꺼내온다는 것만 알면 되겠다.

 

 

 아래는 코드다.

 

import java.awt.Color;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;

import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.table.DefaultTableModel;

public class MyFrame extends JFrame{
	private JTable jtab;
	private DefaultTableModel dtm;

	private Clipboard clipboard;
	
	private ArrayList<Person> list;
	
	private final String[] TABLE_HEADER = {"No", "Name", "Age"};

	public static void main(String[] args) {
		MyFrame myFrame = new MyFrame();
		myFrame.setVisible(true);
	}
	
	public MyFrame() {
		setTitle("MyFrame");
		setBounds(10, 10, 300, 200);
		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		getContentPane().setBackground(Color.white);
		setLayout(null);
		
		clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
		
		setComponent();
		setTable();
	}
	
	private void setComponent() {
		dtm = new DefaultTableModel(TABLE_HEADER, 0) {
			@Override
			public boolean isCellEditable(int row, int column) {
				return false;
			}
		};
		
		jtab = new JTable(dtm);
		jtab.getTableHeader().setReorderingAllowed(false);
		jtab.getTableHeader().setEnabled(false);
		jtab.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
		jtab.setRowHeight(25);
		jtab.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				super.mouseClicked(e);
				if (e.getClickCount() == 2) {
					int row = jtab.getSelectedRow();
					int col = jtab.getSelectedColumn();
					StringSelection value = null;
					switch(col) {
					case 0:
						value = new StringSelection(list.get(row).no + "");
						break;
					case 1:
						value = new StringSelection(list.get(row).name);
						break;
					case 2:
						value = new StringSelection(list.get(row).age + "");
					}
					clipboard.setContents(value, value);
				}
			}
		});
		
		JScrollPane tablePan = new JScrollPane(jtab, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
				JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
		tablePan.setBounds(10, 10, 265, 140);
		add(tablePan);
	}
	
	private void setTable() {
		list = new ArrayList<>();
		list.add(new Person(1, "김철수", 21));
		list.add(new Person(2, "이영미", 22));
		list.add(new Person(3, "신지수", 19));
		
		for(Person person : list)
			dtm.addRow(person.toArray());
	}
	
	class Person {
		int no, age;
		String name;
		
		public Person(int no, String name, int age) {
			this.no = no;
			this.name = name;
			this.age = age;
		}
		
		public Object[] toArray() {
			return new Object[] {no, name, age};
		}
	}
}

 

 기본적인 코드만 존재하기 때문에 JTable을 한 번이라도 생성해봤다면 코드를 어렵지 않게 이해할 수 있을 것이다. 데이터 클래스인 Person의 경우 예제 코드를 줄이기 위해서 getter를 생략하였다. 핵심은 멤버 변수인 Clipboard 객체와 JTable 객체에 마우스 이벤트를 추가한 addMouseListener 메서드가 되겠다.

 

 JTable에 추가된 값을 마우스로 클릭했을 때 이벤트를 발생시키기 위해서 MouseAdapter를 추가한 뒤 마우스 이벤트 중 클릭이 때어진 순간 이벤트를 발생시키는 mouseClicked 메서드를 오버라이드 한다. 여기서 더블 클릭을 구현하기 위해서 getClickCount를 통해 연속으로 클릭된 횟수를 체크하여 2번 클릭되었을 경우에 이벤트를 처리한다.

 

 더블 클릭이 발생하면 먼저 클릭된 행(row), 열(column)의 인덱스를 구한다. 행은 리스트의 인덱스로 사용되고 열은 리스트에서 읽어온 객체에서 특정한 값을 읽는데 사용될 것이다. 값을 읽었으면 StringSelection 객체에 초기화한 후 해당 객체를 클립보드에 세팅한다. 여기까지 문제없이 수행되었다면 클립보드에 값이 복사되었을 것이다. 텍스트를 입력할 수 있는 곳(메모장 등)에다가 "붙여넣기" 혹은 Ctrl+v를 수행하면 값이 복사된 것을 확인할 수 있다.

 안드로이드와 스프링을 연동한 어플을 만들다가 간만에 이 에러를 만났다. 이 에러는 보안이 적용되지 않은 HTTP 프로토콜로 통신하려할 떄 만날 수 있는 에러이다. 에러 메시지를 살펴보면 "CLEAR TEXT"라는 단어가 등장하는데 이는 직역하면 "평문"이며 암호학에서 암호화되지 않은 정보를 의미한다. 에러 메시지는 말 그대로 "네트워크 보안 정책에 의거하여 로컬 호스트에 대한 평문 전송을 허용할 수 없다"는 의미다. 안드로이드는 9(Level 28)부터 HTTP에 대한 통신을 기본적으로 불허하고 있다. 앞에 한 말들은 HTTP, HTTPS에 대한 개념에 대해 알고 있다면 어렵지 않게 이해할 수 있다. 이해가 되지 않는다면 HTTPS 혹은 SSL(TLS)에 대해서 알아보고 오자.

 

 

 

 이 에러의 해결 방안을 검색하면 요청할 URL의 프로토콜을 HTTP에서 HTTPS로 바꾸어주라는 것을 볼 수 있다.

 

 

 

 데이터(API)를 요청하려는 서버가 공식적으로 API를 제공하고 있다면 프로토콜을 바꾸어주는 것으로 문제가 해결될 것이다. 하지만 요청하려는 서버가 개인(혹은 자신)이 만든 서버이고 보안 설정을 해두지 않았다면 다시 에러 메시지(Failed to connect to localhost/127.0.0.1:8012)를 마주하게 될 것이다. 처음 발생한 에러 메시지는 HTTP 말고 HTTPS를 사용하라는 의미이긴 하지만 서버 단에서 HTTPS에 대한 보안이 설정되어 있지 않다면 클라이언트인 안드로이드 측에서 HTTPS로 데이터를 요청해도 서버와 통신이 불가능한 것이다.

 

 

 기본적으로 서버는 보안이 제대로 적용되어 있어야 한다. 하지만 서버에 대해 익숙하지 않은 사람이나 스프링으로 과제물을 만드는 학생들, 단순히 공부 과정에서 테스트만 하고 싶은 사람에게 보안까지 적용하는 건 다소 무리일 수 있기에 HTTP를 사용할 수 있는 대안이 존재한다.

 

<application
	..
	android:usesCleartextTraffic="true">

 manifests 파일의 <application> 태그 내에 위처럼 android:usesCleartextTraffic="true" 한줄을 추가해주면 통신을 할 때 HTTP 프로토콜을 사용할 수 있다.

 

 

 혼자 사용하는 내부 서버나 과제물같은 수준이라면 위와 같은 설정을 적용시켜 사용해도 문제없다. 필자가 이 에러를 처음 본 건 몇 년 전이지만 가끔 프로젝트를 새로 만들고 테스트할 때 이 에러를 만나기 때문에 뇌도 돌릴 겸 가볍게 포스팅해보았다. 스프링 프로젝트를 기준으로 HTTPS 보안 설정을 하는 포스팅을 할 기회가 있다면 여기에 추가하도록 하겠다.

 

 포스팅이 도움이 되었다면 좋겠습니다.

+ Recent posts