IT 성장일기

[URL Shortner] URL Shortner 개발하기 (2) 본문

Project/URL Shortner

[URL Shortner] URL Shortner 개발하기 (2)

고 양 2025. 3. 19. 18:18
반응형
URL Shortner 개발하기 (2)

담당하고 있는 서비스에는 간소화된 URL을 사용해 컨텐츠를 공유하는 기능이 있었습니다.

얼마 전 네이버 단축 URL 서비스가 종료됨에 따라 대체재로 사용할 URL 단축 서비스를 사이드 프로젝트로 개발하게 되었습니다.

 

URL Shortner 개발하기는 두 편으로 나뉩니다.

이번 포스팅에서는 URL 단축에 있어 실제적으로 사용된 기술과 예제 코드를 포스팅합니다.

1. 개요

개발 환경

스프링, 자바, 마리아DB를 사용합니다.

로직 자체는 굉장히 단순해서 언어와 플랫폼에 크게 구애받지 않고 응용할 수 있을 것이라고 생각합니다.

URL 단축 방식

원본 URL에 대한 ID를 생성하고, 생성된 ID를 BASE62 문자셋을 이용해 인코딩합니다.

인코딩된 ID를 사용자에게 전달하는 방식으로 URL 단축이 이루어집니다.

BASE62 문자셋

BASE64에서 특수문자 2개를 제외한, 숫자 10자 + 알파벳 소문자 26자 + 알파벳 대문자 26자의 62자로 이루어진 문자셋입니다.

BASE64와 BASE62에 대한 자세한 내용은 별도의 포스팅으로 정리해보겠습니다!

2. 프로세스

프로세스는 이전 글에서 정리했던 내용과 유사합니다.

되새겨 볼 겸 다시 한 번 정리해보겠습니다.

URL 공유 요청

  1. 클라이언트에서 URL 공유를 요청
  2. 컨트롤러에서 URL을 전달받아 서비스를 호출함
  3. 서비스에서 URL을 전달받아 데이터베이스에서 단축 URL을 조회함 ( 여기서 조회 결과에 따라 두가지 경우로 나뉩니다. )
  4. 단축 URL이 존재한다면 해당 행의 조회 횟수를 증가시킴 ( 이 단계는 생략해도 무방합니다. )
  5. 단축 URL이 존재하지 않는다면 새로운 단축 URL을 생성하여 데이터베이스에 저장함
  6. 컨트롤러를 거쳐서 클라이언트에 단축 URL을 전달함

리다이렉트 요청

  1. 클라이언트에서 단축 URL에 의한 페이지 호출 발생
  2. 컨트롤러에서 단축 URL을 전달받아 서비스를 호출함
  3. 서비스에서 단축 URL을 이용해 데이터베이스에서 원본 URL을 조회함
  4. 원본 URL이 존재한다면 해당 행의 조회 횟수를 증가시킴 ( 이 단계는 생략해도 무방합니다. )
  5. 원본 URL이 존재한다면 컨트롤러로 전달 후 페이지 리다이렉트
  6. 원본 URL이 존재하지 않는다면 컨트롤러에서 에러페이지 등으로 리다이렉트

3. 예제 코드

Entity

DTO 역할을 하는 클래스입니다.

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UrlEntity {
    private Long urlId;
    private String encId;
    private String url;
    private String reqCount;
}

Encoder

BASE62 문자셋을 이용해 문자열을 인코딩 / 디코딩하는 클래스입니다.

public class Base62Util {
    private static final String BASE62 = "kLmNO67ABCDyz89EFGHIJKLMNOPQRSTUVWX12abcd34YZefghijklmnopqrstuvwx05";
    private static final int BASE = 62;

    public String encode(long num) {
        StringBuilder encoded = new StringBuilder();

        while (num > 0) {
            int index = (int) (num % BASE);
            encoded.append(BASE62.charAt(index));
            num /= BASE;
        }

        return encoded.toString();
    }

    public long decode(String encoded) {
        long result = 0;
        long power = 1;

        for (int i = 0; i < encoded.length(); i++) {
            int index = BASE62.indexOf(encoded.charAt(i));
            result += index * power;
            power *= BASE;
        }

        return result;
    }
}

Controller

컨트롤러에는 두 개의 메서드를 생성했습니다.

하나는 원본 URL을 전달받아서 단축 URL을 반환합니다.

다른 하나는 단축 URL을 전달 받아서 원본 URL이 가리키는 위치로 페이지를 리다이렉트 합니다.

@Controller
@RequestMapping("/api/u")
public class UrlController {

    @Autowired
    UrlService urlService;

    @PostMapping
    public ResponseEntity<String> getEncodedId(@RequestBody Map<String, String> request) {
        String url = request.get("url");
        if (url == null || url.isEmpty()) {
            return ResponseEntity.badRequest().body("URL is required.");
        }

        return ResponseEntity.ok(urlService.findEncIdByUrl(url));
    }
    
    @GetMapping("/{encId}")
    public RedirectView redirectToUrl(@PathVariable String encId) {
        String url = urlService.findUrlByEncId(encId);
        if (url == null) {
            url = "/error";
        }
        return new RedirectView(url);
    }
}

Service

서비스 클래스는 아래와 같이 구성됩니다.

단축 URL을 반환하는 메서드와 원본 URL을 반환하는 메서드로 구분됩니다.

@Service
@Transactional
public class UrlServiceImpl implements UrlService {

    @Autowired
    UrlMapper urlMapper;
    Base62Util base62Util = new Base62Util();

