// 에드센스

H2 데이터베이스를 사용할 일이 많음에도 불구하고 초기 설정할때마다 삐걱대는게 지겨워져서 한번 정리한다.


1. H2 데이터베이스란?

H2 데이터베이스는 설치가 필요 없고 용량이 매우 가벼우며 웹용 콘솔을 제공하여 개발용 로컬 DB로 사용하기 좋은 데이터베이스이다. H2의 특징을 정리하면 다음과 같다.

  • 스프링 부트가 지원하는 인메모리 관계형 데이터베이스이다.
  • 인메모리로 띄우면 애플리케이션을 재시작할 때마다 초기화된다.
  • 별도의 설치가 필요 없다.
  • 로컬 환경, 테스트 환경에서 많이 사용된다.

http://h2database.com에 들어가서 다운받으면 된다.

 

 


2. Spring boot에서 H2 사용하기

 

build.gradle 설정

runtimeOnly 'com.h2database:h2'

 

 

application.yml 설정

spring:
  h2:
    console:
      enabled: true
      path: /jwt

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE
    username: sa
    password:

h2.console.enable : true 는 웹 콘솔을 사용하겠다는 의미

h2.console.path : /jwt는 콘솔의 경로를 의미(나는 jwt 관련 프로젝트를 진행하고 있기에 이름을 이렇게 했다)

 

 

이렇게 하면 h2 웹 콘솔에 접속할 때 

http://localhost:8080/jwt

주소로 들어가면 된다. 

 

 

datasource.url은 로컬에서 어디에 h2 데이터베이스의 정보를 저장할 것이냐는 건데, 나의 경우

~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

로 설정하면 바탕화면에 jwt-h2라는 폴더가 생기고 그 안에 jwt라는 이름으로 저장된다.

 

 

 

이제 웹 콘솔창에서 url 전체를 입력하고 connect를 누르면 사용 가능하다.

jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

 

 

 


3. 스프링 시큐리티를 사용한다면

스프링 시큐리티에 의해서 h2 콘솔 접근이 차단될 수 있다. 나의 경우는

h2 console localhost에서 연결을 거부했습니다.

라고 뜨며 다음과 같은 화면이 나왔다.

 

 

 

 

이럴땐 스프링 시큐리티의 WebSecurityConfig 클래스에서 h2-console에 대한 접근을 허용해줘야 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

	// ... 
	
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
    
    //...
}

configure 메소드에 위와 같이 h2 콘솔에 대한 접근을 허용해 주면 된다. 그럼 문제 해결

 

 

 

 

 

참고:


1. JWT란?

Json Web Token의 줄임말이다. 

두 개체에서 JSON객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안정성 있게 전달해 주는 인증 방식이다.

 

 


2. 세션과의 차이점

세션

보통 로그인을 구현할때 세션로그인을 많이 사용했다. 세션로그인이란, 

이런 흐름을 가지고 진행된다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 세션아이디를 응답한다. 이 세션아이디는 서버에서도 가지고있는다.
  3. 이후 클라이언트의 요청에는 2번에서 응답받은 세션아이디를 쿠키에 담아서 함께 요청한다.
  4. 서버는 함께 요청된 쿠키 속의 세션아이디를 확인하여 로그인된 사용자인지 확인한다.

 

이러한 세션 로그인 방식은 단점이 있다.

 

단점.

  1. 만약 여러대의 서버가 운영된다면, 서버측에서 저장하고있는 모든 세션을 공유해야한다.
  2. 사용자가 많아질수록 서버측에서 모든 사용자의 세션들을 저장하기 부담스럽다.

 

 

그러면 JWT는 이 문제를 어떻게 해결할까?

JWT

JWT방식은 다음과 같은 흐름을 가진다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 토큰을 응답한다. 이 토큰은 서버에서 보관하지 않는다.
  3. 이후 클라이언트의 2번에서 응답받은 토큰을 요청에 함께 보낸다.
  4. 서버는 토큰이 유효한지 검증하고 유효하면 로그인된 사용자라고 생각한다.

 

세션방식과 다른 가장 큰 포인트는, 로그인 후 무언가를 응답으로 보내주긴하는데 JWT는 서버측에서 그걸 기억하지 않는다는 것이다. 

세션방식의 문제점 중 하나가 기억해야할 세션 아이디가 너무 많다는 것이었는데 이 문제점을 해결하는 부분이다.

서버는 자신이 발행한 토큰을 기억하지 않고, 나중에 토큰을 받으면 그 토큰이 유효한지 검증만 하면 되는 것이다.

또한 여러 디바이스나 도메인에서도 토큰에 대한 인증만 하면 되니 여러 서버가 운영될 때에도 문제 없다.

 

 

JWT가 마냥 좋은것만은 아니다.

세션은 시간에 따라 바뀌는 값을 갖는 stateful한 값이므로 어떠한 장점이 있느냐, 세션 값을 가지고있는 대상들을 제어할 수 있다. 예를 들어서 한 기기에서만 로그인이 가능하도록 구현하려 한다고 가정해보자.

1번 기기에 로그인이 돼있는데 2번 기기에서 로그인을 하면, 1번 기기의 세션을 종료하면 된다.

