포스트

아이디어 실현에 도움이 될 소프트웨어 기술의 학습과 깊이 있는 경험을 기록하는 공간입니다.

  • Spring Boot 웹 애플리케이션에서 Tailwind CSS 설정

    Spring Boot 프로젝트에서 Tailwind CSS를 사용하려고 합니다. HTML에서 CDN 설정으로 쉽게 사용할 수 있지만, 해당 방법은 Tailwind에서 개발 목적으로 설계된 설정입니다. 프로덕션 환경에서는 정의된 스타일 기반으로 최적화된 CSS를 생성하는 게 권장됩니다. 그러므로 Spring Boot 프로젝트 빌드를 진행할 때, Tailwind CSS가 함께 진행되어 최적화된 결과물이 생성되도록 해야 합니다. IntelliJ IDEA 환경에서 프로젝트 설정을 진행하도록 하겠습니다. 버전 정보 Spring Boot : 3.5.0 (템플릿 엔진은 Thymeleaf로 진행) Tailwind CSS : 4.1 IntelliJ IDEA : 2025.1.2 1. Tailwind CSS 설정 HTML에서 사용된 class를 스캔하여 Tailwind CSS 파일을 생성하기 위해서는 Node.js가 설치되어 있어야 합니다. Node.js 설치가 안되어 있다면, LTS 버전으로 설치해 주세요. 글을 작성하는 시점에는 LTS 버전은 22입니다. Spring Boot 프로젝트에서 Tailwind 소스 관리를 위해서는 src/main/ 디렉터리 하위에 frontend 디렉터리 생성이 필요합니다. 작업의 연속성을 위해 터미널 환경에서 디렉터리를 생성하겠습니다. 디렉터리 생성을 완료했으면 해당 위치로 이동해 주세요. 명령어 수행 작업은 src/main/frontend/ 디렉터리에서 진행됩니다. 해당 디렉터리 위치에서 명령어를 진행해야 필요한 파일과 설정이 frontend 디렉터리에 생성됩니다. package.json 파일 생성을 위해 npm 초기화 명령어를 실행해 주세요. Tailwind 설정은 공식 문서를 참고해 진행하겠습니다. Tailwind CSS와 Tailwind CLI 사용을 위해 패키지를 설치해 주세요. 설치가 완료되면 src/main/frontend/ 디렉터리 하위에 관련 파일과 라이브러리가 생성됩니다. src/main/frontend/ 디렉터리 하위에 input.css 파일을 생성하고, 아래 코드를 작성해 주세요. input.css 파일은 Tailwind의 스타일을 생성하기 위해 관련 설정이 정의된 입력 파일입니다. Tailwind 스타일 생성을 위한 빌드 스크립트를 설정하겠습니다. package.json 파일에서 scripts 필드에 아래 빌드 스크립트를 참고해서 추가해 주세요. build는 HTML에서 정의된 스타일을 분석해서 CSS 파일을 생성하는 스크립트입니다. watch는 HTML에서 변경되는 스타일을 실시간으로 감지해서 CSS 파일에 반영해주는 스크립트입니다. Tailwind 스타일 생성을 위한 설정은 완료됐습니다. 빌드 명령어를 실행하면 src/resources/static/ 디렉터리 하위에 Tailwind 스타일이 정의된 main.css 파일이 생성됩니다. 2. Tailwind CSS 적용 Tailwind 스타일이 정상적으로 적용되는지 확인해 보겠습니다. 간단한 테스트를 위해 Spring Web MVC 코드를 작성하도록 하겠습니다. HTML 파일과 Controller를 생성해 주세요. HTML 파일에서 main.css를 정의하고, Tailwind 클래스를 작성해 주세요. 테스트를 위한 코드 작성을 완료했으면 터미널에서 빌드 스크립트 명령어를 실행해 주세요. build 스크립트를 실행하면 HTML 클래스를 분석해서 필요한 스타일의 main.css 파일을 생성됩니다. watch 스크립트는 HTML 클래스를 실시간으로 분석해서 변경된 스타일을 main.css 파일에 반영합니다. 로컬 환경에서 개발을 진행할 때는 watch 스크립트를 사용해서 편의성을 확보하겠습니다. 빌드 스크립트 명령어를 실행하고, Spring Boot Application을 실행해 주세요. localhost:8080/ 접속하면 Tailwind CSS가 정상적으로 적용된 걸 확인할 수 있습니다. 3. NPM Command + Application 통합 실행 로컬에서 개발을 진행할 때, Tailwind CSS 적용을 위한 빌드 스크립트 명령어 때문에 터미널을 접속하고 디렉터리를 이동해서 명령어를 실행하는 행위가 반복적으로 발생합니다. 불필요한 반복 행위를 줄이기 위해 Application을 실행할 때 빌드 스크립트 명령어도 같이 실행될 수 있도록 IntelliJ 설정을 진행하겠습니다. [그림 10]을 참고해서 우측 상단에 Edit Configurations...를 클릭하세요. Run/Debug Configurations 팝업에서 좌측 상단에 + 버튼을 클릭 후, npm을 추가하세요. [그림 12]를 참고해서 package.json 파일 경로와 Command는 run, Scripts는 watch를 설정하고 Apply 버튼을 클릭해서 적용하세요. Run/Debug Configurations 팝업에서 좌측 상단에 + 버튼을 클릭해서 Compound를 추가하세요. Compound 설정에서 + 버튼을 클릭해서 npm과 Spring Boot Application 설정을 추가하세요. 설정한 Compound를 실행하면 NPM Command와 Spring Boot Application이 한 번에 실행됩니다.

  • IntelliJ에서 Spring Boot 멀티 모듈 구성

    Spring Boot 모듈 구성하는 방법을 작성한다. 반복적으로 하는 작업도 아니어서 새롭게 프로젝트 구성이 필요할 때마다 설정 옵션 등 다시 찾아보며 기억을 되살리기 위한 시간을 소비하게 된다. 기본적인 설정 방법부터 작성해서 모듈 구성을 진행할 때, 변경 또는 추가되는 내용이 있는 경우에는 해당 글을 업데이트하며 반복적으로 소비되는 시간을 줄이고자 작성한다. 버전 정보 IntelliJ IDEA : 2025.1.2 Java : 21 Spring Boot : 3.5.0 1. Spring Boot Module 구성 시작하기 IntelliJ에서 New Project를 클릭하여 Generators > Spring Boot로 프로젝트 생성을 진행한다. [그림 1]에서 프로젝트 생성할 때 설정하는 항목에 대한 설명이 필요하면 다음 표를 참고하도록 한다. | 항목 | 설명 | | --- | --- | | Name | 프로젝트 이름 (Artifact와 동일하게 설정 권장) | | Location | 프로젝트 저장 경로 | | Language | 프로젝트 개발 언어 | | Type | 프로젝트 빌드 도구 | | Group | 프로젝트 식별 그룹 ID (회사/조직의 도메인 이름 역순 권장. e.g., io.github) | | Artifact | 프로젝트 식별 이름 | | Package name | 패키지 이름 (Group ID + Artifact ID 조합해서 생성 권장. e.g., io.github.whitepaek) | | JDK | IntelliJ에서 프로젝트를 개발/실행에서 사용할 JDK | | Java | 프로젝트에서 사용할 Java 버전 (JDK 버전과 동일하게 설정 권장) | | Packaging | 빌드 패키징 방식 | 모듈에 따라 필요한 의존성을 추가할 것이기 때문에 [그림 2] 과정에서는 추가하지 않고 프로젝트를 생성한다. 프로젝트 생성을 완료했으면, 멀티 모듈 구성을 위해 src 디렉토리를 삭제하고 build.gradle 파일 수정을 진행하겠다. 멀티 모듈 설정을 위해 루트 디렉토리의 build.gradle 파일을 아래 스크립트를 참고해 수정한다. 스크립트의 간단한 설명은 주석으로 작성했다. 루트 프로젝트의 build.gradle 설정을 완료했으면, 루트 프로젝트에 모듈을 생성하도록 한다. 모듈은 좌측에 New Module > Java를 선택해서 생성하도록 한다. 모든 모듈에서 공통적으로 사용할 코드 개발을 위해 common 모듈을 먼저 생성하겠다. common 모듈이 추가됐으면, 모듈에 포함된 build.gradle 파일의 스크립트 설정을 진행하겠다. 모듈에 포함된 build.gradle 설정은 해당 모듈에만 적용된다. 해당 모듈에서 사용할 플러그인과 의존성 스크립트를 작성하도록 한다. 우선 common 모듈에서 Spring Boot를 사용할 예정이기 때문에 관련 플러그인을 추가하고, 필요한 의존성은 이후 추가하도록 하겠다. [그림 5] ~ [그림 8] 과정을 반복해서 필요한 모듈을 추가하고, 루트 프로젝트의 settings.gradle 파일에 추가한 모듈을 정의하도록 한다. (모듈을 생성하면 IntelliJ에서 settings.gradle 파일에 자동으로 추가한다. 만약, 추가가 안되어 있다면 직접 작성한다.) client-api, backoffice-api 모듈을 추가하고, common 모듈을 의존성으로 설정해 필요한 공통 코드를 사용하도록 하겠다. 이로써 기본적인 멀티 모듈 설정은 완료되었다. 지금까지 설정한 멀티 모듈 구조와 파일에 대한 정리된 내용은 다음과 같다. 2. Spring Boot Module 실행하기 구성한 멀티 모듈의 애플리케이션이 정상적으로 실행되는지 테스트해 보도록 하겠다. 각 API 모듈은 공통 모듈 common을 의존성으로 추가해 공통 코드와 의존성을 사용하며, 테스트는 common ---> client-api 모듈로 진행하겠다. common ---> client-api common ---> backoffice-api common 모듈에 H2 Database 의존성을 추가해 API 모듈에서 공통으로 사용하겠다. common 모듈의 build.gradle 파일에 의존성을 추가하도록 한다. client-api 모듈의 build.gradle 파일에는 common 모듈과 web, actuator 의존성을 추가해서 common 모듈에 정의한 H2 Database와 Spring Boot Application 실행이 정상적으로 되는지 확인하겠다. client-api 모듈에 application.yml 파일을 생성해서 H2 Database 실행을 위한 콘솔 정보를 설정한다. client-api 애플리케이션 실행을 위해 ClientApplication.class 코드를 작성하고, 멀티 모듈로 구성한 애플리케이션이 정상적으로 동작하는지 확인하기 위해 실행하도록 한다. ClientApplication 실행을 완료했으면, http://localhost:8080/h2-console 접속하여 공통 모듈에 추가한 H2 Database의 콘솔에 접속되는지 확인한다. 마찬가지로 http://localhost:8080/actuator/health 접속해서 Spring Boot Application 실행 상태를 확인한다. 이상으로 IntelliJ에서 Spring Boot 멀티 모듈을 구성하고 애플리케이션 실행까지 완료했다.

  • 스프링 부트 JVM 핫 스와핑 (devtools, JRebel)

    스프링 부트(Spring Boot) 애플리케이션 개발을 진행할 때, 코드를 변경하고 결과 확인을 위해 서버를 재시작하는 번거로움이 있어요. 이런 번거로움을 해소하기 위한 핫 스와핑(hot-swapping) 기능을 할 수 있는 솔루션이 있는데, Devtools와 JRebel이 있어요. Devtools와 JRebel에 대해 알아보고, macOS와 인텔리제이(IntelliJ IDEA) 환경에서 실행하는 방법을 확인해 볼게요. 1. Devtools 많은 분들이 스프링 부트 애플리케이션을 개발할 때 대표적으로 spring-boot-devtools 의존성을 추가하여 사용하고 있어요. devtools를 사용하면 클래스, 리소스 파일을 변경하면 애플리케이션이 자동으로 재시작 돼요. 그리고 HTML, CSS, JavaScript 등 정적 리소스를 수정하고 브라우저를 새로고침 하면 변경 사항을 즉시 반영할 수 있어요. 그뿐만 아니라 properties, yml 파일의 설정 정보를 자동으로 적용해 줘요. devtools를 적용하고, 실행하는 방법을 알아볼게요. 본인이 사용하고 있는 빌드 도구(Gradle, Maven)에 맞춰 의존성을 추가해 주세요. devtools는 의존성만 추가하면 별도의 설정은 필요가 없어요. devtools가 동작하는지 확인을 위해 서버를 실행하고, 클래스 파일을 수정해 주세요. 클래스 파일 변경이 감지되면 자동으로 서버가 재시작되는 걸 확인할 수 있어요. 인텔리제이에서 포커스가 벗어나는 경우에 변경 감지가 이뤄지기 때문에 개인적으로 이 부분이 불편하다고 생각해요. 그래서 수동으로 Command + Shift + F9 단축키로 명확하게 변경된 코드를 적용하여, devtools의 변경 감지가 이뤄져서 서버가 재시작되는 걸 확인할 수 있어요. [1] devtools 실행 결과 코드 변경이 일어날 때마다 자동으로 애플리케이션이 재시작되기 때문에 그 시간 동안 딜레이가 발생해요. 하지만 서버를 아예 멈췄다가 실행하는 콜드 스타트에 비해 빠르기 때문에 보다 쾌적한 개발을 진행할 수 있어요. devtools 사용할 때 한 가지 주의할 점은 개발할 때만 사용하고, 운영 환경에서는 비활성화 해줘야 해요. 운영 환경에서 사용하게 되면 불필요한 자원이 낭비되고, 성능 저하 등 발생할 수 있어요. 그렇기 때문에 운영 환경에서는 spring-boot-devtools 의존성을 제거하거나 properties 또는 yml 파일에서 spring.devtools.restart.enabled = false로 설정해 주세요. 2. JRebel JRebel도 devtools와 마찬가지로 클래스, 리소스 파일, 정적 파일 그리고 설정 파일에 대한 변경을 실시간으로 적용해 줘요. devtools와 가장 큰 차이점으로는 애플리케이션을 재시작하지 않고, 즉시 반영해 준다는 점이에요. devtools는 스프링 부트에서 사용되는 오픈 소스로 의존성만 추가하면 무료로 사용할 수 있지만, JRebel은 상용 소프트웨어로 플러그인을 설치하고 라이선스를 등록해야 사용할 수 있어요. JRebel을 설정하고, 실행하는 방법을 알아볼게요. 라이선스를 받기 위해 JRebel 사이트에 접속 후 정보를 입력해 주세요. [2-1] JRebel 사이트에서 정보 등록 입력한 이메일로 라이선스 정보를 받으면 라이선스 키를 복사해 주세요. [2-2] 메일로 라이선스 키 수신 인텔리제이를 실행하고, Settings > Plugins > Marketplace에서 JRebel and XRebel 플러그인을 설치해 주세요. [2-3] JRebel 플러그인 설치 Help > JRebel > Activation을 클릭 후, 메일로 전달받은 라이선스 키를 입력하고 활성화해 주세요. [2-4] JRebel 라이선스 활성화 우측 상단에 JRebel 실행 버튼을 클릭하여 서버를 시작해 주세요. [2-5] JRebel 서버 실행 JRebel이 동작하는지 확인을 위해 클래스 파일을 수정하고, Command + Shift + F9 단축키로 Recompile 해주세요. (devtools와 동일하게 변경 감지가 이뤄지면 리컴파일 단축키를 누르지 않더라도 자동으로 실행돼요) 그러면 애플리케이션 재시작 없이 변경된 파일이 적용되는 걸 확인할 수 있어요. [2-6] 변경된 클래스 파일 리로딩 JRebel에서 제공받은 평가판 라이선스는 14일 동안 유효하게 사용할 수 있어요. 제한 없이 라이선스를 사용하기 위해 유료 라이선스 가격을 문의해 봤더니, 연간 $655 라는 작지 않은 금액을 답변 받았어요. [2-7] 유료 라이선스 가격 개인이 구매하기에는 부담되는 금액으로 판단되고, 대신에 약간의 불편함을 감수하고 평가판 라이선스를 지속해서 사용할 수 있는 방법이 있어요. 홈 디렉터리로 이동 후 .jrebel 디렉터리를 제거해 주세요. (파인더(Finder)에서 직접 제거해도 돼요) 그리고 인텔리제이를 재실행 후 [2-4] 이미지 과정을 다시 진행해 주세요. 그러면 평가판 라이선스 기간이 14일로 초기화되어 사용할 수 있어요. [2-8] .jrebel 디렉터리 삭제 기간이 만료될 때마다 해당 방법을 반복해야 하는 수고로움이 있지만, 그래도 JRebel 유료 라이선스 구매 없이 평가판 라이선스를 계속 사용할 수 있어요. 개인적으로 devtools 보다 JRebel을 추천해요. 이유는 devtools는 변경 시점마다 애플리케이션이 자동으로 재시작되지만, JRebel은 재시작 없이 변경된 파일을 바로 적용 해줘요. 그렇기 때문에 속도도 빠르고, JVM 핫 스와핑도 안정적으로 동작해요. [3] 스프링 부트 핫 스와핑에 대한 공식 문서 또한 서버가 내려가지 않기 때문에 Memory DB를 사용하고 있다면 테스트 중이던 데이터가 유지돼요. devtools는 서버가 재시작되면서 메모리가 날아가기 때문에 테스트 중이던 데이터를 다시 등록해야 한다는 불편한 점도 있거든요. 그리고 프로젝트에 의존성을 추가하지 않고, 인텔리제이 플러그인으로 설정하여 동작하기 때문에 devtools 처럼 의존성을 신경 쓰지 않아도 돼요. JRebel로 디버깅을 켠 상태로 개발을 진행하면 쾌적한 개발 환경을 경험할 수 있어요! JRebel 플러그인 설치 및 라이선스 활성화 "Settings > Keymap"에서 Rebel Debug 단축키를 Control + D로 설정 Control + D 단축키로 Debug with Rebel로 서버 실행 Command + Shift + F9 단축키로 Recompile 하여 변경된 코드 반영 이상으로 JVM 핫 스와핑 기능을 위한 Devtools와 JRebel에 대해 알아봤어요.

  • 스프링 프로젝트에 애플 인 앱 결제(IAP, In-App Purchase) 서버 개발

    애플(Apple)의 인 앱 결제(In-App Purchase)를 통해 구입한 상품 데이터를 스프링(Spring) 프로젝트로 구현한 서버(Server)에서 검증을 하고 데이터베이스에 저장하여 구입이 완료된 상품을 관리할 수 있도록 프로세스를 확인해보도록 하겠습니다. 상품 구매에 대한 인 앱 결제 프로세스를 확인해보도록 하겠습니다. App은 Server에서 상품 목록 API를 호출합니다. Server는 관리자가 실제로 App Store Connect에서 등록한 상품을 DB에서 관리하며, 목록을 App에게 제공합니다. App은 선택한 상품의 구매 가능 여부를 Server에 요청합니다. Server는 유저가 선택한 상품이 구매한 적이 있는 상품인지 확인하여 App에게 제공합니다. (상품 구매가 가능한 경우, Server에서 고유한 ID를 생성해서 [3 ~ 5-2] 과정을 Server 입장에서 하나의 트랜잭션으로 관리하여 데이터 안정성을 관리할 수 있지 않을까..? 고유한 ID가 생성되면 DB에 저장 - 서버 입장에서의 구매 트랜잭션 시작) App은 구매 가능한 상품인 경우에 App Store에 결제를 진행합니다. 결제가 완료되면 App은 receipt-data를 생성합니다. (트랜잭션 시작) App은 생성한 receipt-data로 Server에 상품 결제 검증 요청 API를 호출합니다. 4-1. Server는 App에서 전달받은 receipt-data로 App Store에게 영수증 검증(/verifyReceipt) API를 호출합니다. 4-2. Server는 App Store에게 JSON 형태의 영수증 데이터를 응답받습니다. 4-3. Server는 응답받은 영수증 데이터에서 필요한 값을 파싱 하여 DB에 저장하도록 합니다. (유저가 구입한 상품을 DB에 저장) 4-4. 유저가 구입한 상품을 DB에 저장 완료했다면 Server는 성공 응답 값을 App에게 전달합니다. App은 Server에게 성공 응답 값을 받으면 트랜잭션을 종료시키고 Server에게 구매 완료 API를 호출합니다. 5-1. Server는 App에게 구매 완료 요청을 받으면 [4-3]과정에서 DB에 저장한 영수증 데이터에 완료에 대한 업데이트를 합니다. ([2-2] 과정에서 Server가 생성한 고유한 ID로 구매 완료된 상품에 대한 데이터를 찾아서 업데이트 - 서버 입장에서의 구매 트랜잭션 종료) 5-2. App은 인 앱 결제에 대한 프로세스가 최종적으로 종료됩니다. 서버(Server)는 인 앱 결제 프로세스에서 "상품 목록, 상품 구매 가능 여부, 상품 결제 검증, 상품 구매 완료" 4개의 API를 App에게 제공해주면 됩니다. "상품 목록, 상품 구매 가능 여부, 상품 구매 완료" API는 해당 서비스 정책에 따라서 다르기 때문에 플로우(Flow)에서만 설명하도록 하고, 해당 글에서는 [Step 03 ~ Step 04] 과정에 대한 코드를 확인하고 발생할 수 있는 이슈 사항에 대해 체크해보도록 하겠습니다. 인 앱 결제에 대한 서버 코드에 대해 확인하기 전, 애플에서 제공하는 상품 종류에 따른 몇 가지 특징에 대해서는 확인해보도록 하겠습니다. 애플에서 제공하는 인 앱 결제에 대한 상품 종류는 총 4가지입니다. Ref. In-app purchase types | 상품명 | 설명 | 특징 | | --- | --- | --- | | 소모품(Consumable) | 앱 내에서 사용하면 소모되는 일회성 상품입니다. | 정상적으로 구매가 완료(App에서 트랜잭션을 종료) 되었다면 "/verifyReceipt"를 호출하였을 때 소모품에 대한 응답 값은 확인할 수 없습니다. | | 비소모품(Non-Consumable) | 한 번 구매하면 기간 제한없이 계속 사용할 수 있는 상품입니다. | \- "/verifyReceipt"를 호출하면 비소모품에 대한 데이터를 포함한 응답 값을 반환합니다. \- 복원(Restore)되면 transaction\_id는 변경됩니다. | | 자동 갱신 구독(Auto-Renewable Subscription) | 구매 후 고객이 취소하기 전까지 자동으로 결제가 이루어지는 상품입니다. | \- "/verifyReceipt"를 호출할 때 "receipt-data, password" 2개의 값이 필요합니다. \- "/verifyReceipt"를 호출하면 자동 갱신 구독에 대한 데이터를 포함한 응답 값을 반환합니다. \- 갱신되면 transaction\_id는 변경되며 상품에 대한 receipt 데이터는 응답 값에 추가됩니다. | | 비자동 갱신 구독(Non-Renewable Subscription) | 구매 후 특정 기간동안 사용할 수 있으며, 자동으로 결제가 이루어지지 않습니다. 고객이 재구매를 통해 기간을 연장할 수 있는 상품입니다. | \- "/verifyReceipt"를 호출하면 비자동 갱신 구독에 대한 데이터를 포함한 응답 값을 반환합니다. \- 갱신되면 transaction\_id는 변경되며 상품에 대한 receipt 데이터는 응답 값에 추가됩니다. | 각 상품 특징에 대해 확인했습니다. 그렇다면 상품에 대한 receipt-data는 어떻게 얻을까요? App 개발자와 협업을 통해 테스트 앱 혹은 데이터를 얻을 수 있다면 큰 문제는 되지 않습니다. 하지만 상황이 여의치 않다면 직접 테스트 앱을 만들어서 필요한 데이터를 얻는 수밖에.. 저는 여의치 않은 상황이었고, 앱 쪽에서 상품 구매와 완료 처리되는 게 궁금했기 때문에 테스트 앱을 실행 후 데이터를 얻었습니다. Developer와 App Store Connect 설정과 테스트 앱 실행까지 설명하면 좋겠지만, 인 앱 결제 처리에 대한 서버 프로세스에 집중하도록 하겠습니다. 테스트 앱에서 제가 얻은 각 상품에 대한 receipt-data는 아래와 같습니다. 서버 프로세스를 간략하게 이해하고, 애플에서 제공하는 상품 4가지에 대해서 알아봤습니다. receipt-data까지 준비가 완료되었다면 스프링 프로젝트의 로직을 확인하도록 하겠습니다. (receipt-data 준비가 어렵다면 위에 제가 제공해드린 receipt-data를 사용해도 상관없습니다.) 글에서 설명드리는 코드가 필요하신 분은 GitHub을 통해서 다운로드하시면 됩니다. application.properties는 따로 설정할 필요가 없을 거 같습니다. "APPLE.PASSWORD"는 자동 갱신 구독 상품에 대한 서비스를 제공하고 있다면 App Store Connect에서 생성한 공유 암호를 넣어주시면 됩니다. 하지만 이 글에서는 소모품 상품에 대해서만 테스트해 보도록 하겠습니다. (소모품 상품에 대해서만 테스트를 진행한 이유는 아래에서 설명드리도록 하겠습니다.) 소모품 외에도 "비소모품, 비자동 갱신 구독" 상품은 따로 password가 필요하지 않기 때문에 문제없이 진행할 수 있습니다. (자동 갱신 구독 상품에 대한 구매 이력이 포함되어 있는 receipt-data는 password가 필요합니다.) 1. App에서 "receipt-data" 요청받기 [그림 1]에서 "4. 상품 결제 검증 요청" 프로세스 부분입니다. 유저가 구매한 상품을 서버에서 관리하기 위해서는 App에게 receipt-data 외에도 유저(구매자)에 대한 데이터도 받아야 합니다. 실제 서비스를 구현할 때는 UserReceipt.java 파일에서 유저 관리에 필요한 Fields를 적절하게 정의해주도록 하세요. 테스트에서는 receipt-data만 받아서 진행하도록 하겠습니다. 2. App Store에게 receipt-data 검증 요청 (/verfityReceipt) [그림 1]에서 "4-1. 영수증 검증 요청" 프로세스 부분입니다. receipt-data 데이터와 함께 "POST https://buy.itunes.apple.com/verifyReceipt" 를 호출합니다. 호출 후 전달받은 JSON 응답 값에서 status 코드 값이 21007인 경우에는 "POST https://sandbox.itunes.apple.com/verifyReceipt" 를 다시 호출하도록 합니다. (password는 자동 구독 갱신 상품에 대한 receipt-data 혹은 이력이 포함된 receipt-data를 검증하는 경우에 필요합니다.) 인 앱 결제 테스트를 진행하는 경우에는 Sandbox Test URL을 호출하여야 합니다. 애플 공식 문서에 따르면 Production URL을 먼저 호출하고 21007 코드를 받으면 Sandbox URL을 호출하여 계속 진행하라고 합니다. 3. 영수증 검증 후 응답받은 데이터를 DB에 저장 [그림 1]에서 "4-2. 영수증 검증 응답" 및 "4-3. 영수증 응답 데이터 저장" 프로세스 부분입니다. "/verifyReceipt"을 호출 후 응답받은 JSON 형태의 영수증(receipt) 데이터에서 필요한 값을 파싱 하고 데이터베이스에 저장하여 유저가 구매한 상품 이력을 관리해주어야 합니다. 저는 소모품 상품에 대한 영수증 데이터를 파싱 하여 데이터베이스에 저장하는 대신 logger로 찍었습니다. 실제 서비스에서 상품에 대한 정책과 DB에서 관리할 영수증 데이터가 정해지지 않았기 때문에 각 상품에 대한 상황을 체크하여 프로세스를 확인하는 게 목적이기 때문에 소모품 상품에 대한 영수증을 기반으로 로직을 작성하였습니다. 응답받은 영수증 데이터에서 소모품의 경우에는 in_app에 1건의 데이터만 존재합니다. ("비소모품, 자동 갱신 구독, 비자동 갱신 구독" 상품의 결제를 진행했다면 소모품 상품 외 이력이 in_app에 존재합니다. 또한 트랜잭션이 종료되지 않은 상품의 경우에도 이력이 존재합니다.) Apple에게 "/verifyReceipt" API 요청 후 응답받은 상품 별 영수증 데이터(JSON)는 아래와 같습니다. JSON의 Properties 설명은 공식 문서를 참고해주세요. 이제부터는 해당 로직에서 발생할 수 있는 이슈를 확인해 보도록 하겠습니다. 구매한 상품의 트랜잭션이 정상적으로 종료되지 않는 문제에 대해 아래와 같은 케이스를 생각해볼 수 있습니다. App에서 발생한 문제 1-1. App에서 receipt-data를 Server에 전달하기 전 비정상적으로 종료된 경우 1-2. App에서 receipt-data를 Server에 전달한 후 비정상적으로 종료된 경우 1-3. App에서 구매 완료(트랜잭션 종료) 후 Server에게 구매 완료 요청 API를 호출하기 전 종료된 경우 Server에서 발생한 문제 2-1. 데이터베이스에 영수증 데이터를 저장 후 Server 문제 발생으로 App에게 응답 값을 전달하지 못한 경우 2-2. 영수증 검증 오류와 같은 이슈로 데이터베이스에 저장하지 못하고 App에게 실패(fail)의 응답 값을 전달한 경우 위 5가지의 문제에 대해 제가 생각해본 해결 포인트는 아래와 같습니다. 1-1. App이 재구동될 때 종료되지 않은 트랜잭션의 receipt-data를 재생성하여 Server에 재요청하기 때문에 Server 쪽에서는 신경 쓸 부분이 없다. ‣ App에서 처리해야하는 이슈 1-2. App이 재구동 되면서 종료되지 않은 트랜잭션의 receipt-data를 재생성하여 Server에 재요청 한다. 이 경우에 Server는 기존 로직을 동일하게 진행하나 데이터베이스에 중복으로 유저의 상품 정보가 존재하는지 확인해야 한다. ‣ App & Server에서 처리해야하는 이슈 1-3. App에서 유저의 구매 이력과 DB에 저장된 유저의 구매 이력을 비교하여 DB를 현행화 해주는 Server 로직이 필요하다. 이 경우에는 어떻게 처리해야 할까.. 댓글로 의견 부탁드리겠습니다. ‣ 고객이 결제를 했으나, 상품 구매가 정상적으로 이루어지지 않았기 때문에 고객센터를 통해 문의를 한다. (?) 2-1. App은 일정시간 동안 응답 값을 받지 못하면 Server에 재요청한다. 일정시간 혹은 재요청 후에도 응답 값을 받지 못하면 App은 결제 실패에 대한 트랜잭션을 처리할 로직을 구현해야 한다. (서버에 문제가 발생한 경우에는 서비스 전체 장애이기 때문에 절대 발생하는 일이 없기를..) ‣ App & Server에서 처리해야하는 이슈 2-2. App은 실패에 대한 응답 값을 전달받았기 때문에 Server에 재요청 한다. 이 경우에는 Apple 쪽 문제 혹은 Server에서 데이터 처리 과정에서 발생한 문제이기 때문에 관련 로그를 적절하게 남기고 빠른 시간 내에 장애를 처리해야 한다. (마찬가지로 발생해서는 안되는 문제이지만 만약 발생한 경우 App 쪽에서 결제 실패에 대한 트랜잭션을 처리할 로직을 구현해야한다.) ‣ App & Server에서 처리해야하는 이슈 로직에서 고려해야 할 사항은 (1) "비소모품, 자동 갱신 구독, 비자동 갱신 구독" 상품의 구매 이력이 존재하는 영수증 데이터에 대한 처리와 (2) 1-2 이슈 상황에 대한 부분일 거라고 생각합니다. 아래에서 두 가지 상황에 대해서 얘기를 해보겠습니다. 1. 구매 이력이 존재하는 영수증 in_app 프로퍼티에 상품의 구매 이력이 존재하는 영수증 데이터를 처리하는 로직은 현재 코드에서 구현하지 않았습니다. 이유는 서비스 정책이 정해지지 않은 상황이지만, 앱 내에서 사용하는 "기간제 & 무기한"의 상품을 다룰 예정이고 유저의 구매 상품과 기간을 서버에서 관리할 것이기 때문에 transaction_id가 일관성 있게 유지되는 소모품(Consumable) 옵션이 적절하다고 판단하였기 때문입니다. "비소모품, 자동 갱신 구독, 비자동 갱신 구독" 상품의 경우에는 App에서 복원(Restore) 기능을 구현해야 합니다. 복원 기능을 구현하지 않는다면 App Store에서 거절(reject) 사유에 해당되기 때문입니다. 또한 복원을 하는 경우 transaction_id가 변경되어 구매 이력이 추가적으로 생성되기 때문에 앱 내에서 사용하는 상품을 관리하기에는 적절하지 않다고 판단하였습니다. 해당 부분에서 본인의 서비스 정책에 따른 상품의 영수증(receipt)에서 필요한 데이터를 파싱 하는 로직을 구현하면 됩니다. (추후에 정책이 정해지면 적절한 프로세스를 구현해보고 좀 더 자세하게 설명을 추가해보도록 하겠습니다.) 2. App에서 receipt-data를 Server에 전달한 후 비정상적으로 종료된 경우 App에서는 receipt-data를 Server에 전달하였고 Server에서는 영수증 검증까지 완료하고 유저의 구매 상품의 정보를 데이터베이스에 정상적으로 저장까지 완료하고 성공(Success)에 대한 응답 값을 App에게 전달하였으나, App이 비정상적으로 종료되어서 Server에서 보낸 성공에 대한 응답 값을 못 받았기 때문에 구매 트랜잭션을 종료하지 못한 상황입니다. 이 경우에는 App이 재 구동된 시점에 종료되지 않은 트랜잭션에 대한 receipt-data를 재생성하여 Server에 다시 요청합니다. 하지만 유저가 구매한 상품에 대한 정보가 데이터베이스에 저장된 상황이기 때문에 Server는 transaction_id를 이용하여 데이터베이스에 저장된 데이터가 있는지 확인하고 데이터가 존재한다면 App에게 성공(Success)에 대한 응답 값을 전달해주면 됩니다. 데이터베이스에 저장하는 로직은 현재 구현하지 않았고 logger로 필요한 데이터를 출력하였습니다. 이미 존재하는 데이터에 대한 중복 저장에 대해서는 INSERT IGNORE를 이용하여 처리하면 어떨까 하는 의견입니다. 마찬가지로 데이터베이스에서 관리할 상품 정보가 정해지지 않아서 몇 가지의 데이터만 뽑아서 확인했습니다. 애플 공식 문서에서는 transaction_id, original_transaction_id, product_id를 기준으로 데이터베이스에서 상품을 관리하길 권장합니다. 저는 데이터베이스에서 "기간제 & 무기한" 상품을 관리할 예정이기 때문에 "transaction_id, original_transaction_id, product_id" 외에도 상품을 구매한 유저의 정보와 구매한 상품에 대한 기간으로 계산한 만료 기간 등 추가로 넣어줄 예정입니다. 4. 데이터베이스에 유저가 구매한 상품 정보를 정상적으로 저장하였다면 App에게 성공(Success)에 대한 응답 값 반환 [그림 1]에서 "4-4. 상품 결제 검증 응답" 프로세스 부분입니다. 이상으로 애플(Apple) 인 앱 결제(In-App Purchase)에 대한 프로세스를 알아봤습니다. 가장 중요한 것은 서비스에 대한 정책을 어떻게 정하느냐에 따라 로직이 달라질 것입니다. 인 앱 결제에서 관리할 데이터와 프로세스가 정해진다면 프로토타입으로 간단하게 작성한 로직을 좀 더 구체화시킬 수 있을 거 같습니다. 해당 글 내용은 개인적인 판단으로 임의의 서비스 정책을 정한 후 작성한 글입니다. 또한 코드를 작성하는 실력이 아직 많이 부족하기 때문에 부족함이 많은 프로토타입의 코드입니다. 애플의 인 앱 결제(In-App Purchase) 프로세스를 이해하는 부분에서 도움이 되었으면 합니다. 인 앱 결제에서 사용되는 4가지 종류의 상품과 다양한 방식의 예외 사항을 테스트해봤습니다. 프로세스 흐름에 따라 글을 작성하다 보니 제가 경험한 전부를 내용에 넣지는 못했습니다.

  • 스프링 프로젝트에 애플 로그인 API 연동하기

    이전 글에서 "Sign in with Apple" 연동을 위한 Apple Developer 3가지 설정을 진행하였습니다. 설정을 통해서 필요한 데이터 준비가 끝났으므로 프로젝트에 설정하여 확인해보도록 하겠습니다. 먼저, 깃허브에서 샘플 코드를 다운로드 하도록 합니다. macOS Catalina 버전 10.15.6 운영체제에서 IntelliJ IDEA Ultimate 환경에서 Spring Boot 프로젝트로 설명하도록 하겠습니다. JWT 관련 라이브러리는 "nimbus-jose-jwt(v3.10)"를 이용했습니다. 해당 버전의 라이브러리를 이용한 이유는 애플 로그인 API를 적용시킬 프로젝트에서 사용하고 있기 때문에 그대로 유지하여 사용하기로 결정하였기 때문입니다. 프로젝트 다운로드하여 실행하였다면 application.properties 파일로 이동해주세요. 이전 글에서 설정 후 얻은 값을 아래와 동일한 위치에 입력해주면 됩니다. 프로젝트를 실행 후 "localhost:8080/"으로 접속하면 Sign in with Apple 로그인 화면이 정상적으로 실행됩니다. 그리고 앞서 application.properties에 설정이 정상적이라면 로그인을 진행하고 값을 반환받을 수 있습니다. 프로젝트 실행은 설정 값만 적용한다면 쉽게 진행할 수 있습니다. 이어서 애플 로그인 프로세스와 코드에 대해서 설명드리도록 하겠습니다. [1] 애플 로그인 버튼 페이지 Ref. configuring_your_webpage_for_sign_in_with_apple 위 코드는 "localhost:8080/"으로 접속하면 Sign in with Apple 애플 로그인 버튼이 보이는 화면입니다. 유저가 버튼을 클릭하면 로그인이 진행되는데 이때 메타정보와 유저 아이디, 비밀번호가 애플에게 요청됩니다. | 필드 | 설명 | | --- | --- | | ID | 유저 아이디 | | Password | 유저 비밀번호 | | appleid-signin-client-id | Services ID - Identifier 값 | | appleid-signin-scope | 애플에게 전달받을 유저 정보 - name email | | appleid-signin-redirect-uri | Services ID - Return URLs 값 | | appleid-signin-state | 상태 값 | | appleid-signin-nonce | 임시 값 | [2] 유저 로그인 후 정보 받기 Ref. sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple 정의된 7개의 데이터와 함께 "https://appleid.apple.com/auth/authorize" 호출되고, 애플은 Services ID에 정의된 Return URLs로 JSON 데이터를 반환합니다. 반환받은 JSON 데이터는 "state, code, id_token, user" 4개의 키로 이루어져 있습니다. 여기서 알고 있어야 할 부분은 user 키는 유저가 서비스 최초 가입할 때만 받을 수 있습니다. 또한, 유저는 자신의 email을 공유할 수도 있고, 하지 않을 수도 있습니다. (JSON 데이터는 유저가 email을 공유하지 않은 데이터이며, "code" 키의 값은 5분 동안 유효합니다.) [3] id_token 5가지 유효성 검증 Ref. sign_in_with_apple_rest_api/verifying_a_user Ref. sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature 애플에게 로그인 유저에 대한 정보를 JSON 데이터로 받은 후 "id_token" 값을 decode 하여 "RSA, exp, nonce, iss, aud" 5가지의 검증 절차를 진행합니다. "exp, nonce, iss, aud"의 값은 "id_token" 값을 decode 하면 PAYLOAD 영역에 존재합니다. jwt.io에서 전달받은 JSON을 decode 할 수 있습니다. RSA 검증은 "GET https://appleid.apple.com/auth/keys" 를 호출하여 공개키 리스트를 받은 후 "id_token" 값의 HEADER 영역의 kid와 동일한 공개키 데이터로 서명 확인을 진행합니다. | 키 | 값 | | --- | --- | | exp | id\_token 만료 시간 (10분) | | iss | https://appleid.apple.com | | aud | Services ID - Identifier 값 | | nonce | 생성된 임의 값 | | RSA | Apple에서 제공받은 Public Key | [4] client_secret 생성 Ref. sign_in_with_apple/generate_and_validate_tokens [3]에서 5가지의 검증 절차가 정상적으로 완료되었다면 client_secret을 생성해주도록 합니다. client_secret은 JWT로 생성되며 필요한 값은 아래와 같습니다. | 키 | 값 | | --- | --- | | kid | 애플에서 생성한 Private Key에 대한 Key ID | | alg | ES256 | | iss | App ID 생성에 사용된 Team ID | | iat | 생성 시간 | | exp | 만료 시간 | | aud | https://appleid.apple.com | | sub | Services ID - Identifier 값 | 위의 데이터로 client_secret의 JWT가 생성되었다면, 마지막으로 애플에서 다운로드한 Key 파일 안에 들어있는 Private Key로 서명을 해주면 client_secret이 정상적으로 생성 완료됩니다. [5] 토큰 검증 및 발급 Ref. sign_in_with_apple/generate_and_validate_tokens Ref. sign_in_with_apple/tokenresponse [2]에서 전달받은 code와 [4]에서 생성한 client_secret의 값 그리고 "client_id, grant_type, redirect_uri" 값으로 "POST https://appleid.apple.com/auth/token" 을 호출하여 권한 부여를 위한 토큰 검증을 진행하도록 합니다. ("code"는 5분간 유효한 값이므로 주의하도록 한다.) | 키 | 값 | | --- | --- | | client\_id | Services ID - Identifier 값 | | client\_secret | eyJraWQiOiJWTTJOOFMzN1RSIiwiYWxnIjoiRVMyNT ... 생략 | | code | c3944a20072b7446b97633646556204f8.0.rruy.Gjgud84EqqpCvP31MrudDw | | grant\_type | authorization\_code | | redirect\_uri | Services ID - Return URLs 값 | "POST https://appleid.apple.com/auth/token" 호출이 정상적으로 완료되면 JSON 데이터를 반환받습니다. 반환받은 JSON 데이터에서 "id_token"을 decode 하여 필요한 유저 정보를 얻을 수 있습니다. [6] refresh_token 검증 및 토근 재발급 Ref. sign_in_with_apple/generate_and_validate_tokens Ref. sign_in_with_apple/tokenresponse [5]에서 전달받은 "refresh_token"에 대한 유효성 검증을 하고 싶다면 "client_id, client_secret, grant_type, refresh_token"의 값으로 "POST https://appleid.apple.com/auth/token" 호출하여 검증을 진행합니다. | 키 | 값 | | --- | --- | | client\_id | Services ID - Identifier 값 | | client\_secret | eyJraWQiOiJWTTJOOFMzN1RSIiwiYWxnIjoiRVMyNTYifQ ... 생략 | | grant\_type | refresh\_token | | refresh\_token | r8e88bc9f62bc496398b71117610c5aeb.0.mruy.UuuL5tpwnWaof86XPErqJg | "refresh_token"에 대한 "POST https://appleid.apple.com/auth/token" 호출이 정상적으로 완료되면 JSON 데이터를 반환받습니다. 반환받은 JSON 데이터에서 "id_token"을 decode 하여 필요한 유저 정보를 얻을 수 있습니다. 대략적인 애플 로그인(Sign in with Apple) 연동에 대해서 설명했습니다. 코드와 같이 풀어서 쉽게 설명하려고 했으나 생각보다 더 복잡하고 헷갈릴 수 있을 거 같습니다. Apple Developer Documentation을 참고하며 코드를 보면서 이해하신다면 포스트보다 이해하기가 좀 더 수월하실 거라 생각합니다. Sign in with Apple 흐름은 아래와 같습니다. "Sign in with Apple" 버튼이 있는 애플 로그인 페이지 "https://appleid.apple.com/auth/authorize" 호출 - (유저 로그인) "https://appleid.apple.com/auth/keys" 공개 키 호출 및 검증 - (rsa, exp, nonce, iss, aud) "client_secret" 생성 - (jwt + private key) "https://appleid.apple.com/auth/token" 호출 - (authorization_code) "https://appleid.apple.com/auth/token" 호출 - (refresh_token) 내용 추가 1. 애플 로그인 페이지 추가적으로 앱(App)에 Sign in with Apple 버튼이 존재하는 페이지가 아닌 애플 로그인 페이지 화면을 제공해야 하는 경우에는 "https://appleid.apple.com/auth/authorize"를 redirect 해주면 ID와 Password를 입력하는 화면으로 바로 이동됩니다. 내용 추가 2. 이메일 변경, 서비스 해지, 애플 계정 탈퇴 이벤트가 발생한 경우 유저의 애플 계정에 대한 이벤트가 발생하면 body 안에 payload 키로 jwt 형태의 데이터가 담겨서 "App ID에 등록된 Endpoint URL"로 전송됩니다. payload의 값은 jwt이므로 decode 하면 HEADER와 PAYLOAD 데이터 영역으로 나뉩니다. 유저가 서비스 해지를 한 경우, 전달된 payload의 값을 decode 한 결과입니다.