김상욱의 db 이용한 awt 그래프 출력강좌

 

※ 강좌에 대한 질문은 '문의게시판'에서 해주세요.

강좌예정입니다.

안녕하세요. 오랜만에 강좌를 추가하는군요. 넘 오랜만이라 쑥스...(/^.^)_

자 오늘은 애플릿으로 Canvas와 Scrollbar를 이용한 챠트표시에 대한 강좌를 해보겠습니다. 먼저 한 번 보시죠.

클릭~~!! (1) 클릭~~! (2)

하셨어요? 음. 간단하지만 초보님들이 구현하시기는 제법 까다로운 점이 많습니다. 자세히 살펴보시면, 스크롤 이동에 따라 maximun과 minimum값을 체크하여 그 영역만 표시하는 등 흥미로운 기능이 많습니다.

이번 차트는 보시다시피 db를 이용하는 부분은 일단 제외하고, 단순히 param값으로 줄줄줄 데이타를 받아들여 StringTokenizer로 잘라서 Vector에 저장한 다음 이들을 각각 매치시켜 Canvas로 그렸습니다. 하나하나 공부해봅시다. (이 강좌를 위해 저 6시간 코딩했습니다. ㅜ.ㅜ)

보시다시피 이 것은 애플릿입니다. 그럼 우선 애플릿 클래스가 있어야겠지요?

한 번 소스를 보겠습니다.

import java.applet.Applet;
import java.awt.*;
import java.util.*;

public class Chart extends Applet
{
String strYear, strMonth, strType, strParam1, strParam2, strUnit;
Vector vtDay, vtSent, vtRecv;
AxisCanvas axisCanvas;
ChartCanvas chartCanvas;
ScrollPanel scrollPanel;
GridBagLayout gb;
GridBagConstraints gbc;
int naxismax, naxismin;
Panel panel;

public void init()
{
setBackground(Color.white);
vtDay = new Vector(31);
vtSent = new Vector(31);
vtRecv = new Vector(31);

strParam1 = getParameter("param1");
if(strParam1 == null)
strParam1 = "";
strParam2 = getParameter("param2");
if(strParam2 == null)
strParam2 = "";
strUnit = getParameter("unit");
if(strUnit == null)
strUnit = "";
strYear = getParameter("year");
if(strYear == null)
strYear = "";
strMonth = getParameter("month");
if(strMonth == null)
strMonth = "";
strType = getParameter("type");
if(strType == null)
strType = "0";
String strtemp = getParameter("day");
if(strtemp == null)
strtemp = "";
StringTokenizer stntemp = new StringTokenizer(strtemp, ",");
for(;stntemp.hasMoreTokens();)
vtDay.addElement(stntemp.nextToken());

strtemp = getParameter("sent");
if(strtemp == null)
strtemp = "";
stntemp = new StringTokenizer(strtemp, ",");
for(;stntemp.hasMoreTokens();)
vtSent.addElement(stntemp.nextToken());

strtemp = getParameter("receive");
if(strtemp == null)
strtemp = "";
stntemp = new StringTokenizer(strtemp, ",");
for(;stntemp.hasMoreTokens();)
vtRecv.addElement(stntemp.nextToken());

gb = new GridBagLayout();
gbc = new GridBagConstraints();

panel = new Panel();
setLayout(new BorderLayout());
add("Center", panel);
panel.setLayout(gb);
gbc.ipadx = gbc.ipady = 0;

axisCanvas = new AxisCanvas(this);
gbc.fill = GridBagConstraints.BOTH;
gbc.weightx = 0.1D;
gbc.weighty = 1.0D;
addComp(axisCanvas, 0, 0, 1, 1);
chartCanvas = new ChartCanvas(this);
gbc.weightx = 0.9D;
gbc.weighty = 1.0D;
addComp(chartCanvas, 1, 0, 1, 1);
scrollPanel = new ScrollPanel(this);
scrollPanel.setBackground(Color.white);
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.weightx = 1.0D;
gbc.weighty = 0.005D;
addComp(scrollPanel, 1, 1, 1, 1);
gbc.fill = GridBagConstraints.BOTH;
gbc.weightx = 0.1D;
gbc.weighty = 0.005D;
Panel pnX = new Panel();
pnX.setBackground(Color.white);
addComp(pnX, 0, 1, 1, 1);
}

public void addComp(Component component, int a, int b, int c, int d)
{
gbc.gridx = a;
gbc.gridy = b;
gbc.gridwidth = c;
gbc.gridheight = d;
gb.setConstraints(component, gbc);
panel.add(component);
}

public void stop()
{
}

public void destroy()
{
}
}