하지만 JWT는 사용자의 상태를 모르기 때문에 (stateless) 이것이 불가능하다. 이미 줘버린 토큰을 다시 회수할 수도 없고, 그 토큰의 발급 내용이나 정보를 서버가 추적하고 있지도 않기 때문이다. 반면 세션은 서버측의 세션저장소에 있기에 가능하다.

 

 

여담으로 지금 말한 단일 기기 로그인을 JWT로 해결하기 위한 기법도 존재한다. 

  • 최초 토큰 발행시 refresh 토큰과 access 토큰, 총 2개의 토큰을 발행한다.
  • refresh토큰은 만료기한(수명)이 꽤 길고 access토큰은 매우 짧다. 
  • refresh토큰의 상응값을 데이터베이스에도 저장한다.
  • 사용자가 요청할때 access토큰을 사용하는데 access토큰의 수명이 끝나면 refresh토큰을 사용해서 요청을 보낸다.
  • 서버는 서버측의 refresh 토큰 상응값과 비교해보고 맞다면 새로운 access토큰을 발행해 준다.
  • 즉, refresh토큰만 안전하게 관리된다면 중간에 access토큰이 탈취당해도 어자피 수명이 짧기 때문에 보안상 위협을 줄일 수 있고, 로그인 유지도 가능하다. 

 

 


3. JWT의 구성

JWT토큰은 3개의 부분으로 구성된다.

실제로는

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

이렇게 생겼다. 각 부분을 디코딩해보면 JSON형태로 나온다.

 

 

헤더 (header)

헤더는 두가지 정보를 갖는다.

typ: 토큰의 타입을 지정. 여기는 JWT가 고정으로 들어간다. 여기가 JWT여야지만 JWT기 때문에

alg: 어떤 해싱 알고리즘을 사용할지 지정한다. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용된다.

 

{ 
  "typ": "JWT", 
  "alg": "HS256" 
}

 

 

내용 (payload)

이 부분에는 토큰에 담을 정보가 들어있다. 이 정보의 한 조각을 "클레임(Claim)"이라고 부르고 key : value의 한 쌍으로 이루어져 있다. 클레임의 종류는 3가지로 분류된다.

 

등록된(registered) 클레임

등록된 클레임은 서비스에 필요한 정보가 아니라 토큰에 대한 정보를 담기위해 이름이 이미 정해진 클레임들이다. 등록된 클레임은 Optional하다.

  • iss : 토큰 발급자 (issuer)
  • sub : 토큰 제목 (subject)
  • aud : 토큰 대상자 (audience)
  • exp : 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정돼야한다.
  • nbf : Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념. 여기에도 NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
  • iat : 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
  • jti : JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용된다. 일회용 토큰에 사용하면 유용.

 

공개(public) 클레임

공개 클레임들은 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.

 

{
    "https://velopert.com/jwt_claims/is_admin": true
}

 

비공개(private) 클레임

등록된 클레임도아니고, 공개된 클레임들도 아니다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들이다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의.

 

{
    "username": "velopert"
}

 

Payload의 예시

{
    "iss": "llshl.com",
    "exp": "1485270000000",
    "https://llshl.com/jwt_claims/is_admin": true,
    "userId": "11028373727102",
    "username": "llshl"
}

 

 

서명 (signature)

서명은 헤더의 인코딩값과, 정보의 인코딩값을 합친후 주어진 비밀키로 해쉬를 하여 생성한다.

서버에서 요청에서 토큰을 받으면 헤더와 페이로드의 값을 서버의 비밀키와 함께 돌려서 계산된 결과값이 서명값과 일치하는지 확인한다.

 

 


한 줄 요약

"세션은 서버에서 세션아이디 보관,

JWT는 보관 없이 인증만"

 

 

 

 

참고:

'Web' 카테고리의 다른 글

[Web] REST API  (0) 2021.07.03
[Web] 세션과 쿠키  (0) 2021.07.02

1편에서 이어지는 글이다. 카카오 디벨로퍼스에서의 설정은 끝났으니 코드로 남은 구현을 마무리하자.


1. REST API 키 복사하기

여기서 두번째 저것을 사용해야한다. 복사하자.

 

 

 

2. Callback Url을 받을 Controller (인증 코드 요청)

@GetMapping("/user/kakao/callback")
public String kakaoLogin(String code) {
  // code는 카카오 서버로부터 받은 인가 코드
  log.info("kakaoLogin");
  memberService.kakaoLogin(code);
  return "redirect:/";
}

이 컨트롤러에서 다음 그림의 첫번째 한쌍의 절차를 수행한 것이다.

 

 

 

3. 토큰 요청

service의 kakaoLogin 메소드

