정의된 RESTful API를 구현하는 도중 Client측에서 요청한 정보에 대해 결과를 모두 반환하지 않고 중간에 글자가 잘리는 버그가 있었다.
해당 버그를 해결하지 않은 시점에서 본 게시글을 작성하기 시작했는데, 버그는 쉽게 해결됐다.
버그의 발생은 라즈베리파이에서 MkWeb의 RESTful API를 사용할 경우 모두 출력되지 않고 종료되는 (성능상의 이슈로 인해) 문제가 있었는데, 해당 문제를 해결하기 위해 PrintWriter의 버퍼를 일정 주기를 내가 강제하여 flush 하였더니, 기존에 잘 동작하던 플랫폼에서도 해당 이슈가 발생하였다.
flush를 지워봐도 해당 문제가 지속되어, flush를 내장하고 있는 PrintWriter의 close() 메소드를 사용해보니 이유는 모르겠지만 기존 플랫폼에서는 버그가 해결됐다.
아무리 생각해봐도 flush를 강제적으로 해주면, 성능 저하의 이슈가 발생할지라도 정상적으로 출력돼야 하는데 잘 모르겠다.
해당 문제의 원인을 알아내면 이어쓰도록 하겠다.
2021/01/07 21:32분 수정
오류의 원인을 발견하였다. HttpServletResponse 객체의 헤더 중 Content-Length를 직접 설정해 주었는데, 이로 인해 response의 PrintWriter 객체가 내가 Content-Length를 설정한 만큼만 출력하게 되어 있었다. 따라서 실제 출력하고자 하는 내용이 모두 출력되지 않는 버그가 간간히 있었다.
RESTful API PUT method를 어떻게 구현할지 많이 고민 했다. RESTful API에 대한 정보를 찾아봐도, 특성만 나타나 있지 실제로 어떤식으로 구현해야할지는 없었다.
MkWeb의 초기 버전은 RESTful API의 성능을 떠나서 "동작하는 기능"으로써의 구현을 진행하고 있기 때문에, 개발 및 테스트 하면서 원리를 이해하고, 앞으로의 수정방향을 잡고 있기 때문에 일단 구현해 봤다.
MkWeb에서 PUT Method는 다음과 같이 정의 하였다.
삽입 / 수정, 즉 UPDATE를 담당하는 METHOD
PUT Method에 관한 응답 및 처리를 찾아 봤을 때, 어떤 글은 GET과 같은 조회와 POST의 삽입의 기능을 담당한다.
또 어떤 글은 POST의 삽입 기능과 SQL의 UPDATE문을 담당한다는 등 크게 두 파로 나뉘어 있었다.
그래서 사실 조회의 경우 URL을 통해 쉽게 할 수 있으니, 삽입과 수정의 기능을 바탕으로 만들었다.
그리고 수정의 경우, PATCH로써 동작하게끔 했다. ( PATCH 를 넣으니, 톰캣의 버전 때문인지 정상적인 method가 아니라며 실행되지 않았기 때문. 후에 가능하다면 MKWeb을 업데이트 할 때 PATCH를 새로 만들 예정이다.)
조회와 삽입을 구분하는 기준은 다음과 같이 정했다.
URI에 수정하고자 하는 대상의 조건을 보내고, Body Parameter에 수정 내용을 입력한다. 이 때, 대상이 존재하지 않으면 삽입, 존재하면 데이터를 수정한다.
이렇게 정의하고 나니 한결 쉬워졌는데, 데이터 입력 및 수정 할 때, 삽입/수정하고자 하는 column이 Not Null이고 Deafult 값이 없거나 하는 경우에는 오류가 발생해서 난감했다.
사실 이 문제는 RESTful API의 본질적인 기능을 잊고, 기능 구현에 집중하다보니 발생한 문제였는데, message와 error code를 보내주는걸로 해결했다.
사용 예는 다음과 같다.
URI : /users/name/dev.whoan body parameter: email=dev.whoan@gmail.com
users Data Set에서 name column이 dev.whoan인 사람의 email을 dev.whoan@gmail.com으로 수정한다. 여기에 입력되지 않은 column은 기존 값을 유지한다.
삽입 URI: /users/name/whoan body parameter: email=dev.whoan@gmail.com, name=whoan
users Data Set에 name=whoan이라는 사람이 존재하지 않으면, email=dev.whoan@gmail.com, name=whoan으로 데이터를 삽입한다. 이 때, 모든 column이 입력되어야 한다. 모든 column이 없으면, 400 Bad Request와 모두 입력하라는 message를 보내주었다.
구현할 때 문제점이 있었는데, MkWeb의 SQL을 정의하는 구간에서 각 data를 짜집어 SQL을 미리 정의해두는게 있었는데, 이 때 이미 SQL이 설정되어 있어서 따로 수정할 수 없는게 문제였다. 이는 따로 RESTful API전용 SQL Creator를 만들어 고쳤다. (여기서 중요한건 이게 아니니 패스)
Data의 유/무를 확인하기 위해 GET Method를 통해 PUT Method 요청시 특정된 데이터가 존재하는지 확인했고, 특정된 데이터가 있다면 update, 없으면 insert문을 수행하게 했다. 사실 insert문의 경우 POST로 대체할 수 있지 않을까 싶었는데, PATCH의 기능을 수행하게 하였기 때문에 column의 값들이 문제가 되어서 put에서 수행하게 했다.
그리고 PUT method 안에서 INSERT와 UPDATE 중 하나가 발생하기 때문에(두 메소드 모두 사용되기 때문에), 해당 작업 수행 후 MkRestApiResponse에 응답 코드를 담아서 반환해주었다.
프론트로부터 request.getParameter를 통해 데이터를 받을 때, 데이터가 없는 경우가 있다. 이 때는 Form Data나 Body Message에 데이터가 담겨온 것인데, 이럴 경우 Stream을 이용해 parameter를 받아와야 한다. 보통 프론트 사이드에서 HttpRequest에 Parameter를 전송할 경우 Servlet에서 다음 함수로 받을 수 있다.
파라미터명을 아는 경우 request.grtParamrter(String param); 파라미터명을 모르는 경우 request.getParameterNames()
위 방식은 쿼리 스트링, 혹은 Form Data의 경우 정상적으로 받아지지만 Content-Type이 변하여(application/json 등) Body Content에 parameter가 담겨올 경우, 위 방식으로 Parameter를 찾아보면 null 값이 나온다.
이럴 경우 body content를 읽어들여야 하는데, request.getInputStream()을 이용해 buffer를 읽어들이면 된다.
인터넷 연결이 오락가락 해서 문제를 찾아보니 7년동안 사용한 아이피타임 A1004가 보내달라고 소리지르고 있는걸 확인했다.
무려 7년동안 잘 견뎌준 녀석이지만, 고통스러워하는 모습에 새로운 녀석을 장만했다.
"안테나는 4개여야 잘터진다!"라는 생각을 갖고 있었지만, 안테나가 3개임에도 불구하고 꽤 괜찮은 성능을 내는 녀석을 찾았으니, 바로 아이피타임 A6004MX다.
사실 성능도 있지만, 가격도 합리적이어야 하는데, 마침 이녀석이 세일하고 있길래 바로 구매했다.
장비를 개봉했을 때 들어있는 구성품은 다음과 같다.
설치할 때 느낀건데, 정면 측면 후면 샷을 모두 뒤집어 놓고 찍었더라.
A6004MX 스펙은?
Wi-Fi Mesh를 지원하여 여러개의 공유기(Wi-Fi Mesh 기술을 지원하는)를 하나의 네트워크로 묶을 수 있다. 특히 아이피타임의 경우 Easy Mesh기술을 통해 여러개의 네트워크를 하나로 쉽게 묶을 수 있다. 이를 통해 방마다 설치되어 있는 와이파이 공유기가 이름이 다른 경우, A 네트워크 사용중 B 네트워크로 자동연결되어 네트워크 끊김이 발생하거나, 더 느린 네트워크로 연결되어 답답함이 생길 수 있는데 이를 방지할 수 있다. 즉 끊김없는 네트워크 환경을 구성할 수 있다.
A6004MX의 무선랜 속도는 IEEE 802.11ac를 지원하여 2.4GHz 최대 600Mbps, 5GHz 최대 1300Mbps를 지원한다.
유선랜의 경우 최대 1Gbps까지 지원한다. 10Gbps이상의 속도를 사용하고자 하는 사람들은 더 높은 사양의 공유기를 사용해야 한다.
후면에 USB 3.0 포트가 있어, 해당 포트에 외장하드 등 데이터를 저장하고자 하는 저장장치를 연결하면 FTP 서버(파일서버)를 운영할 수 있게 되어있다. (해당포트에 휴대폰 충전 USB단자를 연결하면 충전기로도 사용할 수 있다!)
하드웨어 스펙의 경우 듀얼코어 1.25GHz, 128MB Ram을 지원하는데, 우리가 실사용하기에 큰 무리가 없는 사양이다. 나머지 스펙은 기존의 아이피타임과 같기 때문에 넘어가겠다.
실사용 환경
우리가 실제로 공유기를 고를 때는 얼마나 빠른가?만 고려한다. 추가적인 요소가 있다면, 아이피타임을 통해 웹서버를 구성할 수 있느냐? 인데, 후자의 경우 A6004MX를 통해 가능하다.
그래서 실제 사용환경에서 얼마나 빠른 속도가 나오는지 확인해 봤다.
스펙상에 나와있는 속도에 대해 쉽게 설명드리자면, 두 대역 모두 스펙상으로는 현재 통신3사에서 광고하는 5G보다 더 잘터지는 수준이다. 실제 사용 환경에서도 이런 속도가 나올 수 있는지 궁금했고, 더불어 아이피타임이 광고하는 MU-MIMO기술 (실제 사용 환경에서는 복수의 기기가 같은 네트워크를 할당받을 경우, 속도가 저하되는 문제를 방지)이 정말 동작하는지 직접 측정해 봤다.
테스트 장비
속도 측정을 위해 사용된 기기는 다음과 같다.
KT 기가 광랜 (1Gbps급)이며 통신사 공유기는 사용하지 않고 있다.
1. 무선랜
Galaxy Z Flip, Galaxy Tab S7+, LG Gram 2019 (단, 공유기에 붙어서 측정하는 경우는 없기에 공유기를 거실에 두고, 방에 들어와 측정하였다. 측정 거리는 공유기로부터 벽까지 2m, 그리고 벽 바로 건너편에서 측정하였다.)
2. 유선랜
인텔 CPU 데스크탑 + Cat 5E LAN Cable
우선 모바일기기에서 5G 측정이다.
모바일 기기의 경우 데이터 네트워크는 모두 '사용 안함' 상태에서 와이파이만 이용하였다. MU-MIMO기술이 있어서 속도 저하가 안될거라 예상하였지만, 실제는 그렇지 않았다.
몇번을 시도 해도 Z Flip의 속도가 더 빠르거나, S7+에 속도가 몰아지는 등 두 기기 모두 300Mbps에 가까운 속도를 내는 경우는 없었다.
그래서 한 기기씩 측정해 보았는데, 다음과 같다.
1. Z Flip만 측정
2. S7+만 측정
양 기기간에 발생하는 최대속도 차이는 기기차이인 것 같다. Z Flip은 아무리 해도 330Mbps를 넘는 경우가 없었고, S7+는 최대 500Mbps까지 나왔다.
2.4GHz의 경우는 두 기기 모두 평균 다운로드: 60.3Mbps 업로드: 131Mbps로 측정됐다.
노트북 환경에서의 속도는 다음과 같았다.
1. 5G
2. 2.4G
유선랜 측정
사실 데이터를 무제한 사용하는 입자에서 무선랜 보다 유선랜이 더 중요했다.
(요즘 LTE가 굉장히 느렸는데, 와이파이를 바꾼 후 집에서는 와이파이만 사용하고 있다.)
총 10번 시도 했을 때 평균 다운로드: 750Mbps 업로드: 625Mbps를 유지했다. 과거 1기가급 광랜이 많이 보급되지 않았을 때는 1.1Gbps도 나오곤 했는데, 보급이 많이 됨에 따라 속도가 많이 떨어졌다. (이건 KT에서 보상해야하는 것 아닌가?)
총평
총평을 내리자면, 잘 구매한 것 같다. 59,000원을 주고 구매했는데 해당 가성비가 꽤 괜찮다.
해당 기기를 사기 전에 아이피타임의 어떤 기기의 경우 DDNS를 여러개 지원하는데, 해당 기기는 타 보급품 급과 같이 1개만 지원하는건 안타까웠지만
Java는 클래스를 중심으로 객체들을 설계하는 언어이다. 바꿔 말하면 문제를 해결하기 위해 클래스를 설계하고, 이를 인스턴스화 하여 사용한다.
또한 Java를 사용할 때 의 장점 중(객체지향 프로그래밍의 장점 중) 재사용성이 있는데, 이는 다음과 같이 해석할 수도 있다.
'비슷한 형태를 묶어 하나의 클래스를 만들고, 그 클래스로부터 각각의 문제를 해결한다.'
즉, 클래스의 구현과 사용을 분리하여 만드는 것이 일반적이다. 만약 현대자동차를 만드는 클래스 하나, 기아자동차를 만드는 클래스 하나와 같이 같은 형태의 문제를 각각의 클래스를 만들면 낭비이고 비효율을 초래한다.
여기까지 이해하면 다음과 같이 설계할 수 있다.
class Car{
String company;
String name;
int speed;
Car(String company; String name, int speed){
this.company = company;
this.name = name;
this.speed = speed;
}
public void move(){
this.speed = 100;
System.out.println(this.company + "의 " + this.name + "가 " + this.speed + "의 속도로 달립니다.");
}
public void stop(){
this.speed = 0;
System.out.println(this.company + "의 " + this.name + "가 " + this.speed + "의 속도로 달립니다.");
}
}
<이는 정말 간단한 예를 표현하기 위한것이니 왜 이렇게 짰는지는 묻지 말아주길 바란다.>
그런데, 위에서 말했듯이, 클래스의 구현과 사용은 분리하는것이 좋다. 위의 예는 클래스의 '사용'을 나타낸 것이다.
이를 어떻게 구현과 사용으로 분리할 수 있을까?
구현은 Abstract(추상화) 사용은 Extends(상속)
abstract의 사전적 의미는 추상이다. 추상의 국어사전 속 의미는
추상1 (抽象)[명사][심리 ] 여러 가지 사물이나 개념에서 공통되는 특성이나 속성 따위를 추출하여 파악하는 작용. <출처: 네이버 국어사전>
이를 프로그래밍 관점에서 보자면, "객체들에 대해 공통적인 속성을 추상화 시켜놓고 해당 속성을 각각의 Class에서 구현하고 사용한다."로 생각하면 된다.
자동차로 예를 들면, 쉽게 자동차는 굴러가고, 멈춘다. 그런데 각각의 자동차가 어떤 속도로 굴러갈지, 어떻게 멈출지는 모르는 법이다. 따라서 위에서 만들었던 Car class에서 move와 stop을 추상화하면 된다.
Java에서 추상화 하는 방식은 abstract한정자를 사용하면 된다. 또한 abstract한정자를 갖는 클래스는 abstract class, 즉 추상 클래스가 된다.
우리는 각각의 자동차가 어떻게 굴러가는지, 또 어떻게 멈추는지 모른다. 즉 추상화하고자 하는 method의 동작 방식을 전혀 모른다. 따라서 abstract method는 본문 내용을 가져서는 안된다. 즉, 다음과 같이 표현하여야 한다
abstract public void move(); (단, 우리는 동작방식이 어떻게 될지 모르는거지, 인자로 뭐가 들어올지는 설정할 수 있다. 가령, 움직이는 속도를 전달받는다 하면 abstract public void move(int speed);와 같이 나타내면 된다.)
그러면 실제로는 어떻게 해야할까?
앞선 내용을 바탕으로 구현과 사용을 분리해 보면, 다음과 같이 나타낼 수 있다.
public abstract class CarFrame{
int speed;
String name;
String company;
abstract public void move();
abstract public void stop();
public void Status(){
System.out.println(this.company + "의 " + this.name + " 현재 속도: " + this.speed);
}
public int getSpeed() { return this.speed; }
public String getName() { return this.name; }
public String getCompany() { return this.company; }
public void setSpeed(int speed) { this.speed = speed; }
public void setName(String name) { this.name = name; }
public void setCompany(String company) { this.company = company; }
}
public class Car extends CarFrame{
public Car(String name, String company){
setSpeed(0);
setName(name);
setCompany(company);
}
public void move(){ setSpeed(100); }
public void stop(){ setSpeed(0); }
}
public class mainAbstract {
public static void main(String[] args) {
Car Sportage = new Car("스포티지", "기아자동차");
Car Tucson = new Car("투싼", "현대자동차");
Sportage.Status();
Tucson.Status();
/*****************************/
Sportage.move();
Tucson.move();
Sportage.Status();
Tucson.Status();
/*****************************/
Sportage.stop();
Tucson.stop();
Sportage.Status();
Tucson.Status();
}
}
CarFarme이라는 abstract class, 추상 클래스를 통해 자동차들에 대해 하나로 구현하였고, 각각의 자동차에 대해 Car라는 class를 인스턴스화 함으로써 사용하게 하였다. (이때 getter와 setter를 사용하지 않고 직접 변수 getter setter를 사용하고 싶으면 CarFrame의 변수들에 대해 접근제한자를 바꿔주면 된다.)
아래의 maniAbstract 클래스를 실행하면 다음과 같은 결과가 나온다.
앞에서 우리가 사용할 때 공통적인 내용을 추상화 했다. 프로그래밍을 시작한지 얼마 안된 사람이라면 다음과 같은 의문을 가질 수 있다.
"어? 그러면 쉐보레와 현대/기아가 있을때, 현대/기아만 갖는 속성은 어떻게 나타낼 수 있어요?"
우리는 현대 자동차와 기아 자동차가 같은 내용을 가진다고 가정 했다. (같은 그룹이라서..)
이는 우리가 Car를 상속받아 현대자동차 클래스 / 쉐보레 클래스를 각각 구현하여, 해당하는 클래스에 멤버변수나 메소드를 추가해주면 된다.
그러한 예를 나타내고 상속클래스 게시글을 마치겠다.
전방충돌감지 레이더를 장착한 기아 자동차의 스포티지와 깡통 옵션으로 출고된 트래버스를 구현할 때, 다음처럼 구현할 수 있다.
쉐보레를 싫어하는게 아니라 구현의 귀찮음으로 인해 스포티지만 전방인식 레이더가 있다고 구현했습니다.
public class Kia extends Car{
int frontObject = -1;
public Kia(String name, String company) { super(name, company); this.frontObject = -1;}
public void frontObject(int distance) {
this.frontObject = distance;
}
@Override
public void move() {
if(frontObject > 0) {
super.setSpeed(100 - frontObject);
}else {
super.setSpeed(100);
}
if(super.getSpeed() < 30) {
System.out.println("전방 충돌 주의!!! 긴급제동을 시작합니다.");
super.setSpeed(0);
}
}
}
public class Chevrolet extends Car{
public Chevrolet(String name, String company) { super(name, company); }
}
public class mainAbstract {
public static void main(String[] args) {
System.out.println("=====짧은머리 개발자=====");
Kia Sportage = new Kia("스포티지", "기아자동차");
Chevrolet Traverse = new Chevrolet("트래버스", "쉐보레");
Sportage.move();
Traverse.move();
Sportage.Status();
Traverse.Status();
System.out.println("===50m 전방 사람===");
Sportage.frontObject(50);
Sportage.move();
Sportage.Status();
System.out.println("===30m 전방 사람===");
Sportage.frontObject(70);
Sportage.move();
Sportage.Status();
System.out.println("===20m 전방 사람===");
Sportage.frontObject(80);
Sportage.move();
Sportage.Status();
}
}
Chevrolet Class는 frontObject라는게 없기 때문에 Traverse.frontObject를 사용할 수 없어서 Sportage에만 사용하였습니다.
톰캣 8.5 버전 이상부터 톰캣 컨텍스트 애플리케이션에서 파일 쓰기 작업을 할 때 정상적으로 안되는 경우가 있다.
톰캣 버전이 올라가면서 시스템 보안을 위한 정책이 추가된건데, 기본적으로 톰캣이 포함된 webapps와 logs 파일에 읽고 쓰기를 지원하고, 다른 경로는 설정을 통해 읽고 쓰기가 가능하게끔 해야한다.
이러한 방법에는 다음과 같은 두 가지 방법을 생각할 수 있다.
1. 톰캣유저 자체의 권한 상승
2. 톰캣 서비스 수정을 통한 IO 경로 추가
1번의 경우 보안상 이유로 패치된것 이기 때문에 추천하지 않고, 따라서 2번을 추천하다.
$ cd /etc/systemd/system/multi-user.target.wants
$ vi /tomcat9.service
# 여기부터 tomcat9.service File
...
#Security
#제일 하단에 다음과 같이 추가
ReadWritePaths=IO작업을 수행할 경로
ReadWritePaths 옵션을 보면 알겠지만, 톰캣이 파일 IO작업을 할 수 있도록 허용하는 경로를 추가하는 것이다.
모든 데이터 및 알고리즘을 List 구조만 이용해서 최적화된 프로그래밍을 할 수 있으면 얼마나좋을까?
안타깝게도 그렇지 않기 때문에 세상에는 List를 제외한 많은 자료구조가 존재한다.
이번에는 Stack과 관련된 구현을 할 예정이다
Stack은 Last In First Out 구조를 갖는다. 이 LIFO 구조를 갖는 가장 흔한 예를 들어보면 많이 원기둥으로 된 초콜릿 통을 얘기한다. 하지만 우리 프로그래밍 세계에는 초콜릿 통만 만들지 않으니, 다른 예를 알아볼 필요도 있다.
이런 예는 어떨까?
구조선에 총 500kg의 인원을 태울 수 있다. 구조를 기다리는 사람들은 차례로 줄 서서 탑승을 기다리고 있다. 구조선이 도착지에 도착하면, 탑승구에 있는 사람부터 내린다. 구조선은 총 탑승 중량이 500kg을 넘어가면, 출발한지 조금 지나 침몰한다. 탑승할 때, 중량을 나타내는 전자저울이 있다.
해당 문제는 도착지에 도착하건, 중량초과를 하건, 제일 마지막에 탑승한 사람부터 내려야 한다. 이러한 경우 Stack을 이용해 구현할 수 있겠다.
Stack은 Last In First Out
Stack에 대해 이미지를 연상해보면 다음과 같이 생각하면 편하다.
즉, 제일 위에서(top) 데이터를 삽입하거나, 데이터를 반환한다.
stack은 리스트처럼 1. 크기가 고정(배열) 2. 크기가 동적(ArrayList)의 성격을 가질 수 있고, 구현 방법은 그 성격과 같다.
크기를 고정시키고 싶다면 배열로 구현하고, 크기를 동적으로 변경하고 싶다면 ArrayList로 구현하면 된다. 오늘 게시글에서는 크기가 고정된 Stack을 구현해 보겠다.
Stack에는 어떤게 필요할까?
Stack은 어떤 기능이 필요할까? 기본적인 목적은 제일 위에서 데이터를 삽입, 반환할 수 있어야 한다. 부가적으로, 현재 Stack의 사이즈와 크기, 초기화, 그리고 비어있거나 꽉찼는지를 구할 수 있으면 된다.
따라서 제일 위를 구하는 top, 크기를 담는 capacity를 가지면 된다.
Stack에서는 ArrayList와 달리 add, remove라는 함수 이름을 사용하지 않고 각각 push, pop이라는 이름의 함수를 사용한다.
Stack 구현
따라서 Stack의 기본은 다음과 같다.
public class Stack<T> {
int top;
int capacity = -1;
T[] stack;
Stack(int capacity){
this.capacity = capacity;
stack = (T[]) (new Object[capacity]);
top = -1;
}
}
top을 -1로 초기화 하는 이유는 개발의 간편함을 위함이다. 따라서 스택이 비어있을때 top은 -1이 된다.
고정된 크기를 갖는 Stack을 구현하기 생성자는 capacity값을 전달해야 한다.
데이터를 삽입할 때는 Stack이 가득찼는지 확인해야 한다. 그리고 공간이 남아있다면 데이터를 삽입하면 된다.
public class Stack<T> {
...
public void push(T element) {
if(isFull()) {
System.out.println("Stack이 가득 찼습니다.");
return;
}
stack[++top] = element;
}
public boolean isFull() { return (this.top == this.capacity-1); }
...
}
isFull(): 스택이 가득찼는지 확인한다.
데이터를 반환할 때는 삽입할 때와 반대로 Stack이 비어있는지 확인해야 한다. 그리고 비어있지 않다면 데이터를 반환하면 된다.
public class Stack<T> {
...
public T pop() {
if(isEmpty()) {
System.out.println("Stack이 비어있습니다.");
return null;
}
return stack[top--];
}
public boolean isEmpty() { return (this.top == -1); }
...
}
isEmpty(): 스택이 비어있는지 확인한다.
그런데 우리는 반환하지 않고 데이터를 확인만 하고 싶을수도 있다. 이럴 때를 위해 peek()라는 함수가 존재한다. 이는 stack에서 데이터를 삭제하지 않고, top에 어떤 data가 존재하는지 확인을 가능하게 한다.
public class Stack<T> {
...
public T peek() {
if(isEmpty()) {
System.out.println("Stack이 비어있습니다.");
return null;
}
return stack[top];
}
public boolean isEmpty() { return (this.top == -1); }
...
}
pop과의 차이점은 return (T) stack[top--]; 과 return (T) stack[top]; 이다.
pop의 경우 top을 하나 감소시켜 다음 데이터가 삽입될때, 반환한 데이터 위치에 새로운 데이터가 덮어씌워지지만, peek의 경우 top의 데이터 조작을 일체 하지 않음으로써 다음 pop 혹은 push를 수행할 때 기존과 같은 역활을 하게 하는 것이다.
이제 나머지 부가기능인 stack의 크기와 초기화 하는 함수를 만들어주면 된다.
public class Stack<T> {
...
public void clear(){
if(isEmpty()){
System.out.println("Stack은 이미 비어있습니다.");
return;
}
top = -1;
stack = (T[]) (new Object[capacity]);
System.out.println("Stack 초기화 완료!");
}
public int size(){
return (top+1);
}
}
전체코드와 실행 결과
public class mainStack {
public static void main(String[] args) {
System.out.println("=====짧은머리 개발자=====");
Stack<Integer> stack = new Stack<>(5);
for(int i = 0; i < 5; i++) {
stack.push((i+1));
System.out.println(i + " 번째 peek: " + stack.peek());
}
System.out.println("===Pop===");
for(int i = stack.size(); i > 0; i--) {
System.out.print(i + " 번째 : " + stack.pop() + " | " );
}
}
}
public class Stack<T> {
int top;
int capacity = -1;
T[] stack;
public Stack(int capacity){
this.capacity = capacity;
stack = (T[]) (new Object[capacity]);
System.out.println("size : " + capacity);
top = -1;
}
public void push(T element) {
if(isFull()) {
System.out.println("Stack이 가득 찼습니다.");
return;
}
stack[++top] = element;
}
public T pop() {
if(isEmpty()) {
System.out.println("Stack이 비어있습니다.");
return null;
}
return stack[top--];
}
public T peek() {
if(isEmpty()) {
System.out.println("Stack이 비어있습니다.");
return null;
}
return stack[top];
}
public void clear(){
if(isEmpty()){
System.out.println("Stack은 이미 비어있습니다.");
return;
}
top = 0;
stack = (T[]) (new Object[capacity]);
System.out.println("Stack 초기화 완료!");
}
public int size(){
return (top+1);
}
public boolean isEmpty() { return (this.top == -1); }
public boolean isFull() { return (this.top == this.capacity-1); }
}
배열(Array)와의 차이점으로 배열은 초기화할 때 크기를 지정해야 하지만, ArrayList는 그러지 않아도 된다.
즉 배열은 고정값의 크기를 가지게 되고, 이후에 크기를 늘리는 행위를 하려면 새로운 배열을 생성하여 내용을 복사해야 하는 반면에, ArrayList는 그냥 추가해 주면 된다.
그렇다면 동적 크기를 갖는 ArrayList를 구현하려면 어떻게 해야할까?
다른 블로그 포스트들을 보면 똑같은 코드를 바탕으로 똑같은 설명을 반복하고 있다. 해당 내용을 보면 심화된 ArrayList를 구현할 수 있기 때문에 나는 기초 구현을 하겠다.
들어가기에 앞서서, 제네릭이라는 기술이 있다. 제네릭은 쉽게 말하면 하나의 구조에 대해 여러 자료형을 사용할 수 있도록 하는 것이다. 우리가 String name이라는 변수를 통해 홍길동, 김철수, 김영희 등을 지정하는 것 처럼 name을 String뿐만 아니라 Integer, Float, Boolean, 등 여러개의 자료형을 사용할 수 있도록 해주는 것이다.
이러한 제네릭을 사용하기 위해서는 제네릭 클래스를 만들어야하는데, 간혹 어떤 클래스를 볼 때 다음과 같은 구조를 본 적 있을 것이다.
class Point<T>{ ... }
여기서 <T>부분이 제네릭 부분이며, 내가 자료형을 T로 표현한 부분은 모두 해당 클래스를 사용할 때 지정한 자료형임을 나타내는것이다. 따라서 간략히 Point 내부에는 int x, y 혹은 Float x, y 대신 T x, y를 사용함으로써 Integer를 갖는 Point와 Float을 갖는 Point를 따로 만들지 않고 하나의 Point<T>를 통해 두가지를 모두 구현할 수 있게 되는 것이다.
Array List
이름을 보면 정답이 나온다.
Array List, 즉 Array를 이용하여 List를 만드는 것이다. Array는 고정 크기를 갖는데 어떻게 동적 크기를 만든다는거지?
본 게시글의 시작 부분에서 " ... 배열은 고정값의 크기를 가지게 되고, 이후에 크기를 늘리는 행위를 하려면 새로운 배열을 생성하여 내용을 복사 ... "
즉, ArrayList는 내부에 어떤 고정 크기를 갖는 배열을 가지고 있고, 어떤 요소를 추가할 때 그 배열의 크기를 넘어가게되면 배열의 크기를 늘려주면 된다.
ArrayList를 만들기에 앞서 목적이 무엇인지 생각해보자.
1. 어떠한 Data를 보관한다.
2. 보관된 Data를 반환한다.
1. Data를 보관할 때 조금 옵션을 추가해 보자면 제일 앞에, 제일 뒤에, 특정한 위치에 이렇게 3가지가 있을 수 있다. (제일 뒤는 가장 마지막으로 추가한 Data의 뒤를 의미)
2. Data를 반환할 때 마찬가지로 옵션을 추가해 보면 제일 앞에, 제일 뒤에, 특정한 위치에 이렇게 3가지가 있다.
위 목적을 바탕으로 ArrayList가 갖는 요소를 생각해 보면 Array, index(혹은 iterator)면 충분하다.
이제 ArrayList를 만들기 위한 준비는 끝났다.
Class ArrayList
class ArrayList{
Object[] arr = null;
int capacity = 0;
int size = 0;
ArrayList(int capacity){
this.capacity = capacity;
arr = new Object[capacity];
size = 0;
}
ArrayList(){
capacity = 1;
arr = new Object[capacity];
size = 0;
}
}
시작은 위와 같다. ArrayList class에 대하여 array, arr의 크기를 알려주는 capacity, 그리고 현재 arr의 size를 반환해주는 size,를 만들었다. (size는 arr이 지금까지 사용하고 있는 크기, capacity는 arr의 length를 알려주는 변수이다.)
그리고 ArrayList를 생성함과 동시에 arr을 전달받은 size로 크기를 초기화하거나, 그렇지 않은 경우 배열의 크기를 1로 설정했다.(1 이상으로 해도 된다. 다만 0으로 할 경우, ArrayList 생성 후에 삽입을 한다면 에러가 발생하니 주의하자)
Data 보관
이제 데이터 보관을 구현할 차례다. 이 때 주의해야 할 점은 다음과 같다.
1. arr이 꽉찼다면, arr의 크기를 바꾸고 내용을 복사한다.
2. 삽입하는 위치가 특정 위치일 경우, 해당 위치부터 요소들을 뒤로 한칸씩 민다.
1번의 경우, 그림으로 표현하면 다음과 같다.
arr이 가득 찬 상태에서 새로운 요소를 추가한다면, 새로운 배열을 만들고, 해당 배열의 크기를 기존의 2배로 만들면 된다. 이후에 새로운 배열에 기존 배열을 복사 하고, 새로운 요소를 추가해주면 된다.
2번의 경우, 그림으로 표현하면 다음과 같다.
그림에서 보면 새로운 요소 5를 두번째에 추가하려고 한다.
하지만 이미 두번째부터 2, 3, 4라는 요소가 삽입되어 있다. 이럴 경우, 둘, 셋, 네 번째 위치의 요소들을 뒤로 한칸씩 밀어주고, 내가 원하는 위치에 데이터를 삽입하면 된다. 이를 응용하여, 제일 앞에 요소를 추가한다면, 모든 요소를 뒤로 한칸씩 밀어주면 된다.
이를 코드로 표현하면 다음과 같다.
class ArrayList{
...
public void add(Object element){
if(size == capacity){
expandArray();
}
arr[size++] = element;
}
public void addFirst(Object element){
add(0, element);
}
public void add(int index, Object element){
if(size == capacity){
expandArray();
}
for(int i = size; i >= index; i--)
arr[i] = arr[i-1];
arr[index] = element;
size++;
}
private void expandArray(){
capacity *= 2;
Object[] tempArr = new Object[capacity];
copyArr(tempArr, arr);
arr = new Object[capacity];
copyArr(arr, tempArr);
}
private void copyArr(Object[] arr1, Object[] arr2){
for(int i = 0; i < arr2.length; i++){
arr1[i] = arr2[i];
}
}
...
}
요소를 추가할 때 마다 size는 증가하게되고 따라서 arr이 가득 차게 되면 size는 capacity와 값이 같아지게된다. 이를 조건으로 사용하여 arr이 가득 찼다면 expandArray함수를 호출함으로써 arr을 크기를 확장힌다.
기본적으로 add 함수는 배열의 가장 뒤에 요소를 추가하며, 추가하고자 하는 위치가 있을 경우 해당 index로 데이터를 삽입한다.
Data 반환
데이터를 반환하는 코드는 비교적 단순하다.
원하는 위치에 대해 Object를 반환해주면 끝이다.
class ArrayList{
...
public Object get(int index){
if(size <= 0){
System.out.println("배열이 비어있습니다.");
return null;
}
return arr[index];
}
...
}
이 때 주의해야 할 점은 내가 구하고자 하는 위치가 배열의 크기를 넘어설 수 있다. 이를 따로 조건문으로 처리해주어도 되지만, 그냥 반환함으로써 Out Of Bounds 예외를 출력해주어도 되기 때문에 따로 작업하지 않았다.
추가적인 동작들
추가적으로 배열 초기화, 삭제, Size 반환, Capacity 반환 등이 있을 수 있다.
삭제의 경우, 삭제하고자 하는 위치가 배열의 크기를 넘어가는지, 이미 비어있는지 등을 확인하고 삭제, 반환해주면 된다. 나는 따로 조건을 추가하지 않았다.
초기화는 현재의 capacity로 arr를 초기화 시켜주면 된다.
class ArrayList{
...
public Object remove(int index){
/*
크기 초과, 이미 비어있는지 등 조건문은 원하는대로 추가해주면 된다.
*/
Object result = arr[index];
arr[index] = null;
return result;
}
public void reset(){
arr = new Object[capacity];
}
public int size(){
return this.size;
}
public int getCapacity() {
return this.capacity;
}
}
전체 코드와 실행 결과
package DataStructure;
public class ArrayList<T> {
Object[] arr = null;
int capacity = 0;
int size = 0;
public ArrayList(int capacity){
this.capacity = capacity;
arr = new Object[capacity];
size = 0;
}
public ArrayList(){
capacity = 1;
arr = new Object[capacity];
size = 0;
}
public void add(T element){
if(size == capacity){
expandArray();
}
arr[size++] = element;
}
public void addFirst(T element){
add(0, element);
}
public void add(int index, T element){
if(size == capacity){
expandArray();
}
for(int i = size; i >= index; i--)
arr[i] = arr[i-1];
arr[index] = element;
size++;
}
private void expandArray(){
capacity *= 2;
Object[] tempArr = new Object[capacity];
copyArr(tempArr, arr);
arr = new Object[capacity];
copyArr(arr, tempArr);
}
private void copyArr(Object[] arr1, Object[] arr2){
for(int i = 0; i < arr2.length; i++){
arr1[i] = arr2[i];
}
}
public T get(int index){
if(size <= 0){
System.out.println("배열이 비어있습니다.");
return null;
}
return (T) arr[index];
}
public T remove(int index){
/*
크기 초과, 이미 비어있는지 등 조건문은 원하는대로 추가해주면 된다.
*/
T result = (T) arr[index];
arr[index] = null;
size--;
if(size > 0) {
/*
삭제한 이후부터 앞으로 한칸씩 땡긴다.
*/
for(int i = index; i < size; i++) {
arr[i] = arr[i+1];
}
}
return result;
}
public void reset(){
arr = new Object[capacity];
size = 0;
}
public int size() {
return this.size;
}
public int getCapacity() {
return this.capacity;
}
}
package Main;
import DataStructure.ArrayList;
public class mainTask {
public static void main(String[] args) {
System.out.println("=====짧은머리 개발자=====");
ArrayList<Integer> arr = new ArrayList<Integer>();
System.out.println("배열 크기 : " + arr.getCapacity());
System.out.println("데이터 삽입 1~5");
for(int i = 0; i < 5; i++) {
arr.add((i+1));
}
//출력
System.out.println("\n==출력==");
for(int i = 0; i < arr.size(); i++) {
System.out.print(i + "번째 : " + arr.get(i) + " | ");
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
arr.add(1, 6);
arr.add(4, 7);
System.out.println("\n==출력2==");
for(int i = 0; i < arr.size(); i++) {
System.out.print(i + "번째 : " + arr.get(i) + " | ");
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
System.out.println("1번째 요소 삭제 : " + arr.remove(1));
System.out.println("\n==출력3==");
for(int i = 0; i < arr.size(); i++) {
System.out.print(i + "번째 : " + arr.get(i) + " | ");
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
System.out.println("\n==출력4==");
while(arr.size() > 0) {
System.out.println("0번째 삭제: " + arr.remove(0));
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
System.out.println("데이터 삽입 1~5");
for(int i = 0; i < 5; i++) {
arr.add((i*2));
}
//출력
System.out.println("\n==출력6==");
for(int i = 0; i < arr.size(); i++) {
System.out.print(i + "번째 : " + arr.get(i) + " | ");
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
System.out.println("초기화");
arr.reset();
System.out.println("\n==출력7==");
for(int i = 0; i < arr.size(); i++) {
System.out.print(i + "번째 : " + arr.get(i) + " | ");
}
System.out.println("\n배열 크기 : " + arr.getCapacity());
}
}