히야 절라 깁니다. 음 이거 보다 더 긴 소스도 하나 더 있으니, 마음의 준비 하세요. 우선 찬찬히 살펴볼까요? extends Applet한 것이 보이실테고, 각종 변수들을 멤버변수로 선언했군요. 어디에 쓰이는 변수인지는 ctrl+F 해서 찾아보시면 되고^_^;

AxisCanvas axisCanvas;
ChartCanvas chartCanvas;
ScrollPanel scrollPanel;

이 세 클래스는 제가 만든 클래스입니다. AxisCanvas는 차트 좌측에 축을 이루는 Canvas고요, ChartCanvas는 메인 차트가 들어가는 Canvas고요, ScrollPanel은 scrollbar를 넣은 Panel클래스 입니다.

애플릿은 생성자 다음에 바로 init 메소드가 실행되지요? init메소드는 아시다시피 애플릿이 로딩될 때 단 한 번만 수행되는 메소드입니다. 보통 어플리케이션의 생성자 역할(각종 변수 초기화 등)을 대체로 여기서 하지요. 그 안에 보니까... param으로 선언된 변수들을 받아오는 부분이 주욱 길게 보이는군요. 다 똑같은 모양인데요, null값이 넘어오면 String으로 표시할 때 null !! 하고 표시되니까 애써 "" 공백값을 넣어주고 있습니다. null값이 넘어올 경우의 처리는 꼬옥 해주셔야 합니다.

오옷 day,sent, receive부터 바로 StringTokenizer에 갖다 넣는군요. StringTokenizer는 이전에 쓴 채팅 강좌에서 밝혔듯이 긴 String을 일정한 패킷으로 잘라내는 역할을 쉽게 해주는 클래스입니다. 사용법은 new StringTokenizer(String, "구분자"); 이렇게 생성해서 nextToken()으로 스트링을 받아 쓰지요. 패킷은 한 문자가 들어갈 수도 있고 두 세글자가 들어갈 수도 있습니다. 두 세 글자가 들어갈 경우에는 각각을 모두 패킷으로 보고 구분하지요. "#*" 이렇게 구분자를 정의하면, #로도 *로도 구분하지요. StringTokenizer의 또 하나 유용한 메소드는 hasMoreTokens입니다. for문에서 유용하게 쓰고 있군요.

부연이지만, 사실 한 문자로 된 구분자로 패킷을 잘라낼 때에는 String의 indexOf 메소드를 이용해서 잘라내는 것이 효율이 좋습니다. 한두문자일 경우에는 귀찮더라도 indexOf를 이용하시구요. 저요? 귀찮아서요....ㅜ.ㅜ

그 밑에는 Vector를 이용하고 있군요. 위에서 이미 new Vector(31)로 생성해 두었습니다. 왜 31이냐구요? 한 달이 31일 이니까..ㅜ.ㅜ; 미리 이렇게 선언해두면 2배씩, 2배씩 Vector안에 배열을 새로 생성하는 로드를 줄일 수 있겠지요.

부연인데, 왜 두세개씩 안늘리고 2배씩증가시키나요? 음, 컴에서는 2배씩 증가한다는 개념이 상당히 빠른 효율이더군요... 왜인지는 저도 모릅니다.....고수도 모릅니다. 하수도 모릅니다. 2진수를 좋아해서 2배를 좋아하는 지도 모릅니다...단지 실험결과입니다. ㅜ.ㅜ

헉! 그 복잡한 GridBagLayout이 나왔군요. 음 솔직히 실무에서는 가장 많이 씁니다. 그냥 간단히 BorderLayout이나 FlowLayout으로 처리할 수 있는 외의 부분에서는 대부분 GridBagLayout으로 처리하지요. 복잡하긴 하지만, 만사형통이니까... 사용법 줄줄 칠 수 있을 정도로 필히 알아두시구요. 외우세요~ 이 소스에서는 가장 중요한 gridx gridy와 gridwidth, gridheight만 바로바로 쓸 수 있도록 addComp라는 메소드를 하나 만들어서 사용했습니다. 갖다 쓰셔도 되고요.

