Day_45 ( 스프링 - 12 )

HD.Y·2024년 1월 2일
0

한화시스템 BEYOND SW

목록 보기
40/58
post-thumbnail

포트원 API를 이용한 카카오페이 결제 기능 구현하기

  • 카카오페이 결제 기능 구현을 위해 포트원 홈페이지에 가입하여 로그인한다.

  • 왼쪽 메뉴창에서 결제 연동을 클릭테스트/실 연동에서 결제대행사 설정 및 추가를 진행한다.

  • 테스르 목적으로 사용할 것이기 때문에, 테스트 / 카카오페이 / 카카오페이를 선택 후 추가를 누른다.

  • pay.html 파일을 하나 생성해서 아래의 코드를 입력한다. 이 코드는 포트원에서 제공하는 결제하기 기능에 대한 JavaScript 코드이다.

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <!-- jQuery -->
      <script type="text/javascript" src="https://code.jquery.com/jquery-1.12.4.min.js" ></script>
      <!-- iamport.payment.js -->
      <script type="text/javascript" src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"></script>
      <script>
          var IMP = window.IMP; 
          IMP.init("[자신의 가맹점 식별코드]"); // ✅ 내 식별코드·API Keys에 있는 자신의 가맹점 식별코드를 입력
        
          var today = new Date();   
          var hours = today.getHours(); // 시
          var minutes = today.getMinutes();  // 분
          var seconds = today.getSeconds();  // 초
          var milliseconds = today.getMilliseconds();
          var makeMerchantUid = hours +  minutes + seconds + milliseconds;
          
          function requestPay() {
              IMP.request_pay({
                  pg : 'kakaopay.[PG상점아이디]', // ✅ 결제대행사 및 PG 상점아이디를 입력
                  pay_method : 'card',
                  merchant_uid: "IMP"+makeMerchantUid, 
                  name : '당근 10kg',
                  amount : 50000,
                  buyer_email : 'Iamport@chai.finance',
                  buyer_name : '아임포트 기술지원팀',
                  buyer_tel : '010-1234-5678',
                  buyer_addr : '서울특별시 강남구 삼성동',
                  buyer_postcode : '123-456',
                  display: {
                      card_quota: [3]  // 할부개월 3개월까지 활성화
                  }
              }, function (rsp) { // callback
                  if (rsp.success) {
                      console.log(rsp);
                  } else {
                      console.log(rsp);
                  }
              });
          }
      </script>
      <meta charset="UTF-8">
      <title>Sample Payment</title>
    </head>
    <body>
      <button onclick="requestPay()">결제하기</button> <!-- 결제하기 버튼 생성 -->
    </body>
    </html>

    위에서 체크 표시한 2개만 수정해주면 되는데, 아래 사진을 참고하면 된다.


  • 위 처럼 설정해주고, pay.html 파일을 클릭하면 결제하기 버튼이 생겼을 것이다. 결제하기를 클릭하면 카카오페이 결제를 위한 QR 코드가 나타난다.

  • 실제로 카카오페이에서 결제하기를 눌러서 QR 결제를 해보면 아래처럼 당근 10kg을 50,000원에 테스트 결제하는 창이 뜬다. 실제로 결제되는 것은 아니니 결제하기를 클릭해본다.

  • 그럼 결제가 완료됬다고 뜰것이고, https://classic-admin.portone.io/payments 로 접속해서, 결제승인내역을 봐보면 아래와 같이 결제 정보가 나온다.

  • 하지만, 이것은 프론트 서버에서만 동작하도록 설정한 것인데, 이렇게 되면 악의적인 의도를 가진 클라이언트나 해커가 결제 전에 프론트 엔드 서버의 코드에서 상품 가격을 바꿔서 결제하면 50,000원 짜리 상품을 100원에도 결제할 수 있을 것이다.

  • 따라서, 우리는 백엔드 서버에서 결제 정보에 대한 확인 과정이 반드시 필요하다. 프론트 서버에서 백엔드로 확인 요청을 보내는 것은 포트원 홈페이지 결제 관련 API 문서에 HTTP 요청 형태가 기술되어 있다.

  • 오늘은 라이브러리 사용을 안하고, 직접 HTTP 요청을 코드로 짜서 구현해 봤다.

  • 실습을 위해 강사님께서 간단하게 프론트엔드 서버 코드를 짜서 주셨고, 그 코드에서 결제하기 기능을 구축하기 위해 아래와 같이 코드를 짰다.

    🐭 OrdersService 클래스

    1. ACCESS 토큰 발급받기

        public String getToken() throws IOException {
           HttpsURLConnection conn = null;
    
           URL url = new URL("https://api.iamport.kr/users/getToken");
           conn = (HttpsURLConnection) url.openConnection();
           conn.setRequestMethod("POST");
           conn.setRequestProperty("Content-type", "application/json");
           conn.setRequestProperty("Accept", "application/json");
           conn.setDoOutput(true);
    
           JsonObject json = new JsonObject();
           
           // ✅ 결제 연동 페이지 - 내 식별코드·API Keys 에서 확인 가능
           json.addProperty("imp_key", "[나의 REST API Key]");
           json.addProperty("imp_secret", "[나의 Rest API Secret]");
    
           BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
           bw.write(json.toString());
           bw.flush();
           bw.close();
    
           BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
           Gson gson = new Gson();
           
           String response = gson.fromJson(br.readLine(), Map.class).get("response").toString();
           String token = gson.fromJson(response, Map.class).get("access_token").toString();
           
           br.close();
           conn.disconnect();
    
           return token;
       }

    Gson 이란❓
    Gson은 구글에서 개발한 자바 라이브러리로 JSON 데이터와 자바 객체 간의 직렬화(객체를 JSON으로 변환) 및 역직렬화(JSON을 객체로 변환)를 수행하는 데 사용되는 것이다. 즉, 자바 객체와 JSON 데이터 간의 변환을 쉽게 처리할 수 있도록 도와주는 라이브러리이다.

    pom.xml 파일에 라이브러리 추가

    		<dependency>
    			<groupId>com.google.code.gson</groupId>
    			<artifactId>gson</artifactId>
    			<version>2.8.8</version>
    		</dependency>

    2. 결제 정보 불러오기

        public Map<String, String> getPaymentInfo(String impUid) throws IOException {
           String token = getToken();
           HttpsURLConnection conn = null;
    
           URL url = new URL("https://api.iamport.kr/payments/" + impUid);
           conn = (HttpsURLConnection) url.openConnection();
           conn.setRequestMethod("GET");
           conn.setRequestProperty("Authorization", token);
           conn.setDoOutput(true);
    
           BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
           Gson gson = new Gson();
           String response = gson.fromJson(br.readLine(), Map.class).get("response").toString();
    
           br.close();
           conn.disconnect();
    
           String amount = response.split("amount")[1].split(",")[0].replace("=", "");
           String name = response.split(" name")[1].split(",")[0].replace("=", "");
    
           Map<String, String> result = new HashMap<>();
           result.put("name", name);
           result.put("amount", amount);
    
           return result;
    
       }

    3. 검증하기 ( 결제 가격과 실제 DB에 저장된 상품 가격이 같은지 )

        public String paymentValidation(String impUid) throws IOException {
    
           Map<String, String> paymentResult = getPaymentInfo(impUid);
    
           String custom_data = paymentResult.get("custom_data");
           Gson gson = new Gson();
    
           Integer productAmount = Integer.valueOf(Integer.valueOf(paymentResult.get("amount").split("\\.")[0]));
           Integer realAmount = 0;
    
           List<Map<String, Object>> orderInfo = gson.fromJson(custom_data, List.class);
    
           for(int i=0; i<orderInfo.size(); i++) {
               String productName = (String)orderInfo.get(i).get("name");
    
               Double productPrice = ((Double)orderInfo.get(i).get("price"));
               Integer resultPrice = productPrice.intValue();
    
               Optional<Product> result = productRepository.findByName(productName);
               Product product = result.get();
    
               if(resultPrice.equals(product.getPrice())) {
                   realAmount += product.getPrice();
               }
           }
           
           // 만약, 총 상품 가격과 실제 DB에서 불러온 가격의 총합이 같으면
           if(productAmount.equals(realAmount)) {
               return "ok";
           } else {
               // 다르면, 결제 취소 실행
               String token = getToken();
               cancelPayment(token, impUid, productAmount);
               return "error";
           }
       }
    

    4. 결제 취소

        public void cancelPayment(String token, String impUid, Integer amount) throws IOException {
           HttpsURLConnection conn = null;
    
           URL url = new URL("https://api.iamport.kr/payments/cancel");
           conn = (HttpsURLConnection) url.openConnection();
           conn.setRequestMethod("POST");
           conn.setRequestProperty("Content-type", "application/json");
           conn.setRequestProperty("Authorization", token);
           conn.setDoOutput(true);
    
           JsonObject json = new JsonObject();
           json.addProperty("imp_uid", impUid);
           json.addProperty("amount", amount);
           json.addProperty("reason", "결제 금액 에러");
    
           BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
           bw.write(json.toString());
           bw.flush();
           bw.close();
    
           br.close();
           conn.disconnect();
       }

    🐱 OrdersController 클래스

    @RestController
    @CrossOrigin("*") // ✅ CORS 에러를 위한 프론트 서버 URL 허용하는법 / 일단은 다 허용해준것
    public class OrdersController {
    
       private final OrdersService ordersService;
    
       public OrdersController(OrdersService ordersService) {
           this.ordersService = ordersService;
       }
    
       @RequestMapping(method = RequestMethod.GET, value = "/validation")
       public ResponseEntity paymentValidation(String impUid) throws IOException {
           return ResponseEntity.ok().body(ordersService.paymentValidation(impUid));
       }
    }

    🐶 상품 등록 기능 구현

    // 💻 Product 엔티티
    @Entity
    @Getter
    @Setter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Product {
    
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Integer id;
    
      private String name;
      private Integer price;
    }
    
    // 💻 Product 레포지토리
    @Repository
    public interface ProductRepository extends JpaRepository<Product, Integer> {
      public Optional<Product> findByName(String name);
    }
    
    // 💻 ProductService
    @Service
    public class ProductService {
    
      private final ProductRepository productRepository;
    
      public ProductService(ProductRepository productRepository) {
          this.productRepository = productRepository;
      }
    
      // CREATE
      public void create(ProductReadRes productDto) {
    
          productRepository.save(Product.builder()
                  .name(productDto.getName())
                  .price(productDto.getPrice())
                  .build());
      }
    }
    
    // 💻 ProductController
    @RestController
    @RequestMapping("/product")
    public class ProductController {
      private final ProductService productService;
    
      public ProductController(ProductService productService) {
          this.productService = productService;
      }
    
      @RequestMapping(method = RequestMethod.POST, value = "/create")
      public ResponseEntity create(ProductReadRes productReadRes) {
          productService.create(productReadRes);
    
          return ResponseEntity.ok().body("상품 생성");
      }
    }  

  • 🐧 위와 같이 작성 후 프론트엔드 서버 실행 및 결제하기를 테스트 해보겠다. 🐧

    1) 메인 페이지에 DB에 저장된 상품이 나타난다.


    2) "상품1" 과 "상품2" 를 클릭 후 결제하기를 하면 정상적으로 주문 됬다고 나온다.


    3) 이어서, 결제하기 전 인터넷 설정창에서 "상품1" 의 가격을 1000원 -> 10원으로
      바꾸고 결제하기를 해보겠다.


    4) 그러면 상품 총 가격이 2,010원이 되어 DB에 저장된 상품 가격과 다르므로,
      아래처럼 주문이 취소되게 된다.


    5) 포트원 관리자 페이지에 들어가서 보면 결제 성공한 것과 결제 취소 된 내역을
      확인 할 수 있다.


  • 위에서 작성한 결제하기 코드들은 전부 포트원 공식 홈페이지에 나와있는 API 문서를 참고해서 만든 것이다. 이러한 API 문서들을 읽고 적용하기 위해서는 HTTP 요청/응답에 대한 지식이 필요하다고 생각한다. 나도 아직 어렴풋이 알고 있는 지식이기 때문에, 날잡고 제대로 한번 파헤쳐 볼 필요가 있을 것 같다.

