Remeber-Me 인증하기

웹에서의 로그인 처리는 크게 HttpSession을 이용하는 방식과, 쿠키(Cookie)를 이용하는 방식이 있습니다. HttpSession을 이용하는 방식을 흔히 세션방식이라고 합니다. 세션방식은 모든 데이터를 서버에서 보관하고, 브라우저는 단순히 키(key)에 해당하는 세션ID만을 이용하기 때문에 좀 더 안전하다는 장점이 있지만, 브라우저가 종료되면 사용할 수가 없기 때문에 모바일과 같은 환경에서 불편함이 있습니다.

쿠키를 이용하는 방식은 브라우저에 일정한 정보를 담은 쿠키를 전송하고, 브라우저는 서버에 접근할 때 주어진 쿠키를 같이 전송합니다. 이때 서버에서는 쿠키에 유효기간을 지정할 수 있기 때문에 브라우저가 종료되어도 다음 접근 시 유효기간이 충분하다면 정보를 유지할 수 있습니다.

Remember-Me는 최근 웹 사이트에서는 로그인 유지라는 이름으로 서비스되는 기능입니다. 이 방식은 쿠키를 이용해서 사용자가 로그인했던 정보를 보관하는 것입니다.

스프링 시큐리티의 Remember-Me 기능은 기본적으로 사용자가 로그인했을 때의 특정한 토큰 데이터를 2주간 유지되도록 쿠키를 생성합니다. 브라우저에 전송된 쿠키를 이용해서 로그인 정보가 필요하면 저장된 토큰을 이용해서 다시 정보를 사용합니다.

아래와 같이 로그인 폼에 remember-me 체크박스 처리하는 부분을 만들었습니다.

<form action="" method="post" class="form form--login">
    <div class="form__field">
        <label class="fontawesome-user" for="username"><span class="hidden">Username</span></label>
        <input type="text" id="username" name="username" class="form__input" placeholder="Username" required>
    </div>

    <div class="form__field">
        <label class="fontawesome-lock" for="password"><span class="hidden">Password</span></label>
         <input type="password" id="password" name="password"  class="form__input" placeholder="Password" required>
    </div>

    <p>
    <label for="remember-me">로그인 유지하기</label>
    <input type="checkbox" id="remember-me" name="remember-me"  class="form__input" />
    </p>

    <input type="hidden" th:name="${_csrf.parameterName}"
                   th:value="${_csrf.token}" />
    <div class="form__field">
                <input type="submit" value="Log In">
    </div>
</form>

스크린샷 2019-11-18 오전 12 31 41

remember-me를 데이터베이스에 보관하기

스프링 시큐리티는 기본적으로 remember-me 기능을 사용하기 위해서 Hash-based Token 저장 방식과 Persistent Token 저장 방식을 사용할 수 있습니다.

remember-me 쿠키의 생성은 기본적으로 username과 쿠키의 만료시간, 패스워드를 Base-64 방식으로 인코딩한 것입니다. 따라서.. 사용자가 패스워드를 변경하면 정상적인 값이라도 로그인이 될 수 없다는 단점을 가지고 있습니다.

이를 해결하기 위해서 가능하면 데이터베이스를 이용해서 처리하는 방식이 권장됩니다.
데이터베이스를 이용하는 설정은 org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl이라는 긴 이름의 클래스를 이용합니다.

원한다면 PersistentTokenRepository라는 인터페이스를 이용해서 자신이 원하는 대로 구현이 가능합니다만, 관련된 모든 SQL을 직접 개발해야 하므로 번거러운 점이 있습니다.

우선 토큰을 보관할 수 있는 테이블을 아래와 같이 생성합니다.(MySQL 기준)

create table persistent_logins(
    username varchar(64) not null,
    series varchar(64) primary key,
    token varchar(64) not null,
    last_used timestamp not null
);

SecurityConfig에서의 설정

SecurityConfig에서는 rememberMe()를 처리할 때 JdbcTokenRepositoryImpl을 지정해주어야 하는데 기본적으로 DataSource가 필요하므로 주입해 줄 필요가 있습니다.

@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomerAuthenticationProvider customerAuthenticationProvider;

    private final JpaUserService jpaUserService;

    private final DataSource dataSource;

HttpSecurity에서는 JdbcTokenRepositoryImpl을 이용할 것이므로 간단한 메소드를 이용해서 처리합니다.

private PersistentTokenRepository getJDBCRepository(){

    JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
    repo.setDataSource(dataSource);
    return repo;
}

마지막으로 HttpSecurity가 이를 이용하도록 configure() 메서드의 내부를 수정합니다.

remember-me 기능을 설정하기 위해서는 UserDetailsService를 이용해야 한다는 조건이 있습니다. 만든 후에 HttpSecurity 인스턴스에 간단히 rememberMe()를 이용해 주면 처리가 가능합니다.

http.rememberMe()
.key("jpa")
.userDetailsService(jpaUserService)
.tokenRepository(getJDBCRepository())
.tokenValiditySeconds(60*60*24);

마지막에 추가한 tokenValiditySeconds()는 쿠키의 유효시간을 초단위로 설정하는데 사용합니다. 코드에서는 24시간을 유지하는 쿠키를 생성합니다.

브라우저에서 토큰정보

SecurityConfig의 설정을 추가한 후에 화면에서 remember-me를 선택한 로그인을 하면 persistent_logins테이블에 생성된 토큰의 값이 기록되는 것을 확인할 수 있습니다.

스크린샷 2019-11-18 오전 12 48 21

MySQL에서 토큰정보

스크린샷 2019-11-18 오전 12 48 41

이제 쿠키의 생성은 패스워드가 아니라 series에 있는 값을 기준으로 하게 됩니다. 사용자가 remember-me를 한 상태에서 로그아웃을 하면 스프링 시큐리티가 자동으로 데이터베이스에서 토큰정보를 삭제하는것을 확인할 수 있습니다.

스크린샷 2019-11-18 오전 12 56 36

'SpringFramework' 카테고리의 다른 글

Jenkins SSH를 이용한 GitHub 연동방법  (0) 2021.05.20
Jenkins에서 EC2로 배포하기  (0) 2021.05.20
Jenkins 설치 및 구동하기  (0) 2021.05.19

기존에 간단하게 만들었던 프로젝트에 로그인 기능을 추가해달라는 요구사항이 있으면 여러가지 방법이 있습니다. 대표적으로 Filter를 사용하여 DispatcherServlet이 요청 URL를 인터셉트 하기전에 세션을 관리하는 방식으로 인증을 처리하는 방법입니다. 하지만 이미 스프링에서는 쉬운지는 모르겠지만 더 간편하게 보안을 적용 할 수 있는 스프링 시큐리티라는 기능을 제공하고 있습니다. 오늘은 웹 환경에서 스프링 시큐리티가 어떻게 동작하고 인증 및 권한처리 방법에 대해서 포스팅하였습니다.

스프링 시큐리티란?

Spring Security는 스프링 기반의 어플레케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크 입니다.

스프링 시큐리티 개념

스프링 시큐리티를 개념을 잡기 위해선은 아래 기본적인 용어는 알아두어야 한다고 생각합니다. 평상시에 많이 접하던 용어라 거부감은 딱히 없었습니다...ㅎㅎ

용어 정리

보안: 위험, 손실 , 범죄가 발생하지 않도록 방지하는 상태를 가리킵니다. 일반적으로 보안의 피해발생의 원인이 인간의 행위라는 점에서 안전이라는 개념과 구분됩니다.

접근주체(Principal): 보호된 리소스에 접근하는 대상

인증(Authentication) : 애플리케이션의 작업을 수행할 수 있는 주체(사용자)라고 주장할 수 있는것을 말합니다.

인증의 방식

  • 크리덴셜 기반 인증 : 사용자명과 비밀번호를 이용한 방식
  • 이중인증 : ATM을 이용할 때처럼 물리적인 카드와 사용자가 입력한 개인정보를 조합하는 방식
  • 하드웨어 인증 : 자동차 키와 같은 방식

인증과 인가
일반 사원인 김사원과 재무팀의 박부장은 사내 모든 직원들의 급여명세서를 열람하고 싶어한다. 먼저 시스템에 로그인을 하면 인증관리자(인증을 처리함)는 사용자가 제시한 정보를 통해 사용자가 누구인지를 확인한다. 다음으로, 접근결정 관리자는 사용자가 가지고 있는 역할을 가지고 급여명세서에 접근할 수 있는지 아닌지를 판별하게 된다.

인가(Authorize): 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication)

권한: 권한은 인증된 주체가 어플리케이션의 동작을 수행할 수 있도록 허락되있는지를 결정하는것을 말합니다. 따라서 권한 승인이 필요한 부분으로 접근하려면 인증 과정을 통해 주체가 증명 되어야만 한다는 것을 의미합니다.

역할부여란?

인증을 통해서 인증된 주체를 하나 이상의 권한(역할)에 매핑하고 보호된 리소스에 대한 권한을 체크하는 것을 말한다.

필터

스프링 시큐리티에서 웹 애플리케이션에 주로 영향을 주는 방식은 일련의 ServletRequest 필터를 사용한 방식이다. 필터들이 애플리케이션에 대한 모든 요청을 감싸서 처리한다. 스프링 시큐리티에서 여러개의 필터들은 아래 그림과 같이 체인형태를 이루면서 동작한다. 자동 설정 옵션을 사용하면 10개의 스프링 시큐리티 필터가 자동으로 설정된다.

DelegatingFilterProxy

DelegatingFilterProxy는 스프링 시큐리티가 모든 애플리케이션 요청을 감싸게 해서 모든 요청에 보안이 적용되게 하는 서블릿필터이다.(스프링 프레임워크에서 제공) 스프링 프레임워크 기반의 웹 애플리케이션에서 서블릿 필터 라이프 사이클과 연계해 스프링 빈 의존성을 서블릿 필터에 바인딩하는데 사용합니다.
web.xml에 다음과 같은 설정을 해주면 애플리케이션의 모든 요청을 스프링 시큐리티가 감싸서 처리할 수 있게 됩니다.

<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

추가로 설명하자면
DelegatinFilterProxy는 filter-name으로 명시된 스프링 컨텍스트에서 빈으로 등록된 springSecurityFilterChain에게 실제 인증처리를 위임하기만 합니다. springSecurityFilterChain은 Filter 인터페이스를 구현한 여러 종류의 필터들을 List 객체에 가지고 있습니다.

