2010년 5월 18일 화요일

[펌]Java GAE 시작上 (Datastore)

저는 파이썬보다는 자바가 더 친숙하고, 자바보다는 C#이 더 친숙한 편입니다. 그래서 파이썬만 제공될 때는 해보고 싶다 생각했어도 일상에 쫓겨 마음 한 구석에 접어 두었는데, 얼마 전에 아직('09 5월 초 시점) 어설픈 상태(Early Look)긴 하지만 자바가 추가 지원 언어로 제공된 것을 보았고, 이번에 팀 회의에서 웹서비스의 일부를 구글 앱 엔진으로 빼내서 하드웨어 추가구매 없이 서비스 퍼포먼스를 향상시키자는 의견을 제안, 의사결정권자의 샘플 작성 허가를 얻어서 시도해보는 김에 이렇게 튜토리얼도 적어보고자 합니다.

리소스
  1. http://code.google.com/intl/ko-KR/appengine/
  2. http://code.google.com/intl/ko/appengine/downloads.html
  3. http://code.google.com/eclipse/docs/download.html 
  4. http://code.google.com/intl/ko/appengine/docs/java/gettingstarted/


먼저 구글 앱 엔진의 인프라를 이용하려면 구글계정으로 애플리케이션 엔진 계정에 가입하는 과정부터 시작하게 됩니다.(리소스 1 참조)

로그인을 하시면 http://appengine.google.com/으로 연결되고 애플리케이션을 관리할 수 있는 대시보드가 나타납니다.


이 곳에서 애플리케이션을 최대 10개까지 생성, 관리하실 수 있습니다. 그리고 각 애플리케이션 마다 꽤 많은 걸 할 수 있습니다.

로그 조회, cron.yaml을 활용한 작업 예약 스케쥴러 이용, 함께 애플리케이션 개발에 참여 가능한 구글 아이디 추가, 데이터 스토어의 데이터 현황 조회, 리비전 조회 등 왠만한 IT 부서에서 맨 땅에 지으려면 비용이 꽤 드는 지원요소들이 고맙게도 잘 준비되어 있습니다.


그리고 Google App Engine SDK for Java를 다운받으셔야 합니다. 학부 신입생 시절에 자바 SDK가 뭐야? 라고 떠들면서 sun.com에 접속하던 기억이 떠오르네요^^a(리소스 2 참조)

전 내려 받은 SDK를 d:\appengine-java-sdk에 저장했습니다. 커맨드라인에 d:\appengine-java-sdk\bin\dev_appserver.cmd d:\appengine-java-sdk\demos\guestbook\war를 입력하셔서 데모가 잘 동작하는지 확인할 수 있습니다.

혹시 숙련도 향상을 위한 노트패드 개발 등의 수행을 하고 계신 것이 아니라면 이클립스용 구글 플러그인(Google Plugin for Eclipse)도 설치하시는 것을 권장합니다.(리소스 3 참조)


벌써 코드를 작성하고 싶어 손이 근질근질 하실 수 있지만, 일단은 개발 프로세스를 살펴야 합니다. 먼저 프로젝트를 만드는 법을 살펴봅니다.

GAE에서 프로젝트를 운영하려면 GAE의 폴더 아키텍쳐를 이해하면 좋습니다. 그런데 이클립스와 플러그인을 이용하면 사실.. 잘 몰라도 됩니다.

어린 시절 JSP를 처음 접할 때 아파치니 톰캣이니 설정하고 서버를 올렸다가 내렸다가, admin 페이지에서 애플리케이션 릴름을 잡아 주느니 마느니 하다가... 머리에 스팀이 오르는 걸 느끼신 분도 계실 겁니다.

그만큼 뭔가 서버를 설정하고 애플리케이션을 디플로이한 다음 Hello my first jsp page! 라고 찍기까지 꽤 노력이 필요했단 얘기죠.

그런데.. 말씀드린 것처럼 이클립스 쓰시면 꽤 쉽습니다.


일단 아래처럼 프로젝트 템플릿으로 구글 웹 애플리케이션 프로젝트를 만들어 봅니다.



마법사에서 프로젝트를 고른 다음 아래처럼 간단하게 네이밍을 해줍니다. (GWT가 맘에 안들면 선택해제 하셔도 됩니다.)



'완료' 버튼을 누르시면 뭐가 그리 쉬운지 뚝딱! 구글 웹 애플리케이션 아키텍쳐를 충실히 갖춘 프로젝트가 만들어집니다.

아래처럼 콘솔에 소요된 시간도 나타납니다.
DataNucleus Enhancer (version 1.1.0) : Enhancement of classes