음 레이아웃까지 다 하고 나니 Applet을 상속한 Chart 클래스가 완성되었군요. 뿌듯합니다.

그 다음에 나온 AxisCanvas를 한 번 볼까요?

import java.awt.*;

public class AxisCanvas extends Canvas
{
int nmax, nmin;
Chart applet;

AxisCanvas(Chart applet)
{
this.applet = applet;
setBackground(Color.white);
}

public void paint(Graphics g)
{
nmax = applet.naxismax;
nmin = applet.naxismin;
FontMetrics fm = g.getFontMetrics(g.getFont());
Image backImg = createImage(getSize().width, getSize().height);
Graphics G = backImg.getGraphics();
G.setColor(Color.lightGray);
G.drawString("▲", getSize().width - 10 - (int)(fm.stringWidth("▲") / 2), 10);
//수직선
G.drawLine(getSize().width - 10, 10, getSize().width - 10, getSize().height - 20);
//맨 아래 수평선
G.drawLine(getSize().width - 10, getSize().height - 20, getSize().width, getSize().height - 20);

G.drawLine(getSize().width - 6, (int)(getSize().height / 6), getSize().width- 14, (int)(getSize().height / 6));

G.drawLine(getSize().width - 8, (int)(getSize().height / 3), getSize().width - 12, (int)(getSize().height / 3));

G.drawLine(getSize().width - 6, (int)(getSize().height * 5 / 6), getSize().width - 14, (int)(getSize().height * 5 / 6));

G.drawLine(getSize().width - 8, (int)(getSize().height * 2 / 3), getSize().width - 12, (int)(getSize().height * 2/ 3));

G.drawLine(getSize().width - 6, (int)(getSize().height / 2), getSize().width - 14, (int)(getSize().height / 2));

G.setColor(Color.black);
G.drawString(applet.strParam1,1, 15);
G.setColor(new Color(100,100,255));
G.fill3DRect(1, 23, 30, 3, true);
G.drawString(applet.strParam2, 1, 45);
G.setColor(new Color(255,100,100));
G.fill3DRect(1, 53, 30, 3, true);
G.drawString(nmax + applet.strUnit , getSize().width - fm.stringWidth(nmax + applet.strUnit) - 12, (int)(getSize().height / 6));
G.drawString(nmin + applet.strUnit, getSize().width - fm.stringWidth(nmin + applet.strUnit) - 12, (int)(getSize().height * 5 / 6));
G.drawString((int)((nmax + nmin)/ 2) + applet.strUnit, getSize().width -
fm.stringWidth((int)((nmax + nmin)/ 2) + applet.strUnit ) - 12, (int)(getSize().height / 2));

g.drawImage(backImg, 0, 0, this);
backImg.flush();
G.dispose();
//지표 추가하자.
}
}

음, AxisCanvas는 아까 말씀드렸던 대로 애플릿의 왼쪽에 붙어 좌표축을 나타내는 클래스입니다. 뭐 그냥 중앙 챠트에서 다 처리할 수도 있지만,(아래 X축은 그렇게 하고 있습니다.) 뭐... 멋있잖아요 ^_^; 그냥 한 번 나눠봤습니다. 공부도 되실겸...

히야 이 클래스는 상당히 복잡해보입니다만. 결국 생성자와 paint메소드 둘 밖에 없습니다. Canvas클래스는 paint메소드가 생명이죠. 뭐 대부분 Component가 paint를 지원하기는 합니다만...

AxisCanvas를 생성할 때, this로 애플릿클래스(Chart)를 넘겨줬습니다. 음 사실 이런 방식은 객체지향적 구조에는 반하는 내용입니다만, 뭐 간단한 프로그래밍이니 아주 굳~~ 건하게 단결된 클래스를 만들어 편의를 도모했습니다. ㅜ.ㅜ; 변수 applet으로 받아왔지요.

그 다음은 골치아픈 그리기를 paint에서 수행하고 있습니다. 메인챠트 클래스에서 maximum값과 minimum값이 바뀔 경우 repaint를 해주어 값을 바꾸면서 다시 그리도록 하고 있지요. Graphics 객체를 이용해서 drawLine, drawRect, fillRect, fill3DRect 등을 그리고 있고, 엇 더블버퍼링을 쓰고 있군요. (내가 코딩하고서 놀란 척 하고 있군요...--a) 더블버퍼링에 대해 잘 모르시는 분이라도 이 코딩을 그대로 쓰시면 되겠습니다. 그리기 성능을 향상시켜 주지요.