스프링 시큐리티를 커스터마이징 하기 위해서는 SpringSecurity에서 제공하는 필터들에 대해 이해하는게 좋습니다.
SpringSecurityFilterChain은 아래 표와 같이 여러 종류의 필터들을 가지고 있습니다.

SpringSecurityFilterChain 종류

12

필터 체인의 제일 마지막에 위치한 FilterSecurityInterceptor는 앞에 지나온 모든 필터들의 정보를 토대로 최종 결정을 내립니다.

SpringSecurityFilterChain

다운로드

스프링 시큐리티 구조

스크린샷 2019-11-12 오후 8 27 23

Spring Security Process

아래 사진은 웹 기반 인증 요청을 처리하는 기본 프로세스를 그림으로 도식화 하였습니다.

스크린샷 2019-11-16 오전 12 01 52

가장 많이 사용하는 폼 기반의 인증방식에 대해서 살펴보겠습니다.
저희 웹 서비스를 가입한 사용자 A가 있다고 가정하겠습니다. 저희 웹 서비스는 회원가입을 통해 가입하여 리소스에 대한 해당 권한을 가진 사용자만이 웹 서버의 리소스에 접근할 수 있습니다.

  1. 먼저, A가 처음에 브라우저를 켜서 저희 웹 서버에서 제공하는 서비스를 이용하기 위해서 폼 기반의 인증요청을 보냅니다. 그러면 위의 표에 나와있듯이 맨 처음에 AbstractAuthenticationProcesFilter를 구현한 클래스(UsernamePasswordAuthenticationFilter)가 인증요청에 해당하는 URL을 감지하여 요청을 가로챕니다.

    public class UsernamePasswordAuthenticationFilter extends
         AbstractAuthenticationProcessingFilter {
  2. Request 객체에 사용자명과 패스워드를 획득 후에 UsernamePasswordAuthenticationTocken 객체에 해당 정보를 바인딩을 하게 됩니다.

UsernamePasswordAuthenticationTocken는 단순 사용자명과 패스워드를 담는 단순 도메인 객체입니다.

  1. UsernamePasswordAuthenticationFilter가 주입 받은 AuthenticationManager의 authenticate 메소드를 호출합니다. 이때 인자로 UsernamePasswordAuthenticationTocken을 반환합니다.
    AuthenticationManager는 스프링 시큐리티의 중요한 요소 중 하나이기 때문에 나중에 자세히 설명하겠습니다.

UsernamePasswordAuthenticationTocken은 Authentication을 상속했기 때문에 자바의 다형성으로 인해 반환이 가능합니다.

  1. AuthenticationManager는 사용자명/패스워드를 실질적으로 인증하는 역할을 맡고 있습니다. AuthenticationManager는 인터페이스이기 때문에 구현체인 ProviderManager를 사용합니다. 이 ProviderManager는 이 권한을 AuthenticationProvider 인터페이스에게 넘깁니다.

뭔가 복잡하지만.. 결국 인증 처리는 AuthenticationProvider를 통해서 처리한다고 생각하면 됩니다. 저희 개발자는 AuthenticationProvider의 구현체를 이용하여 인증 메커니즘을 커스터마이징하여 구현하면 되는 것입니다.

  1. 데이터베이스에서 사용자 정보를 가져올때는 AuthenticationManager는 UserDetailService에게 폼 기반의 인증을 요청한 주체(principal)로 부터 입력받은 사용자명을 파라미터로 받아서 실제 DB에서 정보를 가져와 UserDetails에 정보를 넣어서 반환하는 역할을 합니다.

AuthenticationProvider: 로그인 인증처리를 구현 (비밀 번호 대조)
UserDetailsService: 유저정보를 DB에서 조회하는 역할

위의 순서과 대략적인 스프링 시큐리티 처리 과정입니다. 일반적으로는 인증 관리자는 데이터베이스에 저장된 사용자 정보로 인증을 처리 합니다.

Spring Security 코드 리뷰

이제 간단하게 스프링 시큐리티를 코드를 작성하여 구현해보겠습니다.
가장 먼저 웹 환경에서 스프링 시큐리티를 적용하기 위해서 아래와 같은 클래스 파일을 작성하였습니다.

SpringSecurity config

//웹 보안 활성화 시키는 어노테이션
@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // AuthenticationProvider 커스터마이징한 구현체
    private final CustomerAuthenticationProvider customerAuthenticationProvider;

    // DB에서 사용자 정보를 가져오는 UserDetailService 구현체
    private final JpaUserService jpaUserService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
      log.info("security config.......");


      // 웹과 관련된 다양한 보안 설정을 걸어주는 역할을 합니다.
      http.authorizeRequests().antMatchers("/guest/**").permitAll()
              .antMatchers("/board/list").permitAll()
              .antMatchers("/board").hasRole("BASIC");


      // authorizeRequests()는 시큐리티 처리에 HttpServletRequest를 이용합니다.
      http.authorizeRequests().antMatchers("/manager/**").hasRole("MANAGER");

      http.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN");

      // customize 한 로그인 페이지 설정
      http.formLogin().loginPage("/login");

      //특정 리소스에 대한 접근 권한이 존재하지 않을때 이동시킬 페이지 설정
      http.exceptionHandling().accessDeniedPage("/accessDenied");

      // 로그아웃시에 세션 무효화, 스프링 시큐리티는 사용자 정보를 HttpSession 방식으로 관리하기 때문에 브라우저가 완전히 종료되면, 로그인한 정보를 잃게 됩니다. 브라우저를 종료하지않을시에.. 로그아웃으로 무효화시킵니다.
      http.logout().logoutUrl("/logout").invalidateHttpSession(true);

    }

    // 패스워드 암호화를 위해 PasswordEncoder Bean으로 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception{
        // AuthenticationManagerBuilder는 다양한 인증 관리자를 생성해주는 객체입니다. 
        auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());
    }
}

@EnableWebSecurity

WebSecurityConfigurerAdapter는 세가지 configure() 메소드를 오버라이딩하고 동작을 설정하는 것으로 전반적인 웹 보안정책을 설정할 수 있습니다.

스크린샷 2019-11-12 오후 7 10 31

이 중에서 가장 많이 사용하는 객체가 HttpSecurity 입니다. 보안에 관련된 역할과 많은 책임을 가지고 있습니다.

AuthenticationManagerBuilder는 다양한 인증관리자를 생성해주는 빌더로 메모리 기반의 인증과 데이터베이스 인증 관리자 등 다양한 인증 관리자를 생성해주는 역할을 맡고 있습니다.

http.formLogin()을 이용하면 기본적으로 AbstractAuthenticationProcessingFilter를 구현한 UsernamePasswordAuthenticationFilter 구현체를 이용하게 됩니다.

http.formLogin().loginPage("/login");

참고로 UsernamePasswordAuthenticationFilter에서는 인증(로그인)요청에 대해서 기본적으로 GET 방식으로 인증 정보가 들어오는 것을 허용하지 않고 Http Method를 POST 방식으로만 지원하고 있습니다.

저 같은 경우에는 아래 인증관리자 빌더를 통해서 AuthenticationProvider와 UserDetailsService를 등록하였습니다. 위에서 언급한것 처럼 DB를 통한 인증처리 방식을 많이 사용하기 때문에 authenticationProvider(customerAuthenticationProvider)를 작성하지 않고 UserDetailsService()만 작성하여도 AuthenticationManager에 DaoAuthenticationProvider가 자동으로 연결됩니다.

auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());

AuthenticationProvider 구현

@Component
@RequiredArgsConstructor
public class CustomerAuthenticationProvider implements AuthenticationProvider {
    // UserDetailsService 구현체 
    private final JpaUserService jpaUserService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        UserDetails userDetails = null;

        try {
              userDetails = jpaUserService.loadUserByUsername(username);
        }catch (UserNotFoundException e){
            System.out.println(e.getMessage());
        }catch (BadCredentialsException e){
            System.out.println(e.getMessage());
        }catch (Exception e){
            System.out.println(e.getMessage());
        }

        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(),userDetails.getPassword(),userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return false;
    }
}

위의 코드가 실제로 인증을 처리해주는 AuthenticationProvider 구현체 입니다.
UserDetailsService 인터페이스를 구현한 JpaUserService 객체에게 사용자로 받은 사용자명으로 데이터베이스에 해당 사용자를 조회하여 존재하면 UserDetails에 담아서 AuthenticationProvider에게 제공해주는 역할을 하고 있습니다. 존재하지 않으면 예외를 처리하겠죠

UserDetails

public class JpaSecurityUser extends User {

    // 스프링 시큐리티에서는 권한정보를 ROLE + 권한명으로 리소스 접근승인을 판단합니다. 
    // 따라서 접두사인 PREFIX 변수에 필수로 적어줘야 권한을 확인하고 판별할 수 있습니다.
    private static final String ROLE_PREFIX = "ROLE_";

    private Member member;

    public JpaSecurityUser(Member member){
        // User 클래스를 상속하기 때문에 부모생성자는 필수로 호출해줘야 합니다.
        super(member.getUid(), member.getUpw(),makeGrantedAuthority(member.getRoles()));

        this.member = member;
    }

    public JpaSecurityUser(String username, String password, Collection<? extends GrantedAuthority> authorities){

        super(username, password, authorities);

    }

    // 접근제한자 private로 캡슐화를 통해서 해당 User 클래스에서 생성자로 필요한 사용자의 권한정보를 생성 합니다.  
    private static List<GrantedAuthority> makeGrantedAuthority(List<MemberRole> roles){

        List<GrantedAuthority> list = new ArrayList<>();

        roles.forEach(role -> list.add(new SimpleGrantedAuthority(ROLE_PREFIX + role.getRoleName())));

        return list;
    }

    public Member getMember() {
        return member;
    }
}

UserDetails는 인터페이스이기 때문에 실제로 DB에서 조회한 정보를 담기 위해서는 해당 인터페이스의 구현체를 만들어야 합니다. 하지만 우리의 넘사벽 프레임워크인 스프링은 이미 다 준비가 되어있죠.. User라는 클래스가 UserDetails를 구현해놨기 때문에 아래에 UserDetailsService 구현부에서 User를 반환해도 됩니다. 하지만 저는 위의 코드 처럼 나중에 추가적인 맴버변수(이메일, 생년월일)를 사용할 수도 있기 때문에 User 클래스를 확장한 JpaSecurity를 구현하여 Member 도메인을 필드로 가지도록 하였습니다.

public class JpaSecurityUser extends User{

}