DataNucleus Enhancer completed with success for 0 classes. Timings : input=31 ms, enhance=0 ms, total=31 ms. Consult the log for full details
DataNucleus Enhancer completed and no classes were enhanced. Consult the log for full details



아래는 기본적인 프로젝트 폴더 구조입니다.



이제 브라우저에서 애플리케이션을 확인하려면, 디플로이 단계만 거치면 되는데 이 또한 간단합니다.

이클립스 상단(아.. 사용자마다 꼭 상단은 아니겠네요; 저의 경우~) 툴바에서 디플로이 아이콘 꾸욱 누릅니다.



그러면 애플리케이션 이름과 인증정보를 요구하는 창이 떠오릅니다.



이제 Ok를 누르면 컴파일이니 링크니 업로드니.. 다 척척 진행됩니다.



마지막으로 http://당신의-앱-이름.appspot.com/ 을 브라우저 주소창에 입력하면 예쁘장한 기본 페이지가 뜨는 것을 볼 수 있습니다.

생각보다 쉽지 않나요?^^; 무료.. 무료... 무료....-_-;;


- 下편에서는 데이터저장소(Datastore) 등 좀 더 세부적인 면을 다뤄보려고 계획 중입니다.
이제 정말.. 시스템 관리자, 개발자, DBA 같은 1~2개 카테고리의 스킬만 전문적으로 보유한 인력보다는,
여러 서비스와 트렌드, 마켓을 이해하고 엮어서 필요한 세계를 꾸려낼 수 있는, 컨버전스를 해낼 수 있는
인력의 비중과 가치가 더 높아질 것 같다는 생각도 듭니다.

Google App Engine에서 Spring3 + JPA 사용한 소녀시대 예제

앱엔진에 뭔가 제 사이트를 만들고 싶어서 이렇게 삽질을 하고 있는데, 망할 제한이 왜이렇게 많지-_-

암튼, "구글 앱 엔진"에서는 JPA를 지원합니다. 하지만, 이상하게도 잘 안됩니다-_- 굉장히 제한적으로 이것저것 막아둔 것 같습니다. 사실 구글 앱 엔진에서는 DataBase를 BigTable인지 뭐시기인지 그걸 사용하고, 직접적으로 접근을 못하기 때문에(전부 프로그래밍 또는 관리페이지(관리페이지도 매우 제한적인-_-)에서만 관리 가능), 이걸 이용하는 API에서도 엄청나게 뭔가 막아둔 것 같습니다.
뭐 좀 해보려고 하면 에러를 내뱉습니다. 검색해보면 구글앱엔진에서만 나는 에러입니다-_- 사실 아직 구글앱엔진이 프리뷰버전이기에 뭐라 따지지도 못하는 게 사실입니다^^ 정식버전(언제나오려나....Beta딱지 떼는데 10년넘게 걸리겠지-_-)나오면 매우 안정화가 되지 않을까 싶습니다^^

암튼, Spring3 + JPA의 조합으로 앱엔진에 올리는 건 성공했는데, 사실 스프링에서 제공하는 TransactionManager를 사용했어야 했는데, JPATemplate으로 뭔가 처리를 하면 잘 안되더군요-_- 일단 가져오고, persist하고, 이런건 잘 되는데, 왜 삭제가 안될까요-_- 삭제가 안되서 그냥JPATemplate빼고 했습니다-_-
JPATemplate사용해서 성공하신 분 트랙백좀 ㅠㅠ

0. 환경
Eclipse 3.5 + Google AppEngine Plugin + Spring 3.0.0
일단 스프링3다운로드 - http://www.springsource.org/download

1. 프로젝트 생성
New Project -> Google에 있는 Web Application Project 선택.
Project Name은 SosiSchedule. package는 com.mudchobo.
Use Google Web Toolkit은 체크해제. 사용안할꺼라....(이것도 언제한번 공부해야하는데-_-)
Finish.

2. 라이브러리 복사 및 build path추가
spring3에서는 spring.jar가 산산조각 났어요. 필요한 것만 넣으면 되는 듯.
일단 제가 사용한 것은....
org.springframework.asm-3.0.0.RELEASE.jar
org.springframework.beans-3.0.0.RELEASE.jar
org.springframework.context-3.0.0.RELEASE.jar
org.springframework.core-3.0.0.RELEASE.jar
org.springframework.expression-3.0.0.RELEASE.jar
org.springframework.orm-3.0.0.RELEASE.jar
org.springframework.web.servlet-3.0.0.RELEASE.jar
org.springframework.web-3.0.0.RELEASE.jar
그리고, jstl을 사용할 것이기에....
jstl.jar와 standard.jar
※이번버전에서는 lib폴더가 없습니다-_- 어디서 찾아야하는 거지-_- 암튼 그래서 2.5.6버전에서 가져왔습니다^^

