tailwindcss version 4 에 대한 설정방법은 잘 노출되있지 않기에 여기에 기록한다. 주소 프로젝트 루트 경로 기준 node -v npm install tailwindcss @tailwindcss/cli npx @tailwindcss/cli -i ./src/main/resources/static/input.css -o ./src/main/resources/static/output.css --watch <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link href="/output.css" rel="stylesheet"> </head> package.json { "scripts": { "build": "npx @tailwindcss/cli -i ./src/main/resources/static/input.css -o ./src/main/resources/static/output.css --minify", "watch": "npx @tailwindcss/cli --watch -i ./src/main/resources/static/input.css -o ./src/main/resources/static/output.css --minify" }, "dependencies": { "@tailwindcss/cli": "^4.1.7", "tailwindcss": "^4.1.7" } } 참고 Tailwind v4 + Java 템플릿 엔진 및 Spring Boot tailwind-cli
plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" id("org.springframework.boot") version "3.4.5" id("io.spring.dependency-management") version "1.1.7" id("org.asciidoctor.jvm.convert") version "3.3.2" id("com.epages.restdocs-api-spec") version "0.19.4" id("org.hidetake.swagger.generator") version "2.19.2" kotlin("plugin.jpa") version "1.9.25" } extra["snippetsDir"] = file("build/generated-snippets") openapi3 { val local = closureOf<Server> { url("http://localhost:8080") description("Local Development Server") } as Closure<Server> val dev = closureOf<Server> { url("https://dev-api.freeapp.me") description("dev Development Server") } as Closure<Server> setServers(listOf(local, dev)) title = "API 문서" description = "RestDocsWithSwagger Docs" version = "0.0.1" format = "yaml" //outputDirectory = "build/resources/main/static/docs" } swaggerSources { create("api") { setInputFile(layout.buildDirectory.file("api-spec/openapi3.yaml").get().asFile) } } dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") testImplementation("org.springframework.security:spring-security-test") testImplementation("com.epages:restdocs-api-spec-mockmvc:0.19.4") testImplementation("org.instancio:instancio-junit:5.4.1") // Swagger UI swaggerUI("org.webjars:swagger-ui:5.21.0") } tasks.withType<Test> { useJUnitPlatform() } tasks.test { outputs.dir(project.extra["snippetsDir"]!!) } tasks.asciidoctor { inputs.dir(project.extra["snippetsDir"]!!) dependsOn(tasks.test) } tasks.bootJar { dependsOn(tasks.getByName("generateSwaggerUIApi")) from("${tasks.getByName<GenerateSwaggerUI>("generateSwaggerUIApi").outputDir}") { into("static/docs/") } } tasks.withType<GenerateSwaggerUI> { dependsOn("openapi3") inputFile = layout.buildDirectory.file("api-spec/openapi3.yaml").get().asFile options.set("docExpansion", "none") } 주소 multipart-form-data-support-issue multipart-form-data는 아직 지원을 안 한다. 따라서, 임시로 description 편의 생성 함수를 만듬 fun formatMultipartFormDescription( title: String = "## Multipart Form Data 필드", fields: List<Triple<String, String, Boolean>> // 필드명, 설명, 필수 여부 ): String { val sb = StringBuilder() sb.appendLine(title) sb.appendLine() // 테이블 헤더 sb.appendLine("| 필드명 | 설명 | 필수 여부 |") sb.appendLine("|--------|------|----------|") // 필드 정보 fields.forEach { (name, description, required) -> val requiredText = if (required) "필수" else "선택" sb.appendLine("| `$name` | $description | $requiredText |") } sb.appendLine() sb.appendLine("> ⚠️ Swagger UI에서는 multipart form data 전송이 제대로 되지 않으므로 여기에 표시") return sb.toString() } OpenAPI 3.0 스펙에서 null은 유효한 타입이 아님 참고 Spring Rest Docs 와 Swagger-UI 연동 [Spring] restdocs + swagger 같이 사용하기 Swagger와 Spring rest docs, 두마리 토끼 잡기! Spring - REST Docs 적용 및 최적화하기 Spring boot | Restdocs-api-spec with Swagger, Docker 완전 정복 하기 Spring Rest Docs로 행복해지는 간단한 방법 Spring RestDocs 개선기(2) - 리플렉션을 이용한 Enum 문서 작성 자동화 OpenAPI Specification을 이용한 더욱 효과적인 API 문서화
[Android/Compose] 새로 추가된 Navigation3에 대해 알아보기 Migrating from XML to Compose 안드로이드 개발자를 위한 Kotlin 2.0의 주요 변경사항 10 Expert Jetpack Compose Techniques That Boosted My Productivity (with Code Examples) Compose를 사용한 탐색 jetpack-navigation-3-alpha Best Practices for Composition Patterns in Jetpack Compose [DroidKnights 2023] 이상훈 - 기존 앱을 Jetpack Compose로 마이그레이션 하기 Implementing Compose Hot Reload | Sebastian Sellmair compose desktop jetbrains theme Room KMP로 기존 앱 이전 Room (Kotlin Multiplatform) KMP 개발을 위한 알아두면 좋은 라이브러리 소개 / DI 프레임워크 찍먹하기 Coil and Ktor in Kotlin Multiplatform Compose project Integrate custom fonts in Kotlin/Compose Multiplatform Jetpack-Compose-Top-20-mistakes-6-10 Handling Lifecycle in Jetpack Compose: A Complete Guide for Android Developers
<script> let cctvs = "<%= cctvs %>"; console.log("cctvs1", cctvs); cctvs = cctvs.replace(/"/gi, '"'); console.log("cctvs2", cctvs); cctvs = JSON.parse(cctvs); console.log("cctvs3", cctvs); </script> node.js 짬처리 과정.. 오브젝트 array를 ejs에서 받는 방법. 자바스크립트에서는 replaceAll 함수가 없드라.. 정규표현식을 활용했다.
별 거 없다. 까먹지 않도록 간단히 기록해본다. 1. 내 로컬 pc에 ollama 설치한다. 나는 mac이라 brew로 설치했는데 뭐 어떤 방식으로 설치하든 상관없다. ollama란 대충 로컬에서 ai 모델을 편리하게 실행 관리하게 해주는 tool? 같은 거라 생각하면 된다. 2. 설치했으면 실행해주고 deepseek 모델을 다운받는다. deep seek 모델은 https://ollama.com/library/deepseek-r1 대충 여기서 보고 고르면 된다. 나는 제일 가벼운 거 다운받았다. ollama serve > /dev/null 2>&1 & # 백그라운드로 실행 ollama run deepseek-r1:1.5b 잘 작동하는 걸 확인할 수 있다. ollama 프로세스는 기본 포트가 11434로 배정되며 보시다시피 어떤 엔드포인트로 질의했는지도 확인할 수 있다. postman으로 확인해보면 모델명 모르겠으면 ollama list 로 확인해보자. 3. 이제 springboot 와 통합해보자. 직접 http 요청을 통해 통신할 수도 있겠지만, 최신 springboot feature들을 보면 이미 ai와 쉽게 통합시킬 수 있는 공식 dependency를 제공해주는 걸 알 수 있다. 기본 springboot 셋팅 같은 거 다 생략하겠다. 어차피 알고있는 것일 테니까, 딴 거 다 필요없이 ollama 관련된 거는 아래와 같다. 필자는 gradle 사용했다. extra["springAiVersion"] = "1.0.0-M5" dependencyManagement { imports { mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") } } implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter") 간단히 컨트롤러 만들고 통신하는 엔드포인트 함수 생성해주자. chatclient 같은 경우 spring-ai 가 제공해주는 클래스인데 코드로 설정정보를 기입할 수도 있겠지만, 나는 yml 파일을 읽어드리는 식으로 정의했다. 테스트해보면 세상 참 편리해졌다. ㅎ
의존관계의 최소화 의존관계가 최소화 되어야 한다는 것은 무슨 의미인가.. 넓게 보면 시스템 아키텍처와 더 나아가서도 생각해 볼 수 있지만, 범위를 한정짓자. 어쨌건 나는 기술자니까, 디테일하게 얘기해보아야 한다. 예시는 SpringBoot + JPA Project로 상정하겠다. 범용적인 토크를 하고자 한다면 관련 예시로는 한계가 있으나, 구체적인 예를 하나 선정해야지만 이해가 더 잘 된다. 나는 선택하자면 후자를 선택하겠다. 코어 의존성은 코어끼리만 의존하도록 예를 들어 다음과 같은 상황이 있다고 치자 보시다시피 UserVerify Entity Class의 인스턴스를 생성하는데 Token이 필요하다. 만약 이렇게 UserVerify 인스턴스를 만드는 코드가 이것 말고도 여지저기 중복되어 있다면 어떻게 할까? 한가지 아이디어는 이 Token을 만드는 코드를 정적 메서드 함수로 따로 빼놓고 재사용하는 것이다. 이렇게 되면, createUserVerify 를 호출하는 여러 군데에서 토큰을 만드는 중복 코드를 엔티티 클래스 한 군데로 몰아넣을 수 있다. 중복을 제거한다는 목적에서 만족스러운 리팩토링이라고 생각이 든다. 그러나, 과연 손실은 없을까? 엔티티가 다른 클래스에 대해 의존하게 된다. 한가지 걱정해야 되는 문제는, 엔티티 클래스가 유틸리티 클래스에 대해 의존을 한다는 것이다. 엔티티 클래스는 일반적으로 서버 로직에서 코어에 위치하게 된다. 이 말인 즉슨 가장 깊숙한 곳에 위치해 있다는 것이며, 모듈을 하나의 모듈로 한정짓지 않고, 여러개의 모듈로 넓혀나갔을 때 다른 모듈들도 같이 이 엔티티를 사용할 가능성이 높다는 뜻이다. 만약에 프로젝트 규모가 커져서 서버를 쪼갠다고 했을 때 떨어져나간 다른 모듈도 이 엔티티를 사용하고 싶다면, TokenUtil이라는 오브젝트도 필요하게 된다. 하지만 떨어져나간 이 모듈은 TokenUtil의 로직이 필요가 없다. 그렇다면 우리는 그 모듈에서 저 엔티티 클래스를 고대로 갖고 오지 못하고 TokenUtil에 관련 코드들을 지워버리거나 같이 가져와야 된다. 코어 의존성에 다른 부가적인 의존성들이 덕지덕지 붙어나가기 시작하면, 모듈은 분리시키기 점점 더 어려워지고 복잡해진다. 정적 유틸리티 클래스는 편하기에 남용될 여지가 있다. DTO <=> Entity 변환 로직은 DTO에게 담당 같은 맥락에서 얘기를 하자. Spring MVC와 JPA를 사용해서 DATA 지향 API 를 만든다 치면, 보통 Controller <=> Service <=> Repository로 이어지는 흐름을 타게 된다. 이 과정에서 DTO를 주고 받으며, DTO를 Entity로 또는 Entity를 Dto로 변환하게 된다. 꼭 이렇게 해야지만 한다는 법은 없지만, 일반적인 경우에서, 실보다 득이 더 많은 방법이라 판단되기에 대부분 따르는 관습이다. (이렇게 할 시 별도의 Mapper Layer를 만들어 담당하게 하는 경우도 있으나 나는 선호하는 패턴은 아니다.) 그리고 아래와 같이 이렇게 DTO로 변환하는 로직이 Entity Class에 위임되어 있는 경우가 있다. 객체지향적인 관점에서 변화를 하는 주체가 Entity니까 어찌보면 맞는 방향일 수 도 있다. 그러나 Entity Class 가 Dto Class에 대해 의존하게 되는 문제가 발생한다. 따라서 아래와 같이 변경한다. 나는 Entity Layer 에는 필수적인 의존성들만 포함시키고 나머지는 바깥으로 밀어내는 전략을 선호한다. Entity에 존재하는 비즈니스 로직은 자기 스스로에 대한 상태변화만을 의미하게끔 한다. 만약 UpdateDto의 필드가 너무 많을 때 Entity Update 시간 나면 적겠다.. 응답형식 설계 일관성 있는 Response를 보장한다면, 클라이언트 측에서도 일관성 있는 로직을 작성하기 쉬워진다. 공통 DTO는 그러한 맥락에서 설계한다. 매번 달라지는 필요한 데이터는 제네릭 타입의 필드로 제한해두면 된다. 이렇게 하면, 클라이언트는 API 호출시 필요한 데이터를 매번 data 라는 이름으로 접근할 수 있다는 사실을 알게 될 거고, 이로 인해 일관성 있는 코드를 작성하는 데 도움을 줄 수 있다. 나는 하나의 API가 성공시 응답코드는 항상 동일한 하나의 결과를 반환받는 걸 선호하는 편이라 이렇게 성공시 응답코드를 2개를 나눈 것에 있어서 좋아하는 설계는 아니다. 하지만 드문 케이스에서 위의 ResultCode 처럼 하나의 API에 대해 성공케이스를 여러개로 나누는 것이 유효한 방법이 될 수 있다. Controller Layer에서는 반환값을 : SuccessResponse<*> 로 통일하자. 마찬가지로 응답 DTO에 대한 의존성을 줄이려는 목적에 있으며 서비스 레이어에서 반환하는 리턴값이 달라졌을 때, 컨트롤러 레이어까지 미치는 영향력을 최소화하기 위해서이다. Exception 도 동일하게 응답형식을 일관성 있게 작성하도록 하자. 의존성을 최소화하는 게 꼭 더 나은 방향일까? 이거에 대한 대답은 항상 문맥에 따라 달라진다는 게 답이다. 의존성의 최소화는 유연한 설계, 확장에 강점이 있지만 다른 한편으로는 코드를 다소 이해하기 어렵게 만들고 어떤 경우에는 생산성을 저하시키는 원인이 되기도 한다. JPA Entity Setter 코틀린으로 JPA Entity를 설계할 때 가장 골치아픈 게 setter 에 대한 문제다. 이러한 문제점은 아래 링크에서 자세히 설명하고 있다. https://multifrontgarden.tistory.com/272 나 같은 경우는 정말 민감한 필드의 경우, protected set 으로 막아놓고, 나머지는 Setter를 그냥 열어두고 최대한 안 쓰는 방향으로 작업한다. 특정 경우에는 DTO 보다 Map이 더 나은 대안 대부분의 경우 요청 파라미터로 Map을 받는 경우는 없어야 한다. Map을 받게 되면, 해당 API가 요청 값으로 무엇을 받고 있는지 코드를 직접 까보면서 파악해야 되고, DTO 단에서 미리 설정할 수 있는 여러가지 Validation도 서비스 레이어로 내려오게 된다. 하지만 특수한 몇몇 케이스에서는 DTO보다 유용하게 쓰일 수 있는데 가령 다음과 같은 경우다. 내가 제어할 수 없는 외부 API를 가져다 쓰는데 그 API를 신뢰할 수가 없는 경우. lateinit, Lazy 사용법 팁 간혹 가다, 초기값을 늦게 주고 싶은 변수들을 lateinit 키워드로 할당하는 경우가 있다. 이럴 경우, 정말 할당이 되었는지 체크를 하고 싶을 경우가 있을 수 있을 텐데 다음과 같이 사용하면 된다. 불변값으로 늦게 할당하고 싶을 경우, Repository Layer에서 비즈니스 로직의 분리 시간 나면 적겠다.. NON-Nullable 타입을 보장받고 싶을 때 시간 나면 적겠다.. Event Publisher 를 고려 시간 나면 적겠다.. Transaction 범위 선정 일반적인 경우의 어플리케이션 서버에서 대부분의 병목지점은 I/O 작업들에서 나타난다. 그리고 I/O 작업의 대표적인 케이스가 데이터베이스 쿼리일 것이다. 고로 DB에서 일어나는 병목현상만 최소화해도 성능개선이 유의미하게 일어나는 뜻. 여기에는 단순 쿼리튜닝만 있는 게 아니라 어플리케이션 레벨에서도 생각보다 많이 고칠 구석이 있다. JPA를 사용하다보면 아마 대부분 아래와 같은 에러를 맞닥뜨렸을 거라고 생각한다. Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection DB Connection Pool 이 고갈되서, transaction을 시작할 수 없다는 의미이다. 무식한 방법은 Connection Pool을 단순 늘릴 수 있겠지만, 좀 더 디테일하게 들어가서 트랜잭션의 범위에 대해서도 고민해볼 필요가 있다. 트랜잭션이 시작되는 메소드를 가만히 뜯어보면, 그 안에서 트랜잭션이 필요없거나, 하나의 트랜잭션으로 묶일 필요가 없는 로직들이 들어가 있는 경우가 있다. 그런 부분들은 별도로 분리해서 트랜잭션 범위를 최소화 할 수 있는 만큼 적용해준다. 나는 이럴 경우 별도의 함수로 분리하고, @TransactionalEventListener 로 다루는 걸 선호하는 편이다. 비동기 함수 호출 시 주의사항 시간 나면 적겠다.. 결론 소프트웨어 디자인에서 정해진 답은 없다. 각자의 맥락 속에서 더 유효한 방법만이 존재할 뿐이다. 그래도 내가 얘기하고자 하는 것은 일반적인 문맥에서 내가 지키고자 하는 원칙 같은 것이다.
멘탈이슈로 회사에 얘기해 당분간 일을 그만두고 프리랜서로서 작업을 하고 싶다고 얘기했다. 그래서 그동안의 회고를 짤막하게 기록할려고 한다. 첫 직장은 SI 외주 회사였다. 흔히 낮추어 말하는 좆소라는 단어의 대표적인 케이스로 딱 알맞는 것 같다. 임금체불, 잦은 야근, 열악한 근무환경, 가족 경영 회사, 사실상 인수인계 과정 X, 사수 X, 중구난방 업무체계와 같은 대한민국 IT 소기업의 극단을 달리는 회사 중 하나였다. 나는 거기서 자바 + 스프링 기반의 벡엔드 개발자로 입사하였지만, 정말 다양한 업무를 경험하게 되었다. xml 기반의 스프링 레거시 + Mybatis + JSP + Jquery로 이루어진 오래된 웹 사이트 유지보수 및 기능 추가부터, Express.js + React.js + MobX로 이루어진 다소 힙한 사이트 유지보수, IOS 웹뷰 기능 추가 및 HTTP 레이어가 아닌 TCP 단위의 소켓 프로그래밍을 통해서, 파이썬 QT로 만들어진, IOT와 통신하는 컨트롤러 제작 및, 역시 소켓 프로그래밍을 활용한 로우레벨 전기자전거 컨트롤러를 스프링으로 구현하거나 등등.. JSON이 아닌 생전 처음 보는 비트 단위 프로토콜을 따라서 데이터를 구현해야 되는데 영어로 된 PDF 문서가 전부였다. 단순 웹 API 개발도 많이 했지만, 중소 외주 회사답게 다양한 일을 떠안아 왔고, 거기서 어떻게든 길을 찾아내서 개발을 완료하는 게 내 목적이었다. 여기서 일을 할 때 가장 큰 스트레스는, 중구난방인 개발업무도 있었지만, 내가 유지보수해야할 프로젝트들의 코드 퀄리티였다. SI 개발은 필연적으로 코드 퀄리티가 낮아질 수 밖에 없다. 일단 어떻게든 개발을 완료하고 떠 넘기면 유지보수는 남의 일이 된다. 개발 담당자는 그 사이 다른 곳으로 이직하면 끝이다. 그래서 정말 상상도 하지 못할 코드베이스들을 많이 발견하게 된다. 수백줄이 넘는 쿼리문이라든가, 32중 if 문 중첩이라든가, 모든 파라미터를 HashMap으로 받고 HashMap으로 리턴해주든가 등등.. 물론 같은 개발자로서 화도 나지만, 이해도 간다. 중간에 누가 남기고 간 똥덩어리를 맡아서 한다면, 대충 휴지조각으로 덮고 다른 데 Run할 생각을 하지. 내가 사서 고생을 하며 똥덩어리를 치울 생각은 안 하니까. 나조차도 화를 내면서 대충 치울 생각을 하지. 이걸 갱생시켜서 다시 만들 생각은 안 한다. 그래서 오래된 코드베이스일 수록 임시방편 코드들이 쌓여가며 누적떼기가 되고, 나중에는 더 이상 돌이킬 수 없는 지경으로 빠지는 거다. 필자는 이런 환경에서 업무를 하면서 한가지 생각이 확고해졌다. 자체 솔루션 회사로 들어가자. 거기를 가면 더 이상 누적떼기 같은 코드베이스를 안 보아도 되고, 설령 보게 되더라도 그 프로젝트만 보면 되니까, 리팩토링도 즐겁게 시도할 수 있을 것 같았다. 그럼 그런 회사로 쉽게 가려면? 답은 스타트업이었다. 크든 작든 상관없었다. 오히려 작으면 내 권한이 세지므로 더 나쁘지 않겠다는 생각까지 했다. 1년간의 개발 경력은 경력이라 치기에는 짧지만, 그동안 산전수전을 다 겪었다고 생각했다. 나름 자신이 있었다. 그래서 친구의 추천으로 스타트업에 들어가게 되었다. 개발 상주 인원 4명정도의 전 직장보다도 개발팀 규모가 더 작은 회사였다. 그 중 벡엔드 개발자는 나와 다른 한 명이었다. 작은 규모였지만 나름대로 최소한의 소프트웨어 인프라는 갖춰져 있었다. 개인 계정 깃허브를 돌려쓰면서 코드를 공유하고 있었고, 정기적인 회의와 지라를 통해 업무내역을 공유하고 있었다. 서버 인프라는 AWS 클라우드 위에 조그맣게 갖춰져 있었다. 거기서 보게 된 벡엔드 개발자도 역시 나와 비슷한 연차의 주니어 개발자였다. Node.js + TypeScript 기반의 벡엔드 개발스택을 보유한 친구였는데 사람 참 좋은 친구였다. 다만 코드적으로는 아쉬운 부분이 많았는데, 일단 비대한 함수들이 많았고, 중복된 코드들도 상당히 많이 존재했다. 클래스를 쓰면서 클래스를 활용하지 않고, 리터럴 객체와 클래스 객체의 차이점을 구분하지 않고 있으며 적극적인 조인과 인덱스를 통한 쿼리 최적화가 안 되어있고, 여러개의 쿼리를 나눠서 호출하는 등 비효율적으로 짜져있었다. 마음같아서는 내가 다 갈아엎고 새로 API를 짜고 싶었지만, 그동안 이 친구가 1년동안 해온 게 있는데 존중해야 된다고 생각했다. 더군다나 나와 다른 기술스택을 가지고 있는지라 함부로 뭐라 말도 못 꺼내겠고, AWS 인프라는 나도 여기 직장에서 처음 실무로 접하는 거라, 배워야 될 부분도 있겠다 싶었다. 그래서 나는 어드민 같은 서브 루틴으로 빠지고, 이 친구를 보좌하면서, 새로운 프로젝트는 내 담당으로 아예 분리를 해서 개발을 진행하는 게 마음 편하다고 생각했다. 처음 ADMIN을 분리하고 개발을 진행하면서 답답한 부분이 많았다. 서로 개발계 데이터베이스를 공유하면서, 작업을 진행하는데, 상대방 쪽이 DB 쪽 칼럼 구조나 호스트 주소를 바꾸면 내 쪽에서 에러가 터지는 거다. 아무래도 이건 나와 상대방 모두 협업에 대한 개념이 약하다 보니, 서로 명확히 정보를 공유하고 진행해야 되는 부분도 생략하는 부분도 있기에 발생하는 문제였다. 마음 같아서는 DB 테이블 구조나 이런 것 들 내가 다 새로 짜고 싶은데, 선을 넘는 행위라는 생각이 들었다. 상대방의 코드베이스가 마음에 안 들어도, 최대한 맞춰가면서 진행하기로 마음을 먹었다. 내 쪽 어드민 대시보드는 당시엔 프론트 인원도 배정되어있지 않아서, 앞으로 올 프론트 개발자를 위해 React로 내가 작업을 했다. 지금 생각하면 후회스럽다. 그냥 내가 혼자 개발하기 편한 타임리프 + SpringBoot 조합으로 전적으로 개발을 하는 게 맞았다. 익숙치 않은 react 를 조합해서 쓰는라 생산성도 낮아지고, 나중에는 담당 개발자가 결국 배정되지 않아서 아예 프론트엔드를 다른 프레임워크로 새로 만들게 되었으니. 그러다가 회사의 신규 프로덕트를 담당으로 맡게 되었다. 기존 프로덕트와 완전히 분리된 새로운 프로덕트이고, 그래서 이 작업의 벡엔드를 먼저 맡아서 진행하기로 했다. 신규 프로덕트에는 코프링 + JPA 조합을 사용하기로 결정했다. 둘 다 예전부터 쭉 사용해보고 싶은 기술스택이었는데, 전 직장에서는 여러가지 요건 상 실무에 적용시킬 수 없었는데, 여기서는 내 재량껏 선택할 수 있었다. 사실 요런 점들이 소규모 스타트업 개발자로서의 장점이라고 할 수도 있다. 일을 열심히 했다. 아무래도 온전히 내 담당으로 개발을 진행하다보니 애정이 안 생길 수가 없다. 클라우드 인프라 시스템도 루트계정부터 기존 프로덕트와 분리해서 새로 만들어 제로부터 구축하였다. 삽질이 많았다. 당시에는 ChatGpt 도 없어서, 관련 키워드를 알아낼 수단도 변변치 않았다. 열심히 구글링하면서 맨땅에 헤딩하는 식으로 부딪치고 알아보는 법 밖에 없었다. 맨땅에 헤딩하는 것은 사실 내 주특기이기도 하고, 익숙하기 때문에 별 스트레스 거리는 아니었다. 초기, 업무에서 오는 스트레스는 개발에서 오는 스트레스는 아니었다. 내가 담당한 프로덕트가 별 조명을 받지 못하고, 방치된 느낌이라서 오는 스트레스가 컸다. 회사입장에서 별로 마케팅을 태우고 싶지 않은 서비스 하나를 대충 하나 잡아서 나한테 던져준 느낌이랄까? 그래도 내가 온전히 맡아서 진행할 프로젝트라 생각하기 때문에, 혼자서라도 열심히 해보려 했다. 시간이 한참 지나고 나서야 프론트엔드 개발자도 따로 배정이 되고, 동료개발자가 퇴사를 함으로서 내가 맡은 프로덕트가 회사의 주요 서비스로 떠올랐다. 그렇게 되기까지 나름 2년동안 열심히 재밌게 개발했다고 생각한다. 그 과정에서 격은 개발과 관련된 삽질과 개선과정은 따로 생각나는 것들만 글로 정리해서 올려보도록 하겠다. 그럼 왜 퇴사를 결심했느냐? 어느 순간부터 장작이 다 타버린듯이 내게 일을 지속할 동기도 의욕도 사라져버렸다고 느꼈기 때문이다. 타성적으로 일을 하고, 회사에서 시간만 때우다 집에 빨리 가길 바란다. 어느 순간부터는 회사에 있는 순간이 회사에게도 나에게도 시간낭비라는 느낌이 강해졌다. 스스로 생각해보았다. 무엇 때문에 열정이 다 식어버렸을까? 돌이켜보면 몇가지 계기는 분명히 있다. 업무간 소통에 대한 답답함 회사의 초장기부터 함께한 인원으로서 대표님의 소통방식에 대해서는 늘 답답한 면이 있었다. 상주 인원 20명이 채 안 되는 소규모 회사에서 뭐가 그리 비밀이 많고 숨겨야 될 게 많은지. 주에 한 번씩 팀 대표들끼리 정기 회의를 통해 대표님이 일정을 보고받는 식으로 진행되었는데, 그 과정에서 무슨 얘기가 오갔는지 파악할 길이 없다. 기획에 대해서도 들리는 얘기가 이랬다, 저랬다 해서 내가 어느 장단에 맞춰서 개발을 해야될지 분간이 채 안 간다. 개인적으로 스타트업의 장점이라고 하면, 직접적인 업무 소통과 그로 인한 빠른 수행능력이라고 생각하는데, 어째 하는 꼴은 점점 그 반대를 추구하는 것 같아서 영 불만이었다. 단독진입적으로 무엇을 원하고 어디까지 됐고 어느정도를 생각하고 있는지 다이렉트로 나한테 소통했으면 하는데, 이렇게 작은 규모의 회사가 꼭 중간관리자를 하나두고 보고를 받는 식으로 할려고 굳이 굳이 할려는 이유를 납득을 못하겠다. 어차피 일은 내가 하고, 업무도 내가 가장 잘 아는데 정보를 알려면 내가 가장 많이 알아야 되고, 내가 판단해야 된다. 그래야 대응이 된다. 근데 이건 내 잘못도 있다. 내가 원래 그렇게 소통을 즐기는 스타일이 아니고, 여러번 회사에 실망을 하다 보니, 일부러 거리를 스스로 둔 셈이기도 하다. 채용방식에 대한 스트레스 채용방식에 대한 불만도 늘 존재했다. 예전부터 회사는 계속해서 개발자를 추가모집하려고 했다. 그런데 그 과정에서 왜 채용공고를 안 내는지 모르겠다. 공개된 이력서를 보고 스카우트하는 식으로 진행을 하는데, 스카웃을 누가 담당할까. 대표님은 개발과는 무관한 사람이고, 현재 회사에 벡엔드 개발자 포지션을 뽑는다면 자문을 구할 사람이 나랑 동료 개발자 2명만이 전부다. 그러면 우리가 이력서를 계속 보면서 검증을 해야하는데, 공개 이력서만 가지고는 감도 안 잡히고, 업무도 바쁜데 이거 때문에 업무에 대해서 지장이 생기고 스트레스만 더 받게 된다. 차라리 공고를 내면, 회사에 필요한 기술스택을 적고 원하는 사람이 우리 회사에 지원을 하면 면접을 다 같이 보는 식으로 하면 서로가 다 좋을텐데, 죽어도 그렇게 안 한다. 사실 채용과정 프로세스에 대해서는 내 권한이 아니기 때문에 가타부타 아무 말도 안 했지만, 속으로는 불만이 쌓여갔다. 업무 지원에 대한 열악함 예를 들어, 프로덕트가 글로벌 서비스이면 해외 결제 모듈을 테스트해야 된다. 그래서 요구사항으로 국내에서도 해외결제가 가능한 테스트 카드를 하나 발급해달라 요청을 하면, 무조건 지원해준단다. 사실은 회사 측에서도 방법이 없다. 그냥 해외에 있는 지인들에게 연락해서 그때 그때마다 결제 해달라고 요청하면서 확인하는 수 밖에 없다. 만약 프로덕트를 중국에 진출하고 싶다. 그러면 중국 쪽 법인과 관련해서 여러가지 계약과 행정적 절차가 필요한데, 그런 게 아무것도 없이 그냥 중국에 서비스할 프로덕트를 만들어달라는 식이다. 결국은 스스로 방법을 찾아야 된다. 이 과정이 재밌긴 하지만 점점 지쳐간다. 초기에 아무것도 없이 구축하다보니, 클라우드 비용부터 해서 어떻게 아키텍처를 짜고 비용을 절감할 것인지 스스로 고민해보고 설정해야 한다. MSP 계약도 없던 시절이라, MSP 계약 및 행정적인 절차, 구글 프로덕션 심사, 깃허브 스타트업 엔터프라이즈 플랜 신청, 슬랙 스타트업 할인혜택 신청 등 스스로 알아보고 비용을 절감할 수 있으면 신청하고 최대한 혜택을 받도록 노력했다. 열정이 있는 시기에는 이 모든 게 재밌는 과정이었지만, 열정이 식어가자 점점 더 귀찮고 짜증나는 과정으로 변모했다. 비즈니스 모델에 대한 의구심 현재 프로덕트가 관련되어있는 도메인이 내가 전혀 관심도 없었고, 알지도 못했던 도메인이였고 이로 인해 어떤 수요층이 있을까 하는 의구심이 늘 있었다. 그런 것과 상관없이 내가 하고싶은 기술스택으로 개발하는 게 좋았기 때문에, 쭉 진행하는 것이었지만, 근본적으로 관심이 없는 도메인이었기에 가지는 열정의 한계와, 매출에 대한 압박이 있었다. 나는 뭔가 효용이 발생해야 개발에 재미가 붙는데, 내 쪽 프로덕트는 가입자수는 만명이 넘지만, 유료구독 유저는 100명이 채 안 된다. 매달 발생하는 서버비용을 챙기기에도 모자라며, 회사는 매출과 상관없이 투자금과 정부과제산업으로 연명을 하고 있는데, 그러다 보니 실제 프로덕트보다 그 쪽 관련 업무 쪽으로 관심이 집중될 수 밖에 없다. 슬슬 떠나야 하는 타이밍이라고 느낀다. 점점 사라지는 자유도 그럼에도 불구하고 회사의 장점들이 확실히 있었다. 일단 대표님이 나를 믿고 존중해준다는 느낌이 있었고, 실제로 내 쪽 편의를 많이 봐주기도 했다. 유연한 출퇴근제와, 하고싶은 개발쪽 업무나, 일들이 있을 때 비용적인 면에서 믿고 나한테 맡겨주었다. 그래서 더 열심히 일을 하기도 했다. 나쁘지 않은 업무 환경이었다. 다른 잡무를 시키지도 않고, 개발 쪽 업무에 집중할 수 있도록 세팅해주었다. 대표님에 대해서는 안 좋은 생각도 있지만 고마운 점도 많이 있다. 하지만 점점 이런 자유도가 사라지고 눈치를 준다는 느낌이 들었다. 문제는 회사가 성장하면서 자유도를 낮추는 건 좋은데, 합리적인 이유가 아니라는 생각이 들어서다. 이건 다음 이유에서 나오는데.. 좋은 동료에 대한 갈망 사실 가장 큰 이유는 여기에 있다. 새로 뽑은 시니어 개발자가 자꾸 되도 않는 얕은 수작을 부린다는 인상이 드니까 일을 하는 데 있어서 현타가 온다. 내가 제일 싫어하는 유형의 사람이 모르는데 아는 척 같은 말 두 번, 세 번 반복하게 하는 것 대화를 제대로 듣지 않는 사람 + 쓸데없는 고집 및 기싸움 갑자기 감정적 급발진 팀장 직으로 왔으면, 먼저 팀원들에게서부터 신뢰를 얻고 통제권을 가져야 되는데, 그런 거 없이 팀장 노릇부터 할려고 하니, 그 꼬라지가 보기가 힘들다. 가만히 있으면 알아서 모셔줄텐데, 자꾸 뭐해볼려고 불필요한 절차를 만들려고 한다. 무엇보다 침착하지 않고 안절부절 어쩔 줄 모른다. 속으로는 그럴지라도 상급자라면 겉으로는 내색을 하면 안 되는데, 그게 전부 느껴지니까, 짜증이 치솟는다. 도움도 안 되는데 이런 감정적인 스트레스까지 받아줘야 되나? 회사도 이해가 안 간다. 경력이 십수년이 넘은 개발자들을 찾아서, 중요한 담당자를 뽑는 자리에 검증절차가 없이 대충 아무나 뽑고 자리에 앉힐려고 하는 느낌이 있다. 뭐 상관은 없다. 어차피 개발은 혼자 하는 거고, 원래도 내가 도움은 안 바래는 성격이다. 근데 일을 하는데 있어서 방해는 안 되야 되는 게 아닌가 하는 생각이 든다. 뭐가 중요한지 판단이 안 되는 건지, 아니면 기싸움을 할려는 건지. 소감 그래도 좋은 경험이었다. 돈 받은 만큼 할만큼 했다고 본다. 다음에는 나도 좀 더 성숙해져서 동료들과 일을 해야된다. 이직 준비를 해야 되는데 경기도 불황이고, 알고리즘도 하도 연습을 안 하다보니, 거의 백지 상태로 초기화되었다. 조금 채우는 시간이 필요할 것 같다.
최근 프로젝트 아키텍처를 변경하였다. 기존에는 어떤 단일 요청이 발생하였을 떄, 하나의 단일 서버에서 모든 과정을 순차적으로 관제하고 트랜잭션을 마감했다면, 바뀐 구조에서는 서버를 쪼개서 작업과정을 분산시켰다. 이로 인해 여러가지 고려해야 될 사항이 생겼는데, 그 과정을 공유해보자. 기존 작업 흐름 기존에는 클라이언트로부터 요청이 오면, 요청 스레드가 아닌 다른 스레드에게 작업을 넘기고 즉시 응답을 시켰다. 넘겨받은 스레드에서는 해당 작업이 순차적으로 이어졌으며, 최대 9번의 외부 API/SDK 호출과, 최대 4번의 파일 I/O 작업, 최대 5번의 DB I/O 작업을 하나의 트랜잭션으로 관리하고 있었다. 단일 요청에 대한 함수로 꽤나 부하가 걸리는 흐름이므로, 새로 바뀐 구조에서는 이 작업과정을 쪼개서 여러 대의 서버에 분산처리를 시킴과 동시에 병렬구조로 실행하는 걸로 방향성을 잡았다. 각 작업에 대한 수행을 비동기로 개별 서버에게 분산시키고, 각자 할당된 작업이 끝나면 DB에 관련결과를 업데이트한다. DB 해당 칼럼의 STEP이 일정단계를 초과했을 경우 상태를 완료처리한 걸로 간주한다. JPA Dirty Checking X 해당 함수마다 각자의 스텝이 끝나면 DB에 상태를 반영시키도록 해야했는데 기존에는 JPA 의 변경감지 기능을 적극 사용했다. 다만 분산 서버 환경에서는 이 기능을 사용하기가 몹시 까다로워졌다. 각각의 서버마다 트랜잭션이 다르고, 병렬로 수행하다 보니, DB에서 불러온 엔티티의 필드가, 동시에 다른 트랜잭션 내에서 불러온 Entity의 필드를 업데이트했으면 반영하지 못하는 경우가 발생하는 것. 따라서 썡쿼리를 날리는 걸로 쇼부봤다. fun increaseStep(id: Long): Int { val sql = """ update 테이블 set sequence = sequence + 1, status = case WHEN sequence >= ${finalSequence} THEN ? ELSE status end where id = ? """.trimIndent() return jdbcTemplate.update( sql, Status.SUCCESS.name, id ) } 대충 이런 식으로 쿼리를 해당 함수가 끝날때마다 호출하는 식으로 작업했다. 쌩쿼리를 날리는 게 영 찝찝하다면, QueryDsl을 사용할 수 도 있다. 그래도 타입안정성은 챙겨갈 수 있다. 아니다. QueryDsl을 쓰면 에러가 발생한다. 이거 나중에 정리해보겠다.. val caseBuilder = CaseBuilder() .`when`(큐타입엔티티.sequence.goe(finalSequence)) .then(Status.SUCCESS) .otherwise(큐타입엔티티.status) queryFactory .update(큐타입엔티티) .set(큐타입엔티티.sequence, 큐타입엔티티.sequence.add(1)) .set(큐타입엔티티.status, caseBuilder) .where( 큐타입엔티티.id.eq(id) ) .execute() JPA의 영속성 생명주기를 사용하는 것이 아니기 때문에 당연히 JPA의 여러가지 편의기능( EntityListner.. ) 등을 사용하지 못한다. 따라서 쿼리 내에서 자체적으로 case 절을 도입했다. SSE Event => POLLING 기존에는 해당 작업이 모두 완료되었으면 다 끝났다는 신호를 Server Sent Event를 통해, 서버에서 단방향으로 클라이언트에게 쏴주곤 했다. 다만 역시 분산 환경에서 이 부분을 컨트롤하기가 몹시 까다로워졌다. 일단 클라이언트의 요청을 제일 앞단에서 받는 웹 서버도 로드밸런싱 되어있어서, 여러 대의 서버로 분산 처리될 뿐 아니라, 해당 작업들을 전파할 다른 서버들도 로드밸런싱 될 가능성이 있었다. Server Sent Event 같은 경우, 클라이언트와(이 경우 브라우저) 커넥션을 해당 서버가 지속적으로 물고 있어야 되므로, 커넥션을 맺은 해당 서버의 메모리에서 메시지를 발행해야 된다. 따라서 클러스터링이 매우 힘든 구조이다. 최초에는 이 모든 서버를 메시지 큐 서버 (Amazon MQ) 에 묶고 브로드캐스팅 방식으로, 마무리 단계일 경우, 한 번의 Publish 로 모든 구독된 서버가 알람을 받고 클라이언트에게 모두 쏘는 형태로 생각을 해보았으나, 구현상 복잡도가 기능의 단순함에 비해 지나치게 과하다는 생각이 들어 그만뒀다. 해당 서버의 메모리에 저장된 Emitter를 끄집어낼 고유식별자를 어딘가 저장하고, 메시지를 발행할때, 로드 밸런싱되어있는 모든 서버에게 알리는 구조로 설계를 해야했는데, 애매한 것이 지금 서버는 이 Emitter를 구별할 식별자를 결정하기가 힘든 구조였다. 해당 서비스는 모든 유저, 즉 로그인 유저와 비로그인 유저 모두에게 Notify를 줘야 되는 형태였고, 따라서 비로그인 유저 같은 경우는 클라이언트에서 랜덤한 식별자를 생성해 서버에 전달해주면, 서버는 그걸 가지고 Emitter를 저장하는 형태였다. 이 비로그인 유저들의 식별자를 매번 DB에 저장하기도 애매하니, 계속 식별자를 모든 서버에게 넘기면서 유지를 해야 되는데, 이 또한 귀찮은 과정이며, 이렇게 넘겨받은 EmitterId를 가지고 개별 서버 모두 메시징 큐 서버에 묶어놓고 pub 요청을 해야 되는데, 너무나 과하다는 생각이 들었다. 더군다나, Status 가 성공이란 걸 판단할려면, 매번 업데이트 후 검색하는 쿼리를 날려야 되는데, 이럴 거면 프론트에서 바로 POLLING 요청으로 DB의 상태를 바라보는 것과 크게 다르지 않다는 생각이 들었다. 그래서 최종적으로 프론트에서 해당작업을 요청한 후의 바로 ID를 넘겨받아 로컬 스토리지에 저장, 2초 주기로 인터벌로 요청을 날려 최종 상태를 판단하고 성공 또는 실패가 뜰시 유저에게 알려주고 인터벌을 중단시키는 형태로 작업을 바꿨다. 비동기 SDK 작업 강제로 동기화 해당 작업 중 AWS 미디어컨버팅 API를 호출하는 부분이 있는데, 관련 응답기록을 DB에 저장시켜야 되서, while문을 사용해 강제로 블락킹시켰다. 해당 SDK 내부가 비동기로 작성되어있어서 어디 콜백받을 수 있는 인터페이스를 따로 제공하지 않을까 찾아보았지만 관련문서도 빈약하고 내 빈약한 머리로 찾을 수 가 없더라 ㅠㅠ fun getJobUntilComplete( jobResponse: CreateJobResult, outPutName: String, userMastering: UserMastering ){ var job = jobResponse.job log.info { "convertJob.job().id() = " + job.id} while ( job.status == null || job.status == JobStatus.SUBMITTED.name || job.status == JobStatus.PROGRESSING.name ) { job = getConvertJob(job.id).job log.info("convertJob.job().status() = " + job.status) Thread.sleep(5000) } log.info{"job is complete = " + job.status} if (job.status == JobStatus.COMPLETE.name || job.status == JobStatus.CANCELED.name || job.status == JobStatus.ERROR.name ) { //update job status to DB if (job.status != JobStatus.COMPLETE.name){ // 실패처리 }else{ // increaseStep } } } (conn=806895) Deadlock found when trying to get lock; try restarting transaction java.sql.SQLTransactionRollbackException
서론 개발 업계에서 MSA 라는 용어가 화제가 된 지 꽤 시간이 지났다. MSA는 마이크로 서비스 아키텍처의 약자이며 쉽게 말해, 서버를 잘게 쪼개가지고 애플리케이션을 구성하는 접근방식이다. 어디까지 쪼개야 MSA라고 불러야 될지 모르지만, 필자는 모놀리틱한 서비스 구성에서 일해보기도 했고, 이를 다시 여러개의 분리된 역할의 서버로 쪼개는 경험도 해 봤다. MSA의 장점에 대해서 소개하는 글들은 많지만, 실제로 이를 행했을 때의 어려움과 단점에 대해서 얘기하는 글은 잘 보이지 않는다. 이는 그 과정에서 필자가 실제적으로 와닿은 힘든 점들을 써보려고 한다. 아마도 이러한 하지마라 시리즈로 계속해서 글을 연재해나갈 생각이긴 하다. (생각만..) 지금 당장 떠오르는 것들은.. 니들은 ORM 쓰지마라 니들은 클라우드 쓰지마라 니들은 GraphQL 쓰지마라 니들은 서버리스 서비스 쓰지마라 (feat.. 람다) 니들은 프론트 SPA 프레임워크(feat.. 리액트) 쓰지마라 니들은 NOSQL 쓰지마라 뭐 요 정도 있다. 하지만 오해는 하지마라, 실제로 나는 이 중 대다수를 운영레벨에서 쓰고 있는 회사에서 일하고 있고, 제목도 어그로성이다. 다만 짧은 개발자 경력동안 느낀 것은, 기술이란 것은 결국 특정상황에서 더 유용한 기술이 있을 수 있지만, 모든 상황에서 정답인 기술이나 아키텍처는 없을 뿐더러, 해당 기술들은 명백히 특정 상황, 문맥에서 더 유용하다고 생각할 뿐이다. 암튼 이야기 시작하겠다. 단일 서버로 운영했을 때와 달리 여러개의 서버로 분리한 아키텍처를 가지게 되었을 때, 거기에 해당하는 문제점이 튀어나오기 마련이다. 당장 분리된 서버들끼리 통신할 때, 어떤 프로토콜을 사용할지부터가 고민이다. 범용적인 HTTP 프로토콜을 사용해야 하나? 아니면 중간에 메시지 큐 같은 서버를 하나 둬서 이용할 수 도 있겠다. AWS 같은 클라우드 위에 구축되어있다면, AWS SQS나 SNS같은 벤더 종속적인 서비스도 고려대상이다. 여기서는 가장 범용적이라고 판단되는 HTTP를 이용하고 HTTP Client로서 Spring webclient를 예시로 들겠다. 트랜잭션 관리 분산 서버 아키텍처를 다룰 때, 필자가 생각하기에 가장 까다로운 것은 트랜잭션 처리이다. 예를 들어 서버 A와 서버 B가 있다고 치자. 서버 A에는 spring webclient를 통해 서버 B를 호출하고 있으며 @Transactional 어노테이션을 통해 선언적으로 트랜잭션을 관리하고 있다. 만약 서버 B에서 실패가 떨어졌을 시, 서버 A에서 실행했던 결과들도 롤백시키고 싶다면? 롤백이 될까? 결론적으로 안 된다. @Transactional 어노테이션과 함께 Reactive 스트림을 사용할 때, 에러가 발생해도 스프링의 선언적 트랜잭션 관리는 subscribe 블록 내부에서 예외를 던져도 롤백되지 않는다. Reactive 프로그래밍 모델에서는 에러 핸들링이 다르게 동작하기 때문이다. 이럴경우, .block()을 통해 결과가 올 때까지 동기적으로 기다려야 된다. 이럴 경우, 정상적으로 @Transactional 이 동작한다. 그러나 외부 API 호출의 결과를 기다려야 다음 로직이 진행된다는 게 맘에 안 든다. 다른 방법이 없을까? 프로그래밍적 트랜잭션 처리 이럴 경우 @Transactional 대신 직접 TransactionManager를 주입받아서 좀 더 명시적으로 트랜잭션을 처리해줄 수도 있다. 이렇게 하면, 원하는 결과가 나온다. 그러나 원하는 결과가 나왔다는 거랑, 과연 좋은 해결방법이냐는 거는 다른 문제이다. 이런 식으로 처리했을 시 발생할 수 있는 단점은 뭐가 있을까. 비즈니스 로직에서, transaction 처리 코드가 명시적으로 끼어들면서, 코드의 가독성이 현저히 떨어진다. 같은 DB라면 상관없지만, 만약 MSA끼리 서로 다른 데이터베이스를 사용한다면 통하지 않는다. 만약 서버 B의 실행 결과가 오래 걸린다면? doOnError 또는 doOnSuccess가 호출되어 트랜잭션이 커밋 또는 롤백되기 전까지, 시작된 트랜잭션은 열려있는 상태로 유지되게 된다. 결론적으로, 외부 API의 처리 시간동안 트랜잭션 커넥션이 물리고 있게 된다. 이러한 접근 방식은 트랜잭션 리소스를 오랫동안 점유하게 만들어, 시스템의 성능이나 다른 트랜잭션의 처리에 영향을 줄 수 있다. 보상 트랜잭션 이처럼 분리된 여러 서비스 간의 조합으로 비즈니스 로직이 처리될 때, 데이터의 일관성을 유지하는 것은 어려운 일이다. 네트워크 지연, 서비스 장애, 데이터 불일치 등의 이유로 트랜잭션이 부분적으로만 성공하는 상황이 발생할 때 쉽게 롤백을 시키기 어렵다. 그럴 경우, 실제로 트랜잭션을 롤백시키는 게 아닌, 논리적인 개념으로 롤백시킬 수 있게 따로 작업 단위를 선정할 수 있다. "보상 트랜잭션(Compensating Transaction)"의 개념을 사용하여 이미 커밋된 트랜잭션의 효과를 취소하거나 반대되는 작업을 수행함으로써 사실상의 "롤백"을 구현하는 거다. 그러면 위와 같이 고칠 수 있을 것이다. 장애 발생 시 복원력을 제공하기 위해 보상 로직의 정의와 관리를 추가적으로 작성해줌으로써 데이터 일관성을 챙길 수 있었다. 주의할 점은, 리액티브 영역에서는 이미 트랜잭션이 끝났으므로, 일반적인 JPA 메서드를 활용하려고 든다면, 트랜잭션이 필요하다는 에러메시지가 뜰 것이다. 이럴 경우, 이 부분에서 따로 트랜잭션을 새로 시작하다든가, Native Query를 그대로 실행하는 방안들이 있을 것이다. 그리고 통신하는 서버도 장애가 발생했을 시 반드시 에러를 명시적으로 응답해줘야 됨은 물론이다. 보상 트랜잭션을 구성하는데 드는 추가적인 개발 유지관리의 비용을 제외하고도 보상 트랜잭션 자체에서 에러가 발생할 수 도 있다. 이런 부분까지 고려해야 된다는 것은 그만큼 복잡성을 증가시킨다. SAGA 패턴이라든가 여러가지 아키텍처는 다 이러한 부분을 해결하기 위한 고민의 발버둥이다. 이번엔 조금 더 복잡한 상황을 가정해보자. 주문이 들어오면, 주문에 대해서 전처리를 하고, 주문 엔티티 휘하의 아이템들을 후처리하는 외부 API가 있다. 외부 API의 모든 작업결과가 성공일 때만 주문을 성공처리시키려고 한다. 보다시피 주문아이템들을 B 서버에게 넘겨준다. 순차적으로 실행할 필요가 없으므로, 병렬적으로 넘겨주고, 해당 B 서버의 결과가 모두 성공일 때의 케이스와, 일부 실패했을 때의 케이스를 분리했다. 여기서는 시나리오를 단순화해서 보여주고 있지만, 더 복잡해지고 해당 트랜잭션들의 순서가 중요할 경우, 분산 아키텍처에서 트랜잭션을 관리하는 것은 정말 많은 예외 케이스들을 염두에 둬야 한다. 통합 테스트 난이도 또 하나의 커다란 난관은 통합 테스트의 어려움이다. 서버를 배포하기 전, 해당 비즈니스 로직이 잘 동작하는지 테스트를 해보기 마련이다. 테스트에도 여러가지 단계가 있다. 실제 개발서버에 새로운 버전의 서버를 배포한 뒤, 사람이 직접 테스트하는 인수 테스트도 있고, 그 전에 코드 레벨에서 로직을 검증하는 테스트도 있다. MSA 환경에서 통합 테스트 코드를 짜는 것은 모놀리틱한 구조에 비해서 훨씬 어렵다. 하나의 비즈니스 로직을 처리하기 위해 각 마이크로서비스가 독립적인 단위로 배포되고 운영되기에, 소스코드를 수정하고 실제로 테스트하기 위해서는, 거기에 관여하는 해당 마이크로 서비스들의 의존성이 모두 필요하다. 실제로 이를 원활히 구성하기가 몹시 힘들다. 때문에 이 대신 Service Mocking과 Stubbing을 사용하여 실제 마이크로서비스 대신 테스트 더블을 구현할 수 있다. 이를 통해 의존 서비스 없이 특정 서비스 또는 통합 포인트를 격리하여 테스트할 수 있지만, Mocking에 대해서는 언제나 필자는 회의적이다. 이미 결과를 정해놓고, 테스트를 하는 것이 무슨 의미인가. 결국 해당 서버 내에서의 로직을 테스트하는 것에 지나지 않는다. 이는 통합 테스트의 의미를 퇴색시키고 결국 단위 테스트에 불과하다는 의미와 같다. 운영 단계에서 발생할 수 있는 버그를 100% 걸러주는 사전 테스트란 원래도 존재하지 않는다는 것은 잘 알지만, MSA 환경에서 통합테스트의 유효성은 훨씬 떨어진다. Docker와 Kubernetes와 같은 컨테이너 툴을 활용해서, 실제 서비스와 유사한 Test Environment을 만들 수도 있지만, 구현 복잡성과 관리가 극도로 높아진다. 나는 이런 Test Environment을 구성하는 데 내 시간을 소모하기 싫다. 그동안 실제 프로덕트에 유용한 기능개발에 더 집중하고 싶다. 아마도 멋진 훌륭한 DevOps 팀이 있는 환경이라면, 이러한 점들이 별 이슈가 되지 않을 수 있지만, 모두가 그런 환경에 놓여있는 것은 아니다. 배포 복잡도 마지막으로 느낀 실질적인 어려움은 배포 난이도의 상승이다. 단순히 얘기해서, 관리해야 될 서버 대수가 늘어난다. 각각의 서비스는 독립적으로 운영되며 배포되므로, 각각의 서비스를 위한 별도의 배포 파이프라인 필요할 수도 있다는 것을 의미한다. 관리 포인트가 늘어난다는 것은 그 자체로 극심한 스트레스이다. 운영단계에서 안정적인 서비스를 유지할려면, 해당 어플리케이션에 대한 지속적인 모니터링이 꼭 필요한데, 서버가 늘어난다는 것은, 그 모든 서버의 지표들, (로그, 매트릭)을 한곳에 모아서 관리해야할 또 다른 서버가 필요하다는 의미와 같다. 흔히, CI/CD로 불리는 지속적 통합, 관리까지 거창하게 가고 싶지 않다. 단순히 로그파일들을 보는 데도, 해당 서비스가 배포되는 환경을 명확히 구분하고 인지해야 된다. 또한 해당 MSA들끼리 공유하는 모듈이 따로 있을 경우, 그 부분을 서로 일치시켜주지 않을 시 발생할 문제도 있다. 만일 단일 기술스택을 공유하고 있다면, 예를 들어 스프링 개발자라면 멀티 모듈 같은 서비스를 이용해, 공유 모듈을 따로 빼놓을 수 있지만, 같은 기술스택을 공유하지 않는다면, 이 또한 힘들다. 어디 하나를 수정했을 시, 다른 서비스는 그걸 인지하지 못해서 발생할 버그의 위험성도 높아진다는 것이다. 결론 이번에는 흔히 MSA에 대해 장점이라고 얘기하는 것들에 대해 반박해보겠다. 1: 확장성 서비스가 독립적으로 배포되므로, 특정 서비스에 대한 수요가 증가할 때 해당 서비스만을 확장할 수 있다. 여기에는 숨겨진 비용이 따른다. 서비스가 분산되어 있어 네트워크 지연 시간이 발생하고, 각 서비스 간의 통신 오버헤드가 증가한다. 시스템 전체의 복잡성은 확장성을 향상시키려는 원래의 목적을 방해하는 요소로 작용할 수 있다. 2: 기술 다양성 각 마이크로서비스가 서로 다른 기술 스택으로 구성될 수 있다는 점은, 기술 선택의 자유를 의미한다. 이론적으로는 각 팀이 자신의 작업에 가장 적합한 기술을 선택할 수 있다. 이론적으로는. 실제로는 이를 감당할 개발팀의 성숙도가 없는 경우, 기술 부채와 운영 복잡성을 증가시키는 주범이 되는 경우가 더 많다. 서로 다른 프로그래밍 언어, 데이터베이스, 도구를 사용하면, 시스템을 유지보수하고 모니터링하는 작업이 어려워진다. 결국, 이러한 "다양성"은 통합과 협업의 장벽이 된다. 3: 복원력 한 서비스의 실패가 전체 시스템의 다운타임으로 이어지지 않는다. 예를 들어, 결제 서버가 장애가 나도, 결제를 이용하지 않는 유저들은 원활하게 시스템을 이용할 수 있다. 그러나 이는 모든 서비스가 완벽하게 격리되어 있고, 모든 의존성이 적절히 관리될 때만 가능한 일이다. 서비스 간 의존성이 복잡하게 얽혀 있고, 한 서비스의 실패가 연쇄적으로 다른 서비스에 영향을 미치는 경우가 많다면 의미가 없는 이야기다. 복원력을 달성하기 위해선, 각 서비스 간의 인터페이스를 엄격하게 관리하고, 격리 수준을 높이는 추가 작업이 필요한데 이는 정말 쉽지 않은 일이다. 4: 빠른 개발 서비스가 독립적으로 배포될 수 있기 때문에, 새로운 기능을 빠르게 출시할 수 있다는 점. 역시 이것도 각 서비스의 인터페이스가 잘 정의되어 있고, 서비스 간의 의존성이 최소화될 때만 가능하다. 실제로는 서비스 간의 통합 테스트, API 버전 관리, 배포 파이프라인의 복잡성 등이 프로젝트의 진행 속도를 늦춘다. 결국 MSA가 내세우는 가장 큰 줄기는 느슨한 결합과 최소한의 의존성 이다. 이는 소프트웨어 개발원칙에서도 항상 강조하는 원칙 중 하나다. 그러나 느슨한 결합과 최소한의 의존성을 달성하기 위해서는 생각보다 고려해야 될 점이 훨씬 많으며, 때로는 강한 결합이 훨씬 더 유용한 경우가 있다. 내가 실무에서 느낀 MSA에 대한 인상은 이렇다. 프로젝트 초기 단계부터 쪼개지마라. 위에서부터 계속 강조하고 싶은 것은 MSA는 이론적으로는 분명 강점을 가지고 있는 구조나 실제로 이를 구현하기가 몹시 힘들다는 것이다. MSA를 잘 구성하기 위해서는 해당 서버에 정확한 역할과 책임을 부여해야 한다. 문제는 프로젝트 초기 단계에서는 기획이 급변한다는 점이다. 즉 애초에 해당 역할과 책임을 부여하기가 힘들다. 특히 스타트업에서는 그 경향이 심한데, 흔히 애자일이라고 블리는 내 꼴리는 대로, 그때 그때 다르게 개발해줘 메타에서는 분명 연관관계가 없다고 생각하는 기능들이 나중에 서로 붙어줘야 되는 경우가 흔히 발생한다. 시작부터 MSA로 구성하면, 나중에는 MSA 구조를 위해서 그 기능을 억지로 비틀어버려서 끼워맞추는 주객전도의 개발방식이 이루어질 수 있다. 코드베이스가 작을 때 쓰지마라. 모놀리틱의 단점 중 하나는 코드베이스가 일정규모 이상 커질 때, 하나 기능을 추가하거나 수정해서 테스트하는 것도 빌드시간이 너무 오래 걸린다는 점도 있다. 이러한 단점은 코드베이스가 작을 때는 전혀 의미가 없으므로 신경쓰지 않아도 된다. 정말로 빌드시간이 개발생산성에 유의미하게 장애가 된다고 느낄 지점이 오고나서 고민해봐도 늦지 않다. 개발팀이 성숙치 않을 경우 쓰지마라. 모놀리틱의 단점 중 하나는, 전체 서비스가 하나의 언어, 프레임워크로 구성되어 있어서. 특정 기술스택에 대해 강한 결합성을 가지므로 종속적인 건데, 이는 앞서 말했듯이 개발인원이 소규모일 때는 단점 대신 장점이 되기도 한다. 말하자면, 특정 상황이 충족되지 않으면 쓰는 것보다 쓰지 않는 게 더 좋다는 얘기이다. 그리고 내가 절대 스타트업에서 일하고 있어서 그런 게 아니라, 해당 문제는 대부분의 스타트업에 해당할 것이다. 필자는 지금 소규모 스타트업에서 벡엔드 개발자로 일하고 있는데, 현재 개발 팀에 벡엔드 개발자가 나 포함 2명이다. 아니 지금은 3명으로 늘어나긴 했다. 그마저도 담당 서비스가 둘로 나눠져 있어서, 실제로 내가 담당하는 프로덕션 서비스의 벡엔드는 전적으로 나 혼자 개발하고 운영하는 판국이다. MSA로 구성하면, 유지보수성이 높아지고 개발생산성이 빨라진다. MSA로 구성하면, 유지보수성이 나빠지고 개발생산성이 느려진다. 아이러니하게도 둘 다 맞는 말이다. 그럼에도 불구하고 MSA 하지만, 서비스 규모가 일정 정도를 넘어서고, 회사에 개발팀이 충분히 갖춰지게 될시, MSA 구조가 추후 협업 및 개발 에 있어서 더욱 유리한 구조라는 것에는 동의한다. 물론 그렇게 넘어가는 것은 그때 가서 고민해봐도 늦지 않는다는 게 내 의견이고, 추후 쪼개기 쉽도록 단일 코드 베이스 내에서 패키지 수준의 의존관계만 잘 고민해서 분리해줘도 상관없다는 것이고. 내 생각에 MSA 가 각광받는 이유 중 가장 커다란 것은 다른 데 있는데, 그것은 우리가 지금 클라우드 시대에 살고 있다는 점이다. 클라우드 시대에 무엇이 가장 큰 이슈일까? 이슈는 클라우드가 비싸다는 점이다. 클라우드는 온프레미스에 비하면 너무 비싸다. 필자가 현재 운영해보면서 느낀 점 중 가장 강렬한 점이다. 물론 클라우드가 절약해주는 눈에 보이지 않는 비용이 있기 때문에, 클라우드를 사용하는 것이지만, 지금 물리적으로 눈에 보이는 것은 비용이다. 따라서 클라우드 인프라 안에서 얼마만큼 비용 효율적으로 구성할 지가 벡엔드 개발자에게 있어서 정말 큰 필요역량 중 하나라고 생각한다. 필자는 인프라 팀이 따로 갖춰져 있는 환경에서 일해본 적이 없다. 첫번째 직장은 작은 외주회사였고, 현재 직장도 작은 스타트업이므로, 개발자 1명당 부여되는 역할의 범위가 매우 넓은 편이다. 아무튼 이러한 환경에서 효율적인 서버 배치를 위해선 모놀리틱 구조는 불리할 수 밖에 없다. 사족 사족이지만 golang이 이러한 시대에서 가장 큰 혜택을 받은 언어라고 생각한다. 반면 JVM 기반 언어와 스프링이라는 프레임워크는 이러한 시대흐름과는 좀 맞지 않는 언어와 프레임워크 라는 생각도 들고, 심지어 나는 코틀린이라는 언어를 너무 좋아하고 (언어 자체의 디자인 측면에서) 스프링이라는 프레임워크가 서버사이드 개발에 있어서 정말로 뛰어난 거의 완벽한 육각형 프레임워크라고 생각하지만, 어쩔 수 없는 시대의 흐름이 있다고 생각한다. 이는 기술적으로 이 기술이 저 기술과 비교해서 더 후지거나 나빠서가 아니다. 나는 GoLang을 찍먹하면서, 언어 디자인 측면에서 마음에 안 드는 점들이 참 많이 있지만, 그 외의 부분. 컴파일 속도라든가, 별도의 VM 필요없이 실행가능한 싱글 바이너리로 파일로 바로 빌드된다는 점이라든가, 적은 메모리, 웜업 과정 필요없이 빠른 스피드 등등이 클라우드 시대에 강점이라는 점을 부정할 수가 없다. golang은 아직 익숙치 않고, 잘 모른다. 하지만 분명 코틀린만큼 즐거운 기분을 느낄 수가 없다. 굉장히 따분하고 장황하고, 언어 디자인 적으로 분명 더 나은 선택지가 있었다고 생각한다. 그러나 이 강점들 JVM은 미친 물건이지만, Docker의 등장으로 그 필요성이 예전보다 떨어지는 편이며, 클라우드 시대에는 경량 배포가 트렌드다. JVM을 설치해야 된다는 것은 분명 그 흐름과는 맞지 않다. 스프링은 정말 강력한 프레임워크지만, MSA 시대에는 스프링의 그 강력한 기능들이 그렇게 필요하지가 않다. 스프링의 가장 큰 장점이라고 한다면, 인터페이스를 만족한 빈들을 추가해주면서, 기존 서버코드의 변경없이 유연하게 끝없이 확장할 수 있다는 점일 텐데, 이 점들이 요새 시대에는 그렇게 필요하지 않다는 느낌이다. 그래도 나는 스프링 좋아한다. 앞으로 가능한 이걸로 밥 벌어 먹고 살았으면 싶다. 최근 JVM 이 Golang 처럼 변할려고 시도를 많이 하고 있다. 실행가능한 단일 바이너리 파일 빌드라든지, 하지만 아직 부족하다. JVM이 계속 일을 해서, 모든 부분을 만족하는 육각형이 되길 원하다. 그리고 코틀린!, 코틀린으로 다 하는 세상이 오길 원한다. 코틀린으로 머신러닝 개발. 내가 머신러닝 공부를 안 하는 것 중 상당부분 파이썬 때문도 있다. 필자는 파이썬과 JS는 암만 봐도 정이 안 가드라. 물론 지금도 코틀린용 딥러닝 프레임워크가 따로 있긴 하지만, 원하는 건 생태계에서 유의미하게 점유율이다. 아니면 최소한 반반정도. 사족이다..
알리바바 클라우드 포팅 기존에 AWS로 호스팅하는 서비스를 알리바바 클라우드로 포팅하는 작업이 가장 메인이다. 중국 쪽 서비스를 진행할려면, 비안이 필요한데. 중국 내에 위치한 서버에게만 도메인 주소로 비안이 발급되기 때문. 그 외에도 행정적인 문제가 참 많은데, 아무튼 결국 정상적으로 서비스를 할려면 중국 내로 서버를 전진배치시키는 방법 외에는 없다고 결론나왔다. 이 과정에서 중국의 AWS라는 알리바바 클라우드를 사용하기로 결정했다. 서비스를 포팅하는 작업이 알리바바 클라우드에 문외한인 2명 뿐 (나 포함) 이라는 게 처음에는 막막했다. 기존 AWS로 구축했던 서비스를 최대한 비슷하게 알리로 포팅해야됐다. 다행히 파트너사와 계약을 맺어 자문을 구할 수 있었다. 대응되는 서비스 찾기 중국 내 리전으로 먼저 만들면, 비안이 발급되지 않은 상태에서 테스트를 하다 자칫 GFW에 의해 차단될 수 있다는 파트너사의 조언으로 홍콩 리전에 먼저 인프라를 구축하고 후에 상해 리전으로 이미지를 떠서 복붙하는 식으로 진행하기로 했다. 대략적인 AWS와 대응되는 서비스 맵핑은 아래링크에서 비교할 수 있다. https://osamaoracle.com/2020/06/19/services-mapping-aws-azure-gcp-oc-ibm-and-alibab-cloud/ 이걸 기준으로 대략적인 맵핑 서비스를 뽑아냈다. 1. EC2 ⇒ ECS 2. RDS (AURORA MYSQL) ⇒ Polar DB 3. NAT 게이트웨이 ⇒ NAT GW 4. SES([Amazon Simple Email Service] ⇒ Direct Mail 5. SNS([Simple Notification Service] ⇒ MNS 6. S3 ⇒ OSS 7. Load Balncer ⇒ SLB 8. CloudFront ⇒ CDN 9. MediaConvert ⇒ [ApsaraVideo for Media Processing (MPS)] 10. elasticache ⇒ alibaba Redis 11. ACM([Certificate Manager] ⇒ SSL Certificate 12. Route53 ⇒ Alibaba Cloud DNS 13. CodeDeploy ⇒ EDAS 14. AWS Auto Scalling ⇒ EDAS Auto Scalling 15. IAM => RAM 대부분 AWS와 비스무리해서 크게 포팅하는 데 어려움이 있지는 않았다. 다만 UI가 훨씬 더 구리다. AWS도 UI 구리다고 욕 많이 먹는데, AWS와 비교해서도 훨씬 더 구리다. 2000년대 초반 감성을 느낄 수가 있다. 암튼 비슷하기에 차이점이 눈에 더 들어오게 되는데, Internet Gateway 가 그렇다. 알리바바에서는 별도로 IG를 설정하는 부분이 없고, ECS 를 만들면 기본적으로 Private 망 에 위치한다. 외부 인터넷 망에 노출시키면 별도의 EIP 를 할당하던가, CDN이나 LB에 붙이는 식이다. OSS는 Data Management - Static Page 에서 기본 홈페이지 경로를 설정할 수 있다. VPN 서버 셋팅 (WireGuard) Private 망에 위치한 ECS들에게 접근하기 위해 중간에 public 망의 ECS 하나를 띄우고 VPN 서버를 설치해 그걸 통해 접속하기로 했다. OpenVpn 및 여러가지 옵션을 고려하다. WireGuard가 대세라길래 설치했다. 편하게 셋팅하기 위해서, 웹 UI를 지원하는 wg-easy docker image를 받아서 설치했다. https://github.com/wg-easy/wg-easy EDAS 무중단 배포 파이프라인을 구축하러 CodeDeploy와 비슷한 솔루션을 찾아 헤메다, 비슷한 걸로 EDAS가 있다. EDAS에서 ASG를 만들어도 되고 ECS 란에 별도로 ASG 그룹 생성란이 있는데 뭔 차이인지 모르겠다. Alibaba Cli을 통해서, 깃허브 액션에 aliyun/setup-aliyun-cli-action@v1 패키지를 사용해 액션에서 바로 ali cli를 사용할 수 있다. MPS HLS 포맷으로 트랜스코딩하는데는 MPS 솔루션을 사용했다. MNS TOPIC을 통해서 트랜스코딩 결과값을 콜백받게 했다. FileNotFoundException, multipartfile.transferto(file) 이건 또 희한한 장애라서 기록해둔다. 내부적으로 multipart form 으로 파일을 받으면, 임시로 파일 객체를 만들고 거기로 멀티파트파일 내용을 옮기는데 나중에 만든 임시파일 객체를 가져올려니 File이 없다고 런타임 에러가 뜨는 게 아닌가? File.createTempFile() 메서드는 사용하지 않았다. 파일이름을 기존포맷과 공통적으로 통일시켜줘야 돼서, 아무튼 아래와 같이 바꿔주었다. https://deonggi.tistory.com/123 https://stackoverflow.com/questions/76286304/spring-boot-multipartfile-transferto-not-working AWS SI 적용 1년 견적 뽑고 EC2 Saving Plan 적용 NLB 포트 포워딩 포트포워딩할려면 해당 포워딩하려는 EC2에 그 포트도 보안그룹에서 열어줘야 됨. 처음에는 몰라서, 살짝 당황했음. Route53 - CloudFront - LB - Backend API 정적 콘텐츠를 서빙하는데 있어 CDN 이 효율적이라는 거는 이해가 되는 부분이다. 그런데 동적 컨텐츠를 내려주는 Backend API 까지 CloudFront를 앞단에 놓아야 될지는 모르겠다. 그래서 이때까지, 정적파일은 CDN을 사용했지만, Backend API 서버는 ALB로 바로 호스팅하도록 설정해주었다. 그러다 API 성능 개선을 위해서 여러가지 아티클을 읽어보던 중, 연결시켜주는 게 여러모로 나은 방향이라고 알게 되었다. https://www.reddit.com/r/aws/comments/jelo7r/should_i_one_put_their_application_load_balancer/ https://stackoverflow.com/questions/53655625/what-is-the-benefit-of-adding-aws-cloudfront-on-top-of-aws-application-lb/67815119#67815119 https://www.reddit.com/r/aws/comments/jelo7r/should_i_one_put_their_application_load_balancer/ 결론적으로 말하자면 CloudFront는 아무것도 캐싱하지 않더라도 전 세계 네트워크의 지연 시간을 개선할 수 있다는 거다. CloudFront를 사용하면 지리적으로 가장 가까운 CloudFront 상호 접속 위치(Point of Presence)로 라우팅된 다음 AWS 네트워킹 인프라를 통해 전송된다. CloudFront를 사용하지 않고 직접 ALB로 쏠 경우, 각 요청이 ISP를 통해 라우팅되며 트래픽 요금도 alb로 직접 들어올 경우에는 DataTransfer로 분류되지만, cf 통해서 들어오면 cf 별도 요금제를 타기 때문에 cfrc 같은 할인 제도를 통해서 비용적인 면에서도 이득이다. 또한 WAF 같은 방화벽 설정하기도 더 용이하다. 기존 LB에도 따로 붙일 수 있지만, 리전마다 다르면, 따로따로 설정해줘야 되는 반면, CF에서는 전역적으로 붙이고 관리의 포인트가 한 곳으로 좁혀지기 때문에 이 또한 장점이다. 그래서 기존 ALB들을 다 앞단에 CF를 두도록 고쳤다. 캐시정책 원본요청 정책 쿼리 문자열 모두 포함 선택해라.. 갑자기 전달 안 되서 놀랐네.. 응답헤더 정책 Managed-CORS-With-Preflight 희한한 건 WAF를 적용하면 multipart/form-data API 가 안 된다. 원인은 아직 모르겠다. 일단 해당 서버 방화벽 WAF 해제.. 원인은 아래 링크에 https://stackoverflow.com/questions/64853122/aws-waf-getting-403-forbidden-error-while-trying-to-upload-an-image https://repost.aws/knowledge-center/waf-explicit-allow-file-uploads grafana login loop.. cdn으로 grafana 서버 앞단에 달아줬더니 login 성공해도 다시 login page로.. 아 짜증나 CodeDeploy - TG 이슈 codeDeploy Blue Green 배포를 실행했다. 테스트까지 끝낸 뒤, 퇴근을 했는데, 같은 VPC 내부의 배포된 서버의 주소로 통신하는 다른 서버들이 있는데, 통신장애를 겪었다. 알고보니 CodeDeploy 옵션에 깜빡하고, TG 그룹을 추가하지 않은 것이다. 테스트를 할 당시에는 기존 그룹이 아직 종료되기 전이라, 성공했다고 착각한 것이다. 어차피 같은 DB를 사용하니, 결과 값은 종료 예정인 원본 서버그룹이 받아서 업데이트를 한 것이고, 사소한 실수가 큰 서버장애로 이루어질 수 있다.. File Upload 또 한가지 주요 이슈는 파일 업로드에 대한 속도의 문제였다. 내 서버에는 Multipart-form으로 파일을 업로드받는 API 가 있는데, 이 부분에서 네트워크 병목지점이 눈에 띄게 드러났다. 만든 API는 글로벌 국가를 대상으로 서비스하는 데, 서버는 정작 서울리전 하나에서 모두 커버를 치고 있으니 당연한 일이다. 목표는 서버를 증설시키지 않고, 최대한 해당 조건 내에서 파일 업로드의 시간을 단축하는 거다. 관련 이슈로 검색을 하다가 Multipart Upload 라는 키워드가 눈에 들어왔다. https://techblog.woowahan.com/11392/ https://develop-writing.tistory.com/129 https://velog.io/@sangwoo-sean/spring-AWS-S3-Multipart-Upload https://aws.amazon.com/ko/blogs/compute/uploading-large-objects-to-amazon-s3-using-multipart-upload-and-transfer-acceleration/ S3 Multipart Upload 병렬처리 AWS 공식문서에서는 100MB 이상의 파일을 업로드 할 때, Multipart Upload를 권장하고 있다. 나는 그 정도까지는 아니더라도, 네트워크 지연률을 생각해서, 여러개의 파일을 청크로 짤라서 병렬적으로 업로드하는 게 효율적이라고 생각이 들었다. VPN을 키고 홍콩에서 우리 API 로 파일을 쏘는 속도테스트를 해봤을 시 100MB 파일을 전송하고 응답받는데, 12분이 넘게 걸린다.. 어마어마한 속도 차이다. (vpn off 시 12.93s 정도) 물론 개발서버라 네트워크 대역폭이 낮은 서버스펙을 사용하는 것도 있고, 이 테스트가 정확한 테스트가 아닐지라도 타국에서는 느리게 돌아가는 건 확실하다. 해당 API가 전 세계를 커버하는 지라, 각국의 네트워크 대역폭과 망 위치에 따라 속도는 천차만별일 거라 예상된다. 이 네트워크를 쏘는 중에 브라우저를 새로고침하거나, 다른 페이지로 이동하면 파일을 온전히 받지 못하고, 다음과 같은 Exception이 터진다. org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request] with root cause java.io.EOFException: null 가장 좋은 것은 각국 해당 리전에 서버를 복제하고 DNS에서 가장 가까운 리전으로 라우팅하도록 하는 건데, 현실적으로는 비용문제 때문에 서비스를 제 궤도에 올리기 전까지 증축은 힘들고, 최대한 이 시간을 단축시키는 방법을 찾아야 했다. 일단 해당 네트워크 요청에서 응답이 오기 전까지 페이지를 이탈하거나, 새로고침을 할 시, 파일이 손실 될 수 있다는 경고창을 띄우기로, UI에서 기획을 했다. 다음은 위의 아티클을 보면서, 병렬처리로 단축시키기로 해봤다. 기존에는 파일을 해당 API 서버에서 통째로 직접 받고 전처리를 해준다음, S3로 올리는 식이였다면, 바뀐 로직은 클라이언트에서 파일을 청크 단위로 짤라서, 한꺼번에 s3로 직접 올리고, 완료 신호를 API 서버에게 쏘면, API 서버는 그 정보를 바탕으로 S3에서 파일을 다운받고 후처리를 하는 식으로. 일단 간단한 예제코드와 테스트 페이지를 만들고 테스트해보았다. Backend Test Page <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>S3 upload Test</title> <style> body { height: 100vh; background-color: #212121; margin: 0; width: 100%; font-family: Arial, Helvetica, sans-serif; } h1, input { color: rgb(202, 202, 202); } hr { margin-top: 50px; margin-bottom: 50px; } </style> </head> <body> <h1>S3 멀티파트 업로드 병렬처리 테스트하기 (최대 3GB)</h1> <input type="file" id="multipartInput"> <button id="multipartInputBtn">send file</button> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js" integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ==" crossorigin="anonymous"></script> <script> document.getElementById('multipartInputBtn').addEventListener('click', async () => { const multipartInput_fileInput = document.getElementById('multipartInput'); // const targetId = document.getElementById('tutorialId').value; const file = multipartInput_fileInput.files[0]; const filename = file.name; const fileSize = file.size; // 3GB가 넘어가는 파일 업로드 제한 if (fileSize > 3 * 1024 * 1024 * 1024) { alert('The file you are trying to upload is too large. (under 3GB)'); return; } const url = `http://localhost:2002/v2`; try { let start = new Date(); // 1. Spring Boot 서버로 멀티파트 업로드 시작 요청합니다. let res = await axios.post(`${url}/initiate-upload`, {filename: filename}); const uploadId = res.data.data.uploadId; const newFilename = res.data.data.newFilename; // 서버에서 생성한 새로운 파일명 console.log(res); // 세션 스토리지에 업로드 아이디와 파일 이름을 저장합니다. sessionStorage.setItem('uploadId', uploadId); sessionStorage.setItem('filename', newFilename); // 청크 사이즈와 파일 크기를 통해 청크 개수를 설정합니다. const chunkSize = 10 * 1024 * 1024; // 10MB const chunkCount = Math.floor(fileSize / chunkSize) + 1; console.log(`chunkCount: ${chunkCount}`); const promiseList = []; for (let uploadCount = 1; uploadCount < chunkCount + 1; uploadCount++) { // 청크 크기에 맞게 파일을 자릅니다. let start = (uploadCount - 1) * chunkSize; let end = uploadCount * chunkSize; let fileBlob = uploadCount < chunkCount ? file.slice(start, end) : file.slice(start); // 3. Spring Boot 서버로 Part 업로드를 위한 미리 서명된 URL 발급 바듭니다. let getSignedUrlRes = await axios.post(`${url}/upload-signed-url`, { filename: newFilename, partNumber: uploadCount, uploadId: uploadId }); let preSignedUrl = getSignedUrlRes.data.data.preSignedUrl; console.log(`preSignedUrl ${uploadCount} : ${preSignedUrl}`); console.log(fileBlob); // 3번에서 받은 미리 서명된 URL과 PUT을 사용해 AWS 서버에 청크를 업로드합니다, let uploadChunckPromiss = fetch(preSignedUrl, { method: 'PUT', body: fileBlob }).then((response) => { return response }).then((res) => { let EtagHeader = res.headers.get('ETag').replaceAll('\"', ''); var modifyData = { awsETag: EtagHeader, partNumber: uploadCount, }; return modifyData }).catch((err) => console.error(err)); promiseList.push(uploadChunckPromiss); } const multiUploadArray = await Promise.all(promiseList) .then((datas) => { return datas }) .catch((err) => { //todo 하나라도 실패하면 실패했다고 벡엔드에게 알려줘야 됨 console.log("e==?>", err) }); console.log("multiUploadArray", multiUploadArray); // 6. 모든 청크 업로드가 완료되면 Spring Boot 서버로 업로드 완료 요청을 보냅니다. // 업로드 아이디 뿐만 아니라 이 때 Part 번호와 이에 해당하는 Etag를 가진 'parts'를 같이 보냅니다. const completeUpload = await axios.post(`${url}/complete-upload`, { filename: newFilename, parts: multiUploadArray, uploadId: uploadId, }); let end = new Date(); console.log("파일 업로드 하는데 걸린 시간 : " + (end - start) + "ms") console.log(completeUpload.data, ' 업로드 완료 응답값'); } catch (err) { console.log(err, err.stack); //todo 실패하면 실패했다고 벡엔드에게 알려줘야 됨 } }); </script> </body> </html> 이번엔 4KB 정도의 파일로 테스트를 해보니, 확실히 눈에 띄게 속도개선이 되었다. Promise.all 을 적용하지 않았을 때. Promise.all 을 적용했을 때 S3 Transfer Acceleration 이번에는 S3의 전송 가속화를 활성화하고 가속화 엔드포인트로 테스트 해보았다. VPN을 키고 테스트를 해보았는데, 가속화 엔드포인트를 활용한 업로드 시간이 더 오래걸린다.. 이유를 모르겠다. 누구 짐작가는 원인이 있으면 댓글 좀 https://stackoverflow.com/questions/77601432/upload-to-s3-using-transfer-acceleration-enabled https://medium.com/@chochoswim98/aws-sa-s3-transfer-acceleration-1dd549f00cd8 APM 재구축 grafana 대쉬보드를 재구성했다. 간단한 구조는 개별 스프링부트로 구성된 서버는 Spring Actuator를 통해서 매트릭 지표를 오픈해두고, 프로메테우스 서버는 그 엔드포인트들로 지표를 수집한다. 그라파나 서버에 그 프로메테우스 서버를 데이터 소스로 등록해두고, 대쉬보드를 구성하면 된다. 문제는 로그파일인데, Actuator에서 로그파일을 공개하도록 구성할 수 있지만 프로메테우스에서 그것까지 수집을 못 한다. 결국 로그를 수집할 전용 Loki Server를 설치하고, 그 Loki Server가 로그를 수집하도록 하고, 그라파나 서버가 해당 Loki Server를 데이터소스로 등록해서 대쉬보드를 꾸며줘야 된다. 여기서 검색을 했을 때, 대부분은 해당 로그파일이 적재되는 인스턴스에 promtail 서버를 각각 깔아서 로그파일을 주기적으로 Loki Server에게 쏴주는 식으로 설명이 되었는데, 좀 더 찾아보니, loki-logback-appender 라는 괜찮은 오픈소스를 발견했다. 그래서 부트 앱에서 직접 Loki Server로 쏴주도록 설정했다. https://loki4j.github.io/loki-logback-appender/ https://neilwhite.ca/spring-boot-3-observeability/ https://github.com/blueswen/spring-boot-observability AWS SDK V2 마이그레이션 https://github.com/aws/aws-sdk-java-v2/blob/master/docs/LaunchChangelog.md#411-s3-operation-migration https://medium.com/@iamcrypticcoder/spring-boot-services-for-aws-java-sdk-v2-a3b8bc1c1b12 버킷 이름에 .이 들어가면 Failed to load: resource: net::ERR_CERT_COMMON_NAME_INVALID 오류가 발생한다. 버킷 이름에 .이 들어갈 경우 postman은 잘 되는데 구글은 안된다던가, 구글은 되는데 네이버 웨일은 안된다던가 하는 문제가 발생할 수 있으니 버킷이름에 .은 지양하도록 하자
전에는 깃허브 액션만을 이용해 원격으로 배포하는 걸 해보았는데, 이번에는 Docker를 살짝 끼얹어보기로 하자. Docker 설치 https://www.seankim.life/aws/1827/ DockerFile 작성 FROM arm64v8/openjdk:17 ARG JAR_FILE=/build/libs/*.jar COPY ${JAR_FILE} /deploy.jar ENV AWS_REGION=ap-northeast-2 ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod", "/deploy.jar"] ./gradlew clean build -x test docker build -t my-spring-app -f /Users/stella6767/IdeaProjects/free/DockerFile . Nginx Conf # nginx.conf 파일 # 컨테이너 내부에 /etc/nginx/conf.d/nginx.conf 경로에 존재 user nginx; worker_processes auto; #CPU의 갯수를 알아서 파악 error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; #최대 worker_processes * worker_connections의 갯수로 처리할 수 있다. } http { client_max_body_size 500M; #요청 바디 500MB로 제한 gzip on; #응답 압축 gzip_disable "msie6"; #IE6 지원 안함 gzip_min_length 500; #최소 사이즈를 지정하며 이보다 작은 파일은 압축하지 않음 gzip_buffers 16 8k; #버퍼의 숫자와 크기를 지정 gzip_comp_level 6; #압축 레벨 6 gzip_proxied any; #항상 압축함 https://www.lesstif.com/system-admin/nginx-gzip-59343019.html gzip_types application/json; #컨텐츠의 유형에 따라 압축 여부를 설정 server_tokens off; #서버 정보를 노출하지 않음 (취약점 방지하기 위해) #로드밸런싱으로 AWS private ip 대신 클라이언트의 ip를 가져올 수 있도록 함 # set_real_ip_from 172.31.0.0/16; # real_ip_header X-Forwarded-For; log_format main '$http_x_forwarded_for $time_local $request_method $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" $request_uri $request_id $request_time "$host"'; # map $http_user_agent $loggable { # default 1; # "ELB-HealthChecker/2.0" 0; #ELB health check의 경우 로깅하지 않음 # } # official docs : https://www.nginx.com/blog/rate-limiting-nginx/ # ip는 binary 저장이 제일 공간적으로 효율적임 # zone={zoneName}:{zoneSize;1m(160,000)} # (burst 지시자가 없는 경우) 10r/s의 의미는 초당10개를 허용한다는 의미지만 더 정확하게는 이전 요청보다 100ms 낮을 수 없다. limit_req_zone $binary_remote_addr zone=accountZone:50m rate=20r/s; server { listen 80; listen [::]:80; server_name www.freeapp.life; location /.well-known/acme-challenge/ { allow all; root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } server { charset utf-8; listen 443 ssl http2; listen [::]:443 ssl http2; server_name www.freeapp.life; keepalive_timeout 65; #burst 와 noddelay를 적절히 섞어야 함 # limit_req zone=accountZone burst=10 nodelay; # limit_req_status 429; #too many request 에러 # 일반적으로 많이 사용되는 취약점 스캐너, 공격 TOOL에서 사용하는 USER-AGENT이다. if ($http_user_agent ~* "Paros|ZmEu|nikto|dirbuster|sqlmap|openvas|w3af|Morfeus|JCE|Zollard|Arachni|Brutus|bsqlbf|Grendel-Scan|Havij|Hydra|N-Stealth|Netsparker|Pangolin|pmafind|webinspect") { return 444; } # 인증서 등록 필요 ssl_certificate /etc/letsencrypt/live/www.freeapp.life/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/www.freeapp.life/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location / { proxy_pass http://web:8081; proxy_redirect off; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host:443; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-Port 443; proxy_set_header X-Forwarded-Proto https; } location ~ /.well-known/acme-challenge/ { root /var/www/certbot; } } } init-letsencrypt.sh #!/bin/bash if ! [ -x "$(command -v docker-compose)" ]; then echo 'Error: docker-compose is not installed.' >&2 exit 1 fi domains=www.너의도메인 rsa_key_size=4096 data_path="./certbot" email="" # Adding a valid address is strongly recommended staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits if [ -d "$data_path" ]; then read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then exitㅛy fi fi if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then echo "### Downloading recommended TLS parameters ..." mkdir -p "$data_path/conf" curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" echo fi echo "### Creating dummy certificate for $domains ..." path="/etc/letsencrypt/live/$domains" mkdir -p "$data_path/conf/live/$domains" docker-compose -f docker-compose-dev.yml run --rm --entrypoint "\ openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ -keyout '$path/privkey.pem' \ -out '$path/fullchain.pem' \ -subj '/CN=localhost'" certbot echo echo "### Starting nginx ..." docker-compose -f docker-compose-dev.yml up --force-recreate -d nginx echo echo "### Deleting dummy certificate for $domains ..." docker-compose -f docker-compose-dev.yml run --rm --entrypoint "\ rm -Rf /etc/letsencrypt/live/$domains && \ rm -Rf /etc/letsencrypt/archive/$domains && \ rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot echo echo "### Requesting Let's Encrypt certificate for $domains ..." #Join $domains to -d args domain_args="" for domain in "${domains[@]}"; do domain_args="$domain_args -d $domain" done # Select appropriate email arg case "$email" in "") email_arg="--register-unsafely-without-email" ;; *) email_arg="--email $email" ;; esac # Enable staging mode if needed if [ $staging != "0" ]; then staging_arg="--staging"; fi docker-compose -f docker-compose-dev.yml run --rm --entrypoint "\ certbot certonly --webroot -w /var/www/certbot \ $staging_arg \ $email_arg \ $domain_args \ --rsa-key-size $rsa_key_size \ --agree-tos \ --force-renewal" certbot echo echo "### Reloading nginx ..." docker-compose -f docker-compose-dev.yml exec nginx nginx -s reload Docker Compose 기본 명령어 개념 docker-compose -f docker-compose.dev.yml build // 이미지 빌드 docker-image ls // 도커 이미지 확인 docker-compose down : 도커 컨테이너를 중지 시키고 삭제 합니다. docker-compose stop : 도커 컨테이너를 중지 시킵니다. docker-compose start : 도커 컨테이너를 실행합니다. docker-compose ps : 컨테이너 상태를 확인합니다. docker-compose exec [servicename] [shell cmd] : 도커 컨테이너 접속 접속시 컨테이너 명이 아니고 .yml 파일에 작성한 서비스 명(ubuntu)입니다. Compose Script version: "3" services: nginx: container_name: nginx image: nginx restart: always ports: - "80:80/tcp" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf # nginx.conf를 mount. - ./certbot/conf:/etc/letsencrypt - ./certbot/www:/var/www/certbot environment: - TZ=Asia/Seoul depends_on: - web command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" certbot: container_name: certbot image: certbot/certbot:arm32v6-latest restart: unless-stopped volumes: - ./certbot/conf:/etc/letsencrypt - ./certbot/www:/var/www/certbot entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" web: container_name: web restart: always build: # context: . #dockerfile: /Users/stella6767/IdeaProjects/free/DockerFile dockerfile: /home/ubuntu/cicd/DockerFile ports: - "8081:8081" environment: - "spring_profiles_active=prod" https://unix.stackexchange.com/questions/45781/shell-script-fails-syntax-error-unexpected https://hbesthee.tistory.com/2098 Deploy Script whoami echo " " echo "========================" echo "Path move" echo "========================" cd /home/ubuntu/cicd/ echo " " echo "========================" echo "Docker compose down" echo "========================" # 이미 실행 중인 Docker Compose 중지 및 컨테이너 삭제 sudo docker-compose -f /home/ubuntu/cicd/docker-compose-dev.yml down #echo " " #echo "========================" #echo "Docker compose build" #echo "========================" #sudo docker-compose -f /Users/stella6767/IdeaProjects/free/docker-compose-dev.yml build echo " " echo "========================" echo "Docker Compose Up" echo "========================" sudo docker-compose -f /home/ubuntu/cicd/docker-compose-dev.yml up -d Github Action name: Java CI with Gradle on: workflow_dispatch: inputs: BRANCH: description: 'Branch to use' required: true default: 'dev' type: choice options: - dev - prod - kt permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: #ref: ${{ github.event.inputs.target-branch }} # 왜 안 먹히냐... ref: ${{ inputs.branch }} - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - name: Retrieve secrets env: MY_SECRETS_ARCHIVE: ${{ secrets.MY_SECRETS_ARCHIVE }} run: | echo "$MY_SECRETS_ARCHIVE" | base64 --decode > secrets.tar.gz tar xzvf secrets.tar.gz -C src/main/resources - name: Build with Gradle run: ./gradlew clean build shell: bash - name: Upload artifact uses: actions/upload-artifact@v2 with: name: cicdsample retention-days: 1 path: | build/libs/ scripts/ nginx/nginx.conf DockerFile docker-compose-dev.yml deploy: needs: build runs-on: ubuntu-latest steps: - name: Download artifact uses: actions/download-artifact@v2 with: name: cicdsample - name: Setup SSH uses: webfactory/ssh-agent@v0.5.4 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Add remote server to known hosts run: | mkdir -p ~/.ssh ssh-keyscan ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts - name: check File List run: | pwd ls -al - name: SCP transfer run: | scp -r scripts build DockerFile docker-compose-dev.yml nginx ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }}:~/cicd - name: Execute remote commands run: | if [[ "${{ inputs.branch }}" == "dev" ]]; then ssh -v ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }} "sudo sh ~/cicd/scripts/deploy-docker.sh" elif [[ "${{ inputs.branch }}" == "prod" ]]; then ssh -v ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }} "sudo sh ~/cicd/scripts/deploy-docker.sh" else echo "No specific script found for this" fi # delete-artifact - uses: geekyeggo/delete-artifact@v2 with: name: cicdsample 먼저 Execute remote commands를 실행하기 전에 init-letsencrypt를 먼저실행 물론 도메인을 구입하고 배포 인스턴스의 ip를 가리키는 도메인의 A 레코드가 있다는 것을 가정. 참고 https://velog.io/@lijahong/0%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-Docker-%EA%B3%B5%EB%B6%80-Docker-Compose-wordpress-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0 https://www.daleseo.com/docker-compose-networks/ https://github.com/twer4774/docker-nginx/blob/master/README.md https://jforj.tistory.com/226 https://sonnson.tistory.com/45 https://dev.gmarket.com/80 https://bgpark.tistory.com/132 https://medium.com/geekculture/webapp-nginx-and-ssl-in-docker-compose-6d02bdbe8fa0 https://medium.com/@su_bak/docker-compose%EC%97%90%EC%84%9C-%EC%84%9C%EB%A1%9C-%EB%8B%A4%EB%A5%B8-container%EA%B0%80-%EA%B0%99%EC%9D%80-volume%EC%9D%84-%EA%B3%B5%EC%9C%A0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-5e49430c5282 https://qspblog.com/blog/SSL-%EC%9D%B8%EC%A6%9D-%EB%B0%9B%EA%B8%B0-docker-%EC%82%AC%EC%9A%A9-certbot-%EC%9C%BC%EB%A1%9C-certificates-%EB%B0%9B%EA%B8%B0-https%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-php%EC%99%80-nginx-%EC%9B%B9%EC%84%9C%EB%B2%84 https://docs.docker.com/compose/networking/ https://velog.io/@zero-black/Docker-compose-certbot-nginx-%EB%A1%9C-SSL-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EB%B0%9C%EA%B8%89%ED%95%98%EA%B8%B0 https://bgpark.tistory.com/132 https://ninano1109.tistory.com/152 https://github.com/twer4774/docker-nginx/blob/master/README.md
IOS 모바일에서 결제를 터뜨리고 난 후 영수증을 받고 영수증을 서버에게 넘겨주면 검증하는 절차를 정리해보려고 한다. SpringBoot 3 Apple storekit V2 API 구매 검증 Transaction 검증 storekit API 위 링크 보다시피, transactionId를 통해서 구매검증을 진행할 수 있다. (기존에는 verifyReceipt api (deprecated) 를 사용했다.) 이 API를 호출하기 위해서 권한이 필요한데, JWT를 만들어야 한다. JWT 생성 필요한 JWT부터 만들어주겠다. JWT를 만드는데 필요한 변수는 여기서 얻을 수 있다. app store connect 사용자 및 액세스를 선택한 다음, 키 탭을 선택합니다. Key Type(키 유형)에서 In-App Purchase(인앱 구매)를 선택합니다. API 키 생성 또는 추가(+) 버튼을 클릭합니다. implementation("io.jsonwebtoken:jjwt-api:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") API 요청에 대한 JSON 웹 토큰 생성 얻어낸 privateKey 형식 중에서 "+" "/" "\n" 같은 특수문자가 포함되어있으면, 파싱해주는 과정이 필요하다. 이거 때문에 삽질 오래했으니, 이거 보는 사람은 나같은 삽질 안 하길 바란다. 이렇게 얻어낸 JWT를 Bearer Token으로 보내주면 인증과정은 끝났다. appleClient.get() .uri("/inApps/v1/transactions/${purchaseId}") .accept(MediaType.ALL) .header("Authorization", "Bearer $code") .retrieve() .toEntity(String::class.java) 이렇게 호출해서 응답을 받으면, signedTransactionInfo를 받을 수 있을 것이다. 이제 이 signedTransactionInfo는 JWT 형식이므로 Base64 디코딩해서 내가 원하는 정보를 손쉽게 얻을 수 있다. 정상적으로 디코딩 되었다면, 대략 아래와 같은 형태의 JSON 문자열로 변환할 수 있을 것이다. { "bundleId": "test_30943ac3bc74", "currency": "test_d935a9f40f64", "environment": "test_e10158055909", "expiresDate": 42, "inAppOwnershipType": "test_20450bb42e39", "offerDiscountType": "test_7dbb7b7cd99c", "offerType": 19, "originalPurchaseDate": 17, "originalTransactionId": "test_ccf6ed159e63", "price": 22, "productId": "test_f86e2d9e6492", "purchaseDate": 6, "quantity": 99, "signedDate": 51, "storefront": "test_6b13f0b6fb93", "storefrontId": "test_7130b504bb19", "subscriptionGroupIdentifier": "test_533b068c896a", "transactionId": "test_5e41646f0e11", "transactionReason": "test_a1f025ff952e", "type": "test_a011716346be", "webOrderLineItemId": "test_abb51a6690bd" } 이제 우리가 할 일은 간단하다. 파싱된 expiresDate 를 보고 구독이 만료되었는지 아닌지 판단하는 것이다. 만료되지 않고, 파싱이 제대로 되었다면, 구매가 정상적으로 이루어졌다고 판단하고, DB에 반영하면 된다. 만약 구매가 이미 이루어졌는데, 서버에서 받지 못했고 뒤늦게 다시 터뜨릴 경우 Auto-renewal 결제 데이터가 아래와 같은 형태로 뽑혀져 나올건데, 이럴 경우도, originalTransactionId는 동일하다. 리뉴얼 결제들은 매 결제 건 마다 transctionId가 새로 부여되지만 originalTransactionId는 동일하다. 따라서 이 originalTransctionId 기준으로 모든 결제건을 보고 signedDate 를 가지고 가장 최신 결제건을 뽑아낸 다음, expiresDate로 만료여부를 판단하면 된다. { "bundleId": "test_3072f8d1fce1", "currency": "test_641bdd31d52f", "environment": "test_5d8bbfec06ea", "expiresDate": 73, "inAppOwnershipType": "test_475fa6a3198b", "originalPurchaseDate": 3, "originalTransactionId": "test_c1e9cf838e5e", "price": 30, "productId": "test_b8cacbec0a3d", "purchaseDate": 98, "quantity": 82, "signedDate": 96, "storefront": "test_9bda2e3e5ac0", "storefrontId": "test_c429084cb0ad", "subscriptionGroupIdentifier": "test_cbeae4080e67", "transactionId": "test_c844381dec9b", "transactionReason": "test_31a7d174dce2", "type": "test_e3ac9f1a4de1", "webOrderLineItemId": "test_73e5d7dd8e72" } 최초 구독 결제냐, 아니면 재결제냐에 따라서 JSON Data 양식이 달라지기 때문에 파싱하는 부분을 신경써줘야 된다. 서버 Notification2 구독 검증 말고도 안전장치를 하나 더 마련해줘야 된다. Apple은 이를 위해서 Server to Server Notification 을 지원해준다. App Store 서버 알림 활성화 App Store Connect > 나의 앱 > 앱 선택 > 일반 정보 > 앱 정보 > App Store 서버 알림 { "signedPayload": "" } signedPayload 로 형태로 JWT 형식의 데이터가 올 건데, 마찬가지로 payload 부분만 Base64로 디코딩해주고, 데이터를 확인해보면 아래와 같은 형식으로 올 것이다. 이 정보를 가지고 (notificationType) 구독상태를 최신으로 반영해주면 된다. transactionId의 경우, signedTransactionInfo를 가지고 다시 디코딩해주면 얻을 수 있을 것이다. { "data": { "appAppleId": 0, "bundleId": "", "bundleVersion": "", "environment": "", "signedRenewalInfo": "", "signedTransactionInfo": "", "status": 0 }, "expirationDate": 0, "notificationType": "", "notificationUUID": "", "subtype": "", "signedDate": 0, "version": "" } 주의할 점은, 결제 상태가 변경됨에 따라 Callback이 오는데 순서가 동기적이지 않다는 것이다. 따라서 , signedDate를 가지고 필터링해주는 과정이 필요하다. 다음은 구글 인앱 구독 결제 검증을 다뤄보겠다. 참고 https://developer.apple.com/account/resources/authkeys/list iOS 인 앱 구매 (In App Purchase) 구현 가이드라인 (신)영수증 검증+Notification V2 iOS 인앱 정기결제(IAP Auto-renewable Subscription) 튜토리얼
별 건 없고, 회사에서 간단히 CSV 파일을 테이블 규격에 맞춰서 DB에 적재시키는 배치서버를 만들다가, OPENCSV 라이브러리 도움을 받은 게 있어서 공유해보고자 한다. dependencies { // https://mvnrepository.com/artifact/com.opencsv/opencsv implementation group: 'com.opencsv', name: 'opencsv', version: '5.7.1' implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.batch:spring-batch-test' } public List<Dto> getItems(String filePath) { InputStream in = getClass().getResourceAsStream(filePath); InputStreamReader fileReader = new InputStreamReader(in, StandardCharsets.UTF_8); try (CSVReader reader = new CSVReaderBuilder(fileReader).withSkipLines(2).build()) { List<String[]> result = reader.readAll(); List<Dto> dtos = result.stream().map(strings -> { List<String> list = Arrays.stream(strings).map(s1 -> { return s1.replaceAll("\n", " ").trim(); }).collect(Collectors.toList()); if (!StringUtils.hasLength(list.get(1))) return null; return getDto(list); }).filter(a -> a != null).collect(Collectors.toList()); return musicDtos; } catch (Exception e) { System.err.println(e.getMessage()); } return null; } @Slf4j @Component @RequiredArgsConstructor public class CustomTasklet implements Tasklet { private final DataRepository repository; private String filePath = "/~~"; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { execute(); return RepeatStatus.FINISHED; } public void execute(){ List<Dto> items = new CsvUtil().getItems(filePath); List<MyEntity> collect = items.stream().map(Dto::toEntity).collect(Collectors.toList()); repository.saveAll(collect); } } @Bean @JobScope public Step csvJob1_batchStep1() { return stepBuilderFactory.get("csvJob1_batchStep1") .tasklet(customTasklet) .build(); } CSV 파일을 읽을 때, 골칫거리 중 하나가 칼럼에 있는 줄바꿈인데, opencsv를 활용하면, 효과적으로 csv row 별로 읽을 수 있다.
생각없이 작업하다가 100M가 넘는 파일을 git에 add 해버렸다. 깃허브에 푸쉬하니 파일 사이즈가 초과되었다는 에러가 뜨고, 이 파일을 깃 내역에서 삭제할려고 했다. 파일을 삭제하고, 캐시를 날리고, igonre 파일을 수정해도 여전히 똑같은 에러가 뜬다.. 고민하다가 ChatGpt에게 물어봤다. git filter-branch --tree-filter 'rm -f java_pid11196.hprof' HEAD 현재 브랜치에서 java_pid11196.hprof 이 녀석을 지우고, 다시 git rewritten 하드라.. 암튼 성공! https://stackoverflow.com/questions/72315037/file-size-exceed-error-how-to-remove-the-file-from-git
오늘의 삽질.. 엔티티 연관관계를 orderItem과 order를 주고 요렇게 잡았을 때, 자꾸 addOrderItem 빨간색 부분에서 null pointer exception이 터졌다. 확인해보니, list가 null.. 분명 처음 생성될 때 new를 해줬는데 왜 이러지?? 도대체 왜 이러나 계속 삽질하다가, 어이없는 부분에서 실수 발견. order를 생성할 때 정적팩토리메서드 패턴을 사용했는데, builder 패턴을 썼다.. 생성자가 아니라서, 당연히 위 빨간 부분처럼 orderitems를 초기화해줘야 하는 코드를 넣어줘야 됐는데 빼먹었다. 아 이런 건 금방 파악할 수 있어야 되는데..
@Test void helloMessage(){ String result = ms.getMessage("hello", null, Locale.ENGLISH); String result2 = ms.getMessage("hello", null, null); log.info(result); log.info(result2); //assertThat(result).isEqualTo("안녕"); } 순간 당황을 했다.. 다시 파일을 저장.