public void kakaoLogin(String authorizedCode) {
        log.info("kakaoLogin Service 호출");
        // 카카오 OAuth2 를 통해 카카오 사용자 정보 조회
        KakaoUserInfo userInfo = kakaoOAuth2.getUserInfo(authorizedCode);
        System.out.println("userInfo.getNickname() = " + userInfo.getNickname());

        //카카오에서 받아온 사용자의 정보
        Long kakaoId = userInfo.getId();
        String nickname = userInfo.getNickname();
        String email = userInfo.getEmail();

        //카카오 로그인을 통해 이미 회원가입한 회원인지 확인하기 위해 카카오ID를 통해 검색
        MemberVo kakaoMember = memberMapper.findByKakaoId(kakaoId)
                .orElse(null);

        //중복된 사용자가 없다면(처음으로 카카오 로그인을 하는 경우 카카오에서 받은 정보를 통한 회원가입 진행)
        if(kakaoMember == null) {
            MemberVo sameEmailMember = memberMapper.findByEmail(email).orElse(null);
            if(sameEmailMember != null){
                //카카오로그인은 처음인데 이미 그냥 회원가입은 돼있는경우
                kakaoMember = sameEmailMember;
                kakaoMember.setKakao_id(kakaoId);    //이미 저장돼있는 회원 정보에 카카오 ID만 추가해서 다시 저장한다.
                memberMapper.updateMember(kakaoMember);     //기존 회원에 카카오 아이디만 추가해준다
            }
            else{
                String userName = nickname;
                String password = kakaoId + ADMIN_TOKEN;
                String encodedPassword = passwordEncoder.encode(password);
                kakaoMember = MemberVo.builder()
                        .kakao_id(kakaoId)
                        .login_id(email)    //카카오 로그인의 경우에는 login id가 없기에 이메일을 넣어줌
                        .nickname(nickname)
                        .name(userName)
                        .email(email)
                        .login_password(encodedPassword)
                        .build();
                memberMapper.save(kakaoMember); //회원가입

                int idByLoginId = memberMapper.findIdByLoginId(email);
                kakaoMember.setMember_id(idByLoginId);
            }
        }

        // 로그인 처리
        // 스프링 시큐리티 통해 인증된 사용자로 등록
        MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
        Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

 

 

kakaoOAuth2 메소드

@Component
public class KakaoOAuth2 {

    public KakaoUserInfo getUserInfo(String authorizedCode) {
        System.out.println("getUserInfo 호출");
        // 인가코드 -> 액세스 토큰
        String accessToken = getAccessToken(authorizedCode);
        // 액세스 토큰 -> 카카오 사용자 정보
        KakaoUserInfo userInfo = getUserInfoByToken(accessToken);

        return userInfo;
    }

    private String getAccessToken(String authorizedCode) {
        System.out.println("getAccessToken 호출");
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpBody 오브젝트 생성
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", "여기에 REST API 키");   
        params.add("redirect_uri", "http://localhost:8080/user/kakao/callback");
        params.add("code", authorizedCode);

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(params, headers);

        // Http 요청하기, Post방식으로, 그리고 response 변수의 응답 받음
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // JSON -> 액세스 토큰 파싱
        String tokenJson = response.getBody();
        JSONObject rjson = new JSONObject(tokenJson);
        String accessToken = rjson.getString("access_token");   //우리가 필요한건 accessToken

        return accessToken;
    }

    //토큰을 통해 사용자 정보 가져오기
    private KakaoUserInfo getUserInfoByToken(String accessToken) {
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest = new HttpEntity<>(headers);

        // Http 요청하기 - Post방식으로 - 그리고 response 변수의 응답 받음.
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoProfileRequest,
                String.class
        );

        JSONObject body = new JSONObject(response.getBody());
        Long id = body.getLong("id");
        String email = body.getJSONObject("kakao_account").getString("email");
        String nickname = body.getJSONObject("properties").getString("nickname");

        //가져온 사용자 정보를 객체로 만들어서 반환
        return new KakaoUserInfo(id, email, nickname);
    }
}

 

 

 

kakaoLogin 메소드의 마지막 부분인

SecurityContextHolder.getContext().setAuthentication(authentication);

을 통해서 로그인 처리가 된다.

 

*SecurityContextHolder란 Spring Security의 인메모리 세션저장소

 

 

 

4. 로그인 페이지

<button id="login-kakao-btn" onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id=[여기에RESTAPI키]&redirect_uri=http://localhost:8080/user/kakao/callback&response_type=code'">
        카카오로 로그인하기
</button>

 

 

 

5. 현재 사용자의 정보 가져오기

지금까지 OAuth2를 사용하지 않고 로그인을 구현할 때는 

session.setAttribute("loginMemberId",loginDto.getUserID());

이런식으로 세션에 직접 무언가를 set 해줬는데 스프링 시큐리티를 사용할때는

 

 

MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);

이렇게  MemberDetails로 꺼내 사용자의 session 생성하고 이를 SecurityContextHolder에 저장한다.

이것을 가져올때는

 

 

@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal MemberDetailsImpl memberDetails){
    model.addAttribute("username", memberDetails.getUsername());
    System.out.println("member_id: "+memberDetails.getMemberVo().getMember_id());
    return "index";
}

@AuthenticationPrincipal 어노테이션을 통해 SecurityContextHolder에 저장된 현재 사용자 정보를 가져올 수 있다. 위 코드의 출력 결과는 사용자의 member_id가 된다. 이것을 사용하여 여러 쿼리문의 where에 사용할 수 있겠지? 

 

 

사실 스프링시큐리티와 OAuth2에 대한 정보가 많이 부족하기에 혹시라도 지나가시던 고수님께서 보시고 틀린점이나 추가할 점을 발견하신다면 한소리 부탁드립니다..

 

 

 

 

참고:

지난 네이버 아이디로 로그인하기에 이어서 다음 소셜 로그인으로 카카오를 선택했다.

선택했다기보단 지금 모 코딩 교육 프로그램에서 한번 배웠기에 간단하게라도 복기하는 느낌으로 정리하는거다.

 