문장의 width나 높이 등등을 쓰기 위해서

FontMetrics fm = g.getFontMetrics(g.getFont());

문장이 보이는군요. fm객체에 stringWidth와 getHeight를 이용해 유용하게 쓰고 있습니다. Canvas클래스는 좌표를 이용해 그림 그리듯이 다 해주어야 하기 때문에, 이처럼 String을 그릴 때, 정확한 좌표 지정을 위해 FontMetrics객체가 필요하지요. 유용한 메소드가 많으니 API를 참조하시고요.

한 가지 주의하실 점은 String을 그리실 때, 그 기준 좌표가 String의 왼쪽 아래가 된다는 것입니다. 따라서, 일정 지점 아래에 String을 그리고 싶으면 getHeight로 String의 높이를 구한 다음 그 만큼 더해주시면 되겠구요. 어떤 점을 기준으로 왼쪽에 가변길이의 String을 그리려면 stringWidth메소드를 이용해 String의 width를 구한 다음 기준 점에서 빼주시면 되겠지요.

naxismax와 naxismin이 보이는군요.최대값과 최소값이고 이를 이용해 중간값을 구할 때 씁니다.

applet.strUnit는 "M"냐 "G"냐 chart.html에서 param으로 넘겨주는 값을 애플릿에서 받아온 값으로 그리 중요한 것이 아닙니다.

Axis는 그런대로 간단하군요. 다음 클래스를 보시겠습니다.

import java.awt.*;
import java.awt.event.*;

public class ScrollPanel extends Panel implements AdjustmentListener
{
Chart applet;
Scrollbar scr;

ScrollPanel(Chart applet)
{
this.applet = applet;
scr = new Scrollbar(Scrollbar.HORIZONTAL, 0, 1, 0, 14);
scr.addAdjustmentListener(this);
setLayout(new BorderLayout());
add(scr, BorderLayout.CENTER);
setBackground(Color.white);
}

public void adjustmentValueChanged(AdjustmentEvent e)
{
//처리
//e.getValue();
applet.chartCanvas.sbChanged(scr);

}

}

음 이거는 더 쉽군요. 그냥 Panel을 하나 만들어 거기에 BorderLayout을 이용해 scrollbar를 하나 생성해 붙였습니다. 이 scrollbar에는 AdjustmentListner(this)를 붙여야겠지요? 따라서 public void adjustmentValueChanged(AdjustmentEvent e)도 구현해 넣었고, 이는 메인차트의 sbChanged메소드를 호출합니다. sbChanged메소드는 메인차트의 기준좌표축을 이동시킨 후(중요!! translate메소드 이용) 다시 그리도록 해줍니다.

이제 제일 중요한 메인차트 클래스 ChartCanvas입니다.

import java.awt.*;
import java.awt.event.*;

