주노 님의 블로그

20240909 본캠프 41일차 TIL 본문

TIL

20240909 본캠프 41일차 TIL

juno0432 2024. 9. 9. 21:59

본캠프 41일차 내용 간단요약

  • 09:00 ~ 10:00 : 코드카타
  • 10:00 ~ 10:30 : 발제
  • 10:30 ~ 12:00 : 팀회의
  • 12:00 ~ 13:00 : 점심시간
  • 13:00 ~ 14:30 : 강의
    1-1 ~ 1-2강
  • 14:30 ~ 17:00 :  매니저님 인터뷰 및 튜터님 인터뷰
  • 17:00 ~ 17:40 : 강의
    1-3강
  • 17:40 ~ 18:00 : 튜터님 
  • 18:00 ~ 19:00 : 저녁시간
  • 19:00 ~ 21:00 : 강의
    1-4 ~ 1-6강

오늘 해야할 일 ✔️ 🔺 ❌


강의

더보기

모든 웹사이트에서 회원가입을 수행하는게 사용자에게는 부담이될것이다

귀찮으니까..

 

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

 

  • OAuth
    사용자들이 비밀번호를 제공하지않고, 인증 인가를 받으며, 접근권한을 받을 수 있는 표준이다
    애플리케이션에게 모든 권한을 넘기지 않고 서비스를 이용할수있는 HTTP 기반의 보안 프로토콜이다.
    예로는 구글, 페이스북, 네이버, 카카오가 있다

  • 카카오 로그인 사용해보기
    https://developers.kakao.com/console/app
    접속하기
    애플리케이션  추가하기

    내용 채워넣기

    앱설정 > 플랫폼


    본인이 테스트할 계정이나 배포하는 도메인을 적기

    앱설정 > 비즈니스

    개인 개발자 비즈 앱(동의를 해야지 이메일 동의가 가능함)


    제품설정 > 카카오 로그인
    redirect URI등록


    활성화 ON

    제품설정 > 동의항목

    닉네임
    이메일 선택동의

    이렇게 작성하면 카카오 회원가입 로그인을 서버에서 사용할 준비가 되었다

  • 카카오 사용자 정보 가져오기

    사용자가 카카오 로그인을 선택한다면
    서버는 인증코드 요청을 하고 서버는 인증코드를 전달한다
    인증코드로 추가적인 토큰을 요청하면 서버는 토큰을전달한다
    토큰으로 api 요청을 하면 서버는 토큰의 유효성을 확인하고 응답을 전달한다.

  • 기존에 만든 HTML에 아래의 내용을 추가해준다
    https://kauth.kakao.com/oauth/authorize?client_id=본인의 REST API키&redirect_uri=http://localhost:8080/api/user/kakao/callback&response_type=code


    앱 설정 > 앱키 > REST API키를 넣어주자

    순서
    1. USER LOGIN 기능을 구현해야하기때문에 CONTROLLER의 GETMAPPING으로 코드를 구현하자
    2. kakaoservice에서 카카오 로그인을 구현하며,  jwt토큰을 controller에 전달해준다
    3. rest template가 원래는 rest template builder로 생성하고 있었는데. bean 수동등록을 해서 관리해본다.
    package com.sparta.myselectshop.config;
    
    import org.springframework.boot.web.client.RestTemplateBuilder;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.client.RestTemplate;
    
    import java.time.Duration;
    
    @Configuration
    public class RestTemplateConfig  {
        @Bean
        public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
            return restTemplateBuilder
                    // RestTemplate 으로 외부 API 호출 시 일정 시간이 지나도 응답이 없을 때
                    // 무한 대기 상태 방지를 위해 강제 종료 설정
                    .setConnectTimeout(Duration.ofSeconds(5)) // 5초
                    .setReadTimeout(Duration.ofSeconds(5)) // 5초
                    .build();
        }
    }

    rest template는 추가 옵션을 설정하는 경우가 많아서 수동으로 bean을 등록한다.
    위 설정은 외부 api를 호출하였을때 응답이 장시간 없을경우 무한대기에 걸릴 수 있다.
    외부 api가 5초안에 응답을 내려주지 않는다면, 요청을 끊어버리는것이다.
        public String kakaoLogin(String code) throws JsonProcessingException {
            // 4. "인가 코드"로 "액세스 토큰" 요청
            String accessToken = getToken(code);
    
            // 5. 액세스 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
            KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);
    
            return null;
        }


    4. 인가 코드로 엑세스 토큰을 요청
    service에서 요청 url을 만든다음, http body에 secret key와 redirect_url과 인증코드를 같이 넣어서 전송해주며
    http 요청을 보낸다
    받은 응답을 json으로 변환한다음, 액세스토큰만 파싱해온다.
            JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
            Long id = jsonNode.get("id").asLong();
            String nickname = jsonNode.get("properties")
                .get("nickname").asText();
            String email = jsonNode.get("kakao_account")
                .get("email").asText();


    5. 액세스 토큰으로 사용자 정보 요청
    받은 access토큰을 사용하여 특정 url에
    http헤더에 bearer 토큰에다가 access 토큰을 넣어준다
    response.getBody()에 받은 응답을 넣는다
    id와 nickname, email을 가져온다.
    {
      "id": 1632335751,
      "properties": {
        "nickname": "르탄이",
        "profile_image": "http://k.kakaocdn.net/...jpg",
        "thumbnail_image": "http://k.kakaocdn.net/...jpg"
      },
      "kakao_account": {
        "profile_needs_agreement": false,
        "profile": {
          "nickname": "르탄이",
          "thumbnail_image_url": "http://k.kakaocdn.net/...jpg",
          "profile_image_url": "http://k.kakaocdn.net/...jpg"
        },
        "has_email": true,
        "email_needs_agreement": false,
        "is_email_valid": true,
        "is_email_verified": true,
        "email": "letan@sparta.com"
      }
    }

    id값은 바로 뽑아올수 있지만
    nickname은 properties에
    email은 kakao_account에 있어서 위처럼 요청을 해야한다.

  •  사용자 정보로 회원가입 구현해보기
    user 테이블은 id, username, password, email, role가 있다
    하지만 카카오에서 받아온 데이터는 kakaoid, nickname, emaile이 있다.

    어떻게 결합하나?
    1. kakao를 위한 user 테이블을 하나 더 만든다

    그렇게 하면 어떤 장점이 생기는가?
    결합도가 낮아지는 장점이 있다.

    어떤 단점이 있는가?
    관심상품 등록시, 회원별로 다른 테이블들을 참조해야해서 구현 난이도가 올라간다

    2. 기존 회원 user 테이블에 카카오 user을 추가하자

    그렇게 하면 어떤 장점이 생기는가?
    구현이 단순해진다

    어떤 단점이 있는가?
    결합도가 높아진다 : 폼 로그인을통해 카카오 사용자의 로그인을한다면? > 아이디와 패스워드를 알아야한다
    패스워드가 있는가?

    user 테이블로 구현해보자

    패스워드는 왜 UUID인가? >> 폼 로그인을 통해 로그인이 되지 않도록 한다.

    구현
    1. User 엔티티에 kakaoId추가
    2. service에서 로그인 로직을 추가한다
    가. 카카오 id가 있으면 로그인, 카카오 아이디가 없으면 회원가입을 진행한다.
    나. 카카오 이메일과 db에 이메일이 존재한다면 해당 id에 카카오 id를 추가한다
    다. 카카오 ID가 없고, 이메일도 매치되지 않는다면, 회원가입을 진행한다
    라. 회원가입을 진행할때 password는 uuid로 만들고, passwordEncoder로 인코딩을 해준다