1. 소셜 로그인?

우리가 사용하는 웹 사이트들마다 전부 회원가입을 하는것은 사용자에게 너무 부담이 된다. 귀찮으니까. 또한 대부분 지키지 않겠지만 웹 사이트마다 다른 아이디와 비밀번호를 사용해야 보안적으로도 좋기에 각각 어떤 아이디였는지 외우기도 힘들다. 웹사이트를 운영하는 측면에서도 개인정보를 지켜야하는 부담이 있다.

따라서 OAuth를 사용한 소셜로그인이 등장했다.

 

 

OAuth란,

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜이다.

대표적으로 구글, 페이스북, 네이버, 카카오에서 서비스를 제공해주고 있다.

 

 

카카오 로그인의 큰 흐름은 다음과 같다.

 

 

 

 


2. 카카오 로그인 설정하기

1. 카카오 디벨로퍼스에 프로젝트 등록하기

https://developers.kakao.com/console/app

 

카카오계정 로그인

여기를 눌러 링크를 확인하세요.

accounts.kakao.com

 

애플리케이션 추가하기를 눌러서 적으라는것을 다 적는다! 등록된 애플리케이션 ID는 정확히 어디에 사용되는 것인지 모르기에 일단은 모자이크 처리를 하여서 캡쳐했다.

 

 

 

2. 사이트 도메인 입력

좌측 메뉴에서 플랫폼에 들어간 후 Web 플랫폼을 등록해준다.

 

 

 

3. Callback Url 설정하기

등록하러 가기를 눌러서 카카오 로그인이 성공하면 redirect 될 url을 설정해준다.

나는 이렇게 설정했고 controller에서 저 url을 처리해 줄 예정이다.

 

 

 

4. 동의항목 설정

카카오로부터 받을 사용자의 정보를 선택해야한다.

닉네임과 이메일정도만 받아보자.

 

 

다음부턴 코드로 구현해야한다. 다음 포스팅에 이어 작성하겠다.

 

 

 

 

참고:

 

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.


연관관계 매핑 시 고려할 것 3가지

  • 다중성
  • 단/양방향
  • 연관관계의 주인

 

 

다중성

N:1 , 1:N , 1:1 , N:N의 4가지 다중성이 있다.

보통은 다대일과 일대다를 많이 사용하고 다대다는 실무에서 거의 사용하지 않는다고 한다.

 

단방향/양방향

테이블에서는 외래 키 하나로 조인을 통해서 테이블간 양방향 참조가 가능하기 때문에 사실상 방향성이 없다. 하지만 객체에서는 참조용 필드를 통해 단방향으로만 접근할 수 있다. 단방향으로 서로 참조하고있으면 이를 양방향이라고 부른다. 

 

연관관계의 주인

테이블에서는 외래 키 하나로 두 테이블의 연관관계를 맺는다. 즉, 테이블의 연관관계를 관리하는 포인트는 외래 키 하나이다. 하지만 객체의 양방향 관계는 A->B , B->A 처럼 2개의 참조가 필요하다. JPA는 두 객체 연관관계 중 하나를 정해서 데이터베이스의 연관관계를 관리하는데 이를 연관관계의 주인이라고 한다. 

외래 키를 가진 쪽이 연관관계의 주인이고 외래키는 다(N)쪽에 존재한다.

 

 

 


다대일(N:1)

다대일 단방향
다대일 양방향

가장 많이 사용하는 관계이다. 외래 키는 항상 다(N)쪽에 있다. 따라서 객체 양방향 관계에서 연관관계의 주인은 항상 다(N)쪽이다. 여기서는 Member의 team이 연관관계의 주인이다.

또한 양방향 연관관계에서는 항상 서로를 참조하고 있어야한다. 이를 위해서는 이전 포스팅의 순수객체 상태 관련 메소드를 사용하면 좋다.

 

 

 


일대다(1:N)

일대다 단방향

일대다 단방향은 일(1)이 연관관계의 주인이다.

객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조이다. 꼭 @JoinColunm을 사용해야한다. 사용하지 않으면 중간에 매핑테이블이 자동으로 생성돼버린다.

 

엔티티가 관리하는 외래 키가 다른 테이블에 있기에 추가로 update SQL이 실행된다. 일대다 단방향보다는 다대일 양방향을 이용하자.

 

 

일대다 양방향

이런 매핑은 공식적으로 존재하지 않는다.

@JoinColumn(insertable=false, updatable=false) 읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법이다.

 

즉, 그냥 다대일 양방향을 사용하는 것이 좋다.

 

 

 


일대일 (1:1)

일대일 단방향

주 테이블이나 대상 테이블 중 어떤곳에 외래 키를 두어도 된다.

외래 키에 데이터베이스 유니크 제약조건 추가돼야 한다.

 

 

주 테이블에 외래 키

  • 주 객체가 대상 객체를 참조하는 형태로, 객체지향 개발자들이 선호한다.
  • 장점은 주 테이블만 확인해도 대상 테이블과의 연관관계를 알 수 있다.
  • 단점은 값이 없으면 외래 키에 null 허용

 

대상 테이블에 외래 키

  • 전통적인 데이터베이스 개발자들은 대상 테이블에 외래 키를 두는 것을 선호한다.
  • 장점은 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다.
  • 단점은 프록시 기능의 한계로 지연로딩으로 설정해도 항상 즉시로딩이 된다.

 

 

 