public class ChartCanvas extends Canvas //implements MouseMotionListener
{
Chart applet;
private int nPrePointX, nPrePointY, nPointX, nPointY, nOriginX;
private boolean pointFlag;
private Image backImg;
private Graphics G;

ChartCanvas(Chart applet)
{
this.applet = applet;
setBackground(Color.white);
}

public void sbChanged(Scrollbar scrollbar)
{
nOriginX = -scrollbar.getValue() * 30;
repaint();
}

/*

public void mouseMoved(MouseEvent e)
{
nPointX = e.getX();
nPointY = e.getY();
// if(nPointX != nPrePointX || nPointY != nPrePointY)
// {
drawMoveXLine(nPointX);
drawMoveYLine(nPointY);
// }
nPrePointX = nPointX;
nPrePointY = nPointY;
}

public void mouseDragged(MouseEvent e)
{
nPointX = e.getX();
nPointY = e.getY();
// if(nPointX != nPrePointX || nPointY != nPrePointY)
// {
drawMoveXLine(nPointX);
drawMoveYLine(nPointY);
// }
nPrePointX = nPointX;
nPrePointY = nPointY;
}

*/

public void drawMoveXLine(int i)
{
Graphics g = getGraphics();
g.setColor(Color.black);
g.setXORMode(Color.white);
if(!pointFlag)
g.drawLine(nPrePointX, 1, nPrePointX, getSize().height - 15);
g.drawLine(i, 1, i, getSize().height - 15);
pointFlag = false;
g.dispose();
}

public void drawMoveYLine(int i)
{
Graphics g = getGraphics();
g.setColor(Color.black);
g.setXORMode(Color.white);
if(!pointFlag)
g.drawLine(1, nPrePointY, getSize().width - 1, nPrePointY);
g.drawLine(1, i, getSize().width - 1, i);
pointFlag = false;
g.dispose();
}

public void setDBuf()
{
backImg = createImage(getSize().width, getSize().height);
G = backImg.getGraphics();
}

public void setOrigin()
{
G.translate(nOriginX, 0);
}

public void setOutLine()
{
G.setColor(Color.black);
G.drawRect(1 - nOriginX, 1, getSize().width - 2, getSize().height - 30);
}

public void setGrid(FontMetrics fm)
{
G.setColor(Color.lightGray);

//수평선그리기
for(int i = 0; i < 6 ; i++)
G.drawLine(2-nOriginX, (int)(getSize().height * i / 6), getSize().width - 2-nOriginX, (int)(getSize().height * i / 6));

//X축 그리기
G.drawLine(-nOriginX, getSize().height - 20,getSize().width - 15 -nOriginX, getSize().height - 20);
G.drawString("▶", getSize().width - 15 -nOriginX, getSize().height - 20 + (int)(fm.getHeight() / 2)- 1);

//수직선그리기
}
public void setRange()
{
applet.naxismax = applet.naxismin = 0;
for(int i=0; i < applet.vtDay.size() && i < applet.vtSent.size() && i < applet.vtRecv.size() ; i++)
{
if( i == 0 )
applet.naxismin = (Integer.parseInt((String)applet.vtSent.elementAt(i)) < Integer.parseInt((String)applet.vtRecv.elementAt(i)) ?
Integer.parseInt((String)applet.vtSent.elementAt(i)) : Integer.parseInt((String)applet.vtRecv.elementAt(i)));
if( i > (int)(-nOriginX / 30) && i < (int)((-nOriginX + getSize().width) / 30) )
{
if(Integer.parseInt((String)applet.vtSent.elementAt(i)) > applet.naxismax)
applet.naxismax = Integer.parseInt((String)applet.vtSent.elementAt(i));
if(Integer.parseInt((String)applet.vtRecv.elementAt(i)) > applet.naxismax)
applet.naxismax = Integer.parseInt((String)applet.vtRecv.elementAt(i));
if(Integer.parseInt((String)applet.vtSent.elementAt(i)) < applet.naxismin)
applet.naxismin = Integer.parseInt((String)applet.vtSent.elementAt(i));
if(Integer.parseInt((String)applet.vtRecv.elementAt(i)) < applet.naxismin)
applet.naxismin = Integer.parseInt((String)applet.vtRecv.elementAt(i));
}
}
applet.axisCanvas.repaint();
}
public void drawData()
{
//데이타 그리기
int nSent = 0, nRecv = 0, nPreSent = 0, nPreRecv = 0;
float nSentRatio = 0.0f, nRecvRatio= 0.0f;
int ndrawheight = (int)(getSize().height * 2 / 3);
int ndrawgap = (int)(getSize().height / 6);
int nrange = applet.naxismax - applet.naxismin;
for(int i=0; i < applet.vtDay.size() && i < applet.vtSent.size() && i < applet.vtRecv.size() ; i++)
{
nSentRatio = (float)((float)(applet.naxismax - Integer.parseInt((String)applet.vtSent.elementAt(i))) / nrange);
nSent = (int)(nSentRatio * ndrawheight) + ndrawgap;
nRecvRatio = (float)((float)(applet.naxismax - Integer.parseInt((String)applet.vtRecv.elementAt(i))) / nrange);
nRecv = (int)(nRecvRatio * ndrawheight) + ndrawgap;
// System.out.println(nSentRatio +" " + nSent + " " + nRecvRatio + " " + nRecv);

if(applet.strType.equals("0"))
{
G.setColor(Color.blue);
G.fillRect( i * 30 + 20 - 1, nSent - 1, 3, 3);
G.setColor(Color.red);
G.fillRect( i * 30 + 20 - 1, nRecv - 1, 3, 3);
if(i > 0)
{
G.setColor(Color.blue);
G.drawLine( (i - 1) * 30 + 20 ,nPreSent , i * 30 + 20, nSent);
G.setColor(Color.red);
G.drawLine( (i - 1) * 30 + 20, nPreRecv , i * 30 + 20, nRecv);
}
}
if(applet.strType.equals("1"))
{
G.setColor(new Color(100,100,255));
if(Integer.parseInt((String)applet.vtSent.elementAt(i)) == applet.naxismin)
G.drawLine( i * 30 + 20 - 10, nSent, i * 30 + 20 - 1, nSent);
else
{
G.fillRect( i * 30 + 20 - 10, nSent, 9, -(nSent - (int)(getSize().height * 5 / 6)));
G.setColor(Color.black);
G.drawRect( i * 30 + 20 - 10, nSent, 9, -(nSent - (int)(getSize().height * 5 / 6)));
}
G.setColor(new Color(255,100,100));
if(Integer.parseInt((String)applet.vtRecv.elementAt(i)) == applet.naxismin)
G.drawLine( i * 30 + 20 + 1, nRecv, i * 30 + 20 + 10, nRecv);
else
{
G.fillRect( i * 30 + 20 + 1, nRecv, 9, -(nRecv - (int)(getSize().height * 5 / 6)));
G.setColor(Color.black);
G.drawRect( i * 30 + 20 + 1, nRecv, 9, -(nRecv - (int)(getSize().height * 5 / 6)));
}
}
G.setColor(Color.black);
G.drawString((String)(applet.vtDay.elementAt(i)),i * 30 + 20 - 8,getSize().height - 4);
G.drawString("day", getSize().width - 25 - nOriginX, getSize().height - 40);
G.drawString(applet.strYear, 5 - nOriginX, getSize().height - 52);
G.drawString(applet.strMonth, 5 - nOriginX, getSize().height - 40);

nPreSent = nSent;
nPreRecv = nRecv;
}
}

public void paint(Graphics g)
{
FontMetrics fm = g.getFontMetrics(g.getFont());
setDBuf();
setOrigin();
setOutLine();
setGrid(fm);
setRange();

drawData();

g.drawImage(backImg, 0, 0, this);
G.dispose();
backImg.flush();
pointFlag = true;
}
}