CORS 에러란 ❓

  • 오늘 실습을 진행하면서, CORS 에러가 떴었는데 신입 개발자라면 반드시 한번쯤은 경험해보는 에러라고 한다. 따라서, 이번 기회에 CORS 에러에 대해 알아보려고 한다,
  • CORS는 "Cross-Origin Resource Sharing" 의 줄임말로, 한국말로 교차-출처 리소스 공유인데, 쉽게 말해서 "출처가 다르다" 는 것을 말한다.
  • 그렇다면 출처(Origin) 란 무엇인가 🧐
    URL 구조를 살펴보면 아래와 같은데, 여기서 출처는 Protocol, Host, 포트번호까지를 의미한다. 즉, 서버를 찾아가기 위해 필요한 가장 기본적인 것들을 합쳐놓은 것이다.

  • 동일 출처 정책(SOP : Same-Origin Policy)
    동일 출처 정책은 단어 그대로 동일한 출처에 대한 정책을 말한다. 이 정책은 동일한 출처에서만 리소스를 공유할 수 있다는 법률을 가지고 있다. 즉, 동일 출처 서버에 있는 리소스는 자유로이 가져올 수 있지만, 다른 출처 서버에 있는 이미지나 유튜브 영상 같은 리소스는 상호작용이 불가능하다는 것을 의미한다.

  • 동일 출처 정책이 필요한 이유
    악의적인 의도를 가진 해커가 CSRFXSS 등의 공격으로 우리 서비스에 가입되어 있는 클라이언트들의 개인 정보를 가로채는 등의 악용이 가능하기 때문이다. 따라서 이러한 악의적인 공격을 방지하기 위해 동일 출처 정책으로 동일하지 않은 다른 출처의 스크립트가 실행되지 않도록 브라우저에서 사전에 방지하는 것이다.

  • 여기서 중요한 것이 출처를 비교하는 로직이 서버에서 구현되는 것이 아닌 브라우저에서 구현된다는 것이다. 서버에서는 HTTP 요청에 대한 응답을 정상적으로 해주었지만, 브라우저에서 서버로부터의 응답을 분석해서 동일 출처가 아니라면 에러를 발생시키는 것이다.

    ➡ 그래서 백엔드 서버 응답은 정상인데, 브라우저에서 에러를 발생시키다 보니 신입
      개발자들이 서버쪽에서 문제가 발생한 것으로 많이 착각한다고 한다...

  • 그렇다고 출처가 다른 모든 것을 차단할 수는 없을 것이다. 다른 회사의 서버 API를 이용해야 하는 상황도 있을 수 있기 때문에, 이러한 상황을 위해서 CORS 정책을 허용하는 출처를 지정하여 예외를 두고 허용시켜주는 것이다.

  • 먼저, CORS가 동작하는 방식은 세 가지의 시나리오에 따라 변경된다고 한다. 따라서 이 세 가지 시나리오를 모두 알고 있어야 된다고 하는데 자세한 내용은 자료를 참고한 출처에서 확인할 수 있다.
    1) 예비 요청(Preflight Request)
    2) 단순 요청(Simple Request)
    3) 인증된 요청(Credentialed Request)

  • 🧐 CORS를 해결하는 방법 🧐

    1) Chrome 확장 프로그램 이용
    "Allow CORS: Access-Control-Allow-Origin" 크롬 확장 프로그램을을 설치 한 뒤 활성화 시키면 로컬 환경에서 API 테스트 시 CORS 문제 해결이 가능하다.

    2) 서버에서 Access-Control-Allow-Origin 헤더 설정하기
    💻 스프링 서버 전역적으로 CORS 설정

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
      @Override
      public void addCorsMappings(CorsRegistry registry) {
          registry.addMapping("/**")
          	.allowedOrigins("http://localhost:8080", "http://localhost:8081") // 허용할 출처
              .allowedMethods("GET", "POST") // 허용할 HTTP method
              .allowCredentials(true) // 쿠키 인증 요청 허용
              .maxAge(3000) // 원하는 시간만큼 pre-flight 리퀘스트를 캐싱
      }
    }

    💻 특정 컨트롤러에만 CORS 설정

    @RestController@CrossOrigin(origins = "*", methods = RequestMethod.GET) 
    public class customController {
    
    	// 특정 메소드에만 CORS 적용 가능
      @RequestMapping("/url")  
      @CrossOrigin(origins = "*", methods = RequestMethod.GET) 
      public List<Object> findAll(){
          return service.getAll();
      }
    }