일대일 양방향

양방향 매핑이므로 연관관계의 주인을 정해야한다. 이 경우 MEMBER 테이블이 외래 키를 가지고 있으므로 Member 엔티티의 locker가 연관관계의 주인이다.

따라서 반대 매핑인 Locker 엔티티의 member는 mappedBy를 통해 연관관계의 주인이 아니라고 명시해야 한다.

 

 

 

 


다대다 (N:N)

김영한 강사님이 쓰지 말라신다. 쓰지 말자.

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야한다.

 

예를 들어 회원들(N)은 상품들(N)을 주문하고, 상품들(N)도 회원들(N)에게 주문된다고 하면, 이 둘은 다대다 관계다.

고로 회원 테이블과 상품 테이블만으로 이 관계를 표현할 수 없고 아래처럼 중간 테이블을 통해 다대일, 일대다 관계로 풀어내야 한다. (@ManyToMany => @ManyToOne , @OneToMany)

 

 

 

참고:

더보기

'JPA' 카테고리의 다른 글

[JPA] 연관관계 매핑  (0) 2021.07.21
[JPA] 엔티티 매핑  (0) 2021.07.19
[JPA] 영속성 관리  (0) 2021.07.17
[JPA] JPA란?  (0) 2021.07.15

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.


연관관계가 필요한 이유

"객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다"

 

예를 들어보자.

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 N:1 관계다.
@Entity
public class Member {
  @Id @GeneratedValue
  private Long id;
  
  @Column(name = "USERNAME")
  private String name;
  
  @Column(name = "TEAM_ID")
  private Long teamId;
}

@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  
  private String name;
}
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);

라는 상황이 있을때, 특정 회원이 속한 팀을 찾으려면 어떻게 해야할까?

 

 

일반적인 경우는 다음과 같은 방법을 사용한다.

Member findMember = em.find(Member.class, member.getId());

Long findTeamId = findMember.getId();
Team findTeam = em.find(Team.class, findTeamId);

먼저 특정 회원을 조회하고, 그 회원의 팀 ID를 getter로 가져와서

팀 ID로 다시 조회한다. 

 

벌써 두번이나 쿼리가 발생했다. 이런것을 보고 객체지향스럽지 못하다고 한다.

즉, 객체를 테이블에 맞춰서 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.

  • 테이블은 외래키를 사용한 조인을 통해 연관된 테이블을 찾는다.
  • 객체는 참조를 통해서 연관된 객체를 찾는다.

이것이 객체와 테이블이 가지는 가장 큰 차이점.

 

 

 


단방향 연관관계

객체지향 모델링

위에서 작성한 Member 엔티티를 조금 수정해보자.

@Entity
public class Member {

  @Id @GeneratedValue
  private Long id;

  @Column(name = "USERNAME") 
  private String name;

  // @Column(name = "TEAM_ID")
  // private Long teamId;

  //Team에 대한 참조를 넣음
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  
}

 

 

Team을 참조하는 Long형 teamId 대신 Team에 대한 참조값 team을 넣어서 매핑을 한다면 어떻게 될까?

이렇게 모델링을 한다면 위에서 예시로 들었던 특정 멤버의 팀을 조회하는 코드가 한결 간결해지고 쿼리도 줄어든다.

 

 

 

//조회
Member findMember = em.find(Member.class, member.getId());

//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

해당 회원의 팀 ID를 조회하고 그것으로 다시 팀을 조회하는것이 아닌,

한번에 해당 회원의 팀을 조회할 수 있다. team 참조가 있기 때문에.

 

 

 

하지만 Team에서는 어떤 Member가 있는지 조회할 수 없다. 단방향 연관관계이기 때문이다.

  • 객체는 team이라는 참조를 통해 단방향으로 연관관계를 가진다. Member에서는 team을 통해 팀에 대한 정보를 알 수 있지만 Team에서는 Member를 알 수 있는 방법이 없기에 단방향 연관관계다.
  • 테이블에서는 TEAM_ID라는 외래키를 통해 연관관계를 가진다. 이는 Team에서도 Member를 조회할 수 있기에 양방향 관계라고 할 수 있다.

 

즉, 정리하자면

  • 객체는 참조를 통해 연관관계 가진다.
  • 객체의 연관관계는 단방향이다.
  • 테이블은 외래키를 통해 연관관계 가진다.
  • 테이블의 연관관계는 양방향이다.

 

 

 


양방향 연관관계

 

팀 1개에 N명의 멤버가 있을 수 있으므로 Team 엔티티에 리스트형으로 member의 참조값을 넣었다.

@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  
  private String name;
  
  @OneToMany(mappedBy = "team")
  List<Member> members = new ArrayList<Member>();

 

 

이를 통해 객체에서도 Team -> Member로의 조회가 가능하다.

//조회
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); //역방향 조회

 

 

 


연관관계의 주인

  • 양방향 연관관계에서는 주인이 필요하다.
  • 테이블의 연관관계는 외래키 하나로 양쪽을 다 조회할 수 있지만,
  • 객체에서는 양쪽에서 서로를 참조하는 값이 있어야 서로간의 조회가 가능하다. 즉 까보면 단방향 연관관계 두개로 인해 양방향처럼 보이는 것이다.