사용자명을 파라미터로 받아서 DB에서 해당 유저의 정보를 조회하는 역할을 담당하는 UserDetailsService를 구현한 부분입니다. JPA를 사용하기 때문에 Repository를 이용하여 DB에 접근하였습니다.

UserDetailsService

@Service
@RequiredArgsConstructor
public class JpaUserService implements UserDetailsService {

    @Autowired
    private  final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        JpaSecurityUser user = memberRepository.findById(username).filter(m -> m != null).map(m -> new JpaSecurityUser(m)).get();

        if(user == null){
            throw new UserNotFoundException("해당 계정이 존재하지 않습니다.");
        }

        return user;
    }
}

아래 개발자분들이 올려주신 블로그가 저에게 스프링 시큐리티 개념을 정립하는데 많은 도움을 주셨습니다.

출처: https://blog.naver.com/sipzirala/220979656224,https://javaiyagi.tistory.com/431

2019년에 조영호님께서 출판한 OBJECTS 코드로 이해하는 객체지향 설계라는 책을 사놓고... 읽지를 않다가 조금씩이라도 읽으면서 나중에 잊어버리지 않도록 하기 위해서 간단하게 1장 객체, 설계에 대해서 블로그에 포스팅을 해보았습니다.

패러다임의 시대

가장 먼저 소개하고 있는 부분이 패러다임의 대한 것입니다. 제가 생각하는 패러다임은 한 시대의 사회 전체가 공유하는 이론이나 방법, 문제 의식 등의 체계 정도로만 알고 있었는데 이 책에서는 패러다임이 어떻게 등장하였고 프로그래밍 세계에서 패러다임이 의미하는것이 무엇인지 구체적으로 설명하고 있습니다.

과거에는 표준적인 모델을 따르거나 모방하는 상황을 가르키는 매우 제한적인 상황에서만 패러다임이라는 단어를 사용했습니다.
쿤이라는 사람은 과학혁명의 구조라는 책을 세상에 내놓았는데, 이 책에는 기존의 과학사에 대한 관점을 뿌리채 흔들었습니다.
과학혁명은 과학이 단순한 계단식 발전의 형태를 이루는 것이 아니라 새로운 발견이 기존의 과학적 견해를 붕괴시키는 혁명정인 과정을 거쳐 발전해왔다고 주장했습니다.
과학혁명이란 과거의 패러다임이 새로운 패러다임에 의해 대체됨으로써 정상과학의 방향과 성격이 변하는 것을 의미합니다. 이를 패러다임(Paradigm Shift)의 전환이라고 부릅니다. 대표적인 예로는 우주를 바라보는 관점이 천동설에서 지동설로 변화한 사건이 있습니다.

이제 프로그래밍 관점에서 패러다임을 살펴보겠습니다.
프로그래밍 패러다임은 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일이라고 할 수 있습니다. 간단히 말해서 우리가 어떤 프로그래밍 패러다임을 사용하느냐에 따라 우리가 해결할 문제를 바라보는 방식과 프로그램을 작성하는 방법이 달라집니다.
프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 함으로써 불필욜한 부분에 대한 의견 충돌을 방지합니다. 또한 프로그래밍 패러다임을 교욱시킴으로써 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있습니다.

결국 이 책은 객체지향 패러다임에 관한 책으로써 객체지향 패러다임이 제시하는 프로그래밍 패러다임을 설명하는 것에 목적을 두고 있습니다. 또한 객체지향에 대한 다양한 오해를 제거함으로써 객체지향 프로그래밍을 하는 개발자들이 동일한 규칙과 표준에 따라 프로그램을 작성할 수 있게 할 것입니다.

티켓 판매 애플리케이션 구현하기

이 책은 이론보다 실무를 강조하는 책입니다. 그렇기 때문에 객체지향에 대한 다양한 측면을 설명하기 위해 이론보다는 코드를 작성하여 설명할 것입니다.

이번 시간에는 간단한 티켓 판매 프로그램을 작성하여 리뷰해보겠습니다.

이 프로그램은 관객이 티켓을 통해 소극장에 입장하여 연극이나 음악회를 즐길 수 있습니다. 여기에 소극장을 홍보도 겸할 겸 관람객들의 발길이 이어지도록 작은 이벤트를 기획하기로 했습니다. 이벤트의 내용은 간단하게 추첨을 통해 선정된 관람객에게 공연을 무료로 관람할 수 있는 초대장을 발송하는 것입니다.

여기서 핀트는 이벤트에 당첨된 관람객과 그렇지 못한 관램객은 다른 방식으로 극장에 입장시켜야 한다는 것입니다. 이벤트에 당첨된 관람객은 초대장을 티켓으로 교환한 후에 입장할 수 있습니다.
이벤트에 담청되지 않은 관람객은 티켓을 구매해야만 입장할 수 있습니다. 따라서 관람객을 입장시키기 전에 이벤트 당첨 여부를 확인해야 하고 이벤트 당첨자가 아닌 경우에는 티켓을 판매한 후에 입장시켜야 합니다.

먼저, 이벤트 당첨자에게 발송하는 초대장을 구현하는 것으로 시작하겠습니다.
초대장이라는 개념을 구현한 Invitation은 공연을 관람할 수 있는 초대일자를 인스턴스 변수로 포함하는 간단한 클래스입니다.

package object;

import java.time.LocalDateTime;

public class Invitation {

    private LocalDateTime when;
}

공연을 관람하기 원하는 모든 사람들은 티켓을 소지하고 있어야만 하기 때문에 Ticket 클래스도 추가합니다.

package object;

public class Ticket {

    private Long fee;

    public Long getFee(){
        return this.fee;
    }
}

이벤트 당첨자는 티켓으로 교환할 초대장을 가지고 있습니다. 이벤트에 당첨되지 않은 관람객은 티켓을 구매할 수 있는 현금을 보유하고 있을 것입니다. 따라서 관람객이 가지고 올 수 있는 소지품은 초대장, 현금, 티켓 세 가지뿐 입니다.

이제 관람객이 소지품을 보관할 Bag 클래스를 추가해봅니다. Bag 클래스는 초대장(invitation), 티켓(ticket), 현금(amount)을 인스턴스 변수로 포함합니다.
또한 초대장의 보유 여부를 판단하는 hasInvitation 메서드와 티켓의 소유 여부를 판단하는 hasTicket 메서드, 현금을 증가시키거나 감소시키는 plusAmount, minusAmount 메서드, 초대장을 티켓을오 교환하는 setTicket 메서드를 구현하고 있습니다.

package object;

public class Bag {

    private Long amount;
    private Ticket ticket;
    private Invitation invitation;

    // 이벤트 당첨자가 아닐 경우 초대장이 없고 현금만 보유하고 있기 때문에 생성자로 아래 this 키워드를 이용하여 초대장에 null값을 참조
    public Bag(Long amount){
        this(null, amount);
    }

    // 이벤트 당첨자일 경우 초대장과, 현금을 둘다 보유하고 있기 때문에 아래와 같은 생성자를 호출합니다.
    public Bag(Invitation invitation, Long amount){
        this.invitation = invitation;
        this.amount = amount;
    }

    // 초대장이 있습니까?
    public boolean hasInvitation(){
        return this.invitation != null;
    }

    // 티켓이 있습니까?
    public boolean hasTicket(){
        return this.ticket != null;
    }
    // 현금 감소
    public void minusAmount(Long amount){
        this.amount -= amount;
    }
    // 현금 증가
    public void plusAmount(Long amount){
        this.amount += amount;
    }
    //티켓 교환
    public void setTicket(Ticket ticket){
            this.ticket = ticket;
    }

}

여기서 이벤트에 당첨된 관람객의 가방 안에는 현금과 초대장이 들어있지만 이벤트에 당첨되지 않는 관람객의 경우 가방 안에는 초대장이 들어있지 않을 것입니다. Bag 인스턴스의 상태는 현금과 초대장을 함꼐 보관하거나, 초대장 없이 현금만 보관하는 두 가지 중 하나일 것 입니다.
위의 코드에서 Bag 인스턴스를 생성하는 시점에 이 제약을 강제할 수 있도록 생성자를 추가하였습니다.

다음은 관람객이라는 개념을 구현하는 Audience 클래스를 정의하였습니다. 관람객은 소지품을 보관하기 위해 가방을 소지할 수 있습니다.

package object;

public class Audience {

    private Bag bag;

    public Audience(Bag bag) {
        this.bag = bag;
    }

    public Bag getBag() {
        return bag;
    }
}

관람객이 소극장에 입장하기 위해서는 매표소에서 초대장을 티켓으로 교환하거나 구매해야 합니다. 따라서 매표소에는 관람객에게 판매할 티켓과 티켓의 판매 금액이 보관되어야 합니다. 매표소를 구현하기 위해 TicketOffice 클래스를 구현할 것 입니다.
TicketOffice는 판매하거나 교환해 줄 티켓의 목록(tickets)과 판매금액(amount)을 인스턴스 변수로 포함합니다. 티켓을 판매하는 getTicket 메서드는 편의를 위해 tickets 컬렉션에서 맨 첫번째 위치에 저장된 Ticket을 반환하는 것으로 구현했습니다. 또는 판매금액을 더하거나 차감하는 plusAmount와 minusAmount 메서드로 구현돼 있습니다.

package object;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class TicketOffice {

    private Long amount;
    private List<Ticket> tickets = new ArrayList<>();

    public TicketOffice(Long amount , Ticket ... tickets){
        this.amount = amount;
        this.tickets.addAll(Arrays.asList(tickets));
    }


    public Ticket getTicket(){
        return this.tickets.remove(0);
    }

    public void minusAmount(Long amount){
        this.amount -= amount;
    }

    public void plusAmount(Long amount){
        this.amount += amount;
    }

}

판매원은 매표소에서 초대장을 티켓으로 교환해 주거나 티켓을 판매하는 역할을 수행합니다. 판매원을 구현한 TicketSeller 클래스는 자신이 일하는 매표소(ticketOffice)를 알고 있어야 합니다.

package object;

public class TicketSeller {

    private TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }

    public TicketOffice getTicketOffice() {
        return ticketOffice;
    }
}

모든 준비가 끝이 났습니다. 이제 아래의 그림처럼 클래스들을 조합해서 관람객을 소극장에 입장시키는 로직을 완성하는 일만 남았습니다.

스크린샷 2019-11-02 오전 1 15 15

소극장을 구현하는 클래스 Theater입니다. Theater 클래스가 관람객을 맞이할 수 있도록 enter 메소드를 구현합시다.

package object;

public class Theater {

    private TicketSeller ticketSeller;