단위 테스트

  • 버그 발견이 늦어짐에따라 비용이 많이 증가한다!
    개발자는 빨리 버그를 처리할수록 비용을 아낄수있다.
    개발자는 단위 테스트를 작성하여 프로그램을 테스트 할수있다.
    작은 단위를 쪼개서 각 단위가 정확하게 동작하는지 검사하는 테스트 기법이다

  • Before After
        @BeforeEach
        void setUp() {
            System.out.println("각각의 테스트코드가 실행되기 전에 수행");
        }
    
        @AfterEach
        void tearDown() {
            System.out.println("각각의 테스트 코드가 실행된 후에 수행");
        }
    
        @BeforeAll
        static void setUpAll() {
            System.out.println("모든 테스트 코드가 실행되기 전, static 영역에서 실행");
        }
    
        @AfterAll
        static void tearDownAll() {
            System.out.println("모든 테스트 코드가 실행된 후, static 영역에서 실행");
        }
    
        @Test
        void test1() {
            System.out.println("test1() - 테스트 1");
        }
    
        @Test
        void test2() {
            System.out.println("test2() - 테스트스트 2");
        }



    @BeforeEach
    각각의 테스트코드가 실행되기전 수행하는 메서드이다

    @AfterEach
    각각의 테스트 코드가 실행된 후에 수행하는 메서드 이다

    @BeforeAll
    모든 테스트가 실행되기 전 실행되는 메서드이다

    @AfterAll
    모든 테스트가 실행된 후 실행되는 메서드이다

  • 테스트 스타일

    @DisplayName ("테스트의 내용을 네이밍 해줄때")
    >> 메서드 이름에 신경쓰지않아도 된다

    @Nested
    테스트를 그룹화 할 수 있다

    @Order
    메서드의 순서를 정할 수 있다

  • 테스트 반복
        @RepeatedTest(value = 5, name = "반복 테스트 {currentRepetition} / {totalRepetitions}")
        void repeatTest(RepetitionInfo info) {
            System.out.println("테스트 반복 : " + info.getCurrentRepetition() + " / " + info.getTotalRepetitions());
        }
    
        @DisplayName("파라미터 값 활용하여 테스트 하기")
        @ParameterizedTest
        @ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
        void parameterTest(int num) {
            System.out.println("5 * num = " + 5 * num);
        }


    @RepeatedTest(value = 반복횟수, name = "설명")
    테스트를 반복 할 수 있다

    @ParameterizedTest
    전달되는 파라미터 수만큼 반복된다

  • Assertions

    assertEquals(기대값, 검증값) : 검증값이 기대값에 맞는지 확인하는 메서드
    assertNotEquals(기대값, 검증값) : 검증값이 기대값이랑 틀린지 확인하는 메서드
    assertTrue(검증값) :  검증값이 true인지 확인하는 메서드
    assertFalse(검증값) : 검증값이 false인지 확인하는 메서드 
    assertNotNull(검증값) : 검증값이 null이 아닌지 확인하는 메서드
    assertNull(검증값) : 검증값이 null인지 확인하는 메서드
    assertThrows(예외클래스타입, 함수) : 해당 예외클래스 타입이 나오는지 확인하는 메서드

  • given - when - then
    테스트 코드 작성의 대중적인 작성 양식이라고 생각하면 된다.
        @Test
        @DisplayName("계산기 연산 성공 테스트")
        void test1() {
            // given
            int num1 = 5;
            String op = "/";
            int num2 = 2;
    
            // when
            Double result = calculator.operate(num1, op, num2);
    
            // then
            assertNotNull(result);
            assertEquals(2.5, result);
        }


    given
    테스트 하고자 하는 대상의 값들을 미리 선언해놓는다

    when
    테스트하고자하는 메서드를 실행한다

    then
    발생하고자 하는 결과를 assertion으로 예측하고 맞는지 확인한다.
        @Test
        @DisplayName("계산기 연산 실패 테스트 : 연산자가 잘못됐을 경우")
        void test1_2() {
            // given
            int num1 = 5;
            String op = "?";
            int num2 = 2;
    
            // when - then
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5, "?", 2));
            assertEquals("잘못된 연산자입니다.", exception.getMessage());
        }

    실행과 예측이 만약 하나로 되는 경우가 있다.
    그럴경우에는 위 처럼 when과 then을 같이 넣는다.

  • Mokito
    service를 테스트하고 싶다, 하지만 기본 service로직은 repository를 주입받고 있다
    테스트 코드를 작성시에는 어떻게 해야할까?
    진짜 레포지토리와 연동해야하나?

    우리는 service의 메서드의 동작만 체크하면된다.

    그래서 가짜객체를 생성해주는 mock Object를 사용한다
    @ExtendWith(MockitoExtension.class) // @Mock 사용을 위해 설정합니다.
    class ProductServiceTest {
    
        @Mock
        ProductRepository productRepository;
    
        @Mock
        FolderRepository folderRepository;
    
        @Mock
        ProductFolderRepository productFolderRepository;

    @ExtendWith으로 mockito를 사용하기 위한 설정을하고

    @mock 어노테이션으로 repository를 가져온다.
    @Test
        @DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
        void test1() {
            // given
            Long productId = 100L;
            int myprice = ProductService.MIN_MY_PRICE + 3_000_000;
    	
            //희망 가격을 설정할 DTO 객체 생성 및 희망가격 적용
            ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
            requestMyPriceDto.setMyprice(myprice);
    
            //관심상품을 등록할 유저객체 생성
            User user = new User();
            
            //PRODUCT 객체 생성에 필요한 관심상품 등록
            ProductRequestDto requestProductDto = new ProductRequestDto(
                "Apple <b>맥북</b> <b>프로</b> 16형 2021년 <b>M1</b> Max 10코어 실버 (MK1H3KH/A) ",
                "https://shopping-phinf.pstatic.net/main_2941337/29413376619.20220705152340.jpg",
                "https://search.shopping.naver.com/gate.nhn?id=29413376619",
                3515000
            );
            
            //PRODUCT 객체 생성
            Product product = new Product(requestProductDto, user);
            
            //ProductService 객체 생성 및 테스트에 필요한 레파지토리가 주입됨
            ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);
            
            //REPOSITORY에서 PRODUCTID로 상품을 찾았을경우, 해당 상품이 존재한다고 가정을 하고
            //willReturn(Optional.of(product)) Optional.of(product) 이 객체를 반환하도록 설정 
            given(productRepository.findById(productId)).willReturn(Optional.of(product));
    
            // when
            //updateProduct 메서드를 호출하여 희망가격으로 업데이트를함
            ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);
    
            // then
            // update 메서드를 통해 변경한 값과, 희망 가격이 같은지 조회
            assertEquals(myprice, result.getMyprice());
        }

    위는 구현된 단위테스트이다.

