🏢 당근마켓 기술 스택 분석
당근마켓이 동네 커뮤니티를 혁신하며 유니콘으로 성장한 기술 스택 심층 분석 - Ruby on Rails, Kotlin, Swift로 만든 하이퍼로컬 플랫폼
🏢 당근마켓 기술 스택 분석
동네 중고거래에서 시작해 지역 커뮤니티 플랫폼으로 진화한 당근마켓의 기술 스택을 심층 분석합니다.
"우리 동네를 더 따뜻하게" - 기술로 이웃을 연결하는 혁신
📊 회사 개요
서비스 규모
- 월간 활성 사용자: 2,500만+ 명
- 월간 거래: 300만+ 건
- 활성 동네: 4만+ 개
- 월간 등록 물품: 2,000만+ 개
- 서비스 국가: 4개국 (한국, 일본, 영국, 캐나다)
엔지니어링 조직
- 개발자: 200명+ (전체 직원 600+)
- 문화: "Trust & Impact"
- 조직: 목적 중심 스쿼드
- 기술 철학: 사용자 가치 우선
기술적 도전과제
- 하이퍼로컬: 정확한 위치 기반 서비스
- 실시간 매칭: 구매자-판매자 연결
- 신뢰 시스템: 동네 기반 평판
- 글로벌 확장: 각국 특성 대응
🏗️ 아키텍처 Overview
시스템 다이어그램
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile │────►│ API │────►│ Location │
│ Client │ │ Gateway │ │ Service │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐
│ │ Rails │ │ Matching │
│ │ Backend │ │ Engine │
│ └───────────┘ └───────────┘
│ │ │
└────────────────────┤ │
┌─────▼─────┐ ┌─────▼─────┐
│ Chat │ │ ML/AI │
│ Service │ │ Platform │
└───────────┘ └───────────┘
핵심 설계 원칙
- Hyperlocal First: 동네 중심 설계
- Trust by Design: 신뢰 기반 거래
- Mobile Native: 모바일 최적화
- Community Driven: 커뮤니티 우선
- Simple & Intuitive: 직관적 UX
기술 진화
- 2015-2016: MVP, 중고거래 시작
- 2017-2018: 동네인증, 매너온도
- 2019-2020: 동네생활, 당근페이
- 2021-2022: 글로벌 진출
- 2023-현재: AI 기반 추천, 부동산
🧩 기술 스택 상세
Backend 기술
Ruby on Rails 메인 서버
# 당근마켓 Rails 백엔드
class ItemsController < ApplicationController
before_action :authenticate_user!
before_action :verify_neighborhood
def create
@item = current_user.items.build(item_params)
@item.neighborhood = current_user.active_neighborhood
ActiveRecord::Base.transaction do
if @item.save
# 이미지 처리 (비동기)
ImageProcessingJob.perform_later(@item)
# AI 카테고리 분류
CategoryClassificationJob.perform_later(@item)
# 동네 사용자에게 알림
NotifyNeighborhoodJob.perform_later(@item)
# 실시간 피드 업데이트
ActionCable.server.broadcast(
"neighborhood_#{@item.neighborhood_id}_feed",
item: ItemSerializer.new(@item).as_json
)
render json: @item, serializer: ItemSerializer
else
render json: { errors: @item.errors }, status: :unprocessable_entity
end
end
end
def index
# 동네 기반 아이템 조회
@items = Item
.in_neighborhood(current_user.active_neighborhood)
.within_radius(params[:radius] || 6) # 기본 6km
.active
.includes(:user, :images, :category)
.page(params[:page])
# 개인화 추천 적용
if params[:personalized]
@items = PersonalizationService.new(current_user).rank_items(@items)
end
render json: @items, each_serializer: ItemSerializer
end
def search
# Elasticsearch를 통한 검색
results = ItemSearchService.new(
query: params[:q],
neighborhood: current_user.active_neighborhood,
filters: search_filters
).search
render json: results
end
private
def verify_neighborhood
unless current_user.verified_neighborhoods.exists?(id: current_user.active_neighborhood_id)
render json: { error: "동네 인증이 필요합니다" }, status: :forbidden
end
end
end
# 위치 기반 서비스
class LocationService
include Redis::Objects
NEIGHBORHOOD_RADIUS = 6.0 # km
def initialize(user)
@user = user
end
# 동네 인증
def verify_neighborhood(lat, lng)
# 좌표로 동네 찾기
neighborhood = Neighborhood.find_by_coordinates(lat, lng)
return false unless neighborhood
# 이미 인증된 동네인지 확인
if @user.verified_neighborhoods.exists?(id: neighborhood.id)
return { status: :already_verified, neighborhood: neighborhood }
end
# GPS 스푸핑 검사
if detect_gps_spoofing?(lat, lng)
return { status: :spoofing_detected }
end
# 인증 처리
verification = @user.neighborhood_verifications.create!(
neighborhood: neighborhood,
verified_at: Time.current,
latitude: lat,
longitude: lng,
verification_method: 'gps'
)
# 동네 활성화
@user.update!(active_neighborhood: neighborhood)
# 캐시 업데이트
update_user_location_cache(lat, lng)
{ status: :verified, neighborhood: neighborhood, verification: verification }
end
# 근처 아이템 조회
def nearby_items(options = {})
radius = options[:radius] || NEIGHBORHOOD_RADIUS
category = options[:category]
# Redis GeoRadius 사용
item_ids = redis.georadius(
location_key,
@user.last_longitude,
@user.last_latitude,
radius,
'km'
)
# 데이터베이스 조회
items = Item.where(id: item_ids).active
items = items.where(category_id: category) if category
# 거리 정보 추가
items.map do |item|
distance = calculate_distance(
@user.last_latitude,
@user.last_longitude,
item.latitude,
item.longitude
)
ItemWithDistance.new(item, distance)
end.sort_by(&:distance)
end
private
def detect_gps_spoofing?(lat, lng)
# 최근 위치 이동 패턴 분석
recent_locations = @user.location_histories.recent(1.hour)
return false if recent_locations.empty?
last_location = recent_locations.last
distance = calculate_distance(
last_location.latitude,
last_location.longitude,
lat,
lng
)
time_diff = Time.current - last_location.created_at
speed = (distance / time_diff.to_f) * 3600 # km/h
# 비현실적인 이동 속도 감지 (200km/h 이상)
speed > 200
end
def location_key
"items:location:#{@user.active_neighborhood_id}"
end
end
# 매칭 엔진
class MatchingEngine
def initialize(item)
@item = item
end
def find_potential_buyers
# 관심 카테고리 등록 사용자
interested_users = User
.joins(:interests)
.where(interests: { category_id: @item.category_id })
.in_neighborhood(@item.neighborhood_id)
# 검색 키워드 매칭
keyword_users = find_users_by_keywords
# 행동 패턴 기반 추천
behavior_users = find_users_by_behavior
# 종합 점수 계산
all_users = (interested_users + keyword_users + behavior_users).uniq
rank_users(all_users)
end
private
def find_users_by_keywords
# 사용자 검색 히스토리와 매칭
keywords = extract_keywords(@item.title, @item.description)
SearchHistory
.where(keyword: keywords)
.where('created_at > ?', 7.days.ago)
.includes(:user)
.map(&:user)
.select { |u| u.in_neighborhood?(@item.neighborhood_id) }
end
def find_users_by_behavior
# 비슷한 아이템 조회/구매 이력
similar_items = @item.similar_items(limit: 10)
UserActivity
.where(
item_id: similar_items.pluck(:id),
activity_type: ['view', 'like', 'inquiry']
)
.includes(:user)
.map(&:user)
.uniq
end
def rank_users(users)
users.map do |user|
score = calculate_user_score(user)
{ user: user, score: score }
end.sort_by { |u| -u[:score] }
end
def calculate_user_score(user)
score = 0.0
# 활동성 점수
score += user.recent_activity_score * 0.3
# 매너온도
score += (user.manner_temperature / 100.0) * 0.3
# 거래 완료율
score += user.transaction_completion_rate * 0.2
# 응답률
score += user.response_rate * 0.2
score
end
end
실시간 채팅 서비스
# ActionCable을 이용한 실시간 채팅
class ChatChannel < ApplicationCable::Channel
def subscribed
@chat_room = ChatRoom.find(params[:room_id])
# 권한 확인
if can_access_chat_room?
stream_for @chat_room
# 온라인 상태 업데이트
update_online_status(true)
# 읽음 처리
mark_messages_as_read
else
reject
end
end
def speak(data)
message = @chat_room.messages.create!(
user: current_user,
content: data['message'],
message_type: data['type'] || 'text'
)
# 상대방에게 전송
ChatChannel.broadcast_to(
@chat_room,
message: MessageSerializer.new(message).as_json,
action: 'new_message'
)
# 푸시 알림
send_push_notification(message)
# 메시지 분석 (비동기)
AnalyzeMessageJob.perform_later(message)
end
def typing(data)
ChatChannel.broadcast_to(
@chat_room,
user_id: current_user.id,
action: 'typing',
is_typing: data['is_typing']
)
end
def unsubscribed
update_online_status(false)
end
private
def can_access_chat_room?
@chat_room.participants.exists?(id: current_user.id)
end
def send_push_notification(message)
recipient = @chat_room.other_participant(current_user)
# 온라인 상태가 아닐 때만 푸시
unless recipient.online?
PushNotificationService.new.send(
user: recipient,
title: current_user.nickname,
body: message.preview_text,
data: {
type: 'chat_message',
room_id: @chat_room.id,
message_id: message.id
}
)
end
end
end
# 메시지 안전성 분석
class MessageAnalyzer
PROHIBITED_PATTERNS = [
/계좌\s*번호/,
/송금\s*해\s*드립니다/,
/선입금/,
/택배\s*착불/
]
def analyze(message)
result = {
is_safe: true,
warnings: [],
auto_block: false
}
# 금지 패턴 검사
PROHIBITED_PATTERNS.each do |pattern|
if message.content.match?(pattern)
result[:warnings] << {
type: 'potential_fraud',
pattern: pattern.source,
severity: 'high'
}
result[:is_safe] = false
end
end
# 외부 링크 검사
if contains_suspicious_link?(message.content)
result[:warnings] << {
type: 'suspicious_link',
severity: 'medium'
}
end
# AI 기반 분석
ai_result = ai_safety_check(message.content)
if ai_result[:risk_score] > 0.8
result[:auto_block] = true
result[:is_safe] = false
end
# 결과에 따른 조치
if result[:auto_block]
message.update!(blocked: true, blocked_reason: result[:warnings])
notify_safety_team(message, result)
elsif !result[:is_safe]
add_warning_to_chat(message.chat_room, result[:warnings])
end
result
end
private
def contains_suspicious_link?(content)
urls = URI.extract(content, ['http', 'https'])
urls.any? do |url|
# 단축 URL 서비스
url.match?(/bit\.ly|tinyurl|goo\.gl/) ||
# 의심스러운 도메인
!url.match?(/daangn\.com|karrot\.com/)
end
end
end
Mobile 기술
iOS 앱 (Swift)
// 당근마켓 iOS 앱
import UIKit
import CoreLocation
import Combine
// 메인 피드 뷰컨트롤러
class FeedViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
private var viewModel = FeedViewModel()
private var cancellables = Set<AnyCancellable>()
private let locationManager = CLLocationManager()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
setupLocationServices()
}
private func setupUI() {
// 컬렉션뷰 설정
collectionView.delegate = self
collectionView.dataSource = self
// 당겨서 새로고침
let refreshControl = UIRefreshControl()
refreshControl.addTarget(
self,
action: #selector(refreshFeed),
for: .valueChanged
)
collectionView.refreshControl = refreshControl
// 플로팅 버튼
setupFloatingButton()
}
private func bindViewModel() {
// 아이템 업데이트
viewModel.$items
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.collectionView.reloadData()
}
.store(in: &cancellables)
// 에러 처리
viewModel.$error
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
self?.showError(error)
}
.store(in: &cancellables)
// 위치 업데이트
viewModel.$currentNeighborhood
.receive(on: DispatchQueue.main)
.sink { [weak self] neighborhood in
self?.updateNavigationTitle(neighborhood)
}
.store(in: &cancellables)
}
@objc private func refreshFeed() {
viewModel.refreshItems()
// 햅틱 피드백
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
impactFeedback.impactOccurred()
// 애니메이션과 함께 종료
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.collectionView.refreshControl?.endRefreshing()
}
}
// 무한 스크롤
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let height = scrollView.frame.size.height
if offsetY > contentHeight - height * 2 {
viewModel.loadMoreItems()
}
}
}
// 위치 기반 서비스
extension FeedViewController: CLLocationManagerDelegate {
private func setupLocationServices() {
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.requestWhenInUseAuthorization()
}
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last else { return }
// 동네 확인
LocationService.shared.verifyNeighborhood(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
) { [weak self] result in
switch result {
case .success(let neighborhood):
self?.viewModel.updateNeighborhood(neighborhood)
case .failure(let error):
self?.handleLocationError(error)
}
}
}
}
// 아이템 상세 화면
class ItemDetailViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var imagePageControl: UIPageControl!
@IBOutlet weak var priceLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var mannerTemperatureView: MannerTemperatureView!
var item: Item!
private let haptics = UINotificationFeedbackGenerator()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
loadItemDetail()
trackView()
}
private func setupUI() {
// 이미지 스와이프
setupImageGallery()
// 가격 포맷팅
priceLabel.text = PriceFormatter.format(item.price)
// 매너온도 표시
mannerTemperatureView.temperature = item.user.mannerTemperature
// 하단 버튼 설정
setupBottomButtons()
}
@IBAction func likeButtonTapped(_ sender: UIButton) {
// 햅틱 피드백
haptics.notificationOccurred(.success)
// 애니메이션
UIView.animate(withDuration: 0.3, animations: {
sender.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}) { _ in
UIView.animate(withDuration: 0.2) {
sender.transform = .identity
}
}
// API 호출
ItemService.shared.toggleLike(itemId: item.id) { [weak self] result in
switch result {
case .success(let isLiked):
self?.updateLikeButton(isLiked: isLiked)
case .failure(let error):
self?.showError(error)
}
}
}
@IBAction func chatButtonTapped(_ sender: UIButton) {
// 채팅방 생성 또는 이동
ChatService.shared.getOrCreateChatRoom(
itemId: item.id,
sellerId: item.user.id
) { [weak self] result in
switch result {
case .success(let chatRoom):
self?.navigateToChatRoom(chatRoom)
case .failure(let error):
self?.showError(error)
}
}
}
}
// 매너온도 커스텀 뷰
@IBDesignable
class MannerTemperatureView: UIView {
@IBInspectable var temperature: Double = 36.5 {
didSet {
updateTemperature()
}
}
private let gradientLayer = CAGradientLayer()
private let temperatureLabel = UILabel()
private let iconImageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
private func setupView() {
// 그라데이션 배경
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.addSublayer(gradientLayer)
// 온도 라벨
temperatureLabel.font = .systemFont(ofSize: 14, weight: .bold)
temperatureLabel.textAlignment = .center
addSubview(temperatureLabel)
// 아이콘
iconImageView.contentMode = .scaleAspectFit
addSubview(iconImageView)
updateTemperature()
}
private func updateTemperature() {
// 색상 결정
let color = temperatureColor(for: temperature)
gradientLayer.colors = [
color.withAlphaComponent(0.3).cgColor,
color.cgColor
]
// 텍스트 업데이트
temperatureLabel.text = "\(temperature)°C"
temperatureLabel.textColor = color
// 아이콘 업데이트
iconImageView.image = temperatureIcon(for: temperature)
iconImageView.tintColor = color
}
private func temperatureColor(for temp: Double) -> UIColor {
switch temp {
case ...30:
return .systemBlue
case 30..<36.5:
return .systemTeal
case 36.5..<40:
return .systemOrange
default:
return .systemRed
}
}
}
Android 앱 (Kotlin)
// 당근마켓 Android 앱
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupBottomNavigation()
observeViewModel()
checkLocationPermission()
}
private fun setupBottomNavigation() {
binding.bottomNavigation.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_home -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, HomeFragment())
.commit()
true
}
R.id.nav_neighborhood -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, NeighborhoodFragment())
.commit()
true
}
R.id.nav_nearby -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, NearbyFragment())
.commit()
true
}
R.id.nav_chat -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, ChatListFragment())
.commit()
true
}
R.id.nav_my_karrot -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, MyKarrotFragment())
.commit()
true
}
else -> false
}
}
}
private fun observeViewModel() {
// 새 메시지 알림
viewModel.unreadMessageCount.observe(this) { count ->
if (count > 0) {
binding.bottomNavigation.getOrCreateBadge(R.id.nav_chat).apply {
number = count
isVisible = true
}
} else {
binding.bottomNavigation.removeBadge(R.id.nav_chat)
}
}
// 동네 변경
viewModel.currentNeighborhood.observe(this) { neighborhood ->
updateToolbarTitle(neighborhood.name)
}
}
}
// 홈 프래그먼트
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by viewModels()
private lateinit var itemAdapter: ItemAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
setupSwipeRefresh()
observeViewModel()
viewModel.loadItems()
}
private fun setupRecyclerView() {
itemAdapter = ItemAdapter { item ->
findNavController().navigate(
HomeFragmentDirections.actionHomeToItemDetail(item.id)
)
}
binding.recyclerView.apply {
adapter = itemAdapter
layoutManager = StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
// 무한 스크롤
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as StaggeredGridLayoutManager
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
val firstVisibleItems = IntArray(2)
layoutManager.findFirstVisibleItemPositions(firstVisibleItems)
val firstVisibleItem = firstVisibleItems.minOrNull() ?: 0
if (!viewModel.isLoading.value &&
visibleItemCount + firstVisibleItem >= totalItemCount - 4) {
viewModel.loadMoreItems()
}
}
})
}
}
private fun observeViewModel() {
viewModel.items.observe(viewLifecycleOwner) { items ->
itemAdapter.submitList(items)
}
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.isVisible = isLoading && viewModel.items.value.isNullOrEmpty()
}
viewModel.error.observe(viewLifecycleOwner) { error ->
error?.let {
Snackbar.make(binding.root, it.message ?: "오류가 발생했습니다", Snackbar.LENGTH_LONG).show()
}
}
}
}
// 채팅 서비스
class ChatService(private val context: Context) {
private val stompClient = StompClient()
private val notificationManager = context.getSystemService(NotificationManager::class.java)
fun connectToChat(roomId: String) {
stompClient.connect(
url = "${BuildConfig.WEBSOCKET_URL}/chat",
headers = mapOf(
"Authorization" to "Bearer ${UserSession.token}",
"Room-Id" to roomId
)
)
// 메시지 구독
stompClient.subscribe("/topic/room/$roomId") { message ->
handleIncomingMessage(ChatMessage.fromJson(message))
}
// 타이핑 상태 구독
stompClient.subscribe("/topic/room/$roomId/typing") { data ->
handleTypingStatus(data)
}
}
fun sendMessage(roomId: String, content: String, type: MessageType = MessageType.TEXT) {
val message = ChatMessage(
roomId = roomId,
senderId = UserSession.userId,
content = content,
type = type,
timestamp = System.currentTimeMillis()
)
stompClient.send(
destination = "/app/chat.send",
data = message.toJson()
)
}
private fun handleIncomingMessage(message: ChatMessage) {
// 로컬 DB에 저장
ChatDatabase.getInstance(context).messageDao().insert(message)
// 알림 표시 (백그라운드인 경우)
if (isAppInBackground()) {
showNotification(message)
}
// 이벤트 발생
EventBus.getDefault().post(NewMessageEvent(message))
}
private fun showNotification(message: ChatMessage) {
val notification = NotificationCompat.Builder(context, CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(message.senderName)
.setContentText(message.content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(createPendingIntent(message))
.build()
notificationManager.notify(message.roomId.hashCode(), notification)
}
}
// 위치 서비스
class LocationService @Inject constructor(
private val context: Context,
private val api: KarrotApi
) {
private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
suspend fun verifyCurrentLocation(): NeighborhoodVerification {
// 위치 권한 확인
if (!hasLocationPermission()) {
throw LocationPermissionException()
}
// 현재 위치 가져오기
val location = getCurrentLocation()
// Mock Location 검사
if (isMockLocation(location)) {
throw MockLocationException()
}
// 서버에 인증 요청
return api.verifyNeighborhood(
VerifyNeighborhoodRequest(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy
)
)
}
@SuppressLint("MissingPermission")
private suspend fun getCurrentLocation(): Location = suspendCancellableCoroutine { cont ->
fusedLocationClient.getCurrentLocation(
LocationRequest.PRIORITY_HIGH_ACCURACY,
CancellationTokenSource().token
).addOnSuccessListener { location ->
cont.resume(location)
}.addOnFailureListener { exception ->
cont.resumeWithException(exception)
}
}
private fun isMockLocation(location: Location): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
location.isMock
} else {
location.isFromMockProvider
}
}
}
AI/ML 시스템
추천 시스템
# 당근마켓 AI 추천 엔진
import tensorflow as tf
import numpy as np
from typing import List, Dict, Tuple
class KarrotRecommendationEngine:
def __init__(self):
self.user_embedding_model = self.build_user_model()
self.item_embedding_model = self.build_item_model()
self.ranking_model = self.build_ranking_model()
def build_user_model(self):
# 사용자 임베딩 모델
user_id = tf.keras.Input(shape=(1,), name='user_id')
neighborhood_id = tf.keras.Input(shape=(1,), name='neighborhood_id')
user_features = tf.keras.Input(shape=(20,), name='user_features')
# 임베딩 레이어
user_embedding = tf.keras.layers.Embedding(
input_dim=1000000,
output_dim=64,
name='user_embedding'
)(user_id)
neighborhood_embedding = tf.keras.layers.Embedding(
input_dim=50000,
output_dim=32,
name='neighborhood_embedding'
)(neighborhood_id)
# 결합
concatenated = tf.keras.layers.Concatenate()([
tf.keras.layers.Flatten()(user_embedding),
tf.keras.layers.Flatten()(neighborhood_embedding),
user_features
])
# DNN
dense1 = tf.keras.layers.Dense(128, activation='relu')(concatenated)
dense2 = tf.keras.layers.Dense(64, activation='relu')(dense1)
user_vector = tf.keras.layers.Dense(32, name='user_vector')(dense2)
return tf.keras.Model(
inputs=[user_id, neighborhood_id, user_features],
outputs=user_vector
)
def recommend_items(self, user_id: int, limit: int = 20) -> List[Dict]:
# 사용자 벡터 생성
user_vector = self.get_user_vector(user_id)
# 후보 아이템 검색
candidate_items = self.get_candidate_items(user_id)
# 점수 계산
scores = []
for item in candidate_items:
item_vector = self.get_item_vector(item)
# 코사인 유사도
similarity = self.cosine_similarity(user_vector, item_vector)
# 추가 요인 고려
distance_factor = self.calculate_distance_factor(user_id, item)
time_factor = self.calculate_time_factor(item)
popularity_factor = self.calculate_popularity_factor(item)
# 최종 점수
score = (
similarity * 0.4 +
distance_factor * 0.3 +
time_factor * 0.2 +
popularity_factor * 0.1
)
scores.append({
'item': item,
'score': score,
'reasons': self.generate_recommendation_reasons(
user_id, item, similarity, distance_factor
)
})
# 상위 N개 반환
return sorted(scores, key=lambda x: x['score'], reverse=True)[:limit]
def calculate_distance_factor(self, user_id: int, item: Dict) -> float:
user_location = self.get_user_location(user_id)
item_location = item['location']
distance = self.haversine_distance(
user_location['lat'], user_location['lng'],
item_location['lat'], item_location['lng']
)
# 거리에 따른 점수 (가까울수록 높음)
if distance < 1: # 1km 이내
return 1.0
elif distance < 3: # 3km 이내
return 0.8
elif distance < 6: # 6km 이내
return 0.5
else:
return 0.1
def train_on_interaction(self, user_id: int, item_id: int, interaction_type: str):
# 상호작용 데이터로 모델 업데이트
interaction_weight = {
'view': 1.0,
'like': 2.0,
'inquiry': 3.0,
'purchase': 5.0
}.get(interaction_type, 1.0)
# 온라인 학습
with tf.GradientTape() as tape:
user_vec = self.user_embedding_model(user_id)
item_vec = self.item_embedding_model(item_id)
# 예측 점수
predicted = tf.reduce_sum(user_vec * item_vec)
# 실제 점수 (상호작용 가중치)
actual = tf.constant(interaction_weight)
# 손실 계산
loss = tf.square(predicted - actual)
# 그래디언트 계산 및 적용
gradients = tape.gradient(loss, self.ranking_model.trainable_variables)
self.optimizer.apply_gradients(
zip(gradients, self.ranking_model.trainable_variables)
)
# 카테고리 자동 분류
class CategoryClassifier:
def __init__(self):
self.tokenizer = BertTokenizer.from_pretrained('klue/bert-base')
self.model = self.load_fine_tuned_model()
self.category_tree = self.load_category_tree()
def classify(self, title: str, description: str, images: List[str]) -> Dict:
# 텍스트 분류
text_category = self.classify_text(title, description)
# 이미지 분류
if images:
image_category = self.classify_images(images)
else:
image_category = None
# 종합 판단
if image_category and text_category != image_category:
# 불일치 시 신뢰도 높은 것 선택
if text_category['confidence'] > image_category['confidence']:
final_category = text_category
else:
final_category = image_category
else:
final_category = text_category
# 하위 카테고리 추론
subcategories = self.infer_subcategories(
final_category['id'],
title,
description
)
return {
'category': final_category,
'subcategories': subcategories,
'keywords': self.extract_keywords(title, description)
}
def classify_text(self, title: str, description: str) -> Dict:
# BERT 토큰화
text = f"{title} [SEP] {description}"
inputs = self.tokenizer(
text,
padding=True,
truncation=True,
max_length=256,
return_tensors='tf'
)
# 예측
outputs = self.model(inputs)
probabilities = tf.nn.softmax(outputs.logits, axis=-1)
# 상위 카테고리
top_category_idx = tf.argmax(probabilities, axis=-1).numpy()[0]
confidence = float(probabilities[0][top_category_idx])
return {
'id': top_category_idx,
'name': self.category_tree[top_category_idx]['name'],
'confidence': confidence
}
기술 스택 요약:
- Backend: Ruby on Rails, Node.js
- Mobile: Swift (iOS), Kotlin (Android)
- 데이터베이스: PostgreSQL, Redis
- 검색: Elasticsearch
- 실시간: ActionCable, WebSocket
- 인프라: AWS, Kubernetes
- ML/AI: TensorFlow, Python
- 모니터링: Datadog, Sentry
💡 핵심 기술 인사이트
1. 하이퍼로컬 기술
- GPS 기반 동네 인증
- 위치 기반 피드 알고리즘
- 지역 커뮤니티 활성화
2. 신뢰 시스템
- 매너온도 평가 체계
- 사기 거래 방지
- 안전한 거래 환경
3. 단순하고 직관적인 UX
- 최소한의 탭으로 거래 완료
- 직관적인 채팅 인터페이스
- 빠른 사진 업로드
📈 성과 & 지표
기술적 성과
- 앱 평점: 4.8+ (iOS/Android)
- 크래시율: < 0.1%
- API 응답시간: P50 < 100ms
- 이미지 업로드: < 2초
- 푸시 도달률: 95%+
비즈니스 영향
- MAU: 2,500만+ (한국 인구 절반)
- 월간 거래: 300만+ 건
- 재거래율: 65%+
- 기업 가치: 3조원+ (유니콘)
🎓 당근마켓에서 배울 점
✅ 적용 가능한 패턴
- 위치 기반 서비스: 정확한 GPS 활용
- 커뮤니티 형성: 지역 기반 네트워크
- 신뢰 메커니즘: 평판 시스템
- 모바일 최적화: 빠른 로딩과 반응
- AI 활용: 카테고리 자동 분류
❌ 주의사항
- 위치 정확도: GPS 스푸핑 대응
- 사기 거래: 지속적인 모니터링
- 커뮤니티 관리: 부적절한 콘텐츠
- 확장성: 지역별 특성 고려
📚 추천 리소스
🔮 미래 전망
현재 집중 분야
- 당근페이: 중고거래 결제
- 부동산 직거래: 중개 플랫폼
- 동네 커머스: 로컬 비즈니스
- 글로벌 확장: 북미, 유럽
- AI 고도화: 추천, 가격 예측
기술 투자 영역
- 머신러닝: 사용자 행동 분석
- 컴퓨터 비전: 상품 자동 인식
- 자연어 처리: 채팅 분석
- 블록체인: 거래 투명성
- AR: 가상 상품 미리보기
"기술로 이웃과 이웃을 연결하여, 따뜻한 동네를 만들어갑니다."
- 당근마켓 기술 조직
마지막 업데이트: 2025-01-28