토비의 스프링 초난감 Dao

토비의 스피링 초난감 Dao

초난감 Dao

Dao 란 데이터 엑세스 오브젝트이다.
데이터를 조회 하거나 조작하는 기능을 말한다.


우리는 흔히 쓰는 자바빈 규약에 따른 오브젝트이다.

public class User {
    String id;
    String name;
    String password;

    public User() {
    }

    public User(String id, String name, String password) {
        this.id = id;
        this.name = name;
        this.password = password;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

데이터 베이스를 접근하는 Dao를 만들어 보자

public class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {
        Class.forName("org.h2.Driver");
        Connection c = DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");

        PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?,?,?)");
        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ps.setString(3, user.getPassword());

        ps.executeUpdate();
        ps.close();
        c.close();

    }

    public User get(String id) throws ClassNotFoundException, SQLException {
        Class.forName("org.h2.Driver");

        Connection c = DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");

        PreparedStatement ps = c.prepareStatement("select * from users where id = ?");
        ps.setString(1, id);
        ResultSet rs = ps.executeQuery();
        rs.next();
        User user = new User();
        user.setId(rs.getString("id"));
        user.setName(rs.getString("name"));
        user.setPassword(rs.getString("password"));
        rs.close();
        ps.close();
        c.close();
        return user;
    }
}

코드를 살펴 보면 일단 좋은 코드는 아니다.
try catch fianlly 로 코드를 감싸주지 않았다. 만약 에러가 발생한다면 리소스를 반납하지 못할 것이다.
하지만 지금은 그게 중요한게 아니므로 일단은 넘어간다.
우리는 유저정보를 입력하고 또한 유저에 대한 정보를 가져 오는 Dao를 만들었다.
테스트를 해보자

public static void main(String[] args) throws SQLException, ClassNotFoundException {

    UserDao userDao = new UserDao();

    User user = new User();
    user.setId("wonwoo");
    user.setName("이원우");
    user.setPassword("zaq12wsx");

    userDao.add(user);

    System.out.println(user.getId() + " 완료");

    User user2 = userDao.get(user.getId());
    System.out.println(user2.getName());
    System.out.println(user2.getPassword());

    System.out.println(user2.getId() + " 조회 성공 ");
}

일단 잘 돌아간다. 이렇게 코딩을 하는 사람도 있겠지만 필자도 맘에 안들고 보는사람도 맘에 안드는 사람이 있을 것이다.
우리는 관심사를 분리 해보자.

중복코드의 메소드 추출

가장 먼저 해야 할 일은 중복코드를 메소드로 추출 하는 것이다.

//커넥션 정보를 불러오는 코드를 분리
private Connection getConnection() throws ClassNotFoundException, SQLException {
    Class.forName("org.h2.Driver");
    return DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");
}

public void add(User user) throws ClassNotFoundException, SQLException {
    Connection c = getConnection();
    ...
}

우리는 커넥션 정보를 불러오는 코드를 메소드를 분리 시켰다.
테스트를 해도 잘 돌아간다.
아주 초보적인 관심사의 분리 작업이지만 메소드 추출만으로도 변화에 좀 더 유연하게 대처 할 수 있는 코드를 만들었다.
하지만 우리는 변화에 대응하는 수준이 아니라 반기는 Dao를 만들어 보자

상속을 통한 확장

우리는 N사와 D사의 각기 다른 데이터 베이스를 사용 하고 있다. DB 커넥션을 가져오는데 있어 독자적으로 만든 방법을 적용하고 싶어하는 점이다.
또한 종종 변경될 가능성이 있다.
다음 코드를 보자

public abstract class UserDao {

    public abstract Connection getConnection() throws ClassNotFoundException, SQLException;

.. add 
.. get(id)
}

UserDao를 추상화 하여 만들었다.
나머지는 구현체에서 구현하도록 템플릿 메서드 패턴을 이용하였다.

public class NUserDao extends UserDao {

    @Override
    public Connection getConnection() throws ClassNotFoundException, SQLException {
        Class.forName("org.h2.Driver");
        return DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");
    }
}

이렇게 만들어도 나쁘지는 않다.
하지만 NUserDao 는 UserDao와 밀접한 관계에 있다.
UserDao 가 변경되면 NUserDao에도 영향을 미친다.
또한 자바는 다중상속을 지원하지 않는다.
조금더 확장 시켜보자.

클래스의 분리

public  class UserDao {

    SimpleConnectionMaker simpleConnectionMaker = new SimpleConnectionMaker();

.. add()
.. get(id)
}