통합 테스트

  • 두개 이상의 모듈이 상호 연결된 상태를 테스트 할 수 있음.
    모듈 간에 연결에서 발생하는 에러를 검증 가능함.

  • 단위 테스트와 다른점

    모듈간 상호 검증 불가능
    vs
    모듈간의 연결에서 발생하는 오류를 검증 가능함

    단위 테스트시에는 spring은 동작하지 않아도 됐음
    vs
    통합 테스트 시에는 spring이 동작되어야하며, @SpringBootTest 어노테이션을 부착하면됌
    테스트 수행시 스프링이 동작을 하며 ioc나, di기능을 수행가능함.

 


통합 테스트를 할때의 문제점 - lazyInitializationException

더보기

 

failed to lazily initialize a collection of role: com.sparta.myselectshop.entity.Product.productFolderList: could not initialize proxy - no Session
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.sparta.myselectshop.entity.Product.productFolderList: could not initialize proxy - no Session
	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:634)
	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:217)
	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:613)
	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:136)
	at org.hibernate.collection.spi.PersistentBag.iterator(PersistentBag.java:369)
	at com.sparta.myselectshop.dto.ProductResponseDto.<init>(ProductResponseDto.java:29)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.ArrayList$Itr.forEachRemaining(ArrayList.java:1003)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1845)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
	at org.springframework.data.domain.Chunk.getConvertedContent(Chunk.java:121)
	at org.springframework.data.domain.PageImpl.map(PageImpl.java:86)
	at com.sparta.myselectshop.service.ProductService.getProducts(ProductService.java:74)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:716)
	at com.sparta.myselectshop.service.ProductService$$SpringCGLIB$$0.getProducts(<generated>)
	at com.sparta.myselectshop.service.ProductServiceIntegrationTest.test3(ProductServiceIntegrationTest.java:97)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

 