    public Theater(TicketSeller ticketSeller) {
        this.ticketSeller = ticketSeller;
    }


    public void enter(Audience audience){

        if(audience.getBag().hasInvitation()){
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        }else {

            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);

        }
    }
}

소극장은 먼저 관람객의 가방 안에 초대장이 들어 있는지 확인합니다. 만약 초대장이 들어 있다면 이벤트에 당첨된 관람객이므로 판매원에게 받은 티켓을 관람객의 가방 안에 넣어줍니다. 가방 안에 초대장이 없다면 티켓을 판매해야 합니다. 이 경우 소극장은 관람객의 가방에서 티켓 금액만큼을 차감한 후 메표소에 금액을 증가시킵니다. 마지막으로 소극장은 관람객의 가방 안에 티켓을 넣어줌으로써 관람객의 입장 절차를 끝냅니다.

여기까지만 작성해도 프로그램 로직은 간단하고 예상했던대로 동작합니다. 하지만 안타깝게도 이 작은 프로그램은 몇가지 문제점을 가지고 있습니다.

무엇이 문제인가

로버트 마틴은 <클린 소프트웨어: 애자일 원칙과 패턴, 그리고 실천 방법>에서 소프트웨어 모듈이 가져야 하는 세 가지 기능에 관해 설명합니다. 여기서 모듈이란 크기와 상관없이 클래스나 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소를 의미합니다.

모든 소프트웨어 모듈에는 세가지 목적이 있습니다.

  • 첫 번째 목적은 실행 중에 제대로 동작하는 것입니다. 이것은 모듈의 존재 이유입니다.

  • 두 번째 목적은 변경을 위해 존재하는 것입니다. 대부분의 모듈은 생명주기 동안에 변경되기 때문에 간단한 작업만으로도 변경이 가능해야합니다. 변경하기 어려운 모듈은 제대로 동작하더라도 개선해야 합니다.

  • 세 번째 목적은 코드를 읽는 사람과 의사소통 하는 것입니다. 모듈은 특별한 훈련 없이도 개발자가 쉽게 읽고 이해할 수 있어야 합니다. 읽는 사람과 의사소통 할 수 없는 모듈은 개선해야 합니다.

위에서 작성한 티켓 판매 프로그램은 관람객들을 입장시키는데 필요한 기능을 오류 없이 정확하게 수행하고 있습니다. 따라서 제대로 동작해야 한다는 제약은 만족합니다. 하지만 불행하게도 변경 용이성과 읽는 사람과의 의사소통이라는 목적은 만족시키지 못합니다.

예상을 빗나가는 코드

마지막에 소개한 Theater 클래스의 enter 메소드가 수행하는 일을 풀어보겠습니다.

소극장은 관람객의 가방을 열어 그 안에 초대장이 들어 있는지 살펴봅니다. 가방 안에 초대장이 들어 있으면 판매원은 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮깁니다. 가방 안에 초대장이 들어 있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내 매표소에 적립한 후에 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮깁니다.

여기서 문제는 관람객과 판매원이 소극장의 통제를 받는 수동적인 존재라는 점입니다.
관람객의 입장에서 문제는 소극장이라는 제3자가 초대장을 확인하기 위해 관람객의 가방을 마음대로 열어 본다는데 있습니다. 만약 누군가가 허락 없이 가방 안의 내용물을 마음대로 뒤적이고 돈을 가져간다면... 어떻겠습니까.. 넋놓고 다른 사람이 저의 가방을 헤집어 넣는 것을 멍하니 바라볼 사람은 없을 것입니다.

판매원도 마찬가지 입니다. 소극장이 판매원의 허락도 없이 매표소에 보관 중인 티켓과 현금을 마음대로 접근 할 수 있기 때문입니다. 더 큰 문제는 티켓을 꺼내 관람객의 가방에 집어넣고 관람객에게서 받은 돈을 매표소에 적립하는 일을 판매원이 아닌 소극장이 수행한다는 점입니다. 판매원 입장에서는 가만히 앉아 티켓이 하나씩 사라지고 돈이 저절로 쌓이는 광경을 두 손 놓고 쳐다볼 수 밖에 없는 것입니다.

현재 위의 코드는 우리의 상식과는 다르게 너무나도 다르게 동작하기 때문에 코드를 읽는 사람과 제대로 의사소통하지 못합니다.
코드를 이해하기 어렵게 만드는 또 다른 이유는 이 코드를 이해하기 위해서는 여러가지 세부적인 내용들을 한꺼번에 기억하고 있어야 합니다. Theater의 enter 메소드를 살펴보면 Audience가 Bag을 가지고 있고, Bag 안에는 현금과 티켓이 들어 있으며 TicketSeller가 TicketOffice에서 티켓을 판매하고, TicketOffice안에 돈과 티켓이 보관돼 있다는 모든 사실을 동시에 기억하고 있어야 합니다. 이 코드는 하나의 클래스나 메서드에 너무 많은 세부사항을 다뤽 때문에 코드를 작성하는 사람뿐만 아니라 코드를 읽고 이해해야 하는 모두에게 큰 부담을 줍니다.

하지만 가장 심각한 문제는 이것이 아닙니다. 그것은 Audience와 TicketSeller를 변경할 경우 Theater도 함께 변경해야 한다는 사실입니다.

변경에 취약한 코드

더 큰 문제는 변경에 취약하다는 것입니다. 이 코드는 관람객이 현금과 초대장을 보관하기 위해 항상 가방을 들고 다닌다고 가정합니다. 또한 판매원이 매표소에서만 티켓을 판매한다고 가정합니다. 관람객이 가방을 들고 있지 않다면 어떻게 해야할까요?
관람객이 현금이 아니라 신용카드를 이용해서 결제를 한다면 어떻게 해야할까요? 판매원이 매표소 밖에서 티켓을 판매해야 한다면 어떻게 해야할까요? 이런 가정이 깨지는 순간 모든 코드가 일시에 흔들리게 됩니다.

관람객이 가방을 들고 있다는 가정이 바뀌었다고 상상해봅시다. Audience 클래스에서 Bag을 제거해야 할뿐만 아니라 Audience의 Bag에 직접 접근하는 Theater의 enter 메소드 역시 수정해야 합니다. Theater는 관람객이 가방을 들고 있고 판매원이 매표소에서만 티켓을 판매한다는 지나치게 세부적인 사실에 의존해서 동작합니다. 이러한 세부적인 사실 중 한 가지라도 바뀌면 해당 클래스 뿐만 아니라 이 클래스에 의존하는 Theater도 함께 변경해야 합니다. 이처럼 다른 클래스가 Audience의 내부에 대해 더 많이 알면 알수록 Audience를 변경하기 어려워집니다.

이것은 객체 사이의 의존성(dependency)과 관련된 문제입니다. 문제는 의존성이 변경과 관련돼 있다는 점입니다. 의존성은 변경에 대한 영향을 암시합니다. 의존성이라는 말 속에는 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함꼐 변경될 수 있다는 사실이 내포돼 있습니다.

그렇다고 해서 객체 사이의 의존성을 완전히 없애는 것이 정답이 아닙니다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것입니다. 따라서 우리의 목표는 애플리케이션의 기능을 구현하는데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것입니다.

스크린샷 2019-11-03 오전 2 07 21

객체 사이의 의존성이 과한 경우를 가리켜 결합도(coupling)가 높다고 말합니다. 반대로 객체들이 합리적인 수준으로 의존할 경우에는 결합도가 낮다고 말합니다. 결합도는 의존성과 관련돼 있기 때문에 결합도 역시 변경과 관련이 있습니다. 두 객체 사이의 결합도가 높으면 높을수록 함께 변경될 확률도 높아지기 때문에 변경하기 어려워 집니다. 따라서 설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만드는 것입니다.

설계 개선하기

예제 코드는 로버트 마틴이 이야기한 세 가지 목적 중 한가지는 만족시키지만 다른 두 조건은 만족시키지 못합니다. 이 코드는 기능은 제대로 수행하지만 이해하기 어렵고 변경하기 쉽지 않습니다.

여기서 변경과 의사소통이라는 문제가 서로 엮여 있다는 점에 주목합니다. 코드를 이해하기 어려운 이유는 Theater가 관람객의 가방과 판매원의 매표소에 직접 접근하기 때문입니다. 이것은 관람객과 판매원이 자신의 일을 스스로 처리해야 한다는 우리의 직관을 벗어납니다. 다시 말해서 의도를 정확하게 의사소통하지 못하기 때문에 코드가 이해하기 어려워진 것입니다. Theater가 관람객의 가방과 판매원의 매표소에 직접 접근한다는 것은 Theater가 Audience와 TicketSeller에 결합된다는 것을 의미합니다. 따라서 Audience와 TicketSeller를 변경할 때 Theater도 함꼐 변경해야 하기 때문에 전체적으로 코드를 변경하기도 어려워집니다.

해결방법은 간단합니다. Theater가 Audience와 TicketSeller에 관해 너무 세부적인 부분까지 알지 못하도록 정보를 차단하면 됩니다. 사실 관람객이 가방을 가지고 있다는 사실과 판매원이 매표소에서 티켓을 판매한다는 사실을 Theater가 알아야 할 필요가 없습니다. Theater가 원하는 것은 관람객이 소극장에 입장하는 것 뿐입니다. 따라서 관람객이 스스로 가방 안의 현금과 초대장을 처리하고 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 한다면 이 모든 문제를 한 번에 해결할 수 있습니다.

다시 말해서 관람객과 판매원을 자율적인 존재로 만들면 되는 것이 이 장의 핀트입니다.

자율성을 높이자

해결 방법은 Audience와 TicketSeller가 직접 Bag과 TicketOffice를 처리하는 자율적인 존재가 되도록 설계를 변경하는 것입니다.

첫 번째 단계는 Theater의 enter 메소드에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 내부로 숨기는 것입니다. TicketSeller에 sellTo 메소드를 추가하고 Theater에 있던 로직을 이 메서드로 옮깁니다.

public void enter(Audience audience){

    if(audience.getBag().hasInvitation()){
        Ticket ticket = ticketSeller.getTicketOffice().getTicket();
        audience.getBag().setTicket(ticket);
    }else {
        Ticket ticket = ticketSeller.getTicketOffice().getTicket();
        audience.getBag().minusAmount(ticket.getFee());
        ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
        audience.getBag().setTicket(ticket);
    }
}
---------------------변 경 전 후 ------------------------
package object;

public class TicketSeller {