SimpleConnectionMaker 클래스를 만들어 위임해주고 있다.

public class SimpleConnectionMaker {
    public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
        Class.forName("org.h2.Driver");
        return DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");
    }
}

하지만 여기에선 또 UserDao는 SimpleConnectionMaker와 너무 밀접한 관계에 있다. 또한 SimpleConnectionMaker의 makeNewConnection메소드는 보장 받지 못하고 있다.
만약 SimpleConnectionMaker이라는 클래스에 makeConnection 이라는 함수가 사용 되었을때는 메소드 또한 변경해야 된다.
이것은 다음과 같이 해결할 수 있다.

인터페이스의 도입

public interface ConnectionMaker {
    Connection makeNewConnection() throws ClassNotFoundException, SQLException;
}

우리는 위와 같이 인터페이스를 도입하였다.

public class SimpleConnectionMaker implements ConnectionMaker {

    @Override
    public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
        Class.forName("org.h2.Driver");
        return DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", "");
    }
}

그리고 SimpleConnectionMaker는 ConnectionMaker 인터페이스를 구현하고 있다.

public class UserDao {

    ConnectionMaker connectionMaker = new SimpleConnectionMaker();

...add()
...get(id)
}

인터페이스 도입으로 우리는 makeNewConnection함수는 보장 받을수 있다.
하지만 아직도 UserDao은 SimpleConnectionMaker을 의존하고 있다.
아직도 맘에 들지 않는다.

의존성 주입

이제 많이 왔다. 아니 거의 다왔다.
마지막으로 UserDao는 SimpleConnectionMaker의존 하고 있다.
아직까지 커넥션 정보를 바뀌면 UserDao코드를 변경해야 된다.
우리는 이렇게 해결 할 수 있다.

public class UserDao {

    final ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker){
        this.connectionMaker = connectionMaker;
    }
...add()
...get(id)
}

우린 생성자를 통해 ConnectionMaker 주입받도록 하고 있다.
UserDao는 책임을 떠넘기고 외부에서 주입 받도록 하고 있다.
UserDao를 쓰는 클래스(클라이언트) 혹은 Service들은 Dao에 의존성을 주입해야 된다.
이렇게 UserDao를 전혀 손을 대지 않고 DB연결을 확장 시킬 수 있는 방법을 알아 냈다.

    public static void main(String[] args) throws SQLException, ClassNotFoundException {

        ConnectionMaker connectionMaker = new SimpleConnectionMaker();

        UserDao userDao = new UserDao(connectionMaker);

...
...
}

우리는 이것을 의존성 주입이라고 한다. 별거 아닌 것처럼 느껴질 수 도 있다.
솔직히 별거 아닌건 아니다. 아주 중요하고 해보면 쉽지 않은 기술 일 수도 있다.
우리는 좀더 유연한 dao를 만들어 보았다.

실제 DI는 보시다 시피 스프링의 기술이 아니다. 굳이 스프링 컨테이너가 없어도 가능한 일이다.
또한 그렇다고 자바에만 있는 것도 아니다. 객체 지향 언어라면 DI란 기술을 마음껏 펴칠 수 있다.

우리는 이렇게 초난감 Dao에서 초슈퍼울트라캡짱 Dao를 만들어 봤다.

spring security 권한 관리

이번에 security의 동적 권한?에 대해 알아보자.
권한 관리를 정적으로 하지 않고 db에서 권한을 관리하는 거다.
예전에 시큐리티를 처음 했을때 낑낑거리던 그때가 생각난다.
시큐리티가 좋긴 하지만 어렵다. 아무튼 한번 보자. 물론 내가 틀릴 수도 있다.

user-erd

대충 설계는 위와 같다.(hierarchy 구조는 일단 뺏다. 하드코딩)
Authorities는 매핑 테이블이다. User와 role을 매핑 시킨다. 한 user가 여러개의 롤을 가질수 있으며 롤 역시 여러개의 유저에 할당 할 수 있다.
RoleResouce도 마찬가지다. Resouces는 실제 url 정보를 가지고 있다.
실제 이 예제에선 url을 등록 하지만 패턴으로 해도 상관 없을 듯 하다.
실제 패턴이 낫다. url을 한개씩 관리하면 관리가 힘들지 않을까?

spring boot와 jpa를 기준으로 만들었다.
일단 소스를 보자

...

