오브젝티파이

오브젝티파이(Objectify)는 데이터스토어를 위해 만든 자바 API이다.
데이터스토어는 구글 클라우드의 공식 저장소로, NoSQL 데이터베이스이다.

사용법

다음을 pom.xml의 dependencies에 추가한다.

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>23.0</version>
</dependency>
<dependency>
  <groupId>com.googlecode.objectify</groupId>
  <artifactId>objectify</artifactId>
  <version>5.1.22</version>
</dependency>

헬퍼 클래스를 다음과 같이 작성한다.

package net.java_school.guestbook;

//..생략..

public class OfyHelper implements ServletContextListener {
  public static void register() {
    ObjectifyService.register(Guestbook.class);
    ObjectifyService.register(Greeting.class);
  }

  public void contextInitialized(ServletContextEvent event) {
    // This will be invoked as part of a warmup request, or the first user
    // request if no warmup request was invoked.
    register();
  }

  public void contextDestroyed(ServletContextEvent event) {
    // App Engine does not currently invoke this method.
  }

}

다음을 web.xml에 추가한다.

<!-- [START Objectify] -->
<filter>
  <filter-name>ObjectifyFilter</filter-name>
  <filter-class>com.googlecode.objectify.ObjectifyFilter</filter-class>
</filter>
<filter-mapping>
  <filter-name>ObjectifyFilter</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>
<listener>
  <listener-class>net.java_school.guestbook.OfyHelper</listener-class>
</listener>
<!-- [END Objectify] -->

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

삭제 기능 추가

삭제 기능 추가를 구현하기에 앞서, 이미 guestbook.jsp에 구현된 오브젝티파이 코드를 살펴보자.

목록 가져오기
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에 자신의 고유 키를 반환하는 메소드를 다음과 같이 추가한다.

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

여기서 Key의 타입은 com.googlecode.objectify.Key다. Key는 자기 자신으로 복원할 수 있는 String을 반환하는 메소드인 toWebSafeString()을 가진다. guestbook.jsp 파일에서 다음 강조한 부분을 추가한다.

guestbook.jsp
// 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을 전달하는 링크를 생성할 수 있다. (자바스크립트와 필요한 폼 태그는 곧 다룬다.) 다음 강조한 부분을 guestbook.jsp에 추가한다.

guestbook.jsp
<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에 저장해야 한다. guestbook.jsp 파일을 수정한다.

guestbook.jsp
// 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을 전달하는 링크를 아래와 같이 수정한다.

guestbook.jsp
<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" %>

guestbook.jsp에 다음 자바스크립트 함수 추가한다.

guestbook.jsp
<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>

guestbook.jsp에 다음 폼을 추가한다.

guestbook.jsp
<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>

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

@PostMapping("/guestbook/del")
public String del(String guestbookName, String keyString) {
  Key<Greeting> key = Key.create(keyString);
  ObjectifyService.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
import net.java_school.spring.security.GaeUserAuthentication;
import net.java_school.user.GaeUser;
import static com.googlecode.objectify.ObjectifyService.ofy;
//omit

@Autowired
private GuestbookService guestbookService;

//omit

@PostMapping("/guestbook/sign")
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;
}

@PostMapping("/guestbook/del")
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:run

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

참고