NAVER Pay Dev Blog - Medium 최신 글
medium.com에서 발행한 기술 글을 테크곳간이 수집해 최신순으로 보여드립니다.
최신 글
Playwright E2E 테스트로 자가개선 루프 구축하기
안녕하세요. 네이버페이 금융FE 팀의 정회원입니다. 사내 ENGINEERING DAY 2026에서 발표한 “AI 에이전트를 위한 Playwright E2E 테스트 하네스 구축하기” 내용을 글로 옮기고 일부 설명을 덧붙였습니다. 발표 영상은 NAVER D2나 YouTube에서 보실 수 있습니다. 검증이 병목이다 2026년, AI가 코드를 짜는 시대가 되며 코딩은 확실히 빨라졌습니다. 제가 주로 작업하는 코드베이스에서 개발자 한 명이 머지한 PR을 세어봤더니, AI 도구를 본격 도입하기 전 9개월은 227건, 도입 후 9개월은 348건으로 1.5배가 됐습니다. 그럼 PR이 늘어난 만큼, 검증도 1.5배 더 잘 되고 있을까요? 한번은 에이전트에게 Next.js 버전 업그레이드를 맡겼더니 얼마 뒤 ‘문제없이 완료했습니다.’ 라고 보고하고 PR도 작성했습니다. 그런데 막상 돌려보니 특정 페이지에서 뒤로가기를 눌렀을 때 기존 동작이 깨져 있었습니다. 프레임워크 내부 구현이 바뀌면서 기존 기능이 영향을 받은 것인데, 에이전트는 이걸 확인하지 않은 채 ‘끝났다’고 보고한 것입니다. 여러분도 누군가가 바이브 코딩하고 검토 없이 올린 PR을 보고 당황하신 적이 있으실 겁니다. PR마다 로컬에 받아 직접 돌려보고, 문제 없는지 확인할 수 있을까요? 쏟아지는 변경을 사람이 일일이 살펴보는 건 현실적으로 불가능합니다. 즉, 코딩은 빨라졌지만 검증이 병목이 된 것입니다. 간단한 스크립트나 사내 도구라면 일단 배포하고, 문제가 생기면 그때 손봐도 됩니다. 하지만 수십만 명이 쓰는 서비스라면, 걸러지지 않은 결함이 심각한 사업적 타격으로 이어질 수 있습니다. 그렇다고 전부 QA 팀에 떠넘길 수도 없는 노릇입니다. AI가 쓴 코드, 어떻게 검증해야 할까요? AI에게 검증할 수단을 주자 https://medium.com/media/c5f0d8d7e564b7e2b450435729f83521/href Claude Code를 만든 Boris Cherny는 얼마 전 X(트위터)에 ‘Claude에게 자기 작업을 검증할 수단을 줘라. 이건 늘 결과를 2~3배 끌어내는 방법이었다.’ 라고 남겼습니다. 모델에게 검증 수단을 제공하는 것이, 모델이 똑똑해질수록 오히려 더 중요해졌다는 이야기였습니다. 개발자가 자신이 쓴 코드의 결함을 잘 못 찾듯, 모델도 자기가 짠 코드에 대해서는 관대합니다. ‘이 정도면 잘했네’ 하고 작업을 끝내버립니다. 모델에게 검증할 수단을 주기 위해 가장 먼저 떠올린 방법은 에이전트가 직접 브라우저를 조작하게 하는 것이었습니다. Vercel의 Agent Browser, Claude의 Chrome 익스텐션, Playwright MCP 같은 도구들이 이미 나와 있었습니다. 이런 도구를 쥐어주면, AI 에이전트도 사람처럼 화면을 확인하고 브라우저를 직접 조작할 수 있습니다. 덕분에 사람이 매번 직접 확인하던 부담은 확실히 줄었습니다. 문제는 코드를 고칠 때마다 에이전트에게 그 시나리오를 처음부터 다시 시켜야 한다는 점이었습니다. 시나리오를 마크다운에 적어두고 반복하게 할 수는 있었지만, 매번 실행이 오래 걸리고, 토큰도 많이 소모됩니다. 무엇보다 결과가 비결정론적입니다. 필요한 건 에이전트가 언제 돌려도 똑같이 동작하는 결정론적인 테스트 코드였습니다. 🥵 에이전트에게 매번 건네는 테스트 시나리오 1. 대출 비교 페이지(/loan)를 연다 2. 한도조회 버튼을 누른다 3. 한도조회 결과가 보이는지 확인한다 매번 처음부터 다시 실행 → 느리고 토큰을 많이 소모 에이전트가 매번 새로 해석해 조작 → 비결정론적 ✅ 한 번 짜두는 테스트 코드 test('한도조회 버튼을 누르면 결과가 보인다', async ({ page }) => { await page.goto('/loan') await page.getByRole('button', { name: '한도조회' }).click() await expect(page.getByRole('heading', { name: '한도조회 결과' })).toBeVisible() }) LLM 없이 즉시 실행 → 빠르고 토큰
2026년 7월 3일
Composite PK에서 시작된 Spring Boot 4 / Spring Batch 6 업그레이드 기록
Spring Data JDBC Composite PK를 쓰고 싶었을 뿐인데, Batch·Kotlin·Jackson·Gradle까지 함께 정리하게 된 이야기 안녕하세요. 네이버페이 카드 BE 팀 오규석 입니다. 네이버페이에서 카드결제 매입/정산 개발을 담당하고 있습니다. 이 글은 카드 매입/정산 배치 모듈을 Spring Boot 3.5.0 → 4.0.1로 업그레이드하면서 만났던 시행착오와 진행과정을 정리한 기록입니다. 📝 용어 한 줄 정리 이 글에서는 같은 대상을 두 가지로 부릅니다. Composite PK는 DB 관점에서 PK가 여러 컬럼으로 구성된 키를 가리키고, Composite ID는 이 PK를 Spring Data JDBC에서 표현한 ID 클래스 매핑을 가리킵니다. 즉 “DB의 Composite PK를 Spring Data JDBC의 Composite ID로 매핑하는 작업” 이라고 보시면 됩니다. 먼저 요약하면 시작은 Spring Data JDBC의 Composite ID 적용이었습니다. 실제 작업은 Spring Boot 4 / Spring Framework 7 / Spring Batch 6 / Jackson 3 전환으로 확장되었습니다. 가장 큰 변경은 Batch 메타데이터 저장 방식, Job 실행 API, JobParameters 구조, listener·item 패키지 이동이었습니다. 운영 반영 전에는 Spring Batch v5에서 실패한 JobExecution 정리, Batch 메타 스키마 마이그레이션, DB 접근 정책 확인이 필요했습니다. 메이저 업그레이드는 “컴파일 통과”보다 “운영에서 기존과 동일하게 동작하는지”를 확인하는 작업에 가까웠습니다. 이 글의 전제 이 글은 실제 업그레이드 작업 당시 적용한 Spring Boot 4.0.1 버전 기준의 기록입니다. 이 글을 읽으시는 시점에는 더 최신 패치 버전이 있을 수 있고, Spring Boot 4 / Spring Batch 6은 패치 버전에 따라 세부적인 내용이 이 글과 차이가 있을 수 있습니다. 이 글에서 나오는 코드는 실제 프로젝트 코드가 아닌 상황을 단순화한 예제입니다. 또한 이 글은 일반적인 Spring WAS 서버가 아니라, Spring Batch Application 모듈 업그레이드이니 일반적인 Spring 서버 업그레이드시에는 추가 사항이 필요할 수 있습니다. 이 글은 다음 환경을 기준으로 작성했습니다. (화살표(→) 앞은 업그레이드 전, 뒤는 업그레이드 후 버전입니다) Spring Boot 3.5.0 → 4.0.1 (Spring Framework 6.2.7 → 7.0.2) Spring Batch 5.2.2 → 6.0.1 Spring Data JDBC 3.5.0 → 4.0.1 Kotlin 2.1 → 2.3 Java 21 ojdbc8 → ojdbc11 kotest 5.9.1 → 6.0.7 spring-boot-starter-aop → spring-boot-starter-aspectj Oracle DB에 JDBC 기반 Spring Batch 메타데이터 저장 📖 이 글을 읽는 법 글이 다루는 범위가 꽤 넓습니다. 관심사에 따라 다음 순서로 골라 읽으셔도 됩니다. - Spring Data JDBC Composite ID가 뭔지 궁금하다면 → 1장 - Spring Batch 6 마이그레이션이 주 목적이라면 → 3장 - 운영 반영 체크리스트가 필요하다면 → 5장과 마지막 체크리스트 참고한 공식 문서 - Spring Boot Upgrading Guide — https://docs.spring.io/spring-boot/upgrading.html#upgrading.from-3x - Spring Boot 4.0 Migration Guide — https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide - Spring Boot Release Notes — https://github.com/spring-projects/spring-boot/wiki#release-
2026년 5월 11일
신입개발자의 역량과 성장에 대해서(feat. Done is better than perfect)
안녕하세요. 네이버 파이낸셜 카드 BE 팀의 허수진입니다. 저는 현재 3년차 서버 개발자로서 Java, Kotlin, Spring을 주로 다루며 성장해나가고 있어요. 이 글에서는 개발자로 일하면서 느낀 성장에 대한 생각과, 신입 개발자가 갖춰야 할 역량에 대해 경험을 바탕으로 공유드리고자 합니다! 신입사원 첫 출근 짤 사실 우리가 배운 국영수가 핵심 역량이라고 생각합니다. 국어: 소통의 도구 — 동료들과 명확하게 의사소통할 수 있는 능력 영어: 정보 습득 도구 — 최신 기술 문서를 읽을 수 있는 능력 수학: 논리적 사고 도구 — 비즈니스 로직을 체계적으로 분석하는 능력 우리는 신입개발자가 가져야 할 역량을 충분히 보유하고 있습니다. 이를 바탕으로 어떤 성장 전략을 세워나갔는지 단계별로 공유해보겠습니다! 그게 그 뜻이었어? 용어 정리하기 — 카드 수수료 예시 “카드 수수료”라는 용어 하나만 봐도 여러 의미가 있었습니다. 가맹점 수수료: 가맹점이 카드사에 지불하는 수수료 연회비: 카드 소지자가 카드사에 지불하는 연간 수수료 해외 이용 수수료: 해외 결제 시 발생하는 환전 수수료 현금서비스 수수료: ATM에서 현금을 인출할 때 발생하는 수수료 // 혼란스러운 초기 코드 public class CardFeeService { // 이 수수료가 정확히 무엇을 의미하는지 불분명 public BigDecimal calculateFee(String cardNumber, BigDecimal amount) { return amount.multiply(new BigDecimal("0.03")); } } 의미에 맞는 용어로 전환하기 논의를 할 때 “수수료”라고 하면 각자 다른 것을 생각하고 있어서 혼란스러운 소통을 할 여지가 있습니다. 코드와 문서에 이를 한번 정리하고 넘어간다면 훨씬 수월한 소통을 할 수 있게 되었던 것 같습니다. // 명확한 도메인 용어를 사용한 개선된 코드 public class MerchantFeeCalculator { /** * 가맹점 수수료 계산 * - 가맹점이 카드사에 지불하는 수수료 * - 업종별로 다른 수수료율 적용 */ public BigDecimal calculateMerchantFee(CardType cardType, MerchantCategory category, BigDecimal amount) { BigDecimal feeRate = merchantFeeRateRepository.findByCardTypeAndCategory(cardType, category); return amount.multiply(feeRate); } } 해석의 여지를 없애기 일할 때는 저맥락 소통이 중요하다고 생각합니다. 고맥락 대화: “그거 처리해 주세요” (무엇을 처리할지 불분명) 저맥락 대화: “KB 발급사 카드 코드 변경건 내일까지 처리 가능할까요?” (소통할 때는 편하게 하기보다 예의 바르게 소통하는 것이 중요한 것 같습니다. 🙌🏼) 다양한 관점에서 정보 찾기 좋은 개발자가 되려면 스스로 정보를 찾는 능력이 필수라고 생각합니다. 저는 다음과 같은 키워드로 검색하며 균형 잡힌 시각을 기르려고 노력하고 있습니다. 긍정적 정보뿐만 아니라 부정적 정보도 함께 탐색 Spring Boot 장점 뿐만 아니라 Spring Boot 단점, Spring Boot 주의할점 Kotlin cons, Kotlin pitfall, Kotlin gotchas JPA when not to use, JPA warns 구체적인 구글 검색 방법 site:stackoverflow.com JPA N+1 problem - Stack Overflow에서만 JPA N+1 문제 검색 "Spring Boot" -tutorial 2024..2025 - Spring Boot 관련 최신 정보 중 튜토리얼 제외 Kotlin coroutines pitfalls OR gotchas - Kotlin 코루틴의 함정이나 주의사항 검색 오픈소스 이슈 트래커 활용 라이브러리나 프레임워크의 실제 문제점을 파악하려면 GitHub Issues를 직접 확인하는 것이 좋은 것 같습니다
2025년 8월 7일
똑같은 코드인데 왜 안 돼? Spring JDBC 컨버터 미궁 탈출기
안녕하세요. 네이버페이에서 카드 BE 담당하고 있는 허수진입니다. 🙂 저희 팀에서는 데이터베이스의 실제 저장 형태와 무관하게 애플리케이션에서 타입 안정성을 확보하면서 원하는 형식으로 데이터를 다루기 위해서 Spring Data JDBC에서 커스텀 컨버터를 사용하고 있습니다. e.g. DB에는 문자열로 저장되어 있지만 애플리케이션에서는 LocalDate로 다루고 싶거나 DB의 숫자값을 애플리케이션에서는 Enum으로 처리하고 싶거나 JSON 문자열을 객체로 자동 변환해서 사용하고 싶을 때 출처 geeksforgeeks 그런데 똑같은 패턴으로 작성한 컨버터인데 LocalDateTime은 작동하고 LocalDate는 예상과 다르게 동작하는 현상을 발견했습니다. 😱 공부해보니 JDBC 처리 과정에 해답이 있었습니다. 이 글에서는 JDBC 계층별 동작 원리와 타입 변환 우선순위를 파헤쳐보고, 실제 해결책까지 다뤄보겠습니다! 커스텀 컨버터 작성과 등록 먼저 일반적인 방식으로 커스텀 컨버터를 작성했습니다. @WritingConverter class LocalDateToStringConverter : Converter<LocalDate, String> { override fun convert(source: LocalDate): String = source.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) }@ReadingConverter class StringToLocalDateConverter : Converter<String, LocalDate> { override fun convert(source: String): LocalDate = LocalDate.parse(source, DateTimeFormatter.ofPattern("yyyy-MM-dd")) } 그리고 Spring Data JDBC 설정에서 컨버터를 등록합니다. @Configuration class DatabaseConfig : AbstractJdbcConfiguration() { override fun userConverters(): MutableList<Converter<*, *>> = listOf( LocalDateToStringConverter(), // LocalDate → String StringToLocalDateConverter(), // String → LocalDate ).toMutableList() } 이렇게 하면 Entity의 LocalDate 필드가 자동으로 문자열로 변환되어 DB에 저장될 것으로 예상했습니다. data class PaymentEntity( val id: Long, val paymentDate: LocalDate // "2024-08-01" 형식으로 저장되길 기대 ) 하지만 실제로는 Oracle의 타임스탬프 형식으로 저장되었습니다. 분명 컨버터를 올바르게 등록했는데 왜 작동하지 않는 걸까요? 🥹 -- 기대했던 결과 SELECT payment_date FROM payment_entity WHERE id = 1; -- '2024-08-01' -- 실제 저장된 결과 SELECT payment_date FROM payment_entity WHERE id = 1; -- '01-AUG-24' (Oracle 기본 DATE 형식) 심지어 VARCHAR 컬럼임에도 불구하고 Oracle의 DATE 타입으로 변환된 결과가 저장되었습니다. 반면 동일한 방식으로 작성한 LocalDateTime 컨버터는 완벽하게 작동했습니다. @WritingConverter class LocalDateTimeToStringConverter : Converter<LocalDateTime, String> { override fun convert(source: LocalDateTime): String = source.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) } 핵심 문제 발견 Spring JDBC 소스코드를 분석한 결과, StatementCreatorUtils.s
2025년 8월 5일
MongoDB CQRS 성능 개선기: 예상치 못한 Tomcat NDJSON 병목 해결
소개 안녕하세요. 네이버페이 혜택&정산BE 에서 네이버페이 정산 개발을 담당하고 있는 하성권 입니다. 최근 대용량 데이터의 조회 성능을 개선하기 위해 CQRS 패턴 기반의 MongoDB 시스템을 도입했습니다. 이 과정에서 예상치 못한 성능 병목 현상을 겪었고, 그 해결 과정을 공유하고자 합니다. MongoDB와 CQRS 패턴을 적용한 이후 API 조회 속도는 기존 샤딩 DB 대비 40배 이상 향상됐습니다. 하지만, 엑셀 다운로드 성능은 전혀 개선되지 않았습니다. MongoDB 튜닝도 하고, 엑셀 라이브러리도 변경해봤지만 효과가 없었습니다. 문제의 원인은 의외로 Tomcat(Spring Web Embedded)에서 Flux 리턴 타입으로 NDJSON을 스트리밍할 때, 한 줄씩 blocking 되는 구조에 있었습니다. 1. 배경: 대형 가맹점의 고통 네이버페이 정산 시스템은 가맹점 기반의 샤딩 DB를 사용 중에 있습니다. 성능 개선 및 스케일아웃에 유리하도록 채택한 샤딩 DB 구조였지만 샤딩 불균형 문제가 발생하고 있습니다. 상위 1위 가맹점: 전체 데이터의 2% 상위 1~10위 가맹점 합산: 전체 데이터의 약 7% 서비스가 확장되고, 쿼리가 날로 복잡해짐에 따라 이런 대형 가맹점들은 조회할 때마다 타임아웃에 시달렸습니다. 그래서 CQRS 패턴을 도입하기로 했습니다. 1.1 CQRS 도입: 절반의 성공 MongoDB에 미리 가공된 데이터를 저장해 join 없이 빠르게 조회되는 시스템을 구축했습니다. API 성능 측정 결과 기획팀에게 성능 개선 효과 좋다고 자랑하러 가도 되겠다!! 엑셀 다운로드 성능 측정 결과 어….? 2. 원인을 찾아서 성능 병목을 발생시키는 원인 파악을 위해서 구간별로 성능 테스트를 진행하였습니다. 대략적인 엑셀 파일 생성 흐름도 2.1. MongoDB 조회 성능 readConcern 레벨 조정 projection으로 필요한 필드만 조회 결과: 쿼리 성능 약 10% 내외의 조회 성능 개선이 있었지만 엑셀 파일 생성 성능에 영향은 없었음 2.2. 엑셀 라이브러리 성능 POI SXSSF(현재 사용중) vs EasyExcel vs FastExcel 벤치마킹 FastExcel 도입 시 약 2배 수준의 엑셀 파일 생성 성능 개선 효과 결과: 엑셀 파일 생성 성능은 여전히 개선되지 않음 2.3. 네트워크 성능 엑셀 결과 파일과 비슷한 용량의 파일 전송 속도 테스트 결과: 8만건 약 1초 내외로 전송 완료되어 네트워크는 전혀 이슈가 되지 않음 2.4 원인 발견: Tomcat + NDJSON 조합 남은 구간(Spring web) 에서 테스트를 하다가 이상한 점을 발견했습니다. # JSON 형식 (일반 배열) curl localhost:8080/api/excel/json > Speed: 37.7MB/s # NDJSON 형식 (줄바꿈으로 구분) curl localhost:8080/api/excel/ndjson > Speed: 3.7MB/s 3. 원인 분석 NDJSON 이란? Newline Delimited JSON의 약자로 각 줄이 JSON 객체 하나를 나타내고, 각 JSON 객체는 개행 문자(\n)로 구분되어 저장되는 데이터 포맷을 의미합니다. // 예시 {"name": "John", "age": 30} {"name": "Jane", "age": 25} {"name": "Peter", "age": 40} 이러한 NDJSON의 특성으로 인해 spring web에서 Flux 리턴 타입의 NDJSON 응답 형식을 처리 시 아래와 같은 처리를 순서대로 하게 됩니다. 한 줄 직렬화 → HttpResponse에 write Tomcat은 write 완료까지 blocking 50만 줄 = 50만 번 blocking(사실 개행문 까지 포함하여 100만 번) // org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler private static final class DefaultSseEmitterHandler implemen
2025년 7월 31일
레거시 시스템 교체기: 실시간 트래픽 미러링을 통한 안정적 전환 사례
안녕하세요. 네이버페이 신용서비스BE 개발자 권민준입니다. 이번 글에서는 네이버페이 서비스 중 하나인 ‘신용점수 조회’ 서비스의 레거시 시스템을 새로운 시스템으로 전환하는 과정에 대해서 소개하고자 합니다. 이 과정에서 저희 팀은 Spring WebFlux에서 Spring WebMVC로의 전환을 시도했으며, 이를 위해 트래픽 미러링 기법을 활용하여 신규 시스템의 안정성을 검증했습니다. 전환 배경, 기술 스택 선택 이유, 트래픽 미러링 구현 방법, 그리고 전환 과정에서 얻은 교훈 등을 공유하려고 합니다. 기술 스택 이전 배경 및 마이그레이션 필요성 ‘신용점수 조회’ 서비스는 초기부터 Kotlin과 Spring WebFlux 기반으로 개발되어 운영되어 왔습니다. WebFlux는 비동기 스트림 기반 아키텍처로 높은 동시성 처리에 적합하지만, 운영 경험이 쌓이면서 점점 더 많은 문제가 드러났습니다. 운영하는 시간이 지날수록 Reactive 기반의 코드가 점차 복잡해지고, 비즈니스 로직을 구현하는 데 필요한 보일러플레이트 코드가 많아지면서 유지보수가 어려워졌습니다. WebFlux는 장점도 많지만, 다음과 같은 문제로 인해 실제 운영에서는 오히려 비효율을 초래했습니다: 팀원들이 함수형 리액티브 프로그래밍에 익숙하지 않아 러닝 커브가 높음 같은 기능을 구현하더라도 표현 방식이 다양하여 코드 스타일이 통일되지 않음 Kotlin의 null-safety와 WebFlux 리액티브 타입(Flux/Mono)의 패러다임 충돌 예를 들어, 다음과 같이 동일한 기능을 각자 다르게 구현할 수 있었습니다: // 방식 1: flatMap + map 체인 userRepository.findById(id) .flatMap { user -> Mono.just(UserDto.from(user)) } // 방식 2: switchIfEmpty 사용 userRepository.findById(id) .switchIfEmpty(Mono.error(NotFoundException())) .map { UserDto.from(it) } 또한, Mono<T>는 null 값을 담을 수 없기 때문에 다음과 같은 코드 보일러플레이트가 자주 생겼습니다: val name = dto.name ?: "" // 빈 String을 null 대신 사용 Mono.just(name) // nullable 타입을 강제로 non-null로 변환하여 처리 이러한 경험을 통해 생산성 저하와 코드 품질 저하를 체감하게 되었고, 팀 내 회의를 통해 단순히 스키마만 바꾸는 것이 아니라 시스템 전반을 보다 유지보수하기 쉬운 구조로 개선하자는 결론에 도달했습니다. 그렇게 해서 Spring WebMVC 기반의 신규 시스템 구축이 시작되었고, 기존 시스템과 병행 운영을 위한 전략도 함께 수립되었습니다. 그리고 또한, 도메인 확장과 기능 추가에 따라 기존 MongoDB 컬렉션 구조가 한계를 드러냈습니다. 서비스를 운영하는 동안 점점 요구사항이 복잡해지고 변경되면서, 기존 데이터베이스 스키마로는 이를 수용하기 어려운 상황이 발생했고, 이에 따라 스키마를 대폭 수정해야 하는 신규 스키마로의 전환 필요성이 발생했습니다. 다만 운영 중인 데이터를 한 번에 옮기는 것은 리스크가 컸기 때문에, 데이터 마이그레이션을 동반한 신규 시스템 구축이 필요했습니다. WebMVC 선택 이유와 기대 성능 Spring WebMVC는 전통적인 동기식 요청-응답 기반이며, 대부분의 개발자에게 익숙하고 직관적인 프로그래밍 모델을 제공합니다. 이러한 특성은 팀의 개발 속도와 코드 일관성을 높이는 데 크게 기여했습니다. 더불어 JVM 생태계에서는 Java 21의 Virtual Threads 도입 등으로 인해 동기 코드에서도 높은 동시성 처리 성능을 기대할 수 있게 되었습니다. 기술 스택 전환을 논의하던 시점에, 저희는 Kotlin Reactor Plugin을 도입하는 방안도 고려했습니다. 이 플러그인은 Kotlin에서 Reactor 타입(Flux, Mono 등)을 더 자연스럽게 사용할 수 있도록 DSL 및 확장 기능을 제공하여, 코드의 간결성과 가
2025년 6월 30일
도메인 Error를 다루는 고민과 Arrow의 Either
소개 안녕하세요. 결제형 BE팀의 조남원입니다. 저희 팀은 복잡한 결제 도메인의 안정성과 코드 명확성을 높이기 위해 끊임없이 고민하고 있습니다. 이 글에서는 도메인 에러를 다루기 위해 어떤 고민을 했고, Arrow 를 도입해서 어떻게 효과적으로 처리하고 있는지, 그 적용 과정과 경험을 공유하고자 합니다. 배경 저희 팀은 기존의 old protocol 기반 결제 로직을 현대적인 HTTP API 기반의 신규 시스템으로 전환하는 프로젝트를 진행했습니다. 기존 시스템은 Java 8과 Spring 4 프레임워크를 기반으로 하고 있었으며, 다수의 EOL(End-Of-Life)된 라이브러리들이 복잡하게 얽혀 있어 버전 업그레이드나 기능 확장이 매우 어려운 상황이었습니다. 내부 논의를 거쳐, 10여 년간 결제 도메인을 지탱해 온 기존 코드를 정리하고 Kotlin과 Spring Boot를 기반으로 한 새로운 결제 시스템을 개발하기로 결정했습니다. 도메인 Error, 어떻게 다뤄야 할까요? 새로운 시스템을 설계하면서 가장 큰 고민 중 하나는 ‘도메인 로직에서 발생하는 다양한 상황, 특히 오류 상황을 어떻게 명확하고 일관되게 코드로 표현할 것인가?’였습니다. 기존 시스템에서는 다음과 같은 어려움이 있었습니다: 도메인 예외가 발생 조건과 처리하는 위치가 구현마다 달라 예외의 근본 원인을 추적하기 어려웠습니다. 서비스가 성장하며 예외 클래스가 계속 늘어났고, 전체적인 오류 처리 흐름을 파악하기 쉽지 않았습니다. 저희는 이 문제들을 해결하기 위해, 함수의 결과를 값(Value)으로 다루는 방식을 고민했고, 그 첫 번째 시도로 Kotlin의 Sealed Class를 적극적으로 활용해 보았습니다. Sealed Class로 에러 핸들링 개선하기 Kotlin의 sealed class는 타입이 가질 수 있는 여러 상태를 명확하게 정의하고, 컴파일 시점에 하위 클래스의 종류를 제한하여 예측하지 못한 상황 발생 가능성을 크게 줄여주는 강력한 기능입니다. 실제 ‘결제 승인 API’ 응답을 BillingPaymentResult라는 sealed class로 모델링했습니다. 결제 승인 API는 다양한 resultCode를 반환하는데, 저희 도메인에서는 이 중 특정 케이스들에만 관심을 가집니다. // API 응답 결과를 표현하는 Sealed Class sealed class BillingPaymentResult { data class Billed(val value: BilledPaymentInfo) : BillingPaymentResult() data class PgFailed(val resultCode: String, ...) : BillingPaymentResult() data class BillingFailed(val resultCode: String, ...) : BillingPaymentResult() // ... 기타 실패 케이스 } // API 호출 결과를 BillingPaymentResult로 변환 fun executeBillingPayment(command: PaymentCommand): BillingPaymentResult { return kotlin.runCatching { val responseEntity = this.client.billingPayment(command.toRequestOrThrow()) responseEntity.body?.let { body -> when (body.resultCode) { // resultCode: "100" 인 경우는 정상 케이스 "100" -> BillingPaymentResult.Billed(value = body.toDomainOrThrow()) // resultCode: "2xx" 인 경우는 비정상 케이스 "200", "201", "202" -> BillingPaymentResult.InvalidCommand(exception = IllegalArgumentException(body.resultMessage), resultMessage = body.resultMessage) // ... 그외
2025년 6월 24일
Node.js가 싱글스레드 서버라는 미신(feat. Node.js의 대용량 데이터 처리)
“Node.js는 싱글스레드인가, 멀티스레드인가?” 대부분은 너무 쉽게 대답합니다. 하지만 정말 그렇게 단순한 문제일까요? 1. Node.js가 싱글스레드 서버라는 미신 저는 네이버페이에서 백엔드 서버 개발 업무를 맡고 있는 김태우입니다. “Node.js는 싱글스레드인가요, 멀티스레드인가요?” 제가 이직 전 회사에서 면접관으로 면접에 참여할 때 즐겨 묻는 질문이였습니다. 기술 면접에서 이런 질문을 던지면, 열이면 아홉은 ‘Node.js 면접 단골 질문 20선’ 같은 글로 벼락치기하고 온 티를 내며 이렇게 대답합니다. “싱글스레드입니다!” 틀렸다는 건 아닙니다, 완전히 틀린 건 아니죠. 하지만 완전히 맞다 보기도 어렵습니다. HTTP 요청은 응답을 기다리는 동안 응답을 기다리는 Loop에 빠져 스레드를 점유하게 됩니다. 그렇기에, 일반적인 단일 스레드에서는 그 요청이 끝날 때까지 다음 요청을 받을 수 없습니다. 예를 들어, 어떤 API 요청이 처리되는 과정에서 외부에 요청을 보내고 10초를 기다린다고 가정해 봅시다. 순수한 싱글스레드 서버라면 그 10초 동안 서버는 멈춰 버리는게 맞습니다, 그런데 Node.js 서버는 멈추지 않고 그 시간에도 다른 요청을 처리합니다. 그래서 저는 이런 설명을 마친 다음, 다시 이렇게 물어봅니다. “그래도 Node.js는 싱글스레드인가요?” 그러면 보통 면접장에는 아주 묘한 정적이 흐릅니다. “시간이 없어서 그 부분까지는 공부를 못 했습니다…” “그 부분은 더 공부해보겠습니다…” 그런 대답도 나오긴 하지만, 그런 대답도 정적만큼 어색하고… 불행합니다. 2. 싱글스레드처럼 보이지만, 속은 다르다 Node.js의 스레드 구조에 대해 검색해 보면, 늘 모호한 표현들부터 마주치게 됩니다. “Single-threaded event loop”, “Non-blocking I/O” 같은 설명 말입니다. 그럴싸해 보이지만, 사실 우리가 정말 알고 싶은 건 이겁니다 “이거, 그냥 싱글스레드냐 아니냐만 좀 명확히 말해줘!” 애석하게도, 그 대답은 “둘 다 맞다”입니다. 아주 개발자 친화적인 답변이죠. 똑 부러진 걸 원했는데, 돌아온 건 철학 수업입니다. 하지만 어쩔 수 없습니다. 현실이 그렇거든요. Node.js는 자바스크립트 코드를 실행할 때, 딱 하나의 메인 스레드를 사용합니다. 이 안에서 우리가 잘 아는 이벤트 루프(Event Loop)가 돌아가고 있죠. 그런데 이 메인 스레드는, 오래 걸리는 작업은 직접 처리하지 않습니다. 예를 들어: 파일 시스템 접근 데이터베이스 쿼리 HTTP 요청 이런 작업들은 시간을 꽤 잡아먹을 수 있습니다, Node.js는 이런 I/O 작업을 libuv라는 C로 작성된 라이브러리에 넘깁니다. 물론 Nginx처럼 커널 이벤트를 직접 다루는 서버는 단일 스레드로도 hang 없이 I/O를 처리할 수 있습니다. 하지만 Node.js는 애플리케이션 레벨에서 동작하므로, 그런 방식은 현실적이지 않죠. 그래서 오래 걸리는 일은 과감히 libuv에게 맡기는 겁니다. libuv는 전달받은 작업을 내부의 워커스레드에 던지고, 메인 스레드는 다시 이벤트 루프를 돌며 다른 작업을 처리합니다. 이런 구조로 인해 Node.js는 멈추지 않고 작동할 수 있습니다. 그렇기에 Node.js는 겉보기엔 싱글스레드지만, 실제로는 뒤에서는 묵묵히 일하는 워커 스레드들이 도와주는 멀티스레드 아키텍처 기반 서버입니다. 게다가, 굳이 libuv 구조까지 고려하지 않더라도, Node.js v12부터는 worker_threads 모듈이 정식 도입 되면서, 개발자 스스로도 명시적인 멀티스레드 처리가 가능해졌습니다. 이제는 직접 워커스레드를 만들고, 공유 메모리나 메시지를 통해 데이터도 주고받을 수 있죠. 즉, Node.js는 “분명히 멀티스레드 요소를 활용하는” 서버입니다. 그리고 최신 버전에서는 ‘진짜’ 멀티스레드 프로그래밍까지 가능해졌습니다. 3. 개발자 입장에선 싱글스레드가 맞다 이쯤 되면 이런 생각이 들 수도 있습니다. “아니, 뒤에서 멀티스레드가 그렇게 열심히 돌아가고 있는데, 왜 굳이 Node.js를 싱글스
2025년 6월 17일
Opensearch 검색엔진을 이용한 네이버 페이 검색 하기
안녕하세요, 네이버페이 유저인프라BE에서 백엔드 개발을 하고 있는 민경훈 입니다. 이번에 소개하고자 하는 내용은 최근 네이버페이 검색 서비스를 오픈하면서 경험한 고민과 해결 과정을 소개하고자 합니다. 검색이라는 기능이 단순해 보이지만, 실제로는 사용자 경험부터 시스템 구조까지 많은 고민이 필요했습니다. 번거로운 탐색을 검색으로 바꾸기 네이버페이에는 수백 개의 다양한 서비스가 존재합니다. 기존에는 이 서비스들을 일일이 찾아가며 사용하는 방식이었는데, 이 과정은 사용자경험이 좋지 않아 항상 검색에 대한 니즈가 있어왔습니다. 그래서 시작한 것이 “검색”입니다. 단순한 텍스트 검색이 아닌, 네이버페이 안에서 제공되는 서비스, 이벤트, 기능들을 손쉽게 찾을 수 있도록 하는 검색 기능을 제공하자는 것이 목표였습니다. 검색이 가능하려면? 검색 기능을 구현하려면 먼저 서비스 정보를 구조화된 형태로 정리할 필요가 있습니다. 기존에는 FE 화면에 하드코딩되어 있었던 서비스 정보를 DB화하는 것부터 시작했습니다. 이 정보를 관리자가 직접 등록하고 수정할 수 있도록 어드민 도구도 함께 개발했습니다. 검색 결과에 보여질 아이콘, 뱃지, 노출 우선순위 같은 정보도 이 과정에서 함께 관리되도록 설계했습니다. 이렇게 해서 검색 엔진에 색인할 사전 준비를 하였습니다. 검색엔진 색인하기 색인을 위해 OpenSearch를 검색엔진으로 사용하였습니다. OpenSearch는 사내 클라우드 플랫폼에서 온디맨드로 제공되는 상품 형태로 사용할 수 있어 확장성과 관리 측면에서도 이점이 많았습니다. OpenSearch는 Elasticsearch 기반으로 만들어진 고성능 분산 검색엔진으로, 기본적으로 역색인(inverted index) 구조를 사용합니다. 문서의 각 필드를 분석하여 단어(토큰) 단위로 색인을 구성하고, 검색 시에는 해당 단어들이 어느 문서에 포함되어 있는지를 빠르게 찾을 수 있습니다. 이 구조 덕분에 대용량 데이터에 대해서도 빠르고 정교한 검색이 가능합니다. 네이버페이 검색에서는 DB에 있는 데이터는 그대로 두고, 검색 전용 색인을 따로 만들어 관리하는 구조를 채택했습니다. 이는 검색 로직 변경이나 인덱스 개선 작업을 더 유연하게 할 수 있도록 도와주며, 장애 대응이나 롤백 시에도 원본 데이터는 건드리지 않기 때문에 유리한 구조입니다. 색인은 하루에 한 번 자동으로 수행되며, 필요 시 관리자가 수동으로 즉시 색인할 수 있는 기능도 어드민에 추가했습니다. 다운타임 없는 색인 교체 색인을 새로 할 때 기존 검색에 영향을 주면 안 되기 때문에 index alias를 사용했습니다. index alias는 말 그대로 색인을 가리키는 별칭입니다. 이 별칭을 통해 사용자 검색 요청은 항상 alias를 바라보게 하고, 실제 물리적인 색인(index)은 백그라운드에서 새로 생성하여 교체하는 방식입니다. alias는 atomic하게 스위칭이 가능하므로 다운타임 없이 색인을 교체할 수 있습니다. 이는 관리자가 빈번하게 재색인을 할 수 있는 환경에서 서비스 안정성에 중요한 포인트였습니다. 사용자의 타이핑, 그대로 검색되게 네이버페이 검색은 기존의 컨텐츠 검색과는 다릅니다. 자동완성처럼 사용자 입력 하나하나에 반응해야 하고, 이때 가장 중요한 기능 중 하나가 한글 자모 분리입니다. 한글은 복합 문자이기 때문에 ‘ㄱ’, ‘ㅏ’처럼 자음과 모음 단위로 쪼개어 검색이 되어야 사용자의 입력에 더 유연하게 대응할 수 있습니다. 이 기능을 위해 OpenSearch 플러그인 형태로 자모 분리 토큰 필터를 직접 개발했습니다. 한글을 자모로 분리하기 한글은 유니코드 상에서 규칙적인 구조를 가지고 있기 때문에 이를 활용하면 자음과 모음을 분리할 수 있습니다. 자모 분리 필터는 이 구조를 기반으로 동작하며, 예를 들어 “한글”이라는 단어는 “ㅎㅏㄴㄱㅡㄹ”로 분해됩니다. 이렇게 분해된 토큰을 기반으로 검색을 하면, 아직 문장을 완성하지 않은 입력에도 정확히 반응할 수 있게 됩니다. class JamoTokenFilter(tokenStream: TokenStream?) : TokenFilte
2025년 6월 2일
실무에서 만나는 DB isolation level
안녕하세요. 네이버페이 비즈인프라 BE의 박상욱입니다. 오늘은 DB islolation level(트랜잭션 격리 수준)을 실제 서비스 개발 중에 발생했던 이슈를 통해 비교적 친숙하게 다가가고자 합니다. 다들 전공시간이나 면접 대비를 위해서 DB isolation level에 대해서 공부를 해봤던 적이 있을 겁니다. 구글링을 하게 되면 나오는 대략적인 내용들은 DB isolation level에는 READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE 4개의 단계로 나누고, 보통 READ COMMITTED나 REPEATABLE READ를 사용한다. 이 정도의 내용과 함께 DB query 수준에서 transaction을 잡아 각 격리 수준의 차이점이 무엇인지 보여주는 내용이 대부분입니다. 실제 isolation level 관련한 이슈들이 실무에서 어떻게 발생하게 되는지는 잘 다루고 있지 않습니다. chat GPT가 설명하는 격리 수준. 그러나 막상 어떤느낌인지 감이 오지 않는다. 해당 내용을 공부하면서 단순히 각 격리 수준의 차이점에 대해서 깊은 이해가 아닌 암기만 했고, 응용도 실제 DB query console 수준에서만 재현을 해보았더니 막상 실제 서비스 개발 도중 DB isolation level 관련 이슈를 직면하게 되니 DB isolation level 관련 이슈일 것이라고는 전혀 짐작하지 못했습니다. 이어지는 내용은 DB isolation level 관련하여 기본적인 이해가 있다는 전제하에 진행되니 참고 부탁드리겠습니다. 문제 파악과 해결 과정 기존 Oracle 기반의 환경에서 MySQL로 DB를 변경하고, 신규 결제 시스템 개발을 진행하기 시작했습니다. 해당 결제 시스템은 결제 진행 마지막 후 사용자의 잔액을 업데이트 해주게 됩니다. @Transactional fun processPayment(userId: Long) { //... // 이후 결제 처리 로직 수행 (예시) println("Processing payment for user: ${user.name}") // 결제 완료 후 사용자의 잔액 업데이트 updateUserBalance (userId, balanceToUpdate, originalBalance) } UPDATE user_balance SET balance = {#balance_to_update} WHERE user_id = {#user_id_to_update} and balance = {#original_balance}; 결제 시스템의 경우 동일한 유저에게 여러 요청이 동시에 들어올 수 있기 때문에 동시성 이슈를 항상 신경써야 합니다. 그래서 업데이트 전 잔액이 결제 이전 잔액(originalBalance)과 동일할 때만 유저의 최종 잔액을 업데이트 해주게 됩니다. 한 건 한 건 순차적으로 들어올 때에는 당연히 문제가 없었고, 두 번 이상의 결제 요청을 문제 없이 처리하기 위해서 DB lock을 이용했습니다. 결제 Transaction 시작 제일 앞 부분에서 해당 사용자의 잔액 컬럼을 업데이트해 lock을 잡아 진행하도록 했습니다. (동시에 두 개 이상의 요청이 들어오면, 두 번째 이후로 update를 진행하게되는 요청은 첫 번째 요청이 끝날 때 까지 대기) @Transactional fun processPayment(userId: Long) { // 트랜잭션 내에서 해당 유저를 DB 락으로 가져옴. // 두 번째 이후 요청은 여기서 결제를 하지 않고 대기 val user = lockUserById(userId) // 이후 결제 처리 로직 수행 (예시) println("Processing payment for user: ${user.name}") // 결제 완료 후 사용자의 잔액 업데이트 updateUserBalance (userId, balanceToUpdate, originalBalance) } ##lockUserById 쿼리 UPDATE user_balance SET modidttm = {#sysytem_curren
2025년 5월 23일