@Bean
public FilterSecurityInterceptor filterSecurityInterceptor() {
  FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
  filterSecurityInterceptor.setAuthenticationManager(authenticationManager);
  filterSecurityInterceptor.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
  filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
  return filterSecurityInterceptor;
}

@Bean
public AffirmativeBased affirmativeBased() {
  List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
  accessDecisionVoters.add(roleVoter());
  AffirmativeBased affirmativeBased = new AffirmativeBased(accessDecisionVoters);
  return affirmativeBased;
}

@Bean
public RoleHierarchyVoter roleVoter() {
  RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
  roleHierarchyVoter.setRolePrefix("ROLE_");
  return roleHierarchyVoter;
}

//RoleHierarchy 설정
@Bean
public RoleHierarchy roleHierarchy() {
  RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
  roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
  return roleHierarchy;
}

...

기본적인 셋팅은 제외하고 그외의 중요한 빈들만 보자
이 예제는 hierarchy 구조를 설정 하였으나 하드코딩한 예제이다. 나중에 기회가 된다면 다시 한번 살펴보겠다.
hierarchy 구조를 설정 하고 싶지 않다면 RoleVoter을 빈으로 등록 하면 된다.
RoleHierarchyVoter은 RoleVoter을 상속받고 있긴 하다.
여기서 중요한건 FilterSecurityInterceptor 클래스 이다.
여기선 위에 보다시피 3가지 객체를 넣는다.
첫번째는 authenticationManager 로그인한 인증정보를 담기 위함이라고 하는데 이건 잘 모르겠다.
두번째는 filterInvocationSecurityMetadataSource url의 대상 정보이다. 실제 url이 권한과 일치 하는지 확인하는거다.
마지막으로 AccessDecisionManager는 위와 두개를 이용해서 실제 이 사용자가 허용이 되야 하는지 거부가 되어야 하는지 판단해준다.

DefaultFilterInvocationSecurityMetadataSource를 확장해서 사용하거나 빈으로 등록할 때 생성자에 requestMap 를 담아줘도 된다.

필자는 filterInvocationSecurityMetadataSource를 구현 했다.
해당 url이 어떤 권한을 가지고 있는지 확인해주어야 하기 때문이다.
filterInvocationSecurityMetadataSource 의 구현체를 살펴 보자

@Slf4j
public class FilterMetadataSource implements FilterInvocationSecurityMetadataSource, InitializingBean {

  @Autowired
  private ResourceMetaService resourceMetaService;

  @Autowired
  private CacheManager cacheManager;


  @Override
  public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
    FilterInvocation fi = (FilterInvocation) object;
    String url = fi.getRequestUrl();

    List<AuthoritiesDto> userRoleDto = cacheManager.getAuthorities().get(url);
    if (userRoleDto == null) {
      return null;
    }
    List<String> roles = userRoleDto.stream().map(AuthoritiesDto::getRoleName).collect(Collectors.toList());

    String[] stockArr = new String[roles.size()];
    stockArr = roles.toArray(stockArr);

    return SecurityConfig.createList(stockArr);
  }

  @Override
  public Collection<ConfigAttribute> getAllConfigAttributes() {
    return null;
  }

  @Override
  public boolean supports(Class<?> clazz) {
    return FilterInvocation.class.isAssignableFrom(clazz);
  }

  @Override
  public void afterPropertiesSet() throws Exception {
    resourceMetaService.findAllResources();
  }
}

url을 꺼내 와서 해당 url의 권한을 확인한다.
매번 db에 꺼내올 필요는 없어서 Cache 하고 있다. 최초 빈이 등록될때 url정보를 셋팅한다.
그리고 권한에 따른 url이 변경 될 때마다 이벤트를 줘야 한다.

@Slf4j
public class ResourceMetaServiceImpl implements ResourceMetaService {

  @Autowired
  private ResourceRepository resourceRepository;

  @Autowired
  private ApplicationContext applicationContext;

  @Override
  public void findAllResources() {
    List<AuthoritiesDto> authorities = resourceRepository.findAllAuthorities();

    authorities.stream().forEach(userRoleDto -> {
      log.info("role name {} ", userRoleDto.getRoleName());
      log.info("url {}", userRoleDto.getUrl());
    });
    applicationContext.publishEvent(new CacheEventMessage(this, authorities));
  }
}
public class CacheEventMessage extends ApplicationEvent {

  final List<AuthoritiesDto> authoritiesDto;

  public CacheEventMessage(Object source, final List<AuthoritiesDto> authoritiesDto) {
    super(source);
    this.authoritiesDto = authoritiesDto;
  }