앱엔진에서는 lib폴더 복사로 libpath가 잡히지 않네요. 그래서 각각 다 추가해줘야한다는...-_-
일단 war/WEB-INF/lib폴더에 복사 후에 복사한 파일 선택 후 오른쪽버튼 후, Build Path -> Add to Build Path 선택하면 됩니다^^

3. web.xml파일 수정
web.xml
<servlet>
   
<servlet-name>dispatcher</servlet-name>
   
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
   
<load-on-startup>2</load-on-startup>
</servlet>

<servlet-mapping>
   
<servlet-name>dispatcher</servlet-name>
   
<url-pattern>/sosischedule/*</url-pattern>
</servlet-mapping>

<welcome-file-list>
   
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>

일단 sosischedule/*요청은 spring이 받습니다.

4. dispacher-servlet.xml파일과 persistence.xml파일 생성
war/WEB-INF/폴더에 생성
dispatcher-servlet.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
   
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   
xmlns:p="http://www.springframework.org/schema/p"
   
xmlns:context="http://www.springframework.org/schema/context"
   
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd"
>

   
<context:component-scan base-package="com.mudchobo" />
   
   
<bean id="entityManager"
       
factory-bean="EMF"
       
factory-method="get" />
   
   
<bean id="viewResolver"
       
class="org.springframework.web.servlet.view.InternalResourceViewResolver"
       
p:prefix="/WEB-INF/jsp/" p:suffix=".jsp" />
</beans>


src/META-INF/ 폴더에 생성
persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
   
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
        http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd"
version="1.0">
   
   
<persistence-unit name="transactions-optional">
       
<provider>org.datanucleus.store.appengine.jpa.DatastorePersistenceProvider</provider>
       
<properties>
           
<property name="datanucleus.NontransactionalRead" value="true"/>
           
<property name="datanucleus.NontransactionalWrite" value="true"/>
           
<property name="datanucleus.ConnectionURL" value="appengine"/>
       
</properties>
   
</persistence-unit>
</persistence>


5. EMF클래스 생성.
이제 jpa접근할 수 있는 EntityManagerFactory클래스(EMF)를 생성해봅시다.
com.mudchobo.sosi.sosischedule.dao.EMF.java
package com.mudchobo.sosischedule.dao;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

import org.springframework.stereotype.Component;

@Component
public final class EMF {
     
private static final EntityManagerFactory emfInstance =
       
Persistence.createEntityManagerFactory("transactions-optional");

   
private EMF() {}

   
public EntityManager get() {
       
return emfInstance.createEntityManager();
   
}
}


6. Entity클래스 생성
일단 Sosi와 Schedule이라는 Entity를 생성할 건데요. 둘의 관계는 1:N관계입니다.
com.mudchobo.sosischedule.entity.Sosi.java
package com.mudchobo.sosischedule.entity;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;

import com.google.appengine.api.datastore.Key;

@Entity
public class Sosi implements Serializable {
   
private static final long serialVersionUID = 5448408922872112420L;

   
@Id
   
@GeneratedValue(strategy=GenerationType.IDENTITY)
   
private Key key;
   
   
private String sosiName;
   
   
@OneToMany(mappedBy="sosi", cascade=CascadeType.ALL)
   
private List<Schedule> scheduleList = new ArrayList<Schedule>();

   
public Key getKey() {
       
return key;
   
}

   
public void setKey(Key key) {
       
this.key = key;
   
}

   
public List<Schedule> getScheduleList() {
       
return scheduleList;
   
}

   
public void setScheduleList(List<Schedule> scheduleList) {
       
this.scheduleList = scheduleList;
   
}

   
public String getSosiName() {
       
return sosiName;
   
}

   
public void setSosiName(String sosiName) {
       
this.sosiName = sosiName;
   
}

   
public Sosi() {
       
   
}
   
   
public Sosi(Key key, String sosiName) {
       
super();
       
this.key = key;
       
this.sosiName = sosiName;
   
}
}

com.mudchobo.sosischedule.entity.Schedule.java
package com.mudchobo.sosischedule.entity;

import java.io.Serializable;

import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToOne;

import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;

@Entity
public class Schedule implements Serializable{
   
private static final long serialVersionUID = -8676837674549793653L;

   
@Id
   
@GeneratedValue(strategy = GenerationType.IDENTITY)
   
private Key key;
   
   
private String program;
   
   
@ManyToOne(fetch=FetchType.LAZY)
   
private Sosi sosi;
   
   
public Sosi getSosi() {
       
return sosi;
   
}

   
public void setSosi(Sosi sosi) {
       
this.sosi = sosi;
   
}

   
public Key getKey() {
       
return key;
   
}

   
public void setKey(Key key) {
       
this.key = key;
   
}
   
   
   
public String getKeyString() {
       
return KeyFactory.keyToString(key);
   
}
   
   
public String getProgram() {
       
return program;
   
}

   
public void setProgram(String program) {
       
this.program = program;
   
}
   
   
public Schedule() {
   
}

   
public Schedule(String program, Sosi sosi) {
       
this.program = program;
       
this.sosi = sosi;
   
}
}

일단 App Engine용 JPA에서는 ID 타입이 Long이면 관계형태를 사용할 수 없더라구요. 그래서 앱엔진에서 제공하는 Key타입이 있는데, 이걸 이용해야합니다.

7. Dao만들기
com.mudchobo.sosisochedule.SosiDao.java
package com.mudchobo.sosischedule.dao;

import java.util.List;

import javax.persistence.EntityManager;
import javax.persistence.Query;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import com.google.appengine.api.datastore.KeyFactory;
import com.mudchobo.sosischedule.entity.Schedule;
import com.mudchobo.sosischedule.entity.Sosi;

@Repository
public class SosiDao {
   
private EntityManager em;
   
   
@Autowired
   
public void setEntityManager(EntityManager em) {
       
this.em = em;
       
       
// 소시데이터 추가
        addSosi
(new Long(1), "효연");
        addSosi
(new Long(2), "윤아");
        addSosi
(new Long(3), "수영");
        addSosi
(new Long(4), "유리");
        addSosi
(new Long(5), "태연");
        addSosi
(new Long(6), "제시카");
        addSosi
(new Long(7), "티파니");
        addSosi
(new Long(8), "써니");
        addSosi
(new Long(9), "서현");
   
}
   
   
public void addSosi(Long id, String sosiName) {
        em
.getTransaction().begin();
        em
.persist(new Sosi(KeyFactory.createKey(Sosi.class.getSimpleName(), id), sosiName));
        em
.getTransaction().commit();
   
}
   
   
@SuppressWarnings("unchecked")
   
public List<Sosi> getSosiList() {
       
return em.createQuery("select s from Sosi s").getResultList();
   
}

   
public Sosi getSosi(Long sosiId) {
       
return em.find(Sosi.class, sosiId);
   
}
   
   
@SuppressWarnings("unchecked")
   
public List<Schedule> getScheduleList(final Long sosiId) {
       
Query q = em.createQuery("select s.scheduleList from Sosi s where s.key = :key");
        q
.setParameter("key", KeyFactory.createKey(Sosi.class.getSimpleName(), sosiId));
       
return (List<Schedule>) q.getSingleResult();
   
}
   
   
public void addSchedule(Long sosiId, String program) {
        em
.getTransaction().begin();
       
Sosi sosi = em.find(Sosi.class, sosiId);
        sosi
.getScheduleList().add(new Schedule(program, sosi));
        em
.getTransaction().commit();
   
}
   
   
public void deleteSchedule(String scheduleKey) {
        em
.getTransaction().begin();
       
Schedule schedule = em.find(Schedule.class, scheduleKey);
        em
.remove(schedule);
        em
.getTransaction().commit();
   
}
}

EntityManager받을 때 디폴트로 데이터를 넣어줘야 합니다(아까 위에서 말했듯이 프로그래밍적으로만 테이블을 생성할 수 있어서 이런 형태로 데이터를 넣어줘야합니다ㅠㅠ)

일단 실행해보고 데이터가 잘 생성되었는지 보려면 아래와 같은 주소로 접속해보면 됩니다.
http://localhost:8888/_ah/admin
일단 보고 삭제까지는 되는데, 테이블 생성같은 건 안되더라구요. 그리고 여기서 보여지는데에는 한글이 깨지는데 나중에 출력해보면 잘 나오니 걱정마시길-_-
8. Service 클래스 생성
com.mudchobo.sosischedule.service.SosiService.java
package com.mudchobo.sosischedule.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.mudchobo.sosischedule.dao.SosiDao;
import com.mudchobo.sosischedule.entity.Schedule;
import com.mudchobo.sosischedule.entity.Sosi;

@Service
public class SosiService {
   
   
@Autowired
   
private SosiDao sosiDao;
   
   
public List<Sosi> getSosiList()
   
{
       
return sosiDao.getSosiList();
   
}
   
   
public Sosi getSosi(Long sosiId) {
       
return sosiDao.getSosi(sosiId);
   
}
   
   
public List<Schedule> getScheduleList(Long sosiId) {
       
return sosiDao.getScheduleList(sosiId);
   
}
   
   
public void deleteSchedule(String scheduleKey) {
        sosiDao
.deleteSchedule(scheduleKey);
   
}

   
public void addSchedule(Long sosiId, String program) {
        sosiDao
.addSchedule(sosiId, program);
   
}
}

Service에서 하는 역할은 뭐 없네요-_-

9. Controller생성
스프링3.0에서 새로 추가된 기능인 REST기능입니다.
com.mudchobo.sosischedule.controller.SosiController.java
package com.mudchobo.sosischedule.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import com.mudchobo.sosischedule.entity.Schedule;
import com.mudchobo.sosischedule.entity.Sosi;
import com.mudchobo.sosischedule.service.SosiService;

@Controller
public class SosiController {
   
private static String PREFIX = "/sosischedule";
   
   
@Autowired
   
private SosiService sosiService;
   
   
@RequestMapping(value="/", method=RequestMethod.GET)
   
public String index(Model model) {
       
List<Sosi> sosiList = sosiService.getSosiList();
        model
.addAttribute("sosiList", sosiList);
       
       
return "index";
   
}
   
   
@RequestMapping(value="/schedule/{sosiId}", method=RequestMethod.GET)
   
public String getSchedule(
           
@PathVariable("sosiId") Long sosiId,
           
Model model) {
       
Sosi sosi = sosiService.getSosi(sosiId);
       
List<Schedule> scheduleList = sosiService.getScheduleList(sosiId);
        model
.addAttribute("scheduleList", scheduleList)
           
.addAttribute("sosi", sosi);
       
       
return "sosi";
   
}
   
   
@RequestMapping(value="/schedule/{sosiId}/add", method=RequestMethod.POST)
   
public String addSchedule(
           
@PathVariable("sosiId") Long sosiId,
           
@RequestParam("program") String program,
           
Model model
           
) {
        sosiService
.addSchedule(sosiId, program);
       
       
return "redirect:" + PREFIX + "/schedule/" + sosiId;
   
}
   
   
@RequestMapping(value="/schedule/{sosiId}/{scheduleKey}", method=RequestMethod.GET)
   
public String removeSchedule(
           
@PathVariable("sosiId") Long sosiId,
           
@PathVariable("scheduleKey") String scheduleKey,
           
Model model) {
        sosiService
.deleteSchedule(scheduleKey);
       
       
return "redirect:" + PREFIX + "/schedule/" + sosiId;
   
}
}


10. View jsp파일 생성
소시 리스트를 보여주는 index파일 입니다.
war/WEB-INF/jsp/index.jsp
<%@ page language="java" contentType="text/html; charset=utf-8"
    pageEncoding
="UTF-8"%>
<%@ page isELIgnored="false" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>소녀시대 스케줄</title>
</head>
<body>
   
<div>
        스케줄 확인하기
       
<ul>
       
<c:forEach var="sosi" items="${sosiList}">
           
<li><a href="/sosischedule/schedule/${sosi.key.id}">${sosi.key.id}. ${sosi.sosiName}</a></li>
       
</c:forEach>
       
</ul>
   
</div>
</body>
</html>


해당 소시의 스케줄을 보여주는 스케줄 파일입니다.
war/WEB-INF/jsp/sosi.jsp
<%@ page language="java" contentType="text/html; charset=utf-8"
    pageEncoding
="UTF-8"%>
<%@ page isELIgnored="false" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>소녀시대 스케줄</title>
</head>
<body>
   
<div>
        스케줄 확인하기
       
<ul>
       
<c:forEach var="sosi" items="${sosiList}">
           
<li><a href="/sosischedule/schedule/${sosi.key.id}">${sosi.key.id}. ${sosi.sosiName}</a></li>
       
</c:forEach>
       
</ul>
   
</div>
</body>
</html>

리다이렉트를 위한 파일입니다. 기존 index.html파일 지우시고, index.jsp파일 생성
index.jsp
<% response.sendRedirect("/sosischedule/"); %>


앱엔진에 올려보았습니다.
http://2.latest.mudchobosample.appspot.com/sosischedule/