    private TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }

    public void sellTo(Audience audience){
        if(audience.getBag().hasInvitation()){
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().setTicket(ticket);
        }else{
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketOffice.plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

TicketSeller에서 getTicketOffice 메소드가 제거됐다는 사실에 주목합시다. ticketOffice의 가시성이 private이고 접근 가능한 퍼블릭 메소드가 더이상 존재하지 않기 때문에 외부에서 ticketOffice에 직접 접근할 수 없습니다.
결과적으로 ticketOffice에 대한 접근은 오직 ticketSeller 안에만 존재하게 됩니다. 따라서 TicketSeller는 ticketOffice에서 티켓을 꺼내거나 판매 요금을 적립하는 일을 스스로 수행할 수밖에 없습니다.

이처럼 개념적이거나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화(encapsulation)이라고 부릅니다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것입니다. 캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 좀 더 쉽게 변경할 수 있게 됩니다.

이제 Theater의 enter 메소드는 sellTo 메소드를 호출하는 간단한 코드로 변경됩니다.

package object;

public class Theater {

    private TicketSeller ticketSeller;

    public Theater(TicketSeller ticketSeller) {
        this.ticketSeller = ticketSeller;
    }


    public void enter(Audience audience){
        ticketSeller.sellTo(audience);
    }
}

이제는 Theater는 TicektOffice가 TicketSeller 내부에 존재한다는 사실을 알지 못합니다. Theater는 단지 ticketSeller가 sellTo 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐입니다.

Theater는 오직 TicketSeller의 인터페이스(interface)에만 의존합니다. TicketSeller가 내부에 ticketOffice 인스턴스를 포함하고 있다는 사실은 구현의 영역에 속합니다. 객체를 인터페이스와 구현(implementation)으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙입니다.

스크린샷 2019-11-03 오후 2 27 13

Theater의 결합도를 낮춘 설계

위의 그림은 수정 후의 클래스 사이의 의존성을 나타낸 것입니다. Theater의 로직을 TicketSeller로 이동시킨 결과, Theater에서 TicketOffice로의 의존성이 제거됐다는 사실을 알 수 있습니다. TicketOffice와 협력하는 TicketSeller의 내부 구현이 성공적으로 캡슐화 된 것입니다.

아제 Audience의 캡슐화를 개선해야 합니다. TicketSeller는 Audience의 getBag 메소드를 호출해서 Audience 내부의 Bag 인스턴스에 직접 접근합니다. Bag 인스턴스에 접근하는 객체가 Theater에서 TicketSeller로 바뀌었을 뿐 Audience는 여전히 자율적인 존재가 아닌 것입니다.

TicketSeller와 동일한 방법으로 Audience의 캡슐화를 개선할 수 있습니다. Bag에 접근하는 모든 로직을 Audience 내부로 감추기 위해 Audience에 buy 메소드를 추가하고 TicketSeller의 sellTo 메소드에서 getBag 메소드에 접근하는 부분을 buy 메소드로 옮겨 보겠습니다.

package object;

public class Audience {

    private Bag bag;

    public Audience(Bag bag) {
        this.bag = bag;
    }

    public Bag getBag() {
        return bag;
    }

    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

변경된 코드에서 Audience는 자신의 가방 안에 초대장이 들어있는지를 스스로 확인합니다. 외부의 제3자가 자신의 가방을 열어보도록 허용하지 않습니다. Audience가 직접 Bag을 처리하기 때문에 외부에서는 더 이상 Audience가 Bag을 소유하고 있다는 사실을 알 필요가 없습니다.

이제 TicketSeller가 Audience의 인터페이스에만 의존하도록 수정하면 됩니다.TicketSeller가 buy 메서드를 호출하도록 코드를 변경하면 됩니다.

package object;

public class TicketSeller {

    private TicketOffice ticketOffice;

    public TicketSeller(TicketOffice ticketOffice) {
        this.ticketOffice = ticketOffice;
    }


    public void sellTo(Audience audience){
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }

}

코드를 수정한 결과, TicketSeller와 Audience 사이의 결합도가 낮아졌습니다. 또한 내부 구현이 캡슐화 됐으므로 Audience의 구현을 수정하더라도 TicketSeller에는 영향을 미치지 않습니다.

캡슐화를 개선한 후에 가장 크게 달라진 점은 Audience와 TicketSeller가 내부 구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결한다는 것입니다.
다시 말해 자율적인 존재가 된 것입니다.

무엇이 개선됐는가

수정된 Audience와 TicketSeller는 자신이 가지고 있는 소지품을 스스로 관리합니다. 이것은 우리의 예상과도 정확하게 일치합니다. 따라서 코드를 읽는 사람과의 의사소통이라는 관점엣허 이 코드는 확실히 개선된 것으로 보입니다.
더 중요한 점은 Audience나 TicketSeller의 내부 구현을 변경하더라도 Theater를 함께 변경할 필요가 없다는 것입니다. Audience가 가방이 아니라 작은 지갑을 소지하도록 코드를 변경하고 싶으면 Audience 내부만 변경하면 됩니다.
TicketSeller가 매표소가 아니라 은행에 돈을 보관하도록 만들고 싶으면 TicketSeller 내부만 변경하면 됩니다.

어떻게 한 것인가

간단하게 판매자가 티켓을 판매하기 위해 TicketOffice를 사용하는 모든 부분을 TicketSeller 내부로 옮기고, 관람객이 티켓을 구매하기 위해 Bag을 사용하는 모든 부분은 Audience 내부로 옮겼습니다. 다시 말해 자기 자신의 문제를 스스로 해결하도록 코드를 변경하였습니다. 우리는 우리의 직관을 따랐고 그 결과로 코드는 변경이 용이하고 이해 가능학도록 수정됐습니다.

우리는 객체의 자율성을 높이는 방향으로 설계를 개선했습니다. 그 결과, 이해하기 쉽고 유연한 설계를 얻을 수 있었습니다.

캡슐화와 응집도

핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것입니다. Theater는 TicketSeller의 내부에 대해서는 전혀 알지 못합니다.단지 TicketSeller가 sellTo 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐입니다. TicketSeller 역시 Audience의 내부에 대해서는 전혀 알지 못합니다. 단지 Audience가 buy 메시지에 응답할 수 있고 자신이 원하는 결과를 반환할 것이라는 사실만 알고 있을 뿐입니다.

밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 윙미하는 객체를 가리켜 응집도(cohesion)가 높다고 말합니다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.

외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길 입니다.

절차지향과 객체지향

수정하기 전의 코드에서 Audience, TicketSeller, Bag, TicketOffice는 관람객을 입장시키는데 필요한 정보를 제공하고 모든 처리는 Theater의 enter 메소드안에 존재했습니다.

이 관점에서 Theater의 enter 메소드는 프로세스이며 Audience, TicketSeller,Bag, TicketOffice는 데이터 입니다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부릅니다.

절차적 프로그래밍 세상에서는 데이터 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것입니다. Audience, TicketSeller의 내부 구현을 변경하려면 Theater의 enter 메소드를 함께 변경해야 합니다. 변경은 버그를 부르고 버그에 대한 두려움은 코드를 변경하기 어렵게 만듭니다. 따라서 절차적 프로그래밍 세상은 변경하기 어려운 코드를 양산하는 경향이 있습니다.

변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계입니다. 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수 밖에 없습니다.

수정한 후의 코드에서는 데이터를 사용하는 프로세스가 데이터를 소유하고 있는 Audience와 TicketSeller 내부로 옮겨졌습니다. 이처럼 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍 하는 방식을 객체지향 프로그래밍이라고 부릅니다.

책임의 이동

두 방식 사이에 근본적인 차이를 만드는 것은 책임의 이동입니다. 여기서는 책임을 기능을 가리키는 객체지향 세계의 용어로 생각해도 무방합니다.

두 방식의 차이점을 가장 쉽게 이해할 수 있는 방법은 기능을 처리하는 방법을 살펴보는 것입니다.

스크린샷 2019-11-03 오후 11 29 27

책임이 중앙집중된 절차적 프로그래밍

위의 절차지향 프로그래밍 처리 흐름도 그림에서 알수 있듰이 작업 흐름이 주로 Theater에 의해 제어된다는 사실을 알 수 있습니다.
객체지향 세계의 용어를 사용해서 표현하면 책임이 Theater에 집중돼 있는 것입니다.

스크린샷 2019-11-03 오후 11 35 06

책임이 분산된 객체지향 프로그래밍

그에 반해 객체지향 설계에서는 제어 흐름이 각 객체에 적절하게 분산돼 있음을 알 수 있습니다. 다시 말해 하나의 기능을 완성하는데 필요한 책임이 여러 객체에 걸쳐 분산돼 있는 것입니다.

변경 전의 절차적 설계에서 Theater가 전체적인 작업을 도맡아 처리했습니다. 변경 후의 객체 지향 설계에서는 각 객체가 자신이 맡은 일을 스스로 처리했습니다. 다시 말해 Theater에 몰려 있던 책임이 개별 객체로 이동한 것 입니다. 이것이 바로 책임의 이동이 의미하는 것입니다.

이렇게 객체지향적으로 코드를 작성하면서 더 즐거운 일은 코드가 더 이해하기 쉬워졌다는 점입니다. TicketSeller의 책임은 티켓을 판매하는 것이고, Audience는 티켓을 사는 책임을 가졌습니다. Theater는 관람객을 입장시키는 책임을 가졌습니다. 적절한 객체에 적절한 책임을 할당하면 이해하기 쉬운 구조와 읽기 쉬운 코드를 얻게 됩니다.

설계를 어렵게 만드는 것은 의존성이라는 것을 기억해야 합니다. 해결 방법은 불필요한 의존성을 제거함으로써 객체 사이의 결합도를 낮추는 것입니다. 예제코드에서 결합도를 낮추기 위해 선택한 방법은 Theater가 몰라도 되는 세부사항을 Audience와 TicketSeller 내부로 감춰 캡슐화하는 것입니다. 결과적으로 불필요한 세부사항을 객체 내부로 캡슐화하는 것은 객체의 자율성을 높이고 응집도 높은 객체들의 공동체를 창조할 수 있게 합니다.

더 개선할 수 있다

현재의 설계는 이전의 설계보다 분명히 좋아졌지만 아직도 개선의 여지가 있습니다. Audience 클래스를 살펴보겠습니다.

package object;

public class Audience {

    private Bag bag;

    public Audience(Bag bag) {
        this.bag = bag;
    }


    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

Audience는 분명 자율적인 존재입니다. 스스로 티켓을 구매하고 가방 안의 내용물을 직접 관리합니다. 하지만 Bag은 Audience처럼 스스로 자기 자신을 책임지지 않고 Audience에 끌려다니는 수동적인 존재입니다. 여기서... 이부분을 눈치채신다면 그대는 객체지향의.. 빡고수의 자질을 가지신겁니다.. 전 몰랐습니다.. 아직 많이 부족한걸 느끼네요

다시 Bag을 자율적인 존재로 바꿔보겠습니다. 방법은 이전과 동일합니다. Bag 내부 상태에 접근하는 모든 로직을 Bag 안으로 캡슐화해서 결합도를 낮추면 됩니다. Bag에 hold 메소드를 추가해보겠습니다.

package object;

public class Bag {

    private Long amount;
    private Ticket ticket;
    private Invitation invitation;

    // 이벤트 당첨자인지 확인하는 비즈니스 로직
    public Long hold(Ticket ticket) {

        if (hasInvitation()) {
            setTicket(ticket);
            return 0L;
        } else {
            minusAmount(ticket.getFee());
            setTicket(ticket);
            return ticket.getFee();
        }
    }

    // 초대장이 있습니까?
    private boolean hasInvitation() {
        return this.invitation != null;
    }

    // 현금 감소
    private void minusAmount(Long amount) {
        this.amount -= amount;
    }

    //티켓 교환
    private void setTicket(Ticket ticket) {
        this.ticket = ticket;
    }
}

public 메소드였던 hasInvitation, minusAmount, setTicket 메소드들은 더 이상 외부에서 사용되지 않고 내부에서만 사용되기 때문에 가시성을 private로 변경했습니다. 이 작은 메소드들은 제거하지 않고 그대로 유지한 이유는 코드의 중복을 제거하고 표현력을 높이기 위해서 입니다.

Bag의 구현을 캡술화 시켰으니 이제 Audience를 Bag의 구현이 아닌 인터페이스에만 의존하도록 수정합니다.

package object;

public class Audience {

    public Long buy(Ticket ticket) {
       return bag.hold(ticket);
    }
}

TicketSeller 역시 TicketOffice의 자율권을 침해합니다. 아래 코드에서 알 수 있듯이 현재의 TicketSeller는 TicketOffice에 있는 Ticket을 마음대로 꺼내서는 자기 멋대로 Audience에게 팔고 Audience에게 받은 돈을 마음대로 TicketOffice에 넣어 버립니다.

package object;

public class TicketSeller {

    public void sellTo(Audience audience){
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }

}

잃어버린 TicketOffice의 자율권을 찾아보겠습니다. TicketOffice에 sellTicketTo 메소드를 추가하고 TicketSeller의 sellTo 메소드의 내부 코드를 이 메소드로 옮기면 됩니다. 이제 getTicket 메서드와 plusAmount 메소드는 TicketOffice 내부에서만 사용되기 때문에 가시성을 public에서 private로 변경할 수 있습니다.

public class TicketOffice {


    public void sellTicketTo(Audience audience){
        plusAmount(audience.buy(getTicket()));

    }


    private Ticket getTicket(){
        return this.tickets.remove(0);
    }


    private void plusAmount(Long amount){
        this.amount += amount;
    }
}

TicketSeller는 TicketOffice의 sellTicketTo 메소드를 호출함으로써 원하는 목적을 달성할 수 있습니다. 좋은 소식은 이제 TicketSeller가 TicketOffice의 구현이 아닌 인터페이스에만 의존하게 됐다는 점입니다

package object;

public class TicketSeller {

    public void sellTo(Audience audience){
        ticketOffice.sellTicketTo(audience);
    }
}

하지만 여기서 끝이 아닙니다... 안타깝게도 처음에 생각했던 것만큼 만족스럽지가 않습니다. 그 이유는 TicketSeller와 Audience 사이에 의존성이 추가됐기 때문입니다. 변경 전에는 TicketOffice가 Audience에 대해 알지 못했었다는 불편한 진실이 있습니다. 변경 후에는 TicketOfffice가 Audience에게 직접 티켓을 판매하기 때문에 Audience에 관해 알고 있어야 합니다.

스크린샷 2019-11-04 오전 12 36 33

TicketOffice에서 Audience로 향하는 의존성이 추가

현재로서는 Audience에 대한 결합도와 TicketOffice의 자율성 모두를 만족시키는 방법이 잘 떠오르지 않습니다. 트레이드오프 시점이 왔습니다. 여기에서는 자율성보다는 Audience에 대한 결합도를 낮추는 것이 더 중요하다는 결론에 도달했습니다.

이 작은 예제를 통해서 두 가지 사실을 알게 돼었습니다.

  • 첫째, 어떤기능을 설계하는 방법은 한 가지 이상일 수 있습니다.
  • 둘째, 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물입니다.

설계는 균형의 예술입니다. 훌륭한 설계는 적절한 트레이드 오프의 결과물이라는 사실을 명심해야 합니다. 이러한 트레이드오프 과정이 설계를 어려우면서도 흥미진진한 작업으로 만드는 것입니다.

참조:오브젝트 코드로 이해하는 객체지향 설계

'SpringFramework > JAVA' 카테고리의 다른 글

제네릭을 사용하는 이유?  (1) 2019.12.03
프록시 패턴  (0) 2019.11.20
Stream(스트림)  (0) 2019.10.05
@Annotation 이란?  (0) 2019.10.05
클린코드 OOP의 장점  (0) 2019.10.05

조인 테이블

데이터베이스 테이블의 연관관계를 설계하는 방법은 크게 2가지 입니다.

  • 조인 컬럼 사용(외래 키)
  • 조인 테이블 사용(테이블 사용)

테이블 간에 관계는 주로 조인 컬럼이라 부르는 외래 키 컬럼을 사용해서 관리합니다.

스크린샷 2019-10-30 오전 1 16 48

조인 컬럼 사용

스크린샷 2019-10-30 오전 1 13 19

조인 컬럼 데이터

위의 그림에서 회원과 사물함이 있고 각각 테이블에 데이터를 등록했다가 회원이 원할 때 사물함을 선택할 수 있다고 가정해보겠습니다. 회원이 사물함을 사용하기 전까지 아직 둘 사이에 관계가 없으므로 MEMBER 테이블의 LOCKER_ID 외래 키에 null을 입력해두어야 합니다. 이렇게 외래 키에 null을 허용하는 관계를 선택적 비식별 관계라고 합니다.

선택적 비식별 관계는 외래 키에 null을 허용하므로 회원과 사물함을 조인할 때 외부 조인을 사용해야 합니다. 실수로 내부 조인을 사용하면 사물함과 관계 없는 회원은 조회되지 않습니다. 그리고 회원과 사물함이 아주 가끔 관계를 맺는다면 외래 키 값 대부분이 null로 지정되는 단점이 있습니다.

조인 테이블 사용

아래 그림은 조인 컬럼을 사용하는 대신에 조인 테이블을 사용해서 연관관계를 관리하고 있습니다.

스크린샷 2019-10-30 오전 1 38 42

조인 테이블 사용

스크린샷 2019-10-30 오전 1 44 41

조인 테이블 데이터

이 방법은 조인 테이블이라는 별도의 테이블을 사용해서 연관관계를 관리합니다.
조인 컬럼을 사용하는 방법은 단순히 외래 키 컬럼만 추가해서 연관관계를 맺지만 조인 테이블을 사용하는 방법은 연관관계를 관리하는 조인 테이블을 추가하고 여기서 두 테이블의 외래 키를 가지고 연관관계를 관리합니다. 따라서 MEMBER, LOCKER 테이블에는 연관관계를 관리하기 위한 외래 키 컬럼이 없습니다.

조인 테이블의 가장 큰 단점은 테이블을 하나 추가해야 한다는 점입니다. 따라서 관리해야 하는 테이블이 늘어나고 회원과 사물함 두 테이블을 조인하려면 MEMBER_LOCKER 테이블까지 추가로 조인해야 합니다. 따라서 기본은 조인 컬럼을 사용하고 필요하다고 판단되면 조인 테이블을 사용하면 됩니다.

일대일 조인 테이블

조인 테이블 일대일에서 조인 테이블을 살펴보겠습니다. 일대일 관계를 만들려면 조인 테이블의 외래 키 컬럼 각각에 총 2개의 유니크 제약조건을 걸어야 합니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;

    @OneToOne
    @JoinTable(name = "PARENT_CHILD",
        joinColumns = @JoinColumn(name = "PARENT_ID"),
        inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private Child child;
}

@Getter
@Setter
@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
}






@Test
@Transactional
@Rollback(false)
public void 식별자_테스트() throws Exception {

        Parent parent = new Parent();
        parent.setName("임종수");

        Child child = new Child();
        child.setName("임준영");

        parent.setChild(child);
        em.persist(parent);
        em.persist(child);

}

다대일 조인 테이블

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;

    @OneToMany
    private List<Child> child = new ArrayList<Child>();
}


@Getter
@Setter
@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;


    @ManyToOne(optional = false)
    @JoinTable(name = "PARENT_CHILD",
            joinColumns = @JoinColumn(name = "CHILD_ID"),
            inverseJoinColumns = @JoinColumn(name = "PARENT_ID")
    )
    private Parent parent;

}

다대다 조인 테이블

다대다 관계를 만들려면 조인 테이블의 두 컬럼을 합해서 하나의 복합 유니크 제약조건을 걸어야 합니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(name = "PARENT_CHILD",
            joinColumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private List<Child> child = new ArrayList<Child>();
}

@Getter
@Setter
@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;

}