    public String findEncIdByUrl(String url) {
        try {
            String encId = urlMapper.findEncIdByUrl(url);
            if (encId != null) {
                urlMapper.updateReqCount(url);
                return encId;
            }

            encId = base62Util.encode(UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE);
            UrlEntity urlEntity = UrlEntity.builder()
                    .encId(encId)
                    .url(url)
                    .build();
            urlMapper.saveUrl(urlEntity);

            return encId;
        } catch (Exception e) {
            throw new RuntimeException("Failed to process URL", e);
        }
    }

    public String findUrlByEncId(String encId) {
        try {
            String url = urlMapper.findUrlByEncId(encId);
            if (url != null) {
                urlMapper.updateReqCount(url);
                return url;
            }
            return null;
        } catch (Exception e) {
            throw new RuntimeException("Failed to process URL", e);
        }
    }
}

Mapper

기존 서비스가 JPA가 아닌 데이터 매핑 방식을 사용하기 때문에 Mapper와 MyBatis를 이용해 쿼리를 작성했습니다.

<select id="findEncIdByUrl" resultType="string">
    SELECT enc_id FROM url_table WHERE url = #{url}
</select>

<select id="findUrlByEncId" resultType="string">
    SELECT url FROM url_table WHERE enc_id = #{encId}
</select>

<insert id="saveUrl">
    INSERT INTO url_table (url_id, enc_id, url, req_count)
    VALUES (#{urlId}, #{encId}, #{url}, 1)
        ON DUPLICATE KEY UPDATE req_count = req_count + 1
</insert>

<update id="updateReqCount" parameterType="string">
    UPDATE url_table
    SET req_count = req_count + 1
    WHERE url = #{url}
</update>

Table

데이터베이스 테이블 DDL입니다.

CREATE TABLE url_table (
    url_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    enc_id VARCHAR(255) NOT NULL,
    url VARCHAR(255) NOT NULL UNIQUE,
    req_count BIGINT
);

요청 결과

AJAX 요청으로 URL을 전송하고 단축된 값을 리턴받았습니다.

let url = '/manage/newpost/154?type=post&returnURL=ENTRY'

$.ajax({
    url : `/api/u`,
    type : "POST",
    contentType: 'application/json; charset=UTF-8',
    data : JSON.stringify({ url: url }),
    dataType: 'text',
});

4. 유의할 부분

아래는 기능을 개발하면서 예상할 수 있었던 이슈와 해결 방법을 간략히 정리한 내용입니다.

인코딩 방식

인코딩 방식으로 BASE62 문자셋을 사용했습니다.

처음엔 URL 자체를 인코딩 / 디코딩 할 의도로 BASE62 문자셋을 고려했습니다.

 

하지만 개발 과정에서 URL마다 인코딩 된 ID를 생성해 그 ID를 URL로 사용하도록 해서 사실 문자셋은 크게 상관이 없어졌네요...

(BASE64 문자셋엔 2개의 특수기호('/', '+')와 패딩문자('=')가 포함되는데, 이 기호가 URL에 포함되면 잘못된 파싱 결과를 발생시킬 수 있습니다.)

 

그래도 인코딩과 URL에 관해 많은 공부를 할 기회가 되었습니다.

트랜잭션 관리

Short URL 반환 시 존재 여부 판단, 원본 URL 저장, 요청 횟수 증가가 각각 개별적으로 실행됩니다.

이 때 하나라도 데이터 처리에 실패하면 트랜잭션이 성립하지 않고 원자성을 보장하지 못할 위험이 있습니다.

@Transactional 애너테이션을 적용하여 롤백을 보장하도록 했습니다.

동시성 이슈

Short URL 중복 가능성

URL마다 별도의 ID를 생성하고 이를 BASE62 문자셋으로 인코딩하는 방식으로 개발 방향이 바뀌었습니다.

 

처음엔 밀리초 단위로 현재 시간 값을 반환해주는 currentTimeMillis() 메서드를 사용했습니다.

하지만 중복 가능성이 높아 서로 다른 URL이 동일한 ID를 가질 수 있는 경우를 고려해야 했죠.

 

대비책으로 중복 가능성이 훨씬 적은 UUID를 사용하도록 했습니다. UUID를 기반으로 랜덤한 long 타입 양수를 생성합니다.

레코드 중복 가능성

밀리초 단위로 요청이 겹칠 확률은 매우 낮겠지만, 그럼에도 만에 하나를 대비해야 합니다.

 

동일한 URL에 동시에 여러 요청이 발생하면 URL 존재 여부 판단 이후 저장까지의 시간 동안 레코드가 중복될 가능성이 있습니다.

그렇기 때문에 데이터베이스에 레코드를 저장할 때는 Merge(Upsert) 방식을 사용했고, URL 컬럼에 유일키 제약조건을 걸어줬습니다.

마치며

프로젝트 자체의 난이도는 높지 않았지만 의외로 고려해야 할 것들이 굉장히 많았습니다.

URL의 구조, 인코딩 방식 등 기초적인 CS 지식도 크게 늘면서 배운 것들이 정말 많다고 느껴집니다.

 

서비스 운영 환경에 사용될지 아닐지는 모르겠지만, 사용되지 않는다 하더라도 개인적으로 보람을 많이 느낀 프로젝트가 되겠습니다.

 

감사합니다!

반응형

'Project > URL Shortner' 카테고리의 다른 글

[URL Shortner] URL Shortner 개발하기 (1)  (0) 2025.03.19