  public List<AuthoritiesDto> getAuthoritiesDto() {
    return authoritiesDto;
  }
}

ApplicationEvent 는 한번 써봤다. 굳이 이렇게 안해도 된다.
applicationContext.publishEvent 를 하면 ApplicationListener를 구현하고 있는 구현체로 이벤트가 날라간다.

public class CacheManager implements ApplicationListener<CacheEventMessage> {

  private Map<String, List<AuthoritiesDto>> authorities;

  public Map<String, List<AuthoritiesDto>> getAuthorities() {
    return authorities;
  }

  public List<AuthoritiesDto> getAuthoritie(String key) {
    return authorities.get(key);
  }

  @Override
  public void onApplicationEvent(CacheEventMessage event) {
    authorities = event.getAuthoritiesDto()
      .stream().collect(groupingBy(AuthoritiesDto::getUrl, toList()));
  }
}

이벤트가 날라오면 다시 url정보를 셋팅한다.
설명이 이상하다. 아니 시큐리티가 어렵다.
소스를 보며 이것 저것 해보는게 더 빠를지도 모른다.
더 설명 하고 싶으나 내공이 부족하므로 이만 하겠다.ㅠㅠㅠ

해당 소스는 github에 올라가 있으니 그걸 보는게 더 낫겠다.


이슈보다는 하다가 어이 없던것
lombok이 엄청엄청 좋으나 가끔 이상한대서 어이없게 에러를 발생했다. 아니 lombok이 잘못한게 아니라 lombok에 모든걸 떠넘긴 탓일까
@Data는 toString 까지 구현해 준다. 그래서 문제가 됐다. jpa 에서 양방향이 되면 toString 호출시 스택오버플로우가 발생한다.
맞는말이긴 하지만 그래도 어이 없었다.. 그것만 조심하자. 이렇게 하나더 배우나요?

@ToString(exclude = {"제외시킬변수"})

exclude 를 사용해 제외 시키자!

@Lazy 와 @Primary

이번에는 Lazy와 Primary에대해 알아보자
Lazy는 말 그대로 게으른 걸 뜻한다.
코드로 확인하자

public class BeanClass {

}

위와 같은 코드가 있다고 가정하자
그리고 빈으로 등록하자

@Bean
public BeanClass beanClass(){
    System.out.println("register bean");
    return new BeanClass();
}

그런후에 테스트를 해보자.

@Test
public void lazyTest(){
    System.out.println("get bean before");
    BeanClass bean = applicationContext.getBean(BeanClass.class);
    System.out.println(bean);
}

그럼 결과는 당연히 register bean 부터 출력 될 것이다.
이번엔 Lazy어노테이션을 써보자

@Bean
@Lazy
public BeanClass beanClass(){
    System.out.println("register bean");
    return new BeanClass();
}

그리고 다시 테스트를 해보자
그럼 get bean before 출력 되고 getBean 호출시 빈으로 등록된다. (단 BeanClass를 DI 받으면 등록 되어 있다.)

이번엔 Primary를 알아보자.
말그대로 기본으로 등록 되는 빈을 말하는거다.
일단 같은 클래스를 빈으로 두개 등록하자.

@Bean
@Primary
public BeanClass beanClass(){
    BeanClass beanClass = new BeanClass();
    System.out.println("beanClass");
    System.out.println(beanClass);
    return beanClass;
}

@Bean
public BeanClass beanClass1(){
    BeanClass beanClass = new BeanClass();
    System.out.println("beanClass1");
    System.out.println(beanClass);
    return beanClass;
}

한개는 @Primary가 존재한다.

그리고 테스트를 해보자

@Test
public void lazyTest(){
    BeanClass bean = applicationContext.getBean(BeanClass.class);
    System.out.println(bean);
}

위와 같이 테스트를 해보면 항상 beanClass 가 빈으로 등록된다.
순서는 중요하지 않다.
먼저 등록이 되도 @Primary 있는 빈이 항상 등록된다.(기본으로)

BeanClass bean = (BeanClass) applicationContext.getBean("beanClass1");

물론 이렇게 하면 beanClass1이 나온다. 당연한 이야기 이지만..
만약 Primary없이 다시 해보자

BeanClass bean = applicationContext.getBean(BeanClass.class);

그럼 빈이 2개 존재 한다고 에러가 난다.
그럼 만약 둘다 Primary 존재 한다면 어떻게 될까?
마찬가지로 에러가 난다.

이렇게 @Lazy 와 @Primary에 대해 알아 봤다.