엔티티 하나에 여러 테이블 매핑

잘 사용하지는 않지만 @SecondaryTable을 사용하면 한 엔티티에 여러 테이블을 매핑할 수 있습니다.

스크린샷 2019-10-30 오전 2 39 20

@Entity
@Table(name = "BOARD")
@SecondaryTable(name = "BOARD_DETAIL",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "BOARD_DETAIL_ID"))
public class Board{

    @Id @GeneratedValue
    @Column(name = "BOARD_ID")
    private Long id;

    private String title;

    @Column(table = "BOARD_DETAIL")
    private String content;

}

위의 코드를 살펴보면 Board 엔티티는 @Table을 사용해서 BOARD 테이블과 매핑했습니다. 그리고 @SecondaryTable을 사용해서 BOARD_DETAIL 테이블을 추가로 매핑했습니다.

@SecondaryTable 속성은 다음과 같습니다.

@SecondaryTable.name: 매핑할 다른 테이블의 이름, 예제에서는 테이블명을 BOARD_DETAIL로 지정했습니다.

@SecondaryTable.pkJoinColumn: 매핑할 다른 테이블의 기본 키 컬럼 속성, 예제에서는 기본 키 컬럼명을 BOARD_DETAIL_ID로 지정했습니다.

@Column(table = "BOARD_DETAIL")
private String content;

