AOP를 이용한 개인정보 접근이력 남기기

개요

개인정보가 있는 페이지에 접근했을 때 접근 이력을 남기도록 해달라는 요청이 들어왔다.
요구 사이트는 관리자 페이지라서 개인정보를 포함하고있는 페이지가 매우 많았다. 기존 코드를 최대한 건들지 않고 적용하기 위해서 SPRING AOP를 사용하여 구현하게 되었다.

요구사항

관리자가 개인정보가 있는 페이지에 접근하게되면 다음과 같은 정보를 남겨야한다.

  • 접근 아이디
  • 접근 아이피
  • 접근 시간
  • 수행업무 : 조회, 등록, 수정, 삭제, 다운로드 등.
  • 정보주체 : 개인정보의 주체
  • 사유 : 개인정보 열람 이유

아이디와 아이피는 request와 session에서 얻을 수 있고, 수행업무와 사유는 해당 메소드의 어노테이션에 정의하기로 정했다.
문제는 정보주체인데 대부분 정보주체는 데이터베이스에 저장되어있기 때문에 메소드 안에서 정보주체 데이터를 받아와야만한다. 결국엔 메소드 수정이 불가피하다. (최소한으로 수정할 뿐)

코드

먼저 pointcut으로 사용할 어노테이션을 만든다.

1
2
3
4
5
6
7
8
9
10
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PrivacyLog {
/** 수행 업무 */
PrivacyLogTaskCode task() default PrivacyLogTaskCode.V;

/** 사유 */
String reason() default "";
}

어노테이션의 값으로 수행업무와 사유를 지정할 수 있게했다.
수행업무는 조회, 등록 등 정해져있으므로 enum으로 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum PrivacyLogTaskCode {
V("조회"),
I("등록"),
U("수정"),
R("삭제"),
D("다운로드");

private String desc;

PrivacyLogTaskCode(String desc) {
this.desc = desc;
}

public String getDesc() {
return desc;
}
}

다음으로 로그 데이터를 담을 PrivacyLogInfo 클래스도 만들어준다.

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class PrivacyLogInfo {
private String accessId; // 접근 아이디
private String accessIp; // 접근 아이피
private String accessTime; // 접근 시간
private PrivacyLogTaskCode task; // 수행업무
private String subject; // 정보 주체
private String reason; // 사유
}

다음으로 가장 중요한 로그를 남기는 Advice 클래스 PrivacyLogAspect를 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class PrivacyLogAspect {
private static final Logger logger = LoggerFactory.getLogger(PrivacyLogAspect.class);

@Resource(name = "UserLogSvc")
private UserLogSvc userLogSvc;

public void insertPrivacyLog(JoinPoint joinPoint, PrivacyLogInfo privacyLogInfo) throws Throwable {
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
PrivacyLog annotation = signature.getMethod().getAnnotation(PrivacyLog.class);
HttpServletRequest request = ((ServletRequestAttributes)(RequestContextHolder.currentRequestAttributes())).getRequest();
HttpSession session = request.getSession();

privacyLogInfo.setAccessId((String) session.getAttribute("SesUserId"));
privacyLogInfo.setAccessIp(getClientIP(request));
privacyLogInfo.setAccessTime(new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date()));
privacyLogInfo.setTask(annotation.task());
privacyLogInfo.setReason(annotation.reason());

userLogSvc.insertUserLog(privacyLogInfo);
} catch(Exception e) {
logger.error("개인정보 접근 이력 Aspect Error : {}", e.getMessage());
}
}

private String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");

if(ip == null) {
ip = request.getHeader("Proxy-Client-IP");
}
if(ip == null) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if(ip == null) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if(ip == null) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if(ip == null) {
ip = request.getRemoteAddr();
}

return ip;
}
}

@Service("UserLogSvc")
public class UserLogSvc userLogSvc {
public void insertUserLog(PrivacyLogInfo privacyLogInfo) {
//DB insert
}
}

insertPrivacyLog() 메소드의 두번째 인자로 PrivacyLogInfo 타입이 오는데 타켓 메소드에서 정보주체를 담아서 전달하면 받기 위함이다.
마지막으로 Aspect 설정을 해야한다. 해당 사이트는 SPRING LEGACY로 되어있어서 XML로 설정해야했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?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:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd">

<bean id="privacyLog" class="egovframework.cmm.aop.privacy.PrivacyLogAspect" />

<aop:config proxy-target-class="true">
<aop:aspect id="privacyLogAspect" ref="privacyLog">
<aop:after pointcut="@annotation(egovframework.cmm.aop.privacy.PrivacyLog) and args(privacyLogInfo, ..)" method="insertPrivacyLog" />
</aop:aspect>
</aop:config>
</beans>

포인트컷으로 @annotation(egovframework.cmm.aop.privacy.PrivacyLog)를 사용하였고, args(privacyLogInfo, ..)를 사용하여 첫번째 인자로 privacyLogInfo 이름이 오는 메소드만 타겟으로 삼는다.
주의할 점은 어노테이션이 달릴 메소드의 첫번째 인자의 이름은 privacyLogInfo와 같아야한다.

사용은 아래처럼 개인정보에 접근하는 메소드 위에 @PrivacyLog을 달고 PrivacyLogInfo에 정보주체 값을 셋팅하면된다.

1
2
3
4
5
6
7
8
9
@Controller
public class TestController {
@PrivacyLog(task = PrivacyLogTaskCode.R, reason = "회원 조회")
@RequestMapping("/test/mypage")
public void privacyTest1(PrivacyLogInfo privacyLogInfo, HttpServletRequest request) {
//... work
privacyLogInfo.setSubject("홍길동");
}
}

삽질

처음에 위와같이 설정을하고 테스트했을 때 AOP가 작동되질 않았다. 여려가지 테스트를 해보다 service계층의 메소드는 잘 작동하지만 Controller에 적용한 Aspect는 작동하지 않는다는 걸 알았다.
원인은 xml 설정파일의 위치가 잘못되었기 때문이다.
Controller는 DispatcherServlet의 component-scan을 통해 DispatcherServlet ApplicationContext에 등록되므로 DispatcherServlet의 contextConfigLocation 설정에 있는 xml에 AOP 설정을 해야한다.

결론적으로 컨트롤러에 AOP를 적용할 때 주의할 점은 두가지이다.

  • Controller의 Context는 root context 하위의 WebApplicationContext이기 때문에 해당 위치의 xml 파일에 AOP 설정을해야한다.
  • Controller는 보통 인터페이스로 구현을 하지 않기 때문에 클래스에 AOP를 적용하려면 CGLIB를 사용해야한다.