대충 무수한 오류의 향연이 펼쳐졌다.
대충 키워드는 lazy뭐시기.. 프록시를 초기화 할수 없다는뜻 같았다

알고보니 

Page<ProductResponseDto> productList = productService.getProducts(user,
    0, 10, "id", false);

 

 위 코드에서 생긴 문제였고

영속성 컨텍스트가 종료되어, 지연로딩을 할 수 없다는 문제였다.
 

public Page<ProductResponseDto> getProducts(User user,
                                            int page, int size, String sortBy, boolean isAsc) {
    // 페이징 처리
    Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
    Sort sort = Sort.by(direction, sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);

    // 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
    UserRoleEnum userRoleEnum = user.getRole();

    Page<Product> productList;

    if (userRoleEnum == UserRoleEnum.USER) {
        // 사용자 권한이 USER 일 경우
        productList = productRepository.findAllByUser(user, pageable);
    } else {
        productList = productRepository.findAll(pageable);
    }

    return productList.map(ProductResponseDto::new);
}

 

위 코드를 확인하면, transactional 어노테이션이 적용되어있지 않다.

@OneToMany(mappedBy = "product")
private List<ProductFolder> productFolderList = new ArrayList<>();

 

productFolderList는 one to many로 lazy로딩이 설정되어 있다.

 

