java-school logo

Objectify

Objectify는 구글이 데이터스토어를 위해 만든 자바 API이다. 데이터스토어는 구글 클라우드의 공식 저장소다. 일단 해시 맵의 해시 맵 정도로 이해하고 넘어가자.

Objectify 추가

pom.xml의 properties 엘리먼트에 다음을 추가한다.

<objectify.version>5.1.13</objectify.version>
<guava.version>20.0</guava.version>

dependencies 엘리먼트에 다음을 추가한다.

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>${guava.version}</version>
</dependency>
<dependency>
    <groupId>com.googlecode.objectify</groupId>
    <artifactId>objectify</artifactId>
    <version>${objectify.version}</version>
</dependency>

guestbook 프로젝트는 Objectify를 사용하고 있고 위 설정은 이미 되어 있다.

삭제 기능 추가

삭제 기능 추가를 구현하기에 앞서, 이미 구현된 Objectify 코드를 살펴보자.

목록 가져오기
List<Greeting> greetings = ObjectifyService.ofy()
	.load()
	.type(Greeting.class) // We want only Greetings
	.ancestor(theBook)    // Anyone in this book
	.order("-date")       // Most recent first - date is indexed.
	.limit(5)             // Only show 5 of them.
	.list();
엔티티 저장
ObjectifyService.ofy().save().entity(greeting).now();

예를 보듯이 데이터스토어를 다루는 코드는 관계형 데이터베이스를 다루는 코드와 모습이 완전히 다르다. (데이터스토어를 해시 맵의 해시 맵이라고 이해했다면 완전히 다른 모습의 코드가 이상하지 않을 것이다.)

다음은 엔티티 삭제를 위해 사용해야 할 코드다.

엔티티 삭제
ObjectifyService.ofy().delete().key(key).now();

여기서 key는 엔티티의 고유 키다. 고유 키는 아래 코드로 얻는다.

Key.create(theBook, Greeting.class, id);

이 코드를 사용하면 guestbook.jsp에서 각각의 Greeting 객체의 고유 키를 얻을 수 있다. 그러기 위해 먼저 Greeting.java에 자신의 고유 키를 반환하는 메서드를 다음과 같이 추가한다.

public Key getKey() {
	return Key.create(theBook, Greeting.class, id);
}

여기에서 Key의 타입은 com.googlecode.objectify.Key다. Key는 자기 자신으로 복원할 수 있는 String을 반환하는 메서드인 toWebSafeString()을 가진다. 따라서 Greeting의 키 문자열은 아래와 같이 구할 수 있다.