class A {
	B b;
}
class B {
	A a;
}

이런 윈리지만 우리는 이를 양방향 연관관계라고 부른다.

 

 

  • 엔티티를 양방향으로 연관관계를 가지게 하기 위해 참조가 2개 필요한데,
  • 데이터베이스에서는 외래키는 1개 필요하다.
  • 이 차이로인해 JPA에서는 객체 연관관계 중 하나를 주인으로 삼고 이 외래키를 보관하도록 한다.
  • 연관관계의 주인만이 데이터베이스의 연관관계와 매핑되고 외래키를 관리할 수 있다. 주인이 아닌쪽은 읽기만 간으하다.

 

 

 


누구를 주인으로?

외래키가 있는 곳을 주인으로.

외래키는 N이 있는 곳.

(이 예시에서는 Member쪽이다)

 

 

양방향 매핑 규칙

  • 연관관계의 주인만이 외래키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용X
  • 주인이 아닌쪽에서 mappedBy 속성을 통해 주인 지정

 

 

주의할 점

연관관계의 주인에 값을 입력하지 않음

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

 

 

순수한 객체 관계를 고려하면 연관관계 주인과 반대편 양쪽 다 값을 입력 해야한다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");
team.getMembers().add(member);  //반대편인 여기랑

//연관관계의 주인에 값 설정
member.setTeam(team);  //주인인 여기 양쪽 모두 값 넣어야함
em.persist(member);

DB에 올라가지 않고 1차 캐시에서 엔티티를 다시 바로 가져오는 경우를 순수한 객체상태라고 하는데 이때 연관관계의 주인쪽에만 값을 입력해주면 다른 한쪽엔 반영이 안된다. 1차 캐시를 없애고 조회하는 순간부터 반대편에서도 참조가 가능한 문제가 생기기에 영속성 컨텍스트로 올릴때 부터 양쪽에 값을 넣어주자.

 

 

이때 한가지 팁이 있다면,

연관관계 주인(Member)쪽 엔티티에서 setter 메소드를

public void setTeam(Team team){
    this.team = team;
    team.getMembers.add(this);
}

이처럼 수정하여 주인쪽은 자신의 인스턴스를 한번에 넣어주도록 수정하면 된다.

이때 setter 메소드는 관례적인 setter와 다르므로 메소드 이름을 changeTeam과 같이 이례적으로 바꾸면 더 좋다.

 

 

 

 

 

참고:

더보기

'JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계  (2) 2021.07.24
[JPA] 엔티티 매핑  (0) 2021.07.19
[JPA] 영속성 관리  (0) 2021.07.17
[JPA] JPA란?  (0) 2021.07.15

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.


@Entity

  • @Entity 어노테이션이 붙은 클래스는 JPA가 관리한다. 이를 엔티티라고 한다.
  • 이 엔티티와 데이터베이스의 테이블이 매핑된다.
  • 엔티티 클래스에는 기본 생성자가 있어야한다.
  • final 클래스, enum, interface, inner 클래스는 안된다.
  • 저장할 필드에 final키워드는 안된다.
  • name 속성을 사용하여 엔티티 이름을 커스터마이징 할 수 있다. (중복되는 이름의 클래스가 없다면 기본값 권장)

 

 

 


데이터베이스 스키마 자동 생성

  • create: 기본 테이블 삭제 후 다시 생성(DROP + CREATE)
  • create-drop: create와 같으나 애플리케이션 종료 시점에 DROP
  • update: 변경사항만 반영(운영중인 DB에는 사용하면 안됨)
  • validate: 엔티티와 테이블이 정상적으로 매핑됐는지만 확인
  • none: 사용x

개발 초기에는 create나 update를,

테스트 서버에는 update나 validate를,

운영 서버에는 validate나 none을 권장한다.

 

 

 


필드와 컬럼 매핑

import javax.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "name")
    private String username;
    
    private Integer age;
    
    @Enumerated(EnumType.STRING)
    private RoleType roleType;
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedDate;
    
    @Lob
    private String description;
}

 

  • @Colunm: 해당 필드와 명시한 컬럼 매핑
  • @Temporal: 날짜 타입 매핑
  • @Enumerated: enum타입 매핑
  • @Lob: 대용량 매핑, CLOB, BLOB이 있다.
  • @Transient: 해당 필드는 매핑하지 않겠다.

 

여기서 Enumerated 매핑 사용시 EnumType.ORDINAL 옵션은 사용하지 않도록하자

이는 enum 순서를 데이터베이스에 저장하는 것으로 의미 자체도 모호하고 나중에 찾기 힘든 버그를 야기한다.

 

EnumType.STRING으로 하자. 이는 enum의 이름 그 자체를 데이터베이스에 저장한다.

 

 

 


기본키 매핑

직접할당과 자동생성이 있다.

직접 할당시 @Id를 통해 지정할 수 있다.

@Id
private Long id;

 

 

자동할당(@GenerateValue)는 4가지 종류가 있다.

  • IDENTITY: 데이터베이스에 위임, MYSQL
  • SEQUENCE: 데이터베이스 시퀀스 오브젝트 사용, ORACLE
  • TABLE: 키 생성용 테이블 사용, 모든 DB에서 사용
  • AUTO: 방언에 따라 자동 지정, 기본값