일단, all by user은 user를 찾아오며 그와 연관된 객체(폴더리스트)까지 찾아온다,

다만 lazy로딩이라 사용되지 않는 객체는 proxy라는 객체를 가리키게 한다

 

proxy는 실제 클래스를 상속 받아서 만들어지는 가짜 객체이다, 하이버네이트가 자동으로 생성하며, 사용하는 입장에서는 프록시인지 아닌지 모른다, 프록시 객체는 실제 데이터를 저장하지 않지만, 실제 데이터를 저장하는 참조만 보관한다.


레이지 로딩의 경우 프록시 객체가 연관된 참조를 불러올때 같이 불러와지며 실제 객체를 참조하려고 할때 실제 객체를 가져오게 된다

 

현지 productFolderList는 findAllByUser를 사용하였을때 프록시객체가 만들어졌지만, transaction을 사용하지않아 저 메소드를 호출한후 사라져버린다, 그래서 productlist를 참조하는순간 lazyInitializationException이 생기는것,

transaction을 걸어줬다면 메서드 안에서는 일관성을 보장하기때문에 사용할수 있다.

 

 참고자료

https://velog.io/@wonizizi99/%EC%97%90%EB%9F%AC%EB%85%B8%ED%8A%B8-LazyInitializationException-failed-to-lazily-initialize-a-collection-of-role


오늘의 회고 & 12시간 몰입했는가? 

오늘은 반정도 밖에 몰입못한듯..

그리고 강의보다 중간에 막혀서 고생을 좀..

내일 강의 다 보고 후딱후딱.. 과제해야지..

'TIL' 카테고리의 다른 글

20240911 본캠프 43일차 TIL  (2) 2024.09.11
20240910 본캠프 42일차 TIL  (2) 2024.09.11
20240906 본캠프 40일차 TIL  (1) 2024.09.06
20240905 본캠프 39일차 TIL  (0) 2024.09.06
20240904 본캠프 38일차 TIL  (0) 2024.09.06