content 필드는 @Column(table = "BOARD_DETAIL")을 사용해서 BOARD_DETAIL 테이블의 컬럼에 매핑했습니다. title 필드처럼 테이블을 지정하지 않으면 기본 테이블인 BOARD에 매핑됩니다.

@SecondaryTables({

    @SecondaryTable(name = "BOARD_DETAIL"),
    @SecondaryTable(name = "BOARD_FILE")

})

더 많은 테이블을 매핑하려면 @SecondaryTables를 사용하면 됩니다.
하지만 이 방법은 항상 두 테이블을 조회하므로 최적화하기가 어렵습니다. 반면에 일대일 매핑은 원하는 부분만 조회 할 수 있고 필요하면 둘다 함께 조회됩니다.

참고: 김영한의 JAVA ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

지연로딩과 즉시로딩  (0) 2019.11.23
프록시와 연관관계 관리  (0) 2019.11.22
복합 키와 식별관계 매핑  (0) 2019.10.29
고급매핑 - 상속관계 매핑  (0) 2019.10.28
스프링 부트 테스트 : @DataJpaTest  (0) 2019.10.09

복합 키과 식별 관계 매핑

복합 키를 매핑하는 방법과 식벽관계, 비식별 관계를 매핑하는 방법을 알아보겠습니다.

식별관계 vs 비식별 관계

데이터베이스 테이블 사이에 관계는 외래 키가 기본 키에 포함되는지 여부에 따라 식별 관계와 비식별 관계로 구분됩니다. 두 관계의 특징을 이해하고 각각을 어떻게 매핑하는지 알아봅시다.

식별관계

식별 관계는 부모 테이블의 기본 키를 내려받아서 자식 테이블의 기본 키 + 외래 키로 사용하는 관계 입니다.

스크린샷 2019-10-28 오후 10 53 41

위의 그림을 보면 PARENT 테이블의 기본 키 PARENT_ID를 받아서 CHILD 테이블의 기본키 (PK) + 외래 키(FK)로 사용합니다.

비식별 관계

비식별 관계는 부모 테이블의 기본 키를 받아서 자식 테이블의 외래 키로만 사용하는 관계입니다.

스크린샷 2019-10-28 오후 10 55 20

PARENT 테이블의 기본 키 PARENT_ID를 받아서 CHILD 테이블의 외래 키(FK)로만 사용한다.
비식별 관계는 외래 키에 NULL을 허용하는지에 따라 필수적 비식별 관게와 선택적 비식별 관계로 나눕니다.

  • 필수적 비식별 관계: 외래키에 NULL을 허용하지 않습니다. 연관관계를 필수적으로 맺어야 합니다.

  • 선택적 식별 관계: 외래키에 NULL을 허용합니다. 연관관계를 맺을지 말지 선택할 수 있습니다.

최근에는 테이블을 설계할 때 식별 관계나 비식별 관계 중 하나를 선택해야 합니다.
최근에는 비식별 관계를 주로 사용하고 꼭 필요한 곳에만 식별 관계를 사용하는 추세입니다.
JPA는 식별 관계와 비식별 관계를 모두 지원합니다.

복합 키: 비식별 관계 매핑

둘 이상의 컬럼으로 구성된 복합 기본 키는 아래 코드처럼 매핑하면 될 것 같지만 막상 해보면 매핑 오류가 발생합니다. JPA에서는 식별자를 둘 이상 사용하려면 별도의 식별자 클래스가 필요하기 때문입니다.

@Entity
public class Hello{

    @Id
    private String id1;
    @Id
    private String id2; // 실행 시점에 매핑 예외발생

}

JPA는 영속성 컨텍스트에 엔티티를 보관할 때 엔티티의 식별자를 키로 사용합니다. 그리고 식별자를 구분하기 위해 equals와 hashCode를 사용해서 동등성 비교를 합니다. 그런데 식별자 필드가 하나일 때는 보통 자바의 기본타입을 사용하므로 문제가 없지만, 식별자 필드가 2개 이상이면 별도의 식별자 클래스를 만들고 그곳에 equals와 hashCode를 구현해야 합니다.

JPA는 복합 키를 지원하기 위해 @IdClass와 @EmbeddedId 2가지 방법을 제공하는데 @IdClass는 관계형 데이터베이스에 가까운 방법이고 @EmbeddedId는 좀 더 객체지향에 가까운 방법입니다. 먼저 @IdClass부터 알아봅시다.

@IdClass

복합 키 테이블은 비식별 관계이고 PARENT는 복합 기본 키를 사용합니다. 참고로 여기서 이야기하는 부모 자식은 객체의 상속과는 무관합니다. 단지 테이블의 키를 내려받는 것을 강조하려고 이름을 이렇게 지었습니다.

스크린샷 2019-10-28 오후 11 12 22

PARENT 테이블을 보면 기본 키를 PARENT_ID1, PARENT_ID2로 묶은 복합 키로 구성했습니다. 따라서 복합 키를 매핑하기 위해서 식별자 클래스를 만들어야 합니다.

@Entity
@IdClass(ParentId.class)
public class Parent{

    @Id
    @Column(name = "PARENT_ID1")
    private String id1;  // ParentId.id1과 연결

    @Id
    @Column(name = "PARENT_ID2")
    private String id2;  // ParentId.id2와 연결

    private String name;

}
public class ParentId implements Serializable{

    private String id1; // Parent.id1 매핑
    private String id2; // Parent.id2 매핑

    public ParentId(){}



    public ParentId(String id1, String id2)}
        this.id1 = id1;
        this.id2 = id2;
    }

    @Override
    public boolean equals(Object obj) { ... }

    @Override
    public int hashCode() {...}

}

@IdClass를 사용할 때 식별자 클래스는 다음 조건을 만족해야 합니다.

  • 식별자 클래스의 속성명과 엔티티에서 사용하는 식별자의 속성명이 같아야 합니다.
    실제로 코드를 보면 Parent.id1과 Parent.id2, 그리고 ParentId.id1, ParentId.id2가 같습니다.

  • Serializable 인터페이스를 구현해야 합니다.

  • equals, hashCode를 구현해야 합니다.

  • 기본 생성자가 있어야 합니다.

  • 식별자 클래스는 public이어야 합니다.

실제 어떻게 사용하는지 코드로 살펴보겠습니다. 먼저 복합 키를 사용하는 엔티티를 저장합니다.


    @Test
    @Transactional
    @Rollback(false)
    public void 식별자_테스트() throws Exception {

        //given
        Parent parent = new Parent();
        parent.setId1("myId1");
        parent.setId2("myId2");
        parent.setName("parentName");

        //when
        em.persist(parent);
        ParentId parentId = new ParentId("myId1", "myId2");

        //복합 키로  조회
        Parent findParent = em.find(Parent.class, parentId);
        //then
        System.out.println(findParent.getId1() + " " + findParent.getId2() + " " + findParent.getName());
     }

저장 코드를 보면 식별자 클래스인 ParentId가 보이지 않는데, em.persist()를 호출하면 영속성 컨텍스트에 엔티티를 등록하기 직전에 내부에서 Parent.id1, Parent.id2 값을 사용해서 식별자 클래스인 ParentId를 생성하고 영속성 컨텍스트의 키로 사용합니다.

아래 코드는 자식 클래스를 추가하였습니다.

@Getter
@Setter
@Entity
public class Child {

    @Id
    private String id;

    @ManyToOne
    @JoinColumns({@JoinColumn(name = "PARENT_ID1", referencedColumnName = "PARENT_ID1"),
    @JoinColumn(name = "PARENT_ID2", referencedColumnName = "PARENT_ID2")})
    private Parent parent;

}

부모 테이블의 기본 키 컬럼이 복합 키이므로 자식 테이블의 외래 키도 복합 키입니다. 따라서 외래 키 매핑시 여러 컬럼을 매핑해야 하므로 @JoinColumns 어노테이션을 사용하고 각각의 외래 키 컬럼을 @JoinColumn으로 매핑합니다.

참고로 @JoinColumn의 name 속성과 referencedColumnName 속성의 값이 같으면 referencedColumnName은 생략해도 됩니다.

@EmbeddedId

@IdClass가 데이터베이스에 맞춘 방법이라면 @EnbeddedId는 좀 더 객체지향적인 방법입니다.

@EqualsAndHashCode
@Embeddable
public class ParentId implements Serializable {

    @Column(name = "PARENT_ID1")
    private String id1; // Parent.id1 매핑
    @Column(name = "PARENT_ID2")
    private String id2; // Parent.id2 매핑

    public ParentId(){}


    public ParentId(String id1, String id2){
        this.id1 = id1;
        this.id2 = id2;
    }
}


@Setter
@Getter
@Entity
@IdClass(ParentId.class)
public class Parent {

    @EmbeddedId
    ParentId parentId;

    private String name;

}

@IdClass와는 다르게 EmbeddedId를 적용한 식별자 클래스는 식별자 클래스에 기본키를 직접 매핑합니다.

@EmbeddedId를 적용한 식별자 클래스는 다음 조건을 만족해야 합니다.

  • @Embeddedable 어노테이션을 붙어주어야 합니다.
  • Serializable 인터페이스를 구현해야 합니다.
  • equals, hashCode를 구현해야 합니다.
  • 기본 생성자가 있어야 합니다.
  • 식별자 클래스는 public이어야 합니다.

@EmbeddedId를 사용하는 코드를 보겠습니다.

    @Test
    @Transactional
    @Rollback(false)
    public void 식별자_테스트() throws Exception {

        Parent parent = new Parent();
        ParentId parentId = new ParentId("myId1", "myId2");
        parent.setParentId(parentId);
        parent.setName("parentName");

        em.persist(parent);

        ParentId parentId1 = new ParentId("myId1", "myId2");
        Parent findParent = em.find(Parent.class, parentId1);

        System.out.println(findParent.getName());
     }

위의 코드를 보면 식별자 클래스 parentId를 직접 생성해서 사용합니다.

@IdClass와 @EmbeddedId는 각각 장단점이 있으므로 본인의 취향에 맞는 것을 일관성 있게 사용하면 됩니다.

복합 키에는 @GenerateValue를 사용 할 수 없습니다. 복합 키를 구성하는 여러 컬럼 중 하나에도 사용할 수 없습니다.