@GeneratedValue(strategy = GenerationType.[여기에전략])
private Long id;

 

 

IDENTITY 전략

  • 기본키 생성을 DB에 위임 (ex. MySQL의 AUTO_INCREMENT)

 

영속성 컨텍스트에 persist할때, 우리는 Id와 Value를 알고있어야 한다.

 

지난 포스팅의 사진

하지만 DB에서 알아서 기본키를 생성하는데 코드를 짜는 우리가 이를 알 수는 없다. 그럼 어떻게 하느냐?

IDENTITY전략을 사용하면 트랜잭션 커밋 시점이 아닌 persist시점에 INSERT SQL이 실행된다.

그렇게해서 DB에서 식별자를 바로 조회할 수 있다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

 

 

 

SEQUENCE 전략

  • 데이터베이스의 시퀀스 오브젝트 사용
  • 이는 유한한 값을 순서대로 생성하는 특별한 데이터베이스 오브젝트이다.
  • 오라클, PostgreSQL, DB2, H2에서 사용

이 전략 또한 DB에서 기본키 값을 부여해 주는 방식이므로 우리는 다음 기본키로 어떤 값이 올지 모른다.

즉, 이 전략도 영속성 컨텍스트에 영속화 시키기 전에 Id값을 미리 조회하는 추가적인 절차가 필요한 것이다.

 

SEQUENCE 전략에서는 persist하는 순간

이와 같이 DB로부터 다음 기본키 값을 받아온다.

 

 

 

 

그런데, 이렇게 INSERT 쿼리를 날리기 위해 추가적인 DB와의 통신이 늘어날수록 성능이 떨어지게 된다. 당연히도.

그렇다면 어떻게 개선할 수 있을까?

 

 

 

SeqenceGeneratorallocationSize 옵션을 통해 개선이 가능하다.

이 옵션은 DB와 한번 통신 할때마다 시퀀스를 한번에 가져와놓을 수량을 지정할 수 있다. 예를들어 50이라고 해두면 50번에 한번씩만 시퀀스를 호출하는 작업을 해주면 된다.

 

@Entity
@SequenceGenerator(
    name = “MEMBER_SEQ_GENERATOR",
    sequenceName = “MEMBER_SEQ", //매핑할 데이터베이스 시퀀스 이름
    initialValue = 1, allocationSize = 50)	//한번에 가져올 시퀀스 값 수량
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
	private Long id;

 

 

 

 

TABLE 전략

  • 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략.
  • 장점은 모든 데이터베이스에 적용 가능하다는 점.
  • 단점은 성능이 다소 떨어진다는 점.

 

create table MY_SEQUENCES (
  sequence_name varchar(255) not null,
  next_val bigint,
  primary key ( sequence_name )
)
@Entity
@SequenceGenerator(
    name = “MEMBER_SEQ_GENERATOR",
    sequenceName = “MY_SEQUENCE", //직접 만든 시퀀스 테이블을 매핑
    pkColunmValue = "MEMBER_SEQ",
    allocationSize = 50)	//한번에 가져올 시퀀스 값 수량
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "MEMBER_SEQ_GENERATOR")
	private Long id;

 

 

 

 


권장하는 식별자 전략

  • 기본키 제약조건: null이 아니며 변하면 안된다.
  • 이 조건을 미래까지 쭉 만족하는 자연키는 찾기 어렵다. 대리키(대체키)를 사용하자.
  • 권장 형태: Long형 + 대체키 + 키 생성전략 사용

*대체키란,

후보키가 두개 이상일 경우 그 중에서 어느 하나를 기본키로 지정하고 남은 후보키들을 대체키라한다.

 

*후보키란,

 테이블에서 각 행을 유일하게 식별할 수 있는 최소한의 속성들의 집합다.

 

 

 

 

 

 

참고:

더보기

'JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계  (2) 2021.07.24
[JPA] 연관관계 매핑  (0) 2021.07.21
[JPA] 영속성 관리  (0) 2021.07.17
[JPA] JPA란?  (0) 2021.07.15

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.

 


영속성 컨텍스트란?

"엔티티를 영구 저장하는 환경"

 

  • 엄밀히 말하자면 DB에 저장하는 것이 아니라 엔티티를 영속성 컨텍스트라는 곳에 저장한다는 의미
  • 눈에 보이지 않는 논리적인 개념이다.
  • 엔티티 매니저를 통해 접근한다.
  • 엔티티 매니저와 영속성 컨텍스트가 1대 1로 매핑된다

 


엔티티의 생명주기

  • 비영속: 영속성 컨텍스트와 관련 없는 새로운 상태
  • 영속: 영속성 컨텍스트에 관리되는 상태
  • 준영속: 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제: 삭제된 상태

 

 

비영속 상태

영속성 컨텍스트와 엔티티가 아무런 관련이 없음. 그저 엔티티가 새로 생성되어있는 상태일 뿐.

Member member = new Member();
member.setId("member1");
member.setUsername("Lee");

그냥 member객체를 생성했을 뿐 아무런 다른 과정이 없다.

 

 

영속 상태

생성한 엔티티를 영속성 컨텍스트에 넣음. 

Member member = new Member();
member.setId("member1");
member.setUsername(“회원1”);

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

//객체를 저장한 상태(영속)
em.persist(member);

 

 

준영속, 삭제 상태