// Look at all of our greetings
for (Greeting greeting : greetings) {
	pageContext.setAttribute("greeting_content", greeting.content);
	pageContext.setAttribute("keyString", greeting.getKey().toWebSafeString());
		
	//omit

pageContext에 저장한 keyString을 이용해서 자바스크립트 함수에 keyString을 전달하는 링크를 생성할 수 있다. (자바스크립트와 필요한 폼 태그는 곧 다룬다.)

<p><b>${fn:escapeXml(greeting_user)}</b> wrote:</p>
<blockquote>${fn:escapeXml(greeting_content)}</blockquote>
<blockquote><a href="javascript:del('${keyString }')">Del</a></blockquote>

하지만, 이렇게 구현하면 작성자가 아닌 사용자가 글을 삭제할 수 있다. 심지어 로그인하지 않은 사용자도 글을 삭제할 수 있다. 프로그램에 스프링 시큐리티가 작동하고 있다는 것을 상기하자. 스프링 시큐리티 태그를 사용하면 권한에 따라 뷰를 선택적으로 랜더링할 수 있다. 일단, 로그인 사용자와 작성자를 비교하려면 작성자 아이디를 pageContext에 저장해야 한다. (아래 강조한 부분 참조)

// Look at all of our greetings
for (Greeting greeting : greetings) {
    pageContext.setAttribute("greeting_content", greeting.content);
    pageContext.setAttribute("keyString", greeting.getKey().toWebSafeString());
    String author;
    String author_id = null;
    if (greeting.author_email == null) {
        author = "An anonymous person";
    } else {
        author = greeting.author_email;
        author_id = greeting.author_id;
        if (user != null && user.getUserId().equals(author_id)) {
            author += " (You)";
        }
    }
    pageContext.setAttribute("greeting_user", author);
    pageContext.setAttribute("author_id", author_id);
    
    //omit

로그인 사용자와 작성자를 비교하기 위한 모든 준비가 끝났다. 자바스크립트 함수에 keyString을 전달하는 링크를 아래와 같이 구현한다.

<p><b>${fn:escapeXml(greeting_user)}</b> wrote:</p>
<blockquote>${fn:escapeXml(greeting_content)}</blockquote>
<security:authorize access="isAuthenticated() and (#author_id == principal.userId or hasRole('ROLE_ADMIN'))">
	<blockquote><a href="javascript:del('${keyString }')">Del</a></blockquote>
</security:authorize>

스프링 시큐리티 태그를 사용하려면 스프링 시큐리티 태그 라이브러리 지시어를 guestbook.jsp에 추가해야 한다.

<%@ taglib uri="http://www.springframework.org/security/tags" prefix="security" %>

위에서 쓰인, 다음 스프링 시큐리티 태그는 로그인 사용자 중 글 작성자나 관리자에게 삭제 링크를 보여준다.

<security:authorize access="isAuthenticated() and (#author_id == principal.userId or hasRole('ROLE_ADMIN'))">

자바스크립트 함수 추가

<script type="text/javascript">
function del(key) {
	var check = confirm('Are you sure you want to delete this greeting?');
	if (check) {
    	var form = document.getElementById("delForm");
    	form.keyString.value = key;
    	form.submit();
	}
}
</script>

폼 태그 추가

<form id="delForm" action="/guestbook/del" method="post" style="display: none;">
	<input type="hidden" name="keyString" />
	<input type="hidden" name="guestbookName" value="${fn:escapeXml(guestbookName)}"/>
	<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
</form>

컨트롤러에 "/guestbook/del" 요청 핸들러 추가

GuestbookController에 "/guestbook/del" 요청을 처리하는 핸들러를 아래와 같이 추가한다.

@RequestMapping(value="/guestbook/del", method=RequestMethod.POST)
public String del(String guestbookName, String keyString) {
	Key<Greeting> key = Key.create(keyString);//고유 키 복원
	ofy().delete().key(key).now();
	return "redirect:/guestbook/?guestbookName=" + guestbookName;
}

하지만, 이렇게 구현하면 서버 측에서의 사용자 검증을 생략하게 된다. 서버 측에서는 스프링 시큐리티의 메소드 보안을 사용해 로그인 사용자가 작성자인지 검사하도록 구현하자. 메소드 보안은 서비스 계층에 적용하는 것이 좋으니, 다음과 같이 서비스 계층을 추가한다.

서비스 계층 추가

GuestbookService.java
package net.java_school.guestbook;

import org.springframework.stereotype.Service;

@Service
public interface GuestbookService {
	public void sign(Greeting greeting);
	public void del(Greeting greeting);
}
GuestbookServiceImpl.java
package net.java_school.guestbook;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import com.googlecode.objectify.Key;
import static com.googlecode.objectify.ObjectifyService.ofy;//1.

@Service
public class GuestbookServiceImpl implements GuestbookService{
	public void sign(Greeting greeting) {
		ofy().save().entity(greeting).now();//1.
	}

	@PreAuthorize("isAuthenticated() and (#greeting.author_id == principal.userId or hasRole('ROLE_ADMIN'))")//2.
	public void del(Greeting greeting) {
		Key<Greeting> key = greeting.getKey();
		ofy().delete().key(key).now();//1.
	}
}

1. 임포트 문을 import static com.googlecode.objectify.ObjectifyService.ofy;로 수정했고, Ojectify 코드를 다음과 같이 사용한 것을 주목하자.

ofy().save().entity(greeting).now();
ofy().delete().key(key).now();

2. del() 메서드에 적용한 애노테이션에서 #greeting.author_id는 greeting 인스턴스의 getAuthor_id() 메소드를 호출한다. 따라서 Greeting.java에 다음 getter를 추가해야 한다.

Greeting.java
public String getAuthor_id() {
	return author_id;
}

컨트롤러 수정

컨트롤러가 서비스 계층을 사용하도록 수정한다.

GuestbookController.java
//omit

@Autowired
private GuestbookService guestbookService;

//omit

@RequestMapping(value="/guestbook/sign", method=RequestMethod.POST)
public String sign(String guestbookName, String content, GaeUserAuthentication gaeUserAuthentication) {
	Greeting greeting = null;

	if (gaeUserAuthentication != null) {
		GaeUser gaeUser = (GaeUser) gaeUserAuthentication.getPrincipal();
		greeting = new Greeting(guestbookName, content, gaeUser.getUserId(), gaeUser.getEmail());
	} else {
		greeting = new Greeting(guestbookName, content);
	}
	
	guestbookService.sign(greeting);
	
	return "redirect:/guestbook/?guestbookName=" + guestbookName;
}

@RequestMapping(value="/guestbook/del", method=RequestMethod.POST)
public String del(String guestbookName, String keyString) {
	Key<Greeting> key = Key.create(keyString);
	Greeting greeting = ofy().load().key(key).now();
	guestbookService.del(greeting);
	return "redirect:/guestbook/?guestbookName=" + guestbookName;
}

로컬 테스트

mvn clean
mvn appengine:devserver

로그인하지 않은 사용자가 방명록을 방문하면 Del 링크를 볼 수 없다. not login 일반 사용자로 로그인한다.
Normal user login screen 자신의 글을 삭제할 수 있는 링크를 볼 수 있다. Normal user login 로그아웃하고 관리자로 로그인한다.
Admin user login screen 모든 글에 대한 삭제 링크를 볼 수 있다.
익명 사용자가 작성한 글을 삭제한다. Admin login 익명 사용자가 작성한 글이 삭제된 것을 확인한다. delete Anomynous user's greeting

참고