김영한의 JAVA ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

프록시와 연관관계 관리  (0) 2019.11.22
고급매핑 - 조인테이블  (0) 2019.10.30
고급매핑 - 상속관계 매핑  (0) 2019.10.28
스프링 부트 테스트 : @DataJpaTest  (0) 2019.10.09
JPA 다양한 연관관계 매핑  (0) 2019.10.04

고급매핑

상속 관계 매핑

  • 상속관계 매핑은 객체의 상속관계를 데이터베이스에 어떻게 매핑하는지 다룹니다.

관계형 데이터베이스에는 객체지향 언어에서 다루는 상속이라는 개념이 없습니다.
대신에 슈퍼타입 서브타입 관계라는 모델링 기법이 객체의 상속 개념과 가장 유사합니다.
ORM에서 이야기하는 상속 관계 매핑은 객체의 상속 구조와 데이터베이스의 슈퍼타입 서브타입 관계를 매핑하는 것 입니다.

객체 상속 모델

스크린샷 2019-10-27 오후 11 10 37

슈퍼타입 서브타입 논리 모델을 실제 물리 모델의 테이블로 구현할 때는 3가지 방법을 선택할 수 있습니다.

  • 각각의 테이블로 변환: 위 그림과 같이 각각을 모두 테이블로 만들고 조회할 때 조인을 사용합니다. JPA에서는 조인 전략이라고 할수 있습니다.

  • 통합 테이블로 변환: 테이블을 하나만 사용해서 통합합니다. JPA에서는 싱글 테이블 전략이라고 합니다.

  • 서브타입 테이블로 변환: 서브 타입마다 하나의 테이블을 만듭니다. JPA에서는 구현 클래스마다 테이블 전략이라 합니다.

위의 전략들을 하나하나씩 살펴보겠습니다.

조인전략은 그림과 같이 엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본 키를 받아서 기본 키 + 외래 키로 사용하는 전략입니다. 따라서 조회할 때 조인을 자주 사용합니다. 이 전략을 사용할 때 주의할 점이 객체는 타입으로 구분할 수 있지만 테이블은 타입 개념이 없습니다. 따라서 타입을 구분하는 컬럼을 추가해야 합니다. 여기서는 DTYPE 컬럼을 구분 컬럼으로 사용했습니다.

조인전략

스크린샷 2019-10-27 오후 11 28 01

조인전략 예제 코드

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;

}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;

}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;

    private String actor;
}

@Inheritance(strategy = InheritanceType.JOINED): 상속 매핑은 부모 클래스에 @Inheritance를 사용합니다. 그리고 매핑 전략을 지정해야 하는데 여기서는 조인 전략을 사용하므로 InheritanceType.JOINED를 사용했습니다.

@DiscriminatorColumn(name = "DTYPE"): 부모 클래스에 구분 컬럼을 지정합니다. 이컬럼으로 저장된 자식 테이블을 구분할 수 있습니다. 기본 값이 DTYPE이므로 @DiscriminatorColumn으로 줄여 사용해도 된다.

@DiscriminatorValue("M"): 엔티티를 저장할 때 구분 컬럼에 입력할 값을 지정한다.
만약 영화 엔티티를 지정하면 구분 컬럼인 DTYPE에 값 M이 저장됩니다.

기본값으로 자식 테이블은 부모 테이블의 ID 컬럼명을 그대로 사용하는데, 만약 자식 테이블의 기본 키 컬럼명을 변경하고 싶으면 아래 코드처럼 사용하시면 됩니다.

@Entity
@DiscriminatorValue("B")
@PrimaryKeyJoinColumn(name = "BOOK_ID") //ID 재정의
public class Book extends Item{

    private String author; //저자
    private String isbn;   // ISBN

}
  • 장점

    - 테이블이 정규화 됩니다.

    - 외래키 참조 무결성 제약조건을 활용할 수 있습니다.

    - 저장공간을 효율적으로 사용합니다.

  • 단점

    - 조회할 때 조인이 많이 사용되므로 성능이 저하될 수 있습니다.

    - 조회쿼리가 복잡합니다.

    - 데이터를 등록할 INSERT SQL을 두 번 실행합니다.

JPA 표준 명세는 구분 컬럼을 사용하도록 하지만 하이버네이트를 포함한 몇몇 구현체는 구분컬럼 없이도 동작합니다.

싱글 테이블 전략

싱글 테이블 전략은 아래 그림과 같이 이름 그대로 테이블 하나만 사용합니다. 그리고 구분 컬럼으로 어떤 자식 데이터가 저장되었는지 구분합니다. 조회할 때 조인을 사용하지 않으므로 일반적으로 가장 빠릅니다.

싱글 테이블 전략

스크린샷 2019-10-27 오후 11 31 27

이 전략을 사용할 때 주의점은 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다는 점입니다. 예를 들어 Book 엔티티를 저장하면 ITEM 테이블의 AUTHOR, ISBN 컬럼만 사용하고 다른 엔티티와 매핑된 ARTIST, DIRECTOR, ACTOR 컬럼은 사용하지 않으므로 null이 입력되기 때문입니다.

싱글테이블 예제 코드

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id
    @GeneratedValue    
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;

}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;

}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;

    private String actor;
}

InheritanceType.SINGLE_TABLE로 지정하면 단일 테이블 전략을 사용합니다.
테이블 하나에 모든 것을 통합하므로 구분 컬럼을 필수로 사용해야 합니다. 단일 테이블 전략의 장단점은 하나의 테이블을 사용하는 특징과 관련 있습니다.

  • 장점

    - 조인이 필요 없으므로 일반적으로 조회 성능이 빠릅니다.

    - 조회 쿼리가 단순합니다.

  • 단점

    - 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 합니다.

    - 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있습니다. 그러므로 상황에 따라 조회 성능이 오히려 느려질 수 있습니다.

싱글 테이블 전략에서는 구분 컬럼 @DiscriminatorColumn을 꼭 설정해야 합니다.
@DiscriminatorValue를 지정하지 않으면 기본으로 엔티티 이름을 사용홥니다.

구현 클래스마다 테이블 전략

구현 클래스마다 테이블 전략은 아래 그림과 같이 자식 엔티티마다 테이블을 만듭니다.
그리고 자식 테이블에 각각에 필요한 컬럼이 모두 있습니다.

구현 클래스마다 테이블 전략

스크린샷 2019-10-27 오후 11 34 31

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {

    @Id
    @GeneratedValue    
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;

}

@Entity
public class Book extends Item {

    private String author;
    private String isbn;
}

@Entity
public class Album extends Item {

    private String artist;

}

@Entity
public class Movie extends Item {

    private String director;

    private String actor;
}

InheritanceType.TABLE_PER_CLASS를 선택하면 구현 클래스마다 테이블 전략을 사용합니다. 이 전략은 자식 엔티티마다 테이블을 만듭니다. 일반적으로 추천하지 않는 전략입니다.

  • 장점

    - 서브 타입을 구분해서 처리할 때 효과적입니다.

    - not null 제약조건을 사용할 수 없습니다.

  • 단점

    - 여러 자식 테이블을 함꼐 조회할 때 성능이 느립니다.

    - 자식 테이블을 통합해서 쿼리하기 어렵습니다.

이 전략은 데이터베이스 설계자와 ORM 전문가 둘 다 추천하지 않는 전략입니다.
조인이나 싱글 테이블 전략을 고려합시다.

@MappedSuperclass

이전 포스팅에서 상속 관계 매핑은 부모 클래스와 자식 클래스를 모두 데이터베이스 테이블과 매핑하였습니다. 부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속 받는 자식 클래스에게 매핑 정보만 제공하고 싶으면 @MappedSuperclass를 사용하면 됩니다.

@MappedSuperclass는 비유를 하자면 추상 클래스와 비슷한데 @Entity는 실제 테이블과 매핑되지만 @MappedSuperclass는 실제 테이블과는 매핑되지 않습니다.
이것은 단순히 매핑 정보를 상속할 목적으로만 사용합니다.

@MappedSuperclass
public abstract class BaseEntity {

    @Id @GeneratedValue
    private Long id;
    private String name;


}

@Entity
@Setter
@Getter
public class Member extends BaseEntity{

    // ID 상속
    // NAME 상속
    private String email;    
    ...
}

public class Seller extends BaseEntity{

    // ID 상속
    // NAME 상속
    private String shopName;
}

BaseEntity는 객체들이 주로 사용하는 공통 매핑 정보를 정의했습니다. 그리고 자식 엔티티들은 상속을 통해 BaseEntity의 매핑 정보를 물려받았습니다. 여기서 BaseEntity는 테이블과 매핑할 필요가 없고 자식 엔티티에게 공통으로 사용되는 매핑 정보만 제공하면 됩니다. 따라서 @MappeedSuperclass를 사용했습니다.

@Entity
@AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID"))
public class Member extends BaseEntity{

}

위의 코드는 부모에게 물려받은 id 속성의 컬럼명을 MEMBER_ID로 재정의 하였습니다.
매핑 정보를 재정의하려면 AttributeOverrides 나 AttributeOverride를 사용하고, 연관관계를 재정의하려면 @AssociationOverrides나 @AssociationOverride를 사용합니다.

@MappedSuperclass의 특징

  • 테이블과 매핑되지 않고 자식 클래스에 엔티티의 매핑 정보를 상속하기 위해 사용합니다.

  • @MappedSuperclass로 지정한 클래스는 엔티티가 아니므로 em.find()나 JPQL에서 사용 할 수 없습니다.

  • 이 클래스를 직접 생성해서 사용할 일은 거의 없으므로 추상 클래스로 만드는 것을 권장합니다.

결론은 @MapperdSuperclass는 테이블과는 관계가 없고 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모아주는 역할을 할 뿐입니다. ORM에서 이야기 하는 진정한 상속 매핑은 이전에 학습한 객체 상속을 데이터베이스 슈퍼타입 서브타입 관계와 매핑하는 것입니다.

@MapperdSuperclass를 사용하면 등록일자, 수정일자, 등록자, 수정자 같은 여러 엔티티에서 공통으로 사용하는 속성을 효과적으로 관리할 수 있습니다.

참조: 김영한님의 JAVA ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

고급매핑 - 조인테이블  (0) 2019.10.30
복합 키와 식별관계 매핑  (0) 2019.10.29
스프링 부트 테스트 : @DataJpaTest  (0) 2019.10.09
JPA 다양한 연관관계 매핑  (0) 2019.10.04
JPA 연관관계 매핑기초  (0) 2019.10.04

+ Recent posts