준영속 상태는 영속성 컨텍스트에 있다가 detach된 상태

삭제는 삭제 상태

//회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
em.detach(member);

//객체를 삭제한 상태(삭제)
em.remove(member);

 

 

 


영속성 컨텍스트의 장점

  • 1차 캐시
  • 동일성 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

 

1차 캐시

  • find나 persist시 1차 캐시에 저장된다.
  • 영속성 컨텍스트 안에 1차 캐시 공간이 있다. PK가 Key가 되고 엔티티 그 자체가 Value가 된다.
  • JPA를 통한 조회 작업시 바로 DB에 SELECT 쿼리를 날리는 것이 아닌 1차 캐시부터 조회한다. 만약 1차 캐시가 있다면 (찾고자 하는 엔티티가 영속화 돼있다면) 쿼리문을 날리지 않고도 조회가 가능하고 캐시에 없으면 DB를 조회한다.
  • 만약 DB에서 조회를 한다면 그 엔티티는 1차 캐시에 저장된다. 
  • 요청 시작부터 트랜잭션 종료 시까지만 1차 캐시가 사용된다. 매우 짧은 순간이기에 정교한 구조가 아니면 성능상의 큰 이점을 얻기는 힘들다.
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

//1차 캐시에 저장됨
em.persist(member);

//1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");
  • member1은 DB에서 조회한 것이 아닌 영속성 컨텍스트의 1차캐시에서 조회한 엔티티이다. 
  • 사실 persist만으로 DB에 저장이 되진 않는다. persist가 완료되면 영속성 컨텍스트에 저장되는 것이지 트랜잭션이 수행되어야 DB에 저장된다.

 

 

만약 1차 캐시에 없는 엔티티를 조회하려고 하면 DB에서 조회 후 1차 캐시에 영속화 한다.

 

 

 

동일성 보장

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");

System.out.println(a == b); //동일성 비교 true

같은 트랜잭션 안에서의 같은 엔티티를 조회하면 동일한 엔티티임을 보장할 수 있다.

 

 

 

 

트랜잭션을 지원하는 쓰기 지연

em.persist(memberA);
em.persist(memberB);  //여기까지는 DB에 보내지 않고

transaction.commit()  //이 순간에 한번에 SQL이 전송됨

  • memberA와 memberB에 대한 영속화 요청이 들어오면 JPA는 이 엔티티를 분석하여 적절한 SQL문을 생성한다.
  • 단, 바로 DB에 SQL을 전송하는 것이 아닌 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소 라는 곳에 차곡차곡 모아둔다.
  • 그리고 트랜잭션 Commit의 순간 쌓여있던 SQL들이 한번에 요청된다.

 

 

변경 감지(더티체크)

find로 가져와서 setter로 그냥 객체 수정 그냥 하면 JPA가 수정을 감지하고 자동 반영해준다. 즉, 다시 persist 안해도 됨.

 

  • Commit 시점에 수정된 엔티티와 그것의 스냅샷(초기 상태를 가지고 있는 일종의 복사본)을 비교하여 바뀐부분이 있는지 확인하고, 이에대한 SQL문을 SQL저장소에 저장한다. 그리고 update 쿼리를 생성하여 전송한다.
  • 엔티티의 삭제도 이런 원리로 작동한다.

 

 

 


플러시란?

  • 영속성 컨텍스트의 변경내용을 DB에 반영하는 것
  • 모아놓은 SQL들을 한번에 실행(트랜잭션과 다름)
  • 영속성 컨텍스트를 비우지 않음
  • 즉, 영속성 컨텍스트와 DB와의 동기화
  • 플러시를 해도 1차캐시는 유지됨

 

영속성 컨텍스트를 플러시 하는 방법

  1. em.flush() - 직접호출
  2. 트랜잭션 커밋 - 자동호출
  3. JPQL 쿼리 실행 - 자동호출

 

JPQL 쿼리 실행시 플러시가 자동으로 실행된다.

em.persist(memberA);
em.persist(memberB);
em.persist(memberC);

//중간에 JPQL 실행
query = em.createQuery("select m from Member m", Member.class);
List<Member> members= query.getResultList();

왜 자동으로 플러시가 실행되지?

 

persist만으로는 DB까지 반영이 되지 않고 영속성 컨텍스트라는 논리적 공간에만 저장되기에 Commit이 되기 전에 JPQL 쿼리가 요청되면 이에 대한 응답을 해야하기 때문에 현재 영속성 컨텍스트에 있는 것들을 일단 전부 플러시 하는 것. 그래야만 JPQL 쿼리에 대한 응답이 보장된다.

 

 

 


준영속 상태

  • 영속상태였던 엔티티가 영속성 컨텍스트에서 분리된 상태(detached)
  • 영속성 컨텍스트가 제공하는 기능을 사용 못함

 

준영속 상태로 만드는 법

  1. em.detach(entity) - 특정 엔티티만 준영속 상태로 전환
  2. em.clear() - 영속성 컨텍스트를 완전히 초기화
  3. em.close() - 영속성 컨텍스트를 종료

 

 

참고:

'JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계  (2) 2021.07.24
[JPA] 연관관계 매핑  (0) 2021.07.21
[JPA] 엔티티 매핑  (0) 2021.07.19
[JPA] JPA란?  (0) 2021.07.15

+ Recent posts