💥 2024. 1. 5.(금) 실습 간 다시 튀어 나온 CORS 에러 내용 추가 💥

  • 수업 간 실습하는 과정에서 회원과 상품에 대해서는 @CrossOrigin(*) 을 달아줘서 CORS 에러가 해결이 되었었다.

  • 하지만, 똑같이 장바구니 기능을 만들때 CartController에 분명 @CrossOrigin 어노테이션을 달아줬는데 계속 CORS 에러가 뜨면서 프론트 서버에서 보내는 요청이 백엔드 서버로 들어오는 것조차 안되었다.

  • 찾아봐도, CORS 에러를 해결하는 방법에 대한 글들이 많았고, 저렇게 처리를 해줬는데, 또 같은 에러가 발생하는 것에 대해서는 쉽게 글이 보이지 않았다. 그래서 해결한 방법을 기록해 놓는다.

  • 먼저 디버깅 했을때, 분명 장바구니에 상품을 등록할때 HTTP 요청 시 메서드가 POST 방식으로 가도록 프론트 서버에서 설정을 해놨는데, 백엔드 서버에서 디버깅한 내용을 보면 OPTION 메서드로 요청이 들어오는 것이다.

  • 그래서 Spring Security 쪽에서 컨트롤러에 도달하기 전에 필터에서 차단을 하고 있다는 것을 깨닫고, SecurityConfig 클래스에서 장바구니 기능의 URL인 /cart/** 에 대한 모든 접근을 허용 시켜줘봤더니 제대로 동작했다.

  • 하지만, 장바구니를 회원가입을 한 회원에게만 기능을 사용할 수 있도록 권한을 부여해줘야 되는 서비스도 분명 있을텐데, 이럴때는 어떻게 해야할까? 라는 의문이 든다.

  • 그것에 대한 방법으로 필터에서 OPTION 메서드로 들어오는 요청에 대한 허용을 먼저 시켜준 뒤, 이어서 장바구니에 대한 기능을 권한이 있는 사람에게만 사용토록 부여해주는 것이다. 코드는 아래와 같다.

            http.csrf().disable()
                    .authorizeHttpRequests()
                    .antMatchers(HttpMethod.OPTIONS).permitAll()  // ✅ CORS 해결하기 위한 OPTION 메서드 허용
                    .antMatchers("/member/**","/member/login", "/member/signup", "/product/list", "/product/{idx}").permitAll()
                    .antMatchers("/productWrite","/product/create").hasAuthority("ROLE_SELLER")
                    .antMatchers("/cart/in", "/cart/list", "/cart/cancel").hasAuthority("ROLE_USER")
                    .anyRequest().authenticated()
  • 왜 CORS 에러가 악명이 높다는 것인지 깨닫는 시간이었다. 분명 똑같이 해줬는데 어떤곳에선 잘되다가 갑자기 안되니 멘붕이 왔다.

    또한, CORS 에러의 3가지 시나리오에 대해서도 명확히 공부를 해야겠다. 이번 에러는 첫번째 시나리오였던 예비 요청 시나리오에 의한 에러였다.

오늘의 느낀점 👀

  • 오늘은 결제 기능을 구현해보는 시간이었다. 포트원이라는 결제 API 를 사용하여 구현했는데, 저번에 카카오 로그인 기능을 구현할때와 마찬가지로 API 구현을 위한 문서 등 설명은 홈페이지에 다 나와있었다.

  • 하지만, 중요한 것은 그것을 보고 실제로 그 형식에 맞게 구현할 수 있는지와 없는지가 가장 큰 문제라고 생각한다. HTTP 요청/응답에 대한 이해가 바탕이 되어 있는 상태에서 JSON 형태의 요청과 응답까지 모든 이론적 지식이 갖춰줘 있어야 API 문서를 보고 구현할 수 있다고 생각한다.

  • 따라서, 부족한 부분은 조금 더 심화학습을 해볼 필요가 있을 것 같다.






    [자료 참고] https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F

profile
Backend Developer

0개의 댓글