음 메인차트인 만큼 제일 길군요. 헉. 죄송합니다. x,y 좌표축을 마우스를 따라 그리는 구현을 해두었는데, 미완성인 채로 올렸군요. /* */ 처리 해두었습니다. 마우스 이벤트는 보지 마세요 ㅜ.ㅜ;

일단, 생성자를 보시면 받아오기만 하고 있고요. 그 밑에 아까 말한 sbChanged 메소드가 보이고요. (물론 제가 만든 메소드입니다.) 그 담에 보실 것은 paint메소드지요. setDBuf에서 더블버퍼링을 준비하고 있고, setOrigin 메소드에서는 sbChanged에서 바꾼 nOriginX를 이용해서 전체 캔버스의 좌표축을 translate해주고 있습니다. 아웃라인 그려주고, Grid도 그려주고요. maximum과 minimum값도 여기서 변경해주고 있지요. setRange메소드를 보시면, 전체 Vector데이터의 Size를 돌리면서 최대,최소값을 추출하고 잇는데요, 여기서 if문을 이용해서 보이는 부분에 대해서만 최대, 최소를 지정하도록 하고 있습니다. if문을 생략하시면, 전체 Data를 검색해서 최대,최소를 추출하겠지요.

drawData에서 실제로 그려주고 있습니다.그리고 더블버퍼링 처리를 해주는데, 더블버퍼링에 사용한 Image객체와 Graphics객체를 소거해주는 부분을 눈여겨 봐주시면 좋겠습니다.

끝이군요. 수고하셨습니다. 소스 받아가세요.

오늘은 여기까지 하고요, 다음번엔 jdbc를 이용해서 oracle에서 데이타를 받아오는 걸 한 번 해볼까요? 시간이 되면 C와의 socket통신을 하는 것도 보여드리도록 하겠습니다.

더운 여름인데요(이 글 보실 때는 겨울일지도) 건강 조심하세요.

소스 다운

 

증권 차트 등 3가지의 프로젝트를 보다 충실하고 친절하게 설명한

"자바 실무 테크닉 비법전수"가 발간되었습니다.

구경하기 -> [http://www.50001.com/books/ ]